想象你有一排人,按编号站好。现在想把队伍整体往右平移三步,但场地只有原地大小,不能另开一片空地。你会怎么做?最直接的办法是一个一个地往右挪,但每次挪动都会把别人挤出去,最后还得补回来。这个场景就是数组旋转问题的缩影。

一、从生活场景说起

在编程里,数组旋转是指把数组里的元素按照某个方向移动若干个位置。比如一个数组包含 1、2、3、4、5,向右移动一位,就会变成 5、1、2、3、4;向右移动两位,就是 4、5、1、2、3。这个操作看着简单,真要写出高效的代码,却有好几条路可以走。

很多人第一次遇到这个问题,脑子里冒出来的办法就是“硬转”。为什么说它“硬”?因为你可以像挪箱子一样,把最后一个元素拿出来,然后所有元素都往后退一步,再把拿出来的元素放到最前面。这样做一次,相当于右移一位。如果想右移 k 位,就重复这个动作 k 次。代码写起来很顺手,但问题也很明显:数组越长,k 越大,耗时就越恐怖。

既然一个一个挪太慢,那我们多开一块“空地”行不行?当然行。你创建一个和原数组一样大的新数组,然后按照位置对应关系,把旧数组里的元素装到新数组的正确位置上。这个做法思路简单,一眼就能看懂,代价是内存占用会翻倍。在数据量小的场景里没什么,但假如你正在处理一个超大的数组,比如几千万个传感器数据,多出一倍空间可能直接让程序崩溃。

所以,真正的挑战是:能不能既不额外占用太多空间,又能很快地完成旋转?答案是有的。这个答案的关键,就是用“分段反转”来借力打力。反转本身是个很朴素的操作,但它和数组旋转之间,存在着一种意想不到的对称美。

二、先看一个直观做法

2.1 一个一个挪的方式

先写一个最笨的办法,把整个流程拆开来看。假设数组叫它输入列表,长度是 n。右移一位,实际上就是把最后一个元素拿走,然后从后往前,把每个元素复制到它后面的位置。等所有元素都后移完,再把之前拿走的元素放到开头。这个过程用 Python 语言写出来大概是这样的:

def move_one(arr):
    """
    把数组整体右移一位
    arr: 列表
    """
    if not arr:
        return
    temp = arr[-1]              # 先保存最后一位
    for i in range(len(arr) - 1, 0, -1):
        arr[i] = arr[i - 1]     # 从后往前搬
    arr[0] = temp               # 最后一位放到开头


def rotate_by_loop(arr, k):
    """
    右移 k 位,靠循环调用 move_one 实现
    """
    k = k % len(arr) if arr else 0
    for _ in range(k):
        move_one(arr)

这里有个小细节:如果 k 比数组长度还大,那移动 k 位和移动 k 对长度取余后的位数,效果是一样的。比如一个长度为 5 的数组,右移 7 位,其实和右移 2 位完全一样,因为转一整圈又回到了原点。所以先取余能省掉很多无效的搬运。

这个办法非常直观,但缺陷也一目了然。每右移一位,都要把 n 减一个元素重新挪一遍。如果 k 也接近 n,那总操作次数差不多是 n 的平方。数据一多,运行时间就会像排队时不断有人插队一样,越拖越长。

2.2 多开一个数组的做法

要减少时间,最简单的思路就是“用空间换时间”。我们准备一个新数组,长度和原数组一样。然后遍历原数组,把下标 i 的元素放到新数组中下标为 i 加 k 再对 n 取余的位置上。这里取余的作用是让下标超过 n 的时候,自动绕回开头。

def rotate_with_extra(arr, k):
    """
    开新数组完成右移 k 位
    arr: 列表
    k: 右移位数
    """
    n = len(arr)
    if n == 0:
        return
    k = k % n
    if k == 0:
        return
    new_arr = [0] * n           # 新数组,长度一致
    for i in range(n):
        new_arr[(i + k) % n] = arr[i]   # 对应位置放过去
    # 注意:这里要原地修改 arr,所以把 new_arr 的值逐个复制回去
    for i in range(n):
        arr[i] = new_arr[i]

这样一趟就能完成,时间复杂度是线性级别,非常快。但空间上多出了一个和原数组等长的临时数组。如果数组有 1 亿个元素,那就是多出 1 亿个格子的内存。在内存吃紧的环境里,这不是个好选择。

有没有一种办法,既不申请额外空间,又能让元素各就各位?接下来就是重头戏。

三、关键发现:反转的魔法

3.1 反转是什么

反转一个数组,就是把它的顺序彻底倒过来。例如一个包含 1、2、3、4、5 的数组,反转后变成 5、4、3、2、1。这个操作很常见,写起来也不难,从头到中间,左右两侧两两交换。

