zoukankan      html  css  js  c++  java
  • 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     }
    isMatch(String s, String p)

    【回溯法的反思】

    递归的解法向来都是比较慢的,因为不是尾递归,每次递归栈中需要保存方法中全部变量信息,串长度一大,速度可想而知。还有没有更快的办法?更合理的办法?答案是肯定的。

    【解法二:动态规划 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     }
    isMatchDP

    【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     }
    isMatchLessSpace
  • 相关阅读:
    WPF之感触
    C# WinForm 给DataTable中指定位置添加列
    MyEclipse 8.6 download 官方下载地址
    将博客搬至CSDN
    Building Microservices with Spring Cloud
    Building Microservices with Spring Cloud
    Building Microservices with Spring Cloud
    Building Microservices with Spring Cloud
    Building Microservices with Spring Cloud
    Building Microservices with Spring Cloud
  • 原文地址:https://www.cnblogs.com/lupx/p/leetcode-10.html
Copyright © 2011-2022 走看看