动态规划进阶(1) - 经典问题

509 阅读3分钟

动态规划进阶(1) - 经典问题

作者:光火

邮箱:victor_b_zhang@163.com

各大论坛及技术博客上不乏动态规划相关的文章,但是就整体而言,它们普遍面向的是初学者,所例举的题目也无非是走楼梯、机器人路径这类简单明了的问题。在如此情形下,那些已经入门动态规划,但是又缺乏习题积累,尚未达到精通水平的读者往往无法找到合适的教程。因此,本文尝试汇总一些中等难度的动态规划问题,以供这部分读者温习巩固。

基础回顾

不喜欢阅读大段文字的读者,可直接跳过该部分内容

  • 动态规划 vsvs 分治算法

    • 动态规划算法与分治算法具有一定的相似性。分治算法可以将问题划分为互不相交的子问题,并递归地对子问题进行求解,再将它们的解组合起来,进而获得原问题的解。动态规划算法则应用于子问题重叠的情况,即不同的子问题拥有公共的子部分。此时若应用分治算法,会导致程序反复求解公共子问题,造成性能损失。动态规划算法则可以保证对于每一个子问题只求解一次
  • 最优子结构

    • 应用动态规划算法求解<最优化问题>的重要一步就是刻画最优解的结构,如果一个问题的最优解包含其子问题的最优解,就称该问题具有最优子结构性质。
    • 应用动态规划算法需要保证子问题之间不可相互影响,否则就不存在最优子结构。
    • 拥有最优子结构的问题并不一定就需要利用动态规划进行求解。
  • 复杂度估计

    • 动态规划算法的复杂度本质上取决于原问题的最优解涉及多少个子问题,以及在确定最优解应当使用哪些子问题时,需要考察多少种选择。利用此两者的乘积就可以粗略估算出计算的规模。
  • 自顶向下 vsvs 自底向上

    • 关于自底向上法自顶向下法的对比:两者的复杂度一致。通常情况下,自底向上的动态规划算法会比自顶向下的备忘录法快,因为自底向上算法没有递归调用的开销,而且表的维护代价也更小。并且,对于某些问题,还可以利用表的访问模式来进一步降低时空代价。但是,如果子问题空间中的某些子问题完全不需要求解,那么备忘录算法的优势就会得以体现,因为它只求解那些绝对必要的子问题

钢条切割问题

  • 假定我们知道 SerlingSerling 公司出售一段长度为 ii 英寸的钢条的价格为 pip_i (i=1,2,...i=1,2,...,单位为美元)。钢条的长度均为整英寸,下表给出了一个价格表的样例。对于长度为 lengthlength 的钢条,我们希望获知它能够卖出的最高价格
长度i长度i12345678910
价格pi价格p_i1589101717202430
// 函数声明
int cutRod(vector<int>& prices, int length);

题目的表述还是比较清晰的,对于一段钢条,我们可以将其拆分为不同的部分进行售卖(不拆解也行),并希望尽可能多地获取利润

最值是动态规划问题的标志之一,但是要想进一步地得到确认,还需考察重叠子问题和最优子结构

让我们考虑一段非常长的钢条,它的尺寸已经超出了价格表中所规定的可售卖的最大长度,因此我们必须将它切成若干段。我们首先切一刀,将这段钢条一分为二。假设切割的位置刚好就是最优化方案的一步,那么接下来我们很自然地会希望余下的两段都能卖出各自的最高价格,这样两者合在一起的价格也一定是最高的。可见,我们能够通过子问题的解拼凑出原问题的解,这说明本题具有最优子结构。或许,你会好奇,我怎么知道自己切的这刀就是最优解的一部分呢?答案是遍历就好啦~

进一步,我们发现无论我们怎么切割,这段钢条的尾端总会剩下一小段。那么,我们不妨认为最后这小段的长度为 i。由于是最终切割方案,因此 i 一定能在价格表中找到相应的 prices[i],这样我们就将两个子问题缩小为一个。我们定义 dp[n] 为长度为 n 的钢条所能售卖的最大价格,则有如下状态转移方程:

