

🛸个人主页: dragoooon34
🚁所属专栏: C++
🚀操作环境: Visual Studio 2022

目录
[2-1 🍕哈希函数的设计原则](#2-1 🍕哈希函数的设计原则)
[2-2 🍔常见的哈希函数](#2-2 🍔常见的哈希函数)
[3-1 🥚负载因⼦](#3-1 🥚负载因⼦)
[3-2 🍟冲突的原因](#3-2 🍟冲突的原因)
[3-3 🍳开放定址法代码实现](#3-3 🍳开放定址法代码实现)
[3-3-1 🐕开放定址法的哈希表结构](#3-3-1 🐕开放定址法的哈希表结构)
[3-3-2 🐈⬛扩容](#3-3-2 🐈⬛扩容)
[3-3-3 🐆key不能取模的问题](#3-3-3 🐆key不能取模的问题)
[3-3-4 🦌完整代码实现](#3-3-4 🦌完整代码实现)
[四、😃unordered_set / unordered_map](#四、😃unordered_set / unordered_map)
[4-1 🍿使用](#4-1 🍿使用)
[4-2 🧂与 set/map 的区别](#4-2 🧂与 set/map 的区别)
[4-3 🥓性能对比](#4-3 🥓性能对比)
🌞前言
哈希(Hash)是一个广泛的概念,其中包括哈希表、哈希冲突、哈希函数等,核心为 元素(键值) 与 存储位置(哈希值) 之间的映射关系,哈希值 可以通过各种哈希函数进行计算,需要尽量确保 "唯一性",避免冲突,除此之外,哈希函数还可用于 区块链 中,计算 区块头(Head)中的信息,本文将带你认识哈希,学习其中的各种知识
🌜正文
一、😀哈希思想
哈希(Hash) 是一种 映射 思想,规定存在一个唯一的 哈希值 与 键值 之前建立 映射 关系,由这种思想而构成的数据结构称为 哈希表(散列表)
哈希表中的数据查找时间可以做到 O(1)
这是非常高效的,比 AVL树 还要快
哈希表 中 插入数据 和 查找数据 的步骤如下:
- 插入数据:根据当前待插入的元素的键值,计算出哈希值,并存入相应的位置中
- 查找数据:根据待查找元素的键值,计算出哈希值,判断对应的位置中存储的值是否与 键值 相等
比如在 数组 中利用哈希思想,构建哈希表,存储数据:5、49、27、38、55
假设此时 数组 的大小 capacity 为 8,哈希函数 计算哈希值:HashI = key % capacity
数据存储如下:
显然,这个哈希表并没有把所有位置都填满,数据分布无序且分散
因此,哈希表 又称为 散列表
二、😁哈希函数
元素对应的存储位置(哈希值)需要通过 哈希函数 进行计算,哈希函数 并非固定不变,可以根据需求自行设计
2-1 🍕哈希函数的设计原则
在进行 映射 时,要尽量确保 唯一性 ,尽量让每一个元素都有自己的 映射 位置,这样在查找时,才能快速定位 元素
哈希函数 的设计原则如下:
- 哈希函数的定义域必须包括需要存储的全部键值,且如果哈希表允许有 m 个地址,其值域为 [0, m-1]
- 哈希函数计算出来的哈希值能均匀分布在整个哈希表中
- 哈希函数应该尽可能简单、实用
哈希函数 的设计没必要动用太多数学高阶知识,要确保 实用性
2-2 🍔常见的哈希函数
哈希函数 的发展已经有很多年历史了,在前辈的实践之下,留下了这些常见的 哈希函数
1️⃣直接定址法(常用)
函数原型:HashI = A * key + B
- 优点:简单、均匀
- 缺点:需要提前知道键值的分布情况
- 适用场景:范围比较集中,每个数据分配一个唯一位置
2️⃣除留余数法(常用)
假设 哈希表 的大小为
m函数原型:HashI = key % p (p < m)
- 优点:简单易用,性能均衡
- 缺点:容易出现哈希冲突,需要借助特定方法解决
- 适用场景:范围不集中,分布分散的数据
3️⃣平方取中法(了解)
函数原型:HashI = mid(key * key)
- 适用场景:不知道键值的分布,而位数又不是很大的情况
假设键值为 1234,对其进行平方后得到 1522756,取其中间的三位数 227 作为 哈希值
4️⃣折叠法(了解)
折叠法是将键值从左到右分割成位数相等的几部分(最后一部分位数可以短些),然后将这几部分叠加求和,并按 哈希表 表长,取后几位作为散列地址
- 适用场景:事先不需要知道键值的分布,且键值位数比较多
假设键值为 85673113,分为三部分 856、731、13,求和:1600,根据表长(假设为 100),哈希值 就是 600
5️⃣随机数法(了解)
选择一个随机函数,取键值的随机函数值为它的 哈希值
函数原型:HashI = rand(key)
其中 rand 为随机数函数
- 适用场景:键值长度不等时
哈希函数 还有很多很多种,最终目的都是为了计算出重复度低的 哈希值
最常用的是 直接定址法 和 除留余数法
**注意:**哈希函数设计的越精妙,产生哈希冲突的可能性越低,但是无法避免哈希冲突。
三、😂哈希冲突
哈希冲突(哈希碰撞) 是面试中的常客,可以通过一个 哈希冲突 间接引出 哈希 中的其他知识点
3-1 🥚负载因⼦
假设哈希表中已经映射存储了N个值,哈希表的⼤⼩为M,那么 ,负载因⼦有些地⽅也翻译为载荷因⼦/装载因⼦等,他的英⽂为load factor。负载因⼦越⼤,哈希冲突的概率越⾼,空间利⽤率越⾼,增删查改的效率越低;负载因⼦越⼩,哈希冲突的概率越低,空间利⽤率越低,增删查改的效率越高;
3-2 🍟冲突的原因
哈希值 是 键值 通过 哈希函数 计算得出的 位置标识符,难以避免重复问题
比如在上面的 哈希表 中,存入元素 20,哈希值 HashI = 20 % 8 = 4 ,此时 4 位置中并没有元素,可以直接存入
但是如果继续插入元素 36,哈希值 HashI = 36 % 8 = 4 ,此时 4 位置处已经有元素了,无法继续存入,此时就发生了 哈希冲突
不同的 哈希函数 引发 哈希冲突 的概率不同,但最终都会面临 哈希冲突 这个问题,因此需要解决一些方法,解决哈希冲突
3-3🌭解决方法
主要的解决方法有两种:闭散列 与 开散列
闭散列(开放定址法)
例如,我们用除留余数法将序列**{1, 6, 10, 1000, 101, 18, 7, 40}**插入到表长为10的哈希表中,当发生哈希冲突时我们采用闭散列的线性探测找到下一个空位置进行插入,插入过程如下:
通过上图可以看到,随着哈希表中数据的增多,产生哈希冲突的可能性也随着增加,最后在40进行插入的时候更是连续出现了四次哈希冲突。
规定:当哈希表中存储的数据量 与 哈希表的容量 比值(负载因子)过大时,扩大哈希表的容量,并重新进行映射
因为有 负载因子 的存在,所以 哈希表是一定有剩余空间的
当发生 哈希冲突 时,从冲突位置向后探测,直到找到可用位置
例如,我们将哈希表的大小改为20,可以看到在插入相同序列时,产生的哈希冲突会有所减少:
像这种线性探测(暴力探测)可以解决 哈希冲突 问题,优点是实现非常简单,但会带来新的问题:踩踏
踩踏:元素的存储位置被别人占用了,于是也只能被迫线性探测,引发连锁反应,插入、查找都会越来越慢
哈希冲突 越多,效率 越低
优化方案:二次探测,每次向后探测 i ^ 2 步,尽量减少踩踏
例如,我们用除留余数法将序列**{1, 6, 10, 1000, 101, 18, 7, 40}**插入到表长为10的哈希表中,当发生哈希冲突时我们采用闭散列的二次探测找到下一个空位置进行插入,插入过程如下:
采用二次探测为产生哈希冲突的数据寻找下一个位置,相比线性探测而言,采用二次探测的哈希表中元素的分布会相对稀疏一些,不容易导致数据堆积。
和线性探测一样,采用二次探测也需要关注哈希表的负载因子,例如,采用二次探测将上述数据插入到表长为20的哈希表,产生冲突的次数也会有所减少:
尽管如此,闭散列 的实际效果 不尽人意 ,因为其本质上就是一个 零和游戏 ,实际中还是 开散列 用的更多一些
开散列(链地址法、开链法、哈希桶)
所谓 开散列 就在原 存储位置 处带上一个 单链表 ,如果发生 哈希冲突 ,就将 冲突的值依次挂载即可
因此也叫做 链地址法、开链法、哈希桶
开散列 中不需要 负载因子,如果每个位置都被存满了,直接扩容就好了,当然扩容后也需要重新建立映射关系
例如,我们用除留余数法将序列**{1, 6, 15, 60, 88, 7, 40, 5, 10}** 插入到表长为10的哈希表中,当发生哈希冲突时我们采用开散列的形式,将哈希地址相同的元素都链接到同一个哈希桶下,插入过程如下:
- 闭散列解决哈希冲突,采用的是一种报复的方式,"我的位置被占用了我就去占用其他位置"。而开散列解决哈希冲突,采用的是一种乐观的方式,"虽然我的位置被占用了,但是没关系,我可以'挂'在这个位置下面"。
- 与闭散列不同的是,这种将相同哈希地址的元素通过单链表链接起来,然后将链表的头结点存储在哈希表中的方式,不会影响与自己哈希地址不同的元素的增删查改的效率,因此开散列的负载因子相比闭散列而言,可以稍微大一点。
开散列 中进行查找时,需要先根据 哈希值 找到对应位置,并在 单链表 中进行遍历
一般情况下,单链表的长度不会太长的,因为扩容后,整体长度会降低
哈希桶的极端情况就是,所有元素全部产生冲突,最终都放到了同一个哈希桶中,此时该哈希表增删查改的效率就退化成了O(N):
如果 单链表 真的过长了(几十个节点),我们还可以将其转为 红黑树,此时效率依旧非常高
在这种情况下,就算有十亿个元素全部冲突到一个哈希桶中,我们也只需要在这个哈希桶中查找30次左右,这就是所谓的"桶里种树"。
图片出自:2021dragon
值得一提的是 哈希表(开散列法)最坏时间复杂度为
O(N),平均是O(1)哈希表(开散列法) 和 快排 一样很特殊,时间复杂度不看最坏的,看 平均时间复杂度 ,因为 最坏的情况几乎不可能出现
以上就是解决 哈希冲突 的两种方法,后面在模拟实现 哈希表 时会详细讲解
3-3 🍳开放定址法代码实现
开放定址法在实践中,不如链地址法,因为开放定址法解决冲突不管使⽤哪种⽅法,占⽤的都是哈希表中的空间,始终存在互相影响的问题。所以开放定址法,我们简单选择线性探测实现即可。
3-3-1 🐕开放定址法的哈希表结构
cppenum State { EXIST, // 已存储有效数据 EMPTY, // 空(从未存储过数据) DELETE // 已删除(曾存储数据,后被删除) }; // 哈希表节点结构:存储键值对 + 状态 template<class K, class V> struct HashData { pair<K, V> _kv; // 键值对 State _state = EMPTY; // 初始状态为空 }; // 开放定址法哈希表(线性探测) template<class K, class V> class HashTable { private: vector<HashData<K, V>> _tables; // 哈希表桶数组 size_t _n = 0; // 有效数据个数(EXIST状态) };要注意的是这⾥需要给每个存储值的位置加⼀个状态标识,否则删除⼀些值以后,会影响后⾯冲突的值的查找。如下图,我们删除30,会导致查找20失败,当我们给每个位置加⼀个状态标识{EXIST,EMPTY,DELETE} ,删除30就可以不⽤删除值,⽽是把状态改为 DELETE ,那么查找20时是遇到 EMPTY 才能,就可以找到20。
h(19) = 8,h(30) = 8,h(5) = 5,h(36) = 3,h(13) = 2,h(20) = 9,h(21) =10,h(12) = 1
3-3-2 🐈⬛扩容
这⾥我们哈希表负载因⼦控制在0.7,当负载因⼦到0.7以后我们就需要扩容了,我们还是按照2倍扩容,但是同时我们要保持哈希表⼤⼩是⼀个质数,第⼀个是质数,2倍后就不是质数了。那么如何解决了,⼀种⽅案就是上⾯除法散列中我们讲的Java HashMap的使⽤2的整数幂,但是计算时不能直接取模的改进⽅法。另外⼀种⽅案是sgi版本的哈希表使⽤的⽅法,给了⼀个近似2倍的质数表,每次去质数表获取扩容后的⼤⼩。
cpp// 头文件依赖:需要 <algorithm>(lower_bound) #include <algorithm> // 查找大于等于n的最小质数(从预定义质数表中) inline unsigned long __stl_next_prime(unsigned long n) { // 1. 预定义28个近似2倍递增的质数(覆盖从53到4294967291的范围) static const int __stl_num_primes = 28; static const unsigned long __stl_prime_list[__stl_num_primes] = { 53, 97, 193, 389, 769, 1543, 3079, 6151, 12289, 24593, 49157, 98317, 196613, 393241, 786433, 1572869, 3145739, 6291469, 12582917, 25165843, 50331653, 100663319, 201326611, 402653189, 805306457, 1610612741, 3221225473, 4294967291 }; // 2. 指针指向质数表的首尾 const unsigned long* first = __stl_prime_list; const unsigned long* last = __stl_prime_list + __stl_num_primes; // 3. 二分查找:找第一个≥n的质数(lower_bound是二分查找,效率O(log28)=O(1)) const unsigned long* pos = lower_bound(first, last, n); // 4. 边界处理:若n超过最大质数,返回最大质数;否则返回找到的质数 return pos == last ? *(last - 1) : *pos; }
3-3-3 🐆key不能取模的问题
当key是string/Date等类型时,key不能取模,那么我们需要给HashTable增加⼀个仿函数,这个仿函数⽀持把key转换成⼀个可以取模的整形,如果key可以转换为整形并且不容易冲突,那么这个仿函数就⽤默认参数即可,如果这个Key不能转换为整形,我们就需要⾃⼰实现⼀个仿函数传给这个参数,实现这个仿函数的要求就是尽量key的每值都参与到计算中,让不同的key转换出的整形值不同。string做哈希表的key⾮常常⻅,所以我们可以考虑把string特化⼀下
cpp// 1. 通用哈希仿函数(默认:数值类型直接转换) template<class K> struct HashFunc { size_t operator()(const K& key) { // 数值类型(int/long/size_t等)直接强转 return (size_t)key; } }; // 2. 字符串特化哈希仿函数(BKDR哈希算法,低冲突) template<> struct HashFunc<string> { size_t operator()(const string& key) { size_t hash = 0; // BKDR核心逻辑:乘以质数131 + 累加字符ASCII码 for (char ch : key) { hash = hash * 131 + ch; // 131是经验质数,也可选用31/13131等 } return hash; } }; // 3. 哈希表类(增加Hash仿函数模板参数,默认用HashFunc<K>) template<class K, class V, class Hash = HashFunc<K>> class HashTable { public: private: vector<HashData<K, V>> _tables; size_t _n = 0; // 表中存储数据个数 };
3-3-4 🦌完整代码实现
cpp#include <iostream> #include <vector> #include <utility> #include <string> #include <algorithm> // lower_bound using namespace std; namespace open_address { // 状态枚举:标记哈希桶状态 enum State { EXIST, // 已存储有效数据 EMPTY, // 空(从未存储) DELETE // 已删除(软删除标记) }; // 哈希节点结构:存储键值对 + 状态 template<class K, class V> struct HashData { pair<K, V> _kv; State _state = EMPTY; }; // 1. 通用哈希仿函数(默认处理数值类型) template<class K> struct HashFunc { size_t operator()(const K& key) { return (size_t)key; } }; // 2. 字符串特化哈希仿函数(BKDR算法,低冲突) template<> struct HashFunc<string> { size_t operator()(const string& key) { size_t hash = 0; for (char ch : key) { hash = hash * 131 + ch; // 131为经验质数,保证哈希分散性 } return hash; } }; // 开放定址法哈希表(线性探测,支持任意Key类型) template<class K, class V, class Hash = HashFunc<K>> class HashTable { public: // 质数查找函数:SGI STL实现,找≥n的最小质数 inline unsigned long __stl_next_prime(unsigned long n) { static const int __stl_num_primes = 28; static const unsigned long __stl_prime_list[__stl_num_primes] = { 53, 97, 193, 389, 769, 1543, 3079, 6151, 12289, 24593, 49157, 98317, 196613, 393241, 786433, 1572869, 3145739, 6291469, 12582917, 25165843, 50331653, 100663319, 201326611, 402653189, 805306457, 1610612741, 3221225473, 4294967291 }; const unsigned long* first = __stl_prime_list; const unsigned long* last = __stl_prime_list + __stl_num_primes; const unsigned long* pos = lower_bound(first, last, n); return pos == last ? *(last - 1) : *pos; } // 构造函数:初始化哈希表容量为最小质数(53) HashTable() { _tables.resize(__stl_next_prime(0)); // __stl_next_prime(0)返回53 } // 插入键值对(不允许重复Key) bool Insert(const pair<K, V>& kv) { // 1. Key已存在,插入失败 if (Find(kv.first) != nullptr) return false; // 2. 负载因子≥0.7,触发扩容(用整数运算避免浮点误差) if (_n * 10 / _tables.size() >= 7) { // 新建临时哈希表,扩容为当前容量2倍的最小质数 HashTable<K, V, Hash> newHT; newHT._tables.resize(__stl_next_prime(_tables.size() * 2)); // 遍历旧表,将有效数据插入新表 for (size_t i = 0; i < _tables.size(); i++) { if (_tables[i]._state == EXIST) { newHT.Insert(_tables[i]._kv); } } // 交换新旧表(现代写法,避免深拷贝) _tables.swap(newHT._tables); } // 3. 线性探测找空闲位置 Hash hash; size_t hash0 = hash(kv.first) % _tables.size(); // 初始哈希位置 size_t hashi = hash0; size_t i = 1; // 循环探测:直到找到非EXIST的位置 while (_tables[hashi]._state == EXIST) { hashi = (hash0 + i) % _tables.size(); // 线性探测公式 ++i; } // 4. 插入数据,标记状态为EXIST _tables[hashi]._kv = kv; _tables[hashi]._state = EXIST; ++_n; // 有效数据个数+1 return true; } // 查找Key:返回节点指针(nullptr表示未找到) HashData<K, V>* Find(const K& key) { // 空表直接返回nullptr if (_tables.empty()) return nullptr; Hash hash; size_t hash0 = hash(key) % _tables.size(); size_t hashi = hash0; size_t i = 1; // 探测终止条件:遇到EMPTY(无后续数据) while (_tables[hashi]._state != EMPTY) { // 找到有效数据且Key匹配 if (_tables[hashi]._state == EXIST && _tables[hashi]._kv.first == key) { return &_tables[hashi]; } // 线性探测下一个位置 hashi = (hash0 + i) % _tables.size(); ++i; // 极端边界:探测完所有位置仍未找到,终止循环 if (i > _tables.size()) break; } return nullptr; } // 删除Key:软删除(标记为DELETE) bool Erase(const K& key) { HashData<K, V>* ret = Find(key); if (ret == nullptr) { return false; // Key不存在,删除失败 } else { ret->_state = DELETE; // 软删除,不清空数据 --_n; // 有效数据个数-1 return true; } } // 打印哈希表(调试用,适配不同Key类型) void Print() { cout << "哈希表容量:" << _tables.size() << ",有效数据个数:" << _n << endl; for (size_t i = 0; i < _tables.size(); i++) { cout << "下标" << i << ": "; if (_tables[i]._state == EXIST) { // 适配string和数值类型的打印 if constexpr (is_same<K, string>::value) { cout << "[\"" << _tables[i]._kv.first << "\"," << _tables[i]._kv.second << "]"; } else { cout << "[" << _tables[i]._kv.first << "," << _tables[i]._kv.second << "]"; } } else if (_tables[i]._state == DELETE) { cout << "[DELETE]"; } else { cout << "[EMPTY]"; } cout << endl; } } private: vector<HashData<K, V>> _tables; // 哈希表桶数组 size_t _n = 0; // 有效数据个数(EXIST状态) }; } // 测试用例:验证int/string作为Key的核心功能 int main() { // ========== 测试1:int作为Key ========== cout << "===== int Key 测试 =====" << endl; open_address::HashTable<int, string> htInt; // 插入示例数据 htInt.Insert({19, "a"}); htInt.Insert({30, "b"}); htInt.Insert({5, "c"}); htInt.Insert({36, "d"}); htInt.Insert({13, "e"}); htInt.Insert({20, "f"}); htInt.Insert({21, "g"}); htInt.Insert({12, "h"}); htInt.Print(); // 查找测试 auto ret1 = htInt.Find(30); if (ret1) cout << "\n查找key=30: " << ret1->_kv.second << endl; // 输出b // 删除测试 bool eraseRet = htInt.Erase(30); cout << "\n删除key=30: " << (eraseRet ? "成功" : "失败") << endl; auto ret2 = htInt.Find(30); cout << "删除后查找key=30: " << (ret2 ? "找到" : "未找到") << endl; // 输出未找到 htInt.Print(); // ========== 测试2:string作为Key ========== cout << "\n===== string Key 测试 =====" << endl; open_address::HashTable<string, int> htStr; htStr.Insert({"apple", 10}); htStr.Insert({"banana", 20}); htStr.Insert({"cherry", 30}); htStr.Insert({"abcd", 40}); htStr.Insert({"bcad", 50}); // 验证BKDR哈希无冲突 // 查找易冲突字符串 auto ret3 = htStr.Find("abcd"); if (ret3) cout << "查找\"abcd\": " << ret3->_kv.second << endl; // 输出40 auto ret4 = htStr.Find("bcad"); if (ret4) cout << "查找\"bcad\": " << ret4->_kv.second << endl; // 输出50 htStr.Print(); return 0; }
3-4 🧇链地址法代码实现
核心特性:高效节点移动扩容、支持任意 Key 类型、低冲突哈希算法。
cpp#include <iostream> #include <vector> #include <utility> #include <string> #include <algorithm> // lower_bound using namespace std; namespace hash_bucket { // 1. 通用哈希仿函数(默认处理数值类型) template<class K> struct HashFunc { size_t operator()(const K& key) { return (size_t)key; } }; // 2. 字符串特化哈希仿函数(BKDR算法,低冲突) template<> struct HashFunc<string> { size_t operator()(const string& key) { size_t hash = 0; for (char ch : key) { hash = hash * 131 + ch; // 131为经验质数,保证哈希分散性 } return hash; } }; // 哈希桶节点结构:存储键值对 + 下一个节点指针 template<class K, class V> struct HashNode { pair<K, V> _kv; HashNode<K, V>* _next; // 构造函数:初始化键值对,next置空 HashNode(const pair<K, V>& kv) : _kv(kv) , _next(nullptr) {} }; // 链地址法哈希表(哈希桶) template<class K, class V, class Hash = HashFunc<K>> class HashTable { public: typedef HashNode<K, V> Node; // 节点类型别名 // SGI STL质数查找函数:找≥n的最小质数(扩容用) inline unsigned long __stl_next_prime(unsigned long n) { static const int __stl_num_primes = 28; static const unsigned long __stl_prime_list[__stl_num_primes] = { 53, 97, 193, 389, 769, 1543, 3079, 6151, 12289, 24593, 49157, 98317, 196613, 393241, 786433, 1572869, 3145739, 6291469, 12582917, 25165843, 50331653, 100663319, 201326611, 402653189, 805306457, 1610612741, 3221225473, 4294967291 }; const unsigned long* first = __stl_prime_list; const unsigned long* last = __stl_prime_list + __stl_num_primes; const unsigned long* pos = lower_bound(first, last, n); return pos == last ? *(last - 1) : *pos; } // 构造函数:初始化桶数组为最小质数(53),所有桶初始化为nullptr HashTable() { _tables.resize(__stl_next_prime(0), nullptr); } // 析构函数:释放所有节点内存(避免内存泄漏) ~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; // 桶置空 } } // 插入键值对(头插法,负载因子==1时扩容,移动节点而非重建) bool Insert(const pair<K, V>& kv) { // 0. 先检查Key是否已存在(避免重复插入) if (Find(kv.first) != nullptr) return false; Hash hs; size_t hashi = hs(kv.first) % _tables.size(); // 1. 负载因子==1时触发扩容(链地址法负载因子可>1,此处选==1扩容) if (_n == _tables.size()) { // 新桶数组容量:当前容量2倍的最小质数(修复原代码+1的问题) size_t newCap = __stl_next_prime(_tables.size() * 2); vector<Node*> newTables(newCap, nullptr); // 遍历旧桶,移动节点到新桶(无需重建节点,效率更高) for (size_t i = 0; i < _tables.size(); i++) { Node* cur = _tables[i]; while (cur) { Node* next = cur->_next; // 保存下一个节点 // 计算节点在新桶中的位置 size_t newHashi = hs(cur->_kv.first) % newTables.size(); // 头插到新桶的链表 cur->_next = newTables[newHashi]; newTables[newHashi] = cur; cur = next; // 处理下一个节点 } _tables[i] = nullptr; // 旧桶置空(可选,仅为规范) } // 交换新旧桶数组(现代写法,避免深拷贝) _tables.swap(newTables); } // 2. 头插法插入新节点到对应桶 Node* newNode = new Node(kv); newNode->_next = _tables[hashi]; _tables[hashi] = newNode; ++_n; // 有效数据个数+1 return true; } // 查找Key:返回节点指针(nullptr表示未找到) Node* Find(const K& key) { Hash hs; size_t hashi = hs(key) % _tables.size(); // 遍历对应桶的链表 Node* cur = _tables[hashi]; while (cur) { if (cur->_kv.first == key) return cur; // 找到Key,返回节点 cur = cur->_next; } return nullptr; // 未找到 } // 删除Key:找到节点后调整链表指针,释放节点内存 bool Erase(const K& key) { Hash hs; size_t hashi = hs(key) % _tables.size(); Node* prev = nullptr; // 前驱节点 Node* cur = _tables[hashi]; // 遍历链表找目标节点 while (cur) { if (cur->_kv.first == key) { // 1. 目标节点是链表头 if (prev == nullptr) { _tables[hashi] = cur->_next; } // 2. 目标节点是链表中间/尾 else { prev->_next = cur->_next; } // 释放节点内存,有效数据个数-1 delete cur; --_n; return true; } prev = cur; cur = cur->_next; } return false; // Key不存在,删除失败 } // 打印哈希表(调试用,适配不同Key类型) void Print() { cout << "哈希桶容量:" << _tables.size() << ",有效数据个数:" << _n << endl; for (size_t i = 0; i < _tables.size(); i++) { cout << "桶" << i << ": "; Node* cur = _tables[i]; while (cur) { // 适配string和数值类型的打印 if constexpr (is_same<K, string>::value) { cout << "[\"" << cur->_kv.first << "\"," << cur->_kv.second << "] -> "; } else { cout << "[" << cur->_kv.first << "," << cur->_kv.second << "] -> "; } cur = cur->_next; } cout << "nullptr" << endl; } } private: vector<Node*> _tables; // 桶数组(存储链表头节点指针) size_t _n = 0; // 有效数据个数(所有链表节点总数) }; } // 测试用例:验证int/string作为Key的核心功能 int main() { // ========== 测试1:int作为Key ========== cout << "===== int Key 测试 =====" << endl; hash_bucket::HashTable<int, string> htInt; // 插入示例数据(触发扩容:初始容量53,插入53个元素后扩容到97) for (int i = 0; i < 55; i++) { htInt.Insert({i, "test" + to_string(i)}); } htInt.Print(); // 查找测试 auto ret1 = htInt.Find(30); if (ret1) cout << "\n查找key=30: " << ret1->_kv.second << endl; // 输出test30 // 删除测试 bool eraseRet = htInt.Erase(30); cout << "\n删除key=30: " << (eraseRet ? "成功" : "失败") << endl; auto ret2 = htInt.Find(30); cout << "删除后查找key=30: " << (ret2 ? "找到" : "未找到") << endl; // 输出未找到 // ========== 测试2:string作为Key ========== cout << "\n===== string Key 测试 =====" << endl; hash_bucket::HashTable<string, int> htStr; htStr.Insert({"apple", 10}); htStr.Insert({"banana", 20}); htStr.Insert({"cherry", 30}); htStr.Insert({"abcd", 40}); htStr.Insert({"bcad", 50}); // 验证BKDR哈希无冲突 // 查找易冲突字符串 auto ret3 = htStr.Find("abcd"); if (ret3) cout << "查找\"abcd\": " << ret3->_kv.second << endl; // 输出40 auto ret4 = htStr.Find("bcad"); if (ret4) cout << "查找\"bcad\": " << ret4->_kv.second << endl; // 输出50 htStr.Print(); return 0; }
四、😃unordered_set / unordered_map
哈希表 最xx的地方在于 查找速度非常快
快过红黑树!
因此在 C++11 标准中,利用 哈希表 作为底层结构,重写了 set / ma
p,就是 unordered_set / unordered_map图片出自:C++新特性之三:标准库中的新增容器
4-1 🍿使用
哈希表 版的 unordered_set / unordered_map 与 红黑树 版的 set / map 在功能上 没有差别
可以直接无缝衔接
关于 set 和 map 的使用 详见:[C++------lesson28.数据结构进阶------「set 和 map 学习及使用」]
unordered_set的使用
cpp#include <iostream> #include <vector> #include <unordered_set> using namespace std; int main() { vector<int> arr = { 7,3,6,9,3,1,6,2 }; unordered_set<int> s1(arr.begin(), arr.end()); //迭代器遍历 cout << "迭代器遍历结果: "; unordered_set<int>::iterator it = s1.begin(); while (it != s1.end()) { cout << *it << " "; ++it; } cout << endl; //判空、求大小 cout << "===================" << endl; cout << "empty(): " << s1.empty() << endl; cout << "size(): " << s1.size() << endl; cout << "max_size(): " << s1.max_size() << endl; //插入元素 cout << "===================" << endl; cout << "insert(5): "; s1.insert(5); for (auto e : s1) cout << e << " "; cout << endl; //删除元素 cout << "===================" << endl; cout << "erase(6): "; s1.erase(6); for (auto e : s1) cout << e << " "; cout << endl; //交换、查找、清理 cout << "===================" << endl; unordered_set<int> s2(arr.begin() + 5, arr.end()); s1.swap(s2); cout << "s1: "; for (auto e : s1) cout << e << " "; cout << endl; cout << "s2: "; for (auto e : s2) cout << e << " "; cout << endl; cout << "s1.find(9): "; cout << (s1.find(9) != s1.end()) << endl; cout << "s2.clear(): " << endl; s2.clear(); cout << "s1: "; for (auto e : s1) cout << e << " "; cout << endl; cout << "s2: "; for (auto e : s2) cout << e << " "; cout << endl; return 0; }
unordered_map的使用
cpp#include <iostream> #include <vector> #include <string> #include <unordered_map> using namespace std; int main() { vector<pair<string, int>> arr{ make_pair("z", 122), make_pair("a", 97), make_pair("K", 75), make_pair("h", 104), make_pair("B", 66) }; unordered_map<string, int> m1(arr.begin(), arr.end()); //迭代器遍历 cout << "迭代器遍历结果: "; unordered_map<string, int>::iterator it = m1.begin(); while (it != m1.end()) { cout << "<" << it->first << ":" << it->second << "> "; ++it; } cout << endl; //判空、求大小、解引用 cout << "===================" << endl; cout << "empty(): " << m1.empty() << endl; cout << "size(): " << m1.size() << endl; cout << "max_size(): " << m1.max_size() << endl; cout << "m1[""a""]: " << m1["a"] << endl; //插入元素 cout << "===================" << endl; cout << "insert(""a"", 5): "; m1.insert(make_pair("a", 5)); for (auto e : m1) cout << "<" << e.first << ":" << e.second << "> "; cout << endl; //删除元素 cout << "===================" << endl; cout << "erase(""a""): "; m1.erase("a"); for (auto e : m1) cout << "<" << e.first << ":" << e.second << "> "; cout << endl; //交换、查找、清理 cout << "===================" << endl; unordered_map<string, int> m2(arr.begin() + 2, arr.end()); m1.swap(m2); cout << "m1.swap(m2)" << endl; cout << "m1: "; for (auto e : m1) cout << "<" << e.first << ":" << e.second << "> "; cout << endl; cout << "m2: "; for (auto e : m2) cout << "<" << e.first << ":" << e.second << "> "; cout << endl; cout << "m1.find(""B""): "; cout << (m1.find("B") != m1.end()) << endl; cout << "m2.clear()" << endl; m2.clear(); cout << "m1: "; for (auto e : m1) cout << "<" << e.first << ":" << e.second << "> "; cout << endl; cout << "m2: " << endl; for (auto e : m2) cout << "<" << e.first << ":" << e.second << "> "; cout << endl; return 0; }照搬 set / map 使用的代码,可以无缝衔接,所以说正常使用就好了,无非就是名字长一些
C++ 为了确保向前兼容性,无法修改原来的名字,因此只能加上 unordered 以示区分
相比之下,Java 中两个不同版本的 set / map 就非常容易区分
- 红黑树版:TreeSet / TreeMap
- 哈希表版:HashSet / HashMap
注意: unordered_set / unordered_map 默认不允许出现键值冗余,如果相要存储重复的数据,可以使用 unordered_multiset / unordered_multimap
4-2 🧂与 set/map 的区别
哈希表 版 与 红黑树 版的主要区别有两个
- 迭代器:哈希表版 是单向迭代器,红黑树版 是双向迭代器
- 遍历结果:哈希表版 无序,红黑树 有序
因为 unordered_set 是 单向迭代器 ,自然无法适配 反向迭代器
两种不同底层数据结构的遍历结果:
cpp#include <iostream> #include <vector> #include <set> #include <unordered_set> using namespace std; int main() { vector<int> v = { 2,3,45,2,345,231,21,543,121,34 }; set<int> TreeSet(v.begin(), v.end()); unordered_set<int> HashSet(v.begin(), v.end()); cout << "TreeSet: "; for (auto e : TreeSet) cout << e << " "; cout << endl << endl; cout << "HashSet: "; for (auto e : HashSet) cout << e << " "; cout << endl; return 0; }显然,哈希表 实现的 unordered_set / unordered_map 遍历结果为 无序
哈希表 究竟比 红黑树 强多少?
4-3 🥓性能对比
下面是性能测试代码,包含 大量重复、部分重复、完全有序 三组测试用例,分别从 插入、查找、删除 三个维度进行对比
注:测试性能用的是 Release 版,这里的基础数据量为 100 w
cpp#include <iostream> #include <vector> #include <set> #include <unordered_set> using namespace std; int main() { const size_t N = 1000000; unordered_set<int> us; set<int> s; vector<int> v; v.reserve(N); srand(time(0)); for (size_t i = 0; i < N; ++i) { //v.push_back(rand()); //大量重复 //v.push_back(rand()+i); //部分重复 //v.push_back(i); //完全有序 } size_t begin1 = clock(); for (auto e : v) { s.insert(e); } size_t end1 = clock(); cout << "set insert:" << end1 - begin1 << endl; size_t begin2 = clock(); for (auto e : v) { us.insert(e); } size_t end2 = clock(); cout << "unordered_set insert:" << end2 - begin2 << endl; size_t begin3 = clock(); for (auto e : v) { s.find(e); } size_t end3 = clock(); cout << "set find:" << end3 - begin3 << endl; size_t begin4 = clock(); for (auto e : v) { us.find(e); } size_t end4 = clock(); cout << "unordered_set find:" << end4 - begin4 << endl << endl; cout << s.size() << endl; cout << us.size() << endl << endl;; size_t begin5 = clock(); for (auto e : v) { s.erase(e); } size_t end5 = clock(); cout << "set erase:" << end5 - begin5 << endl; size_t begin6 = clock(); for (auto e : v) { us.erase(e); } size_t end6 = clock(); cout << "unordered_set erase:" << end6 - begin6 << endl << endl; return 0; }插入大量重复数据
插入数据 大量重复 ---- 结果:
- 插入:哈希 比 红黑 快 88%
- 查找:哈希 比 红黑 快 100%
- 删除:哈希 比 红黑 快 37%
插入部分重复数据
插入数据 部分重复 ---- 结果:
- 插入 :哈希 与 红黑 差不多
- 查找:哈希 比 红黑 快 100%
- 删除:哈希 比 红黑 快 41%
插入大量重复数据
插入数据 完全有序 ---- 结果:
- 插入:哈希 比 红黑 慢 52%
- 查找:哈希 比 红黑 快 100%
- 删除:哈希 比 红黑 慢 58%
总的来说,在数据 随机 的情况下,哈希各方面都比红黑强 ,在数据 有序 的情况下,红黑更胜一筹
单就 查找 这一个方面来说:哈希 一骑绝尘,远远的将红黑甩在了身后
🔥提炼与总结🔥
以上就是本次关于 C++【初识哈希】的全部内容了,在本文中,我们主要学习了哈希的相关知识,包括哈希思想、哈希函数、哈希冲突及其解决方法,最后还学习了 C++11 中基于哈希表的新容器,见识了哈希表查找的快,不是一般的快。在下一篇文章中,我们将会对哈希表进行模拟实现,同时也会用一张哈希表同时封装实现 unordered_set 和 unordered_map
一、核心区别(表格总结)
维度 unordered_set unordered_map 存储结构 仅存储单个值(value),且值唯一 存储键值对(key-value),key 唯一,value 可重复 / 任意 核心特性 是 "集合",用于去重、快速存在性检查 是 "字典",用于通过 key 快速映射 / 查找 value 重复元素处理 自动去重(插入重复值会失败) 自动保证 key 唯一(重复 key 插入会覆盖 value) 访问方式 直接访问值,或通过迭代器遍历值 通过 key 访问 value( map[key]),或遍历键值对底层哈希依据 以存储的 "值" 作为哈希键 以 "key" 作为哈希键(value 不参与哈希) 常用方法 find(val)、 insert(val)、erase(val)find(key)、insert({key, val})、erase(key)、[] 运算符 二、通俗理解
- unordered_set**:像一个 "无重复的抽屉",只放一个个独立的元素,你只能查 "某个元素是否在抽屉里",或 "取出所有元素",无法给元素绑定额外信息。例如:存储 {1,2,3},只能查 "1 是否存在",不能给 1 绑定其他值。**
- unordered_map:像一个 "带标签的抽屉",每个抽屉有唯一标签(key),抽屉里可以放任意东西(value)。你可以通过标签快速找到对应的东西,也可以修改抽屉里的东西。例如:存储
{1: "a", 2: "b"},可以通过 key=1 找到 value="a",也可以把 1 对应的 value 改成 "c"。
结束语
以上就是我对于【C++】------数据结构进阶------「初识哈希」的理解
感谢你的三连支持!!!




















































