跳躍列表(Skip List)與其在Redis中的實現詳解

目錄

引子

跳躍列表(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中用跳表而不是樹,原文如下,淺顯易懂,就不翻譯了:

  1. 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.
  2. 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.
  3. 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

謝謝食用~

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 230,182評論 6 543
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,489評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,290評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,776評論 1 317
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,510評論 6 412
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,866評論 1 328
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,860評論 3 447
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 43,036評論 0 290
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,585評論 1 336
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,331評論 3 358
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,536評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,058評論 5 363
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,754評論 3 349
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,154評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,469評論 1 295
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,273評論 3 399
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,505評論 2 379

推薦閱讀更多精彩內容