秋招那会儿我刷到“轮转数组”这道题,第一反应是:这有什么好考的?不就是把数组往右挪几步吗?真到白板上一写,才发现里面全是细节——往哪边转、转几步、要不要原地改、k 比数组长度大怎么办。后来我用最朴素的“临时数组法”把这道题吃透了,又顺带对比了反转法、环状替换,才算彻底搞明白这类题的套路。
这篇东西不是照搬题解,是我自己从“能跑”到“跑得明白”的全过程。核心围绕临时数组法展开,但也会把其它解法的优劣讲清楚,毕竟面试官大概率会追问一句“还能不能优化”。如果你正准备算法面试,或者刚开始刷题,这篇文章应该能帮你省不少时间。
1. 先搞清楚“轮转”到底在考什么:语义、方向与取模运算
1.1 轮转的语义:右移 k 步到底发生了什么
轮转数组,也叫旋转数组,最常见的描述是:给定一个数组,将数组中的元素向右轮转 k 个位置。注意是“轮转”不是“平移”,平移是元素整体挪过去之后,空出来的位置用 0 或别的值填充;轮转是把尾巴上被挤出去的元素绕到数组头部来,形成环形效果。
举个例子:
输入:nums = [1, 2, 3, 4, 5, 6, 7], k = 3 输出:[5, 6, 7, 1, 2, 3, 4]从结果上看,最后三个元素 5、6、7 被挪到了开头,原来的 1、2、3、4 依次后移。这个例子几乎出现在所有题解里,但真正动手写的时候,很多人会纠结一个问题:到底是把“最后 k 个元素挪到前面”,还是把“每个元素向右移动 k 个下标”?
这两句话等价,但思考路径完全不同。临时数组法用的是后者:对每个位置 i,它旋转后的新下标是(i + k) % n。理解了这个公式,代码就能写对一半。
1.2 方向、负数 k 与“k 大于长度”的三重陷阱
轮转方向通常默认是右轮转。如果题目说“向左轮转”,那映射公式就变成(i - k + n) % n。有些变体题会直接给负数 k 表示反向轮转,这时需要先统一方向:
- 右轮转 k 步,等价于左轮转
n - k % n步。 - k 为负数时,先右转?其实最简单的方法是统一成右轮转:
k = (k % n + n) % n。
k 大于数组长度是最容易踩的坑。比如nums = [1, 2, 3], k = 5,如果直接拿i + 5去算下标,会越界。正确做法是k = k % 3 = 2,因为轮转 3 次等于没转,轮转 5 次等于轮转 2 次。
取模运算在这里的语义,可以想象成一个环形时钟:下标只是在 0 到 n-1 的圈里打转。理解了“环形”这个概念,后面所有解法的推导都会顺畅。
1.3 从映射公式到可视化:为什么(i + k) % n是唯一的
建议第一次接触这道题时,拿出纸笔画一张图。坐标轴写原下标 0 到 n-1,再写一行新下标,然后一个萝卜一个坑地填:
原数组: 1 2 3 4 5 6 7 下标: 0 1 2 3 4 5 6 k=3 时: 下标 0 的新位置 = (0+3)%7 = 3 下标 1 的新位置 = (1+3)%7 = 4 ... 下标 4 的新位置 = (4+3)%7 = 0你会发现,原下标 4、5、6 的新位置分别是 0、1、2,正好对应最后三个元素跳到前面。这就在视觉上验证了公式。很多面试者背下了(i+k)%n,但被问“为什么是这个”时卡住。其实答案很朴素:因为数组下标是连续整数,轮转就是给每个下标加上一个偏移量,再加上模 n 把它拉回合法区间。
到这里,临时数组法的数学基础就建好了:我们不需要移动元素,只需要计算每个元素的“新家地址”,然后搬到新家。
2. 临时数组法的完整推导:为什么第一反应是开一个新数组
2.1 最直观的算法:每个元素直接放到新位置
临时数组法,简单说就是创建一个和原数组等长的辅助数组,遍历原数组,把每个元素放到辅助数组的正确位置上,最后再把辅助数组的内容拷贝回原数组。它的正确性几乎不需要证明:每个元素都去了它该去的地方,没有一个元素被覆盖丢失,也没有一个位置被重复填入。
伪代码大概长这样:
n = nums.length k = k % n temp = new int[n] for i = 0 to n-1: newIndex = (i + k) % n temp[newIndex] = nums[i] for i = 0 to n-1: nums[i] = temp[i]第一遍写的时候,很多人会问:为什么不是temp[i] = nums[(i - k + n) % n]?这两个写法其实等效,只是视角不同。前者是“从原位置出发,找新位置”;后者是“从新位置出发,找原位置”。我习惯用前者,因为遍历原数组时脑子不容易乱。
2.2 一个关键问题:需不需要处理 k 的符号和大小?
临时数组法的第一步必须是k %= nums.length。如果 k 恰好等于数组长度,取模后 k=0,遍历一遍拷贝回来,数组原样,白跑一趟。如果不取模,i + k可能超过 n,取模之后虽然结果也对,但中间可能出现溢出吗?在 Java/C++ 里,如果 k 是 int 且很大(比如超过 2^31),可能溢出。所以先取模是必要的,不是可有可无的优化。
负数 k 的处理,建议统一成:
k = ((k % n) + n) % n;这一行的意思:先取模到-n+1到n-1的区间,再加 n 后取模,保证非负。面试时最好主动提一句“如果 k 可能为负,我会这么处理”,会显得考虑问题周全。
2.3 复杂度分析:时间 O(n),空间 O(n)
临时数组法的时空复杂度非常清晰:
- 时间复杂度:遍历数组两次,一次写临时数组,一次拷贝回来,所以是 O(n)。
- 空间复杂度:额外开了一个等长数组,所以是 O(n)。
在很多算法教程里,O(n) 空间被看作“不够好”,因为这道题常见的进阶要求是“原地修改数组,空间复杂度 O(1)”。但我不觉得临时数组法是垃圾,它有一大堆优点:逻辑简单、不容易写错、可以轻松处理各种边界情况,而且在实际工程中,如果数组不大,多一份内存占用完全无所谓。
面试时候如果时间紧张,先用临时数组法跑通,再提优化,这是最稳的策略。反过来,如果一上来就憋反转法,憋半天写错了,反而丢分。
2.4 空间换时间的取舍:不是所有场景都需要“原地”
我见过不少刷题新手,被“原地”两个字吓到,觉得不 O(1) 空间就是错的。其实不然。真实业务里,数组轮转通常发生在内存里的小型数组上,比如图片像素的平移、列表的循环滚动,这些场景下临时数组的额外内存开销微乎其微,但代码的可读性大幅提升。
我自己的判断标准是:如果数组大小在几万以内,临时数组法完全可以接受;如果数组很大(比如千万级),或者内存极其紧张,才需要考虑原地方案。刷题的进阶要求是练习思路,不是每个题都必须用最省空间的解。
所以,别因为临时数组法空间复杂度高就觉得它“低级”。能把最简单的解法写得干净、正确、可扩展,也是一种能力。
3. 多语言实现与细节差异:从 C++ 到 Python 的写法习惯
3.1 C++ 实现:vector 操作与拷贝陷阱
C++ 中我一般这样写:
void rotate(vector<int>& nums, int k) { int n = nums.size(); if (n == 0) return; k = ((k % n) + n) % n; vector<int> temp(n); for (int i = 0; i < n; ++i) { temp[(i + k) % n] = nums[i]; } nums = temp; }这里有几个 C++ 细节:
nums.size()返回的是size_t,无符号类型,如果直接用(-k) % n这类表达式,要小心符号转换。所以我习惯先把 k 专门处理成非负值。nums = temp是深拷贝,会重新分配内存。如果不想重新分配,可以用nums.assign(temp.begin(), temp.end())。但刷题时直接赋值最简单。if (n == 0)一定要判断,否则vector<int> temp(n)虽然合法,但后面循环不执行,不会崩溃;不过如果 n=0 时 k 取模会除零,所以必须先判断。
3.2 Java 实现:System.arraycopy 与手动循环
Java 版本,我写过两种。第一种是纯手动循环:
public void rotate(int[] nums, int k) { int n = nums.length; if (n == 0) return; k = ((k % n) + n) % n; int[] temp = new int[n]; for (int i = 0; i < n; i++) { temp[(i + k) % n] = nums[i]; } System.arraycopy(temp, 0, nums, 0, n); }第二种是用System.arraycopy代替第二段循环。这个 API 是 JVM 底层实现的,效率比手写循环略高,但刷题时几乎感受不到差距。需要注意的是arraycopy的五个参数:源数组、源起始、目标数组、目标起始、长度。写错很容易运行时抛异常。
顺便提一个 Java 特有的坑:int 溢出。如果 k 是Integer.MAX_VALUE,直接i + k会变成负数,取模后下标就错了。所以先k %= n非常必要。这一点在 C++ 里同样存在,但 C++ 的 signed overflow 是未定义行为,更危险。
3.3 Python 实现:列表推导与切片的优雅与陷阱
Python 写起来最像伪代码:
def rotate(nums, k): n = len(nums) if n == 0: return k = ((k % n) + n) % n temp = [0] * n for i in range(n): temp[(i + k) % n] = nums[i] nums[:] = temp注意最后一行我用的是nums[:] = temp,而不是nums = temp。前者会原地修改列表,后者会让nums指向一个新列表,函数外部的引用不会变化。这是 Python 新手最容易踩的坑。
如果你只是想实现功能,Python 还有更简洁的切片旋转:
nums[:] = nums[-k:] + nums[:-k] if k else nums但切片法本质也是创建了新列表,只不过把细节藏起来了。面试时如果你这么写,面试官可能会问“底层是怎么实现的”,所以切片法适合工程,临时数组法适合讲解。
3.4 Go 实现:make、copy 和 append 的注意事项
Go 的惯用写法:
func rotate(nums []int, k int) { n := len(nums) if n == 0 { return } k = ((k % n) + n) % n temp := make([]int, n) for i := 0; i < n; i++ { temp[(i+k)%n] = nums[i] } copy(nums, temp) }有一个小细节:make([]int, n)会初始化 n 个零值元素,所以后面的赋值是直接覆盖,没问题。如果用var temp []int然后append,就要注意初始长度。copy(nums, temp)是内建函数,目标切片和源切片长度可以不同,它拷贝 min(len(dst), len(src)) 个元素。这里长度相同,安全。
Go 的切片本身就是引用类型,但函数内的nums[i] = ...能修改底层数组,nums = temp却不会影响外部,所以一定要用copy或者循环赋值。
3.5 各写法的共性思考
不管什么语言,算法骨架完全一致:取模、建临时数组、循环赋值、拷贝回去。区别只在于语法糖和内存管理方式。我建议至少掌握两种语言的写法,因为面试官可能会让你用自己最熟的语言写,但思路必须能迁移。
一个额外提醒:在 C/C++/Java/Go 里,如果 k 很大,k % n这一步可以有效避免整数溢出;在 Python 里整数无限大,不会溢出,但取模依然必要,因为不取模数组访问会越界。
4. 我在实际提交中反复踩过的坑:边界、取模与数组越界
4.1 忘记对 k 取模导致的诡异结果
我第一次提交这个题的临时数组解法时,写的是:
temp[(i + k) % n] = nums[i];k 没有提前取模,表面看也能过,因为(i+k)%n仍然能算出正确下标。但当时我遇到一个测试用例是nums=[1,2], k=3,手动推导:i=0 -> 3%2=1,i=1 -> 4%2=0,结果 temp = [2,1],正确。所以这个写法不算错。
但后来我改成先取模再算下标,纯粹是因为代码可读性和安全性更好。如果 k 很大,比如k = 2147483647,在某些语言里i + k可能溢出成负数,负数取模结果还是负数,数组访问就越界了。所以“先取模”和“后取模”在大多数测试用例下等价,但在极端输入下有本质区别。
4.2 数组长度为 0 和长度为 1 的边界
长度为 0 的数组,任何轮转都没意义。如果你在没判断长度的情况下执行k % n,会直接除零异常。长度为 1 的数组,k 随便多大,轮转后还是原样。有经验的刷题者会在函数开头就写上:
if (nums == null || nums.length <= 1) return;顺手连空引用也一并处理了。这个习惯在面试中很加分,因为你展示了对异常输入的警觉。
4.3 把临时数组的大小写成 k,导致下标越界
这个错误我见过很多次。有同学想“只需要保存后面 k 个元素”,于是写int[] temp = new int[k],然后想把前 n-k 个元素往后挪。这不是临时数组法,是另一种思路,但很容易和临时数组法混淆。
临时数组法要求“完整等长数组”,因为每个元素都要在新数组里占一个位置。如果你只开 k 大小的数组,你就必须额外处理哪些元素需要移动,逻辑就复杂了。反正空间复杂度都是 O(n),不如开满,省心。
4.4 原地数组复制时用错引用
在 Python 里,nums = temp不会修改原列表;在 Java 里,nums = Arrays.copyOf(temp, n)同样不会修改调用者传入的数组引用。要原地修改,必须逐元素赋值或者用系统拷贝 API。
这个坑在本地调试时很难发现,因为你在函数内部打印nums看着是对的,但回到 main 函数再打印,发现没变。遇到这种情况,别怀疑算法,先检查“到底改没改到原数组”。
4.5 测试用例设计:别只验证题目的例子
做了一个小时的题,最后提交却超时或越界,很多情况下是因为自测用例太单一。我建议至少测这几组:
- 数组长度为 0、1。
- k 等于 0、等于 n、等于 n 的倍数。
- k 是负数(如果题目允许)。
- 数组元素包含重复值、负值、最大值最小值。
- n 为奇数/偶数。
特别是 k 等于 n 的倍数时,如果提前取模,一切正常;如果不取模,虽然结果也对,但总会多跑几遍无意义循环,临时数组法受影响不大,其它解法可能因为循环链判断错误而出问题。
4.6 临时数组法和“反转法”的坑别混在一起
说到轮转数组,很多资料会同时提到三种解法:临时数组法、反转法、环状替换。这三种解法的边界处理和陷阱完全不同。临时数组法的坑主要在“引用传递”和“取模”;反转法的坑在“反转三段的位置”;环状替换的坑在“需要记录起始点和计数”。
如果你在同一个面试里先写了临时数组法,被追问“能不能优化”,再写反转法,请务必在脑子里切换到另一套逻辑。我见过有人把临时数组法的(i+k)%n硬套到反转法里,结果自然是错的。
5. 临时数组法 vs 反转法 vs 环状替换:什么时候该选谁
5.1 三种解法核心思想概览
临时数组法我们已经很熟悉了。其余两种我简单概括一下:
- 反转法:先把整个数组反转,再反转前 k 个,再反转后 n-k 个。利用了反转的可逆性质,原地完成,时间 O(n),空间 O(1)。
- 环状替换:从某个起点开始,把元素放到它该去的位置,被替换的元素继续放到下一个位置,形成若干条环。每移动一个元素就计数,直到所有元素移动完,时间 O(n),空间 O(1)。
用一个表格对比:
| 解法 | 时间复杂度 | 空间复杂度 | 是否原地 | 代码可读性 | 出错点 |
|---|---|---|---|---|---|
| 临时数组法 | O(n) | O(n) | 否 | 高 | 引用传递、取模 |
| 反转法 | O(n) | O(1) | 是 | 中 | 反转边界、k 为 0 |
| 环状替换 | O(n) | O(1) | 是 | 低 | 环的计数、gcd 推理 |
5.2 反转法为什么是面试官最想听的优化
反转法的核心代码特别短:
reverse(nums, 0, n-1) reverse(nums, 0, k-1) reverse(nums, k, n-1)前提是先k = k % n。原理是:整体反转后,数组元素完全倒序,然后再分别反转两段,就能让后 k 个元素回到头部。很多人不理解为什么三次反转能实现轮转,其实可以类比一串珠子:先整体翻转,相当于把珠子顺序倒过来;再把前一段和后一段各自翻转,相当于把原来最后一段“拧”到开头。
这个解法的空间优势很明显,但有一个隐藏的边界:如果 k=0,第二段反转的起点和终点重合(reverse(nums, 0, -1)之类),实现反转函数时要能正确处理空区间。另外,反转法对负数 k 的处理不如临时数组法直观,需要先统一 k 的符号。
5.3 环状替换的 tricky 点:为什么不能只循环一次
环状替换是最容易写错的原地解法。它的基本思路:从下标 0 开始,把 nums[0] 移到下标(0+k)%n,再把原来那个元素移到它的目标位置,如此循环,直到回到起点。然后再从下标 1 开始,重复这个过程。
但问题来了:如果 n 和 k 不互质,可能会出现多个环。比如n=6, k=2,从 0 出发:0 -> 2 -> 4 -> 0,只处理了偶数下标,奇数下标永远不会被访问。所以必须用一个计数器,统计已移动元素个数,当等于 n 时停止。这是环状替换最容易错的地方。
相比之下,临时数组法完全不需要考虑这些,因为新数组的每个位置都是干净的,不存在覆盖问题。
5.4 实际工程与面试场景的选型建议
结合我的实际经验,给你一个选型策略:
- 如果面试官没有明确要求 O(1) 空间,优先写临时数组法,快速、稳定、易解释。
- 如果追问“能不能不用额外空间”,立刻切换反转法,因为原理简单且代码短。
- 如果面试官非要你写环状替换,一般是想考察你对“循环链”的理解,写之前先想清楚计数器。
在真实业务代码里,我反而更少用原地修改,因为原地修改会改变入参,容易引发调用方预期之外的副作用。临时数组法返回一个新数组,或者拷贝回原数组,语义更清晰。所以刷题归刷题,工程归工程。
6. 从这道题延伸出去:轮转数组的变体与真实应用
6.1 左轮转与右轮转的相互转换
很多场景下你会遇到“左轮转 k 位”的描述。左轮转 k 位等价于右轮转n - k % n位。比如[1,2,3,4,5]左转 2 位得到[3,4,5,1,2],右转 3 位也能得到同样结果。
这个转换在写代码时很实用:不管题目怎么描述,先统一成右轮转,然后继续用临时数组法。如果面试官问“为什么能转换”,你可以用数组下标映射解释:左转 k 的新下标是(i-k+n)%n,右转 n-k 的新下标是(i+n-k)%n,两者在模 n 意义下完全相同。
6.2 二维数组/矩阵的轮转:增强版的临时数组法
轮转数组的思想可以推广到二维矩阵旋转。比如顺时针旋转 90 度,每个元素(i, j)会到(j, n-1-i)。如果你不想原地旋转,完全可以开一个同样大小的新矩阵,逐元素映射。这就是临时数组法在二维场景的直接应用。
我曾在一个图像处理 Demo 里处理过图片旋转,当时用的就是临时矩阵法。虽然内存翻了一倍,但代码写起来非常清晰,后续加滤镜、裁剪也方便。真要做高性能图像处理,才会考虑原地旋转的分块算法。
6.3 循环队列与滚动数组:轮转思想的工程化
轮转数组的本质是“环形下标”。这个概念在很多底层数据结构里都会出现:循环队列的队头队尾指针移动时,都要对容量取模;滚动日志文件、环形缓冲区、滑动窗口的最大值等问题,底层都是同一套取模逻辑。
所以,做这道题的价值不只是背一个(i+k)%n,而是理解“环形下标”这个思维模型。以后遇到“数据流持续写入、只保留最近 N 条”的需求,你自然会想到用固定大小数组加头尾指针,而不是反复申请新数组。
6.4 要求返回新数组而非原地修改时
有些变体题不会要求你原地修改,而是直接返回一个新数组。这时临时数组法的代码可以更简洁:直接把元素放到新数组的下标(i+k)%n,返回新数组。不需要第二段拷贝逻辑。
我个人在写这种版本时,会顺手把边界情况也处理掉,比如返回空数组而不是 null。面试时“返回什么”一定要先和面试官确认,避免用完你写的函数后,发现原数组没变,或者返回值不符合预期。
6.5 一道题学到什么程度算“学透了”
最后说说我自己衡量是否掌握一道题的标准。不是看一眼题解然后默写,而是能做到以下几点:
- 不看任何参考,十分钟内写出临时数组法。
- 能说出
k = k % n的必要性,并举出极端例子。 - 能流畅写出反转法,并说明为什么三次反转是对的。
- 知道环状替换需要计数器,能解释 gcd 的作用。
- 能把这道题和循环队列、矩阵旋转联系起来。
如果这些都能做到,那么这道题对你来说就不只是一个题,而是一个可以迁移的思维工具。刷题最怕的是背题解,最值得的是理解“为什么”。临时数组法作为最朴素的解法,正是理解这道题最合适的起点。每次写它,我都会提醒自己:先搞明白要到哪里去,再决定怎么去。