🏆个人主页:企鹅不叫的博客
🌈专栏
- C语言初阶和进阶
- C项目
- Leetcode刷题
- 初阶数据结构与算法
- C++初阶和进阶
- 《深入理解计算机操作系统》
- 《高质量C/C++编程》
- Linux
⭐️ 博主码云gitee链接:代码仓库地址
⚡若有帮助可以【关注+点赞+收藏】,大家一起进步!
💙系列文章💙
【C++高阶数据结构】并查集
文章目录
- 💙系列文章💙
- 💎一、概念
- 🏆1.图概念
- 🏆2.邻接矩阵
- 🏆3.邻接表
- 🏆4.代码
- 💎二、图遍历
- 🏆1.广度优先遍历-BFS
- 🏆2.深度优先遍历-DFS
- 💎二、生成最小树
- 🏆1.Kruskal算法
- 🏆2.Prim算法
- 💎三、最短路径
- 🏆1.Dijkstra算法
- 🏆2.Bellman-Ford算法
💎一、概念
🏆1.图概念
图用G表示,顶点集合用V表示,边用E表示
有向图:<x, y>和<y, x>是不同的,带有方向,G3和G4
无向图:<x, y>和<y, x>是一样的,没有方向,G1和G2
无向完全图:有n个顶点的无向图中,若有n * (n-1)/2条边 (参考等差数列求和),任意两个顶点间有且仅有一条边,如G1
有向完全图:有n个顶点的无向图若有n * (n-1)条边 (参考等差数列求和),任意两个顶点间有且仅有二条边,如G4
邻接顶点:x和y是通过<x,y>连接的,就是邻接顶点
顶点的度:等于入度和出度的和,入度是从外面连接到此顶点,出度是从这个顶点连接到外面,如G3的0顶点入度是1出度是1,度是2
径的路径长度是指该路径上各个边权值的总和
权值:边附带的数据信息
连通图:在无向图中任意一对顶点都是连通的
强连通图:在有向图中任意一对顶点都是连通的
生成树:一个连通图的最小连通子图作为该图的生成树,n个顶点的连通图的生成树有n个顶点和n-1条边
图中既有节点,又有边(节点与节点之间的关系),因此,在图的存储中,只需要保存:节点和边关系即可。节点保存比较简单,只需要一段连续空间即可。其边关系一般采用邻接矩阵或邻接表的方式保存。
🏆2.邻接矩阵
因为节点与节点之间的关系就是连通与否,即为0或者1,因此邻接矩阵(二维数组)即是:先用一个数组将定点保存,然后采用矩阵来表示节点与节点之间的关系。
- 无向图的邻接矩阵是对称的,第i行(列)元素之和,就是顶点i的度。有向图的邻接矩阵则不一定是对称的,第i行(列)元素之后就是顶点i 的出(入)度。
- 如果边带有权值,并且两个节点之间是连通的,上图中的边的关系就用权值代替,如果两个顶点不通,则使用无穷大代替。
- 邻接矩阵的优势是快速找到两个顶点之间的边,适合于边比较多的图;其劣势为要找一个顶点连出去的边,需要遍历其他顶点,因此时间复杂度为O(N),并且如果顶点比较多,边比较少时,矩阵中存储了大量的0成为系数矩阵,比较浪费空间。
🏆3.邻接表
无向图邻接表存储
有向图邻接表存储
邻接表的优势为:
找一个点相连的顶点的边很快。
适合边比较少,比较稀疏的图。
劣势为:
要确认两个顶点是否相连为O(N),因为要把所有的边都找一遍。
🏆4.代码
邻接矩阵
//V表示中节点的属性,W表示边的权值,Direction表示是否为有向图,默认是无向图,默认值权值是最大 template<class V, class W, W MAX_W = INT_MAX, bool Direction = false> class Graph { public: Graph(const V* vertexs, size_t n) { //将顶点全部插入集合 _vertexs.reserve(n); for (size_t i = 0; i < n; i++) { _vertexs.push_back(vertexs[i]); _indexMap[vertexs[i]] = i; } //初始化邻接矩阵 _matrix.resize(n); for (auto& e : _matrix) { e.resize(n, MAX_W); } } //获取顶点的下标 size_t GetVertexIndex(const V& v) { //遍历map auto it = _indexMap.find(v); if (it != _indexMap.end()) { return it->second; } else { //throw invalid_argument("不存在的顶点"); assert(false); return -1; } } void AddEdge(const V& src, const V& dst, const W& w) { size_t srcindex = GetVertexIndex(src); size_t dstindex = GetVertexIndex(dst); _matrix[srcindex][dstindex] = w; if (Direction == false) { //无向图,双向的 _matrix[dstindex][srcindex] = w; } } void Print() { // 打印顶点和下标映射关系 for (size_t i = 0; i < _vertexs.size(); ++i) { cout << _vertexs[i] << "-" << i << " "; } cout << endl << endl; cout << " "; for (size_t i = 0; i < _vertexs.size(); ++i) { cout << i << " "; } cout << endl; // 打印矩阵 for (size_t i = 0; i < _matrix.size(); ++i) { cout << i << " "; for (size_t j = 0; j < _matrix[i].size(); ++j) { if (_matrix[i][j] != MAX_W) cout << _matrix[i][j] << " "; else cout << "#" << " "; } cout << endl; } cout << endl << endl; // 打印所有的边 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) { cout << _vertexs[i] << "-" << _vertexs[j] << ":" << _matrix[i][j] << endl; } } } } private: vector<V> _vertexs; //顶点集合 map<V, int> _indexMap; //顶点和下标的映射关系 vector<vector<W>> _matrix; //存储边集合的矩阵,邻接矩阵 }; void TestGraph() { string a[] = { "张三","李四","王五","赵六","周七" }; Graph<string, int>g1(a, sizeof(a) / sizeof(a[0])); g1.AddEdge("张三", "李四", 100); g1.AddEdge("张三", "王五", 200); g1.AddEdge("王五", "赵六", 10); g1.AddEdge("李四", "周七", 50); g1.Print(); }
邻接表
template<class W> struct Edge { int _srcIndex; int _dstIndex;//目标点下标 W _w; //权值 Edge<W>* _next; Edge(const W& w) :_srcIndex(-1) , _dstIndex(-1) , _w(w) , _next(nullptr) { } }; //V表示中节点的属性,W表示边的权值,Direction表示是否为有向图,默认是无向图,默认值权值是最大 template<class V, class W, W MAX_W = INT_MAX, bool Direction = false> class Graph { typedef Edge<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]); _indexMap[vertexs[i]] = i; } //初始化邻接矩阵 _tables.resize(n); } //获取顶点的下标 size_t GetVertexIndex(const V& v) { //遍历map auto it = _indexMap.find(v); if (it != _indexMap.end()) { return it->second; } else { //throw invalid_argument("不存在的顶点"); assert(false); return -1; } } void AddEdge(const V& src, const V& dst, const W& w) { size_t srcindex = GetVertexIndex(src); size_t dstindex = GetVertexIndex(dst); Edge* eg = new Edge(w); eg->_srcIndex = srcindex; eg->_dstIndex = dstindex; eg->_next = _tables[srcindex]; _tables[srcindex] = eg; if (Direction == false) { //无向图的起点和终点相互颠倒 Edge* eg = new Edge(w); eg->_srcIndex = dstindex; eg->_dstIndex = srcindex; eg->_next = _tables[dstindex]; _tables[dstindex] = eg; } } void Print() { // 打印顶点和下标映射关系 for (size_t i = 0; i < _vertexs.size(); ++i) { cout << _vertexs[i] << "-" << i << " "; } cout << endl << endl; cout << " "; for (size_t i = 0; i < _vertexs.size(); ++i) { cout << i << " "; } cout << endl; // 打印矩阵 for (size_t i = 0; i < _vertexs.size(); ++i) { cout << "[" << i << "]" << "->" << _vertexs[i] << endl; } cout << endl; for (size_t i = 0; i < _tables.size(); ++i) { cout << _vertexs[i] << "[" << i << "]->"; Edge* cur = _tables[i]; while (cur) { cout << "[" << _vertexs[cur->_dstIndex] << ":" << cur->_dstIndex << ":" << cur->_w << "]->"; cur = cur->_next; } cout << "nullptr" << endl; } } private: vector<V> _vertexs; //顶点集合 map<V, int> _indexMap; //顶点和下标的映射关系 vector<Edge*> _tables; //邻接表 }; void TestGraph() { string a[] = { "张三","李四","王五","赵六","周七" }; Graph<string, int>g1(a, sizeof(a) / sizeof(a[0])); g1.AddEdge("张三", "李四", 100); g1.AddEdge("张三", "王五", 200); g1.AddEdge("王五", "赵六", 10); g1.AddEdge("李四", "周七", 50); g1.Print(); }
💎二、图遍历
🏆1.广度优先遍历-BFS
用邻接矩阵为例子,相当于二叉树中的层序遍历
//广度优先搜索,起点 void BFS(const V& src) { //找到其下标 size_t srcindex = GetVertexIndex(src); //visited表示没有被访问过的顶点,用于标记 vector<bool> visited(_vertexs.size(), false); //使用队列完成广度遍历 queue<int>q; q.push(srcindex); visited[srcindex] = true; size_t d = 1;//记录起点的度 //dSize保证队列中的数据走完 size_t leveSize = 1; while (!q.empty()) { printf("%s的%d度好友:", src.c_str(), d); while (leveSize--) { size_t front = q.front(); q.pop(); //把front顶点的邻接顶点入队列 for (size_t i = 0; i < _vertexs.size(); ++i) { if (visited[i] == false && _matrix[front][i] != INT_MAX) { printf("[%d:%s]", i, _vertexs[i].c_str()); q.push(i); visited[i] = true; } } } cout << endl; leveSize = q.size(); ++d; } } void TestGraph() { string a[] = { "张三","李四","王五","赵六","周七" }; Graph<string, int>g1(a, sizeof(a) / sizeof(a[0])); g1.AddEdge("张三", "李四", 100); g1.AddEdge("张三", "王五", 200); g1.AddEdge("王五", "赵六", 10); g1.AddEdge("李四", "周七", 50); g1.Print(); g1.BFS("张三"); g1.DFS("张三"); }
🏆2.深度优先遍历-DFS
用邻接矩阵为例子,相当于而擦函数中的前中后序遍历
//起点和标记数组 void _DFS(size_t srcIndex, vector<bool>& visited) { printf("[%d:%s]", srcIndex, _vertexs[srcIndex].c_str()); visited[srcIndex] = true;//表示该点被标记 for (size_t i = 0; i < _vertexs.size(); ++i) { if (visited[i] == false && _matrix[srcIndex][i] != INT_MAX) { _DFS(i, visited); } } } //深度优先搜索,起点 void DFS(const V& src) { //找到其下标 size_t srcindex = GetVertexIndex(src); //visited表示没有被访问过的顶点 vector<bool> visited(_vertexs.size(), false); _DFS(srcindex, visited); } void TestGraph() { string a[] = { "张三","李四","王五","赵六","周七" }; Graph<string, int>g1(a, sizeof(a) / sizeof(a[0])); g1.AddEdge("张三", "李四", 100); g1.AddEdge("张三", "王五", 200); g1.AddEdge("王五", "赵六", 10); g1.AddEdge("李四", "周七", 50); g1.Print(); g1.BFS("张三"); g1.DFS("张三"); }
💎二、生成最小树
若连通图由n个顶点组成,则其生成树必含n个顶点和n-1条边。因此构造最小生成树的准则有三条:
- 只能使用图中的权值最小的边来构造最小生成树,这几条边加起来的权值是最小的。
- 只能使用恰好n-1条边来连接图中的n个顶点。
- 选用的n-1条边不能构成回路。构造最小生成树的方法:Kruskal算法和Prim算法。这两个算法都采用了逐步求解的贪心策略。贪心算法:是指在问题求解时,总是做出当前看起来最好的选择。也就是说贪心算法做出的不是整体最优的的选择,而是某种意义上的局部最优解。贪心算法不是对所有的问题都能得到整体最优解。
🏆1.Kruskal算法
任给一个有n个顶点的连通网络N={V,E},首先构造一个由这n个顶点组成、不含任何边的图G={V,NULL},其中每个顶点自成一个连通分量,其次不断从E中取出权值最小的一条边(若有多条任取其一),若该边的两个顶点来自不同的连通分量,则将此边加入到G中。如此重复,直到所有顶点在同一个连通分量上为止。
核心:每次迭代时,选出一条具有最小权值,且两端点不在同一连通分量上的边,加入生成树,直到选出N-1条边。
- 先判断加入边的两个顶点在不在一个集合,如果在一个集合,加入就会构成环。
- 加入的边,就把它的两个顶点合并。
- 并查集传送门
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); } struct Edge { size_t _srci;//原点 size_t _dsti;//目标点 W _w;//权值 Edge(size_t srci, size_t dsti, const W& w) :_srci(srci) ,_dsti(dsti) ,_w(w) {} //比较权值的大小,重载我们的>运算符 bool operator>(const Edge& e) const { return _w > e._w; } }; //最小生成树Kruskal W kruskalMST(Graph<V, W, MAX_W, Direction>& g) { size_t n = _vertexs.size(); //创建最小生成树 minTree._vertexs = _vertexs; minTree._indexMap = _indexMap; minTree._matrix.resize(n); for (size_t i = 0; i < n; ++i) { minTree._matrix[i].resize(n, MAX_W); } //升序的优先级队列,每次获取权值最小的边 priority_queue<Edge, vector<Edge>, greater<Edge>> minque; for (size_t i = 0; i < n; ++i) { for (size_t j = 0; j < n; ++j) { if (i < j && _matrix[i][j] != MAX_W) { minque.push(Edge(i, j, _matrix[i][j])); } } } //每次从优先级队列中获取权值最小的边,然后判断它们的起点和终点在不在一个集合 //若不在则选中这条边,并将起点和终点放入集合 //若在则说明如果选中则会形成环,此时忽略掉这条边 //重复上述操作,直到优先级队列为空 // 选出n-1条边 int size = 0; W totalW = W(); UnionFindSet ufs(n);//利用并查集 while (!minque.empty()) { Edge min = minque.top(); minque.pop(); if (!ufs.InSet(min._srci, min._dsti)) { cout << _vertexs[min._srci] << "->" << _vertexs[min._dsti] << ":" << min._w << endl; //往我们的最小生成树中添加这条边 minTree._AddEdge(min._srci, min._dsti, min._w); //将这条边添加到我们的并查集当中 ufs.Union(min._srci, min._dsti); ++size; totalW += min._w; } else { cout << "构成环:"; cout << _vertexs[min._srci] << "->" << _vertexs[min._dsti] << ":" << min._w << endl; } } //找到了最小生成树 if (size == n - 1) { return totalW; } //如果没有找到就返回权值 else { return W(); } }
🏆2.Prim算法
Prim算法随便选一个点,然后选该点权值最小的那条边,再选这条边相连的节点的所有边中权值最小的边,不断重复。
这种方式不会构成环,因为每次选边,都是从两个集合里面分别选一个节点构成的边:已经相连顶点是一个集合,没有相连顶点是一个集合。因此只需要用set记录选过的节点即可。//最小生成树Prim W Prim(Graph<V, W, MAX_W, Direction>& minTree, const W& src) { //获取这个起点的下标 size_t srci = GetVertexIndex(src); size_t n = _vertexs.size(); minTree._vertexs = _vertexs; minTree._indexMap = _indexMap; minTree._matrix.resize(n); for (size_t i = 0; i < n; ++i) { minTree._matrix[i].resize(n, MAX_W); } //同两个数组,分别表示已经被最小生成树选中的结点和没有被最小生成树选中的结点 vector<bool> X(n, false); vector<bool> Y(n, true); X[srci] = true; Y[srci] = false; // 从X->Y集合中连接的边里面选出最小的边 priority_queue<Edge, vector<Edge>, greater<Edge>> minq; // 先把srci连接的边添加到队列中 for (size_t i = 0; i < n; ++i) { if (_matrix[srci][i] != MAX_W) { //分别将起始点,指向的最终点和权值构成的边放入队列中 minq.push(Edge(srci, i, _matrix[srci][i])); } } cout << "Prim开始选边" << endl; size_t size = 0; W totalW = MAX_W; while (!minq.empty()) { Edge min = minq.top(); minq.pop(); // 最小边的目标点也在X集合,则构成环 if (X[min._dsti]) { cout << "构成环:"; cout << _vertexs[min._srci] << "->" << _vertexs[min._dsti] << ":" << min._w << endl; } else { //将这条边添加到我们的最小生成树当中 minTree._AddEdge(min._srci, min._dsti, min._w); cout << _vertexs[min._srci] << "->" << _vertexs[min._dsti] << ":" << min._w << endl; //X中对应的点将其标记成true代表已经加入了x集合 X[min._dsti] = true; //Y代表的是还没有被连接的点,所以我们将我们这个已经被连接的点的位置标记成false Y[min._dsti] = false; ++size; totalW += min._w; if (size == n - 1) break; for (size_t i = 0; i < n; ++i) { //将当前边的终点作为我们挑选下一条边的起点,并且这条起点的终点不能在我们的X集合中 //然后将这些点重新放入我们的队列中 if (_matrix[min._dsti][i] != MAX_W && Y[i]) { minq.push(Edge(min._dsti, i, _matrix[min._dsti][i])); } } } } //选到了n-1条边就返回总的权重 if (size == n - 1) { return totalW; } else { return MAX_W; } }
💎三、最短路径
🏆1.Dijkstra算法
最短路径问题:从在带权图的某一顶点出发,找出一条通往另一顶点的最短路径,最短也就是沿路径各边的权值总和达到最小。
单源最短路径问题:算法要求图中所有边的权重非负,一般在求解最短路径的时候都是已知一个起点 和一个终点,所以使用Dijkstra算法求解过后也就得到了所需起点到终点的最短路径。运用贪心思想,每次从 「未求出最短路径的点」中取出距离距离起点最小路径的点,以这个点为跳转点刷新「未求出最短路径的点」的距离。然后锁定该跳转点,因为该跳转点到起始位置的距离已经是最小值了。
Dijkstra算法详解 通俗易懂
// 传入起点 // 传入存储起点到其他各个点的最短路径的权值和容器(例:点s到syzx的路径) // 传入每个节点的父路径,也就是从我们的源节点到我们每一个节点的最短路径的前一个节点的下标,存储路径前一个顶点下标 void Dijkstra(const V& src, vector<W>& dist, vector<int>& pPath) { //源点的下标 size_t srci = GetVertexIndex(src); //节点的数量 size_t n = _vertexs.size(); //将所有的路径初始化为无穷大 dist.resize(n, MAX_W); //将路径全部都初始化成-1,也就是没有前一个结点(我们结点的下标从0开始) pPath.resize(n, -1); //自己结点到自己的距离就是0 dist[srci] = 0; //自己到自己的最短路径的前一个节点就是自己,所以前一个节点的下标也是自己 pPath[srci] = srci; // 标记已经确定最短路径的顶点集合,初始全部都是false,也就是全部都没有被确定下来 vector<bool> S(n, false); for (size_t j = 0; j < n; ++j) { // 选最短路径顶点且不在S更新其他路径 //最小的点为u,初始化为0 int u = 0; //记录到最小的点的权值 W min = MAX_W; for (size_t i = 0; i < n; ++i) { //这个点没有被选过,并且到这个点的权值比我们当前的最小值更小,我们就进行更新 if (S[i] == false && dist[i] < min) { //用u记录这个最近的点的编号 u = i; min = dist[i]; } } //标记当前点已经被选中了 S[u] = true; // 松弛更新u连接顶点v srci->u + u->v < srci->v 则更新 //确定u链接出去的所有点 for (size_t v = 0; v < n; ++v) { //如果v这个点没有被标记过,也就是我们这个点还没有被确定最短距离,并且从我们当前点u走到v的路径是存在的 //并且从u走到v的总权值的和比之前源点到v的权值更小,我们就更新我们从源点到我们的v的最小权值 if (S[v] == false && _matrix[u][v] != MAX_W && dist[u] + _matrix[u][v] < dist[v]) { //更新从源点到v的最小权值 dist[v] = dist[u] + _matrix[u][v]; //标记我们从源点到v的最小路径要走到v这一步的前一个节点需要走u pPath[v] = u; } } } }
测试
void TestGraphDijkstra() { const char* str = "syztx"; Graph<char, int, INT_MAX, true> g(str, strlen(str)); g.AddEdge('s', 't', 10); g.AddEdge('s', 'y', 5); g.AddEdge('y', 't', 3); g.AddEdge('y', 'x', 9); g.AddEdge('y', 'z', 2); g.AddEdge('z', 's', 7); g.AddEdge('z', 'x', 6); g.AddEdge('t', 'y', 2); g.AddEdge('t', 'x', 1); g.AddEdge('x', 'z', 4); vector<int> dist; vector<int> parentPath; g.Dijkstra('s', dist, parentPath); }
时间复杂度O(N^2),空间复杂度O(N)
🏆2.Bellman-Ford算法
bellman—ford算法可以解决负权图的单源最短路径问题,是一种暴力算法。时间复杂度 O(N*E) (N是点数,E是边数),使用邻接矩阵实现,那么遍历所有边的数量的时间复杂度就是O(N^3),空间复杂度:O(N)。
bool BellmanFord(const V& src, vector<W>& dist, vector<int>& pPath) { //获取我们点的总和数 size_t n = _vertexs.size(); //获取我们源点的索引 size_t srci = GetVertexIndex(src); // vector<W> dist,记录srci-其他顶点最短路径权值数组 dist.resize(n, MAX_W); // vector<int> pPath 记录srci-其他顶点最短路径父顶点数组 pPath.resize(n, -1); // 先更新srci->srci为缺省值 dist[srci] = MAX_W; //cout << "更新边:i->j" << endl; // 总体最多更新n轮 //从s->t最多经过n条边,否则就会变成回路。 //每一条路径的更新都可能会影响别的路径 for (size_t k = 0; k < n; ++k) { // i->j 更新一次 bool update = false; cout << "更新第:" << k << "轮" << endl; for (size_t i = 0; i < n; ++i) { for (size_t j = 0; j < n; ++j) { // srci -> i + i ->j //如果i->j边存在的话,并且srci -> i + i ->j比原来的距离更小,就更新该路径 if (_matrix[i][j] != MAX_W && dist[i] + _matrix[i][j] < dist[j]) { //这一轮有发生更新 update = true; cout << _vertexs[i] << "->" << _vertexs[j] << ":" << _matrix[i][j] << endl; dist[j] = dist[i] + _matrix[i][j]; //记录当前点的前一个节点 pPath[j] = i; } } } // 如果这个轮次中没有更新出更短路径,那么后续轮次就不需要再走了 if (update == false) { break; } } // 还能更新就是带负权回路,具体的例子在下面 for (size_t i = 0; i < n; ++i) { for (size_t j = 0; j < n; ++j) { // srci -> i + i ->j if (_matrix[i][j] != MAX_W && dist[i] + _matrix[i][j] < dist[j]) { return false; } } } return true; }