스킵 리스트
IT 위키
스킵 리스트(skip list)는 정렬된 원소를 빠르게 탐색, 삽입, 삭제할 수 있도록 설계된 확률적 자료구조이다. 1989년 William Pugh가 제안하였으며, 연결 리스트(linked list)의 구조를 확장하여 이진 탐색 트리 수준의 효율성을 얻을 수 있도록 고안되었다.
1 개요[편집 | 원본 편집]
스킵 리스트는 여러 수준의 연결 리스트를 위로 확장한 구조를 가진다. 기본 연결 리스트는 가장 하위 레벨에 존재하며, 그 위로 확률적으로 선택된 노드들만 포함하는 상위 레벨들이 존재한다. 각 노드는 자신이 속한 레벨의 다음 노드를 가리키는 포인터를 가진다. 탐색, 삽입, 삭제 등의 연산은 상위 레벨부터 내려가며 진행되어, 일반 연결 리스트보다 빠른 속도를 제공한다.
2 구조[편집 | 원본 편집]
스킵 리스트는 레벨(level)의 개념을 기반으로 구성된다.
- Level 0: 전체 원소를 포함하는 기본 연결 리스트
- Level 1 이상: 일정 확률(p, 일반적으로 1/2 또는 1/4)에 따라 무작위로 선택된 노드만 포함
- Head node: 모든 레벨의 시작점을 포함하는 특수 노드
노드마다 여러 개의 포인터가 있으며, 자신의 레벨 수에 따라 상위 레벨까지 연결된다.
3 연산[편집 | 원본 편집]
스킵 리스트는 다음과 같은 주요 연산을 효율적으로 수행할 수 있도록 설계되었다.
- 탐색
- 탐색 연산은 찾고자 하는 키 값 k에 대해, 가장 높은 레벨에서 시작하여 오른쪽으로 이동하면서 현재 노드의 다음 노드의 키가 k 이하일 때까지 진행한다.
- 조건이 만족되지 않으면 한 단계 아래 레벨로 내려가 같은 과정을 반복하며, 최하위 레벨까지 도달한 후 k 이하의 가장 큰 키 값을 갖는 노드를 찾는다.
- 삽입
- 삽입할 위치는 탐색 연산을 통해 찾는다. 그 후, 새 노드의 레벨을 확률적으로 결정하여 해당 레벨까지의 포인터를 조정한다.
- 새 노드는 0번 레벨(가장 아래)부터 자신의 레벨까지의 모든 레벨에서 연결되어야 하며, 각 레벨마다 이전 노드의 포인터를 적절히 갱신해야 한다.
- 레벨이 기존 최대 레벨보다 높아지면, 리스트의 레벨도 확장된다.
- 삭제
- 삭제 연산도 먼저 탐색을 통해 삭제 대상 노드를 찾는다.
- 이후 해당 노드가 존재하는 각 레벨에서 연결 포인터를 수정하여 노드를 제거한다.
- 삭제 이후 상위 레벨에서 더 이상 노드가 없다면 리스트의 전체 레벨을 줄이는 작업이 수행된다.
4 시간 복잡도[편집 | 원본 편집]
스킵 리스트는 다음과 같은 평균 시간 복잡도를 갖는다.
- 탐색: O(log n)
- 삽입: O(log n)
- 삭제: O(log n)
최악의 경우 O(n)일 수 있으나, 확률적 구조 덕분에 평균적으로는 균형잡힌 이진 탐색 트리와 유사한 성능을 가진다.
아래 분석 내용은 일부 p = 1/2으로 고정하여 쓰여졌음에 주의하자.
4.1 탐색[편집 | 원본 편집]
- 최상위 레벨에서 시작하여 다음 규칙에 따라 탐색한다.
- 현재 노드의 다음(next) 노드의 키가 찾고자 하는 키보다 작으면 → 오른쪽으로 이동
- 그 외의 경우 → 한 단계 아래 레벨로 내려감
- 레벨 0까지 반복하면서, 마지막으로 지나간 노드를 반환한다.
- 탐색은 각 레벨당 평균 한 번씩 이동하므로, 전체 기대 시간은 O(log n)이다.
4.2 삽입[편집 | 원본 편집]
- 새 노드의 레벨은 확률 p 에 따라 무작위로 결정된다.
- 레벨 k가 선택될 확률은 p(k+1), 예를 들어 p=1/2이면 (1/2)(k+1)
- 삽입 위치 찾기 (검색 단계)
- 탐색과 동일하게 최고 레벨부터 오른쪽으로 이동하고, 조건이 맞지 않으면 아래로 내려가며 레벨 0까지 반복
- 각 레벨에서 새 노드가 삽입될 위치의 직전 노드를 기록
- 이 탐색 단계의 기대 시간은 O(log n)
- 포인터 갱신 (삽입 단계)
- 선택된 최고 레벨 h에 대해, 각 레벨 i (0 ≤ i ≤ h)에 대해 다음을 수행***새 노드의 next[i]를 prev[i].next[i]로 지정
- prev[i].next[i]를 새 노드로 갱신
- 레벨 h의 기대값은 상수 수준이므로, 이 단계의 기대 시간은 O(1)
- 선택된 최고 레벨 h에 대해, 각 레벨 i (0 ≤ i ≤ h)에 대해 다음을 수행***새 노드의 next[i]를 prev[i].next[i]로 지정
- 삽입 전체 기대 시간: O(log n)
4.3 삭제[편집 | 원본 편집]
- 삭제할 노드의 위치를 탐색하는 과정은 삽입 시와 동일하게 수행된다.
- 최상위 레벨에서 시작하여, 각 레벨에서 삭제 대상 노드 직전까지 이동
- 레벨 0에서 삭제할 노드를 확인
- 이 탐색 단계의 기대 시간은 O(log n)
- 포인터 갱신 (삭제 단계)
- 삭제할 노드가 레벨 h에 존재한다고 할 때, 각 레벨 i (0 ≤ i ≤ h)에 대해
- prev[i].next[i]를 삭제 노드의 next[i]로 갱신
- 레벨 h의 기대값은 상수 수준이므로, 이 단계의 기대 시간은 O(1)
- 삭제할 노드가 레벨 h에 존재한다고 할 때, 각 레벨 i (0 ≤ i ≤ h)에 대해
- 삭제 전체 기대 시간: O(log n)
5 공간 복잡도[편집 | 원본 편집]
- 각 키가 레벨 k를 갖는 확률은 1/2(k+1)
- 따라서 레벨 k에 속하는 노드 수의 기대값은 n × 1/2(k+1)
- 모든 레벨을 더하면
- n×(1/2 + 1/4 + 1/8 + …) = n
- 결국 원래 리스트와 상위 레벨들을 합하면 2n이다. 따라서 선형 복잡도 수준이므로,
- 결론: 기대 공간 복잡도는 O(n)입니다.
6 예제[편집 | 원본 편집]
다음은 Python으로 구현한 스킵 리스트의 주요 연산(탐색, 삽입, 삭제) 예시이다.
import random
class SkipNode:
def __init__(self, key, level):
self.key = key
self.forward = [None] * (level + 1)
class SkipList:
def __init__(self, max_level, p):
self.max_level = max_level # 최대 레벨
self.p = p # 노드가 상위 레벨에 포함될 확률
self.header = SkipNode(float('-inf'), max_level)
self.level = 0 # 현재 리스트의 최대 레벨
def random_level(self):
lvl = 0
while random.random() < self.p and lvl < self.max_level:
lvl += 1
return lvl
def look_up(self, key):
u = self.header
for i in reversed(range(self.level + 1)):
while u.forward[i] and u.forward[i].key <= key:
u = u.forward[i]
return u # key 이하의 가장 큰 노드 반환
def search(self, key):
node = self.look_up(key)
return node if node.key == key else None
def insert(self, key):
update = [None] * (self.max_level + 1)
u = self.header
for i in reversed(range(self.level + 1)):
while u.forward[i] and u.forward[i].key < key:
u = u.forward[i]
update[i] = u
node = u.forward[0]
if node and node.key == key:
return # 중복 키는 삽입하지 않음
lvl = self.random_level()
if lvl > self.level:
for i in range(self.level + 1, lvl + 1):
update[i] = self.header
self.level = lvl
new_node = SkipNode(key, lvl)
for i in range(lvl + 1):
new_node.forward[i] = update[i].forward[i]
update[i].forward[i] = new_node
def delete(self, key):
update = [None] * (self.max_level + 1)
u = self.header
for i in reversed(range(self.level + 1)):
while u.forward[i] and u.forward[i].key < key:
u = u.forward[i]
update[i] = u
node = u.forward[0]
if node and node.key == key:
for i in range(self.level + 1):
if update[i].forward[i] != node:
continue
update[i].forward[i] = node.forward[i]
while self.level > 0 and self.header.forward[self.level] is None:
self.level -= 1
# 실행 예시
if __name__ == "__main__":
sl = SkipList(max_level=4, p=0.5)
for k in [3, 6, 7, 9, 12, 19]:
sl.insert(k)
print("Search 6:", sl.search(6).key if sl.search(6) else "Not Found")
sl.delete(6)
print("Search 6 after deletion:", sl.search(6).key if sl.search(6) else "Not Found")
자료 구조 위 구현은 다음과 같은 구조를 따른다.
- 각 노드는 key 값을 가지며, 최대 max_level+1 개의 forward 포인터를 가진다.
- SkipList는 확률 p를 기준으로 랜덤하게 레벨을 결정하고, 삽입 시 각 레벨에 노드를 연결한다.
- look_up 연산은 상위 레벨부터 오른쪽으로 이동하며 key 이하의 가장 큰 노드를 찾아 반환한다.
7 장점[편집 | 원본 편집]
- 간단한 구현으로도 이진 탐색 트리 수준의 성능 달성
- 포인터 조작만으로 동적 삽입, 삭제에 용이
- 동시성 제어가 상대적으로 쉬움 (Lock-free skip list 구현 등)
8 단점[편집 | 원본 편집]
- 공간 사용량 증가 (다수의 포인터 필요)
- 성능이 확률에 의존함
9 같이 보기[편집 | 원본 편집]
10 참고 문헌[편집 | 원본 편집]
- William Pugh (1990). "Skip Lists: A Probabilistic Alternative to Balanced Trees". Communications of the ACM.
- Cormen, T. H., Leiserson, C. E., Rivest, R. L., and Stein, C. (2009). Introduction to Algorithms. MIT Press.