再看C++哈希表(散列表)

依稀還記得上學期期末,數據結構課設的抽籤中抽中了哈希表實現的電話號碼查詢系統,再回過頭來看自己寫的,好菜。。。

github程序鏈接https://github.com/duchenlong/Cpp/tree/master/Hash/Hash

因爲紅黑樹在查找中的效率爲O(log N),爲了能夠更爲快速的查找數據,最好是趨於O(1)的時間複雜度。於是C++11中新加入了unordered_mapunordered_set兩個容器,不同於map和set的是,map和set的底層是紅黑樹的接口,而unordered_mapunordered_set的底層是哈希表的接口,可以讓查找效率無限接近於O(1)。

在這裏插入圖片描述
在這裏插入圖片描述

哈希表的原理

首先有這樣一個問題,就是給定一串數據,再給一個數字num,怎麼在O(1)的時間複雜度內判斷num在那一串數據中有沒有出現?
在這裏插入圖片描述

直接定製法(適用於數據範圍比較小的時候)

因爲是數據類型,如果數據的範圍不是很大的話,我們就可以開闢數據範圍大小的數組,讓數組的下標位置就表示這個數據,那麼我們就可以在O(1)的時間範圍內判斷出該數據有沒有出現,只需判斷arr[num] == num就可以知道答案了。

但是實際中,很有可能這個數據比較極端:

  1. 1,10000這種的,明明只有兩個數據,卻要我們開10000個數據的空間,就造成了很大程度的浪費;

  2. 再比如4,294,967,296這樣的數據,表示的範圍很大,我們在32位的系統中,就得開闢4G大小的空間,這有點不現實。

  3. 再一種就是字符串(string)類型的數據,char類型的字符,我們還可以用ASCII碼操作操作,但是字符串類型不能直接進行索引了。

對於字符串類型,處理方式比較簡單,我們可以看成是一個個字符拼接而成的數據,依次相加每個字符的ASCII 碼就可以操作了。

	size_t ans = 0;
	for (size_t i = 0; i < key.size(); i++)
	{
		ans *= 131;//玄學研究,*131可以很大程度上避免衝突
		ans += key[i];
	}

除留餘數法

而對於其他兩種情況,我們可以規定一個數組的大小,然後對這個數據取餘就可以了pos = data % size,這樣就可以保證之後的數據都是在這個數組中。

哈希表描述

對於哈希表的描述,我們可以這樣來

	template<class K, class T, class KOfT>
	class HashTable
	{
	public:
		HashTable()
			:_num(0)
		{
			//初始的容量不能爲0,
			_table.resize(10);
		}
	private:
		vector<HashData<T> > _table;
		size_t _num;//所存儲數據的數量
	};

其中哈希表的數據類型,我們定義了一個結構體

	//數組的類型
	template<class T>
	struct HashData
	{
		T _data;
		Status _status;//當前位置的狀態
	};

又因爲存在哈希衝突的問題,所以說我們在刪除元素節點的時候,不能真正的刪除掉,得采用惰性刪除的方式。也就是給每一個結點定義三個狀態,存在數據,不存在數據,刪除數據

	//哈希表中每一個位置的狀態
	enum Status
	{
		EMPTY,//空
		EXITS,//存在
		DELETE,//刪除
	};

而KOfT容器呢,是我們自定義的一個容器,他重載了(),作用就是獲取計算權值的數據

因爲這個數據不一定是數值類型,所以我們需要一個進行計算的模板

  • 如果數據是數值類型,我們完全可以直接進行獲取;
  • 如果數據是字符串類型,我們就需要自己進行計算了。這就可以使用模板的特化進行解決
	//得到當前位置的數據
	template<class T>
	struct _Hash
	{
		const T& operator()(const T& key)
		{
			return key;
		}
	};
	//特化字符串類型數據
	template<>
	struct _Hash < string >
	{
		size_t operator()(const string& key)
		{
			size_t ans = 0;
			for (size_t i = 0; i < key.size(); i++)
			{
				ans *= 131;
				ans += key[i];
			}
			return ans;
		}
	};

在哈希表內部,我們寫一個HashFunction函數來實現這一功能,根據數據來計算插入的位置

	//得到當前位置的數據,轉爲數字類型
	size_t HashFunc(const K& key)
	{
		Hash hash;
		return hash(key);
	}

哈希衝突問題

那麼問題又來了,如果我們數組的大小是10,當我們依次插入 1 ,11,21的時候,經過計算,他們的插入位置都是pos = 1,這就發生了哈希衝突。

