跳躍表簡介
我們先拋開redis,單獨了解下跳越表
skiplist本質上也是一種查找結構,用于解決算法中的查找問題(Searching),即根據給定的key,快速查到它所在的位置(或者對應的value)。
我們在《Redis內部數據結構詳解》系列的第一篇中介紹dict的時候,曾經討論過:一般查找問題的解法分為兩個大類:一個是基于各種平衡樹,一個是基于哈希表。但skiplist卻比較特殊,它沒法歸屬到這兩大類里面。
這種數據結構是由William Pugh發明的,最早出現于他在1990年發表的論文《Skip Lists: A Probabilistic Alternative to Balanced Trees》。對細節感興趣的同學可以下載論文原文來閱讀。
skiplist數據結構簡介
skiplist,顧名思義,首先它是一個list。實際上,它是在有序鏈表的基礎上發展起來的。
我們先來看一個有序鏈表,如下圖(最左側的灰色節點表示一個空的頭結點):
在這樣一個鏈表中,如果我們要查找某個數據,那么需要從頭開始逐個進行比較,直到找到包含數據的那個節點,或者找到第一個比給定數據大的節點為止(沒找到)。也就是說,時間復雜度為O(n)。同樣,當我們要插入新數據的時候,也要經歷同樣的查找過程,從而確定插入位置。
假如我們每相鄰兩個節點增加一個指針,讓指針指向下下個節點,如下圖:
這樣所有新增加的指針連成了一個新的鏈表,但它包含的節點個數只有原來的一半(上圖中是7, 19, 26)。現在當我們想查找數據的時候,可以先沿著這個新鏈表進行查找。當碰到比待查數據大的節點時,再回到原來的鏈表中進行查找。比如,我們想查找23,查找的路徑是沿著下圖中標紅的指針所指向的方向進行的:
- 23首先和7比較,再和19比較,比它們都大,繼續向后比較。
- 但23和26比較的時候,比26要小,因此回到下面的鏈表(原鏈表),與22比較。
- 23比22要大,沿下面的指針繼續向后和26比較。23比26小,說明待查數據23在原鏈表中不存在,而且它的插入位置應該在22和26之間。
在這個查找過程中,由于新增加的指針,我們不再需要與鏈表中每個節點逐個進行比較了。需要比較的節點數大概只有原來的一半。
利用同樣的方式,我們可以在上層新產生的鏈表上,繼續為每相鄰的兩個節點增加一個指針,從而產生第三層鏈表。如下圖:
在這個新的三層鏈表結構上,如果我們還是查找23,那么沿著最上層鏈表首先要比較的是19,發現23比19大,接下來我們就知道只需要到19的后面去繼續查找,從而一下子跳過了19前面的所有節點。可以想象,當鏈表足夠長的時候,這種多層鏈表的查找方式能讓我們跳過很多下層節點,大大加快查找的速度。
skiplist正是受這種多層鏈表的想法的啟發而設計出來的。實際上,按照上面生成鏈表的方式,上面每一層鏈表的節點個數,是下面一層的節點個數的一半,這樣查找過程就非常類似于一個二分查找,使得查找的時間復雜度可以降低到O(log n)。但是,這種方法在插入數據的時候有很大的問題。新插入一個節點之后,就會打亂上下相鄰兩層鏈表上節點個數嚴格的2:1的對應關系。如果要維持這種對應關系,就必須把新插入的節點后面的所有節點(也包括新插入的節點)重新進行調整,這會讓時間復雜度重新蛻化成O(n)。刪除數據也有同樣的問題。
skiplist為了避免這一問題,它不要求上下相鄰兩層鏈表之間的節點個數有嚴格的對應關系,而是為每個節點隨機出一個層數(level)。比如,一個節點隨機出的層數是3,那么就把它鏈入到第1層到第3層這三層鏈表中。為了表達清楚,下圖展示了如何通過一步步的插入操作從而形成一個skiplist的過程(點擊看大圖):
從上面skiplist的創建和插入過程可以看出,每一個節點的層數(level)是隨機出來的,而且新插入一個節點不會影響其它節點的層數。因此,插入操作只需要修改插入節點前后的指針,而不需要對很多節點都進行調整。這就降低了插入操作的復雜度。實際上,這是skiplist的一個很重要的特性,這讓它在插入性能上明顯優于平衡樹的方案。這在后面我們還會提到。
根據上圖中的skiplist結構,我們很容易理解這種數據結構的名字的由來。skiplist,翻譯成中文,可以翻譯成“跳表”或“跳躍表”,指的就是除了最下面第1層鏈表之外,它會產生若干層稀疏的鏈表,這些鏈表里面的指針故意跳過了一些節點(而且越高層的鏈表跳過的節點越多)。這就使得我們在查找數據的時候能夠先在高層的鏈表中進行查找,然后逐層降低,最終降到第1層鏈表來精確地確定數據位置。在這個過程中,我們跳過了一些節點,從而也就加快了查找速度。
剛剛創建的這個skiplist總共包含4層鏈表,現在假設我們在它里面依然查找23,下圖給出了查找路徑:
需要注意的是,前面演示的各個節點的插入過程,實際上在插入之前也要先經歷一個類似的查找過程,在確定插入位置后,再完成插入操作。
至此,skiplist的查找和插入操作,我們已經很清楚了。而刪除操作與插入操作類似,我們也很容易想象出來。這些操作我們也應該能很容易地用代碼實現出來。
當然,實際應用中的skiplist每個節點應該包含key和value兩部分。前面的描述中我們沒有具體區分key和value,但實際上列表中是按照key進行排序的,查找過程也是根據key在比較。
但是,如果你是第一次接觸skiplist,那么一定會產生一個疑問:節點插入時隨機出一個層數,僅僅依靠這樣一個簡單的隨機數操作而構建出來的多層鏈表結構,能保證它有一個良好的查找性能嗎?為了回答這個疑問,我們需要分析skiplist的統計性能。
在分析之前,我們還需要著重指出的是,執行插入操作時計算隨機數的過程,是一個很關鍵的過程,它對skiplist的統計特性有著很重要的影響。這并不是一個普通的服從均勻分布的隨機數,它的計算過程如下:
首先,每個節點肯定都有第1層指針(每個節點都在第1層鏈表里)。
如果一個節點有第i層(i>=1)指針(即節點已經在第1層到第i層鏈表中),那么它有第(i+1)層指針的概率為p。
節點最大的層數不允許超過一個最大值,記為MaxLevel。
這個計算隨機層數的偽碼如下所示:
randomLevel()
level := 1
// random()返回一個[0...1)的隨機數
while random() < p and level < MaxLevel do
level := level + 1
return level
randomLevel()的偽碼中包含兩個參數,一個是p,一個是MaxLevel。在Redis的skiplist實現中,這兩個參數的取值為:
p = 1/4
MaxLevel = 32
skiplist的算法性能分析
在這一部分,我們來簡單分析一下skiplist的時間復雜度和空間復雜度,以便對于skiplist的性能有一個直觀的了解。如果你不是特別偏執于算法的性能分析,那么可以暫時跳過這一小節的內容。
我們先來計算一下每個節點所包含的平均指針數目(概率期望)。節點包含的指針數目,相當于這個算法在空間上的額外開銷(overhead),可以用來度量空間復雜度。
根據前面randomLevel()的偽碼,我們很容易看出,產生越高的節點層數,概率越低。定量的分析如下:
節點層數至少為1。而大于1的節點層數,滿足一個概率分布。
節點層數恰好等于1的概率為1-p。
節點層數大于等于2的概率為p,而節點層數恰好等于2的概率為p(1-p)。
節點層數大于等于3的概率為p2,而節點層數恰好等于3的概率為p2(1-p)。
節點層數大于等于4的概率為p3,而節點層數恰好等于4的概率為p3(1-p)。
......
因此,一個節點的平均層數(也即包含的平均指針數目),計算如下:
現在很容易計算出:當p=1/2時,每個節點所包含的平均指針數目為2;
當p=1/4時,每個節點所包含的平均指針數目為1.33。這也是Redis里的skiplist實現在空間上的開銷。
接下來,為了分析時間復雜度,我們計算一下skiplist的平均查找長度。查找長度指的是查找路徑上跨越的跳數,而查找過程中的比較次數就等于查找長度加1。以前面圖中標出的查找23的查找路徑為例,從左上角的頭結點開始,一直到結點22,查找長度為6。
為了計算查找長度,這里我們需要利用一點小技巧。我們注意到,每個節點插入的時候,它的層數是由隨機函數randomLevel()計算出來的,而且隨機的計算不依賴于其它節點,每次插入過程都是完全獨立的。所以,從統計上來說,一個skiplist結構的形成與節點的插入順序無關。
這樣的話,為了計算查找長度,我們可以將查找過程倒過來看,從右下方第1層上最后到達的那個節點開始,沿著查找路徑向左向上回溯,類似于爬樓梯的過程。我們假設當回溯到某個節點的時候,它才被插入,這雖然相當于改變了節點的插入順序,但從統計上不影響整個skiplist的形成結構。
現在假設我們從一個層數為i的節點x出發,需要向左向上攀爬k層。這時我們有兩種可能:
- 如果節點x有第(i+1)層指針,那么我們需要向上走。這種情況概率為p。
- 如果節點x沒有第(i+1)層指針,那么我們需要向左走。這種情況概率為(1-p)。
這兩種情形如下圖所示:
用C(k)表示向上攀爬k個層級所需要走過的平均查找路徑長度(概率期望),那么:
C(0)=0
C(k)=(1-p)×(上圖中情況b的查找長度) + p×(上圖中情況c的查找長度)
代入,得到一個差分方程并化簡:
C(k)=(1-p)(C(k)+1) + p(C(k-1)+1)
C(k)=1/p+C(k-1)
C(k)=k/p
這個結果的意思是,我們每爬升1個層級,需要在查找路徑上走1/p步。而我們總共需要攀爬的層級數等于整個skiplist的總層數-1。
那么接下來我們需要分析一下當skiplist中有n個節點的時候,它的總層數的概率均值是多少。這個問題直觀上比較好理解。根據節點的層數隨機算法,容易得出:
- 第1層鏈表固定有n個節點;
- 第2層鏈表平均有n*p個節點;
- 第3層鏈表平均有n*p2個節點;
...
所以,從第1層到最高層,各層鏈表的平均節點數是一個指數遞減的等比數列。容易推算出,總層數的均值為log1/pn,而最高層的平均節點數為1/p。
綜上,粗略來計算的話,平均查找長度約等于:
C(log1/pn-1)=(log1/pn-1)/p
即,平均時間復雜度為O(log n)。
當然,這里的時間復雜度分析還是比較粗略的。比如,沿著查找路徑向左向上回溯的時候,可能先到達左側頭結點,然后沿頭結點一路向上;還可能先到達最高層的節點,然后沿著最高層鏈表一路向左。但這些細節不影響平均時間復雜度的最后結果。另外,這里給出的時間復雜度只是一個概率平均值,但實際上計算一個精細的概率分布也是有可能的。詳情還請參見William Pugh的論文《Skip Lists: A Probabilistic Alternative to Balanced Trees》。
skiplist與平衡樹、哈希表的比較
- skiplist和各種平衡樹(如AVL、紅黑樹等)的元素是有序排列的,而哈希表不是有序的。因此,在哈希表上只能做單個key的查找,不適宜做范圍查找。所謂范圍查找,指的是查找那些大小在指定的兩個值之間的所有節點。
- 在做范圍查找的時候,平衡樹比skiplist操作要復雜。在平衡樹上,我們找到指定范圍的小值之后,還需要以中序遍歷的順序繼續尋找其它不超過大值的節點。如果不對平衡樹進行一定的改造,這里的中序遍歷并不容易實現。而在skiplist上進行范圍查找就非常簡單,只需要在找到小值之后,對第1層鏈表進行若干步的遍歷就可以實現。
- 平衡樹的插入和刪除操作可能引發子樹的調整,邏輯復雜,而skiplist的插入和刪除只需要修改相鄰節點的指針,操作簡單又快速。
- 從內存占用上來說,skiplist比平衡樹更靈活一些。一般來說,平衡樹每個節點包含2個指針(分別指向左右子樹),而skiplist每個節點包含的指針數目平均為1/(1-p),具體取決于參數p的大小。如果像Redis里的實現一樣,取p=1/4,那么平均每個節點包含1.33個指針,比平衡樹更有優勢。
- 查找單個key,skiplist和平衡樹的時間復雜度都為O(log n),大體相當;而哈希表在保持較低的哈希值沖突概率的前提下,查找時間復雜度接近O(1),性能更高一些。所以我們平常使用的各種Map或dictionary結構,大都是基于哈希表實現的。
- 從算法實現難度上來比較,skiplist比平衡樹要簡單得多。
Redis中跳躍表實現
跳躍表節點
跳躍表節點的實現由 redis.h/zskiplistNode 結構定義:
typedef struct zskiplistNode {
struct zskiplistNode *backward; /* 后退指針 */
double score; /* 分值 */
robj *obj; /* 成員對象 */
// 層
struct zskiplistLevel {
struct zskiplistNode *forward; /* 前進指針 */
unsigned int span; /* 跨度 */
} level[];
} zskiplistNode;
層
跳躍表節點的 level 數組可以包含多個元素, 每個元素都包含一個指向其他節點的指針, 程序可以通過這些層來加快訪問其他節點的速度, 一般來說, 層的數量越多, 訪問其他節點的速度就越快。
每次創建一個新跳躍表節點的時候, 程序都根據冪次定律 (power law,越大的數出現的概率越小) 隨機生成一個介于 1 和 32 之間的值作為 level 數組的大小, 這個大小就是層的“高度”。
圖 5-2 分別展示了三個高度為 1 層、 3 層和 5 層的節點, 因為 C 語言的數組索引總是從 0 開始的, 所以節點的第一層是 level[0] , 而第二層是 level[1] , 以此類推。
前進指針
每個層都有一個指向表尾方向的前進指針(level[i].forward 屬性), 用于從表頭向表尾方向訪問節點。
圖 5-3 用虛線表示出了程序從表頭向表尾方向, 遍歷跳躍表中所有節點的路徑:
- 迭代程序首先訪問跳躍表的第一個節點(表頭), 然后從第四層的前進指針移1. 動到表中的第二個節點。
- 在第二個節點時, 程序沿著第二層的前進指針移動到表中的第三個節點。
- 在第三個節點時, 程序同樣沿著第二層的前進指針移動到表中的第四個節點。
-
當程序再次沿著第四個節點的前進指針移動時, 它碰到一個 NULL , 程序知道這時已經到達了跳躍表的表尾, 于是結束這次遍歷。
圖5-3.png
跨度
層的跨度(level[i].span 屬性)用于記錄兩個節點之間的距離:
兩個節點之間的跨度越大, 它們相距得就越遠。
指向 NULL 的所有前進指針的跨度都為 0 , 因為它們沒有連向任何節點。
初看上去, 很容易以為跨度和遍歷操作有關, 但實際上并不是這樣 —— 遍歷操作只使用前進指針就可以完成了, 跨度實際上是用來計算排位(rank)的: 在查找某個節點的過程中, 將沿途訪問過的所有層的跨度累計起來, 得到的結果就是目標節點在跳躍表中的排位。
舉個例子, 圖 5-4 用虛線標記了在跳躍表中查找分值為 3.0 、 成員對象為 o3 的節點時, 沿途經歷的層: 查找的過程只經過了一個層, 并且層的跨度為 3 , 所以目標節點在跳躍表中的排位為 3 。
后退指針
節點的后退指針(backward 屬性)用于從表尾向表頭方向訪問節點: 跟可以一次跳過多個節點的前進指針不同, 因為每個節點只有一個后退指針, 所以每次只能后退至前一個節點。
圖 5-6 用虛線展示了如果從表尾向表頭遍歷跳躍表中的所有節點: 程序首先通過跳躍表的 tail 指針訪問表尾節點, 然后通過后退指針訪問倒數第二個節點, 之后再沿著后退指針訪問倒數第三個節點, 再之后遇到指向 NULL 的后退指針, 于是訪問結束。
分值和成員
節點的分值(score 屬性)是一個 double 類型的浮點數, 跳躍表中的所有節點都按分值從小到大來排序。
節點的成員對象(obj 屬性)是一個指針, 它指向一個字符串對象, 而字符串對象則保存著一個 SDS 值。
在同一個跳躍表中, 各個節點保存的成員對象必須是唯一的, 但是多個節點保存的分值卻可以是相同的: 分值相同的節點將按照成員對象在字典序中的大小來進行排序, 成員對象較小的節點會排在前面(靠近表頭的方向), 而成員對象較大的節點則會排在后面(靠近表尾的方向)。
舉個例子, 在圖 5-7 所示的跳躍表中, 三個跳躍表節點都保存了相同的分值 10086.0 , 但保存成員對象 o1 的節點卻排在保存成員對象 o2 和 o3 的節點之前, 而保存成員對象 o2 的節點又排在保存成員對象 o3 的節點之前, 由此可見, o1 、 o2 、 o3 三個成員對象在字典中的排序為 o1 <= o2 <= o3 。
跳躍表
Redis使用一個zskiplist結構來持有這些節點, 程序可以更方便地對整個跳躍表進行處理, 比如快速訪問跳躍表的表頭節點和表尾節點, 又或者快速地獲取跳躍表節點的數量(也即是跳躍表的長度)等信息, 如圖 5-9 所示。
zskiplist 結構的定義如下:
typedef struct zskiplist {
struct zskiplistNode *header, *tail; /* 表頭節點和表尾節點 */
unsigned long length; /* 表中節點的數量 */
int level; /* 表中層數最大的節點的層數 */
} zskiplist;
header 和 tail 指針分別指向跳躍表的表頭和表尾節點, 通過這兩個指針, 程序定位表頭節點和表尾節點的復雜度為 O(1) 。
通過使用 length 屬性來記錄節點的數量, 程序可以在 O(1) 復雜度內返回跳躍表的長度。
level 屬性則用于在 O(1) 復雜度內獲取跳躍表中層高最大的那個節點的層數量, 注意表頭節點的層高并不計算在內。
跳躍表 API
函數 | 作用 | 時間復雜度 |
---|---|---|
zslCreate |
創建一個新的跳躍表。 | O(1) |
zslFree |
釋放給定跳躍表,以及表中包含的所有節點。 | O(N) , N 為跳躍表的長度。 |
zslInsert |
將包含給定成員和分值的新節點添加到跳躍表中。 | 平均 O(\log N) ,最壞 O(N) , N 為跳躍表長度。 |
zslDelete |
刪除跳躍表中包含給定成員和分值的節點。 | 平均 O(\log N) ,最壞 O(N) , N 為跳躍表長度。 |
zslGetRank |
返回包含給定成員和分值的節點在跳躍表中的排位。 | 平均 O(\log N) ,最壞 O(N) , N 為跳躍表長度。 |
zslGetElementByRank |
返回跳躍表在給定排位上的節點。 | 平均 O(\log N) ,最壞 O(N) , N 為跳躍表長度。 |
zslIsInRange |
給定一個分值范圍(range), 比如 0 到 15 , 20 到 28 ,諸如此類, 如果給定的分值范圍包含在跳躍表的分值范圍之內, 那么返回 1 ,否則返回 0 。 |
通過跳躍表的表頭節點和表尾節點, 這個檢測可以用 O(1) 復雜度完成。 |
zslFirstInRange |
給定一個分值范圍, 返回跳躍表中第一個符合這個范圍的節點。 | 平均 O(\log N) ,最壞 O(N) 。 N 為跳躍表長度。 |
zslLastInRange |
給定一個分值范圍, 返回跳躍表中最后一個符合這個范圍的節點。 | 平均 O(\log N) ,最壞 O(N) 。 N 為跳躍表長度。 |
zslDeleteRangeByScore |
給定一個分值范圍, 刪除跳躍表中所有在這個范圍之內的節點。 | O(N) , N 為被刪除節點數量。 |
zslDeleteRangeByRank |
給定一個排位范圍, 刪除跳躍表中所有在這個范圍之內的節點。 | O(N) , N 為被刪除節點數量。 |