本文同步更新於個人網站中,有更好的程式碼 syntax highlighting 和 KaTeX 數學公式顯示支援。
我們可以把 Stack 看成是一個弱化的陣列,它只有兩個改變長度的方法:push
和 pop
。用生活中的例子來描述 Stack,可以想像有一個用來裝書本的箱子,每次只能在最上面放入或取出一本書,這就是 Stack 的 push 和 pop。而且最先放入的書本會在最底下,必須要先取出最上面的書本才能取出下面的書本,這就是 Stack 的“後進先出”(Last In First Out,LIFO)特性。
還有一個就是我們常用的網站 Stack Overflow,它的名字就是來自於 Stack 這個資料結構,前面說到有個箱子用來裝書本,那麼如果我們不斷放書進去,當箱子已經被裝滿時,我們還繼續放書進去,那麼箱子就會爆滿,書本會掉出來,這就是 overflow,現在我們再回頭看看這個網站的 Logo,是不是突然知道為什麼它會這樣設計了呢?
Stack 有如下的特點:
Stack 的相關概念如下:
push 和 pop 的操作示意圖如下:
Stack 的 push 和 pop 操作示意圖
接著來看 Stack 的相關方法:
push
:推入元素pop
:彈出元素isEmpty
:判斷 Stack 是否為空size
:回傳 Stack 的長度top
或 peek
:回傳 Stack 最頂端的元素C++ 中取得 Stack 最頂端元素的方法叫 top
,而 Java 中則是 peek
。
Stack 的實作方式很簡單,就是把陣列再包裝一層,讓陣列只能從最後面插入和刪除元素,這樣就可以達到 Stack 的效果。
在開始之前,我們先來將單元測試給打開,這樣我們可以透過測試即時看到我們的程式碼是否正確。首先,我們打開系列文專案的 day04-stack/Stack.spec.js
檔案,然後將測試程式碼上的 describe.skip
的 skip
去掉:
- describe.skip('Stack', () => {
+ describe('Stack', () => {
// ...
})
然後執行我們的測試指令 pnpm test:ui
,就可以在瀏覽器中看到測試結果了:
一開始都會是失敗的,因為我們還沒開始實作 Stack,接下來就讓我們開始實作 Stack 吧!
實作程式碼如下:
class Stack {
// 使用 # 讓它是私有屬性,讓外部無法直接存取,
// 這樣就可以限定只能透過 push 和 pop 來對其進行操作
#items = [];
push(data) {
this.#items.push(data);
}
pop() {
return this.#items.pop();
}
size() {
return this.#items.length;
}
isEmpty() {
return this.#items.length === 0;
}
peek() {
return this.#items[this.#items.length - 1];
}
top() {
return this.peek();
}
toString() {
return this.#items.toString();
}
clear() {
this.#items.length = 0;
}
}
將上面的程式碼實作到 day04-stack/Stack.js
裡的 Stack 類別後,我們可以檢查一下我們的測試是否通過了:
確認測試通過後,讓我們用剛才實作的這個 Stack 來套用到下面的應用場景中。
Stack 最大的特點就是後進先出,所以逆序輸出是一個經常使用到的場景。先將所有元素依序 push 進到 stack 中,再依序 pop 出來就可以達到逆序的效果。
例如大家最常用的 ctrl + z
、瀏覽器的上一頁。
這個應用場景是在編譯器中,編譯器會檢查程式碼中的括號是否成對,如果沒有成對就會報錯。例如:{[()]}
這個括號就是成對的,而{[(]}
這個括號就是沒有成對的,編譯器會報錯。它其實也是 LeetCode 上的一道題目,20. Valid Parentheses。
這道題是 Stack 的經典題,但是一般初學者可能沒辦法直接聯想到 Stack,大部分人(像是我一開始)都會去試著數左括號跟右括號的數量有沒有對上,然後就會發現如果是這樣 ([)]
的字串會無法正確判斷,因為不只括號數要對,它的出現順序也必須要合理,那麼我們怎麼把它跟剛才學的 Stack 做連結呢?
用圖解來看就是像這樣:
我們一開始先準備一個 Stack,然後依序遍歷每個括號:
只要遇到左括號(開啟符號)就將它推入 Stack 中記錄各個括號的開啟順序:
遇到右括號(關閉符號)我們就從 Stack 中彈出最後一次開啟的左括號,然後比較這兩個括號是否能成對:
如果發現無法配對,代表我們這組括號對不合法,因為它必須依照後開啟先閉合的規則;如果配對成功我們就繼續往下遍歷,最後還要檢查我們的 Stack 是否已經清空,因為它有可能只有開啟就沒有關上了。
具體的實作如下:
function match(s) {
const stack = new Stack();
for (let i = 0; i < s.length; i++) {
const c = s.charAt(i);
switch (c) {
case ')':
if (stack.pop() === '(') {
break;
}
return false;
case ']':
if (stack.pop() === '[') {
break;
}
return false;
case '}':
if (stack.pop() === '{') {
break;
}
return false;
case '(':
case '[':
case '{':
stack.push(c);
break;
}
}
return stack.isEmpty();
}
// 實際 console 看看結果或是直接 run 單元測試
console.log(match('{[()]}')); // true
console.log(match('{[(])}')); // false
十進位 N 和其他 d 進位的轉換,解決的方式有很多種,其中一個簡單的算法基於下列原理:
例如 (2007)10 = (3727)8,其運算過程如下:
十進位轉成八進位運算過程
可以看到上述過程是從低位到高位產生八進位的各個數位,然後從高位到低位輸出,結果數位的使用有後出現先使用的特性,所以可以使用 Stack 來解決這個問題。
// 十進位轉成二進位
function decimalToBinary(decimalNumber) {
const stack = new Stack();
let number = decimalNumber;
let binaryString = '';
while (number > 0) {
stack.push(number % 2);
number = Math.floor(number / 2);
}
while (!stack.isEmpty()) {
binaryString += stack.pop();
}
return binaryString || '0';
}
decimalToBinary(5); // 101
decimalToBinary(10); // 1010
// 通用進位轉換
function convertDecimalToBase(dec, base) {
const stack = new Stack();
let number = dec;
let ret = '';
const digits = '0123456789ABCDEF';
while (number > 0) {
stack.push(number % base);
number = Math.floor(number / base);
}
while (!stack.isEmpty()) {
ret += digits[stack.pop()];
}
return ret || '0';
}
convertDecimalToBase(10, 2) // 1010
convertDecimalToBase(10, 8) // 12
convertDecimalToBase(10, 16) // A
表達式求值是 Stack 應用的一個典型例子。這裡介紹一種簡單直觀、廣為使用的算法,通常稱為「算符優先法」。
要把一個表達式翻譯成正確求值的一個機器指令序列,或者直接對表達式求值,首先要能夠正確解釋表達式。例如要對下述表達式求值:
首先要理解四則運算的規則
因此這個算術表達式的計算順序應為:
任何一個表達式都是由操作數(operand)和運算子(operator)和分隔符(delimiter)組成。分隔符就是小括號,運算子包括加減乘除,以及更複雜的求餘、三角函數等。這裡我們只討論簡單的算術表達式,所以運算子只有加減乘除。
我們把運算子和分隔符都統稱為算符,根據上面三條規則,在運算每一步時,任意兩個相繼出現的算符 θ1 和 θ2 之間的優先關係最多是下面三種關係:
這種優先關係如下表所示:
由規則 3 可知,+
、-
、*
和 /
為 θ1 時優先度均低於“(
”,但高於右括號“)
”。基於此,首先我們要來討論如何使用算符優先演算法來實作表達式求值。
為了實作這個演算法,我們需要使用兩個 stack,一個用來儲存操作數或運算結果,另一個用來儲存算符。
這個演算法的基本思想如下:
下面是完整實作程式碼:
function evaluate(expression) {
const OPND_stack = new Stack();
const OPTR_stack = new Stack();
// 遍歷這個表達式
for (let i = 0; i < expression.length; i++) {
let c = expression.charAt(i);
// 如果是數字,也就是操作數
if (isDigit(c) || c == '.') {
let stringBuilder = '';
// 操作數的拼接,包含小數點
while (i < expression.length && (isDigit((c = expression.charAt(i))) || c == '.')) {
stringBuilder += c;
i++;
}
// 操作數 push 到 OPND_stack
OPND_stack.push(Number(stringBuilder));
// 跳過本次迴圈,i 的值已經增加過,所以要減回來
i--;
continue;
} else {
// 如果是運算符
outer: while (!OPTR_stack.isEmpty()) {
switch (precede(OPTR_stack.top(), c)) {
case '<':
// 如果 OPTR_stack 的 top 運算符小於 c,那麼 c 直接入 OPTR_stack
OPTR_stack.push(c);
break outer;
case '=':
// 如果 OPTR_stack 的 top 運算符等於 c,那麼只有一種情況,左右括號相遇,直接 pop 出 "("
OPTR_stack.pop();
break outer;
case '>':
// 如果 OPTR_stack 的 top 運算符大於 c
const operator = OPTR_stack.pop();
// 如果有多餘的運算符卻沒有操作數可以計算,那麼這個表達式是錯誤的
try {
const opnd2 = OPND_stack.pop();
const opnd1 = OPND_stack.pop();
const result = operate(opnd1, operator, opnd2);
OPND_stack.push(result);
} catch {
console.log('表達式錯誤!');
return;
}
break;
}
}
// 如果 OPTR_stack 是空的,那麼直接 push c
if (OPTR_stack.isEmpty()) {
OPTR_stack.push(c);
}
}
}
while (!OPTR_stack.isEmpty()) {
const operator = OPTR_stack.pop();
// 如果有多餘的運算符卻沒有操作數可以計算,那麼這個表達式是錯誤的
try {
const opnd2 = OPND_stack.pop();
const opnd1 = OPND_stack.pop();
const result = operate(opnd1, operator, opnd2);
OPND_stack.push(result);
} catch {
console.log('表達式錯誤!');
return;
}
}
if (OPND_stack.size() === 1) {
return OPND_stack.pop();
} else {
console.log('表達式錯誤!');
return;
}
}
const isDigit = (c) => /[0-9]/.test(c);
// 比較兩個運算符的優先級大小
const precede = (θ1, θ2) => {
if (θ1 == '+' || θ1 == '-') {
if (θ2 == '+' || θ2 == '-' || θ2 == ')') {
return '>';
} else {
return '<';
}
} else if (θ1 == '*' || θ1 == '/') {
if (θ2 == '(') {
return '<';
} else {
return '>';
}
} else if (θ1 == '(') {
if (θ2 == ')') {
return '=';
} else {
return '<';
}
} else if (θ1 == ')') {
return '>';
}
return '>';
};
// 執行運算
const operate = (opnd1, optr, opnd2) => {
switch (optr) {
case '+':
return opnd1 + opnd2;
case '-':
return opnd1 - opnd2;
case '*':
return opnd1 * opnd2;
case '/':
return opnd1 / opnd2;
}
return 0;
};
我們最後再來檢查一下所有測試是不是都通過了:
今天我們介紹了 stack 結構也說明了系列文專案的使用方式,在之後大部分的程式碼實作的檢查我都會交給單元測試來做,大家可以在實作前先觀察測資了解一下需求,然後就可以專注在程式碼上,也能順便體驗到由紅燈轉綠燈的樂趣。