1. 题目理解与核心考点拆解
1.1 先读懂题面:什么叫做“消失的数字”
力扣 hot100 里的第 448 题“找到所有数组中消失的数字”,是一道看起来非常轻松、但越品越有味道的数组题。题目给了一个长度为 n 的整数数组 nums,数组里每个元素都在 1 到 n 的范围之间,但是因为有些数字重复出现,所以 1 到 n 里必然有数字没出现。题目要求把这些“消失的数字”全部返回。比如输入 nums = [4,3,2,7,8,2,3,1],n = 8,理想情况应该是 1、2、3、4、5、6、7、8 各出现一次,结果 2 和 3 出现了两次,所以 5 和 6 就消失了,输出就是 [5,6]。
这个题面有两层信息非常重要。第一层,数组长度 n 和元素取值范围 [1, n] 完全对应。这意味着下标天然可以用作“数字是否出现过”的标记位:数字 x 出现,我们就在数组下标 x-1 的位置留下一个记号。第二层,数组里可能有重复值,也可能有缺失值,但总数一定是 n 个位置、n 个候选数字,严格符合“鸽笼原理”的典型结构。这基本上就是在暗示你,不要绕远路,要把数组本身变成一张哈希表。
1.2 题目真正想考什么
很多第一次刷这题的人,会马上想到“我用一个哈希集合把出现过的数字存起来,再遍历 1 到 n 检查哪个不在里面”,这当然是对的,但面试官大概率会追问一句:能不能不用额外空间?这时候,数组下标和值域的对应关系就成了突破口。这道题的核心考点并不是“如何判断一个数字出现过”,而是“如何在不开辟额外数组的情况下,仍然完成这个判断”。
根据我刷题和实际面试的经验,这道题真正想培养的思维是:当数据的值域被限制在一个和数组长度相关的范围内时,数组本身就可以当作哈希表来用。哈希表的本质是“通过一个函数把键映射到存储位置”,这里映射函数就是 f(x) = x - 1。你不用自己 new 一个 HashMap,也不用额外分配 O(n) 的布尔数组,直接在原来的 nums 上做标记,最后还能通过标记区分哪些数字出现过、哪些没出现过。能把这个思路讲明白,才算真正吃透这题。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 暴力解法与哈希表解法:先保证做出来
2.1 最直接的想法:用哈希集合记录出现过的数字
在讲进阶思路之前,还是先从最直白的方法开始。遍历一次 nums,把所有出现过的元素放进一个哈希集合(Python 里是 set,C++ 里是 unordered_set),然后再遍历一次 1 到 n,判断每个数字是否在集合里。不在集合里的,就是缺失数字,加入结果列表。这个办法的好处是符合直觉,几乎不需要动脑子,而且不会改变原数组内容。
python复制def findDisappearedNumbers(nums):
n = len(nums)
appeared = set(nums)
res = []
for x in range(1, n + 1):
if x not in appeared:
res.append(x)
return res
这段代码虽然只有几行,但已经能通过力扣的测试。我第一次写这题的时候也是这么写的,原因是 set 的查找复杂度是 O(1),整体时间复杂度 O(n),非常干净。如果你在面试时一下子想不出原地标记法,先写这个版本也完全没问题,至少能向面试官证明你具备基本的编程能力,后续再在这个基础上优化空间。
2.2 哈希解法的复杂度与面试官的下一步追问
哈希集合解法的时间复杂度是 O(n),因为两次遍历都是线性;空间复杂度是 O(n),因为额外存储了一个 set。问题就出在这个 O(n) 空间上。LeetCode 原题的进阶要求是:在不使用额外空间且 O(n) 时间内完成,假设返回数组不算额外空间。所以当你说完哈希集合解法后,面试官大概率会直接问“你能把空间降到 O(1) 吗?”
这里有个很常见的认知误区:有人会认为“返回的结果列表也是 O(n) 空间,所以额外空间不可能是 O(1)”。但实际上题目明确说了返回的列表不计入额外空间,这个约定在算法题里很常见。我们要节省的是除结果数组之外的开销。如果面试官没有特别说明,你可以主动确认一下,这样做不会显得不专业,反而能体现你在意复杂度边界的细节。
| 解法 | 时间复杂度 | 空间复杂度 | 是否修改原数组 |
|---|---|---|---|
| 哈希集合 | O(n) | O(n) | 否 |
| 布尔计数数组 | O(n) | O(n) | 否 |
| 排序后扫描 | O(n log n) | O(1)(忽略排序栈) | 否 |
| 原地负数标记 | O(n) | O(1) | 是 |
| 原地加 n 标记 | O(n) | O(1) | 是 |
从这张表能很直观地看到,原地标记法是唯一满足 O(n) 时间和 O(1) 额外空间两个条件的方案。它付出的代价是修改了输入数组,所以面试时要把这个 trade-off 主动讲清楚,显得更有掌控力。
2.3 用布尔数组代替哈希集合,也是过渡方案
如果你想从哈希集合平滑地过渡到原地标记法,可以先试试用一个长度为 n+1 的布尔数组记录出现情况。遍历 nums,把 visited[num] 置为 True,第二遍遍历 1 到 n,找到 visited 为 False 的数字。这个方案和哈希集合本质一样,空间也是 O(n),但有一个优势:它更容易让你意识到“记录某个数字是否出现”可以用一个固定位置来承载。既然申请额外的数组可以,那为什么不能直接用输入数组的位置呢?这个思路一旦打开,原地标记法就顺理成章了。
python复制def findDisappearedNumbers(nums):
n = len(nums)
seen = [False] * (n + 1)
for x in nums:
seen[x] = True
return [i for i in range(1, n + 1) if not seen[i]]
这里我故意把数组长度设为 n+1,是为了下标可以直接用数字本身,避免 x-1 的换算。你可能会说这样不是浪费一个位置吗?没错,但在允许 O(n) 额外空间的解法里,这样写更不容易犯错。原地标记法其实就是把这个额外数组“折叠”进了原数组,换取空间上的节省。
3. 进阶:原地标记法,这题的核心解法
3.1 为什么能原地?用数组下标当哈希键
回到刚才那个思路:我们需要的只是一块“记录数字 x 出现过”的标记位。既然数组长度 n 刚好对应值域 [1, n],那索引 i 和数字 i+1 就天然形成一一对应。看到数字 x,就去找索引 x-1,在那里留下标记。这个过程本质上就是在建哈希表,只不过哈希函数是 f(x)=x-1,存储空间就是原数组本身,所以不需要额外空间。
选择标记方式的时候,有两种常见做法。第一种是负数标记法,把访问到的位置变成负数,表示“对应数字出现过”;第二种是加 n 标记法,在访问到的位置统一加上 n,第二遍通过数值大小判断是否被标记。两种方法都基于同一个原理,但细节上有些不同,下一节我会一个个拆开讲。
3.2 负数标记法步骤拆解
负数标记法的逻辑很精妙,也很容易写错。我第一次看别人的代码时花了不少时间才绕过来。我先把正确步骤列出来,再用例子走一遍。
- 第一步,遍历数组中的每个位置 i,取出当前值并取绝对值:v = abs(nums[i])。这里一定要取绝对值,因为当前位置可能已经被前面的操作标记成了负数。
- 第二步,计算 v 对应的下标:idx = v - 1。
- 第三步,检查 nums[idx] 是否大于 0。如果大于 0,就把 nums[idx] 改成 -nums[idx],代表数字 v 出现过。
- 第四步,第二遍遍历 nums,如果 nums[i] 仍然大于 0,说明数字 i+1 没有出现过,加入结果列表。
用刚才的例子 [4,3,2,7,8,2,3,1] 走一遍。一开始数组都是正数。i=0 时,nums[0]=4,v=4,idx=3,nums[3]=7>0,所以 nums[3] 变成 -7。i=1 时,nums[1]=3,v=3,idx=2,nums[2]=2>0,变成 -2。i=2 时,nums[2]=-2,但取绝对值是 2,idx=1,nums[1] 变成 -3。i=3 时,nums[3]=-7,v=7,idx=6,nums[6]=3>0,变成 -3。继续处理完所有元素后,数组变成了 [4,3,-2,-7,8,2,-3,-1]。第二遍遍历时,i=4 的 nums[4]=8>0,说明数字 5 缺失;i=5 的 nums[5]=2>0,说明数字 6 缺失。输出 [5,6],和目标一致。
3.3 负数标记法为什么必须取绝对值
这一步是最容易出 bug 的地方。假设当前 nums[i] 是负数,但它表示的不一定是“数字 i+1 消失了”,它可能只是之前在标记其他数字时被改成了负数。比如上面例子里的 nums[2] 原本是 2,因为数字 3 出现过,被改写成了 -2,但它在第二遍扫描时应该表示“数字 3 出现过”,而不是“数字 3 缺失”。所以我们处理当前位置时,必须先取绝对值,恢复它真正的存储值,再去找它对应的索引。
如果代码里漏了 abs,直接拿 nums[i]-1 当下标,一旦 nums[i] 是负数,很可能数组越界,或者错误地标记了另一个位置。我自己第一次写时就是这样翻车的:当下标变成负数时 Python 还能用负索引,结果它会偷偷访问数组末尾,代码竟然没报错,但结果完全错乱了。所以这个 abs 不是可有可无,而是整个方案的基石。
3.4 加 n 标记法:另一种稳定的实现
除了负数标记,还有一种常见的做法是加 n。思路是:看到数字 v,就让 nums[(v-1) % n] 加上 n。因为每个位置会被加到的次数最多 n 次,所以被标记过的位置最终值会超过 n,而没被标记的位置仍然保持原值。第二遍遍历时,如果 nums[i] 小于等于 n,说明数字 i+1 没出现过。
这里有一个细节要注意:因为前面可能已经给某个位置加过 n,后面再次访问这个位置时,需要把它还原成原始值后再取下标。所以第一步不是直接取 nums[i]-1,而是取 (nums[i]-1) % n。取模的作用是把多加的 n 去掉,得到原始值。用 Python 或 C++ 实现可以写成:
python复制def findDisappearedNumbers(nums):
n = len(nums)
for i in range(n):
idx = (nums[i] - 1) % n
nums[idx] += n
res = []
for i in range(n):
if nums[i] <= n:
res.append(i + 1)
return res
这里我拿之前例子在脑子里跑了一遍,假设 nums[0]=4,第一次 idx=3,nums[3] 变成 7+8=15。后面访问 nums[3] 时,取 (15-1)%8=6,这个 6 对应原始值 7,所以标记位置 6。逻辑是正确的。加 n 法比负数法的好处是不用频繁判断正负,但前提是数值加起来不会溢出,力扣环境下用 int 没问题。如果面试官问起,你可以顺带提一句“要注意整数溢出”,会显得你考虑问题全面。
3.5 两趟遍历的本质:先记录,后收集
原地标记法看起来复杂,其实本质只有两步:第一趟负责“记录”,第二趟负责“收集”。记录阶段把“出现过的数字”转换成“数组中的某个标记”,收集阶段遍历数组,找出哪些位置没有被标记过。整个过程和哈希表解法没有任何本质区别,只是把额外的 set 换成了数组本身。
我们可以用一个生活场景来类比:一个班级有 n 个学生,学号从 1 到 n,老师准备了一个 n 格子的考勤表。每个学生点到后,在他学号对应的格子里画钩。最后哪个格子没画钩,哪个学号的学生就缺席了。这里的考勤表就是原数组,画钩就是把数字改成负数或加 n。这样想的话,原地标记法其实非常朴素,并不神秘。
4. 其他思路与边界情况
4.1 排序后扫描:能过但不够漂亮
还有一种思路是先把数组排序,然后扫描一次,找出缺失的数字。排序后数组接近升序,扫描时可以用一个计数器 expected 从 1 开始,跳过重复值,把 missing 收集起来。时间复杂度是 O(n log n),主要来自排序。
python复制def findDisappearedNumbers(nums):
nums.sort()
res = []
n = len(nums)
expected = 1
for x in nums:
if x == expected:
expected += 1
elif x > expected:
while expected < x:
res.append(expected)
expected += 1
while expected <= n:
res.append(expected)
expected += 1
return res
这段代码也可以用,但无法满足 O(n) 的时间要求。如果你在面试中写这个版本,面试官可能接受,但接下来一定会引导你往原地哈希上面靠。所以排序法我一般只在“先跑通再优化”的阶段用,不会作为最终答案。需要额外注意的一点是,排序后会有重复值,所以不能简单地让 expected 和每个 nums[i] 对齐,必须分类讨论“相等”和“大于”两种情况,否则很容易漏掉缺失值或把已有数字误判成缺失。
4.2 额外计数数组:思路最直白,但不是最优
如果题目不要求 O(1) 空间,除了哈希集合,还可以直接用一个长度为 n+1 的计数数组。遍历 nums 时对相应位置累加,第二遍统计 count[i] == 0 的数字。它的好处是比哈希集合更容易理解,也不用处理哈希冲突,在 C++ 里可以用 vector
在实际工作中,如果处理的数据量特别大、不介意额外空间,用这种计数数组反而比原地标记更安全,因为原始数据不会被破坏。算法题和工程场景经常是两种取舍,这点值得记下来。换句话说,O(1) 空间并不是永远都比 O(n) 空间好,要看场景里“修改输入数据”这个副作用是不是可以接受。
4.3 边界情况与笔试中的细节坑
我自己在反复提交这题时,积累了下面几个边界情况,建议你们测试代码时都带上:
- n = 1,nums = [1],输出 []。因为只有 1 个数字且出现了。
- 所有数字都只出现一次,比如 nums = [1,2,3],输出 []。
- 同一个数字重复多次,例如 nums = [2,2,2,2],n=4,输出 [1,3,4]。
- 最大数字 n 恰好缺失,比如 nums = [1,2,2,3],n=4,输出 [4]。
- 前半段正常、后半段缺失,例如 nums = [3,1,3,4],n=4,数字 2 缺失。
负数标记法在处理“最大数字缺失”时尤其要小心,因为数字 n 对应的下标是 n-1,处于数组末尾,取绝对值后不会越界。加 n 法也要注意,当某个位置被加过 n 后数值会很大,判断时千万别写成等于 n,而是小于等于 n。边界值用额外用例验证一遍,比自己盲目自信要可靠得多。
5. 常见问题与调试经验
5.1 为什么第二遍判断用 nums[i] > 0 或 nums[i] <= n
很多读者会困惑:负数标记法中,第二遍遍历时 nums[i] 是正数就说明 i+1 缺失,那如果某个数字恰好被标记了多次呢?比如数字 2 出现了三次,位置 1 会被改成负数,后续再遇到 2 时,我们判断 nums[1] 已经小于 0,就不再重复标记。所以一个位置只要被标记过一次,就一直保持负数。而没出现过的数字,对应的位置从未被改成负数,第二遍必然保持正数。加 n 法同理,位置每被标记一次就加 n,已经标记过的位置始终大于 n,没标记过的位置保持原来的值,所以用 nums[i] <= n 判断缺失。
这个判断条件直接决定了答案是否正确。如果用负数标记法但第二遍写成“如果 nums[i] < 0”,那么收集到的是出现过的数字,而不是缺失数字,结果正好反了。所以写代码之前先想清楚自己要收集的是“有标记”还是“没标记”的位置,再决定判断符号。
5.2 负数标记法会不会覆盖掉某些信息
不会。我们只关心“是否被标记”,不关心“出现了几次”。负数标记法把 nums[idx] 变成负数,丢失的是它的原始正数,但因为我们需要的信息已经从原始值转到了“正负号”上,所以这种覆盖是无害的。第二遍遍历时,从不指望 nums[i] 还保存原始值,而是直接看符号。这正是原地哈希的精髓:用数据本身的一部分(符号位)来存储额外信息。
不过,如果后续步骤还需要用到数组的原始值,比如在同一趟遍历中同时做其他统计,那负数标记法就会干扰到后续逻辑。遇到这种情况,要么改用加 n 法,要么先复制一份数组。面试时可以主动提这件事,说明你清楚修改原数组的边界和代价。
5.3 我踩过的坑:Python 负索引悄悄帮你掩盖错误
我在前面提过一次,Python 里如果索引是负数,比如 -2,会从数组末尾开始访问,代码不会崩溃,但结果完全错误。负数标记法中,如果不取 abs,nums[i] 为负数时 idx 就是负数,程序“顺利”运行却输出一堆错误答案。这种 bug 特别难排查,因为运行时不报错。所以我的建议是:先在本地加一行断言 idx 的范围,或者在纸上完整模拟一遍小数组。C++ 里虽然会越界,但运行行为未定义,同样危险。
我印象最深的一次是,本地测试某个用例通过,换一个用例就失败,后来一步步打印才发现 nums[i] 已经被改成负数了,可我还是拿它直接去定位下标。从那以后,凡是遇到“数组里的值会动态变化”的题目,我都会条件反射地在每一步取最新状态时多想一步:这个值还是原始值吗?需不需要取绝对值或取模?
5.4 一个典型的错误实现:漏掉 abs 会怎样
我经常在讨论区看到类似下面这种错误写法:
python复制def findDisappearedNumbers(nums):
n = len(nums)
for i in range(n):
idx = nums[i] - 1
if nums[idx] > 0:
nums[idx] = -nums[idx]
res = []
for i in range(n):
if nums[i] > 0:
res.append(i + 1)
return res
表面上看,这个代码在数组初始全为正数时好像没问题,因为第一遍遍历时 nums[i] 可能还没被改过。但只要数组中出现重复数字,后面的某个位置就可能已经变成负数。比如 nums = [2,2,1],n=3,第一轮 i=0 时 idx=1,nums[1] 变成 -2;i=1 时 nums[1] 已经是 -2,此时 idx=-3,Python 会把索引 -3 映射到数组末尾元素,结果把 nums[0] 改成 -1。最后输出可能完全错误。修正办法就是加上 abs,改成 idx = abs(nums[i]) - 1。
这种错误非常值得在面试前自己写一遍,因为只有亲手踩过,才能在讲解时解释清楚为什么要取绝对值。如果只是背答案,面试官一追问“这里为什么不是 nums[i]-1”,你可能就回答不上来。
5.5 需要恢复原数组怎么办
有些笔试系统会检测原数组是否被修改,或者你在面试时写了原地算法,但面试官追问“如果调用方不允许修改数组呢”。这时你就需要回到哈希集合解法,额外空间 O(n) 是必须付出的代价。面试时你可以这样说:如果数据敏感、不能动原数组,那么我们可以用哈希集合,代价是 O(n) 空间;如果允许修改,原地标记法可以做到 O(1) 额外空间。这个回答能证明你理解了空间与不修改原数组之间的权衡。
如果你在本地练习时确实需要恢复原数组,可以在原地标记法结束后再遍历一次,把所有负数取绝对值,就能把数组还原成初始状态。例如 nums[i] = abs(nums[i]),这样不会影响结果,但会让修改副作用消失。这个技巧在竞赛和面试中不常用,但作为工程习惯,值得了解。
6. 本题变形与面试问答扩展
6.1 同类经典题:原地哈希系列串讲
448 题是原地哈希系列里最基础的一道。掌握它之后,建议按顺序把下面几道题一起刷:
-
- 数组中重复的数据:同样是 1 到 n 的范围,要找出现两次的数字。用负号标记后,如果第二次遇到时它已经是负数,就说明重复了。
-
- 缺失的第一个正数:思路升级版,允许的值域从 1 开始,但数组里可能有负数、0、超过 n 的数,需要先做一轮“分区”处理。
-
- 寻找重复数:这个更偏向二分和快慢指针,也可以用原地哈希的思想,但实现方式略有不同。
-
- 只出现一次的数字:用异或,虽然不是原地哈希,但它同样展示了“利用数字本身性质避免额外空间”的思维。
题量不在多,把这些放在一起对比,你会明显感受到“值域映射下标”这个套路的通用性。以后看到“长度为 n 的数组,元素在 [1, n] 范围内”这个描述,条件反射就该是原地哈希。刷题时可以把这些题放进同一个收藏夹,过两周再回头看,理解会深很多。
6.2 面试时如何一步步讲清楚这题
我建议的回答框架是这样的:先观察数组长度和值域的关系,指出可以把数组下标当作哈希键;然后给出朴素哈希集合解法作为 baseline;接着抛出优化方向,说明为什么可以省略额外数组;最后用负数标记法完整描述两趟扫描。讲的时候可以配合例子,但不用写太多复杂推导,重点是让面试官觉得你确实理解每一步在做什么。
如果面试官问“加 n 法和负数法有什么区别”,你可以回答:负数法改的是符号位,对数值大小影响小;加 n 法需要取模还原,但逻辑统一,不会出现符号判断。两者时间复杂度相同,选一个自己更顺手的讲清楚即可。如果你能主动提到加 n 法在极端情况下可能溢出,就说明你对细节有把控。
6.3 复盘与核心套路总结
最后,我想把这道题的解法路径浓缩成三句话。第一句话:看到“n 长度数组,值域 1 到 n”,就要想到用下标当哈希表。第二句话:标记一个数字出现过,就是在它对应的下标位置留下“记号”,无论记号是负号还是加 n。第三句话:两遍扫描,第一遍打标记,第二遍检查哪些位置没有记号,这些位置对应的数字就是缺失数字。
这套思维不只在力扣上有效。后面你会遇到各种变体,比如找出多个缺失值、找出重复值、找出只出现一次的值,本质上都是同一个“用数组本身记录信息”的思路在迁移。我建议刷完这题后,花点时间把 442 题也写一遍,因为它就是把负数标记法从“找缺失”变成“找重复”的天然延伸。
我个人在实际体验里,最大的收获不是记住了这个解法,而是养成了一个习惯:做题前先观察数据范围。很多看似要额外哈希表的题目,只要值域和长度存在一一映射,往往都有原地解法。刷完 448 之后我再去写 41 题,明显轻松了很多,因为这个套路已经长在脑子里了。如果你现在对负数标记法还有点绕,别急,拿支笔在纸上把每一步的下标和值变化都写出来,跑两个用例,整条逻辑就会豁然开朗。
