LeetCode无数种解法的hard问题,10-正则表达式匹配

作者 | 梁唐

出品 | 公众号:Coder梁(ID:Coder_LT)

大家好,我是梁唐。

今天和大家继续来聊聊LeetCode,我们今天看的是LeetCode第10题——正则表达式匹配。

我们这是一个系列已经更完了1-5题,跳过了6-9题,直接来到第10题。因为6-9相对来说难度不算大,也没有新颖的解法,大家可以自行练习,就不额外写文细说了。

这LeetCode第10题非常非常经典,绝对是值得大家反复练习的一道题。好了,我们废话不多说,直接来看题吧。

题意

给你一个字符串 s 和一个字符规律 p,请你来实现一个支持 '.' 和 '*' 的正则表达式匹配。

  • '.' 匹配任意单个字符
  • '*' 匹配零个或多个前面的那一个元素

所谓匹配,是要涵盖 整个 字符串 s的,而不是部分字符串。

解法

首先还是老规矩,我们拿到题目审完题之后先看数据范围。数据范围是很强的提示信息,既体现了题目的难度,有的时候也能解读出很多信息。

本题的范围很小,两个串最大长度才30。必须要说的是,这是出题方手下留情,正因此很多解法都可以通过。如果稍微提高一下范围,估计通过率还要进一步降低。

对于算法题来说,一般无非正推和逆推两种解法。所谓正推,即顺着题意进行思考,对中途遇到的问题,主要是性能问题进行优化和解决,从而解决问题。逆推则是相反,我们不顺着题意推导,而是另辟蹊径,将题意进行转化,转化成另外一道更容易解决的问题,并且还要保证要能满足原题的条件,不会构成冲突。

这道题非常经典,因为它正推和逆推都可行,我们先从相对比较简单的正推开始。

正向推导

整个题目是经典的字符串匹配问题,唯一的难点在于*的出现。因为它可以匹配任意多个同样的字符,在我们进行匹配的过程当中,遇到*的时候,无法确定它究竟怎样匹配才能达到最优。所以我们可以很自然地可以想到,当我们遇到*时可以进行搜索,也就是枚举所有可能匹配的情况。只要有一种情况能够完成匹配,那么就说明有解。

这个思路是很容易想到的,难点在于如何用代码来表达。

由于我们判断匹配是在两个字符串s和p中间的,所以每一种匹配的情况都会对应s和p中的两个位置。我们可以用一个pair对<i, j>来记录匹配的状态,<i, j>表示字符串s[0:i]的子串和字符串p[0:j]的子串匹配。这样我们只需要维护所有能够匹配上的<i, j>,如果能够匹配到两个字符串的末尾,也就是说<n, m>出现在合法的状态当中,就说明有解,这里的n表示s的长度,m表示p串的长度。

这种用两个数字表示状态的方法是字符串匹配问题当中的常用技巧,我们可以顺着这个思路,要做的就是使用一个数据结构维护所有合法(匹配)的状态,通过当前的合法状态寻找新的合法状态,只要能找到最终要求的状态,那么就说明有解。如果找遍了所有合法的状态也没有找到最终状态,就说明无解。

如果大家学过宽度优先搜索,那么可以很自然地想到可以使用队列来记录所有的状态。那么顺水推舟,不难写出代码。

在这段代码当中我们使用了一个小技巧,我们将字符串s和p向右移动了一位,有效字符从下标1开始。这是因为我们用下标0表示空串的状态,如果下标从0开始,则空串的表达不太方便,代码书写会比较繁琐。

完整代码如下:

class Solution {
public:
    bool isMatch(string s, string p) {
        int n = s.size(), m = p.size();
        s = ' ' + s;
        p = ' ' + p;
        queue<pair<int, int>> que;
        // s和p都是空串可以匹配上,所以是合法的
        que.push(make_pair(0, 0));

        while(!que.empty()) {
            pair<int, int> head = que.front();
            que.pop();
            int i = head.first, j = head.second;
            // 如果s和全部和p串全部都已经完成了匹配,返回true
            if (i == n && j == m) {
                return true;
            }
   // 跳过非法长度
            if (i > n || j > m) continue;
            // 如果s[i+1]和p[j+1]匹配
            if (i < n && j < m && (s[i+1]==p[j+1] || p[j+1] == '.')) {
                que.push(make_pair(i+1, j+1));
            }
            // 如果p[j+2]=*,需要考虑p[j+1]位置出现0次即被删除的情况
            if (j+2 <= m &&p[j+2] == '*') {
                que.push(make_pair(i, j+2));
            }
            if (p[j] == '*') {
                // 如果p[j]=*,s[i+1]能够和p[j-1]匹配,那么<i+1, j>也是合法状态
                if (i < n && (j > 0 && s[i+1] == p[j-1] || p[j-1] == '.')) {
                    que.push(make_pair(i+1, j));
                }
            }
        }
        return false;
    }
};

