上節(jié)提到,通常情況下哈希函數的輸入空間遠大于輸出空間,因此理論上哈希沖突是不可避免的。比如,輸入空間為全體整數,輸出空間為數組容量大小,則必然有多個整數映射至同一數組索引。
哈希沖突會導致查詢結果錯誤,嚴重影響哈希表的可用性。為解決該問題,我們可以每當遇到哈希沖突時就進行哈希表擴容,直至沖突消失為止。此方法簡單粗暴且有效,但效率太低,因為哈希表擴容需要進行大量的數據搬運與哈希值計算。為了提升效率,我們可以采用以下策略。
哈希表的結構改良方法主要包括“鏈式地址”和“開放尋址”。
在原始哈希表中,每個桶僅能存儲一個鍵值對?!告準降刂?separate chaining」將單個元素轉換為鏈表,將鍵值對作為鏈表節(jié)點,將所有發(fā)生沖突的鍵值對都存儲在同一鏈表中。圖 6-5 展示了一個鏈式地址哈希表的例子。
圖 6-5 鏈式地址哈希表
哈希表在鏈式地址下的操作方法發(fā)生了一些變化。
鏈式地址存在以下局限性。
以下代碼給出了鏈式地址哈希表的簡單實現,需要注意兩點。
hash_map_chaining.cpp
/* 鏈式地址哈希表 */
class HashMapChaining {
private:
int size; // 鍵值對數量
int capacity; // 哈希表容量
double loadThres; // 觸發(fā)擴容的負載因子閾值
int extendRatio; // 擴容倍數
vector<vector<Pair *>> buckets; // 桶數組
public:
/* 構造方法 */
HashMapChaining() : size(0), capacity(4), loadThres(2.0 / 3), extendRatio(2) {
buckets.resize(capacity);
}
/* 析構方法 */
~HashMapChaining() {
for (auto &bucket : buckets) {
for (Pair *pair : bucket) {
// 釋放內存
delete pair;
}
}
}
/* 哈希函數 */
int hashFunc(int key) {
return key % capacity;
}
/* 負載因子 */
double loadFactor() {
return (double)size / (double)capacity;
}
/* 查詢操作 */
string get(int key) {
int index = hashFunc(key);
// 遍歷桶,若找到 key 則返回對應 val
for (Pair *pair : buckets[index]) {
if (pair->key == key) {
return pair->val;
}
}
// 若未找到 key 則返回 nullptr
return nullptr;
}
/* 添加操作 */
void put(int key, string val) {
// 當負載因子超過閾值時,執(zhí)行擴容
if (loadFactor() > loadThres) {
extend();
}
int index = hashFunc(key);
// 遍歷桶,若遇到指定 key ,則更新對應 val 并返回
for (Pair *pair : buckets[index]) {
if (pair->key == key) {
pair->val = val;
return;
}
}
// 若無該 key ,則將鍵值對添加至尾部
buckets[index].push_back(new Pair(key, val));
size++;
}
/* 刪除操作 */
void remove(int key) {
int index = hashFunc(key);
auto &bucket = buckets[index];
// 遍歷桶,從中刪除鍵值對
for (int i = 0; i < bucket.size(); i++) {
if (bucket[i]->key == key) {
Pair *tmp = bucket[i];
bucket.erase(bucket.begin() + i); // 從中刪除鍵值對
delete tmp; // 釋放內存
size--;
return;
}
}
}
/* 擴容哈希表 */
void extend() {
// 暫存原哈希表
vector<vector<Pair *>> bucketsTmp = buckets;
// 初始化擴容后的新哈希表
capacity *= extendRatio;
buckets.clear();
buckets.resize(capacity);
size = 0;
// 將鍵值對從原哈希表搬運至新哈希表
for (auto &bucket : bucketsTmp) {
for (Pair *pair : bucket) {
put(pair->key, pair->val);
// 釋放內存
delete pair;
}
}
}
/* 打印哈希表 */
void print() {
for (auto &bucket : buckets) {
cout << "[";
for (Pair *pair : bucket) {
cout << pair->key << " -> " << pair->val << ", ";
}
cout << "]\n";
}
}
};
值得注意的是,當鏈表很長時,查詢效率 O
「開放尋址 open addressing」不引入額外的數據結構,而是通過“多次探測”來處理哈希沖突,探測方式主要包括線性探測、平方探測、多次哈希等。
線性探測采用固定步長的線性搜索來進行探測,其操作方法與普通哈希表有所不同。
圖 6-6 展示了一個在開放尋址(線性探測)下工作的哈希表。
圖 6-6 開放尋址和線性探測
然而,線性探測存在以下缺陷。
以下代碼實現了一個簡單的開放尋址(線性探測)哈希表。
hash_map_open_addressing.cpp
/* 開放尋址哈希表 */
class HashMapOpenAddressing {
private:
int size; // 鍵值對數量
int capacity; // 哈希表容量
double loadThres; // 觸發(fā)擴容的負載因子閾值
int extendRatio; // 擴容倍數
vector<Pair *> buckets; // 桶數組
Pair *removed; // 刪除標記
public:
/* 構造方法 */
HashMapOpenAddressing() {
// 構造方法
size = 0;
capacity = 4;
loadThres = 2.0 / 3.0;
extendRatio = 2;
buckets = vector<Pair *>(capacity, nullptr);
removed = new Pair(-1, "-1");
}
/* 哈希函數 */
int hashFunc(int key) {
return key % capacity;
}
/* 負載因子 */
double loadFactor() {
return static_cast<double>(size) / capacity;
}
/* 查詢操作 */
string get(int key) {
int index = hashFunc(key);
// 線性探測,從 index 開始向后遍歷
for (int i = 0; i < capacity; i++) {
// 計算桶索引,越過尾部返回頭部
int j = (index + i) % capacity;
// 若遇到空桶,說明無此 key ,則返回 nullptr
if (buckets[j] == nullptr)
return nullptr;
// 若遇到指定 key ,則返回對應 val
if (buckets[j]->key == key && buckets[j] != removed)
return buckets[j]->val;
}
return nullptr;
}
/* 添加操作 */
void put(int key, string val) {
// 當負載因子超過閾值時,執(zhí)行擴容
if (loadFactor() > loadThres)
extend();
int index = hashFunc(key);
// 線性探測,從 index 開始向后遍歷
for (int i = 0; i < capacity; i++) {
// 計算桶索引,越過尾部返回頭部
int j = (index + i) % capacity;
// 若遇到空桶、或帶有刪除標記的桶,則將鍵值對放入該桶
if (buckets[j] == nullptr || buckets[j] == removed) {
buckets[j] = new Pair(key, val);
size += 1;
return;
}
// 若遇到指定 key ,則更新對應 val
if (buckets[j]->key == key) {
buckets[j]->val = val;
return;
}
}
}
/* 刪除操作 */
void remove(int key) {
int index = hashFunc(key);
// 線性探測,從 index 開始向后遍歷
for (int i = 0; i < capacity; i++) {
// 計算桶索引,越過尾部返回頭部
int j = (index + i) % capacity;
// 若遇到空桶,說明無此 key ,則直接返回
if (buckets[j] == nullptr)
return;
// 若遇到指定 key ,則標記刪除并返回
if (buckets[j]->key == key) {
delete buckets[j]; // 釋放內存
buckets[j] = removed;
size -= 1;
return;
}
}
}
/* 擴容哈希表 */
void extend() {
// 暫存原哈希表
vector<Pair *> bucketsTmp = buckets;
// 初始化擴容后的新哈希表
capacity *= extendRatio;
buckets = vector<Pair *>(capacity, nullptr);
size = 0;
// 將鍵值對從原哈希表搬運至新哈希表
for (Pair *pair : bucketsTmp) {
if (pair != nullptr && pair != removed) {
put(pair->key, pair->val);
}
}
}
/* 打印哈希表 */
void print() {
for (auto &pair : buckets) {
if (pair != nullptr) {
cout << pair->key << " -> " << pair->val << endl;
} else {
cout << "nullptr" << endl;
}
}
}
};
顧名思義,多次哈希方法是使用多個哈希函數 f1(x)、f2(x)、f3(x)、… 進行探測。
與線性探測相比,多次哈希方法不易產生聚集,但多個哈希函數會增加額外的計算量。
Java 采用鏈式地址。自 JDK 1.8 以來,當 HashMap 內數組長度達到 64 且鏈表長度達到 8 時,鏈表會被轉換為紅黑樹以提升查找性能。
Python 采用開放尋址。字典 dict 使用偽隨機數進行探測。
Golang 采用鏈式地址。Go 規(guī)定每個桶最多存儲 8 個鍵值對,超出容量則連接一個溢出桶;當溢出桶過多時,會執(zhí)行一次特殊的等量擴容操作,以確保性能。
更多建議: