导言java
这篇文章主要目的是解析 LeetCode 上面的一道经典动态规划问题,Regular Expression Matching,但这里还会讲解暴力搜索、记忆化搜索,以及面对字符串类的动态规划问题该如何更好地切题express
LeetCode 10. Regular Expression Matching数组
这道题实际上是要实现 Regular Expression 里面的两个符号,一个是 '.',另外一个是 '*', 前者能够 match 任意一个字符,后者表示其前面的字符能够重复零次或者屡次,举几个例子,match("aa","a") => false
,match("ab",".*") => true
,match("aab","c*a*b") => true
spa
题目的难点实际上是在于 * 上面,若是没有这个 *,题目会变得很是简单,这里仍是说一下题目的两个隐含条件,一个就是 * 不会出如今字符串的开头,另一个是 * 前面不能是 *,好比 "a**b" 就不行,固然你也能够把这两个隐含条件看成一个来看,无论如何,咱们的代码实现必须创建在这个基础之上,不然,cases 考虑多了,题目将无从下手。code
递归方式的暴力深度优先搜索求解方法每每是搜索问题的万金油,这里你只须要简单的考虑两件事情,一是,这个问题是否能够划分为几个小的子问题,二是,每一个划分后的子问题有几种状态,也就是在当前考虑的子问题下,一共有多少种不一样的可能性。知道了这两点后,对于每一个子问题的每个状态递归求解就行。递归
上面说的可能有点抽象,结合这个题目来作例子,这里的问题是,输入一个字符串 s,以及匹配字符串 p,要求解这两个字符串是否匹配。咱们首先考虑这个字符串比较的问题能不能划分为一个个的子问题,你发现字符串是能够划分红为一个个字符的,这样字符串比较的问题就会变成字符的比较问题,这样一来,咱们就能够把问题当作,决定 s[i,...n] 是否可以匹配 p[j,...m] 的条件是子问题 s[i+1,...n] 能不可以匹配 p[j+1,...m],另外还要看 s[i] 和 p[j] 是否匹配,可是这里的当前要解决的问题是 s[i] 和 p[j] 是否匹配,只有这一点成立,咱们才会继续去看 s[i+1,...n] 和 p[j+1,...m] 是否匹配,注意这里我说的 s[i] p[j] s[s+1,...n] p[j+1,...m], 并不表示说当前就只用考虑这两个字符之间匹不匹配,它只是用来表示当前问题,和后面须要考虑的问题,这个当前问题也许只须要比较一个字符,也许要比较多个,这就引伸出了前面提到的第二点,咱们还须要考虑当前问题中的状态。对于字符串 s 来讲,没有特殊字符,当前问题中字符只会是字母,可是对于 p 来讲,咱们须要考虑两个特殊符号,还有字母,这里列举全部的可能,若是说当前的子问题是 s[i,...n] 和 p[j...m]:leetcode
这里我解释下第四和第五种状况,以前在题目描述里说过,p 的起始字符不多是 *,也就是说 * 的前面必须有字母,根据定义,这里咱们能够把 * 的前面的元素个数算做是零个或者是一个或多个,若是是零个,这样咱们就只用看,s[i,...n] 和 p[j+2,...n] 是否匹配,若是算做一个或者多个,那么咱们就能够看 s[i+1,...n] 和 p[j,...m] 是否成立,固然算做一个或者多个的前提是 p[j] == s[i] 或者 p[j] == '.', 咱们能够结合代码来看看字符串
public boolean isMatch(String s, String p) {
if (s.equals(p)) {
return true;
}
boolean isFirstMatch = false;
if (!s.isEmpty() && !p.isEmpty() && (s.charAt(0) == p.charAt(0) || p.charAt(0) == '.')) {
isFirstMatch = true;
}
if (p.length() >= 2 && p.charAt(1) == '*') {
// 看 s[i,...n] 和 p[j+2,...m] 或者是 s[i+1,...n] 和 p[j,...m]
return isMatch(s, p.substring(2))
|| (isFirstMatch && isMatch(s.substring(1), p));
}
// 看 s[i+1,...n] 和 p[j+1,...m]
return isFirstMatch && isMatch(s.substring(1), p.substring(1));
}
复制代码
上面的实现之因此被称为暴力求解是由于子问题的答案没有被记录,也就是说若是当前要用到以前的子问题的答案,咱们还得去计算以前计算过的子问题。get
上面的暴力解法是由于没有记录答案,记忆化搜索是在 “傻搜” 的基础之上添加 “记事本”。这里提早说明一下待会给出的代码实现,我把递归的方向给改变了,固然这不是必要的,主要想说明,对于这道题来讲,从后往前考虑和从前日后考虑都是可行的,可是从后往前考虑重合的 cases 会更多,这样 “记事本” 所体现的功能就会更大。string
咱们假设当前问题是考虑 s 的第 i 个字母,p 的第 j 个字母,因此这时的子问题是 s[0...i] 和 p[0...j] 是否匹配:
不论是从前日后,仍是从后往前,你能够看到,考虑的点都是同样的,只是这里咱们多加了一个 “记事本”
public boolean isMatch(String s, String p) {
if (s.equals(p)) {
return true;
}
boolean[] memo = new boolean[s.length() + 1];
return helper(s.toCharArray(), p.toCharArray(),
s.length() - 1, p.length() - 1, memo);
}
private boolean helper(char[] s, char[] p, int i, int j, boolean[] memo) {
if (memo[i + 1]) {
return true;
}
if (i == -1 && j == -1) {
memo[i + 1] = true;
return true;
}
boolean isFirstMatching = false;
if (i >= 0 && j >= 0 && (s[i] == p[j] || p[j] == '.'
|| (p[j] == '*' && (p[j - 1] == s[i] || p[j - 1] == '.')))) {
isFirstMatching = true;
}
if (j >= 1 && p[j] == '*') {
// 看 s[0,...i] 和 p[0,...j-2]
boolean zero = helper(s, p, i, j - 2, memo);
// 看 s[0,...i-1] 和 p[0,...j]
boolean match = isFirstMatching && helper(s, p, i - 1, j, memo);
if (zero || match) {
memo[i + 1] = true;
}
return memo[i + 1];
}
// 看 s[0,...i-1] 和 p[0,...j-1]
if (isFirstMatching && helper(s, p, i - 1, j - 1, memo)) {
memo[i + 1] = true;
}
return memo[i + 1];
}
复制代码
除了记事本,其他并无太大的差异。其实这种方式已经算是动态规划了。
有了上面两种方法和解释做为铺垫,我想迭代式的动态规划应该不难理解。这里咱们再也不用递归,而是使用 for 循环的形式,先上代码:
public boolean isMatch(String s, String p) {
if (s.equals(p)) {
return true;
}
char[] sArr = s.toCharArray();
char[] pArr = p.toCharArray();
// dp[i][j] => is s[0, i - 1] match p[0, j - 1] ?
boolean[][] dp = new boolean[sArr.length + 1][pArr.length + 1];
dp[0][0] = true;
for (int i = 1; i <= pArr.length; ++i) {
dp[0][i] = pArr[i - 1] == '*' ? dp[0][i - 2] : false;
}
for (int i = 1; i <= sArr.length; ++i) {
for (int j = 1; j <= pArr.length; ++j) {
if (sArr[i - 1] == pArr[j - 1] || pArr[j - 1] == '.') {
// 看 s[0,...i-1] 和 p[0,...j-1]
dp[i][j] = dp[i - 1][j - 1];
}
if (pArr[j - 1] == '*') {
// 看 s[0,...i] 和 p[0,...j-2]
dp[i][j] |= dp[i][j - 2];
if (pArr[j - 2] == sArr[i - 1] || pArr[j - 2] == '.') {
// 看 s[0,...i-1] 和 p[0,...j]
dp[i][j] |= dp[i - 1][j];
}
}
}
}
return dp[sArr.length][pArr.length];
}
复制代码
这里我说一下前面的 DP 数组的初始化,由于须要考虑空串的状况,因此咱们 DP 数组大小多开了 1 格。由于两个空串是匹配的,因此 dp[0][0] = true
,紧接着下面一行的 for 循环是为了确保空串和 p 的一部分是匹配,好比 s = "",p = "a*",那么这里 dp[0][2]=true
,也就是 s[0,0]和p[0,2] 是匹配的,注意和以前不同的是这里的 0 表明空串。
通常来讲,对于字符串匹配的问题中,题目输入参数都会有两个字串,若是肯定了问题是能够分解成一系列子问题,那么就能够考虑使用动态规划求解,能够根据区间来定义状态,通常来讲只须要考虑头区间或者是尾区间,这道题中的动态规划解法,咱们就是考虑了头区间,s[0,...i]和p[0,...j] 是否匹配记录在 dp[i+1][j+1] 中,若是你选择尾区间的话,那么遍历的方式须要从后往前,就和以前讲解的记忆化搜索同样。通常的字符串匹配的动态规划的 DP 数组都是二维的,固然也有特例。我的以为肯定了考虑的区间和遍历方向,至少来讲在动态规划状态方程的推导上会清晰很多。
接下来就是重点的部分,递推方程的推导,这里没有特别多的技巧,仍是那句话,惟手熟尔,无他,要说重点的话,仍是在肯定当前子问题和前面子问题的联系上吧,或者你能够这样想 “当前考虑的子问题在什么状况下会变成前面求解过的子问题”,仍是拿这道题举例,上面的 DP 解法咱们从前日后遍历,在考虑子问题 s[0,...i]和p[0,...j] 是否匹配,若是拿掉 s[i] 和 p[j],这个问题就会变成前面求解过的子问题 s[0,...i-1]和p[0,...j-1],若是只拿掉 s[i],这个问题就会变成前面求解过的子问题 s[0,...i-1]和p[0,...j],若是拿掉 p[j-1]和p[j],这个问题就会变成前面求解过的子问题 s[0,...i]和p[0,...j-2],至于为何有些能够拿掉,有些不能,那这个只能根据题意来分析了,相信经过前面的分析应该不难理解。
结合上面的分析,这里列了一些字符串匹配类动态规划的一些注意事项:
以上就是此次的所有内容,但愿对于你理解这道题,或者说是理解字符串匹配类动态规划有所帮助。