数据结构☞散列表
散列表(Hash Table)
散列表的英文叫“Hash Table”,我們平時也叫它“哈希表”或者“Hash 表”。
散列表用的是數組支持按照下標隨機訪問數據的特性,所以散列表其實就是數組的一種擴展,由數組演化而來。可以說,如果沒有數組,就沒有散列表。
- key:鍵或者關鍵字。
- 散列函數(或“Hash 函數”“哈希函數”):把key值轉化為數組下標的映射方法。
- 散列函數計算得到的值就叫作散列值(或“Hash 值”“哈希值”)。
散列函數
散列函數設計的基本要求:
- 散列函數計算得到的散列值是一個非負整數;
- 如果 key1 = key2,那 hash(key1) == hash(key2);
- 如果 key1 ≠ key2,那 hash(key1) ≠ hash(key2)。(往往這個條件很難辦到,key不同可能出現相同的散列值,于是出現散列沖突)
解決散列沖突
1. 開放尋址法
(1)線性探測(Linear Probing)(最好O(1);最壞情況下的時間復雜度為 O(n))
- 插入:如果某個數據經過散列函數散列之后,存儲位置已經被占用了,我們就從當前位置開始,依次往后查找,看是否有空閑位置,直到找到為止。
- 查找:過程和插入一樣,找到對應數組下標后,對比x與數組中存儲的值是否相等,若不等則依次往后查找…
- 刪除:刪除的元素,特殊標記為 deleted。當線性探測查找的時候,遇到標記為 deleted 的空間,并不是停下來,而是繼續往下探測。
(2)二次探測(Quadratic probing)
和線性探測(Linear Probing)類似,只不過每次步長為沖突下標的平方(i^2)。
(3)雙重散列(Double hashing)
所謂雙重散列,意思就是不僅要使用一個散列函數。我們使用一組散列函數 hash1(key),hash2(key),hash3(key)……我們先用第一個散列函數,如果計算得到的存儲位置已經被占用,再用第二個散列函數,依次類推,直到找到空閑的存儲位置。
2. 鏈表法(HashMap和Hashtable就是這樣存儲的嘛)
散列表中,每個“桶(bucket)”或者“槽(slot)”會對應一條鏈表,所有散列值相同的元素我們都放到相同槽位對應的鏈表中:
裝載因子(現在知道為什么HashMap和Hashtable有擴容因子0.75了吧!)
當散列表中數組空閑位置不多的時候,散列沖突的概率就會大大提高。
裝載因子的計算公式是:散列表的裝載因子=填入數組中的元素個數/數組長度裝載因子越大,說明空閑位置越少,沖突越多,散列表的性能會下降。
思考題
1. 假設我們有 10 萬條 URL 訪問日志,如何按照訪問次數給 URL 排序?
遍歷 10 萬條數據,以 URL 為 key,訪問次數為 value,存入散列表,同時記錄下訪問次數的最大值 K,時間復雜度 O(N)。
如果 K 不是很大,可以使用桶排序,時間復雜度 O(N)。如果 K 非常大(比如大于 10 萬),就使用快速排序,復雜度 O(NlogN)。
2. 有兩個字符串數組,每個數組大約有 10 萬條字符串,如何快速找出兩個數組中相同的字符串?
以第一個字符串數組構建散列表,key 為字符串,value 為出現次數。再遍歷第二個字符串數組,以字符串為 key 在散列表中查找,如果 value 大于零,說明存在相同字符串。時間復雜度 O(N)。
(其實就用 Set思想就可以了, 第一個String數組 放到 set中(其實就是個散列表, key 是 String, value 無所謂) 然后 拿散列表和 第二個String數組中元素contains() 有就存在 沒有就不存在)
設計散列表
一、散列函數怎么設計?
數據分析法:
處理手機號碼,因為手機號碼前幾位重復的可能性很大,但是后面幾位就比較隨機,(因為隨機,所以會分布比較均勻)我們可以取手機號的后四位作為散列值。這種散列函數的設計方法,我們一般叫做“數據分析法”。
二、裝載因子過大了怎么辦?
為避免低效擴容:
將一次性擴容的代價,均攤到多次插入操作中:任何情況下,插入一個數據的時間復雜度都是 O(1)。
如何選擇沖突解決方法?
Java 中 LinkedHashMap 就采用了鏈表法解決沖突,ThreadLocalMap 是通過線性探測的開放尋址法來解決沖突。
1. 開放尋址法
當數據量比較小、裝載因子小的時候,適合采用開放尋址法。 這也是 Java 中的ThreadLocalMap使用開放尋址法解決散列沖突的原因。
2. 鏈表法
基于鏈表的散列沖突處理方法比較適合存儲大對象、大數據量的散列表,而且,比起開放尋址法,它更加靈活,支持更多的優化策略,比如用紅黑樹代替鏈表。
HashMap
1. 初始大小
HashMap 默認的初始大小是 16,當然這個默認值是可以設置的,如果事先知道大概的數據量有多大,可以通過修改默認初始大小,減少動態擴容的次數,這樣會大大提高 HashMap 的性能。
2. 裝載因子和動態擴容
最大裝載因子默認是 0.75,當 HashMap 中元素個數超過 0.75*capacity(capacity 表示散列表的容量)的時候,就會啟動擴容,每次擴容都會擴容為原來的兩倍大小。
3. 散列沖突解決方法
HashMap 底層采用鏈表法來解決沖突。即使負載因子和散列函數設計得再合理,也免不了會出現拉鏈過長的情況,一旦出現拉鏈過長,則會嚴重影響 HashMap 的性能。于是,在 JDK1.8 版本中,為了對 HashMap 做進一步優化,我們引入了紅黑樹。而當鏈表長度太長(默認超過 8)時,鏈表就轉換為紅黑樹。我們可以利用紅黑樹快速增刪改查的特點,提高 HashMap 的性能。**當紅黑樹結點個數少于 6個的時候,又會將紅黑樹轉化為鏈表。**因為在數據量較小的情況下,紅黑樹要維護平衡,比起鏈表來,性能上的優勢并不明顯。
4. 散列函數
散列函數的設計并不復雜,追求的是簡單高效、分布均勻。
散列表和鏈表的配合使用
LRU 緩存淘汰算法
一個緩存(cache)系統主要包含下面這幾個操作:
- 往緩存中添加一個數據;
- 從緩存中刪除一個數據;
- 在緩存中查找一個數據。
其實很簡單:
因為鏈表的查詢效率低,而插入和刪除的效率高。
于是:利用數組(散列表)的查詢效率,用散列表的散列函數給雙向鏈表做一個“索引”:
前驅和后繼指針是為了將結點串在雙向鏈表中,hnext 指針是為了將結點串在散列表的拉鏈中。
1. 查找一個數據
散列表中查找數據的時間復雜度接近 O(1),所以通過散列表,我們可以很快地在緩存中找到一個數據。當找到數據之后,我們還需要將它移動到雙向鏈表的尾部。
2. 刪除一個數據
需要找到數據所在的結點,然后將結點刪除。借助散列表,我們可以在 O(1) 時間復雜度里找到要刪除的結點。因為我們的鏈表是雙向鏈表,雙向鏈表可以通過前驅指針 O(1) 時間復雜度獲取前驅結點,所以在雙向鏈表中,刪除結點只需要 O(1) 的時間復雜度。
3.添加一個數據(鏈表不允許有重復key!!!)
需要先看這個數據是否已經在緩存中。(用散列表去查找)如果已經在其中,需要將其移動到雙向鏈表的尾部;如果不在其中,還要看緩存有沒有滿。如果滿了,則將雙向鏈表頭部的結點刪除,然后再將數據放到鏈表的尾部;如果沒有滿,就直接將數據放到鏈表的尾部。這整個過程涉及的查找操作都可以通過散列表來完成。
其他的操作,比如刪除頭結點、鏈表尾部插入數據等,都可以在 O(1) 的時間復雜度內完成。所以,這三個操作的時間復雜度都是 O(1)。至此,我們就通過散列表和雙向鏈表的組合使用,實現了一個高效的、支持 LRU 緩存淘汰算法的緩存系統原型。
Redis 有序集合
在有序集合中,每個成員對象有兩個重要的屬性,key(鍵值)和 score(分值)。我們不僅會通過 score 來查找數據,還會通過 key 來查找數據。
Redis 有序集合的操作,那就是下面這樣:
- 添加一個成員對象;
- 按照鍵值來刪除一個成員對象;
- 按照鍵值來查找一個成員對象;
- 按照分值區間查找數據,比如查找積分在[100,356]之間的成員對象;
- 按照分值從小到大排序成員變量;
散列表配合跳表使用:
1. 利用跳表:按照分值(區間)查找對象(那么鏈表必須要按分值排序)
2. 再按照鍵值構建一個散列表:按照 key 來刪除、查找一個成員對象(時間復雜度O(1))
LinkedHashMap(有序)
但還是不能重復,鏈表就是不能重復!
LinkedHashMap 是通過雙向鏈表和散列表這兩種數據結構組合實現的。
和上面的LRU 緩存淘汰算法實現原理一樣!(只是,LinkedHashMap支持擴容,和HashMap容量、擴容什么的都一樣!)
// 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 }先用散列表查找鍵值是否已存在:
若存在,則刪除存在元素,將新對象插入到雙向鏈表的尾部,并且串在對應散列表的拉鏈中;
若不存在,則直接將新對象插入到雙向鏈表的尾部,并且串在對應散列表的拉鏈中。
利用散列表訪問到元素后,將元素變為雙向鏈表尾結點(但此過程元素仍然串在對應散列表的拉鏈中)
為什么散列表和鏈表經常會一起使用?
就是為了散列表存儲下的數據能夠有序遍歷!!!
因為查找、插入、刪除這些散列表自己就可以做!!!
思考題
1.如果把LinkedHashMap雙向鏈表改成單鏈表,還能否正常工作呢?為什么呢?
不能,因為在雙向鏈表中刪除元素O(1),而單鏈表中這就變成O(n)。
2.假設獵聘網有 10 萬名獵頭,每個獵頭都可以通過做任務(比如發布職位)來積累積分,然后通過積分來下載簡歷。假設你是獵聘網的一名工程師,如何在內存中存儲這 10 萬個獵頭 ID 和積分信息,讓它能夠支持這樣幾個操作:
- 根據獵頭的 ID 快速查找、刪除、更新這個獵頭的積分信息;
- 查找積分在某個區間的獵頭 ID 列表;
- 查找按照積分從小到大排名在第 x 位到第 y 位之間的獵頭 ID 列表。
答:以積分排序構建一個跳表,再以獵頭 ID 構建一個散列表。
1)ID 在散列表中所以可以 O(1) 查找到這個獵頭;
2)積分以跳表存儲,跳表支持區間查詢;
3)這點根據目前學習的知識暫時無法實現。
3.Word 文檔中單詞拼寫檢查功能是如何實現的?
常用的英文單詞有 20 萬個左右,假設單詞的平均長度是 10 個字母,平均一個單詞占用 10 個字節的內存空間,那 20 萬英文單詞大約占 2MB 的存儲空間,就算放大 10 倍也就是 20MB。對于現在的計算機來說,這個大小完全可以放在內存里面。
所以我們可以用散列表來存儲整個英文單詞詞典。當用戶輸入某個英文單詞時,我們拿用戶輸入的單詞去散列表中查找。 如果查到,則說明拼寫正確;如果沒有查到,則說明拼寫可能有誤,給予提示。借助散列表這種數據結構,我們就可以輕松實現快速判斷是否存在拼寫錯誤。
我知道你頭楞了,來總結一下!
- 需要以A屬性區間查找對象 ===> 跳表:以A屬性將對象排序構造跳表
- 需要以B屬性快速查找(刪除 刪除前要先查找)===> 散列表:以B屬性構建散列表
- 需要有序遍歷整體對象 ===> 雙向鏈表:按照插入(訪問)順序維護一個雙向鏈表
萬變不離其宗:其實這些都是鏈表和數組的結合使用,數組有利于隨機訪問,鏈表有利于插入刪除。各種衍生方式的出現都是按照情況將兩者結合,揚其所長、避其所短。
總結
- 上一篇: C#生成GS1码制二维码
- 下一篇: 音阶频率对照表_音符频率对应表