在LeetCode刷题中,栈是一个常用的数据结构,可以帮助解决很多问题。以下是一些需要使用栈的方法,以及单调栈的应用场景:
栈的使用技巧:
- 栈常用于解决与括号匹配相关的问题,如括号序列的有效性、最长有效括号等。
- 栈也常用于解决逆波兰表达式、表达式求值等与计算相关的问题。
- 栈可以用于解决深度优先搜索(DFS)中的回溯问题,如组合、排列等。
- 栈还可以用于解决某些需要“后进先出”(LIFO)特性的问题,如某些遍历问题、消除重复元素等。
判断题目需要使用栈的方法:
- 当题目涉及到括号匹配、表达式求值、逆波兰表达式等与栈相关的特性时,很可能需要使用栈。
- 当题目需要“后进先出”(LIFO)的特性时,也可能需要使用栈。
- 如果题目涉及到深度优先搜索(DFS)中的回溯操作,栈也可能是一个有用的工具。
单调栈的应用场景:
- 单调栈是指栈中元素保持单调递增或单调递减的栈结构。
- 单调栈常用于解决找到下一个更大元素、下一个更小元素等问题。
- 在求解某些问题时,可以通过维护一个单调递增栈或单调递减栈来简化问题的处理过程,提高效率。
JavaScript模拟栈的技巧和方法
栈的主要思想是“后进先出”(Last In First Out,LIFO),即最后进入栈的元素最先出栈。栈通常用于解决需要“回溯”或“撤销”操作的问题,以及需要维护“最近相关性”的问题。
对于新手刷题,以下是一些建议:
- 掌握基本操作:首先要熟练掌握栈的基本操作,包括入栈、出栈、获取栈顶元素等。
- 理解栈的应用场景:了解栈在解决问题中的常见应用场景,例如上面提到的几类问题。
- 练习经典问题:尝试解决一些经典的栈相关问题,例如括号匹配、逆波兰表达式求值等。
- 多思考、多实践:在解决问题时,多思考不同的解题思路,多实践不同类型的题目,逐渐提升对栈的理解和应用能力
使用数组模拟栈
JavaScript中的数组可以很方便地模拟栈的操作,如
push()
向栈顶添加元素、pop()
从栈顶移除元素。通过数组的length
属性可以获取栈的大小,通过判断数组是否为空来判断栈是否为空。
利用数组的push()
和pop()
方法
let stack = [];
stack.push(1); // 入栈
stack.push(2);
let topElement = stack.pop(); // 出栈
使用数组的length
属性判断栈是否为空
let stack = [];
if (stack.length === 0) {
console.log("栈为空");
}
使用数组的slice()
方法复制栈
let newStack = stack.slice();
LeetCode真题
20. 有效的括号
给定一个只包括 '('
,')'
,'{'
,'}'
,'['
,']'
的字符串 s
,判断字符串是否有效。
有效字符串需满足:
- 左括号必须用相同类型的右括号闭合。
- 左括号必须以正确的顺序闭合。
- 每个右括号都有一个对应的相同类型的左括号。
主要思路是遇到左括号推入栈
遇到右括号,将栈顶左括号移除,并和它进行对比。
不相等return false
否则继续比较,直到最后,判断是否还有没匹配上的左括号
刚开始刷题,先看第一个版本 ,有两个地方可以优化,一个是很明显的stask.length的判断可以改写为三元表达式。另外可以用对象key:value的特性进行括号对比。
/**
* @param {string} s
* @return {boolean}
*/
var isValid = function (s) {
const stask = [];
for (let item of s) {
if (['(', '[', '{'].includes(item)) {
stask.push(item);
} else {
const pre = stask.pop();
if ((pre === '(' && item === ')') || (pre === '[' && item === ']') || (pre === '{' && item === '}')) {
continue;
} else {
return false;
}
}
}
if (stask.length > 0) {
return false;
} else {
return true;
}
};
优化后的代码
使用了一个映射表
map
来存储左右括号的对应关系,这样可以使代码更加清晰易懂。同时,在判断右括号是否匹配时,直接与栈顶元素进行比较,避免了多次判断的情况,提高了效率。
/**
* @param {string} s
* @return {boolean}
*/
var isValid = function (s) {
const stask = [];
const map = {
'(': ')',
'[': ']',
'{': '}',
};
for (let char of s) {
if (char in map) {
stask.push(char);
} else if (char != map[stask.pop()]) {
return false;
}
}
return stask.length === 0;
};
这里有个巧妙之处,可以用in判断一个属性是不是对象的属性!!
此外一些小点,如可用用for of进行字符串的遍历
1047. 删除字符串中的所有相邻重复项
给出由小写字母组成的字符串 S
,重复项删除操作会选择两个相邻且相同的字母,并删除它们。
在 S 上反复执行重复项删除操作,直到无法继续删除。
在完成所有重复项删除操作后返回最终的字符串。答案保证唯一。
思路:使用栈来消除相邻重复的字符。当前遍历的元素和前一个栈顶元素比较,如果不等,将已经pop出的元素和当前元素入栈,继续。否则,相等,当重复的元素已经pop了,无需处理。
比较后留在栈的元素就是没有相邻重复的数组,再将数组转成字符串。使用数组的join方法。
这里相同的是对字符串操作时,使用for of进行遍历即可
/**
* @param {string} s
* @return {string}
*/
var removeDuplicates = function (s) {
let stack = [];
for (let char of s) {
const pre = stack.pop();
if ( char != pre) {
stack.push(pre);
stack.push(char);
}
}
return stack.join('');
};
1614. 括号的最大嵌套深度
这道题原文很长,其实主要就是求括号最大嵌套层数,可以不考虑中间出现的数字和运算符。
思想是用栈存储左括号,当遇到右括号的时候先看栈内左括号的个数,即当前的嵌套层数,将其保存下来。然后将栈顶左括号出栈;只要统计栈的最大长度即可得到最大嵌套深度
/**
* @param {string} s
* @return {number}
*/
var maxDepth = function (s) {
let stack = [];
let tempDep = 0;
let dep = 0;
for (let char of s) {
//char是左括号
if (char === '(') {
stack.push(char);
} else if (char === ')') {
//char是右括号
tempDep = stack.length;
dep = tempDep > dep ? tempDep : dep;
stack.pop();
}
}
return dep;
};
这样只用考虑左括号和右括号就好啦
1544. 整理字符串
给你一个由大小写英文字母组成的字符串 s
。一个整理好的字符串中,两个相邻字符 s[i]
和 s[i+1]
,其中 0<= i <= s.length-2
,要满足如下条件:
- 若
s[i]
是小写字符,则s[i+1]
不可以是相同的大写字符。 - 若
s[i]
是大写字符,则s[i+1]
不可以是相同的小写字符。
请你将字符串整理好,每次你都可以从字符串中选出满足上述条件的 两个相邻 字符并删除,直到字符串整理好为止。
请返回整理好的 字符串 。题目保证在给出的约束条件下,测试样例对应的答案是唯一的。
注意:空字符串也属于整理好的字符串,尽管其中没有任何字符。
思路:用栈保留不相邻大小写字符,每次用栈顶元素与当前元素比较,如果两个是相邻的大小写字符,则弹出栈顶元素。否则,当前元素入栈。
使用字符串的charCodeAt方法获取字符串的ASCII码;使用Math.abs返回相差的绝对值,大小写相差32
返回字符串,需要将stack数组转字符串,同样也用数组的join方法
/**
* @param {string} s
* @return {string}
*/
var makeGood = function (s) {
let stack = [];
for (let char of s) {
let stackTop = stack[stack.length - 1];
if (stackTop && Math.abs(char.charCodeAt(0) - stackTop.charCodeAt(0)) == 32) {
stack.pop();
} else {
stack.push(char);
}
}
return stack.join("");
};
682. 棒球比赛 ——逆波兰表达式求值
你现在是一场采用特殊赛制棒球比赛的记录员。这场比赛由若干回合组成,过去几回合的得分可能会影响以后几回合的得分。
比赛开始时,记录是空白的。你会得到一个记录操作的字符串列表 ops
,其中 ops[i]
是你需要记录的第 i
项操作,ops
遵循下述规则:
- 整数
x
- 表示本回合新获得分数x
"+"
- 表示本回合新获得的得分是前两次得分的总和。题目数据保证记录此操作时前面总是存在两个有效的分数。"D"
- 表示本回合新获得的得分是前一次得分的两倍。题目数据保证记录此操作时前面总是存在一个有效的分数。"C"
- 表示前一次得分无效,将其从记录中移除。题目数据保证记录此操作时前面总是存在一个有效的分数。
请你返回记录中所有得分的总和。
思路:这是一道用栈来进行求值的,当前项与前一项或前两项有关系,且已经存在的元素可以被删除。就可以考虑栈对栈顶元素的操作
/**
* @param {string[]} operations
* @return {number}
*/
const calPoints = function (operations) {
const stack = [];
for (let item of operations) {
if (item === "C") {
stack.pop();
} else if (item === "D") {
stack.push(stack[stack.length - 1] * 2);
} else if (item === "+") {
stack.push(stack[stack.length - 1] + stack[stack.length - 2]);
} else {
stack.push(Number(item));
}
}
return stack.reduce((total, num) => total + num, 0);
};
思想是用栈存储确定后的没项元素,根据当前匹配的是数字还是符号进行响应规则增加或删除项。最后用数组的reduce方法对数字求和。比较简单
150. 逆波兰表达式求值
逆波兰表达式是一种不需要括号来标识优先级的数学表达式表示方法,它通过将操作符放在操作数的后面来表示运算顺序。
给你一个字符串数组 tokens
,表示一个根据 逆波兰表示法 表示的算术表达式。
请你计算该表达式。返回一个表示表达式值的整数。
注意:
- 有效的算符为
'+'
、'-'
、'*'
和'/'
。 - 每个操作数(运算对象)都可以是一个整数或者另一个表达式。
- 两个整数之间的除法总是 向零截断 。
- 表达式中不含除零运算。
- 输入是一个根据逆波兰表示法表示的算术表达式。
- 答案及所有中间计算结果可以用 32 位 整数表示。
思路:用栈存储操作数,遇到操作符号+-*/时从栈顶依次弹出两个数,分别作为右操作数和左操作数。需要手动处理+-*/匹配时的运算。并且考虑除法向0靠拢。js的Math函数,trunc提供了这个方法。这个平时真用的挺少的。先给个初版的,后面有个进阶优化的代码
/**
* @param {string[]} tokens
* @return {number}
*/
var evalRPN = function (tokens) {
let stack = [];
const symbol = ['+', '-', '*', '/'];
for (let item of tokens) {
if (symbol.includes(item)) {
let right = stack.pop();
let left = stack.pop();
stack.push(getData(left, right, item));
} else {
stack.push(Number(item));
}
}
return stack[0];
};
function getData(left, right, operate) {
switch (operate) {
case '+':
return left + right;
case '-':
return left - right;
case '*':
return left * right;
case '/':
return Math.trunc(left / right);
}
}
这里比较操作符用数组includes方法减少了比较,那如何更加匹配时的简化运算逻辑呢
考虑使用ES6提供的Map对象,key值存放符合,value值存放一个函数,接收两个操作数。
通过Map对象的has方法判断key值是否存在,通过get方法获取函数。parseInt和Number都可以转换数字,这里只是提供了一种方法
var evalRPN = function (tokens) {
const stack = [];
const operators = new Map([
['+', (a, b) => a + b],
['-', (a, b) => a - b],
['*', (a, b) => a * b],
['/', (a, b) => Math.trunc(a / b)]
]);
for (let token of tokens) {
if (operators.has(token)) {
let right = stack.pop();
let left = stack.pop();
let result = operators.get(token)(left, right);
stack.push(result);
} else {
stack.push(parseInt(token));
}
}
return stack[0];
};
394. 字符串解码
给定一个经过编码的字符串,返回它解码后的字符串。
编码规则为: k[encoded_string]
,表示其中方括号内部的 encoded_string
正好重复 k
次。注意 k
保证为正整数。
你可以认为输入字符串总是有效的;输入字符串中没有额外的空格,且输入的方括号总是符合格式要求的。
此外,你可以认为原始数据不包含数字,所有的数字只表示重复的次数 k
,例如不会出现像 3a
或 2[4]
的输入。
思路:用栈解决问题就是很烧脑。栈暂存还没发处理的数据。
这里需要放入栈的数字和字符串提前统计好。在遇到左括号时入栈。
遇到右括号时处理,弹出栈内的前两个值,数字和字符串,注意入栈和出栈顺序相反。遇到右括号时,当前肯定有正在统计的子串res。弹出的数字就是子串要重复的次数,通过repeat方法重复子串,弹出的字符串是之前处理结果preRes,将res+preRes更新res。
结束当前右括号逻辑,在遇到下一个左括号前,继续拼接res和数字。重复逻辑。
不管左括号和右括号怎么嵌入的,在遇到每个右括号前,栈内都有它可处理的最近一次的左括号信息。
/**
* @param {string} s
* @return {string}
*/
var decodeString = function (s) {
let stack = []; // 创建一个栈,用于保存遇到的字符
let count = 0; // 用于保存重复次数
let result = ''; // 用于保存解码后的结果
for (let char of s) {
if (char >= '0' && char <= '9') { // 如果是数字字符
count = count * 10 + parseInt(char); // 将数字字符转换为整数
} else if (char === '[') { // 如果是左括号
stack.push(result); // 将当前结果和重复次数压入栈中
stack.push(count);
result = ''; // 重置当前结果和重复次数
count = 0;
} else if (char === ']') { // 如果是右括号
let prevCount = stack.pop(); // 弹出上一次的重复次数
let prevResult = stack.pop(); // 弹出上一次的结果
result = prevResult + result.repeat(prevCount); // 根据重复次数重复上一次的结果,并与上一次结果拼接
} else { // 如果是字母字符
result += char; // 将字母字符加入到结果中
}
}
return result; // 返回解码后的结果
}
844. 比较含退格的字符串
给定 s
和 t
两个字符串,当它们分别被输入到空白的文本编辑器后,如果两者相等,返回 true
。#
代表退格字符。
注意:如果对空文本输入退格字符,文本继续为空。
/**
* @param {string} s
* @param {string} t
* @return {boolean}
*/
var backspaceCompare = function (s, t) {
return getStr(s) === getStr(t);
};
function getStr(str) {
let stack = [];
for (let char of str) {
if (char != '#') {
stack.push(char);
} else {
stack.pop();
}
}
return stack.join('');
}
思路:用栈保存s或t最终的结果,在比较两者是否相等。
不等于#时push进,为#时将栈顶元素弹出。
比较简单
2696. 删除子串后的字符串最小长度
给你一个仅由 大写 英文字符组成的字符串 s
。你可以对此字符串执行一些操作,在每一步操作中,你可以从 s
中删除 任一个 "AB"
或 "CD"
子字符串。通过执行操作,删除所有 "AB"
和 "CD"
子串,返回可获得的最终字符串的 最小 可能长度。注意,删除子串后,重新连接出的字符串可能会产生新的 "AB"
或 "CD"
子串。
/**
* @param {string} s
* @return {number}
*/
var minLength = function (s) {
const map = {
'A': 'B',
'C': 'D'
}
let stack = [];
for (let char of s) {
if (stack.length > 0 && char === map[stack[stack.length - 1]]) {
stack.pop();
} else {
stack.push(char);
}
}
return stack.length;
};
也是一道典型的字符匹配题目,用栈加map存储键值对比较真的无敌了。
不啰嗦了,这里返回的是数组长度注意一下就行
71. 简化路径
给你一个字符串 path
,表示指向某一文件或目录的 Unix 风格 绝对路径 (以 '/'
开头),请你将其转化为更加简洁的规范路径。
在 Unix 风格的文件系统中,一个点(.
)表示当前目录本身;此外,两个点 (..
) 表示将目录切换到上一级(指向父目录);两者都可以是复杂相对路径的组成部分。任意多个连续的斜杠(即,'//'
)都被视为单个斜杠 '/'
。 对于此问题,任何其他格式的点(例如,'...'
)均被视为文件/目录名称。
请注意,返回的 规范路径 必须遵循下述格式:
- 始终以斜杠
'/'
开头。 - 两个目录名之间必须只有一个斜杠
'/'
。 - 最后一个目录名(如果存在)不能 以
'/'
结尾。 - 此外,路径仅包含从根目录到目标文件或目录的路径上的目录(即,不含
'.'
或'..'
)。
返回简化后得到的 规范路径 。
终结题目的要求:/不能重复;..表示回退一步;.表示在当前;这道题的难点是对/的处理。要是按/进行分隔呢,只保留文件名和操作运算符。看下代码怎么实现吧
/**
* @param {string} path
* @return {string}
*/
var simplifyPath = function (path) {
let stack = [];
const pathArr = path.split('/');
pathArr.forEach(item => {
if (item === '..') {
stack.pop();
} else if (item && item != '.') {
stack.push(item);
}
})
return stack.length ? '/' + stack.join('/') : '/';
};
思路:按照/将字符分开为数组。然后用栈存储当前经过的路径上的文件夹名称。
遇到..栈顶弹出,遇到.不处理。遇到文件名,push进栈
最后将文件名按/拼接,注意以/开头
144. 二叉树的前序遍历
(1)先(根)序遍历(根左右)
不管是前、中、后序遍历,总的来说都要从根元素开始,把左子树遍历一遍在把右子树遍历一遍,只不过过程中打印节点顺序不同而有所差异。栈的思想是当前元素遍历到了,但是它的左子树或右子树没遍历完,需要入栈,当左子树或右子数遍历了即可出栈
/**
* Definition for a binary tree node.
* function TreeNode(val, left, right) {
* this.val = (val===undefined ? 0 : val)
* this.left = (left===undefined ? null : left)
* this.right = (right===undefined ? null : right)
* }
*/
/**
* @param {TreeNode} root
* @return {number[]}
*/
var preorderTraversal = function (root) {
let arr = [];
let stack = [];
let current = root;
while (current != null || stack.length > 0) {
while (current!= null) {
arr.push(current.val);
stack.push(current);
current = current.left;
}
current = stack.pop();
current = current.right;
}
return arr;
};
arr存放遍历后的数据,优先入栈跟节点,其次左节点,最后右节点。每次循环仅得到一个节点,切记不多写,容易混乱
94. 二叉树的中序遍历
中(根)序遍历(左根右)
/**
* Definition for a binary tree node.
* function TreeNode(val, left, right) {
* this.val = (val===undefined ? 0 : val)
* this.left = (left===undefined ? null : left)
* this.right = (right===undefined ? null : right)
* }
*/
/**
* @param {TreeNode} root
* @return {number[]}
*/
var inorderTraversal = function (root) {
const arr = [];
const stack = [];
let current = root;
while (current != null || stack.length > 0) {
while (current != null) {
stack.push(current);
current = current.left;
}
current = stack.pop();
arr.push(current.val);
current = current.right;
}
return arr;
};
入栈的顺序是一样的,只不过arr添加的顺序不同,这里先push的一定是左子树
设计栈
155. 最小栈
设计一个支持 push
,pop
,top
操作,并能在常数时间内检索到最小元素的栈。
实现 MinStack
类:
MinStack()
初始化堆栈对象。void push(int val)
将元素val推入堆栈。void pop()
删除堆栈顶部的元素。int top()
获取堆栈顶部的元素。int getMin()
获取堆栈中的最小元素。
思路:题目要求实现一个栈,同时扩展栈的功能,能够获取栈顶元素的同时,也能获得栈的最小值。因此可以考虑用一个辅助栈跟栈同步入栈出栈,只不过用最小值进行更新。当前可以用二维数组,第一个值表示当前元素,第二个值存储入栈时栈内的最小值。由于每次栈顶元素都记录了栈的最小值,因此入栈的元素只要和栈顶相比就行了
var MinStack = function () {
this.stack = [];
};
/**
* @param {number} val
* @return {void}
*/
MinStack.prototype.push = function (val) {
if (!this.stack.length) {
this.stack.push([val, val]);
} else {
const min = Math.min(this.stack[this.stack.length - 1][1], val);
this.stack.push([val, min]);
}
};
/**
* @return {void}
*/
MinStack.prototype.pop = function () {
this.stack.pop();
};
/**
* @return {number}
*/
MinStack.prototype.top = function () {
return this.stack[this.stack.length - 1][0];
};
/**
* @return {number}
*/
MinStack.prototype.getMin = function () {
return this.stack[this.stack.length - 1][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()
*/
单调栈
739. 每日温度
给定一个整数数组 temperatures
,表示每天的温度,返回一个数组 answer
,其中 answer[i]
是指对于第 i
天,下一个更高温度出现在几天后。如果气温在这之后都不会升高,请在该位置用 0
来代替。
思路:维护一个单独递增减的栈。入栈的元素没有出栈是因为没有找到比他大的元素。
如果当前遍历的元素大于栈顶,那么栈顶的右边的第一个大的值就找到了。更新指定索引的值。
单调栈的思想在这里的应用是为了找到每个元素右边第一个比它大的元素的位置。具体步骤如下:
- 创建一个栈
stack
和一个结果数组res
,初始化结果数组为全 0。- 遍历温度数组
temperatures
,对于每个温度:
- 如果栈不为空且当前温度大于栈顶温度,说明找到了栈顶元素的下一个更高温度,弹出栈顶元素,更新结果数组中对应位置的值为当前位置与栈顶位置的差值(即等待的天数)。
- 将当前位置入栈。
- 返回结果数组
res
。这种单调栈的思想可以帮助在一次遍历中解决需要查找元素右边第一个比其大的元素位置的问题,从而提高效率。
/**
* @param {number[]} temperatures
* @return {number[]}
*/
var dailyTemperatures = function (temperatures) {
let stack = [];
let res = Array(temperatures.length).fill(0);//初始化结果数组
for (let i = 0; i < temperatures.length; i++) {
//入栈之前判断栈是否大于栈顶元素,如果大于栈顶元素,找到了栈顶的最近一个较大值
while (stack.length && temperatures[stack[stack.length - 1]] < temperatures[i]) {
const index = stack.pop();//栈顶元素的较高值找到,就是i
res[index] = i - index;//更新栈顶index位置的res结果数组,为i-index
}
stack.push(i);//正常入栈
}
return res;
};