20 | 散列表(下):为什么散列表和链表经常会一起使用?
有兩種數(shù)據(jù)結(jié)構(gòu),散列表和鏈表經(jīng)常會被放在一起使用。常見的使用方式有:
問題一:散列表和鏈表都是如何組合起來使用的?
問題二:為什么散列表和鏈表會經(jīng)常放到一塊使用,形影不離?
LRU 緩存淘汰算法
LRU緩存算法的核心點(diǎn):
- 需要維護(hù)一個按照訪問時間從大到小有序排列的鏈表結(jié)構(gòu)。
- 當(dāng)緩存空間不夠,需要淘數(shù)據(jù)的時候?qū)㈡湵眍^部的結(jié)點(diǎn)刪除。
- 當(dāng)要緩存某個數(shù)據(jù)的時候,先在鏈表中查找這個數(shù)據(jù)。如果沒有找到,則直接將數(shù)據(jù)放到鏈表的尾部;如果找到了,我們就把它移動到鏈表的尾部
因?yàn)椴檎覕?shù)據(jù)需要遍歷鏈表,所以單純用鏈表實(shí)現(xiàn)的 LRU 緩存淘汰算法的時間復(fù)雜很高,是 O(n)
一個緩存(cache)系統(tǒng)主要包含下面這幾個操作:
往緩存中添加一個數(shù)據(jù);
從緩存中刪除一個數(shù)據(jù);
在緩存中查找一個數(shù)據(jù);
這三個操作都要涉及“查找”操作,如果單純地采用鏈表的話,時間復(fù)雜度只能是 O(n)。如果我們將散列表和鏈表兩種數(shù)據(jù)結(jié)構(gòu)組合使用,可以將這三個操作的時間復(fù)雜度都降低到 O(1)。具體的結(jié)構(gòu)就是下面這個樣子:
使用雙向鏈表存儲數(shù)據(jù),鏈表中的每個結(jié)點(diǎn)處理存儲數(shù)據(jù)(data)、前驅(qū)指針(prev)、后繼指針(next)之外,還新增了一個特殊的字段 hnext。這個 hnext 有什么作用呢?
散列表是通過鏈表法解決散列沖突的,所以每個結(jié)點(diǎn)會在兩條鏈中。一個鏈?zhǔn)莿倓偽覀兲岬降?/span>雙向鏈表,另一個鏈?zhǔn)巧⒘斜碇械?strong>拉鏈。前驅(qū)和后繼指針是為了將結(jié)點(diǎn)串在雙向鏈表中,hnext 指針是為了將結(jié)點(diǎn)串在散列表的拉鏈中。
這種雙鏈表和散列表組合的結(jié)構(gòu)中緩存的三個操作,是如何做到時間復(fù)雜度是 O(1) 的?
這整個過程涉及的查找操作都可以通過散列表來完成。其他的操作,比如刪除頭結(jié)點(diǎn)、鏈表尾部插入數(shù)據(jù)等,都可以在 O(1) 的時間復(fù)雜度內(nèi)完成。所以,這三個操作的時間復(fù)雜度都是 O(1)。至此,我們就通過散列表和雙向鏈表的組合使用,實(shí)現(xiàn)了一個高效的、支持 LRU 緩存淘汰算法的緩存系統(tǒng)原型。
工業(yè)級實(shí)例
Redis 有序集合(zSet)
在有序集合中,每個成員對象有兩個重要的屬性,key(鍵值)和 score(分值)。通過 score 來查找數(shù)據(jù),還會通過 key 來查找數(shù)據(jù)。
比如用戶積分排行榜有這樣一個功能:可以通過用戶 ID 來查找積分信息,也可以通過積分區(qū)間來查找用戶 ID 或者姓名信息。這里包含 ID、姓名和積分的用戶信息,就是成員對象,用戶 ID 就是 key,積分就是 score。
如果我們細(xì)化一下 Redis 有序集合的操作,那就是下面這樣:
添加一個成員對象;
按照鍵值來刪除一個成員對象;
按照鍵值來查找一個成員對象;
按照分值區(qū)間查找數(shù)據(jù),比如查找積分在[100, 356]之間的成員對象;
按照分值從小到大排序成員變量;
如果按照分值將成員對象組織成跳表的結(jié)構(gòu),那按照鍵值來刪除、查詢成員對象就會很慢,解決方法與 LRU 緩存淘汰算法的解決方法類似。再按照鍵值構(gòu)建一個散列表,這樣按照 key 來刪除、查找一個成員對象的時間復(fù)雜度就變成了 O(1)。同時借助跳表結(jié)構(gòu),其他操作也非常高效。
實(shí)際上Redis 有序集合的操作還有另外一類,也就是查找成員對象的排名(Rank)或者根據(jù)排名區(qū)間查找成員對象。這個功能單純用剛剛講的這種組合結(jié)構(gòu)就無法高效實(shí)現(xiàn)了。
Java LinkedHashMap
HashMap 底層是通過散列表這種數(shù)據(jù)結(jié)構(gòu)實(shí)現(xiàn)的。而 LinkedHashMap 前面比 HashMap 多了一個“Linked”是不是說,LinkedHashMap 是一個通過鏈表法解決散列沖突的散列表呢?
實(shí)際上,LinkedHashMap 并沒有這么簡單,其中的“Linked”也并不僅僅代表它是通過鏈表法解決散列沖突的。看一段代碼。你覺得這段代碼會以什么樣的順序打印 3,1,5,2 這幾個 key ?原因是什么?
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。散列表中數(shù)據(jù)是經(jīng)過散列函數(shù)打亂之后無規(guī)律存儲的,這里是如何實(shí)現(xiàn)按照數(shù)據(jù)的插入順序來遍歷打印?LinkedHashMap 也是通過散列表和鏈表組合在一起實(shí)現(xiàn)的。它不僅支持按照插入順序遍歷數(shù)據(jù),還支持按照訪問順序來遍歷數(shù)據(jù)。看下面這段代碼:
// 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()); }打印的結(jié)果是 1,2,3,5。每次調(diào)用 put() 函數(shù),往 LinkedHashMap 中添加數(shù)據(jù)的時候,都會將數(shù)據(jù)添加到鏈表的尾部。第 8 行代碼中,再次將鍵值為 3 的數(shù)據(jù)放入到 LinkedHashMap 的時候,會先查找這個鍵值是否已經(jīng)有了,然后,再將已經(jīng)存在的 (3,11) 刪除,并且將新的 (3,26) 放到鏈表的尾部。當(dāng)?shù)?/span> 9 行代碼訪問到 key 為 5 的數(shù)據(jù)的時候,我們將被訪問到的數(shù)據(jù)移動到鏈表的尾部。最后的結(jié)果是這樣:
按照訪問時間排序的 LinkedHashMap 本身就是一個支持 LRU 緩存淘汰策略的緩存系統(tǒng)。LinkedHashMap 是通過雙向鏈表和散列表這兩種數(shù)據(jù)結(jié)構(gòu)組合實(shí)現(xiàn)的。LinkedHashMap 中的“Linked”實(shí)際上是指的是雙向鏈表,并非指用鏈表法解決散列沖突 。
解答開篇
為什么散列表和鏈表經(jīng)常一塊使用?
散列表這種數(shù)據(jù)結(jié)構(gòu)雖然支持高效的插入、刪除、查找操作,但是散列表中數(shù)據(jù)都是無規(guī)律存儲的,無法支持按照某種順序快速遍歷數(shù)據(jù)。如果希望按照順序遍歷散列表中數(shù)據(jù),那需要將散列表中的數(shù)據(jù)拷貝到數(shù)組中然后排序再遍歷。而散列表是動態(tài)數(shù)據(jù)結(jié)構(gòu),不停地有數(shù)據(jù)的插入、刪除,每當(dāng)按順序遍歷散列表中的數(shù)據(jù)的時候都需要先排序效率很低。為了解決這個問題,將散列表和鏈表(或者跳表)結(jié)合在一起使用 。
思考題:
假設(shè)獵聘網(wǎng)有 10 萬名獵頭,每個獵頭都可以通過做任務(wù)(比如發(fā)布職位)來積累積分,然后通過積分來下載簡歷。假設(shè)你是獵聘網(wǎng)的一名工程師,如何在內(nèi)存中存儲這 10 萬個獵頭 ID 和積分信息,讓它能夠支持這樣幾個操作:
根據(jù)獵頭的 ID 快速查找、刪除、更新這個獵頭的積分信息;
查找積分在某個區(qū)間的獵頭 ID 列表;
查找按照積分從小到大排名在第 x 位到第 y 位之間的獵頭 ID 列表。
?
總結(jié)
以上是生活随笔為你收集整理的20 | 散列表(下):为什么散列表和链表经常会一起使用?的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: windows中如何显示/隐藏桌面图标
- 下一篇: JQuery.autocomplete扩