Luogu P1182 数列分段 Section II
这是一道非常经典的算法题,它完美地展示了如何将一个求解“最优值”的问题,转化为一个“判定性”问题,并利用二分查找高效求解。这种问题类型通常有固定的关键词,比如本题的 “最大值最小”。
思路分析:从问题到算法
直接去寻找“最小的那个最大值”是非常困难的,因为我们不知道该如何分段。分段的方式有千千万万种,暴力枚举所有分段方式显然会超时。
这时,我们需要转变思路。我们不直接“求解”答案,而是去“猜测”答案。假设我们猜了一个答案,比如说,我们猜测“每段和的最大值”不会超过 \(X\)。那么问题就从:
“最小的最大值是多少?”
变成了:
“在每段和都不超过 \(X\) 的前提下,我们能否将数列分成不超过 \(M\) 段?”
这个问题就变成了一个“Yes/No”的判定性问题。如果我们能高效地回答这个问题,事情就好办了。
答案的单调性
为什么“猜测”是可行的?因为这个“猜测的答案 \(X\)”具有非常重要的单调性:
- 如果一个较大的值 \(X\) 作为“每段和的最大值”是可行的(即可以分成 \(\le M\) 段),那么任何比 \(X\) 更大的值 \(X+1, X+2\cdots\) 作为最大值,也一定是可行的。因为放宽了限制,我们只可能用更少或者同样多的段数完成分段。
- 反之,如果一个较小的值 \(Y\) 作为“每段和的最大值”是不可行的(即需要超过
M段才能完成分段),那么任何比 \(Y\) 更小的值 \(Y-1, Y-2\cdots\) 作为最大值,也一定是不可行的。因为限制更严格了,我们只会需要更多段。
这种一半可行、一半不可行的性质,正是二分查找的完美应用场景!我们可以对“答案”进行二分查找,从而在 \(O(log W)\) 的时间内(\(W\) 是答案的范围)找到那个“最小的可行值”。
如何实现 check(X) 函数?—— 贪心策略
现在,核心问题变成了:给定一个最大和限制 \(X\),如何用最少的段数来划分数列?
这里我们可以使用贪心策略。策略非常直观:
从数列的第一个元素开始,我们为当前段尽可能多地装入连续的元素,直到再装下一个元素就会超过限制 \(X\)。此时,我们不能再装了,必须结束当前段,并从下一个元素开始,开启一个新段。我们重复这个过程,直到所有元素都被分完。
举例说明贪心过程:数列 \(A = [4, 2, 4, 5, 1], M = 3\)。我们来 check(8),即判断最大和不超过 \(8\) 是否可行。
- 第一段:
- 放入 \(4\),当前和为 \(4\)。
- 放入 \(2\),当前和为 \(4+2=6\)。
- 下一个元素是 \(4\)。\(6+4=10\),超过了限制 \(8\)。所以第一段只能是 \([4, 2]\)。
- 第二段:
- 从 \(4\) 开始。放入 \(4\),当前和为 \(4\)。
- 下一个元素是 \(5\)。\(4+5=9\),超过了限制 \(8\)。所以第二段只能是 \([4]\)。
- 第三段:
- 从 \(5\) 开始。放入 \(5\),当前和为 \(5\)。
- 放入 \(1\),当前和为 \(5+1=6\)。
- 数组结束。第三段是 \([5, 1]\)。
最终,我们分成了 \(3\) 段。题目要求是 \(\le M\) 段,这里 \(3 \le 3\),所以 check(8) 返回 True。这说明 \(8\) 是一个可行的答案,但可能不是最小的,我们应该尝试更小的值。
这个贪心策略的正确性在于:对于任何一段,我们都让它尽可能地长。这样做可以保证在满足条件的前提下,使用的总段数最少。因为延长当前段,绝不会使得后续需要的段数增加,反而可能因为“吸收”了更多元素而减少后续段数。
代码实现
结合“二分答案”的框架和“贪心验证”的 check 函数,我们可以写出完整的代码。
import sys
# n: 数列长度, m: 最多分的段数
n, m = map(int, sys.stdin.readline().split())
# 数列 a
a = list(map(int, sys.stdin.readline().split()))
def check(max_sum_limit: int) -> bool:
"""
判定函数:在每段和不超过 max_sum_limit 的前提下,
是否能将数列分成 <= m 段。
使用贪心策略来计算最少需要多少段。
"""
num_segments = 1 # 段数计数器,初始为1,因为至少有1段
current_sum = 0 # 当前段的和
for x in a:
# 贪心:只要当前段还能装下,就继续装
if current_sum + x <= max_sum_limit:
current_sum += x
else:
# 装不下了,必须开启新的一段
num_segments += 1
# 新段的第一个元素就是 x
current_sum = x
# 最终判断所需的段数是否满足要求
return num_segments <= m
# 二分答案的下界:至少要能容纳数组中最大的那个元素
lb = max(a)
# 二分答案的上界:最坏情况,整个数列作为一段,和为 sum(a)
ub = sum(a)
# 最终答案
ans = ub
# 标准的二分查找模板,寻找满足 check 条件的最小值
while lb <= ub:
mid = (lb + ub) // 2
if check(mid):
# 如果 mid 是一个可行的解,说明真正的最优解可能更小(或就是 mid)
# 所以我们记录下这个可行的解,并尝试在左半部分继续寻找
ans = mid
ub = mid - 1
else:
# 如果 mid 不可行,说明 mid 太小了,必须扩大范围
# 所以最优解一定在右半部分
lb = mid + 1
print(ans)
总结经验与易错点
在解决这类问题时,有几个非常关键且容易出错的细节需要牢记:
-
二分答案的下界(Lower Bound)至关重要
- 错误做法:将下界设为 \(0\) 或 \(1\)。
- 正确做法:下界
lb必须是max(a)。 - 原因:最终求出的“每段和的最大值”,至少要能容纳下数组中最大的那个元素。如果二分出的
mid值比max(a)还小,那么那个最大的元素自身就无法放入任何一段,check(mid)必然失败。将下界设为max(a)可以有效缩小搜索范围,并且避免一些潜在的逻辑错误。
-
check函数中分段计数的逻辑- 易错点:
total记录的是“切分”的次数。切分 \(k\) 次会得到 \(k+1\) 段。所以最后的判断应该是total + 1 <= m,或者等价的total < m。 - 更清晰的做法:如最终代码所示,直接声明一个变量
num_segments来记录段数,初始值为 \(1\)。每当需要开启一个新段时,才将num_segments加一。这种方式更直观,不易出错。
- 易错点:
-
check函数中对单个元素大于限制的处理- 虽然我们通过设置二分下界
lb = max(a)避免了这个问题,但如果在check函数内部考虑,也需要注意:如果某个元素a[i]本身就大于max_sum_limit,那么无论如何都无法分段。一个健壮的check函数应该能处理这种情况(虽然在本题的二分框架下不会发生)。
- 虽然我们通过设置二分下界
核心思想总结
遇到求解“最大值最小化”或“最小值最大化”这类问题时,应立刻联想到“二分答案”。它的本质是:
将一个复杂的“最优化问题”,转化为一个更简单的、可通过贪心等策略解决的“判定性问题”,然后通过二分查找快速锁定最优解的边界。
掌握了这个思维模式,你会发现很多看起来棘手的题目都会迎刃而解。

浙公网安备 33010602011771号