C 언어에서 링크드 리스트로 스택을 구현하는 방법

C 언어에서 스택은 데이터 구조의 기본을 이해하는 데 중요한 역할을 합니다. 특히, 링크드 리스트를 활용해 스택을 구현하면 메모리 효율성과 유연성을 극대화할 수 있습니다. 본 기사에서는 링크드 리스트로 스택을 구현하는 과정을 살펴보고, 이를 통해 얻을 수 있는 장점을 분석합니다. 이를 통해 C 언어의 동적 메모리 관리 및 자료 구조 설계에 대한 깊은 이해를 돕고자 합니다.

목차

스택의 개념과 작동 원리


스택(Stack)은 데이터를 일시적으로 저장하고, 삽입과 삭제가 한쪽 끝에서만 이루어지는 선형 자료 구조입니다. 스택의 작동 원리는 LIFO(Last In, First Out), 즉 가장 마지막에 삽입된 데이터가 가장 먼저 삭제된다는 점에 기반합니다.

스택의 주요 연산

  • Push: 데이터를 스택에 삽입하는 연산입니다.
  • Pop: 스택의 최상단 데이터를 제거하고 반환하는 연산입니다.
  • Peek/Top: 스택의 최상단 데이터를 제거하지 않고 반환하는 연산입니다.
  • IsEmpty: 스택이 비어 있는지 확인하는 연산입니다.

스택의 활용 예시

  • 함수 호출 관리: 함수가 호출될 때마다 호출 정보를 스택에 저장하고, 함수가 종료되면 해당 정보를 스택에서 제거합니다.
  • 표현식 평가: 수식의 후위 표기법(Postfix Notation) 계산이나 괄호 유효성 검사에 사용됩니다.
  • 데이터 되돌리기: 텍스트 편집기의 Undo 기능, 웹 브라우저의 뒤로 가기 기능 등에서 사용됩니다.

스택은 구현 방식에 따라 배열(Array)을 사용할 수도 있고, 링크드 리스트(Linked List)를 사용할 수도 있습니다. 본 기사에서는 링크드 리스트를 활용해 스택을 구현하는 방법을 다룹니다.

링크드 리스트의 기초


링크드 리스트(Linked List)는 동적으로 메모리를 관리하며, 데이터를 노드(Node) 단위로 저장하는 선형 자료 구조입니다. 각 노드는 데이터 필드와 다음 노드를 가리키는 포인터 필드로 구성됩니다.

링크드 리스트의 구조

  • 노드(Node): 데이터와 다음 노드의 주소를 저장하는 구조체입니다.
  • 헤드 포인터(Head Pointer): 링크드 리스트의 첫 번째 노드를 가리키는 포인터입니다.
  • NULL: 리스트의 끝을 표시하기 위해 사용되는 특별한 값입니다.
// 노드 구조체의 예
struct Node {
    int data;           // 데이터 필드
    struct Node* next;  // 다음 노드를 가리키는 포인터
};

링크드 리스트의 장점

  • 동적 크기 조정: 배열과 달리 초기 크기를 고정할 필요가 없어 메모리 사용이 효율적입니다.
  • 빠른 삽입 및 삭제: 리스트 중간에 삽입하거나 삭제할 때 전체를 이동할 필요가 없습니다.

링크드 리스트의 종류

  • 단일 링크드 리스트: 각 노드가 다음 노드만 가리킵니다.
  • 이중 링크드 리스트: 각 노드가 이전 노드와 다음 노드를 모두 가리킵니다.
  • 환형 링크드 리스트: 리스트의 마지막 노드가 처음 노드를 가리켜 원형 구조를 만듭니다.

링크드 리스트는 메모리 관리의 유연성과 동적 데이터 처리를 제공하기 때문에, 스택과 같은 자료 구조를 구현하는 데 적합합니다. 다음 섹션에서는 링크드 리스트를 사용한 스택 구현의 장점을 알아봅니다.

링크드 리스트를 사용한 스택 구현의 장점

링크드 리스트를 사용해 스택을 구현하면 배열 기반 스택에 비해 다음과 같은 여러 가지 장점을 제공합니다.

