完整可执行代码见 sun2ot/algorithm-learning
基本思想
与分治法类似,其基本思想也是将待求解问题分解成若干个子问题,先求解子问题,然后从这些子问题的解得到原问题的解。
与分治法不同的是,适合于用动态规划法求解的问题,经分解得到的子问题往往不是互相独立的,有些子问题被重复计算了许多次。如果我们能够保存已解决的子问题的答案,而在需要时再找出已求得的答案,这样就可以避免大量的重复计算,从而得到多项式时间算法。
为了达到此目的,可以用一个表来记录所有已解决的子问题的答案。不管该子问题以后是否被用到,只要它被计算过,就将其结果填入表中。这就是动态规划法的基本思想。
矩阵连乘问题
问题定义
矩阵连乘算法问题是一个经典的优化问题,其目标是找到一种最优的计算顺序,以最小化矩阵连乘操作的总次数。
假设有一系列矩阵 ,其中矩阵 的维度为 ,我们想要计算它们的连乘积 。注意,矩阵连乘是一个不可交换的操作,即矩阵的乘法顺序会影响最终结果。
矩阵连乘问题的目标是找到一种最优的计算顺序,使得计算矩阵连乘的总次数最小。这个问题可以使用动态规划的方法来解决,其中关键是定义一个递归的状态和状态转移方程。
分析最优子结构
将矩阵连乘积 记为 。假设最优计算次序是在 和 之间断开,则原问题的解即为 的计算量加上 的计算量再加上 的计算量。
那么这个拆解过程存在一个前提,就是计算 的最优次序所包含的矩阵子链 和 的次序也是最优的。我们可以用反证法来证明这一点是成立的。
假设当 这种计算次序最优时, 的计算次序不是最优的,即存在 ,使得 的计算量小于 。那么显然, 这种拆解方式的计算量一定小于原先的 和 ,即 的计算次序不是最优,这与假设矛盾。
{% note info modern %} 同理可证 所包含的矩阵子链 也是最优的。 {% endnote %}
综上,矩阵连乘问题的最优解包含着其子问题的最优解,这种性质称为最优子结构性质。
{% note warning modern %} 问题的最优子结构性质是该问题可用动态规划算法求解的显著特征。 {% endnote %}
算法思想
在确定了原问题具有最优子结构性质后,我们需要递归地定义最优值。假设计算 所需的最少数乘次数为 ,则原问题 的最优值为 。
- 当 时, 就是一个单独的矩阵,所以
- 当 时,
{% note info modern %} 时的解释 关于 ,我们将矩阵 的维数记为 。此处有一个问题,就是对于两个矩阵相乘,总共的数乘次数为多少?若 是 矩阵, 是 矩阵,则计算 总共需要 次数乘。
p 行,每行都要乘 r 列,一共就是 pr 次乘法。每一次乘法都是 q 个数和 q 个数相乘,因此总共为 prq=pqr 次。
因此, 表示从 乘到 ,其中 是 矩阵, 是 矩阵,由于矩阵连乘的前提一定是矩阵链是可乘的,因此 的结果是一个 矩阵。同理, 的结果是 矩阵。所以这两个矩阵相乘的数乘次数为 。 {% endnote %}
那么 取多少呢?我们无法确定,因为 的位置有 共 种可能,因此 递归地定义为:
上式给出了两个信息,一是最优值 ,即矩阵连乘所需的最小次数;二是最优解 ,即应该怎么划分才能获得最优值。
代码实现
/** * 使用动态规划解决矩阵连乘问题,并返回最小计算次数和最优解 * * @param dimensions 矩阵的维度数组 * @param n 矩阵的数量 * @param dp 存储最小计算次数的二维数组 * @param split 存储最优划分点的二维数组 * @return 最小计算次数 */int matrixChainOrder(int dimensions[], int n, int dp[MAX_MATRICES][MAX_MATRICES], int split[MAX_MATRICES][MAX_MATRICES]) { // 初始化dp数组和split数组 for (int i = 1; i <= n; i++) { dp[i][i] = 0; }
// 计算子问题的最优解和划分点 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++) { // 子问题的划分点 int cost = dp[i][k] + dp[k + 1][j] + dimensions[i - 1] * dimensions[k] * dimensions[j]; if (cost < dp[i][j]) { dp[i][j] = cost; // 更新最优值 split[i][j] = k; // 记录最优划分点 } } } }
// 返回最小计算次数 return dp[1][n];}
/** * 输出最优解 * * @param split 分割矩阵的位置 * @param i 矩阵的起始位置 * @param j 矩阵的结束位置 */void printOptimalParenthesis(int split[MAX_MATRICES][MAX_MATRICES], int i, int j) { if (i == j) { // 矩阵只有一个时,直接输出 printf("A%d", i); } else { // 矩阵有多个时,递归输出 printf("("); // 输出左括号 printOptimalParenthesis(split, i, split[i][j]); // 输出左边的矩阵链的 printOptimalParenthesis(split, split[i][j] + 1, j); // 输出右边的矩阵链 printf(")"); // 输出右括号 }}在这段代码中,三重循环的循环变量分别代表以下内容:
- 外层循环变量
len:表示子问题的规模,从2开始逐渐增加,直到达到矩阵的大小n。这个循环用于控制子问题的规模。 - 中间循环变量
i:表示子问题的起始位置,从1开始逐渐增加,直到n - len + 1。这个循环用于遍历所有可能的子问题起始位置。 - 内层循环变量
k:表示子问题的划分点,从i开始逐渐增加,直到j。这个循环用于遍历所有可能的划分点,计算出最优的划分点。
{% note info modern %} 关于 len {% endnote %}
循环从 len = 2 开始,是因为在矩阵乘法中,我们需要先计算较小的子矩阵的乘积,然后逐渐增加子矩阵的大小。通过从较小的子矩阵开始,我们可以利用已经计算出的乘积结果来计算更大的子矩阵的乘积。
例如,对于一个 4x4 的矩阵,我们首先计算 2x2 的子矩阵的乘积,然后计算 3x3 的子矩阵的乘积,最后计算整个 4x4 矩阵的乘积。通过从较小的子矩阵开始,我们可以利用已经计算出的 2x2 子矩阵的乘积来计算 3x3 子矩阵的乘积,从而减少计算量。
因此,循环从 len = 2 开始,以便从较小的子矩阵开始计算乘积,并逐渐增加子矩阵的大小,直到计算整个矩阵的乘积。
{% note warning modern %} 为什么 ? {% endnote %}
假设矩阵下标从 1 开始到 n。当问题规模为 len 时,即 这样逐个划分计算,其对应的起始位置就是 。那么,我们要求起始位置的上界,对应的极端情况显然是长度为 len 的矩阵链在整个矩阵连乘的式子中尽可能地靠右,这样得到的起始位置就是尽可能大的。
所以问题就变成了,已知最后(最右的)一个矩阵为 n,从它开始向左推一个长度为 len 的矩阵链,这个链的起始位置是谁? 显然,答案是 。
{% note warning modern %} 矩阵的维度有两个值,为什么可以通过一维数组 dimensions 来表示? {% endnote %}
参见上文两矩阵的数乘次数问题,我们可知,对于第 i 个矩阵, dimensions[i-1] 是它的行数, dimensions[i] 是它的列数。这是因为,如果我们按照顺序进行矩阵链乘法,那么第 i 个矩阵的列数必须等于第 i+1 个矩阵的行数(矩阵可乘,左列等于右行),所以我们可以用一个连续的数组来存储所有矩阵的行数和列数。
最长公共子序列
问题定义
给定两个序列 ,找出最长的、公共的子序列。
分析最优子结构
最长公共子序列问题有没有最优子结构性质,说白了就是,如果有最长公共子序列 , 是不是第二长的?(当然是的)下面给出该问题的最优子结构性质定义:
设序列 的最长公共子序列为 ,则
- 若 ,则 ,且 是 和 的最长公共子序列
- 若 且 ,则 是 和 的最长公共子序列
- 若 且 ,则 是 和 的最长公共子序列
{% note info modern %} 下面分别针对这三点给出证明 {% endnote %}
若 时,,那意味着最长公共子序列 不是最长的。因为 显然也是公共子序列,且长度为 ,比 还长,构成矛盾。所以当 时,必有 ,同时 是 和 的公共子序列。
接下来证明 是最长的。假设 和 有长度 的公共子序列 ,那就八达鸟了。因为 的长度至少是 ,再把两个父串共有的 加上, 的长度直接达到 ,此之谓无中生有,故矛盾。
若 且 ,则 肯定是 和 的公共子序列,不过是不是最长的呢?还是反证法,我们假设 和 有长度大于 的公共子序列 ,显然 也是 和 的公共子序列。但 和 的最长公共子序列为 长度也只有 ,故矛盾。
第三点证明与二同理,略。
综上,最长公共子序列问题具有最优子结构性质。
算法思想
根据最优子结构性质可知,
- 任一串为空时,最长公共子序列长度为 0
- 当 时,找出 和 的最长公共子序列,然后尾部加上 即为 和 的最长公共子序列。
- 当 时,取 和 、 和 的最长公共子序列中更长的一个
将序列 和 的最长公共子序列长度记为 ,根据以上内容,构建递推关系式如下:
代码实现
/** * 使用动态规划解决最长公共子序列问题 * * @param str1 字符串1 * @param str2 字符串2 */void longestCommonSubsequence(char* str1, char* str2) { int m = strlen(str1); int n = strlen(str2);
int dp[m + 1][n + 1];
// 初始化dp数组的第一行和第一列 for (int i = 0; i <= m; i++) { dp[i][0] = 0; } for (int j = 0; j <= n; j++) { dp[0][j] = 0; }
// 计算dp数组的其他元素 for (int i = 1; i <= m; i++) { for (int j = 1; j <= n; j++) { if (str1[i - 1] == str2[j - 1]) { // 两个子串的最后一个字符相同 dp[i][j] = dp[i - 1][j - 1] + 1; } else { dp[i][j] = (dp[i - 1][j] > dp[i][j - 1]) ? dp[i - 1][j] : dp[i][j - 1]; } } }
// 构造最长公共子序列 int lcsLength = dp[m][n]; char lcs[lcsLength + 1]; lcs[lcsLength] = '\0';
int i = m, j = n; while (i > 0 && j > 0) { // 串的扫描和LCS的构建都是从右往左,倒着来 if (str1[i - 1] == str2[j - 1]) { // 两个串的尾部字符相等,说明该字符属于LCS lcs[lcsLength - 1] = str1[i - 1]; i--; j--; lcsLength--; } else if (dp[i - 1][j] > dp[i][j - 1]) { // 删去str1最后一个字符得到的LCS比删去str2最后一个字符得到的LCS长,因此i向前回溯 i--; } else { j--; } } printf("LAS Length is: %d\n", dp[m][n]); printf("LCS is: %s\n", lcs); // 注意,最长公共子序列并不唯一}如果这篇文章对你有帮助,欢迎分享给更多人!
部分信息可能已经过时









