LeetCode 10.正则表达式匹配

题目:


给你一个字符串 s 和一个字符规律 p,请你来实现一个支持 '.' 和 '*' 的正则表达式匹配。

'.' 匹配任意单个字符
'*' 匹配零个或多个前面的那一个元素
所谓匹配,是要涵盖 整个 字符串 s的,而不是部分字符串。

说明:

s 可能为空,且只包含从 a-z 的小写字母。
p 可能为空,且只包含从 a-z 的小写字母,以及字符 . 和 *。
示例 1:

输入:
s = "aa"
p = "a"
输出: false
解释: "a" 无法匹配 "aa" 整个字符串。
示例 2:

输入:
s = "aa"
p = "a*"
输出: true
解释: 因为 '*' 代表可以匹配零个或多个前面的那一个元素, 在这里前面的元素就是 'a'。因此,字符串 "aa" 可被视为 'a' 重复了一次。
示例 3:

输入:
s = "ab"
p = ".*"
输出: true
解释: ".*" 表示可匹配零个或多个('*')任意字符('.')。
示例 4:

输入:
s = "aab"
p = "c*a*b"
输出: true
解释: 因为 '*' 表示零个或多个,这里 'c' 为 0 个, 'a' 被重复一次。因此可以匹配字符串 "aab"。
示例 5:

输入:
s = "mississippi"
p = "mis*is*p*."
输出: false


思路:

摊手,想了很久,发现还是写不出来。也测试了一些特殊case,本想写出这些边界条件,但是有些case测试执行的时候没有预期结果,更不知道怎么实现了,只好看题解学***们的精华了,果然满满的收获。

方法一:

使用正则库去检索,这可以作为一种解题方法不过不推荐,这对锻炼解题思路没有帮助。

import re
class Solution(object):
    def isMatch(self, s, p):
        """
        :type s: str
        :type p: str
        :rtype: bool
        """
        return bool(re.match(p + '$', s))

执行用时 :108 ms, 在所有 Python 提交中击败了43.68%的用户

内存消耗 :12.8 MB, 在所有 Python 提交中击败了33.33%的用户

方法二:

方法二来自labuladong大大的题解,作者很nice,mark过来分享记录一下由简入深的思路,看完题解对自己的算法分析很有帮助。

一、热身

第一步,我们暂时不管正则符号,如果是两个普通的字符串进行比较,如何进行匹配?我想这个算法应该谁都会写:

C++
bool isMatch(string text, string pattern) {
    if (text.size() != pattern.size()) 
        return false;
    for (int j = 0; j < pattern.size(); j++) {
        if (pattern[j] != text[j])
            return false;
    }
    return true;
}

然后,我稍微改造一下上面的代码,略微复杂了一点,但意思还是一样的,很容易理解吧:

C++
bool isMatch(string text, string pattern) {
    int i = 0; // text 的索引位置
    int j = 0; // pattern 的索引位置
    while (j < pattern.size()) {
        if (i >= text.size()) 
            return false;
        if (pattern[j++] != text[i++])
            return false;
    }
    // 判断 pattern 和 text 是否一样长
    return j == text.size();
}

如上改写,是为了将这个算法改造成递归算法(伪码):

Python
def isMatch(text, pattern):
    if pattern is empty: return text is empty
    first_match = (text not empty) and pattern[0] == text[0]
    return first_match and isMatch(text[1:], pattern[1:])
}

如果你能够理解这段代码,恭喜你,你的递归思想已经到位,正则表达式算法虽然有点复杂,其实是基于这段递归代码逐步改造而成的。

二、处理点号「.」通配符

点号可以匹配任意一个字符,万金油嘛,其实是最简单的,修改上面的伪码,再稍加改造即可:

Python
def isMatch(text, pattern):
    if not pattern: return not text
    first_match = bool(text) and pattern[0] in {text[0], '.'} 
    return first_match and isMatch(text[1:], pattern[1:])

三、处理「*」通配符
星号通配符可以让前一个字符重复任意次数,包括零次。那到底是重复几次呢?这似乎有点困难,不过不要着急,我们起码可以把框架的搭建再进一步:

Python
def isMatch(text, pattern):
    if not pattern: return not text
    first_match = bool(text) and pattern[0] in {text[0], '.'}
    if len(pattern) >= 2 and pattern[1] == '*':
        # 发现 '*' 通配符
    else:
        return first_match and isMatch(text[1:], pattern[1:])

