LeetCode 189. 轮转数组:三种经典解法全解析

34 阅读7分钟

大家好!今天我们来深入剖析 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 个环:

  1. start=0,current=0,prev=1;next=(0+3)%7=3,temp=4;nums[3]=1,prev=4,current=3;

  2. next=(3+3)%7=6,temp=7;nums[6]=4,prev=7,current=6;

  3. next=(6+3)%7=2,temp=3;nums[2]=7,prev=3,current=2;

  4. next=(2+3)%7=5,temp=6;nums[5]=3,prev=6,current=5;

  5. next=(5+3)%7=1,temp=2;nums[1]=6,prev=2,current=1;

  6. next=(1+3)%7=4,temp=5;nums[4]=2,prev=5,current=4;

  7. next=(4+3)%7=0,temp=nums[0]=1;nums[0]=5,prev=1,current=0;

  8. current === start,循环结束,最终数组为 [5,6,7,1,2,3,4]。

三、解法三:反转数组法(最优解,兼顾简洁与高效)

1. 核心思路

这是最经典、最推荐的解法,思路巧妙且易于理解:通过三次反转数组,实现元素的轮转效果。

核心步骤:

  1. 反转整个数组(将尾部元素转到头部附近);

  2. 反转前 k 个元素(调整头部元素顺序,使其符合轮转要求);

  3. 反转剩余的(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. 初始数组:[1,2,3,4,5,6,7]

  2. 反转整个数组:[7,6,5,4,3,2,1]

  3. 反转前 3 个元素:[5,6,7,4,3,2,1]

  4. 反转剩余 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)思路巧妙,代码简洁,兼顾高效与易理解需要掌握三次反转的逻辑,初次接触可能难以想到

推荐使用场景

  • 新手入门:优先学习新数组映射法,理解轮转的核心位置映射关系;

  • 面试/实际开发:优先使用反转数组法,兼顾代码简洁性和性能;

  • 空间极致优化:使用环状替换法(但需确保能清晰解释逻辑)。

五、常见踩坑点总结

  1. 忘记处理 k = k % nums.length,导致 k 大于数组长度时出现错误;

  2. 试图通过 nums = 新数组 实现原地修改,忽略了引用类型的参数传递规则;

  3. 反转数组时,区间边界处理错误(比如反转前 k 个元素时,结束索引写成 k 而非 k-1);

  4. 环状替换法中,忘记计算最大公约数,导致漏处理部分元素。

希望通过这篇解析,大家能彻底掌握轮转数组的三种解法,理解每种解法的核心思路和优缺点。如果有疑问,欢迎在评论区交流~