LeetCode (10): Regular Expression Matching [HARD]
https://leetcode.com/problems/regular-expression-matching/
【描述】
Implement regular expression matching with support for '.'
and '*'
.
'.' Matches any single character.
'*' Matches zero or more of the preceding element.
The matching should cover the entire input string (not partial).
Some examples:
isMatch("aa","a") → false
isMatch("aa","aa") → true
isMatch("aaa","aa") → false
isMatch("aa", "a*") → true
isMatch("aa", ".*") → true
isMatch("ab", ".*") → true
isMatch("aab", "c*a*b") → true
【中文描述】
给两个字符串:s和p. p是正则表达式串,其中包含有三种字符:普通字符、'.'、'*'。要求实现方法,返回p是否能够匹配s.
其中:
'.'可以匹配s中任意字符。
'*'是个控制字符,在'*'前的字符在s中可以出现0或者无限次。
题目要求全部匹配,不能部分匹配。
例子:
isMatch("aa","a") → false //解释:s中2个a,而p中一个普通字符a,显然不匹配
isMatch("aa","aa") → true
isMatch("aaa","aa") → false
isMatch("aa", "a*") → true //解释:s中2个a,而p中有'a*',根据题意,a可以出现无数次,所以是匹配的
isMatch("aa", ".*") → true //解释:由于'.'可以代替任意字符,所以'.*'的意思就是任意字符出现任意次数,所以肯定可以匹配aa
isMatch("ab", ".*") → true //同上,ab也能匹配
isMatch("aab", "c*a*b") → true //解释:c在s中没有出现,但是c*是可以匹配的。然后aa匹配了a*, 最后的b互相匹配。 所以整体匹配
————————————————————————————————————————————————————————————
【初始思路】
刚开始没觉得是hard题,就觉得给2个指针,一个指s,一个指p。然后从前往后一步步比较就行了。无非就是比较当前位的时候兼顾后一位的情况,注意边界条件,仔细写应该不会出错。先写了一个,一提交wrong answer了。 用例是这样的:
s="aabbbcd", p="a*b*bbbcd"
显然,s和p是匹配的。但是用我上面的方法,就绝对匹配不成功。因为,p中的b*会直接和S中的bbb全部匹配,然后p中剩下的bbb就要和cd匹配,返回false。换句话说,我上面的算法是一条路走到黑,成功就成功,失败就失败。根本不考虑是否还有其他可能性!
【重整思路】
看到这个用例,我才反应过来,我太naive了,太没有程序猿的知觉了。事实上,仔细想想就能发现,这个题是需要回溯考虑的。可能按照s当前位和p中某'普通字符+*'模式比较是匹配的,到结尾有可能不匹配。但是如果当前位按照不与p中“字符+*”匹配(也即直接跳过p中'x*')走到最后却有可能成功。 所以,这就需要回溯考虑。如果按照既定步骤匹配到结尾不成功,我们可以回溯回来,然后从当前位用下一个策略去尝试一下。 所有的尝试里,只要成功一次,就算匹配成功!
说到回溯,程序猿的直觉告诉我,需要用递归Recursion!
【解法一:递归 Recursion】
回溯递归解法需要知道3个关键点:(1)如何确定当前步在哪里?(2)当前步有哪些决策?(3) 当前决策失败后回到哪里?
首先,如何确定当前步?由于已经考虑清楚要用递归,那么其实是不需要指针的。递归其实就是把大问题化为小问题的典型解题方案。本题的大问题是s和p是否匹配。假设s当前从左往右的一个子部分s1已经和p从左往右的一个子部分p1匹配了。那么剩下的子问题就是判断,s从s1后的部分和p从p1后的部分是否匹配的问题,这就把问题从大化小了。看下面图:
对于递归方法,由于传参要求都一样,所以显然,传进一个当前串的子串拷贝不就可以了么?
其次,当前有哪些决策,我们来分析一下,由于每次考虑的都是尚未进行匹配测试的子串,所以当前位置就是0位置:
(1) 当前p中下一个字符不为'*'的情况,这种情况下,p当前必须是'.'或者和s当前相同,才能匹配。如果匹配成功,那么s和p各自截取后一位子串继续递归。如果匹配不成功直接返回false。这是策略1;
(2) 当前p中下一个字符为'*'的情况,比较复杂,设当前字符为X。
(2.1) 首先初始假设,p当前的X*在s中根本没有出现过,所以,尝试一下把p后推2位,递归尝试一次。如果失败,说明s当前字符必须和p当前X匹配,然后才有可能成功。
(2.2) 2.1失败,只有X与s当前字符相同做匹配尝试有可能能成功。根据这个策略,s前移一位,p不动,递归尝试一次。如果返回失败,则可以认为必然失败。因为每个情况下,要么X*在s中匹配0次,也即策略1。 要么匹配1或无数次,这是策略2.2。 策略2.1已经失败,策略2.2也失败,没有其他策略可以选择了。所以肯定失败;
第三,怎么回溯?
首先,对于情况(1),s和p当前必须匹配,并且各推一位尝试成功,才能算成功,两个条件失败一个就肯定失败,所以不存在决策和回溯。
而对于情况(2),由于决策2.1和决策2.2成功一个即可,所以使用一个if-else判断。如果决策2.1成功,直接成功。 如果2.1失败,进入决策2.2 看看是否成功。
此外,递归方案必须考虑基准条件。什么是基准条件。s和p当前都是空串,肯定匹配,返回成功。p当前只有一个字符(因为上面每次都要考虑p下一个字符的问题,所以只有一个字符的时候是个特殊情况,需要单独拎出来考虑),这个时候,p当前字符必须和s当前字符匹配,并且s不能为空,才能算成功。
好了,到了这里,全部分析完了,可以编码。
【Show me the Code!!!】
1 /** 2 * 递归方法: 3 * 每次检查当前字符,有几种可能性: 4 * 1. p的下一个字符是*,那么首先考虑的可能性是S当前字符并不是p当前字符的通配出现, 也即初始假设p当前这个字符并没有在s中出现. 5 * 1.1 初始尝试,p指针后移2位递归求个结果,如果true,那肯定直接返回成功 6 * 1.2 初始尝试失败, 说明s当前字符需要和p当前字符匹配一下, 再递归一次, 看看结果. 如果还不行,那直接返回失败. 7 * 2. p的下一个字符不是*, 那么有2个可能性: p当前是. 或者 普通字符. 8 * 这两种情况下,都需要考虑和s当前字符的匹配情况,成功则指针后移,不成功则直接返回false 9 * @param s 待匹配串 10 * @param p 正则表达式 11 * @return 是否匹配的结果 12 */ 13 public static boolean isMatch(String s, String p) { 14 if(p.length() == 0) return s.length() == 0; 15 16 if(p.length() == 1) { 17 //这个返回的精妙之处在于,直接把对s的长度条件融入到了与条件里. 这个条件成立的时候,后面的条件才能拿来做最终的判断. 18 //如果s的长度条件不满足,那么后面不用判断了,肯定是false的. 19 //所以用了"&&",相当于以下2句的效果: 20 // if(s.length()==1) return p.charAt(0) == '.' || p.charAt(0) == s.charAt(0); 21 // else return false; 22 return s.length() == 1 && (p.charAt(0) == '.' || p.charAt(0) == s.charAt(0)); 23 } 24 25 //p.length()>1时, 看当前字符的下一个字符是什么了. 26 if(p.charAt(1) == '*') { 27 if(isMatch(s, p.substring(2))) return true;//初始假设 28 else { // 初始假设失败, s当前字符必须和p当前字符匹配,才有可能成功 29 // s.length() > 0 的意义上面讲过: 30 // 如果s已经为空串了,又已知p除去当前2个通配字符以后还有字符和s不匹配, 那就不用比了, 现在肯定也不匹配. 31 // 第二行的意义是, p当前还得是'.' 或者和s相同的字符 32 // 第三行的意义是, s跳过当前字符后,和p匹配了 33 // 以上三个条件都成立, 才能算最终可以匹配成功. 34 // 否则均失败 35 return s.length() > 0 36 && (p.charAt(0) == '.' || p.charAt(0) == s.charAt(0)) 37 && isMatch(s.substring(1), p); 38 } 39 } 40 41 //p当前字符下一个字符不是*, 最好处理 42 //匹配的条件是, 43 //1.s不为空串,因为s若为空串, 而p当前字符不是.就是普通字符,必须有个字符和它匹配,那必然失败 44 //2.p当前和s当前匹配 45 //3.p和s分别后移一位,也最终匹配 46 //1+2+3返回成功,才能算成功 47 else { 48 return s.length() > 0 49 && (p.charAt(0) == '.' || p.charAt(0) == s.charAt(0)) 50 && isMatch(s.substring(1), p.substring(1)); 51 } 52 }
【回溯法的反思】
递归的解法向来都是比较慢的,因为不是尾递归,每次递归栈中需要保存方法中全部变量信息,串长度一大,速度可想而知。还有没有更快的办法?更合理的办法?答案是肯定的。
【解法二:动态规划 DP】
动态规划的核心思想是,把算法执行过程中的中间结果保存起来,为了计算下一个状态,可以根据当前状态的结果递推得出。比如著名的菲波那切数列,1,1,2,3,5,8,13....,显然为了求当前的数字,只需要知道前面2个数字即可,之前的结果不再重要。但是如果用递归来解,那么之前的每一步的结果,都会保存在栈中,耗时耗空间。
此外,动态规划还适合解决只需要知道结果,而不关注中间过程的题目。如果,中间过程也需要给出,动态规划可能就不太适合了。
好了,既然是从前一个状态推当前状态,那么我们需要建立一个递推模型,然后找出递推公式(但凡用DP解题,这个是必须的!)。
【递推模型】
我们用一个二维数组来记录中间状态,并且数组元素就是boolean变量。比如dp[i][j]表示s中s{0,1,...i-1}子串和p中p{0,1,....j-1}子串的匹配情况。然后我们可以根据dp[i-1][j-1]的真假以及s{i-1}和p{j-1}的匹配情况,综合判断得出dp[i][j]的结果。
【递推公式】
既然我们用了2维数组,并且在递推的过程中要经常检查dp[i-1][j-1]这些情况,所以为防止越界,我们需要考虑先把数组的第一行和第一列先确定下来。
首先,显然的是,s为空串,p也为空串的情况下,dp[0][0]就表示了这个状态,显然 dp[0][0] = true。
同时,根据上面的递推模型来看,第一行dp[0][j]其实就表示了s为空串的时候,p和空串s匹配的情况。 而dp[i][0]表示,p为空串时,s各个字符和p匹配的情况。显然,dp[i][0]也就是第一列除第一行外肯定全部为false。因为p为空串,s只要不是空串就肯定不匹配。
我们来看看dp[0][j]的各个情况:
(1)j为1的时候,dp[0][1]=false。
(2)j>1的时候,p{j-1}=='*'为真并且dp[0][j-2]也为真,dp[0][j]才能为真。
这样,我们在正式递推之前,把这两个边界情况讨论清楚了。
由于前面已经把i==0和j==0情况下的边界讨论清楚了,所以我们的两个循环i和j都分别从1开始,到字符串最后一个字符停止。所以,用两个for循环可搞定。
为了求dp[i][j],其实要看p{j-1}的情况:
(1)p{j-1}!='*'情况:简单。p{j-1}必须和s{i-1}字符匹配。同时dp[i-1][j-1]必须匹配成功。这是个&&逻辑。
(2)p{j-1}=='*'情况,参考上面递归方法中的分析,假设p{j-2} = X, 所以目前有个*二元组:X*, 有2个不同的可能性:
(2.1) X在s中根本没有出现,那么dp[i][j] = dp[i][j-2] ;
(2.2) X在s中已经出现了1次或N次。1次的时候,p{j-2} == s{i-1}或者p{j-2}=='.',同时,dp[i-1][j]要为真,也即当前的p{0,...j-1}已经能匹配s{0,...i-2},那么前面条件如果成立,p{0....j-1}就也能匹配s{0,...i-1}。这两个条件是&&的关系,都得成立,才能算成功。
显然,2.1和2.2之间是||的关系。
到此,递推公式就出来了。然后按照递推公式去写就行了。最终,根据模型定义,dp[s.length][p.length]就是我们要求的结果: s{0,...slength-1} 与 p{0,...plength-1}的匹配情况。
【Show me the Code!!!】
1 /** 2 * 根据自己的理解写的DP, O(nm)时间, 但是空间是O(MN).时间应该是不能再优化了, 空间可优化成上面的O(slength) 3 * @param s 4 * @param p 5 * @return 6 */ 7 public static boolean isMatchDP(String s, String p) { 8 int slen = s.length(); 9 int plen = p.length(); 10 11 /** 12 * 保存动态规划的中间结果,我们用dp[i][j]来表示: S{0,..i-1} 与P{0,..j-1}的匹配结果. 13 */ 14 boolean dp[][] = new boolean[slen+1][plen+1];//上面解释了,i和j在dp里代表s和p的下标.所以,dp尺寸需要加1 15 16 /** 17 * 下面来分析一下递推公式(DP少不了这个东西!). 18 * 所谓递推公式就是根据之前已经保存的状态推出当前的状态. 也即求当前dp[i][j],可根据之前的结果间接的求出 19 * 假设当前求dp[i][j], 它代表了S{0->i-1}与P{0->j-1}的匹配情况. 那么有以下几个可能: 20 * (1)如果p{j-1}当前不是*,情况简单,当前匹配的唯一条件就是p{j-1}要与s{i-1}匹配 21 * 并且, 之前也都一直匹配, dp[i-1][j-1]匹配! 两者哪个不满足都是false,所以两个条件"&&"一下即可. 22 * 得递推公式: 23 * when p{j-1}!='*', dp[i][j] = dp[i-1][j-1] && p{j-1} == s{i-1} || p{j-1} == '.' 24 * (2)如果p{j-1}当前是个*, 情况比较复杂了. 首先看看有哪几种可能性, 我们设p{j-2} = X, X* 是个二元组 25 * (2.1) X没有在s中重复过, 也即X重复了0次, 所以这种情况就是只要dp[i][j-2]为true, 当前就可以为true. 26 * (2.2) X在S中...i-3,i-2,i-1的位置出现过>=1次, >=1可以拆分开理解,=1成立&&>1也成立!(这是本题最难的部分!一旦理解,这个题就是个easy题了!) 27 * 那么可以假设出现一次的话, 显然必须满足 p{j-2}==s{i-1}||p{j-2}=='.' 28 * 出现>1次, 还应要求, S{0->i-2}最起码要能匹配p{0->j-1}, 也即dp[i-1][j]也需为true 29 * 综上, 2.1和2.2之间是或者的关系,但是2.2内部,>=1我们拆成了>1&&=1的情况,这样就是个&&的关系 30 * 得递推公式: 31 * when p{j-1}=='*', dp[i][j] = dp[i][j-2] || (p{j-2}==s{i-1}||p{j-2}=='.') && dp[i-1][j] 32 * 有了递推公式, 我们可以看到,当i和j分别推进到各自边界的时候,两个串的最终匹配结果一定保存在dp[slen][plen],return这个结果就可以了! 33 */ 34 35 /** 36 * 显然 dp[0][0] = true, 因为代表两个空串做匹配的结果,肯定是true 37 */ 38 dp[0][0] = true; 39 40 /** 41 * 当p为空串的时候,s有字符,显然全部不可能匹配 42 */ 43 for(int i = 1; i <= s.length(); i++) { 44 dp[i][0] = false; 45 } 46 47 /** 48 * 显然, i=0, j从1-plen遍历的各个结果,代表了p各个子串分别是否能否匹配空串s. 49 * 有一定可能, 当p中j-1位置是*,并且0->j-3的匹配结果是true, 也即dp[0][j-2] = true 50 * 否则,dp[0][j] =false 51 * 这里, 我们把i=0的第一行计算出来 52 */ 53 for(int j = 1; j <= p.length(); j++) { 54 //之所以从1开始,是为了方便理解: j位置结果表示了p{0->j-1}的匹配结果 55 //所以,显然dp[0][1]代表了p第一个字符是否能够匹配空串, 显然是不可能的 56 if(j==1) dp[0][j] = false; 57 else dp[0][j] = p.charAt(j-1) == '*' && dp[0][j-2]; 58 } 59 60 61 62 /**到这里,我们就已经分析完了基本边界情况以及空串情况,下来开始递推*/ 63 for(int i = 1; i <= slen; i++) { 64 for(int j = 1; j <= plen; j++) { 65 if(p.charAt(j-1) != '*') { 66 dp[i][j] = dp[i-1][j-1] && (p.charAt(j-1) == '.' || p.charAt(j-1) == s.charAt(i-1)); 67 } 68 else { 69 dp[i][j] = dp[i][j-2]|| 70 (p.charAt(j-2) == '.' || p.charAt(j-2) == s.charAt(i-1)) && dp[i-1][j]; 71 } 72 } 73 } 74 return dp[slen][plen]; 75 }
【DP的反思】
上面这个DP时间复杂度是O(mn), 空间复杂度是O(mn)。 还是有优化余地的。在网上看大神的解法,有一个O(N)空间复杂度的解法很牛逼,这里贴出来,我还没有对单个字符为什么要从后往前匹配研究清楚,慢慢研究吧!
1 /** 2 * This is the O(nm) time and O(n) space DP, awesome! 3 * @param s 4 * @param p 5 * @return 6 */ 7 public static boolean isMatch(String s, String p) { 8 String[] patterns = new String[p.length()]; 9 int i = 0, ptr = 0; 10 while (i != p.length()) {//parse p into tokens[], 要么单字符,要么*二元组 11 if (i + 1 < p.length() && p.charAt(i + 1) == '*') { 12 patterns[ptr++] = p.substring(i, i + 2); 13 i += 2; 14 } 15 else { 16 patterns[ptr++] = p.substring(i, i + 1); 17 i += 1; 18 } 19 } 20 21 boolean[] d = new boolean[s.length() + 1]; 22 d[0] = true; 23 for (i = 1; i <= s.length(); ++i) d[i] = false; //d[]全部置为false 24 for (i = 1; i <= ptr; ++i) { 25 //根据tokens[], 一一判断是否和s中每个字符匹配. 26 String pattern = patterns[i - 1];//获取当前token 27 char c = pattern.charAt(0);//当前token第一个字符 28 if (pattern.length() == 2) {//2元组情况 29 for (int j = 1; j <= s.length(); ++j) {//分别针对s中字符进行匹配测试 30 d[j] = d[j] || (d[j - 1] && (c == '.' || c == s.charAt(j - 1))); 31 } 32 } 33 else {//单个情况 34 for (int j = s.length(); j >= 1; --j) { 35 d[j] = d[j - 1] && (c == '.' || c == s.charAt(j - 1)); 36 } 37 } 38 d[0] = d[0] && pattern.length() == 2; 39 } 40 return d[s.length()]; 41 }