1. 一道看似 easy 的 medium:轮转数组到底在考什么
我第一次遇到这题是在某次模拟笔试里,当时扫了一眼题目,觉得这不就是"把末尾元素搬到开头"嘛,直接掏出辅助数组三分钟写完提交,自我感觉良好。结果当然是栽了——题目在最后一行写着一句不太起眼的话:要求使用原地算法,空间复杂度 O(1)。从那一刻起我才明白,hot 100 里的这个 189 题,真正考的不是"你会不会轮转",而是"你能不能在被限制空间的情况下,依然把下标关系、边界条件和算法正确性全部想清楚"。
这道题的题干极其简单:给你一个数组,把元素向右轮转 k 个位置。比如 [1,2,3,4,5,6,7],k=3,结果是 [5,6,7,1,2,3,4]。就这么一个操作,演化出了至少四种主流解法,复杂度从 O(n*k) 一路降到 O(n),空间从 O(n) 降到 O(1)。对于准备算法面试的人来说,这是一道性价比极高的题目:它覆盖面广、变体多、可深挖的点也多。
这篇文章我打算按我自己踩坑的顺序来写,适合两类读者:一类是刚开始刷题、想系统理解这题所有解法的新手;另一类是已经能 AC、但想在面试时把"为什么这么写"讲得更清楚的进阶选手。看完你会得到三样东西:一张可以覆盖所有边界情况的测试用例清单、一套从暴力到最优的完整解法演进路径,以及一个我自己调试环状替换算法时真实踩过的坑。
2. 正式写代码之前,先把三处容易翻车的边界处理干净
很多人在本地跑用例是好的,一提交就挂在奇奇怪怪的测试数据上,绝大多数情况都出在边界处理。这题的边界其实就三件事,提前处理掉,后面所有解法都能少掉一半烦恼。
2.1 k 可能比数组长度大得多,先取模再做
题目只保证了 k 是非负整数,并没有限制 k < 数组长度。假设数组长度是 5,k 是 7,你如果老老实实轮转 7 次,前 5 次把数组转回了原位,后面 2 次才是真正有效的操作。所以第一行就应该写:
k = k % n;这一步不是优化,是保命。不取模的话,后面无论用三次反转还是环状替换,都会在某些用例上出现莫名其妙的越界或错乱,而且最恶心的是:小数据全对,大数据全错,非常难定位。我自己的习惯是,凡涉及循环位移的题目,第一反应就是先看 k 和模数的关系,这不只是本题的规矩,也是处理环形数组类问题的通用姿势。
2.2 空数组、单元素数组和 k=0 都要提前返回
取模之后,如果 k 变成 0,说明数组不需要任何变化,直接 return。这行代码能帮你省掉无谓的工时,更重要的是避免在后续逻辑里把 k=0 当成正常情况处理,反而引入新的 bug。
数组长度为 0 时必须直接返回,否则任何访问 nums[n-1] 的操作都会越界;长度为 1 时,不管 k 是多少,轮转结果都是它本身。这两个用例看似无聊,却是判题系统最喜欢塞进来的边界,我在代码里一定把它们放在取模之后的第一梯队处理。
int n = nums.length; if (n == 0) return; k = k % n; if (k == 0) return;2.3 "原地"这个限制,决定了你该往哪个方向想
原题要求空间复杂度 O(1)。这意味着刚上手想到的"开一个新数组,把元素按新位置放进去再拷贝回来"虽然逻辑完全正确,却不是这道题想要的终局答案。我见过很多人在面试时一上来就写辅助数组,思路没错,但面试官接下来一定会追问一句:"能不能不用额外空间?"如果你没有提前准备原地方案的推导过程,现场再想会很被动。
我建议的练习顺序是:先用辅助数组把思路做对,理解了下标取模公式之后,再去研究环状替换和三次反转这两个原地方案。先保证正确,再追求空间,这个顺序对任何算法题都适用。
3. 从暴力到原地:四种解法逐行拆解与复杂度对比
这四种解法不是简单的罗列,它们之间有一条清晰的主线:先弄清楚"每个元素最终要去哪个位置",然后围绕这个位置公式,逐步减少空间和时间上的浪费。
3.1 暴力右移:只用来理解题意,别拿去提交
最直观的做法是:外层循环 k 次,每次把最后一个元素保存下来,然后所有元素依次右移一位,最后把保存的元素放到开头。
public void rotate(int[] nums, int k) { int n = nums.length; k = 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; } }时间复杂度 O(nk),空间 O(1)。当 n=10^5、k=510^4 时,内层要跑几十亿次,超时是必然的。这个版本的价值在于验证你对"轮转"这件事的理解:每次只移动一位,k 次就是 k 位。把它当成思考起点可以,但作为提交版本没有任何竞争力。
3.2 辅助数组:最符合直觉的"换座位"方案
每个元素向右挪 k 位,新位置就是 (原下标 + k) % n。基于这个公式,开一个长度相同的辅助数组,遍历原数组,把每个元素放到 tmp 里的正确位置,最后拷贝回来。
public void rotate(int[] nums, int k) { int n = nums.length; int[] tmp = new int[n]; for (int i = 0; i < n; i++) { tmp[(i + k) % n] = nums[i]; } for (int i = 0; i < n; i++) { nums[i] = tmp[i]; } }时间复杂度 O(n),空间 O(n)。这段代码的精华就是下标公式 (i + k) % n,后面两个原地算法的出发点都是它。面试时如果你先写这个版本,面试官通常不会打断你,因为它清晰地展示了你是理解题意而不是背题解。真正的追问会在你写完它之后到来。
3.3 环状替换:原地版本,但计数逻辑是真正的胜负手
既然每个元素的目标位置是确定的,就可以顺着这条链"接力"下去:把 nums[0] 的值放到下标 k 的位置,把原来 k 位置的值放到下标 2k 的位置,一直走到回到起点。每安置好一个元素,计数器加一,全部元素安置完毕就结束。
public void rotate(int[] nums, int k) { int n = nums.length; k = k % n; int count = 0; for (int start = 0; count < n; start++) { int cur = start; int prev = nums[start]; do { int next = (cur + k) % n; int temp = nums[next]; nums[next] = prev; prev = temp; cur = next; count++; } while (cur != start); } }这里的核心陷阱是:从某个起点出发的那条链,往往不能一次覆盖所有元素。比如 [1,2,3,4],k=2,从下标 0 出发,只经过 0→2→0,走完两个元素就回到起点,下标 1 和 3 完全没被碰过。所以外层循环必须依赖 count 这个计数器,而不是简单地按 start 从 0 到 n-1 扫一遍。count 记录的是"已经被安置到最终位置的元素个数",当它等于 n 时,立刻停止,后面的 start 不需要再处理。
如果你把 do-while 换成 while,或者漏写 count,代码会在 k 与 n 不互质的用例上报错。这一块的详细翻车现场,我在第五节单独复盘。
3.4 三次反转:代码最短,也最适合当作面试终版
思路就一句话:先整体反转,再反转前 k 个,再反转后 n-k 个。以 [1,2,3,4,5,6,7],k=3 为例:
- 原始数组:1 2 3 4 5 6 7
- 整体反转:7 6 5 4 3 2 1
- 反转前 3 个:5 6 7 4 3 2 1
- 反转后 4 个:5 6 7 1 2 3 4
结果完全正确。这个方案的代码只有十来行,时间复杂度 O(n),空间 O(1),没有复杂的计数逻辑,边界也清晰。面试时如果时间紧张,直接写这个版本是最稳妥的选择。
public void rotate(int[] nums, int k) { int n = nums.length; k = 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 temp = nums[left]; nums[left++] = nums[right]; nums[right--] = temp; } }四种方案的复杂度对比,我整理成了一张表,方便你快速回忆:
| 方案 | 时间复杂度 | 空间复杂度 | 原地 | 推荐场景 |
|---|---|---|---|---|
| 暴力右移 | O(n*k) | O(1) | 是 | 理解题意 |
| 辅助数组 | O(n) | O(n) | 否 | 快速验证思路 |
| 环状替换 | O(n) | O(1) | 是 | 练习原地算法 |
| 三次反转 | O(n) | O(1) | 是 | 面试最优解 |
4. 三次反转为什么成立:反转一次、两次、三次的数学本质
很多教程只告诉你"先整体反转,再局部反转",但你要是追问一句"为什么这样就等价于轮转",大部分人答不上来。这一节我把原理拆开,你看完不仅自己能信服,面试时也能用两句话让面试官点头。
4.1 一次反转的本质是下标对称变换
对一个区间 [left, right] 做反转,等价于把区间内每个元素的下标 i 映射成 left + right - i。比如说区间 [2,6],原来下标 2 的元素反转后到下标 6,下标 3 的到 5,依此类推。这是理解整套推导的地基:反转不是随便倒腾,它是一次精确的、可逆的下标变换。
4.2 三次反转等于完成了 A 和 B 两块的整体交换
假设原数组可以拆成两个连续块:A 是前 n-k 个元素,B 是后 k 个元素。原数组写作 AB,向右轮转 k 位的目标就是得到 BA。
第一次整体反转,把 AB 变成 reverse(B) reverse(A)。注意这里的顺序:整体反转后,原来在后面的块会跑到前面,但块内部的顺序是反的。
第二次反转前 k 个元素,作用的对象是 reverse(B),再一次反转就把 B 的内部顺序恢复回来,得到 B reverse(A)。
第三次反转后 n-k 个元素,作用的对象是 reverse(A),恢复 A 的内部顺序,最终得到 BA。
整个过程没有任何多余的空间开销,也没有复杂的环计数,核心思想就是把"交换两块位置"这个动作,拆成三次局部倒序。这就是它优雅的根源——你不需要逐位计算每个元素的新下标,只需要利用反转本身的自逆性质。
4.3 两个最容易写错的细节,管住它们就稳了
细节一:反转区间要精确。整体反转是 [0, n-1],前 k 个是 [0, k-1],后 n-k 个是 [k, n-1]。我见过不少人把后一段写成 [k+1, n-1],或者把前一段写成 [0, k],结果差一个位置,整个数组错位。写完后拿一个简单用例在纸上走一遍,比肉眼检查可靠得多。
细节二:k 必须先取模。假设 nums=[1,2],k=3,不取模直接反转前 3 个元素,在长度只有 2 的数组上根本无从谈起,代码要么越界要么逻辑混乱。取模之后 k=1,整体反转得到 [2,1],反转前 1 个不变,再反转后 1 个不变,结果 [2,1],正是正确答案。所以说到底,第五节之前提到的取模习惯,是后面所有方案的安全网。
5. 环状替换调错实录:少了一个 count 变量,数组转了一圈回到原点
环状替换是我当初最看好的方案,因为它是纯正的"顺着下标走"的数据结构思路,不依赖任何技巧。但也是我实际写代码时翻车最狠的方案。有一次我在本地反复调试,发现数组在特定用例下居然回到了原样,当时整个人是懵的。
5.1 少了 count 的"简洁"版本长什么样
我一开始写的版本是这样的,表面上比标准写法还简洁:
public void rotate(int[] nums, int k) { int n = nums.length; k = k % n; for (int start = 0; start < n; start++) { int cur = start; int prev = nums[start]; while (true) { int next = (cur + k) % n; int temp = nums[next]; nums[next] = prev; prev = temp; cur = next; if (cur == start) break; } } }没有 count,没有 do-while,但问题恰恰出在这里。这段代码在 n=7、k=3 这种"单环"用例上是正确的,因为从下标 0 出发就能走完所有元素,start=1 时内层几乎不会执行。但是在 n=4、k=2 这种"多环"用例上,它就把自己绕进去了。
5.2 手动推演 [1,2,3,4],k=2 的完整翻车过程
这个用例的环结构是 0→2→0 和 1→3→1 两个独立的环。我用表格记录了错误代码每一步执行后的数组状态:
| start | 内层轮次 | cur | prev 缓存值 | next | 写入 nums[next] | 数组状态 |
|---|---|---|---|---|---|---|
| 0 | 1 | 0 | 1 | 2 | 1 | [1,2,1,4] |
| 0 | 2 | 2 | 3 | 0 | 3 | [3,2,1,4] |
| 1 | 1 | 1 | 2 | 3 | 2 | [3,2,1,2] |
| 1 | 2 | 3 | 4 | 1 | 4 | [3,4,1,2] |
| 2 | 1 | 2 | 1 | 0 | 1 | [1,4,1,2] |
| 2 | 2 | 0 | 3 | 2 | 3 | [1,4,3,2] |
| 3 | 1 | 3 | 2 | 1 | 2 | [1,2,3,2] |
| 3 | 2 | 1 | 4 | 3 | 4 | [1,2,3,4] |
看到最后一行了吗?数组居然回到了 [1,2,3,4],转了一圈白忙活。原因在于 start 从 0 扫到 3 的过程中,同一个环被处理了多次。以环 0↔2 为例,start=0 处理了一遍,start=2 又处理了一遍,而一个交换操作连续做两次恰好等于什么都不做,于是前面的修改被后面的操作抵消掉了。
标准写法里的 count 干的就是这件事:它精确记录"还有多少元素没有被安置到位"。每个元素只允许被移动到最终位置一次,当 count==n 时,所有环都恰好处理了一遍,立即终止。没有这个计数器,外层循环就会重复处理已经完成的环,把小数据上的"碰巧正确"变成大数据上的"必然错误"。
5.3 用数学话说清楚:环的数量由 gcd(k, n) 决定
为什么有的用例从 0 出发能走完,有的不能?这是因为链的长度取决于 k 和 n 的最大公约数。从任意下标出发,每次都加 k,经过 n/gcd(n,k) 步会回到起点,形成一个环;全数组一共被分成 gcd(n,k) 个互不相交的环。n=6,k=2 时,gcd(2,6)=2,所以有 [0,2,4] 和 [1,3,5] 两个环;n=5,k=2 时,gcd(2,5)=1,只有一个环,从 0 出发能一次走完 5 个元素。
count 变量的本质,就是在替你隐式统计"还差多少个元素没走完"。与其手动计算 gcd、按环去分组遍历,不如用一个计数器来兜底,代码更简单,也更不容易出错。这是我在这个坑里学到的最重要的一课:循环里的每个变量都必须有明确的语义,不要觉得"反正循环会结束"就忽略它。
6. 交给面试官之前:自检用例、答题顺序与变体引申
代码能跑只是第一步,面试和实际项目中真正的分水岭,是你有没有一套属于自己的验证套路,以及能不能应对题目的各种变形。
6.1 我每次写完都跑一遍的自检用例清单
下面这组用例几乎覆盖了这题的所有分支,我在本地会用一个简单的循环把它们全部跑一遍,任何解法过不了这组用例,我都不放心提交:
| 输入数组 | k | 预期结果 | 检查点 |
|---|---|---|---|
| [] | 任意 | [] | 空数组不能越界 |
| [1] | 任意 | [1] | 单元素数组不变 |
| [1,2,3,4,5,6,7] | 3 | [5,6,7,1,2,3,4] | 标准用例 |
| [1,2,3,4] | 2 | [3,4,1,2] | k 与 n 不互质,多环场景 |
| [1,2] | 3 | [2,1] | k 大于 n,必须取模 |
| [1,2,3] | 0 | [1,2,3] | k=0 提前返回 |
| [-1,-100,3,99] | 2 | [3,99,-1,-100] | 负数元素不受影响 |
| [1,2,3,4,5] | 5 | [1,2,3,4,5] | k 等于 n |
这里我最想强调的还是 [1,2,3,4] 配 k=2 这组:它是专门用来测试环状替换计数逻辑的,如果你写的版本没有 count 或者 count 位置不对,这组用例一定会让问题现形。
6.2 面试时的答题顺序,我建议按这个节奏来
第一步,先口述暴力解,让面试官知道你理解最朴素的做法;第二步,写辅助数组版本,展示你对下标公式 (i+k)%n 的掌握;第三步,主动指出这个方案占用 O(n) 空间,然后提出要优化到 O(1);第四步,写出三次反转,并顺手用一个小用例在纸上走一遍。
关于环状替换,我的建议是:可以作为延伸话题提一句"我还知道另一种原地方案,但三次反转在代码上更简洁",不必非要现场推导环的数量。面试官更看重的是你能不能清晰、果断地选出最简单可靠的方案,而不是把最炫技的方案背出来。
6.3 变体题怎么应对:掌握公式之后一通百通
这题的变体非常多,比如改成向左轮转、要求返回新数组、把数组换成链表、或者只问"轮转后某个位置的值是多少"。核心都是同一个公式:向右 k 位的新下标是 (i+k)%n,向左 k 位就是 (i-k+n)%n,链表版本则是先计算出新的头节点位置,再断链重接。底层的取模思维和"先定位新头"的判断逻辑完全一致。
我个人的做法是,在代码注释里把"为什么这么写"写进去,而不是只堆一堆变量名。比如三次反转的注释我会写"AB → reverse(B)reverse(A) → Breverse(A) → BA",这样下次复习时,扫一眼注释就能恢复完整思路,比自己重新推导一圈快得多。
这个习惯帮我省下了大量温习时间。一道 medium 题的价值,不在于你把标准答案背得有多熟,而在于你能不能从一次调错、一次边界漏判里,提炼出下次不会再犯的判断依据。轮转数组这题,恰恰就是个能给你这种收获的题目。