C++实现逆波兰表达式计算器:栈与调度场算法详解

发布时间:2026/7/14 4:58:26
C++实现逆波兰表达式计算器:栈与调度场算法详解 1. 项目概述从“波兰表达式”到可运行的C计算器如果你在C的学习路上已经啃完了变量、循环和函数正琢磨着怎么把知识串起来做个有点意思的东西那么“波兰表达式”这个项目绝对是个宝藏。我第一次接触这个概念是在数据结构课上当时觉得这玩意儿理论性太强直到后来自己动手用C实现了一个基于波兰表达式的计算器才真正体会到它的精妙——它不仅是栈Stack这一数据结构的绝佳应用案例更是理解编译器如何解析复杂算术表达式的钥匙。简单来说波兰表达式更常见的叫法是“逆波兰表达式”或“后缀表达式”是一种不需要括号就能明确表示运算顺序的表达式书写方法。我们熟悉的(1 2) * 3这种写法叫“中缀表达式”而它的逆波兰表达式则是1 2 3 *。计算机处理这种表达式非常高效因为它完全消除了对运算符优先级和括号的依赖只需要一个栈从左到右扫描遇到数字就入栈遇到运算符就从栈顶弹出相应数量的操作数进行计算再把结果压回栈中扫描结束栈里剩下的那个数就是最终结果。这个项目的核心价值在于它完美融合了C的核心语法、标准库容器特别是std::stack的使用、字符串处理、以及基础的算法设计思想。通过实现它你能深刻理解“栈”的后进先出特性掌握中缀表达式到后缀表达式的转换算法调度场算法并亲手打造一个能处理加减乘除甚至更复杂运算的计算引擎。这远比写一个简单的控制台计算器有成就感也是面试中考察基本功和思维能力的经典题目。接下来我将带你从零开始拆解每一个步骤分享我踩过的坑和总结的技巧最终实现一个健壮、可扩展的C波兰表达式求值程序。2. 核心思路与方案设计为什么是栈和调度场算法在动手写代码之前我们必须把整个计算过程的逻辑理清楚。我们的目标是输入一个中缀表达式字符串如3 4 * (2 - 1)程序能正确计算出结果7。这个过程需要分两步走中缀转后缀然后后缀表达式求值。为什么不能直接对中缀表达式求值因为中缀表达式有运算符优先级和括号直接解析复杂度很高而后缀表达式消除了这些歧义求值过程变得极其规整。2.1 后缀表达式求值栈的教科书级应用我们先看简单的后半部分后缀表达式求值。算法清晰得就像一份食谱初始化一个空栈用于存放操作数。从左到右扫描后缀表达式的每个元素token。如果当前元素是数字则将其转换为数值后压入栈中。如果当前元素是运算符如,-,*,/则 a. 从栈顶弹出右操作数。 b. 再从栈顶弹出左操作数。 c. 根据运算符进行计算。 d. 将计算结果压回栈中。重复步骤2-4直到表达式扫描完毕。此时栈中应只剩下一个元素即为最终计算结果。这个过程的确定性非常强几乎就是为栈这种数据结构量身定做的。在C中我们直接使用标准库的std::stackdouble就能轻松实现。这里有一个关键细节弹栈的顺序。先弹出的是右操作数后弹出的是左操作数这对于减法和除法这种不满足交换律的运算至关重要。a - b在后缀表达式a b -中求值时先弹出b再弹出a计算a - b顺序不能错。2.2 中缀转后缀调度场算法的精妙难点和精华都在前半部分如何将人类习惯的中缀表达式转换成机器友好的后缀表达式这里我们采用经典的调度场算法。这个算法由艾兹赫尔·戴克斯特拉提出同样使用一个栈来临时存放运算符其核心思想是通过栈来调整运算符的输出顺序以反映其优先级和结合性。算法的具体规则如下初始化两个结构一个用于输出后缀表达式的队列或列表一个用于暂存运算符的栈。从左到右扫描中缀表达式。遇到操作数数字直接加入输出队列。遇到运算符记为op1 a. 只要栈非空且栈顶运算符记为op2的优先级高于或等于op1并且op2不是左括号(就将op2弹出栈并加入输出队列。 b. 将op1压入运算符栈。遇到左括号(直接压入运算符栈。遇到右括号)则不断将栈顶运算符弹出并加入输出队列直到遇到左括号(为止然后将这对括号丢弃左括号弹出但不输出。当表达式扫描完毕后将运算符栈中剩余的所有运算符依次弹出并加入输出队列。注意优先级规则是算法的核心。通常设定*和/的优先级高于和-。对于优先级相同的运算符如和-规则4a中的“等于”保证了左结合性即a - b - c会被处理为(a - b) - c。这个算法巧妙地利用栈“后进先出”的特性延迟了低优先级运算符的输出保证了高优先级运算符先被计算。括号则作为一个强制重置的边界左括号入栈时像一个“高墙”其内的运算符自成体系直到遇到右括号这面墙被推倒。2.3 方案选型与C实现要点基于以上分析我们的C实现方案就明确了输入处理我们将表达式作为字符串std::string输入。需要编写一个分词器tokenizer能正确区分数字、运算符和括号。对于多位数和小数点分词是第一个小挑战。数据结构使用std::stackstd::string或std::stackchar用于调度场算法中的运算符栈。我更倾向于使用std::string来存储token便于统一处理多位数字符串。使用std::vectorstd::string或std::queuestd::string来存储输出的后缀表达式序列。使用std::stackdouble用于后缀表达式求值。核心函数std::vectorstd::string infixToPostfix(const std::string infix)实现调度场算法返回后缀表达式token序列。double evaluatePostfix(const std::vectorstd::string postfix)实现后缀表达式求值。辅助函数int getPriority(const std::string op)用于定义运算符优先级bool isOperator(const std::string s)用于判断token类型。错误处理需要考虑表达式不合法的情况如括号不匹配、运算符缺少操作数、除零错误等。这能极大提升程序的健壮性。这个设计清晰地将问题分解为两个独立的、可测试的模块符合软件工程的高内聚低耦合原则。接下来我们就进入具体的实现环节。3. 核心实现细节与C编码实战理论清晰后我们开始动手编码。我会先给出一个基础但完整的实现然后逐步加入更健壮的特性。我们假设输入的表达式中数字和运算符之间可能有空格也可能没有这要求我们的分词器要足够灵活。3.1 分词器从字符串到Token序列这是所有处理的第一步。一个健壮的分词器能省去后面无数麻烦。#include iostream #include string #include vector #include cctype // 用于 isdigit 函数 #include sstream std::vectorstd::string tokenize(const std::string expression) { std::vectorstd::string tokens; std::string currentToken; // 标记是否正在读取一个数字用于处理多位数 bool readingNumber false; for (size_t i 0; i expression.length(); i) { char ch expression[i]; // 处理数字包括小数点 if (std::isdigit(ch) || ch .) { currentToken ch; readingNumber true; } else { // 如果之前正在读数字现在遇到了非数字字符先把数字token保存 if (readingNumber) { tokens.push_back(currentToken); currentToken.clear(); readingNumber false; } // 处理运算符和括号忽略空格 if (ch || ch - || ch * || ch / || ch ( || ch )) { // 注意这里将运算符转换为字符串存储 tokens.push_back(std::string(1, ch)); } // 其他字符如空格直接跳过 // 如果需要更严格的检查可以在这里添加对非法字符的报错 } } // 处理表达式末尾可能剩下的数字token if (readingNumber) { tokens.push_back(currentToken); } return tokens; }这个分词器逻辑简单但已经能处理3.142*(1-5)这样的表达式将其分解为[3.14, , 2, *, (, 1, -, 5, )]。但它有一个潜在问题无法正确区分负号和减号。在表达式-3 4或3 * (-4)中开头的-和括号后的-是负号一元运算符而不是减号二元运算符。处理这个问题需要更复杂的上下文判断我们可以在后续的调度场算法中做特殊处理或者改进分词逻辑。为了简化我们先支持正数间的二元运算。3.2 调度场算法实现中缀转后缀有了token序列我们就可以实现调度场算法了。我们需要定义运算符的优先级。#include stack #include unordered_map // 获取运算符优先级 int getPriority(const std::string op) { static const std::unordered_mapstd::string, int priorityMap { {, 1}, {-, 1}, {*, 2}, {/, 2} // 可以扩展{^, 3} 等 }; auto it priorityMap.find(op); return (it ! priorityMap.end()) ? it-second : 0; } bool isOperator(const std::string s) { return s || s - || s * || s /; } std::vectorstd::string infixToPostfix(const std::vectorstd::string infixTokens) { std::vectorstd::string postfix; std::stackstd::string opStack; // 运算符栈 for (const auto token : infixTokens) { // 情况1数字直接输出 // 这里我们简单判断如果第一个字符是数字或小数点就认为是数字 // 更严谨的做法是使用 std::istringstream 尝试转换 if (std::isdigit(token[0]) || (token[0] . token.size() 1)) { postfix.push_back(token); } // 情况2左括号直接入栈 else if (token () { opStack.push(token); } // 情况3右括号弹出直到左括号 else if (token )) { while (!opStack.empty() opStack.top() ! () { postfix.push_back(opStack.top()); opStack.pop(); } if (!opStack.empty() opStack.top() () { opStack.pop(); // 弹出左括号丢弃 } else { // 栈为空或栈顶不是左括号说明括号不匹配 throw std::runtime_error(Mismatched parentheses!); } } // 情况4运算符 else if (isOperator(token)) { // 关键循环当栈顶运算符优先级 当前运算符且栈顶不是左括号时弹出并输出 while (!opStack.empty() opStack.top() ! ( getPriority(opStack.top()) getPriority(token)) { postfix.push_back(opStack.top()); opStack.pop(); } // 当前运算符入栈 opStack.push(token); } // 情况5其他非法token根据需求处理或报错 } // 情况6扫描完毕弹出栈中剩余所有运算符 while (!opStack.empty()) { if (opStack.top() () { throw std::runtime_error(Mismatched parentheses!); } postfix.push_back(opStack.top()); opStack.pop(); } return postfix; }实操心得在实现getPriority时使用std::unordered_map比一连串的if-else更清晰也更容易扩展新的运算符。另外注意在遇到右括号时必须检查栈顶是否为左括号这是检测括号是否匹配的关键点之一。最后的清栈操作也要检查是否有残留的左括号这是检测括号匹配的另一个关键点。3.3 后缀表达式求值实现转换得到后缀表达式后求值就相对直接了。#include stdexcept #include cmath // 如果后续需要扩展 pow, sin 等函数 double evaluatePostfix(const std::vectorstd::string postfixTokens) { std::stackdouble valueStack; for (const auto token : postfixTokens) { // 如果是数字转换为double并入栈 if (std::isdigit(token[0]) || (token[0] . token.size() 1)) { // 使用 std::stod 进行字符串到double的转换它比 std::atof 更安全会抛出异常 try { double num std::stod(token); valueStack.push(num); } catch (const std::invalid_argument e) { throw std::runtime_error(Invalid number token: token); } catch (const std::out_of_range e) { throw std::runtime_error(Number out of range: token); } } // 如果是运算符弹出操作数进行计算 else if (isOperator(token)) { if (valueStack.size() 2) { throw std::runtime_error(Insufficient operands for operator: token); } double right valueStack.top(); valueStack.pop(); double left valueStack.top(); valueStack.pop(); double result 0.0; switch (token[0]) { case : result left right; break; case -: result left - right; break; case *: result left * right; break; case /: if (std::fabs(right) 1e-12) { // 避免除零错误 throw std::runtime_error(Division by zero!); } result left / right; break; default: throw std::runtime_error(Unsupported operator: token); } valueStack.push(result); } // 其他token理论上不应该出现因为来自转换函数 } // 最终栈中应该只有一个值即结果 if (valueStack.size() ! 1) { throw std::runtime_error(Invalid expression: too many operands or operators.); } return valueStack.top(); }注意事项这里有几个工程上的好习惯。第一使用std::stod而非atof因为前者在转换失败时会抛出异常便于我们做错误处理。第二在进行除法运算前检查除数是否为零或接近零这是一个必须的防御性编程步骤。第三在弹出操作数前检查栈内元素是否足够可以捕获像1 2 这样的非法后缀表达式。第四最终检查栈内是否恰好剩下一个值这是验证表达式合法性的最后一道关卡。3.4 主函数与测试将以上模块组合起来一个基础版的波兰表达式计算器就完成了。int main() { std::string inputExpr; std::cout Enter an infix expression (e.g., 3 4 * (2 - 1)): ; std::getline(std::cin, inputExpr); try { // 1. 分词 std::vectorstd::string infixTokens tokenize(inputExpr); std::cout Infix Tokens: ; for (const auto t : infixTokens) std::cout t ; std::cout std::endl; // 2. 中缀转后缀 std::vectorstd::string postfixTokens infixToPostfix(infixTokens); std::cout Postfix (RPN) Tokens: ; for (const auto t : postfixTokens) std::cout t ; std::cout std::endl; // 3. 后缀表达式求值 double result evaluatePostfix(postfixTokens); std::cout Result: result std::endl; } catch (const std::exception e) { std::cerr Error: e.what() std::endl; return 1; } return 0; }你可以用34*2/(1-5)这样的表达式测试观察它的分词、转换和计算过程。正确的后缀表达式应该是3 4 2 * 1 5 - / 结果应为1。4. 进阶优化与功能扩展基础版本跑通后我们可以让它变得更强大、更健壮。以下是几个常见的优化方向。4.1 处理负号与一元运算符这是基础版本最大的短板。在表达式-34或3*(-4)中-是负号。我们需要在分词或转换阶段识别它。一个常见的策略是在分词时如果一个-前面是另一个运算符、左括号(或者它就是表达式的第一个字符那么它就是一个一元负号。我们可以用一个特殊的符号如#来标记一元负号并在优先级和求值逻辑中单独处理。修改分词器std::vectorstd::string tokenizeAdvanced(const std::string expr) { std::vectorstd::string tokens; // ... 类似之前的循环 ... for (size_t i 0; i expr.length(); i) { char ch expr[i]; if (std::isdigit(ch) || ch .) { // ... 读取数字 ... } else if (ch || ch - || ch * || ch / || ch ( || ch )) { // 判断减号是否为负号 if (ch -) { bool isUnary false; // 情况1是表达式第一个字符 if (i 0) isUnary true; // 情况2前一个字符是运算符或左括号 else if (i 0) { char prev expr[i-1]; if (prev || prev - || prev * || prev / || prev () { isUnary true; } } if (isUnary) { tokens.push_back(#); // 用 # 代表一元负号 continue; } } // 如果是普通二元运算符或括号正常处理 tokens.push_back(std::string(1, ch)); } } // ... return tokens; }修改调度场算法和求值逻辑在isOperator和getPriority中需要加入对#的判断。一元负号的优先级通常设定为高于乘除。在求值函数中遇到#时只需从栈中弹出一个操作数进行取负操作-operand再将结果压栈。4.2 添加更多运算符和函数扩展性是这个设计的一大优点。要添加新的二元运算符如求幂^只需在isOperator函数中添加判断。在getPriority的映射表中为其赋予合适的优先级通常求幂优先级最高且是右结合这需要在算法中微调规则4a。在evaluatePostfix的switch语句中添加对应的计算逻辑例如使用std::pow(left, right)。要添加函数如sin,cos,log需要在分词器中识别这些多字母的token。在调度场算法中函数名通常直接加入输出队列或者像左括号一样特殊处理取决于算法变体。更常见的做法是函数具有最高优先级并且当它出现在栈顶时遇到左括号才触发其参数的计算和弹出。这需要更复杂的逻辑但核心思想不变。在求值函数中实现对应的数学函数调用。4.3 提升健壮性更完善的错误处理一个工业级的计算器需要能优雅地处理各种错误输入。括号不匹配我们已经在转换函数中做了基本检查。非法字符在分词阶段可以检查每个字符如果不是数字、小数点、运算符、括号或允许的函数名字符则抛出异常。数字格式错误使用std::stod的异常机制可以捕获像12.34.56这样的非法数字。表达式为空或只有运算符在求值前后检查token序列和栈的状态。自定义异常类可以定义不同的异常类型如ParseError,EvalError让错误信息更清晰。4.4 性能与内存考量对于教学和大多数应用场景当前的实现性能足够。如果追求极致可以考虑避免字符串拷贝使用std::string_viewC17来传递token减少不必要的字符串构造。预分配内存对于vector和stack如果可以预估最大token数量可以使用reserve提前分配内存减少动态扩容的开销。表达式求值一次完成实际上调度场算法和后缀求值可以合并成一个步骤使用两个栈一个存操作数一个存运算符同时进行这被称为“调车场算法的求值变体”可以减少中间后缀表达式的存储开销。但对于理解和教学分两步更清晰。5. 常见问题与调试技巧实录在实现和调试过程中我遇到过不少坑。这里总结几个典型问题及其解决方法。5.1 问题一计算结果完全不对排查步骤打印分词结果首先确认你的分词器是否正确。输入12*3输出应该是[1, , 2, *, 3]。如果分词就错了后面全错。常见错误是没处理好空格或者小数点的识别有问题。打印后缀表达式在infixToPostfix函数结束后立即打印输出的后缀表达式序列。对于12*3正确的后缀表达式是[1, 2, 3, *, ]。如果这里错了问题出在调度场算法。检查优先级映射表是否正确*/的优先级必须大于-。检查规则4a的循环条件while (!opStack.empty() opStack.top() ! ( getPriority(opStack.top()) getPriority(token))。这里的保证了同优先级运算符的左结合性。如果是右结合运算符如^条件应为。单步调试求值过程如果后缀表达式正确但结果不对在evaluatePostfix函数中每进行一次运算就打印出栈的状态。这能帮你发现是操作数弹出顺序错了还是运算逻辑错了。5.2 问题二遇到括号就崩溃或结果错误可能原因括号不匹配检测缺失确保在遇到右括号)时如果栈空或栈顶不是(一定要报错。在清空运算符栈时如果发现(也要报错。运算符栈中残留左括号在算法最后清空栈的循环里我加入了检查左括号的代码这就是为了捕获(12这种缺少右括号的情况。优先级与括号的交互左括号(在栈内时其优先级应被视为最低或者在比较时遇到左括号就停止弹出否则会影响括号内运算符的正常弹出。我们的代码中while循环条件包含了opStack.top() ! (这确保了左括号像一个屏障其内的运算符不会被括号外的低优先级运算符提前弹出。5.3 问题三处理带空格和负号的表达式这是两个最常见的输入格式问题。空格我们的基础分词器通过if条件跳过了非运算符/数字/括号的字符因此天然支持空格。但要注意如果表达式中间有多个空格或制表符我们的逻辑也能正常工作。负号如前所述这是需要特殊处理的。一个简单的测试用例-12就能击垮基础版本。实现4.1节中的负号识别逻辑是解决这个问题的标准方法。一个更取巧但不推荐的方法是要求用户在所有一元负号前加一个0将-12写成0-12但这改变了用户的输入习惯。5.4 调试技巧可视化工具对于复杂表达式在脑子里推演转换过程很累。我常用的方法是画图。准备三列输入Token、操作栈Stack、输出队列Output。模拟调度场算法一步一步填写表格。对比程序输出的后缀表达式和你手动推导的是否一致。例如对于3 4 * 2 / ( 1 - 5 )输入 Token操作栈 (底-顶)输出队列33343 4* *3 42 *3 4 2/ /3 4 2 *( / (3 4 2 *1 / (3 4 2 * 1- / ( -3 4 2 * 15 / ( -3 4 2 * 1 5) /3 4 2 * 1 5 -结束3 4 2 * 1 5 - / 最终输出队列即为后缀表达式。通过这种手动画表的方式能非常直观地验证你的算法逻辑也是面试时向面试官展示思路的好方法。实现一个波兰表达式计算器就像搭积木把栈、字符串处理、算法逻辑这些C基础知识块严丝合缝地拼接在一起。当你看到自己写的程序能正确解析并计算出一个复杂表达式时那种对底层逻辑豁然开朗的感觉是单纯看书无法比拟的。这个项目代码量不大但“麻雀虽小五脏俱全”它为你打开了通往编译器前端、表达式解析和更复杂算法世界的一扇门。你可以尝试在此基础上增加变量赋值、自定义函数、甚至一个简单的语法树把它变成一个真正可用的脚本引擎雏形。