一個基於運氣的數據結構,你猜是啥?

排行榜

懂行的老哥一看這個小標題,就知道我要以排行榜做爲切入點,去講 Redis 的 zset 了。java

是的,經典面試題,請實現一個排行榜,大部分狀況下就是在考驗你知不知道 Redis 的 zset 結構,和其對應的操做。node

固然了,排行榜咱們也能夠基於其餘的解決方案。好比 mysql。mysql

我曾經就基於 mysql 作過排行榜,一條 sql 就能搞定。可是僅限於數據量比較少,性能要求不高的場景(我當時只有 11 支隊伍作排行榜,一分鐘刷新一次排行榜)。web

對於這種經典的面試八股文,網上一找一大把,因此本文就不去作相關解析了。面試

說好的只是一個切入點。redis

若是你不知道具體怎麼實現,或者根本就不知道這題在問啥,那必定記得看完本文後要去看看相關的文章。最好本身實操一下。算法

相信我,八股文,得背,這題會考。sql

zset 的內部編碼

衆所周知,Redis 對外提供了五種基本數據類型。可是每一種基本類型的內部編碼倒是另一番風景:segmentfault

其中 list 數據結構,在 Redis 3.2 版本中還提供了 quicklist 的內部編碼。不是本文重點,我提一嘴就行,有興趣的朋友本身去了解一下。數組

本文主要探討的是上圖中的 zset 數據結構。

zset 的內部編碼有兩種:ziplist 和 skiplist。

其實你也別以爲這個東西有多神奇。由於對於這種對外一套,對內又是一套的「雙標黨」其實你已經很熟悉了。

它就是 JDK 的一個集合類,來朋友們,大膽的喊出它的名字:HashMap。

HashMap 除了基礎的數組結構以外,還有另外兩個數據結構:一個鏈表,一個紅黑樹。

這樣一聯想是否是就以爲也不過如此,內心至少有個底了。

當鏈表長度大於 8 且數組長度大於 64 的時候, HashMap 中的鏈表會轉紅黑數。

對於 zset 也是同樣的,必定會有條件觸發其內部編碼 ziplist 和 skiplist 之間的變化?

這個問題的答案就藏在 redis.conf 文件中,其中有兩個配置:

上圖中配置的含義是,當有序集合的元素個數小於 zset-max-ziplist-entries 配置的值,且每一個元素的值的長度都小於 zset-max-ziplist-value 配置的值時,zset 的內部編碼是 ziplist。

反之則用 skiplist。

理論鋪墊上了,接下我給你們演示一波。

首先,咱們給 memberscore 這個有序集合的 key 設置兩個值,而後看看其內部編碼:

此時有序集合的元素個數是 2,能夠看到,內部編碼採用的是 ziplist 的結構。

爲了你們方便理解這個儲存,我給你們畫個圖:

而後咱們須要觸發內部編碼從 ziplist 到 skiplist 的變化。

先驗證 zset-max-ziplist-value 配置,往 memberscore 元素中塞入一個長度大於 64字節(zset-max-ziplist-value默認配置)的值:

這個時候 key 爲 memberscore 的有序集合中有 3 個元素了,其中有一個元素的值特別長,超過了 64 字節。

此時的內部編碼採用的是 skiplist。

接下來,咱們往 zset 中多塞點值,驗證一下元素個數大於 zset-max-ziplist-entries 的狀況。

咱們搞個新的 key,取值爲 whytestkey。

首先,往 whytestkey 中塞兩個元素,這是其內部編碼仍是 ziplist:

那麼問題來了,從配置來看 zset-max-ziplist-entries 128

這個 128 是等於呢仍是大於呢?

不要緊,我也不知道,試一下就好了。

如今已經有兩個元素了,再追加 126 個元素,看看:

經過實驗咱們發現,當 whytestkey 中的元素個數是 128 的時候,其內部編碼仍是 ziplist。

那麼觸發其從 ziplist 轉變爲 skiplist 的條件是 元素個數大於 128,咱們再加入一個試一試:

果真,內部編碼從 ziplist 轉變爲了 skiplist。

理論驗證完畢,zset 確實是有兩幅面孔。

本文主要探討 skiplist 這個內部編碼。

它就是標題說的:基於運氣的數據結構。

什麼是 skiplist?

這個結構是一個叫作 William Pugh 的哥們在 1990 年發佈的一篇叫作《Skip Lists: A Probabilistic Alternative to Balanced Trees》的論文中提出的。

論文地址:ftp://ftp.cs.umd.edu/pub/skipLists/skiplists.pdf

