主题 4:攒青豆 解题思路分享 | 青训营 X 码上掘金

45 阅读3分钟

当青训营遇上码上掘金

其实这道题还是比较典型的一道题,它与leetcode42积雨水这道题是一样的,相信很多人都刷过做过。

这里我先介绍我自己最直观的暴力做法是怎样思考和实现的,之后再看怎么通过单调栈减少时间复杂度,用空间换时间。

首先,给出的是一系列柱体的高度,每个柱体的宽度都为1,通过这些柱体围出的凹陷来存储豆子所有凹陷都需要通过给出的柱体封口,边界不会提供封口的功能。有了这些条件之后我们就有了一个最直观的想法,如何找到给出的所有柱体中构成的不重叠凹陷?这需要一些思考,因为一个构成的凹陷的区段内部可能还有凹陷内的小凹陷。比如[3,1,2,0,4]这一整个序列构成一个完整的凹陷,但是这个凹陷区段中有两个小的凹陷分别为[3,1,2][2,0,4]但是如果只统计这两个小凹陷显然就漏掉了高度为3的从索引1到索引3的存储容量。因此,为了得到一个凹陷区域的完整,不能只计算小凹陷。

计算大凹陷区段首先要找到凹陷区段的最左侧柱体,这里就是找开始柱体做就可以。确定了开始柱体接下来就是向右扫描,找到凹陷的最右侧柱体。不难看出,当右侧有柱体大于或等于左侧边界柱体时,一个完整的凹陷区段就直接能确定了如[3,1,4,0,4]这个柱体序列中,[3,1,4][4,0,4]两个就是完整的凹陷区段。但是问题在于左侧边界的右侧不一定有高度大于等于它的柱体,这时该如何选择一个凹陷的右侧边界柱体呢?那就当然是找右侧的高度最大的柱体了。这样就保证了小凹陷上方的容量可以计算进去了。之后就是将凹陷区段的右侧柱体当成下一个凹陷区段的最左侧柱体进行下一个凹陷的计算。

// 外循环用来计算所有区段的容量
    for lIdx < len(heights) - 1 {
        // 内循环用来确定一个区段
        leftHeight := heights[lIdx]
        var candidateRightIdx = lIdx + 1
        var candidateRightHeight = heights[candidateRightIdx]
        // 找右侧的确实边界
        for rIdx := lIdx + 1;
         rIdx < len(heights) && candidateRightHeight < leftHeight; // 要么所有可能候选都遍历了 要么直接找到确定的右边界了 
         rIdx++ {
            // 读下一个右侧高度
            rh := heights[rIdx]
            if rh >= candidateRightHeight {
                // 如果下一个右侧高度可以更新右侧高度候选
                candidateRightHeight, candidateRightIdx = rh, rIdx
            }
        }
        // 确定这个区段的高度
        waterMark := leftHeight
        if leftHeight > candidateRightHeight {
            waterMark = candidateRightHeight
        }
        // 计算容量
        for i := lIdx + 1; i < candidateRightIdx; i++ {
            res += waterMark - heights[i]
        }
        // 更新左边界
        lIdx = candidateRightIdx
    }

这样计算出所有的区段累加之后就可以得到最终的总容量。可以看出解题的关键是算出当前索引右侧第一个高于当前索引对应高度值的索引,如果这个值不存在就找右侧最大高度值的索引。想要计算出这个值如果暴力计算那么需要的时间复杂度必然是O(n2)O(n^2)的。

上面的计算复杂度高主要是因为其在计算时需要反复将未计算完成的右侧进行扫描,没有利用上之前扫描时的信息。其实可以看出当计算右侧最高值时,在遍历过程中更新临时的右侧最高值时上一个临时最高值的右侧边界就得以确定了。通过这样的信息可以通过增加额外空间记忆中间结果以达到加速计算的目的。

但是,这道题的典型解法是单调栈。何为单调栈呢,就是一个栈其栈元素自栈底到栈顶遵循单调递增或递减的。类似的题还有力扣上的天气预报和套信封。这里求容量的问题使用单调栈解决的方法就是将上面暴力算法的从左至右扫描确定区段的过程,变成了从右至左通过不断填平小凹陷的方式从下至上计算填平凹陷的面积,这个填平的总面积就是围成的容量。但是这里的问题就在于由于边界是不围起来的从右至左的填平是有可能填多的。这时就需要根据确定的右侧边界的高度做填多后的找回。

// 类似用粉底液糊痘印的思想,一点一点把坑填上,在计算容量的同时更新一个区段的高度为填坑后的同一高度
func beansCountWithMonotonicStack(heights [] int) (res int) {
    // 一个单调栈栈中的元素自底向上单调递减
    var monotonicStack [][2]int
    // 单调栈中元素由两部分构成分别为柱高和柱宽
	const (
		HEIGHT = iota
		WIDTH
	)
    // 遍历所有高度
	for _, h := range heights {
        // 每一个新读入的右侧柱宽度只有1
		w := 1
        // 如果它比单调栈的栈顶元素高,说明它有可能可以向右侧填豆以统一高度
		if stackSize := len(monotonicStack); stackSize > 0 && monotonicStack[stackSize-1][HEIGHT] <= h {
            // 如果右边的新柱可以向左侧的下坡填豆,那么将豆子预填入,并更新新柱的宽度
			for ; stackSize > 0 && monotonicStack[stackSize-1][HEIGHT] <= h; stackSize-- {
				w += monotonicStack[stackSize-1][WIDTH]
				res += (h - monotonicStack[stackSize-1][HEIGHT]) * monotonicStack[stackSize-1][WIDTH]
			}
            // 这个地方是防止左边的高度不足封不了口,需要把从右边预填的多出的容量还回去
            // 这个条件也解决了左侧边界成单增坡计算容量的问题
			if lastLeftHeight := monotonicStack[0][HEIGHT]; lastLeftHeight < h {
				res -= (w - 1) * (h - lastLeftHeight)
			}
            // 弹出单调栈中已经处理了的元素
			monotonicStack = monotonicStack[:stackSize]
		}
        // 向单调栈中压入新的高度,记得要压入填好坑后的总宽度
		monotonicStack = append(monotonicStack, [2]int{h, w})
	}
	return
}

具体实现代码在码上掘金上。