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

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

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

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

例题1:数组分段

已知一个长度为n的数组a,把它切分成m个连续的段,使得每段之和的最大值最小。求这个最小值。
数据范围:1mn105,0ai109

二分的思路很简单:二分答案mid,定义一个min_segments(mid)函数,用来求每段和不超过mid时,最少划分几段。划分的方法是:从左往右遍历整个数组,如果当前段能放得下ai(加入ai之后不会让当前段的和超出mid),则把ai加到当前段中,否则新开一段,把ai放进去。然后根据划分的段数,判断答案在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。假如此时发现 min_segments(mid)m,那么代码会执行到 l=mid 这一步,然后继续循环——等等,这l不就没改变嘛!怪不得死循环了!

问题二:逻辑问题

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

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

所以二分到底怎么写?

第一步:判断mid是否可行

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

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

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

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

在这道题里,如果mid可能是答案,则实际的答案mid;如果mid不可能是答案,则实际的答案>mid。(而在其他题中,情况也可能是:如果mid可能是答案,则实际的答案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=(l+r)/2,即向上取整。无论是向下取整还是向上取整,都不会影响二分复杂度的正确性,但是这一个“+1”之差很可能决定你是否死循环。

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

例题2:x的前驱

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

正确的代码:

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 @   胡小兔  阅读(3399)  评论(8编辑  收藏  举报
编辑推荐:
· 从 HTTP 原因短语缺失研究 HTTP/2 和 HTTP/3 的设计差异
· AI与.NET技术实操系列:向量存储与相似性搜索在 .NET 中的实现
· 基于Microsoft.Extensions.AI核心库实现RAG应用
· Linux系列:如何用heaptrack跟踪.NET程序的非托管内存泄露
· 开发者必知的日志记录最佳实践
阅读排行:
· winform 绘制太阳,地球,月球 运作规律
· AI与.NET技术实操系列(五):向量存储与相似性搜索在 .NET 中的实现
· 超详细:普通电脑也行Windows部署deepseek R1训练数据并当服务器共享给他人
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理
· 上周热点回顾(3.3-3.9)
历史上的今天:
2017-11-26 POJ 3348 Cows | 凸包——童年的回忆(误)
2017-11-26 ZOJ 1081 Within(点是否在多边形内)| 计算几何
点击右上角即可分享
微信分享提示