说实话,我第一次看到 P8650 这道题的时候,压根没把它和“正则表达式”联系起来。考场上谁要是真按正则引擎的思路去写,基本就掉坑里了。题目名字带个“正则问题”,实际考的却是栈、递归、表达式求值这一套最经典的模型。尤其是“或运算”这层语义,稍微绕一下就能把一票人卡住。这篇就把我从读题到 AC 的完整思路写出来,备战蓝桥杯的同学可以参考一下,尤其是刷题时总感觉“递归题写得乱”的朋友,应该能从中找到一点规律。
题目本身难度不高,普及+的定位很准确。它不考高深算法,考的是你能不能把一个带括号、带二选一的字符串表达式,翻译成“递归函数 + 几个局部变量”的简单逻辑。想明白之后,代码量其实也就十几行。这篇我会把题目语义、递归推演、代码实现、易错点、以及这类题的迁移方法都过一遍,带你彻底吃透它。
1. 先把题面翻译成人话:这不是匹配,是求“最大展开长度”
1.1 x、|、括号三种符号到底在表达什么
原题给的是一个只有三种字符的“正则表达式”:x、|、(、)。很多同学看到“正则”俩字就慌了,以为要处理什么复杂字符集、量词、分组捕获,完全不是。题意是这样的:
x表示匹配一个普通字符。|表示“或”,也就是左右两边二选一。(和)用来分组,括号里算一个整体。
题目问:这个表达式能匹配出的字符串,最大长度是多少。
理解这个题最重要的一个转变是:别把它当成“匹配问题”,当成“求值问题”。正则表达式会匹配很多可能字符串,我们的目标不是判断某个串能不能匹配,也不是输出所有可能结果,而是计算所有可能结果中,哪个最长,长度是多少。
比如表达式 xx|xxx,它既可以匹配 xx,也可以匹配 xxx,那答案就是 3。这个很简单,本质就是两个分支取最大值。
再加上括号之后,问题就从“横向二选一”变成了“嵌套分组 + 二选一”的组合。拿 (xx|x)x 来说,括号内可以是 xx 或 x,取最大是 2,再和括号外的 x 拼接,总长就是 3。如果取的是 x,那整体是 xx,长度 2,显然不是最大。
所以这道题的核心计算规则并不复杂:
| 字符 | 含义 | 对答案的贡献 |
|---|---|---|
x |
匹配一个字符 | 当前长度 + 1 |
| ` | ` | 二选一 |
( 和 ) |
分组 | 括号内部是一个子表达式,整体作为外层的一部分 |
一旦把规则翻译成“表达式求值”,这个题就退掉了“正则”这层皮,剩下的核心是:如何解析一个带括号、带或运算的表达式,并返回它能产生的最大长度。
1.2 一个手算例子,先把直觉建立起来
看一个稍微完整一点的例子:(x|xx)x(x|xxx)。
我先不写代码,纯手算。这个表达式可以拆成三段拼接:
- 第一段:
(x|xx),括号里 x 长度 1,xx 长度 2,取最大为 2。 - 第二段:一个单独的
x,长度 1。 - 第三段:
(x|xxx),括号里 x 长度 1,xxx 长度 3,取最大为 3。
三段是拼接关系,所以答案就是 2 + 1 + 3 = 6。
这种手算你很容易就能做对。但关键问题是:怎么把这个过程写成通用程序?因为括号可以无限嵌套,比如 (x|(xx|(xxx|x))),手算可以一层层剥,程序也必须能一层层展开。而“一层层展开”这个动作,天然就是递归的形态。这就是本题指向栈和递归的原因。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 为什么这题一拍脑袋就该用栈+递归,而不是遍历硬模拟
2.1 暴力展开为什么不现实
有同学可能想:我直接把所有可能性枚举出来不就行了?遇到 | 就分叉,遇到括号就进入子问题,最后统计最长路径。
理论上可以,但实际写起来非常痛苦。因为 | 的分叉可能嵌在括号里,括号又套括号,暴力展开会产生指数级分支。比如 (x|xx)(x|xx)(x|xx) 这类表达式,手动枚举所有组合,数量是 2 的 n 次方级别。题目长度稍大一点,这种写法直接爆炸。
更严重的问题是写法本身很绕。你要维护一个“当前展开结果”的集合,每遇到一个符号都要更新所有分支,还要处理括号进出。这种思路在逻辑上容易漏分支,尤其嵌套层数深了以后,几乎没法调试。
2.2 递归函数本身就是一台“解析器”
真正适合这类题的做法,是写一个递归解析函数,让它按语法结构一步步“吃掉”字符串。
为什么要用递归?因为括号这种结构在语法上叫“嵌套结构”。嵌套结构的天然处理方式就是递归:遇到一个左括号,就递归调用自己,去解析括号里的内容;当遇到匹配的右括号时,返回结果,回到外层继续解析。
从另一个角度看,递归调用时系统会帮你维护一个“调用栈”。每一层递归对应一层括号,函数参数和局部变量就相当于栈帧里保存的当前状态。这就是“栈”这个标签的由来。
那如果不用递归,改成显式栈可不可以?也可以。但显式栈本质上是在模拟递归调用过程,代码会比递归写法繁琐一些。所以对大多数选手来说,递归是更直接、更不容易出错的方案。
2.3 为什么这里的“或运算”可以直接取 max
还有一个关键点很多人没想透:| 二选一,在求最大长度时,为什么直接左右两边取最大值就完了?
因为题目只问长度,不问具体匹配内容。左边一种可能长度是 2,右边一种可能长度是 3,那么整体最大就是 3。即使左边还有别的可能长度是 4,计算时也用 max 把它取出来。所以“或”在数值计算层面,就是取最大值运算。
这也是为什么这个题只是个“普及+”难度的题。它不要求你区分不同路径,只要求你在所有可能路径里挑最长的那条,因此只需要把一个局部变量和当前结果取 max,就完成了二选一的抽象。
想通了这一点,题目瞬间就从“玄学正则”变成了“带括号的加法/最大值表达式”,所有难点都集中到如何正确 parse 上。
3. 递归解析的完整推演:从输入串到答案的每一步
3.1 先定义递归函数到底要干什么
写递归最怕的就是函数语义不清。写这个题之前,先明确两件事:
dfs()返回什么:返回“从当前扫描位置开始,一个完整子表达式能匹配的最大长度”。dfs()扫描到哪里结束:扫描到右括号)或者字符串末尾。
这两点想清楚,代码就不容易乱。
我习惯用一个全局变量 pos 记录当前扫描位置,这样递归函数之间不需要传引用,代码看起来更清爽。dfs() 内部维护两个局部变量:
cur:当前正在累积的普通字符x的数量。res:已经扫过的所有分支中,最大的长度。
整个函数的逻辑就是从左到右扫描字符,遇到 x 就把 cur 加一,遇到 | 说明前一段结束了,把 cur 和 res 比较,保留大的,然后 cur 清零,继续处理后面的分支。遇到 ( 就递归进入子表达式,子表达式的返回值作为整体长度,累加到 cur 上。遇到 ) 说明当前子表达式结束,跳出循环,返回结果。
3.2 用一个完整表达式走一遍程序的状态变化
我们拿 (x|xx)x(x|xxx) 来模拟一遍。假设初始 pos = 0,调用 dfs()。
初始 res = 0,cur = 0。
第一层 dfs(),pos 扫到 (
- 字符是
(,说明要进入子表达式,pos++后递归调用dfs()。
第二层 dfs(),这时 pos 指向第一个 x
- 遇到
x,cur = 1,pos++。 - 遇到
|,把cur = 1记为当前最大,res = max(0, 1) = 1,cur = 0,pos++。 - 再遇到
x,cur = 1,pos++。 - 再遇到
x,cur = 2,pos++。 - 遇到
),说明子表达式结束,pos++并退出循环。 - 退出前执行
res = max(res, cur) = max(1, 2) = 2。 - 第二层返回 2。
回到第一层后,cur += 2,所以此时 cur = 2,pos 指向括号后面的那个 x。
回到第一层继续扫描
- 遇到
x,cur = 3,pos++。 - 遇到
(,再次递归,pos++后进入第三层。
第三层 dfs(),这里解析 (x|xxx)
- 遇到
x,cur = 1,pos++。 - 遇到
|,res = max(0, 1) = 1,cur = 0,pos++。 - 遇到
xxx,cur = 3,pos到末尾。 - 遇到
),pos++并退出循环。 - 退出前
res = max(1, 3) = 3。 - 第三层返回 3。
回到第一层,cur += 3,于是 cur = 6。
第一层继续扫描,发现 pos 已经指向字符串末尾
- while 循环判断
pos < len(s)不成立,退出。 - 退出前执行
res = max(res, cur) = max(0, 6) = 6。 - 返回 6。
最终结果是 6,和手算一致。
这个过程中你可能会注意到一个细节:第一层一直没有遇到 |,所以它的 res 一直是 0,全靠最后的 cur = 6 兜底。而第二、第三层因为遇到了 |,所以需要通过 res = max(res, cur) 来保存左分支和右分支的最大值。最后的 res = max(res, cur) 很关键,它保证不管最后一个分支是普通 x、括号子表达式,还是 | 之后的尾巴,都不会被漏掉。
3.3 为什么子表达式返回值可以直接加到 cur 上
这个细节值得单独拿出来说。很多同学在写代码时纠结:遇到 ( 后,子表达式的返回值凭什么能直接加到外层 cur 上?它不应该是一个独立的整体吗?
注意看题目的拼接规则:括号里的子表达式和括号外的字符是“拼接关系”。比如 (xx|x) 后面再接一个 x,那么总长度就是括号长度 + 1,是加法关系。所以子表达式的返回值当然要加到当前 cur 上。
打个比方:外层表达式是大拼图,括号内是一个小拼图块。外层扫到 ( 时,等于拿起了一块小拼图,把它嵌入到当前正在拼的位置,此时总的完成尺寸就是 原来的长度 + 小拼图尺寸。所以 cur += dfs() 是符合语义的。
理解了这一点,整个递归模型就通了。
4. 代码实现与细节处理:一个变量都不能乱用
4.1 主推的递归写法,C++ 完整代码
cpp复制#include <bits/stdc++.h>
using namespace std;
string s;
int pos;
int dfs() {
int res = 0; // 记录当前子表达式已扫描分支里的最大值
int cur = 0; // 记录当前正在累积的长度
while (pos < (int)s.size()) {
char c = s[pos];
if (c == '(') {
// 进入子表达式,子表达式的结果和当前长度拼接
pos++;
cur += dfs();
} else if (c == ')') {
// 当前子表达式结束,把位置向前推进并退出
pos++;
break;
} else if (c == '|') {
// 处理“或”运算:取前面分支的最大值,然后清空当前长度
res = max(res, cur);
cur = 0;
pos++;
} else {
// 普通字符 x,长度加一
cur++;
pos++;
}
}
// 退出时,再把 cur 纳入比较,防止最后一个分支被漏掉
res = max(res, cur);
return res;
}
int main() {
cin >> s;
pos = 0;
cout << dfs() << '\n';
return 0;
}
这段代码逻辑不多,但每一行都有讲究。我强调三个地方:
第一,case ')' 里是 break 而不是 return res。因为函数末尾还要统一处理 res = max(res, cur),如果在这里直接 return,当前这一段的最后一段 cur 可能因为被 | 清空过而丢失。写 break 可以让所有结束路径统一收口,逻辑更稳。
第二,pos++ 的位置不能写错。( 和 ) 都需要把 pos 推进,否则会出现死循环。尤其是遇到 ( 时,要先 pos++ 再进入递归,否则递归层内永远扫描同一个 (。
第三,res 和 cur 必须初始化为 0。这个看似废话,但在多组数据或函数被反复调用时,如果变量声明在函数外面,就很容易被上一次调用的脏值污染。写成函数内部局部变量最安全。
4.2 如果非要用显式栈,也可以,但不推荐
有些同学对递归有心理阴影,总觉得递归会爆栈。这个题我明确说,题目给定的字符串长度在合理范围内,递归深度对应括号的嵌套层数,完全不可能爆栈。除非你拿一个几万层括号的数据去卡,那另说,但那不是这道题的正常考察范围。
如果真要用显式栈,思路是用一个栈保存当前层在遇到 | 之前的结果,以及当前层是否在括号里。当遇到 ( 时压入一个标记,遇到 | 时把 cur 压栈并清零,遇到 ) 时弹栈并做合并。逻辑上是递归的翻版,但代码量明显变多,而且容易在栈的状态维护上出问题。竞赛里除非为了炫技,否则不如直接递归。
4.3 Python 版的极简写法,感受一下思路差异
python复制s = input().strip()
pos = 0
def dfs():
global pos
res, cur = 0, 0
while pos < len(s):
c = s[pos]
if c == '(':
pos += 1
cur += dfs()
elif c == ')':
pos += 1
break
elif c == '|':
res = max(res, cur)
cur = 0
pos += 1
else:
cur += 1
pos += 1
return max(res, cur)
print(dfs())
Python 写起来更短,核心逻辑和 C++ 完全一致。如果你在蓝桥杯赛场上习惯用 Python,这个版本可以直接用。唯一要注意的是 global pos,递归函数里要修改全局扫描位置,必须声明 global,否则 pos += 1 会报错或者出现局部变量覆盖。
5. 考场上的易错点与优化思路:这些都是我见过别人踩过的坑
5.1 最常见的几个翻车现场
第一种错误:遇到 | 时直接清零 cur,但没有先把 cur 保存到 res。这样左分支的长度就丢了,遇到 xx|xxx 这种表达式时,返回值只会是 3,而不是预期的 3,但如果是 xxx|xx,就会错误返回 2。凡是遇到 | 必须先 res = max(res, cur),再 cur = 0,顺序不能反。
第二种错误:忘记在循环结束后 res = max(res, cur)。字符串末尾通常是一段连续的 x,或者被 ) 包裹的最后一段分支。如果不在函数返回前把 cur 纳入比较,这一段长度就被丢掉了。这种错误非常隐蔽,因为遇到单个简单样例时可能刚好能过,但遇到 x(xx|x) 这类结尾带分支的测试点就会出错。
第三种错误:处理 ( 时忘记 pos++。很多初学者会直接写成 cur += dfs() 然后不移动指针,结果递归进去之后,看到的还是同一个 (,死循环。正确写法是先进 pos++,再递归。这个细节一旦写错,本地调试时程序直接失去响应,非常搞心态。
第四种错误:混淆了 res 和 cur 的用途。res 是“历史最大值”,遇到 | 时要更新;cur 是“当前累计长度”,遇到 x 时要增加,遇到 ( 时要拼接。一旦把两个变量混用,表达式复杂一点就全乱套。建议写代码前先在注释里标注清楚两个变量的含义。
5.2 时间复杂度与空间复杂度分析
这个题的时间复杂度是 O(n),其中 n 是输入字符串长度。因为每个字符在整个递归过程中只会被扫描一次,pos 单调递增,不会重复回溯。空间复杂度是 O(d),d 是最大括号嵌套深度,对应递归调用栈的深度。
也就是说,这个解法已经是理论最优复杂度了。它没有常数大的问题,也没有需要记忆化优化的必要。你不需要开数组,不需要建树,不需要转后缀表达式,直接解析就是最简单高效的方案。
5.3 要不要记忆化?不需要,但可以了解一下
有人可能会想,如果同一个子表达式被多次重复解析,是不是可以用记忆化加速?比如 (x|xx)(x|xx) 这种,括号内容一样,能否只算一次?
理论上可以,用一个 unordered_map<int, int> 记录某个起始位置 pos 对应的结果,但本题完全没必要。因为每个括号子表达式在字符串中的位置是唯一的,解析一次后就再也不会遇到同样的起始位置,记忆化根本命不中几次。而且引入哈希表还会带来额外开销,得不偿失。
如果你真想优化,不如把目光放在“如何让代码更简洁、更不容易写错”上。这道题不是你展示性能优化的舞台,而是展示你基础功的舞台。
6. 把栈+递归的套路迁移到其他题:这题只是其中一个实例
6.1 一个通用的解析框架
做完这道题,你会发现在算法题里有一类“表达式解析”问题,套路几乎是固定的:
code复制定义解析函数:
初始化结果变量 res = 0
初始化当前累计变量 cur = 0
while 当前位置没有到边界:
当前是普通字符:累计到 cur
当前是优先级高的运算符:res 和 cur 做合并,清空 cur
当前是左括号 / 左分隔符:递归解析子表达式,结果累计到 cur
当前是右括号 / 右分隔符:退出当前层
返回 res 和 cur 合并后的结果
这个框架可以套在很多题上,比如:
- 字符串解码:
3[a2[c]],数字后面跟括号,括号内子串要重复。递归解析时,遇到[就进入子问题,返回子串,然后按倍数拼接。 - 计算器:
1 + (2 + 3 * 4),遇到左括号递归计算括号内表达式,再把结果作为操作数参与当前层运算。遇到运算符则处理优先级和累积值。 - 带括号的布尔表达式:
&(t, f, t)这种,递归解析每个参数,再对子结果做与或非运算。
你会发现这些题的共同点都是:嵌套结构 + 运算符优先级。递归天然处理嵌套,运算符优先级则通过“在什么时候合并、什么时候清空”来实现。
6.2 和“括号匹配”类问题的联系
栈 这个标签很容易让人联想到括号匹配。括号匹配的经典解法是用一个显式栈,遇到左括号入栈,遇到右括号出栈。本题其实也隐含着括号匹配的思想,递归函数的调用和返回,本质上就是隐式的入栈出栈。
如果你先掌握了括号匹配,再来看这道题,会更容易理解递归深度和括号嵌套深度是一致的。每进入一层 dfs(),相当于压入一个左括号;每从 dfs() 返回,相当于弹出一个右括号。整个表达式能否正确解析,取决于能否一一匹配。
我在做这类题时有个习惯:先在纸上画括号的层次结构,把嵌套关系画成树状图,然后根据树的递归遍历思路写代码。这个方法看起来笨,但对付复杂表达式非常有效。哪怕题目给出的括号嵌套很深,只要层次图画清楚,递归结构就一目了然。
6.3 实战建议:如何在考场上快速识别这类题
最后分享一点个人经验。刷题多了你会发现,只要题目中同时出现下面几个信号,大概率就是栈+递归的解法方向:
- 表达式中存在括号,且括号可以嵌套。
- 存在某种“二选一”或“优先级取舍”的语义,比如
|、||、+和*混合。 - 要求的结果是某种“最大/最小/是否可行”,且这种结果可以通过子问题的结果合并得到。
- 题目输入结构符合“嵌套定义”的语法,比如正则、JSON、XML、计算表达式。
当你识别出这些信号后,不用急着写代码。先问自己三个问题:
- 递归函数返回什么?
- 递归函数处理到哪个字符时,需要进入下一层?
- 当前层的结果如何和子层的结果合并?
这三个问题想清楚了,代码基本就出来了。P8650 这样写,字符串解码题这样写,计算器题也这样写。所谓“栈+递归”的套路,说透了就是这套思维模型。把这套模型吃透,比多刷十道类似的题都管用。
