HashMap設計原理與實現(下篇)200行帶你寫自己的HashMap!!!

一無是處的研究僧發表於2022-07-12

HashMap設計原理與實現(下篇)200行帶你寫自己的HashMap!!!

我們在上篇文章雜湊表的設計原理當中已經大體說明了雜湊表的實現原理,在這篇文章當中我們將自己動手實現我們自己的HashMap,完整的程式碼在文章末尾。

在本篇文章當中主要通過線性探測法,從最基本的陣列再到HashMap當中節點的設計,一步一步的實現一個能夠實現KeyValue對映的容器,寫出我們自己的雜湊表MyHashMap,讓可以具備HashMap最常見的兩個功能,putget方法。

我們的陣列當中應該儲存什麼樣資料?

在上篇雜湊表的設計原理當中我們已經仔細說明,在HashMap當中我們是使用陣列去儲存具體的資料的,那麼在我們的陣列當中應該儲存什麼樣的資料呢?假設在HashMap的陣列當中儲存的資料型別為Node,那麼這個類需要有哪些欄位呢?

  • 首先一點我們肯定需要儲存Value值,因為我們最終需要通過get方法從HashMap當中取出我們所需要的值。

  • 第二點當我們通過get方法去取值的時候是通過Key(鍵值)去取的,當雜湊值產生衝突的時候,我們不僅需要通過雜湊值確定位置,還需要通過比較通過函式get傳遞的Key和陣列噹噹中儲存的資料的key是否相等,因此

  • 第三點為了避免重複計算雜湊值(因為有的物件的雜湊值計算還是比較費時間),我們可以使用一個欄位去儲存計算好的雜湊值。

根據以上三點我們的Node類的設計如下:

  private static class Node<K, V> {
    /**
     * 用於儲存我們計算好的 key 的雜湊值
     */
    final int hash;

    /**
     * Key Value 中的 Key 物件
     */
    final K key;

    /**
     * Key Value 中的 Value 物件
     */
    V value;

    /**
     * hash 是鍵值 key 的雜湊值  key 是鍵 value 是值
     * @param hash
     * @param key
     * @param value
     */
    public Node(int hash, K key, V value) {
      this.hash = hash;
      this.key = key;
      this.value = value;
    }

    public V setValue(V newValue) {
      V oldValue = newValue;
      value = newValue;
      return oldValue;
    }

    @Override
    public String toString() {
      return key + "=" + value;
    }
  }

我們的陣列長度應該怎麼設定?

在討論這個問題之前我們首先來回歸一下位運算的操作。在計算機當中資料都是二進位制儲存那麼二進位制運算是如何操作的呢?

int a = 7;
int b = 3;
System.out.println(a & b); // 輸出結果為3

上述程式碼的位運算操作如下:

進行位運算是,二進位制數的對應位置進行相應的操作,&運算的結果只有兩個位元位的資料都是1時,運算結果才等於1,其餘的情況都為0,因此3 & 7 = 3

通過put函式放入HashMap當中的資料首先需要將key的雜湊值與陣列的長度進行取餘運算得到對應的下標,再將資料放入到陣列對應的下標當中。但是在實際的操作當中我們將底層陣列的長度設定為2的整數次冪,並且使用位運算&去進行取餘數操作,而這樣操作主要有一下三點原因:

  • 位運算&的程式執行效率,比取餘%操作更加高效,需要的時間越短。
  • 當陣列的長度為2的整數次冪的時候,得到的下標越均勻,造成的雜湊衝突更少。
  • 任何一個數a\(2^n\)取餘數得到的結果與a\(2^n - 1\)進行&操作結果是相等的,即:

\[a \% 2^n = a \& (2^n - 1) \]

我們現在先來解釋一下第三點,令a = 127n = 4,那麼:127%16 = 15127 & 15 = 15

首先我們需要明白求餘數的意義是什麼?12716取餘,就是用127一直減去16,直到某個結果小於16為止,得到的值為求餘數結果。

上圖當中紅框當中包括的位置就是小於16的部分

