1. 程式人生 > >雜湊表簡介(Intorduction to Hash Table)

雜湊表簡介(Intorduction to Hash Table)

雜湊表簡介(Intorduction to Hash Table)

作者:Bluemapleman([email protected])

麻煩不吝star和fork本博文對應的github上的技術部落格專案吧!謝謝大家的支援!

知識無價,寫作辛苦,歡迎轉載,但請註明出處,謝謝!


文章目錄


前言:雜湊表是最常用的資料結構之一,大部分情況下它的查詢效能一般都是O(1)。瞭解它對於考慮實際專案過程當中應該使用哪種資料結構是非常有必要的。比如在資料庫建立索引時,應該選用雜湊索引(無序)還是B樹索引(有序),就需要參考你的業務對於索引欄位的查詢要求。比如如果對於索引欄位基本都是用於where條件子句中的等值查詢,那麼雜湊一般會效能更好一點;而如果是範圍查詢,則B樹一般會更好一點。

雜湊的動機

符號表

雜湊表本質上是一個符號表(symbol table)。符號表是一種儲存鍵值對的資料結構並且支援兩種操作:將新的鍵值對插入符號表中(insert);根據給定的鍵值查詢對應的值(search)。符號表中每一個用來放鍵值對的位置稱作一個槽(slot)。

Symbol Table(符號表)

鍵的集合

假定有一個小的可能的鍵的集合(small universe of keys):

U={0,1,2,…,9}

針對這個鍵的集合,我們可以考慮用**直接地址表(Direct-address tables)**來儲存它們。

pic2

直接地址表的做法是,每個slot直接指向儲存的那個唯一的鍵。而直接地址表的搜尋,插入和刪除操作顯然都是O(1)時間複雜度的。

但是直接地址表的做法的缺陷很明顯的,就是若鍵的集合(所有可能取得值)是非常大的,我們如果要儲存這樣一個鍵的集合的時候,就需要相當尺寸的直接地址表,這樣對空間的要求是比較高的。

加假如我們所擁有的符號表的尺寸m就是隻能遠遠小於全集的大小的,那麼我們可以考慮選擇一個**“雜湊函式(Hash Function)”:h: U -> {0,1,…,m-1},將所有的鍵值都對映到我們的符號表中。這種利用雜湊函式來對鍵做對映的符號表,就叫做

雜湊表(Hash Table)**。

但是因為鍵的全集的元素數目大於符號表的尺寸m,故肯定有不同的鍵被對映到同一個符號表的位置中來,這種情況叫做衝突/碰撞(collision)。我們怎麼處理衝突的情況呢?一個簡單的做法是:用連結串列做鏈式雜湊(Chaining),即把有衝突的鍵用連結串列的形式連線起來,每個鍵我們用一個連結串列的節點來儲存(node)。

pic1

雜湊表的操作複雜度分析

查詢(Search)

首先我們假設雜湊函式h是隨機的,即對於任意的鍵,雜湊函式h將該鍵對映到m個slot中的每個slot裡的概率都是相等的,等於1/m。(簡單一致雜湊假設[Simple Uniform Hashing])

我們還定義一個**負載因子(Load Factor)**的概念, α = n / m \alpha= n/m ,n是雜湊表中的鍵的數目。

此時,我們來思考一個問題:含有n個鍵的尺寸為m的雜湊表裡,任意slot裡的連結串列的期望長度是多少呢?(多少個鍵被對映到該slot呢)

答: 由於h是隨機的,故每個鍵分配到每個slot的概率都是相同的,為1/m。故每個槽中的期望的鍵的數目為n*1/m=n/m,即正好為負載因子 α \alpha 的值。

為什麼要考慮slot對應的連結串列的期望長度呢?因為它和查詢操作的時間複雜度是線性相關的。

對於一個目標查詢物件x,它的key為 x k x_k 。當我們試圖在雜湊表中查詢它時,首先肯定要對它的鍵進行雜湊,即計算h( x k x_k ),然後在對應的slot中去找它。但無論對應的slot中是否有x,這個查詢操作平均的耗時都是O(1+ α \alpha ),O(1)是計算雜湊值的時間複雜度,O( α \alpha )是遍歷連結串列的平均時間複雜度。故如若我們能保證雜湊表的尺寸m與儲存的元素的數目是成一定比例,或者說規模是相當的,那麼我們就有n=O(m),那麼此時 α \alpha =n/m,也就是複雜因子就等於O(m)/m=O(1)。那麼也就是說,查詢操作的平均時間複雜度就是O(1)了!

插入(Insertion)

而雜湊表的插入操作也很簡單,就是直接將鍵插入到對應h( x k x_k )的連結串列的表頭(此處我們假設插入的元素x之前是不存在於雜湊表中的),故插入也是O(1)的。

刪除(Deletion)

刪除操作的時間複雜度要取決於連結串列是單向連結串列(Singly linked list)還是雙向連結串列(doubly linked list)。

  • 如果是雙向連結串列,由於傳入刪除操作的引數是元素x本身,故我們可以直接改變一下x元素前後的元素的指標指向即可完成刪除,所以是O(1)。
  • 而若是單向連結串列,那麼我們首先得用Search()找到元素x的前一個元素,然後再做連結串列刪除。這就是在查詢操作上加了一個改變指標指向的操作,故時間複雜度等於 O ( α + 1 + 1 ) O(\alpha+1+1) 了。

