컴퓨터 구조 운영체제 네트워크 자료구조 알고리즘 데이터베이스 내 학습
ds · 15

스킵 리스트

확률적연결리스트스킵리스트

정렬된 연결 리스트는 삽입과 삭제가 쉽지만 원하는 값을 찾으려면 처음부터 하나씩 지나야 해서 탐색이 O(n)입니다. 배열은 이진 탐색으로 O(log n)에 찾지만 중간에 끼워 넣으려면 원소를 밀어야 합니다. 스킵 리스트(skip list)는 정렬된 연결 리스트 위에 "빠른 길"을 여러 층 얹어, 위층에서 크게 건너뛰며 탐색을 평균 O(log n)으로 줄인 자료구조입니다.

핵심은 각 노드가 몇 층까지 올라갈지를 동전 던지기로 정한다는 점입니다. 앞면이 나오는 동안 한 층씩 올라가므로 위층으로 갈수록 노드 수가 대략 절반씩 줄고, 이 분포가 이진 탐색과 비슷한 구조를 확률적으로 만들어 줍니다. 균형 트리처럼 회전이나 재배치 규칙이 없어 구현이 단순하다는 것이 장점입니다. 이 토픽에서는 층이 쌓이는 과정, 탐색 경로, 삽입 때 고치는 포인터를 단계별로 봅니다.

핵심 개념

층(레벨)

맨 아래층(L1)은 모든 노드를 이은 정렬된 연결 리스트이고, 위층은 그 일부만 골라 이은 리스트입니다. 위층일수록 노드가 적어 한 번에 더 멀리 건너뜁니다.

핵심 개념

머리 노드

모든 층에 걸쳐 있는 맨 앞 노드로, 탐색은 항상 여기서 맨 위층부터 시작합니다. 층마다 다음 노드를 가리키는 포인터를 하나씩 가집니다.

핵심 개념

동전 던지기로 높이 결정

새 노드는 앞면이 나오는 동안 한 층씩 올라가고 뒷면에서 멈춥니다. 앞면 확률이 1/2이면 높이가 k 이상일 확률은 1/2의 (k-1)제곱입니다.

핵심 개념

탐색

맨 위층에서 시작해, 오른쪽 다음 노드가 찾는 키보다 작으면 오른쪽으로 가고 아니면 한 층 내려갑니다. 맨 아래층에서 다음 노드가 찾는 키인지 확인합니다.

핵심 개념

삽입과 삭제

탐색하며 층마다 바로 앞 노드를 기록해 두고, 그 노드들의 포인터를 새 노드로 이어 붙이거나 건너뛰게 바꿉니다. 삽입은 노드 높이만큼의 층에서 포인터 두 개(앞 노드와 새 노드)씩, 삭제는 층마다 앞 노드의 포인터 하나씩만 고칩니다.

핵심 개념

확률적 균형

균형이 입력 순서가 아니라 동전 던지기 결과에 달려 있어, 정렬된 입력이 들어와도 기대 성능이 유지됩니다. 대신 보장은 평균과 높은 확률 수준이고 최악은 O(n)입니다.

탐색 · 삽입 · 삭제가 평균 O(log n)입니다 — 층 수가 log n 정도이고 층마다 오른쪽 이동이 기대 몇 번 안에 끝나기 때문입니다. 최악은 O(n)이지만 이런 경우는 확률이 매우 낮습니다.
공간은 기대 O(n)입니다 — 노드당 평균 포인터 수가 1 + 1/2 + 1/4 + ... = 2에 가까워 포인터 수가 연결 리스트의 두 배 정도입니다(키와 값까지 포함한 전체 메모리는 구현마다 다릅니다). 확률을 1/4로 낮추면 평균 포인터가 약 1.33개로 줄어 공간을 아낄 수 있습니다.
구현이 단순합니다 — 레드-블랙 트리나 AVL 트리의 회전과 색 규칙이 없고, 연결 리스트에 층만 더하면 됩니다. 삽입이 국소적이라 동시성 구현도 비교적 쉽습니다.
정렬된 순서와 범위 질의가 됩니다 — 맨 아래층이 정렬된 연결 리스트라서 한 키를 찾은 뒤 오른쪽으로 따라가면 범위 조회가 끝납니다. 해시 테이블이 효율적으로 하지 못하는 일입니다.
이 시각화에서 확인할 것 — "레벨 구성" 탭에서 층마다 노드가 절반씩 줄어드는 모양을, "탐색" 탭에서 위에서 아래로 내려오는 경로를, "삽입" 탭에서 층마다 기록하는 앞 노드(보라색 테두리)를 STEP으로 확인하세요.

