Java 底層實現 HashTable 哈希表

HashTable 哈希表


1、什麼是哈希表

  這裏我們首先借用一個leetcode上的一道題目來引入哈希表的概念。

LeetCode例題

  我們知道解決這個問題可以使用一個我們之前的Map映射來解決這個問題,保存出現的每一個字符作爲鍵值,出現的次數作爲值,創建類似於 Map<Character, Integer> 這樣的映射。
使用Map程序實現:

class Solution {
    public int firstUniqChar(String s) {
        Map<Character, Integer> map = new TreeMap<>();
        for(int i = 0; i < s.length(); i++){
            char c = s.charAt(i);
            map.put(c, map.get(c) == null ? 1: map.get(c) + 1);
        }
        for(int i = 0; i < s.length(); i++)
            if(map.get(s.charAt(i)) == 1)
                return i;
        return -1;
    }
}

  可以發現最後運行的效率特別低,所以可以對此進行更改底層實現。在題目的下方標註着:
注意事項:您可以假定該字符串只包含小寫字母。
  所以說可以使用固定的數組來進行實現。數組大小爲 26,數組索引的方式char - 'a'通過這樣的方式來找到對應的字符。
使用數組程序實現:

class Solution {
    public int firstUniqChar(String s) {
        int[] code = new int[26];
        for (int i = 0; i < s.length(); i++)
            code[s.charAt(i) - 'a']++;
        for (int i = 0; i < s.length(); i++)
            if (code[s.charAt(i) - 'a'] == 1)
                return i;
        return -1;
    }
}

  使用數組(隨機訪問)實現起來的效率遠大於Map的實現效率。char - 'a'就是哈希的哈希函數。哈希表就是將真正關心的內容存儲到數組當中,時間複雜度就變成了O(1)級別。

總結:
  哈希函數就是將“鍵”轉換爲“索引”。而且可以發現我們在哈希表上查找元素的時候時間複雜度爲O(1)級別的。所以說我們最後實現的哈希表得時間複雜度爲O(1)級別的。在哈希表上的操作難點也就在與如何解決哈希衝突問題上。

2、哈希函數的設計原則

哈希函數的設計主要從以下幾個方面考慮:

  1. 一致性:如果a=b,則hash(a) = hash(b),反之不一定成立,對應哈希衝突
  2. 高效性:計算速度快,簡便
  3. 均勻性:哈希值分佈均勻

2.1、整型

小範圍

  • 小範圍的正整數可以直接進行使用
  • 小範圍的負整數可以通過偏移進行歸正

大範圍
  通常的方法是對其進行取模操作,只保留最後的幾位,或者具有特徵的幾位。
但是對於取模的值相對來說是比較講究的,模值取得越大,相對來說產生哈希衝突的概率越小,具體模值的取值有一個基本規則:

取一個素數(數學領域的範疇,這裏不做介紹)

具體素數取什麼,有人已經給出了結論。

哈希函數模值的取值
  • lwr:數據大小的下界
  • upr:數據大小的上界 也就是數據大小位於(lwr - upr)之間
  • err:錯誤率
  • primer:模值的取值(素數)

2.2、浮點型

  我們知道在我們的計算機當中,對於浮點數的存儲也是採用二進制的形式,只不過計算機解析成了浮點數。下面以32位計算機爲例。

浮點數的存儲

存儲方式同整型相同,所以仍然可以將浮點數按照整型進行處理。

2.3、字符串

  這裏字符串依然按照整型進行處理。我們知道整型有下面的變換公式:
2020=2×103+0×102+2×101+2×100 2020 = 2 \times 10^3 + 0 \times 10^2 + 2 \times 10^1 + 2 \times 10^0
字符串依然可以使用這種方式:
java=j×263+a×262+v×261+a×260 java = j \times 26^3 + a \times 26^2 + v \times 26^1 + a \times 26^0
  這裏我們默認只有小寫字母,使用26進制,如果字符串中包含更加複雜的字符,例如大小寫,標點符號等等。那麼可以使用下面的形式:
Java=J×I3+a×I2+v×I1+a×I0 Java = J \times I^3 + a \times I^2 + v \times I^1 + a \times I^0
   I 表示進制,I的數值表示所代表類型的大小。對於字符串產生的這種大整型,我們可以按照2.1節那種,對大整型進行取模。
hash(Java)=(J×I3+a×I2+v×I1+a×I0)%M hash(Java) = (J \times I^3 + a \times I^2 + v \times I^1 + a \times I^0) \% M
化簡可得:
hash(Java)=(((J×I+a)×I+v)×I+a)%M hash(Java) = (((J \times I + a)\times I + v) \times I + a) \% M

  但是上面的式子在不斷進行乘法運算的時候,導致在取模之前的數值會變得十分巨大,所以對上面的式子繼續進行化簡。
