一文完全搞懂跳錶的各類時間複雜度、適用場景以及實現原理

跳錶是一種神奇的數據結構,由於幾乎全部版本的大學本科教材上都沒有跳錶這種數據結構,並且神書《算法導論》、《算法第四版》這兩本書中也沒有介紹跳錶。可是跳錶插入、刪除、查找元素的時間複雜度跟紅黑樹都是同樣量級的,時間複雜度都是O(logn),並且跳錶有一個特性是紅黑樹沒法匹敵的(具體什麼特性後面會提到)。因此在工業中,跳錶也會常常被用到。廢話很少說了,開始今天的跳錶學習。java

經過本文,你能 get 到如下知識:git

  • 什麼是跳錶?
  • 跳錶的查找、插入、刪除元素的流程
  • 跳錶查找、插入、刪除元素的時間複雜度
  • 跳錶插入元素時,如何動態維護索引?
  • 爲何Redis選擇使用跳錶而不是紅黑樹來實現有序集合?
  • 工業上其餘使用跳錶的場景

友情提示:下文在跳錶插入數據時,會講述如何動態維護索引,實現比較簡單,邏輯比較繞,不要放棄,加油!!!若是一遍看不懂不要緊,能夠選擇暫時性的跳過,畢竟這塊偏向於源碼。可是讀者必須知道跳錶的查找、插入、刪除的時間複雜度都是 O(logn),並且能夠按照範圍區間查找元素,當工做中遇到某些場景時,須要想到可使用跳錶解決問題便可。畢竟平時的工做都是直接使用封裝好的跳錶,例如:java.util.concurrent 下的 ConcurrentSkipListMap()。github

理解跳錶,從單鏈表開始提及

下圖是一個簡單的有序單鏈表,單鏈表的特性就是每一個元素存放下一個元素的引用。即:經過第一個元素能夠找到第二個元素,經過第二個元素能夠找到第三個元素,依次類推,直到找到最後一個元素。面試

跳錶-原始鏈表.jpeg

如今咱們有個場景,想快速找到上圖鏈表中的 10 這個元素,只能從頭開始遍歷鏈表,直到找到咱們須要找的元素。查找路徑:一、三、四、五、七、八、九、10。這樣的查找效率很低,平均時間複雜度很高O(n)。那有沒有辦法提升鏈表的查找速度呢?以下圖所示,咱們從鏈表中每兩個元素抽出來,加一級索引,一級索引指向了原始鏈表,即:經過一級索引 7 的down指針能夠找到原始鏈表的 7 。那如今怎麼查找 10 這個元素呢?redis

跳錶-一級索引.jpeg

先在索引找 一、四、七、9,遍歷到一級索引的 9 時,發現 9 的後繼節點是 13,比 10 大,因而不日後找了,而是經過 9 找到原始鏈表的 9,而後再日後遍歷找到了咱們要找的 10,遍歷結束。有沒有發現,加了一級索引後,查找路徑:一、四、七、九、10,查找節點須要遍歷的元素相對少了,咱們不須要對 10 以前的全部數據都遍歷,查找的效率提高了。算法

那若是加二級索引呢?以下圖所示,查找路徑:一、七、九、10。是否是找 10 的效率更高了?這就是跳錶的思想,用「空間換時間」,經過給鏈表創建索引,提升了查找的效率。數據庫

跳錶-二級索引.jpeg

可能同窗們會想,從上面案例來看,提高的效率並不明顯,原本須要遍歷8個元素,優化了半天,還須要遍歷 4 個元素,實際上是由於咱們的數據量太少了,當數據量足夠大時,效率提高會很大。以下圖所示,假若有序單鏈表如今有1萬個元素,分別是 0~9999。如今咱們建了不少級索引,最高級的索引,就兩個元素 0、5000,次高級索引四個元素 0、2500、5000、7500,依次類推,當咱們查找 7890 這個元素時,查找路徑爲 0、5000、7500 ... 7890,經過最高級索引直接跳過了5000個元素,次高層索引直接跳過了2500個元素,從而使得鏈表可以實現二分查找。由此能夠看出,當元素數量較多時,索引提升的效率比較大,近似於二分查找。bash

數據量增多後,索引效果圖.png

到這裏你們應該已經明白了什麼是跳錶。跳錶是能夠實現二分查找的有序鏈表微信

查找的時間複雜度

既然跳錶能夠提高鏈表查找元素的效率,那查找一個元素的時間複雜度究竟是多少呢?查找元素的過程是從最高級索引開始,一層一層遍歷最後下沉到原始鏈表。因此,時間複雜度 = 索引的高度 * 每層索引遍歷元素的個數。數據結構

