STL源码解析之哈希(2)

一、插入流程

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)

    实际实现常迭代计算:

    cpp 复制代码
    size_t h = 0;
    for (char c : str)
        h = h * p + c;        // p 常用质数 31 或 131

经典算法

  • DJB2h = 5381; h = h * 33 + c

  • SDBMh = c + (h << 6) + (h << 16) - h

  • MurmurHashCityHashxxHash:现代高速非加密哈希,对字符串和二进制数据极快。

  • C++ 标准库的 std::hash<std::string> 实现方式未规定,常见编译器使用 MurmurHash 变种或类似多项式的方法。

3) 复合键(多个字段)

需要将多个成员的哈希值"组合"成一个。直接相加或异或容易导致冲突((a,b)(b,a) 同哈希)。常用组合方法:

  • boost::hash_combine 的模式:

    cpp 复制代码
    size_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::stringstd::wstringstd::u16stringstd::u32string

  • std::bitset

  • std::vector<bool>(C++11)

  • std::error_codestd::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) 威力。