【动态规划入门】从矩阵链乘法到最优二叉查找树

闲话说在前头

进入ACM队以后,作为三人团队里最拉跨的一个,我默默地开启了天坑修补工作。最近在配合着《挑战程序设计竞赛2:算法与数据结构》刷AIZU的相关专题,个人感觉2比1通俗易懂多了,《挑战1》感觉啥都讲,但是啥都是点到为止,《挑战2》就逮着基础数据结构算法详细展开,感觉舒服多了。话说回来,最近推到了基础篇的动态规划专题,碰到了两个非常有意思的问题。记性差的我决定记录下来。

这两题在AIZU上的ID是 ALDS1_10_BALDS_10_D。在这里我放了VJudge的链接。那我们就一个个来看吧。

先从简单的开始

(1)矩阵链乘法

矩阵链乘法问题的目标是找到最有效的方法来乘以给定的 n 个矩阵 M1,M2,M3,...,Mn
编写一个程序,读取 Mi 的维度,并找到最小次数标量乘法以计算 M1M2...Mn

首先由线性代数的知识知道,矩阵乘法满足结合律,不满足交换律,一个 p×q 的矩阵和一个 q×r 的矩阵相乘需要 p×r×q 次标量乘法运算,因为得到的是一个p×r 的矩阵,而这个矩阵每一个元素的计算都经历了q次乘法与q1次加法。我们希望通过改变运算顺序,让总的乘法计算次数最小。自然,这种情况下加法的次数也会变小。

我们设这个矩阵链的第一个矩阵M1大小为p0×p1, 第二个矩阵M2大小p1×p2,以此类推。

先从最简单的来看。很显然一个矩阵需要计算0次乘法。两个相邻矩阵MiMi+1只有一种计算方法,乘法次数为pi1×pi×pi+1

那么三个矩阵MiMi+1Mi+2呢?由结合律,我们可以计算(MiMi+1)Mi+2,也可以计算Mi(Mi+1Mi+2)。前者需要计算pi1×pi×pi+1+0+pi1×pi+1×pi+2次,后者需要计算0+pi×pi+1×pi+2+pi1×pi×pi+2 次。是不是出现了一些熟悉的身影?对了,三个矩阵组成的链可以拆分成前两个和后一个,或者前一个和后两个。而一个矩阵和两个矩阵的情况是确定的,因此只要两种情况取最小就行。

四个矩阵呢?就拆分成前一后三,前二后二,前三后一的情况来考虑,然后取最小值就行。一种、二种、三种的情况可以提前算出来,这就完成了已经计算的结果的再利用。接下来,一步步往上递推吧。当然这个递推式子在动态规划里面叫做“状态转移方程”。这个问题的状态转移方程用数学语言描述如下:

dp[i][j]={0,i=jpi1×pi×pi+1,j=i+1min{dp[i][k]+dp[k+1][j]+pi1×pk×pj|k=i,i+1,,j1},others

我们设dp[i][j]表示第i个矩阵到第j个矩阵相乘的最小乘法次数。最终我们要通过递推关系计算得dp[1][n]的值。根据这道题的样例输入,我们可以这样读入矩阵的尺寸:

#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;
}

并且将dp[i][i]赋值为0。

接下来,分别计算矩阵链里连续2个、3个、……、 n个矩阵相乘的最小标量乘法次数。设长度为len,则若是第 i 个矩阵开始的连续len个矩阵,那么最后一个矩阵就是第i+len1个。我们就可以写出以下关键代码:

#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个互异的关键字组成的序列K=<k1,k2,...,kn>,且关键字有序(k1<k2<...<kn),我们想用这些关键字构造一棵二叉查找树。对每个关键字ki,一次搜索搜索到的概率为pi。可能有一些搜索的值不在K内,因此还有n+1个“失败节点”d0,d1,...,dn作为虚拟键,他们代表不在K内的值。具体:d0代表所有小于k1的值,dn代表所有大于kn的值。而对于i=1,2,...,n1,虚拟键di代表所有位于kiki+1之间的值。对于每个虚拟键,一次搜索对应于di的概率为qi。要使得查找一个节点的代价(代价定义为:从根节点到目标节点的路径上的节点数目)期望值最小,就需要建立一棵最优二叉查找树。

