当青训营遇上码上掘金 | 攒青豆

75 阅读4分钟

当青训营遇上码上掘金,本文将基于该活动的主题四,对一道经典的算法题目进行讲解。对应的代码实现: 码上掘金:攒青豆

活动主题

现有 n 个宽度为 1 的柱子,给出 n 个非负整数依次表示柱子的高度,排列后如下图所示,此时均匀从上空向下撒青豆,计算按此排列的柱子能接住多少青豆。(不考虑边角堆积)

image.png

输入:height = [5,0,2,1,4,0,1,0,3] 
输出:17 
解析:上面是由数组 [5,0,2,1,4,0,1,0,3] 表示的柱子高度,在这种情况下,可以接 17 个单位的青豆。

问题解析

对于一些接触过算法题的同学来说,该问题一定是不陌生的。通过分析题目,我们可以将该题转化为如下问题:对于每一个中间列,找到其左右的最高列,该列能接住青豆的数量即为中间列的高度两侧最高列中较低列高度之差。

将问题转化后,我们就可以通过垂直和水平两种方法解决此题。

  • 垂直方法的思想为:找到每一列两侧最高列的具体高度,并按列计算青豆数量;
  • 水平方法的思想为:对于某一高度,找到该高度中的空缺面积,并将所有高度的空缺面积相加;

对于垂直方法,我们可以运用动态规划及双指针方法求解;对于水平方法,我们可以运用单调栈方法求解,下面是这些方法的代码实现及讲解。

方法1 : 动态规划

func dp(height []int) int {
  n := len(height)
  leftMax := make([]int, n)
  rightMax := make([]int, n)
  leftMax[0] = height[0]
  rightMax[n - 1] = height[n - 1]
  for i := 1; i < n - 1; i++ {
    leftMax[i] = max(leftMax[i - 1], height[i - 1])
  }
  for i := n - 2; i > 0; i-- {
    rightMax[i] = max(rightMax[i + 1], height[i + 1])
  }
  ret := 0
  for i := 1; i < n - 1; i++ {
    minHeight := min(leftMax[i] ,rightMax[i])
    if minHeight > height[i] {
      ret += minHeight - height[i]
    }
  }
  return ret
}

对于本题,动态规划方法的思想为求解两个数组:leftMax[i]中存放了索引i左侧最高的墙的高度;rightMax[i]中存放了索引i右侧最高的墙的高度。

当两个数组求取完毕后,每一列可存储的青豆数量即为height[i] - min(leftMax[i], rightMax[i])。该方法的时间复杂度为O(n),空间复杂度为O(n)。

方法2 : 双指针

func doublePointer(height []int) int {
  n := len(height)
  left := 0
  right := n - 1
  leftMax := 0
  rightMax := 0
  ret := 0
  for left < right {
    leftMax = max(height[left], leftMax)
    rightMax = max(height[right], rightMax)
    if leftMax < rightMax {
      ret += leftMax - height[left]
      left++
    } else {
      ret += rightMax - height[right]
      right--
    }
  }
  return ret
}

对于动态规划方法而言,其中leftMaxrightMax数组中的每一个元素在求解过程中仅被使用了一次,在这里我们可以使用双指针方法降低该问题的求解复杂度。

在这里,我们维护两个指针leftright,其被初始化为0n-1并向中间移动。当两个指针遍历至i时,left指针求解得到了区间[0,i]中最高的列高度,right指针求解得到了区间[i,n-1]中最高的列高度。

那么,本方法的主要问题有两个:

  • 问题1:当整个数组未遍历结束时,我们并不知道某一列两侧最高的列分别的高度,我们应该如何在不全面的已知条件下解决问题呢?
  • 问题2:我们应该如何确定在某次遍历中,移动leftright指针?

解决以上问题的出发点在于,我们并不需要求解某一列两侧最高的列分别的高度,而是仅需知道其两侧最高的列中较低列的高度。因此,我们在遍历过程中,保存leftMaxrightMax分别作为[0,left][0,right]中最高列的高度。

  • leftMax < rightMax时,此时left指针左侧的所有列均被遍历完毕,因此该列左侧最高列的高度是已知的,又因为leftMax < rightMax,所以可以已知该列右侧存在比左侧更高的列,因此对于列left的青豆数量可以求得为height[left] - leftMax,上述问题1可以被解决;
  • leftMax < rightMax时,移动right是不安全的,因为我们不知道(left,right]中是否有列的高度高于leftMax,因此我们只能移动left,上述问题2可以被解决。

该方法的时间复杂度为O(n),空间复杂度为O(1)。

方法3 : 单调栈

func stack(height []int) int {
  n := len(height)
  s := make([]int, n)
  top := -1
  ret := 0
  for i := 0; i < n; i++ {
    for top >= 0 && height[i] > height[s[top]] {
      h := height[s[top]]
      top--
      if top < 0 {
        break
      }
      dis := i - s[top] - 1
      bound := min(height[i], height[s[top]])
      ret += dis * (bound - h)
    }
    top++
    s[top] = i
  }
  return ret
}

该方法维护了一个从栈底到栈顶列高度单调递减的栈,并具体存储列的索引。其从左到右顺序遍历每一列的高度,并在遍历每一列时执行如下操作:

  • 当该列的高度小于等于栈顶元素的高度时,则该列左侧没有更低的列,因此该列能装的青豆数量未知,将该列的索引加入单调栈;
  • 当该列的高度大于栈顶元素的高度时,则该列左侧有更低的列。设栈底此时的索引为lo,该列的索引为hi、高度为h,则从lohi的列中高度h以下的空间的青豆数可以被求取。具体的求取方法见如下图解。

image.png

在这里,当列的高度大于栈顶元素的高度时,我们从栈顶向下遍历列,直到栈顶列高度大于该列、或栈的容积小于2(栈顶列左侧不存在列)。对于栈中的第i层,我们可以水平的计算i-1hi列中高度低于min(height[i-1],height[hi])空间的空余容积为(hi - i) * minHeight,并将该列从栈中删除。当遍历栈结束后,我们将该列加入栈。

image.png

在该方法中,从栈中删除的列所能容纳的额外青豆数量不会被忽略。如上图所示,对于这些被删除的列而言,高度低于4的空间容积已经被计算;对于高度大于4的空间,当其两侧存在更高的列时(栈中仍存在大于2数量的列,且新遍历的列高度高于栈顶列)会在新的列遍历中被计算。