[자료구조] 연결 리스트(Linked List)

 

연결 리스트

정의

  • 포인터를 이용하여 리스트를 구현한 자료 구조로 각 요소가 다음 요소의 위치를 포인터로 가리킨다. → 즉, 메모리 상에서 연속되지 않지만 순서를 유지할 수 있다.
  • 배열과 차이점: 크기 제한이 없다 → 유동적으로 조절 가능
  • 집합과의 차이점: 연결 리스트는 요소의 순서를 유지하며, 중복된 값도 허용한다.
  • 그래프와의 차이점: 갈림길 없이 일렬로 나열되어 처음과 끝이 각각 하나씩 존재한다.
  • 즉, 동적 메모리에 할당된, 링크에 의해 연결된 유한 개수의 데이터 원소 노드들이다.

연결 리스트의 장단점

  • 장점
    • 데이터의 추가와 삭제가 용이하다.(원소의 이동이 필요 없다.)
    • 데이터의 삽입과 삭제가 배열보다 빠르다.
    • 데이터의 중복이 없다.
    • 메모리 할당이 동적이다.(메모리 효율성 좋음)
    • 크기 제한이 없다.
    • 추상적 자료구조 구현이 가능하다.(스택, 트리, 큐 등), 확장성이 좋다.
  • 단점
    • 데이터의 탐색이 느리다.(찾을 때까지 순회해야되기 때문)
    • 메모리 공간이 많이 필요하다.
    • 데이터의 순서가 중요하다.

연결 리스트의 구조

  • 연결 리스트 명($L$): 연결 리스트의 시작 위치, 즉 첫 노드의 주소
  • 연결 리스트 크기($n$): 연결 리스트 내 노드 수

연결 리스트의 종류

  • 단일 연결 리스트
  • 원형 연결 리스트
  • 이중 연결 리스트
  • 헤더 및 트레일러 연결 리스트
  • 이들의 복합

노드에 대한 메모리 할당

  • 노드는 한 개의 데이터 원소를 저장하기 위해 동적 메모리에 할당된 메모리로, 주로 실제 데이터 값다음 원소의 위치를 가리키는 포인터 가지고 있다.
  • getnode()
    • 새로운 노드를 메모리에 생성하는 과정을 나타내는 것으로 노드를 할당하고 그 노드의 주소를 반환한다.(동적 메모리가 고갈된 시점이면 Null 포인터를 반환)
  • putnode()
    • 연결 리스트에서 노드를 삭제하거나, 사용을 마친 경우에는 해당 노드를 메모리에서 해제해줘야 하는데 그 때 사용한다.
    • 주소 i의 노드에 할당되었던 메모리의 사용을 해제하고, 이를 메모리 재활용을 위해 동적 메모리에 반환한다.
  • 순차 연결 리스트( ==순차 리스트)
    • 배열로 구현한 리스트로서 구현이 쉽고 속도가 빠르다.
    • 하지만 배열의 특성 상 동적으로 크기를 줄이기 어렵다는 단점이 있다.
    • 배열로 구성되니, 배열의 특성을 다 가져간다.
    • 삽입이나 삭제 연산 시, 원소를 직접 옮기는 과정을 거친다. 즉, 삽입 삭제 연산이 많다면, 시간이 많이 걸린다.

단일 연결 리스트

  • 각 노드가 데이터와 다음 노드 정보를 가지고 한 줄로 연결되어 있는 방식으로 데이터를 저장하는 자료 구조

image

  • 저장 내용
    • 원소: 데이터
    • 원소 링크: 다음 노드의 주소(다음 노드가 없는 경우 Null링크 저장)
  • 구성 요소
    • Head: 연결 리스트의 시작 노드
    • Tail: 연결 리스트의 마지막 노드
    • Length: 전체 길이 추적
  • 노드의 구성

      typedef struct Node{
          int data; // 가지고 있는 데이터
          struct Node* next; // 다음 노드 정보
      	} node;
    
  • 구현 내용
    • 더미 머리 / 더미 꼬리 / 리스트 크기 선언
    • 리스트 초기화
    • 특정 노드의 앞 혹은 뒤에 노드 추가하여 삽입하기
    • 특정 노드 삭제
    • 특정 노드 찾기
    • 모든 노드 제거/출력

원형 연결 리스트

  • 리스트의 마지막 노드의 링크가 첫 번째 노드를 가리키는 연결 리스트

image

  • 장점
    • 한 노드에서 다른 모든 노드로의 접근이 가능하다. 즉, 노드의 삽입, 삭제가 단순한다.
    • 리스트의 끝에 노드를 추가하는 것이 단순 연결 리스트보다 쉽다.
    • 큐(원형 큐) 구현이 쉽다.
  • 단점
    • 노드의 삽입, 삭제 시 선행 노드의 포인터가 필요함
    • 디버깅이 어려울 수 있다.(무한 루프 가능성)

이중 연결 리스트

  • 단방향 흐름을 2개의 링크 필드를 이용하여 양방향으로 움직일 수 있도록 만든 연결 리스트

    image

  • 장점
    • 양방향으로 움직일 수 있어 데이터 접근이 단순하다.
    • 원형 연결 리스트 보다 쉽고 삽입, 삭제 연산 시 선행 노드에 대한 정보가 필요 없다.
    • 역순 탐색(tail부터)도 가능
  • 단점
    • 변수 추가에 따라 메모리를 더 많이 사용하고, 구현이 복잡하다.
  • 헤더(Header node) & 트레일러(Trailer node)
    • 작업의 편의를 위해 추가하는 특별 노드(모조 노드 혹은 더미 노드)
    • 아무 원소(실제 데이터 원소)도 저장하지 않는다.
    • 헤드노드와 헤더노드, 테일 노드와 트레일러 노드는 각각 다른 것이다.
    • 헤드노드와 테일 노드는 각 값들이 원소를 저장하고 있다. 하지만 헤더와 트레일러는 원소들을 저장하고 있지않다.
    • 즉, 헤드 노드는 값을 가진 것들 중 맨 앞에 있는 것이고 이 헤드를 가리키는 노드가 헤더 노드이고, 테일 노드는 값을 가진 것들 중 맨 뒤에 있는 노드이고, 이 테일 노드가 가리키는 것이 트레일러 노드이다.

    image

    • 헤더 노드: 헤드 노드 바로 앞에 추가하여 사용
    • 트레일러 노드: 테일 노드 바로 뒤에 추가하여 사용

배열, 단일 연결 리스트, 이중 연결 리스트 성능 비교

image


응용 문제

  1. 이중 연결 리스트의 삽입, 삭제, 순회 등 구현

     #include<stdio.h>
     #include<string.h>
     #include<stdlib.h>
     #pragma warning(disable:4996)
        
     typedef struct node {
         char elem;
         struct node* prev;
         struct node* next;
     } node;
        
     void add(node* h, int r, char e) {
         node* new = (node*)malloc(sizeof(node));
         node* cur = h;
        
         new->elem = e;
        
         if (r < 1) {
             printf("invalid position\n");
             return;
         }
        
         for (int i = 0; i < r; i++) {
             if (cur->next == NULL) {  
                 printf("invalid position\n");
                 return;
             }
             cur = cur->next;
         }
        
         new->prev = cur->prev;
         new->next = cur;
         if (cur->prev != NULL){
             cur->prev->next = new;
         }
         cur->prev = new;
     }
        
     void delete(node* h, int r) {
         node* cur = h;
         for (int i = 0; i < r; i++) {
             if (cur->next == 0) {
                 printf("invalid position\n");
                 return;
             }
             cur = cur->next;
         }
         if (cur->next == NULL || r < 1) {
             printf("invalid position\n");
             return;
         }
         (cur->next)->prev = cur->prev;
         (cur->prev)->next = cur->next;
         free(cur);
     }
        
     char get(node* h, int r) {
         node* cur = h;
         for (int i = 0; i < r; i++) {
             if (cur->next == NULL) {
                 printf("invalid position\n");
                 return;
             }
             cur = cur->next;
         }
         if (cur->next == NULL || r == 0) {
             printf("invalid position\n");
             return;
         }
         printf("%c\n", cur->elem);
     }
        
     void print(node* h) {
         node* cur = h->next;
         while (cur->next != NULL) {
             printf("%c", cur->elem);
             cur = cur->next;
         }
         printf("\n");
     }
        
     int main() {
         node* h = (node*)malloc(sizeof(node));
         node* t = (node*)malloc(sizeof(node));  
        
         h->next = t;
         t->prev = h;
         h->prev = NULL;
         t->next = NULL;
        
         int n, r;
         char func, e;
         scanf("%d", &n);
         getchar();
         for (int i = 0; i < n; i++) {
             scanf("%c", &func);
             getchar();
             if (func == 'A') {
                 scanf("%d", &r);
                 getchar();
                 scanf("%c", &e);
                 getchar();
                 add(h, r, e);
             }
             else if (func == 'D') {
                 scanf("%d", &r);
                 getchar();
                 delete(h, r);
             }
             else if (func == 'G') {
                 scanf("%d", &r);
                 getchar();
                 get(h, r);  
             }
             else if (func == 'P') {
                 print(h);  
             }
         }
        
         return 0;
     }