1. 为什么是这道题:题面、考点与前置知识
1.1 题面与核心考点
LeetCode 热题100里面的“反转链表”,很多人第一眼看到觉得简单,不就是把1->2->3->4->5变成5->4->3->2->1嘛。但真正动手写的时候,迭代写不顺畅、递归想不明白的大有人在。面试里这道题出现的频率极高,因为它能同时考察你三件东西:对链表这种数据结构本质的理解、对指针/引用操作的熟练度、以及对递归这种抽象思维方式的掌握。
题面本身不长,给一个单链表的头节点head,返回反转后的链表头节点。要求时间复杂度 O(n),空间复杂度上迭代是 O(1),递归是 O(n)(栈空间)。很多人在意的是“能不能写出来”,但面试官真正在意的是“你能不能讲清楚为什么这么写”。这也是我今天想把迭代和递归两种解法放在一起彻底拆一遍的原因。
1.2 前置知识:链表结构与指针操作
在 Java 里,链表节点通常长这样:
public class ListNode { int val; ListNode next; ListNode() {} ListNode(int val) { this.val = val; } ListNode(int val, ListNode next) { this.val = val; this.next = next; } }这里要特别强调一件事:Java 里没有 C/C++ 那种“指针”语法,但引用类型本质上就是指针的封装。ListNode next存的不是下一个节点本身,而是下一个节点的地址引用。搞懂这一点,后续所有操作都顺了。
反转链表的核心操作就是“断链”和“接链”。把当前节点的next指向前一个节点,原来的后续节点会暂时丢失,所以必须先用一个变量把后一个节点保存下来。这个“先保存、再改指向”的模式,是整个反转算法的灵魂,也是新手最容易漏掉的地方。
2. 思路拆解与方案选型:迭代和递归到底在解决什么
2.1 迭代思路:三指针翻转的完整推导
迭代解法最核心的三个指针是prev(前驱)、curr(当前节点)、next(后继)。整个过程就像排队转身:队首的人先转过去,然后第二个人转过去,依次传递,最后队尾的人变成新的队首。
具体到每一步,逻辑是:
prev初始化为null,curr初始化为head- 在循环里先保存
curr.next到next,防止改完指向后找不到后续节点 - 把
curr.next指向prev prev移到curr,curr移到next- 当
curr为null时,循环结束,返回prev作为新头
为什么返回prev?因为当curr走到链表末尾的null时,prev恰好停在新链表的头节点上,也就是原链表的最后一个节点。这一步很多人想当然返回curr,结果返回了个空,属于典型踩坑现场。
2.2 递归思路:先想清楚“一个节点要做什么”
递归解法的难点在于很多人一开始就试图“沿着链表走一遍”,然后用循环的思路去套递归,结果越套越乱。正确的打开方式是:不要管整个链表怎么反转,先想清楚“对当前节点head来说,我需要做什么”。
递归的信任链是这样的:假设head.next之后的那段链表已经反转好了,返回的新头节点叫newHead。现在head.next指向的是原链表的下一个节点,而反转之后,这个节点应该指向head,所以执行head.next.next = head。同时,head变成了新链表的尾节点,它的next要置为null,否则会形成环。
边界条件是:head == null || head.next == null,直接返回head。这里尤其要记得判断head == null,很多人只写了head.next == null,传入空链表直接空指针异常。
递归的理解难点在于“信任”:你先相信子递归能搞定后面那一整段,然后专注做好当前节点的两行操作。写多了你会发现,递归其实是“自底向上”的视角,和迭代的“从前往后”正好相反。
2.3 方案对比与选型建议
| 维度 | 迭代解法 | 递归解法 |
|---|---|---|
| 时间复杂度 | O(n) | O(n) |
| 空间复杂度 | O(1) | O(n)(调用栈) |
| 实现难度 | 低,但指针顺序容易错 | 中,需要理解递归范式 |
| 面试推荐度 | 必会,首选 | 必会,作为加分项 |
| 适用场景 | 长链表、生产代码 | 链表较短时的优雅写法 |
我的建议是:面试时先写迭代,因为空间复杂度更好,也不容易爆栈;写完迭代之后再主动说“我还能用递归实现”,并给出递归版本,这会让面试官看到你对两种思维模式的掌握程度。但要注意,如果链表很长(比如上万节点),递归可能导致栈溢出,需要提前说明这一点。
3. 完整实操:Java 实现与逐行拆解
3.1 迭代版 Java 实现与逐行注释
直接上代码,这是我最常用的版本,也是实测下来最不容易写错的顺序:
class Solution { public ListNode reverseList(ListNode head) { ListNode prev = null; ListNode curr = head; while (curr != null) { ListNode next = curr.next; // 先保存后继,避免断链后丢失 curr.next = prev; // 当前节点指向前驱,完成翻转 prev = curr; // 前驱指针后移 curr = next; // 当前指针后移 } return prev; } }这里每一行都有讲究。ListNode next = curr.next必须放在最前面,而且是每次循环的第一步。如果把curr.next修改了之后才保存next,那么拿到的就是上一轮的prev,整个链表就断了,还会形成环。这个顺序问题是我见过最多的报错原因。
另外注意循环结束后的返回。curr == null说明已经越过了原链表的最后一个节点,此时prev指向的就是反转后的头节点。如果传入的是空链表,循环一次都不执行,直接返回null,这个行为正好符合预期。
3.2 递归版 Java 实现与逐行注释
递归版代码更短,但每一行都需要仔细品:
class Solution { public ListNode reverseList(ListNode head) { // 空链表或只有一个节点时,直接返回 if (head == null || head.next == null) { return head; } // 递归反转后半段,newHead 是反转后链表的头 ListNode newHead = reverseList(head.next); // 让当前节点的下一个节点指向当前节点 head.next.next = head; // 当前节点变成尾节点,next 置空 head.next = null; return newHead; } }我建议你在调试时把递归调用过程打印出来看。比如链表是1->2->3->4,最深一层处理的是节点 4,它直接返回自己;回到节点 3 时,执行4.next = 3,3.next = null;回到节点 2,执行3.next = 2,2.next = null;回到节点 1,执行2.next = 1,1.next = null。整个链表从尾部开始逐步翻转,最后返回的newHead一直是最初的节点 4。
一个很容易犯的错是忘记head.next = null。如果漏了这行,原头节点(反转后的尾节点)仍然指向原来的第二个节点,最终会导致链表带环,遍历时会死循环。这个问题在 LeetCode 上执行报错时会提示“Cycle detected”,非常典型。
3.3 测试用例与调试技巧
写算法题,很多人过一遍样例就提交,然后被隐藏用例打败。我在本地一般会准备几类固定的测试数据:
- 空链表:
reverseList(null),预期返回null - 单节点链表:
reverseList(new ListNode(1)),预期返回原节点 - 两个节点:
1->2,预期返回2->1 - 普通多节点:
1->2->3->4->5,预期返回5->4->3->2->1 - 带重复值的链表:
1->1->2->1,用来排除“靠值判断”的错误写法
打印链表的工具方法我建议写一个,方便观察每一步的结果:
public static void printList(ListNode head) { List<Integer> values = new ArrayList<>(); while (head != null) { values.add(head.val); head = head.next; } System.out.println(values); }用断点调试时,重点观察prev、curr、next三个变量在每次循环后的变化。你会发现它们的移动就像一段“接力”,next先跑,curr紧跟,prev最后。这个节奏一旦熟了,写起来就不会卡壳。
4. 变种与扩展:从反转链表到一类问题的解法
4.1 反转局部区间:从整个链表到一部分链表
掌握了全链表反转后,下一个常见变种是反转left到right区间内的节点。LeetCode 上这道题的编号是 92,我在模拟项目X里也给学员讲过。思路可以复用迭代反转:先找到left的前驱节点,记录为pre;然后从left节点开始,对区间内的节点执行标准的迭代反转;最后把pre.next指向反转后的头节点,把反转区间的尾节点指向right的后续节点。
实现时最需要注意的是边界条件。如果left == 1,就没有前驱节点,这时需要用到虚拟头节点(dummy node)技巧。我们在链表操作里经常创建dummy节点,让dummy.next = head,这样即使操作头节点,也能通过pre统一处理,不需要单独写分支。
4.2 两两交换节点与K个一组反转
两两交换链表中的节点是另一个经典变种。核心逻辑是每两个节点一组进行交换,交换完成后跨越到下一组。这里的关键是搞清楚三个引用之间的关系:prev(组前的节点)、first(组内第一个节点)、second(组内第二个节点)。交换操作是prev.next = second,first.next = second.next,second.next = first,然后prev移到first。
再往上走一层,K个一组反转链表就更有意思了。先数一数剩余节点是否够 K 个,够的话就反转这一段,然后递归处理后面的部分。这个题综合了三个能力:区间反转、链长计数、递归处理。我当时在实际练习时,是先自己实现一个reverseRange(start, end)方法,再在 K 个一组反转里循环调用,代码结构会清晰很多。
4.3 回文链表与环形链表排查
回文链表问题也经常被拿来一起复习。判断一个链表是否为回文,有一个经典做法是:先用快慢指针找到中间节点,反转后半段链表,然后从两头往中间遍历比较值。这里就用到了我们刚学的反转链表能力,相当于一个综合应用题。
环形链表问题是另一个相关考点。用快慢指针判断是否有环,如果有环,快慢指针会在环内相遇。之后再用“从相遇点到环入口的距离等于从头节点到环入口的距离”这个数学性质来找出入环点。我在面试辅导中常建议把这些链表题放到一起复习,因为它们的底层都是引用操作和指针移动,一通百通。
5. 常见问题与排查技巧实录
5.1 高频问题速查表
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 返回结果是 null | 循环结束返回了 curr 而不是 prev | 注意循环退出时 prev 指向新头 |
| 栈溢出(StackOverflowError) | 递归深度过大,或递归终止条件写错 | 先检查head == null条件;长链表改用迭代 |
| 死循环/遍历超时 | 忘记设置head.next = null,链表成环 | 递归版检查尾节点是否置空 |
| 空指针异常 | 传入空链表时直接访问head.next | 终止条件写 `head == null |
| 只反转了部分节点 | 循环内next保存时机不对 | 确保每次改curr.next之前先保存后继 |
| 输出结果少了头节点 | 初始prev没有置为null | prev必须从null开始 |
这张表是我在实际刷题和辅导过程中沉淀下来的,几乎覆盖了新手所有的典型报错。很多人遇到问题就急着看答案,其实更有效的做法是在 IDE 里打上断点,一步步看变量的变化。比如输出少节点的情况,你一调试就能发现prev初始值不对。
5.2 面试官会追问什么
除了让你默写代码,面试官大概率会追加这些问题:
- “递归的空间复杂度是多少?”——O(n),因为递归栈深度等于链表长度。
- “如果链表有一万个节点,你会选哪种实现?”——迭代,因为递归会栈溢出。
- “能否原地反转,不新建节点?”——能,上面的实现都是原地操作。
- “如果链表有环,反转会怎样?”——会死循环,生产代码里要先判环。
我在真实面试中遇到过一道追问:“递归改成迭代,本质区别是什么?”现在回头看,这个问题的答案其实很通透:迭代是维护一组变量,在循环中逐步改变状态;递归是把问题拆成同构子问题,用调用栈保存中间状态。两者的核心差异不在于“性能”,而在于“思考方式”。
还有一个隐藏考点容易被忽略:输入链表可能带有环。LeetCode 原题的测试数据里没有环,但面试官可能会追问。如果直接对带环链表做反转,遍历永远走不完。所以严谨的回答应该是:先判断是否有环,有环则先处理环或直接拒绝反转。
6. 个人实操心得与经验分享
最后分享几个我自己的体会。
第一个体会是:反转链表这道题,光看代码是学不会的。我见过不少开发者把代码背得滚瓜烂熟,但一换到“反转链表 II”(区间反转)就完全懵了。原因就是他们没有真正理解“三指针接力”的含义,只是在背模板。我的建议是拿一张纸,画一个三节点的链表,用箭头模拟每一步的指针变化。如果能把这个过程画清楚,迭代解法就永远不会忘。
第二个体会是:递归的写法虽然简短,但真正内化需要“信任”这个思维跃迁。很多人在递归时总想跟踪每一层的调用,导致越分析越乱。正确的做法是:只关注当前节点要做的事,剩下的交给递归去完成。这不是玄学,而是函数式思考的基本功,它不仅在链表题里有价值,在树、图、动态规划里同样重要。
第三个体会是关于测试的。我自己早期刷题时喜欢“写完就提交”,结果压测崩溃。后来改成在本地先跑若干组边界数据,再手推每一步的结果,提交一次通过的几率大幅上升。比如反转链表,在本地把null、单节点、双节点、多节点、重复值这些用例都跑一遍,基本可以杜绝低级错误。
第四个补充小技巧:在 IDEA 里调试递归时,可以给reverseList方法加一个参数,记录当前深度,打印缩进后的调试信息。这样你能直观地看到每一层递归的输入和输出,理解起来会顺畅很多。我自己带人的时候经常用这个办法,效果非常明显。
如果后续还想深挖,我建议按这个顺序练习:暴力反转字符串数组(练基础)-> 反转链表(练指针)-> 区间反转(练边界)-> K个一组反转(练综合)-> 回文链表(练组合应用)。把这一条线打通,链表面试题基本就不怵了。