算法与数据结构 / 开区间与循环不变量 待审核
binary-search · 循环不变量

开区间与循环不变量

二分查找的思想一句话能说清:在有序序列里每次将范围减半,O(logn)O(\log n) 步定位目标。真正写对它却出了名的难,边界取 cc 还是 c±1c \pm 1、循环条件用 < 还是 <=、找不到时返回什么、要找的是等于还是第一个大于等于,稍有不慎就 off-by-one 或死循环。本页不背模板,而是用一个循环不变量把所有变体统一起来。

1 · 开区间与红蓝染色

二分难写,难在边界。与其逐条记忆什么时候 +1、什么时候 <=,不如抓住一个贯穿始终的命题:用两个指针把数组划成三段,红段(确定属于一类)、蓝段(确定属于另一类),以及夹在中间的待定段。只要每一轮后红蓝段的含义不变、待定段严格缩小,循环结束时答案必然落在红蓝交界处。

本页采用开区间写法,取 l=1l = -1r=nr = n,始终维持 (l,r)(l, r) 是唯一的待定区间:下标 l\le l 的全染红、r\ge r 的全染蓝。初始时整个数组都待定,两端的虚拟哨兵 1-1nn 天然满足红蓝条件,因为空集上任何命题都成立。本节以最常用的 lower_bound 为例,即找第一个 \ge target 的下标,于是红是「小于 target」、蓝是「大于等于 target」。

图 1-1 · 在 1, 3, 4, 7, 7, 9, 11, 15 中查第一个 7\ge 7 的位置。每一步只判定一个中点 cc,它要么并入红段、要么并入蓝段,待定区间每轮约减半。target 取数组中不存在的值(如 8)时,rr 给出的是该值应插入的位置。

定义 1.1(本模板的循环不变量) 整个循环自始至终维持三条恒真命题:下标 l\le l 的元素都小于 target;下标 r\ge r 的元素都不小于 target;答案即第一个不小于 target 的下标落在 (l,r](l, r] 内。

三条命题在 l=1l = -1r=nr = n 时平凡成立,因为红蓝段都是空集;每一步无论走哪个分支都不破坏它们;当 l+1=rl + 1 = r 时待定段为空,答案被夹到唯一的 rr。初始成立、每步保持、终止时可读出答案,这三段构成它正确性的全部论证。

终止条件写作 l+1<rl + 1 < r 也出自同一处:开区间 (l,r)(l, r) 里的待定元素个数是 rl1r - l - 1,只要它不小于 1 就还有元素要判,一旦 l+1=rl + 1 = r 就该停。又因为 cc 严格落在 (l,r)(l, r) 内,即 l<c<rl < c < r,每轮 llrr 必向中间挪至少一格,区间真正在缩小,不会死循环。§3 与闭区间写法对比时,这一点是关键。

2 · 四种边界查询的统一模板

实战里二分常不是找等于 target,而是找一个边界:第一个 \ge、第一个 >>、最后一个 \le、最后一个 <<。它们看似要背四份代码,在 §1 的开区间模板下却只有两个可变之处。其一是红条件,即把 cc 并入红段的判定,只有 a[c] < targeta[c] <= target 两种,它决定分界线落在 target 的哪一侧。其二是返回端,即循环结束于 l+1=rl + 1 = r 时取第一个蓝 rr 还是最后一个红 ll。两条判定乘两个返回端得到四种查询,循环主体一字不改。

