动态规划高级应用:典型问题
今天我们来聊聊动态规划的三个经典问题:矩阵连乘、最优二叉搜索树、凸多边形最优三角剖分。说实话,这三个问题我在工作中都遇到过它们的变体。比如矩阵连乘,我在做深度学习框架的算子优化时就碰到过类似的计算顺序问题。
这三个问题有个共同点——它们都是区间DP的典型代表。什么意思呢?就是在一个连续的区间上做决策,把大区间拆成小区间,最后合并出最优解。
核心思想:最优子结构 + 区间划分 + 状态转移
1. 矩阵连乘问题
先说说矩阵连乘。给定一堆矩阵,你要决定先乘哪两个,再乘哪两个……不同的顺序,计算量天差地别。
举个例子:A(10×100)、B(100×5)、C(5×50)。
- (AB)C:10×100×5 + 10×5×50 = 5000 + 2500 = 7500次乘法
- A(BC):100×5×50 + 10×100×50 = 25000 + 50000 = 75000次乘法
你看,差了整整10倍!
我在项目中遇到过类似场景——做矩阵运算库时,用户传了一串矩阵,我得自动找出最优计算顺序。嗯,这就是矩阵连乘的用武之地。
状态定义
定义 dp[i][j] 表示从第 i 个矩阵乘到第 j 个矩阵的最小乘法次数。
状态转移方程
dp[i][j] = min(dp[i][k] + dp[k+1][j] + p[i-1]*p[k]*p[j]) (i ≤ k < j)
其中 p 数组存储矩阵的维度:矩阵 Ai 的维度是 p[i-1]×p[i]。
代码实现
int matrixChainOrder(int p[], int n) {
int dp[n][n];
// 初始化:单个矩阵不需要乘法
for (int i = 1; i < n; i++)
dp[i][i] = 0;
// L是区间长度
for (int L = 2; L < n; L++) {
for (int i = 1; i < n - L + 1; i++) {
int j = i + L - 1;
dp[i][j] = INT_MAX;
for (int k = i; k < j; k++) {
int cost = dp[i][k] + dp[k+1][j] + p[i-1]*p[k]*p[j];
if (cost < dp[i][j])
dp[i][j] = cost;
}
}
}
return dp[1][n-1];
}
小技巧:我习惯用L表示区间长度,从2开始枚举。这样代码逻辑清晰,不容易出错。
2. 最优二叉搜索树
这个问题很有意思。给定一组关键字和它们的搜索概率,要构造一棵二叉搜索树,使得平均查找代价最小。
你想想看,高频词应该放在靠近根的位置,低频词放得深一点。但问题来了——BST还有中序遍历有序的约束,不能随便排。
状态定义
定义 dp[i][j] 表示关键字 ki 到 kj 构成的最优BST的代价。
状态转移方程
dp[i][j] = min(dp[i][k-1] + dp[k+1][j] + sum(p[i..j])) (i ≤ k ≤ j)
这里的 sum(p[i..j]) 是 ki 到 kj 的概率之和。为什么要加这个?因为把 k 提成根后,左右子树所有节点的深度都增加了1,代价自然要加上去。
代码实现
double optimalBST(double p[], int n) {
double dp[n+2][n+2] = {0};
double sum[n+2][n+2] = {0};
// 预处理概率和
for (int i = 1; i <= n; i++) {
sum[i][i] = p[i];
for (int j = i+1; j <= n; j++)
sum[i][j] = sum[i][j-1] + p[j];
}
for (int L = 1; L <= n; L++) {
for (int i = 1; i <= n - L + 1; i++) {
int j = i + L - 1;
dp[i][j] = DBL_MAX;
for (int k = i; k <= j; k++) {
double cost = dp[i][k-1] + dp[k+1][j] + sum[i][j];
if (cost < dp[i][j])
dp[i][j] = cost;
}
}
}
return dp[1][n];
}
注意:边界处理要小心。当 k=i 时,左子树为空;当 k=j 时,右子树为空。我刚开始写的时候忘了处理 dp[i][i-1] 和 dp[j+1][j] 的情况,结果数组越界了。
3. 凸多边形最优三角剖分
这个问题,说白了就是给一个凸多边形,用不相交的对角线把它切成三角形,要求所有三角形权值和最小。
权值怎么定义?通常是三角形三个顶点构成的某种函数,比如周长、面积、或者顶点权值之和。
状态定义
定义 dp[i][j] 表示从顶点 vi 到 vj 构成的子多边形的最优三角剖分代价。
状态转移方程
dp[i][j] = min(dp[i][k] + dp[k][j] + w(vi, vk, vj)) (i < k < j)
这里的 w(vi, vk, vj) 是三角形 (vi, vk, vj) 的权值。
代码实现
double minTriangulation(int n, double (*weight)(int, int, int)) {
double dp[n][n];
// 初始化:相邻顶点不能构成三角形
for (int i = 0; i < n; i++)
dp[i][i+1] = 0;
for (int L = 2; L < n; L++) {
for (int i = 0; i < n - L; i++) {
int j = i + L;
dp[i][j] = DBL_MAX;
for (int k = i+1; k < j; k++) {
double cost = dp[i][k] + dp[k][j] + weight(i, k, j);
if (cost < dp[i][j])
dp[i][j] = cost;
}
}
}
return dp[0][n-1];
}
三个问题的统一框架:
- 枚举区间长度 L
- 枚举区间起点 i
- 计算区间终点 j = i + L - 1
- 枚举分割点 k
- 根据状态转移方程更新 dp[i][j]
知识体系总览
下面这张图帮你理清这三个问题的关系:
避坑指南
我曾经在实现最优BST时犯过一个低级错误——把概率和 sum[i][j] 加在了错误的位置。你想想看,每次把 k 提成根,左右子树的深度都增加了1,所以 sum[i][j] 必须加在 min 外面,而不是里面。
还有一点:这三个问题的时间复杂度都是 O(n³)。n 在 100 以内还好,超过 500 就要考虑优化了。我在项目中用过四边形不等式优化,能把 O(n³) 降到 O(n²),但那是另一个话题了。
我的建议:先理解矩阵连乘,它是最直观的。然后看最优BST,理解概率和为什么要加。最后看凸多边形,你会发现它和矩阵连乘几乎一模一样——只是权值函数不同。
好了,这三个问题就讲到这里。代码我都跑过,没问题。你可以在自己的机器上试试,改改参数看看效果。
公众号:蓝海资料掘金营,微信deep3321