【动态规划入门】从矩阵链乘法到最优二叉查找树
闲话说在前头
进入ACM队以后,作为三人团队里最拉跨的一个,我默默地开启了天坑修补工作。最近在配合着《挑战程序设计竞赛2:算法与数据结构》刷AIZU的相关专题,个人感觉2比1通俗易懂多了,《挑战1》感觉啥都讲,但是啥都是点到为止,《挑战2》就逮着基础数据结构算法详细展开,感觉舒服多了。话说回来,最近推到了基础篇的动态规划专题,碰到了两个非常有意思的问题。记性差的我决定记录下来。
这两题在AIZU上的ID是 ALDS1_10_B和 ALDS_10_D。在这里我放了VJudge的链接。那我们就一个个来看吧。
先从简单的开始
(1)矩阵链乘法
矩阵链乘法问题的目标是找到最有效的方法来乘以给定的 个矩阵 。
编写一个程序,读取 的维度,并找到最小次数标量乘法以计算 。
首先由线性代数的知识知道,矩阵乘法满足结合律,不满足交换律,一个 的矩阵和一个 的矩阵相乘需要 次标量乘法运算,因为得到的是一个 的矩阵,而这个矩阵每一个元素的计算都经历了次乘法与次加法。我们希望通过改变运算顺序,让总的乘法计算次数最小。自然,这种情况下加法的次数也会变小。
我们设这个矩阵链的第一个矩阵大小为, 第二个矩阵大小,以此类推。
先从最简单的来看。很显然一个矩阵需要计算0次乘法。两个相邻矩阵只有一种计算方法,乘法次数为。
那么三个矩阵呢?由结合律,我们可以计算,也可以计算。前者需要计算次,后者需要计算 次。是不是出现了一些熟悉的身影?对了,三个矩阵组成的链可以拆分成前两个和后一个,或者前一个和后两个。而一个矩阵和两个矩阵的情况是确定的,因此只要两种情况取最小就行。
四个矩阵呢?就拆分成前一后三,前二后二,前三后一的情况来考虑,然后取最小值就行。一种、二种、三种的情况可以提前算出来,这就完成了已经计算的结果的再利用。接下来,一步步往上递推吧。当然这个递推式子在动态规划里面叫做“状态转移方程”。这个问题的状态转移方程用数学语言描述如下:
我们设表示第个矩阵到第个矩阵相乘的最小乘法次数。最终我们要通过递推关系计算得的值。根据这道题的样例输入,我们可以这样读入矩阵的尺寸:
#define rep(i,a,b) for(int i=a;i<=b;++i)
#define IN2(a,b) scanf("%d %d",&a,&b)
rep(i, 1, n) {
IN2(p[i - 1], p[i]);
dp[i][i] = 0;
}
并且将赋值为0。
接下来,分别计算矩阵链里连续2个、3个、……、 n个矩阵相乘的最小标量乘法次数。设长度为len,则若是第 个矩阵开始的连续len个矩阵,那么最后一个矩阵就是第个。我们就可以写出以下关键代码:
#define min(a,b) ((a)<(b)?(a):(b))
rep(len, 2, n) {
rep(i, 1, n - len + 1) {
int j = i + len - 1;
dp[i][j] = INT_MAX;
rep(k, i, j - 1) {
dp[i][j] = min(dp[i][j], dp[i][k] + dp[k + 1][j] + p[i - 1] * p[k] * p[j]);
}
}
}
接下来上完整代码:(学校的OJ系统这个专题只允许交C语言代码,这里就放C版本的了)
#include <stdio.h>
#include <stdlib.h>
#include <math.h>
#include <string.h>
#include <stdbool.h>
#include <limits.h>
#define IN1(a) scanf("%d",&a)
#define IN2(a,b) scanf("%d %d",&a,&b)
#define IN3(a,b,c) scanf("%d %d %d",&a,&b,&c)
#define GRP int T;scanf("%d",&T);rep(C,1,T)
#define etr putchar('\n')
#define rep(i,a,b) for(int i=a;i<=b;++i)
#define rrep(i,a,b) for(int i=a;i>=b;--i)
#define mem(arr,val) memset(arr,val,sizeof(arr))
#define min(a,b) ((a)<(b)?(a):(b))
typedef long long ll;
typedef unsigned long long ull;
int n;
int p[110], dp[110][110];
int main(void) {
IN1(n);
rep(i, 1, n) {
IN2(p[i - 1], p[i]);
dp[i][i] = 0;
}
rep(len, 2, n) {
rep(i, 1, n - len + 1) {
int j = i + len - 1;
dp[i][j] = INT_MAX;
rep(k, i, j - 1) {
dp[i][j] = min(dp[i][j], dp[i][k] + dp[k + 1][j] + p[i - 1] * p[k] * p[j]);
}
}
}
printf("%d\n", dp[1][n]);
return 0;
}
提高了一些难度
(2)最优二叉查找树
给定n个互异的关键字组成的序列,且关键字有序,我们想用这些关键字构造一棵二叉查找树。对每个关键字,一次搜索搜索到的概率为。可能有一些搜索的值不在内,因此还有个“失败节点”作为虚拟键,他们代表不在内的值。具体:代表所有小于的值,代表所有大于的值。而对于,虚拟键代表所有位于和之间的值。对于每个虚拟键,一次搜索对应于的概率为。要使得查找一个节点的代价(代价定义为:从根节点到目标节点的路径上的节点数目)期望值最小,就需要建立一棵最优二叉查找树。
在这里我们首先进行反推:如果一棵树是最优二叉查找树,那么它的两个子树(如果有的话)也一定是最优二叉查找树。否则子树还可以再继续优化,使得局部期望值减小,进而总期望值减小,不符合假设条件。
由题意,失败节点一定是叶节点。我们按照查找树的层数对这个问题进行考虑,个关键字组成的查找树最高可能有层。当处于最底层的时候,子树中只有一个节点,按照题意一定是失败节点,期望代价就是。
我们往上爬一层。二叉查找树中序遍历得到的序列一定有序,因此如果根节点是,那么它的左右儿子一定是和。对子节点而言,深度增加了1,因此代价为。
接下来爬第三层。假设根节点,那么可能的情况有两种,需要分别计算代价并取最小值:
- 左子树,右子树为组成的子树。此时的代价为。
- 左子树为组成的子树,右子树为。此时的代价为。
我们对第一种情况得到的式子做一个变形,得到。我们发现,第一个括号里面的是根节点所有子树取到的概率(权值)的总和,第二个括号内是左右子树的平均查找代价,最后再加上根节点的概率。其实这也很好理解:往上把两颗树挂在一个节点上的时候,子树的深度都加了1,所以需要把子树的概率乘以增加的深度再进行求和,这就是增加的查找代价。对于第二种情况,我们也可以做同样的分析。看到这个挂节点的过程,是不是和矩阵链乘法的推理过程非常像?
接下来,我们仿照矩阵链乘法选取一个区间进行分析,定义几个变量。令表示包含关键字 的二叉查找树的概率总和,表示这段关键字构成的最优二叉查找树的代价期望。最终,我们想要得到。若在其中选择作为树的根,则我们有
注意到,因此我们可以得到下面的关系:
可以验证,这个关系对第二层也是成立的。因此我们可以得到这样的状态转移方程:
我们发现,也可以通过动态规划的思想进行计算。首先我们初始化数组:
rep(i, 1, n + 1) {
w[i][i - 1] = q[i - 1];
e[i][i - 1] = q[i - 1];
}
接下来DP过程中的关键代码和矩阵链乘法的关键代码一模一样,这里不再赘述。这里我们用到了式子。
rep(len, 1, n) {
rep(i, 1, n - len + 1) {
int j = i + len - 1;
e[i][j] = INF;
w[i][j] = w[i][j - 1] + p[j] + q[j];
rep(k, i, j) {
e[i][j] = min(e[i][j], e[i][k - 1] + e[k + 1][j] + w[i][j]);
}
}
}
接下来上完整代码:
#include <stdio.h>
#include <stdlib.h>
#include <math.h>
#include <string.h>
#include <stdbool.h>
#define IN1(a) scanf("%d",&a)
#define IN2(a,b) scanf("%d %d",&a,&b)
#define IN3(a,b,c) scanf("%d %d %d",&a,&b,&c)
#define GRP int T;scanf("%d",&T);rep(C,1,T)
#define etr putchar('\n')
#define rep(i,a,b) for(int i=a;i<=b;++i)
#define rrep(i,a,b) for(int i=a;i>=b;--i)
#define elif else if
#define mem(arr,val) memset(arr,val,sizeof(arr))
#define min(a,b) ((a)<(b)?(a):(b))
typedef long long ll;
typedef unsigned long long ull;
const double INF = 1e9;
double w[510][510], e[510][510];
double p[510], q[510];
int n;
int main(void) {
IN1(n);
rep(i, 1, n) {
scanf("%lf", &p[i]);
}
rep(i, 0, n) {
scanf("%lf", &q[i]);
}
rep(i, 1, n + 1) {
w[i][i - 1] = q[i - 1];
e[i][i - 1] = q[i - 1];
}
rep(len, 1, n) {
rep(i, 1, n - len + 1) {
int j = i + len - 1;
e[i][j] = INF;
w[i][j] = w[i][j - 1] + p[j] + q[j];
rep(k, i, j) {
e[i][j] = min(e[i][j], e[i][k - 1] + e[k + 1][j] + w[i][j]);
}
}
}
printf("%.6f", e[1][n]);
return 0;
}
感谢你看到这里~
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· go语言实现终端里的倒计时
· 如何编写易于单元测试的代码
· 10年+ .NET Coder 心语,封装的思维:从隐藏、稳定开始理解其本质意义
· .NET Core 中如何实现缓存的预热?
· 从 HTTP 原因短语缺失研究 HTTP/2 和 HTTP/3 的设计差异
· 分享一个免费、快速、无限量使用的满血 DeepSeek R1 模型,支持深度思考和联网搜索!
· 使用C#创建一个MCP客户端
· ollama系列1:轻松3步本地部署deepseek,普通电脑可用
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· 按钮权限的设计及实现