【单调栈】单调栈基础及经典案例
【单调栈】单调栈基础及经典案例
- 单调栈理论基础
- 每日温度
- 下一个更大元素Ⅰ
- 下一个更大元素Ⅱ
- 经典例题—接雨水
- 思路一 暴力求解
- 思路二 双指针优化
- 思路三 单调栈解法
- 经典例题—柱状图中最大的矩形
- 思路一 暴力求解
- 思路二 单调栈
单调栈理论基础
单调栈的应用场景:要寻找任一个元素的右边或者左边第一个比自己大或者小的元素的位置,此时就要用到单调栈,时间复杂度为O(n)。
单调栈的本质是空间换时间,因为在遍历的过程中需要用一个栈来记录右边第一个比当前元素高的元素,优点是整个数组只需要遍历一次。
更直白来说,就是用一个栈来记录我们遍历过的元素,因为我们遍历数组的时候,我们不知道之前都遍历了哪些元素,以至于遍历一个元素找不到是不是之前遍历过一个更小的,所以我们需要用一个容器(这里用单调栈)来记录我们遍历过的元素。
在使用单调栈的时候首先要明确如下几点:
-
单调栈里存放的元素是什么?
单调栈里只需要存放元素的下标i就可以了,如果需要使用对应的元素,直接nums[i]
就可以获取。 -
单调栈里元素是递增呢? 还是递减呢?
顺序的描述为 从栈头到栈底的顺序,若求一个元素右边第一个更大元素,单调栈就是递增的,反之则是递减的。
使用单调栈主要有三个判断条件,以递增栈为例
-
当前遍历的元素nums[i]小于栈顶元素nums[st.top()],即
nums[i] < nums[st.top()]
直接将遍历数据加入栈即可,实现递增,即st.push(i);
-
当前遍历的元素nums[i]等于栈顶元素nums[st.top()],即
nums[i] == nums[st.top()]
直接将遍历数据加入栈即可,因为求的是第一个比自己大的数,即st.push(i);
-
当前遍历的元素nums[i]大于栈顶元素nums[st.top()],即
nums[i] > nums[st.top()]
(1)收集结果,结果处理
(2)将栈顶元素弹出,进行下次比较,因为需要用while()
(3)最后将当前遍历的元素下标加入栈
单调栈程序模板:
求每个数值的右边第一个比自己大的元素下标
vector<int> func(vector<int>& nums)
{
vector<int> result(nums.size(), -1);
stack<int> st; // 单调递增存放遍历过的数据的下标
st.push(0); // 把第一个元素下标先加入
//数组遍历完毕
for(int i = 1; i < nums.size(); i++)
{
if(nums[i] <= nums[st.top()])
st.push(i);
else
{
// 如果遍历数据比栈顶数据大
while(!st.empty() && nums[i] > nums[st.top()])
{
result[st.top()] = i; // 保存下标值
st.pop(); // 移除元素
}
st.push(i);
}
}
return result;
}
说明:
-
栈的数据存放过程详情参考:代码随想录-每日温度
-
单调栈的顺序:若求第一个比自己大(递增),反之则相反
-
栈存放的元素:已经遍历过的数据的下标
-
当前遍历元素:
nums[i];
-
右边第一个比当前元素大的数据下标:
st.top();
-
左边第一个比当前元素大的数据下标:栈顶第二个元素
// 先把右边第一个比当前数据大的数据下标记录,并弹出 int right = st.top(); st.pop(); // 获取左边第一个比当前数据大的数据下标 int left = st.top();
每日温度
力扣原题
给定一个整数数组 temperatures
,表示每天的温度,返回一个数组 answer
,其中 answer[i]
是指对于第 i 天,下一个更高温度出现在几天后。如果气温在这之后都不会升高,请在该位置用 0 来代替
例如,给定一个列表 temperatures = [73, 74, 75, 71, 69, 72, 76, 73],你的输出应该是 [1, 1, 4, 2, 1, 1, 0, 0]。
核心思想
- 本题是一个单调栈的裸题,题意即要求右边第一个比自己大的元素和自己直接的下标差值,因此用单调递增栈。
- 结果数组是求两数的下表差值,因此收集结果代码为
result[st.top()] = i - st.top(); // 计算下标差
代码实现
vector<int> dailyTemperatures(vector<int>& temperatures)
{
vector<int> result(temperatures.size(), 0);
stack<int> st; // 单调递增存放遍历过的数据的下标
st.push(0); // 把第一个元素下标先加入
//数组遍历完毕
for(int i = 1; i < temperatures.size(); i++)
{
if(temperatures[i] <= temperatures[st.top()])
st.push(i);
else
{
while(!st.empty() && temperatures[i] > temperatures[st.top()]) // 如果遍历数据比栈顶数据大
{
result[st.top()] = i - st.top(); // 计算下标差
st.pop(); // 移除元素下标
}
st.push(i);
}
}
return result;
}
下一个更大元素Ⅰ
力扣链接
给你两个 没有重复元素 的数组 nums1
和 nums2
,其中nums1
是 nums2
的子集。
请你找出 nums1 中每个元素在 nums2 中的下一个比其大的值。
nums1 中数字 x 的下一个更大元素是指 x 在 nums2 中对应位置的右边的第一个比 x 大的元素。如果不存在,对应位置输出 -1 。
输入:nums1 = [4,1,2], nums2 = [1,3,4,2].
输出:[-1,3,-1]
核心思想
- 本题也是一个单调栈的裸题,只是通过了
nums1
数组进行包装,需要明确结果数组大小为nums1.size()
,遍历的数组为nums2
- 解题核心:明确
nums2
中的数据在nums1
中的下标(若存在) - 通过map映射
nums1
中的数据的下标,使其在遍历num2
时,可以通过数值获取该数据在nums1
中的下标
部分核心代码
(1)将nums1数据通过数值找下标的map映射:
// 将数组1的值与下标做映射,通过数值找到在nums1中的下标
unordered_map<int,int> umap;
for(int i = 0; i < nums1.size(); i++)
umap[nums1[i]]= i;
(2)查找nums2中的数据在nums1中的下标
// 看nums1中是否存在该元素
if(umap.count(nums2[st.top()]) > 0)
int idx = umap[nums2[st.top()]]; // 获取数据在nums1中的下标
代码实现
vector<int> nextGreaterElement(vector<int>& nums1, vector<int>& nums2)
{
vector<int> result(nums1.size(),-1);
stack<int> st;
if(nums1.size() == 0)
return result;
// 将数组1的值与下标做映射,通过数值找到在nums1中的下标
unordered_map<int,int> umap;
for(int i = 0; i < nums1.size(); i++)
umap[nums1[i]]= i;
st.push(0);
for(int i = 1; i < nums2.size(); i++)
{
if(nums2[i] <= nums2[st.top()])
st.push(i);
else
{
while(!st.empty() && nums2[i] > nums2[st.top()])
{
if(umap.count(nums2[st.top()]) > 0) // 看nums1中是否存在该元素
{
int idx = umap[nums2[st.top()]]; // 获取数据在nums1中的下标
result[idx] = nums2[i];
}
st.pop();
}
st.push(i);
}
}
return result;
}
下一个更大元素Ⅱ
力扣链接
给定一个循环数组(最后一个元素的下一个元素是数组的第一个元素),输出每个元素的下一个更大元素。
数字 x 的下一个更大的元素是按数组遍历顺序,这个数字之后的第一个比它更大的数,这意味着你应该循环地搜索它的下一个更大的数。如果不存在,则输出 -1。
示例 :
输入: nums = [1,2,3,4,3]
输出: [2,3,4,-1,4]
本题也为一道明显的单调栈问题
思路一 暴力拼接
-
由于循环数组,不妨直接把两个数组拼接在一起,然后使用单调栈求下一个最大值就暴力解决了!
-
将两个nums数组拼接在一起,使用单调栈计算出每一个元素的下一个最大值,最后再把结果集即result数组resize到原数组大小就可以了
-
这种写法确实比较直观,但做了很多无用操作,例如修改了nums数组,扩充nums数组相当于多了一个 O ( n ) O(n) O(n)的操作
程序实现:
vector<int> nextGreaterElements(vector<int>& nums)
{
// 拼接一个新的nums
vector<int> nums1(nums.begin(), nums.end());
nums.insert(nums.end(), nums1.begin(), nums1.end());
// 用新的nums大小来初始化result
vector<int> result(nums.size(), -1);
if (nums.size() == 0) return result;
// 开始单调栈
stack<int> st;
st.push(0);
for (int i = 1; i < nums.size(); i++) {
if (nums[i] < nums[st.top()]) st.push(i);
else if (nums[i] == nums[st.top()]) st.push(i);
else {
while (!st.empty() && nums[i] > nums[st.top()]) {
result[st.top()] = nums[i];
st.pop();
}
st.push(i);
}
}
// 最后再把结果集即result数组resize到原数组大小
result.resize(nums.size() / 2);
return result;
}
思路二
- 其实也可以不扩充nums,而是在遍历的过程中模拟走两遍
nums
,即:for(int i = 1; i < nums.size() * 2; i++)
- 对循环中用到的每个
i
取模长操作,保证nums[i]
不会溢出
程序实现:
vector<int> nextGreaterElements(vector<int>& nums)
{
vector<int> result(nums.size(),-1);
stack<int> st;
if(nums.size() == 0)
return result;
st.push(0);
// 模拟遍历两边nums,注意一下都是用 i % nums.size()来操作
for(int i = 1; i < nums.size() * 2; i++)
{
if(nums[i % nums.size()] <= nums[st.top()])
st.push(i % nums.size());
else
{
while(!st.empty() && nums[i % nums.size() ] > nums[st.top()])
{
result[st.top()] = nums[i % nums.size()];
st.pop();
}
st.push(i % nums.size());
}
}
return result;
}
经典例题—接雨水
力扣链接
给定 n 个非负整数表示每个宽度为 1 的柱子的高度图,计算按此排列的柱子,下雨之后能接多少雨水。
示例 :
输入:height = [0,1,0,2,1,0,1,3,2,1,2,1]
输出:6
思路一 暴力求解
本题暴力解法也是也是使用双指针
首先要明确,要按照行来计算,还是按照列来计算。
按照行与列计算如下图所示:
- 这里按照列来计算,比较容易理解,并且宽度是1,只需计算每一列雨水的高度即可。
- 可以看出每一列雨水的高度,取决于,该列左侧最高的柱子和右侧最高的柱子中最矮的那个柱子的高度。
- 对于其中一根长度为
height
的柱子,左侧最高的柱子为leftHeight
,右侧最高的柱子为rightHeight
,那么这根柱子可接的雨水为:min(leftHeight, rightHeight) - height
- 从头遍历一遍所有的列,然后求出每一列雨水的体积,相加之后就是总雨水的体积了
- 注意第一个柱子和最后一个柱子不接雨水
代码实现:
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;
}
思路二 双指针优化
-
在上述解法中,我们可以看到只要记录左边柱子的最高高度 和 右边柱子的最高高度,就可以计算当前位置的雨水面积,min(左边柱子的最高高度,记录右边柱子的最高高度) - 当前柱子高度。
-
为了得到两边的最高高度,使用了双指针来遍历,每到一个柱子都向两边遍历一遍,这其实是有大量重复计算的。我们把每一个位置的左边最高高度记录在一个数组上(maxLeft),右边最高高度记录在一个数组上(maxRight),这样就避免了重复计算。
-
当前位置,左边的最高高度是前一个位置的左边最高高度和本高度的最大值。
从左向右遍历:
maxLeft[i] = max(height[i], maxLeft[i - 1]);
从右向左遍历:
maxRight[i] = max(height[i], maxRight[i + 1]);
程序实现:
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;
}
思路三 单调栈解法
而接雨水这道题目,我们正需要寻找一个元素,右边最大元素以及左边最大元素,来计算雨水面积。
那么本题使用单调栈有如下几个问题:
-
首先单调栈是按照行方向来计算雨水
-
使用单调栈内元素的顺序:由于是查找一个元素左右两侧第一个比自身大的元素,因此顺序为单调递增栈
-
遇到相同高度的柱子怎么办:遇到相同的元素,更新栈内下标,就是将栈里元素(旧下标)弹出,将新元素(新下标)加入栈中。
例如 5 5 1 3 这种情况。如果添加第二个5的时候就应该将第一个5的下标弹出,把第二个5添加到栈中。因为我们要求宽度的时候 如果遇到相同高度的柱子,需要使用最右边的柱子来计算宽度。
单调栈处理逻辑
(1)当前遍历的元素高度小于栈顶元素的高度:把这个元素加入栈中,因为栈里本来就要保持从小到大的顺序
if(height[i] <= height[st.top()])
st.push(i);
(2)当前遍历的元素高度等于栈顶元素的高度:更新栈顶元素,因为遇到相相同高度的柱子,需要使用最右边的柱子来计算宽度
else if(height[i] == height[st.top()])
{
st.pop();
st.push(i);
}
(3)当前遍历的元素高度大于栈顶元素的高度:出现凹槽,计算雨水面积。
- 取栈顶元素,将栈顶元素弹出,这个就是凹槽的底部,下标记为mid,对应的高度为
height[mid]
(就是图中的高度1) - 此时的栈顶元素
st.top()
,就是凹槽的左边位置,下标为st.top(),对应的高度为height[st.top()]
(就是图中的高度2) - 当前遍历的元素i,就是凹槽右边的位置,下标为i,对应的高度为height[i](就是图中的高度3)
栈顶和栈顶的下一个元素以及要入栈的元素,三个元素来接水!
那么雨水高度 h = min(凹槽左边高度, 凹槽右边高度) - 凹槽底部高度,代码为:
int h = min(height[st.top()], height[i]) - height[mid];
雨水的宽度是 w = 凹槽右边的下标 - 凹槽左边的下标 - 1(因为只求中间宽度),代码为:
int w = i - st.top() - 1 ;
求当前凹槽雨水的体积代码如下:
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;
}
}
完整代码实现
int trap(vector<int>& height)
{
int result = 0;
stack<int> st;
if(height.size() == 0)
return result;
st.push(0);
// 记录每个格子左右两边比他的的第一个元素和自身的差值
for(int i = 1; i < height.size(); i++)
{
if(height[i] <= height[st.top()])
st.push(i);
else if(height[i] == height[st.top()])
{
st.pop();
st.push(i);
}
else
{
while(!st.empty() && height[i] > height[st.top()])
{
int mid = st.top(); // 中间元素的下标
st.pop();
if(!st.empty())
{
// 左边第一个比他大的元素为栈里面第二个元素
int h = min(height[i], height[st.top()]) - height[mid];
int w = i - st.top() - 1;
result += (h * w);
}
}
st.push(i);
}
}
return result;
}
经典例题—柱状图中最大的矩形
力扣链接
给定 n 个非负整数,用来表示柱状图中各个柱子的高度。每个柱子彼此相邻,且宽度为 1,求在该柱状图中,能够勾勒出来的矩形的最大面积。
输入:heights = [2,1,5,6,2,3]
输出:10
解释:最大的矩形为图中红色区域,面积为 10
本题和接雨水是遥相呼应的两道题目,原理上有很多相同的地方,细节上又有差
异。
核心思路
- 分别求每一根柱子左右比自身低的柱子下标,并求其间距为
w
- 以自身高度为
h
,形成的h * w
的矩形即为每一个柱子可以构造的最大矩形 - 其中以 1 为高度构造的矩形如下图所示:
思路一 暴力求解
通过暴力求解每一根柱子左右两侧第一根比自身矮的柱子,以此求出每根柱子可形成的最大矩形面积
程序实现:
// 暴力求解
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;
}
注:该方法的时间复杂度为 O ( n 2 ) O(n^2) O(n2),在力扣上超时,但核心思想为下述优化做了很好的铺垫。
双指针优化
同接雨水一样,引入数组,记录每个柱子 左右两边第一个小于该柱子的下标
// 暴力求解 - 双指针优化
int largestRectangleArea2(vector<int>& heights)
{
int result = 0;
int size = heights.size();
vector<int> minLeftIndex(heights.size(), -1);
vector<int> minRightIndex(heights.size(), -1);
// 记录每个柱子 左边第一个小于该柱子的下标
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[i] = t;
}
// 记录每个柱子 右边第一个小于该柱子的下标
minRightIndex[size - 1] = size; // 注意这里初始化,防止下面while死循环
for(int i = 0; i < size - 1; i++)
{
int t = i + 1;
while(t < size && heights[t] >= heights[i] )
t++;
minRightIndex[i] = t;
}
for(int i = 0; i < size; i++)
{
int w = minRightIndex[i] - minLeftIndex[i] - 1;
int h = heights[i];
result = max(w * h, result);
}
return result;
}
思路二 单调栈
考虑到本题题意为求每根柱子左右两边第一根比自身矮的柱子的下标,显然是一道单调栈题目。
- 由于是求左右第一个比自身小的元素下标,那么单调栈为递减栈
- 栈顶和栈顶的下一个元素以及要入栈的三个元素组成了我们要求最大面积的高度和宽度
主要就是理清当前遍历元素和栈顶元素大小比较的三种情况,具体代码如下:
int largestRectangleArea(vector<int>& heights)
{
int result = 0;
stack<int> st;
// 头尾加0
heights.insert(heights.begin(), 0);
heights.push_back(0);
st.push(0);
// 记录每一个数值左右两边比自身小的数值 即矩形面积
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()])
{
int mid = st.top(); // 中间元素的下标 为基准
st.pop();
// 左边第一个比他小的元素为栈里面第二个元素
if(!st.empty())
{
int left = st.top();
int right = i; // 右边比中间小的 下标
int h = heights[mid];
int w = right - left - 1;
result = max(h * w, result);
}
}
st.push(i);
}
}
return result;
}