圖解leetcode5-10 | 和233醬一起刷leetcode系列(2)

本周我們繼續來看5道磨人的小妖精,圖解leetcode6-10~

多說一句,leetcode10 殺死了233醬不少腦細胞…

另:

沉迷算法,無法自拔。快來加入我們吧!

別忘了233醬的一條龍服務:

公眾號文章題解 -> 私信答疑 -> 刷題群答疑 -> 視頻講解

我們的目的是成為套路王~

嘿嘿,廣告完畢 , Let’s go!

leetcode6: Z 字形變換

題目描述:

將一個給定字符串根據給定的行數,以從上往下、從左到右進行 Z 字形排列。

題目示例:

輸入: s = "LEETCODEISHIRING", numRows = 4
輸出: "LDREOEIIECIHNTSG"

解釋:

L     D     R
E   O E   I I
E C   I H   N
T     S     G

解題思路:

相信小夥伴看到這道題目,也和233一樣覺得Z字形排列的字符串冥冥中有些規律。為了方便解釋 ,我們假設輸入:

字符串s=”0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15″
numRows=4
注意: s中的輸入字符依次為:為0-15,中間的空格是我為了展示清楚額外加的。

那麼s的Z字形排列如下:

需要輸出的結果是:“0 6 12 15 7 11 13 2 4 8 10 14 3 9 15”

假設我們將Z字形排列后的字符串每一行i 用一個數組arr[i]存起來,最後按行數i的順序輸出arr[i]中的值,那麼就可以得到最終的輸出結果。

如何知道字符串s中的各個字符在哪個arr數組的哪個索引位置呢?這就是我們用数字字符的字符串來舉例子的好處了,因為数字的值就對應着字符在字符串s中的下標。當我們遍歷字符串s時,是我們可以用pointer表示當前遍歷的字符所對應的行數i,代表這個字符是要放到arr[i]中的。

我們可以發現每當遍歷numRows=4 個字符,pointer就從 0->3 轉化為 3->0。所以我們可以用一個flag記錄pointer的變化量。

思路有了,我們來看一下時間空間複雜度:

  • 時間複雜度:遍歷一遍字符串s: O(n)。
  • 空間複雜度:數組arr的存儲:O(n)。

可以寫出代碼嗎:)

Java版本

class Solution {
    public String convert(String s, int numRows) {
        if(numRows <= 1){
            return s;
        }
        List<StringBuilder> arr = new ArrayList<>();
        for(int i = 0 ;i< numRows;i++){
            arr.add(new StringBuilder());
        }
        int flag = -1;
        int pointer = 0;
        for(int i =0;i<s.length();i++){
           char ch = s.charAt(i);
           arr.get(pointer).append(ch);
           if(pointer == 0 || pointer == numRows -1) flag = - flag;
            pointer += flag;
            
        }
        StringBuilder res = new StringBuilder();
        for(StringBuilder row : arr) res.append(row);
        return res.toString();
    }
}

leetcode7: 整數反轉

題目描述:

給出一個 32 位的有符號整數,你需要將這個整數中每位上的数字進行反轉。

題目示例:

輸入: 123
輸出: 321

輸入: -123
輸出: -321

輸入: 120
輸出: 21

注意:
假設我們的環境只能存儲得下 32 位的有符號整數,則其數值範圍為 [−231,  231 − 1]。請根據這個假設,如果反轉后整數溢出那麼就返回 0。

解題思路:
這道題考的還是 數學運算

Step1:需要分別取出十進制数字的個位,十位,百位..一直到最高位的数字。

阿姨來教你小學數學的除法運算:

所以當我們 取余再取模 就可以得到高位的数字。

Step2:將取出來的個位,十位,百位..一直到最高位的数字 依次放到 最高位,…,百位,十位,個位。

阿姨來教你小學數學的乘法運算:

至於示例中列舉的幾個邊界條件,Java中的整數是帶有符號的。剛好符合我們的乘除運算。

另外,需要判斷乘法計算時正負数字的越界問題。當然如果res用long表示,也就不需要考慮這個問題了。代碼如下:

Java版本

class Solution {
    public int reverse(int x) {
        int res = 0;
        while(x!=0){
            if(x>0 && res > ((Integer.MAX_VALUE-x%10)/10)) return 0;
            if(x<0 && res < ((Integer.MIN_VALUE-x%10)/10)) return 0;
            res = res*10 + x%10;
            x/=10;
        }
        return res;
    }
}

leetcode8: 字符串轉換整數(atoi)

題目描述:

請你來實現一個 atoi 函數,使其能將字符串轉換成整數。

首先,該函數會根據需要丟棄無用的開頭空格字符,直到尋找到第一個非空格的字符為止。接下來的轉化規則如下:

如果第一個非空字符為正或者負號時,則將該符號與之後面盡可能多的連續数字字符組合起來,形成一個有符號整數。
假如第一個非空字符是数字,則直接將其與之後連續的数字字符組合起來,形成一個整數。
該字符串在有效的整數部分之後也可能會存在多餘的字符,那麼這些字符可以被忽略,它們對函數不應該造成影響。
注意:假如該字符串中的第一個非空格字符不是一個有效整数字符、字符串為空或字符串僅包含空白字符時,則你的函數不需要進行轉換,即無法進行有效轉換。

在任何情況下,若函數不能進行有效的轉換時,請返回 0 。

提示:

本題中的空白字符只包括空格字符 ‘ ‘ 。
假設我們的環境只能存儲 32 位大小的有符號整數,那麼其數值範圍為 [−231,  231 − 1]。如果數值超過這個範圍,請返回  INT_MAX (231 − 1) 或 INT_MIN (−231) 。

題目示例:

示例 1:
輸入: "42"
輸出: 42

示例 2:
輸入: "   -42"
輸出: -42
解釋: 第一個非空白字符為 '-', 它是一個負號。
     我們盡可能將負號與後面所有連續出現的数字組合起來,最後得到 -42 。

示例 3:
輸入: "4193 with words"
輸出: 4193
解釋: 轉換截止於数字 '3' ,因為它的下一個字符不為数字。

示例 4:
輸入: "words and 987"
輸出: 0
解釋: 第一個非空字符是 'w', 但它不是数字或正、負號。
     因此無法執行有效的轉換。

示例 5:
輸入: "-91283472332"
輸出: -2147483648
解釋: 数字 "-91283472332" 超過 32 位有符號整數範圍。 
     因此返回 INT_MIN (−231) 。

解題思路:
放這麼多 題目示例 阿姨並不是為了湊字數,而是這類問題就是屬於考邊界情況的問題,邊界情況拎清了,就不會被磨到了~

假設輸入一個字符串 ” -4193 with words” , 我們可以從左到右遍歷這個字符串,用k 表示當前遍歷到的字符:

另外,我們還需要注意 示例5的情況,當乘法計算時的值超過INT_MAX or INT_MIN時,結束並返回 INT_MAX or INT_MIN.

Java版本

class Solution {
    public int myAtoi(String str) {
        int res = 0;
        int k = 0;

        while(k< str.length() &&  ' ' == str.charAt(k))k++;
        int minus = 1;
        if(str.length() == k) return res;
        if('-' == str.charAt(k)) {
            minus = -1;
            k++;
        }else if('+' == str.charAt(k)){
            k++;
        }

        while(k<str.length() && str.charAt(k) >= '0' && str.charAt(k) <='9'){
            int x = str.charAt(k) - '0';
            if(minus >0 && res> (Integer.MAX_VALUE - x)/ 10){
                return Integer.MAX_VALUE;
            }
            //-res * 10 - str.charAt(k) < Integer.MIN_VALUE
            if(minus <0 && -res < (Integer.MIN_VALUE + x)/10) 
                return Integer.MIN_VALUE;
            //最大的負數是存不下來的
            if((-res * 10 - x) == Integer.MIN_VALUE ) {
                return Integer.MIN_VALUE;
            }
            res = res* 10 + x;
            k++;
        }
        res *= minus;
        return res;

    }
}

leetcode9: 迴文數

題目描述:

判斷一個整數是否是迴文數。迴文數是指正序(從左向右)和倒序(從右向左)讀都是一樣的整數。

題目示例:

示例 1:

輸入: 121
輸出: true
示例 2:

輸入: -121
輸出: false
解釋: 從左向右讀, 為 -121 。 從右向左讀, 為 121- 。因此它不是一個迴文數。
示例 3:

輸入: 10
輸出: false
解釋: 從右向左讀, 為 01 。因此它不是一個迴文數。

解題思路:

上篇文章中我們講過最長迴文子串的查找。再來看這道題就很easy了。這道題的解法也很多:
比如我們可以把它變為字符串。然後reverse一下,判斷前後兩個字符串是否相等。

但是我們用一種更簡單的方式,只需要反轉整數,然後判斷兩個整數是否相等,就可以確定是不是迴文整數。又回到leetcode7了,有沒有覺得阿姨的乘除法運算還是有幫助的:)

Java版本

class Solution {
    public boolean isPalindrome(int x) {
        
        if(x<0) return false;
        if(x<=9) return true;
        int oringin = x;
        int res = 0;
        while(x>0){
            //如果越界了說明不對稱
            res = res*10 + x%10;
            x/=10;
        }
        return oringin == res;
    }
}

leetcode10: 正則表達式匹配

題目描述:

給你一個字符串 s 和一個字符規律 p,請你來實現一個支持 ‘.’ 和 ‘*’ 的正則表達式匹配。

‘.’ 匹配任意單個字符
‘*’ 匹配零個或多個前面的那一個元素
所謂匹配,是要涵蓋 整個 字符串 s的,而不是部分字符串。

說明:

  • s 可能為空,且只包含從 a-z 的小寫字母。
  • p 可能為空,且只包含從 a-z 的小寫字母,以及字符 . 和 *。

題目示例:

示例 1:
輸入:
s = "aa"
p = "a*"
輸出: true
解釋: 因為 '*' 代表可以匹配零個或多個前面的那一個元素, 在這裏前面的元素就是 'a'。因此,字符串 "aa" 可被視為 'a' 重複了一次。

示例 2:
輸入:
s = "ab"
p = ".*"
輸出: true
解釋: ".*" 表示可匹配零個或多個('*')任意字符('.')。

神奇的.*來了,Hard模式,大家坐好~

判斷 字符串s 是否與 一個 可能還有“.” or “*” 的字符規律 p 匹配,其實就是從 p 代表的所有的字符串中枚舉出一個 匹配值。 簡單暴力枚舉的時間複雜度是指數級的。我們需要考慮對於求解一個最優解 或 匹配解的類似問題,有哪些可以降低時間複雜度的方案?

好了,不饒彎子了,動態規劃 要來了。

溫馨後記:寫着寫着就列舉了一堆動態規劃的理論,比較了解的朋友可以直接翻過這段看後面這一題的圖解。

解題之前,我們先了解下:動態規劃是什麼?為什麼動態規劃能降低時間複雜度?什麼類型的問題又能用動態規劃去解決?如何構造解題步驟?

動態規劃是什麼

動態規劃與分治方法相似,都是通過組合子問題的解來求解原問題。

分治算法將問題劃分為互不相交的子問題,遞歸地求解子問題,再將他們的解組合起來,求出原問題的解。如歸併排序,劃分的左右排序子問題是對不同的数字序列進行排序的,最後再把他們合併起來。

動態規劃應用於子問題重疊的情況,即不同的子問題具有公共的子子問題。這種情況下分治算法需要對子子問題反覆求解,而動態規劃算法只對子子問題求解一次,將其結果保存到備忘錄中 or 按照 自底向下 的順序 求解每個子問題(也就是保證在求解子問題時,它所依賴的子子問題的解已經求出來了)這兩種方式,避免不必要的計算工作,降低時間複雜度。

舉一個簡單的斐波那契數列的例子:

斐波那契數列指的是這樣一個數列:
1、1、2、3、5、8…

相信小夥伴們都知道,它的遞推規律是:

假設求f(10),則遞推公式展開為:

可以看到其中有大量的重複子問題:f(6),f(5) 等。

動態規劃的兩種做法就是:
1.用 遞歸的代碼求解時,將第一次計算的f(6)保存起來,如f(8)中的f(6). 這樣再求解f(7)中的f(6)就可以直接獲取到結果了
2.按照求f(3), ->(4)->…->f(10)的自底向下的順序求解,這樣再求 f(8)時,只需要保存下來 f(7) 和 f(6)的值,就可以求出了,f(10)同理。這種方式大多是循環的寫法。

動態規劃解決的問題類型

初步明白后,我們再來看下動態規劃解決問題的類型:

極客時間的王爭大佬 概括為: 一個模型,三個特徵

一個模型:多階段決策最優解模型
我們一般是用動態規劃來解決最優問題。而解決問題的過程,需要經歷多個決策階段。每個決策階段都對應着一組狀態。然後我們尋找一組決策序列,經過這組決策序列,能夠產生最終期望求解的最優值。
特徵1:最優子結構

指的是,問題的最優解包含子問題的最優解。反過來說就是,我們可以通過子問題的最優解,推導出問題的最優解。如果我們把最優子結構,對應到我們前面定義的動態規劃問題模型上,那我們也可以理解為,後面階段的狀態可以通過前面階段的狀態推導出來。

特徵2:無後效性

無後效性有兩層含義,第一層含義是,在推導後面階段的狀態的時候,我們只關心前面階段的狀態值,不關心這個狀態是怎麼一步一步推導出來的。第二層含義是,某階段狀態一旦確定,就不受之後階段的決策影響。無後效性是一個非常“寬鬆”的要求。只要滿足前面提到的動態規劃問題模型,其實基本上都會滿足無後效性。

特徵3. 重複子問題
這個就是我們前面提到的,不同的決策序列,到達某個相同的階段時,可能會產生重複的狀態。

動態規劃的解題步驟

Step1.刻畫一個最優解的結構特徵
也就是能夠把問題抽象轉化為一種數學描述,通俗說 就是 狀態的定義。如上述斐波那契數列 中 f(n)就是狀態的定義。

Step2.遞歸地定義最優解的值。
就是問題與子問題之間的遞推表達式是什麼,通俗說 就是 狀態轉移方程的定義。如上述斐波那契數列 中的f(n) = f(n-1) + f(n-2)

Step3.計算最優解的值
就是採用的動態規劃具體計算的做法,包括 遞歸+備忘錄 or 循環+自底向下 求解兩種方式。

Step4.利用計算出的信息構造一個最優解
因為我們步驟一定義的狀態有時並不是我們直接要求的最優解,所以這一步就是利用狀態和狀態轉移方式 表達出我們最終要求的最優解怎麼得到。

我們會根據leetcode10來理解這些理論知識。

解題思路:

Step1.抽象出狀態

這個問題實際求的是字符串s能否從字符規律p代表的所有字符串集合中找出一個匹配值。一般求兩個字符串的匹配問題的狀態用二維的數組來定義,為什麼。。聽大佬說:靠經驗,靠悟。我們定義:
dp[i,j] : 代表 所有 字符串s[0,i-1] (前i個字符) 和 字符規律p[0,j-1] (前j個字符)的匹配方案 集合。
dp[i,j] 的值: 代表是否存在一種方案 使得 字符規律p 匹配 字符串s。這個值就是我們這個問題的解。true:存在。false:不存在。

Step2.遞歸地定義最優解的值。

這一步其實就是求狀態遞推式,找出問題dp[i,j] 和子問題之間的關係。

對於字符串s[i] 和 p[j] 是否匹配,因為p[j] 可能是* or . 。我們需要枚舉出p所代表的所有字符串。我們我們可以從最後的字符 s[i] 和 p[j]來考慮。

可分為p[j] == * or p[j] != * 兩種情況。因為 ‘*’ 代表着0-多個字符,會影響p的枚舉數。’.’ 我們只需要把它當成一個萬能字符就好,’.’ 不會影響p的枚舉數量。

  • p[j] != '*' 時,則 s 與 p 是否匹配 取決於 s[i] 是否等於 p[j] && dp[i][j] 是否為true

  • p[j] == '*' 時,我們需要枚舉* 代表的從0-多個字符的字符序列集合中,s 是否與他們其中之一匹配。

如圖所示,考慮p[j] == '*' 所代表的字符數,我們需要列舉出 組成dp[i+1,j+1] 的所有可能情況,同時我們其實靠yy也能推斷出:
dp[i+1,j+1] 和 它的子問題:dp[i,j+1] 的關係,圖中我也有列舉出公式推導來源。

這裡有一點需要注意: dp[i+1,j+1]才表示s[0,i] 和 p[0,j] 匹配。因為s[0]就代表了第一個字符。而我們也需要表示 s長度為0的dp[0,..]的值。不然會影響到我們遞推公式的求值。

好了,到這裏我們先總結下 這個問題動態規劃解法的狀態和狀態轉移方程:

Step3.計算最優解的值。
這個步驟就是具體計算遞推公式dp[i+1,j+1]的過程了,我們可以採用 循環+ 自底向下的方式來求解,也就是對於二維數組先填第0行的值,再填第0列的值,以此類推。
假設s=”aa”, p=”a*” 。則它的二維填狀態表的順序和結果為:

Step4.利用計算出的信息構造一個最優解

在Step1的時候,我們其實就定義了。 s與p是否匹配 等價於 dp[i+1][j+1] 的值 是否為 true。 所以我們只需要返回 dp[i+1][j+1]的值 就是這道題的結果。

徹底完了,看懂了沒,上代碼吧。

Java版本

class Solution {
    public boolean isMatch(String s, String p) {
        int slen = s.length();
        int plen = p.length();
        //需要分別取出s和p為空的情況,所以dp數組大小+1
        boolean[][] dp = new boolean[slen + 1][plen + 1];
        //初始化dp[0][0]=true,dp[0][1]和dp[1][0]~dp[s.length][0]默認值為false所以不需要顯式初始化
        dp[0][0] = true;
        //填寫第一行dp[0][2]~dp[0][p.length]
        for (int k = 2; k <= plen; k++) {
            //p字符串的第2個字符是否等於'*',此時j元素需要0個,所以s不變p減除兩個字符
            dp[0][k] = p.charAt(k - 1) == '*' && dp[0][k - 2];
        }
        //填寫dp數組剩餘部分
        for (int i = 0; i < slen; i++) {
            for (int j = 0; j < plen; j++) {
                //p第j個字符是否為*
                if (p.charAt(j) == '*') {
                    //兩種情況:1.s不變[i+1],p移除兩個元素[j+1-2]。
                    // 2.比較s的i元素和p的j-1(因為此時j元素為*)元素,相等則移除首元素[i+1-1],p不變。
                    dp[i + 1][j + 1] = dp[i + 1][j - 1] ||
                            (dp[i][j + 1] && headMatched(s, p, i, j - 1));
                } else {
                    //s的i元素和p的j元素是否相等,相等則移除s的i元素[i+1-1]和p的j元素[j+1-1]
                    dp[i + 1][j + 1] = dp[i][j] && headMatched(s, p, i, j);
                }
            }
        }
        return dp[slen][plen];
    }

    //判斷s第i個字符和p第j個字符是否匹配
    public boolean headMatched(String s, String p, int i, int j) {
        return s.charAt(i) == p.charAt(j) || p.charAt(j) == '.';
    }

}

能看到這裏看來是真愛了,233醬都要對你豎起大拇指,要不要也在看,轉發 對233醬豎起大拇指 …… ^ _ ^。不管對文章是否有疑問,都歡迎可愛的你加入我們的刷題群,有疑問233醬會在群里答疑哦~

參考資料:
[1].《算法導論》
[2].https://time.geekbang.org/column/article/75702

本站聲明:網站內容來源於博客園,如有侵權,請聯繫我們,我們將及時處理

【其他文章推薦】

※廣告預算用在刀口上,台北網頁設計公司幫您達到更多曝光效益

新北清潔公司,居家、辦公、裝潢細清專業服務

※別再煩惱如何寫文案,掌握八大原則!

※教你寫出一流的銷售文案?

※超省錢租車方案

FB行銷專家,教你從零開始的技巧

深入理解JVM(③)虛擬機性能監控、故障處理工具

前言

JDK的bin目錄中有一系列的小工具,除了java.exe、javac.exe這兩個編譯和運行Java程序外,還有打包、部署、簽名、調試、監控、運維等各種場景都會用到這些小工具。

這些工具根據軟件可用性和授權的不同,可以把它們劃分為三類:

  • 商業授權工具: 主要是JMC(Java Mission Control)及它要使用到的JFR(Java Flight Recorder),JMC在個人開發環境中使用是免費的,但是在商業環境中使用它則是付費的。
  • 正式支持工具: 這一類工具屬於被長期支持的工具,不同平台、不同版本的JDK之間,這類工具可能會略有差異,但是不會出現某一個工具突然消失的情況。
  • 實驗性工具: 這一類工具在它們的使用說明中被聲明為“沒有技術支持,並且是實驗性質的”(Unsupported and Experimental)產品,日後可能會轉載,也可能會在某個JDK版本中國無聲無息地消失。

jps:虛擬機進程狀態工具

JDK的一些小工具都參考了UNIX的命名方式,jps(JVM Process Status Tool)是其中的典型。
功能也是和UNIX的ps的命令類似:
可以列出正在運行的虛擬機進程,並显示虛擬機執行主類(Main Class,main()函數所在的類)名稱以及這些進程的本地虛擬機唯一ID(LVMID,Local Virtual Machine Identifier)。
jps命令格式:

jps [ options ]  [ hostid ]

jps工具主要選項:

jstat:虛擬機統計信息監視工具

jstat( JVM Statistics Monitoring Tool )是用戶監視虛擬機各種運行狀態信息的命令行工具。可以显示本地虛擬機進程中 類加載、內存、垃圾收集、即時編譯等運行時數據,這個命令是在服務器是哪個運行期定位虛擬機性能問題的常用工具。
jstat 命令格式為:

jstat [ option  vmid [ interval [ s | ms ] [ count ] ] ]

參數interval 和 count 代表查詢間隔和次數,如果省略這2個參數,說明只查詢一次假設需要每250毫秒查詢一次進程 1440 垃圾收集狀況,一共查詢20次,那命令應當是:

jstat -gc 1440 250 20 

option 代表用戶希望查詢的虛擬機信息,主要分三類:
類加載、垃圾收集、運行期間編譯狀況。
jstat工具主要選項

jinfo:Java配置信息工具

jinfo(Configuration Info for Java)的作用是實時查看和調整虛擬機各項參數。使用jps命令的-v參數可以查看虛擬機啟動時显示指定的參數列表,但如果想知道未被显示指定的參數的系統默認值,除了去找資料外,就只能使用jinfo的-flag選項進行查詢了。jinfo還可以使用-sysprops選項把虛擬機進程的

System.getProperties()

的內容打出來。
jinfo 命令格式:

jinfo [ option ] pid

jmap:Java內存映像工具

jmap (Memory Map for Java)命令用於生成堆轉儲快照(一般稱為heapdump 或 dump文件)。
jmap的作用並不僅僅是為了獲取堆轉儲快照,它還可以查詢finalize執行隊列、Java堆和方法區的詳細信息,如空間使用率、當前用的是哪種收集器等。
jmap 命令格式:

jmap [ option ] vmid

jmap工具主要選項

jhat:虛擬機堆轉儲快照分析工具

JDK提供jhat(JVM Heap Analysis Tool)命令與jmap搭配使用,來分析jmap生成的堆轉儲快照。jhat內置了一個微型的HTTP/Web服務器,生成堆轉儲快照的分析結果后,可以在瀏覽器中查看。但是一般在實際工作中,都不會直接使用jhat命令來分析堆轉儲快照文件,一是因為分析工作耗時而且極為耗費資源,一般不會直接在服務器上使用,而是在其他機器上進行分析。二是jhat的分析功能比較簡陋,不如VisualVM,以及一些專業的分析工具例如:Eclipse Memory Analyzer、IBM HeapAnalyzer。

jstack:Java堆棧跟蹤工具

jstack(Stack Trace for Java)命令用於生成虛擬機當前時刻的線程快照(一般稱為threaddump或者javacore文件)。
線程快照就是當前虛擬機內每一條線程正在執行的方法堆棧的集合,生成線程快照的目的通常是定位線程出現長時間停頓的原因,如線程死鎖、死循環、請求外部資源導致長時間掛起等,都是導致線程長時間停頓的常見原因。
jstack命令格式:

jstack [ option ] vmid 

線程出現停頓時通過jstack來查看各個線程的調用堆棧,就可以獲知沒有響應的線程到底在後頭做些什麼事情,或者等待着什麼資源。
jstack工具主要選項

本站聲明:網站內容來源於博客園,如有侵權,請聯繫我們,我們將及時處理

【其他文章推薦】

※超省錢租車方案

※別再煩惱如何寫文案,掌握八大原則!

※回頭車貨運收費標準

※教你寫出一流的銷售文案?

FB行銷專家,教你從零開始的技巧

「從零單排canal 03」 canal源碼分析大綱

在前面兩篇中,我們從基本概念理解了canal是一個什麼項目,能應用於什麼場景,然後通過一個demo體驗,有了基本的體感和認識。

從這一篇開始,我們將從源碼入手,深入學習canal的實現方式。了解canal相關功能的實現方式,其中有很多機制是非常值得深入了解的,從代碼實現角度去學習實時數據訂閱與同步的實現與核心技術點。當然,如果要在生產中使用這個開源項目,了解源碼更是必不可少,是解決問題和新特性定製的前提條件。

本文使用的版本是1.1.4,這也是筆者寫這篇博客時的最新穩定版。

1.準備工作

下載源碼

git clone https://github.com/alibaba/canal.git

切換到1.1.4這個tag

git checkout canal-1.1.4

 

或者可以關注我的源碼註釋版本(正在不斷更新中)
https://github.com/saigu/JavaKnowledgeGraph/tree/master/code_reading/canal

2.canal項目模塊介紹

canal項目是基於maven構建的,將不同的功能模塊劃分了不同的子模塊。

我們可以簡單執行可執行模塊deployer,也可以將模塊通過maven依賴的方式,將你需要的子模塊引入到你自己的項目中進行使用開發。

 

簡單介紹下核心模塊的功能:

  • deployer模塊:獨立部署模塊,用於canal-server的獨立啟動,包括本地配置解析、拉取遠程配置、啟動canal-server。
  • server模塊:canal-server的實現邏輯,一個canal-server一般是一個jvm進程。重點關注兩種canal-server的實現方式,內嵌型的canalServerEmbed和獨立使用的canalServerWithNetty。新版本中新增了直接對接mq的canal-server實現。
  • instance模塊:具體實時訂閱任務是由一個個instance組成的,每個canal-server中可以同時運行多個instance。instance由parser、sink、store三個重點模塊組成。
  • parser模塊:數據源接入,模擬slave協議和master進行交互,協議解析。parser模塊依賴於dbsync、driver模塊。
  • sink模塊:將parser抓取到的數據,進行過濾,加工,然後發送到store模塊進行存儲。核心接口為CanalEventSink。
  • store模塊:數據存儲模塊,類似內存模式到消息隊列,本質上是一個RingBuffer。核心接口為CanalEventStore。
  • meta模塊:增量訂閱&消費信息管理器,核心接口為CanalMetaManager,主要用於記錄canal消費到的mysql binlog的位置
  • client模塊:項目最早的消費客戶端,通過將client模塊引入自己的項目中,然後直接消費canal-server獲取的數據。
  • client-adapter模塊:1.1.x后新出的模塊,可以獨立部署為canal-server的消費服務端,是一個springboot項目。通過SPI機制,能夠加載不同plugins,將消費信息投遞到ES\hbase\rdb等下游。
  • admin模塊:1.1.x新出的模塊,可以獨立部署為canal-server的控制台,配置canal-server、instance相關配置,非常好用。

3.模塊關聯

那這些模塊之間是如何組織、如何關聯的呢?

我們從整體到局部來看一下。

整體架構關聯,包括admin模塊、server模塊、client-adapter模塊

 

1)server模塊是服務端核心模塊,用來拉取binlog的實時變更,然後投遞到客戶端。

2)server可以通過配置,選擇投遞到MQ,或者是啟動一個netty,讓客戶端來拉取。

3)client-adapter就是一個獨立部署到服務,可以直接拉取canal-server的消息(或者拉取mq的消息),轉發到對應RDS/Redis/HBase,當然,你也可以自己實現一個轉發到redis的adapter

4)admin模塊是管理控制台,可以調度canal-server組成一個個集群實現instance的高可用、可以更改server、instance的配置信息。

Canal-server模塊局部關係,包括deployer模塊、server模塊、instance模塊、parser模塊、sink模塊、store模塊、meta模塊、client模塊。

 

1)deployer模塊是一個啟動模塊,可以啟動canal-server。

2)一個server是一個獨立應用,是一個jvm進程,裏面可以有多個instance對象。

3)instance內包括了parser、sink、store、meta

4)parser負責獲取binlog變更,然後sink將parser獲取的binlog變更轉換為event,存入store。

5)meta是元信息管理器

6)client模塊可以內嵌入你的應用,用來消費canal-server的消息事件。

基本上核心模塊的關係就是這樣了,後續會按照模塊的維度進行源碼分析,敬請期待。

 

都看到最後了,原創不易,點個關注,點個贊吧~

文章持續更新,可以微信搜索「阿丸筆記 」第一時間閱讀,回復關鍵字【學習】有我準備的一線大廠面試資料。

知識碎片重新梳理,構建Java知識圖譜: github.com/saigu/JavaK…(歷史文章查閱非常方便)

 

本站聲明:網站內容來源於博客園,如有侵權,請聯繫我們,我們將及時處理

【其他文章推薦】

※廣告預算用在刀口上,台北網頁設計公司幫您達到更多曝光效益

新北清潔公司,居家、辦公、裝潢細清專業服務

※別再煩惱如何寫文案,掌握八大原則!

※教你寫出一流的銷售文案?

※超省錢租車方案

FB行銷專家,教你從零開始的技巧

7000 字說清楚 HashMap,面試點都在裏面了

我是風箏,公眾號「古時的風箏」,一個兼具深度與廣度的程序員鼓勵師,一個本打算寫詩卻寫起了代碼的田園碼農!
文章會收錄在 JavaNewBee 中,更有 Java 後端知識圖譜,從小白到大牛要走的路都在裏面。

這是上篇文章 有趣的條漫版 HashMap,25歲大爺都能看懂 的文字版。有不少同學說條漫版的比較有意思,簡單易懂,但是畢竟圖片畫不了那麼詳細,只能從大面而上理解。

真正的了解細節,還得看這一篇。其實是這篇先寫完,然後畫了不少圖片,所以就寫了一篇圖片版的。本篇 7000 多字,建議三連呦。

在 Java 中,最常用的數據類型是 8 中基本類型以及他們的包裝類型以及字符串類型,其次應該就是 ArrayListHashMap了吧。HashMap存的是鍵值對類型的數據,其存儲和獲取的速度快、性能高,是非常好用的一個數據結構,每一個 Java 開發者都肯定用過它。

而且 HashMap的設計巧妙,其結構和原理也經常被拿去當做面試題。其中有很多巧妙的算法和設計,比如 Hash 算法、拉鏈法、紅黑樹設計等,值得每一個開發者借鑒學習。

想了老半天,怎麼才能簡單易懂的把 HashMap說明白呢,那就從我理解它的思路和過程去說吧。要理解一個事物最好的方式就是先了解整體結構,再去追究細節。所以,我們先從結構談起。

先從結構說起

拿我自身的一個體會來說吧,風箏我作為一個專業路痴,對於迷路這件事兒絕不含糊,雖然在北京混跡多年,但是只在中關村能分清南北,其他地方,哪怕是我每天住的小區、每天工作的公司也分不太清方向,回家只能認一條路,要是打車換條路回家,也得迷糊一陣,這麼說吧,在小區前面能回家,小區後面找不到家。去個新地方,得盯着地圖看半天。這時,我就在想啊,要是我能在城市上空俯瞰下面的街道,那我就再也不怕找不到回家的路了。這不就是三體里的降維打擊嗎,站在高維的立場,理解低維的事物,那就簡單多了。

理解數據結構也是一個道理,大多數時候,我們都是停留在會用的層面上,理解一些原理也只是支離破碎的,困在數據機構的迷宮裡跌跌撞撞,迫切的需要一張地圖或者一架直升機。

先來看一下整個 Map家族的集成關係圖,一看東西還不少,但其他的可能都沒怎麼用過,只有 HashMap最熟悉。

以下描述可能不夠專業,只為簡單的描述 HashMap的結構,請結合下圖進行理解。

HashMap主體上就是一個數組結構,每一個索引位置英文叫做一個 bin,我們這裏先管它叫做桶,比如你定義一個長度為 8 的 HashMap,那就可以說這是一個由 8 個桶組成的數組。當我們像數組中插入數據的時候,大多數時候存的都是一個一個 Node 類型的元素,Node 是 HashMap中定義的靜態內部類。

當插入數據(也就是調用 put 方法)的時候,並不是按順序一個一個向後存儲的,HashMap中定義了一套專門的索引選擇算法,叫做散列計算,但散列計算存在一種情況,叫哈希碰撞,也就是兩個不一樣的 key 散列計算出來的 hash 值是一致的,這種情況怎麼辦呢,採用拉鏈法進行擴展,比如圖中藍色的鏈表部分,這樣一來,具有相同 hash 值的不同 key 即可以落到相同的桶中,又保證不會覆蓋之前的內容。

但隨着插入的元素越來越多,發生碰撞的概率就越大,某個桶中的鏈表就會越來越長,直到達到一個閾值,HashMap就受不了了,為了提升性能,會將超過閾值的鏈錶轉換形態,轉換成紅黑樹的結構,這個閾值是 8 。也就是單個桶內的鏈表節點數大於 8 ,就會將鏈表變身為紅黑樹。

以上概括性的描述就是 HashMap的整體結構,也是我們進一步研究細節的藍圖。我們將從中抽取出幾個關鍵點一一解釋,從整體到細節,降維打擊 HashMap

接下來就是說明為什麼會設計成這樣的結構以及從單純數組到桶內鏈表產生,接着把鏈錶轉換成紅黑樹的詳細過程。

認清幾個關鍵概念

存儲容器

因為HashMap內部是用一個數組來保存內容的,數組定義如下:

transient Node<K,V>[] table;

Node 類型

table 是一個 Node類型的數組,Node是其中定義的靜態內部類,主要包括 hash、key、value 和 next 的屬性。比如之後我們使用 put 方法像其中加鍵值對的時候,就會轉換成 Node 類型。

static class Node<K,V> implements Map.Entry<K,V> {
  final int hash;
  final K key;
  V value;
  Node<K,V> next;
}

TreeNode

前面說了,當桶內鏈表到達 8 的時候,會將鏈錶轉換成紅黑樹,就是 TreeNode類型,它也是 HashMap中定義的靜態內部類。

static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
  TreeNode<K,V> parent;  // red-black tree links
  TreeNode<K,V> left;
  TreeNode<K,V> right;
  TreeNode<K,V> prev;    // needed to unlink next upon deletion
  boolean red;
}

容量和默認容量

容量就是 table 數組的長度,也就是我們所說的桶的個數。其定義如下

int threshold;

默認是 16,如果我們在初始化的時候沒有指定大小,那就是 16。當然我們也可以自己指定初始大小,而 HashMap 要求初始大小必須是 2 的 冪次方。

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

元素個數

容量是指定了桶的個數,而 size 是說 HashMap中實際存了多少個鍵值對。

transient int size;

最大容量

table 的長度也是有限制的,不能無限大,HashMap規定最大長度為 2 的30次方。

static final int MAXIMUM_CAPACITY = 1 << 30;

負載因子

這是一個係數,它和 threshold 結合起作用,默認是 0.75。一般情況下不要改。

final float loadFactor;

擴容閾值

閾值 = 容量 x 負載因子,假設當前 HashMap的容量是 16,負載因子是默認值 0.75,那麼當 size 到達 16 x 0.75= 12 的時候,就會觸發擴容。

初始化 HashMap

使用 HashMap肯定要初始化吧,很多情況下都是用無參構造方法創建。

Map<String,String> map = new HashMap<>();

這種情況下所有屬性都是默認值,比如容量是 16,負載因子是 0.75。

另外推薦的一種初始化方式,就是給定一個默認容量,比如指定默認容量是 32。

Map<String,String> map = new HashMap<>(32);

但是 HashMap 要求初始大小必須是 2 的 n 次方,但是又不能要求每個開發人員指定初始容量的時候都按要求來,比如我們指定初始大小為為 7、18 這種會怎麼樣呢?

沒關係,HashMap中有個方法專門負責將傳過來的參數值轉換為最接近、且大於等於指定參數的 2 的 n 次方的值,比如指定大小為 7 的話,最後實際的容量就是 8 ,如果指定大小為 18的話,那最後實際的容量就是 32 。

public HashMap(int initialCapacity, float loadFactor) {
  if (initialCapacity < 0)
    throw new IllegalArgumentException("Illegal initial capacity: " +
                                       initialCapacity);
  if (initialCapacity > MAXIMUM_CAPACITY)
    initialCapacity = MAXIMUM_CAPACITY;
  if (loadFactor <= 0 || Float.isNaN(loadFactor))
    throw new IllegalArgumentException("Illegal load factor: " +
                                       loadFactor);
  this.loadFactor = loadFactor;
  this.threshold = tableSizeFor(initialCapacity);
}

執行這個轉換動作的就是 tableSizeFor方法,經過轉換后,將最終的結果賦值給 threshold變量,也就是初始容量,也就是本篇中所說的桶個數。

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;
}

