數據結構之哈希表

哈希表基礎

哈希表的英文叫「Hash Table」,咱們平時也叫它「散列表」或者「Hash 表」,是一種經常使用的數據結構。Java中的HashMapHashTable就是基於哈希表實現的。java

爲了學習哈希表,咱們先從LeetCode上一個的問題開始:node

這是LeetCode上的第387號問題:字符串中的第一個惟一字符。需求是:給定一個字符串,找到它的第一個不重複的字符,並返回它的索引。若是不存在,則返回 -1。案例:算法

s = "leetcode"
返回 0

s = "loveleetcode"
返回 2

這道題相對來講比較簡單,解題思路也比較多。這裏給出的一種思路就是聲明一個長度爲26的整型數組,該數組的索引 0 ~ 1 就對應着字母 a ~ z。首先,遍歷目標字符串,經過計算 char - 'a' 獲得字符所對應的數組索引,並將該索引的元素進行+1,這樣就實現了對出現的字符進行計數。最後,再遍歷一次目標字符串,一樣計算 char - 'a' 獲得對應的數組索引,並判斷該索引位置的數值是否爲1,爲1就表明已經找到第一個不重複的字符所在的索引了。編程

具體的實現代碼以下:數組

public int firstUniqChar(String s) {
    int[] freq = new int[26];
    for (int i = 0; i < s.length(); i++) {
        freq[s.charAt(i) - 'a']++;
    }

    for (int i = 0; i < s.length(); i++) {
        if (freq[s.charAt(i) - 'a'] == 1) {
            return i;
        }
    }

    return -1;
}

實際上這就是一種典型的哈希表思想,由於其中的 int[26] freq 就是一個哈希表。經過這個數組,咱們創建了每一個字符和一個數字之間的映射關係。bash

s.charAt(i) - 'a' 則是一個哈希函數,所謂哈希函數就是能夠基於某種規則對數據進行計算獲得數據所映射的位置。在咱們這個例子中,「數據」指的是字符串中的字符,「位置」則指的是數組中的索引。數據結構

經過這個簡單的計算咱們就能獲得字符所對應的數組索引,進而獲得字符所出現的次數,看得出來這個操做的時間複雜度是 $O(1)$。所以,訪問哈希表的時間複雜度也就是 $O(1)$。ide

哈希表用的是數組支持按照下標隨機訪問數據的特性,實現高效的數據操做。因此哈希表其實就是數組的一種擴展,由數組演化而來。能夠說,若是沒有數組,就沒有哈希表。函數

稍微總結一下哈希表就是:一種基於數組實現的線性結構,經過哈希函數來實現尋址,可以創建一種「數據」與「位置」的映射關係。利用的是數組支持按照下標隨機訪問元素的特性,一般狀況下查詢操做具備 $O(1)$ 的時間複雜度。性能


哈希函數的設計

從上一小節的例子咱們能夠看到,哈希函數在哈希表中起着很是關鍵的做用。在該例子中,哈希函數就是一個簡單的運算,比較簡單,也比較容易想到。

可是若是要設計一個「工業級」的哈希函數仍是比較難的,對於一個通用的數據結構,咱們須要考慮不一樣的數據類型:字符串、浮點數、日期等以及不一樣的數據格式:身份證號、單詞、車牌號等,對於這種不一樣的狀況如何獲得能用於哈希計算的依據。因此對於一些特殊領域,有特殊領域的哈希函數設計方式,甚至還有專門的論文。

除此以外,咱們還要設計哈希函數的計算規則,如何使數據能在數組中均勻的分佈。而後還得思考如何解決哈希衝突,由於要想找到一個不一樣的 key 對應的哈希值都不同的哈希函數,幾乎是不可能的。即使像業界著名的MD五、SHA、CRC等哈希算法,也沒法徹底避免這種哈希衝突。並且,由於數組的存儲空間有限,也會加大哈希衝突的機率。

這裏總結下幾點哈希函數設計的基本要求:

  • 哈希函數計算獲得的哈希值是一個非負整數
  • 高效性:計算高效簡便
  • 均勻性:哈希值均勻分佈
  • 一致性:
    • 若是 $key1 = key2$,那 $hash(key1) == hash(key2)$
    • 若是 $key1 ≠ key2$,那 $hash(key1) ≠ hash(key2)$

