#19484. 最大平均值和的分组

最大平均值和的分组

你好!我是你的OI教练。这份讲义非常系统地讲解了 “将数组划分为K个连续子数组”的经典一维DP模型

从这份教学材料的思路来看,它是按照“基础模型构建 \rightarrow 边界与约束处理 \rightarrow 复杂度优化 \rightarrow 综合实战”的逻辑层层递进的。基于这个教学思路,我们可以提取出以下4道典型例题形成一套循序渐进的题单,为你接下来的训练做好准备:

📋 提取的例题清单 (对应材料的不同章节)

  1. 【标准模板题】最大平均值和的分组 (对应 LeetCode 813)
    • 特征:恰好划分 KK 个非空子数组,求特定代价(平均值)之和的最大值。
  2. 【变体一:数量约束】最多划分 K 次的最大/最小收益
    • 特征:要求划分成“最多 KK 个”子数组,考察最终答案收集域的变化。
  3. 【变体二:长度约束】附带最小长度限制的 K 次划分
    • 特征:每个子数组至少包含 LL 个元素,考察内层状态转移(分割点 xx)的边界推导。
  4. 【变体三:代价替换】分割数组的最大值最小化 (如 LeetCode 410)
    • 特征:代价函数变为“求区间和的最大值”,外层求最小值,考察状态转移方程中 max/min 算子的灵活替换。

下面我将严格按照你的要求,为你进行针对这套题型的教学引导。

1、输入题目的思路提示 (不提供完整解答)

在面对上述题单中的问题时,不要急于写代码,先问自己以下几个问题来打通思路:

  • 状态的基石是什么? 我们的操作是“切分”,并且是有顺序的。要切分前 ii 个元素,你不仅需要知道当前切到了哪里,还需要知道“这是第几刀”。尝试用二维状态 dp[i][j]dp[i][j] 来描述。
  • 寻找最后一块拼图(状态转移): 假设你已经切好了前 j1j-1 块,现在要切第 jj 块(也就是最后一块)。这最后一块的起点在哪里?假设起点是 xx,那么这最后一块包含的元素范围是什么?前 j1j-1 块包含的元素范围又是什么?
  • 如何做选择(最优化): 既然起点 xx 是未知的,它可能在哪些位置?你应该遍历哪些 xx 来寻找最优解?
  • “不可能”的状态怎么处理?00 个元素切成 55 段可能吗?这种状态在求最大值或最小值时,应该被赋予什么特殊值,才不会在后续的比较中被错误地选中?

2、预备知识点总结

在完美解决此类问题前,你需要掌握以下OI基础知识点:

  • 动态规划核心三要素:状态定义(明确维度的物理意义)、状态转移方程(寻找子问题之间的递推关系)、边界条件(Base Cases与无效状态隔离)。
  • 前缀和技巧 (Prefix Sum):能够利用 O(N)O(N) 的预处理,在 O(1)O(1) 的时间复杂度内求出任意连续子数组 [L,R][L, R] 的区间和。
  • 滚动数组与空间优化:理解多维 DP 中“当前状态仅依赖上一层状态”的特性,并掌握如何通过逆向遍历(倒序枚举)将二维 DP 降维压缩至一维数组。
  • 极端值处理 (Infinity values):在 C++ 中熟练使用 INT_MAX, INT_MIN1e9 等大数来初始化不可达状态。

3、启发式与图形式的草稿纸推演教学

(假设我们现在面对面,你拿着纸笔,我来引导你画图)

第一步:画出数组,具象化“最后一次切分” 教练:“来,在草稿纸上画一个长条代表数组。我们在末尾标上索引 ii。现在我们的目标是把前面这 ii 个元素分成 jj 个段。” 教练:“最核心的突破口在于最后一段。请你在中间随便画一条竖线,标上 xx。”

数组原貌: [0 ........................................ i-1]
切一刀 x:[0 ...... x-1]  |  [x .................... i-1]
           \__ 前 j-1 段__/    \______ 第 j 段 (最后一段)___/

