1. 程式人生 > 其它 >資料結構 - 散列表

資料結構 - 散列表

簡介

散列表也被稱為雜湊表,其具體實現就是使用到了雜湊技術。

雜湊技術是在記錄的儲存位置和它的關鍵字之間建立一個確定的對應關係,使得每個關鍵字對應一個儲存位置。

關鍵字

散列表一般都是用在查詢的時候,所以,需要儲存的原始資料被稱作是查詢的關鍵字

雜湊演算法

雜湊技術的關鍵在於將關鍵字與儲存位置建立對應關係,這種建立對映關係的規則被稱作雜湊演算法

一般的雜湊演算法都是將任意長度的二進位制值串對映為固定長度的二進位制值串,而這個固定長度的二進位制值串可以匹配上儲存位置。

雜湊值

通過原始資料對映之後得到的二進位制值串就是雜湊值

裝載因子

雜湊衝突是非常常見的,因此,在此基礎上增加了一個裝載因子

的概念,用來表示散列表中空位的多少,其計算公式為:

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

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

構造方法

散列表利用了陣列支援使用下標快速隨機訪問資料的特性,散列表是由陣列演化而來,可以理解為散列表其實就是陣列的一種擴充套件。可以說,如果沒有陣列,就沒有散列表。

散列表通常是基於陣列實現的,而相比陣列,它還存在建立、刪除、查詢都非常快的優勢,無論資料量多大,這些操作都接近於 \(O(1)\) 的時間複雜度;但相對也存在一些劣勢,散列表中的資料沒有順序,而且不能在同一儲存地址儲存重複的雜湊值。

不管是什麼程式語言,實現一個散列表資料結構的過程都可以分為三個步驟:

  1. 實現一個雜湊演算法;
  2. 合理解決雜湊衝突;
  3. 實現其他操作方法。

雜湊演算法

一個好的雜湊演算法對於散列表是非常重要的,是散列表具有比較優的時間複雜度和空間複雜度的根本。而好的雜湊演算法需要具備兩個原則:快速計算均勻分佈

特點

具體來說,實現一個好的雜湊演算法會有以下特點:

  • 雜湊演算法的方向是單向的,從雜湊值不能反向推匯出原始資料
  • 雜湊演算法的轉換是敏感的,原始資料任何一點變動,得到的雜湊值都會大不相同
  • 雜湊衝突的概率要極小的,不同的原始資料,經過雜湊演算法得到的雜湊值相同的概率很小
  • 雜湊演算法的執行效率是高效的,即使是很長的文字也能快速計算出雜湊值

直接定址法

直接定址法就是取關鍵字的某個線性函式值作為雜湊值,或者使用這個線性函式值經過特定的演算法計算出雜湊值。

例如,儲存中國每一年的人數時,可以將年份作為計算雜湊值的線性函式值,可以想象得到,年份是連續的,並且沒有衝突,非常適合用來計算雜湊值。

直接定址法的優點就是簡單、分佈均勻、不會出現衝突。其缺點也很明顯,就是要求選定的線性函式值分佈均勻、不會出現衝突。

換一種角度看,直接定址法是有限制的,這個雜湊演算法並不常用。

數字分析法

數字分析法的核心就是從原始資料中選取一個辨識度較大的資料作為計算雜湊演算法的關鍵字,比較常見的就是用於處理關鍵字位數比較大的數字。

數字分析法有個比較常見的場景,國內的手機號都是 11 位的數字,而且也常見到將中間 4 位數字隱藏的做法,這個其實就是數字分析法的一種用法。因為 11 位手機號的前 3 位是運營商接入號,中間 4 位是歸屬地識別號,後 4 位是使用者號,當手機號碼都在同一個地區時,只需要使用前 3 位和後 4 位就可以作為雜湊演算法的關鍵字。

數字分析法也是一種相對簡單的雜湊演算法,但一般針對較大數字,如果這些較大數字分佈均勻的話,可以選用這個方法。

平方取中法

平方取中法的規則如其名。假設關鍵字是 1234,計算得到它的平方就是 1522756,再抽取中間三位 227 可以作為雜湊值;假設關鍵字是 4321,計算得到它的平方就是 18671041,抽取中間三位 671 或 710 作為雜湊值即可。

平方取中法比較適合不知道關鍵字的分佈、而位數又不是很大的情況。

摺疊法

摺疊法主要是將關鍵字從左到右分割成位數相等的幾部分,然後將這幾部分疊加求和,並根據散列表的長度,取後幾位作為雜湊值。

比如,對 9876543210 使用摺疊法,假設散列表表長為三位,將 9876543210 分成 987|654|321|0 這樣四組,然後對這四組使用 987+654+321+0 疊加求和計算得到 1962,再取 1962 的後三位作為雜湊值。

