栈算法题的技巧总结

3 阅读5分钟

一、解题思维总结

1. 何时使用栈结构?

场景解法复杂度
括号匹配问题使用栈存储左括号,遇到右括号时检查栈顶是否匹配O(n)
需要追踪历史最小值使用辅助栈存储当前最小值O(1) 获取最小值
后缀表达式求值遇到数字入栈,遇到运算符弹出栈顶两个元素计算O(n)
生成有效括号组合回溯算法,维护左右括号计数O(4^n/√n)
寻找下一个更大元素单调栈,存储未找到更大元素的索引O(n)
车队问题按位置排序,计算到达时间,使用栈追踪车队O(n log n)

2. 复杂度分析

  • 栈操作:push/pop/top 操作均为 O(1)
  • 空间复杂度:通常为 O(n),最坏情况下可能需要存储所有元素
  • 回溯算法:时间复杂度通常较高,但能保证找到所有解

3. 常用技巧

  1. 括号映射:使用对象存储左右括号对应关系
  2. 辅助栈:用于追踪最小值、最大值等特殊需求
  3. 单调栈:保持栈内元素单调性,用于解决"下一个更大元素"类问题
  4. 回溯模板:递归 + 条件判断,注意字符串拼接不要修改原字符串

二、核心技巧详解

技巧一:括号匹配与栈结构

适用场景:字符串中的括号匹配、表达式有效性检查

核心要点

  • 左括号入栈,右括号检查栈顶是否匹配
  • 使用映射对象提高代码可读性和可扩展性
  • 遍历结束后栈必须为空才有效

典型例题有效的括号

var isValid = function(s) {
    const bracketMap = {
        ')': '(',
        '}': '{',
        ']': '[',
    };
    const stack = [];
    for (let v of s) {
        if (Object.values(bracketMap).includes(v)) {
            stack.push(v);
            continue;
        }
        if (v in bracketMap) {
            if (stack.length > 0 && stack.pop() === bracketMap[v]) {
                continue;
            }
        }
        return false;
    }
    return stack.length === 0;
};

技巧二:辅助栈追踪极值

适用场景:需要在常数时间内获取栈中最小值/最大值

核心要点

  • 主栈存储所有元素,辅助栈只存储小于等于栈顶的元素
  • pop操作时需要检查是否弹出的是当前最小值
  • 时间复杂度:所有操作均为 O(1)

典型例题最小栈

var MinStack = function() {
    this.stack = [];
    this.minStack = [];
};

MinStack.prototype.push = function(val) {
    this.stack.push(val);
    if (this.minStack.length === 0 || val <= this.minStack[this.minStack.length - 1]) {
        this.minStack.push(val);
    }
};

MinStack.prototype.pop = function() {
    const val = this.stack.pop();
    if (val === this.minStack[this.minStack.length - 1]) {
        this.minStack.pop();
    }
};

技巧三:后缀表达式求值

适用场景:计算逆波兰表达式(后缀表达式)

核心要点

  • 数字直接入栈,运算符弹出栈顶两个元素计算
  • 除法使用 Math.trunc() 实现向零截断
  • 注意操作数顺序:先弹出的是右操作数

典型例题逆波兰表达式求值

function evalRPN(tokens) {
    let stack = [];
    for (let token of tokens) {
        if (!isNaN(token)) {
            stack.push(parseInt(token));
        } else {
            let num2 = stack.pop();
            let num1 = stack.pop();
            switch (token) {
                case '+': stack.push(num1 + num2); break;
                case '-': stack.push(num1 - num2); break;
                case '*': stack.push(num1 * num2); break;
                case '/': stack.push(Math.trunc(num1 / num2)); break;
            }
        }
    }
    return stack.pop();
}

技巧四:回溯算法生成括号

适用场景:生成所有可能的有效组合

核心要点

  • 任何时候右括号数量不能超过左括号
  • 使用递归回溯,尝试所有可能路径
  • 注意字符串拼接不要修改原字符串

典型例题括号生成

var generateParenthesis = function(n) {
    let result = [];
    function backtrack(s = '', left = 0, right = 0) {
        if (s.length === n * 2) {
            result.push(s);
            return;
        }
        if (left < n) {
            backtrack(s + '(', left + 1, right);
        }
        if (right < left) {
            backtrack(s + ')', left, right + 1);
        }
    }
    backtrack();
    return result;
};

技巧五:单调栈解决"下一个更大"问题

适用场景:寻找数组中每个元素的下一个更大元素

核心要点

  • 使用栈存储尚未找到更大元素的索引
  • 当前元素大于栈顶索引对应元素时,计算天数差
  • 时间复杂度 O(n),每个元素入栈出栈各一次

典型例题每日温度

var dailyTemperatures = function(temperatures) {
    const answer = new Array(temperatures.length).fill(0);
    const stack = [];
    
    for (let i = 0; i < temperatures.length; i++) {
        while (stack.length > 0 && temperatures[i] > temperatures[stack[stack.length - 1]]) {
            const prevIndex = stack.pop();
            answer[prevIndex] = i - prevIndex;
        }
        stack.push(i);
    }
    
    return answer;
};

技巧六:排序+栈解决车队问题

适用场景:计算车辆到达目的地时形成的车队数量

核心要点

  • 按位置从大到小排序,位置越大的车越先到达
  • 计算每辆车到达目的地所需时间
  • 如果当前车到达时间大于栈顶,说明追不上前车,形成新车队

典型例题车队

var carFleet = function(target, position, speed) {
    const arr = position
                  .map((p, index) => [p, speed[index]])
                  .sort((a, b) => b[0] - a[0]);
    const stack = [];
    for (let i = 0; i < arr.length; ++i) {
        const time = (target - arr[i][0]) / arr[i][1];
        if (stack.length === 0 || stack[stack.length - 1] < time) {
            stack.push(time);
        }
    }
    return stack.length;
};

三、JavaScript 数值处理技巧

向零截断方法比较

let num = -12.345;

// 推荐:最清晰的方式
Math.trunc(num)     // -12

// 位运算技巧(有32位限制)
num | 0             // -12
~~num               // -12
num << 0            // -12

// 其他方法
parseInt(num)       // -12
Math.floor(num)     // -13(注意负数差异)
Math.ceil(num)      // -12
Math.round(num)     // -12

注意事项

  • 位运算有32位整数限制(-2³¹ 到 2³¹-1)
  • parseInt 会将数字先转为字符串,注意科学计数法陷阱
  • 推荐使用 Math.trunc() 作为最清晰的解决方案

循环性能优化

// 传统 for 循环(性能最优)
for (let i = 0; i < arr.length; i++) {
    const [pos, spd] = arr[i];
    // 操作
}

// for...of 循环(可读性更好)
for (const [pos, spd] of arr) {
    // 操作
}

开发准则

  1. 优先可读性:先写出清晰、易维护的代码
  2. 测量后再优化:使用性能分析工具找到真正瓶颈
  3. 考虑使用场景:大多数情况下性能差异可以忽略

四、易错点提醒

  1. 字符串拼接陷阱:在回溯算法中,不要使用 s += '(' 会修改原字符串,应该使用 s + '('
  2. 操作数顺序:在栈操作中,注意弹出顺序,后弹出的是右操作数
  3. 边界条件:栈操作前检查栈是否为空
  4. 数值转换:注意 parseInt 的科学计数法陷阱和基数问题
  5. 性能优化:避免过早优化,优先保证代码可读性
  6. 单调栈理解:不要试图在遍历时立即计算结果,利用栈的延迟计算特性