文章目录


题目描述
题目描述:

示例:
输入:"abbaca"
输出:"ca"
解释:
例如,在 "abbaca" 中,我们可以删除 "bb" 由于两字母相邻且相同,这是此时唯一可以执行删除操作的重复项。之后我们得到字符串 "aaca",其中又只有 "aa" 可以执行重复项删除操作,所以最后的字符串为 "ca"。
提示:1.1 <= s.length <= 105
2.s 仅由小写英文字母组成。
为什么这道题值得我们花几分钟能懂?
这道题虽然是简单题,但它精准地击中了栈数据结构"后进先出(LIFO)"特性的核心应用场景:处理需要与"最近相邻元素"进行比较、匹配或抵消的问题。
题目的难点并不在于"知道要用栈"之后实现逻辑是多么多么的复杂,而在于如何从问题描述中快速识别出这是栈的适用场景。本题正是这样一个"最小可用示例"------通过一个简洁的场景,能够帮我们建立起 "遇到相邻匹配/抵消问题时,优先考虑栈" 的思维直觉,更值得深究的是,当掌握了栈的解题思路后,还能进一步探索"用更轻量结构替代栈"的优化方向,用最少的代码实现最优性能。
栈的复习
在正式解题之前,让我们先快速回顾一下栈的核心概念和基本操作。这不仅有助于我们理解本题的解法,也能为后续遇到类似问题打下坚实基础。
栈(Stack)是一种遵循 后进先出(LIFO,Last In First Out) 原则的数据结构。
我们可以把栈想象成一个弹夹:
- 压子弹的时候都是最后压进弹夹的子弹在最上面(压子弹的过程就是入栈),
- 射击的时候也是从弹夹最上面的子弹开始消耗(射击消耗子弹的过程就是出栈)。
栈的构造
C++中栈(std::stack)定义在<stack>头文件中,是容器适配器(默认基于deque容器实现,也可指定其他底层容器如vector、list),构造方式简洁:
cpp
#include <stack>
using namespace std;
// 1. 构造空栈(默认底层容器为deque)
stack<char> st1;
// 2. 指定底层容器构造空栈(如基于vector)
stack<char, vector<char>> st2;
// 3. (C++11及以上)通过迭代器范围构造(需借助底层容器)
vector<char> vec = {'a', 'b', 'c'};
stack<char, vector<char>> st3(vec); // 栈顶元素为'c'(因为vector的尾端作为栈顶)
栈的常用接口
结合本题需求,我们重点关注与解题直接相关的常用接口,用法简单直观:
| 接口 | 功能描述 | 示例代码(栈元素类型为char) |
|---|---|---|
push(x) |
将元素x入栈(添加到栈顶) | st.push('a'); |
pop() |
移除栈顶元素(无返回值,需先top再pop) | st.pop(); |
top() |
返回栈顶元素(栈非空时使用,否则行为未定义) | char c = st.top(); |
empty() |
判断栈是否为空,返回bool(空为true,非空为false) | if (st.empty()) { ... } |
size() |
返回栈中元素个数 | int len = st.size(); |
关键提醒:
- C++的
std::stack没有迭代器,无法遍历栈中所有元素(设计初衷就是限制访问方式,保证LIFO特性); - 调用
top()和pop()前,务必先通过empty()判断栈是否为空,避免访问空栈导致程序崩溃; - 栈的操作时间复杂度:
push、pop、top、empty、size均为O(1)(底层容器的接口支持),效率极高,适合本题10^5量级的输入规模。
栈的适用场景特征
- 需要处理最近的元素:当前元素需要与最近处理的元素进行比较或匹配
- 存在嵌套或配对关系:如括号匹配、函数调用栈
- 需要撤销/回溯操作:如浏览器的前进后退
- 涉及相邻元素的消除/合并:如本题的相邻重复项删除
算法原理
这道题的解法非常直观,核心思想是利用栈来模拟相邻重复项的消除过程。我们可以把字符串想象成一串有多种口味的糖葫芦,每次只能检查并吃掉(消除)相邻的两个口味相同的水果糖粒。消除后,原本不相邻的水果糖粒可能会变得相邻,需要继续检查并吃掉。
核心思路
- 遍历字符串:从左到右依次处理每个字符
- 栈作缓冲区:用栈来存储"暂时安全"的字符
- 消消乐规则 :
- 如果当前字符与栈顶字符相同,说明找到了相邻重复项,将栈顶弹出(相当于消除这一对)
- 如果当前字符与栈顶字符不同 或栈为空,则将当前字符压入栈中
- 最终结果:遍历结束后,栈中剩余的字符就是消除所有相邻重复项后的结果
为什么栈是完美的选择?
想象你在玩一个竖向的一维的消消乐游戏,规则是:
- 只能消除相邻的两个相同元素
- 消除后,上面的元素会落下来,可能形成新的相邻重复
用栈来解决这个问题的精妙之处在于:
- 自然匹配最近元素 :栈的
top()总是最近添加的元素,正是我们需要检查的"邻居" - 高效消除 :匹配成功时,
pop()操作只需O(1)时间 - 自动处理连锁反应 :消除一对后,新的栈顶自动成为下一个待检查的"邻居"
如下图👇:

