1. 程式人生 > >散列表(雜湊表)及其儲存結構和特點詳解

散列表(雜湊表)及其儲存結構和特點詳解

順序儲存的結構型別需要一個一個地按順序訪問元素,當這個總量很大且我們所要訪問的元素比較靠後時,效能就會很低。散列表是一種空間換時間的儲存結構,是在演算法中提升效率的一種比較常用的方式,但是所需空間太大也會讓人頭疼,所以通常需要在二者之間權衡。我們會在之後的具體演算法章節中得到更多的領悟。

什麼是散列表

讓我們想一下,若在手機通訊錄中查詢一個人,那我們應該不會從第 1 個人一直找下去,因為這樣實在是太慢了。我們其實是這樣做的:首先看這個人的名字的首字母是什麼,比如姓張,那麼我們一定會滑到最後,因為“Z”姓的名字都在最後。

還有在查字典時,要查詢一個單詞,肯定不會從頭翻到尾,而是首先通過這個單詞的首字母,找到對應的那一頁;再找第 2 個字母、第 3 個字母……這樣可以快速跳到那個單詞所在的頁。

其實這裡就用到了散列表的思想。

散列表
,又叫雜湊表(Hash Table),是能夠通過給定的關鍵字的值直接訪問到具體對應的值的一個數據結構。也就是說,把關鍵字對映到一個表中的位置來直接訪問記錄,以加快訪問速度。

通常,我們把這個關鍵字稱為 Key,把對應的記錄稱為 Value,所以也可以說是通過 Key 訪問一個對映表來得到 Value 的地址。而這個對映表,也叫作雜湊函式或者雜湊函式,存放記錄的陣列叫作散列表。

其中有個特殊情況,就是通過不同的 Key,可能訪問到同一個地址,這種現象叫作碰撞(Collision)。而通過某個 Key 一定會得到唯一的 Value 地址。

目前,這個雜湊函式比較常用的實現方法比較多,通常需要考慮幾個因素:關鍵字的長度、雜湊表的大小、關鍵字的分佈情況、記錄的查詢頻率,等等。

下面簡單介紹幾種雜湊函式。
  • 直接定址法:
    取關鍵字或關鍵字的某個線性函式值為雜湊地址。
  • 數字分析法:通過對資料的分析,發現數據中衝突較少的部分,並構造雜湊地址。例如同學們的學號,通常同一屆學生的學號,其中前面的部分差別不太大,所以用後面的部分來構造雜湊地址。
  • 平方取中法:當無法確定關鍵字裡哪幾位的分佈相對比較均勻時,可以先求出關鍵字的平方值,然後按需要取平方值的中間幾位作為雜湊地址。這是因為:計算平方之後的中間幾位和關鍵字中的每一位都相關,所以不同的關鍵字會以較高的概率產生不同的雜湊地址。
  • 取隨機數法:使用一個隨機函式,取關鍵字的隨機值作為雜湊地址,這種方式通常用於關鍵字長度不同的場合。
  • 除留取餘法:取關鍵字被某個不大於散列表的表長 n 的數 m 除後所得的餘數 p 為雜湊地址。這種方式也可以在用過其他方法後再使用。該函式對 m 的選擇很重要,一般取素數或者直接用 n。

對散列表函式產生衝突的解決辦法

散列表為什麼會產生衝突呢?前面提到過,有時不同的 Key 通過雜湊函式可能會得到相同的地址,這在我們操作時可能會對資料造成覆蓋、丟失。之所以產生衝突是由於雜湊函式有時對不同的 Key 計算之後獲得了相同的地址。

衝突的處理方式也有很多,下面介紹幾種。
  • 開放地址法(也叫開放定址法):實際上就是當需要儲存值時,對Key雜湊之後,發現這個地址已經有值了,這時該怎麼辦?不能放在這個地址,不然之前的對映會被覆蓋。這時對計算出來的地址進行一個探測再雜湊,比如往後移動一個地址,如果沒人佔用,就用這個地址。如果超過最大長度,則可以對總長度取餘。這裡移動的地址是產生衝突時的增列序量。
  • 再雜湊法:在產生衝突之後,使用關鍵字的其他部分繼續計算地址,如果還是有衝突,則繼續使用其他部分再計算地址。這種方式的缺點是時間增加了。
  • 鏈地址法:鏈地址法其實就是對Key通過雜湊之後落在同一個地址上的值,做一個連結串列。其實在很多高階語言的實現當中,也是使用這種方式處理衝突的,我們會在後面著重學習這種方式。
  • 建立一個公共溢位區:這種方式是建立一個公共溢位區,當地址存在衝突時,把新的地址放在公共溢位區裡。

散列表的儲存結構

