數據結構筆記淺記(十三) 哈希表

「哈希表 hash table」,又稱「散列表」,它通過建立鍵 key 與值 value 之間的映射,實現高效的元素查詢。具體而言,我們向哈希表中輸入一個鍵 key ,則可以在 𝑂(1) 時間內獲取對應的值 value 。

從本質上看,哈希函數的作用是將所有 key 構成的輸入空間映射到數組所有索引構成的輸出空間,而輸入空 間往往遠大於輸出空間。因此,理論上一定存在“多個輸入對應相同輸出”的情況。 將這種多個輸入對應同一輸出的情況稱 爲「哈希衝突 hash collision」。因此,我們可以 通過擴容哈希表來減少哈希衝突

哈希表擴容需將所有鍵值對從原哈希表遷移至新哈希表,非常耗時;並且由於哈希表容量 capacity 改變,我們需要通過哈希函數來重新計算所有鍵值對的存儲位置,這進一步增加了擴容過程的計算 開銷。爲此,編程語言通常會預留足夠大的哈希表容量,防止頻繁擴容

「負載因子 load factor」是哈希表的一個重要概念,其定義爲哈希表的元素數量除以桶數量,用於衡量哈希衝突的嚴重程度,也常作爲哈希表擴容的觸發條件。

哈希表擴容簡單粗暴且有效,但效率太低,因爲哈希表擴容需要進行大量 的數據搬運與哈希值計算。爲了提升效率,我們可以採用以下策略。

         1. 改良哈希表數據結構,使得哈希表可以在出現哈希衝突時正常工作。

        2. 僅在必要時,即當哈希衝突比較嚴重時,才執行擴容操作。

哈希表的結構改良方法主要包括“鏈式地址”和“開放尋址”。

 

鏈式地址

在原始哈希表中,每個桶僅能存儲一個鍵值對。「鏈式地址 separate chaining」將單個元素轉換爲鏈表,將鍵值對作爲鏈表節點,將所有發生衝突的鍵值對都存儲在同一鏈表中。

基於鏈式地址實現的哈希表的操作方法發生了以下變化

         ‧ 查詢元素:輸入 key ,經過哈希函數得到桶索引,即可訪問鏈表頭節點,然後遍歷鏈表並對比 key 以查 找目標鍵值對。

         ‧ 添加元素:首先通過哈希函數訪問鏈表頭節點,然後將節點(鍵值對)添加到鏈表中。

         ‧ 刪除元素:根據哈希函數的結果訪問鏈表頭部,接着遍歷鏈表以查找目標節點並將其刪除。

鏈式地址的侷限性

         ‧ 佔用空間增大:鏈表包含節點指針,它相比數組更加耗費內存空間。

        ‧ 查詢效率降低:因爲需要線性遍歷鏈表來查找對應元素。 

 

開放尋址

開放尋址 open addressing」不引入額外的數據結構,而是通過“多次探測”來處理哈希衝突,探測方式主 要包括線性探測、平方探測和多次哈希等。

線性探測 線性探測採用固定步長的線性搜索來進行探測,其操作方法與普通哈希表有所不同。

         ‧ 插入元素:通過哈希函數計算桶索引,若發現桶內已有元素,則從衝突位置向後線性遍歷(步長通常爲 1 ),直至找到空桶,將元素插入其中。

         ‧ 查找元素:若發現哈希衝突,則使用相同步長向後進行線性遍歷,直到找到對應元素,返回 value 即可;如果遇到空桶,說明目標元素不在哈希表中,返回 None 。

