跳表的簡單認識


圖解分析

對於一個單向鏈表來說,即使鏈表中存儲的是有序的數據,但如果想要從中查找某個數據時,也只能從頭到尾遍歷鏈表,其時間復雜度是 \(O(n)\)

為了提高鏈表的查詢效率,使其支持類似“二分查找”的方法,對鏈表進行多層次擴展,這樣的數據結構就是 跳表。跳表對標的是平衡樹,是一種提升鏈表插入、刪除、搜索效率的數據結構。

首先,跳表處理的是 有序 的鏈表,一般使用雙向鏈表更加方便。

普通鏈表

然后,每兩個結點提取一個結點到上一級,提取的這一層被稱作為 索引層

單層索引的跳表

這時候,當想要查找 19 這個數字,可以先從索引層開始查找;當到達 17 時,發現下一個結點存儲 21 這個數字,則可以確定,想要查找的 19 肯定是在 17 到 21 之間;這時候可以轉到下一層(原始鏈表)中查找,快速從 17 開始檢索,很快就可以查找出 19 這個數字。

跳表查找的示意圖

加入一層索引之后,查找一個結點需要遍歷的結點個數減少了,也就是查找效率提高了。實際上,一般會新增多層索引,擁有多層索引的跳表,查找一個結點需要遍歷的結點個數將再次減少。

多層索引的跳表

這種鏈表加多層索引的結構,就是跳表。

效率分析

為了方便對跳表的效率做分析,在這里設定一個常見的跳表類型。

假設每兩個結點會抽出一個結點作為上一級索引的結點,那第一級的索引個數大約就是 \(\frac{n}{2}\),第二級的索引個數大約就是 \(\frac{n}{4}\),以此類推,第 k 個索引的結點個數是第 k-1 個索引的結點個數的 \(\frac{1}{2}\),那么,第 k 個索引的結點個數就是 \(\frac{n}{2^k}\)

時間復雜度

假設索引總共有 h 級,最高級的索引有 2 個結點,使用公式 \(\frac{n}{2^h} = 2\) 進行反推,可以計算得出 \(h = \log_2 n - 1\),如果包含原始鏈表那一級,跳表的高度就是 \(\log_2 n\) 級。

當想要從跳表中查詢某個數據時,每層都會遍歷 m 個結點,那么,在跳表中查詢一個數據的時間復雜度就是 \(O(m \log n)\)

跳表搜索過程

從上面圖中可知,在每一級索引中最多只需要遍歷 3 個結點,其實就可以看作是 m = 3。

實際就是,在最高級索引時最多遍歷 3 個結點,當需要在下一級索引中繼續檢索時,算上前后兩個當做范圍的結點也只有 3 個,因此,在每一級索引最多只需要遍歷 3 個結點。

如果細究的話,m 的值與抽取索引值的間隔有直接關系,但是只是計算時間復雜度的話,可以將 m 值看作是一個常數。

因此,在跳表中做檢索的時間復雜度是 \(O(\log n)\)

空間復雜度

同樣的,假設每兩個結點會抽出一個結點作為上一級索引的結點,那第一級的索引個數大約就是 \(\frac{n}{2}\),第二級的索引個數大約就是 \(\frac{n}{4}\),依次類推,最終索引占用的空間將是 \(\frac{n}{2} + \frac{n}{4} + ... + 4 + 2 = n - 2\)

所以,跳表的空間復雜度是 \(O(n)\)

實際上,跳表是一種使用空間換時間的數據結構,以增加索引的方式,提高檢索數據的效率。因此,跳表會比普通鏈表耗費更多內存進行數據存儲。

結點間隔

在上述分析跳表的時間復雜度和空間復雜度時,都是以每兩個結點抽出一個結點作為上一級索引的結點。

實際上,也可以使用 3 個結點或 4 個結點甚至更多結點做間隔。當然,以不同個數結點做間隔時,檢索效率和內存占用都會有些不一樣。

假設以 3 個結點做間隔,占用的空間會有所降低,在這個跳表上做檢索操作時,檢索的效率也會有一些降低。

因為在每一級索引檢索的最多結點個數將從 2 個變成 3 個,跳表的高度是 \(\log_3 n\) 級,最終占用的空間將是 \(\frac{n}{3} + \frac{n}{9} + ... + 3 + 1 = \frac{n}{2}\)

在理論上,以 3 個結點做間隔的跳表與以 2 個結點做間隔的跳表的時間復雜度和空間復雜度都是一樣的。但是,實際操作時,以 3 個結點做間隔的跳表的空間占用會比以 2 個結點做間隔的跳表更優一些。

實際上,在軟件開發中,不必太在意索引占用的額外空間。雖然原始鏈表中存儲的有可能是很大的對象,但索引結點可以只存儲關鍵值和幾個指針,並不需要存儲對象,所以當對象比索引結點大很多時,那索引占用的額外空間就可以忽略了。

動態插入和刪除

上面理解的跳表都是靜態的,實際開發中,跳表在新增、刪除結點時需要做動態處理,否則容易導致檢索效率降低。

插入結點后的跳表

如上圖所示,如果頻繁插入結點,而沒有對索引層做動態處理,很容易出現不滿足一開始設定的跳表規則。

刪除鏈表的結點時也是同樣道理,如果刪除結點而沒有更新索引層,索引層容易出現已被刪除的臟結點。

重建索引

比較容易理解的方法就是重建索引,當每次插入、刪除結點的時候,把整個跳表的所有索引層刪除重建。

但是這種方法會降低插入結點時的效率,已知跳表的空間復雜度是 \(O(n)\),也可以推斷出重建跳表索引層的時間復雜至少是 \(O(n)\)

也就是說,使用重建索引的方式,跳表插入結點耗費時間將會直線上升。

隨機索引

與重建索引相比,隨機索引的效率會更高一些,像 Redis 實現 SortedSet 底層用的跳表就是使用隨機索引的方式進行動態處理。

這里的做法是通過使用一個隨機函數,來決定這個結點插入時,是否需要插入到索引層、以及插入到第幾級索引。

一般來說,通過隨機函數得到的數據都是比較均勻的,也表示最終得到的跳表索引層也是比較均勻,而且數據量越大,索引層越是均勻。

先設定索引的生成規則:從原始鏈表中隨機選擇 \(\frac{1}{2}\) 個結點作為一級索引,從一級索引中隨機選擇 \(\frac{1}{4}\) 個結點作為二級索引,以此類推,一直到最頂層索引。這時候就需要根據這個規則完成所需的隨機函數,並且是每次插入結點的時候,都通過隨機函數判斷這個結點需要插入到幾級索引。

以下是 Redis 源碼當中使用到的 隨機函數

/* Returns a random level for the new skiplist node we are going to create.
 * The return value of this function is between 1 and ZSKIPLIST_MAXLEVEL
 * (both inclusive), with a powerlaw-alike distribution where higher
 * levels are less likely to be returned. */
int zslRandomLevel(void) {
    int level = 1;
    while ((random()&0xFFFF) < (ZSKIPLIST_P * 0xFFFF))
        level += 1;
    return (level<ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL;
}

這個隨機函數會隨機生成 1 到索引最高層數之間的一個數字,該方法有 \(\frac{1}{2}\) 的概率返回 1、有 \(\frac{1}{4}\) 的概率返回 2、有 \(\frac{1}{8}\) 的概率返回 3、以此類推。其中 1 表示不需要生成索引,2 表示需要生成一級索引,3 表示需要生成二級索引,以此類推。

為什么不是返回 1 時生成一級索引呢?這是因為,在生成比一級索引更高層級的索引時,都會向下生成索引,即如果隨機函數返回 3,則會給這個結點同時生成二級索引和一級索引。這樣,如果返回 1 時生成一級索引則會出現生成一級索引的概率為 100%。

使用隨機索引方法的跳表,插入結點的時間復雜度與跳表索引的高度相同,最終時間復雜度降到 \(O(\log n)\),而不是重建索引的 \(O(n)\)

應用場景

跳表和平衡查找樹

與平衡查找樹相比,跳表擁有以下優勢:

  • 跳表的底層原始鏈表支持范圍查詢
  • 跳表相對簡單,更容易使用代碼實現
  • 跳表更加靈活,可以通過改變索引構建策略,有效平衡執行效率和內存消耗

針對上述的第 1 點,支持范圍查詢的 B+ 樹更適用於磁盤,跳表主要用於內存中讀取數據。

LSM-Tree

LSM-Tree 全稱是 Log Structured-Merge Tree,其中文名是日志結構的合並樹,是一種分層的、有序的、基於硬盤的數據結構。

LSM-Tree 的核心思路是,首先寫入數據到內存中,不需要每次有數據更新時就必須將數據寫入到磁盤中,內存達到閾值之后,再使用歸並排序的方式將內存中的數據合並追加到磁盤隊尾。

因為跳表恰好就是天然有序的,所以在 flush 的時候效率很高,通常基於 LSM-Tree 結構的數據庫在內存部分都會選擇跳表這種數據結構。

HBase 的 MemStore 內部基於 LSM-Tree 實現,Google 開源的 LevelDB 以及 Facebook 基於 LevelDB 優化的 RocksDB 內部的 MemTable 都是基於 LSM-Tree 實現,它們都使用到了跳表這一結構。


免責聲明!

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



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