1. 程式人生 > 其它 >資料結構與演算法_17 _ 跳錶:為什麼Redis一定要用跳錶來實現有序集合?

資料結構與演算法_17 _ 跳錶:為什麼Redis一定要用跳錶來實現有序集合?

上兩節我們講了二分查詢演算法。當時我講到,因為二分查詢底層依賴的是陣列隨機訪問的特性,所以只能用陣列來實現。如果資料儲存在連結串列中,就真的沒法用二分查詢演算法了嗎?

實際上,我們只需要對連結串列稍加改造,就可以支援類似“二分”的查詢演算法。我們把改造之後的資料結構叫做跳錶(Skip list),也就是今天要講的內容。

跳錶這種資料結構對你來說,可能會比較陌生,因為一般的資料結構和演算法書籍裡都不怎麼會講。但是它確實是一種各方面效能都比較優秀的動態資料結構,可以支援快速地插入、刪除、查詢操作,寫起來也不復雜,甚至可以替代紅黑樹(Red-black tree)。

Redis中的有序集合(Sorted Set)就是用跳錶來實現的。如果你有一定基礎,應該知道紅黑樹也可以實現快速地插入、刪除和查詢操作。那Redis為什麼會選擇用跳錶來實現有序集合呢?

為什麼不用紅黑樹呢?學完今天的內容,你就知道答案了。

如何理解“跳錶”?

對於一個單鏈表來講,即便連結串列中儲存的資料是有序的,如果我們要想在其中查詢某個資料,也只能從頭到尾遍歷連結串列。這樣查詢效率就會很低,時間複雜度會很高,是O(n)。

那怎麼來提高查詢效率呢?如果像圖中那樣,對連結串列建立一級“索引”,查詢起來是不是就會更快一些呢?每兩個結點提取一個結點到上一級,我們把抽出來的那一級叫做索引索引層。你可以看我畫的圖。圖中的down表示down指標,指向下一級結點。

如果我們現在要查詢某個結點,比如16。我們可以先在索引層遍歷,當遍歷到索引層中值為13的結點時,我們發現下一個結點是17,那要查詢的結點16肯定就在這兩個結點之間。然後我們通過索引層結點的down指標,下降到原始連結串列這一層,繼續遍歷。這個時候,我們只需要再遍歷2個結點,就可以找到值等於16的這個結點了。這樣,原來如果要查詢16,需要遍歷10個結點,現在只需要遍歷7個結點。

從這個例子裡,我們看出,加來一層索引之後,查詢一個結點需要遍歷的結點個數減少了,也就是說查詢效率提高了。那如果我們再加一級索引呢?效率會不會提升更多呢?

跟前面建立第一級索引的方式相似,我們在第一級索引的基礎之上,每兩個結點就抽出一個結點到第二級索引。現在我們再來查詢16,只需要遍歷6個結點了,需要遍歷的結點數量又減少了。

我舉的例子資料量不大,所以即便加了兩級索引,查詢效率的提升也並不明顯。為了讓你能真切地感受索引提升查詢效率。我畫了一個包含64個結點的連結串列,按照前面講的這種思路,建立了五級索引。

從圖中我們可以看出,原來沒有索引的時候,查詢62需要遍歷62個結點,現在只需要遍歷11個結點,速度是不是提高了很多?所以,當連結串列的長度n比較大時,比如1000、10000的時候,在構建索引之後,查詢效率的提升就會非常明顯。

前面講的這種連結串列索引的結構,就是跳錶。我通過例子給你展示了跳錶是如何減少查詢次數的,現在你應該比較清晰地知道,跳錶確實是可以提高查詢效率的。接下來,我會定量地分析一下,用跳錶查詢到底有多快。

用跳錶查詢到底有多快?

前面我講過,演算法的執行效率可以通過時間複雜度來度量,這裡依舊可以用。我們知道,在一個單鏈表中查詢某個資料的時間複雜度是O(n)。那在一個具有多級索引的跳錶中,查詢某個資料的時間複雜度是多少呢?

這個時間複雜度的分析方法比較難想到。我把問題分解一下,先來看這樣一個問題,如果連結串列裡有n個結點,會有多少級索引呢?

按照我們剛才講的,每兩個結點會抽出一個結點作為上一級索引的結點,那第一級索引的結點個數大約就是n/2,第二級索引的結點個數大約就是n/4,第三級索引的結點個數大約就是n/8,依次類推,也就是說,第k級索引的結點個數是第k-1級索引的結點個數的1/2,第k索引結點的個數就是n/(2k)。

