尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

C++图数据结构实现:邻接矩阵与邻接表详解与实战

C++图数据结构实现:邻接矩阵与邻接表详解与实战 1. 项目概述为什么图论是程序员的必修课如果你正在学习算法或者准备面试那么“图”这个概念你一定绕不开。它不像数组、链表那样直观但却是描述现实世界复杂关系最强大的工具。社交网络的好友关系、地图导航的路径规划、编译器中的依赖分析甚至是游戏里的寻路AI背后都是图论在支撑。很多初学者觉得图论抽象、难懂代码写起来也复杂其实关键在于没有把概念和具体的代码实现清晰地对应起来。今天我们就抛开那些晦涩的数学定义直接从一个C程序员的角度手把手带你从零构建图的数据结构核心就是两种最经典的存储方式邻接矩阵和邻接表。我会用最直白的语言解释它们是什么、什么时候用、以及怎么用C高效地实现过程中穿插我踩过的坑和性能调优的心得。无论你是正在刷题的学生还是需要处理网络关系数据的开发者这篇内容都能让你对图有一个扎实、可实操的理解。2. 图论核心概念与程序设计中的映射在写代码之前我们必须统一“语言”。图论里的术语在程序设计中都有其对应的实体和逻辑理解这个映射关系是后续一切的基础。2.1 顶点与边程序世界的基本元素图Graph由两部分组成顶点Vertex 也叫节点 Node和边Edge。在程序里顶点通常用一个唯一的标识符ID来表示最简单的方式就是用一个整数比如0, 1, 2, ...。这个ID就是我们在数组中的下标这是理解邻接矩阵的关键。边则表示顶点之间的关系。对于无向图Undirected Graph边(A, B)表示A和B是双向连通的就像微信好友关系。而在有向图Directed Graph中边A, B通常用尖括号表示方向意味着关系从A指向B比如微博的关注关系你关注了别人但别人不一定关注你。在代码中一条边至少需要存储两个信息它连接的两个顶点。如果图是带权重的Weighted Graph比如地图上道路的长度、网络传输的带宽那么边还需要附带一个权重值。所以一条边在内存里可以简单地用一个结构体struct或元组tuple来表示包含两个顶点ID和一个可选的权重。注意在实际项目中顶点的ID不一定非要从0开始的连续整数。但如果能用连续整数会极大简化存储和访问因为可以直接用数组下标进行O(1)的随机访问。如果顶点ID是字符串或其他复杂类型我们通常会维护一个从ID到数组索引的映射map。2.2 度、路径与连通性算法逻辑的基石理解了点和线我们再看几个关键属性它们直接决定了算法的逻辑。度Degree对于无向图一个顶点的度就是与它相连的边的数量。在程序中计算一个顶点的度就是在查询这个顶点有多少个邻居。这个操作的速度直接取决于我们选择的存储结构。入度In-degree与出度Out-degree这是针对有向图的概念。入度是指有多少条边指向该顶点出度是指从该顶点出发有多少条边。在任务调度拓扑排序或网页排名PageRank等算法中这两个概念至关重要。路径Path与环Cycle路径是一系列顶点的序列其中每两个相邻顶点之间都有边相连。如果路径的起点和终点是同一个顶点且至少包含一条边那就形成了一个环。检测图中是否存在环是判断任务依赖是否合理死锁检测、图是否为树等问题的关键。连通性Connectivity如果图中任意两个顶点之间都存在路径那么这个图就是连通的。对于有向图还有强连通任意两点可互达的概念。判断连通性通常使用深度优先搜索DFS或广度优先搜索BFS算法。这些概念不是孤立的。当你用DFS遍历图时你就是在探索路径当你统计每个顶点的度时你就在分析图的结构。把这些抽象概念和具体的遍历、统计代码结合起来图论就变得可触摸了。3. 邻接矩阵直观的“地图”存储法邻接矩阵Adjacency Matrix是最直观的存储方式。想象一个N个顶点的图我们用一个N×N的二维数组矩阵matrix来表示它。如果matrix[i][j]的值不为零通常为1或权重值就表示顶点i到顶点j之间存在一条边。3.1 设计思路与内存布局为什么选择二维数组因为它提供了顶点间关系的“常量时间”查询。对于任意两个顶点i和j我只需要O(1)的时间就能判断它们是否相连以及获取边的权重。这种速度优势在某些场景下是无法替代的。它的内存布局非常规整。假设我们有5个顶点0~4下图展示了一个无向无权图的邻接矩阵0 1 2 3 4 0 [0 1 0 0 1] 1 [1 0 1 1 0] 2 [0 1 0 1 0] 3 [0 1 1 0 1] 4 [1 0 0 1 0]矩阵沿主对角线对称因为边(i, j)和(j, i)是等价的。对于有向图矩阵则不一定对称。3.2 C实现与模板化设计下面是一个支持带权有向/无向图的邻接矩阵C类实现。我采用了模板来支持不同的权重类型int, float, double等。#include vector #include iostream template typename WeightType int // 默认权重为整型 class AdjacencyMatrixGraph { private: int numVertices_; bool directed_; std::vectorstd::vectorWeightType matrix_; // 用一个特定的值表示“无边”对于整数权重常用0或-1这里用0表示无边对于有权图需确保0不是有效权重 const WeightType NO_EDGE WeightType(0); public: // 构造函数初始化n个顶点的图directed指示是否为有向图 AdjacencyMatrixGraph(int n, bool directed false) : numVertices_(n), directed_(directed), matrix_(n, std::vectorWeightType(n, NO_EDGE)) { } // 添加边从u到v权重为w void addEdge(int u, int v, WeightType w WeightType(1)) { if (u 0 || u numVertices_ || v 0 || v numVertices_) { throw std::out_of_range(Vertex index out of range); } matrix_[u][v] w; if (!directed_) { // 如果是无向图对称位置也要设置 matrix_[v][u] w; } } // 判断是否存在从u到v的边 bool hasEdge(int u, int v) const { return matrix_[u][v] ! NO_EDGE; } // 获取边(u, v)的权重 WeightType getWeight(int u, int v) const { return matrix_[u][v]; } // 获取顶点的出边邻居对于无向图就是所有邻居 std::vectorint getNeighbors(int u) const { std::vectorint neighbors; for (int v 0; v numVertices_; v) { if (matrix_[u][v] ! NO_EDGE) { neighbors.push_back(v); } } return neighbors; // 注意返回局部对象的拷贝对于频繁调用可考虑传递引用参数 } // 获取顶点数量 int getNumVertices() const { return numVertices_; } // 打印矩阵用于调试 void printMatrix() const { for (int i 0; i numVertices_; i) { for (int j 0; j numVertices_; j) { std::cout matrix_[i][j] ; } std::cout std::endl; } } };实现要点解析模板化权重使用template typename WeightType使得这个图类可以轻松处理整数、浮点数等不同类型的权重提高了代码的复用性。NO_EDGE的选择这里用WeightType(0)表示无边。这在无权图中很自然0表示无边1表示有边。但在有权图中如果0是一个合法的权重比如两点间距离恰好为0就会产生歧义。一个更健壮的做法是使用std::optionalWeightType或者一个特殊的标记值如INT_MAX表示无穷大。这里为了代码简洁先这样处理但你需要根据实际场景调整。添加边的逻辑注意处理无向图时的对称性。addEdge时如果是无向图需要同时设置matrix_[u][v]和matrix_[v][u]。获取邻居getNeighbors函数需要遍历一行中的所有元素时间复杂度是O(V)。这是邻接矩阵的一个劣势。3.3 优势、劣势与适用场景分析邻接矩阵的优点和缺点都极其鲜明选择与否完全取决于你的应用场景。优势查询速度极快判断任意两点间是否有边或者获取边的权重都是O(1)的操作。实现简单直观代码结构非常清晰易于理解和调试。对稠密图友好当图的边数接近顶点数的平方时即稠密图矩阵的空间利用率高。劣势空间复杂度高需要O(V^2)的空间对于顶点数V很大的稀疏图边数远小于V^2这会造成巨大的内存浪费。一个100万个顶点的图矩阵就需要1万亿个存储单元这显然不现实。遍历邻居效率低要找出一个顶点的所有邻居必须扫描对应的一整行即使它只有一两个邻居也需要O(V)的时间。动态添加顶点开销大如果图需要频繁增加顶点二维数组的扩容成本很高需要重新分配和拷贝整个矩阵。适用场景图规模较小顶点数通常在几百到几千的量级。需要频繁进行任意两点间的边查询或更新。例如某些图论算法中需要反复检查边是否存在。图非常稠密边数接近V^2此时矩阵的空间浪费相对较小。算法本身需要矩阵运算比如利用图的邻接矩阵计算幂次来寻找指定长度的路径数。实操心得在LeetCode等编程题中如果题目给出的顶点数n明确小于1000并且图比较稠密我会优先考虑使用邻接矩阵因为代码写起来快不容易出错。但在实际工程项目中尤其是处理社交网络、网页链接等大规模稀疏图时邻接矩阵几乎不会被采用。4. 邻接表高效的“关系链”存储法为了解决邻接矩阵的空间浪费问题邻接表Adjacency List应运而生。它的核心思想是只为每个顶点存储它实际连接出去的边。这就像通讯录每个人名下只记录他直接联系的朋友而不是记录全世界所有人是否是他的朋友。4.1 设计思路与数据结构选型邻接表有多种实现方式最常用的是使用一个数组或向量数组的每个元素对应一个顶点而这个元素本身是一个链表或动态数组里面存储了该顶点的所有邻居信息。对于无权图这个列表可以只存邻居的顶点ID。对于带权图则需要存储一个(邻居ID, 权重)对。在C中我们有几种选择std::vectorstd::vectorint最常用。内层的vector存储每个顶点的邻居列表。访问随机缓存友好添加边平均O(1)。std::vectorstd::listint使用链表。在需要频繁从列表中间插入或删除边时这种场景在图算法中较少见链表可能更有优势但遍历和随机访问性能不如vector。std::vectorstd::setint或std::vectorstd::unordered_setint使用集合。优点是自动去重和快速查找某个邻居是否存在O(log n)或平均O(1)但存储开销稍大且遍历顺序可能不确定unordered_set。对于绝大多数算法竞赛和工程场景vectorvectorpairint, WeightType是邻接表实现带权图的最佳选择它在空间和时间的平衡上做得最好。4.2 C实现基于vector的灵活方案下面是一个基于vector的邻接表实现同样支持有向/无向和带权图。#include vector #include utility // for std::pair #include iostream template typename WeightType int class AdjacencyListGraph { private: int numVertices_; bool directed_; // 核心数据结构每个顶点对应一个vector里面存的是pair(邻居顶点, 权重) std::vectorstd::vectorstd::pairint, WeightType adjacencyList_; public: AdjacencyListGraph(int n, bool directed false) : numVertices_(n), directed_(directed), adjacencyList_(n) { } // 添加边 void addEdge(int u, int v, WeightType w WeightType(1)) { if (u 0 || u numVertices_ || v 0 || v numVertices_) { throw std::out_of_range(Vertex index out of range); } adjacencyList_[u].emplace_back(v, w); // 使用emplace_back原地构造效率更高 if (!directed_ u ! v) { // 无向图且不是自环需要添加反向边 adjacencyList_[v].emplace_back(u, w); } } // 判断是否存在从u到v的边 (效率较低需要线性搜索) bool hasEdge(int u, int v) const { for (const auto neighbor : adjacencyList_[u]) { if (neighbor.first v) { return true; } } return false; } // 获取边(u, v)的权重 (同样需要线性搜索) WeightType getWeight(int u, int v) const { for (const auto neighbor : adjacencyList_[u]) { if (neighbor.first v) { return neighbor.second; } } // 如果边不存在可以返回一个特定值或抛出异常。这里简单返回默认值。 return WeightType(0); // 注意这要求0不是有效权重否则歧义。 } // 获取顶点u的所有出边邻居常量时间获取引用避免拷贝 const std::vectorstd::pairint, WeightType getNeighbors(int u) const { return adjacencyList_[u]; } // 获取顶点数量 int getNumVertices() const { return numVertices_; } // 打印邻接表用于调试 void printList() const { for (int i 0; i numVertices_; i) { std::cout i : ; for (const auto [v, w] : adjacencyList_[i]) { // C17结构化绑定 std::cout - ( v , w ) ; } std::cout std::endl; } } };实现要点解析核心数据结构std::vectorstd::vectorstd::pairint, WeightType adjacencyList_。这是整个类的灵魂。外层vector的索引是顶点ID内层vector存储该顶点的所有出边每条边是一个(目标顶点ID, 权重)对。添加边的效率addEdge操作平均时间复杂度是O(1)只需要在对应顶点的列表末尾添加一个元素。这是它相比邻接矩阵在稀疏图下的巨大优势。查询边的劣势hasEdge和getWeight函数需要遍历顶点u的邻居列表来查找v时间复杂度是O(degree(u))。在最坏情况下比如完全图这可能退化为O(V)。这是邻接表为节省空间付出的代价。如果应用需要频繁的边存在性查询可以考虑使用vectorunordered_mapint, WeightType将邻居查找优化到平均O(1)。获取邻居的高效性getNeighbors函数直接返回了内层vector的常量引用时间复杂度O(1)。这是图遍历算法如DFS、BFS最频繁的操作邻接表在这方面表现优异。无向边的处理添加无向边时需要同时向u和v的邻居列表中添加对方。注意处理自环u v的情况避免重复添加。4.3 性能对比与深度优化策略让我们通过一个表格来直观对比两种存储结构特性邻接矩阵邻接表 (vector of vector)空间复杂度O(V^2)O(V E)检查边(u,v)是否存在O(1)O(degree(u))或O(log(degree(u)))(若内层用set)获取顶点u的所有邻居O(V)O(degree(u))添加一条边O(1)O(1)平均删除一条边O(1)O(degree(u))(需查找)适用图类型稠密图小规模图稀疏图大规模图内存访问模式连续缓存友好可能不连续遍历时缓存局部性一般邻接表的优化技巧预分配内存如果你能预估每个顶点大致的邻居数量可以在初始化时使用adjacencyList_[i].reserve(estimated_degree)来预分配内存减少vector动态扩容带来的开销。使用emplace_back在添加边时使用emplace_back(v, w)而非push_back(make_pair(v, w))可以直接在vector内存中构造对象避免临时对象的创建和拷贝。考虑unordered_map变体对于需要极快边查询且不关心邻居顺序的场景std::vectorstd::unordered_mapint, WeightType是更好的选择。hasEdge和getWeight可以优化到平均O(1)但牺牲了内存和遍历的缓存友好性。压缩稀疏矩阵CSR在超大规模图计算如图神经网络中工业级系统会使用压缩稀疏行Compressed Sparse Row, CSR格式它用三个数组来存储整个图的边信息能极致地压缩内存并保持高效的遍历能力。这可以看作是邻接表的一种高度优化和标准化形式。踩坑记录我曾经在一个社交网络分析项目中使用vectorlist实现邻接表以为链表在动态增删上更有优势。结果性能测试被vectorvector完爆。原因是现代CPU缓存机制下连续内存访问vector的速度远快于随机内存访问list。图遍历是顺序访问邻居vector的缓存命中率极高。除非有非常特殊的频繁中间插入删除需求否则无脑选vectorvector。5. 从存储到算法DFS/BFS遍历的实现差异存储结构选好了接下来就要用它来做点事情。深度优先搜索DFS和广度优先搜索BFS是图论算法的基础。同样的算法逻辑用不同的存储结构实现代码细节和性能表现会有差异。5.1 基于邻接矩阵的遍历实现以DFS递归实现为例void dfsMatrix(const AdjacencyMatrixGraphint graph, int v, std::vectorbool visited) { visited[v] true; std::cout v ; // 访问顶点 // 遍历所有顶点检查是否为邻居 for (int i 0; i graph.getNumVertices(); i) { if (graph.hasEdge(v, i) !visited[i]) { // 这里hasEdge是O(1)的 dfsMatrix(graph, i, visited); } } }特点分析外层循环需要遍历所有顶点V即使当前顶点v只有很少的邻居。因此基于邻接矩阵的DFS/BFS其时间复杂度都是O(V^2)。在稀疏图上这非常低效因为做了大量无用的hasEdge检查。5.2 基于邻接表的遍历实现同样实现DFSvoid dfsList(const AdjacencyListGraphint graph, int v, std::vectorbool visited) { visited[v] true; std::cout v ; // 直接遍历v的邻居列表 for (const auto [neighbor, weight] : graph.getNeighbors(v)) { // C17结构化绑定 if (!visited[neighbor]) { dfsList(graph, neighbor, visited); } } }特点分析这里直接遍历顶点v的邻居列表循环次数等于v的度degree(v)。对整个图做一次完整的DFS每个顶点被访问一次每条边被检查两次无向图或一次有向图。因此总时间复杂度是O(V E)。对于稀疏图E ~ V这比O(V^2)要好得多。BFS的实现差异同样体现在获取邻居的方式上。邻接表的BFS队列操作中从队列取出顶点u后是遍历graph.getNeighbors(u)而邻接矩阵则是遍历所有顶点i并检查graph.hasEdge(u, i)。性能提示在绝大多数涉及图遍历的算法题中输入规模顶点数V和边数E都会给出。如果V很大比如10^5但边数E相对较小那么这一定是一个稀疏图必须使用邻接表否则O(V^2)的复杂度必然超时。这是选择存储结构的第一条黄金法则。6. 实战选择与构建——以LeetCode经典题为例理论说再多不如看实战。我们拿LeetCode 1971. “寻找图中是否存在路径”这道题来举例。题目给定一个无向图顶点数n边数edges判断顶点source和destination之间是否存在路径。6.1 场景分析与数据结构选择首先分析图是无向的顶点数n最大到2 * 10^5边数edges长度最大到2 * 10^5。这明显是一个大规模稀疏图边数最多和顶点数同量级。因此邻接矩阵O(n^2)空间绝对不可行必须使用邻接表。我们的目标只是判断连通性不需要权重所以邻接表内层存储int即可。6.2 邻接表构建与BFS/DFS搜索这里给出BFS的解决方案#include vector #include queue using namespace std; class Solution { public: bool validPath(int n, vectorvectorint edges, int source, int destination) { // 1. 构建邻接表 vectorvectorint adjList(n); for (const auto edge : edges) { int u edge[0], v edge[1]; adjList[u].push_back(v); adjList[v].push_back(u); // 无向图双向添加 } // 2. BFS遍历 vectorbool visited(n, false); queueint q; q.push(source); visited[source] true; while (!q.empty()) { int curr q.front(); q.pop(); if (curr destination) { return true; } // 遍历当前顶点的所有邻居 for (int neighbor : adjList[curr]) { if (!visited[neighbor]) { visited[neighbor] true; q.push(neighbor); } } } return false; // BFS结束仍未找到终点 } };代码细节与优化点邻接表构建vectorvectorint adjList(n);直接初始化n个空的vector。遍历边数组向两个顶点的列表中添加对方。这是标准的无向图构建方式时间复杂度O(E)。BFS队列使用queue进行广度优先遍历。visited数组防止重复访问和陷入循环。提前终止一旦在队列中取出destination立即返回true这是一个有效的优化。空间优化考虑对于超大规模图visited数组可以用vectorchar或vectorbool需注意其特化问题来节省空间。如果顶点ID范围很大但不连续可能需要使用unordered_set来记录已访问顶点。6.3 邻接矩阵为何在此处失败如果我们强行使用邻接矩阵vectorvectorbool matrix(n, vectorbool(n, false)); for(...) { matrix[u][v] matrix[v][u] true; }当n2*10^5时矩阵需要存储4e10个布尔值。即使每个bool只占1字节也需要大约40GB的内存这远远超出了任何在线判题系统的内存限制通常是几百MB。程序会立刻因为“内存超限”而失败。这个例子清晰地展示了在稀疏图和大规模图场景下邻接表是唯一可行的选择。7. 高级话题邻接表的变体与工程实践掌握了基础的邻接表在实际项目中你可能会遇到更复杂的需求这就需要我们对基础结构进行扩展。7.1 支持动态顶点与边属性基础的邻接表只存储了拓扑结构。现实中顶点和边往往附带丰富的属性。顶点属性在社交网络中顶点用户可能有姓名、年龄、城市等属性。我们可以用一个与adjacencyList_平行的vectorVertexData来存储索引就是顶点ID。边属性在交通网络中边道路可能有长度、限速、拥堵状态等。我们内层vector存储的就不再是简单的pairint, weight而是一个Edge结构体或者存储边ID通过另一个边列表来查询属性。struct VertexData { string name; int age; // ... 其他属性 }; struct EdgeData { int from, to; WeightType weight; string roadName; // ... 其他属性 }; class AdvancedGraph { vectorVertexData vertices_; vectorvectorint adjacencyList_; // 存储的是边的索引 vectorEdgeData edges_; };这种将拓扑结构与属性数据分离的设计更符合数据库的范式化思想也便于单独对属性进行索引和查询。7.2 处理超大规模图CSR格式简介当图大到无法单机内存存放时例如数十亿顶点和边就需要分布式存储和计算。此时邻接表的vectorvectorT形式因为内存不连续和指针开销效率不高。工业界标准格式是压缩稀疏行CSR。CSR用三个数组表示一个图offsets或row_ptr长度为V1。offsets[i]表示顶点i的边在edges数组中的起始索引。edges或col_ind按顺序存储所有边的目标顶点ID。weights可选按相同顺序存储边的权重。例如对于邻接表0: [1, 2], 1: [2], 2: [0, 1]对应的CSR表示offsets [0, 2, 3, 5](顶点0有2条边起始于索引0顶点1有1条边起始于索引2...)edges [1, 2, 2, 0, 1]CSR的优势在于极致压缩消除了vector的每个内层容器开销。内存连续offsets和edges都是连续数组对CPU缓存极其友好。并行友好规整的数据布局便于SIMD指令和多线程处理。在CUDA编程或使用图计算框架如Google的Pregel、Apache Giraph时你处理的数据通常就是CSR格式。7.3 常见陷阱与调试技巧即使理解了原理实现时也容易踩坑。无向图边重复添加在addEdge时如果忘记为无向图添加反向边会导致图变成“单向”的遍历和连通性判断都会出错。务必在无向图添加边时执行两次adjacencyList_[u].push_back(v)和adjacencyList_[v].push_back(u)。顶点索引越界这是最常见的运行时错误。在addEdge、hasEdge等任何接受顶点ID作为参数的函数开头必须添加边界检查。在生产代码中这应该是强制性的。自环处理添加边(u, u)时对于无向图如果代码是adjacencyList_[u].push_back(u); adjacencyList_[u].push_back(u);就会在同一个列表中添加两次自环。这通常不是问题但如果你需要严格的无重复边就需要检查。遍历时的迭代器失效在遍历一个顶点的邻居列表时例如在for (auto it list.begin(); ...)循环中切忌直接对该列表进行增删操作这会导致迭代器失效引发未定义行为。如果需要修改可以先记录要修改的内容遍历后再处理。性能热点对于hasEdge这种需要线性搜索的操作如果成为性能瓶颈例如在稠密子图中频繁调用就需要考虑更换内层数据结构为unordered_set或unordered_map。调试图算法时一个非常有效的方法是编写一个小的printGraph()函数以可读的格式打印出邻接表或邻接矩阵。肉眼检查前几行数据往往能快速发现边添加错误、索引错位等问题。对于复杂算法可以尝试在极小规模的、手工可以推导的图上比如3-5个顶点运行将程序每一步的状态与你的手动推导对比。
返回列表