栈和队列
有效括号问题
题目描述:给定一个只包括
'(',')','{','}','[',']'
的字符串,判断字符串是否有效。 💡 有效字符串需满足左括号必须用相同类型的右括号闭合。左括号必须以正确的顺序闭合。注意空字符串可被认为是有效字符串。示例 1:输入:
"()"
输出:true
示例 2:输入:"()[]{}"
输出:true
示例 3:输入:"(]"
输出:false
示例 4:输入:"([)]"
输出:false
示例 5:输入:"{[]}"
输出:true
题目中若涉及括号问题,则很有可能和栈相关。因为括号配对成立意味字符串是有对称性的,而根据栈的后进先出原则,一组数据的入栈和出栈顺序刚好是对称的。
在遍历字符串的过程时,使用一个空数组作为栈,存储遍历到的左括号时,如遍历到了 (
,就往栈里 push
相应的右括号 )
;当遍历到右括号时,就将其与栈当前 pop
出来的顶部元素作比较(使用 pop
取出顶部的元素来进行比较,可以确保栈顶的括号总是与下一个需要被匹配的左括号相应的右括号)。由于栈这种数据结构遵循后进先出原则,而如果字符串满足对称性,相应的右括号会在较外侧,也是较后才会被遍历到。
不仅使用栈的进栈出栈有序性进行比较,还可以及时地丢掉比对成功的括号,最后判断栈是否为空,即可直到所有遍历到的左括号是否有相应的右括号配对了。
/ 使用一个对象模拟 map 记录左右括号的对应关系
const leftToRight = {
"(": ")",
"{": "}",
"[": "]"
};
// 用数组列出所有左侧括号
const left = ["(", "{", "["]
/**
* @param {string} s
* @return {boolean}
*/
function isValid(str) {
// 先判断是否为空字符串
if(!str) {
return true;
}
// 初始化 stack 数组
let stack = [];
const len = str.length;
// 遍历字符串
for(let i=0; i<len; i++) {
// 读取当前索引字符串所对应的字符
let ch = str[i];
// 判读是否为左括号,如果是就 push 相应的右括号到栈中
if(left.includes(ch)) {
stack.push(leftToRight[ch])
} else {
// 如果遍历字符为右括号,就和当前 pop 出的栈顶部元素作对比(如果栈还存在元素时)
if(!stack.length || stack.pop() !== ch) {
return false
}
}
}
// 遍历字符串后,栈刚好也清空,表示括号完全匹配,返回 true
return !stack.length;
}
每日温度问题
题目描述:根据每日气温列表,请重新生成一个列表,对应位置的输出是需要再等待多久温度才会升高超过该日温度的天数。如果之后都不会升高,请在该位置用
0
来代替。例如,给定一个列表
temperatures = [73, 74, 75, 71, 69, 72, 76, 73]
,你的输出应该是[1, 1, 4, 2, 1, 1, 0, 0]
。💡 气温列表长度的范围是[1, 30000]
。每个气温的值的均为华氏度,都是在[30, 100]
范围内的整数。
可以使用最直观的暴力遍历法:直接两层遍历,第一层定位一个温度,第二层定位离这个温度最近的一次升温是哪天,然后求出两个温度对应索引的差值即可,但是会出现多次重复比较的情况,而且两次遍历时间复杂度是 O(n^2)。
使用栈记录元素的索引,并维持为一个递减栈(即栈记录的索引所对应的元素值是依次递减的),如果遍历时遇到不是递减的元素,就求出索引差值,并及时将不必要的数据出栈,避免它对我们后续的遍历产生干扰,同时可以帮我们避免重复操作,将时间复杂度降低到 O(n)。
📺 具体步骤参考该视频。
/**
* @param {number[]} T
* @return {number[]}
*/
function dailyTemp(arr) {
const len = arr.length;
// 初始化递减栈
let stack = [];
// 初始化结果数组,长度与输入数组 arr 相同,初始占位值为 0
let result = (new Array(len)).fill(0);
// 遍历数组
for(let i=0; i<len; i++) {
// 当栈不为空,且当前遍历的元素的值「打破」元素值递减的规定
// 并将栈顶部元素(是 arr 中元素的索引)出栈,并(索引)差值记录在结果数组中
while(stack.length && arr[i]>arr[stack[stack.length-1]]) {
let top = stack.pop();
let distance = i - top;
result[top] = distance;
}
// 将当前的元素的索引入栈,等待与其后元素作对比
// 注意栈里存的不是温度值,而是索引值
stack.push(i)
}
// 返回结果数组
return result;
}
最小栈问题
题目描述:设计一个支持
push
,pop
,top
操作,并能在常数时间内检索到最小元素的栈。push(x)
—— 将元素x
推入栈中。pop()
—— 删除栈顶的元素。top()
—— 获取栈顶元素。getMin()
—— 检索栈中的最小元素。示例:
let minStack = new MinStack();
minStack.push(-2);
minStack.push(0);
minStack.push(-3);
minStack.getMin();
--> 返回 -3.minStack.pop();
minStack.top();
--> 返回 0.minStack.getMin();
--> 返回 -2.
其中前三个操作:push
、pop
和 top
可以利用数组的方法和索引实现,而 getMin
这个接口可以使用遍历读取到当前数组中最小值,但是对栈进行了一次遍历,时间复杂度无疑是 O(n)。为了提升时间效率,可以付出更多的空间占用作为代价。在这道题里,可以考虑再使用一个递减栈 stack2
作为辅助,通过以下规则,让这个栈去容纳当前的最小值,即从栈底到栈顶呈递减趋势的栈:
- 取最小值:由于整个栈从栈底到栈顶递减,因此栈顶元素就是最小元素。
- 若有新元素入栈:判断是不是比栈顶元素还要小,否则不准进入 stack2。
- 若有元素出栈:判断是不是和栈顶元素相等,如果是的话,stack2 也要出栈。
function MinStack() {
this.stack = [];
// 辅助的递减栈
this.stack2 = [];
}
/**
* @param {number} x
* @return {void}
*/
MinStack.prototype.push = function (x) {
this.stack.push(x);
// 若入栈的值笔递减栈顶部的值(最小值),则推入辅助栈
if (this.stack2.length === 0 || this.stack2[this.stack2.length - 1] >= x) {
this.stack2.push(x);
}
}
/**
* @return {void}
*/
MinStack.prototype.pop = function () {
// 若 stack 出栈的值与辅助栈 stack2 顶部的元素相同(最小值),则同时将辅助栈顶部值出栈
// 保证辅助栈顶部的值是栈 stack 最小值
if (this.stack.pop() === this.stack2[this.stack2.length - 1]) {
this.stack2.pop()
}
}
/**
* @return {number}
*/
MinStack.prototype.top = function() {
return this.stack[this.stack.length-1]
}
/**
* return {number}
*/
MinStack.prototype.getMin = function () {
// 辅助栈 stack2 顶部元素就是原始栈 stack 的最小值
return this.stack2[this.stack2.length - 1]
}
用栈实现一个队列
题目描述:使用栈实现队列的下列操作:
push(x)
——将一个元素放入队列的尾部。pop()
——从队列首部移除元素。peek()
——返回队列首部的元素。empty()
——返回队列是否为空。示例:
let queue = new MyQueue();
queue.push(1);
queue.push(2);
queue.peek();
// 返回 1queue.pop();
// 返回 1queue.empty();
// 返回 false 💡 你只能使用标准的栈操作模拟队列——也就是只有 push to top, peek/pop from top, size, 和 is empty 操作是合法的。你所使用的语言也许不支持栈。你可以使用 list 或者 deque(双端队列)来模拟一个栈,只要是标准的栈操作即可。假设所有操作都是有效的(例如,一个空的队列不会调用 pop 或者 peek 操作)。
栈,后进先出;队列,先进先出。用栈实现队列,说白了就是用栈实现先进先出的效果,再说直接点,就是想办法让栈底的元素首先被取出,也就是让出栈序列被逆序,一个栈做不到的事情,我们用两个栈。
如果要添加元素到栈中,就按照原来栈的操作;只要需要取出元素时,就先将所有元素从 stack1
「转移」到 stack2
,实现元素的逆序,然后再出栈,这样就可以「模拟」先进先出的队列规则(💡 如果后来有元素添加进来 stack1
,那么下一次需要取出元素时,应该等到 stack2
元素全部取出,再将 stack1
中这些新添加的元素全部「转移」到 stack2
,先实现逆序中再进行出栈)
通过两个栈,分别执行「接受」元素,和「取出」元素的功能,模拟队列先进先出的功能;当两个栈都为空时,模拟的队列才为空。
function MyQueue() {
// 初始化两个栈
this.stack1 = [];
this.stack2 = [];
}
/**
* Push element to stack1, back of queue
* @param {number} x
* @return {void}
*/
MyQueue.prototype.push = function (x) {
this.stack1.push(x)
}
/**
* Remove the element from in front of queue and return the element
* @return {number}
*/
MyQueue.prototype.pop = function () {
// 如果 stack2 为空,将 stack1 所有元素转移到 stack2,元素逆序后再从 stack2 出栈
if (this.stack2.length === 0) {
// 当 stack1 不为空时,转移全部元素
while (this.stack1.length !== 0) {
this.stack2.push(this.stack1.pop());
}
}
// 出栈在 stack2 中完成
return this.stack2.pop();
}
/**
* Get the front element
* @return {number}
* 这个方法和 pop 唯一的区别就是没有将定位到的值出栈
*/
MyQueue.prototype.peek = function () {
if (this.stack2.length === 0) {
// 当 stack1 不为空时,转移全部元素
while (this.stack1.length !== 0) {
this.stack2.push(this.stack1.pop());
}
}
// 返回 stack2 中栈顶元素
return this.stack2[this.stack2.length - 1];
}
/**
* Return whether the queue is empty
* @return {boolean}
*/
MyQueue.prototype.empty = function () {
// 若 stack1 和 stack2 均为空,那么队列空
return !this.stack2.length && !this.stack2.length;
}
滑动窗口问题
双端队列就是允许在队列的两端进行插入和删除的队列。体现在编码上,最常见的载体是既允许使用 pop
、push
同时又允许使用 shift
、unshift
的数组。双端队列衍生出的滑动窗口问题。
let queue = [1,2,3,4] // 定义一个双端队列
queue.push(1) // 双端队列尾部入队
queue.pop() // 双端队列尾部出队
queue.shift() // 双端队列头部出队
queue.unshift(1) // 双端队列头部入队
题目描述:给定一个数组
nums
和滑动窗口的大小k
,请找出所有滑动窗口里的最大值。示例:输入:
nums = [1,3,-1,-3,5,3,6,7]
, 和k = 3
输出:[3,3,5,5,6,7]
💡 你可以假设k
总是有效的,在输入数组不为空的情况下,1 ≤ k ≤ 输入数组的大小
滑动窗口的位置:[1 3 - 1] - 3 5 3 6 7
1[3 - 1 - 3] 5 3 6 7
1 3[-1 - 3 5] 3 6 7
1 3 - 1[-3 5 3] 6 7
1 3 - 1 - 3[5 3 6] 7
1 3 - 1 - 3 5[3 6 7]
最大值分别对应:3 3 5 5 6 7
直观解法是直接模拟题中描述的这个过程,使用双指针约束一个窗口(范围),然后每次窗口移动后遍历其中的元素,返回最大值即可。但是这种解法需要嵌套循环,外层循环一共走了 n-k+1
次,每次内层循环固定执行 k
次,因此时间复杂度简化后用大 O 表示法可以记为 O(kn)。
当滑动窗口前进一步的时候,如果减少的数不是当前最大值,增加的数也没有超越当前最大值,则最大值仍然不变,即 push
到结果数组中的元素和前一个元素一样,因此可以只需要考察新增和删除元素即可基于不同情况即可知道新窗口中最大值。
双端队列可以完美地帮助我们达到这个目的,其核心的思路是维护一个有效的递减队列,这样就可以确保队头元素始终是当前窗口的最大值,可以将时间复杂度优化到 O(n)。
在遍历数组的前期(第一个窗口),尝试将遍历到的每一个元素都推入队列内部;然后在之后移动窗口时,只需要尝试推入右侧元素即可。而且每尝试推入一个元素前,都把这个元素与队列尾部的元素作对比,根据对比结果的不同,采取不同的措施:
- 如果试图推入的元素大于队尾元素,则意味着队列的递减趋势被打破了,此时我们需要将队列尾部的元素依次出队(由于是双端队列,所以队尾出队是没有问题的),直到队尾元素大于等于当前元素为止,此时再将当前元素入队。
- 如果试图推入的元素小于队列尾部的元素,那么就不需要额外的操作,直接把当前元素入队即可。
- 要考虑最大值可能在左侧由于窗口的移动被移除的情况,因此需要将双端队列的队头元素与窗口左侧移出的元素比对(因为最大元素在队头,而且根据元素进队的规则,如果窗口左侧的值不比右侧的值大,不可能还留在队列中,因此不需要遍历整个队列来比对),如果相同则需要将其同步
shift
移除出双端队列,确保队列的有效性。
💡 当遍历到的元素个数达到了 k
个时(完成第一个窗口所有元素的遍历),意味着滑动窗口的第一个最大值已经产生了,应该把它 push
进结果数组里;然后每移动一次窗口完成递减双端队列「重整」后,将队列的队头元素作为当前窗口的最大值 push
到结果数组中。
/**
* @param {number[]} nums
* @param {number} k
* @return {number[]}
*/
function maxSlideWindow(nums, k) {
const len = nums.length;
// 初始化结果数组
let res = [];
// 初始化双端队列
let deque = [];
// 遍历数组 nums
for (let i = 0; i < len; i++) {
// 判断当前元素与双端队列的队尾元素大小关系
// 队尾元素不断 pop 出队,直至队尾元素大于或等于当前元素
while (deque.length && nums[i] > nums[deque[deque.length - 1]]) {
deque.pop()
}
// 将当前元素的索引 push 到「重整」好的双端队列中
deque.push(i);
// 检查队列的队头元素是否已被排除到窗口之外
// 通过索引来比较
while (deque.length && deque[0] <= i - k) {
// 将队头元素(数组 nums 的索引)出队
deque.shift();
}
// 判断滑动窗口的状态,是否完成第一个窗口的元素遍历
// 只有被遍历的元素大于 k 时,才在每一次遍历中将双端队列的队首元素 push 到结果数组中
if (i >= k - 1) {
res.push(nums[deque[0]]);
}
}
// 返回结果数组
return res;
}