9.雜湊表
雜湊表介紹
雜湊表是一種非常重要的資料結構,但是很多學習程式設計的人一直搞不懂雜湊表到底是如何實現的
在這一章中,我們就一點點來實現一個自己的雜湊表,通過實現來李傑雜湊表背後的原理和它的優勢
幾乎所有的程式語言都有直接或者間接的應用這種資料結構,
雜湊表通常是基於陣列進行實現的,但是相對於陣列,它也很多的優勢:
它可以提供非常快速的插入-刪除-查詢操作
無論多少資料,插入和刪除值需要接近常量的時間:即O(1)的時間級。實際上,只需要幾個機器指令即可完成
雜湊表的速度比樹還要快,基本可以瞬間查詢到想要的元素
雜湊表相對於樹來說編碼要容易得多
雜湊表相對於陣列的一些不足:
雜湊表中的資料是沒有順序的,所以不能以一種固定的方式(比如從小到大)來遍歷其中的元素
通常情況下,雜湊表中的key是不允許重複的,不能放置相同的key,用於儲存不同的元素
雜湊表到底是什麼呢?
那麼,雜湊表到底是什麼呢?
似乎還是沒有說它到底是什麼
這也是雜湊表不好理解的地方,不像陣列和連結串列,甚至是樹一樣直接畫出你就知道它的結構,甚至是原理了
它的結構就是陣列,但是它神奇的地方在於對下標值的一種變換,這種變換我們可以稱之為雜湊函式,通過雜湊函式,通過雜湊
函式可以獲得HashCode。
不著急,我們慢慢來認識它到底是什麼
我們通過三個案例,案例需要你挑選某種資料結構,而你會發現最好的選擇就是雜湊表
案例一:公司使用一種資料結構來儲存所有員工
案例二:設計一個數據結構,儲存聯絡人和電話
案例三:使用一種資料結構儲存單詞資訊,比如有50000個單詞,找到單詞後每個單詞有自己的翻譯&讀音&應用等等
案例一:公司員工儲存
案例二:聯絡人和電話儲存
案例三:50000個單詞的儲存
字母轉數字的案例一
似乎所有的案例都指向一個目標:將字串轉成下標值
但是,怎樣才能將一個字串轉成陣列的下標值呢?
單詞/字串轉下標值,其實就是字母/文字轉文字
怎麼轉?
現在我們需要設計一種方案,可以將單詞轉成適當的下標
其實計算機中有很多的編碼方法就是用數字代替單詞的字元。就
是字元編碼。(常見的字元編碼?)
比如ASCII編碼:a是98,b是98,依次類推122代表z
我們也可以設計一個自己的編碼系統,比如a是1,b是2,c是3,依
次類推,z是26.
當然我們可以加上空格用0代表,就是27個字元(不考慮大寫問題)
但是,有了編碼系統後,一個單詞如何轉成數字呢?
方案一:數字相加
一種轉換單詞的簡單方案就是把單詞每個字元的編碼求和
例如單詞cats轉成陣列:3+1+20+19=43,
那麼43就作為cats單詞的下標存在資料中
問題:按照這種方案有一個很明顯的問題就是很多單詞最終的下標可能都是43.
比如was/tin/give/tend/moan/tick等等
我們知道陣列中一個下標值位置只能儲存一個數據
如果存入後來的資料,必然會造成資料的覆蓋
一個下標儲存這麼多單詞顯然是不合理的。
字母轉數字的方案二
方案二:冪的連乘
現在,我們想通過一種演算法,讓cats轉成數字後不那麼普通
數字相加的方案就有些過於普通了
有一種方案就是使用冪的連乘,什麼是冪的連乘呢?
其實我們平時使用的大於10的數字,可以用一種冪的連乘來表示它的
唯一性:比如:7654 = 7 * 10的3次方 + 6 * 10的2次方 + 5 * 10 + 4
我們的單詞是可以使用這種方案來表示:比如cats = 3 * 27的3次方 + 1*27的2次方 + 20*27+17 = 60337
這樣得到的數字可以基本保證它的唯一性,不會和別的單詞重複
問題:如果一個單詞是zzzzzzzzzz(一般英文單詞不會超過10個字元),那麼得到的數字超過7000000000000,陣列可以表示這麼大的下標值嗎?
而且就算能建立這麼大的陣列,實時上有很多事無效的單詞
建立這麼大的陣列是沒有意義的
兩種方案總結:
第一種方法(把數字相加求和)產生的陣列下標太少
第二種方案(與27的冪相乘求和)產生的陣列下標又太多
認識雜湊化
現在需要一種壓縮方法,把冪的連乘方案系統中得到的巨大整數範圍壓縮到可接受的陣列範圍中
對於英文詞典,多大的陣列才合適呢?
如果直有50000個單詞,可能會定義一個長度為50000的陣列
但是實際情況中,往往需要更大的空間來儲存這些單詞,因為
我們不能保證單詞會對映到每一個位置
比如兩倍的大小:100000
如何壓縮呢?
現在,就找一種方法,把0到超過7000000000000的範圍,壓縮為從0到100000
有一種簡單的方法就是使用取餘操作符,它的作用是得到一個數
被另外一個數整除後的餘數
取餘操作的實現:
為了看到這個方法如何工作,我們先來看一個小點的數字範圍壓縮到一個小點的空間中
假設把從0~199的數字,比如使用largeNumber代表,壓縮為從0到9的數字,
比如使用samallRange代表
下標值的結果:index = largeNumber % smallRange
當一個數被10整除時,餘數一定在0~9之間
比如13%10=3,157%10=7
當然,這中間還是會有重複,不過重複的數量明顯變小了
因為我們的陣列時100000,而只有50000個單詞
就好比,你在0~199中間選取5個數字,放在這個長度為10的陣列中,
也會重複,但是重複的概率非常小。(後面我們會講到真的發生重複了應該怎麼解決)
雜湊表的一些概念
認識情況了上面的內容,相信你應該懂了雜湊表的原理了,我們來看看幾個感念:
雜湊化:將大數字轉換成陣列範圍內下標的過程,我們稱之為雜湊化
雜湊函式:通常我們會將單詞轉成大數字,大數字在進行雜湊化的程式碼實現放在一個函式中,這個函式我們稱為雜湊函式
雜湊表:最終將資料插入到的這個陣列,對整個結構的封裝,我們就稱之為時一個雜湊表
但是,我們還有問題需要解決:
雖然,我們在一個100000的陣列中,放50000個單詞已經足夠
但是通過雜湊化後的下標值依然可能會重複,如何解決這種重複的問題呢?
什麼是衝突?
儘管50000和單詞,我們使用了100000個位置來儲存,並且通過一種相對比較好的
雜湊函式來完成。但是依然有可能會發生衝突
比如melioration這個單詞,通過雜湊函式得到陣列的下標值後,發現那個位置上
已經存在一個單詞demystify
因為它經過雜湊化後喝melioration得到的下標實現相同的
這種情況我們稱為衝突
雖然我們不希望這種情況發生,當然更希望每個下標對應一個數據項,但是通常這是不可能的
衝突不可避免,我們只能解決衝突
就像之前0~199的數字選取5個放在長度為10的單元格中
如果我們隨機選出來的是33,82,11,45,90,那麼最終他們的
位置會是3-2-1-5-0,沒有發生衝突
我們需要針對這種衝突提出一些解決方案
即使衝突的可能性比較小,你依然需要考慮到這種情況
以便發生的時候進行對應的代理程式碼
如何解決這種衝突呢?常見的情況有兩種方案
鏈地址法 開放地址法
鏈地址法
鏈地址法是一種比較常見的解決衝突的方案(也稱為拉鍊法)
其實,如果你理解了為什麼產生衝突,看到圖後就可以立馬理解鏈地址是什麼含義
開放地址法
開放地址法的主要工作方式是尋找空白的單元格來新增重複的資料
我們還是通過圖片來了解開放地址的工作方式
圖片解析:
從圖片的文字中我們可以瞭解到
開發地址法其實就是要尋找空白的位置來放置衝突的資料項
但是探索這個位置的方式不同,有三種方法:
線性探測 二次探測 再雜湊法
線性探測
線性探測非常好理解:線性的查詢空白的單元
插入的32:
經過雜湊化得到的index=2,但是在插入的時候,發現該位置已經又了82,怎麼辦呢?
線性探測就是從index位置+1開始一點點查詢合適的位置來放置32,什麼是合適的位置呢?
空的位置就是合適的位置,在我們上面的例子中就是index=3的位置,這個時候32就會放在該位置。
查詢32呢?
查詢32和插入32比較相似。
首先經過雜湊化得到index=2,比如2的位置結果和查詢的數值是否相同,相同那麼就直接返回
不相同呢?線性查詢,從index位置+1開始查詢和32一樣的。
這裡有一個特別需要注意的地方:如果32的位置我們之前沒有插入,是否將整個雜湊表查詢一邊來確定32存不存在嗎?
當然不是,查詢過程有一個約定,就是查詢到空位置,就停止
因為查詢到這個有空位置,32之前不可能跳過空位置去其他的位置
刪除32呢?
刪除操作和插入查詢比較類似,但是也有一個特別的注意點。
注意:刪除操作一個數據項時,不可以將這個位置下標的內容設定為null,為什麼呢?
因為將它設定為null可能會影響我們之後查詢其他操作,所以通常刪除一個位置的資料項時,我們可以將它進行特殊處理(比如設定為-1)
當我們之後看到-1位置的資料項時,就知道查詢時要繼續查詢,但是插入時這個位置可以放置資料。
線性探測問題:
線性探測有一個比較嚴重的問題,就是聚焦,什麼時聚焦呢?
比如我在沒有任何資料的時候,插入的是22-23-24-25-26,那麼意味著下標值:2-3-4-5-6的位置都有元素
這叫一連串填充單元就叫做聚焦。
聚焦會影響雜湊表的效能,無論是插入/查詢/刪除都會影響。
比如我們插入一個32,會發現連續的單元都不允許我們放置資料,並且在這個過程中我們需要探索多次
二次探測可以解決一部分這個問題,我們一起來看一看
二次探測
我們剛才談到,線性探測存在的問題:
如果之前的資料是連續插入的,那麼新插入的一個數據可能需要探測很長的距離
二次探測線上性探測的基礎上進行了優化:
二次探測主要優化的是探測時的步長,什麼意思呢?
線性探測,我們可以看成時步長為1的探測,比如從下標值x開始,那麼線性測試就是x+1,x+2,x+3依次探測
二次探測,對步長做了優化,比如從下標值x開始,x+1的2次方,x+2的2次方,x+3的3次方
這樣就可以一次性探測比較長的距離,比避免那些聚焦帶來的影響
二次探測的問題:
但是二次探測依然存在問題,比如我們連續插入的時32-1112-82-2-192,那麼它們依次累加的時候步長的1相同的
也就是這種情況下造成步長不一的一種聚焦,還是會影響效率。(當然這種可能性相對於連續的數字會小一些)
怎麼根本解決這個問題呢?讓每個人的步長不一樣,一起來看看再雜湊法吧
再雜湊法
為了消除線性探測和二次探測無論步長+1還是步長+平法中存在的問題,還有一種最常用的解決方案:再雜湊法
再雜湊法:
二次探測的演算法產生的探測序列步長是固定的:1,4,9,16,依次類推
現在需要一種方法:產生一種依賴關鍵字的探測序列,而不是每個關鍵字都一樣
那麼,不同的關鍵字即使對映到相同的陣列下標,也可以使用不同的探測序列
再雜湊法的做法就是:把關鍵字用另外一個雜湊函式,再做一次雜湊化,用這次雜湊化的結果作為步長。
對於指定的關鍵字,步長再整個探測中是不變的,不過不同的關鍵字使用不同的步長
第二次雜湊化需要具備如下特點:
和第一個雜湊函式不同(不要再使用上一次的雜湊函數了,不然結果還是原來的位置)
不能輸出為0(否則,將沒有步長,每次探測都是原地踏步,演算法就進入了死迴圈)
其實,我們不用費腦細胞來設計了,計算機專家已經設計出一種工作很好的雜湊函式
stepSize = constant - (Key % constant)
其中constant是質數,且小於陣列的容量
例如:stepSize = 5 - (key % 5),滿足需求,並且結果不可能為0.
雜湊化的效率
雜湊表中執行插入和搜尋操作效率是非常高的
如果沒有產生衝突,那麼效率就會更高
如果發生衝突,存取時間就依賴後來的探測長度
平均探測長度以及平均存取時間,取決於填裝因子,隨著填裝因子變大,探測長度也越來越長
隨著填裝因此變大,效率下降的情況,在不同開放地址方案中比鏈地址法更嚴重,所以我們來對比一下他們的
效率,再決定我們選取的方案
在分析效率之前,我們先了解一個概念:裝填因子
裝填因子表示當前雜湊表中已經包含的資料項和整個雜湊表長度的比值
裝填因子 = 總資料項 / 雜湊表長度
開放地址法的裝填因子最大是多少呢?1,因為它必須尋找到空白的單元才能將元素放入
鏈地址法的裝填因子呢?可以大於1,因為拉鍊法可以無限的延伸下去,只要你願意。(當然後面效率就變低了)
線性探測效率
下面的等式顯示了線性探測時,探測序列(P)和填裝因子(L)的關係
對成功的查詢:P=(1+1/(1-L)^2)/2
對不成功的查詢:P=(1+1/(1-L))/2
公式來自於Knuth(演算法分析領域的專家,現代計算機的先驅人物),這些公式的推導自己去看了一下,確實有些繁瑣,
這裡不再給出推導過程,僅僅說明它的效率
圖片解析:
當填充因子是1/2時,成功的搜尋需要1.5次比較,不成功的搜尋需要2.5次
當填充因子為2/3時,分別需要2.0次和5.0次比較
如果填充因子更大,比較次數會非常大
應該使填充因子保持在2/3以下,最好在1/2一下,另外一面,填裝因子越低,對於給定
數量的資料項,就需要越多的空間
實際情況中,最好的填裝因子取決於儲存效率和速度之間的平衡,隨著填裝因子變小
儲存效率下降,而速度上升
二次探測和再雜湊化
二次探測和再雜湊化的效能相比。它們的效能比線性探測略好
對成功的搜尋,公式是:-log2(1-loadFactor)/loadFactor
對於不成功的搜尋,公式是:1/(1-loadFactor)
圖片解析:
當填裝因子是0.5時,成功和不成功的查詢平均需要2次比較
當填裝因子為2/3時,分別需要2.37和3.0次比較
當填裝因子為0.8時,分別需要2.9和5.0次
因此對於較高的填裝因子,對比線性探測,二次探測和再雜湊法還是可以忍受的。
鏈地址法
鏈地址法的效率分析有些不同,一般來說比開放地址法簡單,我們來分析一下這個公式應該時怎麼樣的
加入雜湊表包含arraySize個數據項,每個資料項有一個連結串列,在表中一共包含N個數據項
那麼,平均起來每個連結串列有多少個數據項呢?非常簡單,N / arraySize
有沒有發現這個公式有點眼熟?其實就是裝填因子
OK,那麼我們就可以求出查詢查詢成功和不成功的次數了
成功可能只需要查詢連結串列的一半即可:1 + loadFactor / 2
不成功呢?可能需要將整個連結串列查詢玩才知道不成功:1 + loadFactor
經過上面的比較我們可以發現,鏈地址法相對來說效率是好於開放地址法的
所以再真是開發中,使用鏈地址法的情況較多
因為他不會因為添加了某元素後效能急劇下降
比如再Java的HashMap中使用的就是鏈地址法
優秀的雜湊函式
講了很久的雜湊表理論知識,你有沒有發現再整個過程中,一個非常簡單的東西:雜湊函式?
好的雜湊函式應該儘可能讓計算的過程變得簡單,提高計算的效率
雜湊表的主要優點是它的速度,所以在速度上不能足夠,那麼就達不到設計的目的了
提高速度的一個方法就是讓雜湊函式中儘量少的有乘法和除法,因為它們的效能是比較低的
設計好的雜湊函式應該具備那些優點?
快速的計算
雜湊表的優勢就在於效率,所以快速獲取到對應的hashCode非常重要
我們需要通過快速的計算來獲取到元素對應的hashCode
均勻的分佈
雜湊表中,無論是鏈地址法還是開放地址法,當多個元素對映到同一個位置的時候,都會影響效率
所以,優秀的雜湊函式應該儘可能將元素對映到不同的位置,讓元素在雜湊表中均勻的分佈
快速計算:霍納法則
在前面,我們計算雜湊值的時候使用的方式
cats = 3*27的3次方+1*27的2次方+20*27+17=60337
這種方式是直觀的計算結果,那麼這種計算方式會
進行幾次乘法幾次加法呢?
當然,我們可能不止4項,可能有更多項
我們抽象一下,這個表示式其實是一個多項式:
a(n)x^n+a(n-1)x^(n-1)+...+a(1)x+a(0)
現在問題就變成了多項式有多少次乘法和加法:
乘法次數:n+(n-1)+...+1=n(n+1)/2
加法次數:n次
多項式的優化:霍納法則
解決這類求值問題的高效演算法-霍納法則。在中國,霍納法則也被稱為為秦九韶演算法
通過如下變換我們可以得到一種快得多的演算法,即
Pn(x)=anx^n+a(n - 1)x^(n - 1)+...+a1x+a0=
((...(((anx + an -1)x + an - 2)x + an - 3)...)x+a1)x+a0
這種求值的安排我們稱為霍納法則
變換後,我們需要多少次乘法,多少次加法呢?
乘法次數:N次
加法次數:N次
如果使用大Q表示時間複雜度的話,我們直接從O(N方)降到了O(N)
均勻分佈
均勻的分佈
在設計雜湊表時,我們已經有辦法處理對映到相同下標值的情況:鏈地址發或者開放地址法
但是無論那種方案,為了提供效率,最好的情況還是讓資料在雜湊表中均勻分佈
因此,我們需要在使用常量的地方,儘量使用質數
那些地方我們會使用到常量呢?
質數的使用:
雜湊表的長度
N次冪的底數(我們之前使用的是27)
為什麼他們使用質數,會讓雜湊表分佈更加均勻呢?
我們這裡簡單來討論一下
雜湊表的長度
雜湊表的長度最好使用質數
再雜湊法中質數的重要性:
假設表的容量不是質數,例如:表長為15(下標值0~14)
有一個特定關鍵字對映0,步長為5.探測序列是多少呢?
0 - 5 - 10 - 0 - 5 - 10,依次類推,迴圈下去
演算法只嘗試著三個單元,如果這三個單元已經有了資料,那麼會一直迴圈下去,直到程式崩潰
如果容量是一個質數,比如13,探測序列是多少呢?
0 - 5 -10 - 2 - 7 - 12 - 4 - 9 - 1 - 6 - 11 - 3,一直這樣下去
不僅不會產生迴圈,而且可以讓資料在雜湊表中更加均勻的分佈
鏈地址法中質數沒有那麼重要,甚至在Java中故意是2的N次冪
Java中的HashMap
Java中的雜湊表採用的是鏈地址法
HashMap的初始長度是16,每次自動擴充套件(我們還沒有聊到擴充套件的話題),長度必須是2的次冪
這是為了服務於從Key對映到index的演算法
HashMap中為了提高效率,採用了位運算的方式
HashMap中index的計算公式:index = HashCode (Key) & (Length - 1)
比如計算book的hashcode,結果為十進位制的3029737,二進位制的101110001110101110 1001
假定HashMap長度是預設的16,計算Length-1的結果為十進位制15,二進位制的1111
把以上兩個結果做與運算,1011100011101011110 1001 & 1111 = 1001,十進位制是0,所以index = 9
這樣的方式相對於取模來說效能是高的,因為計算機更運算計算二進位制的資料
但是,我個人發現JavaScript中進行較大資料的位運算時會出問題,所以我的程式碼實現中還是使用了取模
另外,我這裡為了方便程式碼之後向開放地址法中遷移,容量還是選擇使用質數
設定雜湊函式
<script> // 設計雜湊函式 // 將字串轉成比較大的數字:hashCode // 將大的數字hashCode壓縮到陣列範圍(大小)之內 function hashFunc(str, size) { // 定義hashCode變數 var hashCode = 0 // 霍納演算法,來計算hashCode的值 // cats -> Unicode編碼 for (let i = 0; i < str.length; i++) { hashCode = 37 * hashCode + str.charCodeAt(i) } // 取餘操作 var index = hashCode % size return index } // 測試雜湊函式 console.log(hashFunc('abc', 7)) console.log(hashFunc('def', 7)) console.log(hashFunc('ghi', 7)) console.log(hashFunc('jkl', 7)) </script>
建立雜湊表
經過前面那麼多內容的學習,我們現在可以真正實現自己的雜湊表了
可能你學到這裡的時候,已經感覺到資料結構的一些複雜性
但是如果你仔細品味,你也會發現它在設計時候的巧妙和優美
當你愛上它的那一刻,你也真正愛上了程式設計
我們這裡採用鏈地址法來實現雜湊表
實現的雜湊表(基於storage的陣列)每個index對應的是一個數組(bucket)(當然基於連結串列也可以)
bucket中存放什麼呢?我們最好將key和value都放進去,我們繼續使用一個數組(其實其他語言使用元組更好)
最終我們的雜湊表的資料格式是這樣:[ [ [ k, v],[ k, v ], [ k, v ] ], [ [ k, v],[ k, v ], [ k, v ] ] ]
封裝雜湊表
<script> // 封裝雜湊類 function HashTable() { // 屬性 this.storage = [] this.count = 0 this.limit = 0 // 方法 } </script>
程式碼解析:
我們定義了三個屬性
storage作為我們的陣列,陣列中存放相關的元素
count表示當前已經存在了多少資料
limit用於標記陣列中一共可以存放多少個元素
插入&修改資料
雜湊表的插入和修改操作是同一個函式:
因為,當使用者傳入一個<Key,Value>時
如果原來不存該key,那麼就是插入操作
如果已經存在該key,那麼就是修改操作
// 插入&修改操作 HashTable.prototype.put = function(key, value) { // 根據key獲取對應的index let index = this.hashFunc(key, this.limit) // 根據index取出對應的bucket let bucket = this.storage[index] // 判斷該bucket是否為null if (bucket == null) { bucket = [] this.storage[index] = bucket } // 判斷是否是修改資料 for (let i = 0; i < bucket.length; i++) { let tuple = bucket[i] if (tuple[0] == key) { tuple[1] = value return } } // 進行新增操作 bucket.push([key, value]) this.count += 1 }
程式碼解析:
步驟1:根據傳入的key獲取對應的hashCode,也就是陣列的index
步驟2:從雜湊表的index位置中取出桶(另外一個數組)
步驟3:檢視上一步的bucket是否為null
為null,表示之前在該位置沒有放置過任何內容,那麼就新建一個數組[]
步驟4:檢視是否之前已經放置過key對應的value
如果放置過,那麼就是依次代替操作,而不是插入新的資料
我們使用一個變數override來記錄是否是修改操作
步驟5:如果不是修改操作,那麼插入新的資料
在bucket中push新的[key,value]即可
注意:這裡需要將count + 1,因為資料增加了一項
獲取方法
//獲取操作 HashTable.prototype.get = function(key) { // 根據key獲取對應的index let index = this.hashFunc(key, this.limit) // 根據index獲取對應的bucket let bucket = this.storage[index] // 判斷bucket是否為null if (bucket == null) { return null } // 有bucket,那麼就進行線性查詢 for (let i = 0; i < bucket.length; i++) { let tuple = bucket[i] if (tuple[0] == key) { return tuple[1] } } // 依然沒有找到,那麼返回null return null }
思路:
根據key獲取對應的index
根據index獲取對應的bucket
判斷bucket是否為null
如果為null,直接返回null
線性查詢bucket中每一個key是否等於傳入的key
如果等於,那麼直接返回對應的value
遍歷完後,依然沒有找到對應的key
直接return null 即可
刪除方法
// 刪除方法 HashTable.prototype.remove = function(key) { // 根據key獲取對應的index let index = this.hashFunc(ket, this, limit) // 根據index獲取對應的bucket let bucket = this.storage[index] // 判斷bucket是否為null if (bucket == null) return null // 有bucket,那麼就進行線性查詢,並且刪除 for (let i = 0; i < bucket.length; i++) { let tuple = bucket[i] if (tuple[0] == key) { bucket.splice(i, 1) this.count-- return tuple[1] } } // 依然沒有找到,那麼返回null return null }
思路:
根據key獲取對應的index
根據index獲取bucket
判斷bucket是否存在,如果不存在,那麼直接返回null
線性查詢bucket,尋找對應的資料,並且刪除
依然沒有找到,那麼返回null
其他方法
// 其他方法 // 判斷雜湊表是否為空 HashTable.prototype.isEmpty = function() { return this.count == 0 } // 獲取雜湊表中元素的個數 HashTable.prototype.size = function() { return this.count }
雜湊表擴容的思想
為什麼需要擴容?
目前,我們是將所有的資料項放在長度為7的陣列中的
因為我們使用的是鏈地址發,loadFactor可以大於1,所以這個雜湊表可以無限制的插入新資料
但是,隨個數據量的增多,每一個index對應的bucket會越來越長,也就造成效率的降低
所以,在合適的情況對陣列進行擴充。比如擴容兩倍
如何進行擴充?
擴充可以簡單的將容量增大兩倍(不是質數嗎?質數的問題後面再討論)
但是這種情況下,所有的資料項一定要同時進行修改(重新呼叫雜湊函式,來獲取到不同的位置)
比如hashCode=12的資料項,在length=8的時候,index=5,在長度為16的時候呢?index=12
這是一個耗時的過程,但是如果陣列需要擴容,那麼這個過程是必要的
什麼情況下擴充呢?
比較常見的情況是loadFactor>0.75的時候進行擴容
比如Java的雜湊表就是在裝填因子大於0.75的時候,對雜湊表進行擴容
擴容縮容程式碼
// 雜湊表擴容/縮容 HashTable.prototype.resize = function(newLimit) { // 儲存舊的陣列內容 let oldStorage = this.storage // 重置所有屬性 this.storage = [] this.count = 0 this.limit = newLimit // 遍歷oldStorage中所有的bucket for (let i = 0; i < oldStorage.length; i++) { // 取出對應的bucket let bucket = oldStorage[i] // 判斷bucket是否為null if (bucket == null) { continue } // bucket 中有資料,那麼取出資料,重新插入 for (let j = 0; j < bucket.length; j++) { let tuple = bucket[j] this.put(tuple[0], tuple[1]) } } } // 判斷是否需要擴容操作 if (this.count > this.limit * 0.75) { this.resize(this.limit * 2) } // 縮小容量 if (this.limit > 7 && this.count < this.limit * 0.25) { this.resize(Math.floor(this.limit / 2)) }
容量質數
我們前面提到過,容量最好是質數
雖然在鏈地址法中將容量設定為質數,沒有在開放地址法中重要
但是其實鏈地址中質數作為容量也更利於資料的均勻分佈,所以,我們還是完成一下這個步驟
我們這裡先討論一個常見的面試題,判斷一個數的質數
質數的特點:
質數也稱為素數
質數表達大於1的自然數中,只能被1和自己整除的數
OK,瞭解了這個特點,應該不難寫出它的演算法
判斷是否是質數
<script> // 封函式:判斷傳入的數字是否是質數 // 特點:只能被1和自己整除,不能被2到之間的num-1數字整除 function isPrime(num) { for (let i = 2; i < num; i++) { if (num % i == 0) { return false } } return true } // 驗證函式 console.log(isPrime(3)) console.log(isPrime(9)) console.log(isPrime(37)) console.log(isPrime(20)) </script>
更高效的質數判斷
但是,這種做法的效率並不高,為什麼呢?
對於每個數n,其實並不需要從2判斷到n-1
一個數若可以進行因數分解,那麼分解時得到的兩個數一定是一個小於等於sqrt(n),一個大於等於sqrt(n)。
比如16可以被分分別,那麼是2*8,2小於sqrt(16),也就是4,8大於4,而4*4都是等於sqrt(n)
所以其實我們遍歷到等於sqrt(n)即可
判斷是否是質數
<script> // 封裝函式判斷質數 function isPrime(num) { // 獲取num的平方根 let temp = parseInt(Math.sqrt(num)) // 迴圈判斷 for (let i = 2; i <= temp; i++) { if (num % i == 0) { return false } } return true } // 驗證函式 console.log(isPrime(3)) console.log(isPrime(9)) console.log(isPrime(37)) console.log(isPrime(20)) </script>
雜湊表完整程式碼
<script> // 封裝雜湊類 function HashTable() { // 屬性 this.storage = [] this.count = 0 this.limit = 7 // 方法 // 雜湊函式 HashTable.prototype.hashFunc = function(str, size) { // 定義hashCode變數 let hashCode = 0 // 霍納演算法,來計算hashCode的值 // cats -> Unicode編碼 for (let i = 0; i < str.length; i++) { hashCode = 37 * hashCode + str.charCodeAt(i) } // 取餘操作 let index = hashCode % size return index } // 插入&修改操作 HashTable.prototype.put = function(key, value) { // 根據key獲取對應的index let index = this.hashFunc(key, this.limit) // 根據index取出對應的bucket let bucket = this.storage[index] // 判斷該bucket是否為null if (bucket == null) { bucket = [] this.storage[index] = bucket } // 判斷是否是修改資料 for (let i = 0; i < bucket.length; i++) { let tuple = bucket[i] if (tuple[0] == key) { tuple[1] = value return } } // 進行新增操作 bucket.push([key, value]) this.count += 1 // 判斷是否需要擴容操作 if (this.count > this.limit * 0.75) { let newSize = this.limit * 2 let newPrime = this.getPrime(newSize) this.resize(newPrime) } } //獲取操作 HashTable.prototype.get = function(key) { // 根據key獲取對應的index let index = this.hashFunc(key, this.limit) // 根據index獲取對應的bucket let bucket = this.storage[index] // 判斷bucket是否為null if (bucket == null) { return null } // 有bucket,那麼就進行線性查詢 for (let i = 0; i < bucket.length; i++) { let tuple = bucket[i] if (tuple[0] == key) { return tuple[1] } } // 依然沒有找到,那麼返回null return null } // 刪除方法 HashTable.prototype.remove = function(key) { // 根據key獲取對應的index let index = this.hashFunc(key, this.limit) // 根據index獲取對應的bucket let bucket = this.storage[index] // 判斷bucket是否為null if (bucket == null) return null // 有bucket,那麼就進行線性查詢,並且刪除 for (let i = 0; i < bucket.length; i++) { let tuple = bucket[i] if (tuple[0] == key) { bucket.splice(i, 1) this.count-- return tuple[1] // 縮小容量 if (this.limit > 7 && this.count < this.limit * 0.25) { let newSize = Math.floor(this.limit / 2) let newPrime = this.getPrime(newSize) this.resize(newSize) } } } // 依然沒有找到,那麼返回null return null } // 雜湊表擴容/縮容 HashTable.prototype.resize = function(newLimit) { // 儲存舊的陣列內容 let oldStorage = this.storage // 重置所有屬性 this.storage = [] this.count = 0 this.limit = newLimit // 遍歷oldStorage中所有的bucket for (let i = 0; i < oldStorage.length; i++) { // 取出對應的bucket let bucket = oldStorage[i] // 判斷bucket是否為null if (bucket == null) { continue } // bucket 中有資料,那麼取出資料,重新插入 for (let j = 0; j < bucket.length; j++) { let tuple = bucket[j] this.put(tuple[0], tuple[1]) } } } // 判斷某個數字是否是質數 HashTable.prototype.isPrime = function(num) { // 判斷num的平方根 let temp = parseInt(Math.sqrt(num)) // 迴圈判斷 for (let i = 2; i <= temp; i++) { if (num % i == 0) { return false } } return true } // 獲取質數的方法 HashTable.prototype.getPrime = function(num) { while (!this.isPrime(num)) { num++ } return num } } // 測試雜湊表 // 建立雜湊表 let ht = new HashTable() // 插入資料 ht.put('abc', '123') ht.put('cba', '321') ht.put('nba', '521') ht.put('mba', '520') // 獲取資料 console.log(ht.get('abc')) // 修改方法 ht.put('abc', '111') console.log(ht.get('abc')) // 刪除方法 ht.remove('abc') console.log(ht.get('abc')) </script>