利用栈实现四则混合运算引擎

浮点数问题

尝试在浏览器端做算术运算的同学,多多少少都会遇到过这样奇怪的问题:

0.1 + 0.2    // 0.30000000000000004
1 - 0.9    // 0.09999999999999998
19.9 * 100    // 1989.9999999999998
0.3 / 0.1    // 2.9999999999999996

这是由于 JS 是一门弱类型语言,不像 Java 有 intfloatdouble 等丰富的数据类型,JS 中所有数字(无论整数或小数)都只有 Number 一种类型。其采用 IEEE 754 的 64 位双精度浮点数,由 1 位符号位、11 位的指数位和 52 位的尾数位组成。由于有限的存储位数,当数字从十进制转换成二进制的尾数无穷时,多出的部分就会被截断,在计算结束转换为十进制时就会出现浮点误差。为了更好理解下面例子的换算过程,建议先读这篇文章: JavaScript 浮点数陷阱及解法

0.1+0.2 的例子( 此网站 支持十进制与双精度浮点数的转换):

0.1 的双精度浮点数为 0 01111111011 1001100110011001100110011001100110011001100110011010,指数位转换成十进制是 10191019 - 1023 = -4,补上整数部分 1 再小数点前移4位,得 0.1 的二进制数为 0.00011001100110011001100110011001100110011001100110011010。按照同样的转换方法:

// 0.1 + 0.2
  0.00011001100110011001100110011001100110011001100110011010
+ 0.0011001100110011001100110011001100110011001100110011010
= 0.0100110011001100110011001100110011001100110011001100111

// 由于双精度的位数限制,最后一位舍去进1,得
0 01111111101 0011001100110011001100110011001100110011001100110100

// 转换成十进制
0.30000000000000004

解决方案

浮点数运算的解决方案很简单,就是把小数转换成整数后再进行相应的运算。譬如:

(0.1 * 10 + 0.2 * 10) / 10    // 0.3

代码实现:

function operate(left, right, operator) {
  /** 固定小数位精度的写法 **/
  const precision = 2;
  const factor = +'1000000000000'.slice(0, precision + 1);

  /** 自动获取最大小数位的写法 **/
  // const lPrecision = (left.toString().split('.')[1] || '').length;
  // const rPrecision = (right.toString().split('.')[1] || '').length
  // const factor = Math.pow(10, Math.max(lPrecision, rPrecision));

  if (operator === '+') {
    return Math.round(left * factor + right * factor) / factor;
  } else if (operator === '-') {
    return Math.round(left * factor - right * factor) / factor;
  } else if (operator === '*') {
    return Math.round(left * factor * right * factor) / Math.pow(factor, 2);
  } else if (operator === '/') {
    return Math.round(left / right * factor) / factor;
  }
}

operate(0.1, 0.2,'+');    // 0.3
operate(0.3, 0.1,'/');    // 3

然而这样简陋的处理在复杂的四则运算场景中使用会非常麻烦,我们可以实现一个简单的计算引擎,让其可以处理基本的四则混合运算(如 1 * ( 2 - 3 ) )。

实现四则混合运算

四则混合运算涉及加、减、乘、除及括号的优先级,如果用正常的 中缀表达式 的计算思维来实现会比较复杂,所以我们引入逆波兰表达式。

逆波兰表达式

逆波兰表达式 中,所有运算符都被置于操作数后面,因此也称为后缀表示法

// 正常算术式 -> 逆波兰表达式
`a + b`  ->  `a b +`
`a - b + c`  ->  `a b - c +`
`a * ( b - c )`  ->  `a b c - *`

逆波兰表达式的计算一般依赖堆栈结构,过程非常简单:

如为操作数则入栈,如为运算符则把栈内操作数弹出并计算,再重新入栈,遍历直至栈内只剩一个元素即为表达式的值。

逆波兰表达式不需要关心运算符之间的优先级问题,也不存在括号,可以极大限度地减小程序复杂度。

调度场算法

借助 调度场算法 ,我们可以简单地把中缀表达式转换成逆波兰表达式。为了更好地描述调度场算法的流程,这里初始化了三个对象:

  • Input queue:输入队列,一般是预处理表达式字符串后提取出的元素数组;
  • Operator stack:用于存储操作符的栈;
  • Output queue:逆波兰表达式元素队列;

