数组和矩阵这章,说实话是好多人的“劝退点”。学校里讲的时候喜欢从定义开始:数组是相同类型元素的集合,矩阵是数学概念——听起来都对,但一上手写代码就懵。尤其是面试笔试里那些数组指针、二维数组传参、矩阵快速幂的题,明明背过,换个数就卡壳。我整理这份笔记不是为了抄教材,而是把这些年折腾数组和矩阵踩过的坑、沉淀下来的思路,按“从底层原理到工程落地”的顺序重新捋一遍,从内存布局讲到面试题,从数学运算讲到矩阵键盘,尽量让你看完能直接上手用。
1. 先弄明白:数组在计算机里到底怎么存
1.1 数组不是一个“盒子”,是一段连续的内存
初学的时候很多人把数组理解成“一排抽屉”,这个类比其实很到位,但有个关键点容易忽略:这些抽屉在物理内存里是紧挨着的。声明一个int a[5],编译器做的就是两件事:在栈上划出一块5 * 4 = 20字节的连续空间,然后记录下首地址。访问a[i]的时候,CPU 执行的是取基地址 + i * 4这个偏移运算,这就是数组随机访问 O(1) 的本质——下标就是偏移量。
所以数组下标从 0 开始,不是约定俗成,而是偏移语义。a[0]的偏移是 0,a[1]的偏移是一个元素大小。如果你从 1 开始编号,每次访问都得做一次-1的减法,白白多算一个指令。我看到过很多新手写循环遍历时习惯for (int i = 1; i <= n; i++),然后访问a[i-1],功能没错,但读起来绕,而且容易在边界判断上翻车。
这个连续内存特性,直接带来一个数组最大的优势:局部性好。一次性把整块数据加载到 CPU 缓存里,遍历的时候几乎不会 miss。这也是为什么底层算法、图像处理、矩阵运算里数组仍然霸占着不可替代的地位。你在 Python 里用的list,底层其实也是动态数组:一块连续内存,满了就重新申请更大的块然后把数据搬过去。
1.2 一维数组的指针陷阱:数组名不是指针
面试中最高频的数组题,十有八九和指针有关。很多人背得滚瓜烂熟“数组名就是指针”,这句话严格来说是有问题的。int a[5]中a是一个数组类型的表达式,在大多数情况下会隐式转换成“指向首元素的指针”。但有两个场景它不转换:
sizeof(a)得到的是整个数组占用的字节数(20),不是指针大小(8);&a得到的是“指向整个数组的指针”,类型是int (*)[5],而不是int **。
我自己就在&a上翻过车,当时想遍历数组,写了个int **p = &a;,编译直接报错。正确写法是int (*p)[5] = &a;,此时p + 1跨过的可不是一个 int,而是整整 5 个 int。
再说说到底什么是“指针数组”。别忘了:指针数组本质是数组,只是元素类型变成了指针。比如const char *strs[] = {"hello", "world", "abc"};,这就是一个存放字符串首地址的指针数组。它比char strs[][20]省内存,因为每个字符串按实际长度存放,不用预留 20 字节的定长空间。但代价是:字符串本身不连续,你想要修改某个字符串内部字符时,可能会因为常量区只读而报错。
// 指针数组:数组元素是指针 const char *names[] = {"张三", "李四", "王五"}; // 数组指针:指向数组的指针,可用于二维数组传参 int matrix[3][4]; int (*p)[4] = matrix;实操里最实用的一个技巧是“指针移动输出指定位置的字符串”。核心就是让指针从起始位置不断自增,越过你不想输出的部分。比如字符串"hello world",你从第 6 个字节开始输出,就是puts(str + 6);。注意这里的偏移单位是char的字节数,如果是宽字符或者多字节编码,就不能这么裸移了。
2. 二维数组、字符数组与图的邻接矩阵
2.1 二维数组在内存里其实还是一维的
很多人看到int a[3][4]就自动脑补成三行四列的表格,这没问题,但千万别以为它像是“三排抽屉叠起来”。C/C++ 的内存模型是线性寻址的,二维数组的实际存储方式是行优先:第 0 行全部存完,再存第 1 行,再存第 2 行。所以a[1][2]的地址 = 基地址 +(1 * 4 + 2) * sizeof(int)。
行优先这个顺序,直接关系到代码性能。如果你写了一个双重循环,外层行、内层列,那么访问顺序是连续的,缓存友好。反过来先固定列、内层扫行,每次访问都得跨一整行的距离,缓存 miss 暴涨。这个小细节在大矩阵的转置、矩阵乘法里就是几倍甚至几十倍的性能差距。
Python 的三方库 NumPy 里,numpy.array默认也是行优先(C order)存储,但你可以通过order='F'改成列优先。这里特别提醒一句:Python 自带的多层嵌套list不是真正的二维数组,它每行是独立的 list 对象,内存不连续,做矩阵运算时性能差、内存开销大。所以只要是数值计算,直接上 NumPy,别用原生 list 硬扛。
二维数组与图的关联,是《数据结构 408》里绕不开的考点。图可以用邻接矩阵存:graph[i][j] = 1表示 i 到 j 有边。这个方案的优势是判断两个顶点是否相邻 O(1),但代价是空间复杂度 O(n^2),10000 个顶点的图就存不下 1 亿个 int。所以一般 BFS/DFS 的题,稠密图用邻接矩阵,稀疏图用邻接表(本质就是指针数组,vector<int> adj[N])。
2.2 字符数组的初始化与边界坑
字符数组是面试最愿意挖的坑之一。先记住一条结论:
char s1[] = "abc"; // 数组长度 4,末尾自动补 '\0' char s2[3] = "abc"; // 编译错误或运行时风险,放不下 '\0' char s3[] = {'a', 'b', 'c'}; // 长度 3,没有 '\0',当字符串用会读越界第二种写法 S 级坑人。"abc"字面量实际是 4 个字符,s2只给 3 个空间,编译器允许某些平台这么做,但后续所有以\0为终止条件的函数(strlen、strcpy、printf("%s"))都会越界读。我见过最隐蔽的一种事故是:两个字符数组在栈上挨着放,s3本身没有\0,结果输出s3时把相邻数组的内容也打出来了,排查了半天才意识到是结尾符问题。
二维字符数组,比如char grid[4][10],常用于存储多个字符串。输入的时候要小心:用scanf("%s", grid[i])没问题,但用gets就极危险。而且二维字符数组做函数参数传递时,第二维必须显式给出:void print(char arr[][10]),因为编译器需要知道跨行步长。这个知识点几乎每次校招笔试都会露脸。
2.3 指针数组和二维数组怎么选
如果你的需求是“存 N 个长度不定的字符串”,用指针数组;如果“存 N 个固定长度的字符串”,用二维数组。前者灵活省内存,后者数据紧凑利于内存和逐行遍历。还有一个常见场景是命令行参数:char *argv[]就是个指针数组,每个元素指向一个参数字符串。
C++ 里更推荐直接用std::vector<std::vector<int>>来模拟二维数组,但要注意它每行独立分配内存,性能并不等于真正的连续二维数组。如果是性能敏感的矩阵运算,可以考虑std::vector<int>包一层索引公式idx = row * col + col,这样连续内存、传参也方便。
3. 从数组到矩阵:不只是“改名”那么简单
3.1 把数组用成“动态的”,需要了解的工具
所谓“动态数组”,指的就是运行期长度可变的数组。C 语言里需要用malloc/realloc手动管理,C++ 用std::vector,Python 的list天然动态,Java 用ArrayList。底层原理都差不多:开局分配一块较小的连续内存,元素个数达到容量上限后,重新申请一块更大的空间(通常按 1.5~2 倍增长),把旧数据 memcpy 过去,释放旧块。
动态数组最值得注意的性能点,是插入与扩容的摊销复杂度。虽然扩容时拷贝整块数据是 O(n),但因为扩容次数少,平均到每个插入操作上仍然接近 O(1)。这也是vector在尾部 push_back 很快、但在头部 insert 极慢的原因——头部插入要把所有元素往后挪。
Python 的列表切片是新手最爱踩的一个“玄学点”。a[1:4]取的是一个新列表,修改切片不会影响原列表;但如果你给切片赋值,它就是一个原地修改操作。比如a[1:3] = [9, 9, 9],会直接替换原列表中间两个位置的内容,长度也会变。这和 C 语言的数组完全不同,因为 Python 中的切片本质是拷贝 + 替换。
数组去重最常见的三种方案:哈希表法(Python 里通常写list(set(arr)),但会打乱顺序)、双指针法(先排序再原地去重)、以及维持有序的插入法。笔试里经常要求在 O(n) 时间、O(n) 空间里去重且不改变相对顺序,正确解法是seen = set(); [x for x in arr if not (x in seen or seen.add(x))]。这种写法有点 trick 的味道,但确实验证过。
3.2 矩阵转置、求逆与快速幂:数学运算的代码化
矩阵转置是图像处理、深度学习里非常基础的操作。朴素实现长这样:
void transpose(int src[][N], int dst[][N], int n) { for (int i = 0; i < n; i++) for (int j = 0; j < n; j++) dst[j][i] = src[i][j]; }代码没错,但大矩阵时性能很拉胯。原因还是缓存:写dst[j][i]时,目标矩阵的访问是跳跃的,无法利用缓存行。解决办法是分块转置:把矩阵切成block_size * block_size的小块,先转置块内的元素,再移动块的坐标。这样做的好处是每次操作的数据都能装进缓存,重复使用概率高。
分块大小一般取 8~16 这个量级,配合循环展开效果更明显。我在自己机器上对比过,1024x1024 的 float 矩阵,朴素转置用时约 8ms,分块转置降到 3ms 左右。
矩阵快速幂则是把“数”的快速幂思想推广到“矩阵”。斐波那契数列可以写成:
| F(n+1) | = | 1 1 |^n | F(1) | | F(n) | | 1 0 | | F(0) |于是求第 n 项就变成求矩阵的 n 次幂。要理解快速幂,先想指数的二进制表示。比如M^13 = M^(8+4+1),也就是把 13 拆成二进制1101,从最低位开始,base 每轮自乘为M、M^2、M^4、M^8,遇到二进制位为 1 就把当前的 base 乘到结果里。核心逻辑:
Matrix power(Matrix base, long long exp) { Matrix result = identity(2); // 单位矩阵,相当于整数里的 1 while (exp > 0) { if (exp & 1) result = result * base; base = base * base; // 每轮平方 exp >>= 1; } return result; }这里面最容易错的是:单位矩阵别漏。整数乘法里的单位元是 1,矩阵乘法里的单位元是对角线为 1、其余为 0 的单位矩阵。没有它,结果会莫名其妙少乘几次。
矩阵求逆的代码实现,笔试中反而不常见,因为手写时容易在数值问题、零主元判断上出状况。最稳妥的方法是高斯-约当消元法:把原矩阵和单位矩阵并排放成增广矩阵,通过行变换把左边变成单位矩阵,右边就是逆矩阵。注意每一步要判断主元是否为 0,为 0 就交换行;如果整个列都找不到非零主元,矩阵不可逆。
分块矩阵求逆的公式也比较实用。把矩阵分成四块:
M = [ A B ] [ C D ]其中 A、D 是可逆方阵,那么:
M^-1 = [ (A - B D^-1 C)^-1 -(A - B D^-1 C)^-1 B D^-1 ] [ -D^-1 C (A - B D^-1 C)^-1 D^-1 + D^-1 C (A - B D^-1 C)^-1 B D^-1 ]这个公式我建议别背,会推导就行。核心思想是先把大矩阵方程MX = E拆成两组方程,用块消元法解出来。工程上直接调库(LAPACK、Eigen、NumPy 的numpy.linalg.inv)就行,手写分块求逆主要是在考研题目里出现。
3.3 特征值分解与矩阵特征的那种“分量”
矩阵特征值分解指的是:如果方阵 A 可以对角化,就能写成:
A = P D P^-1其中 P 的每一列是 A 的特征向量,D 是对角矩阵,对角线上是特征值。这个分解的价值在于,计算A^n一下子就简单了:因为(P D P^-1)^n = P D^n P^-1,而 D^n 就是对每个特征值直接取 n 次方。
特征值在工程中的应用多到爆炸。举个最直观的例子:物理里的振动模态分析,系统固有频率就对应特征值;图论里图的特征值能反映图的结构性质;PCA 降维本质就是算协方差矩阵的特征向量,取最大几个特征值对应的方向作为主成分。
用矩阵特征根求函数最值这个“小套路”,本质上是把二次型化成标准形。比如f(x,y) = 3x^2 + 2y^2 - 2xy,写成向量形式v^T A v,A 的特征值就刻画了函数在对应特征向量方向上的“拉伸系数”。最大特征值对应的是这个二次型在所有单位向量上的最大值,最小特征值对应最小值。这个结论在内积空间、优化问题里很常用。
特别提一下“矩阵特征码”这个词,最近在一些系统和逆向分析里经常见到。它的思路是把程序的关键结构(比如函数入口、指令序列、循环模式)转成矩阵特征,再用奇异值分解等方式做“指纹匹配”,本质上和文本特征码是同一个套路,只是载体从字符串换成了矩阵。学代数的时候可能觉得虚,真正用起来你会发现它解决的问题全是“怎么把复杂结构量化成数字”。
4. 矩阵在真实项目里的应用:从图像到嵌入式
4.1 单应性矩阵与图像矫正
图像处理、三维视觉里的“单应性矩阵”,是一个 3x3 的非奇异矩阵,描述同一个平面在两个视角下的投影变换关系。拍一张平面照片,把四个角的点对应关系找出来,就能解出 H。再往后做全景拼接、透视矫正、AR 贴图,全靠它。
单应性矩阵的求解用到了咱们前面说的知识:构造一个线性方程组,然后做最小二乘 / SVD 分解。但这里有个工程坑:对应点如果共线或者分布在一个很小的区域,方程组会变成病态矩阵,解极其不稳定。我试过用四点法求 H,四个点几乎挤在照片同一角,结果矫正出来的图像严重扭曲甚至直接炸成 NAN。
所以实际项目中,首先要避免对应点共线或过于集中;其次是多点拟合时用 RANSAC 剔除错误匹配。顺手提一下“鱼眼镜头的畸变矫正病态矩阵”这个问题,它的根源也是像素坐标在极端畸变下接近线性相关,导致标定矩阵的条件数极大。解决思路是:加正则化项,或者用有理函数模型替代强的多项式模型,别硬解原方程。
4.2 矩阵键盘的工作原理和复键失效问题
嵌入式面试里“矩阵键盘”是个经典话题。原理特别简单:按键按 m 行 x n 列排布,行线接 GPIO 输出,列线接 GPIO 输入。扫描时一行一行拉低,其余行拉高,然后读列电平,哪一列的列线为低电平时,说明该行和该列交叉处的按键被按下。一棵按键对应的就是“行 + 列”两个编号,这本质上就是一个二维数组索引。
但实际项目里经常遇到“同一根矩阵线路上的多个按键集体失效”这种诡异故障。我第一次遇到时以为是程序 bug,结果逐个排查发现是公共端线路断裂。有个小技巧:先把所有行线置为低,读全列,如果某一列恒为高且和它相连的按键全没反应,就先怀疑那根线的物理连接,不要急着改代码。
矩阵键盘能否检测复合按键?简单扫描方案做不到。因为当两个按键同时按下,比如 (row1, col1) 和 (row2, col2) 都导通,扫描时会出现“鬼键”:row1 col2 和 row2 col1 也表现为导通,因为电流通过按键串走了。要解决这个问题,硬件上加二极管防止反向电流,或者换用“逐键扫描 + 行列翻转”的方式。如果要求不高的场景,不检测复合键也算一个可接受的行为。
4.3 混淆矩阵:看一眼就知道模型哪里不行
“多分类混淆矩阵”在机器学习里太常用了。它是一个 N x N 的矩阵,行是真实类别,列是预测类别,对角线上的值就是预测正确的个数。Python 里一行代码就出来:
from sklearn.metrics import confusion_matrix cm = confusion_matrix(y_true, y_pred, labels=['cat', 'dog', 'bird'])混淆矩阵的价值不只是算准确率,而是它能直接暴露“模型偏向哪一类错误”。比如一个三分类模型,label 是猫狗鸟,你会发现“狗”类里有很多“猫”的样本被误判。这说明模型在狗和猫之间区分能力弱,特征提取需要优化。除此之外,和二分类的 Precision / Recall 结合看,还能判断是该调分类阈值,还是该加更多训练数据。
5. 常用工具与代码片段:数组到矩阵的拼接技巧
5.1 从.mat文件提取数据做差求绝对值
针对课题组前辈留下的.mat文件,很多人第一次用 MATLAB 或者 Python 时完全不知道从哪下手。Python 侧推荐scipy.io.loadmat:
from scipy.io import loadmat data = loadmat('record.mat') A = data['matrixA'] # 取出变量名为 matrixA 的矩阵 B = data['matrixB'] diff_abs = np.abs(A - B)需要注意一点:loadmat默认会把 MATLAB 的结构体转为嵌套的dict,如果你想要的变量名里带下划线,要先打印data.keys()看看真实键名,避免取错。矩阵数据如果带了squeeze_me=True参数,维度会被压缩,小心别把形状搞没了。
5.2 JavaScript 取数组元素与数组增删操作
前端场景里“取数组元素”是日常操作。访问某个位置用arr[index],但不要被arr[0]有值误导,越界访问得到undefined,不会报错。判断数组是否为空用arr.length === 0,不要拿if (arr)来判断,因为空数组也是一个 truthy 值。
数组增加元素的方法:尾部push(),头部unshift(),指定位置splice(index, 0, item)。对应删除:尾部pop(),头部shift(),指定位置splice(index, 1)。还有一个常见的坑是直接用delete arr[2],它只是把那个位置变成空槽,但length不变,遍历的时候会跳过,调试时经常会把人绕晕。要用就用splice。
Array.from和展开运算符也是日常开发利器,比如深浅拷贝和类数组转数组:
const arr = [1, 2, 3]; const copy1 = [...arr]; // 浅拷贝 const copy2 = Array.from(arr); // 同样浅拷贝 const rows = Array.from({length: 3}, () => new Array(4).fill(0)); // 3x4 矩阵5.3 MATLAB / VBA / C 语言的数组差异速查
VBA 里的数组比较特殊,下标默认从 0 开始,但如果声明Dim arr(1 To 5) As Integer,下标从 1 开始。两种写法混合使用很容易出 bug。VBA 数组用UBound、LBound取边界。
C 语言里字符串数组和 char 数组本身就是一家人,但你要分清char *p = "hello"和char arr[] = "hello"。前者 p 指向常量区,p[0]='H'大概率运行时报错;后者是栈上变量,可以修改。这个差异是很多刚入门的人第一次接触“段错误”的根源。
MATLAB 的好处是矩阵是原生类型,A(1:3, 2:5)直接切片,不需要像 Python 那样记一堆语法。但 MATLAB 正是因为它太自然,很多人忽略了“所有矩阵默认 double 存储”这个开销,大矩阵运算时容易内存爆炸。要显式用single或int8来省内存。
6. 常见问题速查:那些坑我一个个踩过
| 问题现象 | 根因 | 正确做法 |
|---|---|---|
scanf("%s", str)输入带空格的字符串只拿到一半 | %s以空白字符分隔 | 用fgets(str, len, stdin)或scanf("%[^\n]", str) |
| Python 列表切片结果和预期不符 | 切片是左闭右开:a[1:3]只含下标 1、2 | 记住start:end不包含 end |
C 中a[5]但访问a[5]没报错 | 未定义行为,编译器不保证报错 | 越界访问后果自负,善用 AddressSanitizer |
| 矩阵乘法结果全部是 nan | 中间数溢出或者除数接近 0 | 检查输入数据量级;除法前判绝对阈值 |
| 混淆矩阵打印不出来,报 ValueError | y_true 和 y_pred 的类别标签不一致 | 给confusion_matrix参数里的 labels 显式传入全部类别 |
| 矩阵转置性能慢到离谱 | 列访问破坏缓存行 | 用分块转置或者先把整个矩阵转成行主序再处理 |
MATLAB 里A*B报维度错 | 把点乘和矩阵乘弄混了 | 逐元素相乘用.*,矩阵乘法用* |
vector扩容频繁导致卡顿 | 反复触发 allocate - copy - free | 提前reserve预留容量 |
| 结构体数组的地址对齐问题 | 编译器默认对齐和 sizeof 不一致 | 用offsetof验证,必要时#pragma pack |
| 二维数组函数传参编译报错 | 第二维必须明确 | void f(int arr[][4]) |
Array.from生成的矩阵行引用相同 | new Array(3).fill([])会让三行指向同一个数组 | 用Array.from({length:3}, () => []) |
数组和矩阵的本质都是“用连续内存和下标来描述批量同类型数据”,但数组更多是语言层面的存储结构,矩阵则是数学运算的实体。千万别觉得把数组弄明白了矩阵就自动会了——矩阵还绑着运算规则、数值稳定性、甚至硬件的缓存行为。如果你现在还在学基础阶段,多写几遍矩阵乘法、转置、求逆的实现,把指针和内存布局画一遍,后面理解深度学习、图形学里的东西会顺畅得多。
我个人经验:数组相关的问题十有八九出在“边界”上,矩阵相关的问题十有八九出在“运算规则”上。每次遇到诡异 bug,先从这两点入手排查,通常不用看算法逻辑。最终你会发现,这些概念的本质都是相通的:知道数据放在哪,知道怎么访问它,知道怎么用数学运算把它组合起来,剩下就是工程问题而已。