小五的算法系列 - 棧與隊列

Hello, 各位勇敢的小夥伴, 你們好, 我是大家的嘴強王者小五, 身體健康, 腦子沒病.git

本人有豐富的脫髮技巧, 能讓你一躍成爲資深大咖.github

一看就會一寫就廢是本人的主旨, 菜到摳腳是本人的特色, 卑微中透着一絲絲剛強, 傻人有傻福是對我最大的安慰.算法

歡迎來到小五算法系列棧與隊列.segmentfault

前言

此係列文章以《算法圖解》和《學習JavaScript算法》兩書爲核心,其他資料爲輔助,並佐以筆者愚見所成。力求以簡單、趣味的語言帶你們領略這算法世界的奇妙。數組

本文內容爲棧和隊列。筆者將帶領你們從 js 模擬其實現出發,探索棧與隊列在實際場景中的應用。其間會穿插講解 程序調用棧任務隊列,使理解程序執行、遞歸、異步調用等易如破竹。最後,附上幾道習題以加深理解及鞏固所學。異步

特色及實現

👺 特色:<後進先出>函數

👇 下圖羽毛球筒就是對棧的形象比喻oop

👺 建立棧post

👇 咱們接下來用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 連接:隊列

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

相關文章
相關標籤/搜索