资讯详情

资讯详情

计算器核心解析算法:从词法分析到递归下降的完整实现

1. 从按键到结果计算器核心解析算法到底在算什么很多人第一次听到“计算器核心解析算法”这个词脑子里浮现的可能是满屏的按钮和一块液晶屏。但真正让计算器“会算数”的从来不是那块屏幕而是藏在背后的一套字符串解析与求值逻辑。你按下3 5 × 2它凭什么知道要先算乘法再算加法你输入(1 2) × 3它又怎么知道括号里的内容要优先处理这些问题的答案就是计算器核心解析算法要解决的事。这篇文章面向的是想自己动手写一个计算器、或者想理解表达式求值底层机制的开发者。不管你是刚学完数据结构的学生还是想给某个工具加一个公式计算模块的工程师这套算法都用得上。它不依赖任何特定编程语言核心思想在 Python、JavaScript、Java、C 里都能落地。我会从最朴素的正则拆分讲起一路走到调度场算法和递归下降解析把每一步的取舍理由、踩坑经验和实测细节都摊开来说。先明确一个概念计算器核心解析算法本质上处理的是中缀表达式的解析与求值。所谓中缀表达式就是运算符写在两个操作数中间的形式比如2 3。人类习惯这种写法但计算机直接处理它并不方便因为运算符之间存在优先级和结合性的问题。所以绝大多数实现都会先把中缀表达式转换成一种更容易计算的形式再执行求值。这个“转换”和“求值”的过程就是核心所在。我见过不少初学者一上来就写一个eval()调用或者用正则把表达式拆成数组后从左到右硬算。前者在真实项目里几乎不可控后者遇到2 3 × 4就会算出 20 而不是 14。所以这篇文章不会走捷径而是把一套可扩展、可维护的解析方案完整拆解出来。你跟着走一遍以后遇到任何表达式求值需求都能自己搭出一套靠谱的引擎。2. 词法拆分把一串字符切成有意义的记号2.1 为什么不能直接按字符遍历最直觉的做法是逐个字符扫描表达式遇到数字就累加遇到运算符就处理。但现实中的表达式远比12复杂数字可能是多位的123可能是小数3.14可能是科学计数法1.5e3运算符可能是双字符的或如果你要支持比较运算还有括号、逗号、函数名等等。如果按单字符处理123会被拆成1、2、3三个独立数字这显然不对。所以第一步必须做词法分析也叫 tokenization。它的任务是把原始字符串切分成一个个有意义的“记号”token每个 token 带有类型和值。比如3.14 sin(0.5)经过词法分析后应该得到这样的序列序号类型值1NUMBER3.142OPERATOR3FUNCTIONsin4LPAREN(5NUMBER0.56RPAREN)有了这个序列后续的语法分析和求值就有了清晰的输入。2.2 手写词法分析器的关键细节写词法分析器时最容易出问题的地方是数字的识别。很多人用正则\d去匹配结果遇到3.14只能匹配到3。正确的做法是用一个更完整的数字模式同时覆盖整数、小数和科学计数法。下面是一个经过实测可用的正则片段import re TOKEN_PATTERN re.compile(r (?PNUMBER (?:\d\.\d*|\.\d|\d) (?:[eE][-]?\d)? ) |(?POPERATOR[\-*/^%]) |(?PLPAREN\() |(?PRPAREN\)) |(?PFUNCTION[a-zA-Z_][a-zA-Z_0-9]*) |(?PCOMMA,) |(?PWHITESPACE\s) , re.VERBOSE)这个模式里数字部分先匹配3.14这种带小数点的再匹配.5这种省略整数部分的最后匹配纯整数。科学计数法的e部分是可选的这样1e3和1.5e-2都能正确识别。函数名用字母开头后面可以跟字母、数字或下划线这样sin、cos、log2都能覆盖。注意正则里的顺序很重要。如果把纯整数的\d放在最前面那么3.14会先被匹配成3剩下的.14就变成非法字符了。所以一定要把更具体的模式放在前面。2.3 空白字符和非法字符的处理空白字符在词法分析阶段通常直接跳过不生成 token。但非法字符必须报错不能悄悄忽略。我见过一些实现遇到不认识的字符就跳过结果用户输入3 $ 5时程序算出 8这显然不合理。正确的做法是记录非法字符的位置抛出一个明确的错误信息比如“位置 2 处存在无法识别的字符$”。这样调试起来会方便很多。另外词法分析器最好能记录每个 token 在原始字符串中的起始位置。这在后续报错时非常有用比如“第 5 个字符附近的括号不匹配”比单纯说“语法错误”要友好得多。3. 中缀转后缀调度场算法为什么成为经典3.1 中缀表达式的天然缺陷中缀表达式对人友好但对机器不友好。核心问题在于运算符的优先级和结合性需要额外的规则来处理。比如2 3 × 4机器从左到右扫描时先遇到2和如果立刻计算就会得到5再遇到× 4算出20这就错了。要正确处理机器必须“向前看”发现后面有更高优先级的运算符时先把它处理掉。这种“向前看”的逻辑如果直接写在求值循环里会变得非常复杂。后缀表达式也叫逆波兰表达式就没有这个问题。在后缀表达式里运算符写在两个操作数后面比如2 3 4 × 。它的求值规则极其简单从左到右扫描遇到数字就压栈遇到运算符就弹出两个操作数计算再把结果压回去。整个过程不需要考虑优先级因为优先级已经在转换阶段处理好了。3.2 调度场算法的核心逻辑把中缀表达式转换成后缀表达式最经典的算法是调度场算法由计算机科学家 Edsger Dijkstra 提出。它的核心是维护两个结构一个输出队列和一个运算符栈。扫描 token 序列时按以下规则处理遇到数字直接加入输出队列。遇到函数名压入运算符栈。遇到逗号不断弹出栈顶运算符加入输出队列直到栈顶是左括号。遇到运算符比较它与栈顶运算符的优先级。如果栈顶运算符优先级更高或相等且当前运算符是左结合就弹出栈顶加入输出队列重复这个过程直到栈顶优先级更低或栈为空然后把当前运算符压栈。遇到左括号压栈。遇到右括号不断弹出栈顶运算符加入输出队列直到遇到左括号然后把左括号弹出丢弃。如果栈顶是函数名也一并弹出加入输出队列。扫描结束后把栈中剩余运算符全部弹出加入输出队列。这套规则看起来步骤多但逻辑非常清晰。关键在于优先级比较那一步它决定了整个算法的正确性。3.3 优先级与结合性的具体设定优先级和结合性必须提前定义好。下面是一张常用的设定表你可以根据自己的需求调整运算符优先级结合性说明1左加法-1左减法*2左乘法/2左除法%2左取模^3右幂运算函数4右sin、cos 等一元负号5右负号这里有两个容易踩坑的地方。第一幂运算^通常是右结合的也就是说2^3^2应该理解为2^(3^2)等于 512而不是(2^3)^2等于 64。第二一元负号和二元减号需要区分。-3 5里的-是一元负号优先级很高5 - 3里的-是二元减号优先级较低。区分方法是看-前面是否有操作数如果前面是数字、右括号或变量它就是二元减号否则就是一元负号。3.4 一个完整的转换示例拿3 5 × (2 - 1)走一遍调度场算法扫描到3输出队列[3]。 扫描到栈为空压栈。栈[]。 扫描到5输出队列[3, 5]。 扫描到×栈顶优先级 1×优先级 2不弹出压栈。栈[, ×]。 扫描到(压栈。栈[, ×, (]。 扫描到2输出队列[3, 5, 2]。 扫描到-栈顶是(不弹出压栈。栈[, ×, (, -]。 扫描到1输出队列[3, 5, 2, 1]。 扫描到)弹出-加入输出队列弹出(丢弃。输出队列[3, 5, 2, 1, -]。栈[, ×]。 扫描结束弹出×和。最终输出队列[3, 5, 2, 1, -, ×, ]。对应的后缀表达式就是3 5 2 1 - × 。求值时先算2 - 1 1再算5 × 1 5最后算3 5 8。结果正确。4. 后缀表达式求值栈结构如何一步步算出结果4.1 求值循环的极简逻辑后缀表达式求值的代码非常短核心就是一个栈。遍历 token 序列遇到数字就压栈遇到运算符就弹出两个操作数计算后把结果压回去。遍历结束后栈里剩下的唯一元素就是最终结果。def evaluate_postfix(tokens): stack [] for token in tokens: if token.type NUMBER: stack.append(float(token.value)) elif token.type OPERATOR: right stack.pop() left stack.pop() result apply_operator(token.value, left, right) stack.append(result) elif token.type FUNCTION: arg stack.pop() result apply_function(token.value, arg) stack.append(result) return stack[0]这段代码里apply_operator根据运算符类型执行加减乘除等操作。注意弹出顺序先弹出的是右操作数后弹出的是左操作数。对于减法和除法顺序不能反否则5 - 3会算成3 - 5。4.2 一元负号在后缀表达式中的处理一元负号在转换阶段通常被处理成一个特殊的运算符比如u-。在后缀表达式里它只弹出一个操作数取负后压回栈。求值循环里需要单独判断这个运算符不能和二元减号混在一起。if token.value u-: operand stack.pop() stack.append(-operand)这个细节如果漏掉-3 5就会因为栈里只有一个操作数而报错。4.3 函数调用的多参数问题如果只支持单参数函数比如sin、cos上面的逻辑就够了。但如果要支持多参数函数比如max(1, 2, 3)或pow(2, 3)就需要在转换阶段用逗号作为分隔符在求值阶段根据函数名决定弹出几个参数。一种常见的做法是维护一个函数参数个数表函数名参数个数sin1cos1max可变pow2对于可变参数函数可以在转换阶段把逗号也作为一个特殊 token 保留求值时遇到逗号就记录一个分隔点最后统一弹出。不过对于大多数计算器场景单参数函数已经够用多参数支持可以作为扩展项。5. 递归下降解析另一种更直观的实现路径5.1 调度场算法的局限调度场算法很优雅但它有一个前提所有运算符的优先级和结合性必须提前定义好。如果你要支持自定义运算符或者运算符的优先级需要动态调整调度场算法就会变得笨拙。另外调度场算法对语法错误的检测不够友好比如括号不匹配时它只能在最后发现栈里还有左括号但无法指出具体是哪个位置出了问题。递归下降解析是另一种思路。它把表达式按语法规则拆分成多个层级每个层级对应一个函数。比如expression处理加减term处理乘除factor处理数字、括号和函数调用每个函数负责解析自己这一层级的表达式遇到更高层级的运算符就递归调用下一层。这种结构天然支持优先级因为优先级体现在函数的调用层级上。5.2 递归下降的代码骨架class Parser: def __init__(self, tokens): self.tokens tokens self.pos 0 def parse(self): return self.expression() def expression(self): left self.term() while self.current() and self.current().value in (, -): op self.current().value self.advance() right self.term() left apply_operator(op, left, right) return left def term(self): left self.factor() while self.current() and self.current().value in (*, /, %): op self.current().value self.advance() right self.factor() left apply_operator(op, left, right) return left def factor(self): token self.current() if token.type NUMBER: self.advance() return float(token.value) elif token.value (: self.advance() result self.expression() self.expect()) return result elif token.type FUNCTION: func_name token.value self.advance() self.expect(() arg self.expression() self.expect()) return apply_function(func_name, arg) elif token.value -: self.advance() return -self.factor() else: raise SyntaxError(f意外的 token: {token})这段代码里expression处理加减term处理乘除factor处理数字、括号、函数和一元负号。优先级通过调用层级自然体现expression调用termterm调用factor越往下优先级越高。5.3 递归下降的错误处理优势递归下降解析在遇到错误时可以立即抛出带有位置信息的异常。比如expect())发现当前 token 不是右括号就可以报告“位置 X 处期望右括号但遇到了 Y”。这种精确的错误定位是调度场算法难以做到的。另外递归下降解析更容易扩展。如果你想支持三元运算符a ? b : c只需要在expression里加一个判断即可。如果你想支持赋值表达式x 5也可以在顶层加一个assignment函数。这种可扩展性在复杂场景下非常有价值。6. 实测中的坑那些文档不会告诉你的细节6.1 浮点数精度问题计算器算0.1 0.2结果往往是0.30000000000000004而不是0.3。这不是算法的错而是 IEEE 754 浮点数表示法的固有问题。解决方式有两种一种是在输出时做格式化比如保留 10 位有效数字后去掉末尾的零另一种是使用十进制浮点数库比如 Python 的decimal模块。对于普通计算器格式化输出就够了对于财务计算必须用十进制库。from decimal import Decimal, getcontext getcontext().prec 28 result Decimal(0.1) Decimal(0.2) print(result) # 输出 0.36.2 除零错误的处理除以零在数学上是未定义的程序里必须显式处理。如果直接让1 / 0执行Python 会抛出ZeroDivisionErrorJavaScript 会返回Infinity。对于计算器来说更好的做法是捕获这个错误并提示用户“除数不能为零”而不是让程序崩溃或显示一个奇怪的结果。6.3 括号不匹配的检测括号不匹配有两种情况左括号多了或者右括号多了。调度场算法在扫描结束后检查栈里是否还有左括号如果有就说明左括号多了。但右括号多了的情况需要在扫描过程中检测遇到右括号时如果栈为空或者栈顶不是左括号就说明右括号多了。递归下降解析则在expect())失败时直接报错。6.4 大数运算的溢出问题如果计算器需要支持非常大的整数比如2^1000普通浮点数会溢出。Python 的整数是任意精度的不会溢出但 JavaScript 的Number类型最大只能表示2^53 - 1。如果要在 JavaScript 里做大数运算需要用BigInt或者第三方库。这一点在选型时就要考虑清楚。7. 从能算到好用性能优化与扩展方向7.1 缓存 token 序列如果同一个表达式需要反复求值比如在表格软件里每一行都用同一个公式计算那么可以把词法分析和语法分析的结果缓存起来。这样每次求值只需要执行后缀表达式求值或递归下降的求值部分省去了重复的解析开销。7.2 支持变量和自定义函数在计算器基础上加变量支持需要在词法分析阶段识别标识符在求值阶段从环境里查找变量的值。自定义函数则需要把函数体保存下来调用时把参数绑定到环境里再求值。这些扩展会让计算器变成一个简易的表达式引擎适用于配置化场景。7.3 错误恢复与提示好的计算器不仅要在出错时提示还要尽量给出有用的建议。比如用户输入3 × 5可以提示“运算符 × 前面缺少操作数”。这需要在解析阶段记录更多的上下文信息虽然增加了复杂度但用户体验会好很多。我在实际项目里用过调度场算法和递归下降解析两种方案。调度场算法适合运算符固定的场景代码紧凑性能好递归下降解析适合需要频繁扩展的场景错误处理更友好。如果你只是做一个普通计算器调度场算法足够了如果你要做一个可配置的公式引擎递归下降解析会更合适。选型时想清楚你的扩展需求比盲目追求“最优算法”更重要。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →