1. 程式人生 > 資料庫 >詳解Redis資料結構之跳躍表

詳解Redis資料結構之跳躍表

1、簡介

我們先不談Redis,來看一下跳錶。

1.1、業務場景

場景來自小灰的演算法之旅,我們需要做一個拍賣行系統,用來查閱和出售遊戲中的道具,類似於魔獸世界中的拍賣行那樣,還有以下需求:

拍賣行拍賣的商品需要支援四種排序方式,分別是:按價格、按等級、按剩餘時間、按出售者ID排序,排序查詢要儘可能地快。還要支援輸入道具名稱的精確查詢和不輸入名稱的全量查詢。

這樣的業務場景所需要的資料結構該如何設計呢?拍賣行商品列表是線性的,最容易表達線性結構的是陣列和連結串列。假如用有序陣列,雖然查詢的時候可以使用二分法(時間複雜度O(logN)),但是插入的時間複雜度是O(N),總體時間複雜度是O(N);而如果要使用有序連結串列,雖然插入的時間複雜度是O(1),但是查詢的時間複雜度是O(N),總體還是O(N)。

那有沒有一種資料結構,查詢時,有二分法的效率,插入時有連結串列的簡單呢?有的,就是 跳錶。

1.2、skiplist

skiplist,即跳錶,又稱跳躍表,也是一種資料結構,用於解決演算法問題中的查詢問題。

一般問題中的查詢分為兩大類,一種是基於各種平衡術,時間複雜度為O(logN),一種是基於雜湊表,時間複雜度O(1)。但是skiplist比較特殊,沒有在這裡面

2、跳錶

2.1、跳錶簡介

跳錶也是連結串列的一種,是在連結串列的基礎上發展出來的,我們都知道,連結串列的插入和刪除只需要改動指標就行了,時間複雜度是O(1),但是插入和刪除必然伴隨著查詢,而查詢需要從頭/尾遍歷,時間複雜度為O(N),如下圖所示是一個有序連結串列(最左側的灰色表示一個空的頭節點)(圖片來自網路,以下同):

詳解Redis資料結構之跳躍表

連結串列中,每個節點都指向下一個節點,想要訪問下下個節點,必然要經過下個節點,即無法跳過節點訪問,假設,現在要查詢22,我們要先後查詢 3->7->11->19->22,需要五次查詢。

但是如果我們能夠實現跳過一些節點訪問,就可以提高查詢效率了,所以對連結串列進行一些修改,如下圖:

詳解Redis資料結構之跳躍表

我們每個一個節點,都會儲存指向下下個節點的指標,這樣我們就能跳過某個節點進行訪問,這樣,我們其實是構造了兩個連結串列,新的連結串列之後原來連結串列的一半。

我們姑且稱原連結串列為第一層,新連結串列為第二層,第二層是在第一層的基礎上隔一個取一個。假設,現在還是要查詢22,我們先從第二層查詢,從7開始,7小於22,再往後,19小於22,再往後,26大於22,所以從節點19轉到第一層,找到了22,先後查詢 7->19->26->22,只需要四次查詢。

以此類推,如果再提取一層連結串列,查詢效率豈不是更高,如下圖:

詳解Redis資料結構之跳躍表

現在,又多了第三層連結串列,第三層是在第二層的基礎上隔一個取一個,假設現在還是要查詢22,我們先從第三層開始查詢,從19開始,19小於22,再往後,發現是空的,則轉到第二層,19後面的26大於22,轉到第一層,19後面的就是22,先後查詢 19->26>22,只需要三次查詢。

由上例可見,在查詢時,跳過多個節點,可以大大提高查詢效率,skiplist 就是基於此原理。

上面的例子中,每一層的節點個數都是下一層的一半,這種查詢的過程有點類似二分法,查詢的時間複雜度是O(logN),但是例子中的多層連結串列有一個致命的缺陷,就是一旦有節點插入或者刪除,就會破壞這種上下層連結串列節點個數是2:1的結構,如果想要繼續維持,則需要在插入或者刪除節點之後,對後面的所有節點進行一次重新調整,這樣一來,插入/刪除的時間複雜度就變成了O(N)。

2.2、跳錶層級之間的關係

如上所述,跳錶為了解決插入和刪除節點時造成的後續節點重新調整的問題,引入了隨機層數的做法。相鄰層數之間的節點個數不再是嚴格的2:1的結構,而是為每個新插入的節點賦予一個隨機的層數。下圖展示瞭如何通過一步步的插入操作從而形成一個跳錶:

詳解Redis資料結構之跳躍表

