数据结构——数组
数组
一、数组的定义
1. 什么是数组?
数组(Array)是由一组类型相同的数据元素构成的集合,每个元素通过下标(Index)来唯一标识。数组是一种最基础的数据结构,几乎所有的编程语言都内置支持。
从逻辑结构上看,数组是一种线性结构——元素之间是一对一的线性关系。但数组与之前讨论的线性表有一个重要区别:
- 线性表:元素个数动态变化,支持频繁的插入、删除操作
- 数组:一旦定义,大小通常固定不变(静态数组),主要操作是按下标读写元素
2. N维数组的定义
(1)一维数组
一维数组是最简单的数组形式,可以看作是 $n$ 个相同类型元素的线性序列:
$$A = (a_0, a_1, a_2, \dots, a_{n-1})$$
在 C 语言中定义为:
int A[10]; // 包含 10 个 int 类型元素的一维数组
(2)二维数组
二维数组可以看作是一维数组的数组——即每个元素本身又是一个一维数组。逻辑上可以看成是一个矩阵(Matrix):
$$
A_{m \times n} = \begin{pmatrix}
a_{0,0} & a_{0,1} & \cdots & a_{0,n-1} \
a_{1,0} & a_{1,1} & \cdots & a_{1,n-1} \
\vdots & \vdots & \ddots & \vdots \
a_{m-1,0} & a_{m-1,1} & \cdots & a_{m-1,n-1}
\end{pmatrix}
$$
其中 $m$ 为行数,$n$ 为列数。
在 C 语言中定义为:
int A[3][4]; // 3 行 4 列的二维数组
(3)N维数组
N维数组可以递归地定义为:N维数组的每个元素是一个 N-1 维数组。当 $N = 0$ 时,称为标量(Scalar,即单个数据元素)。
实际编程中,超过三维的数组使用较少,因为多维数组在内存中仍然是线性存储的,高维数组的语义主要体现在逻辑上的"嵌套"关系。
3. 数组的存储方式
数组采用顺序存储方式——所有元素在内存中占用一片连续的存储空间。由于数组的大小固定,没有插入和删除操作(或者说插入删除代价太高),因此顺序存储非常适合数组。
(1)一维数组的存储
一维数组的元素在内存中按下标顺序依次存放。假设第一个元素的地址为 $\text{Loc}(a_0)$,每个元素占 $L$ 个存储单元,则第 $i$ 个元素的地址为:
$$\text{Loc}(a_i) = \text{Loc}(a_0) + i \times L$$
这在之前的一篇笔记——线性表的顺序存储中已经详细讨论过。
(2)二维数组的存储方式
对于二维数组,需要将逻辑上的"矩阵"映射到一维的线性内存中。主要有两种映射方式:
① 行优先存储(Row-Major Order)
按行依次存储——先存完第 0 行的所有元素,再存第 1 行,以此类推。
C、C++、Java、Python(默认)等语言采用行优先存储。
以 $2 \times 3$ 的数组为例:
a[0][0] a[0][1] a[0][2] → a[0][0] a[0][1] a[0][2] a[1][0] a[1][1] a[1][2]
a[1][0] a[1][1] a[1][2]
② 列优先存储(Column-Major Order)
按列依次存储——先存完第 0 列的所有元素,再存第 1 列,以此类推。
Fortran、MATLAB 等语言采用列优先存储。
以 $2 \times 3$ 的数组为例:
a[0][0] a[0][1] a[0][2] → a[0][0] a[1][0] a[0][1] a[1][1] a[0][2] a[1][2]
a[1][0] a[1][1] a[1][2]
为什么会有两种不同的存储方式? 行优先和列优先本质上没有优劣之分,只是不同的设计选择。C 语言设计者选择了行优先,这与 C 语言中"数组的数组"的定义方式一脉相承——
a[i][j]先取第i行(一个一维数组),再取该行的第j个元素。Fortran 面向科学计算,矩阵运算较多,列优先在某些矩阵乘法中能更好地利用缓存局部性。
4. 数组元素的寻址
假设数组的首地址为 $\text{Loc}_0$,每个元素占 $L$ 个存储单元。
(1)一维数组
元素 $a_i$ 的地址:
$$\text{Loc}(a_i) = \text{Loc}_0 + i \times L$$
(2)二维数组(行优先)
对于 $m \times n$ 的二维数组,元素 $a_{i,j}$ 的地址(行优先):
$$\text{Loc}(a_{i,j}) = \text{Loc}_0 + (i \times n + j) \times L$$
其中 $i \times n + j$ 的含义是:前面有 $i$ 整行(每行 $n$ 个元素),再加上当前行的第 $j$ 个元素。
(3)二维数组(列优先)
对于 $m \times n$ 的二维数组,元素 $a_{i,j}$ 的地址(列优先):
$$\text{Loc}(a_{i,j}) = \text{Loc}_0 + (j \times m + i) \times L$$
其中 $j \times m + i$ 的含义是:前面有 $j$ 整列(每列 $m$ 个元素),再加上当前列的第 $i$ 个元素。
注意:上述公式均假设下标从 0 开始。如果下标从 1 开始,公式中的 $i$ 应替换为 $i-1$,$j$ 替换为 $j-1$,以此类推。
二、稀疏矩阵的压缩存储
1. 什么是稀疏矩阵?
稀疏矩阵(Sparse Matrix)是指非零元素很少(远少于零元素)的矩阵。通常情况下,如果一个矩阵中非零元素的个数占总元素个数的比例(称为稠密度)不超过 5%,就可以视作稀疏矩阵。
为什么需要压缩存储? 如果直接用二维数组存储稀疏矩阵,大量的零元素会白白浪费存储空间。例如一个 $1000 \times 1000$ 的矩阵有 1,000,000 个元素,但只有 1000 个非零元素——直接存储的 99.9% 的空间都存着 0,这是极大的浪费。因此需要只存储非零元素,以节省空间。
2. 三元组顺序表
(1)什么是三元组?
三元组(Triple)用三个信息来表示矩阵中的一个非零元素:
$$(\text{行号},\ \text{列号},\ \text{值})$$
例如矩阵中的非零元素 $a_{i,j} = 5$,对应的三元组为 $(i, j, 5)$。
(2)三元组顺序表的定义
三元组顺序表(Ordered Triple List)将所有非零元素的三元组按某种顺序(通常是行优先的顺序)存放在一个一维数组中,同时记录原矩阵的行数、列数和非零元素个数:
#define MAXSIZE 1000 // 非零元素的最大个数
typedef struct {
int row; // 行号(通常从 0 或 1 开始,视具体实现而定)
int col; // 列号
int val; // 非零元素的值
} Triple; // 三元组
typedef struct {
Triple data[MAXSIZE]; // 三元组表
int rows; // 原矩阵的行数
int cols; // 原矩阵的列数
int nums; // 非零元素个数
} TSMatrix; // Triple Sparse Matrix
和顺序表的类比:三元组顺序表本质上就是用"数组"来存储稀疏矩阵的非零元素。和顺序表类似,它的大小是固定的(
MAXSIZE),插入和删除需要移动元素。
(3)三元组顺序表的转置运算
矩阵转置是将矩阵的行和列互换。对于普通矩阵,转置后 $b_{j,i} = a_{i,j}$。
对于三元组顺序表,转置不能简单地交换三元组的行和列号——因为转置后的三元组需要按行优先排列,而单纯交换后可能不满足行优先的顺序。
① 一般转置(按列扫描)
思路:从原矩阵的第 0 列开始,依次扫描所有三元组,将第 0 列的所有元素(交换行列后)放入新表,再处理第 1 列,以此类推。
void Transpose(TSMatrix A, TSMatrix *B) {
B->rows = A.cols;
B->cols = A.rows;
B->nums = A.nums;
if (A.nums == 0) return;
int k = 0; // B 表的下标
for (int col = 0; col < A.cols; col++) { // 按原矩阵的列号扫描
for (int i = 0; i < A.nums; i++) { // 遍历三元组表
if (A.data[i].col == col) { // 找到第 col 列的元素
B->data[k].row = A.data[i].col; // 交换行号和列号
B->data[k].col = A.data[i].row;
B->data[k].val = A.data[i].val;
k++;
}
}
}
}
时间复杂度:$O(\text{cols} \times \text{nums})$。当非零元素较多时,效率较低。
② 快速转置
思路:先统计原矩阵每列有多少个非零元素(即转置后每行有多少个元素),然后计算出转置后每行在三元组表中的起始位置,最后遍历原三元组表,将每个元素直接放到转置后的正确位置上。
void FastTranspose(TSMatrix A, TSMatrix *B) {
B->rows = A.cols;
B->cols = A.rows;
B->nums = A.nums;
if (A.nums == 0) return;
int numCols[A.cols]; // 统计原矩阵每列的非零元素个数
int position[A.cols]; // 转置后每行的起始位置
for (int i = 0; i < A.cols; i++)
numCols[i] = 0;
for (int i = 0; i < A.nums; i++)
numCols[A.data[i].col]++; // 统计第 col 列的元素个数
position[0] = 0;
for (int i = 1; i < A.cols; i++)
position[i] = position[i - 1] + numCols[i - 1];
for (int i = 0; i < A.nums; i++) {
int col = A.data[i].col;
int k = position[col]; // 获取转置后的存放位置
B->data[k].row = A.data[i].col;
B->data[k].col = A.data[i].row;
B->data[k].val = A.data[i].val;
position[col]++; // 该行的起始位置后移
}
}
时间复杂度:$O(\text{cols} + \text{nums})$,比一般转置快得多,但需要额外的两个辅助数组。
对比:一般转置用"找"的方式——每处理一列就遍历整个表;快速转置用"算"的方式——先算好位置,然后一次遍历直接放好。前者 $O(\text{cols} \times \text{nums})$,后者 $O(\text{cols} + \text{nums})$。
另一种思路:先把三元组按列排序(同列内按行排),再交换行列,就自然得到行优先了
这正是快速转置背后的原理。举个直观的例子:
原三元组(行优先):
(0,0,1), (0,2,3), (1,1,5), (2,0,2), (2,3,4)① 先按列排序(即按转置后的行分组):
(0,0,1), (2,0,2), (1,1,5), (0,2,3), (2,3,4)② 再交换行列:
(0,0,1), (0,2,2), (1,1,5), (2,0,3), (3,2,4)此时已是行优先这本质上就是先按列做一次排序,再交换行列。当 $\text{nums}$ 很大时,快速转置的优势就体现出来了。
3. 十字链表
(1)为什么需要十字链表?
三元组顺序表存在一个明显缺陷:当需要频繁插入或删除非零元素时,移动大量数据的代价很高(和顺序表一样)。而且矩阵的形状(行数和列数)是固定的,难以动态扩展。
十字链表(Orthogonal Linked List)通过链式存储解决了这个问题——它用链表来组织非零元素,插入和删除只需修改指针,无需移动数据。
(2)十字链表的定义
十字链表将每个非零元素存储为一个结点,该结点包含:
- 行号和列号:标识元素的位置
- 值:非零元素的值
- 向右指针(
right):指向同一行中的下一个非零元素 - 向下指针(
down):指向同一列中的下一个非零元素
typedef struct OLNode {
int row; // 行号
int col; // 列号
int val; // 非零元素的值
struct OLNode *right; // 指向同一行的下一个非零元素
struct OLNode *down; // 指向同一列的下一个非零元素
} OLNode; // Orthogonal List Node
typedef struct {
OLNode **rowHead; // 行链表头指针数组,长度为 rows(用 calloc 动态分配)
OLNode **colHead; // 列链表头指针数组,长度为 cols
int rows; // 原矩阵的行数
int cols; // 原矩阵的列数
int nums; // 非零元素个数
} OLinkMatrix; // Orthogonal Link Matrix
初始化时这样分配:
OLinkMatrix *CreateMatrix(int rows, int cols) {
OLinkMatrix *M = (OLinkMatrix *)malloc(sizeof(OLinkMatrix));
M->rows = rows;
M->cols = cols;
M->nums = 0;
M->rowHead = (OLNode **)calloc(rows, sizeof(OLNode *)); // 每个行头指针初始为 NULL
M->colHead = (OLNode **)calloc(cols, sizeof(OLNode *)); // 每列同理
return M;
}
注意:这里
rowHead的长度固定为rows,每一行都有一个头指针(即使该行没有非零元素,头指针为NULL),这样才能通过行号直接索引到对应行。所以不是"最大 rows",而是恰好 rows 个,一个都不能少。
"十字"的含义:每个结点同时属于两个链表——行链表(水平方向)和列链表(垂直方向),从结点出发可以向右(同一行)或向下(同一列)移动,形成了"十字交叉"的链接结构。
(3)十字链表的建立
建立十字链表的过程:逐个插入非零元素的三元组,将其链接到对应的行链表和列链表中(通常按行优先的顺序插入)。
void InsertNode(OLinkMatrix *M, int row, int col, int val) {
OLNode *p = (OLNode *)malloc(sizeof(OLNode));
p->row = row;
p->col = col;
p->val = val;
p->right = NULL;
p->down = NULL;
// 插入行链表(按列号升序排列)
if (M->rowHead[row] == NULL || M->rowHead[row]->col > col) {
p->right = M->rowHead[row];
M->rowHead[row] = p;
} else {
OLNode *q = M->rowHead[row];
while (q->right != NULL && q->right->col < col)
q = q->right;
p->right = q->right;
q->right = p;
}
// 插入列链表(按行号升序排列)
if (M->colHead[col] == NULL || M->colHead[col]->row > row) {
p->down = M->colHead[col];
M->colHead[col] = p;
} else {
OLNode *q = M->colHead[col];
while (q->down != NULL && q->down->row < row)
q = q->down;
p->down = q->down;
q->down = p;
}
M->nums++;
}
(4)十字链表的特点
| 特点 | 说明 |
|---|---|
| 插入/删除灵活 | 只需修改指针,不需要移动大量数据 |
| 空间利用率高 | 只存储非零元素,没有因数组固定大小而浪费的空间 |
| 动态扩展 | 矩阵行数、列数可以动态变化,没有固定上限 |
| 实现复杂 | 指针操作较多,编写和调试难度高于三元组顺序表 |
| 存储密度较低 | 每个结点需要存储两个指针(right 和 down),额外开销较大 |
一句话总结:
- 三元组顺序表(顺序存储)——适合静态矩阵(非零元素位置固定不变),查找方便,但插入删除代价高
- 十字链表(链式存储)——适合动态矩阵(非零元素频繁变化),插入删除方便,但实现较复杂
三、数组的性能分析
| 操作 | 时间复杂度 | 说明 |
|---|---|---|
| 按下标访问(读/写) | $O(1)$ | 随机存取,直接通过寻址公式计算地址 |
| 插入/删除 | $O(n)$ | 需要移动大量元素(数组本身不擅长此操作) |
| 遍历 | $O(n)$ | 需要访问每个元素 |
数组的核心优势:随机存取(Random Access)。这是数组最根本的特性,也是它被广泛使用的原因。无论是普通数组还是多维数组,只要知道下标,就能在 $O(1)$ 时间内计算出地址并访问任意元素。这一特性使得数组在需要频繁按位置访问的场景中无可替代。

浙公网安备 33010602011771号