3.29
贪心算法
跳跃游戏 II
给定一个非负整数数组,你最初位于数组的第一个位置。
数组中的每个元素代表你在该位置可以跳跃的最大长度。
你的目标是使用最少的跳跃次数到达数组的最后一个位置。
贪心的思路,局部最优:当前可移动距离尽可能多走,如果还没到终点,步数再加一。整体最优:一步尽可能多走,从而达到最少步数。所以真正解题的时候,要从覆盖范围出发,不管怎么跳,覆盖范围内一定是可以跳到的,以最小的步数增加覆盖范围,覆盖范围一旦覆盖了终点,得到的就是最少步数!**这里需要统计两个覆盖范围,当前这一步的最大覆盖和下一步最大覆盖**。
这里还是有个特殊情况需要考虑,当移动下标达到了当前覆盖的最远距离下标时
-
如果当前覆盖最远距离下标不是是集合终点,步数就加一,还需要继续走。
-
如果当前覆盖最远距离下标就是是集合终点,步数不用加一,因为不能再往后走了。
class Solution { public int jump(int[] nums) { // 如果数组长度为0或者为null,或者长度为1(已经在最后一个位置),则不需要跳跃,返回0。 if(nums.length == 0 || nums == null || nums.length == 1){ return 0; } // res记录跳跃的次数。 int res = 0; // curDistance记录当前跳跃能到达的最远距离。 int curDistance = 0; // maxDistance记录在当前所有可选跳跃中,能够到达的最远距离。 int maxDistance = 0; // 遍历数组,但不包括最后一个元素,因为到达最后一个元素时不需要再跳跃。 for(int i = 0; i < nums.length; i++){ // 更新能够到达的最远距离。 maxDistance = Math.max(maxDistance, i + nums[i]); // 如果maxDistance已经大于等于最后一个位置的索引,表示可以跳到最后或者超过最后一个位置。 if(maxDistance >= nums.length - 1){ res++; // 增加一次跳跃 break; // 结束循环 } // 如果当前位置达到了上一次跳跃能到达的最远距离,说明需要进行新的跳跃。 if( i == curDistance ){ // 更新curDistance为当前所有跳跃中能达到的最远距离,为下一轮跳跃做准备。 curDistance = maxDistance; // 跳跃次数增加。 res++; } } return res; } }
class Solution { public int jump(int[] nums) { // 初始化跳跃次数为0 int result = 0; // 当前覆盖的最远距离下标,初始为0,表示还未开始移动 int end = 0; // 下一步覆盖的最远距离下标,用于记录在当前所有选择中能够达到的最远距离 int temp = 0; // 遍历数组中的每一个元素,直到当前覆盖的最远距离大于等于数组最后一个元素的下标 // i <= end 保证了在当前跳跃能够到达的范围内移动 for (int i = 0; i <= end && end < nums.length - 1; ++i) { // 更新下一步覆盖的最远距离 temp = Math.max(temp, i + nums[i]); // 当遍历到当前覆盖的最远距离时,说明这一跳已经尽可能远了,需要计算下一跳 if (i == end) { // 更新当前覆盖的最远距离为下一步可以覆盖的最远距离 end = temp; // 跳跃次数增加 result++; } } return result; } }
加油站
在一条环路上有 N 个加油站,其中第 i 个加油站有汽油 gas[i] 升。
你有一辆油箱容量无限的的汽车,从第 i 个加油站开往第 i+1 个加油站需要消耗汽油 cost[i] 升。你从其中的一个加油站出发,开始时油箱为空。
如果你可以绕环路行驶一周,则返回出发时加油站的编号,否则返回 -1。
首先如果总油量减去总消耗大于等于零那么一定可以跑完一圈,说明 各个站点的加油站 剩油量rest[i]相加一定是大于等于零的。
每个加油站的剩余量rest[i]为gas[i] - cost[i]。
i从0开始累加rest[i],和记为curSum,一旦curSum小于零,说明[0, i]区间都不能作为起始位置,因为这个区间选择任何一个位置作为起点,到i这里都会断油,那么起始位置从i+1算起,再从0计算curSum。
如图:
那么为什么一旦[0,i] 区间和为负数,起始位置就可以是i+1呢,i+1后面就不会出现更大的负数?
如果出现更大的负数,就是更新i,那么起始位置又变成新的i+1了。
那有没有可能 [0,i] 区间 选某一个作为起点,累加到 i这里 curSum是不会小于零呢? 如图:
如果 curSum<0 说明 区间和1 + 区间和2 < 0, 那么 假设从上图中的位置开始计数curSum不会小于0的话,就是 区间和2>0。
区间和1 + 区间和2 < 0 同时 区间和2>0,只能说明区间和1 < 0, 那么就会从假设的箭头初就开始从新选择其实位置了。
那么局部最优:当前累加rest[i]的和curSum一旦小于0,起始位置至少要是i+1,因为从i之前开始一定不行。全局最优:找到可以跑一圈的起始位置。
class Solution {
public int canCompleteCircuit(int[] gas, int[] cost) {
// curSum用于记录从某个加油站开始,当前的油量减去花费后的累计值。
int curSum = 0;
// totalSum用于记录整个环路一圈下来,所有加油站的油量减去花费的累计值。
int totalSum = 0;
// index用于记录可能的起始加油站的索引。
int index = 0;
for (int i = 0; i < gas.length; i++) {
// 更新从当前起始加油站出发的累计油量减去花费。
curSum += gas[i] - cost[i];
// 更新整个环路的累计油量减去花费。
totalSum += gas[i] - cost[i];
// 如果curSum小于0,表示从index出发到达不了加油站i+1,需要更换起始加油站。
if (curSum < 0) {
// 将起始加油站更换为下一个加油站。
index = (i + 1) % gas.length;
// curSum重置为0,因为换了新的起始加油站。
curSum = 0;
}
}
// 如果整个环路的总油量减去总花费小于0,则说明无法完成环绕一圈,返回-1。
if (totalSum < 0) return -1;
// 如果totalSum>=0,表示至少有一个解,返回可能的起始加油站的索引。
return index;
}
}
index = (i + 1) % gas.length; // 不过本题目中其实可以直接用 index = i + 1; 也是可以通过的
i
是当前加油站的索引。当你发现从某个起始加油站出发到不了加油站i+1
(因为此时curSum < 0
),说明这个起始加油站不适合作为环绕一圈的起点。
i + 1
表示下一个加油站的索引。如果当前加油站i
无法到达,那么尝试将下一个加油站作为新的起始点。
gas.length
是加油站总数,用于处理边界条件,确保索引不会超出数组范围。这是因为当你到达数组的最后一个元素并且需要更换起始加油站时,下一个起始加油站应该是数组的第一个元素,即索引0
。使用模运算% gas.length
可以确保当i + 1
等于gas.length
时,index
会被正确地设置为0
,从而实现从数组末尾跳转到数组开头的效果。将
index
设置为i + 1
的目的是,下一轮循环从新的起始点开始检测是否能完成整个环路。如果从这个新的起始点开始,你依然无法完成环路,那么curSum
将再次变为负值,这个过程会重复,直到找到一个合适的起始加油站或者遍历完成后确定不存在这样的加油站。
3.30
单调栈
每日温度
请根据每日 气温 列表,重新生成一个列表。对应位置的输出为:要想观测到更高的气温,至少需要等待的天数。如果气温在这之后都不会升高,请在该位置用 0 来代替
通常是一维数组,要寻找任一个元素的右边或者左边第一个比自己大或者小的元素的位置,此时我们就要想到可以用单调栈了。单调栈的本质是空间换时间,因为在遍历的过程中需要用一个栈来记录右边第一个比当前元素高的元素,优点是整个数组只需要遍历一次。
更直白来说,就是用一个栈来记录我们遍历过的元素,因为我们遍历数组的时候,我们不知道之前都遍历了哪些元素,以至于遍历一个元素找不到是不是之前遍历过一个更小的,所以我们需要用一个容器(这里用单调栈)来记录我们遍历过的元素。
单调栈里只需要存放元素的下标i就可以了,如果需要使用对应的元素,直接T[i]就可以获取。
-
单调栈
-
不大于栈顶的,一直加入栈中
-
遇到大于栈顶的,就计算索引,并且将栈顶弹出
-
“遇到大于栈顶的,就计算索引,并且将栈顶弹出”要用while,满足条件时一直弹出
class Solution { public int[] dailyTemperatures(int[] temperatures) { // 初始化结果数组,所有元素默认值为0。 int[] res = new int[temperatures.length]; // 使用双端队列作为栈使用,存储温度索引。 Deque<Integer> dq = new LinkedList<>(); // 遍历温度数组。 for(int i = 0; i < temperatures.length; i++){ // 如果栈不为空,并且当前温度大于栈顶温度索引对应的温度, // 则进入循环,处理所有栈中比当前温度小的元素。 while(!dq.isEmpty() && temperatures[i] > temperatures[dq.peek()]){ // 弹出栈顶元素(温度索引)。 int temp = dq.poll(); // 计算当前天与栈顶元素(之前的某一天)之间的天数差, // 并更新结果数组对应位置的值。 res[temp] = i - temp; } // 将当前天的索引压入栈中。 // 这表示对于这一天,我们还没有找到一个更热的后续日子。 dq.push(i); } // 返回结果数组,包含对于每一天,需要等待多少天才能遇到更温暖的天气。 // 如果没有更温暖的天气,该位置保持为0。 return res; } }
public int[] dailyTemperatures(int[] temperatures) { int lens=temperatures.length; int []res=new int[lens]; /** 如果当前遍历的元素 大于栈顶元素,表示 栈顶元素的 右边的最大的元素就是 当前遍历的元素, 所以弹出 栈顶元素,并记录 如果栈不空的话,还要考虑新的栈顶与当前元素的大小关系 否则的话,可以直接入栈。 注意,单调栈里 加入的元素是 下标。 */ Deque<Integer> stack=new LinkedList<>(); stack.push(0); for(int i=1;i<lens;i++){ if(temperatures[i]<=temperatures[stack.peek()]){ stack.push(i); }else{ while(!stack.isEmpty()&&temperatures[i]>temperatures[stack.peek()]){ res[stack.peek()]=i-stack.peek(); stack.pop(); } stack.push(i); } } return res; }
接雨水
给定 n 个非负整数表示每个宽度为 1 的柱子的高度图,计算按此排列的柱子,下雨之后能接多少雨水。
输入:height = [0,1,0,2,1,0,1,3,2,1,2,1]
输出:6
解释:上面是由数组 [0,1,0,2,1,0,1,3,2,1,2,1] 表示的高度图,在这种情况下,可以接 6 个单位的雨水(蓝色部分表示雨水)。
暴力解法【会超时】
按深度计算,遍历每个位置,计算“左右两端最高的柱子中“低的那一个,再减去当前位置的高度,就是当前位置纵向可存储的雨水量
class Solution { public int trap(int[] height) { // 初始化总积水量为0 int sum = 0; // 遍历每个柱子 for (int i = 0; i < height.length; i++) { // 第一个柱子和最后一个柱子不接雨水,因为它们没有“边界” if (i==0 || i== height.length - 1) continue; // 初始化当前位置右侧最高柱子的高度为当前高度 int rHeight = height[i]; // 初始化当前位置左侧最高柱子的高度为当前高度 int lHeight = height[i]; // 向右遍历,找到右侧最高的柱子 for (int r = i+1; r < height.length; r++) { if (height[r] > rHeight) rHeight = height[r]; } // 向左遍历,找到左侧最高的柱子 for (int l = i-1; l >= 0; l--) { if(height[l] > lHeight) lHeight = height[l]; } // 计算当前柱子上方可以积水的高度,为左右两侧最高柱子的较小值减去当前柱子的高度 int h = Math.min(lHeight, rHeight) - height[i]; // 如果可以积水(高度大于0),累加到总积水量中 if (h > 0) sum += h; } // 返回总积水量 return sum; } }
双指针
-
双指针,统计两次,统计出每个位置的左右最大柱子高度
-
注意
int count = Math.min(maxLeft[i], maxRight[i]) - height[i];
没有问题,因为即使这样也满足条件
只要计算左右最大高度,取其中小的那一个,就是不会溢出的高度;再减去当前高度,就是当前位置纵向的储水量
class Solution { public int trap(int[] height) { int len = height.length; // 如果数组长度小于2,不能形成凹槽积水,直接返回0。 if(len < 2) return 0; // 初始化两个数组,分别用于存储每个位置左侧和右侧的最大高度。 int[] maxLeft = new int[len]; int[] maxRight = new int[len]; // 第一个位置的左侧最大高度就是它自己。 maxLeft[0] = height[0]; // 从左向右遍历数组,更新每个位置左侧的最大高度。 for(int i = 1; i < len; i++){ maxLeft[i] = Math.max(height[i], maxLeft[i - 1]); } // 最后一个位置的右侧最大高度就是它自己。 maxRight[len - 1] = height[len - 1]; // 从右向左遍历数组,更新每个位置右侧的最大高度。 for(int i = len - 2; i >= 0; i--){ maxRight[i] = Math.max(height[i], maxRight[i + 1]); } int res = 0; // 遍历每个位置,使用左侧和右侧最大高度中较小的一个,减去当前高度,计算积水量。 for(int i = 0; i < len; i++){ // 计算当前位置上方可以积累的雨水量。 int count = Math.min(maxLeft[i], maxRight[i]) - height[i]; // 如果积水量大于0,则累加到总量中。 if(count > 0){ res += count; } } return res; } }
单调栈
-
将一个栈作为单调栈
-
初始化第一个放0,因为第一个位置不存储雨水
-
向后遍历,如果小于栈顶元素,就加入,从而保持栈内是递减的
-
如果与栈顶元素的高度相同,则更新栈顶元素
-
如果大于栈顶元素,则栈顶元素作为中间,栈顶向下一个元素作为左边,当前元素作为右边,求一个横向的面积
-
依次遍历求总和,然后把众多横向计算的雨水累加起来
class Solution { public int trap(int[] height) { // 初始化变量 int len = height.length; if(len <= 2){ return 0; // 如果数组长度小于等于2,无法接到雨水,直接返回0 } Stack<Integer> stack = new Stack<>(); // 创建一个单调栈 stack.push(0); // 将数组第一个位置放入栈中,因为第一个位置不存储雨水 int res = 0; // 初始化结果变量,用于累加接到的雨水 // 遍历数组 for(int i = 1; i < len; i++){ int top = stack.peek(); // 获取栈顶元素的下标 if(height[i] < height[top]){ // 如果当前高度小于栈顶元素的高度 stack.push(i); // 将当前下标入栈,保持栈内是递减的 }else if(height[i] == height[top]){ // 如果当前高度与栈顶元素相等 stack.pop(); // 弹出栈顶元素 stack.push(i); // 将当前下标入栈,相当于更新栈顶元素的位置 }else{ // 如果当前高度大于栈顶元素的高度 int curHeight = height[i]; // 当前高度 while(!stack.isEmpty() && curHeight > height[top]){ // 循环直到栈为空或者当前高度小于等于栈顶元素的高度 int mid = stack.pop(); // 弹出栈顶元素,作为中间位置 if(!stack.isEmpty()){ // 如果栈不为空 int left = stack.peek(); // 获取新的栈顶元素,作为左边界 int w = i - left - 1; // 计算宽度 int h = Math.min(height[left],curHeight) - height[mid]; // 计算高度 res += w * h; // 计算面积并累加到结果中 top = stack.peek(); // 更新栈顶元素的位置 } } stack.push(i); // 将当前下标入栈 } } return res; // 返回结果 } }
class Solution { public int trap(int[] height) { int len = height.length; if(len <= 2){ return 0; } Stack<Integer> stack = new Stack<>(); stack.push(0); int res = 0; for(int i = 1; i < len; i++){ while(!stack.isEmpty() && height[i] > height[stack.peek()]){ int mid = stack.pop(); if(!stack.isEmpty()){ int left = stack.peek(); // 重命名变量以避免与数组名称冲突 int waterHeight = Math.min(height[left], height[i]) - height[mid]; int width = i - left - 1; res += waterHeight * width; } // 这里不需要重新赋值stack.peek()给top,因为top变量已经删除 } if(!stack.isEmpty() && height[i] == height[stack.peek()]){ stack.pop(); } stack.push(i); } return res; } }
class Solution { public int trap(int[] height) { // 检查输入数组长度,如果小于或等于2,无法形成凹槽,因此不会积水。 int len = height.length; if(len <= 2){ return 0; } // 使用栈来存储柱子的索引,栈中的索引对应的柱子高度是单调递减的。 Stack<Integer> stack = new Stack<>(); // 初始时,把第一个柱子的索引放入栈中。 stack.push(0); int res = 0; // 用来累计所有位置的积水量。 for(int i = 1; i < len; i++){ // 循环检查当前柱子与栈顶柱子的高度。 while(!stack.isEmpty() && height[i] > height[stack.peek()]){ // 如果当前柱子高于栈顶柱子,表示可能会有积水。 int mid = stack.pop(); // 栈顶柱子作为积水的最低点。 if(!stack.isEmpty()){ // 确保还有左边的边界。 int left = stack.peek(); // 栈顶的下一个元素作为积水的左边界。 // 计算宽度和高度。 int w = i - left - 1; // 积水宽度为右边界(当前柱子)和左边界之间的距离减一。 int h = Math.min(height[left], height[i]) - height[mid]; // 积水的高度为左右边界高度的较小值减去最低点的高度。 // 计算当前凹槽的积水量,并累加到总量中。 res += w * h; } } // 如果当前柱子的高度不低于栈顶柱子或与栈顶柱子相等,则将当前柱子索引入栈。 stack.push(i); } return res; // 返回总的积水量。 } }
注意细节
-
while(!stack.isEmpty() && curHeight > height[top]){
int mid = stack.pop();
// 不要忘记这一层判断单调栈是否为空,这会影响是否左边元素
if(!stack.isEmpty()){
-
// 注意需要更新top
top = stack.peek();
-
一定要注意定义的变量名不要与其他数组名等重复
-
注意,当前元素大于栈顶元素,while循环弹出更新面积后,要把当前元素在压入栈中
柱状图中最大的矩形
给定 n 个非负整数,用来表示柱状图中各个柱子的高度。每个柱子彼此相邻,且宽度为 1 。
求在该柱状图中,能够勾勒出来的矩形的最大面积。
单调栈完全版
class Solution { int largestRectangleArea(int[] heights) { Stack<Integer> st = new Stack<Integer>(); // 数组扩容,在头和尾各加入一个元素 int [] newHeights = new int[heights.length + 2]; newHeights[0] = 0; newHeights[newHeights.length - 1] = 0; for (int index = 0; index < heights.length; index++){ newHeights[index + 1] = heights[index]; } heights = newHeights; st.push(0); int result = 0; // 第一个元素已经入栈,从下标1开始 for (int i = 1; i < heights.length; i++) { // 注意heights[i] 是和heights[st.top()] 比较 ,st.top()是下标 if (heights[i] > heights[st.peek()]) { st.push(i); } else if (heights[i] == heights[st.peek()]) { st.pop(); // 这个可以加,可以不加,效果一样,思路不同 st.push(i); } else { while (heights[i] < heights[st.peek()]) { // 注意是while int mid = st.peek(); st.pop(); int left = st.peek(); int right = i; int w = right - left - 1; int h = heights[mid]; result = Math.max(result, w * h); } st.push(i); } } return result; } }
扩展数组:在原数组的头部和尾部各添加一个高度为0的柱子。这样做的目的是为了保证栈中所有剩余的柱子都能被处理,无论它们的高度如何。
初始化栈:栈用来存储柱子的索引,初始时只包含扩展后数组的第一个元素(即高度为0的虚拟柱子)。
遍历:从扩展数组的第二个元素开始遍历,对于每个元素:
如果当前柱子的高度大于栈顶索引对应的柱子的高度,将当前索引直接压入栈。
如果当前柱子的高度等于栈顶索引对应的柱子的高度,先弹出栈顶(可选操作),再将当前索引压入栈。
如果当前柱子的高度小于栈顶索引对应的柱子的高度,不断弹出栈顶元素,并对每个弹出的元素计算它能够形成的最大矩形面积,直到栈顶元素对应的高度小于或等于当前柱子的高度。
计算面积:对于每个弹出的栈顶元素,其左边界是当前栈顶的索引,右边界是当前遍历到的柱子的索引。因此,宽度为
right - left - 1
。高度是栈顶元素对应的柱子的高度。计算这个矩形的面积,并更新最大面积。
分析
[1,3,2,3]
以
[1,3,2,3]
为例,扩展后的数组为[0,1,3,2,3,0]
。
初始栈为
[0]
,代表高度为0的虚拟柱子。i=1 (
heights[1]=1
):压入栈,栈为[0, 1]
。i=2 (
heights[2]=3
):压入栈,栈为[0, 1, 2]
。i=3 (
heights[3]=2
):栈顶2
对应高度3
大于2
,弹出2
,计算面积:宽度3-1-1=1
,高度3
,面积3
。继续比较,栈顶1
对应高度1
小于2
,压入3
,栈为[0, 1, 3]
。i=4 (
heights[4]=3
):压入栈,栈为[0, 1, 3, 4]
。i=5 (
heights[5]=0
):栈顶4
对应高度3
大于0
,依次弹出4
,3
,1
,并计算面积:
弹出
4
时:宽度5-3-1=1
,高度3
,面积3
。弹出
3
时:宽度5-1-1=3
,高度2
,面积6
。弹出
1
时:宽度5-0-1=4
,高度1
,面积4
。最大面积在弹出
3
时计算得到,为6
。因此,对于[1,3,2,3]
,最大矩形面积为6
。
单调栈简化版1
public int largestRectangleArea(int[] heights) {
// 扩展原始的heights数组,在前后各添加一个高度为0的柱子
int[] newHeight = new int[heights.length + 2];
System.arraycopy(heights, 0, newHeight, 1, heights.length);
newHeight[heights.length + 1] = 0;
newHeight[0] = 0;
// 使用栈来存储柱子的索引,栈中的柱子索引对应的高度是非递减的
Stack<Integer> stack = new Stack<>();
stack.push(0); // 初始时栈中放入一个虚拟的索引0,对应高度为0的柱子
int res = 0; // 用于记录最大的矩形面积
// 从左到右遍历扩展后的柱状图
for (int i = 1; i < newHeight.length; i++) {
// 当当前柱子的高度小于栈顶柱子的高度时,说明找到了右边界
while (newHeight[i] < newHeight[stack.peek()]) {
int mid = stack.pop(); // 弹出栈顶元素,该元素为需要计算面积的柱子的索引
int w = i - stack.peek() - 1; // 计算宽度:当前索引减去新的栈顶索引减1
int h = newHeight[mid]; // 高度为弹出元素对应的柱子高度
res = Math.max(res, w * h); // 更新最大矩形面积
}
stack.push(i); // 将当前索引压入栈中
}
return res; // 返回计算得到的最大矩形面积
}
分析给定输入
[1,3,2,3]
首先,原始数组
[1,3,2,3]
被扩展为[0,1,3,2,3,0]
。初始化栈
stack
并压入索引0
。开始遍历扩展后的数组,从索引
1
开始。
i=1:
newHeight[1]=1
,当前高度大于栈顶高度0
,将1
压入栈。i=2:
newHeight[2]=3
,当前高度大于栈顶高度1
,将2
压入栈。i=3:
newHeight[3]=2
,当前高度小于栈顶高度3,开始弹出栈顶并计算面积:
弹出
2
,w=3-1-1=1
,h=3
,面积为3
,更新res=3
。现在栈顶是
1
,其高度1
小于当前高度2
,停止弹栈,将3
压入栈。i=4:
newHeight[4]=3
,当前高度等于栈顶高度2
,直接将4
压入栈。i=5
newHeight[5]=0
,当前高度小于栈顶高度3,开始弹出栈顶并计算面积:
弹出
4
,w=5-3-1=1
,h=3
,面积为3
,res
保持不变。弹出
3
,w=5-1-1=3
,h=2
,面积为6
,更新res=6
。弹出
1
,w=5-0-1=4
,h=1
,面积为4
,res
保持为6
。
单调栈简化版2
class Solution { public int largestRectangleArea(int[] heights) { int result = 0; // 用于存储和更新最大矩形面积 int left = 0; // 用于记录当前计算的矩形的左边界 Deque<Integer> stack = new ArrayDeque<>(); // 使用双端队列作为栈,存储柱状图中柱子的索引 stack.offerFirst(0); // 将第一个柱子的索引压入栈顶 // 遍历所有柱子,包括一个虚拟的位于末尾的高度为0的柱子,以便清空栈 for(int i = 1; i <= heights.length; i++){ // 如果遍历到最后一个虚拟柱子,则高度设为0,否则使用当前柱子的高度 int current = i == heights.length ? 0 : heights[i]; // 当栈不为空,且当前柱子的高度小于栈顶柱子的高度时,执行循环 while(!stack.isEmpty() && heights[stack.peekFirst()] > current){ // 弹出栈顶元素,该元素索引对应的柱子高度即为需要计算面积的高度 int index = stack.pollFirst(); int preHeight = heights[index]; // 获取弹出元素对应的高度 // 计算当前弹出元素的左边界。如果栈为空,说明当前弹出的是最低的,左边界为0;否则,左边界为栈顶元素的索引加1 left = stack.isEmpty() ? 0 : stack.peekFirst() + 1; // 更新最大面积。计算方式为:当前弹出的高度乘以(当前索引 - 左边界) result = Math.max(result, preHeight * (i - left)); } // 将当前柱子的索引压入栈中 stack.offerFirst(i); } // 返回计算出的最大矩形面积 return result; } }
对于输入
[1,3,2,3]
的情况,让我们同样手动跟踪算法的执行流程来验证它是否能够正确计算最大矩形面积。
初始化:
result = 0
,stack
初始化为空。第一个柱子(高度为 1):
栈为空,所以我们将索引
0
压入栈。此时栈为[0]
。第二个柱子(高度为 3):
当前柱子高度大于栈顶索引对应的柱子高度(1 < 3),直接将索引
1
压入栈。此时栈为[0, 1]
。第三个柱子(高度为 2):
当前柱子高度小于栈顶索引对应的柱子高度(3 > 2),所以我们开始计算以栈顶柱子为高度的矩形面积。
弹出栈顶元素(索引
1
),计算以高度3
的矩形面积。此时左边界为0 + 1 = 1
,宽度为2 - 1 = 1
(当前索引2
减去左边界)。面积为3 * 1 = 3
,更新result
为3
。将当前柱子索引
2
压入栈。此时栈为[0, 2]
。第四个柱子(高度为 3):
当前柱子高度等于栈顶索引对应的柱子高度(2 = 3),直接将索引
3
压入栈。此时栈为[0, 2, 3]
。遍历结束,添加虚拟柱子(高度为 0)以清空栈:
依次弹出处理。
弹出索引
3
,左边界为2 + 1 = 3
,宽度为4 - 3 = 1
,面积为3 * 1 = 3
。弹出索引
2
,左边界为0 + 1 = 1
,宽度为4 - 1 = 3
,面积为2 * 3 = 6
,更新result
为6
。弹出索引
0
,此时栈为空,左边界为0
,宽度为4 - 0 = 4
,面积为1 * 4 = 4
,result
保持为6
。最终,对于输入
[1,3,2,3]
,该算法正确地计算出最大矩形面积为6
。这个面积来自于第二和第四柱子之间(含第二和第四柱子本身)形成的矩形,其高度为2
(由第三柱子的高度决定),宽度为3
(从第二柱子到第四柱子,包括两端),因此面积为2 * 3 = 6
。这个例子展示了算法如何正确处理高度变化,并确保找到了最大的矩形面积。
暴力解法【会超时】
class Solution { public int largestRectangleArea(int[] heights) { int length = heights.length; // 初始化两个数组,用于记录每个柱子左侧和右侧第一个小于该柱子高度的柱子的索引 int[] minLeftIndex = new int[length]; int[] minRightIndex = new int[length]; // 对于数组的第一个元素,左侧没有其他柱子,所以初始化为-1 minLeftIndex[0] = -1; // 从第二个柱子开始遍历,寻找每个柱子左侧第一个小于它的柱子的索引 for (int i = 1; i < length; i++) { int t = i - 1; // 循环向左遍历,直到找到一个小于当前柱子高度的柱子 // 或者没有更多的柱子可以遍历(即t变为-1) while (t >= 0 && heights[t] >= heights[i]) { // 如果当前遍历到的柱子不是小于当前柱子的,更新t为该柱子左侧第一个小于它的柱子的索引 // 这里利用已经计算好的minLeftIndex数组来减少遍历的次数 t = minLeftIndex[t]; } // 记录找到的索引 minLeftIndex[i] = t; } // 对于数组的最后一个元素,右侧没有其他柱子,所以初始化为length(视为一个虚拟的右边界) minRightIndex[length - 1] = length; // 从倒数第二个柱子开始向前遍历,寻找每个柱子右侧第一个小于它的柱子的索引 for (int i = length - 2; i >= 0; i--) { int t = i + 1; // 循环向右遍历,直到找到一个小于当前柱子高度的柱子 // 或者没有更多的柱子可以遍历(即t变为length) while (t < length && heights[t] >= heights[i]) { // 如果当前遍历到的柱子不是小于当前柱子的,更新t为该柱子右侧第一个小于它的柱子的索引 // 这里利用已经计算好的minRightIndex数组来减少遍历的次数 t = minRightIndex[t]; } // 记录找到的索引 minRightIndex[i] = t; } // 计算最大矩形面积 int result = 0; for (int i = 0; i < length; i++) { // 对于每个柱子,其可以形成的最大矩形面积为当前柱子的高度乘以宽度 // 宽度计算为右边界索引减去左边界索引再减去1 int sum = heights[i] * (minRightIndex[i] - minLeftIndex[i] - 1); // 更新最大面积 result = Math.max(sum, result); } return result; } }
为什么这是全局最大面积而非局部?
左右边界的计算方式:对于每个柱子,通过向左和向右搜索第一个高度小于它的柱子,我们实际上确定了以该柱子高度为矩形高度时,这个矩形能达到的最大宽度。这意味着,对于每个柱子,我们都计算了以它为高的最大可能矩形面积。
全局最大面积的更新:通过比较所有柱子计算得到的面积,我们可以确保找到了全局最大的面积。每次计算得到一个新的面积时,都会与当前记录的最大面积进行比较,并且更新最大面积(如果当前计算的面积更大)。
注意细节
-
左右边界的计算方式:对于每个柱子,通过向左和向右搜索第一个高度小于它的柱子,我们实际上确定了以该柱子高度为矩形高度时,这个矩形能达到的最大宽度。这意味着,对于每个柱子,我们都计算了以它为高的最大可能矩形面积。
-
单调栈得方法,会保持栈中元素(也就是下标)对应的高度是升序排列,每遇到一个后面得柱体小于前面的柱体的话,就去掉钱前面高得柱体并且计算面积。因为右边的比当前栈顶低,同时右边的索引又已经固定index,所以可以当前栈顶高度计算面积的话就只计算现在位置往后的。最后形成的单调栈,映射的高度从栈顶到栈顶递增,所以从最后面依次往前弹出并计算,做最终处理
-
当当前柱子的高度大于栈顶元素对应的高度时,直接将其索引压入栈。这是因为我们还没找到这个高度右边第一个小于它的柱子的索引。
-
当当前柱子的高度小于栈顶元素对应的高度时,说明我们找到了栈顶元素右边第一个高度小于它的柱子的索引,此时可以开始计算以栈顶元素对应高度为高的矩形的最大面积了。弹出栈顶元素,计算以该元素高度为高的矩形面积,重复此过程直到当前柱子的高度不再小于栈顶元素对应的高度,然后将当前柱子的索引压入栈。
这个过程保证了:
-
右边界的确定:当弹出栈顶元素时,意味着当前遍历到的柱子的索引就是弹出元素右边第一个高度小于它的柱子的索引。
-
左边界的确定:弹出栈顶元素后,新的栈顶元素对应的柱子的索引就是弹出元素左边第一个高度小于它的柱子的索引。
通过这种方式,每当弹出一个栈顶元素,我们就可以确定一个矩形的左右边界和高度,从而计算出这个矩形的面积,并与当前最大面积比较更新。
-
-
注意单调栈中存储的是下标
-
遇到需要计算的情况时,是先弹栈,再计算下标,这样,
i - stack.peek() - 1
中,i是右起点,stack.peek()是左起点但是并非需要计算的部分,所以宽度是 right - left - 1;比如 左低,中高,右低,3个 >> 弄出来就是 3 - 1 - 1,(3 - 1)是宽度加上了right这个位置,所以要再减1