开头想先说个现象:很多人刷链表题,反转整个链表写得飞快,一到"反转链表的一部分"就卡住了。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。
头插循环的每一步,都在做三件事:
cur.next = nxt.next:让cur跳过nxt,先跟后面的节点连上。nxt.next = pre.next:让nxt指向当前区间反转后的头部。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是怎么变的全部标出来。这比看十遍题解都管用。我就是用这个方法把反转链表的一部分彻底啃透的,希望它也能帮你把这一串链表题全部盘顺。