图论与算法(遍历、最小生成树、最短路径)

图的基本概念

图是由顶点集合及顶点间的关系组成的一种数据结构: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>是两条不同的边。在无向图中,顶点对(x, y)是无序的,顶点对(x,y)称为顶点x和顶点y相关联的一条边,这条边没有特定方向,(x, y)和(y,x)是同一条边。注意:无向边(x, y)等于有向边<x, y>和<y, x>。

完全图:在有n个顶点的无向图中,若有n * (n-1)/2条边,即任意两个顶点之间有且仅有一条边,
则称此图为无向完全图,比如上图G1;在n个顶点的有向图中,若有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的度是指与它相关联的边的条数,记作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的路径。
路径长度:对于不带权的图,一条路径的路径长度是指该路径上的边的条数;对于带权的图,一
条路径的路径长度是指该路径上各个边权值的总和。

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

连通图:在无向图中,若从顶点v1到顶点v2有路径,则称顶点v1与顶点v2是连通的。如果图中任
意一对顶点都是连通的,则称此图为连通图。
强连通图:在有向图中,若在每一对顶点vi和vj之间都存在一条从vi到vj的路径,也存在一条从vj到
vi的路径,则称此图是强连通图。
生成树:一个连通图的最小连通子图称作该图的生成树。有n个顶点的连通图的生成树有n个顶点
和n-1条边。

图的存储结构

图的存储分为邻接矩阵和邻接表(不讲)。图因为节点与节点之间的关系就是连通与否,即为0或者1,因此邻接矩阵(二维数组)即是:先用一个数组将定点保存,然后采用矩阵来表示节点与节点之间的关系。

//K:表示存储的节点名称的数据类型,W:表示图中边的权重或者距离的数据类型,
// MAX_W:表示俩点之间不相连的象征,direction:表示方向,判断该图是否有向
template<class K, class W, W MAX_W = INT_MAX, bool direction = false>
class graph
{
	typedef graph<K, W, MAX_W, direction> Self;
public:
	//构造函数
	graph() = default;
	graph(const K* arr, const size_t n)//构造函数初始化
		:_vertex(n)
		, _matrix(n, vector<W>(n))
	{
		for (size_t i = 0; i < n; i++)
		{
			//完成节点下标映射关系
			_vertex[i] = arr[i];
			_index[_vertex[i]] = i;
		}

		for (size_t i = 0; i < n; i++)
		{
			for (size_t j = 0; j < n; j++)
			{
				if (i == j) _matrix[i][j] = 0;//_matrix[i][j]:表示的是从i->j的权值是多少,一般来说,如果i==j,说明i->i=0的权值,特殊情况特殊处理
				else _matrix[i][j] = MAX_W;//将还未相连接的节点之间关系全部表示无穷大,以此表示i,j不相连
			}
		}
	}
	//插入边
	void AddEdge(const K& kval1, const K& kval2, const W& val)
	{
		if (_index.find(kval1) == _index.end() || _index.find(kval2) == _index.end()) return;//简单处理,检查构成边的俩节点是否都在图中。

		int i1 = _index[kval1], i2 = _index[kval2];//获取节点的对应下标
		_AddEdge(i1, i2, val);//通过下标访问矩阵,同时建造俩节点关系权值为val。
	}
private:
	void _AddEdge(const int i1, const int i2, const W& val)
	{
		_matrix[i1][i2] = val;//构造俩节点关系
		if (direction == false) _matrix[i2][i1] = val;//判断是否为无向图,因为无向图i1->i2的同时i2->i1
	}
private:
	vector<K> _vertex;//通过将节点存储在数组中,这样可以用数组下标映射节点。
	unordered_map<K, int>  _index;//存储节点对应的下标映射关系。方便通过节点名称从而直接得出对应的映射关系,方便矩阵的操作等
	vector<vector<W>> _matrix;//通过节点映射下标,存储节点之间的关系。
};

图的遍历

广度遍历

图的广度遍历从出发点开始:不断搜寻与出发点第N等级近的关系的节点。例如:A与B、C、D是第一等级近的节点,E、F是第二等级近的节点,G、H是第三等级近的节点,I是第四等级近的节点。

//广度遍历
void BFS(const K& kval)
{
	if (_index.find(kval) == _index.end()) return;//简单处理,查看起始点是否在图中

	int i = _index[kval];//获取节点下表
	queue<int> que;//构建一个队列,以此可以存储节点
	int n = _index.size();
	vector<bool> isvisited(n, false);//构建数组,记忆是否已经遍历对应节点
	
	que.push(i);//传入起始点
	isvisited[i] = true;//传入的同时,需要将节点记忆为已经遍历
	int count = 1;//表示的是第n等级近亲的成员共有几个->现在是第0等级,count为1表示的是当前起始点
	while (!que.empty())
	{
		for (size_t i = 0; i < count; i++)//开始每一层逐层遍历
		{
			int front = que.front();
			que.pop();
			cout << _vertex[front] << " ";
			for (size_t i = 0; i < n; i++)
			{
				if (isvisited[i]==true || _matrix[front][i] == 0 || _matrix[front][i] == MAX_W) continue;//对于已经遍历的节点或者俩节点不相连的或节点合一的都跳过

				que.push(i);//传入下一层的节点
				isvisited[i] = true;//将传入队列的节点置为已经遍历,防止将已经遍历的节点重复录入队列
			}
		}
		count = que.size();//获取下一层节点个数
		cout << endl;
	}
}

深度遍历

图的深度优先搜索从图中的一个顶点出发,沿着一条路径尽可能深地搜索,直到不能再继续为止,然后回溯并尝试探索其他路径。在深度优先搜索中,算法会递归地访问每个顶点,并标记已经访问过的顶点,以避免重复访问。

//深度遍历
void DFS(const K& kval)
{
	if (_index.find(kval) == _index.end()) return;//简单处理,查看起始点是否在图中

	int i = _index[kval];//获取节点下表
	int n = _index.size();
	vector<bool> isvisited(n, false);//构建数组,记忆是否已经遍历对应节点

	_DFS(i, isvisited,n);//进入递归函数
}

//深度遍历函数调用
void _DFS(const int i, vector<bool>& isvisited,int n)
{
	cout << _vertex[i] << " ";//每进入一次该函数,就说明当前节点就是要遍历的节点
	isvisited[i] = true;

	for (size_t j = 0; j < n; j++)
	{
		if (isvisited[j] == true || _matrix[i][j] == 0 || _matrix[i][j] == MAX_W) continue;

		_DFS(j, isvisited, n);
	}
}

图的最小生成树

连通图中的每一棵生成树,都是原图的一个极大无环子图,即:从其中删去任何一条边,生成树
就不在连通;反之,在其中引入任何一条新边,都会形成一条回路。
若连通图由n个顶点组成,则其生成树必含n个顶点和n-1条边。因此构造最小生成树的准则有三
条:
1. 只能使用图中的边来构造最小生成树
2. 只能使用恰好n-1条边来连接图中的n个顶点
3. 选用的n-1条边不能构成回路
构造最小生成树的方法:Kruskal算法和Prim算法。这两个算法都采用了逐步求解的贪心策略。
贪心算法:是指在问题求解时,总是做出当前看起来最好的选择。也就是说贪心算法做出的不是
整体最优的的选择,而是某种意义上的局部最优解。贪心算法不是对所有的问题都能得到整体最优
解。

Kruskal算法

Kruskal算法是一种全局贪心算法,他的操作是不断选取图的当前还未选取的边的权值最小的边同时确保当前所选的边不能构成环,直到选出n-1条边。

