LeetCode 41. 缺失的第一个正数:原地哈希详解
1. 这道题属于什么算法思想
这题主要归类为:
text
数组 / 原地哈希 / 原地定位 / 循环排序思想
不同题解可能使用不同名称,但它们强调的是同一件事:
text
不使用额外 HashSet 或额外标记数组,
而是复用 nums 自身的下标,记录某个正整数是否出现过。
这题的直接映射关系是:
text
数字 x -> 下标 x - 1
例如数组长度为 4:
text
数字 1 -> 下标 0
数字 2 -> 下标 1
数字 3 -> 下标 2
数字 4 -> 下标 3
因此,如果处理后:
text
nums[i] == i + 1
就表示:
text
正整数 i + 1 出现过。
2. 这道题到底在问什么
题目要求:
text
给一个未排序的整数数组,找到没有出现过的最小正整数。
例如:
text
nums = [3, 4, -1, 1]
数组中出现过的正整数有:
text
1、3、4
最小没有出现的正整数是:
text
2
所以答案是:
text
2
题目额外要求:
text
时间复杂度 O(n)
额外空间复杂度 O(1)
这两个限制决定了,排序、HashSet、额外 boolean[] 都不能作为最终解法。
3. 为什么直接排序可以做,但不满足题目要求
如果不考虑复杂度限制,排序法非常直观:
java
Arrays.sort(nums);
int expected = 1;
for (int num : nums) {
if (num == expected) {
expected++;
}
}
return expected;
例如:
text
nums = [3, 4, -1, 1]
排序后:[-1, 1, 3, 4]
从 1 开始检查:
text
看到 -1:忽略
看到 1:expected 变为 2
看到 3:2 没有出现
所以答案是 2。
这个思路正确,但排序需要:
text
O(n log n)
题目要求 O(n),所以必须找一种不完整排序、但能更快确认数字是否出现的方法。
4. 为什么答案只可能在 1 到 n + 1
设数组长度为:
text
n
如果数组恰好包含:
text
1、2、3、...、n
那么最小缺失正整数是:
text
n + 1
如果 1 到 n 中存在任意缺失数,它一定比 n + 1 小,并且它就是答案。
因此答案范围一定是:
text
1 到 n + 1
这个结论意味着:
text
小于等于 0 的数字,不可能是答案。
大于 n 的数字,也不可能是答案。
例如 n = 4 时:
text
-7、0、5、100
都不需要参与定位。
真正需要关心的只有:
text
1 到 n
5. 为什么这里可以使用原地哈希
普通 HashSet 会把"某个数是否出现过"存进额外空间。
这题不允许额外 O(n) 空间,但它刚好提供了一个特殊条件:
text
需要关注的有效数字是 1 到 n。
数组下标恰好是 0 到 n - 1。
二者可以一一对应:
text
数字 x -> 下标 x - 1
这可以看成一个特殊的哈希函数:
text
hash(x) = x - 1
因此,不用额外哈希表,而是直接让数字 x 尽量放到:
text
nums[x - 1]
处理完成后:
text
nums[i] == i + 1:数字 i + 1 出现过。
nums[i] != i + 1:数字 i + 1 没有出现过。
这就是"原地哈希"的含义:
text
直接使用数组下标作为哈希位置,复用原数组记录出现信息。
它不是任意数组题都能使用的技巧,通常需要满足:
text
1. 数组允许被修改。
2. 关键数值范围和数组长度 n 有关,例如 1..n 或 0..n。
3. 题目关心数是否出现、重复、缺失或是否处在正确位置。
6. 原地定位:让数字回到它应该在的位置
规定:
text
数字 1 应该放在下标 0。
数字 2 应该放在下标 1。
数字 3 应该放在下标 2。
...
数字 x 应该放在下标 x - 1。
对于:
text
nums = [3, 4, -1, 1]
下标: 0 1 2 3
经过尽量归位后,数组会变成:
text
[1, -1, 3, 4]
此时:
text
下标 0 放 1,说明 1 出现过。
下标 1 没放 2,说明 2 没出现过。
所以答案是:
text
下标 1 + 1 = 2
注意,我们不追求把数组完全从小到大排序。
例如 -1 仍然在中间,但它不是 1..n 内需要关注的数,因此不影响答案。
这就是原地哈希比完整排序做得更少、但刚好足够解决本题的原因。
7. 为什么必须用 while,而不是 if
交换后,当前位置可能得到一个新的有效数字,它也需要继续归位。
例如:
text
nums = [3, 4, -1, 1]
处理下标 1 时,先把 4 放到下标 3:
text
[-1, 4, 3, 1]
-> [-1, 1, 3, 4]
此时下标 1 得到了 1。
但 1 应该在下标 0,所以还要继续交换:
text
[-1, 1, 3, 4]
-> [1, -1, 3, 4]
因此必须不断处理当前位置,直到:
text
1. 当前数字不在 1 到 n 的有效范围内;或
2. 当前数字已经在正确位置;或
3. 当前数字应该去的位置已经有相同数字。
所以使用:
java
while (...)
而不是一次交换就结束的 if。
8. 为什么要避免重复数字无限交换
关键条件是:
java
nums[i] != nums[nums[i] - 1]
例如:
text
nums = [1, 1]
处理下标 1 时:
text
nums[1] = 1
数字 1 应该放在下标 0,但下标 0 已经有 1。
如果还执行交换:
text
[1, 1] -> [1, 1]
数组完全不变,while 会无限执行。
所以当目标位置已经是相同数字时,不再交换。
9. 用 3, 4, -1, 1 完整推导
text
nums = [3, 4, -1, 1]
n = 4
处理下标 0:
text
nums[0] = 3
3 在有效范围 1..4 内,应放到:
text
下标 3 - 1 = 2
交换下标 0 和下标 2:
text
[3, 4, -1, 1]
-> [-1, 4, 3, 1]
此时:
text
nums[0] = -1
无效,停止处理下标 0。
处理下标 1:
text
nums[1] = 4
4 应放到下标 3:
text
[-1, 4, 3, 1]
-> [-1, 1, 3, 4]
此时下标 1 得到 1,继续让它归位到下标 0:
text
[-1, 1, 3, 4]
-> [1, -1, 3, 4]
处理完成后:
text
[1, -1, 3, 4]
开始从左到右检查:
text
下标 0:nums[0] = 1,应该是 1,正确。
下标 1:nums[1] = -1,应该是 2,不正确。
因此最小缺失正整数是:
text
1 + 1 = 2
10. Java 代码完整注释
java
class Solution {
public int firstMissingPositive(int[] nums) {
int n = nums.length;
// 第一阶段:
// 尽量把每个有效正整数 x 放到下标 x - 1。
for (int i = 0; i < n; i++) {
// 当前位置上的数字必须满足三件事才需要继续交换:
// 1. 在 1 到 n 的有效范围内;
// 2. 它不在自己的正确位置;
// 3. 它应该去的位置不是相同数字,避免重复数字无限交换。
while (nums[i] >= 1
&& nums[i] <= n
&& nums[i] != nums[nums[i] - 1]) {
// 数字 x 应该去下标 x - 1。
int targetIndex = nums[i] - 1;
// 将 nums[i] 和它应该去的位置交换。
int temp = nums[i];
nums[i] = nums[targetIndex];
nums[targetIndex] = temp;
}
}
// 第二阶段:
// 第一个不满足 nums[i] == i + 1 的位置,
// 就说明正整数 i + 1 没有出现。
for (int i = 0; i < n; i++) {
if (nums[i] != i + 1) {
return i + 1;
}
}
// 如果 1 到 n 都出现过,答案就是 n + 1。
return n + 1;
}
}
11. for 套 while,为什么时间复杂度仍然是 O(n)
看到代码中有:
text
for 循环套 while 循环
很容易直接认为是:
text
O(n^2)
但时间复杂度不能只看代码缩进,而要看循环体总共真正执行了多少次。
如果要真的是 O(n^2),必须存在一种情况:
text
for 的每一次循环,都让 while 执行接近 n 次。
这道题做不到。
每次真正进入 while 并交换时,假设当前数字是 x:
text
x 会被放到下标 x - 1。
也就是说,交换后:
text
nums[x - 1] == x
数字 x 已经归位。
归位后的数字不会被后续交换随意移走:之后任何数字也只会去自己的目标下标;若遇到重复 x,条件会阻止它与已经归位的 x 重复交换。
因此:
text
每次交换,至少让一个有效数字最终归位。
数组最多有 n 个位置,因此最多有 n 个有效数字能归位。
所有 while 循环加起来,交换次数最多是 O(n)。
再把总操作拆开:
text
for 循环自身最多执行 n 次。
while 中所有成功交换的总次数最多为 n 次。
每个 i 最终还会做一次条件失败检查,最多 n 次。
总工作量约为:
text
n + n + n = 3n
大 O 忽略常数,因此:
text
O(3n) = O(n)
这种"单次操作看似可能很多,但所有操作总数被某个资源限制住"的分析方式,叫做均摊分析。
12. 复杂度分析
时间复杂度:
text
O(n)
第一阶段归位时,所有交换次数总共最多是 O(n);第二阶段检查一次数组是 O(n)。
空间复杂度:
text
O(1)
只使用了 n、targetIndex、temp 等常数个额外变量,直接在 nums 上交换。
13. 总结
这题的难点不在于"找最小缺失正数",而在于题目限制:
text
O(n) 时间。
O(1) 额外空间。
答案只可能在 1 到 n + 1,所以真正需要关心的是 1..n。
由于:
text
数字 1..n 可以一一映射到下标 0..n-1,
我们可以直接让:
text
数字 x 尽量放到下标 x - 1。
归位后,第一个不满足:
text
nums[i] == i + 1
的位置,就表示最小缺失正整数是:
text
i + 1
可以把这题记成一句话:
text
让数字 x 回到下标 x - 1;第一个位置和数字对不上的地方,缺的就是下标加一。