11. 哈希冲突

上一节提到,通常情况下哈希函数的输入空间远大于输出空间,因此理论上哈希冲突是不可避免的。比如,输入空间为全体整数,输出空间为数组容量大小,则必然有多个整数映射至同一桶索引。

哈希冲突会导致查询结果错误,严重影响哈希表的可用性。为解决该问题,我们可以每当遇到哈希冲突就进行哈希表扩容,直至冲突消失为止。此方法简单粗暴且有效,但效率太低,因为哈希表扩容需要进行大量的数据搬运与哈希值计算。为了提升效率,我们可以采用以下策略。

  1. 改良哈希表数据结构,使得哈希表可以在出现哈希冲突时正常工作
  2. 仅在必要时,即当哈希冲突比较严重时,才执行扩容操作。

哈希表的结构改良方法主要包括“链式地址”和“开放寻址”。

11.1 链式地址

在原始哈希表中,每个桶仅能存储一个键值对。链式地址(separate chaining)将单个元素转换为链表,将键值对作为链表节点,将所有发生冲突的键值对都存储在同一链表中。下图展示了一个链式地址哈希表的例子。

基于链式地址实现的哈希表的操作方法发生了以下变化。

  • 查询元素:输入 key ,经过哈希函数得到桶索引,即可访问链表头节点,然后遍历链表并对比 key 以查找目标键值对。
  • 添加元素:首先通过哈希函数访问链表头节点,然后将节点(键值对)添加到链表中。
  • 删除元素:根据哈希函数的结果访问链表头部,接着遍历链表以查找目标节点并将其删除。

链式地址存在以下局限性。

  • 占用空间增大,链表包含节点指针,它相比数组更加耗费内存空间。
  • 查询效率降低,因为需要线性遍历链表来查找对应元素。

以下代码给出了链式地址哈希表的简单实现,需要注意两点。

  • 使用列表(动态数组)代替链表,从而简化代码。在这种设定下,哈希表(数组)包含多个桶,每个桶都是一个列表。
  • 以下实现包含哈希表扩容方法。当负载因子超过 2/3 时,我们将哈希表扩容至原先的 2 倍。
    /* 链式地址哈希表 */
    class HashMapChaining {private:int size;                       // 键值对数量int capacity;                   // 哈希表容量double loadThres;               // 触发扩容的负载因子阈值int extendRatio;                // 扩容倍数vector<vector<Pair *>> buckets; // 桶数组public:/* 构造方法 */HashMapChaining() : size(0), capacity(4), loadThres(2.0 / 3.0), extendRatio(2) {buckets.resize(capacity);}/* 析构方法 */~HashMapChaining() {for (auto &bucket : buckets) {for (Pair *pair : bucket) {// 释放内存delete pair;}}}/* 哈希函数 */int hashFunc(int key) {return key % capacity;}/* 负载因子 */double loadFactor() {return (double)size / (double)capacity;}/* 查询操作 */string get(int key) {int index = hashFunc(key);// 遍历桶,若找到 key 则返回对应 valfor (Pair *pair : buckets[index]) {if (pair->key == key) {return pair->val;}}// 若未找到 key 则返回空字符串return "";}/* 添加操作 */void put(int key, string val) {// 当负载因子超过阈值时,执行扩容if (loadFactor() > loadThres) {extend();}int index = hashFunc(key);// 遍历桶,若遇到指定 key ,则更新对应 val 并返回for (Pair *pair : buckets[index]) {if (pair->key == key) {pair->val = val;return;}}// 若无该 key ,则将键值对添加至尾部buckets[index].push_back(new Pair(key, val));size++;}/* 删除操作 */void remove(int key) {int index = hashFunc(key);auto &bucket = buckets[index];// 遍历桶,从中删除键值对for (int i = 0; i < bucket.size(); i++) {if (bucket[i]->key == key) {Pair *tmp = bucket[i];bucket.erase(bucket.begin() + i); // 从中删除键值对delete tmp;                       // 释放内存size--;return;}}}/* 扩容哈希表 */void extend() {// 暂存原哈希表vector<vector<Pair *>> bucketsTmp = buckets;// 初始化扩容后的新哈希表capacity *= extendRatio;buckets.clear();buckets.resize(capacity);size = 0;// 将键值对从原哈希表搬运至新哈希表for (auto &bucket : bucketsTmp) {for (Pair *pair : bucket) {put(pair->key, pair->val);// 释放内存delete pair;}}}/* 打印哈希表 */void print() {for (auto &bucket : buckets) {cout << "[";for (Pair *pair : bucket) {cout << pair->key << " -> " << pair->val << ", ";}cout << "]\n";}}
    };
    

    值得注意的是,当链表很长时,查询效率 O(n) 很差。此时可以将链表转换为“AVL 树”或“红黑树”,从而将查询操作的时间复杂度优化至 O(logn) 。

