c++:哈希表
1.概念
哈希表又叫散列表,他是非顺序存储,存储的位置被交换并没有影响,将key和存储位置通过哈希函数进行映射,包含无序set和无序map。他的查找主要依靠哈希函数计算出key所处的位置,从而实现常数级别的查找。
2.四种实现方法
2.1直接定址法
直接定址法就是通过一个哈希函数,将一个key唯一绑定一个地址。
使用场景:数据比较集中
eg:26个英文字母直接映射到int[26]中,哈希函数:索引 = 英文字母 - 'a'。
从而就将a字母映射到了int[0]中
图示:
这里我们就是直接把a和0映射上,而这就是一个简单的直接定址法的哈希表
特点:
优点:不会存在key的地址冲突,因为每一个key都有唯一的地址映射。
哈希函数容易得出
缺点:当数据范围很广的时候会导致占用空间过多,因为空间利用率不高
2.2除留余数法
为了解决数据范围分散时直接定址法占用空间量大且空间利用率低的问题,我们采用对原数据取余数的方法制定哈希函数,从而缩短映射后的数据间距,提高空间利用率。
哈希函数:h(key) = key%p
eg:对于248和19491两个数,我们将余数定为400.
图示:
从而求得248存在索引为248,将19491存在索引为48.7275,不过由于数组的索引是整数,所以我们需要将非int数据转换为int,这里我们直接将小数去掉,19491索引直接就是48.
如此一来我们的248和19491之间的距离就从19243减少为200。
(1)优势:大大提高了空间利用率,并导致负载因子提高
(2)劣势:
取余的方法也会带来弊端,因为如果两个数余数一致,此时他们的哈希索引就会一样,从而出现哈希冲突
eg:将余数定为2^n,此时相当于我们只看了数据的二进制位的后n位。因为除2相当于二进制右移一位,取余2相当于取出二进制右移一位的数。
图示:
n取得大了:近似于直接定址法
n取得小了:无法将数据哈希索引分散,导致哈希冲突严重
而一个好的哈希函数应该尽量减少哈希冲突。
(3)疑问:我们应该怎么减少哈希冲突呢?
我们可以取一个不接近2的整数次幂的质数进行取余,因为2的整数次幂的计算不是依赖于整个二进制数的,仅仅依赖后面n个,导致特征不够详细,重复率较高。
不过如果能让整个二进制每一位数都能参与运算,也是可以使用2的整数次幂
2.2.1哈希函数
哈希函数是用来表示数据与存储地址之间的转换关系的。
一个理想的哈希函数可以做到将数据等概率的均匀分布到存储空间中,虽然实际上很难做到,不过我们需要往这个方向靠拢
2.2.2哈希冲突
哈希冲突指的是不同的key值被哈希函数映射到了同一个索引位置,从而导致存储冲突
图示:
这里的1和5对于2取余之后得到的结果都是1,所以他们存储的位置就都是索引为一的位置,会出现哈希冲突
2.2.3负载因子
负载因子指的是已存储了数据的地址数与总地址数的比。
负载因子越大表示空间利用率越高,被占用的空间越多
负载因子越小表示空间利用率越低,被占用的空间越少
图示:
2.3乘法散列法
对哈希表的大小M没有要求,我们确定一个常数A(0~1),然后用key*A,得出小数部分,然后用小数部分乘M,最后向下取整。
这里的关键是如何确定一个合适的常数A,我们可以尝试使用黄金分割点
哈希函数:
h ( key ) = floor ( M × (( A × key )%1.0))
2.4全域散列法
对于前面的三种方法,他们的内置参数都是不变的,所以如果有人测试出内置参数,然后构造一组冲突极高的数据,那么就很容易导致服务器崩溃。
所以我们这里的全域散列法就是将部分内置参数设置为随机值,然后利用他们进行计算来构造哈希函数,从而达到每次启动都是不同的哈希函数的目标,降低崩溃风险
3.处理哈希冲突的方法
除了直接定址法,其他不管是什么实现哈希表的方法都会遇到哈希冲突的问题,所以我们就需要使用一些方法解决哈希冲突
3.1开放定址法
开放定址法是将所有元素都放入哈希表中,他的负载因子一定是小于1的,一旦遇到位置冲突就按照某种规则继续搜索下一个可存储位置。
一共有三种搜索规则:
1.线性探测
2.二次探测
3.双重散列
3.1.1线性探测
基本逻辑:若位置冲突就线性探测后面的可存储位置,直到遇到空存储位置为止
hash0 = key%p:hash0表示初始计算位置,p表示取余数。
hash = (key+i)%p,其中i = {1,2,3.....}:这表示探测方式,而由于开放定址法的负载因子小于1,所以探测p-1次一定可以探测到空存储位置
eg:{19,51,36,72,30,84,13,17,37},p=11,进行插入
图示:
(1)哈希冲突1
这里我们看到,30和19关于11取余数都是8,所以他们都正常来说会存储在索引为8的位置,但是19已经存储进去了,所以我们的30就要往后面线性探测,找出下一个空的位置
往后面探测就发现索引为9的位置是空的,于是就直接存储进去
(2)哈希冲突2
这里的51和84也是同理,发生冲突,往后面搜索
索引为10的位置存储位置为空,插入进去
(3)哈希冲突3
(4)最终结果
代码实现:
(1)框架搭建
#include<iostream> #include<vector> using namespace std; //状态值 enum State { EXIST, EMPTY, DELETE }; //哈希值 template<class K, class V> struct HashData { pair<K, V> _kv; State _state = EMPTY; }; //哈希表 template<class K,class V> class HashTable { public: HashTable(size_t n = 0) :_table(nullptr) ,_n(n) { } private: vector<HashData<K, V>> _tables; size_t _n;//存储数据个数 };
注意:
1.查找需要找到第一个为空的存储位置才能停下,因为线性探测是挨着往后存储的,所以没有查找空我们都不确定是否有这个数
2.我们需要用三个状态常量(EXIST,EMPTY,DELETE)标记数据
如果我们先删除了某个数据,但是后面要查找的数据存储在该删除位置后面,此时探测为空我们就直接结束了,而实际上这个空不是真的没有数据,只是被删除,导致探测链路断了。
图示:
这里我们提前删除了余数同为6的72,导致该位置为空,而按照不加状态常量的逻辑,此时就直接停止搜索了
若我们对被删除数据的地址加上delete状态,就可以继续探测。
若是empty状态,表示从来没有插入过数据,停止探测
找到索引为6的位置发现状态为delete,所以继续探测,直到找到状态为empty的才结束探测
(2)insert实现
bool Insert(pair<K,V> kv) { size_t hash0 = kv.first % _tables.size(); size_t hashi = hash0; int i = 1; //负载因子超过0.7,扩容 if ((double)_n / (double)_tables.size() >= 0.7) { HashTable<K, V,Func> newtable = new HashTable(2 * _tables.size()); for (int i = 0; i < _tables.size(); i++) { if (_tables[i]._state == EXIST) { newtable.Insert(_tables[i]._kv); } } _tables.swap(newtable._tables); } //线性探测查找空位 while (_tables[hashi]._state == EXIST) { hashi = (hash0 + i) % _tables.size(); i++; } _tables[hashi]._kv = kv; _tables[hashi]._state = EXIST; _n++; }
注意:
1.我们这里取余不是对capacity取余,而是对size取余,因为对capacity取余得到的结果可能是位于size和capacity之间的,会导致逻辑错乱。
2.扩容的时候我们直接将旧表的exist状态值的kv插入到新表中,最后再把旧表和新表交换即可
3.delete转态是不用转移到新表的,因为新表相当于是重新排列存储数据,只有empty和exist状态,而不存在delete状态
4.其实库里面实现扩容的方法是先内置一些素数,且这些素数满足基本上逐渐翻倍的特性(这可以满足良好的扩容关系),这样子在扩容的时候就会直接按照内置的数据值进行扩容
(3)Find
HashData<K, V>* Find(K key) { size_t hash0 = key % _tables.size(); size_t hashi = hash0; int i = 1; //查找数据直到遇到空位 while (_tables[hashi]._state != EMPTY) { if (_tables[hashi]._kv.first == key && _tables[hashi]._state != DELETE) return &_tables[hashi]; hashi = (hash0 + i) % _tables.size(); i++; } return nullptr; }
需要注意的是我们只有遇到为exist状态且key值与查找key值一样才返回
(4)Erase
bool Erase(K key) { HashData<K, V>* del = Find(key); if (del) { del->_state = DELETE; return true; } else { return false; } }
删除直接状态置为delete即可
疑问:那么我们现在的哈希表实现完成了吗?
还没有,因为现在我们是直接用key去进行取余操作,而不是所有数据类型都可以进行取余操作的,所以我们还需要对key进行转换,将所有类型的key都转换为可以取余的数据类型。
该数据类型是size_t,且这个类型也可以防止负数索引的情况出现
//仿函数控制key类型 template <class K> struct HashFunc { size_t operator()(const K& key) { return (size_t)key; } };
不过实际上不是所有数据类型都可以转化成size_t的,比如内置类型的string和自定义类型。
此时我们就可以对仿函数的类进行类模板特化或者写一个新的仿函数针对这种类型,然后在调用的时候传递这个新的仿函数,从而控制需要控制的特殊情况。
(1)写一个新的特殊仿函数
//特定的仿函数控制string类型 template <class K> struct StringHashFunc { size_t operator()(const K& key) { size_t num = 0; for (auto& e : key) { num += e; } return num; } };
这里我们创建一个size_t类型的数据num,然后将string的每一个字符ascll码值加到num中。
(2)进行类模板特化
//类模板特化仿函数控制key类型 template <> struct HashFunc<string> { size_t operator()(const string& key) { size_t num = 0; for (auto& e : key) { num += e; } return num; } };
类模板的特化有个优势就是我们不用在调用的时候主动调用自己写的仿函数,而是直接类型匹配上去使用,不过这种情况只针对内置类型转换,因为库中没有实现我们的自定义类型的逻辑。
3.1.2二次探测
由于线性探测是依次往后查找空存储位置,所以很容易出现群积现象,导致很多数据都是堆在一起存储的,二次探测为了让数据均匀分布,将往后探测变为每次增加i^2,从而让存储位置更分散。
hash = (key+i^2)%p,i = {1,2,3,4...}
3.2链地址法
链地址法不再像开放定址法一样直接把数据存储在哈希表中,而是存储指针。
当该索引位置为空,存储nullptr
当该索引需要存储数据,就把该索引的所有数据按照链表的形式连接起来,然后头结点的地址存储在哈希表中
图示:
这里我们就把冲突的数据放在了一个链表中,从而避免了占用其他数据的位置
疑问:复杂因子是否可以大于1?
可以,因为现在不是直接存储在哈希表中了,所以即使负载因子大于1也是可以的。
不过负载因子越大,冲突概率越高,但是空间利用率高
负载因子越小,冲突概率越低,但是空间利用率低
所以我们需要找到一个适合的负载因子,库中控制的为1,大于1就扩容
疑问:当一个哈希桶中存储了很多个数据时,会导致查找效率低下,应该如何改善?
我们可以看看java的改进方案,在存储数据个数大于8时,他们就把链表改成了红黑树的存储,从而提高搜索效率。
代码实现:
1.insert
bool Insert(const pair<K,V>& kv) { Func fc; //不允许冗余 if (find(kv.first)) return false; //负载因子为1就扩容 if (_n == _tables.size()) { vector<Node*> newtable = new HashTable(2*_tables.size()); for (int i = 0; i < _tables.size(); i++) { Node* cur = _tables[i]; while (cur) { //将旧的表中节点挪到新表中 Node* next = cur->_next; size_t hash0 = fc(cur->_kv.first) % newtable.size(); //头插 cur->_next = newtable[hash0]; newtable[hash0] = cur; //更新cur cur = next; } _tables[i] = nullptr; } newtable.swap(_tables); } //头插 size_t hash0 = fc(kv.first) % _tables.size(); Node* newnode = new Node(kv); newnode->_next = _tables[hash0]; _tables[hash0] = newnode; _n++; return true; }
(1)使用头插:为了减少搜索次数,如果我们先从尾部开始插入就要先搜索到链表尾部,然后再插入。
(2)扩容直接挪动旧表数据,而不开新节点:这样子可提高效率,因为不用一个个开新节点了
(3)仿函数:和前面的线性探测一样,这里我们用仿函数控制key的类型为可取模运算类型,若对于自定义类型则写特化模板进行特殊逻辑控制
2.Find
Node* Find(const K& key) { Func fc; size_t hash0 = fc(key) % _tables.size(); Node* cur = _tables[hash0]; while (cur) { if (cur->_kv.first == key) return cur; cur = cur->_next; } return nullptr; }
3.Erase
Node* Erase(const K& key) { Func fc; size_t hash0 = fc(key) % _tables.size(); Node* cur = _tables[hash0]; Node* prv = nullptr; while (cur) { if (cur->_kv.first == key)//delete { if (prv == nullptr) { _tables[hash0] = cur->_next; } else { prv->_next = cur->_next; } delete cur; --_n; } prv = cur; cur = cur->_next; } return nullptr; }
删除的时候要分两种情况,若prv节点是空,说明此时cur为头结点,那么_tables[hash0]就需要更改,若不是头结点,那么就直接让prv指向cur的next节点即可