從數據結構之跳錶分析到Redis的有序集合淺析

前言

個人認爲,大部分的數據結構都是基於鏈表(二叉樹這種也算一種鏈式結構,其節點會保存了左右節點的指針)與數組組成的,所以鏈表與數組是數據結構中的一個基石,在上一篇數據結構分析 文章中我結合了查找這一場景,突出了數組與鏈表的特點,講解了幾個數組、鏈表變形的、用於查找的數據結構,理解上篇文章,我認爲對數組和鏈表的理解可以更上一層樓

上一篇的 關於查找常用的幾個數據結構 文章的主要議題是關於查找的幾個數據結構的優劣和對比各自的場景,分析各數據結構與算法之間的碰撞都會產生如何巧妙的化學反應,但是並沒有細緻去講解數據結構,如果將講解數據結構的解析文章雜糅在一起,個人覺得有點不能專注於一個議題中的味道。所以這篇將跳錶這一數據結構單獨拎出來講解。不過本篇文章不會側重於代碼上的跳錶實現,本篇的議題如下:

  • 跳錶是什麼?
  • 跳錶快在哪裏?爲什麼Redis要用跳錶實現有序集合

跳錶

二分查找與數組的劣勢

上篇文章中我們講到了在查找數據時,如果既要查詢快,又要可以查詢一個範圍(也就相當於有序地存儲),那麼可以使用二分算法,但是二分算法有一個劣勢在於底層的數據結構是數組,在我不斷增刪數據的時候難免要從數組中間地方進行操作,這樣就會造成增刪操作的時候數組數據要進行拷貝纔可以完成操作
在這裏插入圖片描述
在數據量小的情況下數組表現的會比較優秀,畢竟數組對CPU緩存相當友好。如果數據量大,如果往中間插入或者刪除數據,將會導致數組中有一半的數據都會拷貝,只爲了後移能在中間插入一個數據,或者是空間不夠存放,就要做一個新數組存放數據,開闢一段連續的內存空間,如果數據量大,開闢大的連續內存空間的開銷是相當大的。

使用鏈表實現

那麼如果我們使用鏈表去實現二分查找,是不是就沒有頻繁開闢連續大內存和拷貝數據的開銷呢?
在這裏插入圖片描述
是的,只需要斷開和連接指針即可。那麼我們如何將其融入二分查找的算法思想呢?我們知道,二分查找之所以快,是因爲查找一次就可以砍掉一半的數據,就像我們猜數字,1-100,猜50,如果答案比50大,只需要查找51-100,1-49這部分的數據就被我們砍掉了。但是鏈表有個缺點就是不支持隨機訪問,我們訪問數組中中間的地方只需要O(1)的複雜度,因爲其內存地址我們已經知道了(數組地址加上下標就是我們需要拿的元素的內存地址(假設元素字節大小爲1)),如果是鏈表,訪問中間部分的數據要一個個遍歷節點,在上面的例子中我們需要數50個節點我們才知道中間的那個Node在哪裏,那如果我們將中間的那個Node直接提取出來呢?
在這裏插入圖片描述
我們將中間的那個節點提取出一層(Level),此時他在Level2這一層鏈表中,此時如果我們需要尋找大於等於3的那些數據,我們第一步做的是找到最高的那一層(L2),找到了2.5這個節點,發現要找的數據在2.5後面,而後面又沒有數據了,此時降級,往後面查找,到了L1,看到自己後面是3,返回3後面的所有數據,結束。這樣是不是就有了二分查找的幾分味道呢?可能數據量小不太能看出來,讓我們增加一些數據,然後多加幾層Level看看
在這裏插入圖片描述
這次我們整4層,且每兩個節點做一個提取,現在我想查找大於57且小於80的數據,如何查找?

首先我們需要找到大於等於57的數據,且前面一個節點小於57,這個數據就是我們要找的57-80的起點節點,而終點數據就是小於等於80且後面一個節點大於80的節點:

  1. 找最高層L4,找到1後發現要找的數據至少大於57,所以數據還在1後面,此時找到節點50,則降級往後查找
  2. 到L3,發現後面是70,說明數據至少就在這兩個節點之間,繼續降級
  3. 到L2,發現後面是56,說明數據就在56和後面一個70節點之間,指針移動到56節點,繼續往下
  4. 到L1,發現是最後一級,則數據在此層就可以拿到,56往後即爲57,滿足第一個大於等於57且前面一個節點小於57的條件,則57就是起點節點,往後遍歷到70,發現後面是100,大於80了,則70是終點節點

此時我們遍歷了幾次呢?

1 -> 50 -> 50-> 50 -> 56 -> 56 -> 57 -> 70

只需要7次的節點遍歷即可找出數據

如果我們不使用跳錶,單純一個鏈表遍歷需要遍歷幾次呢?從頭節點1開始遍歷到57,需要遍歷12次才能拿到答案,在數據量比較小的情況下可能你還是不能很直觀的感受到,我們先來看看跳錶到底爲什麼可以只遍歷7次就能拿到結果數據:

可以看到,我們第一次就直接從最高一級開始出發,這樣的操作會直接pass掉1-50前面的所有節點,直接砍掉一半的節點,繼續往下層遍歷,雖然後面只砍掉了一個節點,但是如果在數據量大的情況下,每下一層都會砍掉很多數據,也就是說,我們的遍歷次數其實是跟這個Level層數有關係的,如果我們繼續每兩個提升一個節點到上一層,在上面的例子中14個節點需要log14(2爲底)約等於4層,那如果是64個數據,其實只需要6層,65536個數據,只需要16層,100W+個數據只需要20層,可以看到,log級別在大數據量下的表現十分驚人,而層數大致可以看作是遍歷次數,爲什麼呢?

