学习顺序主线第 06 / 22 课 · 阶段 2

为什么现在学:无序数组无法直接二分,哈希表给出另一种查找思路。

基础课程

哈希表:用空间换查找Hash Maps

把『之前有没有见过这个值』变成一个可直接查询的索引。

35 分钟 · 含手算与练习

学完这一课,你应该能做到

  • 能从两数之和写出 need=target−value
  • 理解键、值、冲突和平均复杂度
  • 能解释为什么必须先查询、后插入

01先读懂这个概念

从一个双循环出发

给 a=[4,1,7,3] 和 target=10,要求返回两个不同位置,使数值相加为 10。直接枚举 i<j 最坏检查 n(n−1)/2 对。固定当前 value 后,另一个数并不任意:它必须等于 target−value。

因此需要一份『以前见过的值 → 它的下标』的表。扫描到 7 时 need=3,表中没有;扫描到 3 时 need=7,表中已有 7→2,于是返回 [2,3]。每次只查一个补数,不再重新扫描整个前缀。

哈希表不是一块不会冲突的魔法内存

键通过哈希函数映射到桶,不同键可能落进同一桶,这叫哈希冲突。正确的实现还会判断键是否相等,因此冲突影响性能,不应让 4 和 14 被当作同一个键。Python 的 dict 是键值表,set 只记录是否存在。

在哈希分布与装载控制良好时,查询、插入通常按期望 O(1) 分析;并非任意输入都有最坏常数保证。本课使用短整数键,暂不计长字符串哈希本身的长度成本。你现在不必实现哈希表,但需要知道它用额外空间换了查找效率。

02从具体数据开始手算

拿一个小输入,逐步手算

a=[4,1,7,3],target=10;表只包含已经经过的位置。

当前 i / valueneed查询前 seen动作
0 / 46{}没有 6,记入 4→0
1 / 19{4:0}没有 9,记入 1→1
2 / 73{4:0,1:1}没有 3,记入 7→2
3 / 37{4:0,1:1,7:2}找到下标 2,返回 [2,3]

先查再写使表里的位置一定早于 i,因此不会用同一位置两次;找到的是一组合法答案,不保证某种字典序。

03把正确性的理由说清楚

这个索引为什么足够

  1. 扫描到 i 前,seen 中保存前缀 a[0:i] 里每个已出现值的某个下标。初始空前缀对应空表。
  2. 若 need 在 seen 中,两个位置不同且值之和等于 target。否则把当前值记进去,保持前缀索引的含义。
  3. 任意合法答案的一对下标可写成 j<i。扫描到较晚的 i 时,a[j] 必然已在表中,所以至少会找到一组答案。

04把刚才的思路写成代码

基础课用 Python 表达,先对照变量含义与执行顺序。后续算法课提供 Python、C++ 与 JavaScript 三种实现。

def two_sum(a, target):
    seen = {}
    for i, value in enumerate(a):
        need = target - value
        if need in seen:
            return [seen[need], i]
        seen[value] = i
    return []

代码里的每个关键决定

seen = {}
键是已经出现的数值,值是该数值的下标;两者不能写反,否则无法按补数查。
if need in seen:
查询键是否存在。必须在写入当前元素之前查询;a=[3]、target=6 时不能返回 [0,0]。
seen[value] = i
如果同值已经出现,可以覆盖为最近下标;题目只需任意两个不同位置,这足够。若要返回所有配对,则需要保存更多下标信息。

05这些操作要付出多少代价

把工作量具体数出来

循环最多 n 次,每次一次哈希查询与一次插入,在上述哈希假设下总时间期望 O(n)。

表最多保存 n 个不同键值对,辅助空间 O(n)。与双循环的 O(1) 辅助空间相比,这正是时间与空间交换。

如果问题要求原始下标,先排序会改变位置关系;用哈希表可以保留原下标。有序输入则还可以使用下一节的双指针。

06用一个反例检查理解

用反例检查前提

先写入会把自己当作搭档

a=[3],target=6。若先执行 seen[3]=0,再查询 need=3,就会错误返回 [0,0];但这里只有一个位置。

怎样修正:先查询,成功就返回,失败才写入。a=[3,3] 时第二个 3 才能与第一个匹配,答案为 [0,1]。

07自己推一次,再做自测

合上答案,自己推一次

再做一遍补数查询

手算 a=[2,5,2]、target=4。写出每轮查询前的 seen,解释最后为什么返回两个不同下标。

需要一点提示

第一轮需要 2,但表还是空的。

查看完整推导与答案
  1. i=0,need=2,空表未命中,写入 {2:0}。
  2. i=1,need=−1,未命中,写入 {2:0,5:1}。
  3. i=2,need=2,命中下标 0,返回 [0,2]。当前下标没有预先进入表。

01seen 的键与值分别是什么?

02一次扫描为什么不漏掉答案?

03哈希冲突意味着什么?

继续阅读

本课例子与推导为本站编写。需要另一种表述或进一步学习时,可对照这些公开课程与文档:

MIT 6.006 · 算法讲义Python 官方教程 · 数据结构

能独立完成本课练习,再标记掌握。也可以直接用上面的链接继续学习。