☰
反转链表:迭代与递归解法及边界条件全解析
2026/10/16 10:01:52 网站建设 项目流程

“每日一题”刷到链表反转这道题的时候,很多人的第一反应是“不就是把指针掉个方向嘛”,然后动手一写就发现,要么链表直接断成两截,要么运行超时,要么返回的头结点不对。反转链表是链表操作里的经典题,几乎出现在每一份算法面试题单上,但它的价值远不止应付面试:搞清楚反转链表,等于把链表最核心的“指针修改顺序”这个基本功彻底打通了。

这篇内容我会从题目本身讲起,把迭代、递归两种解法的每一个细节拆开,包括为什么三指针的顺序不能乱、为什么递归的head.next.next = head能完成掉头、哪些边界条件最容易翻车,再延伸到区间反转、K个一组反转这些经典变体。无论你是刚开始刷题的新手,还是准备面试想查漏补缺,这篇都能帮你把反转链表这块吃透。

1. 反转链表到底在问什么:一道题背后的链表本质

1.1 题目描述与基本的输入输出约定

原题要求很简单:给定一个单链表的头结点head,反转该链表,并返回反转后的头结点。举个例子,输入链表1 -> 2 -> 3 -> 4 -> 5,输出应该是5 -> 4 -> 3 -> 2 -> 1。

链表结点一般这样定义(不同语言略有差异,但核心字段一致):

class ListNode: def __init__(self, val=0, next=None): self.val = val self.next = next

输入给的是头结点,输出要求返回新链表的头结点。这个头结点很多人会写错:反转之后,原来的尾结点才是新头结点,如果你最后返回的还是head,那结果会变成1或者乱掉。

题目本身没有额外限制,但一个经常被忽略的隐含条件是:链表长度可能是 0,也可能是 1。这两种情况并不需要真的“反转”,直接返回head就行。很多解法写到最后出 bug,恰恰是没把这个特殊情况处理干净。

1.2 为什么链表反转比数组反转难:单向结构的“断链”风险

数组反转有很多办法,最无脑的是新建一个数组倒着填回去,几分钟就能写完。链表反转却不能这么做(虽然可以拷贝成数组再重建链表,但那只是“看起来简单”,面试里基本不会被认可)。

链表最大的特点就是“单向通行”:每个结点只知道下一个结点在哪里,不知道上一个结点是谁。想要反转两个相邻结点的指向,比如让2指向1,你必须保证在修改2.next之前,已经把3的位置记录下来了,否则2一旦指向1,原来从2到3的联系就永久丢失了,后面的结点全部变成“失联”状态。

画个图感受一下。初始状态:

1 -> 2 -> 3 -> null

如果直接执行head.next.next = head(后面递归法会用到),那2的 next 会被改成1,但3就找不到了,因为从头结点出发已经没有路径能到3了。这就是链表操作里最经典的“断链”。理解这个风险,是掌握所有链表题的第一步。

我自己的体会是:链表题绝大多数 bug 都出在指针修改顺序上,而不是逻辑不懂。每次操作前先问自己一句“我改了这条指针之后,还有没有其他路径能到达剩下的结点”,如果没有,就先备份。

1.3 “每日一题”该怎么刷:先动手还是先想清楚

很多同学刷“每日一题”的习惯是打开题解直接看代码,看完觉得“懂了”,合上又不会写。反转链表这道题特别适合用来纠正这种习惯,因为它套路固定、解法清晰,非常适合“先自己推演再写代码”的训练方式。

我建议拿到题先不要看任何题解,拿一张纸画一个三结点的链表,然后自己想:如果我有三个指针,分别指向当前结点、前一个结点、后一个结点,每一步该怎么动?把这个过程在纸上完整走一遍,再打开编辑器写代码。这样虽然慢,但写出来的代码是你自己的,面试时也不会因为紧张而卡住。

这也是反转链表作为“每日一题”的价值所在:它不考什么高深算法,考的是最基础的“用指针操作结点”的肌肉记忆。这个肌肉记忆建立了,后面做环形链表、合并链表、K个一组反转都会顺很多。

2. 迭代解法:三指针的“掉头”手术

2.1 核心思路:prev、curr、next三个指针的分工

迭代解法是反转链表最主流的写法,核心是三个指针:

  • prev:指向当前结点的前一个结点,初始为null
  • curr:指向当前要处理结点,初始为head
  • nxt:记录当前结点的下一个结点,防止断链

