
聊C语言就绕不开数组。很多初学者会觉得数组不就是“把好几个同类型变量排成一排”吗一维、二维、字符数组翻来覆去就那点东西。但真到写项目、刷题、做嵌入式的时候数组的坑一个接一个下标越界、数组名悄悄变成指针、字符串缓冲区溢出、函数传参后长度丢失……没有一个是编译器能帮你一眼看出来的。这篇我就用实战视角把C语言数组从声明、初始化、传参到指针进阶、动态内存、经典算法应用完整过一遍适合刚开始学C语言的学生也适合把数组当“老朋友”但偶尔还会被坑的开发者。1. 数组在内存里到底长什么样1.1 从变量到数组一种连续内存的组织方式C语言里变量本质上是一块内存的别名。int a对应4个字节多数平台double d对应8个字节。你当然可以写500个单独的变量但没法维护。数组的本质是把同类型、数量固定的变量按顺序连续地摆在一块内存里然后用“数组名下标”访问。这个“连续”特别重要。因为连续所以CPU只要知道首地址和下标就能用“基址偏移量”的方式直接算出目标位置时间复杂度是O(1)。这是数组在现代计算机里依然不可替代的核心原因。链表虽然增删方便但想拿到第n个节点必须从头遍历做不到随机访问。代价也很明显声明时必须确定大小中途不能随意“增加一个元素”。而且C语言不检查下标越界程序一旦越界它会直接去读写相邻内存。这种“不检查”是C语言高效的原因之一也是无数安全漏洞的根源。我用生活里的例子类比数组就像电影院的一排座位下标就是座号。你报“第5排10号”工作人员不需要从头数到10只要知道这一排从哪开始加个偏移就到。但如果你喊“第100号”没有人拦你你可能就走进隔壁机房了。1.2 声明、初始化与遍历的细节声明数组的几种常用写法int a[10]; // 不初始化局部变量里是垃圾值 int b[10] {0}; // 全部初始化为0 int c[10] {1, 2, 3}; // 前3个指定后面自动补0 int d[] {1, 2, 3, 4}; // 省略长度编译器按初始化列表自动计算这些规则看着简单但有一个区分度很高的知识点局部变量不初始化读到的是栈上残留的脏数据而全局变量和静态变量即使不初始化也会默认清零。很多人定义int a[10]后直接printf输出一串随机数就是这个原因。遍历更是基本功for (int i 0; i n; i) { printf(%d , a[i]); }注意边界数组下标范围是[0, n-1]循环条件应该用i n。我见过无数新手写成i n最后一次就读了a[n]这是最典型的越界来源。写数组循环建议养成“左闭右开”的直觉看到先怀疑。2. 数组的日常核心操作与二维数组2.1 一维数组的增删改查本质上是在搬数据数组定长所以它没有真正意义的“增删”只有“搬移”。比如要在下标pos插入一个元素x套路是先把pos到末尾的所有元素往后挪一位然后赋值最后让逻辑长度n加1。void insert(int a[], int *n, int pos, int x) { if (pos 0 || pos *n) return; for (int i *n; i pos; --i) { a[i] a[i - 1]; } a[pos] x; (*n); }我特意强调从后往前挪。你试一次从前往后就明白了a[i] a[i - 1]会先覆盖掉后面还没搬走的元素数据全乱。删元素则是反方向从pos开始把后面的元素逐个往前覆盖然后n--丢掉末尾。void delete_at(int a[], int *n, int pos) { if (pos 0 || pos *n) return; for (int i pos; i *n - 1; i) { a[i] a[i 1]; } (*n)--; }很多新手把删除写成双重循环其实一轮前移就够了。数组里的有效数据范围由n控制物理数组大小不变逻辑长度在变。理解这一点后面所有数组题都会顺畅很多。2.2 二维数组、数组名与指针的类型陷阱二维数组int a[3][4]在内存里依然是连续的一维布局先放第0行的4个int再放第1行总共3 * 4 * sizeof(int)字节。它就像一个Excel表格a[i][j]表示第i行第j列。这里真正会坑人的是类型。a、a[0]、a[0][0]、*a这四个表达式打印出来的地址值很可能一模一样但类型不同做指针加减时步长天差地别表达式类型加1后跳过的距离aint[3][4]一整行即4个int16字节a[0]int[4]退化为int*一个int4字节a[0][0]int*一个int4字节*aint*一个int4字节实际开发中最常见的错误就是把二维数组名直接当“指向int的指针”传给函数。比如写void printMatrix(int *p, int row, int col)然后调用printMatrix(a, 3, 4)编译器会报warning甚至error因为a的类型是“指向含4个int的数组的指针”而不是“指向int的指针”。正确方案有两个。第一种是把列数写死在函数签名里void printMatrix(int (*p)[4], int row)。第二种是干脆用一维数组模拟二维布局arr[i * col j]这种方式在竞赛代码里很常见传参就是一个普通int*自由度更高。如果不是特别较真类型我更推荐第二种省心。3. 字符数组与字符串最容易翻车的区域3.1 字符串的本质就是char数组C语言没有真正的string类型所有字符串都靠字符数组承载并且习惯在末尾补一个\0。比如char s[10] hello;实际占用的内容是h e l l o \0共6个字节而不是5个。这解释了一个经典问题为什么char s[] hello;时sizeof(s)是6而strlen(s)只是5。sizeof算的是整个数组的物理大小strlen数的是遇到\0之前的逻辑长度。字符串字面量还有一个大坑const char *p hello;里的p指向只读的字符串常量区不能通过p修改字符。你要是写p[0] H在多数平台上一个段错误跑不掉。而char s[] hello;会把内容拷贝到可写的栈空间随便改。这两个写法表面相似一个是只读常量一个是可变副本。3.2 字符串函数的安全边界strcpy、strcat、sprintf这些函数都不检查目标缓冲区大小源字符串一旦比目标数组长直接越界。现在写代码我一般优先用带长度限制的版本strncpy、strncat限制拷贝长度snprintf(buffer, sizeof(buffer), %.20s, str)限制输出长度。但在嵌入式老编译器或者考试题里你可能还是得用strcpy那就必须自己保证源字符串不会超过目标数组。更稳妥的做法是自己写一个带长度上限的复制函数哪怕只是几行。字符串逆序是PTA和翁恺C语言作业里的高频题很常考。双指针版本很经典void reverse(char s[]) { int len (int)strlen(s); int i 0, j len - 1; while (i j) { char t s[i]; s[i] s[j]; s[j] t; i; j--; } }注意我先把strlen的结果强转成int。strlen返回的是size_t如果字符串是空的j会变成-1对应的巨大无符号数循环逻辑直接崩。这种细节在题目数据恰好为空串时特别容易踩。另外scanf(%s, s)不会读空格遇到空白就停了。要读带空格的整行用fgets(s, sizeof(s), stdin)。旧教材里爱提的gets已经被C11移除了因为它没办法限制输入长度太危险不建议再用。3.3 字符二维数组和指针数组怎么选如果需要存储多个字符串有两种常见组织方式// 方式1二维字符数组每行定长 char names[][16] {Alice, Bob, Cathy}; // 方式2指针数组只存地址 const char *names[] {Alice, Bob, Cathy};方式1可以修改每一行的内容但固定16字节会浪费空间字符串变量时必须预留最大长度。方式2适合存放一堆固定不变的文本只存地址不复制字符串内容效率高但它本质上是一个“指针的数组”每个元素是const char*不能通过它去改字符串内容。怎么选看需求需要修改字符串、数量不多且长度可控选二维char数组只是持有大量只读文本选指针数组。main(int argc, char *argv[])里的argv就是指针数组每个元素指向一个命令行参数字符串。4. 数组与函数传参退化与动态数组4.1 为什么数组传参之后总丢长度数组名在作为函数参数时会被编译器当指针处理。void f(int a[])和void f(int *a)在函数内部完全等价。所以你在函数里写sizeof(a)得到的不是数组大小而是一个指针的大小。这是个经典面试题更是现场写代码翻车重灾区。解决方式就是额外传一个len参数int sum(int a[], int n) { int s 0; for (int i 0; i n; i) s a[i]; return s; }调用时sum(arr, size)arr自动退化成指针函数内不再有长度信息。这不算C语言设计失误因为传的本来就是地址。只不过很多新人期待“传数组传整个数组”而C语言告诉你传过去的只是一个首地址。如果你的项目里需要频繁传递“数组长度”可以考虑用一个结构体包住它们typedef struct { int data[100]; int len; } IntArray;这样长度信息跟着结构体走函数不会丢。4.2 动态数组malloc、calloc、realloc与柔性数组需要在运行时确定大小就用动态内存分配。int *p (int*)malloc(n * sizeof(int));分配失败返回NULL所以要检查。calloc会额外把内存清零分配大数组时稍微慢一点但多一层安全感。realloc能在原内存上调整大小是“动态扩展数组”的底层实现。常见面试坑有三个忘记free造成内存泄漏free之后还用原指针访问成了悬空指针realloc失败时直接把返回值赋给原指针导致原地址丢失。正确的realloc姿势int *tmp (int*)realloc(p, newSize * sizeof(int)); if (tmp ! NULL) { p tmp; } else { // 扩容失败p 仍然指向旧内存还有机会处理 }动态数组的关键是平衡方便与责任。C语言没有垃圾回收所有分配都得自己释放。我的习惯是写代码时就在函数注释里写明“谁分配谁释放”否则项目一大排查内存泄漏会非常痛苦。C99还有一个好用的“柔性数组”struct buf { int len; char data[]; };分配时用malloc(sizeof(struct buf) extra)把“头部信息不定长载荷”放进同一块连续内存。这个技巧在网络协议报文解析里很常见。4.3 指针数组、数组指针与动态二维数组这一对概念几乎人人混淆过。先背熟int *p;指向int的指针int **pp;二级指针常用于动态二维数组int (*p)[N];数组指针指向“长度为N的int数组”int *p[N];指针数组有N个元素每个元素是int*。记法[]的优先级高于*所以int *p[N]先跟[]结合说明p是数组数组里装的是int*int (*p)[N]因为括号强制p先跟*结合所以p是指针指向int[N]数组。这个区分不是文字游戏二维数组传参时直接用到。动态二维数组有两种典型写法。一种是int **pp先分配一行指针数组再逐行分配列内存不连续释放时要逐行free。另一种是int (*pp)[COLS] malloc(rows * sizeof(*pp));一次分配内存连续释放也只需要一次free。做图像矩阵运算时我更推荐连续内存方案对缓存友好性能更好。5. 数组驱动的算法排序、查找、队列与树状数组5.1 排序和二分查找的数组写法排序是数组最经典的应用。冒泡排序几乎每个大学都布置过。核心是每一轮遍历不断比较相邻元素把大数往右沉for (int i 0; i n - 1; i) { int swapped 0; for (int j 0; j n - 1 - i; j) { if (a[j] a[j 1]) { int t a[j]; a[j] a[j 1]; a[j 1] t; swapped 1; } } if (!swapped) break; }内层循环条件常见错误是写成j n导致j 1越界所以必须写成n - 1 - i。加一个swapped标志位能在数组已经有序时提前退出省掉无意义的遍历。二分查找只能作用在有序数组上每次丢掉一半时间复杂度O(log n)。数组的随机访问特性让二分查找特别好用但前提是数据已经排序。写的时候注意low、high的边界更新mid low (high - low) / 2能避免low high溢出。5.2 数组模拟队列循环队列的经典题目数据结构课有一道经典题数组q[m]作为循环队列用rear和length分别指示队尾和队列长度。为什么要“循环”因为普通数组队列出队后front会后移队头前面留下空闲空间却没法用造成“假溢出”。循环队列让front和rear走到数组末尾后绕回开头用取模实现循环。已知rear和length队头下标可以这样算int front (rear - length m) % m;入队q[rear] x; rear (rear 1) % m; length;出队if (length 0) { int front (rear - length m) % m; int x q[front]; length--; }用length而不是“空余位置数”来判断空和满最大好处是队空和队满不会出现“同一个位置”的歧义。这套写法不只是应付考试串口环形缓冲区、消息队列、音频DMA缓冲都在用同一套思路。5.3 数组表示树树状数组的实战价值树状数组Fenwick Tree看起来完全不像树它只是用一个一维数组靠下标的二进制lowbit关系维护前缀和。核心两个操作int lowbit(int x) { return x (-x); } void update(int i, int delta) { // 单点修改 while (i n) { tree[i] delta; i lowbit(i); } } int query(int i) { // 前缀和查询 int sum 0; while (i 0) { sum tree[i]; i - lowbit(i); } return sum; }单点修改和前缀查询都是O(log n)。有人会想直接维护一个前缀和数组查询不是O(1)吗但前缀和数组一旦发生单点修改后面所有位置都要重算O(n)。树状数组每次只影响log n个节点适合动态维护的场景。比赛里不想写300行线段树时树状数组是性价比最高的替代。5.4 练习题目里的数组模型热词里那些“九九乘法表c语言”“完数”“字符串逆序PTA”“PAT乙级在霍格沃茨找零钱”等核心都是数组或简单循环的应用。九九乘法表用二维数组存结果或直接双重循环都行。完数的做法是遍历因子并记录到数组帮你理解数组作为“集合”的用法。PAT 1037这类币值换算题可以把三个币值段分别存进数组逐段做减法不够了就向上一段借位逻辑清晰不容易乱。我的建议是刷题时别急着写代码先回答三个问题我需要几个数组下标代表什么边界在哪想清楚再动手通常比直接撸代码快得多。6. 实战案例数组去重、过滤与性能心得6.1 数组去重排序加双指针是关键数组去重也是高频题。朴素做法是双重循环遇到重复就搬移删除O(n^2)。排序加双指针更实用先排序再用一个慢指针维护“逻辑上不重复的位置”快指针负责扫描。int removeDuplicates(int a[], int n) { if (n 1) return n; int slow 0; for (int fast 1; fast n; fast) { if (a[fast] ! a[slow]) { slow; a[slow] a[fast]; } } return slow 1; }这个返回值是去重后的新长度。slow是最后一个不重复元素的下标逻辑长度和物理长度分离C语言数组没有删除方法这种原地覆盖就是模拟删除的标准思路。6.2 数组分割与筛选用out数组或同数组分区“数组分割并显示包含某一字符”这类需求可以新建一个输出数组遍历源数组符合条件的就写进去int out[100], out_len 0; for (int i 0; i n; i) { if (strstr(arr[i], key) ! NULL) { out[out_len] i; } }这里out_len是独立的逻辑长度C语言里没有“push”方法你必须自己维护它。如果需要不另外开空间在同数组内分区思路类似快速排序的partition用两个指针一个从左扫描一个从右扫描把符合条件的交换到前面去。这两种模式在数据处理、嵌入式配置表过滤里很常见。我常跟新人说C语言数组编程的核心思路就是用下标当索引用长度变量当状态所有增删改都是对下标和长度的操作别指望有什么魔法方法。6.3 连续内存与缓存同一份代码差几倍速度数组性能优化的一个重要方向是利用内存连续性。CPU加载数据是以缓存行为单位一般是64字节。你顺序访问数组时相邻数据会在一次缓存加载里全部被用到这就是空间局部性。二维数组按行遍历和按列遍历的差距可能很大// 按行遍历 int sum 0; for (int i 0; i n; i) for (int j 0; j n; j) sum a[i][j]; // 按列遍历 int sum 0; for (int j 0; j n; j) for (int i 0; i n; i) sum a[i][j];两种写法逻辑结果相同但按列访问时每次跳一行缓存命中率低实测大矩阵下可能慢数倍。能连续访问就别乱跳能用数组就别轻易上链表这是C语言优化的朴素真理。7. 常见问题与调试实录7.1 为什么“看起来没事”的越界最阴险数组越界不会每次都崩溃这正是它难排查的原因。可能你只是覆盖了相邻变量程序还能正常跑一阵直到某个关键数据被破坏才突然崩。这种bug定位难度极高。我的经验怀疑数组越界时第一时间用编译器自带的消毒器编译一次。GCC和Clang都支持gcc -fsanitizeaddress -g main.c -o main ./main越界几次之后ASan会直接报出错在哪一行。日常写数组代码有条件就开这个选项发布版本再关掉。不要依赖肉眼审查去找越界成本太高。7.2 初始化的坑局部数组里的“随机数”局部数组不初始化时读到的是栈上的垃圾值。很多新人在PTA或作业里遇到“输出带一串大数字”多半就是忘了初始化。规范做法是定义数组后要么立即初始化要么马上通过循环给前n个元素赋值。使用数组时始终用长度变量控制访问范围不要读“逻辑上还不存在”的位置。7.3 scanf、printf和gdb调试最容易手滑的地方scanf和数组结合的坑很典型。int a[10]; scanf(%d, a[0]);这是元素是普通变量所以要加。但scanf(%s, s);传的是数组名数组名在表达式里自动退化成首元素地址不需要再加。新手写成scanf(%s, s);编译器不报错行为却是未定义的看起来“能用”其实已经错位了。调试时我建议每个人至少会用gdb的这几个命令break、print、x/8wx、bt。查看数组时p a[0]10能直接打印a[0]开始的10个元素比自己一个个数地址快得多。利用gdb看C语言内存分布比单纯写printf调试更直观。最后再分享一个小习惯我在写数组代码时会把n、len这类长度变量和数组声明写在同一行注释里标注“物理容量”和“逻辑长度”。很多崩崩溃、乱序、段错误只要在动手前把这两个数字搞清楚就已经避免了大半。C语言数组这一关说到底就是三件事知道数组在哪、知道数组多大、知道你在读写哪个下标。把这三件事养成肌肉记忆数组就真成了你的老朋友。