动态规划&最长公共子序列

一个字符串的子串是字符串中连续的一个序列,而一个字符串的子序列是字符串中保持相对位置的字符序列,譬如,"adi"可以使字符串"abcdefghi"的子序列但不是子串。这也就决定了在解这两种"LCS"问题上的一些区别。
Longest-Common-Substring和Longest-Common-Subsequence是不一样的。

参考:
wiki-动态规划
何海涛微博

动态规划DP

通过把原问题分解为相对简单的子问题的方式求解复杂问题的方法。
动态规划常常适用于有[重叠子问题]和[最优子结构]性质的问题,动态规划方法所耗时间往往远少于朴素解法。
动态规划背后的基本思想非常简单。大致上,若要解一个给定问题,我们需要解其不同部分(即子问题),再合并子问题的解以得出原问题的解。
通常许多子问题非常相似,为此动态规划法试图仅仅解决每个子问题一次,从而减少计算量:一旦某个给定子问题的解已经算出,则将其[记忆化]存储,以便下次需要同一个子问题解之时直接查表。这种做法在重复子问题的数目关于输入的规模呈[指数增长]时特别有用。

DP问题有几个典型应用:
解整数背包问题: 设有n件物品,每件价值记为Pi,每件体积记为Vi,用一个最大容积为Vmax的背包,求装入物品的最大价值。 用一个数组f[i,j]表示取i件商品填充一个容积为j的背包的最大价值,显然问题的解就是f[n,Vmax].
f[i,j]=

  f[i-1,j] {j<Vi}
  max{f[i-1,j],f[i,j-Vi]+Pi} {j>=Vi}
  0 {i=0 OR j=0}

对于特例01背包问题(即每件物品最多放1件,否则不放入)的问题,状态转移方程:

f[i,j]=

  f[i-1,j] {j<Vi}
  max{f[i-1,j],f[i-1,j-Vi]+Pi} {j>=Vi}
  0 {i=0 OR j=0}

WIKI上举的第一个例子是Fibonacci数列,普通的递归求法会重复计算很多次前面的值因而效率很低,所以我们可以从低位算起。这样就可以利用前面的值。

到目前为止我对DP的理解就是,每一步的结果都与上一步有关。

LCS(longest common subsequence)

我们有两个字符串,现在求两个字符串的最长公共子序列(注:这里的要求不包括字符必须连续)
例:abdhgf和dadchgm的LCS就是adhg
这种问题确实不好做,一般的思路解决太复杂了。如果用遍历的方式,不想等的时候往前移动,相等的话,看后面的是否相等,后面的一个也存在之前的情况。。

但是我们也可以假设我们已经有一段字符串满足相同的子序列了,那么我们关心当前的这一个就可以了。

我们假设两个字符串的长度为m,n;LCS的长度为k;并且假设LCS里面的所有字符都是满足条件的
并且假设前k个都满足情况了,我们讨论第k个:

设Xm={x0,x1,…xm-1}和Yn={y0,y1,…,yn-1}为两个字符串,而Zk={z0,z1,…zk-1}是它们的LCS,则:

  1. 如果xm-1=yn-1,那么zk-1=xm-1=yn-1,并且Zk-1是Xm-1和Yn-1的LCS;
  2. 如果xm-1≠yn-1,那么当zk-1≠xm-1时Z是Xm-1和Y的LCS;
  3. 如果xm-1≠yn-1,那么当zk-1≠yn-1时Z是Yn-1和X的LCS

注:这个关系大家多想一想,是最重要的部分。
如果觉得上面的文字有干扰的话,可以自己去理解。

上面的关系式实际上是逆推,即我们假设已经有了LCS,我们去找LCS在两个字符串里面的位置。

  • 我觉得很重要的一点就是,我们只关心这一步(和上一步的关系),至于上一步也不满足的话,那就是递归的事情了。这样想能简化思路!

**性质大家可以反证法证明一下,如果不能理解,可以举个例子试一下,LCS里面的字符肯定会出现在x,y里面,如果x,y的最后面是无关的字符,后面的两个条件就可以逐步把无关的删除掉;
其实后面两个条件就是去掉无关字符的过程,讨论的都是当x!=y的情况。
**

由上面的三种情况:

我们可以得出如下的思路:求两字符串Xm={x0, x1,…xm-1}和Yn={y0,y1,…,yn-1}的LCS,

  • 如果xm-1=yn-1, 那么只需求得Xm-1和Yn-1的LCS, 并在其后添加xm-1( yn-1) 即可;
  • 如果xm-1≠yn-1, 我们分别求得Xm-1和Y的LCS和Yn-1和X的LCS,并且这两个LCS中较长的一个为X和Y的LCS

这就是DP的特点吧,每一步的情况都有两种(多种),你看着办吧。

如果我们记字符串Xi和Yj的LCS的长度为c[i,j],我们可以递归地求c[i,j]:

  • 0, if i<0 or j<0

  • c[i-1,j-1]+1 ,if i,j>=0 and xi=xj

  • max(c[i,j-1],c[i-1,j] if i,j>=0 and xi≠xj

根据这个思路,我们创建一个矩阵lcs_length来记录对应的i,j的值,之所以这样是为了避免类似于Fibonacci里面的重复求值的问题,以及方便输出。
在下面的代码里面,还创建了一个lcs_dir的矩阵,这个也是为了保存每一次值的来源,方便我们打印的时候知道取哪个值。

  • 这个程序的主干部分是这样的:
    矩阵的横列是str2,竖列是str1
    结合上面的实例,我们得到的矩阵是这样的:

str| a |b|d|h|g|f
----|------|----|---|----|
d | 0|0|1|0|0|0
a | 1|1 |1 |1 |1 | 1
d |1| 1|2 |2 |2 |2
c|0| 1| 2|2 |2 |2
h|0| 1|2 |3 | 3|3
g| 0| 1| 2| 3| 4|4

下面是代码:

//c[i,k]=
// 0, if i<0 or j<0
// c[i - 1, j - 1] + 1, if i, j >= 0 and xi = xj
// max(c[i, j - 1], c[i - 1, j] if i, j >= 0 and xi≠xj
#include <iostream>

enum dir {kinit=0,kup,kleftup,kleft};//c[i,j] comes from 3 directions

//we have a matrix holding value,another holding the direction
int lcs(char* str1,char* str2)
{
    if (!str1 || !str2)
        return;
    int len1 = strlen(str1);
    int len2 = strlen(str2);
    if (!len1 || !len2)
        return 0;

    unsigned int i, j;
    int** lcs_len = (int**)(new int[len1]);
    for (i = 0; i < len1; i++)
        lcs_len[i] = (int*)new int[len2];
    for (i = 0; i < len1; i++)
        for (j = 0; j < len2; j++)
            lcs_len[i][j] = 0;

    int** lcs_dir = (int**)(new int[len1]);
    for (i = 0; i < len1; i++)
        lcs_dir[i] = (int*)new int[len2];
    for (i = 0; i < len1; i++)
        for (j = 0; j < len2; j++)
            lcs_dir[i][j] =kinit ;

    //core: detect every unit
    for (i = 0; i < len1; i++)
        for (j = 0; j < len2; j++)
        {
            if (i == 0 || j == 0)//the begin of common string
            {
                if (str1[i] == str2[j])
                {
                    lcs_len[i][j] = 1;
                    lcs_dir[i][j] = kleftup;
                }
                else
                    lcs_len[i][j] = 0;

            }
            else if (str1[i] == str2[j])
            {
                lcs_len[i][j] = lcs_len[i - 1][j - 1] + 1;//case 1
                lcs_dir[i][j] = kleftup;
            }
            else if (lcs_len[i - 1][j] > lcs_len[i][j - 1])
            {
                lcs_len[i][j] = lcs_len[i - 1][j] ;
                lcs_dir[i][j] = kup;
            }
            else
            {
                lcs_len[i][j] = lcs_len[i][j-1] ;
                lcs_dir[i][j] = kleft;
            }
        }
    return lcs_len[len1 - 1][len2 - 1];
}

然后我们根据得到的矩阵打印:
只需要打印方向矩阵里面,方向标识为leftup的字符,其它的根据方向标识来移动。
既然这样的话,我们可以用递归的方式打印,简化代码量:

//only lcs_dir=kleftup are to be printed
void print_path(int**lcs_dir, char* str1, char* str2, size_t row, size_t col)
{
    if (!str1 || !str2)
        return;
    size_t len1 = strlen(str1);
    size_t len2 = strlen(str2);

    if (len1 == 0 || len2 == 0 || !(row < len1&&col < len2))
        return;

    if (lcs_dir[row][col] == kleftup)
    {
        if (row > 0 && col > 0)
            print_path(lcs_dir, str1, str2, row - 1, col - 1);
        std::cout << str1[row];
    }
    if (lcs_dir[row][col]==kleft)
        print_path(lcs_dir, str1, str2, row , col - 1);
    if (lcs_dir[row][col] == kup)
        print_path(lcs_dir, str1, str2, row-1, col );
}

测试了一下没问题的。


总结一下吧,关于动态规划这个概念大家不要太纠结,其实重心在于如何找出规律!

推荐阅读更多精彩内容