C++ - 开散列的拉链法(哈希桶) 介绍 和 实现

前言

 之前我们介绍了,闭散列 的 开放地址法实现的 哈希表:C++ - 开放地址法的哈希介绍 - 哈希表的仿函数例子_chihiro1122的博客-CSDN博客

 但是 闭散列 的 开放地址法 虽然是哈希表实现的一种,但是这种方式实现的哈希表,有一个很大的弊端,就是可能会引起一大片的哈希冲突,因为当发生哈希冲突的时候,他是按照线性探测的方式去找新的位置的,那么在冲突的位置之后,可能有一大片都是有数据存在的,那么每一次寻找都会发生哈希冲突。

而且使用 开放地址法 实现的哈希表,在查找数据的时候,计算出的起始位置不是要查找的数据的时候,也是按照上述哈希冲突的方式去寻找数据的,那么这样的效率就更低了。

更不用想,当 这种哈希表需要扩容的时候,还是类似想 realloc 函数一样,先开一个更大空间,然后再把之前的值都赋值进去之后,再计算新插入的数的初始位置,然后按照上述的方式进行插入。

 这种在表当中相邻位置元素比较多的情况,使用开放地址法就会发生拥堵的情况。把本来没有发生哈希冲突的值,都加到 冲突当中了。

二次探测哈希表介绍

 在上述线性探测的基础之上,引出了二次探测,如果说线性探测是按照 每次 起始位置 + i (i >= 0,i 往后迭代) 这样的方式实现 一次往后遍历,那么 二次探测就是  起始位置 + i^2 (i>=0) 这样的方式进行探测,遮掩的话就缓解了一些 拥堵情况。但是,这并不是最优解,他的本质上还是 一种线性探测,只不过把 冲突的值 分开了一些而已。

两者的本质都是在块共用的空间当中,进行存储值,这样不管你怎么进行探测,当数据量比较密集之后,很难做到 不发生 一大片的拥堵情况。

 在发生哈希冲突之后,只是无脑的往后寻找空位置插入,那么这个位置本来应该插入的元素就被占了,归根结底还是占了别人的空间,而且这种情况随着冲突的变多之后,会发生得更多。

开放地址法的缺点:冲突会互相影响。 

 所以,此时就有人想了,开放地址法都是在一个空间当中寻找可以插入的地址,那么能不能走出这块空间呢?不要让自己冲突之后,去占用别人的位置,来诱发哈希冲突

 开散列的拉链法(哈希桶)

 如果知道计数排序的小伙伴应该很好理解,这里的哈希桶和计数排序当中有点类似。