你可能觉得反转和旋转是两码事,可一旦把旋转看成“把尾部的一段搬到头部”,神奇的联系就出现了。假设原数组是 1、2、3、4、5、6、7,想右移 3 位,最终要得到 5、6、7、1、2、3、4。你发现了吗?最终结果其实就是把原数组分成了两段:前一段是 1、2、3、4,后一段是 5、6、7,然后让后一段跑到前面去,前一段挪到后面来。

3.2 三次反转为什么有效

我们分三步走。

第一步,把整个数组反转。原数组 1、2、3、4、5、6、7 变成 7、6、5、4、3、2、1。注意看,这时候原来位于末尾的 5、6、7 被翻到了开头,但顺序也反了,变成了 7、6、5;原来开头的 1、2、3、4 被翻到了末尾,顺序也反了,变成了 4、3、2、1。

第二步,把开头那一段,也就是原来末尾的部分,反转一下。我们需要的开头是 5、6、7,而现在开头是 7、6、5,反转后正好恢复成 5、6、7。

第三步,把末尾那一段,也就是原来开头的部分,也反转一下。现在末尾是 4、3、2、1,反转后恢复成 1、2、3、4。

三次反转,各司其职:第一次让两段整体换位,第二次和第三次分别让每一段内部的顺序“拨乱反正”。这就是分段反转的对称性质——整段反转时,两段的相对位置交换了,但段内顺序也颠倒了;再对每一段做一次反转,段内顺序又被翻回来。整个过程就像把一件衣服翻个面,再翻袖子,再翻衣身,最后衣服恢复原样但左右位置换了。

四、手把手推导一个完整例子

4.1 数字推演

还是用数组 1、2、3、4、5、6、7,右移 3 位来完整走一遍。

原始数组:

[1, 2, 3, 4, 5, 6, 7]

第一步,整体反转:

[7, 6, 5, 4, 3, 2, 1]

第二步,反转前三个元素,也就是下标 0 到 2:

[5, 6, 7, 4, 3, 2, 1]

第三步,反转从第四个元素到结尾,也就是下标 3 到 6:

[5, 6, 7, 1, 2, 3, 4]

得到的结果正是右移 3 位后的数组。你甚至可以把这个过程画在纸上,每一步都用箭头标出交换的元素,会发现所有的交换都是成对发生的:整个数组的反转虽然看起来眼花缭乱,但实际上两两对称,局部反转也同样对称。这种对称性保证了每个元素最终都能准确落在它该去的位置,不多走一步,也不漏走一步。

4.2 边界情况验证

如果右移 0 位,三步反转都会在原地打转,结果不变。如果右移 7 位,取余后变成 0,也不用操作。如果右移 10 位,取余后是 3,效果和右移 3 位一样。如果数组长度为 1,反转后还是它自己,怎么转都不变。这些边界情况在代码里都要考虑,否则很容易出现数组越界或者白白浪费时间。

再看一个稍微不同的例子:数组 10、20、30、40、50,右移 2 位。整体反转得到 50、40、30、20、10,反转前两个得到 40、50、30、20、10,反转后三个得到 40、50、10、20、30。这正好是原数组右移两位的结果。多次验证之后,你就可以放心把“三次反转”当成一个通用套路了。

五、代码实现

5.1 手写一个反转函数

反转函数是地基。它接收数组以及要反转的起始和结束位置,区间包含起始和结束。我们用双指针从两头往中间走,不停交换元素。技术栈采用 Python 语言。

def reverse(arr, start, end):
    """
    反转 arr 中从 start 到 end 的部分(包含 end)
    arr: 列表
    start: 起始下标
    end: 结束下标
    """
    while start < end:
        arr[start], arr[end] = arr[end], arr[start]  # 两个元素交换位置
        start += 1
        end -= 1

5.2 用三次反转完成旋转

主函数负责计算取余后的 k,然后调用三次反转。注意反转区间的边界:第一次是从开头到结尾,第二次是从开头到 k 前面的位置,第三次是从 k 到结尾。同样使用 Python 语言。

def rotate_right(arr, k):
    """
    原地右移 k 位,空间复杂度 O(1)
    arr: 列表
    k: 右移位数
    """
    n = len(arr)
    if n == 0:
        return                    # 空数组没有可旋转的
    k = k % n                     # 去掉完整轮转,避免无意义的重复
    if k == 0:
        return                    # 不需要动

    reverse(arr, 0, n - 1)        # 第一次:整体反转
    reverse(arr, 0, k - 1)        # 第二次:反转前 k 个,恢复这一段内部的顺序
    reverse(arr, k, n - 1)        # 第三次:反转剩余部分,恢复这一段内部的顺序

这段代码的时间复杂度是线性级别,因为三次反转加起来,每个位置最多被交换两次;空间复杂度是常数级别,因为只用到了少数几个临时变量。无论数组多大,额外内存都不随规模增长,这正是“原地置换”的魅力。

