data structure hash table hashing collision

[Data Structure] 雜湊表

雜湊表 (Hash Table) 是以「鍵 (Key)」直接定位「值 (Value)」的資料結構——給個 key,透過雜湊函式 (Hash Function) 直接算出該 key 所在的陣列位置(index),平均時間複雜度達 O(1)O(1),是實務上最常見的查詢結構。後面會用到的符號:nn(已放入的元素數)、mm(表的總 slot 數)、α=n/m\alpha = n/m(load factor,載入率)。

典型應用:字典、集合、資料庫索引、快取、編譯器符號表。

核心觀念

  • 底層是一個固定大小的陣列(桶 Bucket槽 Slot
  • 透過 index = hash(key) % table_size 決定資料該放哪個 slot
  • 讀、寫、刪除都只要一次 hash 運算 + 一次陣列存取,所以平均是 O(1)O(1)

雜湊函式 (Hash Function)

把任意 key 轉成固定範圍整數的函式,好的 hash 函式要滿足:

  • 確定性:同一個 key 每次算出的 hash 值要相同
  • 快速:計算成本要低(通常要求 O(1)O(1)
  • 均勻分布:不同 key 應盡量分散,避免大量碰撞

最常見的簡化做法是「除法 hash」:

h(k)=kmodmh(k) = k \bmod m

其中 mm 是表的大小,通常取質數以分散 key。

碰撞 (Collision)

不同的 key 經過 hash 函式後對應到同一個 index,稱為碰撞

  • 鴿籠原理 (Pigeonhole Principle) 保證:只要 key 的數量超過 slot 數,碰撞一定發生
  • 即使未超過,hash 函式分布不均也可能提前產生碰撞

解法分兩大陣營:鏈結法 (Chaining)開放定址法 (Open Addressing)


鏈結法 (Chaining)

每個 slot 指向一個串列(或其他容器),碰撞到的 key 都掛在同一個 slot 的串列上。

m=7m = 7h(k)=kmod7h(k) = k \bmod 7,依序插入 [15, 22, 8, 36, 29](這組 key 全部 hash 到 1,是最壞情況的示範):

Keyhashslot串列狀態
1511slot 1 → [15]
2211slot 1 → [15, 22]
811slot 1 → [15, 22, 8]
3611slot 1 → [15, 22, 8, 36]
2911slot 1 → [15, 22, 8, 36, 29]

最終表:

slot0123456
內容[15, 22, 8, 36, 29]

以下互動圖解重現上面這五筆 key 依序掛入 slot 1 的過程,點「下一步」可逐筆觀察串列如何越掛越長:

查詢時先算 hash 找到 slot,再在串列內逐一比對 key。以上表找 36

  1. hash(36) = 36 mod 7 = 1,進 slot 1
  2. 串列 [15, 22, 8, 36, 29] 逐一比對:15 ≠22 ≠8 ≠36 ✓ 命中(第 4 次比對)

找不存在的 10hash(10) = 3,slot 3 為空 → 直接回報不存在。

Java 實作(鏈結法)
import java.util.LinkedList;

public class HashTableChaining {
    private LinkedList<Entry>[] table;
    private int size;

    public HashTableChaining(int size) {
        this.size = size;
        this.table = new LinkedList[size];
        for (int i = 0; i < size; i++) table[i] = new LinkedList<>();
    }

    private int hash(int key) {
        return key % size;
    }

    public void put(int key, String value) {
        int idx = hash(key);
        for (Entry e : table[idx]) {
            if (e.key == key) { e.value = value; return; }
        }
        table[idx].add(new Entry(key, value));
    }

    public String get(int key) {
        for (Entry e : table[hash(key)]) {
            if (e.key == key) return e.value;
        }
        return null;
    }

    static class Entry {
        int key;
        String value;
        Entry(int k, String v) { key = k; value = v; }
    }
}

鏈結法的優點是實作直觀、不怕表被填滿;缺點是每個 slot 要多存一個串列指標,且 cache locality 較差。Java 的 HashMap 在 JDK 8 之後採用鏈結法,當單一 bucket 的串列長度超過門檻(預設 8)時,會把該串列升級成紅黑樹,以改善最壞情況效能。


開放定址法 (Open Addressing)

碰撞時不另外開串列,直接在表內找下一個可用的 slot。探測策略有三種主流做法。

以下互動圖解用同一組 key [15, 22, 8, 36, 29](全都 hash 到 slot 1)示範三種探測法的落點差異——點按鈕切換策略,琥珀色編號代表每次探測的順序:

線性探測 (Linear Probing)

碰撞後一次跳一格:h(k,i)=(h(k)+i)modmh(k, i) = (h(k) + i) \bmod m

同樣以 m=7m = 7h(k)=kmod7h(k) = k \bmod 7,依序插入 [15, 22, 8, 36, 29]

Key探測順序最終 slot
15slot 1 空1
22slot 1 被占 → slot 2 空2
8slot 1 → 2 被占 → slot 3 空3
36slot 1 → 2 → 3 被占 → slot 4 空4
29slot 1 → 2 → 3 → 4 被占 → slot 5 空5

最終表:

slot0123456
152283629

線性探測會產生主叢集 (Primary Clustering)——連續被占用的 slot 會越長越大,越後面插入的 key 平均探測次數越高。上例中 5 個 key 都擠在 1 ~ 5 這段,若要查詢不存在的 key(hash 到 1),還得一路探測到 slot 6 才能確認。

平方探測 (Quadratic Probing)

每次跳的距離變成平方:h(k,i)=(h(k)+i2)modmh(k, i) = (h(k) + i^2) \bmod m

變數 ii探測次數,從 00 開始;每次探測到的 slot 已被占用就 i+=1i \mathrel{+}= 1 再試下一個,直到找到空 slot 為止。i=0i = 0 代表第一次探測(即 h(k)h(k) 本身)。

同 key、同表:

Key探測順序最終 slot
15i=0i = 0:1 空1
22i=0i = 0:1 占 → i=1i = 1(1+1)=2(1+1) = 22
8i=0i = 0:1 占 → i=1i = 1:2 占 → i=2i = 2(1+4)=5(1+4) = 55
36i=0i = 0:1 占 → i=1i = 1:2 占 → i=2i = 2:5 占 → i=3i = 3(1+9)mod7=3(1+9) \bmod 7 = 33
29i=0i = 0:1 占 → i=1i = 1:2 占 → i=2i = 2:5 占 → i=3i = 3:3 占 → i4i \ge 4 循環回到已占 slot插入失敗

平方探測可以打散主叢集,但代價是:當表近滿時可能無法覆蓋所有 slot。以 m=7m = 7 為例,1+i2mod71 + i^2 \bmod 7i=06i = 0 \sim 6 只走過 1、2、5、3 這四個不同位置,碰到第 5 個 key 就卡住。實務上通常要求表大小為 4k+34k + 3 型的質數,並維持 load factor 0.5\le 0.5,才能保證探測序列遍歷整張表。

雙雜湊 (Double Hashing)

跳步長度由第二個 hash 函式決定:h(k,i)=(h1(k)+ih2(k))modmh(k, i) = (h_1(k) + i \cdot h_2(k)) \bmod m

h1(k)=kmod7h_1(k) = k \bmod 7h2(k)=1+(kmod5)h_2(k) = 1 + (k \bmod 5)h2h_2 不能為 0,否則會退化成線性探測):

Keyh1h_1h2h_2探測順序最終 slot
1511i=0i = 0:1 空1
2213i=0i = 0:1 占 → i=1i = 1(1+3)=4(1 + 3) = 44
814i=0i = 0:1 占 → i=1i = 1(1+4)=5(1 + 4) = 55
3612i=0i = 0:1 占 → i=1i = 1(1+2)=3(1 + 2) = 33
2915i=0i = 0:1 占 → i=1i = 1(1+5)=6(1 + 5) = 66

最終表:

slot0123456
153622829

雙雜湊相當於每個 key 都有自己的跳步節奏,分布最均勻、碰撞叢集最少,是三種開放定址法中效能最穩定的。代價是每次碰撞要多算一次 hash 函式。

三種探測法的比較

探測法跳步公式叢集程度探測涵蓋範圍
線性h(k)+ih(k) + i主叢集最嚴重保證走完整張表
平方h(k)+i2h(k) + i^2可分散主叢集,但仍有次叢集mm4k+34k+3 質數且 α0.5\alpha \le 0.5 才保證涵蓋
雙雜湊h1(k)+ih2(k)h_1(k) + i \cdot h_2(k)最均勻gcd(h2(k),m)=1\gcd(h_2(k), m) = 1 時涵蓋全表

時間複雜度

操作平均最差
查詢O(1)O(1)O(n)O(n)
插入O(1)O(1)O(n)O(n)
刪除O(1)O(1)O(n)O(n)
  • 平均情況假設 hash 均勻分布、load factor 控制得宜
  • 最差情況出現在所有 key 都 hash 到同一個 slot(鏈結法退化為鏈結串列、開放定址退化為線性搜尋)

負載因子與 Rehash

負載因子 (Load Factor) α=n/m\alpha = n/m(已放入元素數 ÷ 總 slot 數),衡量表有多擠:

  • α\alpha 越大 → 碰撞越頻繁 → 效能越差
  • 開放定址法通常要求 α0.75\alpha \le 0.75
  • 鏈結法允許 α>1\alpha > 1(串列可無限長),但超過後效能會快速下滑

α\alpha 超過門檻時觸發 Rehash

  1. 建立更大的新表(通常是原本的 2 倍,且保持質數)
  2. 逐一把舊表的每個 entry 重新 hash 到新表
  3. 釋放舊表

Rehash 單次成本是 O(n)O(n),但因為每插入 nn 個元素才會重建一次(等比擴張),插入操作的均攤時間複雜度 (Amortized Time) 仍是 O(1)O(1)

常見考法

#題型重點
1給一組 key + 表大小,手跑鏈結法每個 key 算 h(k)=kmodmh(k) = k \bmod m,同 slot 串成 list
2手跑線性探測碰撞後 slot +1+1,到空為止
3手跑平方探測i=0,1,2,3,i = 0, 1, 2, 3, \ldots,跳 i2i^2
4手跑雙雜湊跳步 =ih2(k)= i \cdot h_2(k)h2h_2 不能為 0
5查詢比對次數計算找某 key 最多比幾次?(串列法 = 串列長度;探測法 = 探測步數)
6主叢集 / 次叢集問題線性 → 主叢集;平方 → 次叢集(同 h(k)h(k) 的 key 探測序列相同)
7負載因子 α=n/m\alpha = n/m開放定址 α0.75\alpha \le 0.75;鏈結法可 >1> 1
8Rehash 時機 + 均攤等比擴張(× 2)→ 均攤 O(1)O(1)
9最壞情況為什麼退化所有 key 撞同 slot → 串列法 O(n)O(n)、探測法也 O(n)O(n)
10Hash 函式設計除法 h(k)=kmodmh(k) = k \bmod m質數 mm;避免 m=2km = 2^k

複雜度推導

  • 平均查詢 O(1)O(1)(鏈結法):α=n/m\alpha = n/m → 串列平均長度 α\alpha → 掃 α\alpha 個元素 = O(1+α)=O(1)O(1 + \alpha) = O(1)α\alpha 為常數)
  • 平均查詢 O(1)O(1)(線性探測):失敗平均探測數 12(1+1(1α)2)\approx \frac{1}{2}(1 + \frac{1}{(1-\alpha)^2})α=0.5\alpha = 0.5 時約 2.5 次
  • 均攤插入 O(1)O(1)nn 次插入總成本 =n+(1+2+4++n)=O(n)= n + (1 + 2 + 4 + \cdots + n) = O(n) → 每次均攤 O(1)O(1)

