Redis 數據結構之跳躍表(skiplist)

前言

有序集合在咱們的平常生活中很是常見,好比根據成績對學生進行排名、根據得分對遊戲玩家進行排名等。對於有序集合的底層實現,咱們可使用數組、鏈表、平衡樹等結構。數組不便於元素的插入和刪除;鏈表的查詢效率低,須要遍歷全部元素;平衡樹或者紅黑樹等結構雖然效率高但實現複雜。html

所以,Redis 採用了一種新的數據結構——跳躍表。跳躍表的效率堪比紅黑樹,可是其實現卻遠比紅黑樹簡單。node

下面就開始咱們今天的學習之旅!git

基礎知識

按照慣例,咱們將下文中的涉及到的一些概念,在這裏作一下簡單介紹,方便後面的學習。github

&0xFFFF

0x 是一種標識,用來表示 16 進制。redis

F 是 16 進制中的 15,其二進制表示爲 1111算法

FFFF 即 1111 1111 1111 1111數組

&0xFFFF 即與 0xFFFF 作位運算,只取低 16 位。markdown

簡介

跳躍表是 zset (有序集合)的基礎數據結構。跳躍表能夠高效的保持元素有序,而且實現相對平衡樹簡單、直觀。Redis 的跳躍表是基於 William Pugh 在 《Skip lists: a probabilistic alternative to balanced trees》 中描述的算法實現的。作了一下幾點改動:數據結構

  1. 容許重複分數;
  2. 比較不只會涉及鍵,還可能涉及節點數據(鍵相等時)。
  3. 有一個後退指針,因此是一個雙向鏈表,便於實現 zrevrange 等命令。

跳躍表的演變過程

skiplist,首先它是一個 list。實際上,它是在有序鏈表的基礎上發展起來的。dom

普通有序鏈表

咱們先來看一下有序鏈表,有序鏈表是全部元素以遞增或遞減方式有序排列的數據結構,其中每一個節點又有指向下個節點的 next 指針,最後一個節點的 next 指針指向 NULL。遞增有序鏈表示例以下:

遞增有序鏈表

如圖所示,若是咱們想要查詢值爲 61 的元素,咱們須要從第一個元素開始依次向後查找、比較才能夠找到,查找的順序爲 1 -> 11 -> 21 -> 31 -> 41 -> 51 -> 61,共 7 次比較,時間複雜度爲 O(N)。有序鏈表的插入和刪除操做都須要先找到合適的位置再修改 next 指針,修改操做基本不消耗時間,因此插入、刪除、修改有序鏈表的耗時主要在查找元素上。

普通有序鏈表的第一次演變

假如咱們 每相鄰兩個節點增長一個指針,讓指針指向下下節點,以下圖所示:

二層有序遞增鏈表

新增長的指針連成了一個新的鏈表,可是它包含的節點個數只有原來的一半(1,21,41,61)。如今當咱們想要查找 61 的時候,咱們就沿着這個新鏈表進行查找(綠色指針方向)。查找的順序爲 1 -> 21 -> 41 -> 61,共 4 次比較,須要比較的次數大概只有原來的一半

普通有序鏈表的第二次演變

利用一樣的方式,咱們能夠在上層新產生的鏈表上,繼續爲每相鄰的兩個節點增長一個指針,從而查看第三層鏈表,以下圖所示:

三層有序鏈表

新增長的指針連成了一個新的鏈表,它包含的節點個數只有第二層的一半(1,41)。如今當咱們想要查找 61 的時候,咱們沿着新鏈表進行查找(紅色指針方向)。查找順序爲 1 -> 41,此時咱們發現 41 的 next 指針指向 null,咱們就開始從 41 節點的下一層開始查找(綠色指針方向),即 41 -> 61,連起來就是 1-> 41 -> 61,總共比較了 3 次,相比於上次查找又少了一次。當數據量大的時候,這種優點會更加明顯

普通有序鏈表演變成 Redis 的 skiplist

skiplist 正是受這種 多層鏈表 的想法啓發設計得來的。

按照上面生成鏈表的方式,上面每一層鏈表的節點個數,是下面一層的節點個數的一半,這樣查找過程就很是相似於一個 二分查找,使得查找的時間複雜度能夠降到 O(logN)。

可是新插入一個節點以後,就會打亂上下相鄰兩層鏈表上節點個數嚴格的 2:1 的對應關係。若是要維持這種對應關係,就必須把新插入的節點後面的全部節點(也包括新插入的節點)從新進行調整,這會讓時間複雜度從新退化爲 O(N)。刪除數據也有一樣的問題。

skiplist 爲了不這一問題,它 不要求上下相鄰兩層鏈表之間的節點個數有嚴格的對應關係,而是爲每一個節點隨機出一個層數(level),新插入的節點就會根據本身的層數決定該節點是否在這層的鏈表上

跳躍表節點與結構

從上面咱們能夠知道,跳躍表由多個節點構成,每一個節點由不少層構成,每層都有指向本層的下個節點的指針。

跳躍表主要涉及 server.ht_zset.c 兩個文件,其中在 server.h 中定義了跳躍表的數據結構,在 t_zset.c 中定義了跳躍表的節本操做。

接下來,讓咱們一塊兒來看一下跳躍表具體是如何實現的。

跳躍表節點

typedef struct zskiplistNode {
    sds ele;
    double score;
    struct zskiplistNode *backward;
    struct zskiplistLevel {
        struct zskiplistNode *forward;
        unsigned long span;
    } level[];
} zskiplistNode;
複製代碼

該結構體包含以下屬性:

  • ele:SDS 類型,用於存儲字符串類型的數據。
  • score:用於存儲排序的分值。
  • backward:後退指針,只能指向當前節點最底層的前一個節點,頭結點和第一個節點——backward 指向 NULL,從後向前遍歷跳躍表使用。
  • level:節點層數,爲 柔性數組,每一個節點的數組長度不同(由於層數不同)。在生成跳躍表節點時,隨機生成 1~64 的值,值越大出現的機率越低。

level 數組中的 每項 包含如下兩個元素:

  • forward:指向本層的下一個節點,尾結點的 forward 指向 NULL。
  • span:forward 指向的節點與本節點之間的元素個數。span 值越大,跳過的節點個數越多。(相鄰兩個節點之間,前一個節點的 span 爲 1)

跳躍表是 Redis 有序集合的底層實現方式之一。因此每一個節點的 ele 存儲有序集合的成員 member 值,score 存儲成員 score 值。全部節點的分值是按從小到大的方式排序的,當有序集合的成員分值相同時,節點會按 member 的字典序進行排序。

跳躍表結構

typedef struct zskiplist {
    struct zskiplistNode *header, *tail;
    unsigned long length;
    int level;
} zskiplist;
複製代碼

該結構體中包含以下屬性:

  • header:指向跳躍表頭結點。頭結點是跳躍表中的一個特殊節點,它的 level 數組元素個數爲 64。頭節點在有序集合中不存儲任何 member 值和 score 值,ele 值爲 NULL,score 值爲 0;也不計入跳躍表總長度。頭節點在初始化時,64 個元素的 forward 都指向 NULL,span 值都爲 0
  • tail:指向跳躍表尾節點。
  • length:跳躍表長度,表示除頭節點外的節點總數。
  • level:跳躍表的高度。

經過跳躍表結構體的屬性咱們能夠看到,程序能夠在 O(1) 的時間複雜度下,快速獲取到跳躍表的頭結點、尾節點、長度和高度。

基本操做

咱們已經知道了跳躍表節點和跳躍表結構體的定義,接下來咱們再看一下跳躍表的建立、插入、查找和刪除操做。

建立跳躍表

zskiplist *zslCreate(void) {
    int j;
    zskiplist *zsl;

    zsl = zmalloc(sizeof(*zsl)); //初始化內存空間
    zsl->level = 1; //將層數設置爲最小的 1 
    zsl->length = 0; //將長度設置爲 0
    zsl->header = zslCreateNode(ZSKIPLIST_MAXLEVEL,0,NULL); //建立跳躍表頭節點,層數爲 ZSKIPLIST_MAXLEVEL=64 層
    for (j = 0; j < ZSKIPLIST_MAXLEVEL; j++) { //依次給頭節點的每層賦值
        zsl->header->level[j].forward = NULL;
        zsl->header->level[j].span = 0;
    }
    zsl->header->backward = NULL; //頭節點的回退指針設置爲 NULL
    zsl->tail = NULL; //尾節點設置爲 NULL
    return zsl;
}
複製代碼

能夠看到,跳躍表的建立過程以下:

首先聲明一塊大小爲 sizeof(zskiplist) 的內存空間。

而後將層高 level 設置爲 1,將跳躍表長度 length 設置爲 0。而後建立頭節點 header,其中 ZSKIPLIST_MAXLEVEL 的定義以下:

#define ZSKIPLIST_MAXLEVEL 64 
複製代碼

表明層節點最高爲 64 層,而咱們的頭結點正是最高的層數。

頭節點是一個特殊的節點,不存儲有序集合的 member 信息。頭節點是跳躍表中第一個插入的節點,其 level 數組的每項 forward 都 爲NULL,span 值都爲 0。

接着將頭節點的回退指針 backward 和尾指針 tail 設置爲 NULL。

這些都很好理解,就是初始化內存,而後依次將跳躍表結構體各個成員設置默認值。

建立跳躍表節點

建立跳躍表節點代碼以下:

zskiplistNode *zslCreateNode(int level, double score, sds ele) {
    zskiplistNode *zn =
        zmalloc(sizeof(*zn)+level*sizeof(struct zskiplistLevel)); //申請 zskiplistNode + 柔型數組(多層)大小的空間
    zn->score = score; //設置節點分支
    zn->ele = ele; //設置節點數據
    return zn;
}
複製代碼

建立跳躍表節點的代碼也很好理解。

首先分配內存空間,這個空間大小爲 zskiplistNode 的大小和 level 數組的大小。

zskiplistNode 結構體的最後一個元素爲柔性數組,申請內存時須要指定柔性數組的大小,一個節點佔用的內存大小爲 zskiplistNode 的內存大小與 levelzskiplistLevel 的內存大小之和。

再將節點的 scoreele 分別賦值。

插入節點

插入節點這塊比較重要,也比較難懂,咱們仔細學習一下。

首先附上插入節點代碼。

zskiplistNode *zslInsert(zskiplist *zsl, double score, sds ele) {
    zskiplistNode *update[ZSKIPLIST_MAXLEVEL], *x; // update[] 數組用於存儲被插入節點每層的前一個節點
    unsigned int rank[ZSKIPLIST_MAXLEVEL]; // rank[] 數組記錄當前層從 header 節點到 update[i] 節點所經歷的步長。
    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]; //給rank[] 數組初始值賦值,最上層從 header 節點開始,因此爲 0,下面的每層都是從上一層走到的最後一個節點開始,因此爲 rank[i+1]
        while (x->level[i].forward &&
                (x->level[i].forward->score < score ||
                    (x->level[i].forward->score == score &&
                    sdscmp(x->level[i].forward->ele,ele) < 0))) //前進的規則存在 forward 節點且(forward 節點評分小於待插入節點評分 || (forward 節點評分等於待插入節點評分 && forward 節點元素字典值小於待插入節點元素字典值))
        {
            rank[i] += x->level[i].span; //加上 x 的跨度
            x = x->level[i].forward; //節點向前前進
        }
        update[i] = x; // 將被插入節點當前層的前一個節點記錄在 update[] 數組中
    }
    level = zslRandomLevel(); //隨機生成一個層高
    if (level > zsl->level) { //新生成節點的層高比當前跳躍表層高大事
        for (i = zsl->level; i < level; i++) { //只更新高出的部分
            rank[i] = 0; //由於是頭結點,因此爲 0
            update[i] = zsl->header; //該層只有頭結點
            update[i]->level[i].span = zsl->length; //由於 forward 指向 NULL,因此跨度應該是跳躍表全部的節點,因此 span 爲跳躍表的長度
        }
        zsl->level = level; //更新跳躍表的層高
    }
    x = zslCreateNode(level,score,ele); // x 被賦值成新建立的節點
    for (i = 0; i < level; i++) {
        x->level[i].forward = update[i]->level[i].forward; //更新 x 節點的 level[i] 層的 forward 指針
        update[i]->level[i].forward = x; //更新 update[i] 節點的 level[i] 層的 forward 指針

        x->level[i].span = update[i]->level[i].span - (rank[0] - rank[i]); //更新 x 節點的 level[i] 層的跨度 span
        update[i]->level[i].span = (rank[0] - rank[i]) + 1; //更新 update[i] 節點的 level[i] 層的跨度 span
    }

    for (i = level; i < zsl->level; i++) { //當新插入節點的層高比跳躍表的層高小時,須要更新少的幾層的 update[] 節點的跨度,即 +1
        update[i]->level[i].span++;
    }

    x->backward = (update[0] == zsl->header) ? NULL : update[0]; //更新 x 的 backward 指針,若是 update[0] 是頭結點則爲 NULL,不然爲 update[0]
    if (x->level[0].forward) // 更新 x 節點第 0 層有後續節點,則後面節點的 backward 指向 x 節點,不然的話 x 節點爲最後一個節點,須要將 tail 指針指向 x 節點
        x->level[0].forward->backward = x;
    else
        zsl->tail = x;
    zsl->length++; //跳躍表的長度 +1
    return x;
}
複製代碼

