【数据结构】图
目录
1. 图的基本概念
图是由顶点集合及顶点间的关系组成的一种数据结构:G = (V, E),其中:
顶点集合V = {x|x属于某个数据对象集}是有穷非空集合;
E = {(x,y)|x,y属于V}或者E = {<x, y>|x,y属于V && Path(x, y)}是顶点间关系的有穷集合,也叫
做边的集合。
(x, y)表示x到y的一条双向通路,即(x, y)是无方向的;Path(x, y)表示从x到y的一条单向通路,即Path(x, y)是有方向的。
顶点、边和权值:图中结点称为顶点,第i个顶点记作vi。两个顶点vi和vj相关联称作顶点vi和顶点vj之间有一条边,图中的第k条边记作ek,ek = (vi,vj)或<vi,vj>。边上附带的数据信息称为权值。
图中的顶点可以理解成生活中的某一个地点或者个体,边可以理解成两个地点或者个体之间具有的某种联系,这种联系有强有弱,我们可以通过数值去衡量,称作权值。
可以看到图描述的是现实生活中的某种具体的情形。如下图,城市就是顶点,城市的边可以使高铁距离、高铁价格之类。比如今天想要从西安前往贵阳,我们就可以通过比较边的权值来衡量怎么走最划算。

有向图和无向图:在有向图中,顶点对<x, y>是有序的,顶点对<x,y>称为顶点x到顶点y的一条边(弧),<x, y>和<y, x>是两条不同的边,比如下图G3和G4为有向图。在无向图中,顶点对(x, y)是无序的,顶点对(x,y)称为顶点x和顶点y相关联的一条边,这条边没有特定方向,(x, y)和(y,x)是同一条边,比如下图G1和G2为无向图。
注意:无向边(x, y)等于有向边<x, y>和<y, x>。

对于有向与无向图的理解,我们可以通过社交关系类比,顶点是人,边表示好友关系,权值表示亲密度,对于像微信、qq这里社交软件来说,一但建立好友关系,两个人就互为好友,这种强社交关系就是无向图,如下图G1;而对于像抖音、微博等社交软件,关注某个人只能建立单向的关系,只有当对方互关,双方才能正式建立好友关系,这种弱社交模式就是有向图,即边存在方向,是单向的,如下图G2。


完全图:在有n个顶点的无向图中,若有n * (n-1)/2条边,即任意两个顶点之间有且仅有一条边,则称此图为无向完全图,比如上图G1;
在n个顶点的有向图中,若有n * (n-1)条边,即任意两个顶点之间有且仅有方向相反的边,则称此图为有向完全图,比如上图G4。(完全图任意两个顶点之间都直接相连,这是最稠密的图)
注:我们发现上图G2的结构就一棵树,其实树是一种特殊的(无环连通)图,但图不一定是树。
树是一种存储型的数据结构,树关注的是节点(顶点中存储的值),数据的存储,图是一种表示型的数据结构,关心的是顶点及边的权值,图描述的是现实中的具体情形。
邻接顶点:在无向图中G中,若(u, v)是E(G)中的一条边,则称u和v互为邻接顶点,并称边(u,v)依附于顶点u和v;在有向图G中,若<u, v>是E(G)中的一条边(自u指向v的一条边),则称顶点u邻接到v,顶点v邻接自顶点u,并称边<u, v>与顶点u和顶点v相关联。
顶点的度:顶点v的度是指与它相关联的边的条数,记作deg(v)。
在有向图中,顶点的度等于该顶点的入度与出度之和,其中顶点v的入度是以v为终点的有向边的条数,记作indev(v);顶点v的出度是以v为起始点的有向边的条数,记作outdev(v)。因此:dev(v) = indev(v) + outdev(v)。
注意:对于无向图,顶点的度等于该顶点的入度和出度,即dev(v) = indev(v) = outdev(v)。
路径:在图G = (V, E)中,若从顶点vi出发有一组边使其可到达顶点vj,则称顶点vi到顶点vj的顶点序列为从顶点vi到顶点vj的路径。如下图从1经过2、5、8到达7就是一条路径,也可以是从1经过2、5、3、6到达7。路径可以有多条。
路径长度:对于不带权的图,一条路径的路径长度是指该路径上的边的条数;对于带权的图,一条路径的路径长度是指该路径上各个边权值的总和。

