力扣热题——栈

101 阅读10分钟

一、概述

概念

栈是一种遵循先入后出原则的数据结构,我们只能在栈顶添加或删除元素。而数组和链表都可以在任意位置添加和删除元素,因此栈可以视为一种受限制的数组或链表。

  1. 基于数组实现
/* 基于数组实现的栈 */
class ArrayStack {
    #stack;
    constructor() {
        this.#stack = [];
    }
    /* 获取栈的长度 */
    get size() {
        return this.#stack.length;
    }
    /* 判断栈是否为空 */
    isEmpty() {
        return this.#stack.length === 0;
    }
    /* 入栈 */
    push(num) {
        this.#stack.push(num);
    }
    /* 出栈 */
    pop() {
        if (this.isEmpty()) throw new Error('栈为空');
        return this.#stack.pop();
    }
    /* 访问栈顶元素 */
    top() {
        if (this.isEmpty()) throw new Error('栈为空');
        return this.#stack[this.#stack.length - 1];
    }
    /* 返回 Array */
    toArray() {
        return this.#stack;
    }
}
  1. 基于链表实现
/* 基于链表实现的栈 */
class LinkedListStack {
    #stackPeek; // 将头节点作为栈顶
    #stkSize = 0; // 栈的长度
    constructor() {
        this.#stackPeek = null;
    }
    /* 获取栈的长度 */
    get size() {
        return this.#stkSize;
    }
    /* 判断栈是否为空 */
    isEmpty() {
        return this.size === 0;
    }
    /* 入栈 */
    push(num) {
        const node = new ListNode(num);
        node.next = this.#stackPeek;
        this.#stackPeek = node;
        this.#stkSize++;
    }
    /* 出栈 */
    pop() {
        const num = this.peek();
        this.#stackPeek = this.#stackPeek.next;
        this.#stkSize--;
        return num;
    }
    /* 访问栈顶元素 */
    peek() {
        if (!this.#stackPeek) throw new Error('栈为空');
        return this.#stackPeek.val;
    }
    /* 将链表转化为 Array 并返回 */
    toArray() {
        let node = this.#stackPeek;
        const res = new Array(this.size);
        for (let i = res.length - 1; i >= 0; i--) {
            res[i] = node.val;
            node = node.next;
        }
        return res;
    }
}

适应场景

  1. 括号匹配:栈可用于检查表达式中的括号是否匹配。遍历表达式时,当遇到左括号时,将其压入栈中;当遇到右括号时,检查栈顶元素是否为对应的左括号,若匹配则出栈,否则表达式非法。
  2. 逆波兰表达式计算:逆波兰表达式是一种后缀表达式,栈可用于计算逆波兰表达式。遍历表达式时,将数字压入栈中,遇到运算符时从栈中弹出操作数进行运算,将结果压回栈中,直到遍历完整个表达式。
  3. 函数调用:在编程语言中,函数调用时会将函数的返回地址以及参数等信息压入栈中,当函数执行完毕后,再从栈中弹出返回地址继续执行。
  4. 深度优先搜索(DFS):在图的深度优先搜索中,栈可用于保存当前节点的邻居节点,从而实现深度优先的遍历。
  5. 递归:递归函数的调用过程也是通过栈实现的。每次递归调用时,函数的参数和局部变量都会被压入栈中,直到递归结束后逐层出栈返回。
  6. 迷宫问题:在迷宫问题中,可以使用栈保存路径,每次向下一步移动时,将当前位置入栈,当无法继续前进时,回溯到上一个位置。
  7. 表达式求值:中缀表达式转换为后缀表达式时,可以利用栈来调整运算符的顺序,从而实现对表达式的求值。
  8. 语法分析:在编译器设计中,栈可用于语法分析,例如LL(1)分析法、LR分析法等,以解析和理解程序的语法结构。
  9. 浏览器中的后退与前进、软件中的撤销与反撤销。每当我们打开新的网页,浏览器就会对上一个网页执行入栈,这样我们就可以通过后退操作回到上一个网页。后退操作实际上是在执行出栈。如果要同时支持后退和前进,那么需要两个栈来配合实现。
  10. 程序内存管理。每次调用函数时,系统都会在栈顶添加一个栈帧,用于记录函数的上下文信息。在递归函数中,向下递推阶段会不断执行入栈操作,而向上回溯阶段则会不断执行出栈操作。

优点

