C++ 的两个参考文档
标准文档: cplusplus
哈希表通过哈希函数将关键字映射到存储位置,实现快速查找。核心概念包括哈希冲突和负载因子。解决冲突的方法主要有开放定址法(线性探测、二次探测)和链地址法(哈希桶)。实现时需注意删除标记状态、扩容策略及自定义哈希函数以支持非整数键类型。

标准文档: cplusplus
哈希表是组织数据的一种方式。
哈希(hash)又称散列,故哈希表又称散列表。哈希的本质就是通过哈希函数把关键字 Key 跟存储位置建立一个映射关系,查找时通过这个哈希函数计算出 Key 存储的位置,进行快速查找。
当关键字的范围比较集中时,直接定址法是非常简单高效的方法。比如一组关键字都在 [0,99] 之间,那么我们开一个 100 个数的数组,每个关键字的值直接就是存储位置的下标。再比如一组关键字值都在 [a,z] 的小写字母,那么我们开一个 26 个数的数组,每个关键字 ascii 码-aascii 码就是存储位置的下标。也就是说直接定址法本质就是用关键字计算出一个绝对位置或者相对位置。
题目描述: 给定一个字符串 s,找到其中第一个只出现一次的字符。如果不存在,则返回 -1。
class Solution { public: int firstUniqChar(string s) { int count[26] = { 0 }; for (auto ch : s) { count[ch - 'a']++; } for (size_t i = 0; i < s.size(); ++i) { if (count[s[i] - 'a'] == 1) return i; } return -1; } };
直接定址法的缺点非常明显,当关键字的范围比较分散时,就很浪费内存甚至内存不够用。假设我们只有数据范围是 [0,9999] 的 N 个值,我们要映射到一个 M 个空间的数组中(一般情况下 M >= N),那么就要借助哈希函数(hash function) hf,关键字 key 被放到数组的 h(key) 位置,这里要注意的是 h(key) 计算出的值必须在 [O , M) 之间。
存在的一个问题就是,两个不同的 key 可能会映射到同一个位置去,这种问题我们叫做哈希冲突,或者哈希碰撞。理想情况是找出一个好的哈希函数避免冲突,但是实际场景中,冲突是不可避免的,所以我们尽可能设计出优秀的哈希函数,减少冲突的次数,同时也要去设计出解决冲突的方案。
假设哈希表中已经映射存储了 N 个值,哈希表的大小为 M,那么负载因子 = N / M,负载因子有些地方也翻译为载荷因子/装载因子等,他的英文为 loadfactor。负载因子越大,哈希冲突的概率越高,空间利用率越高;负载因子越小,哈希冲突的概率越低,空间利用率越低。
我们将关键字映射到数组中位置,一般是整数好做映射计算,如果不是整数,我们要想办法转换成整数,这个细节我们后面代码实现中再进行细节展示。下面哈希函数部分我们讨论时,如果关键字不是整数,那么我们讨论的 Key 是关键字转换成的整数。
前面的第三点建议 M 取一个不太接近 2 的整数次幂的质数,Java 中,HashMap 采用除法散列法时就是 2 的整数次幂做哈希表的 M,这样一来我们可以直接位运算,相对取模会更加高效些。只是 Java 不是单纯地取模,比如说 M 是 2^16,本质上是取后 16 位,那么用 key' = key >> 16,然后把 key 和 key' 异或的结果作为哈希值,也就是说:我们可以映射的值在 [0 , M] 范围内,尽可能让 key 所有的位都参与计算,这样映射出的哈希值更加均匀一些,因此,大多数数据结构书籍中写的基本是这个理论,我们实践中还是建议灵活运用。
乘法散列法对哈希表大小 M 没有要求,这里介绍一下大思路,第一步:用关键字 K 乘上常数 A(0 < A < 1),并抽取出 k*A 的小数部分;第二步:后再用 M 乘以 k * A 的小数部分,再向下取整。
h(key) = floor(M * ((A * key) % 1.0)),其中 floor 表示对表达式进行下取整,A \in (0 , 1),%1.0 是为了取小数,这里最重要的是 A 的值应该如何设定,Knuth 认为 A = (sqrt(5) - 1) / 2 = 0.6180339887...(黄金分割点) 比较好。
乘法散列法对哈希表大小 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。
如果存在这样一个恶意的对手,他针对我们提供的散列函数,特意构造出一个发生严重冲突的数据集,比如,让所有关键字全部落入同一个位置中——这种情况是可以存在的,只要散列函数是公开且确定的,就可以实现此攻击。解决方法自然是见招拆招,给散列函数增加随机性,攻击者就无法找出确定可以导致最坏情况的数据。这种方法叫做全域散列。
hab(key) = ((a * key + b) % P) % M,P 需要选一个足够大的质数,a 可以随机选 [1 , P - 1] 之间的任意整数,b 可以随机选 [0 , P - 1] 之间的任意整数,这些函数构成了一个 P * (P - 1) 组全域散列函数组。假设 P = 17,M = 6,a = 3,b = 4,则 h34(8) = ((3 * 8 + 4) % 17) % 6 = 5。
需要注意的是每次初始化哈希表时,随机选取全域散列函数组中的一个散列函数使用,后续增删查改都固定使用这个散列函数,否则每次哈希都是随机选一个散列函数,那么插入是一个散列函数,查找又是另一个散列函数,就会导致找不到插入的 key 了。
上面的几种方法是在《算法导论》这本书籍中讲解的方法。 《殷人昆数据结构:用面向对象方法与 C++ 语言描述 (第二版)》和《数据结构 (C 语言版).严蔚敏,吴伟民》等教材型书籍上面还给出了平方取中法、折叠法、随机数法、数学分析法等,这些方法相对更适用于一些局限的特定场景,大家如果有兴趣可以去看看这些书籍。
实践中哈希表一般还是选择除法散列法作为哈希函数,当然哈希表无论选择什么哈希函数也避免不了冲突,因为冲突是避免不了的,我们只能减少冲突,那么插入数据时,如何解决冲突呢?主要有两种方法,开放定址法和链地址法。
在开放定址法中所有的元素都放到哈希表里,当一个关键字 key 用哈希函数计算出的位置冲突了,则按照某种规则找到一个没有存储数据的位置进行存储,开放定址法中负载因子一定是小于 1 的。这里的规则有三种:线性探测、二次探测、双重探测。
我们先简单谈谈'堆积 / 群积问题'。 如下图所示,连续占用会导致探测路径变长。
二次探测使用平方增量来寻找下一个可用位置。
双重探测使用第二个哈希函数来计算增量。
开放定址法在实践中是不如下面会介绍的链地址法的,因为开放定址法解决冲突不管使用哪种方法,占用的都是哈希表中的空间,始终存在互相影响的问题——正因如此,开放定址法我们简单选择线性探测实现即可。
enum 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; // 表中存储数据个数 };
要注意的是这里需要给每个存储值的位置加一个状态标识,否则删除一些值以后,会影响后面冲突的值的查找。如下图所示,我们删除 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。
这里我们哈希表负载因子控制在 0.7,当负载因子到 0.7 以后我们就需要扩容了,我们还是按照 2 倍的方式扩容,但是同时我们要保持哈希表大小是一个质数,第一个是质数,2 倍后就不是质数了。如何解决?一种方案就是上面在【除法散列法】中我们介绍过的 JavaHashMap 的使用 2 的整数次幂,但是计算时不能直接取模的改进方法;另外一种方案是 SGI 版本的哈希表使用的方法,给了一个近似 2 倍的质数表,每次去质数表获取扩容后的大小。
当 key 是 string / Date 等类型时,key 不能取模,我们需要给 HashTable 增加一个仿函数,这个仿函数支持把 key 转换成一个可以取模的整型,如果 key 可以转换为整型并且不容易冲突,那么这个仿函数就用默认参数即可,如果这个 Key 不能转换为整型,我们就需要自己实现一个仿函数传给这个参数,实现这个仿函数的要求就是尽量 key 的每值都参与到计算中,让不同的 key 转换出的整型值不同。string 做哈希表的 key 非常常见,所以我们可以考虑把 string 特化一下——
template<class K> struct HashFunc { size_t operator()(const K& key) { return (size_t)key; } }; // 特化 template<> struct HashFunc<string> { // 字符串转换成整形,可以把字符 ascii 码相加即可 // 但是直接相加的话,类似"abcd"和"bcad"这样的字符串计算出是相同的 // 这里我们使用 BKDR 哈希的思路,用上次的计算结果去乘以一个质数,这个质数一般去 31, 131 等效果会比较好 size_t operator()(const string& key) { size_t hash = 0; for (auto e : key) { hash *= 131; hash += e; } return hash; } }; template<class K, class V, class Hash = HashFunc<K>> class HashTable { public: private: vector<HashData<K, V>> _tables; size_t _n = 0; // 表中存储数据个数 };
对比一下——开放定址法适合数据量较小且冲突少的情况,链地址法适合数据量大且允许链表增长的情况。
开放定址法中所有的元素都放到哈希表里,链地址法中所有的数据不再直接存储在哈希表中,哈希表中存储一个指针,没有数据映射这个位置时,这个指针为空,有多个数据映射到这个位置时,把这些冲突的数据链接成一个链表,挂在哈希表这个位置下面,像是一个个挂在晾衣杆的水桶,链地址法也叫做拉链法或者哈希桶。
以 {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) = 88
开放定址法负载因子必须小于 1,链地址法的负载因子就没有限制了,可以大于 1。负载因子越大,哈希冲突的概率越高,空间利用率越高;负载因子越小,哈希冲突的概率越低,空间利用率越低。stl 中 unordered_xxx 的最大负载因子基本控制在 1(负载因子平均是 1,但是这是理想的情况,当然没有那么平均,有的哈希桶不挂,有的挂 2~3 个),大于 1 就扩容,之后在代码实现中也使用这个方式进行扩容。 扩容有分散的意思在——平均每个桶的长度变短。
如果在极端场景下,某个桶特别长怎么办?我们其实可以考虑使用全域散列法,这样就不容易被针对了。假设不是被针对了,此时虽然用了全域散列法,但是在偶然情况下,某个桶很长,查找效率很低怎么办呢?这里在 Java8 的 HashMap 中,当桶的长度超过一定阀值(8)时就把链表转换成红黑树,我们知道红黑树是近似平衡,时间复杂度很稳定,O(logN),而哈希在插入的时候会'抽风',而且随着插入次数增加,'抽风'程度会愈演愈烈。 不过,一般情况下,不断扩容,单个桶很长的场景还是比较少的,这个解决极端场景的思路,读者这里可以了解一下。 换红黑树也是一种解决方案。
#pragma once #include<vector> 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 }; inline unsigned long __stl_next_prime(unsigned long n) { const unsigned long* first = __stl_prime_list; const unsigned long* last = __stl_prime_list + __stl_num_primes; // >= n const unsigned long* pos = lower_bound(first, last, n); return pos == last ? *(last - 1) : *pos; } template<class K> struct HashFunc { size_t operator()(const K& key) { return (size_t)key; } }; // 特化 template<> struct HashFunc<string> { size_t operator()(const string& key) { size_t hash = 0; for (auto ch : key) { hash += ch; hash *= 131; } return hash; } }; namespace open_address { enum State { EMPTY, EXIST, DELETE }; template<class K, class V> struct HashData { pair<K, V> _kv; State _state = EMPTY; }; template<class K, class V, class Hash = HashFunc<K>> class HashTable { public: HashTable() :_tables(__stl_next_prime(1)) { } bool Insert(const pair<K, V>& kv) { if (Find(kv.first)) return false; // 满了 / 快满了就要扩容,负载因子 >= 0.7 就要扩容 if ((double)_n / (double)_tables.size() >= 0.7) // 至少强转一个 { //vector<HashData<K, V>> newTables(__stl_next_prime(_tables.size() * 2)); // //_tables.resize(); // 扩容可以直接 resize 吗?哈希表的扩容不是那么简单的,要重新分配 // std::vector<HashData> newtables(_tables.size()); // for (size_t i = 0; i < _tables, size(); i++) // { // if (_tables[i]._state == EXIST) // { // // 重新映射到新表 // // ... // } // } // // _tables.swap(newtables); HashTable<K, V, Hash> newht; newht._tables.resize(__stl_next_prime(_tables.size() + 1)); for (size_t i = 0; i < _tables.size(); i++) { // 遍历旧表,旧表数据插入到 newht if (_tables[i]._state == EXIST) { newht.Insert(_tables[i]._kv); } } _tables.swap(newht._tables); } Hash hs; // 插入的逻辑 size_t hash0 = hs(kv.first) % _tables.size(); // 只能模 size,不能模 capacity // []访问必须要在 size 访问之内,模 capacity 放不进去(尽可能让 capacity 和 size 一致) // 线性探测 size_t i = 1; // 第一次就不加了 size_t hashi = hash0; while (_tables[hashi]._state == EXIST) { hashi = (hash0 + i) % _tables.size(); // 取模,回绕回去 ++i; // 不断加 i } _tables[hashi]._kv = kv; _tables[hashi]._state = EXIST; ++_n; return true; } HashData<K, V>* Find(const K& key) { Hash hs; size_t hash0 = hs(key) % _tables.size(); //线性探测 size_t i = 1; size_t hashi = hash0; while (_tables[hashi]._state != EMPTY) { if (_tables[hashi]._state != DELETE && _tables[hashi]._kv.first == key) { return &_tables[hashi]; } hashi = (hash0 + i) % _tables.size(); ++i; } return nullptr; } bool Erase(const K& key) { HashData<K, V>* ret = Find(key); if (ret) { ret->_state = DELETE; --_n; return true; } else { return false; } } private: std::vector<HashData<K, V>> _tables; // 指针数组 size_t _n = 0; // 存储的有效数据个数 }; } 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 Hash = HashFunc<K>> class HashTable { typedef HashNode<K, V> Node; public: HashTable() :_tables(__stl_next_prime(1),nullptr) ,_n(0) { } // 析构,要单独实现 ~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; } _n = 0; } bool Insert(const pair<K, V>& kv) { if (Find(kv.first)) return false; Hash hs; // 除余都要套上 hs // 负载因子 == 1 就开始扩容 if (_n == _tables.size()) { //HashTable<K, V> newht; //newht._tables.resize(_tables.size() * 2); //for (size_t i = 0; i < _tables.size(); i++) //{ // // 遍历旧表,旧表数据插入到 newht // Node* cur = _tables[i]; // while (cur) // { // newht.Insert(cur->_kv); // cur = cur->_next; // } //} //_tables.swap(newht._tables); std::vector<Node*> newtables(__stl_next_prime(_tables.size() + 1), nullptr); for (size_t i = 0; i < _tables.size(); i++) { // 遍历旧表,旧表节点重新映射,挪动到新表 Node* cur = _tables[i]; while (cur) { Node* next = cur->_next; // 头插 size_t hashi = hs(cur->_kv.first) % newtables.size(); cur->_next = newtables[hashi]; newtables[hashi] = cur; cur = next; } _tables[i] = nullptr; } _tables.swap(newtables); } size_t hashi = hs(kv.first) % _tables.size(); // 头插 Node* newnode = new Node(kv); newnode->_next = _tables[hashi]; _tables[hashi] = newnode; ++_n; // 插入,_n 是有效数据个数,要++ return true; } 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; } cur = cur->_next; } return nullptr; } 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) { // 删除 if (prev == nullptr) { // 哈希桶中的第一个节点 _tables[hashi] = cur->_next; } else { prev->_next = cur->_next; } --_n; // _n 是有效数据个数,每次删除之后都要减减 delete cur; return true; } prev = cur; cur = cur->_next; } return false; } private: std::vector<Node*> _tables; // 指针数组 size_t _n; // 存储的有效数据个数 //std::vector<std::list<K, V>> _tables; // 不是实现不了,而是这种实现太绕了,而且比较抽象,现阶段对我们来说还是太难了 }; }
#define _CRT_SECURE_NO_WARNINGS 1 #include<iostream> #include<unordered_map> using namespace std; #include"HashTable.h" namespace open_address { void TestHT1() { int a[] = { 19,30,5,36,13,20,21,12,58 }; HashTable<int, int> ht; for (auto e : a) { ht.Insert({ e,e }); } ht.Insert({ 2,2 }); ht.Insert({ 22,22 }); cout << ht.Find(5) << endl; cout << ht.Find(58) << endl; ht.Erase(5); cout << ht.Find(5) << endl; cout << ht.Find(58) << endl; //for (size_t i = 0; i < 100; i++) //{ // ht.Insert({ rand(),i }); //} } struct HashFuncString { // BKDR size_t operator()(const string& key) { size_t hash = 0; for (auto ch : key) { hash += ch; hash *= 131; } return hash; } }; void TestHT2() { //HashTable<string, string, HashFuncString> dict; HashTable<string, string> dict; dict.Insert({ "string","字符串" }); // string 无法取模 dict.Insert({ "string","字符串 1" }); dict.Insert({ "left","左边" }); dict.Insert({ "right","右边" }); cout << dict.Find("string") << endl; cout << dict.Find("left") << endl; cout << dict.Find("left ") << endl; HashFuncString hfs; cout << hfs("abcd") << endl; cout << hfs("acbd") << endl; cout << hfs("aadd") << endl; unordered_map<string, string> dictmap; dictmap.insert({ "string","字符串" }); // 编译报错,需要自己实现 Hash 的仿函数把 key 转成整形 //unordered_map<pair<string, int>, string> um; //um.insert({ {"string", 1}, "字符串" }); } } namespace Hash_bucket { void TestHT1() { int a[] = { 19,30,5,36,13,20,21,12,58 }; HashTable<int, int> ht; for (auto e : a) { ht.Insert({ e,e }); } ht.Insert({ 2,2 }); ht.Insert({ 22,22 }); ht.Insert({ 44,44 }); // 这两个过了就说明代码没问题了:先删 58 再删 36 ht.Erase(58); ht.Erase(36); } void TestHT2() { HashTable<string, string> dict; dict.Insert({ "string","字符串" }); // string 无法取模 dict.Insert({ "string","字符串 1" }); dict.Insert({ "left","左边" }); dict.Insert({ "right","右边" }); cout << dict.Find("string") << endl; cout << dict.Find("left") << endl; cout << dict.Find("left ") << endl; } } int main() { // open_address::TestHT1(); //open_address::TestHT2(); Hash_bucket::TestHT1(); //Hash_bucket::TestHT2(); return 0; }
(此处省略具体输出截图,逻辑验证通过)
(此处省略具体输出截图,逻辑验证通过)
(此处省略具体输出截图,逻辑验证通过)
(此处省略具体输出截图,逻辑验证通过)
本文详细介绍了 C++ 哈希表的实现原理,包括哈希函数设计、冲突处理方法(开放定址法与链地址法)以及扩容机制。通过代码示例展示了如何构建基础的哈希表类,并讨论了在实际应用中需要注意的状态管理、负载因子控制及自定义哈希函数等问题。

微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
使用加密算法(如AES、TripleDES、Rabbit或RC4)加密和解密文本明文。 在线工具,加密/解密文本在线工具,online
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online
将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online
通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online