[单调栈]42.接雨水 84.柱状图中最大的矩形(暴力法 -> 单调栈)
42.接雨水
分类:数组、栈(单调栈)、动态规划、双指针
这类题目就是遍历数组,把遍历多次才能解决的问题用辅助空间优化为只需要遍历一次就能求解。也就是先找到求解的暴力解法,然后对暴力解法做优化,所有优化措施都可以从暴力解法出发推导出来,暴力解法也能更好地帮助我们理解优化解法,所有我认为在做这类题时,暴力解法实际上才是整个问题的关键。
思路1:暴力解(所有解法的基本思想,很关键)
对于每个元素nums[i],都向它的左右两侧遍历寻找元素值 > nums[i]且是最大的两个左右边界,
每个下标i处围成的雨水单位 = min{最大左边界,最大右边界} - nums[i],
最后将这些雨水单位叠加即可。
实现代码:
class Solution {
public int trap(int[] height) {
int len = height.length;
int total = 0;
for(int i = 0; i < len; i++){
//寻找左边界:对于height[i]向左寻找>height[i]的最大边界
//int left = i - 1;
int maxLeft = height[i];
for(int left = i - 1; left >= 0; left--){
if(height[left] > maxLeft) maxLeft = height[left];
}
if(maxLeft == height[i]) continue;//没有找到最大左边界,直接跳过当前height[i]
//寻找右边界:对于height[i]向右寻找>height[i]的最大边界
//int right = i + 1;
int maxRight = height[i];
for(int right = i + 1; right < len; right++){
if(height[right] > maxRight) maxRight = height[right];
}
if(maxRight == height[i]) continue;//没有找到最大右边界,直接跳过当前height[i]
//找到height[i]的左右边界,计算围成的雨水单位
int rain = Math.min(maxLeft, maxRight) - height[i];
total += rain;
}
return total;
}
}
思路2:动态规划
基于思路1暴力解的优化,用两个辅助数组left、right把每个元素的最大左右边界保存起来,不必每次都遍历到首尾来找左右边界。
- 状态设置:left[i]表示num[i]的最大左边界,right[i]表示nums[i]的最大右边界。
- 状态转移:
left[i]的计算是基于left[i-1]而来的,right[i]的计算是基于right[i+1]而来。
所以构造left,right需要分别从前往后和从后往前遍历数组,构造left,right。
left[i] = Math.max(left[i - 1], height[i - 1]);
right[i] = Math.max(right[i + 1], height[i + 1]);
我在实现时把两个遍历过程合并为一次遍历,right数组的下标需要一定的转换,可以先写出两次遍历代码,再合并为一次遍历,不容易混乱。
合并后代码如下:
//同时构造left,right数组
for(int i = 0; i < len; i++){
if(i > 0){
left[i] = Math.max(left[i - 1], height[i - 1]);
}
if(i < len - 1 && len - i - 1 < len - 1){
right[len - i - 1] = Math.max(right[len - i - 1 + 1], height[len - i - 1 + 1]);
}
}
实现代码:
class Solution {
public int trap(int[] height) {
int len = height.length;
int total = 0;
int[] left = new int[len];
int[] right = new int[len];
//同时构造left,right数组
for(int i = 0; i < len; i++){
if(i > 0){
left[i] = Math.max(left[i - 1], height[i - 1]);
}
if(i < len - 1 && len - i - 1 < len - 1){
right[len - i - 1] = Math.max(right[len - i - 1 + 1], height[len - i - 1 + 1]);
}
}
//遍历height数组,根据预先找到的最大左右边界按列计算雨水单位
for(int i = 0; i < len; i++){
//如果最大边界小于等于第i个元素,则直接跳过
if(left[i] <= height[i] || right[i] <= height[i]) continue;
//如果最大边界>第i个元素,则取两个边界中的较小值计算第i个下标的雨水单位
else{
int rain = Math.min(left[i], right[i]) - height[i];
total += rain;
}
}
return total;
}
}
思路3:双指针法(动态规划的空间优化,最佳解法)
参考:https://leetcode-cn.com/problems/trapping-rain-water/solution/jie-yu-shui-by-leetcode/ 的评论。
基于思路2动态规划的状态转移方程可以发现,计算left[i]时只需要知道left[i - 1],计算right[j] 只需要知道right[j + 1],所以可以针对这一点做空间上的优化,用两个变量代替两个辅助数组,同时在变量状态转移的过程中就计算接到的雨水单位。
实现代码:
public int trap(int[] height) {
int len = height.length;
if(len < 3) return 0;
int total = 0;
int left = 0, right = len - 1;
int leftMax = 0, rightMax = 0;
while(left <= right){
//只需要左边界最大小于当前临时的最大右边界就能计算出第i处的雨水单位
if(leftMax <= rightMax){
total += Math.max(0, leftMax - height[left]);
leftMax = Math.max(leftMax, height[left]);
left++;
}
else{
total += Math.max(0, rightMax - height[right]);
rightMax = Math.max(rightMax, height[right]);
right--;
}
}
return total;
}
思路4:单调栈(按行计算 + 算法原理改进,推荐解法)
分析
1、要想一次遍历就计算出总的雨水单位,必定要在遍历数组每一列的同时计算出这一列能接到的雨水单位,叠加到total上。
2、举例 或 由思路3的双指针法可以发现,计算第i列的雨水单位,并不需要分别找出左右的最大边界,只需要找到大于nums[i]的左右边界,且当找到的左边界<右边界,就可以计算出第i列的雨水单位 = nums[左边界] - nums[i];(改进暴力解的算法原理)
如何用栈来实现这一过程?
从头开始遍历数组:
- 如果nums[i]<栈顶,说明当前这一列的高度小于栈顶的高度,能够接雨水,所以nums[i]入栈,此时栈里nums[i]的前一个元素就是大于nums[i]的一个左边界,只需要再判断接下来的右边界是否>nums[i]即可;
- 如果nums[i]>栈顶,说明当前这一列的高度大于栈顶的高度,当前这一列就可以作为栈顶的右边界,弹出栈顶得到nums[i-1],顶替上来的栈顶就是nums[i-1]的左边界,则雨水单位=Math.min(nums[i],顶替上来的栈顶) - nums[i-1],
- 如果nums[i]==栈顶,可以发现操作步骤和<相同。
栈的思想和我们人脑直观上解这题的步骤一样,遇到右边界,就回去找左边界,左右边界之间的就是能够接到的雨水单位;但如果每次都回去找左边界,则时间复杂度很高,所以把左边界备份起来。同时使用栈来使左右边界能够配对,什么是配对?
如图,[2,3]是配对的,[2,3]之间的[1,1]又是配对的,所以栈的实质是横向按行来计算雨水单位。
实现代码:
class Solution {
public int trap(int[] height) {
int len = height.length;
if(len < 3) return 0;
int total = 0;
int left = 0, right = len - 1;
Stack<Integer> stack = new Stack<>();//存放元素的下标
while(left < len){
//栈为空,或 height[i]<=栈顶
while(!stack.isEmpty() && height[left] > height[stack.peek()]){
int top = stack.peek();
stack.pop();
//栈里如果弹出栈顶后为空,则直接退出
if(stack.isEmpty()) break;
int distance = left - stack.peek() - 1;
int lessIndex = height[stack.peek()] < height[left] ? stack.peek() : left;
int temp = height[lessIndex] - height[top];
total += distance * temp;
}
stack.push(left);
left++;
}
return total;
}
}
84. 柱状图中最大的矩形
分类:数组、栈(单调栈)
题目分析
这题和42.接雨水很相似,暴力解法的思路也很类似,只要找到暴力解的思路,后续的解法就是对暴力解的优化。
思路1:暴力解
对每个高度都计算以当前高度为高的最大矩形面积,以第i个高度为高,向该柱子的左右两边遍历寻找 >= 该高度的最远左右边界,一旦遇到 < 当前柱子高度的柱子,就停止寻找,或直到高度数组边界。
以该柱子为高的最大矩形面积 = 柱高*左右边界距离。
例如:
[2,1,5,6,2,3],
以2为高,向左已到达数组边界,向右遇到1 < 2,停止寻找,所以以第0个柱子为高的最大矩形面积 = 2 * 1=2;
以1为高,向左可以遍历到数组边界0,向右也可以遍历到数组边界 len-1,所以以第1个柱子为高的最大矩形面积 = 1 * (len-1-0+1)=6
以此类推。
维护一个max保存计算过程中得到的最大矩形面积,在所有柱子都计算完毕后最后返回max即可。
存在的问题:效率低,做了很多重复计算
实现代码:
class Solution {
public int largestRectangleArea(int[] heights) {
if(heights == null || heights.length == 0) return 0;
int max = 0;
for(int i = 0; i < heights.length; i++){
//向左寻找最远的>=heights[i]的元素
int left = i;
while(left >= 0 && heights[left] >= heights[i]){
left--;
}
left++;//退出循环后+1才是实际找到的左边界元素下标
int right = i;
while(right < heights.length && heights[right] >= heights[i]){
right++;
}
right--;//退出循环后-1才是实际找到的右边界元素下标
max = Math.max(max, (right - left + 1) * heights[i]);
}
return max;
}
}
另一个思路:动态规划(没找到可行方法)
和42题一样也考虑动态规划,用两个数组left,right保存左右边界,避免重复计算。
但可以发现,第i个柱子希望找到的左右边界是>=第i个柱子高度且最远的元素,最
终用来计算矩形面积需要用到元素下标,但寻找满足条件的边界元素是要根据元素值。
而且寻找左边界left[i]也不能仅根据left[i-1]而来,所以不适合用动态规划,
至少不适合用类似第42题的动态规划。
思路2:单调栈 + 哨兵技巧
和42题单调栈解法的思路相同,只是出入栈的规则做一点修改。简单来说就是栈里存放的是栈顶柱子的左边界,栈外要入栈的元素可能是栈顶柱子的右边界。而且栈存放的都是下标。
维护一个栈,为了能够计算第一个入栈的柱子的最大矩形面积,初始时在栈底加入-1(哨兵),-1也可以用作判断算法是否结束的标志。
取heights数组的第i个元素:
- 如果栈只有一个栈底元素-1时,元素下标入栈;
- 如果栈不止一个栈底元素时,取栈顶下标对应的元素值和heights[i]比较:
- 如果heights[i]>=heights[栈顶],则元素下标i入栈;
- 如果heights[i]< heights[栈顶],则可以计算以heights[栈顶]为高的矩形最大面积,其中元素i下标作为矩形的右边界,栈顶的下一个元素作为矩形的左边界,最大矩形面积 = (右边界下标 - 左边界下标-1) * 高=(i下标 - 栈顶下一个元素的下标 - 1) * 栈顶对应的高。计算这个面积后,要及时将栈顶弹出,同时继续比较新的栈顶和heights[i]的大小关系,直到heights[i] >= heights[栈顶]时才退出,所以这里要使用一个while循环不断弹出栈顶,而且当退出循环时,如果i < heights.length(避免第二个哨兵-1也入栈),要记得将 i 压入栈中(这三点极易忽略:1、栈顶弹出 2、循环判断栈顶 3、待入栈元素下标i入栈)
这里的单调栈思想如果不理解可以回看42题的单调栈解法分析。
简单来说:
右边界 - 左边界 - 1;
右边界=待入栈元素的下标;
左边界=栈顶的下一个元素的下标;
如果到达heights末尾时,栈还不止一个栈底元素-1,则构造一个下标=heights.length,值 = -1的元素作为“哨兵”和栈内剩下的元素做比较,重复上面的步骤,以便将栈内所有剩余元素都出栈(除第一个哨兵外)。直到栈里只剩一个-1,且待入栈元素值也为-1,即两哨兵相遇,则算法结束。
例如:
[2,1,5,6,2,3],初始时向栈底压入-1;
nums[0]=2,因为栈内只有一个栈底元素,所以下标0直接入栈。栈=[-1,0]
nums[1]=1<nums[栈顶=0]=2,所以nums[1]的下标1就作为以当前栈顶为高的矩形的右边界,栈顶的下一个元素下标-1就是这个矩形的左边界,所以以栈顶nums[0]=2为高的最大矩形面积=(右边界-左边界)*高=(1-(-1)-1)*2=2;接着,弹出栈顶,因为此时栈里只有一个初始的栈底元素-1,所以nums[1]直接入,此时栈=[-1,1]
nums[2]=5>nums[栈顶=1]=1,所以下标2入栈,栈=[-1,1,2]
nums[3]=6>nums[栈顶=2]=5,所以下标3入栈,栈=[-1,1,2,3]
nums[4]=2<nums[栈顶=3]=6,所以nums[4]的下标4就作为以当前栈顶为高的矩形的右边界,栈顶的下一个元素下标2就是这个矩形的左边界,所以以栈顶nums[3]=6为高的最大矩形面积=(右边界-左边界)*高=(4-2-1)*6=6;接着,弹出栈顶,因为此时栈=[-1,1,2],nums[4]=2<栈顶nums[2]=5,所以还可以继续计算以nums[2]为高的矩形面积(这里记得要用while循环不断弹出>heights[i]的栈顶计算对应的矩形面积):
nums[4]=2<nums[栈顶=2]=5,所以nums[4]的下标4就作为以当前栈顶为高的矩形的右边界,栈顶的下一个元素下标1就是这个矩形的左边界,所以以栈顶nums[2]=5为高的最大矩形面积=(4-1-1)*5=10,;接着,弹出栈顶,此时栈=[-1,1],nums[4]=2>nums[栈顶=1]=1,所以下标4入栈,得栈=[-1,1,4]
nums[5]=3>nums[栈顶=4]=2,所以下标5入栈,栈=[-1,1,4,5]。
到达heights数组末尾,所以设接下来的入栈元素下标=6,元素值=-1.
nums[6]=-1<nums[栈顶=5]=3,所以nums[6]的下标6作为以当前栈顶为高的矩形的右边界,栈顶的下一个元素下标4是左边界,所以最大矩形面积=(6-4-1)*3=3;弹出栈顶,此时栈=[-1,1,4],继续拿下标=6,元素值=0作为入栈元素;
nums[6]=-1<nums[栈顶=4]=2,所以nums[6]的下标6作为当前栈顶为高的矩形的右边界,栈顶的下一个元素下标1是左边界,所以最大矩形面积=(6-1-1)*2=8;弹出栈顶,此时栈=[-1,1],继续拿下标=6,元素值=0作为入栈元素。
nums[6]=-1<nums[栈顶=1]=1,所以ums[6]的下标6作为当前栈顶为高的矩形的右边界,栈顶的下一个元素下标-1是左边界,所以最大矩形面积=(6-(-1)-1)*1=6;弹出栈顶,此时栈=[-1],当栈内只剩-1时,算法结束。
其中,思路3里在栈底预先加入的-1,和到达heights数组末尾时构造的-1元素,就是所谓的“哨兵”,
有了这两个哨兵:
- 预先加入的-1:由于它一定比输入数组里任何一个元素小,它肯定不会出栈,因此栈一定不会为空;
- 最后构造的-1:正因为它一定比输入数组里任何一个元素小,它会让所有输入数组里的元素出栈(第 1 个哨兵元素除外)。
实现代码(更好理解)
class Solution {
public int largestRectangleArea(int[] heights) {
if(heights == null || heights.length == 0) return 0;
int max = 0;
Stack<Integer> stack = new Stack<>();
stack.push(-1);
for(int i = 0; i <= heights.length; i++){
if(i == heights.length){
//数组所有元素都已入过栈且栈只剩-1时
if(stack.size() == 1) break;
//数组所有元素都已入过栈且栈剩余元素不止-1时
else{
//构造下标=heights.length,值为-1的元素
int value = -1;
while(stack.size() > 1){
int right = i;
int high = heights[stack.pop()];
int left = stack.peek();
max = Math.max(max, (right - left - 1) * high);
}
}
}
//数组还有未入栈元素时(i还未到达数组末尾)
else{
//栈内只有一个-1时,则元素入栈
if(stack.size() == 1) stack.push(i);
//栈内不止一个-1时,取栈顶与元素i比较
else{
int top = stack.peek();
if(heights[i] >= heights[top]) stack.push(i);
else{
//易错点:遇到heights[i]<heights[栈顶]时,需要不断弹出栈顶计算对应的矩形面积,直到栈底只剩-1或栈顶元素>=heights[i]
while(stack.size() > 1 && heights[i] < heights[stack.peek()]){
int right = i;//元素i下标作为右边界
int high = heights[stack.pop()];//弹出栈顶,取出对应的高
int left = stack.peek();//顶替上来的下标作为左边界
max = Math.max(max, (right - left - 1) * high);
}
//将当前元素下标入栈(易忽略!!!)
stack.push(i);
}
}
}
}
return max;
}
}
代码简化(推荐版本2)
变得不那么好理解,是基于思路3的初始代码简化而来的。
版本1:
//思路3的代码简化
class Solution {
public int largestRectangleArea(int[] heights) {
if(heights == null || heights.length == 0) return 0;
int max = 0;
Stack<Integer> stack = new Stack<>();
stack.push(-1);
for(int i = 0; i <= heights.length; i++){
if(i == heights.length && stack.size() == 1) break;
//栈内只有一个-1时,则元素入栈
if(stack.size() == 1) stack.push(i);
//栈内不止一个-1时,取栈顶与元素i比较,如果i==height.length,构造哨兵target=-1,否则取heights[i]
int target = i == heights.length ? -1 : heights[i];
int top = stack.peek();
if(target >= heights[top]) stack.push(i);
else{
//当栈顶对应的元素值 > target,不断取出栈顶作为高计算对应的矩形面积,直到target>=栈顶对应的值
while(stack.size() > 1 && target < heights[stack.peek()]){
int right = i;//元素i下标作为右边界
int high = heights[stack.pop()];//弹出栈顶,取出对应的高
int left = stack.peek();//顶替上来的下标作为左边界
max = Math.max(max, (right - left - 1) * high);
}
//将当前元素下标入栈(易忽略!!!)
if(i < heights.length) stack.push(i);//i==heights.length的哨兵是不需要入栈的
}
}
return max;
}
}
版本2:(更简洁,推荐)
//思路3的代码简化:这个版本更简洁
class Solution {
public int largestRectangleArea(int[] heights) {
if(heights == null || heights.length == 0) return 0;
Stack<Integer> stack = new Stack<>();//单调栈
stack.push(-1);//哨兵1
int max = 0, area = 0;
for(int i = 0; i <= heights.length; i++){
if(i == heights.length){//哨兵2
while(stack.size() > 1){
int right = i;
int height = heights[stack.pop()];
int left = stack.peek();
max = Math.max(max,(right - left - 1) * height);
}
}
else{
while(stack.size() > 1 && heights[stack.peek()] > heights[i]){
int right = i;
int height = heights[stack.pop()];
int left = stack.peek();
max = Math.max(max,(right - left - 1) * height);
}
//直到栈中没有大于heights[i]的元素时,将当前元素加入栈中(易忽略!!)
stack.push(i);
}
}
return max;
}
}