배열 기반 스택과의 비교

  1. 동적 크기 조정
  • 배열 기반 스택: 크기가 고정되어 있어 초과 삽입 시 재할당이 필요합니다.
  • 링크드 리스트 스택: 노드를 동적으로 생성하므로 크기 제한이 없습니다.
  1. 메모리 효율성
  • 배열은 미리 정한 크기보다 작게 사용하면 메모리 낭비가 발생합니다.
  • 링크드 리스트는 필요할 때마다 노드를 생성하므로 메모리를 효율적으로 사용합니다.
  1. 삽입 및 삭제의 간편함
  • 배열 기반 스택은 삽입과 삭제 시 크기 변경 작업이 필요할 수 있습니다.
  • 링크드 리스트는 간단한 포인터 조작만으로 삽입 및 삭제가 가능합니다.

실제 사용에서의 장점

  • 크기 예측이 어려운 데이터 처리: 데이터 크기를 예측할 수 없는 상황에서 적합합니다.
  • 메모리 중단 문제 해결: 배열의 경우 연속된 메모리 블록이 필요하지만, 링크드 리스트는 분산된 메모리 블록을 활용합니다.
  • 유연한 자료 구조 설계: 스택의 기능 확장이 용이하며, 다른 자료 구조와 쉽게 통합할 수 있습니다.

한계점

  • 링크드 리스트는 배열에 비해 노드마다 추가 메모리(포인터)가 필요합니다.
  • 포인터 조작이 잘못될 경우 프로그램 오류가 발생할 가능성이 있습니다.

이러한 특성으로 인해 링크드 리스트는 동적이고 유연한 스택 구현을 가능하게 합니다. 다음 섹션에서는 링크드 리스트를 사용한 스택의 실제 구현 과정을 설명합니다.

링크드 리스트로 스택 구현하기

링크드 리스트를 사용해 스택을 구현하려면 스택의 기본 연산인 Push(삽입), Pop(삭제), Peek(조회), IsEmpty(비어 있는지 확인)를 처리하는 코드를 작성해야 합니다. 아래는 C 언어로 링크드 리스트 기반 스택을 구현하는 단계입니다.

1. 노드 구조 정의


먼저, 스택에서 사용할 노드 구조체를 정의합니다.

#include <stdio.h>
#include <stdlib.h>

// 노드 구조체 정의
struct Node {
    int data;              // 데이터 필드
    struct Node* next;     // 다음 노드를 가리키는 포인터
};

2. Push 연산 구현


새로운 데이터를 스택에 삽입하는 함수입니다.

void push(struct Node** top, int value) {
    struct Node* newNode = (struct Node*)malloc(sizeof(struct Node));
    if (newNode == NULL) {
        printf("메모리 할당 실패\n");
        return;
    }
    newNode->data = value;
    newNode->next = *top;  // 새 노드가 현재 top을 가리키게 함
    *top = newNode;        // 새 노드가 스택의 새로운 top이 됨
    printf("%d을(를) 스택에 삽입했습니다.\n", value);
}

3. Pop 연산 구현


스택의 최상단 데이터를 제거하고 반환하는 함수입니다.

int pop(struct Node** top) {
    if (*top == NULL) {
        printf("스택이 비어 있습니다.\n");
        return -1; // 오류 값
    }
    struct Node* temp = *top;
    int poppedValue = temp->data;
    *top = temp->next;     // top을 다음 노드로 변경
    free(temp);            // 메모리 해제
    printf("%d을(를) 스택에서 제거했습니다.\n", poppedValue);
    return poppedValue;
}

4. Peek 연산 구현


스택의 최상단 데이터를 확인하는 함수입니다.

int peek(struct Node* top) {
    if (top == NULL) {
        printf("스택이 비어 있습니다.\n");
        return -1; // 오류 값
    }
    return top->data;
}

5. IsEmpty 연산 구현


스택이 비어 있는지 확인하는 함수입니다.

int isEmpty(struct Node* top) {
    return top == NULL;
}

6. 테스트 및 실행


구현한 스택을 테스트합니다.

