news 2026/10/10 9:40:42

上机40天:用栈实现带负号的四则运算表达式求值

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
上机40天:用栈实现带负号的四则运算表达式求值

今天打开编辑器的时候,时间是晚上九点四十。屏幕上还留着昨天没调完的测试用例,光标一闪一闪地停在那个报错的括号前面。我忽然意识到,这是连续第40天坐在电脑前做上机练习了。

第40天是个很微妙的时间节点。热情早就退了,肌肉记忆还没完全长成,每天到点手会自己摸向键盘,但心里偶尔也会飘过“今天要不歇一天”的念头。我一般会任由这个念头存在,然后继续敲今天的题目。因为我发现,上机练习这事,真正难的不是某一天的题目多难,而是你愿不愿意在第40天、第80天、第120天,打开一个空白文件,从头开始。

今天练的这道题很有意思,它把字符串处理、栈、运算符优先级和边界条件全揉在了一起。一道题能牵出这么多基础知识点,作为第40天的练习再合适不过了。

1. 今天的题目:带负号的四则运算表达式求值

1.1 题面到底要求什么

我是在一个在线判题系统上碰到这道题的。原题很长,但核心要求就一条:给定一个只包含数字、加减乘除、括号和空格的字符串表达式,实现一个计算器,求出结果。表达式里可以出现负数,整数除法向零取整,输入长度不超过100个字符。

题目给了几个示例:

  • "3+2*2"输出7
  • " 3/2 "输出1(除法向零取整)
  • " -3+5 "输出2
  • "2*(-3)"输出-6

我多看了几遍最后两个示例,心里基本有数了:这题真正想卡人的地方,不是四则运算本身,而是负号的识别。负号到底是减号,还是一元取负运算符,这是整道题最核心的分水岭。

1.2 为什么选这道题作为第40天的练习

说句实话,我刷题是不太喜欢跳着刷的。第30天的时候,我给自己定了个规矩:每周最后一天的练习,必须做一道“综合题”——它要能覆盖过去一周涉及的主要知识点,最好还有点小坑,这样第二天复盘的时候有东西可写。

这一周我一直在看字符串处理和栈相关的内容,堆了不少零碎知识:字符串遍历的边界控制怎么处理、栈什么时候压入什么时候弹出、运算符优先级怎么比较。这些知识单独拎出来我都能写出来一套,可真要拼在一起做一个完整的计算器,心里没底。

所以看到这题的时候我就知道,它是本周最好的收官题。它不像纯粹的数据结构题那样只考模板,也不像纯粹的工程题那样只考逻辑。它是一个“计算问题”,需要你在一个极其精简的框架里,同时处理好语法解析、运算规则、边界条件。这种综合度,学三天单项知识是补不出来的,必须动手写、写错、再改。

2. 核心原理:为什么先用“中缀转后缀”再求值

2.1 中缀表达式对人友好,对程序不友好

人类书写数学表达式,习惯用的是中缀形式:数字、运算符、数字,运算符夹在中间,比如3+2*2。程序处理它是很别扭的:你要先看下一个运算符的优先级,决定是先算这个还是先算后面那个,再加上括号改变优先级,麻烦得很。

举个例子:3+2*2,如果程序机械地从左往右读,读到3+2会先得出5,再乘2得到10——这在数学上是错的。所以处理中缀表达式时,程序必须“往后看”、判断优先级,或者使用“双栈”直接计算。

我这次用的方法是先转换成后缀表达式,也就是逆波兰表达式。后缀表达式的特点是:运算符永远跟在它作用的两个数字后面。3+2*2转成后缀是3 2 2 * +。程序求值的时候,从左往右走,遇到数字就压栈,遇到运算符就弹出两个数字做运算,结果再压回栈里。整个过程没有括号、没有优先级纠纷,简单得像流水线工作。

2.2 转换规则与优先级细节

中缀转后缀的经典算法,是借助一个运算符栈完成的。遍历输入字符串,遇到的不是数字就是运算符,分情况处理:

  • 如果是数字:直接输出(或者入数栈)。
  • 如果是运算符:
    • 如果运算符栈为空,或者当前运算符优先级高于栈顶运算符,则直接压栈;
    • 否则,就把栈顶运算符弹出并输出,然后再次比较当前运算符与新栈顶的优先级,直到能压栈为止。
  • 如果是左括号:直接压栈。
  • 如果是右括号:依次弹出栈顶运算符并输出,直到遇到左括号,再把左括号弹出丢弃。

