我最早接触数组旋转这题,是在一次模拟面试里被问到的。当时我第一反应就是“这题简单”,直接双层循环搬数据,结果面试官追问了一句:“如果k接近一百万,而数组只有几十个元素,你的方案还成立吗?”我当时人懵了一下,回来认真把四种主流解法逐个吃透之后才发现,这道题考的根本不是能不能写出答案,而是你对“原地操作”“循环结构”“时间空间权衡”的理解有多深。
说它是算法题里的“万金油”一点不过分。数组旋转在业务代码里也大量出现:环形缓冲区的读写、进程时间片轮询、日志文件滚动、图像像素的几何变换,底层都能看到这道题的影子。本文就用Java把数组旋转问题彻底讲透,从暴力法一直讲到最优的三次反转法和环状替换法,包括每种解法为什么能成立、边界条件怎么处理、面试时怎么递进回答,以及我实际提交代码时踩过的各种坑。适合正在准备技术面试、刚入门算法、或者工作中需要做循环数据处理的朋友。
1. 数组旋转到底在考什么:题目还原与隐藏约束
1.1 三种常见表述,本质是同一个问题
数组旋转在不同教材和在线评测平台上,最常见的表述有三种:把数组向右轮转k个位置、把数组循环右移k位、返回数组右移k位后的结果。别看说法不一样,数学定义完全一致。
给定长度为n的整数数组nums和一个非负整数k,要求把数组中的元素整体向右移动k个位置,末尾的元素循环补到开头。用公式表达就是:
code复制new[(i + k) % n] = nums[i]
这里new是旋转后的数组,i是原数组下标。理解这个公式很重要,后面所有高效解法,本质上都是在想办法减少搬运次数或避免额外空间。
还有一类题目是“左移k位”,公式变成new[(i - k + n) % n] = nums[i]。左右移本质是同一个操作:左移k位,等价于右移(n - k % n) % n位。我见过不少人在这里绕晕,建议先固定一个方向练透,再推导另一个方向。
1.2 藏着掖着的两个约束:原地与不占额外空间
数组旋转这道题,如果只要求“得到结果”,最简单的方式是开一个等长的新数组,遍历一遍按(i + k) % n放到新位置,时间复杂度O(n),空间复杂度O(n),五分钟就写完。但很多刷题网站和面试官都会在后面加一句:请使用原地算法,空间复杂度O(1)。
“原地”的意思是,只能使用常数级别的额外空间,不能开一个跟原数组规模成正比的辅助数组。这一条限制,直接把“临时数组搬运法”判了死刑,逼着你去想怎么在同一个数组里把元素“倒腾”到位。这也是三次反转法和环状替换法存在的意义。
我对这个隐藏约束的理解是:题目表面上在考“轮转逻辑”,实际上考的是对数组下标映射关系的理解,以及对操作顺序的设计能力。如果你只能靠额外数组,说明你还没有把(i + k) % n这个映射关系吃透,面试官自然会继续往深了追问。
1.3 边界条件清单:先把最容易漏的三个提前封死
数组旋转问题的边界条件,翻来覆去就三个,但我在实际刷题过程中发现失分最多的就是这里。
第一个是数组长度等于0或1。空数组无需旋转,长度1的数组无论k是多少,结果都是它本身。这两个情况处理起来很简单,开头加一个if判断即可。
第二个是k等于0,或者k刚好是n的整数倍。旋转n位等于没旋转,旋转2n位也没变化。这里引出一个核心操作:无论你用什么解法,几乎都要先执行k = k % n。举个例子,n = 3,k = 7,右移7位等价于右移1位,因为先转完一轮3位后数组回到原样,剩下只有1位有效位移。
第三个容易被忽略的情况是k远小于n但循环次数过多,比如n = 100000,k = 99999。如果你用暴力法做99999次整组搬移,数据量一大就直接超时。这也是我在文章开头提到的那次模拟面试里,面试官让我当场意识到的问题。
边界问题看着琐碎,但处理不好会直接影响代码的鲁棒性。我现在的习惯是:写完题解先自己列一遍n=0、n=1、k=0、k=n、k>n这五种情况,再提交到评测系统,这样能省下很多调试时间。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 暴力逐位搬移——能跑通,但别拿它当最终答案
2.1 直觉解法的实现与复杂度账
大多数人第一次看到数组旋转,凭借直觉想出来的方案通常是:循环k次,每次把数组整体向右移动一位。每次移动的过程是,把最后一个元素暂存起来,然后从后往前把每个元素往后挪一位,最后把暂存的元素放到开头。
用Java写出来长这样:
java复制public void rotate(int[] nums, int k) {
int n = nums.length;
if (n == 0) {
return;
}
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;
}
}
这个解法正确性没问题,但复杂度很成问题。外层循环执行k次,内层循环每次移动n个元素,总操作次数是k * n。如果n = 1000,k = 500,就是50万次操作,看着还能忍;但如果n = 10万,k = 99999,那就接近100亿次操作,几乎必然超时。
2.2 面试官听到O(kn)后的判断逻辑
我对这类问题的理解,先说一个可能扎心的判断:面试官听到O(kn)这个复杂度,通常不会直接说“错”,但会在心里给你打上一个“对数组操作理解还停留在表面”的标签。
原因在于,暴力法的慢不是绝对值上的慢,而是它的操作次数完全没有利用到“旋转”这个操作的重复性规律。你明明做一次k % n取模就能把问题规模缩小到一个周期内,暴力法却老老实实地把一整轮循环的搬运次数全部执行完。尤其是当k > n时,大量操作都在做无用功,数组被你来回倒腾了好几圈,最终效果却和只操作k % n次一模一样。
还有一点,暴力法的内层循环是从后往前搬运,这种写法一旦边界控制不好,很容易在j = 0的位置丢失元素。我见过A同学第一次提交就挂在“数组长度为1”这个用例上,原因就是内层循环根本没有执行,但last变量又取了nums[0],逻辑上绕了一圈返回原样,虽然结果碰巧对了,但思路是混乱的。
2.3 暴力解法的合理使用场景
话又说回来,暴力法并不是一无是处。它最大的价值有两个:第一,用于理解“右移一位”的底层动作到底是什么,这是推导其他解法的基础;第二,在n和k都很小,或者你只是想快速验证一个自定义测试用例时,暴力法依然是最直接的调试工具。
不过,如果面试时你真的先从暴力法说起,建议把它当作引入思路,然后立刻跟上优化方向。一个比较自然的递进说法是:“我能用O(n)时间、O(n)空间的临时数组一步到位,也能用三次反转做到O(1)空间,我先说一下最简单的临时数组方案,再讲原地做法。”直接提交暴力法,在严格判题环境下往往会被评价为“未充分利用数组轮转的结构性规律”。
我个人的建议是:私下练习时,一定要先把暴力法写一遍,确保自己能徒手写出清边界正确的逐位搬移代码。这样你后面理解三次反转法的每一个动作,都会有“原来它节省了这些冗余操作”的实感。
3. 三次反转法:用“翻转”抵消两次逆序
3.1 核心观察:一次整体反转把前后段换位
三次反转这个方法,我第一次看到的时候觉得“这也太取巧了”,但等自己推导一遍才发现,它的本质就是通过反转操作调整元素顺序,是一个把数学规律转化为代码的典型例子。
先看一个例子。假设数组是{1, 2, 3, 4, 5, 6, 7},n = 7,k = 3,期望结果是{5, 6, 7, 1, 2, 3, 4}。
第一步,整体反转整个数组,得到{7, 6, 5, 4, 3, 2, 1}。你去观察这个结果,会发现原数组的后k个元素(5、6、7)已经跑到数组前面了,但顺序是反的。原数组的前n-k个元素(1、2、3、4)被挤到后面,顺序也是反的。也就是说,一次整体反转完成的是“前后段换位”,但各段内部的顺序错了。
第二步,把数组前k个元素单独反转。{7, 6, 5}变成{5, 6, 7}。
第三步,把数组后面的n-k个元素单独反转。{4, 3, 2, 1}变成{1, 2, 3, 4}。
三步做完,数组变成{5, 6, 7, 1, 2, 3, 4},正好是目标结果。
3.2 为什么恰好是三次,以及顺序有没有讲究
很多人学这个方法时会背口诀:先反转整体,再反转前k个,再反转后n-k个。但我觉得更重要的是理解“为什么三次反转能达成目标”。
我习惯用一个更直观的解释:反转操作会“逆置”一段元素的顺序。整体反转相当于把前段和后段的位置互换,但同时也把前后段内部的顺序都倒了过来。接下来对前段再做一次反转,前段内部顺序又恢复;对后段再做一次反转,后段内部顺序也恢复。两次“逆置”互相抵消,就像把一段文字先反过来,再局部正过来,最终达到整体换位、局部保序的效果。
那顺序能不能换?比如先反转前k个,再反转后n-k个,最后反转整体?你可以自己推一下,结果其实也是对的。因为反转操作是“可组合”的,任何顺序只要保证三个区段各被反转一次,最后都能得到目标数组。但这里我不推荐换顺序,原因有两个:一是“整体→前k→后n-k”的推导过程最直观,面试时讲起来不容易乱;二是很多参考代码都遵循这个顺序,你用标准顺序写,万一中途出bug,排查起来有迹可循。
严谨一点说,这个结论可以用“段内逆序抵消”来证明。设数组写为A + B,其中A是前n-k个元素,B是后k个元素。整体反转后变成reverse(B) + reverse(A),再反转前k个得到B + reverse(A),再反转后n-k个得到B + A,正好是原数组的左右段互换结果。
3.3 Java实现与reverse函数的边界细节
Java代码写起来很简洁,关键点全在reverse函数的边界控制上。我推荐用左闭右闭区间,也就是reverse(nums, start, end)处理的是从start到end这一段,包括两端的下标。
java复制public void rotate(int[] nums, int k) {
int n = nums.length;
if (n == 0) {
return;
}
k = k % n;
if (k == 0) {
return;
}
reverse(nums, 0, n - 1);
reverse(nums, 0, k - 1);
reverse(nums, k, n - 1);
}
private void reverse(int[] nums, int start, int end) {
while (start < end) {
int temp = nums[start];
nums[start] = nums[end];
nums[end] = temp;
start++;
end--;
}
}
我在这里要说一下最容易踩的坑:第二次反转的区间是[0, k - 1],第三次反转的区间是[k, n - 1]。如果你把第二次写成[0, k],第三次写成[k + 1, n - 1],当k恰好等于n的一半时,边界会重叠或漏元素,结果完全错乱。我记得第一次写这个方法时,就是记混了“前k个元素”到底是下标0到k-1还是0到k,卡了好一阵。
还有一个容易忽略的点:k取模之后如果等于0,可以直接return。虽然不return也能通过,因为反转三次后数组会回到原样,但白白做了三次O(n)的遍历,等于浪费了三个完整循环的时间。加一个判断,代码更干净,也体现了对边界条件的敏感。
4. 环状替换法:把数组当作循环链条来搬
4.1 从移动路径看环的数量:gcd(n, k)的来历
三次反转法很优雅,但如果你面试时先说出这个方法,面试官很可能会追问一句:“还有没有别的原地做法?”这时候就需要亮出环状替换法。这个方法是数组旋转问题里“最绕”但“最数学”的解法。
环状替换法的核心思路,是把每个元素直接送到它最终该去的位置:下标i的元素应该去(i + k) % n。你可以想象数组下标围成一个环,从下标0开始,0位置的元素搬到k位置,k位置的元素搬到2k位置,2k位置的元素搬到3k位置……这个链条一直往下走,直到某个时刻回到了起点。
但这里有个微妙的问题:并不是从0出发走一条链就能覆盖全部元素。举例来说,n = 6,k = 2,从0出发的路径是0 → 2 → 4 → 0,只覆盖了三个下标;你还需要从1出发,路径是1 → 3 → 5 → 1,才能覆盖另外三个下标。换句话说,整个数组的替换动作分成多个互不相交的“环”。
环的数量是多少?从0出发走回起点的步数是n / gcd(n, k)。而从每个起点都能覆盖一个环,总的环数就是gcd(n, k)。关于这个结论,你可以用数论里的循环群知识去理解,也可以直接记忆:当n和k互质时,只需要从一个起点出发就能完成全部替换;当n和k不互质时,需要从gcd(n, k)个起点分别启动替换链条。
4.2 计数式实现的正确姿势
环状替换法的实现有两种思路。第一种是预先算出gcd(n, k),然后外层循环遍历gcd个起点;第二种是用一个计数器统计已归位的元素个数,当计数等于n时结束。我更推荐第二种,因为不用额外写gcd函数,逻辑也更通用。
代码是这样的:
java复制public void rotate(int[] nums, int k) {
int n = nums.length;
if (n == 0) {
return;
}
k = 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);
}
}
这里有几个关键细节值得单独说一下。
每个环用一个do-while循环处理,因为至少要移动一次,不能在判断条件时就把循环跳过。count的作用是防止重复处理已经被替换过的环。prev变量保存的是“当前要放入下一个位置的元素”,而temp用于取出下一个位置原来的值,相当于在链条里“接力搬运”。
如果你选择用gcd写法,外层循环从0到gcd(n, k) - 1,内层同样是do-while,每种写法都能跑通。但count写法的好处是不需要引入额外的数论知识,对初学者更友好,代码里还自动处理了“多个环”的情况。
我把两种写法对比一下,你可以根据自己的偏好选择:
| 维度 | gcd循环写法 | count计数写法 |
|---|---|---|
| 需要额外数学知识 | 需要求最大公约数 | 不需要 |
| 环数控制 | 显式控制,理论清晰 | 隐式控制,代码更通用 |
| 代码可读性 | 初见难懂 | 相对直观 |
| 出错风险 | gcd边界容易写错 | 循环嵌套容易漏count |
4.3 为什么这个方法面试时容易翻车
环状替换法是四种解法里面试翻车概率最高的,我自己也在这个方法上栽过跟头。
第一个坑是死循环。如果你把do-while循环的终止条件写错,比如写成while (current != start)而不是do...while,当环的长度恰好是1的时候,比如n = 1,循环体一次都不执行,count永远小于n,外层for循环就越界,直接产生错误结果。这也是为什么数组长度1的情况要在最前面特判掉。
第二个坑是计数器的位置。count++必须放在“完成一次元素搬运”之后,不能放在while判断条件里,更不能漏掉。一旦count算错,外层循环会多启动几个起点,把已经归位的元素又搬一遍,结果肯定乱套。
第三个坑是理解层面。很多人写完代码能过测试,但被问到“为什么起点从0开始就能保证所有环都被访问过”时答不上来。答案其实在于外层循环的start每次自增1,当count < n时,它总能找到下一个未被处理的环的起点。因为每个环内部的下标集合是互不相交的,起点start必然落在某一个尚未完成替换的环上。
我在准备面试时给这个方法准备了一个固定话术:先用小数组画一下移动路径图,再讲“环”的概念,最后说count控制总移动次数。画图比背代码有用得多,面试官看到你能在白板上把0 → 2 → 4 → 0画出来,比你把代码默写出来更加分。
5. 四种解法的复杂度横向对比与扩展追问应对
5.1 一页表格看清四种解法
数组旋转的常规解法就四种:暴力逐位搬移、临时数组搬运、三次反转、环状替换。把它们的核心指标放在一起看,选择思路会清晰很多。
| 解法 | 时间复杂度 | 空间复杂度 | 是否原地 | 实现难度 | 适用场景 |
|---|---|---|---|---|---|
| 暴力逐位搬移 | O(k*n) | O(1) | 是 | 低 | 小规模数据、逻辑验证 |
| 临时数组搬运 | O(n) | O(n) | 否 | 极低 | 无空间限制,最直观 |
| 三次反转 | O(n) | O(1) | 是 | 中 | 面试与工程首选 |
| 环状替换 | O(n) | O(1) | 是 | 高 | 空间敏感且要求一次到位 |
面试时,我的建议回答路径是:先说临时数组方案作为baseline,因为它最容易理解,也展示了“用映射关系直接落位”的思路;然后主动提出“我能用三次反转把空间降到O(1)”,并快速写出代码;如果面试官还有兴趣,再补一句“环状替换也能做到O(1)空间,它利用的是gcd环结构”。这样的递进既展示了知识广度,又不会因为直接祭出最优解而让面试官觉得你是背题。
实际工程里,我个人最常用的是三次反转法。原因有两个:一是代码量少,review起来一眼能看懂;二是它不依赖gcd计算,即使别人没听过这个解法,看到三次reverse的语义也容易理解。环状替换虽然理论优美,但在真实业务代码里,它的“接力搬运”过程可读性不如三次反转,维护成本略高。
5.2 面试官常问的三个变体问题
第一个变体是左移k位。左移的公式是new[(i - k + n) % n] = nums[i],如果你已经写了右移版本,最省事的转换是:左移k位等价于右移(n - k % n) % n位。只要在入口处做一次转换,三次反转的代码一行都不用改。
第二个变体是“如果k是负数怎么办”。这个问题其实是在考取模运算的语义。Java里负数取模结果仍然是负数,你需要先做归一化处理,把负数k转换为正数位移。比如k = -3,可以转换成k = (k % n + n) % n,然后再执行原有逻辑。我建议在方法入口统一处理,而不是在每个解法里分别处理。
第三个变体是“如果数组特别大,内存受限怎么办”。比如n = 10亿,k = 10万,这时连O(1)的严格定义都可能不现实,因为三次反转要读遍整个数组。面试里遇到这种扩展问题,更倾向考察你是否知道“分块搬移”的思想:把数组分成若干块,每次只加载一块到内存,循环替换。这个思路本质上仍然是环状替换,只不过在环上分段处理。
5.3 从这道题延伸出的思想在其他题里的身影
数组旋转的底层思想,在好几个高频考点里都有影子,我说几个我实际遇到过的情况。
字符串轮转判断,判断s2是不是s1的轮转结果,经典做法是判断s1 + s1是否包含s2。这里的数学基础就是“轮转后的字符串必然是拼接串的子串”,和数组旋转公式new[(i + k) % n]背后的周期思想一脉相承。
循环队列的读写指针推进,也是数组旋转的应用。队列的队头、队尾指针用(ptr + capacity) % capacity来移动,本质上就是下标在一个环上走,和环状替换法里的next = (current + k) % n如出一辙。
图像旋转问题,把矩阵顺时针旋转90度,常用的解法是“先按对角线翻转,再按行翻转”。这本质上也是两次反转操作的组合,跟数组三次反转的“逆转后再逆转”是同一个套路。
把这些关联点放在一起看,你会发现数组旋转不是一个孤立的知识点,而是“环状下标映射+反转操作”这两个底层工具的综合演练。搞懂这道题,等于同时复习了取模运算、数组操作、双指针、数学推导四条知识线。
6. 提交与实战中我反复踩过的坑
6.1 k忘记取模:最经典的运行时错误
我第一次提交三次反转法代码时,有一个用例直接报错,排查了半天才发现是忘记处理k > n的情况。当k = n + 1时,三次反转的区间变成了[0, n],而reverse函数里的end已经越界,或者反转了一个根本不存在的区间,抛出数组下标越界异常。
更隐蔽的问题是,当k很大但n很小时,取模前后的结果完全不同。比如n = 4,k = 100,100 % 4 = 0,理论上不需要任何操作;如果忘了取模,三次反转会把数组反转再反转再反转,白白浪费O(n)时间不说,边界还可能出错。从此我把k = k % n写在所有解法的最前面,这个习惯一直保留到现在。
6.2 反转区间边界±1的混乱
三次反转法的边界是我见过最多人写错的地方,我自己也不例外。细节就是前面提过的第二次反转区间是[0, k - 1],第三次是[k, n - 1]。
我后来发现一个有效的自查方法:用n = 2, k = 1这样的最小用例走一遍代码。数组{1, 2}右移1位应该是{2, 1}。三次反转分别是整体反转{2, 1},反转前1个元素还是{2, 1},反转后1个元素还是{2, 1},结果正确。如果哪个边界写成[0, k]或[k + 1, n - 1],这个最小用例马上会暴露问题。
再分享一个我总结的口诀:前k个元素的合理下标范围一定是0到k-1,因为数组下标从0开始;后n-k个元素的起点一定是k。只要把“数量”和“下标”分开想清楚,边界就永远不会错。
6.3 把右移当成左移实现
还有一个很蠢但很容易犯的错,就是把右移的方向搞反。我见过有人把整体反转后的第二次反转区间写成[n - k, n - 1],也就是把“后k个元素”反转而不是“前k个元素”,结果输出的数组顺序完全不对。
这里分享一个判断技巧:右移的结果,原数组最后k个元素会跑到数组最前面,所以前k个位置放的一定是原来的尾部元素。你每次写完可以拿{1, 2, 3, 4, 5}配k = 2过一遍,右移结果是{4, 5, 1, 2, 3},左移结果是{3, 4, 5, 1, 2},跑通了再提交。
6.4 业务场景里数组旋转的省内存实践
最后说点工作里的体会。实际写业务代码时,几乎不会直接调用“右移数组”这个操作,但会经常碰到等价场景。比如日志文件滚动,保留最近N份日志,新日志写入时把最老的丢掉,这本质上就是数组旋转后截断;再比如环形任务列表,每个时间片结束后指针移动到下一个任务,指针的推进方式就是环状替换里的下标更新公式。
在这种场景里,我最大的教训是:不要为了“原地”而原地。如果业务允许你用额外数组,临时数组法反而是最好维护的,因为逻辑最直白。原地算法的主要价值在于小内存设备和面试场景,工程上可读性优先。当然,如果你维护的是一个长时间运行的低内存服务,三次反转这种O(1)空间的操作会明显减少GC压力,具体用哪个还是得结合环境判断。
数组旋转这个题目,说难不难,说简单也不简单。它最好的学习方式就是把这几种解法都手写一遍,再各自跑一遍边界用例。等你把三次反转和环状替换都吃透了,后面再遇到“轮转”“循环”“反转拼接”这类字眼的题目,思路会开阔很多。
