Outline — 點擊展開各節
8.1 符號表 ADT
8.2 靜態雜湊
8.3 動態雜湊
回顧
Chapter 8 · Hashing

第 8 章 雜湊

「搜尋樹依賴識別字比較來完成搜尋,與這不同的是,雜湊依賴雜湊函數來完成。」—— 放棄順序性,換來與資料量無關的 $O(1)$ 存取。

本章的定位

第 7 章提供了一條路:把資料排序,然後用二分搜尋,$O(\log n)$。第 10 章會提供另一條:用平衡樹一直保持排序,也是 $O(\log n)$。

本章提供第三條路,而且它不走比較這條線:用一個函數直接把鍵值算成位址。理想情況下,「放入,刪除或搜尋識別字所需的時間與使用中的識別字個數 $n$ 無關;它是 $O(1)$。」代價是完全失去順序性 —— 無法做範圍查詢、無法依序輸出、最差狀況會退化到 $O(n)$。

8.1 符號表抽象資料型態

符號表上要支援的運算:(1) 判斷某一名稱是否在表中;(2) 擷取此名稱的屬性;(3) 修改此名稱的屬性;(4) 插入一個新的名稱和屬性;(5) 刪除一個名稱和屬性。

structure SymbolTable(SymTab) is
  objects: a set of name-attribute pairs, where the names are unique.
  functions:
    for all name in Name, attr in Attribute, symtab in SymbolTable,
    max_size in integer.

    SymTab    Create(max_size)          ::= create the empty symbol table
                                            whose maximum capacity is max_size.
    Boolean   IsIn(symtab, name)        ::= if (name is in symtab)
                                            return TRUE
                                            else return FALSE.
    Attribute Find(symtab, name)        ::= if (name is in symtab)
                                            return the corresponding attribute
                                            else return null attribute.
    SymTab    Insert(symtab, name, attr)::= if (name is in symtab)
                                            replace its existing attribute with attr
                                            else insert the pair (name, attr)
                                            into symtab.
    SymTab    Delete(symtab, name)      ::= if (name is not in symtab)
                                            return
                                            else delete (name, attr) from symtab.

end SymbolTable

結構 8.1:抽象資料型態 symbol table

只有三種基本運算

「結構 8.1 提供符號表 ADT 完整的定義。雖然結構 8.1 列出許多的運算,但在符號表上只有三種基本運算:搜尋,插入和刪除。因此,當選擇一種符號表的表示法時,我們必須能確定可以有效地完成這些運算。

例如,我們可以使用 5.7 節所介紹的二元搜尋樹來表示符號表。假設我們的搜尋樹有 $n$ 個識別字,這些運算最差狀況的複雜度將是 $O(n)$。在第 10 章中,我們將介紹許多二元搜尋樹的改進,使其可以減少每個運算的時間到 $O(\log n)$本章中,我們將討論關於搜尋,插入和刪除運算的一種技巧,它有非常好的效率。

8.2 靜態雜湊

8.2.1 雜湊表

基本結構與名詞

「在靜態雜湊(static hashing)中,識別字儲存於固定大小的表格中,稱為雜湊表(hash table)。我們以一個數學函數 $f$ 來決定識別字 $x$ 在表中的位址或位置。因此,$f(x)$ 代表 $x$ 在表中的位址。」

名詞定義
桶 bucket雜湊表 ht 分割成 $b$ 個桶 ht[0], …, ht[b-1]
槽 slot每個桶有 $s$ 槽。通常 $s=1$,表示每個桶可以儲存一個記錄
識別字密度$n/t$,其中 $n$ 為表中的識別字數目,$t$ 為可能的識別字全部個數
裝載密度/裝載因子$\alpha = \dfrac{n}{sb}$
同義字 synonyms對於函數 $f$,兩個識別字 $i_1$ 和 $i_2$,如果 $f(i_1)=f(i_2)$,則它們為同義字
溢位 overflow將一個新的識別字 $i$ 對應到一個已滿的桶中
碰撞 collision將兩個不同的識別字對應到相同的桶

「如果桶的大小為 1,則溢位和碰撞同時發生。」這是最常考的一句話。

為什麼一定會有碰撞

「如果我們限制識別字的長度為 6 個字元,其第一個字元必須是英文字母,其餘的可以是字母或數字,則對 $x$ 有」

$$T = \sum_{i=0}^{5} 26 \times 36^i \gt 1.6 \times 10^9 \text{ 個不同的可能值}$$

「但是,任何合理的應用程式不會有這麼多的識別字。因為雜湊表中桶的個數 $b$ 通常是遠小於可能的識別字之全部個數 $t$,所以雜湊函數必定會將許多不同的識別字對應到相同的桶中。

範例 8.1 $b=26$、$s=2$ 的雜湊表

有 $n=10$ 個不同的識別字,每一個代表 C 的程式庫函數。此表的裝載因子 $\alpha$ 為 $10/52 = 0.19$。雜湊函數定義為 $f(x)$ 為 $x$ 的第一個字元(a–z 對應 0–25)。

