我們都知道單鏈表有一個致命的弱點,查找任一節點都至少 O(n) 的時間復雜度,它需要遍歷一遍整個鏈表,那么有沒有辦法提升鏈表的搜索效率?
跳躍表(SkipList)這種數據結構使用空間換時間的策略,通過給鏈表建立多層索引來加快搜索效率,我們先介紹跳躍表的基本理論,再來看看 redis 中的實現情況。
一、跳躍表(SkipList)

這是一條帶哨兵的雙端鏈表,大部分場景下的鏈表都是這種結構,它的好處是,無論是頭插法還是尾插法,插入操作都是常量級別的時間復雜度,刪除也是一樣。但缺點就是,如果想要查詢某個節點,則需要 O(n)。
那如果我們給鏈表加一層索引呢?當然前提是最底層的鏈表是有序的,不然索引也沒有意義了。

讓 HEAD 頭指針指向最高索引,我抽出來一層索引,這樣即便你查找節點 2222 三次比較。
第一次:與 2019 節點比較,發現大於 2019,往后繼續
第二次:與 2100 節點比較,發現依然大於,往后繼續
第三次:本層索引到頭了,指向低層索引的下一個節點,繼續比較,找到節點
而無索引的鏈表需要四次,效率看起來不是很明顯,但是隨着鏈表節點數量增多,索引層級增多,效率差距會很明顯。圖就不自己畫了,取自極客時間王爭老師的一張圖。

你看,原本需要 62 次比較操作,通過五層索引,只需要 4 次比較,跳躍表的效率可見一瞥。
想要知道具體跳躍表與鏈表差距多少,我們接下來進行它們各個操作的時間復雜度分析對比。
1、插入節點操作
雙端鏈表(以下我們簡稱鏈表)的原本插入操作是 O(1) 的時間復雜度,但是這里我們討論的是有序鏈表,所以插入一個節點至少還要找到它該插入的位置,然后才能執行插入操作,所以鏈表的插入效率是 O(n)。
跳躍表(以下我們簡稱跳表)也依然是需要兩個步驟才能完成插入操作,先找到該插入的位置,再進行插入操作。我們設定一個具有 N 個節點的鏈表,它建有 K 層索引並假設每兩個節點間隔就向上分裂一層索引。
k 層兩個節點,k-1 層 4 個節點,k-2 層 8 個節點 ... 第一層 n 個節點,
1:n
2:1/2 * n
3:1/2^2 * n
.....
.....
k:1/2^(k-1) * n
1/2^(k-1) * n 表示第 k 層節點數,1/2^(k-1) * n=2 可以得到,k 等於 logn,也就是說 ,N 個節點構建跳表將需要 logn 層索引,包括自身那層鏈表層。
而當我們要搜索某個節點時,需要從最高層索引開始,按照我們的構建方式,某個節點必然位於兩個索引節點之間,所以每一層都最多訪問三個節點。這一點你可能需要理解理解,因為每一層索引的搜索都是基於上一層索引的,從上一層索引下來,要么是大於(小於)當前的索引節點,但不會大於(小於)其往后兩個位置的節點,也就是當前索引節點的上一層后一索引節點,所以它最多訪問三個節點。
有了這一結論,我們向跳表中插入一個元素的時間復雜度就為:O(logn)。這個時間復雜度等於二分查找的時間復雜度,所有有時我們又稱跳表是實現了二分查找的鏈表。
很明顯,插入操作,跳表完勝鏈表。
2、修改刪除查詢
這三個節點操作其實沒什么可比性,修改刪除操作,鏈表等效於跳表。而查詢,我們上面也說了,鏈表至少 O(n),跳表在 O(logn)。
除此之外,我們都知道紅黑樹在每次插入節點后會自旋來進行樹的平衡,那么跳表其實也會有這么一個問題,就是不斷的插入,會導致底層鏈表節點瘋狂增長,而索引層依然那么多,極端情況所有節點都新增到最后一級索引節點的右邊,進而使跳表退化成鏈表。
簡單一句話來說,就是大量的節點插入之后,而不更新索引的話,跳表將無法一如既往的保證效率。解決辦法也很簡單,就是每一次節點的插入,觸發索引節點的更新,我們具體來看一下更新策略。
一般跳表會使用一個隨機函數,這個隨機函數會在跳表新增了一個節點后,根據跳表的目前結構生成一個隨機數,這個數值當然要小於最大的索引層值,假定這個值等於 m,那么跳表會生成從 1 到 m 層的索引。所以這個隨機函數的選擇或者說實現就顯得很重要了,關於它我們這里不做討論,大家可以看看各種跳表的實現中是如何實現這個隨機函數的,典型的就是 Java 中 ConcurrentSkipListMap 內部實現的 SkipList 結構,當然還有我們馬上要介紹的 redis 中的實現。
以上就是跳表這種數據結構的基本理論內容,接下來我們看 redis 中的實現情況。
二、Redis 中的跳躍表
說在前面的是,redis 自己實現了跳表,但目的是為它的有序集合等高層抽象數據結構提供服務,所以等下我們分析源代碼的時候其中必然會涉及到一些看似無用的結構和代碼邏輯,但那些也是非常重要的,我們也會提及有序集合相關的內容,但不會拆分細致,重點還是看跳表的實現。
跳表的數據結構定義如下:
typedef struct zskiplist {
struct zskiplistNode *header, *tail;
unsigned long length;
int level;
} zskiplist;
跳表中的每個節點用數據結構 zskiplistNode 表示,head 和 tail 分別指向最底層鏈表的頭尾節點。length 表示當前跳表最底層鏈表有多少個節點,level 記錄當前跳表最高索引層數。
zskiplistNode 結構如下:
typedef struct zskiplistNode {
sds ele;
double score;
struct zskiplistNode *backward;
struct zskiplistLevel {
struct zskiplistNode *forward;
unsigned int span;
} level[];
} zskiplistNode;
我這里摘取的 redis 源碼是 4.0 版本的,以前版本 ele 屬性是一個 RedisObject 類型,現在是一個字符串類型,也即表示跳表現在只用於存儲字符串數據。
score 記錄當前節點的一個分值,最底層的鏈表就是按照分值大小有序的串聯的,並且我們查詢一個節點,一般也會傳入該節點的 score 值,畢竟數值類型比較起來方便。
backward 指針指向前一個節點,為什么是倒着往前,我們待會會說。
level 是比較關鍵的一個點,這里面是一個 level 數組,而每個元素又都是一個 zskiplistLevel 類型的結構,zskiplistLevel 類型包括一個 forward 前向指針,一個 span 跨度值,具體是什么意思,我們一點點說。
跳表理論上在最底層是一條雙端鏈表,然后基於此建立了多層索引節點以實現的,但在實際的代碼實現上,這種結構是不好表述的,所以你要打破既有的慣性思維,然后才能好理解 redis 中的實現。實際上正如我們上述介紹的 zskiplistNode 結構一樣,每個節點除了存儲節點自身的數據外,還通過 level 數組保存了該節點在整個跳表各個索引層的節點引用,具體結構就是這樣的:

而整張跳表基本就是這樣的結構:

每一個節點的 backward 指針指向自己前面的一個節點,而每個節點中的 level 數組記錄的就是當前節點在跳表的哪些索引層出現,並通過其 forward 指針順序串聯這一層索引的各個節點,0 表示第一層,1 表示第二層,等等以此類推。span 表示的是當前節點與后面一個節點的跨度,我們等下還會在代碼里說到,暫時不理解也沒關系。
基本上跳表就是這樣一個結構,上面那張圖還是很重要的,包括我們等下介紹源碼實現,也對你理解有很大幫助的。(畢竟我畫了半天。。)
這里多插一句,與跳表相關結構定義在一起的還有一個有序集合結構,很多人會說 redis 中的有序集合是跳表實現的,這句話不錯,但有失偏駁。
typedef struct zset {
dict *dict;
zskiplist *zsl;
} zset;
准確來說,redis 中的有序集合是由我們之前介紹過的字典加上跳表實現的,字典中保存的數據和分數 score 的映射關系,每次插入數據會從字典中查詢,如果已經存在了,就不再插入,有序集合中是不允許重復數據。
下面我們看看 redis 中跳表的相關代碼的實現情況。
1、跳表初始化
redis 中初始化一個跳表的代碼如下:
zskiplistNode *zslCreateNode(int level, double score, sds ele) {
zskiplistNode *zn =
zmalloc(sizeof(*zn)+level*sizeof(struct zskiplistLevel));
zn->score = score;
zn->ele = ele;
return zn;
}
/* Create a new skiplist. */
zskiplist *zslCreate(void) {
int j;
zskiplist *zsl;
//分配內存空間
zsl = zmalloc(sizeof(*zsl));
//默認只有一層索引
zsl->level = 1;
//0 個節點
zsl->length = 0;
//1、創建一個 node 節點,這是個哨兵節點
//2、為 level 數組分配 ZSKIPLIST_MAXLEVEL=32 內存大小
//3、也即 redis 中支持索引最大 32 層
zsl->header = zslCreateNode(ZSKIPLIST_MAXLEVEL,0,NULL);
//為哨兵節點的 level 初始化
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;
}
zslCreate 用於初始化一個跳表,比較簡單,我也給出了基本的注釋,這里不再贅述了,強調一點的是,redis 中實現的跳表最高允許 32 層索引,這么做也是一種性能與內存之間的衡量,過多的索引層必然占用更多的內存空間,32 是一個比較合適值。
2、插入一個節點
插入一個節點的代碼比較多,也稍微有點復雜,希望你也有耐心和我一起來分析。
zskiplistNode *zslInsert(zskiplist *zsl, double score, sds ele) {
//update數組將用於記錄新節點在每一層索引的目標插入位置
zskiplistNode *update[ZSKIPLIST_MAXLEVEL], *x;
//rank數組記錄目標節點每一層的排名
unsigned int rank[ZSKIPLIST_MAXLEVEL];
int i, level;
serverAssert(!isnan(score));
//指向哨兵節點
x = zsl->header;
//這一段就是遍歷每一層索引,找到最后一個小於當前給定score值的節點
//從高層索引向底層索引遍歷
for (i = zsl->level-1; i >= 0; i--) {
//rank記錄的是節點的排名,正常情況下給它初始值等於上一層目標節點的排名
//如果當前正在遍歷最高層索引,那么這個初始值暫時給0
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)))
{
//我們說過level結構中,span表示的是與后面一個節點的跨度
//rank[i]最終會得到我們要找的目標節點的排名,也就是它前面有多少個節點
rank[i] += x->level[i].span;
//挪動指針
x = x->level[i].forward;
}
update[i] = x;
}
//至此,update數組中已經記錄好,每一層最后一個小於給定score值的節點
//我們的新節點只需要插在他們后即可
//random算法獲取一個平衡跳表的level值,標志着我們的新節點將要在哪些索引出現
//具體算法這里不做分析,你也可以私下找我討論
level = zslRandomLevel();
//如果產生值大於當前跳表最高索引
if (level > zsl->level) {
//為高出來的索引層賦初始值,update[i]指向哨兵節點
for (i = zsl->level; i < level; i++) {
rank[i] = 0;
update[i] = zsl->header;
update[i]->level[i].span = zsl->length;
}
zsl->level = level;
}
//根據score和ele創建節點
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;
//rank[0]等於新節點再最底層鏈表的排名,就是它前面有多少個節點
//update[i]->level[i].span記錄的是目標節點與后一個索引節點之間的跨度,即跨越了多少個節點
//得到新插入節點與后一個索引節點之間的跨度
x->level[i].span = update[i]->level[i].span - (rank[0] - rank[i]);
//修改目標節點的span值
update[i]->level[i].span = (rank[0] - rank[i]) + 1;
}
//如果上面產生的平衡level大於跳表最高使用索引,我們上面說會為高出部分做初始化
//這里是自增他們的span值,因為新插入了一個節點,跨度自然要增加
for (i = level; i < zsl->level; i++) {
update[i]->level[i].span++;
}
//修改 backward 指針與 tail 指針
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;
}
整個方法我都已經給出了注釋,具體的不再細說,歡迎你與我交流討論,整體的邏輯分為三個步驟。
- 從最高索引層開始遍歷,根據 score 找到它的前驅節點,用 update 數組進行保存
- 每一層得進行節點的插入,並計算更新 span 值
- 修改 backward 指針與 tail 指針
刪除節點也是類似的,首先需要根據 score 值找到目標節點,然后斷開前后節點的連接,完成節點刪除。
3、特殊的查詢操作
因為 redis 的跳表實現中,增設了 span 這個跨度字段,它記錄了與當前節點與后一個節點之間的跨度,所以就具有以下一些查詢方法。
a、zslGetRank
返回包含給定成員和分值的節點在跳躍表中的排位。
unsigned long zslGetRank(zskiplist *zsl, double score, sds ele) {
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 &&
sdscmp(x->level[i].forward->ele,ele) <= 0))) {
rank += x->level[i].span;
x = x->level[i].forward;
}
/* x might be equal to zsl->header, so test if obj is non-NULL */
if (x->ele && sdscmp(x->ele,ele) == 0) {
return rank;
}
}
return 0;
}
你會發現,這個方法的核心代碼其實就是我們插入節點方法的一個部分,通過累計 span 得到目標節點的一個排名值。
b、zslGetElementByRank
通過給定排名查詢元素。這個方法就更簡單了。
c、zslIsInRange
給定一個分值范圍(range), 比如 0 到 10, 如果給定的分值范圍包含在跳躍表的分值范圍之內, 那么返回 1 ,否則返回 0 。
d、zslFirstInRange
給定一個分值范圍, 返回跳躍表中第一個符合這個范圍的節點。
e、zslDeleteRangeByScore
給定一個分值范圍, 刪除跳躍表中所有在這個范圍之內的節點。
f、zslDeleteRangeByRank
給定一個排名范圍, 刪除跳躍表中所有在這個范圍之內的節點。
其實,后面列出來的那些根據排名,甚至一個范圍查詢刪除節點的方法,都仰仗的是 span 這個字段,這也是為什么 insert 方法中需要通過那么復雜的計算邏輯對 span 字段進行計算的一個原因。
總結一下,跳表是為有序集合服務的,通過多層索引把鏈表的搜索效率提升到 O(logn)級別,但修改刪除依然是 O(1),是一個較為優秀的數據結構,而 redis 中的實現把每個節點實現成類似樓房一樣的結構,也即我們的索引層,非常的巧妙。
關於跳表我們暫時介紹到這,如果有疑問也非常歡迎你與我交流討論。

