数据结构——单调栈和单调队列

数据结构------单调栈和单调队列

单调栈原理

单调栈,顾名思义,就是具有单调性的数据结构栈 。它依旧是一个栈结构,只不过里面存储的数据是严格(非)递增或者严格(非)递减的。栈可以参考栈的c语言实现-CSDN博客,也可以使用STL的工具stackdeque模拟。

单调栈能解决以下四个问题(或者说是因为有这些问题,才出现的单调栈):

  • 寻找当前元素左侧,离它最近,并且比它大的元素在哪(左近大)。
  • 寻找当前元素右侧,离它最近,并且比它大的元素在哪(右近大)。
  • 寻找当前元素左侧,离它最近,并且比它小的元素在哪(左近小)。
  • 寻找当前元素右侧,离它最近,并且比它小的元素在哪(右近小)。

虽然是四个问题,但是原理是一致的。因此,只要解决一个,举一反三就可以解决剩下的几个。

左侧最近最大问题

例如寻找当前元素左侧 ,离它最近 ,并且比它大的元素在哪 。

测试样例:

复制代码
9
1 4 10 6 3 3 15 21 8

预期输出

复制代码
0 0 0 3 4 4 0 0 8

利用单调栈解决:

从左往右遍历元素,构造一个单调递减的栈。插入当前位置的元素时:

  • 如果栈为空,则左侧不存在比当前元素大的元素;
  • 如果栈非空,插入当前位置元素时的栈顶元素就是所找的元素。

注意,因为我们要找的是最终结果的位置。因此,栈里面存的是每个元素的下标。

这一整个流程可以看成贪心 + 栈,即贪心策略通过栈来模拟实现。

参考程序:

cpp 复制代码
#include<bits/stdc++.h>
using namespace std;

int main() {
	int n; cin >> n;
	vector<int>a(n + 1, 0), pos(n + 1, 0);
	for (int i = 1; i <= n; i++)
		cin >> a[i];

	//构建单调栈
	stack<int>sk;
	for (int i = 1; i <= n; i++) {
		while (sk.size() && a[sk.top()] <= a[i])//构建单调不递增的栈
			sk.pop();
		if (sk.size())//栈内有元素,则栈顶元素就是结果
			pos[i] = sk.top();
		sk.push(i);
	}

	for (int i=1;i<= n;i++)
		cout << pos[i] << ' ';
	return 0;
}

根据代码进行样例分析:

数组 a 1 4 10 6 3 3 15 21 8 下标 1 2 3 4 5 6 7 8 9 p o s 0 0 0 \begin{array}{|c|c|c|c|c|c|c|c|c|c|}\hline 数组a&1&4&10&6&3&3&15&21&8\\\hline 下标&1&2&3&4&5&6&7&8&9\\\hline pos&0&0&0&&&&&&\\\hline \end{array} 数组a下标pos110420103064353615721889

遍历1、4、10时,左边不存在比当前数大的数,因此标记为0.

单调栈变化:

1 ( 1 ) → 2 ( 4 ) → 3 ( 10 ) \begin{array}{|c|}\hline 1(1)&&&\\\hline\end{array}\rightarrow\begin{array}{|c|}\hline 2(4)&&&\\\hline\end{array}\rightarrow\begin{array}{|c|}\hline 3(10)&&&\\\hline\end{array} 1(1)→2(4)→3(10)

关于 4 的下标 2 入栈, 1 的下标 1 出栈的情况( a[st.top()]<=a[i] ):

  1. 栈顶元素不是元素要找的数。
  2. 栈顶元素必定不是后面的元素要找的数。

例如这里,小于 1 的数必定小于 4 ,只会优先考虑 4 而不是 0,例如 [1,4,0] 中的 0 ,左边最近的比 0 大的数是 4 而不是 1,填写表格时不会填 1 。

之后单调栈的变化:

3 ( 10 ) → 3 ( 10 ) 4 ( 6 ) \begin{array}{|c|}\hline 3(10)&&\\\hline\end{array}\rightarrow\begin{array}{|c|}\hline 3(10)&4(6)&\\\hline\end{array} 3(10)→3(10)4(6)

→ 3 ( 10 ) 4 ( 6 ) 5 ( 3 ) → 3 ( 10 ) 4 ( 6 ) → 3 ( 10 ) 4 ( 6 ) 6 ( 3 ) \rightarrow\begin{array}{|c|}\hline 3(10)&4(6)&5(3)\\\hline\end{array}\rightarrow\\\begin{array}{|c|}\hline 3(10)&4(6)&\\\hline\end{array}\rightarrow\\\begin{array}{|c|}\hline 3(10)&4(6)&6(3)\\\hline\end{array} →3(10)4(6)5(3)→3(10)4(6)→3(10)4(6)6(3)