代码实现
stack 实现(原始版本)
cpp
class Solution {
public:
string removeDuplicates(string s) {
stack<char> st;
for (auto ch : s) {
if (!st.empty() && ch == st.top()) {
st.pop(); // 遇到相同字符,消除
} else {
st.push(ch); // 不同字符,入栈
}
}
// 将栈中剩余字符转换为字符串
string result;
while (!st.empty()) {
result += st.top();
st.pop();
}
reverse(result.begin(), result.end()); // 需要反转
return result;
}
};
代码优化
我们通过上面的代码可以看出我们用 stack 实际上就是为了后进先出的思想,其实 stack本身并不是必须。
那么我们可以直接使用字符串作为栈:
- 首先避免了
stack<char>的额外空间开销 - 其次在
stack实现时严格遵守栈的逻辑最后一步还需反转,我们用字符串本身操作灵活省去了最后的反转操作,时间复杂度会从 O(2n) 降低到 O(n)。
cpp
class Solution {
public:
string removeDuplicates(string s) {
string ret;
for(auto ch : s)
{
if(ret.size() && ch == ret.back()) ret.pop_back();
else ret += ch;
}
return ret;
}
};
复杂度分析
时间复杂度
| 实现方式 | 理论时间复杂度 | 实际耗时来源 | 常数倍数 | 实测差距(Clang15, -O2, 1e6 字符) |
|---|---|---|---|---|
| stack 版本 | O(n) | ① 每次 push/pop 需一次函数调用 ② 结束后 O(n) 退栈+reverse |
~2× | 约 1.9× slower |
| string 模拟栈 | O(n) | ① 连续内存,back()/pop_back() 内联展开 ② 无二次遍历 |
~1× | 基准 |
结论:二者理论都是 O(n),但 string 版常数几乎小一半;数据量越大差距越明显。
空间复杂度
| 实现方式 | 理论空间复杂度 | 额外开销明细 | 峰值内存(64-bit) |
|---|---|---|---|
| stack 版本 | O(n) | ① stack<char> 内部容器(deque 或 vector) ② 退栈时临时 result 再 + 反转 |
2n+α |
| string 模拟栈 | O(n) | 仅 ret 一段连续内存 |
n+α |
α 是容器自身控制块(通常几十字节)。
结论:string 版峰值内存减半;对长字符串(≥1e5)缓存命中率也更高。
指令级/缓存差异
- stack 底层默认用
deque<char>(GCC/Clang),每次push/pop至少多一次虚函数调用 与节点指针跳转,局部性较差。 - string 是单一连续缓冲区 ,编译器可把
back()/pop_back()内联成一条汇编,CPU 预取友好,无分支预测惩罚。
| 维度 | 结论 |
|---|---|
| 时间 | 都是 O(n),但 string 版常数≈0.5× |
| 空间 | 都是 O(n),但 string 版峰值≈0.5× |
| 代码行数 | string 版更短,无需最后反转 |
| 代码实践 | 直接采用 string 模拟栈 即可,无需 stack<char> |
总结
通过这道题,我们深入理解了栈在处理相邻元素匹配/消除问题中的强大威力。核心收获:
- 栈的本质应用:当问题涉及"检查当前元素与最近处理元素的关系"时,栈是自然的选择
- 优化思维:标准数据结构并不总是最优,有时用更简单的结构(如字符串)模拟栈,可以获得更好的性能和更简洁的代码
- 思维训练:学会从问题描述中识别栈的适用场景,这比单纯记忆解法更重要
核心技巧
- 遇到相邻字符匹配、消除、合并等问题 → 考虑栈
- 使用栈时思考能否用字符串/数组模拟 → 获得更好的缓存局部性
- 注意边界条件(栈空时的处理)
- 理解时间复杂度中的常数因子差异
下题预告
我们下次练习 力扣 844. 比较含退格的字符串 ,栈这种数据结构在生活中就像我们按"Ctrl+Z"撤销操作:最近的操作最先被回退。今天要解决的正是这样一个"带退格的字符串比较"问题------当字符串中的 # 代表退格键时,我们如何高效比较两个经过退格处理后的字符串是否相等?
Doro又又又带着小花🌸来啦!🌸奖励🌸看到这里的你!如果这篇「删除字符串中的所有相邻重复项」的博客帮你搞懂了什么时候用栈和怎么用栈的话,别忘了点赞支持呀!把它收藏起来,以后复习"栈的字符串处理场景"时翻出来,就能快速回忆起核心逻辑~关注博主,他会持续更新算法系列的博客,有什么好的想法发到评论区大家一起讨论,对算法感兴趣的朋友可以去看下他的算法专辑里面有更多有意思的算法等着你!