11.2 开放寻址 

开放寻址(open addressing)不引入额外的数据结构,而是通过“多次探测”来处理哈希冲突,探测方式主要包括线性探测、平方探测、多次哈希等。

下面以线性探测为例,介绍开放寻址哈希表的工作机制。

11.2.1 线性探测

线性探测采用固定步长的线性搜索来进行探测,其操作方法与普通哈希表有所不同。

  • 插入元素:通过哈希函数计算桶索引,若发现桶内已有元素,则从冲突位置向后线性遍历(步长通常为 1 ),直至找到空桶,将元素插入其中。
  • 查找元素:若发现哈希冲突,则使用相同步长向后线性遍历,直到找到对应元素,返回 value 即可;如果遇到空桶,说明目标元素不在哈希表中,返回 None 。

下图展示了开放寻址(线性探测)哈希表的键值对分布。根据此哈希函数,最后两位相同的 key 都会被映射到相同的桶。而通过线性探测,它们被依次存储在该桶以及之下的桶中。

然而,线性探测容易产生“聚集现象”。具体来说,数组中连续被占用的位置越长,这些连续位置发生哈希冲突的可能性越大,从而进一步促使该位置的聚堆生长,形成恶性循环,最终导致增删查改操作效率劣化。

值得注意的是,我们不能在开放寻址哈希表中直接删除元素。这是因为删除元素会在数组内产生一个空桶 None ,而当查询元素时,线性探测到该空桶就会返回,因此在该空桶之下的元素都无法再被访问到,程序可能误判这些元素不存在。

为了解决该问题,我们可以采用懒删除(lazy deletion)机制:它不直接从哈希表中移除元素,而是利用一个常量 TOMBSTONE 来标记这个桶。在该机制下,None 和 TOMBSTONE 都代表空桶,都可以放置键值对。但不同的是,线性探测到 TOMBSTONE 时应该继续遍历,因为其之下可能还存在键值对。

然而,懒删除可能会加速哈希表的性能退化。这是因为每次删除操作都会产生一个删除标记,随着 TOMBSTONE 的增加,搜索时间也会增加,因为线性探测可能需要跳过多个 TOMBSTONE 才能找到目标元素。

为此,考虑在线性探测中记录遇到的首个 TOMBSTONE 的索引,并将搜索到的目标元素与该 TOMBSTONE 交换位置。这样做的好处是当每次查询或添加元素时,元素会被移动至距离理想位置(探测起始点)更近的桶,从而优化查询效率。

以下代码实现了一个包含懒删除的开放寻址(线性探测)哈希表。为了更加充分地使用哈希表的空间,我们将哈希表看作一个“环形数组”,当越过数组尾部时,回到头部继续遍历。