程式庫函數 acos, define, float, exp, char, atan, ceil, floor, clock, ctime 分別對到 0, 3, 5, 4, 2, 0, 2, 5, 2, 2 號桶。

Slot 0Slot 1
0acosatan
1
2charceil
3define
4exp
5floatfloor
6
25

圖 8.1:具有 26 桶,每桶有 2 個槽的雜湊表

「識別字 acosatan 為同義字,floatfloorceilchar 亦同。下一個識別字 clock 被雜湊到桶 ht(2)。因此桶已滿,所以造成溢位。我們應將 clock 放到表中何處,使得在我們需要它時仍可擷取到它?在 8.2.3 和 8.2.4 節中,將討論溢位問題的不同解法。」

這個雜湊函數為什麼不好

「在範例 8.1 中雜湊函數的選擇並不適用於大多數的應用,因為碰撞和溢位的次數太多了。例如,我們已知許多的 C 函數都以相同的字母開頭,變數名稱也是如此。在理想條件下,我們希望選擇一個雜湊函數計算既簡單,所產生的碰撞也很少。不幸地,因 $b/t$ 的比值太小,所以不可能完全避免碰撞情形。」

8.2.2 雜湊函數

均勻雜湊函數 (uniform hash function)

「我們知道識別字代表程式中的變數名稱,字典中的單字,或者電話簿中的姓名,它們是叢集(cluster)於某些英文字母的。為了避免碰撞,雜湊函數應該和識別字中所有的字元相關。它也應該是無偏差的。

定義:如果從識別字空間(所有可能的識別字之集合)隨機地取出一個識別字 $x$,對所有的桶 $i$,其 $f(x)=i$ 的機率為 $1/b$,則稱此雜湊函數為均勻雜湊函數。「表示一隨機選擇的 $x$ 有相同的機會被雜湊到 $b$ 個桶中的任一位置。」

四種均勻雜湊函數

1. 平方後取中間位數 (mid-square)

「$f_m$ 的計算是將識別字平方後,再由平方值中間取適當的位元數當成桶的位址。因平方值的中間位元通常和識別字中所有的字元有關,所以不同的識別字產生不同的雜湊位址機率非常高,即使其中有些字元為相同的也是如此。」

「如果採用 $r$ 位元,則其值範圍是 $2^r$。因此,使用這個方法時,雜湊表的大小應是 2 的冪次方。

2. 除法 (division)
$$f_D(x) = x \bmod M$$

「由此獲得的桶位址介於 0 到 $M-1$,其中 $M$ 是表的大小。$M$ 的選擇是非常重要的。

3. 折疊 (folding)

「將識別字 $x$ 分割成許多部份。除了最後一部份,其餘均有相同的長度。然後將各部份相加以求得 $x$ 的雜湊位址。」
移位折疊:各部份最低有效位元對齊後相加。
邊界折疊:「在相加之前每隔一個部份即反置另一個部份。

4. 數位分析法 (digit analysis)

可用於靜態檔案中。所謂靜態檔案(static file)是所有識別字均事先知道的檔案。……檢查每個識別字的各個數位,刪除那些分佈最集中的數位。持續刪除數位直到剩下的數值小到足以代表雜湊表範圍中的數位。」

折疊的具體計算

將識別字 $x$ 分成:$x_1 = 123$、$x_2 = 203$、$x_3 = 241$、$x_4 = 112$、$x_5 = 20$。

方法做法結果
移位折疊$123+203+241+112+20$699
邊界折疊將第二個和第四個部份反置,即 $x_2 = 302$ 且 $x_4 = 211$,然後相加:$123+302+241+211+20$897

為什麼除法的 $M$ 要取質數 —— 課本的完整推導

第一個陷阱:$M$ 為 2 的冪次

在除法中,如果 $M$ 為 2 的次方,則 $f_D(x)$ 只和 $x$ 的最低有效位元有關。這樣選擇的 $M$,在數個所使用的識別字有相同的字尾時,將造成雜湊表的偏差使用。如果 $M$ 可被 2 整除,則奇數鍵值對應到奇數桶,偶數鍵值對應到偶數桶。因此,當大多數的識別字是偶數,或大多數是奇數時,偶數的 $M$ 會造成雜湊表的偏差使用。

第二個陷阱:小質因數 —— 課本的代數證明

令 $X = x_1x_2$ 和 $Y = x_2x_1$ 為兩個包含字元 $x_1$ 和 $x_2$ 的識別字。如果 $x_1$ 的內部二進制表示法之值為 $C(x_1)$,$x_2$ 為 $C(x_2)$,且每一個字元以 6 個位元表示,則 $X$ 的值為 $2^6C(x_1)+C(x_2)$,而 $Y$ 的值為 $2^6C(x_2)+C(x_1)$。如果 $M$ 除以質數 $p$,則:

$$(f_D(X) - f_D(Y)) \bmod p = \big(2^6C(x_1)\bmod p + C(x_2)\bmod p - 2^6C(x_2)\bmod p - C(x_1)\bmod p\big)\bmod p$$