假設索引有h級,最高階的索引有2個結點。通過上面的公式,我們可以得到n/(2h)=2,從而求得h=log2n-1。如果包含原始連結串列這一層,整個跳錶的高度就是log2n。我們在跳錶中查詢某個資料的時候,如果每一層都要遍歷m個結點,那在跳錶中查詢一個數據的時間複雜度就是O(m*logn)。

那這個m的值是多少呢?按照前面這種索引結構,我們每一級索引都最多隻需要遍歷3個結點,也就是說m=3,為什麼是3呢?我來解釋一下。

假設我們要查詢的資料是x,在第k級索引中,我們遍歷到y結點之後,發現x大於y,小於後面的結點z,所以我們通過y的down指標,從第k級索引下降到第k-1級索引。在第k-1級索引中,y和z之間只有3個結點(包含y和z),所以,我們在K-1級索引中最多隻需要遍歷3個結點,依次類推,每一級索引都最多隻需要遍歷3個結點。

通過上面的分析,我們得到m=3,所以在跳錶中查詢任意資料的時間複雜度就是O(logn)。這個查詢的時間複雜度跟二分查詢是一樣的。換句話說,我們其實是基於單鏈表實現了二分查詢,是不是很神奇?不過,天下沒有免費的午餐,這種查詢效率的提升,前提是建立了很多級索引,也就是我們在第6節講過的空間換時間的設計思路。

跳錶是不是很浪費記憶體?

比起單純的單鏈表,跳錶需要儲存多級索引,肯定要消耗更多的儲存空間。那到底需要消耗多少額外的儲存空間呢?我們來分析一下跳錶的空間複雜度。

跳錶的空間複雜度分析並不難,我在前面說了,假設原始連結串列大小為n,那第一級索引大約有n/2個結點,第二級索引大約有n/4個結點,以此類推,每上升一級就減少一半,直到剩下2個結點。如果我們把每層索引的結點數寫出來,就是一個等比數列。

這幾級索引的結點總和就是n/2+n/4+n/8…+8+4+2=n-2。所以,跳錶的空間複雜度是O(n)。也就是說,如果將包含n個結點的單鏈表構造成跳錶,我們需要額外再用接近n個結點的儲存空間。那我們有沒有辦法降低索引佔用的記憶體空間呢?

我們前面都是每兩個結點抽一個結點到上級索引,如果我們每三個結點或五個結點,抽一個結點到上級索引,是不是就不用那麼多索引結點了呢?我畫了一個每三個結點抽一個的示意圖,你可以看下。

從圖中可以看出,第一級索引需要大約n/3個結點,第二級索引需要大約n/9個結點。每往上一級,索引結點個數都除以3。為了方便計算,我們假設最高一級的索引結點個數是1。我們把每級索引的結點個數都寫下來,也是一個等比數列。

通過等比數列求和公式,總的索引結點大約就是n/3+n/9+n/27+...+9+3+1=n/2。儘管空間複雜度還是O(n),但比上面的每兩個結點抽一個結點的索引構建方法,要減少了一半的索引結點儲存空間。

實際上,在軟體開發中,我們不必太在意索引佔用的額外空間。在講資料結構和演算法時,我們習慣性地把要處理的資料看成整數,但是在實際的軟體開發中,原始連結串列中儲存的有可能是很大的物件,而索引結點只需要儲存關鍵值和幾個指標,並不需要儲存物件,所以當物件比索引結點大很多時,那索引佔用的額外空間就可以忽略了。

高效的動態插入和刪除

跳錶長什麼樣子我想你應該已經很清楚了,它的查詢操作我們剛才也講過了。實際上,跳錶這個動態資料結構,不僅支援查詢操作,還支援動態的插入、刪除操作,而且插入、刪除操作的時間複雜度也是O(logn)。

我們現在來看下, 如何在跳錶中插入一個數據,以及它是如何做到O(logn)的時間複雜度的?

我們知道,在單鏈表中,一旦定位好要插入的位置,插入結點的時間複雜度是很低的,就是O(1)。但是,這裡為了保證原始連結串列中資料的有序性,我們需要先找到要插入的位置,這個查詢操作就會比較耗時。

對於純粹的單鏈表,需要遍歷每個結點,來找到插入的位置。但是,對於跳錶來說,我們講過查詢某個結點的時間複雜度是O(logn),所以這裡查詢某個資料應該插入的位置,方法也是類似的,時間複雜度也是O(logn)。我畫了一張圖,你可以很清晰地看到插入的過程。

好了,我們再來看刪除操作。

如果這個結點在索引中也有出現,我們除了要刪除原始連結串列中的結點,還要刪除索引中的。因為單鏈表中的刪除操作需要拿到要刪除結點的前驅結點,然後通過指標操作完成刪除。所以在查詢要刪除的結點的時候,一定要獲取前驅結點。當然,如果我們用的是雙向連結串列,就不需要考慮這個問題了。

