JS如何处理四则运算表达式,并按照正确的运算优先级输出结果

658 阅读5分钟

最近项目中遇到一个问题,当面对一个表达式: -1+(2-0.3)*-4/5-6 的时候,该如何输出其计算结果,四则运算,这不简简单单直接eval冲他妈的

let str = '1+2*(3-4)/5'
eval(str)  // 0.6

然后就发现,项目疯狂警告 : Use of eval is strongly discouraged.
好嘛 作为一个码怪(指用七行代码,输出了三千七百行报错的人),肯定不能用这么简单粗暴的方法搞定,但苦思冥想不明白该如何处理乘法除法和括号的优先级,最后还是去请求好歌们的帮助,在谷歌上发现,大家在说四则运算前,都会讲到另一个东西——波兰表达式

简述以下波兰式的作用:

  • 将我们平时使用的 (1+2)*3 (中缀式)

  • 转成另一种方式表达: * + 1 2 3(前缀式)

  • 或是另一种 逆波兰表达式 : 1 2 + 3 * (后缀式)

后缀式和调度场算法

前缀式和后缀式都能无歧义的表现运算顺序,还能不需要括号的参与,而为了方便在代码中计算,我们(他们)一般都用后缀式,因为都是从左到右的二元运算,而将中缀式转为后缀式,我们则需要用到一个叫做调度场算法的东西,它大概是这么个意思:

  1. 声名一个后缀式队列,用来存放后缀表达式,生成一个符号栈,用来存放运算符
  2. 分割中缀式,然后遍历它
  3. 遇到数字,直接入后缀式队列
  4. 如果遇到运算符,符号栈为空则直接入符号栈
  5. 如果遇到运算符,符号栈不为空,则对比当前符号和栈顶符号,若当前符号优先级与栈顶符号优先级平级或低级,则栈顶运算符入后缀式队列,直到当前运算符优先级高于栈顶符后,入符号栈
  6. 如果遇到左括号(,入符号栈,左括号后的继续照前面的规则走
  7. 如果遇到右括号),则将符号栈的内容一个个弹出加入后缀式队列,直到遇到(为止,弹出左括号,此时左括号只出栈不入队列
  8. 当遍历结束后,如果符号栈中存在元素,则全部出栈进入后缀式队列,此时便得到一个完成转换的后缀式数组。

下面式维基上大家都贴的一张图我不贴好像不太合群,很直观 image.png

芭芭拉没卵用,直接上代码

确保输入的字符串为正确的中缀表达式

// 为了防止别人输入错误的中缀式,先做一个检查
// 检查输入得表达式是否正确
const calcCheck = str => {
    // 去除 转换中文括号为英文括号
    // 去除所有空格 文字 和非运算符号
    // 将所有负数抓换为 (0-x)的方式,不然无法进行负数运算
    // 他妈的正则真好用
    return str
        .replace(/(/g, '(')
        .replace(/)/g, ')')
        .replace(/[^\d|\(|\)|\+|\-|\*|\|\./]*/g, '')
        .replace(/\D(\-\d+(\.{1}\d+)|\-\d)/g, text => `${text[0]}(0${text.substring(1)})`);
};

规定各运算符的运算规则和运算优先级

// 运算符号 加减乘车 和 次方,priority为运算优先级,越大越优先
const calcSymbol = {
    '+': {
        fn: (x, y) => x + y,
        priority: 1,
    },
    '-': {
        fn: (x, y) => x - y,
        priority: 1,
    },
    '*': {
        fn: (x, y) => x * y,
        priority: 2,
    },
    '/': {
        fn: (x, y) => x / y,
        priority: 2,
    },
    '^': {
        fn: (x, y) => Math.pow(x, y),
        priority: 3,
    },
};

// 检查a运算符优先级是否低于或等于
const checkSymbol = (a, b) => {
    if (!calcSymbol[a] || !calcSymbol[b]) return false;
    let priorityA = calcSymbol[a].priority;
    let priorityB = calcSymbol[b].priority;
    return priorityA <= priorityB;
};

将中缀式转换为逆波兰式

const getPolandExpression = expression => {  

    // 后缀式队列
    let resPoland = [];

    // 符号栈
    let symbolStack = [];

    expression.forEach((v, i) => {
        
        // 如果是运算符
        if (calcSymbol[v]) {
            // 对比符号栈
            // 运算符比栈顶优先级低或相等则将运算符栈顶符号加入表达式队列
            // 此符号此时如果依然比栈顶优先级低或相等,继续循环
            // 直到不符条件再把此符号入栈
            if (symbolStack.length) {
                while (checkSymbol(v, symbolStack[0])) {
                    // 弹出栈顶 入队列
                    let s = symbolStack.shift();
                    resPoland.push(s);
                }
            }
            // 如果是运算符 则进入符号栈
            symbolStack.unshift(v);
        } else if (v === '(') {
            // 如果是左括号,一并压入符号栈
            symbolStack.unshift(v);
        } else if (v === ')') {
            // 如果是右括号,开始把符号栈疯狂出栈,直到遇到左括号'('
            let symbol = symbolStack.shift();
            while (symbol !== '(') {
                resPoland.push(symbol);
                symbol = symbolStack.shift();
            }
        } else {
            // 如果是数字则进入数字队列
            resPoland.push(Number(v));
        }
    });

    return [...resPoland, ...symbolStack];
};

计算逆波兰式的结果

// 以 -1+(2-0.3)*-4/5-6 为例
const calcStr = (str, decimal = 2) => {
    // 检查表达式格式,确保只有括号加减乘除和数字,并且没有空格
    // 表达式中的负数转换成了(0-x):  -1+(2-0.3)*(0-4)/5-6
    str = calcCheck(str);  
    if (!str) return false;
    
    // 如果第一个字符为-,那说明这段表达式可能从负数开始,转换为  0-xxxxxx
    if (str[0] === '-') {
        // 0-1+(2-0.3)*(0-4)/5-6
        str = '0' + str;  
    }

    // 将表达式分割成数组
    // ["0", "-", "1", "+", "(", "2", "-", "0.3", ")", "*", "(", "0", "-", "4", ")", "/", "5", "-", "6"]
    let strArr = str.match(/\d+(\.\d+)|\d+|\+|\-|\*|\/|\(|\)/g);

    // 逆波兰表达式
    // [0, 1, "-", 2, 0.3, "-", 0, 4, "-", "*", 5, "/", "+", 6, "-"]
    let reversePoland = getPolandExpression(strArr);

    // 计算结果
    let calcResults = []; 
    
    // 循环直至表达式没有值
    while (reversePoland.length) {
        // 读取逆波兰表达式的第一个值
        let item = reversePoland.shift();
        
        // 如果item式符号且结果集里至少又两个数字则开始运算
        if (calcSymbol[item] && calcResults.length >= 2) {
        
            // 取出计算结果的头两个数字
            let y = calcResults.shift();
            let x = calcResults.shift();
            let res = calcSymbol[item].fn(x, y);
            
            // 再把计算结果放会计算结果集的第一位
            calcResults.unshift(res);
            
        } else {
            // 否则加入计算结果集
            calcResults.unshift(item);
        }
    }
    
    // 返回calcResults计算结果的第一个元素即是最终的计算结果
    // 一个正确的波兰表达式,它的符号必定比数字少一个,且最后的运算接口集必定只有一个数字
    // 前面的判断条件太麻烦,于是最终的结果集如果length不为1,那么说明表达式肯定错了
    return calcResults.length === 1 ? calcResults[0].toFixed(decimal) : 0;
};

PS:
在最终计算中,保留小数部分用了toFixed,其实式不严谨的,因为toFixed并不是四舍五入,因为我懒目前项目对精度要求几乎没有,所以问题不大

关于精度的问题,推荐看另外两位大佬的文章:

【JavaScript】关于解决JS 计算精度问题(toFixed, Math.round, 运算表达式) !

为什么我说 Math.round 和 toFixed 既不是四舍五入,也不是四舍六入五成双?