当前位置: 首页 > web >正文

【C++】哈希表的实现(链地址法)

 

        【C++】哈希表的实现(开放定址法)中我们介绍了一个哈希函数:除法散列法(除留余数法),还有一个处理哈希冲突的方法:开放定址法中的线性探测。

        本篇继续讲解一些别的哈希函数和处理哈希函数的方法,以及如何用链地址法实现这个哈希表。

1.其他哈希函数

1.1 乘法散列法(了解)

乘法散列法对哈希表⼤⼩ M没有要求 ,他的⼤思路:
  • 第⼀步:⽤关键字 K 乘常数 A (0<A<1),并抽取出 k*A⼩数部分
  • 第⼆步:⽤M乘以k*A 的⼩数部分,再向下取整

        其实就是让M去乘一个[0, 1)之间的小数,这个小数要与K相关,K不同,这个小数就不同,就能映射的更分散。

        经过大佬Knuth的分析,建议这个A取黄金分割点A=(\sqrt{5}-1)/2 = 0.6180339887....

所以乘法散列法的公式为:h(key) = floor(M * (A * key) % 1.0))其中floor表⽰对表达式进⾏向下取整,A∈(0,1)。

        举个例子:乘法散列法对哈希表⼤⼩M是没有要求的,假设M为1024,key为1234,A = 0.6180339887,  A*key = 762.6539420558,取⼩数部分为0.6539420558,  M×((A×key)%1.0) = 0.6539420558*1024 = 669.6366651392,那么h(1234) = 669。

1.2 全域散列法(了解)

        这个函数主要是为了抵御恶意攻击的。如果存在⼀个恶意的对⼿,他针对我们提供的散列函数,特意构造出⼀个发⽣严重冲突的数据集, ⽐如,让所有关键字全部落⼊同⼀个位置中。这种情况是可以存在的,只要散列函数是公开且确定的,就可以实现此攻击。

        解决⽅法就是给散列函数 增加随机性 ,攻击者就⽆法找出确定可以导致最坏情况的数据。这种⽅法叫做全域散列。
        全域散列法的公式为: h(key) = ((a * key + b) % P) % M ,P需要选一个足够大的质数,a可以随机选[1, P-1]之间的任意整数,b可以随机选[0, P-1]之间的任意整数。
这些函数构成了一个P * (P - 1)组 全域散列函数组
假设key是8,P=17,M=6,a = 3, b = 4, 则h(8) = ((3 * 8 + 4) % 17) % 6 = 5。
        需要注意的是每次 初始化 哈希表 随机选取 全域散列函数组中的 ⼀个散列函数 使⽤,后续增删查改都 固定使⽤这个 散列函数,否则每次哈希都是随机选⼀个散列函数,那么插⼊是⼀个散列函数,查找⼜是另⼀个散列函数,就会导致找不到插⼊的key了。

2.处理哈希冲突的方法

在开放定址法中所有的元素都放到哈希表⾥,当⼀个关键字key⽤哈希函数计算出的位置冲突了,则按照某种规则找到⼀个没有存储数据的位置进⾏存储,开放定址法中负载因⼦⼀定是⼩于的。这⾥的规则有三种:线性探测(已做讲解)、⼆次探测、双重探测

2.1 开放定址法

  • 性探测的⽐较简单且容易实现,线性探测的问题假设,hash0位置连续冲突,hash0,hash1,hash2位置已经存储数据了,后续映射到hash0,hash1,hash2,hash3的值都会争夺hash3位置,这种现象叫做群集/堆积。 

二次探测

  • 从发⽣冲突的位置开始,依次左右按加减⼆次⽅跳跃式探测,直到寻找到下⼀个没有存储数据的位置为⽌,如果往右⾛到哈希表尾,则回绕到哈希表头的位置;如果往左⾛到哈希表头,则回绕到哈希表尾的位置。
  • h(key) = hash0 = key % M, hash0的位置冲突了,则二次探测的公式为 hc(key, i) = hashi = (hash0 \pm i^{2}) % M, i = { 1, 2, 3, ... , \frac{M}{2} }。
  • 二次探测hashi = (hash0 - i^{2}) % M 时,hashi<0时,需要hashi += M。

把 {19,30,52,63,11,22} 等这⼀组值映射到M=11的表中。

h(19) = 8, h(30) = 8, h(52) = 8, h(63) = 8, h(11) = 0, h(22) = 0

代码就不实现了。 

双重探测(了解):

线性探测是挨着找空位,二次探测是有规律的跳跃着找空位,双重探测就是再弄一个哈希函数,无规律的跳跃着找空位,减少堆积。

第⼀个哈希函数计算出的值发⽣冲突,使⽤第⼆个哈希函数计算出⼀个跟key相关的偏移量值,不
断往后探测,直到寻找到下⼀个没有存储数据的位置为⽌。
h1 ( key ) = hash 0 = key % M , hash0位置冲突了,则双重探测公式为:
hc ( key , i ) = hashi = ( hash 0 + i h 2 ( key )) % M i = {1, 2, 3, ..., M }