//最小生成树—Kruskal
//typedef graph<K, W, MAX_W, direction> Self;
W Kruskal(Self& mintree)
{
	//初始化新的图,将最小生成树在mintree中存储
	int n = _index.size();
	//节点以及映射关系不变,可以直接赋值
	mintree._index = _index;
	mintree._vertex = _vertex;
	//需要将矩阵初始化为最初状态,才好填入最小生成树构造的关系
	mintree._matrix = vector<vector<W>>(n, vector<W>(n));
	for (size_t i = 0; i < n; i++)
	{
		for (size_t j = 0; j < n; j++)
		{
			if (i == j) mintree._matrix[i][j] = 0;
			else mintree._matrix[i][j] = MAX_W;
		}
	}

	std::priority_queue<Edge, vector<Edge>, std::greater<Edge>> q;//用优先级队列选取还未选取的最小权值的边

	for (size_t i = 0; i < n; i++)
	{
		for (size_t j = 0; j < n&&j<i; j++)
		{
			if (_matrix[i][j] == 0 || _matrix[i][j] == MAX_W) continue;

			q.push(Edge(i, j, _matrix[i][j]));//将当前图的全部的边录入优先级队列
		}
	}
	FindUnionSet set(n);//使用并查集,来检测俩节点的连接是否会出现环
	W result=W();//记录成功构建最小生成树的权值总和
	int size = 0;//来检测是否生成了最小生成树,因为当迭代结束后依旧未能选取n-1条边获取超出n-1条边时,就形成不了最小生成树
	while (!q.empty())
	{
		Edge e = q.top();
		q.pop();
		if (set.IsSet(e._srci,e._dsti)==false)//判断俩节点的连接是否会出现环
		{
			set.Union(e._srci, e._dsti);//如果不成环,那么就是将节点装入并查集,以便下次判断

			mintree._AddEdge(e._srci, e._dsti, e._w);//将选取的边添加到mintree中
			size++;
			result += e._w;
		}
	}
	if (size == n - 1) return result;
	return W();
}

Prim算法

Prim算法的核心思想是每次选择与当前最小生成树相邻的权重最小的边,以逐步扩展生成最小生成树。算法保证了每一步选择的边都是当前生成树中与其他顶点相邻的最短边,从而最终得到最小生成树。

Prim算法的基本思想是从一个起始顶点开始,逐步扩展生成最小生成树。具体步骤如下:
选择一个起始顶点作为初始树,将该顶点加入最小生成树中。
重复以下步骤,直到最小生成树包含所有顶点:(1)在当前最小生成树中找到与树相邻的顶点中权重最小的边,将该边加入最小生成树。(2)将新加入的顶点也加入最小生成树。

//最小生成树—Prim
//typedef graph<K, W, MAX_W, direction> Self;
W  Prim(Self& mintree,const K& kval)
{
	//初始化新的图,将最小生成树在mintree中存储
	int n = _index.size();
	//节点以及映射关系不变,可以直接赋值
	mintree._index = _index;
	mintree._vertex = _vertex;
	//需要将矩阵初始化为最初状态,才好填入最小生成树构造的关系
	mintree._matrix = vector<vector<W>>(n, vector<W>(n));
	for (size_t i = 0; i < n; i++)
	{
		for (size_t j = 0; j < n; j++)
		{
			if (i == j) mintree._matrix[i][j] = 0;
			else mintree._matrix[i][j] = MAX_W;
		}
	}
	int i = _index[kval];

	vector<bool> isvisited(n, false);//构造数组,判断是否已经连接,因为对于Prim算法来说,他每一次只会将一个已经录入最小生成树的节点和一个未录入最小生成树的节点构造成的边进行添加
	std::priority_queue<Edge, vector<Edge>, std::greater<Edge>> q;//构建优先级队列,将已经遍历的节点的相邻的边存入进行挑选
	isvisited[i] = true;
	W totalW = W();

	for (size_t j = 0; j < n; j++)
	{
		if (_matrix[i][j] != 0 && _matrix[i][j] != MAX_W)
		{
			q.push(Edge(i, j, _matrix[i][j]));//将出发点相邻的边存入优先级队列
		}
	}
	size_t size = 0;
	while (!q.empty())
	{
		Edge min = q.top();
		q.pop();
		if (isvisited[min._dsti] == false)//不断从优先级队列中取出权重最小的边,将其目标顶点加入最小生成树,并更新总权重。直到最小生成树包含所有顶点或者所有边都被考虑过。
		{
			isvisited[min._dsti] = true;
			mintree._AddEdge(min._srci, min._dsti, min._w);
			size++;
			totalW += min._w;
			if (size == n - 1) break;


			for (size_t j = 0; j < n; j++)
			{
				if (_matrix[min._dsti][j] != 0 && _matrix[min._dsti][j] != MAX_W && isvisited[j] == false)
				{
					q.push(Edge(min._dsti, j, _matrix[min._dsti][j]));
				}
			}
		}
	}
	if (size == n - 1) return totalW;
	return W();
}

图的最短路径

