算法编程和开发编程不是二选一:栈求值题拆解双轨思维
先说结论算法编程和开发编程根本不是竞争关系它们是一个技术人两条腿里的两条韧带。我做了快十年开发带过不少新人见过太多人在“选算法还是选开发”这个问题上纠结到失眠。半年刷了三百道题最后发现不喜欢做题的也有写了好几年 CRUD 突然发现自己根本不会设计算法的。所以今天想认真聊一次这个话题顺便借一道非常经典的实训题——“基于栈的算术表达式求值”把算法编程和开发编程各自在做什么、拆开揉碎给你看一遍。这道题是最能同时体现两类编程思维差异的例子。在学校里它是《数据结构》课程的经典作业在面试里它是栈和递归的高频考点但如果在真实项目里遇到它你会立刻切换到开发编程的视角异常输入怎么办除零怎么办性能够不够代码要不要给别人维护。也就是说同样的需求算法思维和工程思维给出的成品完全不是同一个东西。这篇文章先帮你看清两条轨道各自长什么样、解决什么问题然后借这道栈求值题走一遍完整的算法实现和开发改造最后抛开焦虑给你一套可落地的选择和深耕路径。1. 双轨并行两类编程到底在解决什么问题很多人第一次被“算法编程”和“开发编程”搞懵是因为这两个词在招聘 JD 里、在课程大纲里、在技术文章里经常混着用。你去看岗位描述后端开发也要求懂算法算法工程师也要求懂工程。但本质上这是两种不同的思维方式服务于两种不同的目标。1.1 算法编程的本质与典型场景算法编程的核心命题是在给定的资源和约束下如何把问题算得快、算得准。它的关注点是时间复杂度和空间复杂度是数据结构的选择是边界条件的覆盖。一个典型场景就是这道实训题——给一个中缀表达式34*2/(1-5)你要让程序按数学规则算出结果。难点不是“会算”而是“程序怎么知道先算乘除后算加减、括号里的优先算”。你需要把计算规则编码成逻辑然后用栈这个数据结构去模拟人的计算过程。用生活化的类比算法编程就像你要设计一套交通调度方案。你要考虑车的数量、道路容量、红绿灯配时追求的是整条路网一分钟能通过多少辆车极端情况下会不会堵死。你不需要亲自去开车也不需要修路但你要把整个系统的最优运作方式想明白。这种思维在工程里也大量使用。缓存淘汰策略选 LRU 还是 LFU消息队列的积压要不要做限流搜索功能的关键词匹配用哈希还是 Trie这些都离不开算法基本功。所以算法编程并不是只在刷题和比赛中存在它嵌在几乎所有高性能系统的内核里。只不过在真实工程里你写的每个算法都要接上输入输出、接上异常处理、接上监控告警而纯算法题把这些都帮你屏蔽掉了。1.2 开发编程的本质与典型场景开发编程的核心命题是如何在真实环境下稳定、可维护、可扩展地把一个功能交付出去。关注点是模块划分、接口设计、依赖管理、部署运维。同样是做一个表达式计算器开发视角要考虑的不只是“能算出 34*2/(1-5)”而是它要不要做成一个服务输入特别长的表达式会不会拖垮 CPU用户传进来一个乱码字符串程序是崩溃还是返回友好错误三个月后别人接手代码能不能读懂这些关注点纯算法题的评测系统一个都不会帮你检查。延续刚才的类比开发编程更像是真正去修路、造车、铺管线的人。你要选水泥标号要设计路面排水要让这段路在暴雨天也不积水还要考虑以后旁边要加一条匝道时怎么预留接口。和交通调度方案比它更贴近土地需要考虑的维度更多、更琐碎但对稳定性的要求极高。所以你会看到开发岗位的人每天都在写业务代码看起来就是“把数据从库里查出来加工再存回去”或者“调第三方接口解析返回渲染给前端”。但这背后牵扯的分布式缓存、消息队列、数据库索引、日志链路追踪每一项都有一套完整的设计哲学。开发编程解决的核心问题不是单个算法快不快而是整个系统烂不烂、崩不崩、能不能演进。1.3 为什么大家总在这两条路之间摇摆矛盾点在于算法题内容相对独立刷一道算一道正反馈很直接面试又必考所以很多人觉得算法更强开发编程需要长期投入在具体业务里效果反馈慢经常几个月都在改同一个模块成就感来得没有那么快。反过来也有人觉得算法不接地气“刷题刷得再好工作里也没人让你手写红黑树”于是又觉得开发才是吃饭本事。说实话这两种感受都对。我自己的观察是这两个方向在入门阶段确实是抢夺时间的关系因为你的精力有限。但走到中后期它们是互相成就的关系算法能力强的人写工程代码时对性能瓶颈的嗅觉更灵敏工程能力强的人做算法落地时知道从“算出来”到“上线”之间还差哪些关键步骤。问题不是选哪条路而是怎么在两件事之间找到你的主次和节奏。下一节我先用这道栈求值题带你走一遍算法编程的完整实现链路让你触摸一下这条轨道的真实质感。2. 用一道栈求值题看清算法编程的完整链路这道题的完整描述通常是这样输入一个只包含数字、四则运算符和括号的算术表达式如34*2/(1-5)要求程序输出计算结果。很多新手一上来就想到“遍历字符串遇到数字就用一个变量存遇到运算符就计算”但很快就会发现自己处理不了优先级和括号嵌套。正确思路需要先建立一个数学模型。2.1 题目拆解从数学表达式到栈模型人眼扫过34*2/(1-5)能立刻知道先算括号里的但计算机没有这个直觉它只能按指令一条条执行。所以第一步是把计算规则结构化。这里引入两个概念运算符优先级乘除是 2加减是 1括号是一个特殊标记。计算时机只有遇到优先级不高于栈顶运算符时才把栈顶的运算符“结算”掉否则压栈等待。为什么用栈因为算术表达式天然就是一个递归嵌套结构。括号内的表达式和整个表达式是同构的而栈正好能保存“还没算完的上下文”。你看2/(1-5)读到左括号时除号后面那个被除数还没出现你必须把它挂起来等括号里算完再继续。这就是“先进后出”越晚遇到的运算符在优先级规则下反而可能越早参与计算。用栈去匹配这个行为是天作之合。具体地说可以维护两个栈一个数字栈存操作数一个运算符栈存待处理的运算符。从左到右扫描表达式遇数字压入数字栈遇左括号直接压入运算符栈遇右括号就把括号之间的运算符逐个弹出并计算直到遇见左括号遇四则运算符就反复弹出“优先级大于等于当前运算符”的栈顶运算符进行结算再压入当前运算符。表达式扫描完成后把运算符栈里剩下的逐个结算数字栈顶就是结果。这个算法的学名是“双栈求值法”它和先转后缀表达式逆波兰式再计算本质上是同一个过程双栈法把“转后缀”和“计算后缀”两个步骤合在了一起。理解了双栈法逆波兰式也就顺手掌握了。2.2 双栈求值的完整实现与关键决策我用 Python 给你一个可以直接跑通的最小实现函数名叫evaluate接收字符串表达式返回浮点数结果。为了把核心逻辑讲清楚先去掉了对一元负号和非法输入的完整处理那些放到第 3 节讨论。def evaluate(expr: str) - float: # 运算符优先级映射左括号优先级设为 0防止被弹出 priority {: 1, -: 1, *: 2, /: 2, (: 0} num_stack [] # 数字栈 op_stack [] # 运算符栈 def apply_op(): 从栈中弹出两个数字和一个运算符计算结果并压回数字栈 right num_stack.pop() left num_stack.pop() op op_stack.pop() if op : num_stack.append(left right) elif op -: num_stack.append(left - right) elif op *: num_stack.append(left * right) elif op /: if right 0: raise ZeroDivisionError(除数不能为0) num_stack.append(left / right) i 0 while i len(expr): ch expr[i] if ch.isdigit(): # 解析多位数字 j i while j len(expr) and expr[j].isdigit(): j 1 num_stack.append(float(expr[i:j])) i j continue elif ch (: op_stack.append(ch) elif ch ): # 弹出括号之间的所有运算符 while op_stack and op_stack[-1] ! (: apply_op() op_stack.pop() # 丢弃左括号 elif ch in priority: # 当前运算符优先级不高于栈顶运算符时先结算栈顶 while op_stack and op_stack[-1] ! ( and priority[op_stack[-1]] priority[ch]: apply_op() op_stack.append(ch) elif ch : pass # 直接跳过空格 else: raise ValueError(f非法字符: {ch}) i 1 while op_stack: apply_op() return num_stack[-1] print(evaluate(34*2/(1-5))) # 输出 1.0这里有两个关键决策你千万别改错。第一左括号的优先级必须设为 0这样当它在栈顶时任何运算符都无法把它弹出来。如果你把它设成大于等于乘除的优先级整个括号结构就被破坏了。第二弹出条件用的是“优先级大于等于当前运算符”而不是“大于”。为什么因为四则运算中同为加减或同为乘除时必须保证从左到右的计算顺序。9-3-2如果遇到第二个减号不弹栈就会先算3-21再算9-18正确答案是 4。这个等号守住了左结合的性质。2.3 实操中的边界情况与调试经验这段代码在课程实训题上是通过了但我在实际调试时踩过几个坑给你逐个排雷。第一个是多位数字的解析。如果你只用ch.isdigit()处理一位数123就会被拆成1和2结果完全错误。上面用j指针向前探测把所有连续数字作为一个整体压栈这个模式在任何表达式解析里都通用。第二个是浮点数和整数混用的问题。全程用float存入栈里可以避免1和1.0不一致引发的比较错误。第三个是把apply_op抽成独立函数。别嫌多写这几行调试的时候你会在apply前后打印栈的状态如果逻辑内联在 while 循环里打印出来的东西会乱得没法看。我在实训课上见过太多同学把这段逻辑写了三遍每遍都在不同的地方出错就是因为不理解“弹出并计算”这个动作是复用的。还有一个很容易被忽略的细节表达式扫描完之后运算符栈里还可能有残留运算符没有结算。比如没有括号的12*3扫描结束后乘号和加号都还在栈里。所以最后的while op_stack循环绝对不能省。我见过很多人把这个循环漏掉然后对着“为什么结果永远是左边第一个数字”苦思冥想。这道题做到这里算法编程的链路是完整的你把一个问题抽象成数据结构问题用栈维护运算顺序通过边界测试最终得到一个正确的结果。整个过程追求的是“算得对、算得好”。但是当我把同一个计算器带到真实项目里我发现我还需要处理更多东西。3. 同一道题开发编程视角会怎么做如果你只是完成课程作业上一节的代码已经足够了。但如果这是一段要上线运行、被其他服务调用的代码你会立刻发现它不够用异常输入会让服务崩溃、日志里什么都没有、多个调用互相干扰别人也没法对它做单测和改造。这就是开发编程要回答的问题从“能跑”到“能用、能维护、能部署”中间还有一大段工程化距离。3.1 从“能跑”到“能维护”的工程化改造第一步是改造封装结构。纯函数evaluate(expr)虽然好调用但缺少类型约束和异常契约。我可以定义一个表达式计算器类把数字栈和运算符栈收敛成实例状态这样将来要加功能比如支持三角函数、支持**运算就不需要改调用方接口。对外暴露的方法只有evaluate内部再拆分细节方法让代码像豆腐一样能切成整齐的块。class ExpressionEvaluator: PRIORITY {: 1, -: 1, *: 2, /: 2, (: 0} def __init__(self): self._num_stack [] self._op_stack [] def evaluate(self, expr: str) - float: self._num_stack.clear() self._op_stack.clear() # 解析并计算... return self._num_stack[-1] def _apply_op(self): # 内部结算逻辑 pass可能有同学觉得这是过度设计但我带项目经验是业务方的需求从来不会只说“给我一个计算器”而是“给我一个计算服务支持多种表达式扩展给我留好监控埋点”。面向扩展的设计在第一天看起来多写了几行代码到第十次需求变更时就看出价值了。另外真实工程里的输入来源五花八门。可能是前端传过来的表单可能是消息队列里的数据可能是另一个部门导出的 Excel 文件里的单元格。所以表达式求值函数还必须做输入清洗、长度限制和超时保护。一个恶意构造的几千行嵌套括号表达式理论上可以让递归版本的代码栈溢出。双栈法本身是迭代的不至于爆栈但字符串长度和计算耗时都需要做上限约束。这就是开发思维和算法思维一个很实在的差异算法主要防逻辑漏洞开发还要防业务和对抗性输入。3.2 异常处理与单元测试的一个参考方案上一节的evaluate遇到非法字符会抛ValueError遇到除零会抛ZeroDivisionError。真实项目里你不会希望一个异常直接穿透到最外层导致整个服务挂掉而是要在边界处捕获、记录、转换成用户可读的错误响应。所以我会给计算器包一层异常处理的外壳并把错误信息结构化。class InvalidExpressionError(Exception): pass class DivisionByZeroError(Exception): pass def safe_evaluate(expr: str) - float: 业务层安全调用入口 if len(expr) 1000: raise InvalidExpressionError(表达式过长) try: return ExpressionEvaluator().evaluate(expr) except ZeroDivisionError: raise DivisionByZeroError(表达式包含除0操作) from None except (IndexError, AttributeError, ValueError): raise InvalidExpressionError(表达式格式错误) from None为什么我要用自定义异常类型因为调用方需要根据异常类型做不同的界面提示。如果是表达式过长前端可以提示“输入内容超限”如果是除零可以提示“除数不能为0”如果是格式错误可以提示“请检查运算符和括号”。如果全部抛同一个原始异常调用方只能用字符串匹配错误信息代码既脆弱又难看。配套的单元测试我建议至少覆盖这几组用例用例表达式期望结果测试目的普通混合运算34*2/(1-5)1.0验证优先级和括号连续减法的左结合9-3-24.0验证运算符弹出顺序多层括号嵌套((23)*4-1)19.0验证括号匹配逻辑多位数和小数12.53.215.7验证数字解析除零异常12/(3-3)抛DivisionByZeroError验证异常分支非法字符12抛InvalidExpressionError验证输入校验用pytest写的话这类测试跑一遍只要几毫秒但它替你守住的是每一次代码重构后的正确性。我经常说算法题里你的“测试”是人与提交框之间的反复试错工程里你的测试是一堵连 CI 都不允许绕过的墙这堵墙就是开发编程给算法代码上的保险。3.3 算法题和工程代码的分界线在哪现在你很直观地看出分界线了算法题在逻辑正确处结束工程代码在系统可靠处开始。算法编程追求的是单个问题的时空最优解工程编程追求的是复杂环境中长期可持续的质量。这不是贬低算法恰恰相反工程化做的每一件事——封装、异常、测试、日志——都隐含了一个前提核心算法必须是对的。如果求值逻辑本身错了分层再漂亮也没用。我也见过一些团队把工程和算法割裂得很厉害算法工程师丢一个模型文件给开发开发接入系统时根本不知道里面怎么做推理的出了问题只能来回对接。结果是两边都觉得对方不行。其实如果两边各自都能往对方的领域多伸一脚这种撕扯会少很多。你能看上面这段工程化改造核心算法部分和我第 2 节写的没有任何差别差别全在“外围”。所以我的判断是算法是内核开发是外延内核决定你能飞多高外延决定你能落多稳。4. 如何选择轨道与制定深耕路径聊完差异回到最现实的问题我应该主攻哪条怎么避免选了之后又后悔我不会给你“两个都要、都很重要”这种正确的废话。资源有限的前提下普通人必须有主轨道。下面是我这几年带人总结出来的一套判断和落地方法。4.1 一个用了很久的选择测试法我建议你做两个对照实验。实验一连续两周每天晚上花一个半小时刷算法题不碰项目代码。两周后问自己——遇到一道看了答案才做出来的难题你是兴奋“这个解法妙啊”还是烦躁“这有什么实际用”。实验二连续两周每天只做一件事情给一个旧项目写单元测试、补文档、重构一个小模块。之后问自己——当发现一个隐藏了三年的低概率 Bug 时你是觉得有成就感还是觉得这活琐碎得不行。我用这个测试法帮过好几个纠结的朋友结果比任何性格测试都准。第一类人做实验一的时候会忘了时间做实验二的时候度日如年这类人适合算法赛道第二类人正好相反遇到 Bug 会兴奋地查日志、翻源码刷题却觉得枯燥这类人适合开发赛道。还有极少数人两边都享受那恭喜你你适合做技术专家或架构师因为这两类能力正是架构师需要的左右手。但哪怕类型三我也建议你在前三年先有一个明确主项另一个只做维持性投入否则容易出现“广度够了深度没建起来”的处境。为什么是这个测试而不是去看“哪个岗位薪资高”因为薪资是市场行情会波动但你的兴趣黏性决定了你能不能熬过漫长枯燥的练习期。算法没有一万小时成不了高手开发的领域里也有大量重复性工作兴趣才是你对抗倦怠的唯一燃料。4.2 双轨并行的学习配比与时间安排即使你决定了主攻算法或开发也不意味着完全放弃另一条线。我的建议是无论主攻哪条另一条都要保持 20% 左右的精力和时间。如果你主攻开发这 20% 用来刷经典算法题目标是看懂题解、能说出时间复杂度和常见最优解保证面试不卡壳如果你主攻算法这 20% 用来学工程基础目标是能写出结构清晰、有异常处理的代码能看懂接口文档保证落地不被开发同学吐槽。具体到时间块分配我用的是“两周一循环”第一周主轨道学 6 天副轨道学 1 天第二周主轨道学 5 天副轨道学 2 天把副轨道的时间集中在周末大块时间。为什么不做每天对半开因为人的专注力切换有代价今天刷题明天写项目一周下来两边都没进入深度状态。我试过相当失败的“每天各1小时”模式三个月后感觉自己像什么都没学会。深度工作比平均用力更有效这一点在技术学习上尤其明显。如果你主攻算法建议的路标是语言基础数组、字符串、指针/引用→ 数据结构逐个突破栈、队列、链表、树、图→ 按专题刷题排序、搜索、动规、贪心→ 参加竞赛或刷高频题巩固 → 进阶特定方向计算几何、字符串匹配、大数据算法。如果你主攻开发建议的路标是一门语言真正吃透 → 一个完整 Web 项目从 0 到 1 → 数据库、缓存、消息队列逐一入门 → 参与开源或团队项目熟悉协作 → 逐步接触高并发和架构设计。两条路线的前半段都很枯燥后劲才体现差别。4.3 常见认知误区与避坑指南误区一算法好的人代码一定写得好。完全两回事。算法讲的是“想清楚再写”工程讲的是“写清楚让所有人看懂”。我带过算法竞赛拿奖的实习生写的代码单行很长、函数动辄上百行、变量名全是a1b2编译能过但队友维护起来想哭。误区二开发做久了就不需要算法。后面做优化、查性能问题时没有算法底子你连问题出在哪都定位不了。误区三刷题越多越好。刷题不是目的理解数据结构和算法思想才是目的。同样一道题认真总结 5 种解法胜过稀里糊涂刷 50 道同类型。避坑指南里最重要的一条是不要用“反正以后做开发把算法扔掉”这种心态学习。你可以在职业选择上偏向开发但算法基本功是你的底线技能。很多公司连业务开发岗的晋升答辩都会考察系统设计里的时间和空间复杂度你不可能绕开它。反过来如果你主攻算法也别把工程能力当成“可以被替代的手艺”因为再好的算法都要跑在别人的工程系统里不懂工程的算法工程师很容易被当成只会写模型脚本的“调参侠”。5. 常见问题与排查技巧实录最后把这几年大家在表达式求值这道题上问得最多的坑以及我在算法和开发双线学习里踩过的雷整理成一张速查表。这道题太经典几乎每个学数据结构的人都会写一遍但能一遍跑通的确实不多。5.1 这题的高频翻车点速查表现象可能原因定位方法结果是 0 或初始栈值最后忘了把运算符栈清空检查表达式扫描结束后是否还有while op_stack循环9-3-2算出 8弹出条件用了而不是检查优先级比较符号括号内计算错乱左括号优先级设成 1 或 2 导致被弹出把左括号优先级改为 0多位数字被拆开只对单个字符做了数字判断用指针探测连续数字后统一入栈除零时程序崩溃没有检查除数在除法分支判 0抛业务异常输入包含空格空格被当成非法字符主循环中显式跳过空白字符调试栈相关代码时我强烈建议你在每个关键步骤后打印两个栈的内容。比如输入12*3扫描完乘号后你期望数字栈是[1, 2]、运算符栈是[, *]如果看到[3, 2]躺在栈里说明提前结算了。打印中间状态比盯着代码空想要快十倍。5.2 多年刷题和开发总结的几条独家心得第一把“看得懂题解”和“写得对代码”当成两件事。很多人在学习算法时陷入“眼高手低”看题解觉得很简单合上书自己写就各种 bug。我的解决办法是强迫自己坚持“提交才算完”不管多简单的题都要亲手在编辑器里跑通跑完之后复制一段空注释里写上“这题考了什么数据结构、什么技巧”一个月后回头翻每个知识点都能串起来。第二开发编程学习里最有价值的习惯是“读别人代码”。不要只读自己项目的代码去 GitHub 上找几个知名开源库每天精读一个文件的实现。读的时候问三个问题这个模块的接口为什么这么设计为什么用这几种数据结构异常处理考虑了哪些情况三个月后你自己的编码风格会明显成熟。算法可以用题量来堆工程能力很大程度上是用“阅读量”来堆的。第三关于双轨时间冲突我的最终建议是前期宁可慢也不要断。你可以把算法练习当成开发工作的背景音乐每天上班前刷一道题雷打不动。哪怕忙到只有 15 分钟打开题目、写出思路、第二天再补实现都比“今天太忙不刷了周六补”强。因为断档之后重新捡起来消耗的意志力远大于每天维持手感。我见过太多人立下“周末猛刷 5 小时”的 flag结果三周就放弃了反而每天 15 分钟的人慢慢攒了两百多道题的沉淀。说到底算法编程和开发编程不是要你在中间二选一的岔路口而是你技术成长路上的两条互相咬合的履带。栈求值这道题你用算法思维写出来是 40 行用工程思维武装起来可能是 150 行加 10 个测试用例但两段代码属于同一个人最理想。我个人这些年的体会是先找一条主路走深让另一条像影子一样跟着你等你在主路上站住了再回头把影子也练成明牌。到那个时候你就不会再问“该选哪个”因为你已经同时拥有了两种视角。