哈希表

散列表（Hash table）是根據鍵而直接訪問在記憶體儲存位置的數據結構。也就是說，它通過計算出一個鍵值的函數，將所需查詢的數據映射到表中一個位置來讓人訪問，這加快了查找速度。這個映射函數稱做散列函數，存放記錄的陣列稱做散列表。

一個通俗的例子是，為了查找電話簿中某人的號碼，可以創建一個按照人名首字母順序排列的表（即建立人名$x$到首字母$F(x)$的一個函數關係），在首字母為W的表中查找「王」姓的電話號碼，顯然比直接查找就要快得多。這裡使用人名作為關鍵字，「取首字母」是這個例子中散列函數的函數法則$F()$，存放首字母的表對應散列表。關鍵字和函數法則理論上可以任意確定。

## 基本概念

- 若關鍵字為$k$，則其值存放在$f(k)$的存儲位置上。由此，不需比較便可直接取得所查記錄。稱這個對應關係$f$為散列函數，按這個思想建立的表為散列表。
- 對不同的關鍵字可能得到同一散列地址，即$k_1 \neq k_2$，而$f(k_1)=f(k_2)$，這種現象稱為衝突（Collision）。具有相同函數值的關鍵字對該散列函數來說稱做同義詞。綜上所述，根據散列函數$f(k)$和處理衝突的方法將一組關鍵字映射到一個有限的連續的地址集（區間）上，並以關鍵字在地址集中的「像」作為記錄在表中的存儲位置，這種表便稱為散列表，這一映射過程稱為散列造表或散列，所得的存儲位置稱散列地址。
- 若對於關鍵字集合中的任一個關鍵字，經散列函數映象到地址集合中任何一個地址的機率是相等的，則稱此類散列函數為均勻散列函數（Uniform Hash function），這就使關鍵字經過散列函數得到一個「隨機的地址」，從而減少衝突。

## 構造散列函數

散列函數能使對一個數據序列的訪問過程更加迅速有效，通過散列函數，數據元素將被更快定位。

- 直接定址法：取關鍵字或關鍵字的某個線性函數值為散列地址。即$hash(k)=k$或$hash(k)=a\cdot k + b$，其中$a\,b$為常數（這種散列函數叫做自身函數）
- 數字分析法：假設關鍵字是以r為基的數，並且哈希表中可能出現的關鍵字都是事先知道的，則可取關鍵字的若干數位組成哈希地址。
- 平方取中法：取關鍵字平方後的中間幾位為哈希地址。通常在選定哈希函數時不一定能知道關鍵字的全部情況，取其中的哪幾位也不一定合適，而一個數平方後的中間幾位數和數的每一位都相關，由此使隨機分布的關鍵字得到的哈希地址也是隨機的。取的位數由表長決定。
- 摺疊法：將關鍵字分割成位數相同的幾部分（最後一部分的位數可以不同），然後取這幾部分的疊加和（捨去進位）作為哈希地址。
- 隨機數法
- 除留餘數法：取關鍵字被某個不大於散列表表長m的數p除後所得的餘數為散列地址。即$hash(k)=k \,\bmod \,p$, $p\le m$。不僅可以對關鍵字直接取模，也可在摺疊法、平方取中法等運算之後取模。對p的選擇很重要，一般取素數或m，若p選擇不好，容易產生衝突。

## 處理衝突

為了知道衝突產生的相同散列函數地址所對應的關鍵字，必須選用另外的散列函數，或者對衝突結果進行處理。而不發生衝突的可能性是非常之小的，所以通常對衝突進行處理。常用方法有以下幾種：

- 開放定址法（open addressing）：$hash_i=(hash(key)+d_i) \,\bmod\, m$, $i=1,2...k\,(k \le m-1)$，其中$hash(key)$為散列函數，$m$為散列表長，$d_i$為增量序列，$i$為已發生衝突的次數。增量序列可有下列取法：

$d_i=1,2,3...(m-1)$稱為線性探測（Linear Probing）；即$d_i=i$，或者為其他線性函數。相當於逐個探測存放地址的表，直到查找到一個空單元，把散列地址存放在該空單元。

