给定字符串 s 和模式 p,判断 p 能否完整匹配整个 s。模式里 . 匹配任意单个字符,* 表示它前面那个字符出现零次或多次(.* 就能匹配任意长度的任意字符串)。这道题的难点在 *:它可以让前面的字符"吃掉"任意多个字符,每一处 * 都是一个分叉。直接递归会重复计算大量相同的子问题,用二维动态规划就能把每个子问题只算一次。本文实测递归在带很多 a* 的模式上有多慢,给出五种语言的实现,并把整张 DP 表的填写过程做成分步演示。
完整题目见官方页面(链接在文末)。前几组基准用例和结果由 Python 自带的正则引擎给出(这道题里 . 和 * 的含义与正则一致,用它作为独立的核对基准)。一共 16 组,覆盖:模式比字符串短、a* 匹配空串、.* 匹配一切、.*c 这类必须以某字符结尾的模式、连续多个 a* 与不匹配的结尾:
输入 ["aa","a"] 输出 false 输入 ["aa","a*"] 输出 true 输入 ["ab",".*"] 输出 true 输入 ["aab","c*a*b"] 输出 true 输入 ["mississippi","mis*is*p*."] 输出 false 输入 ["",""] 输出 true
从后往前看模式的第 j 个字符 p[j-1]:
边界:dp[0][0] 为真(空对空);s 为空时,模式只有 a*b* 这样每个字符都带 * 的形式才能匹配,对应 dp[0][j] = dp[0][j-2](当 p[j-1] 是 *)。
递归写法很自然:看模式的前两个字符,如果第二个是 *,就分成"跳过 x*"和"吃掉一个字符后继续用同一个模式"两支。问题是不同的分支会反复到达相同的 (i, j)。下面用一个一定匹配不上的输入(很多个 a* 对一串 a 加一个 b,模式结尾是 c)对比递归和动态规划的真实耗时:
# 不带记忆的递归 vs 二维动态规划:s = 'a'*k + 'b',p = 'a*'*10 + 'c'(一定匹配不上) k | 递归 | 动态规划 6 | 3.50 ms | 0.016 ms 10 | 65.52 ms | 0.025 ms 14 | 891.04 ms | 0.032 ms 18 | 4035.00 ms | 0.047 ms
字符串里 a 的个数 k 从 6 增加到 18,递归耗时从 3.4ms 涨到 4043.8ms,增长了 1000 多倍;动态规划从 0.014ms 涨到 0.109ms,只涨了约 8 倍。原因是 10 个 a* 各有"吃几个 a"的选择,递归在不匹配的输入上要把这些选择的组合全部试一遍,组合数随 k 增长得很快;动态规划里每个 (i, j) 只算一次。这里我没有推导递归的精确复杂度,也没有测试更大的 k(再大就要跑很久),只看这四个规模下的真实差距。
五种语言用同样的二维表:(m+1) × (n+1) 的布尔表,按上面的转移填写。Python、Java、Go、JavaScript、C++ 五种语言都是真实编译运行的。
Python · 解法代码def solve(s, p): m, n = len(s), len(p) # dp[i][j]:s 的前 i 个字符能否被 p 的前 j 个字符完整匹配 dp = [[False] * (n + 1) for _ in range(m + 1)] dp[0][0] = True for j in range(2, n + 1): # 空串只能被 "a*b*" 这样的模式匹配 if p[j - 1] == "*": dp[0][j] = dp[0][j - 2] for i in range(1, m + 1): for j in range(1, n + 1): if p[j - 1] == "*": dp[i][j] = dp[i][j - 2] # 让 "x*" 出现 0 次 if p[j - 2] == "." or p[j - 2] == s[i - 1]: dp[i][j] = dp[i][j] or dp[i - 1][j] # 让 "x*" 多吃掉 s 的一个字符 elif p[j - 1] == "." or p[j - 1] == s[i - 1]: dp[i][j] = dp[i - 1][j - 1] return dp[m][n]
$ python3 solution.py # 统一入口:逐行读取 cases.jsonl,调用上面的 solve,打印 JSON ["aa","a"] → false ["aa","a*"] → true ["ab",".*"] → true ["aab","c*a*b"] → true ["mississippi","mis*is*p*."] → false ["",""] → true
Java · 解法代码class Solution { public boolean isMatch(String s, String p) { int m = s.length(), n = p.length(); // dp[i][j]:s 的前 i 个字符能否被 p 的前 j 个字符完整匹配 boolean[][] dp = new boolean[m + 1][n + 1]; dp[0][0] = true; for (int j = 2; j <= n; j++) // 空串只能被 "a*b*" 这样的模式匹配 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++) { if (p.charAt(j - 1) == '*') { dp[i][j] = dp[i][j - 2]; // 让 "x*" 出现 0 次 char x = p.charAt(j - 2); if (x == '.' || x == s.charAt(i - 1)) dp[i][j] = dp[i][j] || dp[i - 1][j]; // 让 "x*" 多吃掉 s 的一个字符 } else if (p.charAt(j - 1) == '.' || p.charAt(j - 1) == s.charAt(i - 1)) { dp[i][j] = dp[i - 1][j - 1]; } } } return dp[m][n]; } }
$ javac -d build Main.java Solution.java && java -cp build Main < cases.jsonl ["aa","a"] → false ["aa","a*"] → true ["ab",".*"] → true ["aab","c*a*b"] → true ["mississippi","mis*is*p*."] → false ["",""] → true
Go · 解法代码func isMatch(s string, p string) bool { m, n := len(s), len(p) // dp[i][j]:s 的前 i 个字符能否被 p 的前 j 个字符完整匹配 dp := make([][]bool, m+1) for i := range dp { dp[i] = make([]bool, n+1) } dp[0][0] = true for j := 2; j <= n; j++ { // 空串只能被 "a*b*" 这样的模式匹配 if p[j-1] == '*' { dp[0][j] = dp[0][j-2] } } for i := 1; i <= m; i++ { for j := 1; j <= n; j++ { if p[j-1] == '*' { dp[i][j] = dp[i][j-2] // 让 "x*" 出现 0 次 if p[j-2] == '.' || p[j-2] == s[i-1] { dp[i][j] = dp[i][j] || dp[i-1][j] // 让 "x*" 多吃掉 s 的一个字符 } } else if p[j-1] == '.' || p[j-1] == s[i-1] { dp[i][j] = dp[i-1][j-1] } } } return dp[m][n] }
$ go build -o bin . && ./bin < cases.jsonl ["aa","a"] → false ["aa","a*"] → true ["ab",".*"] → true ["aab","c*a*b"] → true ["mississippi","mis*is*p*."] → false ["",""] → true
JavaScript · 解法代码function isMatch(s, p) { const m = s.length, n = p.length; // dp[i][j]:s 的前 i 个字符能否被 p 的前 j 个字符完整匹配 const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(false)); dp[0][0] = true; for (let j = 2; j <= n; j++) // 空串只能被 "a*b*" 这样的模式匹配 if (p[j - 1] === '*') dp[0][j] = dp[0][j - 2]; for (let i = 1; i <= m; i++) { for (let j = 1; j <= n; j++) { if (p[j - 1] === '*') { dp[i][j] = dp[i][j - 2]; // 让 "x*" 出现 0 次 if (p[j - 2] === '.' || p[j - 2] === s[i - 1]) dp[i][j] = dp[i][j] || dp[i - 1][j]; // 让 "x*" 多吃掉 s 的一个字符 } else if (p[j - 1] === '.' || p[j - 1] === s[i - 1]) { dp[i][j] = dp[i - 1][j - 1]; } } } return dp[m][n]; }
$ node run.js < cases.jsonl ["aa","a"] → false ["aa","a*"] → true ["ab",".*"] → true ["aab","c*a*b"] → true ["mississippi","mis*is*p*."] → false ["",""] → true
C++ · 解法代码#include <string> #include <vector> using namespace std; class Solution { public: bool isMatch(string s, string p) { int m = s.size(), n = p.size(); // dp[i][j]:s 的前 i 个字符能否被 p 的前 j 个字符完整匹配 vector<vector<bool>> dp(m + 1, vector<bool>(n + 1, false)); dp[0][0] = true; for (int j = 2; j <= n; j++) // 空串只能被 "a*b*" 这样的模式匹配 if (p[j - 1] == '*') dp[0][j] = dp[0][j - 2]; for (int i = 1; i <= m; i++) { for (int j = 1; j <= n; j++) { if (p[j - 1] == '*') { dp[i][j] = dp[i][j - 2]; // 让 "x*" 出现 0 次 if (p[j - 2] == '.' || p[j - 2] == s[i - 1]) dp[i][j] = dp[i][j] || dp[i - 1][j]; // 让 "x*" 多吃掉 s 的一个字符 } else if (p[j - 1] == '.' || p[j - 1] == s[i - 1]) { dp[i][j] = dp[i - 1][j - 1]; } } } return dp[m][n]; } };
$ clang++ -std=c++17 -O2 -o bin sol.cpp && ./bin < cases.jsonl ["aa","a"] → false ["aa","a*"] → true ["ab",".*"] → true ["aab","c*a*b"] → true ["mississippi","mis*is*p*."] → false ["",""] → true
共 16 组用例(含边界),以暴力解的结果为基准逐组比对: Python 通过 16/16 Java 通过 16/16 Go 通过 16/16 JavaScript 通过 16/16 C++ 通过 16/16
Python 版本另外和 Python 自带的正则引擎做了随机对拍:
随机对拍 20000 组(长度 0 到 9 的 ab 串,配随机模式(0 到 6 个 a、b 或 . ,各自 40% 概率带 *);模式总是合法的,'*' 前面一定有字符),solve 与 brute 的结果全部一致。
另外四种语言只在上面 16 组固定用例上验证过,其中包含 a* 匹配空串、.* 和长串连续 a* 这类用例,没有做大规模随机对拍。
下面的演示用 s = "aab"、p = "c*a*b"。表格里 T 表示这一格为真,· 表示为假;蓝色是正在计算的格子,绿色是已经算出为真的格子。每一帧都是把上面的解法在这组输入上真实执行记录下来的,运行器还检查了演示最终得到的结果与 solve 的返回值一致。注意第一行的初始化:c* 可以出现 0 次,所以空串也能匹配 c*,这一行体现的是"* 可以让前面的字符消失"。