스킵 리스트의 배경

스킵 리스트는 윌리엄 퓨(William Pugh)가 1990년 논문 "Skip Lists: A Probabilistic Alternative to Balanced Trees"에서 소개했습니다. 균형 트리는 삽입과 삭제 때 구조를 복잡하게 바로잡아야 하지만, 확률을 이용하면 훨씬 단순한 알고리즘으로 비슷한 성능을 낼 수 있다는 것이 논문의 주장입니다. 이후 인메모리 저장소와 데이터베이스의 정렬된 구조에 쓰이게 되었습니다.

숫자로 확인하기

층별 노드 수 — 이 토픽의 예시는 키 10개에 동전 결과가 L1 10개, L2 5개, L3 2개로 쌓여 층마다 대략 절반입니다. 포인터는 모두 17개로 노드당 평균 1.7개입니다.
탐색 비교 — 키 3, 6, 7, 9, 12, 17, 19, 21, 25, 26에서 21을 찾을 때 오른쪽으로 4번(9, 17, 19, 21)과 아래로 2번 이동합니다. 연결 리스트라면 8개 노드를 지나야 합니다.
분포 확인 — 노드 10만 개를 동전으로 높이를 정해 쌓은 시뮬레이션(시드 1)에서 레벨 1~4의 노드 수는 100000, 50171, 24929, 12464개였고 노드당 평균 포인터 수는 2.0이었습니다.
삽입 — 키 14는 높이 2이므로 L1과 L2에서 앞 노드(12, 9)의 포인터와 새 노드의 포인터, 모두 4개를 고칩니다.

자주 하는 오해

항상 O(log n)이 보장된다 — 평균(기대) 시간입니다. 동전 결과가 극단적으로 나쁘면 느려질 수 있고 최악은 O(n)이지만, 그럴 확률은 n이 커질수록 매우 작아집니다.
이진 탐색 트리의 한 종류다 — 트리가 아니라 층을 가진 연결 리스트입니다. 노드가 여러 개의 다음 포인터를 가질 뿐 부모와 자식 관계가 없습니다.
메모리를 훨씬 더 쓴다 — 노드당 평균 포인터가 약 2개라 연결 리스트보다는 포인터를 더 쓰지만, 이진 트리 노드가 자식 포인터 2개를 갖는 것과 비슷한 수준입니다. 확률을 조정하면 더 줄일 수 있습니다.
입력이 정렬돼 있으면 느려진다 — 균형이 입력 순서가 아니라 동전 결과로 정해지므로 정렬된 입력에서도 기대 성능이 같습니다. 일반 이진 탐색 트리와 다른 점입니다.

면접에서 이렇게 물어요

면접 질문

스킵 리스트는 어떻게 O(log n) 탐색을 하나요?

정렬된 연결 리스트 위에 점점 성긴 층을 쌓아, 위층에서 크게 건너뛰고 아래층에서 범위를 좁힙니다. 층 수가 log n 정도라 평균 O(log n)입니다.

면접 질문

노드의 높이는 어떻게 정하나요?

동전을 던져 앞면이면 한 층 올리고 뒷면이면 멈춥니다. 위층으로 갈수록 노드 수가 절반씩 줄어드는 분포가 평균적으로 만들어집니다.

면접 질문

균형 트리와 비교하면?

구현이 단순하고 삽입이 국소적이라 동시성에 유리합니다. 대신 성능 보장이 확률적입니다.

면접 질문

어디에 쓰이나요?

Redis의 정렬 집합(Sorted Set)이 원소가 많아지면 스킵 리스트를 쓰고, 자바의 ConcurrentSkipListMap이 동시성을 지원하는 정렬 맵으로 제공됩니다. LevelDB 같은 저장소의 메모리 테이블에도 쓰입니다.

실제 시스템에서는

