1. 算法思想
栈(Stack)是一种遵循后进先出(Last In First Out, LIFO)原则的抽象数据结构。其核心思想在于元素的添加和删除操作仅在栈的一端进行,这一端被称为栈顶(Top)。栈常用于处理具有嵌套或递归特性的问题,例如表达式求值、函数调用、括号匹配等场景。
基本操作
栈的核心操作包括:
- 入栈(Push):将元素添加到栈顶。
- 出栈(Pop):移除并返回栈顶元素。
- ** peek/Top**:返回栈顶元素但不弹出。
- 判空(Empty):检查栈是否为空。
- ** size**:返回栈中元素的数量。
栈的算法应用场景
-
括号匹配问题
验证表达式中括号的平衡性(如"([{}])"
)。遍历字符串时,遇到左括号入栈,遇到右括号时弹出栈顶元素检查是否匹配。 -
表达式求值
计算中缀表达式(如"3+4*(2-1)"
)时,使用两个栈分别存储操作数和运算符,通过运算符优先级控制计算顺序。 -
递归转迭代
递归函数的调用过程本质上使用了系统栈。手动维护一个栈可以将递归算法转换为迭代实现,避免栈溢出风险。 -
浏览器历史记录
浏览器的后退功能使用栈结构:每次访问新页面时将 URL 入栈,后退时出栈。 -
撤销操作
文本编辑器的撤销功能通过栈记录每一步操作,撤销时依次弹出最近的操作。
复杂度分析
- 时间复杂度:入栈和出栈操作均为 O (1)。
- 空间复杂度:最坏情况下需要存储所有元素,空间为 O (n)。
注意事项
- 栈溢出:当栈深度过大时可能导致内存耗尽,需注意递归深度或手动管理栈的大小。
- 空栈操作:在执行 Pop 或 Top 操作前,需确保栈非空,否则可能引发异常。
栈的 LIFO 特性使其在处理嵌套结构和需要回溯的问题中表现出色,是算法设计中的重要工具之一。
2. 例题
2.1 有效括号
核心思路基于栈的后进先出(LIFO)特性和哈希表的快速映射,具体可拆解为以下 3 步:
1. 数据结构选择
- 栈(stack):用于存储遍历到的左括号(
(
、{
、[
),利用栈的 "后进先出" 特性,确保最近的左括号优先与右括号匹配(符合括号嵌套规则)。 - 哈希表(unordered_map):预存左括号到对应右括号的映射(
{'(' : ')', '{' : '}', '[' : ']'}
),实现右括号与左括号的快速匹配校验。
2. 遍历字符串处理逻辑
- 遇到左括号:直接将其压入栈中,等待后续对应的右括号来匹配。
- 遇到右括号:
- 若栈为空:说明右括号没有对应的左括号(右括号多余),直接返回
false
。 - 若栈非空:取出栈顶的左括号,通过哈希表查找其对应的右括号,与当前右括号对比:
- 若不匹配(如
(
对应]
),返回false
。 - 若匹配,弹出栈顶左括号(表示该对括号已处理)。
- 若不匹配(如
- 若栈为空:说明右括号没有对应的左括号(右括号多余),直接返回
3. 最终校验
遍历结束后,若栈为空:说明所有左括号都找到了对应的右括号(无多余左括号),返回true
;若栈非空:说明存在未匹配的左括号,返回false
。
核心思想总结
通过栈维护左括号的嵌套顺序,通过哈希表快速校验括号类型匹配,最终通过栈是否为空判断整体有效性,高效解决括号匹配问题(时间复杂度 O (n),空间复杂度 O (n))。
bool isValid(string s) {
stack<char> st;
unordered_map<char, char> hash = {
{'{', '}'},
{'(', ')'},
{'[', ']'}
};
for(auto ch : s)
{
if(ch == '(' || ch == '{' || ch == '[')
st.push(ch);
else
{
if (st.empty())
return false; // 右括号多余,直接无效
if(ch == hash[st.top()])
st.pop();
else return false;
}
}
return st.size() == 0;
}
2.2 删除字符串中的所有相邻重复项
1047. 删除字符串中的所有相邻重复项 - 力扣(LeetCode)
核心思路是使用字符串本身作为栈,在线性时间内高效地删除相邻重复字符。具体步骤如下:
1. 数据结构选择
- 字符串
ret
:既作为结果存储容器,又充当栈的角色。利用字符串的back()
和pop_back()
方法(时间复杂度均为 O (1)),模拟栈的top()
和pop()
操作。
2. 遍历与处理逻辑
- 遍历字符串
s
:逐个字符处理。 - 当前字符
ch
:- 若
ret
非空且ch
等于ret
的最后一个字符:
说明遇到了相邻重复字符,删除ret
的最后一个字符(即pop_back()
)。 - 否则:
将ch
添加到ret
的末尾(即push_back(ch)
)。
- 若
3. 核心思想
- 栈的 LIFO 特性:
通过将字符串用作栈,每次检查当前字符与栈顶字符是否相同。若相同,则弹出栈顶;否则入栈。这样可以保证所有相邻重复字符被连续删除,最终得到的字符串中不存在相邻重复字符。 - 时间与空间复杂度:
每个字符仅被处理一次(入栈或出栈),时间复杂度为 O(n);最坏情况下栈的深度为 n,空间复杂度为 O(n)。
string removeDuplicates(string s) {
string ret;
for(auto ch : s)
{
if(ret.size() && ch == ret.back()) ret.pop_back();
else ret += ch;
}
return ret;
}
2.3 比较含退格的字符串
核心思路是模拟退格操作对字符串的影响,通过构建处理后的字符串来比较两者是否相等,具体可拆解为以下 3 步:
1. 数据结构选择
- 字符串
s1
和s2
:分别作为处理字符串s
和t
后的结果容器,同时模拟栈的行为(利用pop_back()
和+=
操作模拟退格和字符添加)。
2. 处理逻辑(以 s
为例,t
同理)
- 遍历字符串
s
的每个字符ch
:- 若
ch
是退格符#
:
若s1
非空(即存在可删除的字符),则删除s1
的最后一个字符(s1.pop_back()
),模拟退格效果。 - 若
ch
是普通字符:
直接将其添加到s1
的末尾(s1 += ch
),模拟正常输入。
- 若
3. 最终比较
处理完两个字符串后,直接比较 s1
和 s2
是否完全相同。若相同,说明两个原始字符串经过退格处理后结果一致,返回 true
;否则返回 false
。
核心思想总结
通过模拟栈的操作处理退格逻辑,将原始字符串转换为 "退格后实际显示的字符串",再通过字符串比较判断结果是否一致。该思路时间复杂度为 O(n + m)(n
和 m
分别为 s
和 t
的长度),空间复杂度为 O(n + m),直观且易于理解。
bool backspaceCompare(string s, string t) {
string s1, s2;
for(auto ch : s)
{
if(ch == '#')
{
if(s1.size()) s1.pop_back();
}
else s1 += ch;
}
for(auto ch : t)
{
if(ch == '#')
{
if(s2.size()) s2.pop_back();
}
else s2 += ch;
}
return s1 == s2;
}
2.4 基本计算器 II
核心思路是使用栈处理表达式中的运算符优先级,并将减法转换为负数的加法,从而简化最终计算。具体可拆解为以下 3 步:
1. 数据结构与初始化
- 栈
st
:存储中间计算结果(数值)。 - 运算符
op
:初始化为'+'
,用于记录当前处理的运算符。
2. 遍历表达式处理逻辑
- 跳过空格:忽略所有空格字符。
- 处理数字:
- 提取连续的数字字符(如
"123"
),转换为整数tmp
。 - 根据前一个运算符
op
决定如何处理tmp
:'+'
:直接将tmp
压入栈。'-'
:将-tmp
压入栈(减法转换为负数的加法)。'*'
/'/'
:弹出栈顶元素,与tmp
进行乘除运算后将结果压回栈(优先处理高优先级运算符)。
- 提取连续的数字字符(如
- 更新运算符:遇到
+
、-
、*
、/
时,更新op
为当前符号。
3. 最终求和
遍历结束后,栈中所有元素均为加减法(减法已转换为负数),直接累加所有元素得到最终结果。
核心思想总结
通过栈延迟计算加减法,并优先处理乘除法,将复杂表达式转换为纯加法运算。具体优势:
- 运算符优先级:乘除法在遍历过程中立即计算,加减法延迟到最后。
- 减法转换:将减法转换为负数的加法,统一运算类型。
- 空间优化:栈中仅存储数值,无需存储运算符,简化计算逻辑。
int calculate(string s) {
// 处理乘除法
// 处理大于10的数
// 处理减法
// 全部相加
vector<int> st;
char op = '+';
int n = s.size(), i = 0;
while(i < n)
{
if(s[i] == ' ') ++i;
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');
if(op == '+') st.push_back(tmp);
else if(op == '-') st.push_back(-tmp);
else if(op == '*') st.back() *= tmp;
else st.back() /= tmp;
}
else
{
op = s[i];
++i;
}
}
int sum = 0;
for(auto x : st) sum += x;
return sum;
}
2.5 字符串解码
核心思路是使用双栈处理嵌套的字符串重复问题,通过栈结构维护上下文信息,实现括号内字符串的正确展开。具体可拆解为以下 5 步:
1. 数据结构选择
- 字符串栈
st
:存储待拼接的字符串片段(包括括号内的字符串)。 - 数字栈
num
:存储重复次数k
。 - 初始占位符:在栈底压入空字符串
""
,避免处理单独字母时访问空栈。
2. 遍历字符串处理逻辑
- 处理数字:
- 提取连续数字字符(如
"3"
),转换为整数k
,压入num
栈。
- 提取连续数字字符(如
- 处理左括号
[
:- 跳过
[
,提取后续连续小写字母(如"abc"
),压入st
栈。
- 跳过
- 处理右括号
]
:- 弹出
st
栈顶字符串tmp
(即当前括号内的字符串)。 - 弹出
num
栈顶数字k
(重复次数)。 - 将
tmp
重复k
次后追加到新的栈顶字符串(即括号前的字符串)。
- 弹出
- 处理单独字母:
- 提取连续小写字母(如
"xyz"
),直接追加到当前栈顶字符串。
- 提取连续小写字母(如
3. 关键机制
- 栈的嵌套关系:每次遇到
[
,将当前处理的字符串压栈,开始处理新的嵌套层级;遇到]
,弹出当前层级的字符串并合并到上层。 - 重复展开:通过
k
次循环将字符串tmp
追加到上层字符串,确保正确展开嵌套结构(如"3[a2[c]]"
→"accaccacc"
)。
4. 示例过程
输入:
s = "3[a2[c]]"
- 初始:
st = [""]
,num = []
- 处理
3
:压入num
→num = [3]
- 处理
[
:跳过,处理a
→ 压入st
→st = ["", "a"]
- 处理
2
:压入num
→num = [3, 2]
- 处理
[
:跳过,处理c
→ 压入st
→st = ["", "a", "c"]
- 处理
]
:
- 弹出
st
栈顶"c"
,弹出num
栈顶2
→ 重复"c"
两次 → 追加到新栈顶"a"
→st = ["", "acc"]
- 处理
]
:
- 弹出
st
栈顶"acc"
,弹出num
栈顶3
→ 重复"acc"
三次 → 追加到新栈顶""
→st = ["accaccacc"]
- 最终返回:
"accaccacc"
5. 优化点说明
- 空栈保护:初始压入
""
避免处理单独字母(如"a3[b]"
)时访问空栈。 - 嵌套处理:通过双栈正确维护括号层级关系,确保内层字符串优先展开并合并到外层。
string decodeString(string s) {
stack<string> st;
stack<int> num;
st.push(""); // 防止会访问空栈的 top(),导致运行时崩溃。
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;
num.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 = num.top();
num.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();
}
2.6 验证栈序列
核心思路是模拟栈的压入和弹出过程,验证给定的弹出序列是否合法。具体可拆解为以下 4 步:
1. 数据结构选择
- 辅助栈
st
:模拟真实栈的压入和弹出操作。 - 指针
i
:跟踪当前需要弹出的元素在popped
数组中的位置。
2. 遍历压入序列
- 压入元素:按顺序将
pushed
中的元素压入辅助栈st
。 - 尝试弹出:每次压入后,检查栈顶元素是否等于
popped[i]
:- 若相等,则弹出栈顶元素,并将
i
后移一位(表示该元素已成功弹出)。 - 重复此过程直到栈为空或栈顶元素不等于
popped[i]
。
- 若相等,则弹出栈顶元素,并将
3. 最终验证
遍历完所有压入元素后,若 i
等于 popped
数组的长度,说明所有弹出操作都合法;否则,弹出序列不合法。
核心思想总结
通过模拟栈的操作,确保在每一步都尽可能地执行弹出操作。如果最终能弹出所有预期元素(即 i
遍历完 popped
数组),则序列合法。该思路时间复杂度为 O(n),空间复杂度为 O(n),高效且直观。
示例过程
- 输入:
pushed = [1,2,3,4,5]
,popped = [4,5,3,2,1]
- 压入
1
,栈[1]
,栈顶1 ≠ 4
,不弹出。- 压入
2
,栈[1,2]
,栈顶2 ≠ 4
,不弹出。- 压入
3
,栈[1,2,3]
,栈顶3 ≠ 4
,不弹出。- 压入
4
,栈[1,2,3,4]
,栈顶4 == 4
,弹出 → 栈[1,2,3]
,i++
。- 压入
5
,栈[1,2,3,5]
,栈顶5 == 5
,弹出 → 栈[1,2,3]
,i++
。- 栈顶
3 == 3
,弹出 → 栈[1,2]
,i++
。- 栈顶
2 == 2
,弹出 → 栈[1]
,i++
。- 栈顶
1 == 1
,弹出 → 栈[]
,i++
。- 最终
i == 5
,返回true
。
关键点
- 贪心策略:每次压入元素后,尽可能多地执行弹出操作,确保栈的状态符合弹出序列的要求。
- 边界检查:通过检查
st.size()
避免空栈访问错误。
bool validateStackSequences(vector<int>& pushed, vector<int>& popped) {
stack<int> st;
int i = 0, n = popped.size();
for(auto x : pushed)
{
st.push(x);
while(st.size() && st.top() == popped[i])
{
st.pop();
++i;
}
}
return i == n;
}