一文助你把哈希表整的明明白白

之前給大家介紹了鏈表棧和隊列今天我們來說一種新的數據結構散列(哈希)表,散列是應用非常廣泛的數據結構,在我們的刷題過程中,散列表的出場率特別高。所以我們快來一起把散列表的內些事給整明白吧。文章框架如下

說散列表之前,我們先設想以下場景。

袁廚穿越回了古代,憑藉從現代學習的做飯手藝,開了一個袁記菜館,正值開業初期,店裏生意十分火爆,但是顧客結賬時就犯難了,每當結賬的時候,老闆娘總是按照菜單一個一個找價格(遍歷查找),每次都要找半天,所以結賬的地方總是排起長隊,顧客們表示用戶體驗不咋滴。袁廚一想這不是辦法啊,讓顧客老是等着,太影響客戶體驗啦。所以袁廚就先把菜單按照首字母排序(二分查找),然後查找的時候根據首字母查找,這樣結賬的時候就能大大提高檢索效率啦!但是呢?工作日顧客不多,老闆娘完全應付的過來,但是每逢節假日,還是會排起長隊。那麼有沒有什麼更好的辦法呢?對呀!我們把所有的價格都背下來不就可以了嗎?每個菜的價格我們都瞭如指掌,結賬的時候我們只需簡單相加即可。所以袁廚和老闆娘加班加點的進行背誦。下次再結賬的時候一說吃了什麼菜,我們立馬就知道價格啦。自此以後收銀臺再也沒有出現過長隊啦,袁記菜館開着開着一不小心就成了天下第一飯店了。

下面我們來看一下袁記菜館老闆娘進化史。

上面的後期結賬的過程則模擬了我們的散列表查找,那麼在計算機中是如何使用進行查找的呢?

散列表查找步驟

散列表-------最有用的基本數據結構之一。是根據關鍵碼的值兒直接進行訪問的數據結構,散列表的實現常常叫做散列(hasing)。散列是一種用於以常數平均時間執行插入、刪除和查找的技術,下面我們來看一下散列過程。

我們的整個散列過程主要分爲兩步

(1)通過散列函數計算記錄的散列地址,並按此散列地址存儲該記錄。就好比麻辣魚我們就讓它在川菜區,糖醋魚,我們就讓它在魯菜區。但是我們需要注意的是,無論什麼記錄我們都需要用同一個散列函數計算地址,再存儲。

(2)當我們查找時,我們通過同樣的散列函數計算記錄的散列地址,按此散列地址訪問該記錄。因爲我們存和取得時候用的都是一個散列函數,因此結果肯定相同。

剛纔我們在散列過程中提到了散列函數,那麼散列函數是什麼呢?

我們假設某個函數爲 f,使得

​ 存儲位置 = f (關鍵字)

輸入:關鍵字 輸出:存儲位置(散列地址)

那樣我們就能通過查找關鍵字不需要比較就可獲得需要的記錄的存儲位置。這種存儲技術被稱爲散列技術。散列技術是在通過記錄的存儲位置和它的關鍵字之間建立一個確定的對應關係 f ,使得每個關鍵字 key 都對應一個存儲位置 f(key)。見下圖

這裏的 f 就是我們所說的散列函數(哈希)函數。我們利用散列技術將記錄存儲在一塊連續的存儲空間中,這塊連續存儲空間就是我們本文的主人公------散列(哈希)表

上圖爲我們描述了用散列函數將關鍵字映射到散列表,但是大家有沒有考慮到這種情況,那就是將關鍵字映射到同一個槽中的情況,即 f(k4) = f(k3) 時。這種情況我們將其稱之爲衝突k3 和 k4則被稱之爲散列函數 f 的同義詞,如果產生這種情況,則會讓我們查找錯誤。幸運的是我們能找到有效的方法解決衝突。

首先我們可以對哈希函數下手,我們可以精心設計哈希函數,讓其儘可能少的產生衝突,所以我們創建哈希函數時應遵循以下規則

(1)必須是一致的,假設你輸入辣子雞丁時得到的是在看,那麼每次輸入辣子雞丁時,得到的也必須爲在看。如果不是這樣,散列表將毫無用處。

