栈算法题通用思路
栈的核心特性是 后进先出(LIFO),它的所有算法应用都围绕这个特性展开。下面我把栈的高频题型、通用思路和模板一次性讲透,帮你建立体系化的解题框架。
一、栈的核心适用场景
栈的本质是"保留上一个状态,处理当前元素",只要题目符合以下特征,优先考虑栈:
-
需要和前一个/上一个元素做对比、匹配、消除
-
处理嵌套结构(括号、嵌套字符串、嵌套表达式)
-
处理逆序相关问题(逆序输出、序列验证)
-
维护单调顺序(单调栈,用于找下一个更大/更小元素)
二、栈解题的通用步骤
-
判断是否能用栈:是否需要对比前一个元素、处理嵌套结构、维护顺序?
-
选择栈的类型:普通栈/字符串模拟栈/双栈/单调栈
-
定义入栈/出栈规则:明确什么情况入栈,什么情况出栈,栈中存什么(元素/下标/状态)
-
处理边界情况:空栈、多位数提取、连续匹配、嵌套最深层的处理
-
验证逻辑:用示例手动模拟一遍,检查是否覆盖所有情况
三、易错点避坑指南
-
空栈操作:出栈/取栈顶前必须判断栈是否为空,避免越界
-
多位数提取:遇到数字时,循环读取连续数字字符,不要只取一位
-
循环出栈:匹配出栈时要用 while 循环,而不是 if,避免漏匹配连续可出栈元素
-
嵌套结构的状态保存:双栈处理时,必须保证数字栈和字符串栈的入栈/出栈操作一一对应
-
整数除法取整:C++ 中 / 运算符对负数的取整是向零取整,符合题目要求,但要注意和数学上的取整区分
题目1:删除字符串中的所有相邻重复项(LeetCode 1047)
- 题目描述
给出由小写字母组成的字符串 S,重复删除操作会选择两个相邻且相同的字母,并删除它们。
在 S 上反复执行重复项删除操作,直到无法继续删除。
在完成所有重复项删除操作后返回最终的字符串。题目保证答案唯一。
◦ 示例
输入:"abbaca"
输出:"ca"
解释:在 "abbaca" 中,我们可以删除 "bb"(两字母相邻且相同,这是此时唯一可以执行删除操作的重复项),得到字符串 "aaca";之后又只有 "aa" 可以执行重复项删除操作,所以最后的字符串为 "ca"。
提示: 1 <= s.length <= 105,s 仅由小写英文字母组成。
- 核心解法:栈(数组模拟栈)
1) 算法思路
本题的消除逻辑与「开心消消乐」类似,也和经典的「括号匹配」问题思想一致:
当前元素是否被消除,需要依赖上一个元素的信息,因此适合用「栈」来保存已遍历但未被消除的字符。
为了避免使用标准栈容器后还需额外拼接结果,我们可以直接用字符串(数组)模拟栈结构,利用字符串的尾插(+=)、尾删(pop_back())和取栈顶(back())操作,实现栈的「后进先出」特性,最终字符串的内容就是栈中剩余的结果。
2)C++ 完整代码
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; // 栈中剩余字符即为最终结果
}
};
- 知识点拆解
1) 栈的核心特性
后进先出(LIFO):最后入栈的元素最先出栈,这一特性让栈天然适合处理「需要对比上一个元素」的场景。
本题中,栈顶元素始终是上一个未被消除的字符,遍历新字符时,只需和栈顶对比即可判断是否需要消除。
2)字符串模拟栈的优势
|-------------|--------------------------------------|--------------------------|
| 实现方式 | 优点 | 缺点 |
| std::stack | 语义清晰,严格遵循栈的操作接口 | 最终需要将栈中元素逐个弹出并拼接字符串,操作冗余 |
| std::string | 直接用尾插/尾删实现栈操作,代码更简洁;最终字符串即为结果,无需额外拼接 | 语义上不如stack直观,但完全满足本题需求 |
3)时间与空间复杂度分析
时间复杂度:O(n),字符串中的每个字符最多入栈、出栈各一次,总操作次数为线性,n 为字符串长度。
空间复杂度:O(n),最坏情况(字符串中无任何相邻重复项)下,栈需要存储全部 n 个字符。
- 过程模拟(以示例 abbaca 为例)
|------|------------|----------------|
| 遍历字符 | 栈状态(ret) | 操作说明 |
| a | "" → "a" | 栈为空,直接入栈 |
| b | "a" → "ab" | 栈顶为a,与b不相同,入栈 |
| b | "ab" → "a" | 栈顶为b,与当前b相同,出栈 |
| a | "a" → "" | 栈顶为a,与当前a相同,出栈 |
| c | "" → "c" | 栈为空,直接入栈 |
| a | "c" → "ca" | 栈顶为c,与a不相同,入栈 |
最终栈中内容为 "ca",与示例结果一致。
题目2:比较含退格的字符串(LeetCode 844)
- 题目描述
给定 s 和 t 两个字符串,当它们分别被输入到空白的文本编辑器后,如果两者相等,返回 true。# 代表退格字符。
注意:如果对空文本输入退格字符,文本继续为空。
示例 1:
输入:s = "ab#c", t = "ad#c"
输出:true
解释:s 和 t 都会变成 "ac"。
示例 2:
输入:s = "ab##", t = "c#d#"
输出:true
解释:s 和 t 都会变成 ""。
示例 3:
输入:s = "a#c", t = "b"
输出:false
解释:s 会变成 "c",但 t 仍然是 "b"。
提示:
1 <= s.length, t.length <= 200s和t只含有小写字母以及字符'#'
- 核心解法:栈(数组模拟栈)
1)算法思路
退格操作的核心逻辑符合栈的后进先出(LIFO)特性:
遇到普通字符:直接入栈,保留当前字符。
遇到 # 退格符:将栈顶的字符出栈(相当于删除前一个字符);如果栈为空,则不做任何操作(空文本退格后仍为空)。
为了方便后续比较,我们直接用字符串模拟栈,最终字符串的内容就是处理完所有退格后的结果,再对比两个字符串是否相等即可。
2)C++ 完整代码
cpp
class Solution
{
public:
bool backspaceCompare(string s, string t)
{
return changeStr(s) == changeStr(t);
}
string changeStr(string& s)
{
string ret; // 用字符串模拟栈结构
for (char ch : s)
{
if (ch != '#')
ret += ch; // 非退格字符,入栈
else
{
if (ret.size())
ret.pop_back(); // 栈非空,出栈(删除前一个字符)
}
}
return ret;
}
};
- 知识点拆解
1)栈的应用场景
这道题是栈在「字符串模拟」场景的典型应用:
退格操作需要依赖前一个字符的状态,栈的后进先出特性正好可以记录并快速访问最近输入的字符。
空栈时处理退格的边界情况,需要额外判断栈是否为空,避免越界操作。
2)字符串模拟栈的优势
实现简单:直接用 += 实现入栈,pop_back() 实现出栈,size() 判断栈空。
结果直接可用:处理完成后,字符串本身就是最终结果,无需额外拼接。
3)时间与空间复杂度分析
时间复杂度:O(n + m),其中 n 和 m 分别是字符串 s 和 t 的长度。每个字符最多入栈、出栈各一次,总操作次数为线性。
空间复杂度:O(n + m),最坏情况下(字符串中没有退格符),需要存储两个完整的字符串。
题目3:基本计算器 II(LeetCode 227)
- 题目描述
给定一个字符串表达式 s,请你实现一个基本计算器来计算并返回它的值。整数除法仅保留整数部分。
你可以假设给定的表达式总是有效的,所有中间结果将在 [-2^31, 2^31 - 1] 的范围内。
注意:不允许使用任何将字符串作为数学表达式计算的内置函数,比如 eval()。
◦ 示例 1:
输入:s = "3+2*2"
输出:7
◦ 示例 2:
输入:s = " 3/2 "
输出:1
◦ 示例 3:
输入:s = " 3+5 / 2 "
输出:5
◦ 提示: 1 <= s.length <= 3 * 105;s 由整数和算符 ('+', '-', '*', '/') 组成,中间由一些空格隔开;s 表示一个 有效表达式 ;表达式中的所有整数都是非负整数,且在范围 [0, 231 - 1] 内;题目数据保证答案是一个 32-bit 整数
- 核心解法:栈模拟运算
1) 算法思路
四则运算的优先级是「先乘除,后加减」,我们可以利用栈的特性,把高优先级的乘除运算先就地处理,加减运算则延迟到最后再统一求和:
用一个栈保存待求和的数字。
遍历字符串时,先提取完整数字,再根据数字前的运算符决定操作:
+:数字直接入栈
-:数字的相反数入栈(等价于后续做减法)
*:栈顶元素出栈,与当前数字相乘,结果再入栈
/:栈顶元素出栈,与当前数字相除(整数除法),结果再入栈
遍历结束后,栈中所有元素的和就是最终结果。
2)C++ 完整代码
cpp
class Solution
{
public:
int calculate(string s)
{
vector<int> st; // 用数组模拟栈结构
int i = 0, n = s.size();
char op = '+'; // 记录当前数字前的运算符,初始为'+'
while (i < n)
{
if (s[i] == ' ')
{
i++; // 跳过空格
}
else if (isdigit(s[i]))
{
// 提取完整数字(处理多位数)
int tmp = 0;
while (i < n && isdigit(s[i]))
{
tmp = tmp * 10 + (s[i] - '0');
i++;
}
// 根据前一个运算符执行操作
if (op == '+')
st.push_back(tmp);
else if (op == '-')
st.push_back(-tmp);
else if (op == '*')
st.back() *= tmp;
else if (op == '/')
st.back() /= tmp;
}
else
{
// 记录当前运算符
op = s[i];
i++;
}
}
// 栈中所有元素求和,得到最终结果
int ret = 0;
for (int x : st)
ret += x;
return ret;
}
};
- 知识点拆解
1)栈的核心作用
栈的本质是「暂存待运算元素」,通过将乘除运算就地执行,把复杂的混合运算简化为最终的加法运算,完美适配四则运算的优先级规则。
把减法转化为「加负数」,是这类题的常用技巧,避免了单独处理减法的逻辑。
2)关键细节处理
多位数提取:字符串中的数字可能是多位的,需要循环读取连续的数字字符,拼接成完整整数。
空格处理:表达式中可能包含空格,需要在遍历时直接跳过。
除法取整:题目要求整数除法仅保留整数部分,直接使用 C++ / 运算符即可(向零取整,符合题目要求)。
3)时间与空间复杂度分析
时间复杂度:O(n),其中 n 是字符串长度,每个字符最多被遍历一次。
空间复杂度:O(n),最坏情况下(全是加减运算),栈中需要存储所有数字。
- 过程模拟(以示例 1 3+2*2 为例)
|------|--------|--------|--------------------------|----------|
| 遍历内容 | 运算符 op | 数字 tmp | 栈操作 | 栈状态 |
| 3 | + | 3 | push_back(3) | [3] |
| + | 更新为 + | - | 记录运算符 | [3] |
| 2 | + | 2 | push_back(2) | [3, 2] |
| * | 更新为 * | - | 记录运算符 | [3, 2] |
| 2 | * | 2 | st.back() *= 2 → 2*2=4 | [3, 4] |
最终栈中元素和为 3 + 4 = 7,与示例结果一致。
题目4:字符串解码(LeetCode 394)
- 题目描述
给定一个经过编码的字符串,返回它解码后的字符串。
编码规则为:k[encoded_string],表示其中方括号内部的 encoded_string 正好重复 k 次。注意 k 保证为正整数。
你可以认为输入字符串总是有效的:输入字符串中没有额外的空格,且输入的方括号总是符合格式要求的。
此外,原始数据不包含数字,所有的数字只表示重复的次数 k,不会出现像 3a 或 2[4] 的输入。
◦ 示例 1:
输入:s = "3[a]2[bc]"
输出:"aaabcbc"
◦ 示例 2:
输入:s = "3[a2[c]]"
输出:"accaccacc"
◦ 示例 3:
输入:s = "2[abc]3[cd]ef"
输出:"abcabccdcdcdef"
- 核心解法:双栈模拟
1) 算法思路
字符串解码的核心难点是嵌套结构(如 3[a2[c]]),需要先处理内层括号,再处理外层括号,这天然符合栈的「后进先出」特性:
定义两个栈:
nums:存储重复次数k
strs:存储待拼接的字符串,初始先放一个空串,方便后续直接拼接
分4种情况处理:
遇到数字:提取完整的多位数,压入nums栈
遇到 [:把[后面的连续字母提取成字符串,压入strs栈
遇到 ]:从nums栈顶取重复次数k,从strs栈顶取待重复字符串,重复k次后,拼接到strs新栈顶的字符串后面
遇到单独字母:提取连续字母,直接拼接到strs栈顶的字符串后面
2)C++ 完整代码
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];
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];
i++;
}
st.top() += tmp;
}
}
return st.top();
}
};
- 知识点拆解
1) 双栈的核心作用
nums 栈:保存括号对应的重复次数,遇到 ] 时取出使用
st 栈:保存括号前的上下文字符串,解码完成后将重复后的字符串拼接回去,完美支持嵌套结构
初始化 st.push("") 是关键,避免处理第一个 [ 时栈为空导致的操作异常
2) 关键细节处理
多位数数字提取:循环读取连续数字字符,拼接成完整整数(如 123[a] 中的 123)
括号嵌套处理:栈的后进先出特性保证了内层括号先解码,外层再使用内层的结果,例如 3[a2[c]] 会先解码 2[c] 得到 cc,再解码 3[acc] 得到 accaccacc
临时字符串拼接:遇到字母直接拼接到当前栈顶字符串,遇到 ] 时将重复后的字符串拼接回上一层上下文
3) 时间与空间复杂度分析
时间复杂度:O(n),其中 n 是解码后的字符串长度。每个字符最多被拼接和处理常数次
空间复杂度:O(m),其中 m 是编码字符串的长度,栈的深度最多为嵌套括号的层数
- 过程模拟(以示例 2 3[a2[c]] 为例)
|------|------------------------------------------|----------|-----------------|-------------|
| 遍历字符 | 操作 | nums栈 | st栈 | 当前临时字符串 |
| 3 | 提取数字 3 入栈 | [3] | [""] | "" |
| [ | 压入当前字符串 "",重置临时字符串 | [3] | ["", ""] | "" |
| a | 拼接 a 到临时字符串 | [3] | ["", ""] | "a" |
| 2 | 提取数字 2 入栈 | [3, 2] | ["", ""] | "a" |
| [ | 压入当前字符串 "a",重置临时字符串 | [3, 2] | ["", "", "a"] | "" |
| c | 拼接 c 到临时字符串 | [3, 2] | ["", "", "a"] | "c" |
| ] | 弹出 2 和 "c",重复 2 次得到 "cc",拼接回上一层 | [3] | ["", "acc"] | "acc" |
| ] | 弹出 3 和 "acc",重复 3 次得到 "accaccacc",拼接回上一层 | [] | ["accaccacc"] | "accaccacc" |
最终栈顶字符串即为结果 "accaccacc"。
题目5:验证栈序列(LeetCode 946)
- 题目描述