咱們來看一下跳躍表實現過程示意圖:

跳躍表實現過程示意圖

假設咱們想插入一個節點 45。咱們首先須要找到插入的位置,而後再更改由於節點插入致使受影響的位置,好比跳躍表的 level,前一個節點的每層的 forward 指針等等。

在下圖中,我用紅色標出哪些位置受了影響須要修改。

插入節點後跳躍表受影響位置

所以咱們把插入節點的步驟總爲以下幾點:

  1. 查找要插入的位置;
  2. 調整跳躍表高度;
  3. 插入節點,並調整受影響節點每層的 forward 指針和 span;
  4. 調整 backward。

如今咱們來思考以下幾個問題:

  1. 爲何須要先查找要插入的位置,而後再調整跳躍表的高度?

    由於咱們是根據跳躍表高度來查找節點的,首先咱們要找到最高的一層,而後一層一層向下查找,直到找到節點。當新插入的節點的 level 比跳躍表的 level 大的時候,若是先調整跳躍表高度,而後咱們就會以調整後的高度爲起點,而後向後查找,可是該層的 forward 指針指向 NULL,咱們是找不到節點的。

  2. 如何調整受影響節點和新插入節點每層的 forward 指針和 span?

    1. 咱們應該按層來尋找受影響節點,即插入節點以前每層的最後一個節點,受咱們須要把這些節點記錄下來,方便後面修改,代碼中記錄爲 update[] 數組;
    2. 新插入節點每層的 forward 指針指向該層前一個節點的 forward 指針指向的節點;
    3. 每層受影響節點的 forward 指針則指向新插入的節點;
    4. 咱們須要利用 span 的值,須要可以計算 update[] 節點與新插入節點 X 之間的距離,這個很差算的話,咱們就計算 update[] 節點與新插入節點 X 的前一個節點 X-1 之間的距離,再加 1 就是到 X 的距離。
      1. 這個距離怎麼算呢,咱們能夠以 header 爲基準,計算 update[] 節點到 header 節點之間的距離,相減就獲得了 update[i] 與 update[0] 之間的距離。

按照上述思路,接下來讓咱們逐步研究插入節點代碼。

變量定義

zskiplistNode *update[ZSKIPLIST_MAXLEVEL], *x;
    unsigned int rank[ZSKIPLIST_MAXLEVEL];
    int i, level;
複製代碼

定義兩個數組:

  • update[]:插入節點時,須要更新被插入節點每層的前一個節點。因爲每層更新的節點不同,因此講每層須要更新的節點記錄在 update[i] 中。
  • rank[]:記錄當前層從 header 節點到 update[i] 節點所經歷的步長,在更新 update[i] 的 span 和設置新插入節點的 span 時用到。

查找插入位置

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 &&
                    sdscmp(x->level[i].forward->ele,ele) < 0)))
        {
            rank[i] += x->level[i].span;
            x = x->level[i].forward;
        }
        update[i] = x;
    }
複製代碼

按照上述代碼邏輯,值爲 2五、3五、45 的節點查找插入位置的查找路線以下圖所示:

節點查找路線

接下來咱們一步一步分析代碼。

for (i = zsl->level-1; i >= 0; i--) 
複製代碼

for 循環的起始值爲 zsl->level-1,正驗證了上面咱們所說的,節點查詢要從最高層開始查找,查找不到再從下一層開始查詢。

rank[i] = i == (zsl->level-1) ? 0 : rank[i+1];
複製代碼

rank[] 數組的做用是記錄當前層從 header 節點到 update[i] 節點所經歷的步長。

從上圖咱們能夠看到,節點查找路線是 「右->下->右->下」 這種的。

在最高層的時候,咱們的起始位置確定是 header 節點,此時該節點與 header 節點之間的距離爲 0,因此 rank[zsl->level-1] 的值確定爲 0。

當咱們向下層走的時候,其實是從上面一層查到的最後一個節點下來的,好比上圖中查找值爲 45 的節點的時候,當咱們從第四層下到第三層的時候,是從 41 節點開始查的,rank[2] 的值同第四層的值 rank[3]。

while (x->level[i].forward &&
                (x->level[i].forward->score < score ||
                    (x->level[i].forward->score == score &&
                    sdscmp(x->level[i].forward->ele,ele) < 0)))
        {
            rank[i] += x->level[i].span;
            x = x->level[i].forward;
        }
        update[i] = x;
