算法 --栈

什么是栈?(核心特性: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)

这道题非常适合用栈的思想来解决。可以遍历字符串,把字符依次放入一个容器中:

  1. 如果当前字符与容器末尾 的字符相同,说明遇到了相邻重复项,将末尾字符弹出(相当于删除这两个相邻字符)。

  2. 如果不同,则将当前字符压入容器。

  3. 遍历结束后,容器中剩下的字符就是最终结果。

复杂度分析

  • 时间复杂度: 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,用来计算一个字符串经过退格操作后的最终形态。在这个辅助函数中,我们遍历字符串:

  1. 如果遇到普通字符 ,将其压入栈(即 ret += ch)。

  2. 如果遇到退格符 #:

    • 如果栈不为空(ret.size() > 0),说明有字符可以退格,弹出栈顶元素(ret.pop_back())。

    • 如果栈为空,根据题目要求"对空文本输入退格字符,文本继续为空",不做任何操作。

  3. 遍历结束后,栈中的内容就是最终的文本。

最后,分别计算 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 记录当前数字前面 的运算符(初始化为 '+',这样第一个数字就能顺利入栈)。

  1. 提取数字 :遇到数字时,用 while 循环把连续的数字字符转换成一个完整的整数 tmp。

  2. 根据 op 处理数字:

    • 如果 op 是 '+':直接将 tmp 压入栈。

    • 如果 op 是 '-':将 -tmp(负数)压入栈。这样后续只需要把所有栈内元素相加即可,不用处理减法。

    • 如果 op 是 '*' 或 '/':说明当前数字要和上一个刚入栈的数字 进行乘除运算。因此,我们直接弹出栈顶元素,与 tmp 运算后,再将结果压回栈中。

  3. 更新 op :遇到非数字且非空格的字符时,说明遇到了新的运算符,更新 op = s[i]。

  4. 最终计算:遍历结束后,栈中存储的都是需要进行加减法(其实都已经转化成了加法)的数值。将栈中所有元素累加,即为最终结果。

复杂度分析

  • 时间复杂度: O(n) 。其中 n 是字符串的长度。我们只需要对字符串进行一次遍历,每个数字和运算符最多被处理一次。

  • 空间复杂度: O(n) 。最坏情况下(例如 "1+1+1+1+1"),栈中会存储所有的数字,因此空间复杂度为 O(n)。

细节提示

  1. 初始 op = '+' :这是一个技巧。它保证了遇到第一个数字时,即使前面没有运算符,也能正常执行 st.push_back(tmp)。

  2. 使用 -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]])。通过两个栈(一个存数字,一个存字符串)和一次遍历来完成:

  1. 初始化哨兵 :

    代码一开始执行了 st.push("");。这非常关键!它相当于在栈底放了一块"地基"。

    • 作用:保证了无论何时遇到 ],栈 st 里至少有一个字符串可以用来承接拼接结果,从而彻底避免了之前因栈空调用 st.top() 导致的崩溃。
  2. 遍历字符串 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)。

  3. 返回结果 :

    遍历结束后,栈底的那唯一的字符串(也就是初始的那个 "" 发展而来的)就是最终答案,返回 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"]。(栈依然永远不会空)

这个"地基"带来的三大好处:

  1. 彻底杜绝空栈异常 :因为栈底始终有一个字符串兜底,无论你怎么弹(st.pop()),永远弹不到它,栈的大小永远 >= 1。这样写 st.top() 时就可以完全放开手脚,不用担心越界。

  2. 统一了拼接逻辑 :如果不用哨兵,在拼凑第一段括号外的字母时(比如 "abc3[d]" 里的 "abc"),需要额外判断"此时栈是不是空的,如果空就要创建新字符串"。有了 st.push(""),所有的拼接操作都变成了统一的 st.top() += tmp,省去了大量的 if-else 边界判断,代码极其简洁。

  3. 直接返回结果 :遍历结束后,不需要去遍历栈找出最后一个元素,直接 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;
    }
};

解题思路:贪心模拟

可以直接用一个真实的栈来模拟这个过程:

  1. 按顺序入栈 :遍历 pushed 数组,将元素依次压入栈中。

  2. 尝试出栈 :每次压入一个元素后 ,立刻检查栈顶元素是否等于 popped 序列中当前等待出栈的元素(即 popped[i])。

    • 如果相等,说明这个元素应该立刻弹出。我们将栈顶元素弹出,并将 popped 的指针 i 向后移动一位。

    • 注意这里要用 while 循环 :因为弹出当前元素后,新的栈顶元素可能也正好等于 popped 的下一个元素,需要连续弹出,直到不匹配为止。

  3. 判断结果 :当 pushed 数组遍历完毕后,如果 popped 数组也全部匹配完了(即 i == n),说明序列合法,返回 true;否则返回 false。

复杂度分析

  • 时间复杂度: O(n) 。其中 n 是 pushed 数组的长度。pushed 中的每个元素最多入栈一次、出栈一次,所以总操作次数是线性的。

  • 空间复杂度: O(n) 。最坏情况下(例如 pushed = [1,2,3], popped = [3,2,1]),栈会存储所有元素。

细节提示

  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 就能保证连续弹出所有符合条件的元素。

  2. st.size() 的作用 :防止栈为空时调用 st.top() 导致未定义行为。

  3. 最终判断 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. 单调栈

单调栈是一种特殊的栈,它强制保持栈内元素单调递增或单调递减 。它是解决 "寻找下一个更大/更小元素" 类问题的终极武器。

  • 核心逻辑:当新元素要入栈时,如果它破坏了栈的单调性(比如求下一个更大元素,栈是递减的,但新元素比栈顶大),就把栈顶元素弹出,并记录下新元素就是栈顶元素的"下一个更大元素"。如此循环,直到新元素入栈后依然保持单调性。
相关推荐
鶴哥只手遮天1 小时前
Gmsh 源码深度解析-第 9 章 后处理与插件体系
算法·开源
圣保罗的大教堂1 小时前
leetcode 3723. 数位平方和的最大值 中等
leetcode
鶴哥只手遮天1 小时前
Gmsh 源码深度解析-第 7 章 网格质量、优化与高级技术
算法·开源
鶴哥只手遮天1 小时前
OpenFOAM 源码深度剖析 · 第二季-15 实战:手写一个求解器、库与边界条件
算法·开源
鶴哥只手遮天1 小时前
Gmsh 源码深度解析-第 8 章 数值内核与高阶有限元
算法·开源
鶴哥只手遮天1 小时前
Gmsh 源码深度解析-第 4 章 网格生成总控与尺寸场
算法·开源
鶴哥只手遮天1 小时前
Gmsh 源码深度解析-第 10 章 求解器框架、API 与语言绑定
算法·开源
鶴哥只手遮天1 小时前
OpenFOAM 源码深度剖析 · 第二季-02 燃烧与化学反应:`combustionModel`、化学求解器与 Arrhenius 反应率
算法·开源
鶴哥只手遮天1 小时前
Gmsh 源码深度解析-第 6 章 三维体网格算法
算法·开源