好家伙,训练营第十天打卡。今天这两道题——20. 有效的括号、1047. 删除字符串中的所有相邻重复项——放在一起刷,其实设计得挺巧。乍一看是两个独立题目,一个在讲括号匹配,一个在讲字符串消除,但本质上都在围绕同一个数据结构打转:栈。
如果你刚从链表、哈希表这些章节过来,第一次接触“栈”这个专题,那今天这两道题就是最好的入门练手。它们不是那种上来就甩你一个单调栈、让你一脸懵的难题,而是把栈最核心的“先进后出”“就近匹配”这两个特性,用最直观的方式展示给你。刷完这两道,你会对“什么时候该用栈”这件事有非常具体的体感。
这篇文章我按训练营的节奏来复盘一下这两道题,包括解题思路、代码实现、我踩过的坑,以及从这两道题延伸出去的一些思考。如果你也正在跟训练营,或者自己刷题刚好刷到这,希望这篇能帮你把这两个经典问题吃透。
1. 为什么这两道题是“栈”的经典入门题
1.1 栈这个数据结构,刷题时到底在考什么
很多人在学校学数据结构的时候,栈就是“先进后出”四个字,背完就过了。但刷题的时候遇到栈相关的题目,往往第一反应是“这题凭什么用栈?”而不是“这题该怎么用栈”。这两道题最大的价值,就是帮你建立“问题特征”和“数据结构选择”之间的连接。
先看栈的核心特性:你只能在栈顶操作,后进去的元素先出来。这个特性天然适合处理“需要记住历史状态,并且最近的记录优先被处理”的场景。用一个生活化的类比来说,栈就像一摞盘子,你洗完一个放上去一个,要用的时候永远先拿最上面那个。括号匹配里,最后一个出现的左括号要最先被匹配;字符串消除里,最新的字符要和它前面紧挨着的字符比较。这两件事,本质上都是“就近优先”的处理逻辑,和栈的行为模式完全一致。
所以刷栈的题目,关键不是背代码,而是培养一种敏感度:看到“相邻”“配对”“最近”“回溯”这类关键词,能条件反射地想到栈。今天这两道题,正好覆盖了栈最经典的两种应用场景——配对校验和相邻消除。
1.2 两道题放在一起的用意:匹配问题的两种形态
训练营把这两道题安排在同一天,我觉得是有深意的。它们一个是“外部配对”问题——括号必须左右对称;一个是“内部消除”问题——相邻重复必须删掉。表面上一个在讲符号合法性,一个在讲字符串净化,但底层逻辑高度一致。
如果画一个抽象层,两道题都在做同一件事:从左到右扫描数据,维护一个“待处理”的栈,每次遇到一个新元素,就和栈顶元素比较,看是否满足某种“抵消”或“配对”条件。满足就弹栈,不满足就压栈。整个算法的正确性,依赖于栈顶元素恰好是“最需要被当前元素处理”的那个历史元素。
你甚至可以理解为,这两道题是“同一个算法思路的两个变体”。区别只在于比较规则:有效的括号比较的是左右括号是否成对,删除相邻重复项比较的是两个字符是否相同。所以如果你做完第一道题,第二道题理论上应该能秒出思路。如果第二道题卡住了,说明你对第一道题的理解还不够本质,建议回头重新捋一遍。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 第一题:20. 有效的括号——匹配问题的标准范式
2.1 题意与核心思路
题目要求判断一个只包含 ()、[]、{} 三种括号的字符串是否有效。有效条件是:左括号必须有相同类型的右括号闭合,并且括号必须以正确的顺序闭合。
比如 () 有效,()[]{} 有效,但 (] 无效,([)] 也无效。注意最后一个例子,它包含了所有类型的括号,但顺序是错位的,这就是“正确顺序”的含义——不仅数量要对,位置也要对。
我第一次做这题的时候,脑子里蹦出来的想法是计数器。比如遇到 ( 就加一,遇到 ) 就减一,最后看是否归零。这个思路马上被 ([)] 这种用例打脸:每种括号单独计数,( 和 ) 是一对,[ 和 ] 是一对,但整体顺序完全乱套。这说明括号匹配不是简单的计数问题,而是结构嵌套问题。
正确的思路就是栈。从左到右遍历字符串,遇到左括号就压栈;遇到右括号时,检查栈顶元素是否是对应的左括号。如果是,弹栈;如果不是,或者栈已经空了,直接判定无效。遍历完整字符串后,栈必须是空的,否则说明有左括号没被闭合。
这里有个很关键的小技巧:与其在遇到右括号时写一堆 if-else 判断当前栈顶是 ( 还是 [ 还是 {,不如在遇到左括号时,直接把对应的右括号压入栈。这样遇到右括号时,只需要比较栈顶元素是否和当前字符相等就行。逻辑上清爽很多,代码也更好写。这个技巧在题解里很常见,但如果你第一次接触,可能会觉得“为什么要存右括号?”,本质上是把“匹配规则”前置到压栈阶段,让后续比较变成一次简单的相等判断。
2.2 代码实现与逐步拆解
给你一份我最终通过的标准写法,用的 C++:
cpp复制class Solution {
public:
bool isValid(string s) {
stack<char> st;
for (char c : s) {
if (c == '(') st.push(')');
else if (c == '[') st.push(']');
else if (c == '{') st.push('}');
else {
// 此时 c 一定是右括号
if (st.empty() || st.top() != c) return false;
st.pop();
}
}
return st.empty();
}
};
一步步拆解:
- 遇到左括号:把对应的右括号压栈。这一步的意思其实是“我期望未来出现一个右括号来抵消我”。
- 遇到右括号:先看栈是不是空的。如果栈空,说明当前右括号没有对应的左括号,比如输入是
)(),第一个字符就是右括号,直接判定无效。再看栈顶元素是否等于当前字符。如果等于,说明匹配成功,弹栈;如果不等于,说明括号类型不匹配,比如(],直接返回 false。 - 遍历结束后:检查栈是否为空。如果栈不为空,说明还有左括号没被闭合,比如
((),返回 false。
这里需要注意:st.empty() 的判断必须放在 st.top() != c 之前。原因很简单,调用 top() 函数时如果栈是空的,C++ 的标准库会直接报 UB(未定义行为),体现为运行时错误或随机结果。这个顺序问题我见过很多新手栽过,包括我自己早期写的时候也踩过。
2.3 三个新手最常见的坑
第一个坑是“只判断了数量,没判断顺序”。比如 ([)] 这种用例,如果你用计数器或者哈希表统计数量,得到的结果是每种括号数量都配平了,但字符串本身是无效的。我这道题在第一遍提交时就是用计数器写的,直接被这个用例打回。刷题平台给的测试用例往往不会让你这么轻易过关,所以一定要想清楚“结构合法”和“类型合法”之间的区别。
第二个坑是“字符匹配写反了”。我见过有人写成 if (st.top() == '(' && c == ')') 这种判断方式,然后再去处理其他括号,代码又长又容易出错。如果一开始就压入对应的右括号,这个问题就不存在了。所以有时候换个角度存数据,比写一堆判断条件要优雅得多。
第三个坑是“遍历完忘记检查栈是否为空”。比如输入是 ((,遍历结束后栈里还有元素,但如果你只检查了中间过程中的失败情况,最后却忘了 return st.empty(),那这个用例就会错误地返回 true。这类边界条件在面试中必考,刷题时一定要养成习惯:每一步操作后,带着“全局状态”去审视整个流程。
3. 第二题:1047. 删除字符串中的所有相邻重复项——消消乐的栈解法
3.1 题意与核心思路
这道题的描述很简单:给出由小写字母组成的字符串 s,重复项删除操作会选择两个相邻且相同的字母,并删除它们。在 s 上反复执行重复项删除操作,直到无法继续删除,返回最终字符串。
比如输入 "abbaca",过程是这样的:先看到 bb,删掉,剩下 "aaca";然后看到 aa,删掉,剩下 "ca"。最终答案是 "ca"。
这里有个特别容易忽略的细节:删除 bb 之后,a 和 a 会变成新的相邻元素,又构成一组重复项。也就是说,删除操作会产生“连锁反应”。这正好对应栈的行为:当你弹出一个元素后,新的栈顶暴露出来,它可能和下一个待处理的字符再次匹配。这也是为什么这道题用栈来做非常自然——你不需要手动调整下标,栈天然支持“删除后回退一步”的操作。
如果你把这道题和“祖玛游戏”“消消乐”类比,就会发现核心机制是一样的:相邻相同则消除,消除后可能引发新一轮消除。只不过这里限制为只能消两个相同的字符,规则更简单一些。
3.2 用栈实现“消除”逻辑,代码就这么写
思路:维护一个栈,遍历字符串的每个字符。如果当前字符和栈顶字符相同,说明出现了一对相邻重复项,把栈顶元素弹出;否则把当前字符压入栈。遍历结束后,栈里剩下的字符就是最终结果。
C++ 写法如下:
cpp复制class Solution {
public:
string removeDuplicates(string s) {
string res;
for (char c : s) {
if (!res.empty() && res.back() == c) {
res.pop_back();
} else {
res.push_back(c);
}
}
return res;
}
};
你没看错,我没有单独定义 std::stack,而是直接用 string 来模拟栈。res.back() 就是栈顶,res.push_back() 就是压栈,res.pop_back() 就是弹栈。这样做有两个好处:一是省掉了最后从栈里倒腾字符到字符串的步骤,直接返回 res 就行;二是内存访问更连续,性能上通常比 std::stack<char> 要好一些。
这是这道题我认为最值得讲的地方:栈不一定非要用 std::stack,字符串、数组、甚至一个普通的 vector,都可以当栈用。核心是你在逻辑上遵循“后进先出”的规则,物理存储结构其实无所谓。很多高级解法都是这么来的,比如用双端队列、用数组加指针,都是基于同样的思想做变体。
3.3 不用栈的空间优化写法(双指针法)
这道题还有一种不额外申请空间的写法,用双指针模拟栈。思路是维护一个“结果区”的末尾指针,遍历原字符串,把每个字符“搬运”到结果区,然后根据规则决定这个字符是留下还是被移除。
代码长这样:
cpp复制class Solution {
public:
string removeDuplicates(string s) {
int slow = 0;
for (int fast = 0; fast < s.size(); fast++) {
if (slow > 0 && s[slow - 1] == s[fast]) {
slow--;
} else {
s[slow] = s[fast];
slow++;
}
}
s.resize(slow);
return s;
}
};
slow 指针指向下一个写入位置,同时它也表示当前“有效结果”的长度。fast 指针用于遍历原字符串。每次 fast 指向一个字符时,对比它与“结果区”最后一个字符(即 s[slow - 1])是否相同。相同则说明构成了重复项,slow 回退一位,相当于弹栈;不同则把 s[fast] 写到 s[slow] 的位置,slow 前进一位,相当于压栈。
这个写法本质上还是栈的思路,只是把栈直接架在了原字符串上,省去了额外空间。面试时如果你能写出这种优化,会是一个不错的加分项。不过日常刷题,先用 string 当栈的版本提交即可,逻辑更清晰,也不容易写错。双指针版本可以作为锻炼思维的进阶练习。
4. 实际调试与踩坑记录
4.1 我在训练营里踩过的坑
先说第一道题。我一开始采用的是“遇到左括号,就把对应的左括号压栈;遇到右括号,就判断栈顶是否是对应的左括号”的写法。整个代码写下来长了差不多一倍,而且判断逻辑分散在三个分支里。提交的时候在一个用例上报了错,我还记得是一个长到不行的嵌套括号串,最后调试了半天才发现是某个分支里把 } 和 ] 的匹配写反了。后来看了题解里的“压入右括号”写法,瞬间觉得整个人通透了。所以有时候,一个微小的设计选择,能极大降低代码出错的概率。
再说第二道题。我第一次提交的时候,写的是 string res; 然后遍历原字符串,但遍历范围写错了,用了 s.size() 而不是 s.length() 这类低级错误。更关键的坑是:当我把 res 初始化为空字符串时,res.back() 不能在 res.empty() 为真的时候调用,否则会崩溃。我一开始没有写 !res.empty() 的判断,直接一上来就判断 res.back() == c,空串直接越界。这类问题在本地跑可能不报错,但在线评测环境下一般是直接 RE(运行时错误),排查起来挺费劲。
还有一种情况,如果有人用递归去模拟这个过程,字符串稍长一点就爆栈了,所以在刷题时,能用迭代的栈就别用递归。训练营里有个同学就是写了递归版,然后拿 "aaaaaaaa" 这个用例去测试,直接 stack overflow,问我怎么回事。递归深度等于字符串长度,链表式的递归在数据量大时非常危险,这个习惯得改。
4.2 边界条件检查清单
这两道题虽然简单,但边界条件一个都不能漏。整理一个检查清单,刷完题后对着过一遍:
| 检查项 | 有效的括号 | 删除相邻重复项 |
|---|---|---|
| 空字符串 | 应为 true | 应为空串 |
| 只有一个字符 | 左括号应为 false,右括号应为 false | 应为自身 |
| 全部是左括号 | 应为 false(栈不为空) | 应原样返回 |
| 全部是右括号 | 应为 false(栈空时遇到右括号) | 应原样返回 |
| 已匹配的成对串 | 如 ()[]{},应为 true |
如 abba 消完为空 |
| 交错错位串 | 如 ([)],应为 false |
如 abccba 消完为空 |
| 极端长串 | 注意栈的深度,用迭代避免递归深 | 注意 string 的扩容开销 |
你可能会觉得这些边界条件“太极端”,但刷题平台最喜欢在这样的用例上设卡。面试时,面试官也特别喜欢让你手动跑一遍这些边界用例,看你有没有这个意识。所以建议每做完一道题,都按这个清单走一遍,形成肌肉记忆。
5. 从这两道题延伸出去:栈的更多应用场景
5.1 类似的匹配类问题,刷题路上还会遇到很多
做完这两道题,你会发现栈的应用远不止“括号匹配”和“字符消除”。整个 LeetCode 栈专题里,大量题目都是这两种模式的变体或组合。
比如表达式求值问题(逆波兰表达式求值),本质上就是遇到数字压栈,遇到运算符弹出两个数计算后再压栈,这就是“相邻处理”的加强版;再比如简化路径问题,用 .. 表示回退到上一级目录,对应栈的弹栈操作;还有函数调用栈、浏览器的前进后退机制,都是栈的现实映射。
如果你考研或者面试时被问到“栈还有什么实际应用”,能说出以下几个场景,会比单纯背定义加分不少:
- 函数调用:每次调用子函数,系统把返回地址压入调用栈,子函数返回后从栈顶弹出,恢复执行现场。
- 撤销/重做:文档编辑器里的撤销操作,就是把每一次操作记录压栈,撤销时弹栈并逆向执行。
- 括号匹配器、XML/HTML 标签校验:编译器静态分析工具里经常用到栈来检查标签是否合法。
- 深度优先搜索(DFS):递归就是隐式的栈,显式用栈也可以实现 DFS,避免递归深度限制。
5.2 下一阶段的衔接:从栈到单调栈
今天的题目是栈的基础篇,后续训练营必然会引入单调栈。单调栈是一种在栈的基础上额外维护“栈内元素有序”的特殊栈,常用在“找下一个更大元素”“接雨水”“柱状图中最大的矩形”这类问题中。
如果你今天这两道题刷得很轻松,那学习单调栈的时候会顺畅很多。因为单调栈的代码框架也是“遍历元素 + 维护栈 + 根据条件弹栈”,只是弹栈的判断条件从“相等/配对”变成了“更大/更小”。从“相等匹配”到“大小比较”,是一个自然的抽象层级跳跃。我当初学单调栈的时候,就是靠今天这两道题打底,才没有觉得太突兀。
所以刷题不要只刷数量,要刷“模型”。今天这两道题就是一个很好的模型提炼点:凡是当前元素需要和左侧最近的、未被抵消的元素进行某种比较或匹配的问题,十有八九都要用到栈。
6. 回顾与个人体会
最后聊点个人感受。这两道题放在第十天,其实是一个温故知新的位置。前面几天你刚把数组、链表、哈希表过了一遍,现在进入“栈”这个专题,等于打开了另一个维度的数据结构视野。栈本身不复杂,但应用极广,而今天这两道题用最温和的方式让你体会到了栈的威力。
我在实际刷题过程中的一个体会是:栈相关的题目,特别适合画图来理解。尤其是第二道题,用“消消乐”的视角去看,删除 bb 后 a 和 a 再相撞的过程,你画出来比脑补要直观得多。我建议你也尝试在纸上模拟一遍 "abbaca" 的完整执行过程,通过手写压栈弹栈,你才能真正熟悉这个数据结构的行为模式。
另外一个建议是善用调试工具。这两道题虽然简单,但如果你在本地用 VS Code 或 CLion 跑代码,可以试着在每次 push 和 pop 的时候打印一下当前栈里的内容,会帮助你建立非常直观的认知。这套方法对后续的很多难题同样适用。
如果你今天这两道题都顺利 AC 了,恭喜你,栈这一关算是迈进了门槛;如果卡住了,也别气馁,回头再看看这篇文章的思路拆解,动手重新推演一遍,把代码亲手写出来,很快就能吃透。算法这个东西,没有太多玄学,就是一次一次地在代码里验证你的思维模型。栈,就是这个模型里非常趁手的一个工具。
