面試28k職位,老鄉面試官從HashCode到HashMap給我講了一下午!「回家趕忙整理出1.6萬字的面試材料」

小傅哥發表於2020-08-23


作者:小傅哥
部落格:https://bugstack.cn


目錄

一、前言

不是面試難,而是30歲要有30歲的能力,35歲要有35歲的經歷!

☺️可能有點標題誇張,但本文通篇乾貨,要不親身實踐各項知識點,很難有這樣的深度的總結。有時候我們會抱怨找工作難,但同樣企業招聘也難,面試官向我透漏,為了招聘3個高開,以及篩選了200份簡歷,面試了70場。

本文從HashCode講到HashMap,從一個小小的知識點擴充套件的理論實踐驗證,10來萬單詞表的資料驗證;資料分佈擾動函式負載因子資料遷移等各項核心數學知識,非常適合即將跨入高開的程式設計師學習。

本文涉及到的原始碼和圖表,可以關注公眾號:bugstack蟲洞棧,回覆下載後,開啟獲得的連結,找到ID:19,即可下載。

好!接下來就是我們這次面試的核心知識點總結,通篇1.6萬字,需耐心閱讀。

二、HashCode為什麼使用31作為乘數

1. 固定乘積31在這用到了

// 獲取hashCode "abc".hashCode();
public int hashCode() {
    int h = hash;
    if (h == 0 && value.length > 0) {
        char val[] = value;
        for (int i = 0; i < value.length; i++) {
            h = 31 * h + val[i];
        }
        hash = h;
    }
    return h;
}

在獲取hashCode的原始碼中可以看到,有一個固定值31,在for迴圈每次執行時進行乘積計算,迴圈後的公式如下;
s[0]*31^(n-1) + s[1]*31^(n-2) + ... + s[n-1]

那麼這裡為什麼選擇31作為乘積值呢?

2. 來自stackoverflow的回答

stackoverflow關於為什麼選擇31作為固定乘積值,有一篇討論文章,Why does Java's hashCode() in String use 31 as a multiplier? 這是一個時間比較久的問題了,摘取兩個回答點贊最多的;

413個贊?的回答

最多的這個回答是來自《Effective Java》的內容;

The value 31 was chosen because it is an odd prime. If it were even and the multiplication overflowed, information would be lost, as multiplication by 2 is equivalent to shifting. The advantage of using a prime is less clear, but it is traditional. A nice property of 31 is that the multiplication can be replaced by a shift and a subtraction for better performance: 31 * i == (i << 5) - i. Modern VMs do this sort of optimization automatically.

這段內容主要闡述的觀點包括;

  1. 31 是一個奇質數。
  2. 另外在二進位制中,2個5次方是32,那麼也就是 31 * i == (i << 5) - i。這主要是說乘積運算可以使用位移提升效能,同時目前的JVM虛擬機器也會自動支援此類的優化。

80個贊?的回答

As Goodrich and Tamassia point out, If you take over 50,000 English words (formed as the union of the word lists provided in two variants of Unix), using the constants 31, 33, 37, 39, and 41 will produce less than 7 collisions in each case. Knowing this, it should come as no surprise that many Java implementations choose one of these constants.
  • 這個回答就很有實戰意義了,告訴你用超過5千個單詞計算hashCode,這個hashCode的運算使用31、33、37、39和41作為乘積,得到的碰撞結果,31被使用就很正常了。
  • 他這句話就就可以作為我們實踐的指向了。

3. Hash值碰撞概率統計

接下來要做的事情並不難,只是根據stackoverflow的回答,統計出不同的乘積數對10萬個單詞的hash計算結果。10個單詞表已提供,可以通過關注公眾號:bugstack蟲洞棧進行下載

3.1 讀取單詞字典表

1	a	"n.(A)As 或 A's  安(ampere(a) art.一;n.字母A /[軍] Analog.Digital,模擬/數字 /(=account of) 帳上"
2	aaal	American Academy of Arts and Letters 美國藝術和文學學會
3	aachen	 亞琛[德意志聯邦共和國西部城市]
4	aacs	Airways and Air Communications Service (美國)航路與航空通訊聯絡處
5	aah	" [軍]Armored Artillery Howitzer,裝甲榴彈炮;[軍]Advanced Attack Helicopter,先進攻擊直升機"
6	aal	"ATM Adaptation Layer,ATM適應層"
7	aapamoor	"n.[生]丘澤,高低位鑲嵌沼澤"
  • 單詞表的檔案格式如上,可以自行解析
  • 讀取檔案的程式碼比較簡單,這裡不展示了,可以通過資源下載進行獲取

3.2 Hash計算函式

public static Integer hashCode(String str, Integer multiplier) {
    int hash = 0;
    for (int i = 0; i < str.length(); i++) {
        hash = multiplier * hash + str.charAt(i);
    }
    return hash;
}
  • 這個過程比較簡單,與原hash函式對比只是替換了可變引數,用於我們統計不同乘積數的計算結果。

3.3 Hash碰撞概率計算

想計算碰撞很簡單,也就是計算那些出現相同雜湊值的數量,計算出碰撞總量即可。這裡的實現方式有很多,可以使用setmap也可以使用java8stream流統計distinct

private static RateInfo hashCollisionRate(Integer multiplier, List<Integer> hashCodeList) {
    int maxHash = hashCodeList.stream().max(Integer::compareTo).get();
    int minHash = hashCodeList.stream().min(Integer::compareTo).get();
    int collisionCount = (int) (hashCodeList.size() - hashCodeList.stream().distinct().count());
    double collisionRate = (collisionCount * 1.0) / hashCodeList.size();
    return new RateInfo(maxHash, minHash, multiplier, collisionCount, collisionRate);
}
  • 這裡記錄了最大hash和最小hash值,以及最終返回碰撞數量的統計結果。

3.4 單元測試

@Before
public void before() {
    "abc".hashCode();
    // 讀取檔案,103976個英語單詞庫.txt
    words = FileUtil.readWordList("E:/itstack/git/github.com/interview/interview-01/103976個英語單詞庫.txt");
}

@Test
public void test_collisionRate() {
    List<RateInfo> rateInfoList = HashCode.collisionRateList(words, 2, 3, 5, 7, 17, 31, 32, 33, 39, 41, 199);
    for (RateInfo rate : rateInfoList) {
        System.out.println(String.format("乘數 = %4d, 最小Hash = %11d, 最大Hash = %10d, 碰撞數量 =%6d, 碰撞概率 = %.4f%%", rate.getMultiplier(), rate.getMinHash(), rate.getMaxHash(), rate.getCollisionCount(), rate.getCollisionRate() * 100));
    }
}
  • 以上先設定讀取英文單詞表中的10個單詞,之後做hash計算。
  • 在hash計算中把單詞表傳遞進去,同時還有乘積數;2, 3, 5, 7, 17, 31, 32, 33, 39, 41, 199,最終返回一個list結果並輸出。
  • 這裡主要驗證同一批單詞,對於不同乘積數會有怎麼樣的hash碰撞結果。

測試結果

單詞數量:103976
乘數 =    2, 最小Hash =          97, 最大Hash = 1842581979, 碰撞數量 = 60382, 碰撞概率 = 58.0730%
乘數 =    3, 最小Hash = -2147308825, 最大Hash = 2146995420, 碰撞數量 = 24300, 碰撞概率 = 23.3708%
乘數 =    5, 最小Hash = -2147091606, 最大Hash = 2147227581, 碰撞數量 =  7994, 碰撞概率 = 7.6883%
乘數 =    7, 最小Hash = -2147431389, 最大Hash = 2147226363, 碰撞數量 =  3826, 碰撞概率 = 3.6797%
乘數 =   17, 最小Hash = -2147238638, 最大Hash = 2147101452, 碰撞數量 =   576, 碰撞概率 = 0.5540%
乘數 =   31, 最小Hash = -2147461248, 最大Hash = 2147444544, 碰撞數量 =     2, 碰撞概率 = 0.0019%
乘數 =   32, 最小Hash = -2007883634, 最大Hash = 2074238226, 碰撞數量 = 34947, 碰撞概率 = 33.6106%
乘數 =   33, 最小Hash = -2147469046, 最大Hash = 2147378587, 碰撞數量 =     1, 碰撞概率 = 0.0010%
乘數 =   39, 最小Hash = -2147463635, 最大Hash = 2147443239, 碰撞數量 =     0, 碰撞概率 = 0.0000%
乘數 =   41, 最小Hash = -2147423916, 最大Hash = 2147441721, 碰撞數量 =     1, 碰撞概率 = 0.0010%
乘數 =  199, 最小Hash = -2147459902, 最大Hash = 2147480320, 碰撞數量 =     0, 碰撞概率 = 0.0000%

