
如果你刚学到C语言的数组大概率会困惑int a[5];这个a到底是一个数组还是一个指针为什么访问a[5]不报错却能打印出奇怪的值为什么同一个数组在主函数里能用sizeof算出长度换个函数就不行这篇是C语言学习系列的第一篇专门把数组这件事讲透。我打算从内存布局讲起再依次展开数组的声明与初始化、一维二维操作、字符串数组、函数传参最后落到变长数组和动态数组。内容定位是“新手能看懂老手能查漏”如果你刚开始学数组可以顺着读如果你学完指针想回来填坑直接跳到第5章和第6章看最实用的部分。读完之后你至少能回答数组名为什么能当指针用、下标运算符到底做了什么、数组长度为什么会被“弄丢”、动态内存怎么配合数组使用。1. 先搞清楚数组在内存里到底占了多少地方1.1 连续内存和下标偏移数组名只是首地址看这一段代码int a[5] {10, 20, 30, 40, 50};假设你的机器上int占 4 字节那a会在栈上分配sizeof(int) * 5也就是 20 字节的连续空间。a[0]的地址最低a[4]的地址最高5 个元素一个挨一个排在一起。你可以打印每个元素的地址验证会发现它们正好相差sizeof(int)。下标访问在C语言里本质是一个语法糖a[i]等价于*(a i)。意思是“从首地址出发向后偏移i个元素步长再解引用”。这也是为什么数组下标从 0 开始下标值恰好等于偏移量a[0]就是首地址处那个元素不需要额外减 1。我一般喜欢用储物柜来类比一排编号从 0 到 4 的柜子每个柜子大小一样。你想找 3 号柜只需要知道第一个柜子在哪然后往前迈 3 步。数组元素类型必须相同正是因为每个“柜子”宽度一致编译器才能用同一个步长i * sizeof(type)计算任意元素的地址。如果数组里混着int和double步长就无法统一按地址偏移的方案就彻底失效了。1.2 用变量代替数组的问题数组解决的是“批量 索引”有人问数组不就是一堆变量吗那我写 5 个变量行不行行但极其痛苦。比如求 5 个分数的平均分不写数组是这样double score1, score2, score3, score4, score5; scanf(%lf %lf %lf %lf %lf, score1, score2, score3, score4, score5); double avg (score1 score2 score3 score4 score5) / 5;如果变成 100 个分数呢变量名没有办法动态生成循环也用不上。数组的价值就在这里把同类型数据放进一段连续内存用同一个名字加上下标去访问代码才能写成循环结构。数组提供了两个核心能力批量存储同类型元素以及通过下标进行 O(1) 随机访问。这里顺便提一个关键点C语言不会检查数组越界。原因很简单——a[i]最终只是被编译器翻译成“计算地址并访问”它不会在运行时去判断i是否在[0, n)范围内。这种设计把检查责任完全交给了程序员换来了极低的访问开销。后面我会专门讲越界的坑这里先有这个概念。1.3 用 sizeof 计算数组长度只在“数组变量”上有效在数组所在的声明作用域里sizeof(a)返回的是整个数组占用的字节数。计算元素个数有一个经典写法int len sizeof(a) / sizeof(a[0]);这个写法在数组变量所在的地方是对的。比如int a[5]sizeof(a)是 20sizeof(a[0])是 4相除得到 5。但这句话只对“真数组变量”成立。一旦把数组作为参数传给函数形参那边的sizeof就不再是数组大小了这个“长度丢失”问题我放到第5章细说。现在你只要记住sizeof(a) / sizeof(a[0])在函数外部用很香在函数内部用会翻车。2. 声明与初始化C语言新手最容易翻车的几个写法2.1 初始化的不同形式与默认值数组声明可以写出各种花样我见过最多的问题都出在初始化和越界这两件事上。先看几种常见写法int a1[5]; // 局部变量未初始化里面是垃圾值 int a2[5] {0}; // 全部初始化为 0 int a3[5] {1, 2, 3}; // 前 3 个是 1,2,3后 2 个自动补 0 int a4[] {1,2,3,4,5}; // 编译器自动推导长度为 5 int a5[5] {[2] 9}; // C99 指定初始化第 3 个元素是 9其余是 0特别注意a2[5] {0}并不是“把第 0 个元素写成 0其他随便”而是C语言里一个约定只要初始化列表里的值不足数组长度剩余元素会被补零。很多人以为{0}只初始化第一个元素然后去读a2[1]发现是 0又开始怀疑自己是不是运气好。其实规则就是补零放心用。a4[]这种写法依赖编译器从初始化列表推导长度适合那些“初始化时已经知道内容”的场景。a5的指定初始化器在嵌入式代码里很常见比如只想设置某个特殊位其他保持 0但初学者不熟悉的话容易看懵可以先了解。还有一个默认值规律局部数组如果不初始化里面是“不确定值”本质是栈上残留的旧数据而全局数组和static修饰的局部数组会默认清零。不要在程序里依赖“局部数组碰巧是 0”那是未定义行为级别的赌博。2.2 char 数组初始化的特殊点字符串结束符字符数组是数组初始化里最容易踩坑的一块。看这几行char s1[5] hello; // 危险hello 实际占 6 字节 char s2[6] hello; // 正确h e l l o \0 char s3[10] hello; // 可以后面自动补 0 char s4[] hello; // 编译器推导长度 6C语言里字符串字面量hello并不是 5 个字符而是 6 个字符最后面还有一个\0结束符。C语言没有内置的字符串类型只能靠字符数组加结束符来模拟。strlen统计的是\0之前的字符数所以strlen(s4)是 5sizeof(s4)是 6它算的是数组总字节数。新手最容易犯的错误是写char s[5];然后strcpy(s, hello);。这相当于往一个只能装 5 个字节的盒子里塞 6 个字节第 6 个字节的\0会写到数组外面。程序可能不崩因为越界写入的只是相邻栈空间但你已经破坏了别人的数据。这种错最难查因为它不会当场报错。2.3 越界不报错是未定义行为不是“碰巧能跑”来看一个经典危险代码#include stdio.h int main(void) { int a[3] {1, 2, 3}; printf(%d\n, a[3]); return 0; }a[3]越界了但很多编译器编译时不会警告运行时也不一定会崩。它可能打印 0可能打印乱七八糟的数也可能因为破坏了关键数据直接段错误。C标准里这种情况叫“未定义行为”意思是你无法预测它接下来会干什么。C语言不像 Java 会在运行时抛数组越界异常因为数组内部根本不保存长度元数据访问时不做边界检查。这是性能换安全的典型场景。我在实际调程序时见过不少这样的问题一个循环多写了一个元素数组后面紧挨着的某个变量值被改掉程序表现变得时好时坏。排查这类问题有两个很实用的工具GCC 编译时加-fsanitizeaddress运行时会直接报告越界位置或者用 Valgrind 检测内存非法访问。建议学完数组之后就练会这两把刀后面写任何C程序都会省力。3. 一维数组的五个基础操作遍历、逆置、查找、排序、拷贝3.1 遍历与累加先写对循环边界数组操作逃不开遍历而遍历最容易错的是边界。正确的区间是[0, n)也就是从 0 开始到n - 1结束。求和的例子int arr[] {15, 8, 23, 6, 42}; int n sizeof(arr) / sizeof(arr[0]); int sum 0; for (int i 0; i n; i) { sum arr[i]; } double avg (double)sum / n; printf(sum%d, avg%.2f\n, sum, avg);求最大值也类似但初始值要注意如果直接int max arr[0];那么数组为空时arr[0]本身就越界了。更稳妥的做法是用int max INT_MIN;然后遍历再把INT_MIN包含进来所以需要包含limits.h。实际刷题时数组通常非空但养成考虑空数组的习惯能避免不少线上崩溃。关于循环边界有一个典型错误是for (i 1; i n; i)访问a[i]。这种写法少访问第一个元素、多访问最后一个越界元素。每当程序运行结果“差一点点正常”第一反应就去查循环边界是不是多了 1 或者少了 1。3.2 逆置与二分查找边界条件是重点逆置数组的意思是让首尾交换。用两个下标从两端往中间走int left 0, right n - 1; while (left right) { int tmp arr[left]; arr[left] arr[right]; arr[right] tmp; left; right--; }这里终止条件是left right。如果写成left right对于奇数长度的数组中间那个元素会和自己交换一次不影响结果但逻辑上不够干净对于偶数长度左右下标会交错必须在循环体里额外判断否则会出现重复交换。所以用left right是最稳妥的写法。二分查找是数组操作的进阶题要求数组必须有序。闭区间写法如下int binary_search(int a[], int n, int target) { int low 0, high n - 1; while (low high) { int mid low (high - low) / 2; if (a[mid] target) { return mid; } else if (a[mid] target) { low mid 1; } else { high mid - 1; } } return -1; }细节有三个。第一mid要用low (high - low) / 2不要写(low high) / 2后者在数字大时可能整数溢出。第二while条件是low high因为闭区间内只剩一个元素时还需要再判断一次如果写成会漏掉目标恰好在最后一个位置的情况。第三更新边界时是low mid 1和high mid - 1直接写low mid会遇到死循环因为区间没有缩小。3.3 冒泡排序与选择排序先会写再谈优化排序是数组操作的分水岭。冒泡排序最直观代码也短void bubble_sort(int a[], int n) { for (int i 0; i n - 1; i) { for (int j 0; j n - 1 - i; j) { if (a[j] a[j 1]) { int tmp a[j]; a[j] a[j 1]; a[j 1] tmp; } } } }每一轮会把当前未排序部分的最大值“冒”到最后面所以内层循环范围每轮减少一个。j n - 1 - i里的- i很容易写漏漏掉之后逻辑仍然勉强能跑但会多做很多无效比较。冒泡排序是稳定排序相同元素的相对顺序不会变这一点在某些工程场景很重要。选择排序的思路是每轮找最小值的下标然后放到前面void selection_sort(int a[], int n) { for (int i 0; i n - 1; i) { int min_idx i; for (int j i 1; j n; j) { if (a[j] a[min_idx]) { min_idx j; } } if (min_idx ! i) { int tmp a[i]; a[i] a[min_idx]; a[min_idx] tmp; } } }选择排序的交换次数比冒泡少每次外层循环最多交换一次所以对“写入成本高”的数据结构更友好。但它不稳定比如数组[2a, 1, 2b]排序后会变成[1, 2b, 2a]两个 2 的相对顺序可能改变。入门阶段不用太纠结稳定性但要知道有这回事。3.4 数组插入与删除的本质移动数据而不是改变长度你可能听说过“数组增加一个元素”“数组删除一个元素”但C语言的固定大小数组本身长度不变所谓插入和删除本质是移动元素。例如在下标pos处插入valueint arr[10] {1, 2, 3, 4, 5}; int n 5; // 当前有效元素个数 int pos 2; int value 99; for (int i n; i pos; i--) { arr[i] arr[i - 1]; } arr[pos] value; n;移动必须从后往前否则前面的元素会覆盖后面的元素。删除则反过来从前往后覆盖for (int i pos; i n - 1; i) { arr[i] arr[i 1]; } n--;这两个操作在数据结构里属于基础中的基础。你要始终记住数组的物理容量不变实际元素个数靠你自己维护一个n。这也是为什么后面出现动态数组时大家会强调“容量”和“有效长度”是两个概念。4. 二维数组与字符串数组它们不是随便嵌套4.1 内存布局和下标计算为什么第二维不能省略二维数组int b[2][3]的准确理解是一个有 2 个元素的数组每个元素又是一个int[3]数组。它在内存里依然是连续排列的按行优先存储int b[2][3] {{1, 2, 3}, {4, 5, 6}};物理顺序是b[0][0], b[0][1], b[0][2], b[1][0], b[1][1], b[1][2]一共 6 个int。访问b[i][j]时编译器按公式计算地址首地址 (i * 列数 j) * sizeof(int)。这就是为什么声明二维数组时第二维必须写没有列数编译器就算不出每一行从哪里开始、b[i]该跳到哪个地址。第一维可以省略比如int d[][3] {{1,2,3}, {4,5,6}, {7,8,9}};编译器看到每行是 3 个int一共 9 个元素自动推断第一维是 3。但是写成int e[2][]就是错的因为第二维未知连一行有多长都不知道。二维数组的遍历一般是双重循环int rows sizeof(b) / sizeof(b[0]); int cols sizeof(b[0]) / sizeof(b[0][0]); for (int i 0; i rows; i) { for (int j 0; j cols; j) { printf(%d , b[i][j]); } printf(\n); }其中sizeof(b[0])是第一行那一个一维数组的总字节数再除以单个元素大小就是列数。这个技巧在二维数组上很好用但同样只在数组变量声明所在作用域内有效。4.2 指针数组与数组指针名字像差别很大热词里经常看到“指针数组”和“数组指针”这俩名字看着像含义完全相反。int *p[3]; // 指针数组p[0], p[1], p[2] 都是 int* int (*q)[3]; // 数组指针q 指向“含 3 个 int 的一维数组”int *p[3]可以这样理解p先是一个数组数组里每个元素是int*。它经常用来做字符串数组char *colors[] {red, green, blue}; printf(%s\n, colors[1]); // green printf(%c\n, colors[1][2]); // ecolors[1]是char*指向字符串green的首字符g再加一个下标[2]就访问到该字符串的第 2 个字符e。这就是“指针数组”读字符串的典型方式。int (*q)[3]中q先被括号限定是一个指针它指向一个含 3 个int的数组。它最常用于二维数组的行指针int a[2][3] {{1,2,3}, {4,5,6}}; int (*q)[3] a; q; // 跳过一整行也就是跨过 3 个 int printf(%d\n, (*q)[1]); // 指向第二行所以输出 5字符串数组还有一种形式是固定行宽的二维字符数组char names[][10] {Alice, Bob};这种方式每行固定 10 字节不管字符串实际多短都会占满行宽但好处是内容存在自己数组里可以随时修改。指针数组的形式更省内存但指向的是字符串字面量修改colors[0][0]属于未定义行为。实际项目里需要频繁改字符串内容就选二维字符数组只需要保存和遍历字符串就选指针数组。4.3 数组名与数组名的区别1 之后差了多远这个问题被问过无数次也是数组和指针关系里最核心的考点。看代码int a[5] {1, 2, 3, 4, 5}; printf(%p\n, (void *)a); // 首元素地址 printf(%p\n, (void *)a); // 整个数组的地址数值和 a 相同 printf(%p\n, (void *)(a 1)); // 比 a 大 sizeof(int) printf(%p\n, (void *)(a 1)); // 比 a 大 5 * sizeof(int)原因在于类型a的类型是int[5]在绝大多数表达式里会“退化”成指向首元素的int*所以a 1只移动一个int。而a的类型是int (*)[5]它是指向整个数组的指针所以a 1会跳过整个数组 20 字节。两个地址打印出来数值一样但指针算术的步长完全不同。这个概念搞不清楚后面学二维数组、动态二维数组、函数指针时会反复踩坑。我的建议是写个程序实际打印一遍一次就能记住。5. 数组作为函数参数传参时的退化陷阱5.1 为什么函数里的 sizeof 不是数组长度新手最容易疑惑的代码是这段#include stdio.h void print_len(int arr[]) { printf(%zu\n, sizeof(arr)); // 输出 8不是 20 } int main(void) { int a[5] {1, 2, 3, 4, 5}; printf(%zu\n, sizeof(a)); // 输出 20 print_len(a); return 0; }原因在C标准里有明确规则当数组作为函数参数时形参int arr[]会被自动调整为int *arr。也就是说你写int arr[]和写int *arr完全等价。调用时传入的数组名a会“退化”成指向首元素的指针函数里sizeof统计的就是指针大小64位系统上是 8。所以不要在函数里用sizeof(arr) / sizeof(arr[0])计算长度否则会得到8 / 4 2这种离谱结果。正确做法很简单额外传一个长度参数。这也是C语言里为什么排序函数普遍长这样void sort(int a[], int n);n必须由调用者提供因为函数自己没有任何手段知道数组有多长。5.2 函数参数的三种写法其实是同一种看三个函数原型void f(int a[]); void f(int a[10]); void f(int *a);这三个在C语言里完全等价。第二个写法里的10会被编译器直接忽略不表示只能传长度为 10 的数组。很多人误以为在形参里写int a[10]能起到“长度约束”作用实际上传一个长度为 3 的数组过去照样编译通过运行时照样可能越界。二维数组作为参数时列数必须写清楚void print_matrix(int m[][4], int rows);可以等价写成void print_matrix(int (*m)[4], int rows);因为函数内部要计算m[i][j]的地址必须知道一行有多少元素。第一维可以省略因为调用时可以靠传入的行数来确定。5.3 返回数组的三种常用方式函数能不能直接返回一个数组语法上不能。最常见的错误是返回局部数组的地址int *bad(void) { int a[10] {0}; return a; // 悬垂指针函数返回后栈内存失效 }a是函数内的局部数组函数返回后这块栈内存就不再有效。地址值可能没变但里面的内容随时会被其他函数调用覆盖。如果你打印出乱码多半就是这个原因。实际工程里常用三种解法调用方提供缓冲区函数填充数据比如void fill(int *buf, int n)。使用static局部数组比如return static int a[10]但这种方式不是线程安全的且函数每次调用只维护同一份数据。用malloc动态分配内存返回堆地址由调用方负责free。第三种方式衔接了下一章的内容如果你需要的是真正“可变大小”的数组绕不开动态内存。6. 变长数组与动态数组突破固定大小限制的两种思路6.1 C99 变长数组 VLA运行时才知道长度也可以用很多初学者以为数组长度必须是常量其实C99 开始支持变长数组允许用变量指定数组长度int n; scanf(%d, n); int a[n]; // VLA运行时确定长度 for (int i 0; i n; i) { a[i] i * i; }这种数组分配在栈上用起来和普通数组一样。但有几个注意点第一n太大时可能直接栈溢出默认栈大小通常是几MB别想着开一个 100 万元素的int数组第二n为负数或 0 时行为未定义使用前最好检查第三C11 把 VLA 标记为可选特性微软的 MSVC 一直不支持如果你写跨平台代码依赖 VLA 会有风险。刷算法题用 GCC 或 Clang 没问题但工程代码里要谨慎。VLA 也可以出现在函数形参里void foo(int n, int a[n]);这种写法能保留一部分“数组感”sizeof(a)在函数内有效值为n * sizeof(int)。不过它本质仍然是指针传参只是编译器多记住了长度信息使用范围有限。6.2 malloc 动态数组真正自己管理生命周期如果数据规模在运行时才确定并且可能很大正确姿势是用malloc#include stdio.h #include stdlib.h int main(void) { int n; scanf(%d, n); int *p (int *)malloc(n * sizeof(int)); if (p NULL) { perror(malloc); return 1; } for (int i 0; i n; i) { p[i] i; } // 使用 p[0] 到 p[n-1] free(p); return 0; }malloc返回的是一块连续内存的首地址所以它能像数组一样用p[i]下标访问。它是堆上的内存不会在函数返回时自动释放必须free否则内存泄漏。动态数组和固定数组的区别可以用一个表列出来对比项固定数组malloc 动态数组内存位置栈或全局区堆长度确定时机编译期或栈帧创建时运行时是否需要 free否是数组名能否重新赋值否指针变量可以长度信息sizeof 可查作用域内必须自己记录动态数组真正强大的地方在于可以扩容。realloc能在保留原数据的前提下调整内存大小。一个简单的“可变数组追加元素”函数可以这样写#include stdlib.h int *append(int *data, int *size, int *cap, int val) { if (*size *cap) { int new_cap (*cap 0) ? 4 : (*cap) * 2; int *tmp (int *)realloc(data, new_cap * sizeof(int)); if (tmp NULL) { return NULL; // 原 data 仍然有效不能直接覆盖 } data tmp; *cap new_cap; } data[(*size)] val; return data; }注意一点realloc失败时会返回NULL但原来的内存块依然有效。如果直接写data realloc(data, new_cap * sizeof(int))一旦失败原指针就被改成NULL原有内存既没法用也没法释放直接泄漏。所以必须用临时变量先接收返回值判断成功后再赋值。这个细节我在代码评审里见过很多次。扩容策略通常按倍数扩展比如翻倍这样平均每次追加的代价是 O(1)而不是每加一个元素就重新分配一次。这也是很多动态数组库的基本实现思路。C语言标准库没有内置动态数组但用malloc/realloc/free自己实现一个并不难。6.3 个人选择与建议如果你现在手边有编译器我建议把下面这段代码敲一遍观察输出int a[5] {1, 2, 3, 4, 5}; printf(%p\n, (void *)a); printf(%p\n, (void *)(a 1)); printf(%p\n, (void *)a); printf(%p\n, (void *)(a 1));再写一个函数把a传进去打印函数内部的sizeof(a)对比主函数里的sizeof(a)。数组的很多坑看完不如跑一遍。我在实际带新人的时候发现大家最容易在数组和指针混着用时犯迷糊。下一篇我准备写指针把a[i]、*(a i)、a[i]这些彻底拆开。在那之前把数组的内存布局和初始化细节吃透后面的路会顺很多。