高阶数据结构 ——— 图

文章目录

    • 图的基本概念
    • 图的存储结构
      • 邻接矩阵
      • 邻接表
    • 图的遍历
      • 广度优先遍历
      • 深度优先遍历
    • 最小生成树
      • Kruskal算法
      • Prim算法
    • 最短路径
      • 单源最短路径-Dijkstra算法
      • 单源最短路径-Bellman-Ford算法
      • 多源最短路径-Floyd-Warshall算法

图的基本概念

图的基本概念

图是由顶点集合和边的集合组成的一种数据结构,记作 G = ( V , E ) G=(V, E) G=(V,E)

有向图和无向图:

  • 在有向图中,顶点对 < x , y > <x,y> 是有序的,顶点对 < x , y > <x,y> 称为顶点 x x x 到顶点 y y y 的一条边, < x , y > <x,y> < y , x > <y,x> 是两条不同的边。
  • 在无向图中,顶点对 ( x , y ) (x, y) (x,y)是无序的,顶点对 ( x , y ) (x, y) (x,y) 称为顶点 x x x 和顶点 y y y 相关联的一条边,这条边没有特定方向, ( x , y ) (x, y) (x,y) ( y , x ) (y, x) (y,x) 是同一条边。

如下图:

高阶数据结构 ——— 图_第1张图片

完全图:

  • 在有 n n n 个顶点的无向图中,若有 n × ( n − 1 ) ÷ 2 n \times (n-1) \div 2 n×(n1)÷2 条边,即任意两个顶点之间都有直接相连的边,则称此图为无向完全图。
  • 在有 n n n 个顶点的有向图中,若有 n × ( n − 1 ) n \times (n-1) n×(n1) 条边,即任意两个顶点之间都有双向的边,则称此图为有向完全图。

如下图:

高阶数据结构 ——— 图_第2张图片

邻接顶点:

  • 在无向图中,若 ( u , v ) (u, v) (u,v) 是图中的一条边,则称 u u u v v v 互为邻接顶点,并称边 ( u , v ) (u, v) (u,v) 依附于顶点 u u u 和顶点 v v v
  • 在有向图中,若 < u , v > <u,v> 是图中的一条边,则称顶点 u u u 邻接到顶点 v v v ,顶点 v v v 邻接自顶点 u u u ,并称边 < u , v > <u,v> 与顶点 u u u 和顶点 v v v 相关联。

顶点的度:

  • 在有向图中,顶点的度等于该顶点的入度与出度之和,顶点的入度是以该顶点为终点的边的条数,顶点的出度是以该顶点为起点的边的条数。
  • 在无向图中,顶点的度等于与该顶点相关联的边的条数,同时也等于该顶点的入度和出度。

路径与路径长度:

  • 若从顶点 v i vi vi 出发有一组边使其可到达顶点 v j vj vj ,则称顶点 v i vi vi 到顶点 v j vj vj 的顶点序列为从顶点 v i vi vi 到顶点 v j vj vj 的路径。
  • 对于不带权的图,一条路径的长度是指该路径上的边的条数;对于带权的图,一条路径的长度是指该路径上各个边权值的总和。

带权图示例:

高阶数据结构 ——— 图_第3张图片

简单路径与回路:

  • 若路径上的各个顶点均不相同,则称这样的路径为简单路径。
  • 若路径上第一个顶点与最后一个顶点相同,则称这样的路径为回路或环。

如下图:

高阶数据结构 ——— 图_第4张图片

子图:

  • 设图 G = ( V , E ) G=(V, E) G=(V,E) 和图 G 1 = ( V 1 , E 1 ) G1=(V1, E1) G1=(V1,E1),若 V 1 ⊆ V V1⊆V V1V E 1 ⊆ E E1⊆E E1E ,则称 G 1 G1 G1 G G G 的子图。

如下图:

高阶数据结构 ——— 图_第5张图片

连通图和强连通图:

  • 在无向图中,若从顶点 v 1 v1 v1 到顶点 v 2 v2 v2 有路径,则称顶点 v 1 v1 v1 与顶点 v 2 v2 v2 是连通的,如果图中任意一对顶点都是连通的,则称此图为连通图。
  • 在有向图中,若每一对顶点 v i vi vi v j vj vj 之间都存在一条从 v i vi vi v j vj vj 的路,也存在一条从 v j vj vj v i vi vi 的路,则称此图是强连通图。

生成树与最小生成树:

  • 一个连通图的最小连通子图称为该图的生成树,有 n n n 个顶点的连通图的生成树有 n n n 个顶点和 n − 1 n-1 n1 条边。
  • 最小生成树指的是一个图的生成树中,总权值最小的生成树。

图的相关应用场景

图常见的表示场景如下:

  • 交通网络:图中的每个顶点表示一个地点,图中的边表示这两个地点之间是否有直接相连的公路,边的权值可以是这两个地点之间的距离、高铁时间等。
  • 网络设备拓扑:图中的每个顶点表示网络中的一个设备,图中的边表示这两个设备之间是否可以互传数据,边的权值可以是这两个设备之间传输数据所需的时间、丢包的概率等。
  • 社交网络:图中的每个顶点表示一个人,图中的边表示这两个人是否互相认识,边的权值可以是这两个人之间的亲密度、共同好友个数等。

关于有向图和无向图:

  • 交通网络对应的图可以是有向图,也可以是无向图,无向图对应就是双向车道,有向图对应就是单向车道。
  • 网络设备拓扑对应的图通常是无向图,两个设备之间有边表示这两个设备之间可以互相收发数据。
  • 社交网络对应的图可以是有向图,也可以是无向图,无向图通常表示一些强社交关系,比如QQ、微信等(一定互为好友),有向图通常表示一些弱社交关系,比如微博、抖音(不一定互相关注)。

图的其他相关作用:

  • 在交通网络中,根据最短路径算法计算两个地点之间的最短路径,根据最小生成树算法得到将各个地点连通起来所需的最小成本。
  • 在社交网络中,根据广度优先搜索得到两个人之间的共同好友进行好友推荐,根据入边表和出边表得知有哪些粉丝以及关注了哪些博主。

图与树的联系与区别

图与树的主要联系与区别如下:

  • 树是一种有向无环且连通的图(空树除外),但图并不一定是树。
  • n n n 个结点的树必须有 n − 1 n-1 n1 条边,而图中边的数量不取决于顶点的数量。
  • 树通常用于存储数据,并快速查找目标数据,而图通常用于表示某种场景。

图的存储结构

图由顶点和边组成,存储图本质就是将图中的顶点和边存储起来。

邻接矩阵

邻接矩阵

邻接矩阵存储图的方式如下:

  1. 用一个数组存储顶点集合,顶点所在位置的下标作为该顶点的编号(所给顶点可能不是整型)。
  2. 用一个二维数组 m a t r i x matrix matrix 存储边的集合,其中 m a t r i x [ i ] [ j ] matrix[i][j] matrix[i][j] 表示编号为 i i i j j j 的两个顶点之间的关系。

如下图:

高阶数据结构 ——— 图_第6张图片

说明一下:

  • 对于不带权的图,两个顶点之间要么相连,要么不相连,可以用0和1表示, m a t r i x [ i ] [ j ] matrix[i][j] matrix[i][j] 为1表示编号为 i i i j j j 的两个顶点相连,为0表示不相连。
  • 对于带权的图,连接两个顶点的边会带有一个权值,可以用这个权值来设置对应 m a t r i x [ i ] [ j ] matrix[i][j] matrix[i][j] 的值,如果两个顶点不相连,则使用不会出现的权值进行设置即可(图中为无穷大)。
  • 对于无向图来说,顶点 i i i 和顶点 j j j 相连,那么顶点 j j j 就和顶点 i i i 相连,因此无向图对应的邻接矩阵是一个对称矩阵,即 m a t r i x [ i ] [ j ] matrix[i][j] matrix[i][j] 的值等于 m a t r i x [ j ] [ i ] matrix[j][i] matrix[j][i] 的值。
  • 在邻接矩阵中,第 i i i 行元素中有效权值的个数就是编号为 i i i 的顶点的出度,第 i i i 列元素中有效元素的个数就是编号为 i i i 的顶点的入度。

