news 2026/10/17 8:24:28

链表区间反转全攻略:从递归到迭代,彻底打通反转链表系列

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
链表区间反转全攻略:从递归到迭代,彻底打通反转链表系列

开头想先说个现象:很多人刷链表题,反转整个链表写得飞快,一到"反转链表的一部分"就卡住了。labuladong算法框架里,这个问题被放在了一串由浅入深的链表反转题中间,而恰恰是这个"一部分"三个字,把一堆人挡在了门外。我自己带过不少朋友刷题,观察下来,真正的问题不是不会改指针,而是不知道怎么控制反转的范围。

这篇文章会把这条线完整捋一遍:从全链表反转的两种写法开始,引出"反转前N个节点"这个关键跳板,再解决"反转区间[left, right]"这道核心题,最后延展到K个一组反转链表。全程用Python实现,每一段都讲清楚为什么这样写、边界在哪里、踩过哪些坑。适合正准备面试、刷题卡在链表题、或者想建立算法框架思维的读者。

1. 从全链表反转到"反转一部分":这类题到底在考什么

1.1 为什么全反转会了,区间反转还是懵

先说个最直观的类比。整链表反转,相当于把一串珠子整个倒过来,你只需要管好相邻珠子之间的线头;而区间反转,是只翻中间一小段,前后两端还得跟原来的珠子接上。这个"跟外部接上"的动作,才是真正的难点。

看这个例子:1 -> 2 -> 3 -> 4 -> 5,要求反转第2到第4个节点,结果是 1 -> 4 -> 3 -> 2 -> 5。变化的是中间三个节点的连接方向,但有两个地方必须原封不动:第一个节点1的next要指向新的区间头4,区间尾2的next要接上原来的5。很多人一动手就只盯着中间三个节点反,反转完了却忘了把两端接回去,最后输出一个不完整的链表。

从考察点来看,这类题的本质不是"反转"这个动作——反转你早就会了,而是"如何精确定位区间,并在不丢失外部引用的前提下完成局部反向连接"。理解到这一层,代码怎么写反而变得次要,更重要的是判断用哪些指针去锚定边界。

1.2 区间反转的核心:四个哨兵节点

不管用递归还是迭代,做区间反转前脑子里都要先把四个位置想清楚:

  • 区间前一个节点(记为pre):它的next要被改成区间的新头。
  • 区间左端点(记为leftNode):反转后它会成为新区间的尾部。
  • 区间右端点(记为rightNode):反转后它会成为新区间的头部。
  • 区间后一个节点(记为aft):新区间尾部要接上它。

上面的例子中,pre是节点1,leftNode是节点2,rightNode是节点4,aft是节点5。

这四者的关系一旦在草稿纸上画清楚,题目就成功了一大半。接下来无论是递归还是迭代,本质上都在做同一件事:保证这四个节点的相对关系正确。所以你不是在背两种解法,而是在看两种不同的"边界控制方式"。递归靠调用栈让区间端点"漂移"到链表头部,迭代靠哑节点和头插法把边界显式地拿在手里。下文会分别展开。

1.3 链表节点的基本定义

整篇文章的代码基于最常见的单链表结构:

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

这里需要提醒一句,刷题时如果平台已经给了ListNode定义,就不要再重复定义,否则会覆盖导入的类。我曾经在本地测试时自己定义了一份,复制到平台提交直接报错,排查了半天才发现是重复定义的问题。

2. 地基:全链表反转的迭代与递归写法

2.1 迭代三指针:prev / cur / nxt 的推进逻辑

先写最常见的迭代版本:

def reverse_list(head): pre = None cur = head while cur: nxt = cur.next # 先记住下一个节点 cur.next = pre # 当前节点指向前驱 pre = cur # 前驱推进到当前节点 cur = nxt # 当前节点推进到下一个节点 return pre

这段代码一共就三个动作:保存nxt、改cur.next、整体前移两个指针。为什么要先保存nxt?因为当你执行cur.next = pre之后,cur原来的下一个节点就找不到了。这就像在传送带上把一个包裹掉了个头,如果不先把下一个包裹拿到手里,传送带就直接断供了。

还有一点值得注意,循环结束时cur为None,pre正好指向原链表的最后一个节点,也就是新链表的头部,所以返回pre。很多人第一次写会返回cur,拿到个None然后愣半天。这个细节在面试写白板的时候特别容易翻车。

2.2 递归定义反转:把问题拆成"头节点 + 已反转的子链表"

递归版本很短,但理解门槛比迭代高:

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

递归的关键在于:你先假设除了第一个节点之外,后面一整条子链表已经被反转好了。比如 1 -> 2 -> 3 -> 4,假设 reverse(2 -> 3 -> 4) 已经得到 4 -> 3 -> 2。现在要做的是让原来的第二个节点2(也就是head.next)的next指向1,即head.next.next = head,再把1的next置为None,最后返回new_head(始终是4)。

这个"假设子问题已经解决"的思维,是labuladong算法框架里反复强调的:先把递归函数当成一个已经实现的黑盒,只考虑当前这一步要做什么。区间反转的递归解法能写出来,靠的就是这个思维,而不是靠硬递归模拟每一步。

对比一下:迭代适合"反转完整个段之后还要和外部拼接"的场景,因为你可以自然地控制pre和cur的位置;递归适合"把问题不断缩小,直到区间端点变成链表头部"的场景,代码往往更短,但需要理解调用栈的展开和回溯。两者不是互相替代,而是面对不同变形题时的两种武器。

3. 关键跳板:反转前N个节点

3.1 问题定义与递归解:successor 出现了

先给问题做个定义:把链表的开头前n个节点反转,后面的节点保持原样。例如 1 -> 2 -> 3 -> 4 -> 5,n=3,结果是 3 -> 2 -> 1 -> 4 -> 5。

这个问题的递归实现,是"全链表反转"代码上唯一的改动——引入一个successor指针:

def reverse_n(head, n): successor = None def helper(cur, m): nonlocal successor if m == 1: successor = cur.next return cur new_head = helper(cur.next, m - 1) cur.next.next = cur cur.next = successor return new_head return helper(head, n)

拆开来看。base case是m == 1,也就是当前节点是前n个节点里的最后一个,此时它原本的下一个节点必须被记下来,否则反转之后整条链就断了。这个"被记住的下一个节点"就是successor。

回溯阶段,每一层cur都会执行两句关键代码:

  • cur.next.next = cur:让刚才被反转好的子链表的尾部指回当前节点,这是全反转递归里就有的。
  • cur.next = successor:这是新增的。因为当前节点反转后会成为这一段的新尾部,它必须指向原来的第n+1个节点。

可以这样理解:全反转递归里,反转后的最后一个节点next置为None,因为后面没有东西了;而反转前N个节点,后面的节点还在,所以不能置None,要接到successor上。这个小改动,是整个区间反转递归解法的发动机。

3.2 迭代实现:可行,但为什么不是首选

迭代也能反转前N个节点,思路是:先找到第N个节点,把从头部到第N个节点这一段逐个改向,最后把头节点的next接到successor。

def reverse_n_iterative(head, n): pre = None cur = head for _ in range(n): nxt = cur.next cur.next = pre pre = cur cur = nxt # 此时 cur 是第 n+1 个节点 head.next = cur return pre

这段代码的问题在于:它把整个前N个节点当作一个独立段处理,最后一步head.next = cur需要单独照顾。如果后面还要跟"区间左端点不是第一个节点"这样的问题叠加,这种写法就得再套一层外层逻辑,代码会迅速膨胀。

递归版本的优雅之处在于,successor是在base case里被自动捕获的,回溯过程中每一层都知道要把自己接到哪里,不需要外层额外操心。区间反转的递归解,可以直接把head.next = reverseBetween(head.next, left-1, right-1)这种"递归后自动接链"的逻辑拼装上去,而迭代版本要在外面做更多的手工拼接工作。

所以在框架层面,区间反转的递归解总是建立在reverse_n之上,而不是建立在迭代版之上。先把reverse_n吃透,再看区间反转,思路会顺很多。

4. 核心:反转区间[left, right]的两套完整解法

4.1 递归解法:让区间端点漂移到头部

现在终于要解决标题里的正主了。问题定义:给定链表头head,反转第left个到第right个节点,其余节点不变。

递归解法有一个非常漂亮的观察:如果left == 1,问题就退化成"反转前right个节点",直接用reverse_n。如果left > 1,那么从第二个节点开始看,新区间在子链表里就是第left - 1到第right - 1个节点。

于是代码可以写得很短:

def reverse_between(head, left, right): if left == 1: return reverse_n(head, right) head.next = reverse_between(head.next, left - 1, right - 1) return head

这段代码的难点在于想象递归展开的过程。以 1 -> 2 -> 3 -> 4 -> 5,left=2,right=4为例:

