经典案例 石子合并
有 n 堆石子排成一排,第 i 堆石子有 1≤≤500) 颗,每次我们可以选择相邻的两堆石子合并,代价是两堆石子数目的和,现在我们要一直合并这些石子,使得最后只剩下一堆石子,问总代价最少是多少?
例如,初始我们有 3 堆石子,其中的石子数分别是 1, 2, 3,有两种合并的方法:
- 先合并前两堆,代价是 1 + 2 = 3,合并完以后还剩两堆石子,其中的石子数分别是 3, 3,再把这两堆石子合并,代价是 6,总代价是 3 + 6 = 9;
- 先合并后两堆,代价是 2 + 3 = 5,合并完以后还剩两堆石子,其中的石子数分别是 1, 5,再把这两堆石子合并,代价是 6,总代价是 5 + 6 = 11;
第一种方法代价较小,所以答案等于 9。
那么怎么来解决这个问题呢?
递归法
首先我们考虑用递归法来解决石子合并问题。
由于每次我们合并的都是相邻两堆石子,在合并的过程中每堆石子都是由初始时连续的一段石子合并而来。现在让我们考虑整个合并过程的最后一步,我们要把最后的两堆石子合并成一堆石子。
一定存在一个分界线 x,使得两堆石子中的一堆是初始第 1 堆到第 x 堆石子合并得到的结果,另一堆是初始第 x + 1 堆到第 n 堆石子合并得到的结果。比如说在例子的第一种方法中,x = 2,即最后一次合并的一堆石子是初始第 1 堆到第 2 堆石子合并得到的结果,另一堆是初始第 3 堆石子(也就是初始第 3 堆到第 3 堆石子合并得到的结果)。
当 x 确定的时候,总代价 = 合并初始第 1 堆到第 x 堆石子的最小代价 + 合并初始第 x + 1 堆到第 n 堆石子的最小代价 + 总石子数。但是我们不知道分界线 x 在哪里更优,所以我们选择枚举 x 的位置,并且在所有情况中选择代价最小的一个,即为最后的答案。
定义 f[i][j] 表示合并初始第 i 堆到第 j 堆石子的最小代价。现在问题从计算 f[1][n] 变成了计算所有的 f[1][x] 和 f[x + 1][n]。需要考虑的区间变小了,接下来是不是可以递归啦?
在递归的过程中,假如我们想求出 f[l][r],也就是合并初始第 l 到第 r 堆石子的最小代价。为了合并这些石子,也会存在一个分界线 m,使得最后一步合并的两堆石子中的一堆是初始第 l 堆到第 m 堆石子合并得到的结果,另一堆是初始第 m + 1 堆到第 r 堆石子合并得到的结果。当 m 确定的时候,总代价 = 合并初始第 l 到第 m 堆石子的最小代价 + 合并初始第 m + 1 到第 r 堆石子的最小代价 + 初始第 l 到第 r 堆石子的石子总数。同样的,我们要枚举分界线 m 的位置,并且从中选出代价最小的方案,有:
#include <bits/stdc++.h>
using namespace std;
int n, a[501], s[501];
int solve(int l, int r) {
if (l == r)
return 0;
int ans = 1 << 30;
for (int m = l; m < r; m++)
ans = min(ans, solve(l, m) + solve(m + 1, r));
return ans + s[r] - s[l - 1];
}
int main() {
scanf("%d", &n);
for (int i = 1; i <= n; i++)
scanf("%d", &a[i]);
for (int i = 1; i <= n; i++)
s[i] = s[i - 1] + a[i];
printf("%d\n", solve(1, n));
}
solve(l, r) 计算的是合并初始第 l 堆到第 r 堆石子的最小代价。
if (l == r)
return 0;
当 l = r,也就是只需要合并一堆石子时,不需要花费任何代价,返回 0。
int ans = 1 << 30;
for (int m = l; m < r; m++)
ans = min(ans, solve(l, m) + solve(m + 1, r));
这段代码的作用是枚举分界线 m,找出 f[l][m] + f[m + 1][r] 的最小值。
return ans + s[r] - s[l - 1];
合并初始第 l 堆到第 r 堆石子的最小代价 ,我们用前缀和的思想计算初始第 l 堆到第 r 堆石子中的石子总数。
for (int i = 1; i <= n; i++)
s[i] = s[i - 1] + a[i];
在这里我们引入 s 数组,s[i] 等于编号小于等于 i 的堆中的石子总数(初始第 1 堆到第 i 堆石子的石子总数)。那么初始第 l 到第 r 堆中有多少石子怎么用 s 数组表示呢?是不是就是 s[r] - s[l - 1] 呀(初始第 1 堆到第 r 堆石子的石子总数 - 初始第 1 堆到第 l - 1 堆石子的石子总数,剩下的就是初始第 l 到第 r 堆石子的石子总数)!
printf("%d\n", solve(1, n));
solve(1, n) 即为最后的答案!
记忆化搜索
递归的解法跑得很慢,n = 500 早就彻底歇菜了,为什么呢?在递归的过程当中,同一个区间 [l, r] (指的是合并初始第 l 堆到第 r 堆的情况)会被重复计算很多次。比如说,对于区间 [3, 4] 来说,计算区间 [1, 4], [2, 4], [3, 5], [3, 6], ...., [3, n] 时都需要被算一次,这显然就太慢了!那怎么办呢?每次计算的时候,合并初始第 l 堆到第 r 堆石子的最小代价都是一样的,于是我们可以用一个数组 f 把算过的情况都记下来,f[l][r] 中记录了合并初始第 l 堆到第 r 堆石子的最小代价。每次递归到区间 [l, r] 的时候,我们首先判断一下之前这个情况有没有出现过。如果这是第一次出现,我们继续往下递归,并用算出来的值更新 f[l][r];否则,我们直接返回 f[l][r] 的值就可以啦!
#include <bits/stdc++.h>
using namespace std;
int n, a[501], s[501], f[501][501];
int solve(int l, int r) {
if (f[l][r] != -1)
return f[l][r];
if (l == r)
return f[l][r] = 0;
int ans = 1 << 30;
for (int m = l; m < r; m++)
ans = min(ans, solve(l, m) + solve(m + 1, r));
return f[l][r] = ans + s[r] - s[l - 1];
}
int main() {
scanf("%d", &n);
for (int i = 1; i <= n; i++)
scanf("%d", &a[i]);
for (int i = 1; i <= n; i++)
s[i] = s[i - 1] + a[i];
memset(f, 255, sizeof(f));
printf("%d\n", solve(1, n));
}
和刚刚的递归解法不一样的是,我们引入了数组 f,把算过的情况都记下来。
memset(f, 255, sizeof(f));
初始 f 中所有元素的值都赋值成 -1。如果 f[l][r] = -1,表示这个情况没有被计算过。
if (f[l][r] != -1)
return f[l][r];
在计算 solve(l, r) ,也就是合并初始第 l 堆到第 r 堆石子的情况的过程中,如果这个情况之前被计算过了,则直接返回 f[l][r]。
if (l == r)
return f[l][r] = 0;
int ans = 1 << 30;
for (int m = l; m < r; m++)
ans = min(ans, solve(l, m) + solve(m + 1, r));
return f[l][r] = ans + s[r] - s[l - 1];
否则,我们只要在 return 之前把算出来的值存入 f[l][r] 就好啦!
我们再来分析下这个代码的时间复杂度。长度为 n 的序列总共有 (实际是 ),每个区间最多被计算到一次,每次计算时分界线最多有 个,所以总的时间复杂度是。
这就是传说中的记忆化搜索啦!顾名思义,记忆化搜索指的是我们在搜索(递归)的过程中,把碰到过的情况都记录下来。再次碰到同一个情况的时候,我们不必从头再计算一次,取而代之的是我们可以直接从记忆中把结果读取出来。记忆化搜索和动态规划秉承着相似的逻辑,核心问题都是以子问题的解推出母问题的解。 不一样的是,在动态规划中拆分问题的过程是在人的头脑中进行的,而记忆化搜索拆分问题是在程序中进行的,它的执行模式是在递归的过程中先自上而下把母问题拆分成子问题,一直拆分直到不能继续拆分为止,然后再自下而上把每个子问题的解一一推导出来。作为对比,动态规划的程序里只有自下而上求解每个子问题的解这一个过程。在实战当中,记忆化搜索的思维难度相较动态规划来说一般更低,在读者早期对动态规划还不甚理解的时候,不妨先熟练掌握使用记忆化搜索的技巧,已经可以解决绝大部分的动态规划类问题。
动态规划
让我们换种思路,用动态规划的想法来解决这个问题,分析过程是和前面的做法类似的。首先,我们来看看这个题的最优子结构、无后效性、状态以及转移。
最优子结构:为了计算合并区间 [i, j] 的最小代价,我们需要先计算合并所有满足
的区间 [i, k],[k + 1, j] 的最小代价;有了后者的值,我们就可以算出前者的值;
无后效性:我们只关心合并区间 [i, j] 的最小代价,不关心具体是怎么合并的;
状态:用 f[i][j] 表示合并区间 [i, j] 的最小代价;
转移:
分别对应了分界线 k = i, k = i + 1, ...., k = j - 1 的情况,我们从这 j - i 种情况中选出代价最小的方案,即可以更新 f[i][j] 的值;
上回也说了在动态规划中,在算一个问题的解之前,必须事先计算这个问题的所有子问题的解。这一点在这里如何保证呢?让我们思索一下,问题 [i, j] 的子问题 [i, k] 和 [k + 1, j] 有什么共同点呢?对了,他们的长度是不是都比 [i, j] 小!如果我们把区间按长度从小到大的顺序排好序进行计算,那么一个问题的子问题是不是都会先于它被计算到呀?
话不多少,让我们来看看代码!
#include <bits/stdc++.h>
using namespace std;
int n, a[501], s[501], f[501][501];
int main() {
scanf("%d", &n);
for (int i = 1; i <= n; i++)
scanf("%d", &a[i]);
for (int i = 1; i <= n; i++)
s[i] = s[i - 1] + a[i];
memset(f, 127, sizeof(f));
for (int i = 1; i <= n; i++)
f[i][i] = 0;
for (int l = 1; l < n; l++)
for (int i = 1; i <= n - l; i++) {
int j = i + l;
for (int k = i; k < j; k++)
f[i][j] = min(f[i][j], f[i][k] + f[k + 1][j] + s[j] - s[i - 1]);
}
printf("%d\n", f[1][n]);
}
我们具体的实现方法是先算所有长度为 1 的区间,再算所有长度为 2 的区间,...,最后算所有长度为 n - 1 的区间。对于同样长度的区间,谁先算谁后算是不关键的(它们中的任意一个不可能成为另一个的子问题)。
memset(f, 127, sizeof(f));
for (int i = 1; i <= n; i++)
f[i][i] = 0;
一开始我们进行初始化操作。因为最后要求最小代价,我们把所有状态的值赋成无穷大,之后出现更小的值,把它们替换掉就可以了。接着,对于所有的区间 [i, i],只有一堆石子,所以不需要合并,因而不需要付出任何代价,把这些 f[i][i] 赋值成 0。
for (int l = 1; l < n; l++)
for (int i = 1; i <= n - l; i++) {
int j = i + l;
接着从小到大枚举当前考虑的区间长度 l 以及考虑的区间的左端点 i,对应的右端点 j 等于 i + l。
for (int k = i; k < j; k++)
f[i][j] = min(f[i][j], f[i][k] + f[k + 1][j] + s[j] - s[i - 1]);
然后枚举分界线 k 的位置,即可以更新出 f[i][j] 的值。
printf("%d\n", f[1][n]);
f[1][n] 就是答案!
最后让我们来分析下这个做法的时间复杂度,我们需要枚举区间长度 l、左端点 i 以及分界线位置 k,每个都是 O(n) 级别的,所以总的时间复杂度是 和记忆化搜索是一样的。
石子合并代表了一类涉及到区间的动态规划问题,其核心思想是按长度从小到大的顺序计算每个区间代表的状态的值。解决这类问题的一般思路就是按上面所说的依次枚举区间长度 l、左端点 i 以及分界线位置 k,然后进行状态的更新和转移。