zoukankan      html  css  js  c++  java
  • 二分的技巧 | 你的二分为什么死循环了

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

    朴素的二分相信大家都很熟悉,无非是每次循环取区间中点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 ceil),即向上取整。无论是向下取整还是向上取整,都不会影响二分复杂度的正确性,但是这一个“+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;
    
  • 相关阅读:
    CentOS 6.5 下源码搭建LAMP环境
    CentOS 6.5 下MySql主从、主主配置
    js基础梳理内存空间
    js基础梳理如何理解作用域和作用域链?
    js基础梳理究竟什么是变量对象,什么是活动对象?
    js基础梳理究竟什么是执行上下文栈(执行栈),执行上下文(可执行代码)?
    python作用域解析
    expect简单自动交互用于密码、命令输入
    使用pyinstaller打包多个py文件为一个EXE文件
    python*args和**kwargs作用和区别
  • 原文地址:https://www.cnblogs.com/RabbitHu/p/binary.html
Copyright © 2011-2022 走看看