ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

数组循环右移的三种解法与踩坑实录:从暴力到取模映射

数组循环右移的三种解法与踩坑实录:从暴力到取模映射

说来有点惭愧,我刚接触 C 语言那会儿,最怵的就是在线练习平台上的编程题。前面 20 来题还能靠着“照课本抄一遍”的思路轻松过关,到了第 26 题左右就开始不对劲——题目读得懂,代码写得出,样例也跑得通,可提交上去总有那么一两个测试点悄无声息地红掉。好几次我都对着屏幕上那行刺眼的 Wrong Answer 发呆,完全不知道问题出在哪。

后来我才慢慢看明白,这个阶段的练习题基本都在围绕数组展开,题目本身不复杂,但评分的目光非常挑剔。它表面上考的是语法和“能不能写出一个程序”,实际上考的是你对下标、边界、内存布局的直觉。这篇文章就以某高校 SSE 平台上一道典型的“数组循环右移”题为例,把从读题到成功提交的整个过程摊开来讲。你不需要提前刷过这道题,只需要跟着我一步步复现分析和调试的过程,把三种解法、两个隐蔽的坑、以及这套思路在字符串、链表、矩阵里的延伸都走一遍,以后再碰到类似题就踏实多了。

1. 先把第26题的题库画像画出来:数组阶段在考什么

1.1 从题号看练习节奏

SSE 这类在线练习平台的题目是成序列的,题号能说明很多事。我当时刷的那套题,前 15 题基本都在练“输入输出 + 分支 + 循环”:交换两个数、判断闰年、打印九九表……它们的共同点是,只要照着语法书写,就一定能过。

从 15 到 25 题,重心明显转向函数和数组。题目开始要求你写独立函数、用数组存储批量数据、处理“考试成绩统计”“求极值和均值”这类场景。到了第 26 题前后,题库的难度出现了一个小小的跳变:题目仍然以数组为载体,但开始在这些基础知识的交叉点埋雷。比如循环右移这道题,表面上是数组下标变化,实际上还牵扯模运算、边界处理、甚至输出格式的严格匹配。

我那时候觉得这个跳变很突兀,后来回头看,不少同学也确实在第 26 题附近卡壳。原因其实很简单:前面的题只要“会写”就能过,而从这一题开始,“会写”只是及格线,还得“写对”。

1.2 一道循环右移题的输入输出约定与评分逻辑

我以当年题库里比较有代表性的一道题为例,题面大致是这样的:

给定一个长度为 n 的整数数组,请将其循环右移 m 个位置。输入第一行为两个整数 n 和 m,第二行为 n 个整数。输出一行,为右移 m 位后的数组,相邻元素用一个空格分隔。

“循环右移”的意思并不难理解:每个元素向右移动 m 个下标,凡是移出数组末尾的,就绕回数组头部接着放。比如1 2 3 4 5右移 2 位,结果是4 5 1 2 3。

为什么这类题卡人?因为它没有把边界条件写进题面,却会在测试点里悄悄塞进去。比如 m 可能大于 n,m 可能等于 0,n 可能只有 1,数组元素之间可能有多余空格……这些都靠你自己兜住。这里我先留个悬念,后面专门讲我踩过的坑。

再说评分逻辑。SSE 这类平台和大部分 OJ 一样,不检查你的代码长什么样,只看你的输出和期望输出是否完全一致。这里的“完全一致”包括空格、换行、行尾有没有多余空格。我以前觉得这是吹毛求疵,后来明白了:机器判分本来就是这么严格,这是所有写代码的人早晚都要适应的习惯。

2. 三种写法,三种境界:从暴力到取模映射到三次反转

2.1 第一版提交:逐个搬运,保证逻辑正确

第一次做这类题,最直觉的写法是“每次右移一位,重复 m 次”。每次把最后一个元素临时存下来,然后让数组中每个元素向右挪动一位,最后把临时存下的元素放到下标 0 的位置。

这个思路一点都不高级,但它有不可替代的优点:逻辑一目了然,几乎不会出错。很多同学当时也确实先用它通过了基础测试点,因为第 26 题的测试点并不一定有大数据压力。

我给出当时的代码骨架:

#include <stdio.h> void move_once(int arr[], int n) { int temp = arr[n - 1]; // 先把末尾元素保存下来 for (int i = n - 1; i > 0; i--) { arr[i] = arr[i - 1]; // 从后往前覆盖 } arr[0] = temp; // 末尾元素补到开头 } int main() { int n, m; int arr[1000]; scanf("%d %d", &n, &m); for (int i = 0; i < n; i++) { scanf("%d", &arr[i]); } m = m % n; // 关键:先取模,避免无效循环 for (int i = 0; i < m; i++) { move_once(arr, n); } for (int i = 0; i < n; i++) { printf("%d", arr[i]); if (i < n - 1) printf(" "); } printf("\n"); return 0; }

这里面有两个细节值得展开讲。第一,内层循环必须从后往前覆盖,也就是i = n - 1; i > 0; i--。如果从前往后写,arr[1] = arr[0]会把前面还没移动的元素覆盖掉,整个数组直接乱掉。第二,主循环里的m = m % n,这一步看起来多余,实际上必不可少。假如 n=5、m=12,那么右移 12 位和右移 2 位结果完全一样,因为 5 次右移刚好回到原样,12 对 5 取模等于 2。如果不取模,程序依然能跑,但白白多转很多圈;更严重的隐患我后面会专门说。

对应地,暴力版的时间复杂度是 O(n*m)。如果 n 和 m 都到了 10^5 量级,几十亿次赋值,评测机通常给不出这个时间预算。所以暴力版适合“先保证正确”和“小数据量”,不适合作为最终交付。

2.2 取模映射:一步到位的关键直觉

如果愿意多往底层想一层,会发现“右移数组”根本不必要物理移动。所谓右移 m 位,只是要建立“新数组第 i 个位置放原数组哪个元素”的映射关系。新位置 i 放的是原下标(i - m + n) % n的元素。

这个公式怎么来的?右移 m 位之后,原数组下标 j 会跑到新下标(j + m) % n。反过来,新下标 i 对应的原下标就是(i - m) % n。问题来了:C 语言里负数的 % 运算结果可能是负数,比如(-3) % 5在某些编译器里是-3,直接拿去做下标就翻车了。解决办法是先加 n 再取模,(i - m + n) % n就能保证结果落在[0, n-1]。这里的加 n 不是玄学,是处理负数模运算的标准手法。

对应代码很简洁:

#include <stdio.h> int main() { int n, m; int arr[1000]; scanf("%d %d", &n, &m); for (int i = 0; i < n; i++) { scanf("%d", &arr[i]); } m = m % n; for (int i = 0; i < n; i++) { printf("%d", arr[(i - m + n) % n]); if (i < n - 1) printf(" "); } printf("\n"); return 0; }

你看,原数组根本没有动过,输出直接按映射顺序打印。这种方式的时间复杂度是 O(n),空间上如果边算边输出就是 O(1)。它的风险全在公式本身:写过一次的人都知道,(i - m + n) % n和(i + m) % n一混淆,结果就反向了。

可以验证:n=5、m=2、原数组[1,2,3,4,5],i=0 时输出arr[(0-2+5)%5] = arr[3] = 4,i=1 时输出 5,i=2 时输出 1,i=3 时输出 2,i=4 时输出 3,得到4 5 1 2 3,完全正确。

所以我说取模解法是“第二个境界”:它不再把数组当一串物理上的箱子,而是把它当一块可以用数学下标描述的存储区。

2.3 三次反转:把“移动”变成“交换”,顺便把时间压到 O(n)

第三种写法,也是很多算法题解里更爱讲的写法——三次反转法。它不讲下标映射,而是用三次“原地反转”完成右移,思路反而更像拼积木:

  • 先把整个数组反转
  • 再反转前 m 个元素
  • 再反转后 n-m 个元素

拿1 2 3 4 5、m=2 来走一遍:整体反转得到5 4 3 2 1;反转前 2 个得到4 5 3 2 1;反转后 3 个得到4 5 1 2 3。和期望结果一致。

为什么成立?因为右移 m 位等价于把数组看成两段:末尾 m 个元素要挪到开头,前面 n-m 个元素要挪到末尾。整体反转让这两段的先后顺序颠倒,再各自反转一次,就恢复了段内原来的顺序,同时完成了两段位置的交换。每个元素只参与两次交换,整体复杂度 O(n),额外空间 O(1)。

#include <stdio.h> void reverse(int arr[], int start, int end) { while (start < end) { int temp = arr[start]; arr[start] = arr[end]; arr[end] = temp; start++; end--; } } int main() { int n, m; int arr[1000]; scanf("%d %d", &n, &m); for (int i = 0; i < n; i++) { scanf("%d", &arr[i]); } m = m % n; if (m != 0) { reverse(arr, 0, n - 1); reverse(arr, 0, m - 1); reverse(arr, m, n - 1); } for (int i = 0; i < n; i++) { printf("%d", arr[i]); if (i < n - 1) printf(" "); } printf("\n"); return 0; }