(2)計算簡單,假設我們設計了一個算法,可以保證所有關鍵字都不會衝突,但是這個算法計算複雜,會耗費很多時間,這樣的話就大大降低了查找效率,反而得不償失。所以咱們散列函數的計算時間不應該超過其他查找技術與關鍵字的比較時間,不然的話我們幹嘛不使用其他查找技術呢?

(3)散列地址分佈均勻我們剛纔說了衝突的帶來的問題,所以我們最好的辦法就是讓散列地址儘量均勻分佈在存儲空間中,這樣即保證空間的有效利用,又減少了處理衝突而消耗的時間。

現在我們已經對散列表,散列函數等知識有所瞭解啦,那麼我們來看幾種常用的散列函數構造規則。這些方法的共同點爲都是將原來的數字按某種規律變成了另一個數字。

散列函數構造方法

直接定址法

如果我們對盈利爲0-9的菜品設計哈希表,我們則直接可以根據作爲地址,則 f(key) = key;

即下面這種情況。

有沒有感覺上面的圖很熟悉,沒錯我們經常用的數組其實就是一張哈希表,關鍵碼就是數組的索引下標,然後我們通過下標直接訪問數組中的元素。

另外我們假設每道菜的成本爲50塊,那我們還可以根據盈利+成本來作爲地址,那麼則 f(key) = key + 50。也就是說我們可以根據線性函數值作爲散列地址。

​ f(key) = a * key + b a,b均爲常數

優點:簡單、均勻、無衝突。

應用場景:需要事先知道關鍵字的分佈情況,適合查找表較小且連續的情況

數字分析法

該方法也是十分簡單的方法,就是分析我們的關鍵字,取其中一段,或對其位移,疊加,用作地址。比如我們的學號,前 6 位都是一樣的,但是後面 3 位都不相同,我們則可以用學號作爲鍵,後面的 3 位做爲我們的散列地址。如果我們這樣還是容易產生衝突,則可以對抽取數字再進行處理。我們的目的只有一個,提供一個散列函數將關鍵字合理的分配到散列表的各位置。這裏我們提到了一種新的方式,抽取,這也是在散列函數中經常用到的手段。

優點:簡單、均勻、適用於關鍵字位數較大的情況

應用場景:關鍵字位數較大,知道關鍵字分佈情況且關鍵字的若干位較均勻

摺疊法

其實這個方法也很簡單,也是處理我們的關鍵字然後用作我們的散列地址,主要思路是將關鍵字從左到右分割成位數相等的幾部分,然後疊加求和,並按散列表表長,取後幾位作爲散列地址。

比如我們的關鍵字是123456789,則我們分爲三部分 123 ,456 ,789 然後將其相加得 1368 然後我們再取其後三位 368 作爲我們的散列地址。

優點:事先不需要知道關鍵字情況

應用場景:適合關鍵字位數較多的情況

除法散列法

在用來設計散列函數的除法散列法中,通過取 key 除以 p 的餘數,將關鍵字映射到 p 個槽中的某一個上,對於散列表長度爲 m 的散列函數公式爲

​ f(k) = k mod p (p <= m)

例如,如果散列表長度爲 12,即 m = 12 ,我們的參數 p 也設爲12,那 k = 100時 f(k) = 100 % 12 = 4

由於只需要做一次除法操作,所以除法散列法是非常快的。

由上面的公式可以看出,該方法的重點在於 p 的取值,如果 p 值選的不好,就可能會容易產生同義詞。見下面這種情況。我們哈希表長度爲6,我們選擇6爲p值,則有可能產生這種情況,所有關鍵字都得到了0這個地址數。

那我們在選用除法散列法時選取 p 值時應該遵循怎樣的規則呢?

m 不應爲 2 的冪,因爲如果 m = 2^p ,則 f(k) 就是 k 的 p 個最低位數字。例 12 % 8 = 4 ,12的二進制表示位1100,後三位爲100。

若散列表長爲 m ,通常 p 爲 小於或等於表長(最好接近m)的最小質數或不包含小於 20 質因子的合數。

**合數:**合數是指在大於1的整數中除了能被1和本身整除外,還能被其他數(0除外)整除的數。

質因子:質因子(或質因數)在數論裏是指能整除給定正整數的質數。