优先级方面,加减为1级,乘除为2级。这里我处理的是四则运算,括号和负号是额外需要考虑的。

比较优先级时有一个容易忽略的细节:相同优先级下,从左往右结合。比如3-2-1,应该相当于(3-2)-1。所以当新运算符的优先级等于栈顶运算符优先级时,栈顶应该弹出。这个细节如果写成>而不是>=,3-2-1会被算成3-(2-1),结果从0变成2,测试用例都过不去。

2.3 核心代码:表达式求值的完整实现

我用的C++,实现分成两个部分:第一部分做中缀转后缀,第二部分对后缀求值。一开始我的版本只处理加减乘除,后来才补的负号,所以下面这份代码已经包含了负号处理的逻辑:

#include <iostream> #include <string> #include <stack> #include <cctype> using namespace std; // 判断字符是否为运算符 bool isOperator(char ch) { return ch == '+' || ch == '-' || ch == '*' || ch == '/'; } // 运算符优先级,负号优先级最高,处理为特殊字符 '~' int getPriority(char op) { if (op == '~') return 3; if (op == '*' || op == '/') return 2; if (op == '+' || op == '-') return 1; return 0; } // 中缀表达式转后缀表达式 string infixToPostfix(string s) { string result; stack<char> ops; bool prevIsOperator = true; // 标记上一个有效字符是否为运算符 for (int i = 0; i < s.length(); i++) { char ch = s[i]; if (ch == ' ') continue; if (isdigit(ch)) { result += ch; prevIsOperator = false; } else if (ch == '(') { ops.push(ch); prevIsOperator = true; } else if (ch == ')') { while (!ops.empty() && ops.top() != '(') { result += ops.top(); ops.pop(); } ops.pop(); // 弹出 '(' prevIsOperator = false; } else if (ch == '-') { // 判断是减号还是一元负号 if (prevIsOperator) { // 负号,用特殊字符 '~' 表示 ops.push('~'); } else { // 减号,正常按运算符处理 while (!ops.empty() && getPriority(ops.top()) >= getPriority(ch)) { result += ops.top(); ops.pop(); } ops.push(ch); } prevIsOperator = true; } else if (isOperator(ch)) { while (!ops.empty() && getPriority(ops.top()) >= getPriority(ch)) { result += ops.top(); ops.pop(); } ops.push(ch); prevIsOperator = true; } } while (!ops.empty()) { result += ops.top(); ops.pop(); } return result; } // 对后缀表达式求值 int evaluatePostfix(string postfix) { stack<int> nums; for (int i = 0; i < postfix.length(); i++) { char ch = postfix[i]; if (isdigit(ch)) { nums.push(ch - '0'); } else if (ch == '~') { int a = nums.top(); nums.pop(); nums.push(-a); } else { int b = nums.top(); nums.pop(); int a = nums.top(); nums.pop(); int result; if (ch == '+') result = a + b; else if (ch == '-') result = a - b; else if (ch == '*') result = a * b; else if (ch == '/') result = a / b; nums.push(result); } } return nums.top(); } int calculate(string s) { string postfix = infixToPostfix(s); return evaluatePostfix(postfix); } int main() { string expr = " -3 + 2 * (-3) "; cout << calculate(expr) << endl; return 0; }

这里我用了~这个特殊字符在内部表示一元负号,因为后面对后缀表达式求值时,它需要一个“单目运算”的标记,和双目加减乘除区分开。你也可以直接在数字入栈时做判断,把负号后的数字先取反再压栈,但那样处理多位负数的时候会比较绕,容易出错,所以我最后还是选择了内部标记的方式。

3. 卡了整整四十分钟的坑:负号被当成了减号

3.1 问题是怎么暴露的

写代码很快,调代码很慢。我第一次提交,前两个示例"3+2*2"和" 3/2 "都过了,第三个示例" -3+5 "直接输出一个莫名其妙的结果。我盯着那个错误结果看了好一会儿,第一反应是“是不是空格没处理好”,于是把字符串先整体去了一遍空格,再测,结果还是错的。

我在这道题上花了比练习时长多得多的调试时间。问题表面上是一个输出不对,实际上是转换阶段把-3里的负号当成了减法运算符,整个表达式的语义全变了。

3.2 排查链路:从逐步打印到问题定位

