연결 리스트 심화 — 이중 연결부터 Skip List까지
연결 리스트가 배열보다 나은 상황이 정말 있을까요? 그리고 연결 리스트를 개선하면 어디까지 빨라질 수 있을까요?
단일 연결 리스트 복습
단일 연결 리스트(Singly Linked List)는 각 노드가 다음 노드를 가리키는 구조입니다.
class Node<T> {
T data;
Node<T> next;
}
- 삽입/삭제가 O(1) (위치를 알고 있을 때)
- 인덱스 접근이 O(n)
- 역방향 탐색이 불가능 합니다
이 마지막 단점을 해결하기 위해 이중 연결 리스트가 등장합니다.
이중 연결 리스트
이중 연결 리스트(Doubly Linked List)는 각 노드가 **이전 노드와 다음 노드를 모두 가리킵니다 **.
노드 구조
class DoublyNode<T> {
T data;
DoublyNode<T> prev; // 이전 노드
DoublyNode<T> next; // 다음 노드
}
삽입 연산
// 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;
}
삭제 연산
// 특정 노드를 삭제 — 노드 참조만 있으면 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는 이중 연결 리스트로 구현되어 있습니다.
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 캐시 **: 최근에 사용된 항목을 앞으로 옮기고, 가장 오래된 항목을 뒤에서 제거
- ** 브라우저 히스토리 **: 뒤로/앞으로 이동
- ** 텍스트 에디터 **: 커서 이동과 삽입/삭제
// 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)는 마지막 노드가 첫 번째 노드를 가리키는 구조입니다.
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는 여러 레벨의 연결 리스트를 쌓아올려 이를 구현합니다.
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을 찾는다고 가정합니다.
- Level 3에서 시작 → 50 발견, 60 > 50이므로 오른쪽으로 → nil, 아래로 내려감
- Level 2에서 50 → 75, 60 < 75이므로 아래로 내려감
- Level 1에서 50 → 60 발견!
총 비교 횟수가 전체 요소 수보다 훨씬 적습니다.
삽입과 레벨 결정
새 요소의 레벨은 ** 동전 던지기(확률)**로 결정합니다.
// 레벨을 확률적으로 결정
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 구현 (간략)
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의 설명을 요약하면 다음과 같습니다.
- **구현 난이도 **: Skip List는 균형 트리보다 구현이 간단합니다.
- ** 범위 쿼리 **:
ZRANGEBYSCORE같은 범위 쿼리가 자연스럽습니다 (연결 리스트를 따라가기만 하면 됩니다). - ** 동시성 **: 락 없는(lock-free) 구현이 상대적으로 쉽습니다.
- ** 성능 **: 실제 벤치마크에서 균형 트리와 비슷한 성능을 보입니다.
Redis 명령어와 Skip List
# 요소 추가: 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 List | AVL/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를 사용하며, 범위 쿼리와 간결한 구현이 그 이유입니다.
- 실무에서 "연결 리스트는 느리다"는 단순한 판단보다, 어떤 연산이 중요한지 에 따라 선택하는 것이 중요합니다.