C语言在线编程环境:https://sky315.com:8080/c/。
很多人学到数组的时候有个错觉:不就是一组变量嘛,拿下标存取,有啥好学的。结果一写代码就露馅——数组越界了不知道,数组名到底啥意思说不清,传参给函数的时候一脸懵。归根结底是没理解数组在内存里到底长啥样。
一、数组是什么?就是内存里一溜排开的格子
先想象一下仓库里的储物柜,编号从0开始,一个挨一个,每个格子大小一样,里面存的东西类型一样。C语言里的数组就是这么个玩意儿。
定义格式:类型 数组名[元素个数];
比如int scores[50];,意思是在内存里连续开辟50个int大小的空间,每块都能存一个整数,用scores[0]到scores[49]来访问它们。
这里有几个点你得刻在脑子里:
-
下标从0开始,不是从1开始。所以长度为n的数组,合法下标是0到n-1。你要访问scores[50],程序编译不会报错,运行的时候可能直接崩溃,这叫“数组越界”,属于未定义行为,后果自负。
-
数组大小必须是常量,不能是变量。
int n; scanf("%d",&n); int arr[n];在C99之前是不合法的,C99之后叫变长数组,有些编译器支持,但标准C里你最好别这么干,老老实实用常量或者宏定义。#define SIZE 100,然后int arr[SIZE];。 -
数组在内存里是连续存放的。这一点特别重要。scores[0]和scores[1]在内存里紧挨着,中间没有空隙。这就意味着只要知道数组的起始地址,就能顺着找到每一个元素。
初始化的几种方式:
int a[5] = {1, 2, 3, 4, 5}; // 完全初始化 int b[5] = {1, 2, 3}; // 只给了前三个,后面自动补0 int c[5] = {0}; // 全部初始化为0 int d[] = {1, 2, 3, 4, 5}; // 不写大小,编译器自动数个数,长度就是5
不初始化的话,局部数组里的值是垃圾数据,啥都有可能。全局数组或者static修饰的数组默认初始化为0。
二、数组的基本操作,就是挨个跟它打交道
数组最常用的操作就是遍历——从头到尾走一遍,对每个元素做点什么事。
遍历的标准写法:
for (int i = 0; i < 长度; i++) { // 对 a[i] 做点什么 }
注意循环条件是i < 长度,不是i <= 长度。写错一个等号,你就越界了。
输入数组:
for (int i = 0; i < n; i++) { scanf("%d", &a[i]); }
输出数组:
for (int i = 0; i < n; i++) { printf("%d ", a[i]); }
就这么简单。但你别小看这个遍历,数组上绝大多数算法都建立在遍历的基础上。
三、典型例题挨个讲,每个都是经典
下面这几道题你挨个啃下来,数组的基本功就扎实了。
例1:求数组的最大值和最小值
这题是数组入门第一关。思路特别朴素:先假设第一个元素是最大(或最小),然后从第二个开始挨个比,发现更大的就更新。
int a[10] = {3, 7, 1, 9, 2, 8, 5, 4, 6, 0}; int max = a[0]; int min = a[0]; for (int i = 1; i < 10; i++) { if (a[i] > max) max = a[i]; if (a[i] < min) min = a[i]; } printf("最大值=%d,最小值=%d\n", max, min);
注意两点:一是从i=1开始,因为0号已经拿来当初始值了,没必要再跟自己比一次。二是如果数组可能为空,那得单独处理,不过实际开发中你很少会处理空数组这种情况。
例2:数组逆序
把数组里的元素倒过来,第一个变最后一个,最后一个变第一个。
思路:用两个下标,一个从前往后走,一个从后往前走,交换它们指向的元素,直到两个下标碰头。
int a[7] = {1, 2, 3, 4, 5, 6, 7}; int left = 0, right = 6; while (left < right) { int temp = a[left]; a[left] = a[right]; a[right] = temp; left++; right--; } // 输出看看:7 6 5 4 3 2 1
循环条件是left < right,如果数组长度是奇数,正中间那个元素不用动。如果是偶数,最后left和right会交错,条件不成立退出。
例3:查找某个元素是否存在
这题就是线性查找,最朴素的查找方式。从头到尾找一遍,找到了就记下位置,找不到就说明不存在。
int a[8] = {4, 2, 7, 1, 9, 3, 6, 5}; int target = 7; int index = -1; // -1表示没找到 for (int i = 0; i < 8; i++) { if (a[i] == target) { index = i; break; // 找到了就赶紧停,不用再找了 } } if (index != -1) { printf("找到了,位置是%d\n", index); } else { printf("没找到\n"); }
这个break用得好,一旦找到目标,后面的元素就不用再看了,省时间。如果数组里可能有重复元素,你又想找第一个出现的,那break正好合适。如果你想找最后一个,那就别break,每次找到都更新位置,遍历完自然就是最后一个。
例4:统计数组中每个元素出现的次数
这题说的是“统计频率”,适合数据范围不大的情况。假如数组里都是0到9之间的整数,你想知道每个数出现了几次。
int a[20] = {3, 5, 2, 3, 7, 3, 5, 1, 8, 9, 4, 0, 5, 6, 7, 3, 2, 1, 9, 4}; int count[10] = {0}; // 下标0-9,全部初始化为0 for (int i = 0; i < 20; i++) { count[a[i]]++; // 以a[i]的值作为count的下标 } for (int i = 0; i < 10; i++) { if (count[i] != 0) { printf("%d出现了%d次\n", i, count[i]); } }
这个技巧叫“计数数组”或者“桶”,用另一个数组的下标来表示值,用数组元素的值来表示次数。但是注意了,这种方法的限制是数据范围不能太大。如果数字是0到100000,你搞一个十万长度的数组,也还行,但如果是浮点数或者字符串,这招就不好使了。
例5:数组插入一个元素
在一个有序数组里插入一个新元素,保持有序。比如数组是{1, 3, 5, 7, 9},要插入6,结果应该是{1, 3, 5, 6, 7, 9}。
思路:先找到该插在哪儿,然后把从那个位置开始的所有元素往后挪一位,把新元素放进去。
int a[10] = {1, 3, 5, 7, 9}; int n = 5; // 当前有5个元素 int insert = 6; int pos = 0; // 找插入位置 while (pos < n && a[pos] < insert) { pos++; } // 从后往前挪,给新元素腾地方 for (int i = n; i > pos; i--) { a[i] = a[i - 1]; } a[pos] = insert; n++; // 元素个数加1 // 输出:1 3 5 6 7 9
注意挪动的方向——必须从后往前挪。如果你从前往后挪,前面的值会把后面的值覆盖掉,数据就全乱了。画个图就明白了:要把pos后面的都往后移一位,最安全的方式是从最后一个开始,逐个往后复制。
例6:删除数组中某个位置的元素
插入反过来,删除就是把它后面的元素往前挪,把要删的位置覆盖掉。
int a[10] = {1, 2, 3, 4, 5, 6}; int n = 6; int pos = 2; // 删掉下标2,也就是3 for (int i = pos; i < n - 1; i++) { a[i] = a[i + 1]; } n--; // 元素个数减1 // 输出:1 2 4 5 6
这里挪动的方向是从前往后,把后面的元素往前复制。你要是从后往前挪,那就把前面的值覆盖到后面去了,结果一团糟。
插入和删除是数组最基础的两个操作,记住:插入从后往前挪,删除从前往后挪,方向反了一切都白搭。
例7:冒泡排序——数组算法里的Hello World
排序是数组的终极考验之一,冒泡排序又是其中最直观的一种。思路:相邻两个数比较,大的往后冒,一轮下来最大的数就跑到最后了,然后对前面的n-1个数再来一轮。
int a[8] = {5, 2, 8, 1, 9, 3, 7, 4}; int n = 8; for (int i = 0; i < n - 1; i++) { // 一共要排n-1轮 for (int j = 0; j < n - 1 - i; j++) { // 每轮比较次数递减 if (a[j] > a[j + 1]) { int temp = a[j]; a[j] = a[j + 1]; a[j + 1] = temp; } } } // 输出:1 2 3 4 5 7 8 9
外层循环控制轮数,n个数最多需要n-1轮。内层循环控制每轮比较到哪里,n-1-i是因为每一轮结束后,最后i个数已经排好了,不用再比了。
当然冒泡排序时间复杂度是O(n²),数据量大的时候效率低,但作为学习数组操作的训练题,它非常合适——既有循环嵌套,又有相邻元素访问,还有交换操作,练的就是你对数组下标的掌控。
例8:斐波那契数列存到数组里
生成前n个斐波那契数,存到数组里。规律是前两个是1和1,后面每个是前两个之和。
int fib[20]; int n = 10; fib[0] = 1; fib[1] = 1; for (int i = 2; i < n; i++) { fib[i] = fib[i - 1] + fib[i - 2]; } // 输出:1 1 2 3 5 8 13 21 34 55
这题训练的是通过下标访问前驱元素的能力,很多动态规划的题目就是从这个套路延伸出去的。你只要把递推公式写出来,转换成数组下标的操作就行。
四、几个必踩的坑,提前知道省得折腾
坑1:数组越界不报错
这是C语言数组最阴险的地方。你写int a[5]; a[5] = 10;,编译通过,运行也不一定崩,但实际上你已经篡改了内存里不该改的地方。这种Bug极难追踪,因为问题不在出错的这一行,而在后续某个莫名其妙的地方。养成习惯,每次访问数组都检查下标是否在合法范围内。
坑2:数组名不是变量
int a[5]; int b[5]; a = b; 这样写是错的。数组名是地址常量,不能赋值。你只能对数组的元素逐个赋值,或者用memcpy之类的函数批量拷贝。
坑3:数组作为函数参数时,长度信息会丢失
你在函数里写void func(int arr[]),编译器看到的是int *arr,是一个指针,它不知道这个数组有多长。你必须在函数外面把长度也传进来:void func(int arr[], int n)。这个问题很多人学到函数的时候就踩了,提前记住。
坑4:字符串和字符数组的区别
其实字符串就是字符数组,只不过以\0结尾。所以char str[10] = "hello";实际占用了6个字节(h-e-l-l-o加上结尾的\0)。如果你用strlen计算长度,得到的是5,不包括\0。而sizeof(str)是10,是整个数组的大小。这两个要分清楚。
五、最后聊两句
一维数组的核心就是“连续”和“下标”这两个词。连续决定了它在内存里的布局和访问效率,下标决定了你如何定位每个元素。
练到什么程度算过关? 给你一个需求——输入一组数,你手到擒来地写出遍历、求最值、逆序、查找、插入、删除、排序这些操作,脑子里能清晰地想象出数组在内存里怎么动的,每个下标是怎么变化的——这就算彻底掌握了。
还是那句话,光看不行,打开编译器敲一遍。每个例子自己写、改、跑、断点调试,把数组里的值变化过程看清楚。这一关过了,后面指针和二维数组的时候你会轻松很多。