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

资讯详情

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

C++高精度计算:从原理到实现,手把手教你构建大整数类

C++高精度计算:从原理到实现,手把手教你构建大整数类 1. 为什么我们需要自己动手“造”一个计算器在C的世界里我们习惯了int、long long这些内置的整数类型。它们用起来方便速度快但都有一个绕不开的硬伤范围有限。以最常见的64位系统为例long long的最大值大约是9.22e18一个19位的数字。这个数字大吗对于日常计算比如算算工资、统计人数它绰绰有余。但一旦你踏入算法竞赛、密码学、科学计算或者金融领域的门槛这个限制就显得捉襟见肘了。想象一下这些场景你需要计算两个100位的质数相乘的结果你需要处理一个天文数字级别的阶乘比如1000!或者在一个金融系统中你需要精确计算到小数点后几十位的利率复利而浮点数的精度损失是完全不可接受的。在这些时候内置的整数类型和浮点数类型就“爆”了——它们会溢出导致结果错误或者因为精度问题产生微小的偏差而这点偏差在金融领域可能就是巨大的损失。这就是“大数模拟”或“高精度计算”登场的时刻。它的核心思想非常朴素既然一个变量装不下我就用多个变量来装。更具体地说我们用数组或者字符串来存储一个超长数字的每一位然后自己定义一套规则来模拟我们小学就学过的竖式加减乘除。这个过程本质上就是在用代码“再造”一个不受位数限制的计算器。虽然速度比不上硬件直接支持的运算但它提供了绝对的精确性和无限的理论上受限于内存位数扩展能力。网上有很多现成的库比如C的GMPJava的BigIntegerPython原生就支持大整数。那为什么我们还要自己实现呢对于学习者而言亲手实现一遍是理解计算机如何表示和操作数据、锻炼编程思维和边界条件处理能力的绝佳机会。你会深刻理解“进位”、“借位”这些基本概念在代码中如何流转也会遇到并解决一系列典型的工程问题比如前导零的处理、正负号的统一管理、除法的复杂边界等。这行代码是连接数学抽象与计算机实现的桥梁。2. 地基如何用代码“表示”一个大数动手写算法之前我们必须先解决一个根本问题在内存里怎么摆放这个“大数”不同的存储方式直接决定了后续所有算法的实现难度和效率。2.1 存储结构选型数组、字符串与容器最常见的方案有三种C风格数组、std::string和std::vector。C风格数组int digits[1000]这是最经典、最高效也是很多竞赛教程首选的方式。它直接在栈或堆上分配一块连续内存访问速度极快。但缺点也很明显长度固定需要预先估计一个足够大的空间比如1000位不灵活且需要手动管理下标容易出错。std::string将数字以字符串形式存储例如“123456789”。它的优势是输入输出极其方便直接cin/cout或者getline即可并且自带长度信息。但劣势在于每次进行运算时都需要将字符‘0’转换为数字0计算完再转回去有额外的性能开销。在进行乘除等复杂运算时对每一位的操作不如直接使用整型数组直观。std::vectorint我认为这是平衡了性能、安全性和灵活性的最佳选择。它像数组一样在内存中连续存储访问效率高它的大小可以动态增长我们不需要关心最大位数它提供了丰富的成员函数如push_back,pop_back,size,clear让我们的代码更安全、更简洁。本文的实现将主要基于std::vectorint。2.2 核心设计倒序存储与符号处理确定了用vector接下来是关键的存储策略。我们约定以下两点倒序存储这是高精度计算中最巧妙也最重要的一个设计。我们让下标0的位置存储数字的个位下标1存储十位以此类推。为什么为了进位方便。在竖式计算中我们是从最低位个位开始计算并处理向高位的进位。如果正序存储下标0存最高位那么当我们在最低位产生进位时就需要在所有数字的前面插入一位这是一个O(n)的操作非常低效。而倒序存储时进位只需要在vector的末尾push_back一个新元素这是O(1)的摊销操作效率极高。例如数字12345在vectorint A中将被存储为A [5, 4, 3, 2, 1]。符号独立我们将数字的符号正负用一个独立的布尔变量is_negative来标记true表示负数false表示正数。数字的绝对值部分则用vector来存储。这样做可以将符号的逻辑与绝对值的计算分离开大大简化代码。例如-12345表示为is_negative true,A [5, 4, 3, 2, 1]。基于以上设计我们可以先搭建一个BigInteger类的骨架#include iostream #include vector #include string #include algorithm // 用于reverse using namespace std; class BigInteger { private: vectorint digits; // 倒序存储每一位数字0-9 bool is_negative; // 符号位true为负 public: // 构造函数们 BigInteger() : is_negative(false) {} // 默认构造为0 BigInteger(long long num); BigInteger(const string str); // 工具函数 void trim(); // 去除前导零并处理结果为-0的情况 string toString() const; // 转换为字符串表示 // 比较运算符 (, !, , , , ) 需要先实现 bool 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; // 整除 BigInteger operator%(const BigInteger other) const; // 赋值运算符支持链式赋值 BigInteger operator(const BigInteger other); // 输入输出友元 friend istream operator(istream is, BigInteger num); friend ostream operator(ostream os, const BigInteger num); };这个类结构清晰地将数据与操作封装在一起。接下来我们从最简单的构造和辅助函数开始实现。2.3 构造函数与字符串转换的实现构造函数负责将各种类型的输入整数、字符串转化为我们内部统一的倒序vector格式。// 从long long构造 BigInteger::BigInteger(long long num) { is_negative (num 0); long long abs_num llabs(num); // 取绝对值 digits.clear(); if (abs_num 0) { digits.push_back(0); // 数字0表示为[0] } else { while (abs_num 0) { digits.push_back(abs_num % 10); // 取出个位 abs_num / 10; // 去掉个位 } } // 注意num0时is_negative是falsedigits[0] } // 从字符串构造 (例如: “-12345”, “678”, “000123”) BigInteger::BigInteger(const string str) { is_negative false; digits.clear(); int start_idx 0; // 处理符号 if (!str.empty()) { if (str[0] -) { is_negative true; start_idx 1; } else if (str[0] ) { start_idx 1; } } // 从字符串末尾开始即数字的个位向前遍历放入digits for (int i str.size() - 1; i start_idx; --i) { if (isdigit(str[i])) { digits.push_back(str[i] - 0); // 字符转数字 } else { // 简单错误处理实际可抛出异常 digits.clear(); digits.push_back(0); is_negative false; return; } } trim(); // 非常重要去除构造时可能产生的前导零例如“00123” } // 去除前导零并规范-0的情况 void BigInteger::trim() { // 从最高位vector末尾开始删除0直到剩下一位或遇到非零 while (digits.size() 1 digits.back() 0) { digits.pop_back(); } // 如果去除后只剩下一个0确保符号为正 if (digits.size() 1 digits[0] 0) { is_negative false; } } // 转换为字符串用于输出 string BigInteger::toString() const { if (digits.empty()) return 0; // 防御性代码 string result; if (is_negative) result -; // 因为存储是倒序的输出要反回来 for (auto it digits.rbegin(); it ! digits.rend(); it) { result char(0 *it); } return result; }这里有几个极易踩坑的细节trim()函数必须在所有可能产生前导零的运算后调用比如构造、加减乘除。否则像[0,0,1,2,3]代表32100这样的数会破坏我们“最高位非零”的约定导致比较和输出错误。对于-0我们必须强制将其规范化为0否则在比较和运算中会带来意想不到的麻烦比如-0 0会成立吗这不符合数学定义。字符串构造时一定要从末尾向前遍历这样才能保证digits[0]是个位。有了这些基础我们已经可以创建和输出大数了。接下来实现比较运算符这是加减法的基础。3. 比较与判断谁大谁小实现加减乘除之前必须先实现比较操作,,,,,!。因为减法和除法都需要判断两个数的大小。我们以小于运算符为例实现一个比较绝对值的函数然后在此基础上实现完整的比较逻辑。比较两个大数的绝对值大小规则很简单先比位数位数多的大位数相同则从最高位digits的末尾开始逐位比较。// 比较两个大数的绝对值大小 (this 和 other) // 返回: -1 表示 |this| |other|, 0 表示相等, 1 表示 |this| |other| int BigInteger::compareAbs(const BigInteger other) const { // 规则1位数多的绝对值大 if (digits.size() ! other.digits.size()) { return digits.size() other.digits.size() ? -1 : 1; } // 规则2位数相同从最高位向最低位比较 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; } // 小于运算符 bool BigInteger::operator(const BigInteger other) const { // 情况1符号不同负数一定小于正数 if (is_negative ! other.is_negative) { return is_negative; // this为负other为正时this other 成立 } // 情况2符号相同同正或同负 int cmp_abs compareAbs(other); if (is_negative) { // 两者都为负 // 对于负数绝对值大的反而小 return cmp_abs 1; // |this| |other| 则 this other } else { // 两者都为正 return cmp_abs -1; // |this| |other| 则 this other } } // 基于 和 可以推导出其他所有比较运算符 bool operator(const BigInteger lhs, const BigInteger rhs) { return lhs.is_negative rhs.is_negative lhs.compareAbs(rhs) 0; } bool operator!(const BigInteger lhs, const BigInteger rhs) { return !(lhs rhs); } bool operator(const BigInteger lhs, const BigInteger rhs) { return !(rhs lhs); } bool operator(const BigInteger lhs, const BigInteger rhs) { return rhs lhs; } bool operator(const BigInteger lhs, const BigInteger rhs) { return !(lhs rhs); }注意比较运算符的实现要特别注意负数的比较规则。对于负数绝对值越大数值反而越小。这是新手实现时最容易出错的地方之一。我建议在写完比较运算符后立刻写一组单元测试验证正数、负数、零之间各种组合的比较结果是否正确。4. 加法与减法重温竖式计算加法和减法是高精度计算中最基础也最能体现“模拟”思想的运算。我们将严格按照竖式计算的步骤来实现。4.1 无符号加法绝对值相加我们先实现一个核心的静态方法计算两个正数绝对值的加法。它不关心符号只负责将两个digits数组相加。// 静态方法计算两个正数向量相加返回结果向量 static vectorint addAbs(const vectorint a, const vectorint b) { vectorint result; int carry 0; // 进位 int max_len max(a.size(), b.size()); for (int i 0; i max_len || carry; i) { int sum carry; if (i a.size()) sum a[i]; if (i b.size()) sum b[i]; result.push_back(sum % 10); // 当前位结果 carry sum / 10; // 新的进位 } return result; // 结果已经是倒序且无前导零因为最后进位可能为0但循环条件保证了至少有一位 }这个函数的逻辑非常清晰从个位i0开始将对应位相加加上低位的进位然后计算当前位的结果和新的进位。循环条件i max_len || carry是关键它确保了即使两个数的所有位都加完了如果还有进位比如9991循环会继续生成最高位的1。4.2 无符号减法绝对值相减要求ab同样我们先实现一个保证a b的绝对值减法。// 静态方法计算 |a| - |b| 前提是 |a| |b| static vectorint subAbs(const vectorint a, const vectorint b) { vectorint result; int borrow 0; // 借位 for (int i 0; i a.size(); i) { int diff a[i] - borrow; if (i b.size()) diff - b[i]; // 处理借位 if (diff 0) { diff 10; borrow 1; } else { borrow 0; } result.push_back(diff); } // 减法结果可能有多余的前导零需要去除 // 例如 a[5,4,3], b[5,4,3] 结果会是 [0,0,0]我们需要去掉两个0变成[0] while (result.size() 1 result.back() 0) { result.pop_back(); } return result; }这里borrow表示从当前位向高位借了1即10。diff a[i] - borrow - b[i]如果diff为负就需要从更高位借位同时borrow置为1留给下一位用。最后必须调用一个类似trim的操作去除前导零因为123 - 123的结果是[0,0,0]我们需要它变成[0]。4.3 完整的加法运算符重载现在结合符号处理实现完整的operator。BigInteger BigInteger::operator(const BigInteger other) const { BigInteger result; // 情况1同号绝对值相加符号不变 if (is_negative other.is_negative) { result.digits addAbs(this-digits, other.digits); result.is_negative is_negative; // 继承相同的符号 } // 情况2异号转化为绝对值相减 else { int cmp this-compareAbs(other); if (cmp 0) { // |this| |other| result.digits subAbs(this-digits, other.digits); result.is_negative this-is_negative; // 结果的符号与绝对值大的数相同 } else { // |this| |other| result.digits subAbs(other.digits, this-digits); result.is_negative other.is_negative; // 结果的符号与绝对值大的数相同 } } result.trim(); // 关键处理结果可能为0的情况并规范符号 return result; }逻辑分支同号相加简单绝对值相加符号不变。异号相加本质上是一个减法。结果的符号与绝对值更大的那个数的符号相同。所以先比较绝对值大小然后用大的绝对值减去小的绝对值。4.4 完整的减法运算符重载减法a - b可以转化为加法a (-b)。所以我们可以利用已经实现的加法。BigInteger BigInteger::operator-(const BigInteger other) const { // 构造一个与other数值相等、符号相反的临时对象 BigInteger neg_other other; neg_other.is_negative !neg_other.is_negative; // 符号取反 // 然后调用加法 return (*this) neg_other; }这个实现非常简洁也保证了逻辑的正确性。它依赖于我们加法中完善的符号处理机制。实操心得在实现加减法时一定要先写一堆测试用例特别是边界情况。比如0 (-123)(-123) 0123 (-123)(-456) - (-456)999999999 1。自己手动算一遍结果再用程序跑对比是否一致。符号处理是这里的重灾区多测试才能保证稳健。5. 乘法从朴素到优化乘法是高精度计算中第一个性能瓶颈。最直观的方法是模拟竖式乘法我们称之为“朴素乘法”。5.1 朴素竖式乘法对于两个大数Am位和Bn位我们让B的每一位从个位开始去乘以整个A然后将结果错位相加。这就像一个二维的计算过程。BigInteger BigInteger::operator*(const BigInteger other) const { BigInteger result; int m this-digits.size(); int n other.digits.size(); // 结果的最大位数是 mn (例如 99*999801 224位) result.digits.resize(m n, 0); // 初始化为0 // 双重循环模拟竖式 for (int i 0; i m; i) { int carry 0; // 每一行内部的进位 for (int j 0; j n; j) { // 当前位的乘积加上之前的进位再加上该位置原有的值来自低位的进位 int sum result.digits[i j] this-digits[i] * other.digits[j] carry; result.digits[i j] sum % 10; carry sum / 10; } // 处理最高位的进位 if (carry 0) { result.digits[i n] carry; // 注意是 因为可能连续进位 } } result.trim(); // 去除前导零比如乘以0的情况 // 符号规则同号得正异号得负 result.is_negative (this-is_negative ! other.is_negative); // 特殊处理如果结果是0符号应为正 if (result.digits.size() 1 result.digits[0] 0) { result.is_negative false; } return result; }这个算法的时间复杂度是O(m*n)对于位数不大的数几百位以内完全够用且实现简单不易出错。result.digits[ij]这个索引是关键它实现了错位相加A的第i位实际是10^i乘以B的第j位10^j结果应该加到第ij位上。5.2 性能优化Karatsuba算法当数字的位数非常大比如上万位时O(n²)的朴素乘法就会变得很慢。这时可以考虑更高效的算法最著名的就是Karatsuba算法。它的核心思想是“分治”将两个大数X和Y各自分成两半通过三次递归乘法来代替四次将时间复杂度降低到约O(n^1.585)。假设X A * 10^k B,Y C * 10^k D其中k大约是位数的一半。那么朴素计算需要AC,AD,BC,BD四次乘法。Karatsuba发现X*Y AC * 10^(2k) [(AB)(CD) - AC - BD] * 10^k BD这里只需要计算三次乘法AC、BD和(AB)(CD)。实现Karatsuba算法需要处理更复杂的位数分割、合并以及符号问题代码比朴素乘法复杂得多。对于绝大多数应用场景位数在几千以内朴素乘法已经足够。但了解这个优化思路是很有价值的当你真正需要处理超大规模计算时就知道该从哪里入手了。踩坑提醒在乘法实现中最容易忽略的是结果数组的初始化大小和最后的进位处理。resize(mn, 0)是安全的因为两个m位和n位的数相乘结果位数不会超过mn。内层循环结束后一定要检查carry是否不为0并正确加到result.digits[in]上这里要用因为该位置可能已经有值来自之前低位的进位叠加。6. 除法与取模最复杂的模拟除法这里指整数除法求商和余数是高精度四则运算中最复杂的一环。它无法像加减乘那样直接逐位处理而是需要模拟“试商”的过程。6.1 高精度除以低精度单精度除法这是一个相对简单的特例即被除数BigInteger除以一个普通的int类型除数。这在很多场景下很有用比如进制转换。其过程类似于我们手算除法从被除数最高位开始逐位进行。// 除法运算符重载 (整除) BigInteger BigInteger::operator/(const BigInteger other) const { // 首先处理除数为0的情况简单处理实际应抛异常 if (other BigInteger(0)) { cerr Error: Division by zero! endl; return BigInteger(0); // 返回0或抛出异常 } // 如果被除数绝对值小于除数绝对值商为0 if (this-compareAbs(other) 0) { return BigInteger(0); } BigInteger result; BigInteger current; // 当前余数或部分被除数 result.digits.resize(this-digits.size()); // 商最多和被除数位数一样多 // 从被除数的最高位我们存储的最低位开始逐位处理 // 注意我们的digits是倒序存储所以最高位在最后 for (int i this-digits.size() - 1; i 0; --i) { current.digits.insert(current.digits.begin(), this-digits[i]); // 将新位插入到当前余数最高位 current.trim(); // 去除可能的前导零 // 试商current / other // 因为other也是大数这里需要一个小函数来估算商 int digit 0; int left 0, right 9; // 商的每一位在0-9之间 // 二分查找当前位最大的商 while (left right) { int mid (left right) / 2; BigInteger product other * BigInteger(mid); // 调用我们已经实现的大数乘法 // 比较 product 和 current 的绝对值 if (product.compareAbs(current) 0) { // product current digit mid; left mid 1; } else { right mid - 1; } } result.digits[i] digit; // 存储商的当前位 // 更新当前余数: current current - digit * other if (digit 0) { current current - other * BigInteger(digit); } } // 去除商的前导零因为我们是正向存储商的每一位从高位到低位 reverse(result.digits.begin(), result.digits.end()); result.trim(); reverse(result.digits.begin(), result.digits.end()); // 再反转回来保持倒序存储 // 确定符号同号得正异号得负向零取整 result.is_negative (this-is_negative ! other.is_negative); if (result.digits.size() 1 result.digits[0] 0) { result.is_negative false; } return result; }这个实现的关键在于内层的for循环它模拟了手算除法中“拉下一位”的过程。current变量维护着当前的余数或部分被除数。我们通过二分查找0-9快速找到当前位最大的商digit使得digit * other current。然后从current中减去digit * other得到新的余数继续处理下一位。6.2 高精度除以高精度与取模上面的除法实现已经同时得到了商。而余数就是循环结束后的current值。因此取模运算%可以非常容易地实现BigInteger BigInteger::operator%(const BigInteger other) const { // 同样处理除数为0 if (other BigInteger(0)) { cerr Error: Modulo by zero! endl; return BigInteger(0); } // 如果 |this| |other| 那么 this % other this (要考虑符号) if (this-compareAbs(other) 0) { return *this; // 注意余数的符号通常与被除数相同这是C/C/Java的约定 } BigInteger current; // 重复上面除法的过程但我们只关心最后的current余数 for (int i this-digits.size() - 1; i 0; --i) { current.digits.insert(current.digits.begin(), this-digits[i]); current.trim(); int digit 0; int left 0, right 9; while (left right) { int mid (left right) / 2; BigInteger product other * BigInteger(mid); if (product.compareAbs(current) 0) { digit mid; left mid 1; } else { right mid - 1; } } if (digit 0) { current current - other * BigInteger(digit); } } // 余数的符号遵循“与被除数相同”的约定 current.is_negative this-is_negative; current.trim(); // 确保-0被规范化为0 return current; }重要注意事项整数除法的余数符号定义在编程语言中并不统一。在C和Java中余数的符号与被除数相同。即(-7) % 3 -17 % (-3) 1。我们的实现遵循了这个约定在取模运算的最后设置了current.is_negative this-is_negative。如果你需要其他约定如欧几里得余数永远非负需要在这里进行调整。6.3 除法实现的优化思路上述除法实现中的二分试商0-9对于单次运算没问题但当除数other很大时other * BigInteger(mid)这个乘法调用会比较耗时。一个常见的优化是如果除数other的位数不多比如小于等于2我们可以将其转换为long long然后用更高效的高精度除以低精度算法来计算current / other的商。这需要额外实现一个divideBySmall函数。对于通用高精度除法还有一种更高效的“牛顿迭代法”或“二分法”来求商但实现起来更为复杂。7. 输入输出与实用技巧为了让我们的BigInteger类真正好用需要重载输入输出流运算符。// 输入运算符重载 istream operator(istream is, BigInteger num) { string s; is s; // 从流中读取一个字符串 num BigInteger(s); // 利用字符串构造函数 return is; } // 输出运算符重载 ostream operator(ostream os, const BigInteger num) { os num.toString(); return os; }现在你可以像使用基本类型一样使用BigInteger了BigInteger a, b; cin a b; cout a b a b endl; cout a - b a - b endl; cout a * b a * b endl; cout a / b a / b endl; cout a % b a % b endl;7.1 性能优化与内存管理避免频繁拷贝在运算符重载中我们按值返回BigInteger这可能会引起拷贝开销。对于C11及以上确保定义了移动构造函数和移动赋值运算符编译器会进行返回值优化RVO/NRVO很大程度上避免拷贝。预留空间Reserve在addAbs、multiply等函数中可以预先用result.reserve(max_len 1)为vector预留足够空间避免push_back时多次重新分配内存。使用更高效的乘法如前所述对于超大数乘法实现Karatsuba或FFT快速傅里叶变换算法能带来数量级的性能提升。FFT可以将大数乘法的时间复杂度降到O(n log n)这是目前最优秀的算法。7.2 常见问题排查Debugging结果全是0或乱码首先检查trim()函数是否在每次运算后都被正确调用。前导零会破坏比较和输出逻辑。加减法符号错误重点测试异号相加和减法。确保比较绝对值大小的逻辑compareAbs正确并且符号分配规则结果符号与绝对值大的数相同被严格执行。乘法结果位数不对检查结果数组是否初始化为mn大小并确保内层循环的进位被正确处理到了result.digits[in]位置。除法死循环或结果错误除法是最容易出错的。确保current在每次迭代前正确更新插入新位并trim。二分试商的边界0-9是否正确。检查current current - other * digit这一步确保减法操作正确。内存泄漏我们使用std::vector所以一般不会有内存泄漏。但如果手动管理数组务必注意new/delete的配对。自己实现一遍高精度计算是一个对基本功的全面锻炼。它强迫你去思考整数的本质、进位的传递、符号的处理以及算法效率的权衡。虽然在实际项目中我们更倾向于使用成熟的库如GMP但掌握其原理能让你在遇到任何“超出范围”的整数问题时心中都有底气。
返回列表