C++线性栈实现计算器:表达式求值原理与代码详解
2026/9/7 9:35:35 网站建设 项目流程

简介:面向数据结构初学者及C++编程爱好者,下载包内含一套基于线性栈模板实现的简易计算器完整源码与讲解材料,重点展示如何运用栈结构完成表达式读取、运算优先级处理与结果计算,支持加减乘除、乘方、开方、求余等常见运算,并允许连续输入多个表达式反复使用。资源包共4个文件,包含2个C/C++头文件、1个计算器源程序文件以及1份代码思路介绍PPT,整体大小约480KB,源码与演示文稿分开存放,便于按需查阅、课堂讲解或期末复习。目前已有1083人浏览下载,适合作为数据结构课程设计、课后实验或自学栈应用的参考项目。通过这份材料,读者既能获得可直接编译运行的计算器程序,也能借助PPT沿着代码思路梳理线性栈的模板封装、运算符比较与求值流程,理解栈在表达式求值中的核心作用;计算器用户界面直观,支持连续多次计算,可在此基础上继续扩展函数、括号等更丰富的功能,实践价值较高。 做C++课程设计或者刷数据结构题的时候,计算器大概是“线性栈”这个知识点最经典也最能用上的练习了。我这次用C++线性栈写了一个支持加减乘除和括号的简易计算器,能处理小数、负数,还能识别除零和括号不匹配这些错误。整个项目没用什么花哨的语法,就是把栈的入栈、出栈、取栈顶这些操作老老实实用在了表达式求值上,做完以后对栈的理解会比看书深刻得多。这篇文章就把完整的实现思路、代码和调试过程都贴出来,适合正在学数据结构的同学,也适合准备C++面试想找个经典例子梳理一遍的人。

1. 为什么计算器必须用栈

1.1 “栈”到底解决什么问题

先想一个问题:如果让你算1 + 2 * 3,你不会从左往右算成9,而是知道乘法优先级高,先算2 * 3 = 6,再加17。这个“先算后面,再算前面”的操作,本质上就是一种后进先出的规律,也就是栈的典型特性。

更明显的场景是括号,比如(1 + 2) * (3 + 4)。我们人工计算时会先找最内层的括号算完,再把结果往外一层一层带出来。这个过程把暂时算不了的中间结果和后缀运算符“压住”,等条件成熟再“弹出”,就是典型的栈行为。

用人话讲:栈能把“暂时还用不到、但一会儿必须用”的数据暂时寄存起来。表达式求值里,数字要寄存,运算符也要寄存,而且必须保证后寄存的先被处理,这正好和运算符优先级、括号嵌套的顺序完全吻合。

1.2 两种主流的求值思路

计算器实现一般有两种路线:

  • 中缀转后缀,再对后缀表达式求值:先把人容易读懂的1 + 2 * 3转成机器好处理的1 2 3 * +,然后遇到数字就压栈,遇到运算符就弹出两个数计算,结果再压栈。这个方案逻辑清晰,但需要写两个阶段的逻辑。
  • 双栈直接求值:同时开一个操作数栈和一个运算符栈,边扫描表达式边处理。优先级够就把运算符压栈,优先级不够就弹出栈顶运算符先算。这个方案逻辑更紧凑,代码也不难理解。

我这次选的是后者——双栈直接求值。原因是它的代码路径更短,调试时看两个栈的变化,计算过程一目了然。中缀转后缀那种方案对初学者来说更容易在某一步忘记处理括号,双栈方案里括号的处理比较统一,容错率更高。

1.3 难点清单

真正开始写之前,先盘一下会遇到哪些坑:

  1. 运算符优先级比较+-一级,*/二级,括号特殊处理。
  2. 多位数字与小数:不能cin >> num偷懒,要把连续的数字字符拼成一个完整的数。
  3. 括号匹配:遇到右括号要一直出栈到左括号,如果栈空了还没找到左括号,说明表达式不合法。
  4. 负数处理-5 + 3里的-是一元负号,不是减号,需要特殊判断。
  5. 异常输入:除零、连续两个运算符、括号不配对,这些都要在程序里明确报错,而不是让代码默默崩溃。

