數據結構與算法分析(八)--- 如何實現並用好一個散列表?

一、如何快速完成搜索

1.1 什麼是索引

你可能會好奇,在谷歌或百度上搜索一個關鍵詞,馬上就能給出相關的結果,爲何會這麼快呢?搜索引擎怎樣實現的結果查找過程呢?我們在windows系統中搜索某個關鍵詞也沒那麼快啊。

要想實現在一個磁盤上查找某個文件的功能,最簡單的就是順序查找,挨個從頭到尾遍歷並比對一遍文件名。對比前面的幾種排序算法,很顯然順序查找的效率比較低,大概是O(n),windows系統中搜索某個關鍵詞也正是使用了這種方式,所以看起來查找比較慢。

如果想進一步提高效率,我們可以先將所有文件按字典序排序,然後使用二分查找就能極大提高查找效率(O(logn)時間)。但二分查找需要先對數據排序,排序的代價比較高(高級排序算法也需要O(n*logn)時間),如果數據是靜態的,增加、刪除、修改元素比較少,先對數據排序,再使用二分查找效率還不錯,我們平時查字典就是按照二分查找進行的。但網絡上新增、刪除、修改文件比較頻繁,要時刻維持文件的有序,成本就比較高了。

搜索引擎是如何做到在很短時間內從數億文件中找到與關鍵詞相關的網頁的呢?還記得我們閱讀過的很多專業書籍後面都有索引頁嗎?藉助索引頁,可以通過某關鍵詞迅速查找到該關鍵詞出現的頁碼或頁碼範圍,藉助索引查找效率就比二分查找高多了(O(1)時間)。以《數據結構與算法分析—C語言描述》爲例,分治算法出現在365-380處、歸併排序出現在230-235處、快速排序出現在235-247處等。

搜索引擎也是使用了類似書籍中的索引頁,比如谷歌爲每個可能的搜索關鍵詞建立索引,索引中記錄了包含該關鍵詞的網頁的存儲位置。我們搜索某個關鍵詞時,根據索引可以迅速定位包含該關鍵詞的相關網頁的存儲位置。假如同時搜索多個關鍵詞,則可以將每個關鍵詞在索引中記錄的網頁集合取交集,搜索引擎就可以返回同時包含多個關鍵詞的網頁。

1.2 如何在索引中迅速找到關鍵詞

有了索引,我們不需要對網頁文件進行排序,就可以使用O(1)時間快速查找到所需要的網頁信息,對於不斷變動的數據文件,我們只需要更新部分索引,省去了維持數據文件有序的成本開銷。索引的建立和更新也比較簡單,在一個數據文件創建、刪除或修改時,將該數據文件中包含的所有關鍵詞對應的索引項中新增或移除該數據文件的位置即可。但是,我們如何在索引表中快速查找到關鍵詞條目呢?

書籍後面的索引頁中,關鍵詞是按照字典序排列的,可以使用二分查找快速找到某關鍵詞在索引頁中出現的位置。在計算機中,還有更快的查找方式嗎?再回顧下索引是如何在O(1)時間內給出結果的,索引條目實際上相當於在關鍵詞和數據文件之間建立了一個一對多的映射關係,我們在索引中找到了關鍵詞key,就可以立即獲得該關鍵詞條目中記錄的數據文件位置value。查找索引關鍵詞是否也能借助這個映射技巧實現呢?

在計算機中,索引是存儲在內存地址空間中的,我們之前瞭解過順序表與鏈式表,順序表能夠實現O(1)時間的隨機訪問效率,在順序表中我們可以將一個數組元素分爲兩部分:數組下標記錄該數組元素在內存中的地址;數組元素值記錄在該存儲單元中存儲的數據。通過數組下標可以在O(1)時間訪問該數組元素值,相當於數組元素下標位置與數組元素值之間有一個一對一的映射關係。使用數組下標訪問元素值,對計算機很方便,但對於我們並不方便(我們對數字編號不敏感,更習慣概念名稱檢索信息),我們更喜歡使用關鍵詞訪問對應的元素值,能否在數組下標與關鍵詞之間建立一個一對一的映射關係呢?

在計算機中,數字、詞彙、符號等都是使用二進制編碼的,關鍵詞與數組下標之間建議一對一的映射關係也是可能的。我們通過一個函數,將關鍵詞映射爲數組下標,在保存索引條目:關鍵字–元素值(key–value)時,通過映射函數在內存中存儲爲數組下標–元素值的形式;當需要使用關鍵字key訪問其對應的元素值value時,再通過通用的映射函數,將關鍵詞映射爲索引數組中的下標,就可以使用數組O(1)時間的隨機訪問效率,快速查找到該索引數組下標存儲的元素值value,也就間接實現了通過關鍵字key在O(1)時間訪問其對應的元素值value的目的。
散列表邏輯結構
上面這種可以將關鍵詞key映射爲數組下標的函數稱作散列(hash)函數,藉助hash函數保存key–value索引條目的數組稱爲散列表(hash table)。散列表的插入、刪除、查找或替換操作都需要兩步:先通過hash函數將關鍵詞key映射爲數組下標,再通過數組下標訪問關鍵字key對應的元素值value。

散列表的物理存儲結構依然是數組,邏輯存儲結構是key–value鍵值索引對兒,相比數組O(n)的插入與刪除時間複雜度,散列表的插入、刪除、訪問或替換時間複雜度都是O(1)。看起來散列表集中了數組與鏈表的優點,既能高效的訪問數據,又能高效的插入、刪除數據,這是怎麼做到的呢?

散列表繼承了數組通過下標快速訪問元素值的優點,數組插入、刪除元素需要空出新位置或擠佔空位置來保證數組元素間的鄰接關係,散列表放棄了元素間的鄰接關係,允許空位置的存在,因此可以達到鏈式表的元素插入、刪除效率(時間複雜度O(1))。由於散列表利用的是數組支持按照下標隨機訪問元素的特性,底層並不能通過鏈式表來實現散列表。

凡事有收益就有代價,散列表相比數組放棄了元素間的鄰接關係,也沒有存儲類似鏈表的指向關係,因此散列表中的元素間沒有關係,散列表並不是線性表(可以把散列表看作一個元素集合)。

二、如何實現一個散列表

2.1 什麼是散列函數

散列函數在散列表中起着非常關鍵的作用,完成將關鍵字key映射爲散列表元素下標的作用。我們可以把散列函數定義爲hash(key),其中key表示元素索引的關鍵詞,hash(key)的值表示經過散列函數計算得到的散列值,也即散列表中關鍵詞key映射到的數組下標。那麼,該如何構造散列函數呢?

散列函數設計需要滿足以下基本要求:

  • 散列函數計算得到的散列值是一個非負整數(數組下標非負);
  • 如果key1 == key2,則hash(key1) == hash(key2);
  • 如果key1 != key2,則hash(key1) != hash(key2);

散列函數理想情況下需要實現關鍵詞key與散列值hash(key)之間一對一的映射關係,相同的鍵值key具有相同的散列值hash(key),不同的鍵值key具有不同的散列值hash(key)。但在真實情況下,要想找到一個不同的鍵值key對應的散列值都不一樣的散列函數,幾乎是不可能的,即便像業界著名的MD5、SHA、CRC等哈希算法,也無法完全避免這種散列衝突(即不同的鍵值key可能有相同的散列值)。

因爲數組的存儲空間有限,當元素數量接近數組容量的某個比值後,也會加大散列衝突的概率。我們幾乎無法找到一個完美的無衝突的散列函數,即便能找到,付出的時間成本、計算成本也是很大的,所以針對散列衝突問題,我們需要通過其它方法來解決。

2.2 如何解決散列衝突

再好的散列函數也無法避免散列衝突。那究竟該如何解決散列衝突問題呢?我們常用的散列衝突解決方法有兩類,開放尋址法(open addressing)和鏈表法(chaining)。

  • 開放尋址法

開放尋址法的核心思想是,如果出現了散列衝突,我們就重新探測一個空閒位置,將其插入。那如何重新探測新的位置呢?我先講一個比較簡單的探測方法,線性探測(Linear Probing)

當我們往散列表中插入數據時,如果某個數據經過散列函數散列之後,存儲位置已經被佔用了,我們就從當前位置開始,依次往後查找,看是否有空閒位置,直到找到爲止。舉一個例子具體說明一下,下面黃色的色塊表示空閒位置,橙色的色塊表示已經存儲了數據:
散列表線性探測示例
從圖中可以看出,散列表的大小爲 10,在元素 x 插入散列表之前,已經 6 個元素插入到散列表中。x 經過 Hash 算法之後,被散列到位置下標爲 7 的位置,但是這個位置已經有數據了,所以就產生了衝突。於是我們就順序地往後一個一個找,看有沒有空閒的位置,遍歷到尾部都沒有找到空閒的位置,於是我們再從表頭開始找,直到找到空閒位置 2,於是將其插入到這個位置。

在散列表中查找元素的過程有點兒類似插入過程。我們通過散列函數求出要查找元素的鍵值對應的散列值,然後比較數組中下標爲散列值的元素和要查找的元素。如果相等,則說明就是我們要找的元素;否則就順序往後依次查找。如果遍歷到數組中的空閒位置,還沒有找到,就說明要查找的元素並沒有在散列表中。

