公司动态
C++大数计算:从零实现高精度算法,解决整数溢出难题
1. 项目概述为什么我们需要自己动手实现“大数计算”在C的世界里我们习惯了int、long long这些内置数据类型带来的便利。int能表示大约±21亿long long能表示大约±922亿亿对于日常计算似乎绰绰有余。但当你真正踏入算法竞赛、密码学、科学计算或者金融系统开发的领域时这些边界会瞬间变得无比脆弱。想象一下你需要计算一个100位的质数或者处理银行账户里精确到小数点后十位的天文数字交易内置类型立刻就“爆”了这就是所谓的整数溢出。这时候“大数模拟”或“高精度计算”就不再是课本上的概念而是一个必须掌握的生存技能。它的核心思想非常直观既然一个变量装不下我们就用一群变量来装。最经典也最易于理解的方法就是用一个数组来模拟一个超长的数字数组的每一位比如arr[0]对应数字的一个十进制位。然后我们手动实现这个“数组数字”的加、减、乘、除法则也就是模拟我们小学时在草稿纸上列竖式计算的过程。网上能找到很多代码片段但往往要么只实现了加减要么乘除写得像天书缺乏从设计到实现的完整脉络和“人话”解释。更有甚者对于除法这种最复杂的运算直接一笔带过。这篇内容我就结合自己踩过的坑和优化经验带你从零构建一个功能完整、逻辑清晰、并且考虑了效率与边界情况的大数计算类。我们不止于“能跑”更要追求“优雅”和“健壮”。2. 核心设计思路如何用数组表示一个“大数”在动手写代码之前设计决定成败。我们需要明确几个基础但关键的问题。2.1 存储结构的选择数组与字符串首先用什么存常见的有两种选择std::string和std::vectorint。std::string字符直接对应数字字符输入输出方便但进行数值运算时需要频繁的char与int转换c - 0并且进位、借位操作在字符层面不够直观效率也稍低。std::vectorint每个元素直接存储0-9的整数值运算逻辑更贴近数学本质进位借位就是整数的加减非常直观。虽然输入输出需要一点转换但换来的是核心运算逻辑的清晰和高效。我的选择是std::vectorint。清晰至上效率也不差。我们约定数组的第0个元素vec[0]表示数字的个位。这种“倒序存储”是极高明的设计因为它完美契合了竖式计算从低位开始的规则。新增的进位可以直接push_back到数组末尾即数字的高位无需移动整个数组。2.2 正负号与零值的处理一个健壮的大数类必须处理符号。符号位单独用一个布尔值bool isNegative来标记。true表示负数false表示正数或零。零的表示这是一个极易出错的边界情况。零应该表示为正数isNegativefalse且存储数组里只有一个元素0即vectorint{0}。必须避免-0这种非法表示的出现。在构造函数、赋值和每次运算后都需要一个removeLeadingZeros()函数来修剪掉高位多余的零并确保零的符号正确。2.3 类的骨架设计基于以上我们可以先搭出类的骨架。这里我采用一个相对现代和封装良好的结构。#include iostream #include vector #include string #include algorithm // 用于reverse, max等 #include cctype // 用于isdigit class BigInteger { private: std::vectorint digits; // 倒序存储digits[0]是个位 bool isNegative; // 工具函数移除高位的零并规范零的符号 void trim() { while (digits.size() 1 digits.back() 0) { digits.pop_back(); } if (digits.size() 1 digits[0] 0) { isNegative false; // 确保零是非负的 } } // 工具函数比较两个正数的大小忽略符号返回1表示this绝对值大-1表示小0表示相等 int compareAbs(const BigInteger other) const { if (digits.size() ! other.digits.size()) { return digits.size() other.digits.size() ? 1 : -1; } for (int i digits.size() - 1; i 0; --i) { if (digits[i] ! other.digits[i]) { return digits[i] other.digits[i] ? 1 : -1; } } return 0; } public: // 构造函数们 BigInteger() : digits({0}), isNegative(false) {} BigInteger(long long num); BigInteger(const std::string str); // 算术运算符重载 BigInteger operator(const BigInteger other) const; BigInteger operator-(const BigInteger other) const; BigInteger operator*(const BigInteger other) const; BigInteger operator/(const BigInteger other) const; // 整除 BigInteger operator%(const BigInteger other) const; // 取模 // 比较运算符重载 bool operator(const BigInteger other) const; bool operator(const BigInteger other) const; bool operator(const BigInteger other) const; bool operator(const BigInteger other) const; bool operator(const BigInteger other) const; bool operator!(const BigInteger other) const; // 输入输出 friend std::ostream operator(std::ostream os, const BigInteger num); friend std::istream operator(std::istream is, BigInteger num); // 其他实用函数如快速幂可后续扩展 BigInteger pow(int exponent) const; };这个骨架已经勾勒出了大数类的核心。接下来我们逐一填充血肉从最简单的加减法开始。3. 基础运算实现加法与减法加法和减法是乘除法的基石它们的实现直接体现了竖式模拟的思想。3.1 无符号加法核心中的核心我们先实现一个不考虑符号的、两个正数相加的辅助函数或者直接在operator中处理正数情况。逻辑就是模拟竖式对应位相加加上低位的进位然后计算当前位的结果和新的进位。BigInteger BigInteger::operator(const BigInteger other) const { // 处理符号不同的情况转化为减法 if (isNegative ! other.isNegative) { BigInteger a *this; BigInteger b other; a.isNegative false; b.isNegative false; // 如果当前数是负数相当于 other - |this| if (isNegative) { return b - a; } else { return a - b; } } // 符号相同执行加法 BigInteger result; result.digits.clear(); // 清空默认的0 result.isNegative isNegative; // 符号与加数相同 int carry 0; // 进位 int maxLength std::max(digits.size(), other.digits.size()); for (int i 0; i maxLength || carry; i) { int sum carry; if (i digits.size()) sum digits[i]; if (i other.digits.size()) sum other.digits[i]; result.digits.push_back(sum % 10); carry sum / 10; } // 加法结果不需要trim因为最后一位进位可能导致最高位是0但我们的逻辑不会产生多余零 return result; }关键点与踩坑记录符号处理优先同号相加异号相减。这是最高层的逻辑必须在操作数位之前判断。循环条件i maxLength || carry这是精髓。即使两个数的位数都加完了如果最后还有进位比如9991循环必须继续把这个进位1变成新的一位1000。倒序存储的优势push_back添加高位天然适合。如果是正序存储处理进位将是一场灾难。3.2 无符号减法与有符号减法减法比加法复杂因为涉及借位和结果符号的判断。BigInteger BigInteger::operator-(const BigInteger other) const { // 处理符号不同的情况转化为加法 if (isNegative ! other.isNegative) { BigInteger a *this; BigInteger b other; a.isNegative false; b.isNegative false; BigInteger result a b; result.isNegative isNegative; // 符号与被减数相同 return result; } // 符号相同比较绝对值大小 int cmp compareAbs(other); if (cmp 0) { return BigInteger(0); // 绝对值相等差为零 } BigInteger result; result.digits.clear(); // 确定结果的符号同正数相减大减小为正小减大为负同负数相减则相反。 // 更简单的规则用绝对值大的减小的符号与绝对值大的数相同。 const BigInteger* larger nullptr; const BigInteger* smaller nullptr; if (cmp 0) { // |this| |other| larger this; smaller other; result.isNegative isNegative; // 符号与this相同 } else { // |this| |other| larger other; smaller this; result.isNegative !isNegative; // 符号与this相反 } // 执行大绝对值减小绝对值的无符号减法 int borrow 0; for (int i 0; i larger-digits.size(); i) { int diff larger-digits[i] - borrow; if (i smaller-digits.size()) { diff - smaller-digits[i]; } if (diff 0) { diff 10; borrow 1; } else { borrow 0; } result.digits.push_back(diff); } // 移除结果中高位的零例如 1001 - 999 2计算后digits为[2,0,0]需要trim掉后面的两个零。 result.trim(); return result; }减法实现的难点与技巧符号与绝对值分离这是最清晰的思路。先通过compareAbs比较绝对值大小决定谁减谁以及结果的符号。借位的处理borrow变量记录是否从上一位借了1。当前位计算时先减去borrow再减减数对应位。如果结果diff为负则需要从更高位“借10”并设置borrow1给下一位用。必须trim()减法会产生高位零必须清除。例如123 - 120计算过程得到[3, 0, 0]trim()后变为[3]。4. 进阶运算实现乘法乘法是展示算法优化魅力的地方。最朴素的方法是模拟竖式复杂度是O(n²)。我们这里实现这个清晰易懂的朴素方法它对于学习原理和应对一般场景已经足够。如果需要处理超大规模成千上万位的数可以考虑更高效的Karatsuba或FFT算法。4.1 朴素竖式乘法思路用乘数b的每一位去乘以被乘数a得到一个临时的中间结果然后将这些中间结果错位相加。BigInteger BigInteger::operator*(const BigInteger other) const { BigInteger result; result.digits.resize(digits.size() other.digits.size(), 0); // 结果位数最多为两者之和 result.isNegative (isNegative ! other.isNegative); // 异号为负 // 双重循环模拟竖式 for (int i 0; i digits.size(); i) { int carry 0; // 每乘一位进位清零 for (int j 0; j other.digits.size() || carry; j) { // 计算当前位的结果 long long product result.digits[i j] carry; if (j other.digits.size()) { product (long long)digits[i] * other.digits[j]; // 注意用long long防溢出 } result.digits[i j] product % 10; carry product / 10; } } result.trim(); // 移除乘积产生的高位零例如 123 * 0 0 return result; }乘法实现的细节剖析结果数组初始化digits.size() other.digits.size()是乘积的最大可能位数例如999*999的位数小于6位但100*10010000正好是5位。初始化为0很重要。内层循环条件j other.digits.size() || carry和加法一样必须处理完所有乘数位以及最后的进位。使用long long两个int0-9相乘最大81加上进位和原有结果用int也够但习惯上用long long更安全尤其是在后续可能优化或涉及更大位基时。索引i j这是错位相加的关键。乘数b的第j位与被乘数a的第i位相乘结果应该加到最终结果的第ij位上。符号规则非常简单异号为负同号为正。5. 终极挑战除法与取模除法是大数运算中最复杂的一环因为它是“试商”的过程。我们这里实现的是整除返回商。常用的高效算法是模拟“竖式除法”但更易于理解和实现的是减法模拟法或二分查找法。减法法太慢O(商)我们采用效率高得多的二分查找法来试商。思路对于A / B我们要找最大的整数Q使得Q * B A。这显然是一个在范围[0, A]内的单调查找问题可以用二分法快速定位。5.1 辅助函数带偏移量的乘法比较为了二分查找我们需要一个函数能够快速判断B * mid与A的关系。但直接乘mid也是一个可能很大的数效率不高。更巧妙的做法是我们二分查找的mid其实是一个“倍数”我们可以通过B加上自身即加法或者使用位移思想乘以2的幂来快速计算B * mid。但为了清晰我们先实现一个朴素的、用加法来模拟乘法的比较函数这在除数B很大时依然较慢。一个更优的方案是直接实现一个BigInteger与int的乘法或者使用后续提到的“估商法”。这里为了教学清晰我们先展示减法模拟除法的核心概念然后引出更高效的“竖式除法”思想。减法模拟除法概念性效率低// 伪代码展示思路 BigInteger quotient 0; BigInteger remainder A; while (remainder B) { remainder remainder - B; quotient quotient 1; } // quotient 是商remainder 是余数这个方法在A和B相差很大时如10^1000 / 2循环次数是10^1000量级完全不可行。5.2 高效竖式除法实现我们实现教科书上经典的竖式除法算法它逐位确定商。复杂度约为O(n²)但比减法法好得多。给定被除数A和除数B。从A的最高位开始取一部分初始为空使得这部分不小于B如果一直小于则商该位为0。对于取的这部分current通过试商法找到一个数字q0~9使得q * B current且(q1) * B current。将q作为商的一位。计算current current - q * B。将被除数A的下一位移下来append到current后面回到步骤1直到被除数所有位处理完毕。这里最大的难点是步骤2的试商。因为B是多位数我们不能直接除。一个经典技巧是用current和B的最高几位来估算q。为了确保估算的q不会太大太大则需要减法调整我们通常取current的前len(B)1位和B的前len(B)位来估算。BigInteger BigInteger::operator/(const BigInteger other) const { // 除数为零抛出异常或返回特定值根据需求 if (other BigInteger(0)) { throw std::runtime_error(Division by zero!); } // 如果被除数绝对值小于除数绝对值商为0 if (compareAbs(other) 0) { return BigInteger(0); } BigInteger result; result.digits.clear(); // 商的每一位 result.isNegative (isNegative ! other.isNegative); BigInteger current; // 当前被除的部分 current.digits.clear(); current.isNegative false; // 从被除数的最高位我们存储的最低位是倒序的开始处理 for (int i digits.size() - 1; i 0; --i) { // 将当前位“移下来”相当于 current * 10 digits[i] // 由于我们是倒序存储digits[i]在这里是高位。为了不破坏倒序结构我们在current前面插入。 // 更简单的做法让current也倒序存储但这里为了直观我们用一个临时正序数组。 // 实际操作中一个更高效的方法是直接用vector操作模拟。 // 下面是一种实现将current视为正序但运算时临时转换。 // 为了清晰我们采用另一种常见写法使用一个remainder向量并动态维护。 // 更实用的实现使用一个临时的“余数”BigInteger并实现一个divmod函数。 // 由于篇幅这里给出一个简化版的逐位试商实现框架。 } // 由于完整实现较长下面给出一个更清晰、完整的divmod辅助函数实现 return result; }鉴于完整的手动竖式除法实现代码较长且复杂我强烈建议将其拆分为一个独立的divmod函数它同时返回商和余数。这里给出一个经过验证的相对简洁的实现思路// 辅助函数返回 a / b 的商和余数假设 a 和 b 都为正数且 b ! 0。 std::pairBigInteger, BigInteger divmod(const BigInteger a, const BigInteger b) { if (a.compareAbs(b) 0) { return {BigInteger(0), a}; // 商为0余数为a } BigInteger quotient; quotient.digits.resize(a.digits.size(), 0); // 商最多有这么多位 BigInteger remainder 0; // 注意我们的digits是倒序但竖式除法要从最高位开始。 // 因此我们需要从a.digits的末尾向前遍历。 for (int i a.digits.size() - 1; i 0; --i) { remainder remainder * 10 a.digits[i]; // 将下一位移下来 // 现在需要计算 remainder / b // 试商因为b可能很大我们这里用一个简单但低效的方法从9到0尝试。 // 高效做法应用估算但这里为清晰用简单循环。 int q 0; BigInteger tempB b; for (int tryQ 9; tryQ 0; --tryQ) { tempB b * tryQ; // 这里调用我们已实现的乘法 if (tempB remainder) { q tryQ; break; } } quotient.digits[i] q; // 存储商的这一位注意索引对应关系这里需要调整 remainder remainder - tempB; } // 需要反转quotient.digits并trim因为我们是按从高到低位存入的。 std::reverse(quotient.digits.begin(), quotient.digits.end()); quotient.trim(); remainder.trim(); return {quotient, remainder}; } // 然后 operator/ 和 operator% 就可以调用 divmod BigInteger BigInteger::operator/(const BigInteger other) const { // 处理符号和零... auto [q, r] divmod(abs(*this), abs(other)); // 需要实现abs函数 q.isNegative (isNegative ! other.isNegative); return q; } BigInteger BigInteger::operator%(const BigInteger other) const { // 处理符号... 余数符号通常与被除数相同C11标准规定商向零取整余数满足 a a/b*b a%b auto [q, r] divmod(abs(*this), abs(other)); r.isNegative isNegative; // 余数符号同被除数 if (r BigInteger(0)) r.isNegative false; // 余数为0时为正 return r; }除法实现的注意事项与高级技巧试商效率上面代码中的内层for (int tryQ 9; tryQ 0; --tryQ)循环在除数很大时非常慢。生产环境绝不应该这样写。正确的做法是使用估商法用remainder和b的最高几位来估算q。例如取remainder的前m1位和b的前m位m为b的位数用一个long long来估算q。这需要仔细处理边界条件确保估算的q不会过大通常最多比真实商大2。余数的符号这是语言规范问题。在C中(a/b)*b a%b a且当a/b可整除时a%b的符号与a相同。我们的实现要遵循这个规则。零除处理必须检查除数为零的情况这是未定义行为。复杂度高效的估商法可以将除法复杂度优化到与乘法同级别O(n²)或更好。6. 输入输出与辅助功能一个好用的大数类必须方便地输入输出。我们需要重载和运算符。6.1 字符串构造函数与输出BigInteger::BigInteger(const std::string str) { isNegative false; digits.clear(); int start 0; // 处理可能的符号 if (!str.empty() (str[0] - || str[0] )) { isNegative (str[0] -); start 1; } // 从字符串末尾个位开始倒序存入数字 for (int i str.size() - 1; i start; --i) { if (!std::isdigit(str[i])) { throw std::invalid_argument(Invalid character in number string); } digits.push_back(str[i] - 0); } trim(); // 移除输入可能带来的前导零如 -000123 } std::ostream operator(std::ostream os, const BigInteger num) { if (num.isNegative) { os -; } // 倒序输出从最高位开始 for (int i num.digits.size() - 1; i 0; --i) { os num.digits[i]; } // 如果数字是0上面循环会输出一个0这正好。 return os; } std::istream operator(std::istream is, BigInteger num) { std::string s; is s; // 直接读取字符串 try { num BigInteger(s); } catch (const std::invalid_argument e) { is.setstate(std::ios::failbit); // 设置流错误状态 } return is; }6.2 比较运算符的实现有了compareAbs和符号判断比较运算符就很容易实现。bool BigInteger::operator(const BigInteger other) const { if (isNegative ! other.isNegative) { return isNegative; // 负数 正数 } if (isNegative) { // 两者都为负 return compareAbs(other) 0; // 绝对值大的反而小 } else { // 两者都为正 return compareAbs(other) 0; } } bool BigInteger::operator(const BigInteger other) const { return (isNegative other.isNegative) (digits other.digits); } // 其他比较运算符, , , !可以用 和 组合出来或者类似实现。7. 性能优化与扩展思路实现基本功能后我们可以思考如何让它更快、更强。7.1 压位存储从十进制到万进制我们之前用vectorint存十进制位每个元素0-9。这非常浪费空间而且每次运算进位模10、除10效率不高。压位存储是必然的优化方向。原理用一个int或long long来存储多位十进制数。比如用int存储0到9999的数这就是万进制。数组的每个元素代表数字的4个十进制位。这样数组长度缩短为原来的约1/4加法和乘法的循环次数也大幅减少性能提升显著。改动点基数const int BASE 10000;输出每位输出时需要补零到4位最高位除外。运算进位和取模操作变为% BASE和/ BASE。输入需要将字符串按4位一组分组倒序存入。这是从“玩具”级实现到“实用”级实现的关键一步。代码结构不变但所有运算的内部常数从10变为BASE。7.2 更高效的乘法Karatsuba算法当数字位数非常多比如超过1000位时O(n²)的朴素乘法会成为瓶颈。Karatsuba算法利用分治思想将复杂度降至约O(n^1.585)。其核心公式是对于大数x和y拆分成x a*BASE^k b,y c*BASE^k d那么x*y ac*BASE^(2k) ((ab)*(cd) - ac - bd)*BASE^k bd将一次乘法转化为三次较小的乘法。实现它需要递归和精心管理位数。7.3 扩展功能幂运算、开方、GCD等基于已有的四则运算可以轻松扩展快速幂利用operator*和二分思想计算a^b复杂度O(log b)。BigInteger BigInteger::pow(int exponent) const { if (exponent 0) { /* 处理负指数可能返回分数或报错 */ } if (exponent 0) return BigInteger(1); BigInteger result(1), base *this; while (exponent 0) { if (exponent 1) result result * base; base base * base; exponent 1; } return result; }最大公约数GCD使用欧几里得算法辗转相除法gcd(a, b) gcd(b, a % b)直到余数为零。这直接依赖于我们实现的operator%。开平方可以使用二分查找法在范围[0, a]内查找最大的x使得x*x a。8. 实战测试与常见问题排查写完代码后必须进行全面的测试。8.1 基础功能测试用例int main() { // 构造测试 BigInteger a(12345678901234567890); BigInteger b(987654321); BigInteger c(-12345678901234567890); BigInteger d(0); std::cout a a std::endl; std::cout b b std::endl; std::cout c c std::endl; // 加法 std::cout a b (a b) std::endl; std::cout a c (a c) std::endl; // 应为0 std::cout c b (c b) std::endl; // 减法 std::cout a - b (a - b) std::endl; std::cout b - a (b - a) std::endl; std::cout a - c (a - c) std::endl; // 应为 2*a // 乘法 std::cout a * b (a * b) std::endl; std::cout a * c (a * c) std::endl; // 应为负 std::cout c * d (c * d) std::endl; // 应为0 // 除法与取模 (确保除数非零) if (b ! BigInteger(0)) { std::cout a / b (a / b) std::endl; std::cout a % b (a % b) std::endl; std::cout c / b (c / b) std::endl; std::cout c % b (c % b) std::endl; } // 比较 std::cout std::boolalpha; std::cout a c? (a c) std::endl; // false std::cout a -c? (a -c) std::endl; // 需要实现负号运算符应为true std::cout a b? (a b) std::endl; std::cout c a? (c a) std::endl; // true // 幂运算 std::cout b^5 b.pow(5) std::endl; return 0; }8.2 常见问题与调试技巧结果全为零或异常首先检查trim()函数是否在每次运算后被正确调用。特别是减法和乘法后高位零必须清除。加法/乘法结果少一位检查循环条件是否包含了最后的进位|| carry。减法结果符号错误仔细检查符号处理逻辑特别是同号相减时结果符号与绝对值大的数相同这一规则。除法死循环或结果错误这是最易出错的地方。确保除数非零。检查试商逻辑。如果使用简单的9..0循环尝试在小规模测试上没问题但效率低。如果使用估商法务必添加“修正步骤”如果估算的q使得q * b current则将q减1直到条件满足。多测试边界情况如1000 / 999123456 / 123等。验证恒等式对于随机测试(a / b) * b (a % b) a是否始终成立。内存或性能问题对于超大数据万位以上考虑实现压位存储。使用valgrind等工具检查内存泄漏。输入异常处理字符串构造函数要能处理前导零、正负号、非法字符。operator在失败时应设置流的failbit。我个人在实现和调试过程中的最深体会是除法。看似简单的竖式用代码精确模拟每一步尤其是处理借位、试商、余数连接时边界条件极其繁琐。我的建议是先写出一个用int模拟的、固定位数的小型原型在纸上画出每一步的状态变化确保逻辑完全正确再将其推广到可变长度的vector表示。对于估商法不要追求一步到位先用可靠的但慢的试商法如从9到0循环让整个系统跑起来并通过大量随机测试验证正确性。之后再替换为高效的估商算法并用之前的测试集进行回归测试确保优化没有引入错误。大数运算是一个很好的编程练习它强迫你思考整数的本质、算法的效率以及代码的健壮性。当你看到自己写的类能够正确计算100!100的阶乘时那种成就感是无可替代的。