1. 程式人生 > >十、散列表(Hash Table)

十、散列表(Hash Table)

一、概述

散列表(Hash Table),也稱“雜湊表”或者“Hash 表”

1、相關概念

  • 原始資料叫作鍵(鍵值)關鍵字(key)
  • 將原始資料轉化為陣列下標的對映方法稱為雜湊函式(或“Hash 函式”“雜湊函式”,hash function)
  • 而雜湊函式計算得到的值就叫作雜湊值(或“Hash 值”“雜湊值”,table)

2、散列表

(1)散列表用的就是陣列支援按照下標隨機訪問的時候,時間複雜度是O(1) 的特性
(2)生成:通過雜湊函式把元素的鍵值對映為下標,然後將資料儲存在陣列中對應下標的位置。
(3)訪問元素

:當我們按照鍵值查詢元素時,用同樣的雜湊函式,將鍵值轉化陣列下標,從對應的陣列下標的位置取資料。

二、雜湊函式

1、定義

  • 定義為 hash(key),其中key 表示元素的鍵值,hash(key) 的值表示經過雜湊函式計算得到的雜湊值。

2、設計

雜湊函式設計的基本要求:

  • 雜湊函式計算得到的雜湊值是一個非負整數; 《== 陣列下標
  • 如果 key1 = key2,那 hash(key1) == hash(key2);
  • 如果 key1 ≠ key2,那 hash(key1) ≠ hash(key2)。

第三點的理解:在真實情況下,想找到一個不同的 key 對應的雜湊值都不一樣的雜湊函式,幾乎是不可能的。eg:MD5、SHA、CRC等雜湊演算法,也無法完全避免這種雜湊衝突。而且,因為陣列的儲存空間有限,也會加大雜湊衝突的概率。

3、雜湊衝突

兩類方法:開放定址法(open addressing)和連結串列法(chaining)

(1)開放定址法

基本思想: 若存在雜湊衝突,則探測一個空閒位置,將其插入。

A、探測方法

a、線性探測(Linear Probing)
從當前位置開始,依次往後(若不夠則從頭繼續)查詢,看是否有空閒位置,直到找到為止。
==》
對應的查詢:通過雜湊函式求出要查詢元素的鍵值對應的雜湊值,然後比較陣列中下標為雜湊值的元素和要查詢的元素。如果相等,則說明就是我們要找的元素;否則就順序往後依次查詢。如果遍歷到陣列中的空閒位置(遇到標記為deleted的空間時,繼續向下探測)

,還沒有找到,就說明要查詢的元素並沒有在散列表中。
注意:散列表同樣支援插入、刪除操作。其中,刪除操作中:將刪除的元素進行特殊標記為deleted。因為如果這個空閒位置是我們後來刪除的,就會導致原來的查詢演算法失效。本來存在的資料,會被認定為不存在。
在這裡插入圖片描述
b、二次探測(Quadratic Probing)
區別: 步長變成了原來的“二次方“,即:探測的下標序列
hash(key)+0,hash(key)+12,hash(key)+22,hash(key)+32,……
c、雙重雜湊(Double hashing)
使用一組雜湊函式 hash1(key),hash2(key),hash3(key)……先用第一個雜湊函式,如果計算得到的儲存位置已經被佔用,再用第二個雜湊函式,依次類推,直到找找到空閒的儲存位置。

B、存在問題

當資料越來越多時,雜湊衝突發生的可能性就會越來越大,空閒位置會越來越少,線性探測的時間就會越來越久。極端情況下,最壞情況下的時間複雜度為 O(n)。同理,在刪除和查詢時,,也有可能會線性探測整張散列表,才能找到要查詢或者刪除的資料。

C、裝載因子(load factor)

為了儘可能保證散列表的操作效率,利用裝載因子(load factor)來表示空閒槽位的多少。

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

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

(2)連結串列法——更常用

所有雜湊值相同的元素我們都放到相同槽位對應的連結串列中。
在這裡插入圖片描述
插入:通過雜湊函式計算出對應的雜湊槽位,將其插入到對應連結串列中即可
==》時間複雜度:O(1)
查詢、刪除:需要遍歷,時間複雜度跟連結串列的長度 k 成正比,也就是 O(k)。

對於雜湊比較均勻的雜湊函式來說,理論上講,k=n/m,其中 n 表示雜湊中資料的個數,m 表示散列表中“槽”的個數。

三、工業級散列表

問題:如何設計一個可以應對各種異常情況的工業級散列表,來避免在雜湊衝突的情況下,散列表效能的急劇下降,並且能抵抗雜湊碰撞攻擊?

1、雜湊函式設計原則

  • 雜湊函式的設計不能太複雜。否則,消耗很多計算時間,間接影響散列表效能。
  • 雜湊函式生成的值儘可能隨機且均勻分佈==》避免或最小化雜湊衝突,即便衝突,也比較平均。

雜湊函式設計方法:資料分析法(從原始資料中擷取部分作為雜湊函式)、直接定址法、平方取中法、摺疊法、隨機數法等

2、裝載因子過大時的處理方法——動態擴容

