跳到正文
前端知识库
算法

大数运算:字符串加法与乘法

从末位模拟竖式运算,处理进位、前导零、输入校验和 JavaScript 安全整数边界,并扩展到大数乘法。

4 分钟算法 · 字符串 · 大数 · 进位 · Number · BigInt · 面试

大数运算:字符串加法与乘法

JavaScript 的 Number 只能精确表示到 Number.MAX_SAFE_INTEGER。当题目把整数作为字符串传入时,不能先 Number(value) 再相加,否则长数字会在算法开始前丢失精度。正确模型是从最低位到最高位逐位运算,并把进位保留到下一位。

1. 无符号十进制字符串规范化

先约定输入契约:下面的实现只接受非空 ASCII 十进制整数,不处理小数、指数和正负号。前导零会被移除,"000" 规范成 "0"。如果业务需要有符号数,应在符号层和绝对值运算层分别处理,不要把规则混在循环里。

function normalizeUnsignedDecimal(value) {
  if (typeof value !== 'string' || !/^\d+$/.test(value)) {
    throw new TypeError('expected a non-empty decimal string')
  }
  return value.replace(/^0+(?=\d)/, '')
}

2. 字符串大数相加

2.1 思路与不变量

ij 指向两个输入的当前最低位,carry 是处理右侧所有位后产生、尚未写入的进位。每轮循环结束时,已写入的结果字符正好对应输入末端已处理的位,且 carry 只能是 01。循环条件要包含 carry,否则 999 + 1 会漏掉最高位 1

function addDecimalStrings(left, right) {
  const a = normalizeUnsignedDecimal(left)
  const b = normalizeUnsignedDecimal(right)
  let i = a.length - 1
  let j = b.length - 1
  let carry = 0
  const reversedDigits = []

  while (i >= 0 || j >= 0 || carry !== 0) {
    const leftDigit = i >= 0 ? a.charCodeAt(i) - 48 : 0
    const rightDigit = j >= 0 ? b.charCodeAt(j) - 48 : 0
    const sum = leftDigit + rightDigit + carry

    reversedDigits.push(String(sum % 10))
    carry = Math.floor(sum / 10)
    i -= 1
    j -= 1
  }

  return reversedDigits.reverse().join('') || '0'
}

addDecimalStrings('99999999999999999999', '1')
// '100000000000000000000'

若输入已经保证是规范字符串,Number(a[i]) 也能得到单个数位,但 charCodeAt 明确表达“只读取一位”,并避免把整串转成数字。设 mn 为两串长度,时间复杂度为 O(max(m, n)),结果字符和临时数组占用 O(max(m, n)) 空间。

2.2 BigInt 是替代方案,不是算法证明

在支持 BigInt 的运行环境中可以写成:

function addWithBigInt(left, right) {
  return (BigInt(left) + BigInt(right)).toString()
}

BigInt 不能与 Number 混算,老旧运行环境或题目禁止内置大数时也不可用。面试仍应先能说明逐位算法;如果使用 BigInt,要交代运行时兼容性、输入校验和性能/内存契约。

3. 字符串大数相乘(常见追问)

竖式乘法将 a[i] * b[j] 累加到结果数组的 i + j + 1 位置,再从右向左统一进位。每个中间值最多是 9 * 9 加上已有累积,安全地落在普通 JavaScript 数字范围内。

function multiplyDecimalStrings(left, right) {
  const a = normalizeUnsignedDecimal(left)
  const b = normalizeUnsignedDecimal(right)
  if (a === '0' || b === '0') return '0'

  const digits = new Array(a.length + b.length).fill(0)

  for (let i = a.length - 1; i >= 0; i -= 1) {
    const x = a.charCodeAt(i) - 48
    for (let j = b.length - 1; j >= 0; j -= 1) {
      const y = b.charCodeAt(j) - 48
      digits[i + j + 1] += x * y
    }
  }

  for (let index = digits.length - 1; index > 0; index -= 1) {
    const carry = Math.floor(digits[index] / 10)
    digits[index] %= 10
    digits[index - 1] += carry
  }

  let first = 0
  while (first < digits.length - 1 && digits[first] === 0) first += 1
  return digits.slice(first).join('')
}

multiplyDecimalStrings('123456789', '987654321')
// '121932631112635269'

相乘需要 O(mn) 时间和 O(m+n) 结果空间。若进一步追问超长输入,可谈分块(例如按每 9 位一组)或 Karatsuba/FFT,但要先确认输入规模和是否允许复杂库;不要为了背算法而忽略普通竖式实现的可验证性。

4. 边界与错误处理

输入 预期
"0" + "0" "0"
"00012" + "03" "15"
"999" + "1" "1000",保留最终进位
长度不同 缺失高位按 0 处理
空串、"1.2""-1" 按当前契约抛错
含空白或非 ASCII 数字 先明确是否允许 trim/规范化,不能静默接受

不要把字符串按 UTF-16 code unit 之外的规则误处理成数字;这里的正则只接受 ASCII 0-9。如果题目要求支持负数,可以先比较符号和绝对值大小,再复用加法/减法内核;如果要求小数,必须另外处理小数点对齐和精度舍入。

5. 面试追问

Q: 为什么不能直接用 parseIntNumber

A: 它们会把整串转换为 Number,超过 53 位有效二进制精度后可能得到错误结果;逐位算法只把单个数位转换成小整数,不会发生整体溢出。(深入阅读:JavaScript 大数相加

Q: carry 为什么要放在循环条件里?

A: 两个输入都处理完后仍可能产生最高位进位,例如 9 + 1。把 carry !== 0 放进条件能保证这个状态被写入结果。

Q: 结果为何用数组再反转?

A: 运算从低位开始,而字符串结果从高位开始;数组尾部追加是均摊 O(1),最后一次反转避免在字符串头部反复插入导致额外拷贝。也可以预分配结果并从尾部写入。

Q: 如何证明乘法下标是 i + j + 1

A: a[i]b[j] 分别代表 10^(m-1-i)10^(n-1-j) 位,相乘后位权落在结果的倒数第 m+n-i-j-1 位,对应零基下标 i+j+1;随后统一进位即可。

来源:高频真题解析与9月考点预测中.pdf 的“模拟大数相加”;与 前端高频算法原题解析.pdf 的复杂度/边界分析以及项目既有大数文章融合整理。

相关专题:链表加法:进位与方向算法面试真题补充JavaScript 数字精度