資料結構和演算法之——跳錶

seniusen發表於2018-10-29

之前我們知道,二分查詢依賴陣列的隨機訪問,所以只能用陣列來實現。如果資料儲存在連結串列中,就真的沒法用二分查詢了嗎?而實際上,我們只需要對連結串列稍加改造,就可以實現類似“二分”的查詢演算法,這種改造之後的資料結構叫作跳錶(Skip List)

1. 何為跳錶?

對於一個單連結串列,即使連結串列是有序的,如果我們想要在其中查詢某個資料,也只能從頭到尾遍歷連結串列,這樣效率自然就會很低。

假如我們對連結串列每兩個結點提取一個結點到上一級,然後建立一個索引指向原始結點,如下圖所示。

這時候,我們要查詢某一個資料的時候,就可以先在索引裡面查詢出一個大的範圍,然後再下降到原始連結串列中精確查詢。

比如,我們要查詢 16,我們發現 16 位於 13 和 17 之間,這時候,我們就從 13 的地方下降到原始連結串列,然後再往後查詢。原來我們查詢 16,需要遍歷 10 個結點,現在只需要遍歷 7 個結點。

我們發現,加一層索引後,查詢一個結點需要遍歷的次數減少了,也就是查詢效率提高了

那麼我們再多加一級索引呢?效果會不會有更大提升?

這一次,我們只需要遍歷 6 個結點了。

資料量不大的時候這種方法可能效率提高得還不是很明顯,下面看一個包含 64 個結點的例子,這次我們建立了五級索引。

查詢 62 的時候原來需要遍歷 62 次,現在只需要 11 次即可。針對連結串列長度比較大的時候,構建索引查詢效率的提升就會非常明顯

2. 跳錶查詢的分析?

如果連結串列中總共有 nn 個結點,那麼第一級索引就有 n2\frac{n}{2} 個結點,第二級索引就有 n4\frac{n}{4} 個結點,以此類推,那麼第 kk 級索引就有 n2k\frac{n}{2^k} 個結點。如果最高階索引有 2 個結點,那總的索引級數 k=log2n1k = log_2n - 1,如果我們算上原始連結串列的話,那也就是總共有 log2nlog_2n 級。

在第 kk 級索引中,假設我們要查詢的資料為 xx,當我們查詢到 yy 結點時,發現 y<x<zy < x < z 時此時我們就要下降到 k1k-1 級索引繼續查詢。在第 k1k-1 級索引中,yyzz 之間只有三個結點,因此,我們最多隻需要查詢 3 個結點。以此類推,每一級的索引最多都只需要遍歷 3 個結點

而總的級別數為 log2nlog_2n,因此查詢的時間複雜度就為 3log2n=logn3* log_2n = logn。跳錶查詢的時間複雜度和二分查詢一樣,但這其實是以空間來換時間的設計思路。

跳錶的所有額外索引結點總數為 n2+n4+n8+...+4+2=n2\frac{n}{2} + \frac{n}{4} + \frac{n}{8} + ... + 4 + 2 = n-2,所以跳錶的空間複雜度為 O(n)O(n)

但如果我們每三個結點建立一個索引,這時候額外需要的結點總數為 n2\frac{n}{2},雖然空間複雜度依然為 O(n)O(n),但減少了一半的索引節點儲存空間。

實際上,在實際開發中,原始連結串列中儲存的可能是很大的物件,而索引結點只需要儲存關鍵值和幾個指標,其額外佔用的空間可以被忽略掉

3. 跳錶高效的動態插入和刪除?

在連結串列中,如果我們知道要插入資料的位置,那麼插入的時間複雜度就為 O(1)O(1)。在跳錶中,查詢的時間複雜度為 O(logn)O(logn),因此,動態插入資料的時間複雜度也就是 O(logn)O(logn) 了。

從連結串列中刪除結點的時候,如果結點在索引中也有出現,那麼我們除了要刪除原始連結串列中的結點,還要刪除索引中的。

當我們不停地往跳錶中插入資料的時候,如果我們不更新索引,就有可能出現某兩個結點之間資料非常多的情況。極端情況下,跳錶還會退化為單連結串列。

因此,我們需要某種手段來維護索引與原始連結串列大小之間的平衡,也就是說,如果連結串列結點變多了,索引值就相應地增加一些

當我們往跳錶中插入資料的時候,我們可以選擇同時也將這個資料插入到部分索引層中。而插入到哪些索引層中,則由一個隨機函式生成一個隨機數字來決定。如果這個數字為 K,那我們就將資料插入到第一級到第 K 級索引中。

4. 為什麼 Redis 要用跳錶來實現有序集合而不是紅黑樹?

Redis 中的有序集合支援的核心操作主要有以下幾個:

  • 插入一個資料
  • 刪除一個資料
  • 查詢一個資料
  • 按照區間查詢資料
  • 迭代輸出有序序列

其中,插入、刪除、查詢以及迭代輸出有序序列這幾個操作,紅黑樹也可以完成,時間複雜度和跳錶是一樣的。

但是,按照區間查詢資料這個操作,紅黑樹的效率沒有跳錶高。跳錶可以在 O(logn)O(logn) 時間複雜度定位區間的起點,然後在原始連結串列中順序向後查詢就可以了,這樣非常高效。

此外,相比於紅黑樹,跳錶還具有程式碼更容易實現、可讀性好、不容易出錯、更加靈活等優點,因此 Redis 用跳錶來實現有序集合。

參考資料-極客時間專欄《資料結構與演算法之美》

獲取更多精彩,請關注「seniusen」!

相關文章