跳錶索引動態更新

當我們不停地往跳錶中插入資料時,如果我們不更新索引,就有可能出現某2個索引結點之間資料非常多的情況。極端情況下,跳錶還會退化成單鏈表。

作為一種動態資料結構,我們需要某種手段來維護索引與原始連結串列大小之間的平衡,也就是說,如果連結串列中結點多了,索引結點就相應地增加一些,避免複雜度退化,以及查詢、插入、刪除操作效能下降。

如果你瞭解紅黑樹、AVL樹這樣平衡二叉樹,你就知道它們是通過左右旋的方式保持左右子樹的大小平衡(如果不瞭解也沒關係,我們後面會講),而跳錶是通過隨機函式來維護前面提到的“平衡性”。

當我們往跳錶中插入資料的時候,我們可以選擇同時將這個資料插入到部分索引層中。如何選擇加入哪些索引層呢?

我們通過一個隨機函式,來決定將這個結點插入到哪幾級索引中,比如隨機函式生成了值K,那我們就將這個結點新增到第一級到第K級這K級索引中。

隨機函式的選擇很有講究,從概率上來講,能夠保證跳錶的索引大小和資料大小平衡性,不至於效能過度退化。至於隨機函式的選擇,我就不展開講解了。如果你感興趣的話,可以看看我在GitHub上的程式碼或者Redis中關於有序集合的跳錶實現。

跳錶的實現還是稍微有點複雜的,我將Java實現的程式碼放到了GitHub中,你可以根據我剛剛的講解,對照著程式碼仔細思考一下。你不用死記硬背程式碼,跳錶的實現並不是我們這節的重點。

解答開篇

今天的內容到此就講完了。現在,我來講解一下開篇的思考題:為什麼Redis要用跳錶來實現有序集合,而不是紅黑樹?

Redis中的有序集合是通過跳錶來實現的,嚴格點講,其實還用到了散列表。不過散列表我們後面才會講到,所以我們現在暫且忽略這部分。如果你去檢視Redis的開發手冊,就會發現,Redis中的有序集合支援的核心操作主要有下面這幾個:

  • 插入一個數據;

  • 刪除一個數據;

  • 查詢一個數據;

  • 按照區間查詢資料(比如查詢值在[100, 356]之間的資料);

  • 迭代輸出有序序列。

其中,插入、刪除、查詢以及迭代輸出有序序列這幾個操作,紅黑樹也可以完成,時間複雜度跟跳錶是一樣的。但是,按照區間來查詢資料這個操作,紅黑樹的效率沒有跳錶高。

對於按照區間查詢資料這個操作,跳錶可以做到O(logn)的時間複雜度定位區間的起點,然後在原始連結串列中順序往後遍歷就可以了。這樣做非常高效。

當然,Redis之所以用跳錶來實現有序集合,還有其他原因,比如,跳錶更容易程式碼實現。雖然跳錶的實現也不簡單,但比起紅黑樹來說還是好懂、好寫多了,而簡單就意味著可讀性好,不容易出錯。還有,跳錶更加靈活,它可以通過改變索引構建策略,有效平衡執行效率和記憶體消耗。

不過,跳錶也不能完全替代紅黑樹。因為紅黑樹比跳錶的出現要早一些,很多程式語言中的Map型別都是通過紅黑樹來實現的。我們做業務開發的時候,直接拿來用就可以了,不用費勁自己去實現一個紅黑樹,但是跳錶並沒有一個現成的實現,所以在開發中,如果你想使用跳錶,必須要自己實現。

內容小結

今天我們講了跳錶這種資料結構。跳錶使用空間換時間的設計思路,通過構建多級索引來提高查詢的效率,實現了基於連結串列的“二分查詢”。跳錶是一種動態資料結構,支援快速地插入、刪除、查詢操作,時間複雜度都是O(logn)。

跳錶的空間複雜度是O(n)。不過,跳錶的實現非常靈活,可以通過改變索引構建策略,有效平衡執行效率和記憶體消耗。雖然跳錶的程式碼實現並不簡單,但是作為一種動態資料結構,比起紅黑樹來說,實現要簡單多了。所以很多時候,我們為了程式碼的簡單、易讀,比起紅黑樹,我們更傾向用跳錶。

課後思考

在今天的內容中,對於跳錶的時間複雜度分析,我分析了每兩個結點提取一個結點作為索引的時間複雜度。如果每三個或者五個結點提取一個結點作為上級索引,對應的在跳錶中查詢資料的時間複雜度是多少呢?

歡迎留言和我分享,我會第一時間給你反饋。


我已將本節內容相關的詳細程式碼更新到GitHub,戳此即可檢視。