深入浅出单调栈:算法优化的利器
摘要:单调栈是一种特殊的栈结构,通过维护栈内元素的单调性,能够高效解决数组中"下一个更大元素"等系列问题,将时间复杂度从O(n²)优化到O(n)。本文将通过多个实际案例,带你深入理解单调栈的应用场景和实现技巧。
什么是单调栈?
在计算机科学中,栈(Stack) 是一种遵循后进先出(LIFO)原则的数据结构。而单调栈(Monotonic Stack) 是在普通栈的基础上,额外维护一个性质:栈内元素始终保持单调递增或单调递减的顺序。
想象一下,你正在浏览网页,每个网页都有一个"热度值"。当你想要找到当前网页之后第一个热度更高的网页时,单调栈就能派上用场。它通过巧妙地维护一个有序的栈结构,让我们能够快速找到答案。
单调栈的核心特点
- 单调性:栈内元素要么单调递增,要么单调递减
- 高效性:能够将O(n²)的暴力解法优化为O(n)
- 适用性:特别适合解决"下一个更大/更小元素"类问题
经典应用:求下一个更大的元素
让我们从一个具体的例子开始:
// 输入: arr = [1, 3, 2, 4, 4]
// 输出: [3, 4, 4, -1, -1]
问题解析:对于数组中的每个元素,找到它右边第一个比它大的元素。如果不存在这样的元素,则返回-1。
暴力解法的局限性
很多人的第一想法是使用双重循环:
function bruteForceNextMax(arr) {
const result = [];
for (let i = 0; i < arr.length; i++) {
let found = false;
for (let j = i + 1; j < arr.length; j++) {
if (arr[j] > arr[i]) {
result.push(arr[j]);
found = true;
break;
}
}
if (!found) result.push(-1);
}
return result;
}
时间复杂度:O(n²),当数组较大时性能堪忧。
单调栈优化方案
单调栈的巧妙之处在于它能够通过一次遍历解决问题:
function nextMaxValue(arr) {
const stack = []; // 单调递减栈
const len = arr.length;
const res = new Array(len).fill(-1); // 初始化结果数组
// 从右向左遍历,维护单调递减栈
for (let i = len - 1; i >= 0; i--) {
// 移除栈中所有小于等于当前元素的值
while (stack.length > 0 && stack[stack.length - 1] <= arr[i]) {
stack.pop();
}
// 如果栈不为空,栈顶就是下一个更大的元素
if (stack.length > 0) {
res[i] = stack[stack.length - 1];
}
// 当前元素入栈
stack.push(arr[i]);
}
return res;
}
// 测试
const result = nextMaxValue([1, 3, 2, 4, 4]);
console.log(result); // [3, 4, 4, -1, -1]
时间复杂度:O(n),每个元素最多入栈出栈一次。
算法原理解析
- 从右向左遍历:确保在处理当前元素时,栈中保存的是右侧的元素
- 维护单调性:移除所有破坏单调递减顺序的元素
- 获取结果:栈顶元素就是当前元素的"下一个更大元素"
- 更新栈结构:将当前元素入栈,为后续元素提供参考
求下一个更大的元素
//输入 arr=[1, 3, 2, 4, 4]
//输出 arr=[3, 4, 4, -1, -1]
从上面的例子中可以清楚的了解题目的需求,1下一个更大的就是3,2对应着4,而4不存在下一个更大的元素,所以就是-1
很多人的第一想法就是遍历数组的每一项,在循环内部拿每一项对应的值去和后面进行依次比较,只要大于当前值,那么就可以找到下一个最大的值,然后给对应的下标进行赋值。这样解法固然可以解决题目,但是时间复杂度方面会不太友好,所以不行,接下来我们以单调栈的形式实现这道题目的解法
function nextMaxValue(arr) {
//单调栈
const stack = [];
const len = arr.length;
//用于存放结果数组
const res = new Array(len);
//从后向前进行遍历
for (let i = len - 1; i >= 0; i--) {
//如果栈的长度大于0并且栈顶元素小于当前值的情况下
while (stack.length > 0 && stack[stack.length - 1] <= arr[i]) {
//将小于当前值的元素进行出栈,保证栈顶元素一定是栈内最大的元素
stack.pop();
}
//如果栈为空的情况下,就代表当前元素是不存在下一个最大的元素的,如果栈存在值,那么栈顶就是当前元素下一个的更大元素
res[i] = stack.length === 0 ? -1 : stack[stack.length - 1];
//在将当前值进行入栈操作,方便进行后面的对比
stack.push(arr[i]);
}
return res;
}
const result = nextMaxValue([1, 3, 2, 4, 4]);
console.log(result); // [3, 4, 4, -1, -1]
通过以上的代码,可以将时间复杂度控制在o(n),大大的优化了性能。
以上代码本质上就是在每次循环的时候进行一个栈的对比,如果栈有值,并且栈顶元素小于当前值的情况,就会将栈顶元素出栈,方便在当前值入栈的时候,栈内元素一定是有序的。
扩展应用:下一个更大或相等的元素
有时候,我们需要找到"下一个更大或相等"的元素:
// 输入: arr = [1, 3, 2, 4, 4]
// 输出: [3, 4, 4, 4, -1]
关键区别:在比较时,我们只需要将 <= 改为 <,也就是移除严格小于当前元素的值,保留相等的元素。
function nextMaxOrEqualValue(arr) {
const stack = [];
const len = arr.length;
const res = new Array(len).fill(-1);
for (let i = len - 1; i >= 0; i--) {
// 关键区别:使用 < 而不是 <=
while (stack.length > 0 && stack[stack.length - 1] < arr[i]) {
stack.pop();
}
if (stack.length > 0) {
res[i] = stack[stack.length - 1];
}
stack.push(arr[i]);
}
return res;
}
console.log(nextMaxOrEqualValue([1, 3, 2, 4, 4]));
// 输出: [3, 4, 4, 4, -1]
举一反三
理解了原理后,我们可以轻松推导出其他变种:
| 问题类型 | 遍历方向 | 比较符号 | 栈类型 |
|---|---|---|---|
| 下一个更大元素 | 从右向左 | <= | 单调递减 |
| 下一个更大或相等 | 从右向左 | < | 单调递减 |
| 下一个更小元素 | 从右向左 | >= | 单调递增 |
| 下一个更小或相等 | 从右向左 | > | 单调递增 |
逆向思维:求上一个更大的元素
掌握了"下一个更大元素"的解法后,"上一个更大元素"就变得简单了。核心思路就是将遍历方向反过来:
function previousMaxValue(arr) {
const stack = [];
const len = arr.length;
const res = new Array(len).fill(-1);
// 关键区别:从左向右遍历
for (let i = 0; i < len; i++) {
// 维护单调递减栈
while (stack.length > 0 && stack[stack.length - 1] <= arr[i]) {
stack.pop();
}
// 栈顶就是上一个更大的元素
if (stack.length > 0) {
res[i] = stack[stack.length - 1];
}
stack.push(arr[i]);
}
return res;
}
console.log(previousMaxValue([1, 3, 2, 4, 4]));
// 输出: [-1, -1, 3, -1, -1]
结果解析:
- 元素1:左边没有元素,返回-1
- 元素3:左边没有比3大的元素,返回-1
- 元素2:左边第一个比2大的是3,返回3
- 元素4:左边没有比4大的元素,返回-1
- 元素4:左边没有比4大的元素,返回-1
实战案例:接雨水问题
单调栈最经典的应用之一就是解决"接雨水"问题。给定一个数组表示地形高度,计算这些地形能接住多少雨水。
function trap(height) {
if (height.length === 0) return 0;
const stack = [];
let result = 0;
for (let i = 0; i < height.length; i++) {
// 维护单调递减栈
while (stack.length > 0 && height[stack[stack.length - 1]] < height[i]) {
const top = stack.pop();
if (stack.length === 0) break;
const distance = i - stack[stack.length - 1] - 1;
const boundedHeight = Math.min(height[i], height[stack[stack.length - 1]]) - height[top];
result += distance * boundedHeight;
}
stack.push(i);
}
return result;
}
// 测试
console.log(trap([0,1,0,2,1,0,1,3,2,1,2,1])); // 输出: 6
算法思路:
- 使用单调递减栈存储柱子索引
- 当遇到比栈顶高的柱子时,说明可能形成凹槽
- 计算凹槽的宽度和高度,累加接水量
性能对比与优化建议
时间复杂度对比
| 解法 | 时间复杂度 | 空间复杂度 | 适用场景 |
|---|---|---|---|
| 暴力解法 | O(n²) | O(1) | 小规模数据 |
| 单调栈 | O(n) | O(n) | 大规模数据 |
| 双指针 | O(n) | O(1) | 特定问题 |
优化建议
- 选择合适的遍历方向:根据问题特点选择从左到右或从右到左
- 合理初始化结果数组:避免运行时动态扩容
- 注意边界条件:空数组、单元素数组等特殊情况
- 栈的实现:使用数组实现的栈,注意性能优化
完整总结
单调栈解题模板
function monotonicStackTemplate(arr, direction = 'next', comparison = 'greater') {
const stack = [];
const len = arr.length;
const res = new Array(len).fill(-1);
// 根据方向选择遍历顺序
const start = direction === 'next' ? len - 1 : 0;
const end = direction === 'next' ? -1 : len;
const step = direction === 'next' ? -1 : 1;
for (let i = start; i !== end; i += step) {
// 根据比较类型选择条件
const shouldPop = comparison === 'greater' ?
(stack.length > 0 && stack[stack.length - 1] <= arr[i]) :
(stack.length > 0 && stack[stack.length - 1] >= arr[i]);
while (shouldPop) {
stack.pop();
}
if (stack.length > 0) {
res[i] = stack[stack.length - 1];
}
stack.push(arr[i]);
}
return res;
}
核心要点回顾
- 单调性维护:确保栈内元素始终保持单调性
- 遍历方向:"下一个"从右向左,"上一个"从左向右
- 比较符号:根据是否包含相等条件选择适当的比较符
- 结果获取:栈顶元素总是我们需要的答案
适用场景
- 📊 下一个/上一个更大元素
- 💧 接雨水问题
- 📈 股票价格问题
- 🎯 每日温度问题
- 🏔️ 柱状图最大矩形
单调栈的魅力在于它将复杂的问题简单化,通过维护一个简单的性质,就能解决一系列看似困难的问题。掌握了这个工具,你的算法工具箱就又多了一件利器!
参考资料:
推荐阅读: