这节课将分析三道比较难的题目,希望能帮助大家拓宽解题的思路。主要内容包括:
* 正则表达式匹配
* 柱状图中的最大矩形
* 实现 strStr() 函数
#### 例题分析一
LeetCode 第 10 题,正则表达式匹配:给你一个字符串 s 和一个字符规律 p,请你来实现一个支持 '.' 和 '*' 的正则表达式匹配。
* '.' 匹配任意单个字符
* '*' 匹配零个或多个前面的那一个元素
> 注意:所谓匹配,是要涵盖整个字符串 s 的,而不是部分字符串。
说明:
* s 可能为空,且只包含从 a-z 的小写字母。
* p 可能为空,且只包含从 a-z 的小写字母,以及字符 . 和 *。
* 示例 1
```
输入:
s = "aa"
p = "a"
输出: false
```
解释: "a" 无法匹配 "aa" 整个字符串。
* 示例 2
```
输入:
s = "aa"
p = "a*"
输出: true
```
解释: 因为 '*' 代表可以匹配零个或多个前面的那一个元素, 在这里前面的元素就是 'a'。因此,字符串 "aa" 可被视为 'a' 重复了一次。
* 示例 3
```
输入:
s = "ab"
p = ".*"
输出: true
```
解释: ".*" 表示可匹配零个或多个('*')任意字符('.')。
* 示例 4
```
输入:
s = "aab"
p = "c*a*b"
输出: true
```
解释: 因为 '*' 表示零个或多个,这里 'c' 为 0 个, 'a' 被重复一次。因此可以匹配字符串 "aab"。
* 示例 5
```
输入:
s = "mississippi"
p = "mis*is*p*."
输出: false
```
解释:'p'与'i'无法匹配。
**解题思路**
不要害怕,这道题只要求实现正则表达式里的两个小功能。
**判断 s 和 p 匹配**
举例:给定两个字符串 s 和 p,判断 s 和 p 是否匹配。
解法:s 和 p 必须要相等。定义两个指针 i 和 j,分别指向 s 和 p 的第一个字符,然后逐个去比较,一旦发现不相等,就立即知道 s 和 p 不匹配。
此时,假设 s 字符串的长度为 m,p 字符串的长度为 n,s 和 p 匹配的条件就是 s 和 p 从头到尾一直匹配,即 i == m 同时 j == n 是 s 和 p 匹配的唯一条件。
**点匹配符 '.'**
'.' 匹配任意单个字符,首先要明确的是,它是一一对应关系,和 '*' 匹配符不一样。举例说明如下。
```
输入:
s = "leetcode"
p = "l..tc..e"
输出: true
```
因为 '.' 可以匹配任何字符,即,一旦遇上了 '.' 匹配符,可以让 i 指针和 j 指针同时跳到下一个位置。
**星匹配符 '*' **
'*' 匹配符较难,先要理解这个星匹配符的定义。题目“ '*' 匹配零个或多个前面的那一个元素”中包含三个重要的信息:
1. 它匹配的是 p 字符串中,该 '*' 前面的那个字符。
2. 它可以匹配零个或多个。
3. '*' 匹配符前面必须有一个非星的字符。
因此,在分析 '*' 匹配符的时候,一定要把 '*' 以及它前面的一个字符看作为一个整体, '*' 不能单独作为一个个体来看(例如点匹配符)。例如,p 字符串是 a *,则把 (a*) 当作一个整体来看。
对 p 字符串说明如下。
* p 可以表示空字符串,因为 '*' 可以匹配 0 个前面的字符,即当有 0 个 a 的时候,为空字符串。
* a* 还能匹配一个 a,两个 a,三个 a,一直到无穷个 a。
* 当 p 等于 '.*' 的时候,可以表示一个空字符串,也可以表示一个点,两个点,三个点,一直到无穷个点。即它可以表示任何长度的一段字符串,包括空串。
举例说明
```
输入:
s = "aaabcd"
p = "ac*a*b.."
```
1. 用两个指针 i 和 j 分别指向 s 和 p 的开头。
2. 在 p 字符串里,a 的下一个字符是 c,不是 '*',比较 s[i] 和 p[j]。因为它们都是字符 a,所以这个位置匹配正确,i 和 j 同时指向下一个。此时 j 的下一个字符是 '*',要将 c* 当作一个整体去看待。
3. 将 c* 看成是空字符,p 如下所示。
4. 若匹配中不一致即 c* 不能当作空字符串,则当作一个 c 字符,此时 p 如下。
5. 若不行,则看作两个 c。
以此类推,应用了回溯的思想。
对于将 c* 作为空字符串的情况。每一次,都要看看当前 j 指向的字符的下一个是不是 '*'。如果是 '*',就要作为整体考虑。很明显,a 的下一个字符是 '*'。
同样,先将 a* 作为空字符串看待。此时,a != b,两个字符串不匹配,因此回溯.现在将 a* 看成是一个 a,此时 a = a,两个位置的字符匹配。
j 的下一个字符不是 '*',而是点号,比较 s[i] 和 p[j],发现 a != b。于是再次回溯,将 a* 看成是两个 a,回到刚才的位置。
最后遇到了两个点号,由于点号可以匹配任何字符,因此可以直接忽略。i 和 j 同时往前一步,再次遇到了点号。i 和 j 继续往前一步。
此时,i 和 j 都已经同时结束了各自的遍历,表明 s 和 p 是匹配的。
> 提示:重点是把这种回溯的思想掌握好。对于这道题,可以采用递归的写法,也可以采用动态规划的写法。
* [ ] 递归法一
一开始,用两个指针 i 和 j 分别指向字符串 s 和 p 的第一个字符,当我们发现它们指向的字符相同时,我们同时往前一步移动指针 i 和 j。
接下来重复进行相同的操作,即,若将函数定义为 isMatch(String s, int i, String p, int j) 的话,通过传递 i 和 j,就能实现重复利用匹配逻辑的效果。
当遇到点匹配符的时候,方法类似。
来看看当遇到星匹配符的情况,举例说明如下。要不断地用 a* 去表示一个空字符串,一个 a,两个 a,一直到多个 a……
当 a* 表示空字符串的时候,i 和 j 应该如何调整呢?此时 i 保持不变,j+2,递归调用函数的时候,变成 isMatch(s, i, p, j + 2)。
此时,指向的字符和 j 指向的字符不匹配,于是回溯,回到原来的位置。11:57
用 a* 去表示一个 a,i 指向的字符与 a 匹配,那么 i+1。指针 j,已经完成了用 a* 去表示一个 a 的任务,接下来要指向 b,调用的时候应该是 isMatch(s, i + 1, p, j + 2)。
i 指向的字符和 j 指向的字符不匹配,又进行回溯,但是不用回到最开始的位置。已知用 a* 去表示空字符串不行,表示一个 a 也不行,那么尝试两个 a 字符,于是,i 再往前一步,用 a* 去匹配两个 a,i 就到了 b 的位置上,调用的时候就是 isMatch(s, i + 2, p, j + 2)。
不断地这样操作,一旦遇到了 '*' 组合,就不断地尝试,直到最后满足匹配;或者尝试过所有的可能还是不行则表示 s 和 p 无法匹配。
代码实现
根据上面的思路,一起来写递归的实现。主体函数如下。
```
boolean isMatch(String s, String p) {
if (s == null || p == null) {
return false;
}
return isMatch(s, 0, p, 0);
}
```
主体函数非常简单,一开始做简单的判断,只要 s 和 p 有一个为 null,就表示不匹配。
> 注意:面试的时候,一定要注意对这些基本情况的考量,千万不要认为输入的值都是有效的。
接下来实现递归函数。
```
boolean isMatch(String s, int i, String p, int j) {
int m = s.length();
int n = p.length();
// 看看pattern和字符串是否都扫描完毕
if (j == n) {
return i == m;
}
// next char is not '*': 必须满足当前字符并递归到下一层
if (j == n - 1 || p.charAt(j + 1) != '*') {
return (i < m) &&
(p.charAt(j) == '.' || s.charAt(i) == p.charAt(j)) &&
isMatch(s, i + 1, p, j + 1);
}
// next char is '*', 如果有连续的s[i]出现并且都等于p[j],一直尝试下去
if (j < n - 1 && p.charAt(j + 1) == '*') {
while ((i < m) && (p.charAt(j) == '.' || s.charAt(i) == p.charAt(j))) {
if (isMatch(s, i, p, j + 2)) {
return true;
}
i++;
}
}
// 接着继续下去
return isMatch(s, i, p, j + 2);
}
```
1. 函数接受四个输入参数,s 字符串,p 字符串,i 指针,j 指针。
2. 开始时计算 s 字符串和 p 字符串的长度,分别记为 m 和 n。
3. 当 j 指针遍历完了 p 字符串后,可以跳出递归,而 i 也刚好遍历完,说明 s 和 p 完全匹配。
4. 判断 j 字符的下一个是不是 '*',不是,则递归地调用 isMatch 函数,i + 1,j + 1。
5. 若是,则不断地将它和 '*' 作为一个整体,分别去表示空字符串,一个字符,两个字符,依此类推。如果其中一种情况能出现 s 和 p 的匹配,就返回 true。
6. while 循环是整个递归算法的核心,前提条件如下。
i 指向的字符必须要能和 j 指向的字符匹配,其中 j 指向的可能是点匹配符。
若无法匹配,i++,即用 '*' 组合去匹配更长的一段字符串。
当 i 字符和 j 字符不相同,或者 i 已经遍历完了 s 字符串,同时 j 字符后面跟着一个 '*'的情况,只能用 '*'组合去表示一个空字符串,然后递归下去。
* [ ] 递归法二
上法是从前往后进行递归地调用,现在从后往前地分析这个问题。例如:
```
s = "aaabcd"
p = "a*b.d"
```
实现过程如下所示。
1. p 字符串的最后一个字符 d 必须要和 s 字符串的最后一个字符相同,才能使 p 有可能与 s 匹配,那么当它们都相同的时候,问题规模也缩小。
2. p 字符串的最后一个字符不是 '*',而是点号。它可以匹配 s 字符串里的任意一个字符,且它是最后一个,所以对应的就是 s 字符串里的 c,很明显互相匹配,继续缩小问题规模。
3. 同样,b 不是 '*',比较它与 s 字符串的最后一个字符是否相同,是,则继续缩小问题规模。
4. 遇到 '*','*'可以表示一个空字符串,与前一个字符表示空字符串的时候,将问题变成了判断两个字符串是否匹配,其中,s 等于 aaa,而 p 是空字符串,很明显不能匹配。
5. 用 a* 去表示一个 a。
6. p 的最后一个还是 '*',用同样的策略。
7. 继续用 a* 去表示一个 a。
8. 用 a* 去表示空字符串。
9. 最后 s 和 p 都变成了空字符串,互相匹配。
代码实现
主函数代码如下。
```
boolean isMatch(String s, String p) {
if (s == null || p == null) return false;
return isMatch(s, s.length(), p, p.length());
}
```
在主函数里,进行一些简单基础的判断,如果 s 和 p 有一个是 null,则返回 false。
递归函数代码如下。
```
boolean isMatch(String s, int i, String p, int j) {
if (j == 0) return i == 0;
if (i == 0) {
return j > 1 && p.charAt(j - 1) == '*' && isMatch(s, i, p, j - 2);
}
if (p.charAt(j - 1) != '*') {
return isMatch(s.charAt(i - 1), p.charAt(j - 1)) &&
isMatch(s, i - 1, p, j - 1);
}
return isMatch(s, i, p, j - 2) || isMatch(s, i - 1, p, j) &&
isMatch(s.charAt(i - 1), p.charAt(j - 2));
}
boolean isMatch(char a, char b) {
return a == b || b == '.';
}
```
1. 递归函数的输入参数有四个,分别是字符串 s,当前字符串 s 的下标,字符串 p,以及字符串 p 的当前下标。由主函数可以看到,两个字符串的下标都是从最后一位开始。
2. 若 p 字符串为空,并且如果 s 字符串也为空,就表示匹配。
3. 当 p 字符串不为空,而 s 字符串为空,如上例所示,当 s 为空字符串,而 p 等于 a*,此时只要 p 总是由 '*'组合构成,一定能满足匹配,否则不行。
4. 若 p 的当前字符不是 '*',判断当前的两个字符是否相等,如果相等,就递归地看前面的字符。
5. 否则,如果 p 的当前字符是 '*':
用 '*' 组合表示空字符串,看看是否能匹配;
用 '*' 组合表示一个字符,看看能否匹配。
**动态规划法**
递归的方法比较好理解,但是容易造成重叠计算。为了避免重叠计算,可以用动态规划,自底向上地实现刚才的策略。
代码实现
```
// 分别用 m 和 n 表示 s 字符串和 p 字符串的长度
boolean isMatch(String s, String p) {
int m = s.length(), n = p.length();
// 定义一个二维布尔矩阵 dp
boolean[][] dp = new boolean[m + 1][n + 1];
// 当两个字符串的长度都为 0,也就是空字符串的时候,它们互相匹配
dp[0][0] = true;
for (int j = 1; j <= n; j++) {
dp[0][j] = j > 1 && p.charAt(j - 1) == '*' && dp[0][j - 2];
}
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
// p 的当前字符不是 '*',判断当前的两个字符是否相等,如果相等,就看 dp[i-1][j-1] 的值,因为它保存了前一个匹配的结果
if (p.charAt(j - 1) != '*') {
dp[i][j] = dp[i - 1][j - 1] &&
isMatch(s.charAt(i - 1), p.charAt(j - 1));
} else {
dp[i][j] = dp[i][j - 2] || dp[i - 1][j] &&
isMatch(s.charAt(i - 1), p.charAt(j - 2));
}
}
}
return dp[m][n];
}
boolean isMatch(char a, char b) {
return a == b || b == '.';
}
```
注意:
* 初始化二维矩阵第一行的所有值时,当 s 字符串为空,对于 p 字符串的任何一个位置,要使到这个位置的子串能和空字符串匹配,要求该子串都是由一系列的 '*' 组合构成。
* 对二维矩阵填表,运用到的逻辑跟递归一摸一样。
* p 的当前字符不是 '*',判断当前的两个字符是否相等。如果相等,就看 dp[i-1][j-1] 的值,因为它保存了前一个匹配的结果。
* 如果 p 的当前字符是 '*':
* 用 '*' 组合表示空字符串,能否匹配,也就是 dp[i][j - 2];
* 用 '*' 组合表示一个字符,能否匹配,也就是 dp[i - 1][j]。
* [ ] 复杂度分析
运用动态规划,把时间复杂度控制在 O(n2),而空间复杂度也是 O(n2)。
建议:LeetCode 上有一道跟这题十分类似的题目,第 44 题,通配符匹配。分析例题的思路,所运用的策略,以及代码的实现,都有很多非常相似的地方。大家一定要去做做看,举一反三才能加深印象。