這裏的2,3,5爲質因子

還是上面的例子,我們根據規則選擇 5 爲 p 值,我們再來看。這時我們發現只有 6 和 36 衝突,相對來說就好了很多。

優點:計算效率高,靈活

應用場景:不知道關鍵字分佈情況

乘法散列法

構造散列函數的乘法散列法主要包含兩個步驟

用關鍵字 k 乘上常數 A(0 < A < 1),並提取 k A 的小數部分

用 m 乘以這個值,再向下取整

散列函數爲

​ f (k) = ⌊ m(kA mod 1) ⌋

這裏的 kA mod 1 的含義是取 keyA 的小數部分,即 kA - ⌊kA⌋ 。

優點:對 m 的選擇不是特別關鍵,一般選擇它爲 2 的某個冪次(m = 2 ^ p ,p爲某個整數)

應用場景:不知道關鍵字情況

平方取中法

這個方法就比較簡單了,假設關鍵字是 321,那麼他的平方就是 103041,再抽取中間的 3 位就是 030 或 304 用作散列地址。再比如關鍵字是 1234 那麼它的平方就是 1522756 ,抽取中間 3 位就是 227 用作散列地址.

優點:靈活,適用範圍廣泛

適用場景:不知道關鍵字分佈,而位數又不是很大的情況。

隨機數法

故名思意,取關鍵字的隨機函數值爲它的散列地址。也就是 f(key) = random(key)。這裏的random是 隨機函數。

優點:易實現

適用場景:關鍵字的長度不等時

上面我們的例子都是通過數字進行舉例,那麼如果是字符串可不可以作爲鍵呢?當然也是可以的,各種各樣的符號我們都可以轉換成某種數字來對待,比如我們經常接觸的ASCII 碼,所以是同樣適用的。

以上就是常用的散列函數構造方法,其實他們的中心思想是一致的,將關鍵字經過加工處理之後變成另外一個數字,而這個數字就是我們的存儲位置,是不是有一種間諜傳遞情報的感覺。

一個好的哈希函數可以幫助我們儘可能少的產生衝突,但是也不能完全避免產生衝突,那麼遇到衝突時應該怎麼做呢?下面給大家帶來幾種常用的處理散列衝突的方法。

處理散列衝突的方法

我們在使用 hash 函數之後發現關鍵字 key1 不等於 key2 ,但是 f(key1) = f(key2),即有衝突,那麼該怎麼辦呢?不急我們慢慢往下看。

開放地址法

瞭解開放地址法之前我們先設想以下場景。

袁記菜館內,鈴鈴鈴,鈴鈴鈴 電話鈴響了

大鵬:老袁,給我訂個包間,我今天要去帶幾個客戶去你那談生意。

袁廚:大鵬啊,你常用的那個包間被人訂走啦。

大鵬:老袁你這不仗義呀,咋沒給我留住呀,那你給我找個空房間吧。

袁廚:好滴老哥

哦,穿越回古代就沒有電話啦,那看來穿越的時候得帶着幾個手機了。

上面的場景其實就是一種處理衝突的方法-----開放地址法

開放地址法就是一旦發生衝突,就去尋找下一個空的散列地址,只要列表足夠大,空的散列地址總能找到,並將記錄存入,爲了使用開放尋址法插入一個元素,需要連續地檢查散列表,或稱爲探查,我們常用的有線性探測,二次探測,隨機探測

線性探測法

下面我們先來看一下線性探測,公式:

f,(key) = ( f(key) + di ) MOD m(di = 1,2,3,4,5,6....m-1)

我們來看一個例子,我們的關鍵字集合爲{12,67,56,16,25,37,22,29,15,47,48,21},表長爲12,我們再用散列函數 f(key) = key mod 12。

我們求出每個 key 的 f(key)見下表

我們查看上表發現,前五位的 f(key) 都不相同,即沒有衝突,可以直接存入,但是到了第六位 f(37) = f(25) = 1,那我們就需要利用上面的公式 f(37) = f (f(37) + 1 ) mod 12 = 2,這其實就是我們的訂包間的做法。下面我們看一下將上面的所有數存入哈希表是什麼情況吧。

