🚀write in front🚀
📜所属专栏: C++学习
🛰️博客主页:睿睿的博客主页
🛰️代码仓库:🎉VS2022_C语言仓库
🎡您的点赞、关注、收藏、评论,是对我最大的激励和支持!!!
关注我,关注我,关注我,你们将会看到更多的优质内容!!
文章目录
- 前言
- 一.哈希的引入
- 二.哈希的概念
- 1.暴力查找:
- 2.二分查找
- 3.二叉搜索树:
- 4.哈希:
- 三.哈希函数:
- 1.哈希函数设计原则
- 2.常见的哈希函数:
- 四.哈希冲突:
- 五.解决哈希冲突的两种方案:
- 闭散列——开放定址法
- 插入:
- 删除:
- 查找:
- 负载因子:
- 字符串怎么使用哈希:
- 扩容操作:
- 取余的数值:
- 代码实现:
- 拉链法(哈希桶):
- 插入:
- 删除:
- 查找:
- 扩容:
- 代码实现
- 六.开散列与闭散列比较
- 总结
前言
在C++98中,STL提供了底层为红黑树结构的一系列关联式容器,在查询时效率可达到 l o g 2 N log_2N log2N,即最差情况下需要比较红黑树的高度次,当树中的节点非常多时,查询效率也不理想。最好的查询是,进行很少的比较次数就能够将元素找到,因此在C++11中,STL又提供了4个unordered系列的关联式容器,这四个容器与红黑树结构的关联式容器使用方式基本类似,只是其底层结构不同。
一.哈希的引入
在C++11里面的unordered_set,unordered_map,unordered_multiset等这四个容器也是叫map,set,所以在使用上基本都是类似的。但是如果大家遍历容器时就会发现,红黑树实现的是有序的,unordered 的是无序的,这就是因为其底层的哈希决定的。
二.哈希的概念
我们对元素进行搜索有几种方式:
1.暴力查找:
直接遍历元素,时间复杂度为O(N)
2.二分查找
时间复杂度为O(logN)
但是二分查找有2个弊端:
- 必须为有序
- 增删查改不方便O(N)
这两个弊端导致二分查找只是一个理想的查找方式,并不是很现实
3.二叉搜索树:
增删查改的效率都是O(logN),总体性能都很不错
4.哈希:
哈希,提供了一种与这些完全不同的存储和查找方式,即将存储的值和存储的位置建立出一个对应的函数关系。在我们平时的生活里面,我们的通讯录联系人就是哈希的一种,每个人的名字对应了一个位置,A的对应在A的位置,B的就对应在B的位置:
其实我们早就学过了哈希,是在计数排序的时候,我们先开一个空间(为待排序数组的最大值和最小值之差),此时,数组里面的每一个值都对应了一个她自己的位置,这就是一种哈希。
那么,到底什么是哈希呢?为什么要创造哈希呢?
顺序结构以及平衡树中,元素关键码与其存储位置之间没有对应的关系,因此在查找一个元素时,必须要经过关键码的多次比较。顺序查找时间复杂度为O(N),平衡树中为树的高度,即O( l o g 2 N log_2 N log2N),搜索的效率取决于搜索过程中元素的比较次数。
理想的搜索方法:可以不经过任何比较,一次直接从表中得到要搜索的元素。
如果构造一种存储结构,通过某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立一一映射的关系,那么在查找时通过该函数可以很快找到该元素。
当向该结构中:
- 插入元素
根据待插入元素的关键码,以此函数计算出该元素的存储位置并按此位置进行存放 - 搜索元素
对元素的关键码进行同样的计算,把求得的函数值当做元素的存储位置,在结构中按此位置取元素比较,若关键码相等,则搜索成功该方式即为哈希(散列)方法,哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称为哈希表(Hash Table)(或者称散列表)
那么我们通常怎么构建这种映射关系呢?
举个🌰:
以数组{10000000000000001,8,6,3}为例,假设hashi = key % 6 ,那则有如下对应关系:
这里如果我们还用计数排序那种方式,就会很浪费空间,所以我们使用这种映射方式就可以减少很多不必要的开销。
三.哈希函数:
上面我们的%的那个操作就是一种哈希函数,叫做除留余数法。
1.哈希函数设计原则
哈希函数的定义域必须包括需要存储的全部关键码,而如果散列表允许有m个地址时,其值域必须是[0,m-1]之间哈希函数计算出来的地址能均匀分布在整个空间中哈希函数较为简单,能在较短时间内计算出结构。
2.常见的哈希函数:
直接定址法(值的范围集中)
取某个线性函数作为散列的地址:Hash(key) = A*key + B
除留余数法(值的范围分散)
设散列表中允许的地址数为m,取一个不大于m,但最接近或者等于m的质数p作为除数, 按照哈希函数:Hash(key) = key% p(p<=m),将关键码转换成哈希地址。
四.哈希冲突:
对于两个数据元素的关键字 k i k_i ki和 k j k_j kj(i != j),有 k i k_i ki != k j k_j kj,但有:Hash( k i k_i ki) ==
Hash( k j k_j kj),即:不同关键字通过相同哈希哈数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞。
比如:1%10=1,101%10=1,此时他们的位置就会发生冲突。
五.解决哈希冲突的两种方案:
闭散列——开放定址法
插入:
通过哈希函数获取待插入元素在哈希表中的位置如果该位置中没有元素则直接插入新元素,如果该位置中有元素发生哈希冲突,使用线性探测找到下一个空位置,插入新元素。说白了就是如果你的位置被占了,你就去占下一个人的位置。
删除:
采用闭散列处理哈希冲突时,不能随便物理删除哈希表中已有的元素,若直接删除元素会影响其他元素的搜索。比如删除元素4,如果直接删除掉,44查找起来可能会受影响。因此线性探测采用标记的伪删除法来删除一个元素,对于每一个插入的值都给他们一个状态:
// 哈希表每个空间给个标记
// EMPTY此位置空, EXIST此位置已经有元素, DELETE元素已经删除
enum State{EMPTY, EXIST, DELETE};
查找:
给每一个位置一个状态值也正好解决了查找的问题,查找一个元素的停止条件是查找到空的地方。如果我们直接删掉了,那个地方为空就会影响查找。
负载因子:
哈希表定义了一个载荷因子:α = 填入表中元素个数 / 哈希表的长度
如果负载因子设计的大,那么哈希冲突的概率就越大(空间利用率高)
如果负载因子设计的小,那么哈希冲突的概率就越小(空间利用率低)
对于开放定址法,经过测算,负载因子应该控制在0.7 ~ 0.8,下面代码实现采用0.7。
字符串怎么使用哈希:
说到这里就不得不说到仿函数了,由于我们传进来的类型不知道是什么类型,比如负数,字符串等。所以我们写一个仿函数来对不同类型的数值映射一个值来。以字符串为例:
对于一个字符串,我们要先对应一个整数,然后才能对应他的存储位置。
扩容操作:
上面我们说到,当扩容因子到达0.7的时候要扩容,扩容的目的其实就是让查找的时候更容易找到空的位置,但是扩容的时候每个元素映射的位置肯定要变(数值的长度变了,取余的数值变了),所以扩容的时候还要重写遍历一遍。
取余的数值:
取余的数值一定不是capacity(),这个问题我一开始还想了很久,因为我们在模拟实现的时候我以为new了那么大的空间,肯定就可以用啊,但是实际上vector底层是虽然你开了那么多空间,但是你只能访问和修改size里面的东西,重载的[]会对你访问的位置进行检查,超出size直接报错了。就是我开了那么大的空间,但是不给你用。所以这里是模上size()
代码实现:
#pragma once
#include<vector>enum STATE
{EXIST,EMPTY,DELETE
};
//因为要存状态,所以要重写定义一个结构体
template<class K, class V>
struct HashData
{pair<K, V> _kv;STATE _state = EMPTY;
};template<class K>
struct DefaultHashFunc
{size_t operator()(const K& key){return (size_t)key;}
};//这里巧用了模板的实例化相关的知识。
template<>
struct DefaultHashFunc<string>
{size_t operator()(const string& str){// BKDR//这里的操作会使不同字符串的数值差距变大size_t hash = 0;for (auto ch : str){hash *= 131;hash += ch;}return hash;}
};template<class K,class V, class HashFunc = DefaultHashFunc<K>>
//这里就使用了仿函数来调用
class HashTable
{
public:HashTable(){_table.resize(10);}bool Insert(const pair<K, V>& kv){// 扩容//if ((double)_n / (double)_table.size() >= 0.7)if (_n*10 / _table.size() >= 7){size_t newSize = _table.size() * 2;// 遍历旧表,重新映射到新表HashTable<K, V, HashFunc> newHT;newHT._table.resize(newSize);// 遍历旧表的数据插入到新表即可for (size_t i = 0; i < _table.size(); i++){if (_table[i]._state == EXIST){newHT.Insert(_table[i]._kv);}}_table.swap(newHT._table);}// 线性探测HashFunc hf;size_t hashi = hf(kv.first) % _table.size();while (_table[hashi]._state == EXIST){++hashi;hashi %= _table.size();}_table[hashi]._kv = kv;_table[hashi]._state = EXIST;++_n;return true;}HashData<const K, V>* Find(const K& key){// 线性探测HashFunc hf;size_t hashi = hf(key) % _table.size();while (_table[hashi]._state != EMPTY){//只有值相同且状态为存在的时候才算找到if (_table[hashi]._state == 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->_state = DELETE;--_n;return true;}--_n;return false;}private:vector<HashData<K, V>> _table;size_t _n = 0; // 存储有效数据的个数,便于计算负载因子
};
线性探测优点:实现非常简单,
线性探测缺点:一旦发生哈希冲突,所有的冲突连在一起,容易产生数据“堆积”。关键码占据了可利用的空位置,使得寻找某关键码的位置需要许多次比较,导致搜索效率降低。
拉链法(哈希桶):
开放定址法的缺陷就是冲突会相互影响。而哈希桶的做法是,设置一个指针数组,如果发现冲突,则内部消化
这里其实还是很好理解的,就是在vector里面存单链表。也就是插入了一堆桶。
插入:
插入操作就是通过数值找到位置,然后头插到那个位置的单链表中
删除:
链表的删除
查找:
链表的查找
扩容:
如果我们不扩容,一直插入,某些桶就会越来越长,效率就下降了。因此这里的负载因子可以适当放大一点,一般负载因子控制在1,平均下来每个桶都有数据。
对于扩容操作,因为这里我们存的是链表,是需要释放空间的,所以在扩容的时候可以将结点移到另外一个数组里面,这样就就不用创造新结点,也不用释放空间了,对那些结点继续使用。
代码实现
template<class K, class V, class HashFunc = DefaultHashFunc<K>>class HashTable{typedef HashNode<K, V> Node;public:HashTable(){_table.resize(10, nullptr);}~HashTable(){for (size_t i = 0; i < _table.size(); i++){Node* cur = _table[i];while (cur){Node* next = cur->_next;delete cur;cur = next;}_table[i] = nullptr;}}bool Insert(const pair<K, V>& kv){if(Find(kv.first)){return false;}HashFunc hf;// 负载因子到1就扩容if (_n == _table.size()){// 16:03继续size_t newSize = _table.size()*2;vector<Node*> newTable;newTable.resize(newSize, nullptr);// 遍历旧表,顺手牵羊,把节点牵下来挂到新表for (size_t i = 0; i < _table.size(); i++){Node* cur = _table[i];while (cur){Node* next = cur->_next;// 头插到新表size_t hashi = hf(cur->_kv.first) % newSize;cur->_next = newTable[hashi];newTable[hashi] = cur;cur = next;}_table[i] = nullptr;}_table.swap(newTable);}size_t hashi = hf(kv.first) % _table.size();// 头插Node* newnode = new Node(kv);newnode->_next = _table[hashi];_table[hashi] = newnode;++_n;return true;}Node* Find(const K& key){HashFunc hf;size_t hashi = hf(key) % _table.size();Node* cur = _table[hashi];while (cur){if (cur->_kv.first == key){return cur;}cur = cur->_next;}return nullptr;}bool Erase(const K& key){HashFunc hf;size_t hashi = hf(key) % _table.size();Node* prev = nullptr;Node* cur = _table[hashi];while (cur){if (cur->_kv.first == key){if (prev == nullptr){_table[hashi] = cur->_next;}else{prev->_next = cur->_next;}--_n;delete cur; return true;}prev = cur;cur = cur->_next;}return false;}void Print(){for (size_t i = 0; i < _table.size(); i++){printf("[%d]->", i);Node* cur = _table[i];while (cur){cout << cur->_kv.first <<":"<< cur->_kv.second<< "->";cur = cur->_next;}printf("NULL\n");}cout << endl;}private:vector<Node*> _table; // 指针数组size_t _n = 0; // 存储了多少个有效数据};
六.开散列与闭散列比较
应用链地址法处理溢出,需要增设链接指针,似乎增加了存储开销。
但是事实上:由于开地址法必须保持大量的空闲空间以确保搜索效率,如二次探查法要求装载因子a <=0.7,而表项所占空间又比指针大的多,所以使用链地址法反而比开地址法节省存储空间。
总结
因为哈希这里还是比较好理解的,只要自己写一写就很好实现,所以只分析了几点易错点和重点。下次我们就开始封装了!!!
更新不易,辛苦各位小伙伴们动动小手,👍三连走一走💕💕 ~ ~ ~ 你们真的对我很重要!最后,本文仍有许多不足之处,欢迎各位认真读完文章的小伙伴们随时私信交流、批评指正!
专栏订阅:
每日一题
C语言学习
算法
智力题
初阶数据结构
Linux学习
C++学习
更新不易,辛苦各位小伙伴们动动小手,👍三连走一走💕💕 ~ ~ ~ 你们真的对我很重要!最后,本文仍有许多不足之处,欢迎各位认真读完文章的小伙伴们随时私信交流、批评指正!