资讯动态

C++高精度算法实现指南:从整型溢出到大数四则运算

发布时间:2026/9/9 22:21:00 来源:尧图企业网站定制
高精度算法这玩意儿说透了就是一场跟C内置数据类型死磕的极限运动。刷算法题或者自己写点计算工具的时候肯定会碰上这种场景要算一个几百位的整数的阶乘或者两个超长整数的乘积int不够用long long 不够用unsigned long long 照样溢出。这时候就要自己动手用数组或者字符串模拟数字的存储和运算把硬件限制撑开这就是所谓的高精度算法。学习这块内容的人通常有三类打算法竞赛想拿分的学生工作中要处理大数计算的开发者还有那些纯粹想搞懂计算机底层整数存储逻辑的C爱好者。不管你是哪类人这篇关于C高精度算法的实现拆解从最基础的整型限制出发一路聊到存储设计、加减乘除核心实现、压位优化和工程化封装都能给你一条可以直接照着写、照着用、避坑避雷的完整路线。1. 为什么需要高精度算法先搞懂整型限制在动手写代码之前得先搞清楚一个问题C自带的数据类型到底能表示多大的数又为什么会限制住我们。1.1 C内置整型的容量边界C标准里定义了几种整数类型每种都有自己的存储大小和范围。在绝大多数现代平台上这些范围是固定的类型典型位数最小值最大值short16位-3276832767int32位-21474836482147483647long long64位-92233720368547758089223372036854775807unsigned long long64位018446744073709551615看这张表long long 能表示的最大值大概是 9.22 × 10^18unsigned long long 上限也不过 1.84 × 10^19也就是19位十进制数。这个数量级在日常生活里够用但在算法题目、科学计算、密码学等领域20位以上的整数比比皆是内置类型直接歇菜。1.2 溢出到底会发生什么——两个反直觉的实例C里整型溢出是未定义行为这意味着编译器可以做出任何疯狂的事。但实践中最常见的表现是绕回就像汽车里程表跑满之后归零。举个例子当 n 21 时n! 的结果是 51090942171709440000用 unsigned long long 来算会发生什么呢它会被截断成一个完全错误的数。实际上 21! ≈ 5.109×10^19已经超过了 unsigned long long 的最大值等到 n 22 时结果就更夸张了。另一个经典例子是斐波那契数列。第 93 项的斐波那契数就已经超过 9.22×10^18所以用递归加 long long 最多只能精确计算到第 92 项。你要是写个程序想算第 100 项输出的数字就会变成一个负的或者乱跳的数怎么看怎么不对劲。这类问题的本质在于硬件把整数存储在一个固定位数的二进制空间中一旦结果超出这个空间高位就被直接丢弃我们拿着残缺的数据继续算自然算不出正确答案。1.3 高精度算法的本质用软件模拟硬件位数要突破硬件的位数限制核心思路是用空间换计算精度。既然硬件只给64位那我们就在内存里开一片足够大的空间把数字的一位数或者几位数存在数组或者vector里然后通过模仿我们手算时的竖式运算过程实现任意位数的精确计算。这个过程不需要任何高深的理论它实际上就是小学算术的机械化实现。我们只需要定义清楚数据的存储格式然后按照竖式加、竖式减、竖式乘、竖式除的逻辑一步步计算最后把结果重新转换成十进制字符串输出即可。这个思路看着简单但背后的数据布局、进位处理、借位处理、防溢出边界等细节恰恰决定了代码的性能和正确性。2. 核心设计思路从竖式运算到数组存储高精度算法说到底就是在模仿人用手在纸上算题的过程。但计算机不懂竖式你得给它规定一个存储格式它才知道怎么操作。2.1 存储方案选型——为什么用数组而不是直接在字符串上算很多初学者拿到高精度问题第一反应是直接用字符串存数字然后从后往前遍历逐位计算不就行了吗这个思路没错但字符串的操作其实不如数组灵活。字符串的每个字符本质上是ASCII码运算时要反复做 0 到数字的转换比较和赋值都多一层开销。而且在很多运算场景中我们需要随机访问某一位并修改它的值数组在这一点上更直接。所以在正式的C实现中约定俗成的做法是用 vector 存储每一位数字每个元素只存0~9或者后面我们会讲到偶尔每一位存更大的基数。这样vector下标就是“位数”取值和修改都是O(1)操作性能有保障。2.2 低位在前与小端存储——为什么数组下标0存的是个位这个问题是新手最容易懵的地方。假设要存储数字 12345直觉上我们会让 a[0] 1, a[1] 2, ..., a[4] 5也就是高位在前。但真正做高精度运算时几乎所有实现都采用低位在前a[0] 存个位a[1] 存十位a[2] 存百位以此类推。为什么因为加法和乘法都会产生进位。计算过程中进位是从低位向高位传递的。如果数组高位在前进位时需要频繁地往数组头部插入元素那是一个O(n)的操作在高精度运算中极不划算。反过来如果低位在前进位最多只需要修改后面的元素或者在vector末尾push_back这几乎不费时间。同理减法里的借位也是从低位向高位传播倒序存储能让整个计算过程更自然。你把这个规则记牢a[0] 是个位a[i] 是 10^i 位。这是整个高精度实现的基石。2.3 数据结构的搭建与输入输出处理先搭一个最基础的结构体。这里给出一个简单但可扩展的写法#include iostream #include string #include vector #include algorithm using namespace std; struct BigInt { vectorint digits; // 每一位数字digits[0] 存个位 // 默认构造函数表示 0 BigInt() : digits(1, 0) {} // 从字符串构造 BigInt(const string s) { digits.clear(); for (int i (int)s.size() - 1; i 0; --i) { digits.push_back(s[i] - 0); } // 去掉前导零但至少要保留一位 while (digits.size() 1 digits.back() 0) { digits.pop_back(); } } // 转成字符串输出 string toString() const { string res; for (int i (int)digits.size() - 1; i 0; --i) { res.push_back(digits[i] 0); } return res; } };注意构造函数的细节输入字符串 00123 时倒序存储之后我们得到的向量是 [3, 2, 1, 0, 0]最后一位是0会通过去零循环被去掉最终变成 [3, 2, 1]也就是数字123。这个去前导零操作是必须的否则后续运算结果里会出现大量多余的0输出时也容易带着一堆没意义的前缀。3. 四大基本运算的完整实现与原理剖析存储格式搭好之后剩下的问题就是如何在数组上做四则运算。这里的核心思想就是手算竖式的机械化但每个运算都有自己的注意点。3.1 高精度加法竖式加法与进位传播加法是最简单的。两个数从低位开始对齐相加每位的结果取个位十位以上部分向下一位进位。动手写之前要决定一个事情结果到底存多长两个数的位数分别是n和m加法的结果最多是max(n, m) 1位所以先开一个长度足够的数组逐位相加最后单独处理最末尾的进位。// 高精度加法直接修改第一个操作数并返回引用 BigInt add(const BigInt a, const BigInt b) { BigInt c; c.digits.clear(); int carry 0; int n max(a.digits.size(), b.digits.size()); for (int i 0; i n; i) { int sum carry; if (i (int)a.digits.size()) sum a.digits[i]; if (i (int)b.digits.size()) sum b.digits[i]; c.digits.push_back(sum % 10); carry sum / 10; } // 处理最高位产生的进位 if (carry 0) { c.digits.push_back(carry); } return c; }这里的 key point 是carry 只可能是0或1两个一位数相加最大是9918加上进位1最大19。所以carry判断可以简化成if (carry) c.digits.push_back(1);。但为了保持加法逻辑的通用性写成上面的形式也不会错还能顺带处理后续压位场景。实操时有个细节值得留意如果三个大数需要连续相加可以复用一个进位变量并且在每一轮循环里重新初始化千万别把上一轮的进位带到下一轮。3.2 高精度减法借位处理与符号判断减法比加法多一个麻烦需要先判断两个数谁大谁小因为结果可能是负数。在这里我们不打算完整实现带负号的大数先做绝对值减法即假设 a b返回 a - b 的绝对值。如果a小于b可以交换操作数并在外部记录负号。// 比较绝对值大小a b 返回1a b 返回-1a b 返回0 int compareAbs(const BigInt a, const BigInt b) { if (a.digits.size() ! b.digits.size()) { return a.digits.size() b.digits.size() ? 1 : -1; } // 从高位向低位比较 for (int i (int)a.digits.size() - 1; i 0; --i) { if (a.digits[i] ! b.digits[i]) { return a.digits[i] b.digits[i] ? 1 : -1; } } return 0; } // 高精度减法要求 a b否则结果会出错 BigInt sub(const BigInt a, const BigInt b) { BigInt c; c.digits.clear(); int borrow 0; int n max(a.digits.size(), b.digits.size()); for (int i 0; i n; i) { int ai (i (int)a.digits.size()) ? a.digits[i] : 0; int bi (i (int)b.digits.size()) ? b.digits[i] : 0; int diff ai - bi - borrow; if (diff 0) { diff 10; borrow 1; } else { borrow 0; } c.digits.push_back(diff); } // 去掉前导0 while (c.digits.size() 1 c.digits.back() 0) { c.digits.pop_back(); } return c; }减法里面最容易踩的坑是连续借位。比如计算 1000 - 1个位向十位借十位自己没有又要向百位借百位又要向千位借这个借位链会一直传播下去。上面代码里borrow 一旦为1下一位的 ai 会先减去1如果不够继续借borrow 会保持不变这就正确处理了链式借位。减法执行完一定要做去前导零操作否则 1000 - 999 的结果会是 [1, 0, 0, 0]高位的三个零会让输出变成 0001这在逻辑上没错但读起来很丑也可能导致后续运算出错。3.3 高精度乘法核心双层循环累加乘法的实现思路依然来自竖式但这次不再是逐位相加而是逐位相乘后再累加、统一进位。我们来看手算 123 × 45 的过程1 2 3 × 4 5 --------- 1 5 (3×5放在第0位) 1 0 (2×5放在第1位) 0 5 (1×5放在第2位) 1 2 (3×4放在第1位) 0 8 (2×4放在第2位) 0 4 (1×4放在第3位)竖式乘法的核心在于a[i]代表10^i 位乘以 b[j]代表10^j 位结果自然落在10^(ij)位上。所以对于结果数组 c算法可以写成BigInt multiply(const BigInt a, const BigInt b) { // 结果最多 a.size() b.size() 位 vectorint res(a.digits.size() b.digits.size(), 0); // 双重循环累加乘积 for (int i 0; i (int)a.digits.size(); i) { for (int j 0; j (int)b.digits.size(); j) { res[i j] a.digits[i] * b.digits[j]; } } // 统一处理进位 for (int i 0; i (int)res.size() - 1; i) { res[i 1] res[i] / 10; res[i] % 10; } // 去掉前导零 BigInt c; c.digits res; while (c.digits.size() 1 c.digits.back() 0) { c.digits.pop_back(); } return c; }这段代码是我个人平时最常用的写法优势在于它先不急着进位而是把所有乘积都累加到对应的位置上最后才统一做一轮进位。这样做的好处是减少了重复的进位操作性能上比每一步都进位要快不少。关于乘法有一个性能上的隐患需要提前说明两个长度分别为 n 和 m 的大数相乘时间复杂度是 O(n×m)。如果 n 和 m 都是几千位这个代价是百万级操作还能接受。但如果 n 和 m 都上万位计算量就到了十亿级这时候就要考虑 FFT快速傅里叶变换优化了但那是另一个更复杂的话题暂时按下不表。3.4 高精度除法从逐位试商到二分加速除法是所有运算里最麻烦的。我先讲最常用也最稳妥的一种情况高精度除以低精度也就是一个很大的数除以一个 int 范围内的数。这种情况的模拟跟手算长除法很像。从最高位开始把当前余数乘以10加上当前位的数字然后除以除数得到当前的商位更新余数。// 高精度除以低精度返回商和余数 pairBigInt, int divMod(const BigInt a, int divisor) { BigInt quotient; quotient.digits.assign(a.digits.size(), 0); int remainder 0; // 从最高位开始逐位计算 for (int i (int)a.digits.size() - 1; i 0; --i) { long long cur (long long)remainder * 10 a.digits[i]; quotient.digits[i] cur / divisor; remainder cur % divisor; } // 去掉前导零 while (quotient.digits.size() 1 quotient.digits.back() 0) { quotient.digits.pop_back(); } return {quotient, remainder}; }这里有个细节为什么计算 cur 时要转成 long long因为 remainder 最大是 divisor - 1再乘以10后加上一位数字结果有可能超过 int 范围。虽然 divisor 本身在 int 范围内但中间过程千万不能大意转成 long long 是最稳妥的做法。如果要做高精度除以高精度情况就复杂了。常见的做法是二分试商在结果区间[0, 被除数]内二分查找商每次用乘法去验证中值乘以除数是否超过被除数。这种做法的时间复杂度是 O(n^3)仅适合处理n不太大的情况但在竞赛题里如果遇到大数除大数除了用Java的BigDecimal也就只能靠这种办法先解决功能问题了。4. 性能优化与工程化封装基础功能实现之后很多场景下你会觉得慢尤其是涉及几千位数字的运算。这就要说到压位存储和工程化封装了。4.1 压位存储把时间复杂度的常数砍掉标准高精度实现中vector 每个元素只存0~9一位十进制数这对空间的利用极不充分。因为int能容纳大约21亿的整数如果每位只存0~9相当于90%以上的存储位都浪费了。优化的思路是让每一位存储多个十进制数字。通常有 1e4 压位和 1e9 压位两种方案。这里重点讲 1e9 压位因为它能最大化 int 的容量同时对乘法的结果仍然安全。// 基础基数1e9表示每一位存0~999999999也就是9位十进制数 const int BASE 1000000000; // 1e9 const int WIDTH 9; // 每位的十进制位数 vectorint digits; // digits[0] 是最低9位压位之后加法和减法的核心逻辑几乎不变只需要把 %10 和 /10 改成 %BASE 和 /BASE。乘法要格外小心两个不超过 1e9 的数相乘结果接近 1e18依然在 long long 范围内所以乘法过程中需要把乘积转成 long long 再累加。压位的效果非常直观处理一个百万位的整数时基础实现需要百万个int而1e9压位只需要约11万个int内存占用降到原来的九分之一循环次数也随之大幅减少加减乘运算的时间消耗几乎能下降一个数量级。4.2 用 vector 和类封装从教学实现到可用组件如果只是刷题写几个独立的函数就够了。但要是想在工程项目里用得顺手最好封装成一个类把运算重载成运算符。这是把散装函数整合成大数类型的关键一步。class BigInt { private: vectorint d; static const int BASE 1000000000; static const int WIDTH 9; bool negative false; public: // 构造函数、运算符重载等方法... };运算符重载的好处是你可以写出BigInt c a b * c;这样的代码表达式读起来跟内置类型完全一致。在实际工程中这比调用一堆add(a, multiply(b, c))要优雅得多。需要注意的是一旦引入压位toString函数要专门处理每段不足WIDTH位时补前导零的问题。举个例子如果按1e9压位一个数存成了 [123, 456]高位是456低位是123输出时要输出 456 00000123而不是 456 123否则位数就丢了。4.3 运算符重载让高精度类型用起来像内置 int重载运算符时最核心的其实是赋值和比较。赋值没写好后面全是坑。C11 之后移动语义和拷贝构造都方便了很多只要记得遵循三/五法则即如果自定义了析构函数、拷贝构造函数或拷贝赋值操作符通常这三个都需要自定义就能规避大多数字节错乱的问题。比较运算的重载可以借助之前写的 compareAbs再结合符号位判断。相等和不等的判断最简单先看符号再看位数和每一位。小于和大于的判断稍微绕一点但思路也不复杂。我自己的习惯是先实现 Compare 工具函数统一处理绝对值比较和符号判断再在这个基础上分别重载 , , , !。这样代码不会重复也不容易出现符号判断的漏网之鱼。5. 常见问题与排查技巧实录这部分我整理了一些在实际写高精度算法时遇到的典型问题每一个都是我自己踩过坑才总结出来的。把这些坑避掉你的高精度算法代码能少走很多弯路。5.1 前导零问题——为什么输出变成了一串 000这是一个非常经典的bug。如果你在减法或乘法的结果里去前导零的时机不对就会导致输出出现大量多余的0。比如计算 1000 - 999如果去前导零的循环写的是while (c.digits.size() 0 c.digits.back() 0)那么当结果为0时digits会被清理成空数组最后toString的时候返回空字符串程序直接报错或输出空白。正确写法是保留一位while (c.digits.size() 1 c.digits.back() 0)。这个至少保留一位的约定在初始化、去零、输出等所有环节都要保持否则就会出现诡异的行为。5.2 边界条件0、负数、极大数边界问题是高精度算法最容易翻车的地方我见过不少人在0的处理上栽跟头。当被减数等于减数时结果必须是0不能是空字符串或负零。当乘法中有一个操作数是0时结果必须是0如果没有正确去前导零可能会得到一个只有零的数组虽然看起来没毛病但后续处理会出现问题。如果后续要支持负数符号位和绝对值必须分开存储每次运算前都要做符号预判不能直接把负数当成绝对值来算。处理极大数时注意 string 的 length() 返回的是 size_t用 int 接收会有隐式转换风险。实际操作中最好在取长度前先转成 int或者直接用 size_t 类型避免溢出和符号位的诡异问题。5.3 性能排查高精度乘法太慢怎么办不少人在写完基础版高精度之后发现自己算一个几万位的乘法要等好几秒这就是没做压位优化。先检查你的基数是不是10如果是先改成1e9压位性能能提升一个量级。这一步是最直接有效的手段。如果压位之后还是慢那就要考虑两个方向一是使用分治乘法例如 Karatsuba 算法把 O(n^2) 降到大约 O(n^1.585)。这个算法适合对时间要求苛刻的大数乘法实现起来也不算太复杂核心是把一个乘法拆成三个规模减半的乘法。二是在位数特别大比如超过一万十进制位的场景下直接上 FFT 或 NTT把时间复杂度压到 O(n log n)。这个方向的实现复杂度高但如果你处理的是密码学、数论计算这类任务这一步是绕不开的。排查性能问题还有一个技巧写一个简单的计时工具对每段运算分别计时不要笼统地统计整个程序运行时间。因为问题往往出在某一个具体环节比如你可能会发现其实不是乘法慢而是反复的字符串转数组操作拖慢了时间。在高精度算法的实际开发中我最深的体会是这个领域的代码成败大多不在算法本身而在数据表示的一致性和极端情况的完整性。低位在前、统一去前导零、统一处理进位这三条原则一旦贯彻到底大部分bug都能在写代码的过程中被消解掉。压位优化和运算符重载则是把高精度类型从能跑推向好用的关键两步。如果你正在刷算法题或者有大数计算的开发需求我建议你先从标准十进制版开始把加减乘除都跑通再去优化压位和封装。先把地基打牢后面盖楼才真正快。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价