第二十四章 图(一):从概念到遍历,彻底搞懂图结构

图,终于讲到图了。说实话,在我早年做嵌入式开发时,图结构用得并不多。直到后来参与一个路由规划项目,才发现图简直是救星。你想想看,地图导航、社交网络、电路设计,哪个离得开图?今天我们就从最基础的概念讲起,一步步把图的存储和遍历吃透。

一、图的基本概念

图是什么?说白了,就是一堆顶点和连接它们的边。我习惯用这个比喻:顶点就像城市,边就像高速公路。但图比树自由多了——树有严格的父子关系,图可不管这套,谁跟谁都能连。

1. 图的定义

图 G 由两个集合组成:顶点集 V 和边集 E。记作 G = (V, E)。

  • 顶点 (Vertex):图中的基本元素,也叫节点。
  • 边 (Edge):顶点之间的连线,表示关系。

2. 图的分类

分类标准类型说明
边是否有方向有向图 / 无向图有向图的边像单行道,无向图像双行道
边是否有权重带权图 / 无权图带权图的边上有数值,比如距离、成本
是否有多条边简单图 / 多重图简单图不允许重复边,多重图允许

避坑指南:我曾经在项目里把无向图当成有向图存,结果路径计算全错了。记住:无向图的每条边,其实相当于两条方向相反的有向边。

3. 常用术语

  • 度 (Degree):顶点关联的边数。有向图里分入度和出度。
  • 路径 (Path):从一个顶点到另一个顶点的顶点序列。
  • 连通图 (Connected Graph):任意两个顶点之间都有路径。
  • 子图 (Subgraph):从原图里挑一部分顶点和边组成的新图。

二、图的存储结构

图怎么存进计算机?我常用的就两种:邻接矩阵和邻接表。选哪个?看场景。稠密图用矩阵,稀疏图用邻接表。嗯,这是经验之谈。

1. 邻接矩阵

用一个二维数组存图。arr[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;

// 初始化图
void initGraph(MGraph *G, int n) {
    G->vertexNum = n;
    G->edgeNum = 0;
    for (int i = 0; i < n; i++) {
        for (int j = 0; j < n; j++) {
            G->edge[i][j] = 0;
        }
    }
}

// 添加边
void addEdge(MGraph *G, int i, int j) {
    G->edge[i][j] = 1;
    G->edge[j][i] = 1;  // 无向图对称
    G->edgeNum++;
}

我的习惯:邻接矩阵判断两点是否相连,时间复杂度 O(1),爽快。但空间是 O(n²),顶点一多就扛不住了。1000个顶点就是100万大小的数组,你想想看。

2. 邻接表

每个顶点维护一个链表,链表里存的是它所有邻居。说白了,就是「谁认识谁」的名单。

// 邻接表实现 - 无向图
typedef struct EdgeNode {
    int adjVertex;              // 邻接顶点下标
    struct EdgeNode *next;      // 指向下一条边
} EdgeNode;

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

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

// 添加边
void addEdge(ALGraph *G, int i, int j) {
    // 头插法:i->j
    EdgeNode *e = (EdgeNode*)malloc(sizeof(EdgeNode));
    e->adjVertex = j;
    e->next = G->adjList[i].firstEdge;
    G->adjList[i].firstEdge = e;
    
    // 无向图还要加 j->i
    e = (EdgeNode*)malloc(sizeof(EdgeNode));
    e->adjVertex = i;
    e->next = G->adjList[j].firstEdge;
    G->adjList[j].firstEdge = e;
    
    G->edgeNum++;
}

注意:邻接表虽然省空间,但判断两点是否相连需要遍历链表,最坏情况 O(n)。我曾在社交网络项目里用邻接表存用户关系,查询好友关系时性能堪忧——后来加了哈希索引才解决。

3. 两种存储对比

对比项邻接矩阵邻接表
空间复杂度O(n²)O(n+e)
判断边是否存在O(1)O(度)
遍历所有边O(n²)O(n+e)
适用场景稠密图稀疏图

三、图的遍历

遍历图,说白了就是把所有顶点都走一遍。但图不像树有天然的层次,图里可能有环,你得小心别死循环。我常用的两种方法:DFS 和 BFS。

1. 深度优先搜索 (DFS)

DFS 的思路很简单:一条路走到黑,走不通了再回头。就像走迷宫,先选一个方向猛走,撞墙了再退回来换条路。

// DFS - 邻接矩阵实现
void DFS(MGraph *G, int v, int visited[]) {
    visited[v] = 1;
    printf("%d ", v);  // 访问顶点
    
    for (int i = 0; i < G->vertexNum; i++) {
        if (G->edge[v][i] == 1 && !visited[i]) {
            DFS(G, i, visited);
        }
    }
}

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

避坑指南:我曾经写 DFS 时忘了处理非连通图的情况。结果图里有两个连通分量,只遍历了第一个。记住:一定要对每个未访问的顶点都调用一次 DFS。

2. 广度优先搜索 (BFS)

BFS 像水波扩散,一圈一圈往外走。先访问离起点最近的,再访问次近的。用队列实现,先进先出。

// BFS - 邻接表实现
void BFS(ALGraph *G, int v, int visited[]) {
    int queue[MAX_VERTEX];
    int front = 0, rear = 0;
    
    visited[v] = 1;
    printf("%d ", v);
    queue[rear++] = v;
    
    while (front != rear) {
        int cur = queue[front++];
        EdgeNode *p = G->adjList[cur].firstEdge;
        
        while (p) {
            int neighbor = p->adjVertex;
            if (!visited[neighbor]) {
                visited[neighbor] = 1;
                printf("%d ", neighbor);
                queue[rear++] = neighbor;
            }
            p = p->next;
        }
    }
}

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

3. DFS vs BFS 对比

对比项DFSBFS
实现方式递归/栈队列
遍历顺序纵向深入横向扩展
空间复杂度O(h),h为深度O(w),w为最大宽度
适用场景拓扑排序、连通分量最短路径、层次遍历

我的经验:找最短路径用 BFS,判断图中是否有环用 DFS。为什么?BFS 第一次访问到的路径就是最短的,而 DFS 递归回溯时天然适合检测环。你想想看,是不是这个理?

四、知识体系总览

下面这张图,是我自己总结的本章知识框架。你看一遍,心里就有谱了。

图 (Graph) 基本概念 顶点 / 边 / 度 有向图 / 无向图 带权图 / 连通图 存储结构 邻接矩阵 (O(n²)空间) 邻接表 (O(n+e)空间) 图的遍历 DFS (深度优先) BFS (广度优先)

图这块内容,说难不难,说简单也不简单。关键是把概念理清楚,把两种存储结构吃透,再把 DFS 和 BFS 的代码多写几遍。我个人建议:先拿邻接矩阵练手,因为直观;熟练了再转邻接表,因为实用。

最后说一句:图是数据结构里最灵活的结构之一。你学会了图,很多看似复杂的问题——比如最短路径、拓扑排序、最小生成树——都会变得有章可循。下一章我们会深入这些经典算法,到时候你就知道今天打的基础有多重要了。


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