【每日算法】专题十二_栈

1. 算法思想

栈(Stack)是一种遵循后进先出(Last In First Out, LIFO)原则的抽象数据结构。其核心思想在于元素的添加和删除操作仅在栈的一端进行,这一端被称为栈顶(Top)。栈常用于处理具有嵌套或递归特性的问题,例如表达式求值、函数调用、括号匹配等场景。

基本操作

栈的核心操作包括:

  • 入栈(Push):将元素添加到栈顶。
  • 出栈(Pop):移除并返回栈顶元素。
  • ** peek/Top**:返回栈顶元素但不弹出。
  • 判空(Empty):检查栈是否为空。
  • ** size**:返回栈中元素的数量。

栈的算法应用场景

  1. 括号匹配问题
    验证表达式中括号的平衡性(如"([{}])")。遍历字符串时,遇到左括号入栈,遇到右括号时弹出栈顶元素检查是否匹配。

  2. 表达式求值
    计算中缀表达式(如"3+4*(2-1)")时,使用两个栈分别存储操作数和运算符,通过运算符优先级控制计算顺序。

  3. 递归转迭代
    递归函数的调用过程本质上使用了系统栈。手动维护一个栈可以将递归算法转换为迭代实现,避免栈溢出风险。

  4. 浏览器历史记录
    浏览器的后退功能使用栈结构:每次访问新页面时将 URL 入栈,后退时出栈。

  5. 撤销操作
    文本编辑器的撤销功能通过栈记录每一步操作,撤销时依次弹出最近的操作。

复杂度分析

  • 时间复杂度:入栈和出栈操作均为 O (1)。
  • 空间复杂度:最坏情况下需要存储所有元素,空间为 O (n)。

注意事项

  • 栈溢出:当栈深度过大时可能导致内存耗尽,需注意递归深度或手动管理栈的大小。
  • 空栈操作:在执行 Pop 或 Top 操作前,需确保栈非空,否则可能引发异常。

栈的 LIFO 特性使其在处理嵌套结构和需要回溯的问题中表现出色,是算法设计中的重要工具之一。

2. 例题

2.1 有效括号

20. 有效的括号 - 力扣(LeetCode)

核心思路基于栈的后进先出(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 比较含退格的字符串

844. 比较含退格的字符串 - 力扣(LeetCode)

核心思路是模拟退格操作对字符串的影响,通过构建处理后的字符串来比较两者是否相等,具体可拆解为以下 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

227. 基本计算器 II - 力扣(LeetCode)

核心思路是使用栈处理表达式中的运算符优先级,并将减法转换为负数的加法,从而简化最终计算。具体可拆解为以下 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 字符串解码

394. 字符串解码 - 力扣(LeetCode)

核心思路是使用双栈处理嵌套的字符串重复问题,通过栈结构维护上下文信息,实现括号内字符串的正确展开。具体可拆解为以下 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 验证栈序列

946. 验证栈序列 - 力扣(LeetCode)

核心思路是模拟栈的压入和弹出过程,验证给定的弹出序列是否合法。具体可拆解为以下 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;
    }

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Code Warrior

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值