bigywater.tech
算法与数据结构 · LeetCode 44

LeetCode 44. 通配符匹配

给定一个字符串 s 和一个含有通配符的模式 p:? 匹配任意单个字符,* 匹配任意长度的字符串(包括空串),判断整个 s 是否被 p 完全匹配。它常见的解法是动态规划,O(nm) 时间和空间。还有一种贪心加回退的双指针做法,只用 O(1) 额外空间:遇到 * 先让它匹配空串,记下位置,以后失配就回到最近的 *,让它多吃 s 的一个字符。这道题很适合展示"不带记忆化的递归"有多慢。本文用真实的调用次数展示指数爆炸,实测贪心和记忆化递归的耗时,给出五种语言的实现,并把贪心的匹配和回退做成分步演示。

30,000
组随机对拍,与暴力解的结果全部一致
9 步
分步演示,每一帧都来自解法的真实执行,另附 5 种语言实现

题意与示例

完整题目见官方页面(链接在文末)。基准解是带记忆化的递归(lru_cache),逐个分情况:模式为空、当前是 *、当前是 ? 或普通字符,是与贪心完全不同的写法。一共 18 组用例,覆盖:官方示例、空串与空模式、只有 *、连续多个 *、模式比字符串长:

真实实测 · 本机执行输出
输入 ["aa","a"]
输出 false
输入 ["aa","*"]
输出 true
输入 ["cb","?a"]
输出 false
输入 ["adceb","*a*b"]
输出 true
输入 ["acdcb","a*c?b"]
输出 false
输入 ["",""]
输出 true

不带记忆化的递归有多慢

最直接的递归是:遇到 * 时有两种选择,让它匹配空串,或者让它吃掉 s 的一个字符,然后继续。同一个(s 的位置,p 的位置)状态会被重复计算很多次。我用 s 全是 a、模式是 *a*a*…*b 的最坏情形(永远匹配不上),统计不带记忆化的递归的真实调用次数:

真实实测 · 本机执行输出
s = a*n,p = *a*a*...*b(k 个 a):不带记忆化的递归调用次数 vs 贪心的循环次数(贪心没有额外开销,用总步数计)
n=10, k=3: 递归调用     1122 次(     0.1 ms),结果 False;solve -> False
n=14, k=4: 递归调用     9886 次(     1.1 ms),结果 False;solve -> False
n=18, k=5: 递归调用    87590 次(     9.0 ms),结果 False;solve -> False
n=22, k=6: 递归调用   781310 次(    81.4 ms),结果 False;solve -> False

n 和模式里 a 的个数 k 每次各增加一点(n 加 4,k 加 1),递归调用次数却每次约乘 9:1122、9886、87590、781310,耗时从 0.1ms 涨到 81.8ms。规模再大一点就无法完成。而贪心(以及带记忆化的递归)在同样的输入上都只需要多项式时间。

贪心加回退:五种语言的实现

用双指针 i(指向 s)、j(指向 p),并记下最近一个 * 的位置 star 和它当前匹配到的 s 的位置 mark。如果当前字符匹配(? 或相同),两个指针都前进;如果 p[j] 是 *,记下 star = j、mark = i,让 * 先匹配空串(只让 j 前进);如果失配,而之前有 *,就让 * 多吃一个字符:mark 加一,i 回到 mark,j 回到 star + 1;如果失配又没有 *,返回失败。s 用完之后,p 剩下的必须全是 *。只需要记住最近的一个 *,因为之前的 * 已经可以匹配任何东西,回退到更早的 * 不会有更多收获。Python、Java、Go、JavaScript、C++ 都是真实编译运行的。