/* 开放寻址哈希表 */
class HashMapOpenAddressing {private:int size;                             // 键值对数量int capacity = 4;                     // 哈希表容量const double loadThres = 2.0 / 3.0;     // 触发扩容的负载因子阈值const int extendRatio = 2;            // 扩容倍数vector<Pair *> buckets;               // 桶数组Pair *TOMBSTONE = new Pair(-1, "-1"); // 删除标记public:/* 构造方法 */HashMapOpenAddressing() : size(0), buckets(capacity, nullptr) {}/* 析构方法 */~HashMapOpenAddressing() {for (Pair *pair : buckets) {if (pair != nullptr && pair != TOMBSTONE) {delete pair;}}delete TOMBSTONE;}/* 哈希函数 */int hashFunc(int key) {return key % capacity;}/* 负载因子 */double loadFactor() {return (double)size / capacity;}/* 搜索 key 对应的桶索引 */int findBucket(int key) {int index = hashFunc(key);int firstTombstone = -1;// 线性探测,当遇到空桶时跳出while (buckets[index] != nullptr) {// 若遇到 key ,返回对应桶索引if (buckets[index]->key == key) {// 若之前遇到了删除标记,则将键值对移动至该索引if (firstTombstone != -1) {buckets[firstTombstone] = buckets[index];buckets[index] = TOMBSTONE;return firstTombstone; // 返回移动后的桶索引}return index; // 返回桶索引}// 记录遇到的首个删除标记if (firstTombstone == -1 && buckets[index] == TOMBSTONE) {firstTombstone = index;}// 计算桶索引,越过尾部返回头部index = (index + 1) % capacity;}// 若 key 不存在,则返回添加点的索引return firstTombstone == -1 ? index : firstTombstone;}/* 查询操作 */string get(int key) {// 搜索 key 对应的桶索引int index = findBucket(key);// 若找到键值对,则返回对应 valif (buckets[index] != nullptr && buckets[index] != TOMBSTONE) {return buckets[index]->val;}// 若键值对不存在,则返回空字符串return "";}/* 添加操作 */void put(int key, string val) {// 当负载因子超过阈值时,执行扩容if (loadFactor() > loadThres) {extend();}// 搜索 key 对应的桶索引int index = findBucket(key);// 若找到键值对,则覆盖 val 并返回if (buckets[index] != nullptr && buckets[index] != TOMBSTONE) {buckets[index]->val = val;return;}// 若键值对不存在,则添加该键值对buckets[index] = new Pair(key, val);size++;}/* 删除操作 */void remove(int key) {// 搜索 key 对应的桶索引int index = findBucket(key);// 若找到键值对,则用删除标记覆盖它if (buckets[index] != nullptr && buckets[index] != TOMBSTONE) {delete buckets[index];buckets[index] = TOMBSTONE;size--;}}/* 扩容哈希表 */void extend() {// 暂存原哈希表vector<Pair *> bucketsTmp = buckets;// 初始化扩容后的新哈希表capacity *= extendRatio;buckets = vector<Pair *>(capacity, nullptr);size = 0;// 将键值对从原哈希表搬运至新哈希表for (Pair *pair : bucketsTmp) {if (pair != nullptr && pair != TOMBSTONE) {put(pair->key, pair->val);delete pair;}}}/* 打印哈希表 */void print() {for (Pair *pair : buckets) {if (pair == nullptr) {cout << "nullptr" << endl;} else if (pair == TOMBSTONE) {cout << "TOMBSTONE" << endl;} else {cout << pair->key << " -> " << pair->val << endl;}}}
};

11.2.2 平方探测

平方探测与线性探测类似,都是开放寻址的常见策略之一。当发生冲突时,平方探测不是简单地跳过一个固定的步数,而是跳过“探测次数的平方”的步数,即 1,4,9,… 步。

平方探测主要具有以下优势。

  • 平方探测通过跳过平方的距离,试图缓解线性探测的聚集效应。
  • 平方探测会跳过更大的距离来寻找空位置,有助于数据分布得更加均匀。

然而,平方探测也并不是完美的。

  • 仍然存在聚集现象,即某些位置比其他位置更容易被占用。
  • 由于平方的增长,平方探测可能不会探测整个哈希表,这意味着即使哈希表中有空桶,平方探测也可能无法访问到它。

11.2.3 多次哈希

顾名思义,多次哈希方法使用多个哈希函数 f1(x)、f2(x)、f3(x)、… 进行探测。

  • 插入元素:若哈希函数 f1(x) 出现冲突,则尝试 f2(x) ,以此类推,直到找到空桶后插入元素。
  • 查找元素:在相同的哈希函数顺序下进行查找,直到找到目标元素时返回;若遇到空桶或已尝试所有哈希函数,说明哈希表中不存在该元素,则返回 None 。

与线性探测相比,多次哈希方法不易产生聚集,但多个哈希函数会带来额外的计算量。

11.3 编程语言的选择 

各个编程语言采取了不同的哈希表实现策略,以下举几个例子。

  • Python 采用开放寻址。字典 dict 使用伪随机数进行探测。
  • Java 采用链式地址。自 JDK 1.8 以来,当 HashMap 内数组长度达到 64 且链表长度达到 8 时,链表会转换为红黑树以提升查找性能。
  • Go 采用链式地址。Go 规定每个桶最多存储 8 个键值对,超出容量则连接一个溢出桶。当溢出桶过多时,会执行一次特殊的等量扩容操作,以确保性能。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.rhkb.cn/news/209917.html