教练:“观察这个图。最后一段是从哪到哪?代价怎么算?” 学生:“从 xxi1i-1。代价就是 cost(x, i-1)。” 教练:“非常好!那么左边那部分呢?它包含了 00x1x-1xx 个元素,它们被分成了几段?” 学生:“分成了 j1j-1 段,也就是 dp[x][j1]dp[x][j-1]。” 教练:“于是,我们要找最优解,转移方程是不是就呼之欲出了?” 👉 引导写出方程:$dp[i][j] = \max/\min \{ dp[x][j-1] + \text{cost}(x, i-1) \}$

第二步:边界与循环范围分析 教练:“xx 可以随便取吗?要想凑齐前 j1j-1 段,左边至少得留几个元素?” 学生:“至少留 j1j-1 个元素(每段至少1个)。” 教练:“对!所以内层循环 xx 的起点应该是 j1j-1。把你草稿纸上的循环嵌套写下来。”

第三步:时间与空间复杂度分析 (核心思考) 教练:“仔细看你写的三层循环:ii 遍历 NNjj 遍历 KKxx 遍历 NN。所以状态数是 O(NK)O(NK),每个状态转移需要 O(N)O(N)。加上如果你每次用 for 循环去算 cost(x, i-1),总时间复杂度是多少?” 学生:“O(N3×K)O(N^3 \times K),太慢了。” 👉 时间复杂度优化建议:“看这个 cost 计算,是不是区间求和/均值?提前做一个前缀和数组,把这步变成 O(1)O(1)。这样时间复杂度瞬间降到 O(N2×K)O(N^2 \times K)!”

教练:“空间呢?存 dp[n+1][k+1]dp[n+1][k+1] 需要 O(N×K)O(N \times K)。如果 NN 很大,内存会爆。观察你的方程,计算第 jj 列的 dpdp 值时,是不是只用到了第 j1j-1 列的旧值?” 👉 空间复杂度优化建议:“这就像打通关游戏,你只需要保留‘上一关’的存档。用一维数组 dp[i]dp[i] 滚动更新。但千万注意,为了防止旧数据被覆盖,内层遍历 ii 的时候,必须从后往前(降序)遍历。在纸上画个一维格子,模拟一下从前更新和从后更新的区别,你就明白为什么要逆序了。”


4、总结此类题型读题时理解题意的关键词

在考场上,当你读到以下关键词组合时,你的雷达就应该立刻响起来:“这极有可能是 O(N2K)O(N^2 K) 的一维数组切分 DP 问题!”

  1. “连续” (Contiguous) / “保持相对顺序”:这是大前提。如果是随便挑元素分组,那是背包或状态压缩DP;只要是“连续段”、“连续子数组”,一定是在做数组切割。
  2. “划分/分割” (Partition / Split):动作的明确指示。
  3. “恰好 KK 个” (Exactly KK) 或 “最多 KK 个” (At most KK)
    • 看到“恰好”,最终答案直接锁定 dp[n][k]dp[n][k]
    • 看到“最多”,说明答案要在 dp[n][1],dp[n][2]dp[n][k]dp[n][1], dp[n][2] \dots dp[n][k] 之间取一个最值。
  4. “最大化...的总和” / “最小化...的最大值”:这决定了你状态转移方程外层套的是 max() 还是 min(),同时也决定了你的初始“无效值”应该设为 -INF(最大化问题)还是 +INF(最小化问题)。
  5. 数据范围:这是最重要的暗示!留意 NNKK 的大小。如果 N1000,K100N \le 1000, K \le 100,暗示算法复杂度是 O(N2K)O(N^2 K),完美契合此 DP 模型。如果 NN 达到 10510^5,则说明此 DP 会 TLE,必须要结合二分答案(Binary Search)或单调队列优化了。

第一题

这道题是整个“数组连续划分”DP模型的标准模板题,对应LeetCode813。

