如果你刷力扣 Hot100 刷到第 15 题“轮转数组”,很容易被它朴素的名字骗过去。我第一次做这题,随手写一行 Python 切片就 AC 了,心里还在嘀咕,这也配进 Hot100?直到后来在面试里被追问“你能做到 O(1) 空间吗”,我才发现自己只是背下了切片,根本没有理解这道题背后的三件事:边界条件、原地修改、同一需求下四种写法的取舍。
这篇文章我会用 Python 把轮转数组从暴力到最优完整拆一遍。先讲清楚题目最容易被忽略的边界,再重点拆面试官最爱的三次反转法,然后分析 Python 特有的切片和 deque 写法,哪些能用于面试、哪些只能用于工程,最后补上环状替换法和一套可以直接自测的用例。适合刚开始刷 Hot100 的新人,也适合已经把题 AC 过、但想彻底搞懂“为什么这么做”的人。
1. 第15题“轮转数组”到底在考什么:两道隐藏的边角题
1.1 先读懂题:右移 k 位和右移 k%n 位是同一件事
力扣的原题编号是 189,在 Hot100 专题里排在第十五位。题目描述很简短:给你一个数组 nums 和一个整数 k,把数组里所有元素向右轮转 k 个位置。给两个例子就好懂多了:nums = [1,2,3,4,5,6,7], k = 3得到[5,6,7,1,2,3,4];nums = [-1,-100,3,99], k = 2得到[3,99,-1,-100]。
很多人做题时会默认 k 小于数组长度,这是第一个坑。比如nums = [1,2],k = 3,把数组右移 3 位,实际效果是右移 1 位,因为移动 2 位之后数组又回到原状,第 3 位其实是重复了一次完整的轮转。所以任何解法第一步都应该是k %= n,这里的 n 是数组长度。为什么取模?因为右移 n 次等于没移,这是一个周期为 n 的操作,取模只是把多余的周期拆掉。同理,如果 k 可能为负数,那就表示左移,需要先把负数矫正成正数,再做右移逻辑。
除了“k 大于 n”,还有两个边界也要重视:n = 1和k = 0。nums = [1],k = 1000000,取模后k = 0,原数组不变;nums = [1,2,3],k = 0也应当原样返回。这些边界不处理,在某些 Python 解法里会表现得很诡异,这就是下一节要说的。
1.2 Python 的负索引会把 k=0 变成一个隐蔽的坑
Python 负索引在处理 k=0 时有一个很少有人注意到的细节:-0等于0。切片法里常见写法是nums[:] = nums[-k:] + nums[:-k],当k = 0时,它实际计算的是nums[0:] + nums[:0],也就是整个数组拼接一个空数组,结果依然正确,不会报错。但它能碰巧工作,完全依赖-0 == 0这个 Python 语言特性,如果你不理解这一点,面试中被问到时很容易卡壳。
所以更稳妥的写法是:先k %= n,紧接着if k == 0: return,再去走后续的逻辑。这样从源头上避开负索引的语义混乱,也让代码的边界行为一目了然。
这里还有一个 Python 专属的坑:nums = nums[-k:] + nums[:-k]和nums[:] = nums[-k:] + nums[:-k],在 LeetCode 上结果完全不同。前者只是把函数内部的局部变量 nums 重新绑定到一个新列表,外部调用者拿到的还是原列表;后者通过切片赋值的语法,把新内容灌进了原列表对象,外部才会看到变化。这个坑无数人踩过,题解区经常有人问“为什么我返回了 nums 还是 WA”,多半就是写成了nums = ...而不是nums[:] = ...。
1.3 为什么它配得上一个 Hot100 席位
把 Hot100 数组区附近的题拿出来对比,你会有感觉。排在它前面的是“合并区间”,排在它后面的是“除自身以外数组的乘积”。这三道题有个共同气质:都不需要冷门算法,考的其实是“你能不能把基础操作写干净”。轮转数组更是如此——暴力切片、额外数组、三次反转、环状替换,四种做法从 O(n) 空间一路优化到 O(1) 空间,同一个需求覆盖了时间复杂度、空间复杂度、原地修改、Python 引用传递这些高频面试点。
作为热身题,它简单到能让人快速进入刷题状态;作为考题,它又能立刻区分“背答案的人”和“懂原理的人”。这也是它看起来简单,却常年待在 Hot100 里的原因。
2. 三次反转法:O(1) 空间解法是怎么被“自然想到”的
2.1 从 AB 变成 BA:反转数组为什么是一把万能钥匙
三次反转法不是凭空冒出来的。把数组看成两段区间:前n - k个元素是 A,后k个元素是 B,原数组就是 AB,我们要的结果是 BA。一个数组整体倒序之后会得到reverse(B) + reverse(A),也就是 B 和 A 都变成了倒序。如果再分别把前 k 个区间和后 n-k 个区间各自倒序一次,reverse(B)会变回 B,reverse(A)会变回 A,整体恰好就是 BA。
用例子走一遍:nums = [1,2,3,4,5,6,7],n = 7,k = 3,A 是[1,2,3,4],B 是[5,6,7]。整体倒序得到[7,6,5,4,3,2,1];前三个元素是 B 的倒序[7,6,5],倒序之后变成[5,6,7];后四个元素是 A 的倒序[4,3,2,1],倒序之后变成[1,2,3,4]。整体拼起来就是[5,6,7,1,2,3,4],正好是答案。
为什么这个思路可以被现场推导出来?因为轮转的本质是两段区间的交换,而“整体反转加局部反转”是处理区间交换的经典手段。理解了这一层,你就不需要死记三次反转的步骤,而是可以从“AB 变 BA”这个目标出发,自己推出全部流程。
2.2 手写一个不依赖切片的区间反转
三次反转的核心操作是“倒序数组的某一段”,所以需要一个能反转任意区间的函数。在 Python 里,反转一段区间主要有三种写法:
nums[i:j] = nums[i:j][::-1]:简洁,但切片会产生临时新列表,严格说是 O(n) 空间;nums[i:j] = reversed(nums[i:j]):同样会创建临时列表;- 双指针原地交换:完全满足 O(1) 空间。
面试和比赛里,我建议手写双指针版本:
def rotate(nums, k): n = len(nums) k %= n if k == 0: return def reverse(i, j): while i < j: nums[i], nums[j] = nums[j], nums[i] i += 1 j -= 1 reverse(0, n - 1) # 整体反转 reverse(0, k - 1) # 反转前 k 个 reverse(k, n - 1) # 反转后 n-k 个这段代码有三个小细节值得注意。第一,reverse 的循环条件是i < j,不需要额外临时数组,交换完往中间收拢即可。第二,三次反转的区间边界是k-1和k,写错一个元素,整个结果就偏了。第三,Python 的多重赋值nums[i], nums[j] = nums[j], nums[i]是原子交换,这也是 Python 写算法题比较舒服的地方。
2.3 取模之后马上加一个 if k == 0 的 return
我在代码里先做k %= n,紧接着判断if k == 0: return。这个防御性写法做题时能省不少事。原因很简单:k=0 时三次反转会把数组从头到尾反转一遍,再局部反转两遍,结果虽然还是原数组,但白白跑了两个 O(n) 的循环。更重要的是,如果后面的代码逻辑依赖“k 一定大于 0”这个前提,提前 return 能避免很多分支判断上的麻烦。
这个习惯和写业务代码时“入参先校验再处理”是一个道理。刷题不只是为了 AC,也是在培养工程习惯。
3. Python 解法里的几道“捷径”:哪些能用于面试,哪些只能用于工程
3.1 切片轮转:一行 AC 背后的两个致命细节
切片解法是很多人第一次 AC 的写法:
def rotate(nums, k): n = len(nums) k %= n nums[:] = nums[-k:] + nums[:-k]它确实短,但有两个致命细节,面试中必须能讲清楚。
第一个细节是为什么必须是nums[:] = ...,而不是nums = ...。函数里的nums = new_list只是让局部变量指向了一个新列表,调用者的列表完全没变;nums[:] = ...才是往原列表对象里灌新内容。LeetCode 的判题系统检查的是外部引用指向的那个列表,所以一定要用切片赋值。这也是 Python 参数传递里最经典的送命题之一。
第二个细节是空间复杂度。切片nums[-k:] + nums[:-k]会产生两个新列表切片,然后把它们拼接成第三个临时列表,最后才赋回去。虽然最终列表的引用没变,但过程里用了 O(n) 的额外空间。力扣上这样写能过,空间复杂度显示也是 O(n),可面试官一句“能不能优化空间”就能把人问住。切片法的定位应该是:本地快速验证、比赛抢时间、工程环境的日常编码。
3.2 deque.rotate:工程里最优雅、面试里最减分
Python 的 collections.deque 自带 rotate 方法,代码更短:
from collections import deque def rotate(nums, k): dq = deque(nums) dq.rotate(k) # 正数是右转,负数是左转 nums[:] = list(dq)工程里这确实好用,尤其是做时间序列滚动窗口、队列轮转这类场景。deque 内部用分块链表存储,rotate 整体时间是 O(n),但常数很小,而且写起来不存在边界问题。但面试时我不建议主动掏 deque。原因有两个:第一,deque(nums)会复制整个数组,list(dq)又复制一次,空间 O(n) 躲不掉;第二,面试官想看的是你处理数组的能力,一个库函数把题目最重要的操作抽走了,题目等于没考。
它更适合作为“日常业务开发”的答案,而不是“面试算法题”的答案。如果你在面试里用了 deque,最好主动补一句“我知道它不是 O(1) 空间,只是工程上写着舒服”,这样至少不会让面试官觉得你只会调库。
3.3 判断“原地修改”的三个自测标准
我后来总结了一个判断标准,刷题时可以拿来对照自己的解法:
- 函数签名没有把 nums 重新绑定为一个新对象;
- 没有使用切片、list()、deque()、reversed() 这类会创建新容器的操作;
- 所有改动都通过索引赋值完成。
如果满足这三点,基本可以确认自己的解法是 O(1) 空间的原地算法。在轮转数组这道题里,满足这三条的只有三次反转法和环状替换法。这也是我建议你至少在本地手写一次双指针 reverse 的原因——只要 reverse 能写出来,三次反转法就永远处于“随时能调出来”的状态,面试被追问也不会慌。
4. 环状替换法:把坑踩完之后才真正理解的“O(n)”
4.1 模拟一次跳跃:从 index=0 出发会发生什么
环状替换法也叫跳跃替换。思路是:从某个位置出发,把当前位置的值放到它应该去的目标位置(当前下标 + k) % n,同时把目标位置原来的值接过来,再继续往下跳,直到回到出发点,这样就完成了一轮移动。
以nums = [1,2,3,4,5,6,7],k = 3为例,从下标 0 出发:
- 下标 0 拿着 1,目标下标 3,把 1 放到下标 3,手里接住 4;
- 下标 3 拿着 4,目标下标 6,把 4 放到下标 6,手里接住 7;
- 下标 6 拿着 7,目标下标 2,把 7 放到下标 2,手里接住 3;
- 下标 2 拿着 3,目标下标 5,把 3 放到下标 5,手里接住 6;
- 下标 5 拿着 6,目标下标 1,把 6 放到下标 1,手里接住 2;
- 下标 1 拿着 2,目标下标 4,把 2 放到下标 4,手里接住 5;
- 下标 4 拿着 5,目标下标 0,把 5 放到下标 0,回到起点,正好移动了 7 个元素。
因为 n=7 和 k=3 互质,这一轮跳跃就覆盖了所有下标,一次循环完成整个轮转。这就是环状替换“每个元素只移动一次”的含义,总时间 O(n),而不是很多人误以为的 O(n*k)。
4.2 为什么有时需要多个起点:gcd 是核心
换一个例子:nums = [1,2,3,4,5,6],k = 2。从下标 0 出发:0 放 1 到下标 2,接 3;下标 2 放 3 到下标 4,接 5;下标 4 放 5 到下标 0,接 1,回到起点。这一轮只移动了 0、2、4 三个下标,1、3、5 完全没动。所以还需要从下标 1 再来一轮:1 放到 3,3 放到 5,5 回到 1,才把整个数组轮转完。
这里“需要几个起点”,答案就是gcd(n, k)。原因是:步长为 k 的跳跃,在模 n 的意义下,会把所有下标分到若干个循环轨道里,轨道的数量正好是 n 和 k 的最大公约数。gcd(6,2) = 2,所以需要两轮;gcd(7,3) = 1,所以一轮就够。写代码时直接math.gcd(n, k)算出起点数量,从 0 到 gcd-1 每个起点各做一轮即可。
4.3 代码实现与两个极易写错的细节
用 gcd 控制的写法非常清晰:
from math import gcd def rotate(nums, k): n = len(nums) k %= n if k == 0: return g = gcd(n, k) for start in range(g): cur = start prev = nums[cur] while True: nxt = (cur + k) % n prev, nums[nxt] = nums[nxt], prev cur = nxt if cur == start: break这个写法我踩过三个坑,列出来给大家省时间。
第一,内层循环的出口是cur == start,不是count == n。如果用 count 控制循环,写法会别扭很多,而且很容易在 gcd>1 时提前 break,导致后面一堆元素没处理。
第二,每轮开始前一定要prev = nums[start]。有人会忘记重新取出发点的值,导致一轮循环里第一个目标位写入了上一轮残留的旧值。
第三,gcd(n, k)里的 k 最好用取模后的 k。虽然gcd(k,n)和gcd(k%n,n)结果一样,但取模后的 k 会让跳跃过程更符合直觉,排查时也更省力。
环状替换的理解成本比三次反转高,但画一遍上面的例子,其实比想象中简单。面试里它可以作为三次反转后的备选方案,讲出来比较加分,因为它体现了对“每个元素移动次数”的深刻理解。
5. 一次“从 AC 到 Offer”的完整复盘:复杂度对比与面试追问拆解
5.1 我在本地用五组测试验证这四种写法
光看代码不跑测试,很难确认边界处理没问题。我刷这道题时给自己准备了五组用例,全部用 assert 跑:
[1,2,3,4,5,6,7], k=3期望得到[5,6,7,1,2,3,4][-1,-100,3,99], k=2期望得到[3,99,-1,-100][1,2], k=3期望得到[2,1][1], k=0期望得到[1]list(range(10000)), k=1000000007,先取模再和切片结果对照
验证代码可以写成这样:
def verify(rotate): nums = [1, 2, 3, 4, 5, 6, 7] rotate(nums, 3) assert nums == [5, 6, 7, 1, 2, 3, 4] nums = [-1, -100, 3, 99] rotate(nums, 2) assert nums == [3, 99, -1, -100] nums = [1, 2] rotate(nums, 3) assert nums == [2, 1] nums = [1] rotate(nums, 0) assert nums == [1]建议不要用 print 肉眼看输出,全部用 assert,刷题时代替手写测试效率高很多。我第一次实现环状替换时就是靠这些用例发现 gcd 那个坑的。
5.2 一张表看清四种方案的取舍
把四种方案放到一张表里,面试时脑内对比非常直观:
| 方案 | 时间复杂度 | 额外空间 | 是否原地 | 面试推荐度 |
|---|---|---|---|---|
| 三次反转 | O(n) | O(1) | 是 | 强烈推荐 |
| 环状替换 | O(n) | O(1) | 是 | 推荐,加分项 |
| 切片拼接 | O(n) | O(n) | 否(有临时列表) | 不推荐主动讲 |
| deque.rotate | O(n) | O(n) | 否 | 工程可用,面试慎用 |
如果面试只有 10 分钟写这道题,直接选三次反转,因为它最容易证明正确性,也不引入额外空间。如果面试官问“还有没有别的原地方法”,再把环状替换拿出来。如果只是闲聊工程能力,可以说“生产环境我可能用 deque.rotate 或切片,因为可读性更好”。关键是要能流畅说出每种方案的适用场景,而不是只会一种。
5.3 面试官顺着这道题会追问的四层问题
第一层,k 比数组长度大怎么办?回答:k %= n,因为右移 n 次等于没移。
第二层,如果 k 是负数,表示左移怎么办?回答:先把 k 矫正成正数,比如k = (k % n + n) % n,再走右移逻辑;如果坚持用切片,左移 k 位就是nums[:] = nums[k:] + nums[:k]。重点是能说清楚正负方向和取模的关系。
第三层,能不能原地且 O(1) 空间?回答:三次反转或环状替换,把流程讲清楚。这一步最能拉好感。
第四层,为什么 LeetCode 里nums = nums[-k:] + nums[:-k]不生效?回答:函数内的局部变量重新绑定不影响外部列表,要用nums[:] = ...原地修改。这个问题看似 Python 细节,实际考察的是对语言内存模型和参数传递的理解。
还有一个变体也容易被问到:如果题目允许返回新数组呢?那直接切片就是最优答案,O(n) 时间和 O(n) 空间都合理,没必要三次反转。能在不同约束下选择不同方案,这才叫真正掌握。
6. 轮转数组的变体地图:从这道题串起数组类面试题
6.1 它在 Hot100 里的位置与“数组题家族”
Hot100 的数组部分很有规律。合并区间考“排序后扫一遍”,除自身以外数组的乘积考“前缀信息”,轮转数组考“原地重排”。它们放在一起看,你会发现数组类题目的核心其实就是三件事:怎么遍历、怎么交换、怎么用额外空间换时间。轮转数组恰好把“交换”和“额外空间”两个维度都覆盖到了。这也是它出现在榜单中段的理由——不是让你背答案,是让你借它建立原地修改的直觉。
刷题的时候不建议孤立刷。每做完一道数组题,可以顺手看看它在榜单里的邻居,想一想“这道题如果加一个原地限制,解法会不会变”。轮转数组就是“原地限制改变一切”的典型例子。
6.2 左旋转字符串、旋转链表、旋转图像的关联
轮转数组不是一个孤立知识点,它和几道常见题有直接血缘关系。
剑指 Offer 58-II 左旋转字符串:字符串左移,用三次反转同样能解,区别是字符串不可变,要先转成列表或直接切片。
LeetCode 61 旋转链表:把链表连成环,再在合适位置断开。核心也是“计算真正移动的步数”和“找到断点”。链表版比数组版多了找断点的操作,但取模的思路一模一样。
LeetCode 48 旋转图像:矩阵顺时针旋转 90 度,经典做法是转置之后上下翻转。它和环状替换一样靠“交换”操作,只是从一维坐标换到了二维坐标。
如果你刷完这道题,能顺手把这三道过一遍,会发现自己对“旋转”这一类问题的理解会一下子立体很多。
6.3 轮转思想在业务代码里的三种形态
最后说点题外话,轮转不只是面试题。我写业务时至少见过三种轮转思想的应用。第一种是时间序列滚动窗口,比如实时指标只保留最近一小时的数据,用deque(maxlen=60)直接控制窗口长度;第二种是环形缓冲区,嵌入式或者消息队列里很常见,核心就是(head + offset) % capacity这种取模;第三种是数据重排,比如按权重轮询一批待执行任务、把最近常用的项挪到列表头部,本质上都是围绕一个有序序列做位移。
所以别觉得排序、轮转这类题离工程很远,它们只是换了一层业务外衣,底层逻辑完全一样。
最后给一个我自己的刷题习惯。轮转数组这道题,我两年里在不同场景下遇到三四次,每次都是同一个套路:先口头答出三种做法的复杂度,再在白板上手写三次反转。能做到这么顺,不是记性好,而是第一次刷的时候把四种写法都用 assert 跑了一遍,并把k %= n这一行牢牢记住了。你现在刷到这道题,不妨也花 20 分钟把三次反转和环状替换各手写一遍,再亲自验证一次nums[:]和nums =的区别。这些功夫不会白费,下一次再见到轮转数组,你大概率不用思考就能写对。