通配符匹配算法详解:从递归到动态规划的C语言实现与优化

发布时间:2026/10/15 4:53:26
通配符匹配算法详解:从递归到动态规划的C语言实现与优化 1. 通配符匹配到底在解决什么问题第一次接触“通配符匹配”这个概念很多人会以为它只是字符串处理里的一个小工具函数实际动手写一遍才知道它背后牵扯的是递归设计、动态规划、边界条件处理、性能优化这一整条链路。我在早期做文件批量处理工具的时候就因为通配符匹配写得不够健壮导致一批文件被错误归类排查了大半天才发现是*和?的组合逻辑出了问题。所谓通配符匹配就是给定一个模式串pattern和一个目标串text判断模式串能否完整匹配目标串。这里有两个核心通配符?表示匹配任意单个字符*表示匹配任意长度包括零个的字符序列。注意这里的*和正则表达式里的*语义完全不同——正则里的*是修饰前一个字符的重复次数而通配符匹配里的*是独立代表一段任意序列。这个区别是很多人第一次写的时候最容易搞混的地方。这个内容适合谁看如果你正在准备技术面试通配符匹配是一道非常经典的算法题考察的是你对递归和动态规划的理解深度如果你在做文件系统工具、日志过滤、命令行参数解析、路由匹配这类实际工程通配符匹配是绕不开的基础能力哪怕你只是想提升自己的C语言功底这道题也是一个非常好的练手项目因为它能把指针操作、内存管理、递归栈这些知识点全部串起来。我下面会从设计思路、核心细节、完整实现、问题排查几个维度把通配符匹配这件事讲透。代码全部用C语言写每个关键决策我都会解释为什么这么做踩过的坑也会一并分享出来。2. 整体设计思路与方案选型2.1 为什么优先考虑递归方案拿到这个问题最直觉的思路就是递归。为什么因为通配符匹配本身就是一个自相似的问题当模式串遇到*的时候它可以匹配目标串的零个字符、一个字符、两个字符……一直到剩余全部字符每一种选择都对应一个规模更小的子问题。这种结构天然适合用递归来表达。递归方案的核心逻辑是这样的从左到右同时扫描模式串和目标串如果当前字符匹配字符相同或者模式字符是?就继续比较下一个如果模式字符是*就尝试让这个*匹配目标串的零个、一个、两个……字符只要有一种情况能让剩余部分匹配成功整体就返回成功。递归方案的优点是代码直观、容易理解、写起来快适合快速验证逻辑。但缺点也很明显时间复杂度在最坏情况下会退化到指数级。比如模式串是*a*a*a*a*a*a*a*a而目标串是一长串a递归会反复计算相同的子问题性能急剧下降。我实测过一个长度为30的目标串配上这种模式递归版本跑了将近两秒才出结果这在工程里是不可接受的。2.2 动态规划方案的优势与代价为了解决递归的性能问题动态规划是更稳妥的选择。DP的核心思想是用空间换时间把子问题的结果存下来避免重复计算。具体来说定义dp[i][j]表示模式串的前i个字符能否匹配目标串的前j个字符。状态转移分三种情况如果pattern[i-1]是普通字符或?那么dp[i][j] dp[i-1][j-1] (pattern[i-1] ? || pattern[i-1] text[j-1])如果pattern[i-1]是*那么dp[i][j] dp[i-1][j] || dp[i][j-1]。这里dp[i-1][j]表示*匹配零个字符dp[i][j-1]表示*至少匹配一个字符边界条件dp[0][0] truedp[0][j] false空模式不能匹配非空串dp[i][0]只有在模式串前i个字符全是*时才为 trueDP方案的时间复杂度是O(m*n)空间复杂度也是O(m*n)其中m是模式串长度n是目标串长度。对于绝大多数实际场景这个复杂度完全够用。如果对空间有更高要求还可以把二维数组压缩成一维因为每次状态转移只依赖上一行和当前行的前一个位置。2.3 两种方案的取舍建议那到底该选哪个我的经验是这样面试场景优先写DP因为面试官通常想看你对状态转移的理解而且DP的性能分析更清晰快速原型开发可以用递归代码量少改起来方便生产环境一律用DP并且要考虑空间优化因为生产环境的数据规模不可控。还有一个容易被忽略的点如果模式串里没有*那么匹配过程是确定性的不需要回溯也不需要DP直接逐字符比较就行。这个特判能省掉大量不必要的计算。我在实际项目里加了这个判断之后纯?模式的匹配速度提升了将近十倍。3. 核心细节解析与实操要点3.1 递归方案的关键实现细节先看递归方案的代码骨架。我用C语言写一个最基础的版本#include stdbool.h #include string.h bool isMatchRecursive(const char *s, const char *p) { if (*p \0) { return *s \0; } if (*p *) { // 尝试匹配零个或多个字符 while (*s ! \0) { if (isMatchRecursive(s, p 1)) { return true; } s; } return isMatchRecursive(s, p 1); } if (*s ! \0 (*p ? || *p *s)) { return isMatchRecursive(s 1, p 1); } return false; }这段代码有几个细节值得说。第一*p \0的判断必须放在最前面因为空模式只能匹配空串这是递归的终止条件之一。第二处理*的时候我先用循环尝试让*匹配零个、一个、两个……字符每次都用剩余的s和p1去递归。注意循环结束后还要再调一次isMatchRecursive(s, p 1)因为当s走到末尾时循环会退出但此时*匹配零个字符的情况还需要检查。第三普通字符匹配时一定要先判断*s ! \0否则会越界读取。注意递归方案里*的处理顺序很关键。必须先尝试匹配零个字符还是先尝试匹配多个字符其实顺序不影响正确性但影响性能。我实测下来先尝试匹配零个字符也就是先递归p1通常更快因为很多情况下*确实只需要匹配很少的字符。3.2 动态规划的状态转移推导DP方案的核心在于把状态转移方程推导清楚。我用一个具体例子来演示。假设模式串是a*b?c目标串是axxbzc。先建表行是模式串长度5列是目标串长度6加上第0行第0列表示空串所以是6x7的表格。初始化dp[0][0] true第一行除了dp[0][0]全是 false第一列要看模式串前缀是否全是*。然后逐行填表。对于pattern[i-1]是*的情况dp[i][j] dp[i-1][j] || dp[i][j-1]。这个转移的意思是要么*匹配零个字符看上一行同一列要么*至少匹配一个字符看同一行前一列。这里有个容易写错的地方dp[i][j-1]这个条件要求j 0否则会数组越界。同理dp[i-1][j]要求i 0。在代码里必须加边界判断或者把数组开大一圈用偏移量处理。3.3 空间优化的具体做法二维DP的空间是O(m*n)如果模式串和目标串都很长内存占用会很可观。比如两个长度都是10000的串二维数组就是一亿个布尔值差不多100MB这在嵌入式环境或者内存受限的场景下是不可接受的。优化思路是观察到dp[i][j]只依赖dp[i-1][j-1]、dp[i-1][j]、dp[i][j-1]这三个位置也就是上一行和当前行的前一个位置。所以可以用两个一维数组滚动更新空间降到O(n)。更进一步如果仔细分析转移方程会发现对于*的情况dp[i][j]依赖的是同一行的dp[i][j-1]这意味着可以只用一个一维数组从前往后更新。但普通字符的情况依赖上一行的dp[i-1][j-1]这个值在更新当前行时会被覆盖所以需要用一个临时变量存一下。这个技巧比较绕我建议先把二维版本写对再考虑优化。4. 完整实操过程与核心环节实现4.1 动态规划版本的完整代码下面是我在实际项目中反复打磨过的DP版本包含了边界处理和空间优化#include stdbool.h #include stdlib.h #include string.h bool isMatch(const char *s, const char *p) { int m strlen(p); int n strlen(s); // 特判模式串为空 if (m 0) { return n 0; } // 分配二维DP数组 bool **dp (bool **)malloc((m 1) * sizeof(bool *)); for (int i 0; i m; i) { dp[i] (bool *)calloc(n 1, sizeof(bool)); } // 初始化边界 dp[0][0] true; for (int i 1; i m; i) { if (p[i - 1] *) { dp[i][0] dp[i - 1][0]; } } // 填表 for (int i 1; i m; i) { for (int j 1; j n; j) { if (p[i - 1] *) { dp[i][j] dp[i - 1][j] || dp[i][j - 1]; } else if (p[i - 1] ? || p[i - 1] s[j - 1]) { dp[i][j] dp[i - 1][j - 1]; } } } bool result dp[m][n]; // 释放内存 for (int i 0; i m; i) { free(dp[i]); } free(dp); return result; }这段代码里calloc的使用很关键它会把数组初始化为全零也就是 false省去了手动初始化的步骤。边界初始化那段dp[i][0] dp[i-1][0]的意思是如果模式串前i个字符全是*那么它可以匹配空串一旦遇到非*字符后面的dp[i][0]就都是 false 了。4.2 空间优化版本的实现如果内存紧张可以用一维数组优化。核心思路是用一个prev变量保存左上角的值bool isMatchOptimized(const char *s, const char *p) { int m strlen(p); int n strlen(s); bool *dp (bool *)calloc(n 1, sizeof(bool)); dp[0] true; for (int i 1; i m; i) { bool prev dp[0]; // 保存 dp[i-1][0] dp[0] (p[i - 1] *) ? dp[0] : false; for (int j 1; j n; j) { bool temp dp[j]; // 保存 dp[i-1][j] if (p[i - 1] *) { dp[j] dp[j] || dp[j - 1]; } else if (p[i - 1] ? || p[i - 1] s[j - 1]) { dp[j] prev; } else { dp[j] false; } prev temp; } } bool result dp[n]; free(dp); return result; }这个版本的空间复杂度降到了O(n)。prev变量在每一行开始时保存dp[i-1][0]在列循环中保存dp[i-1][j-1]。这个技巧需要仔细理解写错了很容易出bug。我的建议是先用二维版本验证逻辑再对照着改成一维。4.3 测试用例设计与验证写完代码一定要用充分的测试用例验证。我整理了一组覆盖各种边界情况的用例模式串目标串期望结果考察点aatrue基础匹配abfalse基础不匹配?atrue单字符通配?false空串边界*true星号匹配空串*abctrue星号匹配多字符a*babtrue星号匹配零个字符a*baxxxbtrue星号匹配多个字符a*b*caXbYctrue多个星号*a*b*xaybztrue星号在两端a?cabctrue问号与字符混合a?cacfalse问号必须匹配一个字符**abctrue连续星号*?*atrue星号问号组合提示连续星号的情况值得特别注意。**和*在语义上是等价的但DP转移时如果没处理好可能会出问题。我的做法是在预处理阶段把连续的*合并成一个这样能减少状态数量提升性能。5. 常见问题与排查技巧实录5.1 递归版本栈溢出怎么办递归版本在模式串很长的时候会栈溢出。我遇到过一次模式串有几百个*目标串也很长程序直接崩溃。排查的时候用调试器看调用栈发现递归深度已经超过了几万层。解决办法有两个一是改用DP从根本上避免深递归二是如果非要用递归可以把递归改成用显式栈的迭代版本。迭代版本需要手动维护一个栈来保存待处理的状态代码复杂度会上升不少但能避免栈溢出。还有一个取巧的办法是加记忆化用一个哈希表或者二维数组缓存已经计算过的(i, j)状态。这样虽然还是递归但每个状态只算一次递归深度虽然没变但重复调用少了实际执行时间会大幅下降。不过记忆化本身需要额外的空间而且哈希表的开销也不小所以综合来看还是DP更划算。5.2 星号匹配顺序导致的错误这是我最开始写递归版本时踩的坑。当时我的逻辑是遇到*就先尝试匹配一个字符如果不行再尝试匹配零个。结果在某些用例上返回了错误的结果。后来分析发现问题出在回溯的顺序上。假设模式是*a目标是ba。如果先尝试让*匹配b然后比较a和a成功但如果目标是ab先让*匹配a然后比较a和b失败这时候需要回溯让*匹配零个字符再比较a和a成功。如果代码里没有正确回溯就会漏掉这种情况。修正的方法就是我在3.1节里写的那样用循环穷举*匹配的所有可能长度每种长度都递归尝试一次。虽然性能差一些但正确性有保证。5.3 DP数组越界的典型场景DP版本最容易出的bug就是数组越界。常见的有两种情况一是初始化第一列时没有判断i 0二是填表时访问dp[i][j-1]没有判断j 0。我的习惯是在写DP的时候先把数组开成(m1) x (n1)然后所有下标都从1开始0表示空串。这样dp[i][j]对应的是模式串前i个字符和目标串前j个字符访问dp[i-1][j-1]时i-1和j-1最小是0不会越界。这个习惯能避免大部分边界问题。5.4 性能瓶颈的定位与优化如果DP版本跑得慢首先要看是不是模式串或目标串特别长。O(m*n)的复杂度在m和n都是几千的时候还能接受但到了几万就明显卡顿了。优化的方向有几个一是合并连续的*减少模式串长度二是如果模式串里没有*直接用线性扫描三是如果模式串里没有?也没有*直接字符串比较四是如果目标串为空只需要检查模式串是否全是*。我实测过一个案例模式串是*abc*def*目标串是一万个字符。合并连续星号后模式串长度没变但加了“无星号特判”之后对于不含星号的模式串匹配速度提升了两个数量级。所以特判虽然简单但收益很大。5.5 常见问题速查表问题现象可能原因排查方法解决方案结果错误某些用例不通过星号回溯逻辑不完整用*a匹配ab测试穷举星号匹配长度程序崩溃栈溢出递归深度过大调试器看调用栈改用DP或迭代结果错误空串处理不对边界条件遗漏测试空模式空串补充dp[0][0]初始化性能差大输入超时重复计算子问题打印递归调用次数加记忆化或改DP数组越界下标未做边界判断用地址检查工具数组开大一圈下标从1开始连续星号结果异常未合并连续星号测试**模式预处理合并星号注意调试通配符匹配的时候建议把DP表格打印出来看。二维表格能直观地展示状态转移过程比单步调试高效得多。我通常会在填表循环里加一个打印语句把每一步的dp值输出到控制台一眼就能看出哪里的转移不对。6. 从面试题到工程实践的延伸思考通配符匹配在面试里是一道题但在工程里它是一类问题的缩影。我后来做日志过滤系统的时候就用到了通配符匹配来支持用户配置过滤规则。用户可以用error*timeout这样的模式来匹配日志行背后就是通配符匹配在支撑。工程实践和面试题最大的区别在于输入规模不可控和性能要求明确。面试题里目标串可能就几十个字符工程里可能是几MB的日志文件。所以工程版本必须做优化必须考虑内存占用必须处理各种异常输入。另一个区别是功能扩展。面试题只要求?和*但工程里可能需要支持字符集[abc]、排除集[!abc]、转义字符\*等等。这些扩展会让匹配逻辑复杂很多但核心思路还是一样的把模式串解析成一个个匹配单元然后逐个单元去匹配目标串。如果要把这个项目继续扩展我建议的方向是支持字符集和转义、支持大小写不敏感匹配、支持多模式同时匹配比如Aho-Corasick算法、支持流式匹配目标串不是一次性给全而是分块到达。每一个方向都能写出一篇独立的文章但通配符匹配的基础打牢了后面这些都是水到渠成的事。我在实际使用中的一个体会是先把最简单的版本写对再考虑优化。很多人一上来就想写最优解结果边界条件没处理好调试半天。不如先用递归写一个能跑通的版本用测试用例验证正确性然后再改成DP再优化空间再特判加速。每一步都有验证每一步都可回退这样最稳。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询