力扣hot100-41.缺失的第一个正数-原地哈希详解

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

如果 1n 中存在任意缺失数,它一定比 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)

只使用了 ntargetIndextemp 等常数个额外变量,直接在 nums 上交换。

13. 总结

这题的难点不在于"找最小缺失正数",而在于题目限制:

text 复制代码
O(n) 时间。
O(1) 额外空间。

答案只可能在 1n + 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;第一个位置和数字对不上的地方,缺的就是下标加一。
相关推荐
祖力553 小时前
数据结构的基本概念与单向链表(链表数据类型构造、创建、头插、尾插、头删、尾删)
数据结构·链表
大明者省4 小时前
WSL2 Ubuntu22.04 GPU训练环境配置指南
人工智能·算法·计算机视觉
白狐_7986 小时前
408数据结构第8章:排序②——性质对比秒杀、场景选择与外部排序
java·数据结构·算法
zander2586 小时前
LeetCode 84:柱状图中的最大矩形——单调栈如何确定左右边界
java·数据结构·算法
Shell运维手记7 小时前
Linux 常用基础命令学习笔记
linux·运维·笔记·学习·算法·github
杨航 AI7 小时前
O(n log n):线性对数原理拆解 这个是排序算法的黄金复杂度之一。
算法·排序算法
船厂电气自动化ai大模型8 小时前
AI大模型与数学 第32课 函数凹凸性与二阶导数:拐点求解、凹凸区间计算(10道二阶导数计算题)
数据结构·人工智能·python·深度学习·算法
旖旎夜光8 小时前
LeetCode 3:无重复字符的最长子串(滑动窗口) —— 题解
数据结构·c++·算法·leetcode·滑动窗口
叠层归一研究院9 小时前
AGI 系统(十一):二阶网络 — 二阶递归 × 多主体 (元符号网络)
开发语言·人工智能·算法·php·agi
zlinear数据采集卡9 小时前
D223的PWM电机控制:6路独立脉冲+加减速算法深度解析
arm开发·stm32·嵌入式硬件·算法·fpga开发·架构