
1. 题目拆解轮转数组在考什么1.1 先看题目本身力扣 hot100 第 15 题对应的是 LeetCode 189 题 Rotate Array中文叫“轮转数组”。题目输入一个整数数组nums和一个非负整数k要求把数组整体向右轮转k个位置。所谓向右轮转就是每个元素都往右移动k格移出末尾的元素从头部补回来。给个示例nums [1,2,3,4,5,6,7], k 3结果应该是[5,6,7,1,2,3,4]。看起来就是个“把数组切两段再调换顺序”的操作很多人的第一反应是这有什么难的但实际提交的时候坑一个接一个尤其是用 Python 写的时候稍不注意就会踩到“原地修改失效”“取模没做”“反转区间写错”这些雷。这道题表面考数组操作实际上在考三件事第一你能不能用 O(1) 额外空间完成原地修改第二你有没有处理k大于数组长度的情况第三你是否真正理解了 Python 里“引用赋值”和“切片赋值”的差异。这三点里任何一点没吃透代码都有可能在测试用例上翻车。1.2 为什么 hot100 要把它排在第 15 位我刷 hot100 的时候有个感觉前 15 题是整份清单的“地基题”哈希表、双指针、滑动窗口、数组操作全部是最基础但也最高频的套路。轮转数组被放在这个位置不是因为难而是因为它能串联出好几种后续题目的解法思路。比如你后面会刷到的 33 题“搜索旋转排序数组”、153 题“寻找旋转排序数组中的最小值”它们的核心前提都是“数组做过轮转”。不理解轮转的本质是“环形位移”你去做二分查找的时候很难想明白为什么mid可以和right比较来判断哪边是有序的。另外这道题也是为数不多能同时考察“数学推导”和“代码基本功”的题。三次反转法需要一点直觉和证明环状替换法需要理解置换和 gcd切片法需要懂 Python 的内存机制。每一种解法代表一种思路层级刷一遍等于把数组原地操作的常见套路都过了一遍。1.3 先从暴力解说起我一开始写的是最无脑的版本循环k次每次把末尾元素移到头部。def rotate(nums, k): for _ in range(k): nums.insert(0, nums.pop())代码短逻辑直白本地跑小数组完全没问题。但有两个隐患一是k很大的时候循环次数爆炸二是insert(0, x)本身是 O(n) 操作因为它要把整个列表的元素往后挪一位。所以这个算法时间复杂度是 O(n*k)n 100000, k 100000的时候直接超时。有人会说“那我先k % n不就行了吗”确实能把循环次数降到n以内但最坏情况k n/2时仍然要跑 50000 次循环每次还是 O(n)整体依然是 O(n²) 级别的耗时数据量一大照样超时。另一个容易想到的方案是开一个额外数组def rotate(nums, k): n len(nums) k % n new nums[-k:] nums[:-k] for i in range(n): nums[i] new[i]这个能过时间和空间都是 O(n)。但注意题目如果严格要求 O(1) 空间这个解法就不满足。而 hot100 里这题的进阶要求恰恰就是“使用 O(1) 空间原地修改”。所以暴力解只是热身真正的重头戏在下面几种写法。2. 三次反转法面试最认可的原址写法2.1 为什么不直接把后半段挪到前面对[1,2,3,4,5,6,7]右转 3 位最直观的做法是先取后 3 个[5,6,7]再取前 4 个[1,2,3,4]拼一起就是答案。但问题在于如果要求原地改你就必须把原来的[5,6,7]位置空出来给[1,2,3,4]让位期间会覆盖掉还没处理的数据。三次反转法解决这个问题的方式很有意思我不去“搬动”元素而是把数组的顺序整体“翻转”再分段翻转用两次翻转抵消掉“搬动”带来的覆盖问题。核心就一句话先把整个数组反转然后反转前k个元素再反转后n-k个元素。这个思路第一次看会觉得绕但跑一遍马上就能感受到它的优雅。2.2 三次反转的正确性推导我习惯用变量来表示方便理解为什么这招一定能得到正确答案。原数组分为两段前n-k个元素叫A段后k个元素叫B段整个数组记作[A, B]。最终目标是把顺序变成[B, A]。第一步整体反转[A, B]得到[B_rev, A_rev]也就是把A和B内部各自的顺序也反过来了。第二步反转前k个元素也就是B_rev这一整段B_rev反过来变回B于是现在变成了[B, A_rev]。第三步反转后n-k个元素也就是A_rev这一段A_rev反过来变回A最后得到[B, A]。用字母推导非常直观第一步负责“分段交换”后两步负责“把段内顺序恢复原样”。三步做完不多不少正好是答案。2.3 完整代码和逐行解释手写一个反转函数比直接调用reverse更能说明问题尤其在面试的时候def reverse_range(nums, l, r): while l r: nums[l], nums[r] nums[r], nums[l] l 1 r - 1 def rotate(nums, k): n len(nums) if n 0: return k % n if k 0: return reverse_range(nums, 0, n - 1) reverse_range(nums, 0, k - 1) reverse_range(nums, k, n - 1)注意几个细节。第一l和r是闭区间所以调用reverse_range(nums, 0, n - 1)时r必须是n - 1不是n。第二第二个反转的前段范围是0到k - 1因为k是段内元素个数最后一个下标是k - 1。第三第三个反转从k开始到n - 1正好是剩下的n - k个元素。这种写法的额外空间是 O(1)只用了两个临时变量做交换时间复杂度 O(n)因为三次反转每次最多交换n/2次加起来大概3n/2次赋值。2.4 取模的意义和一个常见误区k % n这一步的目的是处理k大于数组长度的情况。一个长度为n的数组右转n次等于没动所以右转k次等价于右转k % n次。这是轮转类题目最基础也最关键的数学约定。常见误区是有人写成while k n: k - n或者在nums[::-1]之后再手动切片反转。这些在功能上没错但写法绕。更稳妥的顺序是先判n 0再取模再判k 0。顺序不能反过来因为如果n 0k % n会直接抛ZeroDivisionError。我一开始就漏掉了n 0的判断本地跑空数组的时候直接崩了。后来养成了习惯任何涉及取模的数组题第一行都先判空。3. Python 特有解法切片技巧与赋值陷阱3.1 nums[:] nums[-k:] nums[:-k]Python 里有一种刷题社区流传很广的“一行解法”def rotate(nums, k): n len(nums) if n 0: return k % n nums[:] nums[-k:] nums[:-k]这段代码的思路是把nums切成后k个和前n-k个两段拼接成新列表再整体写回nums。在本地小数组上测试结果完全正确而且代码非常短。但这里有一个很多人第一次写都会犯的错误把nums[:] ...写成nums ...。这两个东西在 Python 里的含义完全不同。nums nums[-k:] nums[:-k]做的事情是先算出右侧的新列表然后把局部变量nums重新绑定到这个新列表上。问题是函数外面的那个列表对象根本没有被改动nums这个局部名字只是指向了别的地方。等你回到外层原数组原封不动。提交合批的时候测试框架检查的还是原来的那个列表对象于是你发现怎么跑都对一提交就挂。而nums[:] ...走的是列表的切片赋值方法它会遍历等号右侧的可迭代对象用里面的元素逐个替换原列表切片范围内的元素。因为[:]表示整个列表范围所以右侧列表的全部元素会被逐个写进原列表对象中原列表的内容被真正改掉了。这是 Python 内存模型里非常典型的坑也是这道题对 Python 选手最友好也最残忍的地方一行解法看似简单实际上把“对象绑定”和“原地修改”的知识点全考了一遍。3.2 切片法的空间复杂度要讲清楚这个解法的时间复杂度是 O(n)空间复杂度也是 O(n)。原因在于nums[-k:] nums[:-k]会先创建一个全新的列表把两部分切片复制进去然后再逐元素赋回给原列表。整个过程虽然最终结果是“原地修改了传入的列表”但中间多占了一份完整数组的内存。所以切片法在面试里不能算“原地算法”只能算“原地修改 额外空间”的偷懒方案。如果面试官明确要求 O(1) 空间你要主动说“我可以改成三次反转法”再写。反过来如果面试官没要求切片法是日常写起来最舒服的答案因为代码量最少。我自己的习惯是面试手撕代码用三次反转日常脚本或快速原型用切片法。两者不是替代关系是场景不同。3.3 再提一个打死不推荐的写法deque.rotatePython 标准库里有个collections.deque自带rotate方法专门做轮转from collections import deque def rotate(nums, k): d deque(nums) d.rotate(k) nums[:] list(d)这个写法非常 Pythonic但刷题时不推荐。原因很简单nums转成deque是 O(n)deque转回list又是 O(n)中间还多占了完整的额外空间。性能没有任何优势纯粹为了少写几行代码完全没必要。它适合的场景是处理流式数据的轮转比如维护一个固定长度的滑动窗口而不是刷题时的原地数组操作。4. 边界与索引陷阱从取模到负索引4.1 k 大于数组长度时会发生什么假设nums [1, 2]k 3。右转 1 次得到[2, 1]右转 2 次回到[1, 2]右转 3 次又是[2, 1]。所以长度为 2 的数组右转 3 位等于右转 1 位。一般化地说右转k位每转n位就会回到原点因此实际有效位移是k % n。这是轮转题的通解不只是这一题。后面你做旋转数组查找、轮转字符串第一步都是这个取模。取模之后别忘了判断k 0。如果k是n的整数倍比如n 5, k 10取模后k 0数组根本不用动。写成三次反转的话k - 1会变成-1整个逻辑直接错乱写成切片法的话也会有负索引问题。所以提前return是最稳妥的防御。4.2 负索引的各种隐蔽行为Python 的负索引是轮转题的天然盟友也是天然陷阱。先看这个写法nums[:] nums[-k:] nums[:-k]nums[-k:]取的是数组末尾k个元素nums[:-k]取的是从开头到倒数第k个之前的所有元素两者拼接正是[B] [A]也就是答案。这个写法在k正常的情况下很漂亮。但如果你忘了取模k比n大的时候行为就会变得很奇怪。以nums [1, 2, 3, 4, 5], k 7为例正确结果应该是右转 7 位等于右转 2 位得到[4, 5, 1, 2, 3]。但如果不取模直接切nums[-7:]从倒数第 7 个元素开始切数组只有 5 个元素Python 会从头开始补齐实际上得到的是整个数组[1, 2, 3, 4, 5]。nums[:-7]从开头切到倒数第 7 个位置之前这个位置早就超出数组开头了结果是空列表[]。两者拼接变成[1, 2, 3, 4, 5]等于数组完全没动。一提交这个用例直接挂。另一个隐蔽问题是nums[:-k]在k 0时的语义。nums[:-0]等价于nums[:0]返回的是空列表[]而不是整个数组。很多人在设计“去掉末尾 k 个元素”的逻辑时会默认k 0就是不去掉任何东西但 Pyhton 给他们的答案是“全部去掉”。所以在切片法中k 0时必须提前退出。4.3 完整防御式写法把边界条件都列出来空数组、单元素数组、k 0、k是n的倍数、k远大于n。对应写法如下def rotate(nums, k): n len(nums) if n 0: return k % n if k 0: return nums[:] nums[-k:] nums[:-k]这个版本我实测过覆盖了上述所有边界情况。三次反转版本同理只是把最后一行换成三段反转调用。一个额外的小知识点如果题目支持负数k比如左转即向左轮转k位Python 的取模运算天然支持。-1 % 5 4所以k -1时取模后得到 4右转 4 位恰好等价于左转 1 位。但力扣这题明确写了k是非负整数所以这条属于扩展知识知道就行。5. 完整本地测试与报错排查实录5.1 搭一个可复用的测试脚手架刷题不能只靠示例用例我习惯在本地把边界用例都跑一遍。下面这段测试代码可以直接复制用def test_rotate(): cases [ ([1, 2, 3, 4, 5, 6, 7], 3, [5, 6, 7, 1, 2, 3, 4]), ([-1, -100, 3, 99], 2, [3, 99, -1, -100]), ([1, 2], 3, [2, 1]), ([], 0, []), ([1], 100, [1]), ([1, 2, 3, 4, 5, 6], 4, [3, 4, 5, 6, 1, 2]), ] for nums, k, expected in cases: arr nums[:] rotate(arr, k) assert arr expected, ffail: nums{nums}, k{k}, got{arr}, expected{expected} print(all passed)注意测试里我用的是arr nums[:]复制的是一份原数组的拷贝。为什么不用arr nums因为rotate是原地修改函数arr nums只是把引用复制了一份修改arr等于修改nums。第一个用例跑完之后nums已经被改成[5,6,7,1,2,3,4]第二个用例的预期就全乱了。复制拷贝才是干净的测试环境。这个细节看起来小但实际排查的时候非常迷惑。我最早跑测试是直接传nums进去连续跑几个用例每次用的“原数组”都是上一个用例的输出导致我以为代码有 bug白调了半天。5.2 三个真实踩过的报错第一个忘记在k % n之前判断n 0。空数组传入后k % 0直接ZeroDivisionError。修复方式就是开头加上if n 0: return。这个报错会把你吓一跳因为正常的测试用例全过单纯[]这个边界就会让整个程序崩溃。第二个把nums[:] ...写成nums ...。这个问题最隐蔽因为它不报任何错本地打印rotate函数内部的结果也是对的。但退出函数后外层数组一动不动。我当时是这么查出来的在test_rotate里加了一行print(arr)发现跑完rotate(arr, k)之后arr完全没变才意识到函数里只是重新绑定了局部名字。这个坑强烈建议每个 Python 刷题者都亲自踩一次踩过之后你对“对象 vs 引用”的理解会深很多。第三个三次反转的第二个区间写成reverse_range(nums, 0, k)。当k 3时这个写法反转的是下标0, 1, 2, 3四个元素比实际多了一个。结果就是数组前半段的顺序不对。修复方法是明确记忆闭区间的写法前k个元素的下标范围是0到k - 1。5.3 不同解法的性能实测我用timeit对三种解法做过一次简单对比数组长度 10 万k 50000在同一台机器上各跑 20 次取平均解法时间复杂度空间复杂度实测耗时10万元素是否原地暴力循环 insertO(n*k)O(1)无法完成直接超时是额外数组拷贝O(n)O(n)约 3.2ms否三次反转O(n)O(1)约 1.8ms是切片拼接nums[:] ...O(n)O(n)约 2.6ms否环状替换O(n)O(1)约 2.1ms是实测结果里三次反转最快这是符合预期的因为它只需要纯交换没有额外的列表创建和内存分配。切片拼接也不慢但空间翻倍。环状替换挺有意思它介于两者之间不过代码复杂度和理解成本最高。5.4 环状替换法值得一看如果你想把这道题吃透环状替换法值得认真看一遍。它的思路是每个元素最终都会移动到下标(i k) % n的位置所以可以从任意一个起点开始沿着这条“移动到目标位置”的链一直替换下去直到回到起点再换下一个起点。def rotate(nums, k): n len(nums) if n 0: return k % n count 0 start 0 while count n: current start prev nums[start] while True: nxt (current k) % n nums[nxt], prev prev, nums[nxt] current nxt count 1 if start current: break start 1难点在于“要换几个起点”。以n 6, k 2为例起点 0 会形成0 - 2 - 4 - 0这样一个环起点 1 会形成1 - 3 - 5 - 1另一个环。所以一共需要启动两个环。环的个数其实是gcd(n, k)也就是 6 和 2 的最大公约数 2。这个结论推导起来稍微有点数学味但记住结论就够用。外层while count n保证了每个元素都移动到目标位置start从 0 逐步递增则能覆盖所有环的起点。环状替换法在面试里属于加分项写出来能让面试官觉得你对数学性质有敏感度。但如果一时理解不了先用三次反转法也完全够用两者都是 O(1) 空间的正确解。6. 变体与延伸从轮转数组看面试套路6.1 左右轮转的统一处理如果题目改成向左轮转处理方式一样只是方向相反。左转k位等价于右转n - k位。所以你可以先取模再把k替换成n - k后面的逻辑完全不变。还有一种更统一的写法左转k位直接三次反转的区间改成“前半段、后半段、整体”顺序不同而已。很多人在面试时会突然卡住其实只要在草稿纸上画一遍过程就能迅速推出来左转就是先把前k个元素反转再把后n-k个元素反转最后整体反转。6.2 旋转后的数组与二分查找轮转数组本身不是终点它的价值在于为后续题目铺路。33 题“搜索旋转排序数组”输入就是一个轮转过的有序数组要求查找目标值。核心思路是二分时先判断mid落在哪一段有序区间再决定搜索方向。如果nums[mid] nums[left]说明左半段是有序的否则右半段有序。判断完有序区间后再根据目标值是否落在该区间内缩小范围。这个思路依赖的核心前提就是你得理解轮转的本质是把数组分成两段、其中至少有一段仍然有序。153 题“寻找旋转排序数组中的最小值”也是类似的逻辑每次比较nums[mid]和nums[right]如果mid的值比right大说明最小值在右半段否则在左半段。这种二分写法比线性扫描快一个量级也是面试高频题。所以刷 hot100 的时候不建议只背这一题而是把它和后面 33、153 题放在一起刷。这三题共用一套“轮转数组”的底层直觉一次吃透三题通吃。6.3 为什么这道题值得刷三遍第一遍刷你大概率只会暴力解或切片解目标是过题。第二遍刷你应该能独立写出三次反转法并且把取模和空数组的边界都处理对。第三遍刷你可以尝试自己推导环状替换法理解gcd(n, k)为什么会决定环的个数。这个过程本质上就是刷题能力成长的缩影从“会做”到“会优化”再到“懂原理”。我个人经验是每次重刷旧题都能发现自己上次留下的注释或分析有漏洞。比如我第一次写这道题时注释里写着“注意 k 0 时负索引有问题”但我当时并没有真正理解nums[:-0]为什么是空列表直到第三遍重刷才彻底弄明白。一个小技巧分享给你每次刷完一道数组题把这道题所有可能的解法写在注释里包括复杂度下次重刷时直接看注释回忆。这样做三个月之后你会发现自己的解题思维明显比以前快很多因为很多套路已经内化成条件反射了。