连载走到第10篇,前面的变量、判断、循环都属于“单打独斗”——变量管一个数,循环管反复操作,可一旦遇到“一批”数据,这两个工具立刻就不够用了。班里60个人的成绩、一个月30天的气温、购物车里几十件商品的价格,你总不能声明60个变量,再写60遍判断找最高分。这个时候,一维数组就是 C++ 里最常用来装“同类型数据集合”的容器:一个名字,一串连续的格子,配合循环就能成批量地存、取、算。这篇适合刚学完循环、正准备写批量数据处理逻辑的零基础读者,我会从数组的长相讲到它最容易踩的坑,看完跟着敲一遍,批处理这一关基本就过了。
1. 一维数组到底是什么:先给内存画一幅地图
1.1 为什么需要数组:用“小卖部流水账”当引子
想象你管着一家小卖部,想记录今天30种商品的销售额。最原始的办法是声明30个变量:
int item0; int item1; int item2; // ... 一直写到 item29手写30行声明已经够累了,更崩溃的是后面:你想找出“今天卖得最好的是哪个商品”,就只能把这些变量挨个比较。比较30个变量,你得嵌套多少层 if?代码会膨胀到比商品清单还长,还特别容易抄错变量名。
数组解决问题的思路非常直接:把30个同类型的数打包进同一个名字,用编号去定位其中的某一个。声明写int sales[30];,一行就能描述整批数据。更重要的是,数组的下标可以用变量表示,配合循环可以把30个元素全部访问一遍。数据的批量输入、批量输出、求和、找极值、排序,全都从这个“数组名+下标”的组合上长出来。
所以你不妨把数组理解成“为了让循环派上用场而存在的数据结构”。没有数组,循环只能反复处理一个变量;有了数组,循环就变成了流水线上的机械臂,挨个处理同一条传送带上的零件。
1.2 内存里的“格子排排坐”:连续存储为何查询快
C++ 的数组在内存里是一段连续的存储区域。声明int scores[10];,如果当前环境下 int 占4字节,编译器就会一次性分配40字节的连续内存。这40字节像宿舍楼同一层10个连着的小房间,房间号从0编到9,数组名就是这栋楼的名字。
访问scores[3]的时候,编译器做的事情其实很朴素:拿数组起始地址,加上3 * sizeof(int),就直接跳到了第4个房间的门口。整个过程不需要像查档案一样从头翻,两次算术运算就定位结束。这就是常说的“随机访问”——访问第7个元素和访问第2个元素,时间几乎一样,因为位置是算出来的,不是找出来的。
“连续”这个词值得多记几遍,因为后续学链表的时候,对比会非常明显。数组查询快,依赖的是连续存储;但它的弱点也埋在这里:想在数组中间插入或删除一个元素,后面的所有元素都得往前或往后挪位置,代价就比较大了。零基础阶段,先把“数组是连续内存、按下标定位”这一层想明白,后面理解指针、动态分配都会顺很多。
2. 声明、初始化与读写:三个动作带你快速上手
2.1 三种声明方式与“不初始化=垃圾值”的陷阱
声明一维数组的标准格式是:类型 数组名[元素个数];。元素个数必须是编译期能确定的值,通常是字面量或者 const 常量。常见的几种:
int scores[60]; // 60个int,适合存全班成绩 double prices[30]; // 30个double,适合存价格 char name[20]; // 20个char,后面讲字符串会用到这一节最大的坑,是声明了却不初始化。int scores[60];写完之后,数组里存的是什么?答案是不确定值,也就是所谓“垃圾值”——这块内存之前可能被别人用过,残留数据谁也说不准。你直接把它输出,会看到一串莫名其妙的大数甚至负数。
很多新手第一次输出遇到这种“乱码”,第一反应是代码写错了,其实问题出在没给数组一个确定的初始状态。我的建议是:养成声明时就初始化的习惯。哪怕你想的是“待会循环里逐个输入”,声明时先给个= {0};也不亏,至少不会带着垃圾数据到处跑。
2.2 初始化的几种写法:自动补0的细节别小看
一维数组的初始化主要就这几种:
int a[5] = {1, 2, 3, 4, 5}; // 全部元素显式给出 int b[10] = {1, 2}; // 前两个是1、2,其余自动补0 int c[] = {5, 6, 7}; // 让编译器数一数,长度就是3 int d[5] = {}; // 5个元素全是0 int e[5] = {0}; // 第一个是0,剩下四个也补0初学最容易忽略的是第二种和最后一种。int b[10] = {1, 2};你以为 b[2] 是垃圾值,其实不是,规则是:初始化列表给出的值少于数组大小时,剩余元素会做“值初始化”,对 int 来说就是 0。
这个规则非常实用。想要快速把整个数组清零,不需要写循环,一句int arr[100] = {0};就完成了。反过来也要注意:如果你声明后没有任何初始化列表,比如只写int arr[100];,那里面仍然可能是垃圾值。有木有{0}这一行,结果天差地别。
2.3 下标从0开始:第1个误区与两种读写法
int scores[60];的合法下标是0到59,不存在scores[60]。下标从0开始这件事,坑过无数新手,这里提供一个好记的解释:下标表示“离起点隔了几个格子”。第一个格子离起点0格,所以是0号;第五个格子离起点4格,所以是4号。你只要记住“有5个数,最后一个下标是4”,写循环的时候就不容易越界了。
在读写方面,arr[i]和普通变量的地位一样。它可以放在赋值号左边当“储物格”写入,也可以放在右边把值取出来用:
arr[0] = 88; cout << arr[0] << endl;但真正发挥威力的是配合循环:
for (int i = 0; i < n; ++i) { cin >> arr[i]; }这段代码从键盘读 n 个数依次存进数组。输入的时候数字之间用空格或回车分隔都行,cin 会自动跳过空白。如果你从1开始计数,比如想处理“第k个”元素,下标就要写成 k-1。很多同学喜欢“第1个数放 arr[1]”这种写法,虽然能避开从0开始的别扭,但会浪费 arr[0] 这个格子,而且后面学指针、容器时容易乱。统一从0开始,是C++的世界规则。
3. 遍历、求和、找最大值与冒泡排序:先跑通这批经典操作
3.1 遍历输出与范围for:懒人写法也要知道限制
遍历就是让下标从0走到 n-1,把每个格子都过一遍。最经典的是输出整个数组:
for (int i = 0; i < n; ++i) { cout << arr[i] << (i == n - 1 ? '\n' : ' '); }这里用了个小技巧:最后一个数后面输出换行,前面都用空格分隔,输出格式干净,链接时也不会有多余空格。
C++11 之后还有一种更省心的写法,基于范围的 for 循环:
for (int x : arr) { cout << x << ' '; }你不用管下标,循环会自动把 arr 里的每个元素依次赋给 x。但要注意,这个 x 是拷贝出来的副本,你在循环里修改 x,不会改动数组本身。如果想通过 x 修改数组里的元素,必须写成引用:
for (int &x : arr) { x *= 2; // 这样才会真的放大数组里的每个值 }另外,范围for拿不到当前元素的下标。如果输出时需要显示“第几个”,或者有根据下标修改元素的需求,老老实实回到传统 for 更顺手。
3.2 累加求平均与打擂台求极值:循环里的老搭档
求和是最常见的批量统计。一个循环搞定:
int sum = 0; for (int i = 0; i < n; ++i) { sum += arr[i]; }求平均就是总成绩除以人数。这里提醒一个类型问题:如果数据量很大,比如几百万个 int 累加,用 int 装 sum 可能会溢出,数字突然变成负数。稳妥的做法是用long long sum = 0;。平时练习题里的小数据用 int 没问题,但要有这个意识。
求最大值用的是“打擂台”思路:
int maxVal = arr[0]; // 先把第一个数当成临时的擂主 for (int i = 1; i < n; ++i) { if (arr[i] > maxVal) { maxVal = arr[i]; } }先用 arr[0] 做初值,再从 i=1 开始比较,这样避免了给 maxVal 随便设一个可能比所有数都小的初值。循环结束后,maxVal 就是数组里的最大值。求最小值把>换成<就行。
把输入、求和、求最大值合在一起,就是一个很典型的完整小题:
const int MAXN = 10005; int arr[MAXN]; int n; cin >> n; for (int i = 0; i < n; ++i) { cin >> arr[i]; } int sum = 0; int maxVal = arr[0]; for (int i = 0; i < n; ++i) { sum += arr[i]; if (arr[i] > maxVal) { maxVal = arr[i]; } } cout << "平均分:" << 1.0 * sum / n << endl; cout << "最高分:" << maxVal << endl;用1.0 * sum / n是为了把整数除法转成浮点除法,否则平均分会直接丢掉小数部分,88和89分就看不出来了。
3.3 冒泡排序:第一次接触“交换两数”
排序是数组应用的一个大头。冒泡排序最适合入门,因为它足够直观:一趟一趟扫描,把相邻的乱序元素交换过来,最大的数就像气泡一样慢慢“浮”到末尾。
for (int i = 0; i < n - 1; ++i) { for (int j = 0; j < n - 1 - i; ++j) { if (arr[j] > arr[j + 1]) { int temp = arr[j]; arr[j] = arr[j + 1]; arr[j + 1] = temp; } } }外层循环控制“总共走几趟”,最多 n-1 趟一定能排好。内层循环负责从头开始比较相邻两个数,但每一趟结束后,末尾都会多一个已经就位的最大值,所以比较范围可以不断缩小,写成n - 1 - i。
这里有一个新手几乎必犯的错误,就是交换两个数时忘了用临时变量。你想当然地写:
arr[j] = arr[j + 1]; arr[j + 1] = arr[j];结果两个位置都变成了原来 arr[j+1] 的值,数据就丢了。因为第一次赋值已经覆盖了 arr[j],再用 arr[j] 去覆盖 arr[j+1],拿到的就是改过的值。临时变量 temp 相当于一个“保险柜”,先把要覆盖的值存起来,再放心大胆地改。
冒泡排序的时间复杂度是 O(n²),数据量几百上千没问题,到了几万就会明显变慢。零基础阶段先把过程跑通,以后学到快排、归并或者标准库的 sort 时,你会回来感谢今天建立的感觉。
4. 数组传参给函数:这里有一道绕不开的坎
4.1 sizeof 陷阱:为什么函数里“数组变小了”
很多新手会写出这样一个函数:
void showArray(int arr[]) { for (int i = 0; i < ???; ++i) { cout << arr[i] << ' '; } }问题来了:循环结束条件里的长度写什么?你没法在函数体内知道数组有多大,因为 C++ 里数组作为参数传到函数时,长度信息不会跟着传过去。更准确地说,形参int arr[]在函数内部会被当作int*指针处理,它只拿到了数组首元素的地址。
如果你在函数里写cout << sizeof(arr),得到的往往是指针的大小,比如64位系统上是8字节,而不是整个数组的字节数。这就是经典的“sizeof 陷阱”。
解决办法非常直接:把长度作为另一个参数传进去。
void showArray(int arr[], int n) { for (int i = 0; i < n; ++i) { cout << arr[i] << ' '; } cout << endl; } // 调用 showArray(scores, 60);以后只要看到数组参数,第一反应就是“还要带上长度”。这是C风格数组的标准用法,也是后续学容器之前必须接受的规则。
4.2 传地址意味着“改得动”:const 保护只读参数
数组传参传的是首地址,这一点和普通变量传值完全不同。普通 int 传进函数只是拷贝一份,你在函数里怎么改都不影响外面;数组则不一样,函数里写arr[i] = something;会直接改变主函数里的数组。
很多新手第一次写“数组排序函数”,惊讶地发现主函数里的数组也变了,怀疑是不是哪里传错了。这其实不是 bug,而是数组传参的特性:函数拿到的是原数组的入口,改的就是原数组。这在批量处理中反而是优势,因为你不需要把数组复制来复制去,直接操作原始数据。
但如果你的函数只想读数组、不想改数组,就最好加上 const 保护:
void printArray(const int arr[], int n) { for (int i = 0; i < n; ++i) { cout << arr[i] << ' '; } }const 相当于给参数加了一道锁:函数内部只能读,一旦写了arr[0] = 0;,编译器立刻报错。这个限制看着小,实际很管用,能帮你挡住不少“函数里手滑改了数据”的意外。
理解“改得动”和“只读”这两个方向,传数组这件事就算看明白了。
4.3 为什么不能直接返回一个数组:绕开新手雷区
有些同学想写int[] makeArray(),这在C++里行不通,函数不能直接返回数组类型。新手阶段,推荐的做法是让函数修改调用者传入的数组,数据自然就“带回来”了:
void fillArray(int arr[], int n) { for (int i = 0; i < n; ++i) { arr[i] = i * i; } }调用方在 main 里准备好数组:
int values[10]; fillArray(values, 10); for (int i = 0; i < 10; ++i) { cout << values[i] << ' '; }数值通过函数写进了 values 里面,主函数再访问时就已经是填好的结果了。至于“在函数内部创建新数组并返回”的写法,涉及内存生命周期的问题,容易踩悬空指针的坑,零基础阶段先别碰。后面用到标准库的 std::vector,这个问题会迎刃而解,现在先攒着这个疑问就好。
5. 别让程序“悄悄坏掉”:越界、垃圾值与调试技巧
5.1 越界访问为何很难发现:认识未定义行为
int arr[5]; arr[5] = 100;这句话在语法上完全合法,编译器也大多不报错,但它写入的位置在数组之外。C++ 把这种行为定义为“未定义行为”,也就是说,标准不保证程序接下来会发生什么。
有时候它碰巧没出问题,因为那段内存恰好没被占用;更多时候,它会覆盖相邻变量的地盘,把别人的值改得面目全非,然后程序在一个完全不同的地方暴雷;还有可能到即将结束时才崩溃,让你根本想不到问题出在这一句。最坑的地方是:它不一定立刻报错,所以排查特别难。
我的建议是:每写一个涉及下标的循环,先在心里把边界过一遍。访问最后一个元素的时候,下标是 n-1,循环条件一般是i < n,千万不要顺手写成i <= n。比赛和练习里大量死活找不到的 bug,最后揪出来都是越界访问,真的不夸张。
5.2 数组大小到底能不能是变量:标准与扩展的拉扯
不少初学者看别人写过:
int n; cin >> n; int arr[n];这段代码在某些编译器上确实能跑通,于是很多人以为数组大小可以用变量。严格来说,这是变长数组(VLA)扩展,不是标准C++保证的行为。同一段代码换一台机器、换一个编译器版本,很可能直接编译失败,或者运行结果变得诡异。
零基础阶段建议直接绕开这个坑。想要稳定的做法,要么声明一个足够大的固定数组,只使用前面 n 个元素:
const int MAXN = 10005; int arr[MAXN]; // 最多能装10005个int cin >> n;要么继续往前走,学 std::vector,用它来应对动态长度需求。与其赌“我这台机器能过”,不如一开始就写标准、可移植的代码。养成这个习惯,以后换环境才不会摔跟头。
5.3 所有元素输出“不对”:初始化与越界的连锁反应
分享一个很常见的现场。有同学写:
int arr[10]; for (int i = 1; i <= 10; ++i) { arr[i] = i * i; } for (int i = 0; i < 10; ++i) { cout << arr[i] << ' '; }表面上看循环是从1到10,似乎只是“少了一个数”,实际却是一连串错位:i=1 时给 arr[1] 赋值,arr[0] 始终是没初始化的垃圾值;i=10 时又越界写到了 arr[10],那一段内存根本不是数组的地盘。输出时从0开始打印,第一个数就是垃圾,数组末尾又缺了数据,整个结果怎么看怎么不对。
遇到所有元素都不对的情况,第一反应就是检查循环的下标起点和终点。想要数组从0号开始放数,循环就从0开始;结束条件就写i < 10,不要为了“凑10次”写成i <= 10。这种错位往往不影响编译,跑起来也不报错,但结果就是不对,排查时盯着下标看就对了。
5.4 常见问题速查表:现象、原因、处理办法
| 现象 | 可能原因 | 处理办法 |
|---|---|---|
| 输出一串莫名其妙的巨大数字 | 声明后没初始化,数组里是垃圾值 | 声明时写int arr[10] = {0};,或先循环逐个赋值 |
| 程序运行中其他变量被乱改,或崩溃 | 下标越界,写到了数组外 | 检查所有 for 边界,最大下标必须是 n-1 |
| 函数里 sizeof(arr) 结果是8 | 数组参数退化成指针 | 不要在函数里用 sizeof,把长度当作参数传进去 |
| 函数里改了数组,外面跟着变 | 正常现象,传的是首地址 | 如果不想让函数修改,参数加 const |
| 用变量当数组大小编译不过 | 变长数组不是标准C++ | 用 const 常量定义大小,或改学 std::vector |
| 平均分总是整数,小数丢了 | int 除以 int 结果是整数 | 先乘 1.0 再除,比如1.0 * sum / n |
调试一维数组还有一个土办法很有效:不要盯着整个大数组看,先用一个长度为5的小数组复现问题,多打印几次中间结果,比如在循环里输出当前下标和对应值。如果怀疑越界,就把循环变量 i 打印出来。对比“输入阶段对不对”“运算阶段对不对”两个环节,问题能快速缩小到具体某一行。这种分段定位的思路,比对着屏幕空想要高效得多。
最后说点我自己的体会。教过一位零基础的同学,数组这一节他卡了三天,问题全出在下标上:要么循环从1开始导致第一个元素被跳过,要么结束条件多算一次导致越界。后来他按一个笨办法练:每次写数组循环前,先在纸上列出 n=3 时 i 的全部取值,再把 i 代入每个下标算一遍。这个动作看起来慢,却让他的准确率一下子上来了。数组这块真的没有捷径,把遍历、求和、找最值、冒泡排序这四个动作亲手敲到闭着眼睛都能写,后面的二维数组和字符串就会顺得多。