上圖當中紅框框住的部分是16的倍數,因此在求餘數的時候上圖當中紅框框住的部分就沒有了,都為0,只會剩下小於16的部分,這跟15進行&操作得到的結果是一致的。這也就解釋了上面提到的第三條。

在第二條中我們提到了使用陣列長度為2的整數次冪可以在一定程度上減少雜湊衝突,因為進行下標運算的時候是與\(2^n - 1\)進行&操作,而\(2^n - 1\)的二進位制表示最後一部分位置上的數都是1

比如下圖中的資料都是2的整數次冪減一之後的結果:

RoundUp函式

因為我們需要陣列的長度是2的整數次冪,而我們之後在初始化函式當中會允許使用者輸入一個陣列長度的大小,但是使用者輸入的數字可能不是2的整數次冪,因此我們需要將使用者輸入的資料變成2的整數次冪,我們可以將使用者輸入的資料變成大於等於這個數的最小的2的整數次冪。

比如說如果使用者輸入的是12我們需要將其變成16,如果輸入的是28我們需要將其變成32。我們可以通過下面這個函式做到這一點:

  /**
   * 返回第一個大於或者等於 capacity 且為 2 的整數次冪的那個數
   * @param capacity
   * @return
   */
  static int roundUp(int capacity) {
    int n = capacity - 1;
    n |= n >>> 1;
    n |= n >>> 2;
    n |= n >>> 4;
    n |= n >>> 8;
    n |= n >>> 16;
    // 如果最終得到的資料小於 0 則初始長度為 1
    // 如果長度大於我們所允許的最大的容量 則將初始長度設定為我們
    // 所允許的最大的容量
    // MAXIMUM_CAPACITY = 1 << 30;
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
  }

上面的程式碼還是很難理解的,讓我們一點一點的來分析。首先我們使用一個2的整數次冪的數進行上面移位操作的操作!

從上圖當中我們會發現,我們咋一個數的二進位制數的32位放一個1,經過移位之後最終32位的位元數字全部變成了1。根據上面數字變化的規律我們可以發現,任何一個位元經過上面移位的變化,這個位元後面的31個位元位都會變成1,像下圖那樣:

因此上述的移位操作的結果只取決於最高一位的位元值為1,移位操作後它後面的所有位元位的值全為1,而在上面函式的最後,如果最終的容量沒有大於我們設定的最大容量MAXIMUM_CAPACITY,我們返回的結果就是上面移位之後的結果 +1。又因為移位之後最高位的1到最低位的1之間的位元值全為1,當我們+1之後他會不斷的進位,最終只有一個位元位置是1,因此它是2的整數倍。

roundUp函式當中,給初始容量減了個1,這樣做的原因是讓這個函式的返回值大於等於傳入的引數capacity

roundUp(4) == 4 // 就是當傳入的資料已經是 2 的整數次冪的時候也返回傳入的值
roundUp(3) == 4
roundUp(5) == 8

雜湊函式

  /**
   * 這個 key 是 put 函式傳進來的 key
   * @param key
   * @return
   */
  static int hash(Object key) {
    int h;
    // 呼叫物件自己實現的 hashCode 方法
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
  }

上面的函式之所以要將物件的雜湊值右移16,是因為我們的陣列的長度一般不會超過\(2^{16}\),因為\(2^{16}\)已經是一個比較大的值了,因此當雜湊值與\(2^n - 1\)進行&操作的時候,高位通常沒有使用到,這樣做的原理是可以充分利用資料雜湊值當中的資訊。

擴容機制

當我們一直往HashMap加入資料的話,陣列遲早會被用完,當陣列用完之後我們就需要進行擴容,我們要記住一點擴容之後的陣列長度也需要滿足2的整數次冪,因為上面我們已經提到陣列的長度需要是2的整數次冪,因此擴容之後的長度也需要保持是2的整數次冪。

但是在實際情況當中我們並不是當陣列完全被使用完之後才進行擴容,因為如果陣列快被使用完之後,再加入資料產生雜湊衝突的可能性就會很大,因此我們通常會設定一個負載因子(load factor),當陣列的使用率超過這個值的時候就進行擴容,即當(陣列長度為L,陣列當中資料個數為S,負載因子為F):

