☰
自己设计算法题实战:基于栈的算术表达式求值全拆解
2026/10/9 8:24:42 网站建设 项目流程

最近一段时间我一直在琢磨一件事:刷了这么多算法题,自己能不能设计出一套题出来。标题就是“自己设计的算法题,会陆续更新哦”,这既是给自己立的一个flag,也是想换一种角度重新理解算法。以前刷leecode必刷基础算法题的时候,总有一种感觉——题目是别人出的,边界是别人划好的,我只需要在给定的框里打转。但当你真正动手设计一道题的时候,才发现出题这件事本身,比做题更考验功底。

如果你也是写Python、练算法思维题的人,或者正在带新人、搞编程课实训,又或者单纯刷题刷腻了想换换口味,这篇文章值得你读完。我会拿我自己设计的“实验2-基于栈的算术表达式求值算法”当主线案例,把从选题、设计、写题解到造测试数据的完整过程拆开揉碎讲清楚,顺带把那些只有出过题才会遇到的坑也交代了。

1. 为什么要自己设计算法题

1.1 刷题和出题是两码事

刷题的时候,你的目标是找到解法,把题目做对。出题的时候,你的目标是让别人能理解题目、能想到解法、能写出正确的代码,并且还要让你的测试数据能分辨出他到底是不是真的理解。这两件事对能力的要求完全不同。

我自己刷leecode基础题的时候,觉得栈的应用挺简单,括号匹配、最小栈、单调栈都是套路。可真到我自己要出一题“基于栈的算术表达式求值”的时候,麻烦立刻来了。括号要支持几层?运算符优先级怎么定义?除法是整除还是浮点?负数怎么处理?空格要不要容忍?这些都是刷题时根本不会去想的问题,因为题目已经替你把这些边界条件全都定义好了。

所以出题这件事,逼着我把一个算法从一个“解法”还原成了“问题本身的复杂度”。这个过程,说实话比自己闷头刷一百道题都有收获。

1.2 自己出题到底适合谁

我觉得有三类人特别适合试试自己设计算法题。

第一类是准备面试的人。现在大厂的算法面试很多题都是leetcode变体,你要是能自己照着某个经典考点改题目、造边界数据,面试遇到变体题就不会慌,因为你已经理解了一个考点能往哪些方向变形。

第二类是带新人的程序员或者老师。与其直接从网上题库找题布置给新人,不如自己设计几道贴合实际工作场景的题。我设计的这道栈的算术表达式求值,本质上是我们在写解释器、做公式解析时候的简化模型,新人做完题再去看生产代码,理解成本会低很多。

第三类是纯粹想加深理解的刷题人。一道题你做完之后,试着改改限制条件、加加难度、设计几个别人一眼看不出来的测试用例,你会发现自己对这道题的理解深度跟之前完全不一样。

2. 算法题设计的基本套路与选题思路

2.1 一个合格的算法题应该长什么样

我个人的理解是,一个合格的算法题必须包含五个要素:清晰的题意、确定的输入输出格式、合理的难度定位、正确的样例、能卡住错误解法的隐藏测试数据。这五个要素缺一个,做题的人就会一脸懵,或者你的评测结果根本没法区分代码好坏。

以我设计的那道“基于栈的算术表达式求值算法”为例,题面我是这样设计的:

给定一个由数字、运算符 + - * /、括号 ( ) 组成的算术表达式字符串,其中运算符均为二元运算,求表达式的值。要求使用栈作为核心数据结构完成,不允许直接使用 eval 这类内置函数。

输入输出格式我也写得非常死板,输入一行字符串,输出一个浮点数或者整数,保留两位小数。为什么要把格式写死?因为我做过评测,知道如果输出格式有歧义,判题的时候会有大量因为格式不对导致的无效提交。

难度定位我定成了中等偏基础。为什么不是困难?因为题目本身只需要用栈把中缀表达式转后缀表达式或者做双栈求值,不涉及递归下降、语法树这些更复杂的东西,定太难了会让初学栈的人直接放弃。但也不能定成简单题,因为它至少要求你会处理优先级和括号。