複製代碼

這段代碼說明了咱們尋找節點插入位置的兩條比較原則:

  1. 當前節點本層存在下一個節點 && 下一節點的評分小於待插入節點的評分
  2. 當前節點本層存在下一個節點 && 下一節點的評分等於待插入節點的評分 && 下一節點的值的字段排序小於待插入節點的值的字典排序

即咱們提到的,評分不相等時比較評分,評分相等值比較值的字典排序,不會出現兩個都相等的狀況。

接着記錄步長 rank[i]rank[i] 的值即爲當前節點的步長 rank[i] 加上該節點到下一節點的跨度 x->level[i].span

節點向前移動到下一個節點。

當一層走完循環以後,此時應該知足兩種狀況:

  1. x->forward == NULL
  2. x->forward != NULL && (x->forward.score > score || (x->forward.score == score && sdscmp(x->level[i].forward->ele,ele) > 0))

此時咱們應該向從下一層開始尋找了,那麼咱們應該記住受影響的節點,也是插入節點每層的前一個節點 update[i] = x

循環直到第一層結束,此時咱們已經找到了要插入的位置,並將插入節點每層的前一個節點記錄在 update[] 數組中,並將 update[] 數組中每一個節點到 header 節點所經歷的步長也記錄了下來。

咱們以 length=3 level=2 的一個跳躍表插入節點爲例,update 和 rank 賦值後跳躍表以下:

update 和 rank 賦值後的跳躍表

獲取新節點層高

level = zslRandomLevel();
複製代碼

每一個節點的層高是隨機生成的,即所謂的 機率平衡,而不是 強制平衡,所以,對於插入和刪除節點比傳統上的平衡樹算法更爲簡潔高效

生成方法以下:

int zslRandomLevel(void) {
    int level = 1;
    while ((random()&0xFFFF) < (ZSKIPLIST_P * 0xFFFF)) //ZSKIPLIST_P=0.25
        level += 1;
    return (level<ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL;
}
複製代碼

上述代碼中,level 的初始值爲 1,經過 while 循環,每次生成一個隨機值,取這個值的低 16 位做爲 x,當 x 小於 0.25 倍的 0XFFFFFF 時,level 的值加 1;不然退出 while 循環,最終返回 level 和 ZSKIPLIST_MAXLEVEL 二者中的最小值。

下面計算節點的指望層高。假設 p = ZSKIPLIST_P;

  1. 節點層高爲 1 的機率爲 (1-p)。
  2. 節點層高爲 2 的機率爲 p(1-p)。
  3. 節點層高爲 3 的機率爲 p^2(1-p)。
  4. ……
  5. 節點層高爲 n 的機率爲 p^n-1(1-p)。

因此節點的指望層高爲:

跳躍表節點層數生成機率

當 p=0.25 時,跳躍表節點的指望層高爲 1/(1-0.25)≈1.33。

更新跳躍表層高以及 update[]、rank[] 數組

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;
    }
複製代碼

只有當待插入節點的層高比當前跳躍表的層高大時,纔會進行該操做。

zsl->level = level; 跳躍表的層高賦值爲最高的層高,這是沒有問題的。

咱們接着以該圖爲例:

update 和 rank 賦值後的跳躍表

第 0 層和第 1 層咱們已經更新過了,所以咱們只須要從未更新過的層開始便可,即 i = zsl->level;,從第 2 層開始。第 2 層只須要更新 header 節點,因此 update[i] = zsl->header。而 rank[i] 則爲 0。

update[2]->level[2].span 的值先賦值爲跳躍表的總長度,後續在計算新插入節點 level[2]span 時會用到此值。在更新完新插入節點 level[2]span 以後會對 update[2]->level[2].span 的值進行從新計算賦值。

至於爲何將 update[2]->level[2].span 的值設置爲跳躍表的總長度,咱們能夠從 span 的定義來思考。span 的含義是 forward 指向的節點與本節點之間的元素個數。而 update[2]->level[2].forward 指向的是 NULL 節點,中間隔着的是跳躍表的全部節點,所以賦值爲跳躍表的總長度。

調整高度後的跳躍表以下圖所示:

調整高度後的跳躍表

插入節點

x = zslCreateNode(level,score,ele);
    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++;
    }
複製代碼

forward 值的修改很好理解,就是簡單的鏈表插入節點。

