数组大概是每种编程语言里出场率最高的数据结构,但“会用”和“真正理解”之间,往往隔着一道很深的认知鸿沟。前阵子帮人 review 代码,连着遇到两个特别典型的错误:一个是把二维数组写成 int a[][] 当函数形参,编译直接不通过;另一个是在 C++ 里用 memset 想给 int 数组全部置 1,结果打印出来全是 16843009。这类问题真不是手误,而是对数组的理论基础没吃透。这篇就把数组这件事完整捋一遍:从内存模型和下标原理,到一维、多维数组的正确初始化和传参,再到指针数组和数组指针的辨析,最后落到不同语言里数组的实际操作和算法题里的高频用法。适合刚开始学数据结构的朋友,也适合写了好几年代码但偶尔被数组“摆一道”的开发者。
1. 数组的底层契约:从内存模型理解“连续”与“下标”
1.1 数组定义到机器实现:一个地址公式解决所有问题
数组的教科书定义是“相同类型元素的有限集合”,这个定义本身没什么信息量。真正重要的是机器层面的实现约定:元素按声明顺序一字排开,存放在一段连续的内存里,每个元素占用相同的字节数。
只要记住首地址 base 和元素类型大小 size,任意下标的地址都可以直接算出来:
c复制addr(a[i]) = base + i * size
这个公式看起来平平无奇,但它解释了两件最重要的事。
第一,为什么数组访问是 O(1) 的?因为不需要遍历、不需要查找,一次乘法一次加法就能定位到任意元素。链表做不到这一点,它必须从头结点一个个跳过去。
第二,为什么数组下标从 0 开始?因为从 0 开始,下标本身就等于偏移量,a[i] 的地址就是 base + i * size,不需要任何额外减法。如果从 1 开始,每次都要算 base + (i - 1) * size,多一次运算,指令上不划算。C 语言从 0 开始不是拍脑袋,是直接对应机器寻址的天然逻辑。很多从 Fortran 或 MATLAB 转过来的同学不习惯这一点,但理解了地址模型就会明白,这不是“规定”,是“最优解”。
1.2 连续内存带来的两个红利:随机访问与缓存局部性
连续内存除了让访问变快,还承包了性能优化里一个很核心的东西——缓存局部性。
CPU 读内存不是一次读一个字节,而是按缓存行(常见 64 字节)成块加载。数组在内存里连续排列,遍历时第一个元素加载的同时,后面相邻的元素大概率已经被一起拉进缓存了,后续访问全是命中。链表就惨了,节点散落在内存各处,遍历时频繁触发缓存未命中,在高数据量下性能差距能到几倍甚至十几倍。
这个特性的直接推论是:遍历二维数组时,按行遍历比按列遍历快得多。同样是访问所有元素,按列遍历时每次都要跨过一整行的距离,缓存局部性被彻底破坏。这个问题在 LeetCode 上不常考,但在图像处理、矩阵计算、Simulink 里的大数组信号处理中非常实际。
1.3 数组名、数组类型与“退化”陷阱
C 语言里数组名在大多数表达式中会“退化”为指向首元素的指针,这是无数 bug 的来源。但有两个例外:
c复制int a[10];
sizeof(a); // 40,整个数组的字节数
&a; // 类型是 int(*)[10],指向整个数组的指针
函数传参时最容易被坑:数组进入函数后,形参退化成指针,sizeof(arr) 返回的是指针大小(8 字节),而不是数组大小。所以多函数里维护数组长度,要么额外传一个长度参数,要么用结构体包起来,要么在 C++ 里用 std::array。
还有一个被反复问到的问题:“C 语言数组变量的类型转换”。比如把 char[4] 里的四个字节转成一个 int。很多人直接写 int value = *(int*)bytes;,这是不严谨的,涉及对齐和字节序问题,更规范的做法是用 memcpy:
c复制#include <stdio.h>
#include <string.h>
int main(void) {
unsigned char bytes[4] = {0x78, 0x56, 0x34, 0x12};
int value;
memcpy(&value, bytes, sizeof(value));
printf("0x%08x\n", value); // 小端机器输出 0x12345678
return 0;
}
直接强转的写法如果内存没对齐,在部分架构上会直接崩溃;即便不崩溃,也属于未定义行为。memcpy 不是“慢”,编译器通常会把它优化成一条移动指令,安全又高效。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 一维数组的初始化与清空:C/C++ 里最容易翻车的基础操作
2.1 静态初始化规则:你以为是垃圾值,其实是 0
C/C++ 数组初始化的坑,比大多数人以为的要多。先看最常见的几种:
c复制int a[5] = {1}; // a[0] = 1, a[1] ~ a[4] 全部为 0
int b[5]; // 局部变量,未初始化,值是垃圾
int c[5] = {}; // C++ 中全部为 0,C 中注意编译器扩展
int d[5] = {[0] = 1, [3] = 2}; // C99 指定初始化器
int a[5] = {1}; 是最典型的一个坑——很多人以为只初始化了第一个元素,剩下的是随机值,实际上只要提供了至少一个初始化值,剩余元素会被标准强制补 0。反过来,int b[5]; 如果是局部变量,它的值是垃圾;但如果是全局变量或 static 修饰的局部变量,则自动初始化为 0。
C 语言里定义定长数组还有一个传统手段——宏定义。#define ARRAY_SIZE 5 然后 int arr[ARRAY_SIZE];。宏是预处理阶段的文本替换,不参与类型检查,所以容易踩副作用坑。C++ 里更推荐用 constexpr:
cpp复制constexpr int N = 5;
int arr[N];
constexpr 有类型、有作用域、能参与编译期计算,比 #define 安全得多。C 语言里虽然没有 constexpr,但可以用枚举常量来定义数组长度。
2.2 字符串数组初始化:一个 '\0' 引发的越界
字符串数组是 C/C++ 里的一等公民,也是最容易出事的区域。看这三行:
c复制char s1[] = "hello"; // 6 字节,含末尾 '\0'
char s2[] = {'h', 'e', 'l', 'l', 'o'}; // 5 字节,没有 '\0'
char s3[10] = "hello"; // 10 字节,其余补 '\0'
s1 和 s3 用 printf("%s") 没问题,因为都有 '\0' 结尾。s2 就麻烦了,printf("%s", s2) 会一直读到遇到内存里的第一个 0x00 为止,这就是典型的字符串越界读。
另一个易混淆的是指针版:
c复制const char* s4 = "hello"; // 字符串字面量,只读,不可修改
s4 指向的是只读区,任何 s4[0] = 'H' 的尝试都是未定义行为,多数平台会段错误。写代码时想清楚:需要修改内容用数组,只做只读引用用 const char*。这是 C++ 字符串数组初始化的核心取舍。
2.3 清空 buffer 的几种方式与 memset 的字节陷阱
“在 Qt Creator 中 C 语言对 buffer 数组清空有哪几种方式”这类问题经常出现,其实答案不复杂:
memset(buf, 0, sizeof(buf)):最常用,按字节填充bzero(buf, sizeof(buf)):POSIX 函数,本质和 memset 一样for循环逐元素赋值:适合非 0 填充或清空结构体数组- 声明时
= {0}:只用于初始化,不能对已定义数组二次赋值清空
重点说 memset 的坑。memset 是按字节填充的,如果你写:
cpp复制int arr[5];
memset(arr, 1, sizeof(arr));
你得到的不是 5 个 1,而是每个 int 的四个字节全变成 0x01,也就是 0x01010101,十进制是 16843009。这就是我开头说的那个 bug 的来历。想给 int 数组统一赋值为某个非零值,老老实实用 std::fill 或 for 循环,别用 memset。
清空数组还有一个常见误区:C 的数组一旦定义,长度不可变,没有“删掉某个元素”的概念。所谓“清空”,本质是覆盖写,把旧值全部覆盖成 0 或别的标记值。C++ 里如果用的是 std::vector,clear() 是改变逻辑长度,vector 本身还在,这跟 C 数组清空是两回事。
2.4 越界访问:为什么编译器不报警
C/C++ 数组越界访问是未定义行为,但编译器默认不检查。原因很简单:检查要付出运行时开销,C/C++ 的设计哲学是“信任程序员,追求极致性能”。后果由程序员自己扛。
栈上数组越界写得深一点,可能把相邻局部变量甚至返回地址改掉,表现成“某个无关变量的值莫名其妙变了”;堆上数组越界写则可能破坏堆管理元数据,表面不出错,但 free() 或 delete 时炸给你看。这就是为什么很多面向对象语言的数组越界会抛异常,而 C/C++ 的越界问题只能用工具兜底:
- 编译选项加
-fsanitize=address,运行时检测越界访问 - 用 valgrind 跑一遍,定位内存问题
- C++ 里用
std::array和at(),越界时抛异常
我把这当成一条铁律:写 C/C++ 数组操作时,先问自己“这个下标会不会越界”,不要指望编译器替你兜底。
3. 多维数组的内存布局与函数传参:为什么第二维是硬性要求
3.1 二维数组的真实内存画像:一条线,不是一张网
很多初学者把二维数组想象成一个“方格网”,这个直觉要纠正。int a[3][4] 的本质是:一个包含 3 个元素的数组,每个元素又是一个 int[4] 数组。这 12 个 int 在内存里是一条直线排下来的,按“行优先”存储:
code复制a[0][0] a[0][1] a[0][2] a[0][3] a[1][0] a[1][1] ... a[2][3]
所以 a[i][j] 的地址公式是:
c复制addr = base + (i * 4 + j) * sizeof(int)
这个公式里必须出现“4”——也就是列数。行优先存储是 C/C++ 的特性,Fortran 是列优先,两种布局在跨语言传大数组时容易出问题,但这属于另一个话题。
3.2 为什么函数形参必须写第二维
二维数组传参,初学者最常见的报错是写 int a[][]。编译器直接拒绝,原因就在上面的地址公式里:计算 a[i][j] 的偏移需要列数,形参里不写列数,函数内部根本没法定位元素。
正确的写法是:
c复制void func(int a[][4], int rows);
// 等价于
void func(int (*a)[4], int rows);
第二维 4 必须写,第一维 rows 可省,因为第一维只用来约束遍历范围,不参与偏移计算。
这里要专门纠正一个长期存在的误区:二维数组名不是 int**。二维数组名退化成指针时,类型是 int (*)[4],即指向“包含 4 个 int 的数组”的指针;int** 则是指向“int 指针”的指针,两者内存布局完全不同。把二维数组直接传给 int** 形参,编译可能会警告,运行必然错乱。
3.3 动态二维数组:三种写法和一个推荐
真正需要根据运行期数据决定行列大小的场景,必须用动态分配的二维数组。常见有三种:
方案一:指针数组(int**)
cpp复制int** a = new int*[rows];
for (int i = 0; i < rows; i++) {
a[i] = new int[cols];
}
// 释放时逐行 delete[]
每一行单独 new,内存不连续,访问时多一次指针跳转,缓存不友好,释放还容易漏。
方案二:一维数组铺开
cpp复制int* a = new int[
