ACM 一个月学习计划 · DAY 23

Day 23 · 二分答案:把「求解」变成「判定」

看到「最大值最小」和「最小值最大」就条件反射地想二分 —— 这是今天要练成的肌肉记忆

今日目标:会判断一道题能不能二分答案,会写 check 函数,会选对 mid 的取整方式
预计用时:约 3 小时(含做题)  难度:普及- ~ 普及/提高-
题目来源:洛谷 P1873 / P2440 / P1182 / P3853
← Day 22 · 二分查找:在有序里快速定位

⏱今日时间安排

0 - 25 min想通「二分答案」到底在二什么(第 1 节)
25 - 60 min啃两套模板 + 死循环分析(第 2 节)—— 今天的核心
60 - 90 mincheck 函数怎么写 + 上下界怎么定(第 3、4 节)
90 - 160 min做 4 道题,每题限时 20 分钟
160 - 180 min对照题解补齐 + 做自测 + 抄速查卡
💡 和昨天的衔接

昨天(Day 22)你学的是二分查找:在一个已经排好序的数组里,快速找某个值的位置。 判断依据是「a[mid] 和 x 谁大」。

今天要往前跨一大步:数组不见了,我们改成在「答案可能的取值范围」里二分, 判断依据是「如果答案是 mid,这件事办得到吗?」。这个判断函数就是传说中的 check()。

二分查找是「在数据里二分」,二分答案是「在答案里二分」—— 内核完全一样,都是「每次砍掉一半」。

1二分答案到底在「二」什么

先讲个生活里的例子

你心里想了一个 1 到 100 之间的整数,我猜。我每猜一个数,你只回答「大了」或「小了」。

我绝不会从 1 开始一个一个试,我会先猜 50。你说「小了」,我立刻知道答案在 51~100,一口气砍掉一半; 再猜 75……最多 7 次就猜中。

二分答案就是把这个游戏搬到程序里,只不过「大了 / 小了」换成了 check 函数的 true / false。

🎯 一句话说清二分答案

把「求最优解」这个大难题,变成「判断某个数可行不可行」这个小问题, 然后用二分把答案的取值范围一次次砍半。

关键前提:单调性

猜数字能玩,是因为有单调性:答「小了」就意味着「比它小的全都不用看了」。

二分答案也一样,必须满足:答案 x 越大(或越小),check(x) 越容易(或越难)成立。 只有这样,check 的结果才会是「一整段 true + 一整段 false」这种形状,二分才有意义。

用一个具体例子感受一下:P1873 砍树

树高分别是 20、15、10、17,要砍到至少 7 米木材。锯片高度 H 设多少最好?

显然 H 定得越高,被锯下来的部分越少。我们列一下:

锯片高度 H砍到的木材够 7 米吗?
020+15+10+17 = 62✅ 够
1010+5+0+7 = 22✅ 够
155+0+0+2 = 7✅ 刚好够
164+0+0+1 = 5❌ 不够
200❌ 不够

看到了吗?「够」永远排在前面,「不够」永远排在后面,中间只有一个分界点。 这个分界点左边的最后一个「够」,就是我们要的答案(H = 15)。

⚠️ 注意单调的方向

在砍树这题里:H 越大 → 越难满足(check 结果从 true 变成 false)。
有些题是反过来的:x 越大 → 越容易满足(例如 P1182 的「每段和不超过 x」,x 越大越好分)。

做题第一步永远是:先在草稿纸上想清楚单调方向,再决定二分怎么写。方向搞反,答案必错。

二分查找 vs 二分答案,对照着看

二分查找(昨天)二分答案(今天)
二分的对象数组的下标范围 [0, n-1]答案的取值范围 [下界, 上界]
判断依据a[mid] 和 x 比大小check(mid) 返回 true / false
要预先有什么数组有序答案单调(check 的形状是 true…true…false…false)
复杂度O(log n)O(n log 值域)(check 通常是 O(n))
典型问法「这个数在下标几」「最大能到多少 / 最小能到多少」
💡 什么题一看就要想二分答案

题目里出现下面这些字眼,八成就是二分答案:

· 「最大值最小」/「最小值最大」
· 「最短跳跃距离的最大值」/「每段和的最大值最小」
· 「求最大的高度 H,使得……」/「求最小的长度 l,使得……」

共同特征:答案是一个数,而且这个数「行不行」很好判断,但直接算出来很难。

2两套模板 + 死循环的根源(今天最重要的一节)

