动态规划应用篇:详解最长公共子序列问题

动态规划是一个强大的工具,将复杂问题分解为多个容易解决的子问题,并且会对中间结果进行存储从而避免重复计算,然后将它们的解组合起来,形成大问题的解,高效地得出全局最优解。前面我们已经了解了动态规划的基础知识及一维动态规划问题的求解,今天,我们将继续深入学习动态规划算法,通过讲解最长公共子序列(LCS)问题的例子来学习更为复杂的二维动态规划。

最长公共子序列(LCS)

问题描述

最长公共子序列(Longest Common Subsequence,LCS)问题是一个经典的计算机科学问题,它寻找两个序列共有的最长子序列。这里的“子序列”是指在不改变序列元素的相对顺序的情况下,通过删除一些元素(也可能一个都不删)形成的新序列。注意,子序列不同于子串,子串要求元素在原序列中是连续的,而子序列不要求连续。

举个例子,如果我们有两个字符串 A = "ABCD" 和 B = "ACBAD",它们的一个最长公共子序列是 "ABD"。可以看出,"ABD" 是通过从 A 中选择"A", "B", "D" 且从 B 中选择"A", "B", "D"形成的,保持了它们在各自序列中的相对位置,但并不要求连续。

问题分析

这里我们先简单回顾一下动态规划的相关知识。动态规划解决问题的关键是将大问题分解为容易解决的小问题,解决这些小问题,然后将它们的解组合起来,形成大问题的解。因为分解得到的小问题通常都会有重叠部分,我们需要对中间结果进行存储从而避免重复计算。要成功应用动态规划,我们需要识别出两个主要特性:最优子结构和重叠子问题。

  • 最优子结构:一个问题的最优解包含了其子问题的最优解。
  • 重叠子问题:在解决大问题的过程中,相同的小问题会被反复解决多次。

在LCS问题中,我们会发现,一个序列的LCS可以通过组合其子序列的LCS得到,这就利用了最优子结构的特性。同时,要找到两个序列的最长公共子序列,我们需要多次寻找它们的子序列之间的最长公共子序列,这正是重叠子问题的体现。

在深入探讨动态规划如何解决LCS问题之前,我们还需要理解二维动态规划是什么。二维动态规划是动态规划的一种特殊形式,它特别适用于处理涉及两个维度或两个序列的问题。在这种情况下,我们通常使用一个二维数组(或矩阵)来存储中间结果,数组的每个元素对应于输入序列的一个子问题的解。

LCS问题正是二维动态规划的一个典型例子。为什么这样说呢?因为在解决LCS问题时,我们需要同时考虑两个序列中的元素,这意味着我们需要同时在两个维度上操作:一个维度是序列A,另一个维度是序列B。我们利用二维数组来存储所有可能的子序列组合的LCS长度,从而避免了重复计算相同子问题的工作。

通过这种方式,我们可以有效地将LCS问题的解构建在之前解决的子问题上。二维数组的每个位置(i, j)代表了序列A的前i个元素和序列B的前j个元素的最长公共子序列的长度。通过逐步填充这个二维数组,我们最终能解决整个LCS问题。这个过程不仅体现了动态规划的核心原则,也展示了二维动态规划在处理此类问题时的强大能力。

问题求解

1. 创建、初始化二维数组(定义状态与确定边界条件):

在LCS问题的动态规划解法中,状态是通过大小为(m+1) x (n+1) 的二维数组 dp来定义的,其中m和n分别是序列A和序列B的长度,dp[i][j] 表示序列A的前i个字符和序列B的前j个字符的最长公共子序列的长度,我们要求的最终结果是dp[m][n]

边界条件是解决动态规划问题的起点,它定义了最基本子问题的解。LCS问题的边界条件当序列A或序列B的长度为0时,最长公共子序列的长度为0。这可以通过初始化二维数组 dp 的第一行和第一列为0来实现,即 dp[0][j] = 0 和 dp[i][0] = 0,表明一个空序列和任意序列的最长公共子序列长度为0。

2. 填充二维数组(建立状态转移方程):

状态转移方程定义了从一个状态如何转移到另一个状态,即它描述了问题的递推关系。对于LCS问题,状态转移方程如下:

对于每一对元素 (i, j)(分别是序列A和B的索引,注意索引是从0开始,而对dp数组的操作是从1开始)

  • 如果 A[i] == B[j],那么 dp[i + 1][j + 1] = dp[i][j] + 1。这表示了当两个序列的当前元素相匹配时,当前的最长公共子序列长度是不包含这个元素的子序列的最长公共子序列长度加一
  • 如果 A[i] != B[j],那么 dp[i + 1][j + 1] = max(dp[i+1][j], dp[i][j+1])。这表示当两个序列的当前元素不匹配时,当前的最长公共子序列长度是两个可能的子问题解中的较大值,即要么不包含A序列的当前元素,要么不包含B序列的当前元素

3. 找出最长公共子序列:

从二维数组的右下角开始回溯。根据 dp 数组的值来确定序列A和B的哪些元素构成了LCS。如果 dp[i][j] 是由 dp[i-1][j-1] + 1 更新来的,说明 A[i-1] 和 B[j-1] 是LCS的一部分。如果是从 dp[i-1][j] 或 dp[i][j-1] 更新来的,我们则向值较大的方向移动,直到到达数组的左边界或上边界。

举例说明

为了能够更好地理解这一过程,我们来举一个例子。假设我们有两个序列:①序列A-"ABCD"②序列B- "AEBD",我们希望找到这两个序列的最长公共子序列。

