阶段 5 · 动态规划 · 第 26 章提高组 S

区间 DP:石子合并

状态从「前 i 个」换成「一段区间」。填表顺序既不是从左到右也不是从右到左 —— 但这一章真正想让你记住的是:顺序的判据从来不是某个固定写法,而是「依赖谁,就先填谁」。

需要先学:第 21 章 DP 入门:从记忆化到递推例题:石子合并(相邻两堆)建议用时:110 分钟
第 21 章那句话,第四次登场

背包三章的状态都长一个样:前 i 件物品 + 还剩多少容量。 这一章的状态换了个形状 —— 一段区间 f[l][r]。

新东西只有这一件。至于填表顺序,你已经会了,只是自己还不知道:

依赖谁,就先填谁。(第 21 章)

  • 第 21 章数字三角形:下一行要先填 → 所以从下往上;
  • 第 23 章 01 背包:要读「上一轮」的 f[j-w] → 所以倒序;
  • 第 24 章完全背包:要读「这一轮」的 f[j-w] → 所以正序;
  • 第 25 章分组背包:要读「上一组」的 f[j-w] → 所以容量倒序、组内枚举在最里层。

这一章:f[l][r] 要读比它短的区间 → 所以短的先填。

⚠ 但这一章会比前面几章多走一步。前面每一章的结论都是「记住这个写法」, 这一章要把那层壳敲掉:按区间长度枚举并不是唯一正确的写法, 还有一种看着完全不像的写法也是对的 —— 而判据自始至终只有上面那一句。

1一句话问题

n 堆石子排成一排,每次只能合并相邻的两堆,代价是这两堆石子数之和。 求把所有石子合并成一堆的最小总代价。

⚠「相邻」这两个字是这道题的全部难点

把「相邻」去掉,这道题立刻就不难了:任意两堆都能合并的话, 每次挑最小的两堆合起来就是最优解 —— 那是哈夫曼树,有严格证明。

加上「相邻」之后,那个证明就断了:你想把两堆小的凑到一起先合, 可它们中间隔着别的堆,换不过去。

第 20 章那句话在这里第二次兑现:贪心的正确性属于问题,不属于算法。 同一个「先合最小的」,在哈夫曼树上对,在这道题上错 —— 第 10 步会把它按在地上打一次假。

2先用手算一遍:五个数字,贯穿全章

石子: 4 1 2 3 5        (5 堆,一共 15 颗)

先想清楚一件事:不管怎么合,最后那一次合并的代价恒等于 15(把整排并成一堆)。 所以能省的只有前面几步。

  • 正确答案 33:先合 1+2=3,再合 4+3=7,另一边合 3+5=8,最后 7+8=15。 总代价 3 + 7 + 8 + 15 = 33。
  • 贪心(每次合最小的相邻两堆)34:它第一步也合 1+2,第二步就分家了 —— 第 10 步细看。
  • 后面三个数字是三份写错的代码跑出来的,每一个都对应一类典型错误: 15(填表顺序写错)、20(前缀和差一)、35(断点范围差一)。

33 / 34 / 15 / 20 / 35 —— 这五个数后面每一步都会回来验。

3暴力:真的一步步合,(n-1)! 条路径

