PAT 1008这道数组循环右移问题,看起来是乙级题库里最人畜无害的一道题,但真把它做明白的人不多。不少人第一次AC靠的是“把最后M个数存下来,前面的往后挪”,甚至有人直接输出后半段加前半段蒙混过关。不是说这些做法不行,而是这道题背后真正想考察的——取模思维、空间复杂度意识、还有对数组操作本质的理解——往往被忽略掉了。我当年刷题时也在这一步吃了亏,后来把几种解法全部手写了一遍,才觉得这道“送分题”其实是个分水岭。
这篇文章适合正在刷PAT乙级、准备机试,或者刚学完C语言数组章节的同学。我会把循环右移的几种思路完整拆开,从暴力到三次逆置,从数学原理到边界条件,再到PAT这套题最折磨人的输出格式,最后结合数组操作的几个常见考点做延伸。保证你看完不仅能AC这一题,还能顺手解决一批数组类的基础题。
1. 题目拆解:这道题到底在考什么
1.1 一个“简单题”的隐藏门槛
题目本身描述不复杂:给定一个长度为N的整数数组,在不允许使用另外数组的前提下,将每个整数循环向右移动M个位置。输入格式是N和M,接下来是N个整数,输出移动后的数组。
举个例子,N=6,M=2,数组是1 2 3 4 5 6,输出就是5 6 1 2 3 4。
你第一眼看过去会觉得:这不就是挪位置吗?但“不允许使用另外数组”这个条件一出来,很多人的第一反应就被堵死了。你要先想清楚,这个限制本质上是在问你能不能做到原地修改,也就是O(1)的额外空间。这是算法题里非常经典的一类约束,它逼着你放弃直觉上最简单、但空间开销最高的做法。
另外还有一个隐藏门槛:M可能比N大。题目说的是循环右移,什么叫循环?就是移过头了要绕回来。如果M=8、N=6,右移8位和右移2位的效果是一样的,因为移完6位等于回到原点。这就涉及到取模运算,而取模恰恰是初学者最不敏感的操作。很多人在这一步就直接把M=N的情况当特殊情况处理,实际上取模一次就够了,根本不需要分支。
1.2 为什么“不使用另外数组”是关键约束
一开始写这道题的时候,我直接开了一个长度为N的临时数组,把后M个元素复制进去,然后把前面的元素逐个后移,再把临时数组的内容放回前面。逻辑清晰、简单直观,提交上去也能AC。
但仔细一想,这个做法其实违背了题目的隐含意图。题目明确写了“不允许使用另外数组”,就是要考察你在空间受限条件下的处理能力。虽然PAT的编译器不会检查你有没有多开数组,但如果你一直用临时数组来解决这类问题,等到数据结构里学链表旋转、字符串移位的时候,就会发现自己的思路被锁死在一个方向上。
“不用额外数组”意味着你的操作只能是交换、覆盖、逆置这类原地震荡。 这个约束推动你往数学规律的层面去想,而不是停留在“拷贝、平移、还原”的流水账操作。学算法,最重要的不是把题AC掉,而是每一次合理的约束都逼你换一个角度看问题。这道题的约束,就是逼你发现三次逆置的规律。
1.3 M比N大?取模是第一个弯
不管用哪种解法,M都有可能大于等于N。比如N=3,M=4,数组是1 2 3,右移4位,理想结果是3 1 2。你如果直接对着M=4做移动,会遇到两类问题:
- 暴力法会循环4次,每次移动一步,白白多做了N次无效操作;
- 临时数组法和逆置法会因为下标越界而崩溃。
正确的做法是第一步就执行:M = M % N。
这是因为循环移动N次后,数组会恢复原状。所以任意M的效果只和M除以N的余数有关。这一步把M压缩到[0, N-1]区间,之后的处理就安全了。取模之后M=0的情况也顺带解决了——不用移动,直接输出原数组。
我见过不少人在这一步写if (M == N) return的特殊判断,其实完全没必要。统一做一次M %= N,M==0和M==N会落到同一个分支,代码简洁且没有遗漏。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 三种解法:从暴力到逆置
2.1 解法一:逐位移动的暴力思路
最朴素的思路是:循环M次,每次把整个数组往右移动一位,最后一位挪到开头。伪代码很直白:
c复制for (int i = 0; i < M; i++) {
int temp = A[N-1];
for (int j = N-1; j > 0; j--) {
A[j] = A[j-1];
}
A[0] = temp;
}
这个方法的移动次数是N乘以M。当N和M都比较小的时候,它完全能通过测试点。但如果N是10的5次方级别、M也接近N,那N*M就是10的10次方次操作,超时是必然的。
暴力法虽然效率低,但它的价值在于验证思路。我刚开始学算法的时候,遇到数组类题目会先写一版暴力的,用来对照正确结果。有了正确的基准,再去优化高阶解法,调试起来就不容易出错。这也是我在实际刷题中一直保留的习惯:先有对的,再有快的。
2.2 解法二:临时数组法,思路直观但违背了题目本意
临时数组法就是我前面提到的做法,先把后M个元素存下来,再把前N-M个元素依次往后移M位,最后把存下来的M个元素放到数组前端。核心代码大致如下:
c复制int temp[MAXN];
for (int i = 0; i < M; i++) {
temp[i] = A[N-M+i];
}
for (int i = N-M-1; i >= 0; i--) {
A[i+M] = A[i];
}
for (int i = 0; i < M; i++) {
A[i] = temp[i];
}
这个做法的时间复杂度是O(N),比暴力法快到不知道哪里去了,但它用了O(M)的额外空间。题目说“不允许使用另外数组”,它确实不符合要求。不过PAT的测试点不会真的去查你的内存开销,所以很多人靠这版代码也能拿到满分。
我至今记得一个学长跟我说的话:“刷题的时候,题目要求什么,你就按什么要求来,别自己糊弄自己。” 临时数组法可以用,但它让你错过了一次理解三次逆置法的机会。这道题真正的精妙解法,是不开任何额外数组的。
2.3 解法三:三次逆置法,空间O(1)的教科书解法
三次逆置法是我个人最推荐的解法。思路一句话就能概括:把数组看成两段,前N-M个元素是一段,后M个元素是另一段。先分别逆置这两段,再整体逆置一次,就能得到循环右移M位的结果。
以1 2 3 4 5 6,N=6,M=2为例:
- 逆置前4个元素:4 3 2 1 5 6
- 逆置后2个元素:4 3 2 1 6 5
- 整体逆置:5 6 1 2 3 4
和预期结果完全一致。写成代码就是:
c复制void reverse(int A[], int left, int right) {
while (left < right) {
int temp = A[left];
A[left] = A[right];
A[right] = temp;
left++;
right--;
}
}
// 调用
M = M % N;
reverse(A, 0, N-M-1);
reverse(A, N-M, N-1);
reverse(A, 0, N-1);
这个解法的时间复杂度是O(N),额外空间是O(1),完美满足题目要求。而且代码极其简洁,三个reverse调用就把问题解决了,写成这样基本不会被判超时。
3. 逆置法的数学原理与循环移位法
3.1 三次逆置为什么能成立
很多人看到三次逆置会觉得“好神奇”,但不知道它背后的道理。其实可以从映照的角度来想:右移M位,本质上就是把每个位置i的元素移动到(i+M) mod N的位置。
现在我们把数组切成两段:A[0]到A[N-M-1]是前段,A[N-M]到A[N-1]是后段。
第一次逆置前段,相当于把前段每个元素的位置做了翻转;第二次逆置后段,同理。第三次整体逆置,则把所有元素又做了一次翻转。三次翻转合在一起的效果,正好等价于每个元素向“右”平移M位。用数学语言说,就是对下标做了一次置换,而这个置换可以分解成三个连续的反向置换。
为什么会这样?因为数组循环右移的本质,是把后M个元素“搬”到前面,同时把前N-M个元素“推”到后面。逆置操作正好可以在不借助额外空间的情况下,完成两个区段的互换。这其实是“块交换”问题的一种经典解法,在字符串旋转、链表区间反转里也能看到类似思想。
3.2 循环移位法:另一种O(N)的思路
三次逆置法之外,还有一种相对冷门但很优雅的方法,我把它叫做“循环移位法”或者“环状替换法”。思路是:从某个起点开始,把当前位置的元素放到它最终应该去的位置,被挤掉的元素继续放到下一个位置,沿着这条链一直走,走完一个环回到起点。
关键在于:N个元素会被分成多少个环?答案是gcd(N, M)个。因为从位置i出发,每次跳M步,回到i所需的步数是N/gcd(N, M),所以整个排列由gcd(N, M)个不相交的环组成。我们对每一个环执行一次循环替换即可。
c复制int gcd(int a, int b) {
return b == 0 ? a : gcd(b, a % b);
}
void rotate(int A[], int N, int M) {
M = M % N;
int cycles = gcd(N, M);
for (int i = 0; i < cycles; i++) {
int temp = A[i];
int cur = i;
do {
int next = (cur + M) % N;
int val = A[next];
A[next] = temp;
temp = val;
cur = next;
} while (cur != i);
}
}
这个算法同样O(N)时间、O(1)空间,但理解成本比逆置法高。我自己在笔试中基本只用逆置法,因为它更容易写对,也不容易出错。但循环移位法的思想在约瑟夫环、洗牌算法中都有延伸,值得花时间吃透。
3.3 两种O(N)方法的取舍
三次逆置法胜在代码简单、不易写错。循环移位法胜在“一步到位”,但理解和调错的成本高。
我的建议是:考试和竞赛用逆置法,平时学习可以两者都写一遍。 逆置法还有一个好处是,它不依赖M与N的大小关系,只要先取模就能统一处理。而循环移位法里,gcd的边界条件稍微忽略一点就容易出问题,特别是在M=0或N=1的时候。
4. 边界条件与PAT的格式审判
4.1 那些让你白交一血的边界
PAT的题目,算法思路对了只是第一步,边界条件和输出格式才是真正卡人的地方。就这道题而言,我总结出几个必测的边界:
- M=0:不需要移动,原样输出。
- M=N:取模后变成0,同样原样输出。
- M>N:例如N=3,M=5,取模后是2,不能直接按5去做。
- N=1:只有一个元素,无论M是多少,输出都是它本身。
- N-M=0:取模后的M为0,逆置的前段长度变成0,这时候要小心左边界大于右边界。
这些边界并不是出题人故意为难你,而是程序健壮性的基本要求。我在实际处理的时候,习惯在写完代码后逐一把这些数据跑一遍,再提交。PAT不会告诉你哪个测试点挂了,能靠的只有自己多测。
4.2 输出格式:空格和换行的“洁癖”
PAT对输出格式的挑剔程度,不亚于对算法正确性的要求。这道题要求输出的数字之间用空格分隔,最后一个数字后面不能有空格,最后要换行。
这里就是新手最容易丢分的地方。如果你在循环里写成:
c复制for (int i = 0; i < N; i++) {
printf("%d ", A[i]);
}
最后一个数后面会多一个空格,PAT的判题系统会认为格式错误,直接给你一个格式错误的红叉。
正确的写法是第一个数前面不加空格,或者用标志位控制:
c复制for (int i = 0; i < N; i++) {
if (i > 0) printf(" ");
printf("%d", A[i]);
}
printf("\n");
这种“第一个不输出空格,之后每个前面输出一个空格”的写法,在PAT题目里几乎成了通用模板。遇到输出数组、链表、字符串列表的题,你都可以直接复用。
4.3 性能对比:数据量放大后的差异
为了直观展示三种解法的差距,我用一个N=100000、M=99999的用例粗略估算了一下:
- 暴力法:内层循环移动约10的10次方次,完全不可接受。
- 临时数组法和三次逆置法:都是百万级别操作,运行时间毫秒级。
所以在数据规模大的时候,暴力和高效解法的差距是天壤之别。这也是PAT为什么要考算法题的原因——不只是让你写对,还要让你体会到在大数据量下算法设计的价值。
5. 从数组右移展开:数组操作的几类高频考点
5.1 数组逆置、旋转与KMP next数组的呼应
顺着数组右移这道题你会发现,逆置操作是数组题里的一个基础原子操作。很多看起来很复杂的题,拆到最后都是在做逆置或者旋转。字符串左旋、矩阵转置、链表反转这些题目,本质上都共用了同一个“翻转”思维。
这里还可以顺带提一下KMP算法里的next数组,很多初学者学到那里会觉得前面的数组知识全忘了。next数组本质上是在求“模式串每个前缀的最长相等前后缀长度”,这也是一种对数组下标和模式串特性的精妙利用。从数组循环右移的置换思想,到KMP的next数组的匹配规律,你会发现“数组”这个概念在不同算法场景下承担着完全不同的角色。把1008这种基础题吃透,后面的进阶内容才能接得住。
5.2 多语言视角:C/C++到Java、JavaScript的数组处理
PAT允许使用多种语言,但绝大多数考生用的是C或C++。用C++的话,三次逆置可以直接用STL的reverse函数,代码量还能再缩一点:
cpp复制#include <algorithm>
using namespace std;
reverse(A, A + N - M);
reverse(A + N - M, A + N);
reverse(A, A + N);
如果切到Python,这道题甚至可以一行解决:
python复制A[-M:] + A[:-M]
但注意,一行解法是Python切片的功劳,它实际上创建了新列表,并没有做到原地修改。如果你想在Python里做到O(1)空间,还是得用逆置或循环移位法。这一点在面试中经常被追问,建议不要只背“一行答案”。
Java和JavaScript里的数组操作略有不同。Java没有内置的reverse方法,需要自己写;JavaScript的splice、slice、扩展运算符非常灵活,做数组右移可以用arr.splice(0, 0, ...arr.splice(-M))这类骚操作,但同样有额外空间的问题。平时用高级语言写业务代码可以怎么方便怎么来,刷题沉淀下来的数组操作基本功,才是真正通用的东西。
5.3 一维数组到二维数组、指针数组的进阶路径
做通这道题之后,建议把数组相关的知识点串联起来复习一趟:一维数组和二维数组的声明、初始化、作为函数参数传递时的退化规则、指针数组与数组指针的区别、字符串数组的初始化方式、多维数组在内存中的连续存储规律。这些都是C语言里让新手头疼的地方,也是PAT常考的基础。
热搜词里还出现了“宏定义数组”和“动态数组”这类关键词。在C语言里,用宏定义数组大小是编译期定长的方式:
c复制#define MAXN 105
int A[MAXN];
动态数组则是在运行时用malloc或new分配内存。刷题时建议先用固定大小的数组,把空间开到题目上限再加一点余量,避免动态内存管理的麻烦。等你对内存布局有了感觉,再上手动态数组会轻松很多。
6. 常见错误与排查技巧实录
6.1 编译错误:常见PAT环境问题
PAT的编译器对C/C++的标准支持比较传统,建议提前检查几件事:不要用C++11之后才有的语法特性(比如stoi在某些老编译器里不支持)、变量不要和关键字冲突、数组大小不要用变量声明(除非是变长数组,但老标准下有的编译器不认)。还有一个很常见的问题,main函数返回类型必须是int,不能是void。
热搜词里有一条“error: java_home is not set and no 'java' command could be found in your pat”,这通常是本地Java环境变量没配好,和PAT题目本身无关。遇到这类问题,优先检查自己电脑上的JDK路径和PATH配置,别往题目上瞎猜。
6.2 运行错误与段错误
数组循环右移最常见的运行错误,是下标越界。尤其在使用临时数组法时,A[N-M+i]这个下标很容易在M=0时变成A[N],直接越界访问。逆置法里,如果M取模后为0,N-M-1会变成N-1,没问题;但如果N=1,N-M-1是0,N-M是1,调用reverse(A, 0, -1)会进入不满足条件的循环,虽然不会报错,但逻辑上要保证边界正确。
排查这类问题,经验是先打印边界值。在关键位置加上printf,把当前的N、M、left、right都打出来,对照手算结果看是哪一步出了问题。调试几轮之后,你对数组下标的敏感度会明显提升。
6.3 答案错误的排查顺序
如果代码能运行但结果不对,我一般按这个顺序排查:
- 有没有对M取模?没取模的话,M=N时结果就错了。
- 逆置的三个区间边界对不对?
N-M作为分界点非常容易写错成N-M-1。 - 输出格式是不是多了空格、少了换行?
- 输入读取有没有漏掉换行符?
这四个问题至少覆盖了八成以上的错误情况。剩下两成,基本是思路本身理解错了,比如把右移写成了左移,或者把逆置的顺序搞反了。这时候拿一个简单的数组手动模拟一遍,很快就能定位。
7. 拓展练习:如何举一反三
这道题做完了,不代表数组操作就过关了。真正有效的学习方法,是把一道题变成一组题。我建议你接着刷以下这些方向:
- 数组左移:把右移改成左移,逆置法的分界点怎么调整?
- 字符串旋转判断:判断一个字符串是否能通过循环移位得到另一个字符串,这题可以用“拼接+查找子串”的方法解。
- 两个数组的归并:合并两个有序数组,要求原地合并,这题和双指针思想有关。
- 链表右移:如果是单链表做循环右移,怎么找到新的头节点?这题在LeetCode上叫Rotate List。
每一道题刷完,都停下来想一想:什么地方用了和1008类似的思维?什么地方引入了全新的概念?带着问题去刷题,比盲目追求AC数量有用得多。
我自己刷题的一个习惯是:每道题AC之后,再写一遍空间复杂度更优的版本。比如这题,第一遍用了临时数组法,第二遍逼自己写逆置法,第三遍写循环移位法。三遍下来,这道题的价值才算被榨干了。
你在做这些练习的时候,不需要贪多。每天认真吃透一道题,胜过刷十道题但都是浅尝辄止。翁恺老师的PAT练习题列表之所以经典,就是因为这些题目覆盖了从语法到算法的完整进阶路径,数组循环右移正好处在这个路径的中间节点上。过了这道坎,你的数组基础就算是扎稳了。