int main() {
    struct Node* stack = NULL;  // 스택 초기화

    push(&stack, 10);
    push(&stack, 20);
    push(&stack, 30);

    printf("스택 최상단: %d\n", peek(stack));

    pop(&stack);
    printf("스택 최상단: %d\n", peek(stack));

    printf("스택이 비었는가? %s\n", isEmpty(stack) ? "예" : "아니오");

    return 0;
}

결과 출력 예시

10을(를) 스택에 삽입했습니다.
20을(를) 스택에 삽입했습니다.
30을(를) 스택에 삽입했습니다.
스택 최상단: 30
30을(를) 스택에서 제거했습니다.
스택 최상단: 20
스택이 비었는가? 아니오

이 코드를 통해 링크드 리스트를 기반으로 동적 스택을 구현할 수 있습니다. 다음 섹션에서는 주요 함수의 상세 설계와 작동 원리를 설명합니다.

주요 함수 설계 및 코드

링크드 리스트를 기반으로 한 스택 구현에서 중요한 함수들은 스택의 기본 연산인 Push, Pop, Peek, 그리고 IsEmpty입니다. 각 함수의 설계와 작동 방식을 자세히 설명합니다.

1. Push 함수


Push 연산은 새로운 데이터를 스택에 삽입하는 작업을 수행합니다.

void push(struct Node** top, int value) {
    // 새 노드 생성
    struct Node* newNode = (struct Node*)malloc(sizeof(struct Node));
    if (newNode == NULL) {
        printf("메모리 할당 실패\n");
        return;
    }
    newNode->data = value; // 데이터 저장
    newNode->next = *top;  // 현재 top을 새 노드의 next로 설정
    *top = newNode;        // 새 노드를 스택의 top으로 설정
    printf("%d을(를) 스택에 삽입했습니다.\n", value);
}

작동 원리

  1. 새로운 노드 생성 및 메모리 할당.
  2. 새 노드의 next 필드가 현재 top을 가리키도록 설정.
  3. top을 새 노드로 업데이트.

2. Pop 함수


Pop 연산은 스택의 최상단 노드를 제거하고 데이터를 반환합니다.

int pop(struct Node** top) {
    if (*top == NULL) {
        printf("스택이 비어 있습니다.\n");
        return -1; // 오류 값
    }
    struct Node* temp = *top; // 제거할 노드 임시 저장
    int poppedValue = temp->data; // 최상단 데이터 저장
    *top = temp->next; // top을 다음 노드로 변경
    free(temp); // 제거한 노드의 메모리 해제
    printf("%d을(를) 스택에서 제거했습니다.\n", poppedValue);
    return poppedValue; // 제거된 데이터 반환
}

작동 원리

  1. 현재 top 노드 임시 저장.
  2. top을 다음 노드로 이동.
  3. 메모리 해제 후 데이터를 반환.

3. Peek 함수


Peek 연산은 스택의 최상단 데이터를 확인하지만 제거하지 않습니다.

int peek(struct Node* top) {
    if (top == NULL) {
        printf("스택이 비어 있습니다.\n");
        return -1; // 오류 값
    }
    return top->data; // 최상단 데이터 반환
}

작동 원리

  1. top이 NULL인지 확인해 스택이 비었는지 점검.
  2. top 노드의 데이터를 반환.

4. IsEmpty 함수


IsEmpty 연산은 스택이 비어 있는지를 확인합니다.

int isEmpty(struct Node* top) {
    return top == NULL;
}

작동 원리

  • top 포인터가 NULL인지 확인해 스택의 비어 있는 상태를 판별합니다.

5. 테스트 케이스


이 함수들의 작동을 확인하기 위한 샘플 코드입니다.

int main() {
    struct Node* stack = NULL; // 스택 초기화

    push(&stack, 10);
    push(&stack, 20);
    push(&stack, 30);

    printf("스택 최상단: %d\n", peek(stack));

    pop(&stack);
    printf("스택 최상단: %d\n", peek(stack));

    printf("스택이 비었는가? %s\n", isEmpty(stack) ? "예" : "아니오");

    return 0;
}

결과 출력 예시

