大家好,我是「柒八九」。
今天,我们继续探索JS算法相关的知识点。我们来谈谈关于栈Stack的相关知识点和具体的算法。
如果,想了解其他数据结构的算法介绍,可以参考我们已经发布的文章。如下是算法系列的往期文章。
好了,天不早了,干点正事哇。
栈是一种遵从「后进先出」(LIFO
)原则的「有序集合」。新添加或待删除的元素都保存在栈的「同一端」,称作「栈顶」,另一端就叫「栈底」。在栈里,「新元素都靠近栈顶,旧元素都接近栈底」。
入栈
出栈
栈也被用在编程语言的编译器和内存中保存变量、方法调用等,也被用于浏览器历史记录(浏览器的返回按钮)。
而在前端,Stack
耳熟能详的功能就是「调用栈」,调用栈就是用来「管理函数调用关系」的一种数据结构,是 JavaScript
引擎追踪函数执行的一个机制。
还有一个比较重要的用处就是在「解析器」中,无论是HTML
/Vue
/JavaScript
,在生成对应的AST
的时候,针对Token
进行匹配处理。此时,就可以利用Stack
后进先出的特性,进行匹配处理。
「解析HTML生成的AST」
「解析Vue模板生成的AST」
关于调用栈的详细介绍,可以翻阅我们之前文章。这里就不在赘述。
在一些题目中,数据「保存的顺序」和「使用顺序」相反,即最后保存的数据最先使用,这与栈的后进先出特性契合,可以将数据保存到栈中。
由于JS语言的特殊性,不存在真正意义上的Stack
结构,一般使用数组特定的Api
(push/pop
)模拟最简单的stack
使得能够满足「后进先出」的特性。
let stack = [];
stack.push(1);
stack.push(2);
==== 入栈 1、2====
stack.pop() // 2出栈
stack.pop() // 1出栈
在一些简单的场景下,利用数组来模拟栈是可以满足条件的。但是作为一个功能完备的数据结构,还有一些其他的功能,使用上述的实现方式显的有点捉襟见肘。
那么,我们就自己实现一个比较功能完备的stack
。它有如下的功能点
push(element(s))
:添加一个(或几个)新元素到栈顶pop()
:移除栈顶的元素,同时返回被移除的元素peek()
: 返回栈顶的元素,不对栈做任何修改isEmpty()
:如果栈里没有任何元素就返回true
,否则返回false
size()
: 返回栈里的元素个数clear()
: 移除栈里所有的元素class Stack {
constructor() {
this.items = [];
}
// 添加element到栈顶
push(element) {
this.items.push(element);
}
// 移除栈顶的元素,同时返回被移除的元素
pop() {
return this.items.pop();
}
// 返回栈顶的元素,不对栈做任何修改
peek() {
return this.items[this.items.length - 1];
}
// 如果栈里没有任何元素就返回`true`,否则返回`false`
isEmpty() {
return this.items.length === 0;
}
// 返回栈里的元素个数
size() {
return this.items.length;
}
// 移除栈里所有的元素
clear() {
this.items = [];
}
}
虽然,我们实现了一个功能完备的stack
结构,但是仔细一看,其实就是对array
中push/pop
等api
进行了一次包装。但是,经过包装后,使得针对stack
结构的各种操作,变得更具有封装性,而不会产生很多样板代码。
题目描述:
❝后缀表达式是一种算术表达式,也叫「逆波兰式」(
RPN
),它的操作符在操作数的后面。 要求输入一个用字符串数组表示的后缀表达式,请输出该后缀表达式的计算结果。 示例:后缀表达式["2","1","3","*","+"]
对应的表达式是2 + 1 * 3
,因此输出的计算结果为5
❞
["2","1","3","*","+"]
为例子分析。2
,由于后缀表达式的特点,「操作符」还在后面,在操作符未知的情况下,是无法进行计算处理。所以,需要将当前的操作数进行「暂存处理」。1/3
)还是「没有操作符的出现」,继续将对应的操作数进行「暂存处理」*
)。按照后缀表达式的规则,此操作符对应的操作数是「刚刚」被暂存的「一对」操作数1/3
1/3
明显位于容器的尾部。也就是说,需要从容器的尾部将「一对」数据取出,并做运算处理。stack
来作为存储操作数的容器function evalRPN(tokens){
let stack = new Stack();
for(let token of tokens){
switch(token){
// 处理操作符
case "+":
case "-":
case "*":
case "/":
// 在源数据中,靠后的操作数
let back = stack.pop();
// 在源数据中,靠前的操作数
let prev = stack.pop();
// 计算操作数,并将其入栈处理
stack.push(
calculate(prev,back,token)
);
break;
default:
// 处理操作数,直接入栈
stack.push(parseInt(token));
}
}
// 操作符都处理完,且栈中只有一个数据
return stack.pop();
}
「辅助函数」,用于处理两个操作数之间的算术问题。(有一点需要注意,就是操作数之间的顺序问题)
fucntion calculate(prev,back,operator){
switch(operator){
case "+":
return prev + back;
case "-":
return prev - back;
case "*":
return prev * back;
case "/":
return (prev/back)>>0; // 数据取整
default:
return 0;
}
}
❝输入一个表示小行星的数组
[4,5,-6,4,8,-5]
,它们相撞之后最终剩下3颗小行星[-6,4,8]
❞
[4,5,-6,4,8,-5]
4/5
,而在遇到「方向不同」的行星时,是率先取「最近一次」加入到数据容器的数据。也就是说,针对数据容器中的数据的存取,满足「后进先出」的规则。我们可以考虑用栈来具象化该数据结构。stack
)battle
一下,哪怕身败名裂function asteroidCollision(asteroids){
let stack = new Stack();
for(let as of asteroids){
// 当前元素向左飞行,并且该元素的绝对值比栈顶元素大
while(!stack.empty()
&& stack.peek()>0
&& stack.peek()<-as
){
stack.pop();
}
// 当前元素向左飞行,当前元素和栈顶元素体积一样 (需要互相抵消)
if(stack.length
&& as<0
&& stack.peek()==-as
){
stack.pop();
}else if(
as >0 //当前元素向右飞行
|| stack.empty() // 栈为空 (初始化)
// 当前元素向左飞行(在经过对比后,还是无法消除)
|| stack.peek()<0
){
stack.push(as)
}
}
return stack;
}
上述的代码中,我们使用了Stack
中的一些方法。
❝给定一个只包括
'(',')'
,'{','}'
,'[',']'
的字符串 s ,判断字符串是否有效。有效字符串需满足:
❞
function isValid (s) {
let stack = new Stack();
// 遍历 字符串
for(let c of s){
// 遇到左括号,将与其匹配的右括号入栈处理
if(c==='('){
stack.push(')')
}else if(c==='['){
stack.push(']')
}else if(c==='{'){
stack.push('}')
// 遇到右括号
// 1. 判断栈内是否有括号,如果没有,那说明此时匹配不了
// 2. 满足①的情况下,判断此时字符是否和栈顶元素匹配
}else if(stack.length ===0 || stack.pop()!==c){
return false;
}
}
// 最后再验证一下,栈是否为空,如果不为空,说明还有未匹配的括号
return !stack.length;
};
❝输入一个数组,每个数字都是某天的温度。 计算每天需要等几天才会出现更高的温度 示例:输入数组
[35,31,33,36,34]
,输出结果为[3,1,1,0,0]
❞
function dailyTemperatures(temperatures){
// 定义一个与源数组相同的数组,用于存储最后结果
let result = new Array(temperatures.length);
let stack = new Stack();
for(let i = 0;i<temperatures.length;i++){
// stack 非空,且当前的温度大于栈顶温度
while(!stack.empty()
&& temperatures[i]>temperatures[stack.peek()]){
// 取出,存于stack中的满足条件的温度的下标
let prev = stack.pop();
// 计算等待天数 并将其存入result[prev]中
result[prev] = i - prev;
}
// 将当前下标存入stack中
stack.push(i)
}
return result;
}
「额外提醒」
stack
中取出栈顶元素stack
中的数据,找到了它对应的「需要等待的天数」i - prev
❝输入一个由非负数组成的数组,数组中的数字是直方图中柱子的高,求直方图中最大矩形的面积 假设直方图中柱子的宽度为1 示例:输入数组
[2,1,5,6,2,3]
,直方图中最大矩形的面积为10(2*5)
❞
i
的柱子开始,到下标为j
的柱子结束,那么两根柱子之间的矩形(含两端的柱子)的宽度是j-i+1
,矩形的高度就是两根柱子之间的「所有」柱子最矮的高度i/j
:i
表示靠前的柱子下标,j
表示靠后的柱子下标function largestRectangleArea(heights){
let maxArae = 0;
for(let i=0;i<heights.length;i++){
let min = heights[i];
for(let j=i;j<heights.length;j++){
min = Math.min(min,heights[j]);
let area = min * (j -i +1);
maxArea = Math.max(maxArea,area)
}
}
return maxArea;
}
想到maxX
是不是联想到「选择排序」 (最主要的特点就是「找极值」的序号(minIndex/largestIndex
))
我们来简单的再重温一下,选择排序的大体思路。
function selectionSort(arr){
let len = arr.length;
if(len<2) return arr; // 处理边界值
let i,j,minIndex;
// 外层循环: 控制迭代轮次
for(i=0;i<len-1;i++){
minIndex = i;
// 内层循环:从内层循环中找到最小值的位置
for(j=i+1;j<len;j++){
// 在未排区域寻找最小的数,并记录其位置j
if(arr[j]<arr[minIndex]) minIndex = j;
}
// 内层循环完毕,最小值确定,和已排区间最后一位交互位置
swap(arr,i,minIndex);
}
return arr;
}
这两个算法之间有很多相似的地方
由于采用了双层循环,所以该方法的时间复杂度为O(n²)
,不够优雅。我们可以采用更加优雅的处理方式。
function largestRectangleArea(heights){
let stack = new Stack();
stack.push(-1);
let maxArea = 0;
for(let i =0;i<heights.length;i++){
// 一边遍历,一边计算,当前高度,比栈顶高度小的数据
// 此时求的是以栈顶元素为高的面积
// 直到当前元素比栈顶元素都小时,才退出
while(stack.peek()!=-1
&& heights[stack.peek()] >= heights[i]){
let height = heights[stack.pop()];
let width = i - stack.peek() -1;
maxArea = Math.max(maxArea,height * width)
}
// 此时当前元素高度,比栈顶元素高,入栈处理
stack.push(i);
}
// 在处理完后,栈中还存在元素
// 这元素在后续的遍历中没找到比它矮的,所以,还需要进行相同操作
while(stack.peek()!=-1){
let height = heights[stack.pop()];
let width = heights.length - stack.peek() -1;
maxArea = Math.max(maxArea,height * width)
}
return maxArea;
}
第一次遍历
针对剩余栈内元素求面积
「分享是一种态度」。
参考资料:剑指offer/leetcode官网