做工程和做练习的最大区别,就是把“正常情况跑通”变成“异常情况也得有反馈”。这也是这个项目最有价值的地方。

2. 线性栈的设计与实现

2.1 顺序栈还是链栈

题目里说的“线性栈”,其实就是指用线性结构来存储栈元素。这个线性结构可以是数组,也可以是链表,于是就有我们熟悉的顺序栈和链栈。

具体到计算器这个场景,用户在一条表达式里遇到的操作数和运算符数量是有限的,而且最大值也能估到——就是表达式字符串的长度。所以直接用顺序栈就够了,不用考虑运行时长运行时扩充的问题。顺序栈的三个基本操作:入栈把栈顶指针加一后放数据,出栈取数据后减一,取栈顶只读不改。

我在实现时没有直接用标准库的std::stack,而是自己写了一个模板类。为什么?因为这是练习线性栈的绝佳机会,手写一遍PushPopTopIsEmpty之后,你才能真正体会到“栈就是一种受限的线性表”这句话是什么意思。

2.2 手写模板Stack类

底层我用std::vector来存数据,因为它的动态扩容已经帮我们处理好了“栈满”的问题,代码更安全,也不影响我们理解栈的核心逻辑。

#include <iostream> #include <vector> #include <string> #include <cctype> #include <sstream> #include <iomanip> using namespace std; template <typename T> class Stack { private: vector<T> data; public: bool IsEmpty() const { return data.empty(); } void Push(const T &val) { data.push_back(val); } T Pop() { T top = data.back(); data.pop_back(); return top; } T Top() const { return data.back(); } };

如果你在考试或课程设计里被要求“手写数组栈”,那也不难,思路其实一模一样:用一个动态数组或固定数组,再加一个top游标变量,入栈前检查是否越界,出栈时返回并回退游标。核心逻辑和我上面这份代码是等价的,只是把vector换成了裸数组。

2.3 运算符优先级表的含义

双栈算法里最关键的就是优先级判断。我定义了一个函数来返回运算符的优先级数值:

int Precedence(char op) { if (op == '+' || op == '-') return 1; if (op == '*' || op == '/') return 2; return 0; // 左括号特殊处理 }

为什么要让括号返回0?因为在比较优先级的时候,左括号只有在遇到右括号时才需要被弹出来,平时它应该一直在栈底待着,不能随便参与计算。给它的优先级设为最低,这样任何运算符到来都不会把左括号挤出来,逻辑就封闭了。

这个优先级表看起来简单,但它正是整个算法的核心。栈顶运算符要不要拿出来算,全靠它决定。如果你以后要做带幂运算的计算器,还得额外处理右结合的问题——幂运算符^的优先级不仅高,而且是从右往左结合。这个扩展留给读者当作业。

3. 表达式求值的完整实现

3.1 从字符串到数字:解析token

数字的解析是整个程序最容易写错的地方。直接遍历字符串的每一个字符,如果遇到数字或小数点,就从当前位置连续往后取,拼成一个完整的数。

处理的时候有一个隐含规则:遇到数字前可能跟着一个负号。比如-5 + 3,这个负号并不是减号,而是数字的一部分。所以我先扫描一遍,判断当前位置是否是“一元负号”出现的位置,如果是,就给后面的数字加上负号。

一元负号的判断条件有两个:

  • 负号是表达式的第一个字符;
  • 负号的前一个字符是左括号或者除右括号外的其他运算符。
bool IsUnaryMinus(const string &expr, int i) { if (expr[i] != '-') return false; if (i == 0) return true; char prev = expr[i - 1]; return prev == '(' || prev == '+' || prev == '-' || prev == '*' || prev == '/'; }

这个函数可以说是我踩坑最多的地方。最开始我没判断prev == '('这种情况,导致(-3)被理解成“先算0减3再套括号”,结果虽然碰巧相等,但一旦写成(-3 + 5) * 2就会出错。所以遇到一元负号,千万别急着当作减号处理。

3.2 双栈计算流程

数字栈numStack和运算符栈opStack并行的主循环,逻辑如下:

bool ApplyOp(Stack<double> &num, char op, double &result) { if (num.IsEmpty()) return false; double b = num.Pop(); if (num.IsEmpty()) return false; double a = num.Pop(); switch (op) { case '+': result = a + b; break; case '-': result = a - b; break; case '*': result = a * b; break; case '/': if (b == 0.0) return false; result = a / b; break; default: return false; } num.Push(result); return true; }

主函数的处理分成四种情况:

  1. 数字:解析完整数字,压入数字栈。
  2. 左括号:直接压入运算符栈。
  3. 右括号:不断弹出运算符栈顶并计算,直到遇到左括号。如果运算符栈已经空了还没遇到左括号,说明输入不合法。
  4. 四则运算符:先处理一元负号;如果不是一元负号,就比较当前运算符与栈顶运算符的优先级。只要栈不空,栈顶不是左括号,并且当前运算符优先级不高于栈顶,就弹出栈顶计算。最后把当前运算符压栈。

整个表达式扫完后,把运算符栈里剩下的运算符一个一个弹出来计算。如果最后数字栈恰好只剩一个数,就把它作为计算结果输出;如果只剩多于一个数,说明表达式里有缺运算符问题。

3.3 整体代码骨架

把上面这些拼在一起,完整的主函数长这样:

double EvaluateExpression(const string &expr, bool &ok) { Stack<double> numStack; Stack<char> opStack; int i = 0; int len = (int)expr.size(); while (i < len) { if (isspace(expr[i])) { i++; continue; } if (isdigit(expr[i]) || expr[i] == '.') { int start = i; int dotCount = 0; while (i < len && (isdigit(expr[i]) || expr[i] == '.')) { if (expr[i] == '.') { dotCount++; if (dotCount > 1) { ok = false; return 0; } } i++; } string numStr = expr.substr(start, i - start); double val = stod(numStr); numStack.Push(val); continue; } if (IsUnaryMinus(expr, i)) { i++; int start = i; while (i < len && (isdigit(expr[i]) || expr[i] == '.')) i++; string numStr = expr.substr(start, i - start); if (numStr.empty()) { ok = false; return 0; } numStack.Push(-stod(numStr)); continue; } if (expr[i] == '(') { opStack.Push(expr[i]); i++; continue; } if (expr[i] == ')') { bool foundLeft = false; while (!opStack.IsEmpty()) { char op = opStack.Pop(); if (op == '(') { foundLeft = true; break; } double tmp; if (!ApplyOp(numStack, op, tmp)) { ok = false; return 0; } } if (!foundLeft) { ok = false; return 0; } i++; continue; } char curOp = expr[i]; while (!opStack.IsEmpty() && opStack.Top() != '(' && Precedence(curOp) <= Precedence(opStack.Top())) { char topOp = opStack.Pop(); double tmp; if (!ApplyOp(numStack, topOp, tmp)) { ok = false; return 0; } } opStack.Push(curOp); i++; } while (!opStack.IsEmpty()) { char op = opStack.Pop(); if (op == '(') { ok = false; return 0; } double tmp; if (!ApplyOp(numStack, op, tmp)) { ok = false; return 0; } } if (numStack.IsEmpty()) { ok = false; return 0; } double result = numStack.Pop(); if (!numStack.IsEmpty()) { ok = false; return 0; } ok = true; return result; }

注意看,我在做乘除法运算时返回了false并附带错误状态,而不是直接用抛异常或者exit结束程序,这样函数可以把错误原因一路传回main,由调用方决定怎么提示。这也是工程代码里比较常见的做法:库代码只负责返回状态,界面才负责展示文案。

4. 测试用例与调试实录

4.1 常规表达式跑一遍

我用下面几个用例验证程序正确性:

输入表达式期望结果实际输出
1+2*377
(1+2)*(3+4)2121
10-2*3+4/266
-5+12/(2+4)-3-3
(1.5+2.5)*288

注意观察第二个和第三个用例。(1+2)*(3+4)里有两个括号,程序在遇到右括号时会把括号内所有运算符处理完,再回到主循环继续。这个过程调试时可以打印两个栈的实时内容,非常直观。

我在调试10-2*3+4/2时打印过栈状态,遇到第一个*时,运算符栈是['-']*的优先级比-高,所以*直接压栈;等到遇到第一个+时,当前优先级是1,而栈顶*是2,于是先把*弹出来计算2*3,再把-+比较,又弹出-计算10-6。最终栈里干净的只有一个结果。

4.2 边界情况处理

边界情况是最容易翻车的部分,我重点测了这几类:

  • 除法除零:输入8/(4-4),程序在ApplyOp中检测到除数为0,返回错误,主函数输出“表达式不合法:除数为零”,而不是产生一个inf或者直接崩掉。
  • 括号不匹配:输入(1+2,扫描结束后运算符栈还剩一个(,代码在最后的清空阶段检测到这个情况,报错。
  • 连续数字误解析:输入1..2+3,解析第一段数字时发现小数点出现了两次,报错。
  • 字符串末尾是运算符:输入3+5*,扫描结束后数字栈有两个数而运算符栈还有一个*,最后判断numStack剩下的元素数量不是1,于是报错。

这些错误处理在书上的示例代码里经常被一笔带过,但实际写工程时,输入永远是不可控的。把错误处理写完整,程序的健壮性会好很多,这也是面试时“加分项”的体现。

4.3 常见问题速查表

现象原因解决办法
计算结果少了最后一步表达式扫描完成后没有把运算符栈清空主循环后加while (!opStack.IsEmpty())结算逻辑
(-3+5)*2结果不对一元负号被当成减号处理IsUnaryMinus判断,把负号合并进数字
8/(4-4)输出inf没有检查除数是否为零在除法分支判断除数是否为0.0
1.1+2.2输出3.3000000000000003double浮点精度问题输出时用std::fixed << std::setprecision(2)控制小数位
括号多了或少了不报错忘记处理括号不配对的情况遇到)时检查是否找到(;结束后检查栈内残留的(
5+-3这种写法不识别只处理了数字前的一元负号可以在解析完运算符后,再判断下一个是否为-+,并从当前位继续解析数字

我把cout的输出格式固定为保留两位小数,避免浮点数尾巴对使用者造成困扰。如果你需要在金融计算或精度要求更高的场合用,建议换成boost::multiprecision::cpp_dec_float或者直接用整数分转元的方式计算。

5. 避坑经验与扩展方向

做完这个项目后,我个人的体会是:数据结构课上讲的“栈的典型应用”,到真正落地实现时,坑都藏在字符串处理和边界判断里,而不是栈本身。栈的操作无非是PushPopTop,但什么时候压、什么时候弹,背后是对表达式语法的理解,这部分才是编程能力的体现。

几个我觉得值得记住的经验:

  1. 每一步都先想“栈空不空”。很多崩溃都发生在空栈上执行TopPop,我代码里几乎所有Pop之前都判断了栈是否是空。
  2. 调试时打印栈内容比用眼睛扫代码快得多。我总会临时写一个小工具函数,把两个栈的内容输出到控制台,观察某个表达式的处理过程,比靠猜高好几倍效率。
  3. 先写正常路径,再回头补异常路径。先把1+2*3这类简单用例跑通,再一一把负号、除零、括号不匹配的 case 补进去,这样思路不会乱。

这个项目后续还有很多可以玩的方向:比如加入sincoslog这类函数的调用,需要把函数名也作为一个占位符压入运算符栈;比如支持变量赋值,用x = 10这种语法以后,相当于把计算器扩展成一个微型脚本引擎;再比如把运算符扩展到幂运算^,这时优先级就不再只是简单的数字比大小,还得处理右结合的问题。顺着这些方向继续改,计算器就会从一个作业题慢慢变成一个有点实用价值的工具。

最后再说一个我自己写代码时的习惯:遇到问题不要立刻去搜完整答案,先用最笨的方式打印栈的信息。当你亲眼看着数字栈和运算符栈一步步变化时,那些算法书里的伪代码就和真实运行的程序呼应起来了。这个项目的意义,其实也就在这里。

本文还有配套的精品资源,点击获取

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

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

立即咨询