$d_i=\pm 1^2, \pm 2^2,\pm 3^2...\pm k^2$ $(k \le m/2)$稱為 平方探測(Quadratic Probing)。相對線性探測，相當於發生衝突時探測間隔$d_i=i^2$個單元的位置是否為空，如果為空，將地址存放進去。

$d_i=$偽隨機數序列，稱為 偽隨機探測。

顯示線性探測填裝一個散列表的過程：

關鍵字為{89,18,49,58,69}插入到一個散列表中的情況。此時線性探測的方法是取$d_i=i$。並假定取關鍵字除以10的餘數為散列函數法則。

散列地址｜空表｜插入89｜插入18｜插入49｜插入58｜插入69

0｜49｜49｜49

1｜58｜58

2｜69

3 4 5 6 7

8｜18｜18｜18｜18

9｜89｜89｜89｜89｜89

第一次衝突發生在填裝49的時候。地址為9的單元已經填裝了89這個關鍵字，所以取$i=1$，往下查找一個單位，發現為空，所以將49填裝在地址為0的空單元。第二次衝突則發生在58上，取$i=3$，往下查找3個單位，將58填裝在地址為1的空單元。69同理。

表的大小選取至關重要，此處選取10作為大小，發生衝突的機率就比選擇質數11作為大小的可能性大。越是質數，mod取余就越可能均勻分布在表的各處。

聚集（Cluster，也翻譯做「堆積」）的意思是，在函數地址的表中，散列函數的結果不均勻地占據表的單元，形成區塊，造成線性探測產生一次聚集（primary clustering）和平方探測的二次聚集（secondary clustering），散列到區塊中的任何關鍵字需要查找多次試選單元才能插入表中，解決衝突，造成時間浪費。對於開放定址法，聚集會造成性能的災難性損失，是必須避免的。

- 單獨鍊表法：將散列到同一個存儲位置的所有元素保存在一個鍊表中。實現時，一種策略是散列表同一位置的所有衝突結果都是用棧存放的，新元素被插入到表的前端還是後端完全取決於怎樣方便。
- 雙散列。
- 再散列：$hash_i = hash_i (key)$, $i=1,2...k$。$hash_i$是一些散列函數。即在上次散列計算發生衝突時，利用該次衝突的散列函數地址產生新的散列函數地址，直到衝突不再發生。這種方法不易產生「聚集」（Cluster），但增加了計算時間。
- 建立一個公共溢出區。

## 例程

在C語言中，實現以上過程的簡要程序：

```c
// HashTable
InitializeTable(int TableSize) {
    HashTable H;
    int i;
    // 為散列表分配空間
    // 有些编譯器不支持為struct HashTable 分配空間，聲稱這是一個不完全的結構。
    // 可使用一个指向HashTable的指針為之分配空間。
    // 如：sizeof(Probe)，Probe作为HashTable在typedef定義的指針。
    H = malloc(sizeof(struct HashTable));
    // 散列表大小为一个質数
    H->TableSize = Prime;
    // 分配表所有地址的空間
    H->Cells = malloc(sizeof(Cell) * H->TableSize);
    // 地址初始為空
    for (i = 0; i < H->TableSize; i++)
        H->Cells[i].info = Empty;
    return H;
}
```

查找空單元並插入：

```c
// Position
Find(ElementType Key, HashTable H) {
    Position Current;
    int CollisionNum;
    // 冲突次数初始为0
    // 通過表的大小對關鍵字進行處理
    CollisionNum = 0;
    Current = Hash( Key, H->TableSize );
    // 不為空時進行查詢
    while (H->Cells[Current].info != Empty &&
            H->Cells[Current].Element != Key) {
        Current = ++CollosionNum * ++CollisionNum;
        // 向下查找超過表範圍時回到表的開頭
        if (Current >= H->TableSize)
            Current -= H->TableSize;
    }
    return Current;
}
```

- 分離連接法

## 查找效率

