Algorithm · Binary Search

二分查找

"二段性"的艺术:统一模板 · 边界变体 · 二分答案 · 旋转数组 —— 最简单也最容易写错的算法

本质

不是"有序"而是"二段性"——能用一个布尔条件把区间切成两半

难点

边界与死循环:mid 取整方向和收缩语句必须配对

高阶

二分答案:把"最小化最大值"翻译成单调可行性的判定问题

定位:二分是"最简单也最容易写错"的算法——Knuth 名言:"第一个二分程序 1946 年发表,第一个正确的 1962 年才出现。"三条线:模板、变体、二分答案。

Why Binary Search

先看差距:每看一眼,能排除多少

数据规模一个一个看(线性扫描)每次砍一半(二分)差距
1 千条最多 1000 次10 次约 100 倍
100 万条最多 100 万次20 次约 5 万倍
10 亿条最多 10 亿次(约 10 秒)30 次约 3000 万倍
先看清慢在哪:线性扫描的每一次比较,只告诉你"这一个是 / 不是",其余 99.99% 的信息被你扔掉了。慢不是因为机器慢,是因为这个策略每看一眼只能排除一个候选
二分换掉的就是这一点:把"看一个、排除一个"换成"看一个、排除一半"。它成立的前提只有一个——你必须能看一眼就知道答案在哪一半。这个前提就叫二段性,它是二分真正的门槛,也是本 deck 反复出现的那个词。

一个能在脑子里跑的最小例子

我心里想一个 1–100 的数,你猜,我只回答"大了 / 小了 / 对了"。第一次猜 50,第二次猜 25 或 75……无论我想的是几,7 次之内你一定能猜中。你做对了什么?——你每一猜都把"还有可能"的范围砍掉一半

这个游戏什么时候会崩

如果我改口说"我只告诉你接不接近"——那你就没法判断该往左还是往右,范围砍不掉,只能重来一个个数。二分失效从来不是因为代码写错,而是因为"那一刀"问不出方向。所以做题时先问的永远是:我有没有一个判据能一刀切两半?

为什么它"最简单也最容易写错"

Knuth 说过:第一个二分程序 1946 年就发表了,第一个正确的直到 1962 年才出现。16 年栽在三个地方:中点往哪取整、区间能不能真的缩小、边界该不该保留。这三件事必须成套,换一个另外两个要跟着换。

本 deck 的路线

先把"砍一半"写成十行模板 → 用 lower_bound 统一所有边界变体 → 再把同一招用到答案的取值范围上(二分答案)→ 最后看旋转数组这类"看起来不能二分"的题怎么恢复二段性。

动机页:用"1 千 / 100 万 / 10 亿"三档具体数字把 O(log n) 变成体感,再点破线性扫描的浪费在于"每眼只排除一个"。核心是提出"看一眼就知道答案在哪一半 = 二段性"这个前提,并用"接不接近"的反例说明前提崩掉时二分无从谈起——为第 5 页 sort.Search 的单调谓词、第 6 页二分答案埋下同一个钩子。

Prerequisites & Glossary

先把词认全:下面每一页都会用到它们

术语一句话理解(先记住这个,细节后面展开)
二段性存在一个"是 / 否"判据,能把当前区间一刀切成"答案必在的一半"和"答案必不在的一半"。有序只是它最常见的来源
区间与开闭闭区间 [l, r] 两端都算在内;左闭右开 [l, r) 右端点不算。两套内部自洽即可,但绝不能混用
中点 mid每次拿来"问那一刀"的位置。写成 l+(r-l)/2 而不是 (l+r)/2,防加法溢出
下取整 / 上取整区间长度是偶数时,中间有两个位置,取靠左还是靠右。取错方向是死循环的唯一来源
收缩看完 mid 之后,把区间缩小到"答案可能还在的那一半"
lower_bound第一个 ≥t 的下标;对应的 upper_bound 是第一个 >t 的下标。整个边界家族都由这两个派生
单调谓词形如 false…false true…true 的布尔函数;sort.Search 找的就是它的第一个 true
二分答案不在数组下标上二分,而是在答案的取值范围上二分(最小速度、最小天数……)
可行性与单调可行性"值 v 能不能完成任务"叫可行性;v 可行 ⇒ 更大的 v 也可行,叫单调可行性——它是二分答案成立的唯一前提
不变量 invariant循环过程中始终成立的那句话,比如"答案一定还在 [l, r] 里"。它是判断收缩有没有写对的唯一抓手

