From c2591b378b546059ba8aac7332e819748c764e1f Mon Sep 17 00:00:00 2001 From: yk Date: Tue, 27 Feb 2024 12:18:48 +0800 Subject: [PATCH] monoStack --- .../\345\215\225\350\260\203\346\240\210.md" | 366 +++++++++++------- ...25\350\260\203\351\230\237\345\210\227.md" | 2 + 2 files changed, 222 insertions(+), 146 deletions(-) diff --git "a/content/posts/algorithm/data structure/\345\215\225\350\260\203\346\240\210.md" "b/content/posts/algorithm/data structure/\345\215\225\350\260\203\346\240\210.md" index c2b6fcc..41b191a 100644 --- "a/content/posts/algorithm/data structure/\345\215\225\350\260\203\346\240\210.md" +++ "b/content/posts/algorithm/data structure/\345\215\225\350\260\203\346\240\210.md" @@ -1,240 +1,314 @@ --- title: '单调栈' date: 2023-01-31T09:14:58+08:00 +lastmod: 2024-02-26 series: [data structure] categories: [algorithm] --- -## 概念 +## 概念及实现 -首先,栈,同端进同端出,符合 LIFO 的特性,在这个基础上,站内的所有元素具有单调性(递增/递减)就是一个**单调栈**了。 +栈,同端进同端出,具有先进后出的特性,当栈内所有元素具有单调性(递增/递减)就是一个**单调栈**了。 -怎么做到呢?就是让每一个元素入栈时,递归检查栈顶,如果大于或小于栈顶(视单调情况而定)就把栈顶 pop 掉,直到满足能使得新加入的元素保持栈的单调性。 +自行干预单调栈的实现:当一个元素入栈时破坏了单调性,那么就 pop 栈顶(可能需要迭代),直到能使得新加入的元素保持栈的单调性。 ```js -// 递增栈 -for(let i = 0; i < arr.length; ++i) { - while(stack.length && stack[stack.length - 1] >= arr[i]) { - stack.pop() - } - stack.push(arr[i]) -} - -// 递减栈 -for(let i = 0; i < arr.length; ++i) { - while(stack.length && stack[stack.length - 1] <= arr[i]) { - stack.pop() - } - stack.push(arr[i]) +for (const item of arr) { + if(stack.length && item (>= | <=) stack[stack.length - 1]) { + stack.pop() + } + stack.push(item) } ``` -> 注意:1. 栈里存储的东西有时是元素,有时是索引,根据实际情况来选择;2. 可以灵活控制遍历顺序来简化算法 +> 栈存储的信息,可以是索引、元素,根据实际情况进行处理 +> 灵活控制遍历顺序来简化算法 + +## 场景 + +单调栈的应用场景比较单一,只处理一类典型的问题:比如 「下一个更/最...」 之类的问题,另一类是接雨水,柱状图中的最大矩形这种变形问题。 ## 练一练 -单调栈针对的往往是 「下一个更/最...」 之类的问题,另一类是接雨水,柱状图中的最大矩形这种变形问题。 +### lc.402 移掉 K 位数字 -### [496. 下一个更大元素 I](https://leetcode.cn/problems/next-greater-element-i/) +分析:为了让数字最小,从左往右遍历,左侧为高位,所以高位越小越好,那么从左往右遍历的过程中,当索引位置的元素 index > index + 1,时,把 index 位置的数字删掉即可。 ```js /** - * @param {number[]} nums1 - * @param {number[]} nums2 - * @return {number[]} + * @param {string} num + * @param {number} k + * @return {string} */ -var nextGreaterElement = function(nums1, nums2) { - const map = new Map() - const stack = [] - // 正序遍历 - for(let i = 0; i < nums2.length; ++i) { - while(stack.length && stack[stack.length - 1] < nums2[i]) { - // 拍扁过程中 记录下被拍扁元素的下一个最大元素即将要进入的元素 - const item = stack.pop() - map.set(item, nums2[i]) +var removeKdigits = function (num, k) { + const nums = num.split('') + const monoStack = [] + for (const item of nums) { + while (monoStack.length && item < monoStack[monoStack.length - 1] && k) { + monoStack.pop() + k-- } - stack.push(nums2[i]) + monoStack.push(item) } - const res = [] - for(let i = 0 ; i < nums1.length; ++i) { - res.push(map.get(nums1[i]) ? map.get(nums1[i]) : -1) + /** k 还有富余继续pop */ + while (k > 0) { + monoStack.pop() + k-- } - return res -}; - -// 法二 倒序 -var nextGreaterElement = function(nums1, nums2) { - const map = new Map() - const stack = [] - // 倒序遍历, 单调递减, 拍扁之后 栈顶的元素就是即将入栈的值的下一个更大值 - for(let i = nums2.length - 1; i >= 0; --i) { - while(stack.length && stack[stack.length - 1] <= nums2[i]) { - stack.pop() + + while (monoStack[0] === '0') monoStack.shift() // 到这里,单调栈变成了双端队列的角色了,由于 js 的语言特性,很容易 + if (monoStack.length) return monoStack.join('') + return '0' +} +``` + +### lc.496 下一个更大元素 I easy + +```js +/** + * @param {number[]} nums1 + * @param {number[]} nums2 + * @return {number[]} + */ +var nextGreaterElement = function (nums1, nums2) { + // 思路:对 nums2 构建单调栈,同时用哈希表存储信息,最后遍历 nums1 并从哈希表中取出数据即可 + const map = {} + const monoStack = [] + for (const num of nums2) { + while (monoStack.length && num > monoStack[monoStack.length - 1]) { + const lastNum = monoStack.pop() // 拍扁过程中记录上一个元素a和新元素b,他两的关系就是 b 是 a 的下一个更大元素 + map[lastNum] = num } - map.set(nums2[i], stack.length ? stack[stack.length - 1] : -1) - stack.push(nums2[i]) + monoStack.push(num) } - const res = [] - for(let i = 0 ; i < nums1.length; ++i) { - res.push(map.get(nums1[i]) ? map.get(nums1[i]) : -1) + let res = [] + for (const num of nums1) { + res.push(map[num] || -1) } return res -}; +} ``` -> 正着遍历,使用单调递减栈,这样每次拍扁过程中,被拍扁的元素的下一个最大值就是将要入栈的值 -> 倒着遍历,也使用单调递减栈,但是是在拍扁过程后,此时的栈顶就是将要入栈的值的下一个更大值 - -### [503. 下一个更大元素 II](https://leetcode.cn/problems/next-greater-element-ii/) +### lc.503 下一个更大元素 II -相比上一题,这里的数组是个循环数组,遍历循环数组的方法最简单的就是直接拼接一次也就是让数组长度翻倍,但我们也可以使用一个小技巧:取模,来遍历循环数组。 +处理循环数组有个常规的技巧是将循环数组拉直 --- 即复制该序列的前 n−1 个元素拼接在原序列的后面,访问拼接位置元素的索引为 `index % arr.length` -```js -const arr = [1, 2, 3, 4, 5] -const n = arr.length -let index = 0 -// 通过取模的方式来取元素 -// 这样 2 * n 就可以把 arr 数组遍历 2 遍 -while (index < 2 * n) { - console.log(arr[index % n]) - index++ -} -``` +> 本题值的注意的是:如过数组中有重复的元素,那么就不能用 map 存储 「元素」 作为 key 了,索引是单调的,因为可以在单调栈中存储索引。 ```js +// [ 0,1,2,3,4,0,1,2,3 ] 5 % 5 == 0, 6 % 5 ==1 /** * @param {number[]} nums * @return {number[]} */ -var nextGreaterElements = function(nums) { - const stack = [] - const n = nums.length - const res = new Array(n).fill(-1) - for(let i = 0; i < 2 * n; ++i) { - while(stack.length && nums[stack[stack.length - 1]] < nums[i % n]) { - const index = stack.pop() - res[index] = nums[i % n] +var nextGreaterElements = function (nums) { + const res = Array(nums.length).fill(-1) + const monoStack = [] + for (let i = 0; i < nums.length * 2 - 1; ++i) { + const index = i % nums.length + const item = nums[index] + // 存储索引,根据索引去获取上一个元素 + while (monoStack.length && item > nums[monoStack[monoStack.length - 1]]) { + // res[monoStack[monoStack.length - 1]] = item + // monoStack.pop() + res[monoStack.pop()] = item } - stack.push(i % n) + monoStack.push(index) } return res -}; -// 同样, 也可以倒序遍历, 然后在拍扁后 栈顶就是即将入栈的值的下一个更大值 +} ``` -平平无奇,只不过这里单调栈存储的是下标。 +### lc.739 每日温度 -### [739.每日温度](https://leetcode.cn/problems/daily-temperatures/) +看见下一个更高温度,直接单调栈解决,根据题意,要求的是几天后,那么根据数组索引去解决即可。 ```js /** * @param {number[]} temperatures * @return {number[]} */ -var dailyTemperatures = function(temperatures) { - const stack = [] - const res = new 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() - res[index] = i - index // 所求为几天后,所以索引相减即可 +var dailyTemperatures = function (temperatures) { + const n = temperatures.length + const res = Array(n).fill(0) + const monoStack = [] + for (let i = 0; i < n; ++i) { + const item = temperatures[i] + while (monoStack.length && item > temperatures[monoStack[monoStack.length - 1]]) { + const lastIndex = monoStack.pop() + res[lastIndex] = i - lastIndex } - stack.push(i) + monoStack.push(i) } return res -}; +} +``` + +### lc.901 股票价格跨度 + +```js +var StockSpanner = function () { + this.arr = [] + this.monoStack = [] +} + +/** + * @param {number} price + * @return {number} + */ +StockSpanner.prototype.next = function (price) { + this.arr.push(price) + while (this.monoStack.length && price > this.arr[this.monoStack[this.monoStack.length - 1]]) { + this.monoStack.pop() + } + // -1 表示-1 天,用来计算间距 + let lastIndex = this.monoStack.length ? this.monoStack[this.monoStack.length - 1] : -1 + const interval = this.arr.length - lastIndex - 1 // (lastIndex...this.arr.length) + this.monoStack.push(this.arr.length - 1) + return interval +} + +/** + * Your StockSpanner object will be instantiated and called as such: + * var obj = new StockSpanner() + * var param_1 = obj.next(price) + */ ``` --- -### [42.接雨水](https://leetcode.cn/problems/trapping-rain-water/) +下方两道 hard 题,加深对单调栈的理解。 -这道题很经典,经典中的经典,解法很多:双指针/动态规划/单调栈,此处使用单调栈的方式来解题。 +### lc.84 柱状图中的最大矩形 hard -![](https://cdn.jsdelivr.net/gh/yokiizx/picgo@main/img/202301311545709.png) +首先暴力解,枚举每个柱子的高度,对每个柱子找到其左边和右边第一个比它矮的柱子,那么这个柱子的最大面积就是 w \* h 了 -单调栈的解法是有点绕头的,需要按照上图的方式去计算面积,这么想就容易理解点了。 +暴力解的时间复杂度可以用单调栈来降低。暴力寻找左右第一个矮的柱子,时间复杂度是 `O(n^2)`,用上单调栈后,时间复杂度可以降到 `O(n)`。 + +```js +/** + * @param {number[]} heights + * @return {number} + */ +var largestRectangleArea = function (heights) { + const left = [] + const right = [] + + const monoStack = [] + for (let i = 0; i < heights.length; ++i) { + // 找到索引 i 左边第一个比 i 的高要小的 + while (monoStack.length && heights[i] <= heights[monoStack[monoStack.length - 1]]) { + monoStack.pop() + } + left[i] = monoStack.length > 0 ? monoStack[monoStack.length - 1] : -1 + monoStack.push(i) + } + + monoStack.length = 0 + for (let i = heights.length - 1; i >= 0; --i) { + while (monoStack.length && heights[i] <= heights[monoStack[monoStack.length - 1]]) { + monoStack.pop() + } + right[i] = monoStack.length ? monoStack[monoStack.length - 1] : heights.length + monoStack.push(i) + } + let max = 0 + for (let i = 0; i < heights.length; ++i) { + max = Math.max(max, (right[i] - left[i] - 1) * heights[i]) + } + return max +} +``` + +> 这题有个小技巧是:因为是根据索引求宽度,那么首尾的时候就有可能有越界行为,上面使用了 -1 和 数组的长度作为 0 前面和最后一个索引后面的索引,这么做是比较麻烦的,那么就可以使用老熟人**哨兵守卫**了,先把 heights 变成 `[0, ...heights, 0]`,后面就不用担心首尾索引了。 + +在上方的解题中,每次迭代 pop 完之后找到最左、右第一个小于 i 的边界,这就把 pop 出的这个信息给浪费了。而当有了守卫的时候,这道题的常数项优化就更简单了([官解的常数项优化](https://leetcode.cn/problems/largest-rectangle-in-histogram/solutions/266844/zhu-zhuang-tu-zhong-zui-da-de-ju-xing-by-leetcode-/))。 + +因为有了最左侧的 0,会保证左侧边界不越界;因为有了右侧的 0 会保证每个柱子都会遍历到。当每个柱子被 pop 的时候,它的左边界就是它在栈中的的上一个,它的右边界就是新进入的,在这个时候去计算并更新最大值即可。 + +```js +/** + * @param {number[]} heights + * @return {number} + */ +var largestRectangleArea = function (heights) { + let max = 0 + const monoStack = [] + heights = [0, ...heights, 0] + for (let i = 0; i < heights.length; ++i) { + while (monoStack.length && heights[i] < heights[monoStack[monoStack.length - 1]]) { + const h = heights[monoStack.pop()] + const left = monoStack[monoStack.length - 1] + max = Math.max(max, (i - left - 1) * h) + } + monoStack.push(i) + } + return max +} +``` + +### lc.42 接雨水 hard + +这道题是经典中的经典了,解法很多:双指针/动态规划/单调栈,此处使用单调栈的方式来解题。 ```js /** * @param {number[]} height * @return {number} */ -var trap = function(height) { - const stack = [] - let res = 0 - for(let i = 0; i < height.length; ++i) { - while(stack.length && height[stack[stack.length-1]] < height[i]) { - const midBase = stack.pop() // 中间高度的索引 - if(stack.length) { - let h = Math.min(height[i], height[stack[stack.length - 1]]) - height[midBase] - let w = i - stack[stack.length - 1] - 1 - res += h * w +var trap = function (height) { + let area = 0 + const monoStack = [] + for (let i = 0; i < height.length; ++i) { + // 找到下一个更大的元素,就能形成 凹 槽 + while (monoStack.length && height[i] > height[monoStack[monoStack.length - 1]]) { + const lowH = height[monoStack.pop()] // 中间的凹槽的高度 + // 注意这里,对边界做判断 + if (monoStack.length) { + const highH = Math.min(height[monoStack[monoStack.length - 1]], height[i]) + area += (highH - lowH) * (i - monoStack[monoStack.length - 1] - 1) } } - stack.push(i) + monoStack.push(i) } - return res -}; + return area +} ``` -补充:双指针法 +补充一下这道题的最优解:双指针法 ```js // 相比单调栈横向计算面积, 双指针是纵向计算面积的,主要根据两边高度的较小个 -var trap = function(height) { +var trap = function (height) { // 每个坐标点能装下的水是 左右最高柱子较小的那一个 减去自身的高度 const n = height.length - let l = 0, r = n - 1 + let l = 0, + r = n - 1 let res = 0 - let l_max = 0, r_max = 0 - while(l < r) { + let l_max = 0, + r_max = 0 + while (l < r) { l_max = Math.max(l_max, height[l]) r_max = Math.max(r_max, height[r]) - if(l_max < r_max) { + if (l_max < r_max) { res += l_max - height[l] l++ - }else { + } else { res += r_max - height[r] r-- } } return res -}; +} ``` -### [84.柱状图中的最大矩形](https://leetcode.cn/problems/largest-rectangle-in-histogram/) - -此题也是有多种解法,此处使用单调栈解法。 - -结合上题,可以帮助我们更好理解单调栈如何确定是单调递增还是单调递减。 +--- -- 接雨水需要找到「盛水的凹点」,所以栈顶要小于左右两侧,因此需要一个「单调递减」的栈 -- 最大矩形需要找到「左右的低点」,所以栈顶要高于左右两侧,因此需要一个「单调递增」的栈 +总结:对于应用类型的问题,要学会转化问题 -另外这道题还用到了一个技巧:哨兵守卫,因为需要使用到首尾索引,所以使用这种技巧来简化算法。 +- 接雨水需要找到「盛水的凹点」,被 pop 出来的就是 凹槽 +- 最大矩形需要找到「左右的低点」,被 pop 出来的就是 峰顶 -```js -/** - * @param {number[]} heights - * @return {number} - */ -var largestRectangleArea = function(heights) { - heights = [0, ...heights, 0] // 添加哨兵守卫 - const stack = [] - let res = 0 - for(let i = 0; i < heights.length; ++i) { - while(stack.length && heights[stack[stack.length - 1]] > heights[i]) { - const top = stack.pop() // 峰 索引 - const h = heights[top] - const w = i - stack[stack.length - 1] - 1 - // 因为有了守卫所以这里可以放心取 stack[stack.length - 1] - res = Math.max(res, h * w) - } - stack.push(i) - } - return res -}; -``` +此类根据凹凸边界去求解的问题,应当联想到单调栈~ diff --git "a/content/posts/algorithm/data structure/\345\215\225\350\260\203\351\230\237\345\210\227.md" "b/content/posts/algorithm/data structure/\345\215\225\350\260\203\351\230\237\345\210\227.md" index 8d04640..9699bd3 100644 --- "a/content/posts/algorithm/data structure/\345\215\225\350\260\203\351\230\237\345\210\227.md" +++ "b/content/posts/algorithm/data structure/\345\215\225\350\260\203\351\230\237\345\210\227.md" @@ -34,6 +34,8 @@ class MonoQueue { } ``` +### lc.918 环形子数组的最大和(单调队列) + ### [239. 滑动窗口最大值](https://leetcode.cn/problems/sliding-window-maximum/) 滑动窗口嘛,完美符合一边进,一边出的条件,所以用单调队列来实现这个滑动窗口再好不过了。