C++
手写哈希表内核
使用独立链地址、负载因子和 rehash 实现通用哈希索引,为 unordered 容器提供共享内核。
发布于 2026年7月23日
手写哈希表内核
使用独立链地址、负载因子和 rehash 实现通用哈希索引,为 unordered 容器提供共享内核。
本系列代码使用 C++20 和
oc::handmade命名空间,目标是解释实现机制、复杂度和工程边界,不是替代标准库。普通容器与缓存核心不内置互斥锁;这不代表 lock-free。
一、学习目标
- 实现 bucket 与独立链地址
- 理解负载因子和 rehash
- 区分平均复杂度与最坏复杂度
二、前置条件
熟悉哈希函数、链表和异常安全。
Linux/macOS:
cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug
cmake --build build -j
ctest --test-dir build --output-on-failure
Windows PowerShell:
cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug
cmake --build build --config Debug
ctest --test-dir build -C Debug --output-on-failure
三、问题与设计选择
桶数组保存节点链首指针,节点保存缓存哈希值、键值和 next。rehash 先分配新桶,再只重连节点,不重新构造元素。
这里刻意保留一条边界:教学实现覆盖构造、复制移动、核心修改、查找和迭代契约,但不复刻标准库全部重载、ABI、constexpr、异构查找或节点句柄。
四、内存布局与核心不变量
每个节点只属于 hash % bucket_count 对应的链;节点总数等于 size;链最终到 nullptr。
每个修改操作都按“准备资源 → 构造新状态 → 提交连接或指针 → 清理旧状态”的顺序设计。提交点之前发生异常,应保持原对象可继续使用;无法提供强保证时,会在接口说明中明确基本保证。
五、核心实现
std::size_t bucket_for(std::size_t hash) const noexcept {
return hash % buckets_.size();
}
void maybe_rehash() {
if (size_ + 1 > buckets_.size() * max_load_factor_)
rehash(buckets_.size() * 2);
}
上面先聚焦最容易写错的核心步骤;若本篇对应一个独立组件,下一节给出统一工程中的完整教学实现。代码没有放入 std 命名空间,避免未定义行为和名称冲突。
六、完整教学实现
下面是统一工程中经过 GCC、Clang、GoogleTest 和 Sanitizer 验证的完整组件。它依赖前序文章已经实现的公共类型以及头文件中的标准库 #include。
namespace oc::handmade {
template<class Key, class T, class Hash = std::hash<Key>, class Equal = std::equal_to<Key>>
class unordered_map {
struct node {
std::size_t hash;
Key key;
T value;
std::unique_ptr<node> next;
node(std::size_t hash_value, Key key_value, T mapped)
: hash(hash_value), key(std::move(key_value)), value(std::move(mapped)) {}
};
std::vector<std::unique_ptr<node>> buckets_;
std::size_t size_{};
float max_load_{1.0F};
Hash hash_{};
Equal equal_{};
std::size_t bucket(std::size_t hash) const noexcept { return hash % buckets_.size(); }
void maybe_rehash() {
const long double projected = static_cast<long double>(size_ + 1);
const long double threshold =
static_cast<long double>(buckets_.size()) *
static_cast<long double>(max_load_);
if (projected > threshold)
rehash(buckets_.size() * 2);
}
public:
explicit unordered_map(std::size_t buckets = 8)
: buckets_(std::max<std::size_t>(1, buckets)) {}
unordered_map(unordered_map&&) noexcept = default;
unordered_map& operator=(unordered_map&&) noexcept = default;
unordered_map(const unordered_map&) = delete;
unordered_map& operator=(const unordered_map&) = delete;
std::size_t size() const noexcept { return size_; }
bool empty() const noexcept { return size_ == 0; }
std::size_t bucket_count() const noexcept { return buckets_.size(); }
float load_factor() const noexcept {
return static_cast<float>(size_) / static_cast<float>(buckets_.size());
}
T* find(const Key& key) {
const auto hashed = hash_(key);
for (node* current = buckets_[bucket(hashed)].get(); current; current = current->next.get())
if (current->hash == hashed && equal_(current->key, key)) return ¤t->value;
return nullptr;
}
const T* find(const Key& key) const { return const_cast<unordered_map*>(this)->find(key); }
bool contains(const Key& key) const { return find(key) != nullptr; }
std::pair<T*, bool> insert(Key key, T value) {
if (T* old = find(key)) return {old, false};
maybe_rehash();
const auto hashed = hash_(key);
const auto index = bucket(hashed);
auto fresh = std::make_unique<node>(hashed, std::move(key), std::move(value));
T* result = &fresh->value;
fresh->next = std::move(buckets_[index]);
buckets_[index] = std::move(fresh);
++size_;
return {result, true};
}
T& operator[](Key key) {
if (T* old = find(key)) return *old;
return *insert(std::move(key), T{}).first;
}
bool erase(const Key& key) {
const auto hashed = hash_(key);
auto* link = &buckets_[bucket(hashed)];
while (*link) {
if ((*link)->hash == hashed && equal_((*link)->key, key)) {
*link = std::move((*link)->next);
--size_;
return true;
}
link = &(*link)->next;
}
return false;
}
void rehash(std::size_t requested) {
std::vector<std::unique_ptr<node>> fresh(std::max<std::size_t>(1, requested));
for (auto& chain : buckets_) {
while (chain) {
auto current = std::move(chain);
chain = std::move(current->next);
const auto index = current->hash % fresh.size();
current->next = std::move(fresh[index]);
fresh[index] = std::move(current);
}
}
buckets_ = std::move(fresh);
}
};
} // namespace oc::handmade
生产级标准库还要处理完整 allocator 传播、全部重载、ABI、调试迭代器和平台特化;这里保留的是能够独立推导核心数据结构的教学边界。
七、使用示例与输出
预期输出或状态:
初始 4 桶插入第 5 个元素时扩到 8 桶;所有键仍能找到,节点地址保持不变。
示例必须在文章对应的测试目标中实际编译。涉及顺序的输出只依赖接口明确承诺的顺序;无序容器不会把某次桶顺序写成稳定结果。
八、复杂度与失效规则
| 操作 | 复杂度 | 说明 |
|---|---|---|
| find/insert/erase | 平均 O(1),最坏 O(n) | 依赖哈希分布 |
| rehash | O(n) | 重连节点 |
| bucket_count | O(1) | 至少为 1 |
| 遍历 | O(n+b) | b 为桶数 |
复杂度中的 O(1) 若标记为“平均”或“摊还”,不能在面试中省略限定词。任何重新分配、节点删除、rehash 或缓存淘汰都必须单独说明迭代器、引用与指针是否失效。
九、异常安全与资源管理
- 获取资源后立即交给 RAII 对象或明确记录已构造数量。
- 用户类型构造、复制、移动、比较器和哈希器都可能抛异常。
- 只有在所有后续步骤不会失败时才修改不可回滚的链接。
- 析构、释放和关闭路径不得抛异常。
- 并发包装通过回调在锁内访问,避免返回保护对象的裸引用。
十、常见错误
1. 把 hash 值直接当数组下标
把 hash 值直接当数组下标会破坏本篇建立的契约。调试时先检查核心不变量,再缩小到触发该状态的最短操作序列。
2. rehash 后继续使用旧桶迭代器
rehash 后继续使用旧桶迭代器会破坏本篇建立的契约。调试时先检查核心不变量,再缩小到触发该状态的最短操作序列。
3. 只讨论平均 O(1) 而忽略恶意碰撞
只讨论平均 O(1) 而忽略恶意碰撞会破坏本篇建立的契约。调试时先检查核心不变量,再缩小到触发该状态的最短操作序列。
十一、面试追问
- 独立链地址与开放寻址如何取舍?
- 为何 rehash 能保持节点引用稳定?
- 负载因子过大或过小各有什么代价?
回答时先说数据结构不变量,再给复杂度,最后说明异常、迭代器或并发边界,通常比背诵结论更有说服力。
十二、练习与自测
- 缓存节点哈希值
- 实现 reserve
- 用恒定哈希函数验证最坏路径
自测标准:能够不看代码画出内存或节点关系,解释一次成功操作和一次失败回滚,并写出至少一个会击穿错误实现的测试。
十三、官方资料与延伸阅读
上一篇:手写 std::multimap | 下一篇:手写 std::unordered_set