跳到主要內容

資料結構

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

主題 · 雜湊表(dict/set)

雜湊表(dict/set)

用雜湊函數把鍵直接算成陣列位置,查找平均只要一步——前提是雜湊函數把鍵分得夠散。

做法
雜湊函數
  1. 0
    bananacherry
  2. 1
  3. 2
  4. 3
  5. 4
    silent
  6. 5
  7. 6
    listen
  8. 7
    apple
這次的桶子/找到的鍵比較過但不是

每個鍵先算雜湊值,再對桶子數取餘數,就知道它住在哪一個桶子。換成「位元組相加」看看同樣的鍵會擠成什麼樣子。

圖示與三種語言都先把鍵編成 UTF-8(Unicode Transformation Format),再做 FNV-1a(取自設計者 Fowler、Noll、Vo 的姓氏)或位元組加總;中文和 emoji 也會得到相同位置。雜湊不做 Unicode 正規化,所以外觀相同、編碼不同的字串仍是不同的鍵。

鍵/桶子
5 / 8
負載因子
0.63
最長的串列
2
空桶子
4
亮起來的是這一步執行的程式碼
function fnv1a(key: string): number {
let h = 0x811c9dc5;
for (const byte of new TextEncoder().encode(key)) {
h ^= byte;
h = Math.imul(h, 0x01000193);
}
return h >>> 0;
}
function charSum(key: string): number {
let sum = 0;
for (const byte of new TextEncoder().encode(key)) {
sum += byte;
}
return sum;
}
class HashTable {
private buckets: string[][] = Array.from({ length: 8 }, () => []);
private count = 0;
constructor(private hash: (key: string) => number = fnv1a) {}
private bucketOf(key: string): string[] {
return this.buckets[this.hash(key) % this.buckets.length];
}
has(key: string): boolean {
for (const k of this.bucketOf(key)) {
if (k === key) return true;
}
return false;
}
add(key: string): void {
if (this.has(key)) return;
this.bucketOf(key).push(key);
this.count++;
if (this.count / this.buckets.length > 0.75) {
this.resize();
}
}
delete(key: string): void {
const bucket = this.bucketOf(key);
const i = bucket.indexOf(key);
if (i === -1) return;
bucket.splice(i, 1);
this.count--;
}
private resize(): void {
const old = this.buckets;
this.buckets = Array.from({ length: old.length * 2 }, () => []);
for (const bucket of old) {
for (const k of bucket) this.bucketOf(k).push(k);
}
}
}
const DELETED = Symbol("deleted");
class OpenHashTable {
private slots: (string | symbol | null)[] = new Array(8).fill(null);
private used = 0; // keys plus tombstones
private live = 0;
constructor(private hash: (key: string) => number = fnv1a) {}
slotOf(key: string): number {
let i = this.hash(key) % this.slots.length;
while (this.slots[i] !== null) {
if (this.slots[i] === key) return i;
i = (i + 1) % this.slots.length;
}
return -1;
}
has(key: string): boolean {
return this.slotOf(key) !== -1;
}
add(key: string): void {
let i = this.hash(key) % this.slots.length;
let reuse = -1;
while (this.slots[i] !== null) {
if (this.slots[i] === key) return;
if (this.slots[i] === DELETED && reuse === -1) reuse = i;
i = (i + 1) % this.slots.length;
}
if (reuse === -1) {
reuse = i;
this.used++;
}
this.slots[reuse] = key;
this.live++;
if (this.used / this.slots.length > 2 / 3) this.resize();
}
delete(key: string): void {
const i = this.slotOf(key);
if (i === -1) return;
this.slots[i] = DELETED;
this.live--;
}
private resize(): void {
const old = this.slots;
// Mostly live keys: double. Mostly tombstones: same size, swept clean.
const size = (this.live + 1) * 3 > old.length ? old.length * 2 : old.length;
this.slots = new Array(size).fill(null);
this.used = 0;
this.live = 0;
for (const k of old) {
if (typeof k === "string") this.add(k);
}
}
}