简单路径与回路:若路径上各顶点v1,v2,v3,…,vm均不重复,则称这样的路径为简单路径。
若路径上第一个顶点v1和最后一个顶点vm重合,则称这样的路径为回路或环。

子图:设图G = {V, E}和图G1 = {V1,E1},若V1属于V且E1属于E,即G1的顶点、边都是G的一部分,则称G1是G的子图。

连通图:在无向图中,若从顶点v1到顶点v2有路径,则称顶点v1与顶点v2是连通的。如果图中任意一对顶点都是连通的,则称此图为连通图。
强连通图:在有向图中,若在每一对顶点vi和vj之间都存在一条从vi到vj的路径,也存在一条从vj到vi的路径,则称此图是强连通图。
生成树:在无向图中,一个连通图的最小连通子图称作该图的生成树。有n个顶点的连通图的生成树有n个顶点和n-1条边。我们通过n-1条边将n个顶点相连形成的连通图就是原图的最小生成树。
如子图1,我们可以通过三条边将4个顶点相连,并且任意个点都有通往其他点的路径,即该子图也是一个连通图,使用的边少于3个,我们无法将所有点连通起来,所以子图1就是G1的最小生成树。


如果不靠率边权值的情况下
2. 图的存储结构
因为图中既有节点,又有边(节点与节点之间的关系),因此,在图的存储中,只需要保存:节点和
边关系即可。节点保存比较简单,只需要一段连续空间即可,那边关系该怎么保存呢?
2.1 邻接矩阵
因为节点与节点之间的关系就是连通与否,即为0或者1,因此邻接矩阵(二维数组)即是:先用一个数组将定点保存,然后采用矩阵来表示节点与节点之间的关系。

注意:
1. 无向图的邻接矩阵是对称的,第i行(列)元素之和,就是顶点i的度。有向图的邻接矩阵则不一定是对称的,第i行(列)元素之后就是顶点i 的出(入)度。
2. 如果边带有权值,并且两个节点之间是连通的,上图中的边的关系就用权值代替,如果两个顶点不通,则使用无穷大代替。

3. 用邻接矩阵存储图的有点是能够快速知道两个顶点是否连通,缺陷是如果顶点比较多,边比较少时,矩阵中存储了大量的0成为系数矩阵,比较浪费空间,并且要求两个节点之间的路
径不是很好求。
//V存储数据的类型,可能是字符串、整形等,所以这里用模版 //W权值的类型,可能有int、double等,所以也是使用模版 //表示边不存在的特殊值默认是int的最大值,其他类型需要显式传入 //Direction用来控制是无向还是有向图 template<class V, class W, W MAX_W = INT_MAX, bool Direction = false> class Graph { public: typedef Graph<V, W, MAX_W, Direction> Self; Graph() = default; //实际邻接矩阵的实现中边关系的存储,有读取OJ输入、从文件读取、以及自己输入 //这里为了方便调试控制,我们边的关系,手动插入 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 invalid_argument("不存在的顶点"); return -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); } 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: //因为数据的类型不定,进而映射的下标不好直接确定 //我们这里通过map映射,可以根据数据快速确定对应的下标,方便找到边 map<V, size_t> _vIndexMap; //存储数据与下标的映射关系 vector<V> _vertexs; // 顶点集合 vector<vector<W>> _matrix; // 存储边集合的矩阵 }; void TestGraph() { Graph<char, int, INT_MAX, true> g("0123", 4); g.AddEdge('0', '1', 1); g.AddEdge('0', '3', 4); g.AddEdge('1', '3', 2); g.AddEdge('1', '2', 9); g.AddEdge('2', '3', 8); g.AddEdge('2', '1', 5); g.AddEdge('2', '0', 3); g.AddEdge('3', '2', 6); g.Print(); }总结:
邻接矩阵可以O(1)判断两个顶点的连接关系,并取到权值,所以邻接矩阵存储方式非常适合稠密图。
但是相对而言邻接矩阵不适合查找一个顶点连接的所有边,因为要想确定从某一个点出发的所有边是否存在,必须从该点映射下标出发,遍历_matrix某一行/列,复杂度为O(N)。
2.2 邻接表
邻接表:使用指针数组表示顶点的集合,使用链表表示边的关系。将从该顶点出发的所有边通过链表方式挂在对应顶点位置上。
1. 无向图邻接表存储
无向图因为边没有方向,所以实际存储时按照双向边处理.从B->A和从A->B都要存储。

