动态规划超清晰易懂的教程
题目描述
https://leetcode.com/problems/distinct-subsequences/
这道题的分类是动态规划,动态规划很难掌握,对于没搞过信息学竞赛的人来说更是难上加难,接下来我会用一个接地气的方法教大家如何推导出这道题的动态规划递推公式。
题目大意是给定一个字符串S和子串T,请问S中有多少个T的字串?
Given a string S and a string T, count the number of distinct subsequences of S which equals T.
A subsequence of a string is a new string which is formed from the original string by deleting some (can be none) of the characters without disturbing the relative positions of the remaining characters. (ie, "ACE" is a subsequence of "ABCDE" while "AEC" is not).
例子1: Input: S = "rabbbit", T = "rabbit" Output: 3 Explanation: As shown below, there are 3 ways you can generate "rabbit" from S. (The caret symbol ^ means the chosen letters) rabbbit ^^^^ ^^ rabbbit ^^ ^^^^ rabbbit ^^^ ^^^ 例子2: Input: S = "babgbag", T = "bag" Output: 5 Explanation: As shown below, there are 5 ways you can generate "bag" from S. (The caret symbol ^ means the chosen letters) babgbag ^^ ^ babgbag ^^ ^ babgbag ^ ^^ babgbag ^ ^^ babgbag ^^^
回溯法
这题的标签是动态规划,但是我对动态规划还不是很熟练,而动态规划是回溯的优化版本,有位大师说过一切动态规划问题都可以由回溯问题转换而来。由于我对回溯更加熟练,因此我决定先实现一个回溯版本的题解。
思路
从左到右遍历S的每个字符,如果T的字符在S中出现了,则这两个字符对碰消掉,接着考虑这两个字符不存在的情况。
在S遍历的循环中需要考虑两个情况:
-
如果S当前的的字符等于T的第一个字符,则删除S和T中的这两个对应的字符,接着递归调用。例如func("abc","ac"),两个字串的第一个字母相等,下一次递归调用的入参则应该是func("bc","c")
-
如果S当前的字符不等于T的第一个字符,则继续遍历S。
函数递归的停止条件为T串为空,意味着子串T的所有字符都被S的字符抵消掉,因此是一个子串组合。
每个递归的尽头意味着是一个子串组合的解,因此我们将所有的递归函数的返回值累加起来即可得到所有可能的组合数量。
代码实现
我们对函数添加入参,表示字符串S和字符串T的开始下标,以此达到删除某个字符的目的,而不用频繁创建字符串对象,例如调用substring()。
class Solution { public static void main(String[] args) { System.out.println(new Solution().numDistinct("babgbag","bag")); } public int numDistinct(String s, String t) { return find(s, 0, t, 0); } private int find(String s, int sIndex, String t, int tIndex) { System.out.println("find("+sIndex+","+tIndex+")"); if (s.length() == 0 && t.length() == 0) { return 1; } if (tIndex == t.length()) { return 1; } int sum = 0; for (int i = sIndex; i < s.length(); i++) { if (s.charAt(i) == t.charAt(tIndex)) { sum += find(s, i + 1, t, tIndex + 1); } } return sum; } }
输出结果
find(0,0) find(1,1) find(2,2) find(4,3) find(7,3) find(6,2) find(7,3) find(3,1) find(6,2) find(7,3) find(5,1) find(6,2) find(7,3) 5
缺点
这个解法超时了,从输出结果中可以看出,find方法有不少重复的调用,例如find(7,3)调用了4次,但是获得的结果应该没什么不同,毕竟入参都一样,因此我们需要缓存已经算出来的结果。
我们使用备忘录法,将每次find()调用的结果保存起来存在一个二维数组中,递归调用之前先查询是否已经计算过,如果计算过则跳过不必要的重复计算,这个方法已经是动态规划的雏形了,具体实现不再赘述。
动态规划法
从回溯法怎么优化为动态规划法呢?从上述回溯法的解法中我们可以知道,find(0,0)的返回值就是答案,即从S串、T串的下标0开始逐个字符匹配,我们用一个二维数组dp代表find()的计算结果。dp[0] [0]则意味着字符串S从下标0开始,子串T从下标0开始,S有多少种T的子串。
初始化
观察回溯函数find()的终止条件,可知当T的下标等于T的长度时,得到一个子串组合,此时为一个解。由于T的下标会等于T的长度,因此因此dp数组的长度需要加1。初始化为
int[][] dp=new int[S.length()+1][T.length()+1]; for(int i=0;i<=S.length;i++) dp[i][T.length()]=1;
从回溯的角度分析,上述初始化对应的回溯函数的入参为find("ab...bc",""),即子串T为空。
递推式
接下来思考递推式
如果S的某个字符等于T的某个字符,则将这两个字符同时消掉,当做不存在,因此有:
-
dp[i] [j]=dp[i+1] [j+1] if S.charAt(i) == T.charAt(j)
或者只消掉S的对应字符,相当于忽略S的这个字符,看看S后面还有没有可能出现这个字符,组成一个不一样的子串:
-
dp[i] [j]=dp[i+1] [j] if S.charAt(i) == T.charAt(j)
如果S的字符不等于T的字符,说明不匹配,继续在S的后面的字符寻找能和T匹配的字符:
-
dp[i] [j]=dp[i+1] [j] if S.charAt(i) != T.charAt(j)
代码实现
根据上述分析,写出通俗易懂的动态规划算法,有理有据,令人信服。
public int dp(String s, String t) { int[][] dp = new int[s.length() + 1][t.length() + 1]; for (int i = 0; i <= s.length(); i++) { dp[i][t.length()] = 1; } for (int i = s.length() - 1; i >= 0; i--) { for (int j = t.length() - 1; j >= 0; j--) { if (s.charAt(i) == t.charAt(j)) { dp[i][j] = dp[i + 1][j + 1] + dp[i + 1][j]; } else { dp[i][j] = dp[i + 1][j]; } } } return dp[0][0]; }