綜上所述,如若雜湊表的尺寸m足夠大,那麼查詢、插入和刪除操作都是可以在O(1)的時間複雜度上完成的。


  • 小問題思考

假設有隨機一致的雜湊函式h,和n個即將儲存的鍵,以及一個尺寸為O(n)的雜湊表,那麼該雜湊表中的最長的連結串列的期望長度是多少呢?

這個問題等價於往一個有n個槽的盒子裡隨機拋n個球,問有最多球的槽裡面的期望的球的數目是多少。

pic3

可參考[1]的Page 283的Problem11-2 Slot-size bound for chaining。


現在看來,我們的雜湊表的效能主要是受以下幾個關鍵因素的影響的:

  • 雜湊表尺寸m
  • 鍵集合即鍵集合的大小n。
  • 雜湊函式h

雜湊函式h的選擇

之前我們一直假設雜湊函式是隨機的。那我們現在來研究一下,為什麼要雜湊函式隨機。

試想極端情況下,如果雜湊函式h的輸出為一個常數c,即對任意的鍵,都對映到同一個slot中,那麼查詢操作的時間複雜度就變成O(n)了,因為要遍歷長度為n的連結串列。

因此,我們之所以希望雜湊函式的效果是隨機的,就是希望能夠將所有的鍵儘可能均勻地分配到每個slot中,這樣才能儘可能保證雜湊表的效能。

通常,針對一個簡單的鍵,比如一個字元(本質上也是一個整數),一個整數,我們都可以選擇用取餘的方式來直接對映它們,即直接用鍵值除以某個固定引數值的餘數,作為該鍵的雜湊值。

但是如果一個鍵由多個部分組成呢?按理來說,我們計算它的雜湊值時應該考慮到它的每個部分。比如像字串這樣的元素,它本質上是字元的一個序列。而我們的一種做法可以是:把字串的所有字元對應的數值加總,然後取餘。但是這種做法的問題是,如果兩個字串含有相同的字元,只是字元的位置不一樣的話(互為anagram),也都會對映到相同的slot中,這就很容易導致對映的不夠均勻。

而如何解決字串的對映問題會比較好呢?我們來看看Java中的String的雜湊值的計算方法:

S = s 0 s 1 s 2 . . . s k 1 S=s_0s_1s_2...s_{k-1}

H a s h C o d e ( S ) = s 0 3 1 k 1 + s 1 3 1 k 2 + . . . + s k 1 HashCode(S)=s_0*31^{k-1}+s_1*31^{k-2}+...+s_{k-1}

可以比較明顯地看出,這個雜湊值的結果時取決於每個字元在字串中的位置的,故anagram的情況不一定會對映到同一slot中了。另外使用31作為乘數的主要原因是:使用和雜湊表尺寸m互質的數作為乘數可以使得鍵的對映更均勻,或者說降低衝突率(更細緻的解釋可以看這篇文章)。

節約記憶體

我們之前解決衝突的方法一隻預設是鏈式雜湊,即用node來儲存鍵,然後用連結串列的形式把這些node連線起來。這樣的做法存在一個問題是,我們需要在雜湊表之外,為每個鍵耗用O(1)的空間,總的來看就是O(n)的額外空間。

那麼如果我們想節省一點空間,有沒有什麼方法呢?或者從本質上來說,除了鏈式雜湊,還有沒有別的解決衝突的方法呢?

開放地址法(Open Addressing)

開放地址法的做法是:將鍵直接存在雜湊值對應的slot中。這種做法要求:n<=m, α 1 \alpha \le 1 ,一般大概要求為1/2左右。

那麼如果用開放地址法,存在衝突時,我們該如何解決呢?答案是探測(Probe)

具體來說,對於鍵k1,我們會按照一個探測序列(probe sequence)[h(k1,0),h(k1,1),…,h(k1,m-1)]來探測我們的鍵可以放入的slot。即如果當前的slot為空,那麼就放入我們的鍵;如果不為空,我們就去檢視探測序列中的下一個槽,直到找到空的槽為止。這個順序取決於探測的方式,如果是線性探測(linear probing),那麼這個序列就是[h(k1),h(k1)+1,h(k1)+2,…,h(k1)+m-1]

pic4

開放地址法的查詢和插入操作都是比較好理解的。查詢就是按照探測序列去一個個找就可以,而插入操作也是按照探測序列先查詢,找到空的槽插入即可。

但是刪除操作就有一個要注意的地方了,就是:如果我們刪除了某個探測序列中的某個slot中的鍵k_del,但是如果該slot對應的下一個探測slot中有鍵值k_next的話,那麼如果我們要查詢k_next的話,我們顯然會在探測到刪除k_del所在的slot時,發現該slot為空,於是停止探測,並認為k_next不存在。下圖是一個這樣的例子。

pic5

解決這個問題有兩種方式:

(1)在刪除的結點處放一個標誌,該標誌表示該結點後續仍然有需要探測的slot。
(2)重新對雜湊表中的所有鍵進行雜湊。

pic6

參考文獻

[1] Introduction to Algorithm: Third Edition, Thomas et al.