注意:无向图中同一条边在邻接表中出现了两次。如果想知道顶点vi的度,只需要知道顶点
vi边链表集合中结点的数目即可。
2. 有向图邻接表存储
有向图中,一条边从顶点指向外界,称作出边;从外界指向节点称作入边,分别存储。

注意:有向图中每条边在邻接表中只出现一次,与顶点vi对应的邻接表所含结点的个数,就
是该顶点的出度,也称出度表,要得到vi顶点的入度,必须检测其他所有顶点对应的边链
表,看有多少边顶点的dst取值是i。
// 邻接表 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) { } }; 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 invalid_argument("不存在的顶点"); return -1; } } void AddEdge(const V& src, const V& dst, const W& w) { size_t srcindex = GetVertexIndex(src); size_t dstindex = GetVertexIndex(dst); // 0 1 Edge* sd_edge = new Edge(w); sd_edge->_srcIndex = srcindex; sd_edge->_dstIndex = dstindex; sd_edge->_next = _linkTable[srcindex]; _linkTable[srcindex] = sd_edge; // 1 0 // 无向图 if (Direction == false) { Edge* ds_edge = new Edge(w); ds_edge->_srcIndex = dstindex; ds_edge->_dstIndex = srcindex; ds_edge->_next = _linkTable[dstindex]; _linkTable[dstindex] = ds_edge; } } private: map<string, int> _vIndexMap; vector<V> _vertexs; // 顶点集合 vector<Edge*> _linkTable; // 边的集合的临接表 }; void TestGraph() { string a[] = { "张三", "李四", "王五", "赵六" }; Graph<string, int> g1(a, 4); g1.AddEdge("张三", "李四", 100); g1.AddEdge("张三", "王五", 200); g1.AddEdge("王五", "赵六", 30); } }总结:相比邻接矩阵,邻接表每个顶点下直接挂上自己连接的顶点,我们从顶点出发遍历一下链表就可以获得所有连接的边,不需要遍历所有方向再判断,所以邻接表适合查找一个顶点连接出去的边。
但是由于本身指针数组+链表的结构不适合稠密图,适合稀疏图,同时不适合确定两个顶点是否相连及权值。
注:下文的算法为了方便处理,都是用邻接矩阵结构
3. 图的遍历
给定一个图G和其中任意一个顶点v0,从v0出发,沿着图中各边访问图中的所有顶点,且每个顶
点仅被遍历一次。"遍历"即对结点进行某种操作的意思。
3.1 图的广度优先遍历

我们以某一个顶点开始,先遍历该点连接所有的顶点,再遍历这些顶点所连接的所有顶点,依此类推,形成了一种一层一层的向外遍历的效果,这就是广度遍历。

为了实现广度遍历,这里我们采用数据结构树部分的思路,利用队列先进先出的效果,遍历一个顶点就将其入队,遍历完将其出队再将该点连接的所有顶点入队,依次类推继续遍历。
不过与树部分不同的是图中顶点会相互的连接,我们在根据顶点入队连接的点时,可能会将之前已经遍历过的点重新入队,我们这需要多借助一个容器用来标记哪个节点之前已经访问过了。入队时对应顶点标记,我们就不入队。

因为广度遍历视觉上的一层一层外扩遍历的效果,一些问题可能会要求对同一层的节点进行标记。

