• 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
  • 相关阅读:
    团队作业(二):项目选题
    今天准备正式开博了!专注于Silverlight!
    ORA01033:ORACLE initialization or shutdown in progress 错误的解决办法
    寻找正在应用和准备学习XNAor3D技术的志同道合的伙伴,大家能够互帮互助,共同探讨,最好能够组成较固定的小团队!
    TNS: could not resolve the connect identifier specified
    今天连接字符串出现了“ORA01008: 并非所有变量都已绑定”错误
    选择HttpHandler还是HttpModule?
    jQuerymenuaim.js
    MVP解读
    揭秘Amazon反应速度超快的下拉菜单
  • 原文地址:https://www.cnblogs.com/lupx/p/leetcode-10.html
Copyright © 2020-2023  润新知