1. 程式人生 > 程式設計 >HashMap面試之FAQ

HashMap面試之FAQ

FAQ

效率

  • 雜湊表是一種典型的空間換時間的做法
  • 由於雜湊函式和陣列元素定位都是O(1),所以無論是搜尋、新增還是刪除都是O(1)
  • 在雜湊表中,學術性地稱每個陣列位置為一個桶(Bucket),整個雜湊表成為桶陣列(Bucket Array)
  • 為了避免在例項化時就建立囊括樣本所有可能性數量級長度的桶陣列,一開始我們通常選取較短的長度,如8、16,如此必然會出現經過雜湊函式多個key被對映到一個桶的情況,這種現象稱為雜湊衝突。

雜湊衝突

11-16-25.png

  • 雜湊衝突的解決存在以下方案:
    • 重定位地址:發現衝突之後,從該位置按照一定的跳躍規則(如躍過3個桶)向後查詢空的桶進行插入
    • 再雜湊:預備多個雜湊函式,當發生雜湊衝突後使用預備函式重新計算雜湊值並定位桶位置
  • JDK8如何解決雜湊衝突
    • 方案:單連結串列+紅黑樹
    • 當桶數量和桶內記錄(鍵值對)達到一定數量時,會將連結串列升級為紅黑樹(如桶數量達到64且桶內記錄數超過8時);當記錄數減少到一定程度後(如桶內記錄數減少到9以下),又會由紅黑樹退化為單連結串列
    • 為何使用單連結串列而不使用雙連結串列
      • 使用雙連結串列雖然可以利用尾指標使得尾插法為O(1),但是記錄插入到連結串列之中既不能使用頭插法也不能使用尾插法,因為我們需要從頭遍歷連結串列,如果發現key相同(equals返回true)那麼應該使用插入的鍵值對覆蓋該原有的鍵值對,否則遍歷到達了連結串列尾部
      • 雙連結串列更佔記憶體,且在此場景下用不上prev
        指標

桶長度

  • 為何桶陣列長度最好為2^n
    • 因為在將key的雜湊值對映到桶下標時,我們通常採用取模的方式,很容易聯想到hash_code % bucket_length,但是%運運算元的效率較低,我們應儘量採用(與——同為1則為1、異或——相同為0不同為1、或——有1則為1)等位運運算元代替,而當bucket_length為2^n時,hash_code & (bucket_length - 1)的值與前者是一致的,舉例易證(bucket_length - 1為2^n對應最高二進位制位為0其餘位為1。

雜湊值計算

  • 雜湊值計算原則

    • 雜湊值的計算要求樣本記錄能夠較均勻的雜湊在各個桶中,這樣能減少雜湊衝突,提升雜湊表效能
  • 如何計算雜湊值

    • 儘量讓key的雜湊值是唯一的

    • 儘量讓key的所有資訊參與運算

    • 以JDK中的常用key型別為例:

      • Integer:數值本身作為雜湊值

        @Override
        public int hashCode() {
            return Integer.hashCode(value);
        }
        
        public static int hashCode(int value) {
            return value;
        }
        複製程式碼
      • Float:佔32位,將該32位二進位制對應的整型數值作為雜湊值

        @Override
        public int hashCode() {
            return Float.hashCode(value);
        }
        
        public static int hashCode(float value) {
            return floatToIntBits(value);
        }
        複製程式碼
      • Long:佔64位,由於Java中Object規定hashCode返回int,又要考慮到儘量讓這64位都參與運算,因此將64中高32位和第32位的異或結果作為雜湊值

        @Override
        public int hashCode() {
            return Long.hashCode(value);
        }
        
        public static int hashCode(long value) {
            return (int)(value ^ (value >>> 32)); // 無符號右移,高位補零
        }
        複製程式碼
      • Double:佔64位,先將該64對應的整型數值換算出來,再同Long一樣處理

        @Override
        public int hashCode() {
            return Double.hashCode(value);
        }
        
        public static int hashCode(double value) {
            long bits = doubleToLongBits(value);
            return (int)(bits ^ (bits >>> 32));
        }
        複製程式碼
      • String:由於字串由一個個字元組成,而每個字元又可以轉換成整數,因此我們可以將每個字元乘以其在字串中位置到末尾字元的權重,如對於"ABCD"=65 * n^3 + 66 * n^2 + 67 * n + 68。在JDK中,n取31,這是因為65 * n^3 + 66 * n^2 + 67 * n + 68 = ((65*n + 66)*n + 67)*n + 68其中*n出現較多,而**JVM會將i * 31優化成(i<<5)-i**以提高計算效率(這相當於面向JVM程式設計,31*i=(32-1)*i=(2<<5)*i - i,且31是一個奇素數,以素數作為乘數比其他方式更容易產生結果的唯一性,減少雜湊衝突,經過觀測分佈結果,31是比15、7等更好的選擇)

        private int hash; // Default to 0
        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;
        }
        複製程式碼
    • 自定義物件:自定義物件由若干基本型別和其他自定義型別欄位組成,我們可以將這一個個的型別的雜湊值計算出來,再乘上不同的權重,最後求和(和字串的求解過程類似)

      public class Person{
          private int age;
          private float height;
          private String name;
          private Car car;
          
          @Override
          public int hashCode(){
              int hash = age;
              int heightHash = Float.floatToIntBits(height);
              hash = hash * 31 + heightHash;
              int nameHash = name == null ? 0 : name.hashCode();
              hash = hash * 31 + nameHash;
              int carHash = car == null ? 0 : car.hashCode();
              hash = hash * 31 + carHash;
              return hash;
          }
      }
      複製程式碼