hash(Java)=(((J%M×I+a)%M×I+v)%M×I+a)%M hash(Java) = (((J \%M \times I + a)\%M\times I + v)\%M \times I + a) \% M

根據上面的式子我們可以寫出程序實現:

int hash = 0;
for(int i = 0; i < s.length; i++)
    hash = (hash * I + s.charAt(i)) % M

2.3、Java 中的 hashCode()

  在Java中的所有類都具有的hasCode()方法,因爲hasCode方法在公共基類Object類中,類似於toString()方法。

注意:在Java語言當中hasCode返回類型爲int,是允許返回負值的。

  當然可以重載hasCode的方法,這樣我們在創建類似於HashSet這類底層使用哈希存儲的容器類的時候就會調用自己的實現的hasCode方法。
下面以一個Dog類來說明Java裏的hasCode();

class Dog{
    private String name;
    private int age;
    private Date birthday;
    public Dog(){
        name = "WangCai";
        age = 4;
        birthday = new Date(2020, Calendar.JUNE, 16);
    }

    @Override
    public int hashCode() {  //重載hashCode
        return name.hashCode() + age + birthday.hashCode();
    }
}

  這樣我們在調用 HashSet <Dog> set = new HashSet<>(),就會調用自己的hashCode()進行哈希存儲。

3、哈希衝突的處理——鏈地址法

  根據前面講解的哈希表的哈希函數,需要對大整型進行取模的操作,其實最後的存儲的數據大小就是模值。存儲的方式就是採用和動態數組類似的方式,底層採用數組的方式存儲。

底層數組存儲

具體存儲到哪個索引的位置就要根據我們的哈希函數設計了。

  在前面中說到 Java 中的 hasCode 可以返回負值,但是數組索引沒有負值,所以需要將最前面的符號位設置爲零。
添加兩個元素後:

添加兩個元素

  當添加第三個元素(s3)的時候,假設獲得的索引依然是 2 。這樣就產生了哈希衝突。那麼我們就需要將新添加的元素 s3 放到 s1 後面,類似於鏈表的形式。

哈希衝突

  當然前面的形式我們採用鏈表的形式,其實對於每一個索引位置我們可以索引一個樹結構,也就降低了時間複雜度。類似於下面的形式。

TreeMap

  實際上HashMap 的底層就是 TreeMap 數組;HashSet 的底層就是 TreeSet 數組;

4、HashTable的實現

  這裏我們以HashMap的實現爲例,我們前面瞭解到HashMap的底層實現是TreeMap數組,所以我們的類中的變量就必須包含TreeMap數組。

4.1、初始化操作

類中的變量主要包含以下幾個內容:

  • hashTable 數組,裏面對應的類型爲TreeMap。
  • M:對應取模的值,也就是我們數組的大小
  • size:用來存儲用戶數據的大小
public class HashTable<K, V> {

    private TreeMap<K, V>[] hashTable;
    private int M;
    private int size;

    public HashTable(int M) {
        this.M = M;
        size = 0;
        hashTable = new TreeMap[M];
        for (int i = 0; i < M; i++)
            hashTable[i] = new TreeMap<>(); //對每一個TreeMap進行初始化
    }

    public HashTable(){
        this(97);
    }
}

4.2、哈希函數

  哈希函數主要是通過獲得key的hashCode()進行取模轉換後來獲得數組索引值。

private int hash(K key) {
    return (key.hashCode() & 0x7FFFFFFF) % M;
}

4.3、增刪改查操作

  所有的基本操作都是首先通過hash(key)獲得哈希值進而獲得索引值。在通過索引值獲得對應索引位置的TreeMap實例。然後對實例進行增刪改查操作。
增加元素:

public void add(K key, V value) {
    TreeMap<K, V>map = hashTable[hash(key)]; //獲得 TreeMap
    if (map.containsKey(key))
        map.put(key, value);
    else{
        map.put(key, value);
        size++;
        }
    }
}

刪除元素:

public V remove(K key) {
    TreeMap<K, V> map = hashTable[hash(key)];
    if (map.containsKey(key)) {
        size--;
        return map.remove(key);
    }
    return null;
}

更改元素:

public void set(K key, V value) {
    TreeMap<K, V> map = hashTable[hash(key)];
    if (map.containsKey(key))
        map.put(key, value);
}

查找元素:

public boolean contains(K key) {
    TreeMap<K, V> map = hashTable[hash(key)];
    return map.containsKey(key);
}

public V get(K key) {
    return hashTable[hash(key)].get(key);
}