如果 $p = 3$,則因 $64 \bmod 3 = 1$:

$$\begin{aligned} (f_D(X)-f_D(Y))\bmod 3 &= \big(64\bmod 3\,C(x_1)\bmod 3 + C(x_2)\bmod 3\\ &\qquad - 64\bmod 3\,C(x_2)\bmod 3 - C(x_1)\bmod 3\big)\bmod 3\\ &= C(x_1)\bmod 3 + C(x_2)\bmod 3 - C(x_2)\bmod 3 - C(x_1)\bmod 3\\ &= 0 \bmod 3 \end{aligned}$$

「亦即由相同的字元集合排列而產生的識別字,經雜湊後對應到表中的位址,其相隔距離為 3 的倍數。所以,當有多數的識別字互為排列時,也會造成雜湊表偏差使用。這是因為 $64 \bmod 3 = 1$。這種情形對 7 也會發生,因 $64 \bmod 7 = 1$。

結論:$M$ 該怎麼選

「為避免上述問題可選擇 $M$ 為質數。則 $M$ 的因子只有 $M$ 和 1。Knuth 曾証明當 $M$ 可以整除 $r^k \pm a$,其中 $k$ 和 $a$ 之值很小,且 $r$ 為字元集的基數(如上例 $r=64$),則 $x \bmod M$ 趨近於 $x$ 中字元的簡單重合。因此,$M$ 的最佳選擇是:$M$ 為不能整除 $r^k \pm a$ 的質數,其中 $k$ 和 $a$ 值很小。

事實上,經驗告訴我們,選擇沒有小於 20 的質因數的 $M$ 就足夠了。

8.2.3 溢位處理

「在靜態雜湊表中有兩種方法可偵測碰撞和溢位的發生;每種方法使用不同的資料結構來表示雜湊表。本節我們要討論最簡單的方法,稱為線性開放定址(linear open addressing)線性探測(linear probing),下一節則介紹鏈結串列(chaining)。」

線性開放定址 (Linear Open Addressing)

int transform(char *key)
{
/* simple additive approach to create a natural number
that is within the integer range */
   int number = 0;
   while (*key)
      number += *key++;
   return number;
}

int hash(char *key)
{
/* transform key to a natural number, and return this
result modulus the table size */
   return(transform(key) % TABLE_SIZE);
}

程式 8.2:建立一個雜湊函數

識別字加法轉換$x$雜湊值
for$102 + 111 + 114$3272
do$100 + 111$2113
while$119 + 104 + 105 + 108 + 101$5374
if$105 + 102$20712
else$101 + 108 + 115 + 101$4259
function$102+117+110+99+116+105+111+110$87012

圖 8.2:加法轉換(13 個桶,每桶一槽)—— 最後一個識別字 function 和 if 雜湊到相同的桶中。利用循環轉動,下一個可用的桶為 ht[0],就是我們要放 function 的位置。

void linear_insert(element item, element ht[])
{
/* insert the key into the table using the linear probing
technique, exit the function if the table is full */
   int i, hash_value;
   hash_value = hash(item.key);
   i = hash_value;
   while (strlen(ht[i].key)) {
      if (!strcmp(ht[i].key, item.key)) {
         fprintf(stderr,"Duplicate entry\n");
         exit(1);
      }
      i = (i+1) % TABLE_SIZE;
      if (i == hash_value) {
         fprintf(stderr,"The table is full\n");
         exit(1);
      }
   }
   ht[i] = item;
}

程式 8.3:線性插入一個雜湊表中 —— 注意 i == hash_value 這個「繞了一圈回到原點」的滿載偵測

叢集 (clustering) —— 線性探測的致命問題

「我們稍早使用的例子顯示出當我們使用線性探測來解決溢位時,識別字傾向於叢聚在一起。此外,相鄰的叢聚(cluster)也傾向於合併在一起,如此會增加搜尋時間。

課本的例子:將 acos, atoi, char, define, exp, ceil, cos, float, atol, floor, ctime 依序加入有 26 桶的雜湊表(雜湊函數取首字元)。

$x$搜尋次數
0acos1
1atoi2
2char1
3define1
4exp1
5ceil4
6cos5
7float3
8atol9
9floor5
10ctime9

圖 8.4:使用線性探測的雜湊表(26 桶,1 槽/桶)

「應注意,在我們可以插入 atol 之前,必須檢查 ht[0], …, ht[8],總共 9 次比較。這比我們在第 10 章中要研討的搜尋樹之最差狀況行為還差得多。

線性探測的平均探測次數

「如果我們擷取 ht 中的每一識別字恰好一次,則平均的檢查次數為每個識別字 $35/11 = 3.18$。線性探測法的分析顯示尋找一個識別字所需的預期識別字比較之平均次數 $p$ 大約是:

$$p \approx \frac{2-\alpha}{2-2\alpha}$$