模型假設與範圍

  • Big O 與實測步數以頁面列出的基本操作為單位;視覺化的快照、畫圖、程式高亮和輸出成本另計,不是執行時間 benchmark。

什麼時候用

  • 用鍵(字串、ID)查找、新增、刪除,而且不在乎順序。
  • 判斷「看過沒有」:去重複、計數、快取。
  • set 和 dict 是同一個結構:set 只存鍵,dict 在每個鍵旁邊多存一個值。要的是「有沒有」用 set,要「鍵對應到什麼」用 dict。

和其他主題的關係

由這些組成
動態陣列

語言內建的版本

Map<K, V> · Set<T>
操作寫法成本
寫入m.set("b", 2)O(1) average
讀取m.get("a")O(1) average
有沒有m.has("a")O(1) average
刪除m.delete("a")O(1) average
加進 sets.add(3)O(1) average
聯集x.union(y)O(n + m)
交集x.intersection(y)O(min(n, m))
差集x.difference(y)O(n)
對稱差x.symmetricDifference(y)O(n + m)
const m = new Map<string, number>([["a", 1]]);
m.set("b", 2);
m.get("a"); // → 1
m.get("zzz"); // → undefined
m.has("a"); // → true
m.size; // → 2
m.delete("a"); // → true
[...m.keys()]; // → ["b"]
const counts = new Map<string, number>();
for (const w of ["to", "be", "or", "not", "to", "be"]) counts.set(w, (counts.get(w) ?? 0) + 1);
[...counts]; // → [["to", 2], ["be", 2], ["or", 1], ["not", 1]]
const s = new Set([1, 2, 2, 3]);
s.size; // → 3
s.add(3);
s.has(2); // → true
const x = new Set([1, 2, 3]);
const y = new Set([2, 3, 4]);
[...x.union(y)]; // → [1, 2, 3, 4]
[...x.intersection(y)]; // → [2, 3]
[...x.difference(y)]; // → [1]
[...x.symmetricDifference(y)]; // → [1, 4]
new Set([2, 3]).isSubsetOf(x); // → true
const keyed = new Map<number[], string>([[[1, 2], "p"]]);
keyed.get([1, 2]); // → undefined
const obj: Record<string, number> = {};
obj[1] = 10;
obj["1"]; // → 10
Object.keys(obj); // → ["1"]

每個 → 後面的結果都是實際執行這段程式碼驗證過的。

  • Set 的 union、intersection、difference、symmetricDifference、isSubsetOf 是 ES2025 才有的(Node 22 起、各大瀏覽器 2024 起)。執行沒問題,但 TypeScript 要在 lib 加 esnext 或 es2025 才認得。舊環境只能自己用 filter 寫:[...x].filter((v) => y.has(v))。
  • Map 和 Set 用「同一個物件」判斷鍵,不比內容:兩個 [1, 2] 是不同的鍵。要用座標之類當鍵,先轉成字串,例如 ${r},${c}。
  • 一般物件 {} 也能當字典,但鍵一律變成字串(obj[1] 和 obj["1"] 是同一格),還會撞到 toString 這類繼承來的名字。鍵是任意資料時用 Map。
  • Map 和 Set 都照插入順序走訪,這是規格保證的;LRU 快取主題就是靠這一點。

時間與空間複雜度(Big O)

操作平均最差
查找
最差是所有鍵都撞進同一個桶子
O(1)O(n)
插入
均攤 O(1):擴容那一次要重新雜湊每個鍵
O(1)O(n)
刪除O(1)O(n)
開放定址:查找
期望看 ½(1+1/(1−α)) 格;α 接近 1 時暴增,所以表維持在 2/3 以下
O(1)O(n)
依序走訪/範圍查詢
雜湊表沒有順序,得先全部拿出來排序
O(n log n)O(n log n)

空間:O(n),鏈結法每個鍵還要一個節點和指標;開放定址只有一排格子,但要留至少 1/3 空著