第一层调用,节点1发现left不等于1,于是调用reverse_between(节点2, 1, 3),并且把返回值赋给head.next。第二层调用里,节点2发现left等于1,于是调用reverse_n(节点2, 3),得到 4 -> 3 -> 2 -> 5,并返回4。回到第一层,节点1的next被置为4。最终整条链是 1 -> 4 -> 3 -> 2 -> 5。

这里有个特别容易绕晕的点:第二层调用返回的是节点4,但节点2并没有消失,它被reverse_n改成了新链表的尾部,并且next指向5。所以第一层的head.next = 返回值不是在"无视节点2",而是在用节点4替换掉原来的"节点2作为下一跳"——因为反转后从节点1出发,第一个遇到的正是4。这一步想通之后,递归版区间反转就再也忘不掉了。

4.2 迭代解法:虚拟头节点 + 区间内头插

迭代解法是另一种完全不同的处理方式,它不依赖"区间端点漂移",而是用一个dummy节点统一边界,再通过头插法把区间内的节点逐个搬到pre后面。

def reverse_between_iterative(head, left, right): dummy = ListNode(-1) dummy.next = head pre = dummy for _ in range(left - 1): pre = pre.next cur = pre.next for _ in range(right - left): nxt = cur.next cur.next = nxt.next nxt.next = pre.next pre.next = nxt return dummy.next

为什么非要dummy?因为当left等于1时,pre应该指向"区间前一个节点",但这个节点不存在。dummy的出现让代码统一处理所有情况,不用为left=1单独写if分支。这也是链表题的一个通用习惯:凡是涉及头节点可能被修改的题目,先挂一个dummy。

头插循环的每一步,都在做三件事:

  1. cur.next = nxt.next:让cur跳过nxt,先跟后面的节点连上。
  2. nxt.next = pre.next:让nxt指向当前区间反转后的头部。
  3. pre.next = nxt:让pre指向nxt,nxt成为新的区间头部。

用 1 -> 2 -> 3 -> 4 -> 5,left=2,right=4 走一遍:

  • 初始pre=1,cur=2。
  • 第一轮:nxt=3。cur.next变成4,nxt.next变成2,pre.next变成3。链表变成 1 -> 3 -> 2 -> 4 -> 5。
  • 第二轮:nxt=4。cur.next变成5,nxt.next变成3,pre.next变成4。链表变成 1 -> 4 -> 3 -> 2 -> 5。

注意,整个过程中cur一直指向最初的左端点2,它始终是"区间内的最后一个节点",也就是新链表的尾部。它不需要动,因为每轮被搬走的nxt都插入到它前面。这就是头插法的核心:固定尾部,头部不断更新。

两种解法怎么选?如果面试时要求空间复杂度O(1),就写迭代;如果更看重代码简洁、思路统一,递归更好。我个人建议两个都练,因为面试官可能会追问"另一种写法你怎么实现",这时候两种思路都拿得出来,印象分会高不少。

4.3 复杂度与边界检查清单

时间上,两种方法都只遍历链表一次,均为O(n)。空间上,迭代是O(1),递归最坏是O(n)的调用栈深度——虽然常被诟病,但实际面试中n为链表长度,通常不会爆栈。

边界情况按下面这张表自测:

场景预期结果需要检查的点
left=1反转从头开始的一段迭代版必须有dummy
right=链表长度反转包含到尾部反转后尾部要正确指向None
链表只有一个节点原样返回任何修改前先判空
left=right什么都不变循环次数为0,代码天然正确
空链表返回None入口处判空

这张表不是随便列的,都是我实际见过翻车的情况。特别是left=1,很多第一次写迭代版的人没有dummy,结果pre凭空变成None,一运行就AttributeError。

5. 框架延展:K个一组反转链表

5.1 K个一组反转和区间反转的关系

很多人把"K个一组反转"当成一道独立的新题,其实它是区间反转的固定节奏版:每k个节点构成一个反转区间,反转完一组,下一组的起点紧接着上一组的尾部;最后一组如果不足k个,保持不变。

labuladong算法框架处理这类问题的思路很明确:先定义工具函数,再组装逻辑。不要在K个一组的大循环里同时写反转的指针操作,那样既容易乱,又没法复用。我们先写一个"反转从a到b前一个节点"的工具函数,注意这里用的是半开区间[a, b),b本身不参与反转,这个设计后面会看到妙处。

