先别急着把 stack 和网上那些网络协议栈文章划等号。咱们聊的是数据结构里那个只能一头进一头出的线性表,以及它最经典的应用——后缀表达式。后缀表达式这个名字听起来像学院派术语,但它解决的是非常实际的问题:你随手写一个(3 + 4) * (5 - 2),让计算机去算,凭什么它知道要先算括号里面?靠人肉记忆优先级规则不是不行,但一旦表达式里出现五六层括号、多种运算符混排,朴素解析就会变成一个让人头大的维护噩梦。
这篇文章要做的,就是把后缀表达式这件事彻底讲透:为什么它能解决表达式求值的难题、中缀转后缀的算法每一步为什么那么设计、后缀求值代码怎么写不翻车,以及栈溢出这个和栈息息相关的坑到底是怎么回事。无论你是在准备面试,还是想给自己的小工具加一个公式计算功能,这篇都值得看完。
1. 一算带括号的表达式就头大?问题出在哪
1.1 表达式求值里的三个纠缠变量
刚才那个(3 + 4) * (5 - 2),小学算术要求我们遵循一个顺序:有括号先算括号,然后乘除,最后加减。翻译成程序逻辑,就是三个变量同时作用:运算符优先级、括号作用域、左结合规则(同一优先级从左到右算)。这三个变量一旦叠起来,代码复杂度会指数上升。
如果不用括号也不管优先级,直接从左往右算,3 + 4 * 5会被算成(3 + 4) * 5 = 35,而正确答案是3 + 20 = 23。所以任何可行的解析方案都必须处理优先级。朴素的思路是把每个运算符和它两侧的操作数看成一个三元组,但三元组之间又有嵌套,就涉及树形结构,再往下就是表达式树、语法树一堆概念。
我第一次尝试手写这种解析器时,就是在“优先级+括号”的递归里绕晕的。后来才明白,问题的根源是:中缀表达式的信息是“分布”在整个式子里的,你必须边扫描边回头处理之前的内容。而栈正好是提供这种“回头能力”的数据结构。
1.2 后缀表达式把计算变成了机械动作
后缀表达式(也叫逆波兰表达式,RPN)的做法是:操作数在前,运算符在后。(3 + 4) * (5 - 2)写成后缀是3 4 + 5 2 - *。乍看很别扭,但妙处在于:
- 不需要括号,“先算什么”完全由顺序决定
- 不需要中途比较运算符优先级
- 同一套机械规则适用于任何表达式:遇到数字就压栈,遇到运算符就弹出两个数算完再压回去
历史上 HP 的计算器就靠这个设计省掉了等号键,用户输入3 ENTER 4 + 5 ENTER 2 - *,机器内部就是一路压栈、弹栈、计算,硬件实现极其简单。FORTH 语言也走的同一条路。这就是为什么后缀表达式是理解栈最好的入口:它把栈的三种核心操作——push、pop、peek——全部用上了,而且每一步都非常直观。
2. 中缀转后缀:先学会手算,再理解算法
2.1 括号定位法:不用栈也能转出正确结果
先说一个不用写代码的手工方法,我叫它“括号定位法”。规则只有两条:
- 给原表达式的每一步运算按优先级加括号,直到整个式子变成一层套一层的完全括号形式
- 把每个运算符移到它对应那对右括号的右边,然后删掉所有括号
拿2 + 3 * 4举例。乘号优先,先给3 * 4加括号:2 + (3 * 4)。这一步要先算 2 加括号整体,所以整个式子是(2 + (3 * 4))。把+移到最外层右括号右边,把*移到内层右括号右边,去掉括号,得到2 3 4 * +。验证一下:3 * 4 = 12,2 + 12 = 14,正确。
再加括号的例子:(1 + 2) * (3 - 4),完全括号化就是((1 + 2) * (3 - 4)),移号后得1 2 + 3 4 - *。你可以用这个办法处理任何复杂的式子,先人肉确定顺序,再机械地移动运算符。这个手动流程最大的价值是,它让你直观理解“后缀表达式其实就是运算顺序的线性展开”。
2.2 栈式转换算法与优先级表
手算能转,程序怎么转?核心思路是一个“分流管道”:操作数走到输出队列;运算符则先放到一个“中转区”,也就是栈里待命,等时机合适再输出。规则如下:
- 数字:直接输出
- 左括号:压入栈
- 右括号:不断弹出栈顶并输出,直到遇到左括号为止,然后弹出左括号丢弃
- 其他运算符:只要栈顶存在且不是左括号、且栈顶优先级不低于当前运算符,就一直弹出并输出;结束后把当前运算符压入栈
最后把栈里剩余的运算符全部弹出输出。
这里需要一张优先级表。常见的四则运算:
| 运算符 | 优先级 |
|---|---|
| + - | 1 |
| * / | 2 |
| ^(幂) | 3 |
举个例子,把2 + 3 * 4过一遍。扫描到 2,输出2;扫描到+,栈为空,直接入栈;扫描到 3,输出;扫描到*,栈顶是+,优先级 1 < 2,不弹,*入栈;扫描到 4,输出。结束,栈里弹出*、+。最终输出2 3 4 * +。
注意比较条件里的“不低于”——同一优先级时也要弹出。因为标准四则运算是左结合的,2 - 1 + 3应该先算2 - 1再算+3,如果同级不弹,就会变成1 + 3先算,结果就错了。
2.3 左括号的双面规则:栈内优先级与栈外优先级
这是初学者最容易疑惑的地方。左括号在输入中遇到时要压栈,但压进去之后,它在栈里到底扮演什么角色?
想象你正在扫描(1 + 2) * 3。扫描到左括号,压栈。扫描到1,输出。扫描到+,栈顶是左括号——这时候如果套用“栈顶优先级不低于当前就弹出”的规则,就可能把左括号弹飞,那就彻底乱了。所以必须给左括号特殊的比较规则。
常见的做法是维护两套优先级:栈外优先级(icp)和栈内优先级(isp)。左括号的 icp 很高,保证它一遇到就能压栈;但 isp 很低,保证它在栈里不会把其他运算符压住。更简单的实现策略是:在比较运算符时,先判断栈顶是不是左括号,如果是就无条件入栈。两种思路等价,但如果你把算法改写成统一查表的版本,记得给左括号不同的内外值,否则很容易莫名弹出左括号导致括号匹配崩溃。
我用一个例子演示整个流程,比如(1 + 2) * 3 - 4:
(入栈1输出+:栈顶是(,直接入栈2输出):弹出+输出,再弹出(丢弃*:栈空,入栈3输出-:栈顶*优先级 2 > 1,弹出输出;栈空,-入栈4输出- 结束,弹出
-
结果1 2 + 3 * 4 -。验证:(1 + 2) * 3 - 4 = 9 - 4 = 5;后缀求值1 2 + = 3,3 3 * = 9,9 4 - = 5,正确。
3. 后缀表达式求值:两分钟写出可运行的引擎
3.1 求值引擎代码逐行拆解
后缀求值比中缀转后缀简单太多了,因为不需要管优先级。规则就一句话:从左到右扫描,数字压栈,遇到运算符弹出两个操作数,算完把结果压回去,扫描结束栈里剩的那个数就是答案。
直接给完整可用的 Python 代码:
def eval_rpn(tokens): stack = [] for token in tokens: if token in '+-*/^': b = stack.pop() a = stack.pop() if token == '+': stack.append(a + b) elif token == '-': stack.append(a - b) elif token == '*': stack.append(a * b) elif token == '/': stack.append(a / b) elif token == '^': stack.append(a ** b) else: stack.append(float(token)) return stack[0]这个函数核心逻辑不超过十五行。每个分支几乎都是“弹出两个数、运算、压回”。数字分支里float(token)会顺手处理掉整数和浮点数的类型差异,除法用/在 Python 3 里自然得到浮点数,不会出现两个整数相除截断成整数的问题。
3.2 踩过最痛的坑:操作数顺序
这个坑我至少见新人踩过一百次:减法和除法的操作数顺序不能反。2 3 -表示2 - 3,结果是-1;如果写反成3 - 2就成了1。更要命的是,顺序反了程序不报错,属于“逻辑错但看起来正常”的幽灵 Bug,比直接报错难排查得多。
原因在于后缀表达式里先出栈的元素是更靠后的操作数。执行减法时,stack.pop()第一次拿到的是3,第二次才是2。所以代码里必须先存b = stack.pop(),再取a = stack.pop(),然后计算a - b。除法同理,a / b是先弹出的做分母,反直觉但必须遵守。
给几个测试用例:
3 4 +→ 72 3 -→ -1(不是 1)4 2 /→ 2(不是 0.5)2 3 4 * +→ 14
我写代码时会专门加一组测试,防止自己哪天手滑把顺序调反。这个意识养成之后,后面写栈相关的算法会稳得多。
4. R 语言报的 protection stack overflow,其实也是栈的锅
4.1 protection stack 是怎么被撑爆的
如果你做过转录组数据分析或者 t-SNE 降维,大概率见过这条报错:
Error: protect(): protection stack overflow我第一次看到时以为是 R 包安装出了问题,查了一圈才发现根本不是版本问题。R 解释器内部用一条“保护栈”(protection stack)来管理内存对象,防止它们在垃圾回收时被误回收。每进入一层 R 表达式求值,解释器就会往保护栈里压入一个保护项;求值深度越大,栈就越高。当嵌套层级超过保护栈的容量,就触发 overflow。
常见的触发场景有两类。一类是代码写了很深层的递归函数,每层递归都在创建和返回对象,保护项只增不减;另一类是在某些包内部,比如处理高维数据、层次聚类、复杂嵌套列表时,实现里递归太深。t-SNE 本身不递归,但它背后计算距离矩阵、构建邻接图的过程如果涉及深层的列表结构,一样可能触发。
应对手段也有档次之分。最安全的做法是改代码逻辑,把递归改写成迭代;如果是临时排查,可以在 R 里调大options(expressions = 10000),但这只能缓解,不能根治。如果问题出在第三方包的 C 代码里,R 层面基本无能为力,只能换实现思路或分批处理数据。
4.2 递归、系统栈和显式栈:三者怎么选
既然聊到栈溢出,就把递归和显式栈的区别讲透。递归调用使用的是系统调用栈,它属于进程运行时环境的一部分。大多数语言对调用栈深度有限制:Python 默认递归深度约 1000,R 还要更保守。而且这个限制不是为了恶心你,是因为线程的栈空间是提前分配的,太深会导致内存地址空间耗尽。
后缀表达式里的栈是显式栈——你自己用数组模拟的那一种。它生长在堆区(heap),容量只受可用内存约束,理论上你可以压入几百万个元素。所以遇到深度不定的数据处理,你会看到很多老手的优先选择是把递归改成“显式栈 + while 循环”,目的就是绕开系统栈的深度上限。
这不是说递归不好。树遍历这类结构清晰、深度可控的场景,递归可读性远胜手动栈。但当你做表达式解析、处理很深的嵌套结构、或者像 R 那种在包内部无法控制递归深度时,显式栈就是更稳的工程方案。理解了这个取舍,再回头看后缀表达式求值里的stack,你会意识到自己已经是在用显式栈做原本可能需要递归才能完成的事情。
5. 把这套算法写进生产代码前,我建议你注意这几件事
5.1 高频 Bug 清单与解法
我在实际开发里反复踩过的坑,整理成一张表:
| Bug 现象 | 根因 | 对策 |
|---|---|---|
3 4 +计算得到 7 但多位数报错 | 把每个数字拆成单个字符处理 | tokenize 阶段按连续数字合并 |
12 2 /得到 1 而不是 6 | 同上,逐字符解析导致 12 被拆成 1 和 2 | 先做词法分析 |
| 除法结果永远是整数 | 项目里用了整型除法或旧版语言语义 | 统一转 float |
| 括号不匹配却不报错 | 转换算法把括号当普通 token 输出 | 右括号触发弹栈,左括号本身不输出 |
幂运算符^算错 | 在不少语言里^是位异或 | 明确运算符语义,推荐用** |
| 单目负号无法解析 | -3 + 2的-不是二元运算符 | 词法分析阶段识别“负号”与“减号”,或补 0 |
| 除数为 0 不报错,结果是 inf | 缺少运行时校验 | 求值时检查b == 0 |
5.2 tokenize 是大多数人漏掉的关键步骤
我发现很多人一上来就写中缀转后缀,结果卡在“怎么处理 12 这种多位数”上。其实表达式解析应该拆成两段:词法分析(tokenize,把字符串变成 token 列表)和算法阶段(利用栈转换和求值)。tokenize 就是先扫一遍,把连续数字识别成一个整体,把运算符识别成单个 token,跳过空格。
一个简版的 tokenizer 并不难:
def tokenize(expr): tokens = [] buf = [] for ch in expr: if ch.isdigit() or ch == '.': buf.append(ch) else: if buf: tokens.append(''.join(buf)) buf = [] if not ch.isspace(): tokens.append(ch) if buf: tokens.append(''.join(buf)) return tokens这个版本没处理幂运算符、单目负号等细节,但骨架是对的。有了它,后面的转换和求值只需要关心 token 类型,完全不用回头处理字符串。词法分析和算法阶段解耦之后,代码的复杂度和维护成本都会低很多。
5.3 一些值得带走的工程建议
第一,面试和练习直接用 Python 这类语言会很省心,但能真正加深理解的是把中缀转后缀和后缀求值分别封装成纯函数,再写一组用例去验证。比如3 4 + 5 2 - *结果应该等于 21。
第二,生产环境里如果要支持完整的表达式语法,不要自己硬刚正则和栈。可以选用成熟的解析库,比如 Python 的ast模块,或者针对具体语言的表达式解析框架。但理解栈原理能帮你判断库的性能特性:为什么有的解析器能处理超长表达式而不爆栈,基本都是显式栈或迭代解析的功劳。
第三,如果以后要在别的语言里实现同样逻辑,算法本身是语言无关的,唯一的语言差异点在 tokenize 部分。这也再次说明,把词法分析和算法阶段分开,是让代码跨语言可迁移的关键做法。
最后分享一个习惯:我每次拿到和栈相关的算法题,都先问自己一个问题——这里我需要维护的“待处理信息”是什么?后缀表达式里,栈保存的是“还没找到运算符的操作数”;转换算法里,栈保存的是“暂时还不能输出的运算符”。想清楚栈里装的是哪一层语义,代码写起来基本一次过。这个思路也可以平移到括号匹配、回文判断、函数调用栈这些场景里,本质都是一样的。