Big O 實測:n 變大時步數怎麼長

數的是:比較次數或搬動的鍵數(FNV-1a,鍵是 user0、user1、…)

Big On = 500n = 5,000n = 50,000成長倍數:實測(理論)
查找一個存在的鍵(平均)O(1)1.11.31.2×1.1 (×1.0)
擴容搬動的鍵,平均到每次插入O(1)1.51.22.0×1.3 (×1.0)
開放定址:查找存在的鍵(平均看幾格)O(1)1.31.71.4×1.1 (×1.0)

鍵變成一百倍,找一個鍵還是比較一次多一點;開放定址因為維持在 2/3 以下,也只要看一兩格。擴容會把全部的鍵重新放一次,但因為每次都加倍,平均到每次插入只搬一到兩個鍵。

和其他做法比

桶子數找一個鍵平均比較幾次最長的串列空桶子
FNV-1a2,0481.1731,208
位元組相加2,04823.91701,993

把 user0、user1 … user999 共 1,000 個鍵放進同樣會自動擴容的表,再把每個鍵都找一次。兩者的負載因子一樣,差別全在雜湊函數:位元組相加讓 2,048 個桶子裡有 1,993 個是空的,鍵全擠在剩下的少數幾個。

鏈結法:找得到開放定址:找得到(估計)鏈結法:找不到開放定址:找不到(估計)
負載因子 0.251.071.09 (1.2)0.241.33 (1.4)
負載因子 0.501.171.31 (1.5)0.582.35 (2.5)
負載因子 0.671.181.50 (2.0)0.724.05 (5.0)
負載因子 0.801.272.28 (3.0)0.849.08 (13.0)
負載因子 0.901.315.45 (5.5)0.9049.24 (50.5)

同樣 4,096 個位置、同樣的鍵,量找一個存在的鍵和確認一個鍵不存在各要比較幾次(鏈結法數串列裡比較的鍵,開放定址數看過的格子)。括號是 Knuth 對線性探測的估計 ½(1+1/(1−α)) 與 ½(1+1/(1−α)²);中段實測比估計好一些,因為估計假設雜湊完全隨機,而 FNV-1a 把 user0、user1… 分得更平均。表快滿時兩者一致:負載因子 0.90 時,開放定址確認一個鍵不存在平均要看 49 格,鏈結法不到 1 次。這就是 Python 的 dict 在 2/3 滿就擴容的原因。

真實世界裡的它

  • Python 的 dict 和 set 用開放定址(探測順序比線性更跳躍);Java 的 HashMap 用鏈結法,串列太長時換成紅黑樹;JavaScript 的 Map、Go 的 map 也都是雜湊表。
  • Python 3.7 起的 dict 會記住插入順序:它其實是兩個陣列,一個依插入順序緊密排列的「雜湊、鍵、值」陣列,加上一個稀疏的索引陣列,用開放定址找到條目在緊密陣列中的位置。
  • Redis 整個 key space 就是一個雜湊表;資料庫的 hash join 與 hash index。
  • 把資料分散到多台機器時也用雜湊決定放哪一台,見一致性雜湊。

取捨與陷阱

  • 雜湊函數不夠散時,負載因子再低也沒用:鍵還是擠在少數幾個桶子(試試上面的「位元組相加」,listen、silent、enlist 會撞在一起)。
  • 擴容時每個鍵都要重新雜湊一次,那一次插入會特別慢。
  • 開放定址刪除時不能直接清空格子,要留墓碑:否則後面經過這格才放下的鍵會再也找不到。墓碑也會拉長搜尋,所以累積多了要重建。
  • 不保留順序,也不能做範圍查詢(「找 100 到 200 之間的鍵」):那要用樹。
  • 對外服務若雜湊函數可預測,攻擊者能故意送會碰撞的鍵把服務拖慢(hash flooding),所以語言內建的雜湊通常會加隨機種子。

LeetCode 練習