继续填表:

数组 a 1 4 10 6 3 3 15 21 8 下标 1 2 3 4 5 6 7 8 9 p o s 0 0 0 3 4 4 \begin{array}{|c|c|c|c|c|c|c|c|c|c|}\hline 数组a&1&4&10&6&3&3&15&21&8\\\hline 下标&1&2&3&4&5&6&7&8&9\\\hline pos&0&0&0&3&4&4&&&\\\hline \end{array} 数组a下标pos110420103064335436415721889

这里的出栈条件是 a[sk.top()]>a[i] ,例如这里 4(6) 小于栈顶元素 3(10) ,则更新结果:4(6) 的左边最近的大于 6 的值是 10 ,于是下标 3 填进表格,同时将 6 本身的下标压进栈。 5(3) 同理。

6(3) 看栈顶代表元素 6(3) 和自己一样大,则出栈,直到找到第 1 个比它大的数 4(6) , 6 的下标 4 压进栈。

时间复杂度:这里看似2个循环,实际上在遍历的过程中只会进栈一次,出栈的部分相对于原来的数组,元素个数非常少几乎忽略不计,整体相当于遍历数组 2 次,也就是 O ( 2 n ) \text{O}(2n) O(2n) ,省略系数,时间复杂度就是 O ( n ) \text{O}(n) O(n) 。

P5788 【模板】单调栈 - 洛谷

P5788 【模板】单调栈 - 洛谷

寻找当前元素右侧,离它最近,并且比它大的元素在哪。比起找左侧,也只是将顺序进行逆向枚举。

cpp 复制代码
#include<bits/stdc++.h>
using namespace std;

int main() {
	int n; cin >> n;
	vector<int>a(n + 1, 0), pos(n + 1, 0);
	for (int i = 1; i <= n; i++)
		cin >> a[i];

	//构建单调栈
	stack<int>sk;
	for (int i = n; i >= 1; i--) {//找右侧则逆向枚举
		while (sk.size() && a[sk.top()] <= a[i])//构建单调不递增的栈
			sk.pop();
		if (sk.size())//栈内有元素,则栈顶元素就是结果
			pos[i] = sk.top();
		sk.push(i);
	}

	for (int i=1;i<= n;i++)
		cout << pos[i] << ' ';
	return 0;
}

最近最小问题和总结

找最大值或最小值,只需要在上述代码的基础上更改 while 循环内不等式的符号即可。

例如寻找当前元素左侧 ,离它最近 ,并且比它的元素在哪:

cpp 复制代码
//构建单调栈
stack<int>sk;
for (int i = 1; i <= n; i++) {//寻找左侧
	while (sk.size() && a[sk.top()] >= a[i])//构建单调不递减的栈
		sk.pop();
	if (sk.size())//栈内有元素,则栈顶元素就是结果
		pos[i] = sk.top();
	sk.push(i);
}

测试样例:

复制代码
9
1 4 10 6 3 3 15 21 8

输出结果:

复制代码
0 1 2 2 1 1 6 7 6

寻找当前元素右侧 ,离它最近 ,并且比它的元素在哪,只需要在上个代码的基础上修改遍历方向即可。

cpp 复制代码
//构建单调栈
stack<int>sk;
for (int i = n; i >= 1; i--) {//找右侧则逆向枚举
	while (sk.size() && a[sk.top()] >= a[i])//构建单调不递减的栈
		sk.pop();
	if (sk.size())//栈内有元素,则栈顶元素就是结果
		pos[i] = sk.top();
	sk.push(i);
}

测试样例:

复制代码
9
1 4 10 6 3 3 15 21 8

输出结果:

复制代码
0 5 4 5 0 0 9 9 0

总结这4个问题用单调栈解决:

  • 找左侧,正遍历;找右侧,逆遍历;
  • 比它大,单调减;比它小,单调增。

P1901 发射站 - 洛谷

P1901 发射站 - 洛谷

分别对左侧和右侧使用单调栈,找到比当前发射站高的发射站,将能量叠加到它们的名下,然后取最大值即可。

cpp 复制代码
#include <bits/stdc++.h>
using namespace std;
using vi = vector<int>;