如果这些词还有一个是陌生的

先读这几篇再回来。本 deck 默认你已经能读懂 O(log n)、数组下标和布尔函数:
复杂度分析 → O(log n) 到底是什么
数组与链表 → 为什么二分依赖"随机访问"
排序算法 → 有序数组是从哪来的

本 deck 的最小心智模型

把二分记成一个不断重复的三步动作:① 取中点;② 问一刀(答案在左还是在右);③ 把区间缩到那一半。后面所有变体(lower_bound、二分答案、旋转数组、找峰值)都不改这个动作,只改"那一刀"怎么问——想清楚判据,代码自然就出来了。

为什么"不变量"值得单独记

二分最容易写错的地方是"收缩了半天区间其实没变小"。用一句话盯住它就能防死循环:答案一定还在 [l, r] 里。每写一条收缩语句就问一次——这句话还成立吗?区间真的变小了吗?

阅读提示:术语不用背,忘了回来查这一页;真正要能默写的只有十行模板,以及速查页里那张"两套区间对照表 + 五条检查清单"。
前置页:十个术语先定义再使用。重点是把"二段性"立成二分的真正门槛(而不是"有序"),并把"不变量 = 答案还在 [l,r] 里"作为防死循环的统一抓手,直接支撑后面模板页的配对规则与速查页的检查清单。

One Template To Rule Them All

统一模板:闭区间三件套

上一页说"每看一眼就砍掉一半",这一页把它写成十行代码——重点看三件套怎么配对,少配一个就是死循环。

// 闭区间 [l, r] 标准模板
func search(a []int, t int) int {
    l, r := 0, len(a)-1
    for l <= r {              // 区间非空
        m := l + (r-l)/2      // 下取整
        switch {
        case a[m] == t:
            return m
        case a[m] < t:
            l = m + 1         // t 在右半
        default:
            r = m - 1         // t 在左半
        }
    }
    return -1
}
// 防溢出写法 l+(r-l)/2(C/Java 必备习惯)

三件套的配对关系

循环条件 l <= r(闭区间非空);② 收缩语句 l=m+1 / r=m−1(m 已排除);③ mid 取整下取整。三者必须自洽——换其中任何一个,另外两个要跟着换。

死循环的唯一来源

用"mid 保留"式收缩(l=mid / r=mid)时:若 mid 下取整且走 l=mid,区间 [l,r] 不缩 → 死循环。规则:l=mid 时 mid 必须上取整 (l+r+1)/2

为什么用 l+(r−l)/2

(l+r)/2 在 32 位语言里 l+r 可能溢出(Java Arrays.binarySearch 历史 bug)。Go 的 int 是 64 位一般不会,但这是跨语言肌肉记忆 + 面试官的检查点。

复杂度

每次排除一半 → 期望与最坏都是 ⌈log₂n⌉ 次比较(复杂度 deck QA9 的推导)。空间 O(1) 迭代。

统一模板的意义:背一套、推其余。三件套配对关系是防错的总纲,"l=mid 必须上取整"是死循环的唯一来源——把这两句话记住,二分不再玄学。

Halve · Check · Shrink

图解:每一步排除一半

二分查找 11 的三步区间收缩过程 有序数组 1 3 5 7 9 11 13 中查找 11:第一步中点下标 3 的值 7 小于 11,收缩到右半;第二步中点下标 5 的值恰好是 11,查找结束,共两次比较。 STEP 1 · l=0 r=6 m=3 1 3 5 7 9 11 13 橙=m · a[3]=7 < 11 → 排除左半 STEP 2 · l=4 r=6 m=5 1 3 5 7 9 11 13 a[5]=11 命中 → 2 次比较 n=7 → 2 步 · n=10⁶ → 20 步 · n=10⁹ → 30 步 每步把"剩余可能"砍半:log₂n 步后区间必然为空或命中 —— O(log n) 的直观来源
两步走完 7 个元素:橙色是中点、虚线是已排除区。底部换算给出"10 亿数据 30 步"的体感——这就是"对数是算法里的刹车"。

Lower Bound · Upper Bound

五种变体:全部由 lower_bound 派生

// lower_bound:第一个 >= t 的下标
func lowerBound(a []int, t int) int {
    l, r := 0, len(a)     // 左闭右开
    for l < r {
        m := l + (r-l)/2
        if a[m] < t {
            l = m + 1     // [m+1, r)
        } else {
            r = m         // m 可能是答案,保留
        }
    }
    return l // 不存在时 = len(a)
}
为什么它是"母模板":其余变体都在它上面做一次平移或取反——统一用 lower_bound 表达,边界 bug 减半。C++ 的 lower_bound/upper_bound、Go 的 sort.SearchInts 都是同一语义。

五个变体 = 一次 lower_bound

① 第一个 ≥ t:lowerBound(a,t);② 第一个 > tlowerBound(a,t+1)(整数)或 upper_bound;③ 最后一个 < t:①的答案 −1;④ 最后一个 ≤ t:②的答案 −1;⑤ t 出现次数:② − ①。

左闭右开的自洽性

循环条件 l < r(开区间不空当);r=m(m 在开区间外、但可能是答案所以保留);l=m+1(m 已确认太小)。与闭区间模板不同但内部自洽——别混用两套。

检查表(写完必过)

① 空数组返回什么?② 全部小于 t 时返回 len?③ 全部大于 t 时返回 0?④ 重复元素取第一个?——四个 case 手推一遍,边界题就稳了。

经典落地

LC 34 搜索范围 = 两次 lower_bound(≥t 和 ≥t+1);LC 35 插入位置 = lower_bound 本身。

lower_bound 是二分家族的"母模板":五个变体一次派生。左闭右开的三行配对关系和四 case 检查表是写对边界的方法论。

Go Stdlib · Monotone Predicate

Go 的二分抽象:sort.Search 是"单调谓词查找"

// sort.Search(n, f):返回最小的
// i ∈ [0,n] 使 f(i)==true(f 单调)
// 例:有序数组中找 target
idx := sort.Search(len(a), func(i int) bool {
    return a[i] >= target
})
// idx == len(a) 表示不存在

// 例:找第一个 > 100 的下标
i := sort.Search(len(a), func(i int) bool {
    return a[i] > 100
})

// 二分答案:速度 v 是否 k 小时吃完
ok := sort.Search(maxSpeed, func(v int) bool {
    return hours(v) <= h
})

抽象的妙处

把"在有序数组里找值"泛化成"在单调布尔函数上找第一个 true"——数组、答案空间、判定函数统一成一个 API。手写二分的边界错误在这里不存在(标准库写好了)。

f 单调是前提

f 必须形如 false…false true…true——这正是"二段性"的函数化表达。f 不单调时结果未定义(不报错,坑)。

sort.SearchInts / SearchFloat64s / SearchStrings

都是 Search 的特化:SearchInts(a, x) = 第一个 ≥ x 的下标(即 lower_bound);返回 len(a) 表示所有元素都小。

什么时候手写

需要"第一个 true 的同时拿 mid 做别的事"、区间收缩方向非标准、或性能敏感的 hot loop——手写时回到第 2 页模板和配对规则。

Go 岗专属页:sort.Search 的"单调谓词"抽象是标准库最优雅的设计之一。第三个例子直接预告第 6 页的二分答案——同一个 API 两页用法。

Binary Search On Answer

二分答案:把"最小化最大值"变成判定题

适用信号(两个特征)

① 问的是"最小的最大 / 最大的最小 / 最少需要多少";② 存在一个单调可行性:x 可行 ⇒ x+1 也可行(或反之)。两条件齐 → 对"答案本身"二分。

LC 875 爱吃香蕉的珂珂

速度 v 越大吃得越快(单调!)→ 判定 hours(v) ≤ H 是否成立 O(n) → 对 v ∈ [1, max(piles)] 二分,总 O(n log maxP)。直接枚举 v 是 O(n·maxP) 爆炸。

