LeetCode 150. 逆波兰表达式求值:栈的经典应用

0 阅读7分钟

在 LeetCode 中,逆波兰表达式求值(150 题)是栈数据结构最经典、最基础的应用题之一。它不仅考察对逆波兰表示法的理解,更考验对栈操作的熟练度,以及边界场景的处理能力。今天就带大家一步步拆解这道题,从题目理解到代码实现,再到易错点排查,彻底吃透这道高频题。

一、题目解读(清晰易懂版)

题目核心:给一个字符串数组 tokens,里面装的是「逆波兰表达式」的元素,要求我们计算这个表达式的结果,返回一个整数。

先搞懂:什么是逆波兰表达式?

逆波兰表达式(Reverse Polish Notation,简称 RPN),也叫后缀表达式。我们平时写的数学表达式是中缀表达式(比如 3 + 4),而逆波兰表达式是把运算符放在两个操作数后面(比如 3 4 +)。

举个例子:中缀表达式 10 + 6 * (9 + 3) / (-11) + 17 + 5,对应的逆波兰表达式就是题目里的测试用例 ["10","6","9","3","+","-11","*","/","*","17","+","5","+"]

这种表达式的优势是:不需要括号来表示运算优先级,只要用栈就能按顺序计算,效率很高。

题目关键约束(必看,避坑重点)

  • 运算符只有 4 种:+-*/,没有其他符号;

  • 操作数可以是整数(正数、负数,比如 "-123"),也可以是另一个表达式的结果(但我们不用管这个,栈会帮我们处理);

  • 重点坑:两个整数除法 必须向零截断(比如 6 / -132 = -0.045,结果取 0;-1 / 2 = -0.5,结果取 0);

  • 无需处理除零异常,题目保证输入合法;

  • 结果和中间计算值,都能用 32 位整数表示(不用考虑溢出问题)。

二、解题核心思路(栈是关键!)

逆波兰表达式的计算逻辑,天生适配「栈」的先进后出特性,核心思路只有 3 步,非常简单:

  1. 初始化一个空栈,用来存储操作数(数字);

  2. 遍历字符串数组 tokens 中的每一个元素(token):

    • 如果当前 token 是 数字(包括正数、负数),就把它转换成数字,压入栈中;

    • 如果当前 token 是 运算符(+、-、*、/),就从栈中弹出两个操作数(注意顺序!先弹出的是「右操作数」,后弹出的是「左操作数」);

  3. 用当前运算符,对两个操作数进行计算,把计算结果压入栈中;

  4. 遍历结束后,栈中只会剩下一个元素,这个元素就是逆波兰表达式的最终计算结果。

思路验证(结合经典测试用例)

测试用例:tokens = ["10","6","9","3","+","-11"," * ","/","*","17","+","5","+"]

分步计算过程(跟着栈的变化走,一眼看懂):

  1. token = "10"(数字)→ 栈:[10]

  2. token = "6"(数字)→ 栈:[10, 6]

  3. token = "9"(数字)→ 栈:[10, 6, 9]

  4. token = "3"(数字)→ 栈:[10, 6, 9, 3]

  5. token = "+"(运算符)→ 弹出 3(右)、9(左),计算 9+3=12 → 栈:[10, 6, 12]

  6. token = "-11"(数字)→ 栈:[10, 6, 12, -11]

  7. token = " * "(运算符)→ 弹出 -11(右)、12(左),计算 12*(-11)=-132 → 栈:[10, 6, -132]

  8. token = "/"(运算符)→ 弹出 -132(右)、6(左),计算 6/(-132)=0(向零取整)→ 栈:[10, 0]

  9. token = " * "(运算符)→ 弹出 0(右)、10(左),计算 10*0=0 → 栈:[0]

  10. token = "17"(数字)→ 栈:[0, 17]

  11. token = "+"(运算符)→ 弹出 17(右)、0(左),计算 0+17=17 → 栈:[17]

  12. token = "5"(数字)→ 栈:[17, 5]

  13. token = "+"(运算符)→ 弹出 5(右)、17(左),计算 17+5=22 → 栈:[22]

遍历结束,栈中只剩 22,就是最终结果,和预期完全一致。

三、完整代码实现(TypeScript)

结合上面的思路,我们来实现代码。代码中会加入详细的注释,同时处理好所有边界场景(比如负数、非法表达式校验),直接复制到 LeetCode 就能通过。