邻接矩阵的优缺点

邻接矩阵的优点:

  • 邻接矩阵适合存储稠密图,因为存储稠密图和稀疏图时所开辟的二维数组大小是相同的,因此图中的边越多,邻接矩阵的优势就越明显。
  • 邻接矩阵能够 O ( 1 ) O(1) O(1) 的判断两个顶点是否相连,并获得相连边的权值。

邻接矩阵的缺点:

  • 邻接矩阵不适合查找一个顶点连接出去的所有边,需要遍历矩阵中对应的一行,该过程的时间复杂度是 O ( N ) O(N) O(N) ,其中 N N N 表示的是顶点的个数。

邻接矩阵的实现

邻接矩阵所需成员变量:

  • 数组 v e r t e x s vertexs vertexs :用于存储顶点集合,顶点所在位置的下标作为该顶点的编号。
  • 映射关系 v I n d e x M a p vIndexMap vIndexMap :用于建立顶点与其下标的映射关系,便于根据顶点找到其对应的下标编号。
  • 邻接矩阵 m a t r i x matrix matrix :用于存储边的集合, m a t r i x [ i ] [ j ] matrix[i][j] matrix[i][j] 表示编号为 i i i j j j 的两个顶点之间的关系。

邻接矩阵的实现:

  • 为了支持任意类型的顶点类型以及权值,可以将图定义为模板类,其中 V V V W W W 分别表示顶点和权值的类型, M A X _ W MAX\_W MAX_W 表示两个顶点没有连接时邻接矩阵中存储的值,将 M A X _ W MAX\_W MAX_W 的缺省值设置为 I N T _ M A X INT\_MAX INT_MAX (权值一般为整型), D i r e c t i o n Direction Direction 表示图是否为有向图,将 D i r e c t i o n Direction Direction 的缺省值设置为 f a l s e false false (无向图居多)。
  • 在构造函数中完成顶点集合的设置,并建立各个顶点与其对应下标的映射关系,同时为邻接矩阵开辟空间,将矩阵中的值初始化为 M A X _ W MAX\_W MAX_W ,表示刚开始时各个顶点之间均不相连。
  • 提供一个接口用于添加边,在添加边时先分别获取源顶点和目标顶点对应的下标编号,然后再将邻接矩阵中对应位置设置为边的权值,如果图为无向图,则还需要在邻接矩阵中添加目标顶点到源顶点的边。
  • 在获取顶点对应的下标时,先在 v I n d e x M a p vIndexMap vIndexMap 中进行查找,如果找到了对应的顶点,则返回该顶点对应的下标编号,如果没有找到对应的顶点,则说明所给顶点不存在,此时可以抛出异常。

代码如下:

//邻接矩阵
namespace Matrix {
	template<class V, class W, W MAX_W = INT_MAX, bool Direction = false>
	class Graph {
	public:
		//构造函数
		Graph(const V* vertexs, int n)
			:_vertexs(vertexs, vertexs + n) //设置顶点集合
			,_matrix(n, vector<int>(n, MAX_W)) { //开辟二维数组空间
			//建立顶点与下标的映射关系
			for (int i = 0; i < n; i++) {
				_vIndexMap[vertexs[i]] = i;
			}
		}
		//获取顶点对应的下标
		int getVertexIndex(const V& v) {
			auto iter = _vIndexMap.find(v);
			if (iter != _vIndexMap.end()) { //顶点存在
				return iter->second;
			}
			else { //顶点不存在
				throw invalid_argument("不存在的顶点");
				return -1;
			}
		}
		//添加边
		void addEdge(const V& src, const V& dst, const W& weight) {
			int srci = getVertexIndex(src), dsti = getVertexIndex(dst); //获取源顶点和目标顶点的下标
			_matrix[srci][dsti] = weight; //设置邻接矩阵中对应的值
			if (Direction == false) { //无向图
				_matrix[dsti][srci] = weight; //添加从目标顶点到源顶点的边
			}
		}
		//打印顶点集合和邻接矩阵
		void print() {
			int n = _vertexs.size();
			//打印顶点集合
			for (int i = 0; i < n; i++) {
				cout << "[" << i << "]->" << _vertexs[i] << endl;
			}
			cout << endl;

			//打印邻接矩阵
			//横下标
			cout << "  ";
			for (int i = 0; i < n; i++) {
				//cout << i << " ";
				printf("%4d", i);
			}
			cout << endl;
			for (int i = 0; i < n; i++) {
				cout << i << " "; //竖下标
				for (int j = 0; j < n; j++) {
					if (_matrix[i][j] == MAX_W) {
						printf("%4c", '*');
					}
					else {
						printf("%4d", _matrix[i][j]);
					}
				}
				cout << endl;
			}
			cout << endl;
		}
	private:
		vector<V> _vertexs;               //顶点集合
		unordered_map<V, int> _vIndexMap; //顶点映射下标
		vector<vector<W>> _matrix;        //邻接矩阵
	};
}

说明一下:

  • 为了方便观察,可以在类中增加一个 p r i n t print print 接口,用于打印顶点集合和邻接矩阵。
  • 后续图的相关算法都会以邻接矩阵为例进行讲解,因为一般只有比较稠密的图才会存在最小生成树和最短路径的问题。

邻接表

邻接表

邻接表存储图的方式如下:

  1. 用一个数组存储顶点集合,顶点所在的位置的下标作为该顶点的编号(所给顶点可能不是整型)。
  2. 用一个出边表存储从各个顶点连接出去的边,出边表中下标为 i i i 的位置存储的是从编号为 i i i 的顶点连接出去的边。
  3. 用一个入边表存储连接到各个顶点的边,入边表中下标为 i i i 的位置存储的是连接到编号为 i i i 的顶点的边。

如下图:

高阶数据结构 ——— 图_第7张图片

说明一下:

  • 出边表和入边表类似于哈希桶,其中每个位置存储的都是一个链表,出边表中下标为 i i i 的位置的链表中存储的都是从编号为 i i i 的顶点连接出去的边,入边表中下标为 i i i 的位置的链表中存储的都是连接到编号为 i i i 的顶点的边。
  • 在邻接表中,出边表中下标为 i i i 的位置的链表中元素的个数就是编号为 i i i 的顶点的出度,入边表中下标为 i i i 的的位置的链表中元素的个数就是编号为 i i i 的顶点的入度。
  • 在实现邻接表时,一般只需要用一个出边表来存储从各个顶点连接出去的边即可,因为大多数情况下都是需要从一个顶点出发找与其相连的其他顶点,所以一般不需要存储入边表。

邻接表的优缺点

邻接表的优点:

  • 邻接表适合存储稀疏图,因为邻接表存储图时开辟的空间大小取决于边的数量,图中边的数量越少,邻接表存储边时所需的内存空间就越少。
  • 邻接表适合查找一个顶点连接出去的所有边,出边表中下标为 i i i 的位置的链表中存储的就是从顶点 i i i 连接出去的所有边。

邻接表的缺点:

  • 邻接表不适合确定两个顶点是否相连,需要遍历出边表中源顶点对应位置的链表,该过程的时间复杂度是 O ( E ) O(E) O(E) ,其中 E E E 表示从源顶点连接出去的边的数量。

邻接表的实现

链表结点所需成员变量:

  • 源顶点下标 s r c i srci srci :表示边的源顶点。
  • 目标顶点下标 d s t i dsti dsti :表示边的目标顶点。
  • 权值 w e i g h t weight weight :表示边的权值。
  • 指针 n e x t next next :连接下一个结点。

代码如下:

//邻接表
namespace LinkTable {
	//链表结点定义
	template<class W>
	struct Edge {
		//int _srci;    //源顶点的下标(可选)
		int _dsti;      //目标顶点的下标
		W _weight;      //边的权值
		Edge<W>* _next; //连接指针

		Edge(int dsti, const W& weight)
			:_dsti(dsti)
			,_weight(weight)
			,_next(nullptr)
		{}
	};
}

说明一下:

  • 对于出边表来说,下标为 i i i 的位置的链表中存储的边的源顶点都是顶点 i i i ,所以链表结点中的源顶点成员可以不用存储。
  • 对于入边表来说,下标为 i i i 的位置的链表中存储的边的目标顶点都是顶点 i i i ,所以链表结点中的目标顶点成员可以不用存储。

邻接表所需成员变量:

  • 数组 v e r t e x s vertexs vertexs :用于存储顶点集合,顶点所在位置的下标作为该顶点的编号。
  • 映射关系 v I n d e x M a p vIndexMap vIndexMap :用于建立顶点与其下标的映射关系,便于根据顶点找到其对应的下标编号。
  • 邻接表(出边表) l i n k T a b l e linkTable linkTable :用于存储边的集合, l i n k T a b l e [ i ] linkTable[i] linkTable[i] 链表中存储的边的源顶点都是顶点 i i i

邻接表的实现:

  • 为了支持任意类型的顶点类型以及权值,可以将图定义为模板,其中 V V V W W W 分别表示顶点和权值的类型, D i r e c t i o n Direction Direction 表示图是否为有向图,将 D i r e c t i o n Direction Direction 的缺省值设置为 f a l s e false false (无向图居多)。
  • 在构造函数中完成顶点集合的设置,并建立各个顶点与其对应下标的映射关系,同时为邻接表开辟空间,将邻接表中的值初始化为空指针,表示刚开始时各个顶点之间均不相连。
  • 提供一个接口用于添加边,在添加边时先分别获取源顶点和目标顶点对应的下标编号,然后在源顶点对应的链表中头插一个边结点,如果图为无向图,则还需要在目标顶点对应的链表中头插一个边结点。

代码如下:

//邻接表
namespace LinkTable {
	template<class V, class W, bool Direction = false>
	class Graph {
		typedef Edge<W> Edge;
	public:
		//构造函数
		Graph(const V* vertexs, int n)
			:_vertexs(vertexs, vertexs + n) //设置顶点集合
			, _linkTable(n, nullptr) { //开辟邻接表的空间
			//建立顶点与下标的映射关系
			for (int i = 0; i < n; i++) {
				_vIndexMap[vertexs[i]] = i;
			}
		}
		//获取顶点对应的下标
		int getVertexIndex(const V& v) {
			auto iter = _vIndexMap.find(v);
			if (iter != _vIndexMap.end()) { //顶点存在
				return iter->second;
			}
			else { //顶点不存在
				throw invalid_argument("不存在的顶点");
				return -1;
			}
		}
		//添加边
		void addEdge(const V& src, const V& dst, const W& weight) {
			int srci = getVertexIndex(src), dsti = getVertexIndex(dst); //获取源顶点和目标顶点的下标

			//添加从源顶点到目标顶点的边
			Edge* sdEdge = new Edge(dsti, weight);
			sdEdge->_next = _linkTable[srci];
			_linkTable[srci] = sdEdge;

			if (Direction == false) { //无向图
				//添加从目标顶点到源顶点的边
				Edge* dsEdge = new Edge(srci, weight);
				dsEdge->_next = _linkTable[dsti];
				_linkTable[dsti] = dsEdge;
			}
		}
		//打印顶点集合和邻接表
		void print() {
			int n = _vertexs.size();
			//打印顶点集合
			for (int i = 0; i < n; i++) {
				cout << "[" << i << "]->" << _vertexs[i] << " ";
			}
			cout << endl << endl;

			//打印邻接表
			for (int i = 0; i < n; i++) {
				Edge* cur = _linkTable[i];
				cout << "[" << i << ":" << _vertexs[i] << "]->";
				while (cur) {
					cout << "[" << cur->_dsti << ":" << _vertexs[cur->_dsti] << ":" << cur->_weight << "]->";
					cur = cur->_next;
				}
				cout << "nullptr" << endl;
			}
		}
	private:
		vector<V> _vertexs;               //顶点集合
		unordered_map<V, int> _vIndexMap; //顶点映射下标
		vector<Edge*> _linkTable;         //邻接表(出边表)
	};
}

说明一下:

  • 为了方便观察,可以在类中增加一个 p r i n t print print 接口,用于打印顶点集合和邻接表。

图的遍历

图的遍历指的是遍历图中的顶点,主要有广度优先遍历和深度优先遍历两种方式。

广度优先遍历

广度优先遍历

广度优先遍历又称BFS,其遍历过程类似于二叉树的层序遍历,从起始顶点开始一层一层向外进行遍历。

如下图:

高阶数据结构 ——— 图_第8张图片

广度优先遍历的实现:

  • 广度优先遍历需要借助一个队列和一个标记数组,利用队列先进先出的特点实现一层一层向外遍历,利用标记数组来记录各个顶点是否被访问过。
  • 刚开始时将起始顶点入队列,并将起始顶点标记为访问过,然后不断从队列中取出顶点进行访问,并判断该顶点是否有邻接顶点,如果有邻接顶点并且该邻接顶点没有被访问过,则将该邻接顶点入队列,并在入队列后立即将该邻接顶点标记为访问过。

代码如下:

//邻接矩阵
namespace Matrix {
	template<class V, class W, W MAX_W = INT_MAX, bool Direction = false>
	class Graph {
	public:
		//广度优先遍历
		void bfs(const V& src) {
			int srci = getVertexIndex(src); //起始顶点的下标
			queue<int> q; //队列
			vector<bool> visited(_vertexs.size(), false); //标记数组
			q.push(srci); //起始顶点入队列
			visited[srci] = true; //起始顶点标记为访问过
			
			while (!q.empty()) {
				int front = q.front();
				q.pop();
				cout << _vertexs[front] << " ";
				for (int i = 0; i < _vertexs.size(); i++) { //找出从front连接出去的顶点
					if (_matrix[front][i] != MAX_W && visited[i] == false) { //是邻接顶点,并且没有被访问过
						q.push(i); //入队列
						visited[i] = true; //标记为访问过
					}
				}
			}
			cout << endl;
		}
	private:
		vector<V> _vertexs;               //顶点集合
		unordered_map<V, int> _vIndexMap; //顶点映射下标
		vector<vector<W>> _matrix;        //邻接矩阵
	};
}

说明一下:

  • 为了防止顶点被重复加入队列导致死循环,因此需要一个标记数组,当一个顶点被访问过后就不应该再将其加入队列了。
  • 如果当一个顶点从队列中取出访问时才再将其标记为访问过,也可能会存在顶点被重复加入队列的情况,比如当图中的顶点B出队列时,顶点C作为顶点B的邻接顶点并且还没有被访问过(顶点C还在队列中),此时顶点C就会再次被加入队列,因此最好在一个顶点被入队列时就将其标记为访问过。
  • 如果所给图不是一个连通图,那么从一个顶点开始进行广度优先遍历,无法遍历完图中的所有顶点,这时可以遍历标记数组,查看哪些顶点还没有被访问过,对于没有被访问过的顶点,则从该顶点处继续进行广度优先遍历,直到图中所有的顶点都被访问过。

深度优先遍历

深度优先遍历

深度优先遍历又称DFS,其遍历过程类似于二叉树的先序遍历,从起始顶点开始不断对顶点进行深入遍历。

如下图:

高阶数据结构 ——— 图_第9张图片

深度优先遍历的实现:

  • 深度优先遍历可以通过递归实现,同时也需要借助一个标记数组来记录各个顶点是否被访问过。
  • 从起始顶点处开始进行递归遍历,在遍历过程中先对当前顶点进行访问,并将其标记为访问过,然后判断该顶点是否有邻接顶点,如果有邻接顶点并且该邻接顶点没有被访问过,则递归遍历该邻接顶点。

代码如下:

//邻接矩阵
namespace Matrix {
	template<class V, class W, W MAX_W = INT_MAX, bool Direction = false>
	class Graph {
	public:
		//深度优先遍历(子函数)
		void _dfs(int srci, vector<bool>& visited) {
			cout << _vertexs[srci] << " "; //访问
			visited[srci] = true; //标记为访问过
			for (int i = 0; i < _vertexs.size(); i++) { //找从srci连接出去的顶点
				if (_matrix[srci][i] != MAX_W && visited[i] == false) { //是邻接顶点,并且没有被访问过
					_dfs(i, visited); //递归遍历
				}
			}
		}
		//深度优先遍历
		void dfs(const V& src) {
			int srci = getVertexIndex(src); //起始顶点的下标
			vector<bool> visited(_vertexs.size(), false); //标记数组
			_dfs(srci, visited); //递归遍历
			cout << endl;
		}
	private:
		vector<V> _vertexs;               //顶点集合
		unordered_map<V, int> _vIndexMap; //顶点映射下标
		vector<vector<W>> _matrix;        //邻接矩阵
	};
}

说明一下:

  • 如果所给图不是一个连通图,那么从一个顶点开始进行深度优先遍历,无法遍历完图中的所有顶点,这时可以遍历标记数组,查看哪些顶点还没有被访问过,对于没有被访问过的顶点,则从该顶点处继续进行深度优先遍历,直到图中所有的顶点都被访问过。

最小生成树

最小生成树

关于最小生成树:

  • 一个连通图的最小连通子图称为该图的生成树,若连通图由 n n n 个顶点组成,则其生成树必含 n n n 个顶点和 n − 1 n-1 n1 条边,最小生成树指的是一个图的生成树中,总权值最小的生成树。
  • 连通图中的每一棵生成树都是原图的一个极大无环子图,从其中删去任何一条边,生成树就不再连通,在其中引入任何一条新边,都会形成一条回路。

说明一下:

  • 对于各个顶点来说,除了第一个顶点之外,其他每个顶点想要连接到图中,至少需要一条边使其连接进来,所以由 n n n 个顶点的连通图的生成树有 n n n 个顶点和 n − 1 n-1 n1 条边。
  • 对于生成树来说,图中的每个顶点已经连通了,如果再引入一条新边,那么必然会使得被新边相连的两个顶点之间存在一条直接路径和一条间接路径,即形成回路。
  • 最小生成树是图的生成树中总权值最小的生成树,生成树是图的最小连通子图,而连通图是无向图的概念,有向图对应的是强连通图,所以最小生成树算法的处理对象都是无向图。

构成最小生成树的准则

构造最小生成树的准则如下:

  1. 只能使用图中的边来构造最小生成树。
  2. 只能使用恰好 n − 1 n-1 n1 条边来连接图中的 n n n 个顶点。
  3. 选用的 n − 1 n-1 n1 条边不能构成回路。

构造最小生成树的算法有Kruskal算法和Prim算法,这两个算法都采用了逐步求解的贪心策略。

Kruskal算法

Kruskal算法(克鲁斯卡尔算法)

Kruskal算法的基本思想如下:

  • 构造一个含 n n n 个顶点、不含任何边的图作为最小生成树,对原图中的各个边按权值进行排序。
  • 每次从原图中选出一条最小权值的边,将其加入到最小生成树中,如果加入这条边会使得最小生成树中构成回路,则重新选择一条边。
  • 按照上述规则不断选边,当选出 n − 1 n-1 n1 条合法的边时,则说明最小生成树构造完毕,如果无法选出 n − 1 n-1 n1 条合法的边,则说明原图不存在最小生成树。

动图演示:

高阶数据结构 ——— 图_第10张图片

Kruskal算法的实现:

  • 根据原图设置最小生成树的顶点集合,以及顶点与下标的映射关系,开辟最小生成树的邻接矩阵空间,并将矩阵中的值初始化为 M A X _ W MAX\_W MAX_W ,表示刚开始时最小生成树中不含任何边。
  • 遍历原图的邻接矩阵,按权值将原图中的所有边添加到优先级队列(小堆)中,为了避免重复添加相同的边,在遍历原图的邻接矩阵时只应该遍历矩阵的一半。
  • 使用一个并查集来辅助判环操作,刚开始时图中的顶点各自为一个集合,当两个顶点相连时将这两个顶点对应的集合进行合并,使得连通的顶点在同一个集合,这样通过并查集就能判断所选的边是否会使得最小生成树中构成回路,如果所选边连接的两个顶点本就在同一个集合,那么加入这条边就会构成回路。
  • 使用 c o u n t count count t o t a l W e i g h t totalWeight totalWeight 分别记录所选边的数量和最小生成树的总权值,当 c o u n t count count 的值等于 n − 1 n-1 n1 时则停止选边,此时可以将最小生成树的总权值作为返回值进行返回。
  • 每次选边时从优先级队列中获取一个权值最小的边,并通过并查集判断这条边连接的两个顶点是否在同一个集合,如果在则重新选边,如果不在则将这条边添加到最小生成树中,并将这条边连接的两个顶点对应的集合进行合并,同时更新 c o u n t count count t o t a l W e i g h t totalWeight totalWeight 的值。
  • 当选边结束时,如果 c o u n t count count 的值等于 n − 1 n-1 n1 ,则说明最小生成树构造成功,否则说明原图无法构造出最小生成树。

代码如下:

//邻接矩阵
namespace Matrix {
	template<class V, class W, W MAX_W = INT_MAX, bool Direction = false>
	class Graph {
	public:
		//强制生成默认构造
		Graph() = default;
		