LC 410 分割数组的最大值

"最大和最小化":上限 cap 越大越容易切 ≤m 段(单调)→ 判定 = 贪心数段 O(n) → 二分 cap ∈ [max(a), sum(a)]。

为什么比 DP 常更优

DP 要设计状态(往往 n²);二分答案 = log(值域) × O(n) 判定——把指数/平方的组合问题压成对数×线性。先问"答案有没有单调性"再想 DP,是解题顺序的优化。

// LC 875:最小吃香蕉速度
func minEatingSpeed(piles []int, h int) int {
    return sort.Search(1<<62, func(v int) bool {
        if v == 0 { return false }
        hours := 0
        for _, p := range piles {
            hours += (p + v - 1) / v // 上取整
        }
        return hours <= h
    })
}
// sort.Search 直接当"最小可行 v"用
// O(n·log(maxP)) —— 判定是 O(n) 的
面试金句:"二分答案的精髓是反过来问:不问'最优值是多少',问'值 v 可不可行'——后者是 O(n) 判定,前者是组合难题。可行性单调性是桥梁。"
二分答案是二分的"高阶玩法":875/1011/410 三题同一模子。代码展示 sort.Search 的第二个用法——答案空间当数组。金句点破"反向提问"的思维转换。

Rotated Array · LC 33/81/153

旋转数组:用"哪半有序"恢复二段性

核心观察

旋转后的数组被断点分成两个有序段。任取中点 m,[l..m] 和 [m..r] 必有一个是有序的——用 a[l] <= a[m] 判断左半是否有序,然后看 target 在不在有序段的范围内。

LC 33 搜索旋转排序数组

左半有序(a[l]≤a[m]):target ∈ [a[l],a[m]) → 去左,否则去右;左半无序 → 右半必有序,同理判断。每步仍排除一半,O(log n) 成立

LC 153 找最小值

a[m] > a[r] → 最小值在 (m, r];a[m] < a[r] → 最小值在 [l, m]。关键:跟 a[r] 比,不跟 a[l] 比(跟 a[l] 比在未旋转时会误判)。

LC 81 允许重复的坑

a[l]==a[m]==a[r] 时无法判断哪边有序 → l++ r-- 双向收缩(最坏退化 O(n))。这是 33 的重复版唯一新增逻辑。

方法论

旋转族的本质:人为构造判据恢复二段性。"每次比较必须能安全排除一半"——判据失效(重复元素)时退化是必然代价。

题目判定条件
33 搜索(无重复)Ma[l]≤a[m] → 左半有序,查 target 是否在内
153 最小值 Ma[m]>a[r] → min 在右半;否则在左半(含 m)
81 搜索(重复)M三值相等 → l++/r--,最坏 O(n)
154 最小值(重复)H同上收缩,均摊 O(log n) 最坏 O(n)
面试金句:"旋转数组题的题眼不是'旋转',而是'半段有序'——任何时刻总有一半是纯有序的,把 target 与有序段比一比,二段性就回来了。"
旋转族三题共用一个观察:"任一半必有序"。153"跟 a[r] 比"是最容易踩的细节,81 的三值相等收缩是重复版的全部新增。金句把方法论提炼成"恢复二段性"。

LeetCode Shortlist

必刷题单:二分的六种考法

考法题目(编号 · 难度)要点
裸模板704 二分查找 E · 35 搜索插入位置 E · 34 范围 M34 = 两次 lower_bound
旋转数组33 搜索 M · 153 最小值 M · 81/154 重复版 M/H哪半有序判据;153 跟 a[r] 比
二分答案875 吃香蕉 M · 1011 运货 M · 410 分割数组 H单调可行性判定;同款三题
局部极值162 寻找峰值 M · 852 山脉数组 Ea[m]<a[m+1] 定爬坡方向
序列上二分300 LIS M · 334 递增三元组 M300 的 tails+lower_bound 是 O(n log n)
数学化二分69 x 的平方根 E · 50 Pow(x,n) M69 是答案域二分最小例
刷法建议:704/35 热身 → 34 写熟 lower_bound → 875 领悟二分答案 → 33/153 旋转族 → 300 把二分嵌到 DP 优化里。每个变体亲手触发一次"死循环"再修好,比背十条规则有效。
六种考法按学习顺序排列。特别推荐"故意写错再修"的学习法——死循环和边界错误必须亲手触发过才能免疫。

