提示:DDU,供自己复习使用。欢迎大家前来讨论~
文章目录
- 题目
- 题目一:42. 接雨水
- 解题思路:
- 暴力解法
- 双指针优化
- 思路:
- 单调栈解法
- 单调栈处理逻辑
- 题目二: 84.柱状图中最大的矩形
- 解题思路:
- 暴力解法
- 双指针解法
- 单调栈
- 为什么要在开头加入元素0?
- 特殊情况下的栈行为:
- 总结
单调栈Part02
题目
题目一:42. 接雨水
42. 接雨水
解题思路:
接雨水问题在面试中还是常见题目的,有必要好好讲一讲。
本文深度讲解如下三种方法:
- 暴力解法
- 双指针优化
- 单调栈
暴力解法
本题暴力解法也是也是使用双指针。
首先要明确,要按照行来计算,还是按照列来计算。
按照行来计算如图:
按照列来计算如图:
一些同学在实现的时候,很容易一会按照行来计算一会按照列来计算,这样就会越写越乱。
我个人倾向于按照列来计算,比较容易理解,接下来看一下按照列如何计算。
首先,如果按照列来计算的话,宽度一定是1了,我们再把每一列的雨水的高度求出来就可以了。
可以看出每一列雨水的高度,取决于,该列 左侧最高的柱子和右侧最高的柱子中最矮的那个柱子的高度。
这句话可以有点绕,来举一个理解,例如求列4的雨水高度,如图:
列4 左侧最高的柱子是列3,高度为2(以下用lHeight表示)。
列4 右侧最高的柱子是列7,高度为3(以下用rHeight表示)。
列4 柱子的高度为1(以下用height表示)
那么列4的雨水高度为 列3和列7的高度最小值减列4高度,即: min(lHeight, rHeight) - height。
列4的雨水高度求出来了,宽度为1,相乘就是列4的雨水体积了。
此时求出了列4的雨水体积。
一样的方法,只要从头遍历一遍所有的列,然后求出每一列雨水的体积,相加之后就是总雨水的体积了。
首先从头遍历所有的列,并且要注意第一个柱子和最后一个柱子不接雨水,代码如下:
for (int i = 0; i < height.size(); i++) {
// 第一个柱子和最后一个柱子不接雨水
if (i == 0 || i == height.size() - 1) continue;
}
在for循环中求左右两边最高柱子,代码如下:
int rHeight = height[i]; // 记录右边柱子的最高高度
int lHeight = height[i]; // 记录左边柱子的最高高度
for (int r = i + 1; r < height.size(); 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 = min(lHeight, rHeight) - height[i];
if (h > 0) sum += h; // 注意只有h大于零的时候,在统计到总和中
整体代码如下:
class Solution {
public:
int trap(vector<int>& height) {
int sum = 0;
for (int i = 0; i < height.size(); i++) {
// 第一个柱子和最后一个柱子不接雨水
if (i == 0 || i == height.size() - 1) continue;
int rHeight = height[i]; // 记录右边柱子的最高高度
int lHeight = height[i]; // 记录左边柱子的最高高度
for (int r = i + 1; r < height.size(); 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 = min(lHeight, rHeight) - height[i];
if (h > 0) sum += h;
}
return sum;
}
};
因为每次遍历列的时候,还要向两边寻找最高的列,所以时间复杂度为O(n^2),空间复杂度为O(1)。
双指针优化
思路:
- 初始化两个指针:
left
指针置于数组的起始位置。right
指针置于数组的结束位置。
- 初始化两个变量记录最大高度:
leftMax
用于存储从左侧开始到当前位置的左侧最大高度。rightMax
用于存储从右侧开始到当前位置的右侧最大高度。
- 填充
maxLeft
和maxRight
数组:maxLeft
数组:从左到右遍历数组,对于每个位置,更新其值为该位置及左侧所有位置中的最大值。这个数组在遍历过程中构建。maxRight
数组:从右到左遍历数组,对于每个位置,更新其值为该位置及右侧所有位置中的最大值。这个数组在遍历过程中构建。
- 计算雨水量:
- 遍历数组中的每个位置,使用
min(maxLeft[i], maxRight[i]) - height[i]
来计算该位置能接的雨水量。这里,min
函数确保了我们使用的是两侧较小的最大高度,因为雨水的存储高度受限于较矮的一侧。 - 将每个位置计算出的雨水量累加,得到总雨水量。
- 遍历数组中的每个位置,使用
- 优化点:
- 避免了对每个位置重复计算两侧最大高度的需要,因为这些值已经在构建
maxLeft
和maxRight
数组时计算并存储了。 - 只需要一次遍历即可构建这两个数组,然后再次遍历计算雨水量,总体时间复杂度为 O(n)。
- 避免了对每个位置重复计算两侧最大高度的需要,因为这些值已经在构建
下面给出一个例子:
假设我们有以下柱子的高度数组:
height = [0, 1, 0, 2, 1, 0, 1, 3, 2, 1, 2, 1]
我们的目标是计算在这些柱子下面能接多少雨水。
步骤 1: 计算 maxLeft 数组
maxLeft 数组存储了每个位置左侧的最大高度。
maxLeft[0] = 0:第一根柱子的高度。
maxLeft[1] = max(0, 1) = 1:第二根柱子的高度大于第一根,所以更新为1。
maxLeft[2] = max(1, 0) = 1:第三根柱子的高度不大于第二根,保持为1。
maxLeft[3] = max(1, 2) = 2:第四根柱子的高度大于前两根,更新为2。
maxLeft[4] = max(2, 1) = 2:第五根柱子的高度不大于第四根,保持为2。
以此类推,直到数组末尾。
最终的 maxLeft 数组为:
maxLeft = [0, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 3]
步骤 2: 计算 maxRight 数组
maxRight 数组存储了每个位置右侧的最大高度。
maxRight[height.length - 1] = 1:最后一根柱子的高度。
maxRight[10] = max(1, 2) = 2:倒数第二根柱子的高度大于倒数第三根,所以更新为2。
maxRight[9] = max(2, 1) = 2:倒数第三根柱子的高度不大于倒数第二根,保持为2。
maxRight[8] = max(2, 3) = 3:倒数第四根柱子的高度大于后面两根,更新为3。
以此类推,直到数组开始。
最终的 maxRight 数组为:
maxRight = [1, 1, 1, 2, 2, 2, 3, 3, 3, 2, 2, 1]
步骤 3: 计算雨水量
现在我们有了 maxLeft 和 maxRight 数组,我们可以计算每个位置能接的雨水量。雨水量由该位置的柱子高度和左右两侧最大高度决定:
对于每个位置 i,雨水量 water[i] = min(maxLeft[i], maxRight[i]) - height[i]。
具体计算过程
water[0] = min(0, 1) - 0 = 0
water[1] = min(1, 1) - 1 = 0
water[2] = min(1, 1) - 0 = 1
water[3] = min(2, 2) - 2 = 0
water[4] = min(2, 2) - 1 = 1
water[5] = min(2, 2) - 0 = 2
water[6] = min(2, 3) - 1 = 1
water[7] = min(3, 3) - 3 = 0
water[8] = min(3, 3) - 2 = 1
water[9] = min(3, 2) - 1 = 1
water[10] = min(3, 2) - 2 = 0
water[11] = min(3, 1) - 1 = 1
累加所有的 water[i] 得到总的雨水量。
总结
通过双指针优化解法,我们只需要两次遍历(一次填充 maxLeft,一次填充 maxRight),再加上一次遍历来计算雨水量,总共需要 O(n) 的时间复杂度。这种方法避免了暴力解法中的重复计算,大大提高了效率。
代码如下:
class Solution {
public:
int trap(vector<int>& height) {
if (height.size() <= 2) return 0;
vector<int> maxLeft(height.size(), 0);
vector<int> maxRight(height.size(), 0);
int size = maxRight.size();
// 记录每个柱子左边柱子最大高度
maxLeft[0] = height[0];
for (int i = 1; i < size; i++) {
maxLeft[i] = max(height[i], maxLeft[i - 1]);
}
// 记录每个柱子右边柱子最大高度
maxRight[size - 1] = height[size - 1];
for (int i = size - 2; i >= 0; i--) {
maxRight[i] = max(height[i], maxRight[i + 1]);
}
// 求和
int sum = 0;
for (int i = 0; i < size; i++) {
int count = min(maxLeft[i], maxRight[i]) - height[i];
if (count > 0) sum += count;
}
return sum;
}
};
单调栈解法
- 栈的初始化:定义一个空栈
st
,用来存储柱子的索引。将第一个柱子的索引(0)压入栈中。 - 遍历柱子:从第二个柱子开始遍历,直到最后一个柱子。
- 情况一:如果当前柱子的高度小于栈顶柱子的高度,说明当前柱子在栈顶柱子形成的“凹槽”内。这时,将当前柱子的索引压入栈中。
- 情况二:如果当前柱子的高度等于栈顶柱子的高度,这意味着栈顶柱子不是凹槽的底部。因此,我们弹出栈顶柱子,将当前柱子的索引压入栈中。
- 情况三:如果当前柱子的高度大于栈顶柱子的高度,这意味着我们找到了一个可能的凹槽的右边界。我们继续弹出所有比当前柱子矮的栈内柱子,直到栈顶柱子的高度小于当前柱子的高度或栈为空。对于每个弹出的柱子,我们计算它和它左侧最高柱子(栈顶柱子)之间的凹槽能接多少雨水,并将这个值累加到
sum
中。 - 雨水量的计算:雨水量由凹槽的宽度(当前柱子索引减去栈顶柱子索引再减去1)和高度(栈顶柱子的高度和当前柱子的高度中的较小值减去弹出柱子的高度)决定。
- 循环结束:继续遍历直到所有柱子都被处理。
那么本题使用单调栈有如下几个问题:
- 首先单调栈是按照行方向来计算雨水,如图:
知道这一点,后面的就可以理解了。
- 使用单调栈内元素的顺序
从大到小还是从小到大呢?
从栈头(元素从栈头弹出)到栈底的顺序应该是从小到大的顺序。
因为一旦发现添加的柱子高度大于栈头元素了,此时就出现凹槽了,栈头元素就是凹槽底部的柱子,栈头第二个元素就是凹槽左边的柱子,而添加的元素就是凹槽右边的柱子。
如图:
- 遇到相同高度的柱子怎么办。
遇到相同的元素,更新栈内下标,就是将栈里元素(旧下标)弹出,将新元素(新下标)加入栈中。
例如 5 5 1 3 这种情况。如果添加第二个5的时候就应该将第一个5的下标弹出,把第二个5添加到栈中。
因为我们要求宽度的时候 如果遇到相同高度的柱子,需要使用最右边的柱子来计算宽度。
如图所示:
- 栈里要保存什么数值
使用单调栈,也是通过 长 * 宽 来计算雨水面积的。
长就是通过柱子的高度来计算,宽是通过柱子之间的下标来计算,
那么栈里有没有必要存一个pair<int, int>类型的元素,保存柱子的高度和下标呢。
其实不用,栈里就存放下标就行,想要知道对应的高度,通过height[stack.top()] 就知道弹出的下标对应的高度了。
所以栈的定义如下:
stack<int> st; // 存着下标,计算的时候用下标对应的柱子高度
明确了如上几点,我们再来看处理逻辑。
单调栈处理逻辑
以下逻辑主要就是三种情况
- 情况一:当前遍历的元素(柱子)高度小于栈顶元素的高度 height[i] < height[st.top()]
- 情况二:当前遍历的元素(柱子)高度等于栈顶元素的高度 height[i] == height[st.top()]
- 情况三:当前遍历的元素(柱子)高度大于栈顶元素的高度 height[i] > height[st.top()]
先将下标0的柱子加入到栈中,st.push(0);
。 栈中存放我们遍历过的元素,所以先将下标0加进来。
然后开始从下标1开始遍历所有的柱子,for (int i = 1; i < height.size(); i++)
。
如果当前遍历的元素(柱子)高度小于栈顶元素的高度,就把这个元素加入栈中,因为栈里本来就要保持从小到大的顺序(从栈头到栈底)。
代码如下:
if (height[i] < height[st.top()]) st.push(i);
如果当前遍历的元素(柱子)高度等于栈顶元素的高度,要跟更新栈顶元素,因为遇到相相同高度的柱子,需要使用最右边的柱子来计算宽度。
代码如下:
if (height[i] == height[st.top()]) { // 例如 5 5 1 7 这种情况
st.pop();
st.push(i);
}
如果当前遍历的元素(柱子)高度大于栈顶元素的高度,此时就出现凹槽了,如图所示:
取栈顶元素,将栈顶元素弹出,这个就是凹槽的底部,也就是中间位置,下标记为mid,对应的高度为height[mid](就是图中的高度1)。
此时的栈顶元素st.top(),就是凹槽的左边位置,下标为st.top(),对应的高度为height[st.top()](就是图中的高度2)。
当前遍历的元素i,就是凹槽右边的位置,下标为i,对应的高度为height[i](就是图中的高度3)。
此时就是栈顶和栈顶的下一个元素以及要入栈的元素,三个元素来接水!
那么雨水高度是 min(凹槽左边高度, 凹槽右边高度) - 凹槽底部高度,代码为:int h = min(height[st.top()], height[i]) - height[mid];
雨水的宽度是 凹槽右边的下标 - 凹槽左边的下标 - 1(因为只求中间宽度),代码为:int w = i - st.top() - 1 ;
当前凹槽雨水的体积就是:h * w
。
求当前凹槽雨水的体积代码如下:
while (!st.empty() && height[i] > height[st.top()]) { // 注意这里是while,持续跟新栈顶元素
int mid = st.top();
st.pop();
if (!st.empty()) {
int h = min(height[st.top()], height[i]) - height[mid];
int w = i - st.top() - 1; // 注意减一,只求中间宽度
sum += h * w;
}
}
关键部分讲完了,整体代码如下:
class Solution {
public:
int trap(vector<int>& height) {
if (height.size() <= 2) return 0; // 可以不加
stack<int> st; // 存着下标,计算的时候用下标对应的柱子高度
st.push(0);
int sum = 0;
for (int i = 1; i < height.size(); i++) {
if (height[i] < height[st.top()]) { // 情况一
st.push(i);
} if (height[i] == height[st.top()]) { // 情况二
st.pop(); // 其实这一句可以不加,效果是一样的,但处理相同的情况的思路却变了。
st.push(i);
} else { // 情况三
while (!st.empty() && height[i] > height[st.top()]) { // 注意这里是while
int mid = st.top();
st.pop();
if (!st.empty()) {
int h = min(height[st.top()], height[i]) - height[mid];
int w = i - st.top() - 1; // 注意减一,只求中间宽度
sum += h * w;
}
}
st.push(i);
}
}
return sum;
}
};
题目二: 84.柱状图中最大的矩形
84. 柱状图中最大的矩形
解题思路:
我们先来看一下暴力解法的解法:
暴力解法
class Solution {
public:
int largestRectangleArea(vector<int>& heights) {
int sum = 0;
for (int i = 0; i < heights.size(); i++) {
int left = i;
int right = i;
for (; left >= 0; left--) {
if (heights[left] < heights[i]) break;
}
for (; right < heights.size(); right++) {
if (heights[right] < heights[i]) break;
}
int w = right - left - 1;
int h = heights[i];
sum = max(sum, w * h);
}
return sum;
}
};
如上代码并不能通过leetcode,超时了,因为时间复杂度是 O ( n 2 ) O(n^2) O(n2)。
双指针解法
难就难在本题要记录记录每个柱子 左边第一个小于该柱子的下标,而不是左边第一个小于该柱子的高度。
class Solution {
public:
int largestRectangleArea(vector<int>& heights) {
vector<int> minLeftIndex(heights.size());
vector<int> minRightIndex(heights.size());
int size = heights.size();
// 记录每个柱子 左边第一个小于该柱子的下标
minLeftIndex[0] = -1; // 注意这里初始化,防止下面while死循环
for (int i = 1; i < size; i++) {
int t = i - 1;
// 这里不是用if,而是不断向左寻找的过程
while (t >= 0 && heights[t] >= heights[i]) t = minLeftIndex[t];
minLeftIndex[i] = t;
}
// 记录每个柱子 右边第一个小于该柱子的下标
minRightIndex[size - 1] = size; // 注意这里初始化,防止下面while死循环
for (int i = size - 2; i >= 0; i--) {
int t = i + 1;
// 这里不是用if,而是不断向右寻找的过程
while (t < size && heights[t] >= heights[i]) t = minRightIndex[t];
minRightIndex[i] = t;
}
// 求和
int result = 0;
for (int i = 0; i < size; i++) {
int sum = heights[i] * (minRightIndex[i] - minLeftIndex[i] - 1);
result = max(sum, result);
}
return result;
}
};
单调栈
本地单调栈的解法和接雨水的题目是遥相呼应的。
这里就涉及到了单调栈很重要的性质,就是单调栈里的顺序,是从小到大还是从大到小。
只有栈里从大到小的顺序,才能保证栈顶元素找到左右两边第一个小于栈顶元素的柱子。
所以本题单调栈的顺序正好与接雨水反过来。
此时大家应该可以发现其实就是栈顶和栈顶的下一个元素以及要入栈的三个元素组成了我们要求最大面积的高度和宽度
主要就是分析清楚如下三种情况:
- 情况一:当前遍历的元素heights[i]大于栈顶元素heights[st.top()]的情况
- 情况二:当前遍历的元素heights[i]等于栈顶元素heights[st.top()]的情况
- 情况三:当前遍历的元素heights[i]小于栈顶元素heights[st.top()]的情况
C++代码如下:
// 版本一
class Solution {
public:
int largestRectangleArea(vector<int>& heights) {
int result = 0;
stack<int> st;
heights.insert(heights.begin(), 0); // 数组头部加入元素0
heights.push_back(0); // 数组尾部加入元素0
st.push(0);
// 第一个元素已经入栈,从下标1开始
for (int i = 1; i < heights.size(); i++) {
if (heights[i] > heights[st.top()]) { // 情况一
st.push(i);
} else if (heights[i] == heights[st.top()]) { // 情况二
st.pop(); // 这个可以加,可以不加,效果一样,思路不同
st.push(i);
} else { // 情况三
while (!st.empty() && heights[i] < heights[st.top()]) { // 注意是while
int mid = st.top();
st.pop();
if (!st.empty()) {
int left = st.top();
int right = i;
int w = right - left - 1;
int h = heights[mid];
result = max(result, w * h);
}
}
st.push(i);
}
}
return result;
}
};
为什么要在开头加入元素0?
- 初始化栈:在“接雨水”问题中,需要找到每个柱子左右两侧最高的柱子。将索引0加入栈是为了初始化这个过程,使得我们可以从第一个柱子开始比较和计算。
- 保证遍历的连续性:加入索引0确保了从数组的开始就有一个参考点,这使得我们可以在遍历过程中始终保持栈内有元素,从而避免在计算左侧最高柱子时出现空栈的情况。
特殊情况下的栈行为:
- 数组是降序的:如果数组是降序的,比如
[8, 6, 4, 2]
,每次遇到的新柱子都比栈顶柱子矮。这意味着每次比较都会触发“情况三”的逻辑,即当前柱子比栈顶柱子高,可以形成凹槽。 - 凹槽的计算:在这种情况下,每次遇到新的矮柱子时,栈顶的柱子(之前的高柱子)就会与它形成凹槽。通过计算凹槽的宽度(当前柱子索引减去栈顶柱子索引减1)和高度(栈顶柱子高度和当前柱子高度的较小值减去凹槽底部柱子的高度),我们可以累加每个凹槽能接的雨水量。
- 避免空栈:在处理降序数组时,如果最开始不加入索引0,那么在第一次遇到比栈顶柱子矮的柱子时,栈可能是空的,这会导致无法计算左侧最高柱子,从而无法计算凹槽。加入索引0确保了即使在极端情况下,我们也有一个初始的参考点来进行计算。
如图所示:
所以我们需要在 height数组前后各加一个元素0。
总结
接雨水问题:
**单调栈的应用:**通过单调栈,我们可以高效地找到每个柱子左侧和右侧最高的柱子,从而计算出在两个最高柱子之间较低的柱子上能接多少雨水。单调栈保证了栈内元素的高度是单调递增或递减的,这使得我们可以在遍历过程中快速更新和查询最大高度。
避免重复计算:在双指针优化解法中,我们通过预先计算并存储每个位置的 maxLeft
和 maxRight
值来避免在计算雨水量时的重复计算。而在单调栈解法中,我们通过维护一个单调递增的栈来确保每个柱子的左右最大高度只被计算一次,进一步提高了算法的效率。
直观理解:
-
接雨水问题:就像一排高低不同的篱笆,你要在它们之间放水桶接雨水。水桶能接多少水,取决于它两边篱笆的高度。如果一边高一边低,水桶就能接满;如果两边一样高,水桶就接不到水。我们要算的是所有水桶加起来能接多少水。
-
柱状图中最大的矩形问题:就像一堆不同宽度的砖头堆在一起,你要找出一个最大的长方形区域。这个区域可以是竖着的,也可以是横着的。我们要做的是找出所有可能的长方形,然后挑出面积最大的那个。