☰
阶乘求和从入门到工程优化:避开溢出、O(n²)与递归陷阱
2026/10/15 12:28:42 网站建设 项目流程

如果去翻各家编程社区里“算法入门必刷 20 题”之类的清单,十有八九能碰到这道题——计算 n 的阶乘,以及 1! + 2! + ... + n!。我见过不少人第一反应是不屑一顾:这有什么好讲的,一个 for 循环套一下不就出来了?但等我真正接触代码评审、带新人写东西之后,才发现这个“入门题”里埋的坑比想象中多。把 n 从 20 改成 2000,时间差距能拉到几十倍;把普通 int 换成更大的数据,结果照样溢出;如果再牵扯到取模、大数、缓存复用,一个简单的阶乘题能延伸出一整套关于“怎么把代码写得又快又稳”的判断思路。

这篇内容就围绕“计算 n 的阶乘 + n 个阶乘相加”展开,把直接可用的写法、背后的复杂度分析、溢出的边界、以及实际工程里更稳妥的处理方式都过一遍。不管是刚入门想刷题,还是已经在写业务代码但对这类基础问题有点模糊的开发者,应该都能从中拿到一点可以直接用的东西。

1. 从一道“入门题”说起:阶乘的边界与增长直觉

1.1 阶乘的定义不只有“n 到 1 连乘”这一层

阶乘的定义,教科书上往往写成 n! = 1 × 2 × 3 × ... × n。这个定义本身没什么好争的,真正容易忽略的是几个边界约定:

  • 0! = 1,这个不是算出来的,而是为了让排列数、组合数公式在边界条件下仍然成立而做的约定。比如从 n 个元素里取 0 个,组合数 C(n, 0) 应当等于 1,往下拆开就需要 0! = 1。
  • 1! = 1,这没什么悬念。
  • 负数没有阶乘。有些题会在输入里故意放一个负数,如果你没做判断,程序会给一个错误结果。写代码之前先想清楚输入范围,这不是多余的动作。

而对于“n 个阶乘相加”这件事,问题通常可以抽象成:输入 n,求 S = 1! + 2! + 3! + ... + n!。有些版本会要求包含 0!,也就是 S = 0! + 1! + ... + n!。别看只是多了一项,后面写循环时初始值、边界条件全是差在这个地方。

1.2 阶乘的增长速度远超大部分人的直觉

很多人低估阶乘的膨胀速度,觉得“n 也没多大,能涨到哪里去”。实际列几个数感受一下:

nn!
5120
103,628,800
136,227,020,800(约 62 亿)
151,307,674,368,000(约 1.3 万亿)
202,432,902,008,176,640,000(约 2.43 × 10^18)

20! 已经超过 2.43 × 10^18,这个数字在 64 位有符号整数(long long)的最大值 9.22 × 10^18 之内,但已经非常接近了。再往下一步,21! 就会彻底溢出 64 位整数。这就是为什么“阶乘求和”这类题,只要 n 稍微开大一点,用什么类型、怎么防溢出就成了比“会不会写循环”更关键的问题。后面第 4 部分我会专门展开讲这块,这里先有个概念:阶乘不是一个“慢慢变大”的东西,它是“爆炸式”的。

2. 实现阶乘的两条路线:递归简洁,迭代扎实

2.1 迭代写法与它的稳定性优势

先给一个最常用的迭代版本,语言我用 C++ 写,因为后面牵扯到溢出类型的问题用它讲更直白:

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

这个写法的思路非常直接:用一个变量 result 承接累乘结果,每轮循环乘一个新的数。它有两个天然优点:

  1. 函数调用栈是平的,只有一层,不存在递归深度的问题。
  2. 乘法顺序固定,从 1 乘到 n,中途不会跳变,方便我们在每一轮检查是否溢出。

实际测试里,我用 n = 10000 跑这个函数,耗时基本是瞬间完成;换成递归版本,直接栈溢出。在业务和常规算法代码里,迭代是我默认首选。

2.2 递归写法、尾递归与栈溢出实测

递归版本长这样:

long long factorial_recursive(int n) { if (n == 0) return 1; return n * factorial_recursive(n - 1); }

从“可读性”角度讲,递归把数学定义直接翻译成了代码,确实是最贴近人的思维方式的。但代价也很明显:每次调用都要压栈、保存上下文、返回时再弹栈。n 稍微大一点,调用深度跟着线性涨。在 C++ 里,我实测 n = 50000 的时候这个函数已经可以稳定触发栈溢出,甚至连跑完的机会都没有。Python 更夸张,默认递归深度限制是 1000 左右,n = 2000 时干脆直接抛 RecursionError。

有人会说:那就用尾递归优化一下。所谓的尾递归,就是把乘积累计参数里往下传:

long long factorial_tail(int n, long long acc = 1) { if (n == 0) return acc; return factorial_tail(n - 1, acc * n); }

尾递归的理论优势是编译器可以把递归改写为循环,从而复用栈帧。但现实是,C++ 编译器在不优化的情况下照样爆栈,Python 官方解释器则明确不支持和自动优化尾递归。也就是说,你在普通开发环境里写尾递归,得到的只是“看起来更优雅”,实际风险和普通递归一样。

2.3 两种方案的选型结论

我自己的习惯是:算法题的题解、项目里的底层工具函数,一律写迭代;只有当面试或演示需要体现“递归思维”时,才会用递归版本点到为止。原因不是递归不好,而是它带来的栈风险和数值溢出风险叠加之后,不确定性太大了。递归适合讲思路,迭代适合真正上生产。

3. 求和的真正陷阱:重算一万遍,不如一个变量滚动起来

3.1 最直觉的“每轮重算”方案为什么慢

现在回到标题里的后半句—— “n 个阶乘相加”。很多人第一版代码会长这样:

long long sum_of_factorials_slow(int n) { long long sum = 0; for (int i = 1; i <= n; ++i) { sum += factorial(i); // 每轮重新调用完整的阶乘函数 } return sum; }

如果只算 n = 10、n = 20,这个代码毫无问题,慢也慢得看不出来。问题出在 n 变大之后。简单算一笔账:

  • factorial(1) 做了 1 次乘法;
  • factorial(2) 做了 2 次乘法;
  • factorial(3) 做了 3 次乘法;

整体乘法次数大约等于 1 + 2 + 3 + ... + n = n(n+1)/2。这是一个标准的 O(n²) 复杂度。n = 100 时,大约 5000 次乘法,无所谓;n = 10000 时,大约 5000 万次乘法,开始明显卡顿;n = 100000 时,乘法次数已经到 50 亿这个量级,跑起来像在等宇宙热寂。

更关键的问题在于:这种做法把算好的中间结果直接扔掉了。算 5! 的时候,前面明明刚算完 4! = 24,只需要再乘一个 5 就能得到 120,结果这个“24”却被重新从 1 × 2 × 3 × 4 算了一遍。重复劳动到了近乎浪费的地步。

3.2 增量构造:每次只多乘一个数

更好的方案是利用阶乘的递推关系:i! = (i - 1)! × i。

这个递推关系说明:从 (i - 1)! 到 i!,不用从头算,只需要在上一个结果上多乘一个 i。于是求和代码可以写成一个循环:

long long sum_of_factorials_fast(int n) { long long fact = 1; long long sum = 0; for (int i = 1; i <= n; ++i) { fact *= i; // fact 现在等于 i! sum += fact; // 累加到总和 } return sum; }

这里的核心变化是:fact 成了一个滚动变量,它保存的是“上一次的阶乘值”。每轮循环多乘一个 i,就能得到当前 i!,然后直接加入 sum。整体只需要 n 次乘法、n 次加法,时间复杂度从 O(n²) 直接降到 O(n)。

我把两个方案在相同环境下跑过对比,结果非常直观:

n每轮重算方案耗时(约)增量构造方案耗时(约)
100不足 1 毫秒不足 1 毫秒
1000约 1 毫秒不足 1 毫秒
10000约 400 毫秒约 1 毫秒
100000约 30 秒以上约 10 毫秒

差距基本是三个数量级起步。B 方案在 n = 100000 时还能保持毫秒级响应,A 方案已经卡到没法看。

3.3 复杂度推演与“增量思维”的通用性

跳出这道题本身,“增量构造”是一个在算法和工程里反复出现的思维模式。斐波那契数列的滚动数组、前缀和数组、滑动窗口里的累计值,本质都是同一件事:把上一个状态的结果缓存下来,利用转移关系快速计算下一个状态,而不是每次从头反推。

所以评价这段代码好不好,不只是看它能不能跑出正确答案,更要看它有没有浪费可复用的信息。同样的道理,换成业务场景里的报表逐日累计、统计脚本里的循环求和、数据流里维护实时总和,思路完全一致。写代码的人如果脑子里没有“承接上一次结果”这根弦,就容易写出复杂度惊人的低效实现。

4. 数值溢出与取模:n 超过 20 之后的世界

4.1 各数据类型能撑到哪个 n

阶乘增长太快,导致一个非常现实的问题:就算你用了最优的增量构造,结果也未必能被装进变量里。常见的整数类型能力如下:

类型最大值能安全表示到几的阶乘
32 位 int2,147,483,647(约 2.1 × 10^9)12!
64 位 long long9,223,372,036,854,775,807(约 9.2 × 10^18)20!
unsigned long long约 1.84 × 10^1920!
Python int无上限取决于内存

也就是说,如果 n = 20,long long 刚好能装下 20! = 2.43 × 10^18;n 一到 21,乘法结果立刻溢出。C++ 这类语言里,结果溢出后行为是未定义的,你拿到的东西可能是一个很大的负数,也可能是随机数。初学者最容易在这种地方懵住:边界用例一跑就错,但又不知道错在哪。

你是不是觉得“那我把类型改成 unsigned long long 不就行了”?没用,unsigned long long 只能装到 20!,21! 照样扛不住。除非题目有特殊设计,否则在固定整型语言里考虑“更大 n 的精确阶乘和”必须换思路。

4.2 取模运算:工程题里最常见的变体

大量在线题和竞赛题会采取一个折中方案:要求结果对某个模数取模后输出,最常见的是 mod = 1e9 + 7。这样做的本质是:把超大结果压缩到一个有限的范围内,同时保留结果在模意义下的正确性。

此时代码只需要在累乘和累加两个位置分别取模:

const long long MOD = 1e9 + 7; long long sum_of_factorials_mod(int n) { long long fact = 1; long long sum = 0; for (int i = 1; i <= n; ++i) { fact = (fact * i) % MOD; sum = (sum + fact) % MOD; } return sum; }

注意这里的细节:不是最后一次性取模,而是每一步都取。原因是想象 n = 100 时,100! 的真实值早就超过 long long 范围了,等它溢出再取模,取出来的是垃圾数据。每一步先取模,把中间值压到模数以内,后面乘法就不会溢出——这是新手最容易漏掉的一步。

为什么常取 1e9 + 7?因为它是质数,且和任何常见整数组合都不太容易产生特殊冲突,在哈希、组合数、同余运算里非常友好。业务代码如果只是要个“不算错”的答案,随便找个合适的模数也行;但涉及算法题时,1e9 + 7 基本是默认值。

4.3 高精度思路:用数组自己实现大数乘法

如果不取模,又想算真实的大数结果,那就要上高精度。Python 是作弊级的友好,int 天生没有上限,下面这段代码可以直接跑:

def sum_of_factorials(n): fact = 1 total = 0 for i in range(1, n + 1): fact *= i total += fact return total

换到 C++,就得自己用数组模拟大数乘法和加法。常见做法是每一位存一个 int,乘法时用双重循环逐位相乘并处理进位:

vector<int> multiply(vector<int>& a, int b) { int carry = 0; for (int i = 0; i < a.size(); ++i) { long long cur = (long long)a[i] * b + carry; a[i] = cur % 10; carry = cur / 10; } while (carry) { a.push_back(carry % 10); carry /= 10; } return a; }

这个函数模拟的是“一个大整数乘以一个小整数”,整体复杂度 O(位数),位数大约等于 log10(n!),n 越大位数越多。求和时再用一个 vector 累加每一轮的阶乘,同样逐位进位。道理不复杂,但手写容易错在进位和位数截断上,我一般建议只在学习时写一次,真正项目里直接调成熟的大数库。

一句话总结这个部分:如果题目没提取模,n 又不大,用 long long;n 可能超过 20,优先看是否要求取模;再大的精确结果,换 Python 或高精度实现。

5. 代码评审时我会检查的阶乘题细节清单

5.1 边界条件与初始化陷阱

这类题虽然短,但我在代码评审里见过太多次低级翻车:

  • fact 初始值写成了 0。0 乘任何数都得 0,整个累乘链直接报废。
  • sum 初始值不是 0,导致答案莫名偏大。
  • n = 0 时没有正确处理。如果题目要求算 0!,循环范围应从 0 开始;如果只算 1! 往后,那么 n = 0 时结果应当定义为 0,两种语义别混。
  • 循环变量 i 用了 int,但 fact 用了 long long。乘法时虽然 short 类型会自动提升,但极端情况下乘到一半 i 自己先溢出,也会出错。
  • 没有判断输入为负数的情况。负数阶乘数学上未定义,至少该有防御性报错。

5.2 不同场景的推荐组合

根据我自己的实践,把这几种场景怎么选列成一个表,方便直接对照:

场景推荐实现理由
学习递归概念、演示定义递归写法贴近数学定义,可读性好
生产代码、通用工具函数迭代 + 增量构造无栈风险,O(n) 稳定
算法题目要求取模迭代 + 逐步取模防止溢出且保持模正确性
n 很大且要精确结果Python 或高精度数组避免自研大数库踩坑
多次重复查询不同 n预计算阶乘表空间换时间,查询 O(1)

5.3 可复用的扩展:预计算阶乘表

如果程序里要频繁查询 1!、2!、3! 到某个上界的“任意一项”,那每次现算都不划算。更合理的做法是用一个数组把阶乘和累计和提前算好:

const int MAXN = 1000; long long fact[MAXN + 1]; long long sumFact[MAXN + 1]; void precompute() { fact[0] = 1; sumFact[0] = fact[0]; for (int i = 1; i <= MAXN; ++i) { fact[i] = fact[i - 1] * i; sumFact[i] = sumFact[i - 1] + fact[i]; } }

之后要算 sumFact[k],直接查表。这种“预计算缓存”的思路,在排列组合、概率统计、动态规划的状态预处理里非常常见。回到阶乘这个题,相当于从单纯“写对一次”升级成“批量高效回答”,工程价值完全不一样。

我个人在实际写这段代码的时候还有一个体会:无论多简单的题,先把输入范围、输出要求和边界条件确认清楚再动手,比写完之后反复调试省时间得多。阶乘题看着基础,但它逼你把定义、复杂度、数据类型和防御性编程全部过了一遍,反而是一个很适合用来做代码基本功自查的样本。如果能把这道题的每个细节都讲明白、写干净,很多相似的循环累加场景也能带着同样的敏感度去处理。

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

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

立即咨询