我呢,寫文章通常遇到大佬的時候我都習慣性的去網上搜一下大佬長什麼樣子。也沒別的意思。主要是關注一下他們的髮量稀疏與否。

在找論文做者的照片以前,我叫他 William 先生,找到以後,我想給他起個「外號」,就叫火男:

他的主頁就只放了這一張放蕩不羈的照片。而後,我點進了他的 website:

裏面提到了他的豐功偉績。

我一眼瞟去,感興趣的就是我圈起來的三個地方。

  • 第一個是發明跳錶。
  • 第二個是參與了 JSR-133《Java內存模型和線程規範修訂》的工做。
  • 第三個是這個哥們在谷歌的時候,學會了吞火。我尋思谷歌真是人才濟濟啊,還教這玩意呢?

eat fire,大佬的愛好確實是不同。

感受他確實是喜歡玩火,那我就叫他火男吧:

火男的論文摘要裏面,是這樣的介紹跳錶的:

摘要裏面說:跳錶是一種能夠用來代替平衡樹的數據結構,跳錶使用機率平衡而不是嚴格的平衡,所以,與平衡樹相比,跳錶中插入和刪除的算法要簡單得多,而且速度要快得多。

論文裏面,在對跳錶算法進行詳細描述的地方他是這樣說的:

首先火男大佬說,對於一個有序的鏈表來講,若是咱們須要查找某個元素,必須對鏈表進行遍歷。好比他給的示意圖的 a 部分。

我單獨截取一下:

這個時候,你們還能跟上,對吧。鏈表查找,逐個遍歷是基本操做。

那麼,若是這個鏈表是有序的,咱們能夠搞一個指針,這個指針指向的是該節點的下下個節點。

意思就是往上抽離一部分節點。

怎麼抽離呢,每隔一個節點,就抽一個出來,和上面的 a 示意圖比起來,變化就是這樣的:

抽離出來有什麼好處呢?

假設咱們要查詢的節點是 25 。

當就是普通有序鏈表的時候,咱們從頭節點開始遍歷,須要遍歷的路徑是:

head -> 3 -> 6 -> 7 -> 9 -> 12 -> 17 -> 19 -> 21 -> 25

須要 9 次查詢才能找到 25 。

可是當結構稍微一變,變成了 b 示意圖的樣子以後,查詢路徑就是:

第二層的 head -> 6 -> 9 -> 17 -> 21 -> 25。

5 次查詢就找到了 25 。

這個狀況下咱們找到指定的元素,不會超過 (n/2)+1 個節點:

那麼這個時候有個小問題就來了:怎麼從 21 直接到 25 的呢?

看論文中的圖片,稍微有一點不容易明白。

因此,我給你們從新畫個示意圖:

看到了嗎?「多了」一個向下的指針。其實也不是多了,只是論文裏面沒有明示而已。

因此,查詢 25 的路徑是這樣的,空心箭頭指示的方向:

在 21 到 26 節點之間,往下了一層,邏輯也很簡單。

21 節點有一個右指針指向 26,先判斷右指針的值大於查詢的值了。

因而下指針就起到做用了,往下一層,再繼續進行右指針的判斷。

其實每一個節點的判斷邏輯都是這樣,只是前面的判斷結果是進行走右指針。

按照這個往上抽節點的思想,假設咱們抽到第四層,也就是論文中的這個示意圖:

咱們查詢 25 的時候,只須要通過 2 次。

第一步就直接跳過了 21 以前的全部元素。

怎麼樣,爽不爽?

可是,它是有缺陷的。

火男的論文裏面是這樣說的:

This data structure could be used for fast searching, but insertion and deletion would be impractical.

查詢確實飛快。可是對於插入和刪除 would be impractical。

impractical 是什麼意思?

你看,又學一個四級單詞。

對於插入和刪除幾乎是難以實現的。

你想啊,上面那個最底層的有序鏈表,我一開始就拿出來給你了。

而後我就說基於這個有序鏈表每隔一個節點抽離到上一層去,再構建一個鏈表。那麼這樣上下層節點比例應該是 2:1。巴拉巴拉的.....

可是實際狀況應該是咱們最開始的時候連這個有序鏈表都沒有,須要本身去建立的。

就假設要在現有的這個跳錶結構中插入一個節點,毋庸置疑,確定是要插入到最底層的有序鏈表中的。

可是你破壞了上下層 1:2 的比例了呀?

怎麼辦,一層層的調整唄。

能夠,可是請你考慮一下編碼實現起來的難度和對應的時間複雜度?

要這樣搞,直接就是一波勸退。

這就受不了了?

我還沒說刪除的事呢。

那怎麼辦?

