力扣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;第一个位置和数字对不上的地方,缺的就是下标加一。
相关推荐
WL学习笔记3 小时前
堆(数据结构)
数据结构·
星空露珠4 小时前
28种颜色对应名称,
开发语言·数据库·算法·游戏·lua
QXWZ_IA5 小时前
桥梁数字孪生怎么落地?
人工智能·科技·算法·智能硬件·政务
Kel5 小时前
输出层与反分词(Output Layer & Detokenization)
人工智能·算法·架构
陕西企来客5 小时前
2026年7月技术好GEO优化方案:算法适配与内容策略
人工智能·算法·机器学习·技术好geo优化
风栖柳白杨6 小时前
【面试】AI算法工程师_空白自测版本
人工智能·算法·面试
闪电悠米7 小时前
力扣hot100-73.矩阵置零-标记数组详解
算法·leetcode·矩阵
栋***t7 小时前
从“纸质试卷”到“AI智能组卷”,麦塔在线考试系统如何重构出题逻辑?
java·大数据·人工智能·算法·重构
wabs6667 小时前
关于图论【卡码网100.最大岛屿的面积的思考】
数据结构·算法·图论