java 實現跳錶(skiplist)及論文解讀

Skiplist.png

什麼是跳躍表

跳錶由William Pugh發明。java

他在論文 《Skip lists: a probabilistic alternative to balanced trees》中詳細介紹了跳錶的數據結構和插入刪除等操做。程序員

跳錶是一種能夠用來代替平衡樹的數據結構,跳錶使用機率平衡而不是嚴格執行的平衡,所以,與等效樹的等效算法相比,跳錶中插入和刪除的算法要簡單得多,而且速度要快得多。

Skip_list.svg.png

爲何須要?

性能比較好。算法

實現相對於紅黑樹比較簡單。spring

佔用更少的內存。數組

論文解讀

爲了學習第一手的資料,咱們先學習一下論文,而後再結合網上的文章,實現一個 java 版本的 skip-list。安全

William Pugh

二叉樹可用於表示抽象數據類型,例如字典和有序列表。數據結構

當元素以隨機順序插入時,它們能夠很好地工做。某些操做序列(例如按順序插入元素)產生了生成的數據結構,這些數據結構的性能很是差。dom

若是能夠隨機排列要插入的項目列表,則對於任何輸入序列,樹都將很好地工做。在大多數狀況下,必須在線回答查詢,所以隨機排列輸入是不切實際的。ide

平衡樹算法會在執行操做時從新排列樹,以保持必定的平衡條件並確保良好的性能。svg

skiplist是平衡樹的一種機率替代方案

經過諮詢隨機數生成器來平衡skiplist。儘管skiplist在最壞狀況下的性能不好,可是沒有任何輸入序列會始終產生最壞狀況的性能(就像樞軸元素隨機選擇時的快速排序同樣)。

skiplist數據結構不太可能會嚴重失衡(例如,對於超過250個元素的字典,搜索所花費的時間超過預期時間的3倍的機會少於百萬分之一)。相似於經過隨機插入構建的搜索樹,但不須要插入便可是隨機的。

機率性地平衡數據結構比顯式地保持平衡容易。

ps: 大部分程序員能夠手寫 skip-list,可是手寫一個紅黑樹就要複雜的多。

對於許多應用程序,skiplist比樹更天然地表示,這也致使算法更簡單

skiplist算法的簡單性使其更易於實現,而且在平衡樹和自調整樹算法上提供了顯着的恆定因子速度改進。

skiplist也很是節省空間。它們能夠輕鬆配置爲每一個元素平均須要 4/3 個指針(甚至更少),而且不須要將平衡或優先級信息與每一個節點一塊兒存儲。

算法核心思想

對於一個linked list來講,若是要查找某個元素,咱們可能須要遍歷整個鏈表。

若是list是有序的,而且每兩個結點都有一個指針指向它以後兩位的結點(Figure 1b),那麼咱們能夠經過查找不超過 ⌈n/2⌉+1 個結點來完成查找。

若是每四個結點都有一個指針指向其以後四位的結點,那麼只須要檢查最多 ⌈n/4⌉+2 個結點(Figure 1c)。

若是全部的第(2^i)個結點都有一個指針指向其2^i以後的結點(Figure 1d),那麼最大須要被檢查的結點個數爲 ⌈log_n2⌉,代價僅僅是將須要的指針數量加倍。

這種數據結構的查詢效率很高,可是對它的插入和刪除幾乎是不可實現的(impractical)。

接下來看下論文中的一張圖:

skip-list

由於這樣的數據結構是基於鏈表的,而且額外的指針會跳過中間結點,因此做者稱之爲跳錶(Skip Lists)

結構

從圖中能夠看到, 跳躍表主要由如下部分構成:

表頭(head):負責維護跳躍表的節點指針。

跳躍表節點:保存着元素值,以及多個層。

層:保存着指向其餘元素的指針。高層的指針越過的元素數量大於等於低層的指針,爲了提升查找的效率,程序老是從高層先開始訪問,而後隨着元素值範圍的縮小,慢慢下降層次。

表尾:所有由 NULL 組成,表示跳躍表的末尾。

skip-list 算法過程

本節提供了在字典或符號表中搜索,插入和刪除元素的算法。

搜索操做將返回與所需的鍵或失敗的鍵關聯的值的內容(若是鍵不存在)。

插入操做將指定的鍵與新值相關聯(若是還沒有存在,則插入該鍵)。

Delete操做刪除指定的密鑰。

易於支持其餘操做,例如「查找最小鍵」或「查找下一個鍵」。每一個元素由一個節點表示,插入節點時,其級別是隨機選擇的,而不考慮元素的數量在數據結構中。

