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