写完了代码会发现这其实就是一个典型的宽度优先搜索问题,我们搜索了所有合法的状态,如果最终状态<n, m>被搜索到,那么说明有解,如果没有,说明无解。

这段代码虽然能够AC,但是仔细分析会发现一个问题。问题在于我们枚举状态的时候会有重复,一个中间状态可能会反复被枚举到,从而出现很多次。比如<2, 2>可以推导到<3, 3>,但<2, 3>也可能推导到<3, 3>,<3, 1>也可能推导到<3, 3>。极端情况下,状态会有很多的重复,从而导致性能损耗。

对于这个问题,其实很好解决,我们有一个非常简单的办法可以解决这个问题。就是把所有出现过的状态记录下来,每次有新的状态时就去检查一下是否已经被记录了,从而之前已经记录过的状态。

在C++当中我们可以使用set或者是map做到这一点,代码和刚才相差不大,只不过在往队列插入状态之前,增加了一个判断是否出现的逻辑。这种搜索结合记忆的做法就叫做记忆化搜索,看起来术语很高大上,但其实逻辑并不难。

class Solution {
public:
    bool isMatch(string s, string p) {
        int n = s.size(), m = p.size();
        s = ' ' + s;
        p = ' ' + p;
        queue<pair<int, int>> que;
        que.push(make_pair(0, 0));
        
        set<pair<int,int>> st;

        while(!que.empty()) {
            pair<int, int> head = que.front();
            que.pop();
            int i = head.first, j = head.second;
            if (i == n && j == m) {
                return true;
            }
            if (i > n || j > m) continue;
            pair<int, int> cur;
            if (i < n && j < m && (s[i+1] == p[j+1] || p[j+1] == '.')) {
                cur = make_pair(i+1, j+1);
                // 如果没有出现在st里才会被添加
                if (st.count(cur) == 0) {
                    que.push(cur);
                    st.insert(cur);
                }
            }
            if (j+2 <= m && p[j+2] == '*') {
                cur = make_pair(i, j+2);
                if (st.count(cur) == 0) {
                    que.push(cur);
                    st.insert(cur);
                }
            }
            if (p[j] == '*') {
                if (i < n && (j > 0 && s[i+1] == p[j-1] || p[j-1] == '.')) {
                    cur = make_pair(i+1, j);
                    if (st.count(cur) > 0) continue;
                    que.push(make_pair(i+1, j));
                    st.insert(cur);
                }
            }
        }
        return false;
    }
};

逆向推导

看完了正推的解法, 我们再来想想逆推吧。

这道题逆推的思路还是比较明显的,在正推中,我们已知<i, j>合法,探索<i+1, j+1>, <i, j+2>, <i+1, j>合法的可能性。逆推就是反过来,我们要求<i, j>,探索它成立时需要满足的条件。比如,假设<i-1, j-1>合法且s[i]和p[j]匹配,那么显然<i, j>也合法。但由于我们是逆推的,所以我们没办法直接知道<i-1, j-1>是否合法,所以我们要继续推导下去。

说到这里,估计有些小伙伴已经反应过来了,这样的推导方式不就是递归吗?

其实整个的思路和代码的写法和上面的做法几乎是一样的,不过一个是从合法的<0, 0>开始,一个是从要求的<n, m>开始而已。

class Solution {
public:
    // 这里要注意,字符串传参会有开销,因此要用引用
    bool dfs(string &s, string &p, int n, int m) {
        // <0, 0>是合法状态,因此return true
        if (n == 0 && m == 0) return true;
        if (n < 0 || m < 0) return false;
  // 如果s[n]=p[m]递归求<n-1, m-1>
        if (n > 0 && (s[n] == p[m] || p[m] == '.')) {
            return dfs(s, p, n-1, m-1);
        }
        // p[m]=*的情况
        if (m > 1 && p[m] == '*') {
            bool match = p[m-1] == s[n] || p[m-1] == '.';
            return dfs(s, p, n, m-2) || (match && dfs(s, p, n-1, m));
        }
        return false;
    }

    bool isMatch(string s, string p) {
        int n = s.size(), m = p.size();
        s = ' ' + s;
        p = ' ' + p;
        return dfs(s, p, n, m);
    }
};

动态规划

我们搞定了逆向推导之后,其实还没有结束,在递归的做法当中,我们求每一个状态都是通过递归的方式去获取的。除了递归之外,还有没有其他办法?

其实是有的,也不难想到,我们完全可以通过一个二维数组来记录。dp[i][j]表示s[0:i]和p[0:j]能否构成匹配,由于我们已知dp[0][0]=true,所以我们可以按照顺序遍历状态进行推导即可。从状态的枚举上,我们是正推的思路,从最初合法的状态<0, 0>向外枚举,而对于每一个要求的状态而言,我们是通过逆推的方式去计算它的结果。

