一、概述
概念
栈是一种遵循先入后出原则的数据结构,我们只能在栈顶添加或删除元素。而数组和链表都可以在任意位置添加和删除元素,因此栈可以视为一种受限制的数组或链表。
- 基于数组实现
/* 基于数组实现的栈 */
class ArrayStack {
#stack;
constructor() {
this.#stack = [];
}
/* 获取栈的长度 */
get size() {
return this.#stack.length;
}
/* 判断栈是否为空 */
isEmpty() {
return this.#stack.length === 0;
}
/* 入栈 */
push(num) {
this.#stack.push(num);
}
/* 出栈 */
pop() {
if (this.isEmpty()) throw new Error('栈为空');
return this.#stack.pop();
}
/* 访问栈顶元素 */
top() {
if (this.isEmpty()) throw new Error('栈为空');
return this.#stack[this.#stack.length - 1];
}
/* 返回 Array */
toArray() {
return this.#stack;
}
}
- 基于链表实现
/* 基于链表实现的栈 */
class LinkedListStack {
#stackPeek; // 将头节点作为栈顶
#stkSize = 0; // 栈的长度
constructor() {
this.#stackPeek = null;
}
/* 获取栈的长度 */
get size() {
return this.#stkSize;
}
/* 判断栈是否为空 */
isEmpty() {
return this.size === 0;
}
/* 入栈 */
push(num) {
const node = new ListNode(num);
node.next = this.#stackPeek;
this.#stackPeek = node;
this.#stkSize++;
}
/* 出栈 */
pop() {
const num = this.peek();
this.#stackPeek = this.#stackPeek.next;
this.#stkSize--;
return num;
}
/* 访问栈顶元素 */
peek() {
if (!this.#stackPeek) throw new Error('栈为空');
return this.#stackPeek.val;
}
/* 将链表转化为 Array 并返回 */
toArray() {
let node = this.#stackPeek;
const res = new Array(this.size);
for (let i = res.length - 1; i >= 0; i--) {
res[i] = node.val;
node = node.next;
}
return res;
}
}
适应场景
- 括号匹配:栈可用于检查表达式中的括号是否匹配。遍历表达式时,当遇到左括号时,将其压入栈中;当遇到右括号时,检查栈顶元素是否为对应的左括号,若匹配则出栈,否则表达式非法。
- 逆波兰表达式计算:逆波兰表达式是一种后缀表达式,栈可用于计算逆波兰表达式。遍历表达式时,将数字压入栈中,遇到运算符时从栈中弹出操作数进行运算,将结果压回栈中,直到遍历完整个表达式。
- 函数调用:在编程语言中,函数调用时会将函数的返回地址以及参数等信息压入栈中,当函数执行完毕后,再从栈中弹出返回地址继续执行。
- 深度优先搜索(DFS):在图的深度优先搜索中,栈可用于保存当前节点的邻居节点,从而实现深度优先的遍历。
- 递归:递归函数的调用过程也是通过栈实现的。每次递归调用时,函数的参数和局部变量都会被压入栈中,直到递归结束后逐层出栈返回。
- 迷宫问题:在迷宫问题中,可以使用栈保存路径,每次向下一步移动时,将当前位置入栈,当无法继续前进时,回溯到上一个位置。
- 表达式求值:中缀表达式转换为后缀表达式时,可以利用栈来调整运算符的顺序,从而实现对表达式的求值。
- 语法分析:在编译器设计中,栈可用于语法分析,例如LL(1)分析法、LR分析法等,以解析和理解程序的语法结构。
- 浏览器中的后退与前进、软件中的撤销与反撤销。每当我们打开新的网页,浏览器就会对上一个网页执行入栈,这样我们就可以通过后退操作回到上一个网页。后退操作实际上是在执行出栈。如果要同时支持后退和前进,那么需要两个栈来配合实现。
- 程序内存管理。每次调用函数时,系统都会在栈顶添加一个栈帧,用于记录函数的上下文信息。在递归函数中,向下递推阶段会不断执行入栈操作,而向上回溯阶段则会不断执行出栈操作。
优点
- 简单易用:栈的操作非常简单,基本操作主要包括入栈(push)、出栈(pop)、查看栈顶元素(peek)以及检查栈是否为空(isEmpty)。这些操作都很直观,易于理解和实现。
- 后进先出(LIFO)原则:栈遵循后进先出的原则,这对于解决某些问题非常有用,如在执行函数调用时管理返回地址、实现撤销(Undo)操作等。
- 时间复杂度低:对于栈的基本操作,如push和pop,时间复杂度为O(1),即它们的执行时间不依赖于栈中的元素数量,这使得栈在性能方面非常高效。
- 辅助解决复杂问题:栈可以帮助解决一些看似复杂的问题,比如算术表达式的求值、括号匹配问题、页面的前进和后退功能等。它可以作为暂存数据的结构,以便在需要时能够快速访问最后一个存储的元素。
- 有助于管理数据:在递归调用、深度优先搜索等算法中,栈可以存储临时信息,帮助管理和恢复状态,这对于理解和解决问题非常有帮助。
- 空间效率:使用栈可以减少程序中对全局变量的需求。通过栈,可以在程序的不同部分传递信息,而不需要额外的外部存储空间。
- 可用于语言解析:在编译器设计中,栈被用来解析语言的语法结构,特别是在处理递归定义的语言结构时。
- 易于实现:栈可以通过数组或链表等基本数据结构简单地实现,这使得栈不仅在理论上有用,而且在实际的软件开发中也非常实用。
二、刷题
有效的括号
思路:历字符串,当遇到左括号时,将其对应的右括号压入栈中,当遇到右括号时,从栈顶弹出一个括号进行匹配,若匹配成功则继续,否则返回 false。
时间复杂度:O(n),其中 n 是字符串的长度,因为需要遍历整个字符串。
空间复杂度:O(n),最坏情况下栈中需要存储所有的左括号。
/**
* @param {string} s
* @return {boolean}
*/
var isValid = function(s) {
const stack = []
for(let item of s){
if(item === '('){
stack.push(')')
}else if(item === "["){
stack.push(']')
}else if(item === '{'){
stack.push('}')
}
else{
if(stack.pop() !== item) return false
}
}
return stack.length === 0
};
最小栈
思路:使用两个栈,一个栈用来存储元素,另一个栈用来存储当前栈中的最小值。在入栈操作时,除了将元素压入存储元素的栈中,还需要将当前元素与当前最小值进行比较,将较小的值压入存储最小值的栈中。出栈操作时,两个栈同时出栈。获取栈顶元素时直接返回存储元素的栈的栈顶元素,获取最小值时直接返回存储最小值的栈的栈顶元素。
var MinStack = function() {
this.stack = []
this.min = [Infinity]
};
/**
* @param {number} val
* @return {void}
*/
MinStack.prototype.push = function(val) {
this.stack.push(val)
this.min.push(Math.min(val, this.min[this.min.length - 1]))
};
/**
* @return {void}
*/
MinStack.prototype.pop = function() {
this.stack.pop()
this.min.pop()
};
/**
* @return {number}
*/
MinStack.prototype.top = function() {
return this.stack[this.stack.length - 1]
};
/**
* @return {number}
*/
MinStack.prototype.getMin = function() {
return this.min[this.min.length - 1]
};
/**
* Your MinStack object will be instantiated and called as such:
* var obj = new MinStack()
* obj.push(val)
* obj.pop()
* var param_3 = obj.top()
* var param_4 = obj.getMin()
*/
字符串解码
思路:使用两个栈分别存储数字和字符串,遍历输入的字符串,当遇到数字字符时,将数字字符转换为数字并累加,直到遇到非数字字符;当遇到左括号时,将累积的数字压入数字栈中,并将当前的结果字符串压入字符串栈中,然后重置累加的数字和结果字符串;当遇到右括号时,从数字栈中弹出一个数字作为重复次数,从字符串栈中弹出一个字符串作为需要重复的内容,将其重复相应次数后与当前结果字符串拼接;当遇到字母字符时,将其添加到当前结果字符串中。
时间复杂度:由于只需遍历一次输入字符串,因此时间复杂度为 O(n),其中 n 为输入字符串的长度。
空间复杂度:由两个栈的空间和结果字符串的空间构成,因此空间复杂度为 O(n)。
/**
* @param {string} s
* @return {string}
*/
var decodeString = function(s) {
const numStack = []
const strStack = []
let num = 0
let res = ''
for(const item of s){
// 是否为字符串的数字
if(!isNaN(item)){
num = 10 * num + Number(item)
}else if(item === '['){
numStack.push(num)
num = 0
strStack.push(res)
res = ''
}else if(item === ']'){
const currentNum = numStack.pop()
res = strStack.pop() + res.repeat(currentNum)
}else{
res += item
}
}
return res
};
每日温度
思路:因为我们最后要的是一个相对位置的举例数组,所有构造一个包含每一个索引值的栈,和一个初始值都为0的res数组,遍历每个元素,使用循环处理当前栈内最后一个index值代表的数小于此时温度的情况(即遇到比栈内存储的温度大的当前温度),符合要求即提取最后一个index值并更新这个索引对应的距离,这样下来,没有更新的index值位置便是没有比其更大温度的了。
时间复杂度:O(N),其中N是温度数组的长度。尽管内部有一个while循环看起来像是嵌套循环,但每个元素最多被压入栈和弹出栈一次,因此总的时间复杂度是线性的。
空间复杂度:O(N),在最坏的情况下,栈内可能需要存储整个温度数组的索引(比如温度单调递减),因此空间复杂度也是线性的。
/**
* @param {number[]} temperatures
* @return {number[]}
*/
var dailyTemperatures = function(temperatures) {
const len = temperatures.length
const res = new Array(len).fill(0)
const stack = []

for(let i = 0; i < len; i++){
while(stack.length && temperatures[i] > temperatures[stack[stack.length - 1]]){
const index = stack.pop()
res[index] = i - index
}
stack.push(i)
}
return res
};
柱状图中最大的矩形
思路:单调栈,核心是需要找到每个柱子左右两边第一个比它矮的柱子,确定以该柱子为高度的矩形的最大宽度。为保证最后一个柱子也能够被处理,需在数组末尾添加高度为0的柱子。要点有两个:
- 其一是单调栈条件,while语句里的内容,单调栈通过维护一个单调递增的索引序列,使得每当遇到一个破坏单调性的元素时(即当前元素小于栈顶元素),我们就可以确定栈顶元素的右边界,并据此计算以栈顶元素为高的矩形的最大面积。
- 其二便是宽度的处理, 如果弹出栈顶元素后栈变为空,意味着当前考虑的柱子左边没有比它低的柱子,即它是到目前为止遇到的最低的柱子。因此,它的宽度可以扩展到最左边,即i(当前柱子的索引)。如果栈不为空,说明当前柱子的左边界是栈顶元素对应的柱子的索引。因此,当前柱子的宽度是当前柱子的索引i减去栈顶元素索引,再减去1(因为要排除掉栈顶元素对应的柱子本身)。
时间复杂度:O(N),其中N是柱状图中柱子的数量。每个柱子被压入和弹出栈各一次。
空间复杂度:O(N),最坏的情况下,栈中可能会包含所有柱子的索引。
/**
* @param {number[]} heights
* @return {number}
*/
var largestRectangleArea = function (heights) {
let maxArea = 0
const stack = [] // 用于存放索引
heights.push(0) // 在数组末尾添加高度为0的柱子,以便能够处理最后一个柱子
for(let i = 0; i < heights.length; i++){
// 维持单调递增栈
while(stack.length > 0 && heights[i] < heights[stack[stack.length - 1]]){
const height = heights[stack.pop()]
// i是右边界(即当前柱子的索引),stack[stack.length - 1]是左边界(即栈顶元素对应的柱子的索引)
// 之所以要减1,是因为我们需要的是两个边界之间的距离,而不是包括边界本身在内的距离
const width = stack.length === 0 ? i : i - stack[stack.length - 1] - 1
maxArea = Math.max(maxArea, width * height)
}
stack.push(i)
}
return maxArea
};