【LeetCode选讲·第四期】「正则表达式匹配」(动态规划DP)

304 阅读7分钟

T10 正则表达式匹配

题目链接:leetcode.cn/problems/re…

题意梳理

对于我们传入的匹配表达式p,其中可能含有3种字符。我们的任务就是要检测字符串s与匹配表达式p是否完全对应匹配:

  • 普通字符:需要和s中对应位置的普通字符完全匹配;
  • "." 字符:能够与s中对应位置的任意字符匹配;
  • "*" 字符:不能单独出现。其前方出现的普通字符或 "." 字符需要和s中对应位置的字符匹配n次 (n≥0). 且由题意,p的首个字符不可能是 "*" 字符

动态规划

本题我们采用动态规划(dp)进行求解。

状态定义

我们定义dp[i][j],用于记录字符串si位是否能与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] = true
  • s[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] = truedp[i-1][j] = true → … → dp[i-(n-1)][j] = truedp[i-n][j] = truedp[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]匹配,即sp不匹配。此时我们可以提前返回结果,以避免后续不必要的遍历:

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的话,欢迎向您的同事和朋友推荐。如果您有技术方面的问题希望与我们探讨,欢迎直接与我联系。您的支持是我们最大的动力!

QQ图片20220701165008.png