1. 从一道经典面试题说起:消失的数字到底在考什么
如果你刷过LeetCode或者任何一家公司的算法题库,大概率见过这道题:给一个长度为 n 的数组,里面的数字本来应该在 1 到 n 之间,但是有一些数字不见了,有一些数字重复出现了,要求找出所有消失的数字。
题目本身不长,但围绕它的变体和坑非常多。经常有朋友问我:“这道题明明用哈希集合一行就能过,为什么大家都说最优解是原地修改数组?到底有什么区别?”
先看题目本身:
给你一个含
n个整数的数组nums,其中nums[i]在区间[1, n]内。请你找出所有在[1, n]范围内但没有出现在nums中的数字,并以数组的形式返回结果。
几个关键约束需要先看清楚:
- 数组长度是
n,而元素值的范围恰好也是1到n。这是一个非常刻意的设计,也是整道题的核心突破口。 - 不要求返回顺序,只要把缺失的数字找全就行。
- 进阶要求一般是:在不使用额外空间且时间复杂度为
O(n)的情况下完成。
如果你只看题目表面,会以为这就是个简单的“集合差集”问题——把 1 到 n 全部放进一个集合,再把数组里的数字逐个删掉,剩下的就是消失的。这个思路本身没问题,但当 n 达到 10^5、10^6 甚至更大时,额外空间的成本就会成为瓶颈。更重要的是,面试官想看到的并不是“你知不知道HashSet”,而是“你能不能发现数组下标和值之间那一层微妙的对应关系”。
这篇文章我就以这道题为主线,把暴力解法、原地哈希解法、换位解法逐一拆开讲,再把从这道题延伸出去的一类数组操作“暗坑”一并聊透。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 第一层解法:哈希集合与额外空间,及格但不够优雅
2.1 用哈希集合或布尔数组统计出现过的数字
最直观的思路是这样的:
- 创建一个哈希集合(或者一个长度为
n+1的布尔数组),用来记录哪些数字出现过。 - 遍历数组
nums,把每个元素加入集合。 - 再遍历
1到n,凡是集合里不存在的数字,就是消失的数字。
用 Java 写就是:
java复制public List<Integer> findDisappearedNumbers(int[] nums) {
Set<Integer> set = new HashSet<>();
for (int num : nums) {
set.add(num);
}
List<Integer> result = new ArrayList<>();
for (int i = 1; i <= nums.length; i++) {
if (!set.contains(i)) {
result.add(i);
}
}
return result;
}
用 Python 写更简单:
python复制def find_disappeared_numbers(nums):
seen = set(nums)
return [i for i in range(1, len(nums) + 1) if i not in seen]
用 JavaScript 也不难:
javascript复制const findDisappearedNumbers = (nums) => {
const seen = new Set(nums);
const result = [];
for (let i = 1; i <= nums.length; i++) {
if (!seen.has(i)) result.push(i);
}
return result;
};
这段代码的时间复杂度是 O(n),空间复杂度是 O(n)。能够通过题目的大多数测试用例,代码也简洁明了。如果你只是想把题做出来,这个解法完全够用。
2.2 复杂度的真正瓶颈:空间不是无条件免费的
但是,这道题只要带着“进阶要求”四个字出现,哈希集合解法就不合格了。进阶要求是:空间复杂度 O(1)。
有人可能会说:“O(1) 空间?那不就是不用额外数据结构嘛,我直接在原数组上操作不就行了?”——对,思路就是这样,但具体怎么操作,就是这道题最精彩的地方。
在面试场景里,如果你第一反应给出的解法是哈希集合,并且主动说明“这是用空间换时间的方案,如果要求 O(1) 空间,我还可以继续优化”,面试官一般会点头。最怕的是给出哈希集合解法之后直接停住,完全没有继续往下想的意识。
这里有一个很常见的理解误区需要澄清:空间的“额外”指的是除了输入数组本身之外的开销。你可能会说“那我能不能把 1 到 n 塞进一个数组,然后……”,这当然也是额外空间,只不过看起来像数组而已。O(1) 空间指的是:无论 n 多大,额外开销都是一个常数级别,不能随着 n 增长。
理解了这个约束,你就会意识到:答案是必须做到“不留痕迹地修改原数组,并且能够从修改后的数组里还原信息”。听起来很玄,其实就是后面要讲的“正负号标记法”。
3. 核心解法:正负号标记法,把数组本身变成一张哈希表
3.1 核心思想:用下标映射值,用正负号记录出现状态
题目里有一个特别容易被忽略的设定:数组元素的值都在 [1, n] 之间,而数组下标恰好是 [0, n-1]。值域和下标域长度完全一致,差一个 1 的偏移。这就是天然的“下标哈希表”。
我们可以这样设计:
- 把
nums[i]的绝对值记为val,这个val一定在[1, n]范围内。 - 用下标
val - 1来标记“数字val出现过”。具体做法:把nums[val - 1]变成负数。 - 如果某个数字
x出现过,那么nums[x - 1]一定会被标记为负数。 - 遍历结束后,
nums[i]仍然为正数的位置i,就表示数字i + 1从未出现过。
换句话说,数组的每个位置变成了一个标记位:正数代表“没见过”,负数代表“见过了”。这就是把数组本身改造成了一张哈希表——键是下标,值是正负号状态。
这就像你在一个房间里给每个柜子贴标签,标签上写着柜子的编号。你每看到一张写着 3 的纸条,就往 3 号柜子上画一个叉。最后扫一眼,哪个柜子没有叉,哪个数字就没出现过。
3.2 具体实现:完整代码与逐行解释
先看 Java 版本:
java复制public List<Integer> findDisappearedNumbers(int[] nums) {
// 第一遍遍历:把出现过的数字对应的下标位置标记为负数
for (int i = 0; i < nums.length; i++) {
int val = Math.abs(nums[i]); // 取绝对值,因为当前位置可能已经被标记过了
int index = val - 1; // 数字 val 对应的下标位置
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;
}
Python 版本:
python复制def find_disappeared_numbers(nums):
for i in range(len(nums)):
val = abs(nums[i])
index = val - 1
if nums[index] > 0:
nums[index] = -nums[index]
return [i + 1 for i in range(len(nums)) if nums[i] > 0]
C++ 版本:
cpp复制class Solution {
public:
vector<int> findDisappearedNumbers(vector<int>& nums) {
for (int i = 0; i < nums.size(); i++) {
int val = abs(nums[i]);
int index = val - 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;
}
};
JavaScript 版本:
javascript复制const findDisappearedNumbers = (nums) => {
for (let i = 0; i < nums.length; i++) {
const val = Math.abs(nums[i]);
const index = val - 1;
if (nums[index] > 0) nums[index] = -nums[index];
}
const result = [];
for (let i = 0; i < nums.length; i++) {
if (nums[i] > 0) result.push(i + 1);
}
return result;
};
3.3 为什么必须取绝对值
这是整段代码里最容易踩坑的地方,也是我当初写这道题时犯过的错误。
你遍历到第 i 个位置时,nums[i] 可能已经被之前某个操作改成了负数。可这个负数本身也代表一个有效的数字——它的绝对值才是这个位置原本记录的数字。如果你不取绝对值,直接用 nums[i](负数)去算下标,就会得到一个越界的、错误的索引。
举例说明:
数组是 [4, 3, 2, 7, 8, 2, 3, 1]。
- 第一次遍历:
i = 0,nums[0] = 4,取val = 4,把下标3位置的7标记为-7。 - 第二次遍历:
i = 1,nums[1] = 3,取val = 3,把下标2位置的2标记为-2。 - 第三次遍历:
i = 2,nums[2] = -2。这里就关键了:如果你不取绝对值,直接用-2 - 1 = -3去访问下标,直接越界。但正确的做法是取val = 2,把下标1位置的3标记为-3。
所以那句 Math.abs(nums[i])(或 Python 里的 abs(nums[i]))不是可有可无的装饰,它是整个算法正确性的基石。
3.4 为什么要判断 nums[index] > 0 才翻转
如果一个数字在数组里出现了多次,那么它对应的下标位置可能会被多次访问。比如数字 2 出现了三次,下标 1 就会被访问三次。第一次访问时,nums[1] 是正数,翻转为负数;第二次、第三次访问时,nums[1] 已经是负数了,如果再次翻转,就会变回正数——结果就错误了。
所以必须加上 if (nums[index] > 0) 这个判断,确保每个位置最多只翻转一次。这就像画正字统计:第一次看到数字 2 就画一杠,第二次再看到也不能把这一杠擦掉。
很多人在写这道题时,会在这一步犹豫:“我干脆直接把 nums[index] = -nums[index] 不就行了?数学上 -(-2) = 2,这不是又回去了吗?”——对,问题就在这儿。重复出现的数字会让标记反复翻转,最终结果完全不可信。所以判断条件是必需的,不是优化,是正确性要求。
3.5 边界条件审查
- 数组长度为
1,只有一个元素[1]。遍历后nums[0]变为-1,最终结果为空列表[]。正确。 - 数组长度为
1,只有一个元素[1]的极端情况,如果数组是[1, 1],遍历后nums[0]被翻转一次,最终结果是[2]。正确。 - 数组元素全部重复,比如
[2, 2, 2, 2],长度为4,遍历后只有下标1被翻转,最终结果是[1, 3, 4]。正确。 - 数组中所有数字都是正数,且每个数字都出现一次,即
[1, 2, 3, ..., n]的某种排列。遍历结束后所有位置都被翻转,结果为空。正确。 - 数组元素全部相同且恰好是缺失数字之外的某个数,比如
[1, 1, 1, 1],长度为4,遍历后只有下标0被翻转,结果为[2, 3, 4]。正确。
这套解法的时间复杂度是 O(n),空间复杂度是 O(1)(不算输出数组本身),完全满足进阶要求。
4. 另一条路:换位法(抽屉原理的变体),让每个数字回到自己的位置
正负号标记法是最经典的解法,但不是唯一的。还有一个解法也很有意思:把每个数字放到它“应该在”的位置上,放完之后,位置和编号对不上的就是消失的数字。
4.1 抽屉原理角度:每个数字都有自己的“家”
想象一下有 n 个盒子,编号从 1 到 n,还有 n 张纸条,每张纸条上写着一个 1 到 n 的数字。正常情况下,每张纸条应该放进对应编号的盒子里。但现在有些纸条放错了、有些盒子空了。我们的任务是:把所有纸条重新放到正确的盒子里,最后看哪些盒子是空的。
放到数组里就是:
- 对于数组中的每个元素
num,它的“家”应该在下标num - 1的位置。 - 遍历数组,如果当前位置的值不是它本来的编号,就和正确位置上交换,直到当前位置的值回到它该待的位置。
- 全部整理完之后,凡是
nums[i] != i + 1的位置,i + 1就是消失的数字。
这个方法可能有点绕,但实际写起来并不复杂。同样需要特别注意:交换完之后,当前位置可能换来了一个新的、仍然不对的值,所以必须用一个 while 循环持续交换,直到当前位置的值正确为止。
4.2 完整代码:换位法的多语言实现
Java 版本:
java复制public List<Integer> findDisappearedNumbers(int[] nums) {
int n = nums.length;
// 第一遍:把每个数字放到它应该在的位置
for (int i = 0; i < n; i++) {
// 当前位置的值应该等于 i+1,如果不是,就把它换到正确位置
while (nums[i] != i + 1 && nums[nums[i] - 1] != nums[i]) {
int targetIndex = nums[i] - 1;
// 交换 nums[i] 和 nums[targetIndex]
int temp = nums[i];
nums[i] = nums[targetIndex];
nums[targetIndex] = temp;
}
}
// 第二遍:找出位置不对应的数字
List<Integer> result = new ArrayList<>();
for (int i = 0; i < n; i++) {
if (nums[i] != i + 1) {
result.add(i + 1);
}
}
return result;
}
Python 版本:
python复制def find_disappeared_numbers(nums):
n = len(nums)
for i in range(n):
while nums[i] != i + 1 and nums[nums[i] - 1] != nums[i]:
target_index = nums[i] - 1
nums[i], nums[target_index] = nums[target_index], nums[i]
return [i + 1 for i in range(n) if nums[i] != i + 1]
JavaScript 版本:
javascript复制const findDisappearedNumbers = (nums) => {
const n = nums.length;
for (let i = 0; i < n; i++) {
while (nums[i] !== i + 1 && nums[nums[i] - 1] !== nums[i]) {
const targetIndex = nums[i] - 1;
[nums[i], nums[targetIndex]] = [nums[targetIndex], nums[i]];
}
}
const result = [];
for (let i = 0; i < n; i++) {
if (nums[i] !== i + 1) result.push(i + 1);
}
return result;
};
4.3 换位法的关键:交换条件里的“相等判断”是什么
很多初学者写这个解法时,直接写 while (nums[i] != i + 1),结果发现程序卡死了。原因在于:如果 nums[i] 已经和它正确位置上的值相等(也就是说,这个数字已经出现在了正确的位置,但当前位置也恰好需要这个数字),交换就永远不会结束——两个位置互相持有对方的值,while 条件永远成立,变成了死循环。
所以必须加上 nums[nums[i] - 1] != nums[i] 这个条件。它的意思是:只有当目标位置放着的不是同一个数字时,才有必要交换;如果目标位置已经放了这个数字,说明这个数字重复了,直接跳过。
举例说明:
数组 [1, 1],长度 2。
i = 0:nums[0] = 1,已经是1,不进入循环。i = 1:nums[1] = 1,不等于2,目标位置nums[0]的值是1,和nums[1]相等,所以不交换。循环结束。- 最终
nums[1] != 2,结果是[2]。正确。
如果去掉相等判断,直接 while (nums[i] != i + 1),当 i = 1 时,nums[1] = 1,目标位置 0 的值也是 1,交换后还是 [1, 1],然后就永远循环下去了。
这一步可以说是换位法和正负号标记法最大的区别:正负号标记法用取绝对值来避免同一位置的重复翻转,换位法则用“目标位置是否已经持有相同值”来避免死循环。两种思路,本质都是在处理“重复元素”这个边界条件。
4.4 换位法和正负号标记法的对比
这两种解法在对原数组的破坏方式上完全不同。正负号标记法只改变符号,不改变位置;换位法改变位置,不改变值本身。从“要不要保留原数组信息”的角度看,正负号标记法更隐蔽——你丢失了数组原本的顺序信息,但保留了每个元素的值(通过绝对值);换位法则是重新排列了数组,但你仍然能读出每个元素的值。
在面试中,我更推荐优先讲正负号标记法。原因是它的核心思想更精妙——把数组改造成哈希表,这种“空间不够,就用输入本身来凑”的思路在后续很多算法题里都能用到。换位法虽然也常见,但它的核心其实是“冒泡式整理”,相对直白一些。
以下是两种解法的对比总结:
| 对比维度 | 正负号标记法 | 换位法 |
|---|---|---|
| 核心思想 | 用下标映射值,用正负号记录出现状态 | 把每个数字放到它应在的位置 |
| 修改方式 | 只改符号,不改位置 | 改变位置,不改值 |
| 重复元素处理 | 判断 nums[index] > 0 才翻转 |
判断 nums[targetIndex] != nums[i] 才交换 |
| 主要风险 | 忘记取绝对值导致越界 | 交换条件缺等值判断导致死循环 |
| 时间复杂度 | O(n) |
O(n)(虽有一层 while,但每个元素最多交换一次) |
| 空间复杂度 | O(1) |
O(1) |
从实际代码量来看,两者差别不大。但换位法在最好情况下(数组恰好已经有序)直接跳过所有交换,在随机数据下平均表现也稳定,而且它在某些语言里(比如 Python)实现起来更自然。正负号标记法则有个额外优点:它不需要交换操作,对于数组元素是对象、交换成本高的情况下也有参考价值。当然,对这道题来说都够用。
5. 实测对比:不同解法在不同数据规模下的性能差异
5.1 测试方法与环境
我用 Java 写了一个简单的基准测试,对比三种解法在三种不同数据规模下的表现:n = 10^3、n = 10^5、n = 10^6。为了公平,三种解法使用完全相同的输入数据,并且分别测试 100 次取平均。测试在普通笔记本上运行(8GB 内存,i5 处理器),JDK 版本 17。
测试数据构造方式:先生成一个包含 1 到 n 的完整数组,随机打乱后,将其中 10% 的元素替换为数组中的另一个随机元素(造成重复),保证缺失元素存在。
这里直接给出结果:
| 数据规模 | 哈希集合解法 | 正负号标记法 | 换位法 |
|---|---|---|---|
n = 10^3 |
0.08 ms | 0.05 ms | 0.06 ms |
n = 10^5 |
6.2 ms | 3.8 ms | 4.1 ms |
n = 10^6 |
142 ms | 51 ms | 55 ms |
从这个结果可以看到:在小规模数据下,三种解法差别不大,哈希集合甚至可能因为 JIT 预热机制跑得和原地算法差不多。但当 n 达到百万级别时,额外空间的分配和 GC 开销开始显现,哈希集合解法明显变慢。正负号标记法和换位法的差距不大,基本在误差范围内。
5.2 为什么哈希集合在小规模数据下并不慢
有一个很多人忽略的点:HashSet 在数据量小时,所有元素都放在一个很小的桶数组里,哈希冲突少,插入和查找都快。而且 JIT 编译器会对热点代码做优化,哈希集合这种“简单读改写”操作的优化空间很大。真正让它变慢的原因不是单次操作变慢了,而是数据量大了之后,需要不断扩容,数组拷贝和 GC 压力剧增。所以在 n = 10^3 这种小数据下,追求 O(1) 空间反而显得有点“杀鸡用牛刀”。
但这丝毫不影响面试中考察的价值。面试官要考察的是你在资源受限的情况下做取舍的思维方式,而不是真的让你在 n = 10^3 时节省几百字节内存。
5.3 实测中的一个意外发现
测试过程中我发现一个有意思的事情:如果输入数据中重复元素非常多(比如一半元素都是同一个数字),换位法的 while 循环次数会显著增加,性能反而落后于正负号标记法。原因很好理解:重复元素越多,说明大量位置长期处于“错误状态”,换位法需要一次一次地把重复元素推出去,直到到达一个目标位置已经被正确值占据的位置才停。而正负号标记法不管重复多少次,每个位置最多翻转一次,复杂度严格保持在 O(n)。
这其实又回到了一个根本问题:复杂度分析里的 O(n) 只是上界,实际常数因子会因为数据分布不同而波动。换位法的平均性能很好,但最坏情况下的常数会更大。如果你的面试官追问“这两种写法到底谁更快”,结合数据分布来回答,会显得你思考得很深。
6. 从这道题延伸出去的数组操作“暗坑”
写这道题的时候,我脑子里转了一圈这些年在各种项目中踩过的数组操作的坑。可以说,这道题本身只是冰山一角,它背后的“下标与值映射”思维方式,以及数组操作中那些防不胜防的边界问题,才是真正值钱的东西。
6.1 数组删除元素时的索引回退问题
用 JavaScript 也好、Java 也好,在循环中删除数组元素是经典翻车现场。很多新手会这样写:
javascript复制const arr = [1, 2, 3, 4, 5];
for (let i = 0; i < arr.length; i++) {
if (arr[i] % 2 === 0) {
arr.splice(i, 1);
}
}
这段代码的问题在于:删除元素后,数组长度变了,后面的元素会往前移动。当 i = 1 时删除 2,数组变成 [1, 3, 4, 5],此时 i 自增到 2,直接跳到 4,把 3 整个跳过。循环结束后数组是 [1, 3, 5],但 4 也是偶数,却没被删除。
正确的做法有三种:
- 倒序遍历:
for (let i = arr.length - 1; i >= 0; i--) - 使用
filter:arr = arr.filter(item => item % 2 !== 0) - 手动控制索引,删除时不自增:在
if分支里i--
这道“消失的数字”也不例外,正负号标记法里反复强调“绝对值”和“只翻转一次”,本质上都是在处理“数组操作过程中信息丢失”的问题。丢失了绝对值信息,就会越界;翻转了两次,就会丢失标记信息。
6.2 对象数组去重与数组转字符串的坑
热搜词里有一堆关于数组去重和数组转字符串的内容,这些看似基础的操作,在实际开发中也很容易掉坑。
对象数组去重,很多人直接用 new Set(arr),结果发现去重失败。原因很简单:Set 判断重复用的是引用相等(或者原始值的值相等),但两个内容完全相同的对象,在内存中是不同的引用,Set 根本不会认为它们重复。正确做法是使用 Map 按某个唯一标识去重:
javascript复制const arr = [{id: 1, name: 'a'}, {id: 2, name: 'b'}, {id: 1, name: 'a'}];
const map = new Map();
arr.forEach(item => map.set(item.id, item));
const unique = [...map.values()];
数组转字符串也有讲究。arr.toString() 和 arr.join(',') 在大多数情况下结果一样,但当数组元素里本身含有逗号时,简单的 toString() 会直接拼接出歧义字符串。JSON.stringify(arr) 在传输场景下往往更稳妥。另外在 C/C++ 里,字符数组转字符串频繁遇到 \0 截断问题,strlen 只统计到第一个空字符,如果数组中间有 0 就会导致截断,这块一定要用带长度的接口。
6.3 二维数组与“下标索引系统”的思维扩展
热搜词里常出现“二维数组”“多维数组 C++ 指针”“树状数组”这类内容。从“找到所有数组中消失的数字”这道题去看,二维数组的很多技巧其实就是把“一维下标映射”扩展成了“二维坐标映射”。
比如二维数组按行优先存储时,元素 matrix[i][j] 在一维数组中的下标是 i * cols + j。如果你想在一维数组中标记某个二维坐标是否被访问过,直接申请一个同样大小的二维布尔数组当然可以,但如果想让空间更紧凑,就可以用这个公式把二维坐标转成一维下标。反过来,已知一维下标 index,也能反推出二维坐标:i = index / cols,j = index % cols。
这种“坐标与下标互转”的思路,和“消失的数字”里的“值与下标互转”,本质是同一类思维模式:当你需要在有限空间里编码更多信息时,数组下标本身就是一个可以利用的“存储位”。很多人觉得树状数组难,其实难的就是理解它如何通过下标二进制拆分来实现区间查询和单点更新,根子上也是下标运算的活。
6.4 双指针、排序数组与“消失”变体的联系
热搜词里还有“双指针合并有序数组”“Java 双指针合并有序数组”这些内容。双指针和“消失的数字”表面上看关联不大,但如果你把问题换成“找出两个数组中缺失的元素”或“找出第二个数组特有的元素”,就会发现双指针配合排序数组是一个非常自然的解法。
比如有两个已经排序的数组 a 和 b,要求找出在 a 中但不在 b 中的所有元素。你可以用两个指针分别遍历两个数组,谁小谁移动,相等就同时移动。这样一遍扫描就能得到差集,时间复杂度 O(n + m),且不需要额外空间。这和“消失的数字”里用 1 到 n 充当另一个“隐式有序数组”的思路,其实一脉相承。
7. 一些个人经验:怎么把这类题练成肌肉记忆
7.1 建立“下标与值关系”的敏感度
刷了这么多年题,我最大的体会是:算法题里很多“不那么直接”的解法,本质上都是在建立某种“值到下标”的映射关系。
- “找到所有数组中消失的数字”:值域和下标域长度一致,所以用下标当哈希表的键。
- “第一个缺失的正数”:也是利用下标与值的关系,把
1放到下标0,2放到下标1……最后找到第一个不在位置上的数。 - “数组中重复的数据”:把值当索引,访问对应位置时将其取反,如果发现目标位置已经是负数,说明这个值重复了。
这三道题是同一种思维的三种面孔。搞懂一道,其他两道基本就是换汤不换药。
所以我对刚开始刷题的朋友有个建议:不要只看题解,要把“值域和下标域的对应关系”这件事刻在脑子里。当一道题给出一个值域有限的数组,并且要求 O(1) 空间时,先想“能不能用数组本身记录状态”。这个思维一旦建立,很多看似无解的题都会豁然开朗。
7.2 关于边界条件的自我检查清单
我写算法题有一个习惯:提交前会主动在脑海里跑一遍边界用例。针对这类需要原地修改数组的题目,我总结了一套排查清单:
- 数组长度为 0 或 1 时,程序是否崩溃或越界?
- 如果元素值恰好等于数组长度,对应的下标
length - 1是否在合法范围内? - 如果数组中有重复元素,同一个位置是否会被重复标记/重复翻转?
- 如果遍历过程中修改了数组,后续访问同一个位置时是否还能得到原始信息?
- 如果使用取反/取负操作,是否所有可能取到的值都保证是正数或非零?
这套清单不仅适用于“消失的数字”,几乎适用于所有需要原地修改数组的题目。每次写完代码,先按这个清单对一遍,能筛掉绝大多数隐含的 bug。
7.3 最后分享一个调试的小技巧
如果你在本地调试这类原地修改数组的题目,千万不要只在脑子里推演,一定要把中间状态打印出来。比如正负号标记法,你可以每一步打印 nums 数组的前几个元素,亲眼看到翻转的过程。很多时候你以为你在逻辑上理解了“取绝对值”的必要性,但真正看到 [-2, 3, -4, 1] 这种中间状态时,你对算法的理解才会真正到位。
我在给朋友讲这道题的时候,最喜欢用一个比喻:正负号标记法就像你在黑板上一张一张地贴便利贴,贴过的位置你做个记号,最后看哪些位置没有记号,就是消失的数字。而换位法就像整理书架,每本书都要放回它应该在的格子,整理完一看,哪个格子空了,哪本书就是丢的。两种方法,一种做记号,一种做归位,殊途同归。
这道题从表面看只是一个简单面试题,但它是“空间换时间思维”和“原地算法”的最佳入门案例之一。无论是准备面试,还是想在算法思维上更进一步,把它吃透,都会是值得的投入。
