一次学会二分法——力扣278.第一个错误的版本
力扣278.第一个错误的版本
LeetCode 278 — 第一个错误的版本:多方法详解与比较(含代码与复杂度分析)
题目要求:给定版本号 1…n,已知从某个版本
bad
开始之后所有版本都是错误的,提供接口isBadVersion(version)
,找出第一个错误的版本,尽量减少对isBadVersion
的调用次数。
一、问题回顾与本质分析
每个版本要么是好的(good),要么是坏的(bad),并且版本是基于前一版本开发的,因此存在单调性:
good, good, ..., good, bad, bad, ..., bad
我们要寻找第一个 bad
出现的位置。这是典型的「在单调序列中查找满足条件的第一个位置」问题,最自然的解法是二分查找,可以将对 isBadVersion
的调用次数降到对数级别 O(log n)
。
二、方法一:标准二分查找(迭代,闭区间)
思路要点
- 使用左右闭区间
[left, right]
保存可能的解; - 每次选
mid = left + (right - left) / 2
; - 若
isBadVersion(mid)
为true
,答案在[left, mid]
(包含 mid); - 否则答案在
[mid+1, right]
; - 循环终止条件
left == right
,返回left
。
代码(Java)
public class Solution extends VersionControl {public int firstBadVersion(int n) {int left = 1, right = n;while (left < right) {int mid = left + (right - left) / 2;if (isBadVersion(mid)) {right = mid; // mid 可能是第一个坏版本} else {left = mid + 1; // 第一个坏版本在右侧}}return left;}
}
复杂度
- 时间:
O(log n)
次isBadVersion
调用 - 空间:
O(1)
三、方法二:递归二分(闭区间)
思路
与方法一逻辑相同,不过用递归实现。递归深度为 O(log n)
。
代码(Java)
public class Solution extends VersionControl {public int firstBadVersion(int n) {return helper(1, n);}private int helper(int left, int right) {if (left == right) return left;int mid = left + (right - left) / 2;if (isBadVersion(mid)) {return helper(left, mid);} else {return helper(mid + 1, right);}}
}
复杂度
- 时间:
O(log n)
- 空间:递归栈
O(log n)
注意:递归实现简洁但依赖语言的递归栈限制(本题 n
最大为 2^31 - 1
,但实际递归深度为 log2 n
,一般安全)。
四、方法三:半开区间二分([left, right))
思路
使用半开区间 [left, right)
,更利于避免 off-by-one 错误:
- 初始
left = 1
,right = n + 1
; - 循环
while (left < right)
,mid = left + (right - left) / 2
; - 若
isBadVersion(mid)
为true
,则right = mid
;否则left = mid + 1
; - 返回
left
。
代码(Java)
public class Solution extends VersionControl {public int firstBadVersion(int n) {long left = 1, right = (long)n + 1; // 避免 n+1 溢出while (left < right) {long mid = left + (right - left) / 2;if (isBadVersion((int)mid)) {right = mid;} else {left = mid + 1;}}return (int)left;}
}
复杂度
- 时间:
O(log n)
- 空间:
O(1)
五、方法四:指数(跳跃)查找 + 二分(当第一个坏版本很靠前时更省 API 调用)
适用场景
当 firstBad
(第一个坏版本)非常靠近开头(k << n
)时,先做指数增长定位区间,然后在区间内做二分,可将调用次数降到 O(log k)
,比直接对整个区间做 O(log n)
更少。
步骤
- 检查
isBadVersion(1)
,若为true
返回 1; - 指数增长
bound = 1, 2, 4, 8, ...
,直到isBadVersion(bound)
为true
或bound > n
; - 在区间
(bound/2, min(bound, n)]
上做二分查找。
代码(Java)
public class Solution extends VersionControl {public int firstBadVersion(int n) {if (isBadVersion(1)) return 1;long bound = 1;while (bound <= n && !isBadVersion((int)bound)) {bound <<= 1;}int left = (int)(bound / 2) + 1;int right = (int)Math.min(bound, n);while (left < right) {int mid = left + (right - left) / 2;if (isBadVersion(mid)) right = mid;else left = mid + 1;}return left;}
}
复杂度
- 调用次数:
O(log k)
,其中k
为第一个坏版本的位置(若k
接近n
,仍为O(log n)
) - 空间:
O(1)
六、方法五:并行 / 分布式
在实际工程中,如果 isBadVersion
接口耗时较长且允许并发,可以并行检测多个版本以减少总体延迟。但这通常会增加 API 调用总量,不符合题目“最少调用次数”的目标。LeetCode 环境也不支持并行调用,所以此方法只做工程层面的说明。
七、常见陷阱与注意事项
-
整型溢出
- 避免使用
(left + right) / 2
,应使用left + (right - left) / 2
或left + ((right - left) >> 1)
来计算中点,防止left + right
超出int
范围。
- 避免使用
-
区间边界 off-by-one
- 选择闭区间还是半开区间要一致。半开区间经常能减少边界错误。
-
接口副作用
- 假设
isBadVersion
是幂等的、无副作用的。如果接口有副作用或昂贵的计算,应尽量减少重复调用。
- 假设
-
递归栈深度
- 递归实现使用
O(log n)
的栈深度,通常安全,但对非常受限的环境还是谨慎使用。
- 递归实现使用
八、方法对比与推荐
方法 | 调用次数(数量级) | 额外空间 | 是否推荐 |
---|---|---|---|
标准闭区间二分 | O(log n) | O(1) | 推荐(常用) |
递归二分 | O(log n) | O(log n) 栈 | 可以(取决于偏好) |
半开区间二分 | O(log n) | O(1) | 推荐(防 off-by-one) |
指数跳跃 + 二分 | O(log k)(k=firstBad) | O(1) | 特殊场景下推荐 |
并行分块 | 视实现 | 需要并发支持 | 工程场景考虑,不用于 LeetCode |
推荐做法:一般直接使用“标准迭代二分”或“半开区间二分”。如果你知道或怀疑第一个坏版本很靠前,使用“指数跳跃 + 二分”可以减少实际调用次数。
九、测试建议(本地调试)
在本地调试时,可以模拟 isBadVersion
,并统计调用次数以比较不同方法的实际性能。示例框架如下:
public class VersionControl {private int bad;public int cnt = 0;public VersionControl(int bad) { this.bad = bad; }public boolean isBadVersion(int v) {cnt++;return v >= bad;}
}
把 isBadVersion
的调用代理给 VersionControl
,然后运行不同实现并输出 cnt
统计,观察调用次数差异。
十、总结
- 本题本质是单调序列上找第一个满足条件的位置,二分查找是最自然、最优的解法(
O(log n)
次 API 调用)。 - 推荐使用迭代形式实现二分(闭区间或半开区间),既高效又易于遵循边界。
- 在特殊场景下(第一个坏版本很靠前),指数跳跃 + 二分可以进一步减少调用次数。
- 实工程中可结合并发策略优化响应时间,但会增加调用次数并不适用于竞赛或 LeetCode 题目约束。