Redis 정렬 집합 — Sorted Set은 원소 수와 값 길이가 작으면(기본 128개, 64바이트 이하) listpack으로 저장하고, 커지면 순위와 범위 조회를 위한 스킵 리스트와 멤버 점수 조회를 위한 해시 테이블을 함께 씁니다. 기준값은 설정으로 바뀝니다.
자바 동시성 컬렉션 — ConcurrentSkipListMap과 ConcurrentSkipListSet이 스킵 리스트 기반의 정렬된 동시성 컬렉션입니다.
LSM 계열 저장소의 메모리 테이블 — LevelDB와 RocksDB가 쓰기 데이터를 모아 두는 메모리 테이블을 스킵 리스트로 구현합니다(RocksDB는 기본 구현).

함께 보면 좋은 토픽

배열과 연결 리스트 — 스킵 리스트의 바탕이 되는 연결 리스트를 봅니다.
이진 탐색 트리 — 같은 정렬 탐색을 트리로 하는 구조를 봅니다.
균형 이진트리 AVL과 레드-블랙 — 회전으로 균형을 맞추는 결정적인 방법을 비교합니다.
이진 탐색 — 절반씩 줄이는 탐색의 원리를 봅니다.

참고 자료

논문 — Pugh, W., "Skip Lists: A Probabilistic Alternative to Balanced Trees", Communications of the ACM 33(6), 1990
공식 문서 — Redis Sorted sets
공식 문서 — Java ConcurrentSkipListMap

Python — 스킵 리스트 탐색과 삽입

import random

class Node:
    def __init__(self, key, level):
        self.key = key
        self.next = [None] * level          # next[i]는 레벨 i의 다음 노드

class SkipList:
    MAX_LEVEL = 16

    def __init__(self, seed=None):
        self.head = Node(None, self.MAX_LEVEL)
        self.level = 1                       # 지금 쓰는 최대 레벨
        self.rng = random.Random(seed)

    def _random_level(self):
        lv = 1
        while lv < self.MAX_LEVEL and self.rng.random() < 0.5:
            lv += 1                          # 앞면이면 한 레벨 올린다
        return lv

    def search(self, key):
        node = self.head
        for i in range(self.level - 1, -1, -1):
            while node.next[i] and node.next[i].key < key:
                node = node.next[i]            # 오른쪽으로 이동
        node = node.next[0]                     # 맨 아래층에서 확인
        return node is not None and node.key == key

    def insert(self, key):
        update = [self.head] * self.MAX_LEVEL
        node = self.head
        for i in range(self.level - 1, -1, -1):
            while node.next[i] and node.next[i].key < key:
                node = node.next[i]
            update[i] = node                 # 레벨 i에서 key 바로 앞 노드
        lv = self._random_level()
        self.level = max(self.level, lv)
        new = Node(key, lv)
        for i in range(lv):
            new.next[i] = update[i].next[i]
            update[i].next[i] = new

sl = SkipList(seed=7)
for k in [3, 6, 7, 9, 12, 17, 19, 21, 25, 26]:
    sl.insert(k)
print(sl.search(21), sl.search(14))   # True False
sl.insert(14)
print(sl.search(14))                    # True

각 노드는 레벨 수만큼의 next 포인터 배열을 가지고, 머리 노드는 모든 레벨에 걸쳐 있습니다. search는 맨 위 레벨에서 시작해 오른쪽 다음 노드의 키가 찾는 키보다 작으면 오른쪽으로 가고, 아니면 한 레벨 내려갑니다. 맨 아래 레벨에서 다음 노드가 찾는 키인지 확인하면 끝납니다. 시각화의 탐색 탭이 그리는 경로와 같은 알고리즘입니다(노드의 높이는 시각화의 고정값과 다릅니다). insert는 같은 방식으로 내려가며 레벨마다 key 바로 앞 노드를 update 배열에 기록합니다. 동전 던지기로 새 노드의 레벨을 정하고, 그 레벨들에서만 앞 노드와 새 노드의 포인터를 이어 붙입니다. 연결 리스트의 삽입과 같은 포인터 두 개 수정이어서 회전이 없습니다. 21은 있고 14는 넣기 전에는 없으므로 True False이고, 14를 넣은 뒤에는 True입니다. 시드를 7로 고정해서 실행할 때마다 같은 구조가 만들어집니다.

Python — 레벨 분포 확인

import random