调度场算法流程如下,其中简称 Output queue 为队列 qOperator stack 为栈 s

  1. Input queue中取出一个元素 x
  2. 如果 x 是操作数,直接添加到队列 q 中;
  3. 如果 x 是左括号 ( ,直接压入栈 s 中;
  4. 如果 x 是右括号 ),从栈 s 中弹出运算符添加到队列q中,直至栈顶元素为左括号,弹出左括号并丢弃掉(如果直至栈 s 清空依然找不到左括号,则表达式格式错误);
  5. 如果 x 是除括号外的运算符,与栈 s 的栈顶元素 o1 比较:如果 x 的优先级大于 o1,则把 x 压入栈 s;反之,从栈 s 中不断弹出操作符并添加到队列 q 中,直至栈顶元素的优先级低于 x,或栈顶元素为左括号 (,然后把 x 压入栈 s
  6. 重复步骤 1 - 5,当 Input queue 中已没有元素时,将栈 s 中的元素逐个弹出放入队列 q 中。

JS 实现

function Calculate(options = {}) {
  // 运算符权重
  this.weight = options.weight || {
    '+': 1,
    '-': 1,
    '*': 2,
    '/': 2,
  };
  // 小数位精度
  this.decimal = options.decimal || 2;
  this.operatorStack = [];    // 运算符栈
  this.outputQueue = [];      // 逆波兰表达式队列
}

Calculate.prototype = {

  /**
   * @desc 四则运算,浮点数处理
   */
  operate(left, right, operator) {
    const factor = +'1000000000000'.slice(0, this.decimal + 1);
    if (operator === '+') {
      return Math.round(left * factor + right * factor) / factor;
    } else if (operator === '-') {
      return Math.round(left * factor - right * factor) / factor;
    } else if (operator === '*') {
      return Math.round(left * factor * right * factor) / Math.pow(factor, 2);
    } else if (operator === '/') {
      return Math.round(left / right * factor) / factor;
    }
  },

  /**
   * @desc 调度场算法
   */
  shuntingYard(token) {
    if (!isNaN(+token)) {
      this.outputQueue.push(+token);
    } else if (token === '(') {
      this.operatorStack.push(token);
    } else if (token === ')') {
      let top = this.operatorStack.pop();
      while (top && top !== '(') {
        this.outputQueue.push(top);
        top = this.operatorStack.pop();
      }
      if (!top) throw new Error('表达式格式错误:括号不闭合');
    } else {
      let top = this.operatorStack.pop();
      while (top && top !== '(' && this.weight[token] <= this.weight[top]) {
        this.outputQueue.push(top);
        top = this.operatorStack.pop();
      }
      top ? this.operatorStack.push(top, token) : this.operatorStack.push(token);
    }
  },

  /**
   * @desc 逆波兰表达式求值
   */
  calRpn() {
    const stack = [];
    while (this.outputQueue.length) {
      let token = this.outputQueue.shift();
      if (typeof token === 'number') {
        stack.push(token);
      } else {
        const right = stack.pop();
        const left = stack.pop();
        if (!right || !left) throw new Error('计算错误');
        stack.push(this.operate(left, right, token));
      }
    }
    if (stack.length !== 1) throw new Error('计算错误');
    return stack[0];
  },

  /**
   * @desc 校验表达式合法性,预处理等
   * @todo 更详细的格式校验
   */
  preValid(expStr) {
    return expStr;
  },

  run(expStr) {
    this.operatorStack = [];
    this.outputQueue = [];

    let chars;
    const str = this.preValid(expStr);
    const reg = /([\d\.]+|[\(\)\+\-\*\/])/g;
    while ((chars = reg.exec(str)) !== null) {
      this.shuntingYard(chars[0]);
    }
    while (this.operatorStack.length) {
      this.outputQueue.push(this.operatorStack.pop());
    }
    return this.calRpn()
  }
}

const cal = new Calculate();
cal.run('1-(2+3*8)');    // -25

代码实现已放在 github 上,欢迎交流。

Reference

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,240评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,328评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,182评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,121评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,135评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,093评论 1 295
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,013评论 3 417
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,854评论 0 273
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,295评论 1 310
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,513评论 2 332
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,678评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,398评论 5 343
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,989评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,636评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,801评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,657评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,558评论 2 352

推荐阅读更多精彩内容