1. 第一天接触栈,两道经典题教会我的事
先说结论:第十天的这两道题,20. 有效的括号和1047. 删除字符串中的所有相邻重复项,本质上都在考同一个东西——栈的“最近匹配”特性。如果你之前对栈的理解只停留在“先进后出”这四个字上,今天这两道题做完,你会真正明白栈在字符串处理里到底是怎么用的。
我当初刷这两道题的时候,第一反应是:括号匹配这种题目不是用计数器就能做吗?左括号加一,右括号减一,最后判断是不是零不就行了?结果一写就发现问题——([)]这种字符串,计数器法会错误地判定为有效,因为左括号总数等于右括号总数,但实际上括号的嵌套顺序是错的。这就是为什么必须用栈:只有栈能记录“最近出现的未匹配符号”,从而确保闭合顺序正确。
1047题就更直接了,删除字符串中的所有相邻重复项,说白了就是字符串版的“消消乐”。abbaca消除bb后变成aaca,再消除aa后变成ca,注意不是只消一轮就完事,要一直消到没有相邻重复项为止。这种“消掉之后还可能引发新的消除”的场景,天然就是栈的主场。
这两道题都很适合作为栈的入门练习。20题是栈最经典的应用场景,1047题是栈在字符串处理里的直接体现。如果你刚开始刷题,或者对栈的应用场景比较模糊,我强烈建议把这两道题放在一起做——它们能帮你建立“什么时候该用栈”的直觉。接下来我把两道题的完整思路、代码实现和我在实际调试中踩过的坑都拆开讲一遍。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 20. 有效的括号:三种不匹配情况的完整处理
2.1 题目本质:括号匹配的三个硬性条件
有效的括号这道题,题目本身不复杂:给定一个只包含(, ), {, }, [, ]的字符串,判断括号是否有效。有效字符串需要满足三个条件:左括号必须用相同类型的右括号闭合、左括号必须以正确的顺序闭合、每个右括号都有一个对应的同类型左括号。
我习惯把这三个条件翻译成更直白的说法:
- 类型要匹配:
(对应),{对应},[对应],不能交叉。 - 顺序要正确:
([)]是无效的,因为[还没闭合,)就来了。 - 数量要守恒:每个左括号最终都要被右括号消耗掉。
这三个条件单独拿出来都很好理解,但合在一起就容易漏。很多人写这道题的时候,只考虑了“类型匹配”和“数量守恒”,忽略了“顺序正确”这个条件。比如([)],用计数器法判断时左右括号数量相等、总类型也齐全,但它明显是无效的。
这里的关键在于:括号匹配是“就近匹配”的。最后一个出现的左括号,必须被第一个出现的右括号闭合。这种“最近匹配”的特性,和栈的后进先出完全一致,所以栈就是这道题天然的数据结构。
2.2 三种不匹配情况:刷题前先穷举清楚
我把字符串可能出现的不匹配情况梳理了一下,一共三种:
第一种,左括号多了。比如((),遍历完之后栈里还剩一个(,说明没有对应的右括号来闭合它。
第二种,右括号多了。比如()),遍历到第二个)时,栈已经空了,说明这个右括号没有对应的左括号。也可能是类型不匹配,比如(],遍历到]时栈顶是(,两者不配对。
第三种,括号类型不匹配。比如({[)]},遍历到)时栈顶是[,不匹配,直接判定无效。
我在做题的时候习惯先把这三种情况写注释列出来,再动手写代码。这样做的好处是,代码写完以后可以一条一条对着验证,不容易漏边界。很多人写这道题没过,就是因为在代码里只处理了“类型不匹配”,忘了处理“栈为空”和“遍历结束栈不为空”这两种情况。
2.3 代码实现:入栈右括号还是左括号,关键看你怎么匹配
这道题的代码写法有两种主流方案,区别在于入栈的元素是什么。第一种方案是遇到左括号时,把对应的右括号压入栈;第二种方案是遇到左括号时把左括号本身压入栈,遇到右括号时再和栈顶元素比对。
我推荐第一种方案,因为代码更简洁,匹配逻辑也更直观。核心思路是:
- 遇到左括号,就把对应的右括号压入栈。
- 遇到右括号,检查栈顶元素是否和当前右括号相等。
- 如果栈为空,直接返回
false。 - 如果栈顶元素不相等,返回
false。 - 遍历结束后,如果栈为空,返回
true,否则返回false。
cpp复制bool isValid(string s) {
if (s.size() % 2 == 1) return false; // 奇数长度直接排除
stack<char> st;
for (char c : s) {
if (c == '(') st.push(')');
else if (c == '{') st.push('}');
else if (c == '[') st.push(']');
else {
if (st.empty()) return false; // 右括号多了
if (st.top() != c) return false; // 类型不匹配
st.pop();
}
}
return st.empty(); // 左括号多了
}
这个写法巧妙的地方在于:入栈右括号,省去了写if (st.top() == '(')这类判断的麻烦。遇到右括号时直接和栈顶比较是否相等,类型匹配自然就成立了。
2.4 边界情况和复杂度分析
这道题的边界情况主要集中在这几个地方:
字符串为空串时,应该返回true。因为空串没有括号需要匹配,可以理解为天然有效。
字符串长度为奇数时,直接返回false。因为有效括号字符串的长度必然是偶数,每个左括号都要配一个右括号。
if (s.size() % 2 == 1) return false;这个提前判断看似多余,实际上能省去不少后续判断。我试过不加这个判断,代码也能过,但在面试场景下,主动做这个判断能给面试官留下思考全面的印象。
时间复杂度和空间复杂度都是O(n),n是字符串长度。时间上每个字符只处理一次,空间上栈最多需要存储n/2个元素(最坏情况是字符串前一半都是左括号)。
我在实际调试中还发现一个常见问题:很多人会把st.top()直接拿来和字符比较,却忘了先判断栈是否为空。如果字符串以右括号开头,例如)(,此时栈是空的,直接访问st.top()会导致未定义行为。所以if (st.empty()) return false;这个判断必须放在st.top()之前,这个顺序换不得。
3. 1047. 删除字符串中的所有相邻重复项:消消乐的栈解法
3.1 理解题意:一轮消除后可能引发新的消除
这道题的要求是:给出由小写字母组成的字符串,反复删除两个相邻且相同的字母,直到无法继续删除为止,返回最终的字符串。
题目举的例子是abbaca,第一轮删除中间的bb,得到aaca;此时aaca中的aa变成相邻重复项了,再删除一次,得到ca,无法继续删除,返回ca。
这个例子很关键,它说明了这道题的迭代性:删除操作会改变字符的相邻关系,消除一对之后,原本不相邻的字符可能会变成相邻的,从而引发新的消除。所以不能只用一次遍历配合原地修改就完事,必须有一种机制能记录“最近一个还没被消除的字符”。
这不就是栈的语义吗?栈顶元素就是“最近一个还没被消除的字符”。每次遍历到一个新字符,都和栈顶比较:相等,说明构成相邻重复项,栈顶出栈;不等,说明不重复,入栈。
3.2 基础解法:栈的完整实现
cpp复制string removeDuplicates(string s) {
stack<char> st;
for (char c : s) {
if (!st.empty() && st.top() == c) {
st.pop(); // 相邻重复,消除
} else {
st.push(c);
}
}
string result = "";
while (!st.empty()) {
result += st.top();
st.pop();
}
reverse(result.begin(), result.end());
return result;
}
这段代码的逻辑很清晰,但有一个细节需要注意:从栈里取出来的字符顺序是反的。因为栈是后进先出,最先入栈的字符最后才被取出来。所以要么在拼接完以后用reverse反转,要么用result.insert(result.begin(), st.top()),但后者在字符串头部频繁插入字符的时间复杂度是O(n^2),不推荐。
我记得代码随想录里Carl学长讲这道题的时候就强调过:栈操作完成后,顺序是反的,一定要反转。这个坑我在第一次写的时候也踩过,当时输出的结果是ac,和正确答案ca正好相反,排查了半天才发现是忘了反转。
3.3 优化写法:直接用字符串当栈用
这道题还有一个很常见的优化写法:用字符串本身作为栈,省去最后的反转步骤。
cpp复制string removeDuplicates(string s) {
string result;
for (char c : s) {
if (!result.empty() && result.back() == c) {
result.pop_back();
} else {
result.push_back(c);
}
}
return result;
}
为什么这样可行?因为字符串本身就支持在尾部追加和删除字符的操作,push_back和pop_back的时间复杂度都是O(1),和栈的操作完全一致。而且字符串的顺序就是最终结果的顺序,不需要额外反转。
这种写法在代码上更简洁,也省去了显式栈的辅助空间。虽然空间复杂度仍然是O(n)(结果字符串本身就要占空间),但省去了栈的额外开销。在实际刷题和面试中,这种写法往往更受欢迎。
不过我个人的习惯是:如果面试官没有特殊要求,我会先写出显式栈的版本,讲清楚思路,然后再补充“其实可以直接用字符串当栈”,展示一步优化。这样既体现了基础,又展示了优化意识。
3.4 进阶思路:双指针原地修改
1047这道题还有第三种解法——双指针,空间复杂度可以做到O(1)。思路是维护一个慢指针slow,它指向当前结果字符串的末尾位置;快指针fast遍历原字符串。如果slow指向的字符和fast指向的字符相同,说明出现相邻重复,slow回退一步;否则把fast指向的字符写到slow位置,slow前进。
cpp复制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];
}
}
s.resize(slow);
return s;
}
这个写法相当巧妙,它把原字符串原地改造成了结果字符串,slow之前的部分就是最终的答案。第一次看到这个写法的时候,我愣了一下才想明白:s[slow-1]扮演的就是栈顶角色,slow--就是弹出栈顶,s[slow++] = s[fast]就是入栈。本质上还是栈的思路,只是用指针模拟了栈顶位置。
双指针版本在面试中属于加分项,因为空间复杂度从O(n)降到了O(1)。但如果基础还不够扎实,建议先用栈版本把思路理清楚,双指针可以作为后续优化来考虑。
4. 两道题横向对比:从栈的特性到应用场景
4.1 栈能解决的问题特征
把20和1047放在一起看,你会发现它们的共同模式:都需要处理“最近出现的元素”和“当前元素”之间的关系。20题是判断当前右括号能否闭合最近的左括号,1047题是判断当前字符是否和最近的字符构成重复。
这种“最近匹配”的问题特征,总结成一句话就是:如果一个问题需要反复回顾最近出现的元素,并且新元素会改变最近元素的去留,那大概率要用栈。
我自己的判断标准是这样的:
- 是否需要最近匹配?是。
- 匹配成功后,是否还要继续处理下一轮匹配?是。
- 数据是否具有嵌套或连续消除的特性?是。
只要这三个问题里有两到三个回答“是”,栈就可以纳入考虑范围了。
栈在算法题里的应用远不止这两道题,后面还会遇到逆波兰表达式求值、滑动窗口最大值(配合单调队列)、柱状图中最大的矩形(单调栈)等。但所有这些都是建立在“最近匹配”这个核心特性之上的。
4.2 代码实现细节对比
20题和1047题在实现细节上的差异也很值得品味:
20题需要处理三种括号类型的对应关系,所以用了if-else if分支结构来区分入栈元素。
1047题只涉及一种字符的重复判断,所以只需要比较st.top() == c。
20题栈为空时遇到右括号是无效情况,1047题栈为空时直接入栈。
20题遍历结束后必须检查栈是否为空,1047题遍历结束后栈里的元素就是最终结果。
这些差异看似是题目不同导致的,实际上是“括号配对”和“字符消除”两种语义的区别。括号配对关心的是完整性——所有括号都要被闭合;字符消除关心的是剩余性——剩下的字符就是答案。
4.3 复杂度与边界条件速查表
| 题目 | 时间 | 空间 | 核心边界条件 | 调试关键点 |
|---|---|---|---|---|
| 20 有效的括号 | O(n) | O(n) | 栈空时遇到右括号、遍历结束栈非空 | 检查顺序:先判空再取栈顶 |
| 1047 删除相邻重复项 | O(n) | O(n) | 消除后产生新的相邻重复 | 遍历结束的栈顺序需要反转 |
这个表是我刷完这两道题以后整理的。我在复习的时候发现,把相似题目的边界条件放在一起对比,比单独看每道题记得更牢。后面再做类似题目时,我会优先回忆这个表,判断新的题目更接近哪种模式。
5. 代码随想录第十天的实际体验与避坑记录
5.1 我在实际练习中踩过的坑
第十天的训练营打卡,我花了大概一个半小时完成这两道题,主要时间花在了20题上。第一个坑是我前面提到过的:忘记处理栈为空的情况。当时我写的代码是这样的:
cpp复制if (s[i] == ')') {
if (st.top() == '(') st.pop();
else return false;
}
这段代码在遇到字符串以右括号开头时,会直接访问st.top(),而空栈的top()是未定义行为。我的编译器在Debug模式下会抛异常,在Release模式下偶尔会返回一个随机值,导致结果时好时坏。后来加了if (st.empty()) return false;才稳定下来。
第二个坑是1047题忘记反转字符串。这个前面已经提到了,不多说,但我想强调的是:有时候错误不是编译报错能发现的,而是逻辑层面的错误。输出ac而不是ca,代码能编译能运行,但结果就是不对。这时候需要回头审视自己的算法,而不是盲目调试。
第三个坑比较隐蔽,是关于字符串拼接的性能。我之前有一次用result = st.top() + result这种方式拼接结果,在小字符串上没问题,但字符串长度达到上万级别时,运行时间明显变长。因为每次在头部插入字符都是O(n)操作,整体复杂度退化成O(n^2)。后来改成result += st.top()再统一reverse,性能就好了很多。
5.2 调试这两道题的有效方法
我调试这类栈问题时,习惯在关键节点打印栈的内容,尤其是20题。比如:
cpp复制stack<char> st;
for (char c : s) {
if (c == '(' || c == '{' || c == '[') {
st.push(c);
cout << "push: " << c << ", stack size: " << st.size() << endl;
} else {
cout << "current: " << c << ", stack top: ";
if (st.empty()) cout << "empty" << endl;
else cout << st.top() << endl;
}
}
这样跑一遍用例,能很直观地看到栈的变化过程,比在纸上推演要高效得多。1047题同理,可以在入栈出栈前后打印栈的内容,观察消除过程。
5.3 两道题之后的一点心得
做完这两道题,我对“栈在字符串处理中的应用”有了更具体的感知。以前提到栈,我只会想到函数调用栈,觉得它离刷题很远。但实际上,只要问题涉及“回退”或“撤销”最近一步操作,栈就是最自然的选择——编辑器的撤销操作、浏览器的后退功能、括号匹配,背后都是栈。
第十天训练营的强度算是中等,20题是经典中的经典,1047题是栈应用的延伸。如果这两道题都能不看题解独立写出来,说明栈的基础已经打牢了。接下来可以试试逆波兰表达式求值(150题)和滑动窗口最大值(239题),进一步巩固“线性结构处理序列问题”的思路。
我个人的体会是:算法训练营到了第十天,最关键的已经不是某个具体算法的代码背得熟不熟,而是能否建立起“看到题目特征,想到对应数据结构”的直觉。这两道题恰好是培养这种直觉的绝佳素材。
