--- tags: ["LeetCode", "数组", "双指针", "原地算法", "中等"] create time: 2026-05-14 13:00 --- # 15-轮转数组 ## 题面 > **LeetCode 189. Rotate Array** 给定一个整数数组 `nums`,将数组中的元素**向右轮转** `k` 个位置,其中 `k` 是非负数。 **示例 1:** ``` 输入: nums = [1,2,3,4,5,6,7], k = 3 输出: [5,6,7,1,2,3,4] 解释: 向右轮转 1 步: [7,1,2,3,4,5,6] 向右轮转 2 步: [6,7,1,2,3,4,5] 向右轮转 3 步: [5,6,7,1,2,3,4] ``` **示例 2:** ``` 输入:nums = [-1,-100,3,99], k = 2 输出:[3,99,-1,-100] 解释: 向右轮转 1 步: [99,-1,-100,3] 向右轮转 2 步: [3,99,-1,-100] ``` **提示:** - `1 <= nums.length <= 10^5` - `-2^31 <= nums[i] <= 2^31 - 1` - `0 <= k <= 10^5` --- ## 思路 > [!question] 💡 思考 想象一排人站成一列,每个人要向右移动 k 个位置——最后 k 个人会绕回到队伍最前面。**如果只允许用一个临时变量来交换数据,你会怎么做?** 这看似简单,但「原地 O(1) 空间」的要求让问题变得耐人寻味。我们先从直观方法出发,逐步收敛到最优解。 ### 方法一:额外数组(O(n) 空间) > [!abstract] 📐 最朴素的映射 最直接的想法:对于每个位置 `i`,轮转后的新位置就是 `(i + k) % n`。我们可以开辟一个新数组存放结果,再拷回原数组。 > [!note] 📌 关键点:取模处理越界 ``` 新位置 i' = (i + k) % n ``` 这个 `% n` 操作确保下标不会超出数组边界——当 `i + k >= n` 时自动"绕回"开头。 #### 逐步跟踪 以 `nums = [1,2,3,4,5,6,7]`, `k = 3`, `n = 7` 为例: | 原位置 i | 原值 | 新位置 (i+3)%7 | 新位置的值 | |---------|------|---------------|----------| | 0 | 1 | 3 | nums[3] = 4 | | 1 | 2 | 4 | nums[4] = 5 | | 2 | 3 | 5 | nums[5] = 6 | | 3 | 4 | 6 | nums[6] = 7 | | 4 | 5 | 0 | nums[0] = **1** ← 绕回开头! | | 5 | 6 | 1 | nums[1] = **2** | | 6 | 7 | 2 | nums[2] = **3** | 结果:`[5,6,7,1,2,3,4]` ✅ > [!example] 🔀 关联观察 你有没有注意到——最终结果的**前 k 个元素**正好是原数组中**倒数 k 个元素**?也就是说,向右轮转本质上是把尾部的一段搬到了头部。这给了我们另一种视角,也是后续更优方法的灵感来源。 ```mermaid flowchart LR A["原始: [1,2,3,4 | 5,6,7]"] --> Cut["在 n-k 处切分"] Cut --> Left["左段 A: [1,2,3,4]"] Cut --> Right["右段 B: [5,6,7]"] Right --> Swap["交换 AB 顺序"] Left --> Swap Swap --> Result["结果 BA: [5,6,7,1,2,3,4]"] ``` > [!tip] 💡 这个视角是后续「三段反转」方法的灵感来源——我们不需要真正切断和拼接数组,只需通过三次反转来达成同样的效果。下面展开详细说明。 - **时间复杂度**:O(n) — 两次遍历 - **空间复杂度**:O(n) — 需要额外数组 虽然不符合进阶要求,但它是最容易验证正确性的基准解法。 --- ### 方法二:反转数组 ⭐(O(1) 空间,推荐) > [!tip] 🔑 核心洞察:三段反转等于一次拼接 回顾上面的观察——把 `[1,2,3,4,5,6,7]` 在 `n-k` 处切成两段,再交换它们的顺序就得到了答案。那么,**能不能只用三次反转来完成这个"交换"?** 答案是肯定的。这是经典的技巧: ``` [A B] → 先分别反转 A 和 B → [A_rev B_rev] → 再整体反转 → B A ``` 为什么可行?因为反转操作相当于把一段序列的索引做镜像翻转。对两段分别反转后再整体反转,等同于直接交换它们的相对位置。 #### 三步反转详解 以 `nums = [1,2,3,4,5,6,7]`, `k = 3`, `n = 7` 为例: **预处理**:`k = k % n = 3 % 7 = 3`,避免无效轮转。 标准的三次反转方案如下: ``` Step 0: [1, 2, 3, 4, | 5, 6, 7] ← 原始数组 Step 1: [7, 6, 5, 4, | 3, 2, 1] ← 反转全部 [0..n-1] Step 2: [5, 6, 7, 4, | 3, 2, 1] ← 反转前 k 个 [0..k-1] Step 3: [5, 6, 7, 1, | 2, 3, 4] ← 反转剩余部分 [k..n-1] ←───────────→ ←──────────────→ 结果正是右旋 k 位! ``` 每一步的逻辑: | 步骤 | 反转区间 | 目的 | 效果 | |------|---------|------|------| | Step 1 | `[0, n-1]` 全部 | 将整个数组倒过来 | 末尾的元素跑到了前面,但顺序也颠倒了 | | Step 2 | `[0, k-1]` 前 k 个 | 修正前 k 个的顺序 | 这些本来来自末尾的元素恢复正序 | | Step 3 | `[k, n-1]` 剩余的 | 修正剩余部分的顺序 | 这些本来来自开头的元素恢复正序 | #### 逐步可视化 以 `nums = [1,2,3,4,5,6,7]`, `k = 3`, `n = 7` 为例: ```mermaid flowchart LR S0["Step 0
'原始': [1,2,3, 4,5,6,7]"] --> S1["Step 1
reverse(0,6)
[7,6,5, 4,3,2,1]"] S1 --> S2["Step 2
reverse(0,2)
[5,6,7, 4,3,2,1]"] S2 --> S3["Step 3
reverse(3,6)
[5,6,7, 1,2,3,4]"] S3 --> DONE["✅ 完成!"] ``` 关键点:**Step 2 和 Step 3 的分割点正是 k=3**。前三个位置放的是末尾元素的正序,后四个位置放的是开头元素的正序。 > [!step] 伪代码总览 > 1. `k = k % len(nums)` — 去冗余 > 2. `reverse(nums, 0, n-1)` — 反转全部 > 3. `reverse(nums, 0, k-1)` — 反转前 k 个 > 4. `reverse(nums, k, n-1)` — 反转剩余部分 > 5. 返回 `nums`(原地修改) #### 证明其正确性 设数组由两部分组成:`A`(前 `n-k` 个元素)和 `B`(后 `k` 个元素),目标是将 `B` 移到 `A` 前面得到 `BA`。 ``` 初始: AB 反转全部: (AB)^R = B^R A^R (整体倒序) 反转前 k 个: (B^R)^R A^R = B A^R (恢复 B 的正序) 反转剩余: B (A^R)^R = BA (恢复 A 的正序)✅ ``` > [!success] ✅ 对称美学的体现 这个解法的精妙之处在于:**你不需要知道任何元素的值,也不需要额外的存储空间**。反转只是通过两个指针向中间靠拢、逐次交换两端元素来实现的——它改变的是"相对顺序"这个结构本身。这在面试中经常给面试官留下深刻印象。 - **时间复杂度**:O(n) — 三个反转各遍历一部分,总共扫描 n/2 + k/2 + (n-k)/2 = n 次 - **空间复杂度**:O(1) — 原地操作,无额外空间 --- ### 方法三:循环替换(O(1) 空间,进阶理解) > [!question] 💡 能否用"单指针游走"解决问题? 我们不借助反转,而是让每个元素沿着自己的"目标路径"走到终点。具体来说: 把位置 `0 → k → 2k → 3k ...` 看作一条环上的轨道,每次把当前元素放到目标位置上。但是——**如果有多个独立的环怎么办?** 这就引出了这道题最微妙的数学性质。 #### GCD 与独立环的数量 考虑 `nums = [0, 1, 2, 3, 4, 5]`, `k = 2`: ``` 从位置 0 开始游走: 0 → 2 → 4 → 0 (回到起点,形成第一个环,访问了 {0, 2, 4}) 从位置 1 开始游走: 1 → 3 → 5 → 1 (回到起点,形成第二个环,访问了 {1, 3, 5}) ``` 共 `gcd(6, 2) = 2` 个独立环,恰好覆盖所有 6 个位置。 > [!abstract] 📐 关键定理 **向右轮转 k 位的置换操作由 `gcd(n, k)` 个不相交的循环组成。** 每个循环内部的长度均为 `n / gcd(n, k)`。 这意味着我们只需要启动 `gcd(n, k)` 次游走,每次从一个尚未访问的起始位置出发,沿着 `next(i) = (i + k) % n` 的规则前进,就能完成全部替换。 #### 逐步跟踪 以 `nums = [1, 2, 3, 4, 5, 6, 7]`, `k = 3`, `n = 7` 为例: ``` gcd(7, 3) = 1 → 只有 1 个环,从位置 0 开始 起点 i=0, prev = nums[0] = 1: 第1次: next=(0+3)%7=3, swap(prev=1, nums[3]=4) → nums[3]=1, prev=4 第2次: next=(3+3)%7=6, swap(prev=4, nums[6]=7) → nums[6]=4, prev=7 第3次: next=(6+3)%7=2, swap(prev=7, nums[2]=3) → nums[2]=7, prev=3 第4次: next=(2+3)%7=5, swap(prev=3, nums[5]=6) → nums[5]=3, prev=6 第5次: next=(5+3)%7=1, swap(prev=6, nums[1]=2) → nums[1]=6, prev=2 第6次: next=(1+3)%7=4, swap(prev=2, nums[4]=5) → nums[4]=2, prev=5 第7次: next=(4+3)%7=0, swap(prev=5, nums[0]=?) → nums[0]=5, prev=1 结果:[5,6,7,1,2,3,4] ✅ ``` 验证:`[5,6,7,1,2,3,4]` —— 与预期一致!这里的操作核心是**"缓存待插入值 + 逐位覆盖"**——每一步先记下来要覆盖的位置的原值,用它作为下一轮的待插入物,形成一条值的流动链。 #### 何时需要多个环? 看 `nums = [1,2,3,4,5,6]`, `k = 2`,`n = 6`: ``` gcd(6, 2) = 2,需要 2 个环 环 1(起点 i=0): 0→2→4→0,每步走2格 环 2(起点 i=1): 1→3→5→1,每步走2格 ``` 每个环各自完成自己的轮换任务。外层循环从 `i = 0` 到 `gcd(n, k) - 1` 依次启动。 > [!warning] ⚠️ 常见陷阱 > **不要跳过已经处理过的环!** 如果你不以内层循环次数 `n/gcd(n, k)` 作为终止条件,而是尝试一直走到"回到起点",那对于多环的情况会在第一个环就无限循环。正确的做法是按环的长度精确控制内层循环的执行次数。 - **时间复杂度**:O(n) — 每个元素恰好被访问并写入一次 - **空间复杂度**:O(1) — 只需常数个辅助变量 --- ### 方法对比 > [!info] 📊 三种方法综合对比 | 维度 | 额外数组 | 反转法 ⭐ | 循环替换 | |------|---------|----------|---------| | 时间复杂度 | O(n) | O(n) | O(n) | | 空间复杂度 | O(n) | **O(1)** | **O(1)** | | 原地操作 | ❌ | ✅ | ✅ | | 理解难度 | ⭐ 极简 | ⭐⭐ 三段反转需领悟 | ⭐⭐⭐ 涉及 GCD 概念 | | 实际性能 | 较慢(分配+拷贝) | 快(纯内存交换) | 快(同反转) | | 面试推荐度 | 基础版可展示 | **首选**,优雅且高效 | 加分项,展示深度 | --- > [!example] 🔀 关联变体题 - **LeetCode 1791. Find Center of Star Graph** — 同样利用 `gcd` 分析图的连通分量 - **LeetCode 46. Permutations / 47. Permutations II** — 全排列问题中遍历所有旋转也是一种子集;循环替换本质是在做「下一个排列」的子操作 - **LeetCode 26. Remove Duplicates from Sorted Array** — 原地修改、双指针、不超出数组长度的经典范式 - **LeetCode 31. Next Permutation** — 同样是「仅用交换重排数组」的问题,目标排列可以用对合操作分解来思考 - **[[16-除了自身以外数组的乘积]]** — 另一个 O(1) 额外空间的数组原地变换题,共享相同的空间约束思维 --- ## 代码提示 > [!abstract] 📝 Go 伪代码框架(反转法) ```go func rotate(nums []int, k int) { n := len(nums) k = k % n // 去冗余轮转 reverse(nums, 0, n-1) // 反转全部 reverse(nums, 0, k-1) // 反转前 k 个 reverse(nums, k, n-1) // 反转剩余部分 } func reverse(nums []int, left, right int) { for left < right { nums[left], nums[right] = nums[right], nums[left] left++ right-- } } ``` > [!abstract] 📝 Go 伪代码框架(循环替换) ```go func rotate(nums []int, k int) { n := len(nums) k = k % n count := 0 // 已放置元素计数 for start := 0; count < n; start++ { curr := start prev := nums[start] for { next := (curr + k) % n nums[next], prev = prev, nums[next] // 旋转 curr = next count++ if start == curr { break } // 回到起点 } } } ``` --- ## 技巧 > [!tip] 🔑 核心模式:原地变换的三大策略 | 策略 | 代表题目 | 思想 | |------|---------|------| | **分段反转** | [[15-轮转数组|轮转数组]]、反转单词顺序、ZigZag 变换 | 把复杂重排分解为若干简单反转的组合 | | **循环追踪** | [[15-轮转数组|轮转数组]]、置换数组、[[17-缺失的第一个正数|缺失的第一个正数]] | 利用置换的循环分解性质,一步到位 | | **前后双指针** | 接雨水、[[16-除了自身以外数组的乘积|除了自身以外数组的乘积]]、盛最多水的容器 | 两端向内逼近,每次排除一个"确定不如"的选项 | > [!note] 📌 必做的预处理:`k %= n` 不管用哪种方法,第一步都应该执行: ```go k = k % len(nums) ``` 原因: 1. **性能**:当 `k >= n` 时,多余的轮转周期不会改变结果 2. **安全性**:避免后续计算中出现越界或无限循环(比如 `k = n` 时某些实现可能出错) 3. **语义**:轮转 n 次等价于什么都没做,所以 `% n` 是最自然的归约 > [!question] 💡 向左轮转怎么写? 向左轮转 k 位与向右轮转的本质区别在于**方向相反**。可以有以下三种处理方式: #### 方式一:转化为右旋(最简单) 左旋 k 位等价于右旋 `(n - k) % n` 位。直接复用右旋逻辑即可。 ```go // rotateLeftByRightRotate 用右旋实现左旋 func rotateLeftByRightRotate(nums []int, k int) { n := len(nums) if n <= 1 { return } rotate(nums, (n-k)%n) // 复用上面的 rotate(三次反转版本) } ``` #### 方式二:调整反转区间(原地最优) 左旋的三步反转与右旋不同,区间划分如下: | 步骤 | 操作 | 效果 | |------|------|------| | Step 1 | `reverse(nums, 0, k-1)` | 反转前 k 个元素 | | Step 2 | `reverse(nums, k, n-1)` | 反转剩余部分 | | Step 3 | `reverse(nums, 0, n-1)` | 整体反转 | ``` [left_part | right_part] → 分别反转 → [left_rev | right_rev] → 整体反转 → right left ✅ ``` ```go // rotateLeft 用三次反转实现向左轮转 func rotateLeft(nums []int, k int) { n := len(nums) if n <= 1 || k == 0 { return } k %= n reverse(nums, 0, k-1) // Step 1: 反转前 k 个 reverse(nums, k, n-1) // Step 2: 反转剩余部分 reverse(nums, 0, n-1) // Step 3: 整体反转 } ``` > [!tip] 💡 左右旋转的反转顺序对比 | | 向右旋转 k 位 | 向左旋转 k 位 | |---|---|---| | 第 1 步 | `reverse(0, n-1)` — 先反全 | `reverse(0, k-1)` — 先翻左段 | | 第 2 步 | `reverse(0, k-1)` — 再翻前 k | `reverse(k, n-1)` — 再翻右段 | | 第 3 步 | `reverse(k, n-1)` — 最后翻尾部 | `reverse(0, n-1)` — 最后反全 | #### 方式三:循环替换中改为减法步进 在循环替换方法中,只需将步进方向从加法改为减法: ```go // rotateCyclicLeft 用循环替换实现向左轮转 func rotateCyclicLeft(nums []int, k int) { n := len(nums) if n <= 1 || k%n == 0 { return } k %= n count := 0 // 已放置元素计数 for start := 0; count < n; start++ { curr := start prev := nums[start] var next int // 必须预先声明,因为 for 体内会先用到再赋值 for { next = (curr - k + n) % n // ← 注意:减法 + n 防负数 nums[next], prev = prev, nums[next] curr = next count++ if curr == start { break } } } } ``` 面试时如果能主动提出这个变体并给出至少两种方案,会是很好的加分项。 > [!warning] ⚠️ 边界情况 checklist | 情况 | 处理方式 | |------|---------| | `nums` 为空或只有一个元素 | 无需轮转,直接返回 | | `k == 0` | 无需操作,提前 return | | `k == n` 或 `k % n == 0` | 等价于没动过,直接返回 | | `k > n` | 必须先 `k %= n` 再计算 | | 全相同元素 | 任意方法都能正确处理 | > [!note] 🐹 Go 语言特有细节 - Go 支持**多重赋值**:`a, b = b, a` 可以一行完成交换,内部机制是先求右边所有表达式的值再同时赋值,天然避免了临时变量的需求。 - Go 的 slice 是引用类型,在函数内修改 `nums` 的内容会直接影响调用方的 slice,因此可以直接传入 `[]int` 而非 `*[]int`。 > [!success] ✅ 记忆口诀 > 欲右旋,先全翻;头正序,尾正翻。 > 三步反转定乾坤,零额外空间搞定。 --- ## 代码 > [!success] ✅ 方法一:额外数组(基准解法) ```go // rotateExtraArray 使用额外数组实现向右轮转。 // 空间复杂度 O(n),易于理解和验证。 func rotateExtraArray(nums []int, k int) { n := len(nums) if n <= 1 || k%n == 0 { return } k %= n // 去冗余 res := make([]int, n) // 创建新数组 for i := 0; i < n; i++ { res[(i+k)%n] = nums[i] // 映射到新位置 } copy(nums, res) // 拷贝回原数组 } ``` > [!success] ✅ 方法二:反转数组(⭐ 推荐) ```go // rotate 使用三次反转实现向右轮转数组。 // 原地 O(1) 空间,时间 O(n),面试首选解法。 // // 原理:将数组视为 A+B 两段,目标是将 B 移动到 A 前面得到 BA。 // 公式推导:(AB)^R = B^R A^R → (B^R A^R)^(R on each part) = BA func rotate(nums []int, k int) { n := len(nums) if n <= 1 || k%n == 0 { return } k %= n reverse(nums, 0, n-1) // Step 1: 反转整个数组 [1,2,3,4,5,6,7] → [7,6,5,4,3,2,1] reverse(nums, 0, k-1) // Step 2: 反转前 k 个 [7,6,5, 4,3,2,1] → [5,6,7, 4,3,2,1] reverse(nums, k, n-1) // Step 3: 反转剩余部分 [5,6,7, 4,3,2,1] → [5,6,7, 1,2,3,4] } // reverse 将 nums[left...right] 原地反转。 // 双指针相向而行,每次交换两端元素。 func reverse(nums []int, left, right int) { for left < right { // Go 多重赋值:右边全部求值后同时赋值,无需显式临时变量 nums[left], nums[right] = nums[right], nums[left] left++ right-- } } ``` > [!success] ✅ 方法三:循环替换(展示深度) ```go // rotateCyclic 使用循环替换实现向右轮转数组。 // 利用 gcd(n, k) 个独立循环的性质,原地 O(1) 空间完成。 // // 核心洞察:向右轮转 k 位构成一个置换,该置换可分解为 gcd(n, k) // 个互不相交的循环。每个循环内的元素沿 "i → (i+k)%n" 方向流动, // 恰好走过 n/gcd(n, k) 步后回到起点。 func rotateCyclic(nums []int, k int) { n := len(nums) if n <= 1 || k%n == 0 { return } k %= n count := 0 // 已正确放置的元素总数——控制外层循环何时停止 for start := 0; count < n; start++ { // 从 start 出发,沿 (start + k) 方向游走一圈 curr := start prev := nums[start] // 缓存当前位置的值,作为下一轮的"待插入物" for { next := (curr + k) % n // 下一个目标位置 nums[next], prev = prev, nums[next] // 旋入值,取出新值留作下一步 curr = next count++ if curr == start { break // 一圈走完,回到起点 } } } } ``` > [!success] ✅ 补充:向左轮转实现 ```go // rotateLeft 使用三次反转实现向左轮转数组。 // 原理与右旋对称但区间顺序不同:先翻左段、再翻右段、最后反全。 func rotateLeft(nums []int, k int) { n := len(nums) if n <= 1 || k == 0 { return } k %= n reverse(nums, 0, k-1) // Step 1: 反转前 k 个 [3,2,1, 4,5,6,7] reverse(nums, k, n-1) // Step 2: 反转剩余部分 [3,2,1, 7,6,5,4] reverse(nums, 0, n-1) // Step 3: 整体反转 [4,5,6,7, 1,2,3] ← [1,2,3,4,5,6,7] 左旋 3 位的结果 } // rotateCyclicLeft 使用循环替换实现向左轮转数组。 // 与右旋唯一区别:步进方向由加法改为减法,注意 +n 防止负数取模。 func rotateCyclicLeft(nums []int, k int) { n := len(nums) if n <= 1 || k%n == 0 { return } k %= n count := 0 for start := 0; count < n; start++ { curr := start prev := nums[start] var next int for { next = (curr - k + n) % n // ← 关键差异:减法代替加法 nums[next], prev = prev, nums[next] curr = next count++ if curr == start { break } } } } ``` > [!success] ✅ 运行验证 这是 LeetCode 第 189 题,是一道非常经典的**原地数组变换**题目。 - **反转法**运行时间:约 0~2 ms(Go,击败 ~95%+ 提交) - **反转法**空间消耗:O(1) - **循环替换**运行时间:约 2~5 ms(Go,击败 ~85%+ 提交) - **循环替换**空间消耗:O(1) > [!quote] 💬 延伸思考 反转法的美妙之处在于它与**群论中的对合运算**相通——反转是对合(involution),即 `reverse(reverse(x)) = x`。三次反转的组合本质上是在 S_n(n 元对称群)中构造了一个特定的排列。当你遇到更多"仅凭交换操作重排数组"的问题时,思考能否将目标排列分解为若干个对合操作的乘积,这会是一个通用的解题利器。