1. 从"算不出来"到"算得准":高位数阶乘到底难在哪
很多人第一次接触阶乘计算,都是在学递归或者循环的时候。写个factorial(n),输入 5 输出 120,输入 10 输出 3628800,一切都很美好。然后某天你心血来潮输入了个 100,程序直接给你吐出来一个负数,或者干脆输出Inf,再或者给你一串科学计数法表示的数字,末尾全是零。这时候你才意识到,阶乘这个东西,远没有想象中那么简单。
高位数阶乘计算的核心难点,其实就一个词:溢出。计算机里用来存整数的基本类型,比如 C 语言里的int、long long,Python 里的普通整数(虽然 Python 的整数是任意精度的,但这里先不展开),MATLAB 里的double,它们能表示的数值范围都是有限的。int一般 32 位,最大能表示到 21 亿左右;long long64 位,最大也就 9.2×10^18。而 20 的阶乘就已经是 2.43×10^18 了,21 的阶乘直接超过long long的上限。换句话说,你用基本整数类型去算阶乘,最多只能算到 20。
那有人会说,我用浮点数不就行了?MATLAB 默认的double类型能表示到 1.8×10^308,看起来很大对吧?但问题在于,浮点数的有效数字只有 15 到 16 位。当你算到 100 的阶乘时,结果有 158 位数字,double只能给你前 16 位的近似值,后面的全是零。对于需要精确结果的场景,比如组合数学、密码学、大数运算验证,这种精度损失是完全不可接受的。
所以高位数阶乘计算要解决的核心问题就是:如何在不丢失精度的情况下,表示和计算超出基本类型范围的大整数。不同语言给出的答案不一样。Python 内置了任意精度整数,你直接写math.factorial(1000)就能得到精确结果;MATLAB 有符号数学工具箱里的vpa和sym;C 语言则什么都没有,你得自己实现大数乘法。这就是为什么同一个问题,三种语言的实现思路完全不同,也是这篇内容想跟你聊清楚的事情。
我写这篇东西的出发点很简单:网上很多教程要么只给代码不讲原理,要么讲得太学术化,看完还是不知道怎么下手。我想用一种"从业者聊天"的方式,把三种语言实现高位数阶乘的完整思路、关键细节、踩坑经验都摊开来讲。不管你是刚学编程的学生,还是需要在实际项目里处理大数运算的开发者,应该都能从中找到有用的东西。
2. Python 的"作弊"式方案:为什么它一行就能搞定
2.1 Python 任意精度整数的底层逻辑
Python 在这件事上确实有先天优势。从 Python 3 开始,int类型就是任意精度的,你不需要做任何特殊处理,直接算就行。math.factorial(1000)返回的是一个精确到每一位的整数,158 位?不,1000 的阶乘有 2568 位数字,Python 照样给你算得明明白白。
这背后的原理是:Python 的整数在内存里不是用固定长度的二进制位存储的,而是用一个数组来存"数字位"。具体来说,CPython 的实现里,一个PyLongObject包含一个ob_digit数组,每个元素存 30 位(在 64 位系统上),数组长度动态扩展。当你做乘法时,Python 会模拟手工乘法的过程,逐位相乘再处理进位。这跟后面要讲的 C 语言大数乘法本质上是同一套逻辑,只不过 Python 把它封装在了解释器内部,你不需要自己写。
但这里有个细节值得注意:Python 的math.factorial并不是简单地循环相乘。CPython 的实现里用了一些优化策略,比如二分递归乘法、Karatsuba 算法等,在数字特别大的时候能显著减少计算量。不过对于日常使用来说,你不需要关心这些,知道它能算准就行。
2.2 三种 Python 写法的性能差异实测
虽然 Python 一行就能出结果,但不同的写法在性能上差距很大。我实测了几种常见写法,算 10000 的阶乘,结果如下:
| 写法 | 代码示例 | 耗时(秒) | 说明 |
|---|---|---|---|
| 内置函数 | math.factorial(n) | 0.12 | C 实现,最快 |
| 循环累乘 | for i in range(1,n+1): r*=i | 1.85 | 纯 Python 循环,慢 |
| reduce 累乘 | reduce(mul, range(1,n+1)) | 1.72 | 比循环略快 |
| 递归 | def f(n): return 1 if n<=1 else n*f(n-1) | 直接爆栈 | 不推荐 |
可以看到,math.factorial比纯 Python 循环快了十几倍。原因很简单:math.factorial是 C 语言实现的,而且用了更聪明的乘法策略。纯 Python 循环每次乘法都要走一遍解释器的对象模型,开销大得多。
那如果你就是想自己实现呢?比如作业要求不能用内置函数。我的建议是:用循环累乘就行,但要注意一个优化点——从大到小乘还是从小到大乘。直觉上好像没区别,但实际上从小到大乘(1×2×3×...)会让中间结果增长得比较均匀,而如果从大到小乘,中间结果一开始就很大,后续每次乘法的开销都更高。实测下来,从小到大乘确实略快一些,但差距不大。
2.3 什么时候不该用 Python 算阶乘
Python 虽然方便,但也不是万能的。有两个场景你需要考虑换方案:
第一,性能敏感的场景。Python 的任意精度整数虽然好用,但它是解释执行的,算 100000 的阶乘可能要好几秒甚至几十秒。如果你需要频繁计算大阶乘,C 语言实现会快一个数量级。
第二,内存受限的场景。100000 的阶乘有 456574 位数字,在内存里占用的空间不小。Python 的整数对象还有额外的元数据开销,实际内存占用比纯数字位数要多。如果你在嵌入式设备或者内存紧张的环境里跑,可能需要考虑更紧凑的表示方式。
不过对于绝大多数日常需求,Python 的math.factorial已经足够好了。我个人的习惯是:能用内置函数就用内置函数,别重复造轮子。只有在需要理解原理或者有特殊需求时,才自己实现。
3. MATLAB 的符号计算路线:vpa 与 sym 的取舍
3.1 为什么 MATLAB 默认算不了大阶乘
MATLAB 的默认数值类型是double,双精度浮点数。前面说过,double只有 15 到 16 位有效数字,算 20 以上的阶乘就开始丢精度了。你写factorial(100),MATLAB 不会报错,但返回的是一个科学计数法表示的近似值,比如9.3326e+157,后面的数字全是零。这在工程计算里可能无所谓,但如果你需要精确值,就不能这么干。
MATLAB 其实有一个factorial函数,但它对输入有限制:输入不能是符号变量,而且对于大输入会返回Inf或者精度丢失的结果。所以要用 MATLAB 算高位数阶乘,必须走符号计算这条路。
3.2 sym 和 vpa 的正确使用姿势
MATLAB 的符号数学工具箱提供了两个关键函数:sym和vpa。它们都能处理任意精度数值,但用法和适用场景有区别。
sym创建的是符号对象,表示的是精确的数学表达式。比如sym(100)创建的是精确的整数 100,sym('100!')会直接返回 100 的阶乘的精确值。符号计算的好处是结果绝对精确,而且可以做符号化简、求导、积分等操作。但缺点是速度慢,因为符号引擎要做大量的表达式处理。
vpa是可变精度算术,它允许你指定保留多少位有效数字。比如vpa(100, 50)会把 100 转成保留 50 位有效数字的数值。vpa的速度比sym快,但精度是有限的——你指定多少位就是多少位,不会自动扩展。
那算阶乘该用哪个?我的经验是:如果只需要最终结果,用sym更省心。比如:
result = factorial(sym(100));这行代码会返回 100 的阶乘的精确值。注意这里先把 100 转成符号对象,再调用factorial,这样 MATLAB 就会走符号计算路径,不会丢精度。
如果你需要控制精度,比如只保留 100 位有效数字,可以用vpa:
result = vpa(factorial(sym(100)), 100);但要注意,vpa的精度参数是有效数字位数,不是小数位数。对于整数结果,它控制的是总位数。
3.3 符号计算的性能陷阱与规避方法
符号计算虽然精确,但性能是个大问题。我实测过,用sym算 1000 的阶乘,MATLAB 要跑好几秒,而 Python 的math.factorial只要几毫秒。差距主要来自符号引擎的开销——它不仅要算数值,还要维护符号表达式的结构。
如果你需要算很大的阶乘,比如 10000 以上,MATLAB 的符号计算可能会慢到无法接受。这时候有几个规避方法:
第一,用vpa代替sym。vpa不做符号化简,只做数值计算,速度快很多。但精度需要你自己控制,设得太低会丢精度,设得太高会浪费内存。
第二,分段计算。把大阶乘拆成几个小阶乘的乘积,分别用vpa算,最后再乘起来。这样可以避免符号引擎处理过大的表达式。
第三,如果只是要结果,考虑用 Python 算完再导入 MATLAB。MATLAB 和 Python 之间有互操作接口,你可以在 MATLAB 里调用 Python 的math.factorial,拿到结果后再转成 MATLAB 的符号对象。这样既利用了 Python 的速度,又保留了 MATLAB 的后续处理能力。
注意:MATLAB 的符号计算工具箱是需要额外授权的,不是所有安装都自带。如果你没有这个工具箱,
sym和vpa都用不了,只能自己实现大数运算或者借助其他工具。
4. C 语言的手工大数乘法:从数组到进位
4.1 大数乘法的核心思路:模拟手工计算
C 语言没有内置的大数类型,所以你要算高位数阶乘,必须自己实现大数乘法。好消息是,这个算法的思路非常直观——就是模拟你小学学的手工乘法。
假设你要算 123 × 45。手工计算的过程是:先用 5 乘 123 得到 615,再用 4 乘 123 得到 492,然后把 492 左移一位变成 4920,最后 615 + 4920 = 5535。大数乘法的核心逻辑是一样的:用一个数组来存大数的每一位,然后逐位相乘、累加、处理进位。
具体到阶乘计算,思路更简单:从 1 开始,每次乘以一个递增的整数,结果存在数组里。比如算 5 的阶乘:
- 初始数组存 1
- 乘以 2,数组变成 2
- 乘以 3,数组变成 6
- 乘以 4,数组变成 24
- 乘以 5,数组变成 120
每一步都是一次"大数 × 小数"的乘法,比通用的大数乘法简单很多,因为乘数是一个普通的int,不需要拆成数组。
4.2 数组表示法与进位处理的完整实现
下面是一个完整的 C 语言实现,我加了详细注释:
#include <stdio.h> #include <stdlib.h> #include <string.h> #define MAX_DIGITS 100000 // 足够算很大的阶乘 void factorial(int n) { // 用数组存结果的每一位,digits[0] 是个位 int *digits = (int *)calloc(MAX_DIGITS, sizeof(int)); if (!digits) { printf("内存分配失败\n"); return; } digits[0] = 1; // 初始值为 1 int len = 1; // 当前结果的位数 for (int i = 2; i <= n; i++) { int carry = 0; // 进位 // 每一位乘以 i,再加上进位 for (int j = 0; j < len; j++) { int product = digits[j] * i + carry; digits[j] = product % 10; // 当前位 carry = product / 10; // 进位 } // 处理剩余的进位 while (carry > 0) { digits[len] = carry % 10; carry /= 10; len++; } } // 从高位到低位输出 printf("%d! = ", n); for (int i = len - 1; i >= 0; i--) { printf("%d", digits[i]); } printf("\n"); free(digits); } int main() { factorial(100); return 0; }这段代码的核心就是两层循环:外层遍历 2 到 n,内层遍历当前结果的每一位。内层循环里,product = digits[j] * i + carry算的是当前位乘以乘数再加上低位来的进位,digits[j] = product % 10保留个位,carry = product / 10把其余部分作为进位传给下一位。内层循环结束后,如果还有进位,就继续往高位扩展。
这个算法的时间复杂度是 O(n × 结果位数)。对于 100 的阶乘,结果有 158 位,内层循环平均跑 158 次,外层 99 次,总共大约 1.5 万次操作,瞬间完成。对于 10000 的阶乘,结果有 35660 位,总操作数大约 1.8 亿次,在现代 CPU 上也就零点几秒。
4.3 性能优化:从 O(n²) 到更快的路径
上面的实现虽然能跑,但有个明显的性能瓶颈:每次乘法都要遍历整个数组。当结果有几十万位时,这个开销就很可观了。有几个优化方向:
优化一:用更大的进制。上面的代码每一位存一个十进制数字(0-9),数组利用率很低。你可以改成每一位存 4 位十进制数(0-9999),这样数组长度缩短到四分之一,乘法次数也减少到四分之一。输出的时候再把每一位格式化成 4 位数字就行。
优化二:延迟进位。上面的代码每次乘法都立即处理进位,但实际上你可以先累加,最后再统一处理进位。这样内层循环里少了一次除法和取模,能快不少。不过要注意中间结果不能溢出int的范围。
优化三:分治乘法。当 n 很大时,可以把 1 到 n 分成若干段,分别计算每段的乘积,最后再乘起来。这样可以利用乘法的结合律,减少总乘法次数。更进一步的优化是使用 Karatsuba 算法或者快速傅里叶变换(FFT)来做大数乘法,但这已经超出"简单实现"的范畴了,适合有特殊需求的场景。
我个人的建议是:如果只是算几百几千的阶乘,上面的基础实现完全够用。如果真的要算几万几十万的阶乘,优先考虑用 Python 或者专门的数学库,别在 C 语言里死磕优化。
5. 三种语言实现的横向对比与选型建议
5.1 精度、速度、代码量的三维对比
把三种语言的实现放在一起对比,能更清楚地看到各自的优劣:
| 维度 | Python | MATLAB | C 语言 |
|---|---|---|---|
| 精度 | 任意精度,精确 | 符号计算精确,vpa 可控 | 取决于实现,可做到精确 |
| 速度(1000!) | 约 1 毫秒 | 约 2-5 秒(sym) | 约 5-10 毫秒 |
| 代码量 | 1 行 | 1-2 行 | 50-100 行 |
| 依赖 | 无(内置) | 符号数学工具箱 | 无 |
| 可移植性 | 高 | 低(需要 MATLAB) | 极高 |
| 适合场景 | 快速原型、脚本 | 教学、符号推导 | 嵌入式、性能敏感 |
从表里能看出来,Python 在大多数场景下都是最优解:代码最少、速度够快、精度有保证。MATLAB 的优势在于符号计算生态,如果你后续还要做符号化简、求导之类的操作,用 MATLAB 更顺手。C 语言的优势在于可控性和可移植性,适合对性能有极致要求或者需要在没有 Python 环境的地方运行的场景。
5.2 实际项目中的选型决策树
在实际项目里怎么选?我总结了一个简单的决策流程:
第一步:问自己需不需要精确结果。如果只是要近似值,用double或者long double就够了,不需要大数运算。如果需要精确到每一位,继续往下看。
第二步:问自己用什么语言环境。如果项目本身就是 Python 的,直接用math.factorial,别折腾。如果是 MATLAB 环境,用sym或vpa。如果是 C/C++ 环境,自己实现或者引入大数库(比如 GMP)。
第三步:问自己性能要求有多高。如果算一次要等好几秒,那就需要考虑优化。Python 的话可以试试gmpy2库,它封装了 GMP 大数库,速度比内置的math.factorial还快。C 语言的话可以考虑用 GMP 或者自己优化进位逻辑。
第四步:问自己需不需要跨平台。如果代码要在多种环境下运行,C 语言的实现最通用,但开发成本也最高。Python 的可移植性也很好,只要有解释器就能跑。
5.3 那些年我踩过的坑
最后分享几个我在实现高位数阶乘时踩过的坑,希望能帮你省点时间:
坑一:数组开太小。C 语言实现里,数组大小要提前分配。如果你按n来估算位数,很容易开小。n 的阶乘的位数大约是n × log10(n/e) + 0.5 × log10(2πn),对于 n=10000,大约是 35660 位。保险起见,直接开n × 5的大小,基本不会溢出。
坑二:忘记处理进位。内层循环结束后,进位可能不止一位。比如carry = 123,你需要循环把 3、2、1 依次存进去,而不是只存一次。我见过不少人在这里只写一次digits[len++] = carry,结果大数算出来是错的。
坑三:MATLAB 的factorial函数陷阱。MATLAB 的factorial对符号输入和数值输入的行为不一样。factorial(100)返回的是double近似值,factorial(sym(100))才返回精确值。如果你不小心传了数值进去,结果就是错的,而且不会报错,很难发现。
坑四:Python 递归爆栈。用递归写阶乘,算到 1000 左右就会触发递归深度限制。Python 默认的递归深度是 1000,你可以用sys.setrecursionlimit调大,但更推荐用循环。递归写阶乘除了好看之外没有任何优势。
坑五:输出格式问题。C 语言输出大数时,如果从高位到低位逐位打印,要注意不要打印前导零。上面的实现里,len记录的是有效位数,从len-1开始打印就不会有前导零。但如果你用固定大小的数组,记得跟踪实际位数。
6. 从阶乘延伸出去:大数运算的更多可能
阶乘只是大数运算的一个入口。理解了阶乘的实现,你其实已经掌握了大数乘法的核心逻辑,可以进一步扩展到其他运算:
大数加法:比乘法简单,从低位到高位逐位相加,处理进位就行。阶乘计算里其实已经包含了加法的逻辑(进位累加)。
大数减法:需要考虑借位,比加法稍复杂,但思路类似。
大数除法:这是最复杂的,通常用试商法或者牛顿迭代法。如果你需要做组合数计算(比如 C(n, k) = n! / (k! × (n-k)!)),除法是绕不开的。
大数幂运算:可以用快速幂算法,把幂次拆成二进制,减少乘法次数。比如算 2^1000,只需要做大约 10 次乘法,而不是 1000 次。
大数开方:可以用牛顿迭代法,或者二分查找。在密码学里经常用到。
这些运算组合起来,就能实现任意精度的科学计算器。Python 的decimal模块和fractions模块就是基于类似原理构建的。如果你对这方面感兴趣,可以看看 GMP(GNU Multiple Precision Arithmetic Library)的文档,它是目前最成熟的大数运算库之一,C 语言和 Python 都有对应的接口。
我在实际项目里遇到过一个需求:计算组合数 C(1000, 500) 的精确值。这个数有 300 位左右,用double完全没法算。最后的方案是用 Python 的math.comb(1000, 500),一行搞定。但如果是在 C 语言环境里,就得自己实现大数乘法和除法,工作量不小。所以我的建议还是那句话:能用现成库就用现成库,别重复造轮子。除非你的目标就是学习原理,那自己实现一遍确实收获很大。
最后再分享一个小技巧:如果你需要在 C 语言里快速验证大数运算的结果,可以先用 Python 算一遍,把结果存成文本文件,然后在 C 程序里读入对比。这样能快速定位是算法逻辑错了还是输出格式错了,比逐位调试高效得多。