前端算法小结 | 栈和队列篇

328 阅读13分钟

写在前面

这是本系列小结的第二篇,今天来讲讲数据结构中的栈和队列。

偷偷打个广告:大家如果刚好与我一样正在学(努)习(力)算(刷)法(题),不妨关注下我的专栏: 龙飞的前端算法小结

我们可以一起探讨,一起学习~

前端开(mian)发(shi)需要掌握的几种数据结构

还是先罗列一下前端开发所需要的掌握的数据结构:

  • 数组
  • 队列
  • 链表
  • 树(二叉树)

本文将跟大家一起聊聊栈和队列。

栈(stack)又名堆栈,它是一种运算受限的线性表。限定仅在表尾进行插入和删除操作的线性表。这一端被称为栈顶,相对地,把另一端称为栈底。向一个栈插入新元素又称作进栈、入栈或压栈,它是把新元素放到栈顶元素的上面,使之成为新的栈顶元素;从一个栈删除元素又称作出栈或退栈,它是把栈顶元素删除掉,使其相邻的元素成为新的栈顶元素。

以上是来自官方对栈的描述。而事实上这段话经常被浓缩成为四字真言:后进先出(LIFO, Last In First Out)

在 JS 中我们常常用数组来模拟栈结构:而由于 后进先出 这个特性,栈结构中使用频率最高的数组方法就是 pushpop

队列

队列是一种特殊的线性表,特殊之处在于它只允许在表的前端(front)进行删除操作,而在表的后端(rear)进行插入操作,和栈一样,队列是一种操作受限制的线性表。进行插入操作的端称为队尾,进行删除操作的端称为队头。

以上是来自官方对队列的描述。这段话也经常被浓缩成为四字真言:先进先出(FIFO, First In First Out)

同样的,在 JS 中我们常常用数组来模拟队列结构。

真题解析

下面我们直接从真题入手,逐一解析栈和队列在算法中都有哪些骚操作。

有效括号

序号: 20

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

有效字符串需满足:

左括号必须用相同类型的右括号闭合。

左括号必须以正确的顺序闭合。

示例 1:

输入: s = "()"
输出: true

示例 2:

输入: s = "()[]{}"
输出: true

示例 3:

输入: s = "(]"
输出: false

这个题目可以说是最经典的一道栈题目了。

首先我们需要遍历 s,当遇到左括号时,我们将其放入(push)一个栈中。而当遍历到右括号时,我们将其与栈顶的元素进行比较,如果是正确闭合的组合,那就将栈顶元素出栈(pop)。

当遍历结束后,栈中如果不存在任何元素,则为有效字符串。

// 记录不同括号的映射关系
const maps = new Map();
maps.set('(', ')');
maps.set('{', '}');
maps.set('[', ']');

var isValid = function(s: string): boolean {
    const array: string[] = s.split('');
    const stack: string[] = [];
    for(let i = 0; i < array.length; i++) {
        const current: string = array[i];
        const topStack: string = stack[stack.length - 1];

        if (maps.has(current)) {
            // 为左括号,则入栈
            stack.push(current);
        } else {
            if (maps.get(topStack) === current) {
                // 左右匹配,则出栈
                stack.pop();
            } else {
                // 左右不匹配,可直接判定为 false
                return false;
            }
        }
    }

    return !stack.length;
};

删除最外层括号

序号:1021

有效括号字符串为空 ""、"(" + A + ")" 或 A + B ,其中 A 和 B 都是有效的括号字符串,+ 代表字符串的连接。

例如,"","()","(())()" 和 "(()(()))" 都是有效的括号字符串。 如果有效字符串 s 非空,且不存在将其拆分为 s = A + B 的方法,我们称其为原语(primitive),其中 A 和 B 都是非空有效括号字符串。

给出一个非空有效字符串 s,考虑将其进行原语化分解,使得:s = P_1 + P_2 + ... + P_k,其中 P_i 是有效括号字符串原语。

对 s 进行原语化分解,删除分解中每个原语字符串的最外层括号,返回 s

示例:

输入:s = "(()())(())"

输出:"()()()"

解释:
输入字符串为 "(()())(())",原语化分解得到 "(()())" + "(())",

删除每个部分中的最外层括号后得到 "()()" + "()" = "()()()"。

相信很多小伙伴第一次读完这道题的时候跟我一样,都是一脸的黑人win号❓ 这题目到底说的是啥???

