太刺激了,面試官讓我手寫跳錶,而我用兩種實現方式吊打了TA!

彤哥讀原始碼發表於2020-09-08

前言

本文收錄於專輯:http://dwz.win/HjK,點選解鎖更多資料結構與演算法的知識。

你好,我是彤哥。

上一節,我們一起學習了關於跳錶的理論知識,相信通過上一節的學習,你一定可以給面試官完完整整地講清楚跳錶的來龍去脈,甚至能夠邊講邊畫圖。

15

然而,面試官說,既然你這麼精通跳錶,不如實現一個唄^^

我,我,實現就實現,誰怕誰,哼~~

本節,我將通過兩種方式手寫跳錶,並結合畫圖,徹底搞定跳錶實現的細節。

第一種方式為跳錶的通用實現,第二種方式為彤哥自己發明的實現,並運用到HashMap的改寫中。

好了,開始今天的學習吧,Let's Go!

文末有跳錶和紅黑樹實現的HashMap的對比,不想看程式碼的同學也可以直達底部。

通用實現

通用實現主要參考JDK中的ConcurrentSkipListMap,在其基礎上,簡化,並優化一些東西,學好通用實現也有助於理解JDK中的ConcurrentSkipListMap的原始碼。

資料結構

首先,我們要定義好實現跳錶的資料結構,在通用實現中,將跳錶的資料結構分成三種:

  • 普通節點,處於0層的節點,儲存資料,典型的單連結串列結構,包括h0
  • 索引節點,包含著對普通節點的引用,同時增加向右、向下的指標
  • 頭索引節點,繼承自索引節點,同時,增加所在的層級

類圖大概是這樣:

3

OK,給出程式碼如下:

/**
  * 頭節點:標記層
  * @param <T>
  */
private static class HeadIndex<T> extends Index<T> {
    // 層級
    int level;

    public HeadIndex(Node<T> node, Index<T> down, Index<T> right, int level) {
        super(node, down, right);
        this.level = level;
    }
}

/**
  * 索引節點:引用著真實節點
  * @param <T>
  */
private static class Index<T> {
    // 真實節點
    Node<T> node;
    // 下指標(第一層的索引實際上是沒有下指標的)
    Index<T> down;
    // 右指標
    Index<T> right;

    public Index(Node<T> node, Index<T> down, Index<T> right) {
        this.node = node;
        this.down = down;
        this.right = right;
    }
}

/**
  * 連結串列中的節點:真正存資料的節點
  * @param <T>
  */
static class Node<T> {
    // 節點元素值
    T value;
    // 下一個節點
    Node<T> next;

    public Node(T value, Node<T> next) {
        this.value = value;
        this.next = next;
    }

    @Override
    public String toString() {
        return (value==null?"h0":value.toString()) +"->" + (next==null?"null":next.toString());
    }
}

查詢元素

查詢元素,是通過頭節點,先盡最大努力往右,再往下,再往右,每一層都要盡最大努力往右,直到右邊的索引比目標值大為止,到達0層的時候再按照連結串列的方式來遍歷,用圖來表示如下:

4

所以,整個過程分成兩大步:

  1. 尋找目標節點前面最接近的索引對應的節點;
  2. 按連結串列的方式往後遍歷;

請注意這裡的指標,在索引中叫作right,在連結串列中叫作next,是不一樣的。

這樣一分析程式碼實現起來就比較清晰了:

/**
  * 查詢元素
  * 先找到前置索引節點,再往後查詢
  * @param value
  * @return
  */
public T get(T value) {
    System.out.println("查詢元素:歡迎關注公眾號彤哥讀原始碼,獲取更多架構、基礎、原始碼好文!");
    if (value == null) {
        throw new NullPointerException();
    }
    Comparator<T> cmp = this.comparator;
    // 第一大步:先找到前置的索引節點
    Node<T> preIndexNode = findPreIndexNode(value, true);
    // 如果要查詢的值正好是索引節點
    if (preIndexNode.value != null && cmp.compare(preIndexNode.value, value) == 0) {
        return value;
    }
    // 第二大步:再按連結串列的方式查詢
    Node<T> q;
    Node<T> n;
    int c;
    for (q = preIndexNode;;) {
        n = q.next;
        c = cmp.compare(n.value, value);
        // 找到了
        if (c == 0) {
            return value;
        }
        // 沒找到
        if (c > 0) {
            return null;
        }
        // 看看下一個
        q = n;
    }
}