2.2 从真实场景中提炼题目

我设计这道题的初衷,是因为我在写一个配置解析器,里面涉及到了公式字符串的计算。你会发现实际场景里的表达式求值比课本上的要恶心得多,比如可能有多余空格、有负数、甚至还有除零的情况。但你不可能把这些全部塞给新手,所以我做了合理的简化:只支持非负整数和四种基本运算,括号只支持小括号,负数用 0 - x 的方式表达。

我的建议是,如果你要自己设计算法题,尽量从你真正做过的业务或者写过的工具里提炼问题。这样好处特别明显:第一,你不是凭空捏造,题目自带说服力;第二,你手头就有真实数据和真实处理经验,设计出来的边界条件不是硬凑的;第三,你讲题解的时候能穿插一些实战背景,读者或学生听的时候明显更有兴趣。

2.3 题目的难度分层与更新规划

既然标题说了“会陆续更新”,我给自己定了一个难度递进的计划。第一批题目全部围绕栈和队列这两种基础数据结构做文章,从括号匹配开始,到表达式求值,再到单调栈。第二批开始加二叉树,二叉树遍历、层序遍历、最近公共祖先这些。第三批再上动态规划和贪心。

每一道题我都要求自己遵循一个“三步走”流程:先定考点,再写题面,最后造数据。考点决定题目的灵魂,题面决定做题人愿不愿意看,数据决定这道题能不能真正检验出水平。三个环节缺一个,题目哪怕再花哨也是废题。

更新频率我也给自己定了死规矩,每周至少出一题,每题的题解必须包含三种解法的对比和复杂度分析。拿这道栈的表达式求值来说,我就可以写两种解法对比:中缀转后缀再求值,和双栈直接求值,各有各的适用场景。

3. 核心示例:基于栈的算术表达式求值算法全拆解

3.1 题目设计的目标与考查点

这道题我在设计之初就明确了核心目标:让学生或读者掌握栈在表达式处理中的核心作用,理解运算符优先级如何用栈来隐式控制。

具体考查点如下:

  • 栈的后进先出特性怎么体现在运算符优先级处理上
  • 中缀表达式中括号对计算顺序的影响
  • 不同解法的时空复杂度对比
  • 边界条件的处理,比如连续运算符、多位数、表达式前后空格

我在出这道题的时候,收集了几个非常典型的输入输出作为基础测试数据:

输入表达式期望输出设计意图
1 + 23.00基础加法,不带括号
2 + 3 * 414.00验证乘法优先级高于加法
( 2 + 3 ) * 420.00验证括号改变了计算顺序
10 - 2 * 34.00减法和乘法的混合
2 * ( 3 + 4 ) - 59.00括号嵌套在复杂表达式中

3.2 中缀转后缀:从人的思维到机器的思维

表达式求值有好几种实现方式,但是作为设计者,我最终推荐大家优先掌握“中缀转后缀表达式,再对后缀表达式求值”的路线。为什么?因为它的每一步都只依赖栈的基本操作,非常适合作为教学和自学的主线。

中缀表达式就是我们平时写的2 + 3 * 4,对人来说有优先级和括号的概念,但是对机器来说,它不知道哪个先算。后缀表达式(也叫逆波兰表达式)则把运算顺序全部压平,变成2 3 4 * +,机器从左往右扫一遍就能算出来。

中缀转后缀的算法逻辑是这样的:

  1. 从左到右扫描表达式,遇到数字就直接输出到结果列表。
  2. 遇到运算符时,如果栈为空或者栈顶是左括号,直接入栈。
  3. 如果栈非空,且当前运算符优先级大于栈顶运算符优先级,入栈。
  4. 如果当前运算符优先级小于等于栈顶运算符优先级,先把栈顶弹出并输出,再重复比较,直到满足条件后入栈。
  5. 遇到右括号时,不断弹出栈顶并输出,直到遇到左括号为止,左括号弹出但不输出。
  6. 扫描结束后,把栈中剩余的运算符依次弹出输出。

这里的关键在于,栈顶存放的是优先级较高的运算符,当低优先级的运算符到来时,高优先级的要先“算完”输出,这正是栈后进先出特性的浓缩。

3.3 后缀表达式的求值过程

后缀表达式求值就简单很多了,同样借助栈:

  1. 从左到右扫描后缀表达式的每一项。
  2. 如果是数字,压入栈。
  3. 如果是运算符,从栈中弹出两个操作数,先弹出的是右操作数,后弹出的是左操作数,执行运算,把结果压回栈中。
  4. 扫描结束后,栈顶元素就是表达式的值。

我特意强调“先弹出的右操作数”这个点,是因为减法和除法对操作数的顺序敏感。我自己在设计测试数据的时候,专门加了一条10 - 2 * 3,如果做题人把顺序搞反了,算出来就会是-4而不是4,测试数据直接能抓出这个错误。

3.4 Python完整代码实现与注释

为了让题目更贴近实际,我用Python写了参考实现。既然是Python算法思维题,代码风格我尽量保持简洁可读,不搞什么花哨的trick,就是老老实实的栈操作。

def infix_to_postfix(expression: str) -> list: """中缀表达式转后缀表达式""" precedence = {'+': 1, '-': 1, '*': 2, '/': 2} stack = [] postfix = [] tokens = expression.replace(' ', '') i = 0 while i < len(tokens): ch = tokens[i] # 处理多位数 if ch.isdigit(): num = 0 while i < len(tokens) and tokens[i].isdigit(): num = num * 10 + int(tokens[i]) i += 1 postfix.append(str(num)) continue if ch == '(': stack.append(ch) elif ch == ')': while stack and stack[-1] != '(': postfix.append(stack.pop()) stack.pop() # 弹出左括号 elif ch in precedence: while stack and stack[-1] != '(' and precedence[stack[-1]] >= precedence[ch]: postfix.append(stack.pop()) stack.append(ch) i += 1 while stack: postfix.append(stack.pop()) return postfix def eval_postfix(postfix: list) -> float: """计算后缀表达式的值""" stack = [] for token in postfix: if token.isdigit() or (token[0] == '-' and len(token) > 1): stack.append(float(token)) else: 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) return stack[0] def evaluate_expression(expression: str) -> float: postfix = infix_to_postfix(expression) return eval_postfix(postfix) # 测试 test_cases = ["1 + 2", "2 + 3 * 4", "( 2 + 3 ) * 4", "10 - 2 * 3", "2 * ( 3 + 4 ) - 5"] for expr in test_cases: result = evaluate_expression(expr) print(f"{expr} = {result:.2f}")

注意我在数字处理上做了多位数支持,在运算符弹栈比较中用到了>=,也就是说优先级相同的运算符也遵循从左到右的计算顺序。这一步很关键,如果你用了>而不是>=,比如10 - 2 - 3,就会因为不满足左结合而算错。

3.5 双栈求值的另一种实现思路

如果我出的题只让作中缀转后缀这一条路,其实有经验的做题人会立刻反问:为什么不能直接双栈求值?所以在我的题解设计里,我又补充了双栈直接求值的解法,跟主解法形成对比。

双栈求值的思路是维护一个操作数栈和一个运算符栈,扫描表达式时:

  1. 遇到数字直接压操作数栈。
  2. 遇到运算符时,如果运算符栈为空,或者当前运算符优先级高于栈顶运算符优先级,直接压栈。
  3. 如果当前运算符优先级小于等于栈顶优先级,从操作数栈弹出两个操作数,从运算符栈弹出一个运算符,算出结果压回操作数栈,然后重复,直到当前运算符可以入栈。
  4. 遇到左括号直接压运算符栈,遇到右括号则一直计算到弹出左括号。
  5. 表达式扫描完后,把运算符栈里的运算符全部计算完。

这个思路本质上和中缀转后缀是一致的,只是把“转后缀”和“求值”两个步骤合在了一起。我在题解里会明确说,这两种解法的时间复杂度都是 O(n),空间复杂度也都是 O(n),没有本质差距。选择哪种纯粹看个人偏好和题的约束条件。

如果题目要求只能用一种数据结构,那用双栈是更直接的做法。如果题目强调要理解逆波兰表达式,那中缀转后缀是不得不走的路。这种解法多样性,也是我在设计题时特意留下的“讨论空间”。

4. 自测数据与常见错误排查实录

4.1 第一批测试数据跑出了哪些bug

我自己写完参考代码之后,信心满满地跑了一遍测试用例,结果第一波就翻车了。这里我把真实踩坑过程记录一下,对设计题的人来说,这些细节比解法本身更值钱。

第一个问题是空格处理。题面说了容忍空格,我的第一版代码是把expression.replace(' ', '')写在函数外面的,结果测试用例里有个用户输入的字符串里面还有制表符\t,直接把解析干崩溃了。后来我改成用正则或者先过滤所有空白字符。在Python里面用''.join(expression.split())就一句搞定。

第二个问题是输入表达式里可能既有整数又有浮点。我的题目限定只支持非负整数,所以测试数据里不该出现浮点。但我在造数据的时候多写了一组2.5 + 3.2,自己参考代码直接报错。后来我把题面严格改了,明确声明“输入不会出现小数”,然后删除这组测试数据。这提醒我,设计题的时候,题目限制条件和测试数据必须严格一致,不能自己打自己脸。

第三个问题是除零。我本来想设计一个10 / 0的用例,测试一下异常处理,但转念一想,对于一个基础栈题来说,这超出了本来的考点范围。于是我删了这个用例,在题面上加了一句“输入保证不会出现除数为零的情况”。这一步很重要,很多初学者题容易在这一类无关考点上翻车,白白增加挫败感。

4.2 做题人最容易在哪一步翻车