这一节请慢一点看。二分答案写挂,90% 都是因为模板用混了或者 mid 取整取反了。

先分清题目问的是哪一类

类型题目的问法check(x) 的单调方向我们要找
A 类
最大化
「最短距离的最大值」
「能架多高」
「能切多长」
x 越大越难满足
true…true…true…false…false
最后一个
true
B 类
最小化
「每段和的最大值最小」
「空旷指数的最小值」
x 越大越容易满足
false…false…true…true…true
第一个
true

模板 A:求「最大的可行值」(A 类题用)

思路:只要 mid 可行,就把它记到 ans 里,然后壮着胆子往更大的方向试。

int l = 下界, r = 上界;
int ans = -1;                        // 记不住答案时的兜底值
while(l <= r){
    int mid = l + (r - l) / 2;       // 下取整(这种写法取哪种都行)
    if(check(mid)){                  // mid 可行 → 记下来,往「更大」收
        ans = mid;
        l = mid + 1;
    } else {                         // mid 不可行 → 只能往「更小」收
        r = mid - 1;
    }
}
cout << ans << endl;                // ans 就是最大的可行值
✅ 为什么这个模板绝不会死循环

两个分支分别是 l = mid + 1 和 r = mid - 1,不管走哪条,区间长度都在变小(至少少 1)。
区间一直在缩短,迟早会变成空区间,l <= r 不成立,循环自然结束。

模板 B:求「最小的可行值」(B 类题用)

思路:mid 可行就往更小的方向压(把 r 收到 mid),不可行就只能放大。

int l = 下界, r = 上界;              // ★ 前提:check(r) 必须为真!
while(l < r){                        // 注意是 < 不是 <=
    int mid = l + (r - l) / 2;       // ★ 下取整,必须配 r = mid
    if(check(mid)) r = mid;          // 可行 → 试着更小
    else l = mid + 1;                // 不可行 → 必须更大
}
cout << l << endl;                  // 循环结束时 l == r,就是答案
⚠️ 模板 B 有个隐藏要求

r 的初值必须是「一定可行」的值。因为循环里 r 只会被赋成 mid, 我们默认它一直是可行的。
如果 check(r) 本身是 false,这个模板会输出一个根本不可行的答案。
例如 P1182 里 r 取所有数之和(整段不切,一定可行);P3853 里 r 取 L(一块路标都不加,间距也不可能超过 L)。

★ 死循环的根源:l = mid 必须配上取整

还有一种「求最大可行值」的写法是这样收缩区间的:

while(l < r){
    int mid = (l + r) / 2;
    if(check(mid)) l = mid;          // ← 注意这里是 l = mid,不是 l = mid + 1
    else r = mid - 1;
}

这么写本身没错,但 mid 必须改成上取整,否则死循环。我们手动模拟一下就明白了。

反面教材:l = 3, r = 4,用下取整

轮次lrmid = (l+r)/2check(mid)动作
第 1 轮343truel = mid = 3
第 2 轮343truel = mid = 3
第 3 轮343truel = mid = 3
……————永远出不去 ⛔

原因很简单:l = 3, r = 4 时 (3+4)/2 = 3(整数除法把 3.5 砍成 3)。 于是 l = mid 执行完 l 还是 3,区间 [3, 4] 一点没变,下一轮当然一模一样。

正确写法:上取整

while(l < r){
    int mid = (l + r + 1) / 2;       // ★★ 多加了 1,向上取整
    if(check(mid)) l = mid;          // l = 4 → l == r,循环结束
    else r = mid - 1;
}

这时候 (3+4+1)/2 = 4,l = mid = 4,l == r,循环干净利落地结束。

🚨 把这句话刻在脑子里

出现 l = mid → mid 上取整 (l+r+1)/2
出现 r = mid → mid 下取整 (l+r)/2

也就是:谁被赋成 mid,mid 就朝谁的方向取整。
r = mid 时如果上取整(l=3, r=4 → mid=4),r = 4 区间也不动, 同样死循环 —— 危险是双向的。

💡 给新手的最佳实践

实在记不住取整规则,就永远用模板 A(l = mid + 1 / r = mid - 1 / 用 ans 记答案)。
它的两个分支都在「跨过 mid」,区间必然缩短,取哪种 mid 都不会死循环。 模板 B 只是代码短一点,没有本质优势。

另外,mid = l + (r - l) / 2 这种写法比 (l + r) / 2 更安全: 当 l、r 都接近 int 上限时,l + r 会溢出,而 l + (r - l) / 2 不会。养成习惯。