/**
  *
  * @param value 要查詢的值
  * @param contain 是否包含value的索引
  * @return
  */
private Node<T> findPreIndexNode(T value, boolean contain) {
    /*
         * q---->r---->r
         * |     |
         * |     |
         * v     v
         * d     d
         * q = query
         * r = right
         * d = down
         */
    // 從頭節點開始查詢,規律是先往右再往下,再往右再往下
    Index<T> q = this.head;
    Index<T> r, d;
    Comparator<T> cmp = this.comparator;
    for(;;) {
        r = q.right;
        if (r != null) {
            // 包含value的索引,正好有
            if (contain && cmp.compare(r.node.value, value) == 0) {
                return r.node;
            }
            // 如果右邊的節點比value小,則右移
            if (cmp.compare(r.node.value, value) < 0) {
                q = r;
                continue;
            }
        }
        d = q.down;
        // 如果下面的索引為空了,則返回該節點
        if (d == null) {
            return q.node;
        }
        // 否則,下移
        q = d;
    }
}

新增元素

新增元素,相對來說要複雜得多。

首先,新增一個元素時,要先找到這個元素應該插入的位置,並將其新增到連結串列中;

然後,考慮建立索引,如果需要建立索引,又分成兩步:一步是建立豎線(down),一步是建立橫線(right);

怎麼說呢?以下面這個圖為例,現在要插入元素6,且需要建立三層索引:

5

首先,找到6的位置,走過的路徑為 h1->3->3->4,發現應該插入到4和7之間,插入之:

6

然後,建立豎線,即向下的指標,一共有三層,因為超過了當前最高層級,所以,頭節點也要相應地往上增加一層,如下:

7

此時,橫向的指標是一個都沒動的。

最後,修正橫向的指標,即 h2->6、3->6、6->7,修正完成則表示插入元素成功:

8

這就是插入元素的整個過程,Show You the Code:

/**
  * 新增元素
  * 不能新增相同的元素
  * @param value
  */
public void add(T value) {
    System.out.println("新增元素:歡迎關注公眾號彤哥讀原始碼,獲取更多架構、基礎、原始碼好文!");
    if (value == null) {
        throw new NullPointerException();
    }
    Comparator<T> cmp = this.comparator;
    // 第一步:先找到前置的索引節點
    Node<T> preIndexNode = findPreIndexNode(value, true);
    if (preIndexNode.value != null && cmp.compare(preIndexNode.value, value) == 0) {
        return;
    }

    // 第二步:加入到連結串列中
    Node<T> q, n, t;
    int c;
    for (q = preIndexNode;;) {
        n = q.next;
        if (n == null) {
            c = 1;
        } else {
            c = cmp.compare(n.value, value);
            if (c == 0) {
                return;
            }
        }
        if (c > 0) {
            // 插入連結串列節點
            q.next = t = new Node<>(value, n);
            break;
        }
        q = n;
    }

    // 決定索引層數,每次最多隻能比最大層數高1
    int random = ThreadLocalRandom.current().nextInt();
    // 倒數第一位是0的才建索引
    if ((random & 1) == 0) {
        int level = 1;
        // 從倒數第二位開始連續的1
        while (((random >>>= 1) & 1) != 0) {
            level++;
        }

        HeadIndex<T> oldHead = this.head;
        int maxLevel = oldHead.level;
        Index<T> idx = null;
        // 如果小於或等於最大層數,則不用再額外建head索引
        if (level <= maxLevel) {
            // 第三步1:先連好豎線
            for (int i = 1; i <= level; i++) {
                idx = new Index<>(t, idx, null);
            }
        } else {
            // 大於了最大層數,則最多比最大層數多1
            level = maxLevel + 1;
            // 第三步2:先連好豎線
            for (int i = 1; i <= level; i++) {
                idx = new Index<>(t, idx, null);
            }
            // 新建head索引,並連好新head到最高node的線
            HeadIndex<T> newHead = new HeadIndex<>(oldHead.node, oldHead, idx, level);
            this.head = newHead;
            idx = idx.down;
        }

        // 第四步:再連橫線,從舊head開始再走一遍遍歷
        Index<T> qx, r, d;
        int currentLevel;
        for (qx = oldHead, currentLevel=oldHead.level;qx != null;) {
            r = qx.right;
            if (r != null) {
                // 如果右邊的節點比value小,則右移
                if (cmp.compare(r.node.value, value) < 0) {
                    qx = r;
                    continue;
                }
            }
            // 如果目標層級比當前層級小,直接下移
            if (level < currentLevel) {
                qx = qx.down;
            } else {
                // 右邊到盡頭了,連上
                idx.right = r;
                qx.right = idx;
                qx = qx.down;
                idx = idx.down;
            }
            currentLevel--;
        }
    }
}