然而,線性探測容易產生“聚集現象”。具體來說,數組中連續被佔用的位置越長,這些連續位置發生哈希衝 突的可能性越大,從而進一步促使該位置的聚堆生長,形成惡性循環,最終導致增刪查改操作效率劣化。 值得注意的是,我們不能在開放尋址哈希表中直接刪除元素。這是因爲刪除元素會在數組內產生一個空桶 None ,而當查詢元素時,線性探測到該空桶就會返回,因此在該空桶之下的元素都無法再被訪問到,程序可能誤判這些元素不存在。爲了解決該問題,我們可以採用「懶刪除 lazy deletion」機制:它不直接從哈希表中移除元素,而是利用一 個常量 TOMBSTONE 來標記這個桶。在該機制下,None 和 TOMBSTONE 都代表空桶,都可以放置鍵值對。但不同 的是,線性探測到 TOMBSTONE 時應該繼續遍歷,因爲其之下可能還存在鍵值對。然而,懶刪除可能會加速哈希表的性能退化。這是因爲每次刪除操作都會產生一個刪除標記,隨着 TOMBSTONE 的增加,搜索時間也會增加,因爲線性探測可能需要跳過多個 TOMBSTONE 才能找到目標元素。 爲此,考慮在線性探測中記錄遇到的首個 TOMBSTONE 的索引,並將搜索到的目標元素與該 TOMBSTONE 交換位 置。這樣做的好處是當每次查詢或添加元素時,元素會被移動至距離理想位置(探測起始點)更近的桶,從 而優化查詢效率

 

平方探測

平方探測與線性探測類似,都是開放尋址的常見策略之一。當發生衝突時,平方探測不是簡單地跳過一個固定的步數,而是跳過“探測次數的平方”的步數,即 1, 4, 9, … 步。

平方探測主要具有以下優勢

        ‧ 平方探測通過跳過探測次數平方的距離,試圖緩解線性探測的聚集效應

         ‧ 平方探測會跳過更大的距離來尋找空位置,有助於數據分佈得更加均勻

然而,平方探測並不是完美的

         ‧ 仍然存在聚集現象,即某些位置比其他位置更容易被佔用。

         ‧ 由於平方的增長,平方探測可能不會探測整個哈希表,這意味着即使哈希表中有空桶,平方探測也可能 無法訪問到它

 

多次哈希

顧名思義,多次哈希方法使用多個哈希函數 𝑓1 (𝑥)、𝑓2 (𝑥)、𝑓3 (𝑥)、… 進行探測。

        ‧ 插入元素:若哈希函數 𝑓1 (𝑥) 出現衝突,則嘗試 𝑓2 (𝑥) ,以此類推,直到找到空位後插入元素。

        ‧ 查找元素:在相同的哈希函數順序下進行查找,直到找到目標元素時返回;若遇到空位或已嘗試所有哈 希函數,說明哈希表中不存在該元素,則返回 None 。 與線性探測相比,多次哈希方法不易產生聚集,但多個哈希函數會帶來額外的計算量。

 

而無論是開放尋址還是鏈式地址,它們只能保證 哈希表可以在發生衝突時正常工作,而無法減少哈希衝突的發生。

鍵值對的分佈情況由哈希函數決定。

        index = hash(key) % capacity

當哈希表容量 capacity 固定時,哈希算法 hash() 決定了輸出值,進而決定了鍵值對在哈希 表中的分佈情況。 這意味着,爲了降低哈希衝突的發生概率,我們應當將注意力集中在哈希算法 hash() 的設計上。

 

哈希算法的目標

爲了實現“既快又穩”的哈希表數據結構,哈希算法應具備以下特點

         ‧ 確定性:對於相同的輸入,哈希算法應始終產生相同的輸出。這樣才能確保哈希表是可靠的。

        ‧ 效率高:計算哈希值的過程應該足夠快。計算開銷越小,哈希表的實用性越高。

        ‧ 均勻分佈:哈希算法應使得鍵值對均勻分佈在哈希表中。分佈越均勻,哈希衝突的概率就越低。

實際上,哈希算法除了可以用於實現哈希表,還廣泛應用於其他領域中。

        ‧ 密碼存儲:爲了保護用戶密碼的安全,系統通常不會直接存儲用戶的明文密碼,而是存儲密碼的哈希值。當用戶輸入密碼時,系統會對輸入的密碼計算哈希值,然後與存儲的哈希值進行比較。如果兩者匹配,那麼密碼就被視爲正確。

         ‧ 數據完整性檢查:數據發送方可以計算數據的哈希值並將其一同發送;接收方可以重新計算接收到的數據的哈希值,並與接收到的哈希值進行比較。如果兩者匹配,那麼數據就被視爲完整。