10을(를) 스택에 삽입했습니다.
20을(를) 스택에 삽입했습니다.
30을(를) 스택에 삽입했습니다.
스택 최상단: 30
30을(를) 스택에서 제거했습니다.
스택 최상단: 20
스택이 비었는가? 아니오

위의 함수들을 조합하면 링크드 리스트 기반 스택을 효율적으로 구현하고 활용할 수 있습니다. 다음 섹션에서는 동적 메모리 관리의 중요성을 다룹니다.

동적 메모리 관리의 중요성

링크드 리스트를 활용한 스택 구현에서 동적 메모리 관리는 핵심적인 요소입니다. 메모리 할당과 해제가 적절히 이루어지지 않으면 메모리 누수(Memory Leak)와 같은 심각한 문제가 발생할 수 있습니다.

1. 메모리 할당과 해제


동적 메모리 관리는 mallocfree 함수를 사용해 이루어집니다.

  • malloc: 새 노드를 생성하기 위해 메모리를 동적으로 할당합니다.
  • free: 사용이 끝난 노드를 제거하여 메모리를 반환합니다.

예를 들어, Push 연산에서는 새 노드에 대해 malloc을 호출하고, Pop 연산에서는 제거된 노드에 대해 free를 호출합니다.

2. 메모리 누수 방지


메모리 누수는 동적으로 할당된 메모리가 적절히 해제되지 않을 때 발생합니다. 이를 방지하기 위해 다음과 같은 점을 유의해야 합니다:

  • Pop 연산을 통해 제거된 노드의 메모리를 반드시 해제해야 합니다.
  • 프로그램 종료 시 모든 노드를 순회하며 메모리를 해제합니다.

전체 스택 메모리 해제 코드

void freeStack(struct Node** top) {
    struct Node* current = *top;
    struct Node* nextNode;
    while (current != NULL) {
        nextNode = current->next;
        free(current);  // 현재 노드 메모리 해제
        current = nextNode;  // 다음 노드로 이동
    }
    *top = NULL;  // 스택을 완전히 비움
    printf("스택 메모리가 모두 해제되었습니다.\n");
}

3. 메모리 오류 디버깅


메모리 관리 실수를 방지하기 위해 디버깅 도구를 활용할 수 있습니다.

  • Valgrind: 메모리 누수를 감지하고 잘못된 메모리 접근을 확인하는 데 유용합니다.
  • GDB: 프로그램 실행 중의 메모리 상태를 분석할 수 있습니다.

4. 실수 방지 팁

  • Push와 Pop 연산에서 메모리 할당 및 해제가 일관되게 이루어지는지 확인합니다.
  • IsEmpty 함수로 스택이 비어 있는 상태인지 항상 점검합니다.
  • 프로그램 종료 전에 반드시 freeStack을 호출하여 모든 메모리를 해제합니다.

5. 메모리 관리의 중요성

  • 시스템 안정성: 메모리 누수는 장시간 실행되는 프로그램의 성능 저하와 충돌을 초래할 수 있습니다.
  • 리소스 효율성: 불필요한 메모리 점유를 방지하여 시스템 리소스를 효율적으로 사용합니다.
  • 디버깅 용이성: 적절한 메모리 관리로 오류 발생 가능성을 줄이고, 디버깅 시간을 단축시킵니다.

메모리 관리를 올바르게 수행하면 안정적이고 효율적인 링크드 리스트 기반 스택을 구현할 수 있습니다. 다음 섹션에서는 에러 처리 및 디버깅 방법을 다룹니다.

에러 처리 및 디버깅

링크드 리스트로 스택을 구현할 때 다양한 에러 상황이 발생할 수 있습니다. 이러한 문제를 효과적으로 처리하고 디버깅하기 위한 방법을 살펴보겠습니다.

1. 에러 처리


스택 연산 중 발생할 수 있는 주요 에러와 이를 처리하는 방법은 다음과 같습니다.

1.1 메모리 할당 실패


동적 메모리 할당(malloc)이 실패하면 프로그램이 비정상적으로 종료될 수 있습니다. 이를 방지하기 위해 에러를 감지하고 적절히 처리해야 합니다.

