💻 프로그래밍

Linked List

연결 리스트

노드가 포인터로 연결된 자료구조. 삽입/삭제 O(1), 접근 O(n). LRU 캐시, 실행 취소 기능에 활용.

📖 상세 설명

연결 리스트(Linked List)는 데이터를 저장하는 노드(Node)들이 포인터(Pointer)를 통해 서로 연결된 선형 자료구조입니다. 각 노드는 실제 데이터를 담는 데이터 필드와 다음 노드의 메모리 주소를 가리키는 포인터 필드로 구성됩니다. 배열과 달리 메모리상에 연속적으로 저장되지 않아, 동적으로 크기를 조절할 수 있습니다.

연결 리스트는 구조에 따라 세 가지 주요 종류로 나뉩니다. 단일 연결 리스트(Singly Linked List)는 각 노드가 다음 노드만 가리키며, 이중 연결 리스트(Doubly Linked List)는 이전 노드와 다음 노드 모두를 가리킵니다. 원형 연결 리스트(Circular Linked List)는 마지막 노드가 첫 번째 노드를 가리켜 순환 구조를 형성합니다.

연결 리스트와 배열의 가장 큰 차이는 시간 복잡도에 있습니다. 연결 리스트는 특정 위치에서의 삽입/삭제가 O(1)로 매우 빠르지만, 인덱스를 통한 임의 접근은 처음부터 순회해야 하므로 O(n)이 소요됩니다. 반면 배열은 인덱스 접근이 O(1)이지만, 중간 삽입/삭제 시 요소 이동으로 O(n)이 필요합니다.

실무에서 연결 리스트는 LRU(Least Recently Used) 캐시 구현에서 해시맵과 함께 사용되어 O(1) 시간에 캐시 항목을 추가, 삭제, 재배치합니다. 또한 텍스트 에디터의 실행 취소(Undo) 기능, 브라우저의 앞으로/뒤로 가기 히스토리, 음악 플레이어의 재생 목록 등 순차적 탐색과 빈번한 삽입/삭제가 필요한 곳에서 널리 활용됩니다.

📊 배열 vs 연결 리스트 시간복잡도

연산 배열 (Array) 연결 리스트 (Linked List) 승자
인덱스 접근 O(1) O(n) 배열
맨 앞 삽입 O(n) O(1) 연결 리스트
맨 뒤 삽입 O(1)* O(1)** 동등
중간 삽입 O(n) O(1)*** 연결 리스트
검색 O(n) O(n) 동등
메모리 효율 좋음 (연속 메모리) 포인터 오버헤드 배열

* 동적 배열의 경우 평균 O(1), 최악 O(n) (리사이징 시)
** tail 포인터가 있는 경우
*** 삽입 위치의 노드 참조를 이미 가지고 있는 경우

💻 코드 예제

# Python 연결 리스트 구현 예제
class Node:
    """연결 리스트의 노드"""
    def __init__(self, data):
        self.data = data      # 데이터 저장
        self.next = None      # 다음 노드 포인터
        self.prev = None      # 이전 노드 포인터 (이중 연결 리스트용)


class LinkedList:
    """단일 연결 리스트 구현"""
    def __init__(self):
        self.head = None
        self.tail = None
        self._size = 0

    def append(self, data):
        """맨 뒤에 노드 추가 - O(1)"""
        new_node = Node(data)
        if not self.head:
            self.head = self.tail = new_node
        else:
            self.tail.next = new_node
            self.tail = new_node
        self._size += 1

    def prepend(self, data):
        """맨 앞에 노드 추가 - O(1)"""
        new_node = Node(data)
        new_node.next = self.head
        self.head = new_node
        if not self.tail:
            self.tail = new_node
        self._size += 1

    def delete(self, data):
        """값으로 노드 삭제 - O(n)"""
        if not self.head:
            return False

        # 헤드 삭제의 경우
        if self.head.data == data:
            self.head = self.head.next
            if not self.head:
                self.tail = None
            self._size -= 1
            return True

        # 중간/끝 노드 삭제
        current = self.head
        while current.next:
            if current.next.data == data:
                if current.next == self.tail:
                    self.tail = current
                current.next = current.next.next
                self._size -= 1
                return True
            current = current.next
        return False

    def find(self, data):
        """값으로 노드 검색 - O(n)"""
        current = self.head
        index = 0
        while current:
            if current.data == data:
                return index
            current = current.next
            index += 1
        return -1

    def __len__(self):
        return self._size

    def __iter__(self):
        current = self.head
        while current:
            yield current.data
            current = current.next

    def __repr__(self):
        return " -> ".join(str(item) for item in self) + " -> None"


