面试题19:正则表达式匹配
围绕点号和星号的完整串匹配,推导作者三分支递归、备忘录与二维动态规划,并用30组官方测试锁定边界。
学习目标
- 能用三分支递归或 DP 实现正则表达式(点号和星号)完整匹配
- 能解释星号将状态拆成"匹配零次"和"匹配多次"的分支
- 能处理模式与字符串同时到达末尾才成功的终止条件
从“匹配一部分还是匹配全部”开始
先预测:字符串 aaa 与模式 a.a 匹配,但字符串 aaa 与模式 .a 不匹配。点号明明能代表任意字符,第二个模式为什么仍失败?
本题的“正则表达式匹配”不是在字符串里寻找一个可匹配子串,而是要求模式覆盖字符串的全部字符。模式 .a 只能消费两个字符,字符串还剩一个 a,因此失败。只有字符串和模式同时到达末尾才成功,这种契约称为↡。
题目只支持三种模式元素:普通字符必须与自身相同;消费任意一个字符;修饰它前面的普通字符或点号,使该项可以出现零次或多次。换句话说,点号匹配任意字符,但不能匹配空;星号表示前一字符出现零次或多次,自身不能独立消费字符。
例如 ab* 可以匹配 a、ab、abb,却不能匹配 b;因为 b* 可以取零次,而开头的 a 仍必须出现。模式 .* 可以匹配任意长度字符串,包括空串;点号负责每次消费一个字符,星号决定重复次数。
这种受限语法没有括号、选择、字符类和锚点,不能把成熟正则引擎的全部规则搬进来。题目考查的是两个位置如何推进,以及星号怎样制造选择;先把输入语言边界缩清楚,才能让状态定义、证明和测试保持一致。
↡把一个状态拆成三种选择
用 i 表示当前输入后缀起点,j 表示当前模式后缀起点。当前模式不是星号组合时,只有当前字符相同或模式为点号,才能把两个位置都前进一步。定义当前字符是否兼容:
当模式下一字符是星号时,作者源码在当前字符兼容的前提下显式枚举三种递归分支:
- 消费当前输入字符并越过整个星号组合,表示前导项这一次就是最后一次。
- 消费当前输入字符但模式位置不动,表示前导项还可能继续重复。
- 输入位置不动、模式越过两格,表示前导项出现零次。
| 作者分支 | 下一个状态 | 语义 |
|---|---|---|
| 消费一次并越过x* | str + 1, pattern + 2 | 把当前字符视为该段最后一次 |
| 消费一次并保留x* | str + 1, pattern | 继续允许同一前导项匹配更多字符 |
| 跳过整个x* | str, pattern + 2 | 让前导项出现零次 |
若 R(i,j) 表示两个后缀能否完整匹配,当前字符兼容且模式下一位是星号时,作者三分支可写成:
第一分支在逻辑上是冗余的:第二分支消费一个字符并保留星号,下一层仍可选择第三分支越过星号,所以“两分支”实现也能覆盖恰好一次。不过忠实阅读作者源码时应认识三条路径各自语义,再说明为什么工程实现可以合并,而不是漏抄一条后宣称完全相同。
若当前字符不兼容,星号组合只能取零次,转到 R(i,j+2)。若没有星号,兼容时转到 R(i+1,j+1),不兼容立即失败。每个递归分支至少推进输入位置或模式位置,因此合法有限输入最终会到达边界。
两个↡决定完整匹配
字符串和模式同时结束时返回 true。模式先结束而字符串未结束时返回 false。字符串先结束但模式未结束时不能立即失败,因为剩余模式可能全部由 x* 组合构成,例如空串与 cab* 匹配。
作者代码没有单独写“字符串先结束”的分支。它继续检查模式下一位是否为星号;若是,就不断取零次越过组合,最终到达双空成功。点号分支还额外要求输入当前字符不是结束符,避免点号错误消费空字符。
模式以星号开头或含连续星号不在作者测试契约中。源码读取 pattern 后一位是安全的,因为 C 字符串末尾还有结束符;但看到星号时默认它有合法前导项。生产接口应先验证模式:首位不能是星号,任一星号前一位不能也是星号。非法模式是返回 false 还是抛参数错误必须由 API 约定。
忠实递归与备忘录实现
下面使用字符串视图和下标复刻作者分支,并加入二维。缓存值 0 表示未知,1 表示失败,2 表示成功。为突出作者结构,星号兼容时仍保留三分支。
#include <cstdint>
#include <string_view>
#include <vector>
bool matchCore(std::string_view text,
std::string_view pattern,
std::size_t i,
std::size_t j,
std::vector<std::vector<std::uint8_t>>& memo) {
auto& cached = memo[i][j];
if (cached != 0) return cached == 2;
bool result = false;
if (j == pattern.size()) {
result = i == text.size();
} else {
const bool currentMatches =
i < text.size() &&
(pattern[j] == '.' || pattern[j] == text[i]);
const bool followedByStar =
j + 1 < pattern.size() && pattern[j + 1] == '*';
if (followedByStar) {
if (currentMatches) {
result =
matchCore(text, pattern, i + 1, j + 2, memo) ||
matchCore(text, pattern, i + 1, j, memo) ||
matchCore(text, pattern, i, j + 2, memo);
} else {
result = matchCore(text, pattern, i, j + 2, memo);
}
} else {
result = currentMatches &&
matchCore(text, pattern, i + 1, j + 1, memo);
}
}
cached = result ? 2 : 1;
return result;
}
bool fullMatch(std::string_view text, std::string_view pattern) {
std::vector memo(
text.size() + 1,
std::vector<std::uint8_t>(pattern.size() + 1, 0));
return matchCore(text, pattern, 0, 0, memo);
}递归状态只由 i、j 决定,一共有 (M+1)(N+1) 种。备忘录让每种状态最多展开一次,时间和空间都是 O(MN),递归栈最深 O(M+N)。不加缓存时,不同星号选择会反复抵达相同后缀,最坏搜索树指数增长。
以 aab 与 cab 为例,先让 c* 取零次;a* 连续消费两个 a;再让 a* 结束;最后普通 b 与 b 同时消费。每一步都只依赖当前两个后缀,所以同一状态无论由哪条路径到达,其真假结果都相同,正适合缓存。
从后缀递归翻转为前缀动态规划
也可以定义 D[i][j]:输入前 i 个字符能否与模式前 j 个字符完整匹配。双空状态为真,非空输入与空模式为假:
普通字符或点号要求当前字符兼容,并继承左上角状态:
若模式第 j 个字符是星号,它修饰第 j-1 个字符。取零次时删除模式末尾两项,查看 D[i][j-2];取一次或多次时,当前输入字符必须兼容前导项,并让星号继续匹配更短输入:
初始化空输入这一行时,a*、ab 等模式可以逐段取零次:模式当前位置为星号时令 D[0][j]=D[0][j-2]。其他空输入状态保持假。计算顺序按 i 从 0 到 M、j 从 1 到 N,使当前格依赖的左侧和上一行都已完成。
#include <string_view>
#include <vector>
bool fullMatchDp(std::string_view text, std::string_view pattern) {
const std::size_t m = text.size();
const std::size_t n = pattern.size();
std::vector dp(m + 1, std::vector<bool>(n + 1, false));
dp[0][0] = true;
for (std::size_t j = 2; j <= n; ++j) {
if (pattern[j - 1] == '*') {
dp[0][j] = dp[0][j - 2];
}
}
for (std::size_t i = 1; i <= m; ++i) {
for (std::size_t j = 1; j <= n; ++j) {
if (pattern[j - 1] != '*') {
const bool same =
pattern[j - 1] == '.' ||
pattern[j - 1] == text[i - 1];
dp[i][j] = same && dp[i - 1][j - 1];
} else if (j >= 2) {
const bool same =
pattern[j - 2] == '.' ||
pattern[j - 2] == text[i - 1];
dp[i][j] =
dp[i][j - 2] ||
(same && dp[i - 1][j]);
}
}
}
return dp[m][n];
}二维 DP 同样需要 O(MN) 时间和空间。每格只依赖上一行与当前行左侧,可以滚动成两行 O(N) 空间;但星号的 D[i][j-2]来自正在构造的当前行,更新顺序必须从左到右。若强行单数组原地更新,还要保存被覆盖的左上角旧值,代码更易错。
作者30组测试锁定哪些边界
官方源码从空串开始,逐渐增加普通字符、点号、星号和多个星号段,共有30组断言。空串与空模式、空串与 .、空串与 .、空串与 c 四组首先锁定终止规则和星号零次。单字符 a 再分别与 .、a.、空模式、点号和 ab 比较,锁定完整匹配。
中段测试 aa、ab、aaa,覆盖星号消费一次、多次、点号消费恰好一次以及普通字符不匹配。后段的 cab、abac*a、.aa 迫使算法在多个零次与多次选择间回溯,可发现只走贪心路径或漏写分支的错误。
值得注意的是作者 Test15 和 Test16 都是 ab 与 .*,属于重复测试。忠实清单应保留它们确实存在这一事实,但工程套件可以补充更有区分度的用例,例如换行字符契约、非法模式、长重复前缀和备忘录性能。
#include <cassert>
#include <utility>
#include <vector>
void testRegularExpressions() {
const std::vector<std::pair<std::pair<const char*, const char*>, bool>> cases{
{{{"", ""}}, true},
{{{"", ".*"}}, true},
{{{"", "."}}, false},
{{{"", "c*"}}, true},
{{{"a", "ab*"}}, true},
{{{"aa", "a*"}}, true},
{{{"aaa", "a.a"}}, true},
{{{"aaa", "ab*a"}}, false},
{{{"aab", "c*a*b"}}, true},
{{{"aaca", "ab*a*c*a"}}, true},
{{{"aaba", "ab*a*c*a"}}, false},
{{{"bbbba", ".*a*a"}}, true},
{{{"bcbbabab", ".*a*a"}}, false},
};
for (const auto& [input, expected] : cases) {
assert(fullMatch(input.first, input.second) == expected);
assert(fullMatchDp(input.first, input.second) == expected);
}
}实际提交应把作者30组全部逐项移植,并让递归备忘录版与 DP 版交叉校验。还要构造长输入 aaaaa...b 与模式 aaa*...c:朴素递归会产生巨大搜索树,优化版应在状态数上界内稳定结束。性能测试必须限制输入规模,避免把无缓存版本拖垮整个测试进程。
正确性与复杂度证明
递归正确性可按剩余模式长度归纳。普通项只有一种合法消费方式;星号项的合法匹配按重复次数分为零次和至少一次,两类互斥且覆盖全部可能。作者把至少一次再分为“本次结束”和“还会继续”,仍然覆盖同一集合。每个分支推进位置,最终边界准确判断完整消费。
DP 是同一分类的反向计算。D[i][j-2]覆盖星号前导项零次,same 与 D[i-1][j]覆盖至少一次;普通项取左上角。状态按依赖顺序填表,所以由更短前缀正确性可推出当前前缀正确,最终 D[M][N]就是整串答案。
备忘录与 DP 的状态数量均为 (M+1)(N+1),每状态只做常数次判断,因此时间 O(MN)。二维表空间 O(MN);递归另有 O(M+N) 调用栈,滚动 DP 可把表空间降到 O(N)。模式验证本身线性,不改变总复杂度。
工程上还要明确字符单位。按字节遍历 UTF-8 时,点号匹配的是一个字节而非一个 Unicode 字符;若接口承诺按字符匹配,应先解码为码点或使用成熟正则引擎。本题作者使用 char,讨论的是窄字符序列,不能直接推广到所有文本语义。
本章练习
练习
问题 1: 完整匹配与子串匹配的区别是什么?
问题 2: 星号的两种分支各处理什么情况?
问题 3: 递归匹配如何避免重复计算?
本章回顾
- 正则表达式匹配要求模式消费整个输入,不是搜索任意子串。
- 点号匹配任意一个非空字符,星号表示前一字符出现零次或多次。
- 作者在星号兼容时显式写了消费后越过、消费后保留、零次跳过三条递归分支。
- 两分支写法可把“恰好一次”并入消费后保留再跳过,但必须证明等价。
- 双空成功;模式先结束失败;字符串先结束仍可能被剩余星号段匹配。
- 备忘录和二维 DP 都把指数回溯降为 O(MN) 状态计算。
- 非法首星号、连续星号、UTF-8字符单位和长输入是工程接口额外边界。
- 作者30组测试锁定空串、点号、星号零次/多次和多段回溯语义。
名词解释
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 完整匹配
- 模式覆盖字符串全部字符,两者同时到达末尾。
- 星号
- 前一字符出现零次或多次的通配符。
- 终止条件
- 字符串和模式为空或星号的分支结束条件。