针对这种问题,我们增加一个变量,记录一整层的顶点数,用来控制一整层出完。
void BFS(const V& src) { size_t srci = GetVertexIndex(src); // 队列和标记数组 queue<int> q; vector<bool> visited(_vertexs.size(), false); //选择一个点作为起点 q.push(srci); visited[srci] = true; int levelSize = 1; size_t n = _vertexs.size(); while (!q.empty()) { // 一层一层出 for (int i = 0; i < levelSize; ++i) { int front = q.front(); q.pop(); cout << front << ":" << _vertexs[front] << " "; // 把front顶点的邻接顶点入队列 for (size_t i = 0; i < n; ++i) { if (_matrix[front][i] != MAX_W) { if (visited[i] == false) { q.push(i); visited[i] = true; } } } } cout << endl; levelSize = q.size(); } cout << endl; } void TestGraphDBFS() { string a[] = { "张三", "李四", "王五", "赵六", "周七" }; Graph<string, int> g1(a, sizeof(a) / sizeof(string)); g1.AddEdge("张三", "李四", 100); g1.AddEdge("张三", "王五", 200); g1.AddEdge("王五", "赵六", 30); g1.AddEdge("王五", "周七", 30); g1.BFS("张三"); g1.DFS("张三"); }3.2 图的深度优先遍历


与树中的深度遍历类似,图的深度遍历是先将一条路上的所有顶点遍历完再返回去遍历其他路径。
因为同样有遍历已经访问过顶点的可能性,所以这里我们同样需要一个容器用来标记顶点。
void _DFS(size_t srci, vector<bool>& visited) { cout << srci << ":" << _vertexs[srci] << endl; //标记已经访问过的顶点 visited[srci] = true; // 找一个srci相邻的没有访问过的点,去往深度遍历 for (size_t i = 0; i < _vertexs.size(); ++i) { if (_matrix[srci][i] != MAX_W && visited[i] == false) { //先访问完一条路上的所有顶点 _DFS(i, visited); } } } void DFS(const V& src) { //以一个点为起点 size_t srci = GetVertexIndex(src); //标记容器 vector<bool> visited(_vertexs.size(), false); _DFS(srci, visited); } void TestGraphDBFS() { string a[] = { "张三", "李四", "王五", "赵六", "周七" }; Graph<string, int> g1(a, sizeof(a) / sizeof(string)); g1.AddEdge("张三", "李四", 100); g1.AddEdge("张三", "王五", 200); g1.AddEdge("王五", "赵六", 30); g1.AddEdge("王五", "周七", 30); g1.BFS("张三"); g1.DFS("张三"); }3.3非连通图情形

上文的广度与深度遍历是根据边来遍历顶点的,如果我们给的并不是连通图,那就以某一个点位出发就无法完成全部的遍历,所以针对这种情况,我们需要借助一个vector<boo>的容器来标记已经遍历过的点,完成一轮遍历后,我们再根据标记遍历剩下未遍历的点。
4. 最小生成树
连通图中的每一棵生成树,都是原图的一个极大无环子图,即:从其中删去任何一条边,生成树就不在连通;反之,在其中引入任何一条新边,都会形成一条回路。
最小生成树:构成生成树的所有边加起来权值最小,即我们需要用最小的代价(权值)让N个顶点连接起来。
若连通图由n个顶点组成,则其生成树必含n个顶点和n-1条边。因此构造最小生成树的准则有三条:
1. 只能使用图中的权值最小边来构造最小生成树
2. 只能使用恰好n-1条边来连接图中的n个顶点
3. 选用的n-1条边不能构成回路
构造最小生成树的方法:Kruskal算法和Prim算法。这两个算法都采用了逐步求解的贪心策略。
贪心算法:是指在问题求解时,总是做出当前看起来最好的选择。也就是说贪心算法做出的不是整体最优的的选择,而是某种意义上的局部最优解。贪心算法不是对所有的问题都能得到整体最优解。
4.1 Kruskal算法
任给一个有n个顶点的连通网络N={V,E},
首先构造一个由这n个顶点组成、不含任何边的图G={V,NULL},其中每个顶点自成一个连通分量,其次不断从E中取出权值最小的一条边(若有多条相同的任取其一),若该边的两个顶点来自不同的连通分量,则将此边加入到G中。如此重复,直到所有顶点在同一个连通分量上为止。
核心:每次迭代时,选出一条具有最小权值,且两端点不在同一连通分量上的边,加入生成树。


Kruskal算法贪心的地方就在于每次都是从所有边中挑选最小的那条,以期最终整体的权值最小
需要注意的是根据最小生成树的生成准则,我们选取边时候不能形成一个环出来,这会导致我们的代价不能最小。
为了能够从所有边中选最小,我们这里需要借助堆。为了避免形成环,我们这里需要借助并查集

对于已经相连(直接/间接)的边,我们可以视作已经在同一个集合中了,那么下次选取一条边之前,我们可以判断下两个顶点是否在同一个集合内,如果在,说明之前已经连接了,再选取该边会成环。
W Kruskal(Self& minTree) { 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; } } //因为最小生成树的准则,边数达到N-1,说明我们已经挑完了 //如果我们所有边都遍历完了, // 还是没找到N-1条边说明无法找到最小生成树 if (size == n - 1) { return totalW; } else { return W(); } } void TestGraphMinTree() { const char* str = "abcdefghi"; Graph<char, int> g(str, strlen(str)); g.AddEdge('a', 'b', 4); g.AddEdge('a', 'h', 8); //g.AddEdge('a', 'h', 9); g.AddEdge('b', 'c', 8); g.AddEdge('b', 'h', 11); g.AddEdge('c', 'i', 2); g.AddEdge('c', 'f', 4); g.AddEdge('c', 'd', 7); g.AddEdge('d', 'f', 14); g.AddEdge('d', 'e', 9); g.AddEdge('e', 'f', 10); g.AddEdge('f', 'g', 2); g.AddEdge('g', 'h', 1); g.AddEdge('g', 'i', 6); g.AddEdge('h', 'i', 7); Graph<char, int> kminTree; cout << "Kruskal:" << g.Kruskal(kminTree) << endl; kminTree.Print(); Graph<char, int> pminTree; cout << "Prim:" << g.Prim(pminTree, 'a') << endl; pminTree.Print(); }4.2 Prim算法


与Kruskal相比,prim不是从所有边中直接选取,而是将点分成两个集合,再从两个集合中的点对应形成的边中选取最小权值的边,这天然就利用了并查集的逻辑,因此prim避免选边成环,可以直接利用原有的存储点的集合X、Y,选取一条边前,如果一条边的两个顶点都在X内,说明它们已经直接/间接连接了,因此我们不能选取这条边(因为X集合中一开始只有一个点,通过这个点相邻的边再加入点的,所以最终X集合中的所有点都是直接或间接相连的)
W Prim(Self& minTree, const V& 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); } //X和Y集合 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 = 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[min._dsti] = true; Y[min._dsti] = false; //因为最小生成树的准则,边数达到N-1,说明我们已经挑完了 ++size; totalW += min._w; if (size == n - 1) break; //只有边的dsti不在Y中说明不会成环,我们加入到队列中 for (size_t i = 0; i < n; ++i) { 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 W(); } } void TestGraphMinTree() { const char* str = "abcdefghi"; Graph<char, int> g(str, strlen(str)); g.AddEdge('a', 'b', 4); g.AddEdge('a', 'h', 8); //g.AddEdge('a', 'h', 9); g.AddEdge('b', 'c', 8); g.AddEdge('b', 'h', 11); g.AddEdge('c', 'i', 2); g.AddEdge('c', 'f', 4); g.AddEdge('c', 'd', 7); g.AddEdge('d', 'f', 14); g.AddEdge('d', 'e', 9); g.AddEdge('e', 'f', 10); g.AddEdge('f', 'g', 2); g.AddEdge('g', 'h', 1); g.AddEdge('g', 'i', 6); g.AddEdge('h', 'i', 7); Graph<char, int> kminTree; cout << "Kruskal:" << g.Kruskal(kminTree) << endl; kminTree.Print(); Graph<char, int> pminTree; cout << "Prim:" << g.Prim(pminTree, 'a') << endl; pminTree.Print(); }5. 最短路径
最短路径问题:从在带权有向图G中的某一顶点出发,找出一条通往另一顶点的最短路径,最短也就是沿路径各边的权值总和达到最小。
5.1单源最短路径--Dijkstra算法
单源最短路径问题:给定一个图G = ( V , E ) G=(V,E)G=(V,E),求源结点s ∈ V s∈Vs∈V到图中每个结点v ∈ V v∈Vv∈V的最短路径。
Dijkstra算法就适用于解决带权重的有向图上的单源最短路径问题,同时算法要求图中所有边的权重非负。一般在求解最短路径的时候都是已知一个起点和一个终点,所以使用Dijkstra算法求解过后也就得到了所需起点到终点的最短路径。
针对一个带权有向图G,将所有结点分为两组S和Q,S是已经确定最短路径的结点集合,在初始时为空(初始时就可以将源节点s放入,毕竟源节点到自己的代价是0),Q 为其余未确定最短路径的结点集合,每次从Q 中找出一个起点到该结点代价最小的结点u ,将u 从Q 中移出,并放入S中,对u 的每一个相邻结点v 进行松弛操作。松弛即对每一个相邻结点v ,判断源节点s到结点u的代价与u 到v 的代价之和是否比原来s 到v 的代价更小,若代价比原来小则要将s 到v 的代价更新为s 到u 与u 到v 的代价之和,否则维持原样。如此一直循环直至集合Q 为空,即所有节点都已经查找过一遍并确定了最短路径,至于一些起点到达不了的结点在算法循环后其代价仍为初始设定的值,不发生变化。
Dijkstra算法每次都是选择V-S中最小的路径节点来进行更新,并加入S中,所以该算法使用的是贪心策略。

