资讯动态

逆波兰表达式求值:栈的经典应用与实现全解析

发布时间:2026/9/28 13:04:51 来源:尧图企业网站定制
1. 题目拆解与解题思路1.1 逆波兰表达式到底在考什么力扣150题“逆波兰表达式求值”是栈结构应用的经典考题刷题列表里基本绕不开它。很多第一次看到这道题的人会有个疑问这不就是一个简单的栈模拟吗为什么能成为热题100里的常客我的理解是这道题表面上考的是“用栈处理后缀表达式”实际上考的是三件事第一你有没有理解后缀表达式为什么天生适合用栈来计算第二你能不能把字符串数组中的每一个token都稳健地解析成操作数或运算符第三你的代码在边界条件下会不会崩比如除法遇到负数、操作数只有一位数、空表达式等。逆波兰表达式也叫后缀表达式它的特点是运算符写在操作数后面比如中缀表达式(1 2) * (3 - 4)转换成后缀就变成1 2 3 4 - *。因为我们平时写的是中缀表达式所以第一次见后缀会觉得别扭但恰恰是这种“运算符后置”的书写方式让计算机可以不需要任何优先级判断和括号只用一次从左到右的扫描就能完成求值。这道题给的输入就是一个token数组每个token要么是整数要么是、-、*、/四种运算符之一要求返回表达式的结果。注意题目自己不要求你做中缀转后缀的转换输入直接就是后缀形式所以核心难点就集中在解析和运算逻辑上。适合刷这道题的人我觉得分几类刚学栈、想找典型题练手的新手准备面试、想把高频题快速过一遍的求职者还有像我这样刷二轮、想把细节彻底弄明白的。无论你是哪一类把这道题的每一个边界条件吃透都会对接下来的栈类题目有很大帮助。1.2 为什么后缀表达式不用管优先级先想一个问题如果给你一个中缀表达式3 4 * 2你心算的时候知道要先算乘法再算加法因为乘法优先级高。但如果让计算机按从左到右的顺序扫描遇到3 4就先加了结果就错了。所以中缀表达式必须引入优先级判断、括号处理甚至还需要两个栈来调度运算符。后缀表达式就不一样了。它把运算符放在操作数之后相当于你每遇到一个运算符它要作用的两个操作数已经在它前面出现了而且就是最近出现的两个。这句话听起来简单其实是栈能处理后缀表达式的核心原因栈天然有“最近数据优先”的特性正好匹配“最近出现的两个操作数”的需求。举个例子后缀表达式3 4 2 * 扫描过程是这样的先遇到3入栈遇到4入栈遇到2入栈此时栈从底到顶是3、4、2遇到*弹出4和2相乘得8入栈遇到弹出3和8相加得11。有没有发现整个过程完全不关心优先级因为运算符的位置已经隐含了计算顺序。这也就解释了为什么力扣把这道题放在栈的经典题目里它不是考你会不会手写一个栈而是考你懂不懂后缀表达式与栈的数据结构特性之间的内在联系。理解了这一点再看后面的代码实现就会有一种“实际是水到渠成”的感觉。1.3 从中缀到后缀这个知识点是隐藏考点虽然题目直接给后缀表达式但刷题的人往往容易忽视一个隐藏知识点如果不懂中缀转后缀调度场算法你很难真正理解为什么后缀表达式长这样面试追问时也容易卡壳。中缀转后缀的核心规则是维护一个输出队列和一个运算符栈遇到数字直接输出遇到运算符就把栈顶所有优先级不低于当前运算符的都弹出并输出再把当前运算符入栈遇到左括号直接入栈遇到右括号则弹出栈内运算符直到左括号。可以把这个过程理解为一种“延迟决策”遇到优先级更高的运算符时不急着决定压入栈里等后面出现更低优先级或括号结束再输出。我用一个小例子演示中缀1 2 * 3扫描到1直接输出扫描到时栈空入栈扫描到2输出扫描到*时发现栈顶优先级低*入栈扫描到3输出最后把栈内剩余运算符依次弹出得到1 2 3 * 。这个结果对应的就是1 2 * 3而不是(1 2) * 3。所以如果面试官让你实现一个计算器比如力扣224、227题你完全可以先用调度场算法转后缀再复用150题的求值逻辑。这也是为什么我建议把中缀转后缀和这道后缀求值一起复习它们是一个完整的两阶段流程。2. 核心实现方案与关键代码剖析2.1 token解析数字与运算符如何区分拿到了题目给的token数组首先面临的问题是怎么区分一个token是数字还是运算符。力扣原题的规定是token一定是整数或者四种运算符之一不会有除零操作也不会有括号。所以最直接的判断就是看第一个字符是否为运算符。但这里有个极其容易翻车的细节-开头的字符串不一定就是减号还可能是负数。比如-2是一个负整数而-是减法运算符。如果代码里写成“只要token是-就按运算符处理”负数的 case 就直接被误判了。我的处理办法是用一个操作符集合unordered_setstring存放、-、*、/四个符号遇到token先查是否在集合里。不在集合里的一定是操作数直接stoi转成整数入栈。这样-2因为不在操作符集合中自然被当作操作数不需要额外判断长度或首字符。注意事项不要试图用token[0]的ASCII码范围来判断数字因为负数首字符就是-会被误判成运算符。也别用isdigit(token[0])判断所有情况-2的isdigit结果是false。最稳妥的还是集合判断或长度判断操作符长度恒为1操作数长度可能大于1结合使用。2.2 栈操作顺序弹出两个数的先后不能反减法和除法是有顺序要求的这是这道题第二个高频坑点。假设当前栈从栈底到栈顶依次是a、b遇到-号时应该算a - b还是b - a后缀表达式的规则是运算符作用于“最近出现的两个操作数”而栈顶是最近的那个所以先弹出的是右操作数后弹出的是左操作数。具体到a、b入栈的情况b比a晚入栈所以先弹出b右操作数再弹出a左操作数计算结果为a - b。很多第一次写的同学会顺手写成num1 - num2其中num1是第一个弹出的数结果在减法和除法时全错。比如表达式5 3 -正确结果是5 - 3 2但如果先弹出3、再弹出5后误写成3 - 5就得到-2完全反了。为了避免这个问题我习惯在代码里用两个语义清晰的变量名比如right st.top()、st.pop()、left st.top()、st.pop()然后计算left right、left - right等。名字写清楚逻辑就不容易错。2.3 除法向零截断C和Python的差异要注意这道题要求除法是“向零截断”也就是舍弃小数部分直接取整数方向。这里有个很多人不知道的语言差异C和Java的整数除法本身就是向零截断所以-3 / 2 -1没问题但Python的//是向下取整-3 // 2 -2直接用//就错了。我在牛客和力扣的评论区都见过有人写Python时踩这个坑。正确的Python写法是int(b / a)先做浮点除法再用int()向零截断因为int()本来就是向零取整。也可以自己实现一个整除函数判断两个数异号时向上调整结果。举个例子假设要计算6 / (-4)C里直接6 / -4 -1符合向零截断要求Python里int(6 / -4) int(-1.5) -1也符合要求但如果用6 // -4 -2就与要求不符了。这个差异一定要记清楚跨语言刷题时最容易被这种隐性差异坑到。实操心得如果你要用Python刷这题我在代码里建议直接写ans int(right / left)这样语义最直观也不容易产生误解。3. 完整代码实现与运行演示3.1 C标准解法干净利落不花哨class Solution { public: int evalRPN(vectorstring tokens) { unordered_setstring ops {, -, *, /}; stacklong long st; for (const string token : tokens) { if (ops.count(token)) { long long right st.top(); st.pop(); long long left st.top(); st.pop(); long long result 0; switch (token[0]) { case : result left right; break; case -: result left - right; break; case *: result left * right; break; case /: result left / right; break; } st.push(result); } else { st.push(stoll(token)); } } return (int)st.top(); } };这段代码的思路很简单遍历所有token遇到运算符就弹出两个操作数按顺序计算后压回遇到数字就转成long long入栈。我用long long而不是int是因为中间结果可能超出32位整数范围原题虽然保证了可以用32位整数存储但用long long更保险也方便处理stoll转换。switch (token[0])的使用有一个前提token一定是四种运算符之一符号位正好是首字符。如果是、-这类单字符操作符token[0]一定取到正确字符。这里代码写得简洁实际上也利用了题目“输入一定是合法后缀表达式”的保证。最后返回时强转成int因为题目要求返回值在int范围内。整个过程一遍遍历时间复杂度O(n)空间复杂度O(n)n为token数量。这个复杂度是最优的因为每个token至少被处理一次栈最深时存储了所有的操作数。3.2 Python标准解法注意除法的坑class Solution: def evalRPN(self, tokens: List[str]) - int: stack [] operators {, -, *, /} for token in tokens: if token in operators: right stack.pop() left stack.pop() if token : stack.append(left right) elif token -: stack.append(left - right) elif token *: stack.append(left * right) else: stack.append(int(left / right)) else: stack.append(int(token)) return stack[-1]Python版本需要注意的是int(left / right)的写法原因前面已经专门说过。另外一个细节是int(token)转换Python的int()可以直接把表示负数的字符串转成整数比如int(-2) -2所以负数和普通数字一样直接转就好。这段代码在时间和空间复杂度上与C版本一致都是O(n)。但因为Python列表的pop()在末尾操作是O(1)的所以用列表模拟栈没有任何性能问题。刷题时不需要自己额外写一个Stack类直接list就够了。如果你追求代码优雅可以试试用字典映射运算符到lambda函数来减少if/elif的重复。不过以我的经验面试时写显式的if/elif分支其实更清晰不容易写错后期也更好排错。花哨的写法容易在紧张时出bug。3.3 多组测试用例走查手把手模拟运行过程我选了四个有代表性的用例来走查这样能直观看到栈的变化。第一个用例tokens [2, 1, , 3, *]对应中缀(2 1) * 3。遇到2入栈栈为[2]。遇到1入栈栈为[2, 1]。遇到弹出right1left2计算3入栈栈为[3]。遇到3入栈栈为[3, 3]。遇到*弹出right3left3计算9入栈栈为[9]。 最终结果9。第二个用例tokens [4, 13, 5, /, ]对应中缀4 (13 / 5)。遇到4入栈栈为[4]。遇到13入栈栈为[4, 13]。遇到5入栈栈为[4, 13, 5]。遇到/弹出right5left13计算13 / 5 2向零截断入栈栈为[4, 2]。遇到弹出right2left4计算6入栈栈为[6]。 最终结果6。这里注意13 / 5 2不是2.6因为题目要求整数除法。第三个用例tokens [10, 6, 9, 3, , -11, *, /, *, 17, , 5, ]这是力扣官方示例中比较复杂的那个。中途会出现负数能验证负数解析和运算符区分逻辑是否正确。我挑中间关键的一段当栈里是[10, 6, 9, 3]时遇到弹出3和9入栈12栈变为[10, 6, 12]遇到-11时因为不在运算符集合中直接入栈栈变为[10, 6, 12, -11]遇到*时弹出-11和12计算-132入栈栈变为[10, 6, -132]。可以看出负数token完全没有被误判成减号。第四个用例单操作数直接返回。比如tokens [18]整个循环只走一次入栈最后返回18。这个场景看似简单但常见于递归生成测试用例时的边界很多人会忽略空表达式或单个数字的情况。原题保证输入至少有一个token但循环结束后取栈顶这个逻辑本身就能正确处理单元素情况。3.4 中缀转后缀代码作为配套扩展前面提到中缀转后缀是这道题的隐藏考点这里给一个配套的转换实现方便需要时参考。这段代码可以配合150题的求值逻辑完成一个中缀计算器的完整链路。vectorstring infixToRPN(const vectorstring tokens) { vectorstring output; stackstring ops; unordered_mapstring, int priority {{, 1}, {-, 1}, {*, 2}, {/, 2}}; for (const string token : tokens) { if (priority.count(token) 0 token ! ( token ! )) { output.push_back(token); } else if (token () { ops.push(token); } else if (token )) { while (!ops.empty() ops.top() ! () { output.push_back(ops.top()); ops.pop(); } ops.pop(); } else { while (!ops.empty() priority[ops.top()] priority[token]) { output.push_back(ops.top()); ops.pop(); } ops.push(token); } } while (!ops.empty()) { output.push_back(ops.top()); ops.pop(); } return output; }这段代码的要点是优先级比较部分当前运算符入栈前将所有优先级不低于自己的栈顶运算符弹出。比如遇到*时栈顶*或/会被弹出但或-不会。等号的情况是“相同优先级从左到右计算”所以要弹出优先级相等的运算符否则1 - 2 - 3会被错误转换成1 - (2 - 3)。用这个转换函数得到后缀表达式后直接喂给150题的evalRPN就是一个可用的中缀计算器。我当时刷力扣227题基本计算器II时就是先用这个方案过的一个栈思路通吃两个题效率很高。4. 常见错误与避坑经验4.1 高发错误一减法除法左右操作数颠倒这是此题错误率最高的一类而且非常隐蔽。代码写出来表面看没问题跑某些用例也正常但一到减法和除法就出错。原因是大多数人只想着“弹出两个数算一下”没细想弹出的先后顺序对应哪个操作数。具体的错误写法int num1 st.top(); st.pop(); int num2 st.top(); st.pop(); st.push(num1 - num2); // 错这段代码算的是先弹出的 - 后弹出的。如果栈里是5和3先弹出3再弹出5算的其实是3 - 5 -2而正确的5 - 3 2被完全反过来。除法同理6 / 2会被算成2 / 6 0。排查技巧如果你写完后测试发现结果总是“符号不对”或者“整除结果少了”优先检查这两个二元运算的减法除法分支。最好的办法就是给变量命名成left和right一眼看出谁在左边谁在右边。4.2 高发错误二负数被误判成运算符第二个常见问题是负数的处理。很多人一看到-开头就在代码里写if (token[0] -)当作减法处理。但-11是操作数不是减号。如果错误地处理了负数测试用例一出现类似[-11, 3, *]的输入就会在遇到-11时尝试弹栈导致栈空报错或者计算出错误结果。更迷惑的是有些用例偏偏没有负数测试全通过了一到隐藏用例就挂。排查技巧检查所有包含负数输入的测试用例。我在自测时一般会把力扣官方示例3原封不动跑一遍基本就能覆盖负数场景。如果你发现某段逻辑对负数处理比较奇怪单独加两个用例[-2, 3, *]应返回-6[2, -3, ]应返回-1。4.3 高发错误三不注意语言特性导致的除法偏差这个坑主要针对Python和JavaScript。C的整数除法向零截断没问题但Python的//是向下取整算负数除法时结果会和预期相反。我举个例子说明差异计算-7 / 2C结果是-3Python的-7 // 2结果是-4而int(-7 / 2)结果是-3。原因在于//向负无穷取整而要求是向零取整。JavaScript里Math.trunc(-7 / 2)可以得到-3但直接用Math.round或parseInt都可能出问题parseInt(-3.5)倒是-3但语义不太直观。排查技巧如果你的代码在除数为负数或操作数为负数时结果总是差1重点检查除法和整除的实现。一个通用规则是想向零截断时用“转浮点数再向零取整”的函数想向下取整时才用Python的//。4.4 高发错误四用char类型判断多字符符号失效还有一种写法是把token按字符处理比如char c token[0]然后判断c 。对单字符操作符没问题但对多字符数字或负数是灾难。比如token -2token[0]是-可能被误认为运算符。而token 123token[0]是1又会被误认为是某种逻辑判断。这种写法对单一字符token有效但对字符串数组场景极易出错。排查技巧从开始就养成用集合判断的习惯或者直接用token.size() 1 ops.count(token)来区分操作符与数字。我通常直接查集合不需要额外判断长度因为集合里只有四个符号负数永远不会命中。4.5 边界情况速查表我把一些容易忽略的测试用例整理成表格自测时对着跑一遍基本能覆盖这道题的主要边界。测试用例期望结果主要验证点[18]18单操作数、无运算[3, -2, *]-6负数作为操作数[5, 3, -]2减法顺序正确性[6, -4, /]-1负数除法向零截断[-2, -3, *]6双重负数运算[2, 1, 12, 3, /, -, *]6复杂嵌套运算对应2 * (1 - 12 / 3)表格里的倒数第二项可能有人会算错12 / 3 4然后1 - 4 -3最后2 * (-3) -6所以结果是-6而不是6写的时候要注意。实际写代码时如果返回值和预期对不上按照“从左到右、最近操作数优先”的规则手动模拟一遍栈变化基本都能定位到问题。4.6 我刷这道题时踩过的一个真实坑说一个我自己的经历。第一次写这道题时我用的判断方式是if (token || token - || token * || token /)这段逻辑本身没问题但问题是后面计算除法时我用了st.top() / st.top()也就是两个top变量没弹出就直接除结果整个程序死循环了因为栈根本没变化。后来我意识到“先弹后算”这个步骤顺序很重要弹一个pop一次绝不能图省事把两个操作数一起处理。另一个印象深刻的坑是stoll和stoi的选择。题目保证结果可以用int存储但中间某个操作数可能超过int范围。例如[2147483647, 2, *]这类的测试用例虽然原题保证最终结果合法但用stoi转换字符串“2147483647”本身是可以的可一旦中间产生乘法溢出C的int乘法就可能出现未定义行为。用long long做中间运算再转int是从根上避免这个问题的最好习惯。5. 进阶思路与扩展练习5.1 不用额外栈能不能做有人会问既然后缀表达式本身就包含了顺序信息能不能不用栈直接在原数组上修改如果允许修改输入数组的话可以复用数组前部作为栈用指针维护栈顶位置逻辑类似原地算法。int evalRPN(vectorstring tokens) { int stackPos 0; for (int i 0; i tokens.size(); i) { if (tokens[i] || tokens[i] - || tokens[i] * || tokens[i] /) { long long right stoll(tokens[--stackPos]); long long left stoll(tokens[--stackPos]); long long result 0; char op tokens[i][0]; switch (op) { case : result left right; break; case -: result left - right; break; case *: result left * right; break; case /: result left / right; break; } tokens[stackPos] to_string(result); } else { tokens[stackPos] tokens[i]; } } return stoi(tokens[0]); }这段代码的好处是空间复杂度降到O(1)因为复用输入数组作为栈。需要注意的是stoll(tokens[--stackPos])这类写法可读性较差实际工作中不建议写这种炫技代码。面试时如果你主动提出这个优化面试官通常会比较加分但前提是你得能讲清原理否则容易显得花哨。5.2 延伸题目一网打尽刷完150题后建议顺势做这几个延伸题目它们共享同一个核心思路力扣224题《基本计算器》支持加减和括号核心是中缀转后缀 后缀求值两步走额外需要处理括号优先级。力扣227题《基本计算器II》支持加减乘除但没有括号比224简单一些用双栈或单栈加优先级判断都能解。如果已经把调度场算法练熟这题会非常轻松。力扣772题《基本计算器III》会员题前两题的综合体加减乘除加括号属于这个系列的集大成者。力扣946题《验证栈序列》考察栈弹入弹出的过程模拟和150题一样依赖对栈操作细节的熟练度。把这些题一起刷完你会形成一条完整的知识链中缀转后缀 - 后缀求值 - 计算器扩展这也是为什么我一直建议别只刷一道题要成体系地刷。5.3 栈在实战场景里的真实应用说完刷题说说这题在实际开发里的应用免得学完觉得只是做题。逆波兰表达式的求值机制在现代计算器、电子表格和编译器的表达式计算中被广泛使用。很多计算器应用会把用户输入的中缀表达式先转换成后缀再交给求值器计算。这样设计的优势在于后缀表达式的求值可以线性扫描完成不需要递归下降解析逻辑简单、性能稳定。还有一个类似的场景是PostgreSQL的tsquery全文检索语法、某些规则引擎的表达式解析用到的都是栈计算带优先级表达式的思路。甚至JVM字节码里的很多指令本身就是基于操作数栈的模型你写iadd时就是弹出两个数再压入结果和这道题的逻辑异曲同工。理解这一点后你会发现刷题不是为了考试而是在掌握一套计算机底层通用的思维工具。栈这种“先进后出”的数据结构天然适配许多需要保存中间状态、延迟决策的场景。6. 总结这类题目的通用解题模板说句实在话150题是栈专题里最值得背模板的题目之一。它的核心模板可以概括成五步遍历所有token判断token是操作数还是运算符操作数直接入栈运算符弹出两个操作数按“先弹出的是right、后弹出的是left”计算计算结果压回栈遍历结束后栈顶就是答案。这五步代码量不超过30行但掩盖了很多细节。我在实际刷题中体会最深的一点是写这道题时要把减法/除法的操作数顺序当成条件反射而不是临时思考。一旦形成条件反射后续遇到中缀表达式转换、计算器变种都会非常顺畅。根据我个人经验这题还有一个很有用的练习方式把测试用例中文转后缀再求值手动在纸上模拟一遍。纸上模拟会让你对“运算符位置决定计算顺序”这个特性有肌肉记忆比直接看题解深刻得多。我当时用这个方法把力扣示例的三个用例各模拟了三四遍之后写代码一次通过几乎没怎么调试。如果你想真正吃透栈和表达式求值强烈建议试一下这个方法。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