雜湊衝突
通常情況下雜湊函式那點輸入空間遠大於輸出空間,因此理論上雜湊衝突是不可避免的。比如輸入空間為全體整數,輸出空間為陣列容量大小,則必然有多個整數對映至同一個桶索引。
雜湊衝突會導致查詢結果錯誤,嚴重影響雜湊表的可用性。為了解決該問題,每當遇到雜湊衝突時,我們就進行雜湊表擴容,直至衝突消失。此方法簡單粗暴且有效,但效率太低,因為雜湊表擴容需要進行大量的資料搬運與雜湊值計算。為了提升效率,我們可以採用以下策略:
- 改良雜湊表資料結構,使得雜湊表可以在出現雜湊衝突時正常工作。
- 僅在必要時,即當雜湊衝突比較嚴重時,才執行擴容操作。
雜湊表的結構改良方法主要包括“鏈式地址”和“開放定址”。
鏈式地址
在原始雜湊表中,每個桶僅能儲存一個鍵值對。鏈式地址(separate chaining)將單個元素轉換為連結串列,將鍵值對作為連結串列節點,將所發生衝突的鍵值對都儲存在同一連結串列中。如圖展示了一個鏈式雜湊表的例子:
基於鏈式地址實現的雜湊表的操作方法發生了以下變化:
- 查詢元素:輸入key,經過雜湊函式得到桶索引,即可訪問連結串列頭節點,然後遍歷連結串列並對比key以查詢目標鍵值對。
- 新增元素:首先透過雜湊函式訪問連結串列頭節點,然後將節點(鍵值對)新增到連結串列中。
- 刪除元素:根據雜湊函式的結果訪問連結串列頭部,接著遍歷連結串列以查詢目標節點並將其刪除。
鏈式地址存在以下侷限性:
- 佔用空間增大:連結串列頭節點包含節點指標,它相比陣列更加耗費記憶體空間。
- 查詢效率降低:因為需要線性遍歷連結串列來查詢對應元素。
以下程式碼給出了鏈式地址雜湊表的簡單實現,需要注意兩點:
- 使用列表(動態陣列)代替連結串列,從而簡化程式碼。在這種設定下,雜湊表(陣列)包含多個桶,每個桶都是一個列表。
- 以下實現包含雜湊表擴容方法。當負載因子超過2/3時我們將雜湊表擴容至原先的2倍。
/*鍵值對*/
struct Pair{
int key;
string val;
Pair(int key, string val){
this->key = key;
this->val = val;
}
};
class HashMapChaining{
private:
int capacity; // 雜湊表容量
double loadThres; // 觸發擴容的負載因子閾值
int extendRatio; // 擴容倍數
vector<vector<Pair*>> buckets; // 桶陣列
public:
int size; // 鍵值對數量
/*構造方法*/
HashMapChaining() :size(0), capacity(4), loadThres(2.0 / 3.0), extendRatio(2){
buckets.resize(capacity);
}
/*析構方法*/
~HashMapChaining(){
for (auto &bucket : buckets){
for (Pair *pair : bucket){
//釋放記憶體
delete pair;
}
}
}
/*雜湊函式*/
int hashFunc(int key){
int index = key % capacity;
return index;
}
/*負載因子*/
double loadFactor(){
return (double)size / (double)capacity;
}
/*查詢操作*/
string get(int key){
int index = hashFunc(key);
if (buckets[index].size() == 0)
return "";
for (Pair * pair : buckets[index]){
if (key == pair->key)
return pair->val;
}
return "";
}
/*新增操作*/
void put(int key, string val){
if (loadFactor() > loadThres)
extend();
Pair * pair = new Pair(key, val);
int index = hashFunc(key);
buckets[index].push_back(pair);
size++;
}
/*刪除操作*/
void remove(int key){
int index = hashFunc(key);
for (auto iter = buckets[index].begin(); iter != buckets[index].end();iter++){
if (key == (*iter)->key){
buckets[index].erase(iter);
size--;
return;
}
}
cout << "未找到該元素" << endl;
}
/*擴容雜湊表*/
void extend(){
buckets.resize(extendRatio * capacity);
capacity *= extendRatio;
}
/*列印雜湊表*/
void print(){
for (auto &bucket : buckets){
for (Pair *pair : bucket){
cout << pair->key << "->" << pair->val << endl;
}
}
}
};
值得注意的是,當連結串列很長時,查詢效率O(n)很差,此時我們可以將連結串列轉換為“AVL樹”或“紅黑樹”,從而將查詢操作的時間複雜度最佳化至O(logn)。
開放定址
開放定址(open addressing)不引入額外的資料結構,而是透過“多次探測”來處理雜湊衝突,探測方式主要包括線性探測、平方探測和多次雜湊等。
下面以線性探測為例,介紹開放定址雜湊表的工作機制。
線性探測
線性探測採用固定步長的線性搜尋來進行探測,其操作方法與普通雜湊表有所不同:
- 插入元素:透過雜湊函式計算桶索引,若發現桶內已有元素,則向衝突位置向後線性遍歷(步長通常為1),直至找到空桶,將元素插入其中。
- 查詢元素:若發現雜湊衝突,則使用相同步長向後進行線性遍歷,知道找到對應元素,返回
value
即可;如果遇到空桶,說明目標不在雜湊表中,返回None
。
下圖展示了開放定址(線性探測)雜湊表的鍵值對分佈。根據此雜湊函式,最後兩位相同的key都會被對映到相同的桶。而透過線性探測,它們被一次儲存在該桶以及之下的桶中。
然而,線性探測容易產生“聚集現象”。具體來說,陣列中連續被佔用的位置越長,這些連續位置發生雜湊衝突的可能性越大,從而進一步促使該位置的聚堆生長,形成惡性迴圈,最終導致增刪改查操作效率劣化。
注意,我們不能再開放定址雜湊表中直接刪除元素。這是因為刪除元素會在陣列內產生一個空桶None
,而當查詢元素時,線性探測到該空桶就會返回,因此在該空桶之下的元素都無法再被訪問到,程式可能誤判這些元素不存在。
為解決這個問題,我們可以採用懶刪除(lazy deletion)機制:它不直接從雜湊表中移除元素,而是利用一個常量TOMBSTONE
來標記這個桶。在該機制下,None
和TOMBSTONE
都代表空桶,都可以放置鍵值對。但不同的是,線性探測到TOMBSTONE
時應該繼續遍歷,因為其之下還可能存在鍵值對。
懶刪除可能會加速雜湊表的效能退化,每次刪除操作都會產生一個刪除標記,隨著TOMBSTONE
的增加,搜尋時間也會增加,因為線性探測可能需要跳過多個TOMBSTONE
才能找到目標元素。
為此,考慮線上性探測中(即在新增元素或查詢元素時)記錄遇到的首個TOMBSTONE
的索引,並將搜尋到的目標元素與該TOMBSTONE
交換位置。這樣做的好處是當每次查詢或新增元素時,元素會被移動至理想位置(探測起始點)更近的桶,從而最佳化查詢效率。可以將其封裝為一個函式供新增和查詢使用:
新增時,假設鍵值不存在與原雜湊表中,程式會走完while
迴圈(線性探測過程),將index
迴圈累加直到陣列中為空一個位置上,供新增使用,如果過程中出現了刪除標記,則最終直接返回這個標記供新增使用,提高利用率。
/*搜尋key對應的桶索引*/
int findBucket(int key){
int index = hashFunc(key);
int firstTompStone = -1;
Pair *pair;
while (buckets[index] != nullptr){
pair = buckets[index];
if (key == pair->key){
// 若之前存在刪除標記
if (firstTompStone != -1){
buckets[firstTompStone] = buckets[index];
buckets[index] = TOMBSTONE;
return firstTompStone;
}
return index;
}
if (firstTompStone == -1 && pair == TOMBSTONE){
firstTompStone = index;
}
// 使其迴圈遍歷 形成環形陣列
index = (index + 1) % capacity;
}
// key不存在時,返回雜湊函式值最近的空桶(供新增元素使用)
return firstTompStone == -1 ? index : firstTompStone;
}
以下程式碼實現了一個包含懶刪除的開放定址(線性探測)雜湊表。為了更加充分地使用雜湊表空間,我們將雜湊表看作一個“環形陣列”,當越過陣列尾部時,回到頭部繼續遍歷。
#include "iostream"
#include "vector"
#include "string"
using namespace std;
/*鍵值對*/
struct Pair{
int key;
string val;
Pair(int key, string val){
this->key = key;
this->val = val;
}
};
class HashMapOpenAddressing{
private:
int capacity; // 雜湊表容量
double loadThres; // 觸發擴容的負載因子閾值
int extendRatio; // 擴容倍數
vector<Pair *> buckets; // 桶陣列
Pair *TOMBSTONE = new Pair(-1, "-1");// 刪除標記
public:
int size; // 鍵值對數量
/*構造方法*/
HashMapOpenAddressing() :size(0), capacity(4), loadThres(2.0 / 3.0), extendRatio(2){
buckets.resize(capacity);
}
/*析構方法*/
~HashMapOpenAddressing(){
for (Pair* pair : buckets){
if (pair != nullptr && pair != TOMBSTONE){
delete pair;
}
}
delete TOMBSTONE;
}
/*雜湊函式*/
int hashFunc(int key){
int index = key % capacity;
return index;
}
/*負載因子*/
double loadFactor(){
return (double)size / (double)capacity;
}
/*搜尋key對應的桶索引*/
int findBucket(int key){
int index = hashFunc(key);
int firstTompStone = -1;
Pair *pair;
while (buckets[index] != nullptr){
pair = buckets[index];
if (key == pair->key){
// 若之前存在刪除標記
if (firstTompStone != -1){
buckets[firstTompStone] = buckets[index];
buckets[index] = TOMBSTONE;
return firstTompStone;
}
return index;
}
if (firstTompStone == -1 && pair == TOMBSTONE){
firstTompStone = index;
}
// 使其迴圈遍歷 形成環形陣列
index = (index + 1) % capacity;
}
// key不存在時,返回雜湊函式值最近的空桶(供新增元素使用)
return firstTompStone == -1 ? index : firstTompStone;
}
/*查詢操作*/
string get(int key){
int index = findBucket(key);
if (buckets[index] != nullptr && buckets[index] != TOMBSTONE)
return (buckets[index]->val);
return "";
}
/*新增操作*/
void put(int key, string val){
if (loadFactor() > loadThres)
extend();
int index = findBucket(key);
// 找到已存在的key值 直接覆蓋
if (buckets[index] != nullptr && buckets[index] != TOMBSTONE){
buckets[index]->val = val;
return;
}
// 不存在則新增鍵值對
buckets[index] = new Pair(key, val);
size++;
}
/*刪除操作*/
void remove(int key){
int index = findBucket(key);
// 找到鍵值對
if (buckets[index] != nullptr && buckets[index] != TOMBSTONE){
delete buckets[index];
buckets[index] = TOMBSTONE;
size--;
}else{
cout << "不存在該鍵值對" << endl;
}
}
/*擴容雜湊表*/
void extend(){
// 暫存原雜湊表
vector<Pair *> bucketsTmp = buckets;
// 初始化擴容後的新雜湊表
capacity *= extendRatio;
buckets = vector<Pair*>(capacity, nullptr);
size = 0;
// 將鍵值對從原雜湊表搬運至新雜湊表
for (Pair* pair : bucketsTmp){
if (pair != nullptr && pair != TOMBSTONE){
put(pair->key, pair->val);
delete pair;
}
}
}
/*列印雜湊表*/
void print(){
for (Pair* pair : buckets){
if (pair != nullptr && pair != TOMBSTONE)
cout << pair->key << "->" << pair->val << endl;
}
}
};
int main(){
HashMapOpenAddressing *OAmap = new HashMapOpenAddressing();
OAmap->put(123, "張三");
OAmap->put(154, "李四");
OAmap->put(198, "王麻子");
OAmap->put(244, "牛魔");
OAmap->put(388, "詩人");
OAmap->print();
cout << "====刪除====" << endl;
OAmap->remove(154);
OAmap->print();
cout << "==========" << endl;
cout << "大小:" << OAmap->size << endl;
cout << "====查詢====" << endl;
cout << "198 ->" << OAmap->get(198) << endl;
system("pause");
return 0;
}
平方探測
平方探測與線性探測類似,都是以開放定址的常見策略之一。當發生衝突時,平方探測不是簡單跳過一個固定的步數,而是跳過探測次數的平方的步數,即1,4,9,...步。
平方探測主要具有以下優勢:
- 平方探測透過跳過探測次數平方的距離,試圖緩解線性探測的聚集效應
- 平方探測會跳過更大的距離來尋找空位置,有助於資料分佈得更加均勻
缺點:
- 仍然存在聚集現象,即某些位置比其他位置更容易被佔用
- 由於平方的增長,平方探測可能不會探測整個雜湊表,這意味著即使雜湊表中有空桶,平方探測也可能無法訪問到它。
多次雜湊
顧名思義,多次雜湊方法使用多個雜湊函式𝑓1(𝑥)、𝑓2(𝑥)、𝑓3(𝑥)、… 進行探測。
- 插入元素:當雜湊函式f1(x)出現衝突,則嘗試f2(x),以此類推,直到出現空位後插入元素
- 查詢元素:在相同的雜湊函式順序下進行查詢,直到找到目標元素時返回;若遇到空位或已嘗試所有雜湊函式,說明雜湊表中不存在該元素,則返回None。
與線性探測相比,多次雜湊不易產生聚集,但多個雜湊函式會帶來額外的計算量。
注意:開放定址(線性探測、平方探測和多次雜湊)雜湊表都不能直接刪除元素。
不同程式語言的實現
各種程式語言採取了不同的雜湊表實現策略,下面舉幾個例子:
- Python 採用了開放定址,字典dict使用為隨機數進行探測
- Java 採用鏈式地址,字JDK1.8以來,當HashMap內陣列長度達到64且連結串列長度達到8時,連結串列會轉換為紅黑樹以提升查詢效能。
- Go 採用鏈式地址。 Go 規定每個桶最多儲存8個鍵值對,超出容量則連線一個溢位桶;當溢位桶過多時,會執行一次特殊的等量擴容操作,以確保效能。