Published on

单调栈

Authors

概念及实现

栈,同端进同端出,具有先进后出的特性,当栈内所有元素具有单调性(递增/递减)就是一个单调栈了。

自行干预单调栈的实现:当一个元素入栈时破坏了单调性,那么就 pop 栈顶(可能需要迭代),直到能使得新加入的元素保持栈的单调性。

for (const item of arr) {
    while(stack.length && item (>= | <=) stack[stack.length - 1]) {
        stack.pop()
    }
    stack.push(item)
}

栈存储的信息,可以是索引、元素,根据实际情况进行处理 灵活控制遍历顺序来简化算法

场景

单调栈的应用场景比较单一,只处理一类典型的问题:比如 「下一个更/最...」 之类的问题,另一类是接雨水,柱状图中的最大矩形这种变形问题。

练一练

lc.402 移掉 K 位数字

分析:为了让数字最小,从左往右遍历,左侧为高位,所以高位越小越好,那么从左往右遍历的过程中,当索引位置的元素 index > index + 1,时,把 index 位置的数字删掉即可。

/**
 * @param {string} num
 * @param {number} k
 * @return {string}
 */
var removeKdigits = function (num, k) {
  const nums = num.split('')
  const stack = []
  for (const el of num) {
    while (stack.length && k && el < stack[stack.length - 1]) {
      stack.pop()
      k--
    }
    stack.push(el)
  }

  /** k 还有富余继续pop */
  while (k > 0) {
    stack.pop()
    k--
  }

  while (stack[0] === '0') stack.shift()
  return stack.join('') || '0'
}

lc.496 下一个更大元素 I easy

/**
 * @param {number[]} nums1
 * @param {number[]} nums2
 * @return {number[]}
 */
var nextGreaterElement = function (nums1, nums2) {
  // 思路:对 nums2 构建单调栈,同时用哈希表存储信息,最后遍历 nums1 并从哈希表中取出数据即可
  const stack = []
  const map = {}
  for (const el of nums2) {
    while (stack.length && el > stack[stack.length - 1]) {
      const last = stack.pop() // 拍扁过程中,el 是 所所有被拍扁元素的下一个更大元素
      map[last] = el
    }
    stack.push(el)
  }
  let res = []
  for (const el of nums1) {
    res.push(map[el] || -1)
  }
  return res
}

lc.503 下一个更大元素 II

处理循环数组有个常规的技巧是将循环数组拉直 --- 即复制该序列的前 n−1 个元素拼接在原序列的后面,访问拼接位置元素的索引为 index % arr.length

本题值的注意的是:如过数组中有重复的元素,那么就不能用 map 存储 「元素」 作为 key 了,索引是单调的,因为可以在单调栈中存储索引。

// [ 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 res = Array(nums.length).fill(-1)
  const stack = []
  for (let i = 0; i < nums.length * 2 - 1; i++) {
    while (stack.length && nums[i % nums.length] > nums[stack[stack.length - 1]]) {
      const index = stack.pop()
      res[index] = nums[i % nums.length]
    }
    stack.push(i % nums.length)
  }
  return res
}

lc.739 每日温度

看见下一个更高温度,直接单调栈解决,根据题意,要求的是几天后,那么根据数组索引去解决即可。

/**
 * @param {number[]} temperatures
 * @return {number[]}
 */
var dailyTemperatures = function (temperatures) {
  const res = Array(temperatures.length).fill(0)
  const stack = []
  for (let i = 0; i < temperatures.length; ++i) {
    while (stack.length && temperatures[i] > temperatures[stack[stack.length - 1]]) {
      const lastIndex = stack.pop()
      res[lastIndex] = i - lastIndex
    }
    stack.push(i)
  }
  return res
}

lc.901 股票价格跨度

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)
 */

下方两道 hard 题,加深对单调栈的理解。

lc.84 柱状图中的最大矩形 hard

首先暴力解,枚举每个柱子的高度,对每个柱子找到其左边和右边第一个比它矮的柱子,那么这个柱子的最大面积就是 w * h 了

暴力解的时间复杂度可以用单调栈来降低。暴力寻找左右第一个矮的柱子,时间复杂度是 O(n^2),用上单调栈后,时间复杂度可以降到 O(n)

/**
 * @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 出的这个信息给浪费了。而当有了守卫的时候,这道题的常数项优化就更简单了(官解的常数项优化)。

因为有了最左侧的 0,会保证左侧边界不越界;因为有了右侧的 0 会保证每个柱子都会遍历到。当每个柱子被 pop 的时候,它的左边界就是它在栈中的的上一个,它的右边界就是新进入的,在这个时候去计算并更新最大值即可。

/**
 * @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

这道题是经典中的经典了,解法很多:双指针/动态规划/单调栈,此处使用单调栈的方式来解题。

/**
 * @param {number[]} height
 * @return {number}
 */
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)
      }
    }
    monoStack.push(i)
  }
  return area
}

补充一下这道题的最优解:双指针法,能做到空间复杂度 O(1)

// 相比单调栈横向计算面积, 双指针是纵向计算面积的,主要根据两边高度的较小个
var trap = function (height) {
  // 每个坐标点能装下的水是 左右最高柱子较小的那一个 减去自身的高度
  const n = height.length
  let l = 0,
    r = n - 1
  let res = 0
  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) {
      res += l_max - height[l]
      l++
    } else {
      res += r_max - height[r]
      r--
    }
  }
  return res
}

总结:对于应用类型的问题,要学会转化问题

  • 接雨水需要找到「盛水的凹点」,被 pop 出来的就是 凹槽
  • 最大矩形需要找到「左右的低点」,被 pop 出来的就是 峰顶

此类根据凹凸边界去求解的问题,应当联想到单调栈~