SkipList 跳錶

轉載:https://blog.csdn.net/fw0124/article/details/42780679算法

 

爲何選擇跳錶編程

提及跳錶,咱們仍是要從二分查找開始。
二分查找的關鍵要求有兩個,
1 數據可以按照某種條件進行排序。
2 能夠經過某種方式,取出該數據集中任意子集的中間值。
可以知足的數據結構主要是有序數組,但對於數據量不斷變化的場景來講,有序數組很難可以高效的進行寫入。
鏈表是一種最容易處理數據不斷增長結構的有序數據結構,而且由於已經有了無鎖完成多線程鏈表寫入的算法,所以鏈表對於併發的支持度是很是好的然而鏈表卻不可以進行二分查找,由於沒法取到任意子集的中值。
因此人們又去想辦法基於樹來作可以既支持寫入,又可以經過「預先找到中值並寫到父節點」的方式來提早將中值準備好,這就是平衡有序二叉樹。不過,不管是AVL仍是紅黑樹,這個預先找到中值並寫入到父節點的操做的都是很是複雜的,對於複雜的操做來講,想使用常見的無鎖操做就幾乎不可能了。

最後,綜合一下,鏈表結構可以作到併發無鎖的增長新節點,但不能很容易的訪問到中值(由於鏈表只能從頭部遍歷或尾部遍歷)。平衡有序二叉樹則相反,雖然很容易能夠訪問到所有數據的中值,但沒法作到併發無鎖的增長新節點。
在90年代以前,人們一直以「這就是生活」 來安慰本身,認爲魚與熊掌不可兼得。但在90年代,William Pugh 在他的論文中提出了一種新的數據結構,很巧妙的解決了這個矛盾,另外也八卦一下,其實目前Java領域很流行的find bugs靜態代碼分析工具也是william發明的~
 數組

跳錶是一種隨機化的數據結構,目前開源軟件 Redis 和 LevelDB 都有用到它,它的效率和紅黑樹以及 AVL 樹不相上下,但跳錶的原理至關簡單,只要你能熟練操做鏈表,就能輕鬆實現一個 SkipList。數據結構

 

有序表的搜索

考慮一個有序表:
多線程

從該有序表中搜索元素 < 23, 43, 59 > ,須要比較的次數分別爲 < 2, 4, 6 >,總共比較的次數併發

爲 2 + 4 + 6 = 12 次。有沒有優化的算法嗎?  鏈表是有序的,但不能使用二分查找。相似二叉工具

搜索樹,咱們把一些節點提取出來,做爲索引。獲得以下結構:性能

 這裏咱們把 < 14, 34, 50, 72 > 提取出來做爲一級索引,這樣搜索的時候就能夠減小比較次數了。優化

 咱們還能夠再從一級索引提取一些元素出來,做爲二級索引,變成以下結構:.net

 

  

這裏元素很少,體現不出優點,若是元素足夠多,這種索引結構就能體現出優點來了。

 

跳錶

下面的結構是就是跳錶:

 

 

跳錶具備以下性質:

(1) 由不少層結構組成

(2) 每一層都是一個有序的鏈表

(3) 最底層(Level 1)的鏈表包含全部元素

(4) 若是一個元素出如今 Level i 的鏈表中,則它在 Level i 之下的鏈表也都會出現。

(5) 每一個節點包含兩個指針,一個指向同一鏈表中的下一個元素,一個指向下面一層的元素。

 

跳錶的搜索



 

例子:查找元素 117

(1) 比較 21, 比 21 大,日後面找

(2) 比較 37,   比 37大,比鏈表最大值小,從 37 的下面一層開始找

(3) 比較 71,  比 71 大,比鏈表最大值小,從 71 的下面一層開始找

(4) 比較 85, 比 85 大,從後面找

(5) 比較 117, 等於 117, 找到了節點。

能夠看到,利用這種結構若是咱們可以比較準確的在鏈表裏將數據排好序,而且level1中每兩個元素中拿出一個元素推送到更高的層級level2中,而後在level2中也按照每兩個元素拿出一個元素推送到更高層級的level3中…依此類推,就能夠構建出一個查詢時間複雜度爲O(log2n)的查找數據結構了。

但這裏有個關鍵的難在於:如何可以知道,當前寫入的元素是否應該被推送到更高的層級呢?這也就對應了原來avl,紅黑裏面爲何要作如此複雜的旋轉的緣由。而在william的解決方案裏,他選擇了一條徹底不相同的路來作到這一點。

這也是skiplist裏面一個最大的創新點,就是引入了一個新條件:機率。與傳統的根據臨近元素的來決定是否上推的avl或紅黑樹相比。Skiplist則使用機率這個徹底不須要依託集合內其餘元素的因素來決定這個元素是否要上推。這種方式的最大好處,就是可讓每一次的插入都變得更「獨立」,而不須要依託於其餘元素插入的結果。 這樣就可以讓衝突只發生在數據真正寫入的那一步操做上,而咱們已經在前面的文章裏面知道了,對於鏈表來講,數據的寫入是可以作到無鎖的寫入新數據的,因而,利用skiplist,就能成功的作到無鎖的有序平衡「樹」(多層級)結構。

咱們能夠把skiplist的寫入分爲兩個步驟,第一個步驟是找到元素在整個順序列表中要寫入的位置,這個步驟與咱們上面講到的讀取過程是一致的。
而後下一個步驟是決定這個數據是否須要從當前層級上推到上一個層級,具體的作法是從最低層級level1開始,寫入用戶須要寫入的值,並計算一個隨機數,若是是0,則不上推到高一層,而若是是1,則上推到高一個層,而後指針跳躍到高一個層級,重複進行隨機數計算來決定是否須要推到更高的層級,若是最高層中只有本身這個元素的時候,則也中止計算隨機數(由於不須要再推到更高層了)。

最後,還有個問題就是如何解決併發寫入的問題,爲了闡述清楚如何可以作到併發寫,咱們須要先對什麼叫」一致性的寫」,進行一下說明。
通常的人理解數據的一致性寫的定義多是:若是寫成功了你就讓我看到,而若是沒寫成功,你就不讓我看到唄。
但實際上這個定義在計算機裏面是沒法操做的,由於咱們以前也提到過,計算機其實就是個打字機,一次只能進行一個操做,針對複雜的操做,只能經過加鎖來實現一致性。但加鎖自己的代價又很大,這就造成了個悖論,如何可以既保證性能,又可以實現一致性呢?
這時候就須要咱們對一致性的定義針對多線程環境進行一下完善:在以前的定義,咱們是把寫入的過程分爲兩個時間點的,一個時間點是調用寫入接口前,另外一個時間點是調用寫入接口後。但其實在多線程環境下,應該分爲三個時間點,第一個是調用寫入接口前,第二個是調用寫入接口,但還未返回結果的那段時間,第三個是調用寫入接口,返回結果後。
而後咱們來看看,針對這三個時間點應該如何選擇,才能保證數據的一致性:
對於第一個時間點,由於尚未調用寫入接口,因此全部線程(包含調用寫入的線程)都不該該可以從這個映射中讀取到待寫入的數據。
第二個時間點,也就是寫入操做過程當中,咱們須要可以保證:若是數據已經被其餘線程看到過了,那麼再這個時間點以後的全部時間點,數據應該都可以被其餘線程看到,也就是說不能出現先被看到但又被刪掉的狀況。
第三個時間點,這個寫入的操做應該可以被全部人看到。

已經定義好了一致性的規範,下面就來看看這個無鎖併發的skiplist是如何處理好併發一致性的。
首先咱們須要先了解一下鏈表是如何可以作到無鎖寫入的:
對於鏈表類的數據結構來講,若是想作到無鎖,主要就是解決如下的問題,如何可以讓當前線程知道,目前要插入新元素的位置,是否有其餘人正在插入? 若是有的話,那麼就自旋等待,若是沒有,那麼就插入。利用這個原理,把原來的多步指針變動操做利用compare and set的方式轉換爲一個僞原子操做。這樣就能夠有效的減小鎖致使的上下文切換開銷,在爭用不頻繁的狀況下,極大的提高性能。(這只是思路,關於linkedlist的無鎖編程細節,能夠參照A pragmatic implementation of non-blocking linked lists,這篇文章)
利用上面鏈表的無鎖寫入,咱們就可以保證,數據在每個level內的寫是保證無鎖寫入的。而且,由於每一次有新的數據寫入的時候其餘嘗試寫入的線程也都能感知的到,因此這些並行寫入的數據能夠經過不斷相互比較的方式來了解到,本身這個要寫入的數據與其餘並行寫入的數據之間的大小關係,從而能夠動態的進行調整以保證在每一層內,數據都是絕對有序的。
同一個level的一致性解決了,那麼不一樣level之間的一致性是如何獲得解決的呢?這就與咱們剛纔定義的一致性規範緊密相關了。由於數據的寫入是從低層級開始,一層一層的往更高的層級推送的。而數據讀取的時候,則是從最高層級往下讀取的。又由於數據是絕對有序的,那麼咱們就必定能夠認爲,只要最低層級(level0)內存在了的數據,那麼他就必定可以被全部線程看到。而若是在上推的過程當中出現了任何異常,其實都是不要緊的,由於上推的惟一目的在於加快檢索速度,因此就算由於異常沒有上推,也只是下降了查詢的效率,對數據的可見性徹底沒有影響。
這個設計確實是很是的巧妙~

