vlambda博客
学习文章列表

动态规划法解通配符匹配算法题

动态规划在维基上的解释是:动态规划(英语:Dynamic programming,简称DP)是一种在数学管理科学计算机科学经济学生物信息学中使用的,通过把原问题分解为相对简单的子问题的方式求解复杂问题的方法。

在编程中,动态规划通常用来优化递归问题。举例说明(例题来自力扣第44题):

通配符匹配:
给定一个输入字符串(s)和一个模式(p),实现通配符模式匹配并支持'?' 和'*'。
'?' 匹配任何单个字符。'*'匹配任何字符序列(包括空序列)。匹配项应覆盖整个输入字符串(而非部分)。
s可以为空,并且仅包含小写字母a-z。p可以为空,并且仅包含小写字母a-z和?及*两种字符。
例1:输入:s ="aa"p ="a"输出:false说明:"a"与整个字符串"aa"不匹配
例2:输入:s ="adceb"p ="*a*b"输出:true说明:第一个"*"匹配空串,而第二个"*"匹配子串"dce"
例3:输入:s ="acdcb"p ="a*c?b"输出:false说明:"?"只能匹配单个字符,不能匹配"dc"或者空串。



看到这题目,首先想到的是,java.util.regex.Pattern类:

 public boolean isMatch(String s, String p) { return java.util.regex.Pattern.matches(p, s);    }

但是很遗憾,java.util.regex.Pattern的匹配里,p字符串的首字符不能是'*',也不支持连续"**"这样的模式匹配。


其次比较容易想到的是用递归的方法:

 public boolean isMatch3(String s, String p) { if("*".equals(p)) return true; if(p.isEmpty()) return s.isEmpty(); if(p.charAt(0) == '*') { if(s.isEmpty() || p.charAt(1) == '*') { return isMatch3(s, p.substring(1)); } else { return isMatch3(s, p.substring(1)) || isMatch3(s.substring(1), p); } } else if(s.isEmpty()) { return false; } else if(p.charAt(0) == '?' || p.charAt(0) == s.charAt(0)){ return isMatch3(s.substring(1), p.substring(1)); }
return false;    }

第1行,如果p只剩下全匹配符,那就是true了;

第2行,如果p剩下空串了,那么如果s也空串,就是true,否则就是false;

第10行,当p不是'*'开头的字符串,而s已经是空串,那么就是false;

第12行,如果当p为'?'开头时,或者p的首字符和s的首字符相同,那么是否匹配,就要看p和s除掉首字符后是否匹配,这就是一个递归;

当p的首字符是'*',情况就比较复杂:

     第5行,如果s是空串,或者p的第二个字符也为'*'时,就看p除掉首字符后是否与s匹配;

     '*'可以匹配空串,所以如果p除掉首字符'*',剩余的与s匹配,说明匹配,另一种情况就是'*'可以匹配任意字符,那么把s串的字符一个一个去除,看p是否可以与其匹配,如果这种情况出现也说明匹配;这两种情况只要其中一种能匹配,那么整个就是匹配的,所以第8行是两个递归函数取或;

如果以上情况都不符合,自然就是不匹配了。


这函数虽然在部分情况下能验证s和p的匹配情况,但是这个算法的时间复杂度太大,假设s的长度为m,p的长度为n,那复杂度最糟情况有m平方*n平方。如果s和p太长了,比如:

 String s = "abbabaaabbabbaababbabbbbbabbbabbbabaaaaababababbbabababaabbababaabbbbbbaaaabababbbaabbbbaabbbbababababbaabbaababaabbbababababbbbaaabbbbbabaaaabbababbbbaababaabbababbbbbababbbabaaaaaaaabbbbbaabaaababaaaabb"; String p = "**aa*****ba*a*bb**aa*ab****a*aaaaaa***a*aaaa**bbabb*b*b**aaaaaaaaa*a********ba*bbb***a*ba*bb*bb**a*b*bb";

这种情况下,这个递归最终是无法归来。