一個好的散列表設計,除了需要選擇一個性能較好的雜湊函式,否則衝突是無法避免的,所以通常還需要有一個好的衝突處理方式。這裡我們選擇除留取餘法作為雜湊函式,選擇鏈地址法作為衝突處理方式。

具體儲存結構如圖 1 所示。

圖 1 散列表的儲存結構

散列表的特點

散列表有兩種用法:一種是 Key 的值與 Value 的值一樣,一般我們稱這種情況的結構為 Set(集合);而如果 Key 和 Value 所對應的內容不一樣時,那麼我們稱這種情況為 Map,也就是人們俗稱的鍵值對集合。

根據散列表的儲存結構,我們可以得出散列表的以下特點。

1) 訪問速度很快

由於散列表有雜湊函式,可以將指定的 Key 都對映到一個地址上,所以在訪問一個 Key(鍵)對應的 Value(值)時,根本不需要一個一個地進行查詢,可以直接跳到那個地址。所以我們在對散列表進行新增、刪除、修改、查詢等任何操作時,速度都很快。

2) 需要額外的空間

首先,散列表實際上是存不滿的,如果一個散列表剛好能夠存滿,那麼肯定是個巧合。而且當散列表中元素的使用率越來越高時,效能會下降,所以一般會選擇擴容來解決這個問題。另外,如果有衝突的話,則也是需要額外的空間去儲存的,比如鏈地址法,不但需要額外的空間,甚至需要使用其他資料結構。

這個特點有個很常用的詞可以表達,叫作“空間換時間”,在大多數時候,對於演算法的實現,為了能夠有更好的效能,往往會考慮犧牲些空間,讓演算法能夠更快些。

3) 無序

散列表還有一個非常明顯的特點,那就是無序。為了能夠更快地訪問元素,散列表是根據雜湊函式直接找到儲存地址的,這樣我們的訪問速度就能夠更快,但是對於有序訪問卻沒有辦法應對。

4) 可能會產生碰撞

沒有完美的雜湊函式,無論如何總會產生衝突,這時就需要採用衝突解決方案,這也使散列表更加複雜。通常在不同的高階語言的實現中,對於衝突的解決方案不一定一樣。

散列表的適用場景

根據散列表的特點可以想到,散列表比較適合無序、需要快速訪問的情況。

快取

通常我們開發程式時,對一些常用的資訊會做快取,用的就是散列表,比如我們要快取使用者的資訊,一般使用者的資訊都會有唯一標識的欄位,比如 ID。這時做快取,可以把 ID 作為 Key,而 Value 用來儲存使用者的詳細資訊,這裡的 Value 通常是一個物件(高階語言中的術語,前面提到過),包含使用者的一些關鍵欄位,比如名字、年齡等。

在我們每次需要獲取一個使用者的資訊時,就不用與資料庫這類的本地磁碟儲存互動了(其實在大多數時候,資料庫可能與我們的服務不在一臺機器上,還會有相應的網路效能損耗),可以直接從記憶體中得到結果。這樣不僅能夠快速獲取資料,也能夠減輕資料庫的壓力。

有時我們要查詢一些資料,這些資料與其他資料是有關聯的,如果我們進行資料庫的關聯查詢,那麼效率會非常低,這時可以分為兩部分進行查詢:將被關聯的部分放入散列表中,只需要遍歷一遍;對於另一部分資料,則通過程式手動關聯,速度會很快,並且由於我們是通過散列表的 Key、Value 的對應關係對應資料的,所以效能也會比較好。

我之前所在的一家公司曾要做一個大查詢,查詢和資料組裝的時間達到了 40 秒,當然,資料量本身也比較大。但是,40 秒實在讓人無法忍受,於是我優化了這段程式碼,發現可以通過散列表處理來減少很多重複的查詢,最終做到了4秒左右的查詢耗時,速度快了很多。

快速查詢

這裡說的查詢,不是排序,而是在集合中找出是否存在指定的元素。

這樣的場景很多,比如我們要在指定的使用者列表中查詢是否存在指定的使用者,這時就可以使用散列表了。在這個場景下使用的散列表其實是在上面提到的 Set 型別,實際上不需要 Value 這個值。

還有一個場景,我們一般對網站的操作會有個IP地址黑名單,我們認為某些 IP 有大量的非法操作,於是封鎖了這些 IP 對我們網站的訪問。這個 IP 是如何儲存的呢?就是用的散列表。當一個訪問行為傳送過來時,我們會獲取其 IP,判斷其是否存在於黑名單中,如果存在,則禁止其訪問。這種情況也是使用的 Set。

當然,對於上面說的兩個例子,用列表也是可以實現的,但是訪問速度會受到很大的影響,尤其是列表越來越長時,查詢速度會很慢。散列表則不會。