散列表跟數組一樣,不僅支持插入、查找操作,還支持刪除操作。對於使用線性探測法解決衝突的散列表,刪除操作稍微有些特別。我們不能單純地把要刪除的元素設置爲空。這是爲什麼呢?

還記得我們剛講的查找操作嗎?在查找的時候,一旦我們通過線性探測方法,找到一個空閒位置,我們就可以認定散列表中不存在這個數據。但是,如果這個空閒位置是我們後來刪除的,就會導致原來的查找算法失效。本來存在的數據,會被認定爲不存在。這個問題如何解決呢?

我們可以將刪除的元素,特殊標記爲 deleted。當線性探測查找的時候,遇到標記爲 deleted 的空間,並不是停下來,而是繼續往下探測。
線性探測刪除元素
你可能已經發現了,線性探測法其實存在很大問題。當散列表中插入的數據越來越多時,散列衝突發生的可能性就會越來越大,空閒位置會越來越少,線性探測的時間就會越來越久。極端情況下,我們可能需要探測整個散列表,所以最壞情況下的時間複雜度爲 O(n)。同理,在刪除和查找時,也有可能會線性探測整張散列表,才能找到要查找或者刪除的數據。

對於開放尋址衝突解決方法,除了線性探測方法之外,還有另外兩種比較經典的探測方法,二次探測(Quadratic probing)雙重散列(Double hashing)

所謂二次探測,跟線性探測很像,線性探測每次探測的步長是 1,那它探測的下標序列就是 hash(key)+0,hash(key)+1,hash(key)+2……而二次探測探測的步長就變成了原來的“二次方”,也就是說,它探測的下標序列就是 hash(key)+0,hash(key)+12,hash(key)+22……

所謂雙重散列,意思就是不只要使用一個散列函數。我們使用一組散列函數 hash1(key),hash2(key),hash3(key)……我們先用第一個散列函數,如果計算得到的存儲位置已經被佔用,再用第二個散列函數,依次類推,直到找到空閒的存儲位置。

不管採用哪種探測方法,當散列表中空閒位置不多的時候,散列衝突的概率就會大大提高。爲了儘可能保證散列表的操作效率,一般情況下,我們會儘可能保證散列表中有一定比例的空閒槽位。我們用裝載因子(load factor)來表示空位的多少,裝載因子的計算公式是:

散列表的裝載因子 = 填入表中的元素個數 / 散列表的長度

裝載因子越大,說明空閒位置越少,衝突越多,散列表的性能會下降。

  • 鏈表法

鏈表法是一種更加常用的散列衝突解決辦法,相比開放尋址法,它要簡單很多。在散列表中,每個“桶(bucket)”或者“槽(slot)”會對應一條鏈表,所有散列值相同的元素我們都放到相同槽位對應的鏈表中,如下圖所示:
鏈表法圖示
當插入的時候,我們只需要通過散列函數計算出對應的散列槽位,將其插入到對應鏈表中即可,所以插入的時間複雜度是 O(1)。當查找、刪除一個元素時,我們同樣通過散列函數計算出對應的槽,然後遍歷鏈表查找或者刪除。那查找或刪除操作的時間複雜度是多少呢?

實際上,這兩個操作的時間複雜度跟鏈表的長度 k 成正比,也就是 O(k)。對於散列比較均勻的散列函數來說,理論上講,k=n/m,其中 n 表示散列中數據的個數,m 表示散列表中“槽”的個數。

2.3 如何設計散列函數

散列函數設計的好壞,決定了散列表衝突的概率大小,也直接決定了散列表的性能。那什麼纔是好的散列函數呢?

首先,散列函數的設計不能太複雜。過於複雜的散列函數,勢必會消耗很多計算時間,也就間接的影響到散列表的性能。其次,散列函數生成的值要儘可能隨機並且均勻分佈,這樣才能避免或者最小化散列衝突,而且即便出現衝突,散列到每個槽裏的數據也會比較平均,不會出現某個槽內數據特別多的情況。

實際工作中,我們還需要綜合考慮各種因素。這些因素有關鍵字的長度、特點、分佈、還有散列表的大小等。散列函數各式各樣,我舉幾個常用的、簡單的散列函數的設計方法,讓你有個直觀的感受。

第一個例子,比如處理手機號碼,因爲手機號碼前幾位重複的可能性很大,但是後面幾位就比較隨機,我們可以取手機號的後四位作爲散列值。這種散列函數的設計方法,我們一般叫作“數據分析法”。

第二個例子,如何實現 Word 拼寫檢查功能。這裏面的散列函數,我們就可以這樣設計:將單詞中每個字母的ASCll 碼值“進位”相加,然後再跟散列表的大小求餘、取模,作爲散列值。散列函數代碼如下:

typedef unsigned int Index;

Index Hash(const char *key, int TableSize)
{
	unsigned int HashVal = 0;
	while(*key != '\0')
	{
		HashVal = HashVal * 26 + *key;
		key++;
	}
	return HashVal % TableSize;
}

上面的散列函數並沒有採用直接將字符串中字符的ASCII碼值相加的方法,主要是ASCII碼值相加的範圍較小,比如假設每個單詞平均長度爲8個字符長度,相加得到的散列值最大也才127 * 8 = 1016,遠小於常用單詞數目(假設TableSize爲20000),這就會導致散列值集中在頭部,分佈很不均勻。因此採用了上面將單詞中每個字母的ASCll 碼值“進位”相加的方法,由於共有26個字母,故按26進制進位相加。如果想進一步提高計算效率,可以使用移位運算替代乘除運算,乘以26可以更改爲左移5位。

實際上,散列函數的設計方法還有很多,比如直接尋址法、平方取中法、摺疊法、隨機數法等,這裏就不再展開介紹了。重點就是前面介紹的散列函數設計原則:散列函數計算成本儘量比較低,散列函數生成的值要儘可能隨機並且均勻分佈。

2.4 如何進行動態擴容

前面說過,裝載因子越大,說明散列表中的元素越多,空閒位置越少,散列衝突的概率就越大。不僅插入數據的過程要多次尋址或者拉很長的鏈,查找的過程也會因此變得很慢。

對於沒有頻繁插入和刪除的靜態數據集合來說,我們很容易根據數據的特點、分佈等,設計出完美的、極少衝突的散列函數,因爲畢竟之前數據都是已知的。

對於動態散列表來說,數據集合是頻繁變動的,我們事先無法預估將要加入的數據個數,所以我們也無法事先申請一個足夠大的散列表。隨着數據慢慢加入,裝載因子就會慢慢變大。當裝載因子大到一定程度之後,散列衝突就會變得不可接受。這個時候,我們該如何處理呢?

還記得前面多次介紹的“動態擴容”嗎?可以回想下,C++ STL Vector和Deque容器是如何做到動態擴容的。

針對散列表,當裝載因子過大時,我們也可以進行動態擴容,重新申請一個更大的散列表,將數據搬移到這個新散列表中。假設每次擴容我們都申請一個原來散列表大小兩倍的空間。如果原來散列表的裝載因子是 0.8,那經過擴容之後,新散列表的裝載因子就下降爲原來的一半,變成了 0.4。

針對數組的擴容,數據搬移操作比較簡單。但是,針對散列表的擴容,數據搬移操作要複雜很多。因爲散列表的大小變了,數據的存儲位置也變了,所以我們需要通過散列函數重新計算每個數據的存儲位置。比如下圖示例,在原來的散列表中,21 這個元素原來存儲在下標爲 0 的位置,搬移到新的散列表中,存儲在下標爲 7 的位置:
散列表擴容重散列
對於支持動態擴容的散列表,插入一個數據,最好情況下,不需要擴容,最好時間複雜度是 O(1)。最壞情況下,散列表裝載因子過高,啓動擴容,我們需要重新申請內存空間,重新計算哈希位置,並且搬移數據,所以時間複雜度是 O(n)。用攤還分析法,均攤情況下,時間複雜度接近最好情況,就是 O(1)。

實際上,對於動態散列表,隨着數據的刪除,散列表中的數據會越來越少,空閒空間會越來越多。如果我們對空間消耗非常敏感,我們可以在裝載因子小於某個值之後,啓動動態縮容。當然,如果我們更加在意執行效率,能夠容忍多消耗一點內存空間,那就可以不用費勁來縮容了。

當散列表的裝載因子超過某個閾值時,就需要進行擴容。裝載因子閾值需要選擇得當。如果太大,會導致衝突過多;如果太小,會導致內存浪費嚴重。裝載因子閾值的設置要權衡時間、空間複雜度。如果內存空間不緊張,對執行效率要求很高,可以降低負載因子的閾值;相反,如果內存空間緊張,對執行效率要求又不高,可以增加負載因子的值,甚至可以大於 1。

大部分情況下,動態擴容的散列表插入一個數據都很快,但是在特殊情況下,當裝載因子已經到達閾值,需要先進行擴容,再插入數據。這個時候,插入數據就會變得很慢,甚至會無法接受。

爲了解決一次性擴容耗時過多的情況,我們可以將擴容操作穿插在插入操作的過程中,分批完成。當裝載因子觸達閾值之後,我們只申請新空間,但並不將老的數據搬移到新散列表中。當有新數據要插入時,我們將新數據插入新散列表中,並且從老的散列表中拿出一個數據放入到新散列表。每次插入一個數據到散列表,我們都重複上面的過程。經過多次插入操作之後,老的散列表中的數據就一點一點全部搬移到新散列表中了。這樣沒有了集中的一次性數據搬移,插入操作就都變得很快了。對於查詢操作,爲了兼容了新、老散列表中的數據,我們先從新散列表中查找,如果沒有找到,再去老的散列表中查找。

通過這樣均攤的方法,將一次性擴容的代價,均攤到多次插入操作中,就避免了一次性擴容耗時過多的情況。這種實現方式,任何情況下,插入一個數據的時間複雜度都是 O(1)。

2.5 如何選擇散列衝突解決方法

前面介紹了兩種主要的散列衝突的解決辦法,開放尋址法和鏈表法。這兩種衝突解決辦法在實際的軟件開發中都非常常用。比如,Java 中 LinkedHashMap 就採用了鏈表法解決衝突,ThreadLocalMap 是通過線性探測的開放尋址法來解決衝突。那你知道,這兩種衝突解決方法各有什麼優勢和劣勢,又各自適用哪些場景嗎?

  • 開放尋址法

開放尋址法的優點:開放尋址法不像鏈表法,需要拉很多鏈表。散列表中的數據都存儲在數組中,可以有效地利用 CPU 緩存加快查詢速度。而且,這種方法實現的散列表,序列化起來比較簡單。鏈表法包含指針,序列化起來就沒那麼容易。

開放尋址法的缺點:用開放尋址法解決衝突的散列表,刪除數據的時候比較麻煩,需要特殊標記已經刪除掉的數據。而且,在開放尋址法中,所有的數據都存儲在一個數組中,比起鏈表法來說,衝突的代價更高。所以,使用開放尋址法解決衝突的散列表,裝載因子的上限不能太大。這也導致這種方法比鏈表法更浪費內存空間。

總結一下,當數據量比較小、裝載因子小的時候,適合採用開放尋址法。這也是 Java 中的ThreadLocalMap使用開放尋址法解決散列衝突的原因。

  • 鏈表法

首先,鏈表法對內存的利用率比開放尋址法要高。因爲鏈表結點可以在需要的時候再創建,並不需要像開放尋址法那樣事先申請好。實際上,這一點也是我們前面講過的鏈表優於數組的地方。

鏈表法比起開放尋址法,對大裝載因子的容忍度更高。開放尋址法只能適用裝載因子小於 1 的情況。接近 1 時,就可能會有大量的散列衝突,導致大量的探測、再散列等,性能會下降很多。但是對於鏈表法來說,只要散列函數的值隨機均勻,即便裝載因子變成 10,也就是鏈表的長度變長了而已,雖然查找效率有所下降,但是比起順序查找還是快很多。

鏈表因爲要存儲指針,所以對於比較小的對象的存儲,是比較消耗內存的,還有可能會讓內存的消耗翻倍。而且,因爲鏈表中的結點是零散分佈在內存中的,不是連續的,所以對 CPU 緩存是不友好的,這方面對於執行效率也有一定的影響。當然,如果我們存儲的是大對象,也就是說要存儲的對象的大小遠遠大於一個指針的大小(4 個字節或者 8 個字節),那鏈表中指針的內存消耗在大對象面前就可以忽略了。

實際上,我們對鏈表法稍加改造,可以實現一個更加高效的散列表。那就是,我們將鏈表法中的鏈表改造爲其他高效的動態數據結構,比如跳錶、紅黑樹。這樣,即便出現散列衝突,極端情況下,所有的數據都散列到同一個桶內,那最終退化成的散列表的查找時間也只不過是 O(logn)。這樣也就有效避免了前面講到的散列碰撞攻擊。
高級散列表
總結一下,基於鏈表的散列衝突處理方法比較適合存儲大對象、大數據量的散列表,而且,比起開放尋址法,它更加靈活,支持更多的優化策略,比如用跳錶或紅黑樹代替鏈表

2.6 如何實現一個散列表

根據前面介紹的散列表知識,我們可以從三個方面來考慮設計思路:

  • 設計一個合適的散列函數;
  • 定義裝載因子閾值,並且設計動態擴容策略;
  • 選擇合適的散列衝突解決方法。

散列函數前面已經實現了一個,這裏對其稍加改進,代碼如下:

// datastruct\hash_table.c

typedef unsigned int Index;

Index Hash(const char *key, int TableSize)
{
	unsigned int HashVal = 0;
	while(*key != '\0')
	{
		HashVal = (HashVal << 5) + *key;
		key++;
	}
	return HashVal % TableSize;
}

像文章開頭談到的搜索引擎,關鍵詞索引的數據規模比較大,我們採用鏈表法作爲散列衝突解決方案。先看散列表的數據結構定義(包括散列表頭,鏈表頭,鏈表元素結點的定義):

// datastruct\hash_table.c

struct ListNode {
    struct ListNode *next;		//指向下一個元素的地址
    KeyType key;                //當前元素結點索引關鍵詞
    ElementType Element;	    //可以是數據集合或其它的數據結構
};
typedef struct ListNode *pNode;

struct ListHead {
    struct ListNode *next;      //指向當前鏈表首結點地址
    int size;                   //當前鏈表元素節點數量
};
typedef struct ListHead *List;

struct HashTbl
{
    int TableSize;          //散列表容量
    List *HashLists;        //散列表數組基地址指針,指向各鏈表頭地址
    int NodeCount;          //散列表當前元素節點數量
};
typedef struct HashTbl *HashTable;

給出散列表的創建,查找,插入,刪除操作函數實現代碼:

// datastruct\hash_table.c

HashTable HashInit(int TableSize)
{
    HashTable H;
    int i;
    // allocate hash table
    H = malloc(sizeof(struct HashTbl));
    if(H == NULL){
        printf("Out of space!");
        return NULL;
    }

    H->TableSize = TableSize;
    H->NodeCount = 0;
    // allocate array of lists
    H->HashLists = malloc((H->TableSize) * sizeof(List));
    if(H->HashLists == NULL){
        printf("Out of space!");
        return NULL;
    }
    // allocate list headers
    for(i = 0; i < H->TableSize; i++)
    {
        H->HashLists[i] = malloc(sizeof(struct ListHead));
        if(H->HashLists[i] == NULL){
            printf("Out of space!");
            return NULL;
        }

        H->HashLists[i]->size = 0;
        H->HashLists[i]->next = NULL;
    }
    return H;
}

pNode HashFind(HashTable H, KeyType key)
{
    List L = H->HashLists[Hash(key, H->TableSize)];
    pNode P = L->next;

    while (P != NULL && P->key != key)
        P = P->next;
    
    return P;
}

void HashInsert(HashTable H, KeyType key, ElementType data)
{
    pNode pos = HashFind(H, key);
    if(pos == NULL)
    {  
        pNode P = malloc(sizeof(struct ListNode));
        if(P == NULL){
            printf("Out of space!");
            return;
        }

        List L = H->HashLists[Hash(key, H->TableSize)];

        P->key = key;
        P->Element = data;
        P->next = L->next;
        L->next = P;

        L->size++;
        H->NodeCount++;
    }
}

void HashDelete(HashTable H, KeyType key)
{
    List L = H->HashLists[Hash(key, H->TableSize)];
    pNode prev = (pNode)L;
    while (prev->next != NULL && prev->next->key != key)
        prev = prev->next;
    
    if(prev->next != NULL)
    {
        List L = H->HashLists[Hash(key, H->TableSize)];
        pNode temp = prev->next;
        prev->next = temp->next;
        free(temp);
        
        L->size--;
        H->NodeCount--;
    }
}

鏈表法對散列衝突的容忍性較強,裝載因子可以略大些,比如超過3,開放尋址法對散列衝突的容忍性較差,裝載因子建議不超過0.5。上面選擇鏈表法作爲散列衝突解決方案,爲方便示例,裝載因子暫設爲1。

散列表的動態擴容函數實現代碼如下:

// datastruct\hash_table.c

HashTable ReHashDouble(HashTable H)
{
    HashTable OldHash = H;

    H = HashInit(2 * (OldHash->TableSize));

    int i;
    for(i = 0; i < OldHash->TableSize; i++)
    {
        List L = OldHash->HashLists[i];
        pNode P = L->next;
        while (P != NULL)
        {
            HashInsert(H, P->key, P->Element);
            pNode temp = P;
            P = P->next;
            free(temp);
        }
        free(L);
    }
    free(OldHash->HashLists);
    free(OldHash);

    return H;
}

到這裏就實現了一個支持動態擴容的散列表,下面給出一個應用示例,驗證一下我們實現的操作函數是否有明顯bug,示例代碼如下:

// datastruct\hash_table.c

#include <stdio.h>
#include <stdlib.h>

#define ElementType int
typedef char *KeyType;
typedef unsigned int Index;

