一、插入流程
cpp
// 1. 计算哈希值和桶索引
size_t __hash = _M_hash(__key);
size_t __bucket_idx = _M_bucket_index(__hash);
// 2. 检查负载因子,决定是否重哈希
if (size() + 1 > _M_max_load_factor * _M_bucket_count) {
_M_rehash(next_size(_M_bucket_count));
// 重哈希后桶数改变,需重新计算桶索引
__bucket_idx = _M_bucket_index(__hash);
}
// 3. 在桶链表中查找是否已存在相同键
__node_ptr __p = _M_find_node(__bucket_idx, __key, __hash);
if (__p) return iterator(__p); // 已存在,插入失败(对于 unique 容器)
// 4. 分配新节点
__node_ptr __new_node = _M_allocate_node(std::forward<_Args>(__args)...);
// 5. 插入到全局链表头部(便于实现 LIFO)
__new_node->_M_nxt = _M_begin();
_M_before_begin->_M_nxt = __new_node;
// 6. 插入到桶链表头部
__new_node->_M_nxt = _M_buckets[__bucket_idx];
_M_buckets[__bucket_idx] = __new_node;
++_M_element_count;
return iterator(__new_node);
-
新节点总是插入到桶链表的 头部(常数时间)。
-
全局链表也插入头部,所以迭代顺序与插入顺序相反(但无序容器不保证顺序)。
-
先检查负载因子再插入,可以避免元素个数超过阈值后仍不扩容。
二、查找
cpp
__node_ptr _M_find_node(size_t __bucket_idx, const _Key& __key, size_t __hash) const {
__node_ptr __p = static_cast<__node_ptr>(_M_buckets[__bucket_idx]);
while (__p) {
if (_M_equals(_M_extract(__p->_M_storage), __key))
return __p;
__p = __p->_M_nxt;
}
return nullptr;
}
-
直接根据哈希值定位桶,遍历该桶的链表。
-
相等比较使用
_Equal仿函数。
三、删除操作
cpp
void erase(const_iterator __it) {
__node_ptr __n = __it._M_cur;
size_t __bucket_idx = _M_bucket_index(_M_hash(_M_extract(__n->_M_storage)));
// 从桶链表中移除 __n
__node_ptr __prev_in_bucket = ...; // 需找到前驱,可能遍历桶链表
if (__prev_in_bucket)
__prev_in_bucket->_M_nxt = __n->_M_nxt;
else
_M_buckets[__bucket_idx] = __n->_M_nxt;
// 从全局链表中移除 __n
__node_ptr __prev_global = ...; // 找到全局链表中的前驱
__prev_global->_M_nxt = __n->_M_nxt;
// 销毁并释放节点
_M_deallocate_node(__n);
--_M_element_count;
}
-
需要维护两条链表:桶链表和全局链表。
-
为了提高删除效率,标准库可能会使用双向链表或者维护前驱指针,但 libstdc++ 早期版本通过
_M_before_begin和遍历方式 实现。
四、哈希函数
哈希函数 是哈希表的核心引擎。它负责把任意类型的键(字符串、整数、自定义对象等)转换成一个固定范围内的整数,这个整数称为哈希值 。
哈希表再用这个哈希值通过取模等运算定位到数组中的具体桶位。
一个好的哈希函数必须满足:
| 性质 | 说明 |
|---|---|
| 确定性 | 同一键永远产生相同的哈希值。 |
| 均匀分布 | 哈希值应尽量均匀地散布在输出空间,避免大量键映射到同一桶(降低冲突)。 |
| 高效计算 | 计算速度要快,否则会抵消 O(1) 的优势。 |
| 雪崩效应 | 输入微小变化应导致哈希值剧烈变化,减小规律性冲突。 |
常见哈希函数设计方法
1) 整数键
-
直接使用键本身或简单变换。
-
取模 :
hash = key % P(P 为质数),但需注意 key 的分布。 -
乘法哈希 :
hash = floor( M * ( key * A mod 1 ) ),其中 A 是 0~1 的常数(如 Knuth 推荐的黄金分割数倒数),M 为桶数。 -
C++ 标准库为整数类型直接返回原值(
std::hash<int>实质是恒等映射),但桶索引的取模由_Mod_range_hashing完成。
2) 字符串键
需要将字符序列组合成一个整数,常用方法:
-
简单累加 :
h = s[0] + s[1] + ... + s[n-1],但冲突严重("abc" 与 "cba" 同值)。 -
多项式滚动哈希(常用):
h = s0 * p^(n-1) + s1 * p^(n-2) + ... + sn-1 (mod M)
实际实现常迭代计算:
cppsize_t h = 0; for (char c : str) h = h * p + c; // p 常用质数 31 或 131
经典算法:
-
DJB2 :
h = 5381; h = h * 33 + c -
SDBM :
h = c + (h << 6) + (h << 16) - h -
MurmurHash 、CityHash 、xxHash:现代高速非加密哈希,对字符串和二进制数据极快。
-
C++ 标准库的
std::hash<std::string>实现方式未规定,常见编译器使用 MurmurHash 变种或类似多项式的方法。
3) 复合键(多个字段)
需要将多个成员的哈希值"组合"成一个。直接相加或异或容易导致冲突((a,b) 与 (b,a) 同哈希)。常用组合方法:
-
boost::hash_combine 的模式:
cppsize_t seed = 0; seed ^= hash1 + 0x9e3779b9 + (seed << 6) + (seed >> 2); seed ^= hash2 + 0x9e3779b9 + (seed << 6) + (seed >> 2); ...其中
0x9e3779b9是黄金分割数相关的魔数。 -
C++17 之后 ,可使用
std::hash特化结合折叠表达式(但实际仍需手动实现组合逻辑,因为没有标准库提供)。
标准库的 std::hash<T>
定义在 <functional>,是一个函数对象模板。标准库为以下类型提供了特化:
-
所有整型、浮点型
-
指针
-
std::string、std::wstring、std::u16string、std::u32string -
std::bitset -
std::vector<bool>(C++11) -
std::error_code、std::thread::id等
默认特化的调用方式:
cpp
std::hash<std::string> hasher;
size_t h = hasher("hello");
实际应用
C++ 标准库在 C++11 起提供了可以直接使用的哈希容器:
-
std::unordered_map<Key, T>------ 键值对集合,键唯一。 -
std::unordered_set<Key>------ 唯一键的集合。 -
std::unordered_multimap<Key, T>------ 键值对集合,允许重复键。 -
std::unordered_multiset<Key>------ 允许重复键的集合。
它们分别定义在头文件 <unordered_map> 和 <unordered_set> 中。
声明如下:
cpp
std::unordered_map<
Key, // 键类型
T, // 值类型
Hash = std::hash<Key>, // 哈希函数对象
KeyEqual = std::equal_to<Key>, // 相等比较
Allocator = std::allocator<std::pair<const Key, T>> // 分配器
> map;
-
平均时间复杂度 :插入、查找、删除均为 **O(1)**,最坏情况(严重冲突)退化为 O(n)。
-
无序性 :元素存储顺序由哈希函数决定,不保证任何排序 (区别于基于红黑树的
std::map)。 -
键要求 :键类型必须支持 哈希函数 (默认
std::hash)和 相等比较 (默认==)。
cpp
std::unordered_map<int, std::string> m;
size_t buckets = m.bucket_count(); // 当前桶数
float lf = m.load_factor(); // 当前负载因子
float mlf = m.max_load_factor(); // 最大负载因子(默认为1.0)
m.rehash(100); // 强制重哈希,使桶数至少为 100
m.reserve(1000); // 预留至少能容纳 1000 个元素的空间(避免多次重哈希)
// 查看特定桶的信息
size_t bucket_idx = m.bucket(key); // 键所在的桶索引
size_t bucket_size = m.bucket_size(n); // 第 n 号桶中的元素个数
如果预先知道大概要插入多少元素,调用**reserve(n)**可以避免多次高昂的重哈希操作。
自定义哈希函数
若使用自定义类型作为 unordered_map 的键,有两种方式:
方式 A:特化 std::hash(推荐)
cpp
struct Point { int x, y; };
bool operator==(const Point& a, const Point& b) {
return a.x == b.x && a.y == b.y;
}
namespace std {
template<> struct hash<Point> {
size_t operator()(const Point& p) const noexcept {
size_t h1 = hash<int>{}(p.x);
size_t h2 = hash<int>{}(p.y);
return h1 ^ (h2 << 1); // 简单组合
}
};
}
// 直接使用
std::unordered_map<Point, int> m;
方式 B:提供自定义哈希函数对象
cpp
struct PointHash {
size_t operator()(const Point& p) const {
return std::hash<int>()(p.x) ^ (std::hash<int>()(p.y) << 1);
}
};
std::unordered_map<Point, int, PointHash> m;
方式 A 的好处是无需在容器模板参数中额外指定哈希,保持接口简洁。
异构查找的支持(C++14/20)
当希望用与键类型不同的类型进行查找时(例如用 std::string_view 查找 std::string 键),需要给哈希函数和相等函数添加 is_transparent 标记:
cpp
struct StringHash {
using is_transparent = void; // 启用异构查找
size_t operator()(std::string_view sv) const { return std::hash<std::string_view>{}(sv); }
size_t operator()(const std::string& s) const { return std::hash<std::string>{}(s); }
};
攻击与安全性:非加密哈希的脆弱性
-
非加密哈希(如 MurmurHash、
std::hash)易受 哈希碰撞攻击:攻击者刻意构造大量产生相同哈希值的键,使哈希表退化为链表,导致服务器 O(n) 插入/查找,引发拒绝服务。 -
解决:
-
使用带随机种子的哈希(如 C++ 标准库允许
std::hash在每次运行时采用不同种子,但并不强制)。 -
使用加密级哈希(如 SipHash)作为键的哈希函数,许多语言(Python、Rust)默认采用。
-
如何选择或设计哈希函数
| 场景 | 推荐 |
|---|---|
| 通用整数/指针 | 默认 std::hash 即可,多数实现是恒等或简单变体。 |
| 字符串键 | 若追求性能且能接受碰撞风险,用 std::hash;若对抗攻击,选择 SipHash。 |
| 自定义聚合类型 | 使用 boost::hash_combine 或基于 std::hash 的位运算组合,保证每个成员都参与哈希。 |
| 超高性能需求 | 使用 xxHash、CityHash 等,并在桶数选择上使用 2 的幂,通过 hash & (size-1) 代替取模。 |
哈希函数与哈希表性能的关系
-
即使哈希表操作理论上是 O(1),一个糟糕的哈希函数会让所有元素挤在少数几个桶里,退化成 O(n)。
-
均匀性比速度更重要------稍慢但分散均匀的哈希,通常优于快速但簇集严重的哈希。
-
负载因子控制和重哈希只是"兜底"方案,良好的哈希函数才能真正发挥 O(1) 威力。