数组轮转(力扣第 189 题)是我刷题过程中反复遇到的一块"照妖镜"。光看题目描述,很多人觉得这题简单得过分:给一个数组,向右移动 k 位,完了。可真到需要自己手写一遍,还要面对"尽可能尝试多种解法""你能不能做到只用 O(1) 额外空间"的时候,能一次写对的人其实不多。我见过不少人在面试里就倒在这一题上,不是思路没有,而是细节太多、边界太碎、写出来就跑不对。这篇笔记把我在这道题上踩过的坑、试过的四种解法、最后沉淀下来的思考路径完整记录下来。如果你正准备刷力扣,或者已经刷到数组这一章,希望能帮你少走一点弯路。
1. 审题阶段最容易被忽略的三个细节
1.1 "向右轮转 k 位"到底在做什么
轮转的本质,是把数组看成首尾相接的环,而不是一条写死的直线。你可以想象一条环形传送带:末尾的元素一路运到最前面,其余元素依次往后挪一位。这个过程重复 k 次,就是题目要求的"向右轮转 k 位"。
以题目给的经典样例为例:nums = [1,2,3,4,5,6,7],k = 3。
- 轮转 1 步:末尾的 7 跑到开头,变成 [7,1,2,3,4,5,6]
- 轮转 2 步:末尾的 6 跑到开头,变成 [6,7,1,2,3,4,5]
- 轮转 3 步:末尾的 5 跑到开头,变成 [5,6,7,1,2,3,4]
把"轮转"理解成环,后面所有高效解法都顺了。如果只把它理解成"每个元素往右平移一格然后处理越界",你就容易陷入插入、删除的低效操作里出不来。
1.2 k 可能比数组长度大:取模是第一个动作
题目里 k 是非负整数,范围可以到 10^5,而数组长度可能只有 5。假设 n = 5,k = 105,你难道要真的反复移动 105 次吗?完全不用。
因为轮转 n 次,数组恰好回到原样。轮转 5 次、轮转 10 次、轮转 105 次,结果全都等于轮转 105 % 5 = 0 次。所以任何解法动手前的第一件事,一定是执行k %= n。
这步不做,问题很大:三次反转法的反转范围会越界,环状替换法可能走出错误的链条,暴力法会白白超时。取模是这道题的地基,地基歪了,上面盖什么都白搭。
1.3 原题要求"原地修改",不是"返回新数组"
这一点特别害人,尤其是 Python 选手。轮转结果很容易算出来:nums[-k:] + nums[:-k],但如果你直接return这个新列表,判题器会告诉你答案没生效。
原因在于函数参数传递方式。判题器传入一个列表对象,检查的也是这个列表对象在函数调用之后的内容。如果你返回一个新列表,原来的列表对象纹丝不动,判题自然失败。正确做法是把结果写回原数组,比如nums[:] = nums[-k:] + nums[:-k],或者用后面要讲的原地交换算法。这个坑看着小,却能直接决定一次提交的成败。
2. 先从不那么"聪明"的解法开始:暴力法与辅助数组
2.1 暴力轮转:直观但没用
最容易想到的写法,就是一次移动一位,重复 k 次:
def rotate(nums, k): n = len(nums) k %= n for _ in range(k): nums.insert(0, nums.pop())这个写法很"偷懒":从末尾弹出元素是 O(1),但insert(0, ...)需要把数组里的所有元素往后挪一位,是 O(n)。每移动一位的成本是 O(n),一共移动 k 位,整体复杂度 O(k*n)。
题目里 n 最大 10^5,k 最大 10^5,最坏情况下是 10^10 量级的操作,在力扣上必然超时。所以暴力法没有实用价值,它唯一的意义是让你用最直白的方式确认自己对题意的理解没有偏差。手动搬移的版本也差不多:记录末尾元素,然后从后往前逐个把前一个元素挪到后一个位置,最后把记下的末尾元素放到开头,复杂度同样是 O(k*n)。
2.2 辅助数组:永远不会写错的兜底方案
暴力的对立面,是多开一个数组,把每个元素一次性放到最终位置:
def rotate(nums, k): n = len(nums) k %= n res = [0] * n for i in range(n): res[(i + k) % n] = nums[i] for i in range(n): nums[i] = res[i]核心逻辑只有一行:元素 nums[i] 最终要去的位置是(i + k) % n。为什么取模?因为 i + k 可能超过数组末尾,取模之后自动绕回开头。
这个解法时间复杂度 O(n),额外空间 O(n),不符合"O(1) 额外空间"的进阶要求,但它有一个无可替代的优点:不会写错。我在刷题时有个习惯:如果一道题短时间内想不到高效解法,就先写辅助数组版本把正确性跑通,再在此基础上优化。辅助数组就是这类数组轮转问题的"安全网"。
2.3 我为什么建议先写这两种解法再求优化
写暴力法是让你对"轮转动作"产生肌肉记忆;写辅助数组是让你把"每个元素去哪"这个公式(i+k)%n刻进脑子里。后面的环状替换法,其实就是辅助数组思路的原地版——它不再开新数组,而是把(i+k)%n的目标下标当成一条移动链条,直接在原数组里搬运。
没有这两步做底子,直接看环状替换的代码会觉得像天书:"为什么 prev 换来换去?为什么 count 要小于 n?"但如果脑子里有辅助数组的图景,你很容易理解:环状替换就是沿着"元素该去的位置"这条链,把值接力传递下去。
3. 三次反转法:一个自己推导也不难发现的 O(1) 空间方案
3.1 从"交换两段"的视角看轮转
先做一个关键观察:把数组分成两段,前面 n-k 个元素叫 A,后面 k 个元素叫 B(这里默认 k 已经取模过了)。原数组是 A+B,轮转 k 位的结果是 B+A。
怎么在 O(1) 额外空间下把 A+B 变成 B+A?答案是想办法"交换"这两段。于是反转就登场了,因为反转有两个很有用的性质:
- 一个序列反转两次,会恢复原样;
- 对整体做一次反转,等价于把两段的内部顺序各自逆序,同时两段位置互换。
具体来说,原数组是 A+B。先整体反转,得到 reverse(B) + reverse(A)。再分别反转 reverse(B) 和 reverse(A),得到 B + A。两次"反转后恢复"把逆序抵消掉了,但两段的位置已经互换。这就是三次反转法的全部原理。
用生活类比:两摞书 A 和 B 要互换位置,一件笨办法是把整摞倒过来,再分别把每摞书的顺序倒正,最终 A 和 B 就完成了交换。
3.2 代码与手动跟踪
先写一个通用的反转函数,双指针从两端向中间交换:
def reverse(nums, left, right): while left < right: nums[left], nums[right] = nums[right], nums[left] left += 1 right -= 1 def rotate(nums, k): n = len(nums) k %= n if k == 0 or n <= 1: return reverse(nums, 0, n - 1) # 1. 整体反转 reverse(nums, 0, k - 1) # 2. 反转前 k 个 reverse(nums, k, n - 1) # 3. 反转剩余 n-k 个用 nums = [1,2,3,4,5,6,7],k = 3 手动跟踪:
| 步骤 | 操作 | 数组 |
|---|---|---|
| 初始 | - | [1,2,3,4,5,6,7] |
| 第 1 次反转 | 反转整个数组 | [7,6,5,4,3,2,1] |
| 第 2 次反转 | 反转前 3 个 | [5,6,7,4,3,2,1] |
| 第 3 次反转 | 反转后 4 个 | [5,6,7,1,2,3,4] |
结果和预期一致。这个解法时间 O(n),额外空间 O(1),是这类数组轮转问题里最经典的原地算法。
3.3 三个容易写错的边界
第一个,漏掉k %= n。如果数组长度 n=5,k=12,你直接拿 12 去反转,前 12 个在哪?数组根本没有 12 个元素,边界直接崩掉。先取模,k 变成 2,一切正常。
第二个,第二段反转的边界写错。整体反转后,前 k 个元素是"原来后 k 个元素"的逆序,所以第一段是下标 [0, k-1],第二段是 [k, n-1]。不少人会写reverse(nums, k + 1, n - 1),把下标 k 的元素漏掉。我刚开始练的时候就在这里翻过车。
第三个,k=0 或 n=1 时没有提前返回。取模之后 k 已经是 0,三次反转执行完结果也是原数组,不会报错,但白白浪费一趟 O(n) 操作。加一句if k == 0 or n <= 1: return,干净又保险。
3.4 关于 Python 版反转写法的一点提醒
Python 里反转数组有很多花活:
nums[:] = nums[::-1] nums[:k] = nums[:k][::-1] nums[k:] = nums[k:][::-1]力扣上这个写法能通过,但如果你跟面试官说它是 O(1) 空间,可能会被追问:"nums[::-1]是不是创建了新的列表?"是的,切片会生成全新的列表,额外空间是 O(n)。nums[:k] = nums[:k][::-1]同样要先构造临时切片。
严格意义上,只有双指针手写反转才是 O(1) 额外空间。我的经验是:做题平台怎么方便怎么来,面试手写时优先用双指针版本。因为它在 C、Java、Python 里写法完全统一,不会被追问"切片的中间对象空间算不算"。
4. 环状替换法:站在"元素去向"的角度看问题
4.1 下标形成环:每个元素最终去哪
辅助数组法的公式是(i + k) % n。既然每个元素的目标下标都是确定的,我们能不能不开新数组,直接在原地搬运?可以,但必须小心:直接改 nums[i] 会覆盖还没搬走的元素,所以得沿着一条"链条"接力搬运。
把每个下标 i 看成节点,它指向自己的目标下标(i + k) % n。由于取模映射是一一对应的,这些有向边会构成若干个互不相交的环。比如 n=6、k=2 时,下标环是 0→2→4→0 和 1→3→5→1,一共两个环。再比如 n=7、k=3 时,7 个下标全部连成一个环。
环的个数等于 gcd(n, k),每个环的长度是 n / gcd(n, k)。这个数学性质不用背,但心中要有数:轮转不是一条走到头的直线,而是若干个环。知道"可能有不止一个环"这件事,写代码时才不会漏元素。
4.2 环状替换的代码和"count 计数器"的意义
沿着一个环搬运:记下起点值,走到目标位置,把目标位置的旧值"顶替"出来,继续沿环走到下一个目标……直到回到起点,完成一个环。然后从下一个没处理过的下标开始走下一个环。用一个 count 记录已经搬运了多少个元素,count 到 n 就说明所有元素都到达了正确位置。
def rotate(nums, k): n = len(nums) k %= n if k == 0 or n <= 1: return count = 0 start = 0 while count < n: cur = start prev = nums[cur] while True: nxt = (cur + k) % n temp = nums[nxt] nums[nxt] = prev prev = temp cur = nxt count += 1 if cur == start: break start += 1手动跟踪一下 nums = [1,2,3,4],k = 2:
- start=0:从下标 0 出发,链条是 0→2→0。搬运完这个环,数组变成 [3,2,1,4],count=2。
- start=1:从下标 1 出发,链条是 1→3→1。搬运完第二个环,数组变成 [3,4,1,2],count=4。
count 到 n 后循环结束,结果正确。外层循环用 count 判断总进度,start 依次递增寻找下一个环的起点;内层循环以"回到 start"作为单个环的结束条件。两者配合,才保证不重不漏。
4.3 环状替换和三次反转的取舍
如果在做题平台上提交,两种写法都能通过,时间复杂度都是 O(n),空间都是 O(1)。但我的建议很明确:面试优先讲三次反转。
理由有三点。第一,三次反转原理简单,一张图、三步操作就能讲清楚,面试官容易跟上思路。第二,环状替换要解释"为什么有多个环""count 为什么不会重复走",演示起来更啰嗦,人在紧张状态下容易写漏或者把 start 的递增位置放错。第三,反转是一个通用工具,很多数组字符串问题都能用;环状替换的代码则和这道题绑定得很深。
但环状替换值得学。它是"原地置换"类问题的典型代表,遇到"禁止额外空间、要求按某一规则重排元素"的题目时,这种沿环移动的思路会派上大用场。学会它的价值,是让你理解"置换环"这个抽象模型,而不是只背代码。
5. Python 切片一行解,以及nums[:]背后的坑
5.1 一行代码的正确写法
Python 选手最爱的解法,是把数组切片拼接后再原地写回:
def rotate(nums, k): k %= len(nums) nums[:] = nums[-k:] + nums[:-k]k=0 时,nums[-0:]是完整列表,nums[:-0]是空列表,拼接后还是原数组,正确。这是所有解法里最快写完的一个。如果题目改成"向左轮转 k 位",把符号换一下即可:nums[:] = nums[k:] + nums[:k]。
5.2 为什么nums[:] = ...和nums = ...完全不同
这应该是 Python 基础里很关键的一个知识点,但踩坑的人依然很多。函数参数传递的是引用,nums在函数内部只是一个局部变量,它指向调用方传来的同一个列表对象。当你写:
nums = nums[-k:] + nums[:-k]你实际做的是:新建了一个列表,然后让局部变量 nums 指向这个新列表。调用方手里的原列表对象,内容一个字都没变。判题器检查的正是那个原对象,所以提交无效。
而nums[:] = ...是切片赋值。它找到列表内部的存储区域,把里面的元素逐个替换成右侧列表的内容。整个过程不改变列表对象的身份,只改内容,调用方自然能看到变化。
这个坑我初学时就踩过,后来看别人写代码也见过不止一次。判断标准很简单:赋值号左边是nums还是nums[:]。记住,刷力扣这类数组题,想原地改就必须用后者。
5.3 切片解法的空间开销:它不满足 O(1) 的进阶要求
代码虽短,但要清醒:nums[-k:] + nums[:-k]一定会在内存里构造新列表。拆开看,nums[-k:]是一份临时列表,nums[:-k]是另一份临时列表,+又生成一份新的拼接结果,最后切片赋值再把元素逐个写回。峰值额外空间是 O(n)。
放到进阶要求"空间复杂度为 O(1) 的原地算法"面前,切片解法是不符合的。但它依然有实用价值:在数据集不大、内存不敏感、只需要快速完成任务的生产代码里,一行切片赋值比一个 12 行的反转函数好维护得多。我的看法是:考试和面试按题意来,日常业务代码按可读性来,两者用不同的标准。
顺手整理了四种解法的对比,方便按场景挑选:
| 解法 | 时间复杂度 | 额外空间 | 代码量 | 推荐场景 |
|---|---|---|---|---|
| 暴力轮转 | O(k*n) | O(1) | 很少 | 仅用来理解题意 |
| 辅助数组 | O(n) | O(n) | 少 | 写不错、能兜底 |
| 三次反转 | O(n) | O(1) | 中等 | 面试首选、通用工具 |
| 环状替换 | O(n) | O(1) | 中等 | 原地重排类题目 |
| Python 切片 | O(n) | O(n) | 极少 | 业务快速实现 |
5.4 顺手提一下另一类"一行解"
Python 的collections.deque自带rotate方法,也能一步得到轮转后的结果:
from collections import deque deq = deque(nums) deq.rotate(k)但这个方法需要先把列表转成 deque,返回的也是 deque 而不是 list。它更多是业务代码里顺手用的工具,不适合当刷题解法。真在面试里答 deque.rotate,面试官一问"内部是怎么实现的",答不上来就尴尬了。库函数是工具,不是算法,这个边界要拎清楚。
6. 边界用例、变体与刷题建议
6.1 一组能覆盖所有边界情况的测试用例
写题之前,先在脑中跑一遍这些用例:
| nums | k | 预期结果 | 说明 |
|---|---|---|---|
| [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,4,5,6] | 1 | [6,1,2,3,4,5] | 只右移一位 |
| [1] | 0 | [1] | 单元素数组 |
| [1] | 100 | [1] | 单元素加超大 k |
| [1,2,3] | 3 | [1,2,3] | k 等于数组长度 |
| [1,2,3,4,5] | 8 | [3,4,5,1,2] | k 大于数组长度 |
第二行用负数是为了防止你在写切片解法时被"负下标"迷惑。k 等于 n 的用例用来验证取模到底生效没有。这几组跑通,代码基本就稳了。
6.2 面试官最喜欢问的几个变体
左轮转。右轮转 k 位等价于左轮转 n-k 位,前提是先对 k 取模。所以所有右移写法改一下符号就是左移。
轮转字符串或字符数组。三次反转法完全通用,把整数数组换成字符数组即可,一个字节都不用多改。
轮转链表。思路迁移很有意思:先遍历一次得到链表长度 n,k 取模,再把首尾相连成环,从新的头节点前一个位置断开。链表题里这叫"找倒数第 k 个节点"的变体。很多人数组反转很熟练,一到链表就卡住,其实是没把"轮转 = 找断点"这件事想透。
判断两个字符串是否互为"轮转串"。把其中一个字符串拼接成双份,另一个如果是它的子串,就说明可以由轮转得到。这是经典的衍生题,考察的是"轮转"这个操作的等价关系。
6.3 我的最终建议:练习顺序与自查技巧
我给身边刷题的朋友的建议是分三步。第一步,先把辅助数组法写到全对,务必理解(i+k)%n这个公式。第二步,练三次反转法,要求能一口气默写出来,万一出错,能在 30 秒内定位是哪个反转范围写错。第三步,有余力再看环状替换,理解环模型即可,不必追求一次写对。
自查技巧很具体:永远准备一个小用例,比如 n=4、k=2,每次写完代码都在草稿纸上走一遍。三次反转容易错在第二次反转的边界,环状替换容易错在 start 的推进时机,多走两遍自然有手感。还有一个经验之谈:写完代码后把 k 换成 k+n 再跑一遍,结果应该完全一样,这样就验证了取模处理是对的。
这道题刷到这种程度,才算真正吃透。后面无论是遇到轮转类问题,还是在别的高难度数组题里需要用到反转技巧,你都会明显比之前顺手。轮转数组的价值不在于题面本身有多难,而在于它是很多数组、链表、字符串题的公共底座,把底座打牢,比多刷十道同类型的简单题更有用。