【LeetCode Hot100 刷题日记(17/100)】41. 缺失的第一个正数 —— 数组、原地操作、哈希表、原地哈希 + 置换思想🚀

96 阅读7分钟

🚀 题目链接: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)

我们重点讲解 原地哈希标记法(更直观易懂),并附上完整行注释代码。


🔁 方法一:原地哈希标记法(推荐)

🧩 核心思想:

利用数组下标作为“键”,值的正负性作为“状态标志”。

具体步骤如下:

  1. 预处理:将所有小于等于 0 的数改为 n+1,因为这些数不影响 [1,n] 范围内的判断;
  2. 遍历标记:对每个数 x = abs(nums[i]),如果 x ∈ [1,n],就将 nums[x-1] 变成负数(表示 x 存在);
  3. 查找结果:从头开始找第一个仍为正数的元素,其下标 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;
}

🧠 解题思路(分步详解)

  1. 理解目标:找最小缺失正整数,且要求 O(n) 时间 + O(1) 空间;
  2. 缩小搜索范围:答案必在 [1, n+1],因此只关心 [1,n] 的出现情况;
  3. 空间优化策略:既然不能用额外空间,那就用数组本身做“哈希表”;
  4. 如何标记?
    • 使用 负号 表示某个数已存在;
    • 因此需先清理无效数据(≤0 的数);
  5. 遍历并标记:对每个合法数 x,令 nums[x-1] 为负;
  6. 最终扫描:找第一个未被标记(仍为正)的位置,即为答案。

📊 算法分析

指标分析
时间复杂度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。

🔹 核心思路:使用两个布尔变量或第一行/列作为标记位,避免额外空间;

🔹 考点:空间优化、边界处理、原地修改、多维数组操作;

🔹 难度:中等,但非常考验细节处理能力,是面试常客!

💡 提示:不要直接遍历两次,容易出错!建议先记录要清零的行列,再统一处理。

📌 记住:当你在刷题时,不要只看答案,要像写这篇文章一样,深入思考每一步背后的原理、优化空间和面试价值。这才是真正提升算法能力的方式!