每一個節點的層數都是隨機演算法得出的,插入一個新的節點不會影響其他節點的層數,因此,插入操作只需要修改插入節點前後的指標即可,避免了對後續節點的重新調整。這是跳錶的一個很重要的特性,也是跳錶效能明顯由於平衡樹的原因,因為平衡樹在失去平衡之後也需要進行平衡調整。

上圖最後的跳錶中,我們需要查詢節點22,則遍歷到的節點依次是:7->37->19->22,可見,這種隨機層數的跳錶的查詢時可能沒有2:1結構的效率,但是卻解決了插入/刪除節點的問題。

2.3、跳錶的複雜度

跳錶搜尋的時間複雜度平均 O(logN),最壞O(N),空間複雜度O(2N),即O(N)

3、Redis中的跳錶

在理解 Redis 的跳躍表之前,我們先回憶一下 Redis 的有序集合(sorted set)操作

  • 不重複但有序的字串元素集合;
  • 每個元素均關聯一個double型別的score,Redis 根據score進行從小到大排序;
  • score可以重複,重複的按照插入順序進行排序;

示例如下:

redis 127.0.0.1:6379> ZADD runoobkey 1 redis
(integer) 1
redis 127.0.0.1:6379> ZADD runoobkey 2 mongodb
(integer) 1
redis 127.0.0.1:6379> ZADD runoobkey 3 mysql
(integer) 1
redis 127.0.0.1:6379> ZADD runoobkey 3 mysql
(integer) 0
redis 127.0.0.1:6379> ZADD runoobkey 4 mysql
(integer) 0
redis 127.0.0.1:6379> ZRANGE runoobkey 0 10 WITHSCORES

"redis"
"1"
"mongodb"
"2"
"mysql"
"4"

這個是 Redis 中的有序列表的基本操作,我們答題可以看出,在有序列表中,有一個浮點數作為 score, 當對應一個值,可以根據 score 精確查詢和範圍查詢,且效率很高

Redis 裡面的這種操作的底層實現就是跳錶。

上面理解了跳錶,再去看 Redis 中的跳錶就輕鬆多了,跳錶的實現在 Redis 原始碼目錄下 redis.h 檔案中

3.1、zskiplistNode

zskiplistNode 表示跳錶的一個節點,宣告如下:

typedef struct zskiplistNode {
  robj *obj;
  double score;
  struct zskiplistNode *backward;
  struct zskiplistLevel {
    struct zskiplistNode *forward;
    unsigned int span;
  } level[];
} zskiplistNode;

robj 型別是 Redis 中用C語言實現一種集合資料結構,它可以表示 string、hash、list、set 和 zset 五種資料型別,這裡不做詳細說明,在跳錶節點中,這個型別的指標表示節點的成員物件

score 表示分值,用於排序和範圍查詢

level 是一個柔性陣列,它表示節點的層級,每層都有一個前進指標 forward,用於指向相同層級指向表尾方向的下一個節點,而 span 則表示當前節點在當前層級中距離下一個節點的跨度,即兩個節點之間的距離。

初看上去,很容易以為跨度和遍歷節點有關,實際並不是,遍歷操作只用前進指標就夠了,跨度是用來計算排位(rank)的:在查詢某個節點的過程中,沿途訪問過的所有層的跨度累計起來,就是目標節點在跳錶中的排位。

下圖中,查詢成員o3,只經歷了一層,排位為3

詳解Redis資料結構之跳躍表

在 Redis 中,每個節點的層級都是根據冪次定律(power law,越大的樹出現的概率越小)隨機生成的,它是1~32之間的一個數,作為level陣列的大小,即高度

下圖分別展示了三個高度為1、3、5層的節點

詳解Redis資料結構之跳躍表

backward 是一個後退指標,每個節點都有一個,指向當前節點的表頭方向的下一個節點,用於從表尾進行遍歷

3.2、zskiplist

zskiplist 表示一個跳錶,宣告如下:

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

header 和 tail 指標分別指向表頭和表尾節點

length 記錄了節點數量

level 記錄了所有節點中層級最高的節點的層級,表頭節點的層高不計算在內

下圖是一個跳錶的示例,最左側是一個 zskiplist 結構,其右側是四個 zskiplistNode 節點,從左向右分別有32層、4層、2層、5層。每個節點向右的指標即前進指標 forward, BW 則表示後退指標 backward,每個節點依據節點的分值 score 進行排列

詳解Redis資料結構之跳躍表

到此這篇關於Redis資料結構中的跳躍表的文章就介紹到這了,更多相關Redis資料結構跳躍表內容請搜尋我們以前的文章或繼續瀏覽下面的相關文章希望大家以後多多支援我們!