没错,这道题最难的地方就在于如何去 “翻译” 这个题目。

首先什么是原语?其实就是不能再拆分成两个有效括号的字符串。举个例子:

  1. (()()) 没法再拆分成两个有效括号,那它就是原语
  2. (()())() 可以被拆分成 (()())() , 则拆分后的 (()())() 才是原语

而这道题的目的就是让我们找出字符串中的所有原语,然后将其最外层的括号去掉。

这题的核心思路与上面的有效括号类似:当栈为空时,就表示你已经找到原语了。

套路还是一样的:

  1. 遍历字符串
  2. 如果是 ( 左括号,则入栈
  3. 如果是 ) 右括号,则将栈顶的左括号出栈
  4. 而当 ( 左括号入栈前,如果栈为空,则表示该 ( 是原语的开头(即外层的左括号),则不记入结果字符串中
  5. 当匹配 ) 括号并且栈顶出栈后,如果栈为空,则表示该 ) 是原语的结束(即外层的右括号),也不计入结果字符串中
function removeOuterParentheses(s: string): string {
    const array: string[] = s.split('');
    const stack: string[] = [];
    let res: string = '';

    for (let i = 0; i < array.length; i++) {
        const current: string = array[i];
        
        if (current === ')') {
            stack.pop();
        }

        if (!!stack.length) {
            res += current;
        }

        if (current === '(') {
            stack.push(current);
        }
    }

    return res;
};

 

删除字符串中的所有相邻重复项

序号:1047

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

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

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

示例:

输入:"abbaca"

输出:"ca"

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

这道题的思路其实和有效括号很相像,先遍历将字符串中的字符,然后比较遍历到的元素是否与栈顶元素相同,如果相同则栈顶元素出栈,否则就入栈。

遍历结束后,栈内剩余元素即为我们想要的答案。

function removeDuplicates(s: string): string {
    const array: string[] = s.split('');
    const stack: string[] = [];

    for (let i = 0; i < array.length; i++) {
        const current: string = array[i];
        const topStack: string = stack[stack.length - 1];

        if (current === topStack) {
            stack.pop();
        } else {
            stack.push(current);
        }
    }

    return stack.join('');
};

用栈实现一个队列

需要借助两个栈才能实现一个队列,一个负责输入,一个负责输出:

class Queue {
    private inStack: string[] = [];
    private outStack: string[] = [];

    push(str: string) {
        this.inStack.push(str);
    }

    pop() {
        if(!!this.outStack.length) {
            this.outStack.pop();
        } else {
            while(!this.inStack.length) {
                this.outStack.push(this.inStack.pop());
            }
        }
    }
}

字符串解码(medium)

序号:394

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

编码规则为: k[encoded_string],表示其中方括号内部的 encoded_string 正好重复 k 次。注意 k 保证为正整数。

你可以认为输入字符串总是有效的;输入字符串中没有额外的空格,且输入的方括号总是符合格式要求的。

此外,你可以认为原始数据不包含数字,所有的数字只表示重复的次数 k ,例如不会出现像 3a 或 2[4] 的输入。

示例 1:

输入: s = "3[a]2[bc]"
输出: "aaabcbc"

示例 2:

输入: s = "3[a2[c]]"
输出: "accaccacc"

这道题不难读懂,它的难点在于会出现括号内嵌套括号的情况,需要我们先计算出里层括号内的结果,再计算外层括号内的结果。这与栈先入后出的特点相似。

我们需要理清楚三样东西:

  1. 当前层括号的倍数,用于字符串倍数计算
  2. 当前层括号内的字符串
  3. 每层嵌套的倍数以及结果,用于这里需要借助一个栈来实现

因此我们需要创建一个栈 stack、一个结果字符串 res、以及一个倍数 multi

假设现在 s = 3[a2[bc]] , 下面我将用一张图来描述整个遍历流程:

image.png

转换成代码:

function decodeString(s: string): string {
    const stringArr: string[] = s.split('');
    const stack: { lastMulti: number, lastRes: string }[] = [];
    let res: string = '';
    let multi: number = 0;

    for (let i = 0; i < stringArr.length; i++) {
        const current: string = stringArr[i];

        if (current === '[') {
            stack.push({
                lastMulti: multi,
                lastRes: res,
            });

            res = '';
            multi = 0;
        } else if (current === ']') {
            const { lastMulti, lastRes } = stack.pop();
            const repeatStr = res.repeat(lastMulti);
            res = lastRes + repeatStr;
        } else if (!Number.isNaN(Number(current))) {
            // multi 的范围是 0 - 300
            multi = Number(current) + Number(multi) * 10;
        } else {
            res += current;
        }
    }

    return res;
};

