正则表达式匹配 Java 题解:动态规划彻底讲清 . 与 *
给定字符串
s和模式p,实现支持.与*的完整匹配。本文用二维动态规划拆解状态定义、空串初始化和星号的两种转移,并附可直接使用的 Java 答案、测试代码和结果。

一、题目规则
.匹配任意一个字符;*匹配零个或多个前面的元素;- 匹配必须覆盖整个字符串,而不是只找到其中一段。
text
s = "aa", p = "a" -> false
s = "aa", p = "a*" -> true
s = "ab", p = ".*" -> true
注意:* 不能独立使用,它修饰紧邻它前面的元素。a* 可以表示空串、a、aa、aaa 等;.* 可以表示任意长度的字符序列。
二、为什么适合动态规划
遇到 * 时有两个选择:
- 让前一个元素出现零次,直接跳过"前一个元素 +
*"; - 当前字符匹配时,让前一个元素再出现一次,消费字符串中的一个字符,但模式位置不变。
这些选择会产生重复子问题,适合使用动态规划保存结果。

三、状态定义
定义:
text
dp[i][j]:s 的前 i 个字符,是否能被 p 的前 j 个字符完整匹配。
最终答案为:
java
dp[s.length()][p.length()]
空字符串和空模式可以匹配,因此:
java
dp[0][0] = true;
四、普通字符和点号
如果当前模式字符不是 *,只有当前字符相互匹配时,才能继承左上角状态:
java
boolean matches = p.charAt(j - 1) == '.'
|| p.charAt(j - 1) == s.charAt(i - 1);
if (matches) {
dp[i][j] = dp[i - 1][j - 1];
}
a 只能匹配 a,而 . 可以匹配任意一个字符,但不能匹配空字符。
五、星号的两种转移
假设当前模式字符为 *,它修饰 p[j - 2]。
1. 匹配零次
把前一个元素和 * 一起忽略:
java
dp[i][j] = dp[i][j - 2];
例如 s = "b"、p = "a*b",其中 a* 匹配零次。
2. 匹配一次或多次
如果 p[j - 2] 能匹配当前字符串字符,则消费一个字符串字符,但继续保留当前模式:
java
if (p.charAt(j - 2) == '.'
|| p.charAt(j - 2) == s.charAt(i - 1)) {
dp[i][j] = dp[i][j] || dp[i - 1][j];
}
这里使用 dp[i - 1][j],是因为 * 仍可能继续匹配更多字符。
六、空字符串初始化
模式即使非空,也可能匹配空字符串,例如:
text
"a*"
"a*b*"
".*"
因此需要初始化第一行:
java
for (int j = 2; j <= p.length(); j++) {
if (p.charAt(j - 1) == '*') {
dp[0][j] = dp[0][j - 2];
}
}
七、可直接使用的 Java 答案
java
class Solution {
public boolean isMatch(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 j = 2; j <= n; j++) {
if (p.charAt(j - 1) == '*') {
dp[0][j] = dp[0][j - 2];
}
}
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
char patternChar = p.charAt(j - 1);
if (patternChar == '*') {
dp[i][j] = dp[i][j - 2];
char previous = p.charAt(j - 2);
if (previous == '.' || previous == s.charAt(i - 1)) {
dp[i][j] = dp[i][j] || dp[i - 1][j];
}
} else if (patternChar == '.'
|| patternChar == s.charAt(i - 1)) {
dp[i][j] = dp[i - 1][j - 1];
}
}
}
return dp[m][n];
}
}
题目通常保证模式合法,即 * 不会出现在首位,也不会脱离前一个元素。若方法用于通用接口,应先验证模式格式,避免访问 j - 2 时越界。
八、示例推演:aa 与 a*
当处理第二个 a 时:
*匹配零次无法覆盖aa;a与当前字符匹配;- 查看
dp[1][2],表示前一个a已经被a*匹配; - 因此
dp[2][2] = true。
这正是 dp[i - 1][j] 表示"继续重复"的含义。
九、复杂度分析
设字符串长度为 m,模式长度为 n:
- 时间复杂度:
O(mn); - 空间复杂度:
O(mn)。
可以使用滚动数组将空间优化为 O(n),但二维表更容易理解和验证状态转移。
十、完整测试代码
java
public class RegexMatchingTest {
public static boolean isMatch(String s, String p) {
int m = s.length(), n = p.length();
boolean[][] dp = new boolean[m + 1][n + 1];
dp[0][0] = true;
for (int j = 2; j <= n; j++) {
if (p.charAt(j - 1) == '*') {
dp[0][j] = dp[0][j - 2];
}
}
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
char pc = p.charAt(j - 1);
if (pc == '*') {
dp[i][j] = dp[i][j - 2];
char previous = p.charAt(j - 2);
if (previous == '.' || previous == s.charAt(i - 1)) {
dp[i][j] |= dp[i - 1][j];
}
} else if (pc == '.' || pc == s.charAt(i - 1)) {
dp[i][j] = dp[i - 1][j - 1];
}
}
}
return dp[m][n];
}
private static void test(int n, String s, String p, boolean expected) {
boolean actual = isMatch(s, p);
System.out.println("测试 " + n + ": "
+ (actual == expected ? "PASS" : "FAIL")
+ " | actual=" + actual);
}
public static void main(String[] args) {
test(1, "aa", "a", false);
test(2, "aa", "a*", true);
test(3, "ab", ".*", true);
test(4, "aab", "c*a*b", true);
test(5, "mississippi", "mis*is*p*.", false);
test(6, "", "a*", true);
test(7, "", ".", false);
test(8, "aaa", "ab*a*c*a", true);
}
}
十一、测试结果
text
测试 1:PASS | false
测试 2:PASS | true
测试 3:PASS | true
测试 4:PASS | true
测试 5:PASS | false
测试 6:PASS | true
测试 7:PASS | false
测试 8:PASS | true
十二、常见错误
1. 把 * 当成独立通配符
* 只能修饰前一个元素,真正参与当前字符比较的是 p[j - 2]。
2. 忘记"匹配零次"
只实现重复匹配会导致 a*b 无法匹配 b。必须包含 dp[i][j - 2]。
3. 使用 dp[i - 1][j - 2] 表示重复
重复一次或多次时,模式仍然停留在 *,正确状态是 dp[i - 1][j]。
4. 忘记初始化空字符串
a*、a*b* 可以匹配空字符串,第一行必须按星号结构初始化。
5. 实现成部分匹配
答案必须取 dp[m][n],表示字符串和模式都被完整消费。
十三、总结
这道题的核心只有两类转移:
- 普通字符或点号匹配时,继承左上角状态;
- 星号匹配零次时跳过两个模式字符;
- 星号匹配一次或多次时,消费一个字符串字符并保留模式位置。
只要理解 dp[i][j - 2] 和 dp[i - 1][j] 分别代表什么,整个动态规划就会非常清晰。
更多技术实践与案例,可在 📗【姚前述】中查阅。