大数运算:字符串加法与乘法
从末位模拟竖式运算,处理进位、前导零、输入校验和 JavaScript 安全整数边界,并扩展到大数乘法。
大数运算:字符串加法与乘法
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 思路与不变量
令 i、j 指向两个输入的当前最低位,carry 是处理右侧所有位后产生、尚未写入的进位。每轮循环结束时,已写入的结果字符正好对应输入末端已处理的位,且 carry 只能是 0 或 1。循环条件要包含 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 明确表达“只读取一位”,并避免把整串转成数字。设 m、n 为两串长度,时间复杂度为 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: 为什么不能直接用 parseInt 或 Number?
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 的复杂度/边界分析以及项目既有大数文章融合整理。