星号前面的那个字符到底要重复几次呢?这需要计算机暴力穷举来算,假设重复 N 次吧。前文多次强调过,写递归的技巧是管好当下,之后的事抛给递归。具体到这里,不管 N 是多少,当前的选择只有两个:匹配 0 次、匹配 1 次。所以可以这样处理:

Python
if len(pattern) >= 2 and pattern[1] == '*':
    return isMatch(text, pattern[2:]) or first_match and isMatch(text[1:], pattern)
#解释:如果发现有字符和 '*' 结合,
# 或者匹配该字符 0 次,然后跳过该字符和 '*'
# 或者当 pattern[0] 和 text[0] 匹配后,移动 text

可以看到,我们是通过保留 pattern 中的「*」,同时向后推移 text,来实现「*」将字符重复匹配多次的功能。举个简单的例子就能理解这个逻辑了。假设 pattern = a*, text = aaa,画个图看看匹配过程:

7c933f57e4f71c61a9bb4eb5d80c6f54373a3787b2c17255e3cefba4ecd06ed6-file_1560139042317

至此,正则表达式算法就基本完成了,

四、动态规划

我选择使用「备忘录」递归的方法来降低复杂度。有了暴力解法,优化的过程及其简单,就是使用两个变量 i, j 记录当前匹配到的位置,从而避免使用子字符串切片,并且将 i, j 存入备忘录,避免重复计算即可。

我将暴力解法和优化解法放在一起,方便你对比,你可以发现优化解法无非就是把暴力解法「翻译」了一遍,加了个 memo 作为备忘录,仅此而已。

Python

# 带备忘录的递归
class Solution(object):
    def isMatch(self, text, pattern):
        memo = {} # 
        def dp(i, j):
            if (i, j) in memo: 
                return memo[(i, j)]
            if j == len(pattern): 
                return i == len(text)
            first = i < len(text) and pattern[j] in {text[i], '.'}
            if j <= len(pattern) - 2 and pattern[j + 1] == '*':
                ans = dp(i, j + 2) or first and dp(i + 1, j)
            else:
                ans = first and dp(i + 1, j + 1)
                memo[(i, j)] = ans
            return ans
        return dp(0, 0)

# 暴力递归
class Solution(object):
    def isMatch(self, text, pattern):
        if not pattern:
            return not text
        first_match = bool(text) and pattern[0] in {text[0], '.'}
        if len(pattern) >= 2 and pattern[1] == '*':
            return (self.isMatch(text, pattern[2:]) or
                    first_match and self.isMatch(text[1:], pattern))
        else:
            return first_match and self.isMatch(text[1:], pattern[1:])

执行用时 :1444 ms, 在所有 Python 提交中击败了34.62%的用户

内存消耗 :13.2 MB, 在所有 Python 提交中击败了33.33%的用户

有的读者也许会问,你怎么知道这个问题是个动态规划问题呢,你怎么知道它就存在「重叠子问题」呢,这似乎不容易看出来呀?

解答这个问题,最直观的应该是随便假设一个输入,然后画递归树,肯定是可以发现相同节点的。这属于定量分析,其实不用这么麻烦,下面我来教你定性分析,一眼就能看出「重叠子问题」性质。

先拿最简单的斐波那契数列举例,我们抽象出递归算法的框架:

Python
def fib(n):
    fib(n - 1)
    fib(n - 2)

看着这个框架,请问原问题 f(n),如何触达子问题 f(n−2)?有两种路径,一是 f(n) -> f(n-1) -> f(n - 1 - 1), 二是 f(n) -> f(n - 2)。前者经过两次递归,后者进过一次递归而已。两条不同的计算路径都到达了同一个问题,这就是「重叠子问题」,而且可以肯定的是,只要你发现一条重复路径,这样的重复路径一定存在千万条,意味着巨量子问题重叠。

同理,对于本问题,我们依然先抽象出算法框架:

Python
def dp(i, j):
    dp(i, j + 2)     #1
    dp(i + 1, j)     #2
    dp(i + 1, j + 1) #3

