HashTable 哈希表
文章目錄
1、什麼是哈希表
這裏我們首先借用一個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、哈希函數的設計原則
哈希函數的設計主要從以下幾個方面考慮:
- 一致性:如果a=b,則hash(a) = hash(b),反之不一定成立,對應哈希衝突
- 高效性:計算速度快,簡便
- 均勻性:哈希值分佈均勻
2.1、整型
小範圍
- 小範圍的正整數可以直接進行使用
- 小範圍的負整數可以通過偏移進行歸正
大範圍
通常的方法是對其進行取模操作,只保留最後的幾位,或者具有特徵的幾位。
但是對於取模的值相對來說是比較講究的,模值取得越大,相對來說產生哈希衝突的概率越小,具體模值的取值有一個基本規則:
取一個素數(數學領域的範疇,這裏不做介紹)
具體素數取什麼,有人已經給出了結論。
- lwr:數據大小的下界
- upr:數據大小的上界 也就是數據大小位於(lwr - upr)之間
- err:錯誤率
- primer:模值的取值(素數)
2.2、浮點型
我們知道在我們的計算機當中,對於浮點數的存儲也是採用二進制的形式,只不過計算機解析成了浮點數。下面以32位計算機爲例。
存儲方式同整型相同,所以仍然可以將浮點數按照整型進行處理。
2.3、字符串
這裏字符串依然按照整型進行處理。我們知道整型有下面的變換公式:
字符串依然可以使用這種方式:
這裏我們默認只有小寫字母,使用26進制,如果字符串中包含更加複雜的字符,例如大小寫,標點符號等等。那麼可以使用下面的形式:
I 表示進制,I的數值表示所代表類型的大小。對於字符串產生的這種大整型,我們可以按照2.1節那種,對大整型進行取模。
化簡可得:
但是上面的式子在不斷進行乘法運算的時候,導致在取模之前的數值會變得十分巨大,所以對上面的式子繼續進行化簡。
根據上面的式子我們可以寫出程序實現:
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 後面,類似於鏈表的形式。
當然前面的形式我們採用鏈表的形式,其實對於每一個索引位置我們可以索引一個樹結構,也就降低了時間複雜度。類似於下面的形式。
實際上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
源碼地址:可在公衆號內回覆 數據結構與算法源碼 即可獲得。