3check 函数:二分答案的心脏

二分的框架就那么几行,真正决定成败的是 check。 竞赛里「check 写错」比「二分写错」常见得多。

check 通常长什么样

今天 4 道题的 check 全是 O(n) 的贪心扫描:从头到尾扫一遍数组,用一个变量累计, 该「切一刀」就切、该「搬走」就搬。扫完跟给定的限制比一比,返回 true / false。

题目check(x) 在做的事复杂度
P1873把 h[i] - x 的正数部分加起来,看有没有 >= MO(n)
P2440把每根木头的 L[i] / x 加起来,看有没有 >= kO(n)
P1182贪心切段:累加和超过 x 就切一刀,看总段数有没有 <= MO(n)
P3853每一段间距 d 要新插 (d-1)/x 块路标,看总数有没有 <= KO(n)
🎯 check 的两条铁律

铁律一:check(x) 只回答「x 行不行」,不负责找最优解。别在里面又写二分、又求最大值。

铁律二:check 的返回值必须随 x 单调。如果发现「有时候 true 有时候 false 毫无规律」, 那不是二分写错了,是这个量根本不能二分(或者你 check 的思路错了)。

先在纸上手算 check(重要习惯)

以 P1182 为例:数列 4 2 4 5 1,要求分成 3 段,问「每段和的最大值最小是多少」。

我们猜答案 x = 6。手工跑一遍贪心切段:

结果分成 [4 2] [4] [5 1],共 3 段,3 <= 3 ✅ 所以 x = 6 可行。

再试 x = 5:[4] [2] [4] [5] [1] 要 5 段,5 > 3 ❌ 不可行。 所以 6 就是最小的可行值 —— 和洛谷给的样例答案一模一样。

💡 为什么要手算

手算一遍,你就知道自己有没有理解 check。代码只是把手算过程翻译成循环而已。 如果样例都手算不出来,写出来的 check 大概率是错的。

4二分的上下界怎么定(别凭感觉乱写)

二分答案的下界和上界不是随便拍脑袋的,它有固定套路:

题目二分的量下界 l上界 r为什么
P1873锯片高度 H0max(hi)再高就一根都砍不到,木材必然是 0
P2440每段长度 len1max(Li)要求长度是正整数;比最长的木头还长就一段都切不出
P1182每段和上限 xmax(Ai)sum(Ai)x 至少得装得下最大的单个数;全放一段一定可行
P3853空旷指数 x1L间距是整数,最小 1;公路总长 L 是任何间距的上限
⚠️ 上下界写错的两种典型后果

上界开小了:真正的答案在上界外面,二分永远找不到它,输出一个偏小的错误答案。
下界开大了:同理,会漏掉更小的可行解。

另外注意 P1182 的 Ai 可以是 0(题面说的是「非负整数」), 所以下界不能想当然地写 1,要写 max(Ai) 才严密。

💡 关于溢出的一句提醒

P1873 的木材总量、P1182 的区间和都可能超过 int(21 亿)。 凡是「一堆数加起来」的地方,一律用 long long。今天 4 道题里有 3 道都用上了。

5四道例题详解

下面每道题的代码我都用 g++(-std=c++14 -O2 -Wall)实际编译过,并用洛谷官方样例跑过, 「实测结果」里写的就是真实输出。

