【Hot100|16-LeetCode 41. 缺失的第一个正数】

LeetCode 41. 缺失的第一个正数 - 原地哈希解法详解

一、问题理解

问题描述

给你一个未排序的整数数组 nums,请你找出其中没有出现的最小的正整数。要求时间复杂度为 O(n),并且只能使用常数级别的额外空间。

示例

text

复制代码
输入: nums = [1,2,0]
输出: 3
解释: 数组中没有出现的最小正整数是 3

输入: nums = [3,4,-1,1]
输出: 2
解释: 数组中没有出现的最小正整数是 2

输入: nums = [7,8,9,11,12]
输出: 1
解释: 数组中没有出现的最小正整数是 1

要求

  • 时间复杂度:O(n)

  • 空间复杂度:O(1)(常数空间)

  • 只能修改原数组

二、核心思路:原地哈希(索引映射)

基本思想

对于一个长度为 n 的数组,缺失的第一个正数一定在 [1, n+1] 范围内:

  • 如果数组中包含了 1n 的所有数,那么答案是 n+1

  • 否则,答案是 [1, n] 中没有出现的最小数

原地哈希策略

通过原地重新排列数组,使得索引 i 处存放数值 i+1

  • 遍历数组,对于每个元素 nums[i]

    • 如果 1 ≤ nums[i] ≤ n,并且 nums[i] 不在正确的位置(即 nums[i] ≠ i+1),则将 nums[i] 交换到正确的位置
  • 再次遍历数组,第一个不满足 nums[i] = i+1 的位置 i,对应的 i+1 就是缺失的最小正整数

三、代码逐行解析

Java 解法

java

复制代码
class Solution {
    public int firstMissingPositive(int[] nums) {
        int n = nums.length;
        
        // 1. 原地哈希:将每个正整数放到正确的位置上
        // 对于每个索引 i,我们希望 nums[i] = i+1
        for (int i = 0; i < n; i++) {
            // 当 nums[i] 在 [1, n] 范围内,并且 nums[i] 不在正确的位置上时
            while (nums[i] >= 1 && nums[i] <= n && nums[nums[i] - 1] != nums[i]) {
                // 将 nums[i] 交换到它应该在的位置
                int correctIndex = nums[i] - 1;
                swap(nums, i, correctIndex);
            }
        }
        
        // 2. 查找第一个不满足 nums[i] = i+1 的位置
        for (int i = 0; i < n; i++) {
            if (nums[i] != i + 1) {
                return i + 1;
            }
        }
        
        // 3. 如果所有位置都正确,那么缺失的是 n+1
        return n + 1;
    }
    
    // 辅助函数:交换数组中的两个元素
    private void swap(int[] nums, int i, int j) {
        int temp = nums[i];
        nums[i] = nums[j];
        nums[j] = temp;
    }
}

Python 解法

python

复制代码
from typing import List

class Solution:
    def firstMissingPositive(self, nums: List[int]) -> int:
        n = len(nums)
        
        # 1. 原地哈希:将每个正整数放到正确的位置上
        # 对于每个索引 i,我们希望 nums[i] = i+1
        for i in range(n):
            # 当 nums[i] 在 [1, n] 范围内,并且 nums[i] 不在正确的位置上时
            while 1 <= nums[i] <= n and nums[nums[i] - 1] != nums[i]:
                # 将 nums[i] 交换到它应该在的位置
                j = nums[i] - 1  # nums[i] 应该放置的位置
                nums[i], nums[j] = nums[j], nums[i]  # 交换
        
        # 2. 查找第一个不满足 nums[i] = i+1 的位置
        for i in range(n):
            if nums[i] != i + 1:
                return i + 1
        
        # 3. 如果所有位置都正确,那么缺失的是 n+1
        return n + 1

四、Java 与 Python 语法对比

1. 循环与控制流