我們把這種解決衝突的開放地址法稱爲線性探測法。下面我們通過視頻來模擬一下線性探測法的存儲過程。

另外我們在解決衝突的時候,會遇到 48 和 37 雖然不是同義詞,卻爭奪一個地址的情況,我們稱其爲堆積。因爲堆積使得我們需要不斷的處理衝突,插入和查找效率都會大大降低。

通過上面的視頻我們應該瞭解了線性探測的執行過程了,那麼我們考慮一下這種情況,若是我們的最後一位不爲21,爲 34 時會有什麼事情發生呢?

此時他第一次會落在下標爲 10 的位置,那麼如果繼續使用線性探測的話,則需要通過不斷取餘後得到結果,數據量小還好,要是很大的話那也太慢了吧,但是明明他的前面就有一個空房間呀,如果向前移動只需移動一次即可。不要着急,前輩們已經幫我們想好了解決方法

二次探測法

其實理解了我們的上個例子之後,這個一下就能整明白了,根本不用費腦子,這個方法就是更改了一下di的取值

線性探測: f,(key) = ( f(key) + di ) MOD m(di = 1,2,3,4,5,6....m-1)

二次探測: f,(key) = ( f(key) + di ) MOD m(di =1^2 , -1^2 , 2^2 , -2^2 .... q^2, -q^2, q<=m/2)

注:這裏的是 -1^2 爲負值 而不是 (-1)^2

所以對於我們的34來說,當di = -1時,就可以找到空位置了。

二次探測法的目的就是爲了不讓關鍵字聚集在某一塊區域。另外還有一種有趣的方法,位移量採用隨機函數計算得到,接着往下看吧.

隨機探測法

大家看到這是不又有新問題了,剛纔我們在散列函數構造規則的第一條中說

(1)必須是一致的,假設你輸入辣子雞丁時得到的是在看,那麼每次輸入辣子雞丁時,得到的也必須爲在看。如果不是這樣,散列表將毫無用處。

咦?怎麼又是在看哈哈,那麼問題來了,我們使用隨機數作爲他的偏移量,那麼我們查找的時候豈不是查不到了?因爲我們 di 是隨機生成的呀,這裏的隨機其實是僞隨機數,僞隨機數含義爲,我們設置隨機種子相同,則不斷調用隨機函數可以生成不會重複的數列,我們在查找時,用同樣的隨機種子它每次得到的數列是相同的,那麼相同的 di 就能得到相同的散列地址

隨機種子(Random Seed)是計算機專業術語,一種以隨機數作爲對象的以真隨機數(種子)爲初始條件的隨機數。一般計算機的隨機數都是僞隨機數,以一個真隨機數(種子)作爲初始條件,然後用一定的算法不停迭代產生隨機數

通過上面的測試是不是一下就秒懂啦,爲什麼我們可以使用隨機數作爲它的偏移量,理解那句,相同的隨機種子,他每次得到的數列是相同的。

下面我們再來看一下其他的函數處理散列衝突的方法

再哈希法

這個方法其實也特別簡單,利用不同的哈希函數再求得一個哈希地址,直到不出現衝突爲止。

f,(key) = RH,( key ) (i = 1,2,3,4.....k)

這裏的RH,就是不同的散列函數,你可以把我們之前說過的那些散列函數都用上,每當發生衝突時就換一個散列函數,相信總有一個能夠解決衝突的。這種方法能使關鍵字不產生聚集,但是代價就是增加了計算時間。是不是很簡單啊。

鏈地址法

下面我們再設想以下情景。

袁記菜館內,鈴鈴鈴,鈴鈴鈴電話鈴又響了,那個大鵬又來訂房間了。

大鵬:老袁啊,我一會去你那喫個飯,還是上回那個包間

袁廚:大鵬你下回能不能早點說啊,又沒人訂走了,這回是老王訂的

大鵬:老王這個老東西啊,反正也是熟人,你再給我整個桌子,我拼在他後面吧

不好意思啊各位同學,信鴿最近太貴了還沒來得及買。上面的情景就是模擬我們的新的處理衝突的方法鏈地址法。

上面我們都是遇到衝突之後,就換地方。那麼我們有沒有不換地方的辦法呢?那就是我們現在說的鏈地址法。

