2. 符号表达式的表示与基本操作
在符号计算系统中,一个数学表达式并不是以字符串形式保存的,而是被组织成一棵表达式树。理解这棵树的结构,是后续实现自动求导与化简的前提。本节围绕“符号对象如何表示、表达式如何构造、变量如何替换、树如何遍历”四个问题展开,并通过多项式与三角函数两个具体例子,说明符号对象的内部表示。
一、原子与复合表达式
表达式树中的节点分为两类。原子节点是不可再分的叶子,包括符号(如 x、y)、数值常量(如 2、3.5)和常量符号(如 pi、E)。复合节点由运算符和若干子表达式构成,例如加法、乘法、幂运算,以及函数调用 sin、cos、exp、log。每个复合节点至少持有一个运算符标识和一组有序子节点,子节点的顺序对减法、除法、幂这类非交换运算是有意义的。
以表达式 3*x^2 + 2*sin(x) 为例,它的根节点是加法,左子树是乘法 3 * (x^2),右子树是乘法 2 * sin(x);x^2 又是幂节点,底为符号 x、指数为常量 2;sin(x) 是函数节点,参数为符号 x。整棵树共有 9 个节点,其中叶子 5 个(3、x、2、2、x),复合节点 4 个。这种“运算符 + 子节点”的递归结构,使得任何复杂表达式都能用统一的数据结构描述。
二、符号创建与表达式构造
构造表达式通常从创建符号开始。以 Python 的 SymPy 为例,symbols('x y') 会返回两个独立的符号对象,它们带有名字、假设属性(如是否为正、是否为整数)等元数据。假设属性会影响后续化简与求导的合法性判断,例如声明 positive=True 后,sqrt(x**2) 才能安全化简为 x。
表达式构造有两种典型方式。第一种是运算符重载:x + 1、x**2、3*x 直接调用符号对象上定义的 __add__、__pow__、__mul__,返回新的表达式对象。第二种是函数式构造:Add(x, 1)、Pow(x, 2)、Mul(3, x)、sin(x),显式指定节点类型与子节点。前者书写自然,后者便于程序化生成。两种方式最终得到的是同一类不可变表达式对象——每次运算都产生新对象,原对象不被修改,这一点对后续的替换与遍历非常重要。
from sympy import symbols, sin, Add, Pow, Mul
x, y = symbols('x y', positive=True)
# 方式一:运算符重载
expr1 = 3*x**2 + 2*sin(x)
# 方式二:函数式构造,与上式等价
expr2 = Add(Mul(3, Pow(x, 2)), Mul(2, sin(x)))
print(expr1 == expr2) # True
print(expr1.args) # (2*sin(x), 3*x**2)
print(expr1.func) # Add
注意 args 返回的是子节点元组,func 返回节点类型。对于加法与乘法,SymPy 会自动展平嵌套并排序,因此 Add(a, Add(b, c)) 会变成 Add(a, b, c),子节点顺序也不一定与书写顺序一致。这是符号系统内部规范化的一部分,编写遍历代码时不能假设子节点顺序等于源码顺序。
三、变量替换
替换是符号操作中最常用的动作之一,核心接口是 subs。它接受“旧对象 → 新对象”的映射,在整棵表达式树上递归匹配并重建节点。替换 x → y+1 时,所有叶子 x 都被替换为子树 y+1,其余结构保持不变。
from sympy import symbols, sin, expand
x, y = symbols('x y')
expr = 3*x**2 + 2*sin(x)
r1 = expr.subs(x, y + 1) # 3*(y+1)**2 + 2*sin(y+1)
r2 = expr.subs(x, 2) # 14 + 2*sin(2)
r3 = expr.subs({x: y, y: 0}) # 同时替换多个符号
print(expand(r1)) # 3*y**2 + 6*y + 3 + 2*sin(y+1)
替换有两个容易踩坑的地方。其一,subs 默认只做一次遍历,不会对替换产生的新子树再次替换,因此 expr.subs(x, y).subs(y, x) 并不是交换变量,而是把 x 先变成 y、再把所有 y(包括原来的 y)变成 x。要交换变量应使用 subs({x: y, y: x}, simultaneous=True)。其二,替换是结构匹配而非数学等价匹配,expr.subs(x**2, t) 只会匹配字面上恰好为 x**2 的子树,不会匹配 x*x 或 x**3/x。
四、遍历表达式树
遍历是提取信息、实现自定义变换的基础。SymPy 提供 preorder_traversal 与 postorder_traversal 两种生成器:前者先访问父节点再访问子节点,适合自上而下收集结构;后者先访问子节点再访问父节点,适合自下而上做化简或求值。若只需枚举所有子表达式,可用 expr.atoms() 按类型筛选,例如 expr.atoms(Symbol) 得到全部符号,expr.atoms(Number) 得到全部数值常量。
from sympy import preorder_traversal, postorder_traversal, Symbol
expr = 3*x**2 + 2*sin(x)
for node in preorder_traversal(expr):
print(type(node).__name__, ':', node)
# 自底向上:先处理叶子,再处理父节点
for node in postorder_traversal(expr):
pass # 在此处对 node 做局部化简
print(expr.atoms(Symbol)) # {x}
一个实用的遍历练习是统计表达式树的节点数与深度。节点数等于遍历中访问的元素个数;深度可在递归下降时用“当前层数”维护最大值。对 3*x**2 + 2*sin(x),节点数为 9,深度为 4(根 → 乘法 → 幂 → 符号)。这两个指标在后续比较化简前后表达式规模时非常直观。
五、从内部表示看两个例子
多项式 p = x**3 - 2*x + 1 的内部表示是加法节点,子节点为 x**3、-2*x、1。调用 p.as_poly() 可得到以 x 为生成元的多项式对象,其系数列表为 [1, 0, -2, 1](按降幂排列),这揭示了“表达式树”与“系数向量”两种表示之间的转换关系。三角函数表达式 q = sin(x)**2 + cos(x)**2 则是加法节点下两个幂节点,每个幂节点的底是函数节点;调用 q.simplify() 或 trigsimp(q) 会利用恒等式把它化为 1,其本质是在树上做模式匹配与重写。
srepr(expr),它输出的是构造该表达式的精确函数调用形式,例如 Add(Mul(Integer(3), Pow(Symbol('x'), Integer(2))), Mul(Integer(2), sin(Symbol('x'))))。能读懂这行输出,就说明已经掌握了该表达式的树结构。