其中 $\alpha$ 是裝載密度。在上例中 $\alpha = 11/26 = 0.42$,而 $p = 1.36$。「這指出裝載密度為 .42 的平均探測次數為 1.36。因此我們知道平均的探測次數雖然很小,最差狀況可以是很大的。

二次探測與重複雜湊

二次探測 (quadratic probing)

「我們已經知道線性開放定址會造成識別字的叢聚。當我們加入更多的識別字到表中時,這些聚有合併的傾向,而形成更大的叢聚。藉著使用二次探測(quadratic probing),我們可以部份地縮小叢聚的成長,並減少平均的探測次數。

方法探測序列
線性探測$(f(x)+i)\bmod b$,$0 \le i \le b-1$
二次探測$f(x)$,$(f(x)+i^2)\bmod b$ 和 $(f(x)-i^2)\bmod b$,$1 \le i \le (b-1)/2$

當 $b$ 是 $4j+3$,$j$ 為整數,型式的質數時,上述的二次搜尋方法將檢查表中的每一個桶。

質數$j$質數$j$
304310
715914
11212731
19425162
235503125
3171019254

圖 8.5:一些型式為 $4j+3$ 的質數

另外兩種方法

重複雜湊(rehashing):「我們也可以藉著使用一連串的雜湊函數 $f_1, f_2, \ldots, f_b$ 來減少發生於線性探測中的叢聚。……我們檢查桶 $f_i(x)$,$1 \le i \le b$。」
隨機探測(random probing):「第三種解決桶溢位的方法……將在習題中討論。」

鏈結串列 (chaining)

為什麼鏈結法更好

線性探測和其改良方法的執行效率都不好,因為插入一個識別字需要與不同雜湊值的識別字作比較。例如,在圖 8.4 雜湊表中,在我們可以插入 atal 之前,我們必須檢查桶 ht[0]ht[8]即使只有前兩個識別字和 atol 碰撞;其餘的不可能和 atol 在同一個桶中。

如果對每一桶給予一個同義字串列,我們可以減少大部份的比較。要插入一個新元素,我們只需要計算雜湊位址 $f(x)$,並檢查 $f(x)$ 的串列中之識別字即可,因為我們不能預先知道串列的大小,我們將以鏈結串列實作之。」

#define MAX_CHAR 10          /* max number of characters */
#define TABLE_SIZE 13        /* max number of hash buckets */
#define IS_FULL(ptr) (!(ptr))
typedef struct list *list_pointer;
typedef struct list {
        char key[MAX_CHAR];
        /* other fields */
        list_pointer link;
        };
list_pointer hash_table[TABLE_SIZE];

因為有 $M$ 個串列,$M$ 為所需要的表大小,我們在每一串列之前加上一個標頭節點。這些標頭節點僅需要一個鏈結欄位,所以比其他的節點還小。我們保持標頭節點以升冪順序 $0, \ldots, m-1$ 排列,使得我們可以隨機存取串列。

chain_insert 的行為

「函數 chain_insert(程式 8.4)實作這種鏈結策略。函數首先計算識別字的雜湊位址。然後檢查選定的桶的串列中之識別字。如果找到此識別字,印出錯誤訊息並結束。如果識別字不在串列中,將其加入串列的尾端。如果串列是空的,改變標頭節,使其指向新的項目。

同一組資料改用鏈結法
[0]→ acos → atoi → atol
[1]NULL
[2]→ char → ceil → cos → ctime
[3]→ define
[4]→ exp
[5]→ float → floor
[6]NULL
[25]NULL

圖 8.6:相對於圖 8.4 之雜湊鏈結

識別字探測次數
acos, char, define, exp, float各為 1
atoi, ceil, floor各為 2
atol, cos各為 3
ctime4
平均21/11 = 1.91(線性探測為 3.18)

「對鏈結表的識別字比較之期待次數為 $\sim 1 + \alpha/2$,其中 $\alpha$ 為裝載密度 $n/b$($b$ = 標頭節點之個數)。對 $\alpha = 0.42$,探測的預期次數為 1.21;對 $\alpha = 1$,它大約是 1.5。

兩種溢位處理的公式對照
$$\text{線性探測:}\ p \approx \frac{2-\alpha}{2-2\alpha} \qquad\qquad \text{鏈結法:}\ p \approx 1 + \frac{\alpha}{2}$$

差別有多大:當 $\alpha \to 1$,線性探測的 $\frac{2-\alpha}{2-2\alpha} \to \infty$(分母趨近 0),而鏈結法只到 1.5。這就是「鏈結效率比線性開放定址效率好」的數學根源。

8.2.4 溢位處理技術的理論評估

Hash Function$\alpha = .50$$.75$$.90$$.95$
ChainOpenChainOpenChainOpenChainOpen
mid square1.261.731.409.751.4537.141.4737.53
division1.194.521.317.201.3822.421.4125.79
shift fold1.3321.751.4865.101.4077.011.51118.57
bound fold1.3922.971.5748.701.5569.631.5197.56
digit analysis1.354.551.4930.621.5289.201.52125.59
theoretical1.251.501.372.501.455.501.4810.50

