news 2026/10/21 21:50:49

C语言实现通配符匹配:从递归回溯到动态规划的避坑指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C语言实现通配符匹配:从递归回溯到动态规划的避坑指南

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
二维 DPO(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表打出来。看着那张表,哪一步转移错了、哪个边界没处理,一目了然。比盯着代码空想快十倍。这个方法我在教新人时反复推荐,几乎没人用了之后还说不好使。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/19 13:03:21

Qt串口通信实战:QSerialPort跨平台稳定收发与断线自愈

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/19 8:19:05

Go语言errgroup.Group限制并发数量与错误传播机制

Go语言errgroup.Group限制并发数量与错误传播机制导语在Go的并发编程实践中&#xff0c;我们经常遇到这样的需求&#xff1a;启动一组goroutine并发执行任务&#xff0c;只要有一个失败就立即取消所有其他任务&#xff0c;同时限制最大并发数量以避免资源耗尽。sync.WaitGroup …

作者头像 李华
网站建设 2026/10/21 8:37:48

Go语言sync.Pool对象获取与放回时序与内存泄漏风险

Go语言sync.Pool对象获取与放回时序与内存泄漏风险导语sync.Pool 是Go语言中最容易被误解的标准库组件之一。它被誉为高并发场景下的"性能神器"&#xff0c;但如果使用不当&#xff0c;反而会引发内存泄漏、对象污染、甚至诡异的bug。sync.Pool 的核心语义是&#xf…

作者头像 李华
网站建设 2026/10/18 5:47:25

企业布局 GEO 要避开哪些陷阱,传播易解析 AI 采信底层规则

随着大语言模型、AI智能搜索逐步取代传统关键词检索&#xff0c;成为用户信息获取的核心渠道&#xff0c;企业品牌营销的底层逻辑正在发生根本性、结构性变革。生成式引擎优化(GEO)的核心逻辑&#xff0c;是依托人工智能模型整合全网权威信源&#xff0c;自动生成精准答案、输出…

作者头像 李华