dp(n)={0,n=01,n<0max{dp(ni)+prices[i]},n>0dp(n)=\begin{cases} 0,n=0\\ -1,n < 0 \\ max\{dp(n-i)+prices[i]\},n>0 \end{cases}

显然,本题也具有重叠子结构,因为 dp[10]dp[15] 都包含 dp[8] 这样的可选子项,我们并不希望反复对其进行求解(考虑一颗递归树,动态规划完全可以写成备忘录式的自顶向下程序)

int cutRod(vector<int>& prices, int length) {
	if(length < 0) return -1;
	vector<int> dp(length + 1, 0);
	int limit = prices.size() - 1;
	for(int i = 1; i <= length; i ++) {
		for(int j = 1; j <= min(i, limit); j ++) {
			dp[i] = max(dp[i], dp[i - j] + prices[j]);
		}
	}
        
        return dp[length];
}
  • 上述代码第二层循环中的 min(i, limit) 主要是为了防止数组越界,因为价格表可能没有 length 那么长
  • 至此,我们就可以获知长度为 length 的钢条所能卖出的最高价格。但对于实际应用场景,企业更希望获取具体的销售方案,这就要求我们记录每一步选择:
int cutRod(vector<int>& prices, int length) {
	if(length < 0) return -1;
	vector<int> dp(length + 1, 0);
	vector<int> step(length + 1, 0);
    
	int limit = prices.size() - 1;
	for(int i = 1; i <= length; i ++) {
		for(int j = 1; j <= min(i, limit); j ++) {
			int tot = dp[i - j] + prices[j];
			if(tot > dp[i])  dp[i] = tot, step[i] = j;
		}
	}
    
	int tmp = length;
	while(tmp > 0) {
		std::cout << step[tmp] << " ";
		tmp -= step[tmp];
	}
        
        return dp[length];
}

矩阵链乘问题

  • 给定 nn 个矩阵的链 <A1,A2,...,An><A_1,A_2,...,A_n>,矩阵 AiA_i 的规模为 pi1×pi(1in)p_{i - 1}\times p_i(1\leq i\leq n),求完全括号化方案,使得计算乘积 A1A2...AnA_1A_2...A_n 所需的标量乘法次数最少。注意,求解矩阵链乘法题并不是要真正进行矩阵相乘运算,而是要确定代价最低的计算顺序,为后续相乘节省时间
// 函数声明
int matrixChainOrder(int n, int* size); 

矩阵乘法满足结合律,通过为矩阵链添加括号,改变运算顺序,我们可以极大减少后续标量乘法所需要的次数,因此本题是具有一定应用价值的

首先,题干中的 “确定代价最低的计算顺序” 表明这是一道最优化问题,于是我们迅速想到或许可以利用动态规划来进行求解。但是人们的直觉未必是可靠的,我们仍旧需要考察本题是否具有最优子结构和重叠子问题

对于一个矩阵链,我们随机地加上一对括号,就会将其划分为若干个部分。假设这个括号的摆放位置恰巧就是最优化方案的一步,那么接下来,我们自然会希望被划分出来的各个子部分同样能取得最优。至此,我们大致就可以判断本题具有最优子结构

具体来说,假设我们一开始选取的括号位于矩阵列的中间部分,这会将矩阵链划分为三个部分。倘若我们已知这三个子部分的最优化方案,那么它们合在一起,就可以得到原问题的最优解决策略。对于括号的一端放置在链首或链尾的情况也基本相同

或许,你又会好奇,我怎么知道第一个括号放在哪里呢?答案还是遍历就好了。其实,动态规划的本质就是遍历,只不过它采用了一种特殊的技巧,保证对于每种情况,只求解一次

通过分析本题的最优子结构,我们的另外一层收获就是,大致可以得知本题的状态应当如何定义。我们需要为这个矩阵链添加括号,括号又分为左右两个部分,因此很自然地想到可以定义一个二维数组 dp[i][j],它表示自矩阵 i 到矩阵 j 乘起来所需要花费的最小运算次数。你可以认为矩阵 i 的左侧就放置着左括号,矩阵 j 的右侧就放置着右括号。那么,我们最终的目的就是求解 dp[1][n]

不过,先等一下,我们似乎还没有考察重叠子问题。这很简单,dp[1][10]dp[1][8] 都包含 dp[2][4],你不会想要重复计算它吧

至此,我们已经基本明确了本题的求解思路。下面,我们就要构思该如何求解 dp[1][n],它位于矩阵的右上角,而初值 dp[i][i] 的值显然为 0,矩阵主对角线以下的部分又没有意义(即对于 dp[i][j],当且仅当 jij\geq i 时才有实际含义)。因此,我们需要逐对角线填满这个上三角,直至得到 dp[1][n]。让我们写出状态方程:

dp[i,j]={0ifi==jminik<j{dp[i,k]+dp[k+1,j]+sizei1sizeksizej}ifi<jdp[i,j]=\begin{cases}0\quad \quad \quad \quad if\quad i==j \\ min_{i\leq k<j}\{dp[i,k]+dp[k+1,j]+size_{i-1}size_ksize_{j}\}\quad if\quad i <j\end{cases}
  • sizei1sizeksizejsize_{i-1}size_ksize_{j} 是我们将 [i][k][k+1][j] 这两部分乘起来所需要花费的代价。矩阵链[i][k] 的运算结果是一个 sizei1sizeksize_{i-1}size_k 的矩阵,而矩阵链 [k+1][j] 的运算结果则是一个 sizeksizejsize_ksize_j 的矩阵
  • 通过观察递推公式中出现的 ijk,可以知晓程序实现需要三层循环。且对于任意待求项 dp[i][j],它考虑的子项位于其同行左侧(dp[i][k])与同列下侧(dp[k+1][j])。因此我们应当自主对角线起,由上至下,逐对角线地填充表格
int matrixChainOrder(int n, int* size) {
	if(n < 0 || size == nullptr)
		return -1;

	int** dp = new int*[n + 1];
	for(int i = 0; i <= n; i ++)
		dp[i] = new int[n + 1];

	for(int i = 1; i <= n; i ++)
		for(int j = i + 1; j <= n; j ++)
			dp[i][j] = INT_MAX;

	for(int i = 1; i <= n; i ++)
		dp[i][i] = 0;

	for(int l = 2; l <= n; l ++)
		for(int i = 1; i <= n - l + 1; i ++) {
			int j = i + l - 1;
			for(int k = i; k < j; k ++) {
				int val = dp[i][k] + dp[k + 1][j] +
				          size[i - 1] * size[k] * size[j];
				dp[i][j] = min(dp[i][j], val);
			}
		}

	int rtn = dp[1][n];

	for(int i = 0; i <= n; i ++)
		delete[] dp[i];
	delete[] dp;
	return rtn;
}

至此,dp[1][n]即为计算整个矩阵链所需的最小运算次数了。但考虑到本题的实际应用场景,我们还是希望可以获得具体的运算顺序:

void martixChainOrder(int n, int* size, int** step) {
	if(n < 0 || size == nullptr || step == nullptr)
		return;

	// ...

	for(int l = 2; l <= n; l ++)
		for(int i = 1; i <= n; i ++) {
			int j = i + l - 1;
			for(int k = i; k < j; k ++) {
				int val = dp[i][k] + dp[k + 1][j] +
				          size[i - 1] * size[k] * size[j];
				if(dp[i][j] > val) {
					dp[i][j] = val;
					step[i][j] = k;
				}
			}
		}

	// ...
	showAnswer(step, 1 , n);
}

void showAnswer(int** step, int begin, int end) {
	if(begin == end) {
		std::cout << "A" << begin;
	} else {
		std::cout << "(";
		showAnswer(step, begin, step[begin][end]);
		showAnswer(step, step[begin][end] + 1, end);
		std::cout << ")";
	}
}

最长公共子序列

  • 给定两个序列 X=<x1,x2,...,xm>X=<x_1,x_2,...,x_m>Y=<y1,y2,...,yn>Y=<y_1,y_2,...,y_n>,求 XXYY 最长的公共子序列
// 函数声明
int LCS(string& X, string& Y); 

这是一道经典的模板题目,求解本题的思路可以很好地应用在其他含字符串的动态规划问题上,我们在后文中会印证这一点。对于字符串类的动态规划问题,大都可通过二维数组进行求解。就本题而言,我们分析后可得如下结论:

LCS(longestcommonsubsequenceproblem)最优子结构X=<x1,x2,...,xm>Y=<y1,y2,...,yn>为两个序列Z=<z1,z2,...zk>XYLCS1.如果xm=yn,则zk=xm=yn,且Zk1Xm1Ym1LCS2.如果xmyn,那么zkxm意味着ZXm1Y的一个LCS3.如果xmyn,那么zkyn意味着ZXYn1的一个LCSLCS(longest-common-subsequence\quad problem)最优子结构 \\ 令X=<x_1,x_2,...,x_m>,Y=<y_1,y_2,...,y_n>为两个序列 \\ Z=<z_1,z_2,...z_k>为X和Y的\forall LCS \\ 1.如果x_m=y_n,则z_k=x_m=y_n,且Z_{k-1}是X_{m-1}和Y_{m-1}的LCS \\ 2.如果x_m\neq y_n,那么z_k\neq x_m 意味着Z是X_{m-1}和Y的一个LCS \\ 3.如果x_m\neq y_n,那么z_k\neq y_n意味着Z是X和Y_{n-1}的一个LCS

由上述分析过程可知,本题具有最优子结构。我们定义 dp[i][j] 为:当 X 长度为 iY 长度为 j 时,两者的 LCS 长度。请着重理解并记忆这个状态的定义,我们还会再看到它。明确数组含义后,可得如下状态转移方程:

dp[i,j]={0ifi=0orj=0dp[i1,j1]+1ifi,j>0Xi=Yjmax(dp[i,j1],dp[i1,j])ifi,j>0XiYjdp[i,j]=\begin{cases}0\quad if\quad i=0\quad or \quad j=0\\ dp[i-1,j-1]+1\quad if \quad i,j>0 \quad X_i=Y_j \\ max(dp[i,j-1],dp[i-1,j])\quad if\quad i,j>0\quad X_i\neq Y_j\end{cases}
int LCS(string& X, string& Y) {
	if(X == "" || Y == "") {
		return 0;
	}
	
	int x_length = X.length();
	int y_length = Y.length();
	
	vector<vector<int>> dp(x_length + 1, vector<int>(y_length + 1));
	
	for(int i = 1; i <= x_length; i ++) {
		for(int j = 1; j <= y_length; j ++) {
			if(X[i - 1] == Y[j - 1]) {
				dp[i][j] = dp[i - 1][j - 1] + 1;
			}else {
				dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]);
			}
		}
	}
	
	showAnswer(dp, X, x_length, y_length);
	return dp[x_length][y_length];
}

在完成 dp 数组的计算后, 可以通过如下方式获知 LCS

void showAnswer(vector<vector<int>>& dp, string& X, int x, int y) {
	if(x == 0 || y == 0) return;
	if(dp[x][y] == dp[x - 1][y - 1] + 1) {
		showAnswer(dp, X, x - 1, y - 1);
		cout << X[x - 1];
	}else if(dp[x][y] == dp[x - 1][y]) {
		showAnswer(dp, X, x - 1, y);
	}else {
		showAnswer(dp, X, x, y - 1);
	}
}

编辑距离

  • 本题在 LeetCodeLeetCode 中的难度划分为 HardHard,但解题思路其实与 LCSLCS 大致相同,感兴趣的读者可以先自行尝试:72. 编辑距离
  • 给定两个单词 word1 和 word2, 请返回将 word1 转换成 word2 所使用的最少操作数
    • 你可以对一个单词进行如下三种操作:
      • 插入一个字符
      • 删除一个字符
      • 替换一个字符
// 函数声明
int minDistance(string word1, string word2);

拿到题目,我们还是先分析。这仍旧是一道字符串相关的最优化问题,并且只要求我们返回最少的操作次数。这些特征均启示我们可以考虑利用动态规划来求解此题

LCS 的影响,我们仍旧采用二维数组,并定义 dp[i][j] 为当 word1 的长度为 iword2 的长度为j 时,将 word1 转化为 word2 所需的最小操作数

同时,应当注意 word1word2 本质上是等价的。下文中,维持 word1 改变 word2 的操作,也可以反过来理解,且不会改变递推关系

word1[i]=word2[j]时,dp[i][j]=dp[i1][j1]即无需多余的操作,扫描下一个字符,两者就自然配对了,没有消耗额外的步骤当 word1[i] = word2[j] 时,dp[i][j] = dp[i - 1][j - 1] \\ 即无需多余的操作,扫描下一个字符,两者就自然配对了,没有消耗额外的步骤
word1[i]word2[j]时,考虑可行的三种操作:插入、删除、替换当 word1[i] \not = word2[j] 时,考虑可行的三种操作: 插入、删除、替换
[1].替换word1[i],使其与word2[j]相等(亦或是反过来):dp[i][j]=1+dp[i1][j1][1]. 替换 word1[i], 使其与 word2[j] 相等 (亦或是反过来): \\ dp[i][j] = 1 + dp[i - 1][j - 1]
[2].word2[j1]后插入word1[i],并调整前面的部分使得word1[0..i]=word2[0...j1]dp[i][j]=1+dp[i][j1][2]. 在 word2[j-1] 后插入 word1[i], 并调整前面的部分使得 word1[0 .. i] = word2[0 ... j - 1] \\ dp[i][j] = 1 + dp[i][j - 1]
[3].word[i]删除,并调整word1[0..i1]=word2[0..j]dp[i][j]=1+dp[i1][j]dp[i][j]=min(dp[i1][j1],dp[i][j1],dp[i1][j])+1[3]. 将 word[i] 删除, 并调整 word1[0 .. i - 1] = word2[0 .. j] \\ dp[i][j] = 1 + dp[i - 1][j] \\ dp[i][j] = min(dp[i - 1][j - 1], dp[i][j - 1], dp[i - 1][j]) + 1
  • 需要注意的是,word1[0..i1]word1[0 .. i - 1]并不代表串的实际长度就是i,因为它可能已经经历了多次操作,如此书写只是为了表示方便。可以理解在一边读取word1的字符,一边尝试对它进行调整
int minDistance(string word1, string word2) {
	int len1 = word1.length();
	int len2 = word2.length();
	int** dp = new int*[len1 + 1];
	for(int i = 0; i <= len1; i ++)
		dp[i] = new int[len2 + 1];

	dp[0][0] = 0;
	for(int i = 1; i <= len1; i ++)
		dp[i][0] = dp[i - 1][0] + 1;

	for(int i = 1; i <= len2; i ++)
		dp[0][i] = dp[0][i - 1] + 1;

	for(int i = 1; i <= len1; i ++)
		for(int j = 1; j <= len2; j ++)
			if(word1[i - 1] == word2[j - 1]) {
				dp[i][j] = dp[i - 1][j - 1];
			} else {
				int tot = dp[i - 1][j] > dp[i][j - 1]?
				          dp[i][j - 1] : dp[i - 1][j];
				dp[i][j] = dp[i - 1][j - 1] > tot? tot + 1:
				           dp[i - 1][j - 1] + 1;
			}

	int rtn = dp[len1][len2];
	for(int i = 0; i <= len1; i ++)
		delete[] dp[i];
	delete[] dp;
	return rtn;
}