對於 「若是 $key1 ≠ key2$,那 $hash(key1) ≠ hash(key2)$」 只是邏輯上的體現,由於咱們以前也說了,在真實狀況下幾乎沒法找到一個完美的無衝突的散列函數,即使能找到,付出的時間成本、計算成本也是很大的,因此針對散列衝突問題,咱們須要經過其餘途徑來解決。

取模在哈希函數中的應用

這裏介紹一種簡單的哈希函數設計思路,那就是取模。對一個合適的數進行取模能獲得一個小範圍的整數,即使獲得負整數也能經過簡單的偏移規則轉換成正整數。但你可能會有疑問了,數據類型有各類各樣,都能進行取模嗎?應該對什麼樣的數進行取模?

對於第一個問題,其實對於各類各樣的數據類型,咱們均可以將其轉換爲相應的整數。對於第二個問題,通常須要視狀況而定。小整數對什麼數取模差異不大,甚至都不須要取模,直接每一個數字對應一個索引。如同上一小節中的例子,每一個單詞對應一個數組索引就能夠了。

而對於大整型,例如身份證號、手機號等,這種沒法直接對應索引的就須要進行取模了,一個簡單的解決辦法就是模一個素數。至於爲何是素數,這是一個數學上的問題,超出了本文的討論範圍,有興趣的能夠自行了解一下。下面這個網站列出了不一樣規模的整數用於取模的最佳素數:

一樣的,浮點型也能夠轉換成整型進行取模,由於在計算機中都是32位或者64位的二進制表示,只不過計算機解析成了浮點數,因此轉成整型處理便可。

字符串類型則相對來講稍微複雜點,但一樣也是轉成整型,只是套路不太同樣。對於數字內容的字符串,例如 166 ,咱們能夠將其每一個字符想象成十進制的表示法,經過以下方式轉換成整數:

166 = 1 * 10^2 + 6 * 10^1 + 6 * 10^0

這個計算很簡單,由於 char 類型是可運算的,將每個字符乘以進制數的 n 次方再加起來就能夠了,這裏的 n 取值是該字符後面的字符數量。例如,1 這個字符它後面還有兩個字符,那麼這個 n 就是 2 ,而後由於是 10 進製表示,因此其進制數是 10,也就得出了 1 * 10^2,其餘字符以此類推,最後再進行相加就能將該字符串轉換成整數了。

同理,字符串內容爲單詞的計算方式也是同樣,只不過進制數須要改變一下,咱們能夠將字母當作是26進制的。以下示例:

code = c * 26^3 + o * 26^2 + d * 26^1 + e * 26^0

這個進制數根據字符串內容不一樣是不固定的,例如要包含大寫字母,可能進制數就須要設置爲52,要包含標點符號、特殊符號等還須要將這個值設置得再大一些。總結成公式大概就是這樣:

code = c * B^3 + o * B^2 + d * B^1 + e * B^0

所以,哈希函數的表示以下:

hash(code) = (c * B^3 + o * B^2 + d * B^1 + e * B^0) % M

這個計算方式還有優化的空間,只須要簡單的變換一下就能節省一些計算。以下所示:

hash(code) = ((((c * B) + o) * B + d) * B + e) % M

可是這樣的計算對於整型來講還會有溢出的問題,因此咱們須要將取模計算放進去,最終得出的哈希函數以下:

hash(code) = ((((c % M) * B + o) % M * B + d) % M * B + e) % M

轉換成代碼的表達以下:

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

解決了字符串的取模問題,複合類型也就簡單了,由於和字符串相似。咱們能夠經過相似於 toString 的方式將複合類型轉換爲字符串,而後再根據上述規則轉換成整型後取模。例如,日期類型:

Date: year, month, day, hour
hash(date) = ((((date.year % M) * B + date.month) % M * B + date.day) % M * B + date.hour) % M

Java中的 hashCode 方法

咱們知道在Java中,能夠經過重寫 hashCode 方法來提供一個對象的哈希值。而且基礎數據類型的包裝類如IntegerDoubleString等都已經重寫了 hashCode 方法,對於這些類型直接調用便可。