級別i節點具備i個前向指針,索引從1到i。

咱們不須要在節點中存儲節點的級別。級別以某個適當的常量MaxLevel進行上限。

list 的級別是列表中當前的最大級別(若是列表爲空,則爲1)。

列表的標題具備從一級到MaxLevel的前向指針。

標頭的前向指針在高於列表的當前最大級別的級別上指向NIL

初始化

分配元素NIL併爲其提供比任何合法密鑰更大的密鑰。

全部skiplist的全部級別均以NIL終止。

初始化一個新列表,以使列表的級別等於1,而且列表標題的全部前向指針都指向NIL

搜索算法

咱們經過遍歷不超過包含要搜索元素的節點的前向指針來搜索元素(圖2)。

若是在當前的前向指針級別上沒法再取得任何進展,則搜索將向下移動到下一個級別。

當咱們沒法在1級進行更多處理時,咱們必須緊靠在包含所需元素的節點以前(若是它在列表中)

skiplist-02.PN

插入和刪除算法

要插入或刪除節點,咱們只需進行搜索和拼接,如圖3所示。

skiplist-03

圖4給出了插入和刪除的算法。

保持向量更新,以便在搜索完成時(而且咱們準備執行拼接),update [i]包含一個指向級別i或更高級別的最右邊節點的指針,該指針位於插圖位置的左側 /刪除。

若是插入生成的節點的級別大於列表的先前最大級別,則咱們將更新列表的最大級別,並初始化更新向量的適當部分。

每次刪除後,咱們檢查是否刪除了列表的最大元素,若是刪除了,請減少列表的最大級別。

skiplist-04

選擇一個隨機級別

最初,咱們討論了機率分佈,其中一半的具備i指針的節點也具備i + 1指針。

爲了擺脫魔術常數,咱們說具備i指針的節點的一小部分也具備i + 1指針。 (對於咱們最初的討論,p = 1/2)。

經過與圖5中等效的算法隨機生成級別。

生成級別時不參考列表中元素的數量。

skiplist-05

咱們從什麼級別開始搜索?定義L(n)

在用p = 1/2生成的16個元素的skiplist中,咱們可能會遇到9個1級元素,3個2級元素,3個3級元素和1個14級元素(這是不太可能的,可是能夠發生)。

咱們應該如何處理呢?

若是咱們使用標準算法並從14級開始搜索,咱們將作不少無用的工做。

咱們應該從哪裏開始搜索?

咱們的分析代表,理想狀況下,咱們將在指望 1/p 個節點的級別L處開始搜索。

當 L = log_(1/p)n 時,會發生這種狀況。

因爲咱們將常常引用此公式,所以咱們將使用 L(n) 表示 log_(1/p)n。

對於決定如何處理列表中異常大的元素的狀況,有許多解決方案。

別擔憂,要樂觀些。

只需從列表中存在的最高級別開始搜索便可。

正如咱們將在分析中看到的那樣,n個元素列表中的最大級別明顯大於 L(n) 的機率很是小。

從列表中的最高級別開始搜索不會給預期搜索時間增長一個很小的常數。

這是本文描述的算法中使用的方法

使用少於給定的數量。

儘管一個元素可能包含14個指針的空間,但咱們不須要所有使用14個指針。

咱們能夠選擇僅使用 L(n) 級。

有不少方法能夠實現此目的,可是它們都使算法複雜化而且不能顯着提升性能,所以不建議使用此方法。

修復隨機性(dice)

若是咱們生成的隨機級別比列表中的當前最大級別大一倍以上,則只需將列表中當前的最大級別再加上一個做爲新節點的級別便可。

在實踐中,從直觀上看,此更改彷佛效果很好。

可是,因爲節點的級別再也不是徹底隨機的,這徹底破壞了咱們分析結果算法的能力。

程序員可能應該隨意實現它,而純粹主義者則應避免這樣作。

肯定MaxLevel

因爲咱們能夠安全地將級別限制爲 L(n),所以咱們應該選擇 MaxLevel = L(n)(其中N是skiplist中元素數量的上限)。

若是 p = 1/2,則使用 MaxLevel = 16適用於最多包含216個元素的數據結構。

ps: maxLevel 能夠經過元素個數+P的值推導出來。

針對 P,做者的建議使用 p = 1/4。後面的算法分析部分有詳細介紹,篇幅較長,感興趣的同窗能夠在 java 實現以後閱讀到。

Java 實現版本

加深印象

咱們不管看理論以爲本身會了,然而經常是眼高手低。

最好的方式就是本身寫一遍,這樣印象才能深入。

