一、一个警告开场

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,所以对切片下标实际上不会触发。但只要你把二分用在数值域上(下一节的"二分答案"),lohi 就是任意大的数值,溢出立刻回来。养成写 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 都会在这两个用例上暴露——而随机大数组测试常常掩盖它们。


随堂自测

  1. 为什么说"BST 是把二分查找的判定路径显式化"?读多写少时为什么有序数组常常打败平衡树?
  2. 闭区间写法中,循环条件为什么必须是 lo <= hi?写成 < 会漏掉什么?
  3. mid := lo + (hi-lo)/2 相比 (lo+hi)/2 解决了什么问题?在 Go 里对切片下标还有必要吗?为什么仍然要写?
  4. 给出一个会死循环的二分写法,说明根因。"lo = mid 配上取整"这条口诀为什么成立?
  5. 半开区间的 LowerBound 中,为什么是 hi = mid 而不是 hi = mid - 1
  6. 只用 LowerBound 实现:存在性判断、前驱、后继、等于 x 的个数、[l, r] 内元素个数。
  7. 什么是二分答案?它把什么问题转化成了什么问题?
  8. 对"分割数组最小化最大段和",写出可行性判定并证明它关于 cap 单调
  9. 为什么实数二分要用固定迭代次数而不是 hi-lo > eps
  10. 寻找峰值的数组并不有序,为什么还能二分?由此说说二分的真正前提是什么。
  11. 旋转数组二分的关键洞察是什么?元素可重复时为什么退化成 O(n)?