1. 从“图”谈起:为什么它不只是点和线?
如果你学过链表、栈、队列,可能会觉得数据结构就是一条线,或者一个先进后出的盒子。但当你第一次接触“图”时,那种感觉是完全不同的。它不再是简单的线性或层级关系,而是一张网,一个可以描述万物的关系模型。我刚开始学图的时候,总觉得它抽象,离实际编程很远。直到后来,我需要为一个社交网络功能设计“可能认识的人”推荐,或者为一个物流系统规划最短配送路径时,我才恍然大悟:图,其实就藏在这些最真实、最复杂的业务场景背后。
所谓图(Graph),就是由顶点(Vertex)和连接这些顶点的边(Edge)组成的数据结构。顶点代表实体,比如一个人、一个城市、一个网页;边代表实体之间的关系,比如好友关系、道路、超链接。这种结构天生就是为了刻画“多对多”的复杂关系。与链表的一对一、树的一对多相比,图的表达能力是最强的,这也意味着它的操作和算法更为丰富和挑战。
今天,我们不谈高深的图算法,就扎扎实实地聊聊图的基本操作实现。这些操作是构建一切图算法的基础,就像盖房子前要先学会砌砖。我们会从最基础的“如何表示一张图”开始,一步步实现增加顶点、增加边、查找、遍历等核心操作。我会用C语言来描述,因为指针和结构体能让你最清晰地看到内存中图的“骨架”,但其中的思想是语言无关的。无论你用Java、Python还是C++,理解了本质,迁移起来轻而易举。
2. 图的两种灵魂:邻接矩阵与邻接表
在动手写代码之前,我们必须做出一个最重要的设计决策:如何在计算机内存中表示一张图?这直接决定了后续所有操作的效率和适用场景。主流有两种表示方法:邻接矩阵和邻接表。它们没有绝对的好坏,只有是否适合。
2.1 邻接矩阵:直观的“城市地图”
想象一个N个城市的交通图。邻接矩阵就像一个N行N列的表格(二维数组)。如果城市i到城市j有直达道路,就在表格的第i行第j列标记为1(或道路的权重,如距离);如果没有,就标记为0或一个特殊值(如无穷大)。
C语言结构定义示例:
#define MAX_VERTEX 100 // 预设最大顶点数 #define INFINITY 65535 // 用一个极大数表示“无穷远”,即无边 typedef struct { char vexs[MAX_VERTEX]; // 顶点数组,用于存储顶点信息,如名称 int arcs[MAX_VERTEX][MAX_VERTEX]; // 邻接矩阵(边表) int numVertexes, numEdges; // 图的当前顶点数和边数 } MGraph;初始化一个无向图:
void CreateMGraph(MGraph *G) { int i, j, k, w; printf("输入顶点数和边数:\n"); scanf("%d %d", &G->numVertexes, &G->numEdges); // 读入顶点信息 for(i = 0; i < G->numVertexes; i++) { printf("输入第%d个顶点信息: ", i+1); scanf(" %c", &G->vexs[i]); // 假设顶点用字符表示 } // 初始化邻接矩阵 for(i = 0; i < G->numVertexes; i++) { for(j = 0; j < G->numVertexes; j++) { if(i == j) G->arcs[i][j] = 0; // 自己到自己的距离为0 else G->arcs[i][j] = INFINITY; // 初始化为无穷大,表示无边 } } // 读入边信息,建立邻接矩阵 for(k = 0; k < G->numEdges; k++) { printf("输入边(vi, vj)的下标i、j和权值w:\n"); scanf("%d %d %d", &i, &j, &w); G->arcs[i][j] = w; G->arcs[j][i] = w; // 因为是无向图,矩阵是对称的 } }邻接矩阵的优劣分析:
- 优点:
- 直观易懂:检查任意两个顶点间是否有边,时间复杂度是O(1),直接数组下标访问。
- 方便计算顶点的度(对于无向图,行或列非零元素个数;对于有向图,出度是行非零个数,入度是列非零个数)。
- 非常适合表示稠密图(边数接近顶点数平方),因为矩阵本身占据了O(V²)空间,边多也不会显著增加开销。
- 缺点:
- 空间浪费严重:对于稀疏图(边数远小于顶点数平方),矩阵中大部分都是0或INFINITY,浪费了大量空间。
- 添加/删除顶点麻烦:需要动态调整二维数组大小,成本高。通常需要预设一个足够大的MAX_VERTEX。
提示:在项目初期,如果图规模固定且比较稠密,或者需要频繁判断任意两点间关系,邻接矩阵是简单可靠的选择。
2.2 邻接表:灵活的“通讯录”
邻接表更像是一个“数组+链表”的组合。数组部分存放所有顶点,每个顶点后面跟着一个链表,链表中存储所有与该顶点直接相连的邻居顶点(及边的信息)。
C语言结构定义示例:
// 边表结点 typedef struct EdgeNode { int adjvex; // 邻接点域,存储该顶点对应的下标 int weight; // 用于存储权值,非网图可以不需要 struct EdgeNode *next; // 链域,指向下一个邻接点 } EdgeNode; // 顶点表结点 typedef struct VertexNode { char data; // 顶点信息 EdgeNode *firstedge; // 边表头指针 } VertexNode, AdjList[MAX_VERTEX]; // 图结构 typedef struct { AdjList adjList; int numVertexes, numEdges; // 图的当前顶点数和边数 } GraphAdjList;初始化一个无向图的邻接表:
void CreateALGraph(GraphAdjList *G) { int i, j, k; EdgeNode *e; printf("输入顶点数和边数:\n"); scanf("%d %d", &G->numVertexes, &G->numEdges); // 读入顶点信息,建立顶点表 for(i = 0; i < G->numVertexes; i++) { printf("输入第%d个顶点信息: ", i+1); scanf(" %c", &G->adjList[i].data); G->adjList[i].firstedge = NULL; // 将边表置为空表 } // 建立边表(头插法,更简单) for(k = 0; k < G->numEdges; k++) { printf("输入边(vi, vj)的顶点序号:\n"); scanf("%d %d", &i, &j); // 为边(i, j)生成边表结点,头插法插入顶点i的链表 e = (EdgeNode*)malloc(sizeof(EdgeNode)); e->adjvex = j; e->next = G->adjList[i].firstedge; G->adjList[i].firstedge = e; // 因为是无向图,还需对称地插入边(j, i) e = (EdgeNode*)malloc(sizeof(EdgeNode)); e->adjvex = i; e->next = G->adjList[j].firstedge; G->adjList[j].firstedge = e; } }邻接表的优劣分析:
- 优点:
- 节省空间:只存储实际存在的边,对于稀疏图,空间复杂度为O(V+E),远优于邻接矩阵。
- 添加顶点灵活:只需在数组末尾添加,并初始化其边链表即可。
- 能高效地遍历一个顶点的所有邻接点,这对于BFS/DFS等遍历算法至关重要。
- 缺点:
- 判断任意两顶点间是否有边效率较低,需要遍历其中一个顶点的链表,时间复杂度为O(度)。
- 对有向图求入度不方便(需要遍历整个邻接表或额外维护一个逆邻接表)。
- 链表结构带来的内存开销(指针)和缓存不友好问题。
注意:在实际工程中,除非图非常小且稠密,否则邻接表通常是更通用的选择。现代编程语言中(如C++的
vector<list<int>>,Python的字典嵌套列表)都能很方便地实现邻接表。
3. 核心操作实现:从建图到遍历
有了图的表示方法,我们就可以实现一系列基本操作了。我们以邻接表为例进行实现,因为它更常用、更灵活。
3.1 顶点与边的增删查改
这些是维护图结构的基础。
1. 查找顶点:根据顶点数据(如名称)查找其索引位置。这是很多其他操作的前提。
// 在顶点表中查找值为ch的顶点,返回其下标,未找到返回-1 int LocateVex(GraphAdjList *G, char ch) { for(int i = 0; i < G->numVertexes; i++) { if(G->adjList[i].data == ch) { return i; } } return -1; }2. 增加顶点:在顶点数组末尾添加一个新顶点,并初始化其边链表。
// 向图G中添加一个数据为ch的新顶点 int AddVertex(GraphAdjList *G, char ch) { if(G->numVertexes >= MAX_VERTEX) { printf("顶点数已达上限,无法添加!\n"); return -1; } if(LocateVex(G, ch) != -1) { printf("顶点已存在!\n"); return -1; } G->adjList[G->numVertexes].data = ch; G->adjList[G->numVertexes].firstedge = NULL; G->numVertexes++; printf("顶点 %c 添加成功,索引为 %d。\n", ch, G->numVertexes-1); return G->numVertexes - 1; // 返回新顶点的索引 }3. 增加边:在指定的两个顶点之间添加一条边。需要处理无向图的双向添加。
// 在顶点v1和v2之间添加一条边(无向图) int AddEdge(GraphAdjList *G, char v1, char v2) { int i = LocateVex(G, v1); int j = LocateVex(G, v2); if(i == -1 || j == -1) { printf("顶点不存在!\n"); return 0; } // 检查边是否已存在(避免重复) EdgeNode *p = G->adjList[i].firstedge; while(p != NULL) { if(p->adjvex == j) { printf("边(%c, %c)已存在!\n", v1, v2); return 0; } p = p->next; } // 使用头插法插入边(i, j) EdgeNode *e = (EdgeNode*)malloc(sizeof(EdgeNode)); e->adjvex = j; e->next = G->adjList[i].firstedge; G->adjList[i].firstedge = e; // 无向图,对称插入边(j, i) e = (EdgeNode*)malloc(sizeof(EdgeNode)); e->adjvex = i; e->next = G->adjList[j].firstedge; G->adjList[j].firstedge = e; G->numEdges++; printf("边(%c, %c)添加成功。\n", v1, v2); return 1; }4. 删除边:删除两个顶点之间的边。需要找到边表节点并正确维护链表。
// 删除顶点v1和v2之间的边(无向图) int DeleteEdge(GraphAdjList *G, char v1, char v2) { int i = LocateVex(G, v1); int j = LocateVex(G, v2); if(i == -1 || j == -1) { printf("顶点不存在!\n"); return 0; } int success = 0; // 从顶点i的链表中删除指向j的边 EdgeNode *p = G->adjList[i].firstedge; EdgeNode *pre = NULL; while(p != NULL) { if(p->adjvex == j) { if(pre == NULL) { // 要删除的是头节点 G->adjList[i].firstedge = p->next; } else { pre->next = p->next; } free(p); success = 1; break; } pre = p; p = p->next; } // 从顶点j的链表中删除指向i的边 p = G->adjList[j].firstedge; pre = NULL; while(p != NULL) { if(p->adjvex == i) { if(pre == NULL) { G->adjList[j].firstedge = p->next; } else { pre->next = p->next; } free(p); success &= 1; // 确保两边都删除成功 break; } pre = p; p = p->next; } if(success) { G->numEdges--; printf("边(%c, %c)删除成功。\n", v1, v2); } else { printf("边(%c, %c)不存在!\n", v1, v2); } return success; }5. 删除顶点(进阶操作):这是最复杂的操作,因为删除一个顶点,需要删除所有与之相连的边。
// 删除顶点ch及其所有关联的边 int DeleteVertex(GraphAdjList *G, char ch) { int v = LocateVex(G, ch); if(v == -1) { printf("顶点不存在!\n"); return 0; } // 1. 删除所有以ch为终点的边(即其他顶点链表中指向ch的边) for(int i = 0; i < G->numVertexes; i++) { if(i == v) continue; // 跳过自己 DeleteEdge(G, G->adjList[i].data, ch); // 复用删边函数 } // 2. 释放顶点ch自身的边链表(所有以ch为起点的边) EdgeNode *p = G->adjList[v].firstedge; while(p != NULL) { EdgeNode *temp = p; p = p->next; free(temp); } G->adjList[v].firstedge = NULL; // 3. 将顶点数组中最后一个顶点移动到被删除的位置,以保持数组紧凑 G->adjList[v] = G->adjList[G->numVertexes - 1]; // 4. 非常重要:更新所有边表中,原来指向最后一个顶点的指针,让其指向新的位置v for(int i = 0; i < G->numVertexes; i++) { p = G->adjList[i].firstedge; while(p != NULL) { if(p->adjvex == G->numVertexes - 1) { p->adjvex = v; // 重定向 } p = p->next; } } G->numVertexes--; printf("顶点 %c 及其所有边已删除。\n", ch); return 1; }实操心得:删除顶点是图操作中最易出错的部分。关键在于两步:一是清理所有关联边,二是用末尾顶点填补空缺后,必须更新整个图中所有指向原末尾顶点的引用。忘记第二步会导致“野指针”,访问到错误或已释放的内存。
3.2 图的遍历:深度优先与广度优先
遍历是图算法的基石,目的是系统地访问图中每一个顶点且仅访问一次。两种最经典的策略是深度优先搜索(DFS)和广度优先搜索(BFS)。
1. 深度优先搜索(DFS)—— “一条路走到黑”DFS类似于树的先序遍历。它从某个顶点出发,沿着一条路径不断深入,直到尽头,然后回溯,探索其他分支。递归实现非常直观。
// 访问标志数组,防止重复访问 int visited[MAX_VERTEX]; // 对邻接表表示的图G进行深度优先遍历 void DFS(GraphAdjList *G, int i) { EdgeNode *p; visited[i] = 1; // 标记当前顶点已访问 printf("%c ", G->adjList[i].data); // 打印或处理顶点 p = G->adjList[i].firstedge; while(p != NULL) { if(!visited[p->adjvex]) { // 对未访问的邻接顶点递归调用 DFS(G, p->adjvex); } p = p->next; } } // DFS遍历入口,处理非连通图 void DFSTraverse(GraphAdjList *G) { int i; for(i = 0; i < G->numVertexes; i++) { visited[i] = 0; // 初始化所有顶点为未访问 } printf("深度优先遍历结果: "); for(i = 0; i < G->numVertexes; i++) { if(!visited[i]) { DFS(G, i); // 对未访问过的顶点调用DFS } } printf("\n"); }DFS的非递归实现(使用栈):递归虽然简洁,但在图很大时可能导致栈溢出。非递归版本用栈模拟递归过程。
void DFS_NonRecursive(GraphAdjList *G, int start) { int stack[MAX_VERTEX], top = -1; int visited[MAX_VERTEX] = {0}; EdgeNode *p; printf("DFS非递归遍历: "); // 起始顶点入栈并访问 stack[++top] = start; visited[start] = 1; printf("%c ", G->adjList[start].data); while(top != -1) { int v = stack[top]; // 获取栈顶,但不弹出 p = G->adjList[v].firstedge; // 寻找v的一个未访问的邻接点 while(p != NULL) { if(!visited[p->adjvex]) { // 找到,访问并入栈 printf("%c ", G->adjList[p->adjvex].data); visited[p->adjvex] = 1; stack[++top] = p->adjvex; break; // 跳出内层while,继续从这个新顶点深入 } p = p->next; } if(p == NULL) { // v的所有邻接点都已访问,回溯 top--; } } printf("\n"); }2. 广度优先搜索(BFS)—— “层层推进”BFS类似于树的层序遍历。它从起始顶点开始,先访问所有直接邻居,然后再访问邻居的邻居,以此类推。这天然需要队列(Queue)的支持。
// 简单循环队列实现 int queue[MAX_VERTEX]; int front = 0, rear = 0; void BFS(GraphAdjList *G, int start) { int visited[MAX_VERTEX] = {0}; EdgeNode *p; printf("广度优先遍历结果: "); // 起始顶点入队并访问 printf("%c ", G->adjList[start].data); visited[start] = 1; queue[rear++] = start; // 入队 while(front != rear) { int v = queue[front++]; // 出队 p = G->adjList[v].firstedge; while(p != NULL) { if(!visited[p->adjvex]) { // 访问邻接点并入队 printf("%c ", G->adjList[p->adjvex].data); visited[p->adjvex] = 1; queue[rear++] = p->adjvex; } p = p->next; } } printf("\n"); }DFS与BFS的核心区别与应用场景:
| 特性 | 深度优先搜索 (DFS) | 广度优先搜索 (BFS) |
|---|---|---|
| 数据结构 | 栈 (递归或显式栈) | 队列 |
| 遍历顺序 | 纵向深入,回溯探索 | 横向扩散,层层推进 |
| 空间复杂度 | O(h),h为递归深度/图的最大深度 | O(w),w为图的最大宽度 |
| 经典应用 | 拓扑排序、连通分量检测、寻找路径(不一定最短)、解决迷宫问题 | 最短路径(无权图)、广播网络、社交网络中的“好友推荐” |
注意:对于非连通图,一次DFS或BFS只能遍历一个连通分量。因此遍历入口函数需要检查所有顶点,对未访问的顶点再次发起遍历,以确保访问到所有顶点。
4. 从理论到实战:一个简单社交关系模拟
理解了基本操作,我们用一个综合例子把它们串起来。假设我们要模拟一个极简的社交网络,用户可以添加好友(无向边),我们可以查找两个人的最短认识路径(通过多少中间人)。
问题简化:在无权图中,两人之间的最短认识路径就是边数最少的路径。这正好是BFS的用武之地,因为BFS按层遍历,第一次到达目标顶点时所经过的层数就是最短路径长度。
实现思路:
- 用邻接表存储用户(顶点)和好友关系(边)。
- 使用BFS搜索从用户A到用户B的路径。
- 在BFS过程中,需要记录每个顶点的前驱顶点(是谁发现它的),以便最后回溯出完整路径。
C语言实现代码:
// 查找从start到target的最短路径(无权图) void BFS_ShortestPath(GraphAdjList *G, char start, char target) { int s = LocateVex(G, start); int t = LocateVex(G, target); if(s == -1 || t == -1) { printf("用户不存在!\n"); return; } if(s == t) { printf("起始用户和目标用户是同一人。\n"); return; } int visited[MAX_VERTEX] = {0}; int predecessor[MAX_VERTEX]; // 记录前驱顶点下标 int queue[MAX_VERTEX]; int front = 0, rear = 0; int found = 0; // 初始化前驱数组为-1 for(int i = 0; i < G->numVertexes; i++) predecessor[i] = -1; visited[s] = 1; queue[rear++] = s; while(front != rear && !found) { int v = queue[front++]; EdgeNode *p = G->adjList[v].firstedge; while(p != NULL) { int adj = p->adjvex; if(!visited[adj]) { visited[adj] = 1; predecessor[adj] = v; // 记录是从v到达adj的 queue[rear++] = adj; if(adj == t) { // 找到目标 found = 1; break; } } p = p->next; } } if(found) { // 回溯路径 int path[MAX_VERTEX], index = 0; int cur = t; while(cur != -1) { path[index++] = cur; cur = predecessor[cur]; } printf("从 %c 到 %c 的最短认识路径(通过 %d 个人): ", start, target, index-2); for(int i = index-1; i >= 0; i--) { printf("%c", G->adjList[path[i]].data); if(i > 0) printf(" -> "); } printf("\n"); } else { printf("用户 %c 和 %c 之间没有连通路径。\n", start, target); } }测试这个功能:
int main() { GraphAdjList G; // 假设初始化图,添加一些用户和好友关系 // CreateALGraph(&G); // 或者手动添加 G.numVertexes = 0; G.numEdges = 0; AddVertex(&G, 'A'); // 用户A AddVertex(&G, 'B'); // 用户B AddVertex(&G, 'C'); AddVertex(&G, 'D'); AddVertex(&G, 'E'); AddEdge(&G, 'A', 'B'); // A和B是好友 AddEdge(&G, 'A', 'C'); // A和C是好友 AddEdge(&G, 'B', 'D'); // B和D是好友 AddEdge(&G, 'C', 'D'); // C和D是好友 AddEdge(&G, 'D', 'E'); // D和E是好友 // 查找A到E的最短路径 BFS_ShortestPath(&G, 'A', 'E'); // 输出:A -> C -> D -> E (通过2个人) BFS_ShortestPath(&G, 'A', 'D'); // 输出:A -> B -> D 或 A -> C -> D (通过1个人) return 0; }这个简单的例子展示了如何将基础的图操作和遍历算法结合起来,解决一个实际的问题。BFS在这里完美地扮演了“寻找最少中间人”的角色。
5. 性能考量与工程实践中的陷阱
在学校做算法题,图的规模可能很小。但在实际工程中,图可能拥有数百万甚至数十亿的顶点和边(如社交网络、网页链接图)。这时,每一个基础操作的实现细节都至关重要。
1. 空间效率是首要考虑对于超大规模稀疏图,邻接矩阵完全不可行。邻接表是唯一选择,但链表指针带来的内存开销也不容小觑。在C++中,vector<vector<int>>(向量套向量)通常比vector<list<int>>(向量套链表)有更好的缓存局部性,访问更快。在追求极致性能时,甚至会使用压缩稀疏行(CSR)等格式。
2. 顶点ID的映射我们例子中用字符表示顶点,实际中可能是字符串(用户名)、数字ID或复杂对象。直接将其作为数组下标不现实。通常的作法是:
- 维护一个从顶点数据到内部整数ID(0,1,2,...)的映射(哈希表)。
- 图的内部操作全部使用整数ID,高效且统一。
- 只在输入输出时进行映射转换。 这解释了为什么我们的
LocateVex函数如此重要,它是连接外部数据和内部表示的桥梁。
3. 边的去重与快速查找在添加边时,我们遍历链表检查是否重复,时间复杂度是O(度)。对于度数很高的顶点(网络中的“大V”),这可能很慢。工程上可能会:
- 使用哈希集合(如C++的
unordered_set)代替链表来存储邻接点,将查找复杂度降至平均O(1)。 - 或者,在批量建图时,先收集所有边,排序去重后再构建邻接表。
4. 遍历中的“已访问”标记我们的visited数组是全局的、与顶点数同大小的整型数组。在多次遍历或图非常大时,反复初始化这个数组(O(V))会成为瓶颈。一个优化技巧是使用“时间戳”或“代”的概念:
- 用一个全局计数器
mark。 - 每个顶点存储一个
last_visited标记。 - 每次遍历时,
mark++。判断一个顶点是否被访问过,只需看它的last_visited是否等于当前的mark。 - 这样就避免了每次遍历前对整个数组的初始化。
5. 内存管理在C语言中,我们手动malloc和free边节点。在删除顶点或整个图时,必须小心地释放所有链表内存,防止内存泄漏。在更高级的语言中(如Java, Python),虽然垃圾回收器会帮忙,但对于长期运行的服务,仍要注意对象引用,避免因缓存等原因导致图节点无法被回收。
6. 并发访问如果图结构需要被多个线程同时读取和修改(例如,一个实时更新的推荐系统),那么基本的邻接表操作就不是线程安全的。在顶点i的链表上插入一条边时,另一个线程可能正在遍历这个链表,导致不可预知的行为。这时需要引入锁机制(如读写锁),或者采用并发数据结构,但这会显著增加复杂度并影响性能。
6. 如何测试你的图实现?
写完代码只是第一步,确保其正确性更为关键。对于图这种复杂的数据结构,需要有系统的测试策略。
1. 单元测试基础操作
- 建图与查询:创建一个小图,测试
LocateVex、顶点度数计算是否正确。 - 增删边:添加一条边,验证两个顶点的邻接表中是否都出现对方。删除边后,再次验证是否消失。特别注意删除最后一条边或重复删除的情况。
- 增删顶点:添加顶点,验证顶点数组和计数。删除一个顶点(尤其是中间顶点),验证:1)其所有关联边是否被删除;2)末尾顶点是否被正确移动;3)其他顶点对它的引用是否被正确更新。这是最容易出错的环节。
2. 遍历测试
- 连通图:创建一个简单的链状或星状图,手动推导DFS和BFS顺序,与程序输出对比。
- 非连通图:创建两个互不连接的子图,测试遍历入口函数是否能访问到所有顶点。
- 有向图:调整你的代码支持有向边(只在起点的邻接表中添加边),测试遍历顺序是否符合预期。
3. 算法功能测试
- 最短路径:使用我们上面实现的
BFS_ShortestPath,在不同形状的图上测试(直线、分叉、环),验证其输出的路径是否确实是最短的。 - 边界条件:测试起点等于终点、起点终点不连通、图中只有一个顶点、空图等情况,程序是否能优雅处理,不会崩溃或输出错误结果。
4. 压力与性能测试(可选)
- 生成大规模随机图(例如1万个顶点,10万条边),测试建图、遍历、最短路径查询的时间,是否符合预期(例如,BFS时间复杂度应为O(V+E))。
- 使用内存分析工具(如Valgrind)检查C/C++实现中是否存在内存泄漏。
一个实用的技巧是,将你的图实现封装成独立的模块(.c和.h文件),并编写一个全面的测试程序,覆盖上述所有情况。这不仅能保证代码质量,未来扩展功能(如加权图、最小生成树算法)时,也能快速验证基础是否稳固。
图的基本操作是数据结构中承上启下的关键一环。它既需要你扎实掌握指针、链表、数组、队列、栈这些基础,又是你学习最短路径、最小生成树、拓扑排序、网络流等高级算法的必经之路。我建议在理解的基础上,自己动手将邻接矩阵和邻接表两种实现都敲一遍,并完成增删查改和遍历操作。过程中遇到的每一个编译错误和逻辑Bug,都会让你对图在内存中的形态有更深的理解。当你能够不假思索地写出非递归DFS和记录路径的BFS时,这些知识才真正变成了你自己的东西。