开区间与循环不变量
二分查找的思想一句话能说清:在有序序列里每次将范围减半,
步定位目标。真正写对它却出了名的难,边界取
还是
、循环条件用 < 还是 <=、找不到时返回什么、要找的是等于还是第一个大于等于,稍有不慎就 off-by-one 或死循环。本页不背模板,而是用一个循环不变量把所有变体统一起来。
1 · 开区间与红蓝染色
二分难写,难在边界。与其逐条记忆什么时候 +1、什么时候 <=,不如抓住一个贯穿始终的命题:用两个指针把数组划成三段,红段(确定属于一类)、蓝段(确定属于另一类),以及夹在中间的待定段。只要每一轮后红蓝段的含义不变、待定段严格缩小,循环结束时答案必然落在红蓝交界处。
本页采用开区间写法,取
、,始终维持
是唯一的待定区间:下标
的全染红、
的全染蓝。初始时整个数组都待定,两端的虚拟哨兵
与
天然满足红蓝条件,因为空集上任何命题都成立。本节以最常用的 lower_bound 为例,即找第一个
target 的下标,于是红是「小于 target」、蓝是「大于等于 target」。
1, 3, 4, 7, 7, 9, 11, 15 中查第一个
的位置。每一步只判定一个中点
,它要么并入红段、要么并入蓝段,待定区间每轮约减半。target 取数组中不存在的值(如 8)时,
给出的是该值应插入的位置。
定义 1.1(本模板的循环不变量) 整个循环自始至终维持三条恒真命题:下标 的元素都小于 target;下标 的元素都不小于 target;答案即第一个不小于 target 的下标落在 内。
三条命题在 、 时平凡成立,因为红蓝段都是空集;每一步无论走哪个分支都不破坏它们;当 时待定段为空,答案被夹到唯一的 。初始成立、每步保持、终止时可读出答案,这三段构成它正确性的全部论证。
终止条件写作 也出自同一处:开区间 里的待定元素个数是 ,只要它不小于 1 就还有元素要判,一旦 就该停。又因为 严格落在 内,即 ,每轮 或 必向中间挪至少一格,区间真正在缩小,不会死循环。§3 与闭区间写法对比时,这一点是关键。
2 · 四种边界查询的统一模板
实战里二分常不是找等于 target,而是找一个边界:第一个
、第一个
、最后一个
、最后一个
。它们看似要背四份代码,在 §1 的开区间模板下却只有两个可变之处。其一是红条件,即把
并入红段的判定,只有 a[c] < target 与 a[c] <= target 两种,它决定分界线落在 target 的哪一侧。其二是返回端,即循环结束于
时取第一个蓝
还是最后一个红
。两条判定乘两个返回端得到四种查询,循环主体一字不改。
| 查询 | 含义 | 红条件(取 l = c) |
返回端 | 不存在时 |
|---|---|---|---|---|
| 第一个 ≥ | lower_bound |
a[c] < target |
||
| 第一个 > | upper_bound |
a[c] <= target |
||
| 最后一个 ≤ | 无标准名 | a[c] <= target |
||
| 最后一个 < | 无标准名 | a[c] < target |
四者两两对偶。同一条红条件下,
与
恰好是分界线两侧紧邻的一对:「第一个 ≥」与「最后一个 <」共用红条件 a[c] < target,两者答案相邻,即
;「第一个 >」与「最后一个 ≤」共用 a[c] <= target。记住其中一个(通常是 lower_bound),另外三个都能当场推出来。
这张表与下面几条派生式经穷举对拍验证过:长度 0 到 6、值域 0 到 4 的全部非降序列,配上 到 5 的全部 target,四种查询共 12936 例与线性扫描的结果逐一相符;派生恒等式在同一组数据的 3234 组上也无一例外。
注 · 精确等于不必另写一份:先令
为 lower_bound(target) 的返回值,再判一次 r < n && a[r] == target,成立则
是 target 首次出现的位置。区间计数同理,等于 target 的个数是 upper_bound(target) 减 lower_bound(target)。边界右移一格还能继续化简,「最后一个
target」等于 lower_bound(target) 减 1,整数场景下「最后一个
target」等于 lower_bound(target + 1) 减 1。工程上因此常只实现 lower_bound 一个原语,其余皆由它派生,std::lower_bound 就是这样设计的。
3 · 三种区间写法及其边界陷阱
同一个 lower_bound,区间的开闭约定可以有三套,各自的循环条件、
的归属、收缩方式都不同,这正是二分容易写错的根源。三种写法是:开区间
两端都不含、左闭右开
含左不含右、闭区间
两端都含。它们答案必然一致,中间机制却各异。
区别全在待定区间用什么端点表示。开区间最对称, 永远不取端点,收缩时直接写 或 ;半开与闭区间因为端点是含的,已判过的 必须排除在外,才出现 与 。本页统一用开区间,图的就是不必记这些加减:条件永远是 ,收缩永远是 或 。
警示 · 整型溢出。教科书爱写
。当
超过 int 上限 2147483647(约 21.47 亿)时会溢出成负数,a[c] 直接越界。1986 年 Bentley 的《Programming Pearls》给出的即是这个写法,2006 年 Joshua Bloch 撰文指出 JDK 的 Arrays.binarySearch 同样中招,潜伏了近二十年。稳妥写法是
,因
必定在范围内故永不溢出。JS 的 Number 没有这个上限,但在 C、C++ 与 Java 里这是必须的。
警示 · 死循环。当区间只剩两个元素、 算出来恰好等于 时,若写成 而非闭区间与半开区间里的 , 原地不动,循环永远停不下来。开区间能规避它,是因为终止条件是 且 严格落在 内,每轮 或 必向中间挪至少一格。半开与闭区间则依赖 来保证前进,漏写就死锁。循环条件与收缩量必须配套,不能随意搭配。
建议 · 三套都对,但每个人固定用一套最不易错。开区间的优点是边界规则最少、四种查询只换 §2 说的两处;左闭右开与 STL 及多数题解一致,迁移成本低。无论哪套,自检的问题都一样:循环不变量是什么、每轮区间是否真的缩小、终止时答案落在哪个端点。
4 · 单调谓词上的二分答案
二分查找的本质不是在数组里找数,而是在一段单调的真值序列上找那个分界点。数组有序只是制造单调性的一种方式:只要存在一个判定 ,它随 增大只会从假变真或从真变假一次,就能直接在答案区间上二分,不必先有排好序的数组。
经典例子是整数平方根:求最大的 使 。判定 显然单调, 越大越难成立,真值序列形如若干个真接若干个假。于是「最后一个使 为真的 」就是答案,正是 §2 表里的「最后一个 ≤」。
值得注意的是,整个过程从不真的开一个长度 的数组, 是用到时当场算的,区间 只是逻辑上的搜索空间。
识别一个问题能否二分答案只看单调性。把问题改写成判定句「 是否可行」,若可行性随 单调,即可行的都不超过某阈值或都不低于某阈值,就能二分那个阈值。把求最优值转成判定某个值是否可行,是这类题的统一套路,最大化最小值与最小化最大值(如把序列分成 段使最大段和最小)、限速可行性、值域上的第 小都是它的变体。
复杂度的维度也随之变了。数组二分是 , 是元素个数;二分答案是 , 是答案的取值范围、 是单次判定的代价,搜索的是答案范围而非数据规模。上例中 、判定是一次乘法即 ,故总代价 ;若判定本身要扫一遍数据,总复杂度就是 。
无论是有序数组里的 lower_bound 还是本节抽象的
,用的都是同一套开区间加循环不变量,差别只在红蓝由谁定义:数组里由 a[c] 与 target 比较给出,二分答案里由谓词
直接给出。
注 · 二分查找也属于双指针里左右对撞的一类, 与 从两端往中间夹,区别在于本页用开区间加循环不变量的视角统一了所有变体。二分答案的单调谓词思路,则是动态规划与贪心优化题里把最优化转成可行性判定的常用手段。
相关链接
-
704. 二分查找
LeetCode
最基础的精确查找,本系列将其归约为 lower_bound 后再判一次
a[r] == target。 - 34. 在排序数组中查找元素的第一个和最后一个位置 LeetCode 左边界取 lower_bound(target),右边界取 lower_bound(target+1) − 1,四种查询的直接应用。
- 69. x 的平方根 LeetCode 二分答案入门题,本系列末节的演示取自此处。
-
Nearly All Binary Searches Are Broken
Joshua Bloch
2006 年那篇博文:指出 JDK 的 Arrays.binarySearch 与几乎所有教科书实现都带
(l + r) / 2的整型溢出缺陷。 - std::lower_bound / upper_bound cppreference C++ 标准库对「第一个 ≥」与「第一个 >」的命名与契约,本系列四种查询与之一一对应。