tableSizeFor這個方法就有意思了,先把初始參數減 1,然後連着做或等於無符號右移操作,最後算出一個接近的 2 的冪次方,下圖演示了初始參數為 18 時的一系列操作,最後得出的初始大小為 32。

這個算法很有意思了,比如你給的初始大小是 63,那得到的結果就是 64,如果初始大小給定 65 ,那得到的結果就是 128,總是能得出不小於給定初始大小,並且最接近的2的n次方的最終值。

從 put 方法解密核心原理

put方法是增加鍵值對最常用的方法,也是最複雜的過程,增加鍵值對的過程涉及了 HashMap最核心的原理,主要包括以下幾點:

  1. 什麼情況下會擴容,擴容的規則是什麼?
  2. 插入鍵值對的時候如何確定索引,HashMap可不是按順序插入的,那樣不就真成了數組了嗎。
  3. 如何確保 key 的唯一性?
  4. 發生哈希碰撞怎麼處理?
  5. 拉鏈法是什麼?
  6. 單桶內的鏈表如何轉變成紅黑樹?

以下是 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) {
  HashMap.Node<K,V>[] tab; // 聲明 Node 數組 tab
  HashMap.Node<K,V> p;    // 聲明一個 Node 變量 p
  int n, i;
  /**
  * table 定義 transient Node<K,V>[] table; 用來存儲 Node 節點
  * 如果 當前table為空,則調用resize() 方法分配數組空間
  */
  if ((tab = table) == null || (n = tab.length) == 0)
    n = (tab = resize()).length;
  // n 總是為 2 的冪次方,(n-1) & hash 可確定 tab.length (也就是table數組長度)內的索引
  // 然後 創建一個 Node 節點賦給當前索引
  if ((p = tab[i = (n - 1) & hash]) == null)
    tab[i] = newNode(hash, key, value, null);
  else {
    //如果當前索引位置已經有值了,怎麼辦
    // 拉鏈法出場
    HashMap.Node<K,V> e;
    K k;
    // 判斷 key 值唯一性
    // p 是當前待插入索引處的值
    // 哈希值一致並且(當前位置的 key == 待插入的key(注意 == 符號),或者key 不為null 並且 key.equals(k))
    if (p.hash == hash &&
        ((k = p.key) == key || (key != null && key.equals(k)))) //如果當前節點只有一個元素,且和待插入key一樣 則覆蓋
      // 將 p(當前索引)節點臨時賦予 e
      e = p;
    else if (p instanceof HashMap.TreeNode) // 如果當前索引節點是一顆樹節點
      //插入節點樹中 並返回
      e = ((HashMap.TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
    else {
      // 當前索引節點即不是只有一個節點,也不是一顆樹,說明是一個鏈表
      for (int binCount = 0; ; ++binCount) {
        if ((e = p.next) == null) { //找到沒有 next 的節點,也就是最後一個
          // 創建一個 node 賦給 p.next
          p.next = newNode(hash, key, value, null);
          // 如果當前位置+1之後大於 TREEIFY_THRESHOLD 則要進行樹化
          if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
            //執行樹化操作
            treeifyBin(tab, hash);
          break;
        }
        //如果又發生key衝突則停止 後續這個節點會被相同的key覆蓋
        if (e.hash == hash &&
            ((k = e.key) == key || (key != null && key.equals(k))))
          break;
        p = e;
      }
    }
    if (e != null) { // existing mapping for key
      V oldValue = e.value;
      if (!onlyIfAbsent || oldValue == null)
        e.value = value;
      afterNodeAccess(e);
      return oldValue;
    }
  }
  ++modCount;
  // 當實際長度大於 threshold 時 resize
  if (++size > threshold)
    resize();
  afterNodeInsertion(evict);
  return null;
}

首次初始化數組和擴容

在執行 put方法時,第一步要檢查 table 數組是否為空或者長度是否為 0,如果是這樣的,說明這是首次插入鍵值對,需要執行 table 數組初始化操作。

另外,隨之鍵值對添加的越來越多,HashMap的 size 越來越大,注意 size 前面說了,是實際的鍵值對數量,那麼 size 到了多少就要擴容了呢,並不是等 size 和 threshold(容量)一樣大了才擴容,而是到了閾值就開始擴容,閾值上面也說了,是容量 x 負載因子

為什麼放在一起說呢,因為首次初始化和擴容都是用的同一個方法,叫做 resize()。以下是我註釋的 resize()方法。

final HashMap.Node<K,V>[] resize() {
  // 保存 table 副本,接下來 copy 到新數組用
  HashMap.Node<K,V>[] oldTab = table;
  // 當前 table 的容量,是 length 而不是 size
  int oldCap = (oldTab == null) ? 0 : oldTab.length;
  // 當前桶大小
  int oldThr = threshold;

  int newCap, newThr = 0;
  if (oldCap > 0) { //如果當前容量大於 0,也就是非第一次初始化的情況(擴容場景下)
    if (oldCap >= MAXIMUM_CAPACITY) { //不能超過最大允許容量
      threshold = Integer.MAX_VALUE;
      return oldTab;
    }
    else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
             oldCap >= DEFAULT_INITIAL_CAPACITY) // 雙倍擴容
      newThr = oldThr << 1; // double threshold
  }
  else if (oldThr > 0) // 初始化的場景(給定默認容量),比如 new HashMap(32)
    newCap = oldThr; //將容量設置為 threshold 的值
  else {               // 無參數初始化場景,new HashMap()
    // 容量設置為 DEFAULT_INITIAL_CAPACITY
    newCap = DEFAULT_INITIAL_CAPACITY;
    // 閾值 超過閾值會觸發擴容
    newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
  }
  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"})
  HashMap.Node<K,V>[] newTab = (HashMap.Node<K,V>[])new HashMap.Node[newCap];
  table = newTab;
  if (oldTab != null) {
    for (int j = 0; j < oldCap; ++j) {
      HashMap.Node<K,V> e;
      //遍歷 獲得得到元素 賦給 e
      if ((e = oldTab[j]) != null) { //如果當前桶不為空
        oldTab[j] = null; // 置空回收
        if (e.next == null) //節點 next為空的話 重新尋找落點 
          newTab[e.hash & (newCap - 1)] = e;
        else if (e instanceof HashMap.TreeNode) //如果是樹節點
          //紅黑樹節點單獨處理
          ((HashMap.TreeNode<K,V>)e).split(this, newTab, j, oldCap);
        else { // 保持原順序
          HashMap.Node<K,V> loHead = null, loTail = null;
          HashMap.Node<K,V> hiHead = null, hiTail = null;
          HashMap.Node<K,V> next;
          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;
}

首次初始化

put方法中線先檢查 table 數組是否為空,如果為空就初始化。

if ((tab = table) == null || (n = tab.length) == 0)
    n = (tab = resize()).length;

首次初始化分為無參初始化和有參初始化兩種情況,前面在講 HashMap初始化的時候說了,無參情況默認就是 16,也就是 table 的長度為 16。有參初始化的時候,首先使用 tableSizeFor()方法確定實際容量,最後 new 一個 Node 數組出來。

HashMap.Node<K,V>[] newTab = (HashMap.Node<K,V>[])new HashMap.Node[newCap];

其中 newCap就是容量,默認16或者自定義的。

而這個過程中還有很重要的一步,就是維護擴容閾值

擴容

put方法中,判斷當 size(實際鍵值對個數)到達 threshold (閾值)時,觸發擴容操作。

// 當實際長度大於 threshold 時 resize
if (++size > threshold)
    resize();

HashMap遵循兩倍擴容規則,每次擴容之後的大小是擴容前的兩倍。另外,說到底,底層的存儲還是一個數組,Java 中沒有真正的動態數組這一說,數組初始化的時候是多大,那它就一直是這麼大,那擴容是怎麼來的呢,答案就是創建一個新數組,然後將老數組的數據拷貝過去。

拷貝的時候可能會有如下幾種情況:

  1. 如果節點 next 屬性為空,說明這是一個最正常的節點,不是桶內鏈表,也不是紅黑樹,這樣的節點會重新計算索引位置,然後插入。
  2. 如果是一顆紅黑樹,則使用 split方法處理,原理就是將紅黑樹拆分成兩個 TreeNode 鏈表,然後判斷每個鏈表的長度是否小於等於 6,如果是就將 TreeNode 轉換成桶內鏈表,否則再轉換成紅黑樹。
  3. 如果是桶內鏈表,則將鏈表拷貝到新數組,保證鏈表的順序不變。

確定插入點

當我們調用 put方法時,第一步是對 key 進行 hash 計算,計算這個值是為了之後尋找落點,也就是究竟要插入到 table 數組的哪個桶中。

hash 算法是這樣的,拿到 key 的 hashCode,將 hashCode 做一次16位右位移,然後將右移的結果和 hashCode 做異或運算,這段代碼叫做「擾動函數」,之所以不直接拿 hashCode 是為了增加隨機性,減少哈希碰撞次數。

/**
* 用來計算 key 的 hash 值
**/
static final int hash(Object key) {
  int h;
  return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

拿到這個 hash 值之後,會進行這樣的運算 i = (n - 1) & hash,其中 i就是最終計算出來的索引位置。

有兩個場景用到了這個索引計算公式,第一個場景就是 put方法插入鍵值對的時候。第二個場景是在 resize 擴容的時候,new 出來新數組之後,將已經存在的節點移動到新數組的時候,如果節點不是鏈表,也不是紅黑樹,而是一個普通的 Node 節點,會重新計算,找到在新數組中的索引位置。

接着看圖,還是圖說的清楚。

HashMap 要求容量必須是 2 的 n 次方,2的 n 次方的二進製表示大家肯定都很清楚,2的6次方,就是從右向左 6 個 0,然後第 7 位是 1,下圖展示了 2 的 6 次方的二進製表示。

然後這個 n-1的操作就厲害了,減一之後,後面之前二進製表示中 1 後面的 0 全都變成了 1,1 所在的位變為 0。比如 64-1 變為 63,其二進製表示是下面這樣的。

下圖中,前面 4 行分別列出了當 map 的容量為 8、16、32、64的時候,假設容量為 n,則對應的 n-1 的二進製表示是下面這樣的,尾部一片紅,都是 1 ,能預感到將要有什麼騷操作。

沒錯,將這樣的二進製表示代入這個公式 (n - 1) & hash中,最終就能確定待插入的索引位了。接着看圖最下面的三行,演示了假設當前 HashMap的容量為 64 ,而待插入的一個 key 經過 hash 計算后得到的結果是 99 時,代入公式計算 index 的值,也就是 (64-1)& 99,最終的計算結果是 35,也就是這個 key 會落到 table[35] 這個位置。

為什麼 HashMap一定要保證容量是 2 的冪次方呢,通過二進製表示可以看出,如果有多位是 1 ,那與 hash 值進行與運算的時候,更能保證最後散列的結果均勻,這樣很大程度上由 hash 的值來決定。

如何確保 key 的唯一性

HashMap中不允許存在相同的 key 的,那怎麼保證 key 的唯一性呢,判斷的代碼如下。

if (p.hash == hash &&
        ((k = p.key) == key || (key != null && key.equals(k))))

首先通過 hash 算法算出的值必須相等,算出的結果是 int,所以可以用 == 符號判斷。只是這個條件可不行,要知道哈希碰撞是什麼意思,有可能兩個不一樣的 key 最後產生的 hash 值是相同的。

並且待插入的 key == 當前索引已存在的 key,或者 待插入的 key.equals(當前索引已存在的key),注意== 和 equals 是或的關係。== 符號意味着這是同一個對象, equals 用來確定兩個對象內容相同。

如果 key 是基本數據類型,比如 int,那相同的值肯定是相等的,並且產生的 hashCode 也是一致的。

String 類型算是最常用的 key 類型了,我們都知道相同的字符串產生的 hashCode 也是一樣的,並且字符串可以用 equals 判斷相等。

但是如果用引用類型當做 key 呢,比如我定義了一個 MoonKey 作為 key 值類型

public class MoonKey {

    private String keyTile;

    public String getKeyTile() {
        return keyTile;
    }

    public void setKeyTile(String keyTile) {
        this.keyTile = keyTile;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        MoonKey moonKey = (MoonKey) o;
        return Objects.equals(keyTile, moonKey.keyTile);
    }
}

然後用下面的代碼進行兩次添加,你說 size 的長度是 1 還是 2 呢?

Map<MoonKey, String> m = new HashMap<>();
MoonKey moonKey = new MoonKey();
moonKey.setKeyTile("1");
MoonKey moonKey1 = new MoonKey();
moonKey1.setKeyTile("1");
m.put(moonKey, "1");
m.put(moonKey1, "2");
System.out.println(hash(moonKey));
System.out.println(hash(moonKey1));
System.out.println(m.size());

答案是 2 ,為什麼呢,因為 MoonKey 沒有重寫 hashCode 方法,導致 moonkey 和 moonKey1 的 hash 值不可能一樣,當不重寫 hashCode 方法時,默認繼承自 Object的 hashCode 方法,而每個 Object對象的 hash 值都是獨一無二的。

划重點,正確的做法應該是加上 hashCode的重寫。

@Override
public int hashCode() {
  return Objects.hash(keyTile);
}

這也是為什麼要求重寫 equals 方法的同時,也必須重寫 hashCode方法的原因之一。 如果兩個對象通過調用equals方法是相等的,那麼這兩個對象調用hashCode方法必須返回相同的整數。有了這個基礎才能保證 HashMap或者HashSet的 key 唯一。

發生哈希碰撞怎麼辦

前面剛說了相等的對象產生的 hashCode 也要相等,但是不相等的對象使用 hash方法計算之後也有可能產生相同的值,這就叫做哈希碰撞。雖然通過算法已經很大程度上避免碰撞的發生,但是卻無法避免。

產生碰撞之後,自然得出的在 table 數組的索引(也就是桶)也是一樣的,這時,怎麼辦呢,一個桶里怎麼放多個鍵值對?

拉鏈法

文章剛開頭就提到了,HashMap可不是簡單的數組而已。當碰撞發生就坦然接收。有一種方法叫做拉鏈法,不是衣服上那種拉鏈。而是,當碰撞發生了,就在當前桶上拉一條鏈表出來,這樣解釋就合理了。

前面介紹關鍵概念的時候提到了 Node類型,裏面有個屬性叫做 next,它就是為了這種鏈表設計的,如下圖所示。node1、node2、node3都落在了同一個桶中,這時候就得用鏈表的方式處理了,node1.next = node2,node2.next = node3,這樣將鏈表串起來。而 node3.next = null,則說明這是鏈表的尾巴。

當有新元素準備插入到鏈表的時候,採用的是尾插法,而不是頭插法了,JDK 1.7 的版本採用的是頭插法,但是頭插法有個問題,就是在兩個線程執行 resize() 擴容的時候,很可能造成環形鏈表,導致 get 方法出現死循環。

鏈錶轉換成樹

鏈表不是碰撞處理的終極結構,終極結構是紅黑樹,當鏈表長度到達 8 之後,再有新元素進來,那就要開始由鏈表到紅黑樹的轉換了。方法 treeifyBin是完成這個過程的。

使用紅黑樹是出於性能方面的考慮,紅黑樹的查找速度要優於鏈表。那為什麼不是一開始就直接生成紅黑樹,而是鏈表長度大於 8 之後才升級成樹呢?

首先來說,哈希碰撞的概率還是很小的,大部分情況下都是一個桶裝一個 Node,即便發生碰撞,都碰撞到一個桶的概率那就更是少之又少了,所以鏈表長度很少有機會能到 8 ,如果鏈表長度到 8 了,那說明當前 HashMap中的元素數量已經非常大了,那這時候用紅黑樹來提高性能是可取的。而反過來,如果 HashMap總的元素很少,即便用紅黑樹對性能的提升也不大,況且紅黑樹對空間的使用要比鏈表大很多。

get 方法

T value = map.get(key);

例如通過上面的語句通過 key 獲取 value 值,是我們最常用到的方法了。

看圖理解,當調用 get方法后,第一步還是要確定索引位置,也就是我們所說的桶的位置,方法和 put方法時一樣,都是先使用 hash這個 擾動函數 確定 hash 值,然後用 (n-1) & hash獲取索引。這不廢話嗎,當然得和 put的時候一樣了,不一樣還怎麼找到正確的位置。

確定桶的位置后,會出現三種情況:

單節點類型: 也就是這個桶內只有一個鍵值對,這也在 HashMap中存在最多的類型,只要不發生哈希碰撞都是這種類型。其實 HashMap最理想的情況就是這樣,全都是這種類型就完美了。

鏈表類型: 如果發現 get 的 key 所在的是一個鏈表結構,就需要遍歷鏈表,知道找到 key 相等的 Node。

紅黑樹類型: 當鏈表長度超過 8 就轉變成紅黑樹,如果發現找到的桶是一顆紅黑樹,就使用紅黑樹專有的快速查找法查找。

另外,Map.containsKey方法其實用的就是 get方法。

remove 方法

removeputget方法類似,都是先求出 key 的 hash 值,然後 (n-1) & hash獲取索引位置,之後根據節點的類型採取不同的措施。

單節點類型: 直接將當前桶元素替換為被刪除 node.next ,其實就是 null。

鏈表類型: 如果是鏈表類型,就將被刪除 node 的前一個節點的 next 屬性設置為 node.next。

紅黑樹類型: 如果是一棵紅黑樹,就調用紅黑樹節點刪除法,這裏,如果節點數在 2~6之間,就將樹結構簡化為鏈表結構。

非線程安全

HashMap沒有做併發控制,如果想在多線程高併發環境下使用,請用 ConcurrentHashMap。同一時刻如果有多個線程同時執行 put 操作,如果計算出來的索引(桶)位置是相同的,那會造成前一個 key 被后一個 key 覆蓋。

比如下圖線程 A 和 線程 B 同時執行 put 操作,很巧的是計算出的索引都是 2,而此時,線程A 和 線程B都判斷出索引為 2 的桶是空的,然後就是插入值了,線程A先 put 進去了 key1 = 1的鍵值對,但是,緊接着線程B 又 put 進去了 key2 = 2,線程A 表示痛哭流涕,白忙活一場。最後索引為2的桶內的值是 key2=2,也就是線程A的存進去的值被覆蓋了。

總結

前面沒說,HashMap搞的這麼複雜不是白搞的,它的最大優點就是快,尤其是 get數據,是 O(1)級別的,直接定位索引位置。

HashMap不是單純的數組結構,當發生哈希碰撞時,會採用拉鏈法生成鏈表,當鏈表大於 8 的時候會轉換成紅黑樹,紅黑樹可以很大程度上提高性能。

HashMap容量必須是 2 的 n 次方,這樣設計是為了保證尋找索引的散列計算更加均勻,計算索引的公式為 (n - 1) & hash

HashMap在鍵值對數量達到擴容閾值「容量 x 負載因子」的時候進行擴容,每次擴容為之前的兩倍。擴容的過程中會對單節點類型元素進行重新計算索引位置,如果是紅黑樹節點則使用 split方法重新考量,是否將紅黑樹變為鏈表。

壯士且慢,先給點個贊吧,總是被白嫖,身體吃不消!

我是風箏,公眾號「古時的風箏」。一個兼具深度與廣度的程序員鼓勵師,一個本打算寫詩卻寫起了代碼的田園碼農!你可選擇現在就關注我,或者看看歷史文章再關注也不遲。

本站聲明:網站內容來源於博客園,如有侵權,請聯繫我們,我們將及時處理

【其他文章推薦】

※超省錢租車方案

※別再煩惱如何寫文案,掌握八大原則!

※回頭車貨運收費標準

※教你寫出一流的銷售文案?

FB行銷專家,教你從零開始的技巧

救蜂群的替代殺蟲劑 結果也對蜜蜂有害

摘錄自2018年8月16日中央社報導

研究人員今天(16日)警告,一種用於替代危害蜜蜂的「新類尼古丁」(neonicotinoid)殺蟲藥的新型殺蟲劑,可能與新類尼古丁殺蟲藥一樣,還是對作物授粉的蜜蜂有害。

研究人員在「自然」(Nature)期刊表示,實驗中,大黃蜂的繁殖能力和牠們蜂群成長速度,都會受到新的鵳基亞胺類(sulfoximine)殺蟲劑所影響。

研究主筆、倫敦大學皇家哈洛威學院(Royal Holloway, University of London)研究人員席維特(Harry Siviter)說:「我們研究結果顯示,新型殺蟲劑之一的速殺氟(sulfoxaflor)會對大黃蜂繁殖量產生負面影響。」

與新類尼古丁相似,速殺氟不會直接殺死蜜蜂,但卻顯示會影響蜜蜂的免疫系統或生殖能力。

不過覓食行為和個別蜜蜂採集花粉量在實驗中保持不變。

本站聲明:網站內容來源環境資訊中心https://e-info.org.tw/,如有侵權,請聯繫我們,我們將及時處理

【其他文章推薦】

※廣告預算用在刀口上,台北網頁設計公司幫您達到更多曝光效益

新北清潔公司,居家、辦公、裝潢細清專業服務

※別再煩惱如何寫文案,掌握八大原則!

※教你寫出一流的銷售文案?

※超省錢租車方案

FB行銷專家,教你從零開始的技巧

氣候暖化 瑞士居民為消失冰川辦葬禮

摘錄自2019年9月23日公視報導

瑞士居民為阿爾卑斯山的冰川舉行了葬禮,受氣候變遷影響,這座冰川從2006年消融速度加快,現在已經消失了90%面積。

大約250個瑞士居民,22日穿著黑衣,披著黑頭紗爬了約兩小時的路程,登上海拔約2700公尺的皮措爾山山頂,為這座即將消失的冰川舉行葬禮。

瑞士蘇黎世聯邦理工學院冰川專家赫斯表示,「照目前情況來看,我們還有約4個足球場大小的冰川,但過去兩年冰川消融的速度迅速增加。」

皮措爾冰川位在瑞士境內的阿爾卑斯山,自從2006年以來,已經失去了將近90%面積,現在只剩下約兩萬6000平方公尺,不到四個足球場大小,科學家認為,冰川消融如此快速是受到氣候變遷影響,如果再不控制溫室氣體排放,這座冰川將會在2030年前完全消失。

 

本站聲明:網站內容來源環境資訊中心https://e-info.org.tw/,如有侵權,請聯繫我們,我們將及時處理

【其他文章推薦】

※超省錢租車方案

※別再煩惱如何寫文案,掌握八大原則!

※回頭車貨運收費標準

※教你寫出一流的銷售文案?

FB行銷專家,教你從零開始的技巧