1. 为什么我们需要单调栈?
单调栈(Monotonic Stack)是算法领域一个看似简单却威力巨大的数据结构。我第一次接触这个概念是在解决LeetCode第42题"接雨水"时,当时用暴力解法折腾了半天,直到发现单调栈这个"神器"——代码量直接从50行缩减到15行,时间复杂度从O(n²)优化到O(n)。
单调栈的核心在于维护一个栈内元素保持单调性(递增或递减)。当新元素不满足当前单调性时,我们就弹出栈顶元素,这个弹出的过程往往就是解决问题的关键。这种结构特别适合处理"下一个更大/更小元素"这类问题。
注意:单调栈不是C++标准库中的现成容器,而是一种使用栈的特定方法。我们需要用stack容器配合特定算法来实现。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 单调栈的两种基本类型
2.1 单调递增栈
维护一个从栈底到栈顶递增的栈结构。当遇到新元素小于栈顶时,需要弹出栈顶元素直到满足递增条件。这种结构常用于寻找"左边/右边第一个更小元素"的问题。
cpp复制stack<int> st;
for(int i = 0; i < nums.size(); i++) {
while(!st.empty() && st.top() > nums[i]) {
// 处理弹出的元素
st.pop();
}
st.push(nums[i]);
}
2.2 单调递减栈
与递增栈相反,维护从栈底到栈顶递减的顺序。适合解决"左边/右边第一个更大元素"的问题。典型应用就是前面提到的"接雨水"问题。
cpp复制stack<int> st;
for(int i = 0; i < nums.size(); i++) {
while(!st.empty() && st.top() < nums[i]) {
// 处理弹出的元素
st.pop();
}
st.push(nums[i]);
}
3. 单调栈的四大经典应用场景
3.1 下一个更大元素(LeetCode 496)
这是单调栈最直白的应用。给定一个数组,为每个元素找到其右边第一个更大的元素。
cpp复制vector<int> nextGreaterElement(vector<int>& nums) {
stack<int> st;
vector<int> res(nums.size(), -1);
for(int i = 0; i < nums.size(); i++) {
while(!st.empty() && nums[st.top()] < nums[i]) {
res[st.top()] = nums[i];
st.pop();
}
st.push(i);
}
return res;
}
3.2 每日温度(LeetCode 739)
计算需要等待多少天才能等到更暖和的温度,本质上是"下一个更大元素"的变种,只是记录的是索引差值而非元素值。
cpp复制vector<int> dailyTemperatures(vector<int>& T) {
stack<int> st;
vector<int> res(T.size(), 0);
for(int i = 0; i < T.size(); i++) {
while(!st.empty() && T[st.top()] < T[i]) {
res[st.top()] = i - st.top();
st.pop();
}
st.push(i);
}
return res;
}
3.3 接雨水(LeetCode 42)
这是单调栈的经典难题。通过维护一个单调递减栈,可以在O(n)时间内计算出能接多少雨水。
cpp复制int trap(vector<int>& height) {
stack<int> st;
int res = 0;
for(int i = 0; i < height.size(); i++) {
while(!st.empty() && height[st.top()] < height[i]) {
int top = st.top();
st.pop();
if(st.empty()) break;
int distance = i - st.top() - 1;
int bounded_height = min(height[i], height[st.top()]) - height[top];
res += distance * bounded_height;
}
st.push(i);
}
return res;
}
3.4 柱状图中最大矩形(LeetCode 84)
这个问题需要同时找到左右边界,使用单调递增栈可以高效解决。
cpp复制int largestRectangleArea(vector<int>& heights) {
stack<int> st;
heights.push_back(0); // 添加哨兵
int res = 0;
for(int i = 0; i < heights.size(); i++) {
while(!st.empty() && heights[st.top()] > heights[i]) {
int h = heights[st.top()];
st.pop();
int w = st.empty() ? i : i - st.top() - 1;
res = max(res, h * w);
}
st.push(i);
}
return res;
}
4. 单调栈的五个实现细节与陷阱
4.1 存储元素还是索引?
在实际应用中,我们通常存储索引而非元素本身,原因有三:
- 可以通过索引访问原数组中的元素
- 可以计算位置差(如"每日温度"问题)
- 处理重复元素时更灵活
4.2 边界条件处理
单调栈最容易出错的就是边界条件。常见处理技巧:
- 在数组末尾添加哨兵值(如柱状图问题中的0)
- 循环结束后处理栈中剩余元素
- 空栈情况的特殊处理
4.3 时间复杂度分析
虽然看起来有嵌套循环,但每个元素最多入栈出栈各一次,因此时间复杂度是O(n)而非O(n²)。这是单调栈高效的关键。
4.4 空间复杂度优化
单调栈本身需要O(n)空间,但在某些问题中可以进行优化。例如"接雨水"问题可以用双指针法将空间降到O(1),但代码会更复杂。
4.5 处理重复元素
当数组中存在重复元素时,需要明确处理逻辑:
- 严格单调(不允许相等)
- 非严格单调(允许相等)
这会影响while循环的条件判断,需要根据具体问题决定。
5. 单调栈的进阶应用
5.1 二维矩阵中的问题
单调栈可以扩展到二维。例如LeetCode 85"最大矩形",可以通过将问题转化为多个柱状图问题来解决。
cpp复制int maximalRectangle(vector<vector<char>>& matrix) {
if(matrix.empty()) return 0;
int m = matrix.size(), n = matrix[0].size();
vector<int> heights(n, 0);
int res = 0;
for(int i = 0; i < m; i++) {
for(int j = 0; j < n; j++) {
heights[j] = matrix[i][j] == '1' ? heights[j] + 1 : 0;
}
res = max(res, largestRectangleArea(heights));
}
return res;
}
5.2 与动态规划结合
有些问题需要结合单调栈和动态规划。例如LeetCode 907"子数组的最小值之和",可以用单调栈快速找到每个元素作为最小值的影响范围。
5.3 滑动窗口最大值
虽然这个问题通常用双端队列解决,但本质上也是维护一个单调递减的序列,与单调栈思想相通。
6. 单调栈的调试技巧
6.1 可视化栈的变化
在解决复杂问题时,我习惯在纸上画出栈的变化过程。例如对于数组[3,1,2,4]:
code复制i=0: [3]
i=1: 1<3 → pop3 → [1]
i=2: [1,2]
i=3: [1,2,4]
6.2 打印中间状态
在代码中添加临时打印语句,观察栈的变化:
cpp复制while(!st.empty() && nums[st.top()] < nums[i]) {
cout << "Pop " << nums[st.top()] << " at " << st.top() << endl;
st.pop();
}
cout << "Push " << nums[i] << " at " << i << endl;
6.3 单元测试构造
构造测试用例时考虑这些情况:
- 空数组
- 全递增序列
- 全递减序列
- 有重复元素的序列
- 随机序列
7. 从单调栈到其他数据结构
理解了单调栈后,可以自然延伸到:
- 单调队列:滑动窗口问题
- 笛卡尔树:由单调栈构建
- 优先队列:某种程度上可以看作"全局单调"
我在实际项目中曾用单调栈优化过一个商品价格分析功能,将处理时间从分钟级降到了秒级。关键在于发现价格序列中的关键转折点,这正是单调栈擅长的。