		void _addEdge(int srci, int dsti, const W& weight) {
			_matrix[srci][dsti] = weight; //设置邻接矩阵中对应的值
			if (Direction == false) { //无向图
				_matrix[dsti][srci] = weight; //添加从目标顶点到源顶点的边
			}
		}
		//添加边
		void addEdge(const V& src, const V& dst, const W& weight) {
			int srci = getVertexIndex(src), dsti = getVertexIndex(dst); //获取源顶点和目标顶点的下标
			_addEdge(srci, dsti, weight);
		}
		//边
		struct Edge {
			int _srci; //源顶点的下标
			int _dsti; //目标顶点的下标
			W _weight; //边的权值
			Edge(int srci, int dsti, const W& weight)
				:_srci(srci)
				, _dsti(dsti)
				, _weight(weight)
			{}
			bool operator>(const Edge& edge) const{
				return _weight > edge._weight;
			}
		};
		//获取当前图的最小生成树(Kruskal算法)
		W Kruskal(Graph<V, W, MAX_W, Direction>& minTree) {
			int n = _vertexs.size();
			//设置最小生成树的各个成员变量
			minTree._vertexs = _vertexs; //设置最小生成树的顶点集合
			minTree._vIndexMap = _vIndexMap; //设置最小生成树顶点与下标的映射
			minTree._matrix.resize(n, vector<W>(n, MAX_W)); //开辟最小生成树的二维数组空间

			priority_queue<Edge, vector<Edge>, greater<Edge>> minHeap; //优先级队列(小堆)
			//将所有边添加到优先级队列
			for (int i = 0; i < n; i++) {
				for (int j = 0; j < i; j++) { //只遍历矩阵的一半,避免重复添加相同的边
					if (_matrix[i][j] != MAX_W)
						minHeap.push(Edge(i, j, _matrix[i][j]));
				}
			}
			UnionFindSet ufs(n); //n个顶点的并查集
			int count = 0; //已选边的数量
			W totalWeight = W(); //最小生成树的总权值
			while (!minHeap.empty() && count < n - 1) {
				//从优先级队列中获取一个权值最小的边
				Edge minEdge = minHeap.top();
				minHeap.pop();
				int srci = minEdge._srci, dsti = minEdge._dsti;
				W weight = minEdge._weight;

				if (!ufs.inSameSet(srci, dsti)) { //边的源顶点和目标顶点不在同一个集合
					minTree._addEdge(srci, dsti, weight); //在最小生成树中添加边
					ufs.unionSet(srci, dsti); //合并源顶点和目标顶点对应的集合
					count++;
					totalWeight += weight;
					cout << "选边: " << _vertexs[srci] << "->" << _vertexs[dsti] << ":" << weight << endl;
				}
				else { //边的源顶点和目标顶点在同一个集合,加入这条边会构成环
					cout << "成环: " << _vertexs[srci] << "->" << _vertexs[dsti] << ":" << weight << endl;
				}
			}
			if (count == n - 1) {
				cout << "构建最小生成树成功" << endl;
				return totalWeight;
			}
			else {
				cout << "无法构成最小生成树" << endl;
				return W();
			}
		}
	private:
		vector<V> _vertexs;               //顶点集合
		unordered_map<V, int> _vIndexMap; //顶点映射下标
		vector<vector<W>> _matrix;        //邻接矩阵
	};
}

说明一下:

  • 在获取图的最小生成树时,会以无参的方式定义一个最小生成树对象,然后用原图对象调用上述Kruskal函数,通过输出型参数的方式获取原图的最小生成树,由于我们定义了一个带参的构造函数,使得编译器不再生成默认构造函数,因此需要通过default关键字强制生成Graph类的默认构造函数。
  • 一条边包含两个顶点和边的权值,可以定义一个Edge结构体来描述一条边,结构体内包含边的源顶点和目标顶点的下标以及边的权值,在使用优先级队列构造小堆结构时,需要存储的对象之间能够支持 > > > 运算符操作,因此需要对Edge结构体的 > > > 运算符进行重载,将其重载为边的权值的比较。
  • 当选出的边不会构成回路时,需要将这条边插入到最小生成树对应的图中,此时已经知道了这条边的源顶点和目标顶点对应的下标,可以在Graph类中新增一个_addEdge子函数,该函数支持通过源顶点和目标顶点的下标向图中插入边,而Graph类中原有的addEdge函数可以复用这个_addEdge子函数。
  • 最小生成树不一定是唯一的,特别是当原图中存在很多权值相等的边的时候,比如对于动图中的图来说,将最小生成树中的 b c bc bc 边换成 a h ah ah 边也是一棵最小生成树。
  • 上述代码中通过优先级队列构造小堆来依次获取权值最小的边,你也可以通过其他排序算法按权值对边进行排序,然后按权值从小到大依次遍历各个边进行选边操作。
  • 上述代码中使用的并查集UnionFindSet类,在博主的另一篇博客中有讲解,不了解并查集的博友可以跳转到博主的这篇博客:高阶数据结构 ——— 并查集。

Prim算法

Prim算法(普里姆算法)

Prim算法的基本思想如下:

  • 构造一个含 n n n 个顶点、不含任何边的图作为最小生成树,将图中的顶点分为两个集合, f o r e s t forest forest 集合中的顶点是已经连接到最小生成树中的顶点, r e m a i n remain remain 集合中的顶点是还没有连接到最小生成树中的顶点,刚开始时 f o r e s t forest forest 集合中只包含给定的起始顶点。
  • 每次从连接 f o r e s t forest forest 集合与 r e m a i n remain remain 集合的所有边中选出一条权值最小的边,将其加入到最小生成树中,由于选出来的边对应的两个顶点一个属于 f o r e s t forest forest 集合,另一个属于 r e m a i n remain remain 集合,因此是不会构成回路的。
  • 按照上述规则不断选边,当选出 n − 1 n-1 n1 条边时,所有的顶点都已经加入到了 f o r e s t forest forest 集合,此时最小生成树构造完毕,如果无法选出 n − 1 n-1 n1 条边,则说明原图不存在最小生成树。

动图演示:

高阶数据结构 ——— 图_第11张图片

Prim算法的实现:

  • 根据原图设置最小生成树的顶点集合,以及顶点与下标的映射关系,开辟最小生成树的邻接矩阵空间,并将矩阵中的值初始化为 M A X _ W MAX\_W MAX_W ,表示刚开始时最小生成树中不含任何边。
  • 使用一个 f o r e s t forest forest 数组来表示各个顶点是否在 f o r e s t forest forest 集合中,刚开始时只有起始顶点在 f o r e s t forest forest 集合中,并将所有从起始顶点连接出去的边加入优先级队列(小堆),这些边就是刚开始时连接 f o r e s t forest forest 集合与 r e m a i n remain remain 集合的边。
  • 使用 c o u n t count count t o t a l W e i g h t totalWeight totalWeight 分别记录所选边的数量和最小生成树的总权值,当 c o u n t count count 的值等于 n − 1 n-1 n1 时则停止选边,此时将最小生成树的总权值作为返回值进行返回。
  • 每次选边时从优先级队列中获取一个权值最小的边,将这条边添加到最小生成树中,并将这条边的目标顶点加入 f o r e s t forest forest 集合中,同时更新 c o u n t count count t o t a l W e i g h t totalWeight totalWeight 的值。此外,还需要将从这条边的目标顶点连接出去的边加入优先级队列,但是需要保证加入的边的目标顶点不能在 f o r e s t forest forest 集合,否则后续选出源顶点和目标顶点都在 f o r e s t forest forest 集合的边就会构成回路。
  • 需要注意的是,每次从优先级队列中选出一个权值最小的边时,还需要保证选出的这条边的目标顶点不在 f o r e s t forest forest 集合中,避免构成回路。虽然向优先级队列中加入边时保证了加入的边的目标顶点不在 f o r e s t forest forest 集合中,但经过后续不断的选边,可能会导致之前加入优先级队列中的某些边的目标顶点也被加入到了 f o r e s t forest forest 集合中。
  • 当选边结束时,如果 c o u n t count count 的值等于 n − 1 n-1 n1 ,则说明最小生成树构造成功,否则说明原图无法构造出最小生成树。

代码如下:

//邻接矩阵
namespace Matrix {
	template<class V, class W, W MAX_W = INT_MAX, bool Direction = false>
	class Graph {
	public:
		//边
		struct Edge {
			int _srci; //源顶点的下标
			int _dsti; //目标顶点的下标
			W _weight; //边的权值
			Edge(int srci, int dsti, const W& weight)
				:_srci(srci)
				, _dsti(dsti)
				, _weight(weight)
			{}
			bool operator>(const Edge& edge) const{
				return _weight > edge._weight;
			}
		};
		//获取当前图的最小生成树(Prim算法)
		W Prim(Graph<V, W, MAX_W, Direction>& minTree, const V& start) {
			int n = _vertexs.size();
			//设置最小生成树的各个成员变量
			minTree._vertexs = _vertexs; //设置最小生成树的顶点集合
			minTree._vIndexMap = _vIndexMap; //设置最小生成树顶点与下标的映射
			minTree._matrix.resize(n, vector<W>(n, MAX_W)); //开辟最小生成树的二维数组空间

			int starti = getVertexIndex(start); //获取起始顶点的下标
			vector<bool> forest(n, false);
			forest[starti] = true;
			priority_queue<Edge, vector<Edge>, greater<Edge>> minHeap; //优先级队列(小堆)
			
			//将从起始顶点连接出去的边加入优先级队列
			for (int i = 0; i < n; i++) {
				if (_matrix[starti][i] != MAX_W)
					minHeap.push(Edge(starti, i, _matrix[starti][i]));
			}

			int count = 0; //已选边的数量
			W totalWeight = W(); //最小生成树的总权值
			while (!minHeap.empty() && count < n - 1) {
				//从优先级队列中获取一个权值最小的边
				Edge minEdge = minHeap.top();
				minHeap.pop();
				int srci = minEdge._srci, dsti = minEdge._dsti;
				W weight = minEdge._weight;

				if (forest[dsti] == false) { //边的目标顶点还没有被加入到forest集合中
					//将从目标顶点连接出去的边加入优先级队列
					for (int i = 0; i < n; i++) {
						if (_matrix[dsti][i] != MAX_W && forest[i] == false) //加入的边的目标顶点不能在forest集合中
							minHeap.push(Edge(dsti, i, _matrix[dsti][i]));
					}
					minTree._addEdge(srci, dsti, weight); //在最小生成树中添加边
					forest[dsti] = true; //将边的目标顶点加入forest集合
					count++;
					totalWeight += weight;
					cout << "选边: " << _vertexs[srci] << "->" << _vertexs[dsti] << ":" << weight << endl;
				}
				else { //边的目标顶点已经在forest集合中,加入这条边会构成环
					cout << "成环: " << _vertexs[srci] << "->" << _vertexs[dsti] << ":" << weight << endl;
				}
			}
			if (count == n - 1) {
				cout << "构建最小生成树成功" << endl;
				return totalWeight;
			}
			else {
				cout << "无法构成最小生成树" << endl;
				return W();
			}
		}
	private:
		vector<V> _vertexs;               //顶点集合
		unordered_map<V, int> _vIndexMap; //顶点映射下标
		vector<vector<W>> _matrix;        //邻接矩阵
	};
}

说明一下:

  • Prim算法构造最小生成树的思想在选边时是不需要判环,但上述利用优先级队列实现的过程中仍需判环,如果在每次选边的时候能够通过某种方式,从连接 f o r e s t forest forest 集合和 r e m a i n remain remain 集合的所有边中选出权值最小的边,那么就无需判环,但这两个集合中的顶点是不断在变化的,每次选边时都遍历连接两个集合的所有边,该过程的时间复杂度较高。
  • Kruskal算法本质是一种全局的贪心,每次选边时都是在所有边中选出权值最小的边,而Prim算法本质是一种局部的贪心,每次选边时是从连接 f o r e s t forest forest 集合和 r e m a i n remain remain 集合的所有边中选出权值最小的边。

最短路径

最短路径

关于最短路径:

  • 最短路径问题:从带权有向图中的某一顶点出发,找出一条通往另一顶点的最短路径,最短指的是路径各边的权值总和达到最小,最短路径可分为单源最短路径和多源最短路径。
  • 单源最短路径指的是从图中某一顶点出发,找出通往其他所有顶点的最短路径,而多源最短路径指的是,找出图中任意两个顶点之间的最短路径。

单源最短路径-Dijkstra算法

Dijkstra算法(迪杰斯特拉算法)

使用前提:图中所有边的权值非负。

Dijkstra算法的基本思想如下:

  • 将图中的顶点分为两个集合,集合 S S S 中的顶点是已经确定从源顶点到该顶点的最短路径的顶点,集合 Q Q Q 中的顶点是尚未确定从源顶点到该顶点的最短路径的顶点。
  • 每个顶点都有一个估计值,表示从源顶点到该顶点的可能最短路径长度,每次从集合 Q Q Q 中选出一个估计值最小的顶点,将其加入到集合 S S S 中,并对该顶点连接出去的顶点的估计值和前驱顶点进行松弛更新。
  • 按照上述步骤不断从集合 Q Q Q 中选取估计值最小的顶点到集合 S S S 中,直到所有的顶点都被加入到集合 S S S 中,此时通过各个顶点的估计值就可以得知源顶点到该顶点的最短路径长度,通过各个顶点的前驱顶点就可以得知最短路径的走向。

动图演示:

高阶数据结构 ——— 图_第12张图片

Dijkstra算法的实现:

  • 使用一个 d i s t dist dist 数组来记录从源顶点到各个顶点的最短路径长度估计值,初始时将源顶点的估计值设置为权值的缺省值(比如int就是0),表示从源顶点到源顶点的路径长度为0,将其余顶点的估计值设置为MAX_W,表示从源顶点暂时无法到达其他顶点。
  • 使用一个 p a r e n t P a t h parentPath parentPath 数组来记录到达各个顶点路径的前驱顶点,初始时将各个顶点的前驱顶点初始化为-1,表示各个顶点暂时只能自己到达自己,没有前驱顶点。
  • 使用一个 b o o l bool bool 数组来记录各个顶点是否在 S S S 集合中,初始时所有顶点均不在 S S S 集合,表示各个顶点都还没有确定最短路径。
  • 每次从 Q Q Q 集合中选出一个估计值最小的顶点 u u u,将其加入到 S S S 集合,并对顶点 u u u 连接出去的各个顶点 v v v 进行松弛更新,如果能够将顶点 v v v 更新出更小的估计值,则更新其估计值,并将被更新的顶点 v v v 的前驱顶点改为顶点 u u u,因为从顶点 u u u 到顶点 v v v 能够得到更小的估计值,所以在当前看来(后续可能还会更新)到达顶点 v v v 的最短路径的前驱顶点就应该是顶点 u u u ,如果不能将顶点 v v v 更新出更小的估计值,则维持原样。
  • 当所有的顶点都加入集合 S S S 后, d i s t dist dist 数组中存储的就是从源顶点到各个顶点的最短路径长度, p a r e n t P a t h parentPath parentPath 数组中存储的就是从源顶点到各个顶点的最短路径的前驱顶点,通过不断查找各个顶点的前驱顶点,最终就能得到从源顶点到各个顶点的最短路径。

代码如下:

//邻接矩阵
namespace Matrix {
	template<class V, class W, W MAX_W = INT_MAX, bool Direction = false>
	class Graph {
	public:
		//获取单源最短路径(Dijkstra算法)
		void Dijkstra(const V& src, vector<W>& dist, vector<int>& parentPath) {
			int n = _vertexs.size();
			int srci = getVertexIndex(src); //获取源顶点的下标
			dist.resize(n, MAX_W); //各个顶点的估计值初始化为MAX_W
			parentPath.resize(n, -1); //各个顶点的前驱顶点初始化为-1

			dist[srci] = W(); //源顶点的估计值设置为权值的缺省值
			vector<bool> S(n, false); //已经确定最短路径的顶点集合
			for (int i = 0; i < n; i++) { //将Q集合中的n个顶点全部加入到S集合
				//从集合Q中选出一个估计值最小的顶点
				W minW = MAX_W; //最小估计值
				int u = -1;     //估计值最小的顶点
				for (int j = 0; j < n; j++) {
					if (S[j] == false && dist[j] < minW) {
						minW = dist[j];
						u = j;
					}
				}
				S[u] = true; //将选出的顶点加入到S集合
				//对u连接出去的顶点进行松弛更新
				for (int v = 0; v < n; v++) {
					if (S[v] == false && _matrix[u][v] != MAX_W && dist[u] + _matrix[u][v] < dist[v]) { //松弛的顶点不能在S集合
						dist[v] = dist[u] + _matrix[u][v]; //松弛更新出更小的估计值
						parentPath[v] = u; //更新路径的前驱顶点
					}
				}
			}
		}
		//打印最短路径及路径权值
		void printShortPath(const V& src, const vector<W>& dist, const vector<int>& parentPath) {
			int n = _vertexs.size();
			int srci = getVertexIndex(src); //获取源顶点的下标
			for (int i = 0; i < n; i++) {
				vector<int> path;
				int cur = i;
				while (cur != -1) { //源顶点的前驱顶点为-1
					path.push_back(cur);
					cur = parentPath[cur];
				}
				reverse(path.begin(), path.end()); //逆置
				for (int j = 0; j < path.size(); j++) {
					cout << _vertexs[path[j]] << "->";
				}
				cout << "路径权值: " << dist[i] << "" << endl;
			}
		}
	private:
		vector<V> _vertexs;               //顶点集合
		unordered_map<V, int> _vIndexMap; //顶点映射下标
		vector<vector<W>> _matrix;        //邻接矩阵
	};
}