對於咱們本身定義的類型來講,就須要自行重寫 hashCode 方法了。而一般一個對象裏的字段都由基礎類型或其包裝類組成,所以也能夠利用這些類型已有的 hashCode 方法。以下示例:

public class Student {

    int grade;
    int cls;
    String firstName;
    String lastName;

    @Override
    public int hashCode() {
        // 哪些字段參與計算
        Object[] a = new Object[]{grade, cls, firstName, lastName};

        // 按照實際狀況取合適的進制數,這裏是仿照jdk源碼取的31
        int B = 31;
        int result = 1;
        for (Object element : a) {
            result = result * B + (element == null ? 0 : element.hashCode());
        }

        return result;
    }
}

重寫了 hashCode 方法後,咱們還須要重寫 equals 方法。由於不一樣的兩個對象有可能哈希值是相等的,這也就是哈希衝突。此時咱們就須要進一步經過 equals 方法來比較兩個對象的內容是否相等,以此來區別它們是否是同一個對象。由此,咱們能夠得出一個結論:hashCode 相等不必定是同一個對象,hashCodeequals 都相等的狀況下才能認爲是同一個對象, 而 equals 相等時 hashCode 必然相等。

重寫 equals 方法也是有套路的,並且如今大部分IDE都支持自動生成,這裏就不過多解釋了。代碼以下:

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

    Student student = (Student) o;
    return grade == student.grade &&
            cls == student.cls &&
            Objects.equals(firstName, student.firstName) &&
            Objects.equals(lastName, student.lastName);
}

鏈地址法 Separate Chaining

如今咱們已經瞭解清楚了哈希函數的設計,以及在Java中如何取得一個對象的哈希值、如何比較兩個對象是否相等。接下來咱們就能夠進一步看看如何解決哈希衝突的問題了,咱們經常使用的哈希衝突解決方法有兩類,開放尋址法(open addressing)和鏈地址法(separate chaining)也叫鏈表法或拉鍊法。

開放尋址法的核心思想是,若是出現了散列衝突,咱們就從新探測一個空閒位置,將其插入。但這種方法並不經常使用,由於相對複雜且侷限性大,通常用於小數據量的狀況,Java中的 ThreadLocalMap 用的是這種方法。

而鏈表法則是一種更加經常使用的哈希衝突解決辦法,相比開放尋址法,它要簡單不少。Java中的 HashMapHashTable 就是用的這種方法。咱們來看這個圖,在哈希表中,每一個「桶(bucket)」或者「槽(slot)」會對應一條鏈表,全部哈希值相同的元素咱們都放到相同槽位對應的鏈表中:
數據結構之哈希表

當插入的時候,咱們只須要經過哈希函數計算出對應的哈希槽位,將其插入到對應鏈表中便可,因此插入的時間複雜度是 $O(1)$。當查找、刪除一個元素時,咱們一樣經過哈希函數計算出對應的槽,而後遍歷鏈表查找或者刪除。那查找或刪除操做的時間複雜度是多少呢?

實際上,這兩個操做的時間複雜度跟鏈表的長度 k 成正比,也就是 $O(k)$。對於分佈比較均勻的哈希函數來講,理論上講,$k=n/m$,其中 n 表示哈希表中數據的個數,m 表示哈希表中「槽」的個數。

當哈希衝突比較大,鏈表達到必定長度時,咱們能夠將其轉換成一棵樹,例如紅黑樹,避免查詢效率退化到 $O(n)$。這也是Java8爲何會在 HashMap 中引入紅黑樹的緣由。


實現屬於咱們本身的哈希表

有了前面的鋪墊後,咱們對哈希表的幾個核心要點有了必定的瞭解,如今咱們就來實現屬於咱們本身的哈希表。具體代碼以下:

package hash;

import java.util.TreeMap;

/**
 * 哈希表
 * 
 * @author 01
 * @date 2021-01-20
 **/
public class HashTable<K, V> {

    /**
     * 藉助TreeMap數組做爲實際的存儲容器
     * 目的是不須要本身去實現紅黑樹了,只須要關注哈希表實現自己
     */
    private TreeMap<K, V>[] table;

