Appearance
手写 flat hashmap(开放寻址 + 线性探测)
把哈希表压进一块连续内存:元素不再逐个分配、探针连续、cache 友好。 代价是 rehash 会搬家(引用失效)、删除必须留墓碑、对哈希质量更敏感。 对比实现见 unordered_map.md(拉链法);另一种连续布局见 手写容器(
flat_map)。
一、题面与澄清
典型要求:实现 FlatHashMap<Key, Value> 的 insert / find / erase / operator[] / size, 说明复杂度与失效规则;追问集中在负载因子、rehash 与删除。
动手前需澄清:
- 删除是否允许使引用/指针失效(线性探测下删除会切断探测链,故需墓碑或后移重排);
Value是否必须可默认构造(operator[]需要;用节点或optional存储可规避);- 负载因子阈值(0.5 保守、0.7 省内存;墓碑须计入);
- 容量是否限定为 2 的幂(是则可用
& (cap-1)代替取模)。
二、接口与约束
- 接口:
insert/find/contains/operator[]/erase/clear/reserve/size/capacity/load_factor/begin/end - 存储:连续数组;槽为「状态字节 +
optional<pair<K,V>>」,状态取「空 / 占用 / 墓碑」三值 - 不变量
- 每个 key 至多占用一个槽;
- 自
hash(key)起的探测链上,在遇到空槽之前必能找到该 key(若存在)——这是墓碑存在的唯一理由。
- 复杂度:
find/insert/erase期望 O(1)、最坏 O(n);rehash O(n);顺序遍历 O(容量) - 迭代器与失效:前向迭代,顺序为槽位顺序(非 key 顺序);rehash 后全部失效,
erase只失效被删元素 - 负载因子:
(size + tombs) / capacity,超过 0.5 时 rehash 到 2 倍容量 - 异常安全:元素存放在
optional中,构造失败不影响表状态 - 前置条件:
Hash与Eq语义一致(Eq(a,b)为真 ⟹Hash(a) == Hash(b))
三、精简版:insert / find / erase(先交这个)
实现范围:最小可用集 ——
insert/find/erase/size,含开放寻址、墓碑与按负载因子增长。 未提供operator[]/clear/reserve/load_factor/capacity/ 迭代器与const重载。
cpp
#pragma once
#include <cstddef>
#include <cstdint>
#include <functional>
#include <optional>
#include <utility>
#include <vector>
template <class K, class V, class Hash = std::hash<K>, class Eq = std::equal_to<K>>
class FlatHashMap {
enum : std::uint8_t { kEmpty = 0, kFull = 1, kTomb = 2 };
struct Slot {
std::uint8_t state = kEmpty;
std::optional<std::pair<K, V>> kv;
};
public:
using size_type = std::size_t;
FlatHashMap() { rehash_to(8); }
size_type size() const noexcept { return size_; }
V* find(const K& k) { // 命中返回指向值的指针,否则 nullptr
const size_type mask = slots_.size() - 1;
size_type i = Hash{}(k) & mask;
for (size_type n = 0; n < slots_.size(); ++n, i = (i + 1) & mask) {
Slot& s = slots_[i];
if (s.state == kEmpty) return nullptr; // 仅空槽终止探查
if (s.state == kFull && Eq{}(s.kv->first, k)) return &s.kv->second;
}
return nullptr;
}
std::pair<V*, bool> insert(K k, V v) { // 已存在 -> {现有值, false}
grow_if_needed();
const size_type mask = slots_.size() - 1;
size_type i = Hash{}(k) & mask;
size_type first_tomb = static_cast<size_type>(-1);
for (size_type n = 0; n < slots_.size(); ++n, i = (i + 1) & mask) {
Slot& s = slots_[i];
if (s.state == kFull) {
if (Eq{}(s.kv->first, k)) return {&s.kv->second, false};
} else if (s.state == kTomb) {
if (first_tomb == static_cast<size_type>(-1)) first_tomb = i;
} else {
const size_type at = first_tomb == static_cast<size_type>(-1) ? i : first_tomb;
if (first_tomb != static_cast<size_type>(-1)) --tombs_;
Slot& d = slots_[at];
d.state = kFull;
d.kv.emplace(std::move(k), std::move(v));
++size_;
return {&d.kv->second, true};
}
}
rehash_to(slots_.size() * 2);
return insert(std::move(k), std::move(v));
}
size_type erase(const K& k) {
const size_type mask = slots_.size() - 1;
size_type i = Hash{}(k) & mask;
for (size_type n = 0; n < slots_.size(); ++n, i = (i + 1) & mask) {
Slot& s = slots_[i];
if (s.state == kEmpty) return 0;
if (s.state == kFull && Eq{}(s.kv->first, k)) {
s.kv.reset();
s.state = kTomb; // 置回 kEmpty 会切断探测链
--size_;
++tombs_;
return 1;
}
}
return 0;
}
private:
void grow_if_needed() {
if (size_ + tombs_ + 1 > slots_.size() / 2) rehash_to(slots_.size() * 2);
}
void rehash_to(size_type n) {
size_type cap = 8;
while (cap < n) cap *= 2;
std::vector<Slot> old = std::move(slots_);
slots_.assign(cap, Slot{});
size_ = 0;
tombs_ = 0;
for (Slot& s : old) {
if (s.state != kFull) continue;
put_into(slots_, size_, s.kv->first, std::move(s.kv->second));
}
}
static void put_into(std::vector<Slot>& slots, size_type& size, const K& k, V&& v) {
const size_type mask = slots.size() - 1;
size_type i = Hash{}(k) & mask;
while (slots[i].state == kFull) i = (i + 1) & mask;
slots[i].state = kFull;
slots[i].kv.emplace(k, std::move(v));
++size;
}
std::vector<Slot> slots_;
size_type size_ = 0;
size_type tombs_ = 0;
};小问答
- 这个版本差在哪? 无
operator[]、无容量接口与load_factor()观测、无迭代器与const重载,接口只够增删查。 - 与
std::unordered_map的差别? 拉链法 vs 开放寻址:本版连续存储、无每元素分配、探针顺序访存;代价是 rehash 搬移元素,且墓碑占位增加探查成本。 - 与完整版的差别? 缺少值初始化插入(
operator[])、容量预留、遍历与负载因子观测。 - 复杂度与失效? 期望 O(1)、最坏 O(n);rehash 会使既有引用/指针失效(本版虽不提供迭代器,但
find返回的指针同样失效)。
四、完整版:全接口 + 迭代器
实现范围:上列全部接口,含前向迭代器与容量/负载因子观测;单写单线程,不含并发保护。 未实现
at、erase(iterator)、swap、merge、自定义分配器与 Robin Hood 等优化变体。
cpp
#pragma once
#include <cstddef>
#include <cstdint>
#include <functional>
#include <optional>
#include <utility>
#include <vector>
template <class K, class V, class Hash = std::hash<K>, class Eq = std::equal_to<K>>
class FlatHashMap {
enum : std::uint8_t { kEmpty = 0, kFull = 1, kTomb = 2 };
struct Slot {
std::uint8_t state = kEmpty;
std::optional<std::pair<K, V>> kv; // 仅在 kFull 时有值,故不要求 K/V 可默认构造
};
public:
using value_type = std::pair<K, V>;
using size_type = std::size_t;
FlatHashMap() { rehash_to(8); }
size_type size() const noexcept { return size_; }
bool empty() const noexcept { return size_ == 0; }
size_type capacity() const noexcept { return slots_.size(); }
double load_factor() const noexcept { // 含墓碑:墓碑同样产生探查成本
return capacity() ? static_cast<double>(size_ + tombs_) / static_cast<double>(capacity()) : 0.0;
}
const V* find(const K& k) const {
if (slots_.empty()) return nullptr;
const size_type mask = capacity() - 1;
size_type i = Hash{}(k) & mask; // 容量为 2 的幂,掩码代替取模
for (size_type probed = 0; probed < capacity(); ++probed, i = (i + 1) & mask) {
const Slot& s = slots_[i];
if (s.state == kEmpty) return nullptr; // 仅空槽终止探查,墓碑不能终止
if (s.state == kFull && Eq{}(s.kv->first, k)) return &s.kv->second;
}
return nullptr;
}
V* find(const K& k) { return const_cast<V*>(static_cast<const FlatHashMap*>(this)->find(k)); }
bool contains(const K& k) const { return find(k) != nullptr; }
std::pair<V*, bool> insert(K k, V v) { // 已存在则不覆盖
grow_if_needed();
return insert_here(std::move(k), std::move(v));
}
V& operator[](const K& k) { // 不存在则值初始化插入
if (V* p = find(k)) return *p;
return *insert(k, V{}).first;
}
size_type erase(const K& k) {
if (slots_.empty()) return 0;
const size_type mask = capacity() - 1;
size_type i = Hash{}(k) & mask;
for (size_type probed = 0; probed < capacity(); ++probed, i = (i + 1) & mask) {
Slot& s = slots_[i];
if (s.state == kEmpty) return 0;
if (s.state == kFull && Eq{}(s.kv->first, k)) {
s.kv.reset();
s.state = kTomb; // 置回 kEmpty 会切断探测链
--size_;
++tombs_;
return 1;
}
}
return 0;
}
void clear() {
for (Slot& s : slots_) { s.kv.reset(); s.state = kEmpty; }
size_ = 0;
tombs_ = 0;
}
void reserve(size_type n) { // 保证容纳 n 个而不再 rehash
if (n * 2 > capacity()) rehash_to(n * 2);
}
class iterator { // 前向;按槽位顺序,跳过空槽与墓碑
public:
iterator(FlatHashMap* m, size_type i) : m_(m), i_(i) { skip(); }
value_type& operator*() const { return *m_->slots_[i_].kv; }
value_type* operator->() const { return &*m_->slots_[i_].kv; }
iterator& operator++() { ++i_; skip(); return *this; }
bool operator==(const iterator& o) const { return i_ == o.i_; }
bool operator!=(const iterator& o) const { return i_ != o.i_; }
private:
void skip() { while (i_ < m_->slots_.size() && m_->slots_[i_].state != kFull) ++i_; }
FlatHashMap* m_;
size_type i_;
};
iterator begin() { return iterator(this, 0); }
iterator end() { return iterator(this, capacity()); }
private:
std::pair<V*, bool> insert_here(K&& k, V&& v) {
const size_type mask = capacity() - 1;
size_type i = Hash{}(k) & mask;
size_type first_tomb = static_cast<size_type>(-1);
for (size_type probed = 0; probed < capacity(); ++probed, i = (i + 1) & mask) {
Slot& s = slots_[i];
if (s.state == kFull) {
if (Eq{}(s.kv->first, k)) return {&s.kv->second, false}; // 已存在
} else if (s.state == kTomb) {
if (first_tomb == static_cast<size_type>(-1)) first_tomb = i; // 记录首个墓碑,继续查找 key
} else { // 空槽:链终止,确定落点
const size_type at = first_tomb == static_cast<size_type>(-1) ? i : first_tomb;
if (first_tomb != static_cast<size_type>(-1)) --tombs_; // 复用墓碑
Slot& d = slots_[at];
d.state = kFull;
d.kv.emplace(std::move(k), std::move(v));
++size_;
return {&d.kv->second, true};
}
}
rehash_to(capacity() * 2); // 负载因子 0.5 下不可达,防御性重试
return insert_here(std::move(k), std::move(v));
}
void grow_if_needed() {
if (size_ + tombs_ + 1 > capacity() / 2) rehash_to(capacity() * 2);
}
void rehash_to(size_type n) {
size_type cap = 8;
while (cap < n) cap *= 2;
std::vector<Slot> old = std::move(slots_);
slots_.assign(cap, Slot{});
size_ = 0;
tombs_ = 0; // 重哈希顺带清除全部墓碑
for (Slot& s : old) {
if (s.state != kFull) continue;
put_into(slots_, size_, s.kv->first, std::move(s.kv->second));
}
}
static void put_into(std::vector<Slot>& slots, size_type& size, const K& k, V&& v) {
const size_type mask = slots.size() - 1;
size_type i = Hash{}(k) & mask;
while (slots[i].state == kFull) i = (i + 1) & mask; // 重哈希时 key 必不重复
slots[i].state = kFull;
slots[i].kv.emplace(k, std::move(v));
++size;
}
std::vector<Slot> slots_;
size_type size_ = 0;
size_type tombs_ = 0; // 计入负载因子,不计入 size()
};技术选型
- 开放寻址 + 线性探测,而非
std::unordered_map的拉链法- 元素存在连续数组中,无需每元素一次分配,探针也顺序访存;一条 cache line 可覆盖多个候选。
- 代价是 rehash 会搬移元素(引用失效)、需为空槽付出内存、对哈希质量更敏感。
- 删除留墓碑,不置回空槽
- 置空会切断探测链:
erase(b)之后同一链上的c将查不到(不变量第 2 条)。
- 置空会切断探测链:
find仅在空槽终止- 墓碑不能作为终止条件,否则整条链上位于墓碑之后的 key 都会漏查。
- 插入时复用首个墓碑,但先确认 key 不存在
- 复用墓碑可抑制墓碑堆积;若不先扫完探测链,同一 key 会被写入两份。
- 容量取 2 的幂,用
& (cap - 1)代替取模- 位运算更快,且探测序列可写作
i = (i + 1) & mask(无需处理回绕分支)。
- 位运算更快,且探测序列可写作
- 负载因子包含墓碑,阈值取 0.5
- 墓碑同样产生探查成本;只看
size / capacity会在「删多插少」时低估真实退化程度。
- 墓碑同样产生探查成本;只看
- 槽内用
std::optional<std::pair<K,V>>存放元素- 不要求
K/V可默认构造,且「空 / 占用」两态表达清晰(墓碑由状态字节区分)。
- 不要求
- rehash 只搬占用槽
- 顺带清除全部墓碑,即「删除过多」的另一种修复手段。
- 迭代器为前向、按槽位顺序
- 哈希表本身无序,不做随机访问假设;只提供
++与相等比较。
- 哈希表本身无序,不做随机访问假设;只提供
小问答
- 比精简版多了什么?
operator[]、contains、clear、reserve、capacity、load_factor与前向迭代器。 - 与
std::unordered_map接口还差什么?at、erase(iterator)、swap、merge、extract(节点句柄)、bucket_*系列、max_load_factor与自定义分配器。 - 与
absl::flat_hash_map的差别? 后者把「控制字节」与「紧凑数据」分离,一次比较多个控制字节;本版是单数组 + 每槽optional。 - 遍历顺序是什么? 槽位顺序 —— 既非 key 顺序,也非插入顺序;rehash 后全部迭代器失效。
五、边界与测试
- 空表:
find返回空、erase返回 0、begin() == end(); - 重复 key:
insert返回{现有值, false}且不覆盖;operator[]才覆盖; - 墓碑链:构造「全部 key 哈希到同一桶」的哈希函数,插 200 个后隔一删一, 验证剩余项可查出、被删项查不到,再插回被删项;
- 增长:插入 10000 个后
load_factor() <= 0.55;reserve之后继续插入capacity()不变; clear()后size() == 0、find全空、可继续使用;- 对拍:与
std::unordered_map执行同一随机insert/operator[]/erase/find序列, 逐步比较size、各 key 的值与全量元素集合; - 字符串 key;容器本体仅一块存活缓冲。
六、高频问答
Q1. 为什么必须有墓碑?
线性探测以「遇到空槽」作为查找终止条件。若删除直接置空,会截断同一探测链, 使该链上后续插入的元素无法被查到。
Q2. 墓碑会无限增长吗?
会:删除多、插入少时 tombs 持续增长并推高负载因子。两个缓解手段为复用墓碑(本实现) 与触发 rehash(本实现),也可对 size / (size + tombs) 单独设阈值。
Q3. rehash 的时机与代价?
size + tombs + 1 > capacity / 2 时容量翻倍;单次 O(n),摊还 O(1)。 rehash 后所有迭代器与引用失效(元素实际搬家),这是相对 unordered_map 的主要语义代价。
Q4. 最坏情况?
所有 key 冲突时探查退化为 O(n)。防御手段是随机化种子或带密钥的哈希(抗哈希洪水攻击), libstdc++ 的 unordered_map 亦采用随机化。
Q5. 与 std::unordered_map 的取舍?
后者每元素一个节点(一次分配 + 指针追逐 + 桶尾),flat 版一次分配、探针连续 (一条 cache line 可覆盖多个候选)。代价是 rehash 搬元素、为空槽付出内存、对哈希质量更敏感。
Q6. Value 不可默认构造怎么办?
本实现以 optional 规避;也可手动管理生命周期 (alignas(T) unsigned char buf[sizeof(T)] + placement new + 显式析构), 或采用「状态字节数组 + 紧凑数据数组」的两数组布局。
Q7. 删除能否不用墓碑?
可以实现「后移重排」(backward shift deletion):删除后将同链后续元素前移,直到遇到空槽。 无需墓碑且探查更短,代价是删除 O(探查长度),且被移动元素的引用会失效。
Q8. 并发场景?
读多写少可用「单写 + 发布不可变副本」(见并发件); 原地并发需分片锁;开放寻址的无锁删除难度较高(墓碑的并发回收问题)。
Q9. 内存优化方向?
小类型可分「控制字节数组 + 紧凑数据数组」,一次比较多个控制字节(absl::flat_hash_map 的思路); 按探查距离重排的 Robin Hood 哈希可降低探查长度方差。
Q10. 与 flat_map 如何选择?
需要有序遍历或范围查询选 flat_map;只需平均 O(1) 查找且不关心顺序选 flat hashmap。 两者均为「连续内存 + 一次分配」。
七、常见错误
| 错误 | 后果 |
|---|---|
erase 将槽置回 kEmpty | 探测链断裂,同链后续元素再也查不到 |
find 遇墓碑即返回未命中 | 同上 |
| 插入只找首个非占用槽,不复用墓碑 | 表被墓碑填满,探查变长 |
| 直接写入首个墓碑而未确认 key 不存在 | 同一 key 出现两份 |
insert 覆盖已存在的值 | 与 unordered_map::insert 语义不符 |
rehash 未重置 size_ / tombs_,或未只搬 kFull 槽 | size 计数错误 |
以 % capacity 取模却未约束容量为 2 的幂 | 探测序列与掩码假设不一致 |
负载因子只统计 size,忽略墓碑 | 删除多时性能退化 |
| 假设迭代顺序按 key 有序,或忽略 rehash 使迭代器全部失效 | 遍历/持有引用出错 |
本页实现已实测:与
std::unordered_map的 20000 步随机对拍、坏哈希下的 200 元素墓碑链、 负载因子与reserve纪律均通过(ASan + UBSan);「erase置空」与「find遇墓碑终止」 两个经典错误以 mutation 注入后测试立即失败。