头图

小五的算法系列 - 栈与队列

黄刀小五
English

Hello, 各位勇敢的小伙伴, 大家好, 我是你们的嘴强王者小五, 身体健康, 脑子没病.

本人有丰富的脱发技巧, 能让你一跃成为资深大咖.

一看就会一写就废是本人的主旨, 菜到抠脚是本人的特点, 卑微中透着一丝丝刚强, 傻人有傻福是对我最大的安慰.

欢迎来到小五算法系列栈与队列.

前言

此系列文章以《算法图解》和《学习JavaScript算法》两书为核心,其余资料为辅助,并佐以笔者愚见所成。力求以简单、趣味的语言带大家领略这算法世界的奇妙。

本文内容为栈和队列。笔者将带领大家从 js 模拟其实现出发,探索栈与队列在实际场景中的应用。其间会穿插讲解 程序调用栈任务队列,使理解程序执行、递归、异步调用等易如破竹。最后,附上几道习题以加深理解及巩固所学。

特点及实现

👺 特点:<后进先出>

👇 下图羽毛球筒就是对栈的形象比喻

👺 创建栈

👇 我们接下来用js模拟一个栈,并为其添加如下方法:

  • push(element(s)):入栈 -> 添加一个/多个元素至栈顶
  • pop():出栈 -> 移除栈顶元素,并返回被移除的元素
  • peek():返回栈顶元素
  • isEmpty():该栈是否存在元素
  • clear():移除栈中所有元素
  • size():栈中元素个数

应用:十进制转二进制

🤔 思考: 如何将十进制转换为二进制

<除2取余,逆序排列>:将十进制数字与2进行累除运算,取余后将余数逆序排列,得到的结果即为该数的二进制.

上述分析可知,这个是典型的后进先出场景,即栈的场景,代码如下:

import Stack from 'stack';
export const binaryConversion = (num: number) => {
  const stack = new Stack<number>();
  let binaryStr = '';

  while (num > 0) {
    stack.push(num % 2);
    num = Math.floor(num / 2);
  }

  while (!stack.isEmpty()) {
    binaryStr += stack.pop();
  }

  return binaryStr;
}

// 输入 10 -> 输出: '1010' 

👺 扩展:十进制转其它进制

思路分析:我们追加一个入参base代表进制;二进制余数为0 ~ 1,八进制余数为0 ~ 7,十六进制余数为0 ~ 15,搭配字母表示即为0 ~ 9,A ~ F。

👉 添加一组对数字的映射就好了, 大功告成!

import Stack from 'stack';
export const binaryConversion = (num: number, base: number) => {
  const stack = new Stack<number>();
  let binaryStr = '';
+ let baseMap = '0123456789ABCDEF';

  while (num > 0) {
-   stack.push(num % 2);
-   num = Math.floor(num / 2);
+   stack.push(num % base);
+   num = Math.floor(num / base);
  }

  while (!stack.isEmpty()) {
-   binaryStr += stack.pop();
+   binaryStr += baseMap[stack.pop() as number];
  }

  return binaryStr;
}

// binaryConversion(100345, 2)  ->  11000011111111001
// binaryConversion(100345, 8)  ->  303771
// binaryConversion(100345, 16) ->  187F9

函数调用栈 - 理解递归的重要神器

提到递归,大家肯定会说,这个我会呀,不就是不断循环自身的过程吗?

可你真的理解递归了吗?快排、归并、树的前中后序遍历等是否可以轻松驾驭?如若不能,则递归仍没有理解透彻,即没有吃透函数的执行顺序。

我们来看下面一段函数:

const greet = (name) => {
  console.log(`hello, ${name}!`);
  newGreet(name);
  console.log('getting ready to say bye...');
  bye();
}

const newGreet = (name) => {
  console.log(`how are you, ${name}?`);
}

const bye = () => {
  console.log('ok, bye!');
}

greet('xiaowu');

它的执行过程是怎样的呢?我们一起来看下吧👇

我们再来写个简单的递归, 求5的阶乘 ($5!$)

const fact = num => {
  if (num === 1) return 1;
  return num * fact(num - 1);
}
fact(5);

翠花, 上执行图

👺 扩展:执行上下文

分为 “全局执行上下文” 和 “函数执行上下文”

在开始执行任何代码前,JavaScript会创建全局上下文压入栈底,即 window 对象;每当我们调用一个函数时,一个新的函数执行上下文就会被创建,如上文过程图所示;整个JavaScript执行过程即是一个 “调用栈”

队列

特点及实现

👺 特点:<先进先出>

👇 先来先服务即对队列形象的比喻

👺 创建队列

👇 我们接下来用js模拟一个队列,并为其添加如下方法:

  • enqueue(element(s)):入队 -> 添加一个/多个元素至队尾
  • dequeue():出队 -> 移除队首元素, 并返回被移除的元素
  • peek():返回队首元素
  • isEmpty():该队列是否存在元素
  • size():队列中元素个数

优先队列

实际生活中,并不会时刻都遵循着先来先服务的原则;比如医院,医生会优先处理病情较为严重的患者;这便引出了队列的第一个变种 - 优先队列;

优先队列 - 在插入数据时,按其权重将数据插入到正确的位置。

👺 代码分析

  • 增加一个代表权重的入参来决定插入位置
  • 改写enqueue方法,使其能按照权重插入正确位置
  • Queue类中的private改为protected,使继承后的类可重写enqueue方法
  • 队列为空时,直接push元素
  • 队列非空时,判断元素插入位置,若为队尾则直接push,否则使用splice插入

👺 代码实现

循环队列

击鼓传花” 讲的是一种游戏规则,人们在击鼓声中传花,鼓声停止时,花传到谁手上,谁就被淘汰,直到剩余一个人〈胜者〉。

此游戏一直处于循环状态,由此引出了队列的第二个变种 - 循环队列。

我们拟定,每循环n次淘汰一人,n为入参。

👺 代码分析

  • 如何循环?先出列在入列
  • 当队列大于1时开启循环,直至仅剩一位,即胜者

👺 代码实现

JavaScript 任务队列

书接上文,我们讲到了程序调用栈,而程序并不会均同步执行,当其执行异步操作时又会发生什么呢?

🤔 思考: $setTimeout(() => {执行函数}, 0)$ 为何没有立即执行

【图片来源 - JavaScript Event Loop 机制详解与 Vue.js 中实践应用】

程序顺序执行,依次入栈;当遇到异步任务时,将其推入任务队列;待程序栈清空后,读取任务队列,将其分别入栈;如此反复的过程,便是 Event Loop - 事件循环

🦅 这里推荐一篇讲解JS执行机制的文章 这一次,彻底弄懂 JavaScript 执行机制

小试牛刀

以下题目均来自 LeetCode,笔者会为每道题目提供一种解题思路;此思路绝非最佳,欢迎各位看官积极思考,并留下自己的独特见解。

LeetCode 232. 用栈实现队列

👺 题目描述

请你仅使用两个栈实现先入先出队列。队列应当支持一般队列支持的所有操作(enqueuedequeuepeeksizeisEmpty);

你只能使用标准的栈操作 ~ 也就是只有 push, poppeek, size 和 isEmpty 操作是合法的。

👺 题目分析

栈实现队列,有两种思路方向,分别为从入栈入手和从出栈入手,笔者选择从入栈入手;

核心思路即是如何让后入栈的元素在栈底,先入栈的元素在栈顶;

用辅助栈倒换下顺序,即可模拟队列,过程如下 👇

👺 代码实现

class Queue<T> {
  private items = new Stack<T>();

  enqueue(item: T) {
    if (this.items.isEmpty()) {
      this.items.push(item);
      return;
    }

    const _stack = new Stack<T>();
    while (!this.items.isEmpty()) {
      _stack.push(this.items.pop() as T);
    }
    _stack.push(item);

    while (!_stack.isEmpty()) {
      this.items.push(_stack.pop() as T);
    }
  }
  
 // 其余方法无变化
}

LeetCode 225. 用队列实现栈

👺 题目描述

请你仅使用两个队列实现一个后入先出(LIFO)的栈,并支持普通栈的所有操作(pushpoppeeksizeisEmpty);

你只能使用队列的基本操作 ~ 也就是 enqueue, dequeuepeek, size和 isEmpty 这些操作。

👺 题目分析

队列实现栈,同样有两种思路方向,分别为从入队入手和从出队入手,笔者选择从入队入手;

核心思路即是如何让后入队的元素在队首,先入队的元素在队尾;

每次入队新元素时,将队列其它元素先出队后入队即可,过程如下 👇

👺 代码实现

class Stack<T> {
  private items = new Queue<T>();

  push(item: T) {
    this.items.enqueue(item);
    for (let i = 1; i < this.items.size(); i++) {
      this.items.enqueue(this.items.dequeue() as T);
    }
  }

  // 其余方法无变化
}

LeetCode 20. 有效的括号

👺 题目描述

👺 题目分析

我们看下面这个 🌰 例子

