【C++】数据结构之图的相关算法

📝 本篇文章主要是讲解图的相关算法,包括遍历、MST 以及最短路径

✈️ 本篇文章讲解的算法比较抽象,而且难度较高,希望大家学习完其他数据结构再来学习 图的相关算法

🚀 如果阅读本文时对图的基础存储结构存在疑问,可查阅前置博客:【C++】数据结构之图的基本概念-CSDN博客


目录

[1 图的遍历](#1 图的遍历)

[1.1 广度优先遍历(BFS)](#1.1 广度优先遍历(BFS))

[1.2 深度优先遍历(DFS)](#1.2 深度优先遍历(DFS))

[2 最小生成树(MST)](#2 最小生成树(MST))

[2.1 Kruskal 算法](#2.1 Kruskal 算法)

[2.2 Prim 算法](#2.2 Prim 算法)

[3 最短路径](#3 最短路径)

[3.1 单源最短路径](#3.1 单源最短路径)

[3.1.1 Dijkstra 算法](#3.1.1 Dijkstra 算法)

[3.1.2 Bellman-Ford 算法](#3.1.2 Bellman-Ford 算法)

[3.2 多源最短路径 --- Floyd-Warshall 算法](#3.2 多源最短路径 — Floyd-Warshall 算法)

总结


1 图的遍历

图的遍历与树的遍历其实很像,分为广度优先遍历和深度优先遍历,都是从一个节点开始,按照一定的规律将整个图的顶点给遍历完。但是在图的遍历中需要注意的是,一个图并不一定是连通图,所以我们在遍历的过程中还需要考虑不是连通图的情况,保证整个图的所有顶点全部被遍历到。

1.1 广度优先遍历(BFS)

我们先来回顾一下二叉树的 BFS。二叉树的广度优先遍历其实就是二叉树的层序遍历:

在二叉树的 BFS 中,会借助队列这一数据结构,首先将根节点入队列,然后在后面的每次循环中,将与队列头部元素的孩子节点全部入队列,直到队列为空为止。

图的 BFS 与二叉树的 BFS 思想是一样的,其实都是层序遍历,首先我们会先选择图中具体的一个顶点,然后首先遍历该顶点,然后再遍历与该顶点相连的所有顶点,再遍历与上一层顶点相连的顶点,如此往复,直至遍历完所有顶点。例如:

我们以 0 顶点为起始顶点,第一个先将 0 顶点入队列,然后在循环内部取出队列的头部顶点,然后遍历头部顶点,再将与头部顶点相连的所有顶点入队列,这样依次循环,就得到了上面的 BFS 图。

但是在遍历的时候我们会发现一个问题,如果队列的顶点可能还会入队列,导致重复遍历的情况,比如 0 顶点,第一个 0 顶点入队列,然后将 1,5 顶点加入队列,当遍历 1 顶点时,0 顶点也是与 1 顶点相连的顶点,会再次加入队列。所以为了防止这种重复入队列的情况发生,我们需要一个访问数组,也就是 vector<bool> visited 来记录哪些顶点已经入队列了,用 true 代表已经入队列,false 表示还没有入过队列,已经入过队列的顶点我们不需要再次加入了。

上面是连通图的情况,那么如果不是一个连通图呢?我们如何保证所有顶点都被遍历到呢?其实很简单,我们在一遍 BFS 之后,检察一下 visited 数组,如果还有顶点编号位置元素为 false,那就再来一遍 BFS,如果还有,那就再来一边就可以了。

cpp 复制代码
void _BFS(size_t srci, std::vector<bool>& visited, int n)
{
	//辅助数据结构队列
	std::queue<size_t> q;

	//1. 先让第一个节点入队列
	q.push(srci);
	//2. 入队列之后就将其设置为访问过
	visited[srci] = true;
	

	int dsize = 1;
	//3. 开始 bfs
	while (!q.empty())
	{
		//进行层序打印
		while (dsize--)
		{
			//先取出队列中的头部数据
			size_t front = q.front();
			q.pop();
			std::printf("[%d:%s] ", front, _vertexs[front].c_str());
			//让与该顶点相连的顶点入队列
			for (size_t i = 0; i < n; i++)
			{
				if (_matrix[front][i] != MAX_W && visited[i] == false)
				{
					q.push(i);
					visited[i] = true;
				}
			}
		}
		std::cout << std::endl;

		dsize = q.size();
	}
}

void BFS(const V& src)
{
	int n = _vertexs.size();
	//访问数组
	std::vector<bool> visited(n, false);
	size_t srci = GetVertexIndex(src);

	//防止图不连通
	for (size_t i = 0; i < n; i++)
	{
		if (visited[i] == false)
			_BFS(i, visited, n);
	}
}

void Test_BFS()
{
	std::string a[] = { "张三", "李四", "王五", "赵六", "马超", "周七", "田八", "刘备"
		, "孙权", "黄忠" };
	Graph<std::string, int> g1(a, sizeof(a) / sizeof(std::string));
	g1.AddEdge("张三", "李四", 100);
	g1.AddEdge("张三", "王五", 200);
	g1.AddEdge("王五", "赵六", 30);
	g1.AddEdge("王五", "周七", 30);
	g1.AddEdge("赵六", "马超", 30);

	g1.AddEdge("田八", "刘备", 100);
	g1.AddEdge("田八", "孙权", 200);
	g1.AddEdge("刘备", "黄忠", 200);
	g1.AddEdge("黄忠", "孙权", 200);

	g1.BFS("张三");
}

1.2 深度优先遍历(DFS)

在 DFS 中,我们同样借助二叉树的 DFS 思想来理解图的 DFS。首先,我们来回顾一下二叉树的 DFS(这里以前序遍历为例):

首先会去遍历每棵子树的根节点,然后去将其左子树全部遍历完,再去遍历其右子树。图的 DFS 与二叉树的 DFS 类似,我们选定图中的一个固定顶点 A 开始 DFS,先去遍历与该顶点相连的一个顶点 B,如果存在与 B 相连的顶点 C,然后再去遍历 C,如果还有与 C 相连的顶点 D,那么就再去遍历 D,没有与 D 相连的顶点了,就会返回顶点 C,再去访问与 C 相连的其他顶点,如此循环。可以发现,图的 DFS 其实就是一个递归循环的过程:

可以看到 DFS 也会面临顶点重复访问的问题,所以我们也需要一个 vector<bool>visited 数组来记录哪些顶点已经被访问过了。同样的,如果图不是一个连通图,我们也需要遍历 visited 数组,看一下有哪些顶点是 false,也就是没有访问过的,再以该顶点为起始顶点去进行一遍 DFS。

cpp 复制代码
void _DFS(size_t srci, std::vector<bool>& visited, int n)
{
	//每次递归先访问当前顶点
	visited[srci] = true;
	std::printf("[%d:%s] ",srci, _vertexs[srci].c_str());

	for (size_t i = 0; i < n; i++)
	{
		//如果当前顶点没有访问过并且 srci 与 i 之间有边
		if (visited[i] == false && _matrix[srci][i] != MAX_W)
		{
			_DFS(i, visited, n);
		}
	}
}

void DFS(const V& src)
{
	int n = _vertexs.size();
	size_t srci = GetVertexIndex(src);
	//标识数组
	std::vector<bool> visited(n, false);
	_DFS(srci, visited, n);

	//防止图不连通
	for (size_t i = 0; i < n; i++)
	{
		if (visited[i] == false)
			_DFS(i, visited, n);
	}
}

void Test_Search()
{
	std::string a[] = { "张三", "李四", "王五", "赵六", "马超", "周七", "田八", "刘备"
		, "孙权", "黄忠" };
	Graph<std::string, int> g1(a, sizeof(a) / sizeof(std::string));
	g1.AddEdge("张三", "李四", 100);
	g1.AddEdge("张三", "王五", 200);
	g1.AddEdge("王五", "赵六", 30);
	g1.AddEdge("王五", "周七", 30);
	g1.AddEdge("赵六", "马超", 30);

	g1.AddEdge("田八", "刘备", 100);
	g1.AddEdge("田八", "孙权", 200);
	g1.AddEdge("刘备", "黄忠", 200);
	g1.AddEdge("黄忠", "孙权", 200);

	g1.DFS("张三");
}

2 最小生成树(MST)

生成树是指一个连通图中的最小连通子图,即具有 n 个顶点和 n-1 条边,而且不存在回路。如果去掉了生成树中的任意一条边,当前生成树都不能够构成有个连通图,必然会出现回路。而最小生成树是指 n-1 条边权值和最小的生成树。

查找最小生成树的算法一共有两种:Kruskal算法和 Prim算法。这两种算法都是基于贪心策略来查找最小生成树的。贪心是指算法中的每一步都选取当前局部最优解,而最终结果是否是全局最优解是不确定的。但是 Kruskal 和 Prim 算法通过贪心策略得到的就是全局最优的最小生成树。

2.1 Kruskal 算法

Kruskal 算法的基本思想是选出整个图中 n-1 条权值最小的边。首先对连通图中的所有边按权值进行排序。然后每次选出权值最小的边,如果加入该边之后,所有选出的边之间没有构成回路,那么就可以将该边加入最小生成树,如果构成回路,那就丢弃这条边,继续选出下一条权值最小的边,直到选出了 n-1 条边。Kruskal 的整个过程如图所示:

在 Kruskal 中,我们只需要解决两个问题,第一个就是边如何选取,第二个就是如何判环的问题。第一个问题好解决,我们可以使用一个优先级队列,将所有边加入优先级队列,然后建小根堆即可。但是判环的问题如何解决呢?我们可以将不同的顶点看作是不同的集合,如果两个顶点之间的边被选择了,那么就将这两个顶点加入同一集合,那么判断一条边加入之后是否会构成环,就是判断这条边的两个顶点是否位于同一集合,如果位于同一集合,那么加入该边之后就会构成环,所以这里使用并查集来判断是否构成环。

cpp 复制代码
//打印函数
void Print()
{
	// 打印顶点和下标映射关系
	for (size_t i = 0; i < _vertexs.size(); ++i)
	{
		std::cout << _vertexs[i] << "-" << i << " ";
	}
	std::cout << std::endl << std::endl;
	std::cout << " ";
	for (size_t i = 0; i < _vertexs.size(); ++i)
	{
		std::cout << i << " ";
	}
	std::cout << std::endl;
	// 打印矩阵
	for (size_t i = 0; i < _matrix.size(); ++i)
	{
		std::cout << i << " ";
		for (size_t j = 0; j < _matrix[i].size(); ++j)
		{
			if (_matrix[i][j] != MAX_W)
				std::cout << _matrix[i][j] << " ";
			else
				std::cout << "#" << " ";
		}
		std::cout << std::endl;
	}
	std::cout << std::endl << std::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)
			{
				std::cout << _vertexs[i] << "-" << _vertexs[j] << ":" <<
					_matrix[i][j] << std::endl;
			}
		}
	}
}

//源点、目标点以及边的权值
struct Edge
{
	size_t _srci;
	size_t _dsti;
	W _w;

	Edge(size_t srci, size_t dsti, const W& w)
		:_srci(srci)
		,_dsti(dsti)
		,_w(w)
	{}

	bool operator>(const Edge& e) const
	{
		return _w > e._w;
	}
};

//利用引用将最小生成树的图结构返回
//成功返回最小生成树权值总和,失败返回缺省值
//为了实现最小生成树,我们需要使用一个边的结构来存储
W Kruskal(Self& minTree)
{
	int n = _vertexs.size();
	//1. 先初始化 minTree
	//最小生成树是该图的子图,所以顶点都是相同的
	minTree._vertexs = _vertexs;
	minTree._IndexMap = _IndexMap;
	(minTree._matrix).resize(n);
	for (int i = 0; i < n; i++)
	{
		(minTree._matrix[i]).resize(n, MAX_W);
	}

	//2. 创建优先级队列存储边的权值, 这里第三个模板参数要传入 greater
    //因为 less 默认是建的大根堆, 要用 greater 建出小根堆
	std::priority_queue<Edge, std::vector<Edge>, std::greater<Edge>> pq;
	//初始化优先级队列
	for (size_t i = 0; i < n; i++)
	{
		for (size_t j = 0; j < n; j++)
		{
			if (_matrix[i][j] != MAX_W && i != j)
			{
				pq.emplace(i, j, _matrix[i][j]);
			}
		}
	}

	//3. 使用并查集来检验边是否构成回路
	UnionFindSet ufs(n);
	W total = W(); //最小生成树的总权值

	//选出 n-1 条边
	int edgenum = 0;
	while (edgenum < n - 1)
	{
		Edge top = pq.top();
		pq.pop();

		size_t srci = top._srci;
		size_t dsti = top._dsti;

		//查找是否处于同一集合
		int root1 = ufs.FindRoot(srci);
		int root2 = ufs.FindRoot(dsti);
		if (root1 != root2)
		{
			//不位于同一集合才加入最小生成树
			minTree.AddEdge(_vertexs[srci], _vertexs[dsti], _matrix[srci][dsti]);

			//将 srci 与 dsti 加入同一集合
			ufs.Union(srci, dsti);

			total += top._w;

			++edgenum;
		}
	}

	if (edgenum == n - 1)
	{
		return total;
	}
	else
	{
		return W();
	}
}

void Test_MinTree()
{
	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;
	std::cout << "Kruskal:" << g.Kruskal(kminTree) << std::endl;
	kminTree.Print(); //图中的打印成员函数
}

2.2 Prim 算法

Prim 算法与 Kruskal 算法都是采用贪心策略,但是不同的是 Kruskal 不是给定具体源点的,而是根据全局的图来选择权值最小的边,而 Prim 算法是从一个固定的源点出发,选出最小生成树。Prim 算法会将所有顶点划分为 X 与 Y 两个集合,X 为已经称为最小生成树顶点的顶点集合,Y 是剩下的顶点集合。Prim 算法每次会从 X 与 Y 直连的所有边中选择最短的边加入最小生成树,并且将这条边另一个没有加入 X 集合的顶点加入 X 集合,并且从 Y 集合中剔除。具体过程如图所示:

所以 Prim 算法是局部选边的过程,而 Kruskal 是全局选边的过程,Kruskal 每次生成的最小生成树相对于 Prim 算法来说,一般是更加固定的。

Pirm 算法是每次从 X 集合中选出直连到 Y、权值最小的边,所以说Prim 算法在选边过程中是不会出现成环现象的 ,因为不会选择边的两个顶点都是 X 集合的边。但是在选边时,我们依旧是使用优先级队列来选边,即将 X 中与 Y 直连的所有边都加入优先级队列,选取堆顶的边,但是这个堆顶的边可能是来自顶点都是 X 集合的边,所以我们依然需要判断选取的边是否成环。而这里判断成环就很简单了,就是判断边的两个顶点是否都属于 X 集合,但是由于我们每次都是从 X 集合出发去选边,所以只要判断边的另一个顶点是否属于 X 集合即可。

cpp 复制代码
W Prim(Self& minTree, const V& src)
{
	int n = _vertexs.size();
	//1. 先初始化 minTree
	//最小生成树是该图的子图,所以顶点都是相同的
	minTree._vertexs = _vertexs;
	minTree._IndexMap = _IndexMap;
	(minTree._matrix).resize(n);
	for (int i = 0; i < n; i++)
	{
		(minTree._matrix[i]).resize(n, MAX_W);
	}

	size_t srci = GetVertexIndex(src);

	//创建 X 与 Y 集合
	std::vector<bool> X(n, false);
	std::vector<bool> Y(n, true);

	//将源点加入集合 X
	X[srci] = true;
	Y[srci] = false;

	//创建一个优先级队列来存储 X 与 Y 相连的边
	std::priority_queue<Edge, std::vector<Edge>, std::greater<Edge>> pq;
	//将与 srci 相连的边加入优先级队列
	for (size_t i = 0; i < n; i++)
	{
		if (srci != i && _matrix[srci][i] != MAX_W)
		{
			pq.emplace(srci, i, _matrix[srci][i]);
		}
	}

	//开始选边
	int size = 0;
	W total = W();

	//选出 n-1 条边
	while (size < n - 1)
	{
		while (!pq.empty())
		{
			Edge top = pq.top();
			pq.pop();

			//判断加入当前边之后构不构成环
			//就是判断边的起点和源点是否在同一集合
			if (!X[top._dsti])
			{
				//将当前边加入最小生成树
				minTree._AddEdge(top._srci, top._dsti, top._w);
				//将目标点加入X集合
				X[top._dsti] = true;
				Y[top._dsti] = false;

				//将与目标点相连的边加入 pq
				for (size_t i = 0; i < n; i++)
				{
					if (_matrix[top._dsti][i] != MAX_W)
					{
						pq.emplace(top._dsti, i, _matrix[top._dsti][i]);
					}
				}

				//更新权重与size
				++size;
				total += top._w;

				if (size == n - 1)
					break;
			}
		}
	}

	if (size == n - 1)
	{
		return total;
	}
	else
	{
		return W();
	}
}

void Test_MinTree()
{
	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;
	Graph<char, int> pminTree;
	std::cout << "Prim:" << g.Prim(pminTree, 'a') << std::endl;
	pminTree.Print();
}

3 最短路径

最短路径问题是指在图中,从一个顶点出发,找到一条通往另一顶点的最短路径,最短是指路径上的所有边的权值和是最小的。解决最短路径的算法分为两种,一种是单源最短路径算法,即起始顶点是固定的,会找到这个起始固定顶点通往其他所有顶点的最短路径,包括 Dijkstra 和 Bellman-Ford 算法;另一种是多源最短路径算法,也就是起始顶点不是固定的,可以找到任意两个顶点之间的最短路径,包括 Floyd-Warshall 算法。

3.1 单源最短路径

3.1.1 Dijkstra 算法

Dijkstra 算法中文翻译为迪杰斯特拉算法,是在 1959 年由荷兰著名计算机科学家 Dijkstra 在论文《A note on two problems in connexion with graphs》上正式提出,后世以及名字来正式命令该算法,取名为 Dijkstra 算法。

Dijkstra 算法采用的依旧是贪心策略。最开始会有两个数组 distpPath,dist 用来记录源点到每一个点的最短路径的权值和,pPath 用来记录源点到每个顶点最短路径中目的顶点的前一个顶点,比如一个有向图中顶点集合 V = {s, x, t, m, n, u, v},编号从左往右分别是 0,1,2,3,4,5,6,源点为 s,目标顶点为 t,最短路径为 s -> x -> m -> t,则 pPath2 = 3。另外,还会有两个集合 Q、S,Q 为已经确定了最短路径的集合,S 是还没有确定最短路径的集合。

Dijkstra 算法每次会从 S 中选出源点到该顶点代价最小的顶点 u,加入集合 Q,然后根据选出的顶点对其余 S 中顶点进行松弛更新。假设选出的顶点为 u,对 v 顶点进行松弛更新,其过程为:如果源点到 u 的最短路径 + u 到 v 的路径更短,那么就将源点到 v 的路径更新为源点 -> u -> v,即如果 distu + _matrixuv < distv,那么 distv = distu + _matrixuv,另外还要将 pPath 中 v 的前一个顶点改为 u。

Dijkstra 的整个过程如图所示:

Dijkstra 贪心策略需要成功的一个必要条件就是图中不能带负权值,正是因为图中都是正或者零权值,所以每一步选出的最短路径一定是最短路径,否则就需要中转其他点再到该顶点,那么路径一定会变长。

cpp 复制代码
void Dijkstra(const V& src, std::vector<W>& dist, std::vector<int>& pPath)
{
	int n = _matrix.size();
	//初始化 dist 和 pPath 数组
	dist.resize(n, MAX_W);
	pPath.resize(n, -1);
	
	//用一个辅助数组 visited 来看一下哪些被访问了
	std::vector<bool> visited(n, false);

	//将源点加入 dist 与 pPath 数组
	size_t srci = GetVertexIndex(src);

	dist[srci] = W();
	pPath[srci] = srci;
	visited[srci] = true;

	//更新与 srci 相连的边的权值和路径
	for (int i = 0; i < n; i++)
	{
		if (i != srci && _matrix[srci][i] != MAX_W)
		{
			dist[i] = _matrix[srci][i];
			pPath[i] = srci;
		}
	}

	//将所有点都更新一遍就结束了
	for (size_t i = 0; i < n; i++)
	{
		if (i == srci)
			continue;

		//1. 先选出 dist 里面的路径最小的
		size_t u = 0;
		W min = MAX_W;
		for (size_t j = 0; j < n; j++)
		{
			if (dist[j] < min && visited[j] == false)
			{
				u = j;
				min = dist[j];
			}
		}

		//2. 将 u 加入访问数组,并且松弛更新 pPath 和 dist
		visited[u] = true;

		for (size_t j = 0; j < n; j++)
		{
			if (visited[j] == false && _matrix[u][j] != MAX_W
					&& dist[u] + _matrix[u][j] < dist[j])
			{
				dist[j] = dist[u] + _matrix[u][j];
				pPath[j] = u;
			}
		}
	}
}

//打印最短路径
void PrintShortPath(const V& src, const std::vector<W>& dist, 
    const std::vector<int>& pPath)
{
	int n = _vertexs.size();
	size_t srci = GetVertexIndex(src);

	for (size_t i = 0; i < n; i++)
	{
		if (i == srci)
			continue;

		std::vector<int> path;
		int parent = i;
		while (parent != srci)
		{
			path.push_back(parent);
			parent = pPath[parent];
		}
		path.push_back(srci);
		reverse(path.begin(), path.end());

		for (int x : path)
		{
			if (x == i)
			{
				std::cout << _vertexs[i];
				break;
			}

			std::cout << _vertexs[x] << "->";
		}
		std::cout << " 最小权值和: " << dist[i] << std::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);

	std::vector<int> dist;
	std::vector<int> parentPath;
	g.Dijkstra('s', dist, parentPath);
	g.PrintShortPath('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);*/
}

3.1.2 Bellman-Ford 算法

Dijkstra 算法时间复杂度为 O(N^2),时间复杂度很优,但是其无法解决负权路径问题。所以后面又由 Richard Ernest Bellman(理查德・贝尔曼) 与 Lester Randolph Ford Jr.(莱斯特・福特)共同提出了 Bellman-Ford 算法,中文翻译名称为贝尔曼-福特算法。

Bellman-Ford 算法的核心思想就是把每一个顶点都作为中转点,然后去求解最短路径。在 Bellman-Ford 算法中也会用到 dist 和 pPath 数组,将每一个顶点 u 都作为源点 s 到其他顶点 m 的路径中转点,如果 distu + _matrixum < distm,那么就进行松弛更新,即 distm = distu + _matrixum,pPathm = u。

所以 Bellman-Ford 其实是一种暴力更新算法,将每一个点都作为中转点来更新一遍。所以 Bellman-Ford 算法是可以解决带负权路径的图最短路径问题。其过程如图所示:

这是一次 Bellman-Ford 算法的过程,但是显然一次是不够的,因为路径更新可能不对。比如上面这幅图,4 的最短路径应该是 0 -> 2 -> 3 -> 1 -> 4,权值为 -2,路径虽然是对的,但是权值是不对的。原因就是因为在以 1 为中转点更新其他点时,更新了 4 点,此时 4 点最短路径权值为 2,但是在以 3 点为中转点时,重新更新了 1 点,此时 4 点最短路径的前一个顶点为 1,按理说应该也将 4 点更新为 -2,但是后续却没有再以 1 点作为中转点更新 4 点,导致权值不对,所以我们需要再次更新。

那么一共需要更新几遍呢?其实是需要更新 n - 2 遍的,因为除去源点和目标点,中间有 n-2 个点,所以我们需要更新 n-2 轮,因为一个更新了之后,可能还会再影响其他顶点。但是由于目标点不同,中间的途径顶点就会不同,所以我们需要更新 n 轮才可以

还有十分重要的一点,就是 Bellman-Ford 算法以及接下来的 Floyd-Warshall 算法,都无法解决负权回路最短路径的问题。所谓的负权回路就是指一个回路权值和为负值,之所以无法解决就是因为负权回路每次都会发生更新,无法停止,更新不出最短路径:

根本原因就是每经过一次回路,源点权值都会减小,就导致其余的顶点权值也都会减小;其余顶点权值较小,那么源点权值又会减小,就会导致源点权值能够不断更新,找不出最短路径。

虽然 Bellman-Ford 算法无法找出负权回路的最短路径,但是却可以检测是否存在负权回路。理论上经过 n 次更新之后,就无法再次更新了,如果还可以更新,那就说明存在负权回路。

cpp 复制代码
//贝尔曼福特算法
bool BellmanFord(const V& src, std::vector<W>& dist, std::vector<int>& pPath)
{
	//首先初始化 dist 与 pPath 数组
	int n = _vertexs.size();
	dist.resize(n, MAX_W);
	pPath.resize(n, -1);

	int srci = GetVertexIndex(src);
	dist[srci] = W();

	bool flag = false;
	for (size_t k = 0; k < n; k++)
	{
		flag = false;
		//利用所有点都作为中转,更新一遍
		for (size_t i = 0; i < n; i++)
		{
			for (size_t j = 0; j < n; j++)
			{
				//松弛更新
				if (_matrix[i][j] != MAX_W && dist[i] + _matrix[i][j] < dist[j])
				{
					dist[j] = dist[i] + _matrix[i][j];
					pPath[j] = i;
					flag = true;
				}
			}
		}

		if (flag == false)
			break;
	}

	//判断有没有负权回路
	for (size_t i = 0; i < n; i++)
	{
		for (size_t j = 0; j < n; j++)
		{
			//松弛更新
			if (_matrix[i][j] != MAX_W && dist[i] + _matrix[i][j] < dist[j])
			{
				return false;
			}
		}
	}

	return true;
}
void Test_BellmanFord()
{
	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('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', 'y', 8);
	g.AddEdge('t', 'z', -4);
	g.AddEdge('x', 't', -2);

	std::vector<int> dist;
	std::vector<int> parentPath;
	if (g.BellmanFord('s', dist, parentPath))
	{
		g.PrintShortPath('s', dist, parentPath);
	}
	else
	{
		std::cout << "存在负权回路" << std::endl;
	}
}

可以看出在邻接矩阵存储结构下,Bellman-Ford 算法的时间复杂度为 O(N^3),比 Dijkstra 效率低一点。


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

Floyd-Warshall 算法中文翻译为弗洛伊德算法,可以解决任意两点之间的最短路径问题。弗洛伊德算法属于经典的三维动态规划问题。接下来我们就按照动态规划的解决问题的思路来解决 Floyd-Warshall 算法问题。

状态表示

因为图中的最短路径 (i, j) 要么是直接由 i -> j,要么是经过了中间的某个中转点,而直接由 i -> j 得到会在初始化进行标注,所以这里指表示中转状态即可。这里添加了一个 k 数字,最直接的想法就是这个 k 可能表示的是中转的第 k 个点,所以 dpkij 可以表示为 (i, j) 经过了中转点 k 的最短路径。表面上看可能是这样的,但是 k 还具有第二层含义。因为在更新 dpkij 之前,dpk-1ij 已经被更新过了,所以 dpkij 更新时,其实是在 (0, 1, 2, ..., k-1) 中转点基础上再次更新的,所以 k 其实表示的是 (0, 1, 2, ..., k) 的中转点集合,而dpkij 也表示中转点不超过 k 的 (i, j) 间的最短路径

状态转移方程

dpkij 表示中转点不超过 k 的 (i, j) 间的最短路径,所以一共会有两种情况,第一种就是经过中转点 k,一个是不经过;如果不经过 k,那么 dpkij = dpk-1ij,经过k,那么 dpkij = dpk-1ik + dpk-1kj。所以 dpkij = min(dpk-1ij, dpk-1ik + dpk-1kj)

初始化

这里先做一个空间上的优化,因为填写 dpkij 时,dpk-1ij 肯定已经填写了,所以我们可以将 dpkij 变为 dpij ,只要保证填表时是 k 从小到大填表即可。所以状态转移方程也变为了 dpij = min(dpij, dpik + dpkj)(就是将 k 那一维去掉了)。

初始化只需要将 dpij 填为初始的邻接矩阵即可。

填表顺序

填表没有具体顺序,因为填表 dpij 时其实用的是上一张 dpij 的信息,所以对于同一张表,没有具体的填表顺序,只要保证 k 是从小到大即可

cpp 复制代码
void FloydWorShall(std::vector<std::vector<W>>& Dist, 
		std::vector<std::vector<int>>& Path)
{
	//首先初始化两个二维数组
	int n = _vertexs.size();
	Dist.resize(n);
	Path.resize(n);

	for (size_t i = 0; i < n; i++)
	{
		Dist[i].resize(n, MAX_W);
		Path[i].resize(n, -1);
	}

	//将 vvDist 初始化为邻接矩阵
	for (size_t i = 0; i < n; i++)
	{
		for (size_t j = 0; j < n; j++)
		{
			Dist[i][j] = _matrix[i][j];
			if (_matrix[i][j] != MAX_W)
			{
				Path[i][j] = i;
			}
		}
	}

	//以 k 作为中转点,开始动态规划
	//Dist[i][j] = min(Dist[i][j], Dist[i][k] + Dist[k][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++)
			{
				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] = Path[k][j];
				}
			}
		}
	}
}

void Test_Floyd()
{
	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);

	std::vector<std::vector<int>> vvDist;
	std::vector<std::vector<int>> vvParentPath;
	g.FloydWorShall(vvDist, vvParentPath);
	// 打印任意两点之间的最短路径
	for (size_t i = 0; i < strlen(str); ++i)
	{
		g.PrintShortPath(str[i], vvDist[i], vvParentPath[i]);
		std::cout << std::endl;
	}
}

总结

本篇文章详细讲解了图的遍历:BFS 与 DFS,最小生成树:Kruskal 与 Prim 算法,最短路径:Dijkstra、Bellman-Ford、Floyd-Warshall 算法。其中 BFS 与 DFS 均需要考虑不是连通图的情况;Kruskal 与 Prim 算法均基于贪心策略,但是得到的是全局最优解;Dijkstra 与 Bellman-Ford 算法都是解决单源最短路径问题,但是 Dijkstra 算法不能解决负权路径问题,Bellman-Ford 算法可以解决负权路径问题;Floyd-Warshall 算法解决的是多源最短路径问题,采用的是动态规划算法思想,但是不管是哪种最短路径算法,都不能够解决负权回路问题。