刪除元素

經過了上面的插入元素的全過程,刪除元素相對來說要容易了不少。

同樣地,首先,找到要刪除的元素,從連結串列中刪除。

然後,修正向右的索引,修正了向右的索引,向下的索引就不用管了,相當於從整個跳錶中把向下的那一坨都刪除了,等著垃圾回收即可。

其實,上面兩步可以合成一步,在尋找要刪除的元素的同時,就可以把向右的索引修正了。

以下圖為例,此時,要刪除7這個元素:

9

首先,尋找刪除的元素的路徑:h2->6->6,到這裡的時候,正好看到右邊有個7,把它幹掉:

10

然後,繼續往下,走到了綠色的6這裡,再往後按連結串列的方式刪除元素,這個大家都會了:

11

OK,給出刪除元素的程式碼(檢視完整程式碼,關注公主號彤哥讀原始碼回覆skiplist領取):

/**
  * 刪除元素
  * @param value
  */
public void delete(T value) {
    System.out.println("刪除元素:歡迎關注公眾號彤哥讀原始碼,獲取更多架構、基礎、原始碼好文!");
    if (value == null) {
        throw new NullPointerException();
    }
    Index<T> q = this.head;
    Index<T> r, d;
    Comparator<T> cmp = this.comparator;
    Node<T> preIndexNode;
    // 第一步:尋找元素
    for(;;) {
        r = q.right;
        if (r != null) {
            // 包含value的索引,正好有
            if (cmp.compare(r.node.value, value) == 0) {
                // 糾正:順便修正向右的索引
                q.right = r.right;
            }
            // 如果右邊的節點比value小,則右移
            if (cmp.compare(r.node.value, value) < 0) {
                q = r;
                continue;
            }
        }
        d = q.down;
        // 如果下面的索引為空了,則返回該節點
        if (d == null) {
            preIndexNode = q.node;
            break;
        }
        // 否則,下移
        q = d;
    }

    // 第二步:從連結串列中刪除
    Node<T> p = preIndexNode;
    Node<T> n;
    int c;
    for (;;) {
        n = p.next;
        if (n == null) {
            return;
        }
        c = cmp.compare(n.value, value);
        if (c == 0) {
            // 找到了
            p.next = n.next;
            return;
        }
        if (c > 0) {
            // 沒找到
            return;
        }
        // 後移
        p = n;
    }
}

OK,到這裡,跳錶的通用實現就完事了,其實,你也可以發現,這裡還是有一些可以優化的點的,比如right和next指標為什麼不能合二為一呢?向下的指標能不能跟指向Node的指標合併呢?

關注公主號彤哥讀原始碼,回覆“skiplist”領取本節完整原始碼,包含測試程式碼。

為了嘗試解決這些問題,彤哥又自己研究了一種實現,這種實現不再區分頭索引節點、索引節點、普通節點,把它們全部合併成一個,大家都是一樣的,並且,我將它運用到了HashMap的改造中,來看看吧。

