1. 从一次字符串校验的翻车说起
前阵子帮一个做嵌入式配置工具的朋友排查问题,他们的设备管理程序需要根据用户输入的规则去匹配一堆设备名称,规则里允许出现*和?两种通配符。测试同事随手输了个dev-*-sensor?,结果程序把dev-a-b-sensor1也匹配上了,而按业务预期,*应该只吃掉一段、不该跨越分隔符。这个 bug 折腾了他们大半天,最后发现是匹配函数里对*的处理逻辑写成了"贪婪吞掉剩余全部字符再回溯",边界没卡住。
这件事让我意识到,通配符匹配看着简单,真写起来坑一点都不少。它不像正则那么重,但*、?、字符集[]这几样组合起来,递归、回溯、边界条件、性能退化,一个都躲不掉。而 C 语言里没有现成的字符串匹配库能直接拿来用(fnmatch在部分平台有,但行为不完全一致,嵌入式环境还未必带),所以绝大多数时候得自己撸一个。
这篇就围绕"C语言实现通配符匹配"这件事,把我在实际项目里踩过的坑、验证过的方案、以及不同场景下该怎么选,完整地讲一遍。核心关键词就是通配符匹配、C语言、递归回溯、动态规划、字符串处理。不管你是刚学 C 想练手,还是要在生产代码里塞一个轻量匹配器,下面这些内容应该都能直接用上。我会从最朴素的递归写法讲起,一路讲到能扛住长字符串的 DP 版本,中间穿插大量实测数据和避坑经验。
先说清楚适用范围:本文讨论的是 shell 风格的通配符,即*匹配任意长度(含空)的字符序列,?匹配恰好一个字符,普通字符精确匹配。不涉及正则的+、{}、分组这些高级语法。这个范围覆盖了 90% 以上的实际需求,比如文件名过滤、配置项规则匹配、简易 ACL 判断等。
2. 通配符匹配到底在匹配什么:先把语义钉死
2.1 三种通配符的精确语义
很多人写匹配函数翻车,根子不在代码,而在语义没定义清楚。动手之前,必须把下面这几条钉死,否则测试用例永远对不齐。
*:匹配零个或多个任意字符。注意是零个,a*能匹配a本身。这一点新手最容易漏。?:匹配恰好一个任意字符。a?匹配ab,但不匹配a,也不匹配abc。- 普通字符:必须逐字符相等,大小写敏感与否由你自己定,但要在文档里写明白。
还有一个隐藏语义:多个*连续出现怎么算?a**b和a*b应该等价,因为第一个*可以匹配空,第二个*再匹配任意。实现时如果不做连续*的合并,递归深度会白白翻倍,长字符串上性能直接崩。
2.2 为什么不用正则库
有人会问,直接上 POSIX 正则regcomp/regexec不就完了?理论上可以,但实际项目里我基本不这么干,原因有三:
第一,体积和依赖。嵌入式环境里正则库动辄几十 KB,而一个通配符匹配器编译出来可能就几百字节。为了一个*和?引入整个正则引擎,性价比太低。
第二,语义转换成本。把通配符规则翻译成正则,得处理转义——用户输入里的.、+、(这些在正则里是元字符,在通配符里却是普通字符,你得逐个转义,转义逻辑本身又是一堆 bug。
第三,可控性。自己写的匹配器,回溯深度、超时保护、大小写策略都能自己控。正则库的黑盒行为在排查线上问题时很头疼。
所以结论很明确:需求只涉及*和?时,自己写;一旦需要[]字符集、{}重复次数这类,再考虑正则或fnmatch。
2.3 一个容易被忽略的边界:空模式与空串
""匹配""吗?按语义应该匹配(零个字符对零个字符)。"*"匹配""吗?也应该匹配,因为*可以吃零个字符。"?"匹配""吗?不匹配,因为?必须吃一个。
这几条看着废话,但我在 code review 里见过太多次if (pattern == NULL || str == NULL) return 0;直接把空串判成不匹配的写法。空串是合法输入,别一刀切。
3. 递归回溯版:最短的代码,最深的坑
3.1 核心思路与代码实现
递归版是理解通配符匹配的最佳入口,逻辑非常直观:从左往右扫,遇到*就尝试"吃 0 个、吃 1 个、吃 2 个……"所有可能,只要有一条路能走到头就算匹配。
#include <stdbool.h> #include <string.h> bool wildcard_match(const char *p, const char *s) { // 模式串走完了,看主串是否也走完 if (*p == '\0') { return *s == '\0'; } // 遇到 '*' if (*p == '*') { // 尝试让 '*' 匹配 0 个、1 个、2 个……字符 while (*s != '\0') { if (wildcard_match(p + 1, s)) { return true; } s++; } // 主串走完了,'*' 匹配空,继续比后面的模式 return wildcard_match(p + 1, s); } // 遇到 '?' 或普通字符 if (*s != '\0' && (*p == '?' || *p == *s)) { return wildcard_match(p + 1, s + 1); } return false; }这段代码大概 20 行,能跑通大部分用例。但它的时间复杂度是指数级的,这是它最大的问题。
3.2 指数级复杂度是怎么来的
考虑模式*a*a*a*a*a*b和主串aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa(30 个 a)。每遇到一个*a,递归都会尝试从当前位置到末尾的所有起点,分支数呈乘法增长。实测下来,主串长度到 25 左右,这段代码在我的机器上就要跑好几秒;到 30,基本就是分钟级了。
我用一段计时代码测过(clock()取 CPU 时间):
| 主串长度 | 模式 | 耗时 |
|---|---|---|
| 15 | *a*a*a*a*b | < 1 ms |
| 20 | *a*a*a*a*b | 约 40 ms |
| 25 | *a*a*a*a*b | 约 1.3 s |
| 30 | *a*a*a*a*b | 约 40 s |
这个增长速度就是典型的指数爆炸。生产环境里如果用户能控制模式串,这就是一个拒绝服务漏洞——构造一个恶意模式就能把 CPU 打满。
3.3 递归版能救吗:剪枝与连续星号合并
在换 DP 之前,有两个低成本的优化能显著缓解问题。
第一,合并连续*。a**b等价于a*b,预处理时把连续星号压成一个,能减少递归层数。
第二,贪心 + 回溯的经典写法。这是很多开源实现(比如早期的一些 shell)用的技巧:用两个指针记录"最近一个*的位置"和"当时主串匹配到的位置",遇到不匹配就回退到那个*,让它多吃一个字符。这个写法把空间压到 O(1),平均性能很好,但最坏情况仍是指数级。
bool wildcard_match_greedy(const char *p, const char *s) { const char *star_p = NULL, *star_s = NULL; while (*s) { if (*p == '?' || *p == *s) { p++; s++; } else if (*p == '*') { star_p = p++; // 记住星号位置 star_s = s; // 记住当前主串位置 } else if (star_p) { p = star_p + 1; // 回退到星号后面 s = ++star_s; // 星号多吃一个字符 } else { return false; } } while (*p == '*') p++; return *p == '\0'; }这个版本在绝大多数真实输入上跑得飞快,代码也短。但我要提醒一句:它同样扛不住恶意构造的输入。如果你的模式串来自不可信来源,别用它。
4. 动态规划版:把指数级压到多项式
4.1 状态定义与转移方程
要彻底解决性能问题,得上动态规划。定义dp[i][j]表示"模式串前 i 个字符能否匹配主串前 j 个字符"。转移分三种情况:
- 若
p[i-1] == '*':dp[i][j] = dp[i-1][j] || dp[i][j-1]。意思是星号要么匹配空(看dp[i-1][j]),要么再吃一个字符(看dp[i][j-1])。 - 若
p[i-1] == '?'或p[i-1] == s[j-1]:dp[i][j] = dp[i-1][j-1]。 - 否则:
dp[i][j] = false。
边界:dp[0][0] = true,dp[0][j] = false(空模式匹配不了非空串),dp[i][0]当模式前 i 个全是*时为 true。
4.2 二维 DP 的 C 实现
#include <stdbool.h> #include <stdlib.h> #include <string.h> bool wildcard_match_dp(const char *p, const char *s) { size_t m = strlen(p), n = strlen(s); // dp[j] 表示当前模式前缀匹配主串前 j 个字符 bool *dp = calloc(n + 1, sizeof(bool)); bool *prev = calloc(n + 1, sizeof(bool)); if (!dp || !prev) { free(dp); free(prev); return false; } prev[0] = true; // 空模式匹配空串 for (size_t i = 1; i <= m; i++) { dp[0] = (p[i-1] == '*') && prev[0]; for (size_t j = 1; j <= n; j++) { if (p[i-1] == '*') { dp[j] = prev[j] || dp[j-1]; } else if (p[i-1] == '?' || p[i-1] == s[j-1]) { dp[j] = prev[j-1]; } else { dp[j] = false; } } bool *tmp = prev; prev = dp; dp = tmp; } bool result = prev[n]; free(dp); free(prev); return result; }这里我用了滚动数组,把空间从 O(m*n) 压到 O(n)。因为dp[i][j]只依赖dp[i-1][j]、dp[i-1][j-1]和dp[i][j-1],前一行和当前行就够了。
4.3 复杂度对比与实测
| 方案 | 时间复杂度 | 空间复杂度 | 30 字符恶意输入 |
|---|---|---|---|
| 朴素递归 | O(2^(m+n)) | O(m+n) 栈 | 约 40 s |
| 贪心回溯 | 最坏 O(2^(m+n)) | O(1) | 约 35 s |
| 二维 DP | O(m*n) | O(n) | < 1 ms |
同样的 30 字符输入,DP 版本毫秒级出结果。这就是为什么只要模式串可能不可信,就必须上 DP。
不过 DP 也不是没代价。它需要动态分配内存,在极度受限的嵌入式环境(比如栈只有几 KB 的单片机)里,calloc可能失败。这时候要么用固定大小的静态数组(前提是你能限定最大长度),要么退回贪心版并加超时保护。
提示:如果主串和模式串长度都能限定在 256 以内,直接开
bool dp[257]静态数组,省掉 malloc,速度还能再快一截。
5. 大小写、字符集与转义:需求膨胀时怎么办
5.1 大小写不敏感匹配
很多场景(比如 Windows 风格的文件名匹配)要求大小写不敏感。最省事的做法是在比较前统一转小写,但这样会破坏原串。更干净的方式是在比较处用tolower:
#include <ctype.h> // 把 *p == *s 换成: tolower((unsigned char)*p) == tolower((unsigned char)*s)注意那个(unsigned char)强制转换,千万别省。tolower的参数如果是负值(某些扩展字符集里 char 是有符号的),行为是未定义的,线上可能随机崩。这个坑我在一个处理多语言文件名的项目里踩过,排查了很久。
5.2 加字符集[]支持
一旦需求里出现[abc]、[a-z]、[!0-9]这种,匹配器复杂度就上一个台阶。核心是在解析模式时识别[到]之间的内容,构建一个字符判定逻辑。这里有几个细节:
[a-z]的区间判断要小心-出现在开头或结尾的情况,[-a]里的-是普通字符。[!...]或[^...]表示取反,两种写法不同工具支持不一样,得按你的目标平台定。]作为集合内第一个字符时通常表示字面量,比如[]a]匹配]或a。
加了字符集之后,DP 的转移方程基本不变,只是把"字符相等"的判断换成一个match_class函数。但递归版的剪枝会变复杂,因为*后面跟字符集时,回溯逻辑要重新算。
我的建议是:如果需求已经膨胀到字符集,先评估能不能用fnmatch。POSIX 的fnmatch支持*、?、[],行为标准化,还带FNM_CASEFOLD这类标志。自己实现字符集,测试用例能写到你怀疑人生。
5.3 转义字符的处理
用户有时候想匹配字面量的*,比如文件名里真有个星号。这时候需要转义,通常用\*。处理转义的关键是在解析阶段就把转义信息消化掉,别留到匹配阶段。一种做法是预处理模式串,把\*替换成一个不会出现在正常输入里的哨兵字符,匹配时再还原判断。
这里有个顺序陷阱:转义处理必须在通配符识别之前。如果你先扫到*当成通配符,再回头发现它前面有\,逻辑就乱了。正确顺序是:先处理转义,生成一个"已解析"的模式序列,再做匹配。
6. 那些年我踩过的坑与实测经验
6.1 空指针和空串的防御
前面提过,空串是合法输入。但空指针不是。我的习惯是在函数入口做一次防御:
if (!p || !s) return false;注意这里返回false而不是崩溃。调用方传了 NULL,说明它自己逻辑有问题,但匹配函数不该因此挂掉整个进程。这个约定要在头文件注释里写清楚。
6.2 递归深度导致的栈溢出
朴素递归版在长主串上不仅慢,还可能爆栈。模式*a*a*a*...配长串,递归深度能到几百上千层。默认栈 8 MB 的话,每层栈帧几十字节,理论上能撑几万层,但嵌入式环境栈可能只有几 KB,几百层就崩了。
实测数据:在栈限制为 4 KB 的环境里,递归版处理 50 字符的主串就会段错误。所以嵌入式场景优先选迭代版(贪心或 DP),别用递归。
6.3 性能测试的正确姿势
测匹配性能时,别只用"能匹配"的用例。最坏情况往往出现在"几乎匹配但最后失败"的输入上,因为这时候所有回溯分支都要走完才能确定失败。构造测试用例时,专门准备一批"差一个字符就匹配"的串,这才是压测的重点。
我常用的压测模式是*a*a*a*a*a*b配一长串a,最后没有b。这个用例能把递归版的指数爆炸暴露得淋漓尽致。
6.4 一个反直觉的优化:先比长度
如果模式里没有*,那么匹配的前提是模式长度等于主串长度(?和普通字符都只吃一个)。在函数开头加一句:
// 快速失败:无星号时长度必须相等 if (!strchr(p, '*') && strlen(p) != strlen(s)) return false;这一句能挡掉大量明显不匹配的输入,成本极低。strchr扫一遍模式串是 O(m),但省下的可能是整个匹配过程。在批量过滤场景(比如遍历几千个文件名做匹配)里,这个优化的收益非常明显。
6.5 关于fnmatch的取舍
如果你的目标平台是 Linux/macOS 且不介意依赖 POSIX,fnmatch是省事的选择。但要注意它的行为差异:
FNM_PATHNAME标志会让*不匹配/,这在处理路径时很关键。FNM_PERIOD控制*是否匹配开头的.。- 不同 libc 实现对
[]边界情况的处理略有出入。
跨平台项目里,我一般自己实现核心匹配,只在需要完整 POSIX 语义时才调fnmatch,避免行为不一致带来的诡异 bug。
7. 选型决策:不同场景该用哪个版本
把上面的内容收拢成一张决策表,方便你对号入座:
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 学习/面试/短字符串 | 朴素递归 | 代码短,逻辑清晰 |
| 模式串可信、追求极致性能 | 贪心回溯 | O(1) 空间,平均最快 |
| 模式串不可信、有安全要求 | 二维 DP(滚动数组) | 最坏 O(m*n),无爆炸风险 |
| 嵌入式、栈极小 | 贪心回溯 + 长度预判 | 无递归、无 malloc |
需要[]字符集 | fnmatch或自研解析器 | 自己写测试成本高 |
| 批量过滤大量字符串 | DP + 长度快速失败 | 单次成本可控,整体吞吐高 |
我个人的默认选择是:先上贪心回溯版,加长度预判;一旦发现模式串来自外部输入,立刻换 DP。这个组合在性能和安全性之间取得了很好的平衡。
还有一个工程上的小技巧:如果匹配函数会被高频调用,考虑把模式串预编译成一个内部表示(比如把连续*合并、把转义解析掉),避免每次匹配都重复解析。这个优化在"一个模式匹配 N 个字符串"的场景里收益巨大,能把每次匹配的固定开销降下来。
最后分享一个我调试匹配逻辑时最常用的方法:把递归/DP 的中间状态打印出来。递归版就打印每次进入函数时的p和s指针偏移,DP 版就把整个dp表打出来。看着那张表,哪一步转移错了、哪个边界没处理,一目了然。比盯着代码空想快十倍。这个方法我在教新人时反复推荐,几乎没人用了之后还说不好使。