操作 Java Python
for 循环 for (int i=0; i<n; i++) for i in range(n):
while 循环 while (condition) { ... } while condition:
条件判断 if (condition) { ... } if condition: ...
逻辑运算 &&、`

2. 数组/列表操作

操作 Java Python
获取长度 nums.length len(nums)
访问元素 nums[i] nums[i]
交换元素 需要临时变量 a, b = b, a
范围检查 1 <= nums[i] && nums[i] <= n 1 <= nums[i] <= n

3. 函数定义与调用

操作 Java Python
函数定义 void swap(int[] nums, int i, int j) def swap(nums, i, j):
函数调用 swap(nums, i, j) swap(nums, i, j)

五、实例演示

示例1:nums = 3, 4, -1, 1

步骤1:原地哈希

text

复制代码
初始: [3, 4, -1, 1] n=4

i=0: nums[0]=3, 1≤3≤4, nums[2]=-1 ≠ 3 → 交换 nums[0] 和 nums[2]
    -> [-1, 4, 3, 1]

i=0: nums[0]=-1, 不在[1,4]范围内,跳过

i=1: nums[1]=4, 1≤4≤4, nums[3]=1 ≠ 4 → 交换 nums[1] 和 nums[3]
    -> [-1, 1, 3, 4]

i=1: nums[1]=1, 1≤1≤4, nums[0]=-1 ≠ 1 → 交换 nums[1] 和 nums[0]
    -> [1, -1, 3, 4]

i=1: nums[1]=-1, 不在[1,4]范围内,跳过

i=2: nums[2]=3, 1≤3≤4, nums[2]=3 已在正确位置,跳过

i=3: nums[3]=4, 1≤4≤4, nums[3]=4 已在正确位置,跳过

最终数组: [1, -1, 3, 4]

步骤2:查找第一个不匹配的位置

text

复制代码
索引0: nums[0] = 1 = 0+1 ✓
索引1: nums[1] = -1 ≠ 2 ✗ → 返回 2

结果:2

示例2:nums = 1, 2, 0

步骤1:原地哈希

text

复制代码
初始: [1, 2, 0] n=3

i=0: nums[0]=1, 1≤1≤3, 已在正确位置,跳过
i=1: nums[1]=2, 1≤2≤3, 已在正确位置,跳过
i=2: nums[2]=0, 不在[1,3]范围内,跳过

最终数组: [1, 2, 0]

步骤2:查找第一个不匹配的位置

text

复制代码
索引0: nums[0] = 1 = 0+1 ✓
索引1: nums[1] = 2 = 1+1 ✓
索引2: nums[2] = 0 ≠ 3 ✗ → 返回 3

结果:3

示例3:nums = 7, 8, 9, 11, 12

步骤1:原地哈希

text

复制代码
初始: [7, 8, 9, 11, 12] n=5
所有元素都不在[1,5]范围内,数组不变

步骤2:查找第一个不匹配的位置

text

复制代码
索引0: nums[0] = 7 ≠ 1 ✗ → 返回 1

结果:1

六、关键细节解析

1. 为什么使用 while 循环而不是 if?

  • 因为交换后,位置 i 上的新元素可能也需要调整

  • 使用 while 循环确保每个位置都处理正确

2. 条件 nums[nums[i] - 1] != nums[i] 的作用是什么?

  • 避免无限循环:如果目标位置已经放置了正确的值,就不需要交换

  • 例如:nums = [1, 1],处理第一个元素时,目标位置已经是 1,不需要交换

3. 为什么算法的时间复杂度是 O(n)?

  • 每个元素最多被交换一次到正确位置

  • 虽然有嵌套循环,但总操作次数不超过 n 次

  • 因此时间复杂度是 O(n)

4. 为什么空间复杂度是 O(1)?

  • 只使用了常数个额外变量

  • 原地修改数组,没有使用额外数据结构

七、算法正确性证明

证明思路

  1. 循环不变式:在 while 循环中,每次交换都将一个元素放到正确位置

  2. 有限性:每个元素最多被移动一次,因此循环会终止

  3. 正确性 :遍历结束后,对于任意 i ∈ [0, n-1],如果 nums[i] ∈ [1, n],则 nums[i] = i+1

数学归纳

  • 基础:对于空数组,算法正确

  • 归纳:假设前 k 个位置已正确,处理第 k+1 个位置时:

    • 如果元素在正确位置,不变

    • 如果元素不在正确位置,交换到正确位置

    • 交换可能破坏前面的顺序,但 while 循环会继续处理

八、复杂度分析

时间复杂度:O(n)

  • 每个元素最多被访问两次(一次放置,一次检查)

  • while 循环的总次数不超过 n

  • 总时间复杂度为 O(n)

空间复杂度:O(1)

  • 只使用了常数个额外变量

  • 原地修改数组,没有使用额外空间

九、边界情况处理

1. 空数组

java

复制代码
// Java
if (nums.length == 0) {
    return 1;
}

python

复制代码
# Python
if not nums:
    return 1

2. 全为负数或 0

python

复制代码
# 示例: nums = [-1, -2, 0]
# 最终会返回 1

3. 包含重复元素

python

复制代码
# 示例: nums = [1, 1]
# 条件 nums[nums[i]-1] != nums[i] 防止无限循环

4. 已排序数组

python

复制代码
# 示例: nums = [1, 2, 3, 4]
# 最终会返回 5

十、其他解法

解法一:使用集合(不符合空间要求)

python

复制代码
def firstMissingPositive_set(nums):
    num_set = set(nums)
    n = len(nums)
    
    for i in range(1, n + 2):
        if i not in num_set:
            return i

复杂度分析

  • 时间复杂度:O(n)

  • 空间复杂度:O(n)(不符合要求)

解法二:标记法(使用额外标记)

java

复制代码
class Solution {
    public int firstMissingPositive(int[] nums) {
        int n = nums.length;
        
        // 1. 将所有非正数和大于 n 的数标记为 n+1
        for (int i = 0; i < n; i++) {
            if (nums[i] <= 0 || nums[i] > n) {
                nums[i] = n + 1;
            }
        }
        
        // 2. 使用负号标记已出现的数
        for (int i = 0; i < n; i++) {
            int num = Math.abs(nums[i]);
            if (num <= n) {
                // 将索引 num-1 处的数标记为负数
                nums[num - 1] = -Math.abs(nums[num - 1]);
            }
        }
        
        // 3. 找到第一个正数的索引
        for (int i = 0; i < n; i++) {
            if (nums[i] > 0) {
                return i + 1;
            }
        }
        
        return n + 1;
    }
}

复杂度分析

  • 时间复杂度:O(n)

  • 空间复杂度:O(1)(但修改了原数组)

十一、常见问题与解答

Q1: 为什么算法使用原地哈希而不是排序?

A1: 排序需要 O(n log n) 时间复杂度,不符合题目要求。原地哈希可以在 O(n) 时间内完成。

Q2: 如果数组中有重复元素怎么办?

A2 : 算法可以正确处理重复元素。条件 nums[nums[i]-1] != nums[i] 防止了重复元素导致的无限循环。

Q3: 算法是否修改了原数组?

A3: 是的,算法原地修改了原数组。这是允许的,因为题目没有要求保持原数组不变。

Q4: 如果数组非常大怎么办?

A4: 算法的时间复杂度是 O(n),空间复杂度是 O(1),可以处理非常大的数组。

Q5: 为什么缺失的正数一定在 1, n+1 范围内?

A5: 对于长度为 n 的数组,最多能包含 n 个不同的正整数。如果包含了 1 到 n 的所有数,那么缺失的是 n+1;否则,缺失的一定在 1 到 n 之间。

十二、相关题目

1. LeetCode 268. 丢失的数字

python

复制代码
def missingNumber(nums):
    n = len(nums)
    total_sum = n * (n + 1) // 2
    actual_sum = sum(nums)
    return total_sum - actual_sum

2. LeetCode 448. 找到所有数组中消失的数字

python

复制代码
def findDisappearedNumbers(nums):
    n = len(nums)
    
    # 使用负号标记法
    for num in nums:
        index = abs(num) - 1
        if nums[index] > 0:
            nums[index] = -nums[index]
    
    # 收集正数的索引
    result = []
    for i in range(n):
        if nums[i] > 0:
            result.append(i + 1)
    
    return result

3. LeetCode 287. 寻找重复数

python

复制代码
def findDuplicate(nums):
    # 使用快慢指针(Floyd判圈算法)
    slow = nums[0]
    fast = nums[0]
    
    # 第一阶段:找到相遇点
    while True:
        slow = nums[slow]
        fast = nums[nums[fast]]
        if slow == fast:
            break
    
    # 第二阶段:找到环的入口
    slow = nums[0]
    while slow != fast:
        slow = nums[slow]
        fast = nums[fast]
    
    return slow

十三、总结

核心要点

  1. 原地哈希是关键:通过原地重新排列数组,使每个正整数出现在正确的位置

  2. 索引映射 :数值 k 应该放在索引 k-1 的位置

  3. 循环处理:使用 while 循环确保每个元素都被正确处理

算法步骤

  1. 遍历数组,对于每个元素:

    • 如果元素在 [1, n] 范围内且不在正确位置,则交换到正确位置

    • 使用 while 循环处理交换后的新元素

  2. 再次遍历数组,找到第一个不满足 nums[i] = i+1 的位置

  3. 如果所有位置都正确,返回 n+1

时间复杂度与空间复杂度

  • 时间复杂度:O(n),每个元素最多被访问两次

  • 空间复杂度:O(1),只使用常数个额外变量

适用场景

  • 需要找出缺失的最小正整数

  • 要求时间复杂度 O(n) 和空间复杂度 O(1)

  • 允许修改原数组

扩展思考

原地哈希是一种强大的技巧,可以应用于许多数组重排问题:

  • 寻找重复数

  • 找到所有消失的数字

  • 数组中重复的数据

掌握原地哈希的解法,不仅能够解决缺失的第一个正数问题,还能够理解如何通过索引映射来优化空间使用,是面试中非常重要的算法技巧。

相关推荐
江畔柳前堤7 小时前
大语言模型分布式训练:从并行策略到万卡工程的系统梳理
人工智能·分布式·深度学习·算法·目标检测·机器学习·语言模型
Forever Nore8 小时前
LeetCode 4 寻找两个正序数组的中位数 - 二分
算法·leetcode
罗西的思考10 小时前
【OpenClaw具身硬件】MiniClaw 阅读笔记---(1)基础
人工智能·算法·机器学习
蛋先生DX11 小时前
大模型参数存储格式揭秘:BF不是男朋友
深度学习·算法·llm
爱跳舞的烤冷面11 小时前
自学嵌入式第22天(数据结构——哈希)
数据结构·算法·哈希算法
猎嘤一号11 小时前
博弈论(Game Theory)的理论、算法与工程
人工智能·算法·安全·博弈论
月光船幽幽12 小时前
影子模式下保护 logits 不被修改
人工智能·python·算法
马拉AI12 小时前
腾讯开源 Agent 记忆系统,AI“换对话就忘”的问题有了新解法(附安装使用教程)
人工智能·算法·开源·科研
地平线开发者13 小时前
征程6工具链模型X86推理方式说明
算法
地平线开发者13 小时前
【征程6】校准量化中HistogramObserver解析
算法