JavaScript 递归实战:用循环与递归输出单链表(Modern JavaScript Tutorial 精讲)

原创2026-10-06 10:26:331,851 阅读
文章标签:文档教程

JavaScript 递归实战:用循环与递归输出单链表(Modern JavaScript Tutorial 精讲)

导读:本文围绕《现代 JavaScript 教程(The Modern JavaScript Tutorial)》中"递归和堆栈"一节的经典习题——输出单链表(printList)展开。链表是递归定义的数据结构,本任务要求分别用循环与递归两种方式实现逐元素输出,并讨论两者的取舍。读完本文,你将掌握链表遍历的两种典型实现、递归与迭代在内存与性能上的本质差异,以及如何将同一思路推广到反向输出、递归求和等进阶场景。

任务背景:为什么用链表来练递归

在 递归和堆栈 一章中,作者引入了 递归结构(recursive structure) 的概念:一种数据结构可以用自身来定义。链表(linked list)就是教科书级的例子——一个链表元素是包含以下两部分的对象:

  • value:当前节点存储的值;
  • next:指向下一个链表元素的引用,若为链表末尾则为 null。

用代码表示即:

list = { value, next -> list }

本任务所用的链表(见 任务原文)为 4 个节点依次嵌套:

let list = {
  value: 1,
  next: {
    value: 2,
    next: {
      value: 3,
      next: {
        value: 4,
        next: null
      }
    }
  }
};

也可以逐段构造(章节原文):

let list = { value: 1 };
list.next = { value: 2 };
list.next.next = { value: 3 };
list.next.next.next = { value: 4 };
list.next.next.next.next = null;

链表与数组的关键差异在于:数组在首部插入/删除元素需要整体重排(arr.unshift、arr.shift 代价高),而链表只需修改相邻节点的 next 引用即可完成插入与删除(详见章节中的 链表分割示例)。链表因此非常适合"快速插入/删除、无需按下标随机访问"的场景,例如队列与双向队列。

解法一:基于循环(迭代)的输出

官方 题解 给出的循环解法如下:

function printList(list) {
  let tmp = list;

  while (tmp) {
    alert(tmp.value);
    tmp = tmp.next;
  }
}

printList(list); // 依次弹出 1、2、3、4

实现要点:

  • while (tmp) 以节点对象作为循环条件:只要 tmp 不是 null 就继续;
  • 每次循环先读取 tmp.value 输出当前节点,再把 tmp 前进到 tmp.next;
  • 当走到 next: null 的尾节点后,tmp 变为 null,循环终止。

为什么引入临时变量 tmp

从技术上讲,也可以直接复用入参 list:

function printList(list) {
  while (list) {
    alert(list.value);
    list = list.next;   // 修改了入参
  }
}

但官方题解明确不推荐这样做,理由有两点:

  1. 破坏入参:list 本身代表链表(即首节点)。一旦被修改,函数执行完毕后调用方就再也拿不到原链表了。若未来需要扩展函数(例如在输出后继续对链表做其他操作),这个能力就会丢失;
  2. 语义清晰:list 应当保持"链表本身"的含义,而 tmp 的角色正如 for 循环中的计数器 i——专门负责遍历游走。好的变量命名让代码的意图一目了然。

解法二:基于递归的输出

递归解法的思路非常直接(官方题解):要输出整个链表,只需输出当前元素,再对 list.next 代表的"其余部分"做同样的事。

function printList(list) {
  alert(list.value);   // 输出当前元素

  if (list.next) {
    printList(list.next); // 链表中其余部分同理
  }
}

printList(list); // 依次弹出 1、2、3、4

这里体现了递归的两个基本要素:

  • 递归步骤:printList(list) 被转化为"输出 list.value + 递归调用 printList(list.next)";
  • 基础情形(base case):当 list.next 为 null(即 falsy)时不再递归,函数自然结束。

与 pow(x, n)、sumTo(n) 等数值递归不同,链表递归的"基础"不是数值比较,而是引用是否为 null——这正是递归定义的数据结构天然适配递归遍历的原因。

循环 vs 递归:哪个更好

官方题解的结论非常务实:

从技术上讲,循环更有效。这两种解法做了同样的事,但循环不会为嵌套函数调用消耗资源。另一方面,递归解法更简洁,有时更容易理解。