    /**
     * 哈希表的大小
     */
    private int capacity;

    /**
     * 元素的個數
     */
    private int size;

    public HashTable(int capacity) {
        this.capacity = capacity;
        this.size = 0;
        this.table = new TreeMap[capacity];
        // 初始化數組
        for (int i = 0; i < capacity; i++) {
            this.table[i] = new TreeMap<>();
        }
    }

    public HashTable() {
        // 默認使用一個素數做爲初始大小
        this(97);
    }

    /**
     * 哈希函數,計算出key所對應的數組索引
     */
    private int hash(K key) {
        // 消除hashCode的符號,即轉換成正整數,由於hashCode有多是負數,而後對capacity取模
        return (key.hashCode() & Integer.MAX_VALUE) % capacity;
    }

    public int getSize() {
        return size;
    }

    /**
     * 添加元素
     */
    public void add(K key, V value) {
        TreeMap<K, V> node = table[hash(key)];
        if (node.containsKey(key)) {
            // key已存在,則更新
            node.put(key, value);
        }

        // 不然就是新增,維護下size
        node.put(key, value);
        size++;
    }

    /**
     * 刪除元素
     */
    public V remove(K key) {
        TreeMap<K, V> node = table[hash(key)];
        V ret = null;
        if (node.containsKey(key)) {
            ret = node.remove(key);
            size--;
        }

        return ret;
    }

    /**
     * 更新元素
     */
    public void set(K key, V value) {
        TreeMap<K, V> node = table[hash(key)];
        if (!node.containsKey(key)) {
            throw new IllegalArgumentException(key + " doesn't exist!");
        }

        node.put(key, value);
    }

    /**
     * 判斷key是否存在
     */
    public boolean containsKey(K key) {
        return table[hash(key)].containsKey(key);
    }

    /**
     * 根據key獲取value
     */
    public V get(K key) {
        return table[hash(key)].get(key);
    }
}

哈希表的動態空間處理

上一小節中咱們已經實現了一個基礎的哈希表,爲了簡化實現使用了Java的 TreeMap 來做爲裝載數據的容器,免得本身去實現鏈表或紅黑樹了,讓咱們只須要關注哈希表的實現自己。但它依舊是個數組,咱們也還得實現哈希函數計算出key所對應的數組索引,以及相應的增刪查改方法。

正由於咱們將 TreeMap 聲明爲一個數組,因此在初始化後,數組的長度就是固定的了。隨着不斷地添加數據,哈希表中的數據愈來愈密集,哈希衝突的機率就會愈來愈大,從而致使每一個 TreeMap 裏存儲愈來愈多的數據,會使得哈希表的時間複雜度從 $O(1)$ 退化至 $O(logn)$,若是使用的是鏈表的話會退化至 $O(n)$。

爲了解決這個問題,咱們就要像實現動態數組那樣,對哈希表實現動態擴容。擴容到合適的大小後能夠減小哈希衝突的機率,將哈希表維持在一個較好的性能水平,這也是設計哈希表時很是關鍵的一個要素。

可是哈希表的動態擴容不像實現數組的動態擴容那麼簡單,數組動態擴容的條件只須要判斷數組是否滿了。而在哈希表中,初始化時就會填滿數組,數組中存放的是一個個的 TreeMap ,這裏的 TreeMap 能夠想象爲一顆樹的根節點,咱們添加的元素是掛到 TreeMap 裏的。

因此咱們的擴容條件就要變成:當數組中平均每一個 TreeMap 承載的元素達到必定的程度時,就進行擴容。這個「程度」是一個具體的數值,一般稱之爲負載因子。即知足 元素個數 / 數組長度 &gt;= 負載因子 時,咱們就須要對哈希表進行擴容。同理,有擴容就有縮容,咱們須要進行一個反向操做,當知足 元素個數 / 數組長度 &lt; 負載因子 時,進行縮容。

基於這種方式,咱們改造一下以前的哈希表,爲其添加動態擴縮容功能。具體代碼以下(僅貼出有改動的代碼):

public class HashTable<K, V> {

    ...

    /**
     * 負載因子
     */
    private static final double loadFactor = 0.75;

