最近開始看Redis設計原理,碰到一個從未遇見的數據結構:跳躍表(skiplist)。於是花時間學習了跳錶的原理,並用java對其實現。
主要參考以下兩本書:
- 《Redis設計與實現》跳錶部分:主要介紹跳錶在Redis中如何實現;
- 《算法:C語言實現(第1~4部分)》的13.5節:介紹跳錶的算法。
介紹
跳躍表是一種有序數據結構,它通過每個結點中維持多個指向其它結點的指針,從而達到快速訪問結點的目的。
我們平時熟知的鏈表,查找效率爲O(N)。跳錶在鏈表的基礎上,每個結點中維護了很多指向其它結點的指針,大大縮短時間複雜度。可以實現時間複雜度平均O(logN),最壞O(N)。後文會有具體的分析和計算。
一個跳躍表示意圖:
由左至右依次是,跳躍表結構結點(存儲跳錶信息)、頭結點、連續的跳錶結點。
最外層的跳錶字段結構如下所示:
public class SkipList<T extends Comparable<? super T>> {
//首尾結點的指針
private SkipListNode<T> header;
private SkipListNode<T> tail;
//記錄跳錶中結點數量
private long length;
//最大結點的層數
private int level;
//...
}
跳錶節點
跳錶節點記爲SkipListNode,內部字段結構如下:
class SkipListNode <T> {
//索引層
private SkipListLevel[] level;
//後退指針
private SkipListNode<T> backword;
//分值
private double score;
//成員對象
private T obj;
//......
}
- 索引層數組:多個索引層組成的數組,每個元素包含一個指向其它節點的指針。通過這些指針的訪問來加快查找速度。
- 後退指針:指向前一個節點;
- 分值:是一個浮點數,跳錶中所有節點都按照分值從小到大來排序;
- 成員對象:即指向具體的數據對象。
索引層
索引層SkipListLevel的結構如下:
class SkipListLevel{
//前進指針
private SkipListNode forward;
//跨度
private int span;
//......
}
- 前進指針:指向後續節點;
- 跨度:與指向的節點之間的距離。譬如,相鄰節點距離就是1。
到這裏,我們對跳錶的基本結構有了一個清晰的認識。
理想的跳錶
這裏想先講講理想狀態的跳錶,不然無法理解實際跳錶爲什麼可以縮減時間複雜度。
跳錶節點間的關聯方式:(索引層中的前向指針)第一層逐個鏈接,第二層每隔t個節點進行鏈接,第三層每隔2*t個節點進行鏈接,不斷迭代。這裏取t=2,畫出每個節點的索引層之間的關聯關係,得到如下圖形式的鏈式結構:
有點像完全二叉樹的結構。因此很容易理解:節點總數爲N時,層最大高度爲1+logN。例如圖中有8個節點,最大層高爲4。
搜索規則:從頭結點的索引層的末端開始向下遍歷。如果第K層的下一節點小於target,則移到該節點;若不小於,則下移到第K-1層。
按照此搜索規則,假設需要查找的target爲7a,則搜索路徑爲0d--8d--0c--4c--4b--6b--6a--7a,如下圖所示:
上述過程中,分別在8d、4c、6b、7a處進行比較。可見每一層都比較了一次,所以比較次數等於層數,爲logN+1。所以時間複雜度爲O(logN)。
如果實際的跳錶按照這種形式進行設計,每次插入節點時,需要對很多結點的索引層進行調整,節點的插入刪除將成爲極其複雜的工作。因此,實際的跳錶使用一種基於概率統計的算法,簡化插入刪除帶來的調整工作,同時也能得到O(logN)的時間複雜度。
實際的跳錶
每當需要新增一個節點時,需要考慮如何確定該節點的索引層層數,即SkipListLevel[]數組的長度。
如何確定“層”的高度?
在redis中,每次創建一個節點,都會根據冪次定律隨機生成一個介於1和32之間的值作爲索引層的高度。問題是,這個隨機的過程如何設計?
我們觀察理想狀態跳錶,可以發現,不算頭節點總共8個節點,其中4個節點擁有2層索引,2個節點擁有3層索引,1個節點擁有4層索引。
可以近似看作滿足這樣的規律:節點索引層高度爲 j 的概率爲 1/2^j。因此每次生成新節點時,通過這樣的概率計算可以得到索引層層數。代碼如下所示:
/**
* 獲取隨機的層高度
* @return
*/
private int getRandomHeight() {
Random random = new Random();
int i = 1;
for (; i < 32; ++i) {
if (random.nextInt(2) == 0) {
break;
}
}
return i;
}
注意:在redis中最大索引高度不超過32
爲什麼時間複雜度平均O(logN),最壞O(N)?
當節點數量足夠多時,這種方式得到的跳躍表形態可以逼近理想的跳錶的。很慚愧我不知道怎麼證明,學過概率統計的同學一定很容易理解。它的時間複雜度就是近似爲 O(logN) 。當然也有不理想的情況,當跳錶中每一個節點隨機得到的層高度都是 1 時,跳錶就是一個普通雙向鏈表,時間複雜度爲 O(N) 。因此,時間複雜度平均O(logN)、最壞O(N),這種說法是比較嚴謹的。
節點的分值
這個分值 score 很容易與節點的“跨度”混淆。跨度其實就是節點在跳錶中的排位,或者說序號。而分值是一個節點屬性。節點按照分值大小由小到大排列,不同節點的分值可以相等。如果分值相等,對象較大的會排在後面(靠近表尾方向)。
在實際API應用中,需要以分值和obj成員對象作爲target進行查詢、插入等操作。
跳躍表的插入-代碼實現
流程如下:
- 按照冪次定律獲取隨機數,作爲索引層的高度levelHeight,實例化新節點target;
- 設置一個SkipListNode類型的數組,update[](記錄所有需要進行調整的前置位節點,包括需要調整forword、或者只需要修改span值的節點),update[]的大小爲max(levelHeight,maxLevelHeight);
- 設置int數組rank[],記錄update[]數組中各個對應節點的排位
- 遍歷 update[] 進行插入和更新操作;根據update[]獲取插入位置節點,進行插入;根據rank[]來輔助更新跨度值span。
實際代碼比上述流程要複雜很多,levelHeight與maxLevelHeight的大小關係不能確定,根據不同的情況要對update[]進行不同的處理。
跳躍表插入的代碼如下所示:
注意:是依據score大小和obj的大小來決定插入順序
public SkipListNode slInsert(double score, T obj) {
int levelHeight = getRandomHeight();
SkipListNode<T> target = new SkipListNode<>(obj, levelHeight, score);
// update[i] 記錄所有需要進行調整的前置位節點
SkipListNode[] update = new SkipListNode[Math.max(levelHeight, maxLevel)];
int[] rank = new int[update.length];//記錄每一個update節點的排位
int i = update.length - 1;
if (levelHeight > maxLevel) {
for (; i >= maxLevel; --i) {
update[i] = header;
rank[i] = 0;
}
maxLevel = levelHeight;
}
for (; i >= 0; --i) {
SkipListNode<T> node = header;
SkipListNode<T> next = node.getLevel()[i].getForward();
rank[i] = 0;
//遍歷得到與target最接近的節點(左側)
while (next != null && (score > next.getScore() || score == next.getScore() && next.getObj().compareTo(obj) < 0)) {
rank[i] += node.getLevel()[i].getSpan();
node = next;
next = node.getLevel()[i].getForward();
}
update[i] = node;
}
//當maxLevel>levelHeight,前面部分節點的span值加1,因爲該節點與forword指向節點之間將要 多出來一個新節點
for (i = update.length - 1; i >= levelHeight; --i) {
int span = update[i].getLevel()[i].getSpan();
update[i].getLevel()[i].setSpan(++span);
}
//遍歷 update[] 進行插入和更新操作
for (; i >= 0; --i) {
SkipListLevel pre = update[i].getLevel()[i];
//將target節點插入update[i]和temp之間
SkipListNode<T> temp = pre.getForward();
int span = pre.getSpan();
pre.setForward(target);
pre.setSpan(rank[0] + 1 - rank[i]);
target.getLevel()[i].setSpan(span > 0 ? (span - rank[0] + rank[i]) : 0);
target.getLevel()[i].setForward(temp);
//設置後退指針
if (temp == null) {
target.setBackword(header);
} else {
target.setBackword(temp.getBackword());
temp.setBackword(target);
}
}
if (tail.getLevel()[0].getForward() != null) {
tail = target;
}
length++;
return target;
}
本篇博客介紹了跳躍表基本原理,並使用java完成了基本數據結構的封裝,實現了節點插入操作。後續博客會陸續記錄“刪除”、“搜索”等功能的實現。