看看論文裏面怎麼說到:

首先咱們關注一下第一段劃紅線的地方。

火男寫到:50% 的節點在第一層,25% 的節點在第二層, 12.5% 的節點在第三層。

你覺得他在給你說什麼?

他要表達的意思除了每一層節點的個數以外,還說明了層級:

沒有第 0 層,至少論文裏面沒有說有第 0 層。

若是你非要說最下面那個有所有節點的有序鏈表叫作第 0 層,我以爲也能夠。可是,我以爲叫它基礎鏈表更加合適一點。

而後我再看第二段劃線的地方。

火男提到了一個關鍵詞:randomly,意思是隨機。

說出來你可能不信,可是跳錶是用隨機的方式解決上面提出的插入(刪除)以後調整結構的問題。

怎麼隨機呢?拋硬幣。

是的,沒有騙你,真的是「拋硬幣」。

跳錶中的「硬幣」

當跳錶中插入一個元素的時候,火男表示咱們上下層之間能夠不嚴格遵循 1:2 的節點關係。

若是插入的這個元素須要創建索引,那麼把索引創建在第幾層,都是由拋硬幣決定的。

或者說:由拋硬幣的機率決定的。

我問你,一個硬幣拋出去以後,是正面的機率有多大?

是否是 50%?

若是咱們把這個機率記爲 p,那麼 50%,即 p=1/2。

上面咱們提到的機率,究竟是怎麼用的呢?

火男的論文中有一小節是這樣的寫的:

隨機選擇一個層級。他說咱們假設機率 p=1/2,而後叫咱們看圖 5。

圖 5 是這樣的:

很是關鍵的一張圖啊。

短短几行代碼,描述的是如何選擇層級的隨機算法。

首先定義初始層級爲 1(lvl := 1)。

而後有一行註釋:random() that returns a random value in [0...1)

random() 返回一個 [0...1) 之間的隨機值。

接下來一個 while...do 循環。

循環條件兩個。

第一個:random() < p。因爲 p = 1/2,那麼該條件成立的機率也是 1/2。

若是每隨機一次,知足 random() < p,那麼層級加一。

那假設你運氣爆棚,接連一百次隨機出來的數都是小於 p 的怎麼辦?豈不是層級也到 100 層了?

第二個條件 lvl < MaxLevel,就是防止這種狀況的。能夠保證算出來的層級不會超過指定的 MaxLevel。

這樣看來,雖然每次都是基於機率決定在那個層級,可是整體趨勢是趨近於 1/2 的。

帶來的好處是,每次插入都是獨立的,只須要調整插入先後節點的指針便可。

一次插入就是一次查詢加更新的操做,好比下面的這個示意圖:

另外對於這個機率,其實火男在論文專門寫了一個小標題,還給出了一個圖表:

最終得出的結論是,火男建議 p 值取 1/4。若是你主要關心的是執行時間的變化,那麼 p 就取值 1/2。

說一下個人理解。首先跳錶這個是一個典型的空間換時間的例子。

一個有序的二維數組,查找指定元素,理論上是二分查找最快。而跳錶就是在基礎的鏈表上不斷的抽節點(或者叫索引),造成新的鏈表。

因此,當 p=1/2 的時候,就近似於二分查找,查詢速度快,可是層數比較高,佔的空間就大。

當 p=1/4 的時候,元素升級層數的機率就低,整體層高就低,雖然查詢速度慢一點,可是佔的空間就小一點。

在 Redis 中 p 的取值就是 0.25,即 1/4,MaxLevel 的取值是 32(視版本而定:有的版本是64)。

論文裏面還花了大量的篇幅去推理時間複雜度,有興趣的能夠去看着論文一塊兒推理一下:

跳錶在 Java 中的應用

跳錶,雖然是一個接觸比較少的數據結構。

其實在 java 中也有對應的實現。

先問個問題:Map 家族中大多都是無序的,那麼請問你知道有什麼 Map 是有序的呢?

TreeMap,LinkedHashMap 是有序的,對吧。

可是它們不是線程安全的。

那麼既是線程安全的,又是有序的 Map 是什麼?

那就是它,一個存在感也是低的不行的 ConcurrentSkipListMap。

你看它這個名字多吊,又有 list 又有 Map。

看一個測試用例:

`public class MainTest {
    public static void main(String[] args) {
        ConcurrentSkipListMap<Integer, String> skipListMap = new ConcurrentSkipListMap<>();
        skipListMap.put(3,"3");
        skipListMap.put(6,"6");
        skipListMap.put(7,"7");
        skipListMap.put(9,"9");
        skipListMap.put(12,"12");
        skipListMap.put(17,"17");
        skipListMap.put(19,"19");
        skipListMap.put(21,"21");
        skipListMap.put(25,"25");
        skipListMap.put(26,"26");
        System.out.println("skipListMap = " + skipListMap);
    }
}
`