2.2 链地址法

前面的开放定址法不管是哪种探测,还是会出现相互挤占位置的情况,不能完全解决这个问题。更好的解决方法其实是链地址法

链地址法中所有的数据不再直接存储在哈希表中,哈希表中存储⼀个指针,没有数据映射这个位置时,这个指针为空,有多个数据映射到这个位置时,我们把这些冲突的数据 链接成⼀个链表 ,挂在哈希表这个位置下⾯,链地址法也叫做 拉链法或者哈希桶
比如说把 {19,30,5,36,13,20,21,12,24,96} 等这⼀组值映射到M=11的表中。
h(19) = 8 h(30) = 8 h(5) = 5 h(36) = 3 h(13) = 2 h(20) = 9 h(21) = 10, h(12) = 1, h(24) = 2, h(96) = 8

开放定址法负载因⼦必须⼩于1, 链地址法 负载因⼦就没有限制 了,可以⼤于1。
  • 负载因⼦越⼤,哈希冲突的概率越⾼,空间利⽤率越⾼;
  • 负载因⼦越⼩,哈希冲突的概率越低,空间利⽤率越低;
  • stl中unordered_xxx的最⼤负载因⼦基本控制在1,⼤于1就扩容
我们下⾯实现也使⽤这个⽅式。

2.2.1 insert代码实现

先把哈希桶的结构搞出来。

namespace hash_bucket  // 链地址法/哈希桶
{template<class K, class V>struct HashNode{pair<K, V> _kv;HashNode<K, V>* _next; //存放下一个节点的地址HashNode(const pair<K, V>& kv):_kv(kv),_next(nullptr){}};template<class K, class V>class HashTable{typedef HashNode<K, V> Node;public:HashTable():_table(11),_n(0){}private:vector<Node*> _table; // vector里面存HashNodesize_t _n;};
}

这里选择vector里面其实也可以存list,但这里选择存自定义类型HashNode。

bool Insert(const pair<K, V>& kv)
{size_t hashi = kv.first % _table.size(); //映射位置Node* newnode = new Node(kv);}

创建好结点之后,这里可以选择头插,也可以选择尾插,都是一样的 ,我在里就写头插的实现。

比如现在要插入这个96,96映射的位置是8。

要让newnode的next链接到30,newnode成为新的头,而hashi在这个位置里面存的就是值为30的节点的地址,所以让newnode的next指向hashi里面存的地址就行,然后更新hashi。

插入数据之后要更新_n。 

bool Insert(const pair<K, V>& kv)
{size_t hashi = kv.first % _table.size(); //映射位置Node* newnode = new Node(kv);//头插newnode->_next = _table[hashi];_table[hashi] = newnode;_n++;
}

我们要把负载因⼦基本控制在1,⼤于1就扩容。

在开放地址法的实现中,我们用了巧妙的写法针对扩容后旧数据的处理,就是insert里面复用insert,但是那种写法在这里并不推荐使用。

首先是因为我们vector里面挂的是HashNode,局部对象出作用域就销毁,但HashNode不特殊处理不会自己释放(如果vector里面存的是list就可以 ),

第二点就是,已经存在的节点,我们可以直接拿过来用,如果是开放定址法那样的写法,这些节点会被拷贝一份,连接到新表上,我们还要自己手动释放旧表的那些节点,还不如把原来的节点直接拿过来用。

实现如下。

bool Insert(const pair<K, V>& kv)
{if (_n == _table.size()) //扩容{vector<Node*> newtable(2 * _table.size());//创建新表for (int i = 0; i < _table.size(); i++) //遍历旧表{Node* cur = _table[i];while (cur) //遍历当前位置挂的所有节点{Node* next = cur->_next; //记录cur的下一个节点size_t hash0 = cur->_kv.first % newtable.size(); //计算在新表的位置//直接把节点头插到新表cur->_next = newtable[hash0];newtable[hash0] = 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] = newnode;_n++;return true;
}

先拿下面这组测试样例测试一下。 

int main()
{int arr[] = { 19,30,5,36,13,20,21,12,24,96,4,17 };hash_bucket::HashTable<int, int> h;for (auto i : arr){h.Insert({ i, i });}return 0;
}

如果通过,就可以在扩容部分换成用素数表

素数表代码在 【C++】哈希表的实现(开放定址法)

2.2.2 find代码实现

find实现逻辑就是 先计算出要找的这个值映射的位置,然后遍历挂着的链表就行。

Node* Find(const K& key)
{size_t hashi = key % _table.size();Node* cur = _table[hashi];while (cur){if (cur->_kv.first == key){return cur; //找到了}cur = cur->_next;}return nullptr; //没找到
}

测试一下。 

int arr[] = { 19,30,5,36,13,20,21,12,24,96,4,17 };
hash_bucket::HashTable<int, int> h;
for (auto i : arr)
{h.Insert({ i, i });
}if (h.Find(1))cout << "找到了" << endl;
elsecout << "没找到" << endl;if (h.Find(19))cout << "找到了" << endl;
elsecout << "没找到" << endl;return 0;

我们可以在insert里面添加一下find,把insert实现成不允许冗余的。

if (Find(kv.first))return false;

 

2.2.3 erase的代码实现

节点的删除要分情况讨论:删除节点为头节点;删除节点为中间节点或尾节点。

删除头节点,比如把这个96删了,96记为cur。

直接让下标为8的这个位置存储cur的下一个节点的地址就行了。

如果删除中间节点,比如删除30,30记为cur。

 我们要让96和19链接上,就是让cur的前一个指向cur的后一个,所以我们需要把cur的前一个记录下来。

bool Erase(const K& key)
{size_t hashi = key % _table.size();Node* cur = _table[hashi];while (cur){Node* prev = nullptr;if (cur->_kv.first == key) //找到了{if (prev == nullptr) //删除节点为头节点{_table[hashi] = cur->_next;}else //删除节点为中间节点或尾节点{prev->_next = cur->_next;}delete cur;return true;}else{prev = cur;cur = cur->_next;}}return false; //删除失败
}

测试一下。 

int arr[] = { 19,30,5,36,13,20,21,12,24,96,4,17 };
hash_bucket::HashTable<int, int> h;
for (auto i : arr)
{h.Insert({ i, i });
}if (h.Find(19))cout << "找到了" << endl;
elsecout << "没找到" << endl;h.Erase(19); //删除19if (h.Find(19))cout << "找到了" << endl;
elsecout << "没找到" << endl;

2.2.4 析构

这里涉及自定义类型的释放,可以自己手动写一个析构函数。

~HashTable()
{for (int i = 0; i < _table.size(); i++){Node* cur = _table[i];while (cur){Node* next = cur->_next;delete cur;cur = next;}_table[i] = nullptr;}
}

拷贝构造这些也可以实现一下,这里就不写了。

2.2.5 K不能取模问题

这里也是和开放地址法一样的,要多加一个模板参数。

template<class K>
struct HashFunc
{bool operator()(const K& key){return (size_t)key;}
};template<class K, class V, class Hash = HashFunc<K>>
class HashTable
{//...
}

string类型经常用到,把string特化

template<>
struct HashFunc<string>
{bool operator()(const string& s){size_t hashi = 0;for(auto ch : s){ hashi += ch;hashi *= 131;}return hashi;}
};

然后其他需要改动的地方也要一起改。

Insert:

Find: 

 

Erase:
 

测试一下。

int main()
{const char* arr[] = { "hello", "left", "sort", "passage" };hash_bucket::HashTable<string, string> s;for (auto& e : arr){s.Insert({ e, e });}return 0;
}

 如果没有错误就可以了,这个哈希桶就实现的差不多了。

本篇就分享到这里,我们下篇见~

http://www.xdnf.cn/news/14709.html

相关文章:

  • 并行计算与共识机制的创新融合,微美全息探索分片区块链机制
  • 计算机网络:(六)超详细讲解数据链路层 (附带图谱表格更好对比理解)
  • MATLAB GUI界面设计 第三章——仪器组件
  • Spring Boot + Logback MDC 深度解析:实现全链路日志追踪
  • 深入浅出Node.js中间件机制
  • R语言入门课| 05 一文掌握R语言常见数据类型
  • docker启动的rabbitmq搭建并集群和高可用
  • 算法与数据结构:动态规划DP
  • 【时时三省】(C语言基础)指针变量例子
  • Kafka 源码剖析:消息存储与协议实现(二)
  • Perplexity AI:对话式搜索引擎的革新者与未来认知操作系统
  • C++ - 标准库之 <string> npos(npos 概述、npos 的作用)
  • ICML 2025 | 时空数据(Spatial-Temporal)论文总结
  • 【数据结构】七种常见排序算法
  • 商品中心—10.商品B端搜索系统的说明文档
  • 详解Redis数据库和缓存不一致的情况及解决方案
  • WEB3合约开发以太坊中货币单位科普
  • react day.js使用及经典场景
  • 【代码解析】opencv 安卓 SDK sample - 1 - HDR image
  • SQL 分页方法全解析:从基础到高级应用
  • 深入解析ID3算法:信息熵驱动的决策树构建基石
  • 【Qt开发】网络运用
  • 项目中后端如何处理异常?
  • JAVA锁机制:对象锁与类锁
  • 一、什么是生成式人工智能
  • GPT-1 与 BERT 架构
  • MySQL之InnoDB存储引擎深度解析
  • 软件工程期末试卷填空题版带答案(共40道)
  • 【环境配置】在Ubuntu Server上安装5090 PyTorch环境
  • CVE-2024-6387漏洞、CVE-2025-26465漏洞、CVE-2025-26466漏洞 一口气全解决