如若内容造成侵权/违法违规/事实不符,请联系长河编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

机器学习的复习笔记3-回归的细谈

一、回归的细分 机器学习中的回归问题是一种用于预测连续型输出变量的任务。回归问题的类型和特点如下&#xff1a; 线性回归&#xff08;Linear Regression&#xff09;&#xff1a;线性回归是回归问题中最简单的一种方法。它假设自变量与因变量之间存在线性关系&#xff0c…

【Unity动画】状态机添加参数控制动画切换(Animator Controller)

Unity - 手册&#xff1a;动画参数 在Unity中&#xff0c;动画状态的切换是通过Animator Controller中的过渡&#xff08;Transition&#xff09;来实现的。过渡是状态之间的连接&#xff0c;控制过渡一般都是靠调用代码参数 我们来实现一个案例&#xff1a; 创建动画状态机&a…

vscode中使用luaide-lite插件断点调试cocos2dx-lua

使用quick-cocos2dx-lua&#xff0c;用了众多插件&#xff0c;包括免费的BabeLua,VS调试太慢&#xff0c;vscode上的免费的EmmyLua, 还有收费的luaide&#xff0c;都没搞出来&#xff0c;唯独这个免费luaide-lite用成功了&#xff0c;步骤也简单&#xff0c;可以断点调试&#…

数据结构第六课 -----链式二叉树的实现

作者前言 &#x1f382; ✨✨✨✨✨✨&#x1f367;&#x1f367;&#x1f367;&#x1f367;&#x1f367;&#x1f367;&#x1f367;&#x1f382; ​&#x1f382; 作者介绍&#xff1a; &#x1f382;&#x1f382; &#x1f382; &#x1f389;&#x1f389;&#x1f389…

Java SpringBoot Controller常见写法

文章目录 环境Controller调用脚本运行结果总结 环境 系统: windows 11 工具: java, idea, git bash Controller 接口常见有以下几种方式 其中&#xff1a; Tobj 调用脚本 我的是windows 系统&#xff0c;使用 git bash 窗口运行, 用 cmd 或者 power shell 会有问题 curl …

C盘分析文件大小的软件

https://sourceforge.net/projects/windirstat/ 上面是windirstat的下载链接 界面是这样的&#xff1a; 选择C盘或者D盘&#xff0c;点击OK&#xff0c;就可以分析了 然后就可以看到哪些占比最高&#xff0c;可以针对性的清理

C#网络编程UDP程序设计(UdpClient类)

目录 一、UdpClient类 二、示例 1.源码 &#xff08;1&#xff09;Client &#xff08;2&#xff09;Server 2.生成 &#xff08;1&#xff09;先启动服务器&#xff0c;发送广播信息 &#xff08;2&#xff09;再开启客户端接听 UDP是user datagram protocol的简称&a…

整数的立方和

