数据结构——数组

数组

一、数组的定义

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)十字链表的特点

特点 说明
插入/删除灵活 只需修改指针,不需要移动大量数据
空间利用率高 只存储非零元素,没有因数组固定大小而浪费的空间
动态扩展 矩阵行数、列数可以动态变化,没有固定上限
实现复杂 指针操作较多,编写和调试难度高于三元组顺序表
存储密度较低 每个结点需要存储两个指针(rightdown),额外开销较大

一句话总结

  • 三元组顺序表(顺序存储)——适合静态矩阵(非零元素位置固定不变),查找方便,但插入删除代价高
  • 十字链表(链式存储)——适合动态矩阵(非零元素频繁变化),插入删除方便,但实现较复杂

三、数组的性能分析

操作 时间复杂度 说明
按下标访问(读/写) $O(1)$ 随机存取,直接通过寻址公式计算地址
插入/删除 $O(n)$ 需要移动大量元素(数组本身不擅长此操作)
遍历 $O(n)$ 需要访问每个元素

数组的核心优势随机存取(Random Access)。这是数组最根本的特性,也是它被广泛使用的原因。无论是普通数组还是多维数组,只要知道下标,就能在 $O(1)$ 时间内计算出地址并访问任意元素。这一特性使得数组在需要频繁按位置访问的场景中无可替代。

posted @ 2026-07-15 14:49  Javenwww  阅读(17)  评论(0)    收藏  举报