作为你的教练,我不会给你直接能 AC 的代码,而是通过流程图和详尽的理论框架,逼迫你自己去推导和实现。请准备好草稿纸,仔细阅读以下内容!


题目一:最大平均值和的分组 (Largest Sum of Averages)

【题目描述】

给定一个包含 NN 个正整数的数组 AA,以及一个正整数 KK。 你需要将数组 AA 恰好划分为 KK非空且连续的子数组。 我们定义一个划分方案的“得分”为:这 KK 个子数组的平均值之和。 请你设计一个算法,求出所有合法划分方案中,能够获得的最大得分。

【输入格式】

从标准输入读入数据。 第一行包含两个正整数 NNKK,分别表示数组的长度和需要划分的段数。 第二行包含 NN 个正整数 A1,A2,,ANA_1, A_2, \dots, A_N,相邻两个数之间用空格隔开,表示数组的元素。

【输出格式】

输出到标准输出。 输出一个实数,表示能获得的最大平均值之和。你的输出与标准答案的绝对误差或相对误差在 10510^{-5} 以内即被视为正确。(建议输出保留 66 位小数)。

【样例输入 1】

5 3
9 1 2 3 9

【样例输出 1】

20.000000

【样例说明】

最优的划分方案是将数组分为:[9][1, 2, 3][9]。 它们的平均值分别为 9.09.02.02.09.09.0。 总得分为 9.0+2.0+9.0=20.09.0 + 2.0 + 9.0 = 20.0

【样例输入 2】

7 4
1 2 3 4 5 6 7

【样例输出 2】

20.500000

【数据范围与提示】

  • 1N1001 \le N \le 100
  • 1KN1 \le K \le N
  • 1Ai1041 \le A_i \le 10^4
  • 保证所有计算过程中的数据不会超过 C++ 中 double 类型的表示范围。

💡 教练的赛场指引 (涵盖讲义全部知识点)

这道题是构建你的一维划分 DP 知识树的“地基”。请在写代码前,在草稿纸上对照以下几点进行推演:

1. 核心概念 (Core Concepts)

  • 状态定义 (State Definition):定义 dp(i, j) 表示将数组的前 i 个元素(即从前缀 1i恰好划分为 j 个连续子数组所能获得的最大得分。我们的最终目标是求 dp(N, K)
  • 状态转移逻辑 (Transition Logic):考虑第 j 个(最后一个)子数组。假设它的起点是 x,那么它包含的元素是 AxAiA_x \dots A_i。前面剩余的 x-1 个元素必须恰好被分成 j-1 段。 转移方程:$dp(i, j) = \max_{j \le x \le i} \{ dp(x-1, j-1) + \text{cost}(x, i) \}$ 注意:为了防止越界,划分 j1j-1 段至少需要 j1j-1 个元素,所以 x1j1x-1 \ge j-1,即 xjx \ge j

2. 初始化与边界情况 (Base Cases & Edge Cases)

  • 起点 (The Starting Point)dp(0, 0) = 0。这是逻辑原点,0个元素分成0段代价为0。
  • 不可达状态 (Impossible States):对于所有 j>ij > i(例如将3个元素分成4段),或者 dp(0, j)j>0j>0),这些在逻辑上是不可能的。因为是求最大值,必须将这些状态初始化为负无穷(如 -1e9),确保它们在状态转移时永远不会被选中。
  • 单一划分 (Single Partition)dp(i, 1) 是后续计算的基础构建块。它表示把前 i 个元素作为一整个段,其值就是前 i 个元素的平均值。
  • 极值特判 (Edge Cases)
    • K=1K=1 时,答案就是整个数组的平均值 dp(N, 1)
    • K=NK=N 时,每个元素各自成一段,答案就是数组所有元素的总和。

