看到“61. 旋转链表”这道题,第一反应很多人会懵:链表怎么旋转?其实翻译成人话,就是给你一条单链表和一个非负整数 k,让你把整条链表整体向右移动 k 个位置,后 k 个节点搬到头部来。这道题在 LeetCode 上是中等难度,但它考的东西非常实在:链表遍历、尾节点处理、取模思维,还有最容易被忽略的边界条件。准备面试的人,或者刚开始啃数据结构想加深链表理解的人,都值得把这道题完整拆一遍。
我最早刷这题的时候,第一版代码提交直接超时,原因就是没处理 k 特别大的情况,循环走了几百万次。后来把取模补上,又遇到断链指针写错,debug 了半小时。今天把这题的完整思路、两种主流写法、所有我能想到的坑一次性讲清楚,看完你不仅能 AC,还能在面试时把背后的设计逻辑讲明白。
1. 题目拆解与核心思路
1.1 题目到底在问什么
先看题目描述:给定一个链表的头节点 head,将链表每个节点向右移动 k 个位置。注意这里的“移动”不是把节点值交换,而是把链表结构整体平移。
举个例子:
code复制输入:1 -> 2 -> 3 -> 4 -> 5,k = 2
输出:4 -> 5 -> 1 -> 2 -> 3
你从视觉上可以理解为:从右侧看,最后的两个节点 4、5 被整体搬到了最前面,原来的 1、2、3 依次往后排。再比如:
code复制输入:0 -> 1 -> 2,k = 4
输出:2 -> 0 -> 1
这个例子很有意思,k 比链表长度还大。如果真的一次一次移动节点,需要移动 4 次,但直觉告诉我们:移动 3 次不就回到原样了吗?没错,这就是取模的雏形。
题目要求我们返回旋转后的链表头节点。链表不能像数组那样按下标直接访问,也不能通过切片把某段截出来拼到前面,所以核心操作必然是改变节点之间的 next 指针指向。
1.2 为什么不能直接“旋转”
很多人一开始会想:我能不能像数组一样,找到第 n - k 个位置,把它后面的子链表整个切下来,接到原链表头部?
思路方向是对的,但链表做不到“整个切下来”这种原子操作。链表的每个节点只知道自己后面的节点是谁,不知道前面的节点是谁,更不知道整条链表有多长。所以你必须先遍历一遍拿到链表长度,再找到合适的断点,修改两个节点的 next 指针,才能实现旋转。
数组和链表的本质差异就在这里:数组是一段连续内存,旋转可以通过整体搬迁或者三次翻转完成;链表是离散节点,靠指针串联,任何结构变化都要落地成“某几个节点的 next 指向谁”。理解了这一点,后面看算法就会很顺。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 关键前置知识:链表基本功
2.1 链表遍历与指针移动
旋转链表这种题,本质上就是链表的遍历 + 指针操作。单链表的一个节点通常长这样:
cpp复制struct ListNode {
int val;
ListNode *next;
ListNode() : val(0), next(nullptr) {}
ListNode(int x) : val(x), next(nullptr) {}
ListNode(int x, ListNode *next) : val(x), next(next) {}
};
遍历链表的标准姿势是从头节点开始,用一个 cur 指针不断向后移,直到 cur == nullptr 结束:
python复制cur = head
while cur:
print(cur.val)
cur = cur.next
你要注意,这里的 cur 只是一个指向节点的引用。让 cur = cur.next 不会影响链表本身,它只是让指针挪到下一个节点。但如果你写的是 cur.next = something,那就在修改链表的结构了。很多链表题的 bug 都出在“想移动指针,结果改了结构”或者反过来。
2.2 几种常见链表操作回顾
既然热词里频繁出现“单链表逆序”“链表插入”,我顺带说一嘴。链表的核心操作无非几种:
- 遍历:从头到尾走一遍,获取长度或者找节点。
- 插入:在指定位置修改前驱节点的
next指向新节点,新节点指向后继。 - 删除:让前驱节点直接跨过待删节点,指向后继。
- 逆序:把一个方向的指针全部反向,通常需要三个指针
pre/cur/nxt配合。
旋转链表其实混合了“遍历求长度”“尾部连头”“断开某个指针”这三件事,所以你如果前面那些基础操作都不太熟,建议先把单链表的基本操作练一遍再来做这题,否则很容易被指针绕晕。
2.3 C++ 结构体链表语法要点
如果是 C++ 做题,结构体链表有几个语法细节容易忘:
- 节点通过
new ListNode(x)创建,返回的是指针。 - 访问成员用
->,比如head->val、head->next。 - 判断是否为空用
head == nullptr,不要写成head == NULL虽然也能编译,但风格上建议统一用nullptr。
很多刚开始刷题的人不理解为什么链表节点里要放一个 next 指针。你可以把链表想象成一条寻宝线索,每张纸条上只写着下一张纸条的位置,你想看第 10 张纸条,就必须从第 1 张开始依次往后找。这就是链表“不支持随机访问”的直观体现。
3. 算法设计:从数学规律到代码实现
3.1 等价关系:k 次移动其实是取模
这题最重要的数学规律是:如果链表长度是 n,那么向右移动 n 次之后,链表会变回原样。这就像钟表转一圈回到 12 点,不需要真的转那么多圈。
所以真正需要移动的只是 k % n 次。举例来说,链表长度 5,k = 7,相当于右移 2 次;k = 5,相当于右移 0 次,直接返回原链表。这个取模处理不仅能让代码通过 k 很大的用例,还能避免极其无谓的循环操作。
但是要注意,取模只解决了“移动次数”,没有解决“怎么移动”。我们还需要确定:新链表的头是原链表中的哪个节点?
结论是:如果要求向右移动 k 次,那么新链表的头就是原链表的倒数第 k 个节点(在 k 已经取模的基础上)。比如 1->2->3->4->5,k=2,倒数第 2 个节点是 4,新头就是 4。这个结论你可以自己拿几个例子验证。
3.2 方案一:快慢指针找新头
知道新头是倒数第 k 个节点之后,一个自然的思路就是先用快慢指针找到这个节点。
具体做法:先让快指针从 head 出发,先走 k 步;然后慢指针和快指针一起每次走一步,当快指针走到链表末尾的时候,慢指针刚好停在倒数第 k 个节点的前一个节点。此时 slow->next 就是新头,slow 就是新尾。
以 1->2->3->4->5 为例,k=2:快指针先走两步到达节点 2,然后慢指针从头开始,两个指针一起走。快指针从 2 到 5 需要走 3 步,慢指针也从 1 走 3 步到达节点 4?慢指针走三步到 4,但我们要的是倒数第 2 个节点 4 作为新头,新尾应该是 3,也就是说慢指针应该停在 3。细想一下,快指针先走 k 步后,两个指针同时移动,当快指针到达最后一个节点(不是 NULL)时,慢指针停在倒数第 k+1 个节点,它的 next 才是倒数第 k 个节点。所以上面的描述需要修正:快指针先走 k 步,然后快慢同时走,直到 fast 指向最后一个节点,此时 slow 指向新尾节点,slow->next 指向新头节点。
不过这个写法有几个边界容易搞混,比如 k 为 0 或者 k 刚好是链表长度的情况。相比之下,我更推荐下面这种“成环再断开”的写法,思路更直观,也不容易数错步数。
3.3 方案二:首尾连成环再断开(推荐)
这个方法一共三步:
- 遍历链表,求出长度
n,同时把尾节点记录下来。 - 让尾节点的
next指向头节点,把链表变成一个环。 - 走
n - k % n步到达新的尾节点,记录下来,然后断开环,返回新头。
为什么是 n - k % n 步?因为当前指针在尾节点,成环之后,尾节点的 next 就是原头节点。我们要找的新尾节点,在环中从原尾节点出发,向右走 n - k 步即可到达。
用 1->2->3->4->5,k=2 来演算:
- 原尾节点是 5,成环后链表变成 5->1->2->3->4->5->1...
- 从 5 出发走 1 步到 1,走第 2 步到 2,走第 3 步到 3。
- 3 就是新尾节点,4 就是新头节点。
- 断开 3->4,返回 4。
结果就是 4->5->1->2->3,完全正确。
这个方法的妙处在于:你不需要单独处理“找到倒数第 k 个节点”的快慢指针逻辑,只需要顺着指针走固定步数,逻辑非常单一。而且取模后的 k 为 0 时,走 n 步刚好回到原尾节点,再绕一圈返回原头,本质就是原链表,所以在取模后可以提前判断 k == 0 直接返回。
3.4 完整代码实现
Python 版本:
python复制def rotateRight(head, k):
if not head or not head.next or k == 0:
return head
# 1. 求长度,并让 cur 停在尾节点
n = 1
cur = head
while cur.next:
cur = cur.next
n += 1
# 2. 计算实际需要移动的次数
k %= n
if k == 0:
return head
# 3. 成环
cur.next = head
# 4. 找到新尾节点,走 n - k 步
steps = n - k
while steps > 0:
cur = cur.next
steps -= 1
new_head = cur.next
cur.next = None
return new_head
C++ 版本:
cpp复制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++;
}
k %= n;
if (k == 0) return head;
cur->next = head; // 成环
int steps = n - k;
while (steps--) {
cur = cur->next;
}
ListNode* newHead = cur->next;
cur->next = nullptr; // 断开环
return newHead;
}
两个版本逻辑完全一致。注意 while (cur->next) 而不是 while (cur),因为我们最终要让 cur 停在尾节点上,方便成环。如果写成 while (cur),循环结束后 cur 是空指针,就找不到尾节点了。
时间复杂度是 O(n),因为只遍历了链表一遍,加上找新尾节点的一次循环,加起来仍然是 O(n)。空间复杂度 O(1),只用了几个指针变量。
4. 边界情况与常见问题排查
4.1 空链表、单节点、k=0
这三类情况是最容易直接写错或者漏掉的。
- 空链表:
head == nullptr,没有节点可以旋转,直接返回nullptr。 - 单节点链表:只有 1 个节点,无论 k 是多少,旋转后还是它自己,直接返回
head。 k == 0:没有移动,直接返回head。
所以代码开头统一写成:
python复制if not head or not head.next or k == 0:
return head
这一步不是可有可无的。如果没有处理空链表,后面访问 head->next 就会变成空指针访问,直接崩溃。面试时漏掉这个判断,哪怕算法对了,也会给面试官留下不严谨的印象。
4.2 k 很大和 k 是 n 的倍数
k 很大是这题设计的核心陷阱。如果链表长度是 3,k 是 4,你实际只需要移动 1 次,因为 4 % 3 = 1。如果不取模,硬循环 4 次,时间复杂度变成 O(k*n),k 一旦到达 10^9,程序直接超时。
取模之后,还要注意 k 刚好是 n 的倍数的情况。比如链表长度 3,k=6,6 % 3 = 0,相当于不需要移动,返回原链表。代码里取模后紧接着判断 if k == 0。这里的判断和开头检查 k 是否为 0 不是重复的,开头是拦截输入,这里是计算后拦截取模结果为 0 的情况。
我见过一个常见错误:只判断了开头的 k == 0,没有判断取模后的结果,导致链表被成环之后走 n - k 步时,k 还是原值,步数算错,最后返回的节点不对。所以一定要在 k %= n 之后重新判断一次。
4.3 断链时指针的经典错误
成环之后,断链这步也有很多坑。
第一个错误是忘记断开 cur.next。如果不断开,链表会一直带环,输出的链表变成循环链表,某些平台的判题系统会卡死。正确的操作是先保存新头节点,然后把新尾节点的 next 置为空。
第二个错误是保存新头的时机不对。有人喜欢先 cur.next = NULL,再去找新头,结果发现找不到了。正确顺序一定是:
python复制new_head = cur.next
cur.next = None
第三,步数数错。有些版本从 head 出发找新尾节点,需要走 n - k - 1 步,而我们从原尾节点出发,走 n - k 步。两种写法都对,但容易混。我建议固定用“从尾节点出发”的写法,因为代码里 cur 正好停留在尾节点,成环后自然往后走,逻辑连贯,不容易少算一步。
注意:如果你把成环后的链表想象成钟表,从尾节点出发找新尾节点,就是在表盘上顺时针数格子,数到指定的位置就停下来,绝对不能数漏。
5. 实操心得与面试扩展
5.1 我在刷题和面试中踩过的坑
我第一次自己写这题,用的是“把链表转成数组,旋转后再转回链表”的懒办法,虽然能过,但空间复杂度 O(n),完全丢了链表题的意义。后来被面试官追问“能不能不用额外空间”,才老老实实学指针操作。建议你别走这个弯路,直接学指针解法。
真正让我记忆深刻的一个坑是:我用快慢指针方法时,把“先走 k 步”和“先走 k%n 步”搞混。k 很大时,如果没取模,快指针可能直接走过头,走到空指针上,代码报错。所以无论用哪种方法,第一步都必须是求链表长度并取模,这一点逃不掉。
还有一次,在断链之前我先打印了链表,想确认环的样子,结果调试工具直接卡死,因为链表已经成环,无限循环了。这里给个小经验:调试这种会成环的链表时,不要写成 while cur: 遍历打印,要用一个计数器限制打印次数,比如只打印 20 个节点,防止死循环。
5.2 这题背后的抽象思维
旋转链表看起来只是一个算法题,但它折射出的思维方式很有用:当一个问题需要重复操作很多次时,先想想操作是否存在周期性。链表右移 n 次回到原点,这是一个天然的循环周期,取模就是利用周期把问题规模降下来。
这种思维在真实项目中很常见。比如任务队列的轮询调度,假设有 n 个任务,每次从第 k 个位置开始执行,其实就是在处理“按长度取模后的偏移量”。再比如环形缓冲区,读写指针的移动也涉及取模。所以不要觉得这题只是刷题用,它是在训练你对“循环结构”和“指针位置”的敏感度。
另外,旋转链表还隐含了一个重要的解题技巧:如果题目要求对链表做某种“整体平移”操作,优先考虑把它变成环形结构,操作完再断开。这个技巧在处理很多环形链表问题时都很有用。
5.3 扩展:如果要求更多限制怎么办
面试官一般会在这题基础上做几个变形,你可以在脑子里先过一遍:
-
如果要求只允许遍历一次链表,还能找到新头吗?快慢指针方法本质上遍历了两次(一次求长度,一次找节点),但这是允许的。如果严格要求一次遍历,我觉得做不到,因为链表长度未知,你无法确定
k是否需要取模,求长度这一步没法省。可以这样回答面试官:单链表在不知道长度的情况下,无法避免一次完整遍历。 -
如果是双向链表,旋转会更容易吗?会,双向链表可以方便地向前移动,但断开和连接时要多处理
prev指针,容易忘。思路还是一样,先取模,再找新尾节点。 -
如果 k 不是右移而是左移呢?本质一样,左移 k 等价于右移 n-k,步数调整一下就行。
-
如果链表有环呢?那就不叫旋转链表了,需要先判断环,再用快慢指针处理,是另一个独立的题目。
这些扩展不要求马上实现,但面试时能说出思路,会加分很多。
最后分享一个小技巧:这类链表题,无论题目怎么变,你都在纸上把节点画出来,然后用箭头模拟指针移动。我遇到再复杂的链表题,都会先画一遍,再写代码,debug 时间能减少一半。旋转链表说白了就是“数清楚步数,找准断点”,画一次 1->2->3->4->5 的旋转过程,你就能彻底记住了。
