Hard | LeetCode 10 | 剑指 Offer19. 正则表达式匹配 | 递归 | 动态规划

请实现一个函数用来匹配包含'. ''*'的正则表达式。模式中的字符'.'表示任意一个字符,而'*'表示它前面的字符可以出现任意次(含0次)。在本题中,匹配是指字符串的所有字符匹配整个模式。例如,字符串"aaa"与模式"a.a""ab*ac*a"匹配,但与"aa.a""ab*a"均不匹配。

示例 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
  • s 可能为空,且只包含从 a-z 的小写字母。
  • p 可能为空,且只包含从 a-z 的小写字母以及字符 .*,无连续的 '*'

方法一:递归

维持两个指针sIndex, pIndex 分别指向主串和模式串, 从左往右比较。

  1. 如果pIndex指向的元素的下一个元素不是'*', 则说明当前的模式串字符必须参与匹配, 且匹配一次, 然后继续匹配后面未匹配的字符。用代码语言表达为

    if (pIndex == p.length() - 1 || p.charAt(pIndex+1) != '*') {
        // 下一个字符不是'*'
        if (sIndex < s.length() &&(p.charAt(pIndex) == s.charAt(sIndex) || p.charAt(pIndex) =='.')) {
            // 匹配主串和模式串后面的字符
            return matchCore(s, sIndex + 1, p, pIndex + 1);
        }
    }
    
  2. 如果pIndex指向的元素的下一个元素是'*', 则说明当前的pIndex元素可以参与匹配0次 或者参与匹配多次。

    2.1 如果当前的pIndex元素参与匹配0次 , 那么当前的主串字符在下一次递归还是要参与匹配的, 而且对于模式串, 下一次递归里, 当前字符和下一个'*'都不参与匹配。用代码语言表达为 return matchCore(s, sIndex, p, pIndex + 2);

    2.2 如果当前的pIndex元素参与匹配, 可能1, 2,3,4... 多次。则当前主串字符在下一次递归就无需再匹配了, 因为当前的pIndex元素已经匹配了。如果是只匹配一次, 当前的模式串字符和下一个'*'号在下一次递归可以不匹配了, 但如果是2, 3, 4...等多次, 那么还是要在下一次递归继续使用。综合来讲, 可以写为matchCore(s, sIndex + 1, p, pIndex), 因为即使下一个'*'号只代表当前字符串出现1次, 把这个'*'号保留到下一次递归当中, 下一次递归如果不需要此字符, '*'号是可以代表当前当前字符出现0次的。所以将'*'代表前一个字符出现1, 2,3..等多次写成matchCore(s, sIndex + 1, p, pIndex)也是没有问题的。

  3. 前面1,2两点是递归的具体逻辑。而递归出口就比较容易想到了。

    3.1 如果主串和模式串刚好全部匹配完, 自然返回true

    3.2 如果模式串已经匹配完, 主串还没有匹配完, 那么返回false。

    3.3 如果是主串已经全部匹配完, 模式串还没有匹配完, 那么这个时候是否能直接返回false呢?答案是不可以。反例就是:主串"", 模式串a*

所以即找到了递归的关系(前面第1, 2点), 也找到了递归出口(前面第3点), 下面的代码自然就水到渠成了。

public boolean isMatch(String s, String p) {
    if (s == null || p == null) {
    }
    return matchCore(s, 0, p, 0);
    
}

public boolean matchCore(String s, int sIndex, String p, int pIndex) {
    if (sIndex == s.length() && pIndex == p.length()) {
        return true;
    }
    // 模式串全部匹配完就会立即判断是否能够匹配
    if ((sIndex < s.length() && pIndex == p.length())) {
        return false;
    }

    // 模式串的下一个不是 * , 或者模式串已经匹配到最后一个字符 的情况
    if (pIndex == p.length() - 1 || p.charAt(pIndex+1) != '*') {
        if (sIndex < s.length() && // 确保主串指针不能越界, 因为即使主串已经全部匹配完, 如果模式串还没有匹配, 还是要继续匹配的
            (p.charAt(pIndex) == s.charAt(sIndex) || p.charAt(pIndex) == '.')) {
            return matchCore(s, sIndex + 1, p, pIndex + 1);
        }
    } else {
        // 模式串下一个是 * 的情况

        // 当前模式串的字符, 能够匹配当前字符串字符
        if (sIndex < s.length() && (p.charAt(pIndex) == s.charAt(sIndex) || p.charAt(pIndex) == '.')) {
            // 可以选择 * 匹配当前字符串字符1次或多次, 当前模式串字符还要继续匹配后面的字符串
            return matchCore(s, sIndex + 1, p, pIndex) ||
                    // * 匹配当前字符串字符 0 次
                    matchCore(s, sIndex, p, pIndex + 2);
        } else {
            // 当前模式串字符 无法匹配 当前字符串字符 那么* 只能出现代表前面字符 0 次
            return matchCore(s, sIndex, p, pIndex + 2);
        }
    }
    return false;
}