系列文章目录 进阶的卡莎C++_睡觉觉觉得的博客-CSDN博客数1的个数_睡觉觉觉得的博客-CSDN博客双精度浮点数的输入输出_睡觉觉觉得的博客-CSDN博客足球联赛积分_睡觉觉觉得的博客-CSDN博客大减价(一级)_睡觉觉觉得的博客-CSDN博客小写字母的判断_睡觉觉觉得的博客-CSDN博客纸币(…

bad_python

攻防世界 (xctf.org.cn) 前戏 下载文件&#xff0c;解压完成后是这个 一个pyc文件 这里要用到python的反编译 要用到的工具有两个 1.python自带的uncompyle6 2.pycdc文件——比uncompyle6强大一点 我们一个一个来尝试一下 uncompyle6&#xff1a; 我是直接在pycharm里面…

uniapp在H5端实现PDF和视频的上传、预览、下载

上传 上传页面 <u-form-item :label"(form.ququ3 1 ? 参培 : form.ququ3 2 ? 授课 : ) 证明材料" prop"ququ6" required><u-button click"upload" slot"right" type"primary" icon"arrow-upward" t…

设计模式-结构型模式之代理设计模式

文章目录 八、代理设计模式 八、代理设计模式 代理设计模式通过代理控制对象的访问&#xff0c;可以详细访问某个对象的方法&#xff0c;在这个方法调用处理&#xff0c;或调用后处理。既(AOP微实现) 。 代理有分静态代理和动态代理&#xff1a; 静态代理&#xff1a;在程序…

集成开发环境PyCharm的使用【侯小啾python基础领航计划 系列(三)】

集成开发环境 PyCharm 的使用【侯小啾python基础领航计划 系列(三)】 大家好,我是博主侯小啾, 🌹꧔ꦿ🌹꧔ꦿ🌹꧔ꦿ🌹꧔ꦿ🌹꧔ꦿ🌹꧔ꦿ🌹꧔ꦿ🌹꧔ꦿ🌹꧔ꦿ🌹꧔ꦿ🌹꧔ꦿ🌹꧔ꦿ🌹꧔ꦿ🌹꧔ꦿ🌹꧔ꦿ🌹꧔ꦿ🌹꧔ꦿ🌹꧔ꦿ🌹꧔ꦿ🌹꧔ꦿ🌹…

QT 中 QProgressDialog 进度条窗口 备查

基础API //两个构造函数 QProgressDialog::QProgressDialog(QWidget *parent nullptr, Qt::WindowFlags f Qt::WindowFlags());QProgressDialog::QProgressDialog(const QString &labelText, const QString &cancelButtonText, int minimum, int maximum, QWidget *…

BFS求树的宽度——结合数组建树思想算距离

二叉树最大宽度 https://leetcode.cn/problems/maximum-width-of-binary-tree/description/ 1、考虑树的宽度一定是在一层上的所以进行BFS&#xff0c;树的BFS不建议直接使用队列&#xff0c;每次add/offer然后poll/remove&#xff0c;这样子层级关系不好显示。我们可以定义…

java连接池 理解及解释(DBCP、druid、c3p0、HikariCP)

一、在Java开发中&#xff0c;有许多常见的数据库连接池可供选择。以下是一些常见的Java数据库连接池&#xff1a;不使用数据库连接池的特性&#xff1a; 优点&#xff1a;实现简单 缺点&#xff1a;网络 IO 较多数据库的负载较高响应时间较长及 QPS 较低应用频繁的创建连接和关…

深入理解JVM虚拟机第二十七篇:详解JVM当中InvokeDynamic字节码指令,Java是动态类型语言么?

😉😉 学习交流群: ✅✅1:这是孙哥suns给大家的福利! ✨✨2:我们免费分享Netty、Dubbo、k8s、Mybatis、Spring...应用和源码级别的视频资料 🥭🥭3:QQ群:583783824 📚📚 工作微信:BigTreeJava 拉你进微信群,免费领取! 🍎🍎4:本文章内容出自上述:Sp…

3D模型渲染导致电脑太卡怎么办?

在线工具推荐&#xff1a; 三维数字孪生场景工具 - GLTF/GLB在线编辑器 - Three.js AI自动纹理化开发 - YOLO 虚幻合成数据生成器 - 3D模型在线转换 - 3D模型预览图生成服务 1、什么是3D渲染&#xff1f; 3D渲染是指通过计算机图形学技术将三维模型转化为二维图像的过程…

Stable Diffusion AI绘画系列【12】:国风美女剑客系列

《博主简介》 小伙伴们好&#xff0c;我是阿旭。专注于人工智能、AIGC、python、计算机视觉相关分享研究。 ✌更多学习资源&#xff0c;可关注公-仲-hao:【阿旭算法与机器学习】&#xff0c;共同学习交流~ &#x1f44d;感谢小伙伴们点赞、关注&#xff01; 《------往期经典推…

FLASK博客系列6——数据库之谜

我们上一篇已经实现了简易博客界面&#xff0c;你还记得我们的博客数据是自己手动写的吗&#xff1f;但实际应用中&#xff0c;我们是不可能这样做的。大部分程序都需要保存数据&#xff0c;所以不可避免要使用数据库。我们这里为了简单方便快捷&#xff0c;使用了超级经典的SQ…

具有五层协议的网络体系结构

目录 一、计算机的网络体系结构 二、五层协议的体系结构 1、物理层 2、数据链路层 3、网络层 4、传输层 5、应用层 三、数据在各层之间传输的过程 一、计算机的网络体系结构 二、五层协议的体系结构 1、物理层 利用传输介质为通信的网络结点之间建立、管理和释放物理连…