最近開始看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完成了基本資料結構的封裝,實現了節點插入操作。後續部落格會陸續記錄“刪除”、“搜尋”等功能的實現。