{[()][]}

给定字符串是否闭合,就是在找最小闭合单元,剔除后继续寻找直至字符串为空则满足条件,过程如下👇

  • 如上图分析,与栈的思想完全吻合
  • 遇左括号则进栈
  • 遇右括号:若为最小闭合单元则出栈,若匹配不上则字符串不闭合
  • 操作完成后,若栈为空,则字符串闭合

👺 代码实现

const isValid = (str: string) => {
  const stack = new Stack();
  const strMap = {
    ')': '(',
    ']': '[',
    '}': '{',
  };
  for (let i of str) {
    if (['(', '[', '{'].includes(i)) {
      stack.push(i);
    }
    if (strMap[i]) {
      if (strMap[i] === stack.peek()) {
        stack.pop();
      } else {
        return false;
      }
    }
  }
  return stack.size() === 0;
}

LeetCode 32. 最长有效括号

👺 题目描述

👺 题目分析

此题为上一题的扩展,由上题分析,我们可知,此题应继续使用栈;

❓ 那怎么求长度呢 🤔 与数字挂钩的话借助下数组的下标呗,下标差即为长度

上图可看出长度为 出栈索引减栈顶值;而实际操作时,右括号不入栈,故我们记录一个bottom值;若栈为空,则减bottom,栈不为空,则减栈顶值。

👺 代码实现

const longestValidParentheses = (str: string) => {
  const stack = new Stack<number>();
  let maxLen = 0;
  let bottom: number | undefined = undefined;
  for (let i = 0; i < str.length; i++) {
    if (stack.isEmpty() && str[i] === ')') bottom = undefined;

    if (str[i] === '(') {
      if (bottom === undefined) bottom = i - 1;
      stack.push(i);
    }

    if (!stack.isEmpty() && str[i] === ')') {
      stack.pop();
      let len = i - (stack.peek() ?? bottom);
      if (len > maxLen) maxLen = len;
    }
  }
  return maxLen;
}

LeetCode 239. 滑动窗口最大值

👺 题目描述

👺 题目分析

乍一看题干,这不就是一个行走的队列吗,[1, 3, -1, -3, 5, 3, 6, 7] 执行顺序如下👇

于是笔者实现了如下代码:

const maxSlidingWindow = (nums: number[], k: number) => {
  let queue: number[] = [];
  let maxArr: number[] = [];
  nums.forEach(item => {
    if (queue.length <= k) {
      queue.push(item);
    }
    if (queue.length === k) {
      let max = queue[0];
      for (let i = 1; i < queue.length; i++) {
        if (queue[i] > max) max = queue[i];
      }
      maxArr.push(max);
      queue.shift();
    }
  })
  return maxArr;
}

当我兴致冲冲的提交到 LeetCode 上时

无奈,我们继续优化,看看怎么能减少循环;观察上图,我们发现,窗口中在最大值左侧的数字没有意义,我们无需关心,可将其出队,这样窗口最大值为队首元素;

❓ 队首什么时候出队呢 🤔 队列中存索引值不就好了,当前元素索引和队首元素的差值与窗口大小比对,若大于窗口大小,则队首元素已不在此窗口中,出队。

👺 代码实现

其本质是一个双端队列,扩展下我们的 Queue 类,增加 poptail 两个方法,代表从队尾移除元素和获取队尾值。

pop() {
  return this.items.pop();
}
tail() {
  return this.items[this.items.length - 1];
}
const maxSlidingWindow = (nums: number[], k: number) => {
  const queue = new Queue<number>();
  let maxArr: number[] = [];
  nums.forEach((item, index) => {
    while (!queue.isEmpty() && item >= nums[queue.tail()]) {
      queue.pop();
    }
    queue.enqueue(index);
    if (queue.peek() <= index - k) queue.dequeue();
    if (index >= k - 1) maxArr.push(nums[queue.peek()]);
  })
  return maxArr;
}

后记

🔗 本文代码 Github 链接:队列

🔗 本系列其它文章链接:起航篇 - 排序算法

阅读 260

小五的算法之路
此专栏为笔者在毕业后首次重新 “研读” 数据结构与算法后所著,意在记录学习中的磕磕绊绊与心得体会,并...

前端开发攻城狮, 擅长搬砖, 精通ctrl + c & ctrl + v, 日常bug缔造者.

277 声望
17 粉丝
0 条评论
你知道吗?

前端开发攻城狮, 擅长搬砖, 精通ctrl + c & ctrl + v, 日常bug缔造者.

277 声望
17 粉丝
宣传栏