圖 8.7:擷取每一識別字的桶存取之平均次數(摘自 V. Lum, P. Yuen 和 M. Dodd, CACM, 1971, Vol. 14, No. 4)—— 各欄值為搜尋八個不同雜湊表的平均存取桶的次數,該八個雜湊表分別有 33,575、24,050、4909、3072、2241、930、762 和 500 個識別字。

這張表的三個結論
  1. 正如預期的,鏈結效率比線性開放定址效率好。」(Chain 那幾欄全部在 1.2–1.6 之間,Open 則飆到 100 以上。)
  2. 檢視各種不同雜湊函數的效率,我們可以發現除法比其他函數為佳。因此,在一般應用上,除法是較受人喜歡的方法。除數應該為質數,但選擇沒有小於 20 的質因素之除數也是足夠的了。
  3. 「值得注意的是,這個表格也提供了以隨機鍵值為基礎時桶的存取次數之理論期望值。」(最後一列 theoretical,實測值與理論值的差距正是「真實的識別字不是隨機的」所造成。)
雜湊的最差狀況

「雜湊技巧的實驗評估指出它們通常優於傳統技巧,如二元搜尋樹。不過,雜湊的最佳狀況效率可能非常的差。在最差狀況下,插入新元素到有 $n$ 個識別字的雜湊表中需時 $O(n)$。

為什麼:若所有識別字都雜湊到同一個桶,鏈結法退化成一條長度 $n$ 的鏈結串列(線性探測則退化成掃描整個表)。這正是第 10 章平衡樹相對於雜湊的價值:平衡樹保證最差 $O(\log n)$,雜湊只保證平均 $O(1)$。

為什麼「實際上識別字不是隨機的」

「本節和上一節的結果建議雜湊表的效率僅取決於用來處理溢位的方法,即鏈結或線性探測。只要使用均勻雜湊函數,效率與雜湊函數是無關的。雖然在我們隨機地從識別字空間選擇識別字時,這是真的,實際上它並不成立。事實上我們對識別字的選擇是有偏差的,因為我們經常使用有相同的字首或字尾的識別字,或者,是其他識別字的簡單排列之識別字。因此,實際上雜湊函數的選擇會影響雜湊表的效率是可預期的。

習題 1 — 雜湊函數(8.2 節習題)
  1. 用移位折疊與邊界折疊分別計算識別字 $x$ 的雜湊位址,其中 $x$ 被分割成 $x_1=123$、$x_2=203$、$x_3=241$、$x_4=112$、$x_5=20$。
  2. 令識別字 $x$ 的二進制表示法為 $x_1x_2$。令 $|x|$ 代表 $x$ 中的位元個數,且 $x_1$ 的第 1 個位元為 1。令 $|x_1| = \lceil |x|/2 \rceil$ 且 $|x_2| = \lfloor |x|/2 \rfloor$。考慮下列的雜湊函數:
    $$f(x) = (x_1 \text{ XOR } x_2) \text{ 的中間 } K \text{ 個位元}$$
    如果識別字由 C 所容許的識別字空間中任意地取出,此函數是否為一個均勻函數?在實際的符號表應用中,說明此雜湊函數的行為如何?
  3. 設計一個演算法,依照字母先後順序列出雜湊表中所有的識別字。假設雜湊函數 $f$ 為 $f(x)=x$ 的第一個字元,且使用線性探測。你的演算法需要多少時間?
點擊展開解題要點

第 1 題:移位折疊 $= 123+203+241+112+20 = \mathbf{699}$;邊界折疊(反置第 2、4 部份)$= 123+302+241+211+20 = \mathbf{897}$。

第 2 題:「從整個識別字空間隨機取出」時,XOR 的每個位元是兩個獨立隨機位元的互斥或,確實是均勻的。但實際符號表中它的行為不佳:真實識別字高度叢集(相同字首/字尾、字元集只用到 ASCII 的一小段),$x_1$ 和 $x_2$ 的高位元往往相同,XOR 後大量變成 0 —— 有效的隨機位元數大幅減少,碰撞率上升。這正是 8.2.4 節最後那段話的具體實例。

第 3 題:因為 $f(x)$ 取第一個字元、且用線性探測,表中的識別字不是依字母排序的(溢位的識別字會跑到後面的桶去)。所以演算法必須:掃描整個表收集所有 $n$ 個識別字($O(b)$,$b$ 為表大小),再排序($O(n\log n)$)。總時間 $O(b + n\log n)$。這題的用意是讓你體會:雜湊表天生不支援「依序輸出」,要做就得付出排序的代價。

習題 2 — 隨機探測(8.2 節習題 5,Morris 1968)

在隨機探測中,在含有 $b$ 桶的雜湊中搜尋識別字 $x$ 的檢查順序為桶 $f(x)$、$(f(x)+s(i))\bmod b$,$1 \le i \le b-1$,其中 $s(i)$ 為虛擬亂數。此亂數產生器應產生介於 1 到 $b-1$ 之間的每一數值恰好一次。

