1. 为什么“高精度加减乘”值得单独拿出来讲
很多人第一次碰到高精度运算,都是在做算法题或者写小工具的时候。比如让程序算一下12345678901234567890 + 98765432109876543210,结果发现用语言自带的整数类型算出来的答案莫名其妙变成了负数,或者干脆丢掉了末尾几位。这不是语言有bug,而是因为计算机原生整数类型有固定的位宽,超出范围就会溢出。
所谓“高精度加减乘”,说白了就是用程序模拟人手算的过程:把一个大整数按位拆开,存到一个数组或者字符串里,然后逐位做加法、减法、乘法,并且自己处理进位和借位。它解决的核心问题只有一个——当数字大到原生类型装不下时,如何还能算得准。
这套东西适合谁看?如果你是刚学编程、正在刷算法题的人,这是必过的一关;如果你平时写业务代码,偶尔要处理超大金额、超大ID、科学计数场景,那这套思路也能直接拿来用。我见过太多人一上来就找现成的大数库,结果连库的边界条件都没搞清楚,出了问题完全不知道怎么排查。自己动手写一遍,比调十个库都管用。
下面我按“设计思路 → 核心细节 → 完整实操 → 踩坑排查”的顺序,把加减乘三个运算从头到尾拆一遍。所有代码用 Python 演示,因为它的列表操作最直观,换成 C++、Java、JavaScript 思路完全一样,只是语法不同。
2. 整体设计思路与存储方案选型
2.1 为什么用数组存每一位,而不是字符串
高精度运算的第一步,是决定怎么把一个大数存进内存。常见有两种做法:一种是直接用字符串存,比如"12345";另一种是转成整数数组,比如[5, 4, 3, 2, 1]。
字符串的好处是输入输出方便,读进来就能用。但坏处也很明显:字符串里的每个字符是只读的,你要修改某一位就得重新拼接,效率低而且代码丑。更关键的是,做运算时你频繁需要“把字符转成数字”“把数字转回字符”,来回转换容易出错。
所以我个人强烈建议:输入用字符串,运算用数组。具体来说,把字符串"12345"转成数组[5, 4, 3, 2, 1],也就是低位存在数组前面,高位存在后面。为什么反过来存?因为加减乘都是从最低位开始算的,低位放在索引0的位置,循环的时候从前往后走,进位直接往后追加,逻辑最顺。
提示:如果你把高位存在前面,每次进位都要往数组头部插入元素,时间复杂度会退化,代码也更容易写错。低位在前是这类问题的通用惯例。
2.2 加减乘三者的难度差异与实现顺序
这三个运算的难度是递增的,我建议按加法 → 减法 → 乘法的顺序来实现,因为后面的会复用前面的思路。
加法最简单,核心就是“逐位相加 + 进位”。减法稍微麻烦一点,因为要处理“不够减要借位”,而且结果可能是负数,需要额外判断大小。乘法最难,因为它是两层循环,而且每一位乘出来的结果要错位相加,进位处理也更复杂。
从算法复杂度看,加法和减法都是 O(n),n 是数字的位数;乘法是 O(n×m),两个数各有多少位就要乘多少次。这也是为什么大数乘法在实际工程里会用更高级的算法(比如分治、快速傅里叶变换),但对于绝大多数场景,朴素的竖式乘法已经够用了。
2.3 符号处理:把正负号单独拎出来
高精度运算里一个容易被忽略的点是符号。如果你把符号和数字混在一起存,每次运算都要判断一堆情况,代码会变得极其难维护。
我的做法是:数字数组只存绝对值,符号单独用一个变量记录。比如-123存成sign = -1,数组是[3, 2, 1]。这样加法就变成“同号相加、异号相减”,减法就变成“同号相减、异号相加”,乘法就是“绝对值相乘、符号相乘”。逻辑一下子清晰了很多。
这个设计的好处在于,你只需要实现“无符号加法”“无符号减法”“无符号乘法”三个核心函数,符号的处理在外层统一调度,代码量能减少一半以上。
3. 核心细节解析与逐位运算要点
3.1 加法:进位是唯一需要操心的事
加法的逻辑非常直白:两个数组从索引0开始逐位相加,再加上上一位的进位,当前位的结果是(a + b + carry) % 10,新的进位是(a + b + carry) // 10。循环结束后如果进位还是1,就在末尾再补一个1。
这里有个细节很多人会写错:两个数的位数不一样长怎么办。正确做法是循环条件用i < len(a) or i < len(b) or carry,也就是只要还有一个数没遍历完,或者还有进位,就继续循环。取当前位数字时,如果索引越界就取0。
我实测下来,这个写法比“先把两个数组补齐到等长”更简洁,也少了一次数组拷贝。补零的做法虽然直观,但多了一步操作,位数很大时会有性能差异。
3.2 减法:借位和结果符号的判断
减法比加法多两个坑。第一个坑是借位:当前位不够减时,要向高位借1,借来的1在当前位相当于10。第二个坑是结果可能是负数:比如3 - 5 = -2,你得先判断两个数谁大,用大的减小的,最后再决定符号。
判断大小的逻辑是:先比位数,位数多的更大;位数相同就从最高位开始逐位比较。这个函数写一次,加法和乘法里都能复用。
借位的实现是:如果a[i] < b[i],就向高位借1,当前位加10再减。但要注意,借位可能引发连锁反应,比如1000 - 1,个位不够减要向十位借,十位是0又要向百位借,一直传到千位。用循环实现时,借位标志要一直带着走。
3.3 乘法:错位相加是核心难点
乘法的本质是用第二个数的每一位去乘第一个数,然后把结果错位相加。比如123 × 45,就是123 × 5加上123 × 4左移一位。
实现时用两层循环:外层遍历第二个数的每一位,内层遍历第一个数的每一位。关键点在于结果数组的下标:a[i] × b[j]的结果应该累加到result[i + j]这个位置。这个规律你拿纸笔写一遍竖式就能看出来,是乘法实现里最核心的一句话。
进位处理和加法类似,但要注意每一位累加完之后可能超过9,需要统一处理进位。我一般先把所有乘积累加到结果数组里,最后再从头到尾扫一遍统一进位,这样比边乘边进位更不容易出错。
注意:乘法结果数组的长度最多是
len(a) + len(b),初始化时直接开这么大,避免中途扩容。
4. 完整实操过程与代码实现
4.1 基础工具函数:字符串与数组互转
先把最基础的转换函数写好,后面所有运算都依赖它。
def str_to_arr(s): # 去掉前导零,但保留至少一位 s = s.lstrip('0') or '0' # 低位在前 return [int(c) for c in reversed(s)] def arr_to_str(arr): # 去掉高位多余的零 while len(arr) > 1 and arr[-1] == 0: arr.pop() return ''.join(str(d) for d in reversed(arr))这两个函数看着简单,但前导零的处理是个高频出错点。比如"000123"要变成[3, 2, 1],而"0000"要变成[0]。如果不处理,输出时会变成"000123",看起来就很业余。
4.2 无符号加法实现
def add_unsigned(a, b): res = [] carry = 0 i = 0 while i < len(a) or i < len(b) or carry: x = a[i] if i < len(a) else 0 y = b[i] if i < len(b) else 0 total = x + y + carry res.append(total % 10) carry = total // 10 i += 1 return res这段代码我用了很多次,实测很稳。carry放在循环条件里是关键,否则最高位的进位会丢。比如99 + 1,如果不带carry循环,结果会变成00而不是100。
4.3 无符号减法与大小比较
def cmp_unsigned(a, b): if len(a) != len(b): return 1 if len(a) > len(b) else -1 for i in range(len(a) - 1, -1, -1): if a[i] != b[i]: return 1 if a[i] > b[i] else -1 return 0 def sub_unsigned(a, b): # 保证 a >= b res = [] borrow = 0 for i in range(len(a)): x = a[i] - borrow y = b[i] if i < len(b) else 0 if x < y: x += 10 borrow = 1 else: borrow = 0 res.append(x - y) return rescmp_unsigned从最高位开始比,这是必须的。我见过有人从低位开始比,结果19和20判断成19更大,因为个位9大于0,这就完全错了。
4.4 无符号乘法实现
def mul_unsigned(a, b): res = [0] * (len(a) + len(b)) for i in range(len(a)): for j in range(len(b)): res[i + j] += a[i] * b[j] # 统一处理进位 carry = 0 for i in range(len(res)): total = res[i] + carry res[i] = total % 10 carry = total // 10 return resres[i + j]这个下标规律是乘法的灵魂。你可以拿12 × 34手动推一遍:2×4放在索引0,1×4 + 2×3放在索引1,1×3放在索引2,结果就是408,完全对得上。
4.5 带符号的完整封装
把符号逻辑套上去,三个运算就完整了。
def add(a_str, b_str): a_sign, b_sign = 1, 1 if a_str[0] == '-': a_sign, a_str = -1, a_str[1:] if b_str[0] == '-': b_sign, b_str = -1, b_str[1:] a, b = str_to_arr(a_str), str_to_arr(b_str) if a_sign == b_sign: res = add_unsigned(a, b) sign = a_sign else: if cmp_unsigned(a, b) >= 0: res = sub_unsigned(a, b) sign = a_sign else: res = sub_unsigned(b, a) sign = b_sign s = arr_to_str(res) return ('-' + s) if sign == -1 and s != '0' else s减法的封装思路一样,只是符号判断反过来:同号相减、异号相加。乘法最简单,绝对值相乘,符号直接相乘。
5. 常见问题与排查技巧实录
5.1 结果出现前导零或负零
这是最高频的问题。arr_to_str里必须去掉高位的零,但要注意保留最后一位。另外-0要输出成0,不能输出-0。我在封装函数里加了一句s != '0'的判断,专门处理这种情况。
5.2 进位丢失导致结果少一位
典型场景是99 + 1得到00。原因就是循环条件没带carry。记住一句话:只要进位不为零,循环就得继续。乘法里统一进位时,如果最后carry还有值,也要继续往后写。
5.3 减法借位连锁反应处理错误
1000 - 1这类题目最容易暴露借位bug。排查方法很简单:手动构造几个边界用例,比如100 - 99、1000 - 1、10000 - 9999,跑一遍看结果对不对。如果错了,大概率是借位标志没有正确传递。
5.4 乘法结果数组越界
res[i + j]的下标最大是len(a) - 1 + len(b) - 1 = len(a) + len(b) - 2,所以数组开len(a) + len(b)是安全的。如果你开小了,Python 会报 IndexError,C++ 会直接越界写内存,后果更严重。
| 问题现象 | 可能原因 | 排查方法 |
|---|---|---|
| 结果少一位 | 最高位进位丢失 | 检查循环条件是否含carry |
| 结果为负零 | 符号判断未排除零 | 输出前判断数值是否为0 |
| 减法结果错误 | 借位未连锁传递 | 用1000-1等边界用例测试 |
| 乘法崩溃 | 结果数组开太小 | 长度设为len(a)+len(b) |
| 前导零残留 | 未清理高位零 | arr_to_str中循环pop |
5.5 性能优化的几个实用技巧
当位数达到几万位时,纯Python的逐位循环会明显变慢。我试过几个优化方向:一是把进制从10改成10000,也就是一次处理四位,循环次数直接降到四分之一;二是乘法用分治,把大数拆成两半递归计算。不过对于日常使用,十进制逐位已经足够,没必要过度优化。
提示:如果你要做进制优化,注意输入输出的转换要同步改,否则中间结果会错乱。我建议先把十进制版本写对,再考虑优化。
6. 我个人的实操体会
这套高精度加减乘我前后写过不下五遍,每次都有新的收获。最开始我总想一步到位写个通用大数类,结果符号处理、边界条件搅在一起,调试了两天才跑通。后来我学乖了,先把无符号的加、减、乘分别写对、测透,再套符号逻辑,半天就搞定了。
如果你也在学这块,我的建议是:别急着看别人的代码,先拿纸笔把竖式过程写一遍,尤其是乘法里res[i + j]那个下标规律,自己推一遍比看十遍代码都管用。写完记得用边界用例测:零、负数、位数差很大的数、全是9的数,这几类跑通了,基本就没大问题了。
后续如果想继续深入,可以研究一下大数除法、取模,以及进制压缩优化。除法的思路和减法类似,是“不断减去除数”,但效率很低,通常会用试商法加速。这些内容展开又是另一大块,有机会再单独聊。