




GESP C++ 六级编程题第一题:《数组划分》
一、故事时间:魔法师的数组切割任务
想象一下,你是一名魔法师。
面前有一排数字:
1 2 -3 4
你的任务是把它们切割成若干个非空的连续子段。
什么叫连续子段呢?
例如,我们可以这样切:
1 | 2 -3 | 4
这就分成了三个子段:
-
第一段:
1 -
第二段:
2 -3 -
第三段:
4
注意两个关键词:
关键词1:连续。
不能把不相邻的数字随意放到同一段中。
例如,不能把 1 和 -3 单独挑出来组成一段,因为它们中间还有数字 2。
关键词2:非空。
每一段至少要有一个数字,不能切出空段。
那么,切割之后,我们应该怎样评价这个方案呢?
这就要看看题目规定的计分规则了。
二、理解计分规则:每一段的和,要平方!
题目规定:
一个子段的偏差值 = 这个子段中所有整数之和的平方。
整个划分方案的偏差值,就是所有子段偏差值加起来。
举个例子。
对于数组:
1 2 -3 4
我们尝试一种切割方法:
1 | 2 -3 | 4
逐段计算:
| 子段 | 子段内数字之和 | 偏差值(和的平方) |
|---|---|---|
1 |
1 | 1² = 1 |
2 -3 |
-1 | (-1)² = 1 |
4 |
4 | 4² = 16 |
所以,这种划分方案的总偏差值为:
1+1+16=18
但是,18 不一定是最小值!
我们还可以尝试其他切法。
例如:
1 2 -3 | 4
第一段的和为:
1+2−3=0
第一段的偏差值为:
0^2 = 0
第二段的偏差值为:
4^2=16
总偏差值为:
0+16=16
这比刚才的 18 小。
那么,能不能找到更小的呢?
这就是本题真正的任务:
尝试所有合法的连续划分方案,找出偏差值最小的那个。
不过,如果我们每次都把所有切法列出来,可能会产生大量重复计算。
因此,我们需要请出今天的主角------动态规划!
三、第一步:把大问题拆成小问题
我们先问自己一个问题:
如果已经知道前面一部分数字的最优答案,能不能利用它,计算出后面数字的最优答案?
答案是:可以!
我们定义:
dpi = 前 i 个数字划分后的最小偏差值
例如:
数组:1 2 -3 4
下标:1 2 3 4
那么:
-
dp[1]:数字1的最小偏差值。 -
dp[2]:数字1、2的最小偏差值。 -
dp[3]:数字1、2、-3的最小偏差值。 -
dp[4]:整个数组的最小偏差值。
最终,我们要求的答案就是:
dpn
这就像闯关游戏:
-
第一关解决前 1 个数字。
-
第二关解决前 2 个数字。
-
第三关解决前 3 个数字。
-
......
-
最后一关解决全部数字。
每一关都可以借助前面已经计算好的结果。
四、第二步:寻找状态转移方程
这是整道题最关键的地方!
假设我们现在要计算 dp[i]。
也就是说,我们要解决前 i 个数字的最优划分问题。
我们可以想象:
最后一段究竟从哪里开始?
假设最后一段从第 j+1 个数字开始,一直延伸到第 i 个数字。
那么数组就被分成了两部分:
前面已经划分好的部分 | 最后一段
1 2 ... j | j+1 ... i
其中:
-
前面
j个数字的最小偏差值是dp[j]。 -
最后一段包含第
j+1到第i个数字。 -
最后一段的偏差值,就是这些数字之和的平方。
所以:
但是,最后一段的起点并不固定!
它可以从第 1 个数字开始,也可以从第 2 个数字开始,还可以从第 3 个数字开始......
因此,我们要让 j 从 0 一直枚举到 i-1。
每一种 j 都代表一种可能的最后一段。
我们把所有方案都比较一遍,取其中的最小值:
这就是本题的核心状态转移方程!
用小学生能理解的话再说一遍
假设你已经站在第 i 个数字的位置。
你可以问:
"如果最后一段只包含第
i个数字,答案是多少?"
接着问:
"如果最后一段包含第
i-1、第i个数字呢?"
再接着问:
"如果最后一段包含第
i-2、第i-1、第i个数字呢?"
......
把这些可能性全部计算出来,选择总偏差值最小的方案。
动态规划的智慧,就是把一个大问题拆成已经解决的小问题,再从所有可能的选择中找到最优答案。
五、第三步:前缀和闪亮登场!
刚才的状态转移方程有一个小麻烦:
每次计算最后一段的数字之和,都要把这一段的数字重新加一遍。
如果数组很长,这样会浪费很多时间。
有没有办法快速求出任意一段的和呢?
当然有!
这就需要使用我们以前学习过的知识:前缀和。
1. 什么是前缀和?
定义:
也就是前 i 个数字的总和。
例如:
原数组 a:
下标: 1 2 3 4
数字: 1 2 -3 4
计算前缀和:
| i | ai | prei |
|---|---|---|
| 0 | --- | 0 |
| 1 | 1 | 1 |
| 2 | 2 | 3 |
| 3 | -3 | 0 |
| 4 | 4 | 4 |
注意:
pre0=0
这是一个非常重要的边界条件。
2. 如何利用前缀和求区间和?
假设我们要求第 j+1 个数字到第 i 个数字的和。
我们可以用:
为什么?
因为:
而:
两者相减,前面的数字就全部抵消了!
最后剩下:
因此,原来的状态转移方程就可以改写成:
这样,每次计算最后一段的和,只需要一次减法!
六、第四步:用例子手动推导 DP 数组
我们继续使用:
a = [1, 2, -3, 4]
前缀和为:
pre[0] = 0
pre[1] = 1
pre[2] = 3
pre[3] = 0
pre[4] = 4
第 1 关:计算 dp1
只有一个数字 1。
只能划分为:
1
因此:
dp1=1^2=1
第 2 关:计算 dp2
现在有:
1 2
最后一段有两种选择。
选择一:最后一段从第 2 个数字开始。
1 | 2
偏差值:
选择二:最后一段从第 1 个数字开始。
1 2
偏差值:
取最小值:
第 3 关:计算 dp3
现在有:
1 2 -3
最后一段可以有三种选择。
| j | 前面部分 | 最后一段 | 总偏差值 |
|---|---|---|---|
| 0 | 空 | 1 2 -3 |
0 |
| 1 | 1 |
2 -3 |
2 |
| 2 | 1 2 |
-3 |
14 |
所以:
这里出现了一个很有意思的现象!
因为:
1+2−3=0
所以把前三个数字放在同一段,偏差值就是:
0^2=0
第 4 关:计算 dp4
现在要处理整个数组:
1 2 -3 4
我们枚举最后一段的起点:
| j | 前面部分的最优值 | 最后一段 | 总偏差值 |
|---|---|---|---|
| 0 | 0 | 1 2 -3 4 |
16 |
| 1 | 1 | 2 -3 4 |
10 |
| 2 | 5 | -3 4 |
6 |
| 3 | 0 | 4 |
16 |
取最小值:
对应的划分方案是:
1 | 2 | -3 4
各段偏差值为:
所以这个例子的最小偏差值为 6。
七、第五步:把算法变成 C++ 程序
理解了故事、前缀和与状态转移方程之后,我们就可以开始写代码了!
参考程序:
#include <iostream>
#include <algorithm>
using namespace std;
const int N = 2005;
int n;
int a[N];
long long pre[N];
long long dp[N];
int main() {
cin >> n;
// 第一步:读入数组,并计算前缀和
for (int i = 1; i <= n; i++) {
cin >> a[i];
pre[i] = pre[i - 1] + a[i];
}
// 第二步:动态规划
dp[0] = 0;
for (int i = 1; i <= n; i++) {
// 假设最后一段从第1个数字开始
dp[i] = (pre[i] - pre[0]) * (pre[i] - pre[0]);
// 枚举最后一段之前有多少个数字
for (int j = 1; j < i; j++) {
long long sum = pre[i] - pre[j];
dp[i] = min(dp[i], dp[j] + sum * sum);
}
}
// 第三步:输出答案
cout << dp[n] << endl;
return 0;
}
八、逐段理解代码
1. 为什么要定义前缀和数组?
long long pre[N];
它就像一本已经记录好答案的账本。
pre[i] 记录前 i 个数字的总和。
以后想知道一段数字的和,就不用重新累加了。
2. 为什么要定义 dp 数组?
long long dp[N];
它是我们的闯关记录表。
dp[i] 记录前 i 个数字能够获得的最小偏差值。
注意,这里使用 long long,是为了给较大的数值留出更充足的存储范围。
3. 为什么外层循环从 1 开始?
for (int i = 1; i <= n; i++)
因为我们要依次计算:
dp[1]
dp[2]
dp[3]
...
dp[n]
在计算 dp[i] 时,之前的 dp[0] 到 dp[i-1] 都已经计算好了。
这正是动态规划的计算顺序。
4. 为什么内层循环从 1 开始?
for (int j = 1; j < i; j++)
j 表示最后一段之前有多少个数字。
当 j=0 时,意味着整个数组都属于最后一段。
我们已经通过下面这行代码处理了这种情况:
dp[i] = (pre[i] - pre[0]) * (pre[i] - pre[0]);
因此,内层循环只需要考虑 j=1 到 i-1。
5. 为什么要使用 min?
dp[i] = min(dp[i], dp[j] + sum * sum);
因为我们要找的是最小偏差值。
每发现一种新的划分方案,就把它和目前的最优答案进行比较。
如果新方案更小,就更新答案。
九、算法复杂度分析
假设数组长度为 n。
1、时间复杂度
前缀和只需要遍历一次数组:
O(n)
动态规划中:
-
外层循环执行约
n次。 -
内层循环最多执行约
n次。
因此,总时间复杂度为:
2、空间复杂度
我们使用了数组:
-
a[N] -
pre[N] -
dp[N]
每个数组的大小都与 n 成正比。
因此,空间复杂度为:
十、这道题真正考查了什么?
这道题并不是简单地让我们把数组切开,而是考查了几个重要的算法知识。
| 知识点 | 在本题中的作用 |
|---|---|
| 连续子段 | 明确每一段必须由连续数字组成 |
| 前缀和 | 快速计算任意连续区间的数字之和 |
| 动态规划 | 利用已经求出的最优答案解决更大的问题 |
| 枚举 | 枚举最后一段的所有可能起点 |
| 最优化 | 从所有划分方案中选择偏差值最小的方案 |
long long |
为较大的计算结果提供更充足的存储范围 |
特别提醒:负数并不会破坏前缀和
本题中出现了 -3 这样的负数。
有些同学可能会想:
"如果数组中有负数,前缀和还能用吗?"
当然可以!
前缀和并不要求数组中的数字都是正数。
无论数字是正数、负数还是零,都可以使用:
prei−prej
求出对应区间的和。
不过,不能因为数组里有负数,就认为偏差值也可能是负数。
因为偏差值是区间和的平方:
所以每一段的偏差值都不会小于零。
十一、送给同学们的 DP 记忆口诀
遇到类似的连续划分问题,可以记住下面这段口诀:
先定义状态,再寻找最后一段;
前缀和来帮忙,区间求和不用慌;
枚举所有分界点,前后答案加起来;
每种方案都比较,最小答案留下来。
最后,我们再把本题最重要的公式记牢:
这就是《数组划分》的核心。
当同学们学会从"最后一段"思考问题时,就掌握了许多连续划分类动态规划问题的关键思路。