rng = random.Random(1)
N = 100000
counts = [0] * 20
for _ in range(N):
    lv = 1
    while rng.random() < 0.5:         # 앞면이면 한 레벨 올린다
        lv += 1
    for i in range(lv):
        counts[i] += 1                # 레벨 i 이하에 노드가 있다

print(counts[:6])                 # [100000, 50171, 24929, 12464, 6127, 3071]
print(round(sum(counts) / N, 2))   # 2.0 (노드당 평균 포인터 수)

노드 10만 개에 동전 던지기로 높이를 정하면 레벨 1에는 10만 개, 레벨 2에는 약 절반인 5만 개, 레벨 3에는 약 2만 5천 개가 남습니다. 위 레벨로 갈수록 노드 수가 대략 절반씩 줄어드는 것이 시각화의 레벨 구성 탭에서 본 모양입니다. 레벨 수는 대략 log2(n)이고, 노드당 평균 포인터 수는 1 + 1/2 + 1/4 + ... = 2에 가깝습니다. 그래서 포인터 수는 연결 리스트의 두 배 정도이고, 탐색은 층마다 평균 두 번쯤(오른쪽 약 1번, 아래 1번)만 움직입니다. 확률이 1/2 대신 1/4이면 평균 포인터 수가 약 1.33으로 줄고, 레벨당 이동은 늘지만 레벨 수가 줄어 기대 탐색 비용은 비슷합니다. 논문은 1/4를 권장합니다.

Java — ConcurrentSkipListMap 사용

import java.util.concurrent.ConcurrentSkipListMap;

ConcurrentSkipListMap<Integer, String> map = new ConcurrentSkipListMap<>();
map.put(30, "c"); map.put(10, "a"); map.put(20, "b"); map.put(40, "d");

System.out.println(map.firstKey());          // 10
System.out.println(map.floorKey(25));        // 20  (25 이하 중 가장 큰 키)
System.out.println(map.ceilingKey(25));      // 30  (25 이상 중 가장 작은 키)
System.out.println(map.subMap(10, 30));        // {10=a, 20=b}  (10 이상 30 미만)

자바의 ConcurrentSkipListMap은 스킵 리스트를 바탕으로 한 정렬된 맵으로, 여러 스레드가 동시에 읽고 써도 안전하도록 설계되어 있습니다. 키를 정렬된 순서로 유지하므로 firstKey, floorKey, ceilingKey, subMap 같은 범위 질의를 쓸 수 있습니다. TreeMap과 인터페이스가 비슷하지만 TreeMap은 동기화가 없어서 여러 스레드가 함께 쓰려면 바깥에서 잠금을 걸어야 합니다. 스킵 리스트는 삽입이 포인터 몇 개를 바꾸는 국소적인 일이라, 락 없이 비교 후 교체(CAS) 방식으로 동시성을 다루기 쉬운 구조입니다. 정렬이 필요 없고 단일 스레드라면 해시 맵이 평균 O(1)로 더 빠르므로, 정렬된 순서와 동시 접근이 함께 필요한 곳에 고르는 것이 일반적입니다.

Q1

스킵 리스트가 평균적으로 O(log n) 탐색을 달성하는 방법은?

"고속도로"와 "일반 도로"를 오가는 것과 비슷합니다.
스킵 리스트는 아래 레벨의 리스트 일부를 위 레벨에 복사해 "빠른 길"을 만듭니다. 위 레벨에서 크게 건너뛰다가 내려오며 범위를 좁혀 평균 O(log n)에 탐색합니다.

Q2

스킵 리스트에서 새 노드가 몇 개 레벨에 걸쳐 존재할지는 어떻게 정할까?

이름에 "확률적 자료구조"라는 성격이 담겨 있습니다.
새 노드의 레벨은 무작위로 정합니다. 보통 1/2 확률로 위 레벨로 승격하는 방식이라, 위로 갈수록 노드 수가 절반씩 줄어드는 구조가 평균적으로 만들어집니다.

Q3

스킵 리스트가 실제로 사용되는 예로 알맞은 것은?

점수 순으로 정렬된 집합을 다루는 인메모리 저장소입니다.
Redis의 Sorted Set(ZSET)은 요소가 많을 때 스킵 리스트와 해시 테이블을 함께 사용해 순위 조회와 범위 조회를 빠르게 처리합니다.