考試常踩的陷阱

  1. 平方探測不保證涵蓋全表mm 要是 4k+34k+3 型質數、α0.5\alpha \le 0.5 才保證
  2. 雙雜湊 h2(k)0h_2(k) \neq 0h2=0h_2 = 0 會讓探測永遠停在原地
  3. 載入因子 α\alpha 的分子是元素數:刪除元素後 α\alpha 會下降(但開放定址要用 tombstone 標記)
  4. 開放定址的刪除不能直接清空:會斷了後面 key 的探測鏈 → 要用 tombstone(墓碑標記)
  5. 最壞 O(n)O(n)O(logn)O(\log n):Java 8 的 HashMap 在 bucket 長度 ≥ 8 時轉紅黑樹才變 O(logn)O(\log n)

實務:JavaScript 的 Object / Map / Set

JS 內建三個 hash-table 類型,底層皆是 hash 表:

類型用途key 型別限制
Object最常見的字典,早期被當 map 用key 自動轉字串
MapES2015 引入,正統 key-value 容器任何型別(含物件)
Set只存 key、無 value 的集合任何型別

常見差異:

const o = {}
o[1] = 'a'
o['1'] = 'b'
console.log(o)           // \{ '1': 'b' \}  — 數字 key 被轉成字串,後面會覆蓋前面

const m = new Map()
m.set(1, 'a')
m.set('1', 'b')
console.log(m.size)      // 2 — key 型別嚴格區分

const s = new Set([1, 1, 2, '2'])
console.log(s.size)      // 3 — 數字 2 與字串 '2' 不同

MapSet 的查詢、插入、刪除皆為 O(1)O(1) 平均,且保留插入順序;Object 雖然也是 hash 表,但多了原型鏈與字串轉換等附帶邏輯,純 key-value 場景優先用 Map

Python 的 dictset、Java 的 HashMapHashSet、Go 的 map,底層原理都是 hash 表。不同語言對碰撞處理、rehash 策略、記憶體配置略有差異,但對使用者來說介面都類似——要查某個 key 有沒有、要建立 key 到 value 的對應,就用 hash 表。