還記得我們說過得同義詞嗎?就是 key 不同 f(key) 相同的情況,我們將這些同義詞存儲在一個單鏈表中,這種表叫做同義詞子表,散列表中只存儲同義詞子表的頭指針。我們還是用剛纔的例子,關鍵字集合爲{12,67,56,16,25,37,22,29,15,47,48,21},表長爲12,我們再用散列函數 **f(key) = key mod 12。**我們用了鏈地址法之後就再也不存在衝突了,無論有多少衝突,我們只需在同義詞子表中添加結點即可。下面我們看下鏈地址法的存儲情況。

鏈地址法雖然能夠不產生衝突,但是也帶來了查找時需要遍歷單鏈表的性能消耗,有得必有失嘛。

公共溢出區法

下面我們再來看一種新的方法,這回大鵬又要來喫飯了。

袁記菜館內.....

袁廚:呦,這是什麼風把你給刮來了,咋沒開你的大奔啊。

大鵬:哎呀媽呀,別那麼多廢話了,我快餓死了,你快給我找個位置,我要喫點飯。

袁廚:你來的,太不巧了,咱們的店已經滿了,你先去旁邊的小屋看會電視,等有空了我再叫你。小屋裏面還有幾個和你一樣來晚的,你們一起看吧。

大鵬:電視?看電視?

上面得情景就是模擬我們的公共溢出區法,這也是很好理解的,你不是衝突嗎?那衝突的各位我先給你安排個地方待著,這樣你就有地方住了。我們爲所有衝突的關鍵字建立了一個公共的溢出區來存放。

那麼我們怎麼進行查找呢?我們首先通過散列函數計算出散列地址後,先於基本表對比,如果不相等再到溢出表去順序查找。這種解決衝突的方法,對於衝突很少的情況性能還是非常高的。

散列表查找算法(線性探測法)

下面我們來看一下散列表查找算法的實現

首先需要定義散列列表的結構以及一些相關常數,其中elem代表散列表數據存儲數組,count代表的是當前插入元素個數,size代表哈希表容量,NULLKEY散列表初始值,然後我們如果查找成功就返回索引,如果不存在該元素就返回元素不存在。

我們將哈希表初始化,爲數組元素賦初值。

插入操作的具體步驟:

(1)通過哈希函數(除法散列法),將 key 轉化爲數組下標

(2)如果該下標中沒有元素,則插入,否則說明有衝突,則利用線性探測法處理衝突。詳細步驟見註釋

查找操作的具體步驟:

(1)通過哈希函數(同插入時一樣),將 key 轉成數組下標

(2)通過數組下標找到 key值,如果 key 一致,則查找成功,否則利用線性探測法繼續查找。

下面我們來看一下完整代碼

散列表性能分析

如果沒有衝突的話,散列查找是我們查找中效率最高的,時間複雜度爲O(1),但是沒有衝突的情況是一種理想情況,那麼散列查找的平均查找長度取決於哪些方面呢?

1.散列函數是否均勻

我們在上文說到,可以通過設計散列函數減少衝突,但是由於不同的散列函數對一組關鍵字產生衝突可能性是相同的,因此我們可以不考慮它對平均查找長度的影響。

2.處理衝突的方法

相同關鍵字,相同散列函數,不同處理衝突方式,會使平均查找長度不同,比如我們線性探測有時會堆積,則不如二次探測法好,因爲鏈地址法處理衝突時不會產生任何堆積,因而具有最佳的平均查找性能

3.散列表的裝填因子

本來想在上文中提到裝填因子的,但是後來發現即使沒有說明也不影響我們對哈希表的理解,下面我們來看一下裝填因子的總結

裝填因子 α = 填入表中的記錄數 / 散列表長度

散列因子則代表着散列表的裝滿程度,表中記錄越多,α就越大,產生衝突的概率就越大。我們上面提到的例子中 表的長度爲12,填入記錄數爲6,那麼此時的 α = 6 / 12 = 0.5 所以說當我們的 α 比較大時再填入元素那麼產生衝突的可能性就非常大了。所以說散列表的平均查找長度取決於裝填因子,而不是取決於記錄數。所以說我們需要做的就是選擇一個合適的裝填因子以便將平均查找長度限定在一個範圍之內。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章