有些人可能觉得很奇怪,一道数组轮转题怎么会被反复拿出来讲十几年。但如果你把力扣189和2010年408那道代码题放在一起看,会发现它们几乎就是同一道题穿了不同的马甲。轮转数组这个操作,表面上只是把元素挪个位置,实际上是检验一个人对数组下标、取模运算、还有原地操作理解程度的试金石。
我不止一次在评论区看到有人说“这题用临时数组一下就写完了,为什么要整什么反转法”。确实,工作里写业务代码你完全可以new一个新数组,没人会拦你。但408不会惯着你,LeetCode的进阶要求也不会惯着你。当空间复杂度被限制成O(1),当考场上要求你写时间上尽可能高效的原地算法时,三次反转和环状替换就成了两种绕不开的标准兵器。
这篇文章就专门来拆这道题,覆盖两种主流原地解法、暴力法和额外数组法的踩坑体验,再回头对照一下2010年那道408真题,把左右移位的关系彻底捋清楚。无论你是刷力扣找手感,还是准备408正在背代码,看到最后应该都能有收获。
1. 为什么一道数组轮转题能穿越十来年
先说一个可能出乎你意料的事实:力扣第189题的英文名是Rotate Array,国内翻译成“轮转数组”,而2010年408那道真题描述里写的则是“循环左移若干位置”。一个向右,一个向左,但底层逻辑一模一样。
1.1 力扣189到底在考什么
力扣的原题描述很短:“给定一个整数数组nums,将数组中的元素向右轮转k个位置,其中k是非负数。”
题目举的例子是nums = [1,2,3,4,5,6,7], k = 3,输出[5,6,7,1,2,3,4]。
这个“向右轮转”的动作,你可以把它想象成把数组排成一个环,所有元素整体往右挪动k格,超出数组末尾的元素绕回开头。如果你选择粗暴地循环k次、每次把整个数组右移一位,那这道题就退化成O(nk)的级别。当n和k都很大的时候,基本只能等着超时。
所以它真正想考的是两件事:第一,你能不能找到一次移动就位的规律;第二,你能不能把额外空间压下去。LeetCode还专门留了一句进阶要求:尝试使用空间复杂度为O(1)的原地算法解决这个问题。
1.2 和408真题之间只差一个方向
2010年408那道题的过程大致是这样的:把n个整数保存在一维数组中,设计一个时间和空间两方面都尽可能高效的算法,把序列循环左移p个位置。题中给了样例,原序列1到9,左移3位后变成4到9然后接上1到3。
把这两道题放一起看,方向相反不影响本质。所谓左移p位,其实就等价于右移n-p位。比如n=9,p=3,左移3位等价于右移6位。你写出右轮转函数,把参数传成n-p,左移立刻完成。
我后来在复盘408真题解法时,最大的感触就是:如果一个人能先把力扣189想透,那408这道大题几乎等于送分。反过来,如果你是按考研的要求把189吃透,去面试的时候把它现场写出来,也完全是稳稳的加分项。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 先别急着上高级写法,暴力法到底慢在哪
很多LeetCode题解上来就甩反转法,看起来确实酷,但一个对轮转操作没有直观感受的人,直接背反转法很容易出事故。我建议新手先把暴力法和额外数组法写一遍,踩一遍坑,才能真正理解为什么后面两种写法更优。
2.1 暴力移动解法与它的复杂度陷阱
最朴素的写法是这样:每次把数组最后一个元素存下来,然后把前面所有元素往后挪一位,最后把存下来的那个元素放到数组开头。这样就完成了一次轮转。外面再套一个for循环让它转k次。
python复制def rotate_violent(nums, k):
n = len(nums)
k %= n
for _ in range(k):
last = nums[-1]
for i in range(n - 1, 0, -1):
nums[i] = nums[i - 1]
nums[0] = last
这段代码的思路很直白,没有任何技巧,直接模拟元素的移动过程。但注意看时间开销,外层执行k次,内层每轮要移动n-1个元素,总次数是O(nk)。假设n是10万,k也是10万,那你就要执行百亿次赋值。这个量级放在任何一种OJ上都不可能通过。
还有一个隐藏的小坑:不先执行k %= n的话,k一但大于n,代码里就会多出一大堆根本不需要的重复轮转。比如n=5,k=12,向左或向右轮转12次的结果,其实和第5个元素?不,因为轮转n次等于回原位,先对k取模成2,得到的结果和12次完全一样。我在刚开始刷题时总会忘这一步,所以还是把它写在前面了。
2.2 额外数组解法,空间换时间一时爽
暴力法的优化方向很自然。既然每个元素的落点可以直接被算出来,那我干脆开一个新数组,把nums[i]放到新位置(i + k) % n上,最后把新数组的内容拷回来,这不就完了吗?
python复制def rotate_extra(nums, k):
n = len(nums)
k %= n
new_arr = [0] * n
for i in range(n):
new_arr[(i + k) % n] = nums[i]
for i in range(n):
nums[i] = new_arr[i]
或者用Python更顺滑的写法:
python复制def rotate_extra(nums, k):
k %= len(nums)
nums[:] = nums[-k:] + nums[:-k]
额外数组法的时间复杂度是O(n),已经足够快。但它需要O(n)的额外空间。LeetCode进阶要求里那句话其实已经暗示得很明显:如果你只想做普通解法,那这道题作为Easy级别都可以;但如果想达到Hard门面的考核标准,就必须做原地算法。
经验之谈:那种直接切片拼接或者复制到新数组再拷回老数组的写法,平时写业务逻辑完全没毛病,代码还清晰。可一旦面试官追问“如果数组大到内存里放不下两个副本怎么办”,就只能暴露你对原地算法的理解深度了。这也是为什么反转法和环状替换值得认真研究。
3. 三次反转法:原地轮转的标准答案
反转法是我个人最喜欢的一个解法,因为它足够优雅,代码短,逻辑也好记。核心思想只有一句话:先把整段数组反转,再把前k个元素反转,最后把剩下的元素反转。
3.1 反转法背后的数学直觉
很多人第一次看到这个方法会说:“我只知道要反转三次,但真的不知道为什么反转三次就对了。”这句话我太熟了,因为我也是一路困惑过来的。
拿例子来演示。nums = [1,2,3,4,5,6,7], k=3。期望答案是[5,6,7,1,2,3,4]。
第一步,反转整个数组:[7,6,5,4,3,2,1]。
第二步,反转前k个位置,也就是下标0到2的这段:[5,6,7,4,3,2,1]。
第三步,反转后面剩余的部分,也就是下标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]拼接成的,而反转整个数组后的[7,6,5,4,3,2,1]中,[5,6,7]已经连在一起躺在前面,[4,3,2,1]也连在一起躺在后面,只是各自内部顺序是反的。于是后两步分别把这两段内部掉个个儿,就成了最终目标。
你可以把整个过程理解成:先把每一段的元素“预迁移”到正确的一侧,然后再把两段内部的顺序整理回来。一次整体反转加上两次局部反转,每个元素都恰好被交换了两次,所以最终结果没有多余的逆序。
3.2 代码实现与取模细节
用Python写的时候,反转函数可以封装成单独的方法,避免每次重复着写交换逻辑。
python复制class Solution:
def rotate(self, nums: List[int], k: int) -> None:
n = len(nums)
if n <= 1:
return
k %= n
if k == 0:
return
self.reverse(nums, 0, n - 1)
self.reverse(nums, 0, k - 1)
self.reverse(nums, k, n - 1)
def reverse(self, nums, left, right):
while left < right:
nums[left], nums[right] = nums[right], nums[left]
left += 1
right -= 1
这里有一个细节很多人会忽略:第二次反转的结束位置是k-1,第三次反转的起始位置是k。也就是说,前k个元素和下标的对应关系是0到k-1。如果k已经在前面对n取过模,可以保证k一定小于n,不会出现越界。
3.3 C语言落地时最容易翻车的两个点
408真题要求用C/C++描述,所以你还要能用C把反转法白板手写出来。
c复制void reverse(int nums[], int left, int right) {
while (left < right) {
int temp = nums[left];
nums[left] = nums[right];
nums[right] = temp;
left++;
right--;
}
}
void rotate(int nums[], int numsSize, int k) {
if (numsSize <= 1) return;
k %= numsSize;
if (k == 0) return;
reverse(nums, 0, numsSize - 1);
reverse(nums, 0, k - 1);
reverse(nums, k, numsSize - 1);
}
用C写的时候,有两个地方我踩过坑。
第一个坑是数组长度为0或者1的时候,如果还继续执行k %= numsSize,就会发生除零错误或直接运行无意义操作。我习惯在开头就加一个长度判断,有时候还要考虑k对n取模之后等于0的情况,这样可以省下三次反转的完全无意义操作。
第二个坑是reverse函数的while条件,千万不能写成while(left != right)。当子数组长度为偶数时,left会和right擦肩而过,永远不会相等,最后越界访问。正确的写法是while(left < right),保证循环到达交叉位置后就停下来。
4. 环状替换法:另一条O(1)空间的优雅路径
如果你以为三次反转是原地算法的唯一答案,那就低估这道题了。力扣官方题解里还给过一种环状替换法。这种写法在思路上比反转法更贴近“轮转”二字的本质,每一步都能看到元素是怎么跳的。
4.1 环状替换的执行过程
先说核心思路。对位置i上的元素nums[i],它最终会到下标(i + k) % n去。如果直接把这个值放到新位置,那新位置上原本的元素又会被挤掉,所以需要把被挤掉的元素存起来,然后沿着它应该去的路径继续走。
拿nums=[1,2,3,4,5,6,7], k=3举例。起点是下标0,nums[0]=1应该去下标3。把nums[3]=4先存到临时变量里,然后把4放到下标3,当前更新为4,当前位置是3。nums[3]=4应该去下标6,把nums[6]=7存到临时变量,然后把7放到下标6。以此类推,这条链路是0→3→6→2→5→1→4→0。走了一圈发现回到了0,而且一共替换了7个元素,恰好覆盖整个数组,所以结束。
不过当k和n不互质的时候,一个圈转不完整个数组。比如nums=[1,2,3,4,5,6], k=2。从0出发,链路是0→2→4→0,只覆盖了0、2、4三个位置。这时候还要从下标1开始走第二条链路1→3→5→1。因此整个算法必须记录总共已经放好了多少个元素,只要总数不足n,就换下一个起点继续。
4.2 回到起点的跳转与计数器之间的关系
环状替换代码其实不算短,但每一步都有明确的目的。
python复制def rotate_cycle(nums, k):
n = len(nums)
if n <= 1:
return
k %= n
if k == 0:
return
count = 0
start = 0
while count < n:
current = start
prev = nums[start]
while True:
nxt = (current + k) % n
temp = nums[nxt]
nums[nxt] = prev
prev = temp
current = nxt
count += 1
if start == current:
break
start += 1
这段代码里有一个非常关键的地方:count计数器不能省。为什么?因为进入内层循环后,一旦回到起点就break,说明这条链已经形成闭环。但链的起点如果是从1开始的,那它自成一个圈。当起点是0的那个圈覆盖了2个元素,起点是1的圈也覆盖了2个元素,两个圈分别处理完后count正好等于n。可是如果某次你从起点0出发但没形成闭环(实际总会在某个点回来),或者数组元素个数和步长不互质时,一个圈结束并不是整个任务完成,这时候必须让count来判断要不要继续换起点。
我最初实现时没有加count,只靠start <= n来控制外层循环,结果在某些case里出现了部分元素重复被覆盖、另一部分元素完全没被移动的情况。最保险的方法就是像上面这样,外层循环以count < n作为总开关,内层循环负责处理完当前环。
4.3 反转法和环状替换法怎么选
从性能来看,反转法的时间复杂度也是O(n),空间复杂度也是O(1),环状替换法同样如此。但考虑到实际运行,反转法的常数非常小,因为它只是做简单交换。而环状替换需要频繁计算目标下标、维护临时变量,逻辑上还要多维护一个count计数器。
不过在408的阅卷视角里,两种方法都算“就地、高效”。区别在于描述算法思想时,反转法说三句话就能完成,环状替换法描述起来可能需要多写两行。考场上我更推荐反转法,因为它的推导难度低,实现也不容易出现环的回跳逻辑错误。
但如果面试现场被追问“你能不能不用反转,直接原地搬元素”,那就用环状替换法来展示你确实吃透了数组下标的映射关系。两套解法都掌握,才算真正把189吃透。
5. 真题视角:2010年408那道循环移位题
把题切回考研场景。我见过很多复习到数据结构前几章的人,都会在某道408真题上停住。不是因为它算法难,而是因为当时还没有建立“轮转数组”这个模型,看了答案也觉得像魔术。
5.1 原题到底问了什么
2010年那题的大致条件是:有n个整数存在一维数组R里,要求把序列循环左移p个位置,其中0<p<n。样例多给的是1到9左移3位变成4到9再跟1到3,和力扣189示例几乎互为镜像。
它其实没有直接说“你必须用原地算法”,但要求里写了“在时间和空间两方面都尽可能高效”,这就是在暗示你空间应该做到O(1),时间最好做到O(n)。如果你用临时数组法把每个元素直接存到新位置也能得出正确答案,但它给出的空间复杂度是O(n),这就背离了“尽可能高效”的要求。阅卷时空间复杂度这一档就会先被扣分。
还有一点容易被忽略的是,“左移p位”可能把你绕进去。拿到题先别急着把“左移”两个字特殊化,你得意识到左移p位和右移n-p位是等价的。这样一来,你既可以直接按左移方向写一个对称的三次反转算法,也可以复用LeetCode189右移的思路。最省事的方法是转化成右移n-p位后调用熟悉的反转法。
5.2 左移与右移的统一转化
先说直接左移的写法。假设数组是[a0, a1, ..., a_{n-1}],要整体左移p位。最终结果是:
[a_p, a_{p+1}, ..., a_{n-1}, a_0, a_1, ..., a_{p-1}]
用三次反转来做的话,先把整个数组反转,得到[a_{n-1}, ..., a_{p+1}, a_p, a_{p-1}, ..., a_1, a_0],此时你想要的左移后字符串并不是连在正确的顺序里?别急,再反转下标0到n-p-1这一段,也就是原数组的右侧部分;再反转下标n-p到n-1这一段,也就是原数组的前p个元素。这样得到结果就是[a_p, ..., a_{n-1}, a_0, ..., a_{p-1}]。
另一个思路是直接把它转化为已经练过无数次的右轮转。右轮转的代码你照着题就能默写,左移p位,就把k写成(n-p)%n。
c复制void rotate_right(int nums[], int numsSize, int k) {
k %= numsSize;
if (k == 0) return;
reverse(nums, 0, numsSize - 1);
reverse(nums, 0, k - 1);
reverse(nums, k, numsSize - 1);
}
void rotate_left(int nums[], int numsSize, int p) {
rotate_right(nums, numsSize, numsSize - p);
}
写这种转换时,我建议在心里默算一个小例子来验证,别只靠背。比如n=6, p=2,数组[1,2,3,4,5,6]左移2位是[3,4,5,6,1,2]。右移n-p=4位后同样是[3,4,5,6,1,2]。有了这个例子垫底,你就不会在手忙脚乱时把方向搞反。
5.3 考场上如何拿到尽可能多的分
408的算法大题是手动阅卷,核心是按点给分。我当年备考总结出来的策略是这样的:先写算法设计思想,再写复杂度结论,再写C函数实现。顺序不要乱。
设计思想写在最前面,相当于告诉阅卷老师“我知道自己在干嘛”。即使代码中出现少量笔误,思想对、逻辑整体对,也往往能拿到绝大部分分数。具体到这道反转法题,我会写类似“观察到循环移位可以通过三次逆置完成,先整体逆置,再逆置前n-p个元素,最后逆置后p个元素。时间复杂度O(n),空间复杂度O(1)”这样一段话。
代码部分一定要给函数起的名字和注释清楚。阅卷老师不会去看注释,但你让自己思路清楚。函数参数可以这样写:
c复制void reverse(int R[], int from, int to) {
int temp;
while (from < to) {
temp = R[from];
R[from] = R[to];
R[to] = temp;
from++;
to--;
}
}
void circularShiftLeft(int R[], int n, int p) {
if (n <= 1 || p % n == 0) return;
p %= n;
reverse(R, 0, n - 1);
reverse(R, 0, n - p - 1);
reverse(R, n - p, n - 1);
}
如果题目要求的是数组元素循环右移而不是左移,我一般写成:
c复制void reverse(int *nums, int left, int right)
这句直接决定了你能不能在代码里用两指针向中间靠拢。很多备考初期的人会把reverse写错成复制一个新数组,其实只需要一个临时变量做两两交换。这个细节本身就是考点。
6. 边界条件、测试用例和刷题复盘建议
好多人把代码默写出来后,觉得自己已经会了。等真正提交LeetCode时才发现,有几个边界用例反复让代码崩掉。我从这些失败里总结了一套固定的测试习惯,放到选择题和工程里都管用。
6.1 边界问题不能只靠if硬扛
先列一下值得每个解法都过一遍的边界:
- 数组为空:numsSize为0。
- 数组只有一个元素:无论k有多大,结果都不变。
- k是数组长度的倍数:例如n=6, k=12,轮转整数圈后等于没有变化。
- k远大于n:例如n=4, k=100。
- k等于n的因子:这会导致环状替换法出现多段循环,最容易测出count计数器写没写对。
- n=2, k=1这类最小规模的两元素交换。
对于反转法,只要reverse函数的边界正确,即left从0到right,right到数组最后一个有效下标,那么这些用例几乎都覆盖到了。加不加特殊判断其实不影响正确性,但加一些提前退出能让代码更稳。
6.2 几个我建议你亲手跑一遍的例子
拿LeetCode示例来跑是最基本的,我推荐再加两个不太容易被注意到的场景。
场景一:nums=[1,2,3,4,5,6], k=2。反转法过程如下:
全部反转:[6,5,4,3,2,1]
反转前2个:[5,6,4,3,2,1]
反转剩下4个:[5,6,1,2,3,4]
最终确实等于[5,6,1,2,3,4],和直接算(i+k)%n的结果一致。
场景二:nums=[1,2,3,4], k=2。这是典型的n与k不互质情况,适合测环状替换法:
从0出发:0→2→0。count等于2。
然后start变成1,走1→3→1,count变成4,外层循环结束。
看看数组:[3,4,1,2]还是[3,4,1,2]?等一下,先算好点,原数组1放0,2放1,3放2,4放3。右移2位原1至2,原2至3,原3至0,原4至1,结果是[3,4,1,2],符合最终结果。如果count没更新,或者内层break时机不对,就极有可能变成[1,4,3,2]这类乱序结果。
6.3 这道题给后续算法学习的启发
轮转数组看起来只是数组操作里的一个小case,但它背后延伸出的东西不少。最直接的是循环队列、循环链表相关的操作,因为凡是“环形”“循环”的结构都会用到下标取模的思想。其次是原地修改数组类问题,比如原地移动零、原地去重、旋转矩阵,它们和轮转数组共享一个核心原则:能直接算出最终位置,就尽量通过交换和逆置完成,别总想着开新数组。
我刷题复盘时还养成了一个习惯:每做完一道题,尝试把所有解法的复杂度表格列出来,并亲手跑一遍边界,再去看讨论区有没有更隐蔽的实现。这样做确实比单纯追求“AC”有收获。当你把LeetCode189的三四套思路都写熟后,再看到2010年408那道循环移位题,会明显感觉到脑内已经建立好了一条完整的思维路径,根本不需要死背答案。
数组轮转就是这么一类题:看起来基础,却是理解数据元素移动、环状替换和取模映射的好样本。如果时间允许,你可以拿一个随机数组,分别用三次反转和环状替换各写一遍,比较两者在细节上的差异。我个人的体会是,反转法的思路最好用,环状替换法对训练下标敏感度最有帮助。两个方向的代码都能完整默写出来,比单纯背一道题答案要有意义得多。
