關於散列表的一些思考
散列表(也叫Hash表)是一種應用較為廣泛的資料結構,幾乎所有的高階程式語言都內建了散列表這種資料結構。然而散列表在不同的程式語言中稱呼不一樣,在JavaScript中被稱為物件,在Ruby中被稱為雜湊,而在Python中被稱為字典。即便稱呼不同,語法不同,它們的原理基本相通。
原理解析
在現代的程式語言中,幾乎都會有散列表的身影,故而難以忽視它為程式設計師所帶來的種種便利性。雜湊跟陣列是很相似的,較大的區別在於,陣列直接通過特定的索引來訪問特定位置的資料,而散列表則是先通過 雜湊函式 來獲取對應的索引,然後再定位到對應的儲存位置。這是比較底層的知識了,一般的散列表,在底層都是通過陣列來進行儲存,利用陣列作為底層儲存的資料結構最大的好處在於它的隨機訪問特性,不管陣列有多長,訪問該陣列的時間複雜度都是 O(1)
。
當然要設計一個能用的散列表,在底層僅僅用普通的陣列是不夠的,畢竟我們需要儲存的不僅僅是數值型別,還可能會儲存字串為鍵,字串為值,或者以字串為鍵,某個函式的指標為值 (JavaScript就很多這種情況)的鍵值對。在這類情況中,我們需要對底層的結點進行精心設計,才能夠讓散列表儲存更多元化的資料。
無論以何種資料型別為鍵,我們始終都需要有把鍵轉換成底層陣列任意位置索引的能力,通過雜湊函式可以做到這一點。雜湊函式是個很考究的東西,設計得不好可能會導致頻繁出現多個不同的鍵對映到同一個索引值的現象,這種現象稱之為 衝突 ,文章的後半部分會看到常用的一些解決衝突的方式。除此之外,每次為散列表所分配的空間是有限的,隨著元素的插入,散列表會越來越滿,這時,衝突的機率就越高。故而,我們需要定期對散列表進行擴張,並把已有的鍵值對重新對映到新的空間中去,讓散列表中的鍵值對更加分散,降低衝突的機率。這個過程被稱為Resize。這個過程能夠在一定程度上降低散列表的衝突機率,提高查詢效率。
接下來會從雜湊函式,衝突解決,雜湊查詢以及散列表Resize這幾個方面來詳細談談散列表的基本概念。
為何不用連結串列來儲存鍵值對?
散列表本質上就是用來儲存鍵值對的,其實完全可以用一個連結串列來實現鍵值對儲存的功能,這樣就不會產生衝突了。舉個簡單的例子
typedef struct Node { char * key; char * value; struct Node * next; } Node; 複製程式碼
以上的結點能夠儲存以字串為鍵,字串為值的鍵值對。假設完全用連結串列來實現鍵值對儲存機制,那麼每次插入元素,我們都得遍歷整個連結串列,對比每個結點中的鍵。如果鍵已經存在的話則替換掉原來的值,如果遍歷到最後都不能找到相關的結點則建立新的結點並插入到雜湊的末端。查詢的方式其實也類似,同樣是遍歷整個連結串列,查詢到對應鍵值對則返回相關的值,當遍歷到雜湊最後都不能找到相關值的話則提示找不到對應結點。刪除操作跟插入操作類似,都需要遍歷連結串列,尋找待刪除的鍵值對。
這種實現方式雖然操作起來較為簡便,也不會有衝突產生,不過最大的問題在於效率。無論是插入,查詢還是刪除都需要遍歷整個連結串列,最壞情況下時間複雜度都是 O(n)
。假設我們有一個相當龐大的鍵值對集合,這樣的時間成本是難以讓人接受的。為此在儲存鍵值對的時候都會採用陣列來作為底層的資料結構,得益於它隨機訪問的特徵,無論最終的鍵值對集合多有龐大,訪問任意位置的時間複雜度始終為 O(1)
。即便這種方式會有產生衝突的可能,但只要雜湊函式設計得當,Resize的時機合適,散列表訪問的時間複雜度都會保持在 O(1)
左右。
雜湊函式
雜湊函式在維基百科上的解釋是
雜湊函式(英語:Hash function)又稱雜湊演算法、雜湊函式,是一種從任何一種資料中建立小的數字“指紋”的方法。
在散列表中所謂的“指紋”其實就是我們所分配的陣列空間的索引值,不同型別的鍵需要設計不同的雜湊函式,它將會得到一個數值不是太大的索引值。假設我們用C語言來設計一個以字串為鍵,字串為值的散列表,那麼這個散列表的結點可以被設計成
typedef struct Hash { char * key; // 指向雜湊的鍵 char * value; // 指向雜湊的值 char isNull; // 標識這個雜湊是否為空 } Hash; 複製程式碼
以這個結點為基礎可以簡單地建立一個長度為7的散列表
Hash hash[7] 複製程式碼
接下來就要設計對應的雜湊函數了,這個函式的目的是 把字串轉換成整型數值 。字串和整型最直接的關聯無非就是字串的長度了,那麼我們可以設計一個最簡單的字串雜湊函式
#include <string.h> unsigned long simpleHashString(char * str) { return strlen(str); } 複製程式碼
不過這個雜湊函式有兩個比較大的問題
- 對同樣長度的字串,它的雜湊值都是相同的。放在陣列的角度上來看就是,它們所對應的陣列索引值是一樣的,這種情況會引發衝突。
- 以字串為鍵,當它的長度大於7的時候,它所對應的索引值會造成陣列越界。
針對第一種情況,可以簡單描述為,這個雜湊函式還不夠“散”。可以進一步優化,把字串中的每個位元組所對應的編碼值進行累加如何?
#include <string.h> unsigned long sum(char * str) { unsigned long sum = 0; for (unsigned long i = 0; i < strlen(str); i++) { sum += str[i]; } return sum; } unsigned long simpleHashString(char * str) { return strlen(str) + sum(str); } 複製程式碼
這樣似乎就可以得到一個更“散”的地址了。接下來還需要考慮第二種情況,索引值太大所造成的越界的問題。要解決越界的問題,最粗暴的方式就是對陣列進行擴充套件。不過依照我前面寫的這個不太合格的雜湊函式,雜湊函式的值幾乎是隨著字串的增長而線性增長。舉個例子
int main() { printf("%lu", simpleHashString("Hello World")); } 複製程式碼
列印結果是 1063
,這是一個相當大的值了。如果要按這個數值來分配記憶體的話那麼所需要的記憶體空間是 sizeof(Hash) * 1063 => 25512
個位元組,大概是 25KB
,這顯然不太符合情理。故而需要換個思路去解決問題。為了讓雜湊函式的值坐落在某個範圍內,採用模運算就可以很容易做到這一點
unsigned long simpleHashStringWithMod(char * str) { return (strlen(str) + sum(str)) % 7; } 複製程式碼
這裡除數是7,故而雜湊函式 simpleHashStringWithMod
所生成索引值的取值範圍是 0 <= hvalue < 7
,恰好落在長度為7的陣列索引範圍內。如果要藉助這個雜湊函式來儲存鍵值對 {'Ruby' => 'Matz', 'Java' => 'James'}
,那麼示意圖為

這裡只是做個簡單的示範,長度為7的散列表會顯得有點短,很容易就會產生衝突,接下來會談談解決衝突的一些方式。
衝突
前面我們所設計的雜湊函式十分簡單,然而所分配的空間卻最多隻能夠儲存7個鍵值對,這種情況下很快就會產生衝突。所謂衝突就是不同的鍵,經過雜湊函式處理之後得到相同的雜湊值。也就是說這個時候,它們都指向了陣列的同一個位置。我們需要尋求一些手段來處理這種衝突,如今用途比較廣泛的就有 開放地址法 以及 鏈地址法 ,且容我一一道來。
1. 開放地址法
開放地址法實現起來還算比較簡單,只不過是當衝突產生的時候通過某種探測手段來在原有的陣列上尋找下一個存放鍵值對位置。如果下個位置也存有東西了則再用相同的探測演算法去尋找下下個位置,直到能夠找到合適的儲存位置為止。目前常用的探測方法有
- 線性探測法
- 平方探測法
- 偽隨機探測法
無論哪種探測方法,其實都需要能夠保證 對於同一個地址輸入,第n次探測到的位置總是相同的。
線性探測法很容易理解,簡單來講就是 下一個索引位置 ,計算公式為 hashNext = (hash(key) + i) mod size
。舉個直觀點的例子,目前散列表中索引為5的位置已經有資料了。當下一個鍵值對也想在這個位置存放資料的時候,衝突產生了。我們可以通過線性探測演算法來計算下一個儲存的位置,也就是 (5 + 1) % 7 = 6
。如果這個地方也已經有資料了,則再次運用公式 (5 + 2) % 7 = 0
,如果還有衝突,則繼續 (5 + 3) % 7 = 1
以此類推,直到找到對應的儲存位置為止。很明顯的一個問題就是當陣列越滿的時候,衝突的機率越高,越難找到合適的位置。用C語言來實現線性探測函式 linearProbing
結果如下
int linearProbing(Hash * hash, int address, int size) { int orgAddress = address; for (int i = 1; !hash[address].isNull; i++) { address = (orgAddress + i) % size; // 線性探測 } return address; } 複製程式碼
只要散列表還沒全滿,它總會找到合適的位置的。平方探測法與線性探測法其實是類似的,區別在於它每次所探測的位置不再是原有的位置加上 i
,而是 i
的平方。平方探測函式 quadraticProbing
大概如下
int quadraticProbing(Hash * hash, int address, int size) { for (int i = 1; !hash[address].isNull; i++) { address = (address + i * i) % size; // 平方探測 } return address; } 複製程式碼
上面兩個演算法最大的特點在於,對於相同的地址輸入,總會按照一個固定的路線去尋找合適的位置,這樣以後要再從散列表中查詢對應的鍵值對就有跡可循了。其實偽隨機數也有這種特性,只要隨機的種子資料是相同的,那麼每次得到的隨機序列都是一定的。可以利用下面的程式觀察偽隨機數的行為
#include <stdio.h> #include <stdlib.h> int main() { int seed = 100; srand(seed); int value = 0; int i=0; for (i=0; i< 5; i++) { value =rand(); printf("value is %d\n", value); } } 複製程式碼
偽隨機種子是 seed = 100
,這個程式無論執行多少次列印的結果總是一致的,在我的計算機上會列印以下數值
value is 1680700 value is 330237489 value is 1203733775 value is 1857601685 value is 594259709 複製程式碼
利用這個特性,我們就能夠以偽隨機的機制來實現偽隨機探測函式 randomProbing
int randomProbing(Hash *hash, int address, int size) { srand(address); while (!hash[address].isNull) { address = rand() % size; } return address; } 複製程式碼
無論採用哪種方式,只要有相同的address輸入,都會得到相同的查詢路線。總體而言,用開放地址法來解決地址衝突問題,在不考慮雜湊表Resize的情況下,實現起來還是比較簡單的。不過不難想到,它較大問題在於當散列表滿到一定程度的時候,衝突的機率會比較大,這種情況下為了找到合適的位置必須要進行多次計算。另外還有個問題,就是刪除鍵值對的時候,我們不能把鍵值對的資料簡單地“刪除”掉,並把當前位置設定成空。因為如果直接刪除並設定為空的話會出現查詢鏈中斷的情況,任何依賴於當前位置所做的搜尋都會作廢,可以考慮另外維護一個狀態來標識當前位置是“空閒”的,表明它曾經有過資料,現在也接受新資料的插入。
PS: 在這個例子中,我們可以只利用 isNull
欄位來標識不同狀態。用數值0來標識當前結點已經有資料了,用1來標識當前結點是空的,採用2來標識當前結點曾經有過資料,目前處於空閒狀態,並且接受新資料的插入。這樣就不會出現查詢鏈中斷的情況了。不過需要對上面的探測函式稍微做一些調整,這裡不展開說。
2. 鏈地址法
鏈地址法跟開放地址法的線性探測十分相似,最大的不同在於線性探測法中的下一個節點是在當前的陣列上去尋找,而鏈地址法則是通過連結串列的方式去追加結點。實際上所分配陣列的每一個位置都可以稱之為桶,總的來說,開放地址法產生衝突的時候,會去尋找一個新的桶來存放鍵值對,而鏈地址法則是依然使用當前的桶,但是會追加新結點增加桶的 深度 。示意圖大概如下

可見它的結點結構是
typedef struct Hash { char * key; // 指向雜湊的鍵 char * value; // 指向雜湊的值 char isNull; // 標識這個雜湊是否為空 struct Hash * next; // 指向下一個結點 } Hash; 複製程式碼
除了原來的資料欄位之外,還需要維護一個指向下一個衝突結點的指標,實際上就是最開始談到的連結串列的方式。這種處理方式有個好處就是,產生衝突的時候,不再需要為了尋找合適的位置而進行大量的探測,只要通過雜湊函式找到對應桶的位置,然後遍歷桶中的連結串列即可。此外,利用這種方式刪除節點也是比較容易的。即便是採用了鏈地址法,到了一定時候還是要對散列表進行Resize的,不然等桶太深的時候,依舊不利於查詢。
3. 彙總
總體而言,採用開放地址法所需要的記憶體空間比較少,實現起來也相對簡單一些,當衝突產生的時候它是通過探測函式來查詢下一個存放的位置。但是刪除結點的時候需要另外維護一個狀態,才不至於查詢鏈的中斷。鏈地址法則是通過連結串列來儲存衝突資料,這為資料操作帶來不少便利性。然而,無論採用哪種方式,都需要在恰當的時候進行Resize,才能夠讓時間複雜度保持在 O(1)
左右。
查詢
瞭解如何插入,那麼查詢也就不成問題了。對開放地址法而言,插入演算法大概如下
- 通過雜湊函式計算出鍵所對應的雜湊值。
- 根據雜湊值從陣列中找到相對應的索引位置。
- 如果這個位置是“空閒”的,則插入資料。如果該鍵值對已經存在了,則替換掉原來的資料。
- 如果這個位置已經有別的資料了,表明衝突已經產生。
- 通過特定的探測法,計算下一個可以存放的位置。
- 返回第三步。
而查詢演算法是類似的
- 通過雜湊函式計算出鍵所對應的雜湊值。
- 根據雜湊值從陣列中找到相對應的索引位置。
- 如果這個位置為空的話則直接返回說找不到資料。
- 如果這個位置能夠匹配當前查詢的鍵,則返回需要查詢的資料。
- 如果這個位置已經有別的資料,或者狀態顯示曾經有過別的資料,表明有衝突產生。
- 通過特定的探測法,計算下一個位置。
- 返回第三步。
鏈地址法其實也類似,區別在於插入鍵值對的時候如果識別到衝突,鏈地址法並不會通過一定的探測法來查詢下一個存放資料的位置,而是順著連結串列往下搜尋,增添新的結點,或者更新已有的結點。查詢的時候則是沿著連結串列往下查詢,找到目標資料則直接把結果返回。假設窮盡連結串列都無法找到對應的資料,表明資料不存在。
重組(Resize)
Resize是專業術語,直接翻譯過來是重新設定大小,不過有些書籍會把它翻譯成重組,我覺得這個翻譯更為貼切。當原有的散列表快滿的時候,其實我們不僅需要對原有的空間進行擴張,還需要用新的雜湊函式來重新對映鍵值對,讓鍵值對可以更加分散地儲存。
不過不同的實現方式進行重組的時機不太一樣,對於開放地址法而言,每個桶都只能存放一個鍵值對,需要通過特定的探測法把衝突資料存放到相關的位置中。當散列表越滿的時候衝突的機率就越大,要找到可以存放資料的地方將會越艱難,這將不利於後續的插入和查詢。為此需要找到一個恰當的時機對散列表進行Resize。業界通過 載荷因子 來評估散列表的負載,載荷因子越大,衝突的可能性就越高。
載荷因子 = 填入表中的元素個數 / 散列表的長度
採用開放地址法來實現的散列表,當載荷因子超過某個閥值的時候就應當對散列表進行Resize了,不過閥值的大小則由設計者自行把控了。據維基百科上的描述,Java的系統庫把載荷因子的閥值設定為0.75左右,超過這個值則Resize散列表。
而採用鏈地址法實現雜湊的時候,利用連結串列的特性,每個桶都能夠存放多個結點,因此在鏈地址法中通過桶的深度來評估一個雜湊是否需要Resize更有意義。你可以當桶的最大深度超過某個值的時候對原有的雜湊進行Resize。
無論採用哪種實現方式,Resize的時機還需要設計者自行把控,不同的應用場景Resize的時機也可能會有所不同。Resize操作可以讓我們原有的鍵值對資料更加分散,讓散列表插入和查詢的時間複雜度保持在 O(1)
左右。然而Resize畢竟是耗時操作,時間複雜度隨著鍵值對資料的增長而增長,因此不宜操作得過於頻繁。
總結
這篇文章主要從原理層面闡述了一個散列表的實現方式,總結了實現一個散列表需要注意的一些事項。需要設計一個較好的雜湊函式,讓鍵值對更加分散以減少衝突的可能。然而很多時候衝突難以避免,我們需要一些手段來解決衝突,還要在恰當的時候進行Resize。一般而言,散列表的底層是採用了能夠隨機訪問的資料結構,只要散列表中的鍵值對足夠分散,就能夠把時間複雜度控制在 O(1)
左右。