LeetCode 206题“反转链表”,我怀疑每个认真刷题的人都至少写过三遍。我自己第一次做的时候,靠的是背下来的三行迭代代码,能AC,但心虚。真正让我想通的一刻,是某次把链表画成一张一张的箭头图,然后发现整道题做的无非是两件事:先把下一个节点记住,再把当前节点的箭头回指。最近重刷到这一题,我特意没有直接写代码,而是把迭代、递归、头插几种思路都重新推了一遍,发现经典的确实值得反复看。
这篇文章不打算只给一份能通过的代码,而是想把反转链表背后那些“为什么要这样”,以及重刷时容易被忽略的细节、边界和扩展题,全部摊开讲清楚。无论你是第一次接触链表,还是准备面试前想彻底吃透它,这篇都可以直接当复习资料用。
1. 这题值得重刷的核心原因:反转是所有链表操作的“原子能力”
1.1 一个看似简单的问题,为什么常被拿来当考点
反转链表的要求一句话就能说清:给定单链表的头节点head,反转链表,返回反转后的头节点。比如输入 1→2→3→4→5,输出 5→4→3→2→1。看起来是不是很简单?但越是这种“所有讲解都能看懂”的题,越容易在实际写代码时翻车。原因在于链表这种数据结构的特殊性:它不像数组那样支持随机访问,也不像双向链表那样天然有前驱指针。单链表里每个节点只存了指向下一个节点的引用,你一旦把箭头掉转,后面的节点就可能“失联”。所以整道题的核心就变成一个工程问题:如何在切断联系之前,先把路记下来。
也正是这个特性,让反转链表成为无数链表题的地基。后面遇到的“反转局部链表”“K个一组翻转链表”“回文链表判断”,本质上都是在反复调用这段“局部反转”的能力。刷题圈常说的“链表题无非是穿针引线”,穿针引线的起手式,基本就是206这一题教会你的。
1.2 现在的大厂面试里,它还够用吗
很多准备面试的朋友会问:都2025年了,还考这种题是不是太基础了?我的看法是,它往往不是作为一道独立难题出现,而是作为“前置步骤”被嵌在更大体的解法里。面试官可能不会直接让你写“反转整条链表”,但可能会让你在O(1)空间内反转链表的某一段,或者让你判断一个链表是不是回文结构。这时候如果你对反转过程的理解还停留在“背模板”层面,大概率会在指针移动的某一步突然卡住,然后越改越乱。
这题“重刷”的价值就在这里:第一次刷,你记住了怎么解;第二次刷,你该记住为什么这么解。比如为什么迭代里要先保存next节点,为什么递归里返回前要把head.next置空,为什么循环结束条件是cur为空而不是cur.next为空。这些问题想通了,你才能在考场上把它当作“搭积木”的零件,而不是一个孤立题目。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 三种解法逐一拆解:迭代、递归与头插
2.1 迭代法:三指针让每个节点原地掉头
迭代法的思路可以理解成“排队改方向”。想象一队人面朝同一个方向站着,每个人都只能看见自己前面那个人。现在要让整队人转过身来,变成每个人都看向自己原来身后的人。操作方式很简单:从队伍最前面的人开始,依次让每个人把手指向自己身后的人。这就要求你在动手前,先看一眼自己身后原本是谁,否则一转身就把人跟丢了。
对应到代码里就是三指针:
- prev:当前节点的前一个节点,初始为 null
- cur:当前要处理的节点,初始为 head
- nextTemp:cur 原本的下一个节点,用来防止断链
每次循环做三步:
javascript复制const reverseList = function(head) {
let prev = null;
let cur = head;
while (cur !== null) {
const nextTemp = cur.next; // 1. 先保存下一个节点
cur.next = prev; // 2. 当前节点指向前驱
prev = cur; // 3. prev 前进一位
cur = nextTemp; // 4. cur 前进一位
}
return prev;
};
这里最关键的是第一步:一定要先用nextTemp把cur.next存起来。因为第二步执行完后,cur原来的后继就断了,如果没有提前保存,循环就没法继续。很多人第一次写会漏掉这个临时变量,结果链表反转了一部分就变成环,或者直接丢了一串节点。
整个循环结束的条件是cur === null。为什么要等到cur为空?因为当cur走到原链表最后一个节点的下一个位置时,说明每一个节点都已经处理完了。此时prev正好停在原链表的尾节点上,也就是反转后新链表的头节点。如果你把条件写成cur.next !== null,最后那个节点的箭头还没来得及回指,循环就已经退出,结果会差一个节点。
从空间复杂度看,迭代法只用到了有限的几个指针,空间是O(1),这也是面试中最被推崇的写法。对于“反转整个链表”这种场景,它也是可读性和性能兼顾得最好的方案。
2.2 递归法:难点在“从后往前”的思维
递归法的代码比迭代法更短,但这东西有个特点:看着越短,越容易让人懵。我先写出来:
javascript复制const reverseList = function(head) {
if (head === null || head.next === null) {
return head;
}
const newHead = reverseList(head.next);
head.next.next = head;
head.next = null;
return newHead;
};
这个递归的终止条件是“当前节点为空,或者当前节点只有一个节点”。为什么单节点也直接返回?因为一个节点的链表反转之后还是它自己,不需要任何操作。
递归的核心是“相信你的函数”:reverseList(head.next)被调用之后,它会返回从head.next开始的那条子链表反转后的新头节点,并且此时head.next已经变成了这条反转后链表的尾节点。接着要做的事情就是把当前的head接到这条链表的最后面。怎么接?既然head.next是尾节点,那么head.next.next原本是null,现在把head填进去,就是head.next.next = head。这行代码的意思是“让原来在我后面的那个节点,它的后面变成我”。做完这一步,还要记得把head.next置空,否则原来指向head.next的那条线还在,链表里会出现环。
这里给一个具体例子。假设链表是1→2→3→4→5,递归调用reverseList(head.next)时其实是在处理2→3→4→5。这个子调用返回的新头是5,且链表内部已经变成5→4→3→2。注意这里的“2”在子链表反转后是尾节点,而head(节点1)的next仍然指向2。所以执行head.next.next = head,就把节点1接在了节点2后面,变成5→4→3→2→1。最后head.next = null切断原来那条从2到3?不对,这里的head.next是节点2,置空之后是切断节点1与2的原始正向连接,但节点2现在指向节点1(因为head.next.next = head已经建立),所以结果是5→4→3→2→1。这样一推就顺了。
用递归处理链表虽然代码优雅,但真正的运行代价是递归栈。对于长度为n的链表,递归深度就是n,在链表特别长的时候会有栈溢出的风险。所以在生产代码或者工程场景里,除非你对递归深度有足够信心,否则我一般会优先选择迭代。但面试中两种都要能写出来,因为面试官想看的是你对递归思维是否熟悉。
2.3 头插法与“不破坏原链表”的特殊场景
还有一种处理反转的思路叫“头插法”。它的操作方式很简单:准备一个新链表的头节点dummy,遍历原链表的每一个节点,把当前节点摘下来,插入到新链表头部。这样先遍历到的节点反倒在更后面,最终得到的就是反转后的链表。
原地头插的代码长这样,很多讲解里也叫“虚拟头节点法”:
javascript复制const reverseList = function(head) {
const dummy = new ListNode(-1);
let cur = head;
while (cur !== null) {
const nextTemp = cur.next;
cur.next = dummy.next;
dummy.next = cur;
cur = nextTemp;
}
return dummy.next;
};
不过要注意一个细节:原题并不限制修改原链表,所以上面这种写法会直接改变原链表结构。如果你需要的是“返回反转后的副本”,比如某些场景不能动原链表,那就需要在遍历时new出新节点,再做头插。代价是O(n)的空间,这就不如迭代法了。
所以在206这道题上,头插法不是最优解,但它是很好的思维扩展,后续做“反转局部区间”的题时,用虚拟头节点处理边界会非常方便。刷题阶段我不建议只记一种,而是建议把三种解法都写一遍,感受一下不同写法的区别。
3. 边界条件与实现细节:比背代码更重要
3.1 空链表与单节点为什么天然安全
算法题最容易翻车的地方不是逻辑主流程,而是边界条件。如果你写的反转函数没有对空链表做额外判断,那么代码里一个“head.next”就可能直接抛空指针。但有趣的是,在206这道题里,迭代法和递归法只要写法正确,天然能处理空链表和单节点的情况。迭代法里cur的初始值是head,如果head是null,循环条件cur !== null直接不成立,返回prev(初始为null),结果正确。递归法里第一行就包含了head === null的判断,所以空链表也能安全返回。
但这不意味着你可以完全不写判断。我这里的意思是:如果你把循环条件、边界返回都写完整了,是不需要单独把头节点判断拎出来的。有些同学喜欢在函数开头单独加一个if (head === null || head.next === null) return head,这当然也没问题,能让代码意图更明确,还能减少一次不必要的循环或递归调用。只是要搞清楚,这个判断在迭代和递归里的位置分别是怎么起作用的。
3.2 迭代终态与指针移动顺序,为什么必须严格固定
迭代法里的指针移动顺序,我认为是整个算法的灵魂。很多人在第三步和第四步容易写反,写成prev = cur; cur = prev,结果就是prev和cur变成同一个节点,链表彻底循环。这里要记住一个铁律:必须先让prev跟上cur,再让cur回到原来的nextTemp。因为一旦你先让cur走到nextTemp,prev就失去了和cur的关联,后面你再执行prev = cur时,prev根本不知道要去哪儿。
更深的逻辑是,这三行操作本质上是在做“拆链”和“建链”。顺序上永远是:先保存旧连接,再建立新连接,最后整体平移。你把这三行的顺序换一换,比如先把cur.next指向prev,再去保存nextTemp,那原链表从cur往后的部分就丢了,程序会进入死循环。很多“看着代码会写,一改就不对”的翻车,根源就在这里。
从退出状态来看,每次循环结束时cur都指向原链表当前节点的后面一个节点。当cur是null时,说明所有节点都已经掉头完成。此时prev指向最后一个被处理的节点,恰好是原链表的尾,也就是新链表的头。这个“终态”必须非常清楚,否则你可能会困惑为什么不返回cur而返回prev。
3.3 递归返回前把next置空,这一行到底救了什么
递归解法里,head.next = null这一行看似多余,其实是防止链表成环的关键。如果不执行这一步,链表从后往前反转的时候会造成什么后果?看一个最简单的例子,两个节点:1→2。调用reverseList(2)返回节点2,然后执行head.next.next = head,也就是节点2的next指向了节点1。这个时候如果不把head.next置空,节点1的next仍然指向节点2,于是节点1和节点2互相指向,整个结构变成循环链表,后续遍历就会死循环。这行代码的意义就是断开原来正向的那条连接。
有的同学会问,如果链表更长,比如1→2→3→4→5,不置空head.next会怎么样?同样的问题依然存在。递归在返回时是从最后一个节点向前推进的,head和head.next之间原始的正向连接并不会因为你改了head.next.next而自动消失,它必须手动断掉。这也是递归法最容易被忽略、最容易制造bug的地方。
4. 代码落地:完整实现与本地调试
4.1 三种主流语言的迭代写法对比
为了让你在面试时能快速用自己熟悉的语言写出来,我分别给一份迭代实现。逻辑完全一样,只是语法略有差异。
C++版本:
cpp复制class Solution {
public:
ListNode* reverseList(ListNode* head) {
ListNode* prev = nullptr;
ListNode* cur = head;
while (cur != nullptr) {
ListNode* nextTemp = cur->next;
cur->next = prev;
prev = cur;
cur = nextTemp;
}
return prev;
}
};
Java版本:
java复制class Solution {
public ListNode reverseList(ListNode head) {
ListNode prev = null;
ListNode cur = head;
while (cur != null) {
ListNode nextTemp = cur.next;
cur.next = prev;
prev = cur;
cur = nextTemp;
}
return prev;
}
}
Python版本:
python复制class Solution:
def reverseList(self, head: ListNode) -> ListNode:
prev = None
cur = head
while cur:
next_temp = cur.next
cur.next = prev
prev = cur
cur = next_temp
return prev
这三份代码几乎长得一样,区别只在类型声明和语法细节上。之所以把三种都放出来,是因为很多同学在面试时临时切换语言会犯低级错误,比如C++里忘了用cur->next,Python里忘了Python是动态类型所以不需要声明,Java则要注意null的大小写是null而不是Null。这些细节点背下来很容易,关键是自己从手写状态能直接顺畅地完成。
4.2 本地如何快速验证正确性
光在编辑器里写了代码还不够,最好能在本地跑起来。刷题时大多数平台已经帮你定义好了ListNode类,但自己调试时需要自己补上。以Java为例,本地完整测试代码大概是这样的:
java复制class ListNode {
int val;
ListNode next;
ListNode(int x) { val = x; }
}
public class Main {
public static void main(String[] args) {
// 构建链表 1 -> 2 -> 3 -> 4 -> 5
ListNode head = new ListNode(1);
head.next = new ListNode(2);
head.next.next = new ListNode(3);
head.next.next.next = new ListNode(4);
head.next.next.next.next = new ListNode(5);
Solution sol = new Solution();
ListNode res = sol.reverseList(head);
// 打印结果 5 -> 4 -> 3 -> 2 -> 1
while (res != null) {
System.out.print(res.val + " ");
res = res.next;
}
}
}
建议至少测这几组用例:
- 空链表:直接传null
- 单节点:传一个节点
- 偶数长度链表:1→2
- 奇数长度链表:1→2→3
- 长度较长的链表:1→2→3→4→5
排查的时候可以在循环里临时打印每轮结束后的prev.val和cur是否为空,帮助确认指针移动是否符合预期。不过要注意,如果链表成环了,打印循环要设置最大次数,不然会死循环导致程序卡死。
4.3 时间与空间复杂度对标
这题两种主要解法的时间复杂度都是O(n),因为每个节点都需要被访问一次。空间复杂度上有明显差异:迭代法额外只用了常数级指针,空间复杂度O(1);递归法的空间消耗来自递归调用栈,最深能到n层,所以空间复杂度是O(n)。面试如果追问空间复杂度,递归法会被作为劣势提出来。
还有一种情况是面试官允许你用额外空间,比如“如果可以用一个栈,这道题怎么做?”方法就是把所有节点压栈,依次弹出的时候把next重新接上。这种方式的空间复杂度也是O(n),但胜在思路非常直观,适用于那些不太熟悉指针操作但熟悉栈的候选人。作为思考扩展了解一下就行。
5. 重刷翻车实录:常见问题与排查技巧
5.1 六个高频翻车点速查表
我整理了一份在重刷过程中容易出现的问题表。这张表在网上各种解析里很难一次性看全,但真正写代码时最常见的坑就是这些。
| 序号 | 错误现场 | 为什么会发生 | 正确做法 |
|---|---|---|---|
| 1 | 没保存next节点,循环直接死掉 | 先改了cur.next,旧链表断掉,cur再也无法前进 | 所有把cur.next重新赋值前,先保存到临时变量 |
| 2 | 循环结束返回cur | 以为cur是反转头 | cur结束为空,prev才是最后一个被处理节点,返回prev |
| 3 | 递归返回后没断head.next | 造成两个节点相互成环 | 递归处理完后必须head.next = null |
| 4 | 递归返回了head而不是newHead | 只把当前节点当成结果 | 子链表反转后的头才是完整链表的新头 |
| 5 | 迭代里prev和cur顺序写反 | 没理解“让prev跟上cur”的含义 | 永远先prev = cur,再把cur移动到nextTemp |
| 6 | 写了if head == null但没管单节点 | 后面继续访问head.next | 直接判断head == null或head.next == null不是必须,只要主流程处理得好也安全 |
第二点很典型。很多初学者第一次看代码会发现while (cur != null)会一直走到cur为null,于是很自然觉得返回null。其实不是,因为cur是“游标”,真正记录最终答案的是prev。这个和遍历数组找最大值最后返回max而不是返回下标有点类似,只是链表指针一多,人容易乱。
5.2 定位问题的调试技巧:画图比打日志更高效
如果反转结果不对,我看很多人的第一反应是到处加打印语句,但链表问题的调试我反而建议先画图。拿一张纸,画几个带箭头的方框,标上pre、cur、nextTemp的位置变化。跟着代码一行一行走,走一次就明白了。这个过程虽然原始,但对理解指针移动的语义非常有帮助。
比如你写了一个递归版本,但是返回结果总是少一个节点,画图后很可能发现是递归返回后没有把newHead带出来,而直接返回了当前head。又比如你发现反转后链表里出现循环,画图后很可能就是少了一句head.next = null。有些问题光靠读代码很难发现,因为思路会顺着自己脑补的逻辑走,但图不一样,它能如实反映每一步的状态变化。
我在本地调试时还会用一个小技巧:写一个“从任意节点开始打印最多N个节点”的工具函数,防止在出现环的情况下打印函数无限循环。这道题写完后,打印函数正常显示到null就说明没有环,如果打印没有停住,就可以很直接判断链表中有循环。
6. 从206到进阶题型:如何把反转能力迁移出去
6.1 反转局部链表(LeetCode 92):虚拟头节点几乎是必须的
反转部分链表是206最常见的变形。题目要求反转从left到right之间的部分,其余部分保持原样。这时候用虚拟头节点dummy就非常合适。先让指针走到left的前一个位置,把这一段区间截出来,对区间内部做一次反转,然后再接回原来的链表。
核心思路是在区间上复用206的迭代逻辑,不同点在于反转结束后要让区间的前一个节点指向反转后的头节点,同时让区间的尾节点指向原来的后继节点。这个“衔接”步骤最常见的坑是丢失原后继。如果你直接用206的写法把这段反完,而没有保存right位置的next,后半段链表就丢了。这也是206刷得不够扎实的人在92题上卡壳的主要原因。
6.2 K个一组翻转链表(LeetCode 25):分组反转的本质
25题要求“每K个节点一组翻转,不足K个保持原样”。这题在外层套了个循环:先从头开始数K个节点,如果不够就直接结束;够K个,就把这一段切下来,调用一次局部反转,再接回去。所以25题的内核其实就是206,只是每反转一组,要把pre指针移动到这一组的末尾,再处理下一组。
很多解法里会嵌套一个reverse函数,这个reverse函数往往就是206原题的单链表反转。所以如果你把206彻底掌握了,25题读起来就是一个“外层控制范围 + 内层反复调用反转”的模板题。难点在于范围边界的维护,而不在于反转本身。
6.3 回文链表和更多场景:为什么面试官总爱问它
回文链表(LeetCode 234)判断链表是否回文,经典做法是先用快慢指针找中点,然后从中点开始反转后半段链表,再逐个比较前后半段。这里再次用到了206的反转能力。如果没掌握反转,就只能借助栈或数组,空间上吃很大的亏。面试官看到你能在O(1)空间内判断回文链表,会认为你的链表基础已经比较扎实了。
更宽泛地说,反转链表的思维还常见于“两数相加II”“反转交替合并链表”之类的问题中。当数据顺序和你处理的顺序相反时,反转往往是第一选择。这也是我为什么反复强调206值得重刷的原因:它在面试题中不是终点,而是通往更复杂题目的钥匙。
7. 重刷这道题,我的三点体会
第一次刷206的时候,我会觉得自己会了,因为代码能通过。但这次重刷过程中我最大的感受是:会写和真正理解之间还隔着一段距离。最后分享三点我自己的实操体会:
第一,不要只背代码,要能说清楚每一行代码解决什么问题。面试时如果你能顺口说出“保存next是为了防止断链”“返回prev是因为它停在原链表尾”“置空head.next是为了防止成环”,面试官一般就会认为你是真的理解了,而不是背了模板。
第二,想练递归思维,可以把206当作一个课题专门研究。先不看答案写递归版本,再手动模拟一个三节点的例子,一步步写在纸上。这一步做完后你会觉得递归没有想象中那么神秘,它只是把“把当前节点接到剩余子链表后面”这件事委托给了函数自己。
第三,所有链表题在写之前都建议先确认一个问题:允许修改原链表吗?如果不允许,206的原地反转就不能用,需要走复制节点的路线。很多后续的变形题都有这个隐含条件,提前确认能少走弯路。
重刷经典题不是浪费时间,它是把“做过”变成“掌握”的最短路径。希望这篇记录也能让你在下次看到206的时候,不再是背出解法,而是真正拥有一套可以随时套用的链表反转思维。
