SymPy 高级表达式操作指南:深入表达式树、递归遍历与求值控制
发布时间:2026/9/15 20:26:27 作者:尧图编辑部 阅读量:1,286

SymPy 高级表达式操作指南深入表达式树、递归遍历与求值控制【免费下载链接】sympyA computer algebra system written in pure Python项目地址: https://gitcode.com/GitHub_Trending/sy/sympy导读SymPy 中每一个数学表达式都不是扁平的对象而是一棵由各种节点构成的树加法、乘法、幂、符号与整数分别对应Add、Mul、Pow、Symbol、Integer等类。本篇文章以 SymPy 官方入门教程doc/src/tutorials/intro-tutorial/manipulation.rst为核心结合仓库源码系统讲解如何用srepr查看表达式的内部结构、如何利用func与args递归遍历并重建表达式树以及如何用evaluateFalse与UnevaluatedExpr精确控制表达式求值。读完本文你将掌握阅读表达式树—遍历表达式树—构造不受自动求值干扰的表达式这一整套高级操作能力无论是调试化简算法还是实现自定义符号操作都能游刃有余。理解表达式树一切表达式的底层形态在深入高级操作之前必须先回答一个根本问题SymPy 内部是如何表示一个数学表达式的答案是一棵树。以表达式x**2 x*y为例。从打印输出看它只是一个普通的多项式但内部结构完全不同。SymPy 提供了sreprstring representation字符串表示来展示表达式的真实内部形态 from sympy import * x, y, z symbols(x y z) expr x**2 x*y srepr(expr) Add(Pow(Symbol(x), Integer(2)), Mul(Symbol(x), Symbol(y)))srepr的实现位于 sympy/printing/repr.py它通过ReprPrinter打印出表达式最原始的构造形式。从输出可以看到x**2 x*y实际上是一个Add节点它有两个子节点Pow(Symbol(x), Integer(2))即x**2和Mul(Symbol(x), Symbol(y))即x*y。用树形图可以更直观地拆解这一结构该图由dotprint(x**2 x*y, labelfuncsrepr)生成dotprint定义于 sympy/printing/dot.py可将表达式树输出为 Graphviz DOT 描述Add / \ Pow Mul / \ / \ Symbol Integer Symbol Symbol x 2 x y根节点Add有两条分支(0,)和(1,)Pow的子节点是(0, 0)、(0, 1)以此类推。这种父节点 位置索引的编号方式正是递归遍历时定位每个子树的依据。叶子节点Symbol 与 Integer先看树的叶子。符号是Symbol类的实例。前面一直使用x symbols(x)其实也可以直接写 x Symbol(x)两者得到的结果相同都是一个名为x的Symbol。二者的区别在于symbols会自动把字符串按空白拆分成多个Symbol如symbols(x y z)返回三个符号的元组而Symbol(x y z)只创建一个名为x y z的单一符号。表达式中的数字2在内部是Integer(2)。Integer是 SymPy 的整数类定义于 sympy/core/numbers.py与 Python 内置类型int类似但能与 SymPy 其他类型无缝协作例如整数与符号混合运算、精确有理数运算等。分支节点Pow、Mul、AddPow当我们写x**2时实际上创建了一个Pow对象。Pow是 power幂的缩写 srepr(x**2) Pow(Symbol(x), Integer(2)) Pow(x, 2) # 直接构造得到相同对象 x**2注意srepr输出里出现的是Integer(2)——尽管输入时写的是 Python 的int类型的2。原因在于任何 SymPy 对象与非 SymPy 对象通过运算或函数结合时非 SymPy 对象都会被自动转换为 SymPy 对象这个转换函数就是sympify type(2) ... int type(sympify(2)) class sympy.core.numbers.Integer严格来说运算内部调用的是_sympify它与公开的sympify的区别是不接受字符串因此x 2是不被允许的。Mulx*y是x与y的乘法对应的类是Mul srepr(x*y) Mul(Symbol(x), Symbol(y)) Mul(x, y) x*yAddx**2 x*y是上面两个对象的加法对应的类是Add Add(Pow(x, 2), Mul(x, y)) x**2 x*y由此得到一个重要结论SymPy 的表达式树正是由Add、Mul、Pow、Symbol、Integer等节点嵌套组合而成的。任何你用运算符写出的表达式都可以用对应的类构造器显式重建。复杂表达式剖析sin(x*y)/2 - x**2 1/y表达式树可以有很多分支也可以很深或很宽。来看一个更复杂的例子 expr sin(x*y)/2 - x**2 1/y srepr(expr) Add(Pow(Symbol(y), Integer(-1)), Mul(Rational(1, 2), sin(Mul(Symbol(x), Symbol(y)))), Mul(Integer(-1), Pow(Symbol(x), Integer(2))))其树形结构dotprint(sin(x*y)/2 - x**2 1/y, labelfuncsrepr)生成如下Add ┌────────────┼────────────────┐ Pow(y, -1) Mul(1/2, sin(x*y)) Mul(-1, Pow(x, 2)) / \ / \ / \ Symbol Integer Rational sin Integer Pow y -1 1/2 | -1 / \ Mul Symbol Integer / \ x 2 Symbol Symbol x y这个表达式揭示了 SymPy 表达式树的几个关键设计逐一说明。1. 没有减法类x - y是x (-1)*y从树中可以看到-x**2被表示为Mul(-1, Pow(x, 2))。SymPy 中没有减法Sub类。x - y被表示为x -y更完整地说是x (-1)*y即Add(x, Mul(-1, y)) srepr(x - y) Add(Symbol(x), Mul(Integer(-1), Symbol(y)))2. 没有除法类1/y是Pow(y, -1)x/y是x*y**-1与减法类似SymPy 中也没有除法Div类。除法通过 -1 次幂来表示所以1/y就是Pow(y, -1)。如果分子不是 1 呢 expr x/y srepr(expr) Mul(Symbol(x), Pow(Symbol(y), Integer(-1)))即x/y等价于x*y**-1也就是Mul(x, Pow(y, -1))。任何a/b都会被规范化为a*b**-1。3. 有理数被合并进单个系数sin(x*y)/2是Mul(Rational(1, 2), sin(x*y))按照上面的除法规则你也许会预期sin(x*y)/2表示为Mul(sin(x*y), Pow(Integer(2), -1))。但实际上它的表示是Mul(Rational(1, 2), sin(x*y))。这是因为Rational有理数在乘法中总是被合并成一个单独的系数项所以除以 2在内部体现为乘以 1/2。这也是为什么在表达式树中你会看到Rational(1, 2)节点而不是Pow(Integer(2), -1)节点。4. 内部顺序与输入顺序无关1 x打印为x 1细心的读者可能早已注意到输入的书写顺序与srepr输出以及打印的顺序经常不同。例如 1 x x 1这是因为在 SymPy 中可交换运算Add和Mul的参数以任意但一致的顺序存储这个顺序与输入顺序无关。它基于一套专为排序唯一且高效而设计的标准如节点计数与default_sort_key不具有数学意义。如果你担心非交换乘法大可不必用Symbol(A, commutativeFalse)可以创建非交换符号非交换符号的乘法顺序会保持与输入一致。另外内部存储顺序与打印顺序也未必相同——这条经验对于调试表达式操作算法至关重要如果某个操作结果不符合预期大概率是内部表示与你脑中的假设不一致。递归遍历表达式树func 与 args了解了表达式树的结构后接下来看如何钻进这棵树。SymPy 的每个对象都有两个极其重要的属性func和args。它们定义于所有 SymPy 对象的基类 sympy/core/basic.py。func表达式的头func是对象的头部head。例如(x*y).func是Mul。通常它与对象的类相同但有两个例外值得注意。例外一创建类与实际类可以不同。请看 expr Add(x, x) expr.func class sympy.core.mul.Mul我们用Add构造得到的却是Mul再看expr本身 expr 2*x原来Add(x, x)即x x会被自动化简为Mul(2, x)即2*x而这是一个Mul对象。SymPy 类大量使用了__new__类构造器与__init__不同__new__允许构造函数返回一个不同的类因此Add的构造可以降级返回Mul。例外二某些类被特判singleton 单例化通常是出于效率考虑。例如 Integer(2).func class sympy.core.numbers.Integer Integer(0).func class sympy.core.numbers.Zero Integer(-1).func class sympy.core.numbers.NegativeOneInteger(0)的func是Zero类Integer(-1)的func是NegativeOne类。这些特殊类在 sympy/core/numbers.py 中通过metaclassSingleton实现Zero、One、NegativeOne、Half等都是单例——无论被调用多少次都只创建一个对象。这既节省空间例如Zero可能频繁出现在稀疏矩阵中NegativeOne在每次出现-x或1/x时都会用到也提升速度单例对象可以用is比较。每个单例类的唯一实例可通过全局的S对象访问如S.Zero、S.One、S.NegativeOne。不过大多数情况下这并不会造成困扰Zero、One、NegativeOne等特殊类都是Integer的子类只要用isinstance判断类型就不会出问题。args对象的顶层参数args是对象的顶层参数元组。(x*y).args是(x, y)。来看一个具体例子 expr 3*y**2*x expr.func class sympy.core.mul.Mul expr.args (3, x, y**2)可以看到expr Mul(3, y**2, x)。事实上任何表达式都可以仅凭func和args完全重建 expr.func(*expr.args) 3*x*y**2 expr expr.func(*expr.args) True回忆 Python 语法若a是元组则f(*a)表示把a的元素展开作为f的实参如f(*(1, 2, 3))等价于f(1, 2, 3)。注意虽然输入是3*y**2*xargs却是(3, x, y**2)。在Mul中有理数系数Rational 系数总是排在args最前面除此之外的顺序没有特殊规律——但一定是确定的。再验证一下 expr y**2*3*x expr.args (3, x, y**2)Mul的args是经过排序的因此相同的Mul一定得到相同的args只是排序依据是对排序唯一性、效率有利的标准与数学意义无关。向下钻取 args。expr.args是(3, x, Pow(y, 2))。如果想拿到Pow(y, 2)的args注意到y**2在expr.args的第三个位置索引 2 expr.args[2] y**2 expr.args[2].args (y, 2)那么再往深处呢y和2的args是什么 y.args () Integer(2).args ()两者都是空元组。在 SymPy 中空args标志着到达了表达式树的叶子节点。于是 SymPy 表达式只有两种可能要么args为空叶子节点要么有args分支节点且可由func与args完全重建。这构成了 SymPy 的核心不变量关键不变量Key Invariant每个良构的 SymPy 表达式要么args为空要么满足expr expr.func(*expr.args)。这个不变量是编写遍历表达式树、修改节点、重建新表达式这类算法的基石。遍历树递归先序与后序args的嵌套特性天然适合递归函数基线情形就是空args。下面这个函数会打印表达式每一层的所有args def pre(expr): ... print(expr) ... for arg in expr.args: ... pre(arg)()表示叶子这一特性让递归格外优雅——甚至不需要显式写基线分支for 循环自动处理了空元组。测试一下 expr x*y 1 pre(expr) x*y 1 1 x*y x y这就是经典的先序pre-order遍历先打印当前节点再递归进入每个子节点。你可以试着照葫芦画瓢写一个后序post-order遍历版本。由于这种遍历在 SymPy 中太常见了官方直接提供了生成器函数preorder_traversal与postorder_traversal实现在 sympy/core/traversal.py。上面的算法可以简化为 for arg in preorder_traversal(expr): ... print(arg) x*y 1 1 x*y x ypreorder_traversal是深度优先的先序迭代器先产出当前节点再按顺序深入所有子节点postorder_traversal则先产出所有子节点的遍历结果最后才产出节点本身。两者都支持可选的keys参数用于在遍历时对Basic对象的args排序传入True时使用节点计数与default_sort_key的默认排序传入自定义 key 时则按该 key 排序。需要交互式逐层浏览表达式结构时还可以使用sympy.interactive_traversal。防止表达式求值evaluateFalse 与 UnevaluatedExpr默认情况下SymPy 在构造表达式时就会自动求值如x x立即变成2*x。但很多场景——比如实现化简算法、格式化输出、调试——需要保留表达式的原始形态。SymPy 提供了两种通用手段。方法一构造时传入evaluateFalse在构造表达式时传入evaluateFalse可以阻止该次构造的自动化简 from sympy import Add from sympy.abc import x, y, z x x 2*x Add(x, x) 2*x Add(x, x, evaluateFalse) x x运算符重载如x x总是按evaluateTrue处理所以必须显式调用类构造器。如果你记不清某个运算符对应的类名可以用sympify从字符串构造同样支持evaluateFalse from sympy import sympify sympify(x x, evaluateFalse) x x注意evaluateFalse只阻止这一次构造时的求值并不阻止后续使用中的求值。一旦未求值表达式参与新的运算自动求值仍会发生 expr Add(x, x, evaluateFalse) expr x x expr x 3*x方法二UnevaluatedExpr——持久化的求值隔离墙要长期保持子表达式不被求值需要UnevaluatedExpr。它位于 sympy/core/expr.py是一个未求值表达式包装类被它包裹的内容不会与外部的表达式交互并化简。例如 from sympy import UnevaluatedExpr expr x UnevaluatedExpr(x) expr x x x expr 2*x x第二次运算结果2*x x中单独留下的那个x就是被UnevaluatedExpr包裹的x——它没有被合并进2*x。需要释放它时调用.doit() (x expr).doit() 3*xdoit的语义是深入执行未执行的操作在 expr.py 的实现中doit会递归取出被包裹参数并对其继续求值deepTrue时还会对参数内部递归doit。更多示例 from sympy import * from sympy.abc import x, y, z uexpr UnevaluatedExpr(S.One*5/7)*UnevaluatedExpr(S.One*3/4) uexpr (5/7)*(3/4) x*UnevaluatedExpr(1/x) x*1/x注意x*UnevaluatedExpr(1/x)保持为x*1/x而不会约分成1这正体现了隔离效果1/x与外面的x不会相互作用。使用限制一UnevaluatedExpr无法阻止作为参数传入的表达式的求值。也就是说它在包装之前参数本身已经按默认规则求值过了 expr1 UnevaluatedExpr(x x) expr1 2*x expr2 sympify(x x, evaluateFalse) expr2 x xUnevaluatedExpr(x x)得到的是2*x因为x x在传入前就已化简。而expr2虽然暂时保留为x x但它一旦参与别的表达式就会被求值。因此要同时阻止内部和外部两方面的求值需要两种方法组合使用 UnevaluatedExpr(sympify(x x, evaluateFalse)) y y (x x)先用sympify(..., evaluateFalse)挡住构造时的内部化简再用UnevaluatedExpr挡住与外部的交互最终y (x x)得以完整保留。与打印器的配合保留未求值形式的 LaTeX 输出UnevaluatedExpr得到了 SymPy 各打印器printers的完整支持可以用来按不同输出形式展示未化简结果。例如配合 LaTeX 打印 from sympy import latex uexpr UnevaluatedExpr(S.One*5/7)*UnevaluatedExpr(S.One*3/4) print(latex(uexpr)) \frac{5}{7} \cdot \frac{3}{4}而需要已求值的 LaTeX 形式时先.doit()再打印即可 print(latex(uexpr.doit())) \frac{15}{28}这一点在实际写文档、生成报告或教学材料时非常实用可以同时展示原始未化简形式与化简后结果两种 LaTeX。小结一张图记住表达式操作的三板斧看结构用srepr任何表达式的内部树形结构都可以通过srepr直接输出dotprint可以生成 Graphviz 图记住减法加负一、除法负一次幂、有理数系数合并进Mul这三条规则复杂表达式也能一眼拆穿。遍历用func/argsexpr.func(*expr.args) expr是关键不变量()空args表示叶子preorder_traversal/postorder_traversal让先序、后序遍历开箱即用。控求值用evaluateFalseUnevaluatedExpr前者只挡本次构造后者是持久隔离墙两者叠加可同时阻止内、外两方面的求值且与latex等打印器无缝协作。无论你是在实现表达式变换算法、排查化简结果不符合预期的 Bug还是需要输出保留原始形态的数学文档这套表达式树视角都是理解 SymPy 一切行为的钥匙。相关源码可继续深入阅读sympy/core/basic.pyfunc/args、sympy/core/numbers.pyInteger/Rational及单例类、sympy/core/traversal.py遍历器、sympy/printing/repr.pysrepr与 sympy/core/expr.pyUnevaluatedExpr。【免费下载链接】sympyA computer algebra system written in pure Python项目地址: https://gitcode.com/GitHub_Trending/sy/sympy创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考