资讯详情

资讯详情

旋转数组最优解:三次翻转实现原地O(1)空间交换

第一次在面试里遇到旋转数组这道题是好几年前的事了。当时我的思路还停留在“开一个新数组把每个元素放到正确位置”的阶段一两分钟写完自我感觉良好。结果面试官轻轻补了一句“能不能不用额外空间”我一愣才意识到这道题真正想考的根本不是会不会复制数组而是对数组做原地操作的熟练程度。后来这几年我在在线题库、模拟面试、帮朋友排查代码的过程中反复碰到这道题最终沉淀下来的首选方案就是标题里写的“三次翻转”。思路朴素得惊人先把整个数组反转再把前一段反转最后把后一段反转。翻三次旋转完成。这篇文章就把这个方案拆开揉碎讲清楚原理是为什么、代码怎么写、边界怎么处理、和主流解法比它凭什么值得优先掌握。不管是刚开始刷题的新手还是准备面试想把手感捡回来的开发者这篇都值得读完。1. 旋转数组这道题到底在考什么1.1 题目定义与高频追问点先把题目固定在同一个坐标系里。给定一个长度为 n 的整数数组 nums 和一个非负整数 k要求把数组循环右移 k 位。举例来说nums [1, 2, 3, 4, 5, 6, 7]k 3结果应该是 [5, 6, 7, 1, 2, 3, 4]。注意题目描述里的三个关键词。第一个是“循环”。超出数组末尾的元素不会丢弃而是从开头重新进入。这本质上就是取模元素最终的位置是 (i k) % n。“循环”这个词决定了后面所有计算都离不开取模。第二个是“右移”。右移对应的是元素向索引增大的方向移动。如果题目改成左移不用慌左移 k 等价于右移 n - k两种问法在算法层面是同一条思路。第三个是“原地”。如果题目允许用额外数组那几乎是一行代码的事但如果要求空间复杂度 O(1)就必须在原地完成。很多题目会在最后注明“尽量使用 O(1) 空间”或“要求原地修改”这正是三次翻转方案发挥价值的地方。在线题库里这道题可能有不同的编号或名字有些叫“旋转数组”有些叫“轮转数组”但输入输出和考点基本一致。除了算法本身面试官通常还会追问几个问题时间复杂度是多少空间复杂度呢k 大于数组长度怎么办k 是 0 呢本质上是在考察边界意识。你平时有没有认真想过这些一问便知。1.2 为什么偏偏是“三次翻转”在真正上手写代码之前先建立直觉。假设数组由两部分组成左边一段 A、右边一段 B原始顺序是 A B。右移 k 位本质就是想把顺序变成 B A。举个例子[1, 2, 3, 4, 5, 6, 7] 右移 3 位相当于 A [1, 2, 3, 4]、B [5, 6, 7]目标是把 A 和 B 交换位置得到 B A [5, 6, 7, 1, 2, 3, 4]。问题于是转化成了怎么在 O(1) 空间内交换数组里两块相邻但长度不同的区域这就是三次翻转要解决的核心问题。整个过程分三步第一步反转整个数组得到 reverse(B) reverse(A)第二步反转前 k 个元素也就是刚才“整段 B 被反转后的结果”B 恢复原来的顺序第三步反转剩下 n - k 个元素A 也恢复原来的顺序。每一步操作的都是连续的一段区间只需要一个临时变量做交换空间开销 O(1)。这个方案的美妙之处在于它把一个看似需要整体搬移的问题拆解成了三次完全对称的局部反转操作。理解了这一层代码几乎不需要背现场推导就能写出来。2. 三次翻转的核心原理拆解2.1 反转操作的两个基本性质要彻底理解三次翻转先搞清楚“反转”这个操作本身的两个性质。第一个性质反转的逆操作还是反转。对任意区间执行两次反转元素会恢复原顺序。套用数学语言就是 reverse(reverse(X)) X。这个性质是后面两步“还原”A 段和 B 段内部顺序的根据。第二个性质反转可以把区间的首尾对调。整体反转后原来靠前的区间会跑到后面原来靠后的区间会跑到前面只是两段内部的顺序都是倒着的。用 A、B 来表达原始排列A B整体反转reverse(B) reverse(A)反转前段B reverse(A)反转后段B A看到了吗第二次反转把 B 内部的倒序恢复了第三次反转把 A 内部的倒序恢复了。两次“还原”分别作用在各自区间上互不干扰边界正好在 k 的位置切开。这里还有个值得想清楚的问题为什么必须是三次不是两次、不是四次只做整体反转A、B 两段内部顺序都是反的只做两次反转只能恢复其中一段另一段保持倒序三次正好是“整体反转制造位置交换 两次局部反转分别还原”。如果再来第四次多余的反转会再次破坏已经恢复的顺序。所以三次不多不少刚刚好。2.2 一次完整流程手推两轮数组光讲抽象公式容易飘拿具体数组过一遍。还是用 nums [1, 2, 3, 4, 5, 6, 7]k 3。第一步反转整个数组[7, 6, 5, 4, 3, 2, 1]这时原来在后面的 [5, 6, 7] 被甩到了前面但顺序是反的前面的 [1, 2, 3, 4] 跑到了后面顺序也是反的。第二步反转前 3 个元素也就是当前数组中索引 0 到 2 的部分[5, 6, 7, 4, 3, 2, 1]前三个元素从 [7, 6, 5] 变成了 [5, 6, 7]。注意 B 段的内部顺序已经恢复。第三步反转从索引 3 到末尾的剩余部分[5, 6, 7, 1, 2, 3, 4]A 段的内部顺序也恢复了。两段的位置完成交换右移 3 位的结果刚好就是 [5, 6, 7, 1, 2, 3, 4]。再试一个稍微不一样的例子nums [1, 2, 3, 4, 5]k 2。整体反转得到 [5, 4, 3, 2, 1]反转前 2 个元素得到 [4, 5, 3, 2, 1]反转剩余 3 个元素得到 [4, 5, 1, 2, 3]。跟直接移动验证的结果一致。写代码的时候拿这种小例子在纸上推一遍往往比干记边界条件更能避免出错。2.3 左移和右移的统一视角很多题目问的是左移。实际上左移 k 位就是把每个元素向索引减小的方向移动 k 格这等价于右移 n - k 位。旋转方向不同三次翻转的切分点也不同。右移 k 位反转整段 → 反转前 k 个 → 反转后 n - k 个左移 k 位反转整段 → 反转前 n - k 个 → 反转后 k 个。把两条规则放在一起看规律其实是一致的总是先整体反转然后把“最终要跑到前面”的那一段先恢复顺序最后把剩余区间恢复顺序。我个人记法只有一句话最后落在数组前面的是哪一段就先反转哪一段。还有个容易被忽略的点如果两次连续旋转先右移 k1 再右移 k2最终结果等价于右移 (k1 k2) % n。这一点在做组合变换、或者题目里要求“旋转后接着做某种二分查找”时能帮你把参数先化简。旋转本质上是一种模 n 的加法三次翻转只是这种加法在数组层面的物理实现。3. 代码实现与细节打磨3.1 最简实现Python、Java 与 C 对照先给一份可以直接用的实现。核心是一个通用的反转函数三个区间调用三次。def rotate(nums, k): n len(nums) if n 1: return k % n if k 0: return reverse(nums, 0, n - 1) reverse(nums, 0, k - 1) reverse(nums, k, n - 1) def reverse(nums, left, right): while left right: nums[left], nums[right] nums[right], nums[left] left 1 right - 1public void rotate(int[] nums, int k) { int n nums.length; if (n 1) return; k k % n; if (k 0) return; reverse(nums, 0, n - 1); reverse(nums, 0, k - 1); reverse(nums, k, n - 1); } private void reverse(int[] nums, int l, int r) { while (l r) { int tmp nums[l]; nums[l] nums[r]; nums[r] tmp; l; r--; } }void rotate(vectorint nums, int k) { int n nums.size(); if (n 1) return; k % n; if (k 0) return; reverse(nums, 0, n - 1); reverse(nums, 0, k - 1); reverse(nums, k, n - 1); } void reverse(vectorint nums, int l, int r) { while (l r) { swap(nums[l], nums[r]); l; --r; } }三份代码逻辑完全一样。Python 里交换两个元素可以直接用元组赋值但底层本质还是临时变量交换所以空间复杂度依然是 O(1)不用担心“简洁写法”让空间变高。C 里我可以直接调用 std::reverse但手写一遍更能体现对原理的理解面试时也不用依赖库函数。这里有个细微之处值得说明反转函数接收的是左右闭区间 [left, right]也就是说 right 是最后一个要交换的元素的下标。因此调用时第二段是 reverse(nums, k, n - 1)不是 reverse(nums, k, n)。如果写成右开区间调用方式就得跟着变。闭区间和开区间本身没有对错但要统一最怕的就是一会儿闭一会儿开现场改来改去很容易翻车。3.2 k 值归一化与边界条件写正确性的关键在 k 的处理。数组长度是 n右移 n 位跟没移一样右移 n k 位跟右移 k 位一样。所以第一步应该做 k % n把 k 归一化到 [0, n - 1] 这个范围。注意不同语言对负数取模的语义不同。Java、C 里 -3 % 7 的结果是 -3而 Python 里 -3 % 7 的结果是 4。如果题目允许 k 为负数表示左移在 Java 里要写成k ((k % n) n) % n;在 Python 里直接 k % n 就够了解释器会帮你得到正确的非负余数。这个语言差异我在面试代码里见过不止一次出事。某次我帮同事 review 代码他用的是 JavaScriptJS 和 Java 一样负数取模结果带负号结果 k -1 时直接跳过所有反转数组纹丝不动排查了半天才发现是取模语义的坑。边界条件列出来其实就那么几条n 0没有元素可旋转直接返回即可n 1无论 k 是多少结果都是原数组提前返回省掉无意义的反转k 0 或 k % n 0旋转不改变数组提前返回k 正好等于 n同上取模后变成 0。提前返回不是炫技而是让主流程保持干净。如果不提前返回代码也能跑但会多执行一次完整的整体反转加两次分段反转等于白做无用功。更关键的是提前返回能避免对空数组或单元素数组做区间反转时的隐性越界。3.3 泛化字符串旋转与可变序列数组能做的事情很多可变序列都能做。Python 里字符串是不可变对象直接反转需要先转成列表操作完再 join 回来但如果用的是 Java 的 StringBuilder 或 C 的 string修改就允许原地进行。核心逻辑不变只是把“数组元素交换”换成“字符交换”。def rotate_string(s, k): chars list(s) n len(chars) if n 1: return s k % n reverse(chars, 0, n - 1) reverse(chars, 0, k - 1) reverse(chars, k, n - 1) return .join(chars)如果是 Python 的切片操作旋转字符串确实有一行写法s[-k:] s[:-k]。但要清楚这行代码创建了一个新字符串空间复杂度 O(n)适合快速出结果、不适合需要原地处理的场景。面试里如果题目强调原地切片写法只能作为口头提及的补充方案不能当正解。三次翻转的思路也不只局限在一维序列。二维数组中有一个非常经典的旋转问题把矩阵原地顺时针旋转 90 度。常见解法是先转置再水平镜像或者先上下翻转再转置。你会发现这和三次翻转是同一个思维模式把一个复杂的整体变换拆成若干个简单的、可逆的局部操作。理解了这个模式遇到新题时会更愿意往“分解为反转”的方向想。4. 常见坑点与排查技巧实录4.1 面试和刷题中的高频错误速查我在帮别人 review 这段代码时见过的问题高度集中。先列一张速查表方便对照检查自己的实现。症状病因修法k 很大时结果完全不对没有先 k % n反转区间越界进入主流程前统一取模k 0 或 k n 时数组被破坏整体反转执行了但分段反转因 k 0 跳过导致顺序错乱提前返回或保证区间为空时不执行反转左侧元素和右侧元素错位首尾颠倒反转函数右边界写成开区间统一闭区间语义调用处严格对齐空数组或单元素数组报错反转函数没做空区间保护在 rotate 开头处理 n 1负数 k 导致结果不变或异常语言取模语义没处理Java/C 用 (k % n n) % nPython 直接 k % n测试用例通过但性能不合格反转函数内用了切片赋值或创建了新数组确保交换只用临时变量这些错误大多不是算法理解的问题而是细节纪律的问题。我自己踩得最深的一个坑是把目标区间长度搞混右移 k 位反转前 k 个这个“前 k 个”是整体反转之后的前 k 个而不是原始数组的前 k 个。如果你在实现里先反转了局部再反转整体顺序错了结果就会变成另一种“伪旋转”看起来像模像样但随机抽几个样例就露出马脚。4.2 用边界用例快速自测写完之后怎么快速确认代码没有问题设计输入输出时不要只测题目的样例按下面的清单跑一遍nums [1, 2, 3, 4, 5, 6, 7]k 3期待 [5, 6, 7, 1, 2, 3, 4]nums [1, 2, 3, 4, 5]k 2期待 [4, 5, 1, 2, 3]nums [1, 2, 3]k 0期待 [1, 2, 3]nums [1, 2, 3]k 3期待 [1, 2, 3]nums [1]k 100期待 [1]nums []k 7期待 []nums [1, 2, 3, 4]k -1如果题目允许负数期待 [2, 3, 4, 1]。这几个用例覆盖了常规样例、零旋转、整周旋转、单元素、空数组和负数 k。能用一笔画完这组用例至少能证明代码在手写和提交两种环境下都不会出低级事故。我个人的习惯是手写这段代码时故意把 k 设成一个很离谱的数比如 100 万、数组长度 7验证取模有没有写再把 k 设成 0验证提前返回有没有生效。另外一个小技巧如果你在编译器或解释器里调试可以在反转函数内部打印 left 和 right 的变化过程逐一核对三次调用的区间。很多时候你以为的第二段区间和实际传入的区间差 1打印出来一眼就能看出来。5. 几种主流解法的横向对比5.1 五种方案的思路与适用场景旋转数组不止一种解法面试时也经常被要求比较。这里把常见的五种方案都过一遍。第一种暴力逐位移动。每次把数组右移一位循环 k 次。思路最直白但每移动一位要 O(n) 时间整体 O(n × k)。k 接近 n 时这个开销是平方级的基本属于面试中用来引出优化话题的反面案例。第二种额外数组。新开一个长度相等的数组把 nums[i] 放到 rotated[(i k) % n]最后拷回原数组。时间 O(n)、空间 O(n)代码最不容易写错但如果题目强调原地这个方案直接出局。第三种三次翻转。时间 O(n)、空间 O(1)、代码短、逻辑易解释。这也是本文的主角适合绝大多数场景。第四种循环替换也叫 juggling 算法。从第一个元素出发沿着“当前位置 k”的路径不断把元素搬到目标位置一路替换下去直到回到起点再从不等于起点的下一个元素继续。这里需要用到最大公约数n 和 k 的 gcd 决定了需要启动多少条独立的替换链。举一个具体的例子n 6、k 4gcd(6, 4) 2所以有 2 条链每条链的长度是 n / gcd 3。时间 O(n)、空间 O(1)但因为涉及数论背景手写时容易在边界处卡壳面试时如果对证明没有十足把握不如选三次翻转。第五种Python 切片拼接。一行代码nums[:] nums[-k:] nums[:-k]能直接完成旋转。注意赋值给 nums[:] 而不是 nums否则会变成指向新列表原数组没有被修改。这个写法时间 O(n)、空间 O(n)适合日常快速处理问题不适合原地要求严格的面试题。5.2 复杂度对比与选型结论把上面五种方案的复杂度整理成一张表。解法时间复杂度空间复杂度原地推荐度暴力逐位移动O(n × k)O(1)是不推荐仅用于教学铺垫额外数组O(n)O(n)否快速验证结果时用三次翻转O(n)O(1)是首选循环替换O(n)O(1)是可作为加分项但实现复杂切片拼接O(n)O(n)否工程速写可以用三次翻转在五项对比里几乎没有短板。时间复杂度最优空间复杂度最优代码量少逻辑能用口语说清楚。相比循环替换它不需要 gcd 的数论知识相比额外数组它满足了最终的原地要求相比暴力法它没有平方级的时间退化。面试的时候先讲清楚思路和复杂度再动手写写完用边界用例自测这一套流程本身就是面试官想看到的完整闭环。有一点想提醒不要在面试中刻意避开简单解。如果面试官没有明确要求原地先说出额外数组方案再主动说明“如果空间受限我可以用三次翻转把空间压到 O(1)”效果往往比闷头直接写三次翻转更好。因为它展示了你具备从不同约束条件出发选择方案的能力这是比记住一个解法更重要的信号。6. 后续扩展与我的实操体会6.1 从三次翻转到反转类题目家族三次翻转并不是孤立技巧。把“先整体反转再局部反转”这个模式稍加变形就得到一类经典问题反转字符串中的单词。比如给定一句英文要求每个单词内部的字母顺序不变但单词之间的顺序整体反转。标准做法是先反转整个字符串让单词顺序颠倒然后逐个单词再反转把单词内部的字母恢复。这和旋转数组的三次翻转用的是完全同一套逻辑——整体反转制造“位置颠倒”局部反转恢复“内部顺序”。类似的题目还有很多比如判断两个字符串是否互为旋转字符串有一个取巧解法判断 s2 是否出现在 s1 s1 中。这个解法的原理跟旋转数组的取模思想同源但实现方式独立于三次翻转可以作为知识网络里的另一个节点。理解了这类“反转家族”之后再回来看旋转数组你会觉得它不是一道孤立的题而是一种思维模式的入口。面试里遇到新题我习惯先问自己能不能用两次反转的组合来表达这个变换能的话大概率就是 O(n) 时间和 O(1) 空间的解。6.2 我在实际使用中最想强调的两件事最后聊两句实际体会不算什么高深道理但都是我踩过坑之后才记住的。第一重视 k % n 这一行。没有它代码在样例上也能过因为样例的 k 往往小于 n但一旦评测数据里出现 k n反转区间就越界轻则报错重则静默产出错误结果。所有看起来“灵异”的失败最后排查下来大概率都落在取模、边界、空值这三类问题上。写完后先口头背诵一遍边界条件再提交成本极低收益极高。第二先在纸上推演再写代码。三次翻转的代码虽然短但它对“当前状态”的感知要求很高。第几次反转作用于哪段区间是整体反转之后的区间顺序不是最初的区间顺序。我见过太多人背下了三次调用的代码却说不清为什么这样调面试官反问一个“如果把整体反转放最后会怎样”立刻就卡住。真正理解原理之后你甚至可以当场推导出左移的版本而不是死记两套参数。按照我个人的习惯凡是涉及数组、字符串的旋转题我默认先用三次翻转做基准实现再根据题目约束决定要不要切换方案。这个习惯让我在真实面试中少踩了很多坑也希望它能成为你工具箱里一个稳定可靠的备用件。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →