深入浅出单调栈:算法优化的利器

74 阅读8分钟

深入浅出单调栈:算法优化的利器

摘要:单调栈是一种特殊的栈结构,通过维护栈内元素的单调性,能够高效解决数组中"下一个更大元素"等系列问题,将时间复杂度从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),每个元素最多入栈出栈一次。

算法原理解析

  1. 从右向左遍历:确保在处理当前元素时,栈中保存的是右侧的元素
  2. 维护单调性:移除所有破坏单调递减顺序的元素
  3. 获取结果:栈顶元素就是当前元素的"下一个更大元素"
  4. 更新栈结构:将当前元素入栈,为后续元素提供参考

求下一个更大的元素

//输入 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)特定问题

优化建议

  1. 选择合适的遍历方向:根据问题特点选择从左到右或从右到左
  2. 合理初始化结果数组:避免运行时动态扩容
  3. 注意边界条件:空数组、单元素数组等特殊情况
  4. 栈的实现:使用数组实现的栈,注意性能优化

完整总结

单调栈解题模板

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;
}

核心要点回顾

  1. 单调性维护:确保栈内元素始终保持单调性
  2. 遍历方向:"下一个"从右向左,"上一个"从左向右
  3. 比较符号:根据是否包含相等条件选择适当的比较符
  4. 结果获取:栈顶元素总是我们需要的答案

适用场景

  • 📊 下一个/上一个更大元素
  • 💧 接雨水问题
  • 📈 股票价格问题
  • 🎯 每日温度问题
  • 🏔️ 柱状图最大矩形

单调栈的魅力在于它将复杂的问题简单化,通过维护一个简单的性质,就能解决一系列看似困难的问题。掌握了这个工具,你的算法工具箱就又多了一件利器!


参考资料

推荐阅读