我见过不少基础不错的同学,链表反转写得飞快,一看到“旋转链表”就卡住了。原因很简单:反转题目的操作对象是“相邻节点之间的箭头”,而旋转题目的操作对象是“整条链的起止位置”,两者思维模式不一样。尤其当k值很大、链表又很短的时候,很多人会一头扎进“模拟移动”的坑里,最后要么超时,要么指针越界。旋转链表这道题其实并不难,难的是“换一种视角看图”——只要把链表看成一个首尾相连的环,问题立刻变得清爽。
这篇文章会用大量图解逐层拆解旋转链表,从题目拆解、闭环断链、双指针定位到边界情况的处理,全部梳理清楚。无论你是准备面试的求职者,还是想在数据结构上补课的学生,跟着图解走一遍,应该就能把这道题彻底吃透。
1. 从一道链表题说起:右移k位的本质与“环形错觉”
1.1 题目拆解:向右移动k位到底发生了什么
先明确题目。给定一个单链表,比如1 -> 2 -> 3 -> 4 -> 5,要求将每个节点向右移动 k 个位置。这里有个容易混淆的点:“向右移动”到底是节点往右走,还是节点往左走?
以 k=1 为例,原链表为1 -> 2 -> 3 -> 4 -> 5,向右移动一位后,结果应该是5 -> 1 -> 2 -> 3 -> 4。也就是说,原来的最后一个节点跑到了最前面,其余节点依次后移。这里的“右移”可以理解为:把链表的尾巴切下来,接到头部前面。k=2 时,结果是4 -> 5 -> 1 -> 2 -> 3,相当于连续执行两次“切尾接头”。
原链表: 1 -> 2 -> 3 -> 4 -> 5 -> null 右移1位: 5 -> 1 -> 2 -> 3 -> 4 -> null 右移2位: 4 -> 5 -> 1 -> 2 -> 3 -> null理解了这个动作,很多人第一个直觉就是:写一个循环,每次弹出尾节点再插入头部,执行k次。这个思路没有错,但它有一个致命问题——假设 k 是一亿,而链表长度只有5,按这个方案要执行一亿次“找尾、断尾、接尾”的操作,单次时间复杂度是 O(n),整体就是 O(n*k),数据量稍大就直接超时。
所以,第一步要建立的重要认知是:旋转链表的核心不只是“模拟动作”,而是“寻找断点”。右移k位,本质上是把链表从某个位置“切断”,然后把前后两段交换顺序。
1.2 链表旋转与数组旋转的本质差异
数组旋转可以用“先整体反转,再局部反转”的三段式操作完成,也可以开辟新数组直接搬运。链表能不能用类似思路?可以,但要注意一个关键差异:数组支持按下标随机访问,而链表只能从头开始遍历。数组旋转时,我们可以轻松知道arr[i]应该搬到arr[(i+k) % n]去;链表如果想找第 i 个节点,只能老老实实走 i 步。
这个差异直接导致了两种不同的解法路径:
- 如果你选择“分段平移”,那么必须先把链表转换成数组,再旋转数组,最后重建链表。这样多了一次 O(n) 的空间开销。
- 如果你选择“原地断链”,那就不需要额外空间,但你必须预先知道两个关键信息:链表的总长度 n,以及真正有效的移动步数 k % n。
在实际开发中,链表的内存通常不连续,修改指针比搬动数据更廉价。因此“原地断链”是更符合链表数据结构的解法。这个选择的背后逻辑是:链表天生的优势就是“改引用”,而不是“动数据”。
1.3 为什么用“环形”视角而不是“多次弹出”
来做一个对比实验。假设链表长度 n=5,k=2。模拟法要连续做两次“取尾插头”;闭环法只需要做两件事:把尾部接到头部形成环,然后在新环中找到正确的“断点”断开。
从操作次数上看,闭环法只需要遍历两次链表(一次求长度,一次找断点),而模拟法每做一次“取尾插头”都要从头遍历到尾。差距在 k 接近 n 时会体现得尤其明显:如果 k=n,模拟法白做 n 次,而闭环法直接判断出“旋转 n 次等于没旋转”,可以立即返回。
更深层的原因是:多次右移在本质上等价于“在一个环上走 k 步”。例如长度为5的链表,右移7位的结果,等同于右移2位;右移12位的结果,也等同于右移2位。因为每移动 n 位,链表就恢复原状。一旦意识到这一点,取模操作k = k % n就变得顺理成章了。
这也解释了一个常见的疑惑:“为什么我看别人解题时,第一步是先统计链表长度?”因为只有知道了 n,才能把 k 化到 [0, n-1] 的范围内,才能避免后续做无意义的循环。很多人卡在这道题,其实不是卡在指针操作,而是卡在“没有意识到旋转的周期性”。
2. 常规解法一:闭环断链,图解每一步
2.1 两个关键前处理:链表长度与取模
闭环断链法适合作为首选解法,因为它思路统一、代码简洁。先做两个前处理:
第一,统计链表长度 n。这一步需要从头遍历到尾,时间复杂度 O(n)。为什么一定要做?因为后续所有定位都依赖 n。如果不知道 n,就无法确定新链表的头节点应该在哪。
第二,执行k = k % n。这一步极其重要。假设 n=5,k=7,那么右移7位和右移2位完全等价;如果不取模,你会在寻找断点时得到一个负索引或者超出长度的索引,导致指针越界。另外如果k % n == 0,说明旋转后链表又变回原样,可以直接返回原链表头节点,省掉后面所有操作。
完成这两步后,实际需要移动的步数已经被限制在0 <= k < n的范围内,接下来的指针操作就简单了。
2.2 环形变换的图解剖解
我下面再画一个完整图解,用链表1 -> 2 -> 3 -> 4 -> 5,n=5,k=2 作为例子。
第一步,先找到尾节点,也就是值为5的节点。把尾节点的 next 指向头节点,形成一个环:
原链表: head -> 1 -> 2 -> 3 -> 4 -> 5 -> null 成环后: head -> 1 -> 2 -> 3 -> 4 -> 5 ──┐ ^ │ └────────────────────────┘严格来说,此时没有“头节点”和“尾节点”了,每个节点都在环上。我们手里保留的 head 指针依然指向节点1,但它不再是真正意义上的“头”。
第二步,确定新链表的尾节点。右移 k 位后,新的尾节点应该是从原头节点往后数n - k - 1个节点。为什么是 n-k-1?因为右移 k 位后,新的头节点是原链表的第n-k个节点(从1开始计数),而新尾节点就是这个新头节点的前一个节点,也就是原链表的第n-k-1个节点(从1开始计数)。
继续看例子:n=5,k=2,那么 n-k-1 = 2,即从节点1开始走2步,到达节点3。节点3就是旋转后链表的尾节点。
环中定位: head -> 1 -> 2 -> 3 -> 4 -> 5 ──┐ ↑ │ 新尾节点 │ └───────────────┘第三步,让新尾节点(节点3)的 next 指向 null,打破环。同时返回节点4作为新的头节点。
断开后: 3 -> null 4 -> 5 -> 1 -> 2 -> 3 -> null整理一下得到:
4 -> 5 -> 1 -> 2 -> 3 -> null这和题目要求完全一致。整个过程中,我们既没有移动节点数据,也没有反复弹出尾节点,只改了两处指针:一处是尾节点的 next 指向 head,另一处是新尾节点的 next 指向 null。
2.3 指针定位到 n-k-1 的两种视角
关于定位新尾节点,有些同学会疑惑:为什么不是“从 head 走 k 步”,而是“从 head 走 n-k-1 步”?这里我用两种视角来解释。
视角一:顺序切分。右移 k 位等价于把原链表的后 k 个节点搬到最前面。也就是说,新的头节点是原链表从后往前数第 k 个节点,也就是从头往后数第 n-k+1 个节点(从1开始计数)。它的前一个节点,就是从头往后数第 n-k 个节点(从1开始计数)。如果你是使用0基索引来遍历,比如 cur 从 head 开始,走一步到节点2,走两步到节点3,那么走到新尾节点恰好需要n - k - 1步。0基索引与1基计数之间的差异,是很多人写错边界条件的根源。
视角二:补位视角。想象一个长度为 n 的环,head 指向节点1。旋转 k 位后,新的 head 相当于在原环上逆时针移动了 k 个位置(或者说顺时针移动了 n-k 个位置)。我们把 head 指针从节点1出发,沿着链表走 n-k-1 步,就能停在新的尾节点上。这和数组旋转中“后半段补到前半段”的思路是一致的。
为了验证这个公式,再看一个极端例子。n=5,k=4,右移4位的结果是2 -> 3 -> 4 -> 5 -> 1。代入 n-k-1 = 0,也就是从 head 走0步,停在实际的 head 节点。这就意味着新尾节点就是原来的头节点1,所以断开后返回2,完全正确。
2.4 当 k 不是 2 而是 7:多个整周期移动的图解推演
很多人面试时会遇到一个变形:k 很大怎么办?例如1 -> 2 -> 3 -> 4 -> 5,k=12。
此时先取模:k = 12 % 5 = 2。也就是说,右移12位和右移2位的结果一样。为什么?因为右移5位会回到原链表,右移10位同样回到原链表,多出来的2位才是“真正有效的移动量”。
图解上可以这样理解:在闭环链表上,从头节点出发,沿环走12步,最后停下的位置和走2步停下的位置相同。因为环的周长是5,走5步回到起点,走10步也回到起点,12步就是“两圈+2步”。这就像钟表上的时针:从12点方向拨动14个小时,最终停在2点方向,因为12小时是一个完整周期。
所以闭环断链的完整流程是:
- 遍历链表,求出长度 n,同时记录尾节点。
- 计算
k = k % n。 - 如果
k == 0,直接返回头节点。 - 将尾节点 next 指向头节点,形成环。
- 从头节点出发,移动
n - k - 1步,定位到新尾节点。 - 记录新头节点
newHead = 新尾节点.next。 - 将新尾节点 next 置为 null。
- 返回 newHead。
这一套流程在实际代码里大概十几行就能写完,时间复杂度 O(n),空间复杂度 O(1)。
3. 常规解法二:双指针寻找新头,避免闭环
3.1 双指针设计的数学原理
闭环断链法要遍历链表两次,一次求长度,一次定位断点。双指针法则可以把求长度和定位断点合并到一次遍历中吗?严格来说,双指针法仍然需要先知道 n,因为它必须取模k = k % n。不过它的巧妙之处在于:不需要“先走到末尾形成环,再从 head 走 n-k-1 步”,而是用两个指针相隔 k 步,同时前进,当快指针到达末尾时,慢指针恰好停在新尾节点上。
这个设计的数学原理很简单。如果快指针 fast 提前出发 k 步,然后 fast 和 slow 以同样的速度前进。当 fast 到达链表末尾的空指针(null)时,slow 距离末尾也是 k 步。也就是说,slow 停在第n-k个节点(从1开始计数)上。右移 k 位后,这个节点恰好就是新头节点。
再精确一点:我们想要的新尾节点是第 n-k 个节点(从1开始计数)?这里要格外小心,不同文章中“第几个”的说法经常不一致。我用0基索引重新表述:
- 链表节点从0开始编号,头节点编号0,尾节点编号 n-1。
- 右移 k 位后,新头节点编号为 n-k(在取模处理后,k 在 [0, n-1] 范围内)。
- 新尾节点编号为 n-k-1。
双指针法中,fast 先走 k 步,此时 fast 指向编号 k 的节点。然后 fast 和 slow 同步走。当 fast 走到 null 时,它一共走了n-k步,slow 也走了n-k步。因为 slow 起始编号是0,所以 slow 最终指向编号0 + (n-k) = n-k的节点。等等,这就指向了新头节点,而不是新尾节点?
这里需要澄清一个常见混淆。双指针法如果要求 slow 停在新尾节点,那么 fast 不能走到 null,而是应该走到“原尾节点”,即编号 n-1 的节点。当 fast 从编号 k 的节点出发,走到编号 n-1 的节点,需要n-1-k步。此时 slow 走到0 + (n-1-k) = n-k-1,正是新尾节点。所以正确说法是:慢指针停在新尾节点,快指针停止在最后一个节点(原尾节点)。
在代码里,通常表达为:
let fast = head; let slow = head; for (let i = 0; i < k; i++) fast = fast.next; while (fast.next !== null) { fast = fast.next; slow = slow.next; }循环结束后,slow 指向新尾节点,fast 指向原尾节点。接下来只需要newHead = slow.next,再把slow.next = null,同时让fast.next = head,就完成了旋转。
3.2 图解双指针的移动过程
用1 -> 2 -> 3 -> 4 -> 5,n=5,k=2 演示。
初始状态,slow 和 fast 都指向节点1:
slow ↓ 1 -> 2 -> 3 -> 4 -> 5 -> null ↑ fast第一步,fast 先走 k=2 步,到达节点3:
slow ↓ 1 -> 2 -> 3 -> 4 -> 5 -> null ↑ fast此时 slow 和 fast 之间刚好隔着 k 个节点。第二步,两者同步前进。当 fast 到达节点5(原尾节点)时,条件是fast.next !== null为假,循环结束:
slow ↓ 1 -> 2 -> 3 -> 4 -> 5 -> null ↑ fast可以看到 slow 停在了节点4。对照闭环法,n-k-1 = 5-2-1 = 2,从节点1走2步也是节点3?等一下,这里我可能把示例写串了。让我重新推演。
如果 fast 先走2步到达节点3。然后 while (fast.next !== null) 循环:
- 当前 fast 指向3,fast.next 指向4,不为null。
- 第一次循环:fast 指向4,slow 指向2。
- fast.next 指向5,不为null。
- 第二次循环:fast 指向5,slow 指向3。
- fast.next 为null,循环结束。
所以最终 slow 指向节点3,不是节点4。对,slow 指向的是新尾节点3。新头节点是 slow.next = 节点4。这与闭环断链法得到的断点完全一致。上面我写“slow 指向4”是错的,修正一下。实际图解如下:
第一次同步移动后: slow ↓ 1 -> 2 -> 3 -> 4 -> 5 -> null ↑ fast 第二次同步移动后: slow ↓ 1 -> 2 -> 3 -> 4 -> 5 -> null ↑ fast循环结束后,恢复链表:
fast.next = head; // 5 -> 1 head = slow.next; // newHead = 4 slow.next = null; // 3 -> null最后得到:
4 -> 5 -> 1 -> 2 -> 3 -> null ↑ newHead整个过程没有先形成环再断环,而是在双指针就能确定断点位置的前提下,直接把后半段接到头部前面。这种解法的好处是:不需要修改尾节点的 next 形成环,因此在某些“不允许成环”的更严格约束下,双指针法可能更直观。
3.3 为什么这个解法不需要预先知道 n(其实还是需要)
有人会误以为双指针法不需要统计链表长度。这里必须纠正:双指针法在取模这一步仍然需要 n。如果 k 大于等于 n,fast 先走 k 步会直接越界,指向 null 甚至更远。所以在双指针法开始前,依然要遍历一次链表求出 n,并执行k = k % n。
那为什么还要采用双指针法?因为它在“定位断点”时,省去了“走 n-k-1 步”的计算,而是用“快指针先出发 k 步、随后两者同步移动”的方式,把断点位置“走”出来。如果你的代码里已经实现了通用的“移动 step 步”函数,那么闭环断链法更直接;如果你想减少取模后的指针计数,双指针法在思维上更接近“滑动窗口”。
两种方法的时间复杂度都是 O(n),空间复杂度都是 O(1)。区别只在于代码风格和思维模型。我的建议是:两种都要掌握,遇到题目时先讲你最有把握的那一种,再用另一种说明“换一种视角也可以”。
4. 最容易出错的三类边界:n=1、k=0、k是n的倍数
4.1 空链表与单节点链表
空链表是最容易处理的边界:直接返回 null。单节点链表也有一个很友好的性质:无论 k 是多少,旋转后还是只有那一个节点,直接返回原头节点即可。
为什么单节点链表可以不取模?因为 n=1 时,k % 1 = 0,旋转任何次都等于没旋转。你会在代码里发现,if (!head || !head.next)这个提前返回条件,可以把空链表和单节点链表一起处理掉。很多同学把它写成两个独立判断,其实没有必要。
不过要警惕一个陷阱:如果链表只有一个节点,求长度时尾节点就是头节点本身。如果你在形成环时执行tail.next = head,等于让这个节点的 next 指向自己。这本身没问题,但如果你忘了在最终返回前断开环,就会导致链表变成一个自环,后续遍历时会无限循环。所以在“单节点提前返回”时,必须确保后续逻辑不会走成环分支。
4.2 k为0与k为n的倍数
当k == 0或者k % n == 0时,旋转结果是原链表本身。这是最容易写出 bug 的分支,因为很多人会漏掉“先取模”,导致后续用n - k - 1计算出负数。
举个例子,n=5,k=5。取模后 k=0。如果忽略取模,直接计算新尾节点位置:n - k - 1 = 5 - 5 - 1 = -1。从 head 移动 -1 步,代码通常表现为“不进入循环”,然后head = slow.next会取到错误节点。更严重的是,如果你写成 for 循环,i 从 0 开始到n-k-1结束,当条件不满足时循环体根本不执行,slow 停留在 head,slow.next指向第二个节点,你得到的新头节点就是错的。
所以取模和if (k === 0) return head不是可选项,而是必修项。这个分支在面试中往往是速死项:思路全对,但漏了这一行,结果就是 case 挂掉一半。
4.3 k为负数时怎么处理
标准题目中通常规定 k 为非负整数,但在实际工作中,你可能会遇到“左移”的说法。左移 k 位等价于右移 n-k 位吗?可以这样等价,但前提是 k 也在 [0, n-1] 范围内。如果遇到负数右移,可以统一做一个取模换算:k = ((k % n) + n) % n。
这种写法在很多工程代码中可以见到。比如在循环队列、环形缓冲区中,下标计算都要保证结果非负。为了防止面试官追问“如果 k 是负数”,你可以先说明“题目默认非负,但工程上的鲁棒写法是……”然后补上这句取模公式。这是一个小加分项,同时也能显示你真正理解取模的语义。
4.4 其他容易被忽视的细节
还有两个细节经常被忽略。
第一个是“取模后是否需要再次判断 k == 0”。如果你在取模后没有判断,而是直接执行闭环逻辑,结果在 k=0 时依然会成环又断环。时间复杂度不变,但逻辑上多做了一次成环操作。更危险的是,如果你使用的是双指针解法,fast 先走0步,fast 仍在 head,while (fast.next !== null)会一直走到尾,slow 最终指向倒数第二个节点。这就会把链表旋转成完全错误的顺序。因此无论哪种解法,k === 0的提前返回都建议保留。
第二个是你需要明确“求长度时遍历到的最后一个节点是谁”。在闭环断链法中,这个节点被称为 tail。求长度和记录尾节点可以一次遍历完成,不要写两个独立的循环。一次遍历的写法是:
let length = 1; let tail = head; while (tail.next !== null) { length++; tail = tail.next; }这里 length 初始值必须是1,而不是0,因为 head 本身已经算一个节点。很多初写者把 length 初始化为0,导致 n 少算1,后面所有断点位置全部偏移。
5. 复杂度对比、代码实现与排错复盘
5.1 两种解法复杂度对比表
我直接给出一张对比表,方便你复习和面试时快速组织语言:
| 维度 | 闭环断链法 | 双指针法 |
|---|---|---|
| 统计长度 | 需要 | 需要 |
| 取模处理 | 必须 | 必须 |
| 成环操作 | 是 | 否 |
| 断点定位 | 走 n-k-1 步 | 快指针先走 k 步 |
| 时间复杂度 | O(n) | O(n) |
| 空间复杂度 | O(1) | O(1) |
| 实现代码量 | 较短 | 略长 |
| 出错点 | 取模后 k 为0 | fast 越界 |
从表里能看出,两种解法本质上没有优劣之分,选哪种更多是个人代码习惯问题。如果只打算记一种,我推荐闭环断链法,因为它的逻辑可以用“先成环,再断环”一句话概括,步骤分明,不容易写乱。
5.2 可运行示例代码:闭环断链法与双指针法
下面给出一段 JavaScript 实现的闭环断链法,注释写得很细,可以直接照着梳理思路:
function rotateRight(head, k) { if (!head || !head.next || k === 0) return head; // 1. 求长度,同时记录尾节点 let length = 1; let tail = head; while (tail.next !== null) { length++; tail = tail.next; } // 2. 取模:移动 n 次等于没移动 k = k % length; if (k === 0) return head; // 3. 成环 tail.next = head; // 4. 找到新尾节点:从原头走 length - k - 1 步 let newTail = head; for (let i = 0; i < length - k - 1; i++) { newTail = newTail.next; } // 5. 断环 let newHead = newTail.next; newTail.next = null; return newHead; }再看双指针法的实现:
function rotateRight(head, k) { if (!head || !head.next || k === 0) return head; let length = 1; let tail = head; while (tail.next !== null) { length++; tail = tail.next; } k = k % length; if (k === 0) return head; let fast = head; let slow = head; // fast 先走 k 步 for (let i = 0; i < k; i++) { fast = fast.next; } // 同步走,直到 fast 到达原尾节点 while (fast.next !== null) { fast = fast.next; slow = slow.next; } // 此时 slow 是新尾节点,fast 是原尾节点 fast.next = head; head = slow.next; slow.next = null; return head; }两份代码都通过了相同的测试样例。我建议你先照着注释手写一遍,再把两份代码对照着看:闭环法通过“数步数”定位断点,双指针法通过“快慢指针的相对距离”定位断点,殊途同归。
5.3 我在实际调试中遇到过的坑
再分享几个我第一次写这道题时踩过的坑。
第一个坑是 length 初始化。当时我把 length 初始化为0,遍历完1 -> 2 -> 3 -> 4 -> 5却得到4。所有断点位置全部前移一位,输出变成了3 -> 4 -> 5 -> 1 -> 2。排查了很久才发现是长度计数少1。这个错误极其隐蔽,因为单看代码很难察觉,必须对照输出样例才能发现偏移。
第二个坑是忘记把 k 之后的新链表继续接回原链表。在闭环断链法中,我一开始只做了“断开新尾节点”,却忘了“尾节点 next 指向 head”。结果链表变成了两段,第一段是4 -> 5,第二段是1 -> 2 -> 3,新头节点返回4,输出完全错误。原因是我在求长度时没有保存 tail,导致后面无法恢复连接。后来我改成一次遍历同时记录 tail,才算解决。
第三个坑是我曾经尝试用“模拟法”去实现,然后被一个超长 k 的测试用例直接打脸。那个用例的 k 值达到了十万级,链表长度却只有个位数,模拟法跑了非常久。从那次以后我彻底记住:旋转链表必须先取模,任何不取模的解法都是不完善的。
我还想提醒一点:如果你在纸上画图,不要把箭头画得太乱。有时节点一多,环上又看不到起点,极易把自己绕晕。我的习惯是:每走一步就在图上标出当前指针的位置,用不同颜色区分 head、tail、newTail 的三个角色。画到后面会发现,只要“成环”和“断环”两步做对,中间过程再乱最终结果也是对的。
最后分享一个用来加深理解的小技巧:把 k 从 0 到 n 依次代入,观察新头节点在环上的移动轨迹。你会发现新头节点一直在绕圈,这正好对应取模的直观含义。多做几组这样的推演,以后看到任何旋转链表变体题,比如“左移”“按奇偶节点分组旋转”,你都能快速发现它其实还是那道题,只是换了个包装而已。