这里我特意加了个if (m != 0),是吃过亏之后的习惯。m 被取模后可能变成 0,如果直接调用reverse(arr, 0, -1),虽然我的实现里while (start < end)会自动跳过,但把负数下标传给函数总归是不太放心。写成条件判断,逻辑更清晰,也防止照抄时踩翻。

三种写法各有适用场景:面试讲思路,我更推荐三次反转,因为它不依赖“输出格式恰好按映射顺序”这种巧合;考试求稳,取模映射往往最快。暴力法在代码简单的前提下也是合理的起点,不要看不起它。

写法时间复杂度额外空间代码量主要风险
暴力移动O(n*m)O(1)最少数据量大时超时
取模映射O(n)O(1)最短公式方向易写反
三次反转O(n)O(1)略多反转边界参数易错

3. 挂在细节上的那些测试点:我的踩坑排查实录

3.1 m 等于 n 的倍数:一个取模引发的连锁错误

有一次我在本地的 C 环境里自己验证,样例明明都对,提交到平台却有一个测试点红。那时没有调试器,能靠的就是 printf 插桩和逻辑排查。先看题面条件,再对照自己的代码,脑子里突然闪过一个念头:m 是不是可以被 n 整除?

如果 m=10、n=5,不做m %= n的话,暴力法会真的循环 10 次。凑巧的是,10 是 5 的 2 倍,数组每转 5 次回原样,转 10 次还是原样,所以输出恰好正确,浪费的只是时间。但如果不做取模,而 m 是 n 的倍数之外的情况,比如 m=7、n=5,暴力法也能得到正确结果,只是慢。那问题出在哪?出在“不取模却去写三次反转”的人身上。

比如有人写了reverse(arr, 0, m - 1),m 没有先取模,m-1 可能大于等于 n,reverse 函数里就发生了数组越界。在 C 语言里,数组越界写不一定会立刻崩,它可能覆盖了相邻变量,让程序跑到最后输出一个莫名其妙的值。这种 bug 最折磨人,因为结果既不是固定的错误,也不是稳定的崩溃。

我的建议很朴素:读入 m 之后,紧接着写一行m = m % n;,把它当作固定的肌肉记忆。别让这种边角条件反复咬你。

3.2 输出格式与缓冲区残留:答案对但 WA 的经典嫌疑

另一个常见的 WA 原因跟算法没关系,纯粹是输出格式。题库要求“相邻元素用一个空格分隔”,不少同学会写成:

for (int i = 0; i < n; i++) { printf("%d ", arr[i]); } printf("\n");

这会把最后一个元素后面也打出一个空格。人眼看起来无所谓,但机器判分是逐字节比较的,多个空格就是 Wrong Answer。我之前也被这种问题卡过,后来一律采用“元素之间才打空格”的写法:

for (int i = 0; i < n; i++) { printf("%d", arr[i]); if (i < n - 1) { printf(" "); } } printf("\n");

还有一个隐藏点:输入里元素之间到底是空格还是换行?scanf的%d会自动跳过任意空白字符,所以这个不需要担心;但如果你用getchar或字符方式读入,就一定要考虑换行残留。

我自己还踩过scanf读入顺序的坑。如果第一行是5 2,第二行才是数组,但有的题目可能把数组也写在第一行,比如5 2 1 2 3 4 5,就要看题面的输入约定。这一类东西读题时先确认,能省一整晚的排查。

3.3 越界写内存:C语言不拦你,但下一刻它就坑你

C 语言的数组越界检查约等于没有,arr[n]写进去不报错,行为未定义。我说的“未定义”是真未定义,不是夸张——它可能碰巧覆盖了别的变量,也可能在 release 版本里一切正常,在调试版里崩掉,还可能在提交平台上表现得不稳定。

我记得排查一个循环右移相关的代码时,同学的错误版长这样:

for (int i = n; i > 0; i--) { arr[i] = arr[i - 1]; }

循环从 i=n 开始,第一次就把 arr[n] 写了一下。arr[n] 正好越界,但程序没崩,只是后面的逻辑全乱了:最后 arr[0] 没有被正确赋值,因为循环只做到 i=1,然后 arr[0] 保持旧值。整个数组看起来像是被平移了一次,但首尾对不上。更隐蔽的是,arr[n] 如果正好覆盖了另一个局部变量,输出结果就会变得“时好时坏”。

正确写法是for (int i = n - 1; i > 0; i--),从最后一个有效下标开始。这个问题值得记一辈子:在 C 语言里,下标从 0 到 n-1,边界上的“多一格”“少一格”可能不会当场报错,但迟早会在评级系统里还给你。

3.4 用 printf 插桩定位:屡试不爽的调试土办法