先來求跳錶的索引高度。以下圖所示,假設每兩個結點會抽出一個結點做爲上一級索引的結點,原始的鏈表有n個元素,則一級索引有n/2 個元素、二級索引有 n/4 個元素、k級索引就有 n/2k個元素。最高級索引通常有2個元素,即:最高級索引 h 知足 2 = n/2h,即 h = log2n - 1,最高級索引 h 爲索引層的高度加上原始數據一層,跳錶的總高度 h = log2n。

查找的時間複雜度證實.jpeg

咱們看上圖中加粗的箭頭,表示查找元素 x 的路徑,那查找過程當中每一層索引最多遍歷幾個元素呢?

圖中所示,如今到達第 k 級索引,咱們發現要查找的元素 x 比 y 大比 z 小,因此,咱們須要從 y 處降低到 k-1 級索引繼續查找,k-1級索引中比 y 大比 z 小的只有一個 w,因此在 k-1 級索引中,咱們遍歷的元素最多就是 y、w、z,發現 x 比 w大比 z 小以後,再降低到 k-2 級索引。因此,k-2 級索引最多遍歷的元素爲 w、u、z。其實每級索引都是相似的道理,每級索引中都是兩個結點抽出一個結點做爲上一級索引的結點。 如今咱們得出結論:當每級索引都是兩個結點抽出一個結點做爲上一級索引的結點時,每一層最多遍歷3個結點。

跳錶的索引高度 h = log2n,且每層索引最多遍歷 3 個元素。因此跳錶中查找一個元素的時間複雜度爲 O(3*logn),省略常數即:O(logn)。

空間複雜度

跳錶經過創建索引,來提升查找元素的效率,就是典型的「空間換時間」的思想,因此在空間上作了一些犧牲,那空間複雜度究竟是多少呢?

假如原始鏈表包含 n 個元素,則一級索引元素個數爲 n/二、二級索引元素個數爲 n/四、三級索引元素個數爲 n/8 以此類推。因此,索引節點的總和是:n/2 + n/4 + n/8 + … + 8 + 4 + 2 = n-2,空間複雜度是 O(n)

以下圖所示:若是每三個結點抽一個結點作爲索引,索引總和數就是 n/3 + n/9 + n/27 + … + 9 + 3 + 1= n/2,減小了一半。因此咱們能夠經過較少索引數來減小空間複雜度,可是相應的確定會形成查找效率有必定降低,咱們能夠根據咱們的應用場景來控制這個閾值,看咱們更注重時間仍是空間。

三個節點提取一個作索引.jpeg

But,索引結點每每只須要存儲 key 和幾個指針,並不須要存儲完整的對象,因此當對象比索引結點大不少時,索引佔用的額外空間就能夠忽略了。舉個例子:咱們如今須要用跳錶來給全部學生建索引,學生有不少屬性:學號、姓名、性別、身份證號、年齡、家庭住址、身高、體重等。學生的各類屬性只須要在原始鏈表中存儲一份便可,咱們只須要用學生的學號(int 類型的數據)創建索引,因此索引相對原始數據而言,佔用的空間能夠忽略。

插入數據

插入數據看起來也很簡單,跳錶的原始鏈表須要保持有序,因此咱們會向查找元素同樣,找到元素應該插入的位置。以下圖所示,要插入數據6,整個過程相似於查找6,整個的查找路徑爲 一、一、一、四、四、5。查找到第底層原始鏈表的元素 5 時,發現 5 小於 6 可是後繼節點 7 大於 6,因此應該把 6 插入到 5 以後 7 以前。整個時間複雜度爲查找元素的時間複雜度 O(logn)。

插入數據圖示.jpeg

以下圖所示,假如一直往原始列表中添加數據,可是不更新索引,就可能出現兩個索引節點之間數據很是多的狀況,極端狀況,跳錶退化爲單鏈表,從而使得查找效率從 O(logn) 退化爲 O(n)。那這種問題該怎麼解決呢?咱們須要在插入數據的時候,索引節點也須要相應的增長、或者重建索引,來避免查找效率的退化。那咱們該如何去維護這個索引呢?

插入數據,不更新索引圖示.jpeg

比較容易理解的作法就是徹底重建索引,咱們每次插入數據後,都把這個跳錶的索引刪掉所有重建,重建索引的時間複雜度是多少呢?由於索引的空間複雜度是 O(n),即:索引節點的個數是 O(n) 級別,每次徹底從新建一個 O(n) 級別的索引,時間複雜度也是 O(n) 。形成的後果是:爲了維護索引,致使每次插入數據的時間複雜度變成了 O(n)。