equals&hashCode

hashCode何時被呼叫

當向雜湊表中put一個鍵值對的時候,keyhashCode方法會被呼叫得出key的雜湊值,再通過對通陣列長度進行去摸運算得出一個桶下標,這個下標對應的桶就是該鍵值對的存放位置

兩個物件相等嗎

如果經過上述運算髮現該同內已有其他鍵值對,此時會遍歷這些記錄,並比較插入記錄的key與被遍歷訪問記錄的key是否相等,如果相等則用插入記錄替換該遍歷記錄並終止遍歷;如果遍歷結束,則向連結串列尾部新增插入記錄。那麼如何判定兩個key是否相等?

Java中為我們提供了以下思路:

  • ==,比較這兩個物件的記憶體地址,這是一種嚴格的相等,實際上是在判定兩個指標是否指向同一塊記憶體,用來實現我們語義上的“兩個物件是否相等”不太恰當

  • compareTo返回0,這要求物件實現Comparable介面,即是可比較的,而雜湊表設計的初衷就是摒棄像紅黑樹那樣維護元素直接的有序性從而使得效能提升為O(1),即被新增到雜湊表中的記錄不應該被要求具備可比較性

  • hashCode,如果認為兩個物件的雜湊值是相等的則認為這兩個物件就是相等的,這也是不嚴謹的,以Integer:1071476900Float:1.73f為例,兩者在記憶體中儲存的32位二進位制數是一致的,但兩者表達的含義天差地別

  • equals,所有物件都繼承了Object中的equals方法,該方法預設使用==進行比較,我們應該為新增到雜湊表中的物件自定義實現equals方法複寫判定是否相等的邏輯,常用的做法是比較物件的各成員變數是否相等,如

    public class Person{
        private int age;
        private float height;
        private String name;
        
        @Override
        public int hashCode(){
            int hash = age;
            int heightHash = Float.floatToIntBits(height);
            hash = hash * 31 + heightHash;
            int nameHash = name == null ? 0 : name.hashCode();
            hash = hash * 31 + nameHash;
            return hash;
        }
        
        @Override
        public boolean equals(Object o){
            if(this == o) return true; // 如果記憶體相同,則肯定相等
            // 如果要比較的為null(能夠走到這裡說明this不為null) 或 型別不同
            if(o == null || this.getClass() != o.getClass()) return false; 
            Person p = (Person)o;
            return age == p.age && height == p.height &&
                (name == null ? p.name == null : name.equals(p.name));
        }
    }
    複製程式碼

只重寫hashCode和只重寫equals都不是理智的

如果只重寫hashCode而不重寫equals,由於equals預設比較記憶體地址,因此等效的兩個物件在被新增到雜湊表中時,不會互相覆蓋,因此你無法達到向雜湊表中新增新記錄(person2,"aaa")從而覆蓋雜湊表中的老記錄(person1,"bbb")的目的,即無法實現通過以等效的key來更新桶中已有記錄的功能

如果只重寫equals而不重寫hashCode(預設返回記憶體地址),那麼被認為相等的兩個物件(equals返回true),在被先後新增到雜湊表中時,雖然兩個不同記憶體的物件hashCode不同:

  • 如果取模得出的桶下標相同,那麼後新增的會覆蓋先新增的 => 更新
  • 如果取模得出的桶下標不同,後新增的會被新增到一個新的桶中 => 新增

這會導致雜湊表功能的不穩定性

重寫兩者需遵循的原則:equals返回true的兩個物件的雜湊值應該一致,雜湊值相同的兩個物件不要求equals返回true

重寫規範

紅黑樹比較邏輯

被加入到雜湊表中的物件不應該被要求是可比較的,那麼桶中的紅黑樹該如何有序組織雜湊衝突的記錄呢?

其實對於兩個物件,有很多可以提取的資訊來幫我們強制比較他們的大小:

  1. 雜湊值,hashCode
  2. 語義上相等,equals。如果雜湊值相等應該呼叫equals進一步判斷,如果雜湊值不等那麼根據重寫原則equals也會返回false
  3. 如果兩個物件實現了Comparable<T>,且型別T一致,那麼可以通過compareTo判斷大小
  4. 在左子樹和右子樹中遞迴查詢記錄是否已存在
  5. 物件唯一身份ID:記憶體地址,System.identityHashCode(Object x)

注意減法陷阱,例如hash1>0,hash2<0hash1-hash2可能發生溢位導致結果小於0,因此最好直接進行比較if (hash1 > hash2)

