二、核心设计决策:每一步都有道理
2.1 冲突解决:线性探测 vs. 链地址法
| 方法 | 优点 | 缺点 | 面试回答 |
|---|---|---|---|
| 线性探测 | 缓存友好,实现简单 | 容易产生主聚集,删除麻烦 | "在元素个数可控、对内存访问模式有要求时,线性探测性能反而优于拉链法。" |
| 链地址法 | 无聚集问题,删除简单 | 需要额外指针,缓存不友好 | "通用场景下链地址法更稳定,但面试手写通常要求线性探测,因为更能考察状态管理。" |
面试技巧:不要只说"我用线性探测",而要主动分析选择原因。例如:"我选择线性探测是因为面试场景通常要求数组实现,且能展示对三种状态的理解。"
2.2 负载因子:为什么是 0.75?
-
定义 :
α = 元素个数 / 桶数量 -
为什么不是 1:当 α=1 时,插入一个元素几乎必定发生多次探测,查找退化为 O(n)。
-
为什么不是 0.5:浪费一半内存,不经济。
-
0.75 的来源 :实验证明,在随机哈希函数下,线性探测的平均查找长度约为
(1 + 1/(1-α)^2)/2,α=0.75 时查找约 2.5 次,空间利用合理。
面试追问 :"负载因子可以动态调整吗?"
答:可以。有些实现允许用户指定,例如 Java 的 HashMap 默认 0.75,也可构造时传入。调整太大会增加冲突,太小浪费内存。
2.3 扩容:为什么不能简单拷贝?
关键点 :数组容量改变后,hash(key) % new_capacity 的结果与之前不同,必须重新哈希所有已有元素。
面试常见错误:
-
"我就把旧数组内容 memcpy 到新数组。" → 错误,位置全错。
-
"新建一个更大的数组,然后把旧元素按原下标搬过去。" → 错误,忽略重新计算下标。
正确流程:
-
创建新表(容量为下一个质数)。
-
遍历旧表的每个
EXIST元素,重新计算其在新表中的位置(线性探测插入)。 -
交换新老表的数组。
性能问题 :扩容是 O(n) 操作,如果频繁扩容,代价很大。
优化:① 初始容量给大一点(比如 64);② 使用"懒迁移"或"渐进式扩容",但面试一般不要求。
三、删除标记 DELETE:99% 的人第一次都会写错
3.1 为什么不能直接设为 EMPTY?
演示 :
假设容量=10,三个元素 A、B、C 哈希值相同:
-
插入 A → 下标 3
-
插入 B → 下标 4(线性探测)
-
插入 C → 下标 5
删除 A,如果直接置 EMPTY,查找 C 时:从下标 3 开始,发现 EMPTY,立即停止,认为 C 不存在 → 错误。
正确做法 :删除 A 时,将状态标记为 DELETE。
-
查找时遇到
DELETE必须继续向后探测。 -
插入时遇到
DELETE可以覆盖(因为它相当于空位)。
3.2 三种状态的转换图
text
EMPTY → (插入) → EXIST → (删除) → DELETE
DELETE → (插入) → EXIST
EXIST → (另一个插入,但冲突) → 仍为 EXIST,移动下标
面试中画这个图会很加分。
四、哈希函数:面试必问的字符串哈希
4.1 整数:最简单
cpp
size_t operator()(int key) { return key; }
4.2 字符串:为什么不能用累加?
错误写法 :hash = hash + ch;
原因 :"ab" 与 "ba" 结果相同,大量碰撞。
正确写法:多项式哈希
cpp
size_t operator()(const string& s) {
size_t hash = 0;
for (char c : s) {
hash = hash * 131 + c; // 131 是一个质数
}
return hash;
}
追问 :为什么乘 131 而不是 31 或 127?
答:质数即可,131 是一个经验值,可以减少冲突。实际上 std::hash<string> 用的更复杂的算法,但面试中写出上述代码已足够。
追问 :为什么最后不取模?
答:哈希函数返回的是整数,取模是哈希表内部做的,不应该在哈希函数里取模,否则会丢失高位信息。
五、手写代码时的关键细节
面试时你不需要写出完整代码,但必须写对核心循环和边界条件。
5.1 线性探测插入的终止条件
cpp
size_t index = hash(key) % capacity;
size_t start = index;
while (table[index].state == EXIST) {
index = (index + 1) % capacity;
if (index == start) { // 表已满,理论上不会发生(负载因子限制)
// 扩容或返回失败
}
}
注意:检测一圈回到起点的情况,防止死循环。
5.2 查找的终止条件
cpp
while (table[index].state != EMPTY) {
if (table[index].state == EXIST && table[index].key == key)
return &table[index];
index = (index + 1) % capacity;
if (index == start) break;
}
5.3 扩容的重哈希
cpp
vector<HashNode> new_table(new_capacity);
for (auto& node : old_table) {
if (node.state == EXIST) {
size_t idx = hash(node.key) % new_capacity;
while (new_table[idx].state == EXIST)
idx = (idx + 1) % new_capacity;
new_table[idx] = node;
}
}
六、面试高频追问 & 回答模板
Q1: 你的哈希表线程安全吗?怎么改进?
答 :不安全。可以加读写锁,但更好的做法是使用 ConcurrentHashMap 那样的分段锁,或者用无锁哈希表(如 C++ 的 tbb::concurrent_hash_map)。面试中承认不安全并给出改进方向即可。
Q2: 怎么遍历哈希表?顺序是插入顺序吗?
答:遍历数组,跳过非 EXIST 状态。顺序不是插入顺序,也不一定是哈希值顺序,而是数组下标顺序。如果要求按插入顺序,需要额外维护一个链表(类似于 LinkedHashMap)。
Q3: 如果哈希函数特别差,所有元素都冲突,性能会怎样?
答:线性探测会退化为 O(n) 的查找。解决方法:改用链地址法,或者使用"再哈希"等更 robust 的哈希函数。在面试中可以补充:设计哈希函数时应避免这种退化情况,或者使用随机哈希种子。
Q4: 你为什么用 vector 而不是原生数组?
答 :vector 自动管理内存,提供 resize 和 swap,并且易于调试。原生数组需要手动 new/delete,容易出错。
七、一张表总结哈希表面试考点
| 考点 | 关键点 | 常见错误 |
|---|---|---|
| 冲突解决 | 线性探测/链地址法区别 | 忘记处理 DELETE 状态 |
| 负载因子 | 阈值 0.75,扩容重新哈希 | 直接拷贝数组 |
| 删除标记 | DELETE 避免查找断裂 | 删除后置 EMPTY |
| 哈希函数 | 字符串用多项式乘质数 | 简单累加导致碰撞 |
| 扩容 | 重新哈希所有元素 | 不重新计算下标 |
| 边界条件 | 探测一圈回到起点 | 死循环 |
八、模拟面试场景
面试官 :"写一个 find 函数,用线性探测。"
你 (边写边说):"首先判断表不为空。然后计算起始下标。因为线性探测可能遇到 DELETE 状态,所以循环条件为 state != EMPTY,并且在内部判断 state == EXIST 且 key 相等时返回。每次探测 (index+1)%size,并记录起始位置,一旦绕回就终止。"
面试官:"为什么插入时遇到 DELETE 就可以直接覆盖?"
你:"因为 DELETE 意味着该位置曾经有元素,但现在已经删除,逻辑上就是一个空位。查找时会跳过 DELETE 继续找,所以覆盖它不会丢失任何有效元素。"
面试官:"你的负载因子是 0.75,如果用户非要插入很多元素,导致表几乎满了,怎么办?"
你 :"可以在扩容阈值处留有余地,或者实现一个 reserve 方法让用户预分配。生产环境中,我会在负载因子超过 0.75 时立即扩容,避免性能崩溃。"
这样的回答能体现你对哈希表设计取舍的理解,而不是机械背代码。
九、结尾:理解 trade-off 才是关键
哈希表的所有设计------负载因子、探测方式、哈希函数质量------都是 trade-off 。
面试官真正想看到的是:你能否分析不同场景下的优劣,并做出合理决策。
如果你能说出"在内存敏感、元素较少的场景,我会把负载因子调低;在性能优先的场景,我会用链地址法",那么你已经超越了 80% 的候选人。
祝你面试顺利,拿下 offer!