那麼如何理解 update[i]->level[i].span - (rank[0] - rank[i])(rank[0] - rank[i]) + 1 呢?

咱們對照下圖來深刻理解一下 span 賦值過程。

span 賦值示意圖

首先,咱們應該對一下幾點有所瞭解:

  1. rank[i] 表示當前層從 header 節點到 update[i] 節點所經歷的步長
  2. rank[0] 表示第 0 層 從 header 節點到 update[0] 節點所經歷的步長,上圖 rank[0] = 2。
  3. rank[1] 表示第 1 層 從 header 節點到 update[1] 節點所經歷的步長,上圖 rank[1] = 1。
  4. level[0] 中的 span 應該總爲 1(能夠理解爲 1 指的是包括 forward 指向的節點,不包括自己節點)

咱們以 update[1] 節點舉例,其餘節點原理也是如此。

因此,rank[0] - rank[1] 實際上就是節點 update[1]update[0] 的距離(score=1 的節點到 score=21 的節點的距離)

update[1]->level[1].span 的值表示在第一層 update[1] 節點與指向的節點之間的跨度,從上圖咱們能夠看到,這段距離中包含 update[1]update[0] 的距離,剩下的距離就是 新插入節點到 update[1]->level[1].forward 節點之間的距離

由於新插入的節點是在 update[0] 後面插入的,所以 update[0]新插入節點 之間的距離爲 1,rank[0] - rank[1] + 1 即爲 update[1]->level[1].span 的值。

咱們把問題抽象化一下:

假設有節點 A 和 B,在他們中間插入 X,

  • rank[0] - rank[i] 計算的就是 A 到 X 的前一個節點 X-1 的距離;
  • update[i]->level[i].span 計算的就是 A 到 B 的距離;
  • update[i]->level[i].span - (rank[0] - rank[i]) 計算的就是 X 到 B 的距離。
  • update[i]->level[i].span = (rank[0] - rank[i]) + 1 計算的是 A 到 X-1 再 +1,表示的是 A 到 X 的距離。

計算的原則是 左開右閉

按照上述算法,咱們來實際走一遍插入過程。level 的值爲 3,因此能夠執行三次 for 循環,插入過程以下:

  1. 第一次 for 循環

    1. x 的 level[0] 的 forward 爲 update[0] 的 level[0] 的 forward 節點,即 x->level[0].forward 爲 score=41 的節點。
    2. update[0] 的 level[0] 的下一個節點爲新插入的節點。
    3. rank[0]-rank[0]=0,update[0]->level[0].span=1,因此 x->level[0].span=1。
    4. update[0]->level[0].span=0+1=1。

    插入節點並更新第 0 層後的跳躍表以下圖所示:

    插入節點後並更新第 0 層後的跳躍表

  2. 第二次 for 循環

    1. x 的 level[1] 的 forward 爲 update[1] 的 level[1] 的 forward 節點,即 x->level[1].forward 爲 NULL。
    2. update[1] 的 level[1] 的下一個節點爲新插入的節點。
    3. rank[0]-rank[1]=1,update[1]->level[1].span=2,因此 x->level[1].span=1。
    4. update[1]->level[1].span=1+1=2。

    插入節點並更新第 1 層後的跳躍表以下圖所示:

    插入節點後並更新第 1 層後的跳躍表

  3. 第三次 for 循環

    1. x 的 level[2] 的 forward 爲 update[2] 的 level[2] 的 forward 節點,即 x->level[2].forward 爲 NULL。
    2. update[2] 的 level[2] 的下一個節點爲新插入的節點。
    3. rank[0]-rank[2]=2,由於 update[2]->level[2].span=3,因此 x->level[2].span=1。
    4. update[2]->level[2].span=2+1=3。

    插入節點並更新第 2 層後的跳躍表以下圖所示:

    插入節點後並更新第 2 層後的跳躍表

新插入節點的高度大於原跳躍表高度,因此下面代碼不會運行。但若是新插入節點的高度小於原跳躍表高度,則從 level 到 zsl->level-1 層的 update[i] 節點 forward 不會指向新插入的節點,因此不用更新 update[i] 的 forward 指針,只將這些 level 層的 span 加 1 便可。

for (i = level; i < zsl->level; i++) {
        update[i]->level[i].span++;
    }
複製代碼

調整 backward

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;
複製代碼