# 사용 예제
if __name__ == "__main__":
    ll = LinkedList()
    ll.append(1)
    ll.append(2)
    ll.append(3)
    ll.prepend(0)

    print(f"리스트: {ll}")           # 0 -> 1 -> 2 -> 3 -> None
    print(f"길이: {len(ll)}")         # 4
    print(f"2의 위치: {ll.find(2)}")  # 2

    ll.delete(2)
    print(f"삭제 후: {ll}")           # 0 -> 1 -> 3 -> None

🗣️ 실무 대화 예시

기술 면접에서 - 배열 vs 연결 리스트 시간복잡도

"배열과 연결 리스트의 핵심 차이는 메모리 배치와 이에 따른 시간복잡도입니다. 배열은 연속 메모리라 인덱스 접근이 O(1)이지만, 중간 삽입 시 뒤 요소들을 전부 밀어야 해서 O(n)입니다. 연결 리스트는 반대로 포인터만 수정하면 되니 삽입/삭제가 O(1)이지만, 특정 위치까지 순회해야 하므로 접근은 O(n)이죠. 그래서 LRU 캐시처럼 빈번한 삽입/삭제가 필요한 곳에는 연결 리스트를, 랜덤 액세스가 많은 곳에는 배열을 씁니다."

코드 리뷰 중 - 메모리 효율성 논의

"이 부분 LinkedList 대신 ArrayList로 바꾸는 게 좋겠어요. 데이터가 100만 개인데 연결 리스트는 노드당 포인터가 추가로 필요해서 메모리 오버헤드가 커요. 게다가 노드들이 메모리에 흩어져 있어서 CPU 캐시 적중률도 낮아지고요. 이 케이스는 순차 접근이 대부분이고 중간 삽입이 거의 없으니 배열이 훨씬 효율적입니다. 캐시 지역성 측면에서 배열이 10배 이상 빠를 수 있어요."

시스템 설계 논의에서

"LRU 캐시 구현에 HashMap과 이중 연결 리스트 조합을 쓰면 get과 put 모두 O(1)에 처리할 수 있어요. HashMap으로 O(1) 조회를 하고, 이중 연결 리스트로 최근 접근 순서를 관리하는 거죠. 접근된 항목은 리스트 맨 앞으로 이동시키고, 캐시가 가득 차면 맨 뒤 항목을 제거합니다. Java의 LinkedHashMap이 정확히 이 원리입니다."

⚠️ 주의사항

1
메모리 단편화

연결 리스트의 노드들은 힙 메모리 곳곳에 흩어져 할당됩니다. 이로 인해 메모리 단편화가 발생할 수 있고, 각 노드마다 포인터를 위한 추가 메모리(64비트 시스템에서 8바이트)가 필요합니다. 소규모 데이터 저장에는 오히려 배열보다 메모리를 더 많이 사용할 수 있습니다.

2
캐시 지역성(Cache Locality) 부족

현대 CPU는 연속된 메모리를 캐시 라인 단위로 불러옵니다. 배열은 요소들이 연속 배치되어 캐시 적중률이 높지만, 연결 리스트는 노드가 분산되어 캐시 미스가 빈번합니다. 순회 작업에서 배열이 연결 리스트보다 수십 배 빠를 수 있습니다.

3
null/None 포인터 처리

연결 리스트 구현 시 빈 리스트, 단일 노드 리스트, head/tail 경계 조건에서 NullPointerException이 자주 발생합니다. 삽입/삭제 로직에서 head, tail, current.next가 null인 경우를 항상 먼저 체크해야 합니다. 단위 테스트로 엣지 케이스를 반드시 커버하세요.

🔗 관련 용어

📚 더 배우기