Process finished with exit code 0

公眾號:bugstack蟲洞棧,hash碰撞圖表

以上就是不同的乘數下的hash碰撞結果圖示展示,從這裡可以看出如下資訊;

  1. 乘數是2時,hash的取值範圍比較小,基本是堆積到一個範圍內了,後面內容會看到這塊的展示。
  2. 乘數是3、5、7、17等,都有較大的碰撞概率
  3. 乘數是31的時候,碰撞的概率已經很小了,基本穩定。
  4. 順著往下看,你會發現199的碰撞概率更小,這就相當於一排奇數的茅坑量多,自然會減少碰撞。但這個範圍值已經遠超過int的取值範圍了,如果用此數作為乘數,又返回int值,就會丟失資料資訊

4. Hash值雜湊分佈

除了以上看到雜湊值在不同乘數的一個碰撞概率後,關於雜湊表也就是hash,還有一個非常重要的點,那就是要儘可能的讓資料雜湊分佈。只有這樣才能減少hash碰撞次數,也就是後面章節要講到的hashMap原始碼。

那麼怎麼看雜湊分佈呢?如果我們能把10萬個hash值鋪到圖表上,形成的一張圖,就可以看出整個雜湊分佈。但是這樣的圖會比較大,當我們縮小看後,就成一個了大黑點。所以這裡我們採取分段統計,把2 ^ 32方分64個格子進行存放,每個格子都會有對應的數量的hash值,最終把這些資料展示在圖表上。

4.1 雜湊值分段存放