在这里我们首先进行反推:如果一棵树是最优二叉查找树,那么它的两个子树(如果有的话)也一定是最优二叉查找树。否则子树还可以再继续优化,使得局部期望值减小,进而总期望值减小,不符合假设条件。

由题意,失败节点一定是叶节点。我们按照查找树的层数对这个问题进行考虑,n个关键字组成的查找树最高可能有n层。当处于最底层的时候,子树中只有一个节点,按照题意一定是失败节点,期望代价就是qi

我们往上爬一层。二叉查找树中序遍历得到的序列一定有序,因此如果根节点是ki,那么它的左右儿子一定是di1di。对子节点而言,深度增加了1,因此代价为2×(qi1+qi)+pi

接下来爬第三层。假设根节点ki,那么可能的情况有两种,需要分别计算代价并取最小值:

  • 左子树di1,右子树为ki+1,di,di+1组成的子树。此时的代价为2×qi1+[2×pi+1+3×(qi+qi+1)]+pi
  • 左子树为ki1,di2,di1组成的子树,右子树为di。此时的代价为[2×pi1+3×(qi2+qi1)]+2×qi+pi

我们对第一种情况得到的式子做一个变形,得到(qi1+pi1+qi+qi+1)+{1×qi1+[1×pi+1+2×(qi+qi+1)]}+pi。我们发现,第一个括号里面的是根节点所有子树取到的概率(权值)的总和,第二个括号内是左右子树的平均查找代价,最后再加上根节点的概率。其实这也很好理解:往上把两颗树挂在一个节点上的时候,子树的深度都加了1,所以需要把子树的概率乘以增加的深度再进行求和,这就是增加的查找代价。对于第二种情况,我们也可以做同样的分析。看到这个挂节点的过程,是不是和矩阵链乘法的推理过程非常像?

接下来,我们仿照矩阵链乘法选取一个区间进行分析,定义几个变量。令w[i][j]表示包含关键字 ki,ki+1,,kj的二叉查找树的概率总和,e[i][j]表示这段关键字构成的最优二叉查找树的代价期望。最终,我们想要得到e[1][n]。若在其中选择kr作为树的根,则我们有

e[i][j]=pr+(e[i][r1]+w[i][r1])+(e[r+1][j]+w[r+1][j])

注意到w[i][r1]+pr+w[r+1][j]=w[i][j],因此我们可以得到下面的关系:

e[i][j]=e[i][r1]+e[r+1][j]+w[i][j]

可以验证,这个关系对第二层也是成立的。因此我们可以得到这样的状态转移方程:

e[i][j]={qi1,j=i1min{e[i][r1]+e[r+1][j]+w[i][j]|r=i,i+1,,j},others

我们发现,w[i][j]也可以通过动态规划的思想进行计算。首先我们初始化数组:

rep(i, 1, n + 1) {
	w[i][i - 1] = q[i - 1];
	e[i][i - 1] = q[i - 1];
}

接下来DP过程中的关键代码和矩阵链乘法的关键代码一模一样,这里不再赘述。这里我们用到了式子w[i][j]=w[i][j1]+pj+qj

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;
}

感谢你看到这里~

posted @   AlexHoring  阅读(298)  评论(0编辑  收藏  举报
编辑推荐:
· go语言实现终端里的倒计时
· 如何编写易于单元测试的代码
· 10年+ .NET Coder 心语,封装的思维:从隐藏、稳定开始理解其本质意义
· .NET Core 中如何实现缓存的预热?
· 从 HTTP 原因短语缺失研究 HTTP/2 和 HTTP/3 的设计差异
阅读排行:
· 分享一个免费、快速、无限量使用的满血 DeepSeek R1 模型,支持深度思考和联网搜索!
· 使用C#创建一个MCP客户端
· ollama系列1:轻松3步本地部署deepseek,普通电脑可用
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· 按钮权限的设计及实现
点击右上角即可分享
微信分享提示