前端學習 資料結構與演算法 快速入門 系列 —— 棧

彭加李發表於2021-08-02

前面,我們學習瞭如何建立和使用電腦科學中最常用的資料結構——陣列

我們知道可以在陣列的任意位置新增或刪除元素,但有時我們還需要一種能在新增和刪除元素時有更多控制的資料結構。有兩種類似陣列的資料結構在新增和刪除時有更多控制,它們就是佇列

棧資料結構

棧是一種遵循後進先出(或先進後出)原則的有序集合。新新增的元素或待刪除的元素在棧的一端,稱作棧頂,另一端叫棧底。在棧裡,新元素都靠近棧頂,舊元素都接近棧底。

現實生活中有很多棧的例子,比如桌上的一摞書或一疊盤子

棧也被用於瀏覽器歷史記錄,即瀏覽器的返回按鈕

建立一個基於陣列的棧

定義一個類來表示棧:

class Stack{
    constructor(){
        this.items = [] // {1}
    }
}

我們需要一個資料結構來儲存棧中的元素。這裡選用陣列(行{1})來儲存棧中的元素。

由於陣列可以在任意位置新增或刪除元素,而棧遵循後進先出(LIFO)原則,所以需要對元素的插入和刪除做限制,接下來我們給棧定義一些方法:

  • push():新增一個或多個元素到棧頂
  • pop():移除棧頂元素,同時返回被移除的元素
  • peek():返回棧頂元素(不做其他處理)
  • clear():移除棧中的所有元素
  • size():返回棧中元素個數
  • isEmpty():如果棧中沒有元素則返回 true,否則返回 false

向棧新增元素

push(...values) {
    this.items.push(...values)
}

從棧移除元素

pop() {
    return this.items.pop()
}

只能通過 push 和 pop 方法新增和刪除棧中元素,這樣一來,我們的棧自然遵循 LIFO 原則。

檢視棧頂元素

peek() {
    return this.items[this.items.length - 1]
}

清空棧元素

clear(){
    this.items = []
}

也可以多次呼叫 pop 方法。

棧中元素個數

size() {
    return this.items.length
}

棧是否為空

isEmpty() {
    return this.size() === 0
}

使用 Stack 類

class Stack {
    constructor() {
        this.items = []
    }
    push(...values) {
        this.items.push(...values)
    }
    pop() {
        return this.items.pop()
    }
    peek() {
        return this.items[this.items.length - 1]
    }
    clear() {
        this.items = []
    }
    size() {
        return this.items.length
    }
    isEmpty() {
        return this.size() === 0
    }
}
const stack = new Stack()
stack.push(1)
stack.push(2, 3, 4)
console.log(stack.items) // [ 1, 2, 3, 4 ]
console.log(stack.pop()) // 4
console.log(stack.items) // [ 1, 2, 3 ]
console.log(stack.peek()) // 3
console.log(stack.size()) // 3
stack.clear()
console.log(stack.isEmpty()) // true

建立一個基於物件的 Stack 類

建立 Stack 最簡單的方式是使用陣列來儲存元素,我們還可以使用物件來儲存元素。

class Stack {
    constructor() {
        this.count = 0
        this.items = {}
    }
    push(...values) {
        values.forEach(item => {
            this.items[this.count++] = item
        })
    }
    pop() {
        if (this.isEmpty()) {
            return undefined
        }
        this.count--
        let result = this.items[this.count]
        delete this.items[this.count]
        return result
    }
    peek() {
        if (this.isEmpty()) {
            return undefined
        }
        return this.items[this.count - 1]
    }
    clear() {
        this.items = {}
        this.count = 0
    }
    size() {
        return this.count
    }
    isEmpty() {
        return this.size() === 0
    }
}
const stack = new Stack()
stack.push(1)
stack.push(2, 3, 4)
console.log(stack.items) // { '0': 1, '1': 2, '2': 3, '3': 4 }
console.log(stack.pop()) // 4
console.log(stack.items) // { '0': 1, '1': 2, '2': 3 }
console.log(stack.peek()) // 3
console.log(stack.size()) // 3
stack.clear()
console.log(stack.isEmpty()) // true 

Tip:clear() 方法還可以使用下面邏輯移除棧中所有元素

clear() {
    while (!this.isEmpty()) {
        this.pop()
    }
}

建立 toString 方法

toString() 方法返回一個表示該物件的字串。

對於基於陣列的棧,我們可以這樣寫:

toString() {
    return this.items.toString()
}
const stack = new Stack()
stack.push('a', 'b', 'c')
console.log(stack.toString()) // a,b,c

基於物件的棧稍微麻煩點,我們可以這樣:

toString() {
    // 轉為類陣列
    const arrayLike = Object.assign({}, this.items, { length: this.count })
    // 轉為陣列,然後使用陣列的 toString
    return Array.from(arrayLike).toString()
}

保護資料內部元素

