十、散列表(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緩存淘汰策略的緩存系統
八、碎碎念
主要就是兩種數據結構:鏈表和數組。
數組占據隨機訪問的優勢,卻有需要連續內存的缺點。
鏈表具有可不連續存儲的優勢,但訪問查找是線性的。
散列表和鏈表、跳表的混合使用,是為了結合數組和鏈表的優勢,規避它們的不足。
我們可以得出數據結構和算法的重要性排行榜:連續空間 > 時間 > 碎片空間。
總結
以上是生活随笔為你收集整理的十、散列表(Hash Table)的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 九、跳表(Skip List)
- 下一篇: 十一、哈希算法