前言
第一次看到"三次反转就能完成数组轮转"时,我的第一反应其实是:这更像一个需要背下来的技巧。
先反转整个数组,再反转前 k 个元素,最后反转剩余元素------步骤确实不多,但如果只记住这三行代码,过几天很容易把反转顺序或者区间边界写错。
后来把数组拆成两部分重新看了一遍,才发现这并不是什么魔法。所谓向右轮转,本质上就是把数组末尾的一段搬到开头,同时保持前后两段内部的顺序不变。三次反转正好完成了这件事。
本文就从这个角度,讲清楚 LeetCode 189"轮转数组"的原地反转解法。
一、题目说明
给定一个整数数组 nums,将数组中的元素向右轮转 k 个位置,其中 k 是非负整数。
例如:
输入:nums = [1,2,3,4,5,6,7], k = 3
输出:[5,6,7,1,2,3,4]
向右轮转一次,数组最后一个元素会移动到最前面:
[7,1,2,3,4,5,6]
继续轮转两次,最终得到:
[5,6,7,1,2,3,4]
题目要求我们直接修改原数组,不需要返回新的数组。
二、最容易想到的方法:一步一步移动
最直观的做法,是每轮都保存数组最后一个元素,再把其他元素整体向后移动一位,最后将保存的元素放到数组开头。重复 k 次,就可以得到答案。
这种方法当然能做出来,但存在一个明显问题:每轮都要移动接近 n 个元素,一共执行 k 轮,时间复杂度会达到 O(n × k)。
如果数组很长,而且 k 也很大,很多重复移动其实没有必要。我们真正想做的,不是模拟每一步轮转,而是一次性把应该出现在前面的那部分元素放过去。
三、换个角度:右旋其实是在交换两段
仍然看下面这个数组:
[1,2,3,4,5,6,7],k = 3
向右轮转 3 位后,数组末尾的 3 个元素会移动到开头:
[5,6,7,1,2,3,4]
我们可以把原数组分为两段:
A = [1,2,3,4]
B = [5,6,7]
原数组是:
A + B
轮转后的数组则是:
B + A
所以问题变成了:如何在不使用额外数组的情况下,把 A + B 变成 B + A?
答案就是三次反转。
四、三次反转的完整过程
第一步:反转整个数组
[1,2,3,4,5,6,7]
↓
[7,6,5,4,3,2,1]
如果用前面的分组表示,A + B 整体反转后会变成:
reverse(B) + reverse(A)
此时 B 已经被移动到了前面,但 A、B 两段内部的顺序都反了。
第二步:反转前 k 个元素
当前数组为:
[7,6,5,4,3,2,1]
反转前 3 个元素:
[5,6,7,4,3,2,1]
这一步将 reverse(B) 恢复成了原来的 B:
B + reverse(A)
第三步:反转后 n-k 个元素
继续反转下标 k 到 n - 1 之间的元素:
[5,6,7,4,3,2,1]
↓
[5,6,7,1,2,3,4]
reverse(A) 也恢复成了原来的 A,最终得到:
B + A
这正是向右轮转后的结果。
所以,三次反转并不是碰巧有效,它对应着一个很清楚的过程:先借助整体反转交换两段的位置,再分别修复两段内部的顺序。
五、为什么一定要对 k 取模
代码中的这一行很容易被忽略:
k = k % len;
假设数组长度为 7,向右轮转 7 次以后,数组会回到原来的状态。因此:
轮转 8 次 = 轮转 1 次
轮转 10 次 = 轮转 3 次
真正有效的轮转次数只有 k % len。
如果不取模,当 k 大于数组长度时,k - 1 就可能超出数组下标范围。例如,数组长度为 4、k = 6,实际只需要轮转:
6 % 4 = 2
取模既消除了重复操作,也保证了后续反转区间合法。
六、Java 完整代码
class Solution {
public void rotate(int[] nums, int k) {
int len = nums.length;
// 对 k 取模,避免 k 大于数组长度
k = k % len;
// 第一次:反转整个数组
reverse(nums, 0, len - 1);
// 第二次:反转前 k 个元素
reverse(nums, 0, k - 1);
// 第三次:反转剩余的 n-k 个元素
reverse(nums, k, len - 1);
}
private void reverse(int[] nums, int left, int right) {
while (left < right) {
int temp = nums[left];
nums[left] = nums[right];
nums[right] = temp;
left++;
right--;
}
}
}
七、复杂度分析
三次反转分别处理 n、k 和 n-k 个元素。虽然代码中调用了三次 reverse,但总操作次数仍然与数组长度成正比,因此:
-
时间复杂度:
O(n); -
空间复杂度:
O(1)。
整个过程直接在原数组上交换元素,没有创建长度与输入规模相关的辅助数组,这也是该解法最有价值的地方。