void get_left(vi &enr, vi &a, vi &V) {
    stack<int> sk;
    for (int i = 1; i < a.size(); i++) {
        while (sk.size() && a[sk.top()] <= a[i])
            sk.pop();
        if (sk.size())
            enr[sk.top()] += V[i];
        sk.push(i);
    }
}
void get_right(vi &enr, vi &a, vi &V) {
    stack<int> sk;
    for (int i = a.size() - 1; i >= 1; i--) {
        while (sk.size() && a[sk.top()] <= a[i])
            sk.pop();
        if (sk.size())
            enr[sk.top()] += V[i];
        sk.push(i);
    }
}

int main() {
    int n;
    cin >> n;
    vi H(n + 1, 0), V(n + 1, 0);
    vi enr(H); // 每个塔最多能接收的能量
    for (int i = 1; i <= n; i++)
        cin >> H[i] >> V[i];
    get_left(enr, H, V);
    get_right(enr, H, V);
    int ans = 0;
    for (int i = 1; i <= n; i++) 
        ans = max(ans, enr[i]);
    cout << ans;
    return 0;
}

B4273 最大的矩形纸片 - 洛谷

B4273 [蓝桥杯青少年组省赛 2023\] 最大的矩形纸片 - 洛谷](https://www.luogu.com.cn/problem/B4273) 可定义一个扫描线,这个扫描线从上往下扫描,找到该扫描线能分割出的矩形,找出最大值即可。这个实现可以用线段树、树状数组、单调栈。这里使用单调栈实现。 当扫描线和某个柱子的顶部重合时,可在两端**离它最近的小于它的柱子** 之间找到高为它,宽为两端离它最近的小于它的柱子的坐标之差减一的矩形。例如下图的 x x x 和 y y y 。 但也有找不到的,则说明左边或右边没有小于它的柱子,扫描线可以截取多个高度一样的柱子。或者说在两端存在一个高度为 0 的柱子,两端离它最近的小于它的柱子就是这俩虚幻的柱子。例如下图标红的 x ′ x\^{\\prime} x′ 和 y ′ y\^{\\prime} y′ 。 ![请添加图片描述](https://i-blog.csdnimg.cn/direct/687174e8226a478b904bdc9d3a5e81b7.png) 因此对每个柱子使用单调栈求它的两端最近的比它小的柱子,然后再模拟扫描线遍历矩阵即可。 ```cpp #include using namespace std; using LL = long long; using vll = vector; void close_num(vll &num, vll &cn, bool flag) { // flag为1则找右边 stack sk; int stt = 1, ed = num.size() - 1; if (flag) swap(stt, ed); for (int i = stt; flag ? i >= ed : i <= ed; flag ? i-- : i++) { while (sk.size() && num[sk.top()] >= num[i]) sk.pop(); if (sk.size()) cn[i] = sk.top(); else cn[i] = flag ? stt + 1 : 0; sk.push(i); } } int main() { vll num, lcn, rcn; // lcn: left close num左近小 LL n, maxx = 0; cin >> n; num.resize(n + 1, 0); rcn = lcn = num; for (int i = 1; i <= n; i++) cin >> num[i]; close_num(num, lcn, 0); // 单调栈 close_num(num, rcn, 1); for (int i = 1; i < lcn.size(); i++) { maxx = max(maxx, num[i] * (rcn[i] - lcn[i] - 1)); } cout << maxx << '\n'; return 0; } ``` 对于同样的扫描线,这个 OJ [SP1805 HISTOGRA - Largest Rectangle in a Histogram - 洛谷](https://www.luogu.com.cn/problem/SP1805) 考察的也是这个。但需要到 SPOJ 平台进行提交,是波兰的 OJ 平台,且评测服务稳定较差,很容易提交不上。 ## 单调队列原理 单调队列是存储的元素要么单调(非)递增要么单调(非)递减的队列。 这里的队列和普通的队列不一样,是一个**双端队列** (支持两端插入、删除的队列),若不手动实现的话就需要使用 STL 的工具 `deque` 和其他类似的工具例如 `list` 。 单调队列一般用于**解决滑动窗口内最大值和最小值问题** ,以及用于优化动态规划。滑动窗口见[用于枚举优化的同向双指针-CSDN博客](https://blog.csdn.net/m0_73693552/article/details/146941532)。 ### P1886 单调队列 - 洛谷 [P1886 滑动窗口 /【模板】单调队列 - 洛谷](https://www.luogu.com.cn/problem/P1886) [1597:【 例 1】滑动窗口](http://ybt.ssoier.cn:8088/problem_show.php?pid=1597) 单调栈实际是使用数据结构栈 + 贪心来解决问题的,因此单调队列也是双端队列 + 贪心来解决问题。 以这个题为例,对于数组 `a[i]={3,1,15,10,7,2,5,14,14}` 以及 k = 3 k = 3 k=3,设`q`为`deque`(或`list`)为模型的双端队列,则整个过程: 窗口位置 最小值 最大值 \[3 1 15\] 10 7 2 5 14 14 1 15 3 \[1 15 10\] 7 2 5 14 14 1 15 3 1 \[15 10 7\] 2 5 14 14 7 15 3 1 15 \[10 7 2\] 5 14 14 2 10 3 1 15 10 \[7 2 5\] 14 14 2 7 3 1 15 10 7 \[2 5 14\] 14 2 14 3 1 15 10 7 2 \[5 14 14\] 5 14 \\begin{array}{\|c\|c\|c\|}\\hline \\textsf{窗口位置} \& \\textsf{最小值} \& \\textsf{最大值} \\\\ \\hline \\verb!\[3 1 15\] 10 7 2 5 14 14 ! \& 1 \& 15 \\\\ \\hline \\verb!3 \[1 15 10\] 7 2 5 14 14 ! \& 1 \& 15 \\\\ \\hline \\verb!3 1 \[15 10 7\] 2 5 14 14 ! \& 7 \& 15 \\\\ \\hline \\verb!3 1 15 \[10 7 2\] 5 14 14 ! \& 2 \& 10 \\\\ \\hline \\verb!3 1 15 10 \[7 2 5\] 14 14 ! \& 2 \& 7 \\\\ \\hline \\verb!3 1 15 10 7 \[2 5 14\] 14 ! \& 2 \& 14 \\\\ \\hline \\verb!3 1 15 10 7 2 \[5 14 14\] ! \& 5 \& 14 \\\\ \\hline \\end{array} 窗口位置\[3 1 15\] 10 7 2 5 14 14 3 \[1 15 10\] 7 2 5 14 14 3 1 \[15 10 7\] 2 5 14 14 3 1 15 \[10 7 2\] 5 14 14 3 1 15 10 \[7 2 5\] 14 14 3 1 15 10 7 \[2 5 14\] 14 3 1 15 10 7 2 \[5 14 14\] 最小值1172225最大值1515151071414 当窗口的下标的长度为 k k k 时才会统计结果,所以只会从 k k k 处开始记录。但需要从头开始遍历,这里简单描述遍历找最大值的过程: 对数组 `[3,1,15,10,7,2,5,14,14] ` ,遍历到3时队长不够,插入队尾,此时`q==deque({3})`(也可以是 `list` )。之后的情况进行分类讨论: 1. `a[i]({3})` 例如遍历到 1 时, `q` 的队尾有个 3 ,因为计算机在运行这个可执行程序时无法确定后续是否有比 1 小的数(例如数组 `a[i]` 不是 `{3,1,15,10,7,2,5,14,14}` 而是`{3,1,0,-1}`,此时1在窗口 `{1,0,-1}` 就是最大值)。 因此需要将 1 **插入队尾** ,后续情况同样如此。此时队列整体呈现单调递减(`q==deque({3,1})`)。 2. `a[i]>q.back();q==deque({3,1})` 原数组 `[3,1,15,10,7,2,5,14,14] ` 。 遍历到15时,15 **大于队尾** 1,则**队尾元素必定不可能是之后某个窗口内的最大值** ,因为后续滑动的过程中,1所在的窗口只有`{3,1,15}`和`{1,15,10}`,而且后续1还会被踢出窗口(`{15,10,7}`)。 因此1需要**从队尾弹出** ,队列变成`deque({3})`。 新的队尾 3 也是同样的道理,所以也被踢出队尾,**直到窗口内没有元素或队尾元素大于** `a[i]`(这里是15),此时将 15 **插入队尾**。 此时**窗口合法** (指窗口长度为`k`且队列有元素),则**队首即为当前窗口的最大值**。 遍历到10、7时满足情况1,因此插入队尾,队列`q==deque({15,10,7})`依旧单调递减。所以队首还是最大值。 这个过程和单调栈保持单调性的操作是一样的,但单调队列有长度限制。 3. `q.size()>k;` 对数组 `[3,1,15,10,7,2,5,14,14] `,假设遍历到2,满足情况1,插入队列`q`, `q=deque({15,10,7,2})`。因为窗口的长度超过规定的窗口长度`k`,所以窗口不合法,需要将队首元素15从**队首弹出**。 窗口合法后,队首即为当前窗口最大值,所以窗口`{10,7,2}`的最大值是10。 4. `a[i]==q.back()` 例如遍历到最后1个14,此时队列为 `deque({2,5,14})`,此时需要将队内14**从队尾弹出**,将新的14插入队列。这样无论后续是否还有元素,新的14依旧可以作为新的窗口的一部分。 但队列需要存储下标,此时可通过队首和队尾的下标之差判断窗口是否合法,否则无法判断窗口是否合法或需要额外的双指针。 [P1886 滑动窗口 /【模板】单调队列 - 洛谷](https://www.luogu.com.cn/problem/P1886) 和 [1597:【 例 1】滑动窗口](http://ybt.ssoier.cn:8088/problem_show.php?pid=1597) 的参考程序: ```cpp #include using namespace std; int main() { int n, k; cin >> n >> k; vectora(size_t(n) + 1, 0); for (int i = 1; i <= n; i++) cin >> a[i]; dequeq; //寻找窗口最小值 for (int i = 1; i <= n; i++) { while (!q.empty() && a[q.back()] >= a[i])//构建单调递减队列 q.pop_back(); q.push_back(i); while (q.back()-q.front()+1 > k)//判断窗口是否合法,这也是为什么队列存的是下标 q.pop_front(); if (size_t(i) >= k) cout << a[q.front()] << ' '; } cout << endl; q.clear(); //寻找窗口最大值 for (int i = 1; i <= n; i++) { while (!q.empty() && a[q.back()] <= a[i])//构建单调递减队列 q.pop_back(); q.push_back(i); while (q.back() - q.front() + 1 > k)//判断窗口是否合法 q.pop_front(); if (size_t(i) >= k) cout << a[q.front()] << ' '; } return 0; } ``` ### P2251 质量检测 - 洛谷 [P2251 质量检测 - 洛谷](https://www.luogu.com.cn/problem/P2251) 单调队列模板题,但滑动窗口求的是最小值。 ```cpp #include using namespace std; using vi = vector; int main() { int n, m; vi num; cin >> n >> m; num.resize(n + 1, 0); for (int i = 1; i <= n; i++) cin >> num[i]; // 单调队列求滑动串口最小值 deque dq; for (int i = 1; i <= n; i++) { while (dq.size() && abs(num[dq.back()]) >= abs(num[i])) dq.pop_back(); dq.push_back(i); while (dq.back() - dq.front() + 1 > m) dq.pop_front(); if (i >= m) cout << num[dq.front()] << '\n'; } return 0; } ``` ## OJ参考 1. 单调栈 [P5788 【模板】单调栈 - 洛谷](https://www.luogu.com.cn/problem/P5788) [P1901 发射站 - 洛谷](https://www.luogu.com.cn/problem/P1901) \[B4273 [蓝桥杯青少年组省赛 2023\] 最大的矩形纸片 - 洛谷](https://www.luogu.com.cn/problem/B4273) [SP1805 HISTOGRA - Largest Rectangle in a Histogram - 洛谷](https://www.luogu.com.cn/problem/SP1805) 2. 单调队列 [P1886 滑动窗口 /【模板】单调队列 - 洛谷](https://www.luogu.com.cn/problem/P1886) [1597:【 例 1】滑动窗口](http://ybt.ssoier.cn:8088/problem_show.php?pid=1597) [P2251 质量检测 - 洛谷](https://www.luogu.com.cn/problem/P2251)

相关推荐
程序员阿鹏27 分钟前
73.矩阵置零
数据结构·算法·矩阵
大母猴啃编程28 分钟前
Socket编程UDP
linux·网络·c++·网络协议·udp
一叶落43841 分钟前
LeetCode 191. 位1的个数(Hamming Weight)——三种解法详解(C语言)
c语言·数据结构·算法·leetcode
程序喵大人42 分钟前
源码剖析:iostream 的缓冲区设计
开发语言·c++·iostream
liu****43 分钟前
4.哈希扩展
c++·算法·哈希算法·位图·bitset
70asunflower44 分钟前
CUDA基础知识巩固检验练习题【附有参考答案】(6)
c++·人工智能·cuda
仰泳的熊猫1 小时前
题目1882:蓝桥杯2017年第八届真题-k倍区间
数据结构·c++·算法·蓝桥杯
Mikowoo0071 小时前
Visual Studio 2022 下CUDA程序开发
c++·visual studio
Darkwanderor1 小时前
图论——拓扑排序和图上DP
c++·算法·动态规划·图论·拓扑排序
hetao17338371 小时前
2026-03-04~03-06 hetao1733837 的刷题记录
c++·算法