单源最短路径--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
void Dijkstra(const K& kval, vector<W>& dist, vector<int>& pPath)
{
	//初始化
	int n = _index.size(), srci = _index[kval];
	dist.resize(n, MAX_W);
	pPath.resize(n, -1);
	pPath[srci] = srci, dist[srci] = 0;

	vector<bool> shortpath(n,false);
	for (size_t j = 0; j < n; j++)
	{
		//查找目前确认的最短的路径的点
		int u = 0;
		W min = MAX_W;
		for (size_t k = 0; k < n; k++)
		{
			if (shortpath[k] == false && dist[k]<min)
			{
				min = dist[k];
				u = k;
			}
		}
		shortpath[u] = true;

		//进行比较:dist[u]+_matrix[u][v]<dist[v];

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

结合案例分析Dijkstra算法的操作步骤

结合代码和案例,分析为什么Dijkstra算法不能计算带有负权的图的最短路径

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

Bellman-Ford算法是一种效率低,耗费时间长的暴力解决最短路径的算法,他是通过暴力枚举边的权值来达到算出最小路径。因此他可以进行负权路径的计算

//最短路径—BellmanFord
bool BellmanFord(const K& kval, vector<W>& dist, vector<int>& pPath)
{
	int srci = _index[kval],n=_index.size();
	dist.resize(n, MAX_W);
	pPath.resize(n, -1);
	pPath[srci] = srci, dist[srci] = 0;

	for (size_t k = 0; k < n; k++)
	{
		int flag = false;
		for (size_t i = 0; i < n; i++)
		{
			for (size_t j = 0; j < n; j++)
			{
				if (_matrix[i][j] != 0 && _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)
		{
			// srci -> i + i ->j
			if (_matrix[i][j] != MAX_W && dist[i] + _matrix[i][j] < dist[j])
			{
				return false;
			}
		}
	}

	return true;
}

在Bellman-Ford算法中,使用三层循环是为了遍历所有可能的边,并更新每个顶点到源顶点的最短距离。下面是对三层循环的作用解释:
外层循环 for (size_t k = 0; k < n; k++):这一层循环控制着迭代的次数,每次迭代都尝试通过当前已知的最短路径来更新其他顶点到源顶点的最短距离。在最坏情况下,需要进行 n−1 次迭代,因为最短路径不会超过 n−1 条边。
第二层循环 for (size_t i = 0; i < n; i++):这一层循环遍历所有顶点,作为当前最短路径的起点,尝试通过每个顶点来更新其他顶点的最短距离。
第三层循环 for (size_t j = 0; j < n; j++):这一层循环遍历所有顶点,作为当前最短路径的终点,检查是否存在从起点到终点的更短路径。如果存在更短路径,则更新终点的最短距离和前驱节点。
通过这三层循环的嵌套,Bellman-Ford算法能够在O(V⋅E) 的时间复杂度内找到单源最短路径。其中,V 表示顶点数,E 表示边数。三层循环的作用是确保每条边都被考虑到,并且在每次迭代中更新所有顶点的最短距离,直到不再有顶点的最短距离被更新为止。

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

Floyd-Warshall算法的特点是先将直接边的权重填入距离矩阵,并将直接边的起点作为路径矩阵的值,然后判断在i->j中是否存在节点或者节点{K},使得i->{K}+{K}->j < i->j的权值,如果有,那就更新。

//最短路径—-FloydWarshall
void FloydWarshall(vector<vector<W>>& vvdist, vector<vector<int>>& vvpath)
{
	//初始化
	int n = _index.size();
	vvdist = vector<vector<W>>(n, vector<W>(n, MAX_W));
	vvpath = vector<vector<int>>(n, vector<int>(n, -1));

	//初始填表
	for (size_t i = 0; i < n; i++)
	{
		for (size_t j = 0; j < n; j++)
		{
			if (_matrix[i][j] == MAX_W) continue;

			vvdist[i][j] = _matrix[i][j];
			vvpath[i][j] = i;
		}
	}

	//动态规划开始填表
	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 (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];
					vvpath[i][j] = vvpath[k][j];
				}
			}
		}
	}
}

因为在i->{K}->j的节点集合{K}中最多存在n-2个节点,所以最多可能需要迭代n-2次才能完成。因此第一层循环是为了可以迭代{K}集合。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值