雜湊表(dict/set)
用雜湊函數把鍵直接算成陣列位置,查找平均只要一步——前提是雜湊函數把鍵分得夠散。
- 0bananacherry
- 1
- 2
- 3
- 4silent
- 5
- 6listen
- 7apple
每個鍵先算雜湊值,再對桶子數取餘數,就知道它住在哪一個桶子。換成「位元組相加」看看同樣的鍵會擠成什麼樣子。
圖示與三種語言都先把鍵編成 UTF-8(Unicode Transformation Format),再做 FNV-1a(取自設計者 Fowler、Noll、Vo 的姓氏)或位元組加總;中文和 emoji 也會得到相同位置。雜湊不做 Unicode 正規化,所以外觀相同、編碼不同的字串仍是不同的鍵。
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 |
| 加進 set | s.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"); // → 1m.get("zzz"); // → undefinedm.has("a"); // → truem.size; // → 2m.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; // → 3s.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]); // → undefinedconst obj: Record<string, number> = {};obj[1] = 10;obj["1"]; // → 10Object.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 O | n = 500 | n = 5,000 | n = 50,000 | 成長倍數:實測(理論) | |
|---|---|---|---|---|---|
| 查找一個存在的鍵(平均) | O(1) | 1.1 | 1.3 | 1.2 | ×1.1 (×1.0) |
| 擴容搬動的鍵,平均到每次插入 | O(1) | 1.5 | 1.2 | 2.0 | ×1.3 (×1.0) |
| 開放定址:查找存在的鍵(平均看幾格) | O(1) | 1.3 | 1.7 | 1.4 | ×1.1 (×1.0) |
鍵變成一百倍,找一個鍵還是比較一次多一點;開放定址因為維持在 2/3 以下,也只要看一兩格。擴容會把全部的鍵重新放一次,但因為每次都加倍,平均到每次插入只搬一到兩個鍵。
和其他做法比
| 桶子數 | 找一個鍵平均比較幾次 | 最長的串列 | 空桶子 | |
|---|---|---|---|---|
| FNV-1a | 2,048 | 1.17 | 3 | 1,208 |
| 位元組相加 | 2,048 | 23.91 | 70 | 1,993 |
把 user0、user1 … user999 共 1,000 個鍵放進同樣會自動擴容的表,再把每個鍵都找一次。兩者的負載因子一樣,差別全在雜湊函數:位元組相加讓 2,048 個桶子裡有 1,993 個是空的,鍵全擠在剩下的少數幾個。
| 鏈結法:找得到 | 開放定址:找得到(估計) | 鏈結法:找不到 | 開放定址:找不到(估計) | |
|---|---|---|---|---|
| 負載因子 0.25 | 1.07 | 1.09 (1.2) | 0.24 | 1.33 (1.4) |
| 負載因子 0.50 | 1.17 | 1.31 (1.5) | 0.58 | 2.35 (2.5) |
| 負載因子 0.67 | 1.18 | 1.50 (2.0) | 0.72 | 4.05 (5.0) |
| 負載因子 0.80 | 1.27 | 2.28 (3.0) | 0.84 | 9.08 (13.0) |
| 負載因子 0.90 | 1.31 | 5.45 (5.5) | 0.90 | 49.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),所以語言內建的雜湊通常會加隨機種子。