function evalRPN(tokens: string[]): number {
  // 1. 初始化栈,用于存储操作数(数字)
  const stack: number[] = [];
  // 2. 定义运算符集合,方便快速判断当前token是否是运算符(比isNaN更高效)
  const operators = new Set(['+', '-', '*', '/']);

  // 3. 遍历每一个token
  for (const token of tokens) {
    // 判断当前token是否是运算符
    if (operators.has(token)) {
      // 弹出右操作数(先弹出的是栈顶元素,作为右操作数)
      const right = stack.pop();
      // 弹出左操作数(后弹出的是下一个栈顶元素,作为左操作数)
      const left = stack.pop();

      // 校验操作数(题目保证输入合法,此处仅做兜底,避免异常)
      if (left === undefined || right === undefined) {
        throw new Error(`Invalid RPN expression: ${tokens.join(' ')}`);
      }

      // 定义计算结果
      let result: number;
      // 根据不同运算符计算
      switch (token) {
        case '+':
          result = left + right;
          break;
        case '-':
          result = left - right; // 注意顺序:左 - 右
          break;
        case '*':
          result = left * right;
          break;
        case '/':
          // 核心重点:向零取整,用Math.trunc直接去除小数部分(兼容正负)
          result = Math.trunc(left / right);
          break;
        default:
          throw new Error(`Unsupported operator: ${token}`);
      }

      // 将计算结果压入栈中
      stack.push(result);
    } else {
      // 不是运算符,就是数字(兼容正负数字,如"-123")
      stack.push(Number(token));
    }
  }

  // 遍历结束,栈中唯一元素就是结果
  return stack[0];
};

四、代码逐行解析(新手也能看懂)

我们逐行拆解代码,重点讲关键细节和避坑点,避免只抄代码不懂原理。

1. 初始化栈和运算符集合

const stack: number[] = [];
const operators = new Set(['+', '-', '*', '/']);
  • stack:空数组,专门存数字,类型标注为 number[],符合 TypeScript 规范;

  • operators:用 Set 存储运算符,好处是 has() 方法判断是否为运算符的效率是 O(1),比用 if (token === '+' || token === '-') 更简洁、高效,也避免了冗余的数字转换。

2. 遍历 token 并判断类型

for (const token of tokens) {
  if (operators.has(token)) {
    // 运算符逻辑
  } else {
    // 数字逻辑
    stack.push(Number(token));
  }
}
  • 遍历 tokens 数组,每个元素都是 token;

  • 若 operators 包含当前 token,说明是运算符,执行运算逻辑;

  • 否则是数字,用 Number(token) 转换为数字(兼容 "-123" 这类负数字符串),压入栈中。

3. 运算符核心逻辑(重点!)

const right = stack.pop();
const left = stack.pop();
if (left === undefined || right === undefined) {
  throw new Error(`Invalid RPN expression: ${tokens.join(' ')}`);
}
  • 重点顺序:pop() 方法会弹出栈顶元素,所以 先弹出的是右操作数,后弹出的是左操作数!比如计算 6 / -132,left 是 6,right 是 -132,不能搞反(搞反会得到 -132/6 = -22,结果错误);

  • 兜底校验:题目保证输入合法,这里的 if 判断是为了避免极端情况(比如输入是 ["+"]),抛出明确的错误信息,便于调试。

4. 除法向零取整(最容易踩坑的地方)

case '/':
  result = Math.trunc(left / right);
  break;

为什么不用 Math.floor?因为 Math.floor 是「向下取整」,会导致负数除法结果错误:

  • 比如 6 / -132 = -0.045,Math.floor(-0.045) = -1(不符合向零取整),而 Math.trunc(-0.045) = 0(正确);

  • 比如 -1 / 2 = -0.5,Math.floor(-0.5) = -1(错误),Math.trunc(-0.5) = 0(正确);

Math.trunc 的作用是「直接去除数字的小数部分」,完美符合题目「向零截断」的要求。如果环境不支持 ES6+ 的 Math.trunc,也可以用兼容写法:

result = left / right > 0 ? Math.floor(left / right) : Math.ceil(left / right);

5. 返回结果

return stack[0];

遍历结束后,栈中只会有一个元素,就是最终结果,直接返回即可。

五、易错点总结(避坑必看)

这道题看似简单,但很多人提交会出错,核心就是踩了以下几个坑,记住就能少走弯路:

  1. 操作数顺序搞反:必须是「先弹右操作数,后弹左操作数」,尤其是减法和除法(左 - 右、左 / 右);

  2. 除法取整错误:误用 Math.floor,一定要用 Math.trunc 或兼容写法,确保向零取整;

  3. 负数处理:忘记兼容 "-123" 这类负数字符串,直接用 Number(token) 转换即可,无需额外判断;

六、解题总结

LeetCode 150 题的核心,就是「栈的应用」—— 逆波兰表达式的特性和栈的先进后出完美契合,记住「数字入栈,运算符出栈计算」的逻辑,就能轻松解决。

这道题的难度不算高,但非常考验细节:操作数顺序、除法取整、负数处理,这些细节直接决定代码是否能通过所有测试用例。

另外,这道题也是面试高频题,面试官常用来考察候选人对栈的理解和代码细节处理能力。掌握了这道题,你对栈的应用会更熟练,后续遇到类似的栈应用题(比如括号匹配)也能举一反三。