算法题这东西,跟做饭、健身其实很像:跟着别人菜谱做一百遍,不如自己设计一道菜,再做一遍。最近我开了一个系列,准备自己出算法题,按基础、进阶、综合三个阶段慢慢更新。第一篇想先聊一道数据结构里必考、刷题平台上也常见的“基于栈的算术表达式求值算法”——它既是栈的经典应用,也是编译器原理和计算器实现的地基。这篇我会把我自己的出题思路、完整题面、手写实现和踩坑过程全部摊开来讲。适合正在学数据结构的同学、准备算法面试的初学者,以及想给自己设计刷题计划的人。
1. 出题动机与设计思路:为什么自拟题更有练头
1.1 刷题不等于会算法,出题才是高强度的主动学习
刷题平台上的题成千上万,但大部分人是被动刷题:打开题库,点开一道题,看题解,照着写,提交,AC,下一道。这套流程熟练之后,确实能提升“读题+套模板”的速度,但也会形成一个盲区——你很少需要自己定义问题边界。
我这两年带过一些实习同学,发现一个很有意思的现象:让他们写“用堆叠实现表达式求值”,大多能写出来;但你要是说“你自己设计一道考栈的题,要求难度适中、边界清晰、有测试点”,很多人一下就卡住了。因为自拟题意味着你同时扮演出题人、做题人和判卷人三个角色,你得想清楚:
- 这题到底在考哪个知识点?
- 输入格式怎么定?合法输入长什么样?
- 有哪些边界情况是学生容易忽略的?
- 测试用例怎么设计才能暴露错误?
这些问题,恰恰是平时刷题时“题目已经替你想好了”的部分。所以我选择自己动手出题,本质上是想补上主动设计这一环。算法题不该只是拿来刷的,更该拿来“造”。
1.2 我定下的三条选题铁律
既然要自己出题,就不能拍脑袋乱出,否则很容易变成“给自己出一道不会做的超纲题”,或者“出一道实现起来巨啰嗦但毫无学习价值的题”。我给自己定了三条铁律:
知识点必须单一而经典。一道题只围绕一个核心数据结构或算法展开,最多带上一个辅助技巧,避免一题揉进三个知识点。栈、队列、链表、递归、排序、二分、动规,一次只深挖一个。
题面要能在30分钟内读完并动手,20分钟实现主体,留出10分钟处理边界。如果题面本身就需要读三遍才能懂,那说明描述有问题,不是题有问题。
测试用例必须有梯度。不能只有Happy Path。入门用例、普通用例、边界用例、异常用例,四类都齐了才叫一道完整的题。
基于这三条铁律,我选定了系列里的第一道题:基于栈的算术表达式求值算法。为什么第一题选它?因为它的教学价值非常清晰:栈的LIFO特性和运算符优先级天然匹配,做这道题能把“什么时候压栈、什么时候弹栈”这个核心逻辑理解透,而且代码量适中,非常适合作为一道自拟题的起点。
2. 第一题题面解析:基于栈的算术表达式求值
2.1 完整题面与输入输出规范
我的第一道题长这样,以后这个系列里的所有题,都会沿用这一套出题格式:
题目名称:基于栈的算术表达式求值算法 题目难度:中等
题目描述: 给定一个字符串形式的算术表达式 expression,其中包含非负整数、四则运算符(+、-、*、/)和括号((、)),部分运算符前可能出现一元负号(如 -3 + 2)。请你用“栈”这种数据结构实现表达式求值,返回计算结果。整数和结果均以十进制浮点数形式参与运算,除法结果保留浮点数精度。
输入格式: 一行字符串 expression,长度不超过 200,允许包含空格。表达式保证括号匹配,不会出现除数为 0 的测试点(但你的程序应当具备基本的除零防护)。
输出格式: 输出一个浮点数,表示表达式的计算结果。允许误差为 1e-6。
这里有几个细节是出题时要刻意写清楚的:
- “不建议使用编程语言内置的 eval、exec 或任何脚本化求值函数。本题要求通过栈结构手动完成词法解析与求值。”——这一条是出题人必须主动加的限制,不然题目直接失去意义,Python里一个 eval 就结束了。
- 我把输入范围限制在 200 字符,原因是栈相关题目的重点在逻辑,不在大数据的IO优化,200字符足够覆盖所有边界类型,又不会让题目变成大数处理题。
- 明确允许空格、括号嵌套、一元负号,是为了让这道题更贴近真实计算器的输入场景。
2.2 两种主流解法的取舍:双栈直算与中缀转后缀
表达式求值在数据结构和编译原理里有两个经典实现路线,我们在自拟题时必须先想清楚考哪条路线:
| 方案 | 核心流程 | 优点 | 缺点 |
|---|---|---|---|
| 双栈直算 | 一个操作数栈、一个运算符栈,边读边算 | 实现直观,适合面试讲解 | 优先级和括号处理容易绕晕 |
| 中缀转后缀(调度场算法) | 把中缀表达式先转成逆波兰式,再单栈求值 | 逻辑分段清晰,测试容易定位 | 代码量稍大,多一道转换步骤 |
这两条路线我最后选择了双栈直算。理由很简单:这道题想考的“栈”,重点在于“什么时候该弹栈”这个决策过程。双栈直算把优先级比较、括号配对、操作数配对全部集中在同一个循环里,学习者能在最短代码量内感受到栈的运用节奏。如果选转后缀,核心考点会被拆到两步里,反而稀释了难度聚焦度。你后续如果想把它扩展成系列里的“进阶版”,完全可以把同一道题改成“调度场算法实现中缀转后缀”来做,那就留给进阶篇了。
2.3 为什么这道题是栈的最佳试金石
栈这个数据结构学的时候人人都懂:先进后出、只操作栈顶、入栈出栈O(1)。但一落到表达式的括号和优先级上,很多人就乱了。原因在于表达式求值中栈顶的“状态”是动态的,每一步都要问自己三个问题:
- 当前这个运算符和栈顶运算符谁优先级高?
- 要不要把左括号之前的运算全部完成?
- 右括号出现后,能保证操作数栈里有足够的数字去配对吗?
这三个问题其实对应着栈应用里的三种经典操作:优先级比较、括号隔离、成对弹出。所以这道题不只在考“你会不会用栈”,更是在考“你在多步状态变化中能不能始终维护好栈的语义”。这也是我把难度定为“中等”而不是“简单”的原因:代码量不大,但对逻辑的连贯性要求不低。
3. Python 实战实现:从 tokenize 到求值引擎
3.1 拆 token:先解决“读入”问题
表达式求值的第一件事不是算,而是读。你拿到的是字符串"1+(2*(3+4))-5/2",但计算机需要的是一个个有意义的单词,也就是 token。这一步在编译原理里叫词法分析,放在这道题里,就是“怎么把字符串拆成数字、运算符、括号三个类型”。
我当时第一次写这个函数时踩过一个很典型的坑:直接把一个中文字符串循环按字符判断,结果遇到"2.5*4"这种带小数点的数,就被拆成了2、.、5三部分。很多初学 Python 的人写表达式求值,第一版都是挂在这儿的,因为他们忘了“数字是一个整体”,需要连续读取。
所以 tokenize 要按“贪婪匹配”来做:当你遇到数字或小数点时,一直往后读到不再是数字或小数点的位置,整个作为数字token。遇到运算符或括号,就单独成token。空格直接跳过。这是最基础也最稳妥的做法。
核心代码片段如下:
def tokenize(expr: str) -> list[str]: tokens = [] i = 0 n = len(expr) while i < n: ch = expr[i] if ch.isspace(): i += 1 continue if ch.isdigit() or ch == '.': j = i while j < n and (expr[j].isdigit() or expr[j] == '.'): j += 1 tokens.append(expr[i:j]) i = j continue if ch in '+-*/()': # 一元负号单独标记,用 '_' 表示 if ch == '-' and (not tokens or tokens[-1] in '+-*/('): tokens.append('_') i += 1 continue tokens.append(ch) i += 1 continue raise ValueError(f'无法识别的字符: {ch}') return tokens这里面有个亮点值得说一下:一元负号单独标记成_。比如-3 + 2会被拆成['_', '3', '+', '2'],而3 * (-2)会被拆成['3', '*', '_', '2']。为什么要单独标记?因为如果直接把-当成普通二元运算符,后面那套“优先级比较”逻辑就会把一个本来应该修饰数字的负号当成减号来处理,结果完全不对。这个设计是从编译原理里“前缀运算符”的概念借鉴过来的,后面求值阶段单独处理_就行。
3.2 核心求值逻辑:优先级与括号的栈顶较量
token 拆完之后,真正考验栈的“求值引擎”才登场。我用的是双栈直算:nums栈放数字,ops栈放运算符。整个算法可以归结为四句话:
- 遇到数字,压入
nums。 - 遇到普通运算符
+ - * /,先看栈顶运算符优先级是否不低于当前运算符,若是就先完成栈顶运算,再压入当前运算符。 - 遇到左括号,直接压入
ops。 - 遇到右括号,从
ops一直向下运算,直到碰到左括号为止,然后把左括号弹出。
第2条是整道题最难讲清楚的地方,也是最容易写错的地方。为什么是“不低于”而不是“高于”?我习惯用一个例子解释:表达式1 + 2 * 3。当读入*时,栈顶运算是+,优先级为1,而*优先级为2,1 < 2,所以不应该先算加号,而是把*压入栈顶,这样最后算的是2 * 3而不是1 + 2。反过来,表达式2 * 3 + 1,读入+时,栈顶是*,优先级2比1高,必须先把2 * 3算完,才能把+压栈。
这个逻辑说白了就是:优先级高的先算,同级从左到右算。用代码写出来反而简洁:
PRECEDENCE = {'+': 1, '-': 1, '*': 2, '/': 2} def apply(op: str, a: float, b: float) -> float: if op == '+': return a + b if op == '-': return a - b if op == '*': return a * b if op == '/': if abs(b) < 1e-12: raise ZeroDivisionError('除数为0') return a / b raise ValueError(f'未知运算符: {op}')def evaluate(expr: str) -> float: tokens = tokenize(expr) nums: list[float] = [] ops: list[str] = [] i = 0 while i < len(tokens): t = tokens[i] if t == '_': i += 1 if i >= len(tokens): raise ValueError('一元负号后缺少数字') nums.append(-float(tokens[i])) i += 1 continue if t in PRECEDENCE: while (ops and ops[-1] != '(' and PRECEDENCE[ops[-1]] >= PRECEDENCE[t]): op = ops.pop() b = nums.pop() a = nums.pop() nums.append(apply(op, a, b)) ops.append(t) i += 1 continue if t == '(': ops.append(t) i += 1 continue if t == ')': while ops and ops[-1] != '(': op = ops.pop() b = nums.pop() a = nums.pop() nums.append(apply(op, a, b)) if not ops: raise ValueError('括号不匹配:缺少左括号') ops.pop() i += 1 continue try: nums.append(float(t)) except ValueError: raise ValueError(f'异常token: {t}') i += 1 while ops: op = ops.pop() b = nums.pop() a = nums.pop() nums.append(apply(op, a, b)) if len(nums) != 1: raise ValueError('表达式不合法') return nums[0]注意最后这里有个细节:全部读完后,ops里可能还剩下运算符,比如1 + 2,等循环结束ops里还有+,所以要再执行一次“把栈里剩下的运算符全部算完”的操作。而算完必须检查nums栈的长度恰好为1,否则说明表达式里数字和运算符的数量对不上,属于非法输入。
3.3 完整可运行代码和测试用例清单
把 tokenize、apply、evaluate 组合起来,就是一个可以放进 Python 文件直接跑的小型计算器。我还顺手写了一个测试函数,保证改代码的时候不会把老功能改坏:
def run_tests(): cases = [ ("1+2*3", 7.0), ("(1+2)*(3-4)", -3.0), ("2.5*4", 10.0), ("1+(2*(3+4))-5/2", 12.5), ("-3+2", -1.0), ("3*(-2)", -6.0), ("((1+2))*3", 9.0), (" 2 * 3 + 1 ", 7.0), ] for expr, expected in cases: result = evaluate(expr) ok = abs(result - expected) < 1e-9 print(f"{expr:>14} => {result:>6} 期望 {expected:>6} {'OK' if ok else 'FAIL'}")运行结果:
1+2*3 => 7.0 期望 7.0 OK (1+2)*(3-4) => -3.0 期望 -3.0 OK 2.5*4 => 10.0 期望 10.0 OK 1+(2*(3+4))-5/2 => 12.5 期望 12.5 OK -3+2 => -1.0 期望 -1.0 OK 3*(-2) => -6.0 期望 -6.0 OK ((1+2))*3 => 9.0 期望 9.0 OK 2 * 3 + 1 => 7.0 期望 7.0 OK这几条用例看似简单,其实涵盖了加乘优先级、括号改变优先级、多层括号、小数运算、一元负号在开头、一元负号在括号内、多余括号、含空格输入一共八种情况。我建议你把这个结构当成以后自己出题时的基本测试模板:不是先写代码再想测试,而是先列测试点再写实现。真正常规文档里不会告诉你的是:一道算法题能不能立住,一半在代码逻辑,另一半在测试用例是否残忍。
4. 真实踩坑与排查技巧
4.1 新手最容易翻车的几类输入
我写这个题的时候故意设计了一些容易翻车的输入,这里给出一份高频问题速查表,都是从各位实际手写代码的报错里提炼出来的:
| 输入 | 表面现象 | 根因 |
|---|---|---|
-3+2 | 结果变成5或直接报错 | 把一元负号当二元减号,运算顺序错乱 |
3*(-2) | 报错IndexError: pop from empty list | 遇到*后面紧跟-,没有处理负号 |
2.5*4 | 拆成2、.、5,求值崩溃 | 数字解析没做贪婪连续读取 |
((1+2) | 结果算完但少了右括号,报错或误算 | 括号匹配只是“遇到右括号处理”,缺少最后检查 |
8/0 | 程序卡死或崩溃 | 除零未防护,或没有检查除数 |
这里面最隐蔽的是第二个。新手写完双栈求值后,通常能通过1+2*3,但一碰到3*(-2)就会从操作数栈弹出一个空数字,直接抛IndexError。为什么?因为在tokenize阶段数字是按字符直接遍历并压栈的,看到-时,它以为这是一个要从栈里拿两个数字来算的减法,结果发现操作数不够,于是引发索引错误。解决方案就是我上面写的:把一元负号单独识别、单独压栈、单独处理。这一步不搞定,后面再怎么写都白搭。
4.2 一个容易被忽略的边界:一元负号
一元负号是表达式求值里出了名的“边缘陷阱”。它之所以麻烦,是因为同一个字符-在表达式里有两种完全不同的身份:二元减法a - b和一元取负-a。不区分这两者,程序就无法正确计算3 - -2这样的式子。
我采用的方案是:在tokenize时判断-的前置上下文——如果它是整个表达式的第一个字符,或者前一个 token 是运算符、左括号,那就说明它是一元负号,而不是减法。因为按数学规则,减号前面必须有一个操作数,而负号可以出现在没有操作数的地方。
这个判断逻辑,比在后面做“看当前 token 是数字还是运算符再决定”要稳健得多。我自己试过另一种思路:想在后端求值时判断“数字不够就按负号处理”。但这样做会让apply函数既要处理二元运算又要处理一元运算,职责混乱,代码可读性直线下降。把一元负号的问题在词法阶段就地解决,后面求值阶段就只需要关心一种干净的二元运算,这是我一次踩完坑之后的实际结论。
4.3 我的自检套路:测试金字塔反转
我给自己出了一道题之后,怎么确认这道题没问题?这里分享一个和普通测试不太一样的做法:正常开发测试,通常是“核心功能用例最多,边界用例其次,异常用例很少”。但给算法题设计测试点的时候,我会把金字塔倒过来——异常和边界用例为主,正常用例为辅。
原因很现实:正常用例哪怕代码写得不优雅,结果也基本是对的;而边界用例和异常用例才是暴露逻辑漏洞的地方。比如上面那张速查表里的六类输入,正常答案是什么不重要,重要的是程序能不能给出明确反馈:
- 除零:应该抛出
ZeroDivisionError,而不是算出inf或nan。 - 括号不匹配:应该主动报“括号不匹配”,而不是默默按错误顺序运算。
- 一元负号后缺数字,比如
3*-:应该报“缺少数字”,而不是抛IndexError。
这种“宁可报错也不给错答案”的测试标准,放到面试里,就是所谓“边界条件是否想全”的考察点。放到工程里,就是健壮性和容错的基本要求。所以我现在给自己出题,写完核心代码后的第一件事不是去算1+2*3,而是先拿一堆特殊输入去“攻击”它。攻击得越狠,题的质量越高。
5. 后续题单规划与复盘方式
5.1 这个系列接下来想做的题
这门“自拟算法题”的系列我打算分成三个阶段推进:
第一阶段是基础数据结构,已经确定要出的题包括:单链表的原地反转(考指针操作细节)、用两个栈实现队列(考栈与队列的语义转换)、括号匹配检验(栈的入门必练)、单调栈解决“下一个更大元素”(从栈到应用的一步跨越)。
第二阶段是算法主题,包括二分查找的边界处理、递归与分治的归并排序实现、快速排序的原地分区细节、状态机在图遍历里的简单应用。
第三阶段是综合题,比如基于表达式求值扩展到“带变量的表达式计算”,或者在二叉树中做层序遍历、锯齿形遍历等组合题。
这些题都会围绕“我自己在实际学算法过程中觉得容易绕、容易错、值得反复练”的点来做设计,而不是平台难度的堆砌。选题的一个原则是:每道题我自己先把代码写一遍,然后在纸上模拟一遍数据变化,最后再拆成题面。这个过程会很慢,但收获远远大于一天无脑刷十道题。
5.2 如何给自己写的题定难度和验收标准
一个常见困惑是:怎么知道自己设计的题是“中等”而不是“简单”?我目前的经验法则是看“熟练者首次编写需要多久”:
- 能在10分钟内写出核心逻辑,算简单。
- 需要20到30分钟,中间会卡在优先级比较、边界处理这类关键处,算中等。
- 超过40分钟仍在调边界和数据结构,那大概率偏难了。
这道“基于栈的算术表达式求值”被我定成中等,就是因为多数熟练者能快速写出主体框架,但在一元负号和括号嵌套处理上通常要停下来想一会儿。它在“会与不会”之间正好留了一个纠缠区,这就是教学价值所在。
验收标准上,我给自己的自拟题定了三条硬指标:测试用例梯度完整(至少包含正常、边界、异常三类);代码能无报错一次性跑完全部自测用例;我自己能在不偷看代码的前提下,用纸笔把示例输入的人工演算过程完整写出来。满足这三条,这题才会被放进系列里。下面把这几条标准整理成一张表,方便你以后自己出题时直接拿来用:
| 验收项 | 达标要求 |
|---|---|
| 题面清晰度 | 别人不经过额外解释,能独立理解输入输出格式 |
| 测试梯度 | 正常用例、边界用例、异常用例各有至少两组 |
| 复现稳定 | 在同一输入下,多次运行输出完全一致 |
| 参考实现 | 作者自己写的实现不用内置解法,能在30分钟内完成 |
| 人工可演算 | 能手工模拟每一步栈的变化,证明题目逻辑可追踪 |
5.3 我的复盘习惯:把每道题当一个小型工程做
最后聊一个我私心很重的习惯:每出一道自拟题,我不只留下题面和代码,还会写一份“踩坑记录”,内容包括初次看到题目时最先想到的错误解法、卡住的具体位置、后来怎么调整思路、测试用例里哪个输入最先暴露问题。这个记录不会发出来,只留给自己看。
写这份记录的最大好处是:下次再设计类似题目时,我能清晰地知道哪类边界是我这个水平的人最容易漏的,从而提前在题面里给出提示或者加强测试用例。比如这次表达式求值,我踩的一元负号和除零问题,已经变成下一道“带括号的表达式化简”的前置知识了。自己出题其实就是一个逼着自己把模糊知识变成确定规则的过程,只要你愿意在每次出题后认真复盘,这个系列就会越做越顺手,题也越来越有质量。