目录
[1. 答案的范围在哪里?](#1. 答案的范围在哪里?)
[2. 存储的本质是什么?](#2. 存储的本质是什么?)
[编写 C++ 代码](#编写 C++ 代码)
[💡 面试加分点](#💡 面试加分点)
给你一个未排序的整数数组 nums ,请你找出其中没有出现的最小的正整数。
请你实现时间复杂度为 O(n) 并且只使用常数级别额外空间的解决方案。
示例 1:
输入:nums = [1,2,0]
输出:3
解释:范围 [1,2] 中的数字都在数组中。
示例 2:
输入:nums = [3,4,-1,1]
输出:2
解释:1 在数组中,但 2 没有。
示例 3:
输入:nums = [7,8,9,11,12]
输出:1
解释:最小的正数 1 没有出现。
(来源:Leecode)
解决"缺失的第一个正数"是算法面试中的一座大山。它对时间 O(n) 和空间 O(1) 的严苛要求,迫使我们必须放弃所有常规武器(如哈希表或排序),直接动用第一性原理。
回归本质,寻找物理规律
1. 答案的范围在哪里?
假设数组 nums 的长度为 n。
-
最好情况:数组包含了 1,2,3,...,n。此时,缺失的第一个正数是 n+1。
-
其他情况:只要数组中少了 1 到 n 中的任何一个数,缺失的第一个正数必然落在 [1,n] 之间。
第一性原理结论: 缺失的答案必然在 [1,n+1] 这个闭区间内。这意味着,我们只需要关心数组中落在 [1,n] 范围内的数字,其他的负数、零或者大于 n 的数都是"噪音"。
2. 存储的本质是什么?
既然题目要求 O(1) 空间,意味着我们不能开辟新的哈希表。
第一性原理思考:数组本身就是一块内存空间。数组的索引(Index)天然就是一个哈希表的 Key。
-
数字
1应该放在索引0的位置。 -
数字
2应该放在索引1的位置。 -
数字
i应该放在索引i-1的位置。
如果我们能让这些数字"各回各家",那么最后扫描一遍数组,第一个"房主"不对的位置,就是缺失的数字。
推导逻辑------原地置换
我们要做的就是:遍历数组,如果发现数字 x 在 [1, n] 范围内,就把由它交换到它应该在的位置 nums[x-1]。
核心动作:
-
我手里有一个数
nums[i],它的值是x。 -
如果
x在 [1, n] 之间,且它没在它该在的位置上(即nums[x-1] != x)。 -
我就把它和
nums[x-1]位置上的数交换。 -
关键点 :交换回来的数可能也需要归位,所以我们要用
while循环持续处理当前位置。
编写 C++ 代码
cpp
#include <vector>
#include <algorithm>
using namespace std;
class Solution {
public:
int firstMissingPositive(vector<int>& nums) {
int n = nums.size();
// 第一步:原地搬运,让数字各回各家
for (int i = 0; i < n; ++i) {
// 只要 nums[i] 是在 [1, n] 范围内的正整数
// 并且它没有在正确的位置上 (nums[nums[i]-1] != nums[i])
// 就把它交换到正确的位置
// 注意:使用 while 而不是 if,因为交换回来的新数也需要归位
while (nums[i] > 0 && nums[i] <= n && nums[nums[i] - 1] != nums[i]) {
swap(nums[i], nums[nums[i] - 1]);
}
}
// 第二步:根据秩序寻找第一个乱序的位置
for (int i = 0; i < n; ++i) {
// 如果位置 i 上住的不是 i+1,说明 i+1 缺失了
if (nums[i] != i + 1) {
return i + 1;
}
}
// 第三步:如果 1 到 n 都在,说明缺失的是 n + 1
return n + 1;
}
};
面试回答
在面试中讲解这道题,最重要的是展现你如何"在极端限制(空间 O(1))下创造条件"。你可以按照"范围收窄 -> 空间挪用 -> 归位逻辑"的步骤来组织语言。
第一步:锁定答案的"物理边界"
"首先,我们要意识到这道题的答案范围是极其确定的。 对于一个长度为 n 的数组,缺失的第一个正数只可能在 [1,n+1] 这个范围内。
-
最理想情况:数组正好是 [1,2,...,n],答案是 n+1。
-
其他情况:只要数组中出现了负数、0、大于 n 的数,或者重复的数,那么 1 到 n 之间一定会有空档。
这个范围的收窄是解决问题的关键第一步。"
第二步:挖掘数组的"双重身份"
"题目要求 O(1) 空间,这意味着我们不能开辟哈希表。
从第一性原理出发,数组的本质就是索引到值的映射。既然我们需要记录 1 到 n 是否出现过,为什么不直接利用数组的'下标'作为哈希表的索引呢?
我们可以建立一个共识:
让数字 1 住在下标 0,数字 2 住在下标 1,以此类推,让数字 i 住在下标 i−1。这种方法通常被称为**'原地哈希'或'桶排序思想'**。"
第三步:推导"原地置换"的动作逻辑
"接下来的核心逻辑就是遍历数组,给每个数字'找家':
-
当我扫描到位置 i 的数字 x 时,如果 x 在 [1,n] 范围内,我就看它对应的家(即下标 x−1)是不是已经住着 x 了。
-
如果不是,我就把当前位置的 x 和它'家'里的那个数进行交换。
-
关键细节:交换回来的数可能也是一个需要归位的正数。因此,我会在当前位置使用
while循环不断进行交换,直到换回来的数不再属于 [1,n],或者它已经正确归位了。"
第四步:判定与复杂度分析
"最后,只需要再次遍历数组。第一个满足 nums[i] != i + 1 的位置,它原本该住的 i + 1 就是我们要找的答案。
关于复杂度,虽然代码里有嵌套循环,但如果我们观察**'数字归位'**这个动作:每一次有效的交换都会让一个数字回到正确位置。 数组一共只有 n 个位置,所以总交换次数最多为 n,整体时间复杂度是严格的 O(n)。"
💡 面试加分点
如果面试官追问细节,你可以补充:
-
关于无限死循环:
"我在
while循环的条件里,不仅判断了数值范围,还判断了nums[i] != nums[nums[i]-1]。这非常重要,因为如果两个位置的数相同(重复数),不停交换会导致死循环。这个判断保证了重复数不会干扰逻辑。" -
关于索引越界:
"在访问
nums[nums[i]-1]之前,我先判断了nums[i]是否在 [1, n] 之间。这种先验检查保证了程序不会出现内存访问错误。"