彤哥獨家實現

因為,正好要改造HashMap,所以,關於彤哥的獨家實現,我會與HashMap的改造一起來講解,新的HashMap,我們稱之為SkiplistHashMap(前者),它不同於JDK中現有的ConcurrentSkipListMap(後者),前者是一個HashMap,時間複雜度為O(1),後者其實不是HashMap,它只是跳錶實現的一種Map,時間複雜度為O(log n)。

另外,我將Skip和List兩個單詞合成一個了,這是為了後面造一個新單詞——Skiplistify,跳錶化,-ify詞綴結尾,什麼化,比如,treeify樹化、heapify堆化。

好了,開始SkiplistHashMap的實現,Come On!

資料結構

讓我們分析一下SkiplistHashMap,首先,它有一個陣列,其次,出現衝突的時候先使用連結串列來儲存衝突的節點,然後,達到一定的閾值時,將連結串列轉換成跳錶,所以,它至少需要以下兩大種節點型別:

普通節點,單連結串列結構,儲存key、value、hash、next等,結構簡單,直接給出程式碼:

/**
  * 連結串列節點,平凡無奇
  * @param <K>
  * @param <V>
  */
static class Node<K extends Comparable<K>, V> {
    int hash;
    K key;
    V value;
    Node<K, V> next;

    public Node(int hash, K key, V value, Node<K, V> next) {
        this.hash = hash;
        this.key = key;
        this.value = value;
        this.next = next;
    }
}

跳錶節點,在通用實現中跳錶節點分成了三大類:頭索引節點、索引節點、普通節點,讓我們仔細分析一下。

繼續下面的內容,請先忘掉上面的三種節點,否則你是很難看懂的,trust me!

還是先拿一張圖來對照著來:

9

首先,我們把這張圖壓扁,是不是就只有一個一個的節點連成一條線了,也就是單連結串列結構:

static class SkiplistNode<K extends Comparable<K>, V> {
    int hash;
    K key;
    V value;
    Node<K, V> next;
}

然後,隨便找一個節點,把它拉起來,比如3這個元素,首先,它有一個高度,這裡它的高度為2,並且,每一層的這個3都有一個向右的指標(忘掉之前的三種節點型別),對不對,所以,這裡把next廢棄掉,變成nexts,記錄每一層的這個3的下一個元素是誰:

static class SkiplistNode<K extends Comparable<K>, V> {
    int hash;
    K key;
    V value;
    int maxLevel;
    Node<K, V>[] nexts;
}

OK,不知道你理解了沒有,我們試著按這種資料結構重畫上面的圖:

12

通過這種方式,就把上面三種型別的節點成功地變成了一個大節點,這個節點是有層高的,且每層都有一個向右的指標。

讓我們模擬一下查詢的過程,比如,要查詢8這個元素,只需要從頭節點的最高層,往右到6這個節點,6在2層向右為空了,所以轉到1層,向右到7這個節點,7再向右看一下,是9,比8大,所以7向下到0層,再向右,找到8,所以,整個走過的路徑為:h(2)->6(2)->6(1)->7(1)->7(0)->8(0)。

好了,原理講完了,讓我們看實現,先來個簡單的。

跳錶的查詢元素

不再區分索引節點和普通節點後,一切都將變得簡單,無腦向右,再向下,再向右即可,程式碼也變得非常簡單。

public V findValue(K key) {
    int level = this.maxLevel;
    SkiplistNode<K, V> q = this;
    int c;
    // i--控制向下
    for (int i = (level - 1); i >= 0; i--) {
        while (q.nexts[i] != null && (c = q.nexts[i].key.compareTo(key)) <= 0) {
            if (c == 0) {
                // 找到了返回
                return q.nexts[i].value;
            }
            // 控制向右
            q = q.nexts[i];
        }
    }
    return null;
}

跳錶的新增元素

新增元素,同樣變得要簡單很多,一切盡在註釋中,不過,彤哥寫這篇文章的時候才發現下面的程式碼中有個小bug,看看你能不能發現^^

