昨日签到题,题目如下
给定一个字符串 (s) 和一个字符模式 (p) ,实现一个支持 '?' 和 '*' 的通配符匹配。
'?' 可以匹配任何单个字符。
'*' 可以匹配任意字符串(包括空字符串)。
两个字符串完全匹配才算匹配成功。说明:
s 可能为空,且只包含从 a-z 的小写字母。
p 可能为空,且只包含从 a-z 的小写字母,以及字符 ? 和 *。
示例 1:输入:
s = "aa"
p = "a"
输出: false
解释: "a" 无法匹配 "aa" 整个字符串。
示例 2:输入:
s = "aa"
p = "*"
输出: true
解释: '*' 可以匹配任意字符串。
示例 3:输入:
s = "cb"
p = "?a"
输出: false
解释: '?' 可以匹配 'c', 但第二个 'a' 无法匹配 'b'。
示例 4:输入:
s = "adceb"
p = "*a*b"
输出: true
解释: 第一个 '*' 可以匹配空字符串, 第二个 '*' 可以匹配字符串 "dce".
示例 5:输入:
s = "acdcb"
p = "a*c?b"
输出: false来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/wildcard-matching
著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。
这几天的签到题都有点难,不看完题解理解一遍,就没办法写出能够提交成功的代码。
一开始不知道为什么,我的思路居然是查找 s 中是否存在能够匹配 p 的子字符串,然后写到一半就写不下去了。
重新审题后才捋清题意,首先肯定是想尝试暴力解题。解题过程中,我发现自己无法处理出现 '*' 的情况,因为我无法确定 '*' 可能匹配的长度。我尝试过这种思路:使用标记 start 记录遇到 '*' 的起始位置(好像 start 么有什么用),然后向下寻找 s 中第一个与 p[start + 1]匹配的字符之后,假设结束这个 '*' 的判定,用 end 标记结束位置,继续向下寻找匹配,如果中途匹配失败,则回到这个 end 位置,重新开始向下寻找 s 中第一个与 p[start + 1]匹配的字符。如果枚举完所有可能性还是未果,则匹配失败。
思路中问题很多,比如:如果假设匹配的过程中遇到 了新的 '*' ,那么还要进行新的假设,如果 '*' 较多,那会产生极其可怕的重复计算,而且还有产生较多的 end 标记,显然不可取。
无果,看官方题解,给出了两个解法:
嗯,又是该死的动态规划,不过这次理解完题解之后,好像对动态规划的认知提高了一点。这里不思考贪心算法,因为我看完动态规划的解法就花了太多时间,没有心情接着看下去了,看了个大概,没有捋清许多细节。
先分割状态,假设 s 长度为m,p 长度为 n,题解即检索 s 中前 m 个字符 与 p 中前 n 个字符是否匹配,当匹配到 s[m-1] 和 p[n- 1](因为在C#中集合首位为 0,所以-1),需要满足 s[m-2] 和 p[n- 2]匹配,以此类推,s[i] 和 p[j] 的结果基于 s[i - 1] 和 p[j - 1] 的结果。
另外,又有存在 '?' 和 '*'。'?' 等价于一个万能字符,与任意字符匹配,但是匹配法则还是基于前文规律,'*' 比较特殊,它可能匹配任意长度的字符串,但是,也可能匹配一个空字符串。由于 '*' 的存在,又基于前文规律,求解过程中 s 中的字符未必匹配 p 中相同下标位置,所以需要枚举 s 中字符对 p 中任意字符的匹配情况。
综上,需要枚举 s 任意位置字符对 p 中任意字符的匹配情况,每种匹配情况又会基于之前的匹配情况,所以需要建立一个 m * n 的二维 bool 数组 dp,去记录这些匹配情况。由于空字符串的存在,所以实际建立的的是 (m + 1)*(n + 1) 长度的二维数组,两个方向上的 0 下标表示 s 或者 p 为空的枚举情况。基于前文规律,数组中 dp[i][j] 需要基于 dp[i][j] 前一位,类推能知道 dp[i][j] 即表示 s 中前 i 位字符串与 p 中前 j 位字符串的匹配情况(即前文规则在数组中的表达)。
以上在官方题解中只是一笔带过,但是我捋清楚这个模型的实际作用花费了些许时间,故此写下自己理解的大致思路。
分割状态后需要开始对每个状态进行判断。
因为 s 中只存在 'a'-'z',但是 p 中含有 '?' 和 '*',所以在对 dp[i][j] 的状态判断中分三种情况(这里先不考虑空字符串,即dp的下标分别于 s 和 p 的下标一一对应):
p[j] 表达为空字符串
即 p[j] 不占位,不与 s[i] 比较,此时 dp[i][j] 从 dp[i][j - 1] 转移状态。
这里我捋了半天,一直认为此时也应该基于 dp[i-1][j-1] 转移状态。但是,在此种情况下 dp[i][j] 没有实际匹配意义(个人认为)。又由于 dp[i + 1][j + 1] 的状态转移自 dp[i][j],但由于 dp[i][j] 中 p[j] 表达空字符串,p[j-1] 与 s[i] 的匹配情况,至此我才理解为什么从 dp[i][j - 1] 转移状态。
p[j] 表达为 1 个或多个字符
由于 '*' 的万能性,实际上此时也不与 s[i] 比较,此时 dp[i][j] 从 dp[i - 1][j] 转移状态。
这里我又捋了半天,同也也是认为还是应该基于 dp[i-1][j-1] 转移状态(至少对 '*' 开始匹配的情况而言)。实际可以认为 p[j] 刚开始检索匹配的状态就是基于 dp[i-1][j-1] 转移,仅仅是刚开始的状态。
先讨论 dp[i][j] 不为 p[j] 刚开始检测匹配的情况,可以很容易理解,此时 p[j] 也需要匹配 s[i-1],所以上述表达式成立。
再讨论 dp[i][j] 为 p[j] 刚开始检测匹配的情况,此时即 p[j] 只表示一个字符,dp[i][j] 从 dp[i - 1][j] 转移状态(此时先假设表达式成立,接下来进行验证情况成立),由于 i -1 ,我们可以知道 dp[i - 1][j] 中 p[j] 表示为空字符串,所以 dp[i - 1][j] 从 dp[i - 1][j - 1] 转移状态。综上 dp[i][j] 刚开始检测匹配的时候状态转移自 dp[i - 1][j - 1]。
综上,我们已经枚举枚举出所有的状态表达式。
另外,我们还需要确定边界条件,即 dp[0][j] 和 dp[i][0] 的所有情况。dp[0][j] 中,除非 p 的前 j 个字符都为 '*',否则不可能匹配。dp[i][0] 中,p 的前 0 位始终为空字符串,所以恒不成立。
复杂度分析:
需要对 dp 中的每一种情况进行枚举,故时间复杂度为 O(MN)。由于使用了数组 dp,故空间复杂度为 O(MN)。
以下为代码:
public class Solution {
public bool IsMatch(string s, string p) {
int m = s.Length;
int n = p.Length;
bool[,] dp = new bool[m + 1,n + 1];
dp[0,0] = true;
for (int i = 0;i < n;i++)
{
if (p[i] == '*')
{
dp[0,i + 1] = true;
}
else
{
break;
}
}
for (int i = 1;i < m + 1;i++)
{
for(int j = 1;j < n + 1;j++)
{
if (p[j - 1] == '*')
{
dp[i,j] = dp[i - 1,j] || dp[i,j - 1];
}
else if (p[j - 1] == '?' || p[j-1] == s[i - 1])
{
dp[i,j] = dp[i - 1,j - 1];
}
}
}
return dp[m,n];
}
}