數據結構與算法分析:(十)跳錶

一、前言

上一篇我們講了關於數組的二分查找算法,數據結構與算法分析:(九)二分查找算法。二分查找的底層依賴的是數組隨機訪問的特性,所以只能用數組來實現。如果數據存儲在鏈表中,就真的沒法用二分查找算法了嗎?

答案是有辦法的,我們只需要對鏈表稍加改造,就可以支持二分查找算法,改造後的數據結構我們稱之爲跳錶(Skip List)

我們先說下跳錶這個數據結構的優缺點後再來分析詳細過程。

對於一個需要頻繁插入、刪除的線性有序結構,如何使插入、刪除的速度提升?

1、優點:

  • 對於單向鏈表,只能從頭到尾遍歷,時間複雜度爲O(n)。
  • 對於數組,刪除、插入複雜度太高O(n),還會涉及數組的擴容操作。
  • 平衡二叉樹查詢速度很快,但是需要平衡的操作開銷很大。
  • 紅黑樹的話,性能差不多,但是如果需要多進程同時訪問修改的話,紅黑樹有個平衡的過程,爭鎖代價也比較大。
  • 跳錶的線程安全也是通過cas鎖實現的,跳錶的構建相對簡單,同時支持範圍查找。

2、缺點:

  • 相對於紅黑樹,空間消耗增加。

我們後端經常用的 Redis 中的有序集合就是用跳錶來實現的。如果你有一定基礎,應該知道紅黑樹也可以實現快速的插入、刪除和查找操作。那 Redis 爲什麼會選擇用跳錶來實現有序集合呢? 爲什麼不用紅黑樹呢?這篇講完後,相信你心中會有一個答案了。

二、如何理解跳錶

對於一個單鏈表來講,即便鏈表中存儲的數據是有序的,如果我們要想在其中查找某個數據,也只能從頭到尾遍歷鏈表。這樣查找效率就會很低,時間複雜度會很高,是 O(n)。

在這裏插入圖片描述

那怎麼來提高查找效率呢?請看我下面畫的圖,在該鏈表中,每隔一個節點就有一個附加的指向它在表中前兩個位置上的節點的鏈,正因爲如此,在最壞的情形下,最多考察 n/2 + 1 個節點。比如我們要查90這個節點,按照之前單鏈表的查找的話要8個節點,現在只需5個節點。

在這裏插入圖片描述

我們來將這種想法擴展一下,得到下面的圖,這裏每隔4個節點就有一個鏈接到該節點前方的下一個第4節點的鏈,只有 n/4 + 1 個節點被考察
在這裏插入圖片描述
這裏我們利用數學的思想,針對通用性做擴展。每隔第 2^i 個節點就有一個鏈接到這個節點前方下一個第 2 ^i 個節點鏈。鏈的總個數僅僅是加倍,但現在在一次查找中最多隻考察 logn 個節點。不難看到一次查找的總時間消耗爲 O(logn),這是因爲查找由向前到一個新的節點或者在同一節點下降到低一級的鏈組成。在一次查找期間每一步總的時間消耗最對爲 O(logn)。注意,在這種數據結構中的查找基本上是折半查找(Binary Search)。

我只舉了兩個例子,這裏你可以自己想象下大量數據也就是鏈表長度爲 n 的時候,查找的效率更加的凸顯出來了。

這種鏈表加多級索引的的結構,就是跳錶。接下來我們來定量的分析下,用跳錶查詢到底有多快。

三、跳錶的時間複雜度分析

我們知道,在一個單鏈表中查詢某個數據的時間複雜度是 O(n)。那在一個具有多級索引的跳錶中,查詢某個數據的時間複雜度是多少呢?

我把問題分解一下,先來看這樣一個問題,如果鏈表裏有 n 個結點,會有多少級索引呢?

按照我們上面講的,第一級索引的鏈節點個數大約就是 n/2 個,第二級索引的鏈節點個數大約就是 n/4 個,第三級索引的鏈節點個數大約就是 n/8 個,依次類推,也就是說,第 k 級索引的鏈節點個數是第 k-1 級索引的鏈節點個數的 1/2,那第 k 級索引節點的個數就是 n/(2k)

假設索引有 h 級,最高級的索引有 2 個節點。通過上面的公式,我們可以得到 n/(2h)=2,從而求得 h=log2n-1。如果包含原始鏈表這一層,整個跳錶的高度就是 log2n。我們在跳錶中查詢某個數據的時候,如果每一層都要遍歷 m 個節點,那在跳錶中查詢一個數據的時間複雜度就是 O(m*logn)