typedef struct ListNode *pNode;
typedef struct ListHead *List;
typedef struct HashTbl *HashTable;

HashTable HashInit(int TableSize);
Index Hash(const char *key, int TableSize);
pNode HashFind(HashTable H, KeyType key);
void HashInsert(HashTable H, KeyType key, ElementType data);
void HashDelete(HashTable H, KeyType key);
HashTable ReHashDouble(HashTable H);

void PrintHash(HashTable H)
{
    int i;
    printf("Hash table size: %d, base address: %X\n", H->TableSize, H->HashLists);
    for(i = 0; i < H->TableSize; i++)
    {
        List L = H->HashLists[i];
        pNode P = L->next;
        printf("HashTable[%d]: ", i);
        while (P != NULL)
        {
            if(P != L->next)
                printf("-->");
            printf("%d", P->Element);
            P = P->next;
        }
        printf("\n");
    }
}

int main(void)
{
    HashTable H = HashInit(3);
    
    HashInsert(H, "key1", 1);
    HashInsert(H, "key2", 2);
    HashInsert(H, "key3", 3);
    HashInsert(H, "key4", 4);
    HashInsert(H, "key5", 5);
    HashInsert(H, "key6", 6);
    PrintHash(H);

    if(H->NodeCount > H->TableSize)
        H = ReHashDouble(H);
    PrintHash(H);

    KeyType key = "key3";
    pNode ptr = HashFind(H, key);
    if(ptr != NULL)
        printf("find %s data: %d\n", key, ptr->Element);

    HashDelete(H, "key1");
    HashDelete(H, "key5");
    PrintHash(H);

    return 0;
}

上面的散列表示例程序運行結果如下:
散列表示例程序運行結果

三、散列表與鏈表的組合應用

前面介紹過,散列表這種數據結構雖然支持非常高效的數據插入、刪除、查找操作,但是散列表中的數據都是通過散列函數打亂之後無規律存儲的。也就說,它無法支持按照某種順序快速地遍歷數據。如果希望按照順序遍歷散列表中的數據,那我們需要將散列表中的數據拷貝到數組中,然後排序,再遍歷。

因爲散列表是動態數據結構,不停地有數據的插入、刪除,所以每當我們希望按順序遍歷散列表中的數據的時候,都需要先排序,那效率勢必會很低。爲了解決這個問題,我們可以將散列表和鏈表(或者跳錶)結合在一起使用,通過散列表實現O(1)時間的數據插入、刪除、查找操作,通過鏈表或跳錶存儲數據元素間的前後指向關係,就可以按照我們需要的某種順序快速遍歷散列表中的數據,充分發揮兩種數據結構的優點,互相彌補自身的不足。

3.1 如何實現一個LRU 緩存淘汰算法

緩存是一種提高數據讀取性能的技術,在硬件設計、軟件開發中都有着非常廣泛的應用,比如常見的 CPU 緩存、數據庫緩存、瀏覽器緩存等等。緩存的大小有限,當緩存被用滿時,哪些數據應該被清理出去,哪些數據應該被保留?這就需要緩存淘汰策略來決定。常見的策略有三種:先進先出策略 FIFO(First In,First Out)、最少使用策略 LFU(Least Frequently Used)、最近最少使用策略 LRU(Least Recently Used)。

如果我們想實現一個 LRU 緩存淘汰算法,該怎麼做呢?先考慮使用鏈表如何實現?我們需要維護一個按照訪問時間從早到晚有序排列的鏈表結構,越靠近鏈表頭部的結點是越早之前訪問的。因爲緩存大小有限,當緩存空間不夠,需要淘汰一個數據的時候,我們就直接將鏈表頭部的結點刪除。

當要緩存某個數據的時候,先在鏈表中查找這個數據。如果沒有找到,則直接將數據放到鏈表的尾部;如果找到了,我們就把它移動到鏈表的尾部。因爲查找數據需要遍歷鏈表,所以單純用鏈表實現的 LRU 緩存淘汰算法的時間複雜很高,是 O(n)。

總結一下,一個緩存(cache)系統主要包含下面這幾個操作:

  • 往緩存中添加一個數據;
  • 從緩存中刪除一個數據;
  • 在緩存中查找一個數據。

這三個操作都要涉及“查找”操作,如果單純地採用鏈表的話,時間複雜度只能是 O(n)。如果我們將散列表和鏈表兩種數據結構組合使用,可以將這三個操作的時間複雜度都降低到 O(1)。具體的結構就是下面這個樣子:
散列表與雙向鏈表結合應用之LRU緩存淘汰算法
我們使用雙向鏈表存儲數據,鏈表中的每個結點處理存儲數據(data)、前驅指針(prev)、後繼指針(next)之外,還新增了一個特殊的字段 hnext。這個 hnext 有什麼作用呢?