// 往跳錶中新增一個元素(只有頭節點可呼叫此方法)
private V putValue(int hash, K key, V value) {
    // 1. 算出層數
    int level = randomLevel();
    // 2. 如果層數高出頭節點層數,則增加頭節點層數
    if (level > maxLevel) {
        level = ++maxLevel;
        SkiplistNode<K, V>[] oldNexts = this.nexts;
        SkiplistNode<K, V>[] newNexts = new SkiplistNode[level];
        for (int i = 0; i < oldNexts.length; i++) {
            newNexts[i] = oldNexts[i];
        }
        this.nexts = newNexts;
    }
    SkiplistNode<K, V> newNode = new SkiplistNode<>(hash, key, value, level);
    // 3. 修正向右的索引
    // 記錄每一層最右能到達哪裡,從頭開始
    SkiplistNode<K, V> q = this; // 頭
    int c;
    // 好好想想這個雙層迴圈,先向右找到比新節點小的最大節點,修正之,再向下,再向右
    for (int i = (maxLevel - 1); i >= 0; i--) {
        while (q.nexts[i] != null && (c = q.nexts[i].key.compareTo(key)) <= 0) {
            if (c == 0) {
                V old = q.nexts[i].value;
                q.nexts[i].value = value;
                return old;
            }
            q = q.nexts[i];
        }
        if (i < level) {
            newNode.nexts[i] = q.nexts[i];
            q.nexts[i] = newNode;
        }
    }
    return null;
}

private int randomLevel() {
    int level = 1;
    int random = ThreadLocalRandom.current().nextInt();
    while (((random>>>=1) & 1) !=0) {
        level++;
    }
    return level;
}

好了,關於SkiplistHashMap中跳錶的部分我們就講這麼多,需要完整原始碼的同學可以關注個人公主號彤哥讀原始碼,回覆skiplist領取哈。

下面我們再來看看SkiplistHashMap中的查詢元素和新增元素。

SkiplistHashMap查詢元素

其實,跳錶的部分搞定了,SkiplistHashMap的部分就非常簡單了,直接上程式碼:

public V get(K key) {
    int hash = hash(key);
    int i = (hash & (table.length - 1));
    Node<K, V> p = table[i];
    if (p == null) {
        return null;
    } else {
        if (p instanceof SkiplistNode) {
            return (V) ((SkiplistNode)p).findValue(key);
        } else {
            do {
                if (p.key.equals(key)) {
                    return p.value;
                }
            } while ((p=p.next) != null);
        }
    }
    return null;
}

SkiplistHashMap新增元素

新增元素參考HashMap的寫法,將新增過程分成以下幾種情況:

  1. 未初始化,先初始化;
  2. 陣列對應位置無元素,直接放入;
  3. 陣列對應位置有元素,又分成三種情況:
    • 如果是SkipListNode型別,按跳錶型別插入元素
    • 如果該位置元素的key值正好與要插入的元素的key值相等,說明是重複元素,替換後直接返回
    • 否則,按連結串列型別插入元素,且插入元素後判斷是否要轉換成跳錶
  4. 插入元素後,判斷是否需要擴容

上程式碼如下:

/**
  * 新增元素:
  * 1. 未初始化,則初始化
  * 2. 陣列位置無元素,直接放入
  * 3. 陣列位置有元素:
  *  1)如果是SkipListNode型別,按跳錶型別插入元素
  *  2)如果該位置元素的key值正好與要插入的元素的key值相等,說明是重複元素,替換後直接返回
  *  3)如果是Node型別,按連結串列型別插入元素,且插入元素後判斷是否要轉換成跳錶
  * 4. 插入元素後,判斷是否需要擴容
  *
  * @param key
  * @param value
  * @return
  */
public V put(K key, V value) {
    if (key == null || value == null) {
        throw new NullPointerException();
    }
    int hash = hash(key);
    Node<K, V>[] table = this.table;
    if (table == null) {
        table = resize();
    }
    int len = table.length;
    int i = hash & (len - 1);
    Node<K, V> h = table[i];
    if (h == null) {
        table[i] = new Node<>(hash, key, value, null);
    } else {
        // 出現了hash衝突
        V old = null;
        if (h instanceof SkiplistNode) {
            old = (V) ((SkiplistNode)h).putValue(hash, key, value);
        } else {
            // 如果連結串列頭節點正好等於要查詢的元素
            if (h.hash == hash && h.key.equals(key)) {
                old = h.value;
                h.value = value;
            } else {
                // 遍歷連結串列找到位置
                Node<K, V> q = h;
                Node<K, V> n;
                int binCount = 1;
                for(;;) {
                    n = q.next;
                    // 沒找到元素
                    if (n == null) {
                        q.next = new Node<>(hash, key, value, null);
                        if (++binCount>= SKIPLISTIFY_THRESHOLD) {
                            skiplistify(table, hash);
                        }
                        break;
                    }

                    // 找到了元素
                    if (n.hash == hash && n.key.equals(key)) {
                        old = n.value;
                        n.value = value;
                        break;
                    }

                    // 後移
                    q = n;
                    ++binCount;
                }
            }
        }

        if (old != null) {
            return old;
        }
    }

    // 需要擴容了
    if (++size > threshold) {
        resize();
    }

    return null;
}

這裡有一個跳錶化的過程,我使用的是最簡單的方式實現的,即新建一個跳錶頭節點,然後把元素都put進去:

// 跳錶化
private void skiplistify(Node<K, V>[] table, int hash) {
    if (table == null || table.length < MIN_SKIPLISTIFY_CAPACITY) {
        resize();
    } else {
        SkiplistNode<K, V> head = new SkiplistNode<>(0, null, null, 1);
        int i = hash & (table.length-1);
        Node<K, V> p = table[i];
        do {
            head.putValue(p.hash, p.key, p.value);
        } while ((p=p.next) != null);
        table[i] = head;
    }
}

好了,關於跳錶實現的HashMap我們就介紹完了。

最後一個問題

不管從原理還是實現過程,跳錶都要比紅黑樹要簡單不少,為什麼JDK中不使用跳錶而是使用紅黑樹來實現HashMap呢?

其實這個問題挺不好回答的,我在給自己挖坑,我簡單從以下幾個方面分析一下:

  1. 穩定度,跳錶的隨機性太大了,要實現O(log n)的時間複雜度,隨機演算法要做得很好才行,這方面可以對比看看ConcurrentSkipListMap和redis中zset的實現,而紅黑樹還算比較穩定;
  2. 範圍查詢,HashMap更多地是運用在查詢單個元素,並沒有範圍查詢這種需求,所以,使用跳錶的必要性不大;
  3. 成熟度,紅黑樹是經過很多實踐檢驗的,比如linux核心、epoll等,而跳錶很少,目前已知的好像只有redis的zset使用了跳錶;
  4. 空間佔用,紅黑樹不管層高多少,每個節點穩定增加左右兩個指標和顏色欄位,而跳錶不一樣,隨著層高的不斷增加,每個元素需要增加的指標也會增加很多,比如,最高為16層,則head和最高的節點需要維護16個向右的指標,這個空間佔用是很大的,所以,實現跳錶一般也要指定最高只能達到多少層;
  5. 流程化,跳錶實現可以多種多樣,每個人寫出來的跳錶可能都不一樣,但紅黑樹不一樣,流程固化,每個人寫出來的差異性不大;
  6. 可測試性,跳錶很難測試,因為多次執行的結果肯定不一樣,而紅黑樹不一樣,只要元素順序不變,執行的結果肯定是固定的,可測試性好很多;

目前,差不多隻能想到這麼多,你有想到的也可以告訴我。

後記

本節,我們一起用兩種方式實現了跳錶,並將其運用到了HashMap的改寫中,相信通過本節的學習你一定可以自信地告訴面試官我可以手寫跳錶了。

好了,既然本節提到了紅黑樹,下一節,我們就來聊聊紅黑樹這個有趣的資料結構,關注我, 及時獲取推文。

關注公眾主“彤哥讀原始碼”,解鎖更多原始碼、基礎、架構知識。

相關文章