一道頭條面試題:如何設計哈希函數並解決衝突問題

引言

本節由一道頭條面試題:如何設計哈希函數以及如何解決衝突問題展開,由以下幾個方面進行循序漸進的闡述:

  • 什麼是散列表?

  • 什麼是散列函數?

  • 常見的散列函數有哪些?

  • 衝突又怎麼解決喃?

  • 散列表的動態擴容

  • 解答

  • +面試題

一、散列表(哈希表、Hash 表)

不同與之前我們介紹的線性表,所有的數據都是順序存儲,當我們需要在線性表中查找某一數據時,當線性表過長,需要查找的數據排序比較靠後的話,就需要花費大量的時間,導致查找性能較差。

例如學號,如果你想通過學號去找某一名學生,假設有 n 學生,難道你要一個一個的找,這時間複雜度就爲 O(n),效率太低。當然你也可以使用二分查找算法,那時間複雜度就爲 O(logn),有沒有更高效的解決方案喃?

我們知道數組通過下標查找的時間複雜度爲 O(1),如果我們將學號存儲在數組裏,那就簡單多了,我們可以直接通過下標(key)找到對應的學生。

但日常生活中,key 一般都賦予特定的含義,使用 0,1,2 ... 太過簡單了。學號通常都需要加上年級、班級等信息,學號爲 010121 代表 1年級,1 班,21號。我們知道某一同學的學號就可以直接找到對應的學生。

這就是散列! 通過給定的學號,去訪問一種轉換算法(將學號010121轉換爲1年級,1 班,21號的方法),得到對應的學生所在地址(1年級,1 班,21號)。

其中這種轉換算法稱爲散列函數(哈希函數、Hash 函數),給定的 key 稱爲關鍵字,關鍵字通過散列函數計算出來的值則稱爲散列值(哈希值、Hash 值)。通過散列值到 散列表(哈希表、Hash 表)中就可以獲取檢索值。

如下圖:

也可以說,散列函數的作用就是給定一個鍵值,然後返回值在表中的地址。

// 散列表
function HashTable() {
  let table = []
  this.put = function(key, value) {}
  this.get = function(key) {}
  this.remove = function(key) {}
}

繼續看上面學號的例子,每個學生對應一個學號,如果學生較多,假如有 10w 個,那我們需要存儲的就有

  • 10w 個學號,每個學號 6 個十進制數,一個十進制數用 4 bit 表示(1個字節=8bit)

  • 散列函數

  • 10w 個學生信息

這就需要多花 100000 * 6 / 2 / 1024  = 292.97 KB 的存儲容量用於存儲每個學生的學號,所以,散列表是一種空間換時間的存儲結構,是在算法中提升效率的一種比較常用的方式,但是所需空間太大也會讓人頭疼,所以通常需要在二者之間權衡。

二、散列函數

這裏,需要了解的是,構造散列函數應遵循的 原則 是:

  • 散列值(value)是一個非負數:常見的學號、內存尋址呀,都要求散列值不可能是負數

  • key 值相同,通過散列函數計算出來的散列值(value)一定相同

  • key 值不同,通過散列函數計算出來的散列值(value)不一定不相同

再看一個例子:學校最近要蓋一個圖書館,用於學生自習,如果給每位學生提供單獨的小桌子的話,就需要 10w 張,這顯然是不可能的,那麼,有沒有辦法在得到 O(1) 的查找效率的同時、又不付出太大的空間代價呢?

散列函數就提供了這種解決方案,10w 是多,但如果我們除以 100 喃,那就 0~999,這就很好找了,也不需要那麼多桌子了。

對應的散列函數就是:

function hashTables(key) {
    return Math.floor(key / 100)
}

但在實際開發應用中,場景不可能這麼簡單,實現散列函數的方式也可能有很多種,例如上例,散列函數也可以是:

function hashTables(key) {
    return key >= 1000 ? 999 : key
}

這個實現的散列函數相對於上一個在 999 號桌的衝突概率就高得多,所以,選擇一個表現良好的散列函數就至關重要

1. 創建更好的散列函數

一個表現良好的散列函數可以大量的提高我們代碼的性能,它有更快的查找、插入、刪除等操作,更少的衝突,佔用更小的存儲空間。所以構建一個高性能的散列函數對我們至關重要。

一個好的散列函數需要具有以下基本要求:

  • 易於計算:它應該易於計算,並且不能成爲算法本身。

  • 統一分佈:它應該在哈希表中提供統一分佈,不應導致羣集。

  • 較少的衝突:當元素對映射到相同的哈希值時發生衝突。應該避免這些。

