题目
题目描述详见:10. 正则表达式匹配 - 力扣(LeetCode)
题解
之前的题目,或许我不知道快速的、巧妙的解法,但我可以用笨方法,或者说“朴素”的方法是手搓出来。
但这道题我甚至是思路全无,无从下笔,所以特地来记录一下题解思路。
正如本题题目 ”正则表达式匹配” 所说,这是一个逐步匹配的过程。
我们记字符串 $s$ ,字符规律 $p$ 。对于字符规律 $p$ ,它包含以下几类字符:
-
普通字符
-
'.':匹配任意单个字符 -
'*':匹配零个或多个前面的那一个元素
事实上,我们可以进一步分为两类:(1)字符(2)星号。
我们每次从字符串 $p$ 取出一个 字符 或 字符+星号 的组合:
-
对于 $p$ 中的一个字符,它只能在 $s$ 中匹配一个字符,匹配方法具有唯一性;
-
对于 p 中字符+星号的组合,它可以在 s 中匹配任意自然数个字符,不具有唯一性。
因此我们可以考虑使用动态规划,对匹配的方案进行枚举。
唯一性?
关于这里的“唯一性”,指的是匹配方式是否确定。
对于字符+星号的组合,由于星号可以匹配0个或多个元素,因此单纯看这个组合是无法确定匹配方式的。
下面是一个例子,单个字符的匹配是唯一确定的,字符+组合则相反:
| 模式 | 面对字符 a |
匹配方式 | 是否唯一 |
|---|---|---|---|
a |
a |
只能匹配这 1 个 a,然后双双后移 |
✅ 唯一 |
. |
a |
只能匹配这 1 个 a,然后双双后移 |
✅ 唯一 |
a* |
aaa |
可以匹配 0 个、1 个、2 个、3 个 a |
❌ 不唯一 |
动态规划?
可以这样理解,因为 * 带来了分支,我们必须尝试所有的分支,才能知道是否存在一个完美的匹配方案。
下面的例子很好地说明了这个问题:
|
|
处理 p 的 a* 时,面对 s 开头的 "aaa",a* 有多种选择:
-
匹配 0 个
a→ 剩余s="aaab",p="aab" -
匹配 1 个
a→ 剩余s="aab",p="aab" -
匹配 2 个
a→ 剩余s="ab",p="aab" -
匹配 3 个
a→ 剩余s="b",p="aab"
注意,单纯看 a* 而不考虑 s 的其他部分,这里的匹配方式都是合法的。
但题目希望找到的是覆盖整个输入字符串的匹配,因此这里的匹配方法只有1个是能够完全匹配的。
那么要找出合适的匹配,显然的想法就是需要枚举所有匹配方案。又因为枚举过程中存在大量重叠子问题,所以我们考虑使用动态规划。
根据题解的描述分析,a* 应该看成一个整体。
也就是说,a* 这个整体可以啥都不匹配,也就是前面说的匹配 0 个字符,而不是说前面的 a 必须要单独匹配一个字符。
我们用 $f[i][j]$ 表示 $s$ 的前 $i$ 个字符与 $p$ 中的前 $j$ 个字符是否能够匹配。
在进行状态转移时,我们考虑 $p$ 的第 $j$ 个字符的匹配情况:
-
如果 $p$ 的第 $j$ 个字符是一个小写字母,那么我们必须在 $s$ 中匹配一个相同的小写字母。
转移方程如下:
$$ f[i][j] = \begin{cases} f[i-1][j-1], & s[i] = p[j] \\ \text{false}, & s[i] \neq p[j] \end{cases} $$这里解释一下(后面讨论的前提是:$p$ 的第 $j$ 个字符是一个小写字母):
如果 $s$ 的第 $i$ 个字符和 $p$ 的第 $j$ 个字符不相同,那么无法匹配;否则可以匹配两个字符串的最后一个字符,完整的匹配结果取决于两个字符串前面的部分。
-
如果 $p$ 的第 $j$ 个字符是
*,那么就表示我们可以对 $p$ 的第 $j-1$ 个字符串匹配任意自然数次。在匹配 0 次的情况下,我们有
$$ f[i][j] = f[i][j-2] $$也就是没有匹配 $s$ 中的字符。
同理,在匹配 1,2,3,… 次的情况下,类似的有:
$$ \begin{align*} f[i][j] &= f[i-1][j-2], &&\text{if } s[i] = p[j-1] \\ f[i][j] &= f[i-2][j-2], &&\text{if } s[i-1] = s[i] = p[j-1] \\ f[i][j] &= f[i-3][j-2], &&\text{if } s[i-2] = s[i-1] = s[i] = p[j-1] \\ \end{align*}\\ \cdots $$显然,理论上我们可以一直这样写下去,这就又需要枚举这个组合到底匹配了 s 中的几个字符,代码编写麻烦的同时,会影响时间复杂度。
这里,我们要换个角度思考问题:字母 + 星号的组合在匹配过程中,本质只有两个情况:
-
匹配 $s$ 末尾的一个字符,将该字符扔掉,而该组合还可以继续匹配;
-
不匹配字符,将该组合扔掉,不再进行匹配。
表达成转移方程如下:
$$ f[i][j] = \begin{cases} f[i-1][j] \text{ or } f[i][j-2], & s[i] = p[j-1] \\ f[i][j-2], & s[i] \neq p[j-1] \end{cases} $$状态转移方程说明官方题解称这个状态转移方程是 “很精巧的”。
不幸的是,它也是很难理解的。这里展开讲讲:
上面提到了匹配过程本质只有两个情况:
第一种情况,
x*匹配至少 1 个字符要满足这个情况,
s的最后一个字符要能够被x匹配。p[j]是*,p[j-1]也就是x,这种情况也就可以表示为s[i] = p[j-1]如果匹配 1 个字符,
s末尾会少一个,但x*仍然保留,因为可以继续匹配,所以有状态转移方程:1f[i][j] = f[i-1][j] # 这里是 j 而不是 j-2,因为 x* 还可以继续匹配
第二种情况,
x*匹配 0 个字符此时,直接把这个组合扔掉,也就是让
x*匹配 0 个字符。当前的匹配的结果取决于前面能不能匹配。
1f[i][j] = f[i][j-2]
下面还是用例子来说明:
假设输入
s="aaa",p="a*":-
a*先匹配s末尾的 1 个a(第 3 个a)剩余问题变成:
"aa"和"a*"能匹配吗? →f[2][2] -
a*再匹配s末尾的 1个a(第 2 个a)剩余问题变成:
"a"和"a*"能匹配吗? →f[1][2] -
a*再匹配s末尾的 1个a(第 1 个a)剩余问题变成:
""和"a*"能匹配吗? →f[0][2] -
f[0][2]:a*匹配 0 个a,跳过这个组合变成
""和""匹配 → true
我们回过头看看这个流程,匹配 3 个
a不是一次性决定的,而是每次只决定匹配 1 个,然后递归交给f[i-1][j]继续处理。如果f[i-1][j]还需要匹配,它自己会继续调用f[i-2][j],知道某一步选择“匹配 0 个”(f[i][j-2])为止。举这个例子的意义在于,说明并理解清楚,目前的思路是把 “匹配多次“ 拆成 ”先匹配1次,剩下的交给子问题递归处理“
所以前面提到的转移方程,也就是分别对应这两个情况。
但值得一提的,为什么可以匹配的时候(
s[i] = p[j-1]),匹配状态还要考虑f[i][j-2],也就是不使用x*的情况?这里需要说明,局部匹配成功不等于全局匹配成功。因为我们需要枚举所有的情况,对于
x*的匹配,并不是说可以匹配就一定要匹配。为了考虑到所有情况,我们同样可以选择不使用这个组合。这是合法的情况,也是我们必须考虑的。
待补充对于前面说明的最后,提到的特殊情况(可以匹配但不匹配),肯定有实际例子可以说明。
但现在还没有想到,后面要补充在这里。
-
-
在任意情况下,只要 $p[j]$ 是
.,那么 $p[j]$ 一定成功匹配 $s$ 中的任意一个小写字母。
综上所述,我们终于可以得出完整的状态转移方程:
$$ f[i][j] = \begin{cases} \text{if } (p[j] \neq \text{`}\ast\text{'}) = \begin{cases} f[i-1][j-1], & \text{matches}(s[i], p[j]) \\ \text{false}, & \text{otherwise} \end{cases} \\ \text{otherwise} = \begin{cases} f[i-1][j] \text{ or } f[i][j-2], & \text{if } \text{matches}(s[i], p[j-1]) \\ f[i][j-2], & \text{otherwise} \end{cases} \end{cases} $$对于 $\text{matches}(x,y)$ ,它是判断两个字符是否匹配的辅助函数:
$$ \text{matches(x,y)} = \begin{cases} \text{true}, & y = \text{`.'} \text{ or } x = y \\ \text{false}, & \text{otherwise} \end{cases} $$这里提取一个函数,来抽象会多次用到的单字符匹配过程。
这一点我在写题时候也有想到,就类似 C++ 中的重载运算符,可惜只停留在想法,没有付诸实践。这里的思路非常值得学习。
这里,动态规划的边界条件是 $f[0][0]=\text{true}$ ,即两个空字符串是可以匹配的。
最后的输出是 $f[m][n]$,其中 $m$ 和 $n$ 分别是字符串 $s$ 和 $p$ 的长度。
下面是官方题解中给出的思考题,之后有空解答一下:
在上面的状态转移方程中,如果字符串 $p$ 中包含一个「字符 + 星号」的组合(例如 a*),那么在进行状态转移时,会先将 a 进行匹配(当 $p[j]$ 为 a 时),再将 a* 作为整体进行匹配(当 $p[j]$ 为 * 时)。
然而,在题目描述中,我们必须将 a* 看成一个整体,因此将 a 进行匹配是不符合题目要求的。
看来我们进行了额外的状态转移,这样会对最终的答案产生影响吗?
代码
|
|