1. 题目解析与核心思路
这道题目要求我们在一个未排序的整数数组中找到缺失的最小正整数。乍一看似乎很简单,但题目要求时间复杂度为O(n)且只能使用常数级别的额外空间,这就让问题变得非常具有挑战性。
首先明确几个关键点:
- 我们需要找的是"第一个缺失的正整数",也就是说从1开始检查,第一个不在数组中的正整数就是答案
- 数组可能包含负数、零和重复的数字
- 数组长度为n时,答案的可能范围是1到n+1(如果1到n都出现了,那么答案就是n+1)
1.1 暴力解法分析
最直观的解法是:
- 从1开始逐个检查是否在数组中
- 第一个不在数组中的正整数就是答案
但这种解法时间复杂度是O(n²),因为对于每个数字都要遍历整个数组检查是否存在。显然不符合题目要求。
1.2 哈希表优化思路
我们可以用哈希表来优化查找过程:
- 将所有数字存入哈希表
- 从1开始检查哈希表中是否存在
- 第一个不在哈希表中的正整数就是答案
这样时间复杂度降为O(n),但需要O(n)的额外空间存储哈希表,仍然不符合空间复杂度要求。
2. 原地哈希算法详解
为了同时满足时间和空间复杂度要求,我们需要使用"原地哈希"的技巧。核心思想是利用数组本身作为哈希表,通过交换元素的位置来标记数字是否存在。
2.1 算法步骤
- 首先处理所有非正数:将它们设置为n+1(因为答案最大就是n+1)
- 遍历数组,对于每个数字的绝对值x:
- 如果x在1到n范围内,将数组中第x-1位置的数字变为负数(标记x存在)
- 最后遍历数组,第一个正数所在的位置i+1就是缺失的最小正整数
- 如果都是负数,说明1到n都存在,返回n+1
2.2 具体实现
python复制def firstMissingPositive(nums):
n = len(nums)
# 第一步:将所有非正数标记为n+1
for i in range(n):
if nums[i] <= 0:
nums[i] = n + 1
# 第二步:使用负号标记存在的数字
for i in range(n):
num = abs(nums[i])
if num <= n:
nums[num - 1] = -abs(nums[num - 1])
# 第三步:找到第一个正数的位置
for i in range(n):
if nums[i] > 0:
return i + 1
# 如果都是负数,返回n+1
return n + 1
2.3 算法正确性证明
这个算法的正确性基于以下几点:
- 我们只关心1到n范围内的数字,其他数字不影响最终结果
- 通过将数字对应的位置变为负数,我们可以在原数组上记录数字是否存在
- 第一个正数的位置就是缺失的最小正整数
3. 算法复杂度分析
3.1 时间复杂度
算法进行了三次遍历:
- 第一次遍历处理非正数:O(n)
- 第二次遍历标记存在的数字:O(n)
- 第三次遍历寻找第一个正数:O(n)
总时间复杂度为O(3n) = O(n),满足题目要求。
3.2 空间复杂度
除了输入数组外,我们只使用了常数个额外变量,因此空间复杂度是O(1),满足题目要求。
4. 边界条件与特殊测试用例
4.1 常见边界情况
- 空数组:应返回1
- 所有数字都是负数:应返回1
- 数组包含重复数字:算法仍然有效
- 数组已经包含1到n:应返回n+1
- 数组包含大于n的数字:这些数字会被忽略
4.2 测试用例示例
python复制测试用例1:[1,2,0] → 3
测试用例2:[3,4,-1,1] → 2
测试用例3:[7,8,9,11,12] → 1
测试用例4:[] → 1
测试用例5:[1,2,3] → 4
测试用例6:[1,1] → 2
5. 算法优化与变种
5.1 交换法实现
除了负号标记法,还可以使用交换法:
- 遍历数组,将每个数字交换到它应该在的位置(即数字x应该放在索引x-1处)
- 最后遍历数组,第一个不在正确位置的数字就是答案
python复制def firstMissingPositive(nums):
n = len(nums)
for i in range(n):
while 1 <= nums[i] <= n and nums[nums[i]-1] != nums[i]:
nums[nums[i]-1], nums[i] = nums[i], nums[nums[i]-1]
for i in range(n):
if nums[i] != i + 1:
return i + 1
return n + 1
5.2 两种方法比较
-
负号标记法:
- 优点:实现简单直观
- 缺点:会修改原始数据的符号
-
交换法:
- 优点:不改变数据的绝对值
- 缺点:实现稍复杂,最坏情况下时间复杂度可能退化为O(n²)(但实际应用中很少出现)
6. 实际应用场景
这种算法虽然看起来是纯理论题目,但实际上有很多实际应用:
- 数据库系统:检测连续ID中的空缺
- 内存管理:寻找可用的最小内存块编号
- 票务系统:找出未被使用的最小票号
- 资源分配:分配最小的可用资源ID
7. 常见错误与调试技巧
7.1 常见错误
- 忽略非正数的处理:直接使用负号标记会导致错误
- 重复数字处理不当:可能多次标记同一个位置
- 数组越界:没有检查数字是否在有效范围内
- 边界条件处理不全:如空数组或全负数数组
7.2 调试技巧
- 打印中间结果:在每次遍历后打印数组状态
- 使用小测试用例:手动跟踪算法执行过程
- 检查特殊输入:如空数组、全负数数组等
- 验证时间复杂度:确保没有嵌套循环导致O(n²)
8. 扩展思考
8.1 如果允许修改输入数组
这就是我们讨论的原地算法的情况,已经给出了最优解。
8.2 如果不允许修改输入数组
如果题目要求不能修改输入数组,我们可以:
- 使用二分查找法,但时间复杂度会变为O(n log n)
- 使用位图,但需要O(n)额外空间
这两种方法都无法同时满足O(n)时间和O(1)空间的要求。
8.3 找出所有缺失的正数
如果题目要求找出所有缺失的正数,我们可以:
- 先使用原地算法找到第一个缺失的正数k
- 然后对k+1到n重复这个过程
- 但这样时间复杂度会变为O(n²)
更有效的方法是使用额外的空间记录所有存在的数字。
9. 同类题目推荐
- 找到所有数组中消失的数字(LeetCode 448)
- 数组中重复的数据(LeetCode 442)
- 设置不匹配(LeetCode 645)
- 消失的两个数字(面试题17.19)
这些题目都可以使用类似的原地标记技巧来解决。
10. 个人实现心得
在实际实现这个算法时,有几个关键点需要注意:
-
处理非正数时,我最初尝试将它们设置为0,但发现这会干扰后续的负号标记。设置为n+1是更安全的选择。
-
在标记阶段,一定要使用abs取绝对值,因为前面的标记可能已经将某些数字变为负数。
-
交换法实现时,while循环的条件要特别注意,确保不会无限循环。我最初漏掉了nums[nums[i]-1] != nums[i]这个条件,导致在某些情况下出现死循环。
-
测试时要特别注意包含重复数字的情况,这是最容易出错的地方之一。