\[S \ge L \times F \]

Java程式碼實現HashMap

先看一下我們需要的欄位

public class MyHashMap<K, V> {

  /**
   * 預設陣列的容量
   */
  static final int DEFAULT_CAPACITY = 16;

  /**
   * 預設負載因子
   */
  static final float DEFAULT_LOAD_FACTOR = 0.75f;

  /**
   * 雜湊表中陣列的最大長度
   */
  static final int MAXIMUM_CAPACITY = 1 << 30;

  /**
   * 真正儲存資料的陣列
   */
  Node<K, V>[] hashTable;

  /**
   * 雜湊表陣列當中儲存的資料的個數
   */
  int size;

  /**
   * 雜湊表當中的負載因子
   */
  float loadFactor = 0.75f;

  /**
   * 雜湊表擴容的閾值 = 雜湊表的長度 x 負載因子
   * 當超過這個值的時候進行擴容
   */
  int threshold;
}

put函式的實現

public void put(K key, V value) {

    if (null == key)
        throw new RuntimeException("雜湊表的鍵值不能為空");
    int hash = hash(key);
    // 這表示使用了空參建構函式 而且是第一次呼叫 put 函式
    if (null == hashTable) {
        hashTable = (Node<K, V>[]) new Node[DEFAULT_CAPACITY];
        // 計算陣列長度對應的擴容閾值
        threshold = (int)(hashTable.length * loadFactor);
        // 進行 & 運算得到資料下標
        hashTable[hash & (DEFAULT_CAPACITY - 1)] = new Node<K, V>(hash, key, value);
    }else {
        int n = hashTable.length;
        int idx = hash & (n - 1);
        // 如果陣列不為空 說明已經有資料存在了
        // 如果雜湊值相同的話說明是同一個物件了
        // 也可以進行跳出
        while (null != hashTable[idx] && !key.equals(hashTable[idx].key))
            idx = (idx + 1) & (n - 1);
        // 如果是通過 null != hashTable[idx] 條件跳出則是新新增資料
        // 如果是通過 !key.equals(hashTable[idx].key) 條件跳出
        // 則是因為 put 函式的 key 已經存在這次操作是更新資料
        hashTable[idx] = new Node<K, V>(hash, key, value);
    }
    // 如果陣列當中使用過的資料超過閾值
    if (++size > threshold) {
        resize();
    }
}

擴容(resize函式)實現

  /**
   * 如果你已經看懂 put 函式的程式碼,那這個程式碼就比較簡單了
   * 因為只是單純的將原陣列的資料重新進行雜湊並且加入到新陣列
   */
  private void resize() {
    int n = (hashTable.length << 1);
    threshold = (int) (n * loadFactor);
    Node<K, V>[] oldTable = hashTable;
    hashTable = (Node<K, V>[]) new Node[n];
    for (int i = 0; i < oldTable.length; i++)  {
      if (null == oldTable[i])
        continue;
      Node<K, V> node = oldTable[i];
      int idx = node.hash & (n - 1);
      while (null != hashTable[idx] && !node.key.equals(hashTable[idx].key))
        idx = (idx + 1) & (n - 1);
      hashTable[idx] = node;
    }
  }

get函式實現

public V get(K key) {
    if (null == key)
        throw new RuntimeException("查詢的鍵值不能為空");
    int hash = hash(key);
    int n = hashTable.length;
    int idx = hash & (n - 1);
    if (null == hashTable[idx])
        return null;
    // 這裡同樣需要進行線性探測
    // 因為雜湊值相同時 key 值
    // 不一定相同 因為會有雜湊衝突
    for (;;) {
        // 當陣列當中的資料不為空
        // 且資料當中的雜湊值和傳入 key
        // 的雜湊值相等 而且鍵值相等
        // 就是找到了對應的資料
        if (null != hashTable[idx]
            && hash == hashTable[idx].hash
            && key.equals(hashTable[idx].key))
            break;
        idx = (idx + 1) & (n -1);
    }
    return hashTable[idx].value;
}