那這個 m 的值是多少呢?按照前面這種索引結構,我們每一級索引都最多隻需要遍歷 3 個結點,也就是說 m=3,爲什麼是 3 呢?我來解釋一下。

假設我們要查找的數據是 x,在第 k 級索引中,我們遍歷到y節點之後,發現 x 大於 y,小於後面的節點 z,所以我們通過 y 的 down 指針,從第 k 級索引下降到第 k-1 級索引。在第 k-1 級索引中,y 和 z 之間只有 3 個節點(包含 y 和 z),所以,我們在 k-1 級索引中最多隻需要遍歷 3 個結點,依次類推,每一級索引都最多隻需要遍歷 3 個節點。

在這裏插入圖片描述

通過上面的分析,我們得到 m=3,所以在跳錶中查詢任意數據的時間複雜度就是 O(logn)。這個查找的時間複雜度跟二分查找是一樣的。換句話說,我們其實是基於單鏈表實現了二分查找,前提是建立了很多級索引,也就是我們講過的空間換時間的設計思路。

我們的時間複雜度很優秀,那跳錶的空間複雜度是多少呢?

實際上,在軟件開發中,我們不必太在意索引佔用的額外空間。在講數據結構和算法時,我們習慣性地把要處理的數據看成整數,但是在實際的軟件開發中,原始鏈表中存儲的有可能是很大的對象,而索引結點只需要存儲關鍵值和幾個指針,並不需要存儲對象,所以當對象比索引結點大很多時,那索引佔用的額外空間就可以忽略了。

四、跳錶的插入和刪除

實際上,跳錶這個動態數據結構,不僅支持查找操作,還支持動態的插入、刪除操作,而且插入、刪除操作的時間複雜度也是 O(logn)。我們就來看下,如何在跳錶中插入一個數據,以及它是如何做到 O(logn) 的時間複雜度的?

我們知道,在單鏈表中,一旦定位好要插入的位置,插入節點的時間複雜度是很低的,就是 O(1)。但是,這裏爲了保證原始鏈表中數據的有序性,我們需要先找到要插入的位置,這個查找操作就會比較耗時。

對於純粹的單鏈表,需要遍歷每個結點,來找到插入的位置。但是,對於跳錶來說,我們講過查找某個節點的的時間複雜度是 O(logn),所以這裏查找某個數據應該插入的位置,方法也是類似的,時間複雜度也是 O(logn)。

我們再來看刪除操作。

如果這個節點在索引中也有出現,我們除了要刪除原始鏈表中的節點,還要刪除索引中的。因爲單鏈表中的刪除操作需要拿到要刪除節點的前驅節點,然後通過指針操作完成刪除。所以在查找要刪除的節點的時候,一定要獲取前驅節點。當然,如果我們用的是雙向鏈表,就不需要考慮這個問題了。

五、跳錶的代碼實現

/**
 * 跳錶代碼實現
 * 跳錶中存儲的是正整數,並且存儲的是不重複的。
 */
public class SkipList {

    private static final int MAX_LEVEL = 16;
    private int levelCount = 1;

    // 帶頭鏈表
    private Node head = new Node(MAX_LEVEL);
    private Random random = new Random();

    public Node find(int value) {
        Node cur = head;
        // 從最大層開始查找,找到前一節點,通過--i,移動到下層再開始查找
        for (int i = levelCount - 1; i >= 0; i--) {
            while (cur.forwards[i] != null && cur.forwards[i].data < value) {
                // 找到前一節點
                cur = cur.forwards[i];
            }
        }

        if (cur.forwards[0] != null && cur.forwards[0].data == value) {
            return cur.forwards[0];
        } else {
            return null;
        }
    }

    /**
     * 優化插入版本
     * @param value
     */
    public void insert(int value) {
        int level = head.forwards[0] == null ? 1 : randomLevel();
        // 每次只增加一層,如果條件滿足
        if (level > levelCount) {
            level = ++levelCount;
        }
        Node newNode = new Node(level);
        newNode.data = value;
        Node cur = head;
        // 從最大層開始查找,找到前一節點,通過--i,移動到下層再開始查找
        for (int i = levelCount - 1; i >= 0; --i) {
            while (cur.forwards[i] != null && cur.forwards[i].data < value) {
                // 找到前一節點
                cur = cur.forwards[i];
            }
            // levelCount 會 > level,所以加上判斷
            if (level > i) {
                if (cur.forwards[i] == null) {
                    cur.forwards[i] = newNode;
                } else {
                    Node next = cur.forwards[i];
                    cur.forwards[i] = newNode;
                    newNode.forwards[i] = next;
                }
            }
        }
    }
    