2. 常見的散列函數

  • 直接尋址法:取關鍵字或關鍵字的某個線性函數值爲散列地址。

  • 數字分析法:通過對數據的分析,發現數據中衝突較少的部分,並構造散列地址。例如同學們的學號,通常同一屆學生的學號,其中前面的部分差別不太大,所以用後面的部分來構造散列地址。

  • 平方取中法:當無法確定關鍵字裏哪幾位的分佈相對比較均勻時,可以先求出關鍵字的平方值,然後按需要取平方值的中間幾位作爲散列地址。這是因爲:計算平方之後的中間幾位和關鍵字中的每一位都相關,所以不同的關鍵字會以較高的概率產生不同的散列地址。

  • 取隨機數法:使用一個隨機函數,取關鍵字的隨機值作爲散列地址,這種方式通常用於關鍵字長度不同的場合。

  • 除留取餘法:取關鍵字被某個不大於散列表的表長 n 的數 m 除後所得的餘數 p 爲散列地址。這種方式也可以在用過其他方法後再使用。該函數對 m 的選擇很重要,一般取素數或者直接用 n。

注意:無論散列函數有多健壯,都必然會發生衝突。因此,爲了保持哈希表的性能,通過各種衝突解決技術來管理衝突是很重要的。

例如上例會存在一個問題,學號爲 011111 與 021111 的學生,他們除以 100 後都是 111 ,這就衝突了。

三、衝突解決

在散列裏,衝突是不可避免的。那怎樣解決衝突喃?

常見的解決衝突方法有幾個:

  • 開放地址法(也叫開放尋址法):實際上就是當需要存儲值時,對Key哈希之後,發現這個地址已經有值了,這時該怎麼辦?不能放在這個地址,不然之前的映射會被覆蓋。這時對計算出來的地址進行一個探測再哈希,比如往後移動一個地址,如果沒人佔用,就用這個地址。如果超過最大長度,則可以對總長度取餘。這裏移動的地址是產生衝突時的增列序量。

  • 鏈地址法:鏈地址法其實就是對Key通過哈希之後落在同一個地址上的值,做一個鏈表。其實在很多高級語言的實現當中,也是使用這種方式處理衝突的,我們會在後面着重學習這種方式。

  • 再哈希法:在產生衝突之後,使用關鍵字的其他部分繼續計算地址,如果還是有衝突,則繼續使用其他部分再計算地址。這種方式的缺點是時間增加了。

  • 建立一個公共溢出區:這種方式是建立一個公共溢出區,當地址存在衝突時,把新的地址放在公共溢出區裏。

我們這裏介紹兩個最簡單的:開放尋址法裏的線性探測,以及鏈地址法。

1. 線性探測

線性探測是開放尋址裏最簡單的方法,當往散列表中增加一個新的元素值時,如果索引爲 index 的位置已經被佔用了,那麼就嘗試 index + 1 的位置,如果 index + 1 的位置也被佔用了,那就嘗試 index + 2 的位置,以此類推,如果嘗試到表尾也沒找到空閒位置,則從表頭開始,繼續嘗試,直到放入散列表中。

如下圖:

如果是刪除喃:首先排查由散列函數計算得出的散列值,與要查找的散列值對比,相同則刪除元素,如果該節點爲空了,則設爲 undefined ,不相等則繼續比較 index + 1 ,以此類推,直到相等或遍歷完整個散列表。

如果是查找喃:首先排查由散列函數計算得出的散列值,與要查找的散列值對比是否相等,相等則查找完成,不相等繼續排查 index + 1 ,直到遇到空閒節點( undefined 節點忽略不計),則返回查找失敗,散列表中沒有查找值。

很簡單,但它也有自己的侷限性,當散列表中元素越來越多時,衝突的機率就越來越大。

最壞情況下的時間複雜度爲 O(n)。

2. 鏈地址法

鏈地址也很簡單,它給每一個散列表中的節點建立一個鏈表,關鍵字 key 通過散列函數轉換爲散列值,找到散列表中對應的節點時,放入對應鏈表中即可。

如下圖:

插入:像對應的鏈表中插入一條數據,時間複雜度爲 O(1)

查找或刪除:從鏈頭開始,查找、刪除的時間複雜度爲 O(k),k爲鏈表的長度

四、動態擴容

