第七章:数组与广义表——从连续内存到递归结构
数组这东西,大家从学C语言第一天就接触了。但说实话,很多人用了好几年,对数组的理解还停留在「连续内存的一块区域」这个层面。我个人习惯把数组看作一种随机存取的线性结构——你给我下标,我直接算出地址,时间复杂度O(1)。
广义表就更有意思了。它允许元素本身又是一个表,这就打破了线性结构的限制。我在做编译器的符号表管理时,就遇到过需要嵌套结构的情况,当时用广义表来处理,比用树结构要灵活得多。
好,我们一步步来看。
7.1 数组的定义与存储
数组的定义其实很简单:一组类型相同、物理连续的元素集合。但要注意,C语言里的数组名本质上是一个常量指针,指向首元素地址。
多维数组的存储,有两种方式:
- 行优先(Row-major):C语言采用这种方式。先存完第一行所有列,再存第二行。
- 列优先(Column-major):Fortran语言采用。先存完第一列所有行,再存第二列。
举个例子,一个二维数组 int a[3][4],在内存中的布局是这样的:
地址: [0][0] [0][1] [0][2] [0][3] [1][0] [1][1] [1][2] [1][3] [2][0] ...
偏移: 0 1 2 3 4 5 6 7 8
计算某个元素 a[i][j] 的地址公式:
地址 = 基地址 + (i * 列数 + j) * sizeof(元素类型)
嗯,这个公式我建议你背下来。面试经常考,而且实际做底层驱动时也经常用到。
7.2 特殊矩阵的压缩存储
什么叫特殊矩阵?说白了就是那些有很多重复元素或者零元素的矩阵。如果我们还按普通二维数组存,太浪费空间了。所以我们要做压缩存储。
7.2.1 对称矩阵
对称矩阵满足 a[i][j] = a[j][i]。我们只需要存上三角或下三角部分,包括对角线。
以下三角为例,存储到一维数组 sa[] 中:
// 下三角矩阵,按行存储
// 元素 a[i][j] (i >= j) 在 sa 中的下标为:
// k = i*(i+1)/2 + j
// 示例:4x4 对称矩阵
// 原始矩阵:
// 1 2 3 4
// 2 5 6 7
// 3 6 8 9
// 4 7 9 10
// 压缩后 sa[] = {1, 2, 5, 3, 6, 8, 4, 7, 9, 10}
// 共 10 个元素,节省了 6 个
7.2.2 三角矩阵
上三角矩阵:下三角部分(不含对角线)全为常数c。下三角矩阵则相反。
存储时,除了存三角部分的元素,还要多一个位置存那个常数c。
7.2.3 对角矩阵
只有主对角线及其附近几条对角线有非零元素。比如三对角矩阵:
// 三对角矩阵,只有三条对角线有值
// 存储方式:按行存储对角线元素
// 或者用带状存储:sa[i][j-i+1]
7.3 稀疏矩阵
稀疏矩阵的定义:非零元素个数远小于总元素个数,且分布没有规律。比如一个1000x1000的矩阵,只有100个非零元素,那就是稀疏矩阵。
稀疏矩阵的存储,核心思想是:只存非零元素的位置和值。
7.3.1 三元组表示法
每个非零元素用一个三元组表示:(行号, 列号, 值)。
// 三元组结构定义
typedef struct {
int row; // 行号
int col; // 列号
int value; // 非零元素值
} Triple;
// 稀疏矩阵结构
typedef struct {
Triple data[MAXSIZE]; // 三元组表
int rows; // 总行数
int cols; // 总列数
int num; // 非零元素个数
} SparseMatrix;
7.3.2 十字链表法
三元组适合静态矩阵。如果矩阵需要频繁插入、删除非零元素,三元组的效率就很低了。这时候用十字链表更合适。
十字链表的结构:每个非零元素是一个节点,它同时属于一行链表和一列链表。
// 十字链表节点
typedef struct OLNode {
int row, col; // 行列号
int value; // 值
struct OLNode *right; // 指向同一行的下一个非零元素
struct OLNode *down; // 指向同一列的下一个非零元素
} OLNode;
typedef struct {
OLNode *row_head[MAXSIZE]; // 行链表头指针数组
OLNode *col_head[MAXSIZE]; // 列链表头指针数组
int rows, cols; // 矩阵维度
int num; // 非零元素个数
} CrossList;
7.4 广义表的定义与操作
广义表,也叫列表(List),是线性表的推广。线性表的元素必须是原子(不可分割的数据),而广义表的元素可以是原子,也可以是另一个广义表。
定义形式:GL = (a1, a2, ..., an),其中 ai 可以是原子,也可以是子表。
举个例子:
A = ()—— 空表B = (a, b)—— 两个原子C = (a, (b, c), d)—— 第二个元素是子表D = (A, B, C)—— 三个子表
7.4.1 广义表的存储结构
广义表不能用顺序存储,因为元素大小不固定。通常用链式存储:
// 广义表节点类型
typedef enum { ATOM, LIST } NodeType;
typedef struct GLNode {
NodeType tag; // 标志域:ATOM 或 LIST
union {
char atom; // 原子值(假设为字符型)
struct {
struct GLNode *hp; // 表头指针
struct GLNode *tp; // 表尾指针
} ptr;
} data;
} GLNode;
这种结构很有意思。每个节点要么存一个原子,要么存一对指针(表头、表尾)。这种设计让广义表的操作变得非常统一。
7.4.2 广义表的基本操作
两个核心操作:
- 取表头(Head):第一个元素
- 取表尾(Tail):除第一个元素外的剩余部分(一定是一个子表)
举个例子,对于 L = (a, (b, c), d):
Head(L) = aTail(L) = ((b, c), d)Head(Tail(L)) = (b, c)Head(Head(Tail(L))) = b
你看,通过反复取表头和表尾,我们可以访问到任意深度的元素。
7.4.3 广义表的递归算法
广义表的很多操作天然适合递归。比如求深度:
int Depth(GLNode *L) {
if (L == NULL) return 1; // 空表深度为1
if (L->tag == ATOM) return 0; // 原子深度为0
int max_depth = 0;
GLNode *p = L;
while (p != NULL) {
int d = Depth(p->data.ptr.hp); // 递归求子表深度
if (d > max_depth) max_depth = d;
p = p->data.ptr.tp; // 遍历下一个元素
}
return max_depth + 1;
}
知识体系总览
下面这张图,把本章的核心知识点串起来了:
数组和广义表,一个强调连续存储和随机访问,一个强调递归结构和灵活嵌套。两者看似不同,但都是构建复杂数据结构的基石。你在实际项目中,根据数据的特点选择合适的结构,往往能事半功倍。
好,这一章就到这里。记住:数组是基础,广义表是进阶。把这两个吃透了,后面学树和图会轻松很多。