大家好!今天我们来深入剖析 LeetCode 上的经典题目——189. 轮转数组。这道题看似简单,但涉及到“原地修改”“空间复杂度优化”等关键考点,不同解法的思路差异较大,非常适合用来巩固数组操作和算法优化的基础。下面我将带大家逐一理解三种主流解法,从直观到高效,层层递进。
一、题目回顾
题目要求:给定一个整数数组 nums,将数组中的元素向右轮转 k 个位置,其中 k 是非负数。核心约束是 不要返回任何东西,原地修改数组。
示例:输入 nums = [1,2,3,4,5,6,7], k = 3,输出应改为 [5,6,7,1,2,3,4]。
先明确一个关键细节:当 k 大于数组长度时,轮转会出现重复(比如数组长度 5,k=7 等价于 k=2),因此所有解法都需要先处理 k = k % nums.length,避免无效操作。
二、解法一:新数组映射法(直观易懂)
1. 核心思路
最直观的想法是:先找到每个元素轮转后的最终位置,用一个新数组存储这些元素,再将新数组的内容覆盖回原数组(实现原地修改)。
关键映射公式:原数组索引 i 的元素,向右轮转 k 步后,新位置为 (i + k) % nums.length。取模操作是为了避免索引越界(比如最后一个元素轮转后回到数组头部)。
2. 代码实现
/**
Do not return anything, modify nums in-place instead.
*/
function rotate_1(nums: number[], k: number): void {
// 思路:创建新数组来存储
const numsLen = nums.length;
k = k % numsLen; // 处理k大于数组长度的情况
if (k === 0) return; // 无需轮转,直接返回
const resArr = new Array(numsLen);
for (let i = 0; i < numsLen; i++) {
// 核心映射:原索引i的元素放到新数组(i+k)%numsLen位置
resArr[(i + k) % numsLen] = nums[i];
}
// 将新数组元素覆盖回原数组,实现原地修改
for (let i = 0; i < numsLen; i++) {
nums[i] = resArr[i];
}
};
3. 复杂度分析
-
时间复杂度:O(n)。两次遍历数组(一次存新数组,一次覆盖原数组),每个元素仅处理一次。
-
空间复杂度:O(n)。需要额外创建一个和原数组等长的新数组,这是该解法的主要缺点。
4. 关键注意点
很多新手会犯的错误:直接写 nums = resArr 试图修改原数组。但在 JavaScript/TypeScript 中,函数参数传递的是“引用副本”,nums = resArr 只是改变了函数内局部变量的指向,并不会修改外部原数组的内存空间。必须通过循环逐个覆盖 nums[i],才能真正实现原地修改。
三、解法二:原地交换法(环状替换,空间最优)
1. 核心思路
该解法的核心是“环状替换”:从第一个元素开始,找到它轮转后的位置,将该位置的元素暂存后放入当前元素,再继续处理被暂存的元素,直到回到起始位置,完成一个“环”的替换。
需要注意的是:数组可能存在多个独立的环(比如数组长度 6,k=2 时,会形成两个环:0→2→4→0 和 1→3→5→1)。因此需要先计算“环的数量”,再逐个处理每个环。
环的数量 = 数组长度和 k 的最大公约数(gcd)。这是因为最大公约数决定了元素替换的循环周期。
2. 代码实现
/**
Do not return anything, modify nums in-place instead.
*/
function rotate_2(nums: number[], k: number): void {
// 思路:原地交换(环状替换)
const numsLen = nums.length;
k = k % numsLen;
if (k === 0) return;
// 求两个数的最大公约数(用于确定环的数量)
const gcd = (x: number, y: number): number => y ? gcd(y, x % y) : x;
const count = gcd(k, numsLen); // 环的数量
// 遍历每个环的起始位置
for (let start = 0; start < count; start++) {
let current = start; // 当前处理的索引
let prev = nums[start]; // 暂存当前位置的元素
// 循环替换,直到回到起始位置
do {
const next = (current + k) % numsLen; // 下一个要替换的位置
const temp = nums[next]; // 暂存下一个位置的元素
nums[next] = prev; // 将当前元素放入下一个位置
prev = temp; // 更新暂存元素为刚才取出的元素
current = next; // 移动到下一个位置
} while (current !== start);
}
};
3. 复杂度分析
-
时间复杂度:O(n)。每个元素仅被替换一次,虽然有两层循环,但总操作次数为 n。
-
空间复杂度:O(1)。仅使用几个临时变量(current、prev、temp),完全原地修改,空间最优。
4. 示例演示(nums = [1,2,3,4,5,6,7], k=3)
数组长度 7,k=3,gcd(7,3)=1,因此只有 1 个环:
-
start=0,current=0,prev=1;next=(0+3)%7=3,temp=4;nums[3]=1,prev=4,current=3;
-
next=(3+3)%7=6,temp=7;nums[6]=4,prev=7,current=6;
-
next=(6+3)%7=2,temp=3;nums[2]=7,prev=3,current=2;
-
next=(2+3)%7=5,temp=6;nums[5]=3,prev=6,current=5;
-
next=(5+3)%7=1,temp=2;nums[1]=6,prev=2,current=1;
-
next=(1+3)%7=4,temp=5;nums[4]=2,prev=5,current=4;
-
next=(4+3)%7=0,temp=nums[0]=1;nums[0]=5,prev=1,current=0;
-
current === start,循环结束,最终数组为 [5,6,7,1,2,3,4]。
三、解法三:反转数组法(最优解,兼顾简洁与高效)
1. 核心思路
这是最经典、最推荐的解法,思路巧妙且易于理解:通过三次反转数组,实现元素的轮转效果。
核心步骤:
-
反转整个数组(将尾部元素转到头部附近);
-
反转前 k 个元素(调整头部元素顺序,使其符合轮转要求);
-
反转剩余的(numsLen - k)个元素(调整尾部元素顺序)。
2. 代码实现
/**
Do not return anything, modify nums in-place instead.
*/
function rotate_3(nums: number[], k: number): void {
// 思路:反转数组
// 辅助函数:原地反转子数组(左闭右闭区间)
function reverseSubArray(arr: number[], start: number, end: number): void {
while (start < end) {
// 解构赋值交换首尾元素,无需临时变量
[arr[start], arr[end]] = [arr[end], arr[start]];
start++;
end--;
}
}
const numsLen = nums.length;
k = k % numsLen;
if (k === 0) return;
reverseSubArray(nums, 0, numsLen - 1); // 1. 反转整个数组
reverseSubArray(nums, 0, k - 1); // 2. 反转前k个元素
reverseSubArray(nums, k, numsLen - 1); // 3. 反转剩余元素
};
3. 示例演示(nums = [1,2,3,4,5,6,7], k=3)
-
初始数组:[1,2,3,4,5,6,7]
-
反转整个数组:[7,6,5,4,3,2,1]
-
反转前 3 个元素:[5,6,7,4,3,2,1]
-
反转剩余 4 个元素:[5,6,7,1,2,3,4](最终结果)
4. 复杂度分析
-
时间复杂度:O(n)。每个元素最多被反转 2 次(三次反转的总操作次数为 n)。
-
空间复杂度:O(1)。仅使用辅助函数的几个变量,完全原地修改。
5. 核心优势
相比环状替换法,反转法的代码更简洁、逻辑更直观,无需理解“最大公约数”和“环状替换”的复杂概念,是面试中的首选解法。
四、三种解法对比与总结
| 解法 | 时间复杂度 | 空间复杂度 | 优点 | 缺点 |
|---|---|---|---|---|
| 新数组映射法 | O(n) | O(n) | 思路直观,易于实现 | 需要额外空间,不满足极致的空间要求 |
| 原地交换法(环状替换) | O(n) | O(1) | 空间最优 | 逻辑较复杂,需要理解最大公约数和环状替换 |
| 反转数组法 | O(n) | O(1) | 思路巧妙,代码简洁,兼顾高效与易理解 | 需要掌握三次反转的逻辑,初次接触可能难以想到 |
推荐使用场景
-
新手入门:优先学习新数组映射法,理解轮转的核心位置映射关系;
-
面试/实际开发:优先使用反转数组法,兼顾代码简洁性和性能;
-
空间极致优化:使用环状替换法(但需确保能清晰解释逻辑)。
五、常见踩坑点总结
-
忘记处理
k = k % nums.length,导致 k 大于数组长度时出现错误; -
试图通过
nums = 新数组实现原地修改,忽略了引用类型的参数传递规则; -
反转数组时,区间边界处理错误(比如反转前 k 个元素时,结束索引写成 k 而非 k-1);
-
环状替换法中,忘记计算最大公约数,导致漏处理部分元素。
希望通过这篇解析,大家能彻底掌握轮转数组的三种解法,理解每种解法的核心思路和优缺点。如果有疑问,欢迎在评论区交流~