第二十六讲:图的基本概念——图的定义与术语、存储结构

各位同学,今天我们来聊聊图。说实话,图这个数据结构在嵌入式里用得不算特别频繁,但一旦用上,往往就是硬骨头——比如网络协议栈的路由表、自动驾驶的路径规划、甚至芯片内部互联的拓扑分析。我个人觉得,理解图的关键不在于背定义,而在于建立一种“关系思维”。

一、图的定义与核心术语

图,说白了就是一组顶点和一组边的集合。用数学语言说:G = (V, E),V 是顶点集合,E 是边集合。但咱们搞工程的,更关心它怎么描述现实问题。

举个例子。我在做智能家居网关项目时,需要描述各个传感器节点之间的通信关系。每个传感器是一个顶点,它们之间的通信链路就是边。这时候,图就派上用场了。

1. 有向图 vs 无向图

  • 无向图:边没有方向。比如邻居关系——你认识我,我也认识你。
  • 有向图:边有方向。比如数据流向——传感器A向网关B发送数据,但B不一定向A发。

2. 顶点的度

无向图中,一个顶点连接的边数就是它的度。有向图里分入度和出度。我记得有一次调试一个环形缓冲区队列,发现数据总是卡在某个节点,一查是那个节点的入度远大于出度——说白了就是“收得多发得少”,堵住了。

3. 路径与连通性

  • 路径:从一个顶点到另一个顶点经过的边序列。
  • 连通图:任意两个顶点之间都有路径。如果图不连通,就分成多个连通分量。
  • 强连通图(有向图):任意两个顶点之间都有双向路径。

避坑指南:我曾经在写一个网络拓扑检测程序时,默认图是连通的,结果遇到一个孤立节点,程序直接死循环。后来我养成了一个习惯——处理图之前,先检查连通性。

二、图的存储结构

图怎么存进计算机?两种主流方式:邻接矩阵和邻接表。选哪个?看场景。

1. 邻接矩阵

用一个二维数组存。arr[i][j] = 1 表示顶点 i 到 j 有边,0 表示没有。如果是带权图,就存权值。

// 邻接矩阵示例(无向图,5个顶点)
#define MAX_VERTEX 5
int graph[MAX_VERTEX][MAX_VERTEX] = {
    {0, 1, 1, 0, 0},
    {1, 0, 0, 1, 0},
    {1, 0, 0, 1, 1},
    {0, 1, 1, 0, 0},
    {0, 0, 1, 0, 0}
};

优点:判断两点之间是否有边,O(1) 搞定。适合稠密图(边多)。
缺点:浪费空间。1000个顶点就要 1,000,000 个元素,很多是0。

我的经验:在嵌入式环境里,RAM 很金贵。如果顶点数超过 200,我一般不用邻接矩阵。除非是固定的小规模图,比如 8x8 的网格地图。

2. 邻接表

每个顶点维护一个链表,链表中存它所有邻居。说白了就是“谁跟我连着,我就记下谁”。

// 邻接表节点结构
typedef struct AdjNode {
    int vertex;           // 邻居顶点编号
    int weight;           // 权值(可选)
    struct AdjNode* next;
} AdjNode;

// 图结构
typedef struct Graph {
    int numVertices;
    AdjNode** adjLists;   // 指针数组,每个元素指向一个链表头
} Graph;

// 创建图
Graph* createGraph(int vertices) {
    Graph* g = (Graph*)malloc(sizeof(Graph));
    g->numVertices = vertices;
    g->adjLists = (AdjNode**)malloc(vertices * sizeof(AdjNode*));
    for (int i = 0; i < vertices; i++)
        g->adjLists[i] = NULL;
    return g;
}

// 添加边(无向图)
void addEdge(Graph* g, int src, int dest) {
    // 添加 src -> dest
    AdjNode* newNode = (AdjNode*)malloc(sizeof(AdjNode));
    newNode->vertex = dest;
    newNode->next = g->adjLists[src];
    g->adjLists[src] = newNode;
    
    // 添加 dest -> src(无向图需要双向)
    newNode = (AdjNode*)malloc(sizeof(AdjNode));
    newNode->vertex = src;
    newNode->next = g->adjLists[dest];
    g->adjLists[dest] = newNode;
}

优点:省空间。边少的时候特别划算。
缺点:判断两点是否相连,需要遍历链表,O(度) 的时间。

注意:邻接表在嵌入式里要注意内存碎片问题。频繁 malloc/free 可能导致堆碎片。我一般会预先分配一个大的节点池,用数组模拟链表,避免动态分配。

三、两种存储结构的对比

对比项 邻接矩阵 邻接表
空间复杂度 O(V²) O(V + E)
判断边是否存在 O(1) O(度)
遍历所有边 O(V²) O(V + E)
适合场景 稠密图、小规模图 稀疏图、大规模图
嵌入式适用性 RAM 够用时可考虑 更灵活,但注意内存管理

四、知识体系结构图

下面我用一张图来梳理本章的核心内容。你想想看,从图的定义出发,分出了术语和存储两大块,存储又分两种方式,各有优劣。

图 (Graph) 定义与术语 存储结构 有向图 / 无向图 顶点的度 路径与连通性 连通分量 邻接矩阵 邻接表 O(1)查边,空间大 省空间,查边慢 选择依据:顶点数、边密度、嵌入式RAM限制

五、实际项目中的选择建议

嗯,这里要注意。很多初学者喜欢问“哪个更好”?其实没有绝对的好,只有适合不适合。

  • 顶点少(比如 < 50)且边多:邻接矩阵。代码简单,查边快。
  • 顶点多(比如 > 500)且边少:邻接表。省内存是王道。
  • 嵌入式环境:我建议优先考虑邻接表,但要用静态数组模拟链表,避免动态内存分配带来的不确定性。

最后说一句:图这个数据结构,理解容易,用对难。我见过不少同事在项目里把邻接矩阵和邻接表混着用——小图用矩阵,大图用表,中间加一个转换层。这其实是个不错的思路,但前提是你得把两种结构的底层细节吃透。


公众号:蓝海资料掘金营,微信deep3321