那有沒有其餘效率比較高的方式來維護索引呢?假如跳錶每一層的晉升機率是 1/2,最理想的索引就是在原始鏈表中每隔一個元素抽取一個元素作爲一級索引。換種說法,咱們在原始鏈表中隨機的選 n/2 個元素作爲一級索引是否是也能經過索引提升查找的效率呢? 固然能夠了,由於通常隨機選的元素相對來講都是比較均勻的。以下圖所示,隨機選擇了n/2 個元素作爲一級索引,雖然不是每隔一個元素抽取一個,可是對於查找效率來說,影響不大,好比咱們想找元素 16,仍然能夠經過一級索引,使得遍歷路徑較少了將近一半。若是抽取的一級索引的元素剛好是前一半的元素 一、三、四、五、七、8,那麼查找效率確實沒有提高,可是這樣的機率過小了。咱們能夠認爲:當原始鏈表中元素數量足夠大,且抽取足夠隨機的話,咱們獲得的索引是均勻的。咱們要清楚設計良好的數據結構都是爲了應對大數據量的場景,若是原始鏈表只有 5 個元素,那麼依次遍歷 5 個元素也沒有關係,由於數據量太少了。因此,咱們能夠維護一個這樣的索引:隨機選 n/2 個元素作爲一級索引、隨機選 n/4 個元素作爲二級索引、隨機選 n/8 個元素作爲三級索引,依次類推,一直到最頂層索引。這裏每層索引的元素個數已經肯定,且每層索引元素選取的足夠隨機,因此能夠經過索引來提高跳錶的查找效率。

跳錶-一級索引隨機分佈.jpg

那代碼該如何實現,才能使跳錶知足上述這個樣子呢?能夠在每次新插入元素的時候,儘可能讓該元素有 1/2 的概率創建一級索引、1/4 的概率創建二級索引、1/8 的概率創建三級索引,以此類推,就能知足咱們上面的條件。如今咱們就須要一個機率算法幫咱們把控這個 1/二、1/四、1/8 ... ,當每次有數據要插入時,先經過幾率算法告訴咱們這個元素須要插入到幾級索引中,而後開始維護索引並把數據插入到原始鏈表中。下面開始講解這個機率算法代碼如何實現。

咱們能夠實現一個 randomLevel() 方法,該方法會隨機生成 1~MAX_LEVEL 之間的數(MAX_LEVEL表示索引的最高層數),且該方法有 1/2 的機率返回 一、1/4 的機率返回 二、1/8的機率返回 3,以此類推

  • randomLevel() 方法返回 1 表示當前插入的該元素不須要建索引,只須要存儲數據到原始鏈表便可(機率 1/2)
  • randomLevel() 方法返回 2 表示當前插入的該元素須要建一級索引(機率 1/4)
  • randomLevel() 方法返回 3 表示當前插入的該元素須要建二級索引(機率 1/8)
  • randomLevel() 方法返回 4 表示當前插入的該元素須要建三級索引(機率 1/16)
  • 。。。以此類推

因此,經過 randomLevel() 方法,咱們能夠控制整個跳錶各級索引中元素的個數。重點來了:randomLevel() 方法返回 2 的時候會創建一級索引,咱們想要一級索引中元素個數佔原始數據的 1/2,可是 randomLevel() 方法返回 2 的機率爲 1/4,那是否是有矛盾呢?明明說好的 1/2,結果一級索引元素個數怎麼變成了原始鏈表的 1/4?咱們先看下圖,應該就明白了。

插入數據圖示.jpeg

假設咱們在插入元素 6 的時候,randomLevel() 方法返回 1,則咱們不會爲 6 創建索引。插入 7 的時候,randomLevel() 方法返回3 ,因此咱們須要爲元素 7 創建二級索引。這裏咱們發現了一個特色:當創建二級索引的時候,同時也會創建一級索引;當創建三級索引時,同時也會創建一級、二級索引。因此,一級索引中元素的個數等於 [ 原始鏈表元素個數 ] * [ randomLevel() 方法返回值 > 1 的機率 ]。由於 randomLevel() 方法返回值 > 1就會建索引,凡是建索引,不管幾級索引必然有一級索引,因此一級索引中元素個數佔原始數據個數的比率爲 randomLevel() 方法返回值 > 1 的機率。那 randomLevel() 方法返回值 > 1 的機率是多少呢?由於 randomLevel() 方法隨機生成 1~MAX_LEVEL 的數字,且 randomLevel() 方法返回值 1 的機率爲 1/2,則 randomLevel() 方法返回值 > 1 的機率爲 1 - 1/2 = 1/2。即經過上述流程實現了一級索引中元素個數佔原始數據個數的 1/2

