双序列型动态规划

双序列顾名思义就是俩单序列放一块问你怎么做。这个章节的题都比较难,跳过也行。

  • state: f[i][j]代表了第一个sequence的前i个数字/字符,配上第二个sequence的前j个...

  • function: f[i][j] = 研究第i个和第j个的匹配关系

  • initialize: f[i][0] 和 f[0][i]

  • answer: f[n][m]

  • n = s1.length(),m = s2.length()


Longest Common Subsequence

问题定义

应该是LC14 Longest Common Prefix的升级版,很奇怪为什么leetcode没有这道题,我现在对LC越来越疑惑了,加了那么多乱七八糟的题,一些经典题目都没有,像是背包问题都没有。

两个字符串的最后两个字符i和j,如果match,那么这时一定是最长的,+1。

如果不match,两种情况

  • A[i]不能和任何match,f[i - 1][j]
  • A[j]不能和任何match,f[i][j - 1]

选一个比较长的。A[i][j]都不能match的情况是最短的,已经包含在f[i - 1][j - 1]内了。

public class Solution {
    public int longestCommonSubsequence(String A, String B) {
        if (A == null || B == null || A.length() == 0 || B.length() == 0) return 0;

        int m = A.length();
        int n = B.length();
        int[][] f = new int[m + 1][n + 1];

        for (int i = 0; i <= m; ++i) {
            f[i][0] = 0;
        }
        for (int i = 0; i <= n; ++i) {
            f[0][i] = 0;
        }
        for (int i = 1; i <= m; ++i) {
            for (int j = 1; j <= n; ++j) {
                f[i][j] = Math.max(f[i - 1][j], f[i][j - 1]);
                if(A.charAt(i - 1) == B.charAt(j - 1)) {
                    f[i][j] = f[i - 1][j - 1] + 1;
                }
            }
        }
        return f[m][n];
    }
}

提一下算法导论这道题的优化方式。

给定长度为 m 的字符串 {1,2,3...m} ,其 subsequences 的数量总数为 2^m,即对每个字符选择 “取 / 不取”。

于是有了下面的可视化版本

这个版本的做法非常适合存储和输出 optimal path,也可以应用到 Longest Increasing Subsequence 中,用于 follow-up 情况下输出 sequence.

比如输入数组为 X = [x1, x2 .. xn],其排序后的数组为 X' = [x'1, x'2 .. x'n]

  • LIS 即 X 与 X' 的 LCS,判断条件完全一样,元素相等向右下走。如果每一步上都存行进方向,那么最后从右下角往左上出发,每一步指向左上角的箭头都是 LIS 的元素。

滚动数组优化版的代码,最大值肯定在 dp[][] 的最右下角。

public class Solution {
    public int longestCommonSubsequence(String A, String B) {
        if(A == null || B == null) return 0;

        int n = A.length();
        int m = B.length();
        if(n == 0 || m == 0) return 0;

        int[][] dp = new int[2][m + 1];

        for(int i = 1; i <= n; i++){
            for(int j = 1; j <= m; j++){
                if(A.charAt(i - 1) == B.charAt(j - 1)){
                    dp[i % 2][j] = dp[(i - 1) % 2][j - 1] + 1; 
                } else {
                    dp[i % 2][j] = Math.max(dp[(i - 1) % 2][j], dp[i % 2][j - 1]); 
                }

            }
        }
        return dp[n % 2][m];
    }
}

72. Edit Distance

Hard题。关于这个问题最好的 slides, by Stanford

这道题和LCS很像,求的是最小。可以很容易看到只依靠 LCS 长度解决这道题的乏力;因为 LCS 长度和 edit distance 对字符串结构的利用是不一样的,mismatch 的字符可以出现错位,而 edit distance 不支持一次操作进行修正。算法导论上也写的非常清楚,鉴定两条 DNA 序列的相似度,substring 是一种思路(KMP),LCS是一种思路,而 edit distance 是另一种思路。

三种操作,插入,删除,替换,word1变成word2。

  • state: f[i][j] 表示A的前 i 个字符最少几次操作变成 B 的前 j 个字符。

定义结束后,我们来分析一下转移方程的可能性。

  • 如果 A 的第 i 个字符等于 B 的第 j 个字符,换句话说就是当前位的字母相同了,这是我们最喜欢的结果,因为不用操作就相同了。这时我们有三种选择:

    • 首先,我们可能不需要第 i 个字符,问题变成了用最少的操作把前 i - 1 个字符变为前 j 个字符。也就是删除:f[i - 1][j] + 1
    • 其次,可能不删掉第 i 个字符,而是在后面加一个字符,一次操作使 A 的前 i 个字符与 B 的前 j - 1 个字符相等:f[i][j - 1] + 1
    • 在 i,j 相同的情况下也可能不需要新的操作,f[i - 1][j - 1]
  • 如果 A 的第 i 个字符 不等于 B 的第 j 个字符:

    • 很简单,我们需要一次replace操作:f[i - 1][j - 1] + 1
  • function:

    • f[i][j] = MIN(f[i - 1][j] + 1, f[i][j - 1] + 1, f[i - 1][j - 1]) // A[i - 1] == B[j - 1]
    • MIN(f[i - 1][j] + 1, f[i][j - 1] + 1, f[i - 1][j - 1] + 1) // A[i - 1] != B[j - 1]
  • initialize: f[i][0] = i, f[0][j] = j
  • answer: f[m][n]
public class Solution {
    public int minDistance(String word1, String word2) {
        if (word1 == null && word2 == null || word1.length() == 0 && word2.length() == 0) return 0;
        int m = word1.length();
        int n = word2.length();

        int[][] f = new int[m + 1][n + 1];

        for (int i = 0; i <= m; ++i) {
            f[i][0] = i;
        }
        for (int i = 0; i <= n; ++i) {
            f[0][i] = i;
        }

        for (int i = 1; i <= m; ++i) {
            for (int j = 1; j <= n; ++j) {
                if (word1.charAt(i - 1) == word2.charAt(j - 1)) {
                    f[i][j] = Math.min(Math.min(f[i - 1][j] + 1, f[i][j - 1] + 1), f[i - 1][j - 1]);
                } else {
                    f[i][j] = Math.min(Math.min(f[i - 1][j] + 1, f[i][j - 1] + 1), f[i - 1][j - 1] + 1);
                }
            }
        }
        return f[m][n];
    }
}

这题花了我很久的时间才理解和AC,如果做不出来我觉得没关系,Hard题不是我们的主要目标,没有多少公司会一直考你Hard题目,那样怎么会有区分度呢?

这道题让我觉得是时候结束DP的题目了,纠缠在难题上面是对一种精力消耗和时间浪费。

Minimum insertions to form a palindrome

和 Edit distance 非常像,考虑到 add/delete 在构造 string 上的等价性质,问题的 optimal substructure 即为

f[i][j] = substring(i,j) 范围内,构造 palindrome 的最小编辑次数

  • 如果 s[i] == s[j]

    • f[i][j] = f[i + 1][j - 1] (不需要操作)

    • 同时考虑 i , j 相邻的情况

  • 如果 s[i] != s[j],那么我们可以经 add/delete 操作构造出当前的 s(i, j)

    • s(i + 1, j) + ADD

    • s(i, j - 1) + ADD

注意这题不支持 replace,如果支持的话,f[i][j] 还要看一个新状态,f[i + 1][j - 1]

public class Main {
    private static int minEditDistance(String str){
        if (str == null || str.length() <= 1) return 0;

        int n = str.length();

        // f[i, j] = min edit distance for substring (i, j);
        int[][] f = new int[n][n];
        for (int i = 1; i < n; ++i) {
            for (int j = i; j >= 0; --j) {
                if (j == i) {
                    f[j][i] = f[i][j] = 0;
                } else {
                    if (str.charAt(i) == str.charAt(j)) {
                        f[j][i] = f[i][j] = (j + 1 == i) ? 0
                                : f[j + 1][i - 1];
                    } else {
                        f[j][i] = f[i][j] = (j + 1 == i) ? 1
                                : Math.min(f[j + 1][i], f[j][i - 1]) + 1;
                    }
                }
            }
        }
        return f[0][n - 1];
    }
}

115. Distinct Subsequences

求方案总数的一道双序列题。Hard题。

S = "rabbbit", T = "rabbit" Return 3

rab1b2b3it,S 里面有三个b,T 里面有两个 b,也就是两个b去匹配三个b有多少种方案,显然是三种,我们可以选择b1b2,b1b3,b2b3. 注意顺序要对(显然)。

再举个例子,rrabbbit 和 rabbit,这时我们有2 * 3 = 6种方案。

像是上一道题,但这次你不能把 T 里面的字符扔掉,只能扔 S。双序列的动态规划记住总是去匹配最后一个字符是否相等。

如果是求最大/小,60-70% 是动态规划。 如果是求方案总数,99% 是动态规划。当然你用DFS也可以,如果不会栈溢出的话。

  • state: f[i][j] 表示 S 的前 i 个字符中选取 T 的前 j 个字符,有多少种方案
  • function:
    • 如果最后一个字符 相等:f[i][j] = f[i - 1][j] + f[i - 1][j - 1] // S[i - 1] == T[j - 1]
    • 如果最后一个字符 不相等:f[i][j] = f[i - 1][j] // S[i - 1] != T[j - 1]
  • initialize: f[i][0] = 1, f[0][j] = 0 (j > 0)
  • answer: f[m][n] (m = sizeof(S), n = sizeof(T))
