🌈欢迎来到数据结构专栏~~手撕哈希表
- (꒪ꇴ꒪(꒪ꇴ꒪ )🐣,我是Scort
- 目前状态:大三非科班啃C++中
- 🌍博客主页:张小姐的猫~江湖背景
- 快上车🚘,握好方向盘跟我有一起打天下嘞!
- 送给自己的一句鸡汤🤔:
- 🔥真正的大师永远怀着一颗学徒的心
- 作者水平很有限,如果发现错误,可在评论区指正,感谢🙏
- 🎉🎉欢迎持续关注!
map和set遍历是有序的,unordered map/set遍历是无序的!(讲到底层就了解了)
map和set是双向迭代器,unordered
系列是单向迭代器
基于上面的区别相比而言,map和set更强大,为什么还需要提供unordered系列?
unordered系列的关联式容器之所以效率比较高,是因为其底层使用了哈希结构
顺序结构以及平衡树中,元素关键码与其存储位置之间没有对应的关系,因此在查找一个元素时,必须要经过关键码的多次比较。顺序查找时间复杂度为O(N),平衡树中为树的高度,即O(log2Nlog_2 Nlog2N),搜索的效率取决于搜索过程中元素的比较次数
而理想的搜索方法:可以不经过任何比较,一次直接从表中得到要搜索的元素
如果构造一种存储结构,通过某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立 一一映射的关系,那么在查找时通过该函数可以很快找到该元素
当向该结构中插入和删除时:
那么这种方式即为哈希(散列),哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称为哈希表(Hash Table)(或者称散列表)
举个例子:
给定集合 {1, 7, 6, 4, 5000, 9000},将哈希函数设置为::hash(key) = key % capacity
(取模),其中capacity为存储元素空间的总大小。
若我们将该集合存储在 capacity 为10的哈希表中,则各元素存储位置对应如下:
用该方法进行搜索不必进行多次关键码的比较,因此搜索的速度比较快
问题:按照上述哈希方式,向集合中插入元素44,会出现什么问题? 哈希冲突!
也叫哈希碰撞:不同关键字通过相同哈希哈数计算出相同的哈希地址,比如在上述的例子中再插入44就会产生哈希冲突,因为44模10后,也等于4
面对这种问题该怎么样处理呢?
不合理的哈希函数就是引发哈希冲突的重要原因,哈希函数设计的越精妙,产生哈希冲突的可能性越低!
哈希函数的设计遵从三大原则:
常见的哈希函数有:
Hash(Key)= A*Key + B
优点:简单、均匀
缺点:需要事先知道关键字的分布情况
使用场景:适合查找比较小且连续的情况
Hash(key) = key% p
(p<=m),将关键码转换成哈希地址优点:使用广泛,不受限制
缺点:需要解决哈希冲突,冲突越多,效率越低
平方取中法比较适合:不知道关键字的分布,而位数又不是很大的情况
折叠法适合事先不需要知道关键字的分布,适合关键字位数比较多的情况
通常应用于关键字长度不等时采用此法
假设要存储某家公司员工登记表,如果用手机号作为关键字,那么极有可能前7位都是相同的,那么我们可以选择后面的四位作为哈希地址。
数字分析法通常适合处理关键字位数比较大的情况,或事先知道关键字的分布且关键字的若干位分布较均匀的情况。
注意:哈希函数设计的越精妙,产生哈希冲突的可能性越低,但是无法避免哈希冲突。
解决哈希冲突两种常见的方法是:闭散列和开散列
也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有
空位置,那么可以把key存放到冲突位置中的“下一个” 空位置中去。那如何寻找下一个空位置呢?
Hi=(H0+i)%m ( i = 1 , 2 , 3 , . . . )
H0:通过哈希函数对元素的关键码进行计算得到的位置
Hi:冲突元素通过线性探测后得到的存放位置
m:表的大小
例如,我们用除留余数法将序列{1, 6, 10, 1000, 11, 18, 7, 40}插入到表长为10的哈希表中,当发生哈希冲突时我们采用闭散列的线性探测找到下一个空位置进行插入,插入过程如下:
通过上图可以看出:随着哈希表中数据的增多,产生哈希冲突的可能性也随着增加,最后在40进行插入的时候更是连续出现了四次哈希冲突(踩踏效应)
我们将数据插入到有限的空间中,随着数据的增多,冲突的概率越发越多,冲突多的时候插入的数据,在查找时候效率也会随之低下,为此引入了负载因子:
负载因子 = 表中有效数据个数 / 空间的大小
如果我们把哈希表增大变成20,可以发现在插入相同数据时,产生的冲突会少
因此我们在闭散列(开放定址法)对负载因子的标准定在了 0.7~0.8
,一旦大于 0.8 会导致查表时缓存未命中率呈曲线上升;这就是为什么有些哈希库都有规定的负载因子,Java 的系统库就将负载因子定成了 0.75,超过 0.75 就会自动扩容
😎作总结:
线性探测的优点:实现非常简单
线性探测的缺点:一旦发生冲突,所有的冲突连在一起,容易产生数据“堆积”,即不同关键码占据了可利用的空位置,使得寻找某关键码的位置需要多次比较(踩踏效应),导致搜索效率降低。
Hi=(H0+i*i)%m ( i = 1 , 2 , 3 , . . . )
H0:通过哈希函数对元素的关键码进行计算得到的位置。
Hi:冲突元素通过二次探测后得到的存放位置。
m:表的大小
接下来举个例子:
但是二次探测没有从本质上解决问题,还是占用式的占用别人位置
开散列,又叫链地址法(拉链法),首先对关键码集合用哈希函数计算哈希地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。
例如,我们用除留余数法将序列{1, 6, 15, 60, 88, 7, 40, 5, 10}插入到表长为10的哈希表中,当发生哈希冲突时我们采用开散列的形式,将哈希地址相同的元素都链接到同一个哈希桶下,插入过程如下:
相比于比散列的报复式占用其他人的位置(小仙女行为)来说,开散列就好得多了,用的是一种乐观的方式,我挂在这个节点的下面
与闭散列不同的是,这种将相同哈希地址的元素通过单链表链接起来,然后将链表的头结点存储在哈希表中的方式,不会影响与自己哈希地址不同的元素的增删查改的效率,因此开散列的负载因子相比闭散列而言,可以稍微大一点
为什么开散列在实际中,更加实用呢?
哈希桶的极端情况就是:所以元素全部都挂在一个节点下面,此时的效率为O(N)
一个桶中如果元素过多的话,可以考虑用红黑树结构代替,并将红黑树的根结点存储在哈希表中
这样一来就算是有十亿个数,都只要在这个桶里查找30次,这就是桶里种树
在闭散列的哈希表中,每个位置不仅仅要存放数据之外,还要存储当前节点的状态,三大状态如下:
对此我们可以用枚举实现:
//枚举出三种状态
enum State
{EXIST,EMPTY,DELETE
};
那么状态的存在意义是什么?
💢举个例子:当我们需要在哈希表中查找一个数据40,这个数据我用哈希函数算出来他的位置是 0 ,但是我们不知道是不是存在哈希冲突,如果冲突就会向后偏移,我们就需要从 0 这个位置开始向后遍历,但是万万不能遍历完整个哈希表,这样就失去了哈希原本的意义
但是这样真的行得通吗?如果我是先删除了一个值1000,空出的空位在40之前,查找遇到空就停止了,此时并没有找到元素40,但是元素40却在哈希表中存在。
因此我们必须要给哈希表中的每个节点设置一个状态,有三种可能:当哈希表中的一个元素被删除后,我们不应该简单的将该位置的状态设置为EMPTY,而是应该将该位置的状态设置为DELETE
这样一来在查找的时候,遇到节点是EXIST
或者DELETE
的都要继续往后找,直到遇到空为止;而当我们插入元素的时候,可以将元素插入到状态为EMPTY或是DELETE的位置
所以节点的数据不仅仅要包括数据,还有包括当前的状态
//哈希节点存储结构
template
struct HashNode
{pair _kv;State _state = EMPTY; //状态
};
为了要在插入的时候算好负载因子,我们还要记录下哈希表中的有效数据,数据过多时进行扩容
templete
class HashTable
{
public://...private:vector> _tables;//哈希表size_t _size;//存储多少个有效数据
};
步骤如下:
1.查找该键值对是否存在,存在则插入失败
2.判断是否需要扩容:哈希表为空 & 负载因子过大 都需要扩容
3. 插入键值对
4. 有效元素个数++
其中扩容方式如下:
此处要注意:是将 旧表的数据重新映射到新表,而不是直接把原有的数据原封不动的搬下来,要重新计算在新表的位置,再插入
产生了哈希冲突,就会出现踩踏事件,不断往后挪,又因为每次插入的时候会判断负载因子,超出了就会扩容,所以哈希表不会被装满!
bool Insert(const pair& kv)
{//数据冗余if (Find(kv.first))return false;//负载因子到了就扩容if (_tables.size() == 0 || 10 * _size / _tables.size() > 7)//扩容{size_t newSize = _tables.size() == 0 ? 10 : _tables.size() * 2;//创建新的哈希表,大小设置为原哈希表的2倍HashTable newHT;newHT._tables.resize(newSize);//旧表的数据映射到新表for (auto e : _tables){if (e._state == EXIST){newHT.Insert(e._kv);//复用插入,因为已经有一个开好了的两倍内存的哈希表}}_tables.swap(newHT._tables);//局部对象出作用域 析构}//注意不能是capacity,size存的是有效字符个数,capacity是能存有效字符的容量size_t hashi = kv.first % _tables.size();//线性探测while (_tables[hashi]._state == EXIST){++hashi;hashi %= _tables.size();}/*Hash hash;size_t start = hash(kv.first) % _tables.size();size_t i = 0;size_t hashi = start;//二次探测while (_tables[hashi]._state == EXIST){++i;hashi = start + i*i;hashi %= _tables.size();}*///数据插入_tables[hashi]._kv = kv;_tables[hashi]._state = EXIST;++_size;return true;
}
步骤如下:
EMPTY
位置还没找到则查找失败,如果遇到状态是DELETE
的话,也要继续往后探测,因为该值已经被删掉了💢注意:key相同的前提是状态不能是:删除。必须找到的是位置状态为 EXIST
且 key
值匹配,才算查找成功(不然找到的数据相同的,确实被删除了的)
HashData* Find(const K& key)
{//如果是空表就直接返回空if (_tables.size() == 0)return nullptr;size_t start = key % _tables.size();size_t hashi = start;while (_tables[hashi]._state != EMPTY)//不等于空 == 存在和删除都要继续找{//key相同的前提是状态不能是:删除if (_tables[hashi]._state != DELETE && _tables[hashi]._kv.first == key){return &_tables[hashi];}hashi++;hashi %= _tables.size();if (hashi == start)//极端判断:兜兜转转一圈遇到了{break;}}return nullptr;
}
删除的步骤比较简单:修改状态 —— 减少元素个数
DELETE
即可-1
注意:我们这里是伪删除:只是修改了数据的状态变成DELETE
,并没有把数据真正的删掉了,因为插入时候的数据可以覆盖原有的 —— 数据覆盖
bool Erase(const K& key)
{HashData* ret = Find(key);if (ret) //找到了{ret->_state = DELETE; //状态改成删除--_size; //有效元素个数-1return true;}else{return false;}
}
如果我们统计的是字符串的出现次数呢?kv.first
还能取模吗?怎么样转化string呢 —— 其实大佬早就帮我们想到了
仿函数转化成一个可以取模的值
size_t
,如果key是string类型的就走string类型的特化版本涉及到了BKDR算法,因为ascll码值单纯地加起来,可能会出现相同现象,大佬推算出了这个算法
特化:符合string类型的优先走string类型
template
struct Hashfunc
{size_t operator()(const K& key){return (size_t)key;}
};//特化版本
template<>
struct Hashfunc
{//BKDR算法size_t operator()(const string& key){size_t val = 0;for (auto ch : key){val *= 131;//为什么是131?经过了val += ch;}return val;}
};
在开散列的哈希表中,哈希表的每个位置存储的实际上是某个单链表的头结点,即每个哈希桶中存储的数据实际上是一个结点类型,该结点类型除了存储所给数据之外,还需要存储一个结点指针用于指向下一个结点:next
template
struct HashNode
{pair _kv;HashNode* _next;//构造函数HashNode(const pair& kv):_kv(kv),_next(nullptr){}
};
为了使代码更有观赏感,对节点的类型进行typedef
typedef HashNode Node;
这里与闭散列不同的是,不用给每个节点设置状态,因为将哈希地址相同的元素都放到了同一个哈希桶中了,不用再所谓的遍历找下一个空位置
当然了哈希桶也是要进行扩容的,在插入数据时也需要根据负载因子判断是否需要增容,所以我们也应该时刻存储整个哈希表中的有效元素个数,当负载因子过大时就应该进行哈希表的增容
template
struct HashTable
{typedef HashNode Node;
public://...private:vector _table;size_t _size; //存储的有效数据个数
};
步骤如下:
其中哈希表中的调整方式
注意:此处我们没有复用插入,是因为我们可以使用原本节点来对新的哈希表进行复制,这样就可以节省了新哈希表中的插入的节点了
bool Insert(const pair& kv)
{//去重if (Find(kv.first))return false;//负载因子到1就扩容if (_size == _table.size()){size_t newsize = _table.size == 0 ? 10 : 2 * _table.size();vector newTable;newTable.resize(newsize);//旧表节点移动映射到新表中for (size_t i = 0; i < _table.size(); i++){Node* cur = _table[i];while (cur){Node* next = cur->_next; //记录cur的下一个节点size_t hashi = cur->_kv.first % newTable.size();//计算哈希地址//头插cur->_next = newTable[hashi];newTable[hashi] = cur;cur = next;}_table[i] = nullptr;//原桶取完后置空}//交换_table.swap(newTable);}size_t hashi = kv.first % _table.size();//头插Node* newnode = new Node(kv);newnode->_next = _table[hashi]; // _table[hashi]指向的就是第一个结点_table[hashi] = newnode;++_size;return true;
}
步骤如下:
//查找
Node* Find(const K& key)
{if(_table.size() == nullptr)//哈希表为0,没得找{return nullptr;}size_t hashi = kv.first % _table.size();//招牌先算出哈希地址Node* cur = _table[hashi];while (cur)//直到桶为空{if (cur->_kv.first == key){return true;}cur = cur->_next;}return nullptr;//遍历完桶,都没找到,返回空
}
注意: 这里我们不调用find函数,因为是单链表,我们还要自己去找prev
,所以干脆我们自己去查找好了
bool Erase(const K& key)
{if(_table.size() == 0){return nullptr;}//1、通过哈希函数计算出对应的哈希桶编号hashisize_t hashi = key % _table.size();Node* prev = nullptr;Node* cur = _table[hashi];while (cur){if (cur->_kv.first == key){//头删if (prev == nullptr){_table[hashi] = cur->_next;//将第一个结点从该哈希桶中移除delete cur;}else //中间删除{prev->_next = cur->_next;//将该结点从哈希桶中移除delete cur;}--_size;return true;}prev = cur;cur = cur->_next;}return false;
}
其实哈希表在使用除留余数法时,为了减少哈希冲突的次数,很多地方都使用了素数来规定哈希表的大小
下面用合数(非素数)10和素数11来进行说明。
合数10的因子有:1,2,5,10。
素数11的因子有:1,11。
我们选取下面这五个序列:
间隔为1的序列:s1 = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
间隔为2的序列:s2 = {2, 4, 6, 8,10, 12, 14, 16, 18, 20}
间隔为5的序列:s3 = {5, 10, 15, 20, 25, 30, 35, 40,45, 50}
间隔为10的序列:s4 = {10, 20, 30, 40, 50, 60, 70, 80, 90, 100}
间隔为11的序列:s5 = {11, 22, 33, 44, 55, 66, 77, 88, 99, 110}
对这几个序列分别放进哈希表,分别观察,不难得出他们的规律:
综上所述,某个随机序列当中,每个元素之间的间隔是不定的,为了尽量减少冲突,我们就需要让哈希表的大小的因子最少,此时素数就可以视为最佳方案
很明显如果还是采用传统的 2 倍扩容就会不符合素数大小的要求,所以我们不妨直接将素数大小存储在数组里,我们规定下面这个数组即可,其中元素近似 2 倍增长
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)
{const int PRIMECOUNT = 28;size_t i = 0;for (i = 0; i < PRIMECOUNT; i++){if (primeList[i] > prime)return primeList[i];}return primeList[i];
}
我想走出浪浪山