总结来说Dijkstra的贪心策略就是给定一个起点,然后选择起点到其他点中的最短路径再去更新出起点到其他点的最短路径。

上述过程中需要解释的是:
本质上Dijkstra算法从起点出发从连接的路径中选择一条最短的,比如上图中s->y,那么此时s->y的最短路径就可以确定,因为此时s出发,如果走s->t再到y,那么路径大小不管怎么走都是一定大于等于10的,一定比s->y的5大(可以看出Dijkstra算法就是基于边不为负权,多走路权值增加的逻辑,所以Dijkstra算法是不支持图中带负权路径,如果带有负权路径,则可能会找不到一些路径的最短路径。);反过来说s->t的路径此时可能不是最小路径,因为s->y的路径是5,从y出发最后再到t的路中是可能存在大于5小于10的情况的,所以此时s->t不能确地为最短路径。
s->y确定为最短路径,之后都不在更新(因为不可能更短),所以当前s->y是最短路径,所以我们从y出发再去其他点得出来的路径一定会更小(这就是Dijkstra的贪心策略),更新出s->t、s->z、s->x的路径,我们选取最小路径s->t,因为s->y是之前的最小,那么加上y->t得出来的s->t一定是s->t的最小路径,不可能走出更小的。之后基于上述逻辑,我们就可以一次更新出s到其他所有点的最小路径。
这里我们使用数组vector<int> dist容器记录s到某一点的距离,对应下标对顶顶点,存储的就是s到该点的距离,我们同样使用vector<int>pPath用来还原最短路,下标对应顶点,存储的是s到该点路径中该点的上一个节点,比如s->y->x,那么x对应下标存储y对应小标,y对应下标存储s对应下标,这样我们就可以还原出一整条路径了。