    public void delete(int value) {
        Node[] update = new Node[levelCount];
        Node cur = head;
        for (int i = levelCount - 1; i >= 0; --i) {
            while (cur.forwards[i] != null && cur.forwards[i].data < value) {
                cur = cur.forwards[i];
            }
            update[i] = cur;
        }

        if (cur.forwards[0] != null && cur.forwards[0].data == value) {
            for (int i = levelCount - 1; i >= 0; --i) {
                if (update[i].forwards[i] != null && update[i].forwards[i].data == value) {
                    update[i].forwards[i] = update[i].forwards[i].forwards[i];
                }
            }
        }
    }

    /**
     * 隨機 level 次,如果是奇數層數 +1,防止僞隨機
     */
    private int randomLevel() {
        int level = 1;
        for (int i = 1; i < MAX_LEVEL; ++i) {
            if (random.nextInt() % 2 == 1) {
                level++;
            }
        }
        return level;
    }

    /**
     * 打印每個節點數據和最大層數
     */
    public void printAll() {
        Node cur = head;
        while (cur.forwards[0] != null) {
            System.out.print(cur.forwards[0] + " ");
            cur = cur.forwards[0];
        }
        System.out.println();
    }

    /**
     * 打印所有數據
     */
    public void printAll_beautiful() {
        Node p = head;
        Node[] c = p.forwards;
        Node[] d = c;
        int maxLevel = c.length;
        for (int i = maxLevel - 1; i >= 0; i--) {
            do {
                System.out.print((d[i] != null ? d[i].data : null) + ":" + i + "-------");
            } while (d[i] != null && (d = d[i].forwards)[i] != null);
            System.out.println();
            d = c;
        }
    }

    /**
     * 跳錶的節點,每個節點記錄了當前節點數據和所在層數數據
     */
    public class Node {
        private int data = -1;
        /**
         * 表示當前節點位置的下一個節點所有層的數據,從上層切換到下層,就是數組下標-1,
         * forwards[3]表示當前節點在第三層的下一個節點。
         */
        private Node forwards[];

        /**
         * 這個值其實可以不用,看優化insert()
         */
        private int maxLevel = 0;

        public Node(int level) {
            forwards = new Node[level];
        }

        @Override
        public String toString() {
            StringBuilder builder = new StringBuilder();
            builder.append("{ data: ");
            builder.append(data);
            builder.append("; levels: ");
            builder.append(maxLevel);
            builder.append(" }");
            return builder.toString();
        }
    }

    public static void main(String[] args) {
        SkipList list = new SkipList();
        list.insert(1);
        list.insert(2);
        list.insert(6);
        list.insert(7);
        list.insert(8);
        list.insert(3);
        list.insert(4);
        list.insert(5);
        System.out.println();
        list.printAll_beautiful();
    }
}

輸出結果:

null:15-------
null:14-------
null:13-------
null:12-------
null:11-------
null:10-------
null:9-------
null:8-------
null:7-------
null:6-------
null:5-------
3:4-------
3:3-------4:3-------5:3-------7:3-------
3:2-------4:2-------5:2-------6:2-------7:2-------8:2-------
2:1-------3:1-------4:1-------5:1-------6:1-------7:1-------8:1-------
1:0-------2:0-------3:0-------4:0-------5:0-------6:0-------7:0-------8:0-------

Github代碼地址:

https://github.com/riemannChow/LeetCode/blob/master/src/main/java/com/algorithm/skipList/SkipList.java

六、知識拓展

爲什麼 Redis 要用跳錶來實現有序集合,而不是紅黑樹?

性能

  • 插入、刪除、查找以及迭代輸出有序序列這幾個操作,紅黑樹也可以完成,時間複雜度跟跳錶是一樣的。但是,按照區間來查找數據這個操作,紅黑樹的效率沒有跳錶高。
  • 對於按照區間查找數據這個操作,跳錶可以做到 O(logn) 的時間複雜度定位區間的起點,然後在原始鏈表中順序往後遍歷就可以了。這樣做非常高效。
  • 跳錶更加靈活,它可以通過改變索引構建策略,有效平衡執行效率和內存消耗。

內存佔用:跳錶的空間利用率還是很高的,加上Redis並非使用普通的跳錶結構,協調相關參數,比如層數,節點元素數等。

不過,跳錶也不能完全替代紅黑樹。因爲紅黑樹比跳錶的出現要早一些,很多編程語言中的 Map 類型都是通過紅黑樹來實現的。我們做業務開發的時候,直接拿來用就可以了,不用費勁自己去實現一個紅黑樹,但是跳錶並沒有一個現成的實現,所以在開發中,如果你想使用跳錶,必須要自己手動實現。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章