拓冰建站拓冰建站
首页 / 资讯中心 / 正文

从零实现大整数运算模块:原理、算法与工程优化

1. 从“溢出”到“无限”为什么我们需要大整数运算如果你写过C语言或者Java大概率都踩过“整数溢出”这个坑。比如在C里定义一个int它的范围大约是-21亿到21亿。当你计算一个简单的阶乘比如20!结果就会远远超出这个范围程序要么给你一个错误的结果要么直接崩溃。在Java里虽然提供了BigInteger类但很多初学者在第一次接触时也会被它那略显笨拙的API和性能问题劝退。这就是“模块计算器”或者说“大整数运算”要解决的核心问题突破硬件和编程语言原生数据类型的位数限制实现对任意大小整数的精确计算。这听起来像是一个纯粹的学术问题但实际上它的应用场景无处不在。从密码学中的RSA加密密钥长度动辄2048位、4096位到金融领域高精度货币计算避免浮点数带来的精度丢失再到科学计算中的超大数模拟甚至是像区块链中计算哈希值、处理交易金额都离不开大整数运算。一个能处理大整数的“模块计算器”本质上就是一个构建在基础数据类型之上的、更强大的数学运算引擎。我自己最初接触大整数是因为要写一个计算组合数C(n, m)的程序。当n和m稍大一点比如50选10中间结果就会大得惊人用普通的long long也根本hold不住。从那时起我就开始琢磨如何自己实现一套大数运算的“轮子”。这个过程充满了挑战但也让我对计算机如何表示和计算数字有了更深的理解。今天我就把自己实现一个简易“大整数运算模块”的思路、核心算法、踩过的坑以及优化技巧毫无保留地分享出来。我们的目标是不依赖任何第三方库从零开始用清晰的逻辑构建一个属于自己的“无限精度”整数计算器。2. 基石如何用“数组”表示一个“大整数”计算机的CPU一次性能处理的位数是固定的32位或64位。要表示一个超过这个位数的整数最直观的想法就是“拼接”。我们可以把一个大整数看作一个非常长的数字然后把它切成一段一段的每一段用一个机器能直接处理的基本数据类型比如C中的unsigned int来存储。这就像我们用十进制书写数字“123456789”可以把它理解为[789, 456, 123]每个单元存储3位十进制数。在计算机中我们通常采用基数为2^W的进制系统其中W是每个存储单元的比特数。为了计算高效和方便进位W通常取小于等于机器字长的值比如在32位系统上常用W30或31留出1-2位用于处理进位在64位系统上常用W60或63。2.1 数据结构设计符号与数值分离我们首先需要定义表示大整数的数据结构。一个经典的设计是采用符号-数值Sign-Magnitude表示法typedef struct { int sign; // 符号1表示正数-1表示负数0可以特殊表示数值0 int size; // 数组实际使用的长度 int capacity; // 数组分配的总容量用于动态扩容 uint32_t *data; // 指向存储单元的数组指针每个单元存储一个“数字位” } BigInt;这里有几个关键设计点符号独立存储sign字段单独存储正负。这比用补码表示大整数要简单得多尤其是在处理乘法和除法时。动态数组data是一个动态分配的数组用于存储数值的每一位我们称之为“limb”或“digit”。size表示当前数字实际占用了数组的前多少位。这里有一个非常重要的细节我们采用“小端序”存储即data[0]存储最低位Least Significant Digit, LSDdata[size-1]存储最高位Most Significant Digit, MSD。这样设计的好处是在进行加减法运算时从低位到高位顺序处理与手工计算的习惯一致并且扩展位数比如进位导致数字变长非常方便只需要在数组末尾添加即可。容量管理capacity记录了数组的总大小当size增长到capacity时需要重新分配更大的内存。注意为什么不用固定长度数组固定数组会限制大整数的最大位数而动态分配内存虽然增加了一点管理开销但提供了真正的“任意精度”能力。在初始化时我们可以预设一个较小的容量如4后续根据运算需要动态扩容。2.2 初始化与内存管理一切的开始有了结构体我们需要一系列辅助函数来创建、销毁和复制大整数。// 创建一个初始值为0的大整数 BigInt* bigint_create() { BigInt* bi (BigInt*)malloc(sizeof(BigInt)); bi-sign 0; // 0表示数值为0 bi-size 0; bi-capacity 4; // 初始容量 bi-data (uint32_t*)calloc(bi-capacity, sizeof(uint32_t)); // 分配并初始化为0 return bi; } // 从C语言原生整数创建大整数 BigInt* bigint_from_int(int64_t val) { BigInt* bi bigint_create(); if (val 0) { return bi; // sign已经是0 } bi-sign (val 0) ? 1 : -1; uint64_t abs_val (val 0) ? val : -val; // 将abs_val按基(2^32)分解存入data数组 while (abs_val 0) { // 确保有足够空间 if (bi-size bi-capacity) { bigint_resize(bi, bi-capacity * 2); } bi-data[bi-size] abs_val 0xFFFFFFFF; // 取低32位 abs_val 32; // 右移32位处理下一个“数字位” bi-size; } return bi; } // 调整大整数的容量 void bigint_resize(BigInt* bi, int new_capacity) { if (new_capacity bi-capacity) return; bi-data (uint32_t*)realloc(bi-data, new_capacity * sizeof(uint32_t)); // 将新分配的内存区域新增部分初始化为0 for (int i bi-capacity; i new_capacity; i) { bi-data[i] 0; } bi-capacity new_capacity; } // 销毁大整数释放内存 void bigint_destroy(BigInt* bi) { if (bi) { free(bi-data); free(bi); } }这里bigint_from_int函数揭示了核心通过循环的按位与和右移操作将一个64位整数“拆分”成多个32位的块存入数组。这个过程是理解大整数存储的基础。3. 核心算法实现加减乘除的“升级打怪”实现四则运算难度是递增的。加法和减法相对简单乘法和除法则需要更精巧的算法。3.1 加法与减法处理进位与借位加法和减法的逻辑类似手工竖式计算从最低位开始对应位相加减处理进位借位。加法步骤确定两个操作数a和b保证a的位数b的位数如果不是则交换。初始化一个结果res其容量至少为a-size 1可能产生最高位进位。遍历i从0到a-size-1sum a-data[i] carrycarry是上一轮的进位初始为0。如果i b-size则sum b-data[i]。res-data[i] sum 0xFFFFFFFF存储当前位的低32位。carry sum 32计算新的进位即sum的高位部分。如果最后carry 0则将其存入res的下一位并增加res-size。处理结果的符号同号相加符号不变异号相加实际转化为减法。减法步骤减法比加法复杂一点因为可能产生连续借位。我们实现一个绝对值减法函数bigint_abs_sub假设|a| |b|。初始化res容量至少为a-size。初始化借位borrow 0。遍历i从0到a-size-1diff (int64_t)a-data[i] - borrow。这里用int64_t是为了容纳借位后的可能负值。如果i b-size则diff - b-data[i]。如果diff 0说明当前位不够减需要向上一位借位。我们令res-data[i] diff BASEBASE 2^32并设置borrow 1。否则res-data[i] diff并设置borrow 0。循环结束后需要**修剪trim**结果由于|a| |b|结果的最高位可能为0需要循环检查并减少res-size直到最高位非零或size为1表示结果为0。踩坑实录减法后的“修剪”操作。这是我早期实现时最容易忽略的一点。如果不进行修剪一个计算如100 - 99结果在数组中的表示可能是[1, 0]size2。这个开头的0会导致后续所有比较、输出和运算都出错。务必在每次可能产生高位零的运算减法、乘法、除法后调用一个bigint_trim函数来规范化数据。完整的加减法还需要处理符号这需要根据两个操作数的符号和绝对值大小来调用bigint_abs_add或bigint_abs_sub并确定最终结果的符号。这部分的逻辑判断稍多但条理清晰。3.2 乘法从朴素算法到分治优化乘法是性能瓶颈。最朴素的算法是模拟手算双重循环时间复杂度为O(n²)其中n是位数。这对于教育目的和小数字是可以的但对于大数比如几千位就太慢了。朴素乘法实现简述// 假设a和b都是非负大整数 BigInt* bigint_naive_mul(const BigInt* a, const BigInt* b) { BigInt* res bigint_create(); bigint_resize(res, a-size b-size); // 结果最大长度为两者之和 res-size a-size b-size; for (int i 0; i a-size; i) { uint64_t carry 0; for (int j 0; j b-size; j) { uint64_t product (uint64_t)a-data[i] * b-data[j] res-data[i j] carry; res-data[i j] product 0xFFFFFFFF; carry product 32; } // 处理内层循环结束后的进位 if (carry 0) { res-data[i b-size] carry; // 注意这里是因为可能连续进位 } } bigint_trim(res); return res; }更高效的乘法算法在实际的模块计算器中我们会采用更优的算法。最著名的是Karatsuba算法它利用分治思想将一次大规模乘法转化为三次较小规模的乘法时间复杂度约为O(n^1.585)。其核心公式是 对于两个大数X和Y将其拆分为高位和低位X A * BASE^m B,Y C * BASE^m D。 则X * Y AC * BASE^(2m) ((AB)(CD) - AC - BD) * BASE^m BD。 这样原本需要计算4次乘法AC, AD, BC, BD现在只需要计算3次AC, BD, (AB)(CD)。递归应用此方法能显著提升大数乘法的速度。实操心得何时切换算法实现Karatsuba算法虽然代码复杂些但收益巨大。一个常见的策略是设置一个阈值比如当乘数的位数小于50时使用朴素的O(n²)算法因为此时分治的递归开销可能超过其收益当位数超过阈值时则切换到Karatsuba算法。在GNU MPGMP这类顶级库中还会使用更快的Toom-Cook算法Karatsuba的推广和用于极大数的FFT快速傅里叶变换乘法。3.3 除法与取模最复杂的运算除法及取模是大整数运算中最复杂的。我们通常实现一个“带余除法”即计算商q和余数r使得被除数 除数 * 商 余数且0 余数 |除数|。对于大整数除法最基础且易于理解的是**“笔算除法”模拟算法**也称为“移位相减”法。其思路是模仿我们手工做除法的过程将除数b与被除数a的高位对齐。估计当前位的商。这是一个难点因为我们需要用a的高几位来除以b的最高位并做出调整以确保估计值不会过大。用估计的商乘以除数b得到一个临时乘积tmp。比较tmp和a的当前高位部分。如果tmp更大则将商减1重新计算tmp。从a的当前高位部分减去tmp。将商的一位记录到结果中。将除数b右移一位相对于被除数重复步骤2-6直到处理完所有位。这个算法实现起来细节非常多尤其是商的估计和调整。一个更稳定、更常用的方法是Knuth的“算法D”收录于《计算机程序设计艺术》第二卷它规范化了除数使其最高位足够大从而简化了商的估计过程使其几乎总是正确的。由于除法算法实现篇幅较长这里给出其核心步骤的伪代码概念BigInt* bigint_divrem(const BigInt* a, const BigInt* b, BigInt** remainder) { // 1. 处理特殊情况除数为0被除数绝对值小于除数绝对值等。 // 2. 规范化除数计算一个缩放因子d使得 (b-data[最高位] * d) BASE/2。 // 3. 将被除数a和除数b都乘以d得到a和b。这样b的最高位足够大便于估计商。 // 4. 主循环从a的高位开始每次取若干位长度比b多1作为“当前被除数部分”。 // 5. 用当前被除数部分的高两位和b的最高位来估计商的一位q_hat。 // 6. 调整q_hat用q_hat乘以b的次高位来检验如果太大则递减q_hat。 // 7. 用q_hat乘以b从当前被除数部分中减去。如果结果为负说明q_hat仍大了1进行调整。 // 8. 将正确的q_hat存入商的对应位。 // 9. 移动窗口处理下一位。 // 10. 循环结束后得到的商是规范化的余数需要除以d即缩放因子得到真正的余数。 // 11. 处理符号商的符号由a和b的符号决定同号为正异号为负余数的符号与被除数a相同。 }重要提示除法是性能黑洞。在大整数库中除法运算通常比乘法慢一个数量级。因此在密码学等应用中会极力避免直接使用除法而是采用基于蒙哥马利模乘Montgomery Multiplication等特殊算法来优化模运算。4. 高级功能与性能优化实战实现基本的四则运算只是第一步。一个实用的模块计算器还需要比较、输出、幂模运算等功能并且必须考虑性能。4.1 比较、输出与输入比较运算, , , , 比较从最高位开始。先比较符号符号不同则正数大于负数。符号相同则比较绝对值先比较size位数多的绝对值大位数相同则从最高位data[size-1]开始逐位比较。输出转换为十进制字符串这是一个看似简单实则容易低效的操作。我们不能直接用data位累加因为可能溢出。标准方法是连续除以10取余。由于我们已有大整数除法可以这样做复制一个大整数tmp等于原数。创建一个空字符串。当tmp不等于0时用tmp除以10得到商q和余数r。r一定是一个0-9之间的整数将其转换为字符添加到字符串末尾。令tmp q。循环结束后字符串里是逆序的数字字符需要反转。最后加上符号如果是负数。这个方法调用了很多次大整数除法效率很低。优化策略是“分治转换”不以10为基数而以10^k如10^9为基数进行除法每次能得到k位十进制数大大减少除法调用次数。这需要实现一个“除以小整数比如10^9”的高效函数这比通用的大整数除法快得多。输入从十进制字符串解析与输出相反。我们可以遍历字符串对于每个字符c计算result result * 10 (c - 0)。这里的乘法result * 10是大整数乘法。同样为了优化可以一次读取多位比如9位组成一个整数然后执行result result * 1000000000 segment_value。4.2 幂模运算密码学的核心在RSA加密中核心操作是计算m^e mod n其中m,e,n都是非常大的整数。直接计算m^e再取模是不可能的因为中间结果会巨大无比。这里必须使用快速幂取模算法也称为模幂运算。其原理基于模运算的性质(a * b) mod n [(a mod n) * (b mod n)] mod n。 算法步骤如下平方-乘算法初始化结果res 1。将指数e表示为二进制形式例如e 13 (二进制1101)。从二进制最高位开始遍历到最低位对于每一位先将结果平方res (res * res) mod n。如果当前二进制位为1则再乘以底数res (res * m) mod n。遍历结束res即为m^e mod n。这个算法的时间复杂度约为O(log e)即只与指数的位数有关而与指数的大小成对数关系效率极高。BigInt* bigint_pow_mod(const BigInt* base, const BigInt* exp, const BigInt* mod) { BigInt* result bigint_from_int(1); BigInt* temp_base bigint_copy(base); // 复制底数因为我们要修改它 BigInt* temp_exp bigint_copy(exp); // 复制指数 // 循环直到指数为0 while (bigint_is_zero(temp_exp) 0) { // 如果当前指数的最低位是1 if (bigint_is_odd(temp_exp)) { // result (result * temp_base) % mod BigInt* product bigint_mul(result, temp_base); BigInt* rem; BigInt* new_res bigint_divrem(product, mod, rem); bigint_destroy(product); bigint_destroy(result); bigint_destroy(new_res); // 我们只需要余数 result rem; } // 指数右移一位除以2 BigInt* quo; BigInt* rem_exp; BigInt* two bigint_from_int(2); BigInt* new_exp bigint_divrem(temp_exp, two, rem_exp); bigint_destroy(temp_exp); bigint_destroy(rem_exp); bigint_destroy(two); temp_exp new_exp; // 底数平方: temp_base (temp_base * temp_base) % mod BigInt* square bigint_mul(temp_base, temp_base); BigInt* rem_base; BigInt* new_base bigint_divrem(square, mod, rem_base); bigint_destroy(square); bigint_destroy(temp_base); bigint_destroy(new_base); temp_base rem_base; } bigint_destroy(temp_base); bigint_destroy(temp_exp); return result; // 最终结果 }性能关键点上面的示例代码为了清晰频繁创建和销毁临时对象在实际实现中这是巨大的性能开销。一个成熟的库会使用“原地运算”即尽可能在已有的内存空间上操作避免不必要的内存分配和拷贝。例如实现一个bigint_mul_mod_inplace(BigInt* out, const BigInt* a, const BigInt* b, const BigInt* mod)函数直接将结果计算到out中。4.3 内存与计算优化策略池化内存分配器频繁的malloc和free是性能杀手。可以预先分配一大块内存池大整数对象从池中申请和释放这能显著减少系统调用的开销。使用汇编语言优化核心循环对于最底层的、循环次数极多的操作如大数乘法的内层循环、进位传播可以用CPU特定的汇编指令如x86的ADC带进位加法指令来编写榨干硬件性能。GMP库的核心部分就是用汇编写的。采用更高效的算法如前所述用Karatsuba、Toom-Cook甚至FFT替代朴素乘法。对于特别大的数FFT乘法可以将时间复杂度降到O(n log n)。惰性规范化不是每次运算后都立即进行“修剪”去除高位零。可以在连续进行一系列中间运算时暂时容忍高位存在零在最终需要输出或比较时再进行一次统一的修剪。这减少了内存移动操作。选择合适的基数我们之前用32位uint32_t作为存储单元。在64位系统上使用64位uint64_t作为基数会更高效因为一次能处理的数据更多进位检查也更简单通过检查加法是否溢出。但需要注意乘法a * b的结果可能超过64位需要用__uint128_t如果编译器支持或拆分成两个64位来处理。5. 测试、调试与边界情况处理自己实现的大整数模块必须经过严格的测试。测试用例应该覆盖基本功能01-1等特殊值。常规运算随机生成的大数加减乘除。边界情况加法最高位进位如0xFFFFFFFF 1。减法产生大量连续借位如1000...000 - 999...999结果为零。乘法操作数中有一个为0或1。除法除数为0应返回错误被除数小于除数商为0余数为被除数被除数等于除数。与已知正确结果的对比使用Python、Java的BigInteger或成熟的库如GMP作为参照运行相同的计算比对结果。性能测试对不同位数的操作数进行压力测试确保没有内存泄漏使用Valgrind等工具并观察运行时间是否符合预期。调试大整数运算非常考验耐心。我常用的方法是编写详细的日志函数可以以十六进制或十进制打印出大整数内部的sign、size和data数组。在关键步骤前后打印状态。单元测试为每个函数add,sub,mul,div编写独立的测试从小数据开始逐步增加复杂度。对比调试当出现错误时用同样的输入在Python里计算一遍然后对比自己程序每一步的中间结果定位第一个出现差异的地方。最后一个健壮的模块还应该考虑错误处理比如内存分配失败时返回NULL除数为0时抛出错误或返回特定值并在函数接口文档中明确说明这些行为。从头实现一个大整数运算模块是一次对计算机算术和算法设计的深度旅行。它强迫你去思考数字在机器中的本质去优化每一个比特的操作。虽然最终的产品在性能上可能无法与GMP这样的工业级库相媲美但这个过程带来的对底层原理的理解和解决问题的能力提升是无可替代的。当你看到自己写的库成功计算出一个上百位的阶乘或者运行起一个简单的RSA加密演示时那种成就感会告诉你这一切都是值得的。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门