跳到主要內容

資料結構

資料怎麼排,決定了哪些操作便宜

主題 · 跳躍串列

跳躍串列

在排序好的鏈結串列上隨機加幾層快速道路:查找平均只要 log n 步,而且不用像平衡樹那樣旋轉。

∅∅∅∅head0head1head2head3371317171717212121262626303030303838383844444450
現在的位置走過的路找到/接上拆掉

最下面一層就是一條排好序的鏈結串列。每個節點插入時擲硬幣決定塔有多高,所以大約一半的節點會出現在第 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 search

JavaScript 沒有內建的跳躍串列,也沒有任何排序容器。小量資料用排序陣列加二分搜尋;插入要挪動後面的元素,是 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); // → 2
keys.splice(lowerBound(keys, 25), 0, 25);
keys; // → [10, 20, 25, 30, 40]
keys[0]; // → 10
keys.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 On = 1,000n = 4,000n = 16,000成長倍數:實測(理論)
跳躍串列O(log n)18.623.326.0×1.4 (×1.4)
只用最下層(排序好的鏈結串列)O(n)4771,8877,815×16 (×16)
每個節點的指標數O(1)2.02.02.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 常常更快。

LeetCode 練習