Java 算法 - 跳表:為什么 Redis 一定要用跳表來實現有序集合


Java 算法 - 跳表:為什么 Redis 一定要用跳表來實現有序集合?

數據結構與算法之美目錄(https://www.cnblogs.com/binarylei/p/10115867.html)

推薦文章:

二分法查找一文中,我們知道二分法查找一種非常高效的算法,其時間復雜度是 O(logn)。但如果直接使用鏈表進行二分法查找,時間復雜度就上升為 O(n),甚至比鏈表順序訪問還要高。下面介紹一種基於鏈表的二分法查找 - 跳表。

跳表是由 William Pugh 發明的,最早出現於他在1990 年發表的論文 《Skip Lists: A Probabilistic Alternative to Balanced Trees》。對細節感興趣的同學可以下載論文原文來閱讀。

  • 二分法查找:只支持有序的靜態數組,不支持動態數據。如果數據需要頻繁的插入和刪除,那么每次查找時就需要先排序,查找的時間復雜度就上升為 O(nlogn)。
  • 跳表:通過構建多級索引,實現鏈表的二分法查找,支持動態數據。

跳表使用空間換時間的設計思路,通過構建多級索引來提高查詢的效率,實現了基於鏈表的“二分查找”。跳表是一種動態數據結構,支持快速的插入、刪除、查找操作,時間復雜度都是 O(logn)。

1. 什么是跳表

在分析跳表結構之前,我們先總結一下目前已經學習的各種數據結構,比較一下它們的優缺點:

常見的數據結構:時間復雜度與空間復雜度分析
數據結構 時間 空間 性能影響指標 備注
哈希表 O(1) O(n) 散列函數+散列沖突+負載因子 支持動態數據
有序數組 O(logn) O(1) 查找前必須先排序 有序靜態數組,不支持動態數據
二叉查找樹 O(logn) O(n) 退化為鏈表,時間復雜度降為 O(n) 支持動態數據
紅黑樹 O(logn) O(n) 維護樹的平衡:左旋右旋 支持動態數據
跳表 O(logn) O(n) 維護索引平衡:隨機函數生成索引高度 支持動態數據
  1. 哈希表:時間復雜度為 O(1),但無法順序訪問,所以很多場景都 "哈希表" + "鏈表" 一起組合使用。

  2. 有序數組:通過二分法查找時間復雜度是 O(logn),非常高效。但它要求必須是靜態的有序數組,如果是動態數據,每次查找前還需要排序,則時間復雜度退化成 O(nlogn)。因此,它的適用場景是一次排序,多次查找的靜態數據。

  3. 二叉查找樹:二叉查找樹支持動態數據,但如果退化為鏈表,其時間復雜度也降為 O(n)。因此,平衡二叉查找樹誕生了,但實現嚴格的平衡(樹的左右高度差不能大於 1),代價也太大。

  4. 紅黑樹:紅黑樹是平衡二叉查找樹的升級版,它不再追求絕對平衡,只追求相對平衡。它保證任意一個葉子結點的最大路徑不能大於 2 倍的最小路徑,也就是樹的高度最大為 2logn。因此,時間復雜度穩定在 O(logn),但為了維護樹的相對平衡,實現過程還是很復雜。

  5. 跳表:Redis 就是選擇跳表實現有序集合。鏈表之所以不能使用二分法查找,是因為查找中間結點需要遍歷鏈表,時間復雜度是 O(n)。但如果我們直接緩存索引,將查找中間結點的時間復雜度降為 O(1)。這樣跳表就可以使用二分法查找,時間復雜度也降為 O(logn)。

    跳表相對紅黑樹,同樣支持動態數據,時間復雜度都穩定在 O(logn)。但跳表只需要通過隨機函數維護索引平衡,不需要像紅黑樹那樣通過左旋右旋維護樹的平衡,代碼實現也要相對簡單很多。

    鏈表和跳表對比結構圖

思考1:單鏈表二分法時間復雜度為什么是 O(n)?

鏈表采用快慢指針算法獲取鏈表的中間節點時,快慢指針都要移動鏈表長度的一半次,也就是 n / 2 次,總共需要移動 n 次指針才行。

- 第一次,鏈表長度為 n,需要移動指針 n 次;
- 第二次,鏈表長度為 n/2,需要移動指針 n/2 次;
- 第三次,鏈表長度為 n/4,需要移動指針 n/4 次;
- ...
- 以此類推,一直到 1 次為值
- 指針移動的總次數 n + n/2 + n/4 + n/8 + ... + 1 = n(1-0.5)/(1-0.5) = 2n

總結:鏈表獲取中間結點的時間復雜度是 O(2n),不僅遠遠大於數組二分查找 O(logn),也要大於順序查找的時間復雜度 O(n)。

2. 跳表工作原理

最理想的跳表如下圖所示,嚴格按照二分法存儲索引結構。它的結構類似多層鏈表,上層索引的數量是下層索引數量的一半,這樣查找過程就非常類似於一個二分查找,使得查找的時間復雜度可以降低到 O(logn)。

說明: 上圖中包含三級索引,其中頭結點是哨兵結點,只存儲索引不存儲任何數據。查找元素時,需要在逐級索引中依次查找。比如要查找元素 e12,需要通過 L3 -> L2 -> L1 -> L0 依次查找。

(1) 空間復雜度

理想跳表每一層元素都是上一層元素的一半,空間復雜度為 O(n)。

空間復雜度分析:每層索引數 = n/2 + n/4 + n/8 + n/16 + ... + 1 = O(n)

(2)時間復雜度

跳表和二分法查找一樣,時間復雜度也是 O(logn)。跳表查找元素時,需要從上到下從左到右,依次遍歷索引進行查找:Ln -> Ln-1 ... L1 -> L0(原始鏈表)。

3. 跳表關鍵指標

3.1 索引平衡

從上述分析,我們可以看出跳表性能好壞,關鍵在於索引的平衡。如果往跳表中插入大量的數據,而沒有更新索引,那么跳表就會退化為鏈表。同樣,如果每次插入刪除,都需要維護索引的絕對平衡,會導致大量的索引需要重新平衡,鏈表的插入刪除的時間復雜度為 O(1) 的特性就被破壞了。

  • 紅黑樹:平衡二叉樹通過左右旋轉,維護樹的平衡。在實際軟件工程中,因為維護樹的絕對平衡代價太大,AVL 樹很少使用,反而是紅黑樹這種只追求相對平衡的二叉查找樹經常使用。
  • 跳表:同紅黑樹一樣,維護索引的絕對平衡的代價也太大。實現軟件工作中,跳表通過隨機函數來維護索引的 "平衡性"。

3.2 隨機索引

那如何衡量跳表索引的平衡性呢?在《Skip Lists: A Probabilistic Alternative to Balanced Trees》論文中對跳表通過隨機函數來維護索引的 "平衡性" 問題進行了詳細的說明。

跳表的平衡性關鍵是由每個節點插入的時候,它的索引層數是由隨機函數計算出來的,而且隨機的計算不依賴於其它節點,每次插入過程都是完全獨立的。這樣,就和普通鏈表的插入一樣,查找到插入點位置后,只需要一次操作就可以完成結點插入,時間復雜度為 O(logn)。

隨機函數計算索引層數過程如下:

  • 首先,每個節點肯定都有第 1 層指針(每個節點都在第 1 層鏈表里)。
  • 如果一個節點有第 i 層( i >= 1)指針(即節點已經在第 1 層到第 i 層鏈表中),那么它有第(i + 1)層指針的概率為 p。
  • 節點最大的層數不允許超過一個最大值,記為 MaxLevel。
randomLevel()
    level = 1
    // random()返回一個[0...1)的隨機數
    while random() < p and level < MaxLevel do
        level = level + 1
    return level

說明: randomLevel() 的偽碼中包含兩個重要參數:

  • 每層指針的概率 p:決定每個結點的平均索引高度。
  • 最大索引高度 MaxLevel:決定了跳表的最大數據量,為 2MaxLevel

在 Redis 的 skiplist 實現中,這兩個參數的取值為:

#define ZSKIPLIST_MAXLEVEL 64 /* Should be enough for 2^64 elements */
#define ZSKIPLIST_P 0.25      /* Skiplist P = 1/4 */

3.3 性能分析

跳表的性能分析,我們主要關注兩個指標,在概率 p 和最大索引高度 MaxLevel 下,跳表的時間空間復雜度。

  1. 時間復雜度:用跳表查詢到底有多快?時間復雜度是 O(k/p),k 為跳表索引高度。對於 n 個元素的跳表,索引高度為 logn,即跳表查詢的時間復雜度是 O(logn/p) = O(logn),p 越小時間復雜度越高。
  2. 空間復雜度:跳表是不是很浪費內存?空間復雜度是 O(1/(1-p)n) = O(n),p 越小空間復雜度越低。

我們先來計算一下每個節點所包含的平均索引高度。節點包含的索引高度,相當於這個算法在空間上的額外開銷(overhead),可以用來度量空間復雜度。

根據前面 randomLevel() 的偽碼,我們很容易看出,索引高度越大,概率越低。定量的分析如下:

- 結點層數至少為 1,而大於1的節點層數,滿足一個概率分布。
- level=1:表示原始鏈表,概率為 p1=1    ,元素結點個數 n
- level=2:表示一級索引,概率為 p2=p    ,元素結點個數 np^1
- level=3:表示二級索引,概率為 p3=p^2  ,元素結點個數 np^2
...

(1)空間復雜度

因此,一個節點的平均層數(也即包含的平均指針數目),計算如下:

  • 1 + p + p2 + p3 + ... + pi-1 = 1/(1-p)

現在很容易計算出每個節點的平均指針層級數(包含原始鏈表層):

  • 當 p = 1/2 時,每個節點所包含的平均指針數目為 2。這是 ConcurrentSkipListMap 的空間復雜度 O(n)。
  • 當 p = 1/4 時,每個節點所包含的平均指針數目為 1.33。這是 Redis 中 skiplist 空間復雜度 O(0.33n)。

總結: Redis 中 skiplist 的 p 取值為 0.25,也就是時間復雜度是 O(4n),空間復雜度大概是 O(0.33n)。相對於 Java 中 ConcurrentSkipListMap 的 p 取值為 0.5,Redis 更傾向於時間換空間。

(2)時間復雜度

時間復雜的推算比較復雜,我們只是粗略的估算一下。最主要是知道跳表的時間復雜為 O(logn) 即可。

首先,我們估算一下跳表的索引高度。如果索引有 k 層,第 k 層索引結點的個數為 npk-1 個。當 npk-1 = 1 時表示最大索引高度,則索引高度為 k = log1/pn。忽略 p 這個常量,有 n 個元素的跳表,索引的高度為 logn。

下面,我們使用遞歸法推導跳表的時間復雜度。跳表查找時,結點的查找是從下往下,從左往右。現在我們反過來,假設從一個層數為 i 的節點 x 出發,需要向左向上攀爬 k 層。這時我們有兩種可能:

  • 如果節點 x 有第(i + 1)層指針,那么我們需要向上走。這種情況概率為 p。
  • 如果節點 x 沒有第(i + 1)層指針,那么我們需要向左走。這種情況概率為(1 - p)。
C(0) = 0
C(k) = (1-p)(C(k)+1) + p(C(k-1)+1)

C(k) = k/p = logn/p

說明: 跳表查找的時間復雜度大概為 O(k/p),其中 k 表示跳表索引高度。對於 n 個元素的跳表,其索引高度為 logn,即跳表的時間復雜度為 O(logn)。

4. 跳表操作

  • 查找:時間復雜度為 O(logn)。從上至下,從左到右依次遍歷。
  • 插入:首先需要查找到插入點的位置,將結點插入原始鏈表中。然后,生成該結點的索引高度,從上至下,依次將索引也插入對應的索引鏈表中。如插入 e5 時,需要先將 e5 插入原始鏈表。然后使用隨機算法,生成 e5 對應的索引高度 level=2。最后從 level=2 依次向下插入索引對應的有序鏈表中,如果索引有多層,依次插入 Ln -> Ln-1 -> ...。
  • 刪除:先將結點對應的 value 設置為 null,標記結點已經被刪除。如果查找時有結點 value=null,則說明該結點已經被刪除,可以刪除該結點。之所以使用標記清除法,是為了將結點和索引的刪除操作分開。

每天用心記錄一點點。內容也許不重要,但習慣很重要!


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM