☰
1到n阶乘之和的递推优化与高精度实现:从循环到防溢出
2026/10/21 15:43:27 网站建设 项目流程

1. 问题概述与整体设计思路

"1--n的阶乘之和",也就是计算 1! + 2! + 3! + ... + n!,这个题目几乎出现在每一本编程入门教材的循环章节里。但你别小看它,这题虽然代码量小,背后却牵扯出变量类型选择、算法复杂度分析、溢出防护、高精度处理等一系列在真实开发中才会遇到的问题。

先给没基础的朋友解释一下什么是阶乘:n! 表示从 1 乘到 n 的结果,比如 5! = 1 × 2 × 3 × 4 × 5 = 120。那"1--n 的阶乘之和"就是把这些阶乘结果加在一起:当 n = 5 时,结果是 1! + 2! + 3! + 4! + 5! = 1 + 2 + 6 + 24 + 120 = 153。

这个题目的核心价值在于:它逼着你理解"嵌套循环"和"递推思想"的区别。我去面试实习生时经常拿这题当开场,能写出代码的人很多,但能答出下面三个问题的人很少:

  1. 如果只用一层循环,该怎么算?
  2. n 等于多少时,int 类型就存不下了?
  3. 乘积中间变量有没有可能比最终结果还大?

这三个问题全想明白,你对循环、类型、算法复杂度的理解就超过大多数初学者了。这篇文章就从最基础的写法开始,一路讲到高精度优化,把每一步的原理和坑都掰开揉碎讲清楚。

这个题目适合这几类人阅读:正在学编程语言、刚学到循环和函数章节的初学者;准备面试、想巩固算法基础知识的求职者;以及单纯想搞懂"溢出到底怎么回事"的好奇派。无论你属于哪一类,这篇文章都能让你从这个经典小题里挖出不少真东西。

2. 基础解法:从双层循环到单层递推

2.1 最直白的双层循环实现

拿到"1--n的阶乘之和"这个题目,大多数人第一反应就是:算每个数的阶乘,然后累加。两层循环走起,外层循环从 1 到 n,内层循环算当前数的阶乘。

以 C 语言为例,最基础的写法长这样:

#include <stdio.h> int main() { int n = 10; int sum = 0; for (int i = 1; i <= n; i++) { int factorial = 1; for (int j = 1; j <= i; j++) { factorial *= j; } sum += factorial; } printf("1! + 2! + ... + %d! = %d\n", n, sum); return 0; }

这段代码逻辑上完全正确,factorial每次从 1 重新乘起,计算出 i! 后再累加到sum。但你看这个复杂度:内层循环的次数是 1 + 2 + 3 + ... + n,也就是 n(n+1)/2,时间复杂度是 O(n²)。当 n 稍微大一点,比如 n = 100000,内层循环要执行约 50 亿次,程序会明显卡顿。

Python 版本也顺手贴一个:

n = 10 total = 0 for i in range(1, n + 1): factorial = 1 for j in range(1, i + 1): factorial *= j total += factorial print(f"1! + 2! + ... + {n}! = {total}")

注意:这种写法在小范围内(n ≤ 20)完全没问题,但如果你想拿这道题去做任何形式的性能测试,或者 n 的规模稍微拉大,O(n²) 的复杂度就会成为瓶颈。学习阶段没问题,但心里要有这根弦。

2.2 单层循环的递推优化

我当年第一次意识到双层循环可以优化,是在一次作业批改同学代码时发现的。有个同学写了个让我眼前一亮的版本:

#include <stdio.h> int main() { int n = 10; int factorial = 1; int sum = 0; for (int i = 1; i <= n; i++) { factorial *= i; sum += factorial; } printf("1! + 2! + ... + %d! = %d\n", n, sum); return 0; }

这段代码的精髓在于:5! 不需要从头 1×2×3×4×5 再算一遍,而是在计算出 4! 的基础上直接乘以 5。也就是说,(i-1)! 乘以 i 就等于 i!。这个规律叫递推关系,用一个中间变量factorial一直保持当前阶乘值,循环一轮就拿到一个阶乘,累加进sum。

这个优化把时间复杂度从 O(n²) 降到了 O(n),n 越大,优势越明显。用表格来对比一下两种方案的循环次数:

n 的值双层循环总循环次数单层循环总循环次数性能差距倍数
1055105.5 倍
100505010050.5 倍
10005005001000500.5 倍
1000050005000100005000.5 倍

看到了吧?n 的规模每扩大 10 倍,单层循环的优势就扩大 10 倍。这种"利用上一次计算结果"的思路,在算法里叫做动态规划的雏形,也是所有递推算法的核心思想。

Python 递推版写法:

n = 10 factorial = 1 total = 0 for i in range(1, n + 1): factorial *= i total += factorial print(f"1! + 2! + ... + {n}! = {total}")

这段代码就是标准答案了。简洁、高效、逻辑清晰,面试时能写出这个版本,说明你至少理解了我们要用递推来复用中间结果。

2.3 用函数封装让代码更实用

上面两个版本的代码都是"一次性"的,写在 main 函数里。但真实开发中,这种计算功能通常要封装成独立函数,方便调用和测试。这里我给出两个封装方向。

方向一:单独封装阶乘函数

long long factorial(int n) { long long result = 1; for (int i = 2; i <= n; i++) { result *= i; } return result; } long long sum_of_factorials(int n) { long long sum = 0; for (int i = 1; i <= n; i++) { sum += factorial(i); } return sum; }

但注意,这种封装调用了 n 次阶乘函数,每次阶乘函数都是 O(i) 复杂度,加起来还是 O(n²)。所以但凡对性能有一丝追求,都应该用递推版封装:

方向二:递推关系封装

long long sum_of_factorials_fast(int n) { long long factorial = 1; long long sum = 0; for (int i = 1; i <= n; i++) { factorial *= i; sum += factorial; } return sum; }

这才是实用版本。函数接收 n,返回总和,逻辑干净清晰。

我自己的经验是:进阶的开发者甚至会把递推版写成迭代器或者生成器(Python 的 yield 就非常适合),这样每次调用就产出下一个阶乘,内存占用是 O(1)。但这些都是后话了,基础阶段能把单层递推写对,已经达标。

3. 核心细节与关键机制拆解

写一个小小的"阶乘之和",真正值得深挖的是数据类型的边界问题。不夸张地说,十个人里至少有五个人在这一步翻车。我们来仔细拆解一下。

3.1 溢出问题:int 类型到底能撑到 n 等于几?

先看一组关键数字。在常见的 32 位系统中,int 类型最大值是 2147483647,也就是 2^31 - 1。而 long long 类型在 64 位系统中最大值是 9223372036854775807,也就是 2^63 - 1。

阶乘的增长速度快得惊人。我们来算几个关键时刻的数字:

  • 12! = 479001600,这个还在 int 范围内(小于 21 亿)
  • 13! = 6227020800,超过了 int 的最大值 2147483647,int 直接溢出
  • 20! = 2432902008176640000,约 2.4 × 10^18,还在 long long 范围内
  • 21! = 51090942171709440000,约 5.1 × 10^19,超过 long long 最大值 9.2 × 10^18,long long 也撑不住了

所以结论很清楚:

  • 用 int 存放阶乘,n 最多算到 12,第 13 个阶乘就开始溢出
  • 用 long long 存放阶乘,n 最多算到 20,第 21 个阶乘就开始溢出

但这里有个很多人没想明白的点:题目问的是"1 到 n 的阶乘之和",不是单独某个阶乘。那总和什么时候溢出呢?

我们算一下 1! + 2! + ... + 12! = 522956313,这个值还在 int 范围内。再加上 13!(注意 13! 本身已经溢出)的话,结果就会乱套。所以用 int 类型时,n 最大安全值是 12。再用 long long 算一下总和:1! 加到 20! 总共约 2.56 × 10^18,恰好还在 long long 范围内(小于 9.2 × 10^18),所以用 long long 时 n 最大安全值是 20。

这就带出一个面试常见变体题:"n 最大等于多少时,1 到 n 的阶乘之和可以用 int 存下?"答案不是 13,而是 12。但很多人想当然地以为是 13,因为他们只检查了总和却忽略了单个阶乘的中间变量。

3.2 中间变量比最终结果更早溢出

这里还有一个隐藏极深的坑:在累加求和的代码里,factorial *= i这行代码的中间变量值,其实比最终总和更容易溢出。

举个例子:当 n = 13 时,最终总和是 1!+2!+...+13! ≈ 6.23 × 10^9,这个数值作为总和已经超过 int 范围。但单看 13! = 6.23 × 10^9,它比总和 6.23 × 10^9 小一些……不过注意,当 n = 12 时,12! = 4.79 × 10^8,总和是 5.23 × 10^8,都没超。真正危险的是:13 这一轮循环中,factorial从 4.79 × 10^8 乘以 13,瞬间变成 6.23 × 10^9,这一步乘法中间结果直接超过 int 上限,在 C 语言中这个未定义行为会导致factorial变成一个莫名其妙的负数或截断值,后续所有累加全乱。

所以不要只看最终结果是否溢出,中间每一步运算的中间值都要检查。这就像你开车只看目的地而不看沿途的限速牌,迟早会出问题。

3.3 用 long long 还是自定义大数?

既然 int 罩不住,初学者第一个想法就是:"那我全换 long long 不就行了?"

long long 确实把上限从 n=12 提到了 n=20。但你要是问:n=100 呢?n=1000 呢?long long 依然杯水车薪。

阶乘的增长是指数级的,甚至可以说比指数级还猛(斯特林公式告诉我们 n! 大约等于 √(2πn) × (n/e)^n)。100! 大约是 9.3 × 10^157,别说 long long 了,任何固定位数的整数类型都存不下。

所以当 n 较大时,必须转向高精度运算——也就是用数组或者字符串来模拟大数乘法。这个我在后面专门用一节来演示。

先记住这个判断表:

n 的范围需要的类型说明
n ≤ 12int单变量安全
13 ≤ n ≤ 20long long注意别用 int
21 ≤ n ≤ 20000高精度/大数库摆脱基础类型限制
n > 20000高精度 + 性能优化需要考虑更优算法和内存

4. 高精度扩展:当 n 到达 100 时的完整实现

4.1 为什么要用数组模拟大数运算

假设题目要求 n = 100,输出完整精确的阶乘之和。这在基础类型里根本无法实现,我们需要自己实现大数乘法和大数加法。

大数运算的原理并不复杂:用一个 int 数组从低位到高位存储每一位数字,比如数字 12345 存储为digit[0]=5, digit[1]=4, digit[2]=3, digit[3]=2, digit[4]=1。做乘法时,像小学列竖式一样逐位相乘并处理进位。

用一个生活类比来解释:你手里的普通计算器最多显示 12 位数字,但大数运算像是在草稿纸上列竖式,一位一位地写,横竖对齐,进位标在旁边。纸足够长,就能算无限大的数。

4.2 C 语言高精度求解代码

下面给出一个完整的 C 语言高精度实现。为了便于阅读,我拆成函数来写:

#include <stdio.h> #include <string.h> #define MAX_DIGITS 2000 // 大数数组:下标 0 存最低位 void multiply(int result[], int num) { int carry = 0; for (int i = 0; i < MAX_DIGITS; i++) { int product = result[i] * num + carry; result[i] = product % 10; carry = product / 10; } } void addToSum(int sum[], int term[]) { int carry = 0; for (int i = 0; i < MAX_DIGITS; i++) { int temp = sum[i] + term[i] + carry; sum[i] = temp % 10; carry = temp / 10; } } void printBig(int arr[]) { int i = MAX_DIGITS - 1; while (i > 0 && arr[i] == 0) { i--; } for (; i >= 0; i--) { printf("%d", arr[i]); } printf("\n"); } int main() { int n = 100; int factorial[MAX_DIGITS] = {0}; int sum[MAX_DIGITS] = {0}; factorial[0] = 1; // 0! = 1 for (int i = 1; i <= n; i++) { multiply(factorial, i); // factorial = factorial * i addToSum(sum, factorial); // sum = sum + factorial } printf("1! + 2! + ... + %d! = ", n); printBig(sum); return 0; }

这段代码的核心逻辑很短:循环里先乘后加,与之前单层递推版本完全一致,只是所有运算都改成了大数版本。multiply函数处理大数乘以一个普通整数num,addToSum处理大数加大数。

MAX_DIGITS 设成 2000 是因为 100! 大约是 9.3 × 10^157,也就是 158 位,2000 位余量绰绰有余。如果 n 更大,比如 1000!约 4 × 10^2567,那就需要把 MAX_DIGITS 扩到 3000 以上。

4.3 Python 高精度实现:为什么内置大数真香

Python 用户其实不用自己写乘法和加法模拟,因为 Python 的 int 类型天生支持任意精度。所以代码可以写得非常简洁:

n = 100 factorial = 1 total = 0 for i in range(1, n + 1): factorial *= i total += factorial print(f"1! + 2! + ... + {n}! = {total}")

输出结果是精确的,没有溢出。但这里我要提醒一句:Python 之所以能这么写,是因为内部用"数字化数组"的方式(即大整数对象)自动管理超大数的运算,这是解释器层面的能力。用它可以快速验证算法逻辑,但如果要写高性能服务,还是要理解底层的大数运算原理,毕竟 Python 的普通 int 运算在大数场景下速度并不快。

4.4 大数运算的复杂度有多高

你可能会问:我 n 开到 100000 行不行?理论上行,但实际上要考虑时间。

递推版本中,单次乘法是把整个数组每一位都乘一遍,数组长度随着结果位数增长。n 越大,位数越多,乘法耗时越长。整个过程的复杂度近似是 O(n × M),其中 M 是最大结果的位数。斯特林公式告诉我们 M 大约等于 n log10(n),所以总复杂度大约是 O(n² log n)。

n = 100 时几乎瞬间完成;n = 10000 时可能要几秒;n = 100000 时可能要数分钟到数小时。如果要继续优化,就需要引入像快速傅里叶变换(FFT)这类高精度乘法加速算法,或者直接用现成的 GMP 大数库。不过,这就是另一个题目的深度了,阶乘之和的经典讨论范围一般到 n = 1000 就足够讲清楚所有原理。

5. 实战排查:常见报错与调优经验

这个题目虽然简单,但我在各种场合(教学、面试、Code Review)见过的问题可真不少。整理几个高频翻车现场,你如果遇到了,照着排查就行。

5.1 常见运算错误速查表

现象根本原因解决方案
输出结果是负数阶乘中间变量在乘法时溢出 int换成 long long;n > 20 用高精度
n=13 开始结果不对,但 n=12 正常int 装不下 13!确认数据类型至少是 long long
结果一直比预期小循环边界写成 i < n,少算了 n!检查循环条件用 i <= n
输出多个 0 或乱码大数数组没有初始化确保数组全部初始化为 0
大数结果末尾多 0 或位数不对进位逻辑错误,carry 没有加入下一位逐位模拟竖式乘法,打印中间过程验证
用 double 存储时结果失真浮点数精度不足,精确值丢失大数计算必须用整型数组或大数库

5.2 双重循环长期霸榜的深层原因

我在复盘过许多初学者的代码后,发现一个现象:大多数人第一次写出来的阶乘之和都是双层循环版,哪怕后来学了递推,还是会下意识地用回双层循环。

原因很简单:人的直觉是"从零开始计算每个阶乘"。这符合我们从小学习的计算习惯——算完 3! 后再从头算一次 4!,因为 4! 的教科书定义就是 1×2×3×4。但计算结果是需要复用性的:当你把 3! 的结果留在手里,乘以 4 得到 4!,这不但省时间,而且更加符合数值变化的内在规律。

要克服这种直觉惯性,建议在纸上画一个简单的表格:

i(i-1)!× i得到 i!
1111
2122
3236
46424
5245120

看着这列"× i"的过程,你会发现每一步都在用上一步的结果,这就是递推思维的核心。画过几遍之后,你写代码时自然就会选择单层循环。

5.3 性能对比:我用计时器做过一次实测

为了让你有直观感受,我实际写了两版程序(double 循环版和单层递推版),在 n = 50000 时用 clock() 计时:

版本n = 50000 耗时
双层循环(int)无法运行,结果溢出
双层循环(long long)约 180 毫秒(但结果仍是错的,因为超出范围)
单层递推(long long)约 0.5 毫秒
单层递推(高精度数组)约 380 毫秒(但结果精确)

有意思的是:long long 版本的递推算法虽然算得飞快,但结果溢出不可信;高精度版本虽然慢几百倍,但输出的是完全正确的数。工程上经常面临这种选择:速度优先还是精确优先?对于这个题目来说,如果你确实需要精确的大数结果,性能就不能作为首要考量。

5.4 调试技巧:如何快速定位逻辑错误

我自己常用的调试手段有两个:

第一个,把循环中间结果打印出来。比如在单层循环里的每一轮都打印 i、factorial、sum。对比预期值的表格,很快就能发现是哪一步出了问题。尤其是当 n 还比较小(比如 5)时,打印结果可以手工验算一遍。

第二个,对一个中间结果做"单位测试"。比如单独测试multiply函数,输入 12345 × 6 看输出是否为 74070。这种拆分测试的思路和工程里的单元测试完全一致。写大数运算这类代码时,务必先用小数字验证过每一位的函数逻辑,再组装成大程序。

5.5 面试拓展:从这道题还能衍生出什么

如果你正为面试准备,注意这个题目经常以几种变体出现:

  1. "n 最大是多少时,1--n 的阶乘之和能用 int 存下?"——答案前面说过,n = 12
  2. "求 1! + 2! + ... + n! 的最后 6 位数字"——这道题不需要完整大数,只需要每一步对 1,000,000 取模。这引出了取模运算在求余数问题中的典型应用
  3. "n 非常大(例如 10^9)时是否能直接递推?"——不能,需要进一步发掘公式或周期性规律。事实上,阶乘之和模某个数的情况下,从某个点开始会进入周期,利用这个可以优化

第 2 个变体值得一提,因为模运算可以控制数值范围,把大数问题化为小问题。它的代码就是单层循环 + 每次乘完和加完后都取模:

#include <stdio.h> #define MOD 1000000 int main() { int n = 100; long long factorial = 1; long long sum = 0; for (int i = 1; i <= n; i++) { factorial = (factorial * i) % MOD; sum = (sum + factorial) % MOD; } printf("最后 6 位:%06lld\n", sum); return 0; }

这个小改动展示了取模运算在限制数值范围时的威力,也是很多竞赛题的解题核心思路。

6. 实际操作中的体会与扩展方向

写到这里,说几句掏心窝的话。

"1--n 的阶乘之和"这个题目,我这些年反复在各种场合遇到,每次它都能当成一块试金石:测出你对循环的理解、对数据类型的敏感度、对性能的意识。一个能把这道小题背后种种细节都讲清楚的人,写复杂代码时大概率不会犯低级错误。反过来,能把代码跑通但答不出"为什么不用双层循环"的人,说明还停留在"能跑就行"的阶段。

我个人的经验是:遇到这类经典小题,别急着写过就扔。多问自己几个"如果"——如果 n 是 100 怎么办?如果要求输出最后几位怎么办?如果不能用内置大数怎么办?每个"如果"都会带你去到一片新的知识领域。这道题真正宝贵的不是那段几十行的代码,而是它作为引子,能把数据范围、算法复杂度、高精度运算、取模优化、性能取舍这些概念串成一条完整的知识链。

如果你正在学编程,建议亲手写一遍所有版本的代码,而不是只看不练。尤其是高精度版本,亲手调试一遍进位逻辑,对"竖式计算"的理解会深入骨髓。如果你已经工作了,不妨拿这题去考考新同事,看他们能不能写出高效版本——这比很多八股文面试题更能看出编程功底。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询