依稀還記得上學期期末,數據結構課設的抽籤中抽中了哈希表實現的電話號碼查詢系統,再回過頭來看自己寫的,好菜。。。
github程序鏈接https://github.com/duchenlong/Cpp/tree/master/Hash/Hash
因爲紅黑樹在查找中的效率爲O(log N),爲了能夠更爲快速的查找數據,最好是趨於O(1)的時間複雜度。於是C++11中新加入了unordered_map
和unordered_set
兩個容器,不同於map和set
的是,map和set
的底層是紅黑樹的接口,而unordered_map
和unordered_set
的底層是哈希表的接口,可以讓查找效率無限接近於O(1)。
哈希表的原理
首先有這樣一個問題,就是給定一串數據,再給一個數字num,怎麼在O(1)的時間複雜度內判斷num在那一串數據中有沒有出現?
直接定製法(適用於數據範圍比較小的時候)
因爲是數據類型,如果數據的範圍不是很大的話,我們就可以開闢數據範圍大小的數組,讓數組的下標位置就表示這個數據,那麼我們就可以在O(1)的時間範圍內判斷出該數據有沒有出現,只需判斷arr[num] == num
就可以知道答案了。
但是實際中,很有可能這個數據比較極端:
-
1,10000
這種的,明明只有兩個數據,卻要我們開10000個數據的空間,就造成了很大程度的浪費; -
再比如
4,294,967,296
這樣的數據,表示的範圍很大,我們在32位的系統中,就得開闢4G大小的空間
,這有點不現實。 -
再一種就是
字符串(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存放到衝突位置中的“下一個” 空位置中去。
問題又來了,如何找這個位置更好呢?
- 線性探測法
從發生衝突的位置開始,依次向後探測(如果到了數組的末尾,就需要來一個循環,從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++;
- 二次探測
爲了解決線性探測中,相同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倍
這裏又兩種寫法
- 第一種就是新建一個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);
}
- 新建一個哈希表,調用這個哈希表的插入函數,最後再交換兩個哈希表底層的數組
//是否需要擴容
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;
}