你的递归正在欺骗你

Recursion is lying to you

你的递归正在欺骗你

递归代码看似优雅简洁,却可能因 JavaScript 运行时缺乏尾调用优化(TCO)而引发栈溢出。文章指出,即便代码结构符合尾递归标准,Chrome、Node.js 等主流环境仍可能分配新栈帧。Fibonacci 等经典案例不仅受限于栈深度,更因指数级时间复杂度导致页面冻结。作者建议在生产环境中,当递归深度不可控时,应优先采用迭代方案或 Trampoline 模式,避免将正确性寄托于不可靠的运行时优化。

递归本身并非敌人,未经验证的运行时假设才是。
  1. ventana

    文章里有个有趣的引用,讨论的是基础的斐波那契递归实现:

    > 每次调用会分支出两个新调用,因此总调用次数按 O(2ⁿ) 增长。

    嗯,其实不对。如果有人真的去数一下实际发生了多少次递归调用,结果远不是 2 的幂次方:

    n | result | # of calls

    1 | 1 | 1

    2 | 1 | 3

    3 | 2 | 5

    4 | 3 | 9

    5 | 5 | 15

    6 | 8 | 25

    7 | 13 | 41

    8 | 21 | 67

    9 | 34 | 109

    10 | 55 | 177

    11 | 89 | 287

    12 | 144 | 465

    13 | 233 | 753

    14 | 377 | 1219

    15 | 610 | 1973

    16 | 987 | 3193

    17 | 1597 | 5167

    18 | 2584 | 8361

    19 | 4181 | 13529

    20 | 6765 | 21891

    好奇的人接着会算出实际比率:

    n | result | # of calls | ratio

    1 | 1 | 1 | 1

    2 | 1 | 3 | 3

    3 | 2 | 5 | 1.6666666666666667

    4 | 3 | 9 | 1.8

    5 | 5 | 15 | 1.6666666666666667

    6 | 8 | 25 | 1.6666666666666667

    7 | 13 | 41 | 1.64

    8 | 21 | 67 | 1.6341463414634145

    9 | 34 | 109 | 1.626865671641791

    10 | 55 | 177 | 1.6238532110091743

    11 | 89 | 287 | 1.621468926553672 […]

  2. RajT88

    这不是 CS 101 吗?递归写起来更简单,但性能更差,风险也更大,不如迭代。

  3. eventualcomp

    怎么没提动态规划来处理递归函数?动态规划就是为这个设计的,你甚至不需要像那个 trampoline 那样频繁折腾堆内存。实例化你的数组,确保设置好你的基本情况和循环,以免掉进 `undefined` 的坑里,然后反向递归即可。

  4. 10000truths

    处理调用栈递归的麻烦,归根结底是因为缺乏强大的静态分析工具来检测栈使用情况。在现有的少数能生成应用程序构建时调用图的工具中,几乎没有任何一个能输出机器可读的格式。据我所知,目前最先进的方案是 LLVM 的 dot-callgraph pass,但即便如此,它输出的也是 DOT 格式,而不是像 CSV 或 JSON 这样更广泛采用的格式。除此之外,你就得自己造轮子了,要么通过运行时分析,要么写个自定义编译器插件。

  5. okzgn

    参考链接:https://v8.dev/blog/modern-javascript#proper-tail-calls (Recursion, Proper tail calls, 2016)

    Proper Tail Calls 曾经在实验性标志后实现过,但从未默认发布——这些标志后来也被移除了。

同日更多故事

2026-07-28