但凡学过C语言的人几乎都在第八章指针这儿卡过一阵子。何钦铭和颜晖主编的《C语言程序设计第四版》在指针这一章最后通常留着一道经典习题有n个整数使前面各数顺序向后移m个位置最后m个数变成最前面的m个数。这道题在网上被反复搜是PTA、浙大版教材配套练习里出镜率极高的题型。题目本身并不复杂但它把指针、数组名、下标运算、地址偏移以及最磨人的边界条件全串在了一个小函数里。很多人不是不会写代码而是没搞明白“移动”到底在移动什么。别急着敲键盘先把这道题吃透指针的底子能扎实一半。1. 先搞清楚“循环后移”到底在让你做什么1.1 原题长什么样标准的题目描述是输入n个整数存到一个数组里再输入一个正整数m要求把数组中的元素顺序向后移动m个位置最后m个元素跑到数组最前面。注意“循环”两个字它不是简单地把数据往后推出去丢掉而是像环形传送带一样尾部的东西绕过一圈补到头部来。举个例子数组内容是1 2 3 4 5 6 7 8n8m3移动后的结果应该是6 7 8 1 2 3 4 5最后三个数6、7、8跑到了最前面前面的1到5依次后移三位。数组整体看起来像顺时针转了三分之一圈。输出格式也有讲究很多时候要求数字之间用空格分隔最后一个数字后面不能有空格。很多同学在PTA上交这道题格式错被扣分往往是最后一个空格没处理干净。1.2 这道题实际上想考你什么教材把它放在指针这一章不是为了让你背一个“移动模板”。它真正要验证的是四件事你是否理解数组在内存里是连续存放的每个元素之间地址相差固定字节。你是否知道数组名和指针变量之间的关系以及a[i]和*(ai)为什么等价。你是否能处理“整体位移”而不是“逐个交换”时的覆盖问题。你是否具备基本的复杂度意识知道什么样的写法在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 用样例验证一遍执行过程拿n8, m3数组初始为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]这些位置前面没有元素可搬了。实际执行顺序i7: a[7] a[4] 5 i6: a[6] a[3] 4 i5: a[5] a[2] 3 i4: a[4] a[1] 2 i3: 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第二步执行i7: a[7] a[4] 5 i6: a[6] a[3] 4 i5: a[5] a[2] 3 i4: a[4] a[1] 2 i3: 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结果完全正确。再测一个边界n5, m7。先做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 后移 i4: a[4] a[2] 3 i3: a[3] a[1] 2 i2: 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时不处理直接搬移。比如n8, m10数组只有8个元素你却访问a[n - m i]也就是a[-2 i]直接访问到数组前面不存在的地址。更隐蔽的是后移循环里i - m为负数比如i0时去访问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。接下来i1时a[4] a[1]还是2这一步碰巧没错但到i3时问题就爆了。我直接用例子演一遍数组1 2 3 4 5 6 7 8m3。i0: a[3] a[0] 1 i1: a[4] a[1] 2 i2: a[5] a[2] 3 i3: a[6] a[3] // 此时a[3]已经被改成1了 i4: a[7] a[4] // a[4]已经被改成2了最终数组变成1 2 3 1 2 3 1 2数据彻底失真。破解办法就是前面反复强调的从后往前搬从in-1一直搬到im。因为每个元素的目标位置都在当前位置后面你搬后面的元素时前面还没搬的原始数据依然待在原地不会被覆盖。4.3 指针越界和野指针用指针操作时最容易写出指向数组外部的指针。典型场景是这种int *p a; while (p a n) { // 错误an已经越界了 ... 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位后元素会形成若干个独立的循环链。以n6, m2为例数组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位等价于先把整段数组倒过来再分别把前后两段倒回去。具体到n8, m3的数组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. 最后再分享几条实操经验这道题是我当年重写了三遍才算真正掌握的。第一遍用临时数组第二遍改用指针第三遍用逆置法。每次重写都有新的理解建议你也试试。一个非常实用的技巧写完后不要直接提交先用几个边界用例测试。我最常用的测试集是m0、n1、m3*n、mn这四组。n1时任何移动都等于原地m3*n时取余后等于0这两组能筛掉不少粗心错误。调试时可以临时加一条输出语句在每一步之后打印数组状态。看到“从后往前搬”之后数组中间那一段是什么状态比任何推导都直观。不过提交前记得删掉调试语句有些OI系统对多余输出很敏感。如果你发现自己始终想不清楚“从后往前”的原因我的建议是拿纸笔把下标画出来。数组画成一列格子每一步搬移动用箭头标注画到第三步基本就通了。我在带学生时发现愿意画图的人学指针的速度普遍比硬背代码的人快很多。这道题做透了再回到教材第八章你会发现后面的指针数组、指向指针的指针、函数指针都建立在同一套“地址加偏移”的逻辑上。循环后移只是把门推开一条缝门后的世界大得很。
阅读完成 · 觉得有帮助?