目录
1.哈希映射
1.1哈希的概念
1.2哈希冲突
1.3哈希函数
1.31直接定值法
1.32除留余数法
2.解决哈希冲突
2.1闭散列法
2.11线性探测
2.12二次探测
3代码实现
3.1状态:
3.2创建哈希节点类
3.21哈希表扩容:
3.3数据插入
3.4查找与删除
3.5仿函数
完整cpp表
4.开散列哈希桶
4.1概念
4.2仿函数
4.3哈希桶结点构建
4.4哈希桶的查找和删除
4.5哈希桶的插入
- 在顺序结构以及平衡树中,元素关键码与其存储位置之间没有对应的关系,因此在查找一个元素时,必须要经过关键码的多次比较。顺序查找时间复杂度为O(N),平衡树中为树的高度,即O(logN),搜索的效率决于搜索过程中元素的比较次数。
- 理想的搜索方法:可以不经过任何比较,一次直接从表中得到要搜索的元素。 如果构造一种存储结构,通过某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立一一映射的关系,那么在查找时通过该函数可以很快找到该元素。
当向该结构中:
- 插入元素:根据待插入元素的关键码,以此函数计算出该元素的存储位置并按此位置进行存放
- 搜索元素:对元素的关键码进行同样的计算,把求得的函数值当做元素的存储位置,在结构中按此位置取元素比较,若关键码相等,则搜索成功
该方式即为哈希(散列)方法,哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称为哈希表(Hash Table)(或者称散列表)。
unordered_map和unordered_set的底层就是哈希来实现的,它是无序的。
- Hash(key)=key%capacity。
聪明的小伙伴已经找到矛盾了,如果说再添加一个数据5,那他存那?
对于两个数据元素的关键字 和 (i != j),有 != ,但有:Hash() == Hash(),即:不同关键字通过相同哈希函数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞。把具有不同关键码而具有相同哈希地址的数据元素称为“同义词”
所以这种方式建立起来的映射就十分的不合理,所以我们要改进。
哈希设计的原则:
- 哈希函数的定义域必须包括需要存储的全部关键码,而如果散列表允许有m个地址时,其值域必须在0到m-1之间。
- 哈希函数计算出来的地址能均匀分布在整个空间中。
- 哈希函数应比较简单。
取关键字的某个线性函数为散列地址:Hash(key)= A*key + B
优点:简单,速度快,节省空间,查找key O(1)的时间复杂度
缺点:当数据范围大时会浪费空间,不能处理浮点数,字符串数据
使用场景:适用于整数,数据范围比较集中
把数据映射到有限的空间里面。设散列表中允许的地址数为m,取一个不大于m,但最接近或者等于m的质数p作为除数,按照哈希函数:Hash(key) = key% p(p<=m),将key转换成哈希地址。
哈希函数设计的越精妙,产生哈希冲突的可能性就越低,但是无法避免哈希冲突。
看1.1节的例子
解决哈希冲突最常用的方法是闭散列和开散列。
闭散列:也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以把key存放到冲突位置中的“下一个” 空位置中去。
但是咋去找下一个空位置才是最关键的?
线性探测:从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。
- 插入:通过哈希函数获取待插入元素在哈希表中的位置。如果该位置中没有元素则直接插入新元素,如果该位置中有元素发生哈希冲突,使用线性探测找到下一个空位置,插入新元素。
- 删除:采用闭散列处理哈希冲突时,不能随便物理删除哈希表中已有的元素,否则会影响其他元素的搜索。比如删除元素1,如果直接删除掉,11查找起来可能会受影响。因此线性探测采用标记的伪删除法来删除一个元素。
给每个位置一个标记,用空、存在、删除3种状态来区分。
- 负载因子 = 存储的有效数据个数/空间的大小 。
- 负载因子越大,冲突的概率越高,增删查改效率越低。
- 负载因子越小,冲突的概率越低,增删查改的效率越高,但是空间利用率低,浪费多。
- 负载因子 <1,就能保证发生哈希冲突时一定能找到空位置。
线性探测的优缺点:
- 线性探测优点:实现非常简单。
- 线性探测缺点:一旦发生哈希冲突,所有的冲突连在一起,容易产生数据“堆积”,即:不同关键码占据了可利用的空位置,使得寻找某关键码的位置需要许多次比较,导致搜索效率降低。
二次探测改进了一些线性探测,但是也就那样,这里我就不给太多画面了。
所谓的二次探测我们可以理解为飞跃式,线性是一个一个找空位置,二次就是跳着找。
方法:hash(key) + i^2
hash(11)=11%10+0*2=1,但是1的位置被占了,所以变成hash(11)+1*2,如果这个位置是空的就放进去,不是的话,i继续加。
状态:这里需要三种状态:空,已占用,已删除
如果只用有/没有来代表状态,那删除一个数据后,这个位置就是空的,那就不会再遍历了,但是它后面还有数据的话就存在问题了,所以我们用已删除这个状态来表示的话,还可以遍历后面的数据。
#pragma once #include
#include using namespace std; namespace CloseHash { enum State { EMPTY, //0 空 EXIST, //1 存在 DELETE, // 2 已删除 }; }
template
struct HashData { pair _kv;//数据 State _state = State::EMPTY;//状态 --空 }; template //添加仿函数便于把其他类型的数据转换为整型数据 class HashTable { public: //相关功能的实现…… private: vector > _table;//哈希表 size_t _n = 0;//存储哈希表中有效数据的个数 };
- 查找:当数据是1时,直接映射到下标1处,此时该位置的状态是EXIST,数据是11时,映射到下标1处,但是已经有1了所以++往后找空位置找到后状态更新为EXIST。
- 删除:删除1,找11,当删除1后,状态更新为DELETE,查找11时下标发现状态是DELETE时会继续往后移动,然后找到11.
3.21哈希表扩容:
当插入的数据较多,而哈希表较短时,就要考虑到扩容,但是哈希的扩容不简单,因为一扩容,下标就变了,那很多数据的映射后的位置就变了。
现在的11在下标11处,就不在2处了。
所以我们在上面提到了负载因子: 填入表中的元素个数 / 散列表的长度
由于散列表的长度一定,所以负载因子和表中的元素个数成正比。元素个数越多,哈希冲突越大,所以我们一般将负载因子定在0.7~0.8,在代码中我们就定在0.7。
插入时我们再介绍。
插入的详细步骤:
- 去除重复:
- 插入的值可能 已经存在,所以用用Find先进行查找
- 找到就返回false,没找到在插入。
- 空间扩容:
- 如果表是空的就给10个空间,否则扩大2倍。
- 建立一个新哈希表,把旧表的值插入到新表
- 探测找位
- 如果位置的状态是EXIST,继续往后移
- 找到空位置后,插入,状态变为存在,_n++。
//查找 bool Insert(const pair
& kv) { size_t hashi = kv.first % _tables.size(); //1.遇到空就停止了 //线性探测 while (_tables[hashi]._state == EXIST) { hashi++; hashi %= _tables.size(); //2. 可能出表 } _tables[hashi]._kv = kv; _tables[hashi]._state = EXIST; ++_n; return true; } 在1.0版本中我注意两个细节:
- hashi为啥要模size而不是capacity?
计算机开辟了20个空间,只存储10个数据,但是只能让计算机在前十个空间存,要不一旦用空的空间,遍历时遇到后就不在往后进行了。所以开的空间一般和数据个数一致。
- while循环中为啥要加一步hashi%=_table.size()?
比如我们开了20个空间,下标16以后都存满了但是前面还有位置,但是我们存一个37,那他就一直找位置,直到找到19,然后就出这个哈希表了,所以要让他返回到表上再到下标靠前的位置去找。
接下来就要开辟空间了。但是这个可不简单。
当插入的数据较多,而哈希表较短时,就要考虑到扩容,但是哈希的扩容不简单,因为一扩容,下标就变了,那很多数据的映射后的位置就变了。
现在的11在下标11处,就不在2处了。
所以我们在上面提到了负载因子: 填入表中的元素个数 / 散列表的长度
由于散列表的长度一定,所以负载因子和表中的元素个数成正比。元素个数越多,哈希冲突越大,所以我们一般将负载因子定在0.7~0.8,在代码中我们就定在0.7。
在这里我们就要重新建一个哈希表。
//查找 bool Insert(const pair
& kv) { if (Find(kv.first)) //插入的值已经存在 { return false; } //扩容 if (_tables.size() == 0 || 10 * _n / _tables.size() >= 7) //负载因子 { size_t newSize = _tables.size() == 0 ? 10 : _tables.size() * 2; //扩容 HashTable newHT; newHT._tables.resize(newSize); //扩容后用一个新表 //遍历旧表,把旧表每个存在的元素插入newHT for (auto& e : _tables) { if (e._state == EXIST) { newHT.Insert(e._kv); } } newHT._tables.swap(_tables);//建立映射关系后交换 } size_t hashi = kv.first % _tables.size(); //1.遇到空就停止了 //线性探测 while (_tables[hashi]._state == EXIST) { hashi++; hashi %= _tables.size(); //2. 可能出表 } _tables[hashi]._kv = kv; _tables[hashi]._state = EXIST; ++_n; return true; } 如果哈希表中已经有插入的值时,我们就要去除冗杂,用find函数。
查找的大致思路和插入的很接近,这里就不在重复了。
//查找 HashData
* Find(const K& key) { if (_tables.size() == 0) { return nullptr; } size_t hashi = key % _tables.size(); while (_tables[hashi]._state != EMPTY) { if (_tables[hashi]._kv.first == key) { return &_tables[hashi]; //返回的是地址 } hashi++; hashi %= _tables.size(); } return nullptr; } //删除 bool Erase(const K& key) { HashData * ret=Find(key); if (ret) { ret->_state = DELETE; --_n; } else { return false; } } 删除时,我们直接把要删除的数据状态改成DELETE就行了,甚至内部的数据都不用删除。
这里的取模用的都是整数,那如果数据是浮点型?更甚至是字符串怎么搞?所依我们就要用到仿函数来进行类型转换。
//利用仿函数将数据类型转换为整型 template
struct HashFunc { size_t operator()(const K& key) { return (size_t)key; } }; //模板的特化 template<> struct HashFunc //如果是字符串,直接调用特化模板 { size_t operator()(const string& key) { size_t hash = 0; for (auto ch : key) { hash = hash * 131 + ch;//把所有字符的ascii码值累计加起来 } return hash; } };
#pragma once #include
#include using namespace std; enum State { EMPTY, //0 空 EXIST, //1 存在 DELETE, // 2 已删除 }; template struct HashData { pair _kv;//数据 State _state = State::EMPTY;//状态 --空 }; //利用仿函数将数据类型转换为整型 template struct HashFunc { size_t operator()(const K& key) { return (size_t)key; } }; //模板的特化 template<> struct HashFunc { size_t operator()(const string& key) //字符串直接使用 { size_t hash = 0; for (auto ch : key) { hash = hash * 131 + ch;//把所有字符的ascii码值累计加起来 } return hash; } }; template > class HashTable { public: //插入 bool Insert(const pair & kv) { if (Find(kv.first)) //插入的值已经存在 { return false; } //扩容 if (_tables.size() == 0 || 10 * _n / _tables.size() >= 7) //负载因子 { size_t newSize = _tables.size() == 0 ? 10 : _tables.size() * 2; //扩容 HashTable newHT; newHT._tables.resize(newSize); //扩容后用一个新表 //遍历旧表,把旧表每个存在的元素插入newHT for (auto& e : _tables) { if (e._state == EXIST) { newHT.Insert(e._kv); } } newHT._tables.swap(_tables);//建立映射关系后交换 } Hash hf; size_t start = hf(kv.first);//取出键值对的key,并且避免了负数的情况,借用仿函数确保是整型数据 start %= _tables.size(); size_t hashi = start; size_t i = 1; //1.遇到空就停止了 //线性探测 while (_tables[hashi]._state == EXIST) { hashi++; hashi %= _tables.size(); //2. 可能出表 } _tables[hashi]._kv = kv; _tables[hashi]._state = EXIST; ++_n; return true; } //查找 HashData * Find(const K& key) { if (_tables.size() == 0) { return nullptr; } Hash hf; size_t start = hf(key);//通过仿函数把其它类型数据转为整型数据 start %= _tables.size(); size_t hashi = start; size_t i = 1; while (_tables[hashi]._state != EMPTY) { if (_tables[hashi]._kv.first == key) { return &_tables[hashi]; //返回的是地址 } hashi++; hashi %= _tables.size(); } return nullptr; } //删除 bool Erase(const K& key) { HashData * ret=Find(key); if (ret) { ret->_state = DELETE; --_n; } else { return false; } } private: vector > _tables;//哈希表 size_t _n = 0;//存储哈希表中有效数据的个数 }; void testHash1() { int a[] = { 1,11,4,15,26,7 }; HashTable ht; for (auto e : a) { ht.Insert(make_pair(e, e)); } }
开散列也叫拉链法:先对所有key用散列函数计算散列地址,把有相同地址的key每个key都作为一个桶,通过单链表链接在哈希表中。
此时的表里面存储一个链表指针,就是把冲突的数据通过链表的形式挂起来。
它的算法公式:hash(key)=key%capacity
这里的插入可以是头插也可以是尾插,插入时是无序的。
也就是说哈希桶的根本是一个指针数组,哈希桶的每一个位置存的都是一个链表指针。
这个指针数组里的每一个元素都是结点指针,并且头插的效率比较高。
这次我们先弄模板来将其他类型转换为size_t。
namespace OpenHash { template
struct Hash { size_t operator()(const K& key) { return key; } }; // 特化 template<> struct Hash < string > { size_t operator()(const string& s) { // BKDR Hash size_t value = 0; for (auto ch : s) { value += ch; value *= 131; } return value; } }; }
因为是指针数组,所以结点中的成员变量多了一个指向下一个桶的指针。
//结点类 template
struct HashNode { pair _kv; HashNode * _next; //构造函数 HashNode(const pair & kv) :_kv(kv) , _next(nullptr) {} }; //哈希表的类 template > class HashTable { typedef HashNode Node; public: //相关功能的实现…… private: //指针数组 vector _tables; size_t _n = 0;//记录有效数据的个数 };
这里的查找\删除操作和上面的如出一辙,但是哈希桶的存储是链表的形式,所以会和链表的相关操作很接近。
//查找 Node* Find(const K& key) { //防止后续除0错误 if (_tables.size() == 0) { return nullptr; } //构建仿函数 HashFunc hf; //找到对应的映射下标位置 size_t hashi = hf(key); hashi %= _tables.size(); Node* cur = _tables[hashi]; //在此位置的链表中进行遍历查找 while (cur) { if (cur->_kv.first == key) { //找到了 return cur; } cur = cur->_next; } //遍历结束,没有找到,返回nullptr return nullptr; } //删除 bool Erase(const K& key) { if (_tables.size() == 0) { return nullptr; } size_t hashi = key % _tables.size(); Node* cur = _tables[hashi]; Node* prev = nullptr; while (cur) { if (cur->_kv.first == key) { //1.头删 if (prev == nullptr) { _tables[hashi] = cur->_next; } else //中间位置删除 { prev->_next = cur->_next; } delete cur; return true; } prev = cur; cur = cur->_next; } return false; }
- 去除重复:
- 插入的值可能 已经存在,所以用用Find先进行查找
- 找到就返回false,没找到再进行插入。
- 空间扩容:
- 如果负载因子==1就进行扩容。
- 建立一个新哈希表,把旧表的值插入到新表。
- 再把新表交换到旧表那里。
但是在把旧表映射到新表时要释放掉旧表,vector类型会自动调用析构函数,然而存储的数据是Node*类型的,是内置类型,不会自动释放,结果就是哈希表释放了但是表中存的数据没释放,所以我们要手写一个析构函数。
- 头插操作
- 根仿函数找到合适映射位置
- 进行头插操作并更新桶内数据个数。
所以我们首先写一个析构函数:
//析构函数 ~HashTable() { for (size_t i = 0; i < _tables.size(); i++) { Node* cur = _tables[i]; while (cur) { Node* next = cur->_next; delete cur; cur = next; } _tables[i] = nullptr;//释放后置空 } }
整体代码:
//插入 bool Insert(const pair
& kv) { //1、去除重复 if (Find(kv.first)) { return false; } //2、负载因子 == 1就扩容 if (_tables.size() == _n) { size_t newSize = _tables.size() == 0 ? 10 : _tables.size() * 2; vector newTable; newTable.resize(newSize, nullptr); for (size_t i = 0; i < _tables.size(); i++)//遍历旧表 { Node* cur = _tables[i]; while (cur) { Node* next = cur->_next; size_t hashi = hf(cur->_kv.first) % newSize;//确认映射到新表的位置 //头插 cur->_next = newTable[hashi]; newTable[hashi] = cur; cur = next; } _tables[i] = nullptr; } newTable.swap(_tables); } //3、头插 //构建仿函数,把数据类型转为整型,便于后续建立映射关系 HashFunc hf; size_t hashi = hf(kv.first); hashi %= _tables.size(); //头插到对应的桶即可 Node* newnode = new Node(kv); newnode->_next = _tables[hashi]; _tables[hashi] = newnode; ++_n; return true; }
C++ 进阶: 本仓库存放一些较难C++代码https://gitee.com/j-jun-jie/c---advanced.git