查找的時間複雜度

例如我們現在想找17這個節點,首先到了L4的1節點,17肯定在1-50節點範圍內,而下一層L3的1-50節點由於我們是每兩個節點提升一次,那麼L4中1-50的節點對應在L3中最多隻有3個,所以我們最多遍歷兩次就可以找到我們要往下的那個節點。來看看例子:

  • 第一次遍歷:此時L4的50節點直接往下到L3,遍歷第一次到L3的20這個節點
  • 第二次遍歷:然後看看20前面的節點(遍歷操作)是1,1比17大,而20比17小,我們選擇在20這個節點開始往下
  • 第三次遍歷:往下到了L2的20節點

從上面的操作我們可以看出,其每下一層都需要最多3次(可能只需要一次,這與數據和幾個節點提升一次層數有關),在上面的例子中如果有5層,我們也就需要平均3*5=15次的遍歷即可找到數據,那麼層數又是怎麼算呢,其實層數與幾個節點提升一次有關係,在上面的例子中,我們每兩個節點提升一次層數,也就是說每提升一層總數量除以2,一直除一直除直到最後頂層只有2個數據,這不就是以2爲底計算 logN 的過程嗎?也就是說,層數乘3即爲時間複雜度,3爲常量直接忽略,則此時可以知道,其時間複雜度爲 O(logN)

大數據量下的查找

我們在100萬+數據中找到某個範圍,也只需要找O(logN)級別的節點就可以找到,隨便估計一下(很隨便但比較直觀)也只需要20-50多次的遍歷,如果純遍歷,找中間範圍的某個數據,你需要遍歷至少50萬個節點…

跳錶的增刪操作

又由於其鏈表結構,使得增刪數據只需要斷開與重建指針即可,所以增刪數據其實瓶頸在查找數據的時間複雜度上,查找到數據之後增刪節點那都是一瞬間的事情(其實Redis在跳錶結構上與哈希表結合,將查找數據的複雜度直接將爲O(1),所以Redis中有序集合刪一個已知的節點的時間複雜度可以視爲常量級別,如果範圍刪或者是插入還是需要O(logN) 的時間複雜度的。這個思想在我上一篇文章中就有講到)

但是有一個問題,如果刪除的節點都是提升爲高層的節點怎麼辦?
在這裏插入圖片描述
可以看到,如果我們將10、20、41這三個有提升的節點全刪了,我們要找3這個節點,將直接退化爲鏈表的時間複雜度,爲了避免這種情況,我們需要動態的去調整哪些節點會提升爲高層,具體提升爲幾層(L4還是L3還是L2)

Redis中的有序集合

  • 動態調整層數

在Redis中,其動態調整的策略即爲隨機策略,首先插入元素時會有一個機率,被提升,我們做一個假設:

  1. 插入數據A,找到A要插入的位置後,調用隨機函數,有30%的概率其會被提升到L2
  2. 運氣很好,A被提升爲L2,繼續調用隨機函數,有10%的概率會被提升爲L3
  3. 這次運氣不太好,所以A這個數據就只被提升最高爲L2

在Redis中好像一共有64層還是32層,有點忘記了,越往上層機率越小,這樣的話,想要達到頂層64層,需要的數據量可就不少了。這樣一來,是同樣可以達到我們上述的每兩個節點提升一層的效果的,讀者可以在自己腦中推演一遍。

但是這個隨機函數的概率值是一個動態值,在上面刪除節點例子中,如果刪掉了L2、L3代表節點,那麼這個提升的隨機概率將會稍微升高一些,比如提升L2的概率會從30%提升爲33%,這樣會讓多一些節點補充剛剛刪掉的L2,等L2節點上來了,這個概率又會從33%下降爲30%,達到了動態調整Level層數的效果。即使刪除了很多高層節點,也不會退化爲鏈表。

  • 與Hash表結合

這個思想是我一直反覆提及的,hash的優點在於查找一個給定值是O(1)的時間複雜度,但是不能範圍查詢,其正好和跳錶補缺補漏,可謂如虎添翼
在這裏插入圖片描述
只需要HashMap中找一下,就可以找到57這個節點,而Node57中有一個前驅節點和後繼節點,我們此時只需要將前驅節點56的next指針指向70,70這個後繼節點的pre指針指向56就完成了刪除操作。

單純的查找57這個值也是只需要一個哈希即可完成,在Redis中的有序集合就是這麼實現的(Redis中C語言實現的SortedSet的struct定義裏面就是一個HashMap和一個SkipList)

  • 爲什麼不用紅黑樹
  1. 在Redis中查找一個範圍數據(56-70)這個需求是一定要有的,而紅黑樹做範圍查找的話沒有跳錶效率高,但也不是說平衡樹做不了範圍查找,就像MySQL中的B+Tree,其樹節點就像跳錶這樣有重複的節點,最底部的葉子節點會保存全部數據的鏈表形式,這樣就可以支持範圍查找了,這也就是MySQL爲什麼要用B+Tree作爲索引的原因
  2. 相對來說,實現一個跳錶(雖然也有一點小難度)比實現一個紅黑樹(甚至是平衡樹)要簡單很多,效率兩者來說都是一樣的,那麼Redis的作者爲何要吃力不討好選擇平衡樹來實現呢?跳錶的動態平衡策略可是比紅黑樹簡單很多的
發佈了84 篇原創文章 · 獲贊 90 · 訪問量 10萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章