资讯详情 数学表达式解析入门:符号、术语、优先级与语法树
📅 2026/10/31 8:14:28
写表达式解析这块我大概栽过三次跟头每一次都跟“符号”和“术语”有关。第一次是把负号和减号当成同一个东西结果1-2和1*-2在扫描器里走了两条完全不同的分支解析出来的结果差了十万八千里第二次是没想清楚幂运算到底是左结合还是右结合2^3^2算出了 64被自己对了一遍才发现应该是 512第三次最冤用户从中文输入法里粘进来的表达式带着全角括号和全角减号报错信息指向的位置偏移了两个字符查了两个小时才发现是字符宽度的问题。这三件事让我彻底明白一个道理数学表达式这个东西看着是小学数学真动手做起来绕不开的就是那套符号体系和术语体系。你如果只把它当字符串处理后面一定会被优先级、结合性、隐式乘法、一元与二元的歧义反复折磨。反过来如果你在第一版就把这套术语梳理清楚后面写词法分析、语法分析、求值器的时候很多坑是自动消失的。这篇内容讲的就是这套基础数学表达式里到底有哪些基本符号每个术语的准确含义是什么它们之间的从属关系怎么划分以及这些概念落到代码里会变成什么样的数据结构。它适合三类人看正在写计算器或公式引擎的开发者、需要给业务系统加表达式配置能力的人、以及想把自己脑子里的数学名词重新对齐一遍的从业者。哪怕你只是想搞懂“为什么有些软件里-2^2等于 -4 而另一些等于 4”这里也能找到答案。1. 先把符号和术语抠清楚再谈解析1.1 三个踩过的坑说到底都是术语没对齐第一个坑我在开头提过减号和负号在视觉上几乎一样但在语法上他们是两个不同的角色。中缀位置的-是减法运算符需要左右两个操作数前缀位置的-是取负运算符只需要右边一个操作数。这个区别听起来像是咬文嚼字但你在写解析器的时候如果不用两套处理逻辑遇到3 - -2这种写法就会直接崩掉。更麻烦的是3--2中间没有空格扫描器如果按最短匹配切可能切成3、-、-、2四个记号也可能有实现把它当成自减运算符的两个字符具体怎么切取决于你事先定好的规则。第二个坑是结合性。2^3^2到底怎么算取决于你把幂运算定义成左结合还是右结合。数学书写习惯是右结合也就是先算最上面那个指数结果是 2 的 9 次方等于 512。但如果你用了一个默认左结合的解析器框架就会算成 8 的 2 次方等于 64。这种错误不会报错只会悄悄给你一个错的答案比崩溃更可怕。第三个坑是字符层面的。全角括号、全角逗号、全角减号、中文省略号、不间断空格这些字符在视觉上和半角符号几乎没有区别但它们对应的码点完全不同。扫描器如果不做归一化处理报错位置会偏移正则匹配会失败而且日志里打印出来肉眼看不出来。后来我养成的习惯是进入扫描环节之前先把整个字符串做一遍字符归一化把全角的符号统一映射成半角把各种奇怪的空格统一成普通空格这一步花不了二十行代码能省掉后面无数的排查时间。注意归一化一定要在记录原始位置之前做或者同时保留原始索引和归一化索引的映射关系否则用户看到的报错位置会对不上他输入的字符。1.2 这套术语体系到底解决了什么问题很多人觉得术语是学术圈的事情实际开发用不着。我的体会正好相反术语是团队沟通的压缩包也是你自己思考的脚手架。当你说“这个表达式的一元运算符优先级不对”的时候你脑子里其实同时装着三件事——哪个符号是一元的、优先级应该排在哪里、一元和二元怎么区分。这三件事只要有一样模糊改动就会变成试错。一套完整清晰的表达式术语体系至少能帮你解决四个具体问题。第一是职责划分扫描环节只关心符号怎么切分解析环节只关心结构怎么组装求值环节只关心数字怎么算三个环节的术语不能混着用。第二是规则表达优先级和结合性这两件事用表格一列比写在代码里的一堆 if-else 清楚十倍改起来也安全。第三是错误诊断用户输入1 * 2你能精确告诉他“第 5 个字符位置的乘号缺少左操作数”而不是笼统地报一句“表达式格式错误”。第四是跨语言对齐不同实现之间的行为差异往往就是因为对同一个术语的理解不同先把术语统一了再看差异就一目了然。我见过一些项目前期图省事把整个表达式当成一个正则来处理简单的ab*c能过业务一复杂就崩。后来推倒重来的时候光是把“操作数、运算符、元数、优先级、结合性、括号、字面量”这几个词定义清楚团队内部的争论就少了一大半。2. 表达式的构成操作数、运算符与元数2.1 操作数的四种来源各有各的坑操作数是表达式里被运算的对象也叫运算对象。它看起来是最简单的部分其实分类相当细致我是按来源把它分成四种。第一种是字面量也就是直接写死的值比如42、3.14、1e-3、true。字面量的麻烦在于格式整数和浮点的边界、有没有前导零、能不能写.5这种省略整数部分的形式、科学计数法里的e和自然常数e会不会冲突。2e到底是“2 乘以自然常数”还是“科学计数法写了一半”这个歧义必须在词法层面定死规则常见做法是科学计数法后面必须紧跟数字否则就把e当成标识符。第二种是变量或标识符比如x、price、单价。这里涉及命名规则能不能用中文、能不能以数字开头、长度限制、大小写敏感、和关键字冲突怎么处理。我倾向于把标识符规则放宽一点允许中文和下划线但必须和运算符严格分开不能出现a×b这种把乘号写成中文符号还指望被识别成乘法的写法。第三种是子表达式也就是被括号包起来的一整段比如(ab)。它在语法树上是一个整体对外表现为一个操作数但内部还有自己的结构。这个“对外是一个、对内是一群”的双重身份是括号处理的核心。第四种是函数调用结果比如sin(x)、max(a,b)。函数调用本身是一个复合结构函数名加参数列表。很多人在实现的时候把函数调用当成运算符来处理这在某些框架里行得通但会让优先级表变得很别扭我更推荐把它当成一个独立的一元结构括号是它的固定组成部分不是优先级工具。实操心得给每一种操作数都定义清楚“合法字符集”和“终止条件”扫描器写起来会顺畅很多。字面量遇到非数字非小数点就停标识符遇到非字母数字下划线就停括号单独成记号。2.2 一元、二元、三元元数决定了什么元数指的是一个运算符需要几个操作数这个词在中文语境里不太常用但它非常关键。按元数分运算符主要有一元、二元、三元三类。一元运算符只吃一个操作数位置可以在前面也可以在后面。前缀一元的有取负-、取正、逻辑非!、按位取反~后缀一元的有阶乘!、百分号%。注意-和!在这里同时出现在前缀和后缀列表里这不是笔误同一个符号在不同位置承担不同角色这是表达式语法里非常普遍的现像。二元运算符是最常见的加减乘除、取模、幂、比较、逻辑与或都是左右各一个操作数。二元运算符是优先级表的主体绝大多数的规则冲突都发生在这一层。三元运算符在数学表达式里很少见在编程语言里比较典型的是条件运算符形式是“条件 ? 值一 : 值二”。它的结合性通常是右结合而且中间那个部分允许出现逗号之类的分隔符处理起来比二元麻烦不少。如果你的业务场景里不需要完全可以不支持但如果支持了就要在优先级表里给它留一个很低的位置一般只比赋值高一点。元数这个概念最大的价值在于消除歧义。同一个-字符在a-b里是二元的在-b里是一元的在a*-b里又变成一元。判断依据是它左边有没有合法的操作数结尾如果左边是数字、标识符、右括号那它就是二元的如果左边是运算符、左括号、或者什么都没有那它就是一元前缀。这个判断规则要写死在解析器里不能靠猜。2.3 中缀、前缀、后缀三种书写方式同一棵表达式树有三种线性写法这个知识点叫表示法或者记法。中缀表示法是我们日常书写的形态运算符夹在操作数中间a b。它的优点是符合人类直觉缺点是必须依赖优先级和括号才能唯一确定结构机器读起来最费劲。前缀表示法也叫波兰表示法运算符写在操作数前面 a b。它的好处是完全不需要括号也不需要优先级从左往右扫一遍就能唯一还原结构。缺点是人不习惯尤其是嵌套深的时候眼睛跟不上。后缀表示法也叫逆波兰表示法运算符写在操作数后面a b 。它最大的优点是求值极其简单维护一个栈遇到操作数就压栈遇到运算符就弹出对应数量的操作数、算完再压回去扫完整个序列栈里就剩一个值。老式计算器和很多虚拟机指令集就是这套思路。我在实际项目里的做法是内部用树输入输出用中缀中间可以借用后缀做一次转换验证。因为后缀序列长度固定、结构无歧义非常适合用来写单元测试——你手算一个表达式的中缀和后缀两边对不上就说明解析器有问题。这是个很土但很有效的验证手段我在排查优先级问题时用过很多次。3. 数学侧的基础术语别在名词上翻车3.1 项、因子、系数、指数四个词别混用表达式解析做久了会频繁和数学术语打交道有几个词特别容易混而它们混用的后果是沟通成本直线上升。项通常指加减法切开的一段。3x 2y - 5里有三个项3x、2y、-5。项是加法的操作数这个理解在很多场景下够用。但要注意严格意义上的“项”在不同教材里定义略有出入有的把符号也包含进去有的不包含。做工程的时候不必纠结学术定义只要团队内部统一就行。因子是乘法切开的一段。3xy里有三个因子3、x、y。因子是乘法的操作数。这个区分特别有用因为你在做化简或者提取公因式的时候操作的对象就是因子不是项。系数是因子里的那个常数部分。3x的系数是 3-2ab的系数是 -2。系数可以是分数、可以是无理数也可以是 1通常省略不写。这里有个小坑省略系数的时候x的系数是 1 而不是 0但-x的系数是 -1符号属于系数不属于变量。这个细节在做符号计算的时候会直接影响结果。指数是幂运算里右上角那个数也叫幂次。x^3里 3 是指数底数是 x。指数的取值范围直接决定了运算能不能在实数域内完成负指数是倒数分数指数是开方零指数是 1底数非零时。这些规则在数值求值时必须逐一处理不能只写一个pow()就完事因为pow(-8, 1/3)在浮点下会返回一个非数而数学上它的实根是 -2。3.2 表达式、方程、恒等式边界在哪里这三个词在日常交流里经常被混着用但在系统设计里必须分开。表达式是一段可以求值的符号组合它没有等号本身不构成判断只产生一个值。2x 3是表达式你给它一个 x它给你一个结果。方程是含等号的陈述它表达的是“左边等于右边”这个断言需要求解的是使断言成立的那个未知数的值。2x 3 7是方程解是 x 2。恒等式是无论变量取什么值都成立的等式比如(ab)^2 a^2 2ab b^2。它不需要求解它的用途是变形和化简。为什么要分这么细因为这三类东西在你的系统里对应三种完全不同的处理流程表达式走求值方程走求解器恒等式走化简或重写引擎。如果一开始没分开很可能出现用户输入一个带等号的式子被当成表达式去求值然后报一个莫名其妙的错。还有一类叫不等式含大于、小于、大于等于、小于等于这些符号处理起来又是另一套逻辑求解得到的是一个区间而不是一个点。如果你的系统要支持建议单开一条分支别和等式混在一起。3.3 求值、化简、展开是三个不同的动作求值是把变量替换成具体数值然后算出唯一结果。x2时2x3求值得 7。求值的确定性最强也最好测试。化简是不改变表达式语义的前提下把它变成更短或更标准的形式。2x 3x化简成5x。化简的难点在于“更短”没有唯一标准不同工具的化简结果可能不一样而且化简的规则如果写得不严谨很容易出错。展开是把乘积形式变成和的形式(ab)(a-b)展开成a^2 - b^2。展开通常是机械的规则明确但项数会爆炸式增长(ab)^10展开有 11 项(abc)^10就更夸张需要做项数上限保护。我在项目里踩过的坑是把化简和求值混在一个函数里先化简再求值结果化简规则里有个 bug把x - x化简成了x导致所有减法结果都错了。后来把三个阶段彻底拆开每个阶段单独测试问题立刻暴露。这个教训很朴素但确实是我用一天时间换来的。4. 优先级与结合性看不见的交通规则4.1 优先级表怎么定为什么不同工具不一样优先级决定了在没有括号的情况下哪个运算符先算。这张表是表达式系统的核心配置也是最容易产生分歧的地方。主流的约定大致是这样括号最高函数调用次之一元运算符再往下然后是幂、乘除模、加减、比较、逻辑运算。但具体到每一位不同工具的处理并不一致尤其是幂运算和一元负号的相对位置。以-2^2为例数学上的通行理解是先算幂再取负结果是 -4因为负号在这里是一个作用于整体的一元运算符它的作用范围是2^2这个整体。很多编程语言也遵循这个约定。但也有一些计算器把它算成 4因为它的实现是先取负再平方。这两种做法在没有明确文档的情况下都是“说得通”的但用在同一个系统里就会变成 bug。我的建议是优先级表必须写进文档并且配上一组固定的测试用例。这组用例不多十几个就够覆盖每一个运算符和它的相邻关系。每次改动优先级配置跑一遍这组用例能挡住绝大多数回归问题。优先级层级运算符类别典型符号常见处理1最高分组圆括号、函数调用括号强制改变顺序2后缀一元阶乘、百分号左结合绑定最紧3幂运算乘方符号右结合优先级高于乘除4前缀一元取负、取正高于乘除低于幂5乘除模乘、除、取余左结合6加减加、减左结合7比较大于、小于、等于左结合一般不串联8最低逻辑与、或、非非为前缀与或为左结合4.2 结合性最容易被忽略的那一列结合性解决的是同一优先级的运算符连着出现时先算哪一边。左结合是先算左边右结合是先算右边。加减乘除都是左结合10 - 3 - 2是(10-3)-2 5不是10-(3-2) 9。除法也是16 / 4 / 2是 2不是 8。这一条大部分人凭直觉就能做对因为和阅读习惯一致。幂运算是右结合2^3^2是2^(3^2) 512。这一条很多人凭直觉会做错因为从左往右读的惯性太强。我在写第一版解析器的时候就栽在这里测试用例里恰好没有连续幂上线之后被用户发现尴尬得要命。函数式的一些运算符也是右结合比如逻辑蕴含、某些赋值符号。判断标准通常是这个运算满不满足结合律。减法不满足所以左结合是约定右结合会得到不同结果幂运算在数学上通常按右上角优先理解所以右结合。在代码里表达结合性的方式很直接如果用 Pratt 解析器左结合的运算符右绑定力比左绑定力大 1右结合的运算符左右绑定力相等。就这么一个数字的差别决定了整个表达式的结构。4.3 括号的多重身份别只用一种逻辑处理括号在表达式里有好几种用途虽然长得一样语义却不同处理的时候要分开看。第一种是分组括号纯粹用来改变运算顺序(ab)*c。这种括号在语法树上不产生节点它只是把一棵子树包起来告诉解析器“先算这里”。第二种是函数调用括号sin(x)里的括号是函数语法的一部分不能省略也不能随意嵌套。它和函数名绑定在一起构成一个完整的调用结构。第三种是参数分隔多参数函数里的逗号max(a, b)。逗号本身是一个低优先级的分隔符它不能出现在普通表达式里只能出现在参数列表或某些特定结构中。第四种在某些场景下是区间或集合表示方括号、花括号含义和圆括号完全不同。如果你的系统需要支持必须在词法层就把它们区分成不同的记号类型不能统一成“括号”。我遇到过一个真实的坑早期实现对括号只做了一个计数器遇到左括号加一、右括号减一最后检查计数器是否归零。这个做法能查出括号不配对但查不出配对错误)(这种写法计数器会先减到负数再回到零如果没做负数检查就会漏过去。正确做法是用栈遇到左括号压栈遇到右括号弹栈并检查类型是否匹配。这个改动只有几行但能挡住一类很隐蔽的错误。注意括号匹配错误要在扫描或解析阶段尽早报出并且带上准确的位置。等到求值阶段才发现括号问题错误信息会非常难定位。5. 把术语落成代码Token 与语法树5.1 记号类型设计一张表定死所有符号从术语到代码第一步是把字符串切成记号也就是词法单元。这一步的关键是先把所有可能的记号类型列全不要边写边加。记号类型说明示例携带信息数字整数、小数、科学计数法42、3.14、1e-3文本、解析后的数值标识符变量名、函数名、常量名x、price、sin文本运算符各类运算符号、*、^、!文本、元数标记左括号分组或调用开始(位置右括号分组或调用结束)位置逗号参数分隔,位置结束符输入结束标记无位置这里有两个设计决策值得展开。第一个是运算符要不要在词法阶段就区分一元和二元。我的做法是不区分扫描器只管切字符一元还是二元留给解析器根据上下文判断。原因是词法阶段看不到足够的上下文-前面是空格还是数字判断起来容易出错不如把这件事推给解析器那里信息最全。第二个是数字的解析放在哪一步。我倾向于在词法阶段就把文本转成数值并且在转换失败时立刻报错这样错误位置最精确。如果拖到求值阶段再转错误位置就只能定位到整条表达式。5.2 扫描器实现最长匹配和几个必须处理的边界扫描器的核心规则是最长匹配在当前位置尽可能匹配最长的记号。因为**和*是两个不同的运算符和也是如果按最短匹配a**b会被切成a、*、*、b语义完全变了。from dataclasses import dataclass dataclass class Token: kind: str # NUM / IDENT / OP / LPAREN / RPAREN / COMMA / END text: str pos: int value: object None # 多字符运算符必须排在单字符前面保证最长匹配 OP_LIST [ **, //, , , , !, , ||, , -, *, /, %, ^, !, , , ] FULLWIDTH_MAP { : (, : ), : ,, : , : -, : *, : /, : , } def normalize(src: str) - str: return .join(FULLWIDTH_MAP.get(ch, ch) for ch in src)这段代码里有两个细节。OP_LIST的顺序是有讲究的双字符的必须排在单字符前面否则**永远匹配不到。normalize那一步看起来可有可无但实际项目里用户从各种地方复制粘贴过来的文本全角符号的比例高得惊人不做这一步报错信息会让人一头雾水。扫描主循环的逻辑很直白跳过空白判断当前字符类别数字走数字分支字母或下划线走标识符分支剩下的交给运算符匹配。每个分支都要保证指针一定前进否则遇到无法识别的字符会死循环。这个死循环我见过不止一次通常是因为某个分支忘了推进位置。数字扫描的边界也要写清楚。.开头允许还是不允许1.后面没数字算不算合法科学计数法的e后面必须跟数字还是可以跟正负号这些规则要在文档里写死。我的建议是规则可以宽松但必须有明确文档并且配单元测试别让不同开发者凭感觉实现。5.3 用 Pratt 解析器把优先级变成两个数字把记号组装成语法树这一步可选的算法不少递归下降、调度场、Pratt各有适用场景。我个人最常用的是Pratt 解析器原因很简单它把优先级和结合性表达成两个整数改规则的时候只需要改数字不用动结构。核心思路是这样每个运算符有一个左绑定力和一个右绑定力。解析时维护一个“最小绑定力”的下限遇到绑定力低于下限的运算符就停下来把控制权交还给上层。左结合的运算符左绑定力等于自身优先级右绑定力等于优先级加一右结合的运算符两个绑定力都等于优先级。# 绑定力配置(左绑定力, 右绑定力) INFIX_BP { ||: (1, 2), : (3, 4), : (5, 6), !: (5, 6), : (7, 8), : (7, 8), : (7, 8), : (7, 8), : (10, 11), -: (10, 11), *: (20, 21), /: (20, 21), %: (20, 21), //: (20, 21), ^: (30, 30), # 右结合左右相等 } PREFIX_BP {-: 25, : 25, !: 25} POSTFIX_BP {!: 40}这里最关键的一行是PREFIX_BP里一元负号取 25。这个数值不是随便定的它必须满足两个约束。第一个约束-2^2要解析成-(2^2)也就是说幂运算必须比一元负号先结合。幂的左绑定力是 30大于 25所以在解析负号的操作数时遇到幂运算符会继续往上结合得到-(2^2)符合数学约定。第二个约束-2*3要解析成(-2)*3也就是说一元负号必须比乘号先结合。乘号的左绑定力是 20小于 25所以解析负号的操作数时遇到乘号会停下来负号先和 2 结合得到(-2)*3结果是 -6符合直觉。如果把一元负号设成 35第一个约束就被破坏了-2^2会变成(-2)^2 4如果设成 15第二个约束就被破坏了-2*3会变成-(2*3) -6虽然这次答案一样但换成-23就会出错。25 这个位置是唯一能同时满足两条约束的区间取 21 到 29 之间的任何值都可以我习惯用 25 是因为它在中间留有余地。后置一元运算符的绑定力给到 40比幂还高因为阶乘和百分号在数学上都是紧密绑定到前面那个数的3!^2应该是(3!)^2而不是3!^(2)之外的什么结构。实操心得绑定力数字不必和优先级层级的序号对应只要保持相对顺序正确就行。我习惯按 10、20、30 这样留间隔将来要在中间插一个新运算符不用把所有数字都改一遍。语法树的节点设计也有讲究。我通常分四类节点字面量节点、变量节点、二元运算节点、一元运算节点函数调用单独一类。每个节点都记录原始位置这样求值出错的时候能精准定位到源码的哪一段。这个位置信息在开发阶段用处不大但在线上环境排查用户反馈的问题时价值极高。6. 常见问题与排查实录6.1 问题速查表先按症状找原因症状可能原因排查动作解决方式报错位置偏移几个字符全角字符或宽字符未归一化打印原始字符码点入参先做归一化并维护索引映射3--2结果不对连续减号被误判打印记号流按左操作数判断一元还是二元2^3^2得到 64幂运算配成左结合检查绑定力表左右绑定力改为相等-2^2得到 4一元负号绑定力过高检查前缀绑定力调到低于幂、高于乘除1e-3解析失败科学计数法规则未实现单独测数字扫描明确e后必须跟数字或符号加数字sin(x)报错函数调用未识别检查标识符后是否跟左括号加函数调用分支表达式为空时不报错未检查结束符打印首个记号首个记号即结束符则直接返回空树小数点开头报错.5规则未定义补充数字扫描分支按约定允许或给出明确提示中文字符变量名失败标识符字符集过窄检查字符判断函数放宽到字母、数字、下划线、中文6.2 我的排查流程以及几个土办法遇到表达式问题我的第一反应永远是打印记号流。把输入字符串切成的每一个记号连同类打印出来一眼就能看出是切分错了还是解析错了。这个动作花十秒钟能省掉一半的猜测。如果记号流是对的第二步打印语法树的缩进形式。不用图形化工具就用缩进打印比如这样Binary() Literal(1) Binary(*) Literal(2) Unary(-) Literal(3)这个结构一出来优先级的问题基本无处遁形。如果树的结构和你想的不一样那就是绑定力配置的问题改数字就行。如果树的结构对但结果不对问题就在求值器这时候再去查具体的运算实现。第三个土办法是手算后缀序列做对照。把中缀表达式转换成后缀形式写在纸上然后手动模拟栈的变化过程一行一行核对。这个方法看起来笨但它在没有调试器的情况下特别好用尤其适合排查结合性和括号相关的错误。我到现在还保留着这个习惯遇到复杂表达式的时候先用纸笔过一遍脑子和代码就能对上。还有一个经验值得说把错误信息写详细比写优雅更重要。报错的时候带上位置、带上期望的记号类型、带上实际遇到的字符用户拿到信息能自己判断问题出在哪。我见过太多系统只报一句“表达式非法”用户完全不知道从哪改起最后全变成工单打到技术这边。多写几行格式化代码能省掉大量沟通成本。最后分享一个我在实际项目里养成的习惯新建表达式功能模块的时候先写二十条测试用例再写实现。这二十条覆盖所有运算符、所有边界、所有已知的坑包括全角符号、连续符号、空输入、超长嵌套。这些用例写好之后后面无论怎么重构跑一遍就知道有没有踩回原来的坑。这个习惯看起来是额外工作实际上是我做过的性价比最高的投入之一。