区间 DP 可以用于解决一些涉及到区间合并或分割的问题。区间 DP 通常有以下三个特点:
原题链接
设有 N 堆石子排成一排,其编号为 1 , 2 , 3 , … , N 1,2,3,…,N 1,2,3,…,N。
每堆石子有一定的质量,可以用一个整数来描述,现在要将这 N 堆石子合并成为一堆。
每次只能合并相邻的两堆,合并的代价为这两堆石子的质量之和,合并后与这两堆石子相邻的石子将和新堆相邻,合并时由于选择的顺序不同,合并的总代价也不相同。
例如有 4 堆石子分别为 1 3 5 2
, 我们可以先合并 1、2 堆,代价为 4,得到 4 5 2
, 又合并 1、2 堆,代价为 9,得到 9 2
,再合并得到 11,总代价为 4 + 9 + 11 = 24 4+9+11=24 4+9+11=24;
如果第二步是先合并 2、3 堆,则代价为 7,得到 4 7
,最后一次合并代价为 11,总代价为 4 + 7 + 11 = 22 4+7+11=22 4+7+11=22 。
问题是:找出一种合理的方法,使总的代价最小,输出最小代价。
状态表示:dp[i][j]
表示将第 i 堆石子到第 j 堆石子合并成一堆石子的最小代价。
状态计算:
[i, j]
分成[i, k]
和[k, j]
,求将第 i 堆石子到第 j 堆石子合并成一堆石子的最小代价,即dp[i][j] = max(dp[i][k] + dp[k+1][j]) + cost(i, j)
#include
#include
#include
#include
using namespace std;
int main()
{
int n;
cin >> n;
vector<int> a(n + 1, 0), sum(n + 1, 0);
vector<vector<int>> dp(n + 1, vector<int>(n + 1, 0));
for(int i = 1; i <= n; i++) {
cin >> a[i];
sum[i] = sum[i-1] + a[i];
}
// 枚举区间长度
for(int len = 2; len <= n; len++) {
// 枚举区间的左端点
for(int i = 1; i <= n - len + 1; i++) {
int j = i + len - 1;
// 初始化
dp[i][j] = INT_MAX;
// 枚举分界点
for(int k = i; k < j; k++) {
dp[i][j] = min(dp[i][j], dp[i][k] + dp[k+1][j] + sum[j] - sum[i-1]);
}
}
}
cout << dp[1][n] << endl;
return 0;
}
时间复杂度为 O ( N 3 ) O(N^3) O(N3)
将 n 堆石子绕圆形操场排放,现要将石子有序地合并成一堆。
规定每次只能选相邻的两堆合并成新的一堆,并将新的一堆的石子数记做该次合并的得分。
请编写一个程序,读入堆数 n 及每堆的石子数,并进行如下计算:
前面我们已经考虑了线性区间的石子合并问题,仔细观察环形区间的石子合并问题,可以发现将两个石子合并,可以看作在两个石子之间连一条边。将 n 个石子合并需要 n - 1 次合并操作,即连 n - 1 条边。沿着缺口将环形区间拉直,便可转换为线性区间问题。
为了遍历所有可能的缺口情况,我们可以将原问题复制一份,接在原数组后面,构成一个具有 2n 个石子的问题。求解该线性区间问题,最终找dp[i][i+n]
的最大值(其中 1 ≤ i ≤ n 1 \leq i \leq n 1≤i≤n),得到环形区间问题的最大值。
状态定义和状态转移方程同上一题(石子合并问题)相同。
#include
#include
#include
using namespace std;
int main()
{
int n;
cin >> n;
vector<int> a(2 * n + 1, 0), sum(2 * n + 1, 0);
vector<vector<int>> dp(2 * n + 1, vector<int>(2 * n + 1, 0));
vector<vector<int>> f(2 * n + 1, vector<int>(2 * n + 1, 0));
for(int i = 1; i <= n; i++) {
cin >> a[i];
a[i + n] = a[i];
}
for(int i = 1; i <= 2 * n; i++) {
sum[i] = sum[i-1] + a[i];
}
for(int len = 2; len <= n; len++) {
for(int i = 1; i <= 2 * n - len + 1; i++) {
int j = i + len - 1;
dp[i][j] = INT_MAX;
f[i][j] = INT_MIN;
for(int k = i; k < j; k++) {
dp[i][j] = min(dp[i][j], dp[i][k] + dp[k+1][j] + sum[j] - sum[i-1]);
f[i][j] = max(f[i][j], f[i][k] + f[k+1][j] + sum[j] - sum[i-1]);
}
}
}
int maxVal = INT_MIN, minVal = INT_MAX;
for(int i = 1; i <= n; i++) {
minVal = min(minVal, dp[i][i + n - 1]);
maxVal = max(maxVal, f[i][i + n - 1]);
}
cout << minVal << endl;
cout << maxVal << endl;
return 0;
}
时间复杂度为 O ( N 3 ) O(N^3) O(N3) 量级
在 Mars 星球上,每个 Mars 人都随身佩带着一串能量项链。在项链上有 N N N 颗能量珠。能量珠是一颗有头标记与尾标记的珠子,这些标记对应着某个正整数。并且,对于相邻的两颗珠子,前一颗珠子的尾标记一定等于后一颗珠子的头标记。因为只有这样,通过吸盘(吸盘是 Mars 人吸收能量的一种器官)的作用,这两颗珠子才能聚合成一颗珠子,同时释放出可以被吸盘吸收的能量。如果前一颗能量珠的头标记为 m m m,尾标记为 r r r,后一颗能量珠的头标记为 r r r,尾标记为 n n n,则聚合后释放的能量为 m × r × n m \times r \times n m×r×n(Mars 单位),新产生的珠子的头标记为 m m m,尾标记为 n n n。
需要时,Mars 人就用吸盘夹住相邻的两颗珠子,通过聚合得到能量,直到项链上只剩下一颗珠子为止。显然,不同的聚合顺序得到的总能量是不同的,请你设计一个聚合顺序,使一串项链释放出的总能量最大。
例如:设 N = 4 N=4 N=4, 4 4 4 颗珠子的头标记与尾标记依次为 ( 2 , 3 ) ( 3 , 5 ) ( 5 , 10 ) ( 10 , 2 ) (2,3)(3,5)(5,10)(10,2) (2,3)(3,5)(5,10)(10,2)。我们用记号 ⊕ \oplus ⊕ 表示两颗珠子的聚合操作, ( j ⊕ k ) (j \oplus k) (j⊕k) 表示第 j , k j,k j,k 两颗珠子聚合后所释放的能量。则第 4 4 4, 1 1 1 两颗珠子聚合后释放的能量为:
( 4 ⊕ 1 ) = 10 × 2 × 3 = 60 (4 \oplus 1)=10 \times 2 \times 3=60 (4⊕1)=10×2×3=60。
这一串项链可以得到最优值的一个聚合顺序所释放的总能量为:
( ( ( 4 ⊕ 1 ) ⊕ 2 ) ⊕ 3 ) = 10 × 2 × 3 + 10 × 3 × 5 + 10 × 5 × 10 = 710 (((4 \oplus 1) \oplus 2) \oplus 3)=10 \times 2 \times 3+10 \times 3 \times 5+10 \times 5 \times 10=710 (((4⊕1)⊕2)⊕3)=10×2×3+10×3×5+10×5×10=710。
不难发现,此题就是环形矩阵连乘问题。与上题类似,我们可以先考虑线性区间问题的求解。
状态表示:dp[i][j]
表示将第 i 个珠子到第 j 个珠子合并在一起,所能释放的最大能量。
状态计算:
[i, j]
分成[i, k]
和[k+1, j]
,表示最后一次的合并点。dp[i][j] = max(dp[i][k] + dp[k+1][j] + profit(i, k, j))
环形的情况:【这里偷个懒,环形石子合并问题的图进行解释】
假设珠子构成的环形有 n 个点,每合并两个珠子,在他们之间连一条线。将所有珠子都合并,需要连 n - 1 条线。可以发现,珠子并没有连成环,而是存在一个缺口。因此,我们可以考虑缺口的所有可能位置,将这个带缺口的环形拉直,变成一条直线,转换成线性区间问题。
为了遍历所有可能情况,我们可以将原问题复制一份,接在原数组后面,构成一个具有 2n 个珠子的问题。求解该线性区间问题,最终找dp[i][i+n]
的最大值(其中 1 ≤ i ≤ n 1 \leq i \leq n 1≤i≤n),得到环形区间问题的最大值。
Note:这里注意,2 个珠子合并需要 3 个参数,因此区间长度应该从 3 开始,最大区间长度应该是
n + 1
#include
#include
#include
using namespace std;
int main()
{
int n;
cin >> n;
vector<int> w(2 * n + 1);
vector<vector<int>> dp(2 * n + 1, vector<int>(2 * n + 1, 0));
for(int i = 1; i <= n; i++) {
cin >> w[i];
w[i + n] = w[i];
}
for(int len = 2; len <= n + 1; len++) {
for(int i = 1; i <= n * 2 - len + 1; i++) {
int j = i + len - 1;
for(int k = i + 1; k < j; k++) {
dp[i][j] = max(dp[i][j], dp[i][k] + dp[k][j] + w[i]*w[k]*w[j]);
}
}
}
int res = 0;
for(int i = 1; i <= n; i++) {
res = max(res, dp[i][i + n]);
}
cout << res << endl;
return 0;
}
时间复杂度为 O ( N 3 ) O(N^3) O(N3)
原题链接
设一个 n n n 个节点的二叉树 tree \text{tree} tree 的中序遍历为 ( 1 , 2 , 3 , … , n ) (1,2,3,\ldots,n) (1,2,3,…,n),其中数字 1 , 2 , 3 , … , n 1,2,3,\ldots,n 1,2,3,…,n 为节点编号。每个节点都有一个分数(均为正整数),记第 i i i 个节点的分数为 d i d_i di, tree \text{tree} tree 及它的每个子树都有一个加分,任一棵子树 subtree \text{subtree} subtree(也包含 tree \text{tree} tree 本身)的加分计算方法如下:
subtree \text{subtree} subtree 的左子树的加分 × \times × subtree \text{subtree} subtree 的右子树的加分 + + + subtree \text{subtree} subtree 的根的分数。
若某个子树为空,规定其加分为 1 1 1,叶子的加分就是叶节点本身的分数。不考虑它的空子树。
试求一棵符合中序遍历为 ( 1 , 2 , 3 , … , n ) (1,2,3,\ldots,n) (1,2,3,…,n) 且加分最高的二叉树 tree \text{tree} tree。要求输出
这道题的主要考察区间 DP 问题的方案记录
先回顾下,二叉树的中序遍历:先递归遍历左子树,输出根节点,再递归遍历右子树。
状态表示:dp[L][R]
表示所有中序遍历是[L, R]
序列的二叉树的集合。
状态计算:dp[L][R] = max(dp[L][k-1] * dp[k+1][R] + w[k])
,其中 k 为根节点所有可能的位置,即分界点。
方案记录与输出:核心在于记录每次区间划分的根节点
g[L][R] = root
:表示对于[L, R]
这个二叉树的中序遍历,其根节点为root
,将二叉树划分成左子树[L, root - 1]
和右子树[root + 1, R]
边界情况处理:
dp[i][i] = w[i]
。根节点就是本身,即g[i][i] = i
k == l
时,说明左子树为空,则dp[i][j] = dp[k+1][r] + w[k]
k == r
时,说明右子树为空,则dp[i][j] = dp[l][k-1] + w[k]
#include
#include
#include
#include
using namespace std;
// 前序遍历
void dfs(const vector<vector<int>>& g, int left, int right)
{
if(left > right) return ;
int root = g[left][right];
cout << root << " ";
dfs(g, left, root - 1);
dfs(g, root + 1, right);
}
int main()
{
int n;
cin >> n;
vector<int> w(n + 1, 0);
vector<vector<int>> dp(n + 1, vector<int>(n + 1, 0));
vector<vector<int>> g(n + 1, vector<int>(n + 1, 0));
for(int i = 1; i <= n; i++) {
cin >> w[i];
}
for(int len = 1; len <= n; len++) {
for(int l = 1; l <= n - len + 1; l++) {
int r = l + len - 1;
if(len == 1) {
dp[l][l] = w[l];
g[l][l] = l;
continue;
}
for(int k = l; k <= r; k++) {
int left = l == k ? 1 : dp[l][k-1];
int right = r == k ? 1 : dp[k+1][r];
int t = left * right + w[k];
if( dp[l][r] < t ) {
dp[l][r] = t;
g[l][r] = k;
}
}
}
}
cout << dp[1][n] << endl;
dfs(g, 1, n);
return 0;
}
时间复杂度为 O ( N 3 ) O(N^3) O(N3) 量级
给定一个具有 N 个顶点的凸多边形,将顶点从 1 至 N 标号,每个顶点的权值都是一个正整数。
将这个凸多边形划分成 N−2 个互不相交的三角形,对于每个三角形,其三个顶点的权值相乘都可得到一个权值乘积,试求所有三角形的顶点权值乘积之和至少为多少。
观察上述凸多边形,我们以(3, 4)
为边,6
为分界点,可以将凸多边形分成left
和right
两个多边形,以及一个三角形(3, 4, 6)
。对于这种情况,凸多边形的最小值就是左边凸多边形left
的最小值,加上右边凸多边形right
的最小值,再加上三角形(3, 4, 6)
权值乘积。
通过遍历所有可能的分界点,我们便可以得到以(3, 4)
为底边的三角形,所有可能情况凸多边形的最小值。
状态表示:dp[L][R]
表示所有将(L, L+1), (L+1, L+2), ..., (R-1, R), (R, L)
这个多边形分成三角形的最小值。
状态计算:
k
dp[L][R] = min(dp[L][k] + dp[k][R] + w[R] * w[L] * w[k])
Note:这里不做环形处理是因为,凸多边形的划分与划分顺序无关,即影响权值乘积的因素只有划分结果。因此,任意一种环形情况,都能在一个线性区间情况中找到一个对应的划分
无高精度,只能过部分样例
#include
#include
#include
#include
using namespace std;
int main()
{
int n;
cin >> n;
vector<int> w(n + 1, 0);
vector<vector<int>> dp(n + 1, vector<int>(n + 1, 0));
for(int i = 1; i <= n; i++) {
cin >> w[i];
}
// 区间长度最小值为3
for(int len = 3; len <= n; len++) {
for(int l = 1; l <= n - len + 1; l++) {
int r = l + len - 1;
dp[l][r] = INT_MAX;
// 遍历三角形的顶点(分界点)
for(int k = l + 1; k < r; k++) {
dp[l][r] = min(dp[l][r], dp[l][k] + dp[k][r] + w[r] * w[l] * w[k]);
}
}
}
cout << dp[1][n] << endl;
return 0;
}
这里用 Python 偷懒过高精度(手动狗头
n = int(input())
a = list(map(int, input().split()))
w = [0] + a
dp = [[0 for i in range(n + 1)] for i in range(n + 1)]
for len in range(3, n + 1):
for l in range(1, n - len + 2):
r = l + len - 1
dp[l][r] = float('inf')
for k in range(l + 1, r):
dp[l][r] = min(dp[l][r], dp[l][k] + dp[k][r] + w[r] * w[l] * w[k])
print(dp[1][n])
时间复杂度为 O ( N 3 ) O(N^3) O(N3) 量级
原题链接
将一个 8 × \times × 8 的棋盘进行如下分割:将原棋盘割下一块矩形棋盘并使剩下部分也是矩形,再将剩下的部分继续如此分割,这样割了 ( n − 1 ) (n-1) (n−1) 次后,连同最后剩下的矩形棋盘共有 n n n 块矩形棋盘。 (每次切割都只能沿着棋盘格子的边进行)
原棋盘上每一格有一个分值,一块矩形棋盘的总分为其所含各格分值之和。现在需要把棋盘按上述规则分割成 n n n 块矩形棋盘,并使各矩形棋盘总分的均方差最小。
均方差 σ = ∑ i = 1 n ( x i − x ˉ ) 2 n \sigma = \sqrt{ \frac{ \sum_{i=1}^n (x_i - \bar x)^2 } { n }} σ=n∑i=1n(xi−xˉ)2 ,其中平均值 x ˉ = ∑ i = 1 n x i n \bar x = \frac{\sum_{i=1}^n x_i}{n} xˉ=n∑i=1nxi , x i x_i xi 为第 i i i 块矩形棋盘的分。
请编程对给出的棋盘及 n n n ,求出 σ \sigma σ 的最小值。
这道题属于二维的区间 DP 问题,为了简化代码,可以使用记忆化搜索来求解
状态表示:dp[x1][y1][x2][y2][k]
表示将子矩阵(x1, y1)(x2, y2)
切分成 k 部分,即切 k - 1 刀,均方差的最小值。
状态计算:
dp(x1, y1, x2, y2, k) = min(get(x1, y1, i, y2) + dp(i + 1, y1, x2, y2, k - 1), get(i + 1, y1, x2, y2) + dp(x1, y1, i, y2, k - 1))
dp(x1, y2, x2, y2, k) = min(get(x1, y1, x2, j) + dp(x1, j + 1, x2, y2, k - 1), get(x1, j + 1, x2, y2) + dp(x1, y1, x2, j, k - 1))
#include
#include
#include
#include
using namespace std;
const int N = 10, M = 17;
const double INF = 1e9 + 7;
int n = 8, m; // 划分成m个矩形
int s[N][N]; // 二维前缀和
double f[N][N][N][N][M];
double xmean; // 均值
// 求方块元素之和
int getSum(int x1, int y1, int x2, int y2)
{
return s[x2][y2] + s[x1 - 1][y1 - 1] - s[x1 - 1][y2] - s[x2][y1 - 1];
}
// 求方差的其中一项
double get(int x1, int y1, int x2, int y2)
{
double t = getSum(x1, y1, x2, y2) - xmean;
return (double)t * t / m;
}
// 记忆化搜索求二维区间DP问题
double dp(int x1, int y1, int x2, int y2, int k)
{
double &v = f[x1][y1][x2][y2][k];
// 已经计算过,直接返回
if(v >= 0) return v;
// 划分成1个矩形,无需再切
if(k == 1) return v = get(x1, y1, x2, y2);
v = INF;
// 横切
for(int i = x1; i < x2; i++) {
// 留下上面部分,继续切下面部分
v = min(v, get(x1, y1, i, y2) + dp(i + 1, y1, x2, y2, k - 1));
// 留下下边部分,继续切上面部分
v = min(v, get(i + 1, y1, x2, y2) + dp(x1, y1, i, y2, k - 1));
}
// 竖切
for(int i = y1; i < y2; i++) {
// 留下左边,继续切右边
v = min(v, get(x1, y1, x2, i) + dp(x1, i + 1, x2, y2, k - 1));
// 留下右边,继续切左边
v = min(v, get(x1, i + 1, x2, y2) + dp(x1, y1, x2, i, k - 1));
}
return v;
}
int main()
{
cin >> m;
for(int i = 1; i <= n; i++) {
for(int j = 1; j <= n; j++) {
cin >> s[i][j];
s[i][j] += s[i - 1][j] + s[i][j - 1] - s[i - 1][j - 1];
}
}
// m个矩形的均值
xmean = (double)s[n][n] / m;
// 初始化
memset(f, -1, sizeof(f));
printf("%.3lf\n", sqrt(dp(1, 1, n, n, m)));
return 0;
}