资讯动态

C++实现波兰表达式求值:栈的应用与算法详解

发布时间:2026/8/23 4:55:07 来源:尧图企业网站定制
1. 项目概述从“波兰表达式”说起最近在整理一些算法笔记翻到了“波兰表达式”这个老伙计。说起来这玩意儿在面试里出现的频率可不低尤其是那些对基础算法和数据结构有要求的岗位。我第一次接触它还是在啃《数据结构》课本的时候当时觉得这前缀、后缀的转换规则有点绕但真正理解后才发现它简直是编译器设计和计算器实现的“灵魂”之一。所谓的波兰表达式也叫前缀表达式它的核心特点就是运算符写在操作数前面比如我们熟悉的 1 2就等价于中缀的1 2。与之对应的还有逆波兰表达式后缀表达式像1 2 。今天我们就用 C 这把“瑞士军刀”来彻底拆解一下波兰表达式的计算与转换这不仅是理解栈Stack这一数据结构的绝佳案例更是提升你解决复杂表达式求值问题能力的实战演练。你可能会问现在各种高级语言和库函数这么丰富为什么还要手动实现这个原因很简单知其然更要知其所以然。当你自己动手实现了一个表达式求值器你就能深刻理解编译器在解析a b * c时为什么要先算乘法以及函数调用、递归下降这些概念到底是怎么落地的。这对于夯实 C 基础、应对技术面试、乃至后续学习编译原理都大有裨益。本文假设你已有 C 的基本语法基础我们将从原理到实现一步步构建一个健壮的前缀表达式求值程序并深入探讨其与中缀、后缀表达式的相互转换。过程中我会分享很多我当年踩过的坑和调试技巧保证你能跟着做出来。2. 核心原理与思路拆解2.1 表达式记法的前世今生我们日常书写数学表达式如3 4 * 5使用的就是中缀表达式。它的特点是运算符在操作数中间符合人类的阅读习惯但缺点是需要依赖运算符优先级和括号来定义运算顺序这对计算机来说并不友好因为它需要额外的逻辑如调度场算法来解析。为了解决这个问题波兰数学家扬·武卡谢维奇提出了前缀表达式波兰表达式和后缀表达式逆波兰表达式。它们的共同点是完全消除了括号和优先级歧义运算顺序唯一由表达式的排列顺序决定。前缀表达式运算符在前操作数在后。例如 3 * 4 5等价于中缀的3 (4 * 5)。计算时需要从右向左扫描。后缀表达式操作数在前运算符在后。例如3 4 5 * 等价于同样的3 (4 * 5)。计算时需要从左向右扫描。为什么计算机偏爱后缀和前缀因为它们的求值过程可以非常直观地用一个栈来实现无需复杂的优先级判断算法清晰且高效。我们今天的重点——前缀表达式求值其核心算法就是基于栈的从右向左扫描。2.2 算法核心栈的巧妙运用栈Stack是一种后进先出LIFO的数据结构它就像一摞盘子你只能从最上面放入或取出。这个特性完美契合了表达式求值中“暂存中间结果”和“逆序处理”的需求。对于前缀表达式 * 2 3 4中缀(2 * 3) 4其求值算法步骤如下从右向左扫描表达式。如果遇到操作数数字则将其压入栈中。如果遇到运算符则从栈中弹出所需数量的操作数对于二元运算符是2个进行运算并将运算结果压回栈中。重复步骤2-3直到表达式最左端。最后栈中应只剩下一个元素即为表达式的最终结果。以 * 2 3 4为例扫描4是操作数入栈。栈[4]扫描3是操作数入栈。栈[4, 3]扫描2是操作数入栈。栈[4, 3, 2]扫描*是运算符。弹出栈顶两个元素2和3计算2 * 3 6将结果6入栈。栈[4, 6]扫描是运算符。弹出栈顶两个元素6和4计算6 4 10将结果10入栈。栈[10]扫描结束栈中结果10即为最终值。这个算法的美妙之处在于它自然地处理了运算符的优先级和结合性。因为前缀表达式本身的结构已经隐含了运算顺序我们从右向左扫描先遇到的运算符在原表达式中更靠左反而后计算这正好符合栈的后进先出特性从而保证了高优先级运算在内层的运算先被计算。注意我们这里讨论的是标准的二元运算符。理论上该算法可以扩展支持一元运算符如负号-、三元运算符等只需要在遇到运算符时弹出相应数量的操作数即可。本文为聚焦核心仅实现最常见的二元运算,-,*,/。2.3 C实现方案选型明确了算法接下来就是用C实现。我们将构建一个命令行程序它能读取一个字符串格式的前缀表达式并输出计算结果。这里有几个关键设计点表达式输入与解析表达式将以字符串形式输入如 * 2 3 4。我们需要按空格分割字符串得到令牌token数组。使用空格分隔是为了简化解析实际应用中表达式可能没有空格那就需要更复杂的词法分析器这超出了本文基础范围。数据结构选择毫无疑问栈是主角。C标准库中的std::stack模板类是最佳选择它封装了栈的基本操作push,pop,top安全且高效。操作数处理从字符串分割出来的令牌需要区分是运算符还是操作数。操作数需要从字符串转换为数值类型如int或double。这里使用std::stoi或std::stod进行转换并注意异常处理。运算执行根据不同的运算符字符执行对应的算术运算。我们可以用一个if-else或switch语句或者使用std::map将运算符映射到函数对象后者在运算符较多时更优雅。错误处理一个健壮的程序必须考虑错误情况例如表达式非法、除数是否为零、栈操作是否异常如遇到运算符时栈内操作数不足等。基于以上分析我们的实现将分为以下几个模块主函数负责流程控制一个核心的求值函数evaluatePrefix以及可能的辅助函数如判断字符串是否为数字。3. 核心细节解析与实操要点3.1 字符串分割与令牌化这是我们的第一步也是容易出错的环节。C标准库没有现成的字符串分割函数但我们可以利用std::istringstream和std::vector轻松实现。#include sstream #include vector #include string std::vectorstd::string tokenize(const std::string expression) { std::vectorstd::string tokens; std::istringstream iss(expression); std::string token; while (iss token) { // 默认以空格为分隔符 tokens.push_back(token); } // 注意为了符合从右向左扫描的算法这里通常不需要反转tokens。 // 因为我们的算法是从右向左扫描所以直接使用这个tokens向量 // 然后通过逆向迭代器或者从 size()-1 开始递减索引来访问即可。 return tokens; }实操心得使用std::istringstream是最清晰、最不容易出错的分割方式。如果输入字符串可能包含多个连续空格或制表符iss token也能正确处理。切忌自己用循环和find去手动分割容易引入边界错误。3.2 操作数与运算符的判定如何判断一个令牌是数字还是运算符一个简单的方法是尝试转换。如果整个令牌都能转换为数字那么它就是操作数否则我们暂时认为它是运算符前提是表达式格式正确。bool isNumber(const std::string token) { // 方法1: 使用异常处理 (简单但可能有性能开销) // try { // std::stod(token); // return true; // } catch (...) { // return false; // } // 方法2: 使用std::all_of和isdigit (更高效但只能处理整数) // 对于浮点数情况更复杂这里以整数为例 return !token.empty() std::all_of(token.begin(), token.end(), ::isdigit); // 注意上述方法无法处理负数如“-5”和浮点数如“3.14”。 // 一个更健壮的方法是使用std::stringstream或正则表达式但为了示例清晰我们先使用简单版本。 // 下文将给出一个支持负数和浮点数的改进版本。 }对于更通用的场景支持负数、浮点数一个更稳妥的方法是使用std::stringstreambool isNumber(const std::string s) { std::istringstream iss(s); double d; // 尝试从字符串流读取一个double iss d; // 检查是否成功读取了一个数并且流已到达末尾确保整个字符串都是数字 return (iss iss.eof()); }运算符的判断则更简单通常检查令牌是否是预定义的几个字符之一如,-,*,/。3.3 栈的使用与运算这是算法的核心循环。我们需要从令牌数组的末尾开始向前遍历。#include stack #include cmath // 如果支持更复杂运算 double evaluatePrefix(const std::vectorstd::string tokens) { std::stackdouble operands; // 使用double以支持浮点运算 // 从右向左遍历令牌 for (auto it tokens.rbegin(); it ! tokens.rend(); it) { const std::string token *it; if (isNumber(token)) { // 是操作数转换为double并入栈 operands.push(std::stod(token)); } else { // 是运算符 // 1. 检查栈内是否有至少两个操作数 if (operands.size() 2) { throw std::runtime_error(Invalid prefix expression: not enough operands for operator token ); } // 2. 弹出右操作数和左操作数注意顺序 double right operands.top(); operands.pop(); double left operands.top(); operands.pop(); double result 0.0; // 3. 根据运算符进行计算 if (token ) { result left right; } else if (token -) { result left - right; // 注意前缀表达式 - A B 对应中缀 A - B } else if (token *) { result left * right; } else if (token /) { if (std::fabs(right) 1e-12) { // 避免除零错误 throw std::runtime_error(Division by zero!); } result left / right; } else { throw std::runtime_error(Unsupported operator: token); } // 4. 将计算结果压回栈中 operands.push(result); } } // 遍历结束后栈中应恰好有一个元素即结果 if (operands.size() ! 1) { throw std::runtime_error(Invalid prefix expression: malformed); } return operands.top(); }关键细节与避坑指南操作数弹出顺序这是最容易出错的地方对于前缀表达式- 5 2中缀5 - 2从右向左扫描先遇到2再遇到5最后遇到-。弹出时先弹出的是2右操作数后弹出的是5左操作数。所以计算必须是left - right即5 - 2。如果顺序搞反结果就错了。除零处理在进行除法运算前务必检查除数是否为零。使用fabs(right) 1e-12来判断比直接right 0对于浮点数更安全。异常处理使用throw抛出异常在主函数中捕获可以让程序在遇到非法表达式时给出清晰的错误信息而不是崩溃或输出无意义结果。栈的最终状态算法结束时栈里必须只有一个值。如果不是说明表达式令牌数量不匹配例如操作数过多或运算符过多是一个非法表达式。4. 完整实现与代码整合现在我们把所有部分整合到一个完整的、可编译运行的C程序中。为了提升用户体验我们还会添加一个简单的主循环。#include iostream #include string #include vector #include sstream #include stack #include cctype #include cmath #include stdexcept // 判断字符串是否为数字支持整数、小数、负数 bool isNumber(const std::string s) { std::istringstream iss(s); double d; // 尝试读取一个数字 iss d; // 检查是否成功读取并且已经消耗掉整个字符串 return (iss iss.eof()); } // 分割表达式字符串为令牌 std::vectorstd::string tokenize(const std::string expr) { std::vectorstd::string tokens; std::istringstream iss(expr); std::string token; while (iss token) { tokens.push_back(token); } return tokens; } // 核心求值函数 double evaluatePrefix(const std::vectorstd::string tokens) { std::stackdouble operands; // 从右向左扫描 for (auto it tokens.rbegin(); it ! tokens.rend(); it) { const std::string token *it; if (isNumber(token)) { operands.push(std::stod(token)); } else { // 是运算符 if (operands.size() 2) { throw std::runtime_error(错误运算符 token 缺少足够的操作数。); } double right operands.top(); operands.pop(); double left operands.top(); operands.pop(); double result 0.0; if (token ) { result left right; } else if (token -) { result left - right; // 注意顺序 } else if (token *) { result left * right; } else if (token /) { if (std::fabs(right) 1e-12) { throw std::runtime_error(错误除零错误。); } result left / right; } else if (token ^) { // 可选支持幂运算 result std::pow(left, right); } else { throw std::runtime_error(错误不支持的运算符 token 。); } operands.push(result); } } if (operands.size() ! 1) { throw std::runtime_error(错误表达式不完整或格式错误。); } return operands.top(); } int main() { std::cout 前缀表达式计算器 (输入 exit 退出) std::endl; std::cout 支持运算符: , -, *, /, ^ (幂运算) std::endl; std::cout 格式要求: 每个令牌数字或运算符之间用空格分隔例如: * 2 3 4 std::endl; std::string input; while (true) { std::cout \n请输入前缀表达式: ; std::getline(std::cin, input); if (input exit || input quit) { break; } if (input.empty()) { continue; } try { std::vectorstd::string tokens tokenize(input); if (tokens.empty()) { std::cout 提示未输入有效表达式。 std::endl; continue; } double result evaluatePrefix(tokens); std::cout 计算结果: result std::endl; } catch (const std::exception e) { std::cerr 计算失败: e.what() std::endl; } } std::cout 程序结束。 std::endl; return 0; }编译与运行 你可以使用任何你熟悉的C编译器来编译这段代码。例如在命令行中使用 gg -stdc11 -o prefix_calculator prefix_calculator.cpp ./prefix_calculator然后按照提示输入表达式如 * 2 3 4程序会输出10。5. 边界测试与常见问题排查写完代码只是第一步通过全面的测试才能确保程序的健壮性。下面是我总结的一些测试用例和常见问题。5.1 测试用例集测试输入 (前缀表达式)预期输出 (中缀等价)测试目的 5 38(53)基础加法- 10 46(10-4)基础减法注意顺序* 2 3 420((23)*4)复合表达式测试优先级/ * 6 2 34((6*2)/3)复合表达式乘除混合 - * / 20 5 2 1 36(((20/5)*2)-1)3复杂嵌套- 5抛出异常(操作数不足)非法表达式检测 1 2 3抛出异常(栈最终元素不为1)非法表达式检测/ 5 0抛出异常(除零错误)运行时错误处理 1 2抛出异常(非法运算符)运算符支持检测 1.5 2.33.8浮点数支持- -5 3-8(-5-3)负数支持5.2 常见问题与调试技巧结果完全不对比如- 5 2输出3而不是-3排查这几乎肯定是操作数弹出顺序错了。仔细检查evaluatePrefix函数中弹出left和right的顺序。记住对于从右向左扫描的前缀表达式先弹出的是右操作数。确保你的计算是left op right。程序遇到复杂表达式就崩溃或输出奇怪结果排查首先检查你的isNumber函数是否足够健壮。如果它错误地将一个非法字符序列判断为数字std::stod可能会抛出异常或产生未定义行为。使用我们上面提供的std::istringstream方法通常更可靠。其次在运算符处理分支务必在弹出操作数前检查栈的大小防止pop空栈。输入带括号的表达式行不行不行。前缀表达式本身无需括号。如果你输入了括号它们会被当作未知令牌既不是数字也不是支持的运算符导致程序抛出“不支持的运算符”异常。我们这个计算器是纯前缀表达式求值器。如何扩展支持更多运算符如求余%、幂运算^很简单在evaluatePrefix函数的if-else链或switch语句中添加新的分支即可。注意求余运算%通常只适用于整数如果使用double类型的栈需要先将操作数转换为int或使用std::fmod函数。我想处理不带空格的表达式字符串比如*23 4该怎么办这就进入了“词法分析”的领域。你需要编写一个更复杂的解析器逐个字符读取区分出数字串和运算符字符。一个简单的思路是遍历字符串如果当前字符是数字或小数点就继续读取直到遇到非数字字符把这中间的一段作为数字令牌如果当前字符是运算符则直接作为一个令牌。这比用空格分割要复杂得多也是编译器前端的基础工作之一。6. 延伸前缀、中缀、后缀表达式的相互转换理解了前缀表达式的求值我们再向前走一步看看它和中缀、后缀表达式之间如何转换。这在面试和深入学习中非常有用。6.1 中缀转前缀手动方法手动转换中缀表达式到前缀有一个清晰的算法步骤反转中缀表达式包括括号。将反转后的表达式中的每个括号进行互换左变右右变左。对修改后的表达式使用调度场算法Shunting-yard algorithm求其后缀形式。将得到的后缀表达式再次反转即得到前缀表达式。听起来有点绕我们以(A B) * C为例反转C * (B A)括号互换C * ) B A (求后缀算法略结果是C B A *反转后缀* A B C这就是前缀表达式。显然手动操作很繁琐。更常见的是用表达式树来理解。任何表达式都可以表示成一棵二叉树其中叶子节点是操作数内部节点是运算符。对这棵树进行前序遍历得到前缀表达式中序遍历得到中缀表达式通常需要加括号后序遍历得到后缀表达式。6.2 用C实现中缀转前缀思路这是一个更高级的课题通常涉及调度场算法和栈的配合。核心思路是反转中缀字符串并处理括号。使用一个栈来存放运算符一个输出队列或向量来存放最终的前缀令牌。从左到右扫描处理后的表达式遇到操作数直接加入输出。遇到)压入运算符栈。遇到(将栈顶运算符弹出并加入输出直到遇到)并丢弃这对括号。遇到运算符当其优先级高于或等于栈顶运算符时弹出栈顶并加入输出直到条件不满足然后将当前运算符压栈。注意由于表达式已反转优先级判断可能与常规中缀转后缀相反需要小心处理。扫描完后将栈中剩余运算符依次弹出加入输出。将输出队列反转即得到前缀表达式。由于实现代码较长且涉及优先级比较表、字符串反转等细节这里不展开完整代码但它是一个非常好的练习项目能让你对栈的应用和表达式解析的理解再上一个台阶。6.3 前缀转中缀和后缀这个相对简单。既然我们已经实现了前缀求值本质是构建和计算表达式树的过程我们可以修改算法不直接计算数值而是构建一个表示表达式的字符串。前缀转中缀在求值函数中当遇到运算符时我们不计算数字结果而是将弹出的两个操作数字符串或子表达式字符串用当前运算符连接并加上括号形成一个新的字符串表达式然后压回栈中。最后栈顶就是中缀表达式字符串。注意为了保持运算顺序每次组合时都需要加括号这样得到的中缀表达式括号可能非常多但逻辑正确。前缀转后缀更为直接。从右向左扫描前缀表达式遇到操作数压入栈栈里存放字符串。遇到运算符弹出两个操作数字符串将它们与运算符拼接成操作数1 操作数2 运算符的新字符串然后压回栈中。 扫描结束后栈顶字符串就是后缀表达式。这个过程几乎和求值算法一模一样只是把数值运算换成了字符串拼接。7. 性能考量与优化空间我们实现的这个基础版本在功能上是完整的但在一些极端场景下或有更高要求时还有优化空间字符串转换开销isNumber和std::stod在循环中调用对于超长表达式可能有性能开销。一种优化是在tokenize阶段就完成类型判断和数值转换生成一个包含枚举类型是操作数还是运算符和联合体存储double值或运算符字符的令牌结构体向量。这样在求值循环中只需做简单的类型判断避免了重复的字符串流解析。错误信息的精确性目前的错误提示还比较笼统。可以记录下出错令牌的位置索引在抛出异常时告知用户是表达式的第几个元素出了问题这对于调试长表达式非常有用。支持更复杂的运算可以很容易地扩展支持三角函数sin,cos、对数log、平方根sqrt等一元或多元函数。这需要在运算符判断和操作数弹出数量上做更通用的设计例如使用一个映射表将运算符字符串映射到其需要的操作数个数和一个可调用对象函数指针或lambda。表达式验证在求值前可以先进行一次快速的语法检查例如操作数和运算符的数量关系是否基本合理操作数数量 运算符数量 1这可以在早期发现一些明显的格式错误。实现一个表达式求值器就像搭积木从最基础的功能开始逐步添加更坚固的结构和更漂亮的装饰。希望这篇长文能帮你把“波兰表达式”这块积木牢牢握在手里。下次面试官再问你栈的应用或者表达式求值你大可以从这里开始滔滔不绝地讲上十分钟。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价