cv.cm

코딩 · 자료 구조

연결 리스트란? 동작 원리, 코드, 시간 복잡도

8분 분량

핵심 답변

연결 리스트는 각 노드가 값과 다음 노드를 가리키는 참조(포인터)를 저장하는 노드의 연속입니다. 배열과 달리 노드가 메모리에서 서로 붙어 있지 않으므로, 위치를 알고 있는 노드의 삽입·삭제는 O(1)이지만 k번째 요소를 찾으려면 head부터 따라가야 해서 O(n)이 걸립니다. 이중 연결 리스트는 이전 노드를 가리키는 포인터도 함께 저장합니다.

연결 리스트가 해결하는 문제

배열(파이썬의 list)은 요소를 메모리의 연속된 한 덩어리에 보관합니다. a[i]를 읽는 것은 즉시 끝나지만, 맨 앞에 삽입하면 나머지 요소가 모두 한 칸씩 오른쪽으로 밀리므로 O(n)이 듭니다. 연결 리스트는 이 이동을 피합니다. 각 요소가 자기만의 노드에 들어 있고 노드끼리는 참조로 연결되어 있어서, 삽입할 때는 참조 몇 개만 바꾸면 됩니다.

연결 리스트는 스택, 큐, 해시 테이블 버킷(체이닝), LRU 캐시, 그래프의 인접 리스트 등 많은 자료구조의 기본 재료입니다.

쉬운 말로 설명하는 원리

보물찾기를 떠올려 보세요. 각 단서는 다음 단서가 어디 있는지 알려 줍니다. 7번 단서로 바로 건너뛸 수는 없고, 1번부터 6번까지 차례로 따라가야 합니다. 리스트 자체는 첫 번째 노드인 head만 기억하고, 마지막 노드는 아무것도 가리키지 않습니다(None).

종류각 노드가 저장하는 것주요 용도
단일 연결 리스트값, next스택, 단순한 큐, 해시 버킷
이중 연결 리스트값, next, prev덱, LRU 캐시, 텍스트 편집기 버퍼
원형 연결 리스트단일 또는 이중 연결 리스트와 같지만 tail이 head를 다시 가리킴라운드 로빈 스케줄링, 링 버퍼

예제: 삽입과 삭제

3 -> 7 -> 9 리스트에서 시작합니다. 3 뒤에 5를 삽입한 다음 7을 삭제합니다.

  1. 값이 5인 새 노드를 만듭니다.
  2. new.next를 3 다음 노드(7)로 설정합니다.
  3. node3.next를 새 노드로 설정합니다. 이제 리스트는 3 -> 5 -> 7 -> 9입니다.
  4. 7을 삭제하려면 head부터 따라가 7 바로 앞 노드(5)를 찾습니다.
  5. node5.next = node5.next.next로 7을 건너뜁니다. 이제 리스트는 3 -> 5 -> 9입니다. 도달할 수 없게 된 노드는 파이썬의 가비지 컬렉터가 해제합니다.

파이썬으로 구현하는 단일 연결 리스트

이 클래스는 head 참조와 크기 카운터를 유지합니다. 앞과 뒤에 추가하기, 처음으로 일치하는 값 삭제하기, 순회를 지원합니다.

class Node:
    def __init__(self, value, next=None):
        self.value = value
        self.next = next
class LinkedList:
    def __init__(self):
        self.head = None
        self.size = 0
    def push_front(self, value):
        self.head = Node(value, self.head)
        self.size += 1
    def push_back(self, value):
        node = Node(value)
        if self.head is None:
            self.head = node
        else:
            cur = self.head
            while cur.next:
                cur = cur.next
            cur.next = node
        self.size += 1
    def remove(self, value):
        prev, cur = None, self.head
        while cur and cur.value != value:
            prev, cur = cur, cur.next
        if cur is None:
            return False          # not found
        if prev is None:
            self.head = cur.next  # removing the head
        else:
            prev.next = cur.next
        self.size -= 1
        return True
    def __iter__(self):
        cur = self.head
        while cur:
            yield cur.value
            cur = cur.next
items = LinkedList()
for x in [3, 7, 9]:
    items.push_back(x)
items.push_front(1)
items.remove(7)
print(list(items), items.size)  # [1, 3, 9] 3

여기서 push_back은 리스트 전체를 따라가므로 O(n)입니다. tail 참조를 추가로 유지하면 O(1)이 되며, 실제 큐 구현은 이렇게 합니다.

대표 연산: 뒤집기와 사이클 탐지

리스트를 제자리에서 뒤집는 것은 연결 리스트 코딩 면접에서 가장 흔한 문제입니다. 리스트를 한 번 순회하면서 각 next 포인터의 방향을 돌립니다.

def reverse(head):
    prev = None
    cur = head
    while cur:
        nxt = cur.next     # remember the rest
        cur.next = prev    # turn the pointer around
        prev, cur = cur, nxt
    return prev            # new head
def has_cycle(head):
    slow = fast = head
    while fast and fast.next:
        slow = slow.next
        fast = fast.next.next
        if slow is fast:
            return True
    return False
head = Node(1, Node(2, Node(3)))
head = reverse(head)
print(head.value, head.next.value, head.next.next.value)  # 3 2 1
print(has_cycle(head))  # False

has_cycle은 플로이드의 토끼와 거북이 알고리즘입니다. 느린 포인터는 한 칸, 빠른 포인터는 두 칸씩 움직입니다. 루프가 있으면 빠른 포인터가 결국 느린 포인터와 만나고, 없으면 None에 도달합니다. 추가 메모리는 O(1)만 사용합니다.

시간 복잡도와 공간 복잡도

연산단일 연결 리스트파이썬 list(동적 배열)이유
k번째 요소 접근O(n)O(1)연결 리스트는 k개의 노드를 따라가야 함
맨 앞 삽입 또는 삭제O(1)O(n)배열은 모든 요소를 이동시킴
이미 아는 노드 뒤의 삽입 또는 삭제O(1)O(n)참조만 바뀜
맨 뒤에 추가tail 포인터가 있으면 O(1)분할 상환 O(1)둘 다 저렴함
값 검색O(n)O(n)둘 다 요소를 하나씩 훑음
추가 메모리노드당 참조 1~2개여분의 용량노드가 각각 별도의 객체임

실제로는 연결 리스트의 빅오가 더 좋은 연산에서도 배열이 더 빠른 경우가 많습니다. 배열 요소는 메모리에서 나란히 놓여 CPU 캐시를 잘 활용하지만, 연결 리스트의 노드는 흩어져 있기 때문입니다.

변형과 흔한 실수

  • 빈 리스트를 잊는 실수: head.next를 읽기 전에 항상 head is None을 확인하세요.
  • next를 저장하기 전에 덮어써서 리스트의 나머지를 잃는 실수(reverse의 nxt 참고).
  • 첫 번째 노드를 삭제할 때 head를, 마지막 노드를 삭제할 때 tail을 갱신하지 않는 실수.
  • 매우 긴 리스트에 재귀를 쓰는 실수: 파이썬의 기본 재귀 한도는 약 1000 프레임이므로 반복문 코드가 더 안전합니다.
  • 더미(센티널) head 노드를 두면 대부분의 특수 처리가 사라집니다. 모든 실제 노드가 앞 노드를 갖게 되기 때문입니다.
  • 이중 연결 리스트에서는 삽입과 삭제마다 참조 네 개를 갱신해야 합니다. 하나라도 빠뜨리면 리스트가 조용히 망가집니다.

연결 리스트를 쓸 때와 파이썬에서 대신 쓸 것

이미 참조를 쥐고 있는 위치에서 양 끝이나 중간에 삽입·삭제를 하고, 임의 접근이 거의 필요 없을 때 연결 리스트를 사용하세요. 예로는 LRU 캐시(이중 연결 리스트와 해시 맵), 메모리 할당기의 프리 리스트, 실행 취소 기록, 해시 테이블의 버킷이 있습니다.

일상적인 파이썬 코드에서는 직접 만들 일이 드뭅니다. 큐나 덱에는 양쪽 끝에서 O(1)로 추가·제거할 수 있는 collections.deque를 쓰고, 일반적인 시퀀스에는 list를 쓰세요. 연결 리스트를 직접 구현하는 것은 학습, 면접, 그리고 LRU 캐시처럼 노드를 O(1)에 이어 붙여야 할 때입니다.

from collections import deque
q = deque([1, 2, 3])
q.appendleft(0)
q.append(4)
print(q.popleft(), q.pop(), list(q))  # 0 4 [1, 2, 3]

자주 묻는 질문

파이썬의 list는 연결 리스트인가요?

아닙니다. 파이썬의 list는 참조를 연속된 메모리에 저장하는 동적 배열이므로 인덱싱은 O(1), 맨 앞 삽입은 O(n)입니다. 큐 용도로는 collections.deque가 연결 리스트에 가장 가까운 내장 자료구조입니다.

연결 리스트의 접근은 왜 O(n)인가요?

노드는 예측 가능한 주소에 저장되지 않습니다. k번째 노드에 도달하는 유일한 방법은 head에서 시작해 next 참조를 k번 따라가는 것입니다.

이중 연결 리스트가 단일 연결 리스트보다 나은 경우는 언제인가요?

노드에 대한 참조만 가지고 그 노드를 삭제해야 하거나 뒤로 이동해야 할 때입니다. prev 포인터가 있으면 이전 노드를 찾지 않고도 O(1)에 삭제할 수 있습니다. 대신 노드마다 참조가 하나 더 필요합니다.

연결 리스트의 가운데를 한 번의 순회로 찾으려면 어떻게 하나요?

포인터 두 개를 사용합니다. 하나는 한 칸씩, 다른 하나는 두 칸씩 움직입니다. 빠른 포인터가 끝에 도달했을 때 느린 포인터가 가운데에 있습니다.

센티널 노드란 무엇인가요?

첫 번째 실제 노드 앞(경우에 따라 마지막 노드 뒤)에 두는 더미 노드입니다. 데이터는 없지만 모든 실제 노드가 이웃 노드를 갖게 되므로, 삽입·삭제 코드에서 빈 리스트와 head에 대한 특수 처리를 줄일 수 있습니다.

코딩 튜토리얼 전체