對於哈希衝突的問題,我們有着這樣幾種處理方式

閉散列法

閉散列法也叫開放定址法。當發生哈希衝突時,如果哈希表未被裝滿,說明在哈希表中必然還有空位置,那麼可以把key存放到衝突位置中的“下一個” 空位置中去。

問題又來了,如何找這個位置更好呢?

  1. 線性探測法

從發生衝突的位置開始,依次向後探測(如果到了數組的末尾,就需要來一個循環,從0號位置開始繼續探測),直到尋找到下一個空位置爲止

在這裏插入圖片描述
這樣做有一個缺點,那就是如果連續插入幾個相同pos的數據,很容易就會出現相同pos的數據進行扎堆的現象,也就是不同關鍵碼佔據了可利用的空位置,使得尋找某關鍵碼的位置需要許多次比較,導致搜索效率降低。

	//線性探測
	size_t idx = _koft(d) % _table.size();
	while (_table[idx]._status == EXITS)
	{
		//如果該數據已經存在
		if (_koft(_table[idx]._data) == _koft(d))
		return false;

		//向後查找的時候,需要組成一個循環查找
		idx = (idx + 1) % _table.size();
	}

	_table[idx]._data = d;
	_table[idx]._status = EXITS;
	_num++;	
  1. 二次探測

爲了解決線性探測中,相同pos的數據扎堆出現的現象,二次探測如果發生了衝突,採取跳躍式的向後尋找插入位置。

在這裏插入圖片描述
這樣由於指數增長的問題,如果直接訪問的話,很快就會越界的,所以說還需要對數組的大小進行取模。

	//二次探測
	size_t start = _koft(d) % _table.size();
	size_t idx = start;
	int i = 1;
	while (_table[idx]._status == EXITS)
	{
		if (_koft(_table[idx]._data) == _koft(d))
			return false;
		idx = start + i * i;
		i++;
		idx %= _table.size();
	}
	_table[idx]._data = d;
	_table[idx]._status = EXITS;
	_num++;

當數據在持續插入的情況下,我們很容易面對的一個問題就是 哈希表滿了

哈希表的擴容問題

對於哈希表,我們是不能讓這個表滿了的,所以說,得在這個哈希錶快要滿的時候,就進行擴容,起碼閉散列是這樣的

這裏又引入了一個新的概念,負載因子,就是元素的個數 除以 哈希表的大小,所得到的結果。所以說,負載因子在閉散列中只會是 0 - 1中間的數據。

而在閉散列中,當負載因子在0.7 或者 0.8的時候,就需要進行擴容了。

對於擴容,我之前在課設驗收的時候就被老師問到了這個問題,當時寫的擴容被老師一眼看出來問題所在(太強了),後來想了想,應該就是下面這種擴容方式

其實哈希表的擴容沒有什麼技巧可言,就是把所有的數據,全部再插入到新的哈希表中就可以了,新的哈希表的容量一般是舊的哈希表的2倍

這裏又兩種寫法

  1. 第一種就是新建一個2倍大小的數組,然後手寫插入函數
	//是否需要擴容
	if (_num * 10 / _table.size() >= 7)
	{
		//擴容
		//第一種寫法,建立一個vector
		vector<HashData<T>> newTable;
		newTable.resize(_table.size() * 2);
		for (size_t i = 0; i < _table.size(); i++)
		{
			if (_table[i]._status == EXITS)
			{
				//計算在新表中的數據
				size_t idx = _koft(_table[i]._data) % newTable.size();
				//找到插入位置
				while (newTable[idx]._status == EXITS)
					idx = (idx + 1) % newTable.size();
				newTable[idx]._data = _table[i]._data;
				newTable[idx]._status = EXITS;
			}
		}
		swap(newTable, _table);
	}
  1. 新建一個哈希表,調用這個哈希表的插入函數,最後再交換兩個哈希表底層的數組
	//是否需要擴容
	if (_num * 10 / _table.size() >= 7)
	{
		//擴容
		//第二種寫法,新建一個hashtable
		HashTable<K, T, KofT<K>> newHash;
		newHash._table.resize(_table.size() * 2);
		for (size_t i = 0; i < _table.size(); i++)
			if (_table[i]._status == EXITS)
					newHash.Insert(_table[i]._data);
		_table.swap(newHash._table);
	}

開散列

開散列法又叫鏈地址法(開鏈法),首先對關鍵碼集合用散列函數計算散列地址,具有相同地址的關鍵碼歸於同一子集合,每一個子集合稱爲一個桶,各個桶中的元素通過一個單鏈錶鏈接起來,各鏈表的頭結點存儲在哈希表中

