一、一个警告开场
Knuth 在 TAOCP 第 3 卷 §6.2.1 中记述:二分查找的思想早在 1946 年就已见诸文献,而第一个对任意长度数组都完全正确的已发表版本要到 1962 年才出现——中间隔了 16 年。
Jon Bentley 在 Programming Pearls 中让上百名职业程序员用两小时写二分查找,90% 的人写出了有 bug 的版本。更著名的是:Java 的 Arrays.binarySearch 在 2006 年被发现有溢出 bug,而它已经在标准库里躺了九年。
为什么一个十行的算法这么难写对? 因为它的正确性完全依赖于三件事的一致性:
① 搜索区间的开闭约定 ② 循环条件 ③ 边界的收缩方式
三者必须互相匹配。任何一处不一致,就会得到死循环或漏掉一个元素。
⭐ 本讲的主张是:不要"凭感觉"调整 ±1,而是先写下循环不变式,让三者从不变式推导出来。 这正是第 1 讲的方法论第一次真正派上用场。
二、为什么二分查找放在这里
有序数组上的二分查找是静态的有序字典:
| 二分查找(有序数组) | 二叉搜索树(第 15 讲) | |
|---|---|---|
| 查找 | O(log n) | O(log n) |
| 插入 / 删除 | O(n) —— 要搬移元素 | O(log n) |
| 空间开销 | 0 | 每节点 2–3 个指针 |
| 缓存局部性 | 极好(连续内存) | 差(指针追逐) |
| 前驱 / 后继 / 范围 | ✓ | ✓ |
⭐ BST 就是"把二分查找的判定路径固化成显式的树":二分查找每次比较的中点序列,恰好对应一棵完美平衡 BST 的根到叶路径。区别只在于——数组把这棵树隐式地编码在下标算术里(所以插入要 O(n) 重排),而 BST 把它显式地存成指针(所以插入只要 O(log n) 改指针)。
这个对照给出一条重要的选型结论:读多写少时,有序数组 + 二分往往打败任何平衡树——没有指针开销、完美的缓存局部性、可以直接 mmap。数据库的只读索引、编译期生成的查找表、Go 里 sort.SearchInts 的典型用法都是如此。只有当写入频繁时,才值得付出树的代价。
三、写法一:闭区间 [lo, hi]
不变式:若目标存在,它一定在 a[lo..hi] 内。
// 返回 x 的下标;不存在返回 -1
func BinarySearch(a []int, x int) int {
lo, hi := 0, len(a)-1 // 闭区间:hi 是合法下标
for lo <= hi { // 区间非空的条件是 lo <= hi
mid := lo + (hi-lo)/2 // ⚠️ 不写 (lo+hi)/2,防溢出
switch {
case a[mid] == x:
return mid
case a[mid] < x:
lo = mid + 1 // mid 已排除,收缩到 [mid+1, hi]
default:
hi = mid - 1 // mid 已排除,收缩到 [lo, mid-1]
}
}
return -1
}
三者的一致性检查:
| 约定 | 推出 |
|---|---|
区间是闭的 [lo, hi] |
hi 初始化为 len(a)-1(因为它是合法下标) |
闭区间非空 ⟺ lo <= hi |
循环条件用 <= |
a[mid] 已被检查过 |
收缩时必须 mid±1,把 mid 排除在外 |
⚠️ 把循环条件写成 lo < hi 会漏掉 lo == hi 这一个元素——这是最常见的错误。当 lo == hi 时闭区间 [lo, lo] 仍含一个元素,必须检查。
三步证明
- 初始化:
[0, n-1]是整个数组,若 x 存在必在其中 ✓ - 保持:
a[mid] < x时,由数组有序,a[lo..mid]全部 < x,x 若存在必在[mid+1, hi]✓(另一分支对称) - 终止:
lo > hi时区间为空,由不变式 x 不存在,返回 −1 ✓
四、两个经典 bug
Bug 1:(lo+hi)/2 溢出
mid := (lo + hi) / 2 // ✗ lo+hi 可能溢出
mid := lo + (hi-lo)/2 // ✓ 差值不会溢出
这就是 Java 标准库那个躺了九年的 bug(Joshua Bloch, 2006)。溢出条件是 lo + hi > 2^31 − 1,由于 hi ≤ n−1,这要求数组长度达到 2^30(约 10.7 亿)量级且搜索已收敛到数组后半段。此时 lo+hi 溢出成负数,/2 后仍为负,a[mid] 抛出越界异常。
⚠️ 在 Go 里这个 bug 更隐蔽:int 是 64 位,切片长度不可能接近 2^63,所以对切片下标实际上不会触发。但只要你把二分用在数值域上(下一节的"二分答案"),lo 和 hi 就是任意大的数值,溢出立刻回来。养成写 lo + (hi-lo)/2 的习惯,代价为零。
Bug 2:死循环
for lo < hi {
mid := lo + (hi-lo)/2
if a[mid] < x {
lo = mid + 1
} else {
lo = mid // ✗ 当 hi == lo+1 时 mid == lo,区间不缩小 ⟹ 死循环
}
}
⭐ 死循环的根因永远是:某个分支没有让区间严格变小。 检查方法:假设区间只剩两个元素(hi == lo+1),此时 mid == lo,看每个分支是否都让区间真正缩小。
修法有两种,取决于你要什么:
mid := lo + (hi-lo+1)/2 // 上取整,配合 lo = mid / hi = mid - 1
mid := lo + (hi-lo)/2 // 下取整,配合 hi = mid / lo = mid + 1
⭐ 口诀:lo = mid 配上取整,hi = mid 配下取整。 否则必死循环。
五、写法二:半开区间与 lower_bound
真实工作中,“找到 x 的下标"往往不如"找到 x 应该插入的位置"有用——后者能同时回答存在性、前驱、后继、范围查询、计数。
lower_bound(x):第一个 ≥ x 的元素下标(不存在则返回 n) upper_bound(x):第一个 > x 的元素下标
// 不变式:答案落在半开区间 [lo, hi) 内;a[lo-1] < x ≤ a[hi]
func LowerBound(a []int, x int) int {
lo, hi := 0, len(a) // 半开区间:hi 可以等于 len(a)
for lo < hi { // 半开区间非空的条件是 lo < hi
mid := lo + (hi-lo)/2
if a[mid] < x {
lo = mid + 1 // a[mid] 太小,排除
} else {
hi = mid // ⭐ 不是 mid-1:a[mid] 本身可能就是答案
}
}
return lo
}
func UpperBound(a []int, x int) int {
lo, hi := 0, len(a)
for lo < hi {
mid := lo + (hi-lo)/2
if a[mid] <= x { // 只有这里的 < 改成 <=
lo = mid + 1
} else {
hi = mid
}
}
return lo
}
⭐ 注意 hi = mid 而不是 mid - 1:半开区间里 hi 是"第一个被排除的位置”,而 a[mid] ≥ x 时 mid 本身还是候选,不能排除它。这与闭区间写法的差别,正是不变式差别的直接后果。
一个函数解决全部问题
i := LowerBound(a, x)
存在性: i < len(a) && a[i] == x
前驱(< x): i-1(若 i > 0)
后继(> x): UpperBound(a, x)
等于 x 的个数:UpperBound(a, x) - LowerBound(a, x)
范围 [l, r] 内的元素个数:UpperBound(a, r) - LowerBound(a, l)
插入位置(保持有序):i
⭐ 强烈建议只记 LowerBound 这一个模板。 它的半开区间写法只有一个循环条件(lo < hi)、一个收缩规则(lo = mid+1 / hi = mid),没有 mid-1,也不需要在循环里判相等——能出错的地方最少。上面 6 个问题全部由它派生。
Go 标准库
i, found := slices.BinarySearch(a, x) // i 就是 lower_bound,found 是存在性
i := sort.SearchInts(a, x) // 同上,只返回位置
i := sort.Search(n, func(i int) bool { // ⭐ 最通用:找第一个使谓词为 true 的 i
return a[i] >= x // 谓词必须单调:false…false,true…true
})
⚠️ sort.Search 的谓词必须单调(一段 false 后全是 true)。这个"单调谓词"抽象正是下一节的基础。
六、⭐ 二分答案:把最优化问题变成判定问题
这是二分查找最有价值、也最常被忽视的用法。
观察:二分查找的本质不是"在数组里找数",而是——
在一个单调的布尔序列
F F F … F T T … T上,找第一个 T 的位置。
数组有序只是让 a[i] >= x 这个谓词单调的一个特例。只要你能构造出一个单调谓词,就能二分,哪怕根本没有数组。
于是最优化问题可以这样转换:
原问题(难): 求最小的可行值 X
转换后(易): 给定 v,判断"v 可行吗"—— 若可行性对 v 单调,就二分 v
⭐ 判定往往比优化容易得多(这个观察在第 34 讲讨论 P 与 NP 时会以另一种面貌重现)。
例 1:分割数组的最小最大和
把数组分成 k 个连续段,最小化各段和的最大值。
直接求最优分割很麻烦(是一个 DP)。但判定很容易:
// 判定:每段和都不超过 cap 时,最少需要分几段?≤ k 就可行
func feasible(a []int, k, cap int) bool {
segs, cur := 1, 0
for _, x := range a {
if x > cap {
return false // 单个元素就超了
}
if cur+x > cap {
segs++ // 开新段(贪心:能装就装)
cur = x
} else {
cur += x
}
}
return segs <= k
}
func SplitArray(a []int, k int) int {
lo, hi := slices.Max(a), 0 // 下界:最大元素;上界:全部和
for _, x := range a {
hi += x
}
for lo < hi { // ⭐ 与 LowerBound 完全同构:找第一个 feasible 为 true 的 cap
mid := lo + (hi-lo)/2
if feasible(a, k, mid) {
hi = mid
} else {
lo = mid + 1
}
}
return lo
}
复杂度 O(n log(Σa))——log 作用在数值范围上而非元素个数上。
单调性论证(必须给出):若 cap 可行,则任何 cap′ > cap 也可行(同样的分割方案仍然满足约束)。因此可行性序列是 F F F … T T T。✓
⚠️ 每次用二分答案,都必须显式论证这个单调性。 谓词不单调时二分会给出一个"看起来合理"的错误答案——这类 bug 极难发现,因为它在多数测试用例上碰巧正确。
例 2:最小化最大值的一般模式
"最小化最大值" / "最大化最小值" / "第 k 小" ──▶ 强烈暗示二分答案
| 问题 | 二分的量 | 判定 |
|---|---|---|
| 分割数组最小化最大段和 | 段和上限 | 贪心分段,段数 ≤ k? |
| 在 D 天内运完包裹的最小运力 | 运力 | 贪心装船,天数 ≤ D? |
| 让 m 束花开需等待的最少天数 | 天数 | 扫描连续开花段,够 m 束? |
| 爱吃香蕉的珂珂(最小速度) | 速度 | 总耗时 ≤ H? |
| 最大化最近两点的最小距离 | 距离 | 贪心放置,能放下 k 个? |
| 有序矩阵中第 k 小的数 | 数值 | ≤ v 的元素个数 ≥ k?(沿阶梯统计 O(n)) |
| 分数规划(最大化 Σa/Σb) | 比值 λ | Σ(a - λb) ≥ 0? |
⭐ 识别信号:题目里出现"最小的最大""最大的最小""至少/至多 k 个",而直接求解需要复杂 DP 时,先问一句:“如果答案已经告诉我了,我能在线性时间内验证吗?” 能,就二分它。
实数域上的二分
func BinarySearchReal(feasible func(float64) bool, lo, hi float64) float64 {
for i := 0; i < 100; i++ { // ⭐ 固定迭代次数,不要用 hi-lo > eps
mid := lo + (hi-lo)/2
if feasible(mid) {
hi = mid
} else {
lo = mid
}
}
return lo
}
⚠️ 不要写 for hi-lo > 1e-9:浮点数在大数值区间上的最小间隔可能大于 eps,导致死循环。固定迭代 100 次即可把区间缩小到 2^-100 倍,远超 float64 的精度,且永不死循环。
七、变体
旋转有序数组
// [4,5,6,7,0,1,2] —— 有序数组被旋转过,元素互异
func SearchRotated(a []int, x int) int {
lo, hi := 0, len(a)-1
for lo <= hi {
mid := lo + (hi-lo)/2
if a[mid] == x {
return mid
}
if a[lo] <= a[mid] { // ⭐ 左半段有序
if a[lo] <= x && x < a[mid] {
hi = mid - 1
} else {
lo = mid + 1
}
} else { // 右半段有序
if a[mid] < x && x <= a[hi] {
lo = mid + 1
} else {
hi = mid - 1
}
}
}
return -1
}
⭐ 关键洞察:数组被旋转后,以 mid 分开的两半中,至少有一半是完全有序的。 判断出哪一半有序,就能在那一半上用普通的范围判断决定去向。
⚠️ 元素可重复时最坏退化为 O(n):a[lo] == a[mid] == a[hi] 时无法判断哪半有序,只能 lo++ 逐个排除。
寻找峰值:无序也能二分
// 找任意一个 a[i] > a[i-1] && a[i] > a[i+1] 的位置(边界视为 -∞)
func FindPeak(a []int) int {
lo, hi := 0, len(a)-1
for lo < hi {
mid := lo + (hi-lo)/2
if a[mid] < a[mid+1] {
lo = mid + 1 // 上坡:右边必有峰
} else {
hi = mid // 下坡:左边(含 mid)必有峰
}
}
return lo
}
⭐ 这个例子推翻了"二分查找需要有序数组"的误解。 它只需要一个能排除掉一半的判据:上坡方向必然存在峰值(要么一直升到边界,要么中途下降)。二分的真正前提是"能安全排除一半",而不是"有序"。
八、常见错误清单
| 症状 | 原因 |
|---|---|
| 死循环 | 某分支未让区间严格缩小;lo = mid 配了下取整 |
| 漏掉一个元素 | 闭区间用了 lo < hi;或半开区间写成 hi = mid - 1 |
| 越界 | 闭区间把 hi 初始化成 len(a) |
| 溢出 | (lo+hi)/2;数值域二分时尤其危险 |
| 答案差 1 | lower_bound / upper_bound 混用;闭开区间约定与收缩不匹配 |
| 实数二分卡死 | 用 hi-lo > eps 作循环条件 |
| 二分答案给出错误结果 | ⚠️ 谓词不单调却没有验证 |
⭐ 一条能救命的自查方法:写完后,用长度为 1 和 2 的数组手工跑一遍。 几乎所有边界 bug 都会在这两个用例上暴露——而随机大数组测试常常掩盖它们。
随堂自测
- 为什么说"BST 是把二分查找的判定路径显式化"?读多写少时为什么有序数组常常打败平衡树?
- 闭区间写法中,循环条件为什么必须是
lo <= hi?写成<会漏掉什么? mid := lo + (hi-lo)/2相比(lo+hi)/2解决了什么问题?在 Go 里对切片下标还有必要吗?为什么仍然要写?- 给出一个会死循环的二分写法,说明根因。"
lo = mid配上取整"这条口诀为什么成立? - 半开区间的 LowerBound 中,为什么是
hi = mid而不是hi = mid - 1? - 只用
LowerBound实现:存在性判断、前驱、后继、等于 x 的个数、[l, r]内元素个数。 - 什么是二分答案?它把什么问题转化成了什么问题?
- 对"分割数组最小化最大段和",写出可行性判定并证明它关于 cap 单调。
- 为什么实数二分要用固定迭代次数而不是
hi-lo > eps? - 寻找峰值的数组并不有序,为什么还能二分?由此说说二分的真正前提是什么。
- 旋转数组二分的关键洞察是什么?元素可重复时为什么退化成 O(n)?