单调栈最经典的用法是解决如下问题:
每个位置都求:
1.当前位置的左侧比当前位置的数字小,且距离最近的位置在哪。
2.当前位置的右侧比当前位置的数字小,且距离最近的位置在哪。
或者
每个位置都求:
1.当前位置的左侧比当前位置的数字大,且距离最近的位置在哪。
2.当前位置的右侧比当前位置的数字大,且距离最近的位置在哪。
用单调栈的方式可以做到:求解过程中,单调栈所有调整的总代价为O(n),单次操作的均摊代价为O(1)。
单调栈分为有重复值的情况和无重复值的情况。
下面讲解有重复值的情况,无重复值为有重复值的一种特殊情况。
假设我们需要找到数组a中每一个i位置左边和右边离i位置最近且值比arr[i]小的位置。这时候,可以用一个数组b做一个栈,栈中每个元素是数组a中值的下标;一个数组c做结果数组,结果数组中每个元素是一个容量为2的数组,下标0存储左边满足条件的下标,下标1存储右边满足条件下标的值,没有则为-1。遍历a数组,如果此时的值严格大于栈顶的值,则入栈;否则将栈中大于等于的元素弹出并可以更新这些的结果,此时值的下标就是右边满足条件的值,而弹出后栈顶的下标就是左边满足条件的值,弹出栈底元素时,左边满足条件的值没有为-1。遍历完数组a后,若栈中还有元素,则依次弹栈,此时右边无满足条件的值故右边下标为-1,栈空则完毕。如果数组a中无重复元素,到这里就结束了。如果有,还需调整结果数组c,因为更新结果数组时并不是严格小于的,所以存在右边满足条件的下标的值和当前值相等。考虑到最右边的右边满足条件的值为-1不需要调整,故调整顺序从右到左速度较快,注意从左到右也可以,只是慢一些。调整方法为如果当前值和右边满足条件下标的值相等,则将当前值的右边满足条件的下标更新为右边满足条件的下标的右边满足条件的下标。遍历完结果数组c即得到结果。
下面通过几个题目加深对于单调栈最基础的用法。
题目一
测试链接:https://www.nowcoder.com/practice/2a2c00e7a88a498693568cef63a4b7bb
分析:这就是一个单调栈的模版,照着上面分析写就行。代码如下。
cpp
#include <iostream>
using namespace std;
int arr1[1000005];
int arr2[1000005];
int res[1000005][2];
int n;
void form_res()
{
int top = 0;
for (int i = 0; i < n; ++i)
{
while (top > 0 && arr1[i] <= arr1[arr2[top - 1]])
{
res[arr2[top - 1]][1] = i;
res[arr2[top - 1]][0] = (top > 1 ? arr2[top - 2] : -1);
--top;
}
arr2[top] = i;
++top;
}
while (top > 0)
{
res[arr2[top - 1]][1] = -1;
res[arr2[top - 1]][0] = (top > 1 ? arr2[top - 2] : -1);
--top;
}
}
void adjust_res()
{
for (int i = n - 2; i >= 0; --i)
{
if (res[i][1] == -1 || arr1[res[i][1]] < arr1[i])
{
continue;
}
res[i][1] = res[res[i][1]][1];
}
}
int main(void)
{
scanf("%d", &n);
for (int i = 0; i < n; ++i)
{
scanf("%d", &arr1[i]);
}
form_res();
adjust_res();
for (int i = 0; i < n; ++i)
{
printf("%d %d\n", res[i][0], res[i][1]);
}
}
其中,arr1是数组a,arr2是栈数组b,res是结果数组c。
题目二
测试链接:https://leetcode.cn/problems/daily-temperatures/
分析:这就相当于是只求一半也就是右边,注意res中的值是间隔天数不是下标,当然也可以先通过模版求出下标再求间隔天数,只不过会慢一些。代码如下。
cpp
class Solution
{
public:
void form_res(vector<int> &temperatures, vector<int> &stack, vector<int> &res, int length)
{
int top = 0;
for (int i = 0; i < length; ++i)
{
while (top > 0 && temperatures[i] >= temperatures[stack[top - 1]])
{
res[stack[top - 1]] = i - stack[top - 1];
--top;
}
stack[top] = i;
++top;
}
while (top > 0)
{
res[stack[top - 1]] = 0;
--top;
}
}
void adjust_res(vector<int> &temperatures, vector<int> &res, int length)
{
for (int i = length - 2; i >= 0; --i)
{
if (res[i] == 0 || temperatures[res[i] + i] > temperatures[i])
{
continue;
}
res[i] = (res[res[i] + i] == 0 ? 0 : res[res[i] + i] + res[i]);
}
}
vector<int> dailyTemperatures(vector<int> &temperatures)
{
int length = temperatures.size();
vector<int> stack;
vector<int> res;
stack.assign(length, 0);
res.assign(length, -1);
form_res(temperatures, stack, res, length);
adjust_res(temperatures, res, length);
return res;
}
};
其中,stack数组为栈数组,res为结果数组。
题目三
测试链接:https://leetcode.cn/problems/sum-of-subarray-minimums/
分析:这个题就是寻找所有的子数组,然后得到每个子数组的最小值,加起来,最后取模。但是如果通过单纯的双重for循环,根据数据量肯定会超时的。所以我们使用单调栈对每一个元素求得最近离它最近的最大的数的下标,因为这个数组存在相同的值,所以按照一般情况需要对最初结果进行调整,但是如果使用调整后的下标值就会漏求一些子数组,可以自己举一些例子来看,所以我们需要使用调整前的结果才可以保证子数组不漏求,不重复求。代码如下。
cpp
class Solution {
public:
int MOD = 1000000007;
int sumSubarrayMins(vector<int>& arr) {
int length = arr.size();
long long sum = 0;
long long l, r;
vector<int> stack;
stack.assign(length, 0);
int top = 0;
for(int i = 0;i < length;++i){
while (top > 0 && arr[i] <= arr[stack[top-1]])
{
r = i - stack[top-1];
l = (top == 1 ? stack[top-1] + 1 : stack[top-1] - stack[top-2]);
sum = (sum + (l * r * (long long)arr[stack[top-1]])) % MOD;
--top;
}
stack[top++] = i;
}
while (top > 0)
{
r = length - stack[top-1];
l = (top == 1 ? stack[top-1]+1 : stack[top-1] - stack[top-2]);
sum = (sum + (l * r * (long long)arr[stack[top-1]])) % MOD;
--top;
}
return (int)sum;
}
};
其中,运用了同余原理保证不溢出,弃用结果数组,在弹栈时就完成结果更新使时间更快。
题目四
测试链接:https://leetcode.cn/problems/largest-rectangle-in-histogram
分析:这个题就是对每个元素求离它最近的比它小的元素的下标,这个左右下标范围就是对于这个元素所能达到的最大矩形面积。遍历数组即可得到最大矩形面积。代码如下。
cpp
class Solution {
public:
int largestRectangleArea(vector<int>& heights) {
int length = heights.size();
if(length == 1){
return heights[0];
}
int max_area = 0;
vector<int> stack;
stack.assign(length, 0);
int top = 0;
int l, r;
for(int i = 0;i < length;++i){
while (top > 0 && heights[i] <= heights[stack[top-1]])
{
r = i - stack[top-1] - 1;
l = (top == 1 ? stack[top-1] : stack[top-1] - stack[top-2] - 1);
max_area = max_area > (l + r + 1) * heights[stack[top-1]] ? max_area : (l + r + 1) * heights[stack[top-1]];
--top;
}
stack[top++] = i;
}
while (top > 0)
{
r = length - 1 - stack[top-1];
l = (top == 1 ? stack[top-1] : stack[top-1] - stack[top-2] - 1);
max_area = max_area > (l + r + 1) * heights[stack[top-1]] ? max_area : (l + r + 1) * heights[stack[top-1]];
--top;
}
return max_area;
}
};
其中,同样弃用结果数组,使耗时更短。
题目五
测试链接:https://leetcode.cn/problems/maximal-rectangle/
分析:这个题就是上一个题的多维度版本,只需要将以每一行为结尾的情况都求一遍,就能得到答案。比如以第3行结尾则形成的一维数组,是以第3行为结尾,向前到第1行的连续1的个数。然后套用上一题的代码,即可得到最大值。代码如下。
cpp
class Solution {
public:
void f(vector<int>& heights, int &ans) {
int length = heights.size();
if(length == 1){
ans = ans > heights[0] ? ans : heights[0];
return;
}
vector<int> stack;
stack.assign(length, 0);
int top = 0;
int l, r;
for(int i = 0;i < length;++i){
while (top > 0 && heights[i] <= heights[stack[top-1]])
{
r = i - stack[top-1] - 1;
l = (top == 1 ? stack[top-1] : stack[top-1] - stack[top-2] - 1);
ans = ans > (l + r + 1) * heights[stack[top-1]] ? ans : (l + r + 1) * heights[stack[top-1]];
--top;
}
stack[top++] = i;
}
while (top > 0)
{
r = length - 1 - stack[top-1];
l = (top == 1 ? stack[top-1] : stack[top-1] - stack[top-2] - 1);
ans = ans > (l + r + 1) * heights[stack[top-1]] ? ans : (l + r + 1) * heights[stack[top-1]];
--top;
}
}
int maximalRectangle(vector<vector<char>>& matrix) {
int row = matrix.size();
int column = matrix[0].size();
int ans = 0;
vector<int> temp;
temp.assign(column, 0);
for(int i = 0;i < row;++i){
for(int j = 0;j < column;++j){
temp[j] = (matrix[i][j] == '0' ? 0 : temp[j] + 1);
}
f(temp, ans);
}
return ans;
}
};
其中,f方法就是上一题的代码,其实就是求一遍以每一行为结尾形成的高度数组的矩形面积最大值。
除了单调栈最经典的用法之外,在很多问题里单调栈还可以维持求解答案的可能性:
1.单调栈里的所有对象按照 规定好的单调性来组织。
2.当某个对象进入单调栈时,会从栈顶开始依次淘汰单调栈里对后续求解答案没有帮助的对象。
3.每个对象从栈顶弹出的时结算当前对象参与的答案,随后这个对象不再参与后续求解答案的过程。
4.其实是先有对题目的分析。进而发现单调性,然后利用单调栈的特征去实现。
下面通过几个问题加深对单调栈其他用法的理解。
题目六
测试链接:https://leetcode.cn/problems/maximum-width-ramp/
分析:可以发现,如果存在下标a和下标b且a小于b以及下标b的值大于等于下标a的值,那么如果存在一个下标c和b组合能够满足题目条件,那么和a也会满足题目条件,所以单调栈需要小压大。遍历数组得到单调栈之后,从数组末尾往前遍历,对每一个位置依次进行单调栈中元素判断,符合条件则弹出;不符合条件则向左移动。遍历完数组即可得到最大值。代码如下。
cpp
class Solution {
public:
int maxWidthRamp(vector<int>& nums) {
int length = nums.size();
int ans = 0;
vector<int> stack;
stack.assign(length, 0);
int top = 0;
stack[top++] = 0;
for(int i = 1;i < length;++i){
if(nums[i] < nums[stack[top-1]]){
stack[top++] = i;
}
}
for(int i = length-1;i >= 0;--i){
while (top > 0 && nums[stack[top-1]] <= nums[i])
{
ans = ans > i-stack[top-1] ? ans : i-stack[top-1];
--top;
}
}
return ans;
}
};
其中,栈中保留的相当于是可能产生最大值的地方,通过从后向前遍历数组得到单调栈中每个地方能够得到的最大值,取最大即可。
题目七
测试链接:https://leetcode.cn/problems/remove-duplicate-letters/
分析:对于这个题,我们可以统计字符串中出现字母的词频,因为需要返回结果的字典序最小,也就是字母尽可能的正向排列,所以单调栈是大压小。当来到的字符比单调栈栈顶的字符小时,需要进行判断。如果栈顶的字符词频为零时代表后面没有已经这个字母,则不能弹出;如果还有词频,则弹出,将较小的字符压栈。同时,如果来到的字符已经在栈中,则跳过。遍历数组得到单调栈,单调栈中的顺序即为结果。代码如下。
cpp
class Solution {
public:
string removeDuplicateLetters(string s) {
int cnt[26] = {0};
bool in[26] = {false};
int length = s.size();
vector<char> stack;
stack.assign(length, 0);
for(int i = 0;i < length;++i){
++cnt[s[i] - 'a'];
}
int top = 0;
for(int i = 0;i < length;++i){
if(in[s[i]-'a'] == true){
--cnt[s[i]-'a'];
continue;
}
while (top > 0 && s[i] <= stack[top-1] && cnt[stack[top-1]-'a'] > 0)
{
in[stack[top-1]-'a'] = false;
--top;
}
stack[top++] = s[i];
--cnt[s[i]-'a'];
in[s[i]-'a'] = true;
}
string ans(top, 0);
for(int i = 0;i < top;++i){
ans[i] = stack[i];
}
return ans;
}
};
其中,cnt数组统计词频,in数组统计字符是否在栈中。
题目八
测试链接:https://www.nowcoder.com/practice/77199defc4b74b24b8ebf6244e1793de
分析:这个题看到右边比自己小的第一条鱼的时候可以反应出是单调栈,最容易想到的思路是将每一个元素右边第一个比自己小的下标得到的,然后将这些下标的值去除,得到新数组。然后再次进行构造单调栈。当新的数组和旧的数组长度一样时,也就是没有去除元素,则代表鱼的数量不变。代码如下。
cpp
#include <iostream>
#include <vector>
#define MAXN 100005
using namespace std;
int A[MAXN];
int N;
vector<int> stack;
vector<bool> re;
vector<int> res;
int top;
bool f() {
int temp = N;
N = 0;
stack.assign(temp, 0);
re.assign(temp, false);
res.assign(temp, -1);
top = 0;
for (int i = 0; i < temp; ++i) {
while (top > 0 && A[i] < A[stack[top - 1]]) {
res[stack[top - 1]] = i;
--top;
}
stack[top++] = i;
}
for (int i = 0; i < temp; ++i) {
if (res[i] == -1) {
continue;
}
re[res[i]] = true;
}
for (int i = 0; i < temp; ++i) {
if (re[i] == false) {
A[N++] = A[i];
}
}
if (temp == N) {
return true;
}
return false;
}
int main(void) {
scanf("%d", &N);
for (int i = 0; i < N; ++i) {
scanf("%d", &A[i]);
}
int ans = 0;
bool b;
while (true) {
b = f();
if (b == true) {
printf("%d", ans);
break;
} else {
++ans;
}
}
}
其中,通过一个死循环一直构造单调栈,直到没有去除元素返回true才跳出循环。
但很显然,这个思路虽然能过,但是太慢了。我们来考虑一个更快的思路。单调栈中的元素存放为每个元素需要多少轮处理完自己右边的元素,最开始所有元素都是0轮。比如倒数第二个数比倒数第一个数大则倒数第二个数要处理完自己右边的元素,需要1轮,也就是吃掉倒数第一个数。如果,倒数第三个数比倒数第二个数要大的时候,它去处理倒数第二个数就需要自己本身的轮数加1和倒数第二个数的轮数取最大值。这样可以看出,单调栈的遍历顺序是从后向前,并且是小压大。代码如下。
cpp
#include <iostream>
#include <vector>
#define MAXN 100005
using namespace std;
int A[MAXN];
int N;
vector<vector<int>> stack;
int main(void) {
scanf("%d", &N);
for (int i = 0; i < N; ++i) {
scanf("%d", &A[i]);
}
int top = 0;
int ans = 0;
vector<int> temp;
temp.assign(2, 0);
stack.assign(N, temp);
for(int i = N-1;i >= 0;--i){
int round = 0;
while (top > 0 && A[i] > A[stack[top-1][0]])
{
round = round + 1 > stack[top-1][1] ? round + 1 : stack[top-1][1];
--top;
}
stack[top][0] = i;
stack[top][1] = round;
++top;
ans = ans > round ? ans : round;
}
printf("%d", ans);
}
其中,while循环代表如果来到的数比栈顶的数大,则可以将栈顶的数吃掉,也就是弹出,然后更新轮数。只需返回遍历过程中的最大轮数,就是答案。
题目九
测试链接:https://leetcode.cn/problems/count-submatrices-with-all-ones/
分析:这道题一个比较简单想到的思路就是利用之前文章提到的二维数组前缀和。对于二维数组中每一个点延伸出去的每一个点,这两个点构成的矩形,求这个矩形区域的前缀和然后判断前缀和是否与这个矩形的面积相等,如果相等就代表子矩形元素,全部都是1。遍历数组即可得到答案。代码如下。
cpp
class Solution
{
public:
int arr[151][151] = {0};
void NumMatrix(vector<vector<int>> &matrix)
{
for (int i = 0; i < matrix.size(); ++i)
{
for (int j = 0; j < matrix[0].size(); ++j)
{
arr[i + 1][j + 1] = matrix[i][j] + arr[i + 1][j] + arr[i][j + 1] - arr[i][j];
}
}
}
int sumRegion(int row1, int col1, int row2, int col2)
{
return arr[row2 + 1][col2 + 1] - arr[row2 + 1][col1] - arr[row1][col2 + 1] + arr[row1][col1];
}
int numSubmat(vector<vector<int>> &mat)
{
int m = mat.size();
int n = mat[0].size();
NumMatrix(mat);
int ans = 0;
for (int i = 0; i < m; ++i)
{
for (int j = 0; j < n; ++j)
{
for (int ii = i; ii < m; ++ii)
{
for (int jj = j; jj < n; ++jj)
{
if (sumRegion(i, j, ii, jj) == (ii - i + 1) * (jj - j + 1))
{
++ans;
}
}
}
}
}
return ans;
}
};
其中,NumMatrix和sumRegion方法在之前文章中提过,这里不再赘述。
对于这个思路,简单算一下其时间复杂度就知道幸好数据量不大,不然这个思路是肯定会超时的。下面,通过单调栈进行求解。
在普通题目中我们接触过题目四和题目五,这里有异曲同工之妙。我们通过单调栈求出以每一行作结尾的数组的元素左右第一个比自己小的值的下标。这里就会发现,如果我们对于每一个元素都计算完整高度时,矩形的个数会被重复计算。比如来到高度为4的元素时会计算一遍高度为1的矩形个数,来到高度为2的元素时又会计算一遍高度为1的矩形个数。所以,为了保证计算不重复,对于来到的元素,计算矩形高度范围为此位置满足条件的左右下标值中的最大值加1至来到元素的高度。比如,来到了下标5,高度4,左边满足条件是下标2,高度2,右边满足条件是下标6,高度1,则需要计算矩形高度范围是3~4。这样就可以保证不多算不漏算,遍历数组即可得到答案。代码如下。
cpp
class Solution
{
public:
vector<int> stack;
int top;
int l;
int len;
int lower;
int cur;
void f(vector<int>& temp, int &ans, int column){
stack.assign(column, 0);
top = 0;
for(int i = 0;i < column;++i){
while (top > 0 && temp[i] <= temp[stack[top-1]])
{
int cur = stack[top-1];
--top;
if(temp[cur] > temp[i]){
l = top == 0 ? -1 : stack[top-1];
len = i - l - 1;
lower = (l == -1 ? (0 > temp[i] ? 0 : temp[i]): (temp[l] > temp[i] ? temp[l] : temp[i]));
ans += ((temp[cur] - lower) * len * (len + 1) / 2);
}
}
stack[top++] = i;
}
while (top > 0)
{
cur = stack[top-1];
--top;
l = top == 0 ? -1 : stack[top-1];
len = column - l - 1;
lower = l == -1 ? 0 : temp[l];
ans += ((temp[cur] - lower) * len * (len + 1) / 2);
}
}
int numSubmat(vector<vector<int>> &mat)
{
vector<int> temp;
int row = mat.size();
int column = mat[0].size();
int ans = 0;
temp.assign(column, 0);
for(int i = 0;i < row;++i){
for(int j = 0;j < column;++j){
temp[j] = mat[i][j] == 0 ? 0 : temp[j]+1;
}
f(temp, ans, column);
}
return ans;
}
};
其中,len是满足高度的下标范围长度,而len * (len + 1) / 2是对于一个高度所存在的矩形个数。