☰
高精度加减乘算法详解:从存储设计到完整C++实现
2026/10/15 1:46:19 网站建设 项目流程

1. 为什么“高精度加减乘”值得单独拎出来讲

很多人第一次碰到高精度运算,都是在做算法题或者写某个小工具的时候。题目要求算2的1000次方,或者让你求两个几百位大整数的乘积,你一看数据类型,int最大也就 21 亿出头,long long撑死到 9.2e18,随便一个阶乘就爆了。这时候你才意识到,原来计算机里那些“理所当然”的加减乘除,是有边界的。

高精度运算,说白了就是用程序去模拟我们小学学的竖式运算。你可能会想,竖式谁不会啊,但真让你用代码把竖式写出来,坑比想象中多得多。进位怎么处理、借位怎么处理、前导零怎么去掉、负数怎么统一、乘法怎么优化,每一个细节都能让你调试半天。我见过太多人,加法写得飞快,一到乘法就各种越界,或者减法忘了处理结果为负的情况,提交上去一片红。

这篇内容就是把我这些年写高精度运算的经验,从最底层的存储方式,到加减乘的完整实现,再到实际踩过的坑,全部摊开来讲。不管你是刚学编程的新手,还是已经写过几遍高精度但总觉得不够扎实的老手,都能从这里拿到可以直接用的东西。我会用 C++ 来演示,因为这是算法竞赛和很多底层场景里最常用的语言,但思路是通用的,你换成 Python、Java、Go 都一样。

核心关键词就三个:高精度加法、高精度减法、高精度乘法。我会把每个运算的存储设计、核心循环、边界处理、复杂度分析全部讲透,最后再给一份可以直接抄的完整模板。你耐心看完,以后遇到任何大数运算都不会再发怵。

2. 高精度运算的整体设计思路

2.1 为什么不用现成的大数库

你可能会问,Python 自带大整数,Java 有BigInteger,C++ 也有各种第三方库,为什么还要自己手写?这个问题我一开始也想过,后来发现原因很实际。

第一,很多场景下你没法用现成的库。比如你在写嵌入式代码,或者在某些竞赛环境里,第三方库根本不允许用。第二,自己写一遍,你对进位、借位、溢出的理解会完全不一样,这种底层思维在别的地方也用得上。第三,现成的库往往有性能开销,你自己针对特定场景优化过的实现,可能比通用库快好几倍。第四,也是最现实的,面试和考试就考这个,你不会就是不会。

所以我的建议是,不管你平时用不用,高精度运算都值得亲手写一遍,写到闭着眼睛都能默出来的程度。

2.2 存储方式的选择:为什么倒着存

高精度运算的第一步,是把一个大数存下来。最直观的方式是用字符串,因为输入动辄几百位,int根本装不下。但字符串不能直接做运算,所以我们要把它转成一个数组,每一位单独存。

这里有个关键决策:数组是正着存还是倒着存?

正着存就是num[0]存最高位,倒着存就是num[0]存最低位。我强烈建议倒着存,原因很简单:进位和借位都是从低位往高位传播的。你倒着存,最低位就在数组开头,进位的时候直接往后走就行,索引自然递增。如果正着存,你每次进位都要往前插,或者从后往前遍历,代码写起来别扭,还容易出错。

举个例子,数字12345,倒着存就是:

下标: 0 1 2 3 4 数值: 5 4 3 2 1

这样num[0]是个位,num[1]是十位,以此类推。做加法的时候,从下标 0 开始,逐位相加,进位往下一个下标送,逻辑非常顺。

提示:倒着存还有一个好处,就是去掉前导零特别方便。因为最高位在数组末尾,你只需要从末尾往前找到第一个非零位就行。

2.3 用 vector 还是静态数组

存储容器有两种选择:std::vector<int>或者固定大小的静态数组。我一般用vector,因为它动态扩容,不用提前猜数字有多少位。静态数组虽然快一点,但你得开一个足够大的空间,比如100000,浪费内存不说,还得额外维护一个长度变量。

用vector的话,长度直接用size()拿,方便。当然,如果你追求极致性能,静态数组确实有优势,但对绝大多数场景来说,vector的开销可以忽略不计。

2.4 正负号怎么处理

高精度运算最烦人的地方之一就是负数。加法可能是正加正、正加负、负加负,减法可能是大减小、小减大。如果每个运算都去判断符号,代码会变得非常臃肿。

我的做法是:把符号和数值分开处理。用一个结构体或者一对变量,一个存符号(bool或int),一个存绝对值数组。所有运算先统一转成绝对值的加减乘,最后再根据符号规则决定结果的符号。

具体规则是这样的:

  • 加法:同号相加,绝对值相加,符号不变;异号相加,绝对值相减,符号取绝对值大的那个。
  • 减法:a - b等价于a + (-b),把减数符号取反,然后走加法逻辑。
  • 乘法:符号异或,绝对值相乘。

这样你只需要实现“绝对值加法”、“绝对值减法”、“绝对值乘法”三个核心函数,符号处理在外面包一层就行,代码量直接减半。

3. 高精度加法:从原理到实现

3.1 竖式加法的核心逻辑

高精度加法的本质就是竖式加法。我们从最低位开始,逐位相加,如果和大于等于 10,就向高位进 1。用代码表达就是:

sum = a[i] + b[i] + carry result[i] = sum % 10 carry = sum / 10

这个循环一直跑到两个数都处理完,并且carry为 0 为止。听起来简单,但有几个细节必须注意。

第一,两个数的位数可能不一样。比如123 + 45,你得处理位数不匹配的情况。我的做法是,循环条件写成i < a.size() || i < b.size() || carry,在循环内部判断i是否越界,越界就当 0 处理。

第二,进位可能在最高位之后还有。比如99 + 1 = 100,多出来一位。所以循环结束后,如果carry不为 0,要把它加到结果末尾。

第三,前导零的问题。如果两个数都是 0,结果应该是0,而不是空数组或者一堆零。所以最后要去掉前导零,但至少要保留一位。

3.2 完整代码实现与逐行解析

下面是我常用的高精度加法实现,用vector<int>倒着存:

vector<int> add(vector<int>& a, vector<int>& b) { vector<int> c; int carry = 0; for (int i = 0; i < a.size() || i < b.size() || carry; i++) { if (i < a.size()) carry += a[i]; if (i < b.size()) carry += b[i]; c.push_back(carry % 10); carry /= 10; } // 去掉前导零(至少保留一位) while (c.size() > 1 && c.back() == 0) c.pop_back(); return c; }

这段代码短小精悍,但每一行都有讲究。carry变量既充当进位,又临时充当当前位的和,这样写省去了额外的sum变量。循环条件里的|| carry保证了最高位的进位不会丢。push_back是从低位往高位追加,正好符合倒着存的约定。

注意:carry在循环里是累加再取模,不是先算sum再取模。这两种写法等价,但前者少一个变量,代码更紧凑。

3.3 边界情况与测试用例

加法看起来简单,但边界情况不少。我列几个必须测的:

测试用例预期结果考察点
0 + 00前导零处理
999 + 11000最高位进位
123 + 456579普通无进位
999 + 9991998连续进位
1 + 999910000位数不匹配

我特别想说的是0 + 0这个用例。很多人的实现会返回一个空数组,因为循环一次都没进,c是空的。但空数组在后续运算里会出问题,所以必须保证结果至少有一位。我的代码里while循环的条件是c.size() > 1,就是为了防止把唯一的零也删掉。

还有一个坑是输入本身带前导零。比如输入00123,你转成数组后是[3,2,1,0,0],最高位有两个零。如果不处理,输出的时候会变成00123。所以读入之后要先去掉前导零,再存进数组。

4. 高精度减法:借位与符号的坑

4.1 减法比加法难在哪

减法和加法对称,但坑更多。加法你不用担心谁大谁小,减法你必须先判断被减数和减数的大小,因为3 - 5和5 - 3结果不一样,符号也不同。

我的处理方式是:先写一个比较函数,判断两个绝对值数组的大小。如果被减数小于减数,就交换它们,计算结果取负。这样核心的减法函数只需要处理“大减小”的情况,逻辑简化很多。

比较函数的写法是从高位往低位比,先比位数,位数多的肯定大;位数相同就从最高位开始逐位比较。

4.2 借位逻辑的代码表达

大减小的情况下,从低位开始逐位相减,如果当前位不够减,就向高位借 1,当前位加 10 再减。代码是这样的:

vector<int> sub(vector<int>& a, vector<int>& b) { vector<int> c; int borrow = 0; for (int i = 0; i < a.size(); i++) { int t = a[i] - borrow; if (i < b.size()) t -= b[i]; if (t < 0) { t += 10; borrow = 1; } else { borrow = 0; } c.push_back(t); } while (c.size() > 1 && c.back() == 0) c.pop_back(); return c; }

这里的borrow是借位标志。注意t的计算顺序:先减去上一位的借位,再减去减数对应位。如果t小于 0,说明不够减,加 10 并设置借位为 1。

提示:这个实现假设a >= b,所以循环只跑到a.size()。如果a比b短,说明调用前没做好比较,会出错。所以比较函数是必须的。

4.3 符号处理的完整方案

把符号处理加进来,完整的减法逻辑是这样的:

// 比较绝对值大小,a > b 返回 1,a < b 返回 -1,相等返回 0 int cmp(vector<int>& a, vector<int>& b) { if (a.size() != b.size()) return a.size() > b.size() ? 1 : -1; for (int i = a.size() - 1; i >= 0; i--) { if (a[i] != b[i]) return a[i] > b[i] ? 1 : -1; } return 0; }

有了cmp,减法就可以这样写:

// 返回结果,sign 表示符号 pair<vector<int>, int> subtract(vector<int>& a, int sa, vector<int>& b, int sb) { if (sa == sb) { int c = cmp(a, b); if (c >= 0) return {sub(a, b), sa}; else return {sub(b, a), -sa}; } else { return {add(a, b), sa}; } }

这段逻辑的意思是:如果两个数同号,就比较绝对值,大减小,符号跟着大的走;如果异号,就变成绝对值相加,符号跟着被减数走。这样减法就完全复用了加法和比较函数,代码非常干净。

4.4 减法常见错误清单

我整理了几个减法最容易出错的地方:

  • 忘记比较大小:直接sub(a, b)当a < b时会得到错误结果,因为借位逻辑假设a >= b。
  • 借位没清零:如果某一位不需要借位,borrow必须重置为 0,否则会一直借下去。
  • 前导零没去干净:100 - 99 = 1,结果数组可能是[1, 0, 0],必须去掉末尾的零。
  • 结果为负时符号丢失:3 - 5 = -2,如果只返回绝对值2,符号就错了。
  • 零减零:结果应该是0,不是空数组。

这些坑我基本都踩过,尤其是借位没清零那个,当时调了半天才发现是borrow变量没重置。

5. 高精度乘法:从 O(n²) 到实用优化

5.1 乘法为什么是 O(n²)

高精度乘法的核心是:用第一个数的每一位去乘第二个数的每一位,然后把结果累加到对应的位置上。两个 n 位的数相乘,结果最多 2n 位,每一位都要和另一边的所有位相乘,所以时间复杂度是 O(n²)。

这个复杂度在位数不大的时候完全够用。比如 1000 位的数相乘,也就 100 万次操作,现代计算机一秒钟能跑几亿次,根本感觉不到。只有当位数上万的时候,才需要考虑 Karatsuba 或者 FFT 这类高级算法。对绝大多数场景来说,O(n²) 的竖式乘法就是最优解。

5.2 结果数组的长度与下标对应关系

乘法最容易搞错的就是下标对应。假设a有n位,b有m位,结果c最多有n + m位。a[i] * b[j]的结果应该累加到c[i + j]上。

为什么是i + j?因为a[i]代表的是a的第i位(从低位算起),它的权重是10^i;b[j]的权重是10^j;两者相乘的权重是10^(i+j),所以应该放在c[i+j]的位置。

这个对应关系是乘法的核心,理解了它,代码就很好写。

5.3 完整乘法实现与进位处理

vector<int> mul(vector<int>& a, vector<int>& b) { vector<int> c(a.size() + b.size(), 0); for (int i = 0; i < a.size(); i++) { for (int j = 0; j < b.size(); j++) { c[i + j] += a[i] * b[j]; } } // 统一处理进位 int carry = 0; for (int i = 0; i < c.size(); i++) { int t = c[i] + carry; c[i] = t % 10; carry = t / 10; } while (c.size() > 1 && c.back() == 0) c.pop_back(); return c; }

这个实现分两步:先逐位相乘累加,不处理进位;最后统一从低位到高位处理进位。这样写比在双重循环里直接处理进位要清晰得多,也不容易出错。

注意:c初始化大小是a.size() + b.size(),这是结果的最大可能位数。实际结果可能少一位,所以最后要去前导零。

5.4 乘法性能优化的几个实用技巧

虽然 O(n²) 已经够用,但有几个小优化能让它更快:

第一,提前处理零。如果a或b是零,直接返回[0],不用进循环。这个判断能省掉大量无谓计算。

第二,用 int 而不是 long long 累加。因为每一位最大是 9,两个 9 相乘是 81,加上进位也不会超过 int 范围。用 int 比 long long 快。

第三,减少 vector 的扩容。c初始化时就分配好a.size() + b.size()的空间,避免push_back触发扩容。

第四,如果其中一个数很小,比如只有一位,可以退化成“高精度乘单精度”,复杂度降到 O(n)。这个优化在实际场景里很常见,比如大数乘 2。

// 高精度乘单精度,效率更高 vector<int> mul_small(vector<int>& a, int b) { vector<int> c; long long carry = 0; for (int i = 0; i < a.size() || carry; i++) { if (i < a.size()) carry += (long long)a[i] * b; c.push_back(carry % 10); carry /= 10; } while (c.size() > 1 && c.back() == 0) c.pop_back(); return c; }

这个版本用long long存进位,因为a[i] * b可能超过 int 范围。循环条件里的|| carry保证最后的进位不会丢。

6. 完整模板与实战测试

6.1 可直接复用的完整代码

把前面的东西整合起来,就是一个完整的高精度运算模板。我把它写成一个结构体,方便复用:

struct BigInt { vector<int> d; // 倒着存,d[0] 是个位 int sign; // 1 正,-1 负 BigInt() : sign(1) {} BigInt(string s) { if (s[0] == '-') { sign = -1; s = s.substr(1); } else sign = 1; for (int i = s.size() - 1; i >= 0; i--) d.push_back(s[i] - '0'); trim(); } void trim() { while (d.size() > 1 && d.back() == 0) d.pop_back(); if (d.size() == 1 && d[0] == 0) sign = 1; } // ... 加减乘实现 };

这个结构体把符号和数值封装在一起,用起来跟内置类型差不多。trim()函数负责去掉前导零,并且处理“零的符号”这个特殊情况——零既不是正也不是负,统一设成 1。

6.2 测试用例设计与验证方法

写完模板,必须用大量测试用例验证。我的做法是:用 Python 生成随机大数,用 Python 的大整数运算算出标准答案,再和 C++ 的结果对比。这种方法叫对拍,是验证算法正确性最有效的手段。

具体步骤:

  1. 写一个 Python 脚本,随机生成两个大数,输出到文件。
  2. 用 Python 算出它们的和、差、积,也输出到文件。
  3. 写一个 C++ 程序,读入大数,用你的模板计算,输出结果。
  4. 写一个脚本对比两个输出文件,不一致就报错。

对拍能覆盖你手动测试想不到的边界情况,比如进位链特别长的、有大量零的、位数差距特别大的。我当年就是靠对拍发现了减法借位没清零的 bug。

6.3 从模板到实际项目的落地建议

这个模板可以直接用在很多场景里:

  • 算法竞赛:把结构体复制到代码开头,直接用。
  • 计算器工具:支持任意精度运算,不会溢出。
  • 密码学相关:大数运算是 RSA 等算法的基础。
  • 科学计算:需要高精度结果的场景。

如果你要处理特别大的数(比如上万位),可以考虑把vector<int>换成vector<long long>,每一位存多位数字,减少循环次数。这种技巧叫“压位”,能把性能提升好几倍。不过压位会让进位逻辑变复杂,建议先把基础版本写熟再考虑。

7. 常见问题与排查技巧实录

7.1 高频错误速查表

问题现象可能原因解决方法
结果多出前导零没去前导零循环去掉末尾的零,至少保留一位
减法结果不对没比较大小先cmp再决定谁减谁
乘法结果错位下标对应错a[i]*b[j]累加到c[i+j]
最高位进位丢失循环条件没带 carry循环条件加 `
负数结果符号错符号没单独处理符号和绝对值分开算
零的结果是空数组没处理零trim保证至少一位

7.2 调试高精度代码的实用方法

高精度代码不好调试,因为数字太长,打印出来一大串。我的经验是:先用小数测试,再用大数对拍。

小数测试就是用手算能验证的例子,比如12 + 34、100 - 99、12 * 12。这些例子能快速定位逻辑错误。等小数都过了,再用对拍跑大数,检查边界和性能。

另外,写一个打印函数,把vector按正常顺序输出,方便观察中间结果。调试的时候可以在关键步骤打印数组内容,看看进位、借位是不是符合预期。

7.3 性能瓶颈的定位与优化

如果发现程序跑得慢,先确认是不是乘法拖后腿。加法和减法都是 O(n),乘法是 O(n²),位数大的时候乘法是瓶颈。

优化的方向有几个:一是压位,把vector<int>换成vector<long long>,每位存 9 位或 18 位数字;二是用 Karatsuba 算法,把 O(n²) 降到 O(n^1.585);三是用 FFT,降到 O(n log n)。但后两种实现复杂,除非位数真的很大,否则没必要。

我个人的建议是:先把 O(n²) 写扎实,99% 的场景够用了。真遇到性能问题,再考虑压位,这是性价比最高的优化。

7.4 我踩过的三个印象最深的坑

第一个坑是减法的借位没清零。当时写的是if (t < 0) { t += 10; borrow = 1; },但忘了写else borrow = 0;,导致借位一直传递下去,结果全错。这个 bug 藏得很深,因为小数测试可能碰不到,只有连续借位的时候才暴露。

第二个坑是乘法的结果数组开小了。我一开始开的是max(a.size(), b.size()),结果99 * 99 = 9801,需要 4 位,但数组只有 2 位,直接越界。后来改成a.size() + b.size()才对。

第三个坑是输入带前导零。有一次题目给的输入是000123,我没处理,存进数组后最高位有两个零,输出的时候变成了000123,被判错。后来在构造函数里加了trim()才解决。

这三个坑都不难,但没踩过就是想不到。所以我现在写高精度,第一件事就是把trim()和边界判断写好,能省掉后面很多麻烦。

8. 一些延伸思考与个人体会

高精度运算写多了,你会发现它其实是一类问题的缩影:当内置类型不够用的时候,怎么用程序去模拟更强大的能力。这种思路在很多地方都用得上,比如大数取模、大数比较、大数进制转换,甚至是大数开方。

我个人在实际操作中的体会是,高精度的难点从来不在算法本身,而在细节。进位、借位、符号、前导零,每一个都是小问题,但组合起来就能让你调一整天。所以我的建议是,不要追求一次写对,而是先把框架搭好,再用对拍去磨。对拍跑个几百组,什么问题都暴露出来了。

最后再分享一个小技巧:如果你只是临时需要算个大数,不想写代码,可以用 Python 的交互式环境,直接输入表达式就能算,2**1000秒出结果。但如果你要把这个能力集成到自己的程序里,还是得老老实实手写。手写一遍,你对计算机底层运算的理解会上一个台阶。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询