5.3 关联技巧:负数 k 的处理

有的场景里,移动步数可能是负数,比如负 2 表示向左移动两位。其实向左移动两位和向右移动“长度减二”位是一样的。在 Python 里,负数取余也会得到一个非负的余数,所以上面的代码天然支持负数。例如数组长度是 7,移动负 2 位,取余后得到 5,向右移动 5 位等同于向左移动 2 位,结果正确。这是 Python 取余运算带来的小便利,在其他语言里需要自己处理一下。

六、应用场景

6.1 任务队列的循环调度

很多系统中都有任务队列,每过一段时间需要把队首的任务挪到队尾,或者把队尾的紧急任务提到队首。如果队列是用数组实现的,那么用一次数组旋转就能完成这种轮换。比如有 5 个任务按优先级排列,每执行一轮后要把前两个过期任务移到末尾,这时右移两位就是一次完美的旋转。

6.2 游戏中的卡牌与转盘

卡牌游戏洗牌后,玩家经常要把最上面几张牌放到最下面;转盘抽奖时,指针按照一定步长旋转,本质上也是数组下标在移动。游戏引擎里需要高性能处理,使用三次反转这种原地算法,可以避免反复创建新数组带来的卡顿。

6.3 图像与矩阵的变换

一维数组的旋转思想可以延展到二维。比如要顺时针旋转一个正方形矩阵,可以先沿主对角线转置,再把每一行反转。这里的“分段反转”和“整体反转”思路如出一辙。理解了三次反转,再去理解矩阵旋转会轻松很多。

七、技术优缺点

7.1 优点

最大的优点是省空间。整个算法只用到了几个临时变量,无论数组长度多大,额外内存都是常数级别。这一点在嵌入式设备、移动端或者处理超大规模数据时特别香。

其次,时间效率也很好。遍历一遍就能完成,比一个一个挪的“笨办法”快了不止一个量级。代码结构也很清晰,只需要一个反转函数,没有复杂的嵌套循环,不容易写错。

另外,它很好地体现了“对称性”在算法中的价值。通过巧妙的操作顺序,把复杂问题拆成几个重复的子问题,这种思维本身就很值得学习。

7.2 缺点

缺点也客观存在。第一,理解门槛比直接开新数组高。如果你不熟悉反转的对称性,很难一眼看出为什么三次反转是对的。第二,它依赖下标计算,边界条件一旦写错,就会出现部分元素没反转或者反转越界的情况。第三,对于链表这种非随机存储的结构,反转需要额外遍历,虽然也能做,但不如数组方便。第四,它不能保持数组的“稳定性”,因为反转会打乱相同元素的相对位置,不过数组旋转本来也不要求稳定,所以影响不大。

八、注意事项

使用三次反转时,有几个细节值得反复确认。

第一,一定要先取余。移动步数可能非常大,甚至超过常见整数能表示的范围,取余后不仅能避免多余操作,还能防止后面计算边界下标时出现负值或越界。

第二,反转区间的定义要一致。我们这里用的是闭区间,也就是说结束位置是包含在反转范围内的。写循环时,条件是开始位置小于结束位置,交换完后开始位置加一,结束位置减一。如果区间定义想用半开半闭,那么所有调用都要跟着改,混用就会出错。

第三,空数组和长度为 1 的数组要单独处理。空数组取余会报错,所以必须先判断长度是否为零。长度为 1 时,无论移动多少位,数组都不变,提前返回能节省一点时间。

第四,如果代码会运行在多线程环境,原地修改数组意味着别的线程可能看到中间状态。这种情况下需要加锁或者使用线程安全的数据结构,不能直接拿这个函数硬上。

第五,当移动步数是负数时,先想清楚你的语义。有些语言里负数取余还是负数,直接拿去做下标会出问题。最好统一转换成一个非负的余数,保证安全。

九、总结

数组旋转看起来是个小问题,但它背后藏着一条很漂亮的思路。我们不需要开辟新数组,只需要利用反转的对称性,把整体反转和局部反转组合起来,就能让两段元素在不改变内部顺序的前提下互换位置。这个技巧的关键是理解“整体反转让两段互换,局部反转恢复段内顺序”,一旦想通,代码就变得水到渠成。

分段反转的思想远不止用于这一道题。很多算法题里,凡是需要“部分元素整体移动”的场景,都可以考虑用反转来简化。比如字符串反转、链表反转、矩阵翻转,都能找到它的影子。掌握了这个套路,你在面对类似问题时,就多了一把趁手的工具。

希望这篇文章能帮你彻底搞懂数组旋转的秘密。以后遇到类似需求,不妨先在心里想象一下排队的人群,然后反向一挥手,再依次理顺两端,答案就自然浮现了。