AI辅助编程实战:调度场算法69秒生成表达式计算器 📅 发布时间:2026/9/8 20:11:24 👁 浏览次数: 一、从灵感到落地为什么偏偏做一个表达式计算器说实话身边没见过几个程序员真的拿 DeepSeek 去写完整项目。多数人拿 AI 写的是脚本片段、正则表达式、SQL或者让它解释报错很少有人真敢把一个有一定算法含量的模块丢给大模型去生成。这次我决定试一把直接让 DeepSeek 写一个表达式计算器核心算法用调度场算法目标是在一分钟左右搞定整个核心代码。选择“表达式计算器”这个题目不是拍脑袋。它看起来小但实际覆盖了整条编程基本功链条字符串解析、中缀转后缀、栈的运用、运算符优先级处理、浮点数精度、异常输入怎么办。数据结构课上它通常是期末考试的重点日常生活中它是每个计算器应用的内核。你直接在 Python 里输入2 3 * 4eval()一行就能算出来但eval()背后藏着一个巨大的安全黑洞尤其是当输入来自用户时等于把你的程序执行权交给了对方。自己写一个解析器既能在功能上等价替代eval()又能彻底掐掉命令注入的隐患还能顺便把经典的调度场算法玩明白。这次的实操环境是我常用的一台 Windows 笔记本Python 3.11无任何额外依赖全程通过 DeepSeek 网页版对话生成代码。我评估自己的造型水平属于“勉强能撸简单脚本”的层次如果你和我水平差不多这篇文章应该非常适合你。整个项目从提问到拿到可运行的代码大约用了 69 秒。这个数字不是噱头是真实计时。当然后台模型推理是联网的我的网络也在正常波动但重点不是精准计算时间而是想说明一件事AI 做这类需要“算法建模 边界场景覆盖”的脏活累活已经靠谱到可以放进日常开发工作流了。二、调度场算法为什么是“标准答案”想弄明白这个项目为什么能这么快完成得先搞清楚调度场算法在设计上有多优雅。它由 Edsger Dijkstra 提出核心思路是“用两个栈处理中缀表达式将其转换为后缀表达式”后缀表达式再交给另一个栈完成求值。整个流程不难但每一步都像乐高积木一样严丝合缝。2.1 中缀转后缀人类友好机器不友好我们平时写3 4 * 2是人眼友好的中缀写法。人和人交流没问题直接丢给计算机计算却很麻烦因为程序员必须提前处理优先级否则你会先算 3 4 再乘以 2得出完全错误的 14而真实结果是 11。调度场算法通过一个运算符栈巧妙地解了这个问题。它从左到右扫描输入遇数字直接输出遇左括号入栈遇右括号把栈里运算符弹到左括号为止遇运算符则先弹出栈顶所有优先级更高或相等的运算符再把自己压栈。当表达式读完把栈里剩余运算符全部弹出得到的就是后缀表达式也叫逆波兰式。3 4 * 2的转换结果是3 4 2 * (1 2) * 3转换结果是1 2 3 *。为什么后缀表达式更好算因为它的计算过程完全去掉了括号和优先级判断只需要一个数字栈从左到右扫一遍碰到数字就压栈碰到运算符就弹出两个数字计算结果再压栈最后栈里只剩一个值。整条链路上没有递归、没有语法树、没有复杂的回溯全靠栈的经典特性时间复杂度 O(n)空间复杂度 O(n)在工程上够干净也够快。2.2 两个栈还是三个栈调度场算法分几步我之所以强调这个算法适合让 AI 来写是因为它天然具备“规则清晰、异常场景多”的特征。你给大模型描述清楚规则它很容易生成一个轮廓正确的实现但如果边界场景没覆盖全比如连续运算符、浮点数字符串、负号、除数为零、右括号先出现、空表达式模型生成的代码多半会翻车。这恰恰就是人的价值所在让 AI 写大框架人来补边界细节。在上手之前我自己先在草稿纸上梳理了一遍完整环节。整体分两个阶段阶段1中缀转后缀。需要一个运算符栈一个存放结果的输出列表。阶段2后缀求值。需要一个数字栈按序处理。特别注意负数怎么处理。-3 2里的减号到底是一元负号还是二元减号调度场的经典做法是给一元负号一个单独的优先级和标记或者扫描时把负数解析成一个特殊的数字值。多数初版实现根本没考虑这个导致-3 2直接解析失败。我把这个点提前跟 DeepSeek 说了结果它生成的代码里没有这个 bug省了我大把调试时间。三、69 秒里发生了什么完整对话还原与代码生成下面这段对话不是润色过的假记录就是我那天实际输入和输出的精简版本。我会把每次提问、模型的关键回复以及我对回复质量的判断一起写出来方便你理解整个工作流长什么样。3.1 第一次提问直给需求限定语言和边界我输入的第一句话是这样的“用 Python 写一个表达式计算器用调度场算法。要求支持加减乘除、括号、小数、负数、空格、幂运算。输入是字符串输出是 float。如果表达式非法要报 ValueError。附带单元测试。”之所以把需求说得这么硬是因为大模型的 output 质量极度依赖输入约束。你要是不说“输出是 float”“非法要报 ValueError”它可能给你一个能跑但接口含糊的版本后面还得来回改。DeepSeek 在十几秒内给了完整代码。核心结构大致如下import re def infix_to_postfix(expr: str): precedence {: 1, -: 1, *: 2, /: 2, ^: 3} right_assoc {^} op_stack [] output [] tokens expr.replace( , ) i 0 while i len(tokens): ch tokens[i] if ch.isdigit() or ch .: j i while j len(tokens) and (tokens[j].isdigit() or tokens[j] .): j 1 output.append(tokens[i:j]) i j continue elif ch -: if i 0 or tokens[i-1] in -*/^(: num_start i i 1 while i len(tokens) and (tokens[i].isdigit() or tokens[i] .): i 1 output.append(tokens[num_start:i]) continue else: while op_stack and op_stack[-1] ! ( and precedence[op_stack[-1]] precedence[-]: output.append(op_stack.pop()) op_stack.append(-) elif ch in precedence: while op_stack and op_stack[-1] ! (: top_prec precedence[op_stack[-1]] cur_prec precedence[ch] if cur_prec top_prec or (cur_prec top_prec and ch not in right_assoc): output.append(op_stack.pop()) else: break op_stack.append(ch) elif ch (: op_stack.append(ch) elif ch ): while op_stack and op_stack[-1] ! (: output.append(op_stack.pop()) if not op_stack: raise ValueError(Mismatched parentheses) op_stack.pop() else: raise ValueError(fUnknown token: {ch}) i 1 while op_stack: if op_stack[-1] (: raise ValueError(Mismatched parentheses) output.append(op_stack.pop()) return output def evaluate(expr: str) - float: rpn infix_to_postfix(expr) num_stack [] for token in rpn: if token.replace(., , 1).replace(-, , 1).isdigit(): num_stack.append(float(token)) elif token in -*/^: if len(num_stack) 2: raise ValueError(Invalid expression) b num_stack.pop() a num_stack.pop() if token : num_stack.append(a b) elif token -: num_stack.append(a - b) elif token *: num_stack.append(a * b) elif token /: if b 0: raise ValueError(Division by zero) num_stack.append(a / b) elif token ^: num_stack.append(a ** b) if len(num_stack) ! 1: raise ValueError(Invalid expression) return num_stack[0]这段代码一出来我的第一反应是 “结构居然是对的”。负号处理逻辑被单独拎出来了i 0 or tokens[i-1] in -*/^(这个判断准确识别了一元负号的位置然后直接吞掉负号和后面的数字作为一个整体 token。幂运算的右结合性也用了一个right_assoc集合弹栈条件里加了cur_prec top_prec and ch not in right_assoc这是调度场里最容易写错的一环。3.2 我手动补充和修正的部分AI 给出的初版代码虽然核心结构没问题但有一个隐藏 bug在evaluate函数判断 token 是否是数字时它用了token.replace(., , 1).replace(-, , 1).isdigit()。这在-3这种 token 上行得通但如果输入是1--2解析器会把第二个负号当成一元负号处理生成1 -2 -这样的后缀序列然后evaluate阶段会把-2当作数字正常压栈最后执行1 - (-2)结果是 3从数学角度看其实是合理的。但问题是非法表达式1 * 2这种就绝对不该通过。*直接跟着初版解析器虽然不会崩但会把*当作普通运算符压栈最后算出来的东西可能完全不是用户想要的。我给 DeepSeek 补充了一条要求“如果连续出现两个运算符且中间没有数字或括号直接报 ValueError。”它随后给我的修复版加了一个前置校验函数。这里的关键启发是AI 能把算法主链路写得很好但“非法输入检测”这种工程意识需要你人为补充约束。这不是 AI 笨而是你给的 prompt 里没写清楚它不知道你的应用对错误输入的容忍度是零。四、实际测试与踩坑记录边界情况比算法更考验人代码生成只花了一分钟但真正让这个项目变得有价值的是接下来半小时的功能验证。AI 生成的代码是骨架你得给它喂各种刁钻输入看它会不会长血栓。4.1 我用哪些用例测了一遍我在本地直接跑了一个测试脚本把下面这些场景全部过了一遍输入表达式预期结果实测结果结论2 3 * 41414.0通过(1 2) * 399.0通过2^3^2512512.0通过右结合生效-3 2-1-1.0通过1 - (2 - 3)22.0通过3.14 * 26.286.28通过1 / 0报错ValueError通过((12)报错ValueError通过2 * 3报错初版通过修复版报错通过修复版注意2 * 3这一行。初版代码不会报错它会把*直接压栈后续解析可能因为缺少操作数而在evaluate阶段弹栈时才发现栈里数字不够抛出 ValueError。所以纠错逻辑还是能被触发的但触发时机不对——我们希望在语法分析阶段就拦住它而不是等到计算阶段。修复方式是在infix_to_postfix开头加一个 token 合法性预检逻辑是“前一个 token 是运算符且当前 token 也是运算符且两个都不是括号时直接报错”。这里有一个特例2 * -3 1是合法的因为*后面跟了一元负号负号在这个上下文里不能被当成二元运算符。所以预检逻辑必须把“一元负号”这个场景单独拎出来否则你会把大量合法负数表达式误杀。最终预检函数大概长这样def validate_tokens(tokens): prev_type None # num, op, lp, rp for ch in tokens: if ch in -*/^: if prev_type op: raise ValueError(Consecutive operators) prev_type op elif ch (: prev_type lp elif ch ): if prev_type in (op, lp): raise ValueError(Invalid token before closing parenthesis) prev_type rp elif ch.replace(., , 1).isdigit(): prev_type num else: raise ValueError(fUnknown token: {ch})这是纯粹的人工补丁DeepSeek 不会自动想到你的应用需要这么严格的语法预检。4.2 浮点误差、除零、幂运算的细节浮点精度问题是另一个常见的坑。0.1 0.2在任何主流编程语言里都是0.30000000000000004而不是0.3这是 IEEE 754 二进制浮点表示的天生限制。如果你做一个面向用户的表达式计算器直接返回这个结果会被用户骂死。我在最终版本的输出上做了格式化如果浮点数的有效位数足够短直接用str()去掉无用尾部如果很长保留 10 位小数并去掉末尾的 0。还有一个细节幂运算^和**的优先级与结合性不同语言里有不同定义。在 Python 里2 ** 3 ** 2等于 512因为幂运算是右结合的。调度场算法里如果不显式处理右结合把它和加减乘除一样在优先级相等时直接弹出栈顶你会得到 64直接错掉。DeepSeek 生成的代码里已经包含了right_assoc集合这是它写得好的地方。除零检查就更不用说了必须在evaluate阶段检测除数为 0并且抛出的错误要有明确信息“Division by zero”否则后面接错误处理系统时你都不知道是哪一步出了问题。五、AI 辅助编程的边界在哪里哪些事我能让 AI 干哪些必须自己干这次实操让我有个很深的感触AI 编程的效率实质上是“你把需求描述得多清晰”的复利。你花 30 秒把约束说清楚它能为你省下 30 分钟的兜底调试你图省事只丢一句话那后面每一句“帮我修一下”都是在透支自己的时间和耐心。5.1 让 AI 写得快的三个小技巧第一给测试用例。不要只给需求而是给一组输入输出对AI 能看到这些对就知道边界条件的优先级。比如你期望2 3 * 4返回 14它就会条件反射式地处理优先级。你期望-3 2返回 -1它就会提前处理一元负号。第二指定报错类型和异常信息。我说“非法表达式要抛 ValueError”比“报个错”具体得多。这让 AI 生成的代码在异常处理上有明确的执行路径还会主动考虑raise的位置而不是 fatal 混乱地 return None。第三要求附带单元测试。这个最简单也最实用。AI 生成的测试代码也许不够全面但它会帮你覆盖最常见的 happy path你只需要在它给出的基础测试上添加自己的边界用例比自己从零写测试骨架省一半时间。5.2 哪些环节 AI 不太擅长这个项目里AI 生成的算法主体非常漂亮但一旦涉及到“非法输入判定”这个工程问题它默认的行为是“尽力解析而不是报错”因为从语言模型的角度它是在“续写代码”而不是“理解一个计算器产品”。这导致它的初版代码允许1 * 2这种非法表达式进入 evaluate 阶段。这种问题你不能指望一次 prompt 解决需要自己调试、发现问题、再喂给它。还有工程结构问题。AI 更喜欢把所有逻辑揉在一个文件里函数堆砌在一起虽然能跑但可维护性差。我自己动手把代码拆成了tokenizer.py、shunting_yard.py、evaluator.py、tests/test_calculator.py四个模块。AI 不会主动考虑你的模块划分除非你明确指示。提示让 AI 写代码的时候“可读性优于技巧性”。你没必要让 AI 生成一个一行写完表达式的压缩版本那写出来自己也看不懂后面改 bug 等于重写。5.3 这个表达式计算器还能往哪个方向扩展写完之后我又顺手扩展了几个功能说实话性价比极高每个都不难但很出效果支持三角函数sin(30)、cos(pi/2)这种只要在 evaluate 阶段把函数名映射到 Python 的math模块即可tokenizer 里识别sin这种标识符就行。支持变量比如x 3; y x * 2 1; y这需要增加符号表本质上就把调度场算法从“表达式计算器”升级成了“简易解释器”。支持//整除和%取余只需要在 precedence 字典里加两个键在 evaluate 阶段加两个分支。感兴趣的话你甚至可以引导 AI 把中缀转后缀的输出直接构建成抽象语法树然后自己写一个树形结构的计算器。那样的话距离实现一个真正脚本语言的求值器就不远了。六、碰到过的坑和对应的排查思路从初版代码到可复用模块我前前后后踩了四个比较明显的坑都记录下来给后来者当路标。6.1 一元负号前后夹击的隐患1--2这种表达式初版代码解析是通过的结果算出来是 3。数学上没错但语法上1--2在很多语言里并不是合法表达式除非你有明确的“负负得正”或“减负数”语义。问题是如果用户本来想输入1 - -2却漏了个空格语义其实是清楚的算 3 也说得过去。这个坑的本质不是算出错而是“错误输入和合法输入之间的边界模糊”。解决方式预检函数里不允许出现连续两个二元运算符但允许“二元运算符 一元负号 数字”。6.2 括号不闭合的“传炸弹”((12)这种输入初版infix_to_postfix在扫描完所有 token 后发现 op_stack 里还残留左括号会抛出 ValueError。这个检查逻辑本身没错但问题在于报错时机太晚。如果表达式很长用户根本不知道错在哪里。我的改进是在扫描过程中记录左括号位置当遇到右括号弹栈时如果对应栈顶不是左括号就立即报错并且报错信息里带上 token 下标位置这样调试的时候能直接定位到字符串里哪个位置出了问题。6.3 幂运算右结合性的优先级陷阱调度场算法实现里最容易翻车的地方就是这里。很多初见版本会用while op_stack and precedence[op_stack[-1]] precedence[ch]来处理所有运算符这对左结合运算符没问题但遇到2^3^2时如果优先级相等直接弹出栈顶等价于从左到右计算得到 64而正确结果是 512。修复方式是我上面写的right_assoc集合加上“如果当前运算符右结合则优先级相等时不弹出栈顶”这行代码是所有 AI 生成初版最容易漏掉或者搞反的。6.4 输入字符串前后空格和制表符这个属于低级坑但所有真实用户都会撞上。用户输入2 3可能前后带空格、中间有多个空格甚至 tab。初版代码用了简单的replace( , )这能干掉空格但干不掉 tab。稳妥做法是在 tokenizer 级别做正则分词re.findall(r(\d\.?\d*|\.\d|[\-*/^()]), expr)这一行收益巨大既能把数字、运算符、括号统一切成 token又能自然忽略掉中间的所有空白字符。七、写在最后我对 AI 写算法项目的一点真实感受69 秒拿到核心代码这是事实。但如果说整个项目 69 秒就全部完工那我是在骗你。真实的时间分配大约是这样的让 DeepSeek 写调度场算法核心1 分钟。我手动梳理需求、补全边界10 分钟。测试各种异常输入并修复15 分钟。拆分模块、整理代码结构10 分钟。加注释、写使用文档5 分钟。总计不到一小时但换来的是一个可以放心给用户用的表达式计算器模块。对比以前我从零手写调度场算法光是在纸上推演优先级细节就得花半小时再加上 bug 调试一整天基本就没了。AI 确实把“从零造轮子”的时间压缩到了一个很夸张的程度但“理解轮子为什么能转”这件事仍然得自己做。我在实际开发中越来越倾向于“AI 生成第一版人类负责所有极端情况”。大模型天然擅长生成符合主流语法的代码因为它的训练语料里全是这种主流写法但你真正需要警惕的不是主流路径而是用户在键盘上乱敲出来的那些稀奇古怪的输入。一个计算器如果处理不好用户手滑打错的括号那它就只是个玩具。这次实践里DeepSeek 的调度场算法主体让我挺意外它不只是背下了教科书代码还能处理好一元负号、右结合幂运算这种容易出错的边角。它生成的right_assoc集合直接命中了经典实现里的关键决策。这些细节说明它真的从成千上万的优秀代码里学到了东西而不是简单调用一个函数库。不过也有它做不到的事我希望它能直接根据用户报错信息反向定位自己生成的 bug 并主动修复但它更像一个“按指令办事的程序员”你说“测试一下0.10.2”它才会想到浮点精度问题。你不说它默认你用的是整数。所以和 AI 协作的正确姿态永远是你掌握全局它负责体力活。还有一个小技巧值得分享给所有用 AI 写代码的朋友写完核心功能后多跑几轮“你自己扮演用户去输入各种不像人话的表达式”的测试。((((12))))、-0.5 * (-0.25)、2 ^ -3、1//2、1e3、1这类输入才真正暴露实现里的隐藏缺陷。我在测试1的时候发现初版代码竟然通过了好家伙一个只支持 - * / ^的表达式计算器居然允许一元正号连写这某种意义上也算是个 feature但显然不是设计本意。后来我在 tokenizer 层直接把这个场景拦截掉了毕竟我们不是在支持 Perl。这个项目后续我还计划让它直接生成支持函数调用的版本比如sqrt(9) abs(-3)把你的 tokenizer 扩展一下把sqrt、abs、log这些函数名注册进来再在 evaluate 阶段做一个apply_function的映射。这样调度场算法就从“简单的四则计算器”进化成了“一个小型公式引擎”。如果你把函数名注册表做成字典把函数指针作为 value扩展一个新的函数只需要加一行注册代码从工程角度来说这个设计足够干净。到时候再让 DeepSeek 帮我写第一版我负责验收边界这大概就是未来程序员和 AI 协作的常态。