高阶数据结构——图

1. 图的基本概念

图是由顶点集合和边的集合组成的一种数据结构,记作 G = ( V , E ) 。V表示顶点的集合,E表示边的集合。下面是图的一些常见概念

1. 有向图和无向图

  • 在有向图中,顶点对<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>。

2. 完全图

  • 在有n个顶点的无向图中,若有n * (n-1)/2条边,即任意两个顶点之间有且仅有一条边,则称此图为无向完全图,比如图G1;
  • 在n个顶点的有向图中,若有n * (n-1)条边,即任意两个顶点之间有且仅有方向相反的边,则称此图为有向完全图,比如图G4。

3. 临界顶点 

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

4. 顶点的度

顶点v的度是指与它相关联的边的条数,记作deg(v)。

  • 在有向图中,顶点的度等于该顶点的入度与出度之和,其中顶点v的入度是以v为终点的有向边的条数,记作indev(v);顶点v的出度是以v为起始点的有向边的条数,记作outdev(v)。因此:dev(v) = indev(v) + outdev(v)。
  • 对于无向图,顶点的度等于该顶点的入度和出度,即dev(v) = indev(v) = outdev(v)。

5. 路径和路径长度

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

6. 简单路径和回路

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

7. 子图

子图:设图G = {V, E}和图G1 = {V1,E1},若V1属于V且E1属于E,则称G1是G的子图。
 

8. 连通图

无向图中,若从顶点v1到顶点v2有路径,则称顶点v1与顶点v2是连通的。如果图中任意一对顶点都是连通的,则称此图为连通图.

9. 强连通图

有向图中,若在每一对顶点vi和vj之间都存在一条从vi到vj的路径,也存在一条从vj到vi的路径,则称此图是强连通图

10. 生成树

一个连通图的最小连通子图称作该图的生成树。有n个顶点的连通图的生成树有n个顶点和n- 1条边。

2. 图的存储结构

因为图中既有节点,又有边(节点与节点之间的关系),因此,在图的存储中,只需要保存:节点和边关系即可。节点保存比较简单,只需要一段连续空间即可,那边关系该怎么保存呢?

2.1 邻接矩阵

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

 

注意:

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

3. 用邻接矩阵存储图的有点是能够快速知道两个顶点是否连通,缺陷是如果顶点比较多,边比较少时,矩阵中存储了大量的0成为系数矩阵,比较浪费空间,并且要求两个节点之间的路径不是很好求。

模拟实现:

模板类型说明:

  • V 表示的是顶点的类型。
  • W 表示边的权值。
  • WAX_W 表示的是默认不存在的值。
  • Direction 表示的是有向图还是无向图。

成员变量说明:

  • _vertex表示的是顶点的集合;
  • _matrix表示的是边的集合,在邻接矩阵中是一个二维数组,如果存放的是一个有效值,说明存在边,存放的是无效值,则不存在边;
  • _vIndexMap表示的是顶点以及下标的对应关系,可以帮助我们快速找到顶点对应的下标。

成员函数说明:

  • Graph 为构造函数,帮助我们完成初始化工作。
  • GetVertexIndex 可以获取顶点对应的下标(其实就是_vIndexMap的功能,将获取下标的过程封装成一个函数可以多一个判断的步骤,更加安全)。
  • AddEdge 添加边(包括这条边的起始顶点,终止顶点,权值)。
  • Print 打印结果,帮助我们观察现象。
namespace LinkTable
{
	template<class V, class W, W MAX_W = INT_MAX, bool Direction = false> 
	class Graph
	{
	public:
		Graph(const V* vertex, size_t size)
			: _vertex(size)
			, _vIndexMap(size)
		{
			//设置顶点集合
			for (size_t i = 0; i < size; i++)
			{
				_vertex[i] = vertex[i];
				_vIndexMap[_vertex[i]] = i;
			}

			//开辟二维数组
			_matrix.resize(size);
			for (auto& e : _matrix)
			{
				e.resize(size, MAX_W);
			}
		}

		//获取顶点对应的下标
		size_t GetVertexIndex(const V& index)
		{
			auto find = _vIndexMap.find(index);
			if (find == _vIndexMap.end())
			{
				throw invalid_argument("不存在的顶点");
				return -1;
			}
			else
			{
				return find->second;
			}
		}
		
		//添加边
		void AddEdge(const V& src, const V& dest, const W& weight)
		{
			size_t srci = GetVertexIndex(src);
			size_t desti = GetVertexIndex(dest);

			_matrix[srci][desti] = weight;
			if (Direction == false)
			{
				_matrix[desti][srci] = weight;
			}
		}

		void Print()
		{
			//打印顶点的集合
			cout << "顶点集合" << endl;
			for (size_t i = 0; i < _vertex.size(); i++)
			{
				cout << _vertex[i] << " ";
			}
			cout << endl << endl;

			//打印边的集合
			cout << "边的集合" << endl;
			for (size_t i = 0; i < _vertex.size(); i++)
			{
				for (size_t j = 0; j < _vertex.size(); j++)
				{
					if (_matrix[i][j] == MAX_W)
					{
						cout << "0 ";
					}
					else
					{
						cout << _matrix[i][j] << " ";
					}
				}
				cout << endl;
			}
		}

