最近刷 LeetCode 61 旋转链表的时候,我一度以为自己是老手稳赢:不就是把链表后面 k 个节点搬到前面吗,跟数组 rotate 一个套路。结果第一次提交,空链表直接空指针;第二次 k 传了 10 万,我还在老老实实循环;第三次更离谱,返回的链表成环,LeetCode 直接提示 cycle detected。这三连跪让我意识到,旋转链表这题考的不是“会不会旋转”,而是“有没有把链表的边界和取模想清楚”。这篇文章就从这三连跪开始,把这道题的完整解法、边界陷阱和调试经验一次讲透,适合刚入门链表题、或者刷了但总在边界挂科的读者。
1. 先把“向右旋转 k 位”翻译成人话
1.1 从数组旋转到链表旋转
在数组里做旋转,最简单的是利用切片或者两次反转:arr[:] = arr[-k:] + arr[:-k]。链表不行,你不能用下标访问任意位置,也没法说“把第 n-k 个元素到末尾这一段切出来”。链表能做的只有一件事:从头遍历,然后改指针。
所以旋转链表的本质是:找到链表中的分界点,把分界点右边的一段接到整个链表前面。比如 1->2->3->4->5 向右旋转 2 位,结果是 4->5->1->2->3。这里的分界点是节点 3:3 的 next 原本指向 4,我们要让 3 的 next 变成 None,让原链表尾节点 5 的 next 指向 1,同时返回 4 作为新头。所有旋转操作最终都归结为一句话:找对分界点,改写必要的指针。
这里有一个容易误解的点:链表节点本身的位置在内存里没有动,变的只是连接关系。旋转不是真正“搬运”了节点,而是头尾引用变了,遍历的起点变了。理解这一点之后,你再看任何链表旋转、反转、重排的题,思路都会清楚很多。
再补充一个等价视角:向右旋转 k 步,等价于向左旋转 n-k 步(n 是链表长度)。不过实现时,与其纠结往左还是往右,不如直接认准一个结论:旋转后的新头节点,是原链表正数第 n-k+1 个节点,新尾节点是正数第 n-k 个节点。这个结论是后面所有解法的地基。
1.2 取模:这道题真正的第一行代码
k 的取值范围是非负整数,也就是说 k 可能是 0,也可能是 100000,还可能比链表长度大得多。链表长度为 n,每旋转 n 次链表就恢复原样,所以真正有效的移动次数是 k % n。
举个例子:1->2->3,k=4。暴力模拟一下:右移 1 次是 3->1->2,右移 2 次是 2->3->1,右移 3 次是 1->2->3,右移 4 次是 3->1->2。结果等价于 k=1。所以 4 % 3 = 1,直接旋转 1 位就好。你看,先取模能省掉大量无效操作。
再列一个对照表,直观感受一下:
| 链表长度 n | k | k % n | 实际效果 |
|---|---|---|---|
| 5 | 0 | 0 | 不变 |
| 5 | 2 | 2 | 正常旋转 2 位 |
| 5 | 5 | 0 | 不变 |
| 5 | 7 | 2 | 等价于旋转 2 位 |
| 5 | 100 | 0 | 不变 |
k % n 之所以重要,不只是省时间。如果你不取模,后面用快慢指针时 fast 要先走 k 步,k 为 10 万而链表只有 3 个节点,fast 早就走过头了。你可以写成让 fast 绕圈走,但绕圈容易把自己绕晕,不如老老实实先求长度,再取模。链表求长度本来就需要 O(n) 遍历,既然都遍历了,顺手把尾节点也找到,一点也不亏。
还有一个很多人会漏的点:取模之后 k 可能变成 0,比如 k 正好等于 n 或 k 是 n 的整数倍。这时候旋转结果就是原链表,直接返回 head。这个判断很重要,因为后面所有找断点的逻辑都依赖 k 是小于 n 的正整数。k 为 0 时,某些写法会算出负数步数,一执行就出错。
2. 成环断链:最不容易写错的解法
2.1 思路只有三步
看到“旋转链表”这题,我第一反应是“找到倒数第 k 个节点的前一个节点”,然后改指针。但直接找断点容易因为步数算错而崩。更稳的写法是“先成环,再断链”,只需要三个步骤:
- 遍历链表,数出长度 length,同时记住尾节点 tail。
- 把 tail.next 指向 head,链表变成环形。
- 根据取模后的 k,从 head 开始走 length-k 步,找到新的尾节点;它的下一个节点就是新头,然后把新的尾节点指向 None,断开环。
为什么成环之后再断链不容易错?因为成环之后你不需要同时维护“头”和“尾”两个引用,只需要在一个环上找到正确的断点,剪一刀就行。打个比方,你有一根绳子,与其纠结从哪头数起,不如先把头尾接成一个圈,然后在想要的长度处剪开,一定不会把方向搞反。这个类比同样适用于后面你会遇到的环形链表题目。
用具体的链表 1->2->3->4->5,k=2 来跑一遍:长度 length=5,取模后 k=2,尾节点 5 的 next 指向 1,链表变成 1->2->3->4->5->1 的环。接着从 head 开始走 length-k-1=2 步,到达节点 3。节点 3 就是新尾,节点 4 是新头。把 3 的 next 置为 None,返回 4,得到 4->5->1->2->3。整个过程清晰明了。
2.2 Python 完整实现
这是我最终提交的版本,注释写在代码里:
class ListNode: def __init__(self, val=0, next=None): self.val = val self.next = next def rotateRight(head: ListNode, k: int) -> ListNode: # 空链表、单节点、移动 0 次,都不用处理 if not head or not head.next or k == 0: return head # 1. 数长度,顺带把尾节点找到 length = 1 tail = head while tail.next: tail = tail.next length += 1 # 2. 取模:去掉整圈的无效旋转 k %= length if k == 0: return head # 3. 成环 tail.next = head # 4. 找到新尾节点:从 head 走 length - k - 1 步 new_tail = head for _ in range(length - k - 1): new_tail = new_tail.next new_head = new_tail.next # 新尾节点的下一个就是新头 new_tail.next = None # 断开环 return new_head代码不长,核心就两个细节:一是先取模再判断 k == 0,能避免成环后再恢复原状的麻烦;二是最后断开时,先保存 new_head 再置空 new_tail.next,顺序不能反。如果你先执行new_tail.next = None,再想拿new_tail.next就成了空指针,经典的“先拆桥再回头找路”错误。
时间复杂度 O(n),空间复杂度 O(1)。链表题的优势就是不需要额外数组,原地改指针就行。
2.3 断点计算为什么是 length - k - 1 步
这是整道题最容易被绕晕的地方,值得单独说清楚。假设链表 1->2->3->4->5,k=2,len=5。旋转后的新头是 4,新尾是 3。新尾在链表里的位置,从 1 开始编号,是第 len-k = 3 个节点:数一下,1、2、3,确实数到 3。而从 head 出发,要走到节点 3,需要走 2 步:head 到 2 是第 1 步,2 到 3 是第 2 步。所以循环次数不是 len-k,而是 len-k-1。
“位置编号”和“移动步数”之间永远差 1,这是链表题里最经典的陷阱。我自己的习惯是:不要背公式,一律按“从当前节点出发,需要走几步才能到目标节点”来算。每次写循环前,心里默念一遍“头节点已经算走了 0 步”,可以有效减少粗心错误。
如果你还是不放心,就在本地打印一下每一步到达的节点值。很多题目不是你不会写,而是算错一步之后,跟着错误代码调试,越调越懵。打印大法虽然朴素,但真的管用。
3. 边界条件全是坑:我在提交记录里翻出的教训
3.1 空链表和单节点:不加这行一定报错
LeetCode 的测试用例里一定有[]和[1],而且 k 可能给一个很大的值,比如 k=99。如果 head 为 None,代码访问head.next会直接 AttributeError,程序当场崩掉。如果只有单节点,成环也能转,但没必要。所以函数第一行统一处理三种情况:not head、not head.next、k == 0。
注意k == 0在这里处理是一种快捷方式,但就算第一行没写,取模之后也必须再写一次。为什么?因为即使 k 不是 0,取模后也可能变成 0。我见过有人只在前面对k == 0做了判断,结果 k=length 时照样走进成环逻辑,计算出负数步数,返回一堆莫名其妙的链表。
我的建议是:第一行的k == 0可以理解为“提前退出的优化”,取模后的if k == 0: return head才是真正的正确性保障。两道闸门都装上,才睡得安稳。
3.2 k 是链表长度的倍数
[1,2,3,4,5],k=5,旋转 5 次等于没转,应该原样返回 [1,2,3,4,5]。k=10、15 同理。写成代码就是取模之后判断if k % length == 0: return head。
如果不加这个判断会怎样?取模后 k=0,length-k-1 等于 -1,range(-1)不会进入循环,new_tail 仍然是 head。接着new_head = new_tail.next,也就是 head.next,然后new_tail.next = None,这等于把链表从第 2 个节点处切断,只剩一个节点。结果完全错误。所以“取模后 k 为 0 必须提前返回”不是优化,是必须有的分支。
还有一种隐蔽情况:链表只有两个节点,比如 [1,2],k=1。length=2,取模后 k=1,length-k-1=0,不需要移动,new_tail 就是 head。new_head 是 head.next,也就是节点 2。然后把节点 1 的 next 置空,把原尾节点节点 2 的 next 指向 head,得到 2->1。这里每一步都对,但很容易因为“循环次数为 0”而产生自我怀疑。记住:循环次数为 0 不代表逻辑错,它表示目标节点就是起点。
3.3 成环后忘记断开:本地测试不容易发现
如果你把 tail.next 改成 head 之后,忘记在返回值里把 new_tail.next 置为 None,LeetCode 后台会检测到环,报 “cycle detected”。这种错误很恶心,因为本地如果只打印一遍节点值,你会看到 4->5->1->2->3->1->2->3...,由于已经出现过 1,程序如果不做保护就会死循环。
怎么快速定位是不是带环?我写本地测试时,会专门在链表打印函数里加一个 id 集合,把访问过的节点对象记录起来,如果走到重复节点立刻停止,并标记 Cycle。这个方法对所有链表题都有用,尤其是做环形链表相关题目时,几乎是必备工具。
另一个经验是:修改指针前先在心里画一下最终形态。比如成环法最后一定是“一条直线链表”,新尾节点的 next 必须为 None。如果最终形态应该是直线,但代码某处还在引用旧节点,多半就是断链位置找错了。
3.4 本地调试模板:造链表、打印链表
在网页编辑器里调试链表题很痛苦,所以我习惯在本地把输入、输出完整跑一遍,重点看指针变化。三个小函数就能搭一个顺手的环境:数组转链表、链表转字符串(带防环)、main 里跑多组用例。
def build_linked_list(arr): dummy = ListNode(0) cur = dummy for val in arr: cur.next = ListNode(val) cur = cur.next return dummy.next def linked_list_to_str(head): res = [] seen = set() cur = head while cur: if id(cur) in seen: res.append("Cycle?") break seen.add(id(cur)) res.append(str(cur.val)) cur = cur.next res.append("NULL") return " -> ".join(res) if __name__ == "__main__": cases = [([1,2,3,4,5], 2), ([1,2,3], 4), ([1], 99), ([], 0)] for arr, k in cases: head = build_linked_list(arr) new_head = rotateRight(head, k) print(f"arr={arr}, k={k} => {linked_list_to_str(new_head)}")这段代码会输出:
arr=[1,2,3,4,5], k=2 => 4 -> 5 -> 1 -> 2 -> 3 -> NULL arr=[1,2,3], k=4 => 2 -> 3 -> 1 -> NULL arr=[1], k=99 => 1 -> NULL arr=[], k=0 => NULL我自己的习惯是每次提交前至少跑六组用例:空链表、单节点、k=0、k=长度、k>长度、正常情况。这套动作帮我挡下了很多低级错误,也让我在面试手写代码时更自信。
4. 换一种姿势:快慢指针解法
4.1 快慢指针的移动逻辑
有些人不太喜欢成环法,觉得“把链表改成环”听起来有点暴力。那可以试试快慢指针:不直接改环,而是找到正确的断点再改指针。
快慢指针的思路分四步:
- 仍然先遍历求 length 和 tail,做
k %= length。 - 让 fast 指针先向前走 k 步。
- slow 停在 head,之后 slow 和 fast 一起移动,直到 fast.next 为 None 时停止。
- 此时 fast 是原链表尾节点,slow 恰好是新尾节点,slow.next 是新头。
为什么 slow 最后会停在新尾节点上?因为 fast 和 slow 之间始终隔着 k 个节点。fast 走到整个链表的最后一个节点时,它距离终点已经不能继续前进,而此时 slow 距离 fast 还有 k 个身位,等价于 slow 距离链表末尾 k 步。从链表末尾倒数 k 个节点,正好就是旋转 k 次之后的新尾节点。这里有点绕,但拿着链表实际走一遍就明白了。
用 1->2->3->4->5,k=2 来模拟:fast 先走 2 步到节点 3,然后 slow 在节点 1,fast 和 slow 一起走。fast 从 3 走到 4,slow 从 1 走到 2。fast 从 4 走到 5,slow 从 2 走到 3。此时 fast.next 为 None,停止。slow 在节点 3,正是新尾节点。
4.2 代码与成环法的对照
def rotateRight_two_pointer(head: ListNode, k: int) -> ListNode: if not head or not head.next or k == 0: return head length = 1 tail = head while tail.next: tail = tail.next length += 1 k %= length if k == 0: return head fast = head for _ in range(k): fast = fast.next slow = head while fast.next: slow = slow.next fast = fast.next new_head = slow.next slow.next = None fast.next = head return new_head这段代码和成环法本质一模一样:都是先求出 length,再定位“从头部数第 length-k 个节点”。区别只是定位方式不同。成环法用for _ in range(length-k-1)直接走到新尾节点;快慢指针用 fast 先走 k 步来拉开一个固定距离,再让 slow 慢慢追上。时间复杂度都是 O(n),空间都是 O(1)。
用表格对照一下:
| 对比项 | 成环断链法 | 快慢指针法 |
|---|---|---|
| 核心操作 | 尾节点指向头节点,再找断点剪开 | 快指针先走 k 步,拉出距离,再同步走 |
| 代码量 | 更短 | 稍长 |
| 找新尾的方式 | 按步数直接走 | 靠快慢指针距离定位 |
| 可迁移性 | 一般 | 能迁移到删除倒数第 N 个节点 |
4.3 两种解法的取舍
我个人的建议是:优先掌握成环断链法,因为代码短、逻辑直白,面试时不容易在指针操作上卡壳。快慢指针解法可以作为补充,因为它的思想可以迁移到 LeetCode 19 删除倒数第 N 个节点这类题。两个解法都有一个共同前置步骤:求长度、取模。这也是旋转链表和“倒数第 k 个节点”题目的最大区别,其他题 k 天然小于等于长度,旋转链表里 k 可能任意大,必须先取模。
另外提醒一句:快慢指针法在k % length == 0时同样要提前返回。如果不返回,fast 先走 0 步,slow 和 fast 同步走,最后 slow 会停在链表末尾,切出的结果就是错的。边界判断永远是第一位的,不管选哪种解法都不能省。
5. 一题带出一串:链表题的通用方法论
5.1 链表题的三个基本功
旋转链表做完之后,我复盘了一下,发现它几乎把链表操作里的所有基本功都串起来了。
第一,遍历求长度、找尾节点。这是链表题最常用的前置操作。你做的很多题目,第一步都是先从头走到尾,数长度或者记住最后一个节点。和数组不同,链表没有 len() 方法,这个 O(n) 遍历躲不掉。既然躲不掉,就把它当成常规操作。
第二,指针步数计算。位置编号和移动步数之间差 1,这个坑我前面专门讲过了。很多链表题的隐蔽 bug 都来自这里:比如“走到第 3 个节点”和“走 3 步到达的节点”根本不是一回事。我自己的方法是把所有类似逻辑都统一成“需要走几步”,代码读起来也更直白。
第三,断链和成环。很多“变形”题都是在这两种状态之间切换视角。比如环形链表检测(141)、环形链表 II(142),核心就是判断环和找环入口。旋转链表则是显式地把链表首尾相接再剪开,和环形链路题是同一类底层思维。
5.2 用这张清单避坑
我在本地调试时准备了一张“链表题提交前检查清单”,每次写完代码挨个过一遍:
| 检查项 | 具体操作 |
|---|---|
| 空链表 | 输入 None,确保函数第一行能返回 |
| 单节点 | 输入 [x],期望输出还是 [x],不能带环 |
| k=0 | 期望输出原链表 |
| k=length | 期望输出原链表 |
| k>length | 先取模再走指针,不许直接走 k 步 |
| 断链 | 返回前确认新尾节点的 next 为 None |
| 保存引用 | 修改 next 之前先保存要返回的 new_head |
这张表不局限在旋转链表上。任何涉及修改指针的链表题,提交前都值得花 30 秒过一遍。尤其是“保存引用”这一项,很多人写反转链表时丢引用,就是因为在cur.next = prev之前没有先用 next 临时变量保存原来的 cur.next。
5.3 可以顺手刷掉的同类题
如果你正按题号顺序刷到 61,我非常建议一起刷这几道:
- LeetCode 19:删除链表的倒数第 N 个结点。快慢指针标准应用,和 61 的定位逻辑几乎一样。
- LeetCode 24:两两交换链表中的节点。练 dummy node 和指针翻转顺序。
- LeetCode 92:反转链表 II。练“找到断点再局部反转”。
- LeetCode 141 / 142:环形链表和环形链表 II。练环的判定和数学推导。
- LeetCode 23:合并 K 个升序链表。练多指针和优先队列,是链表的进阶综合题。
我拿 LeetCode 19 给你做一个具体迁移。删除倒数第 N 个节点,可以先让 fast 走 n 步,再让 slow 和 fast 一起走,等 fast 走到尾部时,slow 正好停在要删除节点的前一个节点。这套逻辑和旋转链表里“让 slow 停在新的尾节点”几乎是同一个模板,只是最后改指针的动作不同。所谓刷题手感,就是在这类重复模式中建立起来的。
如果你想把 61 吃得更透,还可以事后想一想:如果 k 很大但链表很短,成环法是不是天然容错?快慢指针法和成环法到底哪个更适合讲给面试官听?我个人觉得,面试时讲成环法最干脆:先证明取模,再说“首尾相连,找断点,剪开”,三步说完,手写 15 行以内,非常符合面试节奏。
最后说点我个人的经验。旋转链表我前后刷了三遍,第一遍在边界上翻车,第二遍在断链上翻车,第三遍才形成了自己的固定套路:先判空,求长度,取模,成环,找断点。这之后,凡是遇到“第几个节点”“倒数第几个节点”的题,我都会先把这三个基本步骤写出来再动手,效率和正确率都高了不少。一个小技巧:在纸上画一个 5 节点的链表,把 k 分别取 1、2、4、5、6 跑一遍,10 分钟就能把这道题彻底吃透。链表题说穿了就是连接关系的重新编排,想明白这一点,很多坑自然就绕开了。