在這裏插入圖片描述
對於這樣一個一個的桶,就不存在哈希衝突的問題了,我們先計算一個插入位置pos,那麼這個位置所存儲的都是這個pos權值的數據,他們以鏈表的形式連接在一起。(如果想要發散一下的話,也可以使用紅黑樹進行連接)

當我們在插入數據的時候,所選擇的插入方式便是頭插的方法

而在閉散列中,我們在負載因子=1 的時候進行擴容

	//插入
	pair<iterator, bool> insert(const T& data)
	{
		KeyOfT koft;

		//桶哈希中,如果負載因子 = 1,就進行擴容
		if (_table.size() == _num)
		{
			vector<node*> newTable;
			size_t newSize = _table.size() * 2;
			newTable.resize(newSize);

			for (size_t i = 0; i < _table.size(); i++)
			{
				node* cur = _table[i];
				while (cur != nullptr)
				{
					node* next = cur->_next;
					//計算應該插入到新表中的位置
					size_t idx = HashFunc(koft(cur->_data)) % newSize;
					//進行頭插
					cur->_next = newTable[idx];
					newTable[idx] = cur;
					cur = next;
				}
				//這個節點以經沒有數據了
				_table[i] = nullptr;
			}
			_table.swap(newTable);
		}

		//插入這個新的數據
		//計算插入位置
		size_t idx = HashFunc(koft(data)) % _table.size();
		//找到插入的地方
		node* cur = _table[idx];
		while (cur != nullptr)
		{
			//該數據已經在表中,不需要插入了
			if (koft(cur->_data) == koft(data))
				return make_pair(iterator(cur, this), false);
			cur = cur->_next;
		}
		//確保表中沒有該數據,進行頭插
		node* newNode = new node(data);
		newNode->_next = _table[idx];
		_table[idx] = newNode;

		_num++;
		return make_pair(iterator(newNode, this), true);
	}

哈希表的迭代器

迭代器的框架

	template<class K,class T,class KeyOfT,class Hash>
	struct __HashTableIterator
	{
		typedef __HashTableIterator<K, T, KeyOfT, Hash> Self;
		typedef HashTable<K, T, KeyOfT, Hash> HT;
		typedef HashNode<T> node;
		node* _node;
		HT* _pht;

		__HashTableIterator(node* n,HT* pht)
			:_node(n)
			, _pht(pht)
		{}
	};

我們所要實現的迭代器重要有*,->,++,==,!=

因爲在++的操作中,如果出現下面這種情況
在這裏插入圖片描述
當我們把當前鏈表的數據都遍歷完了,我們就需要到下一個pos位置去查找數據了。那麼在查找的時候,我們需要訪問的是這個哈希表的私有成員,所以我們就需要將迭代器設置爲哈希表的友元。

//設置迭代器爲自己的友元
friend struct __HashTableIterator < K, T, KeyOfT, Hash > ;

++的重載

	Self operator++()
	{
		if (_node->_next != nullptr)
		{
			_node = _node->_next;
			return *this;
		}

		//當前權值的桶已經走完了,需要找到下一個桶
		KeyOfT koft;
		size_t i = _pht->HashFunc(koft(_node->_data)) % _pht->_table.size();
		i++;//到下一個桶的位置
		for (; i < _pht->_table.size(); i++)
		{
			node* cur = _pht->_table[i];
			if (cur)
			{
				_node = cur;
				return *this;
			}
		}
		_node = nullptr;//這裏說明遍歷完了最後一個桶
		return *this;
	}

unordered_map的模擬實現

他的底層是調用哈希表的接口,所以很多功能函數只是一個包裝的作用,這裏只是實現一下[]的重載

這是unordered_map的大體框架。

template<class K,class V,class Hash = _Hash<K> >
	class unordered_map
	{
		//計算權值的數據的訪問器
		struct MapKOfT
		{
			const K& operator()(const pair<K, V>& kv)
			{
				return kv.first;
			}
		};
	public:
		typedef typename HashTable<K, pair<K, V>, MapKOfT, Hash>::iterator iterator;
	private:
		HashTable<K, pair<K, V>, MapKOfT, Hash> _ht;
	};

[]的重載,跟map是一個道理,我們只需要進行插入,然後返回第二個數據的引用就可以了

	V& operator[](const K& key)
	{
		pair<iterator, bool> ans = _ht.insert(make_pair(key, V()));
		return ans.first->second;
	}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章