【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)

  • 允许修改原数组

扩展思考

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

  • 寻找重复数

  • 找到所有消失的数字

  • 数组中重复的数据

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

相关推荐
只是懒得想了6 小时前
C++实现密码破解工具:从MD5暴力破解到现代哈希安全实践
c++·算法·安全·哈希算法
m0_736919107 小时前
模板编译期图算法
开发语言·c++·算法
dyyx1117 小时前
基于C++的操作系统开发
开发语言·c++·算法
m0_736919107 小时前
C++安全编程指南
开发语言·c++·算法
蜡笔小马7 小时前
11.空间索引的艺术:Boost.Geometry R树实战解析
算法·r-tree
-Try hard-7 小时前
数据结构:链表常见的操作方法!!
数据结构·算法·链表·vim
2301_790300967 小时前
C++符号混淆技术
开发语言·c++·算法
我是咸鱼不闲呀7 小时前
力扣Hot100系列16(Java)——[堆]总结()
java·算法·leetcode
嵌入小生0077 小时前
单向链表的常用操作方法---嵌入式入门---Linux
linux·开发语言·数据结构·算法·链表·嵌入式
LabVIEW开发7 小时前
LabVIEW金属圆盘压缩特性仿真
算法·labview·labview知识·labview功能·labview程序