針對散列表的擴容,資料搬移操作要複雜很多。因為散列表的大小變了,資料的儲存位置也變了,所以需要通過雜湊函式重新計算每個資料的儲存位置。
例如:
在這裡插入圖片描述

(1)資料插入

  • 最好時間複雜度(不需要擴容)——O(1)
  • 最壞情況下,散列表裝載因子過高,啟動擴容,需要重新申請記憶體空間,重新計算雜湊位置,並且搬移資料,所以時間複雜度——O(n)
  • 均攤時間複雜度——O(1)

(2)裝載因子閾值的設定

要權衡時間、空間複雜度:如果記憶體空間不緊張,對執行效率要求很高,可以降低負載因子的閾值;相反,如果記憶體空間緊張,對執行效率要求又不高,可以增加負載因子的值,甚至可以大於 1。

(3)擴容

為了解決一次性擴容耗時過多的情況,我們可以將擴容操作穿插在插在插入操作的過程中,分批完成。

具體過程:當裝載因子觸達閾值之後,我們只申請新空間,但並不將老的資料搬移到新散列表中。當有新資料要插入時,我們將新資料插入新散列表中,並且從老的散列表中拿出一個數據放入到新散列表。每次插入重複上述過程,多次插入之後,老的散列表中的資料就一點一點搬移到新的散列表中。
在這裡插入圖片描述

查詢操作:為了相容了新、老散列表中的資料,先從新散列表中查詢,如果沒有找到,再去老的散列表中查詢。

小結:避免一次性擴容耗時過多,以動態擴容方法插入資料,在任何情況下,插入一個數據的時間複雜度都是O(1)

四、選擇衝突解決方法

1、開放定址法

(1)優點

  • 易序列化;
  • 可有效利用CPU快取加速查詢速度(資料都儲存在陣列中)

(2)缺點

  • 刪除操作時需要特殊標記已經刪除的資料;
  • 衝突代價高於連結串列法==》裝載因子的上限不能太大==》比連結串列浪費空間

(3)適用場景

資料量比較小、裝載因子小的時候

2、連結串列法

(1)優點

  • 對記憶體的利用率比開放定址法要高(連結串列結點可在需要時再建立);
  • 對大的裝載因子的容忍度更高(只要雜湊函式的值隨機均勻,即使裝載因子很大,雖查詢效率會下降,但比順序查詢要快);

(2)缺點

  • 對於比較小的資料的儲存,比較消耗記憶體(儲存指標)
  • 連結串列的結點不連續,對CPU快取不友好,執行效率也有一定的影響
  • 注意:若儲存大資料(即儲存的物件的大小遠大於一個指標的大小),則連結串列中指標的記憶體消耗可以忽略。

(3)改造的連結串列法

連結串列 ==》其他高效的動態資料結構(eg:跳錶、紅黑樹)

(4)適用

比較適合儲存大物件、大資料量的散列表,而且,比起開放定址法,它更加靈活,支援更多的優化策略,比如用紅黑樹代替連結串列。

五、工業級散列表舉例分析

分析物件:Java 中 HashMap 。

1、初始大小

  • HashMap 預設初值為 16,可修改==》較少動態擴容次數,可提升HashMap的效能。

2、裝載因子和動態擴容

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

3、雜湊衝突解決方法

  • 採用連結串列法解決衝突。
  • 即使負載因子和雜湊函式設計得再合理,也免不了會出現拉鍊過長的情況,一旦出現拉鍊過長,則會嚴重影響 HashMap 的效能。
  • 優化(JDK1.8):
    • 當連結串列長度≥8,則連結串列轉換為紅黑樹==》快速增刪改查==》提升HashMap效能
    • 當連結串列長度≤8,則紅黑樹轉換為連結串列(資料量小時,紅黑樹要維護平衡,效能優勢不明顯)

4、雜湊函式

keywords:簡單、高效、分佈均勻

int hash(Object key){
	int h = key.hashCode();
	//capicity 表示散列表的大小}
	return (h^(h >>> 16)) & (capitity - 1);
}
// String 型別的物件的 hasCode() 如下:
public int hasCode(){
	int var1 = this.hash;
	if(var1 == 0 && this.value.length > 0){
		char[] var2 = this.value;
		for(int var3 = 0; var3 < this.value.length; ++var3){
			var1 = 31 * var1 + var2[var3];
		}
		this.hash = var1;
	}
	return var1;
}

六、小結

1、工業級散列表特性

  • 支援快速查詢、插入、刪除操作;
  • 記憶體佔用合理,不浪費過多的記憶體空間;
  • 效能穩定,在極端情況下,散列表的效能也不會退化到無法接受的情況。

2、設計思路

  • 設計一個合適的雜湊函式;
    • 雜湊後的值隨機且均勻分佈 ==》減少雜湊衝突
    • 不能太複雜,太複雜會太耗時間,影響散列表的效能。
  • 定義裝載因子閾值,並設計動態擴容策略;
  • 選擇合適的雜湊衝突方法。
    • 連結串列法 及其 改造的連結串列法(連結串列換成其他動態查詢資料結構,eg:紅黑樹)
    • 開放定址法:適用於小規模資料、裝載因子不高的散列表。

