🚀 题目链接:leetcode.cn/problems/fi…
🔍 难度:困难 | 🏷️ 标签:数组、原地操作、哈希表、数学思维
⏱️ 目标时间复杂度:O(n)
💾 空间复杂度:O(1)
💡 本题是 LeetCode 经典“脑筋急转弯”题,考察对 数组空间的极致利用 和 数学边界分析能力。
🎯 面试常考!尤其在大厂算法岗、系统设计岗中频繁出现,作为考察「空间优化」与「逻辑建模」的标杆题。
🔍 题目分析
给你一个未排序的整数数组 nums,找出其中没有出现的最小正整数。
⚠️ 关键限制:
- 时间复杂度必须为 O(n);
- 空间复杂度必须为 O(1)(即不能使用额外哈希表或集合);
📌 举个例子:
nums = [3, 4, -1, 1] → 输出: 2
解释:1 存在,但 2 缺失,所以返回 2。
再比如:
nums = [7,8,9,11,12] → 输出: 1
解释:1 没有出现,是最小正整数。
✅ 数学洞察:答案范围一定在 [1, n+1]
这是解题的关键突破口!
🧠 定理:对于长度为
n的数组,缺失的第一个正整数必然落在[1, n+1]区间内。
为什么?
- 若
[1, n]全部存在,则答案是n+1; - 否则,答案是
[1, n]中第一个缺失的正整数。
✅ 所以我们只需关注区间 [1, n] 内的数字是否存在即可,不需要关心大于 n 的数。
这极大简化了问题——我们可以把整个数组当作一个“哈希表”来用!
🎯 核心算法及代码讲解
本题有两个经典解法:
| 方法 | 思路 | 是否修改原数组 | 时间 | 空间 |
|---|---|---|---|---|
| 原地哈希标记法 | 将数组作为哈希表,用负号表示存在 | ✅ 是 | O(n) | O(1) |
| 置换恢复法 | 把每个数放到它对应的位置上 | ✅ 是 | O(n) | O(1) |
我们重点讲解 原地哈希标记法(更直观易懂),并附上完整行注释代码。
🔁 方法一:原地哈希标记法(推荐)
🧩 核心思想:
利用数组下标作为“键”,值的正负性作为“状态标志”。
具体步骤如下:
- 预处理:将所有小于等于 0 的数改为
n+1,因为这些数不影响[1,n]范围内的判断; - 遍历标记:对每个数
x = abs(nums[i]),如果x ∈ [1,n],就将nums[x-1]变成负数(表示x存在); - 查找结果:从头开始找第一个仍为正数的元素,其下标
i+1即为答案;若全为负数,则答案为n+1。
💡 本质是:用数组自身模拟哈希表,通过符号变化记录“某数是否出现”。
✅ 代码实现(含详细行注释)
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
class Solution {
public:
int firstMissingPositive(vector<int>& nums) {
int n = nums.size(); // 数组长度
// 第一步:将所有 <= 0 的数替换为 n+1(使其不在 [1,n] 范围内)
for (int& num : nums) {
if (num <= 0) {
num = n + 1; // 这样就不会影响后续的索引映射
}
}
// 第二步:遍历数组,对每个数 x,如果 x 在 [1,n] 范围内,
// 则将其对应的索引位置(x-1)变为负数,表示 x 存在
for (int i = 0; i < n; ++i) {
int x = abs(nums[i]); // 注意取绝对值,因为可能已被标记为负
if (x <= n) { // 只处理在 [1,n] 范围内的数
// nums[x-1] 对应的是数字 x 的“存在标记”
// 如果已经是负数,说明已标记过,无需重复
nums[x - 1] = -abs(nums[x - 1]); // 强制变负,表示 x 出现过
}
}
// 第三步:找到第一个仍然是正数的位置,其索引 +1 就是答案
for (int i = 0; i < n; ++i) {
if (nums[i] > 0) {
return i + 1;
}
}
// 如果所有位置都为负数,说明 [1,n] 都存在,答案是 n+1
return n + 1;
}
};
// 测试
signed main(){
ios::sync_with_stdio(false);
cin.tie(nullptr);
cout.tie(nullptr);
Solution sol;
// 测试用例1
vector<int> nums1 = {1, 2, 0};
cout << "Test 1: " << sol.firstMissingPositive(nums1) << endl; // 输出: 3
// 测试用例2
vector<int> nums2 = {3, 4, -1, 1};
cout << "Test 2: " << sol.firstMissingPositive(nums2) << endl; // 输出: 2
// 测试用例3
vector<int> nums3 = {7, 8, 9, 11, 12};
cout << "Test 3: " << sol.firstMissingPositive(nums3) << endl; // 输出: 1
return 0;
}
🧠 解题思路(分步详解)
- 理解目标:找最小缺失正整数,且要求 O(n) 时间 + O(1) 空间;
- 缩小搜索范围:答案必在
[1, n+1],因此只关心[1,n]的出现情况; - 空间优化策略:既然不能用额外空间,那就用数组本身做“哈希表”;
- 如何标记?
- 使用 负号 表示某个数已存在;
- 因此需先清理无效数据(≤0 的数);
- 遍历并标记:对每个合法数
x,令nums[x-1]为负; - 最终扫描:找第一个未被标记(仍为正)的位置,即为答案。
📊 算法分析
| 指标 | 分析 |
|---|---|
| 时间复杂度 | O(n):仅需三次遍历,每轮均为线性时间 |
| 空间复杂度 | O(1):仅使用常量级额外空间,无额外数据结构 |
| 是否破坏输入 | 是,修改了原数组(但题目允许) |
| 适用场景 | 面试中考察“空间复用”能力的经典题 |
🔍 面试加分点:
- 能说出“答案范围在 [1,n+1]”这一关键结论;
- 清晰解释为何要用负号做标记;
- 提到“原地哈希”的思想,体现工程思维;
- 对比其他方法(如排序、哈希表)的优劣。
🔄 方法二:置换恢复法(补充)
另一种优雅解法是“置换”思想:让每个数 x 放到索引 x-1 的位置。
class Solution {
public:
int firstMissingPositive(vector<int>& nums) {
int n = nums.size();
for (int i = 0; i < n; ++i) {
// 当前元素为 x,它应该放在索引 x-1 处
while (nums[i] > 0 && nums[i] <= n && nums[nums[i] - 1] != nums[i]) {
swap(nums[nums[i] - 1], nums[i]);
}
}
for (int i = 0; i < n; ++i) {
if (nums[i] != i + 1) {
return i + 1;
}
}
return n + 1;
}
};
📌 核心思想:不断交换,直到每个数都在正确位置,然后找第一个不匹配的位置。
⚠️ 注意:避免死循环,条件中加入 nums[nums[i]-1] != nums[i] 来防止无限交换。
💡 面试延伸与技巧总结
❓ 为什么不能用哈希表?
- 因为空间复杂度要求 O(1),而哈希表需要 O(n) 空间;
- 但可以利用数组自身来替代哈希表,这就是“原地哈希”思想。
❓ 如何想到用负号标记?
- 数组中的数可以是任意整数,但我们需要一种方式“记录”某个数是否出现;
- 正负号是一种天然的状态标志,且不会影响数值本身(只要不改变大小);
- 类似于“位图”思想,但用符号代替比特。
❓ 为什么要把 ≤0 的数改成 n+1?
- 因为这些数无法作为有效索引(如 -1 不合法);
- 改成 n+1 后,它们不会参与后续的索引访问,也不会干扰标记过程。
❓ 有没有不修改原数组的方法?
- 有,但会牺牲空间或时间;
- 比如用额外布尔数组(空间 O(n)),或排序后查找(时间 O(n log n));
- 但都不满足题目双重要求。
🌟 本期完结,下期见!🔥
👉 点赞收藏加关注,新文更新不迷路。关注专栏【算法】LeetCode Hot100刷题日记,持续为你拆解每一道热题的底层逻辑与面试技巧!
💬 欢迎留言交流你的解法或疑问!一起进步,冲向 Offer!💪
📣 下一期预告:LeetCode 热题 100 第18题 —— 73.矩阵置零(中等)
🔹 题目:给定一个 m×n 的二维矩阵,如果某个元素为 0,则将其所在行和列的所有元素都设为 0。
🔹 核心思路:使用两个布尔变量或第一行/列作为标记位,避免额外空间;
🔹 考点:空间优化、边界处理、原地修改、多维数组操作;
🔹 难度:中等,但非常考验细节处理能力,是面试常客!
💡 提示:不要直接遍历两次,容易出错!建议先记录要清零的行列,再统一处理。
📌 记住:当你在刷题时,不要只看答案,要像写这篇文章一样,深入思考每一步背后的原理、优化空间和面试价值。这才是真正提升算法能力的方式!