Java 哈希函數 哈希表 動態容量 鏈地址法 簡介+實現

簡介

實現哈希表有兩個主要的問題, 一個是解決哈希函數的設計, 一個是哈希衝突的處理

哈希函數

鍵通過哈希函數可以得到一個索引, 通過索引可以在內存中找到這個鍵所包含的信息, 索引的分佈越均勻衝突才越少

所有類型的數據, 包括浮點型, 字符型的都可以轉化爲整型, 然後用整型的哈希函數計算

哈希函數的設計要遵循一些原則:

  1. 一致性: 如果 a == b, 則 hash(a) == hash(b)
  2. 高效性: 計算高效簡便
  3. 均勻性: 哈希值均勻分佈

整型

如果是小範圍正整數, 可以直接把鍵作爲索引使用, 比如字母表的大小隻有26, 1對應a, 2對應b…

如果是小範圍的負整數, 可以加偏移, -10 ~ 10 偏移10的話就轉爲 0 ~ 20

如果是大整數, 如身份證號, 通常是取模, 比如身份證號碼401625198906031289, 如果mod 1000000的話, 也就是取後6位, 會有一個問題, 那就是日期的範圍不是00~99, 會導致分佈不均勻,
最簡單的解決方法是模一個素數, 可以根據你的數據範圍, 到 這裏 查找合適的素數 (模素數可以更均勻地分佈)
在這裏插入圖片描述

浮點型

把存儲浮點數的32位或者64位二進制當作整型處理

字符串型

比如 love = l * 26^3 + o * 26^2 + v * 26^1 + e * 26^0, 當作26進制的數字, 如果字符串不止有小寫字母, 還有字符串, 大寫字母的話, 也可以修改26, 下面用 B表示, 如果M表示素數的話, love的哈希值是

hash(love)
= (l * B^3 + o * B^2 + v * B^1 + e * B^0)%M
= ((((l * B) + o * B) + v * B) + e * B) % M
= ((((l % M) * B + o) % M * B + v) % M * B + e) % M. . . . . .每次都先模一次M可以防止整型溢出

代碼如下

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

Java 中的hashCode()

將浮點型, 字符串型等非整型的轉化爲整型

int a = 35;
System.out.println(((Integer)a).hashCode());
運行結果: 35

int a2 = 35;
System.out.println(((Integer)a2).hashCode());
運行結果: 35  // 輸入一樣, 輸出一樣

int b = -35;
System.out.println(((Integer)b).hashCode());
運行結果: -35

double c = 3.14159265;
System.out.println(((Double)c).hashCode());
運行結果: 331478282

String d = "To freedom";
System.out.println(d.hashCode());
運行結果: 1240310481

因爲Java並不知道我們的數據規模, 所以不知道要模多大的素數, hashCode()得到的不是索引, 得模完素數之後纔得到索引

Object類默認都是有 hashCode() 這個方法的, 所有類都是Object類的子類, 這也是爲什麼上面的int, double要轉化爲Integer類, Double類.

如果是自己定義的類, 通常是要 重寫hashCode() 的, 因爲沒有重寫hashCode()的話, 那麼用的就是Object類中的hashCode(), 這個方法把對象的地址映射成整型, 所以只要地址不同, hashCode()返回的值就會不同, 這通常都不是我們想要的
除此之外, 通常還要 重寫equal() , 用於在哈希衝突的時候判斷兩個對象是不是一樣
例子如下

public class Person{
    public String name;
    public int age;

    Person(String name, int age){
        this.name = name;
        this.age = age;
    }

    @Override
    public int hashCode(){
        int B = 31;

        int hash = 0;
        hash = hash * B + name.hashCode();
        hash = hash * B + age;

        return hash;
    }
	
	@Override
	public boolean equals(Object o){
		if(this == o)
			return true;

		if(o == null)
			return false;

		if(getClass() != o.getClass())
			return false;

		Person another = (Person)o;  // 強制類型轉化
		return this.name == another.name && this.age == another.age			
	}
}

哈希衝突

處理方法: 鏈地址法(Separate Chaining), 開發地址法, 再哈希法, Coalesced Hashing(綜合 Separate Chaining 和 開發地址法)

這裏只介紹鏈地址法, 有需要再填坑
有哈希衝突時候, 可以用鏈表把不同的鍵值掛在同一索引上, 也可以用樹
在這裏插入圖片描述
在Java8之前, 每一個索引位置對應一個鏈表
在Java8之後, 一開始每一個索引位置依然對應一個鏈表, 但是當哈希衝突達到一定程度後(比如每一個位置的存儲的元素數超過一定數量), Java就會把鏈表轉爲紅黑樹, 也就是TreeMap(底層實現就是紅黑樹), 因爲衝突小的時候, 鏈表更快, 但是! 轉爲紅黑樹有一個條件, 就是哈希表的鍵要可比較, 因爲紅黑樹是有序的, 可比較纔可以排序

時間複雜度

N個元素放入有M個地址的哈希表, 平均每個地址存N/M個元素
如果用的是鏈表存儲, 時間複雜度是 O(N/M)
如果用的是平衡樹, 時間複雜度是 O(log(N/M)

動態空間處理

當N無線增大時, 時間複雜度就會很大
所以要達到 O(1), 就要使用動態的哈希表, M要隨着N的增大而增大

當每個地址承載的元素多到一定程度時, 擴容: N/M >= upperTol (上界)

if(size >= upperTol * M)  // 用除法會有誤差, size是存儲的元素個數, M是哈希表容量
    resize(2 * M);  // 擴大爲原來的兩倍

當每個地址承載的元素少到一定程度時, 縮容: N/M < lowerTol (下界)

if(size < lowerTol * M && M / 2 >= initCapacity)  // 要防止縮太小, 不能小於初始容量
    resize(M / 2);  // 變爲原來的一半

設置了兩個界限, 可以避免震盪, 如果只有一個界限, 在邊界反覆添加刪除就會導致M反覆變化

擴容的時候是擴大兩倍, 有一點不好, 就是素數乘2之後會變成偶數, 模一個偶數會容易導致分佈不均勻, 所以做一些改進, 那就是使用上面的素數表

上面兩個條件判斷的修改如下

// 上面那個素數表
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};  // 最大不能超過int的表示範圍
private int capacityIndex = 0;  // 初始容量是53

擴容:
if(size >= upperTol * M && capcityIndex+1 < capacity.length){  // 用除法會有誤差
    capcityIndex++;
    resize(capacity[capcityIndex]);
}

縮容:
if(size < lowerTol * M && capacityIndex-1 >= 0) {  // 要防止縮太小
    capacityIndex--;
    resize(capacity[capacityIndex]);
}

哈希函數和擴容縮容函數如下

// 哈希函數
private int hash(K key){
    return (key.hashCode() & 0x7fffffff) % M;  // 去掉符號, 再轉爲索引
}

private void resize(int newM) {
    TreeMap<K, V>[] newHashTable = new TreeMap[newM];
    // 初始化newM個索引, 每個索引是一個TreeMap
    for(int i=0; i<newM; i++)
        newHashTable[i] = new TreeMap<>();

    int oldM = M;  // 保存舊的M
    this.M = newM;  // 更新M, 因爲hash()要用到M
    for(int i=0; i<oldM; i++){  // 遍歷舊的所有索引
        TreeMap<K, V> map = hashtable[i];
        for(K key: map.keySet()){  // 遍歷索引上的TreeMap的每個鍵值
            newHashTable[hash(key)].put(key, map.get(key));
        }
    }

    this.hashtable = newHashTable;
}

適用範圍

Java標準庫中
有序集合, 有序映射用的是平衡樹
無序集合, 無序映射用的是哈希表

實現

總的代碼如下
HashTable.java

import java.util.TreeMap;

public class HashTable<K, V> {

    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};  // 最大不能超過int的表示範圍

    private static final int upperTol = 10;
    private static final int lowerTol = 2;
    private int capacityIndex = 0;  // 初始容量是53

    private TreeMap<K, V>[] hashtable;
    private int M;  // hashtable的長度
    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<>();  // 每個索引位置都連一個TreeMap
    }

    // 哈希函數
    private int hash(K key){
        return (key.hashCode() & 0x7fffffff) % M;  // 去掉符號, 再轉爲索引
    }

    public int getSize(){
        return size;
    }

    // 添加元素
    public void add(K key, V value){
        TreeMap<K, V> map = hashtable[hash(key)]; // 找到索引位置
        // 如果已經存在, 就更新
        if(map.containsKey(key)){  // 看那個索引位置連的樹中有沒有我們要的鍵
            map.put(key, value);
        }
        else{
            map.put(key, value);
            size++;

            if(size >= upperTol * M && capacityIndex+1 < capacity.length){  // 用除法會有誤差
                capacityIndex++;
                resize(capacity[capacityIndex]);
            }
        }
    }

    // 刪除元素
    public V remove(K key){
        TreeMap<K, V> map = hashtable[hash(key)]; // 找到索引位置
        V ret = null;
        if(map.containsKey(key)){
            ret = map.remove(key);
            size--;

            if(size < lowerTol * M && capacityIndex-1 >= 0) {  // 要防止縮太小
                capacityIndex--;
                resize(capacity[capacityIndex]);
            }
        }
        return ret;
    }

    private void resize(int newM) {
        TreeMap<K, V>[] newHashTable = new TreeMap[newM];
        // 初始化newM個索引, 每個索引是一個TreeMap
        for(int i=0; i<newM; i++)
            newHashTable[i] = new TreeMap<>();

        int oldM = M;
        this.M = newM;
        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;
    }

    // 修改
    public void set(K key, V value){
        TreeMap<K, V> map = hashtable[hash(key)];V ret = null;
        if(!map.containsKey(key))
            throw new IllegalArgumentException(key + "does not exist!");

        map.put(key, value);
    }

    // key是否存在
    public boolean contains(K key){
        return hashtable[hash(key)].containsKey(key);
    }

    // 通過key獲取value
    public V get(K key){
        return hashtable[hash(key)].get(key);
    }
}

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