刷力扣hot100的朋友,十有八九都会撞上这道448题“找到所有数组中消失的数字”。别看它名字平平无奇,它的地位在数组类题目里相当特殊:既考数组下标映射的敏感度,又考空间复杂度的掌控力,还顺手把抽屉原理、哈希表思想、原地算法这些底层功一起验了一遍。很多人在面试里被追问“能不能不用额外空间”,问的就是这道题的变种。这篇文章我用实际刷题和复盘的心路历程,把它从题目表面到最优解讲透,顺带把踩过的坑和排查思路一并整理出来,希望能给你省点时间。
先交代清楚题目本身:给定一个长度为 n 的整数数组 nums,数组里的元素值都在 [1, n] 范围内,但有些数字出现多次,有些数字一次都没出现,要求找出所有在 [1, n] 范围内但没有出现在数组中的数字,最后以数组形式返回。
乍听上去很简单,但力扣对这道题有一个硬性要求:在不使用额外空间且时间复杂度为 O(n) 的情况下完成任务。也就是说,你新建一个哈希表当然能做出来,但不算本事;你要在原有数组上动脑筋,这才符合面试官的真实期待。我见过不少人直接开一个布尔数组标记哪些数出现过,然后遍历一遍输出没被标记的数,答案完全正确,却被面试官追问“能不能优化空间”。如果你也卡在这,这篇文章就是为你准备的。
1. 题目解读与核心思路拆解
1.1 原题复述与第一直觉分析
题面我已经在开头说了,咱们干脆用例子走一遍。假设 n = 8,nums = [4, 3, 2, 7, 8, 2, 3, 1],这个数组里所有元素都在 1 到 8 之间,但是 2 和 3 各出现了两次,于是 5 和 6 就没了。返回值应该是 [5, 6]。
大多数人的第一反应是:建一个长度为 n 的布尔数组,初始全是 false,遍历 nums,把 nums[i] - 1 对应的位置标记为 true,最后再扫一遍布尔数组,哪些位置是 false,对应的下标加 1 就是缺失数字。这个思路本身一点错都没有,时间复杂度 O(n),空间复杂度 O(n)。
但题目要求空间复杂度 O(1),也就是不能有跟 n 成正比的额外存储。这就逼着你必须在原数组上做文章。我第一次做这题时也想过排序,但排序的时间复杂度是 O(n log n),不符合 O(n) 的硬性要求,直接否决。所以思路必须转向:如何利用数组下标本身来记录“某个数字是否出现过”。
1.2 为什么空间复杂度被卡死?从暴力解法说起
我经常跟朋友说,力扣的难度不在于你会不会写代码,而在于你有没有识破出题人意图的能力。这道题把 n 这个条件反复用:数组长度是 n,元素取值范围也是 [1, n],这两个 n 不是巧合。
这在算法里其实是个经典的“鸽笼原理”场景,也叫抽屉原理:n 个位置放 n 个编号为 1 到 n 的元素,如果某些位置放了重复元素,那就必然有另一些位置空缺。我们要找的就是那些空缺的编号。
理解到这一层,暴力解法就自然浮现了。我最早写的版本是:
python复制def findDisappearedNumbers(nums):
n = len(nums)
present = [False] * (n + 1)
for x in nums:
present[x] = True
return [i for i in range(1, n + 1) if not present[i]]
这段代码能通过力扣的所有测试用例,但它用了一个长度为 n + 1 的额外数组,显然不符合进阶要求。如果面试官追问“能不能不用额外空间”,这版就直接被毙掉。
有人可能想,那我把布尔数组换成位图?用位运算压缩空间?可以是可以,但位图本质上还是 O(n) 级别的额外空间,只是常数项变小了,不符合 O(1) 的严格定义。所以这条路也走不通。
1.3 抽屉原理:这道题真正的思想根基
我是在做了不少这类题之后,才真正意识到抽屉原理在数组题里的分量。所谓抽屉原理,学术点的说法是:如果把 n + 1 个物体放进 n 个抽屉,至少有一个抽屉里会有两个或以上物体。在这道题里可以反过来理解:n 个格子(对应下标 0 到 n-1)要容纳编号为 1 到 n 的 n 个数字,如果某个数字出现了两次,那么必然有一个数字没地方待。
这个原理的价值在于:它告诉你“缺失的数字”和“重复的数字”是一体两面的存在。你不可能只缺一个而不重复另一个(数组长度固定为 n),所以找缺失数字,本质上是找“哪个编号没有被映射到”。
这样一来,解题方向就清晰了:把 1 到 n 的每个数字映射到一个唯一的下标,用一个可逆的手段标记“这个数字我见过了”,最终扫描哪些下标没被标记,就是答案。关键在于这个“可逆的手段”不能额外占用 O(n) 空间。这直接引出了后文要讲的负数标记法。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 四种主流解法的演进与选型
2.1 排序法:最朴素但被隐藏的坑
如果题目不要求 O(n) 时间,排序是最省脑子的方案:排序后数组会变成有序序列,你只需要从 1 开始逐个比对,就能找出缺失的数字。比如 nums 排完序是 [1, 2, 2, 3, 3, 4, 7, 8],从 1 数到 8,发现 5 和 6 不在,直接返回。
但排序法有两个让人难受的地方。一是时间复杂度 O(n log n),在 n 很大时性能不够看。二是排序会打乱原数组顺序,如果后续还需要用原数组做别的操作,就得提前拷贝一份,反而引入了额外空间。本质上,排序法在这个题里属于“能用但不够好”的过渡方案。
我在实际项目里倒是经常用排序来辅助排查数据缺失,因为业务数据通常不要求 O(n) 复杂度,而且排序之后的数据可以直接对接报表。但刷题和工程是两码事,题目明确要求 O(n) 时间 O(1) 空间时,排序法就只能用来做思路铺垫,不能作为最终答案。
2.2 哈希表法:用空间换时间的标准答案
哈希表法是大多数人第一时间能想到的“正规军”解法:遍历一遍数组,把每个元素放入哈希集合;再遍历 1 到 n,检查每个数字是否在集合中,不在的就是缺失值。
python复制def findDisappearedNumbers(nums):
num_set = set(nums)
return [i for i in range(1, len(nums) + 1) if i not in num_set]
这段代码非常简洁,时间复杂度 O(n),空间复杂度 O(n)。在力扣上这版能通过,因为题目只要求“你能做出来”,并不强制你不让用额外空间。但它的局限性在于:当你面对的是海量数据时,哈希表的空间开销可能撑不住内存,而且哈希碰撞也会影响性能。
我个人的习惯是:在笔试或白板面试中,如果时间紧迫,先用哈希表法保证正确性拿到分,再告诉面试官“我可以进一步优化为 O(1) 空间”。千万不要一上来就卡在最优解上憋半天不写代码,那反而容易丢分。先把一个能跑通的方案写出来,再逐步优化,这是我在实战中验证过非常管用的策略。
2.3 负数标记法:原地算法的精髓
接下来就是本题的精华:负数标记法。核心思想是,利用数组下标来存储“数字是否存在”的信息,通过给对应位置的元素取负数来打标记。
具体来说,遍历 nums,对于每个值 x,取它的绝对值 abs(x),然后看下标 abs(x) - 1 处的元素。如果这个元素是正数,就把它变成负数;如果已经是负数,说明这个数字之前已经出现过,不用重复操作。遍历结束后,nums 中仍然为正数的位置,其下标加 1 对应的数字就是缺失的。
这个方法为什么能成立?因为每个数字 x 都对应唯一的下标 x - 1,我们不需要额外空间来记录 x 是否出现过,直接用原数组中的元素符号变化来标记。遍历到 x 时,x - 1 这个下标已经被“登记”过了,于是它的元素变成负数;如果后面再次遇到 x,会发现 x - 1 处已经是负数,说明 x 重复出现。
等整个数组遍历完,哪些下标对应的元素还是正数,就说明这些下标加 1 的数字从来没被遇到,也就是我们要找的缺失数字。整个过程只修改了原数组中元素的符号,没有申请任何和 n 相关的额外空间。
我在第一次看懂这个解法时,真有一种“卧槽还能这样”的感觉。它完全不依赖复杂的数据结构,只用了数组本身和一点符号运算,就把信息存储进了原数组,这就是原地算法(in-place algorithm)的典型代表。
2.4 置换归位法:另一种原地思路
除了负数标记法,还有一个思路也值得掌握:置换归位法。核心思想是,把每个数字放到它“应该去”的位置:数字 x 应该放在下标 x - 1 处。遍历数组,如果当前位置的元素不是它该有的值,就把它与目标位置的元素交换,直到当前位置的元素归位或遇到重复值为止。最终,凡是下标 i 处的元素不等于 i + 1,那么 i + 1 就是缺失数字。
这个方法也很经典,但实现起来比负数标记法稍微繁琐一点,因为你要处理交换逻辑和死循环的边界。负数标记法只需要一次遍历加两次扫描,代码更短,也更好记。所以我个人更推荐把负数标记法作为首选解答,但置换归位法也建议理解一遍,因为它能加深你对“索引与值一一对应”这种思维模式的理解,在别的题里(比如“找到所有重复的数”)能直接迁移。
3. 核心解法实操全流程
3.1 手动模拟一遍全过程
纸上得来终觉浅,咱们直接手动跑一遍负数标记法。以 nums = [4, 3, 2, 7, 8, 2, 3, 1] 为例,n = 8。
初始化:数组长度为 8。
第一步,遍历 i = 0,x = 4。取 x 的绝对值是 4,对应的下标是 4 - 1 = 3。nums[3] 当前值是 7,是正数,把它变成 -7。数组变为 [4, 3, 2, -7, 8, 2, 3, 1]。
第二步,i = 1,x = 3。对应下标 2,nums[2] 是 2,变负。数组变为 [4, 3, -2, -7, 8, 2, 3, 1]。
第三步,i = 2,x = -2。注意,这里元素已经被上一步改成了负数,所以取绝对值 2,对应下标 1,nums[1] 是 3,变负。数组变为 [4, -3, -2, -7, 8, 2, 3, 1]。
这里就是最容易出错的地方:遍历到负数时,一定要先取绝对值再用,否则你会拿 -2 去访问下标 -3,直接数组越界。
第四步,i = 3,x = -7。取绝对值 7,对应下标 6,nums[6] 是 3,变负。数组变为 [4, -3, -2, -7, 8, 2, -3, 1]。
第五步,i = 4,x = 8。对应下标 7,nums[7] 是 1,变负。数组变为 [4, -3, -2, -7, 8, 2, -3, -1]。
第六步,i = 5,x = 2。对应下标 1,nums[1] 是 -3,已经是负数,不处理。这一步是因为 2 已经出现过一次(在第三步通过 2 标记过),所以这次遇到重复的 2 时,发现标记已在,跳过。
第七步,i = 6,x = -3。取绝对值 3,对应下标 2,nums[2] 是 -2,已经是负数,跳过。
第八步,i = 7,x = -1。取绝对值 1,对应下标 0,nums[0] 是 4,还没有标记,变负。数组变为 [-4, -3, -2, -7, 8, 2, -3, -1]。
遍历结束后,检查数组:nums[4] = 8 是正数,下标 4 加 1 得 5;nums[5] = 2 是正数,下标 5 加 1 得 6。所以缺失的数字是 [5, 6],与预期完全一致。
3.2 代码落地:Python、Java、C++ 三语言实现
先给 Python 版本,这也是我最常用的语言:
python复制def findDisappearedNumbers(nums):
for i in range(len(nums)):
index = abs(nums[i]) - 1
if nums[index] > 0:
nums[index] = -nums[index]
return [i + 1 for i in range(len(nums)) if nums[i] > 0]
Java 版本:
java复制class Solution {
public List<Integer> findDisappearedNumbers(int[] nums) {
for (int i = 0; i < nums.length; i++) {
int index = Math.abs(nums[i]) - 1;
if (nums[index] > 0) {
nums[index] = -nums[index];
}
}
List<Integer> result = new ArrayList<>();
for (int i = 0; i < nums.length; i++) {
if (nums[i] > 0) {
result.add(i + 1);
}
}
return result;
}
}
C++ 版本:
cpp复制class Solution {
public:
vector<int> findDisappearedNumbers(vector<int>& nums) {
for (int i = 0; i < nums.size(); i++) {
int index = abs(nums[i]) - 1;
if (nums[index] > 0) {
nums[index] = -nums[index];
}
}
vector<int> result;
for (int i = 0; i < nums.size(); i++) {
if (nums[i] > 0) {
result.push_back(i + 1);
}
}
return result;
}
};
这三个版本的思路完全一样,唯一的区别是语法细节。Python 里 abs() 直接取绝对值,Java 里用 Math.abs(),C++ 里则是 abs()。要注意 C++ 的 abs() 在
3.3 边界条件与细节处理
这个解法看着简单,但细节坑不少,我把它们一条条梳理出来。
第一个大坑是重复元素的处理。当第二次遇到同一个数字时,它对应的下标位置已经被标记为负数,此时不能再取一次负,否则负数会变回正数,标记就失效了。所以代码里要加判断 if (nums[index] > 0),只有当前是正数时才取反。我见过有人把条件写成 if (nums[index] != 0),这不对,因为 0 不是我们要处理的情况,而且按题意数组元素取值范围是 [1, n],不存在 0,但这种写法语义不清晰,容易埋雷。
第二个大坑是取绝对值的时机。在遍历过程中,nums[i] 可能已经被之前的操作改成了负数,所以访问它时必须用 abs(nums[i])。如果直接拿 nums[i] - 1 当下标,在元素为负数时就会得到负数下标,直接越界崩溃。我在调试时遇到最多的就是这个问题,几乎每次手写都会踩一遍。
第三个坑是边界条件:当 x = n 时,x - 1 = n - 1 正好是数组最后一个下标,不会越界。但如果你用 nums[i]作为下标而不是 nums[i] - 1,那就直接越界了。所以下标转换一定要牢记“减一”。
还有一个容易忽略的点:最后判断缺失数字时,遍历的下标 i 是从 0 开始的,但输出的是数字,也就是 i + 1。这一步看似简单,但如果你在写返回值时忘了加 1,结果就会整体错位,输出的是下标而不是数字本身,测试用例当场挂掉。
4. 常见问题与排查技巧实录
4.1 经典翻车现场汇总
我把自己刷这道题以及帮别人 review 代码时遇到的典型问题整理成了一张表,方便你对照排查:
| 症状 | 可能原因 | 解决方案 |
|---|---|---|
| 数组越界异常 | 用 nums[i] 而不是 abs(nums[i]) - 1 作为下标 | 统一使用 abs(nums[i]) - 1 |
| 结果缺失部分数字 | 重复元素导致负数标记被还原 | 加条件 nums[index] > 0 才取反 |
| 结果多出一些数字 | 第二次遍历用错了判断条件,比如用了 != 0 |
改为 > 0 判断正数 |
| 返回结果为下标而非数字 | 忘了加 1 | 输出时统一用 i + 1 |
| 数组元素全部变成负数 | 极端情况下所有数字都存在 | 这是正常情况,返回空列表即可 |
第一次写的时候,最容易翻车的是“重复元素导致负数标记被还原”。比如 [1, 1, 2] 这个例子,第一次遇到 1,把 nums[0] 标记为 -1;第二次又遇到 1,如果直接取反,nums[0] 会变回 1,标记丢失。最终扫描时 nums[0] 为正,就误认为数字 1 不存在,结果就错了。所以 if (nums[index] > 0) 这个判断不是优化,是必需品。
另外,还有一个很多初学者不太理解的细节:题目说数组元素“可能为负数”,但其实初始数组全是正数,负数是我们自己标记出来的。这也提醒你,在分析问题时,不能想当然地认为数组元素在任何时候都是正数。
4.2 从这道题延伸出去:同类题型的套路识别
448 题不是孤立的,它属于一个更大的题型家族:利用数组下标和值之间的映射关系,在 O(1) 空间内统计或查找信息。理解了这道题,下面这些题你会觉得似曾相识:
-
- 寻找重复数:数组长度为 n + 1,元素范围 [1, n],要求找重复的那个数字。虽然解法会用到快慢指针,但下标映射的思想是相通的。
-
- 缺失的第一个正数:元素可能包含负数和超过 n 的值,需要你先把数组“净化”到有效范围,再用原地标记法。这道题是 448 的加强版。
-
- 数组中重复的数据:反过来,找出所有出现两次的数字,本质上就是 448 的镜像操作。
-
- 错误的集合:集合 S 包含从 1 到 n 的整数,但因为错误导致一个数字重复、一个数字缺失,找出这两个值。这题也可以用符号标记法,一次遍历搞定。
能把这些题放在一起看,你就明白力扣的考察点从来都不是单个知识点,而是“数组下标映射”这个思维模型在不同场景下的套用。448 这道题练熟了,后面几道题的上手速度会明显快很多。
4.3 刷hot100的经验建议
最后说点 hot100 刷题路上的体会。很多人刷题是“题海战术”,一天刷十几道,但过两天全忘了。我的经验是:每道题至少做三遍。第一遍独立思考,哪怕做不出来也要先卡一阵,卡的过程才是思维训练的过程;第二遍看题解,把最优解吃透,手动模拟一遍全过程;第三遍隔一周后再做一遍,只凭记忆和直觉写,能流畅写出来才算真正掌握。
448 这道题非常适合作为“数组下标映射”类题型的入门题,因为它不需要复杂的算法,只需要一点灵巧的思维转换。如果你能不看题解、手动模拟出负数标记法,那说明你已经初步具备了“在原数组上动脑筋”的意识,这在很多工程场景里也是很有用的能力。
另外,刷题时不要只盯着 AC(Accepted),要关注复杂度分析。面试官真正想考察的,往往不是你会不会背答案,而是你能否分析不同方案的优劣,并在约束条件下做出合理取舍。拿 448 来说,哈希表法能 AC,但 O(n) 空间和 O(1) 空间在面试评价上是两码事。你可以先给哈希表法,再自然过渡到负数标记法,这个过程本身就能展示你的分析能力和代码功底。
多提一嘴,数组下标映射这种思路,不只用在刷题里。在我处理日志去重、数据对账这些实际业务时,也经常用它。比如有一批订单 ID 在 1 到 10000 之间,我需要快速找出哪些 ID 没有对应记录,直接用一个长度为 10000 的数组做标记就行,比哈希表省内存,速度还快。刷题的价值从来都不只在面试,它训练的是一种思维习惯,这种习惯会渗透到你写工程代码的每一个角落。