  1. 简单易用:栈的操作非常简单,基本操作主要包括入栈(push)、出栈(pop)、查看栈顶元素(peek)以及检查栈是否为空(isEmpty)。这些操作都很直观,易于理解和实现。
  2. 后进先出(LIFO)原则:栈遵循后进先出的原则,这对于解决某些问题非常有用,如在执行函数调用时管理返回地址、实现撤销(Undo)操作等。
  3. 时间复杂度低:对于栈的基本操作,如push和pop,时间复杂度为O(1),即它们的执行时间不依赖于栈中的元素数量,这使得栈在性能方面非常高效。
  4. 辅助解决复杂问题:栈可以帮助解决一些看似复杂的问题,比如算术表达式的求值、括号匹配问题、页面的前进和后退功能等。它可以作为暂存数据的结构,以便在需要时能够快速访问最后一个存储的元素。
  5. 有助于管理数据:在递归调用、深度优先搜索等算法中,栈可以存储临时信息,帮助管理和恢复状态,这对于理解和解决问题非常有帮助。
  6. 空间效率:使用栈可以减少程序中对全局变量的需求。通过栈,可以在程序的不同部分传递信息,而不需要额外的外部存储空间。
  7. 可用于语言解析:在编译器设计中,栈被用来解析语言的语法结构,特别是在处理递归定义的语言结构时。
  8. 易于实现:栈可以通过数组或链表等基本数据结构简单地实现,这使得栈不仅在理论上有用,而且在实际的软件开发中也非常实用。

二、刷题

有效的括号

image.png
思路:历字符串,当遇到左括号时,将其对应的右括号压入栈中,当遇到右括号时,从栈顶弹出一个括号进行匹配,若匹配成功则继续,否则返回 false。
时间复杂度:O(n),其中 n 是字符串的长度,因为需要遍历整个字符串。
空间复杂度:O(n),最坏情况下栈中需要存储所有的左括号。

/**
 * @param {string} s
 * @return {boolean}
 */
var isValid = function(s) {
    const stack = []
    for(let item of s){
        if(item === '('){
            stack.push(')')
        }else if(item === "["){
            stack.push(']')
        }else if(item === '{'){
            stack.push('}')
        }
        else{
            if(stack.pop() !== item) return false
        }
    }
    return stack.length === 0
};

最小栈

image.png
思路:使用两个栈,一个栈用来存储元素,另一个栈用来存储当前栈中的最小值。在入栈操作时,除了将元素压入存储元素的栈中,还需要将当前元素与当前最小值进行比较,将较小的值压入存储最小值的栈中。出栈操作时,两个栈同时出栈。获取栈顶元素时直接返回存储元素的栈的栈顶元素,获取最小值时直接返回存储最小值的栈的栈顶元素。

var MinStack = function() {
    this.stack = []
    this.min = [Infinity]
};

/** 
 * @param {number} val
 * @return {void}
 */
MinStack.prototype.push = function(val) {
    this.stack.push(val)
    this.min.push(Math.min(val, this.min[this.min.length - 1]))
};

/**
 * @return {void}
 */
MinStack.prototype.pop = function() {
    this.stack.pop()
    this.min.pop()
};

/**
 * @return {number}
 */
MinStack.prototype.top = function() {
    return this.stack[this.stack.length - 1]
};

/**
 * @return {number}
 */
MinStack.prototype.getMin = function() {
    return this.min[this.min.length - 1]
};

/**
 * Your MinStack object will be instantiated and called as such:
 * var obj = new MinStack()
 * obj.push(val)
 * obj.pop()
 * var param_3 = obj.top()
 * var param_4 = obj.getMin()
 */

字符串解码

image.png
思路:使用两个栈分别存储数字和字符串,遍历输入的字符串,当遇到数字字符时,将数字字符转换为数字并累加,直到遇到非数字字符;当遇到左括号时,将累积的数字压入数字栈中,并将当前的结果字符串压入字符串栈中,然后重置累加的数字和结果字符串;当遇到右括号时,从数字栈中弹出一个数字作为重复次数,从字符串栈中弹出一个字符串作为需要重复的内容,将其重复相应次数后与当前结果字符串拼接;当遇到字母字符时,将其添加到当前结果字符串中。
时间复杂度:由于只需遍历一次输入字符串,因此时间复杂度为 O(n),其中 n 为输入字符串的长度。
空间复杂度:由两个栈的空间和结果字符串的空间构成,因此空间复杂度为 O(n)。

/**
 * @param {string} s
 * @return {string}
 */
var decodeString = function(s) {
    const numStack = []
    const strStack = []
    let num = 0
    let res = ''

    for(const item of s){
        // 是否为字符串的数字
        if(!isNaN(item)){
            num = 10 * num + Number(item)
        }else if(item === '['){
            numStack.push(num)
            num = 0
            strStack.push(res)
            res = ''
        }else if(item === ']'){
            const currentNum = numStack.pop()
            res = strStack.pop() + res.repeat(currentNum)
        }else{
            res += item
        }
    }
    return res
};

每日温度

image.png
思路:因为我们最后要的是一个相对位置的举例数组,所有构造一个包含每一个索引值的栈,和一个初始值都为0的res数组,遍历每个元素,使用循环处理当前栈内最后一个index值代表的数小于此时温度的情况(即遇到比栈内存储的温度大的当前温度),符合要求即提取最后一个index值并更新这个索引对应的距离,这样下来,没有更新的index值位置便是没有比其更大温度的了。
时间复杂度:O(N),其中N是温度数组的长度。尽管内部有一个while循环看起来像是嵌套循环,但每个元素最多被压入栈和弹出栈一次,因此总的时间复杂度是线性的。
空间复杂度:O(N),在最坏的情况下,栈内可能需要存储整个温度数组的索引(比如温度单调递减),因此空间复杂度也是线性的。

/**
 * @param {number[]} temperatures
 * @return {number[]}
 */
var dailyTemperatures = function(temperatures) {
    const len = temperatures.length
    const res = new Array(len).fill(0)
    const stack = []

![image.png](https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/98253733e20848bbac7995ec1ced9c08~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image#?w=893&h=483&s=41446&e=png&b=ffffff)
    for(let i = 0; i < len; i++){
        while(stack.length && temperatures[i] > temperatures[stack[stack.length - 1]]){
            const index = stack.pop()
            res[index] = i - index
        }
        stack.push(i)
    }

    return res
};

柱状图中最大的矩形

image.png
思路:单调栈,核心是需要找到每个柱子左右两边第一个比它矮的柱子,确定以该柱子为高度的矩形的最大宽度。为保证最后一个柱子也能够被处理,需在数组末尾添加高度为0的柱子。要点有两个:

  • 其一是单调栈条件,while语句里的内容,单调栈通过维护一个单调递增的索引序列,使得每当遇到一个破坏单调性的元素时(即当前元素小于栈顶元素),我们就可以确定栈顶元素的右边界,并据此计算以栈顶元素为高的矩形的最大面积。
  • 其二便是宽度的处理, 如果弹出栈顶元素后栈变为空,意味着当前考虑的柱子左边没有比它低的柱子,即它是到目前为止遇到的最低的柱子。因此,它的宽度可以扩展到最左边,即i(当前柱子的索引)。如果栈不为空,说明当前柱子的左边界是栈顶元素对应的柱子的索引。因此,当前柱子的宽度是当前柱子的索引i减去栈顶元素索引,再减去1(因为要排除掉栈顶元素对应的柱子本身)。

时间复杂度:O(N),其中N是柱状图中柱子的数量。每个柱子被压入和弹出栈各一次。
空间复杂度:O(N),最坏的情况下,栈中可能会包含所有柱子的索引。

/**
 * @param {number[]} heights
 * @return {number}
 */
var largestRectangleArea = function (heights) {
    let maxArea = 0
    const stack = []    // 用于存放索引
    heights.push(0) // 在数组末尾添加高度为0的柱子,以便能够处理最后一个柱子
    for(let i = 0; i < heights.length; i++){
        // 维持单调递增栈
        while(stack.length > 0 && heights[i] < heights[stack[stack.length - 1]]){
            const height = heights[stack.pop()]
            // i是右边界(即当前柱子的索引),stack[stack.length - 1]是左边界(即栈顶元素对应的柱子的索引)
            // 之所以要减1,是因为我们需要的是两个边界之间的距离,而不是包括边界本身在内的距离
            const width = stack.length === 0 ? i : i - stack[stack.length - 1] - 1
            maxArea = Math.max(maxArea, width * height)
        }
        stack.push(i)
    }
    return maxArea
};