摺疊法的應用場景可以和平方取中法互補,適合用在不知道關鍵字的分佈,而位數較多的情況。

除留取餘法

除留取餘法是最常用的雜湊演算法之一,實際就是對關鍵字求模取餘數,這個餘數就是雜湊值。但是這種方法得到的雜湊值非常容易衝突,這個方法的關鍵就是要選擇合適的除數。

根據前輩們的經驗,通常這個除數選取小於等於散列表表長的最大質數或不包含小於 20 質因子的合數。

合數是指在大於 1 的整數中除了能被 1 和本身整除外,還能被其他數(0 除外)整除的數。與之相對的是質數,而 1 既不屬於質數也不屬於合數。最小的合數是 4。

雜湊衝突

設計得再好的雜湊演算法也很難完全避免衝突,從散列表出現到現在,也出現了很多解決雜湊衝突的常規方法。

開放定址法

所謂開放定址法就是,一旦發生了衝突,就去尋找下一個空的儲存地址,只要散列表足夠大,空的儲存地址總能被找到,並將資料存入。

像是這種尋找下一個空的儲存地址的常用方法就是線性探測法,即一個一個去尋找,直至找到下一個空的儲存地址。但是這種方法在雜湊衝突比較多的時候,散列表的時間複雜度會慢慢下降到 \(O(n)\)

除了線性探測法,還有二次探測、雙重雜湊的方法。

二次探測就是將線性探測為 1 的步長改成平方的步長。例如,線上性探測中是 hash(key) + 0, hash(key) + 1, hash(key) + 2,二次探測就是 hash(key) + 0, hash(key) + \(1^2\)、hash(key) + \(2^2\)

雙重雜湊指的是使用多個雜湊演算法,如果第一個雜湊演算法出現衝突,就使用第二個雜湊演算法,以此類推,直至找到空閒的儲存位置。

當資料量和裝載因子都比較小的時候,適合採用開放定址法。這也是 Java 中 ThreadLocalMap 使用開放定址法解決雜湊衝突的原因。

鏈地址法

鏈地址法是另一種更加常用的雜湊衝突解決辦法。

開放定址法是在原散列表上再次找到儲存位置,而鏈地址法是在出現雜湊衝突之後,將這些出現衝突的關鍵字儲存到連結串列中。具體的儲存結構如下圖所示:

這個方法相對於開放地址法來說多了連結串列這種資料結構,對於會出現很多衝突的雜湊演算法來說,提供了絕不會出現找不到地址的保障。

雖然這個方法在插入時沒有明顯提升效能損耗,但是帶來了查詢、刪除時需要遍歷單向連結串列的效能損耗。

鏈地址法在查詢時耗費的時間取決於連結串列的長度,可以將時間複雜度理解成 \(O(k)\)(k 為連結串列長度)。

針對於這樣的劣勢,一般是使用紅黑樹代替連結串列,Java 中的 HashMap 便是如此。

公共溢位區法

公共溢位區法可以理解為鏈地址法的集中方式。

公共溢位區法也是需要使用到另外的儲存區域,但不像鏈地址法中會將這個儲存區域鏈到連結串列上,而是使用一個單獨的儲存區域儲存所有衝突的關鍵字。

這個公共的溢位區可以是另外一個散列表,對衝突的關鍵字再次雜湊進行儲存。

應用場景

這裡的應用場景可以分為散列表的應用場景和雜湊演算法的應用場景。

對於散列表,使用它主要是為了提升時間複雜度;對於雜湊演算法,使用它是為了應用它的特點。

安全加密

常見的加密演算法使用的都是雜湊演算法,如 MD5、SHA 等。因為雜湊演算法不可逆和轉換敏感的特點,使用雜湊演算法的安全性非常好。

唯一標識

比如 URL 欄位或者圖片欄位要求不能重複,這個時候就可以通過對相應欄位值做 MD5 處理,將資料統一為 32 位長度,對資料庫索引構建和查詢提升非常明顯。

此外,還可以對檔案之類的二進位制資料做 MD5 處理,作為唯一標識,這樣判定重複檔案的時候更快捷。

資料校驗

比如從網上下載的很多檔案(尤其是 P2P 站點資源),都會包含一個 MD5 值,用於校驗下載資料的完整性,避免資料在中途被劫持篡改。

負載均衡

利用散列表代替對映表,可以實現一個會話粘滯的負載均衡策略。

對客戶端 IP 地址或者會話 ID 計算雜湊值,將取得的雜湊值與伺服器列表的大小進行取模運算,最終得到的值就是應該被路由到的伺服器編號。

資料分片

通過散列表對處理的海量資料進行分片,多機分散式處理,可以突破單機資源的限制。

經典案例

MD5

