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\) 是否可行。

  1. 第一段
    • 放入 \(4\),当前和为 \(4\)
    • 放入 \(2\),当前和为 \(4+2=6\)
    • 下一个元素是 \(4\)\(6+4=10\),超过了限制 \(8\)。所以第一段只能是 \([4, 2]\)
  2. 第二段
    • \(4\) 开始。放入 \(4\),当前和为 \(4\)
    • 下一个元素是 \(5\)\(4+5=9\),超过了限制 \(8\)。所以第二段只能是 \([4]\)
  3. 第三段
    • \(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)

总结经验与易错点

在解决这类问题时,有几个非常关键且容易出错的细节需要牢记:

  1. 二分答案的下界(Lower Bound)至关重要

    • 错误做法:将下界设为 \(0\)\(1\)
    • 正确做法:下界 lb 必须是 max(a)
    • 原因:最终求出的“每段和的最大值”,至少要能容纳下数组中最大的那个元素。如果二分出的 mid 值比 max(a) 还小,那么那个最大的元素自身就无法放入任何一段,check(mid) 必然失败。将下界设为 max(a) 可以有效缩小搜索范围,并且避免一些潜在的逻辑错误。
  2. check 函数中分段计数的逻辑

    • 易错点total 记录的是“切分”的次数。切分 \(k\) 次会得到 \(k+1\) 段。所以最后的判断应该是 total + 1 <= m,或者等价的 total < m
    • 更清晰的做法:如最终代码所示,直接声明一个变量 num_segments 来记录段数,初始值为 \(1\)。每当需要开启一个新段时,才将 num_segments 加一。这种方式更直观,不易出错。
  3. check 函数中对单个元素大于限制的处理

    • 虽然我们通过设置二分下界 lb = max(a) 避免了这个问题,但如果在 check 函数内部考虑,也需要注意:如果某个元素 a[i] 本身就大于 max_sum_limit,那么无论如何都无法分段。一个健壮的 check 函数应该能处理这种情况(虽然在本题的二分框架下不会发生)。

核心思想总结

遇到求解“最大值最小化”或“最小值最大化”这类问题时,应立刻联想到“二分答案”。它的本质是:

将一个复杂的“最优化问题”,转化为一个更简单的、可通过贪心等策略解决的“判定性问题”,然后通过二分查找快速锁定最优解的边界。

掌握了这个思维模式,你会发现很多看起来棘手的题目都会迎刃而解。

posted @ 2025-07-23 15:50  AFewMoon  阅读(18)  评论(0)    收藏  举报