☰
LeetCode 189 轮转数组:Python 原地修改的三种解法与边界陷阱
2026/10/1 2:07:11 网站建设 项目流程

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 == 0,k %= 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.rotate

Python 标准库里有个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, f"fail: 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]为什么是空列表,直到第三遍重刷才彻底弄明白。

一个小技巧分享给你:每次刷完一道数组题,把这道题所有可能的解法写在注释里,包括复杂度,下次重刷时直接看注释回忆。这样做三个月之后,你会发现自己的解题思维明显比以前快很多,因为很多套路已经内化成条件反射了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询