#19484. 最大平均值和的分组
最大平均值和的分组
你好!我是你的OI教练。这份讲义非常系统地讲解了 “将数组划分为K个连续子数组”的经典一维DP模型。
从这份教学材料的思路来看,它是按照“基础模型构建 边界与约束处理 复杂度优化 综合实战”的逻辑层层递进的。基于这个教学思路,我们可以提取出以下4道典型例题形成一套循序渐进的题单,为你接下来的训练做好准备:
📋 提取的例题清单 (对应材料的不同章节)
- 【标准模板题】最大平均值和的分组 (对应 LeetCode 813)
- 特征:恰好划分 个非空子数组,求特定代价(平均值)之和的最大值。
- 【变体一:数量约束】最多划分 K 次的最大/最小收益
- 特征:要求划分成“最多 个”子数组,考察最终答案收集域的变化。
- 【变体二:长度约束】附带最小长度限制的 K 次划分
- 特征:每个子数组至少包含 个元素,考察内层状态转移(分割点 )的边界推导。
- 【变体三:代价替换】分割数组的最大值最小化 (如 LeetCode 410)
- 特征:代价函数变为“求区间和的最大值”,外层求最小值,考察状态转移方程中
max/min算子的灵活替换。
- 特征:代价函数变为“求区间和的最大值”,外层求最小值,考察状态转移方程中
下面我将严格按照你的要求,为你进行针对这套题型的教学引导。
1、输入题目的思路提示 (不提供完整解答)
在面对上述题单中的问题时,不要急于写代码,先问自己以下几个问题来打通思路:
- 状态的基石是什么? 我们的操作是“切分”,并且是有顺序的。要切分前 个元素,你不仅需要知道当前切到了哪里,还需要知道“这是第几刀”。尝试用二维状态 来描述。
- 寻找最后一块拼图(状态转移): 假设你已经切好了前 块,现在要切第 块(也就是最后一块)。这最后一块的起点在哪里?假设起点是 ,那么这最后一块包含的元素范围是什么?前 块包含的元素范围又是什么?
- 如何做选择(最优化): 既然起点 是未知的,它可能在哪些位置?你应该遍历哪些 来寻找最优解?
- “不可能”的状态怎么处理? 把 个元素切成 段可能吗?这种状态在求最大值或最小值时,应该被赋予什么特殊值,才不会在后续的比较中被错误地选中?
2、预备知识点总结
在完美解决此类问题前,你需要掌握以下OI基础知识点:
- 动态规划核心三要素:状态定义(明确维度的物理意义)、状态转移方程(寻找子问题之间的递推关系)、边界条件(Base Cases与无效状态隔离)。
- 前缀和技巧 (Prefix Sum):能够利用 的预处理,在 的时间复杂度内求出任意连续子数组 的区间和。
- 滚动数组与空间优化:理解多维 DP 中“当前状态仅依赖上一层状态”的特性,并掌握如何通过逆向遍历(倒序枚举)将二维 DP 降维压缩至一维数组。
- 极端值处理 (Infinity values):在 C++ 中熟练使用
INT_MAX,INT_MIN或1e9等大数来初始化不可达状态。
3、启发式与图形式的草稿纸推演教学
(假设我们现在面对面,你拿着纸笔,我来引导你画图)
第一步:画出数组,具象化“最后一次切分” 教练:“来,在草稿纸上画一个长条代表数组。我们在末尾标上索引 。现在我们的目标是把前面这 个元素分成 个段。” 教练:“最核心的突破口在于最后一段。请你在中间随便画一条竖线,标上 。”
数组原貌: [0 ........................................ i-1]
切一刀 x:[0 ...... x-1] | [x .................... i-1]
\__ 前 j-1 段__/ \______ 第 j 段 (最后一段)___/
教练:“观察这个图。最后一段是从哪到哪?代价怎么算?”
学生:“从 到 。代价就是 cost(x, i-1)。”
教练:“非常好!那么左边那部分呢?它包含了 到 共 个元素,它们被分成了几段?”
学生:“分成了 段,也就是 。”
教练:“于是,我们要找最优解,转移方程是不是就呼之欲出了?”
👉 引导写出方程:$dp[i][j] = \max/\min \{ dp[x][j-1] + \text{cost}(x, i-1) \}$
第二步:边界与循环范围分析 教练:“ 可以随便取吗?要想凑齐前 段,左边至少得留几个元素?” 学生:“至少留 个元素(每段至少1个)。” 教练:“对!所以内层循环 的起点应该是 。把你草稿纸上的循环嵌套写下来。”
第三步:时间与空间复杂度分析 (核心思考)
教练:“仔细看你写的三层循环: 遍历 , 遍历 , 遍历 。所以状态数是 ,每个状态转移需要 。加上如果你每次用 for 循环去算 cost(x, i-1),总时间复杂度是多少?”
学生:“,太慢了。”
👉 时间复杂度优化建议:“看这个 cost 计算,是不是区间求和/均值?提前做一个前缀和数组,把这步变成 。这样时间复杂度瞬间降到 !”
教练:“空间呢?存 需要 。如果 很大,内存会爆。观察你的方程,计算第 列的 值时,是不是只用到了第 列的旧值?” 👉 空间复杂度优化建议:“这就像打通关游戏,你只需要保留‘上一关’的存档。用一维数组 滚动更新。但千万注意,为了防止旧数据被覆盖,内层遍历 的时候,必须从后往前(降序)遍历。在纸上画个一维格子,模拟一下从前更新和从后更新的区别,你就明白为什么要逆序了。”
4、总结此类题型读题时理解题意的关键词
在考场上,当你读到以下关键词组合时,你的雷达就应该立刻响起来:“这极有可能是 的一维数组切分 DP 问题!”
- “连续” (Contiguous) / “保持相对顺序”:这是大前提。如果是随便挑元素分组,那是背包或状态压缩DP;只要是“连续段”、“连续子数组”,一定是在做数组切割。
- “划分/分割” (Partition / Split):动作的明确指示。
- “恰好 个” (Exactly ) 或 “最多 个” (At most ):
- 看到“恰好”,最终答案直接锁定 。
- 看到“最多”,说明答案要在 之间取一个最值。
- “最大化...的总和” / “最小化...的最大值”:这决定了你状态转移方程外层套的是
max()还是min(),同时也决定了你的初始“无效值”应该设为-INF(最大化问题)还是+INF(最小化问题)。 - 数据范围:这是最重要的暗示!留意 和 的大小。如果 ,暗示算法复杂度是 ,完美契合此 DP 模型。如果 达到 ,则说明此 DP 会 TLE,必须要结合二分答案(Binary Search)或单调队列优化了。
第一题
这道题是整个“数组连续划分”DP模型的标准模板题,对应LeetCode813。
作为你的教练,我不会给你直接能 AC 的代码,而是通过流程图和详尽的理论框架,逼迫你自己去推导和实现。请准备好草稿纸,仔细阅读以下内容!
题目一:最大平均值和的分组 (Largest Sum of Averages)
【题目描述】
给定一个包含 个正整数的数组 ,以及一个正整数 。 你需要将数组 恰好划分为 个非空且连续的子数组。 我们定义一个划分方案的“得分”为:这 个子数组的平均值之和。 请你设计一个算法,求出所有合法划分方案中,能够获得的最大得分。
【输入格式】
从标准输入读入数据。 第一行包含两个正整数 和 ,分别表示数组的长度和需要划分的段数。 第二行包含 个正整数 ,相邻两个数之间用空格隔开,表示数组的元素。
【输出格式】
输出到标准输出。 输出一个实数,表示能获得的最大平均值之和。你的输出与标准答案的绝对误差或相对误差在 以内即被视为正确。(建议输出保留 位小数)。
【样例输入 1】
5 3
9 1 2 3 9
【样例输出 1】
20.000000
【样例说明】
最优的划分方案是将数组分为:[9],[1, 2, 3],[9]。
它们的平均值分别为 ,,。
总得分为 。
【样例输入 2】
7 4
1 2 3 4 5 6 7
【样例输出 2】
20.500000
【数据范围与提示】
- 保证所有计算过程中的数据不会超过 C++ 中
double类型的表示范围。
💡 教练的赛场指引 (涵盖讲义全部知识点)
这道题是构建你的一维划分 DP 知识树的“地基”。请在写代码前,在草稿纸上对照以下几点进行推演:
1. 核心概念 (Core Concepts)
- 状态定义 (State Definition):定义
dp(i, j)表示将数组的前i个元素(即从前缀1到i)恰好划分为j个连续子数组所能获得的最大得分。我们的最终目标是求dp(N, K)。 - 状态转移逻辑 (Transition Logic):考虑第
j个(最后一个)子数组。假设它的起点是x,那么它包含的元素是 。前面剩余的x-1个元素必须恰好被分成j-1段。 转移方程:$dp(i, j) = \max_{j \le x \le i} \{ dp(x-1, j-1) + \text{cost}(x, i) \}$ 注意:为了防止越界,划分 段至少需要 个元素,所以 ,即 。
2. 初始化与边界情况 (Base Cases & Edge Cases)
- 起点 (The Starting Point):
dp(0, 0) = 0。这是逻辑原点,0个元素分成0段代价为0。 - 不可达状态 (Impossible States):对于所有 (例如将3个元素分成4段),或者
dp(0, j)(),这些在逻辑上是不可能的。因为是求最大值,必须将这些状态初始化为负无穷(如-1e9),确保它们在状态转移时永远不会被选中。 - 单一划分 (Single Partition):
dp(i, 1)是后续计算的基础构建块。它表示把前i个元素作为一整个段,其值就是前i个元素的平均值。 - 极值特判 (Edge Cases):
- 当 时,答案就是整个数组的平均值
dp(N, 1)。 - 当 时,每个元素各自成一段,答案就是数组所有元素的总和。
- 当 时,答案就是整个数组的平均值
3. 性能优化 (Performance Optimization)
- 更快的开销计算 (Faster Cost Calculation):在最内层循环计算 时,如果用
for循环累加,时间复杂度将退化为 。必须预处理出前缀和数组prefix(i),这样任意区间和就可以用 的查表prefix(i) - prefix(x-1)替代,将整体时间复杂度降维打击至 。 - 精简 DP 表 (Slimming Down the DP Table - 空间优化):
- 仔细观察方程:计算第
j列的数据只依赖于第j-1列的数据。 - 一维滚动数组:我们可以将二维数组压缩为一维
dp(i)。 - 致命陷阱:在使用一维数组时,内层关于
i的循环必须从大到小(逆序)遍历。如果正序遍历,你在计算dp(i)时用到的dp(x-1)可能已经是当前第j轮更新过的新值,而不是上一轮j-1的旧值!(空间复杂度从 优化为 )。
- 仔细观察方程:计算第
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) 代表前 个元素分 段,prefix(i) 代表前 个元素的和)
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。不要照抄流程图,尝试闭上眼睛回忆:
K层循环在最外层还是最内层?- 内层分割点
x的上下界到底是多少?为什么? - 如果我要把代码里的二维
dp数组删掉,只用一维vector<double> dp(N + 1),我的中层循环i该怎么写?
等你把这道题彻底 AC,并且能手写出 空间复杂度的代码后,我们就进入变体二:当题目要求“最多划分 K 次”时,代码该作何改动。加油!