【C语言-一维数组】啃透C语言一维数组,从入门到不再怵它

开发技术 作者: 精灵星空 SKY315.COM 更新于: 浏览: 273
码字发文不易,转载请注明出处【精灵星空 SKY315.COM】

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,是整个数组的大小。这两个要分清楚。

五、最后聊两句

一维数组的核心就是“连续”和“下标”这两个词。连续决定了它在内存里的布局和访问效率,下标决定了你如何定位每个元素。

练到什么程度算过关? 给你一个需求——输入一组数,你手到擒来地写出遍历、求最值、逆序、查找、插入、删除、排序这些操作,脑子里能清晰地想象出数组在内存里怎么动的,每个下标是怎么变化的——这就算彻底掌握了。

还是那句话,光看不行,打开编译器敲一遍。每个例子自己写、改、跑、断点调试,把数组里的值变化过程看清楚。这一关过了,后面指针和二维数组的时候你会轻松很多。