	private:
		vector<V> _vertex; //顶点集合
		vector<vector<W>> _matrix; //边的集合
		unordered_map<V, size_t> _vIndexMap; //顶点和下标的映射关系
	};
}

测试案例:

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();
}

执行结果:

后续我们写一些算法的时候,都会使用邻接矩阵的方式。

2.2 邻接表

邻接表:使用数组表示顶点的集合,使用链表表示边的关系。

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

1. 无向图邻接表存储

注意:无向图中同一条边在邻接表中出现了两次。如果想知道顶点vi的度,只需要知道顶点vi边链表集合中结点的数目即可。

2. 有向图邻接表存储

注意:有向图中每条边在邻接表中只出现一次,与顶点vi对应的邻接表所含结点的个数,就是该顶点的出度,也称出度表,要得到vi顶点的入度,必须检测其他所有顶点对应的边链表,看有多少边顶点的dst取值是i。

模拟实现:

模板类型说明:

  • V 表示的是顶点的类型。
  • W 表示边的权值。
  • WAX_W 表示的是默认不存在的值。
  • Direction 表示的是有向图还是无向图。

成员变量说明:

  • _vertex表示的是顶点的集合;
  • _linkTable表示的是边的集合,在邻接表中是一个哈希桶结构,每个桶中会保存一个Edge结点,每个结点包括:起始顶点,终止顶点,权值,下一个结点的指针。
  • _vIndexMap表示的是顶点以及下标的对应关系,可以帮助我们快速找到顶点对应的下标。

成员函数说明:

  • Graph 为构造函数,帮助我们完成初始化工作。
  • GetVertexIndex 可以获取顶点对应的下标(其实就是_vIndexMap的功能,将获取下标的过程封装成一个函数可以多一个判断的步骤,更加安全)。
  • AddEdge 添加边(包括这条边的起始顶点,终止顶点,权值)。
  • Print 打印结果,帮助我们观察现象。
namespace LinkTable
{
	template<class W>
	struct Edge
	{
		size_t _srci;
		size_t _desti;
		W _weight;
		Edge<W>* _next;

		Edge(const W& weight)
			: _srci(-1)
			, _desti(-1)
			, _weight(weight)
			, _next(nullptr)
		{}
	};

	template<class V, class W, W MAX_W = INT_MAX, bool Direction = false>
	class Graph
	{
		typedef Edge<W> Edge;
	public:
		Graph(const V* vertex, size_t size)
			: _vertex(size)
			, _linkTable(size, nullptr)
			, _vIndexMap(size)
		{
			for (size_t i = 0; i < size; i++)
			{
				_vertex[i] = vertex[i];
				_vIndexMap[_vertex[i]] = i;
			}
		}

		//查找顶点对应的下标
		size_t GetVertexIndex(const V& index)
		{
			auto find = _vIndexMap.find(index);
			if (find == _vIndexMap.end())
			{
				throw invalid_argument("不存在的顶点");
				return -1;
			}
			else
			{
				return find->second;
			}
		}

		//添加边到邻接表中
		void AddEdge(const V& src, const V& dest, const W& weight)
		{
			size_t srci = GetVertexIndex(src);
			size_t desti = GetVertexIndex(dest);

			//构建一个新结点
			Edge* newEdge = new Edge(weight);
			newEdge->_srci = srci;
			newEdge->_desti = desti;
			//将结点插入邻接表中
			newEdge->_next = _linkTable[srci];
			_linkTable[srci] = newEdge;

			if (Direction == false)
			{
				Edge* newREdge = new Edge(weight);
				newREdge->_srci = desti;
				newREdge->_desti = srci;
				newREdge->_next = _linkTable[desti];
				_linkTable[desti] = newREdge;
			}
		}

		void Print()
		{
			cout << "顶点的集合" << endl;
			for (size_t i = 0; i < _vertex.size(); i++)
			{
				cout << i << " : " << _vertex[i] << endl;
			}
			cout << endl;

			cout << "边的集合" << endl;
			for (size_t i = 0; i < _linkTable.size(); i++)
			{
				cout << "[" << i << "]" << " : ";
				Edge* cur = _linkTable[i];
				while (cur != nullptr)
				{
					cout << cur->_weight << "(" << cur->_desti << ")" << " -> ";
					cur = cur->_next;
				}
				cout << endl;
			}
		}

	private:
		vector<V> _vertex; //顶点的集合
		vector<Edge*> _linkTable; //边的集合
		unordered_map<V, int> _vIndexMap; //顶点和下标的映射
	};
}

邻接表在设计上和哈希桶其实是比较像的,每个桶中存放的都是链表,编号为 i 的桶表示以 i 顶点作为起点的边的集合。

测试案例:

void TestGraph()
{
	string a[] = { "张三", "李四", "王五", "赵六" };
	Graph<string, int, INT_MAX, true> g1(a, 4);
	g1.AddEdge("张三", "李四", 100);
	g1.AddEdge("张三", "王五", 200);
	g1.AddEdge("王五", "赵六", 30);

	g1.Print();
}

执行结果:

扩展(逆邻接表)

对于邻接表的有向图来说,出度比较好求,只需要遍历一整个链表就能得到,但是如果求入度就比较麻烦了,需要遍历所有的链表。

针对需要关系出度和入度的情况,我们可以再添加一个邻接表。即每个顶点都有两个链表,其中一个是以他为起点的边(邻接表),另一个是以他为终点的边(逆邻接表)。

2.3 总结

邻接矩阵:

  • 邻接矩阵可以在O(1)的时间内帮我们判断两个顶点是否存在边,以及对应的权值。
  • 邻接矩阵适合稠密图(边较多的图),在稀疏图(边较少的图)中,邻接矩阵会比较浪费空间。

邻接表:

  • 邻接表更加节省空间,因为只存储真实存在的顶点。
  • 查找边的效率比邻接矩阵低,因为要遍历完一整个链表才能找到。

3. 图的遍历

给定一个图G和其中任意一个顶点v0,从v0出发,沿着图中各边访问图中的所有顶点,且每个顶点仅被遍历一次。"遍历"即对结点进行某种操作的意思。

图的遍历分为深度优先遍历和广度优先遍历

3.1 广度优先遍历

广度优先遍历又称为BFS,遍历方式是从一个顶点开始,一层一层向外围扩散,有点像二叉树的层序遍历。

广度优先遍历模拟实现思路:

借助一个辅助队列来保存遍历过的结点。每次从队列中取出一个值,并将这个值连接的所有未遍历过的顶点加入队列当中。

借助一个标记数组来标记已经遍历过的结点,当这个结点进入辅助队列时,即可判断这个结点已经被访问过了(防止重复进入辅助队列)。

以上图为例,遍历过程:

模拟实现:

void BFS(const V& src)
{
	queue<size_t> q;   //辅助队列
	vector<bool> status(_vertex.size(), false);  //标记数组
			
	//将第一个结点加入辅助队列
	size_t srci = GetVertexIndex(src);
	q.push(srci);
	status[srci] = true;

	while (!q.empty())
	{
		//从辅助队列中取出一个结点
		size_t top = q.front();
		q.pop();
		cout << _vertex[top] << " ";

		//将与这个结点连接并且未标记的结点加入辅助队列中
		for (size_t i = 0; i < _vertex.size(); i++)
		{
			if (_matrix[top][i] != MAX_W && status[i] == false)
			{
				q.push(i);
				status[i] = true;
			}
		}
	}
}

测试案例:

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("张三");
}

运行结果:

注意:

如果一个图不是连通图(存在一个结点没有与其他所有结点连接),那么使用广度优先遍历的方式肯定是无法遍历完所有结点的,此时我们可以查看标记数组,从未标记的结点重新开始一次广度优先遍历即可,直到所有结点都被标记。

3.2 深度优先遍历

深度优先遍历又称为DFS,顾名思义就是往深度走,有点像二叉树中的前序遍历,通过不断搜索,不断回溯的过程遍历完所有结点。

我们可以通过递归的方式来实现深度优先遍历,借助一个标记数组,将遍历过的结点标记,然后访问与这个结点相邻的另一个结点,如果这个结点没有相邻的未标记过的结点,那么就回到上一个结点。

模拟实现:

void DFS(const V& src)
{
	vector<bool> status(_vertex.size(), false);
	size_t srci = GetVertexIndex(src);
	status[srci] = true;

	_DFS(srci, status);
}

void _DFS(size_t curi, vector<bool>& st)
{
	cout << _vertex[curi] << " ";

	//查找周围未标记的顶点进行递归
	for (size_t i = 0; i < _vertex.size(); i++)
	{
		if (_matrix[curi][i] != MAX_W && st[i] == false)
		{
			st[i] = true;
			_DFS(i, st);
		}
	}

	//如果周围没有未标记的顶点,则返回上一个结点
}

测试案例:

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.DFS("张三");
}

运行结果:

说明:

如果一个图不是连通图(存在一个结点没有与其他所有结点连接),那么使用深度优先遍历的方式是无法遍历完所有结点的,此时我们可以查看标记数组,从未标记的结点重新开始一次深度优先遍历即可,直到所有结点都被标记。

4. 最小生成树

生成树:

  • 连通图中的每一棵生成树,都是原图的一个极大无环子图,即:从其中删去任何一条边,生成树就不在连通;反之,在其中引入任何一条新边,都会形成一条回路
  • 若连通图由n个顶点组成,则其生成树必含n个顶点和n-1条边

最小生成树:

最小生成树是图的生成树中总权值最小的生成树,生成树是图的最小连通子图,而连通图是无向图的概念,有向图对应的是强连通图,所以最小生成树算法的处理对象都是无向图

因此构造最小生成树的准则有三条:

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

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

贪心算法:是指在问题求解时,总是做出当前看起来最好的选择。也就是说贪心算法做出的不是整体最优的的选择,而是某种意义上的局部最优解。贪心算法不是对所有的问题都能得到整体最优解。

4.1 Kruskal算法

算法思路:

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

过程演示:

实现思路:

  • 我们可以使用一个优先级队列(小堆)来保存所有的边,这样每次找到最小边的效率较高。
  • 使用一个并查集来辅助判环的过程,判断加入这条边后是否存在环,只需要判断这条边的两个顶点是否在并查集中即可。
  • 使用count来记录总共加入了多少条边,weight记录总权值。若最终count不等于n - 1,则说明此图没有最小生成树

总结:先将所有的边按权值加入优先级队列当中,每次选边从优先级队列中获取一个小的边,判断这条边的两个顶点是否在并查集中,如果存在,则重新在优先级队列中选一条边;如果不存在,将这条边加入最小生成树当中,并且将两个顶点加入并查集,同时更新count和weight,结束后,判断count是否等于n - 1,如果不等于则说明不存在最小生成树,存在则返回weight。

注意事项:

  • 边的属性较多,起始位置,终止位置,边的权值,所以我们可以使用一个结构体Edge来表示边,同时,在优先级队列当中,边需要一个比较方式,所以在Edge中我们还要添加一个用来比较的运算符重载。
  • 我们将最小生成树保存在一个图中,这个图由函数的参数提供。
  • 注意在无向图中,防止同一条边重复加入优先级队列,我们只需要加入邻接矩阵的一半即可。
  • 有关并查集的内容可以看一下我的上一篇博客,使用并查集只是为了快速判环。
  • 最小生成树并不是唯一的,原因在于存在权值相等的边,具体选择哪一条边,都是可以的,不影响最终结果。

模拟实现:

struct Edge
{
	size_t _srci;   //这条边的起始顶点下标
	size_t _desti;  //这条边的终止顶点下标
	W _weight;      //这条边的权值

	Edge(size_t srci, size_t desti, const W& weight)
		: _srci(srci)
		, _desti(desti)
		, _weight(weight)
	{}

	bool operator>(const Edge& edge) const  //两条边判断大小
	{
		return _weight > edge._weight;
	}
};

W Kruskal(Graph<V, W, MAX_W, Direction>& minTree)
{
	//1.将minTree进行初始化
	size_t n = _vertex.size();
	minTree._vertex = _vertex;  //设置最小生成树的顶点集合
	minTree._vIndexMap = _vIndexMap; //设置最小生成数顶点和下标的映射
	minTree._matrix.resize(n);  //设置边集合的大小
	for (auto& e : minTree._matrix)
	{
		e.resize(n, MAX_W);
	}

	2.将所有边加入优先级队列
	priority_queue<Edge, vector<Edge>, greater<Edge>> minHeap;
	for (size_t i = 0; i < n; i++)
	{
		for (size_t j = 0; j < n; j++)
		{
			if (i < j && _matrix[i][j] != MAX_W)
				minHeap.push(Edge(i, j, _matrix[i][j]));
		}
	}

	UnionFindSet ufs(n); //并查集
	size_t count = 0;    //最小生成树的边数
	W weight = W();      //最小生成树的总权值

	//4.开始生成最小生成树
	while (!minHeap.empty() && count < n - 1)
	{
		//从优先级队列中取出权值最小的边
		Edge topEdge = minHeap.top();
		minHeap.pop();

		//判断两个顶点是否在并查集当中
		if (ufs.InSameSet(topEdge._srci, topEdge._desti) == false)
		{
			ufs.Union(topEdge._srci, topEdge._desti); //将两个顶点加入并查集当中
			minTree.AddEdge(_vertex[topEdge._srci], _vertex[topEdge._desti], topEdge._weight);  //将这两条边加入最小生成树

			++count;
			weight += topEdge._weight;
		}
	}

	//5.判断能否构成最小生成树
	if (count != n - 1)
	{
		return W();
	}
	else
	{
		return weight;
	}
}

测试案例:

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('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();
}

运行结果:

最终的总权值是37,因为我们创建的是一个无向图,所以边的集合关于对角线是对称的。

4.2 Prim算法

算法思路:

  • 构造一个含 n 个顶点、不含任何边的图作为最小生成树,将原图中的顶点分为两个集合forest是已经连接到最小生成树的结点的集合remain中的是还没有连接到最小生成树的结点的集合。
  • 每次从连接forest集合到remain集合中的所有边中选择一条权值最小的,将这条边加入到最小生成树中,并且将这条边的顶点加入forest中即可(forest是已经连接到最小生成树的,二remain是未连接到的,所以连接的边一定是从forest到remain的,也就是将remain中的一个顶点加入forest中),这两个集合的存在可以保证所选边不会成环。
  • 按照上述规则不断选边,当选出 n − 1 条合法的边时,则说明最小生成树构造完毕,如果无法选出 n − 1  条合法的边,则说明原图不存在最小生成树。

过程演示:

实现思路:

  • 使用一个数组来表示forest集合,将起始顶点加入forest当中,并且将起始顶点连接出去的边都加入优先级队列当中,
  • 每次从优先级队列当中取出权值最小的边,将这条边的顶点加入forest集合当中,并且将这个顶点相连的边加入优先级队列当中(要保证连接的顶点不在forest集合中,否则会构成回路)。
  • 使用count和weight来记录边的个数和总权值,最后判断count是否等于n - 1,如果不等于说明无法构成最小生成树,如果等于,返回最终结果。

注意事项:

  • 边的属性较多,起始位置,终止位置,边的权值,所以我们可以使用一个结构体Edge来表示边,同时,在优先级队列当中,边需要一个比较方式,所以在Edge中我们还要添加一个用来比较的运算符重载。
  • 我们将最小生成树保存在一个图中,这个图由函数的参数提供。
  • 有关并查集的内容可以看一下我的上一篇博客,使用并查集只是为了快速判环。
  • 最小生成树并不是唯一的,原因在于存在权值相等的边,具体选择哪一条边,都是可以的,不影响最终结果。

模拟实现:

  • 单源最短路径指的是从图中某一顶点出发,找出通往其他所有顶点的最短路径,而多源最短路径指的是,找出图中任意两个顶点之间的最短路径。
W Prim(Graph<V, W, MAX_W, Direction>& minTree, const V& start)
{
	//1.初始化minTree
	size_t n = _vertex.size(); 
	minTree._vertex = _vertex;      //设置最小生成树顶点集合
	minTree._vIndexMap = _vIndexMap; //设置最小生成树顶点与下标的映射
	minTree._matrix.resize(n);
	for (auto& e : minTree._matrix)
		e.resize(n, MAX_W);

	//2.设置forest集合
	size_t starti = GetVertexIndex(start); //获得起始位置的下标
	vector<bool> forest(n, false);         //forest集合,用于记录以加入最小生成树的结点
	forest[starti] = true;

	//3.将起始结点的边加入优先级队列当中
	priority_queue<Edge, vector<Edge>, greater<Edge>> minHeap;
	for (size_t i = 0; i < n; i++)
		if (_matrix[starti][i] != MAX_W)
			minHeap.push(Edge(starti, i, _matrix[starti][i]));
			
	//4.开始生成最小生成树
	size_t count = 0;
	W weight = W();
	while (!minHeap.empty() && count < n - 1)
	{
		//取出最小权值的边
		Edge topEdge = minHeap.top();
		minHeap.pop();

		//判断终止结点是否在forest集合当中,不是则加入
		if (forest[topEdge._desti] == false)
		{
			//将终止顶点加入forest集合
			forest[topEdge._desti] = true;
			//将这个顶点连接的边加入优先级队列中
			for (size_t i = 0; i < n; i++)
			{
				if (_matrix[topEdge._desti][i] != MAX_W && forest[i] == false)
				{
					minHeap.push(Edge(topEdge._desti, i, _matrix[topEdge._desti][i]));
				}
			}

			//将这条边加入最小生成树中
			minTree.AddEdge(_vertex[topEdge._srci], _vertex[topEdge._desti], topEdge._weight);
			++count;
			weight += topEdge._weight;
			cout << "选边: " << _vertex[topEdge._srci] << "  " << _vertex[topEdge._desti] << endl;
		}
	}

	//5.判断能否构成最小生成树
	if (count == n - 1)
	{
		return weight;
	}
	else
	{
		return W();
	}
}

5. 最短路径

最短路径问题:从在带权有向图G中的某一顶点出发,找出一条通往另一顶点的最短路径,最短也就是沿路径各边的权值总和达到最小。

单源最短路径指的是从图中某一顶点出发,找出通往其他所有顶点的最短路径,而多源最短路径指的是,找出图中任意两个顶点之间的最短路径。

5.1单源最短路径--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 为空,即所有节点都已经查找过一遍并确定了最短路径。

过程演示:

s结点为源节点,而结点中的数值表示源节点到该结点的最短路径。

(a):将s结点加入S集合当中。

(b):更新s结点到t和y结点的路径长度。

(c):选择y结点(Q集合中预估值最小)进行松弛操作,更新t,x,z的路径长度,并将y结点加入S集合。

(d):同上类似。

实现思路:

  • 使用一个dist数组来表示源节点到每个结点的最短路径预计值,初始时将源节点的预计值设置为权值的缺省值,其余结点设置为MAX_W,表示源节点无法到达。
  • 使用一个parentPath数组来表示到达各顶点路径的前驱结点,这是为了后续还原最短路径,每次从dist数组中取出一个结点后即可更新他的前驱结点。
  • 使用一个S数组来表示已确认最短路径的结点的集合,false表示为确认,true表示已确认。
  • 每次从dist数组中取出一个预计值最小且没有确认最短路径的结点u,将u结点加入S集合当中,并且对u结点相连的结点进行松弛操作,即更新源节点到其的最短路径。然后还要将parentPath数组中对应下标的值改为他的前驱结点。重复以上操作,直到所有结点都加入了S集合。

模拟实现:

