什么是栈?(核心特性:LIFO)
栈是一种操作受限的线性表 。它的核心特性是 LIFO(Last In First Out,后进先出)。
基本操作(C++ STL):
-
push(x):将元素 x 压入栈顶。 -
pop():弹出栈顶元素(注意:C++ 的pop()不返回弹出的值)。 -
top():返回栈顶元素的引用。 -
empty():判断栈是否为空。 -
size():返回栈中元素个数。
例题剖析
题目一:删除字符串中所有相邻的重复项
cpp
class Solution {
public:
string removeDuplicates(string s) {
string ret; // 1. 使用 string 作为栈(因为 string 支持 push_back 和 pop_back)
for(auto ch : s) // 2. 遍历输入字符串 s 中的每一个字符
{
// 3. 如果栈不为空,且当前字符与栈顶字符相同
if(ret.size() && ch == ret.back())
{
ret.pop_back(); // 4. 弹出栈顶元素(抵消掉这对相邻重复项)
}
else
{
ret += ch; // 5. 否则将当前字符入栈
}
}
return ret; // 6. 返回最终结果
}
};
解题思路:栈(Stack)
这道题非常适合用栈的思想来解决。可以遍历字符串,把字符依次放入一个容器中:
-
如果当前字符与容器末尾 的字符相同,说明遇到了相邻重复项,将末尾字符弹出(相当于删除这两个相邻字符)。
-
如果不同,则将当前字符压入容器。
-
遍历结束后,容器中剩下的字符就是最终结果。
复杂度分析
-
时间复杂度: O(n) 。其中
n是字符串的长度。只需要对字符串进行一次遍历,每个字符最多入栈一次、出栈一次。 -
空间复杂度: O(n) 。最坏情况下(例如字符串为
"abcd"或"aaaa"),ret字符串的长度会达到n。
题目二:比较含退格的字符串
cpp
class Solution {
public:
bool backspaceCompare(string s, string t) {
// 分别处理两个字符串,然后比较结果
return changeStr(s) == changeStr(t);
}
// 辅助函数:处理单个字符串的退格逻辑
string changeStr(string& s) {
string ret; // 使用 string 作为栈
for(char ch : s) // 遍历字符串
{
if(ch != '#')
ret += ch; // 普通字符,入栈
else
{
// 遇到退格符,且栈不为空,弹出栈顶元素
if(ret.size())
ret.pop_back();
}
}
return ret; // 返回处理后的字符串
}
};
解题思路:栈(Stack)模拟
题目要求比较两个字符串在文本编辑器中输入后的结果。可以写一个辅助函数 changeStr,用来计算一个字符串经过退格操作后的最终形态。在这个辅助函数中,我们遍历字符串:
-
如果遇到普通字符 ,将其压入栈(即
ret += ch)。 -
如果遇到退格符
#:-
如果栈不为空(
ret.size() > 0),说明有字符可以退格,弹出栈顶元素(ret.pop_back())。 -
如果栈为空,根据题目要求"对空文本输入退格字符,文本继续为空",不做任何操作。
-
-
遍历结束后,栈中的内容就是最终的文本。
最后,分别计算 s 和 t 处理后的结果,判断它们是否相等即可。
复杂度分析
-
时间复杂度: O(n + m) ,其中
n和m分别是字符串s和t的长度。我们分别遍历了这两个字符串一次。 -
空间复杂度: O(n + m) 。最坏情况下(例如字符串全是普通字符,没有
#),辅助函数会创建与原字符串等长的ret字符串。不过相比于上一题,这里的ret在函数结束后就会销毁,内存占用是瞬时的。
进阶思考(双指针法 - O(1) 空间)
由于这道题要求比较两个字符串,其实可以不使用额外的栈空间 ,而是使用双指针从后往前遍历:
-
因为
#只会影响它前面的字符,所以从后往前遍历更容易确定哪些字符会被删除。 -
具体做法:维护两个指针分别指向
s和t的末尾,并维护两个变量skipS和skipT记录当前需要跳过的字符数。 -
遇到
#就增加skip计数,遇到普通字符就判断skip是否大于 0:大于 0 则跳过当前字符并减少skip;等于 0 则说明这个字符是最终保留的,拿去和另一个字符串的当前字符比较。如果不相等直接返回false。 -
这种方法可以将空间复杂度优化到 O(1)。
题目三:基本计算器||
cpp
class Solution {
public:
int calculate(string s) {
vector<int> st; // 用数组来模拟栈结构,比 std::stack 更快
int i = 0, n = s.size();
char op = '+'; // 记录当前数字前面的运算符,初始为 '+' 保证第一个数字能入栈
while(i < n) {
// 1. 跳过空格
if(s[i] == ' ') {
i++;
}
// 2. 提取数字
else if(s[i] >= '0' && s[i] <= '9') {
int tmp = 0;
// 将连续的字符数字转换为整数
while(i < n && s[i] >= '0' && s[i] <= '9') {
tmp = tmp * 10 + (s[i++] - '0');
}
// 3. 根据 op 进行运算
if(op == '+') st.push_back(tmp);
else if(op == '-') st.push_back(-tmp);
else if(op == '*') st.back() *= tmp; // 乘除直接与栈顶元素运算
else st.back() /= tmp;
}
// 4. 遇到运算符,更新 op
else {
op = s[i];
i++;
}
}
// 5. 栈中元素求和
int ret = 0;
for(auto x : st) ret += x;
return ret;
}
};
这道题的核心难点在于运算符优先级 :乘除法(*、/)的优先级高于加减法(+、-)。为了处理这种优先级,可以采用"延迟计算加减法,优先计算乘除法"的策略,利用栈来存储待相加的数值。
解题思路:栈(Stack)处理优先级
遍历字符串,用一个变量 op 记录当前数字前面 的运算符(初始化为 '+',这样第一个数字就能顺利入栈)。
-
提取数字 :遇到数字时,用
while循环把连续的数字字符转换成一个完整的整数tmp。 -
根据
op处理数字:-
如果
op是'+':直接将tmp压入栈。 -
如果
op是'-':将-tmp(负数)压入栈。这样后续只需要把所有栈内元素相加即可,不用处理减法。 -
如果
op是'*'或'/':说明当前数字要和上一个刚入栈的数字 进行乘除运算。因此,我们直接弹出栈顶元素,与tmp运算后,再将结果压回栈中。
-
-
更新
op:遇到非数字且非空格的字符时,说明遇到了新的运算符,更新op = s[i]。 -
最终计算:遍历结束后,栈中存储的都是需要进行加减法(其实都已经转化成了加法)的数值。将栈中所有元素累加,即为最终结果。
复杂度分析
-
时间复杂度: O(n) 。其中
n是字符串的长度。我们只需要对字符串进行一次遍历,每个数字和运算符最多被处理一次。 -
空间复杂度: O(n) 。最坏情况下(例如
"1+1+1+1+1"),栈中会存储所有的数字,因此空间复杂度为 O(n)。
细节提示
-
初始
op = '+':这是一个技巧。它保证了遇到第一个数字时,即使前面没有运算符,也能正常执行st.push_back(tmp)。 -
使用
-tmp:遇到减法时不直接计算,而是把相反数压入栈,最后统一做加法。这避免了处理复杂的括号和减号逻辑,是栈解法的核心优势。
题目四:字符串解码
cpp
class Solution {
public:
string decodeString(string s)
{
stack<int> nums;
stack<string> st;
st.push("");
int i = 0, n = s.size();
while(i < n)
{
if(s[i] >= '0' && s[i] <= '9')
{
int tmp=0;
while(s[i]>='0' && s[i]<='9')
{
tmp=tmp*10+(s[i]-'0');
i++;
}
nums.push(tmp);
}
else if(s[i]=='[')
{
i++;
string tmp="";
while(s[i]>='a' && s[i]<='z')
{
tmp+=s[i++];
}
st.push(tmp);
}
else if(s[i]==']')
{
string tmp=st.top();
st.pop();
int k = nums.top();
nums.pop();
while(k--)
{
st.top()+=tmp;
}
i++;
}
else
{
string tmp;
while(i < n && s[i] >= 'a' && s[i] <= 'z')
{
tmp+=s[i++];
}
st.top()+=tmp;
}
}
return st.top();
}
};
解题思路:基于"哨兵"的栈模拟
这道题要求解码嵌套的字符串(如 3[a2[c]])。通过两个栈(一个存数字,一个存字符串)和一次遍历来完成:
-
初始化哨兵 :
代码一开始执行了
st.push("");。这非常关键!它相当于在栈底放了一块"地基"。- 作用:保证了无论何时遇到
],栈st里至少有一个字符串可以用来承接拼接结果,从而彻底避免了之前因栈空调用st.top()导致的崩溃。
- 作用:保证了无论何时遇到
-
遍历字符串
s:-
遇到数字
0-9:用while循环提取连续的数字,转换成整数tmp,压入数字栈nums。 -
遇到
[:-
指针
i后移跳过[。 -
紧接着用
while循环把[后面紧跟着的纯字母提取出来 ,拼成一个字符串tmp。 -
将这个
tmp压入字符串栈st。
-
-
遇到
]:-
从
st弹出栈顶字符串tmp(这是刚刚提取的、括号内部的内容)。 -
从
nums弹出数字k(这是重复次数)。 -
执行
while(k--),把tmp重复k次,追加到当前栈顶的字符串尾部 (st.top() += tmp)。 -
指针
i后移跳过]。
-
-
遇到普通字母
a-z(在括号外或没有紧跟在[后的情况):-
用
while循环提取一段连续的字母tmp。 -
直接追加到当前栈顶的字符串尾部(
st.top() += tmp)。
-
-
-
返回结果 :
遍历结束后,栈底的那唯一的字符串(也就是初始的那个
""发展而来的)就是最终答案,返回st.top()即可。
复杂度分析
-
时间复杂度: O(n)。
-
其中
n是字符串s的长度。 -
外层
while(i < n)确保每个字符最多被访问一次。虽然内层有多个while循环(提取数字、提取字母),但它们只是在推进指针i,并没有重复遍历。 -
(潜在优化空间) :在
while(k--)拼接字符串时,如果字符串极长且嵌套极深,st.top() += tmp可能会引发底层内存的多次拷贝,理论最坏时间复杂度可能退化到 O(n²)。但在本题数据范围(长度 ≤ 30)下,这种拼接完全在性能红线内。
-
-
空间复杂度: O(n)。
- 最坏情况下(例如
1[1[1[...]]]或字符串本身没有括号),栈中会存储大量的数字和字符串片段,空间复杂度与输入规模成正比。
- 最坏情况下(例如
细节提示
st.push("")
如果没有 st.push(""); 会发生什么?
当输入为 "3[a]2[bc]" 时:
-
遇到第一个
[,把"a"压入栈。此时栈st = ["a"]。 -
遇到第一个
],弹出"a"并把结果加到st.top()上。此时栈st变成了空栈[]。 -
接着读取到
2,然后又遇到[,把"bc"压入栈。此时栈st = ["bc"]。 -
遇到第二个
],弹出"bc",然后再次执行st.top() += tmp。 -
崩溃点就在这里 :此时栈是空的,对空栈调用
st.top()就是访问非法内存,直接报错。
有了 st.push(""); 之后发生了什么?
它就像是在栈底放了一块永远抽不走的"地基"(也就是哨兵节点):
-
初始状态 :栈
st = [""](里面有一个空字符串)。 -
遇到第一个
],弹出内容处理后,要把结果加到栈顶。此时st.top()是那个地基""。它变成了"aaa"。此时栈st = ["aaa"]。(栈永远不会空) -
遇到第二个
],弹出内容处理后,要把结果加到栈顶。此时st.top()是之前的"aaa"。它变成了"aaabcbc"。此时栈st = ["aaabcbc"]。(栈依然永远不会空)
这个"地基"带来的三大好处:
-
彻底杜绝空栈异常 :因为栈底始终有一个字符串兜底,无论你怎么弹(
st.pop()),永远弹不到它,栈的大小永远>= 1。这样写st.top()时就可以完全放开手脚,不用担心越界。 -
统一了拼接逻辑 :如果不用哨兵,在拼凑第一段括号外的字母时(比如
"abc3[d]"里的"abc"),需要额外判断"此时栈是不是空的,如果空就要创建新字符串"。有了st.push(""),所有的拼接操作都变成了统一的st.top() += tmp,省去了大量的if-else边界判断,代码极其简洁。 -
直接返回结果 :遍历结束后,不需要去遍历栈找出最后一个元素,直接
return st.top()即可。因为所有结果最终都被拼接到那个最初为空、现在饱满的字符串上了。
题目五:验证栈序列
cpp
class Solution {
public:
bool validateStackSequences(vector<int>& pushed, vector<int>& popped) {
stack<int> st; // 模拟栈
int i = 0, n = popped.size(); // i 指向 popped 数组中当前需要匹配的元素
for (auto x : pushed) { // 遍历 pushed 数组
st.push(x); // 1. 将 pushed 元素压入栈
// 2. 如果栈顶元素正好是 popped 当前需要的元素,就不断弹出
while (st.size() && st.top() == popped[i]) {
st.pop();
i++;
}
}
// 3. 如果 i 走到了 n,说明所有元素都成功匹配出栈
return i == n;
}
};
解题思路:贪心模拟
可以直接用一个真实的栈来模拟这个过程:
-
按顺序入栈 :遍历
pushed数组,将元素依次压入栈中。 -
尝试出栈 :每次压入一个元素后 ,立刻检查栈顶元素是否等于
popped序列中当前等待出栈的元素(即popped[i])。-
如果相等,说明这个元素应该立刻弹出。我们将栈顶元素弹出,并将
popped的指针i向后移动一位。 -
注意这里要用
while循环 :因为弹出当前元素后,新的栈顶元素可能也正好等于popped的下一个元素,需要连续弹出,直到不匹配为止。
-
-
判断结果 :当
pushed数组遍历完毕后,如果popped数组也全部匹配完了(即i == n),说明序列合法,返回true;否则返回false。
复杂度分析
-
时间复杂度: O(n) 。其中
n是pushed数组的长度。pushed中的每个元素最多入栈一次、出栈一次,所以总操作次数是线性的。 -
空间复杂度: O(n) 。最坏情况下(例如
pushed = [1,2,3],popped = [3,2,1]),栈会存储所有元素。
细节提示
-
为什么用
while而不是if?假设
pushed = [1,2,3],popped = [3,2,1]。当压入
3时,st.top() == 3,弹出3,i变成 1,此时popped[1]是2,而新的栈顶也是2。如果用if,这次循环就结束了,2就错过了弹出的机会。用while就能保证连续弹出所有符合条件的元素。 -
st.size()的作用 :防止栈为空时调用st.top()导致未定义行为。 -
最终判断
i == n:如果遍历完pushed后,popped数组还没有被完全匹配完(i < n),说明有些元素无法按顺序弹出,序列不合法。
std::stack VS ``std::vector 或普通数组
它们在逻辑语义 上完全一样(都是后进先出 LIFO),但在底层实现、性能表现和编码体验上有显著的区别。
底层数据结构与内存分配(最根本的区别)
1. std::stack(容器适配器)
-
本质 :
std::stack不是容器,而是一个容器适配器 。它底层默认使用的是std::deque(双端队列)。 -
内存 :
std::deque的内存是分块连续的(由多个固定大小的内存块组成,通过一个中控数组连接)。这意味着它不需要一整块巨大的连续内存。 -
扩容 :当内存不够时,
deque会分配一个新的内存块,不会像vector那样把所有旧数据拷贝到新的大块内存中。
2. 数组模拟(通常用 std::vector 或静态数组)
-
本质 :
std::vector底层是一块完全连续的动态内存。 -
内存:所有元素紧挨着存放。
-
扩容 :当容量不足时,
vector会申请一块更大的连续内存 (通常是原大小的 1.5 或 2 倍),然后把旧数据全部拷贝 过去,再释放旧内存。如果是静态数组(如int st[10005]),则是在栈上分配固定大小,完全没有扩容开销。
性能差异
1. 缓存命中率
-
数组(
vector)胜出:由于内存完全连续,CPU 的缓存预取机制能发挥最大作用。当你访问栈顶时,相邻的内存大概率已经在 L1/L2 缓存里了。 -
std::stack(deque):由于是分块的,跨块访问时可能会导致缓存未命中,速度略慢。
2. 扩容开销
-
数组(
vector) :在已知数据规模(如题目提示n <= 10^5)时,可以提前reserve()预留空间,从而完全消除扩容开销。如果没预留,扩容时的拷贝操作也会拖慢速度。 -
std::stack(deque) :deque扩容时不需要拷贝旧元素,在这点上它比未预留空间的vector更快。
3. 函数调用开销
-
数组 :
push_back()和pop_back()通常是内联(inline)的,编译器会直接展开为几条机器指令(移动指针、赋值)。 -
std::stack:st.push()和st.pop()是一层封装,虽然编译器也会尝试内联,但在某些编译器优化级别下,可能不如直接操作数组快。
4. 实际测试结论
在 LeetCode 这种数据规模下(通常 n <= 10^5 或 10^6),两者的性能差异其实非常小(通常在几毫秒以内)。
编码体验与安全性
1. std::stack 的优势
-
语义清晰 :代码一看就知道是栈,
push、pop、top、empty一目了然,不易出错。 -
安全性高 :自带边界检查(虽然
pop空栈是未定义行为,但至少不会越界访问数组)。 -
无需管理容量:不用操心扩容问题。
2. 数组模拟的优势
-
灵活性极高 :
std::stack只能访问栈顶。但用vector模拟时,你可以随时访问st[0]、st[st.size()-2],甚至可以遍历整个栈。这是std::stack做不到的。 -
可以直接当数组用 :有些题目(如单调栈)需要访问栈底或栈中第二个元素,用
vector模拟就非常方便。
总结
| 对比维度 | std::stack (默认 deque) |
数组模拟 (std::vector) |
|---|---|---|
| 底层内存 | 分块连续 | 完全连续 |
| 缓存友好度 | 一般 | 极高 |
| 扩容开销 | 无拷贝,分配新块 | 有拷贝(可 reserve 避免) |
| 访问灵活性 | 只能访问栈顶 | 可访问任意位置 |
| 代码可读性 | 语义清晰 | 略微底层,但简洁 |
| 适用场景 | 标准栈操作,逻辑简单 | 单调栈、需要访问栈内其他元素、追求极致性能 |
识别信号
在算法题中,栈的应用通常集中在以下三大类:
1. 括号匹配与表达式求值
这是栈最直观的应用。因为括号是成对出现的,且具有嵌套结构(比如 [ { ( ) } ]),这与栈的 LIFO 特性完美契合。
-
括号匹配:遇到左括号入栈,遇到右括号检查栈顶是否是对应的左括号。
-
表达式求值(中缀转后缀/逆波兰表达式):利用两个栈(一个存数字,一个存运算符)处理优先级,比如"基本计算器"。
-
字符串解码 :遇到
[入栈,遇到]出栈拼接。
2. 消除相邻重复项
只要题目中出现**"相邻元素相互抵消"** 、"删除重复项" 、**"撤销操作"**等字眼,99% 都是用栈来解决。
-
遍历元素时,如果当前元素与栈顶元素"匹配"(比如相同,或者符合某种消除规则),就
pop()掉栈顶;否则就把当前元素push()进去。 -
典型题目:删除字符串中的所有相邻重复项、比较含退格的字符串。
3. 单调栈
单调栈是一种特殊的栈,它强制保持栈内元素单调递增或单调递减 。它是解决 "寻找下一个更大/更小元素" 类问题的终极武器。
- 核心逻辑:当新元素要入栈时,如果它破坏了栈的单调性(比如求下一个更大元素,栈是递减的,但新元素比栈顶大),就把栈顶元素弹出,并记录下新元素就是栈顶元素的"下一个更大元素"。如此循环,直到新元素入栈后依然保持单调性。