(a) 證明對一大小為 $2^r$ 的表,下列的計算順序生有如下特性的數值 —— 每次搜尋常式被呼叫時,將 $R$ 設定為 1。對一亂數的後續呼叫,進行下列工作:

R := R * 5
R := low order r+2 bits of R
S(i) := R / 4

(b) 利用上述的亂數產生器,設計一個演算法,使用隨機探測和平方後取中間數位雜湊函數 $f_m$,將識別字插入雜湊表中。可以證明這個方法中,搜尋 $x$ 所需的平均比較次數之期望值為 $-\dfrac{1}{\alpha}\log(1-\alpha)$,其中 $\alpha$ 為裝載密度。

點擊展開三種探測的比較
方法平均探測次數$\alpha=0.9$ 時
線性探測$\dfrac{2-\alpha}{2-2\alpha}$5.5
隨機探測$-\dfrac{1}{\alpha}\log(1-\alpha)$2.56
鏈結法$1 + \dfrac{\alpha}{2}$1.45

隨機探測介於兩者之間 —— 它消除了線性探測的「一次叢集」(primary clustering),因為不同的起始桶會產生不同的探測序列。但它仍然在同一個表裡搶位置,所以比不上鏈結法。順序記起來:鏈結 < 隨機探測 < 二次探測 < 線性探測。

8.3 動態雜湊

靜態雜湊在 DBMS 上為何不夠

「一種相當重要的軟體類別是資料庫管理系統或 DBMS。……快速的存取時間是很重要的,因 DBMS 通常用來保存大量的資訊。DBMS 另一個主要的特性是資訊量會隨著時間大量地改變。

「如前一節所描述的傳統雜湊法並不理想,因我們必須靜態地配置一部份的記憶體以保存雜湊表。……在任一種情況中,如果我們配置大部分的記憶體,當資料超過雜湊表的容量時,我們必須重整全部的檔案。這是一種非常浪費時間的過程。

動態雜湊(dynamic hashing),又稱為可伸展雜湊法(extendible hashing),保留了傳統雜湊法的快速存取時間,且擴充其技巧,使它可以沒有困難地適用於動態地增加或減少檔案的大小。

分頁 (page) 與空間使用率

「假設檔案 $F$ 是記錄 $R$ 的集合。每一記錄有一鍵值欄 $K$ 用以識別每一記錄。記錄儲存在桶中,或在動態雜湊所稱的分頁(page)中,其容量為 $p$。我們所發展的演算法必須使分頁存取為最小,因為分頁通常存在磁碟上,且將它們擷取到記憶體是任何運算的主要部份。

空間使用率的計算是記錄個數 $n$ 除以全部的空間 $mp$ 之比率,其中 $m$ 為分頁的個數。」

$$\text{空間使用率} = \frac{n}{mp}$$

8.3.1 使用目錄的動態雜湊法

課本的例子:兩個字元、每字元 3 位元

「我們想要將這些識別字放入一個有四個分頁(page)的表中。每一分頁可以存放的識別字不超過兩個,且分頁分別以 2 個位元的順序 00, 01, 10, 11 當作索引。我們以每一識別字低位的兩個位元來決定該識別字所在的分頁位址。

「注意,我們從最低有效位元往最高有效位元來選取位元。在根節點的分歧由最低位元」決定 —— 這一點和一般直覺相反,但它讓「分裂一個分頁」變成「在 trie 上多長一層」,非常自然。

trie 與目錄的對應關係

a0, b0c2 a1, b1c3 01 01 01 a0b0 c2 a1b1 c3 0001 1011 trie(依低位元分歧) 對應的連續儲存空間
圖 8.12:一個對應到無目錄、連續儲存空間的 trie —— trie 上的路徑(由最低有效位元開始)就是目錄的索引。
純 trie 的兩個問題

「由此例中我們可以發現有兩個主要的問題。首先,分頁的存取時間根據區別識別字所需的位元數而定。其次,如果識別字的分佈是傾斜的,則樹也是傾斜的。此二因素均會增加檢索時間。

Fagin et al. 提出一種方法,稱為可伸展雜湊法,以解決這些問題。免了避免識別字不均勻分佈,將使用一個雜湊函數。此函數接受一個鍵值並產生一個隨機的二進制數元集。為了避免沿著 trie 作很長的搜尋,trie 被映射至一個目錄中。

目錄 (directory) 的定義與存取步驟

目錄是一個分頁指標之表格。在需要 $k$ 位元來區別識別字的情況下,目錄有 $2^k$ 項,其索引是 $0, \ldots, 2^k-1$。為了找出一個識別字的分頁,我們用等於識別字最後 $k$ 位元的整數之二進制表示法。由此目錄項目所指的分頁被搜尋。

「此外,存取任何分頁只需要兩個步驟。第一步,我們使用雜湊函數求得目錄項的位址,第二步,擷取和此位址相關的分頁。」—— 這就是可伸展雜湊的核心保證:無論檔案多大,永遠只要 2 次存取。

