浏览知识库目录

C++

手写哈希表内核

使用独立链地址、负载因子和 rehash 实现通用哈希索引,为 unordered 容器提供共享内核。

手写哈希表内核

使用独立链地址、负载因子和 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 &current->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) 而忽略恶意碰撞会破坏本篇建立的契约。调试时先检查核心不变量,再缩小到触发该状态的最短操作序列。


十一、面试追问

  1. 独立链地址与开放寻址如何取舍?
  2. 为何 rehash 能保持节点引用稳定?
  3. 负载因子过大或过小各有什么代价?

回答时先说数据结构不变量,再给复杂度,最后说明异常、迭代器或并发边界,通常比背诵结论更有说服力。


十二、练习与自测

  1. 缓存节点哈希值
  2. 实现 reserve
  3. 用恒定哈希函数验证最坏路径

自测标准:能够不看代码画出内存或节点关系,解释一次成功操作和一次失败回滚,并写出至少一个会击穿错误实现的测试。


十三、官方资料与延伸阅读


上一篇:手写 std::multimap | 下一篇:手写 std::unordered_set