1. 这道easy题为什么值得认真写一遍
先交代个背景。我自己的链表入门,是从这道题开始的,但第一次写的时候直接在纸上把三指针画了十分钟才理顺。当时我就意识到:LeetCode 给它的难度评级是 easy,但它考察的东西一点都不"容易"——你要真的理解指针在节点之间怎么转移,才能保证一遍写对。
这道题的核心任务一句话就能说清:给一个单链表的头节点head,把整个链表反转,返回新链表的头节点。但真正动手写的时候,很多人会踩坑,比如写着写着就丢了下一个节点的引用、返回的还是原来的head、或者写的递归版本在数据量大一点就栈溢出。所以这篇文章打算把这道题掰碎了讲:三种主流解法、每一步为什么那样写、本地调试怎么配、边界测试怎么设计,以及反转链表往后引申的一串变体题。
先说一个判断:反转链表在链表题里的地位,类似于"冒泡排序"在排序里的地位,它不一定是最高效或最复杂的,但它逼你把基础机制吃透。链表和数组最大的区别在于,数组元素在内存里是连续的,你可以用下标直接跳到任意位置;链表节点是分散的,每个节点只知道自己后面是谁(单链表情况),你要动它只能从head一路走过来。所以操作的实质不是移动数据,而是修改节点里的next指针,让它们指向不同的后继。这道题就是把这件事练到条件反射。
打个比方:一排人依次牵手站在路边,现在要求整支队伍反向,原地不变,不许换位置,只能改"谁牵谁的手"。链表的反转就是这个过程——数据还是那些数据,节点还是那些节点,但每个节点的next指向被整体调转了方向。对于刚接触 C++ 数据结构的读者来说,这道题是理解指针语义最好的入门素材;对于准备面试的人来说,它又往往是面试官考察链表面试题的起点:先让你反转整个链表,然后马上加难度,反转区间、K 个一组反转,全是从这儿长出来的。
另外一个角度:这道题虽然代码量很小,却天然适合用来研究"迭代怎么写、递归怎么写、两者有什么区别、各自的代价是什么"。我在刷题时见过太多人只会背迭代模板,遇到递归就懵,也见过人只写递归,但说不出递归栈的开销,更说不清为什么会栈溢出。所以这篇文章不打算只给一个答案,而是把三种思路全讲透,并给出我自己的建议排序。
2. 迭代反转:三指针推演与那段最容易出错的代码
2.1 为什么必须用三个指针
迭代反转的思路其实很朴素:从头到尾扫一遍链表,把每个节点的next指向前一个节点。问题在于,单链表的节点只有"指向下一个"的信息,你把curr->next改成prev之后,原来的下一个节点就再也找不到了。所以必须在改写next之前,先把下一个节点的地址存下来。这就自然引出了三个指针:
prev:当前节点的前一个节点,也是反转后当前节点应该指向的目标。curr:当前正在处理的节点。next:当前节点的原始后继,需要在curr->next被改写之前保存。
有些人会问,两个指针难道不行?我试过。如果你只用prev和curr,执行curr->next = prev之后,链表后半段就跟整个链表断开了,curr已经无法继续向后走。这一步就是整个题最容易出错的地方:不是逻辑上想不通,而是写代码时顺序没守住。
2.2 一步步推演:以 1 -> 2 -> 3 -> 4 -> NULL 为例
假设链表是1 -> 2 -> 3 -> 4 -> NULL。初始状态:
prev = NULL curr = head // 指向节点 1 next = NULL // 还没赋值进入循环的第一轮:
next = curr->next:先把 2 存下来,防止等会丢失。curr->next = prev:节点 1 的next改成指向NULL。此时链表从"1->2"断开了。prev = curr:prev移动到节点 1。curr = next:curr移动到节点 2。
之后你会发现,每轮都在重复同样的事。到最后一轮,curr指向节点 4,处理完后prev指向节点 4,curr变成NULL,循环结束。此时prev正好停在新链表的头节点上——也就是旧链表的最后一个节点。写完整代码:
struct ListNode { int val; ListNode* next; ListNode() : val(0), next(nullptr) {} ListNode(int x) : val(x), next(nullptr) {} ListNode(int x, ListNode* next) : val(x), next(next) {} }; class Solution { public: ListNode* reverseList(ListNode* head) { ListNode* prev = nullptr; ListNode* curr = head; while (curr != nullptr) { ListNode* nextNode = curr->next; // 先保存后继 curr->next = prev; // 掉转指针方向 prev = curr; // prev 前进 curr = nextNode; // curr 前进 } return prev; } };这段代码里最容易写错的就是第四步和第五步的顺序。比如有人会把curr = nextNode写到curr->next = prev之前,那curr->next存的可就不是原来的 2 了,而是prev,程序直接跑飞。还有一个高频失误是循环结束后return curr,但此时curr已经走到NULL,返回的就是空指针。我建议你在本地跑的时候,把每一步的prev、curr、nextNode都打印出来观察,尤其是 4 个节点的链表演示一轮,比直接背模板效果好十倍。
2.3 迭代写法的时间与空间代价
时间复杂度是O(n),因为每个节点恰好被访问一次;空间复杂度是O(1),只用了三个临时指针,不随输入规模增长。面试中如果你先给出这个方案,然后补充一句"每个节点只会处理一次,额外空间是常数",基本上就加分了。
这也是我在工程代码里更推荐迭代的原因之一。链表反转在真实项目里虽然不常出现,但只要出现,很可能链表规模不小。递归版本看着优雅,但空间开销可能成为隐患,这一点下一章展开说。
3. 递归写法:链表里的"先下去再上来"
3.1 递归的思考顺序和迭代不一样
迭代是顺着链表从前往后推,递归则是先一路走到链表尾,再从后往前改指针。很多人觉得递归难,是因为大脑很难直观地看到"回溯"过程。其实你可以把问题这样拆解:
假设我们有一个函数reverseList(head),它的含义是"反转以 head 为头的链表,并返回新链表的头"。那么对于一个节点head来说,它的后面已经是一整条链表head->next。如果我先把后面这条链表反转,得到的新头就是我们最终要返回的东西。接下来要做的事情,就是让原来的"尾巴"变成新链表的一部分:
head->next->next = head; // 让原来 head 的下一个节点反过来指向 head
head->next = nullptr; // 原来的 head 变成新链表的尾巴
这个思路用文字说很绕,但代码其实短得惊人:
class Solution { public: ListNode* reverseList(ListNode* head) { if (head == nullptr || head->next == nullptr) { return head; } ListNode* newHead = reverseList(head->next); head->next->next = head; head->next = nullptr; return newHead; } };基例是head == nullptr(空链表)或head->next == nullptr(只有一个节点)。这两种情况,反转之后还是它自己,直接返回即可。
3.2 回溯过程手推一遍:4 个节点的例子
还是用1 -> 2 -> 3 -> 4 -> NULL:
- 调用
reverseList(1),1->next不是nullptr,进入递归。 - 调用
reverseList(2),2->next存在,再进。 - 调用
reverseList(3),3->next存在,再进。 - 调用
reverseList(4),4->next == nullptr,返回节点 4。
此时递归开始逐层返回:
- 回到
reverseList(3)这一层:newHead是节点 4。执行head->next->next = head,也就是4->next = 3;再执行head->next = nullptr,即3->next = nullptr。返回newHead,也就是节点 4。此时后半段相对 3 的视角是4 -> 3 -> NULL。 - 回到
reverseList(2)这一层:newHead仍然是节点 4。执行3->next = 2,2->next = nullptr。此时整条链是4 -> 3 -> 2 -> NULL。 - 回到
reverseList(1)这一层:执行2->next = 1,1->next = nullptr。最终得到4 -> 3 -> 2 -> 1 -> NULL。
注意每一步里,newHead从未改变过,它一直是递归最深处的那个节点 4。真正在变的是从后往前逐层修正next指针。这也是我想提醒你的:递归版的关键不是理解递归调用的返回值,而是理解每一层返回后那两行指针操作在做什么。
3.3 递归的代价:优雅但并非没有成本
递归版本的代码确实漂亮,但它有一个不可忽视的问题:空间复杂度是O(n),因为每一层递归都会占用调用栈空间。当链表很长时,可能触发栈溢出。这是 LeetCode 上很多链表递归解法在长链表用例上翻车的原因。我自己的判断是:
- 面试场合,先写迭代,稳,省空间,也好解释。
- 如果你确实想展示递归思路,把它作为补充方案讲,并且主动提一句"递归版本的空间开销是 O(n),数据量大时有风险,所以工程实现我会选迭代"。
- 如果题目明确要求"只能使用 O(1) 额外空间",那递归第一轮就被淘汰了。
另外,C++ 的递归栈深度通常受限,比如在常见配置下可能几万层就崩,而链表长度很容易达到这个量级。所以不是递归不好,是它在这里不是最优选。
4. 头插法:刷链表题绕不开的另一个思路
4.1 头插法的核心动作:永远插在新链表头部
第三种写法叫"头插法",本质上和迭代反转是同一套逻辑,但换了一个更容易迁移到其他链表题的视角。它的思路是:遍历原链表,把每个节点从原链表的头部一个一个"摘下来",然后插入到新链表的头部。新链表是从空开始逐渐长出来的,所以每次插入都会成为新头。
过程如下:
class Solution { public: ListNode* reverseList(ListNode* head) { ListNode* newHead = nullptr; ListNode* curr = head; while (curr != nullptr) { ListNode* nextNode = curr->next; // 先保存后继 curr->next = newHead; // 当前节点指向新链表头部 newHead = curr; // 更新新链表头 curr = nextNode; // 继续处理原链表的下一个 } return newHead; } };你会发现,这个newHead其实就相当于迭代写法里的prev。不同之处在于,迭代写法的语义是"把指针反转",头插法的语义是"构建一条新链,往头部插"。这两种理解方式对应不同的题目,后者在学习 LRU 缓存、链表区间反转、两两交换节点这些题时特别有用。
4.2 虚拟头节点的套路:对边界条件的优雅处理
很多链表题(不只是反转)都喜欢用"虚拟头节点"(dummy head)来简化边界判断。具体做法是构造一个临时节点:
ListNode* dummy = new ListNode(0); dummy->next = head;然后以dummy为起点操作,最后返回dummy->next。它的价值在于:当链表本身为空,或者需要处理"头节点可能被换掉"的情况时,你不需要单独写一堆if判断,因为dummy->next永远指向处理后的真实链表头。反转整个链表其实用不到 dummy,但反转链表的区间、删除倒数第 N 个节点这类题里它是神器。建议你在练反转链表的时候顺便把 dummy 头节点写法也练熟,后面用到会感谢现在的自己。
这里额外说一个实践建议:写头插法时,心中始终要明确"新链表"和"原链表"是两个逻辑链表。虽然它们底层共用同一批节点,但在你的思维模型里,newHead链不断变长,curr链不断变短,两者互不干扰。一旦你把这个抽象建立起来,很多复杂链表操作都会清晰不少。
5. 边界条件与本地调试:我在 VS Code 里怎么跑通并验证
5.1 边界测试用例清单:别看小,能暴露好多问题
写反转链表,写完不是结束了,要测试。我通常至少要跑下面几组数据:
| 测试场景 | 输入 | 期望输出 | 目的 |
|---|---|---|---|
| 空链表 | NULL | NULL | 验证空指针的返回 |
| 单节点 | 1 -> NULL | 1 -> NULL | 验证循环根本不进或递归基例 |
| 两个节点 | 1 -> 2 -> NULL | 2 -> 1 -> NULL | 最容易暴露指针顺序错误 |
| 多个节点 | 1 -> 2 -> 3 -> 4 -> NULL | 4 -> 3 -> 2 -> 1 -> NULL | 常规用例 |
| 已经是反序 | 4 -> 3 -> 2 -> 1 -> NULL | 1 -> 2 -> 3 -> 4 -> NULL | 验证反转的对称性 |
| 有重复值 | 2 -> 2 -> 1 -> NULL | 1 -> 2 -> 2 -> NULL | 防止误以为值与反转有关 |
如果你是在 LeetCode 上做题,它自动帮你跑测试集;但如果你在本地 VS Code 里写代码,就需要自己写一个打印链表的工具函数来验证。我每次都会这么做,因为链表题最怕的坑不是逻辑错,而是"看起来对,其实只是没打印出来看"。
5.2 本地调试链表的配置思路
很多初学者卡在"不知道怎么在本地跑 LeetCode 的链表题",因为本地没有题目给定的ListNode结构体,也没有自动造链表的工具。我在 VS Code 里的做法是这样:
- 自己定义
ListNode结构体。 - 手写一个
createLinkedList函数,通过数组初始化链表。 - 手写一个
printList函数,输出整个链表。 - 在
main里调用Solution中的reverseList,最后打印结果。
如果还不会配置 VS Code 的 C/C++ 调试环境,可以先去搞定三个文件:tasks.json负责编译,launch.json负责启动调试,c_cpp_properties.json负责配置编译器路径和 C++ 标准。我用的是 MinGW-w64 的g++,编译选项通常是这样:
{ "type": "cppdbg", "request": "launch", "program": "${fileDirname}/${fileBasenameNoExtension}.exe", "args": [], "stopAtEntry": false, "cwd": "${workspaceFolder}", "environment": [], "externalConsole": false, "MIMode": "gdb", "setupCommands": [ { "description": "Enable pretty-printing for gdb", "text": "-enable-pretty-printing", "ignoreFailures": true } ], "preLaunchTask": "C/C++: g++.exe build active file", "miDebuggerPath": "gdb.exe" }调试中最有用的一招是:在prev = curr那一行打断点,然后单步调试,观察prev、curr、nextNode三个变量的值变化。只要跑一遍 4 个节点的例子,你就能把指针传递的顺序彻底刻在脑子里,比背十遍代码都有用。
5.3 我在实际跑题时遇到过的典型错误
整理三个我自己真实踩过的坑:
第一个是没保存后继就改了next。当时我把curr->next = prev写在保存nextNode之前,导致链表后半段直接丢失,程序输出一个节点就结束了。排查了半天才发现是顺序问题。
第二个是返回值写错。我写的是return head;,但循环结束后head还是原来的链表头,也就是反转后链表的尾巴。输出结果只有最后一个节点。这个错误特别隐蔽,因为小链表测试时可能碰巧返回了正确的值吗?不会,只要节点数超过 1,立刻暴露。
第三个是内存管理问题。在本地用new创建节点后,跑完测试没有delete,导致内存泄漏。虽然 LeetCode 在线判题不追究这个,但作为 C++ 工程师,写链表操作时必须清楚哪些节点是自己new的、需要在测试结束后释放。建议测试代码里加一个deleteLinkedList函数,养成习惯。
还有一个跟题目本身无关、但很影响体验的问题:如果你的链表构造代码写错了,比如把尾节点的next漏设为nullptr,反转时可能因为"访问了未定义地址"而崩溃。所以造链表的函数也值得单独测一遍,不要想当然。
6. 反转之后的一连串变体题:从一道 easy 长出一棵技能树
反转链表真正让人兴奋的地方在于,它后面挂着一大串变体题。把基础的反转写熟之后,下面这些题都是同一棵技能树上的果子。
6.1 变体一:反转链表的前 N 个节点
这个题目要求在链表中反转前 N 个节点,后面的节点保持原序。做法本质上就是在反转过程中记录一个"后继起点"。比如1 -> 2 -> 3 -> 4 -> 5,反转前 3 个变成3 -> 2 -> 1 -> 4 -> 5。核心区别在于:最终返回的头不同,且要保存第 N+1 个节点的位置并接回去。
迭代做法通常需要一个额外的指针指向"反转区间的尾部要接的节点"。这其实也对应了后面区间反转题的第一层思考。
6.2 变体二:反转链表区间 [left, right]
LeetCode 92 题。给你left和right,只反转这一段的节点。做法可以拆成四步:
- 找到
left的前一个节点pre。 - 从
left到right做局部反转。 - 把局部反转后的头和尾,分别接回
pre->next和原区间后面的节点。 - 注意
left == 1时头节点会被换掉,所以用 dummy 节点可以让代码简单很多。
这一步就能明显看出基础反转练熟的价值:你在局部做的还是三指针反转,只是多记了几个位置。
6.3 变体三:K 个一组反转链表
LeetCode 25 题。把链表按 K 个节点一组,每组内部反转,组与组之间保持原顺序。这是反转链表变体里的"大魔王",很考验对边界条件的掌握。做法大致是:
- 先确认当前组是否有 K 个节点,不够就直接返回。
- 对这 K 个节点做局部反转。
- 把反转后的组头、组尾与前后组正确衔接。
- 移动指针进入下一组。
这道题如果直接上,很多人会崩溃。我的建议是先把基础反转的迭代写法练到闭眼能写,再去做前 N 个节点反转,然后区间反转,最后挑战 K 个一组。这个顺序是我自己一步步走过来的,每一步的知识都会在下一步复用。
6.4 变体四:判断回文链表
LeetCode 234 题。判断一个链表是不是回文结构。一个经典 O(n) 时间、O(1) 空间的解法就是:先用快慢指针找到链表的中点,然后反转后半段,最后比较前半段和后半段是否相等。
你看,又是一道要靠反转兜底的题。这里你会用到两个新技能:快慢指针找中点,以及把后半段链表反转。如果反转链表本身不熟练,这道题就会卡在最后一步。
6.5 我推荐的刷题路径
如果你也想把这一串题吃透,我的建议顺序是:
- 反转整个链表(迭代 + 递归 + 头插都写一遍)。
- 反转链表前 N 个节点。
- 反转链表区间 [left, right]。
- K 个一组反转链表。
- 回文链表。
每道题写完都反问自己三个问题:边界条件是什么?额外空间是 O(1) 还是 O(n)?如果链表很长,会不会爆栈?这三个问题练成习惯之后,链表相关的面试题基本就不会慌。
最后分享一个我自己的小习惯:每次写完链表题,我都不会马上提交,先在草稿纸上画一遍指针变化的图。比如四个节点的链表,把每一轮循环开始前的prev、curr、next标注出来,过程里节点之间的箭头怎么断、怎么连,画清楚再回到代码里对照。这个习惯帮我省了不知道多少调试时间,也让我在真正面试时,即使不写代码,也能用手指在桌上画出反转过程,把思路讲得明明白白。如果你能把反转链表讲到"画图 + 边界条件 + 时空复杂度"三重齐全,这道 easy 题就已经被你吃透了。