前言
这篇文章进入哈希表的学习,以及详细介绍如何用哈希表封装unordered_map和unordered_set。
一、什么是哈希?
在红黑树的查找效率中,平均查找时间复杂度为O(logN)。
但还是不够快,因为有大佬想出了更快的方法。即不像红黑
树那样遍历树进行节点之间的比较,而是直接知道查找的数据在结构中的位置。
怎么知道这个数据在不在呢?直接通过一个映射关系即可找到。
如何实现映射关系?通过函数实现,而这个函数就是哈希函数。
总结:哈希就是能让一个数据直接通过这个哈希函数直接查找是否在结构中,而不需要进行比较。
举个简单的例子:计数排序
计数排序就是一个经典的哈希式算法。
在计数排序中,
以该图为例,1这个数字出现了1次,3这个数字出现了1次,6这个数字出现了3次。
它的底层算法是这样的:比如接下来要统计的数字为x。
_a[x % _a.size()]++;
通过映射关系,直接找到x这个数字对应在_a顺序表中的下标,再+1即可。
而上面这个就是一个哈希函数
二、哈希表的插入及哈希冲突
在实际的底层场景,哈希表就是一个vector。
来看下面的一段数据插入哈希表中。
int a[] = {1,7,6,4,5,9};
哈希函数设置为:hash(i) = key % hashtable.size();
此时hash(1) = 1 % 10, hash(7) = 7 % 10, hash(6) = 6 % 10,
hash(4) = 4 % 10, hash(5) = 5 % 10, hash(9) = 9 % 10;
此时如果再插入一个11,就会产生冲突
因为hash(11) = 11 % 10 = 1,11这个数据会通过哈希函数计算后映射到1这个位置,但是1这个位置已经存入了1,此时就产生了哈希冲突。
解决哈希冲突的方式
1.闭散列的哈希表
闭散列也叫做开放定址法,当产生哈希冲突时,如果哈希表未被装满,说明哈希表中必然存在其他空位置,使用线性探测进行探测到空位置,然后在空位置进行插入。
比如在上面的案例中,
1这个位置已经存储数据了,11这个数据就往后找空位置,找到的第一个空位置进行插入即可。
这种直接通过映射的方式进行哈希表的插入时间复杂度为O(1)
闭散列哈希表的删除
在这种以线性探测来解决哈希冲突的操作中,不能真正地物理删除一个元素,因为你也不知道怎么删除,删除无非就是找一个数进行覆盖,找什么数都不行,所以,我们应该用一种状态表示一个位置是否存在元素,即
一个位置可以存在三种状态:
- 空状态(EMPTY)
- 删除状态(DELETE)
- 已存在状态(EXIST)
在这里可以使用枚举来表示三种状态
enum { EXIST, EMPTY, DELETE };
所以删除一个元素非常简单,只需要将该位置的状态设置为空即可。
实现(重点+细节处理)
下面来简单实现线性探测的闭散列哈希表。
注意到这里的一些问题:
- 1.一个
vector<int>
是无法存储状态的,只能搞一个哈希节点存储数据和状态
template<class K,class V> struct HashData { pair<K, V> _kv; STATUS _status = EMPTY; };
- 2.在插入过程中,如果哈希表满了,即每个位置的状态都不是空了该怎么办呢?
- 因为这种情况下,插入一个数据,会进行整个哈希表的搜索,时间复杂度又会下降到
O(n)
。 - 为了提高效率,引入一个负载因子。
- 负载因子通过判断当前哈希表的元素个数/哈希表的大小,来判断容量已达到多少,还剩余多少,当达到一定值时,会进行扩容,并重新建立映射关系。
- 这样便可以让效率提升到平均
O(1)
的水平。 - 在标准库的实现中,负载因子>=0.75时,会触发扩容。
负载因子 = _n / hashtable.size();
- 需要注意,两个都是size_t类型,÷出来之后会直接为0,所以可以这样:
负载因子 = _n*100 / hashtable.size();
- 3.在插入函数扩容逻辑中,由于需要重新建立映射关系,所以需要重新获取key,重新计算映射位置,而这些过程刚好是Insert这个函数的工作,所以在这段逻辑中可以复用Insert函数。
namespace open_addr //(开放定址法,闭散列法) { template<class K,class V> struct HashData { pair<K, V> _kv; STATUS _status = EMPTY; }; //为了解决一个string不能做key的问题,需要增加一个仿函数 template<class K,class V,class HashFunc = DefaultHashFunc<K>> class HashTable { public: HashTable() { _table.resize(10); } //Insert不可能会出现插入失败的情况 bool Insert(const pair<K,V>& kv) { //扩容问题 //当负载因子>=0.75时 if (_n * 100 / _table.size() >= 75) { size_t newsize = _table.size() * 2; HashTable<K,V,HashFunc> newtable; newtable._table.resize(newsize); //将旧数据重新映射到新表中 //在开放定址法中复用Insert的原因是: //插入过程并没有new的操作,复用更加方便 for (auto& e : _table) { newtable.Insert(e._kv); } //现代写法,最后newtable出了作用域会调析构,而此时newtabl的空间正好是_table想要的 //这样写也刚好可以将_table的数据析构 _table.swap(newtable._table); } HashFunc hf; //找到映射位置 size_t hashi = hf(kv.first) % _table.size(); //注意必须是%size,如果%capacity //假如出现capacity扩容了,但是size不是等于capacity的情况,就可能出现越界的问题。 while(_table[hashi]._status == EXIST) { ++hashi; hashi %= _table.size(); // 如果走到最后位置的下一个,就回到0位置 } //走到这里可能为空/删除 _table[hashi]._kv = kv; //如果在HashData将K设置成const K,连插入都不能插入了,最好的方法是封装成迭代器 _table[hashi]._status = EXIST; ++_n; return true; } HashData<const K,V>* Find(const K& key) { HashFunc hf; size_t hashi = hf(key) % _table.size(); while (_table[hashi]._status != EMPTY) { if (_table[hashi]._status == EXIST && _table[hashi]._kv.first == key) { return (HashData<const K, V>*)&_table[hashi]; } ++hashi; hashi %= _table.size(); } return nullptr; } bool Erase(const K& key) { HashData<const K, V>* ret = Find(key); if (ret) { ret->_status = EMPTY; return true; } return false; } private: vector<HashData< K,V>> _table; // 哈希表 size_t _n = 0; //存储有效数据个数 }; }
线性探测的缺陷
- 如果发生哈希冲突,空位置就会被占用,这样会产生数据堆积在一起,查找时数据的映射位置可能会被占用,还需要往后查找,使得效率下降。
所以为了减小哈希冲突而提高效率,需要多一点的空位置,也就是需要更多的空间来减小哈希冲突产生的概率,但这样就会降低空间利用率。
2.开散列的哈希表
开散列法,又叫哈希桶,不同于前面的用线性探测来解决哈西冲突问题,哈希桶,顾名思义,就是一个位置会挂着几个节点,这些节点被形象地称为桶。
大致结构如下:
vector存储结构就应该修改一下了:
template<class K, class V> struct HashData { typedef HashData<K, V> Node; HashData(const pair<K,V>& kv) :_kv(kv) ,_next(nullptr) {} pair<K, V> _kv; Node* _next = nullptr; };
此时vector存储的每个位置的数据为:
vector<Node*> _hashtable;
vector的每个节点并不存储有效数据,而是一个数据通过哈希函数:key % _hashtable.size()
找到。
找到映射位置后,此时就可以像链表头插一样,将该节点插入哈希表中挂起来。
核心代码如下:
size_t hashi = key % _hashtable.size(); Node* newnode = new Node(kv); //头插 newnode->next = _hashtable[hashi]; //由于_hashtable[hashi]存储的是一个节点的地址 _hashtable[hashi] = newnode;
开散列哈希表的插入
插入的过程上面已经讲解了,需要注意的就是一个细节:
- 在扩容过程中,重新建立映射关系时,最好不要复用Insert函数,因为原来的哈希表的节点还可以使用,复用Insert函数意味着先遍历原来的哈希表,然后对每个节点拷贝构造后再挂起到新的哈希表中,然后再析构旧的哈希表。
这样会降低效率,因为旧的节点仍然可以使用,何必多一层对每个节点拷贝构造呢。
所以可以在遍历旧表的时候,直接顺手牵羊将每个节点牵到新表上。
bool Insert(const pair<K, V>& kv) { //扩容问题 //负载因子为1时扩容,此时最坏的情况就是一个位置可能挂好几个节点 //最好情况是每一个位置都只挂一个节点 if (_n == _table.size()) { size_t newsize = _table.size() * 2; HashTable<K, V, HashFunc> newht; newht._table.resize(newsize, nullptr); //遍历旧的哈希表,重新进行映射关系 //在哈希桶写法中不复用Insert的原因是 //Insert中有new Node的操作,复用时一个个申请新空间,挂上去,最后还得释放旧的节点 //还不如直接把旧表的节点直接顺手牵羊拉下来 for (size_t i = 0; i < _table.size(); i++) { Node* cur = _table[i]; Node* next = nullptr; if (cur) next = cur->_next; while (cur) { size_t newhashi = hf(cur->_kv.first) % newht._table.size(); cur->next = newht._table[newhashi]; newht._table[newhashi] = cur; cur = next; if(next) next = next->_next; } } //现代写法,交换资源 _table.swap(newht._table); } HashFunc hf; //如果待插入节点重复了,那就不插入 if (Find(kv.first)) { assert(0); return false; } size_t hashi = hf(kv.first) % _table.size(); //头插 Node* newnode = new Node(kv); //头插 ,假如_table[hashi]不为空 //那么新节点的next就指向当前位置挂着的第一个节点 newnode->_next = _table[hashi]; //更新当前位置挂着的第一个节点 _table[hashi] = newnode; ++_n; return true; }
最后不要忘记,交换两个vector。
哈希桶的删除和查找与前面的线性探测几乎一致,这里不再赘述。
总结
实现哈希表并不难,哈希表的结构也比较清晰,难点就难在对哈希表进行封装,这个才是重点。
下集预告:
- 涉及到string不能做key的问题应该用仿函数处理。
- 不再固定地使用pair,而是使用T来替代,因为要适应map和set。
- 涉及普通迭代器的问题。
- 涉及const迭代器问题,因为set的特性是Key不允许修改,那么如何解决呢?
- 5.inert返回值处理,在库的实现中,insert返回值是
pair<iterator,bool>
的问题,这也是为了适配map的operator[]
而产生的。- 6.key不能修改的问题,set比较难解决,map比较轻松可以解决。