C++实现逆波兰表达式计算器:栈与调度场算法详解
2026/7/14 4:58:21 网站建设 项目流程

1. 项目概述:从“波兰表达式”到可运行的C++计算器

如果你在C++的学习路上,已经啃完了变量、循环和函数,正琢磨着怎么把知识串起来做个有点意思的东西,那么“波兰表达式”这个项目绝对是个宝藏。我第一次接触这个概念是在数据结构课上,当时觉得这玩意儿理论性太强,直到后来自己动手用C++实现了一个基于波兰表达式的计算器,才真正体会到它的精妙——它不仅是栈(Stack)这一数据结构的绝佳应用案例,更是理解编译器如何解析复杂算术表达式的钥匙。

简单来说,波兰表达式,更常见的叫法是“逆波兰表达式”或“后缀表达式”,是一种不需要括号就能明确表示运算顺序的表达式书写方法。我们熟悉的(1 + 2) * 3这种写法叫“中缀表达式”,而它的逆波兰表达式则是1 2 + 3 *。计算机处理这种表达式非常高效,因为它完全消除了对运算符优先级和括号的依赖,只需要一个栈,从左到右扫描,遇到数字就入栈,遇到运算符就从栈顶弹出相应数量的操作数进行计算,再把结果压回栈中,扫描结束,栈里剩下的那个数就是最终结果。

这个项目的核心价值在于,它完美融合了C++的核心语法、标准库容器(特别是std::stack)的使用、字符串处理、以及基础的算法设计思想。通过实现它,你能深刻理解“栈”的后进先出特性,掌握中缀表达式到后缀表达式的转换算法(调度场算法),并亲手打造一个能处理加减乘除甚至更复杂运算的计算引擎。这远比写一个简单的控制台计算器有成就感,也是面试中考察基本功和思维能力的经典题目。接下来,我将带你从零开始,拆解每一个步骤,分享我踩过的坑和总结的技巧,最终实现一个健壮、可扩展的C++波兰表达式求值程序。

2. 核心思路与方案设计:为什么是栈和调度场算法?

在动手写代码之前,我们必须把整个计算过程的逻辑理清楚。我们的目标是:输入一个中缀表达式字符串(如"3 + 4 * (2 - 1)"),程序能正确计算出结果7。这个过程需要分两步走:中缀转后缀,然后后缀表达式求值。为什么不能直接对中缀表达式求值?因为中缀表达式有运算符优先级和括号,直接解析复杂度很高,而后缀表达式消除了这些歧义,求值过程变得极其规整。

2.1 后缀表达式求值:栈的教科书级应用

我们先看简单的后半部分:后缀表达式求值。算法清晰得就像一份食谱:

  1. 初始化一个空栈,用于存放操作数。
  2. 从左到右扫描后缀表达式的每个元素(token)。
  3. 如果当前元素是数字,则将其转换为数值后压入栈中。
  4. 如果当前元素是运算符(如+,-,*,/),则: a. 从栈顶弹出右操作数。 b. 再从栈顶弹出左操作数。 c. 根据运算符进行计算。 d. 将计算结果压回栈中。
  5. 重复步骤2-4,直到表达式扫描完毕。
  6. 此时栈中应只剩下一个元素,即为最终计算结果。

这个过程的确定性非常强,几乎就是为栈这种数据结构量身定做的。在C++中,我们直接使用标准库的std::stack<double>就能轻松实现。这里有一个关键细节:弹栈的顺序。先弹出的是右操作数,后弹出的是左操作数,这对于减法和除法这种不满足交换律的运算至关重要。a - b在后缀表达式a b -中,求值时先弹出b,再弹出a,计算a - b,顺序不能错。

2.2 中缀转后缀:调度场算法的精妙

难点和精华都在前半部分:如何将人类习惯的中缀表达式转换成机器友好的后缀表达式?这里我们采用经典的调度场算法。这个算法由艾兹赫尔·戴克斯特拉提出,同样使用一个栈来临时存放运算符,其核心思想是通过栈来调整运算符的输出顺序,以反映其优先级和结合性

