力扣hot100之10:正则表达式匹配
力扣hot100之10:正则表达式匹配

LeetCode 10:正则表达式匹配(Regular Expression Matching)题解

Regular Expression Matching 是 LeetCode 上一道非常经典的困难题。

这道题要求我们实现一个简单版的正则表达式匹配函数,用来判断字符串 s 是否能够被模式串 p 完整匹配。

这里的模式串只包含两种特殊字符:

  • .:可以匹配任意单个字符

  • *:表示它前面的那个字符可以出现 0 次或多次

注意,这道题要求的是 整个字符串完全匹配,不是子串匹配。

比如:

  • "aa""a" 不匹配

  • "aa""a*" 匹配

  • "ab"".*" 匹配

这道题如果直接暴力递归去写,会出现大量重复计算,因此本文采用的解法是:

递归 + 记忆化搜索(Memoization)

这种写法思路清晰,而且非常贴合题目本身的递归结构,是这道题最经典的写法之一。


题目示例

示例 1

输入:s = "aa", p = "a"
输出:false

因为模式串 "a" 只能匹配一个字符,而字符串 "aa" 有两个字符,所以不能完整匹配。

示例 2

输入:s = "aa", p = "a*"
输出:true

因为 * 表示前面的字符 'a' 可以重复出现多次,所以 "a*" 可以匹配 "aa"

示例 3

输入:s = "ab", p = ".*"
输出:true

因为 . 可以匹配任意字符,* 又表示前面的 . 可以重复多次,所以 ".*" 几乎可以匹配任意字符串。


解题思路

我们可以定义一个函数:

dp(i, j)

表示:

字符串 s 从下标 i 开始的部分,是否能够被模式串 p 从下标 j 开始的部分匹配。

也就是说:

  • dp(0, 0) 就是整道题的答案

  • 每次递归都只考虑当前这两个位置之后的匹配情况

1. 递归终止条件

如果 j == p.length(),说明模式串已经走完了。

这时候只有一种情况算匹配成功:

i == s.length()

也就是字符串也恰好匹配完了。

2. 先判断当前字符是否匹配

当前字符匹配的条件是:

  • i 没有越界

  • 并且 p[j] == s[i]

  • 或者 p[j] == '.'

也就是:

first_match = (i < s.length()) && (p.charAt(j) == s.charAt(i) || p.charAt(j) == '.')

3. 如果下一个字符是 *

这是本题最核心的地方。

假设当前模式是:

x*

那么它有两种处理方式:

情况一:匹配 0 次

也就是直接跳过这两个字符:

dp(i, j + 2)

情况二:匹配 1 次或多次

前提是当前字符能匹配,也就是 first_match == true

如果能匹配,那么就让字符串向后走一位,而模式串仍然停在当前 j,因为 x* 还可以继续匹配后面的字符:

dp(i + 1, j)

所以状态转移就是:

dp(i, j) = dp(i, j + 2) || (first_match && dp(i + 1, j))

4. 如果下一个字符不是 *

那就只能老老实实地让当前字符匹配成功,然后一起往后走:

dp(i, j) = first_match && dp(i + 1, j + 1)

Java 代码(带注释)

class Solution {
    Boolean[][] memo;

    public boolean isMatch(String s, String p) {
        // memo[i][j] 表示 dp(i, j) 的计算结果
        // 由于结果只有 true / false 两种,但还需要区分“没算过”
        // 所以这里用 Boolean 而不是 boolean
        memo = new Boolean[s.length() + 1][p.length() + 1];
        return dp(0, 0, s, p);
    }

    public boolean dp(int i, int j, String s, String p) {
        // 如果这个状态已经计算过,直接返回
        if (memo[i][j] != null) return memo[i][j];

        boolean ans;

        // 如果模式串已经匹配完,只有当字符串也匹配完才算成功
        if (j == p.length()) {
            ans = (i == s.length());
        } else {
            // 判断当前字符是否可以匹配
            boolean first_match = (
                i < s.length() &&
                (p.charAt(j) == s.charAt(i) || p.charAt(j) == '.')
            );

            // 如果下一个字符是 '*'
            if (j + 1 < p.length() && p.charAt(j + 1) == '*') {
                // 两种情况:
                // 1. 跳过当前 "x*",表示匹配 0 次
                // 2. 如果当前字符匹配成功,则让 s 往后走一位,p 仍停在 j
                ans = dp(i, j + 2, s, p) || (first_match && dp(i + 1, j, s, p));
            } else {
                // 普通情况:当前字符匹配成功,然后双方都往后移动
                ans = first_match && dp(i + 1, j + 1, s, p);
            }
        }

        // 记忆化
        memo[i][j] = ans;
        return ans;
    }
}

代码拆解

这段代码整体不长,但如果第一次接触这道题,还是很容易在细节上绕进去。下面分步骤来看。


1. 为什么 memo 要开成 s.length() + 1p.length() + 1

memo = new Boolean[s.length() + 1][p.length() + 1];

这里多开一位,是因为递归过程中,ij 都有可能走到字符串末尾,也就是:

  • i == s.length()

  • j == p.length()

这些位置同样是合法状态,所以数组必须多开一格。


2. dp(i, j) 到底表示什么

这是整道题最关键的定义。

它并不是表示“当前字符是否匹配”,而是表示:

s[i...]p[j...] 开始,后面的整段是否匹配。

只要把这个定义想明白,后面的递归就很自然了。

例如:

s = "aab"
p = "c*a*b"

当我们进入 dp(0, 0) 时,其实就是在问:

"aab" 能不能被 "c*a*b" 匹配?

后面不断递归,就是不断缩小问题规模。


3. first_match 的作用

boolean first_match = (
    i < s.length() &&
    (p.charAt(j) == s.charAt(i) || p.charAt(j) == '.')
);

这里表示当前这一个字符能不能对上。

注意顺序不能乱,必须先判断:

i < s.length()

否则当字符串已经走到末尾时,再去访问 s.charAt(i) 就会越界。


4. 为什么遇到 * 要分成两条路

ans = dp(i, j + 2, s, p) || (first_match && dp(i + 1, j, s, p));

这是最核心的一行。

因为 * 的含义就是“前面的字符可以出现 0 次或多次”,所以天然就有两种选择:

第一条路:这个字符一次都不出现

比如:

s = "ab"
p = "c*ab"

这里 c* 完全可以不参与匹配,直接跳过去。

所以状态就是:

dp(i, j + 2)

第二条路:当前字符至少匹配一次

比如:

s = "aaa"
p = "a*"

只要当前字符能匹配,那么我们就可以消耗掉字符串中的一个字符,但模式串位置不动,因为后面还可能继续匹配更多个 'a'

所以状态就是:

first_match && dp(i + 1, j)

这其实就是“选或不选”的思想。


5. 普通情况为什么是同时后移一位

ans = first_match && dp(i + 1, j + 1, s, p);

如果后面不是 *,那就没有什么特殊处理了。

当前字符匹配成功之后:

  • 字符串往后走一位

  • 模式串也往后走一位

这就是最普通的递归匹配过程。


6. 为什么一定要加记忆化

如果不加 memo,这道题会出现非常多的重复子问题。

例如某些位置:

dp(i, j)

可能会从不同递归路径中反复进入。

如果每次都重新算,时间复杂度会非常高,甚至会超时。

加入记忆化之后,每个状态最多只会计算一次,因此效率会提升很多。


执行过程简单演示

以这组数据为例:

s = "aa"
p = "a*"

一开始进入:

dp(0, 0)

此时模式串当前位置是 'a',并且下一个字符是 '*'

所以分成两种情况:

情况 1:匹配 0 次

dp(0, 2)

此时模式串结束,但字符串还没结束,所以返回 false

情况 2:匹配至少 1 次

因为当前字符 'a' 能匹配,所以进入:

dp(1, 0)

接下来还能继续匹配,最后会进入:

dp(2, 0)
dp(2, 2)

最终 dp(2, 2)true,因此整道题答案就是 true


复杂度分析

时间复杂度

O(m * n)

其中:

  • m 是字符串 s 的长度

  • n 是模式串 p 的长度

因为记忆化搜索会保证每个状态 (i, j) 只计算一次,而总状态数最多就是 (m + 1) * (n + 1)

空间复杂度

O(m * n)

主要来自记忆化数组 memo,以及递归调用栈带来的额外开销。


这道题的难点

这道题真正难的地方,不在代码长短,而在于你能不能把状态定义清楚。

它有两个最容易卡住的点:

  1. dp(i, j) 的含义到底是什么

  2. 遇到 * 时为什么要拆成两种情况

只要把这两个问题理解透,代码其实并不复杂。

而且这道题非常适合拿来训练递归思维,因为它天然就是一个“把大问题拆成更小子问题”的过程。


总结

这道题的核心思想其实可以概括成一句话:

从当前位置出发,讨论当前字符能不能匹配;如果遇到 *,就考虑“跳过”或者“继续使用”两种选择。

所以整道题的解题步骤就是:

  1. 定义 dp(i, j) 表示后缀匹配问题

  2. 判断当前字符是否匹配

  3. 如果下一个字符是 *,分两种情况递归

  4. 如果不是 *,就正常向后推进

  5. memo 记录已经算过的状态,避免重复计算

这也是这道题最经典、最推荐掌握的一种写法。

如果你刚开始接触动态规划或者记忆化搜索,这道题其实非常值得反复理解一遍。 因为它不仅仅是在考“正则表达式”,更是在考你对 状态设计、递归转移、边界处理 的掌握程度。

把这道题真正吃透之后,再去看字符串类的 DP 题,思路会顺很多。

如果对您有帮助的话,能否支持一下博主?
暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