图 2-1 · 切换查询类型时红条件与返回端的联动。循环主体在四种查询下完全相同,只有这两处随之改变。
查询 含义 红条件(取 l = c 返回端 不存在时
第一个 ≥ lower_bound a[c] < target rr r=nr = n
第一个 > upper_bound a[c] <= target rr r=nr = n
最后一个 ≤ 无标准名 a[c] <= target ll l=1l = -1
最后一个 < 无标准名 a[c] < target ll l=1l = -1

四者两两对偶。同一条红条件下,rrll 恰好是分界线两侧紧邻的一对:「第一个 ≥」与「最后一个 <」共用红条件 a[c] < target,两者答案相邻,即 r=l+1r = l + 1;「第一个 >」与「最后一个 ≤」共用 a[c] <= target。记住其中一个(通常是 lower_bound),另外三个都能当场推出来。

这张表与下面几条派生式经穷举对拍验证过:长度 0 到 6、值域 0 到 4 的全部非降序列,配上 1-1 到 5 的全部 target,四种查询共 12936 例与线性扫描的结果逐一相符;派生恒等式在同一组数据的 3234 组上也无一例外。

注 · 精确等于不必另写一份:先令 rrlower_bound(target) 的返回值,再判一次 r < n && a[r] == target,成立则 rr 是 target 首次出现的位置。区间计数同理,等于 target 的个数是 upper_bound(target)lower_bound(target)。边界右移一格还能继续化简,「最后一个 << target」等于 lower_bound(target) 减 1,整数场景下「最后一个 \le target」等于 lower_bound(target + 1) 减 1。工程上因此常只实现 lower_bound 一个原语,其余皆由它派生,std::lower_bound 就是这样设计的。

3 · 三种区间写法及其边界陷阱

同一个 lower_bound,区间的开闭约定可以有三套,各自的循环条件、cc 的归属、收缩方式都不同,这正是二分容易写错的根源。三种写法是:开区间 (l,r)(l, r) 两端都不含、左闭右开 [l,r)[l, r) 含左不含右、闭区间 [l,r][l, r] 两端都含。它们答案必然一致,中间机制却各异。

图 3-1 · 同一输入下三种区间写法的并排执行。可对照每轮的循环条件、中点归属与收缩幅度。
图 3-2 · 三套模板的代码对照,差异集中在循环条件与收缩语句两处。

区别全在待定区间用什么端点表示。开区间最对称,cc 永远不取端点,收缩时直接写 l=cl = cr=cr = c;半开与闭区间因为端点是含的,已判过的 cc 必须排除在外,才出现 c+1c + 1c1c - 1。本页统一用开区间,图的就是不必记这些加减:条件永远是 l+1<rl + 1 < r,收缩永远是 l=cl = cr=cr = c

警示 · 整型溢出。教科书爱写 c=(l+r)/2c = (l + r) / 2。当 l+rl + r 超过 int 上限 2147483647(约 21.47 亿)时会溢出成负数,a[c] 直接越界。1986 年 Bentley 的《Programming Pearls》给出的即是这个写法,2006 年 Joshua Bloch 撰文指出 JDK 的 Arrays.binarySearch 同样中招,潜伏了近二十年。稳妥写法是 c=l+(rl)/2c = l + (r - l) / 2,因 rlr - l 必定在范围内故永不溢出。JS 的 Number 没有这个上限,但在 C、C++ 与 Java 里这是必须的。

警示 · 死循环。当区间只剩两个元素、cc 算出来恰好等于 ll 时,若写成 l=cl = c 而非闭区间与半开区间里的 l=c+1l = c + 1ll 原地不动,循环永远停不下来。开区间能规避它,是因为终止条件是 l+1<rl + 1 < rcc 严格落在 (l,r)(l, r) 内,每轮 llrr 必向中间挪至少一格。半开与闭区间则依赖 c±1c \pm 1 来保证前进,漏写就死锁。循环条件与收缩量必须配套,不能随意搭配。

建议 · 三套都对,但每个人固定用一套最不易错。开区间的优点是边界规则最少、四种查询只换 §2 说的两处;左闭右开与 STL 及多数题解一致,迁移成本低。无论哪套,自检的问题都一样:循环不变量是什么、每轮区间是否真的缩小、终止时答案落在哪个端点。

4 · 单调谓词上的二分答案

二分查找的本质不是在数组里找数,而是在一段单调的真值序列上找那个分界点。数组有序只是制造单调性的一种方式:只要存在一个判定 f(x)f(x),它随 xx 增大只会从假变真或从真变假一次,就能直接在答案区间上二分,不必先有排好序的数组。

经典例子是整数平方根:求最大的 xx 使 x2Nx^2 \le N。判定 f(x)=(x2N)f(x) = (x^2 \le N) 显然单调,xx 越大越难成立,真值序列形如若干个真接若干个假。于是「最后一个使 ff 为真的 xx」就是答案,正是 §2 表里的「最后一个 ≤」。

图 4-1 · 候选答案 xx 摊在纸带上,格内是 x2x^2,红为 f(x)f(x) 成立、蓝为不成立。N=12N = 12 时候选区间是 [0,12][0, 12],答案是最后一个红 x=3x = 3,因 x=3x = 3x2=912x^2 = 9 \le 12x=4x = 4x2=16>12x^2 = 16 > 12

值得注意的是,整个过程从不真的开一个长度 NN 的数组,x2x^2 是用到时当场算的,区间 [0,N][0, N] 只是逻辑上的搜索空间。

识别一个问题能否二分答案只看单调性。把问题改写成判定句「xx 是否可行」,若可行性随 xx 单调,即可行的都不超过某阈值或都不低于某阈值,就能二分那个阈值。把求最优值转成判定某个值是否可行,是这类题的统一套路,最大化最小值与最小化最大值(如把序列分成 kk 段使最大段和最小)、限速可行性、值域上的第 kk 小都是它的变体。

复杂度的维度也随之变了。数组二分是 O(logn)O(\log n)nn 是元素个数;二分答案是 O(logVT)O(\log V \cdot T)VV 是答案的取值范围、TT 是单次判定的代价,搜索的是答案范围而非数据规模。上例中 V=NV = N、判定是一次乘法即 T=O(1)T = O(1),故总代价 O(logN)O(\log N);若判定本身要扫一遍数据,总复杂度就是 O(nlogV)O(n \log V)

无论是有序数组里的 lower_bound 还是本节抽象的 f(x)f(x),用的都是同一套开区间加循环不变量,差别只在红蓝由谁定义:数组里由 a[c] 与 target 比较给出,二分答案里由谓词 f(c)f(c) 直接给出。

注 · 二分查找也属于双指针里左右对撞的一类,llrr 从两端往中间夹,区别在于本页用开区间加循环不变量的视角统一了所有变体。二分答案的单调谓词思路,则是动态规划与贪心优化题里把最优化转成可行性判定的常用手段。

相关链接