用单调栈高效解决 “首尾均为最大值” 的子数组计数问题(Leetcode 3113)
这几天国庆有点疯,没什么好更新的博客,刚好今天刷到了这个题目,单调栈的典型应用,所以来写一篇博客记录一下(QWQ).
本题链接,大家可以先做一下
3113. 边界元素是最大值的子数组数目 - 力扣(LeetCode)
一、题目描述:明确问题边界
给定一个正整数数组 nums
,请统计其中有多少个子数组满足:子数组的第一个元素和最后一个元素,都是该子数组中的最大值。
示例理解
- 输入:
nums = [2, 1, 2]
符合条件的子数组:[2]
(首尾都是 2,最大值 2)、[1]
(首尾都是 1,最大值 1)、[2]
(首尾都是 2,最大值 2)、[2,1,2]
(首尾都是 2,最大值 2)→ 共 4 个。 - 输入:
nums = [3, 1, 3, 3]
符合条件的子数组:4 个单个元素 +[3,1,3]
+[3,1,3,3]
+[3,3]
→ 共 7 个。
二、核心思路:为什么用单调栈?
要解决这个问题,首先需要拆解 “首尾均为最大值” 的核心约束:
- 单个元素子数组必符合条件:每个元素自身组成的子数组(长度 1),首尾都是自己,天然满足 “最大值” 条件,这是基础计数(共
n
个,n
为数组长度)。 - 长度≥2 的子数组需满足两点:
- 首尾元素相等(设为
x
):若首尾元素不等,假设首元素 > 尾元素,则尾元素不是最大值;反之同理,因此首尾必须相等。 - 子数组内所有元素≤
x
:确保x
是子数组的最大值,避免中间出现更大元素破坏约束。
- 首尾元素相等(设为
单调栈的价值
直接暴力枚举所有子数组(O (n²))会超时,而非递增单调栈能高效维护 “元素≤当前处理元素” 的序列,同时记录 “有效子数组计数”,将时间复杂度降至 O (n)。
栈中存储的是(元素值, 计数)
的二元组,其中:
元素值
:维护非递增序列,确保栈内元素≥当前处理元素(满足 “子数组内元素≤首尾”)。计数
:以该元素为结尾的、符合条件的子数组数量(用于递推统计首尾相等的有效子数组)。
三、代码实现与逐行解析
以下是基于单调栈的最优解法(即用户原创的正确代码),我们逐行拆解其逻辑:
cpp
运行
#include <vector>
#include <stack>
using namespace std;class Solution {
public:long long numberOfSubarrays(vector<int>& nums) {long long ans = 0; // 结果(用long long避免溢出)// 栈存储:<元素值, 以该元素为结尾的符合条件子数组数量>stack<pair<int, int>> st;for (auto& e : nums) { // 遍历每个元素int size = 1; // 初始:单个元素自身的子数组(长度1)// 步骤1:弹出栈中比当前元素小的元素// 原因:这些元素无法与e组成有效子数组(e更大,它们不是最大值)while (!st.empty() && st.top().first < e) {st.pop();}// 步骤2:处理与栈顶相等的元素(首尾相等的有效子数组)if (!st.empty() && st.top().first == e) {ans += st.top().second; // 累加之前的有效子数组数量size += st.top().second; // 递推更新当前元素的计数st.pop(); // 弹出旧计数,避免重复统计}// 步骤3:统计单个元素的子数组(基础计数)ans++;// 步骤4:将当前元素及其计数压入栈,维护非递增序列st.push({e, size});}return ans;}
};
四、测试用例验证:代码正确性
我们用 3 个典型测试用例验证代码逻辑,确保覆盖不同场景:
测试用例 1:混合场景 nums = [2, 1, 2]
- 处理第一个
2
:- 栈空,
size=1
,ans++
(ans=1),压入(2, 1)
。
- 栈空,
- 处理
1
:- 栈顶
2>1
,不弹出,size=1
,ans++
(ans=2),压入(1, 1)
。
- 栈顶
- 处理第二个
2
:- 弹出
(1,1)
(1<2); - 栈顶
2==2
,ans +=1
(ans=3,对应子数组[2,1,2]
),size=1+1=2
,弹出(2,1)
; ans++
(ans=4,对应单个元素2
),压入(2, 2)
。
- 弹出
- 最终结果:
4
(正确)。
测试用例 2:连续相等元素 nums = [3, 3, 3]
- 处理第一个
3
:ans=1
,压入(3,1)
。 - 处理第二个
3
:- 栈顶
3==3
,ans +=1
(ans=2,对应[3,3]
),size=2
,弹出(3,1)
; ans++
(ans=3),压入(3,2)
。
- 栈顶
- 处理第三个
3
:- 栈顶
3==3
,ans +=2
(ans=5,对应[3,3]
、[3,3,3]
),size=3
,弹出(3,2)
; ans++
(ans=6),压入(3,3)
。
- 栈顶
- 最终结果:
6
(正确,对应 3 个单个元素 + 2 个长度 2 子数组 + 1 个长度 3 子数组)。
测试用例 3:严格递增序列 nums = [1, 2, 3]
- 处理
1
:ans=1
,压入(1,1)
。 - 处理
2
:- 弹出
(1,1)
(1<2); - 栈空,
ans++
(ans=2),压入(2,1)
。
- 弹出
- 处理
3
:- 弹出
(2,1)
(2<3); - 栈空,
ans++
(ans=3),压入(3,1)
。
- 弹出
- 最终结果:
3
(正确,仅单个元素子数组符合条件)。
五、复杂度分析:为什么高效?
- 时间复杂度 O (n):每个元素最多入栈 1 次、出栈 1 次,栈操作的总次数为 O (n),遍历数组也是 O (n),整体线性。
- 空间复杂度 O (n):最坏情况下(数组严格非递增),栈存储所有元素,空间为 O (n);最好情况下(严格递增),栈内最多 1 个元素,空间为 O (1)。
六、总结与拓展
代码的巧妙之处
- 非递增栈维护约束:确保栈内元素≥当前元素,天然满足 “子数组内元素≤首尾” 的条件。
- 计数递推减少重复:栈中 “计数” 记录了历史有效子数组数量,遇到相等元素时直接累加,避免暴力枚举。
- 覆盖所有场景:同时统计单个元素和长度≥2 的子数组,无漏算、无错算。
类似问题拓展
若遇到 “子数组最大值相关” 的统计问题(如 “以每个元素为最大值的子数组数量”),均可尝试用单调栈维护 “元素大小关系”,核心是通过栈记录元素的左右边界,再结合计数逻辑求解。
通过本文的分析,我们可以看到:好的算法不仅要 “正确”,更要 “高效”。单调栈作为处理 “数组最大值 + 子数组” 问题的利器,能帮我们突破暴力解法的瓶颈,而理解其背后的 “约束维护” 逻辑,才是掌握这类问题的关键。
大家可以直接跳转至灵神的单调栈题单继续学习更多关于单调栈的有趣用法哦
分享|【算法题单】单调栈(矩形面积/贡献法/最小字典序) - 讨论 - 力扣(LeetCode)