完整程式碼

import java.util.*;

public class MyHashMap<K, V> {

  /**
   * 預設陣列的容量
   */
  static final int DEFAULT_CAPACITY = 16;

  /**
   * 預設負載因子
   */
  static final float DEFAULT_LOAD_FACTOR = 0.75f;

  /**
   * 雜湊表中陣列的最大長度
   */
  static final int MAXIMUM_CAPACITY = 1 << 30;

  /**
   * 真正儲存資料的陣列
   */
  Node<K, V>[] hashTable;

  /**
   * 雜湊表陣列當中儲存的資料的個數
   */
  int size;

  /**
   * 雜湊表當中的負載因子
   */
  float loadFactor = 0.75f;

  /**
   * 雜湊表擴容的閾值 = 雜湊表的長度 x 負載因子
   * 當超過這個值的時候進行擴容
   */
  int threshold;

  /**
   * 返回第一個大於或者等於 capacity 且為 2 的整數次冪的那個數
   * @param capacity
   * @return
   */
  static int roundUp(int capacity) {
    int n = capacity - 1;
    n |= n >>> 1;
    n |= n >>> 2;
    n |= n >>> 4;
    n |= n >>> 8;
    n |= n >>> 16;
    // 如果最終得到的資料小於 0 則初始長度為 1
    // 如果長度大於我們所允許的最大的容量 則將初始長度設定為我們
    // 所允許的最大的容量
    // MAXIMUM_CAPACITY = 1 << 30;
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
  }

  private static class Node<K, V> {
    /**
     * 用於儲存我們計算好的 key 的雜湊值
     */
    final int hash;

    /**
     * Key Value 中的 Key 物件
     */
    final K key;

    /**
     * Key Value 中的 Value 物件
     */
    V value;

    /**
     * hash 是鍵值 key 的雜湊值  key 是鍵 value 是值
     * @param hash
     * @param key
     * @param value
     */
    public Node(int hash, K key, V value) {
      this.hash = hash;
      this.key = key;
      this.value = value;
    }

    public V setValue(V newValue) {
      V oldValue = newValue;
      value = newValue;
      return oldValue;
    }

    @Override
    public String toString() {
      return key + "=" + value;
    }
  }

  public MyHashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR;
  }

  public MyHashMap(int initialCapacity, float loadFactor) {
    if (initialCapacity <= 0) {
      throw new RuntimeException("初始化長度不能小於0");
    }
    if (initialCapacity > MAXIMUM_CAPACITY)
      initialCapacity = MAXIMUM_CAPACITY;

    initialCapacity = roundUp(initialCapacity);
    hashTable = (Node<K, V>[]) new Node[initialCapacity];
    this.loadFactor = loadFactor;
    threshold = (int) (loadFactor * initialCapacity);
  }

  public MyHashMap(int capacity) {
    this(capacity, DEFAULT_LOAD_FACTOR);
  }

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

  public void put(K key, V value) {

    if (null == key)
      throw new RuntimeException("雜湊表的鍵值不能為空");
    int hash = hash(key);

    if (null == hashTable) {
      hashTable = (Node<K, V>[]) new Node[DEFAULT_CAPACITY];
      threshold = (int)(hashTable.length * loadFactor);
      hashTable[hash & (DEFAULT_CAPACITY - 1)] = new Node<K, V>(hash, key, value);
    }else {
      int n = hashTable.length;
      int idx = hash & (n - 1);
      while (null != hashTable[idx] && !key.equals(hashTable[idx].key))
        idx = (idx + 1) & (n - 1);
      hashTable[idx] = new Node<K, V>(hash, key, value);
    }
    if (++size > threshold) {
      resize();
    }
  }

  private void resize() {
    int n = (hashTable.length << 1);
    threshold = (int) (n * loadFactor);
    Node<K, V>[] oldTable = hashTable;
    hashTable = (Node<K, V>[]) new Node[n];
    for (int i = 0; i < oldTable.length; i++)  {
      if (null == oldTable[i])
        continue;
      Node<K, V> node = oldTable[i];
      int idx = node.hash & (n - 1);
      while (null != hashTable[idx] && !node.key.equals(hashTable[idx].key))
        idx = (idx + 1) & (n - 1);
      hashTable[idx] = node;
    }
  }

  public V get(K key) {
    if (null == key)
      throw new RuntimeException("查詢的鍵值不能為空");
    int hash = hash(key);
    int n = hashTable.length;
    int idx = hash & (n - 1);
    if (null == hashTable[idx])
      return null;
    for (;;) {
      if (null != hashTable[idx]
          && hash == hashTable[idx].hash
          && key.equals(hashTable[idx].key))
        break;
      idx = (idx + 1) & (n -1);
    }
    return hashTable[idx].value;
  }

  @Override
  public String toString() {
    StringBuilder builder = new StringBuilder();
    builder.append("[");
    for (int i = 0; i < hashTable.length; i++) {
      builder.append(hashTable[i]);
      builder.append(", ");
    }
    builder.delete(builder.length() - 2, builder.length());
    builder.append("]");
    return builder.toString();
  }
}

測試我們自己實現的HashMap

測試程式碼:

public static void main(String[] args) {

    MyHashMap<String, Integer> map = new MyHashMap<>();
    Random random = new Random();
    HashMap<String, Integer> map1 = new HashMap<>();
    ArrayList<String> strs = new ArrayList<>();
    ArrayList<Integer> list = new ArrayList<>();
    for (int i = 0; i < 1000000; i++) {
        String s = UUID.randomUUID().toString().substring(0, 4);
        int n = random.nextInt(1000);
        strs.add(s);
        list.add(n);
    }
    System.out.println("開始測試插入 put 函式");
    long start = System.currentTimeMillis();
    for (int i = 0; i < list.size(); i++) {
        map1.put(strs.get(i), list.get(i));
    }
    long end = System.currentTimeMillis();
    System.out.println("HashMap:花費的時間 = " + (end - start));

    start = System.currentTimeMillis();
    for (int i = 0; i < list.size(); i++) {
        map.put(strs.get(i), list.get(i));
    }
    end = System.currentTimeMillis();
    System.out.println("MyHashMap:花費的時間 = " + (end - start));

    System.out.println("開始測試查詢 get 函式");

    start = System.currentTimeMillis();
    for (int i = 0; i < list.size(); i++) {
        map.get(strs.get(i));
    }
    end = System.currentTimeMillis();
    System.out.println("MyHashMap:花費的時間 = " + (end - start));

    start = System.currentTimeMillis();
    for (int i = 0; i < list.size(); i++) {
        map1.get(strs.get(i));
    }
    end = System.currentTimeMillis();
    System.out.println("HashMap:花費的時間 = " + (end - start));
}

輸出結果:

開始測試插入 put 函式
HashMap:花費的時間 = 232
MyHashMap:花費的時間 = 324
開始測試查詢 get 函式
MyHashMap:花費的時間 = 186
HashMap:花費的時間 = 222

從上面的結果可以看出來我們自己實現的HashMap在插入資料的時候花費的時間比較長,JDKHashMap使用的是鏈地址法,他們擴容的次數肯定會比我們少,因為我們一個位置只能放一個資料,而JDK的能放多個。

但是我們查詢的時候效率會高一點,因為JDK的雜湊表還涉及連結串列的操作(可能還涉及紅黑樹),因此我們的效率可能會高一點。

總結

在本篇文章當中我們自己實現了一個線性探測的雜湊表,但是我們並沒有實現remove函式,大家可以自己去實現這個函式,也不太困難!!!整篇文章的內容主要包含以下內容:

  • 節點Node的設計。
  • 陣列長度的設計。
  • roundUp函式設計。
  • 雜湊函式的設計。
  • 擴容機制。

本篇還是比較多的,希望大家有所收穫,我是LeHung,我們下期再見!!!

更多精彩內容合集可訪問:https://github.com/Chang-LeHung/CSCore

相關文章