P1873 [COCI 2011/2012 #5] EKO / 砍树 普及- · 二分答案入门(A 类)

题意:有一排树,高度各不相同。伐木机的锯片可以升到整数高度 H, 比 H 高的部分会被锯下来。要拿到至少 M 米木材,问锯片最高能升到多少?

⚠️ 数据范围 / 关键约束

1 ≤ N ≤ 10⁶,1 ≤ M ≤ 2×10⁹,树的高度 ≤ 4×10⁵,所有树的高度总和 > M。

两个提醒:
· N 有 100 万,check 必须是 O(n) 且常数要小,二分的层数是 log(4×10⁵) ≈ 19 层, 总共约 2×10⁷ 次操作,可以过。
· 木材总量可能很大,一定要 long long。

思路

典型的 A 类(最大化)问题:答案 H 越大,砍到的木材越少。

check(H):把每棵树超过 H 的部分加起来,看总数是不是 ≥ M。
这个函数从 H = 0 到 H = max(h) 的取值是「true…true…false…false」, 我们要最后一个 true 对应的 H。

所以用模板 A:可行就 ans = mid; l = mid + 1,不可行就 r = mid - 1。

样例输入 1
4 7
20 15 10 17
样例输出 1
15
样例输入 2
5 20
4 42 40 26 46
样例输出 2
36

完整代码

#include <iostream>
using namespace std;

int n;
long long m;                 // 需要的木材总量(可能到 2×10^9,必须 long long)
int h[1000005];              // 每棵树的高度,n 最大 10^6,数组开在全局

// check(H):锯片升到高度 H 时,砍下的木材总量是否 >= m
bool check(long long H){
    long long sum = 0;
    for(int i = 0; i < n; i++){
        if(h[i] > H) sum += h[i] - H;   // 只有比 H 高的部分才被锯下来
        if(sum >= m) return true;       // 够了就提前返回:既加速又防止累加溢出
    }
    return false;
}

int main(){
    ios::sync_with_stdio(false);        // 关掉 cin/cout 与 scanf 的同步,读入更快
    cin.tie(0);
    cin >> n >> m;
    int maxh = 0;
    for(int i = 0; i < n; i++){
        cin >> h[i];
        if(h[i] > maxh) maxh = h[i];    // 顺路求出上界:最高的那棵树
    }

    // 答案(锯片高度)越大,砍到的木材越少 -> 单调,可以二分
    long long l = 0, r = maxh, ans = 0;
    while(l <= r){                      // 模板 A:ans 记录 + 区间跨越 mid
        long long mid = l + (r - l) / 2;
        if(check(mid)){                 // mid 可行,记下来,再试着锯更高
            ans = mid;
            l = mid + 1;
        } else {                        // mid 太高,木材不够,只能往低处找
            r = mid - 1;
        }
    }
    cout << ans << endl;
    return 0;
}
✅ 实测结果

官方样例 1 4 7 / 20 15 10 17 → 输出 15 与标准答案完全一致

官方样例 2 5 20 / 4 42 40 26 46 → 输出 36 与标准答案完全一致

极限数据 N = 10⁶、树高随机取 1~4×10⁵、M = 2×10⁹ → 输出 359815, 实测运行 402 ms(约 19 层二分 × 10⁶ 次扫描,符合 O(N log 值域) 的预期)。

另外与暴力枚举(从 0 到 max(h) 逐个试 H)随机对拍 300 组,全部一致。

易错点

  • 累加和不开 long long:10⁶ 棵树 × 4×10⁵ 高度,最坏能到 4×10¹¹,int 直接爆掉。 代码里用 sum >= m 提前 return 也是一种保护。
  • 上界写成 10⁹ 之类的「想当然值」:树高最大只有 4×10⁵,上界取 max(h[i]) 最稳, 也顺便减少了二分层数。
  • 把 check 的方向写反:记住「H 越高,木材越少」。如果不确定,就拿样例 2 手工验一个 H 值。
  • N = 10⁶ 时用 cin 记得加 ios::sync_with_stdio(false),不然读入会慢得明显。
P2440 木材加工 普及/提高- · 二分答案(A 类)+ 整数除法

题意:有 n 根原木,要切成 k 段等长的小段(长度必须是正整数,允许有剩余)。 问每段最长能有多长?如果连 1cm 的都切不出来,输出 0。

⚠️ 数据范围 / 关键约束

1 ≤ n ≤ 10⁵,1 ≤ k ≤ 10⁸,1 ≤ Li ≤ 10⁸。

注意 k 可能非常大(1 亿段),而木头最长才 1 亿厘米 —— 但每根木头最多切出 L[i] / len 段, 累加值用 long long 就够了。关键是别把「切出的段数」当 int 去比 k,累加过程可能中转出 int 范围。

思路

也是 A 类:小段长度 len 越长,能切出的段数越少。

check(len):Σ (L[i] / len) ≥ k 吗?这里的除法是整数除法, 天然就是「这根木头能切几段」,不用额外处理余数。

下界取 1(长度必须是正整数),上界取 max(L[i])。 如果 check(1) 都不成立,那 ans 一直是初值 0,正好输出 0 —— 初值选 0 是关键。

样例输入
3 7
232
124
456
样例输出
114

验算:232/114 = 2,124/114 = 1,456/114 = 4,合计 7 段 ✅;
如果 len = 115:232/115 = 2,124/115 = 1,456/115 = 3,合计 6 段 < 7 ❌ 所以答案就是 114。

完整代码

#include <iostream>
using namespace std;

int n;
long long k;                 // 需要的小段数量,最大 10^8
int L[100005];               // 每根原木的长度

// check(len):所有原木都切成 len 厘米的小段,能不能凑够 k 段
bool check(long long len){
    long long cnt = 0;
    for(int i = 0; i < n; i++){
        cnt += L[i] / len;        // 整除:这根原木能切出几段
        if(cnt >= k) return true; // 够了就提前退出,后面不用再算
    }
    return false;
}

int main(){
    ios::sync_with_stdio(false);
    cin.tie(0);
    cin >> n >> k;
    int maxl = 0;
    for(int i = 0; i < n; i++){
        cin >> L[i];
        if(L[i] > maxl) maxl = L[i];    // 上界:最长的那根原木
    }

    // len 越大,能切出的段数越少 -> 单调
    long long l = 1, r = maxl, ans = 0; // ★ ans 初值 0:切不出来时正好输出 0
    while(l <= r){
        long long mid = l + (r - l) / 2;
        if(check(mid)){                 // 能切够,记下来,再试试切更长
            ans = mid;
            l = mid + 1;
        } else {
            r = mid - 1;                // 切太长了,段数不够
        }
    }
    cout << ans << endl;
    return 0;
}
✅ 实测结果

官方样例 3 7 / 232 124 456 → 输出 114 与标准答案完全一致

边界 2 5 / 1 1(两根 1cm 的木头要切 5 段,总共只有 2cm)→ 输出 0 正确 (连 1cm 都切不出来,题面要求输出 0)。

与暴力枚举(len 从 1 试到 max(L))随机对拍 300 组,全部一致。

易错点

  • ans 初值写成 -1:那样「切不出来」时会输出 -1,而题目要的是 0。
  • 下界写 0:L[i] / 0 会直接除零崩溃(RE)。长度是正整数,下界就该是 1。
  • 用 int 累加段数:n 最大 10⁵、每根最多切 10⁸ 段,累加过程可能超过 21 亿,用 long long。
  • 不需要判断「余料怎么处理」—— 题目允许有剩余,整数除法天然处理了。
P1182 数列分段 Section II 普及/提高- · 二分 + 贪心验证(B 类)

题意:把一个长度为 N 的正整数数列分成 M 段(每段必须连续), 让「各段和中最大的那个」尽可能小,求这个最小值。

题面例子:4 2 4 5 1 分 3 段。
[4 2][4 5][1] → 段和 6、9、1,最大 9;
[4][2 4][5 1] → 段和 4、6、6,最大 6。最大值的最小可能值就是 6。

⚠️ 数据范围 / 关键约束

1 ≤ N ≤ 10⁵,M ≤ N,Ai < 10⁸,答案不超过 10⁹。

两个坑:
· Ai 是非负整数(题面原话),也就是可能为 0,所以下界不能写死成 1,要写 max(Ai);
· 所有数加起来最多 10⁵ × 10⁸ = 10¹³,必须 long long。

思路

这是今天第一道 B 类(最小化最大值)题,也是「二分答案 + 贪心」的经典组合。

我们二分的量是「允许的每段和上限 x」。check(x) 回答: 在每段和都不超过 x 的前提下,最少要分几段?如果这个段数 ≤ M,那 x 就可行。

「最少分几段」用贪心求:从前往后累加,一旦加上当前数就要超 x,就在这里切一刀。 这是最优的 —— 每一段都塞到装不下为止,段数自然最少。

🎯 为什么 x 越大段数越少(单调性)

上限放宽了,每一段能塞更多数 → 段数只会变少,不会变多。
所以 check(x) 的形状是「false…false…true…true」: x 太小分不出(段数超 M),x 够大就行。
我们要找的是第一个 true → 用模板 B:可行就让 r = mid 往小压。

上下界:l = max(Ai)(任何一段的和都不可能小于最大的单个数), r = sum(Ai)(全部塞进一段,一定可行,满足模板 B 的前提)。

样例输入
5 3
4 2 4 5 1
样例输出
6

完整代码

#include <iostream>
using namespace std;

int n, m;
int a[100005];

// check(x):限制「每段和都不超过 x」,最少要分成几段?段数 <= m 就算可行
bool check(long long x){
    int cnt = 1;             // 至少有一段
    long long cur = 0;       // 当前这一段已经累加的和
    for(int i = 0; i < n; i++){
        if(a[i] > x) return false;      // 一个数自己就超过 x,怎么分都不行
        if(cur + a[i] > x){             // 加上就爆了 -> 在这里切一刀
            cnt++;
            cur = a[i];
        } else {
            cur += a[i];                // 还能装得下,继续累加
        }
    }
    return cnt <= m;         // 用的段数不超过 m 就说明 x 可行
}

int main(){
    ios::sync_with_stdio(false);
    cin.tie(0);
    cin >> n >> m;
    long long sum = 0, mx = 0;
    for(int i = 0; i < n; i++){
        cin >> a[i];
        sum += a[i];
        if(a[i] > mx) mx = a[i];
    }

    // 「最大值最小」:x 越大越容易可行 -> 单调,二分找最小的可行 x
    long long l = mx, r = sum;             // ★ r = sum 保证 check(r) 一定为真
    while(l < r){                          // 模板 B:while(l < r)
        long long mid = l + (r - l) / 2;   // 下取整,配合 r = mid 使用
        if(check(mid)) r = mid;            // 可行,试着更小
        else l = mid + 1;                  // 不可行,必须更大
    }
    cout << l << endl;                     // 结束时 l == r,就是最小的可行值
    return 0;
}
✅ 实测结果

官方样例 5 3 / 4 2 4 5 1 → 输出 6 与标准答案完全一致

边界 3 3 / 1 2 3(N = M,每个数自成一段)→ 输出 3 正确(此时段和最大值就是最大的元素)。

极限数据 N = 10⁵、M = 50000、Ai 随机取 0~10⁸ → 输出 134021264 (在题面保证的 10⁹ 以内),实测 76 ms。

与暴力枚举(枚举所有 2N-1 种切分方案)随机对拍 300 组,全部一致。

易错点

  • 下界写成 0 或 1:Ai 可能比它大,会导致 check 永远返回 false,输出错误。要用 max(Ai)。
  • 两个 while 循环条件搞混:模板 B 是 while(l < r),不是 l <= r。 写成 <= 会多转一轮,可能输出越界的值。
  • 模板 B 里把 r = mid 写成 r = mid - 1:那就变成模板 A 了, 而且最后没有 ans 记录,输出的是错误的值。收缩方式和「答案落在哪」必须配套。
  • 贪心切段时忘了「不足以放下一个数就先切」,或者把 cur + a[i] > x 写成 >=: 题目要求「不超过 x」,等于 x 是合法的,所以必须用 >。
P3853 [TJOI2007] 路标设置 普及/提高- · 二分 + 贪心验证(B 类)

题意:一条长 L 的公路上原来有 N 个路标(起点、终点一定有)。 最多可以再增设 K 个路标(位置必须是整数)。定义「空旷指数」= 相邻路标间距的最大值。 问空旷指数最小能压到多少。

⚠️ 数据范围 / 关键约束

2 ≤ N ≤ 100000,0 ≤ K ≤ 100000,0 < L ≤ 10⁷。

注意 K 可以为 0(一个人都不加),这时候答案就是原来最大的那个间距。 另外路标位置是整数,这个条件会影响 check 里的取整方式(下面细说)。

思路

典型的 B 类题:最小化「最大间距」。

二分「允许的最大间距 x」。check(x) 回答:要让所有相邻间距都 ≤ x, 最少要新增几块路标?总数 ≤ K 就可行。

★ 单段要插几块?推导一下

某两块的间距是 d。如果新插 c 块,就把 d 切成 c+1 段。 要让每一小段都 ≤ x,需要 d / (c+1) ≤ x,也就是 c ≥ d/x - 1。

因为 c 要是整数,所以最少的 c = ⌈d/x⌉ - 1。

而 ⌈d/x⌉ = (d-1)/x + 1(整数除法),所以:

cnt = (d - 1) / x;      // 一行搞定,整数除法自动下取整
间距 d允许的最大间距 x(d-1)/x要不要真的能摆得下?
101511摆 1 块在 51 处 → 间距 51 和 50,都 ≤ 51 ✅
101502摆 2 块在 50、100 处 → 间距 50、50、1 ✅
100501摆 1 块在 50 处 → 间距 50、50 ✅
51510本来就合格,一块都不用加 ✅
💡 为什么是 (d-1)/x 而不是 d/x

如果写成 d / x,当 d 正好是 x 的倍数时会多算一块。
例:d = 50, x = 50。正确答案是 0 块(本来就不超),但 50/50 = 1 会算成 1 块。
用 (d-1)/x:49/50 = 0 ✅ 正确。这个 -1 是本题最大的坑。

上下界:l = 1(间距是整数,最小为 1),r = L(总长是任何间距的上限, 而且 x = L 时一块路标都不用加,必然可行 → 满足模板 B 的前提)。

样例输入
101 2 1
0 101
样例输出
51

只有起点 0 和终点 101 两块路标,可以加 1 块。
加在 50 处 → 间距 50、51,最大 51;加在 51 处 → 间距 51、50,最大 51。
想让最大间距到 50,就必须加 2 块(超预算),所以答案是 51。

完整代码

#include <iostream>
using namespace std;

int L, n, k;
int a[100005];               // 原有路标位置(输入保证递增)

// check(x):想让所有相邻路标的间距都 <= x,最少要新增几块路标?<= k 就可行
bool check(long long x){
    long long need = 0;
    for(int i = 1; i < n; i++){
        long long d = a[i] - a[i - 1];      // 这一段原来的间距
        // 间距 d 用 cnt 块新路标切成 cnt+1 段,需要 d/(cnt+1) <= x
        // 所以 cnt = ceil(d/x) - 1 = (d-1)/x(整数除法自动下取整)
        need += (d - 1) / x;                // ★ 少写这个 -1 就会 WA
        if(need > k) return false;          // 已经超预算,不用再算了
    }
    return need <= k;
}

int main(){
    ios::sync_with_stdio(false);
    cin.tie(0);
    cin >> L >> n >> k;
    for(int i = 0; i < n; i++) cin >> a[i];

    // 「空旷指数」= 相邻路标的最大间距,我们要让它最小
    // x 越大越容易做到 -> 单调,二分最小的可行 x
    long long l = 1, r = L;              // ★ r = L 时一块都不用加,一定可行
    while(l < r){
        long long mid = l + (r - l) / 2;
        if(check(mid)) r = mid;          // 做得到,往更小的方向压
        else l = mid + 1;                // 做不到,只能放宽
    }
    cout << l << endl;
    return 0;
}
✅ 实测结果

官方样例 101 2 1 / 0 101 → 输出 51 与标准答案完全一致

边界 10 3 0 / 0 3 7 10(K = 0,一块都不许加)→ 输出 4 正确 (三个间距是 3、4、3,最大值 4,没得选)。

与暴力对拍(逐个试 x,用「一块一块往前跳」的独立算法统计所需路标数)随机 300 组,全部一致。

易错点

  • (d-1)/x 写成 d/x:间距正好是 x 的倍数时会多算一块路标,导致答案偏大。
  • 把「空旷指数」理解成间距之和或平均值:它是相邻间距的最大值,要最小化这个最大值。
  • K = 0 的情况没考虑:此时答案就是原来最大的间距,二分必须能自然给出这个结果(我们 r = L、l = 1,没问题)。
  • 路标位置是整数这一点决定了取整公式。如果题目允许实数的路标,公式就变成 ceil(d/x)-1 的另一套算法了 —— 看清「整数」二字。

6今日常见错误汇总

错误写法后果正确写法
while(l<r){ mid=(l+r)/2; if(ok) l=mid; ...} l 和 r 相邻时会死循环(TLE),例如 l=3, r=4 → mid=3 → l=3 mid = l+(r-l+1)/2;
(有 l=mid 就上取整)
用模板 B 但忘了「r 必须可行」
(如 P1182 的 r 取 max(A))
输出一个根本不成立的答案 r 取「一定可行」的值
(P1182 取 sum,P3853 取 L)
模板 A 和模板 B 的收缩混着用
(r = mid 配 l = mid)
区间可能不缩短 → 死循环;或漏掉答案 要么「跨越 mid + ans 记录」,要么「贴住 mid + 最后输出 l」
累加和 / 段数用 int P1873、P1182 会溢出,得到莫名其妙的负数 一律 long long
下界 / 上界拍脑袋乱写
(P1182 下界写 1、P2440 下界写 0)
答案偏大/偏小;P2440 下界 0 会除零 RE 下界 = max(单个数) 或 1;上界 = max 或 sum
check 的单调方向搞反
(可行时往错误的方向收)
输出边界值(比如总是输出上界或下界) 写代码前先问自己:x 变大是更容易还是更难?
P3853 里写 d / x d 是 x 的整数倍时多算一块路标 → 答案偏大 (d - 1) / x
check 里写 >= 还是 > 搞混 P1182 要求「不超过 x」,写成 >= 会少分一段 严格读题:「不超过」用 > 判断超限

✓自测:你真的会了吗

第 1 题:这道题能用二分答案做吗?

题目:给 n 个物品的价值,要选出 恰好 k 个,使总价值最大。

答:不能。「选恰好 k 个」这件事和「总价值至少为 x」之间没有单调关系 —— 总价值够了,不代表正好用 k 个物品凑出来的。二分答案的前提是答案具有单调性, 这里 check 的形状是乱的,二分毫无意义。这题是排序 / DP 的活。

第 2 题:这段代码会死循环吗?
int l = 0, r = 100;
while(l < r){
    int mid = (l + r) / 2;
    if(check(mid)) l = mid;
    else r = mid - 1;
}

答:会。因为有 l = mid,mid 必须上取整写成 (l + r + 1) / 2。

举个具体的:当区间收缩到 l = 0, r = 1,若 check(0) 为真, 执行 l = mid = 0,区间还是 [0, 1],下一轮一模一样,永远出不去。

第 3 题:P1182 里,为什么下界要取 max(Ai)?

答:因为「每段和的上限 x」必须至少能装下单独一个元素。

如果某个 Ai > x,这个元素不管放进哪一段,那段的和都会超过 x, 所以 x 必然不可行。换句话说,x 的理论最小值就是最大的那个数。

把下界设成 max(Ai) 还能顺便在 check 里省掉一个判断(但写上 if(a[i] > x) return false; 更保险)。

第 4 题:P3853 里,间距 d = 100,允许的最大间距 x = 25,要插几块路标?

答:用公式 (d-1)/x = 99/25 = 3 块。

验证:插在 25、50、75 处 → 四段间距都是 25,都 ≤ 25 ✅。
如果只用 2 块,最多切成 3 段,100/3 = 33.3 > 25 ❌,确实不够。 公式算出来是 3,手工验证也是 3,对上了。

第 5 题(手写代码):写出「求最小可行值」的模板
int l = 下界, r = 上界;        // 保证 check(r) == true
while(l < r){
    int mid = l + (r - l) / 2;
    if(check(mid)) r = mid;    // 可行 → 往更小压
    else l = mid + 1;          // 不可行 → 必须更大
}
cout << l << endl;              // l == r 就是答案

★ 记忆点:有 r = mid 就下取整,有 l = mid 就上取整。

▤速查卡(截图存手机)

【判断能不能二分答案】
  1. 答案是一个数,而且题问「最大是多少 / 最小是多少」
  2. check(x) == 「如果答案是 x,办得到吗」好写(通常 O(n) 贪心)
  3. check 的结果随 x 单调:true…true…false…false 或反过来
  三条全中 → 二分答案

【模板 A:求最大的可行值】(最大值最大化 / 最小值最大化)
  int l = 下界, r = 上界, ans = -1;
  while(l <= r){
      int mid = l + (r - l) / 2;
      if(check(mid)){ ans = mid; l = mid + 1; }
      else r = mid - 1;
  }
  cout << ans;
  // 区间每次都跨越 mid,取哪种取整都安全

【模板 B:求最小的可行值】(最大值最小化)
  int l = 下界, r = 上界;          // ★ check(r) 必须为 true
  while(l < r){
      int mid = l + (r - l) / 2;   // 下取整,配 r = mid
      if(check(mid)) r = mid;
      else l = mid + 1;
  }
  cout << l;

【防死循环口诀】
  出现 l = mid  →  mid = (l + r + 1) / 2   (上取整)
  出现 r = mid  →  mid = (l + r) / 2       (下取整)
  记不住就永远用模板 A。

【防溢出】
  mid 用 l + (r - l) / 2,不要 (l + r) / 2
  凡是「一堆数加起来」的量(木材、段和、间距和)一律 long long

【今天的上下界】
  P1873  H  ∈ [0, max(h)]
  P2440  len∈ [1, max(L)]
  P1182  x  ∈ [max(A), sum(A)]
  P3853  x  ∈ [1, L]

【今天 check 的三个小公式】
  砍树:    sum(max(0, h[i] - H)) >= M
  切木头:  Σ (L[i] / len) >= k
  切段:    累加超过 x 就切一刀,段数 <= M
  插路标:  Σ ((d - 1) / x) <= K      // ★ 别忘了 -1

→明天预告 · Day 24

二分与数据结构综合 —— 把二分、前缀和、贪心、DP 串起来用。

明天四道题各有各的味道:

今天先把两套模板写熟,明天就不会被实数二分和前缀和搞晕。