散列表的查找過程基本上和造表過程相同。一些關鍵碼可通過散列函數轉換的地址直接找到，另一些關鍵碼在散列函數得到的地址上產生了衝突，需要按處理衝突的方法進行查找。在介紹的三種處理衝突的方法中，產生衝突後的查找仍然是給定值與關鍵碼進行比較的過程。所以，對散列表查找效率的量度，依然用平均查找長度來衡量。

查找過程中，關鍵碼的比較次數，取決於產生衝突的多少，產生的衝突少，查找效率就高，產生的衝突多，查找效率就低。因此，影響產生衝突多少的因素，也就是影響查找效率的因素。影響產生衝突多少有以下三個因素：

- 散列函數是否均勻。
- 處理衝突的方法。
- 散列表的載荷因子（load factor）。

### 載荷因子

散列表的載荷因子定義為：$\alpha$ = 填入表中的元素個數 / 散列表的長度

$\alpha$是散列表裝滿程度的標誌因子。由於表長是定值，$\alpha$與「填入表中的元素個數」成正比，所以，$\alpha$越大，表明填入表中的元素越多，產生衝突的可能性就越大；反之，$\alpha$越小，標明填入表中的元素越少，產生衝突的可能性就越小。實際上，散列表的平均查找長度是載荷因子$\alpha$的函數，只是不同處理衝突的方法有不同的函數。

對於開放定址法，荷載因子是特別重要因素，應嚴格限制在0.7-0.8以下。超過0.8，查表時的CPU緩存不命中（cache missing）按照指數曲線上升。因此，一些採用開放定址法的hash庫，如Java的系統庫限制了荷載因子為0.75，超過此值將resize散列表。

## 舉例：Linux內核的bcache

Linux作業系統在物理文件系統與塊設備驅動程序之間引入了「緩衝區緩存」（Buffer Cache，簡稱bcache）。當讀寫磁碟文件的數據，實際上都是對bcache操作，這大大提高了讀寫數據的速度。如果要讀寫的磁碟數據不在bcache中，即緩存不命中（miss），則把相應數據從磁碟加載到bcache中。一個緩存數據大小是與文件系統上一個邏輯塊的大小相對應的（例如1KiB字節），在bcache中每個緩存數據塊用`struct buffer_head`記載其元資訊：

```c
struct buffer_head {
    char *b_data;               // 指向缓存的数据块的指针
    unsigned long b_blocknr;    // 逻辑块号
    unsigned short b_dev;       // 设备号
    unsigned char b_uptodate;   // 缓存中的数据是否是最新的
    unsigned char b_dirt;       // 缓存中数据是否为脏数据
    unsigned char b_count;      // 这个缓存块被引用的次数
    unsigned char b_lock;       // b_lock表示这个缓存块是否被加锁
    struct task_struct *b_wait; // 等待在这个缓存块上的进程
    struct buffer_head *b_prev; // 指向缓存中相同hash值的下一个缓存块
    struct buffer_head *b_next; // 指向缓存中相同hash值的上一个缓存块
    struct buffer_head *b_prev_free; // 缓存块空闲链表中指向下一个缓存块
    struct buffer_head *b_next_free; // 缓存块空闲链表中指向上一个缓存块
};
```

整個bcache以`struct buffer_head`為基本數據單元，組織為一個封閉定址（close addressing，即「單獨鍊表法」解決衝突）的散列表`struct buffer_head * hash_table[NR_HASH];` 散列函數的輸入關鍵字是b_blocknr（邏輯塊號）與b_dev（設備號）。計算hash值的散列函數表達式為：

(b_dev ^ b_blocknr) % NR_HASH

其中NR_HASH是散列表的條目總數。發生「衝突」的`struct buffer_head`，以b_prev與b_next指針組成一個雙向（不循環）鍊表。bcache中所有的`struct buffer_head`，包括使用中不空閒與未使用空閒的`struct buffer_head`，以b_prev_free和b_next_free指針組成一個雙向循環鍊表free_list，其中未使用空閒的`struct buffer_head`放在該鍊表的前部。