后缀表达式求值(medium)

序号:150

后缀表达式也叫逆波兰表达式。

根据 逆波兰表示法,求表达式的值。

有效的算符包括 +、-、*、/ 。每个运算对象可以是整数,也可以是另一个逆波兰表达式。

注意 两个整数之间的除法只保留整数部分。

可以保证给定的逆波兰表达式总是有效的。换句话说,表达式总会得出有效数值且不存在除数为 0 的情况。

示例 1:

输入:tokens = ["2","1","+","3","*"]
输出:9
解释:该算式转化为常见的中缀算术表达式为:((2 + 1) * 3) = 9

示例 2:

输入:tokens = ["4","13","5","/","+"]
输出:6
解释:该算式转化为常见的中缀算术表达式为:(4 + (13 / 5)) = 6

后缀表达式很好理解,它与我们习惯用的中缀表达式最大的区别就在于运算符放到了要运算的两个数后面: 即 A + B 变成了 A B +

这道题非常适合用栈来解决:

  1. 遍历 tokens;
  2. 当遇到数字时,把数字入栈;
  3. 当遇到运算符时,按顺序取出栈顶的两个数字,分别作为右运算数和左运算数;
  4. 执行运算,并将运算结果入栈;
  5. 遍历结束后,栈内会剩余最后一个元素,也就是最终的运算结果
// 维护一个表达式数组,用于遍历时判断
const operators = ['+', '-', '*', '/'];
// 提取运算函数
const operate = (left: number, operator: string, right: number): string => {
    if (operator === '+') {
        return String(left + right);
    }

    if (operator === '-') {
        return String(left - right);
    }

    if (operator === '*') {
        return String(left * right);
    }

    if (operator === '/') {
        return String(parseInt(String(left / right)));
    }
}

function evalRPN(tokens: string[]): number {
    const stack: string[] = [];

    for (let i = 0; i < tokens.length; i ++) {
        const token: string = tokens[i];
        if (operators.includes(token)) {
            const rightString: string = stack.pop();
            const leftString: string = stack.pop();

            const result: string = operate(
                Number(leftString),
                token,
                Number(rightString),
            );
            stack.push(result)
        } else {
            stack.push(token);
        }
    }

    return Number(stack.pop());
};

中缀表达式转后缀表达式(medium)

从上一题中我们知道了什么是后缀表达式,以及后缀表达式如何求值。那么如果要将中缀表达式转化成后缀表达式,那么要怎么做呢?

首先,中缀表达式与后缀表达式除了运算符书写位置的区别外,还有一个比较大的区别,那就是运算顺序:

  • 有括号时,括号内的表达式优先运算
  • * / 的优先级高于 + -

因此在遍历转换的过程中,我们需要关注括号以及运算符优先级:

  1. 当前字符如果是左括号 ( ,则将该字符入栈
  2. 当前字符如果是右括号 ) ,则栈内元素依次出栈并拼接到结果字符串中,直到遇到左括号为止
  3. 当前字符如果是数字,则将其拼接到结果字符串中(无需入栈)
  4. 当前字符如果是运算符:
    • 如果是 * / ,那么当前字符入栈
    • 如果是 + -, 那么判断栈顶元素是否是更高优先级的 * /运算符,如果是则出栈;直到栈顶元素不是更高优先级的运算符时,再将当前字符入栈

下面还是用一张图来描述遍历流程:

image.png

const highPriority: string[] = ['*', '/'];

const toPRN = (str: string): string => {
    const stringArr: string[] = str.split('');
    let res: string = '';
    const stack: string[] = [];

    for (let i=0; i < stringArr.length; i++) {
        const current = stringArr[i];

        switch(current) {
            case '(':
                stack.push(current);
                break;
            case ')':
                while(stack[stack.length - 1] !== '(') {
                    res += stack.pop();
                }

                stack.pop();
                break;
            case '+':
            case '-':
                while(highPriority.includes(stack[stack.length - 1])) {
                    res += stack.pop();
                };

                stack.push(current);
                break;
            case '*':
            case '/':
                stack.push(current);
                break;
            default:
                res += current;
                break;
        }
    }

    while(stack.length) {
        res += stack.pop();
    }

    return res;
}

