反转链表可能是链表类题目里最容易被低估的一道题。看起来只是把指针掉个个儿,可它串联了指针操作、边界处理、递归思想这三块硬功夫,所以面试和笔试里翻来覆去地考,一点也不奇怪。这些年我面试别人也好,被面也好,几乎每次聊到链表都会回到这道题上,而大多数人能在一分钟内写出迭代版,却会在递归版上卡壳三分钟。这次就把两种解法掰开揉碎讲清楚,不仅讲怎么写出代码,还会解释每一步为什么那么写,以及实际跑代码时最容易踩的坑。
这道题适合三类人:刚入门数据结构、被指针绕晕的新手;准备技术面试、想系统梳理链表题型的求职者;以及已经能默写代码、但说不清两种解法本质差异的进阶学习者。无论你属于哪一类,这篇文章都能从原理到实操给你一套可以直接复用的思路。
1. 反转链表问题到底在考什么
1.1 一个看似简单却高频的经典题
先确认一下问题描述:给定一个单向链表,把它的方向彻底反过来,原来指向下一个节点的指针全部掉头,最终返回新的头节点。比如 1 -> 2 -> 3 -> null 要变成 3 -> 2 -> 1 -> null。
看起来只是遍历一遍、改几个指针的事,但真正动手写的时候,很多人会发现自己的代码在链表长度为 1 或者为空的时候就崩了,或者运行一会儿就死循环。这不是粗心,而是对链表这个结构缺少"肌肉记忆"。链表本质上是一堆节点通过引用串起来的链条,每个节点只知道自己后面是谁,不知道前面是谁。反转这个动作最反直觉的地方在于:当你把当前节点的指针指向前一个节点时,你就失去了继续向后遍历的入口。这个问题不解决,反转根本做不下去。
面试官爱考这道题,通常不是想检验你会不会背代码,而是想看三件事:第一,你有没有意识到"必须先保存下一个节点再改指针"这个先后顺序;第二,你能不能处理空链表和单节点链表这两个边界条件;第三,能不能给自己留出足够的思考空间去解释每一步的意图。迭代法和递归法恰好对应了两种不同的思维习惯,把它们都吃透,才算是真正掌握了这道题。
1.2 迭代与递归两种路线的核心差异
两种解法的目标完全一样,但思考方式截然不同。迭代法把反转拆成一个"边走边改"的过程:拿三个指针在链表上滑行,一路上不断把当前节点的 next 指向前驱节点。它不依赖任何额外的函数调用,纯粹靠循环在原有链表上原地修改。空间复杂度是 O(1),这是它最大的优势。
递归法则换了一个角度:先把整个链表的后续部分递归反转好,再处理当前节点和后续部分的连接关系。它的核心逻辑可以浓缩成一句话——"假设后面的链表已经被反转好了,我只需要让我的下一个节点指向我,再把我的 next 置空"。这种思路依赖系统栈来记录每一层节点的现场,代码可以非常短,但运行时的调用栈深度等于链表长度,空间复杂度是 O(n)。
一个是"从前往后推着走",一个是"从后往前倒着接"。搞懂这两条路线的差异,后面看代码就不会一头雾水。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 迭代法:三指针原地反转
2.1 为什么是三根指针而不是两根
很多第一次写反转链表的人会从两根指针开始:一个指向当前节点,一个指向前一个节点。但是动手一化简,马上会发现一个问题——你把当前节点的 next 改成 prev 之后,当前节点原本的下一个节点找不到了,循环没法继续。
这是链表题最经典的"断链"问题。解决思路说起来很简单:在改变指针之前,先把下一个节点用变量存起来。这就是第三根指针存在的意义。三个指针的分工非常明确:
- prev:已经反转好的部分链表的头,初始为 null
- curr:当前要处理、正准备"掉头"的节点
- next:curr 原本的下一个节点,防止断链后找不到路
有人会问,等循环进入下一步的时候,prev 和 curr 都会更新,那 next 是不是可以省掉?不行。因为 next 必须在"curr.next 被改写"之前保存,而且这个保存动作必须发生在每次循环的内部。这根指针的生命周期很短,只在当次循环里有用,但没有它,整个操作就崩了。三根指针缺一不可。
2.2 迭代法的核心代码与逐行拆解
用 JavaScript 写一个完整的迭代版本,非常简洁:
javascript复制function reverseList(head) {
let prev = null;
let curr = head;
while (curr !== null) {
const next = curr.next; // 1. 先保住下一个节点
curr.next = prev; // 2. 让当前节点掉头指向前驱
prev = curr; // 3. prev 前移
curr = next; // 4. curr 前移
}
return prev; // 循环结束时,prev 就是新链表头
}
逐行拆开看。初始状态:prev 是 null,curr 是原链表头。进入循环后,第一步 const next = curr.next,把当前节点的下一个节点抓在手里,这是防止断链的关键动作。第二步 curr.next = prev,让当前节点的指针反转,这一步真正改变了链表的方向。第三步和第四步是整体前移:prev 移到刚才处理完的 curr 上,curr 移到自己原本的下一个节点上,然后进入下一轮。
这里有个特别容易犯迷糊的点:为什么要用 const 声明 next?因为 next 只在单次循环内有意义,每次循环重新赋值就好,用 const 反而能帮编译器优化,也避免手滑把它改掉。而 prev 和 curr 必须在循环之间传递状态,所以用 let。
循环停止的条件是 curr === null,意思是从头走到尾,所有节点都被处理过一遍。此时 prev 停留在原链表的尾部节点上,那个节点已经在最后一次循环里把 next 指向前驱了,所以它就是反转后链表的新头。返回 prev 就对了。
2.3 边界条件处理与丢头陷阱
迭代法最经典的翻车点有两个。
第一个是空链表。如果 head 是 null,循环压根不执行,直接返回 prev,也就是 null,这完全正确。不少新手在这里多写一行防御逻辑,比如 if (!head) return null,其实多余,但写了也不算错。真正错误的是把 prev 初始成 head,那空链表会返回 null 之外的东西,边界全乱。所以务必记住:prev 初始是 null,不是 head,也不是 head.next。
第二个是丢头。单节点链表 1 -> null 的情况,循环执行一轮就结束:next 是 null,1.next 被置为 null,prev 变成节点 1,curr 变成 null,最终返回节点 1。结果正确,没问题。但如果你在循环结束后习惯性返回了 curr,那就是返回 null,链表整个丢了。很多面试者栽在这一步,就是因为没想清楚"循环结束时 curr 已经被推到了链表外面,真正的头是 prev"。
我再给一个判断代码对不对的小技巧:手动拿一个三个节点的链表,在纸上画出每一次循环之后三个指针的位置。画到第三次循环结束,如果 prev 指向原链表尾并且它的 next 是 null,那这段代码基本就稳了。画不出来或者画错,说明某个更新顺序有问题,回到代码里再捋一遍。
3. 递归法:让系统栈接管现场
3.1 递归解法的三步拆解
递归版的核心思想是:彻底信任"子问题已经被解决"。你递归调用 reverseList(head.next),得到的结果是"从 head.next 开始、一直到末尾"这段链表被反转后的新头。然后你只需要处理一件事:把 head 接到这段反转结果的末尾。
怎么接?这里需要抓住一个关键事实:递归调用虽然返回了新链表的头,但 head.next 指针仍然指向原来的下一个节点。也就是说,原链表结构里"head 后面的那个节点"在递归调用之后并没有自己消失。那个节点在反转之后变成了新链表的末尾,所以 head.next.next = head 就是在把它指向 head,完成首尾衔接。
最后别忘了 head.next = null,把原来的 next 断掉,否则两个节点会互相指,形成一个环。
任何递归都要有基线条件,这个版本也不例外:如果 head 为 null,或者 head.next 为 null,直接返回 head。前一种情况对应空链表,后一种情况对应单节点链表。这两种情况都没有必要再做反转,返回自身就是正确结果。
3.2 核心代码与执行过程推演
javascript复制function reverseList(head) {
if (head === null || head.next === null) {
return head;
}
const newHead = reverseList(head.next);
head.next.next = head;
head.next = null;
return newHead;
}
为了让递归不玄乎,手动推演一个三节点链表 1 -> 2 -> 3 -> null 的完整过程。
调用 reverseList(1),因为 1.next 是 2,不满足基线条件,于是递归调用 reverseList(2)。这里注意,reverseList(1) 中的局部变量、已经执行到哪一行,都会压入系统栈,等子调用返回后继续。
reverseList(2) 发现 2.next 是 3,也不满足基线条件,递归调用 reverseList(3)。reverseList(3) 发现 3.next 是 null,命中基线条件,直接返回节点 3。这时 reverseList(2) 收到了 newHead = 3,然后执行 head.next.next = head,也就是 2.next.next = 2。因为 2.next 是 3,所以这行等价于 3.next = 2,节点 3 就此指向节点 2。接着 head.next = null,把 2.next 改成 null。此时 2 这截链表已经变成 3 -> 2 -> null,返回 newHead(仍然是 3)。
回到 reverseList(1),它收到的 newHead 还是 3,执行 head.next.next = head,即 1.next.next = 1。1.next 是 2,所以等价于 2.next = 1,节点 2 指向节点 1。再执行 head.next = null,1.next 置空。整条链表变成 3 -> 2 -> 1 -> null,最终返回 3。
整个过程有个非常巧妙的地方:newHead 在每一层递归里都是同一个值,它就是原链表的尾节点 3。每一层递归返回的都是这个头,所以最终结果不会丢。理解了这一个点,递归版就通了。
3.3 递归的适用场景与限制
递归版代码虽短,但不是没有代价。它依赖系统调用栈,每一层递归都要占用栈帧,栈帧里至少保存了返回地址和局部变量。链表长度为 n 时,栈深度也是 n。链表达不到特别长时,这没什么问题;但如果你在刷 OJ 时遇到十万甚至几十万个节点的用例,递归很可能直接栈溢出。而迭代版的空间占用是常数,不会出这种问题。
所以我的建议是:日常练习和面试时两种都要能写,但生产代码里我更倾向用迭代版,除非链表长度明确可控。面试时如果用递归版,建议主动跟面试官提一句"这个版本的空间复杂度是 O(n),如果链表很长,我更愿意用迭代写法",这句话本身就能体现你对工程复杂度的敏感度,是个不错的加分点。
递归还有一个容易被忽略的坑:如果链表本身带环,递归会永远不收敛,直接无限递归下去把栈打爆。这个我们放到后面的问题排查章节细说。
4. 实操过程:从零到一写一个能跑的版本
4.1 测试用例怎么设计才算覆盖全面
很多人写完代码只用一个 1 -> 2 -> 3 的例子验证一下就去干别的了,这样远远不够。反转链表属于边界条件非常敏感的题,测试用例至少要覆盖五类。
第一,空链表。head 为 null,返回 null。第二,单节点链表。只有一个节点,返回它本身。第三,普通多节点链表,比如 1 -> 2 -> 3,验证方向是否彻底反转。第四,较长的链表,比如十个节点,用来确认迭代过程不会出现指针混乱。第五,严苛一点的做法是带节点值重复的链表,比如 1 -> 2 -> 1 -> 3,这类用例可以暴露"你是不是只改了值而没改指针"式的假解法——有些人图省事去交换节点 val,遇到重复值会直接出错。
写测试代码时,建议额外写一个辅助函数,把链表转成数组,方便断言结果:
javascript复制function listToArray(head) {
const result = [];
while (head !== null) {
result.push(head.val);
head = head.next;
}
return result;
}
// 断言输出 [3, 2, 1],就说明反转正确
同时再写一个从数组构建链表的辅助函数,测试起来会顺手很多。别小看这两个辅助函数,它们的价值在于把肉眼观察变成了可重复的断言,尤其是调试递归版的时候,能省很多时间。
4.2 两种解法的复杂度对比与选择依据
把两种解法放在同一张表里对比,结论很直观。
| 维度 | 迭代法 | 递归法 |
|---|---|---|
| 时间复杂度 | O(n) | O(n) |
| 空间复杂度 | O(1) | O(n),调用栈深度等于链表长度 |
| 代码量 | 略长,但结构直白 | 非常短,逻辑高度浓缩 |
| 断链风险 | 需要主动保存下一个节点 | 依赖后序递归结果是否正确 |
| 工程适用性 | 高,链表再长都不怕 | 低,长链表可能栈溢出 |
时间上两种解法都是遍历一遍,复杂度没有差别。差别集中在空间上。迭代版只用了三个指针变量,无论链表多长,额外内存都是常数级。递归版虽然每层递归只做常数操作,但系统栈要压 n 层,总空间和链表长度成正比。链表长度几百几千个节点时根本感觉不到差异,一旦上到几万几十万,迭代版会稳定很多。
从维护角度看,迭代版的理解门槛其实更低。因为每一步只涉及三个指针的重新指向,逻辑是一条直线。递归版则要求读者脑子里能自动补出"子问题已经反转好了"这个前置假设,对阅读经验不足的人来说反而更难懂。所以在真实项目里遇到需要反转链表这种操作,默认选迭代版是更稳妥的决定。面试时建议先写迭代版把基本分拿到,再补递归版展示思维层次,最后简短说一句空间复杂度的取舍,基本就是完美答法。
4.3 由反转链表延伸出的三个经典变体
反转整个链表是基础,面试里更常见的是它的变体。掌握了基础版,下面这些题目就只是"改一两个条件"的事。
第一个变体是反转链表的前 N 个节点。它需要多维护一个"后驱节点"的引用,因为反转前 N 个节点之后,原头节点的 next 要接到第 N+1 个节点上,否则会丢尾巴。前面递归版里我们跳过 head.next = null 这行,在变体里不能置空,而是改成指向后驱。代码逻辑和整体反转非常接近,理解了"保存后驱"这个动作,很快就写出来。
第二个变体是反转区间 [m, n]。思路是递归:如果 m 等于 1,整个问题退化成"反转前 n 个节点";如果 m 大于 1,就缩小区间,让 head.next 去递归处理 [m-1, n-1]。这个递推关系画一画就清楚了,本质上是一个"缩小问题规模"的套路。
第三个更进阶的变体是 K 个一组反转链表。这个需要把链表分段,每 K 个一组执行反转,再把每组连接起来。分段时同样要用到"记录下一组的起始节点"这一步防止丢链。别看题目看起来难,拆解后每一组内的操作依然是我们在迭代法里熟悉的指针倒腾。
这三个变体是很好的练习方向。能把基础版吃透,再顺藤摸瓜去写变体,会明显感受到自己的链表思路在变清晰。
5. 常见问题与排查技巧实录
5.1 写出死循环的几种典型姿势
反转链表代码跑进死循环,大多数时候不是运气差,而是指针没有断干净。
第一种典型姿势是递归版忘了写 head.next = null。拿 1 -> 2 -> 3 举例,如果你把 head.next.next = head 写对了,但漏掉最后一行,反转完成后节点 1 的 next 仍然指向 2,而 2 的 next 已经指向 1,这就形成了一个小环。程序遍历链表时会在 1 和 2 之间反复横跳,永远停不下来。对应到代码检查,重点看有没有把"当前节点的 next 彻底断开"这一行写出来。
第二种典型姿势是迭代版在循环内部更新指针的顺序错了。比如你先执行 prev = curr,再执行 curr = next,最后才回头解释 curr.next = prev,那这个赋值顺序就会让正在处理的节点没有被改写,反而让 prev 和 curr 同步移动都没来得及改指针。局面往往是链表没反转成功,还出现环或者丢节点。检查顺序时盯着每次循环里那四行的顺序:先保存 next,再改 curr.next,再移动 prev,最后移动 curr。
第三种典型姿势是在处理反转区间变体时,忘了把反转后的尾部接到后续节点上。比如反转前 N 个节点时没有保存第 N+1 个节点,反转结束后原头节点的 next 还指着已经反转过的节点,过一会儿就绕成环了。这类问题光看代码不容易发现,最好在测试用例里加一个较长链表的场景,把输出转成数组后人工核对首尾是否完整。
5.2 递归栈溢出的边界在哪里
递归版的空间复杂度是 O(n),这个我已经反复强调过。实际开发里你可能要问自己一个问题:链表多长时递归会撑不住?这没有一个标准答案,它取决于运行时环境。比如某些环境默认调用栈大约在 1 万层上下,具体数值跟函数调用开销、设备内存都有关系。虽然十万个节点的链表在普通业务里不算常见,但在竞赛或算法题里就完全有可能遇到。稳妥的做法是,如果你想用递归,先确认数据规模;如果数据规模不可控,直接写迭代版。
还有一类很隐蔽的栈溢出来源:链表带环。假如链表里有环,递归程序永远不会走到基线条件,函数调用无限嵌套,最终必然栈溢出。排查这类问题,先确认输入数据本身有没有环。如果要防患于未然,可以在递归入口加一个深度计数器,超过某个阈值直接抛错,至少不会让程序彻底卡死。
5.3 我踩过坑之后总结的一个自查套路
这类题写多了,我总结出一个特别实用的自查顺序,分享给正要准备面试或者写作业的人。
拿到链表题先别急着动手,用十五秒口头说出答案的"形状":反转之后,原头节点会变成新尾节点,原尾节点会变成新头节点。这个形状图先印在脑子里,再开始写代码。写完之后立刻检查四件事:第一,空链表和单节点链表有没有走对分支;第二,循环或递归的终止条件对不对;第三,每个节点的 next 被改写后,还有没有引用指向它原来的后继;第四,最终返回值到底是不是"新头",而不是某个中间节点。
四步检查完,再用辅助函数把链表打印成数组,拿三个节点的用例跑一遍。如果这一步输出 [3, 2, 1],并且没有卡死,这个版本就可以放心提交了。
反转链表是一个很奇妙的题目:代码最短可以短到十行以内,但背后牵出的指针、边界、递归、复杂度分析全是基本功。花一个晚上把迭代法和递归法分别写透,再用上面的变体扩展开,链表这一类题的手感会明显不一样。至少我当初就是这么练出来的,效果比闷头刷十道同类题还要好。