5、動態空間處理

  最開始的時候我們說過我們在查找的元素時候時間複雜度爲O(1)。但是前面的實現經過分析可以得出來,時間複雜度並不爲O(1)級別。對於地址爲鏈表的形式,時間複雜度爲O(N/M);對於地址爲平衡樹的形式,時間複雜度爲O(log(N/M))。

  所以說我們的實現結構還可以進行優化。優化的方式就是動態空間法,類似於之前的動態數組的實現,底層的數組大小是根據用戶數據的大小進行動態改變的。

  按照之前實現動態數組的方法,引入resize函數,觸發resize函數的條件分別有以下兩個條件:

  • N\M >= upperTol:上界容忍度,觸發擴容操作
  • N\M <=lowwerTol:下屆容忍度,觸發縮容操作

下面我們就對上面的實現進行更改。

5.1、初始化操作

包含以下幾個私有變量:

  • capacity: 容量,這是動態更改數組大小的值,全部爲素數,取值來源於第2.1節整型。
  • upperTol:上界容忍度
  • lowerTol:下界容忍度
  • capacityIndex:當前capacity的索引值,進而表示當前數組大小
  • 其餘變量不再做解釋
public class HashTable<K, V> {  // K不具有可比較性

    private final int[] capacity = {
            53, 97, 193, 389, 769, 1543, 3079, 6151, 12289, 24593,
            49157, 98317, 196613, 393241, 786433, 1572869, 3145739,
            6291469,12582917, 25165843, 50331653, 100663319, 201326611,
            402653189, 805306457, 1610612741
    };

    private static final int upperTol = 10;
    private static final int lowerTol = 2;
    private int capacityIndex = 0;
    private TreeMap<K, V>[] hashTable;
    private int M;
    private int size;

    public HashTable() {
        this.M = capacity[capacityIndex];  //獲取當前數組大小
        size = 0;
        hashTable = new TreeMap[M];
        for (int i = 0; i < M; i++)
            hashTable[i] = new TreeMap<>();
    }
}

5.2、resize 更改容量操作

  同動態數組相同,將原來的數組複製到一個新的容器大小的數組中。通過遍歷就哈希表中的所有數據,重新添加到新的哈希表中。

private void resize(int newM) {
    TreeMap<K, V>[] newHashTable = new TreeMap[newM];
    for (int i = 0; i < newM; i++)
        newHashTable[i] = new TreeMap<>();
    int oldM = this.M;
    this.M = newM; //必須進行更新,後面用到hash()建立在新的M上
    for (int i = 0; i < oldM; i++) { //遍歷哈希表中所有元素
        TreeMap<K, V> map = hashTable[i];
        for (K key : map.keySet())
            newHashTable[hash(key)].put(key, map.get(key));
    }
    this.hashTable = newHashTable;
}

5.3、更新增刪改查操作

  由於導致數據變化的只有增刪操作,對於更改和查詢操作並不涉及,具體的更改只需要添加兩行代碼分別在增刪操作上。
增加操作:

if (size >= upperTol * M && capacityIndex + 1 < capacity.length)
    resize(capacity[++capacityIndex]);

刪除操作:

if (size < lowerTol * M && capacityIndex >= 1)
    resize(capacity[--capacityIndex]);

6、時間複雜度分析

6.1、未增加動態空間處理

  我們假設數組大小爲 M,用戶存儲的數組爲 N。對應着每個地址所存儲的元素個數 N / M(平均來說)。

類型 時間複雜度
地址爲鏈表 O(N/M)
地址爲平衡樹 O(log(N/M))

6.2、增加動態空間處理

下面主要是分析地址爲平衡樹的情況。
首先分析未發生擴容和縮容的過程:
  對於增刪改查四種操作時間複雜度都是O(log(N/M))級別的。但是我們設置了upperTol和lowerTol。lowerTol <= N\M <= upperTol。兩者都是自己設置的常數,所以說時間複雜度均是常數,都是O(1)級別的。
再分析發生擴容的情況:
  當我們進行resize操作以後,我們可以發現,oldM 和 newM 之間大約有兩倍的關係。

精簡版

  對於原本元素個數爲 upperTol * oldM = N 的元素增加到 upperTol * newM = 2 * N,才觸發一次resize操作,相當於進行了翻倍操作。resize的時間複雜度爲O(N)級別的,但是需要增加N個元素才觸發一次resize操作,所以根據均攤時間複雜度,增刪兩種操作的時間複雜度爲O(2)級別的複雜度,改查操作的時間複雜度爲O(1)級別。

綜上所述:
  增加動態空間處理後,所有操作的時間複雜度均爲O(1)級別。下面列一個詳細的表格:

操作 詳細時間複雜度 總時間複雜度
增刪操作(前提:未擴容縮容) O(lowerTol)~O(upperTol) O(1)
增刪操作 O(lowerTol)~O(upperTol) + O(1) O(1)
改查操作 O(1) O(1)

最後

更多精彩內容,大家可以轉到我的主頁:曲怪曲怪的主頁

或者關注我的微信公衆號:TeaUrn

源碼地址:可在公衆號內回覆 數據結構與算法源碼 即可獲得。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章