Mathjs表达式化简模块探索记录
背景
在求解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用于获取详细的求解步骤,需要设计实现更详细的规则选取逻辑(如同时出现分配律结合律的情况应如何做选择?)
作者:了凡一生
链接:https://juejin.cn/post/7023292805502435358