数据结构:图------邻接矩阵与邻接表深度剖析
一、图的基础概念
图
Graph是多对多关系的数据结构,由顶点集合V、边集合E组成,记为 G=(V,E)。
- 顶点(Vertex):图中的节点
- 边(Edge):顶点之间的关系。
- 无向图:边没有方向,( v1,v2 )等价于( v2,v1 )
- 有向图:边有方向,< v1,v2 >代表从 v1 指向 v2 ,和反向不等价
- 权值:边上可以附带数值,此时叫带权图(网)
- 度:
- 无向图:顶点的度 = 相连边的数量
- 有向图:入度(指向该点)、出度(该点指出)
树是图的特例:树是连通无环无向图;图可以存在环、可以不连通。
图最大难点: 没有天然线性顺序,必须专门设计存储结构保存顶点与边的关联关系。
主流两种方案:邻接矩阵、邻接表。
二、邻接矩阵(Adjacency Matrix)
1. 原理
使用二维数组
A[n][n]存储边信息,n为顶点总数。无向图:
A[i][j] = 1 顶点i,j之间有边 A[i][j] = 0 没有边无向图邻接矩阵是对称矩阵,
A[i][j] = A[j][i]。有向图:
A[i][j]=1代表存在一条从i指向j的边,矩阵不一定对称。带权图:把1替换成边的权值,无边用无穷大
INF表示,A[i][i]=0。
2. 内存布局
n个顶点,开辟 n × n 的二维数组。
空间复杂度:O(n²),只和顶点数相关,和实际边数无关。
举例子:4个顶点无向图,矩阵是4×4。哪怕整张图只有2条边,依然要完整存储16个单元。
3. 核心操作时间复杂度
操作 复杂度 说明 判断i,j两点是否连通 O(1) 直接访问数组下标,极速 添加边 O(1) 直接赋值矩阵元素 删除边 O(1) 直接赋值清零 求某个顶点的所有邻接点 O(n) 需要遍历一整行n个元素 ✅优点
- 边的查询、增删边是常数时间,判断两点是否相连极快
- 代码实现简单,数组操作,没有链表指针开销
- 适合做矩阵运算(图论的矩阵幂、可达性计算)
❌缺点
- 空间浪费巨大。稀疏图(顶点多、边很少),大量单元都是0。例如10000个顶点,矩阵需要1亿个存储单元,直接内存爆炸。
- 遍历某个点所有邻居,必须遍历整行,效率低下。
C++ 模板邻接矩阵图类
cpp#include <iostream> #include <vector> #include <map> #include <stdexcept> #include <climits> template<class V, class W, W MAX_W = INT_MAX, bool Direction = false> class Graph { public: using Self = Graph<V, W, MAX_W, Direction>; Graph() = default; Graph(const V* vertexs, size_t n) { _vertexs.reserve(n); for (size_t i = 0; i < n; ++i) { _vertexs.push_back(vertexs[i]); _vIndexMap[vertexs[i]] = i; } // MAX_W 作为不存在边的标识值 _matrix.resize(n); for (auto& e : _matrix) { e.resize(n, MAX_W); } } size_t GetVertexIndex(const V& v) { auto ret = _vIndexMap.find(v); if (ret != _vIndexMap.end()) { return ret->second; } else { throw std::invalid_argument("不存在的顶点"); } } void _AddEdge(size_t srci, size_t dsti, const W& w) { _matrix[srci][dsti] = w; // 无向图双向赋值 if (Direction == false) { _matrix[dsti][srci] = w; } } void AddEdge(const V& src, const V& dst, const W& w) { size_t srci = GetVertexIndex(src); size_t dsti = GetVertexIndex(dst); _AddEdge(srci, dsti, w); } void Print() { // 打印顶点和下标映射关系 for (size_t i = 0; i < _vertexs.size(); ++i) { std::cout << _vertexs[i] << "-" << i << " "; } std::cout << "\n\n"; std::cout << " "; for (size_t i = 0; i < _vertexs.size(); ++i) { std::cout << i << " "; } std::cout << "\n"; // 打印邻接矩阵 for (size_t i = 0; i < _matrix.size(); ++i) { std::cout << i << " "; for (size_t j = 0; j < _matrix[i].size(); ++j) { if (_matrix[i][j] != MAX_W) std::cout << _matrix[i][j] << " "; else std::cout << "#" << " "; } std::cout << "\n"; } std::cout << "\n\n"; // 打印所有边(无向图 i<j 去重输出) for (size_t i = 0; i < _matrix.size(); ++i) { for (size_t j = 0; j < _matrix[i].size(); ++j) { if (i < j && _matrix[i][j] != MAX_W) { std::cout << _vertexs[i] << "-" << _vertexs[j] << ":" << _matrix[i][j] << "\n"; } } } } private: std::map<V, size_t> _vIndexMap; // 顶点 -> 数组下标映射 std::vector<V> _vertexs; // 顶点集合 std::vector<std::vector<W>> _matrix; // 邻接矩阵 };
三、邻接表(Adjacency List)
1. 原理
不使用完整二维数组。
- 开辟数组,数组下标代表顶点;数组每个位置存放一个链表/vector,保存该顶点所有直接相连的邻接顶点。
无向图:增加边u‑v,需要同时把
v加入u的链表,把u加入v的链表。有向图:u→v,只把v加入u的链表。
带权图:链表存储结构体,保存邻接点编号+权值。
2. 内存布局
总存储单元 = 顶点数 + 边数×2(无向图每条边存2次)。
空间复杂度:O(n+e),n顶点数,e边数。只存储真实存在的边。
稀疏图优势巨大:比如10000顶点,只有几千条边,只需要存储几千条记录,不需要n²矩阵。
3. 核心操作时间复杂度
操作 复杂度 说明 添加边 O(1) 链表尾部插入 遍历顶点i所有邻接点 O(deg(i)) 只遍历真实邻居,deg(i)是该点度数 删除边 O(deg(i)) 需要遍历链表找到目标节点再删除 判断i,j之间是否存在边 O(deg(i)) 遍历i的邻接链表挨个比对 ✅优点
- 稀疏图极度省内存,绝大多数现实场景图都是稀疏图(社交网络、路网)
- 遍历一个点的所有邻居效率很高,只访问真实相连节点。BFS、DFS遍历图几乎都优先用邻接表。
❌缺点
- 判断两个点之间有没有边,最坏要遍历链表,时间开销高。
- 结构是链表/vector,内存不连续;稠密图下,链表额外开销会大于邻接矩阵。
C++ vector版邻接表(工程最常用)
cpp#include <vector> #include <map> #include <stdexcept> namespace LinkTable { // 边结点结构体 template<class W> struct LinkEdge { int _srcIndex; int _dstIndex; W _w; LinkEdge<W>* _next; LinkEdge(const W& w) : _srcIndex(-1) , _dstIndex(-1) , _w(w) , _next(nullptr) {} }; // V:顶点类型;W:权值类型;Direction=true代表有向图,false无向图 template<class V, class W, bool Direction = false> class Graph { typedef LinkEdge<W> Edge; public: // 构造函数:传入顶点数组,初始化顶点集合与邻接表头 Graph(const V* vertexs, size_t n) { _vertexs.reserve(n); for (size_t i = 0; i < n; ++i) { _vertexs.push_back(vertexs[i]); _vIndexMap[vertexs[i]] = i; } _linkTable.resize(n, nullptr); } // 根据顶点值获取下标 size_t GetVertexIndex(const V& v) { auto ret = _vIndexMap.find(v); if (ret != _vIndexMap.end()) { return ret->second; } else { throw std::invalid_argument("不存在的顶点"); } } // 添加边 src -> dst,权值w void AddEdge(const V& src, const V& dst, const W& w) { size_t srcindex = GetVertexIndex(src); size_t dstindex = GetVertexIndex(dst); // 创建 src -> dst 的边头插法插入邻接表 Edge* sd_edge = new Edge(w); sd_edge->_srcIndex = static_cast<int>(srcindex); sd_edge->_dstIndex = static_cast<int>(dstindex); sd_edge->_next = _linkTable[srcindex]; _linkTable[srcindex] = sd_edge; // 如果是无向图,反向再加一条 dst -> src if (Direction == false) { Edge* ds_edge = new Edge(w); ds_edge->_srcIndex = static_cast<int>(dstindex); ds_edge->_dstIndex = static_cast<int>(srcindex); ds_edge->_next = _linkTable[dstindex]; _linkTable[dstindex] = ds_edge; } } private: // 修复:模板V,不能写死map<string,int> std::map<V, int> _vIndexMap; // 顶点 -> 下标映射 std::vector<V> _vertexs; // 顶点集合 std::vector<Edge*> _linkTable; // 邻接表,每个元素是链表头指针 }; }适用场景:稀疏图,绝大多数算法题、现实工程场景。
BFS、DFS、Dijkstra、拓扑排序全部默认使用邻接表。
四、邻接矩阵 vs 邻接表深度对比
对比维度 邻接矩阵 邻接表 空间复杂度 O(n²),只看顶点数 O(n+e),顶点+真实边数 适合图类型 稠密图(边很多) 稀疏图(边很少,绝大多数场景) 查询两点是否相连 O(1) O(deg(u)) 遍历某顶点全部邻居 O(n) O(deg(u)) 增加边 O(1) O(1) 删除边 O(1) O(deg(u)) 内存特征 连续数组,缓存友好 链表/vector,碎片化 典型应用 Floyd多源最短路 BFS/DFS/Dijkstra/拓扑排序 💡经典面试题:Floyd算法为什么用邻接矩阵?
Floyd是三重循环遍历所有顶点对,需要高频查询任意两点之间的边,邻接矩阵O(1)访问;如果用邻接表,遍历所有点对代价极高。
💡面试题:Dijkstra为什么大多用邻接表?
现实中图大多稀疏,邻接表节省内存,并且Dijkstra需要频繁遍历某个点的全部邻接点,邻接表只遍历真实边。
五、衍生拓展(提升博客深度)
1. 逆邻接表
针对有向图:普通邻接表存出边;逆邻接表每个顶点保存入边。快速求一个点的全部入度邻接点。
2. 十字链表(有向图优化存储)
结合邻接表+逆邻接表,一套结构同时存出边、入边,有向图专用,复杂,工程很少手写。
3. 邻接多重表(无向图优化存储)
解决无向图邻接表中一条边存两份,删除边需要删两处的问题。
4. 现实工程思考
- 社交网络,亿级顶点,绝对不可能邻接矩阵,只能邻接表思路;
- 小规模网格图,稠密,邻接矩阵简单好用;
- C++中邻接表不一定要用链表,
vector比原生链表性能强很多,vector是连续内存,缓存命中率高,刷题全部用vector。5. 常见坑
- 无向图添加边,千万记住双向添加,很多新手bug;
- 邻接矩阵顶点数量大时直接爆内存,n=1e4,10^4 ×10^4 =1e8个int,约400MB,n再大直接不可行;
- 邻接表判断两点连通不要暴力遍历,如果需要高频查询,可以额外搭配哈希集合
unordered_set。
六、小结
- 邻接矩阵:以顶点为中心,记录全部顶点两两之间的关系。牺牲空间换取两点之间O(1)查询,适合稠密图。
- 邻接表:以边为中心,只记录真实存在的边。空间高效,适合稀疏图,图遍历算法的主流选择。
图的存储是所有图算法的地基。BFS、DFS、最短路径、拓扑排序、最小生成树全部建立在这两种存储之上。选对存储结构,算法的时间复杂度才有意义。
数据结构:并查集(Disjoint‑Set‑Union,DSU)深度讲解
1. 什么是并查集
并查集,也叫不相交集合。 我们有一堆元素,元素会划分成若干互不相交的集合。支持两大核心操作:
- Find(x):查找x属于哪个集合(找根节点);判断两个元素是否在同一个集合,等价于
Find(x) == Find(y)。- Union(x,y):把x所在集合 和 y所在集合 合并成一个集合。
典型场景举例:
- 判断图中两个点是否连通;
- Kruskal算法选边构建最小生成树,判断两个顶点是否已经连通,避免成环;
- 社交网络,判断两个人是否属于同一个朋友圈;
- 检测无向图添加一条边之后会不会产生环:如果
Find(u)==Find(v),加边就会成环。并查集底层用数组实现,没有树的父子那种严格层级,是逻辑上的森林。
2. 基础版本实现
数组
parent[]:parent[x]代表元素x的父节点。
- 如果
parent[x] = x,说明x就是这个集合的根。Find:查找根
递归版本:
int Find(int x) { if(parent[x] != x) { return Find(parent[x]); } return x; }Union:集合合并
简单粗暴:直接把其中一棵树的根,挂到另一棵树的根下面。
void Union(int x,int y) { int rx = Find(x); int ry = Find(y); if(rx != ry) { parent[ry] = rx; } }初始化:每个元素自己是一个集合,
parent[i]=i
//初始化 n个元素 for(int i=0;i<n;i++) parent[i]=i;❗基础版本致命问题: 合并的时候随便挂接,树有可能退化成链表。
比如:1→2→3→4→...→n,此时Find查找时间复杂度退化(O(n)),性能很差。
3. 两大优化(并查集灵魂)
优化1:路径压缩(Path Compression)【优化Find】
查找的时候,把路径上所有节点直接指向根节点。 下一次Find,几乎一步直达根。
路径压缩Find(递归版)
int Find(int x) { if(parent[x] != x) { parent[x] = Find(parent[x]); // 递归回溯,把沿途节点父直接改成根 } return parent[x]; }路径压缩Find(迭代版,避免栈溢出,工程推荐)
int Find(int x) { // 先找到根 int root = x; while(parent[root] != root) { root = parent[root]; } // 路径压缩:全部节点直接指向root while(parent[x] != root) { int next = parent[x]; parent[x] = root; x = next; } return root; }优化2:按秩合并 / 按大小合并(Union优化)【优化合并逻辑】
秩(rank):可以理解为树的高度;或者数组size\[\]记录每颗树的节点数量。
思想:小树挂到大树的根下面,不让树变高。 不要大树挂小树,避免树深度暴涨。
两种实现:
- 按秩(高度rank)合并:rank数组记录树高度。矮树接到高树根;两棵树高度相等,新树rank+1。
- 按集合size大小合并:size\[\]记录集合元素总数,小集合挂到大集合根下面。
工程上按size合并写起来更简单,不容易出错。
完整带路径压缩+按size合并的DSU:
cpp#include <vector> using namespace std; class DSU { public: vector<int> parent; vector<int> size; // size[i]只有i是根的时候才有意义,代表集合元素个数 DSU(int n) { parent.resize(n); size.resize(n,1); for(int i=0;i<n;i++) { parent[i]=i; } } //查找 + 路径压缩 int Find(int x) { if(parent[x] != x) { parent[x] = Find(parent[x]); } return parent[x]; } //合并,返回true代表真正完成合并;false代表已经在同一个集合 bool Union(int x,int y) { int rx = Find(x); int ry = Find(y); if(rx == ry) { return false; //同一个集合,不用合并 } //小集合合并到大集合下面 if(size[rx] < size[ry]) { swap(rx,ry); } parent[ry] = rx; size[rx] += size[ry]; return true; } //判断x y是否连通,同一集合 bool IsConnected(int x,int y) { return Find(x) == Find(y); } //获取x所在集合元素数量 int GetSetSize(int x) { return size[Find(x)]; } };
4. 时间复杂度深度说明
同时开启路径压缩 + 按秩/大小合并: 单次Find/Union操作时间复杂度是 O(a(n))
a(n)是阿克曼函数的反函数。
a(n)增长极其缓慢,对于现实所有计算机n(n<=10^60),a(n)<=5。
可以近似理解为常数时间。
⚠️注意:不是严格O(1),是接近常数的准常数。面试一定要说\\alpha(n),不要直接说O(1)。
5. 并查集核心面试考点
考点1:Kruskal最小生成树中怎么用?
- 将全部边按权从小到大排序;
- 初始化DSU;
- 依次拿最小的边
(u,v,w):
- 如果
Find(u) != Find(v),说明两点不在同一集合,选这条边,执行Union(u,v);- 如果已经连通,丢弃这条边,防止产生环。
考点2:如何检测无向图是否有环?
遍历每一条边
(u,v)
- 如果
Find(u) == Find(v):u、v已经连通,再加入这条边,直接产生环。- 否则执行Union(u,v)。
注意:并查集只适合无向图判环,不能处理有向图!有向图判环要用拓扑排序/DFS。
考点3:路径压缩、按秩合并各自作用?
路径压缩:优化Find,压扁树,减少查找深度;
按秩/大小合并:优化Union,防止合并时树变成链表。
只开其中一个优化,达不到近乎常数的复杂度,两个优化要同时使用。
考点4:并查集缺点
- 适合合并、查询连通性,不支持集合的拆分(分裂集合很难做);
- 不适合遍历一个集合里面全部元素;
- 原生版本只处理无向关系,不能处理有向图。
6. 拓展:带权并查集
普通DSU只记录连通关系;带权并查集(偏移并查集),维护节点到父节点的相对权值。 经典题目:食物链、情侣关系、分数/距离约束。 多开数组
dist[],dist[x]代表x到parentx的偏移量,Find查找的时候同步更新偏移。
7. 代码使用示例
int main() { DSU dsu(5); //0~4一共5个元素 dsu.Union(0,1); dsu.Union(1,2); cout << dsu.IsConnected(0,2) << endl; //true cout << dsu.IsConnected(0,3) << endl; //false cout << dsu.GetSetSize(0) << endl; //集合大小3 return 0; }
8. OJ运用
1. 省份数量
LCR 116. 省份数量
https://leetcode.cn/problems/bLyHh0/
有
n个城市,其中一些彼此相连,另一些没有相连。如果城市a与城市b直接相连,且城市b与城市c直接相连,那么城市a与城市c间接相连。省份 是一组直接或间接相连的城市,组内不含其他没有相连的城市。
给你一个
n x n的矩阵isConnected,其中isConnected[i][j] = 1表示第i个城市和第j个城市直接相连,而isConnected[i][j] = 0表示二者不直接相连。返回矩阵中 省份 的数量
cppclass Solution { public: int findCircleNum(vector<vector<int>>& isConnected) { vector<int> ufs(isConnected.size(),-1); auto findroot=[&ufs](int x){while(ufs[x]>=0)x=ufs[x];return x;}; for(int i=0;i<isConnected.size();i++) { for(int j=0;j<isConnected[i].size();j++) { if(isConnected[i][j]==1) { int root1=findroot(i); int root2=findroot(j); if(root1!=root2) { ufs[root1]+=ufs[root2]; ufs[root2]=root1; } } } } int n=0; for(auto& e:ufs) { if(e<0)n++; } return n; } };2. 等式方程的可满足性
990. 等式方程的可满足性
https://leetcode.cn/problems/satisfiability-of-equality-equations/
给定一个由表示变量之间关系的字符串方程组成的数组,每个字符串方程
equations[i]的长度为4,并采用两种不同的形式之一:"a==b"或"a!=b"。在这里,a 和 b 是小写字母(不一定不同),表示单字母变量名。
只有当可以将整数分配给变量名,以便满足所有给定的方程时才返回
true,否则返回false。
cppclass Solution { public: bool equationsPossible(vector<string>& equations) { vector<int> ufs(26,-1); auto findroot=[&ufs](int x){while(ufs[x]>=0)x=ufs[x];return x;}; for(int i=0;i<equations.size();i++) { if(equations[i][1]=='=') { int root1=findroot(equations[i][0]-'a'); int root2=findroot(equations[i][3]-'a'); if(root1!=root2) { ufs[root1]+=ufs[root2]; ufs[root2]=root1; } } } for(int i=0;i<equations.size();i++) { if(equations[i][1]=='!') { int root1=findroot(equations[i][0]-'a'); int root2=findroot(equations[i][3]-'a'); if(root1==root2) { return false; } } } return true; } };
9.小结
- DSU两大基础操作:Find找根(路径压缩)、Union合并集合(按大小/秩合并);
- 两大优化同时使用,复杂度接近常数 a(n);
- 典型应用:无向图连通性、Kruskal最小生成树、无向图环检测;
- 不能做集合拆分,不适合有向图。