我当时的排查思路是这样的:先把中缀转后缀的结果打印出来。正常输入" -3 + 5 ",手动转换,后缀应该是3~ 5 +——负号先作用于3,再和5相加,结果是2。但我代码里打印出来的是3 5 -。两种顺序一说出来,问题就清楚了:3和5之间的负号,被当成了二元的减法,也就是说,-3在转换时被当成了“0减3”,变成二元运算了,后来+5又被正确处理,最后算成了3-5的转置版。

这个问题的根子还在于负号的语义判断。对计算机而言,负号有双重身份:出现在数字前面,它是一元取负;出现在两个表达式之间,它是二元减法。我的代码里需要一个“上一个有效字符”的状态,来判断这个负号到底该按哪个身份处理。

3.3 三种需要识别负号的典型场景

调试过程中,我把负号的所有出现场景都列了出来,做成了一张表:

场景表达式示例负号性质原因
表达式开头-3 + 2一元负号负号前面没有任何东西
括号后面(-3) + 2一元负号左括号不能作为减法的左操作数
运算符后面2 * -3一元负号乘号后面不能直接跟减法
数字或右括号后面2 - 3、2 - (3)二元减法前面有完整操作数

判断逻辑的核心就是:如果负号前面的有效字符是数字或右括号,那它是减法;如果前面的有效字符是运算符或左括号,或它本身就是第一个有效字符,那它是取负。

我代码里prevIsOperator这个布尔变量就是干这个的。一开始我只把它初始化为true(表示表达式开头),但忘了在处理右括号后更新状态,导致括号后面跟负号的时候判断错。修完这一处再测试,"2*(-3)"才终于输出了-6。

4. 这40天上机练习,我的方法与收获

4.1 从第1天到第40天的节奏演进

前10天,我的练习方式一塌糊涂。每天打开判题系统,挑一个看起来会做的简单题,写完了事。那道题我可能根本没吃透,第二天遇到同样类型的题目照样卡壳。效率很低,但至少养成了“每天打开编辑器”的习惯。

第11天到第25天,我开始改变策略,不再追逐题目的数量,而是改成专题训练:数组类做两天,字符串类做两天,链表类做两天。每个专题结束的那天,我会把这类题的常见套路写进自己的练习笔记。比如字符串题的核心往往集中在:边界索引、字符判断、特殊状态标记。我今天的负号处理,实际就用到了其中两条。

第26天到第40天,我加入了一个环节:固定晚上打练习日志。日志格式很简单,五列数据:

天数练习内容用时主要错误一句话心得
第1天两数之和约90分钟下标越界先想清楚再写,别急着碰键盘
第10天反转字符串约50分钟双指针循环条件写错边界条件用最小输入先跑一遍
第20天括号匹配约60分钟栈栈顶判定弄反括号问题九成是栈的匹配时机问题
第30天字符串解码约70分钟数字累积逻辑漏了位数遇到多位数字要整体读取再转换
第40天表达式求值约80分钟一元负号与二元减号混淆运算符的双重身份要靠上下文状态区分

每天写日志这件事,看起来简单,但它帮我强制建立了一种“练习闭环”:练习、记录、复盘。很多当时没想明白的问题,过两天回头看笔记,一下就通了。

4.2 第40天之后,我给自己的三条硬规矩

通过这40天的练习,我总结出三条对自己特别有用的硬规矩,写在这里,也算给自己下个留档:

第一,每天只做一件事,但这件事必须做完。不要一天开三个题目,每个题目写了一半就换。开题之前先评估:这题的规模大概需要多久?超过今天练习时长就拆成两天做;能在今天结束前提交一个能跑的版本,就把它磨完。

第二,调试时先打印中间状态,不要凭眼睛猜。我今天就是靠把postfix打印出来才定位到负号问题的。C++ 里一条cout语句比盯着代码发呆十分钟管用得多。

第三,遇到新的边界条件,把它加入测试用例。不管题目本身有没有要求,我会额外准备几组用例:负号开头、括号内负号、两个运算符连着、连续括号、除法向零取整。这些用例能复用的价值极高,后面遇到类似题目,直接套用就行。

5. 空闲时间里,我还认真想了“上机练习到底在练什么”

这个问题听起来有点虚,但第40天这个节点,我确实认真想了。很多人上机练习刷题,练的是“把题刷完”,提交通过就完事。但坚持到第40天,我觉得上机练习真正练的是三样东西:

第一,把模糊想法转成精确操作的能力。一个数学上“显然成立”的想法,落到代码里要处理空格、要处理数字越界、要处理运算符栈空不空。这个过程没有任何人能替你完成,只有一次次上机、一次次报错、一次次回头改,大脑才会习惯这种精确性。

第二,把大问题拆成小部分的习惯。表达式求值看起来是一个整体,但我实际写的时候先拆成了“转后缀”和“求值”两大块,每一块再拆成“如何处理数字”“如何处理运算符”“如何处理括号”。这个拆解能力,不只是做算法题有用,写任何稍微大一点的程序都受益。

第三,面对乏味重复任务的耐性。第40天的题目不算难,但我坐下来一遍遍跑测试用例的时候,还是会觉得枯燥。真正拦在“会了”和“熟练了”之间的,就是这份耐性。上机练习第40天,我最大的收获其实不是今天把这道题调通了,而是发现自己对“枯燥”的容忍度,比40天前高了一大截。

回看这40天,我在编辑器里敲下的代码行数大概不到6000行,不算多。但最让我欣慰的是打卡表上那串连续的日期——中间没有断过一天。哪怕是只写二十分钟、只解决一道小题的那几天,也算数。第40天结束前,我把今天的完整题解放进了练习日志。明天,该进入第41天了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/10 9:40:15

时间复杂度和空间复杂度实战指南:从大O记号到优化决策

我刚开始学数据结构那阵子&#xff0c;第一道把我卡死的题目不是链表反转&#xff0c;也不是二叉树遍历&#xff0c;而是一道看起来“平平无奇”的数组求和&#xff1a;给一个长度为 n 的数组&#xff0c;输出所有连续子数组的和。我用了三层 for 循环&#xff0c;自己测试 n10…

作者头像 李华
网站建设 2026/10/10 9:39:41

Hadoop MapReduce实现KNN鸢尾花分类:三种距离度量与调优指南

简介&#xff1a;这份资源面向计算机、人工智能、大数据等专业的学生与开发者&#xff0c;提供KNN分类算法在Hadoop平台上的MapReduce实现方案&#xff0c;解决传统单机KNN难以处理大规模数据的问题。项目以经典鸢尾花数据集为实验对象&#xff0c;通过花萼长度、宽度与花瓣长度…

作者头像 李华
网站建设 2026/10/10 9:38:51

Qwen-Image-2.1 云端部署实战:A10+Triton+vLLM高并发推理方案

1. 项目概述&#xff1a;为什么现在必须认真对待 Qwen-Image-2.1 的云端部署最近两周&#xff0c;我连续接到五位不同背景的朋友咨询&#xff1a;一位做电商视觉设计的自由职业者想自动批量生成商品主图&#xff0c;一位高校实验室的研究生需要处理大量显微图像标注&#xff0c…

作者头像 李华
网站建设 2026/10/10 9:34:16

列车进站模型验证器:用栈和队列判断出站序列是否可行

简介&#xff1a;一份关于列车进站调度问题的数据结构实验资源&#xff0c;面向学习栈和队列的本科生或编程初学者。该问题模拟丁字形铁路调度系统&#xff0c;要求编程实现车厢以编号1到n的顺序出站&#xff0c;是理解栈和队列典型应用场景的良好案例。资源包共含9个文件&…

作者头像 李华
网站建设 2026/10/10 9:33:10

SpringBoot+Vue+MyBatis+MySQL企业级图书大厦管理系统全栈实战

做图书管理系统的源码很多&#xff0c;但大部分都是“能跑通的demo”&#xff1a;后端打个CRUD接口&#xff0c;前端画几个表格&#xff0c;录一本加一本&#xff0c;顶多再加个模糊搜索&#xff0c;然后就在简历上写“完成图书管理系统开发”。但真要放到图书大厦这种场景里&a…

作者头像 李华
网站建设 2026/10/10 9:33:03

MATLAB SVM柴油机故障识别:从特征提取到参数寻优的完整流程

简介&#xff1a;这份资源面向机器学习入门者、故障诊断方向工程师及自动化专业学生&#xff0c;提供一套基于MATLAB的支持向量机柴油机故障识别完整实现方案&#xff0c;帮助读者理解SVM分类原理并落地到工业设备健康管理场景。压缩包共2个文件&#xff0c;包含1个xlsx数据表与…

作者头像 李华