1. 题目先看一眼,考点其实很集中
1.1 轮转数组的题目长什么样
先看原题表述:给定一个整数数组 nums,将数组中的元素向右轮转 k 个位置,其中 k 是非负数。示例很经典:
text复制输入: nums = [1,2,3,4,5,6,7], k = 3
输出: [5,6,7,1,2,3,4]
也就是说,每个元素向右移动 k 步,尾部元素跑到头部来。如果你对“轮转”这个词还不太敏感,可以理解成把数组看成一根环形带子,整条带子往右拉了三格,超出去的部分从左边补回来。
这题在 LeetCode 上是第 189 题,标注难度是中等,但说实话它考察的知识点并不复杂,核心就是“数组能不能原地操作”“能不能设计出 O(1) 额外空间的算法”。很多人在面试里一上来就开一个临时数组,把元素搬过去,代码也能跑通,但面试官追问一句“如果数组很大、内存有限,你还能优化吗”,一下子就露怯了。所以这题的关键不是“做出来”,而是“做得省”。
1.2 返回倒数第k个节点的题目长什么样
这一题来自剑指 Offer 22(以及面试题 02.02),题目原文是:输入一个链表,输出该链表中倒数第 k 个节点。示例:
text复制给定一个链表: 1 -> 2 -> 3 -> 4 -> 5,k = 2
返回倒数第 2 个节点: 4
注意这里坑很多,比如“节点编号从 1 开始”“k 保证是合法的吗”“如果链表为空怎么办”。不同平台对边界的定义不太一样,LeetCode 上的版本通常保证 k 合法,但面试时要主动和面试官确认边界,这是加分的点,而不是扣分的点。
两道题一数组一链表,表面上没什么关系,但它们的核心方法论其实是同一套:对基础数据结构进行高效遍历和原地改造。把这俩放在一个系列里讲,是因为它们能帮你同时拿下“数组的索引运算”和“链表的指针操作”这两块基本功,后面学滑动窗口、双指针、循环队列都会受益。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 轮转数组:从“暴力”到“原地”的三层递进
2.1 第一层:复制一份新数组,最直观但空间复杂度不达标
很多人第一反应是:开一个新数组,把每个元素放到它应该在的位置上。公式很简单,nums 里的元素 nums[i] 应该到新数组的下标 (i + k) % n 去,n 是数组长度。代码写出来大概是这样:
python复制def rotate(nums, k):
n = len(nums)
new_arr = [0] * n
for i in range(n):
new_arr[(i + k) % n] = nums[i]
for i in range(n):
nums[i] = new_arr[i]
时间复杂度是 O(n),这个没问题。空间复杂度 O(n),问题就出在这。如果数组长度是 100 万,你就得额外开 100 万长度的空间,这在某些嵌入式场景、大数组场景下是不可接受的。
而且面试官一定会问:能不能不申请额外数组,原地完成?如果不能原地完成,为什么不行?这就逼着你往第二层想。
2.2 第二层:三次翻转法,面试首选的标准答案
三次翻转的思路特别巧妙,我第一次看到的时候是有点惊艳的。核心就一句话:
向右轮转 k 位,等价于先翻转整个数组,再翻转前 k 个元素,再翻转后 n-k 个元素。
拿 [1,2,3,4,5,6,7],k=3 举例:
- 翻转整个数组:
[7,6,5,4,3,2,1] - 翻转前 3 个元素:
[5,6,7,4,3,2,1] - 翻转后 4 个元素:
[5,6,7,1,2,3,4]
结果正好是题目要求。为什么能这样?我的理解是:翻转操作把数组的顺序彻底倒过来,原来在后面的元素集体跑到前面,然后再把两个子区间分别翻转一次,让每个子区间内部的顺序恢复原样。这样一来,整体呈现出“后 k 个元素 + 前 n-k 个元素”的排列,正是向右轮转的效果。
实现时只需要一个辅助函数 reverse:
java复制class Solution {
public void rotate(int[] nums, int k) {
int n = nums.length;
k %= n;
reverse(nums, 0, n - 1);
reverse(nums, 0, k - 1);
reverse(nums, k, n - 1);
}
private void reverse(int[] nums, int left, int right) {
while (left < right) {
int tmp = nums[left];
nums[left] = nums[right];
nums[right] = tmp;
left++;
right--;
}
}
}
注意 k %= n 这一步,一定要先做。如果 k = 10 而数组长度只有 7,向右轮转 10 次和向右轮转 3 次的结果是完全一样的,因为轮转 7 次就回到原样了。不取模的话,后面翻转区间会越界,写出来的代码就是错的。
这个解法时间复杂度 O(n),空间复杂度 O(1),而且代码量极少。面试的时候我建议先说三次翻转的思路,再说代码,最后补充边界条件。这基本是面试官最期待的标准答案。
2.3 第三层:环状替换,思路更底层但最容易写错
还有一种原地解法叫“环状替换”,或者叫“原地循环移动”。核心思路是:从 0 号位置开始,把 nums[0] 拿走,放到它该去的位置 (0 + k) % n,然后把那个位置原来的元素再拿走,放到它该去的位置,一直循环下去,直到回到起点。
以 [1,2,3,4,5,6,7],k=3 为例:
text复制从 0 开始: 1 放到下标 3,nums[3] 的 4 被挤出来
4 放到下标 6,nums[6] 的 7 被挤出来
7 放到下标 2,nums[2] 的 3 被挤出来
3 放到下标 5,nums[5] 的 6 被挤出来
6 放到下标 1,nums[1] 的 2 被挤出来
2 放到下标 4,nums[4] 的 5 被挤出来
5 放到下标 0,回到起点,结束
一圈下来正好把 7 个元素都放好了。但如果 n 和 k 不互质,一圈走不完所有位置。比如 [1,2,3,4],k=2,从 0 出发会走 0 -> 2 -> 0,只覆盖了两个元素,另两个元素完全没有移动。这时候需要从 1 号位置再出发走一圈。
所以要用一个计数器记录“一共已经放好了多少个元素”,每放好一个就加一,直到放了 n 个才结束。外层循环的起点从 0 开始,如果计数还没满,就切换到下一个起点。代码是这样的:
java复制class Solution {
public void rotate(int[] nums, int k) {
int n = nums.length;
k %= n;
int count = 0;
for (int start = 0; count < n; start++) {
int current = start;
int prev = nums[start];
do {
int next = (current + k) % n;
int tmp = nums[next];
nums[next] = prev;
prev = tmp;
current = next;
count++;
} while (start != current);
}
}
}
环状替换的时间复杂度同样 O(n),空间复杂度 O(1),但它有两个缺点:第一,代码不如三次翻转直观,面试时容易在 do...while 的循环条件上翻车;第二,它需要额外维护一个计数变量,逻辑稍微绕一点。从工程的角度看,三次翻转完全够用了,环状替换更多是帮助你加深“索引取模”的理解。如果你不是特别想炫技,面试时首选还是三次翻转。
三种方法放一起对比一下:
| 方法 | 时间复杂度 | 空间复杂度 | 代码量 | 推荐程度 |
|---|---|---|---|---|
| 额外数组 | O(n) | O(n) | 少 | 能跑,但不是最优 |
| 三次翻转 | O(n) | O(1) | 很少 | 面试首选 |
| 环状替换 | O(n) | O(1) | 中等 | 理解索引运算用 |
3. 返回倒数第k个节点:双指针的经典应用
3.1 先用“两次遍历”把问题做出来,保证不丢分
链表不能随机访问,所以要找倒数第 k 个节点,最直观的办法是:第一遍遍历数出链表长度 n,第二遍从头走 n - k 步,停在的那个节点就是倒数第 k 个。
这个思路没什么坑,代码也很短:
go复制type ListNode struct {
Val int
Next *ListNode
}
func getKthFromEnd(head *ListNode, k int) *ListNode {
n := 0
cur := head
for cur != nil {
n++
cur = cur.Next
}
cur = head
for i := 0; i < n-k; i++ {
cur = cur.Next
}
return cur
}
时间复杂度 O(n),空间复杂度 O(1),能完美跑通。但面试官通常会追问一句:能不能只遍历一次?这就是双指针登场的时候。
3.2 快慢指针:一次遍历拿到答案
思路不复杂:先让一个指针从头部走 k 步,走完之后,另一个指针从头部出发。两个指针一起往后移动,当前面的指针走到链表末尾的 nil 时,后面的指针正好停在倒数第 k 个节点上。
整个过程两个指针之间的距离一直保持 k 个节点。假设链表一共 n 个节点,快指针先走了 k 步,剩下 n - k 步到达末尾;慢指针也跟着走 n - k 步,于是它从头部走了 n - k 步,走到的是第 n - k + 1 个节点,也就是倒数第 k 个节点。用图来想更清晰:
text复制1 -> 2 -> 3 -> 4 -> 5
k = 2
快指针走 2 步,指向 3
快慢一起走:
快指针到 4,慢指针到 2
快指针到 5,慢指针到 3
快指针到 nil,慢指针到 4
慢指针最终指向的是 4,答案正确。代码实现:
python复制class Solution:
def getKthFromEnd(self, head: ListNode, k: int) -> ListNode:
fast = head
slow = head
for _ in range(k):
fast = fast.next
while fast:
fast = fast.next
slow = slow.next
return slow
如果题目要求返回节点值,而不是节点本身,就改成 return slow.val。核心逻辑不变。
这个解法的亮点:一趟遍历解决,时间复杂度 O(n),空间复杂度 O(1)。虽然从大 O 的角度看,两次遍历和一次遍历都是 O(n),但一次遍历意味着更少的指针操作、更少的缓存失效,在链表特别长的时候,性能优势还是能体感出来的。更重要的是,双指针是后面一系列高级算法(快慢指针、滑动窗口、环形链表检测)的基础,这题练完,后面再遇到“链表求中间节点”你会有一种“似曾相识”的感觉。
3.3 两种方法对比,什么场景选哪个
| 方法 | 遍历次数 | 空间复杂度 | 代码难度 | 适用场景 |
|---|---|---|---|---|
| 先求长度再走 | 2 次 | O(1) | 低 | 链表中短,思路直给 |
| 快慢双指针 | 1 次 | O(1) | 低 | 链表长,面试加分 |
如果只是做题,两种都能过。但面试时我建议优先讲双指针,讲完可以再补充一句“还有一种先求长度的做法,逻辑更简单,适合快速实现”。这会让面试官觉得你不仅会写代码,还知道多种方案的取舍。
4. 实操中的边界条件与隐藏坑
4.1 轮转数组的四个隐藏坑,踩过就记住了
第一个坑是 k 比数组长度大。这个前面提过,一定要先 k %= n。不取模不只是结果对不对的问题,是翻转区间会直接越界报错。我见过不少人在面试时栽在这一步,代码写完了,一测试 k=10, n=7 这种用例就崩,来回改了十分钟才发现是取模的问题。
第二个坑是 k 为 0。这看起来没什么好说的,轮转 0 次就是原数组。但如果你在代码里写了 reverse(nums, 0, k-1),而 k=0,那么第二个参数是 0,第三个参数是 -1,reverse 函数里的 while (left < right) 会直接不进入循环,倒是不会报错。但如果你用的是环状替换,k=0 时 count 永远不会增长,外层循环的起点会一直加,最终退出条件依赖 count < n,反而是能跑通的,只是白白绕了一圈。建议单独处理或者确保取模逻辑覆盖这种情况。
第三个坑是数组长度为 0 或 1。长度为 0 时,n 为 0,k % n 直接除零异常。所以代码里要加一个 if (nums == null || nums.length <= 1) return; 之类的保护。这类边界条件不写,测试用例一上就容易翻车。
第四个坑是翻转函数本身的细节。reverse 里的循环条件应该是 while (left < right),不是 while (left <= right)。后者在数组长度为奇数时,最后两个指针会重合,多做一次无意义的交换,代码虽然不出错,但看起来不够严谨。我在写题时习惯用 left < right,避免这种歧义。
4.2 链表题的三个隐藏坑,空指针是重灾区
链表题的空指针问题比数组题更容易被忽略。第一个坑是 head 为 null。如果链表为空,不管 k 是多少,都应该直接返回空。第二个坑是链表只有一个节点且 k=1,快指针先走 1 步直接到 nil,然后 while (fast) 循环一次都不进,直接返回 slow,也就是头节点本身。这个结果是对的,但如果你没有意识到这个流程,很容易在调试时怀疑自己写错了。
第三个坑是 k 与链表长度的关系。LeetCode 的版本一般约定 k 合法,但面试时你可能遇到“k 大于链表长度”的情况,此时应该返回空节点还是报错?不同面试官预期不一样。我的习惯是写代码前先把问题问清楚,就一句话:“如果 k 大于链表长度,返回空可以吗?”这一句话会显得你考虑周全,而不是算法能力有问题。
4.3 调试技巧:打印中间状态比干看代码快得多
数组题的调试很简单,每次翻转后打印一下数组,立刻能看到哪一步不符合预期:
text复制初始: [1,2,3,4,5,6,7]
整体翻转: [7,6,5,4,3,2,1]
前k翻转: [5,6,7,4,3,2,1]
后n-k翻转: [5,6,7,1,2,3,4]
如果某一步的中间状态不对,你马上就知道是 reverse 函数写错了,还是区间边界算错了。链表题就麻烦一点,不能方便地打印整个链表,我习惯在纸上画指针移动图。画三行,第一行是链表元素,第二行是快指针位置,第三行是慢指针位置,每走一步就标一个 f 和 s。画着画着,你自然能理解为什么快慢指针之间的距离恒等于 k。
另外一个小技巧:链表题的输入是形如 1 -> 2 -> 3 这样的顺序结构,写代码前先把“快指针先走 k 步”这个动作翻译成代码,再翻译“一起走”这个动作,两步拆开写,不要揉在一个循环里。揉在一起容易导致快指针多走或者少走一步。
5. 这类题目在真实工程场景里的影子
5.1 轮转数组的思想:日志轮转、循环队列、图像平移
你可能会想,这题刷完除了面试还能干嘛?其实轮转数组在工程里到处都是。最典型的是日志轮转,很多服务会把日志按天切分,写满一个文件后按顺序复用旧的日志文件,文件名的滚动本质上就是数组元素的轮转。在实现上,日志库经常用“循环缓冲区 + 头部指针”来避免频繁搬移数据,这是轮转数组思想的工程化版本。
再看 Redis 的 List 结构、消息队列的环形队列,它们底层维护一个固定大小的数组,写入新数据时通过索引取模覆盖旧数据。这个“数组下标加一,越界回到开头”的机制,和轮转数组里的 (i + k) % n 是同一个数学原理。你在刷题时把这个索引运算吃透了,后面看到 head = (head + 1) % capacity 这样的代码就不会觉得陌生。
图像处理里的像素平移、字符串里的字符循环移位,本质也是轮转数组。比如把 "abcde" 左移两位变成 "cdeab",用的就是同样的三次翻转思想。算法题从来不是孤立的,它是在帮你积累“把一个通用问题抽象成基础操作”的模板。
5.2 快慢指针思想:不只是倒数第k个,它有一整个家族
快慢指针是链表算法里的万能钥匙。做完“返回倒数第 k 个节点”,你可以顺手把下面这几个题一起刷了:
- 求链表中间节点:慢指针每次走一步,快指针每次走两步,快指针到末尾时慢指针正好在中间。
- 判断链表是否有环:同样是快指针走两步、慢指针走一步,如果两者相遇说明有环。
- 寻找环形链表的入口节点:在确认有环之后,把一个指针移回头部,两个指针每次各走一步,相遇点就是环的入口。
你会发现,这些题的核心都是“两个指针以不同速度或不同起点移动,利用位置差解决问题”。倒数第 k 个节点的“先让快指针走 k 步”,本质上就是给两个指针建立了一个固定的偏移量,后面的判断环则是在利用速度差制造相遇条件。掌握一个模型,就能解决一整个系列的题,这才是刷题效率最高的方式。
回到本文的两个题,我的个人体会是:它们难度适中,但非常考察基本功。轮转数组考察的是数组原地操作的意识和对取模运算的理解,返回倒数第 k 个节点考察的是对链表结构和双指针的运用。这两个点几乎是一线面试里数组题和链表题的送分题,练好它们,后面再遇到反转链表、合并有序链表、滑动窗口最大值这些进阶题,你会更有底气。建议你把代码手写三遍以上,先不看答案写一遍,再隔天默写一遍,最后尝试用不同语言实现一遍。数据结构这东西,看得懂和写得出来永远是两回事。
