数组逆序存放这道题,几乎每个学C语言的人都会遇到。PTA上标着20分,看起来简单,但它恰好踩中了C语言里好几个关键知识点:数组下标、内存连续存储、边界条件、输入输出格式。我带过的学生里,不少人在这个题上栽过跟头,有的把“逆序存放”写成了“逆序输出”,有的交换逻辑绕了半天,有的在OJ的格式要求上反复吃罚时。这篇文章就把这个实验题彻底拆开,从题目的真实意图、基础解法、指针视角、扩展场景到常见报错,一次讲清楚。不管你是刚学数组的新手,还是准备面试想复习基础,这篇都值得看完。
1. 题目拆解与核心需求——先搞清楚这道题到底在考什么
1.1 原题长什么样
这道题的描述通常是这样的:本题要求编写程序,将给定的n个整数存入数组后,再把这n个数逆序存放,最后在一行中输出。输入格式是第一行给出一个正整数n(1≤n≤10),第二行给出n个整数,用空格分隔。输出格式是一行中输出逆序存放后的n个整数,每个整数后面跟一个空格,或者行末不得有多余空格,具体看OJ版本。
我见过很多同学拿到题就开始写代码,结果连题都没读完。这里需要先明确一件事:题目要求的是“存入数组后逆序存放”,这意味着你要先老老实实把数据读进一个数组,再对数组本身做操作,最后输出数组内容。不是让你边读边倒着存,也不是让你读完之后倒着打印一遍就完事。
有些题确实可以直接倒着输出得分,但“逆序存放”这道题在多数OJ上不会这么宽松。它会检查数组被修改后的实际内容,或者后续操作依赖这个逆序后的数组。所以第一步就是把题目意图看清楚:它要的是“数组里的数真的被倒过来了”。
1.2 “逆序存放”和“逆序输出”是两码事
从字面上看,“逆序存放”是对数组内容做修改,“逆序输出”只是调整打印顺序。前者改变了数据本身,后者只改变了显示方式。放在实际开发里,这个区别很关键。我举个例子:你从数据库里查出一批记录,需要在界面上倒序展示,这时候只改显示顺序就够了,数据源不用动。但如果后续所有逻辑都基于倒序数据来计算,那你就要真的把数组倒过来,否则每一处逻辑都得小心翼翼地去“倒着遍历”。
很多初学者会想:我直接 for (i = n - 1; i >= 0; i--) printf("%d ", a[i]); 不就完了吗?输出结果确实是从后往前了,但数组a里的内容根本没变。如果题目要求“逆序存放后输出”,这种写法严格来说是错的。你在自己电脑上跑可能看不出区别,因为输出一样,但在OJ上如果判题逻辑检查的是数组状态,或者你后续还有别的操作,就会暴露问题。
1.3 20分的实验题踩分点在哪
PTA这类OJ的20分题,一般不会只给你一个“对或错”的满分判定,而是根据测试点分布来踩分。基础功能占大头,比如能正确读入、正确交换、正确输出,大概能拿一半以上的分数。剩下的分数往往卡在边界情况和格式上:n等于1时怎么办,n是偶数怎么办,行末是否有空格,是否换行。
我批改过很多次类似的实验作业,看到最多的丢分原因不是算法不会,而是格式问题。行末多了一个空格,OJ直接判格式错误,扣掉好几分。所以这道题的本质,不光是考你“会不会反转数组”,还在考你有没有工程思维——对边界条件敏感,对输出格式严格,对每一个循环变量的取值都心里有数。
2. 对称交换法:把逆序操作落到代码上
2.1 最直觉的思路:两头交换,向中间收拢
如果把数组看成一行队伍,逆序存放就是让队头和队尾换位置,第二个和倒数第二个换位置,一直换到中间。这个方法叫对称交换,也叫双指针交换。它的核心逻辑是:a[0]和a[n-1]交换,a[1]和a[n-2]交换,一直到两端相遇为止。
用代码写出来就是三层结构:循环、交换、边界判断。交换两个变量的值需要一个临时变量temp,这是C语言里的基本功,也是很多新手第一次接触“中间变量”概念的场景。
int temp; temp = a[i]; a[i] = a[n - 1 - i]; a[n - 1 - i] = temp;每次交换涉及三次赋值操作,这是交换两个变量的通用写法。为什么非要一个中间变量?因为直接 a[i] = a[n-1-i]; a[n-1-i] = a[i]; 的话,第一步执行完,a[i]原来的值就丢了,第二步拿到的已经是覆盖后的值。这个道理我反复给学生强调,看起来简单,但很多人写代码的时候脑子一热就忘了。
2.2 循环边界为什么是 n/2,奇偶怎么处理
这是这道题最容易出bug的地方。循环要执行多少次?答案是n/2次(整数除法下取整)。我见过不少人写成 i <= n/2,或者 i < n,结果程序跑出来要么数组没变化,要么越界。
我们来手动模拟一下。n = 4时,数组下标是0、1、2、3。i=0交换a[0]和a[3],i=1交换a[1]和a[2],到i=2时,a[2]和a[1]又是一次交换,等于把刚才的交换又换回去了。所以循环条件必须是 i < n/2,也就是i只能取0和1。
n = 5时,下标是0、1、2、3、4。i=0交换a[0]和a[4],i=1交换a[1]和a[3],i=2时a[2]和a[2]自己换自己,没有意义但也不出错。所以 i < n/2 时,i只能取0和1,i=2不会执行,正好避开这个无意义操作。
如果你写的是 i <= n/2,偶数n=4时就会多交换一次,数组等于没变;奇数n=5时多交换一次a[2]自己,影响不大。也就是说,这是一个在奇数情况下可能“侥幸通过”,偶数情况下必错的隐藏bug。
2.3 完整代码与复杂度分析
把输入、逆序、输出串起来,完整代码如下:
#include <stdio.h> int main() { int n; int a[10]; scanf("%d", &n); for (int i = 0; i < n; i++) { scanf("%d", &a[i]); } for (int i = 0; i < n / 2; i++) { int temp = a[i]; a[i] = a[n - 1 - i]; a[n - 1 - i] = temp; } for (int i = 0; i < n; i++) { if (i > 0) printf(" "); printf("%d", a[i]); } printf("\n"); return 0; }时间复杂度是O(n),因为每个元素最多被访问两次(一次交换,一次输出),空间复杂度是O(1),只用了常数个额外变量。这个复杂度已经是最优的了,因为你至少要把每个元素读一遍、写一遍。即便n最大只有10,但分析复杂度的思维要养成,以后遇到大数据量题目才不慌。
3. 指针视角与内存模型:顺带把数组本质看透
3.1 数组在内存里是一段连续的空间,下标就是偏移量
这道题如果用指针来写,能帮助你把数组的本质看得更透。很多教材在讲数组时说“数组名是首元素地址”,这句话新手往往理解不深。实际上,C语言里数组在内存中占据的是一段连续的空间,a[i]的意思是“以a为首地址,偏移i个元素单位后取出那个位置的值”。
这个“偏移”非常关键。int在大多数平台上占4个字节,所以a[3]取的是首地址往右数12个字节处的数据。指针变量就是用来存储这种地址的,int *p = a; 让p指向数组首元素,那么p+1就指向第二个元素,p+2指向第三个。这不是简单的地址+1,而是根据类型大小来移动的,指针算术正是C语言高效访问内存的底气。
3.2 双指针版本:left 和 right 的移动
用两个指针分别指向数组头和尾,同时往中间走,交换它们指向的元素。代码长这样:
#include <stdio.h> int main() { int n; int a[10]; scanf("%d", &n); for (int i = 0; i < n; i++) { scanf("%d", &a[i]); } int *left = a; int *right = a + n - 1; while (left < right) { int temp = *left; *left = *right; *right = temp; left++; right--; } for (int i = 0; i < n; i++) { if (i > 0) printf(" "); printf("%d", a[i]); } printf("\n"); return 0; }这里有个细节,right的初始值是a + n - 1,而不是a + n。因为数组有n个元素,下标从0开始,最后一个元素是a[n-1],所以指针要偏移n-1个单位。写错成a+n的话,指针指向的是数组末尾之后的那块内存,访问它属于未定义行为,运气好没崩,运气不好直接段错误。
while (left < right) 这个条件比下标的 i < n/2 更直观,因为它天然处理了奇偶情况。n为偶数时,两个指针在中间“擦肩而过”前循环结束;n为奇数时,两个指针在中间元素处相遇,left == right,循环停止。你不用去记边界条件,只需要理解“没有相遇就继续换”这个逻辑。
3.3 数组定义方式的取舍:宏定义、const、VLA、固定大小
题目给的n不超过10,所以直接int a[10]就够了。但实际写代码时,很多人会在数组定义大小上纠结:用宏定义、用const常量、还是直接写数字?
我推荐用宏定义,也就是在代码开头写 #define MAXN 10,然后 int a[MAXN]。这样做的理由很朴素:代码的可读性和可维护性。将来题目范围变了,比如n不超过1000,你只需要改一行宏定义,而不是满世界找“10”这个魔法数字。
#define MAXN 10也可以用const int MAXN = 10。注意,C语言里const修饰的变量本质上是“只读变量”,不是真正的编译期常量,用来定义数组大小时在C89标准下可能不被支持,但在C99和更严格的C++里通常没问题。刷OJ的话,宏定义是兼容性最好的选择。
还有一种写法是int a[n],其中n是运行时从scanf读进来的,这叫变长数组(VLA),由C99标准引入。在gcc编译环境下可以运行,但C++标准不支持,部分OJ的编译器也可能报错。稳妥起见,刷题和做实验时优先用固定大小数组,不要为了省那几行代码去挑战编译器的脾气。
4. 从一维到多维:逆序操作的扩展与变体
4.1 先想明白一个问题:数组内容变了吗
做这道题的时候,我总会问学生一个问题:交换前后,数组的地址变了吗?答案是没变。a还是那个a,a[0]的地址还是原来那个地址,变的只是每个地址里存放的值。这个概念听起来简单,但很多人学到后面会把“数组”和“指向数组的指针”搞混。
数组名做函数参数时会退化成指针,这也是很多人在写reverse函数时容易困惑的点。比如你写一个函数void reverse(int arr[], int n),函数内部对arr[i]的修改,会直接影响main函数里的原数组。原理就是数组参数传递的是地址,不是副本。这跟普通int类型参数按值传递完全不同。一维数组、二维数组、指针数组,本质上都是“连续内存上的数据布局”,逆序操作的核心永远是“对称位置交换”。
4.2 二维数组逆序:行内逆序与行序逆序
二维数组比一维数组多一个维度,逆序的做法也分几种。最常见的两种:一是把每一行内部的元素逆序,比如int b[3][4],交换b[i][j]和b[i][3-j];二是把整个数组的行顺序反过来,第一行变最后一行,第二行变倒数第二行。
行内逆序的代码和一维数组几乎一模一样,只不过把a[i]换成了b[i][j],把n换成了列数4。行序逆序稍微麻烦一点,因为你交换的是整行数据,如果列数不大,可以直接用临时数组逐元素交换,或者用memcpy批量拷贝。这个题目虽然只要求一维数组,但理解二维的逆序,能帮你把“对称交换”的思路真正内化,而不是只会套模板刷题。
字符串数组也是类似的场景。char str[] = "hello"; 逆序后变成 "olleh",但要注意字符串结尾的'\0'不能跟着交换。如果你用strlen算出长度len,那么str[len-1]是最后一个有效字符,str[len]才是'\0'。交换范围只能是0到len-1,碰了'\0',字符串就废了。这个坑我在实际项目里也踩过,处理二进制数据时没有注意结束符,导致后面一片乱码。
4.3 指针数组的逆序,换的是指向,不是内容
指针数组在热词里也出现得很频繁。所谓指针数组,就是数组里每个元素存放的是一个指针,比如char *fruits[] = {"apple", "banana", "cherry"};。这里fruits[0]指向字符串"apple"的首字符,fruits[1]指向"banana"的首字符。
对指针数组做逆序,交换的是“指针”本身,字符串在内存中的位置不会变。逆序之后,fruits[0]会指向"cherry",fruits[2]会指向"apple"。这种操作在需要对一批字符串按某种规则倒排时很有用,因为你不用去移动字符串内容,只移动指针,开销小得多。这个思路在工程上叫“改变引用,不改变数据”,和“逆序存放”相比,算是同类操作的进阶版本。
5. 实际调试与常见报错排查实录
5.1 输出格式问题:行末空格和换行
这是新手在OJ上遇到最多的报错之一,不是“答案错误”,而是“格式错误”。输出要求通常是:在一行中输出逆序后的n个整数,每个整数后面跟一个空格,或者行末不得有多余空格,不同OJ规矩不一样。你可以采用“先输出第一个数,之后每个数前面加空格”的策略,这样任何情况下都不会在行末留下多余空格。
for (int i = 0; i < n; i++) { if (i > 0) printf(" "); printf("%d", a[i]); } printf("\n");我见过不少同学用for循环输出每个数后都加个空格,然后最后再单独处理最后一个。这样也不是不行,但多一步判断,代码就多一分出bug的可能。用“前置空格法”是最省心的。
5.2 段错误:scanf漏了取地址符或数组越界
段错误(Segmentation fault)是C语言新手最害怕的报错,一出现就是程序直接崩溃。在这个题里,最常见的段错误原因有两个:一个是scanf里漏写了取地址符。scanf("%d", a[i]); 少了&,这等于把一个整数当成了指针传给scanf,程序去访问那个“假地址”,必崩。正确写法是scanf("%d", &a[i]);
另一个原因是数组越界。题目说n最大是10,但你定义int a[10],下标范围是0到9,如果你读入或访问a[10],就越界了。这种越界有时候不会立刻崩溃,因为那块内存可能碰巧可读,但属于未定义行为,运气差一点就段错误。解决方法是养成习惯:循环条件里始终写 i < n,而不是 i <= n。
5.3 交换逻辑问题:边界写错、temp类型不匹配
交换后数组没变,大概率是循环边界写成了i <= n/2,这在上面已经详细说过。另外还有一种情况,temp类型和数组元素类型不一致。比如数组是double型,你写int temp = a[i],小数部分会被截断,再赋值回去就丢精度了。在这个题里数组是int,一般不会犯这种错,但基础要打好,以后处理浮点数组、结构体数组时,temp类型必须和数组元素类型严格匹配。
还有一种隐蔽的写法错误:把交换写成了 a[i] = a[n-1-i]; 然后缺少临时变量。结果就是数组后半部分的值覆盖了前半部分,前半部分的原值丢了,再交换时拿到的已经是错的数据。我建议初学者把交换三行代码当成一个固定模板来记,每次都先写temp = a[i],再写覆盖,最后回填。
5.4 读入问题:循环边界和n的取值
还有一个常见的隐蔽坑,就是读入环节。题目说第一行输入n,第二行输入n个整数。如果你把读入循环写成for (int i = 0; i <= n; i++),就会读入n+1个数,第n+1个数实际上读到的是下一行的第一个值,或者根本没输入导致scanf返回EOF,数组里留下一个未初始化的随机值。最后输出时,你会发现末尾多了一个奇怪的数字,或者程序行为变得不可预测。
排查这种问题时,最快的办法是加个print语句,把读入后的数组完整打一遍,确认数据是否符合预期。很多同学调试就靠printf,这不丢人,反而很高效。等确认读入没问题,再往下查交换和输出。这种分段排查的思路,是实际工程里最常用的调试方法。
5.5 常见问题速查表
| 错误现象 | 可能原因 | 排查思路 |
|---|---|---|
| OJ报格式错误 | 行末多了一个空格,或者忘了换行 | 改用“前置空格法”输出,printf("\n")结尾 |
| 段错误程序崩溃 | scanf漏写&,或者数组越界访问 | 检查scanf(&a[i]),检查循环条件是否i<n |
| 输出结果和输入一样 | 交换循环边界写成i<=n/2 | 改成i<n/2,手动模拟一遍n=4 |
| 输出末尾多了一个随机数 | 读入循环多读一次,越界 | 检查读入循环是否i<n |
| 数组前半段全是同一个数 | 交换时没用临时变量,覆盖了原值 | 用三步交换模板重写 |
| 逆序后半段乱码 | 如果是字符串,把'\0'也交换了 | 字符串长度用strlen,只交换0到len-1 |
6. 这道基础题在工程和算法里的位置
6.1 字符串反转、回文判断、链表反转的基础
很多人觉得逆序存放这种题太基础、太“实验”,离真实开发很远。实际上,字符串反转、回文判断、链表反转、大数加减中从低位到高位的处理,都离不开“逆序”的思路。比如判断一个字符串是不是回文,最直接的方法就是把它反转后和原串比较,虽然效率不是最优,但逻辑很简单。再比如反转链表,对照数组逆序来看,数组的逆序是交换位置,链表的逆序是调整指针指向,思路一脉相承:从两端向中间,或者从头到尾改变“下一个”的指向。
我记得之前在公司review代码时,就见过一个同事处理数据流的时候,需要把一批日志行按时间倒序输出。他没有去改数据库查询语句,而是直接把内存里的日志数组反转了一下,然后顺序输出。这就是逆序存放在真实场景中的应用。代码和实验题几乎一样,只不过数组元素从整数变成了结构体。所以别小看这20分,它训练的是你处理“连续数据”时最基本的操作能力。
6.2 函数传参:为什么reverse函数能改原数组
这道题还有一个很有价值的延伸,就是自己写一个reverse函数。多数教材会要求你实现一个函数来逆序数组,然后再在主函数里调用。
void reverse(int arr[], int n) { for (int i = 0; i < n / 2; i++) { int temp = arr[i]; arr[i] = arr[n - 1 - i]; arr[n - 1 - i] = temp; } }这里就涉及前面提到的那个重要概念:数组作为函数参数时,传递的是首地址,函数内部对数组元素的修改会直接影响原数组。这也是很多人误以为“函数参数是副本,改副本不会影响原值”的例外情况。普通int参数、float参数都是按值传递的,数组却是按“地址”传递的。更准确地说,void reverse(int arr[], int n) 在编译器眼里等同于 void reverse(int *arr, int n),arr的本质是一个指针。
搞懂这一点之后,你就能明白为什么有些参数带&、有些参数不带,为什么结构体当参数可能复制一大块内存,而数组却能“传进去就改”。这是C语言里一道经典的面试题,也是很多刚入门的人绕不过去的坎。
6.3 动手练一练:手写三版逆序
我最后给大家留一个建议,虽然听起来有点像作业,但真的管用。这个题,不要只写一遍就丢,试着用三种方式各写一遍。
第一遍,基础下标法,就是上面讲到的 a[i] 和 a[n-1-i] 交换。第二遍,指针法,用 left 和 right 两个指针对撞。第三遍,递归法,递归本质上也是一种双指针:
void reverse_recursive(int arr[], int left, int right) { if (left >= right) { return; } int temp = arr[left]; arr[left] = arr[right]; arr[right] = temp; reverse_recursive(arr, left + 1, right - 1); }递归版本的关键在于终止条件 left >= right,只要还没相遇,就交换两个端点的值,然后递归处理中间的子数组。理解这个版本后,你对“函数调用栈”“数组下标是相对偏移”这些概念都会有一个更直观的感受。
我在实际讲课时发现,能把这三版都写对的人,后面学链表反转基本不费劲,因为思维方式已经打通了:不管数据是存在连续内存还是分散的节点里,逆序的操作本质都是“改变元素之间相对顺序”,数组用交换,链表用指针重连,思路同源。
说到底,实验7-1-3这道题只是冰山一角,它的价值不在于那20分,而在于它把“数组”这个最基础的数据结构最重要的一面展现给你:连续存储、下标访问、元素交换、边界控制。把这些打磨扎实,后面无论学算法、刷LeetCode,还是做实际项目,你都会感谢现在认真对待这道题的自己。