目錄
引子
跳躍列表(Skip List),簡稱跳表。在前面我寫過的一篇講解HBase MemStore的文章中曾經提到,MemStore(其實也包括BlockCache)的基礎數據結構都是JUC包提供的并發跳表ConcurrentSkipListMap。當時我就立flag說跳表是個有意思的數據結構,會寫文章專門分析它,那么今天就來兌現承諾吧。
認識跳表
跳表的提出
跳表首先由William Pugh在其1990年的論文《Skip lists: A probabilistic alternative to balanced trees》中提出。由該論文的題目可以知道兩點:
- 跳表是概率型數據結構。
- 跳表是用來替代平衡樹的數據結構。準確來說,是用來替代自平衡二叉查找樹(self-balancing BST)的結構。
看官可能會覺得這兩點與之前文章中講過的布隆過濾器有些相似,但實際上它們的原理和用途還是很不相同的。
由二叉樹回歸鏈表
考慮在有序序列中查找某個特定元素的情境:
- 如果該序列用支持隨機訪問的線性結構(數組)存儲,那么我們很容易地用二分查找來做。
- 但是考慮到增刪效率和內存擴展性,很多時候要用不支持隨機訪問的線性結構(鏈表)存儲,就只能從頭遍歷、逐個比對。
- 作為折衷,如果用二叉樹結構(BST)存儲,就可以不靠隨機訪問特性進行二分查找了。
我們知道,普通BST插入元素越有序效率越低,最壞情況會退化回鏈表。因此很多大佬提出了自平衡BST結構,使其在任何情況下的增刪查操作都保持O(logn)的時間復雜度。自平衡BST的代表就是AVL樹、Splay樹、2-3樹及其衍生出的紅黑樹。如果推廣之,不限于二叉樹的話,我們耳熟能詳的B樹和B+樹也屬此類,常用于文件系統和數據庫。
自平衡BST顯然很香,但是它仍然有一個不那么香的點:樹的自平衡過程比較復雜,實現起來麻煩,在高并發的情況下,加鎖也會帶來可觀的overhead。如AVL樹需要LL、LR、RL、RR四種旋轉操作保持平衡,紅黑樹則需要左旋、右旋和節點變色三種操作。下面的動圖展示的就是AVL樹在插入元素時的平衡過程。
那么,有沒有簡單點的、與自平衡BST效率相近的實現方法呢?答案就是跳表,并且它簡單很多,下面就來看一看。
設計思想與查找流程
跳表就是如同下圖一樣的許多鏈表的集合。
跳表具有如下的性質:
- 由多層組成,最底層為第1層,次底層為第2層,以此類推。層數不會超過一個固定的最大值Lmax。
- 每層都是一個有頭節點的有序鏈表,第1層的鏈表包含跳表中的所有元素。
- 如果某個元素在第k層出現,那么在第1~k-1層也必定都會出現,但會按一定的概率p在第k+1層出現。
很顯然這是一種空間換時間的思路,與索引異曲同工。第k層可以視為第k-1級索引,用來加速查找。為了避免占用空間過多,第1層之上都不存儲實際數據,只有指針(包含指向同層下一個元素的指針與同一個元素下層的指針)。
當查找元素時,會從最頂層鏈表的頭節點開始遍歷。以升序跳表為例,如果當前節點的下一個節點包含的值比目標元素值小,則繼續向右查找。如果下一個節點的值比目標值大,就轉到當前層的下一層去查找。重復向右和向下的操作,直到找到與目標值相等的元素為止。下圖中的藍色箭頭標記出了查找元素21的步驟。
通過圖示,我們也可以更加明白“跳表”這個名稱的含義,因為查找過程確實是跳躍的,比線性查找省時。若要查找在高層存在的元素(如25),步數就會變得更少。當數據量越來越大時,這種結構的優勢就更加明顯了。
插入元素的概率性
前文已經說過,跳表第k層的元素會按一定的概率p在第k+1層出現,這種概率性就是在插入過程中實現的。
當按照上述查找流程找到新元素的插入位置之后,首先將其插入第1層。至于是否要插入第2,3,4...層,就需要用隨機數等方法來確定。最通用的實現方法描述如下。
int randomizeLevel(double p, int lmax) {
int level = 1;
Random random = new Random();
while (random.nextDouble() < p && level < lmax) {
level++;
}
return level;
}
得到層數k之后,就將新元素插入跳表的第1~k層。由上面的邏輯可知,隨著層數的增加,元素被插入高層的概率會指數級下降。
下面的動圖示出以p=1/2概率在跳表中插入元素的過程。這種方法也被叫做“拋鋼镚兒”(coin flip),直到拋出正面/反面就停止。
相對于插入而言,刪除元素沒有這么多彎彎繞,基本上就是正常的單鏈表刪除邏輯,因此不再展開。
復雜度分析
只要我們找出平均查找長度,自然就可以知道跳表的平均時間復雜度了。為了便于分析,我們將查找元素的流程反過來,從目的元素向頭節點看,也就是變成向上、向左的操作。由于隨機層數的計算是相互獨立的,因此這樣做并無妨。
假設從層數為l的某節點x出發,距離最頂層有k層。如果x有第l+1層的指針就向上走,沒有的話就向左走。它們的概率分別為p與1-p。借用William Pugh畫好的圖如下。
設C[k]為向上走k層要走過的路徑長度期望值,就有:C[0] = 0 ; C[k] = (1-p)·(1+C[k]) + p·(1+C[k-1]),最終可得:C[k] = k/p。
接下來設跳表中包含n個元素,那么第1層就有n個元素,第2層平均有np個,第3層平均有np2個……依次類推。可知,跳表層數的期望值為log1/pn。結合上面得出的結論,跳表的平均查找長度就是:[log1/pn - 1] / p。
也就是說,跳表增刪查的平均時間復雜度為O(logn),達到了平衡BST的水準。當然它畢竟是不穩定的,如果運氣奇差無比,總是無法建立起層級結構的話,最壞時間復雜度仍然會退化到O(n)級別,但這個概率是非常微小的了。
由上面的分析我們還可以知道,跳表中單個節點的層數為1的概率為1-p,層數為2的概率為p(1-p),層數為3的概率為p2(1-p)……依次類推。所以,單個節點層數的期望為:(1-p) · ∑[k=1→+∞](kpk-1) = 1/(1-p)。這也是單個節點中指針數量的平均值,列表如下。
可見,p越小,節點的空間開銷也就越小,但(正規化的)查找長度會相應越大。
Redis的跳表實現
跳表在很多框架中都有廣泛的應用,除Java并發包及HBase之外,比較著名的是Redis和leveldb。之前一直讀Java系的源碼,有些膩煩了,并且我最近正好在研究一些Redis調優方面的事情,就干脆拿Redis 4.0.14的源碼來講講吧。
從zset到zskiplist
跳表在Redis中稱為zskiplist,是其提供的有序集合(sorted set/zset)類型底層的數據結構之一。zset的定義如下,位于server.h中。
typedef struct zset {
dict *dict;
zskiplist *zsl;
} zset;
可見,除了zskiplist之外,zset還使用了KV哈希表dict。Redis中有序集合的默認實現其實是更為普遍的ziplist(壓縮雙鏈表),但在redis.conf中有兩個參數可以控制它轉為zset實現。
zset-max-ziplist-entries 128
zset-max-ziplist-value 64
也就是說,當有序集合中的元素數超過zset-max-ziplist-entries
時,或其中任意一個元素的數據長度超過zset-max-ziplist-value
時,就由ziplist自動轉化為zset。具體邏輯參見t_zset.c中的zsetConvert()函數,不再贅述。
扯遠了,回來看看zskiplist,它的定義就在zset上面。
typedef struct zskiplistNode {
robj *obj;
double score;
struct zskiplistNode *backward;
struct zskiplistLevel {
struct zskiplistNode *forward;
unsigned int span;
} level[];
} zskiplistNode;
typedef struct zskiplist {
struct zskiplistNode *header, *tail;
unsigned long length;
int level;
} zskiplist;
zskiplist的節點定義是結構體zskiplistNode,其中有以下字段。
- obj:存放該節點的數據。
- score:數據對應的分數值,zset通過分數對數據升序排列。
- backward:指向鏈表上一個節點的指針,即后向指針。
- level[]:結構體zskiplistLevel的數組,表示跳表中的一層。每層又存放有兩個字段:
- forward是指向鏈表下一個節點的指針,即前向指針。
- span表示這個前向指針跳躍過了多少個節點(不包括當前節點)。
zskiplist就是跳表本身,其中有以下字段。
- header、tail:頭指針和尾指針。
- length:跳表的長度,不包括頭指針。
- level:跳表的層數。
下圖示出一個length=3,level=5的zskiplist。
可見,zskiplist的第1層是個雙向鏈表,其他層仍然是單向鏈表,這樣做是為了方便可能的逆向獲取數據的需求。
另外,節點中還會保存前向指針跳過的節點數span,這是因為zset本身支持基于排名的操作,如zrevrank
指令(由數據查詢排名)、zrevrange
指令(由排名范圍查詢數據)等。如果有span值的話,就可以方便地在查找過程中累積出排名了。
以上是zskiplist相對于前述的傳統跳表的兩點不同,并且都給我們帶來了便利。下面我們來繼續讀代碼,看看它的部分具體操作。
創建zskiplist
zslCreate()函數位于t_zset.c中。
zskiplist *zslCreate(void) {
int j;
zskiplist *zsl;
zsl = zmalloc(sizeof(*zsl));
zsl->level = 1;
zsl->length = 0;
zsl->header = zslCreateNode(ZSKIPLIST_MAXLEVEL,0,NULL);
for (j = 0; j < ZSKIPLIST_MAXLEVEL; j++) {
zsl->header->level[j].forward = NULL;
zsl->header->level[j].span = 0;
}
zsl->header->backward = NULL;
zsl->tail = NULL;
return zsl;
}
并不難懂,唯一需要注意的是常量ZSKIPLIST_MAXLEVEL
,它定義了zskiplist的最大層數,值為32,這也是上節圖中的節點最高只到L32的原因。
向zskiplist插入元素
插入元素靠的是zslInsert()函數,有點長。
zskiplistNode *zslInsert(zskiplist *zsl, double score, robj *obj) {
zskiplistNode *update[ZSKIPLIST_MAXLEVEL], *x;
unsigned int rank[ZSKIPLIST_MAXLEVEL];
int i, level;
serverAssert(!isnan(score));
x = zsl->header;
for (i = zsl->level-1; i >= 0; i--) {
rank[i] = i == (zsl->level-1) ? 0 : rank[i+1];
while (x->level[i].forward &&
(x->level[i].forward->score < score ||
(x->level[i].forward->score == score &&
compareStringObjects(x->level[i].forward->obj,obj) < 0))) {
rank[i] += x->level[i].span;
x = x->level[i].forward;
}
update[i] = x;
}
level = zslRandomLevel();
if (level > zsl->level) {
for (i = zsl->level; i < level; i++) {
rank[i] = 0;
update[i] = zsl->header;
update[i]->level[i].span = zsl->length;
}
zsl->level = level;
}
x = zslCreateNode(level,score,obj);
for (i = 0; i < level; i++) {
x->level[i].forward = update[i]->level[i].forward;
update[i]->level[i].forward = x;
x->level[i].span = update[i]->level[i].span - (rank[0] - rank[i]);
update[i]->level[i].span = (rank[0] - rank[i]) + 1;
}
for (i = level; i < zsl->level; i++) {
update[i]->level[i].span++;
}
x->backward = (update[0] == zsl->header) ? NULL : update[0];
if (x->level[0].forward)
x->level[0].forward->backward = x;
else
zsl->tail = x;
zsl->length++;
return x;
}
該方法涉及到的細節很多,其大致執行流程如下:
- 按照前面講過的查找流程,找到合適的插入位置。注意zset允許分數score相同,這時會根據節點數據obj的字典序來排序。
- 調用zslRandomLevel()方法,隨機出要插入的節點的層數。
- 調用zslCreateNode()方法,根據層數level、分數score和數據obj創建出新節點。
- 每層遍歷,修改新節點以及其前后節點的前向指針forward和跳躍長度span,也要更新最底層的后向指針backward。
其中維護了兩個數組update和rank。update數組用來記錄每一層的最后一個分數小于待插入score的節點,也就是插入位置。rank數組用來記錄上述插入位置的上一個節點的排名,以便于最后更新span值。
隨機計算層數的zslRandomLevel()方法如下。
int zslRandomLevel(void) {
int level = 1;
while ((random()&0xFFFF) < (ZSKIPLIST_P * 0xFFFF))
level += 1;
return (level<ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL;
}
注意p值由ZSKIPLIST_P
常量定義,值為0.25,即被插入到高層的概率為1/4。
下圖示出插入元素的流程。
查詢元素排名/獲取排名對應元素
從zslGetRank()和zslGetElementByRank()這兩個函數可以更明顯地看出通過累積span字段的值獲取到排名的操作。代碼本身比較容易理解,如下所示。
unsigned long zslGetRank(zskiplist *zsl, double score, robj *o) {
zskiplistNode *x;
unsigned long rank = 0;
int i;
x = zsl->header;
for (i = zsl->level-1; i >= 0; i--) {
while (x->level[i].forward &&
(x->level[i].forward->score < score ||
(x->level[i].forward->score == score &&
compareStringObjects(x->level[i].forward->obj,o) <= 0))) {
rank += x->level[i].span;
x = x->level[i].forward;
}
if (x->obj && equalStringObjects(x->obj,o)) {
return rank;
}
}
return 0;
}
zskiplistNode* zslGetElementByRank(zskiplist *zsl, unsigned long rank) {
zskiplistNode *x;
unsigned long traversed = 0;
int i;
x = zsl->header;
for (i = zsl->level-1; i >= 0; i--) {
while (x->level[i].forward && (traversed + x->level[i].span) <= rank)
{
traversed += x->level[i].span;
x = x->level[i].forward;
}
if (traversed == rank) {
return x;
}
}
return NULL;
}
Redis作者對采用跳表的解釋
比起我多費口舌,不如來看看Salvatore Sanfillipo(@antirez)本人說的話。他多年之前在Hacker News的一篇帖子上解釋了自己為什么要在Redis中用跳表而不是樹,原文如下,淺顯易懂,就不翻譯了:
- They are not very memory intensive. It's up to you basically. Changing parameters about the probability of a node to have a given number of levels will make then less memory intensive than btrees.
- A sorted set is often target of many ZRANGE or ZREVRANGE operations, that is, traversing the skip list as a linked list. With this operation the cache locality of skip lists is at least as good as with other kind of balanced trees.
- They are simpler to implement, debug, and so forth. For instance thanks to the skip list simplicity I received a patch (already in Redis master) with augmented skip lists implementing ZRANK in O(log(N)). It required little changes to the code.
The End
謝謝食用~