旋转链表这个题目,我在面试候选人的时候经常拿出来当热身题。它看似简单,但能把“指针操作”“边界处理”“数学化简”这几个基本功一次性全考到。很多刷题刷了大几百道的人,照样会在k取模这种小地方翻车。这篇文章就把这道题彻底拆开讲透,从暴力解到最优解,从代码实现到面试追问,一次说清楚。
1. 先别急着写代码,把题意吃透
1.1 这道题到底在考什么
LeetCode 61题“旋转链表”,题目描述是给一个链表的头节点head和一个整数k,把链表每个节点向右移动k个位置。说白了就是:把链表尾部的若干个节点搬到头部来,顺序保持不变。
举个例子,链表1->2->3->4->5,k=2,结果就是4->5->1->2->3。尾部两个节点(4、5)被平移到了头部,剩下的节点依次往后顺延。这个过程如果用数组来做,一行代码就能搞定切片拼接;但换成链表,就得靠指针操作一步一步来,这就是这道题的核心矛盾:数据结构变了,解法就得从头设计。
很多人第一次看到这个题,第一反应是“这不就是循环右移吗”,然后就开始写循环,每次都把尾节点摘下来挂到头上去。这种思路没错,但它只答对了原理,没答对效率。后面第2节我会详细算这笔账。
1.2 旋转的本质是找断点
数组旋转和链表旋转有一个本质区别:数组支持随机访问,你可以用下标直接算出每个元素的新位置;链表只能从头节点开始,一个节点一个节点地next下去。所以链表旋转没办法做到“每个节点直接跳到目标位置”,它只能通过调整指针指向来实现。
但这里有一个关键的观察:旋转k位,本质上是把链表从某个位置断开,然后把前后两段交换顺序。 比如上面那个例子,链表1->2->3->4->5,k=2,其实就是从3和4之间断开,变成1->2->3和4->5两段,再把它们拼接成4->5->1->2->3。这个断点的位置跟k直接相关,找到断点,就解决了整个问题。
这就是这类题目的通用解题范式:把“移动节点”的思维转换成“找断点、换顺序”的思维。这个思维转换一旦完成,代码就能从繁琐的多次循环中解放出来,变成一趟线性扫描。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从暴力解到最优解的演化路径
2.1 暴力做法为什么不靠谱
最直观的暴力思路很简单:执行k次“右移一位”。每次右移一位的操作是,先找到尾节点tail和尾节点的前一个节点prev,把tail摘下来,让prev指向nullptr,再让tail指向原来的头节点,更新头节点引用。伪代码如下:
code复制for i in 1..k:
prev = 找到倒数第二个节点
tail = prev.next
prev.next = null
tail.next = head
head = tail
这个写法在k比较小的时候看着挺好用的,比如k=2,循环两次就结束了。但你仔细分析一下时间复杂度:每次找倒数第二个节点需要遍历整个链表,是O(n)的耗时;一共执行k次,整体就是O(n*k)。如果链表长度是10万,k是10万,那就是10亿次操作,在LeetCode上直接超时,在面试现场也绝对过不了关。
还有一个很隐蔽的问题:如果k非常大,比如k=10^9,这个循环直接就跑死了。所以暴力解有两个死穴,一是效率低,二是完全没有处理大k的情况。任何一个有经验的面试官看到你写这个解法,都会立刻追问“k特别大怎么办”,如果你答不上来,这道题基本就凉了一半。
2.2 取模运算:把k压缩到有效范围
暴力解的问题在于它没有意识到:旋转这个操作是有周期性的。 一个长度为n的链表,向右旋转n次之后,所有的节点位置全部复位,又变回原来的链表。所以旋转k次和旋转k % n次,结果完全一样。
这个道理特别像钟表上的时针:现在指向3点,往后走14个小时,和往后走2个小时的结果是一样的。因为12个小时是一个完整周期,14 % 12 = 2,所以只往后拨2个小时就够了。链表也一样,n个节点是一个周期,k % n才是真正需要移动的步数。
把k取模之后,k就被压缩到了[0, n-1]的范围内。这时候再来看问题就清爽多了:
- k = 0时,说明不需要做任何操作,直接返回原链表。
- 1 <= k < n时,就是正常的旋转,需要移动k个节点到头部。
取模这一步的代价是O(1),但带来的收益是巨大的:不管k是多大,实际需要处理的步数永远小于链表长度n。这为下面的最优解打下了基础。
2.3 最优解的核心思想
取模之后,我们知道真正需要把末尾k个节点搬到头部。这时候断点的位置就变得非常明确:第n-k个节点是新的尾节点,第n-k+1个节点是新的头节点。原链表的尾节点需要指向原链表的头节点,新尾节点的next需要指向nullptr。
整个算法就三条主线:
- 扫描一遍链表,求出长度n,同时记录尾节点tail。
- 计算有效步数step = k % n,如果step == 0,直接返回头节点。
- 找到第n-step个节点作为newTail,它的下一个节点newHead就是新的头节点。让tail->next = head(闭合为环),再让newTail->next = nullptr(断开环),返回newHead。
这三步做完,时间复杂度是O(n),空间复杂度是O(1)。这个解法才是面试官真正想看到的。下面第3节我会给出完整的代码实现,并逐一解释每个细节。
3. 三种主流实现,总有一种适合你
3.1 方法一:先闭合为环再断链
这个方法是个人认为思路最清晰、最好写对的一种。既然旋转本质上就是把链表尾部切下来接到头部,那不如先把链表首尾相连变成一个环,然后在合适的位置断开。断开的位置就是新的头节点应该出现的地方。
完整代码如下,C++版本:
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) {}
};
class Solution {
public:
ListNode* rotateRight(ListNode* head, int k) {
if (head == nullptr || head->next == nullptr || k == 0) {
return head;
}
// 1. 求链表长度,同时找到尾节点
int length = 1;
ListNode* tail = head;
while (tail->next != nullptr) {
tail = tail->next;
length++;
}
// 2. 取模,得到实际需要移动的步数
int step = k % length;
if (step == 0) {
return head;
}
// 3. 闭合为环
tail->next = head;
// 4. 找到新的尾节点:正数第 length - step 个节点
ListNode* newTail = head;
for (int i = 1; i < length - step; i++) {
newTail = newTail->next;
}
// 5. 新的头节点就是新尾节点的下一个节点
ListNode* newHead = newTail->next;
// 6. 断开环,恢复链表的线形结构
newTail->next = nullptr;
return newHead;
}
};
有几个细节我要重点标注一下。
第一步求长度的时候,我初始值设成1,然后从head开始依次往后遍历,条件是tail->next != nullptr。这样循环结束后,tail就是真正的尾节点,length的值也恰好是链表长度。很多人习惯先设length=0再用do-while,也没有问题,但要注意别把边界算错。
第二步取模是必须的,而且要在闭合为环之前做。因为如果k是length的整数倍,step等于0,链表旋转一圈回到原样,这时候直接返回head就行,省得后面白折腾。
第四步找新尾节点时,循环条件要仔细推敲。链表长度是length,新尾节点是正数第length-step个节点。因为我已经从head开始,head就是第1个节点,所以循环从i=1开始,循环length-step-1次,就能停在目标节点上。这个细节写错的人特别多,要么多走一步,要么少走一步。
闭合为环这个技巧,本质上是用空间换思维的清晰度。它不额外占用内存,只是多改了一次指针,但整个解题逻辑变得非常直观:成一个环,转一下,断开。面试时用这个思路讲解,对方很容易跟上你的节奏。
3.2 方法二:找断点后原地调整指针
如果说闭合为环是“先连再断”,那这个方法就是“找到断点直接改”。它不先形成环,而是先定位到新的尾节点和头节点,然后一次性把指针全部调整到位。
Python实现如下:
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
# 1. 求链表长度,同时记录尾节点
length = 1
tail = head
while tail.next:
tail = tail.next
length += 1
# 2. 取模,计算真实步数
k %= length
if k == 0:
return head
# 3. 找到新尾节点(正数第 length - k 个节点)
new_tail = head
for _ in range(length - k - 1):
new_tail = new_tail.next
new_head = new_tail.next
# 4. 断开:新尾节点变为真正的尾部
new_tail.next = None
# 5. 原尾节点指向原头节点,形成旋转后的链表
tail.next = head
return new_head
跟方法一相比,这个方法的区别在于调整指针的顺序。它先断开new_tail和new_head之间的连接,再把原尾节点指向原头节点。这里有一个隐藏的坑需要特别注意:
如果你先把tail.next = head,再把new_tail.next = None,没有问题;但如果你先执行new_tail.next = None,再执行tail.next = head,也没问题。两种顺序都成立。真正的坑在于,如果先执行tail.next = head,再去遍历查找new_tail,那么链表变成了环,遍历会死循环。
所以核心原则是:在修改指针之前,先把需要用的节点引用保存好。 这个方法里,new_tail和new_head都是提前算好的,tail也是提前记好的,之后怎么改指针都不会丢。
从代码风格上说,方法二比方法一少了一次“闭合为环”的操作,效率理论上略高一点点。不过实际上差别可以忽略不计,因为都是一趟O(n)遍历。选哪种写法,更多是个人风格偏好。我个人的建议是:面试时优先用方法一,因为“成环再断”这个表述更容易让对方理解;如果是自己刷题巩固,两种都写一遍更好。
3.3 方法三:快慢指针的双指针玩法
除了上面两种,还有一个比较巧妙的变种:用快慢指针来定位断点。思路是,先让快指针从头节点出发,向前走k步;然后快慢指针一起同步前进,直到快指针到达尾节点。这时候慢指针恰好停在“倒数第k+1个节点”的位置,也就是新的尾节点。
直接看代码:
cpp复制class Solution {
public:
ListNode* rotateRight(ListNode* head, int k) {
if (head == nullptr || head->next == nullptr || k == 0) {
return head;
}
// 第一趟:求长度
int length = 0;
ListNode* cur = head;
while (cur) {
cur = cur->next;
length++;
}
// 取模,避免k过大
k %= length;
if (k == 0) {
return head;
}
// 快指针先走 k 步
ListNode* fast = head;
while (k--) {
fast = fast->next;
}
// 慢指针从头开始,两个一起走
ListNode* slow = head;
while (fast->next != nullptr) {
slow = slow->next;
fast = fast->next;
}
// 此时 slow 是新的尾节点,slow->next 是新的头节点
ListNode* newHead = slow->next;
slow->next = nullptr;
fast->next = head;
return newHead;
}
};
这个方法的精妙之处在于,它不需要提前记住尾节点,也不需要额外求“第n-k个节点在哪”。快慢指针配合一次遍历,就能精确停在目标位置。原理其实很好理解:快指针先走k步,那么快指针和慢指针之间就拉开了k个节点的距离。然后它们以相同的速度同步移动,当快指针到达链表的最后一个节点时,慢指针距离末尾正好还有k个节点,也就是倒数第k+1个节点,这正是新的尾节点。
快慢指针这个技巧在链表题里出现频率非常高,比如“环形链表”里判断环入口、“链表中倒数第k个节点”等,都是同一套思路。如果你把这道题做透了,顺带就把快慢指针这个家族的方法也一起练了。
4. 边界条件与易错点深度排查
4.1 空链表和单节点链表:最容易踩的坑
我知道很多人的习惯是“先把主逻辑写完,最后回头补边界”。但链表题恰恰是边界最多的地方,漏掉一个,代码一跑就崩。这道题的边界主要有两类:
- 空链表(head == nullptr):这时候没有任何节点,不做任何操作,直接返回nullptr。如果不判断,代码执行到head->next时直接空指针解引用,程序崩溃。
- 单节点链表(head->next == nullptr):链表只有一个节点,旋转任意次,结果都还是这一个节点。所以直接返回head即可。
这两个条件我习惯写在一个if里,配合k == 0一起提前返回:
cpp复制if (head == nullptr || head->next == nullptr || k == 0) {
return head;
}
三个条件合并处理,简洁又高效。这里面k == 0的判断也很关键,它对应的是“不需要旋转”的语义。虽然取模运算也会把k变成0,但提前判断能省一趟链表遍历的耗时,属于锦上添花的优化。
4.2 k的取值:取模前和取模后是两个世界
k这道题的陷阱大多数出在k的取值范围上。LeetCode给出的k可能是一个很大的整数,最坏情况下比链表长度大好几个数量级。如果不取模直接进入主逻辑,找断点位置的循环就会白白跑k步,直接超时。
取模运算发生在求得链表长度之后、任何指针移动之前:
cpp复制int step = k % length;
if (step == 0) {
return head;
}
这一步做完之后,所有后续操作都只需要在[1, length-1]的范围内进行。这时候哪怕原始k是10^9,实际需要处理的步数也不会超过链表长度。
更隐蔽的一个细节是:k是负数的情况。 虽然LeetCode的输入约束里k是非负整数,但面试官有时候会故意追问“如果k是负数,表示向左旋转,怎么办”。这时候你需要回答:把k取模后加上length再取模,也就是k = ((k % length) + length) % length,就可以把负数统一到正数范围。这个知识不一定用得上,但能答出来绝对是加分项。
4.3 断链顺序与指针保存:一次调半天的血泪教训
指针操作的顺序在链表题里是重灾区。我见过太多人,代码逻辑看着没问题,但一跑就出现“链表成环”或者“空指针异常”,最后查了半天发现是断链顺序写反了。
拿方法二来说,核心的指针操作是三句:
python复制new_tail.next = None
tail.next = head
return new_head
这三句的顺序其实只有第二句和第三句不能对调(要先搭桥再返回),第一句和第二句谁先谁后都行。但有一种错误的写法是:
python复制tail.next = head
# 然后才去找 new_tail
new_tail = ...
一旦你先tail.next = head,链表就成了一个环。如果在这之后再去遍历找new_tail,程序就会陷入死循环,永远找不到出口。这是所有链表题目里最容易犯的致命错误。
好的习惯是:在动手修改任何指针之前,先把所有需要保留的节点引用存在局部变量里。 头节点、尾节点、新头节点、新尾节点,这几个关键角色全都保存好,再按顺序执行修改。永远不要靠“修改之后的链表结构”去反推某个节点的位置。
另一个容易出错的地方是:求链表长度时使用了head指针遍历,遍历完head已经指向了尾节点,后面再用head就会出错。所以求长度时一定要用一个临时变量cur = head来遍历,head本身不能动。这是一条铁律。
5. 面试官在考什么,以及怎么应对追问
5.1 复杂度的标准答案
这道题的标准答案复杂度分析是:
- 时间复杂度:O(n)。需要遍历链表求长度,然后在找断点的过程中最多再走O(n)步,总共是O(n)。
- 空间复杂度:O(1)。只用了几个指针变量,没有额外的数组或哈希表。
这里有一个值得跟面试官强调的点:无论k多大,时间复杂度都保持在O(n)。这就是取模运算带来的核心收益。如果面试官问“能不能同时做到一次遍历就完成任务”,你可以回答:求长度这一步是没办法省的,因为取模必须知道n,所以在不知道n的情况下没办法一次遍历完成。不过如果你允许先用一个数组存下所有节点,那确实可以做到一次遍历,但空间复杂度会变成O(n),这就失去了链表题的意义。
5.2 变种题目:向左旋转、k个一组翻转
旋转链表这个知识点在面试里很少单独出现,它经常作为基础,变换出各种变体。我总结了三个最常考的变体,大家可以当作后续练习方向:
- 向左旋转:把链表头部的k个节点搬到尾部。实现思路跟向右旋转几乎一样,只是断点的位置变了。向右旋转时新头节点是原链表的第n-k+1个节点,向左旋转时新头节点是原链表的第k+1个节点。
- 反转链表II:给你一个链表,以及两个区间端点left和right,只反转这个区间内的节点。这道题的核心也是找断点、处理边界,和旋转链表是同一套基本功。
- k个一组翻转链表:每k个节点为一组进行翻转,最后不足k个保持原样。这是链表题里的“综合大魔王”,需要用到递归、反转子链表、拼接等多个技巧,建议在旋转链表完全掌握之后再挑战。
把旋转链表做透,就等于把链表这种数据结构的核心操作全部过了一遍:遍历、求长度、找断点、改指针、处理环路。后面再做任何链表题,你都会觉得游刃有余。
5.3 题目之外的工程价值:链表在真实世界的应用
我知道很多人刷题刷久了会有一个疑问:这玩意儿除了面试,到底在真实项目里有什么用?这里简单说几个真实场景,帮大家建立“这道题没有白刷”的信念。
- LRU缓存淘汰:经典实现是“哈希表+双向链表”,链表用来维护数据的访问顺序。每次访问一个数据,就把它移动到链表头部;链表尾部就是最久未使用的数据,可以直接淘汰。这本质上就是链表的“移动节点”操作,跟旋转链表是一个思路。
- 编辑器撤销栈:很多编辑器的撤销记录用链表实现,每次撤销相当于把最近的操作节点移动到历史记录的前端。
- 芯片设计里的链表应用:热词里提到了“链表 芯片设计”——在集成电路的设计流程中,网表(netlist)里的器件、引脚、连线经常用链表来管理。尤其是在物理设计阶段,布线资源和逻辑单元的连接关系需要频繁地插入、删除和调整位置,链表的O(1)插入删除特性在这里价值巨大。可以说,链表不只是算法题里的玩具,它就是很多工业级软件的地基。
6. 从这道题延伸出去:链表基本功自查清单
旋转链表这道题做完,我建议你对照下面这个清单自查一遍,看链表的基本功是否真的扎实了:
- 能否用C++和Python各写出“创建链表、遍历链表、插入节点、删除节点”的完整代码?
- 能否在不借助额外空间的情况下,实现单链表的逆序(迭代法和递归法各写一遍)?
- 能否判断一个链表是否有环,并找出环的入口?
- 能否找到链表的中间节点?
- 能否找到链表的倒数第k个节点?
这五个问题如果都能不假思索地回答出来,旋转链表对你来说就只是个小练习。如果哪个问题卡壳了,说明链表的基础还有漏洞,建议回过头去把单链表的基本操作系统过一遍。
热词里有人提到“python单链表逆序”和“c++结构体链表基本语法”,如果你还在这个阶段,说明对链表的熟悉程度还不够。逆序是链表题的常青树,建议先掌握迭代逆序的写法:用prev、cur、next三个指针,每次把cur的next指向prev,三个指针整体向后移动。这个操作跟旋转链表里的指针调整是一脉相承的,练熟了再回来看旋转链表,会轻松很多。
python复制def reverse_list(head):
prev = None
cur = head
while cur:
next_node = cur.next
cur.next = prev
prev = cur
cur = next_node
return prev
这个十五行不到的代码,是链表操作中最基础也最重要的一段。逆序、反转、找断点,本质上都是在玩指针的重新指向。等你能在一张草稿纸上把指针变化过程画出来,就算真正入门了。
至于“顺序表和链表”的对比,旋转链表这道题也提供了一个很好的切入点。数组做旋转可以直接用切片,因为数据在内存里是连续存储的,随机访问是O(1);链表做旋转必须一个节点一个节点地走,因为数据在内存里是分散的,只能通过next指针串联。这个差异解释了为什么“同样的逻辑,换一种数据结构就要重新设计解法”。理解了这个底层原因,你就不会在面试时说“数组怎么做,链表也照样做”这种外行话了。
回到题目本身,我最后再分享一个实操技巧:写链表代码之前,先花30秒在纸上画出链表的结构,分别标出head、tail、newHead、newTail的位置。 不要觉得画图耽误时间,恰恰相反,90%的链表题错误都源于“脑子里没图”。把四个节点的位置画清楚,再动手写代码,基本一遍就能通过。这个方法我用了很多年,带过的新人也都觉得管用,强烈推荐。