算法的具体规则如下:

  1. 初始化两个结构:一个用于输出后缀表达式的队列(或列表),一个用于暂存运算符的栈。
  2. 从左到右扫描中缀表达式。
  3. 遇到操作数(数字),直接加入输出队列。
  4. 遇到运算符(记为op1): a. 只要栈非空,且栈顶运算符(记为op2)的优先级高于或等于op1,并且op2不是左括号(,就将op2弹出栈并加入输出队列。 b. 将op1压入运算符栈。
  5. 遇到左括号(,直接压入运算符栈。
  6. 遇到右括号),则不断将栈顶运算符弹出并加入输出队列,直到遇到左括号(为止,然后将这对括号丢弃(左括号弹出但不输出)。
  7. 当表达式扫描完毕后,将运算符栈中剩余的所有运算符依次弹出并加入输出队列。

注意:优先级规则是算法的核心。通常设定*/的优先级高于+-。对于优先级相同的运算符(如+-),规则4a中的“等于”保证了左结合性(即a - b - c会被处理为(a - b) - c)。

这个算法巧妙地利用栈“后进先出”的特性,延迟了低优先级运算符的输出,保证了高优先级运算符先被计算。括号则作为一个强制重置的边界,左括号入栈时像一个“高墙”,其内的运算符自成体系,直到遇到右括号这面墙被推倒。

2.3 方案选型与C++实现要点

基于以上分析,我们的C++实现方案就明确了:

  1. 输入处理:我们将表达式作为字符串(std::string)输入。需要编写一个分词器(tokenizer),能正确区分数字、运算符和括号。对于多位数和小数点,分词是第一个小挑战。
  2. 数据结构
    • 使用std::stack<std::string>std::stack<char>用于调度场算法中的运算符栈。我更倾向于使用std::string来存储token,便于统一处理多位数字符串。
    • 使用std::vector<std::string>std::queue<std::string>来存储输出的后缀表达式序列。
    • 使用std::stack<double>用于后缀表达式求值。
  3. 核心函数
    • std::vector<std::string> infixToPostfix(const std::string& infix):实现调度场算法,返回后缀表达式token序列。
    • double evaluatePostfix(const std::vector<std::string>& postfix):实现后缀表达式求值。
    • 辅助函数:int getPriority(const std::string& op)用于定义运算符优先级,bool isOperator(const std::string& s)用于判断token类型。
  4. 错误处理:需要考虑表达式不合法的情况,如括号不匹配、运算符缺少操作数、除零错误等。这能极大提升程序的健壮性。

这个设计清晰地将问题分解为两个独立的、可测试的模块,符合软件工程的高内聚低耦合原则。接下来,我们就进入具体的实现环节。

3. 核心实现细节与C++编码实战

理论清晰后,我们开始动手编码。我会先给出一个基础但完整的实现,然后逐步加入更健壮的特性。我们假设输入的表达式中数字和运算符之间可能有空格,也可能没有,这要求我们的分词器要足够灵活。

3.1 分词器:从字符串到Token序列

这是所有处理的第一步。一个健壮的分词器能省去后面无数麻烦。

#include <iostream> #include <string> #include <vector> #include <cctype> // 用于 isdigit 函数 #include <sstream> std::vector<std::string> tokenize(const std::string& expression) { std::vector<std::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.14+2*(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_map<std::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::vector<std::string> infixToPostfix(const std::vector<std::string>& infixTokens) { std::vector<std::string> postfix; std::stack<std::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::vector<std::string>& postfixTokens) { std::stack<double> 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::vector<std::string> infixTokens = tokenize(inputExpr); std::cout << "Infix Tokens: "; for (const auto& t : infixTokens) std::cout << t << " "; std::cout << std::endl; // 2. 中缀转后缀 std::vector<std::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; }

你可以用3+4*2/(1-5)这样的表达式测试,观察它的分词、转换和计算过程。正确的后缀表达式应该是3 4 2 * 1 5 - / +,结果应为1

4. 进阶优化与功能扩展

基础版本跑通后,我们可以让它变得更强大、更健壮。以下是几个常见的优化方向。

4.1 处理负号与一元运算符

这是基础版本最大的短板。在表达式-3+43*(-4)中,-是负号。我们需要在分词或转换阶段识别它。一个常见的策略是:在分词时,如果一个-前面是另一个运算符、左括号(,或者它就是表达式的第一个字符,那么它就是一个一元负号。我们可以用一个特殊的符号(如"#")来标记一元负号,并在优先级和求值逻辑中单独处理。

修改分词器

std::vector<std::string> tokenizeAdvanced(const std::string& expr) { std::vector<std::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; }

修改调度场算法和求值逻辑

  • isOperatorgetPriority中需要加入对"#"的判断。一元负号的优先级通常设定为高于乘除。
  • 在求值函数中,遇到"#"时,只需从栈中弹出一个操作数,进行取负操作-operand,再将结果压栈。

4.2 添加更多运算符和函数

扩展性是这个设计的一大优点。要添加新的二元运算符(如求幂^),只需:

  1. isOperator函数中添加判断。
  2. getPriority的映射表中为其赋予合适的优先级(通常求幂优先级最高,且是右结合,这需要在算法中微调规则4a)。
  3. evaluatePostfixswitch语句中添加对应的计算逻辑,例如使用std::pow(left, right)

要添加函数(如sin,cos,log),需要:

  1. 在分词器中识别这些多字母的token。
  2. 在调度场算法中,函数名通常直接加入输出队列(或者像左括号一样特殊处理,取决于算法变体)。更常见的做法是,函数具有最高优先级,并且当它出现在栈顶时,遇到左括号才触发其参数的计算和弹出。这需要更复杂的逻辑,但核心思想不变。
  3. 在求值函数中,实现对应的数学函数调用。

4.3 提升健壮性:更完善的错误处理

一个工业级的计算器需要能优雅地处理各种错误输入。

  • 括号不匹配:我们已经在转换函数中做了基本检查。
  • 非法字符:在分词阶段,可以检查每个字符,如果不是数字、小数点、运算符、括号或允许的函数名字符,则抛出异常。
  • 数字格式错误:使用std::stod的异常机制可以捕获像"12.34.56"这样的非法数字。
  • 表达式为空或只有运算符:在求值前后检查token序列和栈的状态。
  • 自定义异常类:可以定义不同的异常类型(如ParseError,EvalError),让错误信息更清晰。

4.4 性能与内存考量

对于教学和大多数应用场景,当前的实现性能足够。如果追求极致,可以考虑:

  • 避免字符串拷贝:使用std::string_view(C++17)来传递token,减少不必要的字符串构造。
  • 预分配内存:对于vectorstack,如果可以预估最大token数量,可以使用reserve提前分配内存,减少动态扩容的开销。
  • 表达式求值一次完成:实际上,调度场算法和后缀求值可以合并成一个步骤,使用两个栈(一个存操作数,一个存运算符)同时进行,这被称为“调车场算法的求值变体”,可以减少中间后缀表达式的存储开销。但对于理解和教学,分两步更清晰。

5. 常见问题与调试技巧实录

在实现和调试过程中,我遇到过不少坑。这里总结几个典型问题及其解决方法。

5.1 问题一:计算结果完全不对

排查步骤

  1. 打印分词结果:首先确认你的分词器是否正确。输入"1+2*3",输出应该是["1", "+", "2", "*", "3"]。如果分词就错了,后面全错。常见错误是没处理好空格,或者小数点的识别有问题。
  2. 打印后缀表达式:在infixToPostfix函数结束后,立即打印输出的后缀表达式序列。对于"1+2*3",正确的后缀表达式是["1", "2", "3", "*", "+"]。如果这里错了,问题出在调度场算法。
    • 检查优先级映射表是否正确(*/的优先级必须大于+-)。
    • 检查规则4a的循环条件while (!opStack.empty() && opStack.top() != "(" && getPriority(opStack.top()) >= getPriority(token))。这里的>=保证了同优先级运算符的左结合性。如果是右结合运算符(如^),条件应为>
  3. 单步调试求值过程:如果后缀表达式正确,但结果不对,在evaluatePostfix函数中,每进行一次运算,就打印出栈的状态。这能帮你发现是操作数弹出顺序错了,还是运算逻辑错了。

5.2 问题二:遇到括号就崩溃或结果错误

可能原因

  1. 括号不匹配检测缺失:确保在遇到右括号)时,如果栈空或栈顶不是(,一定要报错。在清空运算符栈时,如果发现(,也要报错。
  2. 运算符栈中残留左括号:在算法最后清空栈的循环里,我加入了检查左括号的代码,这就是为了捕获"(1+2"这种缺少右括号的情况。
  3. 优先级与括号的交互:左括号(在栈内时,其优先级应被视为最低(或者在比较时遇到左括号就停止弹出),否则会影响括号内运算符的正常弹出。我们的代码中while循环条件包含了opStack.top() != "(",这确保了左括号像一个屏障,其内的运算符不会被括号外的低优先级运算符提前弹出。

5.3 问题三:处理带空格和负号的表达式

这是两个最常见的输入格式问题。

  • 空格:我们的基础分词器通过if条件跳过了非运算符/数字/括号的字符,因此天然支持空格。但要注意,如果表达式中间有多个空格或制表符,我们的逻辑也能正常工作。
  • 负号:如前所述,这是需要特殊处理的。一个简单的测试用例"-1+2"就能击垮基础版本。实现4.1节中的负号识别逻辑是解决这个问题的标准方法。一个更取巧但不推荐的方法是,要求用户在所有一元负号前加一个0,将-1+2写成0-1+2,但这改变了用户的输入习惯。

5.4 调试技巧:可视化工具

对于复杂表达式,在脑子里推演转换过程很累。我常用的方法是画图

  1. 准备三列:输入Token、操作栈(Stack)、输出队列(Output)。
  2. 模拟调度场算法,一步一步填写表格。
  3. 对比程序输出的后缀表达式和你手动推导的是否一致。

例如,对于3 + 4 * 2 / ( 1 - 5 )

输入 Token操作栈 (底->顶)输出队列
33
++3
4+3 4
*+ *3 4
2+ *3 4 2
/+ /3 4 2 *
(+ / (3 4 2 *
1+ / (3 4 2 * 1
-+ / ( -3 4 2 * 1
5+ / ( -3 4 2 * 1 5
)+ /3 4 2 * 1 5 -
结束3 4 2 * 1 5 - / +

最终输出队列即为后缀表达式。通过这种手动画表的方式,能非常直观地验证你的算法逻辑,也是面试时向面试官展示思路的好方法。

实现一个波兰表达式计算器,就像搭积木,把栈、字符串处理、算法逻辑这些C++基础知识块严丝合缝地拼接在一起。当你看到自己写的程序能正确解析并计算出一个复杂表达式时,那种对底层逻辑豁然开朗的感觉,是单纯看书无法比拟的。这个项目代码量不大,但“麻雀虽小,五脏俱全”,它为你打开了通往编译器前端、表达式解析和更复杂算法世界的一扇门。你可以尝试在此基础上增加变量赋值、自定义函数、甚至一个简单的语法树,把它变成一个真正可用的脚本引擎雏形。

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

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

立即咨询