跳躍串列
在排序好的鏈結串列上隨機加幾層快速道路:查找平均只要 log n 步,而且不用像平衡樹那樣旋轉。
現在的位置走過的路找到/接上拆掉
最下面一層就是一條排好序的鏈結串列。每個節點插入時擲硬幣決定塔有多高,所以大約一半的節點會出現在第 1 層、四分之一在第 2 層。為了畫得下,這裡最多 4 層。
節點
10
最高的塔
4
平均每個節點幾層
2.50
這次比較幾次
–
亮起來的是這一步執行的程式碼
const MAX_LEVEL = 16; class SkipNode { next: (SkipNode | null)[]; constructor(public key: number, height: number) { this.next = new Array(height).fill(null); }} class SkipList { private head = new SkipNode(-Infinity, MAX_LEVEL); private level = 1; constructor(private random: () => number = Math.random) {} search(key: number): boolean { let node = this.head; for (let lv = this.level - 1; lv >= 0; lv--) { while (node.next[lv] && node.next[lv]!.key < key) { node = node.next[lv]!; } } const next = node.next[0]; return next !== null && next.key === key; } insert(key: number): void { const update: SkipNode[] = new Array(MAX_LEVEL).fill(this.head); let node = this.head; for (let lv = this.level - 1; lv >= 0; lv--) { while (node.next[lv] && node.next[lv]!.key < key) { node = node.next[lv]!; } update[lv] = node; } if (node.next[0]?.key === key) return; let height = 1; while (height < MAX_LEVEL && this.random() < 0.5) height++; this.level = Math.max(this.level, height); const fresh = new SkipNode(key, height); for (let i = 0; i < height; i++) { fresh.next[i] = update[i].next[i]; update[i].next[i] = fresh; } } remove(key: number): boolean { const update: SkipNode[] = new Array(MAX_LEVEL).fill(this.head); let node = this.head; for (let lv = this.level - 1; lv >= 0; lv--) { while (node.next[lv] && node.next[lv]!.key < key) { node = node.next[lv]!; } update[lv] = node; } const target = node.next[0]; if (!target || target.key !== key) return false; for (let i = 0; i < target.next.length; i++) { update[i].next[i] = target.next[i]; } return true; }}模型假設與範圍
- Big O 與實測步數以頁面列出的基本操作為單位;視覺化的快照、畫圖、程式高亮和輸出成本另計,不是執行時間 benchmark。
什麼時候用
- 需要保持排序、做範圍查詢,又想要比平衡樹簡單得多的實作:沒有旋轉,只有擲硬幣和改指標。
- 多執行緒同時讀寫:插入只改附近幾個指標,比會旋轉整棵子樹的平衡樹容易做成無鎖(lock-free)。
和其他主題的關係
- 由這些組成
- 鏈結串列
語言內建的版本
sorted Array<T> + binary searchJavaScript 沒有內建的跳躍串列,也沒有任何排序容器。小量資料用排序陣列加二分搜尋;插入要挪動後面的元素,是 O(n)。
| 操作 | 寫法 | 成本 |
|---|---|---|
| 找第一個 ≥ x 的位置 | lowerBound(keys, 25) | O(log n) |
| 插入並保持排序 | keys.splice(lowerBound(keys, 25), 0, 25) | O(n) |
| 最小值 | keys[0] | O(1) |
| 範圍查詢 | keys.slice(lowerBound(keys, 15), lowerBound(keys, 35)) | O(log n + k) |
// Index of the first element >= x (keys.length if none).const lowerBound = (a: number[], x: number) => { let lo = 0, hi = a.length; while (lo < hi) { const mid = (lo + hi) >> 1; if (a[mid] < x) lo = mid + 1; else hi = mid; } return lo;}; const keys = [10, 20, 30, 40];lowerBound(keys, 25); // → 2keys.splice(lowerBound(keys, 25), 0, 25);keys; // → [10, 20, 25, 30, 40]keys[0]; // → 10keys.slice(lowerBound(keys, 15), lowerBound(keys, 35)); // → [20, 25, 30]lowerBound(keys, 99); // → 5每個 → 後面的結果都是實際執行這段程式碼驗證過的。
- 跳躍串列真正派上用場的地方多半是在別的系統裡:Redis 的 sorted set(
ZADD、ZRANGE)、LevelDB/RocksDB 的 memtable。
時間與空間複雜度(Big O)
| 操作 | 平均 | 最差 |
|---|---|---|
| 查找 平均是期望值,靠擲硬幣;最差要每個塔都一樣高,機率小到可以忽略 | O(log n) | O(n) |
| 插入 | O(log n) | O(n) |
| 刪除 | O(log n) | O(n) |
| 依序走訪 沿著最下層走就好 | O(n) | O(n) |
空間:O(n),期望每個節點 2 個指標(1 + 1/2 + 1/4 + …)
Big O 實測:n 變大時步數怎麼長
數的是:找一個存在的鍵比較幾次(200 次取平均),以及每個節點的指標數
| Big O | n = 1,000 | n = 4,000 | n = 16,000 | 成長倍數:實測(理論) | |
|---|---|---|---|---|---|
| 跳躍串列 | O(log n) | 18.6 | 23.3 | 26.0 | ×1.4 (×1.4) |
| 只用最下層(排序好的鏈結串列) | O(n) | 477 | 1,887 | 7,815 | ×16 (×16) |
| 每個節點的指標數 | O(1) | 2.0 | 2.0 | 2.0 | ×1.0 (×1.0) |
同一份資料,有沒有上面那幾條快速道路的差別:n 變成 16 倍,跳躍串列只多比較幾次,最下層一路走則跟著變成 16 倍。代價是每個節點平均 2 個指標,而不是 1 個。
真實世界裡的它
- Redis 的 sorted set(ZSET)是跳躍串列加雜湊表:排行榜的
ZRANGE、ZRANK就是在走這些層。 - LevelDB 和 RocksDB 的 memtable 是跳躍串列(見系統設計的 LSM tree,Log-Structured Merge tree)。
- Java 的
ConcurrentSkipListMap。
取捨與陷阱
- O(log n) 是期望值:靠亂數,不是保證。層數上限(程式碼裡是 16)也決定了大約能有效處理多少資料(約 2¹⁶ 個以上就該加高)。
- 每個節點平均 2 個指標,再加上存指標陣列的開銷,記憶體比單純的排序陣列多。
- 沿著指標跳來跳去對 CPU 快取不友善:單執行緒、讀多寫少時,排序陣列或 B-tree 常常更快。