首先,我们创建一个二维数组 dp,其大小为 (len(A)+1) x (len(B)+1)。其中 len(A)=4 和 len(B)=4,则 dp 数组的大小为 5x5。然后进行初始化。

接下来我们比较序列A的第一个字符"A"和序列B的所有字符。我们发现与序列B的第一个字符"A"匹配,因此 dp[1][1] 更新为dp[0][0] + 1 即1。

继续比较,B中的"E"、"B"、"D"无法匹配"A",因此我们根据状态转移方程,选取左边(dp[i+1][j])和上面(dp[i][j+1])之中的最大值来填充。则填充1。

接下来我们比较序列A的第一个字符"B"和序列B的所有字符,我们发现与序列B的字符"A"、“E”不匹配,则选取左边(dp[i+1][j])和上面(dp[i][j+1])之中的最大值来填充,填充1。与序列B的第三个字符"B"匹配,因此 dp[2][3] 更新为dp[1][2] + 1即2。继续,与序列B的字符"D"不匹配,则选取左边(dp[i+1][j])和上面(dp[i][j+1])之中的最大值来填充,填充2。

以此类推,直到填完整个dp数组。

下面演示如何根据这个结果来构建最长的公共子序列。

  1. 我们从 dp[4][4] 开始,它的值是3。这表示 "ABCD" 和 "AEBD" 的最长公共子序列的长度为3。
  2. 我们比较序列A和B的最后一个字符,最后一个字符都是"D",匹配。因此,"D" 是LCS的一部分。我们将 "D" 添加到LCS末尾,并在dp数组中将关注点向左上移动到 dp[3][3]。
  3. 现在我们在 dp[3][3],其值为2。比较A的第三个字符“C”和B的第三个字符 "B" ,不匹配。这时候我们需要查看 dp[2][3] 和 dp[3][2] 的值来决定向左移动还是向上移动。2 = dp[2][3] > dp[3][2] = 1,所以得知是从 dp[2][3] 转移过来的,我们需要将关注点向上移动到 dp[2][3]。
  4. 现在我们在 dp[2][3],其值为2。比较A的第二个字符“B”和B的第三个字符 "B",匹配。 因此,"B" 是LCS的一部分。我们将 "B" 添加到LCS的“D”前面,形成“BD”,并在dp数组中将关注点向左上移动到 dp[1][2]。
  5. 现在我们在 dp[1][2],其值为1。比较A的第一个字符“A”和B的第二个字符 "E",不匹配。这时候我们需要查看 dp[0][2] 和 dp[1][1] 的值来决定向左移动还是向上移动。1 = dp[1][1] > dp[0][2] = 0,所以得知是从 dp[1][1] 转移过来的,我们需要将关注点向左移动到 dp[1][1]。
  6. 现在我们在 dp[1][1],其值为1。比较A的第一个字符“A”和B的第一个字符 "A",匹配。因此,"A" 是LCS的一部分。我们将 "A" 添加到LCS的“B”前面,形成“ABD”,并在dp数组中将关注点向左上移动到 dp[0][0]。构建结束,子序列为“ABD”。

代码实现

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define MAX_SIZE 10
// 二维数组dp
int dp[MAX_SIZE][MAX_SIZE];
// 函数原型声明
int max(int a, int b);
char* getLCS(char *A, char *B, int m, int n);
int main() {
    char A[] = "ABCD"; // 序列A
    char B[] = "AEBD"; // 序列B
    int m = strlen(A);
    int n = strlen(B);
	int i,j;
    // 初始化、填充dp数组
    for (i = 0; i <= m; i++) {
        for (j = 0; j <= n; j++) {
            if (i == 0 || j == 0) {
                dp[i][j] = 0;
            } else if (A[i-1] == B[j-1]) {
                dp[i][j] = dp[i-1][j-1] + 1;
            } else {
                dp[i][j] = max(dp[i][j-1], dp[i-1][j]);
            }
        }
    }
    // 打印LCS长度
    printf("最长公共子序列的长度为: %d\n", dp[m][n]);
    // 打印LCS
    char *lcs = getLCS(A, B, m, n);
    printf("%s 和 %s 的最长公共子序列为 %s\n", A, B, lcs);
    free(lcs); // 释放动态分配的内存
    return 0;
}
// 返回两个整数中的最大值
int max(int a, int b) {
    return (a > b) ? a : b;
}
// 返回LCS
char* getLCS(char *A, char *B, int m, int n) {
    int index = dp[m][n];
    // 动态分配足够的空间来存储LCS
    char* lcs = (char*)malloc((index+1) * sizeof(char));
    lcs[index] = '\0'; // 设置字符串结束符
    int i = m, j = n;
    while (i > 0 && j > 0) {
        if (A[i-1] == B[j-1]) {
            lcs[index-1] = A[i-1]; // 将匹配的字符加入到lcs中
            i--;
            j--;
            index--;
        } else if (dp[i-1][j] > dp[i][j-1]) {
            i--;
        } else {
            j--;
        }
    }
    // 返回LCS
    return lcs; 
}
// 运行结果:
// 最长公共子序列的长度为: 3
// ABCD 和 AEBD 的最长公共子序列为 ABD

写在最后

在本文中,我们深入探讨了最长公共子序列(LCS)问题,这是动态规划算法应用的一个经典例子。通过详细的步骤,我们不仅学习了如何定义状态、建立状态转移方程和确定边界条件,还通过图表解析和代码示例具体了解了动态规划如何逐步解决这一问题。在下一篇文章中,我们将继续探讨更多的动态规划经典问题,力求稳扎稳打学好动态规划。

敬请期待!

  • 35
    点赞
  • 21
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值