每一步做的事情可以用一句话概括:先把nxt保存下来,再把curr.next指向prev,然后三个指针整体后移一格。

为什么要用三个指针而不是两个?因为curr.next = prev执行之后,curr原来的 next 关系已经没了,必须提前用nxt保存它,否则循环无法继续。这个“先备份,再修改,最后移动”的顺序,是整个迭代法的灵魂。

2.2 代码实现与逐行说明

下面是完整的 Python 实现,也是我认为最简洁的版本:

def reverseList(head): prev = None curr = head while curr is not None: nxt = curr.next # 先备份当前结点的下一个结点 curr.next = prev # 当前结点指向前一个结点,完成掉头 prev = curr # prev 前进 curr = nxt # curr 前进 return prev

逐行拆解一下:

  1. prev = None:头结点反转后要变成尾结点,尾结点的next必须是null,所以初始prev是None。
  2. curr = head:从头结点开始遍历。
  3. while curr is not None:当前结点为空说明链表已经遍历完,循环结束。
  4. nxt = curr.next:备份下一个结点,这步不做的话第 5 行会丢失后续链表。
  5. curr.next = prev:让当前结点指向它前面的结点,这就是“反转”的动作。
  6. prev = curr:prev移动到当前结点。
  7. curr = nxt:curr移动到原来保存的下一结点。
  8. 返回prev:循环结束时prev就是原链表的尾结点,也就是新链表的头结点。

Java 版本几乎一模一样:

class Solution { public ListNode reverseList(ListNode head) { ListNode prev = null; ListNode curr = head; while (curr != null) { ListNode nxt = curr.next; curr.next = prev; prev = curr; curr = nxt; } return prev; } }

C++ 也差不多,只是指针语法更明显,能帮你加深“指针本身是变量,next 是对象字段”这个区别:

class Solution { public: ListNode* reverseList(ListNode* head) { ListNode* prev = nullptr; ListNode* curr = head; while (curr != nullptr) { ListNode* nxt = curr->next; curr->next = prev; prev = curr; curr = nxt; } return prev; } };

2.3 手动画一遍全过程:用 1 -> 2 -> 3 推演

光看代码可能还不够直观,我用1 -> 2 -> 3 -> null完整走一遍迭代过程。这里假设每个结点用“值(指针)”表示:

初始状态:

prev = null curr = 1 链表: 1 -> 2 -> 3 -> null

第 1 轮循环:

  • nxt = 2
  • curr.next = prev,即1.next = null,链表变成1 -> null,同时2和3由nxt保存
  • prev = 1
  • curr = 2

此刻实际状态:

prev = 1 curr = 2 nxt = 2 临时视角: 1 -> null , 2 -> 3 -> null

第 2 轮循环:

  • nxt = 3
  • curr.next = prev,即2.next = 1,链表变成2 -> 1 -> null
  • prev = 2
  • curr = 3

第 3 轮循环:

  • nxt = null
  • curr.next = prev,即3.next = 2,链表变成3 -> 2 -> 1 -> null
  • prev = 3
  • curr = null

循环结束,返回prev = 3,也就是3 -> 2 -> 1 -> null,反转完成。

建议你自己也在纸上画一遍,重点关注第 4 行的nxt = curr.next放在什么位置。很多人写成curr.next = prev之后才去保存nxt,这时原来的下一个结点已经找不到了。顺序错一步,整个链表就断掉。

这里补充一个实用经验:如果担心自己记不住顺序,可以默念口诀“先备份,再掉头,prev 跟上,curr 走”。遇到类似的链表操作题,这个节奏基本通用。

3. 递归解法:让调用栈帮你记住后续结点

3.1 递归的终止条件与新头结点的传递

迭代法的本质是“从前往后”逐个改指针,递归法的本质则是“从后往前”改指针。很多初学者觉得递归难,是因为它违背了人脑习惯的线性思考方式:递归先一路走到链表末尾,再在回溯过程中一层层反转。

递归的终止条件写起来很简单:

if not head or not head.next: return head

这个条件有两层含义:

  • not head:空链表,没有可反转的结点。
  • not head.next:当前结点是尾结点。递归调用会不断深入head.next,直到尾结点才停止。

关键在于:递归函数的返回值是“反转后的新头结点”。尾结点就是新头结点,每一层回溯都要把这个新头结点原封不动地往上传,不能弄丢。很多人递归写不好,就是把这个返回值理解错了,以为返回的是当前层的某个结点。

