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

前言

本文收錄於專輯:http://dwz.win/HjK,點擊解鎖更多數據結構與算法的知識。java

你好,我是彤哥。node

上一節,咱們一塊兒學習了關於跳錶的理論知識,相信經過上一節的學習,你必定能夠給面試官完完整整地講清楚跳錶的前因後果,甚至可以邊講邊畫圖。linux

15

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

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

本節,我將經過兩種方式手寫跳錶,並結合畫圖,完全搞定跳錶實現的細節。算法

第一種方式爲跳錶的通用實現,第二種方式爲彤哥本身發明的實現,並運用到HashMap的改寫中。api

好了,開始今天的學習吧,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("查詢元素:\u6b22\u8fce\u5173\u6ce8\u516c\u4f17\u53f7\u5f64\u54e5\u8bfb\u6e90\u7801\uff0c\u83b7\u53d6\u66f4\u591a\u67b6\u6784\u3001\u57fa\u7840\u3001\u6e90\u7801\u597d\u6587\uff01");
    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->六、3->六、6->7,修正完成則表示插入元素成功:

8

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

/**
  * 添加元素
  * 不能添加相同的元素
  * @param value
  */
public void add(T value) {
    System.out.println("添加元素:\u6b22\u8fce\u5173\u6ce8\u516c\u4f17\u53f7\u5f64\u54e5\u8bfb\u6e90\u7801\uff0c\u83b7\u53d6\u66f4\u591a\u67b6\u6784\u3001\u57fa\u7840\u3001\u6e90\u7801\u597d\u6587\uff01");
    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("刪除元素:\u6b22\u8fce\u5173\u6ce8\u516c\u4f17\u53f7\u5f64\u54e5\u8bfb\u6e90\u7801\uff0c\u83b7\u53d6\u66f4\u591a\u67b6\u6784\u3001\u57fa\u7840\u3001\u6e90\u7801\u597d\u6587\uff01");
    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的改寫中,相信經過本節的學習你必定能夠自信地告訴面試官我能夠手寫跳錶了。

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

關注公衆主「彤哥讀源碼」,解鎖更多源碼、基礎、架構知識。

相關文章
相關標籤/搜索