为什么大家都说动态规划的算法很难,难点其实就在这里,它是搜索算法的集大成者,也是正反两种思路的集成,因此对于思维的要求很高。但往往编码都比较简单,想出算法之后很容易实现。

我们来看代码:

bool isMatch(string s, string p) {
    int n = s.size(), m = p.size();
    s = ' ' + s;
    p = ' ' + p;
    bool dp[n+5][m+5];
    memset(dp, 0, sizeof dp);
    dp[0][0] = true;
    for (int i = 0; i <= n; i++) {
        for (int j = 1; j <=m; j++) {
            // 判断s[i]和p[j]是否匹配
            bool matched = s[i] == p[j] || p[j] == '.';
            if (matched && i > 0) dp[i][j] = dp[i-1][j-1];
            // 判断p[j]是否为*
            if (j > 1 && p[j] == '*') {
                // 判断p[j-1]是否和s[i]匹配
                matched = p[j-1] == s[i] || p[j-1] == '.';
                // dp[i][j-2]表示p[j-1]出现0次,dp[i-1][j]表示s[i]通过*重复完成匹配
                dp[i][j] = dp[i][j-2] || (i > 0 && matched && dp[i-1][j]);
            }
        }
    }
    return dp[n][m];
}

到这里,把动态规划的算法搞定这道题才算是完整写完了。

不知道大家感受如何,有没有感受到这道题的魅力呢?如果只是为了通过这道题的话,最初的代码就可以达成,但我们一步一步推导思考下来,有了更多的解法, 对于这道题的理解也会更加深刻。大部分时候我们对于代码的理解和掌握,正是在这样精益求精的追求中达成的。通过题目不是目的,在做题的过程当中学到东西提升自己才是核心。

关于这题就先聊到这里,感谢大家的阅读。

原文地址:https://cloud.tencent.com/developer/article/2117669

版权声明:本文内容由互联网用户自发贡献,该文观点与技术仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 dio@foxmail.com 举报,一经查实,本站将立刻删除。

相关推荐


学习编程是顺着互联网的发展潮流,是一件好事。新手如何学习编程?其实不难,不过在学习编程之前你得先了解你的目的是什么?这个很重要,因为目的决定你的发展方向、决定你的发展速度。
IT行业是什么工作做什么?IT行业的工作有:产品策划类、页面设计类、前端与移动、开发与测试、营销推广类、数据运营类、运营维护类、游戏相关类等,根据不同的分类下面有细分了不同的岗位。
女生学Java好就业吗?女生适合学Java编程吗?目前有不少女生学习Java开发,但要结合自身的情况,先了解自己适不适合去学习Java,不要盲目的选择不适合自己的Java培训班进行学习。只要肯下功夫钻研,多看、多想、多练
Can’t connect to local MySQL server through socket \'/var/lib/mysql/mysql.sock问题 1.进入mysql路径
oracle基本命令 一、登录操作 1.管理员登录 # 管理员登录 sqlplus / as sysdba 2.普通用户登录
一、背景 因为项目中需要通北京网络,所以需要连vpn,但是服务器有时候会断掉,所以写个shell脚本每五分钟去判断是否连接,于是就有下面的shell脚本。
BETWEEN 操作符选取介于两个值之间的数据范围内的值。这些值可以是数值、文本或者日期。
假如你已经使用过苹果开发者中心上架app,你肯定知道在苹果开发者中心的web界面,无法直接提交ipa文件,而是需要使用第三方工具,将ipa文件上传到构建版本,开...
下面的 SQL 语句指定了两个别名,一个是 name 列的别名,一个是 country 列的别名。**提示:**如果列名称包含空格,要求使用双引号或方括号:
在使用H5混合开发的app打包后,需要将ipa文件上传到appstore进行发布,就需要去苹果开发者中心进行发布。​
+----+--------------+---------------------------+-------+---------+
数组的声明并不是声明一个个单独的变量,比如 number0、number1、...、number99,而是声明一个数组变量,比如 numbers,然后使用 nu...
第一步:到appuploader官网下载辅助工具和iCloud驱动,使用前面创建的AppID登录。
如需删除表中的列,请使用下面的语法(请注意,某些数据库系统不允许这种在数据库表中删除列的方式):
前不久在制作win11pe,制作了一版,1.26GB,太大了,不满意,想再裁剪下,发现这次dism mount正常,commit或discard巨慢,以前都很快...
赛门铁克各个版本概览:https://knowledge.broadcom.com/external/article?legacyId=tech163829
实测Python 3.6.6用pip 21.3.1,再高就报错了,Python 3.10.7用pip 22.3.1是可以的
Broadcom Corporation (博通公司,股票代号AVGO)是全球领先的有线和无线通信半导体公司。其产品实现向家庭、 办公室和移动环境以及在这些环境...
发现个问题,server2016上安装了c4d这些版本,低版本的正常显示窗格,但红色圈出的高版本c4d打开后不显示窗格,
TAT:https://cloud.tencent.com/document/product/1340