Files
leetcode-go/普通数组/15-轮转数组.md
T

20 KiB
Raw Blame History

tags, create time
tags create time
LeetCode
数组
双指针
原地算法
中等
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 个元素?也就是说,向右轮转本质上是把尾部的一段搬到了头部。这给了我们另一种视角,也是后续更优方法的灵感来源。

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 为例:

flowchart LR
    S0["Step 0<br/>'原始': [1,2,3, 4,5,6,7]"] --> S1["Step 1<br/>reverse(0,6)<br/>[7,6,5, 4,3,2,1]"]
    S1 --> S2["Step 2<br/>reverse(0,2)<br/>[5,6,7, 4,3,2,1]"]
    S2 --> S3["Step 3<br/>reverse(3,6)<br/>[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 伪代码框架(反转法)

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 伪代码框架(循环替换)

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

不管用哪种方法,第一步都应该执行:

k = k % len(nums)

原因:

  1. 性能:当 k >= n 时,多余的轮转周期不会改变结果
  2. 安全性:避免后续计算中出现越界或无限循环(比如 k = n 时某些实现可能出错)
  3. 语义:轮转 n 次等价于什么都没做,所以 % n 是最自然的归约

[!question] 💡 向左轮转怎么写?

向左轮转 k 位与向右轮转的本质区别在于方向相反。可以有以下三种处理方式:

方式一:转化为右旋(最简单)

左旋 k 位等价于右旋 (n - k) % n 位。直接复用右旋逻辑即可。

// 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 ✅
// 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) — 最后反全

方式三:循环替换中改为减法步进

在循环替换方法中,只需将步进方向从加法改为减法:

// 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] ✅ 方法一:额外数组(基准解法)

// 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] ✅ 方法二:反转数组(⭐ 推荐)

// 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] ✅ 方法三:循环替换(展示深度)

// 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] ✅ 补充:向左轮转实现

// 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 元对称群)中构造了一个特定的排列。当你遇到更多"仅凭交换操作重排数组"的问题时,思考能否将目标排列分解为若干个对合操作的乘积,这会是一个通用的解题利器。