- 教程
- 文档
【免费下载链接】30-seconds-of-code
Coding articles to level up your development skills
导读
阶乘(Factorial)是数学与算法领域最基础的计算之一,也是理解**递归(recursion)与迭代(iteration)**两种编程思路的经典入门案例。本文基于 30-seconds-of-code 仓库中的 factorial 代码片段,系统讲解如何用 JavaScript 计算n的阶乘,完整覆盖迭代实现、递归实现、边界处理、复杂度对比以及基于仓库源码的进阶优化思路。读完本文,你将掌握两种可直接复制运行的阶乘实现,并能在实际项目中根据数据规模正确选择方案。
阶乘的数学定义与代码映射
在数学中,非负整数n的阶乘记作n!,定义为所有小于或等于n的正整数的乘积:
n! = n × (n-1) × (n-2) × ... × 2 × 1例如:6! = 6 × 5 × 4 × 3 × 2 × 1 = 720。特殊约定0! = 1(空积约定,也是递归实现的基准情形之一)。
用代码表达这个定义有两种自然思路:
- 迭代(Iterative):用循环把
2到n逐一乘进结果变量; - 递归(Recursive):把
n!分解为n × (n-1)!,让函数调用自身,直到到达基准情形。
在 30-seconds-of-code 仓库中,这一主题被组织为 js/recursion 集合 的成员(snippetIds中包含js/s/factorial),与 递归入门、斐波那契、最大公约数与最小公倍数 等文章共同构成一套完整的递归学习路径。
迭代实现:for 循环累积乘积
原文档给出的迭代实现用for循环更新结果变量:
const factorial = n => { if (n < 0) throw new TypeError('Negative numbers are not allowed!'); let result = 1; for (let i = 2; i <= n; i++) result *= i; return result; }; factorial(6); // 720这段代码的核心逻辑可以拆解为三点:
- 入参校验:
n < 0时直接抛出TypeError。阶乘定义域是非负整数,负数的阶乘没有数学意义,提前抛错可以避免循环条件失效(i = 2恒大于负数n,循环永远不会执行,最终错误地返回1)。 - 循环起点为
2:result初始化为1,从i = 2开始累乘。这样既天然覆盖了0! = 1与1! = 1(循环体一次都不执行),也避免了无意义的×1运算,同时规避了0作为乘数会令结果恒为0的错误。 - 单表达式循环体:
for (let i = 2; i <= n; i++) result *= i;把乘法写在循环头部之后,代码紧凑,性能上等价于完整块写法。
为什么不推荐用 reduce 改写
原文档在注意事项中明确提示:可以把for循环改写成Array.prototype.reduce()形式,但不推荐,因为它效率更低。改写后的等价形态大致如下:
const factorial = n => { if (n < 0) throw new TypeError('Negative numbers are not allowed!'); return Array.from({ length: n }, (_, i) => i + 1) .reduce((acc, x) => acc * x, 1); };不推荐的理由很实际:reduce版本需要先构造一个长度为n的数组(额外内存分配),再经历一次数组遍历(额外开销),而for循环只维护一个计数器变量,无中间数组。虽然对常见的小n两者差异可以忽略,但在批量计算或嵌入式场景中,for循环始终是更省资源的写法。
递归实现:函数调用自身的优雅写法
递归思路把阶乘定义改写成递推关系:n! = n × (n-1)!,直至基准情形。原文档给出的实现:
const factorial = n => { if (n < 0) throw new TypeError('Negative numbers are not allowed!'); return n <= 1 ? 1 : n * factorial(n - 1); };执行factorial(5)时,调用栈的展开过程是:
factorial(5) = 5 * factorial(4) = 5 * (4 * factorial(3)) = 5 * (4 * (3 * factorial(2))) = 5 * (4 * (3 * (2 * factorial(1)))) = 5 * (4 * (3 * (2 * 1))) = 120关键设计是基准情形(base case):n <= 1时直接返回1,不再继续调用自身。正如仓库中的递归入门文章所述:"The base case breaks out of the recursion loop"——如果没有基准情形,函数将无限调用自身,最终导致栈溢出(stack overflow)。
注意这里基准条件写成n <= 1而非n === 0,是因为0! = 1且1! = 1,合并判断让两行退化输入都直接返回,代码更简洁。而在函数式编程入门中,同一主题还有另一种等价写法(以num === 0为基准)可作为对比参考:
const factorial = num => { if (num === 0) return 1; return num * factorial(num - 1); };递归的代价:函数调用开销
递归实现虽然结构优雅、与数学定义一一对应,但每次调用都要压入新的调用栈帧,存在函数调用本身的开销。对阶乘这种"一条线性递减链"的计算而言,n次递归调用意味着n层栈帧,当n较大(例如数万)时可能触发栈溢出,这也是原文档明确提示"递归可能因函数调用开销而效率较低"的原因。
两种实现:复杂度与可读性对比
| 维度 | 迭代实现 | 递归实现 |
|---|---|---|
| 时间复杂度 | O(n),单次循环 | O(n),但叠加函数调用开销 |
| 空间复杂度 | O(1),仅一个result变量 | O(n),调用栈深度随n增长 |
| 可读性 | 直观,贴近数学过程 | 更优雅,与数学定义n! = n × (n-1)!直接对应 |
| 栈溢出风险 | 无 | 有,n较大时可能发生 |
| 适用场景 | 常规计算、性能敏感路径 | 教学演示、递归思维训练、小规模输入 |
关于时间复杂度的依据,可以对照仓库中的 Big-O Cheat Sheet:其中将O(n!)描述为阶乘级时间复杂度(最差效率等级),而本文两种实现的时间复杂度都是O(n)(随输入线性增长),n!只是计算结果的数值大小,与算法本身的复杂度等级是两个概念——这是面试中常见的辨析点。
边界情况与错误处理
两种实现都应当处理以下边界输入:
n = 0:返回1(数学约定0! = 1),迭代版本循环不执行、递归版本命中n <= 1基准,均正确。n = 1:返回1,逻辑同上。n < 0:抛出TypeError('Negative numbers are not allowed!'),拒绝无数学定义的输入。- 非整数
n:本文实现未做显式校验,若传入小数(如2.5),迭代版循环次数取决于i <= n的浮点比较结果、递归版则会因n - 1永远到不了<= 1而无限递归最终栈溢出。从源码结构看,实际使用中如需健壮性,可在校验处追加Number.isInteger(n)判断。 n过大:阶乘数值增长极快(21!已超过 JavaScriptNumber的MAX_SAFE_INTEGER,精度丢失),此时应改用BigInt,例如:
const factorial = n => { if (n < 0) throw new TypeError('Negative numbers are not allowed!'); let result = 1n; for (let i = 2n; i <= BigInt(n); i++) result *= i; return result; };进阶:递归性能优化的三种方向
递归是函数式编程的核心概念之一(见仓库函数式编程入门),但正如递归性能优化一文所言,递归代码往往需要优化。针对阶乘,可参考的优化方向有三类:
- 改用迭代:将"自顶向下分解"改为"自底向上累积",即本文第一种实现。该文以斐波那契为例验证了这一思路——迭代方案无需缓存、无递归调用开销,占用资源更少。
- 记忆化(Memoization):用
Map缓存已计算结果,避免重复计算。记忆化入门指出其适用前提是"同一函数在相同参数下被多次调用"。对阶乘而言,若程序中会反复计算不同规模的n!(如组合数公式C(n, k) = n! / (k! × (n-k)!)),缓存中间结果能显著提速:
const factorialCache = new Map([[0, 1], [1, 1]]); const factorial = n => { if (n < 0) throw new TypeError('Negative numbers are not allowed!'); if (factorialCache.has(n)) return factorialCache.get(n); const result = n * factorial(n - 1); factorialCache.set(n, result); return result; };- 尾递归:把累积结果作为参数传递,使递归调用成为尾位置调用,理论上可被引擎优化为循环执行(不过 V8 等主流引擎对尾调用优化(TCO)的支持有限,实际效果需按运行环境验证):
const factorial = (n, acc = 1) => { if (n < 0) throw new TypeError('Negative numbers are not allowed!'); return n <= 1 ? acc : factorial(n - 1, acc * n); };关联阅读:同一集合中的递归姊妹篇
在 30-seconds-of-code 的 js/recursion 集合 中,阶乘与以下片段共享同一递归主题,适合串联学习:
- 递归入门:基准情形、调用栈与栈溢出的概念基础;
- 斐波那契数列:递归 vs 迭代的另一经典对比;
- 最大公约数与最小公倍数:欧几里得算法(
gcd(a, b) = gcd(b, a % b))展示了递归在数论计算中的应用,其多参数版本还用reduce串联递归函数,可反观本片段"不推荐 reduce"的取舍; - Big-O Cheat Sheet:为复杂度分析提供完整参照表。
小结
阶乘计算虽小,却浓缩了算法设计中两个根本性决策:用循环还是递归、如何定义与保护边界。迭代版以 O(1) 空间、O(n) 时间胜在工程效率;递归版以与数学定义同构的表达胜在代码优雅与可读性,适合作为理解递归的入门台阶,并在掌握后进一步用迭代、记忆化或尾递归等手段收敛其开销。需要动手验证时,可直接在浏览器控制台或 Node.js 中运行本文代码,将factorial(6)替换为你关心的输入值,观察两种实现的输出与调用栈行为差异。
- 教程
- 文档
【免费下载链接】30-seconds-of-code
Coding articles to level up your development skills
相关推荐
CyberStrikeAI 实战指南:一句话启动 AI 安全测试平台,100+ 工具自动编排
CyberStrikeAI 实战指南:一句话启动 AI 安全测试平台,100+ 工具自动编排 CyberStrikeAI 是一个用 Go 写的 AI 原生安全测
教程文档30 seconds of code:用递归生成 JavaScript 数组与字符串全排列的完整指南
30 seconds of code:用递归生成 JavaScript 数组与字符串全排列的完整指南 生成一个数组所有元素或字符串所有字符的全排列,是经典算法问
教程文档30-seconds-of-code 实战:用一行 JavaScript 代码计算任意月份的天数
30 seconds of code 实战:用一行 JavaScript 代码计算任意月份的天数 导读 在 JavaScript 中处理日期向来不算直观,但"计
教程文档
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考