尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

C++高精度计算:从字符串模拟到BigInteger类实现

C++高精度计算:从字符串模拟到BigInteger类实现 1. 项目概述为什么我们需要自己动手实现“大数”在C的世界里我们习惯了int、long long这些内置的整数类型。它们用起来方便性能也高但都有一个绕不开的天花板位数限制。一个64位的long long其表示范围大约是正负9.2e18。这个数字看起来很大但在处理金融计算比如涉及万亿级别的金额、密码学RSA密钥通常是几百上千位、科学计算比如计算超大阶乘或高精度圆周率时这点位数就完全不够看了。当你尝试计算100!100的阶乘或者进行两个100位质数的乘法时内置类型会直接溢出结果变得毫无意义。这就是“大数模拟”或“高精度计算”要解决的问题。它的核心思想很朴素既然一个变量装不下我们就用多个变量来装用数组或者字符串来模拟一个可以无限理论上受限于内存扩展的整数。我们自己来定义这些“数字块”如何相加、如何相减、如何相乘、如何相除重新实现一套四则运算的规则。这个过程本质上是在用C的基础语法数组、循环、条件判断来构建一个更强大的数学工具是对计算机如何从底层处理数字的一次深刻理解。网上能找到很多代码片段但往往只给个骨架或者只实现加减乘除一笔带过。对于初学者尤其是正在准备技术面试的朋友光看懂别人的代码就很吃力更别提自己从头实现和应对各种边界条件了。我结合自己多年刷题和项目开发的经验把大数运算从存储设计到四则运算的每一个细节连同那些容易踩坑的地方都系统地梳理出来。目标不只是给你一份能跑的代码而是让你彻底明白每一步为什么这么做从而能够灵活应对各种变体问题。2. 核心设计如何用字符串和数组表示一个大数实现大数计算第一步是选择数据结构。常见的有两种字符串string和整数数组vector 。我强烈推荐从字符串开始因为它输入输出直观调试方便非常适合理解和教学。在性能要求极高的场景可以优化为用整型数组按位存储但那是后话。2.1 存储格式与预处理我们用字符串来存储大数比如“123456789”。但这里有一个关键细节数字的高位百位、千位和低位个位、十位在运算时的便利性冲突。运算便利性当我们手工计算加法或乘法时是从最低位个位开始对齐并计算的。如果我们的字符串下标0存储的是最高位那么每次取个位都需要计算str[len-1]不方便且处理进位时需要向前插入效率低。存储直观性字符串“123”很直观就是一百二十三。为了兼顾我们采取一个折中方案在内部运算时将字符串反转存储。也就是说我们把“123456789”在内存中表示为“987654321”。这样str[0]就对应个位‘9’str[1]对应十位‘8’以此类推。进位操作只需要向字符串末尾追加字符非常高效。仅在最终输入输出时我们再进行一次反转恢复人类的阅读习惯。预处理函数是这一切的基石。它的任务包括去除前导零比如“000123”变成“123”。检查并处理负数我们可以单独用一个布尔值isNegative来标记或者采用补码思想这里为了清晰我们用单独标记。将字符串反转为后续运算做准备。// 预处理函数去除前导零并反转字符串便于从低位开始计算 string preProcess(const string num) { string s num; // 1. 去除前导零 size_t start s.find_first_not_of(0); if (start string::npos) { // 全零情况 return 0; } s s.substr(start); // 2. 反转字符串 reverse(s.begin(), s.end()); return s; }注意这里我们暂时不处理负数正负号逻辑将在具体的运算函数里结合操作数处理。预处理只关心数字的绝对值部分。2.2 大小比较的实现在实现减法、除法之前我们必须能比较两个大数的绝对值谁大谁小。比较的规则是先比位数位数多的肯定大。位数相同则从最高位注意此时我们的字符串是反转的所以最高位在str[len-1]开始逐位比较。// 比较两个已反转的大数字符串的绝对值大小 // 返回 1 表示 a b, 0 表示 a b, -1 表示 a b int compare(const string a, const string b) { if (a.length() ! b.length()) { return a.length() b.length() ? 1 : -1; } // 长度相等从高位反转后的末尾向低位反转后的开头比较 for (int i a.length() - 1; i 0; --i) { if (a[i] ! b[i]) { return a[i] b[i] ? 1 : -1; } } return 0; // 完全相等 }这个compare函数是后续许多操作的“裁判”务必保证其正确性。3. 加法与减法的实现处理进位与借位加法和减法是最基础也是最重要的两种运算它们清晰地展示了手工竖式计算的计算机模拟过程。3.1 高精度加法加法的核心是逐位相加处理进位。我们假设处理的是两个非负大数。将两个反转后的字符串按位相加从下标0个位开始。当前位的和 数字a的当前位 数字b的当前位 上一位的进位。当前位的结果 和 % 10。新的进位 和 / 10。循环处理直到所有位都计算完毕并且进位为0。最后将结果字符串反转返回。string addStrings(const string num1, const string num2) { string a preProcess(num1); string b preProcess(num2); string result; int carry 0; // 进位 int i 0, j 0; int lenA a.length(), lenB b.length(); while (i lenA || j lenB || carry) { int digitA (i lenA) ? (a[i] - 0) : 0; int digitB (j lenB) ? (b[j] - 0) : 0; int sum digitA digitB carry; result.push_back((sum % 10) 0); // 当前位结果 carry sum / 10; // 新的进位 i; j; } // 此时result是反转的个位在开头需要反转回来 reverse(result.begin(), result.end()); return result.empty() ? 0 : result; // 防止空结果 }实操心得while循环的条件(i lenA || j lenB || carry)是精华所在。|| carry确保了即使两个数字的所有位都加完了如果最后还有进位比如9991循环也会多进行一次正确处理最高位的进位。3.2 高精度减法减法比加法复杂一些核心是逐位相减处理借位并且要确保被减数不小于减数。我们这里实现的是num1 - num2num1 num2。先用compare函数比较大小如果num1 num2则交换两者并标记结果为负。从低位开始逐位相减diff digitA - borrow - digitB。如果diff 0则需要向高位借位diff 10,borrow 1。如果diff 0则borrow 0。将diff作为当前位的结果。循环结束后需要删除结果中可能存在的尾随零因为我们在反转状态下尾随零对应的是前导零。例如计算100 - 99中间结果可能是“001”反转前需要去掉末尾的两个零变成“1”。string subtractStrings(const string num1, const string num2) { string a preProcess(num1); string b preProcess(num2); bool isNegative false; // 确保 a b if (compare(a, b) 0) { swap(a, b); isNegative true; } string result; int borrow 0; // 借位 int lenA a.length(), lenB b.length(); for (int i 0; i lenA; i) { int digitA a[i] - 0 - borrow; // 先减去之前的借位 int digitB (i lenB) ? (b[i] - 0) : 0; borrow 0; // 重置借位 if (digitA digitB) { digitA 10; // 不够减借位 borrow 1; } result.push_back((digitA - digitB) 0); } // 删除反转状态下的尾随零即最终结果的前导零 while (result.size() 1 result.back() 0) { result.pop_back(); } // 反转回正常顺序并添加负号 reverse(result.begin(), result.end()); if (isNegative) { result - result; } return result; }踩坑记录减法最容易出错的地方就是借位的处理顺序。一定要先减去上一次的借位再判断当前位是否够减决定是否产生新的借位。顺序弄反结果会完全错误。另外处理完所有位之后一定要记得清理结果中的前导零。4. 乘法的实现从模拟竖式到优化乘法是高精度运算中的性能瓶颈朴素的模拟双重循环时间复杂度是O(n²)对于位数很多的大数会非常慢。我们先理解基础版本再谈优化思路。4.1 基础竖式乘法模拟我们模拟手工乘法的过程用乘数b的每一位去乘以被乘数a的每一位将结果累加到正确的位上。初始化一个足够长的结果数组res长度为len(a) len(b)两数乘积的最大位数。双重循环对于b的每一位j和a的每一位i计算mul (a[i]-‘0’) * (b[j]-‘0’)。将mul加到结果数组的res[ij]位置上。这个ij是关键它完美对应了竖式中乘积的偏移。处理累加后的进位从低位到高位将每一位的值模10保留除以10的商加到下一位。string multiplyStrings(const string num1, const string num2) { if (num1 0 || num2 0) return 0; string a preProcess(num1); string b preProcess(num2); int lenA a.length(), lenB b.length(); vectorint res(lenA lenB, 0); // 结果数组初始化为0 // 双重循环计算每位乘积并累加 for (int i 0; i lenA; i) { for (int j 0; j lenB; j) { res[i j] (a[i] - 0) * (b[j] - 0); } } // 统一处理进位 int carry 0; for (int i 0; i res.size(); i) { int sum res[i] carry; res[i] sum % 10; carry sum / 10; } // 将结果数组转换为字符串并去除前导零 string result; int start res.size() - 1; while (start 0 res[start] 0) start--; // 跳过前导零 if (start 0) return 0; // 结果为0 for (int i start; i 0; --i) { result.push_back(res[i] 0); } return result; }4.2 乘法优化思路Karatsuba算法当数字位数非常大比如超过1000位时O(n²)的复杂度就不可接受了。这时可以考虑更高效的算法最著名的是Karatsuba算法。它的核心思想是“分而治之”将两个大数x和y分别拆分成两部分设x a * 10^(n/2) b,y c * 10^(n/2) d那么x*y ac * 10^n (adbc) * 10^(n/2) bdKaratsuba的巧妙之处在于它发现(ab)(cd) ac ad bc bd所以adbc (ab)(cd) - ac - bd。这样原本需要计算4次乘法ac, ad, bc, bd现在只需要计算3次ac, bd, (ab)(cd)。通过递归应用可以将时间复杂度降低到约O(n^1.585)。实现Karatsuba算法代码较长它涉及递归、大数加法和我们刚才实现的乘法在递归到数字足够小时可以退化为普通乘法。对于面试和大多数应用掌握上述基础乘法并理解Karatsuba的原理已经足够。如果你面临极高性能要求可以去专门实现它。注意事项基础乘法实现中res数组的大小设为lenAlenB是足够的但可能不是最紧凑的。例如100*10010000长度是336实际结果10000长度是5。所以最后需要去除前导零。另外先累加所有乘积再统一进位的做法比在双重循环内每一步都处理进位更清晰且效率差异不大。5. 除法的实现最复杂的运算除法是高精度运算中最复杂的一种因为它是试错的过程。我们这里实现的是高精度整数除法返回商和余数。我们模拟的是手工竖式除法的过程。5.1 高精度除以低精度除数可以用内置整数存储这种情况相对简单可以逐位处理。但为了和下面的高精度除以高精度统一思路我们介绍更通用的减法模拟法。5.2 高精度除以高精度核心思想是被除数dividend不断减去除数divisor的倍数直到被除数小于除数。这个“倍数”就是我们要找的商。但一次减一个除数太慢我们需要加速。算法步骤竖式模拟法将除数divisor和被除数dividend都进行预处理反转去前导零。如果dividend绝对值小于divisor绝对值则商为0余数为dividend。否则将divisor左移即在末尾补零使其位数和dividend相同或比dividend多一位。记录左移的位数shift。从高位开始试商每次将divisor右移一位即去掉末尾的一个零相当于尝试用divisor * 10^k去减。判断当前被除数是否大于等于移位后的除数。如果是则不断用被除数减去移位后的除数直到不够减为止。减的次数就是当前位上的商。将这次减法的结果更新为新的被除数。循环直到divisor被移回原样。过程中记录的所有“减的次数”就组成了最终的商。这个描述比较抽象我们看一个具体例子12345 / 67。67移位成67000shift3。比较12345和67000不够减当前位商0。divisor右移一位变成6700。12345和6700够减1次12345-67005645当前位商1。divisor右移成670。5645和670够减8次5645-8*6705645-5360285当前位商8。divisor右移成67。285和67够减4次285-4*67285-26817当前位商4。组合商0*1000 1*100 8*10 4*1 184余数17。// 返回 pair商, 余数 pairstring, string divideStrings(const string num1, const string num2) { string a preProcess(num1); // 被除数 string b preProcess(num2); // 除数 if (b 0) { throw runtime_error(Divisor cannot be zero.); } if (compare(a, b) 0) { // 被除数小于除数商为0余数为被除数需反转回正常顺序 string remainder a; reverse(remainder.begin(), remainder.end()); return make_pair(0, remainder); } string quotient; // 商 string current; // 当前被除数片段 // 从高位到低位注意a是反转的高位在末尾 for (int i a.length() - 1; i 0; --i) { current.insert(current.begin(), a[i]); // 将被除数的下一位加入当前片段 // 去除当前片段的前导零 current to_string(stoi(current)); // 小技巧转整数再转回字符串可去前导零。但注意这要求current不超过int范围仅示意。 // 更稳妥的做法是循环去除前导零。 while (current.length() 1 current[0] 0) { current.erase(current.begin()); } // 试商 int digit 0; while (compare(current, b) 0) { // 当前片段 除数 current subtractStrings(current, b); // 用之前实现的减法 digit; } quotient.push_back(digit 0); } // 去除商的前导零 size_t start quotient.find_first_not_of(0); if (start string::npos) { quotient 0; } else { quotient quotient.substr(start); } // 余数就是最后的current需要反转回正常顺序 reverse(current.begin(), current.end()); if (current.empty()) current 0; return make_pair(quotient, current); }重要提示上面的除法代码是一个简化版的思路演示其中current to_string(stoi(current));这行代码在实际大数场景下会溢出仅用于说明“去除前导零”的逻辑。一个健壮的高精度除以高精度实现非常复杂需要用到之前实现的compare和subtractStrings函数并且要处理current可能非常大的情况。完整的实现通常需要专门编写一个divideBySingleDigit或subtractMultipleTimes的函数来高效计算当前位商。这里受限于篇幅给出了核心逻辑。在面试或实际编码中如果被要求实现除法一定要和面试官沟通清楚输入范围优先实现高精度除以低精度的版本这个更常见也更容易实现无误。6. 整合与优化构建一个完整的大数类将上述分散的函数整合成一个类会大大提升易用性。我们可以设计一个BigInteger类。6.1 类的设计与构造函数class BigInteger { private: string value; // 存储数字的绝对值反转存储 bool isNegative; // 符号位 // 内部工具函数静态 static string preProcess(const string num); static int compare(const string a, const string b); static string add(const string a, const string b); static string sub(const string a, const string b); // 要求 a b static string mul(const string a, const string b); static pairstring, string div(const string a, const string b); public: // 构造函数 BigInteger(const string s 0); BigInteger(long long num); // 算术运算符重载 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 ostream operator(ostream os, const BigInteger num); friend istream operator(istream is, BigInteger num); // 转换为字符串 string toString() const; };6.2 运算符重载的实现要点在重载运算符时需要正确处理符号。例如加法正数 正数直接绝对值相加结果为正。正数 负数转化为减法a - (-b)。负数 正数转化为减法b - (-a)。负数 负数绝对值相加结果为负。减、乘、除同理都需要根据操作数的符号组合来决定调用绝对值运算的函数以及最终结果的符号。这是实现中最繁琐但也最考验逻辑严密性的部分。6.3 性能优化实践使用整型数组替代字符串字符串存储每个数字是一个字符运算时需要频繁进行c - 0和c 0的转换。可以改用vectorint每个元素直接存储0-9的整数运算更快内存也更紧凑。可以将多个十进制位压缩到一个int中如万进制进一步减少循环次数和内存占用。减少内存分配预分配足够大小的结果数组避免在循环中频繁push_back。使用更高效的算法如前所述乘法用Karatsuba除法用牛顿迭代法等。惰性求值在一些复杂表达式中可以延迟计算优化执行路径。对于绝大多数应用场景用字符串实现的版本已经足够。优化是一个无止境的过程应根据实际性能瓶颈来决定投入。7. 常见问题与调试技巧自己实现大数运算调试是必不可少的环节。下面是一些常见坑点和调试方法。7.1 典型错误与排查表问题现象可能原因排查方法加法结果少一位或多一位进位处理错误循环条件缺少 减法结果出现负数或乱码借位逻辑错误或未确保被减数减数检查compare函数是否正确。单步调试借位变量borrow的变化。用100-99123-456测试。乘法结果全为零结果数组res初始化或进位处理错误检查res数组大小是否为lenAlenB。检查双重循环中下标ij是否正确。检查进位处理循环是否覆盖了整个res数组。除法死循环或商不对试商逻辑错误当前被除数片段未正确更新打印出每一步的current片段和试商过程。用简单的例子如10/3手动跟踪。前导零未去除干净结果反转后或减法、除法后未清理前导零在返回结果前添加一个循环删除字符串末尾反转状态下或开头正常状态下连续的‘0’但要保留至少一个‘0’。处理负数时符号错误运算符重载中符号判断逻辑有遗漏列出所有正负组合情况 - - --分别测试加减乘除与计算器结果对比。7.2 单元测试是王道不要写完所有代码再测试。应该为每一个基础函数addStrings,subtractStrings,compare等编写简单的单元测试。void testAdd() { assert(addStrings(123, 456) 579); assert(addStrings(999, 1) 1000); assert(addStrings(0, 0) 0); cout All addition tests passed! endl; } // 类似地编写 testSubtract, testMultiply, testCompare从简单案例开始逐步过渡到边界案例大数、零、负数组合。使用assert语句可以快速定位失败的地方。7.3 可视化调试对于复杂的乘除法可以在关键步骤打印中间变量。例如在乘法函数里打印出res数组在累加后、处理进位前后的状态。在除法函数里打印出每一步的current片段和试得的商。这种“笨办法”往往比盯着代码苦想更有效。7.4 关于负数的处理策略本文为了清晰在运算函数内部主要处理非负数将符号判断放在运算符重载层。另一种常见策略是使用补码思想将所有运算都转化为加法。例如减法a - b可以转化为a (-b)然后统一用加法来处理其中-b用其补码表示。这种方法在硬件层面很常见但在软件实现中管理补码可能会增加复杂度。选择哪种策略取决于你的需求保持一致性最重要。实现一个完整健壮的高精度计算库是一项细致的工作它涉及对整数运算本质的深刻理解。从字符串处理到进位借位从朴素乘法到分治优化每一步都充满了编程最原始的乐趣和挑战。希望这份详细的拆解能帮你不仅写出代码更理解其背后的每一处设计考量。当你能够流畅地实现它时你对C基础和数据结构的掌握也必然会上一个坚实的台阶。
返回列表