- 核心解法:栈模拟
1) 算法思路
直接用一个栈模拟入栈、出栈的完整流程,验证 popped 序列是否能被完全匹配:
遍历 pushed 序列,依次将元素压入栈中
每次入栈后,循环检查栈顶元素是否与 popped 当前位置的元素相等:
若相等,则执行出栈操作,并将 popped 的指针向后移动一位
若不相等,则继续入栈下一个元素
遍历结束后,若 popped 序列的所有元素都被匹配(指针已走到末尾),则说明序列合法,返回 true;否则返回 false
2)完整 C++ 代码
cpp
class Solution
{
public:
bool validateStackSequences(vector<int>& pushed, vector<int>& popped)
{
stack<int> st;
int i = 0, n = popped.size();
for (auto x : pushed)
{
st.push(x);
// 栈顶元素与popped当前元素匹配,就持续出栈
while (!st.empty() && st.top() == popped[i])
{
st.pop();
i++;
}
}
// 若popped全部匹配完成,则合法
return i == n;
}
};
- 关键知识点拆解
1) 栈模拟的核心逻辑
入栈顺序不可变:必须严格按照 pushed 序列的顺序执行 push 操作
出栈操作被动触发:只要栈顶元素与 popped 当前元素匹配,就立刻执行 pop 操作,保证 popped 序列被尽可能匹配
匹配完成判断:i == n 等价于栈为空,说明所有元素都按 popped 序列的顺序被成功弹出
2) 复杂度分析
时间复杂度:O(n),每个元素最多入栈和出栈各一次,总操作次数为线性
空间复杂度:O(n),最坏情况下(无任何出栈操作),栈中需要存储所有 pushed 元素
- 过程模拟(示例1)
|--------------|-------------|-------------|----------------------------------------------------------------------------------------|
| 遍历 pushed 元素 | 栈状态 | popped 指针 i | 操作说明 |
| 1 | [1] | 0 | 入栈,栈顶 1 ≠ 4,无出栈 |
| 2 | [1,2] | 0 | 入栈,栈顶 2 ≠ 4,无出栈 |
| 3 | [1,2,3] | 0 | 入栈,栈顶 3 ≠ 4,无出栈 |
| 4 | [1,2,3,4] | 0 | 入栈,栈顶 4 == 4 → 出栈,i=1;栈顶 3 ≠ 5,停止出栈 |
| 5 | [1,2,3,5] | 1 | 入栈,栈顶 5 == 5 → 出栈,i=2;栈顶 3 == 3 → 出栈,i=3;栈顶 2 == 2 → 出栈,i=4;栈顶 1 == 1 → 出栈,i=5;栈空,停止出栈 |
遍历结束后 i=5,等于 popped.size(),因此返回 true