简介:这份数据结构课程设计资源聚焦大数运算的完整实现,面向计算机专业学生及需要理解高精度计算的开发者,解决普通整型无法处理超长数值的痛点。项目覆盖大数加法、减法、乘法、除法、乘方与取模六类核心操作,并同时支持十进制与二进制大数运算,涉及进位借位处理、竖式乘法、长除法、快速幂及边乘边取模等算法思路,在密码学、分布式计算等场景中具有实用价值。资源包共35个文件,以17个txt测试数据与结果文件、5个Python验证脚本、2个C++源码及头文件为主,另含可执行文件与工程配置,压缩包约22.24MB,便于对照验证算法正确性。目前已有1353人学习下载。读者可获得完整源代码、多组输入输出样例及Python对照脚本,用于理解大数运算细节、验证结果并完成课程设计报告。
1. 大数运算:为什么long long也会溢出,而字符串却能算到底
写过大数运算的人多半有过这种体验:明明用了 64 位整数,算个阶乘到 21 就翻车,unsigned long long撑到 20 也到头了。这不是编译器的问题,而是定长类型的天花板——64 位能表示的状态只有 2^64 种,而 100 的阶乘有 158 位十进制数字,物理上就塞不进去。大数运算要解决的核心矛盾就一句话:当数值的位数超过机器字长时,如何用软件模拟出一套「无限位」的算术系统。
这份数据结构课程设计资源,把大数加法、减法、乘法、除法、乘方、取模六种运算全部实现,并且同时支持十进制和二进制两种进制。它值得搞懂的地方不在于「能算大数」这个结论,而在于它逼你回答一个底层问题:人类做竖式笔算的那套规则,怎么翻译成计算机能执行的循环和进位逻辑。搞懂这套映射,你再看任意精度库、密码学里的模幂运算、甚至浮点数的舍入误差,都会有一种「原来底层就是这么回事」的通透感。接下来从存储结构开始,一层层拆到除法试商和快速幂的实现细节。
2. 存储结构:为什么用数组存数字,而不是字符串
2.1 大数的物理表示:低位在前还是高位在前
大数在内存里最常见的表示是一个整型数组,每个元素存一位(十进制)或若干位(二进制下的 limb)。关键设计决策是下标方向:数组下标 0 存最低位还是最高位。绝大多数实现选择低位在前,原因是进位传播的方向——加法从最低位开始,进位往高位走,如果低位在下标 0,进位就是i+1,循环自然从左往右推进;如果高位在前,进位方向就和下标方向相反,每次进位都要往前插,代码会别扭很多。
// 大数的核心结构:低位存在下标 0 typedef struct { int *digits; // digits[0] 是个位,digits[1] 是十位,依此类推 int len; // 当前有效位数 int cap; // 已分配的容量 int sign; // 符号:1 正,-1 负,0 表示零 int base; // 进制基数:10 或 2 } BigNum;base字段是这份设计支持双进制的关键。同一套加减乘除逻辑,只要把进位阈值从 10 换成 2,就能在二进制模式下复用。sign单独存符号而不是用补码,是因为大数没有固定位宽,补码表示需要先确定位宽,反而把问题复杂化了。len和cap分离是动态数组的标准做法,避免每次运算都重新分配。
2.2 为什么不用字符串直接算
字符串存大数看起来更直观,"12345"一个字符一位,但每次取某一位都要做c - '0'的转换,乘法里嵌套循环访问频繁,这个转换开销会累积。更麻烦的是字符串不可变,中间结果需要频繁拼接,内存分配次数爆炸。用整型数组,每一位本身就是可运算的数值,乘法和进位直接做算术,省掉了字符与数字之间的来回转换。代价是内存占用比字符串高(一个 int 至少 4 字节存一位十进制数字,利用率只有约 1/8),但换来的运算速度提升在课程设计这个量级上完全值得。
提示:如果追求极致空间效率,可以一个 int 存 9 位十进制(因为 10^9 在 32 位 int 范围内),这就是所谓「压位」技巧,后面乘法章节会提到它的取舍。
3. 加减法:进位与借位是怎么在数组上传播的
3.1 加法的进位传播与符号处理
加法是所有运算的基础,逻辑最直接:从下标 0 开始逐位相加,加上来自低位的进位,结果对 base 取模留在当前位,商作为新进位传给下一位。循环结束后如果进位还是 1,说明结果多了一位,需要扩容。
BigNum* bn_add(BigNum *a, BigNum *b) { // 同号相加:绝对值相加,符号取 a 的符号 if (a->sign == b->sign) { BigNum *r = bn_alloc(max(a->len, b->len) + 1, a->base); int carry = 0; for (int i = 0; i < r->cap; i++) { int da = (i < a->len) ? a->digits[i] : 0; // 越界位补 0 int db = (i < b->len) ? b->digits[i] : 0; int sum = da + db + carry; r->digits[i] = sum % a->base; // 当前位 carry = sum / a->base; // 进位传给下一位 } r->sign = a->sign; bn_trim(r); // 去掉高位多余的 0,修正 len return r; } // 异号相加等价于大减小,交给减法处理 return bn_sub_signed(a, b); }bn_trim这一步容易被忽略但很关键:加法结果的高位可能全是 0(比如100 + (-100)走减法路径后),如果不修正len,后续比较大小和除法都会出错。carry用sum / base而不是硬编码/10,正是为了二进制模式下自动切换成/2。
3.2 减法的借位与大小比较
减法比加法多一个前置判断:必须先比较两个数的绝对值大小,确定结果符号,再用大减小。如果直接小减大,借位会一路传播到最高位之外,产生负数位,逻辑就崩了。
// 比较绝对值大小:返回 1 表示 |a| > |b|,-1 表示小于,0 表示相等 int bn_cmp_abs(BigNum *a, BigNum *b) { if (a->len != b->len) return a->len > b->len ? 1 : -1; for (int i = a->len - 1; i >= 0; i--) { // 从最高位往下比 if (a->digits[i] != b->digits[i]) return a->digits[i] > b->digits[i] ? 1 : -1; } return 0; }比较必须从最高位开始,因为高位权重更大。len不同时直接比len能省掉逐位扫描,这是常见的优化。确定大小后,减法循环里当da - db - borrow < 0时,当前位加base,borrow置 1,等价于向高位借了一个 base。这套借位逻辑和加法进位是对称的,理解了加法,减法只是把「进位」换成「借位」、把「加」换成「减」。
4. 乘法:O(n²) 竖式乘法与压位优化
4.1 双重循环的竖式乘法
乘法是加减法的组合放大:结果第i+j位由a的第i位和b的第j位相乘贡献,所有贡献累加后再统一处理进位。这就是小学竖式乘法的程序化表达。
BigNum* bn_mul(BigNum *a, BigNum *b) { BigNum *r = bn_alloc(a->len + b->len, a->base); // 结果最多这么多位 for (int i = 0; i < a->len; i++) { int carry = 0; for (int j = 0; j < b->len; j++) { // 当前位 = 原有累积值 + 本次乘积 + 进位 int cur = r->digits[i + j] + a->digits[i] * b->digits[j] + carry; r->digits[i + j] = cur % a->base; carry = cur / a->base; } r->digits[i + b->len] += carry; // 处理行末进位 } r->sign = a->sign * b->sign; bn_trim(r); return r; }注意r->digits[i+j]是累加而不是直接赋值,因为不同的(i,j)组合会落到同一个下标上。carry在每行结束后要单独加到i + b->len位置,这是竖式乘法里「错位相加」的体现。时间复杂度 O(n²),n 是位数,对于课程设计里几百上千位的数完全够用。
4.2 压位:用空间换时间的经典取舍
如果一位一位乘,1000 位乘 1000 位要做 100 万次内层循环。压位技巧是把 base 从 10 提到 10^9,一个 int 存 9 位十进制,位数瞬间降到约 1/9,循环次数降到约 1/81。代价是进位和取模的常数变大,且输出时需要补前导零。
// 压位模式下,base = 1000000000 // 输出时每个 limb 要补足 9 位,最高位除外 void bn_print(BigNum *a) { if (a->sign < 0) printf("-"); printf("%d", a->digits[a->len - 1]); // 最高位不补零 for (int i = a->len - 2; i >= 0; i--) printf("%09d", a->digits[i]); // 其余补足 9 位 }%09d这个格式串是压位输出的关键,少了它1000000001会被打印成11。压位和二进制模式有冲突——二进制下天然按位存,压位意义不大,所以这份设计里压位主要用在十进制模式。这是典型的「优化有适用边界」的例子,不是所有场景都该压位。
5. 除法与取模:试商法为什么是最难写对的部分
5.1 长除法的试商逻辑
除法是六种运算里最容易写错的。核心是模拟长除法:从被除数最高位开始,逐位拉下来,和当前余数拼成临时被除数,然后「试商」——猜一个商位,使得除数 × 商位 ≤ 临时被除数。试商从base-1往下试效率太低,常见优化是用临时被除数的最高两位除以除数的最高位来估算,再回退修正。
// 核心:估算商位,然后回退到正确值 int estimate_quotient(BigNum *rem, BigNum *div) { if (bn_cmp_abs(rem, div) < 0) return 0; // 用最高位估算,最多回退几次 int q = (rem->len > div->len) ? (rem->digits[rem->len-1] * rem->base + rem->digits[rem->len-2]) / div->digits[div->len-1] : 0; if (q >= rem->base) q = rem->base - 1; // 估算值封顶 // 回退:保证 div * q <= rem while (q > 0 && bn_cmp_abs(bn_mul_small(div, q), rem) > 0) q--; return q; }估算值可能偏大,所以要用while回退。回退次数在正常情况下是常数级,这是长除法能跑到 O(n²) 而不是 O(n³) 的原因。bn_mul_small是除数乘一个单位数,比完整乘法快得多。
5.2 取模与除法的关系
取模本质是除法的副产品:a mod b = a - (a / b) * b。所以实现了除法,取模几乎免费。但要注意符号约定——-7 mod 3在不同语言里结果不同(C 是 -1,Python 是 2)。这份设计里取模结果符号跟随被除数,和 C 语言一致,这是需要明确的接口约定,否则调用方会踩坑。
BigNum* bn_mod(BigNum *a, BigNum *b) { BigNum *q = bn_div(a, b); // 先求商 BigNum *prod = bn_mul(q, b); // 商 × 除数 BigNum *r = bn_sub(a, prod); // 被除数 - 乘积 = 余数 return r; // 符号跟随 a }6. 乘方与快速幂:把 O(n) 次乘法压到 O(log n)
6.1 朴素乘方与它的瓶颈
乘方最直白的实现是循环 n 次乘法,a^n做 n-1 次乘法。当 n 是几百时还能忍,n 是几万时直接卡死。问题在于每次乘法都是 O(位数²),n 次乘法就是 O(n × 位数²),指数一大就爆炸。
6.2 快速幂的二进制分解
快速幂利用指数的二进制分解:a^13 = a^(1101₂) = a^8 × a^4 × a^1。从低位到高位扫描指数,底数不断自乘(a → a² → a⁴ → a⁸),遇到指数当前位是 1 就把当前底数乘进结果。乘法次数从 n 降到 log n。
BigNum* bn_pow(BigNum *base, long long exp) { BigNum *result = bn_from_int(1, base->base); // 结果初始化为 1 BigNum *b = bn_copy(base); while (exp > 0) { if (exp & 1) // 当前二进制位是 1 result = bn_mul(result, b); b = bn_mul(b, b); // 底数自乘,对应位权翻倍 exp >>= 1; // 指数右移一位 } return result; }exp & 1判断最低位,exp >>= 1右移,这是二进制分解的标准写法。b = bn_mul(b, b)让底数按a, a², a⁴, a⁸...增长,正好对应二进制各位的权重。这个技巧和取模结合就是模幂运算,是密码学里 RSA 的核心,理解了大数快速幂,再看模幂就是多一步取模而已。
注意:快速幂里
b = bn_mul(b, b)会产生大量中间大数,内存峰值比朴素乘方高,位数增长极快,100 的 100 次方有 201 位,1000 的 1000 次方有 3001 位,分配容量时要留够。
7. 避坑与排查:大数运算里那些让人熬夜的细节
7.1 高位零没清理导致比较和除法出错
现象:100 - 100得到的结果打印是空或者0,但和真正的0比较却说不相等。原因:减法后len没修正,高位残留 0,bn_cmp_abs先比len,长度不同直接判不等。解决:每次运算结束调用bn_trim,从最高位往下扫描,遇到非零位就停,把len修正到那里;如果全是 0,len置 1 且sign置 0。
7.2 二进制模式下进位阈值写死成 10
现象:二进制大数加法结果完全错误,1 + 1得到2而不是10。原因:进位逻辑里硬编码了% 10和/ 10,没读base字段。解决:所有进位、取模、借位操作统一用base变量,base在创建时根据模式设为 10 或 2。这是「魔法数字」的典型翻车现场。
7.3 除法试商估算偏大导致死循环
现象:除法跑着跑着卡住,或者商位出现负数。原因:估算公式在除数最高位很小时(比如除数是1000...)估出的商偏大,回退循环条件写错,q减到 0 还在减。解决:回退循环加q > 0保护,估算值先封顶到base - 1,并且每次回退后重新比较,确保div × q ≤ rem严格成立。
7.4 乘方指数用 int 导致大指数溢出
现象:算2^100时指数传进去变成负数或奇怪的值。原因:指数参数用了int,但调用方传的是更大的数,或者指数在中间计算里溢出。解决:指数用long long,并且在快速幂入口检查指数非负;如果要做大指数(指数本身也是大数),需要另一套基于指数二进制的迭代,不能直接用long long。
7.5 内存泄漏:每个中间结果都要释放
现象:跑一批运算后内存持续上涨,最后 OOM。原因:大数运算里bn_mul、bn_add每次都malloc新结构,调用方忘了free中间结果。解决:约定「谁分配谁释放」,或者引入引用计数;课程设计里更简单的做法是每个函数返回新对象,调用方用完立即释放,快速幂里b = bn_mul(b, b)这种自赋值要先释放旧的b。
8. 设计取舍:为什么这套实现值得逐行读一遍
大数运算的实现方式有很多种,这份课程设计选了最「笨」但最透明的一条路:数组存位、竖式模拟、O(n²) 乘法、试商除法。它没有用 Karatsuba 分治乘法,没有用牛顿迭代求倒数来做除法,也没有用 FFT 加速。这不是能力不够,而是教学场景下的正确取舍——每一种高级算法都建立在基础算法之上,如果连竖式乘法的进位传播都没亲手写过,看 Karatsuba 的分治合并只会一头雾水。
我一般会这么理解这套代码的价值:它是一面镜子,照出你对「进位」「借位」「试商」这些词的理解是不是真的落地了。很多人能背出快速幂的公式,但让他处理exp为 0、base为 0、结果为负这些边界,就开始含糊。这份实现把这些边界都摊开在代码里,bn_trim、sign处理、base参数化,每一处都是踩过坑才会加的。
验证这套实现是否真的理解到位,有个简单办法:把base从 10 改成 16,看加法、乘法、除法是否还能正确工作。如果能,说明进位逻辑真正参数化了;如果不能,说明哪里还藏着硬编码的 10。再进一步,把乘法换成 Karatsuba,对比位数在几百和几千时的耗时曲线,你会直观看到 O(n²) 和 O(n^1.585) 的分岔点在哪里。这种「改一个参数看系统反应」的验证习惯,比读十遍代码都管用。
从那以后我每次拿到一个号称「支持任意精度」的实现,都会先翻它的进位逻辑和 trim 函数,看这两处有没有偷懒——这两处干净,整个实现的可信度就立住了。希望这份资源能帮你把大数运算从「知道有这么回事」变成「能讲清楚每一位是怎么进上去的」。
本文还有配套的精品资源,点击获取