前端知库

怎样手写大数相乘?和 BigInt 比有什么区别?

把两个数字字符串按竖式乘法逐位相乘,a[i] 与 b[j] 的乘积累加到结果数组的 i + j + 1 位,进位加到 i + j 位,最后去掉前导零。结果长度最多是两个数位数之和。现代环境可以直接用 BigInt,但它不能和 Number 混合运算,也不能表示小数。

参考答案

简答

两个很长的数字字符串相乘,比如 '9007199254740993' 乘以 3,直接转成 Number 去乘,结果末尾会出错,因为超过 Number.MAX_SAFE_INTEGER 的整数已经不能精确表示。手写的办法是照着小学竖式乘法,一位一位地乘。

具体做法是准备一个长度为 a.length + b.length 的数组,全部填 0。两层循环从末位开始,a[i] 乘 b[j] 的结果落在第 i + j + 1 位,加上这一位原来的值,对 10 取余留在这一位,商作为进位加到第 i + j 位。最后把数组拼成字符串,去掉开头的 0。

为什么数组长度是两数位数之和?因为 m 位数乘 n 位数,结果最多 m + n 位,比如 99 × 99 = 9801,两位乘两位得四位。

现代浏览器和 Node 都支持 BigInt,写成 BigInt(a) * BigInt(b) 就能精确相乘。要注意 BigInt 不能和 Number 混合运算,不能用 Math 的方法,也只能表示整数;手写版本主要用来考察对竖式和进位的理解。

按竖式逐位相乘

下面的 multiply 接收两个非负整数字符串,返回乘积字符串。最后两行用 BigInt 对比结果。

JavaScript
function multiply(a, b) {
  if (a === '0' || b === '0') return '0';
  const result = new Array(a.length + b.length).fill(0);

  for (let i = a.length - 1; i >= 0; i--) {
    for (let j = b.length - 1; j >= 0; j--) {
      // a[i] 和 b[j] 的乘积落在 i + j + 1 这一位,进位加到 i + j。
      const sum = Number(a[i]) * Number(b[j]) + result[i + j + 1];
      result[i + j + 1] = sum % 10;
      result[i + j] += Math.floor(sum / 10);
    }
  }

  return result.join('').replace(/^0+/, '');
}

console.log(multiply('123', '45')); // 5535
console.log(multiply('99', '99')); // 9801
console.log(multiply('9007199254740993', '3')); // 27021597764222979
console.log(9007199254740993 * 3); // 27021597764222976
console.log((9007199254740993n * 3n).toString()); // 27021597764222979
console.log(
  multiply('12345678901234567890', '98765432109876543210') ===
    (12345678901234567890n * 98765432109876543210n).toString(),
); // true

第四行直接用 Number 计算,得到 27021597764222976,末尾错了。原因是字面量 9007199254740993 已经超过 Number.MAX_SAFE_INTEGER(9007199254740991),写进代码时就被舍入成了 9007199254740992,再乘 3 自然不对。字符串版本和 BigInt 版本都得到正确的 27021597764222979。

为什么乘积放在 i + j + 1 位?把结果数组想成从左到右的位:a 的第 i 位从右数是第 a.length - 1 - i 位,b 的第 j 位从右数是第 b.length - 1 - j 位,两者相乘落在从右数第 a.length + b.length - 2 - i - j 位,换算成从左数的下标,正好是 i + j + 1。它的进位比它高一位,就是 i + j。

内层循环里 result[i + j] 只加了进位,没有马上对 10 取余,会不会超过 9?会,但没关系。这个位置之后还会作为某一次的 i + j + 1 被访问到(同一轮里 j 减 1 时,或者 j 到 0 以后的下一轮外层循环),那时会重新取余,把进位继续往左推。最高位 result[0] 只接收进位,而乘积最多 m + n 位,所以它不会超过 9。最终每一位都在 0 到 9 之间。

两层循环各跑一遍,时间复杂度是 O(m × n),m 和 n 是两个数的位数。

结果为什么要去掉前导零

数组长度按最多的 m + n 位准备,可是很多时候乘积没那么长。比如 123 × 45 = 5535,只有 4 位,数组却有 5 位,第一位是 0,所以用 replace(/^0+/, '') 去掉。开头先判断有一个数是 '0' 就直接返回,否则结果全是 0,去掉以后会变成空字符串。

有了 BigInt 还要手写吗

MDN 写明,BigInt 用来表示超出 Number 安全范围的整数,可以在整数字面量后面加 n,也可以用 BigInt('9007199254740993') 从字符串创建。所以实际项目里,后端以字符串返回长整数时,直接转成 BigInt 计算最省事。

可是它有几个限制。BigInt 不能和 Number 混合运算,1n + 1 会抛出 TypeError,要先转成同一种类型;也不能用 Math 对象上的方法。字符串里如果有小数点,BigInt() 会抛出 SyntaxError,所以金额这类带小数的数据,要先换算成最小单位的整数,比如“分”。另外 JSON.stringify 遇到 BigInt 会报错,传给后端前要先转成字符串。

手写竖式主要考察的是逐位计算和进位处理,和《怎样手写大数相加?为什么 JS 的大整数会算错?》是同一类题。

常见追问

怎样支持负数?

先看两个字符串开头有没有负号,记下结果的符号,去掉负号后按非负数相乘。两个数里恰好有一个负数时,结果前面加负号;结果是 0 时不加。

乘积要以 Number 交给图表库怎么办?

先判断结果是否在安全范围内。比较运算允许 BigInt 和 Number 混用,可以直接写 product <= Number.MAX_SAFE_INTEGER。在范围内才用 Number() 转换;超出范围时转换会丢精度,只能保留字符串显示。