    /**
     * 初始容量
     */
    private static final int initCapacity = 16;

    ...

    public HashTable() {
        this(initCapacity);
    }

    ...

    /**
     * 添加元素
     */
    public void add(K key, V value) {
        TreeMap<K, V> node = table[hash(key)];
        if (node.containsKey(key)) {
            // key已存在,則更新
            node.put(key, value);
        }

        // 不然就是新增,維護下size
        node.put(key, value);
        size++;

        // 大於負載因子進行擴容
        if (size >= loadFactor * capacity) {
            // 每次擴容兩倍
            resize(2 * capacity);
        }
    }

    /**
     * 刪除元素
     */
    public V remove(K key) {
        TreeMap<K, V> node = table[hash(key)];
        V ret = null;
        if (node.containsKey(key)) {
            ret = node.remove(key);
            size--;

            // 小於負載因子進行縮容,並保證數組長度不小於initCapacity
            if (size < loadFactor * capacity && capacity / 2 >= initCapacity) {
                // 每次縮容兩倍
                resize(capacity / 2);
            }
        }

        return ret;
    }

    /**
     * 動態擴縮容
     */
    private void resize(int newCapacity) {
        TreeMap<K, V>[] newTable = new TreeMap[newCapacity];
        for (int i = 0; i < newCapacity; i++) {
            newTable[i] = new TreeMap<>();
        }

        this.capacity = newCapacity;
        // 將本來數組中的數據遷移到新的數組中
        for (TreeMap<K, V> node : table) {
            // 爲保證元素可以均勻分佈在新的數組中,在遷移元素時,須要對元素從新進行哈希計算
            for (Map.Entry<K, V> entry : node.entrySet()) {
                newTable[hash(entry.getKey())]
                        .put(entry.getKey(), entry.getValue());
            }
        }
        this.table = newTable;
    }

    ...
}

哈希表更復雜的動態空間處理方法

在上一小節中,咱們爲哈希表添加了動態擴縮容的功能。你可能會有疑問,每次擴縮容都是原來的兩倍,那麼 capacity 不就沒法保持是一個素數了嗎?是的,若是隻是簡單的設置爲兩倍,就沒法讓 capacity 保持是一個素數,甚至不會是一個對取模友好的數。這會使得哈希函數的計算分佈不均勻,增長哈希衝突的機率。

因此咱們能夠再對其作進一步的改造,在對象中聲明一個素數表,當擴容到不一樣的規模時就從該素數表中取不一樣的素數做爲新的數組長度。最終咱們實現的哈希表代碼以下:

package hash;

import java.util.Map;
import java.util.TreeMap;

/**
 * 哈希表
 *
 * @author 01
 * @date 2021-01-20
 **/
public class HashTable<K, V> {

    /**
     * 藉助TreeMap數組做爲實際的存儲容器
     * 目的是不須要本身去實現紅黑樹了,只須要關注哈希表實現自己
     */
    private TreeMap<K, V>[] table;

    /**
     * 哈希表的大小
     */
    private int capacity;

    /**
     * 負載因子
     */
    private static final double loadFactor = 0.75;

    /**
     * 初始容量所對應的素數表索引
     */
    private int capacityIndex = 0;

    /**
     * 元素的個數
     */
    private int size;

    /**
     * 從 https://planetmath.org/goodhashtableprimes 網站
     * 中獲取到的不一樣規模的整數用於取模的最佳素數,咱們基於這裏的素數
     * 做爲擴縮容的大小,使得每次擴縮容能夠將數組的長度保持始終是素數
     */
    private final int[] capacityArray = {
            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
    };

    public HashTable() {
        this.capacity = capacityArray[capacityIndex];
        this.size = 0;
        this.table = new TreeMap[capacity];
        // 初始化數組
        for (int i = 0; i < capacity; i++) {
            this.table[i] = new TreeMap<>();
        }
    }

    /**
     * 哈希函數,計算出key所對應的數組索引
     */
    private int hash(K key) {
        // 消除hashCode的符號,即轉換成正整數,由於hashCode有多是負數,而後對 capacity 取模
        return (key.hashCode() & Integer.MAX_VALUE) % capacity;
    }