// 顶点个数是N -> 时间复杂度:O(N^2)空间复杂度:O(N) //需要借助vector<int>存储空间复杂度是O(N) //最坏情况下,每个顶点遍历剩下所有顶点,等差式遍历,所以时间复杂度为O(N^2 void Dijkstra(const V& src, vector<W>& dist, vector<int>& pPath) { size_t srci = GetVertexIndex(src); size_t n = _vertexs.size(); // vector<W> dist,记录srci-其他顶点最短路径权值数组 dist.resize(n, MAX_W); // vector<int> parentPath 记录srci-其他顶点最短路径父顶点数组 pPath.resize(n, -1); // srci的权值给一个最小值,方便贪心第一次找到这个节点 dist[srci] = 0; pPath[srci] = srci; // // 标记是否找到最短路径的顶点集合S vector<bool> S(n, false); for (size_t j = 0; j < n; ++j) { // 贪心算法:srci到不在S中路径最短的那个顶点u // 选最短路径顶点且不在S更新其他路径 int u = 0; W min = MAX_W; for (size_t i = 0; i < n; ++i) { if (S[i] == false && dist[i] < min) { u = i; min = dist[i]; } } S[u] = true; // 松弛更新u连接顶点v srci->u + u->v < srci->v 更新 for (size_t v = 0; v < n; ++v) { if (S[v] == false && _matrix[u][v] != MAX_W && dist[u] + _matrix[u][v] < dist[v]) { dist[v] = dist[u] + _matrix[u][v]; pPath[v] = u; } } } } // 打印最短路径的逻辑算法 void PrinrtShotPath(const V& src, const vector<W>& dist, const vector<int>& parentPath) { size_t N = _vertexs.size(); size_t srci = GetVertexIndex(src); for (size_t i = 0; i < N; ++i) { if (i == srci) continue; vector<int> path; int parenti = i; while (parenti != srci) { path.push_back(parenti); parenti = parentPath[parenti]; } path.push_back(srci); reverse(path.begin(), path.end()); for (auto pos : path) { cout << _vertexs[pos] << "->"; } cout << dist[i] << endl; } } 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); g.PrinrtShotPath('s', dist, parentPath); // 图中带有负权路径时,贪心策略则失效了。 // 测试结果可以看到s->t->y之间的最短路径没更新出来 /*const char* str = "sytx"; Graph<char, int, INT_MAX, true> g(str, strlen(str)); g.AddEdge('s', 't', 10); g.AddEdge('s', 'y', 5); g.AddEdge('t', 'y', -7); g.AddEdge('y', 'x', 3); vector<int> dist; vector<int> parentPath; g.Dijkstra('s', dist, parentPath); g.PrinrtShotPath('s', dist, parentPath);*/ }5.2 单源最短路径--Bellman-Ford算法
Dijkstra算法只能用来解决正权图的单源最短路径问题,但有些题目会出现负权图。这时这个算法
就不能帮助我们解决问题了,而bellman—ford算法可以解决负权图的单源最短路径问题。
bellman—ford的优点是可以解决有负权边的单源最短路径问题,而且可以用来判断是否有负权回路。它也有明显的缺点,它的时间复杂度 O(N*E) (N是点数,E是边数)普遍是要高于Dijkstra算法O(N²)的。像这里如果我们使用邻接矩阵实现,那么遍历所有边的数量的时间复杂度就是O(N^3),这里也可以看出来Bellman-Ford就是一种暴力求解更新。