提出类似的问题,请问如何从原问题 dp(i,j),触达子问题 dp(i+2,j+2)?至少有两种路径,一是 dp(i, j) -> #3 -> #3,二是 dp(i, j) -> #1 -> #2 -> #2。因此,本问题一定存在重叠子问题,一定需要动态规划的优化技巧来处理。

五、最后总结
通过本文,你深入理解了正则表达式的两种常用通配符的算法实现。其实点号「.」的实现及其简单,关键是星号「*」的实现需要用到动态规划技巧,稍微复杂些,但是也架不住我们对问题的层层拆解,逐个击破。另外,你掌握了一种快速分析「重叠子问题」性质的技巧,可以快速判断一个问题是否可以使用动态规划套路解决。

回顾整个解题过程,你应该能够体会到算法设计的流程:从简单的类似问题入手,给基本的框架逐渐组装新的逻辑,最终成为一个比较复杂、精巧的算法。所以说,读者不必畏惧一些比较复杂的算法问题,多思考多类比,再高大上的算法在你眼里也不过一个脆皮。

方法三:

这个也比较好理解,将情况列出来,再一次实现动态规划,这个时候要定义好dp数组的含义,我们定义dp[i][j]为字符串s中前i个字符和字符串p中前j个字符是否能匹配上,根据p中可能出现的字符情况,有以下三种情况:

  1. 如果p[j] == s[i], 那么dp[i][j] = dp[i-1][j-1],意思就是说,如果p的第j个字符和s的第i个字符匹配上了,那么dp[i][j]是否为true取决于dp[i-1][j-1]
  2. 如果p[j] == '.',那么p[j]此时就可以匹配任意字符,情况就和1一样了,dp[i][j] = dp[i-1][j-1]
  3. 如果p[j] == '*',那么此时分为两种情况:
    1. 如果p[j-1] != '.' and p[j-1] != s[i],也就说*之前的字符不能和当前字符s[i]匹配上,那么这个时候我们也不直接将dp[i][j]设置为false,而是将此时的 * 看作个数为0,查看dp[i][j-2]的状态,即j的前2位的状况.此时dp[i][j] = dp[i][j-2]
    2. 否则的话,说明p[j-1]能够和s[i]匹配上,那么此时*就会有3个状态,分别当作0个 1个 多个来用,对应的状态就是
      -- dp[i][j] = dp[i][j-2], *当作0个来用
      -- dp[i][j] = dp[i][j-1], *当作1个来用
      -- dp[i][j] = dp[i-1][j], *当作多个来用
class Solution(object):
    def isMatch(self, s, p):
        if s == None and p == None:
            return True
        
        n = len(s)
        m = len(p)
        dp = [[False for _ in range(m+1)] for _ in range(n+1)]
        dp[0][0] = True
        for j in range(m):
            if p[j] == '*' and dp[0][j-1]:
                dp[0][j+1] = True
        for i in range(0, n):
            for j in range(0, m):
                if p[j] == '.' or p[j] == s[i]:
                    dp[i+1][j+1] = dp[i][j]
                elif p[j] == '*':
                    if p[j-1] != s[i] and p[j-1] != '.':
                        dp[i+1][j+1] = dp[i+1][j-1] # 相当于*为0个元素
                    else:
                        dp[i+1][j+1] = dp[i+1][j-1] or dp[i+1][j] or dp[i][j+1]
                            	# *为0个						 # *为1个					 # *为多个

        return dp[n][m]

执行用时 :52 ms, 在所有 Python 提交中击败了74.72%的用户

内存消耗 :12.7 MB, 在所有 Python 提交中击败了33.33%的用户

方法四:

递归+动态规划

这个是另一位大大TED的题解思路,也是对理解递归和动态规划思想有帮助。

首先看 ".", 如果只存在 "." 而不存在 "*", 那么 s 和 p 长度是相同的,只要逐位来检测 p 中的字符是否与 s 匹配:要么该位字符与 s 中相同,要么该位字符是 ".", 否则就会匹配失败。

因为返回值是true false,所以对于这个 "." 的检测逻辑,可以写成下面形式:

p = "a.a"
s = "aaa"
for i,c in enumerate(p):
    if c not in [".", s[i]]: #存在为true,不存在返回false
        print 'False'

下面只需要考虑*的情况.也就是匹配0次,1次,和多次,首先p[0] 为*的情况skip,无意义。

那么如果它出现在第二位 p[1] 时:

星号如果是发挥零个前面字符的作用,那么 p[0]p[1] 这两个字符完全不参与对 s 的匹配,即 p[2:] 对 s 的匹配效果与 p 对 s 的匹配效果一致。换句话说,此时就可以将 p 的前两位删去来重新匹配检测
星号如果是发挥复制前面字符的作用,这时,我们可以对 s 字符串做文章,我们把 s 的首字符拿走,因为 * 可以将 p 中的字符转为个数 0 从而不影响匹配效果,即 p 对 s[1:] 的匹配效果和 p 对 s 的匹配效果是一致的。换言之此时可以将 s 的第一位删去来重新匹配检测
还有如果它没有出现在第二位 p[1] 呢?那对于前两位的检测需要按没有 * 时的匹配规则来检测,同时再把 p 和 s 检测通过的第一位同时删去,重新检测 p[1:] 和 s[1:] 是否匹配即可。

结合着刚我们的分析,当我们发现 p 中出现 * 时,如果其在第二位出现,我们可以将 p 的前两位删去重新执行整个检测、或将 s 的首位删去重新执行整个检测;如果没在第二位出现,将 s 和 p 的第一位同时删去来进行重新匹配检测。这个满足重新匹配检测的状态点即“回溯点”。

递归版本:

class Solution:
    def isMatch(self, s, p):
		# 如果有 *      
        if "*" in p:            
            temp =  bool(s) and p[0] in [".",s[0]]
            if len(p)>1 and p[1]=="*":                           
                return self.isMatch(s,p[2:]) or (temp and self.isMatch(s[1:],p))
            else:
                return temp and self.isMatch(s[1:],p[1:])
        # 没 * 时如果 p 为空字符串
        elif p=="":
            if s=="":
                return True
            else:
                return False
        # 如果 p 为非空没 * 字符串
        else:
            if len(p)!=len(s):
                return False
            try:
                for i,c in enumerate(p):
                    if c not in [".", s[i]]:
                        return False
                return True
            except:
                return False 

执行用时 :2148 ms, 在所有 Python 提交中击败了5.08%的用户

内存消耗 :12.7 MB, 在所有 Python 提交中击败了33.33%的用户

动态规划:

这个和前面方法二的思路是一样,唯一的差别就是在对处理的过程有做顺序调整,但是时间差值天壤之别

class Solution(object):
    def isMatch(self, text, pattern):
    	# memo 字典用来存储(i,j)作为key,匹配结果作为值
        memo = {}
        # 定义储存(i,j)对应匹配结果到 memo字典的函数,同时还会返回该(i,j)对应的匹配结果
        def dp(i, j):
        	# 如果字典中没有该key,即还没有对(i,j)进行检测
            if (i, j) not in memo:
            	# 如果j超出pattern长度
                if j == len(pattern):
                	# 此时i必须也超出长度,否则返回False
                    ans = i == len(text)
                # 如果还在pattern中
                else:
                	# i也在text中且pattern在j处字符与text在i处字符相匹配
                    first_match = i < len(text) and pattern[j] in {text[i], '.'}
                    # 如果第二位是星号,dp(i,j)与dp(i,j+2)或在前面字符相符的情况下 dp(i+1,j)的结果等效
                    if j+1 < len(pattern) and pattern[j+1] == '*':
                        ans = dp(i, j+2) or first_match and dp(i+1, j)
                    # 如果星号不在第二位,那么dp(i,j)与在之前字符相符情况下dp(i+1,j+1)等效
                    else:
                        ans = first_match and dp(i+1, j+1)
				# 因为这是字典中没有(i,j)作为key记录的情况,将其记录在字典中
                memo[i, j] = ans
            # 最终返回(i,j)作为key值在字典中对应的匹配结果值
            return memo[i, j]
		# 自顶向下,也就是从最终状态出发,如果遇到一个子问题还未求解,那么就先求解子问题。如果子问题已经求解,那么直接使用子问题的解
        return dp(0, 0)

执行用时 :36 ms, 在所有 Python 提交中击败了93.80%的用户

内存消耗 :13.3 MB, 在所有 Python 提交中击败了33.33%的用户

posted @ 2020-05-25 14:23  萧蔷ink  阅读(262)  评论(0)    收藏  举报