根據 update 的賦值過程,新插入節點的前一個節點必定是 update[0],因爲每一個節點的後退指針只有一個,與此節點的層數無關,因此當插入節點不是最後一個節點時,須要更新被插入節點的 backward 指向 update[0]。若是新插入節點是最後一個節點,則須要更新跳躍表的尾結點爲新插入節點。插入及誒單後,更新跳躍表的長度加 1.

插入新節點後的跳躍表以下圖所示:

插入新節點後的跳躍表

刪除節點

有了上面插入節點的學習,對於節點的刪除,咱們應該更容易理解了。

咱們把刪除節點簡單的分爲兩步:

  1. 查找須要刪除的節點;
  2. 設置 span 和 forward。

刪除節點代碼以下:

void zslDeleteNode(zskiplist *zsl, zskiplistNode *x, zskiplistNode **update) {
    int i;
    for (i = 0; i < zsl->level; i++) {
        if (update[i]->level[i].forward == x) { // update[i].level[i] 的 forward 節點是 x 的狀況,須要更新 span 和 forward
            update[i]->level[i].span += x->level[i].span - 1;
            update[i]->level[i].forward = x->level[i].forward;
        } else {// update[i].level[i] 的 forward 節點不是 x 的狀況,只須要更新 span
            update[i]->level[i].span -= 1;
        }
    }
    if (x->level[0].forward) { // 若是 x 不是尾節點,更新 backward 節點
        x->level[0].forward->backward = x->backward;
    } else { // 不然 更新尾節點
        zsl->tail = x->backward;
    }
    while(zsl->level > 1 && zsl->header->level[zsl->level-1].forward == NULL)
        zsl->level--; //更新跳躍表 level
    zsl->length--; // 更新跳躍表長度
}
複製代碼

查找須要刪除的節點

查找須要刪除的節點要藉助 update 數組,數組的賦值方式與 插入節點 中的 update 的賦值方式相同,再也不贅述。查找完畢以後,update[2]=header,update[1] 爲 score=1 的節點,update[0] 爲 score=21 的節點。刪除節點前的跳躍表以下圖所示:

刪除節點前的跳躍表

設置 span 和 forward

設置 span 和 forward 的代碼以下:

for (i = 0; i < zsl->level; i++) {
        if (update[i]->level[i].forward == x) {
            update[i]->level[i].span += x->level[i].span - 1;
            update[i]->level[i].forward = x->level[i].forward;
        } else {
            update[i]->level[i].span -= 1;
        }
    }
複製代碼

咱們先來看 span 的賦值過程。刪除節點時 span 的賦值以下圖所示:

刪除節點時 span 賦值解釋

假設咱們想要刪除 score=21 的節點,那麼 update[0] 和 update[1] 應該爲 score=1 的節點,update[2] 應該爲頭節點。

現更新節點的 span 和 forward 分爲兩種狀況:

  1. update[i] 的第 i 層的 forward 節點指向 x(如上圖 update[0]->level[0])

    1. update[0].level[0].span 是 update[0] 到 x 的距離;
    2. x.level[0].span 是 x 到 x.level[0].forward 之間的距離;
    3. update[0].level[0].span + x.level[0].span 是 update[0] 到 x.level[0].forward 之間的距離;
    4. update[0].level[0].span + x.level[0].span - 1 是刪除 x 節點後 update[0] 到 x.level[0].forward 之間的距離;
    5. update[0].level[0].forward 即爲 x.level[0].forward。
  2. update[i] 的第 i 層的 forward 節點指向 x(如上圖 update[1]->level[1])

    1. 此時 update[i].level[i].forward 指向 x 節點後面的節點或 NULL;
    2. 說明 update[i] 的層高比 x 節點的層高高,所以不須要修改 forward 值,只須要將 span - 1 便可。

設置 span 和 forward 後的跳躍表以下圖所示:

設置 span 和 forward 後的跳躍表

update 節點更新完畢以後,須要更新 backward 指針、跳躍表高度和長度、若是 x 不爲最後一個節點,之間將第 0 層後一個節點的 backward 賦值爲 x 的backward 便可;不然,將跳躍表的尾指針指向 x 的 backward 節點便可。代碼以下:

if (x->level[0].forward) {
        x->level[0].forward->backward = x->backward;
    } else {
        zsl->tail = x->backward;
    }
複製代碼

當刪除的 x 節點是跳躍表的最高節點,而且沒有其餘節點與 x 節點的高度相同時,須要將跳躍表的高度減 1。

因爲刪除了一個節點,跳躍表的長度須要減 1。

刪除節點後的跳躍表以下圖所示:

刪除節點後的跳躍表

刪除跳躍表