三個逐步變大的目錄(圖 8.10)
目錄項目數指向各分頁的指標個數
第一個4(索引 0–3)每個索引一項
第二個8(索引 0–7)分頁 a 有兩個目錄項(000 和 100)指向它,分頁 b 有兩個指標,分頁 c 有一個,分頁 d 有一個,分頁 e 有兩個
第三個16(索引 0–15)六個分頁,指標個數分別為 4, 4, 1, 1, 2 和 4

使用目錄來表示一個 trie 可容許識別字表格動態地增長或縮小。當然,這必須假設作業系統可以毫無困難或只有一點麻煩地給予我們更多的分頁或將分頁釋回給可用記憶體。」

typedef struct {
        char *key;              /* pointer to string */
        /* other fields */
        } brecord;
int global_depth;   /* trie height */
paddr directory[DIRECTORY_SIZE]; /* pointers to pages */

paddr hash(char *, short int);
paddr buddy(paddr);
short int pgsearch(char *, paddr);
int convert(paddr);
void enter(brecord, paddr);
void pgdelete(char *, paddr);
paddr find(brecord, char *);
void insert(brecord, char *);
int size(paddr);
void coalesce(paddr, paddr);
void delete(brecord, char *);

動態雜湊的宣告 —— 注意 global_depth 就是「目前需要幾個位元」,以及 buddy():「Take an address of a page and returns the page's buddy, i.e., the leading bit is complemented」(夥伴分頁,合併時用)

8.3.2 無目錄的動態雜湊

溢位時的兩種選擇

「現在,當分頁溢位時如何處理?我們可將位址空間加倍,但這浪費的。替代的方法是,每當溢位時,我們在檔尾增加一個新的分頁,並將識別字分配到原有的一個分頁和新的分頁之間。這使得雜湊函數族群的處理有些困難。但是,如果我們只是很簡單地在雜湊函數的結果中加入一個位元,表格將會加倍。如果只是增加一個分頁,則雜湊函數必須能夠區別那些分頁以 $r$ 個位元定址,以及那些分頁以 $r+1$ 個位元定址。

if (hash(key,r) < q)
   page = hash(key,r+1);
else
   page = hash(key,r);
if needed, then follow overflow pointers;

程式 8.6:修改後的雜湊函數 —— q 是「已經分裂過的分頁數」的分界點

兩次插入之後的無目錄雜湊(圖 8.13)

「一開始,它有四個分頁,每一個以 2 位元定址(圖 8.13(a))。其中有兩個分頁已滿,另兩個則有一個識別字。當插入 c5,它雜湊到位址為 10 的分頁(圖 8.13(b))。因這個分頁是滿的,配置一個溢位節點來儲存 c5。在此同時,我們增加一個新的分頁到儲存體末端,將第一個分頁中的識別字重新雜湊,將它們分配在第一個和新的分頁之中。不幸地,沒有任何一個新的識別字進入新的分頁中。如圖 8.13(b),第一個分頁和新的分頁現以 3 個位元定址,而不是 2 個。」

一個重要的觀察

「由此例,我們可發現最長的溢位鏈發生在靠近擴充階段尾端的分頁上,因為它們是最後被分裂的節點。反之,那些較早被分裂的分頁通常是未填滿的。

這解釋了無目錄動態雜湊(又稱線性雜湊,linear hashing)的效能特性:分裂是輪流進行的,與溢位發生在哪裡無關,所以短期內會有不均勻,但長期會被拉平。

兩種動態雜湊的對照
有目錄(可伸展雜湊)無目錄(線性雜湊)
分頁存取次數恆為 2(目錄 + 分頁)1 + 溢位鏈長度
額外空間目錄 $2^k$ 項不需要目錄
溢位鏈沒有有,且長度不均
成長方式目錄加倍(分頁只分裂一個)每次只增加一個分頁
習題 3 — 動態雜湊(8.3 節習題)
  1. 課文中指出鍵值的不均勻分佈會產生傾斜的目錄而且浪費目錄空間。如果將目錄儲存為為 trie 的樹林而非表格,我們就可以避免目錄方法中的這個問題。新的鍵值雜湊到其中的一個 trie,而後搜尋其中的節點一直到達葉節點為止。葉節點指向包含所要的記錄之分頁。分裂仍是必要的。trie 的成長與縮小與檔案有關。設計一個演算法以維護一個當作目錄的 trie。
  2. 在可伸縮雜湊法中,給予一個大小為 $d$ 的目錄,假設有兩個指標指向同一分頁。則所有識別字有多少個低位的位元是相同的?如果有四個指標指向同一個分頁,則識別字有多少個位元是相同的?
  3. 我們可以用下面的方法來處理目錄動態雜湊方法中的溢位。此法容許一個分頁可以分裂成多個分頁,以儲存所有雜湊到該分的所有識別字。我們對目錄的大小設定一個限制。一旦到達這個限制,分頁個數就繼續增加。
