一個基於運氣的資料結構,你猜是啥?

why技術發表於2020-12-07

排行榜

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

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

當然了,排行榜我們也可以基於其他的解決方案。比如 mysql。

我曾經就基於 mysql 做過排行榜,一條 sql 就能搞定。但是僅限於資料量比較少,效能要求不高的場景(我當時只有 11 支隊伍做排行榜,一分鐘重新整理一次排行榜)。

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

說好的只是一個切入點。

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

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

zset 的內部編碼

眾所周知,Redis 對外提供了五種基本資料型別。但是每一種基本型別的內部編碼卻是另外一番風景:

其中 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,一個被程式碼耽誤的文學創作者,不是大佬,但是喜歡分享,是一個又暖又有料的四川好男人。

歡迎關注我呀。

相關文章