public class Solution {
    public int numDistinct(String s, String t) {
        if (s == null && t == null || s.length() == 0 && t.length() == 0) return 0;

        int m = s.length();
        int n = t.length();
        int[][] f = new int[m + 1][n + 1];

        for (int i = 0; i <= m; ++i) {
            f[i][0] = 1;
        }
        for (int i = 1; i <= n; ++i) {
            f[0][i] = 0;
        }

        for (int i = 1; i <= m; ++i) {
            for (int j = 1; j <= n; ++j) {
                if (s.charAt(i - 1) == t.charAt(j - 1)) {
                    f[i][j] = f[i - 1][j] + f[i - 1][j - 1];
                } else {
                    f[i][j] = f[i - 1][j];
                }
            }
        }
        return f[m][n];
    }
}

97. Interleaving String

Hard题。但相比于前面已经容易一些了。

三个string,s1,s2,s3,s3能不能被s1和s2交替组成,判断是否可行的一道题。

有点merge sorted array的感觉。

首先从题目结构来看,和这章的前几道双序列 DP 非常相似,都是一个字符串 “构造问题”,即用小的 substring 通过生长和拼接,构造出更大的目标 string. 一般这类问题有天然的 bottom-up 思路,当然,top-bottom 的递归思路也是完全可行的。

f[i][j][k] 表示 s1 的前 i 个字符和 s2 的前 j 个字符能否交替组成 s3 的前 k 个字符,但是这里面 k = i + j,这时肯定的,所以一个三维的数组没什么意义。

同时我们也要处理好 i = 0 和 j = 0 的初始化条件。

时间空间复杂度 O(s1.len * s2.len)

  • state: f[i][j]表示 s1 的前 i 个字符和 s2 的前 j 个字符能否交替组成 s3 的前 i + j 个字符
  • function:
    • f[i][j] = (f[i - 1][j] && (s1[i - 1] == s3[i + j - 1]) ||
    • f[i][j] = (f[i][j - 1] && (s2[j - 1] == s3[i + j - 1])
  • initialize:
    • f[i][0] = (s1[0..i - 1] == s3[0..i - 1])
    • f[0][j] = (s2[0..j - 1] == s3[0..j - 1])
  • answer: f[m][n], m = sizeof(s1), n = sizeof(s2)
public class Solution {
    public boolean isInterleave(String s1, String s2, String s3) {
        if(s1 == null || s2 == null || s3 == null) return false;
        if(s3.length() != s1.length() + s2.length()) return false;
        if(s1.length() == 0) return s2.equals(s3);
        if(s2.length() == 0) return s1.equals(s3);

        int m = s1.length();
        int n = s2.length();

        boolean[][] f = new boolean[m + 1][n + 1];

        f[0][0] = true;
        for (int i = 1; i <= m; ++i) {
            f[i][0] = (s1.charAt(i - 1) == s3.charAt(i - 1)) && f[i - 1][0];
        }
        for (int i = 1; i <= n; ++i) {
            f[0][i] = (s2.charAt(i - 1) == s3.charAt(i - 1)) && f[0][i - 1];
        }

        for (int i = 1; i <= m; ++i) {
            for (int j = 1; j <= n; ++j) {
                f[i][j] = (f[i - 1][j] && s1.charAt(i - 1) == s3.charAt(i + j - 1)) ||
                            (f[i][j - 1] && s2.charAt(j - 1) == s3.charAt(i + j - 1));
            }
        }
        return f[m][n];
    }
}

10. Regular Expression Matching

44. Wildcard Matching

public class Solution {
    public boolean isMatch(String s, String p) {
        if(s == null || p == null) return false;
        int m = s.length();
        int n = p.length();
        boolean[][] f = new boolean[m + 1][n + 1];

        f[0][0] = true;

        for (int i = 1; i <= n; ++i) {
            if (p.charAt(i - 1) == '*') f[0][i] = true;
            else break;
        }

        for (int i = 1; i <= m; ++i) {
            for (int j = 1; j <= n; ++j) {
                char chs = s.charAt(i - 1);
                char chp = p.charAt(j - 1);
                if (chp != '*' && chp != '?') {
                    f[i][j] = f[i - 1][j - 1] && chs == chp;
                } else if (chp == '?') {
                    f[i][j] = f[i - 1][j - 1];
                } else if (chp == '*') {
                    f[i][j] = f[i - 1][j] || f[i][j - 1] || f[i - 1][j - 1];
                }
            }
        }
        return f[m][n];
    }
}

results matching ""

    No results matching ""