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] 范围内:
-
如果数组中包含了
1到n的所有数,那么答案是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)?
-
只使用了常数个额外变量
-
原地修改数组,没有使用额外数据结构
七、算法正确性证明
证明思路
-
循环不变式:在 while 循环中,每次交换都将一个元素放到正确位置
-
有限性:每个元素最多被移动一次,因此循环会终止
-
正确性 :遍历结束后,对于任意
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
十三、总结
核心要点
-
原地哈希是关键:通过原地重新排列数组,使每个正整数出现在正确的位置
-
索引映射 :数值
k应该放在索引k-1的位置 -
循环处理:使用 while 循环确保每个元素都被正确处理
算法步骤
-
遍历数组,对于每个元素:
-
如果元素在
[1, n]范围内且不在正确位置,则交换到正确位置 -
使用 while 循环处理交换后的新元素
-
-
再次遍历数组,找到第一个不满足
nums[i] = i+1的位置 -
如果所有位置都正确,返回
n+1
时间复杂度与空间复杂度
-
时间复杂度:O(n),每个元素最多被访问两次
-
空间复杂度:O(1),只使用常数个额外变量
适用场景
-
需要找出缺失的最小正整数
-
要求时间复杂度 O(n) 和空间复杂度 O(1)
-
允许修改原数组
扩展思考
原地哈希是一种强大的技巧,可以应用于许多数组重排问题:
-
寻找重复数
-
找到所有消失的数字
-
数组中重复的数据
掌握原地哈希的解法,不仅能够解决缺失的第一个正数问题,还能够理解如何通过索引映射来优化空间使用,是面试中非常重要的算法技巧。