1. 程式人生 > 其它 >資料結構 - 跳錶

資料結構 - 跳錶

簡介

有序的陣列可以使用二分查詢的方法快速檢索一個數據,但是連結串列沒有辦法使用二分查詢。

對於一個單向連結串列來說,即使連結串列中儲存的是有序的資料,但如果想要從中查詢某個資料時,也只能從頭到尾遍歷連結串列,其時間複雜度是 \(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)\)

應用場景

已經知道,Redis 使用了跳錶來實現有序集合。其中的原因就是跳錶按區間查詢元素的時間複雜度是 \(O(\log n + m)\),而如果使用紅黑樹實現有序集合,按區間查詢元素將會比跳錶慢很多。

還有其他的一些開源產品也有使用到跳錶結構,如 HBase MemStore 內部儲存資料就使用到跳錶,Google 開源的 LevelDB 以及 Facebook 基於 LevelDB 優化的 RocksDB 內部的 MemTable 都是使用跳錶這種資料結構。