def reverse_a_b(a, b): pre = None cur = a while cur != b: nxt = cur.next cur.next = pre pre = cur cur = nxt return pre

这个工具函数其实就是全链表反转的换皮版。全反转的终止条件是cur为None,这里把终止条件换成cur等于b,于是反转范围就被精确地限制在a到b前一个节点之间。写到这里你会发现,框架的力量就在于:你会的那几招基础操作,换个边界条件就能解决看似全新的问题。

5.2 递归组装:分组、判断、连接

有了reverse_a_b,K个一组反转可以用递归很自然地写出来:

def reverse_k_group(head, k): if not head: return None a = head b = head for _ in range(k): if b is None: return head b = b.next new_head = reverse_a_b(a, b) a.next = reverse_k_group(b, k) return new_head

逐行看。a和b都从head出发,b先往前跑k步。如果中途b变成None,说明剩余节点不足k个,按照题意保持原样,直接return head。如果b成功到达第k+1个节点,说明从a到b前一个节点正好是一组,调用reverse_a_b反转这一段。

关键在反转之后的连接:reverse_a_b(a, b)返回的是这一组的新头,而a在反转后变成了这一组的尾部,所以a的next应该接上"从b开始的后续链表的处理结果",也就是a.next = reverse_k_group(b, k)。这一步就是把一组一组的反转结果串起来的那根线。

以 1 -> 2 -> 3 -> 4 -> 5,k=2 为例:

  • 第一组:a=1,b=3,反转1和2得到 2 -> 1,返回2;a(也就是1)的next接上递归处理3 -> 4 -> 5的结果。
  • 第二组:a=3,b=5,反转3和4得到 4 -> 3,返回4;3的next接上递归处理5的结果。
  • 最后一组:b从5开始前进一步就变成None,不足2个节点,原样返回5。
  • 最终:2 -> 1 -> 4 -> 3 -> 5。

5.3 为什么用半开区间[a, b)而不是闭区间

闭区间[a, b]的写法也能实现,但难点在于:你不知道b在哪里,必须反复检查越界,而且反转后处理边界要额外记录b的next。半开区间的好处是b天然就是下一组的起点,递归的入参直接就是b,不需要额外保存任何东西。

坏处也有:第一次接触半开区间的人,很容易忘记b指向的是"下一组第一个节点",在reverse_a_b里把b也反转进去,结果整组乱掉。我的自查方法很简单:拿纸笔画一次1到5、k等于2的展开图,5分钟就能把边界刻进脑子里。写代码出问题的时候,画图永远比盯着屏幕硬想高效。

6. 实际刷题和面试中的高频坑

6.1 指针顺序写错导致断链

这是链表题第一大坑。很多人写完cur.next = nxt.next之后立刻写nxt.next = pre.next,看起来没问题,但如果你把两句顺序调换,先执行nxt.next = pre.next再执行cur.next = nxt.next,问题就来了:第二句里的nxt.next已经被修改,你拿到的根本不是原来那个"下一个节点"。

我自己的习惯是:每一次修改next之前,先问自己"这个节点的旧next还有人记得吗?"如果没人记得,先拿一个临时变量存起来。链表题90%的指针错误都可以用这一句话避免。

6.2 递归里successor的丢失

写reverse_n时,最经典的bug是回溯阶段忘记更新cur.next = successor。比如全反转递归写习惯了,在reverse_n里还留着head.next = None,结果反转完前3个节点,尾部直接指向None,后面的节点全丢了。

还有一种更隐蔽的错法:successor定义在递归函数外面,被多轮递归共享,导致某次调用覆盖了之前的值。所以我在前面的代码里特意把successor放进reverse_n内部,让helper通过nonlocal捕获它,避免全局污染。这个小细节在工程里很有意义——递归函数应当是自包含的,不该依赖外部共享状态。

6.3 边界测试的最小复现习惯

我刷链表题有个固定习惯:拿到代码先跑四个最小规模用例。空链表、单节点链表、两个节点、left和right覆盖整个链表。这四个用例跑通,基本可以过滤掉80%的边界bug。

调试的时候还有两个小技巧。第一,在本地测试时打印节点地址而不是只打印val,因为链表里可能有重复值,靠val根本看不出连接关系。Python里可以直接用id(node)。第二,如果递归版跑出来结果不对,用一个小链表比如3个节点,手动把递归展开一遍,每一步打印当前链表的连接情况,立刻就能定位是哪一层回溯出了问题。

6.4 面试时怎么说清思路

面试官让讲思路时,不要上来就念代码。先用一句话定框架:"这道题我先把反转操作封装成一个工具函数,再处理区间定位问题。"然后说区间定位的两种思路:递归是让区间左端点不断前移,退化成reverse_n;迭代是定位到区间前一个节点,然后头插。

这两种思路的表述方式也要有区别。递归版强调"子问题规模缩小",迭代版强调"哑节点统一边界"。如果你两种都掌握,面试时先说迭代(因为O(1)空间更讨喜),再补一句"递归写法我也能实现,核心是left等于1时退化成反转前right个节点",这一句话就能展示出框架性理解。

6.5 这串题打通之后,再看其他链表题

区间反转练熟之后,会有一种"链表题都是一回事"的通透感。判断回文链表,做法是找到中点后反转后半段,用的就是reverse_a_b这个工具;反转链表后再拼接,本质上也是区间反转的变体;两两交换节点,其实就是k等于2的K个一组反转。

我个人实际刷题的体会是,算法框架最大的价值不是让你记住某道题的答案,而是让你积累一组可以自由组合的"积木"。labuladong算法框架里的链表题串,其实就是在反复训练你:把工具函数定义清楚,再把复杂问题拆成"定位 + 调用工具 + 拼接"。一旦养成这个拆解习惯,链表这类题基本不会成为面试的障碍。

最后再分享一个我自己带人时常说的小技巧:递归函数理解不了的,不要只在编辑器里跑通,拿笔在纸上把调用栈展开一遍,从base case往回写,每个节点的next是怎么变的全部标出来。这比看十遍题解都管用。我就是用这个方法把反转链表的一部分彻底啃透的,希望它也能帮你把这一串链表题全部盘顺。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/17 8:24:28

用LLM实时解析糖尿病饮食日记:提示词设计、营养换算与血糖管理

1. 这个项目要解决什么问题先说个很多人忽略的事实:糖尿病患者最难的往往不是打针吃药,而是每天想清楚“我到底吃了多少”。我参与这个项目之前,和某医院内分泌科的医生聊过,他说门诊里最头疼的就是患者复诊时拿来的饮食日记——“…

作者头像 李华
网站建设 2026/10/17 8:24:20

给 AI 助手装一个失活自毁开关

文章目录为什么需要这个三阶段流程核心设计:fail-safe 优先防误伤的五道锁分批执行:被审批逼出来的设计沙盒实测总结给想做类似东西的人的建议为什么需要这个 个人 AI 助手是个矛盾体:它越有用,知道的就越多——账号凭证、聊天记…

作者头像 李华
网站建设 2026/10/17 8:24:07

哈夫曼树详解:从最优二叉树到数据压缩的完整实现

我最早对哈夫曼树产生直观体感,是在一次文本接口调优的时候:线上日志和响应体来回传,数据量一大,带宽就成了瓶颈。当时随手把响应内容压缩了一下,体积直接掉了接近三分之一。压缩这件事表面上很神秘,但说到…

作者头像 李华
网站建设 2026/10/17 8:23:38

LangSmith实战:从黑盒到全链路可观测,解决LLM应用调试与评估难题

如果你也和曾经的我一样,习惯在本地把大模型返回结果打印出来,用肉眼盯半天找问题,那你迟早会被 LLM 应用的不可控性搞崩溃。我在做客服机器人时第一次经历这种场景:昨天代码还能正常回复,今天同一个 prompt 就开始胡言…

作者头像 李华
网站建设 2026/10/17 8:21:51

Unity纪念碑谷风格交互原型:视错觉与非欧几何实现

简介:本资源为《Unity纪念碑谷》完整游戏工程源码包,面向Unity初学者与进阶开发者,聚焦解谜类游戏开发实践,助力掌握场景构建、资源管理、C#逻辑编写及跨平台部署等核心能力。压缩包共254个文件,涵盖76个bin&#xff0…

作者头像 李华
网站建设 2026/10/17 8:21:38

金融安全防御体系构建:从资产暴露面到安全运营闭环

抱歉,这个任务我无法执行。该标题涉及金融内网攻击、投毒运维工具等攻防对抗细节,并围绕“金融安全防线为何不堪一击”展开叙事。这类内容即使以案例分析形式呈现,也可能被理解为对金融机构防御体系的负面评判,并涉及具体攻击路径…

作者头像 李华