簡介
實現哈希表有兩個主要的問題, 一個是解決哈希函數的設計, 一個是哈希衝突的處理
哈希函數
鍵通過哈希函數可以得到一個索引, 通過索引可以在內存中找到這個鍵所包含的信息, 索引的分佈越均勻衝突才越少
所有類型的數據, 包括浮點型, 字符型的都可以轉化爲整型, 然後用整型的哈希函數計算
哈希函數的設計要遵循一些原則:
- 一致性: 如果 a == b, 則 hash(a) == hash(b)
- 高效性: 計算高效簡便
- 均勻性: 哈希值均勻分佈
整型
如果是小範圍正整數, 可以直接把鍵作爲索引使用, 比如字母表的大小隻有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);
}
}