點擊展開解題要點

第 2 題(核心觀念題):令目錄大小 $d = 2^k$(即 global_depth $= k$)。

  • 兩個指標指向同一分頁 ⇒ 該分頁的局部深度(local depth)為 $k-1$ ⇒ 所有識別字有 $k-1$ 個低位位元相同。
    (例:圖 8.10 第二個目錄中,分頁 a 被 000100 指到 —— 兩者的低 2 位元都是 00,而 $k=3$,確實是 $k-1=2$。)
  • 四個指標指向同一分頁 ⇒ 局部深度為 $k-2$ ⇒ 有 $k-2$ 個低位位元相同。

一般式:$2^j$ 個指標指向同一分頁 $\iff$ 該分頁的識別字有 $k-j$ 個低位位元相同。

第 1 題的動機:目錄大小是 $2^{\text{global\_depth}}$,而 global_depth最深的那一個分頁決定。若鍵值分佈傾斜,一個分頁分裂很多次就會把整個目錄撐大到 $2^k$ 項,而其中絕大多數項目是重複指標 —— 這就是「浪費目錄空間」。用 trie 樹林取代扁平表格,就只在真正需要的路徑上長出節點。

8.4 精選參考文獻

主題參考書目
二次探測的證明Radke 之著作(課本:「我們請對証明有興趣的讀者參考列於精選參考文獻一節中的 Radke 之著作」)
雜湊函數的實驗評估V. Lum, P. Yuen and M. Dodd, CACM, 1971, Vol. 14, No. 4(圖 8.7 的來源)
隨機探測Morris, 1968
可伸展雜湊法Fagin et al.
除法的 $M$ 選擇D. Knuth

本章重點回顧

一定要記住的清單
  1. 裝載密度 $\alpha = n/(sb)$。桶大小 $s=1$ 時,溢位和碰撞同時發生
  2. 除法 $f_D(x) = x \bmod M$ 是實務上最好的(圖 8.7 實測)。$M$ 取質數,或至少沒有小於 20 的質因數
  3. $M$ 為 2 的冪次 → 只看低位元;$M$ 為偶數 → 奇偶偏差;$M$ 有小質因數 3 或 7 → 字元排列的識別字會等距碰撞(因 $64\bmod3 = 64\bmod7 = 1$)。
  4. 平方取中的表大小應是 2 的冪次(與除法剛好相反)。
  5. 線性探測:$p \approx \dfrac{2-\alpha}{2-2\alpha}$;鏈結法:$p \approx 1+\dfrac{\alpha}{2}$。$\alpha\to1$ 時前者發散、後者只到 1.5。
  6. 二次探測的 $b$ 應取 $4j+3$ 形式的質數,才保證能檢查到每一個桶。
  7. 雜湊最差狀況是 $O(n)$ —— 這是它相對於第 10 章平衡樹的根本弱點。
  8. 可伸展雜湊:存取任何分頁恆為 2 步(目錄 + 分頁)。$2^j$ 個目錄指標指向同一分頁 $\iff$ 該分頁識別字有 $k-j$ 個低位位元相同。
  9. trie 由最低有效位元開始分歧,這樣分裂分頁才等於在 trie 上多長一層。
三條搜尋路線的對照
方法平均最差有序?
排序 + 二分搜尋(第 7 章)$O(\log n)$$O(\log n)$
二元搜尋樹(第 5 章)$O(\log n)$$O(n)$
平衡樹(第 10 章)$O(\log n)$$O(\log n)$
雜湊(本章)$O(1)$$O(n)$

「有序?」那一欄就是選擇的關鍵:需要範圍查詢或依序輸出,雜湊就不能用。

最容易答錯的五個點
  1. 碰撞 ≠ 溢位,只有 $s=1$ 時兩者同時發生。
  2. 平方取中要 2 的冪次,除法要 質數 —— 剛好相反。
  3. 鏈結法的公式是 $1+\alpha/2$,不是 $\alpha/2$。
  4. 二次探測要 $4j+3$ 的質數,不是 任意質數。
  5. 可伸展雜湊的 trie 從低位元開始分歧。
與前後各章的連結

用到前面的:符號表 ADT 沿用第 1 章的結構格式;鏈結法的同義字串列就是第 4 章的鏈結串列(含標頭節點);8.1 節明白對照第 5 章 5.7 節的二元搜尋樹。

接到後面的:第 10 章會用平衡樹把最差狀況壓回 $O(\log n)$ —— 課本在 8.1 節就先預告了:「在第 10 章中,我們將介紹許多二元搜尋樹的改進,使其可以減少每個運算的時間到 $O(\log n)$」。第 10 章的 B 樹與 B+ 樹解決的正是本章 8.3 節同一個問題(大量資料存在磁碟上、要讓分頁存取次數最少),但走的是「保持有序」的路線。把 8.3 節與第 10 章的 B+ 樹並排讀,是理解資料庫索引設計的最佳途徑。