如果他的规则是 hash = key % mol这个哈希函数的话。那么他会先开一个 mol 大小的指针数组,结点也不会像之前一样用一个 数组来存储,而且是把一个元素用一个结点存储,把 hash 值相同的元素用类似链表的方式链接在 对应 指针数组下标上元素指针来指向,如下图所示:

 大体框架

	template<class K, class V>struct HashNode{pair<K, V> _kv;hashNode* next;  // 每个结点都有一个指针指向这个结点的下一个结点};template<class K, class V>class Hash{typedef HashNode<K, V> Node;public:Hash(){_table.resize(10, nullptr);}private:vector<Node*> _table;  // 指针数组int _n = 0;   // 用于记录哈希桶当中的有效数据个数};

 指针数组我们使用 vector 的容器来实现,这样就方便我们对这个数组进行管理。每个结点要存储的是 本节点的 key 和 value 以及 指向下一个 结点的指针。

insert()插入函数

 同样要先按照key值大小计算出 这个结点的 hash 值,然后再 对应数组下标位置进行链表的 头插或者尾查,两种插入都行。因为,后头插的元素可能会被先找到,但是我们不知道哪一个元素被查找的次数多,所以用哪一种都行。

核心插入逻辑:
 

			// 计算hash值size_t hash = kv.first % _table.size();Node* newnode = new Node(kv);newnode->_next = _table[hash];_table[hash] = newnode;++_n;return true;

当然,因为哈希桶也是一种 开放地址法,那么只要是开放地址法都是需要扩容的,在哈希桶当中就是要扩容 指针数组。因为当数据很多的时候,就会导致 每一个 指针数组元素下面的链表会很长,那么对于查找时候的效率就会降低

 我们应当控制 负载因子 尽量到 1 ,也就是让每个桶当中都尽量存储一个 数据,这样的话,查找的效率就保持在一个很高的 标准。

 而且扩容还有一个好处,我们发现一种极端情况,就会有大量的数据集中在一个桶当中,这种情况虽然概率小,但是还是有可能发生的,那么当我扩容的时候,每一个桶当中的数据都会得到分散,这样可以缓解一个桶的压力

 库容的思路不能再使用 闭散列开发地址法当中,在把 旧表当中的数据插入到 新表当中,直接复用 insert()函数,这样不好

 以为按照我们上述写 insert 的逻辑,如复用的话,每一次复用,都需要重新使用 new Node(kv) 开辟一个新结点出来,而且还有把旧表当中的该结点给释放了,但是,在旧表当中的结点空间是完全可以复用的

所以,我们在依次变量的时候就 直接依次遍历链表和其中的结点,把每个结点直接挪到 新表的 指针数组后面,按照新表当中的 插入规则插入即可。 

 扩容代码:
 

// 负载因子 到 1 就扩容(每一个桶当中都有数据)if (_n == _table.size()){size_t newsize = _table.size() * 2;vector<Node*> newTable;newTable.resize(newsize, nullptr);for (int i = 0; i < _table.size(); i++){Node* cur = _table[i];while (cur){Node* next = cur->_next;  // 保存链表的下一个结点// 头插到新表当中size_t hashi = kv.first % newTable.size();cur->_next = newTable[hashi];newTable[hashi] = cur;// 向链表后迭代cur = next;}}// 交换 两个表在 对象当中的指向,让编译器 帮我们释放旧表的空间_table.swap(newTable); }

 上述使用现代写法,直接交换指针,出了insert()作用域,让编译器把旧表空间释放掉,但是释放的 vector 只是释放 vector 容器的空间,并不会释放 Node* 结点空间。vector 的销毁是调用 delete[] ,而 delete[] 分为两种,先要调用容器中与元素的析构函数,遍历析构各个元素,但是这个容器当中的 每一个元素的类型是一个指针,是一个内置类型,内置类型没有析构函数。所以不会调用析构函数,直接进行第二部,直接对 vector 空间释放。

 只有 类似 vector<list> _table 这种结构,那么vector当中的每一个元素存储的都是一个 list 自定义类型,那么都是有析构函数的,那么我们上述 扩容逻辑使用的是 这种结构的话,就不能复用 结点了, delete[] vector 就会先遍历调用其中每个元素的析构函数来进行析构

哈希表当中的扩容所带来的消耗是无法避免的,但是其实哈希表扩容次数并不多,按照我们上述书写的逻辑,一次扩容两倍的话,2^20 大概 100w,也就是说插入100w 个数据 只用扩容 20次,已经非常的少了。

但是,虽然哈希表的扩容优化不好写,但是还是有人提出:
 

 比如上述,他是用两个表,一个是当前的表,一个是扩容之后的表,当 我们想要 没有桶的位置(比如指针数组下标1位置)插入一个数据的时候,就直接把这个桶移到 扩容的 指针数组当中的对应位置。他用这样的方式,相当于是把 一次扩容的消耗,分散到了每一次插入数据当中,那么在用户体验来说,就大大增强了

 但是这种方法实现起来非常麻烦,其中还有很多细节,我们就是用之前实现的扩容逻辑就已经很好用了。

写一个 print()函数方便查看 哈希桶当中的数据:
 

		void Print(){for (size_t i = 0; i < _table.size(); i++){printf("[%d]->", i);Node* cur = _table[i];while (cur){cout << cur->_kv.first << "->";cur = cur->_next;}printf("NULL\n");}}

insert()代码:

		bool insert(const pair<K , V>& kv){if (find(kv.first)){return false;}// 负载因子 到 1 就扩容(每一个桶当中都有数据)if (_n == _table.size()){size_t newsize = _table.size() * 2;vector<Node*> newTable;newTable.resize(newsize, nullptr);for (int i = 0; i < _table.size(); i++){Node* cur = _table[i];while (cur){Node* next = cur->_next;  // 保存链表的下一个结点// 头插到新表当中size_t hashi = cur->_kv.first % newTable.size();cur->_next = newTable[hashi];newTable[hashi] = cur;// 向链表后迭代cur = next;}}// 交换 两个表在 对象当中的指向,让编译器 帮我们释放旧表的空间_table.swap(newTable); }// 计算hash值size_t hashi = kv.first % _table.size();Node* newnode = new Node(kv);newnode->_next = _table[hashi];_table[hashi] = newnode;++_n;return true;}

find()函数

 哈希桶的find()查找函数,先利用哈希函数计算链表起始位置,然后就是一个链表的遍历查找。

		Node* find(const K& key){size_t hash = key % _table.size();Node* cur = _table[hash];while (cur){if (cur->_kv.first == key)return cur;cur = cur->_next;}return nullptr;}

erase()函数

 删除结点函数,在寻找结点这一块,我们不复用 find()函数,因为find()函数返回的是该结点指针,但是我们删除链表当中的结点需要修改链接关系,需要找到该结点的上一个结点,或者是指针数组当中元素,所以我们可以字节写查找逻辑:
 

		bool erase(const K& key){size_t hashi = key % _table.size();Node* cur = _table[hashi];Node* prev = nullptr;while (cur){if (cur->_kv.first == key){if (!prev){_table[hashi] = cur->_next;}else{prev->_next = cur->_next;}delete cur;return true;}prev = cur;cur = cur->_next;}return false;}

析构函数

 因为,指针数组当中存储的是一个 Node* 是一个指针,也就是一个内置类型,在释放的时候编译器对内置类型不处理,但是对自定义类型会去调用这个自定义类型的析构函数

所以,因为 _table 的类型是 vector<Node*> ,vector 当中的数组空间是属于 vector 自定义类型的空间,那么回去调用vector 的析构函数,但是对于 Node* 就不会了,所以我们要写析构函数把 哈希桶,也就是每一个 指针数组元素指向的链表的空间给释放了。

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

把 哈希当中的key 值转换成适用多多种类型的仿函数写法

 当然,哈希表当中不能可能只存储 int 这一种类型,如果是 string类,也是有自己的比较方式的,所以,我们可以像 在优先级队列当中,实现多种类型的适配仿函数,利用模版参数的控制来控制key 值的取出方法:

template<class K>
struct DefaultHashFunc
{size_t operator()(const K& key){// 不管是什么类型的,都转换成 size_t// 不管是 负数还是正数,都转换为 正数return (size_t)key;}
};// string 的特化
template<>
struct DefaultHashFunc<string>
{size_t operator()(const string& str){// 字符串转 int 的算法int hash = 0;for (auto& ch : str){hash *= 131;hash += ch;}return hash;}
};template<class K, class V, class HashFunc = DefaultHashFunc<K>>class hash{typedef HashNode<K, V> Node;public:
····································
····································
····································bool insert(const pair<K , V>& kv){HashFunc hf;if (find(kv.first)){return false;}// 负载因子 到 1 就扩容(每一个桶当中都有数据)if (_n == _table.size()){size_t newsize = _table.size() * 2;vector<Node*> newTable;newTable.resize(newsize, nullptr);for (int i = 0; i < _table.size(); i++){Node* cur = _table[i];while (cur){Node* next = cur->_next;  // 保存链表的下一个结点// 头插到新表当中size_t hashi = hf(cur->_kv.first) % newTable.size();cur->_next = newTable[hashi];newTable[hashi] = cur;// 向链表后迭代cur = next;}}// 交换 两个表在 对象当中的指向,让编译器 帮我们释放旧表的空间_table.swap(newTable); }// 计算hash值size_t hashi = hf(kv.first) % _table.size();Node* newnode = new Node(kv);newnode->_next = _table[hashi];_table[hashi] = newnode;++_n;return true;}

 把各个函数需要取出key 值的方法都用仿函数封装。

 具体的实现逻辑,可以看看下面博客当中对 闭散列开放式地址法哈希表当中对 仿函数的书写:

C++ - 开放地址法的哈希介绍 - 哈希表的仿函数例子_chihiro1122的博客-CSDN博客

 哈希桶的完整代码:

namespace hash_bucket
{template<class T>struct HashNode{T _data;HashNode* _next;  // 每个结点都有一个指针指向这个结点的下一个结点HashNode(const T& data):_data(data),_next(nullptr){}};template<class K, class T,class KeyOfT , class HashFunc = DefaultHashFunc<K>>class hash{typedef HashNode<T> Node;public:hash(){_table.resize(10, nullptr);}bool Insert(const T& data){HashFunc hf;KeyOfT kot;if (find(data)){return false;}// 负载因子 到 1 就扩容(每一个桶当中都有数据)if (_n == _table.size()){size_t newsize = _table.size() * 2;vector<Node*> newTable;newTable.resize(newsize, nullptr);for (int i = 0; i < _table.size(); i++){Node* cur = _table[i];while (cur){Node* next = cur->_next;  // 保存链表的下一个结点// 头插到新表当中size_t hashi = hf(kot(cur->_data)) % newTable.size();cur->_next = newTable[hashi];newTable[hashi] = cur;// 向链表后迭代cur = next;}}// 交换 两个表在 对象当中的指向,让编译器 帮我们释放旧表的空间_table.swap(newTable); }// 计算hash值size_t hashi = hf(data) % _table.size();Node* newnode = new Node(data);newnode->_next = _table[hashi];_table[hashi] = newnode;++_n;return true;}Node* find(const K& key){HashFunc hf;KeyOfT kot;size_t hash = hf(kot(key)) % _table.size();Node* cur = _table[hash];while (cur){if (kot(cur->_data) == key)return cur;cur = cur->_next;}return nullptr;}bool erase(const K& key){HashFunc hf;KeyOfT kot;size_t hashi = hf(kot(key)) % _table.size();Node* cur = _table[hashi];Node* prev = nullptr;while (cur){if (hf(kot(cur->_data)) == key){if (!prev){_table[hashi] = cur->_next;}else{prev->_next = cur->_next;}delete cur;return true;}prev = cur;cur = cur->_next;}return false;}void Print(){for (size_t i = 0; i < _table.size(); i++){printf("[%zd]->", i);Node* cur = _table[i];while (cur){cout << kot(cur) << "->";cur = cur->_next;}printf("NULL\n");}cout << endl;cout << endl;}~hash(){for (size_t i = 0; i < _table.size(); i++){Node* cur = _table[i];while (cur){Node* next = cur->_next;delete cur;cur = next;}_table[i] = nullptr;}}private:vector<Node*> _table;  // 指针数组int _n = 0;   // 用于记录哈希桶当中的有效数据个数};
}

 结言

 上述实现的 哈希桶,还有很多的封装工作没有做,因为上述的哈希桶准备用来实现 unordered_set 和  unordered_map 的底层实现,所以关于 key 值不能修改,或者是迭代器在  unordered_set 和  unordered_map 的 封装博客的当中还要对 哈希桶进行 改进。

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

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

相关文章

【操作系统】了解Linux操作系统中PCB进程管理模块与进程PID

本篇要分享的内容是有关于操作系统中进程的内容。 目录 1.进程的简单理解 2.了解task_struct&#xff08;进程控制模块&#xff09;内容分类 3.task_struct&#xff08;进程控制模块&#xff09;中的PID 4.调用查看PID的函数 1.进程的简单理解 首先我们需要理解的是什么是…

C++指针的使用

文章目录 1.C指针1.1 定义指针1.2 使用指针 2.空指针和野指针2.1 空指针2.2 野指针 3.指针所占空间4.使用const修饰指针4.1 const修饰指针4.2 const修饰常量4.3 const 既修饰指针也修饰常量 5.指针操作数组6.指针做函数参数7.使用指针知识实现冒泡排序 1.C指针 指针其实就是一…

【python数据建模】Numpy库

数组 创建 import numpy as np # np.array() 生成元素同类型的数组 a1np.array([1,2,3,4]) # 整型 a2np.array([1.0,2,3,4]) # 浮点型 a3np.array([1,2,3,4],dtypefloat) a4np.array([1,2,3,4]) # 字符串# np.astype() 数值类型转换 aa4.astype(int) print(a.dtype)# np.aran…

SpringBoot整合数据库连接

JDBC 1、数据库驱动 JDBC&#xff08;Java DataBase Connectivity&#xff09;&#xff0c;即Java数据库连接。简而言之&#xff0c;就是通过Java语言来操作数据库。 JDBC是sun公司提供一套用于数据库操作的接口. java程序员只需要面向这套接口编程即可。不同的数据库厂商&…

【C++】特殊类设计

文章目录 一、请设计一个类&#xff0c;只能在堆上创建对象二、请设计一个类&#xff0c;只能在栈上创建对象三、请设计一个类&#xff0c;不能被拷贝四、请设计一个类&#xff0c;不能被继承 一、请设计一个类&#xff0c;只能在堆上创建对象 实现方式 将类的构造函数私有&a…

Ubuntu配置深度学习环境(TensorFlow和pyTorch)

文章目录 一、CUDA安装1.1 安装显卡驱动1.2 CUDA安装1.3 安装cuDNN 二、Anaconda安装三、安装TensorFlow和pyTorch3.1 安装pyTorch3.2 安装TensorFlow2 四、安装pyCharm4.1 pyCharm的安装4.2 关联anaconda的Python解释器 五、VScode配置anaconda的Python虚拟环境 前言&#xff…

本地部署 川虎 Chat

本地部署 川虎 Chat 1. 川虎 Chat 项目概述2. Github 地址3. 部署 川虎 Chat4. 配置 config.json5. 启动 川虎 Chat 1. 川虎 Chat 项目概述 为ChatGPT等多种LLM提供了一个轻快好用的Web图形界面和众多附加功能。 支持 GPT-4 基于文件问答 LLM本地部署 联网搜索 Agent 助理…

计算机竞赛 深度学习手势识别 - yolo python opencv cnn 机器视觉

文章目录 0 前言1 课题背景2 卷积神经网络2.1卷积层2.2 池化层2.3 激活函数2.4 全连接层2.5 使用tensorflow中keras模块实现卷积神经网络 3 YOLOV53.1 网络架构图3.2 输入端3.3 基准网络3.4 Neck网络3.5 Head输出层 4 数据集准备4.1 数据标注简介4.2 数据保存 5 模型训练5.1 修…

数据结构:复杂度分析

目录 1 算法效率评估 1.1 实际测试 1.2 理论估算 2 迭代与递归 2.1 迭代 1. for 循环 2. while 循环 3. 嵌套循环 2.2 递归 1. 调用栈 2. 尾递归 3. 递归树 2.3 两者对比 3 时间复杂度 3.1 统计时间增长趋势 3.2 函数渐近上界…

MySQL学习笔记26

MySQL主从复制的搭建&#xff08;AB复制&#xff09; 传统AB复制架构&#xff08;M-S)&#xff1a; 说明&#xff1a;在配置MySQL主从架构时&#xff0c;必须保证数据库的版本高度一致&#xff0c;统一版本为5.7.31 环境规划&#xff1a; 编号主机名称主机IP地址角色信息1ma…

460. LFU 缓存

请你为 最不经常使用&#xff08;LFU&#xff09;缓存算法设计并实现数据结构。 实现 LFUCache 类&#xff1a; LFUCache(int capacity) - 用数据结构的容量 capacity 初始化对象int get(int key) - 如果键 key 存在于缓存中&#xff0c;则获取键的值&#xff0c;否则返回 -1…

盛最多水的容器 接雨水【基础算法精讲 02】

盛雨水最多的容器 链接 : 11 盛最多水的容器 思路 : 双指针 &#xff1a; 1.对于两条确定的边界&#xff0c;l和r,取中间的线m与r组成容器&#xff0c;如果m的高度>l的高度&#xff0c;那么整个容器的长度会减小&#xff0c;如果低于l的高度&#xff0c;那么不仅高度可…

前端与后端:程序中两个不同的领域

前端和后端是构成一个完整的计算机应用系统的两个主要部分。它们分别负责不同的功能和任务&#xff0c;有以下几个方面的区别&#xff1a; 功能&#xff1a;前端主要负责用户界面的呈现和交互&#xff0c;包括网页的设计、布局、样式、动画效果和用户输入等。后端则处理网站或应…

Flink安装及简单使用

目录 转载处&#xff08;个人用最新1.17.1测试&#xff09; 依赖环境 安装包下载地址 Flink本地模式搭建 安装 启动集群 查看WebUI 停止集群 Flink Standalone搭建 安装 修改flink-conf.yaml配置文件 修改workers文件 复制Flink安装文件到其他服务器 启动集群 查…

cesium 热力图(CesiumHeatmap)

cesium 热力图 可添加、删除、显示、隐藏 完整代码 <!DOCTYPE html> <html lang="en"><head><meta charset="utf-8">

vue3组合式api的函数系列一

1、响应式核心 1&#xff09;、 ref(值) 1&#xff09;、功能&#xff1a;接受值&#xff0c;返回一个响应式的、可更改的 ref 对象&#xff0c;ref对象只有一个属性&#xff1a;value。value属性保存着接受的值。 2&#xff09;、使用ref对象&#xff1a;模板上不需要写 .v…

mac如何卸载应用并删除文件,2023年最新妙招大公开!

大家好&#xff0c;今天小编要为大家分享一些关于mac电脑的小技巧&#xff0c;特别是关于如何正确卸载应用程序以及清理卸载后的残留文件。你知道吗&#xff1f;很多人都不知道&#xff0c;mac系统默认的卸载方式可能会导致一些残留文件滞留在你的电脑上&#xff0c;慢慢地占用…

解决 ARouter 无法生成路由表,Toast提示 找不到目标路由

Android Studio 版本&#xff1a;2022.3.1 ARouter 版本&#xff1a;1.5.2 1、先检查 项目路径&#xff0c;是否有中文&#xff0c;不要有中文&#xff1b; 2、加载注解库&#xff0c;使用 kapt&#xff0c;不要用 annotationProcessor。 3、分模块开发&#xff0c;每个需要…

openGauss学习笔记-86 openGauss 数据库管理-内存优化表MOT管理-内存表特性-MOT部署配置

文章目录 openGauss学习笔记-86 openGauss 数据库管理-内存优化表MOT管理-内存表特性-MOT部署配置86.1 总体原则86.2 重做日志&#xff08;MOT&#xff09;86.3 检查点&#xff08;MOT&#xff09;86.4 恢复&#xff08;MOT&#xff09;86.5 统计&#xff08;MOT&#xff09;86…

进入IT行业:选择前端开发还是后端开发?

一、前言 开发做前端好还是后端好&#xff1f;这是一个常见的问题&#xff0c;特别是对于初学者来说。在编程世界中&#xff0c;前端开发和后端开发分别代表着用户界面和数据逻辑&#xff0c;就像城市的两个不同街区一样。但是&#xff0c;究竟哪个街区更适合我们作为开发者呢…