對於密碼學的相關應用,防止從哈希值推導出原始密碼等逆向工程,哈希算法需要具備更高等級的安全特性。

        ‧ 單向性:無法通過哈希值反推出關於輸入數據的任何信息。

        ‧ 抗碰撞性:應當極難找到兩個不同的輸入,使得它們的哈希值相同。

        ‧ 雪崩效應:輸入的微小變化應當導致輸出的顯著且不可預測的變化。

 

哈希算法的設計

哈希算法的設計是一個需要考慮許多因素的複雜問題。然而對於某些要求不高的場景,我們也能設計一些簡單的哈希算法。

         ‧ 加法哈希:對輸入的每個字符的 ASCII 碼進行相加,將得到的總和作爲哈希值。

         ‧ 乘法哈希:利用乘法的不相關性,每輪乘以一個常數,將各個字符的 ASCII 碼累積到哈希值中。

        ‧ 異或哈希:將輸入數據的每個元素通過異或操作累積到一個哈希值中。

         ‧ 旋轉哈希:將每個字符的 ASCII 碼累積到一個哈希值中,每次累積之前都會對哈希值進行旋轉操作。

 

 

Q:哈希表的時間複雜度在什麼情況下是 𝑂(𝑛) ?

當哈希衝突比較嚴重時,哈希表的時間複雜度會退化至 𝑂(𝑛) 。當哈希函數設計得比較好、容量設置比較合理、衝突比較平均時,時間複雜度是 𝑂(1) 。我們使用編程語言內置的哈希表時,通常認爲時間複雜度是 𝑂(1) 。

 

Q:爲什麼不使用哈希函數 𝑓(𝑥) = 𝑥 呢?這樣就不會有衝突了。

在 𝑓(𝑥) = 𝑥 哈希函數下,每個元素對應唯一的桶索引,這與數組等價。然而,輸入空間通常遠大於輸出空 間(數組長度),因此哈希函數的最後一步往往是對數組長度取模。換句話說,哈希表的目標是將一個較大的 狀態空間映射到一個較小的空間,並提供 𝑂(1) 的查詢效率。

 

Q:哈希表底層實現是數組、鏈表、二叉樹,但爲什麼效率可以比它們更高呢?

首先,哈希表的時間效率變高,但空間效率變低了。哈希表有相當一部分內存未使用。其次,只是在特定使用場景下時間效率變高了。如果一個功能能夠在相同的時間複雜度下使用數組或鏈表實 現,那麼通常比哈希表更快。這是因爲哈希函數計算需要開銷,時間複雜度的常數項更大。 最後,哈希表的時間複雜度可能發生劣化。例如在鏈式地址中,我們採取在鏈表或紅黑樹中執行查找操作, 仍然有退化至 𝑂(𝑛) 時間的風險。

 

Q:多次哈希有不能直接刪除元素的缺陷嗎?標記爲已刪除的空間還能再次使用嗎?

多次哈希是開放尋址的一種,開放尋址法都有不能直接刪除元素的缺陷,需要通過標記刪除。標記爲已刪除的空間可以再次使用。當將新元素插入哈希表,並且通過哈希函數找到標記爲已刪除的位置時,該位置可以被新元素使用。這樣做既能保持哈希表的探測序列不變,又能保證哈希表的空間使用率。

 

Q:爲什麼在線性探測中,查找元素的時候會出現哈希衝突呢?

查找的時候通過哈希函數找到對應的桶和鍵值對,發現 key 不匹配,這就代表有哈希衝突。因此,線性探測法會根據預先設定的步長依次向下查找,直至找到正確的鍵值對或無法找到跳出爲止。

 

Q:爲什麼哈希表擴容能夠緩解哈希衝突?

哈希函數的最後一步往往是對數組長度 𝑛 取模(取餘),讓輸出值落在數組索引範圍內;在擴容後,數組長 度 𝑛 發生變化,而 key 對應的索引也可能發生變化。原先落在同一個桶的多個 key ,在擴容後可能會被分配 到多個桶中,從而實現哈希衝突的緩解

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