【数据结构与算法】栈详解

193 阅读7分钟

在学习数据结构与算法的过程中,栈(Stack)是一个非常重要的基础数据结构。它以“后进先出”(LIFO)的方式管理数据,广泛应用于表达式求值、括号匹配、递归调用等场景。今天,我们将通过 LeetCode 上的两个经典题目——20. 有效的括号(Valid Parentheses)和 739. 每日温度(Daily Temperatures)——来深入理解栈的特性和应用。


🧱 栈

栈的概念源于计算机科学中的抽象数据类型(ADT)。具有 「先进后出」 特点,可使用数组或链表实现。使用数组实现的栈也称为 顺序栈,使用链表实现的栈称为 链栈

栈其实是一种运算方式受限的线性表,它只允许在表尾进行插入和删除操作。 允许操作的一端被称为栈顶,相对地,把另一端称为栈底

按这种运算方式,它可以模拟现实世界中的"叠放"行为,例如书本堆叠、盘子堆叠、子弹上膛等。

栈的操作包括:

  • 向栈插入新元素称作入栈 或 压栈 ,把新元素放到栈顶元素的上面,使之成为新的栈顶元素

  • 从栈删除元素又称作 出栈 或 退栈,把栈顶元素删除,使其相邻的元素成为新的栈顶元素

对于代码世界中,栈的各种操作方法描述

  • push:将新元素元素压入栈顶。
  • pop:从栈顶弹出元素。
  • peek:查看栈顶元素,但不移除。
  • isEmpty:检查栈是否为空。

图解:

栈的图解

好了,你已经知道栈这一数据结构的用法和规则,现在和我一起做点题目试试吧

🧩小练习: LeetCode 20:有效的括号

接下来将使用js将进行题解,而js中并没有栈这一种数据类型,所以我们使用数组进行代替

📝 问题描述

给定一个只包含字符 '('')''{''}''['']' 的字符串 s,判断字符串是否有效。有效字符串需满足:

  • 左括号必须用相同类型的右括号闭合。
  • 左括号必须以正确的顺序闭合。

🧠 解题思路

使用栈来处理括号的匹配:

  1. 遍历字符串中的每个字符。

  2. 遇到左括号('(''{''[')时,压入栈。

  3. 遇到右括号(')''}'']')时,检查栈顶元素是否为对应的左括号:

    • 如果是,弹出栈顶元素。
    • 如果不是,返回 false
  4. 遍历结束后,栈为空则返回 true,否则返回 false

💡 JavaScript 实现

function isValid(s) {
    // 初始化一个空栈
    const stack = [];
    // 定义括号的匹配关系
    const mapping = { ')': '(', '}': '{', ']': '[' };

    // 遍历字符串中的每个字符
    for (let char of s) {
        // 如果是左括号,直接入栈
        if (char === '(' || char === '{' || char === '[') {
            stack.push(char);
        } else {
            // 如果是右括号,检查栈是否为空
            if (stack.length === 0) {
                return false; // 栈空说明没有对应的左括号
            }
            // 弹出栈顶元素(最近的左括号)
            const topElement = stack.pop();
            // 检查是否匹配
            if (mapping[char] !== topElement) {
                return false; // 不匹配
            }
        }
    }
    // 最后检查栈是否为空
    return stack.length === 0;
}

// 示例测试
console.log(isValid("()[]{}"));  // true
console.log(isValid("(]"));      // false
console.log(isValid("([)]"));    // false

你怎么知道这里要用到栈?

这时候会有小伙伴问为什么这里选择栈?

仔细我们可以发现,匹配括号是否互相匹配是不是像极了轴对称图形?

  • 例如:( ){ }[ ]{ [ ( { } ) ] }

栈先进后出的特性使得入栈和出栈形成的抽象过程是轴对称的。

  • 例如1→2→3依次入栈,出栈顺序会是3→2→1,整个过程形成了1 2 3 3 2 1的形式

在此基础上我们只需要因为括号的方向不同自定义一个哈希表形成对应关系,使用栈的特性并根据题目要求完善程序即可解出本题

不仅如此,我们以后见到的二叉树或者递归也可以使用栈的特性进行解题


趁热打铁再来一题

🔥 LeetCode 739:每日温度

📝 问题描述

给定一个整数数组 temperatures,表示每天的温度,返回一个数组 answer,其中 answer[i] 是指从第 i 天起,等待多少天温度才会更高。如果之后都不会更高,则 answer[i] = 0

万物即可先暴力