因为边可能出现负权,前面路径长,加上后面路径可能更短,所以Dijkstra的逻辑无法成立,而Bellman-Ford针对负权选择从一条路径的后段开始选,s->i,i->j,其中i是终点的前一个经过的点,Bellman-Ford就选择从多个到j的i暴力的遍历,尝试出最短的那一条路径,然后再反过来一段一段往前更新s->i的部分,如下端代码
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] != MAX_W && dist[i] + _matrix[i][j] < dist[j]) { //cout << _vertexs[i] << "->" << _vertexs[j] << ":" << _matrix[i][j] << endl; dist[j] = dist[i] + _matrix[i][j]; pPath[j] = i; } } }
但是上段代码还存在的一个问题是由于路径是挨个暴力更新的,所以会出现上图中s->t更新了更短的路径,但是之前已经更新的s->t->z中s->t路径没有更新,出现问题
所以我们应该在外层再套一层循环,来根据已经更新过的边纠正其他未更新的边,因为一个起点到其他点(包括自己本身),用dist记录,对应8个下标,所以最多更新n轮,就可以将所有情况修正完全。
同时为了优化,我们记录更新的轮数,如果更新一轮下来,没有发生一次修正,我们可以直接结束,如果更新n轮后仍然可以更新,说明图中有负权构成的回路(一但负权回路存在,那么总是可以更新出更短的路径,所以负权回路问题是无法解决的)
// 时间复杂度: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] = W(); //cout << "更新边:i->j" << endl; // 总体最多更新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 if (_matrix[i][j] != MAX_W && dist[i] != 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; } void TestGraphBellmanFord() { const char* str = "syztx"; Graph<char, int, INT_MAX, true> g(str, strlen(str)); g.AddEdge('s', 't', 6); g.AddEdge('s', 'y', 7); g.AddEdge('y', 'z', 9); g.AddEdge('y', 'x', -3); g.AddEdge('z', 's', 2); g.AddEdge('z', 'x', 7); g.AddEdge('t', 'x', 5); g.AddEdge('t', 'y', 8); g.AddEdge('t', 'z', -4); g.AddEdge('x', 't', -2); vector<int> dist; vector<int> parentPath; if (g.BellmanFord('s', dist, parentPath)) { g.PrinrtShotPath('s', dist, parentPath); } else { cout << "存在负权回路" << endl; } // 微调图结构,带有负权回路的测试 //const char* str = "syztx"; //Graph<char, int, INT_MAX, true> g(str, strlen(str)); //g.AddEdge('s', 't', 6); //g.AddEdge('s', 'y', 7); //g.AddEdge('y', 'x', -3); //g.AddEdge('y', 'z', 9); //g.AddEdge('y', 'x', -3); //g.AddEdge('y', 's', 1); // 新增 //g.AddEdge('z', 's', 2); //g.AddEdge('z', 'x', 7); //g.AddEdge('t', 'x', 5); //g.AddEdge('t', 'y', -8); // 更改 //g.AddEdge('t', 'z', -4); //g.AddEdge('x', 't', -2); //vector<int> dist; //vector<int> parentPath; //if (g.BellmanFord('s', dist, parentPath)) //{ // g.PrinrtShotPath('s', dist, parentPath); //} //else //{ // cout << "存在负权回路" << endl; //} }5.3 多源最短路径--Floyd-Warshall算法
如果说Dijkstra选取的是起始最短边往后更新,Bellman-Ford选取的终止最短边往前更新路径
那么Floyd-Warshall算法是解决任意两点间的最短路径的一种算法。Floyd-Warshall更新的是s->k->j路径上的每一小段路径(k表示0个或多个顶点),如s->k,k->j都要更新。
Floyd算法考虑的是一条最短路径的中间节点,即简单路径p={v1,v2,…,vn}上除v1和vn的任意节
点。设k是p的一个中间节点,那么从i到j的最短路径p就被分成i到k和k到j的两段最短路径p1,p2。p1是从i到k且中间节点属于{1,2,…,k-1}取得的一条最短路径。p2是从k到j且中间节点属于{1,
2,…,k-1}取得的一条最短路径。


即Floyd算法本质是三维动态规划,D[i][j][k]表示从点i到点j只经过0到k个点最短路径,然后建立
起转移方程,然后通过空间优化,优化掉最后一维度,变成一个最短路径的迭代算法,最后即得
到所以点的最短路。

void FloydWarshall(vector<vector<W>>& vvDist, vector<vector<int>>& vvpPath) { size_t n = _vertexs.size(); vvDist.resize(n); vvpPath.resize(n); // 初始化权值和路径矩阵 for (size_t i = 0; i < n; ++i) { vvDist[i].resize(n, MAX_W); vvpPath[i].resize(n, -1); } // // 将直接相连的路径初始化,更新一下 for (size_t i = 0; i < n; ++i) { for (size_t j = 0; j < n; ++j) { if (_matrix[i][j] != MAX_W) { vvDist[i][j] = _matrix[i][j]; vvpPath[i][j] = i; } if (i == j) { vvDist[i][j] = W(); } } } // abcdef a {} f || b {} c // 最短路径的更新i-> {其他顶点} ->j // for (size_t k = 0; k < n; ++k) { for (size_t i = 0; i < n; ++i) { for (size_t j = 0; j < n; ++j) { // 依次用顶点k作为中转点尝试去更新i->j的路径 if (vvDist[i][k] != MAX_W && vvDist[k][j] != MAX_W && vvDist[i][k] + vvDist[k][j] < vvDist[i][j]) { vvDist[i][j] = vvDist[i][k] + vvDist[k][j]; // 找跟j相连的上一个邻接顶点 // 如果k->j 直接相连,上一个点就k,vvpPath[k][j]存就是k // 如果k->j 没有直接相连,k->...->x->j,vvpPath[k][j]存就是x vvpPath[i][j] = vvpPath[k][j]; } } } } } void TestFloydWarShall() { const char* str = "12345"; Graph<char, int, INT_MAX, true> g(str, strlen(str)); g.AddEdge('1', '2', 3); g.AddEdge('1', '3', 8); g.AddEdge('1', '5', -4); g.AddEdge('2', '4', 1); g.AddEdge('2', '5', 7); g.AddEdge('3', '2', 4); g.AddEdge('4', '1', 2); g.AddEdge('4', '3', -5); g.AddEdge('5', '4', 6); vector<vector<int>> vvDist; vector<vector<int>> vvParentPath; g.FloydWarShall(vvDist, vvParentPath); // 打印任意两点之间的最短路径 for (size_t i = 0; i < strlen(str); ++i) { g.PrinrtShotPath(str[i], vvDist[i], vvParentPath[i]); cout << endl; } }注:文中图片结论参考了《算法导论》和《殷人昆 数据结构:用面向对象方法与C++语言描述(第二版)》的内容