這樣,雖然每一個元素的具體可以到達哪一個層級是隨機的,但從宏觀上來看,低層元素的個數基本上是高層元素個數的一倍。從宏觀上來看,若是按照咱們上面定義的自最高層級依次往下遍歷的讀取模式,那麼整個查詢的時間複雜度就是O(log2n)。

下面來介紹一些優化的思路,由於進行隨機數的運算自己也是個很消耗cpu的操做,因此,一種最多見的優化就是,若是在插入的時候就能直接算出這個數據應該往高層推的總次數,那麼就不須要算那麼屢次隨機數了,每次寫入只須要算一次就好了。
第二個優化的思路是如何可以實現一個高性能的隨機數算法。

Skiplist是一個很好的數據結構,由於它足夠簡單,性能又好,除了運氣很是差的時候效率很低,其餘時候都能作到很好的查詢效率,賭博什麼的最喜歡了~~~最重要的是,它還足夠簡單和容易理解!


跳錶的插入

 

先肯定該元素要佔據的層數 K(採用丟硬幣的方式,這徹底是隨機的)

而後在 Level 1 ... Level K 各個層的鏈表都插入元素。

例子:插入 119, K = 2

 

若是 K 大於鏈表的層數,則要添加新的層。

例子:插入 119, K = 4

 

 

丟硬幣決定 K

 

插入元素的時候,元素所佔有的層數徹底是隨機的,至關於作屢次丟硬幣的實驗,若是遇到正面,繼續丟,遇到反面,則中止,

用實驗中丟硬幣的次數 K 做爲元素佔有的層數。顯然隨機變量 K 知足參數爲 p = 1/2 的幾何分佈,

K 的指望值 E[K] = 1/p = 2. 就是說,各個元素的層數,指望值是 2 層。

  

跳錶的高度。

n 個元素的跳錶,每一個元素插入的時候都要作一次實驗,用來決定元素佔據的層數 K,

跳錶的高度等於這 n 次實驗中產生的最大 K

 

跳錶的空間複雜度分析

根據上面的分析,每一個元素的指望高度爲 2, 一個大小爲 n 的跳錶,其節點數目的

指望值是 2n。

 

跳錶的刪除

在各個層中找到包含 x 的節點,使用標準的 delete from list 方法刪除該節點。

例子:刪除 71

 

下面咱們使用一些通用的標準對skiplis進行一下簡單的評價:1. 是否支持範圍查找由於是有序結構,因此可以很好的支持範圍查找。2. 集合是否可以隨着數據的增加而自動擴展能夠,由於核心數據結構是鏈表,因此是能夠很好的支持數據的不斷增加的3. 讀寫性能如何由於從宏觀上能夠作到一次排除一半的數據,而且在寫入時也沒有進行其餘額外的數據查找性工做,因此對於skiplist來講,其讀寫的時間複雜度都是O(log2n)4. 是否面向磁盤結構磁盤要求順序寫,順序讀,一次讀寫必須是一整塊的數據。而對於skiplist來講,查詢中每一次從高層跳躍到底層的操做,都會對應一次磁盤隨機讀,而skiplist的層數從宏觀上來看必定是O(log2n)層。所以也就對應了O(log2n)次磁盤隨機讀。所以這個數據結構不適合於磁盤結構。5. 並行指標終於來到這個指標了, skiplist的並行指標是很是好的,只要不是在同一個目標插入點插入數據,全部插入均可以並行進行,而就算在同一個插入點,插入自己也可使用無鎖自旋來提高寫入效率。所以skiplist是個並行度很是高的數據結構。6. 內存佔用與平衡二叉樹的內存消耗基本一致。

相關文章
相關標籤/搜索