✅ 暴力法思路

  1. 初始化结果数组 ans,长度与输入数组相同,初始值全为 0。
  2. 外层循环遍历数组中的每一个元素。
  3. 内层循环从当前元素的下一个位置开始,查找第一个比当前元素大的温度。
  4. 如果找到,计算两者的索引差并赋值给 ans[i]
  5. 如果没有找到更大的温度,则 ans[i] 保持为 0。

⚠️ 时间复杂度分析

  • 时间复杂度:O(n²)

    • 对于每个元素,最坏情况下都要遍历到数组末尾,因此总操作次数约为 n2n2。
  • 空间复杂度:O(1)(不考虑结果数组)


🧩 JavaScript 实现代码

function dailyTemperatures(temperatures) {
    const n = temperatures.length;
    const ans = new Array(n).fill(0);

    for (let i = 0; i < n; i++) {
        const currentTemp = temperatures[i];
        // 内层循环找第一个更大的温度
        for (let j = i + 1; j < n; j++) {
            if (temperatures[j] > currentTemp) {
                ans[i] = j - i;
                break; // 找到后立即跳出内层循环
            }
        }
    }

    return ans;
}

暴力法虽然直观易懂,它在小规模数据或教学场景中具有一定的参考价值。但由于其时间复杂度较高,在处理大规模数据时性能较差。这道题显然不可用

对于实际应用,推荐使用单调栈算法以获得更高的效率。


🧠 优化思路

使用单调栈来解决:

  1. 初始化一个空栈 stack 和一个与 temperatures 长度相同的结果数组 answer

  2. 从右向左遍历 temperatures 数组:

    • 如果栈不为空且当前温度大于栈顶索引对应的温度,弹出栈顶元素,并更新 answer 数组。
    • 将当前索引压入栈。
  3. 遍历结束后,返回 answer 数组。

💡 JavaScript 实现

function dailyTemperatures(temperatures) {
    const n = temperatures.length;
    const ans = new Array(n).fill(0); // 初始化结果数组
    const stack = []; // 栈中存储索引

    for (let i = 0; i < n; i++) {
        // 当前温度 temp
        const temp = temperatures[i];
        // 只要栈不为空,且当前温度 > 栈顶元素对应的温度
        while (stack.length > 0 && temp > temperatures[stack[stack.length - 1]]) {
            // 弹出栈顶索引
            const prevIndex = stack.pop();
            // 计算等待天数
            ans[prevIndex] = i - prevIndex;
        }
        // 当前索引入栈
        stack.push(i);
    }

    return ans;
}

// 示例测试
console.log(dailyTemperatures([73,74,75,71,69,72,76])); 
// 输出: [1,1,4,2,1,1,0]

为什么是单调栈?-- 戳这有更形象的讲解

单调栈的核心思想

  • 单调栈是一种特殊的数据结构,它通过维护栈内元素的单调性(递增或递减)来快速找到目标值。
  • 需要找每个元素右侧第一个更大的元素,因此使用单调递减栈(栈底到栈顶的元素递减)。

单调栈的适用场景

  • 邻近比较:单调栈通过维护一个单调序列,确保每次比较都是必要的。例如,当栈顶元素小于当前元素时,可以直接弹出栈顶元素并记录结果。
  • 当需要找某个元素右侧第一个比它大的元素时,单调栈是首选方法。
  • 单调栈通过一次遍历即可完成所有比较,时间复杂度为O(n) ,而暴力法的时间复杂度为O(n²)

单调栈的优化原理

  • 避免重复比较:暴力法中,每个元素都需要与右侧所有元素比较,导致大量重复操作。单调栈通过维护一个递减栈,仅在必要时进行比较。
  • 空间换时间:栈存储了未处理的元素索引,通过栈的单调性快速定位目标值。

🎯 栈的适用范围

做了两题,我们大概也知道一些门道了。栈在以下场景中表现尤为出色:

  • 形似对称的求解:使用栈先进后出的特点进行突破
  • 括号匹配与表达式求值:用于检查括号是否成对出现,或计算表达式的值。
  • 递归调用的模拟:通过栈模拟递归过程,避免栈溢出。
  • 深度优先搜索(DFS) :在图或树的遍历中,栈用于记录访问路径。
  • 单调栈问题:解决“下一个更大元素”等问题。

📚 课后练习

  1. LeetCode 32. 最长有效括号
  2. LeetCode 84. 柱状图中最大的矩形
  3. LeetCode 496. 下一个更大元素 I
  4. LeetCode 503. 下一个更大元素 II

通过这些练习,进一步巩固栈的应用技巧,提升解决实际问题的能力。