二分的技巧 | 你的二分为什么死循环了

最近在答疑坊做志愿者,很多大一小朋友来问我二分怎么写。据我观察,类似的问题已经困扰过我和我的无数同学们了。为了今后节省体力、保护嗓子,我决定写一篇博客讲一下二分的技巧,这样下次我可以直接把博客转给问问题的人(

朴素的二分相信大家都很熟悉,无非是每次循环取区间中点mid,再判断答案是在mid左边还是mid右边,递归查找,从而在\(O(\log 初始区间长度)\)复杂度内找到答案。

但是在实现二分的时候,很多同学发现:自己的二分死循环了 / 自己搞不清楚自己的逻辑了。接下来我们用一道例题说明一下。

例题1:数组分段

已知一个长度为\(n\)的数组\(a\),把它切分成\(m\)个连续的段,使得每段之和的最大值最小。求这个最小值。
数据范围:\(1 \le m \le n \le 10^5, 0 \le a_i \le 10^9\)

二分的思路很简单:二分答案\(mid\),定义一个min_segments(mid)函数,用来求每段和不超过\(mid\)时,最少划分几段。划分的方法是:从左往右遍历整个数组,如果当前段能放得下\(a_i\)(加入\(a_i\)之后不会让当前段的和超出\(mid\)),则把\(a_i\)加到当前段中,否则新开一段,把\(a_i\)放进去。然后根据划分的段数,判断答案在mid左边还是右边。

一个bug,改编自我正在debug的代码

小明看完题,写出了这样一份代码:

long long l = 0, r = 1e14, mid;
while (l < r) {
    mid = (l + r) / 2;
    if (min_segments(mid) >= m) 
        l = mid;
    else
        r = mid;
}
cout << l << endl;

运行之后,他惊奇地发现:自己的二分代码死循环了。大家不妨先暂停阅读,思考一下小明的bug出在哪里?

答案揭晓

问题一:死循环

先不考虑别的问题,只考虑二分的最后一步—— \(r = l + 1\) 的情况。此时\(mid = (l + r) / 2 = l\)。假如此时发现 \(\mathrm{min\_segments}(mid) \ge m\),那么代码会执行到 \(l = mid\) 这一步,然后继续循环——等等,这\(l\)不就没改变嘛!怪不得死循环了!

问题二:逻辑问题

其实小明还有一个问题,就是在 if (min_segments(mid) >= m) 这一句。不妨思考一下,如果\(\mathrm{min\_segments}(mid) \ge m\)不成立(也就是说如果\(\mathrm{min\_segments}(mid) < m\)),意味着什么呢?意味着我们可以把数组分成小于\(m\)段,每段之和不超过\(mid\),所以答案大于等于\(mid\),看上去没有错。那么如果\(\mathrm{min\_segments}(mid) \ge m\)成立呢?它什么也不能说明!如果\(\mathrm{min\_segments}(mid) = m\),那么\(mid\)固然可能是答案,可是答案可不可能比\(mid\)还小?完全有可能,比如\(mid-1\),划分出的这\(m\)段完全可能每段之和都不超过\(mid-1\)。当然,答案也可能比\(mid\)还要大。所以这个不等式不能用来判断答案是在\(mid\)左边还是\(mid\)右边。

很多同学在写二分时都踩过上面这两个坑。一些人为了避免逻辑错误,会分“大于m”、“等于m”、“小于m”三种情况讨论,但是这样并没有必要,而且在别的二分题目中很可能无法分出三种情况、只能分出两种。接下来我来讲讲二分到底怎么写,才能尽量不出锅。

所以二分到底怎么写?

第一步:判断\(mid\)是否可行

我见过的所有二分问题都可以只分两种情况讨论

  1. \(mid\)可能是答案;
  2. \(mid\)不可能是答案。

例如这道题中,如果\(\mathrm{min\_segments}(mid) \le m\),则\(mid\)可能是答案;如果\(\mathrm{min\_segments}(mid) > m\)(也就是说不可能分\(m\)段使得每段和不超过\(mid\)),则\(mid\)不可能是答案

第二步:判断答案在\(mid\)哪一侧。

在这道题里,如果\(mid\)可能是答案,则实际的答案\(\le mid\);如果\(mid\)不可能是答案,则实际的答案\(> mid\)。(而在其他题中,情况也可能是:如果\(mid\)可能是答案,则实际的答案\(\ge mid\);如果\(mid\)不可能是答案,则实际的答案\(< mid\)。)

于是我们的代码就改成了:

long long l = 0, r = 1e14, mid;
while (l < r) {
    mid = (l + r) / 2;
    if (min_segments(mid) > m) 
        l = mid + 1;
    else
        r = mid;
}
cout << l << endl;

注意l = mid + 1一句,意味着这种情况中,实际答案不仅在\(mid\)右边,还不可能是\(mid\),也就是严格大于\(mid\)。这句代码让答案可能出现的区间\([l, r]\)变成了\([mid + 1, r]\)

第三步:考虑\((l + r) / 2\)的取整问题

最后一步也是关键的一步。虽然在这道题中,mid = (l + r) / 2是对的,但是有的题中这样却可能导致死循环。例如,假如对另一道题,我们写出了这样的代码:

long long l = 0, r = 1e14, mid;
while (l < r) {
    mid = (l + r) / 2;
    if (一些条件) 
        l = mid;
    else
        r = mid - 1;
}
cout << l << endl;

那么,仍然考虑\(r = l + 1\)的情况,此时\(mid = l\)。那么如果if中的“一些条件”成立,程序会执行l = mid——得,又来了,\(l\)没有改变,死循环了。

对于这种情况,我们不应该写mid = (l + r) / 2,而应该写mid = (l + r + 1) / 2,这句的效果就是\(mid = \lceil (l + r) / 2 \rceil\),即向上取整。无论是向下取整还是向上取整,都不会影响二分复杂度的正确性,但是这一个“+1”之差很可能决定你是否死循环。

例如下面这道题,就可以运用这个技巧:

例题2:x的前驱

已知一个长度为\(n\)的有序数组\(a\),每次询问输入一个\(x\),输出\(a\)中最后一个严格小于\(x\)数的下标(下标从1开始,如果没有比\(x\)小的数则输出\(0\))。
数据范围:\(1 \le n \le 10^5, 0 \le a_i, x \le 10^9\)

正确的代码:

int l = 0, r = n;
while (l < r) {
    mid = (l + r + 1) / 2;
    if (a[mid] < x) 
        l = mid;
    else
        r = mid - 1;
}
cout << l << endl;
posted @ 2020-11-26 16:46  胡小兔  阅读(571)  评论(7编辑  收藏