图的基本概念
图是由顶点集合及顶点间的关系组成的一种数据结构:G=(V,E)
其中:顶点集合V,边集合E
V={x|x属于某个数据对象集}
E={(x,y)|x,y属于V}
(x,y)表示点x到点y的一条双向通路,是无方向的
顶点:图中的节点,第几个顶点记作vi
两个顶点vi和vj相关联称为顶点vi到顶点vj之间的一条边
图分为有向图和无向图
在有向图中,顶点对
<x,y>
是有序的,顶点对<x,y>
称为顶点x到y的一条边
<x,y>
和<y,x>
是两条不同的边。在无向图中,顶点对(x,y)称为顶点x和顶点y相关联的一条边,边是没有方向的,(x,y)
与(y,x)是同一条边。
完全图:
在无向图的基础上,任意两个顶点之间有且仅有一条边——有n*(n-1)/2条边,则称此图为无向图。
在有向图中,任意两个顶点之间有且仅有方向相反的边——n*(n-1),此图称为有向完全图
邻接顶点:
在无向图G中,如果(u,v)是E(G)中的一条边,则称u和v互为邻接顶点,并边(u,v)依附于顶点u和v;
在有向图G中:如果<u,v>是E(G)的一条边,则称顶点u邻接到v,顶点v邻接自顶点u,并称边<u,v>与顶点u和顶点v相关联。
顶点的度:
顶点v的度是指与它相关联的边的条数,记作dev(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的路径。
路径长度:
对于不带权的图,一条路径的路径长度是指该路径上的边的条数;
对于带权的图,一条路径的长度是指该路径上各个边权值的总和。
简单路径与回路:
若路径上各个顶点v1,v2,v3,……vm均不重复,则称这样的路径为简单路径。
当路径的第一个顶点v1和最后一个顶点vm重合时,则称这样的路径为回路或环。
子图:
设图G={V,E}和G1={V1,E1},如果V1属于V且E1属于E,则称G1是G的子图
连通图:
在无向图中,如果从顶点v1到顶点v2有路径,则称顶点v1与顶点v2是连通的。
如果图中任意一对顶点都是连通的,则称此图为连通图
强连通图:
在有向图中,如果任意vi到vj都存在一条路径,同时vj到vi也存储一条路径,则称此图是强连通图。
生成树:
一个连通图的最小连通子图称为该图的生成树。有n个顶点的连通图的生成树有n个顶点和n-1条边。
图的存储结构
图要怎么进行存储呢?
对于节点只需要连续的空间即可,对于边的存储,有下面几种方法。
- 用矩阵存储——邻接矩阵
- 用链表存储——邻接表
邻接矩阵
邻接矩阵就是用一个二维数组存储节点和节点之间的关系
在无向图中,邻接矩阵是对称的
如果边带有权值,并且两个节点之间是连通的,边的关系就用权值代替,如果两个顶点不通,则使用无穷大代替
用邻接矩阵存储可以快速只读两个顶点之间的关系,但是当顶点比较多的时候、边比较少的时候,不仅浪费空间,而且无法快速知道一个顶点和多少个边相连
实现:
用数组存储点
用二维数组存储边
用map进行转换——把其他类型的数据与下标进行对应
成员函数及其构造函数
//V点,W边的权值 template<class V, class W,W MAX_W=INT_MAX,bool Direction=false> class Graph { vector<V> _vertex; map<V, int> _vertextosub; vector<vector<W>> _edge; public: Graph(const V* str,int size) { //把顶点和坐标进行对应 _vertex.resize(size); for (int i = 0; i < size; i++) { _vertex[i] = str[i]; _vertextosub[str[i]] = i; } //边都初始为最大,表示不相连 _edge.resize(size); for (auto& x : _edge) x.resize(size, MAX_W); } int Getvertexsub(const V& x) { //找到返回,找不到返回-1 auto iterator = _vertextosub.find(x); if (iterator == _vertextosub.end()) return -1; return iterator->second; } void AddEdge(const V& src,const V& dst,const W& w) { //找到顶点的下标 int srcsub = Getvertexsub(src); int detsub = Getvertexsub(dst); if (srcsub == -1 && detsub == -1) { perror("顶点获取下标失败"); exit(-1); } //成功进行添加权值 _edge[srcsub][detsub] = w; if (Direction == false) _edge[detsub][srcsub] = w; } };
邻接表
邻接表使用数组表示顶点的集合,使用链表表示边的关系
实现
template<class W> struct Edge//边的结构 { int _scr;//起点 int _dst;//终点 W _w;//权值 Edge* _next; Edge(int scr, int dst, const W& w) :_scr(scr), _dst(dst), _w(w), _next(nullptr) {} }; template<class V, class W, bool Direction =false > class Graph { typedef Edge<W> Edge; vector<V> _vertex; map<V, int> _vertextosub; vector<Edge*> _edge;//边 public: Graph(const V* str,int size ) { _vertex.resize(size); for (int i = 0; i < size; i++) { _vertex[i] = str[i]; _vertextosub[str[i]] = i; } _edge.resize(size, nullptr); } int Getvertexsub(const V& x) { //找到返回,找不到返回-1 auto iterator = _vertextosub.find(x); if (iterator == _vertextosub.end()) return -1; return iterator->second; } void AddEdge(const V& src, const V& dst, const W& w) { //找到顶点的下标 int srcsub = Getvertexsub(src); int detsub = Getvertexsub(dst); if (srcsub == -1 && detsub == -1) { perror("顶点获取下标失败"); exit(-1); } //成功进行添加权值 Edge* e = new Edge(srcsub, detsub, w); //头插 e->_next = _edge[srcsub]; _edge[srcsub] = e; if (Direction == false) { e = new Edge(detsub, srcsub, w); e->_next = _edge[detsub]; _edge[detsub] = e; } } }
两者的优缺点
邻接矩阵:
邻接矩阵的方式非常适合稠密图
邻接矩阵可以在O(1)的基础上判断两个顶点的连接关系,并取得权值
相对而言不适合查找一个顶点连接的所有边——O(N)
邻接表:
适合存储稀疏图
适合查找一个顶点连接出去的所有边
不适合确定两个顶点是否相连及权值
图的遍历
这里我们用邻接矩阵来进行深度和广度搜索
BFS(广度)
广度遍历相当于一层一层的进行遍历(根据节点遍历它所在该行的二维数组),那么我们就要用到队列,但是我们在遍历的时候要注意不要形成环,所以遍历的时候要注意标记,标记已经遍历的节点
void BFS(const V& src) { int srcsub = Getvertexsub(src); if (srcsub < 0) { perror("不存在该节点"); exit(-1); } queue<int> q;//存每层的节点下标 vector<bool> v(_vertex.size(),false);//存节点是不是被访问过了 //遍历二维数组 q.push(srcsub); v[srcsub] = true; int level = 1; while (!q.empty()) { for (int x = 1; x <= level; x++) { int i = q.front(); cout << _vertex[i] << "->" << i << ';'; q.pop(); for (int j = 0; j < _edge.size(); j++) { if (_edge[i][j] != MAX_W && v[j] != true) { q.push(j); v[j] = true; } } } level = q.size(); cout << endl; } cout << endl; for (int i = 0; i < v.size(); i++) { if (v[i] == false) cout << _vertex[i] << "->" << i << endl; } }
DFS(深度)
深度遍历也是有肯存在环路的问题,所以也要进行节点的标记
void _DFS(const V& src,vector<bool>& v) { int srcsub = Getvertexsub(src); if (srcsub < 0) { perror("不存在该节点"); exit(-1); } cout << _vertex[srcsub] << "->" << srcsub << endl; v[srcsub] = true; for (int i = 0; i < _edge.size(); i++) { if (_edge[srcsub][i] != MAX_W && v[i] != true) _DFS(_vertex[i],v); } } void DFS(const V& src) { vector<bool> v(_vertex.size(), false); _DFS(src, v); }
最小生成树
连通图中的每一棵生成树,都是原图的一个极大无环子图 。即:从其中删除任何一条边,生成树就不在连通;反之,在其中引入任何一条新边,都会形成一条回路。
若连通图由n个顶点组成,则其生成树必含有n个顶点和n-1条边。
构成最小生成树的准则有三条:
只能使用图中的边来构造最小生成树
只能使用恰好n-1条边来连接图中的n个顶点
选用的n-1条边不能构成回路
Kruskal算法
从所有边中,优先选取权值最小的那个边,注意选取的时候要注意不要形成环
W Kruskal(Self& mintree) { //首先完成最小生成树的构造 int n = _vertex.size(); mintree._vertex = _vertex; mintree._vertextosub = _vertextosub; mintree._edge.resize(n); for (int i = 0; i < n; i++) mintree._edge[i].resize(n, MAX_W); //用优先级队列获得权值的大小排列——小到大 priority_queue<edge,vector<edge>,greater<edge>> pq; for (int i = 0; i < n; i++) { for (int j = 0; j < n; j++) { //i<j对于无向图来说,不需要保存重复的数据 //对于有向图来说,不需要遍历下面一半数据 //因为i表示的是节点的起点,j为节点的终点 if (i < j && _edge[i][j] != MAX_W) { pq.push(edge(i, j, _edge[i][j])); } } } //构造最小生成树 W minw = W(); int Size = 0; //并查集 UnionSet Uniset(n); Edge<W> t; while (!pq.empty()) { t = pq.top(); int scr = t._scr; int dst = t._dst; W w = t._w; if (Uniset.Find(scr)!=Uniset.Find(dst)) { cout << _vertex[scr] << "->" << _vertex[dst] << ":" << w << endl; Uniset.Union(scr, dst); mintree._edge[scr][dst] = w; Size++; minw += w; } pq.pop(); } //最小生成树的边数一定是n-1条 if (Size != n - 1) return W(); return minw; }
Prim算法
该算法的实现是:
把这些节点分成两部分,
一部分是生成树的节点——记作X集合
另一部分是出去除去生成树的节点的部分——记作Y集合
从X中找与Y连接中最短的边,然后把Y中的该点加入到X集合中,从Y集合中去除该节点
在下面我们实现的时候,没有用Y集合,因为完全没有必要
W Prim(Self& mintree,const V& src) { int n = _vertex.size();//有多少节点 mintree._vertex = _vertex; mintree._vertextosub = _vertextosub; mintree._edge.clear(); mintree._edge.resize(n); for (int i = 0; i < n; i++) mintree._edge[i].resize(n, MAX_W); vector<bool> X(n, false); int srcsub = Getvertexsub(src);//起点下标 int dstsub;//终点下标 W w = W();//权值 W minw = W(); X[srcsub] = true; int size = 0;//边的数 priority_queue<edge, vector<edge>, greater<edge>> pq; for (int i = 0; i < n; i++) { if (_edge[srcsub][i] != MAX_W) pq.push(edge(srcsub, i, _edge[srcsub][i])); } while (!pq.empty()) { edge e = pq.top(); srcsub = e._scr; dstsub = e._dst; w = e._w; pq.pop(); //这个最小的边是终点是没有被选取的 if (X[dstsub] == true) continue; //连接该边 mintree._edge[srcsub][dstsub] = w; X[dstsub] = true; minw += w; size++; for (int i = 0; i < n; i++) { if (_edge[dstsub][i] != MAX_W&&X[i]==false) pq.push(edge(dstsub, i, _edge[dstsub][i])); } } if (size == n - 1) return minw; else return W(); }
最短路径问题
最短路径问题:从在带权有向图G中的某一顶点出发,找出一条通往另一个顶点的最短路径,最短也就是沿路径各边的权值总和达到最小
单源最短路径算法
确定起点的最短路径问题——单源最短路径
Dijkstra算法
该算法只能解决非负权值的问题——权值不是负数
刚开始的时候,到所有点的路径和都是无穷大,表示无法到达。
对于起始点,到该点的最短路径为0。然后去更新与之相连的其他节点的路径。
下一次选取到其他节点最短的路径为下一个路过的节点,同时更新它所连接的节点的路径,如果比之前的小,就进行更新,否则不进行更新。反复此过程,直到所有的节点的访问完,最短路径也就出来了。
void Dijkstra(const V& src,vector<int>& dist,vector<int>& path) { int n = _vertex.size(); //初始化 dist.resize(n, MAX_W); path.resize(n, -1); //节点是否访问过 vector<bool> v(n, false); //起点初始化 int srcsub = Getvertexsub(src); dist[srcsub] = 0;//最小路径的权值 path[srcsub] = srcsub; for (int j = 0; j < n; j++) { //对起点的相连的边进行松弛操作 for (int i = 0; i < n; i++) { if (_edge[srcsub][i] != MAX_W && v[i] == false && _edge[srcsub][i] + dist[srcsub] < dist[i]) { dist[i] = _edge[srcsub][i] + dist[srcsub]; path[i] = srcsub; } } //获取下一个最小的路径起点 W minsub = MAX_W; for (int i = 0; i < n; i++) { if (v[i] == false && dist[i] < minsub) { srcsub = i; minsub = dist[i]; } } v[srcsub] = true; } }
Bellman-Ford算法
该算法就是解决Dijkstra不能解决负权值的问题——暴力求解的方法
该算法是怎么工作的呢?
s->i是我们求的是s经过多个点到i的路径权值
->j表示的所有路径
当我们更新出s->i的路径的时候,同时更新用i->j对所有路径进行更新操作(松弛操作)
这样就能得出s->j的路径权值——这种求出的方法是s->i->j的最短,但是如果出现k(k)
s->k的路径权值是由s->i求出的。那么我们还需要再次从新开始遍历——更新路径,每次更新会更新一条,最坏的情况是更新n条,就是n次。
要注意负权回路的问题,该算法也能检查负权回路
bool BellmanFord(const V& src,vector<int>& dist,vector<int>& path) { int n = _vertex.size(); //初始化 dist.resize(n, MAX_W); path.resize(n, -1); //起点初始化 int srcsub = Getvertexsub(src); dist[srcsub] = 0;//最小路径的权值 path[srcsub] = srcsub; bool flag = true; for (int k = 0; k < n; k++) { flag = true;//判断是否全部更新完成 for (int i = 0; i < n; i++) { for (int j = 0; j < n; j++) { //i->j的路径 //dist[i]表示s->i的路径权值,松弛操作进行更新其他边 if (_edge[i][j] != MAX_W&&dist[i]+ _edge[i][j]<dist[j]) { dist[j] = dist[i] + _edge[i][j]; path[j] = i; flag = false; } } } if (flag) break; } //判断是否有负权回路 for (int i = 0; i < n; i++) { for (int j = 0; j < n; j++) { if (_edge[i][j] != MAX_W && dist[i] + _edge[i][j] < dist[j]) { return false; } } } return true; }
多元最短路径算法——Floyd-Warshall算法
全局最短路径问题——多源最短路径,求图中所有的最短路径
该算法用的是动态规划的思想,求的是任意两点之间的权值
具体求法:
假设我们求的是i->j
那么可能存在节点k,使得i->k->j
所有i->j之间的路径可以用i->k加上k->j来求
Dij=min(Dik+Dkj,Dij)
那么路径和权值都应该用二维进行存储(压缩空间之后)
void FloydWarshall(vector<vector<int>>& dist,vector<vector<int>>& path) { //初始化 int n = _vertex.size(); dist.resize(n); path.resize(n); for (int i = 0; i < n; i++) { dist[i].resize(n, MAX_W); path[i].resize(n, -1); for (int j = 0; j < n; j++) { if (_edge[i][j] != MAX_W) { dist[i][j] = _edge[i][j]; path[i][j] = i; } if (i == j) { dist[i][j] = 0; } } } //依次用k作为中间转换 for (int k = 0; k < n; k++) { for (int i = 0; i < n; i++) { for (int j = 0; j < n; j++) { if (dist[i][k] != MAX_W && dist[k][j] != MAX_W && dist[i][k] + dist[k][j] < dist[i][j]) { dist[i][j] = dist[i][k] + dist[k][j]; //path[i][j]存的是j对应的父节点,而此时父节点应该存储的“k” //该“k”存储在path中 path[i][j] = path[k][j]; } } } } }