void push(struct Node** top, int value) {
    struct Node* newNode = (struct Node*)malloc(sizeof(struct Node));
    if (newNode == NULL) {
        printf("메모리 할당 실패: 스택에 삽입할 수 없습니다.\n");
        return;
    }
    newNode->data = value;
    newNode->next = *top;
    *top = newNode;
}

1.2 비어 있는 스택에서의 Pop/Peek


스택이 비어 있을 때 Pop이나 Peek 연산을 시도하면 오류가 발생합니다. 이를 방지하기 위해 사전에 스택의 상태를 확인합니다.

int pop(struct Node** top) {
    if (*top == NULL) {
        printf("스택 언더플로우: 스택이 비어 있습니다.\n");
        return -1; // 오류 값 반환
    }
    struct Node* temp = *top;
    int poppedValue = temp->data;
    *top = temp->next;
    free(temp);
    return poppedValue;
}

1.3 포인터 오류


잘못된 포인터 참조로 인해 프로그램이 충돌할 수 있습니다. 예를 들어, 해제된 노드를 참조하려는 경우 이러한 문제가 발생합니다. 모든 포인터를 사용 전에 초기화하고, 해제 후 NULL로 설정해야 합니다.

2. 디버깅 기법

2.1 로그 출력


연산 수행 시 상태를 출력하여 프로그램의 흐름을 파악할 수 있습니다.

printf("현재 스택 최상단: %d\n", peek(stack));

2.2 디버깅 도구

  • Valgrind: 메모리 누수 및 잘못된 메모리 접근을 감지합니다.
  • GDB: 프로그램을 단계별로 실행하여 포인터나 메모리 상태를 점검할 수 있습니다.

2.3 유닛 테스트


각 연산에 대해 다양한 입력값을 테스트하여 코드가 의도한 대로 작동하는지 확인합니다.

void testStack() {
    struct Node* stack = NULL;
    printf("스택 테스트 시작...\n");

    push(&stack, 10);
    push(&stack, 20);
    printf("Peek: %d\n", peek(stack)); // 예상 출력: 20

    pop(&stack);
    printf("Peek: %d\n", peek(stack)); // 예상 출력: 10

    pop(&stack);
    pop(&stack); // 스택 언더플로우 테스트
    printf("스택 테스트 완료.\n");
}

3. 예외 처리 개선


스택 연산에서 발생하는 모든 에러를 사용자에게 명확히 전달하고 프로그램을 안전하게 종료할 수 있는 예외 처리 구조를 설계해야 합니다.

개선된 Pop 함수

int safePop(struct Node** top) {
    if (*top == NULL) {
        fprintf(stderr, "에러: 스택이 비어 있어 Pop 연산을 수행할 수 없습니다.\n");
        exit(EXIT_FAILURE);
    }
    return pop(top);
}

4. 에러 시나리오 시뮬레이션


테스트 시 다양한 에러 상황을 시뮬레이션하여 에러 처리 로직이 제대로 작동하는지 확인합니다.

  • 메모리 부족 상황 테스트: 제한된 메모리 환경에서 Push 연산 시도.
  • 스택 언더플로우 테스트: 연속적으로 Pop 연산 호출.

5. 디버깅 예시

valgrind --leak-check=full ./stack_program

이 명령은 메모리 누수를 탐지하며, 각 메모리 오류의 원인을 상세히 알려줍니다.

결론


효율적인 에러 처리와 디버깅은 안정적인 링크드 리스트 기반 스택 구현의 필수 요소입니다. 다음 섹션에서는 응용 예시와 연습 문제를 통해 이 내용을 심화 학습합니다.

응용 예시와 연습 문제

링크드 리스트 기반 스택은 다양한 실제 응용 사례에서 활용될 수 있습니다. 이를 통해 구현된 스택의 유용성을 체감할 수 있으며, 연습 문제를 통해 실습을 통해 이해를 심화할 수 있습니다.

1. 응용 예시

1.1 괄호 유효성 검사


스택을 사용하여 괄호의 짝을 확인하는 프로그램을 작성할 수 있습니다.