    public int getSize() {
        return size;
    }

    /**
     * 添加元素
     */
    public void add(K key, V value) {
        TreeMap<K, V> node = table[hash(key)];
        if (node.containsKey(key)) {
            // key已存在,則更新
            node.put(key, value);
        }

        // 不然就是新增,維護下size
        node.put(key, value);
        size++;

        // 大於負載因子進行擴容,並確保不會發生數組越界
        if (size >= loadFactor * capacity &&
                capacityIndex + 1 < capacityArray.length) {
            // 每次擴容的大小是下一個素數
            capacityIndex++;
            resize(capacityArray[capacityIndex]);
        }
    }

    /**
     * 刪除元素
     */
    public V remove(K key) {
        TreeMap<K, V> node = table[hash(key)];
        V ret = null;
        if (node.containsKey(key)) {
            ret = node.remove(key);
            size--;

            // 小於負載因子進行縮容,並確保不會發生數組越界
            if (size < loadFactor * capacity && capacityIndex - 1 >= 0) {
                // 每次縮容的大小是上一個素數
                capacityIndex--;
                resize(capacityArray[capacityIndex]);
            }
        }

        return ret;
    }

    /**
     * 動態擴縮容
     */
    private void resize(int newCapacity) {
        TreeMap<K, V>[] newTable = new TreeMap[newCapacity];
        for (int i = 0; i < newCapacity; i++) {
            newTable[i] = new TreeMap<>();
        }

        this.capacity = newCapacity;
        // 將本來數組中的數據遷移到新的數組中
        for (TreeMap<K, V> node : table) {
            // 爲保證元素可以均勻分佈在新的數組中,在遷移元素時,須要對元素從新進行哈希計算
            for (Map.Entry<K, V> entry : node.entrySet()) {
                newTable[hash(entry.getKey())]
                        .put(entry.getKey(), entry.getValue());
            }
        }
        this.table = newTable;
    }

    /**
     * 更新元素
     */
    public void set(K key, V value) {
        TreeMap<K, V> node = table[hash(key)];
        if (!node.containsKey(key)) {
            throw new IllegalArgumentException(key + " doesn't exist!");
        }

        node.put(key, value);
    }

    /**
     * 判斷key是否存在
     */
    public boolean containsKey(K key) {
        return table[hash(key)].containsKey(key);
    }

    /**
     * 根據key獲取value
     */
    public V get(K key) {
        return table[hash(key)].get(key);
    }
}

完成了動態擴縮容功能後,咱們能夠簡單分析下添加操做的時間複雜度。插入一個數據,最好狀況下,不須要擴容,最好時間複雜度是 $O(1)$。最壞狀況下,哈希表負載達到負載因子,啓動擴容,咱們須要從新申請內存空間,從新計算哈希位置,而且搬移數據,因此時間複雜度是 $O(n)$。用攤還分析法,均攤狀況下,時間複雜度接近最好狀況,就是 $O(1)$。

最後

在學習了哈希表後,咱們認識到哈希表是一個很是高效的數據結構,設計良好的哈希表各個操做的時間複雜度能達到 $O(1)$ 級別。但在編程領域,老是在空間換時間、時間換空間以及各類 trade-off。因此,一個數據結構或算法在某些方面有良好的表現,一般也在其餘方面作出必定的犧牲。哈希表就是一個典型的空間換時間,組合了不一樣的數據結構,而且犧牲了順序性,換來了 $O(1)$ 的時間複雜度,這前提還得是設計良好。

不知道你有沒有發現,在本文中咱們實現的哈希表實際上有一個小 bug,爲了簡化流程只專一於哈希表自己的實現,咱們是直接使用 TreeMap 來存儲數據,而 TreeMap 底層是紅黑樹,要求 key 是具備可比較性的。但在咱們的實現中沒有對 key 要求實現 Comparable 接口,因此當存入的 key 是沒有實現 Comparable 接口的對象時就會報錯。

你能夠嘗試一下解決這個問題,例如選擇將 TreeMap 替換成 LinkedList,或者傳入一個 ComparatorTreeMap ,甚至能夠實現本身的鏈表或紅黑樹來替換掉 TreeMap

相關文章
相關標籤/搜索