前言
作者,在近几天学习了合并石子,特地来写一篇博客加深印象和理解。本文章有两个目的意在帮助入门的同学学习和借鉴,同时帮助作者及其其他读者复习。
任意合并
题意
有N堆石子,现要将石子有序的合并成一堆,规定如下:每次只能移动任意的2堆石子合并,合并花费为新合成的一堆石子的数量。
分析
这种情况,属于石子合并里面最简单的一种了。其实只需要贪心的找两堆最小的进行合并,计算合并的代价。本质上是哈夫曼树的变形,可以使用优先队列实现。
给出题目链接P1090 合并果子
参考代码
#include <iostream>
#include <cstring>
#include <algorithm>
#include <queue>
int n;
std::priority_queue<int , std::vector<int> , std::greater<int>> heap;
int main(){
std::ios::sync_with_stdio(0);
std::cout.tie(0);
std::cin.tie(0);
std::cin >> n;
for (int i = 1; i <= n; i ++ ){
int x;std::cin >> x;
heap.push(x);
}
int ans = 0;
while(heap.size() != 1){
int h1 = heap.top();
heap.pop();
int h2 = heap.top();
heap.pop();
ans += h1 + h2;
heap.push(h1 + h2);
}
std::cout << ans << '\n';
return 0;
}
相邻两堆进行合并
题意
设有 堆石子排成一排,其编号为 1,2,3,⋯ ,𝑁。每堆石子有一定的质量 。现在要将这 𝑁 堆石子合并成为一堆。每次只能合并相邻的两堆,合并的代价为这两堆石子的质量之和,合并后与这两堆石子相邻的石子将和新堆相邻。合并时由于选择的顺序不同,合并的总代价也不相同。试找出一种合理的方法,使总的代价最小,并输出最小代价。
分析
考虑贪心做法 , 但是我们发现使用上述思路贪心做法似乎不正确。 非常容易就可以举出反例:
4
2 5 3 7
-
使用贪心做法
(2 5) 3 7 ----> (7 3) 7 ----> (10 7) ----> 17 代价是 34 -
枚举出一种反例
2 5 (3 7) ----> 2 (5 10) ----> (2 15) ----> 17 代价是 32
所以我们可以hack 掉这个贪心思路,观察题目N最大只有300 , 可以考虑记忆化搜索或者动态规划。
事实上,我有注意到存在记忆化搜索的思路,但是因为这里主要是介绍动态规划的思路,所以暂不提其他思路。
如何考虑动态规划的思路
一般动态规划问题都是考虑将大问题转化成小问题,小问题解决了,大问题使用与解决小问题相似的做法也可以解决。
- 首先先定义状态数组 f[l][r] 表示的是区间[l , r]之间的石子合并的最小代价。
- 然后考虑小问题扩展到大问题的方式
- 先枚举所有的两堆相邻石子合并的最小代价 , 再枚举所有的三堆相邻石子最小代价以此类推下去。我们可以一直递推出将n堆石子合并的最小代价。
- 对于初始化,因为我们需要求最小值,所以我们要将数据全部初始化成INF ,因为f[i][i] 代表的是第i个石子和第i个石子合并的含义,明显它为0。
给出实例
样例我们以上面给出样例作为参考
- 初始的数组
- 合并两堆石子
对应的数组中的情况是这样的
- 合并三堆石子
对于这里,我们需要讨论一下就绿线的 2 5 3 为例
(2 5) 3 ----> (7 3) -----> 10 代价17
2 (5 3) -----> (2 8) ----> 10 代价18
所以我们发现不同的合并方式会有不同的答案,所以我们需要从这些方案中取一个min。
对应的数组
对于合并四个的也是同理,给出对应的数组
但是对于求出i堆石子合并求出最小值描述并不是特别清楚,所以为了弥补这个缺陷,作者将给出一个更加清楚的方式
我们以下面的样例为例
对于合并4堆石子,我们该如何考虑?
- 首先考虑4堆石子我们有哪些合并方式呢?
(1 , 3) (2 , 2) (3 , 1) 对吧?
使用图示就是这样的
隔板的左边和右边合并 , 我们发现隔板的左边为 2 5 3 , 2 5 的合并最小代价,前面我们已经求出来了。隔板的右边,5 3 7 , 3 7 的合并最小代价,合并两个石子的时候,合并三个石子的时候,我们也求出来了。
所以我们对于这些数据,我们直接求出取来用即可,在一个一个对比几种方案的最小值。
我们将绿线称之为窗口,这个窗口内的最小值求出来了,那么窗口向右移。
类似的求法
#include <iostream>
#include <cstring>
#include <algorithm>
#define LL long long
const int N = 1010;
int n;
int a[N] , s[N];
int f[N][N];
void init(){
for (int i = 1; i <= n; i ++ )
for (int j = 1; j <= n; j ++ )
f[i][j] = 0x3f3f3f3f;
}
int main(){
std::ios::sync_with_stdio(0);
std::cin.tie(0);
std::cout.tie(0);
std::cin >> n;
init();
for (int i = 1; i <= n; i ++ ) {
std::cin >> a[i];
s[i] = s[i - 1] + a[i];
f[i][i] = 0;
}
for (int len = 2; len <= n; len ++ )
for (int l = 1; l <= n - len + 1; l ++ ){ // [l , r] 是一个长度为 len 的窗口
int r = l + len - 1;
for (int k = l; k < r; k ++ ) // 在 len 个元素中间插入隔板
f[l][r] = std::min(f[l][r] , f[l][k] + f[k + 1][r] + s[r] - s[l - 1]);
}
std::cout << f[1][n] << '\n';
return 0;
}
细节解读
-
维护窗口的代码
- for (int l = 1; l <= n - len + 1; l ++ )
这一段代码 , 维护的是窗口的左端点 ,长度为len的窗口 , 以l为起点 区间为 , l 最多枚举到 n - len + 1.
-
插入隔板的代码
- for (int k = l; k < r; k ++ ) 在[l , k] 和 [k + 1] 之间插入一块隔板,表示两个区间内的石子堆合并
-
状态转移方程 f[l][r] = std::min(f[l][r] , f[l][k] + f[k + 1][r] + s[r] - s[l - 1]); f[l][k] + f[k + 1][r] , 表示的是 f[l][k] 代表[l , k] 这个区间的石子堆合并的最小代价 , f[k + 1][r] 类似。但是这两个加起来,还只是得到两个区间的最小代价,
(为了方便理解,你可以将区间石子堆直接看成两堆石子。因为合并成一个区间的最小代价已经找到了,那么就固定了。所以可以直接看成两堆石子合并。)这两个区间合并,还是需要加上 [l,r] 这个区间石子堆和的代价。 -
作者自己想到的问题
- 问题
为什么合并的时候,已经得到左边 f[l][k] 和 右边 f[k + 1][r] , 为什么要加上整个区间s[r] - s[l-1]。而不是再加上一遍 , 因为得到左边区间的代价是f[l][k] , 得到右边区间的代价是f[k + 1][r]。那两个区间合并不是左边+右边吗?
- 答案 我们搞混了概念, f[l][r] 是指定 [l , r] 这个区间内石子合并的最小代价 , 是累计了很多小区间合并的代价的,比如 [1 , 4] 这个区间 ,假设是由这个区间[1 , 1] , [2 , 4] 合并得到 , 那么我们将 f[l][k] + f[k + 1][r] 算两遍是不是算了很多重复的子区间代价。换句话说,因为[l , r] 是通过小区间合并累计的代价和的最小值,s[r] - s[l - 1] 单纯是[l , r] , 石子堆的和。所以我们考虑的时候,是要将合并成[l , k] 和 [k + 1 , r] 最小代价 + [l , r]区间石子堆的和 作为我们合并成区间[l , r] 的最小值。
练习题 P1775 石子合并(弱化版)
环型石子合并
在一个圆形操场的四周摆放 N 堆石子,现要将石子有次序地合并成一堆,规定每次只能选相邻的 2 堆合并成新的一堆,并将新的一堆的石子数,记为该次合并的得分。求最小得分。
这样的情况,我们考虑破环为链 ,然后对每一个点都将当作起点。
这样就直接转化成对一条链求最小合并代价了。 但是区间的最大长度是n , 即 。 只需要将
for (int l = 1; l <= n - len + 1; l ++ )
的 写成 即可
学完可以去刷这个题石子合并
总结
合并石子是区间dp 的典题,作者依旧感觉自己的dp 没有学明白,这个篇文章写的也不是很清楚。
如果读者任何建议,请随时评论!