尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

轮转数组四种Python解法:从切片到O(1)原地反转的完整拆解

轮转数组四种Python解法:从切片到O(1)原地反转的完整拆解 如果你刷力扣 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 的负索引会把 k0 变成一个隐蔽的坑Python 负索引在处理 k0 时有一个很少有人注意到的细节-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)会变回 Breverse(A)会变回 A整体恰好就是 BA。用例子走一遍nums [1,2,3,4,5,6,7]n 7k 3A 是[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。这个防御性写法做题时能省不少事。原因很简单k0 时三次反转会把数组从头到尾反转一遍再局部反转两遍结果虽然还是原数组但白白跑了两个 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 模拟一次跳跃从 index0 出发会发生什么环状替换法也叫跳跃替换。思路是从某个位置出发把当前位置的值放到它应该去的目标位置(当前下标 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 个元素。因为 n7 和 k3 互质这一轮跳跃就覆盖了所有下标一次循环完成整个轮转。这就是环状替换“每个元素只移动一次”的含义总时间 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 放到 33 放到 55 回到 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 控制循环写法会别扭很多而且很容易在 gcd1 时提前 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], k3期望得到[5,6,7,1,2,3,4][-1,-100,3,99], k2期望得到[3,99,-1,-100][1,2], k3期望得到[2,1][1], k0期望得到[1]list(range(10000)), k1000000007先取模再和切片结果对照验证代码可以写成这样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.rotateO(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(maxlen60)直接控制窗口长度第二种是环形缓冲区嵌入式或者消息队列里很常见核心就是(head offset) % capacity这种取模第三种是数据重排比如按权重轮询一批待执行任务、把最近常用的项挪到列表头部本质上都是围绕一个有序序列做位移。所以别觉得排序、轮转这类题离工程很远它们只是换了一层业务外衣底层逻辑完全一样。最后给一个我自己的刷题习惯。轮转数组这道题我两年里在不同场景下遇到三四次每次都是同一个套路先口头答出三种做法的复杂度再在白板上手写三次反转。能做到这么顺不是记性好而是第一次刷的时候把四种写法都用 assert 跑了一遍并把k % n这一行牢牢记住了。你现在刷到这道题不妨也花 20 分钟把三次反转和环状替换各手写一遍再亲自验证一次nums[:]和nums 的区别。这些功夫不会白费下一次再见到轮转数组你大概率不用思考就能写对。
返回列表