輸出結果是這樣的,確實是有序的:

稍微的剖析一下。首先看看它的三個關鍵結構。

第一個是 index:

index 裏面包含了一個節點 node、一個右指針(right)、一個下指針(down)。

第二個是 HeadIndex:

它是繼承自 index 的,只是多了一個 level 屬性,記錄是位於第幾層的索引。

第三個是 node:

這個 node 沒啥說的,一看就是個鏈表。

這三者之間的關係就是示意圖這樣的:

咱們就用前面的示例代碼,先 debug 一下,把上面的示意圖,用真實的值填充上。

debug 跑起來以後,能夠看到當前是有兩個層級的:

咱們先看看第二層的鏈表是怎樣的,也就是看第二層頭節點的 right 屬性:

因此第二層的鏈表是這樣的:

第二層的 HeadIndex 節點除了咱們剛剛分析的 right 屬性外,還有一個 down,指向的是下一層,也就是第一層的 HeadIndex:

能夠看到第一層的 HeadIndex 的 down 屬性是 null。可是它的 right 屬性是有值的:

能夠畫出第一層的鏈表結構是這樣的:

同時咱們能夠看到其 node 屬性裏面實際上是整個有序鏈表(其實每一層的 HeadIndex 裏面都有):

因此,整個跳錶結構是這樣的:

可是當你拿着一樣的程序,本身去調試的時候,你會發現,你的跳錶不長這樣啊?

固然不同了,同樣了纔是撞了鬼了。

別忘了,索引的層級是隨機產生的。

ConcurrentSkipListMap 是怎樣隨機的呢?

帶你們看看 put 部分的源碼。

標號爲 ① 的地方代碼不少,可是核心思想是把指定元素維護進最底層的有序鏈表中。就不進行解讀了,因此我把這塊代碼摺疊起來了。

標號爲 ② 的地方是 (rnd & 0x80000001) == 0

這個 rnd 是上一行代碼隨機出來的值。

而 0x80000001 對應的二進制是這樣的:

一頭一尾都是1,其餘位都是 0。

那麼只有 rnd 的一頭一尾都是 0 的時候,纔會知足 if 條件,(rnd & 0x80000001) == 0

二進制的一頭一尾都是 0,說明是一個正偶數。

隨機出來一個正偶數的時候,代表須要對其進行索引的維護。

標號爲 ③ 的地方是判斷當前元素要維護到第幾層索引中。

((rnd >>>= 1) & 1) != 0 ,已知 rnd 是一個正偶數,那麼從其二進制的低位的第二位(第一位確定是0嘛)開始,有幾個連續的 1,就維護到第幾層。

不明白?不要緊,我舉個例子。

假設隨機出來的正偶數是 110,其二進制是 01101110。由於有 3 個連續的 1,那麼 level 就是從 1 連續自增 3 次,最終的 level 就是 4。

那麼問題就來了,若是咱們當前最多隻有 2 層索引呢?直接就把索引幹到第 4 層嗎?

這個時候標號爲 ④ 的代碼的做用就出來了。

若是新增的層數大於現有的層數,那麼只是在現有的層數上進行加一。

這個時候咱們再回過頭去看看火男論文裏面的隨機算法:

因此,你如今知道了,因爲有隨機數的出現,因此即便是相同的參數,每次均可以構建出不同的跳錶結構。

好比仍是前面演示的代碼,我 debug 截圖的時候有兩層索引。

可是,其實有的時候我也會碰到 3 層索引的狀況。

別問爲何,用心去感覺,你內心應該有數。

另外,開篇用 redis 作爲了切入點,其實 redis 的跳錶總體思想是大同的,可是也是有小異的。

好比 Redis 在 skiplist 的 forward 指針(至關於 index)上,每一個 forward 指針都增長了 span 屬性。

在《Redis深度歷險》一書裏面對該屬性進行了描述:

最後說一句(求關注)

好了,那麼此次的文章就到這裏啦。

才疏學淺,不免會有紕漏,若是你發現了錯誤的地方,能夠提出來,我對其加以修改。 感謝您的閱讀,我堅持原創,十分歡迎並感謝您的關注。

我是 why,一個被代碼耽誤的文學創做者,不是大佬,可是喜歡分享,是一個又暖又有料的四川好男人。

歡迎關注我呀。

相關文章
相關標籤/搜索