【数据结构】单调栈与单调队列深度解析:从模板到实战,一网打尽

🔭 个人主页: 散峰而望

《C语言:从基础到进阶》《编程工具的下载和使用》《C语言刷题》
《C++》《算法竞赛从入门到获奖》《人工智能》《AI Agent》
愿为出海月,不做归山云


🎬博主简介

【数据结构】单调栈与单调队列深度解析:从模板到实战,一网打尽

  • 前言
  • [1. 单调栈](#1. 单调栈)
    • [1.1 什么是单调栈](#1.1 什么是单调栈)
    • [1.2 单调栈解决的问题](#1.2 单调栈解决的问题)
    • [1.3 【模板】单调栈](#1.3 【模板】单调栈)
    • [1.4 发射站](#1.4 发射站)
    • [1.5 HISTOGRA - Largest Rectangle in a Histogram](#1.5 HISTOGRA - Largest Rectangle in a Histogram)
  • [2. 单调队列](#2. 单调队列)
    • [2.1 什么是单调队列](#2.1 什么是单调队列)
    • [2.2 单调队列解决的问题](#2.2 单调队列解决的问题)
    • [2.3 单调队列 / 滑动窗口](#2.3 单调队列 / 滑动窗口)
    • [2.3 质量检测](#2.3 质量检测)
  • 结语

前言

数据结构是算法设计的核心工具,单调栈和单调队列作为两种特殊的线性结构,在解决特定类型问题时展现出极高的效率。单调栈通过维护元素的单调性,能够快速处理最近邻相关的问题;单调队列则擅长在滑动窗口中动态维护极值。这两种结构广泛用于数组优化、区间查询等场景。

本解析从基础模板出发,逐步深入典型例题,涵盖发射站能量传递、柱状图最大矩形等经典问题,同时结合滑动窗口、质量检测等实际案例,系统剖析单调栈与单调队列的应用范式。通过代码实现与复杂度分析,帮助掌握这两种高效工具的底层逻辑与实战技巧。

1. 单调栈

1.1 什么是单调栈

单调栈是一种特殊的栈数据结构,它要求栈中的元素保持严格的单调递增或单调递减的顺序。这种单调性可以是从栈底到栈顶单调递增(单调递增栈),也可以是从栈底到栈顶单调递减(单调递减栈)。

实现方式:

以单调递增栈为例,其基本实现逻辑如下:

  1. 遍历数组元素
  2. 对于当前元素:
    • 如果栈不为空且栈顶元素大于当前元素,则弹出栈顶元素
    • 重复上述操作直到栈为空或栈顶元素小于等于当前元素
  3. 将当前元素压入栈中

维护单调栈的意义:

  1. 高效解决特定问题

    • 可以快速找到数组中某个元素左边/右边第一个比它大或小的元素
    • 典型应用包括:
      • 柱状图中的最大矩形面积问题
      • 接雨水问题
      • 每日温度问题(找下一个更高温度的天数)
  2. 时间复杂度优势

    • 每个元素最多入栈和出栈各一次
    • 总体时间复杂度为 O(n),比暴力解法更高效
  3. 空间复杂度优化

    • 只需要额外的栈空间
    • 最坏情况下空间复杂度为 O(n)
cpp 复制代码
#include <iostream>
#include <stack>

using namespace std;

const int N = 1e6 + 10;

int a[N], n;

void test1()
{
	stack<int> st;//维护一个单调递增的栈
	for(int i = 1; i <= n; i++)
	{
		//栈里面大于等于a[i]的元素全部出栈 
		while(st.size() && st.top() >= a[i]) st.pop();
		st.push(a[i]);
	} 
}

void test2()
{
	stack<int> st;//维护一个单调递减的栈
	for(int i = 1; i <= n; i++)
	{
		//栈里面小于等于a[i]的元素全部出栈
		while(st.size() && st.top() <= a[i]) st.pop();
		st.push(a[i]); 
	} 
}

1.2 单调栈解决的问题

单调栈能帮助我们解决以下四个问题:

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

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

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

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

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

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

【测试用例】

输入:

9

1 4 10 6 3 3 15 21 8

输出:

0 0 0 3 4 4 0 0 8

【代码实现】

cpp 复制代码
//寻找当前元素左侧,离它最近,并且比它大的元素在哪
#include <iostream>
#include <stack>

using namespace std;

const int N = 1e6 + 10;

int a[N], n;
int ret[N];

void test()
{
	stack<int> st;
	for(int i = 1; i <= n; i++)
	{
		while(st.size() && a[st.top()] <= a[i]) st.pop();
		
		//如果此时栈顶元素存在,栈顶元素就是所求结果
		if(st.size()) ret[i] = st.top();
		st.push(i);//存的是下标 
	}
	
	for(int i = 1; i <= n; i++)
	{
		cout << ret[i] << " ";
	} 
	cout << endl;
}

int main()
{
	cin >> n;
	for(int i = 1; i <= n; i++) cin >> a[i];
	
	test(); cout << endl;
	return 0;
}

【测试结果】

  1. 寻找当前元素左侧,离它最近,并且比它小的元素在哪

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

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

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

【测试用例】

输入:

9

1 4 10 6 3 3 15 21 8

输出:

0 1 2 2 1 1 6 7 6

【代码实现】

cpp 复制代码
//寻找当前元素左侧,离它最近,并且比它小的元素在哪
#include <iostream>
#include <stack>

using namespace std;

const int N = 1e6 + 10;

int a[N], n;
int ret[N];

void test()
{
	stack<int> st;
	for(int i = 1; i <= n; i++)
	{
		while(st.size() && a[st.top()] >= a[i]) st.pop();
		
		//如果此时栈顶元素存在,栈顶元素就是所求结果
		if(st.size()) ret[i] = st.top();
		st.push(i);//存的是下标 
	}
	
	for(int i = 1; i <= n; i++)
	{
		cout << ret[i] << " ";
	} 
	cout << endl;
}

int main()
{
	cin >> n;
	for(int i = 1; i <= n; i++) cin >> a[i];
	
	test(); cout << endl;
	return 0;
}

【测试结果】

  1. 寻找当前元素右侧,离它最近,并且比它大的元素在哪

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

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

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

【测试用例】

输入:

9

1 4 10 6 3 3 15 21 8

输出:

2 3 7 7 7 7 8 0 0

【代码实现】

cpp 复制代码
#include <iostream>
#include <stack>

using namespace std;

const int N = 1e6 + 10;

int a[N], n;
int ret[N];

void test()
{
    stack<int> st;
    for(int i = n; i >= 1; i--)
    { 
        while(st.size() && a[st.top()] <= a[i]) st.pop();
        
        // 此时栈顶元素存在,栈顶元素就是所求结果 
        if(st.size()) ret[i] = st.top();
        st.push(i); // 存的是下标 
    }
    
    for(int i = 1; i <= n; i++)
    {
        cout << ret[i] << " ";
    }
    cout << endl;
}

int main()
{
    cin >> n;
    for(int i = 1; i <= n; i++) cin >> a[i];
    test(); cout << endl;
    return 0;
}

【测试结果】

  1. 寻找当前元素右侧,离它最近,并且比它小的元素在哪

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

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

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

【测试用例】

输入:

9

1 4 10 6 3 3 15 21 8

输出:

0 5 4 5 0 0 9 9 0

【代码实现】

cpp 复制代码
#include <iostream>
#include <stack>

using namespace std;

const int N = 1e6 + 10;

int a[N], n;
int ret[N];

void test()
{
    stack<int> st;
    for(int i = n; i >= 1; i--)
    { 
        while(st.size() && a[st.top()] >= a[i]) st.pop();
        
        // 此时栈顶元素存在,栈顶元素就是所求结果 
        if(st.size()) ret[i] = st.top();
        st.push(i); // 存的是下标 
    }
    
    for(int i = 1; i <= n; i++)
    {
        cout << ret[i] << " ";
    }
    cout << endl;
}

int main()
{
    cin >> n;
    for(int i = 1; i <= n; i++) cin >> a[i];
    
    test(); cout << endl;
    return 0;
}

【测试结果】

总结:

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

1.3 【模板】单调栈

【模板】单调栈

算法原理:

右侧离它最近并且比它大的元素:

  • 逆序遍历数组;
  • 构造一个单调递减的栈;
  • 进栈时,栈顶元素就是最终结果。

参考代码:

cpp 复制代码
#include <iostream>
#include <stack>

using namespace std;

const int N = 3e6 + 10;

int n;
int a[N];
int ret[N];

int main()
{
	cin >> n;
	for(int i = 1; i <= n; i++) cin >> a[i];
	
	stack<int> st;
	for(int i = n; i >= 1; i--)
	{
		while(st.size() && a[st.top()] <= a[i]) st.pop();
		if(st.size()) ret[i] = st.top();
		st.push(i);
	}
	for(int i = 1; i <= n; i++) cout << ret[i] << " ";
    cout << endl;	
	return 0;
} 

1.4 发射站

发射站

算法原理:

寻找当前元素右侧,离它最近,并且比它大的元素在哪 以及寻找当前元素左侧,离它最近,并且比它大的元素在哪的结合。

剩下的就是简单的模拟过程。

参考代码:

cpp 复制代码
#include <iostream>
#include <stack>

using namespace std;

typedef long long LL;

const int N = 1e6 + 10;

int n;
LL h[N], v[N];
LL sum[N];

int main()
{
	cin >> n;
	for(int i = 1; i <= n; i++) cin >> h[i] >> v[i];
	
	stack<int> st;
	
	//找左边
	for(int i = 1; i <= n; i++)
	{
		while(st.size() && h[st.top()] <= h[i]) st.pop();
		if(st.size())
		{
			sum[st.top()] += v[i];
		}
		st.push(i);
	} 
	
	//找右边
	while(st.size()) st.pop();//清空
	for(int i = n; i >= 1; i--)
	{
		while(st.size() && h[st.top()] <= h[i]) st.pop();
		if(st.size())
		{
			sum[st.top()] += v[i];
		}
		st.push(i);
	} 
	
	LL ret = 0;
	for(int i = 1; i <= n; i++) ret = max(ret, sum[i]);
	
	cout << ret << endl;
	
	return 0;
}

1.5 HISTOGRA - Largest Rectangle in a Histogram

HISTOGRA - Largest Rectangle in a Histogram(洛谷)

HISTOGRA - Largest Rectangle in a Histogram(SPOJ)

算法原理:

  1. 扫描线
    一种常用于计算几何和计算机图形学中的高效算法,特别适用于解决平面或空间中的覆盖问题。其基本思想是:
  • 将问题转化为在一条虚拟的扫描线移动过程中处理事件
  • 通常从左到右或从上到下扫描
  • 维护当前扫描线状态的数据结构(如线段树)
  • 在关键事件点(如线段端点)触发处理逻辑
  1. 单调栈
    对于 x 位置子矩阵,找到左侧离它最近并且比它小的位置 y ,那么 [x+1, y] 之间就是该矩阵能到达的左端。
    同理再找到右侧离它最近并且比它小的位置 z ,那么 [y, z−1] 之间就是该矩阵能到达的右端。
    对于每一个子矩阵,求出它向左以及向右能延伸的最大长度即可。

参考代码:

cpp 复制代码
#include <iostream>
#include <stack>

using namespace std;

typedef long long LL;

const int N = 1e5 + 10;

int n;
LL h[N];
LL x[N], y[N];

int main()
{
	while(cin >> n, n)
	{
		for(int i = 1; i <= n; i++) cin >> h[i];
		
		stack<int> st;
		
		//找左边
		for(int i = 1; i <= n; i++)
		{
			while(st.size() && h[st.top()] >= h[i]) st.pop();
			if(st.size()) x[i] = st.top();
			else x[i] = 0;
			st.push(i);
		} 
		
		while(st.size()) st.pop();
		
		//找右边
		for(int i = n; i >= 1; i--)
		{
			while(st.size() && h[st.top()] >= h[i]) st.pop();
			if(st.size()) y[i] = st.top();
			else y[i] = n + 1;
			st.push(i);
		}
		LL ret = 0;
	    for(int i = 1; i <= n; i++) 
	    {
	    	ret = max(ret, h[i] * (y[i] - x[i] - 1));
		}
	
	    cout << ret << endl; 
		
	}
	return 0; 
}

2. 单调队列

2.1 什么是单调队列

单调队列是一种特殊的双端队列,它要求队列中的元素始终保持单调递增或单调递减的特性。与普通队列只能在一端进行入队操作、另一端进行出队操作不同,单调队列允许在两端进行操作,这为实现其单调性维护提供了便利。

具体来说,单调队列可以分为两种类型:

  1. 单调递增队列:队列中的元素按照从队首到队尾的顺序严格递增
  2. 单调递减队列:队列中的元素按照从队首到队尾的顺序严格递减

2.2 单调队列解决的问题

一般用于解决滑动窗口内最大值最小值问题,以及优化动态规划。

2.3 单调队列 / 滑动窗口

【模板】单调队列 / 滑动窗口

算法原理:

窗口内最大值:

从左往右遍历元素,维护一个单调递减的队列:

  • 当前元素进队之后,注意维护队列内的元素在大小为 k 的窗口内;
  • 此时队头元素就是最大值。

窗口内最小值:

从左往右遍历元素,维护一个单调递增的队列:

  • 当前元素进队之后,注意维护队列内的元素在大小为 k 的窗口内;
  • 此时队头元素就是最小值。
cpp 复制代码
#include <iostream>
#include <deque>

using namespace std;

const int N = 1e6 + 10;

int n, k;
int a[N];

int main()
{
	cin >> n >> k;
	for(int i = 1; i <= n; i++) cin >> a[i];
	
	deque<int> q;//存下标
	
	//窗口内最小值 - 单调递增的队列 
	for(int i = 1; i <= n; i++)
	{
		while(q.size() && a[q.back()] >= a[i]) q.pop_back();
		
		q.push_back(i);
		
		//判断队列里面的元素是否在合法窗口内
		if(q.back() - q.front() + 1 > k) q.pop_front();
		
		if(i >= k) cout << a[q.front()] << " "; 
	} 
	cout << endl;
	
	q.clear();//清空队列 
	
	//窗口内最大值 - 单调递减的队列 
	for(int i = 1; i <= n; i++) 
	{
		while(q.size() && a[q.back()] <= a[i]) q.pop_back();
		
		q.push_back(i);
		
		//判断队列里面的元素是否在合法窗口内
		if(q.back() - q.front() + 1 > k) q.pop_front();
		
		if(i >= k) cout << a[q.front()] << " "; 
	}
	cout << endl;
	
	return 0;
} 

2.3 质量检测

质量检测

算法原理:

cpp 复制代码
#include <iostream>
#include <deque>

using namespace std;

const int N = 1e6 + 10;

int n, k;
int a[N];

int main()
{
	cin >> n >> k;
	for(int i = 1; i <= n; i++) cin >> a[i];
	
	//窗口最小值
	deque<int> q;
	for(int i = 1; i <= n; i++)
	{
		while(q.size() && a[q.back()] >= a[i]) q.pop_back();
		
		q.push_back(i);
		
		//维护队列内元素合法
		if(i - q.front() + 1 > k) q.pop_front();
		
		//输出结果
		if(i >= k) cout << a[q.front()] << endl;
	} 
	
	return 0;
} 

结语

单调栈与单调队列是解决区间极值问题的高效工具。通过维护数据的单调性,它们能够将许多看似复杂的问题优化到线性时间复杂度。掌握这两种数据结构,能够显著提升解决算法问题的能力。从模板到实战,理解其核心思想并灵活应用,是算法学习中的关键一步。

愿诸君能一起共渡重重浪,终见缛彩遥分地,繁光远缀天

相关推荐
qwehjk20082 小时前
内存泄漏自动检测系统
开发语言·c++·算法
华科大胡子2 小时前
91行代码创意赛
开发语言
研究点啥好呢2 小时前
3月28日Github热榜推荐 | 你还没有为AI接一个数据库吗
数据库·人工智能·驱动开发·github
tankeven2 小时前
HJ153 实现字通配符*
c++·算法
草莓熊Lotso2 小时前
MySQL 多表连接查询实战:内连接 + 外连接
android·运维·数据库·c++·mysql
两年半的个人练习生^_^2 小时前
dynamic-datasource多数据源使用和源码讲解
java·开发语言·数据库·mybatis
旖-旎2 小时前
位运算(两整数之和)(3)
c++·算法·leetcode·位运算
杨校2 小时前
杨校老师课堂备战C++之数据结构中栈结构专题训练
开发语言·数据结构·c++
无籽西瓜a2 小时前
【西瓜带你学设计模式 | 第一期-单例模式】单例模式——定义、实现方式、优缺点与适用场景以及注意事项
java·后端·单例模式·设计模式