滑动窗口的最大值(hard)

序号:239

给你一个整数数组 nums,有一个大小为 k 的滑动窗口从数组的最左侧移动到数组的最右侧。你只可以看到在滑动窗口内的 k 个数字。滑动窗口每次只向右移动一位。

返回 滑动窗口中的最大值 。

示例 1:

输入:nums = [1,3,-1,-3,5,3,6,7], k = 3
输出:[3,3,5,5,6,7]
解释:
滑动窗口的位置                最大值
---------------               -----
[1  3  -1] -3  5  3  6  7       3
 1 [3  -1  -3] 5  3  6  7       3
 1  3 [-1  -3  5] 3  6  7       5
 1  3  -1 [-3  5  3] 6  7       5
 1  3  -1  -3 [5  3  6] 7       6
 1  3  -1  -3  5 [3  6  7]      7

通过示例,我们不难理解这道题目想让我们做什么。无非就是按顺序遍历一个数组,然后找出它在某个区间内最大的元素。

最直接的,我们先上暴力解法。暴力解法需要两层遍历:

  1. 首先通过数组长度 和 k 计算出第一层遍历需要执行多少次
  2. 在第二层遍历中,比较当前元素以及它后两位的元素的大小,并将最大的元素存入结果数组中
  3. 第二层遍历可以利用一个栈协助完成

暴力解法:

function maxSlidingWindow(nums: number[], k: number): number[] {
    const stack: number[] = [];
    const res: number[] = [];
    
    const slideTimes = nums.length - (k - 1);

    for (let i = 0; i < slideTimes; i++) {
        for (let j = i; j < i + k; j++) {
            const current: number = nums[j];
            if (!stack.length) {
                stack.push(current);
            } else {
                const temp: number = Math.max(current, stack.pop());
                stack.push(temp);
            }
        }
        res.push(stack.pop());
    }

    return res;
};

暴力解法的时间复杂度是 O(n²),所以最终结果就是......

image.png

没办法,我们只能寻找另外一种种解法。

我们先从滑动窗口入手进行分析:从示例中我们可以注意到滑动窗口是从左往右依次滑动的,新元素是从窗口右侧进入,而窗口范围是固定的,超出窗口范围的元素将从窗口左侧移除。

元素从一侧进入,从另一侧移除,这跟上述队列先进先出的特性可以说是非常相似了。因此这道题我们可以借助队列来完成。

我们假定滑动窗口的右边界下标为 right,左边界下标为 left,辅助队列为 queue。 因为窗口是滑动的,当左侧元素位置小于左边界时,需要移除。因此这里的辅助队列更适合存储元素的下标

