詳解Redis資料結構之跳躍表

2022-09-21 10:18:09 字數 3674 閱讀 6827

我們先不談redis,來看一下跳表。

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

跳表搜尋的時間複雜度平均 o(logn),最壞o(n),空間複雜度o(2n),即o(n)

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

示例如下:

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 檔案中

zskiplistnode 表示跳表的乙個節點,宣告如下:

typedef struct zskiplistnode level;

} zskiplistnode;

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

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

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

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

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

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

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

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

zskiplist 表示乙個跳表,宣告如下:

typedef struct zskiplist zskiplist;

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

length 記錄了節點數量

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

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

Redis資料結構之跳躍表

跳躍表在每個節點中維持多個指向其他節點的指標,可快速訪問節點且有序 跳躍表查詢複雜度為平均o logn 最壞o n 跳躍表使用於有序集合元素數量比較多或者元素是比較長的字串的場景。跳躍表節點 typedef struct zskiplistnode level 後退指標 struct zskipli...

redis資料結構 跳躍表

跳躍表 skiplist 是一種有序資料鏈表結構,它通過在每個節點中維持多個指向其他節點的指標,從而達到快速訪問節點的目的。查詢平均效能為o logn 最壞的情況會出現o n 情況,而redis中的zset在資料較多的時候底層就是採用跳躍表去實現的,元素較少的時候會進行小物件壓縮採用壓縮列表實現。小...

Redis資料結構 跳躍表

跳躍表是一種有序資料結構,它通過在每個節點中維持多個指向其他節點的指標,從而達到快速訪問節點的目的。redis使用跳躍表作為有序集合鍵的底層實現之一,如果乙個有序集合包含的元素數量比較多,又或者有序集合中的元素的成員是比較長的字串時,redis就會使用跳躍表作為有序集合鍵的底層實現。redis的跳躍...