前面几篇文章,我们操作的都是单个数据——一个整数、一个字符、一个浮点数。但在实际编程中,你很少只处理单个数据。一个班级有 30 个学生,每人有数学成绩;一篇英文文章有几千个字符;一张灰度图片有上百万个像素。这些数据怎么管理?难道给每个数据都单独定义一个变量?当然不是——这时候你就需要数组。
数组的概念——把同类数据打包管理
数组是 一组相同类型元素的集合。从这个定义中就能读出两条关键信息:第一,数组中存放的是 1 个或多个数据(C 语言不允许元素个数为 0 的数组);第二,数组中所有元素的类型必须相同。你不能在一个数组里既存 int 又存 char,就像你不能把苹果和橘子混在同一个鸡蛋盒里。
你可以把数组想象成一排连续的储物柜——每个柜子大小一样(因为类型相同),按顺序编号(这就是下标),你往任意编号的柜子里放东西或取东西。数组分为一维数组和多维数组,多维数组中最常见的是二维数组——可以理解为一个表格,或者说"数组的数组"。
这里有一个非常重要的概念需要你从现在就开始适应:数组的下标从 0 开始。这是 C 语言的规定,也是 C++、Java、Python 等绝大多数编程语言的惯例。一个包含 10 个元素的数组,有效下标是 0 到 9——没有下标 10。访问 arr[10] 会越界,导致不可预期的行为。这个"从 0 开始"的思维习惯,对于之前没用过编程语言的人来说需要一点时间来适应,但你很快就会习惯。
为什么从 0 开始而不是从 1?这要从数组的底层原理说起:arr[i] 的地址 = 数组首地址 + i × 元素大小。如果下标从 0 开始,第一个元素就是 首地址 + 0,编译器不需要做任何减法;如果从 1 开始,每次都要 首地址 + (i-1) × 大小,多一次减法运算。对汇编时代的老前辈们来说,这个减法太奢侈了——"从 0 开始"是性能优化沉淀下来的语言惯例。
数组的天然"局限"
开始之前先打个预防针:C 语言的数组大小在创建时就固定了,不能动态增加或删除元素(那是链表的强项)。而且 C 数组不检查越界——访问 arr[10](数组只有 10 个元素)编译器不会报错,程序也不会主动拦你,后果完全不可预测。这是 C 语言"信任程序员"哲学的又一体现,也是数组相关 bug 的重要来源。我们会在这一篇里反复强调这一点。
一维数组的创建和初始化
创建一维数组的基本语法是这样的:
type arr_name[常量值];type 指定数组中每个元素的类型——可以是 int、char、double,也可以是你自己定义的类型。arr_name 是数组名,根据实际需求起得有意义的就行。[] 中的常量值用来指定数组能容纳几个元素。
比如你要存储一个班级 20 人的数学成绩:
int math[20]; /* 可以存放 20 个整数 */其他类型和大小的数组也一样:
char ch[8]; /* 可以存放 8 个字符 */
double score[10]; /* 可以存放 10 个双精度浮点数 */注意:在 C89 标准(以及很多老式编译器中),[] 里必须是常量或常量表达式,不能是变量。C99 后来引入了变长数组才允许用变量,但这个特性 VS2022 并不支持——后面会详细讲。
创建数组时,我们常常需要给定一些初始值,这就是初始化。数组的初始化用大括号,把数据放在大括号里:
/* 完全初始化:每个元素都给值 */
int arr[5] = {1, 2, 3, 4, 5};
/* 不完全初始化:只给第一个元素,其余自动为 0 */
int arr2[6] = {1}; /* arr2 = {1, 0, 0, 0, 0, 0} */
/* 全部初始化为 0 的常用写法 */
int arr3[10] = {0}; /* 所有元素都是 0 */
/* 初始化时可以不指定大小,编译器自动算 */
int arr4[] = {1, 2, 3}; /* 自动确定大小为 3 */有一种错误需要特别注意:初始化项的数量不能超过数组的大小,否则编译器会直接报错:
int arr[3] = {1, 2, 3, 4}; /* 错误:初始化项太多! */顺便说一句,数组也是有类型的——去掉数组名剩下的就是数组的类型:
int arr1[10]; /* arr1 的类型是 int [10] */
int arr2[12]; /* arr2 的类型是 int [12] */
char ch[5]; /* ch 的类型是 char [5] */注意 int [10] 和 int [12] 是不同的类型——数组的大小是类型的一部分。这意味着同样是 int 数组,大小不同就是不同的类型,这在你后续学到函数参数时会有影响。
数组初始化规则速查表
| 写法 | 效果 |
|---|---|
int a[5] = {1,2,3,4,5}; | 完全初始化,5 个元素都有值 |
int a[5] = {1,2}; | 不完全初始化,其余补 0:{1,2,0,0,0} |
int a[5] = {0}; | 最常用的全 0 初始化 |
int a[] = {1,2,3}; | 省略大小,编译器按初始化项个数定为 3 |
int a[5]; | 不初始化,元素是垃圾值(局部变量) |
int a[5] = {}; | C 标准不允许(C23 前);VS 下可能按全 0 处理,但不要依赖 |
一个容易忽略的点:"初始化"和"赋值"是两回事。数组可以整体初始化,但不能整体赋值——int a[3]; a = {1,2,3}; 是编译错误。数组只能"创建时初始化"或"逐个元素赋值"。
一维数组的使用——用下标访问每个元素
数组创建好了,怎么去访问里面的元素呢?C 语言提供了一个操作符 [],叫做下标引用操作符。数组下标从 0 开始,n 个元素的数组最后一个元素的下标是 n-1:
#include <stdio.h>
int main()
{
int arr[10] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
printf("%d\n", arr[7]); /* 访问下标 7,输出 8 */
printf("%d\n", arr[3]); /* 访问下标 3,输出 4 */
/* arr[0]=1, arr[1]=2, ..., arr[9]=10 */
return 0;
}访问单个元素很简单,但如果想打印整个数组呢?只要你能产生所有元素的下标,就能逐个访问——这里就需要用到 for 循环了。数组和循环是天生的搭档:
#include <stdio.h>
int main()
{
int arr[10] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
int i = 0;
for (i = 0; i < 10; i++) /* i 从 0 到 9 */
printf("%d ", arr[i]); /* 打印每个元素 */
printf("\n");
return 0;
}
/* 输出:1 2 3 4 5 6 7 8 9 10 */同样,你也可以让用户自己给数组输入数据,用 scanf 配合循环:
#include <stdio.h>
int main()
{
int arr[10] = {0}; /* 初始化为全 0 */
int i = 0;
printf("请输入 10 个整数:\n");
for (i = 0; i < 10; i++)
scanf("%d", &arr[i]); /* &arr[i] 取每个元素的地址 */
printf("你输入的是:");
for (i = 0; i < 10; i++)
printf("%d ", arr[i]);
printf("\n");
return 0;
}注意 scanf 需要传入变量的地址,所以这里是 &arr[i] 而不是 arr[i]。如果把 & 漏掉了,程序编译可能不报错,但运行时会出问题——arr[i] 是一个值,scanf 需要的是这个值的存储位置。这是初学者容易犯的低级错误。
数组越界——C 语言最危险的陷阱之一
前面说过,C 数组不检查越界。现在看看"越界"到底会出什么事:
#include <stdio.h>
int main()
{
int arr[10] = {0};
int i = 0;
/* 越界访问:arr[10] 已经超出数组范围(有效下标 0~9) */
arr[10] = 100; /* 编译器可能不报错,但运行行为是未定义的! */
/* 可能:1. 程序正常运行(碰巧那块内存可写)
* 2. 覆盖了相邻变量的值,导致莫名其妙的数据错乱
* 3. 段错误 / 程序崩溃
* 这次运行和下次运行的结果都可能不同! */
return 0;
}越界访问的可怕之处在于它的不确定性:它不保证报错,也不保证正常运行,行为完全取决于"越界之后撞上的那块内存是什么"。如果你覆盖了别的变量的内存,程序可能出现"明明没改那个变量,它的值却变了"的灵异事件——调试起来极其痛苦。
而用 for 循环遍历数组时,循环条件的边界错误是越界最常见的来源:
#include <stdio.h>
int main()
{
int arr[10] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
int i = 0;
/* 错误:i <= 10,当 i=10 时访问 arr[10],越界! */
for (i = 0; i <= 10; i++)
printf("%d ", arr[i]);
return 0;
}记住铁律:数组有 n 个元素,循环就写 i < n,不要写 i <= n。上一篇文章里强调过"i < n 表示循环 n 次",在这里它直接关系到程序的安全性。
一维数组在内存中的存储——连续,连续,还是连续
想要深入理解数组,就必须搞清楚它在内存里是怎么放的。我们可以打印每个元素的地址来看:
#include <stdio.h>
int main()
{
int arr[10] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
int i = 0;
for (i = 0; i < 10; i++)
printf("&arr[%d] = %p\n", i, &arr[i]);
/* %p 是专门打印地址的占位符 */
return 0;
}运行这段代码你会发现:随着下标增大,地址值也在增大,而且每两个相邻的元素之间相差 4 个字节——正好是一个 int 的大小。这个结论非常重要:数组的所有元素在内存中是连续存放的。
这意味着什么呢?意味着如果你知道第一个元素的地址和每个元素的大小,就能算出任意元素的地址。arr[0] 在内存块的最前面,arr[1] 紧挨在后面,依此类推。这也正是为什么指针可以用来遍历数组——知道起点的地址和步长(元素大小),就能一路走到底。这个知识在你学指针时会反复用到。
画个内存图帮助理解(假设数组首地址是 0x1000):
地址: 0x1000 0x1004 0x1008 0x100C ...
┌────────┬────────┬────────┬────────┐
│ arr[0] │ arr[1] │ arr[2] │ arr[3] │ ...
└────────┴────────┴────────┴────────┘
4 字节 4 字节 4 字节 4 字节
数组名就是首元素的地址(更准确地说,数组名在表达式中会"退化"成指向首元素的指针)——arr 的值等于 &arr[0]。这个关系是下一阶段指针的桥梁,现在先种下这颗种子。
sizeof 计算数组元素个数——别再硬编码大小了
遍历数组时,我们经常需要知道数组有多少个元素。你当然可以写出 for (i = 0; i < 10; i++),但如果以后数组大小变了(比如从 10 个变成 20 个),你得同步改循环条件,忘了改就会出 bug。更好的做法是用 sizeof 动态计算:
#include <stdio.h>
int main()
{
int arr[10] = {0};
/* sizeof(arr) 是整个数组占的字节数:10 × 4 = 40 */
printf("数组总大小:%zd 字节\n", sizeof(arr));
/* sizeof(arr[0]) 是一个元素占的字节数:4 */
printf("一个元素大小:%zd 字节\n", sizeof(arr[0]));
/* 元素个数 = 总大小 / 单个大小 */
int sz = sizeof(arr) / sizeof(arr[0]);
printf("元素个数:%d\n", sz); /* 10 */
return 0;
}sizeof(arr) 算的是整个数组所占的内存空间——10 个 int,每个 4 字节,总共 40 字节。sizeof(arr[0]) 算的是一个元素——第一个元素的——大小,4 字节。两者一除,就是元素个数。
有了这个技巧,遍历数组就不用写死大小了:
#include <stdio.h>
int main()
{
int arr[] = {3, 1, 4, 1, 5, 9, 2, 6}; /* 随便多少个元素 */
int sz = sizeof(arr) / sizeof(arr[0]); /* 自动算 */
int i = 0;
for (i = 0; i < sz; i++)
printf("%d ", arr[i]);
printf("\n数组共 %d 个元素\n", sz);
return 0;
}不管 arr 里有多少个元素,sz 都会自动跟着变。在写工程代码时,这种做法远比硬编码数字要安全和可维护。
不过这里要提醒一个关键陷阱:sizeof(数组名) 这个技巧在函数参数里会失效。当数组作为函数参数传递时,它实际上退化成了指针,sizeof 返回的是指针本身的大小(通常是 4 或 8 字节),而不是数组的总大小。这个坑等我们讲到函数和指针时会详细说明,现在先记住:在同一个函数里定义的数组变量,用 sizeof 算大小是安全的;传参到另一个函数后就不安全了。
数组元素的修改与常见操作
访问只是第一步,实际程序里更多的是"改"。给你一组练习,全部自己敲一遍:
#include <stdio.h>
int main()
{
int arr[] = {1, 2, 3, 4, 5};
int sz = sizeof(arr) / sizeof(arr[0]);
int i = 0;
/* 练习1:把所有元素翻倍 */
for (i = 0; i < sz; i++)
arr[i] *= 2; /* arr = {2, 4, 6, 8, 10} */
/* 练习2:把元素逆置(第一个和最后一个交换...) */
int left = 0, right = sz - 1;
while (left < right)
{
int temp = arr[left];
arr[left] = arr[right];
arr[right] = temp;
left++;
right--;
}
/* arr = {10, 8, 6, 4, 2} */
/* 练习3:找到最大值及其下标 */
int max = arr[0];
int maxIdx = 0;
for (i = 1; i < sz; i++)
{
if (arr[i] > max)
{
max = arr[i];
maxIdx = i;
}
}
printf("最大值 = %d,下标 = %d\n", max, maxIdx);
/* 打印验证 */
for (i = 0; i < sz; i++)
printf("%d ", arr[i]);
printf("\n");
return 0;
}"交换两个变量"用的是经典的第三者技巧(temp),"双指针向中间靠拢"(left/right)是逆置的标准写法——这两个小套路会反复出现在你以后的代码里。
练习:冒泡排序
把一组数从小到大排列,最直观的算法就是冒泡排序:每一轮把"最大的数"冒泡到末尾,下一轮就不用再管它了。
#include <stdio.h>
int main()
{
int arr[] = {9, 5, 2, 7, 1, 8, 3, 6, 4, 0};
int sz = sizeof(arr) / sizeof(arr[0]);
int i = 0, j = 0;
/* 冒泡排序:外层控制轮数(sz-1 轮),内层做相邻比较交换 */
for (i = 0; i < sz - 1; i++)
{
for (j = 0; j < sz - 1 - i; j++) /* 每轮少比较一个(末尾已排好) */
{
if (arr[j] > arr[j + 1]) /* 前一个比后一个大 → 交换 */
{
int temp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = temp;
}
}
}
/* 打印排序结果 */
for (i = 0; i < sz; i++)
printf("%d ", arr[i]);
printf("\n");
/* 输出:0 1 2 3 4 5 6 7 8 9 */
return 0;
}分析这段代码:
- 外层循环
i从 0 到sz-2,共sz-1轮——因为最后一轮只剩一个数,不用再比; - 内层循环
j从 0 到sz-2-i——第 i 轮结束时,最大的 i+1 个数已经"沉"到末尾,不需要再参与比较; - 如果
arr[j] > arr[j+1]就交换,否则不动——每轮结束后,当前未排序区间的最大值被"冒"到了最右边。
冒泡排序的时间复杂度是 O(n²),不是最快的排序,但它是理解"嵌套循环 + 交换"的最佳入门算法。以后学数据结构时你会接触快排、归并等更高效的算法,现在先把冒泡写熟练。
二维数组——当一行数据不够用的时候
前面学的一维数组,每个元素都是基本类型(比如 int)。但如果你需要表示一个表格呢?比如 3 个学生,每人 5 门课的成绩,你就需要 3 行 5 列的数据结构。这时一维数组就不够用了。
如果把一维数组作为另一个数组的元素,就构成了二维数组——本质上它是"数组的数组"。一维数组就是一行数据,二维数组则是多行多列,像一个表格。
二维数组的创建语法和加法类似,只是多了一对方括号:
type arr_name[常量值1][常量值2];例如:
int arr[3][5]; /* 3 行 5 列,共 15 个 int 元素 */
double data[2][8]; /* 2 行 8 列,共 16 个 double 元素 */第一个 [] 里的值表示行数,第二个 [] 里的值表示列数(即每行有几个元素)。arr[3][5] 的意思就是 3 行、每行 5 个元素,一共 3×5=15 个 int。
为什么叫"数组的数组"?
int arr[3][5] 可以这样理解:它先是一个有 3 个元素的数组,每个元素又都是一个有 5 个 int 的数组。也就是说——arr[0] 本身就是一个"有 5 个 int 的数组",arr[1]、arr[2] 同理。这个视角在以后学指针(指向数组的指针)时会非常重要,现在先建立"行就是子数组"的认知。
二维数组的初始化——行和列的排列组合
二维数组的初始化也使用大括号,但花样比一维数组多:
/* 不完全初始化:给前两个,其余自动为 0 */
int arr1[3][5] = {1, 2};
/* 全部初始化为 0(最实用的写法) */
int arr2[3][5] = {0};
/* 完全初始化:按行顺序填充 */
int arr3[3][5] = {1,2,3,4,5, 2,3,4,5,6, 3,4,5,6,7};
/* 按行初始化:每组大括号对应一行 */
int arr4[3][5] = {{1,2}, {3,4}, {5,6}};
/* 第一行:1,2,0,0,0
* 第二行:3,4,0,0,0
* 第三行:5,6,0,0,0 */
/* 初始化时可以省略行数,但不能省略列数 */
int arr5[][5] = {1, 2, 3}; /* 自动算出行数为 1 */
int arr6[][5] = {1,2,3,4,5,6,7}; /* 自动算出行数为 2 */
int arr7[][5] = {{1,2}, {3,4}, {5,6}};/* 行数为 3 */为什么行数可以省略但列数不能省略?因为编译器必须知道"一行有多长"才能正确划分内存。如果连列数都不知道,它就设法确定每个元素应该放在哪个位置了。比如 int arr[][5] = {1,2,3,4,5,6,7},编译器一看有 7 个数、每行只能放 5 个,就知道需要 2 行。但如果是 int arr[][] = {1,2,3,...},编译器就不知道一行该放几个,直接报错。
二维数组的使用——用行号和列号精确定位
二维数组的访问也是用下标——需要同时指定行下标和列下标,两个都是从 0 开始。锁定了行和列,唯一的元素就确定下来了:
#include <stdio.h>
int main()
{
/* 3 行 5 列的数组 */
int arr[3][5] = {
{1, 2, 3, 4, 5}, /* 第 0 行 */
{2, 3, 4, 5, 6}, /* 第 1 行 */
{3, 4, 5, 6, 7} /* 第 2 行 */
};
printf("%d\n", arr[2][4]); /* 第 2 行第 4 列 → 7 */
printf("%d\n", arr[0][0]); /* 第 0 行第 0 列 → 1 */
return 0;
}要遍历整个二维数组,你需要双层循环——外层控制行,内层控制列:
#include <stdio.h>
int main()
{
int arr[3][5] = {
{1, 2, 3, 4, 5},
{2, 3, 4, 5, 6},
{3, 4, 5, 6, 7}
};
int i = 0, j = 0;
/* 输入 */
printf("请输入 15 个整数:\n");
for (i = 0; i < 3; i++) /* 外层:遍历每一行 */
{
for (j = 0; j < 5; j++) /* 内层:遍历当前行的每一列 */
{
scanf("%d", &arr[i][j]);
}
}
/* 输出(按矩阵格式) */
printf("二维数组内容:\n");
for (i = 0; i < 3; i++)
{
for (j = 0; j < 5; j++)
{
printf("%2d ", arr[i][j]); /* %2d 对齐显示 */
}
printf("\n"); /* 每行结束换行 */
}
return 0;
}外层循环 i 管行,内层循环 j 管列。对于 arr[i][j],先确定是哪一行(i),再确定是哪一列(j),你就能把整个表格扫一遍。
二维数组的经典应用:3 个学生 × 5 门课的成绩表、黑白图片的像素矩阵(pixel[row][col])、迷宫地图(0 表示墙 1 表示路)、五子棋棋盘……凡是"表格状"的数据,二维数组都是第一选择。
二维数组在内存中的存储——其实也是一条线
二维数组看起来是一个表格,但在内存里,它也是连续存放的——就像把表格一行一行地展开铺平:
#include <stdio.h>
int main()
{
int arr[3][5] = {0};
int i = 0, j = 0;
for (i = 0; i < 3; i++)
{
for (j = 0; j < 5; j++)
{
printf("&arr[%d][%d] = %p\n", i, j, &arr[i][j]);
}
}
return 0;
}从输出可以看到几个规律:每行内部的相邻元素地址差 4 字节;上一行最后一个元素(比如 arr[0][4])和下一行第一个元素(arr[1][0])之间也是差 4 字节。整个 3×5 的 int 数组就是一片连续的 60 字节(3×5×4)内存区域。
这其实说明了一个深刻的道理:二维数组在物理上就是一维的,所谓的"行"和"列"只是我们人类理解数据的逻辑视图。编译器在底层把二维数组按行展开存储——先存第一行的 5 个元素,再存第二行的 5 个,依此类推。这个认知对你后续用指针操作二维数组非常关键。
按行存储的推论:arr[2][3] 的地址 = 数组首地址 + (2×5 + 3) × 4。行下标先乘以列数,再加上列下标——因为每一行有 5 个元素,第 2 行要跳过 2×5=10 个元素才能到。这个"行优先"的寻址公式是二维数组的底层真相,理解它,你就能解释为什么列数不能省略。
C99 中的变长数组——运行时才确定大小
在 C99 标准之前,创建数组时 [] 里面只能用常量或常量表达式:
int arr1[10];
int arr2[3 + 5];
int arr3[] = {1, 2, 3};这种限制有时候确实不方便。比如你要根据用户的输入来决定数组需要多大:当用户输入 5,你就需要一个 5 个元素的数组;输入 100,就需要 100 个。在 C89 下,你只能先定义一个"足够大"的数组(比如 1000 个元素),然后只用其中一部分——这不仅浪费内存,而且万一用户输入超过 1000 就出问题了。
C99 引入了变长数组(Variable-Length Array,简称 VLA),允许用变量来指定数组的大小:
#include <stdio.h>
int main()
{
int n = 0;
scanf("%d", &n); /* 运行时才知道 n 是多少 */
int arr[n]; /* C99 VLA:数组大小在运行时确定 */
int i = 0;
for (i = 0; i < n; i++)
{
scanf("%d", &arr[i]);
}
for (i = 0; i < n; i++)
{
printf("%d ", arr[i]);
}
printf("\n");
return 0;
}关于变长数组,有几个容易误解的地方需要澄清:
第一个,"变长"的意思不是说数组的大小可以改变。数组一旦创建,大小就固定了。这里的"变长"指的是:创建的时候,可以用变量来指定大小,而这个变量的值只有到程序运行时才知道。编译器没法在编译时确定数组多大,所以叫"变长"。
第二个,正是因为编译器不知道数组多大,变长数组不能初始化。int arr[n] = {0}; 这样的写法是错误的——编译器在编译期间没法展开初始化列表。这是一个硬性约束。
第三个,C11 标准把 VLA 改成了可选特性——编译器可以选择支持,也可以选择不支持。VS2022 就不支持变长数组,而 GCC 和 Clang 是支持的。如果你想验证上面的代码,需要切换到 GCC 环境下编译。
如果你使用的是 VS2022 但又需要"运行时确定大小的数组",需要用动态内存分配 malloc/free 来实现,那是后续要讲的内容了。
数组练习——把学的知识用起来
学了这么多理论,来做几个经典的练习题巩固一下。
练习一:多个字符从两端移动,向中间汇聚
这个程序实现了一个有趣的可视化效果——字符串像揭幕一样从两端逐渐显示,配合 Sleep 函数产生逐秒变化的效果:
#include <stdio.h>
#include <string.h> /* strlen() */
#include <windows.h> /* Sleep(),Windows 专用 */
int main()
{
/* 目标字符串 */
char arr1[] = "welcome to bit...";
/* 遮盖用的字符串,长度和目标相同 */
char arr2[] = "#################";
int left = 0; /* 左端下标 */
int right = strlen(arr1) - 1; /* 右端下标 */
printf("%s\n", arr2); /* 初始:全是 # */
while (left <= right) /* 左右指针还未相遇 */
{
Sleep(1000); /* 暂停 1000 毫秒(1 秒) */
arr2[left] = arr1[left]; /* 左端字符逐渐显现 */
arr2[right] = arr1[right]; /* 右端字符逐渐显现 */
left++; /* 左端向右移动 */
right--; /* 右端向左移动 */
printf("%s\n", arr2); /* 打印当前效果 */
}
return 0;
}
/* 运行效果(每行间隔 1 秒):
* #################
* w###############.
* we#############..
* wel###########...
* welc#########t...
* ...最终变成:welcome to bit...
*/这个练习巧妙地利用了数组下标来同时操作首尾两个位置的字符。left 从 0 开始向右走,right 从字符串末尾向左走,每走一步就揭开一个字符。Sleep(1000) 是 Windows API,让程序暂停 1000 毫秒来制造逐帧动画的效果。如果你在 Linux 上用,可以用 sleep(1)(需要 <unistd.h>)。
注意这里的细节:arr2 的长度必须和 arr1 相等(都是 17 个字符),否则右边会错位。strlen 返回的字符串长度不含 \0,所以 right = strlen(arr1) - 1 恰好指向最后一个可见字符。
练习二:二分查找(折半查找)
在一个升序排列的数组中查找指定数字。你当然可以从头到尾顺序找——这叫线性查找,时间复杂度是 O(n),数组越大越慢。但既然数组已经排好序了,我们可以用更聪明的方式:二分查找,时间复杂度只有 O(log n)。
二分查找的思路很直观:每次取区间中间的值,如果目标比中间值大,说明目标在右半边——左半边直接抛弃;如果目标比中间值小,就在左半边找。范围每次都缩小一半,直到找到或区间为空:
#include <stdio.h>
int main()
{
int arr[] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}; /* 升序数组 */
int sz = sizeof(arr) / sizeof(arr[0]); /* 元素个数 */
int left = 0; /* 查找区间左边界 */
int right = sz - 1; /* 查找区间右边界 */
int key = 7; /* 要找的数字 */
int mid = 0; /* 中间位置 */
int find = 0; /* 0=没找到,1=找到了 */
while (left <= right) /* 区间有效 */
{
mid = left + (right - left) / 2; /* 计算中间位置 */
if (arr[mid] > key) /* 中间值太大 */
right = mid - 1; /* 去左半边找 */
else if (arr[mid] < key) /* 中间值太小 */
left = mid + 1; /* 去右半边找 */
else
{
find = 1; /* 找到了 */
break; /* 退出循环 */
}
}
if (find == 1)
printf("找到了!%d 的下标是 %d\n", key, mid);
else
printf("找不到 %d\n", key);
return 0;
}
/* 输出:找到了!7 的下标是 6 */模拟一遍查找 7 的过程(数组 10 个元素,下标 0~9):
- 第一次:left=0, right=9, mid=4,arr[4]=5 < 7 → left=5
- 第二次:left=5, right=9, mid=7,arr[7]=8 > 7 → right=6
- 第三次:left=5, right=6, mid=5,arr[5]=6 < 7 → left=6
- 第四次:left=6, right=6, mid=6,arr[6]=7 == 7 → 找到!
每次比较都让查找区间缩小一半:10 → 5 → 2~3 → 1,只用了 4 次比较。如果线性查找,7 在下标 6,要找 7 次。当数组有 100 万个元素时,线性查找最坏要 100 万次,二分查找最多 20 次(因为 2^20 ≈ 100 万)——这就是 O(log n) 的威力。
注意中间位置的计算方式:mid = left + (right - left) / 2。为什么不直接写 mid = (left + right) / 2 呢?这两种写法在绝大多数情况下结果一样,但当 left 和 right 都特别大(比如接近 INT_MAX,大约是 21 亿)时,left + right 可能溢出,导致结果出错。而 left + (right - left) / 2 先算差值再除以 2,完全不会溢出。虽然在学习阶段遇到的数组都很小,但养成这个习惯对将来写健壮代码有好处。
另外,二分查找有一个硬性前提:数组必须有序。如果数组是乱序的,二分查找的结果是无意义的——你必须先排序,或者使用其他查找方式。
练习三:数组元素的逆置(回顾)与统计
#include <stdio.h>
int main()
{
/* 输入 10 个整数,先统计正数个数,再逆序打印 */
int arr[10] = {0};
int i = 0;
int positive = 0;
printf("请输入 10 个整数:\n");
for (i = 0; i < 10; i++)
{
scanf("%d", &arr[i]);
if (arr[i] > 0)
positive++; /* 边输入边统计,省一次循环 */
}
printf("正数个数:%d\n", positive);
printf("逆序输出:");
for (i = 9; i >= 0; i--) /* 下标从大到小遍历 */
{
printf("%d ", arr[i]);
}
printf("\n");
return 0;
}"边输入边处理"(减少循环次数)和"下标倒序遍历"(for (i = 9; i >= 0; i--))是两个值得学习的惯用法。
数组与字符串的关系预告
"abc" 本质上就是一个 char 数组:{'a', 'b', 'c', '\0'}。字符数组是数组的第一实战场景——以后学字符串函数(strlen、strcpy、strcmp 等)时,你会反复和字符数组打交道。现在的关键认知是:字符串 = 字符数组 + 末尾 \0。如果你用 char arr[3] = "abc";,编译器会直接报错——因为 "abc" 实际需要 4 个字节(含 \0),数组装不下。
本篇思考题
int arr[10] = {0};之后arr[10]能访问吗?为什么说这是"未定义行为"?- 为什么
int a[] = {1,2,3};可以省略大小,而int a[3] = {1,2,3,4};会报错? sizeof(arr) / sizeof(arr[0])求元素个数的原理是什么?这个技巧在函数参数里为什么失效?- 二维数组
int a[3][5]在内存中占多少字节?a[2][4]相对首地址偏移多少字节? - 二分查找的前提条件是什么?
mid = left + (right - left) / 2相比(left + right) / 2的优势在哪? - 冒泡排序中内层循环为什么要写
j < sz - 1 - i?去掉- i会发生什么(结果一样吗)? - 变长数组为什么不能初始化?VS2022 不支持 VLA,那"运行时确定大小"的数组怎么实现?
思考题参考答案与详解
第 1 题:不能访问。int arr[10] 的有效下标是 0~9,arr[10] 已经越界。称其为"未定义行为"是因为 C 标准不规定越界访问会发生什么——可能碰巧那块内存可写而"正常"运行,可能覆盖相邻变量造成数据错乱(表现为"明明没改它、值却变了"的灵异 bug),也可能直接段错误崩溃;而且这次运行和下次运行的结果都可能不同。所以访问数组必须严格保证下标落在 0 ~ n-1 内。
第 2 题:int a[] = {1,2,3}; 省略大小是允许的,因为编译器会根据初始化项的个数自动推断数组大小为 3。而 int a[3] = {1,2,3,4}; 中初始化项的个数(4)超过了数组声明的容量(3),编译器装不下这么多元素,会直接报"初始化值太多"的错误。
第 3 题:sizeof(arr) 得到整个数组占用的总字节数(如 int[10] 是 40 字节),sizeof(arr[0]) 得到一个元素占用的字节数(4),两者相除恰好得到元素个数 40 / 4 = 10——这样数组大小变了,sz 会自动跟着变,不用硬编码。这个技巧在函数参数里会失效:数组作参数传递时退化成指针,sizeof 取到的是指针本身的大小(4 或 8 字节),而不是整个数组的大小。所以应在定义数组的同一函数内使用它,或把元素个数作为参数显式传给函数。
第 4 题:int a[3][5] 一共 3 × 5 = 15 个 int,每个 4 字节,共 60 字节。a[2][4] 相对首地址的偏移 = (行号 × 列数 + 列号) × 元素大小 = (2×5 + 4) × 4 = 14 × 4 = 56 字节(行优先存储:先按行号跳过前两行共 10 个元素,再在本行内跳过 4 个)。
第 5 题:前提是数组必须有序(按查找方向升序/降序之一),否则"用中间值大小判断去左/右半边"的逻辑就毫无意义。mid = left + (right - left) / 2 的优势是避免整数溢出:当 left、right 都接近 INT_MAX(约 21 亿)时,left + right 可能溢出得到负值或错误结果;而先算差值 right - left(不会溢出)再除以 2,则始终安全。学习阶段数组很小两者结果一致,但这是写健壮代码的好习惯。
第 6 题:结果一样,数组仍能正确排序,只是去掉 - i 会让每一轮都固定比较 sz-1 次,做大量重复比较(前一轮已"冒"到末尾的最大元素,下一轮还要再去和前面的数比较一遍)。j < sz - 1 - i 让内层循环的右边界随轮数 i 收缩,跳过已经排好序的尾部,从而把总比较次数从"每轮都全比一次"降到约 (n-1)(n-2)/2 次。所以 - i 是纯性能优化,不改变正确性。
第 7 题:变长数组的大小要到程序运行时才能确定(取决于变量 n 的值),而初始化列表是在编译期展开的——编译器在编译时根本不知道要往多少个元素里填初始值,因此禁止对变长数组初始化。VS2022 不支持 VLA,要"运行时确定大小"的数组,需要用动态内存分配:用 malloc 按需向堆申请空间,用完再用 free 释放(这是后续"动态内存管理"一章的内容):
int *arr = (int*)malloc(n * sizeof(int)); /* n 在运行时才确定大小 */
/* 之后可以像数组一样用 arr[0] ~ arr[n-1] ... */
free(arr); /* 用完一定要释放 */小结
从数组的基本概念到一维数组的创建、访问、内存布局,再到 sizeof 的动态计算技巧、二维数组的行列操作,以及 C99 变长数组和三个经典实战练习,这一讲覆盖了数组的完整知识体系。你会发现无论是一维还是二维,数组元素在内存中始终是连续存放的——这个"连续性"是 C 语言数组最底层的本质特征,也是后续理解指针和数组关系的钥匙。下一阶段我们将进入函数的学习——怎么把代码组织成可复用的模块,以及函数和数组结合使用时的那些微妙规则。
还没有评论 — 第一条由你来留。