void Dijkstra(const V& src, vector<W>& dist, vector<int>& parentPath)
{
	//1.初始化工作
	size_t size = _vertex.size();
	size_t srci = GetVertexIndex(src);
	dist.resize(size, MAX_W);
	parentPath.resize(size, -1);
	dist[srci] = W();
	parentPath[srci] = srci;
			
	vector<bool> S(size, false);  //S是已经确认最短路径的集合

	for (size_t i = 0; i < size; i++)
	{
		//2.找到预计值最小的结点
		size_t u = 0;
		W minWeight = MAX_W;
		for (size_t i = 0; i < size; i++)
		{
			if (S[i] == false && dist[i] < minWeight)
			{
				minWeight = dist[i];
				u = i;
			}
		}

		//3.将预计值最小的结点u加入S集合中,表示u已经可以确认最短路径了
		S[u] = true;

		//4.进行松弛更新
		for (size_t v = 0; v < size; v++)
		{
			//如果源节点srci -> u + u -> v的距离小于srci -> v的距离时需要更新
			if (S[v] == false && _matrix[u][v] != MAX_W && dist[u] + _matrix[u][v] < dist[v])
			{
				dist[v] = _matrix[u][v] + dist[u];
				parentPath[v] = u;   //更新父路径
			}
		}
	}
}

案例测试:

为了方便观察,写了一个PrintShotPath函数

void PrinrtShotPath(const V& src, const vector<W>& dist, const vector<int>& parentPath)
{
	size_t size = _vertex.size();
	size_t srci = GetVertexIndex(src);

	for (size_t i = 0; i < size; i++)
	{
		if (i != srci)
		{
			cout << _vertex[srci] << " -> " << _vertex[i] << " 最短路径:" << dist[i] << "   路径:";

			//将路径的下标保存至数组当中
			vector<int> path;
			size_t cur = i;
			while (cur != srci)
			{
				path.push_back(cur);
				cur = parentPath[cur];
			}
			path.push_back(srci);

			//由于数组保存的是父结点的下标,在path提取路径时采用的是逆向路径,我们需要进行翻转
			reverse(path.begin(), path.end());

			//打印路径
			for (size_t i = 0; i < path.size(); i++)
			{
				cout << _vertex[path[i]];
				if (i != path.size() - 1)
					cout << " -> ";
			}
			cout << 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);
}

运行结果:

结果与图(f)一致。

缺点分析:

我们前面说过,Dijkstra算法的缺陷是不能算带负权路径的图,那是因为贪心策略就失效了。

原因就在于他的第三步,每次找到预计值最小的结点直接可以确认这个结点的最短路径,因为预计值最小的值是图中所有结点到源节点的最小值,如下图中的y结点,y结点到s结点最短,那么通过其他结点到y结点的距离肯定是要比直接到y结点大的(当然只是在没有负权路径的图中)。

那么有负权路径就不一定了,例如我们将t -> y的权值改为-7。

对s进行松弛操作后,将10和5加入dist数组中,找到最小的预计值,也就是y的时候,会直接确认5就是s -> y的最短路径,但其实通过t到达y才是最短的。

负权测试:

void TestGraphDijkstra()
{
	// 图中带有负权路径时,贪心策略则失效了。
	// 测试结果可以看到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算法本质上其实是暴力求解。从源结点 u 到目标结点 v 的路径来说,如果存在从源结点 u 到结点 i 的权值 + 从结点 i 到结点 v 的权值小于从源结点 u 到目标结点 v 的权值,则可以对顶点 v 的估计值和前驱顶点进行松弛更新。
  • 但是仅对图中的边进行一次遍历可能并不能正确更新出最短路径,最坏的情况下需要对图中的边进行 n − 1 轮遍历( n 表示图中的顶点个数)。

它的时间复杂度 O(N * E) (N是点数,E是边数)普遍是要高于Dijkstra算法O(N²)的。像这里如果我们使用邻接矩阵实现,那么遍历所有边的数量的时间复杂度就是O(N^3)。

算法流程:

(a):从源结点s开始更新。

(b):更新 t 和 y。

  • s -> t 的距离更新为6,路径为s -> t(0 + 6 < ∞)
  • s -> y的距离更新为7,路径为s -> y(0 + 7 < ∞)

(c):更新 z 和 x。

从 y 结点

  • s -> z的距离更新为16,s -> y -> z(7 + 9 < ∞)
  • s -> x的距离更新为4,s -> y -> x(7 - 3 < ∞)

从 t 结点:

  • s -> z的距离更新为2,路径为s -> t -> z(6 - 4 < 16)

(d):更新 t。

  • s -> t的距离更新为2,路径为s -> y -> x -> t(4 - 2 < 6)

(e):更新z。

实际上是第二轮更新到z结点了。

  • s -> z的距离更新为-2,路径为s -> y -> x -> t -> z(2 - 4 < 2)

算法原理:

  • 使用一个dist数组来表示源节点到每个结点的最短路径预计值,初始时将源节点的预计值设置为权值的缺省值,其余结点设置为MAX_W,表示源节点无法到达。
  • 使用一个parentPath数组来表示到达各顶点路径的前驱结点,这是为了后续还原最短路径,每次更改dist数组即可更新他的前驱结点。
  • 每次循环判断:源节点s -> u + u -> v的权值是否比s -> v小,如果小则更新,并且要更新前驱结点。一共需要循环n轮,因为可能存在结点 t 最短路径更改而导致其他结点的最短路径也跟着更改。

模拟实现:

bool BellmanFord(const V& src, vector<W>& dist, vector<int>& parentPath)
{
	//1.初始化工作
	size_t size = _vertex.size();
	dist.resize(size, MAX_W);
	parentPath.resize(size, -1);
	size_t srci = GetVertexIndex(src);

	dist[srci] = W();
	parentPath[srci] = srci;

	//2.求最短路径
	for (size_t i = 0; i < size; i++)
	{
		std::cout << "第" << i + 1 << "轮: " << std::endl;
		bool exchange = false;  //如果每发生更改则说明已经完成松弛更新,直接退出即可
		for (size_t u = 0; u < size; u++)
		{
			for (size_t v = 0; v < size; v++)
			{
				//如果 s -> u + u -> v 的权值小于 s -> v的则更新
				if (_matrix[u][v] != MAX_W && dist[u] + _matrix[u][v] < dist[v])
				{
					dist[v] = dist[u] + _matrix[u][v];  //更新预计值
					parentPath[v] = u;                  //更新父结点,后续可进行路径回放
					std::cout << _vertex[u] << " -> " << _vertex[v] << " Value: " << dist[v] << std::endl;

					exchange = true;
				}
			}
		}

		//没有发生更改,所有结点已经更新完毕
		if (exchange == false)
			break;
	}

	//3.判断有无负权回路(只要有一个 s -> u + u -> v 的权值小于 s -> v即可判断
	for (size_t u = 0; u < size; u++)
	{
		for (size_t v = 0; v < size; v++)
		{
			//如果 s -> u + u -> v 的权值小于 s -> v的则更新 
			if (_matrix[u][v] != MAX_W && dist[u] + _matrix[u][v] < dist[v])
			{
				return false;
			}
		}
	}
	return false;
}

测试案例:

	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;
		}
	}

