数据映射--跳表(skiplist)
http://blog.sina.com.cn/s/blog_693f08470101n2lv.html
本周我要介紹的數據結構,是我非常非常喜歡的一個數據結構,因為咱也是吃過平衡二叉樹的苦的人啊T_T?,神馬左旋,右旋,上旋,下旋,看原理的時候就已經暈暈乎乎的了,再看源碼,發現比原理還復雜,心理就想,這東西是不是就是為了讓我掛科給學校交重修費來拯救學校財政的東西啊?!。。
?
當然,現在再來看,這些東西有其非常重要的作用,只是確實有點復雜了,不過,突然有一天,有個結構進入了我的視野,第一次聽說是在redis上面看到,后來發現原來java的并發包里也早就實現了這個結構,可以做到很高的并發度,幾乎是全部無鎖的結構,而最重要的是,這個結構的原理非常的簡單!?這么漂亮的結構才是拯救地球的大舅星啊~~~
?
沒錯,今天我們來介紹skiplist跳表。
?
說起跳表,我們還是要從二分查找開始。二分查找的關鍵要求有兩個,1,數據能夠按照某種條件進行排序。2,可以通過某種方式,取出該數據集中任意子集的中間值。
能夠滿足的數據結構主要是有序數組,但對于數據量不斷變化的場景來說,有序數組很難能夠高效的進行寫入。鏈表是一種最容易處理數據不斷增加結構的有序數據結構,并且因為已經有了無鎖完成多線程鏈表寫入的算法,因此鏈表對于并發的支持度是非常好的(我們后面會介紹這個算法),然而鏈表卻不能夠進行二分查找,因為無法取到任意子集的中值。
所以人們又去想辦法基于樹來做能夠既支持寫入,又能夠通過“預先找到中值并寫到父節點”的方式來提前將中值準備好,這就是平衡有序二叉樹。不過,無論是AVL還是紅黑樹,這個預先找到中值并寫入到父節點的操作的都是非常復雜的,對于復雜的操作來說,想使用常見的無鎖操作就幾乎不可能了。
最后,綜合一下,鏈表結構能夠做到并發無鎖的增加新節點,但不能很容易的訪問到中值(因為鏈表只能從頭部遍歷或尾部遍歷)。平衡有序二叉樹則相反,雖然很容易可以訪問到全部數據的中值,但無法做到并發無鎖的增加新節點。
在90年代之前,人們一直以“這就是生活”?來安慰自己,認為魚與熊掌不可兼得。但在90年代,William Pugh在他的論文中提出了一種新的數據結構,很巧妙的解決了這個矛盾,另外也八卦一下,其實目前Java領域很流行的find bugs靜態代碼分析工具也是william發明的~
首先我們先定義一個概念,叫層(level) ,為了方便理解,大家可以直接對應到平衡有序二叉樹里面的樹的高度。
?
每一層在邏輯上都是一個鏈表,既然是鏈表,那么自然也就只能從頭部遍歷或從尾部遍歷咯。
一個標準的skiplist在內存中可能是這樣的:
Level2:0,4
Level1:0,2,4,6,9
Level0:0,1,2,3,4,5,6,7,8,9
?
可以看到,層級越高,數據量越小,并且,高層級的元素都有一個到低層級元素的指針,這樣他可以很容易的通過指針跳轉到更底層的元素上面。
?
下面讓我們來看看讀取的邏輯,比如如果要讀取6,那么從最高層級的鏈表的頭部(從左向右)依次讀取數據,發現6>4,于是在通過Level2?的4?這個元素到level1的4這個元素的指針,跳躍到Level1,然后從Level1的4這個元素繼續往右面找發現下一個元素就是6,于是將整個6所對應的元素返回。
那么要找3的話應該怎么操作呢?
仍然是從最高層級level2的頭部開始遍歷,發現0<3<4 .?于是利用level2的0這個元素到level1的0這個元素的指針,跳躍到level1的0元素,繼續向右遍歷,發現2<3<4。于是利用Level1的2這個元素到level0的2這個元素的指針,跳躍到level0的2這個元素上,繼續向右遍歷找到元素3,于是將整個3所對應的元素返回。
?
可以看到,利用這種結構如果我們能夠比較準確的在鏈表里將數據排好序,并且level0中每兩個元素中拿出一個元素推送到更高的層級level1中,然后在level1中也按照每兩個元素拿出一個元素推送到更高層級的level2中…依此類推,就可以構建出一個查詢時間復雜度為O(log2n)的查找數據結構了。
?
但這里有個關鍵的難在于:如何能夠知道,當前寫入的元素是否應該被推送到更高的層級呢?這也就對應了原來avl,紅黑里面為什么要做如此復雜的旋轉的原因。而在william的解決方案里,他選擇了一條完全不相同的路來做到這一點。
?
這也是skiplist里面一個最大的創新點,就是引入了一個新條件:概率。與傳統的根據臨近元素的來決定是否上推的avl或紅黑樹相比。Skiplist則使用概率這個完全不需要依托集合內其他元素的因素來決定這個元素是否要上推。這種方式的最大好處,就是可以讓每一次的插入都變得更“獨立”,而不需要依托于其他元素插入的結果。?這樣就能夠讓沖突只發生在數據真正寫入的那一步操作上,而我們已經在前面的文章里面知道了,對于鏈表來說,數據的寫入是能夠做到無鎖的寫入新數據的,于是,利用skiplist,就能成功的做到無鎖的有序平衡“樹”(多層級)結構。
?
下面我們就來看看如何利用概率來決定某個元素是否需要上推的。
讓我們先用一個簡單的模式來說明解決問題的思路,然后再探討如何進行優化。
我們可以把skiplist的寫入分為兩個步驟,第一個步驟是找到元素在整個順序列表中要寫入的位置,這個步驟與我們上面講到的讀取過程是一致的。
然后下一個步驟是決定這個數據是否需要從當前層級上推到上一個層級,具體的做法是從最低層級level0開始,寫入用戶需要寫入的值,并計算一個隨機數,如果是0,則不上推到高一層,而如果是1,則上推到高一個層,然后指針跳躍到高一個層級,重復進行隨機數計算來決定是否需要推到更高的層級,如果最高層中只有自己這個元素的時候,則也停止計算隨機數(因為不需要再推到更高層了)。
最后,還有個問題就是如何解決并發寫入的問題,為了闡述清楚如何能夠做到并發寫,我們需要先對什么叫”一致性的寫”,進行一下說明。
一般的人理解數據的一致性寫的定義可能是:如果寫成功了你就讓我看到,而如果沒寫成功,你就不讓我看到唄。
但實際上這個定義在計算機里面是無法操作的,因為我們之前也提到過,計算機其實就是個打字機,一次只能進行一個操作,針對復雜的操作,只能通過加鎖來實現一致性。但加鎖本身的代價又很大,這就形成了個悖論,如何能夠既保證性能,又能夠實現一致性呢?
這時候就需要我們對一致性的定義針對多線程環境進行一下完善:在之前的定義,我們是把寫入的過程分為兩個時間點的,一個時間點是調用寫入接口前,另一個時間點是調用寫入接口后。但其實在多線程環境下,應該分為三個時間點,第一個是調用寫入接口前,第二個是調用寫入接口,但還未返回結果的那段時間,第三個是調用寫入接口,返回結果后。
然后我們來看看,針對這三個時間點應該如何選擇,才能保證數據的一致性:
對于第一個時間點,因為還沒有調用寫入接口,所以所有線程(包含調用寫入的線程)都不應該能夠從這個映射中讀取到待寫入的數據。
第二個時間點,也就是寫入操作過程中,我們需要能夠保證:如果數據已經被其他線程看到過了,那么再這個時間點之后的所有時間點,數據應該都能夠被其他線程看到,也就是說不能出現先被看到但又被刪掉的情況。
第三個時間點,這個寫入的操作應該能夠被所有人看到。
已經定義好了一致性的規范,下面就來看看這個無鎖并發的skiplist是如何處理好并發一致性的。
首先我們需要先了解一下鏈表是如何能夠做到無鎖寫入的:
對于鏈表類的數據結構來說,如果想做到無鎖,主要就是解決以下的問題,如何能夠讓當前線程知道,目前要插入新元素的位置,是否有其他人正在插入? 如果有的話,那么就自旋等待,如果沒有,那么就插入。利用這個原理,把原來的多步指針變更操作利用compare and set的方式轉換為一個偽原子操作。這樣就可以有效的減少鎖導致的上下文切換開銷,在爭用不頻繁的情況下,極大的提升性能。(這只是思路,關于linkedlist的無鎖編程細節,可以參照A pragmatic implementation of non-blocking linked lists,這篇文章)
利用上面鏈表的無鎖寫入,我們就能夠保證,數據在每一個level內的寫是保證無鎖寫入的。并且,因為每一次有新的數據寫入的時候其他嘗試寫入的線程也都能感知的到,所以這些并行寫入的數據可以通過不斷相互比較的方式來了解到,自己這個要寫入的數據與其他并行寫入的數據之間的大小關系,從而可以動態的進行調整以保證在每一層內,數據都是絕對有序的。
同一個level的一致性解決了,那么不同level之間的一致性是如何得到解決的呢?這就與我們剛才定義的一致性規范緊密相關了。因為數據的寫入是從低層級開始,一層一層的往更高的層級推送的。而數據讀取的時候,則是從最高層級往下讀取的。又因為數據是絕對有序的,那么我們就一定可以認為,只要最低層級(level0)內存在了的數據,那么他就一定能夠被所有線程看到。而如果在上推的過程中出現了任何異常,其實都是沒關系的,因為上推的唯一目的在于加快檢索速度,所以就算因為異常沒有上推,也只是降低了查詢的效率,對數據的可見性完全沒有影響。
這個設計確實是非常的巧妙~
這樣,雖然每個元素的具體能夠到達哪個層級是隨機的,但從宏觀上來看,低層元素的個數基本上是高層元素個數的一倍。從宏觀上來看,如果按照我們上面定義的自最高層級依次往下遍歷的讀取模式,那么整個查詢的時間復雜度就是O(log2n)。
?
下面來介紹一些優化的思路,因為進行隨機數的運算本身也是個很消耗cpu的操作,所以,一種最常見的優化就是,如果在插入的時候就能直接算出這個數據應該往高層推的總次數,那么就不需要算那么多次隨機數了,每次寫入只需要算一次就行了。
第二個優化的思路是如何能夠實現一個高性能的隨機數算法,這個各位可以自行搜索。
?
Skiplist是一個我個人很喜歡的數據結構,因為他足夠簡單,性能又好,除了運氣非常差的時候效率很低,其他時候都能做到很好的查詢效率,賭博什么的最喜歡了~~~最重要的是,它還足夠簡單和容易理解!
下面照例,我們使用一些通用的標準對skiplis進行一下簡單的評價:
1.???????是否支持范圍查找
?
因為是有序結構,所以能夠很好的支持范圍查找。
?
2.???????集合是否能夠隨著數據的增長而自動擴展
?
可以,因為核心數據結構是鏈表,所以是可以很好的支持數據的不斷增長的
?
3.???????讀寫性能如何
?
因為從宏觀上可以做到一次排除一半的數據,并且在寫入時也沒有進行其他額外的數據查找性工作,所以對于skiplist來說,其讀寫的時間復雜度都是O(log2n)
?
4.???????是否面向磁盤結構
?
磁盤要求順序寫,順序讀,一次讀寫必須是一整塊的數據。而對于skiplist來說,查詢中每一次從高層跳躍到底層的操作,都會對應一次磁盤隨機讀,而skiplist的層數從宏觀上來看一定是O(log2n)層。因此也就對應了O(log2n)次磁盤隨機讀。
因此這個數據結構不適合于磁盤結構。
?
并行指標
終于來到這個指標了,?skiplist的并行指標是非常好的,只要不是在同一個目標插入點插入數據,所有插入都可以并行進行,而就算在同一個插入點,插入本身也可以使用無鎖自旋來提升寫入效率。
因此skiplist是個并行度非常高的數據結構。
?
內存占用
與平衡二叉樹的內存消耗基本一致。
unsafe_skiplist
#include <iostream> #include <stdlib.h> #include <time.h> using namespace std;typedef int key_t; typedef int value_t; #define MAX_LEVEL 16 #define SKIPLIST_P 0.25struct node_t {key_t key;value_t val;node_t *forward[]; };class SkipList {protected:int m_level;int m_length;node_t *header;node_t *creatNode(int level, key_t key, value_t val) {node_t *node = (node_t*)malloc(sizeof(node_t)+level*sizeof(node_t*));if (node == NULL) {return NULL;}node->key = key;node->val = val;srand(time(NULL));return node;}public:SkipList() {header = creatNode(MAX_LEVEL, 0, 0);if (header == NULL)exit(-1);m_length = 0;m_level = 0;for (int i = 0; i < MAX_LEVEL; ++i) {header->forward[i] = NULL;}}value_t *getValue(key_t key) {int beg = m_level - 1;node_t *p = header;for (; beg >=0; --beg) {while (p->forward[beg] && p->forward[beg]->key <= key) {if (p->forward[beg]->key == key)return &p->forward[beg]->val;p = p->forward[beg];}//p = p->forward[beg-1];}return NULL;}int randomLevel() {int level = 1;while ((rand()&0xffff) < 0xffff*SKIPLIST_P)++level;if(level > MAX_LEVEL) level = MAX_LEVEL;return level;}void insert(key_t key, value_t val) {node_t *update[MAX_LEVEL];int beg = m_level - 1;node_t *p = header;node_t *last = NULL;for (; beg >=0; --beg) {while( (last = p->forward[beg]) && last->key < key) {p = p->forward[beg];}update[beg] = p;}if (last && last->key == key) {last->val = val;return;}m_length++;int level = randomLevel();if (level > m_level) {for(int i = m_level; i < level; ++i)update[i] = header;m_level = level;}node_t *node = creatNode(level, key, val);for (beg = level - 1; beg >=0; --beg) {node->forward[beg] = update[beg]->forward[beg];update[beg]->forward[beg] = node;}}void erase(key_t key) {node_t *update[MAX_LEVEL];int beg = m_level - 1;node_t *p = header;node_t *last = NULL;for (; beg >=0; --beg) {while ((last = p->forward[beg]) && last->key < key) {p = p->forward[beg];}}if (last && last->key != key)return;for (beg = m_level; beg >=0; --beg) {if (update[beg]->forward[beg] == last){update[beg]->forward[beg] = last->forward[beg];if (header->forward[beg] == NULL)m_level--;}}free(last);m_length--;}void display() {node_t *p = header->forward[0];while (p) {cout << p->key << ":"<<p->val<<" ";p = p->forward[0];}cout <<endl;}~SkipList() {node_t *p = header;while (p){node_t *next = p->forward[0];free(p);p = next;} }};int main() {SkipList sl;/*for (int i = 0; i < 1000; ++i){cout << i <<endl;sl.insert(rand(),i);}*/sl.insert(0,10);sl.insert(5, 50);sl.insert(6, 60);sl.insert(0, 11);sl.insert(5, 51);sl.insert(7,70);sl.insert(3, 30);sl.insert(4,40);sl.insert(3,31);sl.display();return 1; }
總結
以上是生活随笔為你收集整理的数据映射--跳表(skiplist)的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 数据映射--平衡二叉有序树
- 下一篇: Eclipse中查看源码