向紅黑樹中新增記錄

public V put(K key,V value) {
    resize();

    int index = index(key);
    // 取出index位置的紅黑樹根節點
    Node<K,V> root = table[index];
    if (root == null) {
        root = createNode(key,value,null);
        table[index] = root;
        size++;
        fixAfterPut(root);
        return null;
    }

    // 新增新的節點到紅黑樹上面
    Node<K,V> parent = root;
    Node<K,V> node = root;
    int cmp = 0;
    K k1 = key;
    int h1 = hash(k1);
    Node<K,V> result = null;
    boolean searched = false; // 是否已經搜尋過這個key
    do {
        parent = node;
        K k2 = node.key;
        int h2 = node.hash;
        if (h1 > h2) {
            cmp = 1;
        } else if (h1 < h2) {
            cmp = -1;
        } else if (Objects.equals(k1,k2)) {
            cmp = 0;
        } else if (k1 != null && k2 != null 
                   && k1 instanceof Comparable
                   && k1.getClass() == k2.getClass()
                   // compare相等並不意味著equals相等,cmp=0會在後續的case中被覆蓋
                   && (cmp = ((Comparable)k1).compareTo(k2)) != 0) {
            
        } else if (searched) { // 已經掃描了,樹中不存在和新增記錄相等的key
            cmp = System.identityHashCode(k1) - System.identityHashCode(k2);
        } else { // searched == false; 還沒有掃描,然後再根據記憶體地址大小決定左右
            if ((node.left != null && (result = node(node.left,k1)) != null)
                || (node.right != null && (result = node(node.right,k1)) != null)) {
                // 已經存在這個key
                node = result;
                cmp = 0;
            } else { // 不存在這個key
                searched = true; // 已經掃描過整棵樹了
                cmp = System.identityHashCode(k1) - System.identityHashCode(k2);
            }
        }

        if (cmp > 0) {
            node = node.right;
        } else if (cmp < 0) {
            node = node.left;
        } else { // 相等
            V oldValue = node.value;
            node.key = key;
            node.value = value;
            node.hash = h1;
            return oldValue;
        }
    } while (node != null);

    // 看看插入到父節點的哪個位置
    Node<K,V> newNode = createNode(key,parent);
    if (cmp > 0) {
        parent.right = newNode;
    } else {
        parent.left = newNode;
    }
    size++;

    // 新新增節點之後的處理
    fixAfterPut(newNode);
    return null;
}
複製程式碼

從紅黑樹中查詢記錄

private Node<K,V> node(K key) {
    Node<K,V> root = table[index(key)];
    return root == null ? null : node(root,key);
}

private Node<K,V> node(Node<K,V> node,K k1) {
    int h1 = hash(k1);
    // 儲存查詢結果
    Node<K,V> result = null;
    int cmp = 0;
    while (node != null) {
        K k2 = node.key;
        int h2 = node.hash;
        // 先比較雜湊值
        if (h1 > h2) {
            node = node.right;
        } else if (h1 < h2) {
            node = node.left;
        } else if (Objects.equals(k1,k2)) {
            return node;
        } else if (k1 != null && k2 != null 
                   && k1 instanceof Comparable
                   && k1.getClass() == k2.getClass()
                   && (cmp = ((Comparable)k1).compareTo(k2)) != 0) {
            node = cmp > 0 ? node.right : node.left;
        } else if (node.right != null && (result = node(node.right,k1)) != null) { 
            return result;
        } else { // 右子樹中沒有,只能往左邊找,避免遞迴呼叫在左子樹中查詢
            node = node.left;
        }
    }
    return null;
}
複製程式碼

擾動計算

為了避免新增到雜湊表中記錄雜湊值有特徵性(如低位0較多,高位1較多),我們通常會將記錄的雜湊值進行擾動計算後重新賦值:

hash = (hash >>> 16) ^ hash;
複製程式碼

在與、或、非、異或等位運算中,只有異或運算才能混合高低位的所有位資訊到低位中,這樣在計算桶下標時(桶陣列長度減1的二進位制數高位均為0,低位均為1),能讓記錄更加雜湊地分佈

擴容

負載載因子 Load Factor

負載因子 = 記錄數 / 桶陣列長度

當負載因子超高閾值時,應該對雜湊表進行擴容以提高效能(桶中衝突記錄數過多會導致O(1)轉變為O(n)),JDK8中HashMap的預設負載因子閾值為0.75,這是經過試驗、統計後得出最佳選擇。

再雜湊 rehash

新建立一個桶陣列長度為原來2倍的新表,遍歷每個桶以及每個桶中的記錄(如果桶中是紅黑樹根節點,可用層序遍歷法),將記錄rehash到新表中。

rehash:重新計算桶下標,將遍歷記錄從舊錶移至新表中,在此之前還需解除該記錄在原先桶中維護的連線關係,如next; left,right,parent,在此之後需建立新的連線關係,如prev,prev.next; parent,parent.left/right以及染紅新增的紅黑樹節點。