1. 从一道蓝桥杯真题说起:正则表达式也能用DFS解?
第一次看到"正则问题"这道题的时候,我下意识地以为是要写一个正则表达式解析器,或者实现某种模式匹配算法。结果读完题才发现,这其实是一道披着"正则"外衣的深度优先搜索题,而且它的核心逻辑比想象中要简单得多——简单到你会怀疑自己是不是想复杂了。
这道题在蓝桥杯历届试题里算是比较经典的一道DFS应用题。它的题目描述大致是这样的:给定一个由字符x、|、(、)组成的字符串,这个字符串表示一个"正则表达式",其中x表示一个字符,|表示或运算,()表示分组。要求你求出这个表达式能够匹配的字符串中,最长的那个字符串的长度是多少。
说白了,就是让你在这个表达式所代表的所有可能字符串里,找出长度最大的那个。比如xx|xxx能匹配xx或者xxx,那答案就是3。再比如x(xx|xxx)x能匹配xxxx或者xxxxx,答案就是5。
这道题之所以值得拿出来单独讲,是因为它完美地展示了DFS在"表达式求值"类问题中的通用套路:遇到括号就递归,遇到操作符就分治,遇到基础元素就返回。掌握了这个套路,你不仅能秒杀这道题,还能把类似的嵌套结构问题一并解决。
我当年第一次做这道题的时候,卡在了"怎么处理嵌套括号"上,写了一堆栈操作,代码又长又容易出错。后来看了别人的思路才恍然大悟——原来递归才是这道题的最优解,而且代码量少得惊人。这篇文章就把我踩过的坑、想通的逻辑、以及最终打磨出来的代码,完整地分享给你。
2. 题目到底在问什么:把"正则"翻译成人话
2.1 三种操作符的实际含义
题目里虽然用了"正则表达式"这个词,但它和我们平时用的正则引擎完全不是一回事。这里的规则被极度简化了,只有三种操作:
x:表示一个普通字符,它匹配的字符串就是"x",长度为1。|:表示或运算,A|B表示可以匹配 A 或者 B,取两者中较长的那个。():表示分组,括号内的内容作为一个整体参与运算。
没有*、+、?这些量词,没有字符集,没有转义,什么都没有。就是这么纯粹。
所以这道题的本质是:给你一个由这三种元素构成的表达式,让你计算它所能表示的最长字符串的长度。
2.2 几个例子帮你建立直觉
光说规则可能还是有点抽象,我们来看几个具体的例子:
| 表达式 | 能匹配的字符串 | 最长长度 |
|---|---|---|
x | x | 1 |
xx | xx | 2 |
| `x | x` | x或x |
| `xx | xxx` | xx或xxx |
| `x(xx | xxx)` | xxx或xxxx |
| `(xx | xxx)x` | xxx或xxxx |
| `((xx | xxx)x | xx)x` |
注意最后一个例子,它展示了嵌套括号的情况。((xx|xxx)x|xx)x这个表达式,最外层是一个括号后面跟着一个x,括号里面是(xx|xxx)x|xx,而这个括号里面又嵌套了一个(xx|xxx)。这种多层嵌套正是DFS要处理的核心场景。
2.3 为什么不能用简单的字符串替换
有些同学可能会想:我能不能从左到右扫描,遇到|就比较两边,遇到括号就展开?理论上可以,但实际操作起来非常麻烦。因为括号可以无限嵌套,而且|的优先级和括号的作用范围需要精确判断。你如果用迭代的方式写,就需要维护一个栈来记录当前处理到哪一层,代码会变得又长又容易出错。
而DFS的天然递归结构,恰好和括号的嵌套结构一一对应。每遇到一个左括号,就递归进入下一层;每遇到一个右括号,就结束当前层返回结果。这种"结构对应"让代码变得极其简洁。
3. 递归函数的返回值设计:这道题最关键的一步
3.1 为什么返回值不能只是"长度"
很多人写DFS的时候,第一反应是让函数返回"当前表达式能匹配的最大长度"。这个思路方向是对的,但有一个致命问题:当函数遇到|或者)的时候,它需要知道"当前这一段已经处理到哪里了"。
举个例子,对于表达式xx|xxx,如果你只返回长度,那么处理完xx之后返回2,然后遇到|,你需要继续处理xxx,但你怎么知道xxx从哪里开始?你需要一个额外的变量来记录当前扫描位置。
所以正确的做法是:让递归函数同时返回两个信息——当前能匹配的最大长度,以及处理结束后的位置。或者更常见的做法是:用一个全局变量(或引用参数)来记录当前扫描位置,函数只返回长度。
3.2 两种常见的实现方式对比
我见过两种主流的写法,各有优劣:
方式一:全局指针 + 返回长度
def dfs(): global pos max_len = 0 current_len = 0 while pos < len(s): if s[pos] == 'x': current_len += 1 pos += 1 elif s[pos] == '|': max_len = max(max_len, current_len) current_len = 0 pos += 1 elif s[pos] == '(': pos += 1 # 跳过左括号 current_len += dfs() elif s[pos] == ')': pos += 1 # 跳过右括号 break max_len = max(max_len, current_len) return max_len这种写法的优点是代码简洁,逻辑清晰。缺点是依赖全局变量,如果在多线程环境下会有问题,但在竞赛中完全够用。
方式二:传递位置参数 + 返回元组
def dfs(start): max_len = 0 current_len = 0 i = start while i < len(s): if s[i] == 'x': current_len += 1 i += 1 elif s[i] == '|': max_len = max(max_len, current_len) current_len = 0 i += 1 elif s[i] == '(': sub_len, i = dfs(i + 1) current_len += sub_len elif s[i] == ')': max_len = max(max_len, current_len) return max_len, i + 1 max_len = max(max_len, current_len) return max_len, i这种写法不依赖全局变量,更加函数式,但代码稍微冗长一些。
3.3 我个人的选择和建议
在竞赛场景下,我强烈推荐第一种写法。原因很简单:代码短,不容易出错,调试方便。全局变量在单线程竞赛程序里没有任何问题,而且pos这个变量的语义非常明确——它就是"当前扫描到的位置"。
不过有一点需要注意:全局变量的初始化。每次调用dfs()之前,一定要确保pos被重置为0。我见过有同学在多次测试用例之间忘记重置,导致第二个用例从第一个用例结束的位置开始扫描,结果全错。
提示:如果你用的是Python,可以用
nonlocal或者把pos放在一个列表里来避免global关键字,但在竞赛中直接global就完事了,没必要过度设计。
4. 逐字符扫描的逻辑拆解:四种情况分别怎么处理
4.1 遇到x:累加当前长度
这是最简单的情况。每遇到一个x,就说明当前这一段能匹配的字符串长度增加了1。所以直接current_len += 1,然后pos += 1继续往后扫描。
这里有一个容易忽略的点:x是可以连续出现的。比如xxx表示三个连续的字符,长度是3。所以你不能遇到x就返回,而是要继续累加,直到遇到|、)或者字符串结束。
4.2 遇到|:结算当前段,重置计数器
|是或运算的分隔符。当你遇到|的时候,说明前面那一段已经处理完了,你需要把current_len和max_len做一次比较,取较大值存入max_len,然后把current_len重置为0,准备处理|后面的内容。
为什么是重置为0而不是其他值?因为|表示"或者",前后两段是独立的,各自从0开始计算长度。比如xx|xxx,处理完xx后current_len=2,遇到|后max_len=2,current_len重置为0,然后处理xxx得到current_len=3,最后循环结束时再比较一次,max_len=3。
4.3 遇到(:递归进入下一层
这是整个算法的核心。遇到左括号时,说明接下来要处理一个子表达式,而这个子表达式的处理逻辑和当前层完全一样。所以直接递归调用dfs(),让子调用去处理括号内的内容,返回子表达式的最大长度,然后把这个长度累加到current_len上。
注意:在递归调用之前,你需要先把pos加1,跳过左括号本身。否则子调用会再次遇到左括号,导致无限递归。
4.4 遇到):结算并返回
遇到右括号时,说明当前层的子表达式处理完毕了。你需要做两件事:第一,把current_len和max_len做最后一次比较;第二,把pos加1跳过右括号,然后返回max_len。
这里有一个细节:返回之前一定要做最后一次max比较。因为循环可能在遇到)时直接break,如果不做这次比较,最后一段的长度就会丢失。比如xx|xxx在括号内的情况,处理完xxx后遇到),如果不比较就直接返回,max_len还是2,答案就错了。
4.5 循环结束后的收尾
当while循环因为pos到达字符串末尾而结束时,同样需要做一次max比较。这和遇到)的情况类似,都是为了防止最后一段的长度被遗漏。
整个逻辑可以用下面这个表格来总结:
| 遇到字符 | 操作 | 注意事项 |
|---|---|---|
x | current_len += 1; pos += 1 | 连续x要累加 |
| | max_len = max(max_len, current_len); current_len = 0; pos += 1 | 重置计数器 |
( | pos += 1; current_len += dfs() | 先跳过左括号再递归 |
) | max_len = max(max_len, current_len); pos += 1; return max_len | 返回前必须比较 |
| 结束 | max_len = max(max_len, current_len); return max_len | 防止遗漏最后一段 |
5. 完整代码实现与逐行注释
5.1 Python版本
import sys def solve(): s = sys.stdin.readline().strip() pos = 0 n = len(s) def dfs(): nonlocal pos max_len = 0 current_len = 0 while pos < n: if s[pos] == 'x': current_len += 1 pos += 1 elif s[pos] == '|': max_len = max(max_len, current_len) current_len = 0 pos += 1 elif s[pos] == '(': pos += 1 # 跳过左括号 current_len += dfs() elif s[pos] == ')': pos += 1 # 跳过右括号 break max_len = max(max_len, current_len) return max_len print(dfs()) if __name__ == "__main__": solve()这段代码里我用了nonlocal而不是global,因为pos是在solve函数内部定义的,用nonlocal更合适。如果你把pos定义在模块级别,那就用global。
5.2 C++版本
#include <bits/stdc++.h> using namespace std; string s; int pos = 0; int n; int dfs() { int max_len = 0; int current_len = 0; while (pos < n) { if (s[pos] == 'x') { current_len++; pos++; } else if (s[pos] == '|') { max_len = max(max_len, current_len); current_len = 0; pos++; } else if (s[pos] == '(') { pos++; current_len += dfs(); } else if (s[pos] == ')') { pos++; break; } } max_len = max(max_len, current_len); return max_len; } int main() { cin >> s; n = s.size(); cout << dfs() << endl; return 0; }C++版本和Python版本逻辑完全一致,只是语法不同。注意pos和n都定义成了全局变量,这样在dfs里可以直接访问和修改。
5.3 手动模拟一遍执行过程
为了让你彻底理解这段代码,我们拿x(xx|xxx)x这个例子手动走一遍:
- 初始状态:
pos=0,max_len=0,current_len=0 s[0]='x':current_len=1,pos=1s[1]='(':pos=2,递归调用dfs()- 子调用:
max_len=0,current_len=0 s[2]='x':current_len=1,pos=3s[3]='x':current_len=2,pos=4s[4]='|':max_len=2,current_len=0,pos=5s[5]='x':current_len=1,pos=6s[6]='x':current_len=2,pos=7s[7]='x':current_len=3,pos=8s[8]=')':max_len=max(2,3)=3,pos=9,break- 返回
max_len=3
- 子调用:
- 回到主调用:
current_len += 3,变成1+3=4,pos=9 s[9]='x':current_len=5,pos=10- 循环结束:
max_len=max(0,5)=5,返回5
最终答案是5,和我们的预期一致。
6. 那些年我踩过的坑:五个常见错误及修复方案
6.1 忘记在递归前跳过左括号
这是最常见的错误。如果你在遇到(时直接调用dfs()而不先pos++,那么子调用会再次看到同一个(,然后再次递归,导致无限递归直到栈溢出。
修复方案:在递归调用之前,务必先pos += 1跳过左括号。
6.2 遇到右括号后忘记做最后一次max比较
这个错误很隐蔽,因为它在很多测试用例上都能通过,只有在特定情况下才会暴露。比如xx|xxx在括号内时,如果遇到)直接返回而不比较,max_len就停留在2,丢失了xxx的长度3。
修复方案:在break之前,一定要执行max_len = max(max_len, current_len)。
6.3 全局变量pos没有重置
如果你在多个测试用例之间复用同一个pos变量,忘记重置的话,第二个用例会从第一个用例结束的位置开始扫描,结果完全错误。
修复方案:每次处理新用例前,把pos重置为0。或者干脆把pos定义在函数内部,用nonlocal访问。
6.4 把|的优先级理解错了
有些同学会认为|的优先级低于括号,所以在处理括号内的|时需要特殊处理。但实际上在这道题里,括号内的|和括号外的|处理逻辑完全一样,都是"结算当前段,重置计数器"。递归调用天然地隔离了不同层级的|,你不需要额外考虑优先级问题。
修复方案:相信递归的力量,不要人为增加复杂度。
6.5 输入字符串包含空格或换行
有些测试用例的输入可能包含多余的空格或换行符,如果你直接用input()而不做strip(),可能会把空格也当成表达式的一部分,导致解析错误。
修复方案:读取输入后立即strip(),去掉首尾空白字符。
注意:这道题的输入保证是合法的正则表达式,所以你不必考虑非法输入的情况。但在实际工程中,解析表达式时一定要做合法性校验。
7. 从这道题延伸出去:DFS处理嵌套结构的通用套路
7.1 识别"嵌套结构"类问题的特征
这道题的核心特征是什么?是括号嵌套。表达式可以无限层嵌套,每一层的处理逻辑完全相同,只是作用范围不同。这类问题在算法竞赛中非常常见,比如:
- 括号匹配与嵌套深度计算
- 表达式的解析与求值(如四则运算)
- JSON/XML等嵌套格式的解析
- 树形结构的遍历
它们的共同点是:结构是递归定义的,处理逻辑也是递归的。
7.2 递归函数的设计模板
对于这类问题,递归函数通常遵循以下模板:
def dfs(): # 初始化当前层的状态 result = 初始值 current = 初始值 while 还有输入: if 遇到基础元素: # 直接处理 current = 更新(current) elif 遇到分隔符: # 结算当前段 result = 合并(result, current) current = 重置 elif 遇到左括号: # 递归进入下一层 跳过左括号 current = 合并(current, dfs()) elif 遇到右括号: # 结算并返回 result = 合并(result, current) 跳过右括号 return result # 循环结束后的收尾 result = 合并(result, current) return result这个模板可以套用到很多类似问题上。你只需要根据具体问题,替换"基础元素"、"分隔符"、"合并方式"和"重置方式"即可。
7.3 一个变体:如果要求的是最短长度
假设题目改成求最短匹配长度,你只需要把max换成min,并且注意初始值的设置。max的初始值通常是0,而min的初始值需要设为一个很大的数(比如float('inf'))。其他逻辑完全不变。
7.4 另一个变体:如果支持*量词
如果题目增加*表示"前面的元素可以重复任意次",那么处理逻辑会复杂一些。你需要在遇到*时,把前一个元素的长度乘以一个系数(0次就是0,1次就是原长度,多次就是无限大)。但整体的递归框架是不变的,只是在"基础元素"的处理上增加了一种情况。
7.5 实际工程中的应用
虽然这道题是竞赛题,但它背后的DFS思想在实际工程中也有广泛应用。比如:
- 配置文件解析:解析嵌套的JSON或YAML配置,提取最深层的值。
- 模板引擎:处理嵌套的模板标签,如
{% if %}里面嵌套{% for %}。 - 编译器前端:解析源代码中的嵌套括号和表达式。
我在实际项目中就遇到过一个类似的问题:需要从一个嵌套的配置结构中提取所有叶子节点的路径。当时用的就是DFS递归,代码不到50行就搞定了,比用栈迭代清晰得多。
8. 测试用例设计与验证:怎么确认你的代码真的对了
8.1 基础测试用例
写完代码后,不要急着提交,先用几个基础用例验证一下:
| 输入 | 期望输出 | 验证点 |
|---|---|---|
x | 1 | 最简单情况 |
xx | 2 | 连续x |
x|x | 1 | 或运算取max |
xx|xxx | 3 | 或运算取max |
(x) | 1 | 单层括号 |
(xx) | 2 | 单层括号 |
x(xx|xxx)x | 5 | 括号嵌套+前后缀 |
((xx|xxx)x|xx)x | 5 | 多层嵌套 |
8.2 边界测试用例
除了基础用例,还要考虑一些边界情况:
- 空字符串:虽然题目保证输入合法,但你可以测试一下空字符串,看看代码会不会崩溃。
- 只有括号:如
(),理论上应该返回0,但题目可能不会给这种输入。 - 深层嵌套:如
((((x)))),测试递归深度是否会导致栈溢出。 - 大量或运算:如
x|x|x|x|...|x,测试性能。
8.3 对拍验证
如果你不确定自己的代码是否正确,可以写一个暴力解法来对拍。暴力解法的思路是:枚举所有可能的匹配字符串,取最长长度。虽然暴力解法效率低,但对于小规模输入,它可以作为验证标准。
def brute_force(s): # 递归展开所有可能性 def expand(expr): # 返回所有可能的字符串集合 results = set() # 处理或运算 parts = split_by_or(expr) for part in parts: # 处理括号和x ... return results all_strings = expand(s) return max(len(t) for t in all_strings)对拍的方法是:随机生成大量小规模表达式,分别用DFS解法和暴力解法计算,比较结果是否一致。如果所有用例都一致,说明你的DFS解法大概率是正确的。
8.4 性能测试
这道题的时间复杂度是O(n),因为每个字符最多被访问一次。空间复杂度是O(d),其中d是括号的最大嵌套深度,也就是递归栈的深度。对于蓝桥杯的测试数据规模(通常n不超过1000),这个复杂度完全够用。
但如果你担心递归深度过大导致栈溢出,可以把递归改成显式栈的迭代写法。不过对于这道题来说,没必要,因为嵌套深度通常不会太大。
9. 一些实战中的小技巧和心得
9.1 用print大法调试递归
递归代码最难调试的地方在于:你不知道每一层递归的pos和current_len是多少。我的做法是在关键位置加print语句,输出当前层的信息:
def dfs(): nonlocal pos max_len = 0 current_len = 0 print(f"进入dfs, pos={pos}") while pos < n: # ... print(f"处理{s[pos]}, pos={pos}, current_len={current_len}, max_len={max_len}") print(f"离开dfs, 返回{max_len}") return max_len这样你就能清楚地看到每一层递归的进入和离开,以及每一步的状态变化。虽然输出会很多,但对于理解递归流程非常有帮助。
9.2 用缩进表示递归深度
如果你觉得print输出太乱,可以在print时加上缩进,用缩进的层数表示递归深度:
depth = 0 def dfs(): global depth depth += 1 indent = " " * depth print(f"{indent}进入dfs, pos={pos}") # ... print(f"{indent}离开dfs, 返回{max_len}") depth -= 1 return max_len这样输出就会像树形结构一样,一目了然。
9.3 注意Python的递归深度限制
Python默认的递归深度限制是1000。如果你的表达式嵌套深度超过1000,会抛出RecursionError。虽然蓝桥杯的测试数据不太可能这么极端,但你可以通过sys.setrecursionlimit(10000)来增加限制。
9.4 竞赛中的输入输出优化
如果输入字符串很长,用sys.stdin.readline()比input()快。输出用print()就够了,不需要额外优化。
9.5 这道题在蓝桥杯中的定位
这道题在蓝桥杯历届试题中属于中等难度。它的难点不在于算法本身,而在于能否想到用DFS来处理嵌套结构。一旦你想通了这一点,代码其实很短。我见过很多同学在这道题上卡了很久,就是因为一开始想复杂了,试图用栈或者字符串替换来解决问题。
提示:遇到嵌套结构的问题,第一反应应该是"能不能递归"。递归是处理嵌套结构最自然的方式,没有之一。
10. 写在最后:递归思维比代码本身更重要
这道题表面上考的是DFS,实际上考的是你能不能把嵌套结构映射成递归调用。这个思维模式一旦建立起来,你会发现很多看似复杂的问题都变得简单了。
我后来在做其他题目时,经常遇到类似的情况:一看到括号嵌套、树形结构、分治问题,脑子里就会自动浮现出递归的框架。这种条件反射不是天生的,而是通过反复练习这道题这样的经典题目培养出来的。
如果你现在还在纠结这道题的代码细节,我的建议是:先把代码背下来,然后手动模拟几遍执行过程,直到你能不假思索地写出正确的递归逻辑。到那时候,你就真正掌握了DFS处理嵌套结构的精髓。
最后分享一个我个人的习惯:每做完一道经典题,我都会把它的核心思路用一句话总结出来,记在笔记本上。这道题的总结就是:"遇到括号就递归,遇到或运算就取max,遇到x就累加。"短短一句话,涵盖了整道题的所有逻辑。下次再遇到类似的题目,我只需要回忆这句话,就能快速写出代码。