Python · 解法代码def solve(s, p): i = j = 0 star = -1 # 最近一个 * 在 p 里的位置 mark = 0 # 这个 * 当前"吃到"了 s 的哪个位置之前 while i < len(s): if j < len(p) and (p[j] == "?" or p[j] == s[i]): i += 1 j += 1 elif j < len(p) and p[j] == "*": star, mark = j, i # 先让 * 匹配空串,记下位置,以后失配时回来让它多吃一个字符 j += 1 elif star != -1: mark += 1 # 失配:回到最近的 *,让它多吃 s 的一个字符 i, j = mark, star + 1 else: return False while j < len(p) and p[j] == "*": # s 用完了,p 剩下的必须全是 * j += 1 return j == len(p)
真实实测 · Python · 前几组用例的输出
$ python3 solution.py   # 统一入口:逐行读取 cases.jsonl,调用上面的 solve,打印 JSON
["aa","a"]  →  false
["aa","*"]  →  true
["cb","?a"]  →  false
["adceb","*a*b"]  →  true
["acdcb","a*c?b"]  →  false
["",""]  →  true
Java · 解法代码class Solution { public boolean isMatch(String s, String p) { int i = 0, j = 0; int star = -1; // 最近一个 * 在 p 里的位置 int mark = 0; // 这个 * 当前"吃到"了 s 的哪个位置之前 while (i < s.length()) { if (j < p.length() && (p.charAt(j) == '?' || p.charAt(j) == s.charAt(i))) { i++; j++; } else if (j < p.length() && p.charAt(j) == '*') { star = j; mark = i; // 先让 * 匹配空串,记下位置 j++; } else if (star != -1) { mark++; // 失配:回到最近的 *,让它多吃 s 的一个字符 i = mark; j = star + 1; } else { return false; } } while (j < p.length() && p.charAt(j) == '*') j++; // s 用完了,p 剩下的必须全是 * return j == p.length(); } }
真实实测 · Java · 前几组用例的输出
$ javac -d build Main.java Solution.java && java -cp build Main < cases.jsonl
["aa","a"]  →  false
["aa","*"]  →  true
["cb","?a"]  →  false
["adceb","*a*b"]  →  true
["acdcb","a*c?b"]  →  false
["",""]  →  true
Go · 解法代码func isMatch(s, p string) bool { i, j := 0, 0 star := -1 // 最近一个 * 在 p 里的位置 mark := 0 // 这个 * 当前"吃到"了 s 的哪个位置之前 for i < len(s) { if j < len(p) && (p[j] == '?' || p[j] == s[i]) { i++ j++ } else if j < len(p) && p[j] == '*' { star, mark = j, i // 先让 * 匹配空串,记下位置 j++ } else if star != -1 { mark++ // 失配:回到最近的 *,让它多吃 s 的一个字符 i, j = mark, star+1 } else { return false } } for j < len(p) && p[j] == '*' { // s 用完了,p 剩下的必须全是 * j++ } return j == len(p) }
真实实测 · Go · 前几组用例的输出
$ go build -o bin . && ./bin < cases.jsonl
["aa","a"]  →  false
["aa","*"]  →  true
["cb","?a"]  →  false
["adceb","*a*b"]  →  true
["acdcb","a*c?b"]  →  false
["",""]  →  true
JavaScript · 解法代码function isMatch(s, p) { let i = 0, j = 0; let star = -1; // 最近一个 * 在 p 里的位置 let mark = 0; // 这个 * 当前"吃到"了 s 的哪个位置之前 while (i < s.length) { if (j < p.length && (p[j] === "?" || p[j] === s[i])) { i++; j++; } else if (j < p.length && p[j] === "*") { star = j; mark = i; // 先让 * 匹配空串,记下位置 j++; } else if (star !== -1) { mark++; // 失配:回到最近的 *,让它多吃 s 的一个字符 i = mark; j = star + 1; } else { return false; } } while (j < p.length && p[j] === "*") j++; // s 用完了,p 剩下的必须全是 * return j === p.length; }
真实实测 · JavaScript · 前几组用例的输出
$ node run.js < cases.jsonl
["aa","a"]  →  false
["aa","*"]  →  true
["cb","?a"]  →  false
["adceb","*a*b"]  →  true
["acdcb","a*c?b"]  →  false
["",""]  →  true
C++ · 解法代码#include <string> using namespace std; class Solution { public: bool isMatch(string s, string p) { int i = 0, j = 0; int star = -1; // 最近一个 * 在 p 里的位置 int mark = 0; // 这个 * 当前"吃到"了 s 的哪个位置之前 while (i < (int)s.size()) { if (j < (int)p.size() && (p[j] == '?' || p[j] == s[i])) { i++; j++; } else if (j < (int)p.size() && p[j] == '*') { star = j; mark = i; // 先让 * 匹配空串,记下位置 j++; } else if (star != -1) { mark++; // 失配:回到最近的 *,让它多吃 s 的一个字符 i = mark; j = star + 1; } else { return false; } } while (j < (int)p.size() && p[j] == '*') j++; // s 用完了,p 剩下的必须全是 * return j == (int)p.size(); } };
真实实测 · C++ · 前几组用例的输出
$ clang++ -std=c++17 -O2 -o bin sol.cpp && ./bin < cases.jsonl
["aa","a"]  →  false
["aa","*"]  →  true
["cb","?a"]  →  false
["adceb","*a*b"]  →  true
["acdcb","a*c?b"]  →  false
["",""]  →  true
真实实测 · 本机执行输出
共 18 组用例(含边界),以暴力解的结果为基准逐组比对:
  Python      通过 18/18
  Java        通过 18/18
  Go          通过 18/18
  JavaScript  通过 18/18
  C++         通过 18/18

耗时实测:贪心的最坏情形也是平方

贪心的最坏情形是:s 全是 a,模式是 * 加 n/2 个 a 再加一个 b:每次让 * 多吃一个字符,都要重新比较 n/2 个 a 才发现最后的 b 不匹配,所以总共是 O(n · n/2)。我用这个数据比较贪心和带记忆化的递归(也是平方),每个规模取三次中最快的一次:

真实实测 · 本机执行输出
s 的长度 n(s 全是 a,p = * + n/2 个 a + b,匹配不上) | 暴力解 | 本文解法
       200 |     3.345 ms | 0.513 ms
       400 |    20.319 ms | 2.155 ms
       800 |    82.269 ms | 9.775 ms
      1600 |   377.533 ms | 40.860 ms

两者每翻一倍规模都增长约 4 到 6 倍,都是平方增长,与理论一致:贪心的平方最坏情形并没有比动态规划更好。贪心快约 6.5 到 9.4 倍,这个差别来自常数(不需要哈希表和递归调用),不是复杂度的差别。贪心的真正优势是空间:O(1),而记忆化的递归需要 O(nm)。

随机对拍

Python 版本另外和记忆化递归做了随机对拍,s 由 a、b 组成,p 由 a、b、?、* 组成:

真实实测 · 本机执行输出
随机对拍 30000 组(s 取自 {a,b}、长 0 到 9,p 取自 {a,b,?,*}、长 0 到 7),solve 与 brute 的结果全部一致。

另外四种语言只在上面 18 组固定用例上验证过,没有做大规模随机对拍。

分步演示:遇到 * 先记下,失配再回来

下面的演示用官方示例 s = "adceb"、p = "*a*b"。* 先匹配空串,a 匹配成功,第二个 * 记下位置 (star = 2, mark = 1);此后 b 与 d、c、e 都不匹配,每次失配就回到第二个 * 让它多吃一个字符,最后 b 匹配上 s 的最后一个字符。每一帧都是把上面的解法在这组输入上真实执行记录下来的,运行器还检查了演示的最终结果与 solve 的返回值一致。

通配符匹配:s = "adceb",p = "*a*b"
当前比较的字符最近一个 * 及它已经吃到的位置

    参考与说明

    • 官方题目页:LeetCode 44. 通配符匹配。本文只用自己的话概括了题意,完整约束以官方页面为准。
    • 数据来自本机真实运行,五种语言的版本写在页脚;耗时对比只用 Python 测量。
    • 没有覆盖:动态规划的五种语言实现;贪心回退只回到最近一个 * 的正确性证明(本文用随机对拍验证,没有给出证明)。
    京ICP备2026067502号-1京公网安备11010502063487号