本文摘抄於《Redis內部數據結構詳解-skiplist》node
1、skiplist的由來算法
skiplist,顧名思義,首先它是一個list。實際上,它是在有序鏈表的基礎上發展起來的。數據結構
咱們先來看一個有序鏈表,以下圖(最左側的灰色節點表示一個空的頭結點):less
在這樣一個鏈表中,若是咱們要查找某個數據,那麼須要從頭開始逐個進行比較,直到找到包含數據的那個節點,或者找到第一個比給定數據大的節點爲止(沒找到)。也就是說,時間複雜度爲O(n)。一樣,當咱們要插入新數據的時候,也要經歷一樣的查找過程,從而肯定插入位置。dom
假如咱們每相鄰兩個節點增長一個指針,讓指針指向下下個節點,以下圖:性能
這樣全部新增長的指針連成了一個新的鏈表,但它包含的節點個數只有原來的一半(上圖中是7, 19, 26)。如今當咱們想查找數據的時候,能夠先沿着這個新鏈表進行查找。當碰到比待查數據大的節點時,再回到原來的鏈表中進行查找。好比,咱們想查找23,查找的路徑是沿着下圖中標紅的指針所指向的方向進行的:ui
23首先和7比較,再和19比較,比它們都大,繼續向後比較。this
但23和26比較的時候,比26要小,所以回到下面的鏈表(原鏈表),與22比較。翻譯
23比22要大,沿下面的指針繼續向後和26比較。23比26小,說明待查數據23在原鏈表中不存在,並且它的插入位置應該在22和26之間。debug
在這個查找過程當中,因爲新增長的指針,咱們再也不須要與鏈表中每一個節點逐個進行比較了。須要比較的節點數大概只有原來的一半。
利用一樣的方式,咱們能夠在上層新產生的鏈表上,繼續爲每相鄰的兩個節點增長一個指針,從而產生第三層鏈表。以下圖:
在這個新的三層鏈表結構上,若是咱們仍是查找23,那麼沿着最上層鏈表首先要比較的是19,發現23比19大,接下來咱們就知道只須要到19的後面去繼續查找,從而一會兒跳過了19前面的全部節點。能夠想象,當鏈表足夠長的時候,這種多層鏈表的查找方式能讓咱們跳過不少下層節點,大大加快查找的速度。
skiplist正是受這種多層鏈表的想法的啓發而設計出來的。實際上,按照上面生成鏈表的方式,上面每一層鏈表的節點個數,是下面一層的節點個數的一半,這樣查找過程就很是相似於一個二分查找,使得查找的時間複雜度能夠下降到O(log n)。可是,這種方法在插入數據的時候有很大的問題。新插入一個節點以後,就會打亂上下相鄰兩層鏈表上節點個數嚴格的2:1的對應關係。若是要維持這種對應關係,就必須把新插入的節點後面的全部節點(也包括新插入的節點)從新進行調整,這會讓時間複雜度從新蛻化成O(n)。刪除數據也有一樣的問題。
skiplist爲了不這一問題,它不要求上下相鄰兩層鏈表之間的節點個數有嚴格的對應關係,而是爲每一個節點隨機出一個層數(level)。好比,一個節點隨機出的層數是3,那麼就把它鏈入到第1層到第3層這三層鏈表中。爲了表達清楚,下圖展現瞭如何經過一步步的插入操做從而造成一個skiplist的過程:
從上面skiplist的建立和插入過程能夠看出,每個節點的層數(level)是隨機出來的,並且新插入一個節點不會影響其它節點的層數。所以,插入操做只須要修改插入節點先後的指針,而不須要對不少節點都進行調整。這就下降了插入操做的複雜度。實際上,這是skiplist的一個很重要的特性,這讓它在插入性能上明顯優於平衡樹的方案。這在後面咱們還會提到。
根據上圖中的skiplist結構,咱們很容易理解這種數據結構的名字的由來。skiplist,翻譯成中文,能夠翻譯成「跳錶」或「跳躍表」,指的就是除了最下面第1層鏈表以外,它會產生若干層稀疏的鏈表,這些鏈表裏面的指針故意跳過了一些節點(並且越高層的鏈表跳過的節點越多)。這就使得咱們在查找數據的時候可以先在高層的鏈表中進行查找,而後逐層下降,最終降到第1層鏈表來精確地肯定數據位置。在這個過程當中,咱們跳過了一些節點,從而也就加快了查找速度。
剛剛建立的這個skiplist總共包含4層鏈表,如今假設咱們在它裏面依然查找23,下圖給出了查找路徑:
須要注意的是,前面演示的各個節點的插入過程,實際上在插入以前也要先經歷一個相似的查找過程,在肯定插入位置後,再完成插入操做。
至此,skiplist的查找和插入操做,咱們已經很清楚了。而刪除操做與插入操做相似,咱們也很容易想象出來。這些操做咱們也應該能很容易地用代碼實現出來。
固然,實際應用中的skiplist每一個節點應該包含key和value兩部分。前面的描述中咱們沒有具體區分key和value,但實際上列表中是按照key進行排序的,查找過程也是根據key在比較。
可是,若是你是第一次接觸skiplist,那麼必定會產生一個疑問:節點插入時隨機出一個層數,僅僅依靠這樣一個簡單的隨機數操做而構建出來的多層鏈表結構,能保證它有一個良好的查找性能嗎?爲了回答這個疑問,咱們須要分析skiplist的統計性能。
在分析以前,咱們還須要着重指出的是,執行插入操做時計算隨機數的過程,是一個很關鍵的過程,它對skiplist的統計特性有着很重要的影響。這並非一個普通的服從均勻分佈的隨機數,它的計算過程以下:
首先,每一個節點確定都有第1層指針(每一個節點都在第1層鏈表裏)。
若是一個節點有第i層(i>=1)指針(即節點已經在第1層到第i層鏈表中),那麼它有第(i+1)層指針的機率爲p。
節點最大的層數不容許超過一個最大值,記爲MaxLevel。
這個計算隨機層數的代碼以下:
/* Returns a random level for the new skiplist node we are going to create.
* The return value of this function is between 1 and ZSKIPLIST_MAXLEVEL
* (both inclusive), with a powerlaw-alike distribution where higher
* levels are less likely to be returned. */
int zslRandomLevel(void) {
int level = 1;
while ((random()&0xFFFF) < (ZSKIPLIST_P * 0xFFFF))
level += 1;
return (level<ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL;
}
代碼包含兩個參數:一個是ZSKIPLIST_P,一個是ZSKIPLIST_MAXLEVEL。其定義在server.h文件中,取值以下:
#define ZSKIPLIST_MAXLEVEL 32 /* Should be enough for 2^32 elements */
#define ZSKIPLIST_P 0.25 /* Skiplist P = 1/4 */
通常查找問題的解法分爲兩個大類:一個是基於各類平衡樹,一個是基於哈希表。但skiplist卻比較特殊,它無法歸屬到這兩大類裏面。
2、Redis中skiplist實現的特殊性
上文中,咱們提到Redis中sorted set的實現原理:
當數據較少時,sorted set是由一個ziplist來實現的。
當數據多的時候,sorted set是由一個dict + 一個skiplist來實現的。簡單來說,dict用來查詢數據到分數的對應關係,而skiplist用來根據分數查詢數據(多是範圍查找)。
sorted set中的每一個元素主要表現出3個屬性:
數據自己。
每一個數據對應一個分數(score)。
根據分數大小和數據自己的字典排序,每一個數據會產生一個排名(rank)。能夠按正序或倒序。
如今咱們來看一下sorted set與skiplist的關係:
zrevrank由數據查詢它對應的排名,skiplist中並不支持。
zscore由數據查詢它對應的分數,skiplist也不支持的。
zrevrange根據一個排名範圍,查詢排名在這個範圍內的數據,skiplist也不支持。
zrevrangebyscore根據分數區間查詢數據集合,是一個skiplist所支持的典型的範圍查找(score至關於key)。
zscore的查詢,不是由skiplist來提供的,而是由dict來提供的。爲了支持排名(rank),Redis裏對skiplist作了擴展,使得根據排名可以快速查到數據,或者根據分數查到數據以後,也同時很容易得到排名。並且,根據排名的查找,時間複雜度也爲O(log n)。
zrevrange的查詢,是根據排名查數據,由擴展後的skiplist來提供。zrevrank是先在dict中由數據查到分數,再拿分數到skiplist中去查找,查到後也同時得到了排名。
各個操做的時間複雜度:
zscore只用查詢一個dict,因此時間複雜度爲O(1)
zrevrank, zrevrange, zrevrangebyscore因爲要查詢skiplist,因此zrevrank的時間複雜度爲O(log n),而zrevrange, zrevrangebyscore的時間複雜度爲O(log(n)+M),其中M是當前查詢返回的元素個數。
總結起來,Redis中的skiplist跟經典的skiplist相比,有以下不一樣:
分數(score)容許重複,即skiplist的key容許重複。這在經典skiplist中是不容許的。
在比較時,不只比較分數(至關於skiplist的key),還比較數據自己。在Redis的skiplist實現中,數據自己的內容惟一標識這份數據,而不是由key來惟一標識。另外,當多個元素分數相同的時候,還須要根據數據內容來進字典排序。
第1層鏈表不是一個單向鏈表,而是一個雙向鏈表。這是爲了方便以倒序方式獲取一個範圍內的元素。
在skiplist中能夠很方便地計算出每一個元素的排名(rank)。
3、skiplist與平衡樹、哈希表的比較
skiplist和各類平衡樹(如AVL、紅黑樹等)的元素是有序排列的,而哈希表不是有序的。所以,在哈希表上只能作單個key的查找,不適宜作範圍查找。
在作範圍查找的時候,平衡樹比skiplist操做要複雜。
所謂範圍查找,指的是查找那些大小在指定的兩個值之間的全部節點。
在平衡樹上,咱們找到指定範圍的小值以後,還須要以中序遍歷的順序繼續尋找其它不超過大值的節點。若是不對平衡樹進行必定的改造,這裏的中序遍歷並不容易實現。
在skiplist上進行範圍查找就很是簡單,只須要在找到小值以後,對第1層鏈表進行若干步的遍歷就能夠實現。
平衡樹的插入和刪除操做可能引起子樹的調整,邏輯複雜,而skiplist的插入和刪除只須要修改相鄰節點的指針,操做簡單又快速。
從內存佔用上來講,skiplist比平衡樹更靈活一些。
通常來講,平衡樹每一個節點包含2個指針(分別指向左右子樹),而skiplist每一個節點包含的指針數目平均爲1/(1-p),具體取決於參數p的大小。若是像Redis裏的實現同樣,取p=1/4,那麼平均每一個節點包含1.33個指針,比平衡樹更有優點。
當咱們查找單個key,skiplist和平衡樹的時間複雜度都爲O(log n),大致至關;而哈希表在保持較低的哈希值衝突機率的前提下,查找時間複雜度接近O(1),性能更高一些。因此咱們日常使用的各類Map或dictionary結構,大都是基於哈希表實現的。
從算法實現難度上來比較,skiplist比平衡樹要簡單得多。
4、Redis爲何用skiplist而不用平衡樹
在前面咱們對於skiplist和平衡樹、哈希表的比較中,其實已經不難看出Redis裏使用skiplist而不用平衡樹的緣由了。
如今咱們看看,對於這個問題,Redis的做者 @antirez是怎麼說的:
原文https://news.ycombinator.com/item?id=1171423
There are a few reasons:
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.
這裏從內存佔用、對範圍查找的支持和實現難易程度這三方面總結的緣由,咱們在前面其實也都涉及到了。
--EOF--