如需轉載, 請咨詢作者, 并且注明出處.
有任何問題, 可以關注我的微博: coderwhy, 或者添加我的微信: 372623326
前面, 我們使用了大量的篇幅來解析哈希表的理論知識.
現(xiàn)在, 我們進入代碼的實施階段, 但是實施之前, 先來深入一個比較重要的話題: 哈希函數(shù).
一. 哈希函數(shù)
講了很久的哈希表理論知識, 你有沒有發(fā)現(xiàn)在整個過程中, 一個非常重要的東西: 哈希函數(shù)呢?
我們這里來探討一下, 設計好的哈希函數(shù)應該具備哪些優(yōu)點.
快速的計算
- 好的哈希函數(shù)應該盡可能讓計算的過程變得簡單, 應該可以快速計算出結果.
- 哈希表的主要優(yōu)點是它的速度, 所以在速度上不能滿足, 那么就達不到設計的目的了.
- 提高速度的一個辦法就是讓哈希函數(shù)中盡量少的有乘法和除法. 因為它們的性能是比較低的.
- 在前面, 我們計算哈希值的時候使用的方式
- cats = 3*273+1*272+20*27+17= 60337
- 這種方式是直觀的計算結果, 那么這種計算方式會進行幾次乘法幾次加法呢? 當然, 我們可能不止4項, 可能有更多項
- 我們抽象一下, 這個表達式其實是一個多項式: a(n)xn+a(n-1)x(n-1)+…+a(1)x+a(0)
- 現(xiàn)在問題就變成了多項式有多少次乘法和加法:
- 乘法次數(shù): n+(n-1)+…+1=n(n+1)/2
- 加法次數(shù): n次
- 多項式的優(yōu)化: 霍納法則
- 解決這類求值問題的高效算法――霍納法則。在中國,霍納法則也被稱為秦九韶算法。
- 通過如下變換我們可以得到一種快得多的算法,即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,這種求值的安排我們稱為霍納法則。
- 變換后, 我們需要多少次乘法, 多少次加法呢?
- 乘法次數(shù): N次
- 加法次數(shù): N次.
- 如果使用大O表示時間復雜度的話, 我們直接從O(N2)降到了O(N).
均勻的分布
- 均勻的分布
- 在設計哈希表時, 我們已經有辦法處理映射到相同下標值的情況: 鏈地址法或者開放地址法.
- 但是, 為了提供效率, 最好的情況還是讓數(shù)據在哈希表中均勻分布.
- 因此, 我們需要在使用常量的地方, 盡量使用質數(shù).
- 哪些地方我們會使用到常量呢?
- 質數(shù)的使用:
- 哈希表的長度.
- N次冪的底數(shù)(我們之前使用的是27)
- 下面我們簡單來說一下為什么.
- 哈希表的長度使用質數(shù):
- 這個在鏈地址法中事實上重要性不是特別明顯, 明顯的是在開放地址法中的再哈希法中.
- 再哈希法中質數(shù)的重要性:
- 假設表的容量不是質數(shù), 例如: 表長為15(下標值0~14)
- 有一個特定關鍵字映射到0, 步長為5. 探測序列是多少呢?
- 0 - 5 - 10 - 0 - 5 - 10, 依次類推, 循環(huán)下去.
- 算法只嘗試著三個單元, 如果這三個單元已經有了數(shù)據, 那么會一直循環(huán)下去, 知道程序崩潰.
- 如果容量是一個質數(shù), 比如13. 探測序列是多少呢?
- 0 - 5 - 10 - 2 - 7 - 12 - 4 - 9 - 1 - 6 - 11 - 3, 一直這樣下去.
- 不僅不會產生循環(huán), 而且可以讓數(shù)據在哈希表中更加均勻的分布.
- 鏈地址法中質數(shù)沒有那么重要, 甚至在Java中故意是2的N次冪
- 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
- 假定HashMap長度是默認的16,計算Length-1的結果為十進制的15,二進制的1111
- 把以上兩個結果做與運算,101110001110101110 1001 & 1111 = 1001,十進制是9,所以 index=9
- 這樣的方式相對于取模來說性能是高的, 因為計算機更運算計算二進制的數(shù)據.
- 但是, 我個人發(fā)現(xiàn)JavaScript中進行較大數(shù)據的位運算時會出問題, 所以我的代碼實現(xiàn)中還是使用了取模.
- N次冪的底數(shù), 使用質數(shù):
- 這里采用質數(shù)的原因是為了產生的數(shù)據不按照某種規(guī)律遞增.
- 比如我們這里有一組數(shù)據是按照4進行遞增的: 0 4 8 12 16, 將其映射到成都為8的哈希表中.
- 它們的位置是多少呢? 0 - 4 - 0 - 4, 依次類推.
- 如果我們哈希表本身不是質數(shù), 而我們遞增的數(shù)量可以使用質數(shù), 比如5, 那么 0 5 10 15 20
- 它們的位置是多少呢? 0 - 5 - 2 - 7 - 4, 依次類推. 也可以盡量讓數(shù)據均勻的分布.
- 我們之前使用的是27, 這次可以使用一個接近的數(shù), 比如31/37/41等等. 一個比較常用的數(shù)是37.
哈希函數(shù)實現(xiàn)
-
現(xiàn)在, 我們就給出哈希函數(shù)的實現(xiàn):
function hashFunc(str, max) { // 1.初始化hashCode的值 var hashCode = 0 // 2.霍納算法, 來計算hashCode的數(shù)值 for (var i = 0; i < str.length; i++) { hashCode = 37 * hashCode + str.charCodeAt(i) } // 3.取模運算 hashCode = hashCode % max return hashCode }
-
代碼解析:
- 理解了前面所有的內容, 其實代碼就非常簡單了.
- 不再多做解釋, 有不懂的可以留言或者查看前面的內容.
-
代碼測試:
alert(hashFunc("abc", 7)) // 4 alert(hashFunc("cba", 7)) // 3 alert(hashFunc("nba", 7)) // 5 alert(hashFunc("mba", 7)) // 1
二. 哈希表
經過前面那么多內容的學習, 我們現(xiàn)在可以真正實現(xiàn)自己的哈希表了.
可能你學到這里的時候, 已經感覺到數(shù)據結構的一些復雜性, 但是如果你仔細品味, 你也會發(fā)現(xiàn)它在設計時候的巧妙和優(yōu)美, 當你愛上它的那一刻, 你也真正愛上了編程.
我們這里采用鏈地址法來實現(xiàn)哈希表:
實現(xiàn)的哈希表(基于storage的數(shù)組)每個index對應的是一個數(shù)組(bucket).(當然基于鏈表也可以.)
bucket中存放什么呢? 我們最好將key和value都放進去, 我們繼續(xù)使用一個數(shù)組.(其實其他語言使用元組更好)
最終我們的哈希表的數(shù)據格式是這樣: [[ [k,v], [k,v], [k,v] ] , [ [k,v], [k,v] ], [ [k,v] ] ]
創(chuàng)建哈希表
-
我們像封裝其他數(shù)據結構一樣, 先來創(chuàng)建一個哈希表的類: HashTable
// 創(chuàng)建HashTable構造函數(shù) function HashTable() { // 定義屬性 this.storage = [] this.count = 0 this.limit = 8 // 定義相關方法 // 哈希函數(shù) HashTable.prototype.hashFunc = function(str, max) { // 1.初始化hashCode的值 var hashCode = 0 // 2.霍納算法, 來計算hashCode的數(shù)值 for (var i = 0; i < str.length; i++) { hashCode = 37 * hashCode + str.charCodeAt(i) } // 3.取模運算 hashCode = hashCode % max return hashCode } }
-
代碼解析:
- 我們定義了三個屬性:
- storage作為我們的數(shù)組, 數(shù)組中存放相關的元素.
- count表示當前已經存在了多少數(shù)據.
- limit用于標記數(shù)組中一共可以存放多少個元素.
- 另外, 我們直接將哈希函數(shù)定義在了HashTable中.
插入&修改數(shù)據
-
現(xiàn)在, 我們來做向哈希表中插入數(shù)據
// 插入數(shù)據方法 HashTable.prototype.put = function (key, value) { // 1.獲取key對應的index var index = this.hashFunc(key, this.limit) // 2.取出數(shù)組(也可以使用鏈表) var bucket = this.storage[index] // 3.判斷這個數(shù)組是否存在 if (bucket === undefined) { // 3.1創(chuàng)建桶 bucket = [] this.storage[index] = bucket } alert(bucket) // 4.判斷是新增還是修改原來的值. var override = false for (var i = 0; i < bucket.length; i++) { var tuple = bucket[i] if (tuple[0] === key) { tuple[1] = value override = true } } // 5.如果是新增, 前一步沒有覆蓋 if (!override) { bucket.push([key, value]) this.count++ } }
-
代碼解析:
- 步驟1: 根據傳入的key獲取對應的hashCode, 也就是數(shù)組的index
- 步驟2: 從哈希表的index位置中取出桶(另外一個數(shù)組)
- 步驟3: 查看上一步的bucket是否為null
- 為null, 表示之前在該位置沒有放置過任何的內容, 那么就新建一個數(shù)組[]
- 步驟4: 查看是否之前已經放置過key對應的value
- 如果放置過, 那么就是依次替換操作, 而不是插入新的數(shù)據.
- 我們使用一個變量override來記錄是否是修改操作
- 步驟5: 如果不是修改操作, 那么插入新的數(shù)據.
- 在bucket中push新的[key, value]即可.
- 注意: 這里需要將count+1, 因為數(shù)據增加了一項.
獲取數(shù)據
-
有插入和修改數(shù)據, 就應該有根據key獲取value
// 獲取存放的數(shù)據 HashTable.prototype.get = function (key) { // 1.獲取key對應的index var index = this.hashFunc(key, this.limit) // 2.獲取對應的bucket var bucket = this.storage[index] // 3.如果bucket為null, 那么說明這個位置沒有數(shù)據 if (bucket == null) { return null } // 4.有bucket, 判斷是否有對應的key for (var i = 0; i < bucket.length; i++) { var tuple = bucket[i] if (tuple[0] === key) { return tuple[1] } } // 5.沒有找到, return null return null }
-
代碼解析:
- 步驟1: 根據key獲取hashCode(也就是index)
- 步驟2: 根據index取出bucket.
- 步驟3: 因為如果bucket都是null, 那么說明這個位置之前并沒有插入過數(shù)據.
- 步驟4: 有了bucket, 就遍歷, 并且如果找到, 就將對應的value返回即可.
- 步驟5: 沒有找到, 返回null
刪除數(shù)據
-
我們根據對應的key, 刪除對應的key/value
// 刪除數(shù)據 HashTable.prototype.remove = function (key) { // 1.獲取key對應的index var index = this.hashFunc(key, this.limit) // 2.獲取對應的bucket var bucket = this.storage[index] // 3.判斷同是否為null, 為null則說明沒有對應的數(shù)據 if (bucket == null) { return null } // 4.遍歷bucket, 尋找對應的數(shù)據 for (var i = 0; i < bucket.length; i++) { var tuple = bucket[i] if (tuple[0] === key) { bucket.splice(i, 1) this.count-- return tuple[1] } } // 5.來到該位置, 說明沒有對應的數(shù)據, 那么返回null return null }
-
代碼解析:
- 代碼思路和查詢基本一致, 不再給出解析過程. 也可以查看注釋.
其他方法
-
判斷哈希表是否為空: isEmpty
// isEmpty方法 HashTable.prototype.isEmpty = function () { return this.count == 0 }
-
獲取哈希表中數(shù)據的個數(shù)
// size方法 HashTable.prototype.size = function () { return this.count }
哈希表測試
-
我們來簡單測試一下上面的代碼
// 測試哈希表 // 1.創(chuàng)建哈希表 var ht = new HashTable() // 2.插入數(shù)據 ht.put("abc", "123") ht.put("cba", "321") ht.put("nba", "521") ht.put("mba", "520") // 3.獲取數(shù)據 alert(ht.get("abc")) ht.put("abc", "111") alert(ht.get("abc")) // 4.刪除數(shù)據 alert(ht.remove("abc")) alert(ht.get("abc"))
三. 哈希表擴容
我們在來將講一個哈希表的概念: 哈希表擴容.
哈希表擴容的思想
- 為什么需要擴容?
- 目前, 我們是將所有的數(shù)據項放在長度為8的數(shù)組中的.
- 因為我們使用的是鏈地址法, loadFactor可以大于1, 所以這個哈希表可以無限制的插入新數(shù)據.
- 但是, 隨著數(shù)據量的增多, 每一個index對應的bucket會越來越長, 也就造成效率的降低.
- 所以, 在合適的情況對數(shù)組進行擴容. 比如擴容兩倍.
- 如何進行擴容?
- 擴容可以簡單的將容量增加大兩倍(不是質數(shù)嗎? 質數(shù)的問題后面再討論)
- 但是這種情況下, 所有的數(shù)據項一定要同時進行修改(重新哈希化, 來獲取到不同的位置)
- 比如hashCode=12的數(shù)據項, 在length=8的時候, index=4. 在長度為16的時候呢? index=12.
- 這是一個耗時的過程, 但是如果數(shù)組需要擴容, 那么這個過程是必要的.
- 什么情況下擴容呢?
- 比較常見的情況是loadFactor>0.75的時候進行擴容.
- 比如Java的哈希表就是在裝填因子大于0.75的時候, 對哈希表進行擴容.
哈希表擴容的實現(xiàn)
-
我們來實現(xiàn)擴容函數(shù)
// 哈希表擴容 HashTable.prototype.resize = function (newLimit) { // 1.保存舊的數(shù)組內容 var oldStorage = this.storage // 2.重置屬性 this.limit = newLimit this.count = 0 this.storage = [] // 3.遍歷舊數(shù)組中的所有數(shù)據項, 并且重新插入到哈希表中 oldStorage.forEach(function (bucket) { // 1.bucket為null, 說明這里面沒有數(shù)據 if (bucket == null) { return } // 2.bucket中有數(shù)據, 那么將里面的數(shù)據重新哈?;迦? for (var i = 0; i < bucket.length; i++) { var tuple = bucket[i] this.put(tuple[0], tuple[1]) } }.bind(this)) }
-
代碼解析:
- 步驟1: 先將之前數(shù)組保存起來, 因為我們待會兒會將storeage = []
- 步驟2: 之前的屬性值需要重置.
- 步驟3: 遍歷所有的數(shù)據項, 重新插入到哈希表中.
-
在什么時候調用擴容方法呢?
- 在每次添加完新的數(shù)據時, 都進行判斷. (也就是put方法中)
-
修改put方法
- 代碼第5步中的內容
// 插入數(shù)據方法 HashTable.prototype.put = function (key, value) { // 1.獲取key對應的index var index = this.hashFunc(key, this.limit) // 2.取出數(shù)組(也可以使用鏈表) // 數(shù)組中放置數(shù)據的方式: [[ [k,v], [k,v], [k,v] ] , [ [k,v], [k,v] ] [ [k,v] ] ] var bucket = this.storage[index] // 3.判斷這個數(shù)組是否存在 if (bucket === undefined) { // 3.1創(chuàng)建桶 bucket = [] this.storage[index] = bucket } // 4.判斷是新增還是修改原來的值. var override = false for (var i = 0; i < bucket.length; i++) { var tuple = bucket[i] if (tuple[0] === key) { tuple[1] = value override = true } } // 5.如果是新增, 前一步沒有覆蓋 if (!override) { bucket.push([key, value]) this.count++ // 數(shù)組擴容 if (this.count > this.limit * 0.75) { this.resize(this.limit * 2) } } }
-
如果我們不斷的刪除數(shù)據呢?
- 如果不斷的刪除數(shù)據, 當loadFactor < 0.25的時候, 最好將數(shù)量限制在一半.
-
修改remove方法
- 代碼第4步中的內容
// 刪除數(shù)據 HashTable.prototype.remove = function (key) { // 1.獲取key對應的index var index = this.hashFunc(key, this.limit) // 2.獲取對應的bucket var bucket = this.storage[index] // 3.判斷同是否為null, 為null則說明沒有對應的數(shù)據 if (bucket == null) { return null } // 4.遍歷bucket, 尋找對應的數(shù)據 for (var i = 0; i < bucket.length; i++) { var tuple = bucket[i] if (tuple[0] === key) { bucket.splice(i, 1) this.count-- // 縮小數(shù)組的容量 if (this.limit > 8 && this.count < this.limit * 0.25) { this.resize(Math.floor(this.limit / 2)) } } return tuple[1] } // 5.來到該位置, 說明沒有對應的數(shù)據, 那么返回null return null }
四. 容量質數(shù)
我們前面提到過, 容量最好是質數(shù).
雖然在鏈地址法中將容量設置為質數(shù), 沒有在開放地址法中重要, 但是其實鏈地址法中質數(shù)作為容量也更利于數(shù)據的均勻分布. 所以, 我們還是完成一下這個步驟.
判斷質數(shù)
我們這里先討論一個常見的面試題, 判斷一個數(shù)是質數(shù).
-
質數(shù)的特點:
- 質數(shù)也稱為素數(shù).
- 質數(shù)表示大于1的自然數(shù)中, 只能被1和自己整除的數(shù).
-
OK, 了解了這個特點, 應該不難寫出它的算法:
function isPrime(num) { for (var i = 2; i < num; i++) { if (num % i == 0) { return false } } return true } // 測試 alert(isPrime(3)) // true alert(isPrime(32)) // false alert(isPrime(37)) // true
-
但是, 這種做法的效率并不高. 為什么呢?
- 對于每個數(shù)n,其實并不需要從2判斷到n-1
- 一個數(shù)若可以進行因數(shù)分解,那么分解時得到的兩個數(shù)一定是一個小于等于sqrt(n),一個大于等于sqrt(n).
- 比如16可以被分別. 那么是2*8, 2小于sqrt(16), 也就是4, 8大于4. 而4*4都是等于sqrt(n)
- 所以其實我們遍歷到等于sqrt(n)即可
function isPrime(num) { // 1.獲取平方根 var temp = parseInt(Math.sqrt(num)) // 2.循環(huán)判斷 for (var i = 2; i <= temp; i++) { if (num % i == 0) { return false } } return true }
擴容的質數(shù)
首先, 將初始的limit為8, 改成7
-
前面, 我們有對容量進行擴展, 方式是: 原來的容量 x 2
- 比如之前的容量是7, 那么擴容后就是14. 14還是一個質數(shù)嗎?
- 顯然不是, 所以我們還需要一個方法, 來實現(xiàn)一個新的容量為質數(shù)的算法.
-
那么我們可以封裝獲取新的容量的代碼(質數(shù))
// 判斷是否是質數(shù) HashTable.prototype.isPrime = function (num) { var temp = parseInt(Math.sqrt(num)) // 2.循環(huán)判斷 for (var i = 2; i <= temp; i++) { if (num % i == 0) { return false } } return true } // 獲取質數(shù) HashTable.prototype.getPrime = function (num) { while (!isPrime(num)) { num++ } return num }
修改插入和刪除的代碼:
-
插入數(shù)據的代碼:
// 擴容數(shù)組的數(shù)量 if (this.count > this.limit * 0.75) { var primeNum = this.getPrime(this.limit * 2) this.resize(primeNum) }
-
刪除數(shù)據的代碼:
// 縮小數(shù)組的容量 if (this.limit > 7 && this.count < this.limit * 0.25) { var primeNum = this.getPrime(Math.floor(this.limit / 2)) this.resize(primeNum) }
五. 完整代碼
-
最后, 還是給出實現(xiàn)哈希表的完整代碼:
// 創(chuàng)建HashTable構造函數(shù) function HashTable() { // 定義屬性 this.storage = [] this.count = 0 this.limit = 8 // 定義相關方法 // 判斷是否是質數(shù) HashTable.prototype.isPrime = function (num) { var temp = parseInt(Math.sqrt(num)) // 2.循環(huán)判斷 for (var i = 2; i <= temp; i++) { if (num % i == 0) { return false } } return true } // 獲取質數(shù) HashTable.prototype.getPrime = function (num) { while (!isPrime(num)) { num++ } return num } // 哈希函數(shù) HashTable.prototype.hashFunc = function(str, max) { // 1.初始化hashCode的值 var hashCode = 0 // 2.霍納算法, 來計算hashCode的數(shù)值 for (var i = 0; i < str.length; i++) { hashCode = 37 * hashCode + str.charCodeAt(i) } // 3.取模運算 hashCode = hashCode % max return hashCode } // 插入數(shù)據方法 HashTable.prototype.put = function (key, value) { // 1.獲取key對應的index var index = this.hashFunc(key, this.limit) // 2.取出數(shù)組(也可以使用鏈表) // 數(shù)組中放置數(shù)據的方式: [[ [k,v], [k,v], [k,v] ] , [ [k,v], [k,v] ] [ [k,v] ] ] var bucket = this.storage[index] // 3.判斷這個數(shù)組是否存在 if (bucket === undefined) { // 3.1創(chuàng)建桶 bucket = [] this.storage[index] = bucket } // 4.判斷是新增還是修改原來的值. var override = false for (var i = 0; i < bucket.length; i++) { var tuple = bucket[i] if (tuple[0] === key) { tuple[1] = value override = true } } // 5.如果是新增, 前一步沒有覆蓋 if (!override) { bucket.push([key, value]) this.count++ if (this.count > this.limit * 0.75) { var primeNum = this.getPrime(this.limit * 2) this.resize(primeNum) } } } // 獲取存放的數(shù)據 HashTable.prototype.get = function (key) { // 1.獲取key對應的index var index = this.hashFunc(key, this.limit) // 2.獲取對應的bucket var bucket = this.storage[index] // 3.如果bucket為null, 那么說明這個位置沒有數(shù)據 if (bucket == null) { return null } // 4.有bucket, 判斷是否有對應的key for (var i = 0; i < bucket.length; i++) { var tuple = bucket[i] if (tuple[0] === key) { return tuple[1] } } // 5.沒有找到, return null return null } // 刪除數(shù)據 HashTable.prototype.remove = function (key) { // 1.獲取key對應的index var index = this.hashFunc(key, this.limit) // 2.獲取對應的bucket var bucket = this.storage[index] // 3.判斷同是否為null, 為null則說明沒有對應的數(shù)據 if (bucket == null) { return null } // 4.遍歷bucket, 尋找對應的數(shù)據 for (var i = 0; i < bucket.length; i++) { var tuple = bucket[i] if (tuple[0] === key) { bucket.splice(i, 1) this.count-- // 縮小數(shù)組的容量 if (this.limit > 7 && this.count < this.limit * 0.25) { var primeNum = this.getPrime(Math.floor(this.limit / 2)) this.resize(primeNum) } } return tuple[1] } // 5.來到該位置, 說明沒有對應的數(shù)據, 那么返回null return null } // isEmpty方法 HashTable.prototype.isEmpty = function () { return this.count == 0 } // size方法 HashTable.prototype.size = function () { return this.count } // 哈希表擴容 HashTable.prototype.resize = function (newLimit) { // 1.保存舊的數(shù)組內容 var oldStorage = this.storage // 2.重置屬性 this.limit = newLimit this.count = 0 this.storage = [] // 3.遍歷舊數(shù)組中的所有數(shù)據項, 并且重新插入到哈希表中 oldStorage.forEach(function (bucket) { // 1.bucket為null, 說明這里面沒有數(shù)據 if (bucket == null) { return } // 2.bucket中有數(shù)據, 那么將里面的數(shù)據重新哈?;迦? for (var i = 0; i < bucket.length; i++) { var tuple = bucket[i] this.put(tuple[0], tuple[1]) } }).bind(this) } }