【解雨臣的算法课】第二课:二分答案——找对位置,胜过盲目使劲

【解雨臣的算法课】第二课:二分答案——找对位置,胜过盲目使劲

“在墓里,最忌讳的就是瞎摸。每一寸墙都摸一遍?等你摸到机关,氧气早没了。”
——解雨臣


一、上节课回顾

上一课我们讲了复杂度分析。为什么?

因为王胖子用 O(n!) 的算法去开青铜门,门没开,人先没了

上节课的作业还有人记得吗?那道区间和等于 k 的题,不同数据范围要用不同算法:

  • n \le 1000O(n^2) 暴力枚举
  • n \le 10^5:前缀和 + 哈希表 O(n)

这就是看到数据范围,选择合适算法

今天我们来讲一个更骚的操作:既然直接找找不到,那我就猜,然后验证。


二、故事引入:解雨臣找机关

有一次下斗,我进了一间墓室。

墓室很大,四周全是壁画。根据经验,这间墓室一定有机关,打开了才能进下一层。

但是机关在哪?

胖子说:“摸!每块砖都摸一遍!”

我拦住了他。

因为这间墓室有 10^5 块砖。

一块砖摸一秒钟,摸完要 10^5 秒 ≈ 27.7 小时。等摸完,氧气早就没了

我问黑瞎子:“你有什么办法?”

黑瞎子说:“机关的高度肯定在某个范围内。咱们猜一个高度,然后验证那个高度有没有机关。

我说:“猜错了呢?”

黑瞎子说:“猜错了就调整。高了往下猜,低了往上猜。

我说:“这不就是…”

黑瞎子说:“二分。


三、什么是二分答案?

二分答案,就是对答案进行二分查找

等等,答案还能二分?

普通二分 vs 二分答案

类型 普通二分 二分答案
二分对象 数组下标 答案的值
查找内容 某个数在不在 可行解的最值
前提 数组有序 答案具有单调性
经典问题 二分查找 最小值最大、最大值最小

核心思想:如果我直接求最优解很难,但是给我一个解,我能判断它行不行——那我就二分这个解。


四、二分答案的套路模板

// 解雨臣牌二分答案模板
bool ck(int x) {
    // 验证 x 是否可行
    // 返回 true 表示可行,false 表示不可行
}

int sol() {
    int l = 最小值;      // 答案可能的最小值
    int r = 最大值;      // 答案可能的最大值
    int ans = l;         // 记录答案
    
    while (l <= r) {
        int mid = (l + r) / 2;
        if (ck(mid)) {
            ans = mid;   // mid 可行,记录答案
            l = mid + 1; // 尝试更大的(求最大值时)
            // 如果是求最小值,则 r = mid - 1
        } else {
            r = mid - 1; // mid 不可行,缩小范围
        }
    }
    return ans;
}

关键:一定要想清楚——

  • 我二分的是什么?
  • 可行解的定义是什么?
  • 单调性体现在哪?

五、经典例题:解雨臣切木头

题目背景

我从墓里带出来一批木头,准备切成等长的小段做机关。

n 根木头,长度分别为 a_1, a_2, ..., a_n

我要把它们切成至少 k,每段长度相等(可以是小数?不,木头只能切整数长度)。

求每段最长能切多长。

题目重述

  • 输入:n, k,数组 a
  • 输出:最大长度 L,使得能切出至少 k 段长度为 L 的木头

思路分析

如果我会魔法,一眼看出 L 是多少,那就直接输出。

但我是解雨臣,不会魔法。

不过我会验证

给我一个 L,我能算出最多能切多少段:
$$\text{段数} = \sum_{i=1}^n \lfloor \frac{a_i}{L} \rfloor$$

如果段数 \ge k,说明 L 可行;
如果段数 < k,说明 L 太大,切不出那么多段。

单调性

  • L 越小,能切的段数越多
  • L 越大,能切的段数越少
  • 我们要找的是满足段数 \ge k 的最大 L

代码实现

#include <bits/stdc++.h>
using namespace std;

const int N = 1e5 + 10;
int n, k;
int a[N];

bool ck(int x) {
    if (x == 0) {
        return true;  // 长度为0肯定可以,但实际不会取0
    }
    long long cnt = 0;
    for (int i = 1; i <= n; i++) {
        cnt += a[i] / x;
        if (cnt >= k) {
            return true;  // 提前退出
        }
    }
    return cnt >= k;
}

int main() {
    cin >> n >> k;
    int mx = 0;
    for (int i = 1; i <= n; i++) {
        cin >> a[i];
        mx = max(mx, a[i]);
    }
    
    int l = 1, r = mx, ans = 0;
    while (l <= r) {
        int mid = (l + r) / 2;
        if (ck(mid)) {
            ans = mid;
            l = mid + 1;  // 找更大的
        } else {
            r = mid - 1;
        }
    }
    
    cout << ans << endl;
    return 0;
}

复杂度分析

  • 二分次数:O(\log(\max a_i))
  • 每次 ck:O(n)
  • 总复杂度:O(n \log(\max a_i))

对于 n = 10^5\max a_i = 10^9,大约 10^5 \times 30 = 3 \times 10^6 次运算,稳稳通过


六、经典例题2:解雨臣跳石头

题目背景

我要过一条河,河里有 n 块石头,位置分别是 $p_1 < p_2 < … < p_n$(坐标)。

我可以最多搬走 m 块石头(让它们消失)。

起点是 0,终点是 L

我要跳着石头过河,每次只能跳到石头上或者终点。

问:搬走 m 块石头后,最短跳跃距离的最大值是多少?

思路分析

又是最大值最小问题。

直接求:不知道搬哪 m 块。

用二分答案:

给我一个最短跳跃距离 d,我能判断:能否通过搬走不超过 m 块石头,使得所有相邻落脚点的距离都 \ge d

验证方法(贪心):

  1. 从起点开始跳
  2. 如果下一块石头距离 < d,就搬走它
  3. 如果距离 \ge d,就跳上去
  4. 最后统计搬走了多少块,如果 \le m 则可行

单调性

  • d 越小,越容易满足(要搬的石头少)
  • d 越大,越难满足(要搬的石头多)
  • 我们要找的是满足条件的最大的 d

代码实现

#include <bits/stdc++.h>
using namespace std;

const int N = 1e5 + 10;
int L, n, m;
int p[N];

bool ck(int d) {
    int lst = 0;      // 上一次跳到的位置
    int cnt = 0;       // 搬走的石头数
    
    for (int i = 1; i <= n; i++) {
        if (p[i] - lst < d) {
            cnt++;     // 这块石头得搬走
        } else {
            lst = p[i]; // 跳上去
        }
    }
    // 最后还要检查从最后一块石头到终点
    if (L - lst < d) {
        return false; // 最后一段跳不过去
    }
    return cnt <= m;
}

int main() {
    cin >> L >> n >> m;
    for (int i = 1; i <= n; i++) {
        cin >> p[i];
    }
    
    int l = 1, r = L, ans = 0;
    while (l <= r) {
        int mid = (l + r) / 2;
        if (ck(mid)) {
            ans = mid;
            l = mid + 1;  // 找更大的
        } else {
            r = mid - 1;
        }
    }
    
    cout << ans << endl;
    return 0;
}

七、什么时候用二分答案?

解雨臣的经验总结:

适用条件

  1. 答案在一个固定范围内(知道 l 和 $r$)
  2. 容易验证一个答案是否可行(ck 函数好写)
  3. 答案具有单调性($x$ 可行则 <x>x 也可行)

常见题型

  • 最大值最小(如:最大距离的最小值)
  • 最小值最大(如:最小距离的最大值)
  • 满足条件的最值

经典问题

  • 切木头/切绳子(最大值最小)
  • 跳石头(最小值最大)
  • 进击的奶牛(最小值最大)
  • 月度开销(最大值最小)
  • 实数二分(求平方根、解方程)

八、解雨臣踩过的坑

坑1:边界条件

while (l < r) 还是 while (l <= r)?
l = mid 还是 l = mid + 1?

解家箴言:拿不准就写 while (l <= r) 配合 ans 记录,保命第一。

坑2:死循环

while (l < r) {
    int mid = (l + r) / 2;
    if (ck(mid)) {
        l = mid;
    } else {
        r = mid - 1;
    }
}
// 当 l = 1, r = 2 时,mid = 1,如果 ck(1) 为真,l 还是 1,死循环!

解家箴言:小心边界,不行就加1:

int mid = (l + r + 1) / 2;  // 向上取整,避免死循环

坑3:实数二分

while (r - l > eps) {
    double mid = (l + r) / 2;
    if (ck(mid)) {
        l = mid;
    } else {
        r = mid;
    }
}

解家箴言eps 通常取 1e-71e-9,别太小,否则死循环。

坑4:ck 函数写错

这是最常见的。ck 写错了,二分出来的答案也是错的。
解家箴言:先暴力验证小数据,ck 对不对。


九、课后习题(解家的考验)

基础题

  1. 切绳子:有 n 条绳子,长度分别为 L_i。要切成 k 条长度相等的绳子,求最长能切多长?(实数)

  2. 进击的奶牛:有 n 个牛棚,位置 x_i。要把 c 头牛放进牛棚,要求相邻牛的最小距离最大,求这个最大距离。

进阶题

  1. 月度开销:FJ 要把 n 个月的开销 a_i 分成 m 个连续的月份组,使得各组开销之和的最大值最小,求这个最小值。

挑战题(来自青铜门的考验)

  1. 解雨臣的机关:一个机关有 n 个齿轮排成一排,每个齿轮有半径 r_i。我要让相邻齿轮都啮合(即相邻半径之和 $\ge k$)。我可以把某些齿轮替换成任意半径,但最多替换 m 个。求所有相邻齿轮啮合时,最小半径的最大值是多少?

提示:二分答案 + 贪心验证,类似跳石头但更复杂。


十、下集预告

“二分答案就像下斗——你先猜机关在哪,然后伸手去摸。摸到了,就继续往前;摸不到,换个方向再猜。”
——解雨臣

下一课,我们将进入提高组的第一个数据结构前缀和与差分

为什么?
因为很多二分答案的 ck 函数里,需要快速求区间和。
而前缀和,就是最快的区间求和法。

下课。


(本系列课程由解雨臣独家赞助,信友队论坛首发。转载需注明出处,否则下次下斗不带你。)

6 个赞

:+1:

强强

:+1:

谢谢

?话说解雨臣是什么,这是啥新电影吗?

盗墓笔记的衍生角色
@HIM

(稻米狂喜)

稻米本米(来自无限城) :grinning_face:

问这位小花男的女的 :shaking_face:

1 个赞

你猜?