第一次刷 LeetCode 189 的时候,我的第一反应是这题简单:把数组后面的几个元素搬到前面不就行了?可等我真在面试现场被要求一步步优化到 O(1) 空间时,才发现这道题几乎能把循环移位、取模、边界处理、原地算法这些基本功全过一遍。这篇文章我就把自己对这题的完整理解写下来,从暴力法讲起,一直说到三次反转和环状替换。不管你是刚开始刷题,还是准备面试,应该都能从里面找到能直接拿来用的东西。
1. 轮转数组到底在考什么:题意拆解与常见误区
1.1 从"轮转"到"循环移位":题面到底要做什么
轮转数组,英文原题叫 Rotate Array,题号 189。题面不复杂:给你一个数组,将数组中的元素向右轮转 k 个位置。比如输入 nums = [1,2,3,4,5,6,7], k = 3,输出应该是 [5,6,7,1,2,3,4]。
很多人第一次看会把它理解成"普通的数组平移",但实际上"轮转"这两个字强调的是循环。如果把数组看成一个环形结构,右轮转 k 位就等价于:每个元素从下标 i 移动到下标 (i + k) % n,其中 n 是数组长度。反过来,最终数组的第 j 位上的元素,来自原数组下标 (j - k + n) % n。
所以它考察的第一件事,就是你能不能把"移动元素"这件事抽象成一个取模映射。很多人在这一步会想成"把最后 k 个元素切下来放到前面",这种理解没有错,但它只在 k < n 时成立。一旦 k 大到可以绕好几圈,切片逻辑就开始犯迷糊。
1.2 三个最容易出错的边界条件
我见过太多人代码逻辑没问题,样例也过了,一提交就挂在边界用例上。这道题常见的边界条件有三个。
第一个是 k 大于数组长度。比如 nums = [1,2], k = 3,向右轮转 3 位,实际上是轮转 1 位,结果 [2,1]。任何解法都该先执行 k %= n,否则数组访问会越界,或者反转的时候下标算错。
第二个是 k = 0。数组不用动。有些解法没做这个判断,直接进入反转或者环状替换,运气好返回原数组,运气不好可能在特殊实现里跑进死循环。
第三个是数组长度 n <= 1。一个元素怎么转都还是自己,空数组更不能去访问 nums[0]。
工程上还要考虑 k 可能为负数的情况。题目里说 k 是非负整数,但很多工具函数会支持负数表示反向轮转。处理方式很简单:k = ((k % n) + n) % n,把负数归一到 [0, n-1]。
下面列一个边界检查表,写代码之前先对照一遍:
| 输入情况 | 处理方式 | 结果 |
|---|---|---|
n = 0 |
直接返回,避免访问空数组 | 空数组 |
n = 1 |
直接返回,单元素无论怎么转不变 | 原数组 |
k = 0 |
直接返回,避免不必要的计算 | 原数组 |
k > n |
先 k %= n |
等价于轮转余数位 |
k 是 n 的倍数 |
k % n = 0,数组不变 |
原数组 |
k 为负数(扩展) |
k = (k % n + n) % n |
转成等价右移 |
1.3 先想清楚:这是算法题,不是语言库函数调用题
拿到这题,很多人第一反应是 Java 有 Collections.rotate,Python 有列表切片,C++ 有 std::rotate。确实,工程里我可能就用库函数了,但 LeetCode 这道题明确要求原地修改数组,而且 Follow Up 里还问你能不能只用 O(1) 额外空间。
面试官想看到的是你理解这些库函数背后的原理:为什么 std::rotate 能原地完成?它究竟是三次反转还是环状移动?如果你只说"调用库函数",基本上等于告诉面试官你没有深入看过实现。
所以下面几种解法,我建议按顺序掌握:暴力法帮你想清楚移动过程,额外数组法帮你确认取模公式,三次反转法是面试中最推荐写的答案,环状替换法是O(1)空间下更底层的实现思路。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 暴力法很好写,为什么面试官会追问
2.1 一次移一位的代码和它的复杂度
最符合直觉的写法是:每一轮把整个数组往右移一位,末尾元素放到开头,重复 k 次。
java复制public void rotate(int[] nums, int k) {
int n = nums.length;
k %= n;
for (int i = 0; i < k; i++) {
int last = nums[n - 1];
for (int j = n - 1; j > 0; j--) {
nums[j] = nums[j - 1];
}
nums[0] = last;
}
}
如果 k = 3, n = 7,直观过程是:
- 第一轮:
[7,1,2,3,4,5,6] - 第二轮:
[6,7,1,2,3,4,5] - 第三轮:
[5,6,7,1,2,3,4]
看起来没毛病。但问题是,每一轮移动一位,内层要搬动 n-1 个元素,总操作次数是 k * (n-1),时间复杂度 O(n*k)。如果 n = 10^5, k = 50000,那就是大约 50 亿次赋值操作,哪怕是现代 CPU 也扛不住。
我见过有人写这样一个"优化":先把 k 取模,然后只移动 k 次。但取模之后 k 可能还是 n-1,比如 n = 100000, k = 99999,内层循环依然要跑约 10^10 次,照样超时。取模只能解决"多绕圈"的问题,不能解决暴力移位本身复杂度高的问题。
2.2 实测"看起来能跑"的解法在大数据量下有多慢
我在本地拿 n = 100000 的数组跑过一次这个暴力代码,用 k = 50000,跑了十几秒都没出结果。LeetCode 上很多题目数据规模会卡到 10^5 甚至 10^5 以上,这种写法基本是必挂。
更麻烦的是,暴力法在 k = 0 时会白白走一个空循环,虽然不慢,但不做 k %= n 的话,如果 k = 1000000000,内层循环次数会爆炸到你根本不想等。
所以暴力法的定位只有一个:用来理解"移动一位"的物理过程。它对最终答案没什么用,但可以用来生成测试样例的期望结果,验证后面更优的解法是否正确。
2.3 从这里该悟出的复杂度评估习惯
很多新手刷题有个习惯:代码在本地跑通样例就觉得完事了。但算法题最关键的评估方式是最坏情况复杂度。看到 k * n,第一反应应该是"这种双重循环有没有可能退化成 O(n²)"。只要存在,线上数据一定会让它发生。
我可以给一个简单判断法:外层循环次数 k 最大可以接近 n,内层循环次数也是 n,所以最坏情况一定是 O(n²)。遇到这类题,第一步永远是看数据范围,然后立刻排除平方级解法。
3. 额外数组:用空间换时间,但别急着说"好"
3.1 offset 映射公式推导
既然暴力法慢在每次只移动一位,那干脆一次把每个元素放到它最终该去的位置。右轮转 k 位后,原下标 i 的元素会去到 (i + k) % n,这个公式就是整个额外数组解法的核心。
比如 nums = [1,2,3,4,5,6,7], k = 3, n = 7:
i = 0,1去下标(0+3)%7=3i = 1,2去下标4i = 4,5去下标0i = 6,7去下标2
最终得到 [5,6,7,1,2,3,4]。这个过程完全不会覆盖还没处理的元素,因为原数组的值已经存到 nums[i] 里,目标数组是新开的。
3.2 三种写法和细节对比
最直接的是新开一个同样大小的临时数组,按公式填进去,再拷贝回来。
java复制public void rotate(int[] nums, int k) {
int n = nums.length;
int[] temp = new int[n];
k %= n;
for (int i = 0; i < n; i++) {
temp[(i + k) % n] = nums[i];
}
System.arraycopy(temp, 0, nums, 0, n);
}
Python 里有一种看起来很爽的写法:
python复制def rotate(nums, k):
n = len(nums)
k %= n
nums[:] = nums[n - k:] + nums[:n - k]
但这里有一个特别容易踩的坑:当 k = 0 时,nums[n:] 和 nums[:n] 分别是空列表和原数组,结果没问题。可是如果切片写成了 nums[-k:],当 k = 0 时,nums[-0:] 等于 nums[0:],于是你会得到两个原数组拼在一起,结果直接错。所以先 k %= n,再用 n - k 来分割,比用负数下标安全得多。
其实还有一种写法是把后 k 个元素复制到临时数组,然后把前 n-k 个元素搬到末尾,再补回临时数组。这种方法的逻辑和切片一模一样,我更喜欢在面试时用,因为它能帮你一步一步说清楚分界点在哪里。
三种写法对比一下:
| 实现方式 | 时间复杂度 | 空间复杂度 | 可读性 | 风险点 |
|---|---|---|---|---|
| 新建数组 + 取模赋值 | O(n) | O(n) | 高,公式直观 | 无 |
| Python 切片拼接 | O(n) | O(n) | 非常高 | k=0 时负数下标容易写错 |
| 分段复制两次 | O(n) | O(n) | 中 | 分界点容易搞混 |
3.3 空间复杂度为 O(n) 算不算合格解
在 LeetCode 上,额外数组解法能通过,因为你只用了一个额外数组,时间上 O(n) 也很优秀。但题目 Follow Up 明确问:能不能只使用 O(1) 额外空间完成?如果你在面试中停在额外数组,面试官大概率会追问。
你可以这样回答:额外数组法是最容易正确实现的解法,它可以作为基准答案;但数组本身已经给了我们 O(1) 原地操作的潜力,因为每个元素都有一个确定的去处,不需要额外存储中间状态。
这并不是说额外数组没用。我在实际调试复杂解法时,会先用额外数组法生成正确答案,再拿它去对比反转法和环状替换法的输出。有了一个可信的 oracle,后面写优化代码会安心很多。
4. 三次反转:原地算法里最优雅的做法
4.1 为什么是三次 reverse 而不是更多
先看一个例子。nums = [1,2,3,4,5,6,7], k = 3,右轮转 3 位的结果是 [5,6,7,1,2,3,4]。
把原数组分成两段:前 n - k = 4 个元素 [1,2,3,4],后 k = 3 个元素 [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]
你可能会问,为什么是三次反转而不是两次或者四次?本质是:每段元素在整体反转后位置交换了,段内顺序也反了;再分别反转两段,段内顺序恢复,但两段整体位置已经完成交换。这种"交换两块连续区域"的思想,在字符串旋转、链表旋转里都能复用。
另一种等价的顺序是:先反转前 n-k 个,再反转后 k 个,再整体反转。我建议你只记一种,记多了容易在面试时搞混。我自己偏向整体反转放在第一步,因为 k 是我们最关心的切分点。
4.2 反转的边界与实现细节
先写一个数组区间反转辅助函数:
java复制private void reverse(int[] nums, int left, int right) {
while (left < right) {
int temp = nums[left];
nums[left] = nums[right];
nums[right] = temp;
left++;
right--;
}
}
然后调用三次:
java复制public void rotate(int[] nums, int k) {
int n = nums.length;
if (n <= 1) return;
k %= n;
if (k == 0) return;
reverse(nums, 0, n - 1);
reverse(nums, 0, k - 1);
reverse(nums, k, n - 1);
}
注意反转函数的边界是左闭右闭区间。调用 reverse(nums, 0, k - 1) 时,如果 k = 0,右边界为 -1,循环不会执行,但这样代码逻辑不清晰,而且如果 n = 0 直接越界。因此提前判断 n <= 1 和 k == 0 非常重要。
如果你把反转顺序写成先反转前 n-k 和后 k,再整体反转,得到的结果也是一样的,但边界写起来要小心 k = 0 时 n - k = n,导致分割出空段。我在代码里更喜欢先整体反转,这样 k 的位置就是唯一需要关注的分界点。
4.3 常见坑:分段位置 k 和 k % n
这个解法的坑几乎全部集中在"没取模"上。假设 nums = [1,2,3], k = 5,如果不做 k %= n:
- 整体反转:
[3,2,1] - 反转前 5 个:数组越界。
- 反转后
3-5=-2个:完全没有意义。
取模之后一切正常:k = 5 % 3 = 2,结果是 [2,3,1]。你可以自己手动转一下验证,向右轮转 5 位等于轮转 2 位。
还有一个容易忽略的点:k 是 n 的倍数时,比如 nums = [1,2,3], k = 6,取模后 k = 0,数组应该完全不变。如果你没有提前 if (k == 0) return,三次反转还是会执行,结果虽然不变,但纯属浪费时间。更危险的是某些反转实现里用 k - 1 作为右边界,k = 0 时会得到一个负数边界,可能导致异常。
为了把边界情况说清楚,我列一张测试表:
| nums | k | 取模后 | 结果 |
|---|---|---|---|
[1,2,3] |
0 | 0 | [1,2,3] |
[1,2,3] |
5 | 2 | [2,3,1] |
[1,2,3] |
3 | 0 | [1,2,3] |
[1,2] |
1 | 1 | [2,1] |
[1] |
100 | 0 | [1] |
你把这些用例跑一遍,三次反转法基本就不会出错了。
5. 环状替换:把每个元素放到它该去的位置
5.1 环的跳转逻辑和 gcd 的作用
额外数组法用到了一个额外数组来避免覆盖。那如果不想用额外空间,能不能直接在一个原地上循环赋值?可以,这就是环状替换。
核心思路是:从某个起点 start 出发,每次把当前位置的元素放到 (current + k) % n 下标处,同时把目标位置原来的元素取出来,继续往后跳。这样一条路径会形成一个环,最终回到 start。
但问题来了:数组里可能不止一个环。比如 nums 长度为 6,k = 2,从下标 0 出发:
- 0 → 2 → 4 → 0
而从下标 1 出发是另一个环:
- 1 → 3 → 5 → 1
所以一次外循环只从一个起点开始是不行的,必须保证所有环都被处理到。
环的个数其实等于 gcd(n, k)。上面例子里 gcd(6, 2) = 2,正好两个环。如果 n = 7, k = 3,gcd(7, 3) = 1,整个数组是一个大环,从 0 出发走一圈就能覆盖所有元素。
5.2 代码里的 count 变量到底在防什么
如果不想在代码里算 gcd,更通用的做法是用一个 count 变量记录已经归位的元素数量。每当一个元素被放到最终位置,count++,直到 count == n 说明所有元素都处理完了。
java复制public void rotate(int[] nums, int k) {
int n = nums.length;
if (n <= 1) return;
k %= n;
if (k == 0) return;
int count = 0;
for (int start = 0; count < n; start++) {
int current = start;
int prev = nums[start];
do {
int next = (current + k) % n;
int temp = nums[next];
nums[next] = prev;
prev = temp;
current = next;
count++;
} while (current != start);
}
}
count 是这段代码的质检员。我曾经想过不用 count,直接写两层循环,外层从 0 到 n-1,内层回到 start 就退出。但当只有一个大环时,内层一次就能覆盖所有元素;当存在多个环时,外层继续从 1, 2,... 进入时,很可能再次进入已经处理过的环,导致元素被二次覆盖,结果错乱。
可以看到,用 count 的好处是不需要显式计算 gcd,数学上简单,实现也更不容易错。唯一要记住的是内层使用 do...while 而不是 while,因为起点元素本身也要被移动,至少执行一次循环体。
有一个细节:内层终止条件是 current != start,这意味着走完一个环回到起点。由于 start 还没被新值覆盖,代码里先取出 nums[start] 作为 prev,第一次赋值时实际上是用起点元素覆盖了下一个位置。等到环走到最后,当前元素回到 start 位置覆盖原起点,整个过程正好把起点元素放在了它该去的位置。
5.3 环状替换和反转法在实际工程里的取舍
如果你自己实现一个轮转函数,大概率会优先选三次反转,因为它好读、好维护。环状替换虽然每个元素只被赋值一次,常数因子理论上更小,但代码里跳来跳去,对后来维护的人来说很不友好。
我做了一个简单对比:
| 维度 | 三次反转 | 环状替换 |
|---|---|---|
| 时间复杂度 | O(n) | O(n) |
| 空间复杂度 | O(1) | O(1) |
| 赋值次数 | 约 3n 次 | 约 n 次 |
| 可读性 | 高,思路直观 | 中,需要理解环 |
| 边界处理 | 注意 k 取模 | 注意 k==0 和 count |
| 工程推荐度 | 高 | 中 |
如果你的数组非常大,赋值次数会影响性能,环状替换有优势。但在普通业务场景里,反转法的可维护性价值远大于那一点常数优化。
6. 进阶:变体、工程应用和我的实战体会
6.1 轮转数组的变体:左转、字符串反转、旋转矩阵
轮转数组并不是孤立的题目,它有一堆变体。
左转 k 位本质上等于右转 n - k % n 位。比如 [1,2,3,4,5] 左转 2 位得到 [3,4,5,1,2],等价于右转 3 位。你只需要把三次反转里的 k 换成 n - k % n 即可。
还有一个经典变体是字符串反转,比如反转句子中的单词顺序,通常先整体反转,再对每个单词单独反转,思路和轮转数组是一模一样的"两段交换"。
再往远处说,旋转二维矩阵也可以看成多次轮转的叠加,或者按环替换的思路逐层处理。掌握了数组上的一维环移动,理解二维旋转会轻松很多。
6.2 在真实业务系统中的位置:日志滚动、缓存、轮询
不要觉得这题只在面试中出现。日志文件滚动就是一个典型场景:日志写满一个文件后,新日志要覆盖最旧的文件,本质上就是在一个文件列表上做轮转。如果你用固定大小数组维护最近 N 条消息,消息满了之后要覆盖头部指针指向的位置,这也是环形缓冲区,底层就是一个轮转下标。
另一个常见场景是负载均衡里的轮询策略:用一个计数器依次指向后端服务列表,每次请求后计数器加一,超出长度就回到 0。这个计数器加一取模的写法,和轮转数组里 (current + k) % n 完全一样。唯一不同的是,工程里你通常只需要记一个指针,不需要真的移动数组。
6.3 一个更隐蔽的坑:不可变数据结构与流式数组
如果数组本身不可变,比如某些函数式语言里的 list,你不能原地修改,只能通过拼接生成新数组。这时候三次反转和环状替换都失效,只能用额外数组或切片拼接。
流式场景里更复杂:数据是源源不断进来的,你不能一次性拿到整个数组,再执行三次反转。常见的做法是维护一个环形缓冲区,只写入当前数据,利用指针覆盖最旧数据,这样单条数据的时间复杂度是 O(1)。
所以看到"轮转数组"这道题,我建议你别只背代码,而是想清楚它和"循环队列""环形缓冲区"的关系。面试官如果追问"如果数组特别大,甚至存不下怎么办",你要能说出"我们不一定需要移动数据,可以移动下标"这个思路。
6.4 答题顺序和边界测试建议
把这道题讲完,最后分享一点我个人刷题的小技巧。
面试时我的回答顺序通常是:先快速说暴力法,点出问题;再说额外数组法,给出取模公式;紧接着说三次反转,写出辅助函数;如果面试官追问常数空间优化,再表演环状替换。这样一层层递进,能展示你不仅会写题,还理解每种方案的权衡。
写代码前,先想清楚边界条件,把 k %= n、n <= 1、k == 0 这三个判断放在开头。写完再拿几个极端用例跑一遍,比如 [1]、[1,2]、k=0、k=100。这些用例花不了 10 秒,但能帮你躲过绝大多数提交失败。
我自己在实际开发里最常用的还是三次反转,因为它易懂、不容易被维护者改坏。但环状替换让我理解了为什么很多底层库能把数组旋转优化到极致。这两种解法,本质上都是对同一个取模映射的不同落地方式。刷完这道题,我最大的体会是:不要小看任何一道"简单题",越是看似基础的题目,越能检验你对数组、下标和取模这几个底层概念的熟练程度。