節點定義

咱們能夠認爲跳錶就是一個增強版本的鏈表。

全部的鏈表都須要一個節點 Node,咱們來定義一下:

/**
 * 元素節點
 * @param <E> 元素泛型
 * @author 老馬嘯西風
 */
private static class SkipListNode<E> {
    /**
     * key 信息
     * <p>
     * 這個是什麼?index 嗎?
     *
     * @since 0.0.4
     */
    int key;
    /**
     * 存放的元素
     */
    E value;
    /**
     * 向前的指針
     * <p>
     * 跳錶是多層的,這個向前的指針,最多和層數同樣。
     *
     * @since 0.0.4
     */
    SkipListNode<E>[] forwards;

    @SuppressWarnings("all")
    public SkipListNode(int key, E value, int maxLevel) {
        this.key = key;
        this.value = value;
        this.forwards = new SkipListNode[maxLevel];
    }
    @Override
    public String toString() {
        return "SkipListNode{" +
                "key=" + key +
                ", value=" + value +
                ", forwards=" + Arrays.toString(forwards) +
                '}';
    }
}

事實證實,鏈表中使用 array 比使用 List 可讓代碼變得簡潔一些。

至少在閱讀起來更加直,第一遍就是用 list 實現的,後來不所有重寫了。

對好比下:

newNode.forwards[i] = updates[i].forwards[i];   //數組

newNode.getForwards().get(i).set(i, updates.get(i).getForwards(i)); //列表

查詢實現

查詢的思想很簡單:咱們從最高層開始從左向右找(最上面一層能夠最快定位到咱們想找的元素大概位置),若是 next 元素大於指定的元素,就往下一層開始找。

任何一層,找到就直接返回對應的值。

找到最下面一層,尚未值,則說明元素不存在。

/**
 * 執行查詢
 * @param searchKey 查找的 key
 * @return 結果
 * @since 0.0.4
 * @author 老馬嘯西風
 */
public E search(final int searchKey) {
    // 從左邊最上層開始向右
    SkipListNode<E> c = this.head;
    // 從已有的最上層開始遍歷
    for(int i = currentLevel-1; i >= 0; i--) {
        while (c.forwards[i].key < searchKey) {
            // 當前節點在這一層直接向前
            c = c.forwards[i];
        }
        // 判斷下一個元素是否知足條件
        if(c.forwards[i].key == searchKey) {
            return c.forwards[i].value;
        }
    }
    // 查詢失敗,元素不存在。
    return null;
}

ps: 網上的不少實現都是錯誤的。大部分都沒有理解到 skiplist 查詢的精髓。

插入

若key不存在,則插入該key與對應的value;若key存在,則更新value。

若是待插入的結點的層數高於跳錶的當前層數currentLevel,則更新currentLevel。

選擇待插入結點的層數randomLevel:

randomLevel只依賴於跳錶的最高層數和機率值p。

算法在後面的代碼中。

另外一種實現方法爲,若是生成的randomLevel大於當前跳錶的層數currentLevel,那麼將randomLevel設置爲currentLevel+1,這樣方便之後的查找,在工程上是能夠接受的,但同時也破壞了算法的隨機性。

/**
 * 插入元素
 *
 *
 * @param searchKey 查詢的 key
 * @param newValue 元素
 * @since 0.0.4
 * @author 老馬嘯西風
 */
@SuppressWarnings("all")
public void insert(int searchKey, E newValue) {
    SkipListNode<E>[] updates = new SkipListNode[maxLevel];
    SkipListNode<E> curNode = this.head;
    for (int i = currentLevel - 1; i >= 0; i--) {
        while (curNode.forwards[i].key < searchKey) {
            curNode = curNode.forwards[i];
        }
        // curNode.key < searchKey <= curNode.forward[i].key
        updates[i] = curNode;
    }
    // 獲取第一個元素
    curNode = curNode.forwards[0];
    if (curNode.key == searchKey) {
        // 更新對應的值
        curNode.value = newValue;
    } else {
        // 插入新元素
        int randomLevel = getRandomLevel();
        // 若是層級高於當前層級,則更新 currentLevel
        if (this.currentLevel < randomLevel) {
            for (int i = currentLevel; i < randomLevel; i++) {
                updates[i] = this.head;
            }
            currentLevel = randomLevel;
        }
        // 構建新增的元素節點
        //head==>new  L-1
        //head==>pre==>new L-0
        SkipListNode<E> newNode = new SkipListNode<>(searchKey, newValue, randomLevel);
        for (int i = 0; i < randomLevel; i++) {
            newNode.forwards[i] = updates[i].forwards[i];
            updates[i].forwards[i] = newNode;
        }
    }
}

