题目: 给定一个字符串 (s) 和一个字符模式 § ,实现一个支持 ‘?’ 和 ‘*’ 的通配符匹配。
- ‘?’ 可以匹配任何单个字符。
- ‘*’ 可以匹配任意字符串(包括空字符串)。
- 两个字符串完全匹配才算匹配成功。
说明:
- s 可能为空,且只包含从 a-z 的小写字母。
- p 可能为空,且只包含从 a-z 的小写字母,以及字符 ? 和 *。
方法一:动态规划
在给定的模式 p 中,只会有三种类型的字符出现,其中「小写字母」和「问号」的匹配是确定的,而「星号」的匹配是不确定的,因此我们需要枚举所有的匹配情况。为了减少重复枚举,我们可以使用动态规划来解决本题。
我们用 dp[i][j] 表示字符串 s 的前 i 个字符和模式 p 的前 j 个字符是否能匹配。在进行状态转移时,我们可以考虑模式 p 的第 j 个字符 p(j),与之对应的是字符串 s 中的第 i 个字符 s(i):
如果 p(j)是小写字母,那么 s(i)必须也为相同的小写字母,状态转移方程为:
- dp[i][j] = ( s (i)与 p (j)相同)∧dp[i−1][j−1]
其中 ∧ 表示逻辑与运算。也就是说,dp[i][j]为真,当且仅当 dp[i-1][j-1]为真,并且 s (i) 与 p (j) 相同。
如果 p (j)是问号,那么对 s (i)没有任何要求,状态转移方程为:
- dp[i][j]=dp[i−1][j−1]
如果 p (j)是星号,那么同样对 s (i)没有任何要求,但是星号可以匹配零或任意多个小写字母,因此状态转移方程分为两种情况,即使用或不使用这个星号:
- dp[i][j]=dp[i][j−1]∨dp[i−1][j]
其中 ∨ 表示逻辑或运算。如果我们不使用这个星号,那么就会从 dp[i][j-1] 转移而来;如果我们使用这个星号,那么就会从 dp[i-1][j]转移而来。
因为当 s 的前 i 个字符和匹配字符串的 j-1 个字符已经匹配的时候,若p的第 j 个字符为星号,则 p 的前 j 个字符和 s 的前 i 个字符仍然一定是匹配的,这时候星号相当于匹配了空串,所以不使用星号的时候只要dp[i][j-1] 是匹配的,则dp[i][j] 一定是匹配的。同理当 p 的第 j 个字符为星号,而此时 p 的前 j-1 个字符不能和 s 的前 i 个字符匹配的时候,就需要用星号来匹配 s 中的部分字符,这时候就判断dp[i-1][j]是否匹配,若匹配的话星号即可相当于匹配了 其原匹配值加上s 的第 i 个字符 ,因为星号可以匹配字符串(前面可能还匹配了 s 的其他字符),这样dp[i][j] 仍然是匹配的。
最终的状态转移方程如下:
细节
只有确定了边界条件,才能进行动态规划。在上述的状态转移方程中,由于 dp[i][j] 对应着 s 的前 i 个字符和模式 p 的前 j 个字符,因此所有的 dp[0][j] 和 dp[i][0] 都是边界条件,因为它们涉及到空字符串或者空模式的情况,这是我们在状态转移方程中没有考虑到的:
-
dp[0][0]=True,即当字符串 s 和模式 p 均为空时,匹配成功;
-
dp[i][0]=False,即空模式无法匹配非空字符串;
-
dp[0][j] 需要分情况讨论:因为星号才能匹配空字符串,所以只有当模式 p 的前 j 个字符均为星号时,dp[0][j] 才为真。
我们可以发现,dp[i][0] 的值恒为假,dp[0][j] 在 j 大于模式 p 的开头出现的星号字符个数之后,值也恒为假,而 dp[i][j] 的默认值(其它情况)也为假,因此在对动态规划的数组初始化时,我们就可以将所有的状态初始化为 False,减少状态转移的代码编写难度。
最终的答案即为 dp[m][n],其中 m 和 n 分别是字符串 s 和模式 p 的长度。需要注意的是,由于大部分语言中字符串的下标从 0 开始,因此 s (i)和 p (j)分别对应着 s[i-1]和 p[j-1]。
public boolean isMatch2(String s, String p) {
int m = s.length();
int n = p.length();
boolean[][] dp = new boolean[m + 1][n + 1];
dp[0][0] = true;
//判断是否为全星号
for (int i = 1; i <= n; ++i) {
if (p.charAt(i - 1) == '*') {
dp[0][i] = true;
} else {
break;
}
}
//动态规划求每个位置是否匹配
for (int i = 1; i <= m; ++i) {
for (int j = 1; j <= n; ++j) {
if (p.charAt(j - 1) == '*') {
dp[i][j] = dp[i][j - 1] || dp[i - 1][j];
} else if (p.charAt(j - 1) == '?' || s.charAt(i - 1) == p.charAt(j - 1)) {
dp[i][j] = dp[i - 1][j - 1];
}
}
}
return dp[m][n];
}
时间复杂度:O(mn),其中 m 和 n 分别是字符串 s 和模式 p 的长度。
空间复杂度:O(mn),即为存储所有 (m+1)(n+1) 个状态需要的空间。此外,在状态转移方程中,由于 dp[i][j]只会从 dp[i][…]以及 dp[i - 1][…]转移而来,因此我们可以使用滚动数组对空间进行优化,即用两个长度为 n+1的一维数组代替整个二维数组进行状态转移,空间复杂度为 O(n)。
例:p=“a*b?b” , s=“adcbeb”
代码执行后dp的状态
方法二:贪心算法
方法一的瓶颈在于对星号 ∗ 的处理方式:使用动态规划枚举所有的情况。由于星号是「万能」的匹配字符,连续的多个星号和单个星号实际上是等价的,那么不连续的多个星号呢?
我们以 p= ∗ abcd ∗ 为例,p 可以匹配所有包含子串 abcd 的字符串,也就是说,我们只需要暴力地枚举字符串 s 中的每个位置作为起始位置,并判断对应的子串是否为 abcd 即可。这种暴力方法的时间复杂度为 O(mn),与动态规划一致,但不需要额外的空间。
如果 p=∗ abcd∗efgh∗i ∗ 呢?显然,p 可以匹配所有依次出现子串 abcd、efgh、i 的字符串。此时,对于任意一个字符串 s,我们首先暴力找到最早出现的 abcd,随后从下一个位置开始暴力找到最早出现的 efgh,最后找出 i,就可以判断 s 是否可以与 p 匹配。这样「贪心地」找到最早出现的子串是比较直观的,因为如果 s 中多次出现了某个子串,那么我们选择最早出现的位置,可以使得后续子串能被找到的机会更大。
因此,如果模式 p 的形式为 * U1* U2* U3* …* Ux*,即字符串(可以为空)和星号交替出现,并且首尾字符均为星号,那么我们就可以设计出下面这个基于贪心的暴力匹配算法。算法的本质是:如果在字符串 s 中首先找到 U1,再找到 U2, U3,⋯,Ux,那么 s 就可以与模式 p 匹配。
然而模式 p 并不一定是 * U1* U2* U3* …* Ux* 的形式:
-
模式 p 的开头字符不是星号;
-
模式 p 的结尾字符不是星号。
第二种情况处理起来并不复杂。如果模式 p 的结尾字符不是星号,那么就必须与字符串 s 的结尾字符匹配。那么我们不断地匹配 s 和 p 的结尾字符,直到 p 为空或者 p 的结尾字符是星号为止。在这个过程中,如果匹配失败,或者最后 p 为空但 s 不为空,False。
第一种情况的处理也很类似,我们可以不断地匹配 s 和 p 的开头字符。下面的代码中给出了另一种处理方法,即修改 sRecord 和 pRecord 的初始值为 -1,表示模式 p 的开头字符不是星号,并且在匹配失败时进行判断,如果它们的值仍然为 −1,说明没有「反悔」重新进行匹配的机会。
//方法二:贪心算法
public boolean isMatch(String s, String p) {
int sRight = s.length(), pRight = p.length();
//模式p的结尾不是*号,为?或字符,则与s的结尾一个个判断是否匹配
while (sRight > 0 && pRight > 0 && p.charAt(pRight - 1) != '*') {
if (charMatch(s.charAt(sRight - 1), p.charAt(pRight - 1))) {
--sRight;
--pRight;
} else {
//只要结尾有一个非*字符不能匹配s对应位置字符就返回false
return false;
}
}
//p为空模式
if (pRight == 0) {
//s为空串则匹配,否则不匹配
return sRight == 0;
}
int sIndex = 0, pIndex = 0;
int sRecord = -1, pRecord = -1;
while (sIndex < sRight && pIndex < pRight) {
//出现 * 假设其不匹配任何字符,继续判断剩下的 s 和 p 是否匹配
//并记录该 * 号下一个字符的下标,和对应的 s 的下标,便于(后悔)即不匹配的时候,返回来让 * 匹配部分 s 中的字符看剩下的是否匹配
if (p.charAt(pIndex) == '*') {
++pIndex;
sRecord = sIndex;
pRecord = pIndex;
//s[sIndex]和p[pIndex]匹配,则指针后移,判断剩下的是否匹配
} else if (charMatch(s.charAt(sIndex), p.charAt(pIndex))) {
++sIndex;
++pIndex;
// 出现了 * 号,且 * 不起作用时(即不匹配任何字符串时),s 和 p 不匹配则倒退回来(后悔),
// 让 * 代表部分 s 的字符串,继续判断剩下的是否匹配
} else if (sRecord != -1 && sRecord + 1 < sRight) {
++sRecord;
sIndex = sRecord;
pIndex = pRecord;
} else {
return false;
}
}
return allStars(p, pIndex, pRight);
}
//若 p 的左边一部分已经完全匹配了 s ,则此时 pIndex < pRight, p 剩下的p(pIndex)到p(pRight) 部分必须全为*,才匹配
//不然 s 不存在对应的字符串片段与 p 该段的字符相匹配,所以返回false
//如果最后pIndex==pRight则返回true,是匹配的
public boolean allStars(String str, int left, int right) {
for (int i = left; i < right; ++i) {
if (str.charAt(i) != '*') {
return false;
}
}
return true;
}
//判断;两个字符是否匹配
public boolean charMatch(char u, char v) {
return u == v || v == '?';
}
时间复杂度:
- 渐进复杂度:O(mn),其中 m 和 n 分别是字符串 s 和模式 p 的长度。从代码中可以看出,s[sIndex] 和 p[pIndex] 至多只会被匹配(判断)一次,因此渐进时间复杂度为 O(mn)。
- 平均复杂度:O(mlogn)。
空间复杂度:O(1)。
参考:
Leetcode官方题解