前端学习 数据结构与算法 快速入门 系列 —— 栈

其他章节请看:

前端学习 数据结构与算法 快速入门 系列

前面,我们学习了如何创建和使用计算机科学中最常用的数据结构——数组

我们知道可以在数组的任意位置添加或删除元素,但有时我们还需要一种能在添加和删除元素时有更多控制的数据结构。有两种类似数组的数据结构在添加和删除时有更多控制,它们就是队列

栈数据结构

栈是一种遵循后进先出(或先进后出)原则的有序集合。新添加的元素或待删除的元素在栈的一端,称作栈顶,另一端叫栈底。在栈里,新元素都靠近栈顶,旧元素都接近栈底。

现实生活中有很多栈的例子,比如桌上的一摞书或一叠盘子

栈也被用于浏览器历史记录,即浏览器的返回按钮

创建一个基于数组的栈

定义一个类来表示栈:

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()
    }
}

其他章节请看:

前端学习 数据结构与算法 快速入门 系列

posted @ 2021-08-02 15:05  彭加李  阅读(443)  评论(0编辑  收藏  举报