图基础:图的定义与术语、存储结构与遍历

图,说实话,是数据结构里最灵活、也最让人头疼的一个。树你还能找到根,链表你还能找到头,图呢?图没有固定的起点,也没有固定的终点。我当年第一次接触图的时候,脑子里全是问号——这东西到底怎么存?怎么走?别急,咱们一步步来。

图的定义与基本术语

图,说白了就是一堆顶点和一堆边的集合。用数学话说,叫 G = (V, E)。V 是顶点集,E 是边集。你想想看,社交网络里每个人是一个顶点,好友关系就是一条边。地图上每个城市是一个顶点,高速公路就是边。就这么简单。

但图有几个关键术语,我建议你记牢:

  • 有向图 vs 无向图:边有没有方向?有方向就是有向图,比如微博的关注关系。没方向就是无向图,比如微信的好友关系。
  • 权值:边上的数值,比如距离、时间、花费。带权值的图叫网。
  • :顶点关联的边数。有向图里还分入度和出度。
  • 路径与回路:从一个顶点到另一个顶点的顶点序列。如果起点和终点相同,就叫回路。
  • 连通图与强连通图:无向图中任意两点都连通,叫连通图。有向图中任意两点互相可达,叫强连通图。

核心要点:图的核心就是顶点和边的关系。你只要搞清楚「谁和谁相连」「方向是什么」「有没有权值」,图就抓住了。

图的存储结构

图怎么存进计算机?我做过一个项目,要处理几十万个节点的社交关系图。当时选错了存储方式,内存直接爆了。嗯,这里要注意,选存储结构要看图的密度。

邻接矩阵

邻接矩阵,就是用二维数组来存图。假设有 n 个顶点,就开一个 n×n 的矩阵。matrix[i][j] = 1 表示顶点 i 到 j 有边,= 0 表示没有。如果是带权图,就存权值,没有边就存无穷大。

// 邻接矩阵定义
#define MAX_VERTEX 100

typedef struct {
    int vertex[MAX_VERTEX];          // 顶点数组
    int edge[MAX_VERTEX][MAX_VERTEX]; // 邻接矩阵
    int vertexNum, edgeNum;           // 顶点数和边数
} MGraph;

邻接矩阵的好处是直观,判断两个顶点是否相连,O(1) 就搞定了。但缺点也很明显——稀疏图太浪费空间。你想想看,一万个顶点,边只有几千条,却要开一亿个元素的矩阵,大部分都是 0。我曾经在一个项目里犯过这个错,内存直接飙到 400MB,后来改成邻接表才降到 20MB。

我的建议:稠密图(边数接近 n²)用邻接矩阵。稀疏图(边数远小于 n²)用邻接表。判断标准很简单——如果矩阵里大部分是 0,就别用矩阵。

邻接表

邻接表,说白了就是给每个顶点挂一个链表。链表里存的是这个顶点能到达的所有邻居。这样只存实际存在的边,空间省很多。

// 邻接表定义
typedef struct EdgeNode {
    int adjvex;                // 邻接点下标
    int weight;                // 权值(如果有)
    struct EdgeNode *next;     // 下一个邻接点
} EdgeNode;

typedef struct {
    int data;                  // 顶点信息
    EdgeNode *firstEdge;       // 第一条边
} VertexNode;

typedef struct {
    VertexNode adjList[MAX_VERTEX];
    int vertexNum, edgeNum;
} ALGraph;

邻接表遍历邻居很方便,时间复杂度是 O(邻接边数)。但判断两个顶点是否相连,需要遍历链表,最坏 O(n)。

避坑指南:我曾经在写邻接表时,忘记初始化 firstEdge 为 NULL,结果遍历时野指针直接段错误。记住,每个顶点的 firstEdge 一定要初始化为 NULL。

图的遍历:DFS 与 BFS

图建好了,怎么走?两种经典方式:深度优先搜索(DFS)和广度优先搜索(BFS)。

深度优先搜索(DFS)

DFS 的思路很简单:一条路走到黑,走不通了再回头。就像走迷宫,先沿着一条路一直走,遇到死胡同就退回来换一条。我习惯用递归实现,代码很简洁。

// DFS 递归实现
int visited[MAX_VERTEX] = {0};

void DFS(ALGraph *G, int v) {
    visited[v] = 1;
    printf("访问顶点: %d\n", v);
    
    EdgeNode *p = G->adjList[v].firstEdge;
    while (p != NULL) {
        if (!visited[p->adjvex]) {
            DFS(G, p->adjvex);
        }
        p = p->next;
    }
}

void DFSTraverse(ALGraph *G) {
    for (int i = 0; i < G->vertexNum; i++) {
        visited[i] = 0;
    }
    for (int i = 0; i < G->vertexNum; i++) {
        if (!visited[i]) {
            DFS(G, i);
        }
    }
}

DFS 的时间复杂度,邻接表是 O(n + e),邻接矩阵是 O(n²)。空间复杂度 O(n),主要花在递归栈上。

广度优先搜索(BFS)

BFS 的思路是层层推进。先访问起点,再访问起点的所有邻居,再访问邻居的邻居……就像水波一样一圈圈扩散。BFS 需要借助队列来实现。

// BFS 实现
void BFS(ALGraph *G, int v) {
    int queue[MAX_VERTEX];
    int front = 0, rear = 0;
    
    visited[v] = 1;
    printf("访问顶点: %d\n", v);
    queue[rear++] = v;
    
    while (front != rear) {
        int u = queue[front++];
        EdgeNode *p = G->adjList[u].firstEdge;
        
        while (p != NULL) {
            if (!visited[p->adjvex]) {
                visited[p->adjvex] = 1;
                printf("访问顶点: %d\n", p->adjvex);
                queue[rear++] = p->adjvex;
            }
            p = p->next;
        }
    }
}

BFS 的时间复杂度和 DFS 一样,但空间复杂度最坏 O(n),因为队列里最多存所有顶点。

关键区别:DFS 适合找路径、判断连通性、拓扑排序。BFS 适合找最短路径(无权图)、层次遍历。选哪个?看你要找「有没有这条路」还是「最短的这条路」。

知识体系总览

下面这张图,是我梳理的本章知识结构。你对照着看,心里就有谱了。

图基础 定义与术语 存储结构 图的遍历 有向/无向图 权值/度 路径/回路 连通性 邻接矩阵 邻接表 DFS BFS 递归/栈 队列 图基础:定义 → 存储 → 遍历,层层递进

图这块,说白了就是三个问题:图长什么样(定义)、怎么存(存储结构)、怎么走(遍历)。你把这三点吃透了,后面学最短路径、最小生成树、拓扑排序,都会轻松很多。

个人经验:我建议你动手实现一遍邻接表和邻接矩阵,然后用 DFS 和 BFS 遍历一下。代码写一遍,比看十遍都管用。遇到段错误别慌,八成是链表指针没处理好。


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