1. 单调栈原理与核心应用场景
单调栈(Monotonic Stack)是一种特殊的栈结构,它在算法题解中常被用来处理"下一个更大元素"这类问题。我第一次接触这个概念是在解决LeetCode 496题时,当时暴力解法超时后才意识到需要这种高效的数据结构。
栈内元素始终保持单调性(递增或递减)是它的核心特征。以递减栈为例,当新元素比栈顶大时,需要不断弹出栈顶元素,直到满足递减关系。这个弹出过程恰恰就是解决问题的关键时机 - 我们可以确定新元素就是这些被弹出元素的"下一个更大元素"。
典型应用场景包括:
- 柱状图中最大矩形(LeetCode 84)
- 每日温度(LeetCode 739)
- 接雨水(LeetCode 42)
- 股票价格跨度(LeetCode 901)
关键理解:单调栈的精髓在于"及时处理无用数据"。维护单调性的过程,实际上是在排除不可能成为解的候选者,这使得算法时间复杂度能优化到O(n)。
2. C++实现模板与细节剖析
2.1 基础实现框架
cpp复制vector<int> nextGreaterElement(vector<int>& nums) {
stack<int> st;
vector<int> res(nums.size());
for(int i = 0; i < nums.size(); i++) {
while(!st.empty() && nums[i] > nums[st.top()]) {
res[st.top()] = nums[i]; // 找到更大元素
st.pop();
}
st.push(i);
}
while(!st.empty()) { // 剩余元素无解
res[st.top()] = -1;
st.pop();
}
return res;
}
几个易错细节:
- 存储索引而非值:这样既能比较元素大小,又能知道结果存放位置
- 循环终止条件:必须先判断!st.empty(),否则空栈访问top()会段错误
- 剩余元素处理:遍历结束后栈中剩余元素对应的结果需要单独设置
2.2 模板变种与适配
根据问题需求,模板会有以下常见变体:
-
单调方向:
- 递增栈:求下一个更小元素
- 递减栈:求下一个更大元素
-
元素比较:
- 直接比较数值
- 比较经过转换的值(如高度减去索引)
-
结果记录:
- 存储差值而非具体值
- 记录索引间距而非目标值
cpp复制// 每日温度变种:求需要等待的天数
vector<int> dailyTemperatures(vector<int>& T) {
stack<int> st;
vector<int> res(T.size());
for(int i = 0; i < T.size(); ++i) {
while(!st.empty() && T[i] > T[st.top()]) {
res[st.top()] = i - st.top(); // 记录天数差
st.pop();
}
st.push(i);
}
return res;
}
3. 经典问题实战解析
3.1 柱状图最大矩形(LeetCode 84)
这是单调栈的标杆级应用题,需要同时处理左右边界:
cpp复制int largestRectangleArea(vector<int>& heights) {
stack<int> st;
heights.push_back(0); // 哨兵值
int maxArea = 0;
for(int i = 0; i < heights.size(); ) {
if(st.empty() || heights[i] > heights[st.top()]) {
st.push(i++);
} else {
int h = heights[st.top()];
st.pop();
int width = st.empty() ? i : i - st.top() - 1;
maxArea = max(maxArea, h * width);
}
}
return maxArea;
}
关键技巧:
- 末尾添加哨兵值0,确保所有柱子都能被处理
- 宽度计算分两种情况:
- 栈空时:当前索引即宽度(如[2,1,2]中的第一个2)
- 非空时:i - st.top() - 1(中间跨度)
3.2 接雨水问题(LeetCode 42)
二维积水问题需要维护递减栈:
cpp复制int trap(vector<int>& height) {
stack<int> st;
int water = 0;
for(int i = 0; i < height.size(); i++) {
while(!st.empty() && height[i] > height[st.top()]) {
int bottom = height[st.top()];
st.pop();
if(st.empty()) break;
int left = st.top();
int w = i - left - 1;
int h = min(height[left], height[i]) - bottom;
water += w * h;
}
st.push(i);
}
return water;
}
实测发现:当处理到height[i]时,栈中元素从顶到底对应的是从右到左的递减序列,这个性质决定了可以形成"水洼"。
4. 工程实践中的优化技巧
4.1 空间优化策略
标准实现需要O(n)额外空间存储结果,某些情况下可以优化:
- 原地修改:当输入数组可被修改时
cpp复制vector<int> nextGreaterElements(vector<int>& nums) {
stack<int> st;
for(int i = 0; i < nums.size(); i++) {
while(!st.empty() && nums[i] > nums[st.top()]) {
nums[st.top()] = nums[i]; // 直接修改原数组
st.pop();
}
st.push(i);
}
// 处理剩余元素
while(!st.empty()) {
nums[st.top()] = -1;
st.pop();
}
return nums;
}
- 结果复用:当只需要返回单个最大值时
4.2 循环数组处理
对于环形数组问题(如LeetCode 503),常规解法是扩展数组,但更优雅的方式是使用模运算:
cpp复制vector<int> nextGreaterElements(vector<int>& nums) {
int n = nums.size();
vector<int> res(n, -1);
stack<int> st;
for(int i = 0; i < 2 * n; i++) {
while(!st.empty() && nums[i%n] > nums[st.top()]) {
res[st.top()] = nums[i%n];
st.pop();
}
if(i < n) st.push(i);
}
return res;
}
4.3 调试与验证技巧
- 可视化调试:打印栈状态
cpp复制void printStack(stack<int> st, vector<int>& nums) {
cout << "Current Stack: [";
while(!st.empty()) {
cout << nums[st.top()] << " ";
st.pop();
}
cout << "]" << endl;
}
- 边界测试用例:
- 空输入
- 全相同元素
- 严格递增/递减序列
- 包含INT_MIN/INT_MAX的序列
5. 常见问题与解决方案
5.1 栈溢出问题
当处理超大输入时(如1e5量级),递归实现的单调栈可能导致栈溢出。解决方案:
- 改用迭代实现
- 增加编译器栈空间(Linux下ulimit -s unlimited)
- 使用动态分配的内存模拟栈
5.2 时间复杂度误区
虽然存在嵌套循环,但实际时间复杂度仍是O(n):
- 每个元素最多入栈一次、出栈一次
- 2n次操作 → O(n)
可以通过摊还分析证明:
python复制total_operations = 0
for i in range(n):
total_operations += 1 # push
while stack and nums[i] > nums[stack[-1]]:
total_operations += 1 # pop
stack.pop()
stack.append(i)
# 总操作数 ≤ 2n
5.3 多维度比较问题
当元素需要多维度比较时(如先按高度再按索引),有两种处理方式:
- 使用pair存储多属性:
cpp复制stack<pair<int,int>> st; // {height, index}
- 定义比较函数对象:
cpp复制auto cmp = [](const Element& a, const Element& b) {
return a.h != b.h ? a.h < b.h : a.idx > b.idx;
};
6. 扩展应用与思维提升
6.1 单调队列衍生
单调栈可以看作单调队列的特例(只能操作一端)。更通用的单调队列能解决滑动窗口极值问题:
cpp复制vector<int> maxSlidingWindow(vector<int>& nums, int k) {
deque<int> dq;
vector<int> res;
for(int i = 0; i < nums.size(); i++) {
if(!dq.empty() && dq.front() == i - k)
dq.pop_front();
while(!dq.empty() && nums[dq.back()] < nums[i])
dq.pop_back();
dq.push_back(i);
if(i >= k - 1) res.push_back(nums[dq.front()]);
}
return res;
}
6.2 二维问题转化
一些二维问题可以通过降维转化为单调栈问题。如:
- 最大全1矩形(LeetCode 85):逐行构建高度数组
- 子矩阵数目统计(LeetCode 1504):将二维约束转化为一维
cpp复制int numSubmat(vector<vector<int>>& mat) {
int m = mat.size(), n = mat[0].size();
vector<int> height(n, 0);
int res = 0;
for(int i = 0; i < m; i++) {
stack<pair<int,int>> st;
for(int j = 0; j < n; j++) {
height[j] = mat[i][j] ? height[j] + 1 : 0;
int sum = 0;
while(!st.empty() && height[st.top().first] >= height[j]) {
st.pop();
}
if(st.empty()) {
sum = height[j] * (j + 1);
} else {
sum = height[j] * (j - st.top().first) + st.top().second;
}
st.push({j, sum});
res += sum;
}
}
return res;
}
6.3 与其他数据结构的结合
- 与前缀和结合:解决子数组区间问题
- 与线段树结合:处理动态更新的序列
- 与哈希表结合:需要快速查找的场景
我在实际刷题中发现,真正考验功力的不是套用模板,而是识别出哪些问题可以转化为单调栈问题。这需要培养对数据单调性的敏感度 - 当问题中隐含"最近的更大/更小元素"这类需求时,就该考虑单调栈了。
