题目
<困难> 缺失的第一个正数
来源:LeetCode.
给你一个未排序的整数数组 nums ,请你找出其中没有出现的最小的正整数。
注意:实现时间复杂度为 O(n) 并且只使用常数级别额外空间的解决方案。
示例 1:
输入:nums = [1,2,0]
输出:3
示例 2:
输入:nums = [3,4,-1,1]
输出:2
示例 3:
输入:nums = [7,8,9,11,12]
输出:1
提示:
- 1 < = n u m s . l e n g t h < = 5 ∗ 105 1 <= nums.length <= 5 * 105 1<=nums.length<=5∗105
- − 231 < = n u m s [ i ] < = 231 − 1 -231 <= nums[i] <= 231 - 1 −231<=nums[i]<=231−1
接下来看一下解题思路:
实际上,对于一个长度为
N
N
N 的数组,其中没有出现的最小正整数只能在
[
1
,
N
+
1
]
[1, N+1]
[1,N+1] 中。这是因为如果
[
1
,
N
]
[1, N]
[1,N] 都出现了,那么答案是
N
+
1
N+1
N+1,否则答案是
[
1
,
N
]
[1, N]
[1,N] 中没有出现的最小正整数。
所以,可以将所有在
[
1
,
N
]
[1, N]
[1,N] 范围内的数放入哈希表,随后依次枚举
[
1
,
N
]
[1, N]
[1,N] 正整数,并判断其是否在哈希表中。
因为哈希表是一个可以支持快速查找的数据结构:给定一个元素,可以在
O
(
1
)
O(1)
O(1) 的时间查找该元素是否在哈希表中。所以时间复杂度是
O
(
N
)
O(N)
O(N)满足题目要求,但是空间复杂度也是
O
(
N
)
O(N)
O(N),不满足要求。
思路一:哈希表-负数标记:
题目要求 「只能使用常数级别的空间」
,而且也没有说 数组是不可修改的
,所以可以就把原始的数组当做哈希表来使用。
对数组进行遍历,对于遍历到的数
x
x
x,如果它在
[
1
,
N
]
[1, N]
[1,N] 的范围内,那么就将数组中的第
x
−
1
x-1
x−1 个位置(注意:数组下标从
0
0
0 开始)打上「标记」。在遍历结束之后,如果所有的位置都被打上了标记,那么答案是
N
+
1
N+1
N+1,否则答案是最小的没有打上标记的位置加
1
1
1。
那么该如何打这个「标记」呢?由于只在意
[
1
,
N
]
[1, N]
[1,N] 中的数,因此可以先对数组进行遍历,把不在
[
1
,
N
]
[1, N]
[1,N] 范围内的数修改成任意一个大于
N
N
N 的数(例如
N
+
1
N+1
N+1)。
这样一来,数组中的所有数就都是正数了,因此就可以将「标记」表示为「负号」。算法的流程如下:
- 将数组中所有小于等于 0 0 0 的数修改为 N + 1 N+1 N+1;
- 遍历数组中的每一个数 x x x,它可能已经被打了标记,因此原本对应的数为 ∣ x ∣ ∣x∣ ∣x∣,其中 ∣ ∣ |\,| ∣∣ 为绝对值符号。如果 ∣ x ∣ ∈ [ 1 , N ] |x| \in [1, N] ∣x∣∈[1,N],那么给数组中的第 ∣ x ∣ − 1 |x| - 1 ∣x∣−1 个位置的数添加一个负号。注意如果它已经有负号,不需要重复添加;
- 在遍历完成之后,如果数组中的每一个数都是负数,那么答案是 N + 1 N+1 N+1,否则答案是第一个正数的位置加 1 1 1。
public static int firstMissingPositive(int[] nums) {
int n = nums.length;
if(n <= 0) {
return 1;
}
// 对于小于 0 的元素,全部标记为 n + 1
for (int i = 0; i < n; ++i) {
if (nums[i] <= 0) {
nums[i] = n + 1;
}
}
// 把 ∣x∣∈[1,N] 的 x - 1 位置的数字都标记为负数
for (int i = 0; i < n; ++i) {
int num = Math.abs(nums[i]);
if (num <= n) {
nums[num - 1] = -Math.abs(nums[num - 1]);
}
}
// 遍历数组,第一个不为负数的元素的位置就是最小的正整数
for (int i = 0; i < n; ++i) {
if (nums[i] > 0) {
return i + 1;
}
}
return n + 1;
}
复杂度分析
-
时间复杂度: O ( N ) O(N) O(N),其中 N N N 是数组的长度。
-
空间复杂度: O ( 1 ) O(1) O(1)。
思路二:置换:
除了打标记以外,我们还可以使用置换的方法,可以对数组进行一次遍历,对于遍历到的数 x = nums [ i ] x = \textit{nums}[i] x=nums[i],如果 x ∈ [ 1 , N ] x \in [1, N] x∈[1,N],就知道 x x x 应当出现在数组中的 x − 1 x - 1 x−1 的位置,因此交换 nums [ i ] \textit{nums}[i] nums[i] 和 nums [ x − 1 ] \textit{nums}[x - 1] nums[x−1],这样 x x x 就出现在了正确的位置。在完成交换后,新的 nums [ i ] \textit{nums}[i] nums[i] 可能还在 [ 1 , N ] [1, N] [1,N] 的范围内,需要继续进行交换操作,直到 x ∉ [ 1 , N ] x \notin [1, N] x∈/[1,N]。
注意到上面的方法可能会陷入死循环。如果 x x x 已经在正确的位置上了,也就是 nums [ i ] \textit{nums}[i] nums[i] 恰好与 nums [ x − 1 ] \textit{nums}[x - 1] nums[x−1] 相等,那么就会无限交换下去。所以当 nums [ i ] = x = nums [ x − 1 ] \textit{nums}[i] = x = \textit{nums}[x - 1] nums[i]=x=nums[x−1]成立,可以跳出循环,遍历下一个数。
public static int firstMissingPositive(int[] nums) {
int n = nums.length;
if(n <= 0) {
return 1;
}
for (int i = 0; i < n; ++i) {
// 元素还在 [1-n] 的范围内,并且还没有放到合适的位置,就需要继续交换
while (nums[i] > 0 && nums[i] <= n && nums[nums[i] - 1] != nums[i]) {
int tmp = nums[nums[i] - 1];
nums[nums[i] - 1] = nums[i];
nums[i] = tmp;
}
}
// 第一个元素不正确的位置就是最小正整数
for (int i = 0; i < n; ++i) {
if (nums[i] != i + 1) {
return i + 1;
}
}
// 没有就返回 n + 1
return n + 1;
}
嵌套了循环体,为什么时间复杂度是 O ( N ) O(N) O(N) ?
while 循环不会每一次都把数组里面的所有元素都遍历一遍。如果有一些元素在这一次的循环中被交换到了它们应该在的位置,那么在后续的遍历中,就会被跳过。
平均下来,每个数只需要看一次就可以了,while 循环体被执行很多次的情况不会每次都发生。因此时间复杂度是 O ( N ) O(N) O(N)。