刷 UVa 时做到 12421,第一眼看到题目名 (Jiandan) Mua(I) 就被逗乐了——出题人直接用拼音告诉你"简单",这一看就是给刚入门 ACM 的选手准备的送分题。我当时顺手交了发 C++ 就 AC 了,本以为这题就这么过去了,结果后来在群里帮人答疑才发现,这题踩坑的人远比想象中多。问题不在于算法思路,而在于读入方式、边界条件、输出格式这些细节。今天就把这道"简单题"从头到尾拆开讲清楚,新手朋友可以直接照着复现,老手也可以看看有没有你当年踩过的坑。
题目核心是经典的括号合法性检查:给一串由小写字母、空格和 ()[]{} 六种括号组成的字符串,判断括号是否成对且正确嵌套。听起来像数据结构课的课后题,但放进 ACM 的评测环境里,就多了很多需要抠的细节。下文我会从题目设计思路、核心规则拆解、完整实现、常见 WA 原因和扩展思路几个部分来写,保证你读完能把这题吃得透透的。
1. 题目整体设计与思路拆解
1.1 题意到底在说什么
UVa 12421 的输入是一行一行的字符串,每一行可能包含小写字母、空格以及三种括号:圆括号 ()、方括号 []、花括号 {}。要求判断这一行中所有括号是否全部正确匹配。"正确匹配"包含了两个层面:
- 每个右括号都必须有一个同类型的左括号与之对应;
- 括号之间不能交叉嵌套,比如
([)]这种就是非法的,即使左右括号数量相等也不行。
除了括号之外的其他字符,包括普通字母和空格,都不影响判断结果,本质上就是干扰项。题目输出要求也很简单,每一行输入对应输出一行 YES 或 NO,所有测试数据一直读入到文件结束为止。
这种题目放在 UVa 上难度确实不高,它不像图论、动态规划那样需要很强的算法思维,考察的核心就是"栈"这个基础数据结构。但"简单"不等于"没有坑",我在实际做题和帮别人排查的过程中发现,这题真正容易出错的地方全在代码的输入输出和边界处理上。
1.2 为什么这题要选择栈解法而不是计数器
很多刚接触这类题目的同学,第一反应可能是:我数一数左括号和右括号的数量,再按类型分别判断是否相等,不就行了吗?这种方式对类似 (()()) 的用例确实有效,但一旦遇到交叉嵌套就会判断失误,比如:
( [ ) ]
这行字符串中,( 和 ) 数量相等,[ 和 ] 数量相等,但括号的嵌套顺序是错的,因为 [ 还没有闭合,] 之前就先遇到了 )。计数器完全无法感知这种顺序关系,这就会导致错误的判定。
为什么栈能正确处理?因为括号匹配的规律跟栈天然吻合。左括号相当于"压栈",右括号相当于"与栈顶元素配对,配对成功则出栈"。如果右括号来临时,栈顶不是匹配的左括号,或者栈已经空了,说明字符串非法。这种后进先出的处理逻辑,恰好符合括号嵌套的语义。
一句话总结:括号匹配问题,本质就是"栈"的经典应用。用计数器只能判断数量,不能判断顺序,所以正解一定是栈。
1.3 这道题的定位和考点分析
从 UVa 的出题风格来看,12421 这种编号靠后的题目并不都是难题,很多是给新手在刷题过程中调节节奏用的。这题名字里带 (Jiandan),就是官方认证的"简单题",适合作为栈和字符串处理的基础训练。它考察的能力点其实有三个:
- 是否理解栈这一数据结构的基本操作;
- 是否能正确处理多组字符串输入;
- 是否注意到空格、空行、EOF 等输入细节。
这三个点看起来基础,实际却非常见真章。很多初学者把栈的逻辑写对之后,仍然会 WA,多半就是卡在 scanf 读不了空格、getline 对空行的处理、或者是输出大小写不对这些环节。把这些细节处理干净,才算是真正掌握了这道题的完整解法。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心细节解析与规则拆解
2.1 合法与非法的各种情况
分析括号匹配问题,最好把所有可能的输入情况都梳理一遍。这样不仅写代码的时候有据可依,给自己出测试数据也更方便。下面是我整理出来的典型用例表:
| 输入字符串 | 是否合法 | 原因 |
|---|---|---|
() |
合法 | 最基本的配对 |
[]{} |
合法 | 多组互不干扰的括号 |
([]) |
合法 | 支持嵌套,内层方括号先闭合 |
( [ ) ] |
非法 | 交叉嵌套,顺序错误 |
( |
非法 | 只有左括号,没有右括号 |
) |
非法 | 只有右括号,没有左括号 |
([)] |
非法 | 括号类型错配 |
abc def |
合法 | 没有括号,空栈结束 |
(a) b |
合法 | 字母忽略,括号正确 |
| 空行 | 合法 | 没有括号等于空栈结束 |
注意最后两种用例。很多人可能忽略字母和空格,但代码里必须显式跳过它们,不能把它们当成非法字符。空行则直接输出 YES,因为括号数量为 0,栈为空,视为合法。
2.2 为什么小写字母和空格可以安全跳过
从题意设计的角度来说,这些字符存在的意义就是干扰判断。你可以把它们想象成一段文字里无意义的填充内容——只要括号本身匹配正确,中间穿插什么内容都不影响整体结构。例如字符串 a(b)c,括号配对正确,输出就是 YES。
写代码时,如果对每个字符都判断"是不是括号",然后对括号做处理,其他字符全部忽略,这是最稳妥的做法。在具体实现时,我习惯先复用一个判断函数:
- 如果是左括号,压栈;
- 如果是右括号,检查栈顶是否匹配;
- 否则直接
continue。
这样写出来的代码逻辑非常清晰,也不会因为误判空格出问题。
2.3 EOF、多行输入和 getline 的使用
UVa 的题目输入输出通常是"多组测试数据,读到 EOF 结束"。这题也一样。每行字符串可能包含空格,所以不能直接用 scanf("%s") 读取,因为 %s 遇到空格就会截断,导致一行被拆成多次处理,结果必然错乱。正确做法是用 getline 读整行。
while (getline(cin, line)) 是最常见的写法。这个循环会一直读入,直到文件结束。读入的空行是一个空字符串,进入循环体后会输出 YES。如果你用的是 C 的 gets,当然也能实现,但 UVa 的老题目用 C++ 的 getline 更稳。另外,由于输入行可能很长,我建议把 cin 与 stdio 的同步关闭,也就是加一行 ios::sync_with_stdio(false),避免不必要的性能开销。
3. 完整解法与核心代码实现
3.1 建立括号匹配关系
写代码之前,先把括号的对应关系定下来。这里需要两个辅助函数,一个是判断字符是否为左括号,另一个是判断两个括号是否配对。
cpp复制bool isLeft(char c) {
return c == '(' || c == '[' || c == '{';
}
bool isMatch(char l, char r) {
return (l == '(' && r == ')') ||
(l == '[' && r == ']') ||
(l == '{' && r == '}');
}
isLeft 用来决定什么时候入栈,isMatch 用来决定右括号到来时是否与栈顶匹配。这两个函数如果写得有误,整个题目都会错,所以命名和逻辑一定要清晰。
3.2 核心栈处理逻辑
模拟题的关键就是"按规则走"。我们可以把每行的处理逻辑归纳为四个步骤:
- 初始化一个空栈;
- 从左到右扫描字符串中的每个字符;
- 遇到左括号就压栈;遇到右括号就先检查栈是否为空,再检查栈顶是否与当前右括号匹配;
- 扫描结束后,如果栈为空,说明括号完全匹配,输出
YES,否则输出NO。
尤其要注意第三步里的"栈是否为空"判断。很多初学者默认栈里一定还有东西,遇到右括号直接 st.top(),结果在输入是 ) 这种纯右括号的情况下,程序直接崩溃,甚至在某些评测环境下变成 RE 而不是 WA。
cpp复制if (st.empty() || !isMatch(st.top(), c)) {
ok = false;
break;
}
st.pop();
这种写法把"空栈与匹配失败"放在同一个分支里处理,简洁且不会出错。
3.3 可直接 AC 的完整 C++ 代码
下面这段代码是我当时交上去的版本,经过 UVa 评测没问题。需要注意的地方我都加了注释。
cpp复制#include <iostream>
#include <string>
#include <stack>
using namespace std;
bool isLeft(char c) {
return c == '(' || c == '[' || c == '{';
}
bool isMatch(char l, char r) {
return (l == '(' && r == ')') ||
(l == '[' && r == ']') ||
(l == '{' && r == '}');
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
string line;
while (getline(cin, line)) {
stack<char> st;
bool ok = true;
for (char c : line) {
// 空白字符和普通小写字母不影响括号判断
if (c == ' ' || (c >= 'a' && c <= 'z')) {
continue;
}
if (isLeft(c)) {
st.push(c);
} else if (c == ')' || c == ']' || c == '}') {
if (st.empty() || !isMatch(st.top(), c)) {
ok = false;
break;
}
st.pop();
}
}
// 栈不为空说明还有未闭合的左括号
if (!st.empty()) {
ok = false;
}
cout << (ok ? "YES" : "NO") << '\n';
}
return 0;
}
这段代码直接复制提交到 UVa 12421 应该是可以通过的。如果读者用的是别的语言,比如 Python,思路也是一模一样,只是把 stack<char> 换成 Python 的 list 而已。
3.4 时间复杂度和空间复杂度分析
这题的数据规模通常不大,但作为竞赛题,分析复杂度依然是基本素养。假设每行字符串长度为 n,算法需要对每个字符访问一次,并可能进行 O(1) 的栈操作,所以时间复杂度是 O(n)。
空间上最坏情况是整行全是左括号,比如 ((((((...,那么栈中最多会存下 n 个括号,因此空间复杂度也是 O(n)。对于题目给定的长度范围,这个复杂度完全够用,不需要额外优化。如果硬要优化,可以用数组模拟栈,预先分配一个大数组,从而减少动态扩容的开销,不过对本题来说,STL 的 stack 已经足够稳定。
4. 常见问题与排查技巧实录
4.1 用计数器判断导致交叉嵌套误判
这是我见过最多的 WA 原因。有的同学会用一个整数 cnt,遇到左括号加一,遇到右括号减一,最后判断 cnt 是否为 0。这样做对 ()() 没问题,但对 ([)] 这种用例会输出 YES,而正确答案应该是 NO。因为交叉嵌套虽然左右数量相等,但顺序非法。
排查方法很简单:多准备几组交叉嵌套的测试数据,比如 ( [ ) ]、{ ( } )。如果代码输出的是 YES,说明你绝对不能用计数器法,老老实实换栈来写。
4.2 scanf 读取字符串导致空格被截断
如果题目输入含有空格,而代码里用的是 scanf("%s", str),程序会把一行文本按空格拆成好几段,每段单独当作一行逻辑处理,结果自然全错。比如输入 a b (),scanf 会先读到 a,再读到 b,再读到 (),最后输出三行结果。这显然不符合题目要求。
这个问题在本地运行时不一定会被发现,因为你可能手动输入数据,眼睛看不出多出来的输出。解决方式就是换用 getline(cin, line)。如果你一定要用 C 风格的函数,可以考虑 fgets,但 fgets 会在字符串尾部保留换行符,需要手动去掉,相比之下 getline 方便很多。
4.3 栈空时直接访问栈顶元素导致运行时错误
当输入以右括号开头,比如 )( 或 )) 时,第一次遇到右括号时栈还是空的。如果代码没判断空栈,而是直接执行 st.top(),程序会触发未定义行为,轻则跑出错误答案,重则直接崩溃。
这类问题排查起来比较费劲,因为不同平台的报错信息不一样。建议在每次执行 st.top() 之前,都先调用 st.empty() 判断一下。我前面的写法是直接用 || 短路,既规范又简洁。
4.4 忽略输出大小写和换行
UVa 的题目要求输出 YES 和 NO,都是大写字母。有的同学代码逻辑对了,但输出成了 Yes 或 No,照样 WA。这是一个很低级但特别容易被忽略的点。
还有换行符的问题。UVa 对每行输出的结尾换行有严格格式要求,如果某一行没有输出换行,评测时会报 Presentation Error 或 WA。用 cout << (ok ? "YES" : "NO") << '\n'; 是最稳的写法,末尾的 '\n' 不能省。
4.5 问题速查表
我把上面这些常见问题和对应的解决方案整理成了一个速查表,方便你调试时对照:
| 现象 | 大概率原因 | 解决办法 |
|---|---|---|
| 交叉嵌套样例输出 YES | 用计数器判断数量 | 改用栈判断顺序 |
| 输入含空格时结果错乱 | 使用了 scanf("%s") |
改用 getline 逐行读取 |
| 遇到纯右括号时崩溃 | 未判断栈空就访问栈顶 | 先判断 st.empty() 再取栈顶 |
输出 Yes 被判 WA |
大小写不符 | 改成全大写的 YES / NO |
| 最后一行没有换行导致 PE | 输出缺少 \n |
末尾统一输出换行符 |
| 空行输入输出不对 | 没有处理空字符串 | 空字符串判定为合法并输出 YES |
这张表不仅可以用于 UVa 12421,对其他字符串模拟题也有参考价值。
5. 扩展思路和一点个人心得
5.1 从这道题延伸出去的进阶题型
UVa 12421 是括号匹配最基础的版本。实际竞赛和工程场景中,括号匹配问题还有不少变形:
- 要求同时输出第一个出错的位置;
- 括号之间允许出现引号,引号内的括号不参与匹配;
- 只给一种括号,但需要求最长合法子串长度;
- 括号匹配与表达式求值相结合,例如判断一个算术表达式是否语法正确。
如果你能把这题的栈思路吃透,后面做这些衍生题会轻松很多。尤其是"最长合法括号子串"这类动态规划题,很多解法里依然会用到栈来辅助确定边界。所以不要小看这道"简单题",它其实是很多复杂题的地基。
5.2 手写栈和 STL 栈怎么选
我以前刷题的时候有个习惯:能用 STL 就用 STL,因为出错概率低、代码短。不过在某些对性能要求更高的题目里,STL 的栈可能会有动态分配的开销。这时候可以改用数组模拟栈,代码也不复杂:
cpp复制char stk[1000005];
int top = 0;
遇到左括号就 stk[top++] = c,遇到右括号就检查 top > 0 && isMatch(stk[top - 1], c),最后判断 top == 0。数组模拟的好处是可控性更强,也不容易担心递归爆栈的问题。对 UVa 12421 来说,STL 栈完全足够,但如果你想练手,手写一遍也能加深对栈结构的理解。
5.3 个人实际调试中的建议
我调试括号匹配这类题目时,习惯自己写一个小的测试脚本,把合法非法用例混在一起,一次性跑完看结果对不对。下面这几组数据是必测的:
text复制()
(
)
(
)
([)]
{()}
abc
其中 (、) 单独成行,是为了测边界;([)] 是为了测交叉;{()} 是为了测多级嵌套。把这些数据跑一遍,如果输出分别是 YES NO NO YES YES NO NO YES,那代码基本就稳了。
最后分享一个小技巧:写完代码提交前,把样例复制到本地跑一遍是基本操作,更重要的是检查 output 的最后一行有没有换行。很多次我以为自己逻辑没问题,结果卡在 Presentation Error 上,都是换行符惹的祸。用 '\n' 而不是 endl,可以在大批量输出时减少不必要的刷新缓冲操作,让程序跑得更顺畅。做题不是只看 AC 那一刻,把每个细节抠清楚,后面打比赛才会少踩坑。
