吃透栈:LeetCode 栈算法题全解析

栈算法题通用思路

栈的核心特性是 后进先出(LIFO),它的所有算法应用都围绕这个特性展开。下面我把栈的高频题型、通用思路和模板一次性讲透,帮你建立体系化的解题框架。

一、栈的核心适用场景

栈的本质是"保留上一个状态,处理当前元素",只要题目符合以下特征,优先考虑栈:

  1. 需要和前一个/上一个元素做对比、匹配、消除

  2. 处理嵌套结构(括号、嵌套字符串、嵌套表达式)

  3. 处理逆序相关问题(逆序输出、序列验证)

  4. 维护单调顺序(单调栈,用于找下一个更大/更小元素)

二、栈解题的通用步骤

  1. 判断是否能用栈:是否需要对比前一个元素、处理嵌套结构、维护顺序?

  2. 选择栈的类型:普通栈/字符串模拟栈/双栈/单调栈

  3. 定义入栈/出栈规则:明确什么情况入栈,什么情况出栈,栈中存什么(元素/下标/状态)

  4. 处理边界情况:空栈、多位数提取、连续匹配、嵌套最深层的处理

  5. 验证逻辑:用示例手动模拟一遍,检查是否覆盖所有情况

三、易错点避坑指南

  1. 空栈操作:出栈/取栈顶前必须判断栈是否为空,避免越界

  2. 多位数提取:遇到数字时,循环读取连续数字字符,不要只取一位

  3. 循环出栈:匹配出栈时要用 while 循环,而不是 if,避免漏匹配连续可出栈元素

  4. 嵌套结构的状态保存:双栈处理时,必须保证数字栈和字符串栈的入栈/出栈操作一一对应

  5. 整数除法取整:C++ 中 / 运算符对负数的取整是向零取整,符合题目要求,但要注意和数学上的取整区分


题目1:删除字符串中的所有相邻重复项(LeetCode 1047)

  1. 题目描述

给出由小写字母组成的字符串 S,重复删除操作会选择两个相邻且相同的字母,并删除它们。

在 S 上反复执行重复项删除操作,直到无法继续删除。

在完成所有重复项删除操作后返回最终的字符串。题目保证答案唯一。

◦ 示例

输入:"abbaca"

输出:"ca"

解释:在 "abbaca" 中,我们可以删除 "bb"(两字母相邻且相同,这是此时唯一可以执行删除操作的重复项),得到字符串 "aaca";之后又只有 "aa" 可以执行重复项删除操作,所以最后的字符串为 "ca"。

提示: 1 <= s.length <= 105,s 仅由小写英文字母组成。

  1. 核心解法:栈(数组模拟栈)

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. 知识点拆解

1) 栈的核心特性

后进先出(LIFO):最后入栈的元素最先出栈,这一特性让栈天然适合处理「需要对比上一个元素」的场景。

本题中,栈顶元素始终是上一个未被消除的字符,遍历新字符时,只需和栈顶对比即可判断是否需要消除。

2)字符串模拟栈的优势

|-------------|--------------------------------------|--------------------------|
| 实现方式 | 优点 | 缺点 |
| std::stack | 语义清晰,严格遵循栈的操作接口 | 最终需要将栈中元素逐个弹出并拼接字符串,操作冗余 |
| std::string | 直接用尾插/尾删实现栈操作,代码更简洁;最终字符串即为结果,无需额外拼接 | 语义上不如stack直观,但完全满足本题需求 |

3)时间与空间复杂度分析

时间复杂度:O(n),字符串中的每个字符最多入栈、出栈各一次,总操作次数为线性,n 为字符串长度。

空间复杂度:O(n),最坏情况(字符串中无任何相邻重复项)下,栈需要存储全部 n 个字符。

  1. 过程模拟(以示例 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)

  1. 题目描述

给定 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 <= 200
  • st 只含有小写字母以及字符 '#'
  1. 核心解法:栈(数组模拟栈)

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. 知识点拆解

1)栈的应用场景

这道题是栈在「字符串模拟」场景的典型应用:

退格操作需要依赖前一个字符的状态,栈的后进先出特性正好可以记录并快速访问最近输入的字符。

空栈时处理退格的边界情况,需要额外判断栈是否为空,避免越界操作。

2)字符串模拟栈的优势

实现简单:直接用 += 实现入栈,pop_back() 实现出栈,size() 判断栈空。

结果直接可用:处理完成后,字符串本身就是最终结果,无需额外拼接。

3)时间与空间复杂度分析

时间复杂度:O(n + m),其中 n 和 m 分别是字符串 s 和 t 的长度。每个字符最多入栈、出栈各一次,总操作次数为线性。

空间复杂度:O(n + m),最坏情况下(字符串中没有退格符),需要存储两个完整的字符串。


题目3:基本计算器 II(LeetCode 227)

  1. 题目描述

给定一个字符串表达式 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. 核心解法:栈模拟运算

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. 知识点拆解

1)栈的核心作用

栈的本质是「暂存待运算元素」,通过将乘除运算就地执行,把复杂的混合运算简化为最终的加法运算,完美适配四则运算的优先级规则。

把减法转化为「加负数」,是这类题的常用技巧,避免了单独处理减法的逻辑。

2)关键细节处理

多位数提取:字符串中的数字可能是多位的,需要循环读取连续的数字字符,拼接成完整整数。

空格处理:表达式中可能包含空格,需要在遍历时直接跳过。

除法取整:题目要求整数除法仅保留整数部分,直接使用 C++ / 运算符即可(向零取整,符合题目要求)。

3)时间与空间复杂度分析

时间复杂度:O(n),其中 n 是字符串长度,每个字符最多被遍历一次。

空间复杂度:O(n),最坏情况下(全是加减运算),栈中需要存储所有数字。

  1. 过程模拟(以示例 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)

  1. 题目描述

给定一个经过编码的字符串,返回它解码后的字符串。

编码规则为: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. 核心解法:双栈模拟

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. 知识点拆解

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 是编码字符串的长度,栈的深度最多为嵌套括号的层数

  1. 过程模拟(以示例 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. 题目描述
  1. 核心解法:栈模拟

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. 关键知识点拆解

1) 栈模拟的核心逻辑

入栈顺序不可变:必须严格按照 pushed 序列的顺序执行 push 操作

出栈操作被动触发:只要栈顶元素与 popped 当前元素匹配,就立刻执行 pop 操作,保证 popped 序列被尽可能匹配

匹配完成判断:i == n 等价于栈为空,说明所有元素都按 popped 序列的顺序被成功弹出

2) 复杂度分析

时间复杂度:O(n),每个元素最多入栈和出栈各一次,总操作次数为线性

空间复杂度:O(n),最坏情况下(无任何出栈操作),栈中需要存储所有 pushed 元素

  1. 过程模拟(示例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

相关推荐
吟安安安安1 小时前
【算法设计与分析】第一讲 算法基础(上)
算法
阿Y加油吧1 小时前
二刷 LeetCode:62. 不同路径 & 64. 最小路径和 复盘笔记
笔记·算法·leetcode
NQBJT1 小时前
双轮足导盲机器人:多传感融合与全局-局部分层导航系统设计
c++·esp32·openmv·避障·导盲·轮足
lzh200409191 小时前
Linux信号(Signal)
linux·c++
生成论实验室1 小时前
《源·觉·知·行·事·物:生成论视域下的统一认知语法》导论:在破碎的世界寻找统一语法
人工智能·科技·算法·架构·创业创新
承渊政道1 小时前
【动态规划算法】(两个数组的DP问题深度剖析与求解方法)
数据结构·c++·学习·算法·leetcode·动态规划·哈希算法
杨连江2 小时前
原子级平面限域协同晶核诱导定向生长单层鳞片石墨的研究
算法
MATLAB代码顾问2 小时前
混合粒子群-模拟退火算法(HPSO-SA)求解作业车间调度问题——附MATLAB代码
算法·matlab·模拟退火算法
Felven2 小时前
C. Prefix Min and Suffix Max
算法