MD5 訊息摘要演算法是一種被廣泛使用的雜湊函式,可以產生出一個 128 位(16 位元組)的雜湊值,用於確保資訊傳輸完整一致。

MD5 是輸入不定長度資訊,輸出固定長度 128 位的演算法。無論是多長的資訊,經過程式流程,都會生成四個 32 位資料,最後聯合起來成為一個 128 位雜湊值。

但是在 2009 年,中國科學院的謝濤和馮登國僅用了 \(22^{0.96}\) 的碰撞演算法複雜度,破解了 MD5 的碰撞抵抗,該攻擊在普通計算機上執行只需要數秒鐘。2011 年,RFC 6151 禁止將 MD5 用作金鑰雜湊訊息認證碼。

SHA

安全雜湊演算法 SHA 是一個密碼雜湊函式家族,是 FIPS 所認證的安全雜湊演算法。這是能計算出一個數字訊息所對應到的、長度固定的字串(又稱訊息摘要)的演算法。且若輸入的訊息不同,它們對應到不同字串的機率很高。

SHA 家族演算法包含了 SHA-0、SHA-1、SHA-2、SHA-3,對 SHA-0 和 SHA-1 都已經出現理論上破解的方法,現在比較常見的還是 SHA-2,雖然至今尚未出現對 SHA-2 有效的攻擊,但它的演算法跟 SHA-1 基本上相似。

SHA-3 是在 2015 年正式釋出的,由於對 MD5 出現成功的破解,NIST 感覺需要一個與之前演算法不同的、可替換的加密雜湊演算法,也就是現在的 SHA-3。

CRC

迴圈冗餘校驗 CRC 是一種根據網路資料包或計算機檔案等資料產生簡短固定位數校驗碼的一種通道編碼技術,主要用來檢測或校驗資料傳輸或者儲存後可能出現的錯誤。它是利用除法及餘數的原理來作錯誤偵測的。

由於 CRC 演算法檢驗的檢錯能力極強,且檢測成本較低,因此在對於編碼器和電路的檢測中使用較為廣泛。從檢錯的正確率與速度、成本等方面,都比奇偶校驗等校驗方式具有優勢。因而 CRC 成為計算機資訊通訊領域最為普遍的校驗方式。

工業級散列表實現

Java 中的 HashMap 是一個非常經典的工業級散列表實現,理解它的實現可以加深對散列表的印象,有興趣可以看一下 HashMap 的原始碼。

初始大小

HashMap 預設的初始大小是 16,當然這個預設值是可以設定的,如果事先知道大概的資料量有多大,可以通過修改預設初始大小,減少動態擴容的次數,這樣能大大提高 HashMap 的效能。

散列表的容量要取 2 的整次冪,因為這樣正好相當於一個低位掩碼,在 hash() 方法內可以做到高位歸零。

裝載因子和動態擴容

HashMap 預設的最大裝載因子是 0.75,當 HashMap 中元素個數超過 0.75 * capacity(capacity 表示散列表的容量)的時候,就會啟動擴容,每次擴容都會擴容為原來的兩倍大小。

雜湊函式

雜湊函式的設計並不複雜,追求的是簡單高效、分佈均勻。HashMap 雜湊函式的原始碼如下:

int hash(Object key) {
    int h = key.hashCode();
    // capicity 表示散列表的大小
    return (h ^ (h >>> 16)) & (capitity -1);
}

在 Java 中,hashCode 方法通過將物件的實體地址轉換為一個整數,再將整數通過雜湊演算法計算得到雜湊碼,這個雜湊碼是一個 32 位的帶符號整數值,正常情況下很難發生碰撞。

為了讓這個雜湊碼與 HashMap 的底層陣列做對映,Java 還會將這個雜湊碼再次做雜湊操作,採用的方法是:

  1. 將雜湊碼右移 16 位,正好是 32bit 的一半;
  2. 將雜湊碼的高半區和低半區做異或,可以混合雜湊碼的高位和低位,以此加大低位的隨機性;
  3. 將計算結果做高位歸零,只保留低位值。

雜湊衝突解決方法

HashMap 底層採用鏈地址法來解決衝突。即使負載因子和雜湊函式設計得再合理,也免不了會出現拉鍊過長的情況,一旦出現拉鍊過長,則會嚴重影響 HashMap 的效能。

於是,在 JDK1.8 版本中,為了對 HashMap 做進一步優化引入了紅黑樹。

當連結串列長度太長(預設超過 8)時,連結串列就轉換為紅黑樹,這是利用了紅黑樹快速增刪改查的特點,提高 HashMap 的效能。

當紅黑樹結點個數少於 8 個的時候,又會將紅黑樹轉化為連結串列,這是因為在資料量較小的情況下,紅黑樹要維護平衡,比起連結串列來,效能上的優勢並不明顯。