深夜学算法之SkipList:让链表飞
1. 前言
上次寫Python操作LevelDB時提到過,有機(jī)會要實現(xiàn)下SkipList。摘錄下wiki介紹:
跳躍列表是一種隨機(jī)化數(shù)據(jù)結(jié)構(gòu),基于并聯(lián)的鏈表,其效率可比擬二叉查找樹。
我們知道對于有序鏈表,查找的時間復(fù)雜度為O(n),盡管真正的插入與刪除操作節(jié)點復(fù)雜度只有O(1),但都需要先查找到節(jié)點的位置,可以說是查找拉低了有序鏈表的性能。
簡單地講,SkipList采用“空間換時間”的思想,除了原始鏈表外還保存一些“跳躍”的鏈表,達(dá)到加速查找的效果。
我的實現(xiàn):https://github.com/liquidconv/DSAF
2. 感性認(rèn)識SkipList
bottom-up與top-down,我個人傾向后者。所以在給出SkipList里具體定義與算法前,先從問題出發(fā),研究一下SkipList的設(shè)計思路。
來看一個有序鏈表(這里H表示鏈表頭部,T表示鏈表尾部,不是有效節(jié)點):
1.png
假設(shè)我們要查找7,只能老老實實地按照1->2->3->…的順序走,忍受O(n)的效率;但如果是數(shù)組的話,可以使用二分查找達(dá)到O(lgn)。
可以在鏈表中使用二分查找嗎?
不可以,因為二分查找需要用到中間位置的節(jié)點,而鏈表不能隨機(jī)訪問。
——那么就把中間位置的節(jié)點單獨保存吧。
2.png
原來的鏈表寫成了三個鏈表,記從下到上的編號為0、1、2,可以發(fā)現(xiàn)0號鏈表就是原始鏈表,1號鏈表是原始鏈表四等分點,2號鏈表是原始鏈表的二等分點。
我們再來查找7,初始搜索范圍為(H, T):
形象化地說,SkipList就是額外保存了二分查找的中間信息。不過SkipList中含有隨機(jī)化,生成的結(jié)構(gòu)不會像上面那樣完美,來看實際生成的一個SkipList:
3.png
之后會詳細(xì)討論隨機(jī)化的問題,現(xiàn)在先承上啟下地梳理下信息:
- SkipList結(jié)合了鏈表和二分查找的思想
- 將原始鏈表和一些通過“跳躍”生成的鏈表組成層
- 第0層是原始鏈表,越上層“跳躍”的步距越大,鏈表元素越少
- 上層鏈表是下層鏈表的子序列
- 查找時從頂層向下,不斷縮小搜索范圍
最后,可以利用“鏈”的性質(zhì),減少存儲空間:
4.png
3. 實現(xiàn)SkipList
這里寫的SkipList是非常naive的,有許多可優(yōu)化之處。
3.1 定義
首先定義SkipList中的節(jié)點:
typedef struct SkipListNode {int key;void *data;int level;SkipListNode **next_nodes; } SkipListNode;key是鍵,data是值,與標(biāo)準(zhǔn)鏈表中的節(jié)點一樣;區(qū)別在“鏈”的部分,level表示節(jié)點在第幾層中,next_nodes是每層上的后繼節(jié)點——比如上面那個例子里的節(jié)點4,在第2層是T,在第1層是6,在第0層是5。
然后來定義SkipList:
class SkipList {public:SkipList(int max_level);~SkipList(void);void insertNode(int key, void *data);void deleteNode(int key);void *getData(int key);void displayList(void);private:int MAX_LEVEL;int RandomLevel(void);SkipListNode *head;SkipListNode *tail; };接口的含義還是很清楚的。構(gòu)造SkipList時給定最大層數(shù)(其實是可以讓層數(shù)動態(tài)增長的),displayList用于打印整個SkipList。
這里假設(shè)key是不重復(fù)的,所以insertNode實現(xiàn)了插入與修改,deleteNode實現(xiàn)了刪除,getData實現(xiàn)了查找。
3.2 構(gòu)造與析構(gòu)
首先來看構(gòu)造函數(shù)SkipList(int max_level):
SkipList::SkipList(int max_level) {MAX_LEVEL = max_level > 0? max_level : 1;head = new SkipListNode;tail = new SkipListNode;head->next_nodes = new SkipListNode *[MAX_LEVEL];for(int i = 0; i < MAX_LEVEL; ++i)head->next_nodes[i] = tail; }首先確定SkipList的最大層數(shù)MAX_LEVEL,然后生成head與tail節(jié)點,head節(jié)點顯然必須是一個MAX_LEVEL層的節(jié)點,讓head在每一層上的后繼節(jié)點都是tail。
用圖片來表示SkipLsit(3)的話,就是:
5.png
析構(gòu)函數(shù)~SkipList(void)也很簡單:
SkipList::~SkipList(void) {SkipListNode *curr = nullptr;while(head->next_nodes[0] != tail) {curr = head->next_nodes[0];head->next_nodes[0] = curr->next_nodes[0];delete curr->next_nodes;delete curr;}delete head->next_nodes;delete head;delete tail; }第0層的鏈表是原始鏈表,上層鏈表的節(jié)點都來自第0層,所以可以利用這個性質(zhì),沿著第0層鏈表釋放節(jié)點,注意除了釋放SkipListNode還要釋放里面的next_nodes。
3.3 插入、刪除與查找
SkipList的插入、刪除與查找一脈相承,理解插入后刪除與查找都很簡單。但在給出插入算法的代碼前,先讓我們想想insertNode里需要做哪些工作:
- 標(biāo)準(zhǔn)有序鏈表插入前需要定位,通常是確定新節(jié)點的前驅(qū)節(jié)點;SkipList中一個節(jié)點至多是MAX_LEVEL層的,需要插入到MAX_LEVEL個有序鏈表里,所以要確定每層的前驅(qū)節(jié)點
- 構(gòu)造新節(jié)點,生成小于MAX_LEVEL的隨機(jī)數(shù)k,作為新節(jié)點的層數(shù)
- 將新節(jié)點插入到第0層到第(k-1)層的鏈表中
概括起來還是三步走:找前驅(qū),做節(jié)點,插入鏈表。
第一步,找前驅(qū):
SkipListNode *update[MAX_LEVEL];SkipListNode *curr = head;for(int i = MAX_LEVEL - 1; i >= 0; --i) {if(curr->next_nodes[i] == tail || curr->next_nodes[i]->key > key)update[i] = curr;else {while(curr->next_nodes[i] != tail && curr->next_nodes[i]->key < key)curr = curr->next_nodes[i];update[i] = curr;}}update是前驅(qū)節(jié)點數(shù)組,curr用來迭代,初始值為head。for循環(huán)的大結(jié)構(gòu)是自頂向下遍歷每層,找到該層上新節(jié)點的前驅(qū)節(jié)點。
重點在于if-else結(jié)構(gòu),我們來看第i層。curr只有后繼節(jié)點不是tail,而且curr第i層后繼節(jié)點的key比新節(jié)點key小的時候才會更新,所以curr滿足性質(zhì):
curr的后繼節(jié)點是tail,或者curr->key比key小。
假如curr的后繼節(jié)點是tail,或者curr的key比新節(jié)點的key小,curr的后繼節(jié)點比新節(jié)點的key大的話,新節(jié)點的插入位置都正好在curr后面,也就是curr是新節(jié)點在第i層的前驅(qū)節(jié)點。
否則就需要在第i層鏈表上向后移動curr,直到curr的后繼節(jié)點是tail,或者curr的后繼節(jié)點的key大于新節(jié)點的key,也就是回到之前的情形。
假設(shè)要在下面的SkipList里插入5,來看update數(shù)組的計算過程:
6.png
i = 2
curr進(jìn)入循環(huán)時為head,第1層后繼節(jié)點為curr->next_nodes[2]
curr->next_nodes[2]不是tail,而且key = 4 < 5
進(jìn)入else部分,更新curr為4號節(jié)點
update[2] = 4號節(jié)點
i = 1,搜索范圍為(4, tail)
curr進(jìn)入循環(huán)時為4號節(jié)點,第1層后繼節(jié)點為curr->next_nodes[1]
curr->next_nodes[1]不是tail但key = 6 > 5
進(jìn)入if部分,不更新curr
update[1] = 4號節(jié)點
i = 0,搜索范圍為(4, 6)
curr進(jìn)入循環(huán)時為4號節(jié)點,后繼節(jié)點為6號節(jié)點
進(jìn)入if部分,不更新curr
update[0] = 4號節(jié)點
繼續(xù)之前搜索范圍的說法,搜索的過程可以看做搜索范圍(curr, curr->next_nodes[i])的收緊。初始時為(head, tail),每層的while循環(huán)里收緊下界,curr遞增,在逐層下降的for循環(huán)里收緊上界,curr->next_nodes[i]遞減。
這里為了清晰刪除了保證key不重復(fù)的代碼,后面有完整版。
第二步,做節(jié)點
int level = RandomLevel();SkipListNode *temp = new SkipListNode;temp->key = key;temp->data = data;temp->level = level;temp->next_nodes = new SkipListNode *[level + 1];內(nèi)容非常簡單,RandomLevel()之后討論隨機(jī)化時再說,總之就是產(chǎn)生一個0到MAX_LEVEL - 1之間的隨機(jī)數(shù)。唯一的坑就是生成next_nodes是要用(level+1)而不是level,考慮level = 0的情形就明白了。
第三步,插入鏈表
for(int i = 0; i <= level; ++i) {temp->next_nodes[i] = update[i]->next_nodes[i];update[i]->next_nodes[i] = temp;}來看完整的insertNode(int key, void *data):
void SkipList::insertNode(int key, void *data) {SkipListNode *update[MAX_LEVEL];SkipListNode *curr = head;// 尋找每一層上待插入節(jié)點之前的節(jié)點for(int i = MAX_LEVEL - 1; i >= 0; --i) {if(curr->next_nodes[i] == tail || curr->next_nodes[i]->key > key)update[i] = curr;else {while(curr->next_nodes[i] != tail && curr->next_nodes[i]->key < key)curr = curr->next_nodes[i];if(curr->next_nodes[i] != tail && curr->next_nodes[i]->key == key) {curr->next_nodes[i]->data = data;return;}update[i] = curr;}}// 生成待插入節(jié)點int level = RandomLevel();SkipListNode *temp = new SkipListNode;temp->key = key;temp->data = data;temp->level = level;temp->next_nodes = new SkipListNode *[level + 1];// 在每層上的鏈表中插入節(jié)點for(int i = 0; i <= level; ++i) {temp->next_nodes[i] = update[i]->next_nodes[i];update[i]->next_nodes[i] = temp;} }刪除與插入完全是對稱的,直接來看代碼:
void SkipList::deleteNode(int key) {SkipListNode *update[MAX_LEVEL];SkipListNode *curr = head;// 尋找每一層上待刪除節(jié)點之前的節(jié)點for(int i = MAX_LEVEL - 1; i >= 0; --i) {if(curr->next_nodes[i] == tail || curr->next_nodes[i]->key > key)update[i] = nullptr;else {while(curr->next_nodes[i] != tail && curr->next_nodes[i]->key < key)curr = curr->next_nodes[i];if(curr->next_nodes[i] != tail && curr->next_nodes[i]->key == key)update[i] = curr;elseupdate[i] = nullptr;}}SkipListNode *temp = nullptr;// 在每層上的鏈表中刪除節(jié)點for(int i = 0; i < MAX_LEVEL; ++i) {if(update[i]) {temp = update[i]->next_nodes[i];update[i]->next_nodes[i] = temp->next_nodes[i];}}// 最終釋放節(jié)點if(temp) {delete temp->next_nodes;delete temp;} }同樣先查找前驅(qū)數(shù)組,由于節(jié)點不一定在某層中出現(xiàn),找不到時就把前驅(qū)節(jié)點標(biāo)記為nullptr,在該節(jié)點出現(xiàn)的層的鏈表里刪除該節(jié)點,最終釋放節(jié)點。
查找就更加簡單了,從上到下遍歷,找到就返回:
void *SkipList::getData(int key) {SkipListNode* curr = head;for(int i = MAX_LEVEL - 1; i >= 0; --i) {if(curr->next_nodes[i] == tail || curr->next_nodes[i]->key > key)continue;else {while(curr->next_nodes[i] != tail && curr->next_nodes[i]->key < key)curr = curr->next_nodes[i];if(curr->next_nodes[i] != tail && curr->next_nodes[i]->key == key)return curr->next_nodes[i]->data;}}return nullptr; }3.4 隨機(jī)化
SkipList是一種概率算法,非常依賴于生成的隨機(jī)數(shù)。這里不能用rand() % MAX_LEVEL的簡單做法,而要用滿足p=1/2幾何分布的隨機(jī)數(shù)。
來看RandomLevel()的代碼:
int SkipList::RandomLevel(void) {int level = 0;while(rand() % 2 && level < MAX_LEVEL - 1)++level;return level; }這里不做太多的數(shù)學(xué)分析,只做直觀解釋。考慮MAX_LEVEL = 4的情形,可能的返回值為0、1、2、3,顯然出現(xiàn)概率分別為:
P(0) = (1/2)^0 * (1/2) = 1/2
P(1) = (1/2)^1 * (1/2) = 1/4
P(2) = (1/2)^2 * (1/2) = 1/8
P(3) = 1 - P(0) - P(1) - P(2) = 1/8
假設(shè)有16個元素的話,可以預(yù)計第0層有16個元素,第1層約有16 - 8 = 8個元素,第2層約有16 - 8 - 4 = 4個元素,第3層約有16 - 8 -4 -2 = 2個元素,從底向上每層元素數(shù)量大約減少一半。
SkipList層數(shù)合適時自頂向下搜索,理想情況下每下降一層,搜索范圍減小一半,達(dá)到類似二分查找的效果,效率為O(lgn);最壞情況下也只是curr從head移動到tail,效率為O(n)。
我的實現(xiàn)里最大層數(shù)是通過MAX_LEVEL靜態(tài)指定的,也可以讓最大層數(shù)動態(tài)增長——RandomLevel里不設(shè)置最大值,插入節(jié)點時得到的level比當(dāng)前SkipList層數(shù)大時就在頂上再加一層,刪除節(jié)點時如果只有這個節(jié)點在高層就去掉高層。
4. 參考資料
- Skip list:
https://en.wikipedia.org/wiki/Skip_list - skiplist 跳表詳解及其編程實現(xiàn)
http://www.tuicool.com/articles/J7rQRb
作者:kophy
鏈接:https://www.jianshu.com/p/fcd18946994e
來源:簡書
簡書著作權(quán)歸作者所有,任何形式的轉(zhuǎn)載都請聯(lián)系作者獲得授權(quán)并注明出處。
總結(jié)
以上是生活随笔為你收集整理的深夜学算法之SkipList:让链表飞的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: Kafka是靠什么机制保持高可靠,高可用
- 下一篇: 红黑树、B(+)树、跳表、AVL对比