七、散列表 + 連結串列

1、LRU快取淘汰演算法

(1)連結串列實現 LRU 快取淘汰演算法過程

目標需要維護一個按照訪問時間從大到小有序排列的連結串列結構。
==》因為快取大小有限,當快取空間不夠,需要淘汰一個數據的時候,直接將連結串列頭部的結點刪除。

當要快取某個資料的時候,先在連結串列中查詢該資料。若沒找到,則直接將該資料放在連結串列的尾部;若找到,則將其移動到連結串列的尾部。
==》時間複雜度:O(n)

(2)快取(cache)系統主要包含的操作:

  • 往快取中新增一個數據;
  • 從快取中刪除一個數據;
  • 從快取中查詢一個數據;

==》三個操作均涉及“查詢操作”
==》單純使用連結串列:時間複雜度——O(n)
==》散列表 + 連結串列:時間複雜度——O(1)

(3)散列表+雙向連結串列 實現 LRU 快取淘汰演算法

在這裡插入圖片描述

雙向連結串列儲存資料:連結串列中的每個結點處理儲存資料(data)、前驅指標(prev)、後繼指標(next)之外,還新增了一個特殊的欄位 hnext,hnext 指標是為了將結點串在散列表的拉鍊中。==》每個結點會在兩條鏈中:雙向連結串列和散列表的拉鍊中。

資料查詢:散列表中查詢資料的時間複雜度接近 O(1),然後將其移動到雙向連結串列的尾部
資料刪除:O(1)時間複雜度找到要刪除的結點,之後利用雙向連結串列的前驅指標O(1)時間複雜度獲得前驅結點,刪除結點也只需O(1)的時間複雜度。
新增資料:首先查詢該資料是否已經在快取中。若在,則將其移動到雙向連結串列的尾部;若不在,則檢視快取中是否已滿。若滿,則將雙向連結串列頭部的結點刪除,然後再將資料放到連結串列的尾部;若未滿,則直接在連結串列尾部直接插入。

2、Redis 有序集合

有序集合中,每個成員物件有兩個重要的屬性:key ( 鍵值 ) 和 score ( 分值 )。

(1)Redis 有序集合的操作

  • 新增一個成員變數;
  • 按照鍵值來刪除一個成員變數;
  • 按照鍵值來查詢一個成員變數;
  • 按照分值區間查詢資料,eg:查詢積分在[100,555]之間的成員物件;
  • 按照分值從小到大排序成員變數;

(2)散列表+連結串列

Redis 有序集合可以按照 鍵值 或 分值 進行操作。

按照分值(或鍵值)將成員物件組織成跳錶的結構,然後按照鍵值(或分值)構建一個散列表
==》可按鍵值進行刪除、查詢一個成員物件
==》時間複雜度:O(1)

3、Java中LinkedHashMap容器

==》通過 “ 散列表 + 雙向連結串列 ” 結構實現

雖然散列表中資料是經過雜湊函式打亂之後無規律儲存的,但是 LinkedHashMap 容器支援按照順序遍歷資料、按照訪問順序遍歷資料。

// 按照順序遍歷資料
HashMap<Integer, Integer> m = new LinkedHashMap<>();
m.put(3, 11);
m.put(1, 12);
m.put(5, 23);
m.put(2, 22);

for(Map.Entry e : m.entrySet()){
	System.out.println(e.getKey());
}
// ==》列印的順序就是 3,1,5,2。


// 10 是初始大小,0.75 是裝載因子,true 是表示按照訪問時間排序
HashMap<Integer, Integer> m = new LinkedHashMap<>(10, 0.75f, true);
m.put(3, 11);
m.put(1, 12);
m.put(5, 23);
m.put(2, 22);

m.put(3, 26);
m.get(5);

for(Map.Entry e : m.entrySet()){
	System.out.println(e.getKey());
}
// ==》列印的順序就是 1, 2. 3, 5。

分析

  • 每次呼叫 put() 函式,往LinkedHashMap 中新增新資料時,都會先檢查新資料的鍵值是否已經存在,若存在則刪除,然後將新資料新增到連結串列的尾部;
  • 當呼叫 get() 函式,訪問資料時,將訪問的資料移動到連結串列的尾部。

資料的過程
在前四個操作完成之後,連結串列中的資料是下面這樣:
在這裡插入圖片描述
執行 m.put(3, 26); 之後
在這裡插入圖片描述
執行 m.get(5); 之後
在這裡插入圖片描述
==》本質:LRU快取淘汰策略的快取系統

八、碎碎念

主要就是兩種資料結構:連結串列和陣列。

陣列佔據隨機訪問的優勢,卻有需要連續記憶體的缺點。

連結串列具有可不連續儲存的優勢,但訪問查詢是線性的。

散列表和連結串列、跳錶的混合使用,是為了結合陣列和連結串列的優勢,規避它們的不足。

我們可以得出資料結構和演算法的重要性排行榜:連續空間 > 時間 > 碎片空間。