LeetCode 1539. 第 k 个缺失的正整数
题目描述
题意分析
给一个严格递增的正整数数组
arr,把它看成从完整正整数序列1, 2, 3, …里挖掉了若干个数之后剩下的部分,要求返回被挖掉的那些数中的第k个(按从小到大排)。"严格递增"这四个字给了两个信息:一是数组里没有重复元素,每个下标对应一个确定的值;二是数组本身有序,这是任何二分的前提。题目专门强调它,就是在暗示不要停留在线性扫描。
关键的量化关系是:如果一个数都没缺,
arr[i]应该恰好等于i + 1(下标从0开始)。所以arr[i] - (i + 1)就是"在arr[i]之前一共缺了多少个正整数"。因为数组严格递增,相邻元素至少差1,这个缺失量随下标单调不减——它要么保持不变(相邻两数连续),要么增加(中间有空档)。单调性一出现,二分就有了着力点。约束是
1 ≤ arr.length ≤ 1000、1 ≤ k ≤ 1000,规模小到线性扫描完全够用。但这恰恰是这题的陷阱:能过不等于答对。面试里给出 $O(n)$ 之后,下一句一定是"数组是有序的,能不能做到 $O(\log n)$",所以本文直接按二分来写。边界有三档必须想清楚。第一档,答案落在
arr[0]之前:arr = [2, 3, 4]、k = 1时答案是1,此时数组里没有任何元素排在答案前面。第二档,答案夹在数组中间:arr = [2, 3, 4, 7, 11]、k = 5时答案是9。第三档,答案超出arr的最后一个元素:arr = [1, 2, 3, 4]、k = 2时答案是6,二分区间必须容得下这种"跑出数组右端"的情况。
解法:二分缺失数量边界
核心思路
暴力做法有两种。一是从
1开始逐个数往上试,用哈希集合判断在不在arr里,数到第k个不在的就返回;这需要额外的 $O(n)$ 空间,而且要走到的位置可能是n + k。二是双指针扫一遍数组,用一个计数器累计已经跳过了多少个数,一旦累计到k就停;这是 $O(n)$ 时间 $O(1)$ 空间,已经很干净了。瓶颈在于:数组是有序的,而我们却在线性地走它——有序结构的信息完全没有被利用。定义
missing(i) = arr[i] - i - 1,表示"数组前i + 1个元素覆盖的范围内,一共缺失了几个正整数"。这个函数的单调不减性质前面已经论证过。既然它单调,那么"缺失量是否已经达到k"就是一个从假到真、只翻转一次的谓词,标准的二分找左边界场景。于是目标明确为:找到最小的下标
left,使得missing(left) ≥ k;如果所有下标都不满足,left落在n。二分维护的循环不变量是:区间
[0, left)内的元素缺失量全部严格小于k,区间[right, n)内的元素缺失量全部大于等于k,答案下标就夹在[left, right)里。初始时left = 0、right = n,两个已知区间都为空,不变量平凡成立。每轮取中点mid:若missing(mid) < k,说明mid及其左侧都属于"不足k"的一段,把left推到mid + 1;否则mid本身就是一个候选,把right收到mid(保留mid,不能写成mid - 1)。区间每轮至少缩短一半,left == right时循环结束。最后一步是把下标翻译成答案,这是本题真正的巧妙之处:循环结束时
left的含义是"缺失量小于k的数组元素个数",也就是在最终答案之前,还夹着left个确实存在于arr中的数。从1数到答案,一共要经过left个存在的数和k个缺失的数,所以答案就是第left + k个正整数,即left + k。这个公式的好处是它自动统一了前面说的三档边界,不需要任何分支:答案在数组前面时
left = 0,答案就是k;答案超出数组末尾时left = n,答案是n + k。
解题步骤
- 把二分区间初始化为
left = 0、right = arr.length。为什么右边界取n而不是n - 1:n是一个合法的"答案下标"取值,它表示"数组里所有元素的缺失量都不够k,答案落在数组末尾之后"。若右边界写成n - 1,arr = [1,2,3,4]、k = 2这种情况就永远收敛不到left = 4,返回值会小1。- 循环条件用
left < right,中点用left + (right - left) / 2。为什么不用left <= right:这是左闭右开区间的二分模板,left == right时区间为空、答案已经确定,继续循环会越界。中点这样写而不是(left + right) / 2,是为了避免两个大下标相加时的整型溢出——本题n ≤ 1000溢不了,但这是该固化的肌肉记忆。- 计算
missing = arr[mid] - mid - 1。为什么是减mid再减1:无缺失时下标mid上应该是mid + 1,实际值arr[mid]比它大多少,就说明前面被挖走了多少个。少减那个1会让整条判定线整体偏移一格。missing < k时执行left = mid + 1。为什么可以放心跳过mid:mid处缺失量还不够k,说明第k个缺失数一定排在arr[mid]之后,mid不可能是我们要找的左边界,排除掉它是安全的。- 否则执行
right = mid。为什么保留mid而不是mid - 1:mid处缺失量已经达到k,它本身就是一个满足条件的候选,可能正是最小的那个。写成mid - 1会把唯一的答案丢掉,二分退化成漏解。- 返回
left + k,而不是arr[left]或arr[left] - 1。为什么:left是下标不是数值,题目要的是缺失的那个正整数。left个存在的数加上k个缺失的数,答案就排在第left + k位。而且left可能等于n,此时arr[left]直接越界。以
arr = [2, 3, 4, 7, 11]、k = 5走一遍(n = 5,先列出各下标的缺失量:missing(0) = 2-0-1 = 1,missing(1) = 3-1-1 = 1,missing(2) = 4-2-1 = 1,missing(3) = 7-3-1 = 3,missing(4) = 11-4-1 = 5):
- 初始
left = 0、right = 5。- 第一轮:
mid = 0 + (5-0)/2 = 2,missing(2) = 1 < 5,答案在右侧,left = 3。区间收为[3, 5)。- 第二轮:
mid = 3 + (5-3)/2 = 4,missing(4) = 5 ≥ 5,mid = 4是候选,right = 4。区间收为[3, 4)。- 第三轮:
mid = 3 + (4-3)/2 = 3,missing(3) = 3 < 5,left = 4。区间收为[4, 4),为空。- 循环退出,
left = 4,返回4 + 5 = 9。核对一下:
arr覆盖到11,缺失的正整数依次是1, 5, 6, 8, 9, 10,第5个正是9。再从公式的含义验证:答案9之前存在于数组里的数是2, 3, 4, 7共4个,正好等于left = 4;9是第4 + 5 = 9个正整数,吻合。再补两个极端用例。
arr = [2, 3, 4]、k = 1:missing全为1 ≥ 1,二分会一路把right压到0,left = 0,返回0 + 1 = 1,正确。arr = [1, 2, 3, 4]、k = 2:missing全为0 < 2,left一路推到4,返回4 + 2 = 6,正确——这正是右边界必须取n才能到达的位置。
代码实现
class Solution {
public int findKthPositive(int[] arr, int k) {
int left = 0;
// 右边界取 n 而非 n - 1,才容得下"答案落在数组末尾之后"的情况。
int right = arr.length;
while (left < right) {
int mid = left + (right - left) / 2;
// 无缺失时 arr[mid] 应为 mid + 1,差值就是前面缺了几个正整数。
int missing = arr[mid] - mid - 1;
if (missing < k) {
left = mid + 1;
} else {
// mid 本身就是候选,收缩时必须保留它。
right = mid;
}
}
// left 是答案之前仍然存在的数的个数,答案即第 left + k 个正整数。
return left + k;
}
}
func findKthPositive(arr []int, k int) int {
left := 0
// 右边界取 n 而非 n - 1,才容得下"答案落在数组末尾之后"的情况。
right := len(arr)
for left < right {
mid := left + (right-left)/2
// 无缺失时 arr[mid] 应为 mid + 1,差值就是前面缺了几个正整数。
missing := arr[mid] - mid - 1
if missing < k {
left = mid + 1
} else {
// mid 本身就是候选,收缩时必须保留它。
right = mid
}
}
// left 是答案之前仍然存在的数的个数,答案即第 left + k 个正整数。
return left + k
}
复杂度分析
- 时间复杂度:$O(\log n)$。凭什么:每轮循环把区间
[left, right)的长度至少砍掉一半,从n缩到0需要 $O(\log n)$ 轮;循环体内只有一次取中点、一次减法和一次比较,全是常数操作。相比双指针扫描的 $O(n)$,这才是"严格递增"这个条件该换来的收益。- 空间复杂度:$O(1)$。凭什么:只用了
left、right、mid、missing四个整型变量,既没有建哈希集合,也没有递归调用栈(写成迭代二分而非递归二分)。
关键点总结
- 把"找第 k 个缺失元素"改写成"找第一个缺失量达到 k 的下标",是本题的核心动作。原问题的对象是数值、无序可循;改写后的对象是下标、且判定函数单调,二分才有立足点。遇到"第 k 个满足某性质的数",都可以试着构造一个关于下标的单调计数函数。
arr[i] - i - 1这个式子要能当场推出来而不是背下来。推法是固定的:先写出"理想情况下这个位置该是什么值"(i + 1),再用实际值减去理想值,差额就是被挖走的数量。这套"实际减理想"的思路在很多"缺失/重复元素"题里通用。- 二分找左边界的模板要固定成左闭右开:
right初值取n、循环条件left < right、命中时right = mid而不是mid - 1、退出后直接用left。四条配套使用才自洽,混搭闭区间模板必错。- 右边界能否取到
n,取决于"答案不在数组内"是否是合法结果。本题答案本来就是一个不在数组里的数,所以left = n是完全正常的收敛结果,右边界必须留出这一格。写二分前先问一句"下标的合法取值范围到底是哪些",能省掉大量差一调试。- 把下标翻译回答案的那一步往往比二分本身更容易错。
left + k这个公式之所以成立,是因为left恰好是"排在答案前面的存在数的个数";能用这句话解释清楚,就说明真的理解了,而不是凑出来的。- 面试视角:先给出 $O(n)$ 的双指针解并说明它已经能过本题数据,再主动指出"数组严格递增"意味着可以二分到 $O(\log n)$,然后给出上面的写法。被追问时重点讲三件事——单调性从哪来、右边界为什么是
n、left + k的组合意义。这题考的就是能不能把一个非典型问题掰成标准二分。
易错点总结
- 缺失量写成
arr[mid] - mid:arr = [1,2,3,4]、k = 2时每个位置算出的缺失量都是1而不是0,判定整体偏移,left停在错误位置,返回5而正确答案是6。- 右边界初始化成
arr.length - 1:arr = [1,2,3,4]、k = 2时left最多推到3,返回3 + 2 = 5,比正确答案6小1——所有"答案在数组之后"的用例统一错一位。- 命中分支写成
right = mid - 1:arr = [2,3,4]、k = 1时唯一的候选下标0会被跳过,right变成-1,循环条件left < right立刻不成立,left停在0看似侥幸正确,但换成arr = [2,3,4,7,11]、k = 3就会漏掉真正的左边界而返回错值。- 返回
arr[left]或arr[left] - 1:题目要的是缺失的数,而arr[left]是存在的数;更致命的是left可以等于arr.length,arr = [1,2,3,4]、k = 2时直接数组越界抛异常。- 循环条件写成
left <= right却保持right = mid:missing(mid) ≥ k时right不再减小,left == right == mid这一轮会无限重复,程序死循环超时。- 不足
k的分支写成left = mid:同样构成死循环,arr = [2,3,4,7,11]、k = 5在left = 3、right = 4时mid恒为3,left永远推不动。- 判定条件写成
missing <= k走左移分支:arr = [2,3,4,7,11]、k = 5时missing(4) = 5会被误判为"还不够",left被推到5,返回10而不是9。左边界二分的判定必须是严格小于。- 把
k理解成"数组中第 k 个位置对应的缺失数":arr = [2,3,4]、k = 1会去看arr[1]相关的量,返回2或3;正确答案是1,也就是排在整个数组之前的那个缺失数。- 改用哈希集合从
1开始逐个试:虽然本题数据小能过,但空间从 $O(1)$ 涨到 $O(n)$,而且完全没利用有序性,面试里说出这个方案后基本会被要求重做。- 中点写成
(left + right) / 2:本题n ≤ 1000不会溢出,但换成10^9级下标的题目就会因为相加越界得到负数中点,进而数组越界。这是应该无条件写成left + (right - left) / 2的原因。
相似题目
| 题目 | 难度 | 考察点 |
|---|---|---|
| 704. 二分查找 | 简单 | 最基础的等值二分,先在这里把左闭右开模板的四个配套细节固定下来 |
| 35. 搜索插入位置 | 简单 | 同样是找左边界且答案可以等于 n,与本题右边界取 n 的理由完全一致 |
| 34. 在排序数组中查找元素的第一个和最后一个位置 | 中等 | 左右边界各二分一次,是对同一模板正反两个方向的完整训练 |
| 268. 丢失的数字 | 简单 | 只缺一个数且范围固定,可用求和作差或异或直接 $O(n)$ 得到,无需二分 |
| 41. 缺失的第一个正数 | 困难 | 数组无序且要求 $O(n)$ 时间 $O(1)$ 空间,靠原地置换把值放回下标位而不是二分 |
| 540. 有序数组中的单一元素 | 中等 | 判定谓词建立在下标奇偶配对是否被破坏上,同样是"构造单调性再二分" |
| 162. 寻找峰值 | 中等 | 数组整体无序,靠局部相邻大小关系保证二分方向正确,展示单调性的另一种来源 |
| 875. 爱吃香蕉的珂珂 | 中等 | 二分的对象是答案值域而非数组下标,判定函数需要 $O(n)$ 现算 |