同样的,我们依次遍历 nums:

  1. 从 right = 0 开始遍历
  2. 为了比较大小,我们可以辅助队列构造成一个单调递减队列。当遍历到一个新元素时,我们就依次将该元素与队列尾部元素作比较,如果该元素更大,那么队尾元素就从队列右侧移除,直至队尾元素都大于当前元素。最后将当前元素(的下标)入队。(这种右侧既可以入元素,又可以出元素的队列我们可以称之为 双向队列
  3. 滑动窗口左边界 left 可以通过 right 和 k 计算得出:left = right - (k - 1)(这里 (k - 1) 是因为 k 是个数,而 right 和 left 是下标)
  4. 当队首元素 < left 时,表示当前窗口内最大元素已经不在当前窗口范围内,因此需要将队首元素从队列左侧移除
  5. 当窗口右边界 right + 1 >= k (right 是下标,需要 +1)时,开始记录最大元素到结果数组中。此时最大元素的下标即为队首元素

示例1 来演示:

image.png

完整代码:

function maxSlidingWindow(nums: number[], k: number): number[] {
    // 辅助队列
    const queue: number[] = [];
    // 结果数组
    const res: number[] = [];
    
    // 遍历数组,并用 right 表示滑动窗口右边界下标
    for (let right = 0; right < nums.length; right ++) {
        // 当前元素 >= 队列末尾元素,则将队列末尾元素移除
        while(queue.length && nums[right] >= nums[queue[queue.length - 1]]) {
            queue.pop();
        }

        // 记录当前元素下标
        queue.push(right);

        // 滑动窗口左边界下标
        const left: number = right - k + 1;

        // 队列中最大元素下标如果不在滑动窗口范围内,则移除
        if (queue[0] < left) {
            queue.shift();
        }

        // 滑动窗口右边界满足 k 时,开始记录结果
        // 将队列中最大元素存入结果数组中
        if (right + 1 >= k) {
            res.push(nums[queue[0]]);
        }
    }

    return res;
};

柱状图中最大的矩形(hard)

序号:84

给定 n 个非负整数,用来表示柱状图中各个柱子的高度。每个柱子彼此相邻,且宽度为 1 。

求在该柱状图中,能够勾勒出来的矩形的最大面积。

示例 1:

输入: heights = [2,1,5,6,2,3]
输出: 10
解释: 最大的矩形为图中红色区域,面积为 10

如何确定当前柱子的最大面积呢?首先我们要确定三个数值:

  1. 当前柱子左边第一个比它更小的元素的位置
  2. 当前柱子右边第一个比它更小的元素的位置
  3. 左右两边位置之差便是当前柱子对应的最大宽度,与其高度相乘便能得出其最大面积 遍历 heights,再依次对每个元素进行上述遍历,便能得出最大的面积。

很显然,这个属于暴力解法。该解法的时间复杂度是 O(n²),最终的结果不出意外肯定也是以超时告终。

为了减少运算时间,我们可以用空间换取时间。而栈就是最常用的用空间换取时间的数据结构之一

单调递增栈

由上述逻辑我们知道,我们要找的是当前元素左右两边比它更大的元素。所以我们这里借助的栈还需要是一个单调递增栈

单调递增栈:顾名思义,栈内元素保持单调递增的栈

借助单调递增栈,我们在遍历时可以遵循以下逻辑:

  1. 当前元素如果小于栈顶元素,那么需要将栈顶元素 pop,直到当前元素为栈内最大元素
  2. 当前元素比栈顶元素大,那么当前元素入栈

经过上述规则处理后,我们可以确保以下几点:

  1. 栈内元素是单调递增的
  2. 对于被 pop 的元素,它的右边界就是当前元素(因为此时当前元素对于被 pop 的元素而言就是右边第一个比它更小的元素)
  3. 而此时被 pop 元素的左边界就是它前一个元素,既它 pop 之后栈顶的元素(因为此时的栈顶元素对于被 pop 元素而言就是左边第一个比它更小的元素)
  4. 确定了两个边界之后,被 pop 元素的最大宽度便可以得出了:width = right - left - 1, 从而得出当前元素所能得到的最大面积:width * heights[currentIndex]

这里还有一个需要注意的点:由于我们遍历每个元素时,需要关心其左边以及右边的值。因此为了确保 heights 内第一个元素以及最后一个元素都能正常的运算,我们需要在 heights 的头尾分别插入一个不影响运算结果的数值,比如 0。这样的数值也有一个专门的称呼:哨兵

最终可以得出完整代码:

function largestRectangleArea(heights: number[]): number {
    const stack: number[] = [];
    let max: number = 0;

    // 设置哨兵
    heights.push(0);
    heights.unshift(0);
    
    for (let i=0; i < heights.length; i++) {
        while(stack.length && heights[stack[stack.length - 1]] > heights[i]) {
            const currentIndex: number = stack[stack.length - 1];
            stack.pop();
            const left: number = stack[stack.length - 1];
            const right: number = i;
            const width: number = right - left - 1;

            max = Math.max(max, width * heights[currentIndex]);
        }
        
        stack.push(i);
    }

    return max;
};

写在最后

对于什么样的算法题需要用到 栈/队列 来辅助解决,我整理了以下几点:

  1. 一些栈的经典题型:比如括号匹配,后缀表达式等
  2. 当题目特性满足元素从一端进入,从一端移除等特点时,就可以考虑借助 栈/队列
  3. 当需要用空间换取时间时,可以借助 栈/队列 (比如暴力解法超时的时候)
  4. 而当涉及到求最大 / 最小的场景时,就可以往单调栈 / 单调队列方向去思考,至于单调增还是单调减,就需要视具体题目情况而定了、
  5. 特殊场景下,可能需要借助哨兵等手段来确保算法正确运行