Theme:

연결 리스트가 배열보다 나은 상황이 정말 있을까요? 그리고 연결 리스트를 개선하면 어디까지 빨라질 수 있을까요?

단일 연결 리스트 복습

단일 연결 리스트(Singly Linked List)는 각 노드가 다음 노드를 가리키는 구조입니다.

JAVA
class Node<T> {
    T data;
    Node<T> next;
}
  • 삽입/삭제가 O(1) (위치를 알고 있을 때)
  • 인덱스 접근이 O(n)
  • 역방향 탐색이 불가능 합니다

이 마지막 단점을 해결하기 위해 이중 연결 리스트가 등장합니다.

이중 연결 리스트

이중 연결 리스트(Doubly Linked List)는 각 노드가 **이전 노드와 다음 노드를 모두 가리킵니다 **.

노드 구조

JAVA
class DoublyNode<T> {
    T data;
    DoublyNode<T> prev; // 이전 노드
    DoublyNode<T> next; // 다음 노드
}

삽입 연산

JAVA
// newNode를 node와 node.next 사이에 삽입
void insertAfter(DoublyNode<T> node, DoublyNode<T> newNode) {
    newNode.prev = node;
    newNode.next = node.next;
    if (node.next != null) {
        node.next.prev = newNode;
    }
    node.next = newNode;
}

삭제 연산

JAVA
// 특정 노드를 삭제 — 노드 참조만 있으면 O(1)
void delete(DoublyNode<T> node) {
    if (node.prev != null) {
        node.prev.next = node.next;
    }
    if (node.next != null) {
        node.next.prev = node.prev;
    }
}

단일 연결 리스트에서는 삭제하려면 ** 이전 노드 **를 찾아야 했지만, 이중 연결 리스트에서는 prev 포인터가 있으므로 바로 삭제할 수 있습니다.

Java의 LinkedList

Java의 java.util.LinkedList는 이중 연결 리스트로 구현되어 있습니다.

JAVA
import java.util.LinkedList;

LinkedList<String> list = new LinkedList<>();
list.addFirst("A");   // 앞에 추가: O(1)
list.addLast("C");    // 뒤에 추가: O(1)
list.add(1, "B");     // 중간 삽입: O(n) — 탐색이 필요

// 양방향 탐색이 가능합니다
// get(i)는 i가 size/2보다 작으면 앞에서, 크면 뒤에서 탐색
String middle = list.get(1); // 내부적으로 최적화된 탐색

이중 연결 리스트의 활용

  • **LRU 캐시 **: 최근에 사용된 항목을 앞으로 옮기고, 가장 오래된 항목을 뒤에서 제거
  • ** 브라우저 히스토리 **: 뒤로/앞으로 이동
  • ** 텍스트 에디터 **: 커서 이동과 삽입/삭제
JAVA
// LRU 캐시에서 이중 연결 리스트 활용 (간략)
// Java의 LinkedHashMap이 내부적으로 이 구조를 사용합니다
LinkedHashMap<String, String> lruCache = new LinkedHashMap<>(16, 0.75f, true) {
    @Override
    protected boolean removeEldestEntry(Map.Entry<String, String> eldest) {
        return size() > 100; // 100개 초과 시 가장 오래된 항목 제거
    }
};

원형 연결 리스트

원형 연결 리스트(Circular Linked List)는 마지막 노드가 첫 번째 노드를 가리키는 구조입니다.

JAVA
class CircularList<T> {
    Node<T> tail; // 마지막 노드 (tail.next = head)

    void add(T data) {
        Node<T> newNode = new Node<>(data);
        if (tail == null) {
            tail = newNode;
            tail.next = tail; // 자기 자신을 가리킴
        } else {
            newNode.next = tail.next; // 새 노드가 head를 가리킴
            tail.next = newNode;      // 기존 tail이 새 노드를 가리킴
            tail = newNode;           // 새 노드가 tail이 됨
        }
    }
}

활용 사례

  • ** 라운드 로빈 스케줄링 **: 프로세스들을 순환하며 CPU 시간을 배분
  • ** 원형 버퍼 **: 고정 크기 큐에서 인덱스를 순환
  • ** 게임 **: 플레이어들의 순서를 돌아가며 처리

Skip List

여기서 큰 도약이 일어납니다. 연결 리스트의 탐색이 O(n)인 문제를 해결하기 위해 Skip List 가 등장합니다.

아이디어

정렬된 연결 리스트에서 "중간 지점"으로 바로 뛸 수 있다면 이진 탐색처럼 빨라질 것입니다. Skip List는 여러 레벨의 연결 리스트를 쌓아올려 이를 구현합니다.

PLAINTEXT
Level 3: head ──────────────────────── 50 ────────── nil
Level 2: head ────────── 25 ────────── 50 ────── 75 ── nil
Level 1: head ── 10 ── 25 ── 30 ── 50 ── 60 ── 75 ── nil
Level 0: head ── 10 ── 20 ── 25 ── 30 ── 40 ── 50 ── 60 ── 70 ── 75 ── 80 ── nil
  • Level 0은 모든 요소가 있는 정렬된 연결 리스트
  • 상위 레벨은 일부 요소만 포함하는 "고속도로"
  • 검색 시 상위 레벨부터 시작해서 필요할 때만 하위 레벨로 내려갑니다

