演算法與資料結構——雜湊表

风陵南發表於2024-08-27

雜湊表

雜湊表(hash table),又稱雜湊表,它透過建立鍵key與值value之間的對映,實現高效的元素查詢。具體而言,我們向雜湊表中輸入一個鍵key,則可以在O(1)時間內獲取對應的值value

除雜湊表外,陣列和連結串列也可以實現查詢功能,他們的效率對比如下表:

  • 新增元素:僅需將元素新增至陣列(連結串列)的尾部即可,使用O(1)時間。
  • 查詢元素:由於陣列(連結串列)是亂序的,因此需要遍歷其中的所有元素,使用O(n)時間。
  • 刪除元素:需要先查詢到元素,再從陣列(連結串列)中刪除,使用O(n)時間。
陣列 連結串列 雜湊表
查詢元素 O(n) O(n) O(1)
新增元素 O(1) O(1) O(1)
刪除元素 O(n) O(n) O(1)

觀察發現,在雜湊表中進行增刪改查操作的時間複雜度都是O(1),非常高效。

雜湊表常用操作

雜湊表的常見操作包括:初始化、查詢操作、新增鍵值對和刪除鍵值對等,C++中提供了現成的雜湊表類:

/*初始化雜湊表*/
	unordered_map<int, string> map;

	/*新增操作*/
	// 在雜湊表中新增鍵值對(key,value)
	map[123] = "張三";
	map[110] = "李四";
	map[188] = "王五";

	/*查詢操作*/
	// 向雜湊表中輸入鍵 key 得到值 value
	string name = map[188];
	cout << "姓名:" << name << endl;

	/*刪除操作*/
	// 在雜湊表中根據鍵key刪除鍵值對
	map.erase(188);
}

雜湊表有三種常用的遍歷方式:遍歷鍵值對、遍歷鍵和遍歷值。

	/*遍歷雜湊表*/
	// 遍歷鍵值對 kay -> value
	for (auto kv : map){
		cout << kv.first << " -> " << kv.second << endl;
	}
	// 使用迭代器遍歷key -> value
	for (auto iter = map.begin(); iter != map.end(); iter++){
		cout << iter->first << " -> " << iter->second << endl;
	}

雜湊表簡單實現

先考慮最簡單的情況,僅用一個陣列來實現雜湊表。在雜湊表中,我們將陣列中的每個空位稱為(bucket),每個桶可儲存一個鍵值對。因此查詢操作就是找到key對應的桶,並在桶中獲取value。

那麼,如何基於key定位到對應的桶呢? 這是透過雜湊函式(hash function)實現的。雜湊函式的作用是將一個較大的輸入空間對映到一個較小的輸出空間。在雜湊表中,輸入空間是所有的key,輸出空間是所有的桶(陣列索引)。換句話說,輸入一個key,我們可以透過雜湊函式得到該key對應的鍵值對在陣列中的儲存位置。

輸入一個key,雜湊函式的計算過程分為以下兩步。

  1. 透過某種雜湊演算法hash()計算得到雜湊值。
  2. 將雜湊值對桶數量(陣列長度)capacity取模,從而獲取該key對應的陣列索引index。

index = hash(key) % capacity

隨後我們就可以利用index在雜湊表中訪問對應的桶,從而獲取value。

設陣列長度capacity = 100、雜湊演算法hash(key) = key,易得雜湊函式為key % 100。下圖以key學號和value姓名為例,展示了雜湊函式的工作原理。

演算法與資料結構——雜湊表

以下程式碼實現了一個簡單的雜湊表。其中,我們將key和value封裝成一個Pair類,以表示鍵值對。

/*鍵值對*/
struct Pair{
	int key;
	string val;
	Pair(int key, string val){
		this->key = key;
		this->val = val;
	}
};
/*基於陣列實現的雜湊表*/
class ArrayHashMap{
private:
	vector<Pair*> buckets;
public:
	ArrayHashMap(){
		// 初始化陣列,包含100個桶
		buckets = vector<Pair*>(100);
	}
	~ArrayHashMap(){
		// 釋放記憶體
		for (const auto &bucket : buckets){
			delete bucket;
		}
		buckets.clear();
	}
	/*雜湊函式*/
	int hashFunc(int key){
		return key % 100;
	}
	/*查詢操作*/
	string get(int key){
		int index = hashFunc(key);
		Pair *pair = buckets[index];
		if (pair == nullptr)
			return "";
		return pair->val;
	}
	/*新增操作*/
	void put(int key, string val){
		Pair *pair = new Pair(key, val);
		int index = hashFunc(key);
		buckets[index] = pair;		
	}
	/*刪除操作*/
	void remove(int key){
		int index = hashFunc(key);
		/*迭代器方式刪除
		auto iter = buckets.begin();
		iter += index;
		buckets.erase(iter);*/
		// 置空方式刪除
		delete buckets[index];
		buckets[index] = nullptr;
	}
	/*獲取所有鍵值對*/
	vector<Pair*> pairSet(){
		vector<Pair*> pair_set;
		for (Pair* pair:buckets){
			if (pair)
				pair_set.push_back(pair);
		}
		return pair_set;
	}
	/*獲取所有鍵*/
	vector<int> keySet(){
		vector<int> key_set;
		for (Pair* pair : buckets){
			if (pair)
				key_set.push_back(pair->key);
		}
		return key_set;
	}
	/*獲取所有值*/
	vector<string> valueSet(){
		vector<string> val_set;
		for (Pair* pair : buckets){
			if (pair)
				val_set.push_back(pair->val);
		}
		return val_set;
	}
	/*列印雜湊表*/
	void print(){
		for (Pair* pair : pairSet()){
			cout << pair->key << "->" << pair->val << endl;
		}
	}
};

雜湊衝突與擴容

從本質上看,雜湊函式的作用是將所有key構成的輸入空間對映到陣列所有索引構成的輸出空間,而輸入空間往往遠大於輸出空間。因此,理論上一定存在“多個輸入對應相同輸出”的情況

對於上述示例中的雜湊函式,當輸入的key後兩位相同時,雜湊函式的輸出結果也相同。例如,查詢學號為12836和20336的兩個學生時,我們經雜湊函式得到的結果均為36。

如圖所示,兩個學號指向了同一個姓名,這顯然是不對的。這樣多個輸入對應同一輸出的情況稱為雜湊衝突(hash collision)

演算法與資料結構——雜湊表

容易想到,雜湊表容量n越大,多個key被分配到同一個桶中的機率越低,衝突就越少。因此,我們可以透過擴容雜湊表來減少雜湊衝突

如圖所示,擴容前鍵值對(136,A)和(236,D)發生衝突,擴容後衝突消失。

演算法與資料結構——雜湊表

類似於陣列擴容,雜湊表擴容需要將所有鍵值對從原雜湊表遷移至新雜湊表,非常耗時;並且由於雜湊表容量capacity改變,我們需要透過雜湊函式來重新計算所有鍵值對的儲存位置,這進一步增加了擴容過程的計算開銷。為此,程式語言通常會預留足夠大的雜湊表容量,防止頻繁擴容。

負載因子(load factor)是雜湊表的一個重要概念,其定義為雜湊表的元素數量除以桶數量,用於衡量雜湊衝突的嚴重程度,也常作為雜湊表擴容的觸發條件。例如在java中,當負載因子超過0.75時,系統會將雜湊表擴容至原先的2倍。

相關文章