☰
轮转数组原地算法全解:从暴力到三次反转的复杂度优化
2026/10/11 14:13:56 网站建设 项目流程

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内层轮次curprev 缓存值next写入 nums[next]数组状态
010121[1,2,1,4]
022303[3,2,1,4]
111232[3,2,1,2]
123414[3,4,1,2]
212101[1,4,1,2]
220323[1,4,3,2]
313212[1,2,3,2]
321434[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 题的价值,不在于你把标准答案背得有多熟,而在于你能不能从一次调错、一次边界漏判里,提炼出下次不会再犯的判断依据。轮转数组这题,恰恰就是个能给你这种收获的题目。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询