在没有集成调试器的环境下,我强烈推荐用 printf 插桩来排查。做法很简单:在关键位置打印中间状态,比如:

printf("after step, n=%d m=%d\n", n, m); for (int i = 0; i < n; i++) { printf("%d ", arr[i]); } printf("\n");

把三版代码的每一步输出对比,就能快速看出是取模阶段错了、反转边界错了、还是输出格式错了。有段时间我觉得插桩太土,总想直接用调试器,后来发现自己最熟的还是 printf。尤其是做题、赶时间的时候,用一堆输出把函数内部“看穿”,比反复读代码慢一点但绝对更有效。

排查完之后,记得删掉调试输出再提交。带着多余输出的代码哪怕逻辑全对,也会因为输出多出几行而判错。这个低级错误我犯过不止一次。

4. 循环移位思想能解锁的同类题目:一套思路吃遍串、链表和矩阵

4.1 字符串左移与单词翻转

数组的循环右移延伸一下,就是字符串左移。比如把"abcde"左移 2 位得到"cdeab"。左移 k 位本质上等价于右移len-k位,直接用三次反转法,代码都不用改,把 int 数组换成 char 数组即可。

再进一步,字符串里还有一个经典的“翻转句子中的单词顺序”题:输入"i am a student",输出"student a am i"。这个问题朴素做法是先把每个单词单独反转,比如得到"i ma a tneduts",再整体反转,得到"student a am i"。这和三次反转是同一个思想:局部反转 + 整体反转 = 段位置互换。这个题在第 26 题之后经常出现,早早掌握这套反转组合拳,后面会很省力。

4.2 链表的循环右移:指针版本的实现与注意事项

链表版本更考验指针功底。核心思路依然可以复用:先把链表遍历一遍求长度 len,再把 k 对 len 取模,因为链表的长度不一定和数组长度一样可预测,所以这个取模也必不可少。接下来把尾节点的 next 指向头节点,让链表变成环;然后数出新头节点的位置,从那里断开。

C 语言的核心代码:

struct ListNode* rotateRight(struct ListNode* head, int k) { if (!head || !head->next || k == 0) { return head; } int len = 1; struct ListNode* tail = head; while (tail->next) { len++; tail = tail->next; } k = k % len; if (k == 0) { return head; } tail->next = head; // 成环 int step = len - k; // 新尾节点是第 len-k 个节点 struct ListNode* newTail = head; for (int i = 1; i < step; i++) { newTail = newTail->next; } struct ListNode* newHead = newTail->next; newTail->next = NULL; // 断开环 return newHead; }

这段代码里有几个很容易错的点:第一,len 从 1 开始计数,所以找第 len-k 个节点时,for 循环的初值要注意;第二,如果 k 等于 len 或 0,直接返回原链表,不然成环之后容易死循环;第三,断开环的时机必须在返回新头之前,否则整个链表环环相扣,遍历会无限循环。链表题和数组题的区别在于,数组的下标是数学的、可预测的,链表的连接关系则是物理的,改一个指针就要想清楚前后顺序。

4.3 二维矩阵旋转:从一维下标映射到二维行列映射

循环右移的另一条延伸线是二维矩阵旋转。比如把一个 n 阶方阵顺时针旋转 90 度。从下标角度看,旋转就是在建立新旧坐标的一一对应:原矩阵中(i, j)位置的元素,会落到新矩阵(j, n-1-i)位置。这个映射关系看起来和(i - m + n) % n完全不同,但本质同一件事:用数学公式描述“元素搬家”的规律,而不是真的逐个搬运。

区别在于,一维右移只需要一个模运算,二维旋转需要两组坐标换算,而且还要考虑是原地置换还是额外开一个矩阵。如果不要求原地,开一个rotated[n][n],两层 for 循环就能完成:

for (int i = 0; i < n; i++) { for (int j = 0; j < n; j++) { rotated[j][n - 1 - i] = matrix[i][j]; } }

如果要求原地,就需要沿着环 4 个元素一组交换,复杂度会高一些。先掌握“坐标映射”这个思维,再考虑内存和交换优化,是我建议的学习路径。

我当时在这道题上耗了大半个晚上,最后发现错在两处:一是忘了对 m 取模,二是在输出格式上多打了一个空格。后来每次做数组题,我都会下意识先检查这三个点:边界、取模、输出格式。这套“吃亏换来的检查习惯”,帮我省下的时间远超想象。

如果你也卡在 SSE 平台第 26 题附近,我的建议是先别急着翻答案。把题目读三遍,把输入输出例子手算一遍,再想清楚“我要不要真的移动元素”这个问题。这三分钟的前置思考,比在编译器里瞎试半小时有效得多。祝刷题顺利。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进