栈数据结构
栈是一种遵从后进先出(LIFO)原则的有序集合。新添加或待删除的元素都保存在栈的同一端,称作栈顶,另一端就叫栈底。在栈里,新元素都靠近栈顶,旧元素都接近栈底。一个非常贴切的例子就是一摞叠在一起的盘子。放盘子的时候,都是从下往上一个一个放;取的时候,也是从上往下一个一个地依次取,不能从中间任意抽出。
栈的基本操作:入栈和出栈,只允许在一端插入和删除数据,栈是一种“操作受限”的线性表数据结构。
从功能上来说,数组或链表确实可以替代栈,但是栈这种特定的数据结构针对特定的场景:当某个数据集合只涉及在一端插入和删除数据,并且满足后进先出、先进后出的特性,就应该首选“栈”这种数据结构。
JS实现一个“栈”
栈既可以用数组来实现,也可以用链表来实现。用数组实现的栈叫作顺序栈,用链表实现的栈叫作链式栈。
数组实现顺序栈
通过push和pop方法,就能用数组来模拟栈;
注意:如果入栈是放在数组最后,那么出栈也是先取数组最后的;如果入栈是放在数组最前,那么出栈也要取数组最前的;必须要在栈的一端进行操作
// 定义栈类(纯粹是用数组的属性和方法来模拟栈)class StackArray {constructor() {this.items = []}// 入栈push(item) {this.items.push(item) // 放在最后}// 出栈(移除栈顶元素,同时返回被删除的元素)pop() {return this.items.pop();}// 返回栈顶元素值,不对栈做任何修改peek() {return this.items[this.items.length - 1];}// 判断栈是否为空isEmpty() {return this.items.length === 0}// 返回给定对象的位置search(item) {let index = this.items.lastIndexOf(item)if (index >= 0) {return this.size() - index} else {return -1}}// 获取栈元素个数size() {return this.items.length}// 清空栈clear() {this.items = []}toString() {return this.items.toString();}}// 测试const stack = new StackArray()console.log(stack.isEmpty()) // truestack.push('nodeA')stack.push('nodeB')stack.push('nodeC')while (!stack.isEmpty()) {console.log(stack)stack.pop()}// Stack { items: [ 'nodeA', 'nodeB', 'nodeC' ] }// Stack { items: [ 'nodeA', 'nodeB' ] }// Stack { items: [ 'nodeA' ] }
利用数组实现的栈,内部的数组items并不是私有属性,不受保护,可以利用WeakMap来实现私有属性:
const items = new WeakMap() // 声明一个WeakMap类型的变量itemsclass Stack {constructor() {items.set(this, []) // 以this(Stack类自己的引用)为键,把代表栈的数组存入items// ⭐items在Stack类里是真正的私有属性。采用这种方法,代码的可读性不强,而且在扩展该类时无法继承私有属性}push(element) {const s = items.get(this) // 从WeakMap中取出值,即以this为键从items中取值。s.push(element)}pop() {const s = items.get(this)const r = s.pop()return r}// 其他方法}
对象实现栈
class Stack {constructor() {this.count = 0 // 记录栈的大小this.items = {}}// 数组实现的栈可以一次添加多个元素。但是对象,只允许一次插入一个元素push(element) {this.items[this.count] = element // 放在最后this.count++ // 插入元素后,递增count变量}// 对象,通过delete来删除pop() {if (this.isEmpty()) {return undefined}this.count--const result = this.items[this.count]delete this.items[this.count] // 删除最后return result}peek() {if (this.isEmpty()) {return undefined}return this.items[this.count - 1] // 获取栈顶元素}isEmpty() {return this.count === 0}size() {return this.count}clear() {/* while (!this.isEmpty()) {this.pop();} */this.items = {}this.count = 0}toString() {if (this.isEmpty()) {return ''}let objString = `${this.items[0]}`for (let i = 1; i < this.count; i++) {objString = `${objString},${this.items[i]}`}return objString}}// 测试const stack = new Stack()stack.push(5)stack.push(8)console.log(stack) // Stack { count: 2, items: { '0': 5, '1': 8 } }
栈操作的复杂度
空间复杂度:不管是顺序栈还是链式栈,存储数据只需要一个大小为 n 的数组就够了。在入栈和出栈过程中,只需要一两个临时变量存储空间,所以空间复杂度是 O(1)。注意:存储数据需要一个大小为 n 的数组,并不是说空间复杂度就是 O(n)。因为,这 n 个空间是必须的,无法省掉。所以说空间复杂度的时候,是指除了原本的数据存储空间外,算法运行还需要额外的存储空间。
时间复杂度:不管是顺序栈还是链式栈,入栈、出栈只涉及栈顶数据的操作,所以时间复杂度都是 O(1)。
栈的应用
浏览器前进后退
使用两个栈X 和 Y,把首次浏览的页面依次压入栈 X,当点击后退按钮时,再依次从栈 X 中出栈,并将出栈的数据依次放入栈 Y。当点击前进按钮时,依次从栈 Y 中取出数据,放入栈 X 中。当栈 X 中没有数据时,那就说明没有页面可以继续后退浏览了。当栈 Y 中没有数据,那就说明没有页面可以点击前进按钮浏览了。比如顺序查看了 a,b,c 三个页面,就依次把 a,b,c 压入栈,此时两个栈的数据如下图:
当点击后退按钮,从页面 c 后退到页面 a 之后,就依次把 c 和 b 从栈 X 中弹出,并且依次放入到栈 Y:
这时再点击前进按钮回到 b 页面,就把 b 再从栈 Y 中出栈,放入栈 X 中:
这时如果从页面 b 又跳转到新的页面 d 了,页面 c 就无法再通过前进、后退按钮重复查看了,所以需要清空栈 Y。此时两个栈的数据这个样子:
函数调用栈
操作系统给每个线程分配了一块独立的内存空间,这块内存被组织成“栈”这种结构, 用来存储函数调用时的临时变量。每进入一个函数,就会将临时变量作为一个栈帧压入栈,当被调用函数执行完成之后,将这个函数对应的栈帧出栈。
int main() {
int a = 1;
int ret = 0;
int res = 0;
ret = add(3, 5);
res = a + ret;
printf("%d", res);
reuturn 0;
}
int add(int x, int y) {
int sum = 0;
sum = x + y;
return sum;
}
表达式求值
实现一个四则运算表达式求值的功能:3+5*8-6
编译器就是通过两个栈来实现的。其中一个保存操作数的栈,另一个是保存运算符的栈。从左向右遍历表达式,当遇到数字,直接压入操作数栈;当遇到运算符,就与运算符栈的栈顶元素进行比较。
如果比运算符栈顶元素的优先级高,就将当前运算符压入栈;如果比运算符栈顶元素的优先级低或者相同,从运算符栈中取栈顶运算符,从操作数栈的栈顶取 2 个操作数,然后进行计算,再把计算完的结果压入操作数栈,继续比较。
括号匹配
场景:假设表达式中只包含三种括号,圆括号 ()、方括号 [ ] 和花括号{ },并且它们可以任意嵌套。比如,{ [ { } ] }或 [ { ( ) } ( [ ] ) ] 等都为合法格式,而{ [ } ( ) ] 或 [ ( { ) ] 为不合法的格式。那如何检查它是否合法?
解决:可以用栈来保存未匹配的左括号,从左到右依次扫描字符串。当扫描到左括号时,则将其压入栈中;当扫描到右括号时,从栈顶取出一个左括号。如果能够匹配,比如“(”跟“)”匹配,“[”跟“]”匹配,“{”跟“}”匹配,则继续扫描剩下的字符串。如果扫描的过程中,遇到不能配对的右括号,或者栈中没有数据,则说明为非法格式。
当所有的括号都扫描完成之后,如果栈为空,则说明字符串为合法格式;否则,说明有未匹配的左括号,为非法格式。
十进制转换其它进制
要把十进制转化成二进制,可以将该十进制数除以2(二进制是满二进一)并对商取整,直到结果是0为止
function decimalToBinary(decNumber) {
const remStack = new Stack()
let number = decNumber
let rem
let binaryString = ''
while (number > 0) { // 先循环将余数推入栈中
rem = Math.floor(number % 2)
remStack.push(rem)
number = Math.floor(number / 2)
}
while (!remStack.isEmpty()) {
binaryString += remStack.pop().toString()
}
return binaryString
}
// 测试
console.log(decimalToBinary(233)) // 11101001
function baseConverter(decNumber, base) {
const remStack = new Stack()
const digits = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ' // 三十六进制以内转换表,从十一进制开始,字母表中的每个字母将表示相应的基数
let number = decNumber
let rem
let baseString = ''
if (!(base >= 2 && base <= 36)) {
return ''
}
while (number > 0) {
rem = Math.floor(number % base)
remStack.push(rem)
number = Math.floor(number / base)
}
while (!remStack.isEmpty()) {
baseString += digits[remStack.pop()] // 弹出表中对应的基数,主要是针对十一进制以上要用字母表示
}
return baseString
}
// 测试
console.log(baseConverter(100345, 8)) // 303771
console.log(baseConverter(100345, 16)) // 187F9
