深蓝黑背景品牌封面:左侧大标题「单链表逆序输出」,副标题「只能遍历一次:递归与迭代各付什么代价」;右侧原创装饰——四个链表节点 1→2→3→4 正向相连,下方反向箭头 4→3→2→1 表示逆序输出序列;左上标注「经典题解析 · 算法」,右上标注「Day 01 · 09-29」 图 1:本文封面,点明题目与两种思路的代价对比。

先说明属性:这是一篇经典题解析,题目出自本地历史面试资料,与任何公司的当前招聘无关。链表逆序输出是链表题里的「入门天花板」——题面一句话,但能一口气问出递归、栈、迭代、边界条件一整串考点。

想象面试现场,面试官写下题目:给一个单向链表 1→2→3→4→null,逆序输出各节点的值(输出 4 3 2 1),只能遍历链表一次。

第一步,审题。

先抠两个字眼。一是「输出」,不是「反转」——题目没要求改变链表结构,只要求按逆序把值打出来。二是「只能遍历一次」,直接把「先遍历一遍存进数组再倒序打印」这条路划掉了:那需要 O(n) 的额外数组空间,且本质是把压力推给了空间。真正的考察点是:你能否讲清楚递归与迭代两条路各自的代价。

第二步,基础回答:递归,回溯时打印。

最简短的写法是递归:函数一路向下走到 null,然后在回溯的途中打印当前节点。调用栈天然就是一个「先进后出」的结构——压栈顺序是 1、2、3、4,弹栈顺序是 4、3、2、1,逆序输出零成本到手。代码最短,最好写,也是多数人的第一反应。

代价藏在隐式调用栈里:每深入一层就占一格栈帧,空间复杂度 O(n)。链表一长,比如几十万上百万个节点,栈帧直接把调用栈撑爆,抛栈溢出错误。这是递归方案必须在面试里主动交代的短板。

第三步,进阶回答:迭代反转,O(1) 额外空间。

如果面试官追问「链表有一百万个节点怎么办」,就该迭代方案出场了:用 prev、curr、next 三个指针,遍历中逐个把节点的 next 方向翻转。全程只用了三个指针,额外空间 O(1),链表多长都不怕。遍历结束后从原尾节点正向输出,就是逆序序列。

它的代价是另一种:链表结构被永久改变了。如果这份链表别处还要用,要么事先确认可以改,要么遍历完再反转回去(又要一次遍历),要么干脆不用这条路。所以说两种方案没有绝对优劣——只要求输出且链表短,选递归;链表长或栈深度受限,选迭代反转。

第四步,写出可运行的代码。

下面这份实现同时给出递归与迭代两种解法,并实测「长链表让递归栈溢出」这个关键论断,用 Node.js 22.15.0 直接运行(node reverse-print.js)即可:

'use strict';

function buildList(values) {
  const head = { val: null, next: null };
  let tail = head;
  for (const v of values) {
    tail.next = { val: v, next: null };
    tail = tail.next;
  }
  return head.next;
}

function toArray(head) {
  const out = [];
  for (let p = head; p; p = p.next) out.push(p.val);
  return out;
}

// 递归:回溯时打印,天然逆序;隐式调用栈 O(n)
function printReverseRecursive(head, out = []) {
  if (!head) return out;
  printReverseRecursive(head.next, out);
  out.push(head.val);
  return out;
}

// 迭代:三指针反转 next 方向,O(1) 额外空间,但改变原链表
function reverseIterative(head) {
  let prev = null;
  let curr = head;
  while (curr) {
    const next = curr.next;
    curr.next = prev;
    prev = curr;
    curr = next;
  }
  return prev; // 新头节点(原尾节点)
}

const list = buildList([1, 2, 3, 4]);
console.log('原链表:', toArray(list).join(' -> '), '-> null');
console.log('递归逆序输出:', printReverseRecursive(list).join(' '));

const newHead = reverseIterative(list);
console.log('迭代反转后正向输出:', toArray(newHead).join(' '));
console.log('注意:原链表结构已改变,新链表为', toArray(newHead).join(' -> '), '-> null');

// 长链表实测:递归会栈溢出,迭代不会
const big = buildList(Array.from({ length: 1_000_000 }, (_, i) => i + 1));
try {
  printReverseRecursive(big);
  console.log('百万节点递归输出:成功');
} catch (e) {
  console.log(`百万节点递归输出:${e.name}: ${e.message}`);
}
const bigReversed = reverseIterative(big);
console.log('百万节点迭代反转:成功,新头节点值 =', bigReversed.val);

实测输出如下:

原链表: 1 -> 2 -> 3 -> 4 -> null
递归逆序输出: 4 3 2 1
迭代反转后正向输出: 4 3 2 1
注意:原链表结构已改变,新链表为 4 -> 3 -> 2 -> 1 -> null
百万节点递归输出:RangeError: Maximum call stack size exceeded
百万节点迭代反转:成功,新头节点值 = 1000000

最后两行把两种方案的代价直接摆上了台面:同一条一百万节点的链表,递归当场栈溢出,迭代反转稳稳跑完。

解法对比图:题意是把 1→2→3→4→null 的单链表逆序输出为 4 3 2 1,且只能遍历一次。左侧递归方案:函数沿链表一路压栈 f(1)、f(2)、f(3)、f(4),到 null 后逐层回溯,在回溯时打印,天然逆序;代码最短,但隐式调用栈占 O(n) 空间,链表过长会栈溢出。右侧迭代方案:用 prev、curr、next 三个指针逐节点翻转 next 方向,遍历结束后从原尾节点正向输出;O(1) 额外空间,但会改动原链表结构。底部对比条:只要求输出且链表短选递归;链表长或栈深度受限选迭代反转 图 2:递归靠调用栈天然逆序,迭代靠三指针原地反转。

第五步,可能的追问。

「既不改链表、又不让栈溢出,还能怎么做?」用显式栈模拟递归——把节点逐个压进自己维护的数组栈再弹栈输出,空间还是 O(n),但堆上的数组远比调用栈能扛。「递归和迭代哪个快?」同是一次遍历,量级相同,差异在常数:递归有函数调用开销,迭代有指针赋值,工程上无需纠结。

易错点清单。

一,把「逆序输出」做成「逆序存储」,平白多付空间或改变结构。二,递归写法交代不出栈溢出风险,被「链表很长怎么办」问住。三,迭代反转后忘记返回新头节点(prev 才是新头,不是 curr)。四,反转循环里没先存 next 就改 curr.next,链表直接断链。

回到面试现场,建议的答题节奏:先确认「只要输出、遍历一次」两个约束,给出递归写法并主动点破 O(n) 栈空间与溢出风险,再补迭代反转作为长链表答案,最后交代「会改原结构」这个副作用。这个节奏展示的是代价意识,而不是背题。

递归压栈回溯这个「借用调用栈逆序」的思想,在 AI 工程里的调用链回溯、流式输出的增量拼接里都能看到影子。你还被哪道链表题追问出过一身冷汗?欢迎分享。