3. 性能优化 (Performance Optimization)

  • 更快的开销计算 (Faster Cost Calculation):在最内层循环计算 cost(x,i)\text{cost}(x, i) 时,如果用 for 循环累加,时间复杂度将退化为 O(N3×K)O(N^3 \times K)必须预处理出前缀和数组 prefix(i),这样任意区间和就可以用 O(1)O(1) 的查表 prefix(i) - prefix(x-1) 替代,将整体时间复杂度降维打击至 O(N2×K)O(N^2 \times K)
  • 精简 DP 表 (Slimming Down the DP Table - 空间优化)
    • 仔细观察方程:计算第 j 列的数据只依赖于第 j-1 列的数据。
    • 一维滚动数组:我们可以将二维数组压缩为一维 dp(i)
    • 致命陷阱:在使用一维数组时,内层关于 i 的循环必须从大到小(逆序)遍历。如果正序遍历,你在计算 dp(i) 时用到的 dp(x-1) 可能已经是当前第 j 轮更新过的新值,而不是上一轮 j-1 的旧值!(空间复杂度从 O(N×K)O(N \times K) 优化为 O(N)O(N))。

4. 常见错误清单 (Common Mistakes)

  • 差一错误 (Off-by-One Errors):数组索引(0-based)与 DP 状态(1-based 长度)极易混淆。强烈建议在原数组前补一个无效元素(如 0),统一使用 1 索引,让 prefix(i)dp(i, j) 中的 i 完美对齐。
  • 浮点数精度 (Floating Point Precision):计算平均值涉及除法,在 C++ 中请务必将 DP 数组、前缀和数组声明为 double 类型。如果在除法时使用整数,会被向下取整导致严重精度丢失!

📐 算法逻辑伪代码 (C++14 风格流程设计)

教练为你准备了标准三重嵌套循环的逻辑流程图。请在草稿纸上跟着流程图走一遍,然后再尝试用一维滚动数组去实现它!

(注:流程图中的 dp(i, j) 代表前 ii 个元素分 jj 段,prefix(i) 代表前 ii 个元素的和)

graph TD
    A(开始算法) --> B(初始化 double 类型的 PrefixSum 数组)
    B --> C(计算前缀和: prefix_i = prefix_i-1 + nums_i)
    C --> D(初始化二维 dp 数组: 全部填入 -1e9 代表负无穷)
    
    D --> E(处理基准情况: Base Cases)
    E --> F(循环 i 从 1 到 N: dp_i_1 = prefix_i / i)
    
    F --> G(外层循环 j: 划分段数从 2 到 K)
    G --> H{j <= K ?}
    H -- 否: 循环结束 --> Z(返回最终答案 dp_N_K)
    
    H -- 是 --> I(中层循环 i: 数组前缀长度从 j 到 N)
    I --> J{i <= N ?}
    
    J -- 否: i 循环结束 --> G1(j 增加 1)
    G1 --> H
    
    J -- 是 --> K(内层循环 x: 最后一个分段的起点从 j 到 i)
    K --> L{x <= i ?}
    
    L -- 否: x 循环结束 --> I1(i 增加 1)
    I1 --> J
    
    L -- 是 --> M(计算最后一段的平均值 avg)
    M --> N(avg = prefix_i 减去 prefix_x-1 之后, 除以区间长度)
    N --> O(状态转移: new_val = dp_x-1_j-1 + avg)
    O --> P(更新最优解: dp_i_j 取 dp_i_j 和 new_val 的最大值)
    P --> K1(x 增加 1)
    K1 --> L

教练的最后叮嘱: 把流程图看懂后,请打开你的 IDE。不要照抄流程图,尝试闭上眼睛回忆:

  1. K 层循环在最外层还是最内层?
  2. 内层分割点 x 的上下界到底是多少?为什么?
  3. 如果我要把代码里的二维 dp 数组删掉,只用一维 vector<double> dp(N + 1),我的中层循环 i 该怎么写?

等你把这道题彻底 AC,并且能手写出 O(N)O(N) 空间复杂度的代码后,我们就进入变体二:当题目要求“最多划分 K 次”时,代码该作何改动。加油!