我自己站在“出题者”的视角复盘了一下,发现这道题做题人最容易出错的有四处:

  • 运算符优先级比较时忘了处理左括号在栈顶的情况。栈顶如果是(,当前运算符应该直接入栈,而不能先弹出左括号,否则括号结构就乱了。
  • 多位数解析出错。很多人只处理了个位数,遇到10 + 20就算错了。我特意造了两组多位数混合的测试数据,比如10 + 20 * 3 - 5,就是为了卡住这种不严谨的实现。
  • 后缀表达式求值中弹出操作数的顺序搞反。前面已经说过,这里重复强调一下,减法、除法必须保证先弹出的是右操作数,后弹出的是左操作数,关键点就在于表达式10 - 3当成后缀是10 3 -,弹出时先出3再出10,计算的是10 - 3而不是3 - 10。
  • 没有处理表达式扫描完后栈中残留运算符的情况。有人处理到右括号就把整个循环停了,(1 + 2)这种没问题,但1 + 2 * 3扫完整个字符串后,运算符栈里还剩下一个*,忘了把栈弹出,结果就只有7的一半。

我在题目设计的时候,每设计一个测试数据,都会先想清楚它要“卡”掉哪一类错误写法。如果一组的测试数据跑完,所有的错误写法都不报错,说明这组数据是废的。

4.3 在线评测的报错信息排查清单

我把自己在调试题目的过程中遇到的不同报错类型整理成了一个速查表,方便你在设计自己的题时参考。这个表不只适用于这一道题,很多自设计的算法题遇到评测问题都可以按这个思路来排查。

报错类型可能原因排查方法
答案错误(WA)操作数顺序或者优先级逻辑错误人工走一遍后缀转换流程,打印中间过程
运行时错误(RE)弹出空栈在弹栈前加一个判断栈是否为空的打印日志
超时(TLE)数据规模没控制好,或者代码有死循环检查扫描循环里有没有漏掉 i += 1
格式错误(PE)输出小数位数不匹配统一用 print(f"{result:.2f}") 格式
编译错误(CE)语法问题,或者用了题目禁止的内置函数检查是否误用了eval

这里也给一个我在自测时候的习惯:永远准备一组最长的表达式,比如一千个字符层级嵌套括号,看看会不会因为递归调用爆栈。我这个题写的参考实现用的是显式栈,所以没事。以后你设计递归类题目的时候,尤其要注意递归深度问题。

5. 让题目质量更接近大厂题的一些经验

5.1 题面写作的措辞细节

出题出多了你会发现,题面里每一个自然语言描述都可能产生歧义。我举几个非常典型的例子:

  • 如果说“输出表达式的值”,没说是整数还是浮点数,就可能有一半的人输出3,一半的人输出3.00。所以我的题面必须写清楚“结果保留两位小数”。
  • 如果只说“包含数字和运算符”,没说数字的范围,就可能有人写高精度大数处理,把本末搞错。所以我的题面里要明确“数字均为整数且绝对值不超过 1000”。
  • 如果没说括号到底存不存在,就会有人干脆不处理括号,背着一个错误的解法到处碰运气。所以我在题面里要明确“输入保证括号匹配且最多嵌套一层”。

出题人写题面,就像写需求文档,一个模糊的词都能让几十个做题人白白浪费时间。这也是为什么我强烈建议你自己出题练一练的原因,它能直接反哺你日常的代码沟通能力。

5.2 隐藏测试点与边界数据的设计

网上很多公开题库的题都能靠猜数据混过去,但自己出的题数据质量就得自己负责。我总结了几类边界数据,几乎是每道题必备的:

  • 表达式只有一个数字,例如42,检验是否处理了没有运算符的情况。
  • 表达式长度极长,例如一长串连续加减,检验你的扫描循环和栈是否会溢出时间。
  • 运算符全是同级,例如1 - 2 - 3 - 4,检验左结合性是否正确。
  • 括号套括号,例如((2 + 3) * 4 - 5) / 2,检验括号弹栈逻辑是否通畅。
  • 表达式前后带空格,例如1 + 2,检验字符串预处理。

每个隐藏测试点都应该对应一个“容易出错的实现”,否则无效。我设计这道栈题的时候,给这五类数据各准备了好几组变体,加起来总共有二十多个测试用例,目的就是要把参考解法里可能的错误全都抓出来。

5.3 后续题目的规划方向

说到标题里的“会陆续更新哦”,我现在的计划是把这个题的系列做成一个栈主题的mini课程。第一题括号匹配,第二题就是这个算术表达式求值,第三题设计一个单调栈的题目。每一道题都遵循同样的设计规范:考点清晰、题面具体、数据覆盖完整、题解包含多种解法对比。

我还打算把每道题的题解都做成“出题者视角”的,也就是不仅讲怎么解,还解释为什么题目要这么设计。比如为什么优先级表用字典存,为什么用>=弹栈而不是>,为什么空格用join(split())处理。这些从常规题解里看不到的内容,才是这套题库跟外面那些刷题平台最不一样的地方。

6. 个人体会与一点额外建议

这种自己设计算法的过程,我最大的体会是:做一道题到能把它出成一道能考别人的题之间,隔着一条很难跨越的河。以前我刷leecode必刷基础算法题的时候,遇到不会的题第一反应是看题解、背套路。但是自己出完这道栈的表达式求值之后,我现在遇到一道新题,第一反应是分析它的考点层级:第一层考什么、第二层考什么、有没有隐藏的第三层。

我也建议你,哪怕不打算像我一样搞一个题库系列,也可以拿自己最近刷过的一道基础题,试着把它改一版“面向新人”的题目出来:重新设计边界条件、重新写测试数据、写一份比原题更清晰的题面。做完之后再回去看原题,你一定会发现原题突然变得透明了,考点、出题人的陷阱、以及最优解为什么最优,全都能看得清清楚楚。

这个技能对你的代码评审能力也有帮助。看别人的代码的时候,你的第一反应不再是“这里对不对”,而是“这个实现的边界情况处理得怎么样”,视角完全不一样。

最后再分享一个小技巧:每次自己出完一道题,把自己的参考代码丢到你自己设计的测试数据里跑一遍,不需要对拍器,哪怕只是打印每一步中间结果,都足以找出三五个隐藏问题。这套流程走完一遍,你对自己是否真正掌握考点的判断,会变得特别可靠。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询