【Redis】跳躍表原理分析與基本程式碼實現(java)

數小錢錢的種花兔發表於2020-05-06

最近開始看Redis設計原理,碰到一個從未遇見的資料結構:跳躍表(skiplist)。於是花時間學習了跳錶的原理,並用java對其實現。

主要參考以下兩本書:

  • 《Redis設計與實現》跳錶部分:主要介紹跳錶在Redis中如何實現;
  • 《演算法:C語言實現(第1~4部分)》的13.5節:介紹跳錶的演算法。

介紹

跳躍表是一種有序資料結構,它通過每個結點中維持多個指向其它結點的指標,從而達到快速訪問結點的目的。

我們平時熟知的連結串列,查詢效率為O(N)。跳錶在連結串列的基礎上,每個結點中維護了很多指向其它結點的指標,大大縮短時間複雜度。可以實現時間複雜度平均O(logN),最壞O(N)。後文會有具體的分析和計算。

一個跳躍表示意圖:
image
由左至右依次是,跳躍表結構結點(儲存跳錶資訊)、頭結點、連續的跳錶結點。

最外層的跳錶欄位結構如下所示:

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,畫出每個節點的索引層之間的關聯關係,得到如下圖形式的鏈式結構:

image

有點像完全二叉樹的結構。因此很容易理解:節點總數為N時,層最大高度為1+logN。例如圖中有8個節點,最大層高為4。

搜尋規則:從頭結點的索引層的末端開始向下遍歷。如果第K層的下一節點小於target,則移到該節點;若不小於,則下移到第K-1層。

按照此搜尋規則,假設需要查詢的target為7a,則搜尋路徑為0d--8d--0c--4c--4b--6b--6a--7a,如下圖所示:

image

上述過程中,分別在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完成了基本資料結構的封裝,實現了節點插入操作。後續部落格會陸續記錄“刪除”、“搜尋”等功能的實現。

相關文章