
C++专栏:C++_Yupureki的博客-CSDN博客
目录
[1. unordered系列容器详解](#1. unordered系列容器详解)
[1.1 unordered_set和unordered_map基本介绍](#1.1 unordered_set和unordered_map基本介绍)
[1.2 与map/set的主要差异](#1.2 与map/set的主要差异)
[1. 对Key的要求不同](#1. 对Key的要求不同)
[2. 迭代器差异](#2. 迭代器差异)
[1.3 基本使用示例](#1.3 基本使用示例)
[1.4 哈希相关接口](#1.4 哈希相关接口)
[2. 哈希表底层原理](#2. 哈希表底层原理)
[2.1 哈希概念](#2.1 哈希概念)
[2.2 哈希函数](#2.2 哈希函数)
[2.2.1 除法散列法/除留余数法](#2.2.1 除法散列法/除留余数法)
[2.2.2 乘法散列法](#2.2.2 乘法散列法)
[2.3 处理哈希冲突](#2.3 处理哈希冲突)
[2.3.1 开放定址法](#2.3.1 开放定址法)
[2.3.2 链地址法(哈希桶)](#2.3.2 链地址法(哈希桶))
[2.4 关键问题解决](#2.4 关键问题解决)
[3. 封装实现unordered_map和unordered_set](#3. 封装实现unordered_map和unordered_set)
[3.2 迭代器实现](#3.2 迭代器实现)
上一篇:从零开始的C++学习生活 14:map/set的使用和封装-CSDN博客
前言
红黑树和AVL树都是高级的增删查改的数据结构,我们甚至利用红黑树封装了map和set在C++的标准库容器中供我们使用。
但是树这种结构终究比较复杂,特别是还得保证效率,实现就更复杂了。因此有前人就实现了相对平民点的数据结构-哈希表,也能提供高效的增删查改
并且为了应对时代趋势,我们还会利用哈希表封装unorderd_map和unorderd_set。
无论你是希望在实际项目中合理选择容器类型,还是想要深入理解哈希表这一重要数据结构,我都将为你提供全面的指导。

1. unordered系列容器详解
1.1 unordered_set和unordered_map基本介绍
unordered_set和unordered_map是基于哈希表实现的关联式容器,具有以下特性:
- 平均情况下O(1)的查找、插入、删除效率
- 元素无序存储
- 支持唯一键(unordered_set)或键值对(unordered_map)
cpp
// unordered_set声明
template <class Key, // key_type/value_type
class Hash = hash<Key>, // hasher
class Pred = equal_to<Key>, // key_equal
class Alloc = allocator<Key> // allocator_type
> class unordered_set;
// unordered_map声明
template <class Key, // key_type
class T, // mapped_type
class Hash = hash<Key>, // hasher
class Pred = equal_to<Key>, // key_equal
class Alloc = allocator<pair<const Key,T>> // allocator_type
> class unordered_map;
1.2 与map/set的主要差异
1. 对Key的要求不同
map/set要求:
- Key支持小于比较(<运算符)
unordered_map/unordered_set要求:
- Key支持转换为整型(用于哈希计算)
- Key支持相等比较(==运算符)
哈希表的底层实际是vector,需要进行下标的处理,因此key必须得转换成无符号整型,而我们日常传递的key肯定不只是无符号整型,还会是有符号整型,string或者是其他的自定义类型,因此我们必须传递把对应的key转换成无符号整型和比较大小的仿函数,如果不传递就用默认的函数,默认你是无符号整型然后进行处理。
2. 迭代器差异
map/set:
- 双向迭代器
- 遍历时按键升序排列
unordered_map/unordered_set:
- 单向迭代器
- 遍历时无序
在我们之后封装哈希表时就会知道,数据存在哈希表中是无需的,不是红黑树中的有序
1.3 基本使用示例
无论是unordered_set和set还是unordered_map和map在实际使用时没有太大的差别
unordered_set使用
cpp
#include <iostream>
#include <unordered_set>
#include <string>
using namespace std;
void unordered_set_demo() {
unordered_set<int> us = {4, 2, 7, 2, 8, 5, 9};
// 插入元素
us.insert(3);
us.insert({1, 6, 10});
// 遍历(无序)
for (const auto& elem : us) {
cout << elem << " ";
}
cout << endl;
// 查找
if (us.find(5) != us.end()) {
cout << "5 found" << endl;
}
// 删除
us.erase(2);
cout << "Size after erase: " << us.size() << endl;
// 统计
cout << "Bucket count: " << us.bucket_count() << endl;
cout << "Load factor: " << us.load_factor() << endl;
}
unordered_map使用
cpp
#include <iostream>
#include <unordered_map>
#include <string>
using namespace std;
void unordered_map_demo() {
unordered_map<string, string> dict = {
{"apple", "苹果"},
{"banana", "香蕉"},
{"orange", "橙子"}
};
// 插入
dict.insert({"grape", "葡萄"});
dict["peach"] = "桃子";
// 遍历
for (const auto& pair : dict) {
cout << pair.first << ": " << pair.second << endl;
}
// 查找和修改
if (dict.find("apple") != dict.end()) {
dict["apple"] = "苹果🍎"; // 修改
}
// 统计单词频率
vector<string> words = {"apple", "banana", "apple", "orange", "banana", "apple"};
unordered_map<string, int> word_count;
for (const auto& word : words) {
word_count[word]++;
}
for (const auto& pair : word_count) {
cout << pair.first << ": " << pair.second << endl;
}
}
1.4 哈希相关接口
unordered系列容器提供了一些与哈希策略相关的接口:
cpp
unordered_set<int> us;
// 桶相关接口
cout << "Bucket count: " << us.bucket_count() << endl;
cout << "Max bucket count: " << us.max_bucket_count() << endl;
// 负载因子相关
cout << "Load factor: " << us.load_factor() << endl;
cout << "Max load factor: " << us.max_load_factor() << endl;
// 设置最大负载因子
us.max_load_factor(0.8f);
// 重整哈希表,减少冲突
us.rehash(1000); // 设置至少1000个桶
us.reserve(1000); // 预留至少1000个元素的空间
关于这些哈希表特有的术语,我们后面会特地地讲到
2. 哈希表底层原理
2.1 哈希概念
哈希(Hash)又称散列,是一种通过哈希函数建立关键字Key与存储位置映射关系的数据组织方式。
哈希表中底层是vector,所以是一个数组。说叫散列,就是因为数据放在哈希表中是相对无序的。但是无序就是完全的瞎搞吗?当然不是,我们可以联想到之前所使用的计数排序,还记得计数排序的时间效率吗?力压群雄,但唯一的缺点就是空间复杂度会开得太多。如果1和10000利用计数排序,就会开辟十万的空间,但实际上只有两个有效数据。
哈希冲突
在哈希表中,对于同样的key,我们可以key = key%_capacity,其中_capacity是vector的容量大小。对于初始容量,我们设置一个值,假设就1和100000有两个值,那么我们先设两个空间看看,之后1%2 = 1,100000%2 = 0,然后按照下标放到vector中,是不是就解决了空间问题?
看起来是这么简单,但实际上坑还是比较多的。如果1和3怎么说? 3%2=1,不就和1冲突了吗,这就是我们所说的哈希冲突,即多个不同的数据最后key值相同。
负载因子
负载因子 = 元素个数 / 哈希表大小
- 负载因子越大:哈希冲突概率越高,空间利用率越高
- 负载因子越小:哈希冲突概率越低,空间利用率越低
负载因子是我们设计出来来避免哈希冲突的一个变量
将key转为整数
之前说过,我们传的key不可能都是无符号整型,因此我们得自己设计一个仿函数来把key转换成无符号整型
2.2 哈希函数
哈希函数是我们设计出来来尽量避免哈希冲突的方式,是用来对key的加工处理,然后作下标
2.2.1 除法散列法/除留余数法
除法散列法也叫做除留余数法,顾名思义,假设哈希表的大小为M,那么通过key除以M的余数作为 映射位置的下标,也就是哈希函数为:h(key)=key%M。
当使用除法散列法时,要尽量避免M为某些值,如2的幂,10的幂等。因为key%2^x本质相当于保留key的二进制的后x位,那么后x位相同的值,计算出的哈希值都是⼀样的,就冲突了。因此我们最好使用距离2的幂较远且为素数的值
2.2.2 乘法散列法
乘法散列法对哈希表大小M没有要求,这个方法的大思路第一步:用关键字K乘上常数A(0<A<1),并抽 取出k*A的小数部分。第二步:后再用M乘以k*A的小数部分,再向下取整。其中我们一般使用常数A为黄金分割比例,即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。
2.3 处理哈希冲突
我们所用的哈希函数只能够尽量避免哈希冲突,但无法完全处理。还是碰到哈希冲突时,我们就得想办法处理
2.3.1 开放定址法
在开放定址法中所有的元素都放到哈希表里,当⼀个关键字key用哈希函数计算出的位置冲突了,则按照某种规则找到一个没有存储数据的位置进行存储,开放定址法中负载因子一定是小于的。这里的规则有三种:线性探测、二次探测、双重探测。
线性探测
1.从发生冲突的位置开始,依次线性向后 探测,直到寻找到下⼀个没有存储数据的位置为止,如果走到哈希表尾,则回绕到哈希表头的位置。
h(key) = hash0 = key % M hc(key,i) = hashi = (hash0+i) % M , i = {1,2,3,...,M −1} , hash0位置冲突了,则线性探测公式为: ,因为负载因子小于1, 则最多探测M-1次,一定能找到⼀个存储key的位置。
线性探测的比较简单且容易实现,线性探测的问题假设hash0位置连续冲突,hash0,hash1, hash2位置已经存储数据了,后续映射到hash0,hash1,hash2,hash3的值都会争夺hash3位 置,这种现象叫做群集/堆积。下面的⼆次探测可以⼀定程度改善这个问题。

二次探测
从发生冲突的位置开始,依次左右按二次方跳跃式探测,直到寻找到下⼀个没有存储数据的位置为 止,如果往右走到哈希表尾,则回绕到哈希表头的位置;如果往左走到哈希表头,则回绕到哈希表 尾的位置;
h(key) = hash0 = key % M , hash0位置冲突了,则二次探测公式为: 2 hc(key,i) = hashi = (hash0±i ) % M , i = 2 hashi = (hash0−i )%M M {1,2,3,..., M/2}
当hashi<0时,需要hashi+=M
开放定址法代码实现
开放定址法在实践中,不如下面讲的链地址法,因为开放定址法解决冲突不管使用哪种方法,占用的都是哈希表中的空间,始终存在互相影响的问题。所以开放定址法,我们简单选择线性探测实现即可。
cpp
namespace open_address {
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 Hash = HashFunc<K>>
class HashTable {
private:
vector<HashData<K, V>> _tables;
size_t _n = 0; // 元素个数
public:
bool Insert(const pair<K, V>& kv) {
if (Find(kv.first)) return false;
// 负载因子 > 0.7 时扩容
if (_n * 10 / _tables.size() >= 7) {
// 扩容逻辑...
}
Hash hash;
size_t hashi = hash(kv.first) % _tables.size();
size_t i = 1;
// 线性探测寻找空位置
while (_tables[hashi]._state == EXIST) {
hashi = (hashi + i) % _tables.size();
++i;
}
_tables[hashi]._kv = kv;
_tables[hashi]._state = EXIST;
++_n;
return true;
}
HashData<K, V>* Find(const K& key) {
Hash hash;
size_t hashi = hash(key) % _tables.size();
size_t i = 1;
while (_tables[hashi]._state != EMPTY) {
if (_tables[hashi]._state == EXIST &&
_tables[hashi]._kv.first == key) {
return &_tables[hashi];
}
hashi = (hashi + i) % _tables.size();
++i;
}
return nullptr;
}
};
}
2.3.2 链地址法(哈希桶)
链地址法相当于是vector中的每个元素都变成了链表,冲突的元素利用链表连接即可,因此也被形象地称为哈希桶

链地址法代码实现
cpp
namespace hash_bucket {
template<class T>
struct HashNode {
T _data;
HashNode<T>* _next;
HashNode(const T& data)
: _data(data)
, _next(nullptr)
{}
};
template<class K, class T, class KeyOfT, class Hash>
class HashTable {
private:
vector<HashNode<T>*> _tables;
size_t _n = 0;
public:
bool Insert(const T& data) {
KeyOfT kot;
if (Find(kot(data))) return false;
// 负载因子 = 1 时扩容
if (_n == _tables.size()) {
vector<HashNode<T>*> new_tables(GetNextPrime(_tables.size()), nullptr);
// 重新哈希所有元素...
_tables.swap(new_tables);
}
Hash hs;
size_t hashi = hs(kot(data)) % _tables.size();
// 头插法
HashNode<T>* new_node = new HashNode<T>(data);
new_node->_next = _tables[hashi];
_tables[hashi] = new_node;
++_n;
return true;
}
// 其他方法...
};
}
2.4 关键问题解决
Key转换为整型
Key有多种数据,在这里我们用string为例。转换成整型的一个最好的条件就是避免哈希冲突,因此我们需要设计出合理的转换方式,例如对于string,我们可以每次加上字符的ASCII码值,然后乘上131(前人大佬的方法)
cpp
template<class K>
struct HashFunc {
size_t operator()(const K& key) {
return (size_t)key;
}
};
// string特化
template<>
struct HashFunc<string> {
size_t operator()(const string& key) {
// BKDR哈希算法
size_t hash = 0;
for (auto ch : key) {
hash *= 131;
hash += ch;
}
return hash;
}
};
质数表扩容
我们之前说过,哈希表的容量最好是跟2的幂相远并且最好也是素数,那么就有人专门发明了一个素数表供我们使用
cpp
inline unsigned long GetNextPrime(unsigned long n) {
static const int num_primes = 28;
static const unsigned long prime_list[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 = prime_list;
const unsigned long* last = prime_list + num_primes;
const unsigned long* pos = lower_bound(first, last, n);
return pos == last ? *(last - 1) : *pos;
}
3. 封装实现unordered_map和unordered_set
unordered_map和unordered_set的底层都是哈希表
cpp
// MyUnorderedSet.h
template<class K, class Hash = HashFunc<K>>
class unordered_set {
struct SetKeyOfT {
const K& operator()(const K& key) {
return key;
}
};
public:
bool insert(const K& key) {
return _ht.Insert(key);
}
private:
hash_bucket::HashTable<K, K, SetKeyOfT, Hash> _ht;
};
// MyUnorderedMap.h
template<class K, class V, class Hash = HashFunc<K>>
class unordered_map {
struct MapKeyOfT {
const K& operator()(const pair<K, V>& kv) {
return kv.first;
}
};
public:
bool insert(const pair<K, V>& kv) {
return _ht.Insert(kv);
}
V& operator[](const K& key) {
auto ret = _ht.Insert(make_pair(key, V()));
return ret.first->second;
}
private:
hash_bucket::HashTable<K, pair<K, V>, MapKeyOfT, Hash> _ht;
};
3.2 迭代器实现
哈希表迭代器的核心在于operator++的实现
为了高效处理,我们所用的哈希表是链地址法,因此每个哈希节点都是链表
那么++就以下两种情况:
1.在链表里面++
2.已走到当前链表的尾部,需要跳到下一个不为空的链表中
那么如何找到下一个不为空的链表?这里我在迭代器内加上哈希表的地址和当前位置的下标,方便我们查找
cpp
template<class T,class Ptr,class Ref>
struct HashTableIterator {
typedef HashNode<T> Node;
typedef HashTableIterator<T, Ptr, Ref> Self;
Node* _node;//哈希表的节点
typename list<T>::iterator _it;//迭代器本体
vector<Node>* _tables;////哈希表指针
size_t _bucket_index;//当前位置的下标
//......
}
cpp
Self& operator++()
{
if (_node && _it != _node->_kv.end()) {//不为末尾在链表内迭代
++_it;
}
if (_it == _node->_kv.end())//为末尾则跳到下一个不为空的链表中
{
while (_node)
{
++_bucket_index;
if (_bucket_index < _tables->size())
{
_node = &(*_tables)[_bucket_index];
if (!_node->_kv.empty()) {
_it = _node->_kv.begin();
break;
}
}
else
{
_node = nullptr;
_it = typename list<T>::iterator();
break;
}
}
}
return *this;
}
实现unordered_map和unordered_set的基本方法跟map和set差别不大,再次不在过多赘述
哈希表完整实现
cpp
namespace hash_bucket {
template<class K, class T, class KeyOfT, class Hash>
class HashTable {
template<class K, class T, class Ptr, class Ref, class KeyOfT, class Hash>
friend struct HTIterator;
typedef HashNode<T> Node;
public:
typedef HTIterator<K, T, T*, T&, KeyOfT, Hash> Iterator;
typedef HTIterator<K, T, const T*, const T&, KeyOfT, Hash> ConstIterator;
Iterator Begin() {
for (size_t i = 0; i < _tables.size(); i++) {
Node* cur = _tables[i];
if (cur) {
return Iterator(cur, this);
}
}
return End();
}
Iterator End() {
return Iterator(nullptr, this);
}
pair<Iterator, bool> Insert(const T& data) {
KeyOfT kot;
Iterator it = Find(kot(data));
if (it != End()) {
return make_pair(it, false);
}
// 扩容逻辑...
Hash hs;
size_t hashi = hs(kot(data)) % _tables.size();
// 头插
Node* newnode = new Node(data);
newnode->_next = _tables[hashi];
_tables[hashi] = newnode;
++_n;
return make_pair(Iterator(newnode, this), true);
}
Iterator Find(const K& key) {
KeyOfT kot;
Hash hs;
size_t hashi = hs(key) % _tables.size();
Node* cur = _tables[hashi];
while (cur) {
if (kot(cur->_data) == key) {
return Iterator(cur, this);
}
cur = cur->_next;
}
return End();
}
private:
vector<Node*> _tables;
size_t _n = 0;
};
}
unordered_map完整实现
cpp
template<class K, class V, class Hash = HashFunc<K>>
class unordered_map {
struct MapKeyOfT {
const K& operator()(const pair<K, V>& kv) {
return kv.first;
}
};
public:
typedef typename hash_bucket::HashTable<K, pair<const K, V>,
MapKeyOfT, Hash>::Iterator iterator;
iterator begin() {
return _ht.Begin();
}
iterator end() {
return _ht.End();
}
pair<iterator, bool> insert(const pair<K, V>& kv) {
return _ht.Insert(kv);
}
V& operator[](const K& key) {
pair<iterator, bool> ret = _ht.Insert(make_pair(key, V()));
return ret.first->second;
}
iterator find(const K& key) {
return _ht.Find(key);
}
bool erase(const K& key) {
return _ht.Erase(key);
}
private:
hash_bucket::HashTable<K, pair<const K, V>, MapKeyOfT, Hash> _ht;
};
void test_unordered_map() {
unordered_map<string, string> dict;
dict.insert({"sort", "排序"});
dict.insert({"left", "左边"});
dict.insert({"right", "右边"});
dict["left"] = "左边,剩余";
dict["insert"] = "插入";
for (auto it = dict.begin(); it != dict.end(); ++it) {
// it->first 是const,不能修改
// it->second 可以修改
it->second += "x";
cout << it->first << ": " << it->second << endl;
}
}
}
总结
核心知识点总结
-
性能优势:unordered_map/unordered_set在平均情况下提供O(1)的查找、插入、删除性能,在大多数场景下优于map/set的O(logN)性能。
-
数据结构选择:
- 需要有序遍历:选择map/set
- 追求极致性能:选择unordered_map/unordered_set
- 内存敏感:根据实际情况测试选择
-
哈希表设计要点:
- 优秀的哈希函数减少冲突
- 合理的负载因子控制空间效率
- 合适的冲突解决策略
-
工程实践:
- 预分配空间(reserve)提升性能
- 自定义哈希函数优化特定类型
- 监控负载因子避免性能退化
实际应用建议
-
字符串处理:unordered_map<string, T>在词频统计、缓存实现等场景表现优异。
-
大数据处理:在需要快速查找的海量数据场景中,哈希表的O(1)平均复杂度优势明显。
-
缓存系统:LRU Cache等缓存系统通常基于哈希表+链表的组合实现。
哈希表作为计算机科学中最重要的数据结构之一,其思想和应用贯穿于各个领域。通过深入理解其原理和实现,我们不仅能够更好地使用STL提供的容器,还能够在需要时自己实现特定优化的哈希结构,解决实际问题。