C语言里有个概念几乎所有教材都把它放在变量和循环之后讲但每年还是有一大批初学者在它身上栽跟头这就是数组。我见过太多人学指针、结构体、链表时自我感觉良好回头却被一道“为什么函数里算不出数组长度”的题问得怀疑人生根子往往就是数组的基础没打牢。这篇文章不打算绕弯子只想把数组最基础、也最容易被忽略的部分彻底捋一遍——它解决什么问题、怎么声明、怎么初始化、怎么访问以及内存里到底发生了什么。适合刚学完基本语法、准备开始啃数组的同学也适合学了一段时间、想回来补基础的开发者。看这篇文章之前你只要会变量、printf和for循环就够了剩下的我会一层层拆开讲。1. 从一个最简单的需求说起为什么要引入数组1.1 没有数组的日子30个变量写到手软设想一个特别常见的场景你要统计班里30个人的C语言期末成绩算平均分并找出最高分。如果没有数组你得写成这样int s1, s2, s3, s4, s5, s6, ...;声明完变量之后还得逐个赋值算总分时更是噩梦sum s1 s2 ... s30写30次加法眼睛都花了。要是人数从30变成300这套代码基本就得重写复制粘贴都会把人逼疯。我当年带新人时经常让他们先试着用纯粹的变量完成这个需求体验一下什么叫“重复代码写到想砸键盘”。这不是在刁难人而是只有亲手写过那种又臭又长的变量列表你才会真正感激数组这种结构的存在。数组做的事情非常朴素把一群同类型的变量合并成一个名字用编号区分它们再配合循环批量处理。就像一栋宿舍楼楼号是名字房间号是下标每间房里住的数据类型都一样。1.2 数组的本质同类型数据的连续集合严格来说数组是一组相同类型元素的集合在内存中占据一块连续的空间。这句话里藏着三个关键点相同类型int数组里全是intdouble数组里全是double不能混着放。想混着放需要结构体那是另一个话题。连续空间元素一个挨一个中间没有空隙顺序紧紧相连。固定顺序每个元素都有自己唯一的位置编号这个编号就是下标从0开始。这三个特点决定了数组的全部行为也决定了它的优势和局限。优势是随机访问快——想拿第i个元素编译器算一下地址就到手了不用像链表那样从头遍历局限是一旦创建长度基本固定不好动态伸缩。很多初学者没意识到“连续”这两个字意味着什么其实后面所有跟数组有关的坑几乎都能追溯到这段“连续内存”上去。1.3 数组和普通变量的适用边界搞清楚了数组是什么还得知道什么时候该用它。我把数组和普通变量做了一张简单对比场景普通变量方案数组方案存3个成绩int a, b, c;int s[3];存30个成绩手写30个变量名int s[30];计算总和sum a b c ...for循环累加找最大值逐个比较代码冗长for循环加一次比较结论一句话只要数据是“同一类型、数量较多、需要统一处理”优先考虑用数组。反过来如果数据类型各不相同比如一个学生的姓名、学号、成绩那定义成结构体更合适。这种边界感到后期写项目时会越来越明显初学阶段先把数组用熟就好。2. 数组声明类型、名字、长度这次把语法一次讲透2.1 声明语法的拆解数组声明的基本形式是类型 数组名[长度];实际写出来就是int scores[30]; // 30个int double weights[5]; // 5个double char name[20]; // 20个char这行代码可以拆成三部分看类型决定每个元素占多大空间、能存什么值。int是4个字节double是8个字节char是1个字节。数组名和普通变量命名规则一样字母数字下划线不能以数字开头不能和关键字冲突。方括号里的长度表示元素个数必须是正整数。比如int scores[30];的意思是向内存申请30个连续的int空间每个空间都能放一个int值。这里有个容易忽略的细节长度是元素个数不是字节数。int scores[5]声明的是5个元素占20个字节而不是5个字节新手经常把这两个数搞混后面讲内存时还会再提到。2.2 长度到底能不能是变量这是基础阶段最容易被问住的一个问题“老师数组长度可以写变量吗”标准答案是在传统C89/C90里长度必须是编译期常量C99引入了变长数组VLA允许运行时才确定长度。编译期常量的写法有这么几种int a[10]; // 直接写数字 #define SIZE 5 int b[SIZE]; // 用宏 int c[3 2]; // 常量表达式编译时就算出5C99变长数组的写法则是这样int n; scanf(%d, n); int arr[n]; // VLA运行时确定长度但这里有个实操提醒VLA不是所有编译器都支持MSVC默认就不支持就算支持的编译器如果n特别大栈空间不够程序会在运行的一瞬间崩掉。而我见过太多初学者在栈上声明一个几百万长度的数组然后来问为什么程序一启动就“炸”了其实就是数组太大把栈撑爆了。我现在教新人时会建议先老老实实写常量或宏把数组本身的基本功练扎实等理解了内存分布和堆栈区别再去碰VLA。2.3 声明阶段最容易踩的三个坑声明数组看起来简单但实际写代码时有三类错误特别常见第一个坑长度写成负数或0。int arr[-1];编译器直接报错int arr[0];在很多编译器里是零长数组扩展不是标准C不要依赖它。第二个坑数组名和普通变量重名。同一个作用域里先写了int arr;再写int arr[5];编译器会告诉你重复定义。命名时养成习惯数组名尽量带复数含义比如scores、weights能减少不少麻烦。第三个坑试图整体赋值。数组之间不能直接赋值int a[5]; int b[5]; b a;编译不过。C语言只允许对数组的元素逐个操作不允许把整个数组当普通值传来传去。这一点很多人直到写函数参数时才会真正体会到。3. 初始化数组给每个元素一个确定的初始值3.1 五种常见的初始化写法声明数组之后下一步自然是往里面放值。直接初始化有五种常见写法我把它们列出来int arr1[5] {1, 2, 3, 4, 5}; // 完全初始化5个元素都给了值 int arr2[5] {1, 2}; // 部分初始化剩下自动补0 int arr3[] {1, 2, 3}; // 省略长度编译器数出长度是3 int arr4[5] {0}; // 全部清零最常用的兜底写法 int arr5[5] {[0] 10, [2] 30}; // 指定下标初始化C99特性第五种写法很多人可能没见过它允许你只给第0个和第2个元素赋值其他元素自动补0。说实话日常开发里这种写法用得不多而且可移植性一般我建议知道有这回事就行主力记住前四种。有一个细节要特别强调省略长度初始化只在“同时提供初始化列表”时合法。int arr[];这种写作在编译期就是错的编译器根本不知道该分配多少空间。3.2 局部变量不初始化到底会发生什么很多人想当然认为“数组不初始化里面全是0”这是初学者最大的误区之一。我在这里把规则说透全局数组不初始化时自动为0。static局部数组不初始化时也自动为0。普通局部数组不初始化时是“不确定值”本质上是栈上残留的垃圾数据。我在调试时经常看到有人定义一个局部数组没初始化就直接输出结果打印出巨大的随机数然后一脸迷茫地怀疑编译器坏了。其实编译器没坏是你没给初始值。所以我的实战经验是局部数组声明时如果还拿不准该填什么至少给一个空初始化列表兜底int arr[100] {0};这样至少保证数组内容可预期。另外部分初始化后剩余元素自动补0是C标准明确保证的不是碰巧可以放心用。比如int arr[5] {1, 2};后三个元素一定是0。3.3 字符数组和字符串一字之差天壤之别字符数组在所有数组类型里非常特殊因为字符串的本质就是字符数组但它还多了一个看不见的尾巴。看这几种写法char s1[] hello; // 长度是6最后有\0 char s2[5] hello; // 长度不够\0被丢弃 char s3[] {h,e,l,l,o}; // 5个字符不是字符串 char s4[5] {h,e,l,l,o}; // 没有\0不是字符串hello看着是5个字符实际上内存里是6个字节最后一个字节是不可见的\0这是字符串的结束标志。sizeof(s1)的结果是6不是5这个数字经常把新手坑到。最有意思的风险在s2和s4上数组里没有\0但它确实存了字符。你如果转头用printf(%s, s2)去打印printf会从数组开头一直往后面的内存读直到某处碰巧遇到一个0字节才停下来。这属于非常经典的越界读输出内容完全不可控可能后面是乱码也可能直接崩溃。初学阶段我送大家一个建议只要遇到字符数组一律按字符串的标准来写——长度多留1个字节给\0并且主动给最后一位赋\0。宁可多算一位也别丢尾巴。4. 下标访问与遍历数组的正确打开方式4.1 为什么下标从0开始数组第一个元素的下标是0不是1。很多新人别扭但C语言这么设计是有底层原因的。数组元素在内存里连续排列第i个元素的地址计算方式是第i个元素地址 起始地址 i × 每个元素占用的字节数第一个元素到起始地址的偏移量是0所以它理所当然排在0号位置。从0开始编号CPU计算地址时不需要再做“减1”那一步指令更简洁。更重要的是C语言天生贴近内存模型下标就是偏移量的直接表达。如果你把“第5个元素”下意识写成arr[5]那到手的是第6个元素。记住一句话下标是偏移量不是序号。这个思维方式越早建立后面学指针越容易。4.2 用sizeof计算数组长度访问数组前先得知道数组有多少个元素。最经典的一种求长度方式是int arr[] {10, 20, 30, 40, 50}; int len sizeof(arr) / sizeof(arr[0]); // 结果是5sizeof(arr)在编译期求值返回的是整个数组占用的字节数sizeof(arr[0])返回一个元素占用的字节数。两者相除就是元素个数。这个写法在数组定义所在的函数里百试百灵。但这里埋着一个天坑数组一旦作为函数参数传递就会“退化”为指针此时sizeof(arr)求出来的是指针大小在64位机器上通常是8而不是数组大小。很多人因此在函数里算出8 / 4 2然后又想不通“明明传了5个元素为什么函数里长度是2”。这个问题的完整原因等讲到指针和函数传参时再展开现在你只要记住在定义处用sizeof没问题在参数里别这么干。4.3 遍历的经典写法与陷阱遍历数组最标准的写法int arr[] {10, 20, 30, 40, 50}; int len sizeof(arr) / sizeof(arr[0]); for (int i 0; i len; i) { printf(%d , arr[i]); }循环条件i len是无数老程序员验证过最稳妥的写法。常见的错误版本是把条件写成i len多转了一圈最后一次访问arr[len]而那个位置已经越界了。数组有效下标范围是0到len-1循环条件用就天然把这个范围圈住了。另一个常见的低级错误是从1开始循环比如有人写for (int i 1; i len; i)结果把第一个元素漏掉还因为再次越界。我的经验是写遍历前先默念一遍“有效下标0到len-1”再动笔写循环条件这个习惯能帮你省掉大量调试时间。5. 数组的内存本质连续、定长、越界5.1 数组名到底是地址还是指针这个问题我每次讲数组都会被问到“数组名是不是指针”答案是不完全一样。在绝大多数表达式里数组名会“退化”为首元素地址也就是等于arr[0]比如你写printf(%p, arr)打印的就是首元素地址。但数组名本身不是指针变量它没有自己的存储空间也不能给它赋值。更微妙的是arr和arr的区别int arr[5] {0}; arr 1 // 指向第1个元素跳过4个字节 arr 1 // 指向数组末尾的后面跳过整个数组20个字节这种区别在以后做指针运算时特别容易踩坑。我不建议现在就把所有细节都塞进脑子但请先建立一个概念arr代表首元素地址arr代表整个数组的地址两者类型都不一样。以后学指针时回头再看这段你会觉得这层窗户纸捅破得非常及时。5.2 元素大小、总大小和长度的换算我用一张表总结数组和sizeof相关的常见表达式方便你对照记忆表达式含义举例int arr[5]sizeof(arr)整个数组占用的字节数20字节假设int为4字节sizeof(arr[0])单个元素占用的字节数4字节sizeof(arr) / sizeof(arr[0])元素个数5arr 1跳过整个数组的地址比arr大20字节这里有一个基础但关键的认知sizeof的所有计算都在编译期完成它不会在运行时去“数一遍”数组。所以如果数组长度本身是运行时才知道的VLAsizeof的行为就需要单说如果是普通数组这个计算是零成本的不需要担心性能。5.3 越界读写为什么特别危险C语言不会检查数组下标。你写arr[10]去访问一个长度为5的数组编译能过运行也大概率不报错。这种“不报错”恰恰是最危险的地方因为程序会在你毫无察觉的时候悄然越界。越界读的后果可能是读到相邻变量、栈上残留数据或者其他完全不相关的内存输出乱码越界写更凶它可能直接覆盖掉相邻变量的值。比如你在函数里声明了数组a和变量b编译器刚好把b放在a的紧后面你越界写a[5] 100时b的值就被悄无声息改掉了接下来程序所有用到b的地方都会出现莫名其妙的错误。更要命的是局部变量的布局由编译器决定换一个编译优化等级内存排列就变了。这种越界bug可能今天不出现、明天出现调试器里还特别难复现。所以越界问题永远要靠程序员自己兜住工具帮不了你太多。我的习惯是循环里访问数组前先问自己一句“i的最大值会不会超过len-1”。这个问题多问几次比事后调试节省十倍时间。6. 练习自查用三个小例子把基础焊死6.1 例一求总分和平均分学会了数组的声明、初始化和遍历就可以解决开头那个成绩统计问题了#include stdio.h int main(void) { int scores[] {78, 82, 91, 65, 88, 95}; int n sizeof(scores) / sizeof(scores[0]); int sum 0; for (int i 0; i n; i) { sum scores[i]; } printf(总分: %d\n, sum); printf(平均分: %.2f\n, (double)sum / n); return 0; }这段代码练习了两个核心点一是用sizeof求数组长度二是用for循环逐个访问元素。注意算平均分时我把sum强转成了double否则两个整数相除结果还是整数会丢掉小数部分。这是C语言整型运算的一个经典细节写代码时一定要记得处理。6.2 例二找出最大值及它的位置这道题在数组题里出现频率极高也是面试和作业常客#include stdio.h int main(void) { int arr[] {3, 8, -2, 10, 7, 4}; int n sizeof(arr) / sizeof(arr[0]); int max arr[0]; int pos 0; for (int i 1; i n; i) { if (arr[i] max) { max arr[i]; pos i; } } printf(最大值: %d, 位置: %d\n, max, pos); return 0; }我特意把max初始化为arr[0]而不是0这是很多人容易踩的坑。如果数组里全是负数初始化为0就会导致最大值永远算不出来。正确思路是拿第一个元素当“候选人”然后从第二个元素开始比较。6.3 例三倒序输出数组倒序输出是理解下标的绝佳练习因为它会强迫你反过来思考边界#include stdio.h int main(void) { int arr[] {1, 2, 3, 4, 5}; int n sizeof(arr) / sizeof(arr[0]); for (int i n - 1; i 0; i--) { printf(%d , arr[i]); } printf(\n); return 0; }循环从n - 1开始一直递减到0输出顺序正好反转。这个题目虽然简单但能把i 0这个边界条件刻进脑子里以后写倒序遍历就不会手滑。6.4 初学数组自查清单文章最后我把自己平时检查初学者代码时最常用的清单整理出来你可以写完练习后逐项对照数组长度是否用了常量或宏而不是含义不清的魔法数字普通局部数组是否已经初始化至少给了{0}兜底字符数组是否给\0留出了位置循环条件用的是 n还是 nsizeof求数组长度的代码是否写在数组定义所在的作用域有没有对数组名直接整体赋值访问数组时心里是否清楚下标是从0开始、到len-1结束我个人带新人时最常说的一句话是数组不是考点是工具。基础阶段与其死背语法不如多写几个数组小程序把“同类型、连续、固定长度”这九个字在脑子里建立起画面感。等你哪一天用指针操作数组不再发怵回头再看今天这篇文章会发现所有内容都是顺着这个画面感长出来的。下一篇我会接着讲数组作为函数参数时那些更微妙的退化和传址行为先把这一篇的代码亲手跑一遍感受才算真的到位。