brute.cpp(n-1)! 枚举合并顺序
// 石子合并 —— 暴力:DFS 枚举「这一步合并哪一对相邻的堆」
//
// ★ 它是**完全不同的思路**,这一点是特意的(第 20 章那条规矩:
// 标准答案最好用完全不同的思路写出来)。
// 这份代码里**没有区间、没有 f 表、没有断点**,它就是老老实实地在合并石子:
// 手里拿着当前这一排堆,挑一对相邻的合掉,付出它们的和,然后接着挑。
// 正解那边则是「枚举最后一次合并的断点」—— 两边连看问题的角度都不一样,
// 所以它们对上了才有说服力。
//
// 复杂度 (n-1)!:第一步有 n-1 对相邻可选,合完剩 n-1 堆,于是又有 n-2 对……
// n = 10 是 362 880 条路径,n = 12 就是 4 千万条 —— 正文第 11 步会实测这条曲线。
//
// ⚠ 这里**故意一句剪枝都不写**(连「已经比当前最优差了就不往下走」都没有)。
// 第 25 章那张耗时对比表差点做废,就是因为暴力里有一句免费的剪枝,
// 把整棵树剪没了、暴力假装自己不慢。要拿它证明「暴力有多慢」,
// 就得让它老老实实走完 (n-1)! 条路径。
//
// 题意:n 堆石子排成一排,每次只能合并**相邻**两堆,代价 = 两堆石子数之和。
// 求合并成一堆的**最小**总代价。
// 输入:第一行 n,第二行 n 个正整数
// 输出:最小总代价
//
// ⚠「只能合并相邻两堆」是这道题的全部难点所在。
// 要是允许任意两堆合并,它就是哈夫曼树,每次挑最小的两堆贪心就对了 ——
// 正文第 8 步会拿这个差别单独打一次假。
#include <bits/stdc++.h>
using namespace std;
long long best;
// piles:当前这一排还剩下的堆;cost:已经付出的代价
void dfs(vector<long long>& piles, long long cost) {
if (piles.size() == 1) { best = min(best, cost); return; }
for (size_t i = 0; i + 1 < piles.size(); i++) {
long long merged = piles[i] + piles[i + 1];
vector<long long> nxt;
nxt.reserve(piles.size() - 1);
for (size_t t = 0; t < piles.size(); t++) {
if (t == i) { nxt.push_back(merged); t++; } // i 和 i+1 并成一堆
else nxt.push_back(piles[t]);
}
dfs(nxt, cost + merged);
}
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
if (!(cin >> n) || n <= 0) return 0;
vector<long long> a(n);
for (auto& x : a) cin >> x;
best = LLONG_MAX;
dfs(a, 0);
cout << best << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

它手里拿着当前这一排堆,挑一对相邻的合掉,然后接着挑。 第一步有 n-1 对可选,合完剩 n-1 堆于是又有 n-2 对…… 一共 (n-1)! 条路径。

跑出来 33,和手算一致。

为什么这份暴力值得留着当标准答案

因为它是完全不同的思路(第 20 章那条规矩)。 这份代码里没有区间、没有 f 表、没有断点,它就是老老实实在合石子。 而正解那边是「枚举最后一次合并的断点」—— 两边连看问题的角度都不一样。

同一个思路写两遍只能验出打字错误,不同思路才能验出想法错误。

⚠ 另外它故意一句剪枝都不写,连「已经比当前最优差了就别往下走」都没有。 第 25 章那张耗时表差点做废,就是因为暴力里有一句免费的剪枝,把整棵树剪没了、 暴力假装自己不慢。要拿它证明「暴力有多慢」,就得让它老老实实走完 (n-1)! 条路径。

4实测:它慢得非常有节奏

同题对比:(n-1)! 枚举合并顺序 vs 区间 DP O(n³)
先跑 11,再改成 12、13。⚠ 变的是堆数 —— 暴力是 (n-1)!,每加一堆就乘以当前堆数。别超过 13。
(n-1)! 枚举合并顺序
区间 DP O(n³)

本机实测(./genBig n,固定种子):

堆数 n (n-1)! 暴力 区间 DP 暴力比上一行慢了几倍
10 0.027 秒 0.002 秒 —
11 0.173 秒 0.001 秒 6.4
12 1.877 秒 0.002 秒 10.8
13 22.538 秒 0.002 秒 12.0

最后一列就是阶乘的样子:每加一堆,暴力乘以当前的堆数。 而 DP 那一列压根没动 —— 它是 n³,从 10 堆到 13 堆只从 1000 涨到 2197 次转移,量都量不出来。

5慢在哪:一样的区间,被重复算了成千上万遍

盯住暴力的搜索树:先合 (1,2) 再合 (4,5),和先合 (4,5) 再合 (1,2) —— 走到这两条路的尽头时,手里的局面一模一样,后面要做的事也一模一样, 可暴力把它们从头到尾各算了一遍。

这正是第 17 章那个「重复子问题」,一个字都没变。于是:

★ 状态怎么定:把「局面」压成一段区间

暴力在递归过程中遇到的每一个局面,都可以由一句话描述完: 第 l 堆到第 r 堆,已经并成了一堆。

所以状态就是它:

f[l][r] = 把第 l 堆到第 r 堆合并成一堆,最少要付多少代价

转移:枚举最后一次合并的断点 k(左边 [l,k] 已经并成一堆、右边 [k+1,r] 也并成一堆):

f[l][r] = min{ f[l][k] + f[k+1][r] }  +  (a[l] + … + a[r])
           k ∈ [l, r-1]

★ 后面那一项和 k 无关:不管怎么分,最后那一次合并总是把整段并成一堆, 代价恒等于这一段石子之和。所以它可以提到 min 外面,用第 6 章的前缀和 O(1) 求出来。

⚠ 而且请把第 6 章那条老约定一起带上:区间类题目一律 1 基下标, 这样这一项才是干干净净的 s[r] - s[l-1]。第 12 步会看到写成 s[r] - s[l] 的下场。

6先写记忆化搜索 —— 它根本不用你操心顺序

memo.cpp记忆化搜索(接第 17、21 章)
// 石子合并 —— 记忆化搜索版(接第 17、21 章)
//
// ★ 这份代码存在的理由只有一个:**它不用你操心填表顺序。**
//
// solve(l, r) 要用 solve(l, k) 和 solve(k+1, r),就直接递归下去要 ——
// 谁先算谁后算,是递归自己安排的,你一个字都不用想。
// 而下一份 fast.cpp(递推)必须由你亲手安排这个顺序,
// 一旦安排错了,它不会报错、不会崩溃,只会安静地读到一格还没算的 0。
//
// 所以这一章的 ★(按区间长度从小到大填)本质上是:
// **把递归替你做的那件事,自己做一遍。**
// 第 21 章「依赖谁,就先填谁」这句话,到这里是第四次登场了。
//
// 复杂度 O(n³):状态 O(n²) 个,每个状态枚举 O(n) 个断点。和递推完全一样。
// 输入输出同 brute.cpp。
//
// ⚠ f 用 -1 表示「还没算过」,不能用 0 —— 长度为 1 的区间答案就是 0,
// 拿 0 当「没算过」的话,那些格子会被反复重算(虽然答案不错,但退化成指数)。
#include <bits/stdc++.h>
using namespace std;
int n;
vector<long long> s; // s[i] = a[1] + … + a[i],1 基下标(第 6 章的前缀和)
vector<vector<long long>> f;
long long solve(int l, int r) {
if (l == r) return 0; // 只剩一堆,不用合,代价 0
if (f[l][r] >= 0) return f[l][r];
long long best = LLONG_MAX;
for (int k = l; k < r; k++) // 枚举最后一次合并的断点:[l,k] 和 [k+1,r]
best = min(best, solve(l, k) + solve(k + 1, r));
// 最后那一次合并,把整个 [l,r] 合成一堆,代价就是这一段石子的总数
return f[l][r] = best + s[r] - s[l - 1];
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
if (!(cin >> n) || n <= 0) return 0;
s.assign(n + 1, 0);
for (int i = 1; i <= n; i++) { long long x; cin >> x; s[i] = s[i - 1] + x; }
f.assign(n + 1, vector<long long>(n + 1, -1));
cout << solve(1, n) << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

跑出来 33。而且注意:这份代码里没有任何「顺序」的痕迹。

solve(l, r) 要用 solve(l, k) 和 solve(k+1, r),就直接递归下去要 —— 谁先算谁后算,是递归自己安排的,你一个字都不用想。

★ 所以这一章的 ★ 到底是什么

把递归替你做的那件事,自己做一遍。

递推没有递归帮忙,你必须亲手安排一个次序,让每一格被填的时候, 它依赖的那些格子都已经填好了。安排错了不会报错、不会崩溃 —— 只会安静地读到一格还没算的 0。

这就是第 21 章「填表顺序由依赖方向决定」的第四次、也是最难的一次应用。

7★ 关键一步:按区间长度从小到大

★ 关键的一步

f[l][r] 要读 f[l][k] 和 f[k+1][r],而这两个区间都比 [l,r] 短。

所以只要按长度从小到大填,依赖就永远在手上:

for (int len = 2; len <= n; len++)              // ★ ① 先枚举区间长度,从短到长
    for (int l = 1; l + len - 1 <= n; l++) {    //   ② 再枚举左端点
        int r = l + len - 1;
        long long best = LLONG_MAX;
        for (int k = l; k < r; k++)             //   ③ 最后枚举断点
            best = min(best, f[l][k] + f[k + 1][r]);
        f[l][r] = best + s[r] - s[l - 1];
    }

长度 1 的那一层(对角线)全是 0 —— 一堆不用合,代价 0。那是整张表的地基。

时间 O(n³):状态 O(n²) 个,每个枚举 O(n) 个断点。空间 O(n²)。

fast.cpp区间 DP 正解:长度 → 左端点 → 断点
// 石子合并 —— 正解:区间 DP,★ 按区间长度从小到大填
//
// 状态:f[l][r] = 把第 l 堆到第 r 堆合并成一堆,最少要付多少代价。
// 转移:枚举**最后一次**合并的断点 k(左边 [l,k] 已经并成一堆、右边 [k+1,r] 也并成一堆):
//
// f[l][r] = min over k in [l, r-1] of ( f[l][k] + f[k+1][r] ) + (a[l] + … + a[r])
//
// 后面那一项和 k 无关 —— 不管怎么分,最后那一次合并总是把整段并成一堆,
// 代价恒等于这一段石子的总数。所以它可以提到 min 外面(第 6 章的前缀和 O(1) 求出来)。
//
// ★ 关键一步:**填表顺序按区间长度从小到大。**
// 因为 f[l][r] 要读 f[l][k] 和 f[k+1][r],这两个区间都比 [l,r] **短**。
// 「依赖谁,就先填谁」(第 21 章)—— 短的先算好,长的才有得读。
//
// ⚠ 注意这不是「从左到右」也不是「从右到左」:
// l 从小到大、r 从小到大地填,f[k+1][r] 那一项就还没算(wrongOrder.cpp 演示了后果)。
// 而 l 从**大**到小、r 从小到大是**对的**(lrOrder.cpp)——
// 长度枚举只是「让依赖先算好」的一种实现方式,不是唯一的那一种。
//
// 复杂度 O(n³)、空间 O(n²)。n = 100 时约 100 万次转移,眨眼就跑完。
// 输入输出同 brute.cpp。
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
if (!(cin >> n) || n <= 0) return 0;
vector<long long> s(n + 1, 0); // 前缀和,1 基下标
for (int i = 1; i <= n; i++) { long long x; cin >> x; s[i] = s[i - 1] + x; }
vector<vector<long long>> f(n + 2, vector<long long>(n + 2, 0));
// 长度为 1 的区间不用合并,代价 0 —— f 初值就是 0,所以不用另写一句
for (int len = 2; len <= n; len++) // ★ ① 先枚举区间长度,从短到长
for (int l = 1; l + len - 1 <= n; l++) { // ② 再枚举左端点
int r = l + len - 1;
long long best = LLONG_MAX;
for (int k = l; k < r; k++) // ③ 最后枚举断点
best = min(best, f[l][k] + f[k + 1][r]);
f[l][r] = best + s[r] - s[l - 1]; // 最后一次合并的代价:整段石子之和
}
cout << f[1][n] << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

还是 33。想看它一层一层长起来的样子,就跑这份:

trace.cpp把整张 f 表按长度分层打出来
// 把整张 f 表**按长度一层一层**打印出来 —— 动画就是照着这个次序播的
//
// 为什么要有它:动画是用 TypeScript 把这个算法重写一遍画出来的。
// 只比最后那个答案是不够的 —— 答案蒙对、中间过程画错,学生一样看不出来。
// 所以这里把每一层(每一个区间长度)的整排 f 值和选中的断点 k 都打出来,
// check:viz 拿它和动画的每一帧**逐格**对照。
// (第 23 章那份 trace.cpp 是同样的用意,那次对的是每一步的整个 f 数组。)
//
// ★ 顺便,这个输出本身就是这一章的 ★ 的样子:
// 一层一层往上长,每一层都只用到**下面那些层**已经填好的值。
// 长度 1 那一层全是 0,是整张表的地基。
//
// 用法:./trace (用正文那组默认数据 4 1 2 3 5)
// ./trace < 数据文件(格式同 brute.cpp)
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
vector<long long> a;
int n;
if (cin >> n && n > 0) {
a.resize(n);
for (auto& x : a) cin >> x;
} else {
a = {4, 1, 2, 3, 5}; // 正文默认数据
n = (int)a.size();
}
vector<long long> s(n + 1, 0);
for (int i = 1; i <= n; i++) s[i] = s[i - 1] + a[i - 1];
cout << "石子:";
for (int i = 0; i < n; i++) cout << (i ? " " : "") << a[i];
cout << "\n\n";
vector<vector<long long>> f(n + 2, vector<long long>(n + 2, 0));
cout << "长度 1 :";
for (int i = 1; i <= n; i++) cout << " f[" << i << "][" << i << "]=0";
cout << " <- 地基:一堆不用合,代价 0\n";
for (int len = 2; len <= n; len++) {
cout << "长度 " << len << " :";
for (int l = 1; l + len - 1 <= n; l++) {
int r = l + len - 1;
long long best = LLONG_MAX;
int bk = l;
for (int k = l; k < r; k++)
if (f[l][k] + f[k + 1][r] < best) { best = f[l][k] + f[k + 1][r]; bk = k; }
f[l][r] = best + s[r] - s[l - 1];
cout << " f[" << l << "][" << r << "]=" << f[l][r] << "(k=" << bk << ")";
}
cout << "\n";
}
cout << "\n答案 f[1][" << n << "] = " << f[1][n] << "\n";
cout << "每一层用到的都只是下面那些层的值 —— 这就是「按区间长度从小到大」的全部含义。\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
长度 1 :  f[1][1]=0  f[2][2]=0  f[3][3]=0  f[4][4]=0  f[5][5]=0        <- 地基
长度 2 :  f[1][2]=5(k=1)  f[2][3]=3(k=2)  f[3][4]=5(k=3)  f[4][5]=8(k=4)
长度 3 :  f[1][3]=10(k=1)  f[2][4]=9(k=3)  f[3][5]=15(k=4)
长度 4 :  f[1][4]=19(k=1)  f[2][5]=20(k=4)
长度 5 :  f[1][5]=33(k=3)

每一层用到的都只是下面那些层的值。 这就是「按区间长度从小到大」的全部含义。 (check:viz 拿这张表和动画逐格对过,包括每一格选中的断点 k,不只比最终答案。)

8★ 关键一步(二):判据是依赖,不是那个写法

几乎所有资料都会告诉你「区间 DP 就是要按长度枚举」。这句话能用,但它说小了。 下面这份代码看着一点都不像区间 DP —— 它是对的:

lrOrder.cpp左端点倒序、右端点正序(它是对的)
// 石子合并 —— 左端点从**大**到小、右端点从小到大。★ 它看着不像区间 DP,但它是对的。
//
// 这份代码存在的理由,和第 25 章那份 vol2In.cpp(外倒内正)一模一样:
// **要让学生知道「顺序」这件事的判据是依赖关系,不是某个固定的写法。**
//
// 拿转移来对一对:f[l][r] 要读 f[l][k](k < r)和 f[k+1][r](k+1 > l)。
//
// · f[l][k]:同一个 l、更小的 r —— 内层 r 正序,这一轮**前面刚算过** ✓
// · f[k+1][r]:更大的 l —— 外层 l 倒序,**上几轮就算完了** ✓
//
// 两个依赖都在手上,所以它给出的答案和按长度枚举**完全一样**。
// check:viz 拿 300 组随机数据钉死了这一条:它一轮都「抓不到」,因为它根本没错。
//
// ⚠ 那为什么正文还是推荐按长度写?
// 因为「短的先算」是一句**不用每次都重新推的**理由,而这份代码的正确性
// 每次都得把上面那两行依赖重新验一遍。**能不动脑子的地方就别动脑子。**
//
// 输入输出同 brute.cpp。
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
if (!(cin >> n) || n <= 0) return 0;
vector<long long> s(n + 1, 0);
for (int i = 1; i <= n; i++) { long long x; cin >> x; s[i] = s[i - 1] + x; }
vector<vector<long long>> f(n + 2, vector<long long>(n + 2, 0));
for (int l = n; l >= 1; l--) // ★ 左端点倒序
for (int r = l + 1; r <= n; r++) { // ★ 右端点正序
long long best = LLONG_MAX;
for (int k = l; k < r; k++)
best = min(best, f[l][k] + f[k + 1][r]);
f[l][r] = best + s[r] - s[l - 1];
}
cout << f[1][n] << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

跑出来还是 33。为什么?还是拿依赖去对:

要读的格子 它在哪 这个顺序下算过没有
f[l][k](k < r) 同一个 l、更小的 r 内层 r 正序,这一轮前面刚算过 ✓
f[k+1][r](k+1 > l) 更大的 l 外层 l 倒序,上几轮就算完了 ✓

两个依赖都在手上,所以它和按长度枚举一模一样。 check:viz 拿 300 组随机数据钉死了这一条:它的「被抓轮数」是 0 / 300 —— 不是数据不够狠,是它根本没错(第 25 章那份 vol2In.cpp 是同一件事)。

反过来,把左端点写成正序:

wrongOrder.cpp✗ 左端点正序(最经典的错法)
// ✗ 错误版本一:左端点从小到大、右端点从小到大 —— 这一章最经典的错法。
//
// 这个写法看上去无比自然(「两重循环嘛,当然都从小到大」),而且**编译没有警告、
// 运行不会崩、答案还很像话** —— 它只是偏小一点点。这正是填表顺序类 bug 的可怕之处
// (第 21 章那份 triWrong.cpp 讲的是同一件事)。
//
// 错在哪:f[l][r] 要读 f[k+1][r],而 k+1 > l ——
// **左端点更大的那些区间,在这个顺序下一个都还没算。**
// 读到的全是初值 0,于是「右半段的合并代价」被当成了免费的。
//
// ⚠ 免费 = 更小,而这道题求的是最小值,所以错误答案**恒 ≤ 正确答案**。
// 一个偏小的答案在样例上很容易蒙对(样例通常很小),
// 300 组随机数据才是它的照妖镜 —— 正文第 12 步那张表。
//
// 输入输出同 brute.cpp。
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
if (!(cin >> n) || n <= 0) return 0;
vector<long long> s(n + 1, 0);
for (int i = 1; i <= n; i++) { long long x; cin >> x; s[i] = s[i - 1] + x; }
vector<vector<long long>> f(n + 2, vector<long long>(n + 2, 0));
for (int l = 1; l <= n; l++) // ✗ 左端点正序
for (int r = l + 1; r <= n; r++) {
long long best = LLONG_MAX;
for (int k = l; k < r; k++)
best = min(best, f[l][k] + f[k + 1][r]); // ← f[k+1][r] 还没算过
f[l][r] = best + s[r] - s[l - 1];
}
cout << f[1][n] << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

跑出来 15 —— 正是这排石子的总数。

★ 第 23、24、25 章那个现象,第六次出现

它不是随机地错,它精确地解了另一道题。两行就能推出来:

  • 这个顺序下 f[k+1][r](左端点更大)一个都还没算,读到的全是 0;
  • 而 k = l 时 f[l][l] 本来就是 0 —— 所以那个 min 恒取到 0。

于是 f[l][r] = s[r] - s[l-1],答案就是石子总数。 换句话说,它解的是「允许一次把任意多个连续的堆合成一堆」的那道题 —— 那道题当然是一口气全合掉最便宜。

check:viz 用 300 组数据钉死了这条恒等式:输出恒等于石子总数,一组不差。

连上前三章,DP 这几章一共钉死了六条这样的恒等式:

章 写错的地方 它其实解了哪道题
23 01 背包写成正序 完全背包
24 完全背包写成倒序 01 背包
25 分组背包组内枚举提到容量外 无视分组的 01 背包
25 分组背包容量写成正序 无视分组的完全背包
25 二维费用外层正序 二维费用的完全背包
26 区间 DP 左端点正序 允许一次合并任意多个连续堆

它们都在说同一件事:顺序不是格式,顺序就是题目本身。

把三种顺序摆在一起,顺便把「错在哪」数出来:

order.cpp三种填表顺序并排跑,附带脏读计数
// 三种填表顺序并排跑成一张表 —— 顺带把「读到了还没算好的格子」这件事**数出来**
//
// 光说「顺序错了会读到还没算的值」是抽象的。这份代码给每一格挂一个 ready 标记,
// 转移时只要读到 ready = false 的格子就计一次数。于是「错在哪」变成了一个**数字**:
//
// · 按区间长度从小到大 → 0 次 ✓
// · 左端点倒序、右端点正序 → 0 次 ✓(它也是对的,见 lrOrder.cpp)
// · 左端点正序、右端点正序 → 一堆 ✗
//
// ★ 而且第三行的答案不是「随机地偏小」,它**恒等于所有石子之和**。
// 两行推理:这个顺序下 f[k+1][r] 永远是 0,而 k = l 时 f[l][l] 也是 0,
// 所以 min 恒取到 0,f[l][r] 就成了 s[r] - s[l-1]。
// 换句话说,它精确地解了另一道题:**「允许一次把任意多个连续的堆合成一堆」**——
// 那道题当然是一口气全合掉最便宜,答案就是总和。
// 第 23、24、25 章一共钉死了五条这样的恒等式,这是第六条。
//
// 用法:./order (用正文那组默认数据 4 1 2 3 5)
// ./order < 数据文件(格式同 brute.cpp)
//
// ⚠ 表格对齐是手数空格数出来的,不是 setw ——
// setw 数的是**字节数**,一个汉字 3 字节却只占 2 格宽(第 25 章踩过)。
// 含中文的那一列这里用 padDisp() 按**显示宽度**补空格。
#include <bits/stdc++.h>
using namespace std;
// 按显示宽度补空格:ASCII 算 1 格,非 ASCII(这里只有汉字)算 2 格
static string padDisp(const string& s, int width) {
int disp = 0;
for (unsigned char c : s) {
if ((c & 0xC0) == 0x80) continue; // UTF-8 续字节,不算宽度
disp += (c < 0x80) ? 1 : 2;
}
return s + string(max(0, width - disp), ' ');
}
enum Order { BY_LEN, L_DOWN, L_UP };
// 返回 {答案, 读到「还没算好的格子」的次数}
static pair<long long, long long> solve(const vector<long long>& a, Order ord) {
int n = (int)a.size();
vector<long long> s(n + 1, 0);
for (int i = 1; i <= n; i++) s[i] = s[i - 1] + a[i - 1];
vector<vector<long long>> f(n + 2, vector<long long>(n + 2, 0));
vector<vector<bool>> ready(n + 2, vector<bool>(n + 2, false));
for (int i = 1; i <= n; i++) ready[i][i] = true; // 长度 1 的区间一开始就是算好的(0)
long long dirty = 0;
auto fill = [&](int l, int r) {
long long best = LLONG_MAX;
for (int k = l; k < r; k++) {
if (!ready[l][k]) dirty++;
if (!ready[k + 1][r]) dirty++;
best = min(best, f[l][k] + f[k + 1][r]);
}
f[l][r] = best + s[r] - s[l - 1];
ready[l][r] = true;
};
if (ord == BY_LEN) {
for (int len = 2; len <= n; len++)
for (int l = 1; l + len - 1 <= n; l++) fill(l, l + len - 1);
} else if (ord == L_DOWN) {
for (int l = n; l >= 1; l--)
for (int r = l + 1; r <= n; r++) fill(l, r);
} else {
for (int l = 1; l <= n; l++)
for (int r = l + 1; r <= n; r++) fill(l, r);
}
return {f[1][n], dirty};
}
int main() {
// ⚠ 这份代码 cout 和 printf 混着用(表格用 printf 好对齐),
// 所以**不能**关掉 ios::sync_with_stdio —— 关掉之后两边各自缓冲,
// 打印出来的顺序会乱掉(表格跑到结语后面去)。这是踩出来的。
vector<long long> a;
int n;
if (cin >> n && n > 0) {
a.resize(n);
for (auto& x : a) cin >> x;
} else {
a = {4, 1, 2, 3, 5}; // 正文默认数据
}
long long total = 0;
for (auto x : a) total += x;
cout << "石子:";
for (size_t i = 0; i < a.size(); i++) cout << (i ? " " : "") << a[i];
cout << " (共 " << a.size() << " 堆,总数 " << total << ")\n\n";
cout << " " << padDisp("填表顺序", 28) << " 答案 读到还没算好的格子 和正解一样\n";
cout << " " << string(28, '-') << " ---- ------------------ ----------\n";
const char* names[] = {"按区间长度从小到大", "左端点倒序、右端点正序", "左端点正序、右端点正序"};
Order ords[] = {BY_LEN, L_DOWN, L_UP};
long long right = solve(a, BY_LEN).first;
for (int i = 0; i < 3; i++) {
auto [ans, dirty] = solve(a, ords[i]);
printf(" %s %4lld %18lld %s\n",
padDisp(names[i], 28).c_str(), ans, dirty, padDisp(ans == right ? "是" : "否", 10).c_str());
}
cout << "\n★ 「读到还没算好的格子」那一列只要不是 0,这个顺序就是错的。\n";
cout << " 而最后一行的答案恒等于所有石子之和(" << total << ")——\n";
cout << " 它不是随机地错,它精确地解了「允许一次合并任意多个连续堆」的那道题。\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
  填表顺序                      答案   读到还没算好的格子   和正解一样
  ----------------------------  ----   ------------------   ----------
  按区间长度从小到大              33                    0   是
  左端点倒序、右端点正序          33                    0   是
  左端点正序、右端点正序          15                   10   否

这份代码给每一格挂了一个「算好了没有」的标记,转移时只要读到没算好的就计一次数。 于是「顺序错了」不再是一句抽象的话,它是一个数字。

⚠ 那为什么正文还是推荐按长度写

因为「短的先算」是一句不用每次都重新推的理由。 而 lrOrder.cpp 的正确性,每次都得把上面那张依赖表重新验一遍。

能不动脑子的地方就别动脑子 —— 但你得知道自己省的是哪一步脑子。 考场上遇到没见过的区间型转移(比如依赖的不是「更短的区间」而是别的东西), 按长度枚举可能就不管用了,那时候能救你的只有「依赖谁,就先填谁」。

9动画:三角形的表,一层一层往上长

★ 区间 DP:长的要读短的,所以短的必须先填
答案 33
第 1 / 12 步
r=1
r=2
r=3
r=4
r=5
l=1
0
·
·
·
·
l=2
0
·
·
·
l=3
0
·
·
l=4
0
·
l=5
0
读到「还没算好」的格子
0
这个顺序对不对
✓ 对的
当前区间长度
—
答案 f[1][5]
…
每一行是左端点 l,每一列是右端点 r,只有右上半张表有意义。 浅绿的对角线是长度 1 的区间(代价 0),那是地基。蓝色 = 正在填的格子, 它的两个来源 f[l][k] 和 f[k+1][r] 会被标成绿色(已经算好)或 红色(还没算好)。 红色一出现,读到的就是初值 0,这一格的答案立刻变成假的。 换成第三档「左端点正序」看一遍,再回来看前两档。
f[l][r] = 把第 l 堆到第 r 堆合成一堆的最小代价。对角线(长度 1)全是 0,那是整张表的地基。现在按「区间长度从小到大」的次序往下填 —— 请盯住每一格的两个来源是不是都已经算好了。

每一行是左端点 l,每一列是右端点 r,只有右上半张表有意义 —— 所以它是个三角形。 浅绿的对角线是长度 1 的区间(代价 0),那是地基。

蓝色 = 正在填的格子,它的两个来源会被标成 绿色(已经算好) 或 红色(还没算好)。

盯住左下角那个计数器「读到还没算好的格子」:

填表顺序 计数器 答案
按区间长度从小到大 0 33
左端点倒序、右端点正序 0 33
左端点正序、右端点正序 10 15

红色一出现,读到的就是初值 0,这一格的答案立刻变成假的 —— 而程序不会有任何反应。这个计数器也参与交叉验证(防止「答案蒙对、过程画错」)。

10★ 打一次假:那个「每次合最小的相邻两堆」的贪心

这是这道题最经典的错误直觉,而且它错得很有来头(第 1 步那个 ⚠)。

greedy.cpp✗ 贪心:每次合并相邻两堆里和最小的一对
// ✗ 错误版本四:贪心 —— 每次合并「相邻两堆里和最小的那一对」。
//
// ★ 这是这道题最经典的错误直觉,而且它错得很有来头:
// **要是允许任意两堆合并(不必相邻),这个贪心就是对的** —— 那就是哈夫曼树,
// 每次挑最小的两堆合起来,是有严格证明的最优解(第 19 章那套交换论证)。
//
// 一旦加上「只能合并相邻两堆」,交换论证就断了:
// 你想把两堆小的换到一起先合,可它们中间隔着别的堆,**换不过去**。
// 第 20 章那句话在这里第二次兑现:
// **贪心的正确性属于问题,不属于算法。** 同一个「先合最小的」,
// 在哈夫曼树上对,在石子合并上错。
//
// 这份代码用来做两件事:
// ① 正文第 8 步拿它单独打一次假(对拍 300 轮,看它错多少轮);
// ② greedyFind.cpp 会枚举出让它失败的**最小反例**,
// 像第 20 章的 coinFind.cpp 一样,把「反例长什么样」这件事变成可看的东西。
//
// 并列时取最左边那一对(任何一种确定的取法都不影响结论,反例照样存在)。
// 输入输出同 brute.cpp。
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
if (!(cin >> n) || n <= 0) return 0;
vector<long long> a(n);
for (auto& x : a) cin >> x;
long long cost = 0;
while (a.size() > 1) {
size_t bi = 0;
long long bs = LLONG_MAX;
for (size_t i = 0; i + 1 < a.size(); i++) // 找和最小的那一对相邻堆
if (a[i] + a[i + 1] < bs) { bs = a[i] + a[i + 1]; bi = i; }
cost += bs;
a[bi] = bs;
a.erase(a.begin() + bi + 1);
}
cout << cost << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

跑出来 34,比正解多 1。多的这 1 是怎么丢的?看动画:

正解的合并顺序 vs「每次合最小的相邻两堆」
正解 33 · 贪心 34
第 1 / 6 步
正解
4
1
2
3
5
✗ 贪心
4
1
2
3
5
正解累计代价
0
贪心累计代价
0
此刻谁便宜
打平
第几次合并
0 / 4
两排石子从同一个起点出发,合并的次数也完全一样,差别只在「先合谁」。 默认数据上请特别注意第 2 步之后那一眼:贪心的累计代价是 9、正解是 10 ——贪心此时是领先的,第 3 步才被反超。 一个每一步都更便宜、最后却更贵的算法,就是这么骗过人的。
同一排石子,上面按正解的方案合,下面按「每次合并相邻两堆里和最小的那一对」合。两边都要合 4 次,最后都会变成一堆 —— 差别只在「先合谁」,以及右边那个累计代价。
★ 请务必看完第 2 步之后那一眼

两排石子从同一个起点出发,合并次数完全一样,差别只在先合谁。默认数据上:

第几次合并 正解累计 贪心累计 谁便宜
1(两边都合 1+2=3) 3 3 打平
2 10 9 ✗ 贪心领先
3 18 19 正解反超
4 33 34 正解赢

贪心的第一步是对的,第二步开始便宜,直到第三步才输。

这就是它这么难被说服的原因:它每一步都挑当时最便宜的那一对, 代价是把两个大堆留到了最后,而最后那几次合并是最贵的。

(这张表里的每一个数字都在 check:viz 里钉成了断言 —— 换了默认数据它就不成立了,那时脚本会立刻变红提醒我把这段重写。)

那这个贪心到底有多错?错得罕见还是错得普遍?别猜,枚举一遍:

greedyFind.cpp枚举小数据,找贪心的最小反例
// 找「每次合并最小的相邻两堆」这个贪心的**最小反例**(第 20 章 coinFind.cpp 的做法)
//
// 为什么值得单独写一份:
// 「这个贪心是错的」是一句空话,**一个具体的反例才是证据**,而且学生能自己动手验。
// 更有用的是它顺带回答了一个问题:**反例到底有多罕见?**
// 如果一万组里才有一组,那你随手造的数据当然抓不住它 —— 这正是第 20 章那张
// 「300 轮里错了几轮」的表想说的事。
//
// 做法:按「先比堆数、再比字典序」的顺序枚举所有小数据,
// 对每一组同时跑区间 DP(正解)和贪心,第一次不相等的就是最小反例。
//
// 用法:./greedyFind [最大堆数=5] [每堆最大石子数=6]
// 输出:每种堆数下的第一个反例、反例总数和占比。
//
// ⚠ 顺序是「堆数从小到大、同堆数按字典序」,所以「第一个」这件事是良定义的、可复现的。
// 第 20 章踩过的教训:找最小反例时如果枚举顺序含糊,两个人跑出来的「最小反例」会不一样。
#include <bits/stdc++.h>
using namespace std;
// 按显示宽度补空格:ASCII 算 1 格,汉字算 2 格。
// ⚠ 不能用 setw / printf 的 %-Ns —— 它们数的是**字节数**,
// 一个汉字 3 字节却只占 2 格宽,含中文的列会歪掉(第 25 章踩过这个坑)。
static string padDisp(const string& s, int width) {
int disp = 0;
for (unsigned char c : s) {
if ((c & 0xC0) == 0x80) continue;
disp += (c < 0x80) ? 1 : 2;
}
return s + string(max(0, width - disp), ' ');
}
// 区间 DP 正解(和 fast.cpp 同一份转移)
static long long best(const vector<long long>& a) {
int n = (int)a.size();
vector<long long> s(n + 1, 0);
for (int i = 1; i <= n; i++) s[i] = s[i - 1] + a[i - 1];
vector<vector<long long>> f(n + 2, vector<long long>(n + 2, 0));
for (int len = 2; len <= n; len++)
for (int l = 1; l + len - 1 <= n; l++) {
int r = l + len - 1;
long long b = LLONG_MAX;
for (int k = l; k < r; k++) b = min(b, f[l][k] + f[k + 1][r]);
f[l][r] = b + s[r] - s[l - 1];
}
return f[1][n];
}
// 贪心:每次合并相邻两堆里和最小的那一对(和 greedy.cpp 同一份逻辑)
static long long greedy(vector<long long> a) {
long long cost = 0;
while (a.size() > 1) {
size_t bi = 0;
long long bs = LLONG_MAX;
for (size_t i = 0; i + 1 < a.size(); i++)
if (a[i] + a[i + 1] < bs) { bs = a[i] + a[i + 1]; bi = i; }
cost += bs;
a[bi] = bs;
a.erase(a.begin() + bi + 1);
}
return cost;
}
int main(int argc, char** argv) {
int maxN = (argc > 1) ? atoi(argv[1]) : 5;
int maxV = (argc > 2) ? atoi(argv[2]) : 6;
cout << "枚举堆数 3 ~ " << maxN << ",每堆石子数 1 ~ " << maxV
<< "(按字典序),找贪心「每次合并最小的相邻两堆」的反例\n\n";
cout << " 堆数 枚举组数 反例组数 占比 最小反例(字典序最小) 贪心 / 正解\n";
cout << " ---- -------- -------- ------ ---------------------- -----------\n";
for (int n = 3; n <= maxN; n++) {
vector<long long> a(n, 1);
long long total = 0, bad = 0;
vector<long long> firstBad;
long long fg = 0, fb = 0;
while (true) {
total++;
long long g = greedy(a), b = best(a);
if (g != b) {
bad++;
if (firstBad.empty()) { firstBad = a; fg = g; fb = b; }
}
// 字典序 +1
int i = n - 1;
while (i >= 0 && a[i] == maxV) { a[i] = 1; i--; }
if (i < 0) break;
a[i]++;
}
string ex;
if (firstBad.empty()) {
ex = "(一个都没有)";
} else {
for (int i = 0; i < n; i++) ex += (i ? " " : "") + to_string(firstBad[i]);
}
printf(" %4d %8lld %8lld %5.1f%% %s ",
n, total, bad, 100.0 * bad / total, padDisp(ex, 22).c_str());
if (firstBad.empty()) printf("%s\n", padDisp("贪心永远是对的", 11).c_str());
else printf("%lld / %lld\n", fg, fb);
}
cout << "\n★ 反例的占比就是「随手造一组数据能抓住这个贪心」的概率。\n";
cout << " 它同时解释了两件事:为什么这个错误直觉能骗过样例,\n";
cout << " 以及为什么对拍必须跑几百轮而不是跑一轮。\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
  堆数   枚举组数   反例组数     占比   最小反例(字典序最小)   贪心 / 正解
  ----   --------   --------   ------   ----------------------   -----------
     3        216          0     0.0%   (一个都没有)           贪心永远是对的
     4       1296        105     8.1%   2 2 1 2                  15 / 14
     5       7776       1224    15.7%   1 1 2 1 2                17 / 16
     6      46656      10540    22.6%   1 1 1 1 1 2              19 / 18

这张表(第 20 章 coinFind.cpp 的同款做法)一口气回答了三件事:

  1. 最小反例是 2 2 1 2(贪心 15、正解 14)—— 动画里有个按钮可以直接切过去看。
  2. 反例占比就是「随手造一组数据能抓住它」的概率。4 堆时只有 8.1%, 所以样例过了什么都证明不了。
  3. ★ 3 堆时反例是 0 —— 那时候这个贪心是真的对的。
★ 第 3 条不是巧合,两句话能证明

3 堆时只有两种合并顺序:先合 (1,2),或者先合 (2,3)。 不管先合哪一对,最后那一次都是把整排并成一堆,代价恒等于总和。 所以 总代价 = 总和 + 先合的那一对之和 —— 挑和最小的那一对当然最优, 而那正好就是贪心干的事。

这条性质马上会变成一个大坑,第 12 步见。

11顺便把第 23 章欠的账还了:输出合并方案

第 23 章末尾说过一句话:「要方案就得开二维表」。一维滚动数组只留得下答案, 留不下「这个答案是怎么来的」。

区间 DP 的 f 本来就是二维的,所以这笔账还起来特别便宜 —— 只要再开一张同样大的 from[l][r] 记下最优断点:

path.cpp记 from[l][r],回溯输出合并方案
// 输出**合并方案** —— 还第 23 章欠下的那笔账
//
// 第 23 章末尾说过:「要方案就得开二维表」。一维滚动数组只留得下答案,
// 留不下「这个答案是怎么来的」。区间 DP 的 f 本来就是二维的,
// 所以只要再开一张同样大的 from[l][r] 记下**最优断点**,回溯就完事了 ——
// 和第 23 章 trace 那一套回溯是同一个套路,一个字都不用新学。
//
// f[l][r] = 把 [l,r] 合成一堆的最小代价
// from[l][r] = 取到那个最小值的断点 k(最后一次合并是 [l,k] + [k+1,r])
//
// ★ 回溯的顺序有讲究:要输出一个**真的能照着做**的合并序列,
// 必须先输出两个子区间内部的合并、最后才输出这一次(后序遍历)。
// 反过来先输出自己,得到的序列是没法执行的 —— 那两堆当时还没并起来呢。
// 这正好又是第 1 章「递归的归」那件事。
//
// 输出的每一行都是一次真实的合并,check:viz 会**照着做一遍**:
// 维护当前这一排堆,逐行检查「这两段确实是当前相邻的两堆」,
// 并把代价累加起来和第一行的答案对照 —— 不只比最终答案,连方案本身也验。
//
// 用法:./path (用正文那组默认数据 4 1 2 3 5)
// ./path < 数据文件(格式同 brute.cpp)
#include <bits/stdc++.h>
using namespace std;
int n;
vector<long long> s;
vector<vector<long long>> f;
vector<vector<int>> from;
struct Merge { int l, k, r; long long cost; };
vector<Merge> plan;
// 后序:先把左右两段各自合好,最后才轮到这一次合并
void back(int l, int r) {
if (l == r) return;
int k = from[l][r];
back(l, k);
back(k + 1, r);
plan.push_back({l, k, r, s[r] - s[l - 1]});
}
int main() {
// ⚠ cout 和 printf 混用,所以不能关 ios::sync_with_stdio(关了输出顺序会乱)
vector<long long> a;
if (cin >> n && n > 0) {
a.resize(n);
for (auto& x : a) cin >> x;
} else {
a = {4, 1, 2, 3, 5}; // 正文默认数据
n = (int)a.size();
}
s.assign(n + 1, 0);
for (int i = 1; i <= n; i++) s[i] = s[i - 1] + a[i - 1];
f.assign(n + 2, vector<long long>(n + 2, 0));
from.assign(n + 2, vector<int>(n + 2, 0));
for (int len = 2; len <= n; len++)
for (int l = 1; l + len - 1 <= n; l++) {
int r = l + len - 1;
long long best = LLONG_MAX;
int bk = l;
for (int k = l; k < r; k++)
if (f[l][k] + f[k + 1][r] < best) { best = f[l][k] + f[k + 1][r]; bk = k; }
f[l][r] = best + s[r] - s[l - 1];
from[l][r] = bk; // ← 全部的额外开销就是这一行
}
back(1, n);
cout << "最小总代价 = " << f[1][n] << "\n\n";
// ⚠ 表头的空格是手数的:setw 数字节,一个汉字 3 字节只占 2 格宽(第 25 章踩过)
cout << " 第几次 合并的两段 代价 合并后这一排\n";
cout << " ------ --------------- ---- ------------------------\n";
vector<long long> cur = a; // 跟着方案真的合一遍,打印过程
vector<int> left(n); // cur[i] 现在代表原来的哪一段
for (int i = 0; i < n; i++) left[i] = i + 1;
long long acc = 0;
for (size_t t = 0; t < plan.size(); t++) {
const auto& m = plan[t];
size_t i = 0;
while (i < left.size() && left[i] != m.l) i++; // 找到左段所在的位置
cur[i] += cur[i + 1];
cur.erase(cur.begin() + i + 1);
left.erase(left.begin() + i + 1);
acc += m.cost;
string seg = "[" + to_string(m.l) + "," + to_string(m.k) + "] + ["
+ to_string(m.k + 1) + "," + to_string(m.r) + "]";
string row;
for (size_t q = 0; q < cur.size(); q++) row += (q ? " " : "") + to_string(cur[q]);
printf(" %6zu %-15s %4lld %s\n", t + 1, seg.c_str(), m.cost, row.c_str());
}
cout << "\n 累计代价 " << acc << ",和上面那个答案对得上。\n";
cout << " from[l][r] 只多花了一张同样大小的表 —— 这就是第 23 章说的「要方案就得开二维表」。\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
最小总代价 = 33

  第几次   合并的两段        代价   合并后这一排
  ------   ---------------   ----   ------------------------
       1   [2,2] + [3,3]        3   4 3 3 5
       2   [1,1] + [2,3]        7   7 3 5
       3   [4,4] + [5,5]        8   7 8
       4   [1,3] + [4,5]       15   15
★ 回溯的顺序有讲究 —— 又是第 1 章那个「归」

要输出一个真的能照着做的合并序列,必须先输出两个子区间内部的合并、 最后才输出这一次(后序遍历)。

反过来先输出自己,得到的序列是没法执行的 —— 那两堆当时还没并起来呢。

check:viz 对这份输出做的是硬验证,不是比字符串: 它维护当前这一排堆,逐行检查「这两段确实是当前相邻的两堆」、代价确实等于两堆之和, 最后确认只剩一堆、累计代价正好是 33。

12★ 对拍:以及一次「我的两个直觉都错了」的现场记录

对拍器
★ 这个生成器的灵魂是「堆数」,不是石子数。堆数一少,这道题会退化成一道贪心题(3 堆时那个错误贪心是真的对的),最经典的错误直觉就一轮都抓不到了。
// 石子合并 —— 正解:区间 DP,★ 按区间长度从小到大填
//
// 状态:f[l][r] = 把第 l 堆到第 r 堆合并成一堆,最少要付多少代价。
// 转移:枚举**最后一次**合并的断点 k(左边 [l,k] 已经并成一堆、右边 [k+1,r] 也并成一堆):
//
// f[l][r] = min over k in [l, r-1] of ( f[l][k] + f[k+1][r] ) + (a[l] + … + a[r])
//
// 后面那一项和 k 无关 —— 不管怎么分,最后那一次合并总是把整段并成一堆,
// 代价恒等于这一段石子的总数。所以它可以提到 min 外面(第 6 章的前缀和 O(1) 求出来)。
//
// ★ 关键一步:**填表顺序按区间长度从小到大。**
// 因为 f[l][r] 要读 f[l][k] 和 f[k+1][r],这两个区间都比 [l,r] **短**。
// 「依赖谁,就先填谁」(第 21 章)—— 短的先算好,长的才有得读。
//
// ⚠ 注意这不是「从左到右」也不是「从右到左」:
// l 从小到大、r 从小到大地填,f[k+1][r] 那一项就还没算(wrongOrder.cpp 演示了后果)。
// 而 l 从**大**到小、r 从小到大是**对的**(lrOrder.cpp)——
// 长度枚举只是「让依赖先算好」的一种实现方式,不是唯一的那一种。
//
// 复杂度 O(n³)、空间 O(n²)。n = 100 时约 100 万次转移,眨眼就跑完。
// 输入输出同 brute.cpp。
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
if (!(cin >> n) || n <= 0) return 0;
vector<long long> s(n + 1, 0); // 前缀和,1 基下标
for (int i = 1; i <= n; i++) { long long x; cin >> x; s[i] = s[i - 1] + x; }
vector<vector<long long>> f(n + 2, vector<long long>(n + 2, 0));
// 长度为 1 的区间不用合并,代价 0 —— f 初值就是 0,所以不用另写一句
for (int len = 2; len <= n; len++) // ★ ① 先枚举区间长度,从短到长
for (int l = 1; l + len - 1 <= n; l++) { // ② 再枚举左端点
int r = l + len - 1;
long long best = LLONG_MAX;
for (int k = l; k < r; k++) // ③ 最后枚举断点
best = min(best, f[l][k] + f[k + 1][r]);
f[l][r] = best + s[r] - s[l - 1]; // 最后一次合并的代价:整段石子之和
}
cout << f[1][n] << "\n";
return 0;
}
点一下即可编辑

300 轮实测,五个版本:

故意写错的地方 被抓 第几轮 它其实解了哪道题
左端点正序(填表顺序) 300 / 300 第 1 轮 允许一次合并任意多个连续堆
前缀和差一(s[r]-s[l]) 300 / 300 第 1 轮 —(每次少加一堆)
断点范围差一(k 从 l+1 起) 184 / 300 第 2 轮 —(凭空多了「左半段至少两堆」的限制)
贪心(每次合最小的相邻两堆) 114 / 300 第 4 轮 哈夫曼树(不要求相邻的那道题)
左端点倒序、右端点正序 0 / 300 — ← 它就是正解(第 8 步那个 ★)
wrongSum.cpp(前缀和差一)✗ s[r] - s[l]
wrongK.cpp(断点范围差一)✗ k 从 l+1 开始
⚠ 偏小的错误和偏大的错误,不一样危险

前两个错误版本都是偏小的(读到 0、少加一堆),最后一个是偏大的。

偏小的错误你还能靠「答案怎么比暴力小」认出来; 偏大的不行 —— 一个偏大的答案和一个「数据比较难」的正确答案长得一模一样。 只能靠标准答案,不能靠眼力。

★ 生成器改了四次,而我动手前的两个直觉都是错的

gen.cpp 带了四个档位,你可以把当初那四次修改一次一次重跑一遍 (./gen 种子 档位)。种子固定 1..300:

档位 石子数 堆数 抓住错误贪心 抓住断点差一
0(最初) 1 ~ 3 4 ~ 9 74 / 300 66 / 300
1 1 ~ 9 4 ~ 9 79 / 300 144 / 300
2 1 ~ 100 4 ~ 9 101 / 300 168 / 300
3(在用) 1 ~ 100 6 ~ 9 114 / 300 184 / 300
4(废案) 大小交错 6 ~ 9 42 / 300 173 / 300

① 「值域小才是灵魂」在这道题上是错的(档位 0 → 2,石子数放大反而更狠)。 第 22 章(LIS)里值域小确实是灵魂,因为那道题的 bug(lower/upper_bound 写反) 依赖的是相等。这道题的错误贪心依赖的是「相邻两对的和谁大谁小」—— 它要的是对比度。石子数全挤在 1~3 里,每一对都差不多,贪心反而不容易露馅。

规矩本身没变(要随机的是算法依赖的那个量),变的是「那个量」是谁。 这一条得每道题重新问一遍,不能背。

② 「大小交错」这种看着很刁钻的花样,实测是最差的一档(42 / 300)。 交错排开之后「哪一对最小」几乎总是那几对固定的小的,局面反而变单调了。 数据里的花样和打得准是两回事。

真正起作用的旋钮是堆数:49 → 69,两个 bug 的抓获率一起涨。

gen.cpp(带四个档位的生成器)四次改动都能重跑
★ 而只要把堆数造成 3,那个贪心就彻底隐身了

我另写了一份 genSmall.cpp,和最终档比只改了一处:堆数固定成 3。同样跑 300 轮:

故意写错的地方 正常数据(6~9 堆) 只有 3 堆的数据
贪心(每次合最小的相邻两堆) 114 / 300 0 / 300
断点范围差一 184 / 300 136 / 300
左端点正序 300 / 300 300 / 300
前缀和差一 300 / 300 300 / 300

一个 bug 完全隐身,其它照旧。 而且这次不用猜原因 —— 第 10 步已经证明过了:3 堆时那个贪心是真的对的, greedyFind.cpp 枚举全部 216 组三堆数据,反例数正好是 0。

genSmall.cpp(故意造得很温柔的生成器)演示用:反面教材

这是第 24、25 章那条教训的第三次现形。写生成器之前先问一句:

这个量取到极小 / 极大时,题目会退化成哪道更简单的题?

石子合并退化到 3 堆,就退化成了一道贪心题。 一个只造 3 堆的生成器,跑一万轮也是绿的,交上去就是 WA。

13这一章可以带走的四样东西

★ 关键的一步

【1】状态换成一段区间,转移枚举「最后一次合并的断点」。 f[l][r] = min{ f[l][k] + f[k+1][r] } + s[r] - s[l-1]。 那个和 k 无关的尾巴要提到 min 外面,用前缀和 O(1) 求 —— 而且一律 1 基下标。

【2】填表顺序的判据是依赖,不是某个写法。 按区间长度从小到大是最省脑子的一种(短的先算), 但左端点倒序、右端点正序同样正确(0 / 300)。 而左端点正序会读到一片还没算的 0,答案恰好塌成石子总数 —— 第六条「写反了就是另一道题」的恒等式。

【3】想不清顺序,就先写记忆化搜索。 它不用你操心任何顺序,而且和递推是同一个复杂度。 递推的价值在于没有递归开销、也不会爆栈(第 21 章 stairsDeep.cpp 那个段错误)—— 先用记忆化把转移写对,再翻译成递推,这个次序永远不亏。

【4】写生成器之前,先问「这个量取到极端时会退化成哪道题」。 石子合并退化到 3 堆 = 一道贪心题,于是那个错误贪心 0 / 300。 另外这一章还证明了一件事:「值域小才是灵魂」不是普适规律 —— 第 22 章成立是因为那道题的 bug 依赖相等,这道题的 bug 依赖对比度,结论正好反过来。 每道题都要重新问一遍:这个 bug 依赖的到底是什么?

下一章预告

第 27 章:树形 DP(没有上司的舞会)。

这一章的状态是「一段区间」,下一章换成「一棵子树」—— 而转移发生在 DFS 回溯的时候,因为父节点要用到所有孩子的结果。

「依赖谁,就先填谁」会第五次登场,而且这一次它有了个更好听的名字: 后序遍历。你在第 1 章就写过它了(那时候叫「归」), 第 11 章的归并排序、本章第 11 步的输出方案,用的都是同一件东西。

14自测

自测清单0 / 10
配套练习
这一章我卡在哪(过一个月回来看,这几行比整章正文都值钱)