其中 getRandomLevel 是一個隨機生成 level 的方法。

/**
 * 獲取隨機的級別
 * @return 級別
 * @since 0.0.4
 */
private int getRandomLevel() {
    int lvl = 1;
    //Math.random() 返回一個介於 [0,1) 之間的數字
    while (lvl < this.maxLevel && Math.random() < this.p) {
        lvl++;
    }
    return lvl;
}

我的感受 skiplist 很是巧妙的一點就是利用隨機達到了和平衡樹相似的平衡效果。

不過也正由於隨機,每次的鏈表生成的都不一樣。

刪除

刪除特定的key與對應的value。

若是待刪除的結點爲跳錶中層數最高的結點,那麼刪除以後,要更新currentLevel。

/**
 * 刪除一個元素
 * @param searchKey 查詢的 key
 * @since 0.0.4
* @author 老馬嘯西風
 */
@SuppressWarnings("all")
public void delete(int searchKey) {
    SkipListNode<E>[] updates = new SkipListNode[maxLevel];
    SkipListNode<E> curNode = this.head;
    for (int i = currentLevel - 1; i >= 0; i--) {
        while (curNode.forwards[i].key < searchKey) {
            curNode = curNode.forwards[i];
        }
        // curNode.key < searchKey <= curNode.forward[i].key
        // 設置每一層對應的元素信息
        updates[i] = curNode;
    }
    // 最下面一層的第一個指向的元素
    curNode = curNode.forwards[0];
    if (curNode.key == searchKey) {
        for (int i = 0; i < currentLevel; i++) {
            if (updates[i].forwards[i] != curNode) {
                break;
            }
            updates[i].forwards[i] = curNode.forwards[i];
        }
        // 移除無用的層級
        while (currentLevel > 0 && this.head.forwards[currentLevel-1] ==  this.NIL) {
            currentLevel--;
        }
    }
}

輸出跳錶

爲了便於測試,咱們實現一個輸出跳錶的方法。

/**
 * 打印 list
 * @since 0.0.4
 */
public void printList() {
    for (int i = currentLevel - 1; i >= 0; i--) {
        SkipListNode<E> curNode = this.head.forwards[i];
        System.out.print("HEAD->");
        while (curNode != NIL) {
            String line = String.format("(%s,%s)->", curNode.key, curNode.value);
            System.out.print(line);
            curNode = curNode.forwards[i];
        }
        System.out.println("NIL");
    }
}

測試

public static void main(String[] args) {
    SkipList<String> list = new SkipList<>();
    list.insert(3, "耳朵聽聲音");
    list.insert(7, "鐮刀來割草");
    list.insert(6, "口哨嘟嘟響");
    list.insert(4, "紅旗迎風飄");
    list.insert(2, "小鴨水上漂");
    list.insert(9, "勺子能吃飯");
    list.insert(1, "鉛筆細又長");
    list.insert(5, "秤鉤來買菜");
    list.insert(8, "麻花扭一扭");
    list.printList();
    System.out.println("---------------");
    list.delete(3);
    list.delete(4);
    list.printList();
    System.out.println(list.search(8));
}

日誌以下:

HEAD->(5,秤鉤來買菜)->(6,口哨嘟嘟響)->NIL
HEAD->(1,鉛筆細又長)->(2,小鴨水上漂)->(3,耳朵聽聲音)->(4,紅旗迎風飄)->(5,秤鉤來買菜)->(6,口哨嘟嘟響)->(7,鐮刀來割草)->(8,麻花扭一扭)->(9,勺子能吃飯)->NIL
---------------
HEAD->(5,秤鉤來買菜)->(6,口哨嘟嘟響)->NIL
HEAD->(1,鉛筆細又長)->(2,小鴨水上漂)->(5,秤鉤來買菜)->(6,口哨嘟嘟響)->(7,鐮刀來割草)->(8,麻花扭一扭)->(9,勺子能吃飯)->NIL
麻花扭一扭

小結

SkipList 是很是巧妙的一個數據結構,到目前爲止,我仍是不能手寫紅黑樹,不過寫跳錶相對會輕鬆不少。給論文做者點贊!

下一節讓咱們一塊兒 jdk 中的 ConcurrentSkipListSet 數據結構,感覺下 java 官方實現的魅力。

但願本文對你有幫助,若是有其餘想法的話,也能夠評論區和你們分享哦。

各位極客的點贊收藏轉發,是老馬持續寫做的最大動力!

深度學習