哈希概念
順序結構以及平衡樹中,元素關鍵碼與其存儲位置之間沒有對應的關系,因此在查找一個元素時,必須要經過關鍵碼的多次比較。順序查找時間復雜度為O(N),平衡樹中為樹的高度,即O( Log2N)
,搜索的效率取決于搜索過程中元素的比較次數。
理想的搜索方法:可以不經過任何比較,一次直接從表中得到要搜索的元素。 如果構造一種存儲結構,通過某種函數(hashFunc)使元素的存儲位置與它的關鍵碼之間能夠建立一一映射的關系,那么在查找時通過該函數可以很快找到該元素。
當向該結構中:
- 插入元素
根據待插入元素的關鍵碼,以此函數計算出該元素的存儲位置并按此位置進行存放 - 搜索元素
對元素的關鍵碼進行同樣的計算,把求得的函數值當做元素的存儲位置,在結構中按此位置取元素比較,若關鍵碼相等,則搜索成功
該方式即為哈希(散列)方法,哈希方法中使用的轉換函數稱為哈希(散列)函數,構造出來的結構稱為哈希表(Hash Table)(或者稱散列表)
哈希沖突
不同元素通過相同哈希函數計算出相同的哈希地址,該種現象稱為哈希沖突或哈希碰撞。
把具有不同關鍵碼而具有相同哈希地址的數據元素稱為“同義詞”。
所以我們需要進一步的改進哈希函數來解決沖突
哈希函數
引起哈希沖突的一個原因可能是:哈希函數設計不夠合理。 哈希函數設計原則:
- 哈希函數的定義域必須包括需要存儲的全部關鍵碼,而如果散列表允許有m個地址時,其值域必須在0到m-1之間
- 哈希函數計算出來的地址能均勻分布在整個空間中
- 哈希函數應該比較簡單
常用的哈希函數
- 直接定制法
取關鍵字的某個線性函數為散列地址:Hash(Key)= A*Key + B
優點:簡單、均勻 缺點:需要事先知道關鍵字的分布情況 使用場景:適合查找比較小且連續的情況 - 除留余數法
設散列表中允許的地址數為m,取一個不大于m,但最接近或者等于m的質數p作為除數,按照哈希函數:Hash(key) = key% p(p<=m)
,將關鍵碼轉換成哈希地址 - 平方取中法
假設關鍵字為1234,對它平方就是1522756,抽取中間的3位227作為哈希地址; 再比如關鍵字為4321,對它平方就是18671041,抽取中間的3位671(或710)作為哈希地址 平方取中法比較適合:不知道關鍵字的分布,而位數又不是很大的情況 - 折疊法
折疊法是將關鍵字從左到右分割成位數相等的幾部分(最后一部分位數可以短些),然后將這幾部分疊加
求和,并按散列表表長,取后幾位作為散列地址。折疊法適合事先不需要知道關鍵字的分布,適合關鍵字位數比較多的情況 - 隨機數法
選擇一個隨機函數,取關鍵字的隨機函數值為它的哈希地址,即H(key) = random(key),其中random為隨機數函數。通常應用于關鍵字長度不等時采用此法 - 數學分析法
假設要存儲某家公司員工登記表,如果用手機號作為關鍵字,那么極有可能前7位都是 相同的,那么我們可以選擇后面的四位作為散列地址,如果這樣的抽取工作還容易出現 沖突,還可以對抽取出來的數字進行反轉(如1234改成4321)、右環位移(如1234改成4123)、左環移位、前兩數與后兩數疊加(如1234改成12+34=46)等方法。數字分析法通常適合處理關鍵字位數比較大的情況,如果事先知道關鍵字的分布且關鍵字的若干位分布較均勻的情況
不論函數設計的多好----都不可能完全的解決哈希沖突
哈希函數的沖突解決
閉散列
也叫開放定址法,當發生哈希沖突時,如果哈希表未被裝滿,說明在哈希表中必然還有空位置,那
么可以把key存放到沖突位置中的“下一個” 空位置中去
1. 線性探測
- 插入
通過哈希函數獲取待插入元素在哈希表中的位置如果該位置中沒有元素則直接插入新元素,如果該位置中有元素發生哈希沖突,使用線性探測找到下一個空位置,插入新元素
- 刪除
采用閉散列處理哈希沖突時,不能隨便物理刪除哈希表中已有的元素,若直接刪除元素會影響其他元素的搜索。比如刪除元素34,如果直接刪除掉,104查找起來可能會受影響。因此線性探測采用標記的偽刪除法來刪除一個元素。
/ 哈希表每個空間給個標記
// EMPTY此位置空, EXIST此位置已經有元素, DELETE元素已經刪除
enum State{EMPTY, EXIST, DELETE};
線性探測實現
規定:表格中元素唯一
線性探測:表格中的元素一定不會存滿-----隨著元素不斷增多,產生哈希沖突的概率就急劇增加
#include<iostream>
using namespace std;
#include<vector>
enum State{ EMPTY, EXIST, DELETE };
template<class T>
class hashtable
{struct Elem //存儲的元素類型{ Elem():_state(EMPTY){}T _data;State _state;};
public:hashtable(size_t capacity):_table(capacity), _size(0){}bool Insert(const T&data){//檢測哈希空間是否充足_CheckCapacity();//1.通過哈希函數計算哈希地址size_t hashAddr = HashFunc(data);//2.檢測位置是否可以存儲while(_table[hashAddr]._state != EMPTY){//檢測該位置元素是否有效&&是否為待插入元素if (EXIST == _table[hashAddr]._state&&data == _table[hashAddr]._data){return false;//元素已經存在,不進行插入}//發生哈希沖突++hashAddr;if (hashAddr == _table.capacity())//如果地址越界了,把地址放回0hashAddr = 0;}//3.插入元素_table[hashAddr]._data = data;_table[hashAddr]._state = EXIST;_size++;return true;}int Find(const T&data){size_t hashAddr = HashFunc(data);while (_table[hashAddr]._state != EMPTY){if (EXIST == _table[hashAddr]._state&&data == _table[hashAddr]._data){return hashAddr;}//發生哈希沖突hashAddr++;if (hashAddr == _table.capacity())hashAddr = 0;}return -1;}bool Erase(const T&data){int ret = Find(data);if (-1 != ret){_table[ret]._state = DELETE;--_size;return true;}return false;}size_t Size()const{return _size;}
private:size_t HashFunc(const T&data){return data%_table.capacity();}
private:vector<Elem>_table; //狀態表格size_t _size;
};
哈希算法
什么時候增容?
哈希的負載因子
哈希負載因子= 有效元素個數/哈希表的容量
哈希因子越小:產生沖突的概率越低
負載因子控制在0.7左右,性能就還可以
負載因子達到0.7,就需要進行擴容
擴容
void Swap(hashtable<T>& ht){_table.swap(ht._table);swap(_size, ht._size);swap(_total, ht._total);}
//擴容
void _CheckCapacity()
{if (_total * 10 / _table.capacity() >= 7){//新的哈希表hashtable<T>newHT(_table.capacity() * 2);//獎舊哈希表中有效元素搬移到新的哈希表中//注意:已經刪除的元素不用搬移for (size_t i = 0; i < _table.capacity(); ++i){if (_table[i]._data == EXIST)newHT.Insert(_table[i]._data);}Swap(newHT);}
}
線性探測缺陷
容易產生數據的堆積-----產生沖突的元素全部集中在一起,一但產生沖突,可能會引起一連串的沖突,解決方法:二次探測
二次探測
線性探測的缺陷是產生沖突的數據堆積在一塊,這與其找下一個空位置有關系,因為找空位置的方式就是挨著往后逐個去找,因此二次探測為了避免該問題,找下一個空位置的方法為:H(i) = ( H(0)+i2 )% m,或者:H(i) = (H(0) -i2 )% m。其中:i = 1,2,3…, 是通過散列函數Hash(x)對元素的關鍵碼 key 進行計算得到的位置,m是表的大小。(i代表第幾次探測)
代碼實現
enum State{ EMPTY, EXIST, DELETE };
template<class T, bool IsLine = true>
class hashtable
{struct Elem //存儲的元素類型{ Elem():_state(EMPTY){}T _data;State _state;};
public:hashtable(size_t capacity):_table(capacity), _size(0),_total(0){}bool Insert(const T&data){//檢測哈希空間是否充足_CheckCapacity();//1.通過哈希函數計算哈希地址size_t hashAddr = HashFunc(data);size_t i = 0;//二次探測中的第幾次探測//2.檢測位置是否可以存儲while(_table[hashAddr]._state != EMPTY){//檢測該位置元素是否有效&&是否為待插入元素if (EXIST == _table[hashAddr]._state&&data == _table[hashAddr]._data){return false;//元素已經存在,不進行插入}//發生哈希沖突if (IsLine){DetectiveLine(hashAddr);}else{++i;DetectiveTwice(hashAddr,i);}}//3.插入元素_table[hashAddr]._data = data;_table[hashAddr]._state = EXIST;_size++;_total++;return true;}int Find(const T&data){size_t hashAddr = HashFunc(data);size_t i = 0;while (_table[hashAddr]._state != EMPTY){if (EXIST == _table[hashAddr]._state&&data == _table[hashAddr]._data){return hashAddr;}//發生哈希沖突if (IsLine){DetectiveLine(hashAddr);}else{++i;DetectiveTwice(hashAddr,i);}}return -1;}bool Erase(const T&data){int ret = Find(data);if (-1 != ret){_table[ret]._state = DELETE;--_size;return true;}return false;}size_t Size()const{return _size;}void Swap(hashtable<T,IsLine>& ht){_table.swap(ht._table);swap(_size, ht._size);swap(_total, ht._total);}
private:size_t HashFunc(const T&data){return data%_table.capacity();}//線性探測void DetectiveLine(size_t&hashAddr){hashAddr += 1;if (hashAddr == _table.capacity())hashAddr = 0;}//二次探測void DetectiveTwice(size_t& hashAddr, size_t i){hashAddr = hashAddr + 2 * i + 1;if (hashAddr >= _table.capacity())hashAddr %= _table.capacity();}//擴容void _CheckCapacity(){if (_total * 10 / _table.capacity() >= 7){//新的哈希表hashtable<T,IsLine>newHT(_table.capacity() * 2);//獎舊哈希表中有效元素搬移到新的哈希表中//注意:已經刪除的元素不用搬移for (size_t i = 0; i < _table.capacity(); ++i){if (_table[i]._data == EXIST)newHT.Insert(_table[i]._data);}Swap(newHT);}}
private:vector<Elem>_table; //狀態表格size_t _size; //哈希表中有效元素的個數size_t _total; //哈希表中元素的個數:存在和刪除(為了保證哈希表中數據的唯一性,刪除位置不能插入元素)
};
研究表明:當表的長度為質數且表裝載因子a不超過0.5時,新的表項一定能夠插入,而且任何一個位置都不會被探查兩次。因此只要表中有一半的空位置,就不會存在表滿的問題。在搜索時可以不考慮表裝滿的情況,但在插入時必須確保表的裝載因子a不超過0.5,如果超出必須考慮增容
閉散列最大的缺陷就是空間利用率比較低,這也是哈希的缺陷。
開散列
開散列概念
開散列法又叫鏈地址法(開鏈法),首先對關鍵碼集合用散列函數計算散列地址,具有相同地址的關鍵碼
歸于同一子集合,每一個子集合稱為一個桶,各個桶中的元素通過一個單鏈表鏈接起來,各鏈表的頭結點存儲在哈希表中。每個鏈表中保存發生哈希沖突的元素
代碼實現
//開散列:一個鏈表的集合---產生相同哈希地址的元素放到同一個鏈表里
#include<vector>
//哈希表的結點
template<class T>
struct HashNode
{HashNode(const T&data = T()):_pNext(nullptr), _data(data){}HashNode<T>* _pNext;T _data;
};template<class T>
class hashbucket
{typedef HashNode<T> Node;
public:hashbucket(size_t capacity):_table(capacity), _size(0){}~hashbucket(){Clear();}bool Insert(const T& data){//擴容_CheckCapacity();//通過哈希函數計算哈希的桶號size_t bucketNo = HashFunc(data);Node* pCur = _table[bucketNo];while (pCur){if (data == pCur->_data)return false;pCur = pCur->_pNext;}pCur = new Node(data);pCur->_pNext = _table[bucketNo];_table[bucketNo] = pCur;_size++;return true;}bool Erase(const T&data){//先找在哪個鏈表size_t bucketNo = HashFunc(data);//在鏈表中找元素Node* pCur = _table[bucketNo];Node* pPrev = nullptr;while (pCur){if (data == pCur->_data){//可以刪除if (pCur == _table[bucketNo])_table[bucketNo] = pCur->_pNext;elsepPrev->_pNext = pCur->_pNext;delete pCur;--_size;return true;}else{pPrev = pCur;pCur = pCur->_pNext;}}return false;}Node* Find(const T& data){size_t bucketNo = HashFunc(data);Node* pCur = _table[bucketNo];while (pCur){if (pCur->_data == data)return pCur;pCur = pCur->_pNext;}return nullptr;}size_t Size()const{return _size;}bool Empty()const{return 0 == _size;}void Clear(){for (size_t bucketNo = 0; bucketNo < _table.capacity(); ++bucketNo){Node* pCur = _table[bucketNo];while (pCur){_table[bucketNo] = pCur->_pNext;delete pCur;pCur = _table[bucketNo];}}}/*void _CheckCapacity(){if (_size == _table.capacity())}*///打印函數void PrintHashBucket(){for (int i = 0; i < _table.capacity(); ++i){Node* pCur = _table[i];cout << "table[" << i << "]:";while (pCur){cout << pCur->_data << "-->";pCur = pCur->_pNext;}cout << "NULL" << endl;}}
private:size_t HashFunc(const T&data){//T:可以是任意類型---可能不是整型return data % _table.capacity();}
private:vector<Node*> _table;//每一個鏈表的首地址size_t _size; //當前哈希桶里放了多少個元素
};
開散列的增容
桶的個數是一定的,隨著元素的不斷插入,每個桶中元素的個數不斷增多,極端情況下,可能會導致一個桶中鏈表節點非常多,會影響的哈希表的性能,因此在一定條件下需要對哈希表進行增容,那該條件怎么確認呢?開散列最好的情況是:每個哈希桶中剛好掛一個節點,再繼續插入元素時,每一次都會發生哈希沖突,因此,在元素個數剛好等于桶的個數時,可以給哈希表增容。
將舊哈希桶里的結點直接向新哈希桶里進行搬移,在新哈希桶里不要創建新的結點
void Swap(hashbucket<T>& ht)
{_table.swap(ht._table);swap(_size, ht._size);
}
void _CheckCapacity()
{if (_size == _table.capacity()){//新的哈希桶hashbucket<T> newHT(_table.capacity() * 2);//將舊哈希桶中的結點往新哈希桶中進行搬移for (size_t i = 0; i < _table.capacity(); ++i){Node* pCur = _table[i];//將i號桶中的所有結點搬移到新哈希桶中while (pCur){//將pCur結點從table的i號移除掉_table[i] = pCur->_pNext;//將pCur插入到新哈希桶中size_t bucketNo = newHT.HashFunc(pCur->_data);//采用頭插法pCur->_pNext = newHT._table[bucketNo];newHT._table[bucketNo] = pCur;newHT._size++;_size--;pCur = _table[i];}}//新桶和舊桶進行交換this->Swap(newHT);}
}
哈希表只能存儲int類型的元素,如何實現任意類型
template<class T>
struct HashNode
{HashNode(const T&data = T()):_pNext(nullptr), _data(data){}HashNode<T>* _pNext;T _data;
};template<class T>
struct DefD2INIT
{const T& operator()(const T& data){return data;}
};struct Str2INT
{size_t operator()(const string& s){stringstream ss;ss << s;size_t ret;ss >> ret;return ret;}
};
template<class T,class DTOINT = DefD2INIT<T>>
class hashbucket
{typedef HashNode<T> Node;typedef hashbucket<T, DTOINT> Self;
public:hashbucket(size_t capacity):_table(capacity), _size(0){}~hashbucket(){Clear();}bool Insert(const T& data){//擴容_CheckCapacity();//通過哈希函數計算哈希的桶號size_t bucketNo = HashFunc(data);Node* pCur = _table[bucketNo];while (pCur){if (data == pCur->_data)return false;pCur = pCur->_pNext;}pCur = new Node(data);pCur->_pNext = _table[bucketNo];_table[bucketNo] = pCur;_size++;return true;}bool Erase(const T&data){//先找在哪個鏈表size_t bucketNo = HashFunc(data);//在鏈表中找元素Node* pCur = _table[bucketNo];Node* pPrev = nullptr;while (pCur){if (data == pCur->_data){//可以刪除if (pCur == _table[bucketNo])_table[bucketNo] = pCur->_pNext;elsepPrev->_pNext = pCur->_pNext;delete pCur;--_size;return true;}else{pPrev = pCur;pCur = pCur->_pNext;}}return false;}Node* Find(const T& data){size_t bucketNo = HashFunc(data);Node* pCur = _table[bucketNo];while (pCur){if (pCur->_data == data)return pCur;pCur = pCur->_pNext;}return nullptr;}size_t Size()const{return _size;}bool Empty()const{return 0 == _size;}void Clear(){for (size_t bucketNo = 0; bucketNo < _table.capacity(); ++bucketNo){Node* pCur = _table[bucketNo];while (pCur){_table[bucketNo] = pCur->_pNext;delete pCur;pCur = _table[bucketNo];}}}void Swap( Self& ht){_table.swap(ht._table);swap(_size, ht._size);}void _CheckCapacity(){if (_size == _table.capacity()){//新的哈希桶Self newHT(_table.capacity() * 2);//將舊哈希桶中的結點往新哈希桶中進行搬移for (size_t i = 0; i < _table.capacity(); ++i){Node* pCur = _table[i];//將i號桶中的所有結點搬移到新哈希桶中while (pCur){//將pCur結點從table的i號移除掉_table[i] = pCur->_pNext;//將pCur插入到新哈希桶中size_t bucketNo = newHT.HashFunc(pCur->_data);//采用頭插法pCur->_pNext = newHT._table[bucketNo];newHT._table[bucketNo] = pCur;newHT._size++;_size--;pCur = _table[i];}}//新桶和舊桶進行交換this->Swap(newHT);}}void PrintHashBucket(){for (int i = 0; i < _table.capacity(); ++i){Node* pCur = _table[i];cout << "table[" << i << "]:";while (pCur){cout << pCur->_data << "-->";pCur = pCur->_pNext;}cout << "NULL" << endl;}}
private:size_t HashFunc(const T&data){//T:可以是任意類型---可能不是整型return DTOINT ()(data) % _table.capacity();}
private:vector<Node*> _table;//每一個鏈表的首地址size_t _size; //當前哈希桶里放了多少個元素
};
除留余數法,最好模一個素數,如何每次快速取一個類似兩倍關系的素數?
//素數表,以遞進兩倍方式給出
const int PRIMECOUNT = 28;
const size_t primeList[PRIMECOUNT] =
{53ul, 97ul, 193ul, 389ul, 769ul,1543ul, 3079ul, 6151ul, 12289ul, 24593ul,49157ul, 98317ul, 196613ul, 393241ul, 786433ul,1572869ul, 3145739ul, 6291469ul, 12582917ul, 25165843ul,50331653ul, 100663319ul, 201326611ul, 402653189ul, 805306457ul,1610612741ul, 3221225473ul, 4294967291ul
};size_t GetNextPrime(size_t prime)
{size_t i = 0;for (; i < PRIMECOUNT; ++i){//當前素數大于提供的素數if (primeList[i] > prime)//返回return primeList[i];}//返回最后一個return primeList[PRIMECOUNT - 1];
}
開散列與閉散列比較
應用鏈地址法處理溢出,需要增設鏈接指針,似乎增加了存儲開銷。事實上: 由于開地址法必須保持大量的空閑空間以確保搜索效率,如二次探查法要求裝載因子a <= 0.7,而表項所占空間又比指針大的多,所以使用鏈地址法反而比開地址法節省存儲空間。