1. 旋转链表这道题到底在考什么
1.1 从题目表面到本质需求
看到“61. 旋转链表”这个题号,很多人第一反应是“又一个链表移动题”。但说实话,这道题在LeetCode上的难度标注虽然是中等,实际区分度却很高。会的人三分钟写完,不会的人改了半天还在死循环里打转。原因在于,它表面考的是“旋转”,实际上考的是三件事:链表遍历与计数、取模运算的边界处理、以及指针断开的时机把控。
题目本身不复杂:给定一个链表的头节点 head,将链表每个节点向右移动 k 个位置。比如 1->2->3->4->5,k=2,得到 4->5->1->2->3。这种操作在真实业务里其实很常见,最典型的就是轮换调度场景——一组任务节点按顺序执行,每轮从不同的起点开始,本质上就是对任务链表做旋转。还有日志分片、缓存队列的滚动淘汰,底层抽象出来都是这个模型。所以这道题不是纯粹的应试玩具,它在工程里的映射非常直接。
我觉得学习这道题的正确姿势,不是死背题解,而是理清楚它背后的几个关键决策点:第一,旋转的本质是什么;第二,k 非常大会发生什么;第三,怎么用尽量少的指针操作完成这件事。把这三点想明白了,代码就是水到渠成的事。
1.2 面试官最想看到的全局观
这道题在面试中出现频率很高,通常不是单独出现,而是作为链表专题的第一题或第二题。面试官通过它考察的维度很明确:
- 基本素养:能不能准确计算出链表长度,能不能快速判断 k 是否需要取模。
- 指针操控能力:链表题的核心就是若干指针的移动和断链、接链。旋转题需要恰到好处地在正确的位置切开,多了少了都会出问题。
- 代码健壮性:空链表、单节点链表、k=0、k 是长度的整数倍,这些边界你是否都能处理利索,在面试官眼里比主流程代码更重要。
- 复杂度意识:能不能说出 O(n) 时间、O(1) 空间,并且解释清楚为什么不能更优。
换句话说,这道题是一块很好的“试金石”。如果你的旋转链表代码能一次性通过,并且能清晰讲出每一步指针操作的动机,那你的链表功底基本过关了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 闭环法:先转成一个圈,再切开
2.1 模型直觉:项链和断口
我第一次解这道题的时候,用的是比较常规的“找新头节点然后拆链”思路。后来看到题解区有人用“闭环法”,当时就觉得这个思路太妙了——它把“旋转”这件事还原成它本来的样子:一个环在转,你只需要选择在哪里剪开。
你可以这么想象:一条项链,上面串着几颗珠子,对应链表的节点。所谓“向右旋转 k 步”,听起来像是在项链上移动珠子,操作起来很别扭。但如果先把项链的搭扣扣上,变成一个完整的环,那么旋转就变成了“在这个环上另找一个点,把环重新剪开”——剪开的位置决定了新的头节点是谁,新的尾节点是谁。
这就是闭环法最核心的直觉:旋转不改变节点之间的相对顺序,只改变头尾的位置。 与其去想“怎么把后面的节点搬到前面来”,不如先把尾部和头部接起来,让整个链表变成一个环,然后在指定位置断开。断开的那条边,就是旋转之后新尾节点和新头节点之间的边。
这个思路能省掉很多不必要的指针操作,代码也短得多。更重要的是,它好理解、好记忆,不容易出错。
2.2 闭环法的操作步骤拆解
具体来说,闭环法只需要四步:
- 遍历链表,计算长度 n,同时让尾节点指向头节点,形成环。
- 遍历时用一个指针 cur,从 head 开始,走到最后一个节点(
cur.next == None)为止。 - 此时 cur 就是尾节点,让
cur.next = head,链表变成环。
- 遍历时用一个指针 cur,从 head 开始,走到最后一个节点(
- 对 k 取模:
k = k % n。- 因为旋转一整圈等于没转,所以 k 只需要对 n 取余。
- 找到新的尾节点位置。
- 向右旋转 k 位,等价于把最后 k 个节点移到最前面。那么新链表的尾节点,就是原链表的第 n-k 个节点。
- 此时再用一个指针从 head 出发,走 n-k-1 步,或者复用 cur(它现在在尾节点),继续走 n-k 步,就能到达新尾节点。
- 断开环,返回新头节点。
- 记
new_head = cur.next,然后设置cur.next = None,断开环。
- 记
这里有一个容易搞混的点:到底走 n-k 步还是 n-k-1 步?我建议用一种不容易出错的方式——复用已经停在尾节点的 cur,然后让它走 n-k 步。因为初始时 cur 指向原链表的尾节点(编号为 n),走一步到第 1 个节点,走两步到第 2 个节点……所以走 n-k 步就停在编号为 n-k 的节点上,这正是新的尾节点。
如果你觉得这个推导容易晕,另一个办法是:先用一个变量指向 head,走 n-k-1 步。比如 n=5、k=2,n-k-1=2,从 head 走 2 步会到达编号为 3 的节点,也就是新链表的尾节点。两种方式殊途同归,选一种你习惯的就行。
2.3 闭环法的代码实现
下面是我的 Python 实现,注释放在关键位置:
python复制class Solution:
def rotateRight(self, head: Optional[ListNode], k: int) -> Optional[ListNode]:
# 空链表、单节点链表、以及 k=0 都没有旋转的必要
if not head or not head.next or k == 0:
return head
# 第一步:计算长度,并让尾节点指向头节点,形成环
n = 1
cur = head
while cur.next:
cur = cur.next
n += 1
# 此时 cur 是尾节点
cur.next = head
# 第二步:取模
k = k % n
if k == 0:
# 取模之后是 0,说明旋转整圈回到了原位
# 先把环断开再返回头节点
cur.next = None
return head
# 第三步:找到新尾节点,走 n-k 步
for _ in range(n - k):
cur = cur.next
# 第四步:断开环
new_head = cur.next
cur.next = None
return new_head
C++ 版本也一并给出,方便面试时手写参考:
cpp复制class Solution {
public:
ListNode* rotateRight(ListNode* head, int k) {
if (!head || !head->next || k == 0) return head;
int n = 1;
ListNode* cur = head;
while (cur->next) {
cur = cur->next;
n++;
}
cur->next = head; // 形成环
k %= n;
if (k == 0) {
cur->next = nullptr;
return head;
}
for (int i = 0; i < n - k; ++i) {
cur = cur->next;
}
ListNode* newHead = cur->next;
cur->next = nullptr;
return newHead;
}
};
实测下来,这个写法能保证时间复杂度 O(n),空间复杂度 O(1)。n-k 步的循环内做的只是移动指针,没有任何节点复制或新建,效率很高。
2.4 取模这一步为什么省不得
很多人第一次写会漏掉 k %= n 这行代码,因为题目示例里 k 都是小于 n 的。但真实评测里 k 可以大到 10^9,这时候如果你直接走 n-k 步,步数依然在 n 的范围内,所以单纯看闭环法好像不取模也行……不对,等一下,n-k 这个值本身可能已经超过 n 了,比如 n=5、k=10^9,n-k 是负的?对,这里就出问题了。
Python 里的 range(n - k) 如果传入负数,循环次数为 0,结果直接返回 head 的下一个节点,逻辑就全错了。C++ 里 for 循环条件 i < n - k,如果 n-k 是负数,循环直接不执行,同样会得到错误的 new_head。
所以取模这一步不是“锦上添花”,而是保证 n-k 落在 [0, n-1] 范围内的前提。同时要注意:取模之后如果 k=0,说明旋转 n 的整数倍,等于原链表,此时需要把刚才形成的环断开再返回 head,否则返回的是一个环形链表,后续遍历会造成死循环。
这里有一个细节值得强调:取模为 0 的情况是边界条件里的常客,但很多人只判断了 k==0 的情况,忽略了 k 是 n 的整数倍时取模后也会变成 0。 所以严谨的写法是先取模再判断,顺序不能反。
3. 快慢指针法:不闭环也能完成旋转
3.1 双指针的定位原理
如果面试官要求你“不要修改链表结构,连临时的环都不能成”,那闭环法可能就不合适。这时候可以换用快慢指针法。它的思路是:向右旋转 k 位,等价于把倒数第 k 个节点变成新的头节点。那么问题就转化为——找到倒数第 k+1 个节点(即新尾节点),然后断开它和下一个节点的链接,同时把原尾节点接到原头节点上。
快慢指针的经典用法是:先让 fast 指针走 k 步,然后 slow 和 fast 一起走,当 fast 走到尾节点时,slow 正好停在倒数第 k+1 个节点。
你可以把 fast 想象成“探测兵”,它提前探出了 k 步,然后 slow 才出发。两者保持恒定距离,当 fast 到达终点时,slow 离终点当然也是 k 步。这个逻辑在数组、链表、字符串中都很常见,比如 LeetCode 19(删除链表的倒数第 N 个节点)就是同一个套路。
3.2 快慢指针的具体实现
python复制class Solution:
def rotateRight(self, head: Optional[ListNode], k: int) -> Optional[ListNode]:
if not head or not head.next or k == 0:
return head
# 先求长度,以便对 k 取模
n = 0
cur = head
while cur:
cur = cur.next
n += 1
k %= n
if k == 0:
return head
# 快指针先走 k 步
fast = head
slow = head
for _ in range(k):
fast = fast.next
# 两个指针一起走,直到 fast 到达尾节点
while fast.next:
fast = fast.next
slow = slow.next
# 此时 slow 是新的尾节点,slow.next 是新的头节点
new_head = slow.next
slow.next = None
fast.next = head # 原尾节点接到原头节点
return new_head
这里的指针操作比闭环法多了一些,但只要理清含义就不容易出错。注意最后一步 fast.next = head 不可少,因为断开了 slow 和 slow.next 之后,如果忘了把原尾节点接到原头节点,链表就断成两截了。我当时第一次写就漏了这一步,结果输出了两个链表片段,调试了一会儿才发现。
3.3 两种主流解法的取舍
| 维度 | 闭环法 | 快慢指针法 |
|---|---|---|
| 核心思想 | 成环再断开 | 双指针保持固定距离 |
| 时间/空间 | O(n)、O(1) | O(n)、O(1) |
| 代码量 | 较短 | 略长 |
| 对 k 的取模 | 需要,且取模后为 0 时需断开环 | 需要,取模后为 0 时可直接返回 |
| 面试友好度 | 思路巧妙,容易让面试官眼前一亮 | 双指针模型更通用,可迁移性强 |
我的建议是两种都掌握。闭环法适合快速解题,快慢指针法适合在面试中展示更强的指针控制能力。而且快慢指针这个模型在后续很多题目里都会反复用到,比如“寻找链表倒数第 K 个节点”“判断链表是否有环”“寻找环的入口”,练熟之后是一鱼多吃的。
4. 边界条件与踩坑实测
4.1 k 很大或 k 是链表长度的整数倍
先说最容易踩的坑:k 没有取模。
我在 LeetCode 上提交、以及在牛客网刷题时,都见过不少类似的错误代码。比如 k=2000000000,链表长度只有 3,如果不取模就进入快慢指针的 for 循环,fast 要走 20 亿步,直接超时。更离谱的是在闭环法中,n-k 算出来是负数,Python 的 range 不会报错但会直接跳过,代码逻辑就完全错了。
正确的处理顺序是:
python复制k %= n
if k == 0:
return head
先取模,后判断。千万不要先判断 k 是否为 0 再取模,因为即使 k 初始不为 0,取模后也可能变成 0(比如 n=3,k=6)。
4.2 空链表和单节点链表的“一刀切”处理
空链表、单节点链表,以及 k=0,这三种情况其实都可以在入口统一拦截:
python复制if not head or not head.next or k == 0:
return head
为什么把 not head.next 也放进条件里?因为单节点链表旋转任意次都是它自己,而且如果只判断 not head,单节点链表进入求长度循环时,n=1,随后 cur.next = head 会把唯一的节点接回自己,虽然也能工作,但属于不必要的操作,而且稍有不慎就可能写出死循环。与其在后面绕,不如入口直接短路返回。
4.3 C/C++ 里最容易出问题的断链顺序
在 C++ 或 C 这类手动管理内存的语言里,断链顺序非常有讲究。一个常见 bug 是:先修改了 fast 的 next,再去取 slow.next,结果拿到的已经是被修改后的节点。
举一个错误示例:
cpp复制// 错误写法:顺序反了
fast->next = head; // 先把尾节点接到头节点
ListNode* newHead = slow->next; // 此时 slow->next 还是原值,这个还好
slow->next = nullptr; // 但这里把 newHead 的前驱断掉了
这段代码看起来能跑,但问题是 fast->next = head 这行执行后,整个链表的角度变了。如果 slow 恰好就在 fast 前面,那问题不大;但如果不小心把 new_head 算错,整个链表就乱了。更稳妥的顺序是:
cpp复制ListNode* newHead = slow->next;
slow->next = nullptr;
fast->next = head;
也就是先保存 new_head,再断开 slow 的 next,最后才把原尾节点接回原头节点。顺序的核心原则是:先把需要保存的指针保存下来,再修改 next 指向。
C++ 版本还有一个隐患:如果你用裸指针操作,不要在旋转过程中 new 任何节点,否则还要负责 delete,容易造成内存泄漏。旋转链表这道题应该是纯指针操作,不涉及节点创建和销毁,这本身也是一个考点——很多面试官会追问“你的代码会不会导致内存泄漏”,回答“全程未新建节点,只修改了指针指向”就是标准答案。
4.4 我自己实际调试时的一次失误
说一个我自己的真实调试经历。有一次我写闭环法,思路完全正确,但提交后一直报错。我看了半天,发现问题是出在 k 取模之后为 0 的情况。当时我直接 return head,但忘了断开 cur.next = head 形成的环。结果返回的链表带有环,测试代码遍历到某个节点后永远走不到 None,直接超时判负。
所以这里一定要记住:闭环法一旦形成了环,就必须在返回前断开。 取模后 k==0 返回前要断开,找到新头节点后也要断开。这是闭环法的“两个断点”,一个都不能漏。
5. 旋转链表的扩展与变体思路
5.1 向左旋转怎么办
有些题目会把“向右旋转”改成“向左旋转 k 位”。其实这个改动非常简单,因为向左旋转 k 位,等价于向右旋转 n-k 位(k 先取模)。但这里有个小坑:如果 k 取模后为 0,n-k 就等于 n,再取模一次才是 0。所以比较稳妥的写法是:
python复制k %= n
k = (n - k) % n # 转换为向右旋转
或者更直接地,在快慢指针法中,先让快指针走 n - k 步。逻辑是一样的,只是方向反过来。理解了“旋转方向本质上只是断点位置的偏移”这个道理,左右旋转都不会再困惑你。
5.2 旋转链表与约瑟夫环问题的亲缘关系
说到环形链表,就不得不提约瑟夫环问题。约瑟夫环的经典描述是:n 个人围成一圈,从第一个人开始报数,报到 m 的人出圈,然后从下一个人重新报数,直到剩最后一个人。这个问题的核心就是循环链表上的删除操作,每次从当前位置走 m-1 步,然后删除节点。
你看,这和旋转链表的“走 n-k 步找到新尾节点”异曲同工。区别在于旋转链表只断开一次,约瑟夫环要断 n-1 次。如果把旋转链表的闭环法练熟了,约瑟夫环的链表实现也就掌握了 80%。这也是为什么我强烈建议把链表成环、走步数、断开这几个操作练到肌肉记忆,它们在后续高级题目中会反复出现。
5.3 旋转思想在数组和字符串中的应用
双向链表、循环数组也会用到类似的“取模 + 偏移”思想。比如数组轮转问题(LeetCode 189),原地旋转数组的经典三段反转法,空间复杂度和这里的思路是共通的。还有字符串循环移位、KMP 算法中部分匹配表的构建,都避不开取模和环形结构的心智模型。
所以,别把 61 题当成一道孤立的链表题。它更像是一个“旋转模型”的代表作,把这个模型吃透,你在数组、字符串、循环队列相关的问题上都会有更深的直觉。
5.4 进阶变体:对链表区间做旋转
还有一种常见的面试追问:给定一个链表和区间 [left, right],只旋转这个区间内的节点,区间外的节点保持在原位置。这个问题把“旋转”和“区间操作”结合起来,难度立刻上一个台阶。解法思路是:
- 找到 left 的前驱节点 pre 和 right 节点。
- 把 [left, right] 部分截断成一个子链表。
- 对子链表执行标准的旋转操作。
- 再拼接回原链表。
这种题考察的就是模块化能力——你能否把一个大问题拆成几个已知的小问题。只要你旋转整条链表的方法足够熟练,区间旋转只是加了两步“截断”和“拼接”而已。
6. 总结优化方向:还有什么可以聊
如果你已经掌握了上面的所有内容,这道题基本就吃透了。最后再分享几个值得继续深入的优化方向:
第一个方向是递归与非递归。有些链表题用递归写会非常简洁,比如反转链表。但旋转链表我一般不推荐递归,因为它的递归深度可能等于链表长度 n,在长链表上有栈溢出风险。非递归的迭代版本已经足够优雅,没必要为了炫技引入递归。
第二个方向是双向链表。如果题目换成双向链表做旋转,你需要在断链和接链时同时维护 prev 和 next 两个方向的指针,复杂度会高一些。面试官可能会以此考察你对指针的理解是否够深入。建议自己手写一遍双向链表版本的旋转,会很有收获。
第三个方向是批量资源回收。在真实系统里,如果这个链表代表的是缓存对象、数据库连接等资源,旋转之后要特别注意是否有节点被“悬空”(既不在新链表中,也没有被释放)。这类资源管理问题在分布式缓存、连接池轮换中非常常见,比算法本身更值得留个心眼。
我个人在实际项目里写这类轮换逻辑时,最常踩的坑反而不是指针,而是并发。如果多个线程同时操作同一个链表做轮换,不加锁的话,一个线程正在断链,另一个线程已经遍历到被断的节点,直接空指针异常。所以工程里做这种轮换操作,要么加锁,要么用不可变对象+版本号标记。
希望这篇文章能帮你把旋转链表彻底吃透。代码量不大,但里面浓缩的取模思维、指针操作、边界处理,都是链表题最核心的基本功。练熟这一道,你会发现自己对链表的感觉完全不一样了。
