目录
1.哈希概念(主要应用于频繁查找的场景)
- 顺序结构以及平衡树中,元素关键码与其存储位置之间没有对应的关系,因此在查找一个元素时,必须要经过关键码的多次比较。顺序查找时间复杂度为O(N),平衡树中为树的高度,即O(log2N),搜索的效率取决于搜索过程中元素的比较次数。理想的搜索方法:可以不经过任何比较,一次直接从表中得到要搜索的元素。 如果构造一种存储结构,通过某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立一一映射的关系,那么在查找时通过该函数可以很快找到该元素。
- 插入元素:有了我们定义的哈希函数我们就可以将当前要存储的值当作函数的参数,我们将其传入哈希函数之后就可以由哈希函数计算出一个值作为我们要存储的函数位置。
- 查找元素:我们插入的时候由哈希函数计算出当前值来存入那么我们查找的时候也按照当前函数得出存储位置我们直接到相应位置来查找即可。
- 举个例子:这里我们写一个简单的哈希函数,通过要存储的值模除当前要存储的值:然后得到存储位置,那么我们查找相同元素得时候就可以通过哈希函数来求得存储位置来取到元素。
- 负载因子:
- 负载因子=哈希函数中插入元素个数/哈希存储空间大小。负载因子一定是小于1的因为我们一定要让新插入元素有位置存放。
2. 哈希冲突
对于两个数据元素的关键字 和 (i != j),有ki !=kj ,但有:Hash(ki ) == Hash( kj),即:不同关键字通过相同哈希哈数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞。把具有不同关键码而具有相同哈希地址的数据元素称为“同义词”。
也就是我们下面的例子
3.解决哈希冲突
- 首先我们要知道差生哈希冲突的原因主要有两个方面一个是哈希函数的设计会导致我们在存储元素的时候计算元素的存储位置的时候导致两个元素存储的位置相同,还有就是我们的空间问题,我们导致哈希冲突的主要原因就是我们在计算元素存储位置时候当前元素要存储的位置已经有了元素,但是相同的存储位置只能存储一个元素。那么我们如果将同一个位置可以存储多个元素那么我们是不是就也解决了哈希冲突,所以我们接下来从这两方面来入手解决哈希冲突。
3.1哈希函数的选择
-
1. 直接定制法--(常用)
- 取关键字的某个线性函数为散列地址:Hash(Key)= A*Key + B 优点:简单、均匀 缺点:需要事先知道关键字的分布情况 使用场景:适合查找比较小且连续的情况。我们来看一道题来体会当前哈希函数的应用场景。
-
2. 除留余数法--(常用)
- 设散列表中允许的地址数为m,取一个不大于m,但最接近或者等于m的质数(这里最好取素数当作除数来取余因为这样产生的哈希冲突相对较少)p作为除数,按照哈希函数:Hash(key) = key% p(p<=m),将关键码转换成哈希地址。这里就相当于我们上面最一开始举例的方式。
-
3. 平方取中法--(了解)
- 假设关键字为1234,对它平方就是1522756,抽取中间的3位227作为哈希地址; 再比如关键字为4321,对它平方就是18671041,抽取中间的3位671(或710)作为哈希地址 平方取中法比较适合:不知道关键字的分布,而位数又不是很大的情况
-
4. 折叠法--(了解)
- 折叠法是将关键字从左到右分割成位数相等的几部分(最后一部分位数可以短些),然后将这几部分叠加求和,并按散列表表长,取后几位作为散列地址。折叠法适合事先不需要知道关键字的分布,适合关键字位数比较多的情况
-
5. 随机数法--(了解)
- 选择一个随机函数,取关键字的随机函数值为它的哈希地址,即H(key) = random(key),其中random为随机数函数。通常应用于关键字长度不等时采用此法
-
6.数学分析法--(了解)
- 设有n个d位数,每一位可能有r种不同的符号,这r种不同的符号在各位上出现的频率不一定相同,可能在某些位上分布比较均匀,每种符号出现的机会均等,在某些位上分布不均匀只有某几种符号经常出现。可根据散列表的大小,选择其中各种符号分布均匀的若干位作为散列地址。例如:假设要存储某家公司员工登记表,如果用手机号作为关键字,那么极有可能前7位都是 相同的,那么我们可以选择后面的四位作为散列地址,如果这样的抽取工作还容易出现 冲突,还可以对抽取出来的数字进行反转(如1234改成4321)、右环位移(如1234改成4123)、左环移位、前两数与后两数叠加(如1234改成12+34=46)等方法。数字分析法通常适合处理关键字位数比较大的情况,如果事先知道关键字的分布且关键字的若干位分布较均匀的情况
- 总结:哈希函数设计的越精妙,产生哈希冲突的可能性就越低,但是无法避免哈希冲突,所以我们真正解决哈希冲突的方式是解决数据的存储空间问题。
3.2.闭散列和开散列
3.2.1闭散列
- 闭散列:也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以把key存放到冲突位置中的“下一个” 空位置中去。那如何寻找下一个空位置呢?
-
1. 线性探测
- 比如上面的场景,现在需要插入元素77,先通过哈希函数计算哈希地址,hashAddr为7,因此44理论上应该插在该位置,但是该位置已经放了值为7的元素,即发生哈希冲突。
- 线性探测:从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。
- 插入
- 通过哈希函数获取待插入元素在哈希表中的位置如果该位置中没有元素则直接插入新元素,如果该位置中有元素发生哈希冲突,使用线性探测找到下一个空位置,插入新元素
- 删除
- 采用闭散列处理哈希冲突时,不能随便物理删除哈希表中已有的元素,若直接删除元素会影响其他元素的搜索。比如删除元素4,如果直接删除掉,44查找起来可能会受影响。因此线性探测采用标记的伪删除法来删除一个元素。
- 代码实现:
- 线性探测优点:实现非常简单,
- 线性探测缺点:一旦发生哈希冲突,所有的冲突连在一起,容易产生数据“堆积”,即:不同关键码占据了可利用的空位置,使得寻找某关键码的位置需要许多次比较,导致搜索效率降低。如何缓解呢?
-
2.二次探测:
- 代码实现:(代码实现我们只需要更改三个地方即可)
- 在构造时候我们可以选择二次探测的方式构造哈希:
- 这里还有一个小疑问那就是为什么我们二次探测hash地址超过容量的时候不像上面线性探测一样将地址直接置为0呢?
- 虽然二次探测可以一定程度上解决数据堆积问题但是当表中大部分空间都被占用之后,二次探测每次探测可能取到重复数据,那么就要探测多次,但是最终一定会将所有的位置都探测到。所以二次探测要求更大的空间。研究表明:当表的长度为质数且表装载因子a不超过0.5时,新的表项一定能够插入,而且任何一个位置都不会被探查两次。因此只要表中有一半的空位置,就不会存在表满的问题。在搜索时可以不考虑表装满的情况,但在插入时必须确保表的装载因子a不超过0.5,如果超出必须考虑增容。所以二次探测就是以空间换时间的探测方法。
- 二次探测的优点:可以解决线性探测的数据堆积问题。
- 二次探测的缺点:但是,闭散列最大的缺陷就是空间利用率比较低,这也是哈希的缺陷。
- 最终代码(可在构造对象时选择线性探测还是二次探测):
//*****闭散列*******// #pragma once #include<iostream> #include<vector> using namespace std; //我们可以使用 #define 定义常量,为什么非要使用枚举? 枚举的优点: //1. 增加代码的可读性和可维护性 //2. 和#define定义的标识符比较枚举有类型检查,更加严谨。 //3. 防止了命名污染(封装) //4. 便于调试 //5. 使用方便,一次可以定义多个常量 //6. 这些可能取值都是有值的,默认从0开始,一次递增1,当然在定义的时候也可以赋初值。 enum State{ EXIST,EMPTY,DELETE }; //哈希中存放的元素我们约定存放的元素不可以重复 template<class T> struct element { element(const T& key=T()) :_key(key),_state(EMPTY) {} T _key; State _state; }; template<class T, bool isline=true> class my_hash{ public: my_hash(size_t capacity=10) :_capacity(capacity),_size(0),_total(0),_con(capacity) {} size_t size()const { return _size; } size_t capacity()const { return _capacity; } //插入元素:要想插入散列表就要先判断当前元素的插入位置是否有了元素,如果有元素那么 //就线性探测向后寻找要插入元素的位置 bool insert(const T& val) { //插入之前先判断当前元素是否已经存在: if (find(val).second) { return false; } check_capacity(); size_t val_addr = get_index(val); int i = 0;//二次探测,探测的次数 //寻找要插入元素位置: while (_con[val_addr]._state != EMPTY) { //如果当前要插入元素已经存在那么就返回false插入失败,这里必须要加EXIST因为 //这里我们插入元素实际给原有的初始化空间赋值(意思就是我们这里用vector做为底层容器 //那么默认初始化的元素为0,如果我们当前要插入的元素刚好也是0相同那么这里如果不加_state==EXIST //的话空间中元素相同那么就认为当前元素已经存在,但是其标志位state其实还是EMPTY,逻辑上是不存在的 if (_con[val_addr]._state==EXIST&&_con[val_addr]._key == val) { return false; } else if (_con[val_addr]._state == DELETE&&_con[val_addr]._key == val) {//这里处理的情况是当前空间的元素被删除了只是逻辑上的删除那么我们可以在该位置插入元素 //而不需要更新_total因为当前位置原本就被算入到total中 _con[val_addr]._state = EXIST; _con[val_addr] = val; _size++; } if (isline) {//线性探测 val_addr++; if (val_addr == _capacity) { val_addr = 0; } } else {//二次探测 val_addr =( val_addr + 2 ^ i + 1)%_capacity; i++; } } _con[val_addr]._key = val; _con[val_addr]._state = EXIST; _size++; _total++; return true; } void swap(my_hash<T,isline>& t) { _con.swap(t._con); std::swap(t._size, _size); std::swap(t._capacity, _capacity); std::swap(t._total, _total); } pair<size_t,bool> find(const T& val) { size_t val_addr = get_index(val); int i = 0; while (_con[val_addr]._state!=EMPTY) { if (_con[val_addr]._key == val) { if (_con[val_addr]._state == DELETE) //如果当前元素被删除了那么就返回false { return make_pair(npos, false); } return make_pair(val_addr,true); } if (isline) {//线性探测 val_addr++; if (val_addr == _capacity) { val_addr = 0; } } else {//二次探测:为什么这里不像上面一样超过当前容量就将哈希地址赋值为0呢? //因为如果探测次数变多的话那么每次加的地址就会变大,有可能到后面每次加的地址偏移量都大于容量大小那么就会 //陷入一个死循环 val_addr = (val_addr + 2 ^ i + 1) % _capacity; i++; } } //找不到就返回npos; return make_pair(npos, false); } bool erase(const T& val) { if (find(val).second == false) { return false; } //只有当addr存在才可以删除 size_t erase_addr = find(val).first; _con[erase_addr]._state = DELETE; --_size;//更新有效元素个数 return true; } static size_t npos; private: //判断当前的哈希散列表是否要扩容: //当哈希的载荷因子大于0.7时就需要扩容,载荷因子=填入表中元素个数/散列表的长度 void check_capacity() { if (_total * 10 / _capacity >= 7) { my_hash<T,isline> newcon(2 * _capacity); for (int i = 0; i < _capacity; i++) { if (_con[i]._state == EXIST) { newcon.insert(_con[i]._key); } } swap(newcon);//这里如果不加this->会如何调用的当前类中的swap还是std中的swap } } //哈希中元素位置: size_t get_index(const T& val) { return val%_capacity; } size_t _capacity; vector<element<T>> _con; size_t _size;//有效元素个数 //哈希散列表中存放的总的元素个数这里包括已删除的元素。为什么要存放有效总的元素个数呢? //我们知道delete位置是不可以插入元素的因为如果在delete位置插入元素的话那么,有可能 //当前元素是已经插入过了的,只是因为哈希冲突存放在了后面,那么我们直接在delete插入 //就有可能将后面已经存在的元素再次插入哈希表中。从而导致哈希表中存放了相同的元素 //因为状态为delete的位置不可以插入元素那么我们可以将delete位置也当作一个有效元素来 //看待所以我们在扩容的时候要用有效元素个数加上delete位置个数来计算是否要扩容。 //一句话总结上面的内容就是:因为被删除位置是不可以插入元素的,所以我们在计算是否要 //扩容的时候要用有效元素个数+被删除位置元素个数来计算。 size_t _total; }; template<class T,bool isline=true> size_t my_hash<T,isline>:: npos = -1; void test() { my_hash<int,false> h(10); h.insert(22); h.insert(23); h.insert(24); h.insert(22); h.insert(32); h.insert(42); h.insert(7); h.insert(8); h.insert(42); h.insert(19); h.insert(99); cout << h.size() << endl; h.erase(19); cout << h.size() << endl; if (h.find(99).second) { cout << "99 is in hash,index=" << h.find(99).first<<endl; } else { cout << "99 is not in hash" << endl; } h.insert(99); if (h.find(99).second) { cout << "99 is in hash,index=" << h.find(99).first << endl; } else { cout << "99 is not in hash" << endl; } cout << h.find(1).first; }
-
3.2.2.开散列
-
1.开散列概念
- 开散列法又叫链地址法(开链法),首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。也就是说我们解决哈希冲突的方法变成了将节点组织成一个链表挂在哈希冲突的位置。
-
2.代码实现
- 这里我们将代码一次性实现完成随后讲解代码实现中的问题
#pragma once #include<iostream> #include<vector> #include"primeList.h" using namespace std; //开散列:(拉链法-链地址法-哈希桶) 原理:数组+链表 template<class V> struct HashBucketNode { public: HashBucketNode(const V& data) : _next(nullptr), _data(data) {} HashBucketNode<V>* _next; V _data; }; //将字符串转为整型: template<class T> class STRTOI { public: size_t operator()(const T *str) { return BKDRHash(str); } private: size_t BKDRHash(const T *str) { register size_t hash = 0; while (size_t ch = (size_t)*str++) { hash = hash * 131 + ch; } return hash; } }; //哈希中默认传入的是整型 template<class T> class BASEC_TYPE { public: size_t operator()(const T& val) { return val; } }; template<class T, class TYPE = BASEC_TYPE<T>> class hashbucket { public: typedef HashBucketNode<T> node; hashbucket(size_t capacity=7) :_con(capacity),_size(0),_capacity(capacity) {} //允许插入重复元素 bool insert_repeat_element(const T& val) {//那么直接头插 ChekeCapacity(); size_t bucket_addr = get_bucket(val); node* newnode = new node(val); newnode->_next = _con[bucket_addr]; _con[bucket_addr] = newnode; _size++; return true; } //删除所有重复元素,返回删除重复元素个数 size_t erase_repeat_element(const T& val) { size_t bucket_addr = get_bucket(val); node* cur = _con[bucket_addr]; node* prev = nullptr; size_t oledsize = _size; while (cur) { if (cur->_data == val) { if (prev == nullptr) {//删除头节点 prev = cur; cur = cur->_next; delete prev; prev = nullptr; _con[bucket_addr] = cur; } else {//否则删除中间节点 prev->_next = cur->_next; delete cur; cur = prev->_next; } _size--; } else { prev = cur; cur = cur->_next; } } return oledsize - _size; } //插入元素为唯一值 bool insert_unique_element(const T& val) { ChekeCapacity(); //获取元素要插入桶的位置: size_t bucket_addr = get_bucket(val); if (find(val)) {//如果在桶中找到当前元素那么就插入失败返回false return false; } else {//如果没有找到那么头插到当前桶中 node* newnode = new node(val); newnode->_next = _con[bucket_addr]; _con[bucket_addr] = newnode; _size++; return true; } } //删除唯一元素: bool erase_unique_element(const T& val) { size_t bucket_addr = get_bucket(val); node* cur = _con[bucket_addr]; node* prev = nullptr; while (cur) { if (cur->_data == val) { if (prev == nullptr) {//删除头节点 prev = cur;//用prev将当前要删除节点保存 cur = cur->_next;//保存头节点的下一个 _con[bucket_addr] = cur;//更新头节点 delete prev;//删除原来头节点 } else { prev->_next = cur->_next; delete cur; } _size--; return true; } else { prev = cur; cur = cur->_next; } } return false; } node* find(const T& val) { //获取要查找元素所在桶 size_t bucket_addr = get_bucket(val); node* cur = _con[bucket_addr]; while (cur) { if (cur->_data == val) { return cur; } else { cur = cur->_next; } } return nullptr; } size_t bucket_count() { return _con.capacity(); } //辅助方法:打印当前哈希桶中所有元素帮助我们检测当前哈希桶是否实现正确 void print_bucket() { for (int i = 0; i < _capacity; i++) { node* cur = _con[i]; cout << "bucket[" << i << "]:"; while (cur) { cout << cur->_data << "--->"; cur = cur->_next; } cout << "nullptr" << endl; } cout << "===========================================" << endl; } size_t size() { return _size; } bool empty() { return _size == 0; } void swap(hashbucket<T, TYPE>& t) { _con.swap(t._con); std::swap(t._size, _size); std::swap(t._capacity, _capacity); } void ChekeCapacity() { //开散列最好的情况就是散列表中每条链表只挂一个元素也就是当我们散列表长度=元素个数那么就需要扩容了 if (_size == _capacity) { size_t new_capacity = GetNextPrime(_capacity); vector<node*> new_bucket(new_capacity); for (int i = 0; i < _capacity; i++) { node* cur = _con[i];//将当前哈希桶中的节点挂到新建哈希桶中 while (cur) { cur->_next = new_bucket[get_bucket(cur->_data, new_capacity)]; new_bucket[get_bucket(cur->_data, new_capacity)] = cur; cur = cur->_next; } } _con.swap(new_bucket); } } ~hashbucket() { for (int i = 0; i < _capacity; i++) { node** cur = &_con[i]; while (*cur) { node* next = (*cur)->_next; delete *cur; *cur = nullptr; *cur = next; } } } private: //获取哈希元素在所在哈希桶的位置: size_t get_bucket(const T& val) { TYPE type; return type(val)%_capacity; } size_t get_bucket(const T& val,const size_t capacity) { TYPE type; return type(val) % capacity; } size_t GetNextPrime(size_t prime) { size_t i = 0; for (; i < PRIMECOUNT; ++i) { if (primeList[i] > prime) return primeList[i]; } return primeList[i]; } private: vector<node*> _con; size_t _size; size_t _capacity; };
包含的primeList.h文件:
#pragma once const int PRIMECOUNT = 28; 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 };
-
3.范型模板兼容问题
- 我们可以发现如果我们哈希中放入string类,但是string类又不是一个整数,那么我们想要用整数的方式获取当前string类要存入的位置是不可能的,所以我们必须要找到一个办法来将整数转化为字符串这里我们采用一种网上的方法将string类转化为字符串,我们给一个仿函数。
- 然后再给一个获取默认类型的仿函数。
- 然后我们在模板中增加一个类型,如果我们想要放入string类就要再构造对象的时候将其传入模板参数列表:
- 获取桶的位置
-
4.容量为素数时候模除计算桶位置哈希冲突最少
- 我们知道当计算桶的位置的时候容量为素数的时候哈希冲突的位置较少(这是我在网上看到的一种结论,如果大家想要深入了解可以去查阅相关资料),那么我们即使在最一开始的时候将哈希表的容量设置为一个素数但是在扩容的时候如果二倍扩容那么容量就会变为一个素数这样是,那么我们就要想办法在二倍的容量左右找到一个素数来设置为新的容量。这里我们事先将要扩容的素数作为一个数组给出,然后我们写一个寻找素数的方法。每次扩容的时候新的容量就取最接近当前容量的素数作为新容量。
-
5. 开散列增容
- 桶的个数是一定的,随着元素的不断插入,每个桶中元素的个数不断增多,极端情况下,可能会导致一个桶中链表节点非常多,会影响的哈希表的性能,因此在一定条件下需要对哈希表进行增容,那该条件怎么确认呢?开散列最好的情况是:每个哈希桶中刚好挂一个节点,再继续插入元素时,每一次都会发生哈希冲突,因此,在元素个数刚好等于桶的个数时,可以给哈希表增容。
- 这里我们增容时不是采用的像闭散列那样直接新创建一个对象然后调用对象中的insert函数然后遍历每一个节点将节点插入到新的开散列中。因为我们如果调用insert函数就会重新创建一遍节点那么当前已经存在的节点就没有利用到,这样不仅使得程序需要重新创建节点消耗时间而且如果没有对旧的节点进行释放那么还会造成内存泄漏。
-
6.关于黑客闲着没事攻击你的哈希表
- 尽管我们上面已经尽量使哈希表中一个节点中所挂元素减少,但是如果现在有黑客知道了你解决哈希冲突的办法,和你的哈希函数,那么他针对你的哈希函数存入一堆元素使得这些元素都集中在一条链表上,那么我们又该怎么办呢?
- 解决方案:我们实现哈希其实就是要作为map和set的底层容器来实现map和set那么我们就可以自然的想到红黑树,如果这里有大部分元素集中在一条链表上,那么我们是否可以将这些元素转化为一颗红黑树,这样即使所有元素都集中在一条链表上那么我们最次的查找效率也就是红黑树的查找效率O(log2N)。具体实现就是给每条链表加一个阈值例如阈值为8链表中元素超过八个的时候我们就将链表结构转化为红黑树,如果此时元素被删除之后,删除元素剩下6个我们就将结构转化为链表(这是因为红黑树对红黑树的删除也需要复杂的旋转操作,我们当元素个数不是对查找效率影响不是那么大的时候我们就可以考虑将结构转化回来)。