T10 正则表达式匹配
题意梳理
对于我们传入的匹配表达式p,其中可能含有3种字符。我们的任务就是要检测字符串s与匹配表达式p是否完全对应匹配:
- 普通字符:需要和
s中对应位置的普通字符完全匹配;- "." 字符:能够与
s中对应位置的任意字符匹配;- "*" 字符:不能单独出现。其前方出现的普通字符或 "." 字符需要和
s中对应位置的字符匹配n次 (n≥0). 且由题意,p的首个字符不可能是 "*" 字符
动态规划
本题我们采用动态规划(dp)进行求解。
状态定义
我们定义dp[i][j],用于记录字符串s的第i位是否能与p的第j位匹配。
状态转移(易点)
我们先来考虑p[j]为普通字符或 "." 字符,且p[j+1] !== '*'的情况。此时只需要检测s前面的字符是否都能与p匹配,同时p[j]能与s[i]是否能够匹配即可。
即dp[i][j] = dp[i-1][j-1] && (s[i] === p[j] || p[j] === '.')
当p[j+1] === '*'时,p[j]无法单独使用,此时需要先跳开!
状态转移(难点)
然后我们再来考虑p[j]为 "*" 的情况,这时会稍稍复杂一些,因为我们并不知道 "*" 前面的一个字符会与s中对应位置的字符匹配几次。这时我们可以先罗列前几项,找一找感觉。
当 "*" 前的字符与s中的字符匹配0次时,我们可以直接跳开p中 "*" 和前面的一位字符,只需要检测再前面的字符与s中对应位置的字符是否匹配即可。
即dp[i][j] = dp[i][j-2]
当 "*" 前的字符与s中的字符匹配1次时,我们在检测p中 "*" 前一位的字符和s中对应位置的字符匹配一次的基础上,需要检测再前面的字符与s中对应位置的字符是否匹配。
即dp[i][j] = dp[i-1][j-2] && (s[i] === p[j-1] || p[j-1] === '.')
以此类推,
当 "*" 前的字符与s中的字符匹配2次时,
有dp[i][j] = dp[i-2][j-2] && ((s[i] === p[j-1] && s[i-1] === p[j-1]) || p[j-1] === '.')
当 "*" 前的字符与s中的字符匹配3次时,
有dp[i][j] = dp[i-3][j-2] && ((s[i] === p[j-1] && s[i-1] === p[j-1]) && s[i-2] === p[j-1]) || p[j-1] === '.')
. . . . . .
归纳可知,当 "*" 前的字符与s中的字符匹配n次时,
当n = 0时,dp[i][j] = dp[i][j-2]
当n ≥ 1时,dp[i][j] = dp[i-n][j-2] && ((s[i] === p[j-1] && s[i-1] === p[j-1]) && … && s[i-n+1] === p[j-1]) || p[j-1] === '.')
不妨记此时的状态转移方程为式①。
我们发现,由于匹配次数n的不确定性,状态转移方程dp[i][j]的书写也是不确定的。一个朴素的解法是,我们从s[i]开始向前「枚举」,以求解n的数值,再生成对应的状态转移方程。我们知道,虽然这个方法可行性非常强,但TA将极大增强程序的复杂程度。
如果我们能够寻找到一个新的状态转移方程,将dp[i][j]的状态进行转移,并且在转移后能够确保求解出结果,这个问题就能得到妥善的解决。
存在这样的状态转移方程吗?
为了表述的方便,我们记(s[x] === p[j-1] && s[x+1] === p[j-1] && s[x+2] === p[j-1] && … && s[y] === p[j-1] || p[j-1] === '.')为s[x,y] 匹配 p[j-1],其中x<y。
显然,式①等价于
dp[i][j] = dp[i][j-2] || dp[i-1][j-2] && s[i] 匹配 p[j-1] || dp[i-2][j-2] && s[i-1,i] 匹配 p[j-1] || dp[i-3][j-2] && s[i-2,i] 匹配 p[j-1] || ……
(记作式②)
现在,我们直接将i = i - 1代入式②。如果你暂时不理解为什么要这么做,不要着急,先让我们尝试一下!
dp[i-1][j] = dp[i-1][j-2] || dp[i-2][j-2] && s[i-1] 匹配 p[j-1] || dp[i-3][j-2] && s[i-2,i-1] 匹配 p[j-1] || dp[i-4][j-2] && s[i-3,i-1] 匹配 p[j-1] || ……
(记作式③)
现在我们来对比一下式②和式③,便能够很容易地发现,式③中被||符号所隔的每一项都与式②中对应的项相差了s[i] 匹配 p[j-1]。那么除开式②的第一项dp[i][j-2],式②和式③整体就应当相差了s[i] 匹配 p[j-1]。
于是我们得到
dp[i][j] = dp[i][j-2] || dp[i-1][j] && s[i] 匹配 p[j-1]
即dp[i][j] = dp[i][j-2] || dp[i-1][j] && (s[i] === p[j-1] || p[j-1] === '.')
(记作式④)
我们发现dp[i][j]可以转化为一个与dp[i-1][j]有关的「递推」式子。下面我们再来思考一下:这个「递推」关系的内涵是什么?
我们知道,当p[j] = '*'时,如果s[i] 匹配 p[j-1]且匹配表达式p与整个字符串s匹配,那么可能出现如下的两种情况:
s[i]前的字符s[i-1]也与p[j-1]匹配,即dp[i-1][j] = trues[i]前的字符s[i-1]不再与p[j-1]匹配,即dp[i][j-2] = true
为了检查整个匹配表达式p与整个字符串s是否匹配,程序在检查s[i]是否匹配p[j-1]前,必须首先检查上述的两种情况是否存在一种是成立的,也就是说后者成立是前者成立的基础。
假设s[i]前还有n-1个字符与p[j-1]对应的字符匹配,那么便会有
dp[i][j] = true → dp[i-1][j] = true → … → dp[i-(n-1)][j] = true → dp[i-n][j] = true → dp[i-n][j-2] = true
我们发现,随着递推的进行,最终dp[i][j]的状态是与s[i-n,i]和p对应字符的匹配情况挂钩的,即我们对dp[i][j]实现了状态的转移,并且最终的dp[i-n][j-2]是可以直接进行求解的(此时s[i-n]与p[j-1]匹配0次,故dp[i-n][j] = dp[i-n][j-2]),式④便是我们寻找的状态转移方程!
编写代码
我们采用动态规划常用的双重循环+二维数组进行代码的实现:
function isMatch(s, p) {
const SLen = s.length;
const PLen = p.length;
/*
技巧:在字符串头部插入空格
这样可以使得遍历时真正的字符串下标从1开始,
同时可以直接设置dp[0][0],将其值在dp过程中传递下去
*/
s = ' ' + s;
p = ' ' + p;
/* 初始化二维数组 */
let dp = [];
for(let i = 0; i <= SLen; i++) {
dp.push([]);
}
dp[0][0] = true; //初始条件,因为s=""和p=""是可以匹配成功的
/* dp核心部分 */
for(let i = 0; i <= SLen; i++) { //考虑到可能存在传入s=""的情况,所以i要从0开始遍历
for(let j = 1; j <= PLen; j++) {
//如果当前字符后面跟随的是"*",说明当前字符不能单独使用,需要跳开
if (p[j + 1] === '*') {
continue;
}
//如果当前字符是普通字符或"."
else if (p[j] !== '*' && i >= 1) {
dp[i][j] = !!( dp[i - 1][j - 1] && (s[i] === p[j] || p[j] === '.') );
}
//如果当前字符是"*"
else if (p[j] === '*') {
dp[i][j] = !!( dp[i][j - 2] || ( i >= 1 && dp[i - 1][j] && ( s[i] === p[j - 1] || p[j - 1] === '.' ) ) );
}
/*
注意:由于当前的s[i]可能为后方出现的"*"字符的第0次匹配,
因此此时即便已经求解出dp[i][j]为true,也不可以退出内循环!
如此处还有疑问请重新回顾上文!
*/
}
}
/*
关于最终结果:
根据题意,s和p必须完全匹配,故s的最后一个字符与p的最后一个字符能否匹配,即为最终结果
*/
return !!(dp[SLen][PLen]);
}
代码编写过程中的注意点已通过代码注释的形式进行呈现,本文正文部分不再进行赘述!
代码优化
下面我们对代码进行性能优化。
提前返回结果
我们知道,在每一轮对p的遍历结束后,如果dp[i]中没有true,表明s[i]无法与p任何一项p[j]匹配,即s与p不匹配。此时我们可以提前返回结果,以避免后续不必要的遍历:
if (!dp[i].includes(true)) {
return false;
}
滚动数组优化
通过分析状态转移方程,我们可以发现,在求解任意dp[i][j]的过程中,用到二维数组dp中的量只有可能包括处于本行左侧的dp[i][j - 2]和上一行左侧的dp[i - 1][j]和dp[i - 1][j - 1],因此我们可以采用滚动数组的方法将二维数组降维为一维数组,减少空间开销。
最终版本代码如下:
function isMatch(s, p) {
const SLen = s.length;
const PLen = p.length;
/*
技巧:在字符串头部插入空格
这样可以使得遍历时真正的字符串下标从1开始,
同时可以直接设置dp2[0],将其值在dp过程中传递下去
*/
s = ' ' + s;
p = ' ' + p;
/* 初始化滚动数组 */
let dp = [];
let dp2 = [];
dp2[0] = true; //初始条件,因为s=""和p=""是可以匹配成功的
/* dp核心部分 */
for(let i = 0; i <= SLen; i++) { //考虑到可能存在传入s=""的情况,所以i要从0开始遍历
for(let j = 1; j <= PLen; j++) {
//如果当前字符后面跟随的是"*",说明当前字符不能单独使用,需要跳开
if (p[j + 1] === '*') {
continue;
}
//如果当前字符是普通字符或"."
else if (p[j] !== '*' && i >= 1) {
dp2[j] = !!( dp[j - 1] && (s[i] === p[j] || p[j] === '.') );
}
//如果当前字符是"*"
else if (p[j] === '*') {
dp2[j] = !!( dp2[j - 2] || ( i >= 1 && dp[j] && ( s[i] === p[j - 1] || p[j - 1] === '.' ) ) );
}
/*
注意:由于当前的s[i]可能为后方出现的"*"字符的第0次匹配,
因此此时即便已经求解出dp2[j]为true,也不可以退出内循环!
如此处还有疑问请重新回顾上文!
*/
}
//更新滚动数组
dp = dp2;
dp2 = [];
//如果dp中没有true,表明s[i]无法与p匹配,直接返回结果
if (!dp.includes(true)) {
return false;
}
}
/* 返回结果 */
return !!(dp[PLen]);
}
写在文末
我是来自在校学生编程兴趣小组江南游戏开发社的PAK向日葵,我们目前正在致力于开发自研的非营利性网页端同人游戏《植物大战僵尸:旅行》,以锻炼我们的前端应用开发能力。
我们诚挚邀请您体验我们的这款优秀作品,如果您喜欢TA的话,欢迎向您的同事和朋友推荐。如果您有技术方面的问题希望与我们探讨,欢迎直接与我联系。您的支持是我们最大的动力!