在建立別的開發者也能使用的資料結構時,我們希望保護內部元素,只有通過我們暴露的方法才能修改內部結構。

對於 Stack 類,要確保元素只能被新增到棧頂,可惜現在我們在 Stack 中宣告的 items 並沒有被保護。

使用者可以輕易的獲取 items 並對其直接操作,就像這樣:

const stack = new Stack()
// 在棧底插入元素
stack.items.unshift(2)

下劃線命名約定

我們可以用下劃線命名約定來標記一個屬性為私有屬性:

class Stack {
    constructor() {
        this._count = 0
        this._items = {}
    }
}

:這種方式只是一種約定,只能依靠使用我們程式碼的開發者所具備的常識。

使用 Symbol

Symbol 可以保證屬性名獨一無二,我們可以將 items 改寫為:

const unique = Symbol("Stack's items")

class Stack {
    constructor() {
        this[unique] = []
    }
    push(...values) {
        this[unique].push(...values)
    }
    // ...省略其他方法
}

這樣,我們不太好直接獲取 items,但我們還是可以通過 getOwnPropertySymbols() 獲取 items 的新名字,從而對內部資料進行直接修改:

const stack = new Stack()
stack.push(1)
console.log(stack) // Stack { [Symbol(Stack\'s items)]: [ 1 ] }

let symbol = Object.getOwnPropertySymbols(stack)[0]
console.log('symbol: ', symbol);
stack[symbol].unshift(2) // {1}
console.log(stack) // Stack { [Symbol(Stack\'s items)]: [ 2, 1 ] }

在行{1},我們給棧底新增元素,打破了棧只能在棧頂新增元素的原則。

用 WeakMap

將物件的 items 存在 WeakMap 中,請看示例:

const weakMap = new WeakMap()
class Stack {
    constructor() {
        weakMap.set(this, [])
    }
    push(...values) {
        weakMap.get(this).push(...values)
    }
    toString() {
        return weakMap.get(this).toString()
    }
    // ...省略其他方法
}
const stack = new Stack()
stack.push(1, 2)
console.log(stack.toString()) // 1,2

現在 items 在 Stack 中是真正的私有屬性,我們無法直接獲取 items。但擴充套件類時無法繼承私有屬性,因為該屬性不在 Stack 中。

類私有域

類屬性在預設情況下是公共的,可以被外部類檢測或修改。在ES2020 實驗草案 中,增加了定義私有類欄位的能力,寫法是使用一個#作為字首。

class Stack {
    #items
    constructor() {
        this.#items = []
    }
    push(...values) {
        this.#items.push(...values)
    }
    toString(){
        return this.#items.toString()
    }
    // ...省略其他方法
}

const stack = new Stack()
stack.push(1, 2)
console.log(stack.toString()) // 1,2

Tip:這段程式碼可以在 chrome 74+ 執行

在瀏覽器控制檯訪問 #items 報錯,進一步證明該私有變數無法在外訪問:

> stack.#items
VM286:1 Uncaught SyntaxError: Private field '#items' must be declared in an enclosing class

用棧解決問題

十進位制轉二進位制

比如10轉為二進位制是1010,方法如下:

Step1    10/2=5     餘0
Step2    5/2=2      餘1   (2.5向下取整為2)
Step3    2/2=1      餘0
Step4    1/2=0      餘1   (0.5向下取整為0)

將餘數依次放入棧中:0 1 0 1
最後,將餘數依次移除則是結果:1010

實現如下:

/**
 *
 * 將十進位制轉為二進位制
 * @param {正整數} decimal
 * @return {String} 
 */
function decimalToBianary(decimal) {
    // 存放餘數
    const remainders = new Stack()
    let number = decimal
    let result = ''
    while (number > 0) {
        remainders.push(number % 2)
        number = Math.floor(number / 2)
    }
    while (!remainders.isEmpty()) {
        result += remainders.pop()
    }
    return result
}
console.log(decimalToBianary(10)) // 1010

:這裡只考慮正整數轉為二進位制,不考慮小數,例如 10.1。

平衡圓括號

判斷輸入字串是否滿足平衡圓括號,請看以下示例:

() -> true
{([])} -> true
{{([][])}()} -> true 
[{()] -> false
  • 空字串視為平衡
  • 字元只能是這6個字元:{ [ ( ) ] }。例如 (0) 則視為不平衡。
function blanceParentheses(symbols) {
    // 處理空字元
    if (symbols.length === 0) {
        return true
    }

    // 包含 {}[]() 之外的字元
    if ((/[^\{\}\[\]\(\)]/g).test(symbols)) {
        return false
    }

    let blance = true
    let symbolMap = {
        '(': ')',
        '[': ']',
        '{': '}',
    }
    const stack = new Stack()
    for (let item of symbols) {
        // 入棧
        if (symbolMap[item]) {
            stack.push(item)
            // 不是入棧就是出棧,出棧字元不匹配則說明不平衡
        } else if (symbolMap[stack.pop()] !== item) {
            blance = false
            break
        }
    }
    return blance && stack.isEmpty();
}
console.log(blanceParentheses(`{([])}`)) // true
console.log(blanceParentheses(`{{([][])}()}`)); // true
console.log(blanceParentheses(`[{()]`)) // false
console.log(blanceParentheses(`(0)`)) // false
console.log(blanceParentheses(`()[`)) // false

漢諾塔

從左到右有三根柱子 A B C,柱子 A 有 N 個碟子,底部的碟子最大,越往上碟子越小。需要將 A 中的所有碟子移到 C 中,每次只能移動一個,移動過程中必須保持上面的碟子比下面的碟子要小。問需要移動多少次,如何移動?

可以使用遞迴,大致思路:

  • 將 A 中 N - 1 的碟子移到 B 中
  • 將 A 中最後一個碟子移到 C 中
  • 將 B 中所有碟子移到 C 中

Tip: 遞迴是一種解決問題的方法,每個遞迴函式必須有基線條件,即不再遞迴呼叫的條件(停止點)。後續會有章節詳細講解遞迴。

/**
 *
 * 漢諾塔
 * @param {Number} count 大於0的整數
 * @param {Stack} from 
 * @param {Stack} to 
 * @param {Stack} helper
 * @param {Array} steps 儲存詳細步驟
 */
function hanoi(count, from, to, helper, steps) {
    if (count === 1) {
        const plate = from.pop()
        to.push(plate)
        steps.push(Array.of(plate, from.name, to.name))
        return
    }
    // 將 from 中 count - 1 個移到 helper
    hanoi(count - 1, from, helper, to, steps)
    // 將 from 中最後一個移到 to
    hanoi(1, from, to, helper, steps)
    // 將 helper 中的移到 to
    hanoi(count - 1, helper, to, from, steps)
}
// 測試漢諾塔
function testHanoi(plateCount) {
    const fromStack = new Stack()
    const toStack = new Stack()
    const helperStack = new Stack()
    fromStack.name = 'A'
    toStack.name = 'C'
    helperStack.name = 'B'
    const result = []
    let i = plateCount
    while (i > 0) {
        fromStack.push(`碟子${i--}`)
    }
    hanoi(plateCount, fromStack, toStack, helperStack, result)
    result.forEach((item, i) => {
        console.log(`step${i + 1} ${item[0]} ${item[1]} -> ${item[2]}`);
    })
}

testHanoi(2)
console.log()
testHanoi(3)
step1 碟子1 A -> B
step2 碟子2 A -> C
step3 碟子1 B -> C

step1 碟子1 A -> C
step2 碟子2 A -> B
step3 碟子1 C -> B
step4 碟子3 A -> C
step5 碟子1 B -> A
step6 碟子2 B -> C
step7 碟子1 A -> C

Tip: hanoi() 方法還可以精簡:

function hanoi(count, from, to, helper, steps) {
    if (count >= 1) {
        // 將 from 中 count - 1 個移到 helper
        hanoi(count - 1, from, helper, to, steps)
        // 將 from 中最後一個移到 to
        const plate = from.pop()
        to.push(plate)
        steps.push(Array.of(plate, from.name, to.name))
        // 將 helper 中的移到 to
        hanoi(count - 1, helper, to, from, steps)
    }
}

Stack 完整程式碼

基於陣列的棧

/**
 * 棧(基於陣列的棧)
 * @class StackOfArray
 */
class StackOfArray {
    constructor() {
        this.items = []
    }
    push(...values) {
        this.items.push(...values)
    }
    pop() {
        return this.items.pop()
    }
    peek() {
        return this.items[this.items.length - 1]
    }
    clear() {
        this.items = []
    }
    size() {
        return this.items.length
    }
    isEmpty() {
        return this.size() === 0
    }
    toString() {
        return this.items.toString()
    }
}

基於物件的棧

/**
 * 棧(基於物件的棧)
 * @class StackOfObject
 */
class StackOfObject {
    constructor() {
        this.count = 0
        this.items = {}
    }
    push(...values) {
        values.forEach(item => {
            this.items[this.count++] = item
        })
    }
    pop() {
        if (this.isEmpty()) {
            return undefined
        }
        this.count--
        let result = this.items[this.count]
        delete this.items[this.count]
        return result
    }
    peek() {
        if (this.isEmpty()) {
            return undefined
        }
        return this.items[this.count - 1]
    }
    clear() {
        this.items = {}
        this.count = 0
    }
    size() {
        return this.count
    }
    isEmpty() {
        return this.size() === 0
    }
    toString() {
        // 轉為類陣列
        const arrayLike = Object.assign({}, this.items, { length: this.count })
        // 轉為陣列,然後使用陣列的 toString
        return Array.from(arrayLike).toString()
    }
}

相關文章