☰
C语言指针经典习题:数组循环后移的三种实现方式
2026/10/10 15:36:58 网站建设 项目流程

但凡学过C语言的人,几乎都在第八章指针这儿卡过一阵子。何钦铭和颜晖主编的《C语言程序设计(第四版)》在指针这一章最后,通常留着一道经典习题:有n个整数,使前面各数顺序向后移m个位置,最后m个数变成最前面的m个数。这道题在网上被反复搜,是PTA、浙大版教材配套练习里出镜率极高的题型。

题目本身并不复杂,但它把指针、数组名、下标运算、地址偏移,以及最磨人的边界条件全串在了一个小函数里。很多人不是不会写代码,而是没搞明白“移动”到底在移动什么。别急着敲键盘,先把这道题吃透,指针的底子能扎实一半。

1. 先搞清楚“循环后移”到底在让你做什么

1.1 原题长什么样

标准的题目描述是:输入n个整数,存到一个数组里;再输入一个正整数m;要求把数组中的元素顺序向后移动m个位置,最后m个元素跑到数组最前面。注意“循环”两个字,它不是简单地把数据往后推出去丢掉,而是像环形传送带一样,尾部的东西绕过一圈补到头部来。

举个例子,数组内容是:

1 2 3 4 5 6 7 8

n=8,m=3,移动后的结果应该是:

6 7 8 1 2 3 4 5

最后三个数6、7、8跑到了最前面,前面的1到5依次后移三位。数组整体看起来像顺时针转了三分之一圈。

输出格式也有讲究,很多时候要求数字之间用空格分隔,最后一个数字后面不能有空格。很多同学在PTA上交这道题,格式错被扣分,往往是最后一个空格没处理干净。

1.2 这道题实际上想考你什么

教材把它放在指针这一章,不是为了让你背一个“移动模板”。它真正要验证的是四件事:

  • 你是否理解数组在内存里是连续存放的,每个元素之间地址相差固定字节。
  • 你是否知道数组名和指针变量之间的关系,以及a[i]和*(a+i)为什么等价。
  • 你是否能处理“整体位移”而不是“逐个交换”时的覆盖问题。
  • 你是否具备基本的复杂度意识,知道什么样的写法在n很大时会崩。

如果只是想把结果算出来,用两个数组拷贝一下当然能过。但那样指针这章的重点就没练到。教材的用意,是让你用指针的“可移动视角”来看待数组,而不是把数组当成一个不能移动的固定容器。

1.3 初学者最常见的错误直觉

我见过太多人第一次接触这道题时,第一反应是“循环左移=一次移一格”。于是写出这样的逻辑:

