圖解分析
對於一個單向鏈表來說,即使鏈表中存儲的是有序的數據,但如果想要從中查找某個數據時,也只能從頭到尾遍歷鏈表,其時間復雜度是 \(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 實現,它們都使用到了跳表這一結構。