说明一下:

  • 为了方便观察,可以在类中增加一个 p r i n t S h o r t P a t h printShortPath printShortPath 接口,用于根据 d i s t dist dist p a r e n t P a t h parentPath parentPath 数组来打印最短路径及路径权值。
  • 对于从源顶点 s s s 到目标顶点 j j j 的最短路径来说,如果最短路径经过了顶点 i i i ,那么最短路径中从源顶点 s s s 到顶点 i i i 的这条子路径一定是源顶点 s s s 到顶点 i i i 的最短路径,因此可以通过存储前驱顶点的方式来表示从源顶点到各个顶点的最短路径。
  • Dijkstra算法每次需要选出一个顶点,并对其连接出去的顶点进行松弛更新,因此其时间复杂度是 O ( N 2 ) O(N^2) O(N2) ,空间复杂度是 O ( N ) O(N) O(N)

Dijkstra算法的原理

  • Dijkstra算法每次从集合 Q Q Q 中选出一个估计值最小的顶点 u u u ,将该顶点加入到集合 S S S 中,表示确定了从源顶点到顶点 u u u 的最短路径。
  • 因为图中所有边的权值非负(使用Dijkstra算法的前提),所以对于估计值最小的顶点 u u u 来说,其估计值不可能再被其他比它估计值更大的顶点松弛更新得更小,因此顶点 u u u 的最短路径就是当前的估计值。
  • 而对于集合 Q Q Q 中的其他顶点来说,这些顶点的估计值比顶点 u u u 的估计值大,因此顶点 u u u 可能将它们的估计值松弛更新得更小,所以顶点 u u u 在加入集合 S S S 后还需要尝试对其连接出去的顶点进行松弛更新。

单源最短路径-Bellman-Ford算法

Bellman-Ford算法(贝尔曼福特算法)

Bellman-Ford算法的基本思想如下:

  • Bellman-Ford算法本质是暴力求解,对于从源顶点 s s s 到目标顶点 j j j 的路径来说,如果存在从源顶点 s s s 到顶点 i i i 的路径,还存在一条从顶点 i i i 到顶点 j j j 的边,并且其权值之和小于当前从源顶点 s s s 到目标顶点 j j j 的路径长度,则可以对顶点 j j j 的估计值和前驱顶点进行松弛更新。
  • Bellman-Ford算法根据路径的终边来进行松弛更新,但是仅对图中的边进行一次遍历可能并不能正确更新出最短路径,最坏的情况下需要对图中的边进行 n − 1 n-1 n1 轮遍历( n n n 表示图中的顶点个数)。

Bellman-Ford算法的实现:

  • 使用一个 d i s t dist dist 数组来记录从源顶点到各个顶点的最短路径长度估计值,初始时将源顶点的估计值设置为权值的缺省值(比如int就是0),表示从源顶点到源顶点的路径长度为0,将其余顶点的估计值设置为MAX_W,表示从源顶点暂时无法到达其他顶点。
  • 使用一个 p a r e n t P a t h parentPath parentPath 数组来记录到达各个顶点路径的前驱顶点,初始时将各个顶点的前驱顶点初始化为-1,表示各个顶点暂时只能自己到达自己,没有前驱顶点。
  • 对图中的边进行 n − 1 n-1 n1 轮遍历,对于 i − > j i->j i>j 的边来说,如果存在 s − > i s->i s>i 的路径,并且 s − > i s->i s>i 的路径权值与边 i − > j i->j i>j 的权值之和小于当前 s − > j s->j s>j 的路径长度,则将顶点 j j j 的估计值进行更新,并将顶点 j j j 的前驱顶点改为顶点 i i i ,因为 i − > j i->j i>j 是图中的一条直接相连的边,在这条路径中顶点 j j j 的上一个顶点就是顶点 i i i
  • 再对图中的边进行一次遍历,尝试进行松弛更新,如果还能更新则说明图中带有负权回路,无法找到最短路径。

代码如下:

//邻接矩阵
namespace Matrix {
	template<class V, class W, W MAX_W = INT_MAX, bool Direction = false>
	class Graph {
	public:
		//获取单源最短路径(BellmanFord算法)
		bool BellmanFord(const V& src, vector<W>& dist, vector<int>& parentPath) {
			int n = _vertexs.size();
			int srci = getVertexIndex(src); //获取源顶点的下标
			dist.resize(n, MAX_W); //各个顶点的估计值初始化为MAX_W
			parentPath.resize(n, -1); //各个顶点的前驱顶点初始化为-1

			dist[srci] = W(); //源顶点的估计值设置为权值的缺省值
			for (int k = 0; k < n - 1; k++) { //最多更新n-1轮
				bool update = false; //记录本轮是否更新过
				for (int i = 0; i < n; i++) {
					for (int j = 0; j < n; j++) {
						if (_matrix[i][j] != MAX_W && dist[i] != MAX_W && dist[i] + _matrix[i][j] < dist[j]) {
							dist[j] = dist[i] + _matrix[i][j]; //松弛更新出更小的路径权值
							parentPath[j] = i; //更新路径的前驱顶点
							update = true;
						}
					}
				}
				if (update == false) { //本轮没有更新过,不必进行后续轮次的更新
					break;
				}
			}
			//更新n-1轮后如果还能更新,则说明带有负权回路
			for (int i = 0; i < n; i++) {
				for (int j = 0; j < n; j++) {
					if (_matrix[i][j] != MAX_W && dist[i] + _matrix[i][j] < dist[j]) {
						return false; //带有负权回路的图无法求出最短路径
					}
				}
			}
			return true;
		}
	private:
		vector<V> _vertexs;               //顶点集合
		unordered_map<V, int> _vIndexMap; //顶点映射下标
		vector<vector<W>> _matrix;        //邻接矩阵
	};
}

说明一下:

  • Bellman-Ford算法是暴力求解,可以解决带有负权边的单源最短路径问题。
  • 负权回路指的是在图中形成回路的各个边的权值之和为负数,路径每绕一圈回路其权值都会减少,导致无法找到最短路径,由于最多需要进行 n − 1 n-1 n1 轮松弛更新,因此可以在 n − 1 n-1 n1 轮松弛更新后再进行一轮松弛更新,如果还能进行更新则说明带有负权回路。
  • Bellman-Ford算法需要对图中的边进行 n n n 轮遍历,因此其时间复杂度是 O ( N × E ) O(N\times E) O(N×E),由于这里是用邻接矩阵实现的,遍历图中的所有边的时间复杂度是 O ( N 2 ) O(N^2) O(N2) ,所以上述代码的时间复杂度是 O ( N 3 ) O(N^3) O(N3) ,空间复杂度是 O ( N ) O(N) O(N)

为什么最多进行 n − 1 n-1 n1 轮松弛更新?

从一个顶点到另一个顶点的最短路径中不能包含回路:

  • 如果形成回路的各个边的权值之和为负数,则该回路为负权回路,找不到最短路径。
  • 如果形成回路的各个边的权值之和为非负数,则多走这个回路是“徒劳”的,可能会使得路径长度变长。

在每一轮松弛过程中,后面路径的更新可能会影响到前面已经更新过的路径,比如使得前面已经更新过的路径的长度可以变得更短,或者使得某些源顶点之前不可达的顶点变得可达,但每一轮松弛至少能确定最短路径中的一条边,如果图中有 n n n 个顶点,那么两个顶点之间的最短路径最多有 n − 1 n-1 n1 条边,因此最多需要进行 n − 1 n-1 n1 次松弛更新。

例如下图中,顶点 A A A B B B C C C D D D E E E 的下标分别是0、1、2、3、4,现在要计算以顶点 E E E 为源顶点的单源最短路径。

高阶数据结构 ——— 图_第13张图片

对于上述图来说,Bellman-Ford算法在第一轮松弛的时候只能更新出 E − > D E->D E>D 这条边,在第二轮的时候只能更新出 D − > C D->C D>C ,以此类推,最终就会进行4轮松弛更新(建议通过代码调试观察)。

说明一下:

  • 由于只有当前轮次进行过更新,才有可能会影响其他路径,因此在代码中使用 u p d a t e update update 标记每轮松弛算法是否进行过更新,如果没有进行过更新,则无需进行后面轮次的更新。
  • Bellman-Ford算法还有一个优化方案叫做SPFA(Shortest Path Faster Algorithm),其用一个队列来维护可能需要松弛更新的顶点,避免了不必要的冗余计算,大家可以自行了解。

多源最短路径-Floyd-Warshall算法

Floyd-Warshall算法(弗洛伊德算法)

Floyd-Warshall算法的基本思想如下:

  • Floyd-Warshall算法解决的是任意两点间的最短路径的算法,其考虑的是路径的中间顶点,对于从顶点 i i i 到顶点 j j j 的路径来说,如果存在从顶点 i i i 到顶点 k k k 的路径,还存在从顶点 k k k 到顶点 j j j 的路径,并且这两条路径的权值之和小于当前从顶点 i i i 到顶点 j j j 的路径长度,则可以对顶点 j j j 的估计值和前驱顶点进行松弛更新。
  • Floyd-Warshall算法本质是一个简单的动态规划,就是判断从顶点 i i i 到顶点 j j j 的这条路径是否经过顶点 k k k ,如果经过顶点 k k k 可以让这条路径的权值变得更小,则经过,否则则不经过。

Floyd-Warshall算法的实现:

  • 使用一个 v v D i s t vvDist vvDist 二维数组来记录从各个源顶点到各个顶点的最短路径长度的估计值, v v D i s t [ i ] [ j ] vvDist[i][j] vvDist[i][j] 表示从顶点 i i i 到顶点 j j j 的最短路径长度的估计值,初始时将二维数组中的值全部初始化为MAX_W,表示各个顶点之间暂时无法互通。
  • 使用一个 v v P a r e n t P a t h vvParentPath vvParentPath 二维数组来记录从各个源顶点到达各个顶点路径的前驱顶点,初始时将二维数组中的值全部初始化为-1,表示各个顶点暂时只能自己到自己,没有前驱顶点。
  • 根据邻接矩阵对 v v D i s t vvDist vvDist v v P a r e n t P a t h vvParentPath vvParentPath 进行初始化,如果从顶点 i i i 到顶点 j j j 有直接相连的边,则将 v v D i s t [ i ] [ j ] vvDist[i][j] vvDist[i][j] 初始化为这条边的权值,并将 v v P a r e n t P a t h [ i ] [ j ] vvParentPath[i][j] vvParentPath[i][j] 初始化为 i i i ,表示在 i − > j i->j i>j 这条路径中顶点 j j j 前驱顶点是 i i i ,将 v v D i s t [ i ] [ i ] vvDist[i][i] vvDist[i][i] 的值设置为权值的缺省值(比如int就是0),表示自己到自己的路径长度为0。
  • 依次取各个顶点 k k k 作为 i − > j i->j i>j 路径的中间顶点,如果同时存在 i − > k i->k i>k 的路径和 k − > j k->j k>j 的路径,并且这两条路径的权值之和小于当前 i − > j i->j i>j 路径的权值,则更新 v v D i s t [ i ] [ j ] vvDist[i][j] vvDist[i][j] 的值,并将 v v P a r e n t P a t h [ i ] [ j ] vvParentPath[i][j] vvParentPath[i][j] 的值更新为 v v P a r e n t P a t h [ k ] [ j ] vvParentPath[k][j] vvParentPath[k][j] 的值。

代码如下:

//邻接矩阵
namespace Matrix {
	template<class V, class W, W MAX_W = INT_MAX, bool Direction = false>
	class Graph {
	public:
		//获取多源最短路径(FloydWarshall算法)
		void FloydWarshall(vector<vector<W>>& vvDist, vector<vector<int>>& vvParentPath) {
			int n = _vertexs.size();
			vvDist.resize(n, vector<W>(n, MAX_W)); //任意两个顶点直接的路径权值初始化为MAX_W
			vvParentPath.resize(n, vector<int>(n, -1)); //各个顶点的前驱顶点初始化为-1

			//根据邻接矩阵初始化直接相连的顶点
			for (int i = 0; i < n; i++) {
				for (int j = 0; j < n; j++) {
					if (_matrix[i][j] != MAX_W) { //i->j有边
						vvDist[i][j] = _matrix[i][j]; //i->j的路径权值
						vvParentPath[i][j] = i; //i->j路径的前驱顶点为i
					}
					if (i == j) { //i->i
						vvDist[i][j] = W(); //i->i的路径权值设置为权值的缺省值
					}
				}
			}
			for (int k = 0; k < n; k++) { //依次取各个顶点作为i->j路径的中间顶点
				for (int i = 0; i < n; i++) {
					for (int j = 0; j < n; j++) {
						if (vvDist[i][k] != MAX_W && vvDist[k][j] != MAX_W &&
							vvDist[i][k] + vvDist[k][j] < vvDist[i][j]) { //存在i->k和k->j的路径,并且这两条路径的权值之和小于当前i->j路径的权值
							vvDist[i][j] = vvDist[i][k] + vvDist[k][j]; //松弛更新出更小的路径权值
							vvParentPath[i][j] = vvParentPath[k][j]; //更小路径的前驱顶点
						}
					}
				}
			}
		}
	private:
		vector<V> _vertexs;               //顶点集合
		unordered_map<V, int> _vIndexMap; //顶点映射下标
		vector<vector<W>> _matrix;        //邻接矩阵
	};
}

说明一下:

  • Bellman-Ford算法是根据路径的终边来进行松弛更新的,而Floyd-Warshall算法是根据路径经过的中间顶点来进行松弛更新的,因为根据Bellman-Ford算法中的 d i s t dist dist 只能得知从指定源顶点到某一顶点的路径权值,而根据Floyd-Warshall算法中的 v v D i s t vvDist vvDist 可以得知任意两个顶点之间的路径权值。
  • Floyd-Warshall算法的时间复杂度是 O ( N 3 ) O(N^3) O(N3) ,空间复杂度是 O ( N 2 ) O(N^2) O(N2) 。虽然求解多源最短路径也可以以图中不同的顶点作为源顶点,去调用Dijkstra算法或Bellman-Ford算法,但Dijkstra算法不能解决带负权的图,Bellman-Ford算法调用 N N N 次的时间复杂度又太高。

你可能感兴趣的:(数据结构,算法,c++,图论)