同理,當 randomLevel() 方法返回值 > 2 時,會創建二級或二級以上索引,都會在二級索引中增長元素,所以二級索引中元素個數佔原始數據的比率爲 randomLevel() 方法返回值 > 2 的機率。 randomLevel() 方法返回值 > 2 的機率爲 1 減去 randomLevel() = 1 或 =2 的機率,即 1 - 1/2 - 1/4 = 1/4。OK,達到了咱們設計的目標:二級索引中元素個數佔原始數據的 1/4

以此類推,能夠得出,遵照如下兩個條件:

  • randomLevel() 方法,隨機生成 1~MAX_LEVEL 之間的數(MAX_LEVEL表示索引的最高層數),且有 1/2的機率返回 一、1/4的機率返回 二、1/8的機率返回 3 ...
  • randomLevel() 方法返回 1 不建索引、返回2建一級索引、返回 3 建二級索引、返回 4 建三級索引 ...

就能夠知足咱們想要的結果,即:一級索引中元素個數應該佔原始數據的 1/2,二級索引中元素個數佔原始數據的 1/4,三級索引中元素個數佔原始數據的 1/8 ,依次類推,一直到最頂層索引。

可是問題又來了,怎麼設計這麼一個 randomLevel() 方法呢?直接擼代碼:

// 該 randomLevel 方法會隨機生成 1~MAX_LEVEL 之間的數,且 :
//        1/2 的機率返回 1
//        1/4 的機率返回 2
//        1/8 的機率返回 3 以此類推
private int randomLevel() {
  int level = 1;
  // 當 level < MAX_LEVEL,且隨機數小於設定的晉升機率時,level + 1
  while (Math.random() < SKIPLIST_P && level < MAX_LEVEL)
    level += 1;
  return level;
}
複製代碼

上述代碼能夠實現咱們的功能,並且,咱們的案例中晉升機率 SKIPLIST_P 設置的 1/2,即:每兩個結點抽出一個結點做爲上一級索引的結點。若是咱們想節省空間利用率,能夠適當的下降代碼中的 SKIPLIST_P,從而減小索引元素個數,Redis 的 zset 中 SKIPLIST_P 設定的 0.25。下圖所示,是Redis t_zset.c 中 zslRandomLevel 函數的實現:

Redis zslRandomLevel實現原理.png

Redis 源碼中 (random()&0xFFFF) < (ZSKIPLIST_P * 0xFFFF) 在功能上等價於我代碼中的 Math.random() < SKIPLIST_P ,只不過 Redis 做者 antirez 使用位運算來提升浮點數比較的效率。

總體思路你們應該明白了,那插入數據時維護索引的時間複雜度是多少呢?元素插入到單鏈表的時間複雜度爲 O(1),咱們索引的高度最多爲 logn,當插入一個元素 x 時,最壞的狀況就是元素 x 須要插入到每層索引中,因此插入數據到各層索引中,最壞時間複雜度是 O(logn)。

過程大概理解了,再經過一個例子描述一下跳錶插入數據的全流程。如今咱們要插入數據 6 到跳錶中,首先 randomLevel() 返回 3,表示須要建二級索引,即:一級索引和二級索引須要增長元素 6。該跳錶目前最高三級索引,首先找到三級索引的 1,發現 6 比 1大比 13小,因此,從 1 下沉到二級索引。

插入數據且維護跳錶圖示1.jpeg

下沉到二級索引後,發現 6 比 1 大比 7 小,此時須要在二級索引中 1 和 7 之間加一個元素6 ,並從元素 1 繼續下沉到一級索引。

插入數據且維護跳錶圖示2.jpeg

下沉到一級索引後,發現 6 比 1 大比 4 大,因此日後查找,發現 6 比 4 大比 7 小,此時須要在一級索引中 4 和 7 之間加一個元素 6 ,並把二級索引的 6 指向 一級索引的 6,最後,從元素 4 繼續下沉到原始鏈表。

插入數據且維護跳錶圖示3.jpeg
下沉到原始鏈表後,就比較簡單了,發現 四、5 比 6小,7比6大,因此將6插入到 5 和 7 之間便可,整個插入過程結束。

插入數據且維護跳錶圖示4.jpeg

整個插入過程的路徑與查找元素路徑相似, 每層索引中插入元素的時間複雜度 O(1),因此整個插入的時間複雜度是 O(logn)。

刪除數據

跳錶刪除數據時,要把索引中對應節點也要刪掉。以下圖所示,若是要刪除元素 9,須要把原始鏈表中的 9 和第一級索引的 9 都刪除掉。

刪除數據.jpeg

跳錶中,刪除元素的時間複雜度是多少呢?

刪除元素的過程跟查找元素的過程相似,只不過在查找的路徑上若是發現了要刪除的元素 x,則執行刪除操做。跳錶中,每一層索引其實都是一個有序的單鏈表,單鏈表刪除元素的時間複雜度爲 O(1),索引層數爲 logn 表示最多須要刪除 logn 個元素,因此刪除元素的總時間包含 查找元素的時間刪除 logn個元素的時間 爲 O(logn) + O(logn) = 2 O(logn),忽略常數部分,刪除元素的時間複雜度爲 O(logn)。

總結

  1. 跳錶是能夠實現二分查找的有序鏈表;

  2. 每一個元素插入時隨機生成它的level;

  3. 最底層包含全部的元素;

  4. 若是一個元素出如今level(x),那麼它確定出如今x如下的level中;

  5. 每一個索引節點包含兩個指針,一個向下,一個向右;(筆記目前看過的各類跳錶源碼實現包括Redis 的zset 都沒有向下的指針,那怎麼從二級索引跳到一級索引呢?留個懸念,看源碼吧,文末有跳錶實現源碼)

  6. 跳錶查詢、插入、刪除的時間複雜度爲O(log n),與平衡二叉樹接近;

爲何Redis選擇使用跳錶而不是紅黑樹來實現有序集合?

Redis 中的有序集合(zset) 支持的操做:

  1. 插入一個元素

  2. 刪除一個元素

  3. 查找一個元素

  4. 有序輸出全部元素

  5. 按照範圍區間查找元素(好比查找值在 [100, 356] 之間的數據)

其中,前四個操做紅黑樹也能夠完成,且時間複雜度跟跳錶是同樣的。可是,按照區間來查找數據這個操做,紅黑樹的效率沒有跳錶高。按照區間查找數據時,跳錶能夠作到 O(logn) 的時間複雜度定位區間的起點,而後在原始鏈表中順序日後遍歷就能夠了,很是高效。

工業上其餘使用跳錶的場景

在博客上歷來沒有見過有同窗講述 HBase MemStore 的數據結構,其實 HBase MemStore 內部存儲數據就使用的跳錶。爲何呢?HBase 屬於 LSM Tree 結構的數據庫,LSM Tree 結構的數據庫有個特色,實時寫入的數據先寫入到內存,內存達到閾值往磁盤 flush 的時候,會生成相似於 StoreFile 的有序文件,而跳錶剛好就是自然有序的,因此在 flush 的時候效率很高,並且跳錶查找、插入、刪除性能都很高,這應該是 HBase MemStore 內部存儲數據使用跳錶的緣由之一。HBase 使用的是 java.util.concurrent 下的 ConcurrentSkipListMap()。

Google 開源的 key/value 存儲引擎 LevelDB 以及 Facebook 基於 LevelDB 優化的 RocksDB 都是 LSM Tree 結構的數據庫,他們內部的 MemTable 都是使用了跳錶這種數據結構。

後期筆者還會輸出一篇深刻剖析 LSM Tree 的博客,到時候再結合場景分析爲何使用跳錶。

參考:

Redis zset源碼

極客時間-數據結構與算法之美課程

  • 王爭老師的整套課程都很棒,對數據結構與算法想總體提升的同窗能夠訂閱

王爭老師SkipList 實現

  • 這個跳錶實現相對簡單,建議初學者參考,整個項目是王爭老師極客時間課程配套的代碼,其餘數據結構實現也能夠參考
  • 筆記在寫本博客期間,向該項目提交了 pr,已被merge,模仿 redis 源碼從新實現了 randomLevel() 方法,不過爲了容易理解沒有使用redis的位運算,以前的 randomLevel() 方法會致使索引冗餘特別嚴重,5 級如下的索引中元素個數接近於全部元素的個數,有興趣的同窗能夠繼續深刻研究

源碼 5:凌波微步 —— 探索「跳躍列表」內部結構

  • 老錢的《Redis 深度歷險》系列很是推薦

拜託,面試別再問我跳錶了!

  • 彤哥讀源碼系列,把 Java java.util.concurrent 包下的大多數集合類從源碼層次深刻分析了一遍,很是推薦

歡迎關注筆者的博客,後續持續更新數據結構與算法、大數據、Flink實戰以及原理性的文章

微信二維碼公衆號.png
相關文章
相關標籤/搜索