背景
在求解K12数学应用题/运算题,生成运算/讲解步骤时,应用到多种化简表达式的方法(四则运算的分配律、交换律等),这里需要处理两个核心问题:
- 如何设计表达式化简逻辑(与表达式的数据结构强相关)
- 如何判断使用各化简方法的优先级
此文用以探索知名度、活跃度高的数学表达式解析库 math.js的实现
math.js 化简功能的设计与实现
src/function/algebra/simplify.js
表达式的数据结构
公认的数学表达式数据结构表示方式为树形结构(链表),通过将表达式解析为以二元运算符、数字、括号为节点存储(TODO: 此处需要添加一篇详解), 如math.js 解析字符串x + (y * 3)的树形结构为
import * as math from 'mathjs'
const expression = 'x + (y * 3)';
const node = math.parse(expression)
/*
peratorNode {
args:[
// SymbolNode
{name: 'x'},
// ParenthesisNode
{
content: {
args: [
// SymbolNode
{name: 'y'},
// ConstantNode
{value: 3}
],
fn: 'multiply',
implicit: false,
isPercentage: false,
op: '*'
}
}
],
comment:'',
fn:'add',
implicit:false,
isPercentage:false,
op:'+'
}
*/
默认化简规则
可以通过调用以下代码即可打印内置的默认规console.log(math.simplify.rules)
或者 直接查看src/function/algebra/simplify.js, 237行
可见,math.js以对象左式l 和 右式r的转换字符串表示转换 简化规则({l:'', r:''})
import * as math from "../src/index.js"
console.log(math.simplify.rules)
/*
simplify.rules = [
simplifyCore,
// { l: 'n+0', r: 'n' }, // simplifyCore
// { l: 'n^0', r: '1' }, // simplifyCore
// { l: '0*n', r: '0' }, // simplifyCore
// { l: 'n/n', r: '1'}, // simplifyCore
// { l: 'n^1', r: 'n' }, // simplifyCore
// { l: '+n1', r:'n1' }, // simplifyCore
// { l: 'n--n1', r:'n+n1' }, // simplifyCore
{ l: 'log(e)', r: '1' },
// temporary rules
{ l: 'n-n1', r: 'n+-n1' }, // temporarily replace 'subtract' so we can further flatten the 'add' operator
{ l: '-(c*v)', r: '(-c) * v' }, // make non-constant terms positive
{ l: '-v', r: '(-1) * v' },
{ l: 'n/n1^n2', r: 'n*n1^-n2' }, // temporarily replace 'divide' so we can further flatten the 'multiply' operator
{ l: 'n/n1', r: 'n*n1^-1' },
// expand nested exponentiation
{ l: '(n ^ n1) ^ n2', r: 'n ^ (n1 * n2)' },
// collect like factors
{ l: 'n*n', r: 'n^2' },
{ l: 'n * n^n1', r: 'n^(n1+1)' },
{ l: 'n^n1 * n^n2', r: 'n^(n1+n2)' },
// collect like terms
{ l: 'n+n', r: '2*n' },
{ l: 'n+-n', r: '0' },
{ l: 'n1*n2 + n2', r: '(n1+1)*n2' },
{ l: 'n1*n3 + n2*n3', r: '(n1+n2)*n3' },
// remove parenthesis in the case of negating a quantitiy
{ l: 'n1 + -1 * (n2 + n3)', r: 'n1 + -1 * n2 + -1 * n3' },
simplifyConstant,
{ l: '(-n)*n1', r: '-(n*n1)' }, // make factors positive (and undo 'make non-constant terms positive')
// ordering of constants
{ l: 'c+v', r: 'v+c', context: { add: { commutative: false } } },
{ l: 'v*c', r: 'c*v', context: { multiply: { commutative: false } } },
// undo temporary rules
// { l: '(-1) * n', r: '-n' }, // #811 added test which proved this is redundant
{ l: 'n+-n1', r: 'n-n1' }, // undo replace 'subtract'
{ l: 'n*(n1^-1)', r: 'n/n1' }, // undo replace 'divide'
{ l: 'n*n1^-n2', r: 'n/n1^n2' },
{ l: 'n1^-1', r: '1/n1' },
{ l: 'n*(n1/n2)', r: '(n*n1)/n2' }, // '*' before '/'
{ l: 'n-(n1+n2)', r: 'n-n1-n2' }, // '-' before '+'
// { l: '(n1/n2)/n3', r: 'n1/(n2*n3)' },
// { l: '(n*n1)/(n*n2)', r: 'n1/n2' },
{ l: '1*n', r: 'n' }, // this pattern can be produced by simplifyConstant
{ l: 'n1/(n2/n3)', r: '(n1*n3)/n2' }
]
*/
基本应用
直接应用math.simplify()函数即可,具体如下所示
const math = require('mathjs');
const expression = 'x-3(x-2)';
const result = math.simplify(expression);
console.log(result.toString()); // x - 3 * (x - 2)
增加自定义简化规则(结合律)
由默认化简规则可知,是没有提供结合律求解的,因此上文没能获得最简解,需要添加自定义的化简逻辑,具体如下所示
const math = require('mathjs');
const expression = 'x-3(x-2)';
const rules = [
{
l: 'n1*(n2+n3)',
r: 'n1*n2+n1*n3'
},
{
l: 'n1*(n2-n3)',
r: 'n1*n2-n1*n3'
}
];
const result = math.simplify(expression, math.simplify.rules.concat(rules));
console.log(result.toString()); // 6 - 2 * x
化简顺序的缺陷
如果把表达式改为 x-3(x+y-2),会发现输出为x - (3 * x + 3 * y) + 6,而默认规则里是有{ l: 'n1 + -1 * (n2 + n3)', r: 'n1 + -1 * n2 + -1 * n3' },为什么规则没有生效(代码逻辑看下文化简逻辑)?因为算式化简是通过for循环遍历rules规则的,则应用规则的顺序是固定的。当前表达式不重复遍历重复的规则。
其实也可以通过简单函数测例,从解析顺序推理:当输入为 x+y-2时,parse函数将表达式解析成了 (x+y)-2, 所以x-3(x+y-2)处理过程如下:
x - ( 3 * ( ( x + y ) - 2) )
x - ( 3 * ( x + y ) - 3 * 2 )
x - ( ( 3 * x + 3 * y ) - 6 )
x - ( 3 * x + 3 * y ) + 6
因此可以通过将新规则提前至新规则前,进行分配率操作:
const math = require('mathjs');
const expression = 'x+3(x+y-2)';
const rules = [
{ l: 'n1*(n2+n3)', r: 'n1*n2+n1*n3' },
{ l: 'n1*(n2-n3)', r: 'n1*n2-n1*n3' },
{ l: '(n1+n2)*n3', r: 'n1*n2+n1*n3' },
{ l: '(n1-n2)*n3', r: 'n1*n2-n1*n3' },
];
let newRules = rules.concat(math.simplify.rules);
let result = math.simplify(expression, newRules);
console.log(result.toString()); // (y + 4) * x - 6
按顺序化简,首先将x+3(x+y-2)进行分配律操作,而后在后续rules将(y * x + 4 * x - 6 合并为(y + 4) * x - 6。化简顺序和结果与rules遍历顺序强相关。
化简逻辑
可直接跳过,仅用于帮助理解其设计:
parse(expression)将表达式解析为树形结构 ->new simplify新建化简对象->generic.apply(fn, arguments)->'Node, Array, Map, Object': function (expr, rules, scope, options){}执行简化逻辑
(1) 其中rules = _buildRules(rules)同样将rules解析为以l和r区分的math.js树形结构。解析后的数据结构如下图所示:(2)最后通过
applyRule(res, rules[i])将化简规则应用进行节点替换
// 执行简化逻辑
// src/function/algebra/simplify.js, 208行
'Node, Array, Map, Object': function (expr, rules, scope, options) {
rules = _buildRules(rules) // (1)将化简规则rules解析为math.js的树形结构
let res = resolve(expr, scope)
res = removeParens(res)
const visited = {}
let str = res.toString({ parenthesis: 'all' })
while (!visited[str]) {
visited[str] = true
_lastsym = 0 // counter for placeholder symbols
for (let i = 0; i < rules.length; i++) {
if (typeof rules[i] === 'function') {
res = rules[i](res, options)
} else {
flatten(res)
res = applyRule(res, rules[i]) // (2)应用化简规则
}
unflattenl(res) // using left-heavy binary tree here since custom rule functions may expect it
}
str = res.toString({ parenthesis: 'all' })
}
return res
}
})
// 应用化简规则
// src/function/algebra/simplify.js, 416行
const applyRule = typed('applyRule', {})
结论
通过以上的测例、代码分析可知,mathjs的化简步骤是按照化简规则遍历顺序执行的,以结果为导向。如果要获取详细的化简、求解过程,需要动态的选择规则进行匹配(applyRule)。
若应用mathjs用于获取详细的求解步骤,需要设计实现更详细的规则选取逻辑(如同时出现分配律结合律的情况应如何做选择?)