C/C++实现带优先级计算器:栈与调度场算法实战

发布时间:2026/7/17 1:59:54
C/C++实现带优先级计算器:栈与调度场算法实战 1. 项目概述为什么我们要亲手实现一个带优先级的计算器如果你正在学习C或C并且已经掌握了变量、循环、函数这些基础语法那么恭喜你你来到了一个关键的“练手”节点。很多朋友会问学了这些能做什么难道只能写个“Hello World”或者做几道课后习题吗当然不是。一个带优先级的四则运算计算器就是检验你基础是否扎实、能否将知识串联起来的绝佳实战项目。这个项目听起来简单不就是算个“12*3”吗但它的“坑”可不少。它直接挑战你对数据结构尤其是栈和算法中缀表达式转后缀表达式即逆波兰式的理解。市面上大多数教程只教你实现“12”这样的两个数运算一旦遇到混合运算和优先级代码逻辑就会变得混乱不堪。而我们要做的是一个能正确解析“3 5 * ( 10 - 4 ) / 2”这样复杂表达式的“真”计算器。这不仅仅是写一个工具更是一次系统性的思维训练。你需要考虑如何拆分字符串、如何判断运算符优先级、如何处理括号、如何设计内存安全的计算流程。整个过程你会深刻体会到栈Stack这种“后进先出”数据结构的美妙之处以及将人类习惯的表达式中缀转换为计算机易于计算的表达式后缀的精巧算法。做完这个项目你对程序设计的理解会上一个台阶再去看“数据结构与算法”的理论会感觉豁然开朗。2. 核心思路拆解从“人算”到“机算”的思维转换我们人类计算“1 2 * 3”时会本能地先算乘法再算加法。这是因为我们大脑里内置了“运算符优先级”的规则。但计算机是线性的、顺序执行的它读到“1”接着是“”然后是“2”它并不知道后面的“*”优先级更高。如果我们让计算机也顺序计算结果就会变成错误的“(12)*39”。所以核心问题在于如何让计算机理解并遵守我们设定的运算优先级规则解决方案就是著名的“调度场算法”Shunting-yard Algorithm由艾兹赫尔·戴克斯特拉没错就是那个提出最短路径算法的牛人发明。这个算法的核心思想是引入两个栈操作数栈用于存放等待计算的数字。运算符栈用于存放运算符包括括号并依据优先级规则决定运算顺序。算法的目标是将我们输入的中缀表达式如3 5 * 2转换为后缀表达式又称逆波兰表达式如3 5 2 * 。后缀表达式的最大优点是完全消除了优先级和括号计算时只需要从左到右扫描遇到数字就入栈遇到运算符就从栈顶弹出两个数进行计算结果再入栈最终栈里剩下的那个数就是结果。2.1 为什么选择“栈”和“后缀表达式”这背后有深刻的考量栈的“后进先出”特性完美匹配表达式求值最后遇到的运算符在栈顶可能正是需要优先计算的。括号的匹配也是栈的经典应用——遇到右括号就一直弹出运算符直到遇到左括号。后缀表达式消除了歧义中缀表达式“AB*C”有歧义吗对我们没有但对顺序执行的计算机有。而后缀表达式“A B C * ”只有一种解读方式计算顺序是唯一确定的。算法逻辑清晰易于实现和调试整个流程可以分解为“分词”、“转换”、“计算”三个相对独立的阶段每个阶段职责单一代码结构清晰。注意很多新手会试图在一个循环里边读边算用一堆if-else来判断优先级。这种方法在表达式稍微复杂一点比如嵌套括号时代码会迅速变得极其臃肿和难以维护。采用“分词-转后缀-计算”的三段式架构是工业级表达式解析的通用做法也是本项目要传递的核心方法论。3. 项目设计与关键技术点解析在动手写代码之前我们必须把设计蓝图和关键的技术决策想清楚。一个健壮的计算器不能只处理“11”还要考虑错误输入、浮点数、空格、多位数等问题。3.1 整体架构设计我们的计算器将遵循经典的“管道”处理模式输入字符串 - 分词器 - 中缀Token序列 - 调度场算法 - 后缀Token序列 - 后缀表达式求值 - 输出结果每一个环节都只负责一件事这样不仅代码好写未来如果想增加新功能比如支持三角函数sin也只需要修改或扩展特定环节。3.2 核心数据结构Token的设计“Token”意为“词元”是我们对输入表达式的最小语义单元。我们需要定义一个结构体或类来统一表示数字和运算符。#include string #include variant // C17 或使用 union/枚举 // 类型枚举 enum class TokenType { NUMBER, OPERATOR_PLUS, // OPERATOR_MINUS, // - OPERATOR_MULTIPLY, // * OPERATOR_DIVIDE, // / PAREN_LEFT, // ( PAREN_RIGHT, // ) END // 结束标志 }; struct Token { TokenType type; double value; // 当type为NUMBER时有效 char opChar; // 当type为运算符或括号时有效便于调试输出 // 构造函数示例 Token(TokenType t) : type(t), value(0.0), opChar(\0) {} Token(double v) : type(TokenType::NUMBER), value(v), opChar(\0) {} Token(char c) : type(charToType(c)), value(0.0), opChar(c) {} private: static TokenType charToType(char c) { switch(c) { case : return TokenType::OPERATOR_PLUS; case -: return TokenType::OPERATOR_MINUS; case *: return TokenType::OPERATOR_MULTIPLY; case /: return TokenType::OPERATOR_DIVIDE; case (: return TokenType::PAREN_LEFT; case ): return TokenType::PAREN_RIGHT; default: return TokenType::END; // 简单处理实际应报错 } } };为什么这么设计用一个统一的Token结构来封装所有类型的词元后续处理流程栈操作、算法逻辑就可以只处理Token对象而不需要关心它具体是数字还是哪种运算符极大简化了逻辑。std::variantC17是更现代、安全的实现方式如果使用C语言可以用union加type标签来实现。3.3 优先级与结合性的定义这是算法的灵魂。我们必须为每个运算符赋予一个优先级数值并规定其结合性左结合还是右结合四则运算都是左结合。// 获取运算符优先级 int getPrecedence(TokenType op) { switch(op) { case TokenType::OPERATOR_PLUS: case TokenType::OPERATOR_MINUS: return 1; case TokenType::OPERATOR_MULTIPLY: case TokenType::OPERATOR_DIVIDE: return 2; default: return 0; // 括号或其他优先级最低 } } // 判断是否为左结合四则运算都是 bool isLeftAssociative(TokenType op) { return (op TokenType::OPERATOR_PLUS || op TokenType::OPERATOR_MINUS || op TokenType::OPERATOR_MULTIPLY || op TokenType::OPERATOR_DIVIDE); }优先级数值的设定是任意的只要保持乘除 加减这个关系即可。通常乘除设为2加减设为1。括号在算法中有特殊作用不直接参与优先级比较。3.4 分词器的实现细节分词器Tokenizer或Lexer负责将输入字符串3.14 2 * (5 - 1)分解成一个个Token。这是第一步也是最容易出bug的一步。关键点与避坑指南处理空格直接跳过不要将它们视为错误。识别数字需要处理整数、小数。核心逻辑是连续读取数字字符和小数点然后使用std::stod或C语言的atof/strtod进行转换。切记要检查连续多个小数点的情况。识别运算符和括号,-,*,/,(,)这几个字符直接映射为对应的Token。处理负号与减号的歧义这是最大的坑之一在表达式开头或(后面的-应该被解释为负号一元运算符而不是减号二元运算符。我们的简单计算器可能不直接支持负号但至少要能识别这种情况并给出友好错误提示或者可以将其转换为(0 - ...)。一个常见的策略是如果-前面是另一个运算符、左括号或者什么都没有表达式开头那么它可能就是负号。错误处理遇到无法识别的字符如,$分词器应该抛出错误或返回一个错误Token而不是默默忽略或导致后续崩溃。// 分词器伪代码思路 std::vectorToken tokenize(const std::string expr) { std::vectorToken tokens; size_t i 0; while (i expr.length()) { if (isspace(expr[i])) { i; continue; } if (isdigit(expr[i]) || expr[i] .) { // 处理数字 size_t start i; while (i expr.length() (isdigit(expr[i]) || expr[i] .)) i; std::string numStr expr.substr(start, i - start); try { double val std::stod(numStr); tokens.push_back(Token(val)); } catch (...) { throw std::runtime_error(无效的数字: numStr); } } else if (isOperatorOrParen(expr[i])) { // 处理运算符和括号 // 特别处理负号如果当前是-且它位于开头或前一个token是运算符或左括号 if (expr[i] - (tokens.empty() || tokens.back().type TokenType::PAREN_LEFT || isOperator(tokens.back().type))) { // 简单方案报错提示不支持一元负号。高级方案将其转换为(0 - X)处理。 throw std::runtime_error(暂不支持一元负号运算符请使用(0 - x)形式。); } tokens.push_back(Token(expr[i])); i; } else { throw std::runtime_error(std::string(无法识别的字符: ) expr[i]); } } tokens.push_back(Token(TokenType::END)); // 添加结束标志 return tokens; }4. 核心算法实现调度场算法与后缀表达式求值有了清晰的Token流我们就可以实现核心算法了。4.1 调度场算法中缀转后缀算法规则用两个栈输出队列output和运算符栈opStack从左到右读取中缀表达式的每个Token。如果是数字直接加入输出队列。如果是运算符o1只要运算符栈非空且栈顶运算符o2不是左括号并且满足以下条件之一o2的优先级高于o1或者o2的优先级等于o1且o1是左结合的。就将o2从栈顶弹出加入到输出队列。循环结束后将o1压入运算符栈。如果是左括号(直接压入运算符栈。如果是右括号)不断将运算符栈顶的运算符弹出并加入输出队列直到遇到左括号(。弹出左括号丢弃不加入输出队列。读取完所有Token后将运算符栈中剩余的所有运算符依次弹出并加入输出队列。std::vectorToken shuntingYard(const std::vectorToken infixTokens) { std::vectorToken output; // 用作输出队列 std::stackToken opStack; for (const auto token : infixTokens) { if (token.type TokenType::NUMBER) { output.push_back(token); } else if (isOperator(token.type)) { while (!opStack.empty() opStack.top().type ! TokenType::PAREN_LEFT getPrecedence(opStack.top().type) getPrecedence(token.type)) { // 注意这里是 处理同优先级左结合 output.push_back(opStack.top()); opStack.pop(); } opStack.push(token); } else if (token.type TokenType::PAREN_LEFT) { opStack.push(token); } else if (token.type TokenType::PAREN_RIGHT) { while (!opStack.empty() opStack.top().type ! TokenType::PAREN_LEFT) { output.push_back(opStack.top()); opStack.pop(); } if (opStack.empty()) { throw std::runtime_error(括号不匹配缺少左括号); } opStack.pop(); // 弹出左括号 } // 忽略END或在循环外处理 } // 处理剩余的运算符 while (!opStack.empty()) { if (opStack.top().type TokenType::PAREN_LEFT) { throw std::runtime_error(括号不匹配缺少右括号); } output.push_back(opStack.top()); opStack.pop(); } return output; }4.2 后缀表达式求值得到后缀表达式如[3, 5, 2, *, ]后求值就非常简单了准备一个操作数栈。从左到右扫描后缀表达式。如果是数字入栈。如果是运算符则从栈顶弹出两个操作数注意顺序先弹出的是右操作数再弹出的是左操作数进行运算将结果压回栈中。扫描结束后栈中应只剩下一个元素即为最终结果。double evaluatePostfix(const std::vectorToken postfixTokens) { std::stackdouble numStack; for (const auto token : postfixTokens) { if (token.type TokenType::NUMBER) { numStack.push(token.value); } else if (isOperator(token.type)) { if (numStack.size() 2) { throw std::runtime_error(表达式错误运算符缺少足够的操作数); } double right numStack.top(); numStack.pop(); double left numStack.top(); numStack.pop(); double result 0.0; switch(token.type) { case TokenType::OPERATOR_PLUS: result left right; break; case TokenType::OPERATOR_MINUS: result left - right; break; case TokenType::OPERATOR_MULTIPLY: result left * right; break; case TokenType::OPERATOR_DIVIDE: if (std::abs(right) 1e-12) { // 避免除零错误 throw std::runtime_error(数学错误除数为零); } result left / right; break; default: break; } numStack.push(result); } } if (numStack.size() ! 1) { throw std::runtime_error(表达式错误未能计算出唯一结果); } return numStack.top(); }5. 代码整合与工程化实践将上述模块组合起来就是一个完整的计算器核心。我们还需要一个主函数来驱动整个流程并处理用户交互。5.1 主程序框架与错误处理一个健壮的程序必须考虑错误处理。我们的计算器可能遇到多种错误分词错误、括号不匹配、除零错误、表达式不合法等。使用C的异常机制或C语言的错误码返回是清晰的做法。#include iostream #include string #include vector #include stack #include cctype #include stdexcept // ... 之前定义的 Token, tokenize, shuntingYard, evaluatePostfix 等函数 ... int main() { std::string input; std::cout 请输入四则运算表达式 (输入 quit 退出):\n; while (std::getline(std::cin, input)) { if (input quit || input exit) { break; } if (input.empty()) { continue; } try { // 1. 分词 std::vectorToken tokens tokenize(input); // 2. 中缀转后缀 std::vectorToken postfix shuntingYard(tokens); // 3. 后缀表达式求值 double result evaluatePostfix(postfix); // 4. 输出结果 std::cout 结果: result std::endl; } catch (const std::exception e) { std::cerr 错误: e.what() std::endl; } std::cout \n请输入下一个表达式 (或 quit): ; } return 0; }5.2 内存管理与资源安全在这个项目中我们主要使用了std::vector和std::stack它们会自动管理内存无需手动new/delete这是现代C推荐的做法能有效避免内存泄漏。如果使用纯C语言实现则需要手动管理数组和栈的内存分配与释放务必注意成对使用malloc/free并在函数返回前释放所有临时资源。一个C语言实现的要点为Token数组动态分配空间并记录容量和当前大小。实现一个栈结构体包含一个动态数组。在每个函数出口包括错误返回处确保释放临时分配的内存。使用strtod而不是atof来转换数字因为strtod能提供更完善的错误检查。6. 功能扩展与优化思路一个基础版本完成后你可以考虑以下方向进行扩展这会让你的项目更加出彩6.1 支持更多运算符和函数幂运算^优先级高于乘除且是右结合的如2^3^2应计算为2^(3^2)512而非(2^3)^264。这需要在getPrecedence和isLeftAssociative函数中增加判断。取模运算%优先级同乘除。数学函数如sin,cos,sqrt,log等。这需要扩展Token类型例如增加FUNCTION类型并在分词阶段识别函数名。在求值阶段遇到函数token时从栈中弹出一个操作数进行计算。常量如PI,E。可以在分词阶段直接替换为对应的数值。6.2 增强输入处理能力处理一元负号如前所述这是硬骨头。一个可行的方案是在分词或转换阶段将一元负号-5转换为(0-5)。这需要更复杂的状态机来区分减号和负号。处理多位数和浮点数我们的基础版本已经实现。忽略所有空白字符使用isspace判断。提供更友好的错误信息不仅提示“错误”还提示错误发生的大概位置第几个字符附近。6.3 性能与代码优化避免不必要的拷贝在C中传递const std::vectorToken引用而不是值。使用std::deque或std::vector作为输出队列std::vector在尾部添加元素效率很高适合作为输出队列。预分配内存如果知道表达式的大致长度可以预先为vector和栈reserve空间减少动态扩容的开销。考虑使用std::string_viewC17在分词阶段处理子字符串时可以避免创建大量的临时std::string对象。6.4 构建更复杂的应用图形界面使用Qt、wxWidgets或ImGUI为你的计算器核心逻辑套上一个图形界面。命令行历史与编辑类似bash支持上下箭头调出历史表达式。变量赋值与使用例如x 10; y x * 2;。这需要引入符号表std::mapstd::string, double来存储变量。7. 调试技巧与常见问题实录在实际编码中你一定会遇到各种问题。以下是我在实现过程中踩过的坑和解决方法问题1表达式1 2 * 3计算结果错误得到9而不是7。排查首先检查后缀表达式是否正确。正确的后缀表达式应为1 2 3 * 。如果你的结果是1 2 3 *说明优先级处理逻辑有误。解决重点检查shuntingYard函数中当遇到运算符时从opStack弹出栈顶运算符的条件。核心条件是栈顶运算符优先级 当前运算符优先级且左结合。确保乘除的优先级2大于加减的优先级1。问题2带括号的表达式如(1 2) * 3计算错误或程序崩溃。排查检查右括号)的处理逻辑。算法应该不断弹出运算符直到遇到左括号(。如果栈空了都没遇到说明括号不匹配多写了右括号。同时在算法最后清空运算符栈时如果遇到左括号说明括号不匹配多写了左括号。解决在相应位置添加括号匹配的检查并抛出清晰的异常信息。问题3输入10 / 0导致程序崩溃或输出inf。排查在后缀表达式求值阶段执行除法运算前没有检查除数是否为零。解决在evaluatePostfix函数的除法分支中添加除零检查并抛出异常或返回一个特定的错误值。问题4输入2 * -3被错误地分词或计算。排查分词器将-3中的-识别为减号导致表达式被解析为2 * - 3这在语法上是错误的。解决在分词器识别到-字符时判断它前面是什么。如果前面是另一个运算符、左括号或者什么都没有表达式开头则应该按一元负号处理。如之前所述可以采用报错或转换的策略。问题5输入3.14.15这样的非法数字程序崩溃。排查分词器在读取数字时没有对数字格式进行有效性检查直接将字符串3.14.15传给std::stod导致转换失败抛出异常。解决可以在分词器内部实现一个简单的数字格式检查或者用更安全的方式如std::from_charsC17来转换并捕获所有可能的异常给出友好提示。调试建议打印中间结果在tokenize、shuntingYard、evaluatePostfix三个关键函数的开头和结尾打印输入的Token序列和输出的Token序列。这是最有效的调试手段。单元测试为每个函数编写简单的测试用例。例如测试tokenize(“12”)是否返回[NUM:1, OP:, NUM:2]测试shuntingYard转换是否正确测试evaluatePostfix计算是否准确。使用调试器学会使用GDBLinux/macOS或Visual Studio DebuggerWindows设置断点单步执行观察变量值的变化。实现这个项目的过程就像在搭一个精密的机械。每一个齿轮函数都必须严丝合缝。当你第一次看到自己写的程序正确计算出(12*3-4)/5的结果时那种成就感是无可替代的。它不仅巩固了你的C/C语法更重要的是让你初步掌握了用计算机思维解决复杂问题的方法——分解、抽象、算法、数据结构。这才是本项目最大的价值。