二分的技巧 | 你的二分为什么死循环了
最近在答疑坊做志愿者,很多大一小朋友来问我二分怎么写。据我观察,类似的问题已经困扰过我和我的无数同学们了。为了今后节省体力、保护嗓子,我决定写一篇博客讲一下二分的技巧,这样下次我可以直接把博客转给问问题的人(
朴素的二分相信大家都很熟悉,无非是每次循环取区间中点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\)是否可行
我见过的所有二分问题都可以只分两种情况讨论:
- \(mid\)可能是答案;
- \(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;
博客地址:http://rabbithu.cnblogs.com