#include <stdbool.h>

bool isBalanced(const char* expr) {
    struct Node* stack = NULL;

    for (int i = 0; expr[i] != '\0'; i++) {
        char ch = expr[i];

        if (ch == '(' || ch == '{' || ch == '[') {
            push(&stack, ch);
        } else if (ch == ')' || ch == '}' || ch == ']') {
            if (isEmpty(stack)) return false;
            char top = pop(&stack);

            if ((ch == ')' && top != '(') ||
                (ch == '}' && top != '{') ||
                (ch == ']' && top != '[')) {
                return false;
            }
        }
    }

    return isEmpty(stack);
}

int main() {
    const char* expr = "{[()]}";
    printf("괄호 유효성 검사: %s\n", isBalanced(expr) ? "유효" : "무효");
    return 0;
}

1.2 수식 후위 표기법 변환


스택을 사용하여 중위 표기법을 후위 표기법으로 변환하는 알고리즘을 구현할 수 있습니다.

void infixToPostfix(const char* expr) {
    struct Node* stack = NULL;

    for (int i = 0; expr[i] != '\0'; i++) {
        char ch = expr[i];

        if (isalnum(ch)) {
            printf("%c", ch); // 피연산자 출력
        } else if (ch == '(') {
            push(&stack, ch);
        } else if (ch == ')') {
            while (!isEmpty(stack) && peek(stack) != '(') {
                printf("%c", pop(&stack)); // 연산자 출력
            }
            pop(&stack); // '(' 제거
        } else {
            while (!isEmpty(stack) && precedence(peek(stack)) >= precedence(ch)) {
                printf("%c", pop(&stack)); // 연산자 출력
            }
            push(&stack, ch);
        }
    }

    while (!isEmpty(stack)) {
        printf("%c", pop(&stack)); // 남은 연산자 출력
    }
}

1.3 웹 브라우저의 뒤로 가기


스택을 사용하여 브라우저의 “뒤로 가기”와 “앞으로 가기” 기능을 구현할 수 있습니다.

2. 연습 문제

2.1 문제 1: 스택 복제


스택을 복제하는 함수 copyStack을 작성하세요. 복제된 스택은 원본 스택의 순서와 동일해야 하며, 원본 스택은 변경되지 않아야 합니다.

2.2 문제 2: 최소값 추적


스택에서 현재 스택의 최소값을 O(1) 시간 복잡도로 반환하는 getMin 함수를 설계하고 구현하세요.

2.3 문제 3: 두 스택을 사용한 큐 구현


스택 두 개를 사용하여 큐를 구현하세요. 큐의 기본 연산인 Enqueue와 Dequeue를 스택 연산만으로 수행해야 합니다.

2.4 문제 4: 문자열 뒤집기


스택을 사용하여 입력된 문자열을 뒤집는 프로그램을 작성하세요.

3. 연습 문제 풀이


위 문제에 대한 풀이 코드는 스스로 작성해보는 것을 추천합니다. 문제를 풀면서 링크드 리스트 기반 스택의 작동 방식을 더 깊이 이해할 수 있습니다.

응용과 연습을 통해 링크드 리스트 기반 스택의 실질적인 사용 사례를 경험하며, 구현 능력을 향상시킬 수 있습니다. 다음 섹션에서는 본 기사의 내용을 간단히 요약합니다.

요약

본 기사에서는 C 언어를 사용하여 링크드 리스트로 스택을 구현하는 방법을 다뤘습니다. 스택의 기본 개념과 링크드 리스트의 구조를 이해한 뒤, 주요 연산인 Push, Pop, Peek, IsEmpty 함수를 구현하고, 메모리 관리와 에러 처리의 중요성을 강조했습니다. 또한, 괄호 유효성 검사, 후위 표기법 변환 등 스택의 실질적인 활용 사례를 살펴보았으며, 다양한 연습 문제를 통해 구현 능력을 심화할 수 있는 기회를 제공했습니다. 링크드 리스트 기반 스택은 동적 메모리 관리와 자료 구조 설계 능력을 향상시키는 데 유용한 학습 도구입니다.

목차