前面在介紹數組的時候,我們已經介紹過擴容,在 JavaScript 中,當數組 push 一個數據時,如果數組容量不足,則 JavaScript 會動態擴容,新容量爲老的容量的 1.5 倍加上 16。

在散列表中,隨着散列值不斷的加入散列表中,散列表中數據越來越慢,衝突的機率越來越大,查找、插入、刪除等操作的時間複雜度越來越高,散列表也需要不斷的動態擴容。

五、回答開頭問題

如何設計哈希函數以及如何解決衝突,這是哈希表考察的重要問題。

如何設計哈希函數?

一個好的散列函數需要具有以下基本要求:

  • 易於計算:它應該易於計算,並且不能成爲算法本身。

  • 統一分佈:它應該在哈希表中提供統一分佈,不應導致羣集。

  • 較少的衝突:當元素對映射到相同的哈希值時發生衝突。應該避免這些。

如何解決衝突?

常見的解決衝突方法有幾個:

  • 開放地址法(也叫開放尋址法):實際上就是當需要存儲值時,對Key哈希之後,發現這個地址已經有值了,這時該怎麼辦?不能放在這個地址,不然之前的映射會被覆蓋。這時對計算出來的地址進行一個探測再哈希,比如往後移動一個地址,如果沒人佔用,就用這個地址。如果超過最大長度,則可以對總長度取餘。這裏移動的地址是產生衝突時的增列序量。

  • 鏈地址法:鏈地址法其實就是對Key通過哈希之後落在同一個地址上的值,做一個鏈表。其實在很多高級語言的實現當中,也是使用這種方式處理衝突的,我們會在後面着重學習這種方式。

  • 再哈希法:在產生衝突之後,使用關鍵字的其他部分繼續計算地址,如果還是有衝突,則繼續使用其他部分再計算地址。這種方式的缺點是時間增加了。

  • 建立一個公共溢出區:這種方式是建立一個公共溢出區,當地址存在衝突時,把新的地址放在公共溢出區裏。

六、常見的哈希表問題

我們已經刷過的(https://github.com/sisterAn/JavaScript-Algorithms):

  • 騰訊&leetcode349:給定兩個數組,編寫一個函數來計算它們的交集

  • 字節&leetcode1:兩數之和

  • 騰訊&leetcode15:三數之和

今天刷一道 leetcode380:常數時間插入、刪除和獲取隨機元素

leetcode380:常數時間插入、刪除和獲取隨機元素

設計一個支持在平均 時間複雜度 O(1) 下,執行以下操作的數據結構。

  • insert(val) :當元素 val 不存在時,向集合中插入該項。

  • remove(val) :元素 val 存在時,從集合中移除該項。

  • getRandom :隨機返回現有集合中的一項。每個元素應該有 相同的概率 被返回。

示例 :

// 初始化一個空的集合。
RandomizedSet randomSet = new RandomizedSet();

// 向集合中插入 1 。返回 true 表示 1 被成功地插入。
randomSet.insert(1);

// 返回 false ,表示集合中不存在 2 。
randomSet.remove(2);

// 向集合中插入 2 。返回 true 。集合現在包含 [1,2] 。
randomSet.insert(2);

// getRandom 應隨機返回 1 或 2 。
randomSet.getRandom();

// 從集合中移除 1 ,返回 true 。集合現在包含 [2] 。
randomSet.remove(1);

// 2 已在集合中,所以返回 false 。
randomSet.insert(2);

// 由於 2 是集合中唯一的數字,getRandom 總是返回 2 。
randomSet.getRandom();

歡迎將解答提到 https://github.com/sisterAn/JavaScript-Algorithms/issues/48 ,這裏我們提交了前端所用到的算法系列文章以及題目(已解答),歡迎star,如果感覺不錯,點個在看支持一下唄????

瓶子君將明日解答????

閱讀❤️

歡迎關注「前端瓶子君」,回覆「交流」加入前端交流羣!

歡迎關注「前端瓶子君」,回覆「算法」自動加入,從0到1構建完整的數據結構與算法體系!

在這裏,瓶子君不僅介紹算法,還將算法與前端各個領域進行結合,包括瀏覽器、HTTP、V8、React、Vue源碼等。

在這裏,你可以每天學習一道大廠算法題(阿里、騰訊、百度、字節等等)或 leetcode,瓶子君都會在第二天解答喲!

》》面試官也在看的算法資料《《

“在看轉發”是最大的支持

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