一、二分答案介绍

二分查找是我们很熟悉的一种算法,通过对一个有序序列进行分割,最终能实现查找到目标数值。而二分答案就是基于这种思想衍生出来的解决最优化问题的策略。在题目中,我们最常看见的字眼就是最大值最小化最小值最大化等目标,就在暗示我们使用二分答案算法。

从专业的角度来介绍二分答案算法,该算法基于二分的思想,通过对解空间进行不断分割,逐步逼近最优解。

今天这篇文章将结合具体例子,介绍笔者关于二分答案这个算法的应用范围的一点巧思

二、具体例子

以下这道题作为一个例子来介绍这个算法,P1182 数列分段 Section II - 洛谷

题目描述

对于给定的一个长度为 NN 的正整数数列 A1NA_{1\sim N},现要将其分成 MMMNM\leq N)段,并要求每段连续,且每段和的最大值最小。

关于最大值最小:

例如一数列 4 2 4 5 14\ 2\ 4\ 5\ 1 要分成 33 段。

将其如下分段:

[4 2][4 5][1][4\ 2][4\ 5][1]

第一段和为 66,第 22 段和为 99,第 33 段和为 11,和最大值为 99

将其如下分段:

[4][2 4][5 1][4][2\ 4][5\ 1]

第一段和为 44,第 22 段和为 66,第 33 段和为 66,和最大值为 66

并且无论如何分段,最大值不会小于 66

所以可以得到要将数列 4 2 4 5 14\ 2\ 4\ 5\ 1 要分成 33 段,每段和的最大值最小为 66

输入格式

11 行包含两个正整数 N,MN,M

22 行包含 NN 个空格隔开的非负整数 AiA_i,含义如题目所述。

输出格式

一个正整数,即每段和最大值最小为多少。

输入输出样例 #1

输入 #1

1
2
5 3
4 2 4 5 1

输出 #1

1
6

说明/提示

对于 20%20\% 的数据,N10N\leq 10

对于 40%40\% 的数据,N1000N\leq 1000

对于 100%100\% 的数据,1N1051\leq N\leq 10^5MNM\leq NAi<108A_i < 10^8, 答案不超过 10910^9

观察这道题,要求每段和最大值最小,从直觉和做题经验上来说,就告诉我们,这道题应该用二分答案。

让我们回忆一下二分查找的基本结构

1
2
3
4
5
6
7
8
9
10
11
func binary_search(A, target)
L = 0, R = A.length - 1
while L<=R
MID = (L + R) / 2
if A[MID] < target
L = MID + 1
else if A[MID] > target
R = MID -1
else
return MID
return unsuccessful

类似于我们判断A[MID]target的大小关系,我们需要判断当前我们二分分割出来的MID作为解,在给出的输入空间下,是否能实现。

如果能实现,我们是进入左区间/右区间,否则则进入右区间/左区间

那么在这道题的条件下,我们二分的结构即为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
cin >> n >> m;
for (int i = 1; i <= n; ++i) {
cin >> a[i];
prefix[i] = prefix[i - 1] + a[i];
}
ll l = 1, r = prefix[n], mid = 0;
while (l <= r) {
mid = (l + r) / 2;
if (check(mid))
r = mid - 1;
else
l = mid + 1;
}
cout << r + 1 << "\n";

在这道题中,check()的功能即为判断,是否存在一种符合条件的方案能实现mid作为每段和的最大值

容易想到(笔者也不知道怎么想到的,也不会证明,反正做题经验如此(笑)),

我们遍历整个序列,不断累加当前段的和,如果大于当前mid的值,便重新开启一个新的子段进行累加,最终判断子段的数量是否小于等于我们的规定的最大子段数量

1
2
3
4
5
6
7
8
9
10
11
12
bool check(ll maxn) {
ll res = 1, cur_sum = 0;
for (int i = 1; i <= n; ++i) {
if (a[i] > maxn) return false;
if (cur_sum + a[i] > maxn) {
cur_sum = 0;
res += 1;
}
cur_sum += a[i];
}
return res <= m;
}

所以最终的题解如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const int N = 1e5 + 10;
ll n, m, prefix[N], a[N];
bool check(ll maxn) {
ll res = 1, cur_sum = 0;
for (int i = 1; i <= n; ++i) {
if (a[i] > maxn) return false;
if (cur_sum + a[i] > maxn) {
cur_sum = 0;
res += 1;
}
cur_sum += a[i];
}
return res <= m;
}
int main() {
ios::sync_with_stdio(false), cin.tie(0), cout.tie(0);
cin >> n >> m;
for (int i = 1; i <= n; ++i) {
cin >> a[i];
prefix[i] = prefix[i - 1] + a[i];
}
ll l = 1, r = prefix[n], mid = 0;
while (l <= r) {
mid = (l + r) / 2;
if (check(mid))
r = mid - 1;
else
l = mid + 1;
}
cout << r + 1 << "\n";
return 0;
}

(当然这里多余了一个前缀和数组,本来以为要用,后来没用上)

三、关于二分答案的应用范围的巧思

看到这里,读者或许恍然大悟,觉得这道题不过如此。但是我想请各位仔细想一个问题,为什么在这道题里我们可以使用二分答案这个算法?

我们知道,二分查找算法需要在一段有序序列的情况下才能使用,也就是其序列具备两个性质:单调性有界性

同理,我们在使用二分算法的同时也需要具备这两个性质。

首先,有界性很好理解,比如这道题我们的最大值一定是存在单个元素的最大值和所有元素的和这两个值之间,我们的解空间存在有限性

然而,对于解的单调性却有点难以理解。从序列的单调性来看,我们可以用另一种方式去解释:

对于一个数列来说,对于其任意第i个元素,其值都大于或小于序列小于i的所有元素,则数列具有单调性(证明可用数学归纳法,略)

而对于我们的当前的解来说,是否具备这样一个性质:如果该最大值能实现,那么大于这个值的最大值均能实现,那么如此,便是符合了广义上的单调性

从更直观的方面理解,如果不能实现看做0,能实现看做1,那么从解空间的下界开始,解空间一定是单调不减的,这便是我们能使用二分答案的原因。

笔者希望从如此方面理解二分的性质,能否使读者更能理解二分答案的应用范围