public static Map<Integer, Integer> hashArea(List<Integer> hashCodeList) {
    Map<Integer, Integer> statistics = new LinkedHashMap<>();
    int start = 0;
    for (long i = 0x80000000; i <= 0x7fffffff; i += 67108864) {
        long min = i;
        long max = min + 67108864;
        // 篩選出每個格子裡的雜湊值數量,java8流統計;https://bugstack.cn/itstack-demo-any/2019/12/10/%E6%9C%89%E7%82%B9%E5%B9%B2%E8%B4%A7-Jdk1.8%E6%96%B0%E7%89%B9%E6%80%A7%E5%AE%9E%E6%88%98%E7%AF%87(41%E4%B8%AA%E6%A1%88%E4%BE%8B).html
        int num = (int) hashCodeList.parallelStream().filter(x -> x >= min && x < max).count();
        statistics.put(start++, num);
    }
    return statistics;
  • 這個過程主要統計int取值範圍內,每個雜湊值存放到不同格子裡的數量。
  • 這裡也是使用了java8的新特性語法,統計起來還是比較方便的。

4.2 單元測試

@Test
public void test_hashArea() {
    System.out.println(HashCode.hashArea(words, 2).values());
    System.out.println(HashCode.hashArea(words, 7).values());
    System.out.println(HashCode.hashArea(words, 31).values());
    System.out.println(HashCode.hashArea(words, 32).values());
    System.out.println(HashCode.hashArea(words, 199).values());
}
  • 這裡列出我們要統計的乘數值,每一個乘數下都會有對應的雜湊值數量彙總,也就是64個格子裡的數量。
  • 最終把這些統計值放入到excel中進行圖表化展示。

統計圖表

公眾號:bugstack蟲洞棧,hash雜湊表

  • 以上是一個堆積百分比統計圖,可以看到下方是不同乘數下的,每個格子裡的資料統計。
  • 除了199不能用以外,31的雜湊結果相對來說比較均勻。
4.2.1 乘數2雜湊

  • 乘數是2的時候,雜湊的結果基本都堆積在中間,沒有很好的雜湊。
4.2.2 乘數31雜湊

  • 乘數是31的時候,雜湊的效果就非常明顯了,基本在每個範圍都有資料存放。
4.2.3 乘數199雜湊

  • 乘數是199是不能用的雜湊結果,但是它的資料是更加分散的,從圖上能看到有兩個小山包。但因為資料區間問題會有資料丟失問題,所以不能選擇。

三、HashMap 資料結構與演算法

1. 寫一個最簡單的HashMap

學習HashMap前,最好的方式是先了解這是一種怎麼樣的資料結構來存放資料。而HashMap經過多個版本的迭代後,乍一看程式碼還是很複雜的。就像你原來只穿個褲衩,現在還有秋褲和風衣。所以我們先來看看最根本的HashMap是什麼樣,也就是隻穿褲衩是什麼效果,之後再去分析它的原始碼。

問題:假設我們有一組7個字串,需要存放到陣列中,但要求在獲取每個元素的時候時間複雜度是O(1)。也就是說你不能通過迴圈遍歷的方式進行獲取,而是要定位到陣列ID直接獲取相應的元素。

方案:如果說我們需要通過ID從陣列中獲取元素,那麼就需要把每個字串都計算出一個在陣列中的位置ID。字串獲取ID你能想到什麼方式? 一個字串最直接的獲取跟數字相關的資訊就是HashCode,可HashCode的取值範圍太大了[-2147483648, 2147483647],不可能直接使用。那麼就需要使用HashCode與陣列長度做與運算,得到一個可以在陣列中出現的位置。如果說有兩個元素得到同樣的ID,那麼這個陣列ID下就存放兩個字串。

以上呢其實就是我們要把字串雜湊到陣列中的一個基本思路,接下來我們就把這個思路用程式碼實現出來。

1.1 程式碼實現

// 初始化一組字串
List<String> list = new ArrayList<>();
list.add("jlkk");
list.add("lopi");
list.add("小傅哥");
list.add("e4we");
list.add("alpo");
list.add("yhjk");
list.add("plop");

// 定義要存放的陣列
String[] tab = new String[8];

// 迴圈存放
for (String key : list) {
    int idx = key.hashCode() & (tab.length - 1);  // 計算索引位置
    System.out.println(String.format("key值=%s Idx=%d", key, idx));
    if (null == tab[idx]) {
        tab[idx] = key;
        continue;
    }
    tab[idx] = tab[idx] + "->" + key;
}
// 輸出測試結果
System.out.println(JSON.toJSONString(tab));

這段程式碼整體看起來也是非常簡單,並沒有什麼複雜度,主要包括以下內容;

  1. 初始化一組字串集合,這裡初始化了7個。
  2. 定義一個陣列用於存放字串,注意這裡的長度是8,也就是2的倍數。這樣的陣列長度才會出現一個 0111 除高位以外都是1的特徵,也是為了雜湊。
  3. 接下來就是迴圈存放資料,計算出每個字串在陣列中的位置。key.hashCode() & (tab.length - 1)
  4. 在字串存放到陣列的過程,如果遇到相同的元素,進行連線操作模擬連結串列的過程
  5. 最後輸出存放結果。

測試結果

key值=jlkk Idx=2
key值=lopi Idx=4
key值=小傅哥 Idx=7
key值=e4we Idx=5
key值=alpo Idx=2
key值=yhjk Idx=0
key值=plop Idx=5
測試結果:["yhjk",null,"jlkk->alpo",null,"lopi","e4we->plop",null,"小傅哥"]
  • 在測試結果首先是計算出每個元素在陣列的Idx,也有出現重複的位置。
  • 最後是測試結果的輸出,1、3、6,位置是空的,2、5,位置有兩個元素被連結起來e4we->plop
  • 這就達到了我們一個最基本的要求,將串元素雜湊存放到陣列中,最後通過字串元素的索引ID進行獲取對應字串。這樣是HashMap的一個最基本原理,有了這個基礎後面就會更容易理解HashMap的原始碼實現。

1.2 Hash雜湊示意圖

如果上面的測試結果不能在你的頭腦中很好的建立出一個資料結構,那麼可以看以下這張雜湊示意圖,方便理解;

bugstack.cn Hash雜湊示意圖

  • 這張圖就是上面程式碼實現的全過程,將每一個字串元素通過Hash計算索引位置,存放到陣列中。
  • 黃色的索引ID是沒有元素存放、綠色的索引ID存放了一個元素、紅色的索引ID存放了兩個元素。

1.3 這個簡單的HashMap有哪些問題

以上我們實現了一個簡單的HashMap,或者說還算不上HashMap,只能算做一個雜湊資料存放的雛形。但這樣的一個資料結構放在實際使用中,會有哪些問題呢?

  1. 這裡所有的元素存放都需要獲取一個索引位置,而如果元素的位置不夠雜湊碰撞嚴重,那麼就失去了雜湊表存放的意義,沒有達到預期的效能。
  2. 在獲取索引ID的計算公式中,需要陣列長度是2的倍數,那麼怎麼進行初始化這個陣列大小。
  3. 陣列越小碰撞的越大,陣列越大碰撞的越小,時間與空間如何取捨。
  4. 目前存放7個元素,已經有兩個位置都存放了2個字串,那麼連結串列越來越長怎麼優化。
  5. 隨著元素的不斷新增,陣列長度不足擴容時,怎麼把原有的元素,拆分到新的位置上去。

以上這些問題可以歸納為;擾動函式初始化容量負載因子擴容方法以及連結串列和紅黑樹轉換的使用等。接下來我們會逐個問題進行分析。

2. 擾動函式

在HashMap存放元素時候有這樣一段程式碼來處理雜湊值,這是java 8的雜湊值擾動函式,用於優化雜湊效果;

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

2.1 為什麼使用擾動函式

理論上來說字串的hashCode是一個int型別值,那可以直接作為陣列下標了,且不會出現碰撞。但是這個hashCode的取值範圍是[-2147483648, 2147483647],有將近40億的長度,誰也不能把陣列初始化的這麼大,記憶體也是放不下的。

我們預設初始化的Map大小是16個長度 DEFAULT_INITIAL_CAPACITY = 1 << 4,所以獲取的Hash值並不能直接作為下標使用,需要與陣列長度進行取模運算得到一個下標值,也就是我們上面做的雜湊列子。

那麼,hashMap原始碼這裡不只是直接獲取雜湊值,還進行了一次擾動計算,(h = key.hashCode()) ^ (h >>> 16)。把雜湊值右移16位,也就正好是自己長度的一半,之後與原雜湊值做異或運算,這樣就混合了原雜湊值中的高位和低位,增大了隨機性。計算方式如下圖;

bugstack.cn 擾動函式

  • 說白了,使用擾動函式就是為了增加隨機性,讓資料元素更加均衡的雜湊,減少碰撞。

2.2 實驗驗證擾動函式

從上面的分析可以看出,擾動函式使用了雜湊值的高半區和低半區做異或,混合原始雜湊碼的高位和低位,以此來加大低位區的隨機性。

但看不到實驗資料的話,這終究是一段理論,具體這段雜湊值真的被增加了隨機性沒有,並不知道。所以這裡我們要做一個實驗,這個實驗是這樣做;

  1. 選取10萬個單詞詞庫
  2. 定義128位長度的陣列格子
  3. 分別計算在擾動和不擾動下,10萬單詞的下標分配到128個格子的數量
  4. 統計各個格子數量,生成波動曲線。如果擾動函式下的波動曲線相對更平穩,那麼證明擾動函式有效果。
2.2.1 擾動程式碼測試

擾動函式對比方法

public class Disturb {

    public static int disturbHashIdx(String key, int size) {
        return (size - 1) & (key.hashCode() ^ (key.hashCode() >>> 16));
    }

    public static int hashIdx(String key, int size) {
        return (size - 1) & key.hashCode();
    }

}
  • disturbHashIdx 擾動函式下,下標值計算
  • hashIdx 非擾動函式下,下標值計算

單元測試

// 10萬單詞已經初始化到words中
@Test
public void test_disturb() {
    Map<Integer, Integer> map = new HashMap<>(16);
    for (String word : words) {
        // 使用擾動函式
        int idx = Disturb.disturbHashIdx(word, 128);
        // 不使用擾動函式
        // int idx = Disturb.hashIdx(word, 128);
        if (map.containsKey(idx)) {
            Integer integer = map.get(idx);
            map.put(idx, ++integer);
        } else {
            map.put(idx, 1);
        }
    }
    System.out.println(map.values());
}

以上分別統計兩種函式下的下標值分配,最終將統計結果放到excel中生成圖表。

2.2.2 擾動函式雜湊圖表

以上的兩張圖,分別是沒有使用擾動函式和使用擾動函式的,下標分配。實驗資料;

  1. 10萬個不重複的單詞
  2. 128個格子,相當於128長度的陣列

未使用擾動函式

bugstack.cn 未使用擾動函式

使用擾動函式

bugstack.cn 使用擾動函式

  • 從這兩種的對比圖可以看出來,在使用了擾動函式後,資料分配的更加均勻了。
  • 資料分配均勻,也就是雜湊的效果更好,減少了hash的碰撞,讓資料存放和獲取的效率更佳。

3. 初始化容量和負載因子

接下來我們討論下一個問題,從我們模仿HashMap的例子中以及HashMap預設的初始化大小裡,都可以知道,雜湊陣列需要一個2的倍數的長度,因為只有2的倍數在減1的時候,才會出現01111這樣的值。

那麼這裡就有一個問題,我們在初始化HashMap的時候,如果傳一個17個的值new HashMap<>(17);,它會怎麼處理呢?

3.1 尋找2的倍數最小值

在HashMap的初始化中,有這樣一段方法;

public HashMap(int initialCapacity, float loadFactor) {
    ...
    this.loadFactor = loadFactor;
    this.threshold = tableSizeFor(initialCapacity);
}
  • 閥值threshold,通過方法tableSizeFor進行計算,是根據初始化來計算的。
  • 這個方法也就是要尋找比初始值大的,最小的那個2進位制數值。比如傳了17,我應該找到的是32。

計算閥值大小的方法;

static final int tableSizeFor(int cap) {
    int n = cap - 1;
    n |= n >>> 1;
    n |= n >>> 2;
    n |= n >>> 4;
    n |= n >>> 8;
    n |= n >>> 16;
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
  • MAXIMUM_CAPACITY = 1 << 30,這個是臨界範圍,也就是最大的Map集合。
  • 乍一看可能有點暈?怎麼都在向右移位1、2、4、8、16,這主要是為了把二進位制的各個位置都填上1,當二進位制的各個位置都是1以後,就是一個標準的2的倍數減1了,最後把結果加1再返回即可。

那這裡我們把17這樣一個初始化計算閥值的過程,用圖展示出來,方便理解;

bugstack.cn 計算閥值

3.2 負載因子

static final float DEFAULT_LOAD_FACTOR = 0.75f;

負載因子是做什麼的?

負載因子,可以理解成一輛車可承重重量超過某個閥值時,把貨放到新的車上。

那麼在HashMap中,負載因子決定了資料量多少了以後進行擴容。這裡要提到上面做的HashMap例子,我們準備了7個元素,但是最後還有3個位置空餘,2個位置存放了2個元素。 所以可能即使你資料比陣列容量大時也是不一定能正正好好的把陣列佔滿的,而是在某些小標位置出現了大量的碰撞,只能在同一個位置用連結串列存放,那麼這樣就失去了Map陣列的效能。

所以,要選擇一個合理的大小下進行擴容,預設值0.75就是說當閥值容量佔了3/4s時趕緊擴容,減少Hash碰撞。

同時0.75是一個預設構造值,在建立HashMap也可以調整,比如你希望用更多的空間換取時間,可以把負載因子調的更小一些,減少碰撞。

4. 擴容元素拆分

為什麼擴容,因為陣列長度不足了。那擴容最直接的問題,就是需要把元素拆分到新的陣列中。拆分元素的過程中,原jdk1.7中會需要重新計算雜湊值,但是到jdk1.8中已經進行優化,不在需要重新計算,提升了拆分的效能,設計的還是非常巧妙的。

4.1 測試資料

@Test
public void test_hashMap() {
    List<String> list = new ArrayList<>();
    list.add("jlkk");
    list.add("lopi");
    list.add("jmdw");
    list.add("e4we");
    list.add("io98");
    list.add("nmhg");
    list.add("vfg6");
    list.add("gfrt");
    list.add("alpo");
    list.add("vfbh");
    list.add("bnhj");
    list.add("zuio");
    list.add("iu8e");
    list.add("yhjk");
    list.add("plop");
    list.add("dd0p");
    for (String key : list) {
        int hash = key.hashCode() ^ (key.hashCode() >>> 16);
        System.out.println("字串:" + key + " \tIdx(16):" + ((16 - 1) & hash) + " \tBit值:" + Integer.toBinaryString(hash) + " - " + Integer.toBinaryString(hash & 16) + " \t\tIdx(32):" + ((
        System.out.println(Integer.toBinaryString(key.hashCode()) +" "+ Integer.toBinaryString(hash) + " " + Integer.toBinaryString((32 - 1) & hash));
    }
}

測試結果

字串:jlkk 	Idx(16):3 	Bit值:1100011101001000010011 - 10000 		Idx(32):19
1100011101001000100010 1100011101001000010011 10011
字串:lopi 	Idx(16):14 	Bit值:1100101100011010001110 - 0 		Idx(32):14
1100101100011010111100 1100101100011010001110 1110
字串:jmdw 	Idx(16):7 	Bit值:1100011101010100100111 - 0 		Idx(32):7
1100011101010100010110 1100011101010100100111 111
字串:e4we 	Idx(16):3 	Bit值:1011101011101101010011 - 10000 		Idx(32):19
1011101011101101111101 1011101011101101010011 10011
字串:io98 	Idx(16):4 	Bit值:1100010110001011110100 - 10000 		Idx(32):20
1100010110001011000101 1100010110001011110100 10100
字串:nmhg 	Idx(16):13 	Bit值:1100111010011011001101 - 0 		Idx(32):13
1100111010011011111110 1100111010011011001101 1101
字串:vfg6 	Idx(16):8 	Bit值:1101110010111101101000 - 0 		Idx(32):8
1101110010111101011111 1101110010111101101000 1000
字串:gfrt 	Idx(16):1 	Bit值:1100000101111101010001 - 10000 		Idx(32):17
1100000101111101100001 1100000101111101010001 10001
字串:alpo 	Idx(16):7 	Bit值:1011011011101101000111 - 0 		Idx(32):7
1011011011101101101010 1011011011101101000111 111
字串:vfbh 	Idx(16):1 	Bit值:1101110010111011000001 - 0 		Idx(32):1
1101110010111011110110 1101110010111011000001 1
字串:bnhj 	Idx(16):0 	Bit值:1011100011011001100000 - 0 		Idx(32):0
1011100011011001001110 1011100011011001100000 0
字串:zuio 	Idx(16):8 	Bit值:1110010011100110011000 - 10000 		Idx(32):24
1110010011100110100001 1110010011100110011000 11000
字串:iu8e 	Idx(16):8 	Bit值:1100010111100101101000 - 0 		Idx(32):8
1100010111100101011001 1100010111100101101000 1000
字串:yhjk 	Idx(16):8 	Bit值:1110001001010010101000 - 0 		Idx(32):8
1110001001010010010000 1110001001010010101000 1000
字串:plop 	Idx(16):9 	Bit值:1101001000110011101001 - 0 		Idx(32):9
1101001000110011011101 1101001000110011101001 1001
字串:dd0p 	Idx(16):14 	Bit值:1011101111001011101110 - 0 		Idx(32):14
1011101111001011000000 1011101111001011101110 1110
  • 這裡我們隨機使用一些字串計算他們分別在16位長度和32位長度陣列下的索引分配情況,看哪些資料被重新路由到了新的地址。
  • 同時,這裡還可以觀察?出一個非常重要的資訊,原雜湊值與擴容新增出來的長度16,進行&運算,如果值等於0,則下標位置不變。如果不為0,那麼新的位置則是原來位置上加16。{這個地方需要好好理解下,並看實驗資料}
  • 這樣一來,就不需要在重新計算每一個陣列中元素的雜湊值了。

4.2 資料遷移

bugstack.cn 資料遷移

  • 這張圖就是原16位長度陣列元素,像32位陣列長度中轉移的過程。
  • 其中黃色區域元素zuio因計算結果 hash & oldCap 不為1,則被遷移到下標位置24。
  • 同時還是用重新計算雜湊值的方式驗證了,確實分配到24的位置,因為這是在二進位制計算中補1的過程,所以可以通過上面簡化的方式確定雜湊值的位置。

四、HashMap原始碼解析

1. 插入

1.1 疑問點&考題

通過上一章節的學習:《HashMap核心知識,擾動函式、負載因子、擴容連結串列拆分,深度學習》

大家對於一個雜湊表資料結構的HashMap往裡面插入資料時,基本已經有了一個印象。簡單來說就是通過你的Key值取得雜湊再計算下標,之後把相應的資料存放到裡面。

但再這個過程中會遇到一些問題,比如;

  1. 如果出現雜湊值計算的下標碰撞了怎麼辦?
  2. 如果碰撞了是擴容陣列還是把值存成連結串列結構,讓一個節點有多個值存放呢?
  3. 如果存放的資料的連結串列過長,就失去了雜湊表的效能了,怎麼辦呢?
  4. 如果想解決連結串列過長,什麼時候使用樹結構呢,使用哪種樹呢?

這些疑問點都會在後面的內容中逐步講解,也可以自己思考一下,如果是你來設計,你會怎麼做。

1.2 插入流程和原始碼分析

HashMap插入資料流程圖

公眾號:bugstack蟲洞棧,HashMap插入資料流程圖

visio原版流程圖,可以通過關注公眾號:bugstack蟲洞棧,進行下載

以上就是HashMap中一個資料插入的整體流程,包括了;計算下標、何時擴容、何時連結串列轉紅黑樹等,具體如下;

  1. 首先進行雜湊值的擾動,獲取一個新的雜湊值。(key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);

  2. 判斷tab是否位空或者長度為0,如果是則進行擴容操作。

    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    
  3. 根據雜湊值計算下標,如果對應小標正好沒有存放資料,則直接插入即可否則需要覆蓋。tab[i = (n - 1) & hash])

  4. 判斷tab[i]是否為樹節點,否則向連結串列中插入資料,是則向樹中插入節點。

  5. 如果連結串列中插入節點的時候,連結串列長度大於等於8,則需要把連結串列轉換為紅黑樹。treeifyBin(tab, hash);

  6. 最後所有元素處理完成後,判斷是否超過閾值;threshold,超過則擴容。

  7. treeifyBin,是一個連結串列轉樹的方法,但不是所有的連結串列長度為8後都會轉成樹,還需要判斷存放key值的陣列桶長度是否小於64 MIN_TREEIFY_CAPACITY。如果小於則需要擴容,擴容後連結串列上的資料會被拆分雜湊的相應的桶節點上,也就把連結串列長度縮短了。

JDK1.8 HashMap的put方法原始碼如下:

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    // 初始化桶陣列 table,table 被延遲到插入新資料時再進行初始化
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    // 如果桶中不包含鍵值對節點引用,則將新鍵值對節點的引用存入桶中即可
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {
        Node<K,V> e; K k;
        // 如果鍵的值以及節點 hash 等於連結串列中的第一個鍵值對節點時,則將 e 指向該鍵值對
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
            
        // 如果桶中的引用型別為 TreeNode,則呼叫紅黑樹的插入方法
        else if (p instanceof TreeNode)  
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        else {
            // 對連結串列進行遍歷,並統計連結串列長度
            for (int binCount = 0; ; ++binCount) {
                // 連結串列中不包含要插入的鍵值對節點時,則將該節點接在連結串列的最後
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    // 如果連結串列長度大於或等於樹化閾值,則進行樹化操作
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
                
                // 條件為 true,表示當前連結串列包含要插入的鍵值對,終止遍歷
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        
        // 判斷要插入的鍵值對是否存在 HashMap 中
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            // onlyIfAbsent 表示是否僅在 oldValue 為 null 的情況下更新鍵值對的值
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount;
    // 鍵值對數量超過閾值時,則進行擴容
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

1.3 擴容機制

HashMap是基於陣列+連結串列和紅黑樹實現的,但用於存放key值得的陣列桶的長度是固定的,由初始化決定。

那麼,隨著資料的插入數量增加以及負載因子的作用下,就需要擴容來存放更多的資料。而擴容中有一個非常重要的點,就是jdk1.8中的優化操作,可以不需要再重新計算每一個元素的雜湊值,這在上一章節中已經講到,可以閱讀系列專題文章,機制如下圖;

裡我們主要看下擴容的程式碼(註釋部分);

final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table;
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;
    int newCap, newThr = 0;
    // Cap 是 capacity 的縮寫,容量。如果容量不為空,則說明已經初始化。
    if (oldCap > 0) {
        // 如果容量達到最大1 << 30則不再擴容
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        
        // 按舊容量和閥值的2倍計算新容量和閥值
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // double threshold
    }
    else if (oldThr > 0) // initial capacity was placed in threshold
    
        // initial capacity was placed in threshold 翻譯過來的意思,如下;
        // 初始化時,將 threshold 的值賦值給 newCap,
        // HashMap 使用 threshold 變數暫時儲存 initialCapacity 引數的值
        newCap = oldThr;
    else {               // zero initial threshold signifies using defaults
        // 這一部分也是,原始碼中也有相應的英文註釋
        // 呼叫無參構造方法時,陣列桶陣列容量為預設容量 1 << 4; aka 16
        // 閥值;是預設容量與負載因子的乘積,0.75
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    
    // newThr為0,則使用閥值公式計算容量
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY
                  (int)ft : Integer.MAX_VALUE);
    }
    threshold = newThr;
    
    @SuppressWarnings({"rawtypes","unchecked"})
        // 初始化陣列桶,用於存放key
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;
    if (oldTab != null) {
        // 如果舊陣列桶,oldCap有值,則遍歷將鍵值對映到新陣列桶中
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            if ((e = oldTab[j]) != null) {
                oldTab[j] = null;
                if (e.next == null)
                    newTab[e.hash & (newCap - 1)] = e;
                else if (e instanceof TreeNode)
                    // 這裡split,是紅黑樹拆分操作。在重新對映時操作的。
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                else { // preserve order
                    Node<K,V> loHead = null, loTail = null;
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    // 這裡是連結串列,如果當前是按照連結串列存放的,則將連結串列節點按原順序進行分組{這裡有專門的文章介紹,如何不需要重新計算雜湊值進行拆分《HashMap核心知識,擾動函式、負載因子、擴容連結串列拆分,深度學習》}
                    do {
                        next = e.next;
                        if ((e.hash & oldCap) == 0) {
                            if (loTail == null)
                                loHead = e;
                            else
                                loTail.next = e;
                            loTail = e;
                        }
                        else {
                            if (hiTail == null)
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    
                    // 將分組後的連結串列對映到桶中
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

以上的程式碼稍微有些長,但是整體的邏輯還是蠻清晰的,主要包括;

  1. 擴容時計算出新的newCap、newThr,這是兩個單詞的縮寫,一個是Capacity ,另一個是閥Threshold
  2. newCap用於創新的陣列桶 new Node[newCap];
  3. 隨著擴容後,原來那些因為雜湊碰撞,存放成連結串列和紅黑樹的元素,都需要進行拆分存放到新的位置中。

1.4 連結串列樹化

HashMap這種雜湊表的資料結構,最大的效能在於可以O(1)時間複雜度定位到元素,但因為雜湊碰撞不得已在一個下標裡存放多組資料,那麼jdk1.8之前的設計只是採用連結串列的方式進行存放,如果需要從連結串列中定位到資料時間複雜度就是O(n),連結串列越長效能越差。因為在jdk1.8中把過長的連結串列也就是8個,優化為自平衡的紅黑樹結構,以此讓定位元素的時間複雜度優化近似於O(logn),這樣來提升元素查詢的效率。但也不是完全拋棄連結串列,因為在元素相對不多的情況下,連結串列的插入速度更快,所以綜合考慮下設定閾值為8才進行紅黑樹轉換操作。

連結串列轉紅黑樹,如下圖;

微信公眾號:bugstack蟲洞棧,連結串列轉紅黑樹

以上就是一組連結串列轉換為紅黑樹的情況,元素包括;40、51、62、73、84、95、150、161 這些是經過實際驗證可分配到Idx:12的節點

通過這張圖,基本可以有一個連結串列換行到紅黑樹的印象,接下來閱讀下對應的原始碼。

連結串列樹化原始碼

final void treeifyBin(Node<K,V>[] tab, int hash) {
    int n, index; Node<K,V> e;
    // 這塊就是我們上面提到的,不一定樹化還可能只是擴容。主要桶陣列容量是否小於64 MIN_TREEIFY_CAPACITY 
    if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
        resize();
    else if ((e = tab[index = (n - 1) & hash]) != null) {
    	// 又是單詞縮寫;hd = head (頭部),tl = tile (結尾)
        TreeNode<K,V> hd = null, tl = null;
        do {
            // 將普通節點轉換為樹節點,但此時還不是紅黑樹,也就是說還不一定平衡
            TreeNode<K,V> p = replacementTreeNode(e, null);
            if (tl == null)
                hd = p;
            else {
                p.prev = tl;
                tl.next = p;
            }
            tl = p;
        } while ((e = e.next) != null);
        if ((tab[index] = hd) != null)
            // 轉紅黑樹操作,這裡需要迴圈比較,染色、旋轉。關於紅黑樹,在下一章節詳細講解
            hd.treeify(tab);
    }
}

這一部分連結串列樹化的操作並不複雜,複雜點在於下一層的紅黑樹轉換上,這部分知識點會在後續章節中專門介紹;

以上原始碼主要包括的知識點如下;

  1. 連結串列樹化的條件有兩點;連結串列長度大於等於8、桶容量大於64,否則只是擴容,不會樹化。
  2. 連結串列樹化的過程中是先由連結串列轉換為樹節點,此時的樹可能不是一顆平衡樹。同時在樹轉換過程中會記錄連結串列的順序,tl.next = p,這主要方便後續樹轉連結串列和拆分更方便。
  3. 連結串列轉換成樹完成後,在進行紅黑樹的轉換。先簡單介紹下,紅黑樹的轉換需要染色和旋轉,以及比對大小。在比較元素的大小中,有一個比較有意思的方法,tieBreakOrder加時賽,這主要是因為HashMap沒有像TreeMap那樣本身就有Comparator的實現。

1.5 紅黑樹轉鏈

在連結串列轉紅黑樹中我們重點介紹了一句,在轉換樹的過程中,記錄了原有連結串列的順序。

那麼,這就簡單了,紅黑樹轉連結串列時候,直接把TreeNode轉換為Node即可,原始碼如下;

final Node<K,V> untreeify(HashMap<K,V> map) {
    Node<K,V> hd = null, tl = null;
    // 遍歷TreeNode
    for (Node<K,V> q = this; q != null; q = q.next) {
    	// TreeNode替換Node
        Node<K,V> p = map.replacementNode(q, null);
        if (tl == null)
            hd = p;
        else
            tl.next = p;
        tl = p;
    }
    return hd;
}

// 替換方法
Node<K,V> replacementNode(Node<K,V> p, Node<K,V> next) {
    return new Node<>(p.hash, p.key, p.value, next);
}

因為記錄了連結串列關係,所以替換過程很容易。所以好的資料結構可以讓操作變得更加容易。

2. 查詢

公眾號:bugstack蟲洞棧,HashMap查詢流程圖

上圖就是HashMap查詢的一個流程圖,還是比較簡單的,同時也是高效的。

接下來我們在結合程式碼,來分析這段流程,如下;

public V get(Object key) {
    Node<K,V> e;
    // 同樣需要經過擾動函式計算雜湊值
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}

final Node<K,V> getNode(int hash, Object key) {
    Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
    // 判斷桶陣列的是否為空和長度值
    if ((tab = table) != null && (n = tab.length) > 0 &&
        // 計算下標,雜湊值與陣列長度-1
        (first = tab[(n - 1) & hash]) != null) {
        if (first.hash == hash && // always check first node
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        if ((e = first.next) != null) {
            // TreeNode 節點直接呼叫紅黑樹的查詢方法,時間複雜度O(logn)
            if (first instanceof TreeNode)
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            // 如果是連結串列就依次遍歷查詢
            do {
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;
}

以上查詢的程式碼還是比較簡單的,主要包括以下知識點;

  1. 擾動函式的使用,獲取新的雜湊值,這在上一章節已經講過
  2. 下標的計算,同樣也介紹過 tab[(n - 1) & hash])
  3. 確定了桶陣列下標位置,接下來就是對紅黑樹和連結串列進行查詢和遍歷操作了

3. 刪除

 public V remove(Object key) {
     Node<K,V> e;
     return (e = removeNode(hash(key), key, null, false, true)) == null ?
         null : e.value;
 }
 
final Node<K,V> removeNode(int hash, Object key, Object value,
                           boolean matchValue, boolean movable) {
    Node<K,V>[] tab; Node<K,V> p; int n, index;
    // 定位桶陣列中的下標位置,index = (n - 1) & hash
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (p = tab[index = (n - 1) & hash]) != null) {
        Node<K,V> node = null, e; K k; V v;
        // 如果鍵的值與連結串列第一個節點相等,則將 node 指向該節點
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            node = p;
        else if ((e = p.next) != null) {
            // 樹節點,呼叫紅黑樹的查詢方法,定位節點。
            if (p instanceof TreeNode)
                node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
            else {
                // 遍歷連結串列,找到待刪除節點
                do {
                    if (e.hash == hash &&
                        ((k = e.key) == key ||
                         (key != null && key.equals(k)))) {
                        node = e;
                        break;
                    }
                    p = e;
                } while ((e = e.next) != null);
            }
        }
        
        // 刪除節點,以及紅黑樹需要修復,因為刪除後會破壞平衡性。連結串列的刪除更加簡單。
        if (node != null && (!matchValue || (v = node.value) == value ||
                             (value != null && value.equals(v)))) {
            if (node instanceof TreeNode)
                ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
            else if (node == p)
                tab[index] = node.next;
            else
                p.next = node.next;
            ++modCount;
            --size;
            afterNodeRemoval(node);
            return node;
        }
    }
    return null;
} 
  • 刪除的操作也比較簡單,這裡面都沒有太多的複雜的邏輯。
  • 另外紅黑樹的操作因為被包裝了,只看使用上也是很容易。

4. 遍歷

4.1 問題點

HashMap中的遍歷也是非常常用的API方法,包括;

KeySet

 for (String key : map.keySet()) {
     System.out.print(key + " ");
 }

EntrySet

 for (HashMap.Entry entry : map.entrySet()) {
     System.out.print(entry + " ");
 }

從方法上以及日常使用都知道,KeySet是遍歷是無序的,但每次使用不同方式遍歷包括keys.iterator(),它們遍歷的結果是固定的。

那麼從實現的角度來看,這些種遍歷都是從雜湊表中的連結串列和紅黑樹獲取集合值,那麼他們有一個什麼固定的規律嗎?

4.2 用程式碼測試

測試的場景和前提;

  1. 這裡我們要設定一個既有紅黑樹又有連結串列結構的資料場景
  2. 為了可以有這樣的資料結構,我們最好把HashMap的初始長度設定為64,避免在連結串列超過8位後擴容,而是直接讓其轉換為紅黑樹。
  3. 找到18個元素,分別放在不同節點(這些資料通過程式計算得來);
    1. 桶陣列02節點:24、46、68
    2. 桶陣列07節點:29
    3. 桶陣列12節點:150、172、194、271、293、370、392、491、590

程式碼測試

@Test
public void test_Iterator() {
    Map<String, String> map = new HashMap<String, String>(64);
    map.put("24", "Idx:2");
    map.put("46", "Idx:2");
    map.put("68", "Idx:2");
    map.put("29", "Idx:7");
    map.put("150", "Idx:12");
    map.put("172", "Idx:12");
    map.put("194", "Idx:12");
    map.put("271", "Idx:12");
    System.out.println("排序01:");
    for (String key : map.keySet()) {
        System.out.print(key + " ");
    }
    
    map.put("293", "Idx:12");
    map.put("370", "Idx:12");
    map.put("392", "Idx:12");
    map.put("491", "Idx:12");
    map.put("590", "Idx:12");
    System.out.println("\n\n排序02:");
    for (String key : map.keySet()) {
        System.out.print(key + " ");
    }    
    
    map.remove("293");
    map.remove("370");
    map.remove("392");
    map.remove("491");
    map.remove("590");
    System.out.println("\n\n排序03:");
    for (String key : map.keySet()) {
        System.out.print(key + " ");
    }
    
}

這段程式碼分別測試了三種場景,如下;

  1. 新增元素,在HashMap還是隻連結串列結構時,輸出測試結果01
  2. 新增元素,在HashMap轉換為紅黑樹時候,輸出測試結果02
  3. 刪除元素,在HashMap轉換為連結串列結構時,輸出測試結果03

4.3 測試結果分析

排序01:
24 46 68 29 150 172 194 271 

排序02:
24 46 68 29 271 150 172 194 293 370 392 491 590 

排序03:
24 46 68 29 172 271 150 194 
Process finished with exit code 0

從map.keySet()測試結果可以看到,如下資訊;

  1. 01情況下,排序定位雜湊值下標和連結串列資訊

公眾號:bugstack蟲洞棧,連結串列結構

  1. 02情況下,因為連結串列轉換為紅黑樹,樹根會移動到陣列頭部。moveRootToFront()方法

公眾號:bugstack蟲洞棧,連結串列樹化

  1. 03情況下,因為刪除了部分元素,紅黑樹退化成連結串列。

公眾號:bugstack蟲洞棧,紅黑樹轉連結串列

五、紅黑樹前身,2-3樹分析

日常的學習和一部分夥伴的面試中,竟然會聽?到的是;從HashMap中文紅黑樹、從資料庫索引為B+Tree,但問2-3樹的情況就不是很多了。

1. 為什麼使用樹結構

從最根本的原因來看,使用樹結構就是為了提升整體的效率;插入、刪除、查詢(索引),尤其是索引操作。因為相比於連結串列,一個平衡樹的索引時間複雜度是O(logn),而陣列的索引時間複雜度是O(n)。

從以下的圖上可以對比,兩者的索引耗時情況;

公眾號:bugstack蟲洞棧 & 連結串列與二叉搜尋樹(Binary Search Tree)時間複雜度對比

  • 從上圖可以看到,使用樹結構有效的降低時間複雜度,提升資料索引效率。
  • 另外這個標準的樹結構,是二叉搜尋樹(Binary Search Tree)。除此之外樹形結構還有;AVL樹、紅黑樹、2-3樹等

2. 二叉搜尋樹退化連結串列

在樹的資料結構中,最先有點是二叉查詢樹,也就是英文縮寫BST樹。在使用資料插入的過程中,理想情況下它是一個平衡的二叉樹,但實際上可能會出現二叉樹都一邊倒,讓二叉樹像列表一樣的資料結構。從而樹形結構的時間複雜度也從O(logn)升級到O(n),如下圖;

公眾號:bugstack蟲洞棧 & 二叉搜尋樹退化連結串列

  • 二叉搜尋樹的資料插入過程是,插入節點與當前樹節點做比對,小於在左,大於在右。
  • 隨著資料的插入順序不同,就會出現完全不同的資料結構。可能是一棵平衡二叉樹,也極有可能退化成連結串列的樹。
  • 當樹結構退化成連結串列以後,整個樹索引的效能也跟著退化成連結串列。

綜上呢,如果我們希望在插入資料後又保持樹的特點,O(logn)的索引效能,那麼就需要在插入時進行節點的調整

3. 2-3樹解決平衡問題

2-3樹是什麼結構,它怎麼解決平衡問題的。帶著問題我們繼續?。

2-3樹是一種非常巧妙的結構,在保持樹結構的基礎上,它允許在一個節點中可以有兩個元素,等元素數量等於3個時候再進行調整。通過這種方式呢,來保證整個二叉搜尋樹的平衡性。

這樣說可能還沒有感覺,來看下圖;

公眾號:bugstack蟲洞棧 & 2-3樹解決平穩問題

  • 左側是二叉搜尋樹,右側是2-3平衡樹,分別插入節點4、5,觀察樹形結構變化。
  • 二叉搜尋樹開始出現偏移,節點一遍倒。
  • 2-3樹通過一個節點中存放2到3個元素,來調整樹形結構,保持平衡。所謂的保持平衡就是從根節點,到每一個最底部的自己點,鏈路長度一致。

2-3樹已經可以解決平衡問題那麼,資料是怎麼存放和調整的呢,接下來我們開始實踐使用。

六、紅黑樹前身,2-3樹使用

1. 樹結構定義和特點性質

2-3樹,讀法;二三樹,特性如下;

序號 描述 示意圖
1 2-,1個資料節點2個樹杈
2 3-,2個資料節點3個樹杈
3 三叉與兩叉的不同點在於,除了兩邊的節點,中介軟體還有一個節點。這個節點是介於2、4之間的值。
4 當隨著插入資料,會出現臨時的一個節點中,有三個元素。這時會被調整成一個二叉樹。

綜上我們可以總結出,2-3樹的一些性質;

  1. 2-3樹所有子葉節點都在同一層
  2. 1個節點可以有1到2個資料,如果有三個需要調整樹結構
  3. 1個節點1個資料時,則有兩個子節點
  4. 1個節點2個資料時,則有三個子節點,且中間子節點是介於兩個節點間的值

2. 資料插入

接下來我們就模擬在二叉搜尋樹中退化成連結串列的資料,插入到2-3樹的變化過程,資料包括;1、2、3、4、5、6、7,插入過程圖如下;

公眾號:bugstack蟲洞棧 & 資料插入過程圖

以上,就是整個資料在插入過程中,2-3樹的演化過程,接下來我們具體講解每一步的變化;

  • α,向節點1插入資料2,此時為了保持平衡,不會新產生分支,只會在一個節點中存放兩個節點。
  • β,繼續插入資料3,此時這個節點有三資料,1、2、3,是一個臨時區域。
  • γ,把三個資料的節點,中間節點拉起來,調整成樹形結構。
  • δ,繼續插入資料4,為了保持樹平衡,會插在節點3的右側。
  • ε,繼續插入資料5,插入後3、4、5共用1個節點,當一個節點上有三個資料時候,則需要進行調整。
  • ζ,中間節點4向上⏫調整,調整後,1節點在左、3節點在中間、5節點在右。
  • η ,繼續插入資料6,在保持樹平衡的情況下,與節點5公用。
  • θ ,繼續插入資料7,插入後,節點7會與當前的節點 5 6 共用。此時是一個臨時存放,需要調整。初步調整後,抽出6節點,向上存放,變為2 4 6共用一個節點,這是一個臨時狀態,還需要繼續調整。
  • ι,因為根節點有三個資料2、4、6,則繼續需要把中間節點上移,1、35、7 則分別成二叉落到節點2節點6上。

??希臘字母:α(阿爾法)、 β(貝塔)、γ(伽馬)、δ(德爾塔)、ε(伊普西隆)、ζ(截塔)、η(艾塔)、θ(西塔)、ι(約塔)

3. 資料刪除

有了上面資料插入的學習,在看資料刪除其實就是一個逆向的過程,在刪除的主要包括這樣兩種情況;

  1. 刪除了3-節點,也就是包含兩個資料元素的節點,直接刪除即可,不會破壞樹平衡。
  2. 刪除了2-節點,此時會破壞樹平衡,需要將樹高縮短或者元素合併,恢復樹平衡。

承接上面?的例子,我們把資料再從7、6、5、4、3、2、1順序刪除,觀察2-3樹的結構變化,如下;

公眾號:bugstack蟲洞棧 & 資料刪除過程圖

  • α,刪除節點7,因為節點7只有一個資料元素,刪除節點5、6合併,但此時破壞了2-3樹的平衡性,需要縮短樹高進行調整。
  • β,因為刪除節點後,整個樹結構不平衡,所以需要縮短樹高,調整元素。節點2、4合併,節點1、3分別插入左側和中間。
  • γ,刪除節點6,這個節點是3-節點(可以分出3個叉的意思),刪除後不會破壞樹平衡,保持不變。
  • δ,刪除節點5,此時會破壞樹平衡,需要把跟節點4下放,與3合併。
  • ε,刪除節點4,這個節點依舊是3-節點,所以不需要改變樹結構。
  • ζ,刪除節點3,此時只有1、2節點,需要合併。
  • η ,刪除節點2,此時節點依舊是3-節點,所以不需要改變樹結構。

再看一個稍微複雜點2-3樹刪除:

公眾號:bugstack蟲洞棧 & 複雜樹刪除過程

上面?這張圖,就一個稍微複雜點的2-3平衡樹,樹的刪除過程主要包括;

  1. 刪除4,其實需要將節點3、5合併,指向節點2,保持樹平衡。
  2. 刪除7,節點8、9合併。
  3. 刪除14,節點15上移,恢復成3-叉樹。

?如果有時候不好理解刪除,可以試想下,這個要刪除的節點,在插入的時候是一個什麼效果。

4. 資料索引

相比於插入和刪除,索引的過程還是比較簡單的,不需要調整資料結果。基本原則就是;

  1. 小於當前節點值,左側尋找
  2. 大於當前節點值,右側尋找
  3. 一直到找到索引值,停止。

?第一層尋找:

?第二層尋找:

?第三次尋找:

七、紅黑樹解析

紅黑樹,是一種高效的自平衡二叉查詢樹

Rudolf Bayer 於1978年發明紅黑樹,在當時被稱為對稱二叉 B 樹(symmetric binary B-trees)。後來,在1978年被 Leo J. Guibas 和 Robert Sedgewick 修改為如今的紅黑樹

紅黑樹具有良好的效率,它可在近似O(logN) 時間複雜度下完成插入、刪除、查詢等操作,因此紅黑樹在業界也被廣泛應用,比如 Java 中的 TreeMap,JDK 1.8 中的 HashMap、C++ STL 中的 map 均是基於紅黑樹結構實現的。

死記硬背,很難學會

紅黑樹的結構和設計都非常優秀,也同樣在實現上有著複雜的處理邏輯,包括插入或者刪除節點時;顏色變化、旋轉操作等操作。但如果只把這些知識點硬背下來,什麼時候染色、什麼時候旋轉,是沒有多大意義的,用不了多久也就忘記了。所以這部分的學習,瞭解其根本更重要。

1、2-3樹與紅黑樹的等價性

在上一章節《講解2-3平衡樹「紅黑樹的前身」》,使用了大量圖例講解了2-3樹,並在標題處寫出它是紅黑樹的前身。閱讀後更容易理解紅黑樹相關知識。

紅黑樹規則

1. 根節點是黑色
2. 節點是紅黑或者黑色
3. 所有子葉節點都是黑色(葉子是NIL節點,預設沒有畫出來)
4. 每個紅色節點必須有兩個黑色子節點(也同樣說明一條鏈路上不能有鏈路的紅色節點)
5. 黑高,從任一節點到齊每個葉子節點,經過的路徑都包含相同數目的黑色節點

那麼,這些規則是怎麼總結定義出來的呢?接下里我們一步步分析講解。

1. 為什麼既有2-3樹要有紅黑樹

首先2-3樹(讀法:二三樹)就是一個節點有1個或者2個元素,而實際上2-3樹轉紅黑樹是由概念模型2-3-4樹轉換而來的。-4叉就是一個節點裡有3個元素,這在2-3樹中會被調整,但是在概念模型中是會被保留的。

雖然2-3-4樹也是具備2-3樹同樣的平衡樹的特性,但是如果直接把這樣的模型用程式碼實現就會很麻煩,且效率不高,這裡的複雜點包括;

  1. 2-叉、3-叉、4-叉,三種結構的節點型別,互相轉換複雜度較高
  2. 3-叉、4-叉,節點在資料比較上需要進行多次,不像2-叉節點,直接布林型別比較即可非左即右
  3. 程式碼實現上對每種差異,都需要有額外的程式碼,規則不夠標準化

所以,希望找到一種平衡關係,既保持2-3樹平衡和O(logn)的特性,又能在程式碼實現上更加方便,那麼就誕生了紅黑樹。

2. 簡單2-3樹轉紅黑樹

2-3樹轉紅黑樹,也可以說紅黑樹是2-3樹2-3-4樹的另外一種表現形式,也就是更利於編碼實現的形式。

簡單轉換示例;

2-叉、3-叉、4-叉,轉換紅黑樹示意圖

從上圖可以看出,2-3-4樹與紅黑樹的轉換關係,包括;

  1. 2-叉節點,轉換比較簡單,只是把原有節點轉換為黑色節點
  2. 3-叉節點,包括了2個元素,先用紅色線把兩個節點相連,之後拆分出來,最後調整高度黑色節點在上
  3. 4-叉節點,包括了3個元素,分別用紅黑線連線,之後拆分出來拉昇高度。這個拉昇過程和2-3樹調整一致,只是新增了顏色

綜上,就是2-3-4樹的節點轉換,總結出來的規則,如下;

  1. 將2-3-4樹,用二叉樹的形式表示
  2. 3-叉、4-叉節點,使用紅色、黑色連線進行連線
  3. 另外,3-叉節點有兩種情況,導致轉換成二叉樹,就有左傾和右傾

3. 複雜2-3樹轉紅黑樹

簡單2-3樹轉換紅黑樹的過程中,瞭解到一個基本的轉換規則右旋定義,接下來我們在一個稍微複雜一點的2-3樹與紅黑樹的對應關係,如下圖;

複雜2-3樹轉換紅黑樹

上圖是一個稍微複雜點的2-3樹,轉換為紅黑樹的過程,是不這樣一張圖讓你對紅黑樹更有感覺了,同時它也滿足一下條件;

  1. 從任意節點到葉子節點,所經過的黑色節點數目相同
  2. 黑色節點保持著整體的平衡性,也就是讓整個紅黑樹接近於O(logn)時間複雜度
  3. 其他紅黑樹的特點也都滿足,可以對照紅黑樹的特性進行比對

2、紅黑樹操作

2.1 平衡操作

通過在上一章節2-3樹的學習,在插入節點時並不會插到空位置,而是與現有節點融合以及調整,保持整個樹的平衡。

而紅黑樹是2-3-4樹的一種概念模型轉換而來,在插入節點時通過紅色連結相連,也就是插入紅色節點。插入完成後進行調整,以保持樹接近平衡。

那麼,為了讓紅黑樹達到平衡狀態,主要包括染色、↔左右旋轉、這些做法其實都是從2-3樹演化過來的。接下來我們就分別講解幾種規則的演化過程,以此更好了解紅黑樹的平衡操作。

2.1.1 左旋轉

左旋定義: 把一個向右傾斜的紅節點連結(2-3樹,3-叉雙元素節點),轉化為左連結。

背景:順序插入元素,1、2、3,2-3樹保持平衡,紅黑樹暫時處於右傾斜。

接下來我們分別對比兩種樹結構的平衡操作;

  1. 2-3樹,所有插入的節點都會保持在一個節點上,之後通過調整節點位置,保持平衡。
  2. 紅黑樹,則需要通過節點的左側旋轉,將元素2拉起來,元素1和元素3,分別成為左右子節點。

紅黑樹的左旋,只會處理與之對應的2-3樹節點進行操作,不會整體改變。

2.1.2 右旋轉

右旋定義: 把一個向左傾斜的紅節點連線(2-3樹,3-叉雙元素節點),轉換為右連線。

背景:順序插入元素,3、1、1,2-3樹保持平衡,紅黑樹暫時處於左傾斜。

接下來我們分別對比兩種樹結構的平衡操作;

  1. 2-3樹,所有插入的節點都會保持在一個節點上,之後通過調整節點位置,保持平衡。
  2. 紅黑樹,則需要通過節點的右側旋轉,將元素2拉起來,元素1和元素3,分別成為左右子節點。

你會發現,左旋與右旋是相互對應的,但在2-3樹中是保持不變的

2.1.3 左右旋綜合運用

左旋、右旋,我們已經有了一個基本的概念,那麼接下來我們再看一個可以綜合左右旋以及對應2-3樹的演化案例,如下;

以上的例子分別演示了一個元素插入的三種情況,如下;

  1. 1、3,插入0,左側底部插入,與2-3樹相比,需要右旋保持平衡
  2. 1、3,插入2,中間位置插入,首先進行左旋調整元素位置,之後進行右旋進行樹平衡
  3. 1、3,插入5,右側位置插入,此時正好保持樹平衡,不需要調整
2.1.4 染色

在2-3樹中,插入一個節點,為了保持樹平衡是不插入到空位置上的,當插入節點後元素數量有3個後則需要調整中間元素向上,來保持樹平衡。與之對應的紅黑樹則需要調整顏色,來保證紅黑樹的平衡規則,具體參考如下;

2.2 旋轉+染色運用案例

接下來我們把上面講解到的旋轉染色,運用到一個實際案例中,如下圖;

  • 首先從左側開始,是一個按照順序插入生產出來的紅黑樹,插入順序;7、2、8、1、4、3、5
  • α,向目前紅黑樹插入元素6,插入後右下角有三個紅色節點;3、5、6
  • β,因為右下角滿足染色條件,變換後;黑色節點(3、5)、紅色節點(4、6)。
  • γ,之後看被紅色連線連結的節點7、4、2,最小節點在中間,左旋平衡樹結構。
  • δ,左旋完成後,紅色連結線的7、4、2為做傾順序節點,因此需要做右旋操作。
  • ε,左旋、右旋,調整完成後,又滿足了染色操作。到此恢復紅黑樹平衡。

注意,所有連線紅色節點的,都是是紅色線。以此與2-3樹做對應。

2.3. 刪除操作

根據2-3-4樹模型的紅黑樹,在刪除的時候基本是按照2-3方式進行刪除,只不過在這個過程中需要染色和旋轉操作,以保持樹平衡。刪除過程主要可以分為如圖四種情況,如下;

2.3.1 刪除子葉紅色節點

紅色子葉節點的刪除並不會破壞樹平衡,也不影響樹高,所以直接刪除即可,如下;

2.3.2 刪除左側節點
2.3.2.1 被刪節點兄弟為黑色&含右子節點

2.3.2.2 被刪節點兄弟為黑色&含左子節點

2.3.2.3 被刪節點兄弟為黑色&含雙子節點(紅)

2.3.2.4 被刪節點兄弟為黑色&不含子節點

2.3.2.5 被刪節點兄弟為黑色&含雙黑節點(黑)

2.3.3 刪除右側節點
2.3.3.1 被刪節點兄弟為黑色&含左子節點

2.3.3.2 被刪節點兄弟為黑色&含右子節點

2.3.3.3 被刪節點兄弟為黑色&含雙子節點(紅)

2.3.2.4 被刪節點兄弟為黑色&不含子節點

2.3.2.5 被刪節點兄弟為黑色&含雙黑節點(黑)

八、總結

  • HashMap的資料結構設計的非常優秀,同時也非常複雜,涉及的知識點眾多。作為高階開發的程式設計師雖然平時開發不需要實現這樣的功能,但是這裡的設計思想非常值得學習。
  • 這裡的知識點基本包括了;Hash值的設計,HashMap中,1、雜湊表實現2、擾動函式3、初始化容量4、負載因子5、擴容元素拆分得演算法的制定以及2-3-4樹到紅黑樹的轉換,都非常值得深入學習。
  • 面試只是一份新工作的開發,就像比武招親一樣,你總要拿出自己最優秀的實力,如果當前能力還不足,那麼就可以繼續深入學習。

九、系列文章

相關文章