Hash表

二、核心设计决策:每一步都有道理

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 到新数组。" → 错误,位置全错。

  • "新建一个更大的数组,然后把旧元素按原下标搬过去。" → 错误,忽略重新计算下标。

正确流程

  1. 创建新表(容量为下一个质数)。

  2. 遍历旧表的每个 EXIST 元素,重新计算其在新表中的位置(线性探测插入)。

  3. 交换新老表的数组。

性能问题 :扩容是 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 自动管理内存,提供 resizeswap,并且易于调试。原生数组需要手动 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!

相关推荐
恼书:-(空寄8 小时前
缓存:Redis7.0+、多级缓存设计、缓存三大问题解决方案
redis·缓存
_深海凉_8 小时前
LeetCode热题100-验证二叉搜索树
算法·leetcode·职场和发展
_深海凉_9 小时前
LeetCode热题100-二叉树的右视图
算法·leetcode·职场和发展
测试秃头怪9 小时前
接口测试与常用接口测试工具详解
自动化测试·软件测试·python·测试工具·职场和发展·测试用例·接口测试
qq_296553279 小时前
[特殊字符] 搜索插入位置:从O(n)到O(log n)的优雅进化
数据结构·算法·面试·分类·柔性数组
凯瑟琳.奥古斯特9 小时前
力扣3654:二维矩阵连续空位统计
数据结构·数据库·算法·职场和发展
leory10 小时前
android常见的内存泄漏场景及检测工具
面试