Mathjs表达式化简模块探索记录

1,511 阅读5分钟

背景

在求解K12数学应用题/运算题,生成运算/讲解步骤时,应用到多种化简表达式的方法(四则运算的分配律、交换律等),这里需要处理两个核心问题:

  1. 如何设计表达式化简逻辑(与表达式的数据结构强相关)
  2. 如何判断使用各化简方法的优先级

此文用以探索知名度、活跃度高的数学表达式解析库 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遍历顺序强相关。

化简逻辑

可直接跳过,仅用于帮助理解其设计:

  1. parse(expression) 将表达式解析为树形结构 ->
  2. new simplify 新建化简对象-> generic.apply(fn, arguments) ->
  3. 'Node, Array, Map, Object': function (expr, rules, scope, options){} 执行简化逻辑
    (1) 其中rules = _buildRules(rules) 同样将rules解析为以l和r区分的math.js树形结构。解析后的数据结构如下图所示: image.png (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用于获取详细的求解步骤,需要设计实现更详细的规则选取逻辑(如同时出现分配律结合律的情况应如何做选择?)