3.2 递归版代码:head.next.next = head 的深意

先看完整代码:

def reverseList(head): if not head or not head.next: return head new_head = reverseList(head.next) head.next.next = head head.next = None return new_head

最让人困惑的是这两行:

head.next.next = head head.next = None

用1 -> 2 -> 3 -> null来理解。假设递归已经深入到head = 2这一层:

  • head.next是3
  • head.next.next = head等价于3.next = 2,这一步让3掉头指向2
  • head.next = None是为了断开原来的2 -> 3,防止成环

关键点来了:head.next.next = head起作用的前提是,head.next这个结点在递归返回后仍然可达。这正体现了递归与迭代的不同——迭代需要用nxt主动保存后继,而递归由调用栈天然保存了每一层的现场。回溯过程中,每一层都还握着自己的head.next,所以即使2.next在下一轮才会被断开,当前这一轮执行时head.next(也就是3)依然是可访问的。

如果把执行过程拆成时间线:

  1. reverseList(1)调用reverseList(2)
  2. reverseList(2)调用reverseList(3)
  3. reverseList(3)满足终止条件,返回3
  4. 回到head = 2层:执行3.next = 2,2.next = null,返回new_head = 3
  5. 回到head = 1层:此时head.next是2,执行2.next = 1,1.next = null,返回new_head = 3

最终从3出发:3 -> 2 -> 1 -> null。

3.3 递归 vs 迭代:什么时候用哪个

两种解法在多数面试官眼里都是标准答案,但适用场景不太一样。下面用表格对比一下:

对比维度迭代法递归法
空间复杂度O(1),只用了三个指针O(n),递归调用栈深度等于链表长度
实现思路从前往后逐个改指针从后往前,利用回溯改指针
代码长度稍长但直观更短但理解门槛高
链表过长时风险无额外风险链表很长时可能导致调用栈溢出
面试追问点为什么先保存 nxthead.next.next 为什么不丢节点

如果是面试场合,我建议先讲迭代法,因为空间复杂度更优,而且不容易被追问“栈溢出怎么办”。但一定要把递归法也掌握,因为很多面试官会问一句“能不能用递归写”,或者反过来问“递归会不会溢出”,你答不上来就会扣分。

实际刷题时有个小技巧:递归法一旦遇到“环”的需求就更容易乱,比如后面要说的区间反转、K个一组反转,迭代法反而更可控。建议以迭代法为主,递归作为理解链表递归结构的补充。

4. 最容易翻车的边界条件与调试技巧

4.1 空链表与单结点链表:很多人在这里丢分

链表题有个共性:边界条件对个数的要求不高,但错一个就全错。反转链表的两个最基本边界是:

  • 输入null(空链表):没有任何结点,返回null
  • 输入单结点1 -> null:反转后还是1 -> null,返回该结点

迭代法天然处理了这两种情况。空链表时curr = None,循环不执行,返回prev = None;单结点时循环执行一次,curr.next指向null,返回prev = 1,正确。递归法同理,因为终止条件里已经包含not head和not head.next。

所以这两个边界其实不需要额外if判断,前提是你的循环条件必须写对。常见的错误写法是while curr.next:,这样遇到空链表会直接抛空指针异常,因为curr本身是null,访问curr.next就崩了。一定要写while curr:或者while curr is not None。

4.2 防止循环链表:末尾结点的 next 必须置空

这是迭代法里最容易忽略的一步。如果最后忘记把原头结点的next置空,会出现什么情况?比如反转1 -> 2 -> 3,在最后一轮循环中,1.next已经在第一轮被改为null了,所以迭代法通常不会成环。但递归法不同,每一层都会执行head.next = None,这行一旦漏掉,就会形成环。

举个具体例子:递归到head = 2这一层时,如果不执行2.next = None,那么从3出发回到2,然后2还指向3,链表变成3 -> 2 -> 3 -> 2 ...,死循环。测试用例如果链表长度稍微长一点,程序会直接超时,而且这种 bug 特别难查,因为打印链表时可能会卡死。

我自己的排查经验是:反转类操作完成后,一定要在返回前检查“反转后链表的尾结点”它的 next 是不是 null。如果是 null,说明环路断开了;如果不是,说明某个结点的 next 没有处理好。

4.3 调试技巧:打印链表、断点观察指针关系

链表题调试比数组题难,因为你看不到“整条链”现在长什么样。推荐两个实用的调试方法。

第一个是写一个简单的打印链表函数,每次循环都打印当前状态:

def print_list(head): cur = head parts = [] while cur: parts.append(str(cur.val)) cur = cur.next print(" -> ".join(parts))

在迭代法循环里加一行:

while curr: nxt = curr.next curr.next = prev prev = curr curr = nxt print(f"prev={prev.val if prev else 'None'}, curr={curr.val if curr else 'None'}")

第二种是断点调试:把nxt、curr.next = prev这两行打断点,单步执行,观察每一步执行后curr和prev的关系。这一步能帮你直观地理解“先备份再修改”的必要性。

这里再分享一个我踩过的坑:链表题千万不要尝试用 JSON 序列化来调试,循环引用会直接导致堆栈溢出。老老实实用“值 -> 值 -> 值”这种字符串拼接方式最稳,也最直观。

5. 从反转链表延伸出去:面试与实战中的变体

5.1 反转前 N 个结点、区间反转:先会基础版再进阶

反转链表本身是小菜,面试官通常会往上加条件,最常见的两种是“反转链表的前 N 个结点”和“反转链表从 left 到 right 的区间”。

反转前 N 个结点可以借用迭代法的思路,但要注意:前 N 个反转之后,原来第 N 个结点的 next 要指向第 N+1 个结点,这个衔接很容易漏。核心是记录两个关键位置:

  • first:反转段的起始结点,反转后变成尾结点,它的 next 要接上下一段
  • last:反转前第 N 个结点,反转后变成反转段的头结点

区间反转更复杂一点,需要引入“虚头节点”(dummy node)来统一处理 left 可能为 1 的情况。虚头节点是一个非常常用的链表技巧:新建一个dummy结点,让它的 next 指向原链表头,这样无论从哪个位置开始反转,都能用统一逻辑处理。最后返回dummy.next而不是head,因为head可能已经被反转操作改变了。

区间反转的实现思路是两段式:

  1. 先走到left位置的前一个结点,记为pre
  2. 在pre后面这一段执行“头插法”,把每个结点依次插入到pre的后面,重复right - left次

这个“头插法”实际上就是反转链表的另一种形态:每来一个新结点,就把它放到最前面,迭代完整个区间,顺序自然就反了。

5.2 K 个一组反转链表:先会基础版再进阶

如果在区间反转上再加难度,就是“K 个一组反转链表”。题目要求每 K 个结点一组反转,最后一组不足 K 个保持原样。

这道题是反转链表家族里的天花板之一,核心逻辑可以拆成三步:

  1. 用count判断剩余结点是否够 K 个,不够则直接返回
  2. 找到当前组的起始结点和结束结点,按组反转
  3. 将上一组的尾结点连接到本组反转后的头结点

每一步都调用一次反转链表的子逻辑,小组内反转就相当于普通反转。写这道题之前,普通反转链表必须达到默写水平,否则一旦出错,根因很难定位。

5.3 实际工程中的链表场景与刷题心态

除了面试,链表反转在工程里也有真实应用场景,比如:

  • LRU Cache 等数据结构在结点移动时会用到链表指针重排
  • 文本编辑器撤销栈的链表式组织,在页面回退时需要反转遍历顺序
  • 数据流中的逆序输出,为了节省额外存储空间,反转让空间复杂度从 O(n) 降到 O(1)

不过坦率讲,实际项目中直接用反转链表的场景不算特别高频,它的价值更多体现在“训练指针操作思维”上。链表题教会你的不是某个具体 API,而是“修改引用之前想清楚谁还持有旧引用”这种工程直觉。这种直觉在写缓存淘汰、处理事件队列、设计状态机时都很受用。

刷题心态上,我想多说一句:不要因为“代码短”就轻视这道题。反转链表虽然核心代码只有几行,但它几乎能覆盖链表题的所有基础考点。我见过很多同学刷了几百道题,回头让他手写反转链表,还是会忘掉nxt = curr.next这一步,原因就是当时只是“背代码”而不是“理解指针顺序”。把这道题彻底吃透,后面再看环形链表、回文链表、链表排序,你会觉得整个链表系列的“手感”一下子打开了。

最后分享一个我个人的小习惯:每过一段时间,我会在草稿纸上重新手写一遍反转链表的迭代和递归解法,不借助任何参考资料。写的过程中不需要犹豫、不需要试错,就说明这个基本功还在。这个习惯我保持了很长时间,成本很低,收益却很大——无论面试现场状态多差,反转链表永远是我的保底题。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询