Cheat Sheet

一页带走:母模板 · 五变体 · 五条检查

① 母模板 lower_bound:左闭右开 [l, r)

// 第一个 >= t 的下标;不存在则返回 len(a)
func lowerBound(a []int, t int) int {
    l, r := 0, len(a)          // 右端点开区间
    for l < r {                 // 区间非空
        m := l + (r-l)/2       // 下取整,防溢出
        if a[m] < t {
            l = m + 1          // m 太小,排除
        } else {
            r = m              // m 可能是答案,保留
        }
    }
    return l
}

② 五变体 = 一次 lower_bound

第一个 ≥ tlowerBound(a, t)
第一个 > tlowerBound(a, t+1)(整数)/ upper_bound
最后一个 < tlowerBound(a, t) - 1
最后一个 ≤ tlowerBound(a, t+1) - 1
t 出现次数lowerBound(t+1) - lowerBound(t)

③ 两套区间:自洽即可,绝不能混用

要素闭区间 [l, r]左闭右开 [l, r)
r 初值len(a)-1len(a)
循环条件l <= rl < r
收缩l=m+1 / r=m-1l=m+1 / r=m
mid 取整下取整下取整;l=m 时必须上取整
找不到时返回 -1返回 l(可等于 len)

④ 默写检查清单(写完逐条扫一遍)

1 · 二段性成立吗?先说清"那一刀"是什么——没判据就别二分(162 峰值、33 旋转都不是有序,但有判据)。

2 · 区间真的在缩小吗?盯住不变量"答案还在 [l,r] 里";l=m 必须配上取整,否则两个元素时死循环。

3 · 两套区间混用了吗?初始值 / 循环条件 / 收缩语句 / 返回值是一套,混一半必错。

4 · 四个边界 case 过了吗?空数组、全都 < t、全都 > t、有重复元素——手推一遍。

5 · 二分答案的前提验证了吗?必须先说清"v 可行 ⇒ 更大 v 也可行",否则单调性不成立,二分无意义。

一句话背下来:二分 = 取中点、问一刀、缩一半;所有变体改的只有"那一刀"怎么问。
速查页:左半给出"母模板 + 五变体派生",右半给出"两套区间四要素对照 + 五条检查清单"。这套组织的意图是让读者只记一个模板、一张对照表,其余全部现场推导——这正是"背一套、推其余"的落地。

Interview QA · Part 1

高频追问:模板与边界

先自答,再对照:每题先说出你的答案(哪怕只说关键词),再展开下面那行——卡住的那 30 秒才是真正长记性的部分。

1 · 二分的前提到底是"有序"还是"单调"?

二段性

本质是二段性:存在一个判据能把当前区间分成"必然满足/必然不满足"两半。有序只是二段性最常见的来源(峰值 162、旋转 33 都不是全局有序)。

2 · (l+r)/2 有什么问题?

溢出

l+r 可能超出 int 范围(Java/32 位 C)。写 l+(r−l)/2。Go int 64 位通常安全,但习惯要统一——面试官看到 (l+r)/2 常会追问。

3 · 什么情况下死循环?

收缩不缩

用 l=mid 收缩但 mid 下取整:当 l=r−1 时 mid 恒等于 l,区间永远不缩。规则:l=mid 必须配 (l+r+1)/2 上取整;r=mid 配下取整。

4 · 左闭右闭和左闭右开怎么选?

自洽即可

两套都行但内部必须自洽:闭区间配 l<=r 与 r=m−1;开区间配 l<r 与 r=m。混用必错。lower_bound 系统库语义是左闭右开,建议熟记那套。

5 · lower_bound 返回什么?不存在呢?

第一个 ≥

第一个 ≥t 的下标;全部元素小于 t 时返回 len(a)。由此派生:upper_bound=lowerBound(t+1)、个数=upper−lower、前驱=lower−1。

6 · sort.Search 的 f 有什么要求?

单调谓词

f 必须满足 false…false true…true 的单调形。Search 返回第一个 true 的下标;f 不单调时结果未定义且不报错——用它之前先证明单调。

7 · 二分和复杂度里的 O(log n) 是什么关系?

排除一半

每步把剩余可能性减半:k 步后剩 n/2^k,停机条件 n/2^k≤1 → k=log₂n。对比线性扫描 O(n)——n=10⁹ 时是 30 步 vs 10 亿步。

8 · 162 找峰值为什么能二分?数组并不有序!

局部二段性

看 a[m] 与 a[m+1]:上坡 → 右侧必存在峰(到结尾一路升也算);下坡 → m 或其左侧有峰。判据只需保证"答案在哪一半",与全局有序无关。

前八题是二分的方法论核心:二段性、溢出、死循环、两套区间、lower_bound 派生、sort.Search、O(log n) 推导、峰值题的二段性证明。第 8 题是"打破有序迷信"的关键题。

Interview QA · Part 2

高频追问:二分答案与实战

先自答,再对照。这一组的重点是"识别"——看到题先自己判断它该不该二分,再对照答案里的信号词。

9 · 什么是二分答案?怎么识别?

单调可行性

对"答案本身"二分:信号词是"最小化最大 / 最大化最小 / 最少多少天",且可行性随答案单调。复杂度 = O(log 值域) × O(n) 判定。

10 · 875/1011/410 三题的共同骨架?

判定函数

都是"给定上限 → 贪心判定是否可行":875 数小时、1011 数天数、410 数段数。骨架 = 外层二分答案 + 内层 O(n) 贪心;区别只在判定细节。

11 · 实数域二分怎么写?

eps / 定次

两种终止:while(r−l>eps)(精度控制,注意 eps 过小导致死循环)或固定迭代 100 次(2¹⁰⁰ 倍缩小区间,最稳)。浮点无"精确等于",输出 l 或 r 均可。

12 · 300 LIS 的 O(n log n) 是怎么来的?

tails 数组

tails[i] = 长度 i+1 的递增子序列的最小结尾(单调递增)。每个新元素对 tails 二分替换(lower_bound)——替换不改变其单调性, tails 长度即 LIS 长度。

13 · 153 为什么跟 a[r] 比而不是 a[l]?

经典细节

未旋转(整体有序)时 a[m] 恒 > a[l],会一直往右走错过最小值 a[0];跟 a[r] 比则:a[m]>a[r] 断点在右,否则最小在左半(含 m)——两种情形都正确。

14 · 什么时候手写二分、什么时候用库?

Go 实践

标准 lower_bound 语义 → sort.SearchInts / sort.Search;二分答案 → sort.Search 直接当"最小可行值";非标准收缩(峰值、旋转、三分)→ 手写并过第 2 页检查表。

后六题:二分答案识别、三题同骨架、实数域、LIS 优化、153 细节、手写 vs 库。第 11 题的"固定 100 次迭代"是浮点二分的工程最稳写法。

Related & References

相关知识点与参考

算法系列(本分类)

复杂度分析 →(O(log n) 与二段性的语言体系)
排序算法 →(有序数组从哪来)
滑动窗口 →("同族但不同宗"的区间收缩)
动态规划 →(LIS 的 DP 与二分优化对照)

数据结构系列

平衡树 →(动态数据上的"二分")
堆 →(最值查询的另一种 O(log n))
数组与链表 →(随机访问是二分的前提)

参考来源(本 deck 结论可溯源至下列一手材料)

CLRS · grep/binary search 章节 · Knuth TAOCP 6.2.1二分的经典表述;Knuth 论 1946-1962 的正确实现历史
go.dev · sort 包文档(Search/SearchInts)"smallest index i for which f(i) is true"的官方语义
C++ std::lower_bound / upper_bound 语义左闭右开与派生关系的标准出处
LeetCode 704/34/33/153/162/875/410/300 题解六种考法的代表题
oi-wiki.org/basic/binary二分与二分答案的中文系统讲解
收尾:算法系列四条 + 数据结构三条链接。Knuth 的历史轶事、sort 包官方语义、std::lower_bound 标准出处三线互证。总页数 11。