Redis: 跳跃表
總結(jié):
基于鏈表的查詢由于不能使用二分查找算法,因此需要挨個(gè)遍歷鏈表元素對(duì)比定位。插入和刪除操作雖然只需要進(jìn)行指針的變換,但首先還是要定位到插入位置,因此當(dāng)鏈表的數(shù)據(jù)量比較大的時(shí)候,就會(huì)出現(xiàn)效率很低,非常的耗時(shí)。
為了減少遍歷時(shí)比較的次數(shù),提出將鏈表中部分的關(guān)鍵節(jié)點(diǎn)提取出來。例如1-2-3-4-5-6-7-9,將奇數(shù)點(diǎn)1-3-5-7-9提取出來作為關(guān)鍵點(diǎn),當(dāng)我要插入8的時(shí)候,就只需要和1,3,5,7,9進(jìn)行比較而不用和2,4,6比較,這樣一來就減少了比較的次數(shù),當(dāng)數(shù)據(jù)量比較大的時(shí)候,這種效率的提高就會(huì)比較明顯。這是一種以空間換取時(shí)間的策略。其實(shí)這就相當(dāng)于將1-3-5-7-9提取出來作為一級(jí)索引,接下來還可以繼續(xù)抽取關(guān)鍵點(diǎn)進(jìn)行二級(jí)索引,三級(jí)索引...等等。每建立一層索引比較次數(shù)就會(huì)降低為原先的1/2.?
當(dāng)新插入很多節(jié)點(diǎn)的時(shí)候,之前的上層節(jié)點(diǎn)的索引關(guān)鍵點(diǎn)就會(huì)不夠用,就要考慮在每當(dāng)在新插入關(guān)鍵點(diǎn)的時(shí)候就要從新關(guān)鍵點(diǎn)中選取一部分提到上一層。至于怎么提法?由于跳躍表的添加和刪除關(guān)鍵點(diǎn)是不可預(yù)測的,很難用一種有效的算法來保證跳表的索引部分始終均勻,因此給出的方法是使用拋硬幣的概率事件來決定,由于在大樣本的情況下,正反面的概率都會(huì)趨近0.5,可以使得大體趨近均勻。
跳躍表插入關(guān)鍵點(diǎn)的關(guān)鍵步驟大概如下:
1)首先新關(guān)鍵點(diǎn)和各層的索引關(guān)鍵點(diǎn)進(jìn)行比較,確定在原鏈表(最底層鏈表)中需要插入的位置。O(logn)
2) 把新關(guān)鍵點(diǎn)插入到原鏈表;
3)利用拋硬幣的隨機(jī)方式?jīng)Q定新關(guān)鍵點(diǎn)是否需要提升為上一層的索引。結(jié)果為正則繼續(xù)提升,為負(fù)則停止提升。
因此跳躍表的插入時(shí)間復(fù)雜度是O(logn),空間復(fù)雜度是O(n)
跳躍表刪除關(guān)鍵點(diǎn)的關(guān)鍵步驟大概如下:
1)首先自上而下,查找第一次出現(xiàn)關(guān)鍵點(diǎn)的索引,并逐層找到每一層對(duì)應(yīng)的節(jié)點(diǎn)。O(logn)
2)刪除每一層查找到的關(guān)鍵點(diǎn),如果該層只剩下一個(gè)節(jié)點(diǎn),刪除當(dāng)前整個(gè)索引層(原鏈表層除外)
跳躍表刪除操作的時(shí)間復(fù)雜度是O(logN)
?
為什么選擇跳表
目前經(jīng)常使用的平衡數(shù)據(jù)結(jié)構(gòu)有:B樹,紅黑樹,AVL樹,Splay Tree, Treep等。
想象一下,給你一張草稿紙,一只筆,一個(gè)編輯器,你能立即實(shí)現(xiàn)一顆紅黑樹,或者AVL樹
出來嗎? 很難吧,這需要時(shí)間,要考慮很多細(xì)節(jié),要參考一堆算法與數(shù)據(jù)結(jié)構(gòu)之類的樹,
還要參考網(wǎng)上的代碼,相當(dāng)麻煩。
用跳表吧,跳表是一種隨機(jī)化的數(shù)據(jù)結(jié)構(gòu),目前開源軟件 Redis 和 LevelDB 都有用到它,
它的效率和紅黑樹以及 AVL 樹不相上下,但跳表的原理相當(dāng)簡單,只要你能熟練操作鏈表,
就能輕松實(shí)現(xiàn)一個(gè) SkipList。
有序表的搜索
考慮一個(gè)有序表:
從該有序表中搜索元素 < 23, 43, 59 > ,需要比較的次數(shù)分別為 < 2, 4, 6 >,總共比較的次數(shù)
為 2 + 4 + 6 = 12 次。有沒有優(yōu)化的算法嗎???鏈表是有序的,但不能使用二分查找。類似二叉
搜索樹,我們把一些節(jié)點(diǎn)提取出來,作為索引。得到如下結(jié)構(gòu):
這里我們把 < 14, 34, 50, 72 > 提取出來作為一級(jí)索引,這樣搜索的時(shí)候就可以減少比較次數(shù)了。
我們還可以再從一級(jí)索引提取一些元素出來,作為二級(jí)索引,變成如下結(jié)構(gòu):
這里元素不多,體現(xiàn)不出優(yōu)勢,如果元素足夠多,這種索引結(jié)構(gòu)就能體現(xiàn)出優(yōu)勢來了。
這基本上就是跳表的核心思想,其實(shí)也是一種通過“空間來換取時(shí)間”的一個(gè)算法,通過在每個(gè)節(jié)點(diǎn)中增加了向前的指針,從而提升查找的效率。
跳表
下面的結(jié)構(gòu)是就是跳表:
其中 -1 表示 INT_MIN, 鏈表的最小值,1 表示 INT_MAX,鏈表的最大值。
跳表具有如下性質(zhì):
(1) 由很多層結(jié)構(gòu)組成
(2) 每一層都是一個(gè)有序的鏈表
(3) 最底層(Level 1)的鏈表包含所有元素
(4) 如果一個(gè)元素出現(xiàn)在 Level i 的鏈表中,則它在 Level i 之下的鏈表也都會(huì)出現(xiàn)。
(5) 每個(gè)節(jié)點(diǎn)包含兩個(gè)指針,一個(gè)指向同一鏈表中的下一個(gè)元素,一個(gè)指向下面一層的元素。
跳表的搜索
例子:查找元素 117
(1) 比較 21, 比 21 大,往后面找
(2) 比較 37,???比 37大,比鏈表最大值小,從 37 的下面一層開始找
(3) 比較 71,??比 71 大,比鏈表最大值小,從 71 的下面一層開始找
(4) 比較 85, 比 85 大,從后面找
(5) 比較 117, 等于 117, 找到了節(jié)點(diǎn)。
具體的搜索算法如下:
C代碼
1.
3. find(x)??
4. {?
5.?????p = top;?
6.?while?(1) {?
7.?while?(p->next->key < x)?
8.?????????????p = p->next;?
9.?if?(p->down == NULL)??
10.?return?p->next;?
11.?????????p = p->down;?
12.?????}?
13. }?
跳表的插入
先確定該元素要占據(jù)的層數(shù) K(采用丟硬幣的方式,這完全是隨機(jī)的)
然后在 Level 1 ... Level K 各個(gè)層的鏈表都插入元素。
例子:插入 119, K = 2
如果 K 大于鏈表的層數(shù),則要添加新的層。
例子:插入 119, K = 4
丟硬幣決定?K
插入元素的時(shí)候,元素所占有的層數(shù)完全是隨機(jī)的,通過一下隨機(jī)算法產(chǎn)生:
C代碼
1.?int?random_level()?
2. {?
3.?????K = 1;?
4.
5.?while?(random(0,1))?
6.?????????K++;?
7.
8.?return?K;?
9. }?
相當(dāng)與做一次丟硬幣的實(shí)驗(yàn),如果遇到正面,繼續(xù)丟,遇到反面,則停止,
用實(shí)驗(yàn)中丟硬幣的次數(shù) K 作為元素占有的層數(shù)。顯然隨機(jī)變量 K 滿足參數(shù)為 p = 1/2 的幾何分布,
幾何分布的期望值為E[K] = 1/p
因此,K 的期望值 E[K] = 1/p = 2. 就是說,各個(gè)元素的層數(shù),期望值是 2 層。
跳表的高度。
n 個(gè)元素的跳表,每個(gè)元素插入的時(shí)候都要做一次實(shí)驗(yàn),用來決定元素占據(jù)的層數(shù) K,
跳表的高度等于這 n 次實(shí)驗(yàn)中產(chǎn)生的最大 K。
?
跳表的空間復(fù)雜度分析
根據(jù)上面的分析,每個(gè)元素的期望高度為 2, 一個(gè)大小為 n 的跳表,其節(jié)點(diǎn)數(shù)目的
期望值是 2n。
跳表的刪除
在各個(gè)層中找到包含 x 的節(jié)點(diǎn),使用標(biāo)準(zhǔn)的 delete from list 方法刪除該節(jié)點(diǎn)。
例子:刪除 71
?
引言:
上周現(xiàn)場面試阿里巴巴研發(fā)工程師終面,被問到如何讓鏈表的元素查詢接近線性時(shí)間。筆者苦思良久,繳械投降。面試官告知回去可以看一下跳躍表,遂出此文。
?
跳躍表的引入
我們知道,普通單鏈表查詢一個(gè)元素的時(shí)間復(fù)雜度為O(n),即使該單鏈表是有序的,我們也不能通過2分的方式縮減時(shí)間復(fù)雜度。
?
?
如上圖,我們要查詢?cè)貫?5的結(jié)點(diǎn),必須從頭結(jié)點(diǎn),循環(huán)遍歷到最后一個(gè)節(jié)點(diǎn),不算-INF(負(fù)無窮)一共查詢8次。那么用什么辦法能夠用更少的次數(shù)訪問55呢?最直觀的,當(dāng)然是新開辟一條捷徑去訪問55。
?
?
如上圖,我們要查詢?cè)貫?5的結(jié)點(diǎn),只需要在L2層查找4次即可。在這個(gè)結(jié)構(gòu)中,查詢結(jié)點(diǎn)為46的元素將耗費(fèi)最多的查詢次數(shù)5次。即先在L2查詢46,查詢4次后找到元素55,因?yàn)殒湵硎怯行虻?#xff0c;46一定在55的左邊,所以L2層沒有元素46。然后我們退回到元素37,到它的下一層即L1層繼續(xù)搜索46。非常幸運(yùn),我們只需要再查詢1次就能找到46。這樣一共耗費(fèi)5次查詢。
?
那么,如何才能更快的搜尋55呢?有了上面的經(jīng)驗(yàn),我們就很容易想到,再開辟一條捷徑。
如上圖,我們搜索55只需要2次查找即可。這個(gè)結(jié)構(gòu)中,查詢?cè)?6仍然是最耗時(shí)的,需要查詢5次。即首先在L3層查找2次,然后在L2層查找2次,最后在L1層查找1次,共5次。很顯然,這種思想和2分非常相似,那么我們最后的結(jié)構(gòu)圖就應(yīng)該如下圖。
?
?
我們可以看到,最耗時(shí)的訪問46需要6次查詢。即L4訪問55,L3訪問21、55,L2訪問37、55,L1訪問46。我們直覺上認(rèn)為,這樣的結(jié)構(gòu)會(huì)讓查詢有序鏈表的某個(gè)元素更快。那么究竟算法復(fù)雜度是多少呢?
?
如果有n個(gè)元素,因?yàn)槭?分,所以層數(shù)就應(yīng)該是log n層 (本文所有l(wèi)og都是以2為底),再加上自身的1層。以上圖為例,如果是4個(gè)元素,那么分層為L3和L4,再加上本身的L2,一共3層;如果是8個(gè)元素,那么就是3+1層。最耗時(shí)間的查詢自然是訪問所有層數(shù),耗時(shí)logn+logn,即2logn。為什么是2倍的logn呢?我們以上圖中的46為例,查詢到46要訪問所有的分層,每個(gè)分層都要訪問2個(gè)元素,中間元素和最后一個(gè)元素。所以時(shí)間復(fù)雜度為O(logn)。
?
至此為止,我們引入了最理想的跳躍表,但是如果想要在上圖中插入或者刪除一個(gè)元素呢?比如我們要插入一個(gè)元素22、23、24……,自然在L1層,我們將這些元素插入在元素21后,那么L2層,L3層呢?我們是不是要考慮插入后怎樣調(diào)整連接,才能維持這個(gè)理想的跳躍表結(jié)構(gòu)。我們知道,平衡二叉樹的調(diào)整是一件令人頭痛的事情,左旋右旋左右旋……一般人還真記不住,而調(diào)整一個(gè)理想的跳躍表將是一個(gè)比調(diào)整平衡二叉樹還復(fù)雜的操作。幸運(yùn)的是,我們并不需要通過復(fù)雜的操作調(diào)整連接來維護(hù)這樣完美的跳躍表。有一種基于概率統(tǒng)計(jì)的插入算法,也能得到時(shí)間復(fù)雜度為O(logn)的查詢效率,這種跳躍表才是我們真正要實(shí)現(xiàn)的。
?
容易實(shí)現(xiàn)的跳躍表
容易實(shí)現(xiàn)的跳躍表,它允許簡單的插入和刪除元素,并提供O(logn)的查詢時(shí)間復(fù)雜度,以下我們簡稱為跳躍表。
?
先討論插入,我們先看理想的跳躍表結(jié)構(gòu),L2層的元素個(gè)數(shù)是L1層元素個(gè)數(shù)的1/2,L3層的元素個(gè)數(shù)是L2層的元素個(gè)數(shù)的1/2,以此類推。從這里,我們可以想到,只要在插入時(shí)盡量保證上一層的元素個(gè)數(shù)是下一層元素的1/2,我們的跳躍表就能成為理想的跳躍表。那么怎么樣才能在插入時(shí)保證上一層元素個(gè)數(shù)是下一層元素個(gè)數(shù)的1/2呢?很簡單,拋硬幣就能解決了!假設(shè)元素X要插入跳躍表,很顯然,L1層肯定要插入X。那么L2層要不要插入X呢?我們希望上層元素個(gè)數(shù)是下層元素個(gè)數(shù)的1/2,所以我們有1/2的概率希望X插入L2層,那么拋一下硬幣吧,正面就插入,反面就不插入。那么L3到底要不要插入X呢?相對(duì)于L2層,我們還是希望1/2的概率插入,那么繼續(xù)拋硬幣吧!以此類推,元素X插入第n層的概率是(1/2)的n次。這樣,我們能在跳躍表中插入一個(gè)元素了。
?
在此還是以上圖為例:跳躍表的初試狀態(tài)如下圖,表中沒有一個(gè)元素:
?
?
如果我們要插入元素2,首先是在底部插入元素2,如下圖:
?
?
然后我們拋硬幣,結(jié)果是正面,那么我們要將2插入到L2層,如下圖
?
?
繼續(xù)拋硬幣,結(jié)果是反面,那么元素2的插入操作就停止了,插入后的表結(jié)構(gòu)就是上圖所示。接下來,我們插入元素33,跟元素2的插入一樣,現(xiàn)在L1層插入33,如下圖:
?
?
然后拋硬幣,結(jié)果是反面,那么元素33的插入操作就結(jié)束了,插入后的表結(jié)構(gòu)就是上圖所示。接下來,我們插入元素55,首先在L1插入55,插入后如下圖:
?
?
然后拋硬幣,結(jié)果是正面,那么L2層需要插入55,如下圖:
?
?
繼續(xù)拋硬幣,結(jié)果又是正面,那么L3層需要插入55,如下圖:
?
?
繼續(xù)拋硬幣,結(jié)果又是正面,那么要在L4插入55,結(jié)果如下圖:
?
?
繼續(xù)拋硬幣,結(jié)果是反面,那么55的插入結(jié)束,表結(jié)構(gòu)就如上圖所示。
?
以此類推,我們插入剩余的元素。當(dāng)然因?yàn)橐?guī)模小,結(jié)果很可能不是一個(gè)理想的跳躍表。但是如果元素個(gè)數(shù)n的規(guī)模很大,學(xué)過概率論的同學(xué)都知道,最終的表結(jié)構(gòu)肯定非常接近于理想跳躍表。
?
當(dāng)然,這樣的分析在感性上是很直接的,但是時(shí)間復(fù)雜度的證明實(shí)在復(fù)雜,在此我就不深究了,感興趣的可以去看關(guān)于跳躍表的paper。
?
再討論刪除,刪除操作沒什么講的,直接刪除元素,然后調(diào)整一下刪除元素后的指針即可。跟普通的鏈表刪除操作完全一樣。
?
再來討論一下時(shí)間復(fù)雜度,插入和刪除的時(shí)間復(fù)雜度就是查詢?cè)夭迦胛恢玫臅r(shí)間復(fù)雜度,這不難理解,所以是O(logn)。
?
Java實(shí)現(xiàn)
在章節(jié)2中,我們采用拋硬幣的方式來決定新元素插入的最高層數(shù),這當(dāng)然不能在程序中實(shí)現(xiàn)。代碼中,我們采用隨機(jī)數(shù)生成的方式來獲取新元素插入的最高層數(shù)。我們先估摸一下n的規(guī)模,然后定義跳躍表的最大層數(shù)maxLevel,那么底層,也就是第0層,元素是一定要插入的,概率為1;最高層,也就是maxLevel層,元素插入的概率為1/2^maxLevel。
?
我們先隨機(jī)生成一個(gè)范圍為0~2^maxLevel-1的一個(gè)整數(shù)r。那么元素r小于2^(maxLevel-1)的概率為1/2,r小于2^(maxLevel-2)的概率為1/4,……,r小于2的概率為1/2^(maxLevel-1),r小于1的概率為1/2^maxLevel。
?
舉例,假設(shè)maxLevel為4,那么r的范圍為0~15,則r小于8的概率為1/2,r小于4的概率為1/4,r小于2的概率為1/8,r小于1的概率為1/16。1/16正好是maxLevel層插入元素的概率,1/8正好是maxLevel層插入的概率,以此類推。
?
通過這樣的分析,我們可以先比較r和1,如果r<1,那么元素就要插入到maxLevel層以下;否則再比較r和2,如果r<2,那么元素就要插入到maxLevel-1層以下;再比較r和4,如果r<4,那么元素就要插入到maxLevel-2層以下……如果r>2^(maxLevel - 1),那么元素就只要插入在底層即可。
?
以上分析是隨機(jī)數(shù)算法的關(guān)鍵。算法跟實(shí)現(xiàn)跟語言無關(guān),但是Java程序員還是更容易看明白Java代碼實(shí)現(xiàn)的跳躍表,以下貼一下別人的java代碼實(shí)現(xiàn)。作者找不到了,就這樣吧。
? 1 /*************************** SkipList.java *********************/2 3 import java.util.Random;4 5 public class SkipList<T extends Comparable<? super T>> {6 private int maxLevel;7 private SkipListNode<T>[] root;8 private int[] powers;9 private Random rd = new Random(); 10 SkipList() { 11 this(4); 12 } 13 SkipList(int i) { 14 maxLevel = i; 15 root = new SkipListNode[maxLevel]; 16 powers = new int[maxLevel]; 17 for (int j = 0; j < maxLevel; j++) 18 root[j] = null; 19 choosePowers(); 20 } 21 public boolean isEmpty() { 22 return root[0] == null; 23 } 24 public void choosePowers() { 25 powers[maxLevel-1] = (2 << (maxLevel-1)) - 1; // 2^maxLevel - 1 26 for (int i = maxLevel - 2, j = 0; i >= 0; i--, j++) 27 powers[i] = powers[i+1] - (2 << j); // 2^(j+1) 28 } 29 public int chooseLevel() { 30 int i, r = Math.abs(rd.nextInt()) % powers[maxLevel-1] + 1; 31 for (i = 1; i < maxLevel; i++) 32 if (r < powers[i]) 33 return i-1; // return a level < the highest level; 34 return i-1; // return the highest level; 35 } 36 // make sure (with isEmpty()) that search() is called for a nonempty list; 37 public T search(T key) { 38 int lvl; 39 SkipListNode<T> prev, curr; // find the highest nonnull 40 for (lvl = maxLevel-1; lvl >= 0 && root[lvl] == null; lvl--); // level; 41 prev = curr = root[lvl]; 42 while (true) { 43 if (key.equals(curr.key)) // success if equal; 44 return curr.key; 45 else if (key.compareTo(curr.key) < 0) { // if smaller, go down, 46 if (lvl == 0) // if possible 47 return null; 48 else if (curr == root[lvl]) // by one level 49 curr = root[--lvl]; // starting from the 50 else curr = prev.next[--lvl]; // predecessor which 51 } // can be the root; 52 else { // if greater, 53 prev = curr; // go to the next 54 if (curr.next[lvl] != null) // non-null node 55 curr = curr.next[lvl]; // on the same level 56 else { // or to a list on a lower level; 57 for (lvl--; lvl >= 0 && curr.next[lvl] == null; lvl--); 58 if (lvl >= 0) 59 curr = curr.next[lvl]; 60 else return null; 61 } 62 } 63 } 64 } 65 public void insert(T key) { 66 SkipListNode<T>[] curr = new SkipListNode[maxLevel]; 67 SkipListNode<T>[] prev = new SkipListNode[maxLevel]; 68 SkipListNode<T> newNode; 69 int lvl, i; 70 curr[maxLevel-1] = root[maxLevel-1]; 71 prev[maxLevel-1] = null; 72 for (lvl = maxLevel - 1; lvl >= 0; lvl--) { 73 while (curr[lvl] != null && curr[lvl].key.compareTo(key) < 0) { 74 prev[lvl] = curr[lvl]; // go to the next 75 curr[lvl] = curr[lvl].next[lvl]; // if smaller; 76 } 77 if (curr[lvl] != null && key.equals(curr[lvl].key)) // don't 78 return; // include duplicates; 79 if (lvl > 0) // go one level down 80 if (prev[lvl] == null) { // if not the lowest 81 curr[lvl-1] = root[lvl-1]; // level, using a link 82 prev[lvl-1] = null; // either from the root 83 } 84 else { // or from the predecessor; 85 curr[lvl-1] = prev[lvl].next[lvl-1]; 86 prev[lvl-1] = prev[lvl]; 87 } 88 } 89 lvl = chooseLevel(); // generate randomly level 90 newNode = new SkipListNode<T>(key,lvl+1); // for newNode; 91 for (i = 0; i <= lvl; i++) { // initialize next fields of 92 newNode.next[i] = curr[i]; // newNode and reset to newNode 93 if (prev[i] == null) // either fields of the root 94 root[i] = newNode; // or next fields of newNode's 95 else prev[i].next[i] = newNode; // predecessors; 96 } 97 } 98 }總結(jié)
以上是生活随笔為你收集整理的Redis: 跳跃表的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: equal、hashcode、==
- 下一篇: 数据库:redis和MySQL如何做到数