刪除跳躍表就比較簡單了。獲取到跳躍表對象以後,從頭節點的第 0 層開始,經過 forward 指針逐步向後遍歷,沒遇到一個節點便將其釋放內存。當全部節點的內存都被釋放以後,釋放跳躍表對象,即完成了跳躍表的刪除操做。代碼以下

void zslFree(zskiplist *zsl) {
    zskiplistNode *node = zsl->header->level[0].forward, *next;

    zfree(zsl->header);
    while(node) {
        next = node->level[0].forward;
        zslFreeNode(node);
        node = next;
    }
    zfree(zsl);
}
複製代碼

跳躍表的應用

在 Redis 中,跳躍表主要應用於有序集合的底層實現(有序集合的另外一種實現方式爲壓縮列表)。

redis.conf 有關於有序集合底層實現的兩個配置:

zset-max-ziplist-entries 128 // zset 採用壓縮列表時,元素個數最大值。默認值爲 128。
zset-max-ziplist-value 64 // zset 採用壓縮列表時,每一個元素的字符串長度最大值,默認爲 64。
複製代碼

zset 添加元素的主要邏輯位於 t_zset.czaddGenericCommand 函數中。zset 插入第一個元素時,會判斷下面兩種條件:

  • zset-max-ziplist-entries 的值是否等於 0;
  • zset-max-ziplist-value 小於要插入元素的字符串長度。

知足任一條件 Redis 就會採用跳躍表做爲底層實現,不然採用壓縮列表做爲底層實現方式。

if (server.zset_max_ziplist_entries == 0 ||
            server.zset_max_ziplist_value < sdslen(c->argv[scoreidx+1]->ptr))
        {
            zobj = createZsetObject(); //建立跳躍表結構
        } else {
            zobj = createZsetZiplistObject(); //建立壓縮列表結構
        }
複製代碼

通常狀況下,不會將 zset_max_ziplist_entries 配置成 0,元素的字符串長度也不會太長,因此在建立有序集合時,默認是有壓縮列表的底層實現。zset 新插入元素時,會判斷如下兩種條件:

  • zset 中元素個數大於 zset_max_ziplist_entries
  • 插入元素的字符串的長度大於 zset_max_ziplist_value

當慢如任一條件時,Redis 便會將 zset 的底層實現由壓縮列表轉爲跳躍表,代碼以下:

if (zzlLength(zobj->ptr) > server.zset_max_ziplist_entries ||
                sdslen(ele) > server.zset_max_ziplist_value)
                zsetConvert(zobj,OBJ_ENCODING_SKIPLIST);
複製代碼

值得注意的是,zset 在轉爲跳躍表以後,即便元素被逐漸刪除,也不會從新轉爲壓縮列表。

總結

本章咱們介紹了跳躍表的演變過程、基本原理、和實現過程。

演變過程就是在鏈表的基礎上,間隔抽取一些點,在上層造成一個新的鏈表,相似於二分法,達到時間減半的效果,可是又不一樣於二分法,由於新插入的節點的層高是隨機生成的,即所謂的 機率平衡,這樣保證了跳躍表的查詢、插入、刪除的平均複雜度都爲 O(logN)。

跳躍表的實現過程,咱們着重講了插入節點,其中咱們引入了兩個數組,update[] 和 rank[] 數組,咱們須要對這兩個數組特別理解,才能理解插入過程。

看到這了,咱們不妨問本身幾個問題:

  1. 什麼是跳躍表?跳躍表是如何從有序鏈表演化過來?時間複雜度是多少?
  2. 跳躍表是如何維持鏈表的平衡的?(關鍵點:隨機函數產生層數,層數越高几率越低)
  3. 跳躍表是如何插入節點的?(關鍵點:update[] 和 rank[] 數組,update[] 數組記錄插入節點前每層的節點,rank[] 數組記錄頭結點到 update[] 節點之間的距離)
  4. 跳躍表的結構?(關鍵點:length、level、header、tail)
  5. 跳躍表節點結構?(關鍵點:score、backward、ele、level)
  6. redis 中 zset 的實現用到了哪些數據結構?何時用到壓縮列表?何時用到跳躍表?(關鍵點:entries=0,value>128)
  7. redis 爲何選擇跳躍表而不選擇其餘平衡結構?(關鍵點:效率堪比紅黑樹,實現卻更簡單)

若是你們可以對這些問題解答出來,相信你們已經對跳躍表瞭如指掌了。

參考文檔

相關文章
相關標籤/搜索