演算法與資料結構——雜湊衝突

风陵南發表於2024-08-28

雜湊衝突

通常情況下雜湊函式那點輸入空間遠大於輸出空間,因此理論上雜湊衝突是不可避免的。比如輸入空間為全體整數,輸出空間為陣列容量大小,則必然有多個整數對映至同一個桶索引。

雜湊衝突會導致查詢結果錯誤,嚴重影響雜湊表的可用性。為了解決該問題,每當遇到雜湊衝突時,我們就進行雜湊表擴容,直至衝突消失。此方法簡單粗暴且有效,但效率太低,因為雜湊表擴容需要進行大量的資料搬運與雜湊值計算。為了提升效率,我們可以採用以下策略:

  • 改良雜湊表資料結構,使得雜湊表可以在出現雜湊衝突時正常工作。
  • 僅在必要時,即當雜湊衝突比較嚴重時,才執行擴容操作。

雜湊表的結構改良方法主要包括“鏈式地址”和“開放定址”。

鏈式地址

在原始雜湊表中,每個桶僅能儲存一個鍵值對。鏈式地址(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來標記這個桶。在該機制下,NoneTOMBSTONE都代表空桶,都可以放置鍵值對。但不同的是,線性探測到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個鍵值對,超出容量則連線一個溢位桶;當溢位桶過多時,會執行一次特殊的等量擴容操作,以確保效能。

相關文章