方法二: 动态规划

既然是动态规划, 我首先给一个二维表格boolean[][] match = new boolean[s.length() + 1][p.length() + 1];其中match[i][j]代表从 主串i以及左边的字符串 与 模式串里j以及左边的字符串 能够匹配上。所以动态规划的递归关系就分为以下两种情况

  1. 如果当前字符不是'*', 那么当前match值取决于 (1) 当前的主串和模式串的字符(单个) 是否能够匹配; (2) 如果能够匹配了, 还要看他们前面的那些字符是否能够匹配上, 使用代码语言表达为

    if (p.charAt(j-1) != '*') {
        match[i][j] = matches(s, i, p, j) && match[i-1][j-1];
    }
    

    其中判断某两个字符是否能够匹配的方法如下 , 注意, 在进行遍历的时候, 第一个字符的下标是从1开始里, 这里要相应的处理。

    public boolean matches(String s, int i, String  p, int j) {
        if (i == 0) {
            return false;
        }
        return s.charAt(i-1) == p.charAt(j-1) || p.charAt(j-1) == '.';
    }
    
  2. 如果当前字符是'*'。这种情况就要分两种情况讨论了。
    2.1 当前'*'字符的前一个字符出现次数为0, 那么当前的match[i][j]值, 完全等价于match[i][j-2], 这个应该很容易理解
    2.2 当前'*'字符的前一个字符出现次数不为0, 出现1, 2,3....等多次, 那么当前的match[i][j]值即取决于:(1) 当前主串的s[i]的值与模式串*号前一个值p[j-1]的值是否能够匹配; (2) 主串前面的值 与 模式串前面的值(包含当前的'*') 是否能够匹配。(至于为什么即使*表示前一个字符只出现1次, 比较前面的字符串 也要包含在内 ? 原因和第一种递归里的方法一样, 在前一次匹配当中, 这里的*同样是可以代表前一个字符出现次数为0的);

    这两种情况用代码语言表达为:

    match[i][j] = match[i][j-2] || // * 的前一个字符匹配0次
                      (matches(s, i, p, j-1) && match[i-1][j]); // * 的前一个字符匹配1次或者多次
    
  3. 动态规划的初始边界。

    3.1 match[0][0] = true, 表示当主串和模式串都没有字符时, 为true。

    3.2 循环遍历起始指针如下, 主串是从下标0开始的, 原因是即使主串没有字符, 模式串有字符, 比如主串:"", 模式串"a*", 这个时候match[0][1]和match[0][2] 都是true, 也是需要进行计算的。

    for(int i = 0; i <= s.length(); i++) {
        for (int j = 1; j <= p.length(); j++) {
            // TODO 动态规划递推关系
        }
    }
    

依据以上的递推关系和起始边界, 动态规则算法的代码如下:

public boolean isMatch(String s, String p) {
    boolean[][] match = new boolean[s.length() + 1][p.length() + 1];
    match[0][0] = true; 
    for(int i = 0; i <= s.length(); i++) {
        for (int j = 1; j <= p.length(); j++) {
            if (p.charAt(j-1) != '*') {
                match[i][j] = matches(s, i, p, j) && match[i-1][j-1];
            } else {
                // 当前字符为 * 
                match[i][j] = match[i][j-2] || // * 的前一个字符匹配0次
                                (matches(s, i, p, j-1) && match[i-1][j]); // * 的前一个字符匹配1次或者多次
            }
        }
    }
    return match[s.length()][p.length()];
}

public boolean matches(String s, int i, String  p, int j) {
    if (i == 0) {
        return false;
    }
    // 匹配的条件: (1) 主串和模式串字符相等 (2) 模式串字符为'.', 可以匹配任意字符
    return s.charAt(i-1) == p.charAt(j-1) || p.charAt(j-1) == '.';
}