动态规划法-最长公共子序列

动态规划算法思想

动态规划算法是一种解决问题的方法,它将复杂的问题分解成更小、更简单的子问题。这些子问题不是完全独立的,它们之间存在关联。动态规划通过记录这些子问题的解(通常是在一个表格中),来避免重复计算,从而提高效率。

动态规划步骤

  1. 找出最优解的性质:确定问题的最优解应该具备哪些特点。
  2. 递归地定义最优值:根据子问题的定义,递归地表达原问题的最优解。
  3. 自底向上计算最优值:从最基本的子问题开始,逐步计算更复杂问题的最优解。
  4. 构造最优解:利用计算过程中得到的信息,构建问题的最优解。

最长公共子序列

题目

给定两个序列,找出它们的最长公共子序列(LCS)。子序列是指在不改变字符相对顺序的情况下,从原序列中删除一些(或不删除)字符后形成的序列。

子序列与子串

  • 子序列:可以删除原序列中的一些字符,但保留剩余字符的相对顺序。
  • 在这里插入图片描述
    如上图,给定的字符序列: {a,b,c,d,e,f,g,h},它的子序列示例: {a,c,e,f} 即元素b,d,g,h被去掉后,保持原有的元素序列所得到的结果就是子序列。同理,{a,h},{c,d,e}等都是它的子序列。
  • 子串:是原序列中连续字符的序列。
  • 在这里插入图片描述
    它的字串示例:{c,d,e,f} 即连续元素c,d,e,f组成的串是给定序列的字串。同理,{a,b,c,d},{g,h}等都是它的字串。

例子

给定序列 s1={1,3,4,5,6,7,7,8}s2={3,5,7,4,8,6,7,8,2},它们的一个最长公共子序列是 {3,4,6,7,8}
s1和s2的相同子序列,且该子序列的长度最长,即是LCS。
在这里插入图片描述

情况1:S1和S2的最后一个元素相等

如果 S1 的最后一个元素与 S2 的最后一个元素相等,那么 S1S2 的 LCS 就等于 {S1去掉最后一个元素} 与 {S2去掉最后一个元素} 的 LCS 再加上 S1S2 相等的最后一个元素。

情况2:S1和S2的最后一个元素不相等

如果 S1 的最后一个元素与 S2 的最后一个元素不相等(如本例),那么 S1S2 的 LCS 就等于:

  • {S1去掉最后一个元素} 与 S2 的 LCS
  • {S2去掉最后一个元素} 与 S1 的 LCS

我们需要从这两个结果中选择较大的那个作为最终的 LCS。

动态规划算法设计

最优解结构性质

我们假设 Z 是序列 XY 的最长公共子序列。这个假设的目的是为了使问题的规模不断缩小。

序列定义
  • X = {x1, x2, ..., xm},长度为 m
  • Y = {y1, y2, ..., yn},长度为 n
  • Z = {z1, z2, ..., zk},长度为 k
情况分析
  1. xm == yn == zk

    • 如果序列 X 的最后一个元素等于序列 Y 的最后一个元素,并且这个元素也是序列 Z 的最后一个元素,那么我们可以将问题简化为求 X 去掉最后一个元素后与 Y 去掉最后一个元素后的 LCS。
  2. xm != ynzk != xm

    • 如果 X 的最后一个元素不等于 Y 的最后一个元素,或者 Z 的最后一个元素不等于 X 的最后一个元素,那么我们需要考虑两种情况:
      • Z 去掉最后一个元素等于 X 去掉最后一个元素与 Y 的 LCS
      • Z 去掉最后一个元素等于 XY 去掉最后一个元素的 LCS
状态转移方程

状态转移方程是动态规划算法的核心,它描述了如何根据子问题的解来构建原问题的解。

递归公式
  • 基本情况

    • ij 为 0 时,最长公共子序列的长度为 0。
    • c[i][j] = (i == 0 || j == 0) ? 0 : ...
  • 状态转移

    • 如果 X 的第 i 个元素等于 Y 的第 j 个元素,则:
      • c[i][j] = c[i-1][j-1] + 1
    • 否则:
      • c[i][j] = max(c[i-1][j], c[i][j-1])

所以递归方程为
在这里插入图片描述

5.计算LCS的长度

在这里插入图片描述
图中的空白格子需要填上相应的数字(这个数字就是c[i,j]的定义,记录的LCS的长度值)。填的规则依据递归公式,简单来说:如果横竖(i,j)对应的两个元素相等,该格子的值 = c[i-1,j-1] + 1。如果不等,取c[i-1,j] 和 c[i,j-1]的最大值。首先初始化该表:

在这里插入图片描述
然后,一行一行地从上往下填:
在这里插入图片描述
S1的元素3 与 S2的元素3 相等,所以 c[2,1] = c[1,0] + 1。继续填充:
在这里插入图片描述
S1的元素3 与 S2的元素5 不等,c[2,2] =max(c[1,2],c[2,1]),图中c[1,2] 和 c[2,1] 背景色为浅黄色。

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
中间几行填写规则不变,直接跳到最后一行:
在这里插入图片描述
该表填完。根据性质,c[8,9] = S1 和 S2 的 LCS的长度,即为5。

构造LCS

本文S1和S2的最LCS并不是只有1个,本文并不是着重讲输出两个序列的所有LCS,只是介绍如何通过上表,输出其中一个LCS。

我们根据递归公式构建了上表,我们将从最后一个元素c[8][9]倒推出S1和S2的LCS。

c[8][9] = 5,且S1[8] != S2[9],所以倒推回去,c[8][9]的值来源于c[8][8]的值(因为c[8][8] > c[7][9])。

c[8][8] = 5, 且S1[8] = S2[8], 所以倒推回去,c[8][8]的值来源于 c[7][7]。

以此类推,如果遇到S1[i] != S2[j] ,且c[i-1][j] = c[i][j-1] 这种存在分支的情况,这里请都选择一个方向(之后遇到这样的情况,也选择相同的方向)。

第一种结果为:
在这里插入图片描述
这就是倒推回去的路径,棕色方格为相等元素,即LCS = {3,4,6,7,8},这是其中一个结果。

如果如果遇到S1[i] != S2[j] ,且c[i-1][j] = c[i][j-1] 这种存在分支的情况,选择另一个方向,会得到另一个结果。

在这里插入图片描述
即LCS ={3,5,7,7,8}。

时间复杂度

  • 构建动态规划表c[i][j]的时间复杂度是Θ(mn),其中m和n分别是S1和S2的长度。
  • 输出一个LCS的时间复杂度是Θ(m+n),因为回溯过程中最多只会访问S1和S2的每个元素一次。

全部LCS

为了输出全部的LCS,我对lcs.cpp中的print_lcs函数进行了改进,改进后的代码见lcs_all.cpp。在lcs_all.cpp中,我从动态规划表右下角开始回溯,直到到达表的上边界或左边界停止。若sx[i-1]等于sy[j-1],则把这个字符放入最长公共子序列中;若sx[i-1]不等于sy[j-1],则比较c[i-1][j]c[i][j-1]的值,跳入值较大的继续进行判断;若c[i-1][j]等于c[i][j-1],说明最长公共子序列有多个,两边都要进行回溯。最后将所有的LCS都保存在集合中。需要注意的是:当字符串过长时,由于回溯范围过广,输出全部的LCS耗时很长。

详细步骤

  1. 从右下角开始回溯

    • 从动态规划表的右下角开始,即c[m][n],其中mn分别是字符串S1S2的长度。
  2. 比较字符

    • S1[i-1]等于S2[j-1],则说明这个字符是LCS的一部分,将其加入到结果中,并继续回溯到c[i-1][j-1]
  3. 选择较大值

    • S1[i-1]不等于S2[j-1],则比较c[i-1][j]c[i][j-1]的值,选择较大的那个继续回溯。
  4. 处理分支

    • c[i-1][j]等于c[i][j-1],说明存在多个LCS,需要同时回溯两个方向。
  5. 保存结果

    • 将所有找到的LCS保存在一个集合中,以确保不重复。

代码

#include <stdio.h>  // 包含标准输入输出库
#include <string.h> // 包含字符串处理库
#define MAX 50      // 定义最大字符串长度为50

char str1[MAX];    // 定义第一个字符串数组
char str2[MAX];    // 定义第二个字符串数组
int n1, n2;        // 定义两个字符串的长度变量
int dp[MAX][MAX];  // 定义动态规划数组,用于存储LCS的长度
char s[MAX];       // 定义一个字符数组,用于存储最长公共子序列
int count;         // 定义一个计数器,用于记录最长公共子序列的长度

void view();       // 声明view函数,用于输出最长公共子序列
void LCS();        // 声明LCS函数,用于计算LCS
int max(int a, int b); // 声明max函数,用于比较两个整数的大小

int main() {
    printf("请输入两个字符串\n"); // 提示用户输入两个字符串
    scanf("%s %s", str1, str2);   // 读取用户输入的两个字符串
    n1 = strlen(str1);            // 计算第一个字符串的长度
    n2 = strlen(str2);            // 计算第二个字符串的长度
    LCS();                        // 调用LCS函数计算LCS
    view();                       // 调用view函数输出LCS
    printf("第一个字符串:%s\n第二个字符串:%s\n", str1, str2); // 输出两个字符串
    printf("长度:%d\n", dp[n1][n2]); // 输出LCS的长度
    printf("最长子序列:%s\n", s);   // 输出LCS
    return 0;                     // 程序正常结束
}

int max(int a, int b) {
    return a > b ? a : b; // 如果a大于b,返回a,否则返回b
}

void LCS() {
    for (int i = 0; i <= n1; i++) { // 初始化dp数组的第一行
        dp[i][0] = 0;               // 如果任一字符串为空,LCS长度为0
    }
    for (int j = 0; j < n2; j++) {  // 初始化dp数组的第一列
        dp[0][j] = 0;               // 如果任一字符串为空,LCS长度为0
    }
    for (int i = 1; i <= n1; i++) { // 遍历str1的每个字符
        for (int j = 1; j <= n2; j++) { // 遍历str2的每个字符
            if (str1[i - 1] == str2[j - 1]) // 如果两个字符相等
                dp[i][j] = dp[i - 1][j - 1] + 1; // LCS长度为左上角值加1
            else
                dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]); // 否则取上方和左方的最大值
        }
    }
}

void view() {
    int i = n1, j = n2; // 从字符串的末尾开始
    count = dp[n1][n2] - 1; // 记录最长公共子序列的长度
    while (count >= 0) { // 遍历dp数组,回溯找到LCS
        if (dp[i][j] == dp[i - 1][j - 1]) { // 如果当前位置的值等于左上角的值
            s[count] = str1[i - 1]; // 说明这个字符是LCS的一部分
            count--; i--; j--; // 移动到左上角
        } else if (dp[i][j] == dp[i][j - 1]) { // 如果当前位置的值等于正上方的值
            j--; // 说明这个字符不是LCS的一部分,移动到正上方
        } else { // 如果当前位置的值等于正左方的值
            i--; // 说明这个字符不是LCS的一部分,移动到正左方
        }
    }
    s[count + 1] = '\0'; // 在字符串末尾添加结束符
}
  • 24
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值