前面几篇文章,我们操作的都是单个数据——一个整数、一个字符、一个浮点数。但在实际编程中,你很少只处理单个数据。一个班级有 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),数组装不下。


本篇思考题

  1. int arr[10] = {0}; 之后 arr[10] 能访问吗?为什么说这是"未定义行为"?
  2. 为什么 int a[] = {1,2,3}; 可以省略大小,而 int a[3] = {1,2,3,4}; 会报错?
  3. sizeof(arr) / sizeof(arr[0]) 求元素个数的原理是什么?这个技巧在函数参数里为什么失效?
  4. 二维数组 int a[3][5] 在内存中占多少字节?a[2][4] 相对首地址偏移多少字节?
  5. 二分查找的前提条件是什么?mid = left + (right - left) / 2 相比 (left + right) / 2 的优势在哪?
  6. 冒泡排序中内层循环为什么要写 j < sz - 1 - i?去掉 - i 会发生什么(结果一样吗)?
  7. 变长数组为什么不能初始化?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 语言数组最底层的本质特征,也是后续理解指针和数组关系的钥匙。下一阶段我们将进入函数的学习——怎么把代码组织成可复用的模块,以及函数和数组结合使用时的那些微妙规则。