因爲我們的散列表是通過鏈表法解決散列衝突的,所以每個結點會在兩條鏈中。一個鏈是剛剛我們提到的雙向鏈表,另一個鏈是散列表中的拉鍊。前驅和後繼指針是爲了將結點串在雙向鏈表中,hnext 指針是爲了將結點串在散列表的拉鍊中。

瞭解了這個散列表和雙向鏈表的組合存儲結構之後,我們再來看,前面講到的緩存的三個操作,是如何做到時間複雜度是 O(1) 的?

首先,我們來看如何查找一個數據。我們前面講過,散列表中查找數據的時間複雜度接近 O(1),所以通過散列表,我們可以很快地在緩存中找到一個數據。當找到數據之後,我們還需要將它移動到雙向鏈表的尾部。

其次,我們來看如何刪除一個數據。我們需要找到數據所在的結點,然後將結點刪除。藉助散列表,我們可以在 O(1) 時間複雜度裏找到要刪除的結點。因爲我們的鏈表是雙向鏈表,雙向鏈表可以通過前驅指針 O(1) 時間複雜度獲取前驅結點,所以在雙向鏈表中,刪除結點只需要 O(1) 的時間複雜度。

最後,我們來看如何添加一個數據。添加數據到緩存稍微有點麻煩,我們需要先看這個數據是否已經在緩存中。如果已經在其中,需要將其移動到雙向鏈表的尾部;如果不在其中,還要看緩存有沒有滿。如果滿了,則將雙向鏈表頭部的結點刪除,然後再將數據放到鏈表的尾部;如果沒有滿,就直接將數據放到鏈表的尾部。

這整個過程涉及的查找操作都可以通過散列表來完成。其他的操作,比如刪除頭結點、鏈表尾部插入數據等,都可以在 O(1) 的時間複雜度內完成。所以,這三個操作的時間複雜度都是 O(1)。至此,我們就通過散列表和雙向鏈表的組合使用,實現了一個高效的、支持 LRU 緩存淘汰算法的緩存系統原型。

3.2 Redis有序集合是如何實現的

Redis(全稱:Remote Dictionary Server 遠程字典服務)是一個開源的使用ANSI C語言編寫、支持網絡、可基於內存亦可持久化的日誌型、Key-Value數據庫,並提供多種語言的API。它通常被稱爲數據結構服務器,因爲值(value)可以是 字符串(String)、哈希(Hash)、列表(list)、集合(sets) 和 有序集合(sorted sets)等類型。

在Redis有序集合中,每個成員對象有兩個重要的屬性:key(鍵值)和 score(分值)。我們不僅會通過 score 來查找數據,還會通過 key 來查找數據。舉個例子,比如用戶積分排行榜有這樣一個功能:我們可以通過用戶的 ID 來查找積分信息,也可以通過積分區間來查找用戶 ID 或者姓名信息。這裏包含 ID、姓名和積分的用戶信息,就是成員對象,用戶 ID 就是 key,積分就是 score。

如果我們細化一下 Redis 有序集合的操作,那就是下面這樣:

  • 添加一個成員對象;
  • 按照鍵值來刪除一個成員對象;
  • 按照鍵值來查找一個成員對象;
  • 按照分值區間查找數據,比如查找積分在 [100, 356] 之間的成員對象;
  • 按照分值從小到大排序成員變量;

前面三種操作使用散列表就可以實現,而且時間複雜度爲O(1),但後兩個操作散列表就無能爲力了。我們該用什麼數據結構來保存成員對象按照分值從小到大的排序信息呢?由於Redis是動態的,需要頻繁插入、刪除和查找,我們首先會想到鏈表,但鏈表的查找時間複雜度爲O(n),效率太低,還有更高效的方法嗎?

還記得前面介紹過的跳錶嗎?跳錶很適合在有序鏈表中實現二分查找的高效率,時間複雜度爲O(logn)。而且在跳錶中也很適合按照分值區間查找數據,使用O(logn)時間找到分值區間的起始分值,然後從起始分值處按順序往後遍歷跳錶,直到遍歷到分值區間的末尾分值即可。

Redis每個成員對象的兩個屬性:key(鍵值)和 score(分值),鍵值key屬性使用散列表組織起來,分值score屬性使用跳錶組織起來,能充分發揮散列表與跳錶的優勢,同時互相彌補自身的不足。

本章數據結構實現源碼下載地址:https://github.com/StreamAI/ADT-and-Algorithm-in-C/tree/master/datastruct

更多文章:

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