所以,用DP来优化递归问题就很必要。为了更好的理解之后的程序,先基于字符串如果匹配从头开始与从尾部开始都应该匹配这个事实,改写一下上面的程序:

    public boolean isMatch2(String s, String p) { if("*".equals(p)) return true; if(p.isEmpty()) return s.isEmpty();        int m = s.length(), n = p.length(); if(p.charAt(n-1) == '*') { if(s.isEmpty() || p.charAt(n-2) == '*') {                return isMatch2(s, p.substring(0,n-1)); } else {                return isMatch2(s, p.substring(0,n-1)) || isMatch2(s.substring(0,m-1), p); } } else if(s.isEmpty()) { return false; } else if(p.charAt(n-1) == '?' || p.charAt(n-1) == s.charAt(m-1)){            return isMatch2(s.substring(0,m-1), p.substring(0,n-1)); }
return false; }

最后的字符串是否匹配,除了最后字符的比较,也取决于最后字符之前的比较,如果最后的字符是匹配的,那么只要之前的字符串都是匹配的,整个字符串就是匹配的。


有了之前的递归思想,那么DP算法就很容易理解了。

先贴出程序,再来解释:

 public boolean isMatch(String s, String p) { boolean[][] dp = new boolean[s.length()+1][p.length()+1]; dp[0][0] = true; for (int i = 0; i < p.length(); i++) { if (p.charAt(i) == '*') { dp[0][i + 1] = dp[0][i]; } }  for(int i = 0; i < s.length(); i++) { for(int j = 0; j < p.length(); j++) { if(p.charAt(j) == '?' || p.charAt(j) == s.charAt(i)) { dp[i + 1][j + 1] = dp[i][j]; } if(p.charAt(j) == '*') { dp[i + 1][j + 1] = dp[i + 1][j] || dp[i][j + 1]; } } }
return dp[s.length()][p.length()]; }

    dp[i][j]标志一个状态,表示s字符串前i长度的子字符串和p字符串前j长度的子字符串的匹配情况。(JAVA的序列从0开始,i长度即为charAt(0)到charAt(i-1)的长度)

    dp[0][0]标志的就是当s为空串,p也为空串时的状态,这时的状态是匹配的,所以第4行dp[0][0]赋值为true;

    当p为空串,s不为空时,肯定都是不匹配的,即dp[i][0]都为false。boolean变量在java里的默认值就是false,所以就无需赋值了;

     而当s为空串,p不为空时,是否匹配要看'*'字符之前是否是匹配的,所以,对dp[0][j]进行初始赋值。第4到8行,对这个初始状态进行赋值。意即当s为空串时,如果p都是由'*'组成的字符串,那么是匹配的,如果有某一个字符不是'*'时,从那个字符开始,之后的都不匹配了(当然最终结果也是不匹配)。

      s字符串前i+1的长度的子串是否和p字符串前j+1长度的子串是否匹配,就取决于字串最后一个字符的比较,以及之前子串是否匹配的情况,程序第10-19行就是处理这种情况:

  • 当p子串的最后一个字符为'?'或者与s子串的最后一个字符相同时,最终是否匹配,就取决于i长度的s子串和j长度的p子串是否匹配,即dp[i + 1][j + 1] = dp[i][j];

  • 当p子串最后一个字符为'*'时,因为p可以匹配任意字符串或者空串,所以只要是i+1长度的s子串与j长度的p子串匹配,或者是i长度的s子串与j+1长度的p子串匹配就可以了,即dp[i + 1][j + 1] = dp[i + 1][j] || dp[i][j + 1];

  • 如果都不是,自然就不匹配,但boolean变量默认为false,无需处理。


    最终dp[][]的最后一个元素就是总体两个字符串是否匹配的最终结果了。其实这也是isMatch2这个递归方法的拆解倒推,只不过用了状态数组来标志已经对比过的子串,减少了很多重复的递归计算,所以把时间复杂度减少到了O(mn)。


点击阅读全文可以获取程序的源码。https://github.com/yangmingxuan/algorithmicexamples

这个项目目前上传了上百个算法题程序源码,每周末会更新(争取)。有兴趣的朋友可以共同探讨。