可以看到和我们前面的分析一样,t -> z的路径会在第二轮更新出z的最短路径。那么我们可以配合打印函数进一步观察。

void PrinrtShotPath(const V& src, const vector<W>& dist, const vector<int>& parentPath)
{
	size_t size = _vertex.size();
	size_t srci = GetVertexIndex(src);

	for (size_t i = 0; i < size; i++)
	{
		if (i != srci)
		{
			cout << _vertex[srci] << " -> " << _vertex[i] << " 最短路径:" << dist[i] << "   路径:";

			//将路径的下标保存至数组当中
			vector<int> path;
			size_t cur = i;
			while (cur != srci)
			{
				path.push_back(cur);
				cur = parentPath[cur];
			}
			path.push_back(srci);

			//由于数组保存的是父结点的下标,在path提取路径时采用的是逆向路径,我们需要进行翻转
			reverse(path.begin(), path.end());

			//打印路径
			for (size_t i = 0; i < path.size(); i++)
			{
				cout << _vertex[path[i]];
				if (i != path.size() - 1)
					cout << " -> ";
			}
			cout << endl;
		}
	}
}

缺点分析:

Bellman-Ford算法虽然可以解决带负权路径的问题,但是不能解决带负权回路的问题

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', '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;
	}
	g.PrinrtShotPath('s', dist, parentPath);
}

可以看到s,y,t存在一条带负权值的路径。那么运行的结果会是怎么样的呢。

我们会发现最短路径越来越离谱,那是因为存在负权路径就会导致预计值一直变小,最终导致如上所示,并且我们可以看到路径打印时也是打印不出来了,因为路径存在回路问题,会无限循环最终导致程序崩溃。

算法优化:

Bellman-Ford算法虽然可以解决负权路径,但是也有一个非常严重的问题就是效率太低了。它的时间复杂度 O(N * E) (N是点数,E是边数)普遍是要高于Dijkstra算法O(N²)的。像这里如果我们使用邻接矩阵实现,那么遍历所有边的数量的时间复杂度就是O(N^3)。

经过发现,其实主要的原因在于,第二轮及以后,每次都要将所有边都遍历一遍,但是其实并不需要,只需要更新发生改变的边即可,因为只需要关注发生改变的边,而不用把所有的边都遍历一遍,所以这种方式的效率会更高,这种优化方法叫做:SPFA。

算法具体思想:

将所有的边都入队列中,每次从队列中取出一条边,因为一条边包括三个信息,顶点下标,权值,目标下标。所以我们也可以进行同前面的s -> u + u -> v < s -> v的判断。如果判断成功,则说明这个顶点的预计值就要更改了,所以我们要把这条边重新加入队列当中。当队列为空时,说明不存在变化的边了,最小路径判断结束。但是如果存在负权回路的情况,会陷入死循环。

代码实现:

bool BellmanFordSPFA(const V& src, vector<W>& dist, vector<int>& parentPath)
{
	// SPFA
	// 上面的方法中,存在多判断了很多已经确定最短路径的边
	// 使用SPFA方法可以只判断没有确定最短路径的边
	// 无法确定最短路径的边 -> 发生了更新
	size_t size = _vertex.size();
	size_t srci = GetVertexIndex(src);
	dist.resize(size, MAX_W);
	parentPath.resize(size, -1);

	dist[srci] = W();
	parentPath[srci] = srci;

	queue<Edge> spfa;
	//将所有的边入队列
	for (size_t i = 0; i < size; i++)
	{
		for (size_t j = 0; j < size; j++)
		{
			if (_matrix[i][j] != MAX_W)
			{
				spfa.push(Edge(i, j, _matrix[i][j]));
			}
		}
	}

	// 如果是负权回路问题,则会死循环
	while (!spfa.empty())
	{
		//从队列中取出一条边
		Edge top = spfa.front();
		spfa.pop();

		//如果 s -> u + u -> v < s -> v则说明最短路径发生改变
		if (dist[top._srci] + top._weight < dist[top._desti])
		{
			spfa.push(Edge(top._srci, top._desti, top._weight));
			parentPath[top._desti] = top._srci;
			dist[top._desti] = dist[top._srci] + top._weight;
			std::cout << _vertex[top._srci] << " -> " << _vertex[top._desti] << " Value: " << _matrix[top._srci][top._desti] << std::endl;
		}
	}

	return true;
}

运行结果:

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

Floyd-Warshall(弗洛伊德)算法是解决任意两点间的最短路径的一种算法

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}取得的一条最短路径。

如果p1 + p2 < p,那么就可以对 j 结点的预计值进行更新。

算法原理:

Floyd-Warshal算法的原理是动态规划

设Di,j,k为从i到j的只以(1..k)集合中的节点为中间节点的最短路径的长度。

  • 1.若最短路径经过点k,则Di,j,k= Di,k,k-1+ Dk,j,k-1;
  • 2.若最短路径不经过点k,则Di,j,k = Di,j,k-1。

因此,Di,j,k=min(Di,j,k-1,Di,k,k-1+Dk,j,k-1)

在实际算法中,为了节约空间,可以直接在原来空间上进行迭代,这样空间可降至二维

算法流程:

初始时,不允许其他顶点作为中转点,例如,v0->v1就是6,v2->v1没有直接相连的边,所有是无穷大填好A和path两个数组。

如果我们以v0作为中转点,我们可以发现,v2->v1可以使用v0作为中转点,并且路径小于无穷大,于是更新A和path。

接着我们以v1作为中转点,又可以发现一个可以更新的点。

以v2作为中转点也是一样。经过n(结点个数)轮的递推,我们最终能得到上图。我们可以得到任意两个顶点之间的最短路径,例如v0->v2的最短路径就是10,同样我们也可以通过path来还原路径,从path可得,v0->v2会通过1号中转点,而v1是-1,表示他没有通过其他中转点了,所以最短路径就是v0 -> v1 -> v2。

算法思路:

  • 使用一个二维数组vvDist来记录两个顶点之间最短路径的预计值,vvDist[i][j]表示从 i 结点到 j 结点的最短路径预计值,初始时全部设置为MAX_W。
  • 使用一个二维数组vvParentPath来记录从源节点来到达某个结点的前驱结点,初始时全设置为-1,表示不存在前驱结点(与上面的流程图略有不同,上面的path保存的是中转结点,这是保存的是父结点)。
  • 将vvDist和vvParentPath进行初始化,如果i -> j存在一条边权值为weight,那么就将vvDist[i][j]初始化为weight,并且将vvParentPath[i][j] 设置为i,表示从 j  的前驱结点时 i。
  • 设k作为i -> j的中间结点,如果存在 i -> k 和 k -> j 的边存在,并且权值之和要小于 i - > j。那我们就更新最短路径的预计值。

模拟实现:

void FloydWarShall(vector<vector<W>>& vvDist, vector<vector<int>>& vvParentPath)
{
	//1. 初始化工作
	size_t size = _vertex.size();
	vvDist.resize(size, vector<W>(size, MAX_W));
	vvParentPath.resize(size, vector<int>(size, -1));

	for (size_t i = 0; i < size; i++)
	{
		for (size_t j = 0; j < size; j++)
		{
			if (_matrix[i][j] != MAX_W)
			{
				vvDist[i][j] = _matrix[i][j];
				vvParentPath[i][j] = i;
			}

			if (i == j)
				vvDist[i][j] = W();
		}
	}

	//2.使用k结点来作为中转结点
	for (size_t k = 0; k < size; k++)
	{
		for (size_t i = 0; i < size; i++)
		{
			for (size_t j = 0; j < size; j++)
			{
				//如果存在 i -> k + k -> j的距离小于 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];  //更改预计值
					vvParentPath[i][j] = vvParentPath[k][j];     //更改父亲结点
				}
			}
		}
	}
}

非常值得注意一段代码是:vvParentPath[i][j] = vvParentPath[k][j]。这里容易写成vvParentPath[i][j] = k;我们要注意vvParentPath保存的不是中转结点,而是他的父结点

k作为中转结点有两种情况,1.k是 j 的父结点,那么这里vvParentPath[i][j] = k也没有问题。2.k不是 j 的父结点,x是 j 的父结点,那么这里vvParentPath[i][j] = x。而这两种情况都可以统一写成vvParentPath[i][j] = vvParentPath[k][j]。i -> j 的倒数第二个结点和 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;
	}
}

对照图如下:

每次递推vvDIst的值和vvParentPath的值。

运行结果:

最短路径打印:我们可以将vvParentPath这个二维数组按行看成多个一位数组,每个数组其实就是源节点到其他顶点的最短路径。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值