具体展开如下:

维度 循环(迭代) 递归
内存占用 恒定,仅一个上下文 每个嵌套调用都会在 执行上下文堆栈 中保存上下文,深度为 n 时占用 O(n) 内存
执行速度 更快,无函数调用开销 更慢,涉及压栈/出栈
代码可读性 需显式管理 tmp 游标 结构与链表定义天然对应,更简洁直观
深度限制 无堆栈深度问题 受引擎最大递归深度限制(一般引擎在 10000 以下可靠,100000 很可能超限)

关于递归深度的限制,章节原文 明确说明:"最大递归深度受限于 JavaScript 引擎。引擎在最大迭代深度为 10000 及以下时是可靠的……对于大多数引擎来说,100000 可能就超出限制了。"尾部调用优化(TCO)虽然能缓解这种情况,但目前"还没有被完全支持,只能用于简单场景"。

同章的 对数字求和到给定值 任务也印证了这一点:官方 题解 指出"递归涉及嵌套调用和执行堆栈管理,这会占用资源,因此递归的速度更慢一些";如果引擎不支持尾调用优化,计算 sumTo(100000) 会因"超出最大堆栈深度"而报错。

因此实践建议是:

  • 链表较长、对性能敏感、或可能超过引擎递归深度上限时,优先用循环;
  • 链表较短、追求代码简洁与可维护性时,递归是更好的选择——正如 章节原文 所强调的:"并不是每个地方都需要优化,大多数时候我们需要一个好代码。"

举一反三:反向输出单链表

紧接着的进阶任务 反向输出单链表 要求以 4、3、2、1 的顺序输出同一个链表,同样用循环与递归两种方式实现。这组练习能帮你更深刻地体会递归与循环在"顺序"处理上的差异。

递归版反向输出

官方题解 的核心逻辑是:先输出链表的其余部分,再输出当前元素——即把 alert 放到递归调用之后:

function printReverseList(list) {
  if (list.next) {
    printReverseList(list.next); // 先递归深入
  }
  alert(list.value);              // 回溯时再输出
}

printReverseList(list); // 依次弹出 4、3、2、1

其运行原理正是利用了执行上下文堆栈:每次递归调用先把当前节点"记录"在堆栈中,直到触底(list.next === null)后逐层回溯,alert 便以逆序执行。

循环版反向输出

循环无法从链表尾部向前读取(链表只有 next 单向指针),因此官方 题解 采用的策略是:先正序收集到一个数组,再反向遍历数组:

function printReverseList(list) {
  let arr = [];
  let tmp = list;

  while (tmp) {
    arr.push(tmp.value);
    tmp = tmp.next;
  }

  for (let i = arr.length - 1; i >= 0; i--) {
    alert(arr[i]);
  }
}

printReverseList(list); // 依次弹出 4、3、2、1

题解最后点明了一个有趣的等价性:"递归解法实际上也是这样做的:它顺着链表,记录每一个嵌套调用里链表的元素(在执行上下文堆栈里),然后输出它们。"——递归的调用栈与循环版的临时数组,本质上是同一思路的两种载体。

小结

围绕"输出单链表"这一经典习题,可以提炼出以下可复用的知识点:

  1. 链表是递归定义的数据结构:list = { value, next -> list },用 next === null 表示链表终点;
  2. 遍历链表有两种范式:循环版用 tmp 游标显式前进;递归版用"输出当前元素 + 递归处理 next"表达同一逻辑;
  3. 性能与可读性的权衡:循环省内存、速度快;递归更简洁,但受执行上下文堆栈与引擎递归深度限制;
  4. 递归反向输出只需把输出动作移到递归调用之后,体现了调用栈天然的"后进先出"特性;
  5. 通用原则:任何递归都可以用循环重写(章节原文),但当结构复杂、分支众多时,递归往往更易读、更易维护。

如果想继续深入学习递归的应用,同章还提供了 斐波那契数(注意其朴素递归在 fib(77) 时存在性能陷阱)、递归遍历公司薪资求和 等练习;而 递归与堆栈 章节原文则系统讲解了执行上下文堆栈、递归深度限制与尾调用优化的现状,是理解本文所有结论的底层理论基础。

登录后查看全文
zh.javascript.info