검색 과정

값 60을 찾는다고 가정합니다.

  1. Level 3에서 시작 → 50 발견, 60 > 50이므로 오른쪽으로 → nil, 아래로 내려감
  2. Level 2에서 50 → 75, 60 < 75이므로 아래로 내려감
  3. Level 1에서 50 → 60 발견!

총 비교 횟수가 전체 요소 수보다 훨씬 적습니다.

삽입과 레벨 결정

새 요소의 레벨은 ** 동전 던지기(확률)**로 결정합니다.

JAVA
// 레벨을 확률적으로 결정
int randomLevel() {
    int level = 0;
    // 50% 확률로 레벨을 올림
    while (Math.random() < 0.5 && level < MAX_LEVEL) {
        level++;
    }
    return level;
}

이렇게 하면 평균적으로 다음과 같은 분포가 됩니다.

  • Level 0: 100%의 노드
  • Level 1: 50%의 노드
  • Level 2: 25%의 노드
  • Level 3: 12.5%의 노드

이진 탐색과 유사한 구조가 확률적으로 만들어집니다.

Skip List 구현 (간략)

JAVA
class SkipListNode<T extends Comparable<T>> {
    T value;
    SkipListNode<T>[] forward; // 각 레벨의 다음 노드

    @SuppressWarnings("unchecked")
    SkipListNode(T value, int level) {
        this.value = value;
        this.forward = new SkipListNode[level + 1];
    }
}

class SkipList<T extends Comparable<T>> {
    private static final int MAX_LEVEL = 16;
    private SkipListNode<T> head;
    private int currentLevel;

    SkipList() {
        head = new SkipListNode<>(null, MAX_LEVEL);
        currentLevel = 0;
    }

    // 검색: O(log n) 평균
    boolean search(T target) {
        SkipListNode<T> current = head;
        for (int i = currentLevel; i >= 0; i--) {
            while (current.forward[i] != null &&
                   current.forward[i].value.compareTo(target) < 0) {
                current = current.forward[i]; // 같은 레벨에서 전진
            }
        }
        current = current.forward[0];
        return current != null && current.value.compareTo(target) == 0;
    }
}

시간 복잡도

연산평균최악
검색O(log n)O(n)
삽입O(log n)O(n)
삭제O(log n)O(n)

최악의 경우는 모든 노드가 같은 레벨인 경우인데, 확률적으로 거의 발생하지 않습니다.

Redis Sorted Set과 Skip List

Redis의 Sorted Set(ZSET)은 내부적으로 Skip List + Hash Table 을 사용합니다.

왜 균형 이진 트리가 아니라 Skip List인가?

Redis 개발자 Salvatore Sanfilippo의 설명을 요약하면 다음과 같습니다.

  1. **구현 난이도 **: Skip List는 균형 트리보다 구현이 간단합니다.
  2. ** 범위 쿼리 **: ZRANGEBYSCORE같은 범위 쿼리가 자연스럽습니다 (연결 리스트를 따라가기만 하면 됩니다).
  3. ** 동시성 **: 락 없는(lock-free) 구현이 상대적으로 쉽습니다.
  4. ** 성능 **: 실제 벤치마크에서 균형 트리와 비슷한 성능을 보입니다.

Redis 명령어와 Skip List

BASH
# 요소 추가: Skip List에 삽입 — O(log n)
ZADD leaderboard 100 "player1"
ZADD leaderboard 200 "player2"

# 점수 기반 범위 조회: Skip List를 따라가며 수집
ZRANGEBYSCORE leaderboard 100 200

# 순위 조회: Skip List에서 앞의 노드 수를 세서 계산
ZRANK leaderboard "player1"

Skip List vs 균형 이진 트리 비교

특성Skip ListAVL/Red-Black Tree
검색O(log n) 평균O(log n) 보장
구현 복잡도비교적 간단복잡 (회전 로직)
범위 쿼리자연스럽게 지원in-order 순회 필요
메모리포인터 오버헤드 가변고정적 오버헤드
동시성lock-free 구현 용이lock-free 구현 어려움

정리

  • ** 이중 연결 리스트 **는 양방향 탐색과 O(1) 삭제를 지원하며, LRU 캐시 등에 활용됩니다.
  • ** 원형 연결 리스트 **는 순환 구조가 필요한 라운드 로빈 스케줄링 등에 사용됩니다.
  • Skip List 는 정렬된 연결 리스트에 레벨 구조를 추가하여 O(log n) 검색을 달성합니다.
  • Redis의 Sorted Set은 Skip List를 사용하며, 범위 쿼리와 간결한 구현이 그 이유입니다.
  • 실무에서 "연결 리스트는 느리다"는 단순한 판단보다, 어떤 연산이 중요한지 에 따라 선택하는 것이 중요합니다.
댓글 로딩 중...