1 哈希概念
顺序结构以及平衡树中,元素关键码与其存储位置之间没有对应的关系,因此在查找一个元素时,必须要经过关键码的多次比较。顺序查找时间复杂度为O(N),平衡树中为树的高度,即O(l o g 2 N log_2 Nlog 2 N),搜索的效率取决于搜索过程中元素的比较次数。
理想的搜索方法:可以不经过任何比较,一次直接从表中得到要搜索的元素。如果构造一种存储结构,通过某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立一一映射的关系,那么在查找时通过该函数可以很快找到该元素。
当向该结构中:
插入元素
根据待插入元素的关键码,以此函数计算出该元素的存储位置并按此位置进行存放。
搜索元素
对元素的关键码进行同样的计算,把求得的函数值当做元素的存储位置,在结构中按此位置取元素比较,若关键码相等,则搜索成功。
该方式即为哈希(散列)方法,哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称为哈希表(Hash Table)(或者称散列表)
例如:数据集合{1,7,6,4,5,9};
哈希函数设置为:hash(key) = key % capacity; capacity为存储元素底层空间总的大小。
但是我们再插入一个数,例如14 ,会出现什么问题?
是不是就和之前的4给冲突了,那我们应该怎样解决哈希冲突呢?
2 哈希冲突
对于两个数据元素的关键字k i k_ik i和 k j k_jk j (i != j),有k i k_ik i != k j k_jk j ,但有:Hash(k i k_ik i ) == Hash(k j k_jk j ),即:不同关键字通过相同哈希哈数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞。
把具有不同关键码而具有相同哈希地址的数据元素称为“同义词”。
引起哈希冲突的一个原因可能是:哈希函数设计不够合理。
哈希函数设计原则:
哈希函数的定义域必须包括需要存储的全部关键码,而如果散列表允许有m个地址时,其值 域必须在0到m-1之间.
哈希函数计算出来的地址能均匀分布在整个空间中 哈希函数应该比较简单.
常见哈希函数
2.1 直接定址法 (常用)
取关键字的某个线性函数为散列地址:Hash(Key)= A*Key + B
优点:简单、均匀
缺点:需要事先知道关键字的分布情况
使用场景:适合查找比较小且连续的情况
2.2 除留余数法 (常用)
设散列表中允许的地址数为m,取一个不大于m,但最接近或者等于m的质数p作为除数,按照哈希函数:Hash(key) = key% p(p<=m),将关键码转换成哈希地址。
2.3 平方取中法
假设关键字为1234,对它平方就是1522756,抽取中间的3位227作为哈希地址;再比如关键字为4321,对它平方就是18671041,抽取中间的3位671(或710)作为哈希地址平方取中法比较适合:不知道关键字的分布,而位数又不是很大的情况.
2.4 折叠法
折叠法是将关键字从左到右分割成位数相等的几部分(最后一部分位数可以短些),然后将这几部分叠加求和,并按散列表表长,取后几位作为散列地址。
2.5 随机数法
选择一个随机函数,取关键字的随机函数值为它的哈希地址,即H(key) = random(key),其中random为随机数函数。
2.6 数学分析法
设有n个d位数,每一位可能有r种不同的符号,这r种不同的符号在各位上出现的频率不一定相同,可能在某些位上分布比较均匀,每种符号出现的机会均等,在某些位上分布不均匀只有某几种符号经常出现。可根据散列表的大小,选择其中各种符号分布均匀的若干位作为散列地址。例如:假设要存储某家公司员工登记表,如果用手机号作为关键字,那么极有可能前7位都是 相同的,那么我们可以选择后面的四位作为散列地址,如果这样的抽取工作还容易出现 冲突,还可以对抽取出来的数字进行反转(如1234改成4321)、右环位移(如1234改成4123)、左环移位、前两数与后两数叠加(如1234改成12+34=46)等方法。数字分析法通常适合处理关键字位数比较大的情况,如果事先知道关键字的分布且关键字的若干位分布较均匀的情况.
注意:哈希函数设计的越精妙,产生哈希冲突的可能性就越低,但是无法避免哈希冲突。
解决哈希冲突两种常见的方法是:闭散列和开散列
3 闭散列
闭散列:也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以把key存放到冲突位置中的“下一个” 空位置中去。那如何寻找下一个空位置呢?
3.1 线性探测
比如2上面中的场景,现在需要插入元素14,先通过哈希函数计算哈希地址,hashAddr为4,因此44理论上应该插在该位置,但是该位置已经放了值为4的元素,即发生哈希冲突。那我们就将14插入到hashAddr为4后面没有被使用的地方(如果超出了表的长度就需要重新从头开始查找)。
但是这样做了还有一个小问题:我们删除了一个数再次查找时应该怎样查找呢?
如果只是查找为空位置为止的话那么删除元素后面的数据是不是都找不到了,所以为了方便区分各个元素的状态,我们不妨将元素的状态分为:空,存在,删除。
那我们就可以先把基本框架先搭好:
namespace CloseHash { enum State { DELETE, EMPTY, EXIST }; template<class K, class V> struct HashData { pair<K, V> _kv; State _st = EMPTY; }; template<class K> struct HashTranfor { size_t operator()(const K& k) { return (size_t)k; } }; //特化一个版本出来 template<> struct HashTranfor<string> { // BKDR size_t operator()(const string& key) { size_t val = 0; for (auto ch : key) { val *= 131; val += ch; } return val; } }; template<class K,class V,class Hash=HashTranfor<K>> class HashTable { private: vector<HashData<K,V>> _tables; size_t _size=0; public: };
大家发现了没有,除了模板参数我们除了给出了K,V关系外,我们还给了一个仿函数,为什么我们要给出仿函数呢?
其实也很好理解,因为我们插入查询的关键值可能不仅仅是能够直接取模的,比如常见的我们还可能插入字符串等等,所以我们的要有一个能够将关键值转化为整形的仿函数,因为字符串是比较常见的,所以我们特化了一个版本出来,字符串哈希的方式有很多种,想要进一步了解的老哥可以移步下面的文章:【字符串哈希算法】
插入:通过之前的分析我们知道:当表中空间中有大量位置已经有数据时再次插入数据发生冲突的可能性也会更大,那我们可以通过负载因子来控制表中数据量,一般负载因子给出在0.7~0.8之间比较合适,但是最大的问题是扩容咋办?这也是闭散列的劣势所在,扩容了后原来位置的映射关系可能发生了改变,所以之前旧表的元素得重新映射一遍,而扩容是闭散列效率低下的主要原因。
那应该如何扩容呢?比较常见的做法是我们在开一个空间更大的vector,然后在重复一遍之前插入的动作,但是我们可以采用一下更为巧妙的做法:
bool Insert(const pair<K, V>& kv) { if (Find(kv.first)) return false; //扩容 if (_tables.size() == 0 || (_size * 10 / _tables.size() >= 7)) { //负载因子满了就扩容,一般负载因子给到0.7 size_t newCapacity = _tables.size() == 0 ? 10 : _tables.size() * 2; HashTable<K, V> newHash;//这里也可以考虑直接用一个vector, //但是没必要,创建一个新的哈希表直接调用Insert即可 newHash._tables.resize(newCapacity); for (auto& e : _tables) { if (e._st == EXIST) newHash.Insert(e._kv); } newHash._tables.swap(_tables); } Hash hash; //1 线性探测 size_t hashi =hash(kv.first) % _tables.size(); while (_tables[hashi]._st == EXIST) { ++hashi; hashi %= _tables.size(); } _tables[hashi]._kv = kv; _tables[hashi]._st = EXIST; ++_size; return true;
注意:
- 代码中我们直接创建了一张新表,通过新表直接复用Insert即可,然后再交换旧表与新表的vector即可。
- 这里面还有一个小细节是每次模表的长度时我们模的都是size(),而不是capacity,这是由于我们要用的空间必须是已经开好了可以直接通过下标访问的。
- 查询:
HashData<K, V>* Find(const K& k) { if (_tables.size() == 0)//出错控制 return nullptr; Hash hash; //size_t hashi = k % _tables.size();//负数也可以处理,会将k先转换成无符号数再取模 //注意这里用二次探测的时候也是用的是一个一个向后查找 size_t hashi = hash(k) % _tables.size(); size_t start = hashi; while (_tables[hashi]._st != EMPTY) { if (_tables[hashi]._st == EXIST && _tables[hashi]._kv.first == k) return &_tables[hashi]; ++hashi; hashi %= _tables.size(); if (hashi == start)//防止表中全部剩的是删除的元素或者大部分是删除状态和存在状态而导致死循环 break; } return nullptr; }
查询时有一个特别容易让人忽略的地方:当表中元素全部都是删除状态和存在状态时上面代码也能够正常处理,我们记录了最开始的位置当我们重新回到了原位置时就退出循环。
删除:
bool erase(const K& k) { if (Find(k) == nullptr) return false; auto ptr=Find(k); ptr->_st = EMPTY; --_size; return true; }
线性探测优点:实现非常简单;
线性探测缺点:一旦发生哈希冲突,所有的冲突连在一起,容易产生数据“堆积”,即:不同关键码占据了可利用的空位置,使得寻找某关键码的位置需要许多次比较,导致搜索效率降低。如何缓解呢?
我们可以用二次探测来缓解线性探测踩踏问题。
3.2 二次探测
线性探测的缺陷是产生冲突的数据堆积在一块,这与其找下一个空位置有关系,因为找空位置的方式就是挨着往后逐个去找,因此二次探测为了避免该问题,找下一个空位置的方法为:H i H_iH i= (H 0 H_0H 0 + i 2 i^2i 2 )% m, 或者:H i H_iH i = (H 0 H_0H 0 - i 2 i^2i 2)% m。其中:i = 1,2,3…, H 0 H_0H 0是通过散列函数Hash(x)对元素的关键码 key 进行计算得到的位置,m是表的大小。
二次探测的实现也很简单,但是无论是线性探测还是二次探测都不能解决闭散列扩容时的代价。
二次探测插入的实现:
bool Insert(const pair<K, V>& kv) { if (Find(kv.first)) return false; //扩容 if (_tables.size() == 0 || (_size * 10 / _tables.size() >= 7)) { //负载因子满了就扩容,一般负载因子给到0.7 size_t newCapacity = _tables.size() == 0 ? 10 : _tables.size() * 2; HashTable<K, V> newHash;//这里也可以考虑直接用一个vector, //但是没必要,创建一个新的哈希表直接调用Insert即可 newHash._tables.resize(newCapacity); for (auto& e : _tables) { if (e._st == EXIST) newHash.Insert(e._kv); } newHash._tables.swap(_tables); } Hash hash; //2 二次探测 size_t hashi = hash(kv.first) % _tables.size(); int i = 0; while (_tables[hashi]._st == EXIST) { ++i; hashi = i * i; hashi %= _tables.size(); } _tables[hashi]._kv = kv; _tables[hashi]._st = EXIST; ++_size; return true; }
查找的代码大家可以参考线性探测的代码再修改修改,这里我就不在多说了,很简单。
4 开散列
4.1 开散列概念
开散列法又叫链地址法(开链法),首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。
我们可以将结点像桶似的一个一个挂起来:
这样冲突的元素就在同一条链子上。
4.2哈希桶的模拟实现
开散列的模拟实现:
namespace BucketHash { template<class K,class V> struct BucketHashNode { BucketHashNode* _next; pair<K, V> _kv; BucketHashNode(const pair<K,V>& kv) :_next(nullptr) ,_kv(kv) {} }; template<class K> struct HashTranfor { size_t operator()(const K& k) { return (size_t)k; } }; //特化一个版本出来 template<> struct HashTranfor<string> { // BKDR size_t operator()(const string& key) { size_t val = 0; for (auto ch : key) { val *= 131; val += ch; } return val; } }; static const int __stl_num_primes = 28; static const unsigned long __stl_prime_list[__stl_num_primes] = { 53, 97, 193, 389, 769, 1543, 3079, 6151, 12289, 24593, 49157, 98317, 196613, 393241, 786433, 1572869, 3145739, 6291469, 12582917, 25165843, 50331653, 100663319, 201326611, 402653189, 805306457, 1610612741, 3221225473, 4294967291 }; template<class K, class V,class Hash=HashTranfor<K>> class HashTable { private: typedef BucketHashNode<K, V> Node; vector<Node*> _tables; size_t _size = 0; size_t GetCapacity(size_t sz) { int i = 0; for (; i < __stl_num_primes; ++i) { if (sz < __stl_prime_list[i]) break; } return __stl_prime_list[i]; } public: ~HashTable() { for (size_t i = 0; i < _tables.size(); ++i) { Node* cur = _tables[i]; while (cur) { Node* next = cur->_next; delete cur; cur = next; } _tables[i] = nullptr; } } bool Insert(const pair<K, V>& kv) { if (Find(kv.first)) return false; Hash hash; //扩容,负载因子为1 if (_size == _tables.size()) { //int newcapacity = _tables.size() == 0 ? 10 : _tables.size() * 2; int newcapacity =GetCapacity(_tables.size()); vector<Node*> newTables;//这里直接用vector的目的是可以直接用原表的结点直接链接即可 //不必多拷贝结点 newTables.resize(newcapacity); for (int i = 0; i < _tables.size(); ++i) { Node* cur = _tables[i]; //头插 while (cur) { Node* next = cur->_next; size_t hashi = hash(cur->_kv.first) % newTables.size(); cur->_next = newTables[hashi]; newTables[hashi] = cur; cur=next; } _tables[i] = nullptr; } _tables.swap(newTables); } size_t hashi = hash(kv.first) % _tables.size(); Node* newNode = new Node(kv); newNode->_next = _tables[hashi]; _tables[hashi] = newNode; ++_size; return true; } Node* Find(const K& k) { if (_tables.size()==0) return nullptr; Hash hash; size_t hashi = hash(k) % _tables.size(); Node* cur = _tables[hashi]; while (cur) { if (cur->_kv.first == k) return cur; cur = cur->_next; } return nullptr; } bool Erase(const K& k) { if (Find(k) == nullptr) return false; Hash hash; size_t hashi = hash(k) % _tables.size(); if (_tables[hashi]->_kv.first == k) { Node* del = _tables[hashi]; _tables[hashi] = del->_next; delete del; return true; } Node* cur = _tables[hashi]; while (cur->_next && cur->_next->_kv.first!=k) { cur = cur->_next; } Node* del = cur->_next; cur->_next = del->_next; delete del; return true; } size_t Size()const { return _tables.size(); } size_t BucketSize()const { int cnt = 0; for (int i = 0; i < Size(); ++i) { if (_tables[i]) ++cnt; } return cnt; } size_t MaxBucketSize()const { int maxLen = 0; for (int i = 0; i < Size(); ++i) { Node* cur = _tables[i]; int len = 0; while (cur) { ++len; cur = cur->_next; } maxLen = max(maxLen, len); } return maxLen; } };
注意:
代码中扩容时我们并没有直接从10然后二倍二倍的扩容,而是直接打表了一组数据(质数)扩容,这样出的好处时能够尽少的减低哈希冲突的概率。
扩容时我们并没有像之前闭散列那样直接创建一张新表然后复用insert,我们创建的一个新的vector,然后将原表的结点一个一个取下来头插即可,这样做节约了开辟结点的开销,但是一定要注意,新插入的节点不能够直接将对应链表直接连接过来,而是所有结点要重新映射关系,因为空间容量变了,必须重新映射数据。
至于其他地方我相信大家学了链表的增删查改后都是小意思了。
至于验证的话大家可以找一组随机数来测测哈希冲突的概率:
void test1() { srand(time(0)); HashTable<int, int> sh; int N = 100; for (int i = 0; i < N; ++i) { int x = rand(); sh.Insert(make_pair(x,x)); } cout << sh.BucketSize() << endl; cout << sh.MaxBucketSize() << endl; cout << sh.Size() << endl; cout << "负载因子" << (double)sh.BucketSize() / (double)sh.Size() << endl; }
4.3 开散列与闭散列的比较
应用链地址法处理溢出,需要增设链接指针,似乎增加了存储开销。事实上:由于开地址法必须保持大量的空闲空间以确保搜索效率,如二次探查法要求装载因子a <= 0.7,而表项所占空间又比指针大的多,所以使用链地址法反而比开地址法节省存储空间。
好了,今天的内容就到这里了,如果该文章对你有帮助的话能不能一件三连博主呢?💗💗💗