for (int k = 0; k < m; k++) { // 每轮把最后一个元素存起来 int temp = a[n - 1]; // 从后往前移动 for (int i = n - 1; i > 0; i--) { a[i] = a[i - 1]; } a[0] = temp; }

这个写法思路没错,结果也算得对。但它的复杂度是O(n×m)。当n等于十万、m等于五万时,要执行五十亿次赋值,程序会慢到像死机。

教材要的不是这种“挪一步看一步”的笨办法,而是希望你想清楚:能不能一次性把整段数据搬到目标位置,同时用指针的方式表达出来?这个问题想明白了,后面写代码就是水到渠成的事。

2. 数组名、指针和“移动”的本质:先理解再写码

2.1 数组名不是指针变量,但可以当指针用

这是很多初学者绕不过去的弯。数组名和指针到底是什么关系?在C语言里,数组名代表数组首元素的地址,也就是&a[0],但它是一个常量,不是变量。你不能写:

int a[10]; a++; // 编译错误

因为a本身没有存储空间,它是编译器用来计算地址的一个符号。但你可以这样:

int *p = a; // p保存a[0]的地址 p++; // 现在p指向a[1]

指针变量是一个真正的变量,它的值可以被修改。这就是数组名和指针的核心区别:数组名的值不能变,指针的值随时可以变。

在这个基础上,下面几种写法在访问同一个元素时是等价的:

a[i] 等价于 *(a + i) p[i] 等价于 *(p + i)

a + i是在数组名这个常量基础上加偏移量,p + i是在指针变量当前值的基础上加偏移量。运算规则都是“跳i个元素”,而不是“跳i个字节”。

2.2 “移动”在内存里到底发生了什么

很多人觉得“把数字往后移”就是把内存里的每一个字节都搬走。这是最朴素的物理认知,但指针给了你一个更高级的视角。

你可以把数组想象成一条固定长度的书架,上面摆了n本书。循环后移m位,最直观的做法是每本书都往后挪m个位置,排在末尾的书搬回最前面。

但换个想法:如果不要求“物理位置”必须改变,只要求“按顺序读取”时呈现新的序列,那完全可以用一个指针重新定义起点。比如:

int *p = a + n - m;

p指向数组倒数第m个元素。当你先输出p到a + n这一段,再输出a到p这一段时,得到的结果就已经是“循环后移”之后的样子了:

a + n - m → a + n:输出最后m个数 a → a + n - m:输出前面n-m个数

合起来就是完整的新序列。这个过程中,数组一个元素都没搬,只是指针改变了读取顺序。这种方式在很多场景下非常有用,比如共享内存、只读缓冲区、嵌入式系统里不想浪费拷贝时间的地方。

但注意,很多习题明确要求“修改原数组并输出”,这时候你就不能只靠指针换起点,必须真正搬移数据。理解“指针可以重新定义访问起点”,是理解整道题的关键一步。

2.3 为什么这道题推荐用指针而非纯下标

用下标当然能解这道题,但指针有不可替代的优势。

第一,指针可以动态指向任意偏移位置,数组名不能。比如你需要“从倒数第m个元素开始处理”,写成int *p = a + n - m;非常自然,而用数组名表示这个逻辑就得额外计算下标。

第二,函数传参时,数组名传进函数本质上也是传地址,形参看起来是数组,实际上是一个指针。理解了这点,你就不会写出那种“在函数里修改数组却带不回去”的代码。

第三,从思维层面看,指针训练的是“地址即视角”。一个数组在内存里只有一份,但你可以通过不同的指针看到不同的数据片段。这种能力在链表、字符串处理、多维数组、函数指针里都会反复用到。教材把这个题放在指针章,就是想让你提前养成这个习惯。

3. 从零开始写出可运行的代码:思路和逐行拆解

3.1 输入处理和程序框架

先把框架搭起来。用固定大小数组,输入n和n个整数,再输入m。主函数结构如下:

#include <stdio.h> int main(void) { int n, m; int a[100] = {0}; scanf("%d", &n); for (int i = 0; i < n; i++) { scanf("%d", &a[i]); } scanf("%d", &m); // 核心处理 m = m % n; // 关键步骤,见后面说明 // 输出 for (int i = 0; i < n; i++) { printf("%d", a[i]); if (i < n - 1) { printf(" "); } } printf("\n"); return 0; }

这里最开始就应该处理m和n的关系。m完全可能大于n,比如数组长度是8,要求后移10位。移动10位和移动2位(10 % 8 = 2)的结果是完全一样的。

如果m恰好是n的倍数,比如移动8位、16位,数组转了一圈回到原地,m % n == 0,此时可以跳过所有移动逻辑直接输出。

3.2 核心函数:用指针实现循环后移

解决这道题最稳妥、也最好理解的思路是:

  • 先把最后m个数备份到临时数组。
  • 把前面n-m个数从后往前依次搬到后面m个位置。
  • 把备份的m个数放回最前面。

为什么必须从后往前搬?因为如果你从前往后搬,比如先把a[0]赋给a[3],那a[3]原来的值就被覆盖了,后面再把它往后搬时,搬走的不再是原始数据,而是被污染过的数据。

从后往前搬则不会有这个问题,因为每个位置的目标位置都在它自己后面,先搬后面的元素,不会影响前面还没搬的元素。

完整代码:

#include <stdio.h> int main(void) { int n, m; int a[100] = {0}; // 输入 scanf("%d", &n); for (int i = 0; i < n; i++) { scanf("%d", &a[i]); } scanf("%d", &m); // 如果m比n大,先取余,等价于只转一圈以内 m = m % n; if (m != 0) { int temp[100] = {0}; // 1. 用指针把末尾m个元素备份到temp for (int i = 0; i < m; i++) { *(temp + i) = *(a + n - m + i); } // 2. 从后往前,把前面n-m个元素整体后移m位 for (int i = n - 1; i >= m; i--) { *(a + i) = *(a + i - m); } // 3. 把备份的m个元素填回数组开头 for (int i = 0; i < m; i++) { *(a + i) = *(temp + i); } } // 输出,最后一个数字后不加空格 for (int i = 0; i < n; i++) { printf("%d", a[i]); if (i < n - 1) { printf(" "); } } printf("\n"); return 0; }

这里用的*(a + i)本质就是a[i],写成指针形式是为了呼应教材章节主题。你完全可以把三处赋值写成temp[i] = a[n - m + i],效果完全一样。

3.3 用样例验证一遍执行过程

拿n=8, m=3,数组初始为1 2 3 4 5 6 7 8来推演。

第一步:备份末尾3个元素。

temp[0] = a[5] = 6 temp[1] = a[6] = 7 temp[2] = a[7] = 8

第二步:从后往前搬前5个元素。i从7开始循环,但注意i >= m,也就是i只到3,因为a[0]到a[2]这些位置前面没有元素可搬了。

实际执行顺序:

i=7: a[7] = a[4] = 5 i=6: a[6] = a[3] = 4 i=5: a[5] = a[2] = 3 i=4: a[4] = a[1] = 2 i=3: a[3] = a[0] = 1

此时数组变成:

1 1 2 3 4 5 6 7

等一下,位置不对。让我重新理清:

初始:a[0]=1 a[1]=2 a[2]=3 a[3]=4 a[4]=5 a[5]=6 a[6]=7 a[7]=8

第二步执行:

i=7: a[7] = a[4] = 5 i=6: a[6] = a[3] = 4 i=5: a[5] = a[2] = 3 i=4: a[4] = a[1] = 2 i=3: a[3] = a[0] = 1

执行完后数组为:

a[0]=1 a[1]=2 a[2]=3 a[3]=1 a[4]=2 a[5]=3 a[6]=4 a[7]=5

第三步:把temp中的6、7、8填回开头:

a[0]=6 a[1]=7 a[2]=8

最终数组:

6 7 8 1 2 3 4 5

结果完全正确。

再测一个边界:n=5, m=7。先做m = 7 % 5 = 2,相当于只后移2位。数组1 2 3 4 5,后移2位的结果应该是4 5 1 2 3。按上面的流程走一遍:

备份末尾2个:temp[0]=a[3]=4, temp[1]=a[4]=5 后移: i=4: a[4] = a[2] = 3 i=3: a[3] = a[1] = 2 i=2: a[2] = a[0] = 1 数组变为 1 1 1 2 3 填回开头:a[0]=4 a[1]=5 最终:4 5 1 2 3

正确。

3.4 为什么备份区大小不好拍脑袋

有些同学图省事,把temp开成和a一样大:

int temp[100];

这当然没问题,但如果题目在嵌入式开发或者内存受限环境下,最好还是根据实际需要分配。严格来说,这道题需要的备份空间是O(m),m最大是n-1,所以开int temp[100]在固定小数组场景下完全够用。

更规范的做法是动态分配:

int *temp = (int *)malloc(sizeof(int) * m);

用完记得free(temp);。教材阶段很多环境还没讲malloc,用固定数组也行,但你要理解备份空间的量级是m而不是n,这是复杂度意识的一部分。

4. 这题最容易翻车的4个地方:我当年全踩过

4.1 忘记对m取余,导致数组访问越界

最经典的错误是:m大于n时不处理,直接搬移。比如n=8, m=10,数组只有8个元素,你却访问a[n - m + i],也就是a[-2 + i],直接访问到数组前面不存在的地址。

更隐蔽的是后移循环里i - m为负数,比如i=0时去访问a[-10]。这种情况在C语言里不会直接报错,它只是访问了数组之外的内存,结果完全不可控,可能随机崩也可能给你一个莫名其妙的垃圾值。

正确做法就是进入处理逻辑之前先执行:

m = m % n;

取余后保证m > 0 && m < n,这是所有后续操作的安全前提。

4.2 从前往后搬数据,结果数组被“复制”成同一个数

我见过最多的错误写法是这样的:

for (int i = 0; i < n - m; i++) { a[i + m] = a[i]; }

执行第一步a[3] = a[0]后,a[3]已经不是原来的4,而是1。接下来i=1时,a[4] = a[1]还是2,这一步碰巧没错,但到i=3时,问题就爆了。

我直接用例子演一遍:数组1 2 3 4 5 6 7 8,m=3。

i=0: a[3] = a[0] = 1 i=1: a[4] = a[1] = 2 i=2: a[5] = a[2] = 3 i=3: a[6] = a[3] // 此时a[3]已经被改成1了! i=4: a[7] = a[4] // a[4]已经被改成2了!

最终数组变成1 2 3 1 2 3 1 2,数据彻底失真。

破解办法就是前面反复强调的:从后往前搬,从i=n-1一直搬到i=m。因为每个元素的目标位置都在当前位置后面,你搬后面的元素时,前面还没搬的原始数据依然待在原地,不会被覆盖。

4.3 指针越界和野指针

用指针操作时,最容易写出指向数组外部的指针。典型场景是这种:

int *p = a; while (p <= a + n) { // 错误!a+n已经越界了 ... p++; }

在C语言里,指针可以指向数组最后一个元素之后的一个位置,也就是a + n,这个地址允许存在,但不允许读写它。标准中专门允许这样的“尾后指针”存在,用于循环边界判断。如果你傻乎乎地对它取值*p,那行为就是未定义的。

正确的循环条件应该是:

while (p < a + n) { ... p++; }

还有一类越界更隐蔽:对指针做++操作次数不对,导致指针指到数组之外。比如你想遍历末尾m个元素,起始指针是a + n - m,结束位置应该是a + n - 1,很多人写着写着把边界算成a + n - m + 1,少或多访问一个位置。

我的建议是:先用下标把逻辑写对,再统一改成指针形式。这一步能过滤掉大部分越界风险。

4.4 临时变量类型和数组类型不一致

有些人会把临时数组定义成int,但数组元素本身是int,这没问题。一旦数据类型变成double或char,问题就来了。

比如数组是double类型,你写int temp[100]去备份,会把每个double的低32位截断保存,搬回来时数据已经坏了。

正确的做法是让备份数组和原数组保持同类型。而且在用指针赋值时,编译器会根据指针类型自动计算地址偏移,double *p + 1是跳过8个字节,char *p + 1是跳过1个字节。这个差异在处理非int类型数据时尤其重要。

5. 对照三种主流实现方案:临时数组、原地搬移、逆置三段法

5.1 临时数组备份法:最稳但多耗空间

前面完整代码用的就是这种方案。它的特点是逻辑直白:先备份末尾m个数,搬移其余元素,再把备份填回开头。时间复杂度O(n),空间复杂度O(m)。

  • 优点:思路简单,几乎不会写错,适合初学和考试快速AC。
  • 缺点:需要一个额外的数组,当m接近n时,空间开销接近O(n)。

如果题目没有刻意卡内存,这种写法是最推荐优先完成的版本。先把对的代码写出来,再去追求更省的写法。

5.2 原地搬移法:省掉临时数组但有细节坑

严格意义上的“原地搬移”不借助辅助空间,而是把最后一个元素暂存到一个变量里,然后通过一系列“轮转”把每个元素放到正确位置。

一个实现方式是用数学上的分组轮换。因为移动m位后,元素会形成若干个独立的循环链。以n=6, m=2为例,数组1 2 3 4 5 6移动后是5 6 1 2 3 4。

你可以从位置0出发,元素1应该去位置2,位置2的元素3应该去位置4,位置4的元素5应该去位置0,形成一个环。按这个环一次性把元素放到位。

但分组的规律和n、m的最大公约数有关系,实现起来比较绕,解释成本也高。实际考试中不推荐优先写这个方案,因为边界条件太多,稍微写错一个就全盘崩。

5.3 逆置三段法:最优雅,空间复杂度O(1)

这是目前公认的最优解法之一。它的原理基于一个简单事实:把一个数组循环后移m位,等价于先把整段数组倒过来,再分别把前后两段倒回去。

具体到n=8, m=3的数组1 2 3 4 5 6 7 8:

第一步:把前n-m个数逆置,也就是前5个数1 2 3 4 5变成5 4 3 2 1,数组变为:

5 4 3 2 1 6 7 8

第二步:把后m个数逆置,6 7 8变成8 7 6,数组变为:

5 4 3 2 1 8 7 6

第三步:把整个数组逆置:

6 7 8 1 2 3 4 5

完美匹配目标结果。三段逆置法的好处是空间复杂度O(1),只需要一个临时变量用来交换元素。代码也非常稳定:

void reverse(int arr[], int left, int right) { while (left < right) { int temp = arr[left]; arr[left] = arr[right]; arr[right] = temp; left++; right--; } } // 调用 reverse(a, 0, n - m - 1); // 逆置前 n-m 个 reverse(a, n - m, n - 1); // 逆置后 m 个 reverse(a, 0, n - 1); // 整体逆置

需要注意的是,调用前必须先处理m = m % n,并且当m == 0时三段逆置会把数组倒两遍又恢复原样,虽然结果对,但没必要执行。

如果题目要求不能用额外数组,逆置法是首选。

三种方案对比:

方案时间复杂度空间复杂度编码难度适用场景
临时数组备份O(n)O(m)低考试稳拿分、小规模数据
分组轮换O(n)O(1)高内存极受限且熟悉数论
逆置三段O(n)O(1)中推荐标准答案、嵌入式场景

6. 循环后移不只是会做题:这套思维在真实场景里的延伸

6.1 环形缓冲区:底层最常用的循环思想

你在操作系统、网络驱动、音频播放里看到的环形缓冲区,本质上就是“循环后移”思想的高级形态。它们不用真的把数据从后往前搬移,而是维护头指针和尾指针,让数据在固定大小的内存池里打转。

比如一个生产音频数据的程序,缓冲区只有4096字节,每次新数据到达,旧的播放完,新数据覆盖掉最旧的位置。这里的头尾指针每写一次就向后移动,越界后回到起点,这就是一个“永不搬数据”的循环后移。

用我们这道题的思路去理解:如果只在输出时重新定义访问起点,那你完全不需要搬动数组里的任何元素。这个想法在嵌入式开发、性能敏感系统中非常常见。

6.2 数组轮转是很多算法题的地基

“循环后移”换个名字就是“数组轮转”。LeetCode第189题“轮转数组”和这道题几乎一模一样,只是方向可能不同。逆置三段法在那里依然是好用的通用解法。

不止如此,很多字符串问题也用同一套路。比如判断两个字符串是否互为“循环移位后的结果”,你完全可以拼接其中一个字符串,再在拼接结果里查找另一个字符串。这也是循环后移思想的变体。

指针在这里依然扮演核心角色:拼接字符串str是str1 + str1,用指针遍历从不同起点出发的子串,恰好就是“重新定义访问起点”的实战应用。

6.3 双指针的起点:一个指针不够,就再开一个

学完这道题,下一个值得关注的概念是“双指针”。教材后面的习题里,经常有“将数组中负数放前面正数放后面”“查找两个有序数组共同元素”这类题目,双指针的思想可以高效解决。

回到循环后移本身:如果你用指针p指向数组开头,用另一个指针q指向数组的新起点a + n - m,然后同时遍历,你甚至可以在不修改原始数组的情况下直接按新顺序输出。这就是“指针即视角”的直观体现。

等你学到链表,你会发现循环链表、约瑟夫环问题,本质上也是“移动一个指针,跨过n个位置,循环回到起点”。这道题学到的东西完全没有浪费。

7. 最后再分享几条实操经验

这道题是我当年重写了三遍才算真正掌握的。第一遍用临时数组,第二遍改用指针,第三遍用逆置法。每次重写都有新的理解,建议你也试试。

一个非常实用的技巧:写完后不要直接提交,先用几个边界用例测试。我最常用的测试集是m=0、n=1、m=3*n、m>n这四组。n=1时任何移动都等于原地,m=3*n时取余后等于0,这两组能筛掉不少粗心错误。

调试时可以临时加一条输出语句,在每一步之后打印数组状态。看到“从后往前搬”之后数组中间那一段是什么状态,比任何推导都直观。不过提交前记得删掉调试语句,有些OI系统对多余输出很敏感。

如果你发现自己始终想不清楚“从后往前”的原因,我的建议是拿纸笔把下标画出来。数组画成一列格子,每一步搬移动用箭头标注,画到第三步基本就通了。我在带学生时发现,愿意画图的人学指针的速度,普遍比硬背代码的人快很多。

这道题做透了,再回到教材第八章,你会发现后面的指针数组、指向指针的指针、函数指针,都建立在同一套“地址加偏移”的逻辑上。循环后移只是把门推开一条缝,门后的世界大得很。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询