图算法—拓扑排序,dijkstra算法详解 图文并茂通俗易懂 数据结构图(二)

前言

在上篇文章中我们学习了图的基础知识,了解了有向图无向图,入度出度的概念,以及存储图的方式。现在,我们一起开始学习图的有关算法,一起将理论知识运用到实践之中。

上篇文章传送门:数据结构:图(graph) 通俗易懂 图文生动详解 拒绝照搬概念(一)

一.拓扑排序

1.拓扑排序介绍

在生活中,我们经常要做一连串的事情,而这些事情之间往往有顺序或依赖关系,比如做一件事情之前必须先做另一件事,这些事情便可以抽象为拓扑排序问题。我们一起来看一下力扣上这道题。

是不是具体很多了?在选修一门课之前必须必修一些课,这就强调了一种先后关系

具体的我们理解了,现在我们开始抽象了 。

如图是一个简单的无环有向图。我们设abcd分别是一件事,其中a事件优先度最高,b,c事件优先度相等,d事件优先度最低,表示为a->(b,c)->d,那么abcd和acbd都是可行的排序(记住这个小细节,待会例题有用)。把事情看作图的点,先后关系(甚至是大小,强弱等关系)看作有向边,把问题转化为图中求一个有先后关系的拓扑序列,这就是拓扑排序。而一个图能进行拓扑排序的充要条件是它是一个有向无环图

2.代码实现

拓扑排序是bfs和dfs的一个简单直接的应用。我们既可以用bfs方式也可以用dfs方式实现,当然我个人推荐用bfs,思路理解起来会简单一些。

 另外,我们也需要用到入度出度的知识(不知道的看我上篇文章)。如果一个点的入度为0,则证明它是起点,是排在最前面的。那么我们可以想到,我们可以先从一堆点里面找到入度为0的点,将它们作为起点,然后往下搜索就是

①Kahn(卡恩)算法/即用bfs实现

e[x]存点x的邻点,tp存拓扑序列, din[x]存点x的入度。
算法的核心用队列维护一个入度为0的节点的集合

1.初始化,队列q压入所有入度为0的点;
2.每次从q中取出一个点x放入数组tp;
3.然后将x的所有出边删除。若将边(x, y) 删除后,y的入度变为0,则将y压入q中;
4.不断重复2, 3过程,直到队列q为空。
5.若tp中的元素个数等于n,则有拓扑序;否则,有环。

 我们用如图所示例子一起走一遍

①for循环找出入度为0的点,2, 3入队
②先将2压入tp数组中,然后2出队。删除2-1,2-7这两条边
③3压入tp数组中,3出队,删除3-1,3-5这两条边。此时发现1,5两点的入度都变为0了,1,5入队。
④1压入tp数组中,1出队,删1-7,7入度变为0, 7入队
⑤5压入tp数组中,5出队
⑥7出队,4,6入队
⑦4出队
⑧6出队


最后得到拓扑序列tp:2315746

我们一起来看看完整代码-----》

#include <iostream>
#include <vector>
#include <queue>
using namespace std;
const int N = 10010;
//拓扑排序,卡恩kahn算法
vector <int> e[N], tp;
int din[N];//e[x]存e的邻接点,tp存拓扑序列,din[x]存点x的入度
int n, m;

bool topsort()
{
	queue<int>q;
	for (int i = 1; i <= n; i++)//第一步
	{
		if (din[i] == 0)
		{
			q.push(i);
		}
	}
	while (q.size())//当队列不空时
	{
		int x = q.front();//第二步
		q.pop();
		tp.push_back(x);
		for (auto y : e[x])//第三步
		{
			if (--din[y] == 0)
			{
				q.push(y);
			}
		}
	}
	return tp.size() == n;
}

int main()
{
	int a, b;
	scanf("%d %d", &n, &m);
	for (int i = 0; i <m ; i++)
	{
		scanf("%d %d", &a, &b);
		e[a].push_back(b);
		din[b]++;
	}
	if (!topsort())
	{
		printf("-1\n");
	}
	else
	{
		for (auto z : tp)
		{
			printf("%d ", z);
		}
	}
	
	return 0;
}

 ②例题讲解 洛谷P1960

a球队能打败b球队,便可以抽象为有一条从a指向b的有向边,那么题目中的求排名其实就转化为了求拓扑序列。

题目中还额外要求我们判断是否有其他排名方法,好好思考一下,这该咋整?

我先把代码附在下面,防止你看到答案了哈哈,思考一下再翻下去看方法吧。

#include <iostream>
#include <vector>
#include <queue>
using namespace std;
const int N = 10010;
//拓扑排序,卡恩kahn算法
vector <int> e[N], tp;
int din[N];//e[x]存e的邻接点,tp存拓扑序列,din[x]存点x的入度
int n, m;
int flag = 0;

bool topsort()
{
	queue<int>q;
	for (int i = 1; i <= n; i++)
	{
		if (din[i] == 0)
		{
			q.push(i);
		}
	}
	if (q.size() > 1)
	{
		flag = 1;
	}
	while (q.size())//当队列不空时
	{
		if (q.size() > 1)
		{
			flag = 1;
		}
		int x = q.front();
		q.pop();
		tp.push_back(x);
		for (auto y : e[x])
		{
			if (--din[y] == 0)
			{
				q.push(y);
			}
		}
	}
	return (tp.size() == n);
}

int main()
{
	int a, b;
	scanf("%d %d", &n, &m);
	for (int i = 0; i <m ; i++)
	{
		scanf("%d %d", &a, &b);
		e[a].push_back(b);
		din[b]++;
	}
	if (!topsort())
	{
		printf("-1\n");
	}
	else
	{
		for (auto z : tp)
		{
			printf("%d\n", z);
		}
	}
	
	if (flag == 1)
	{
		printf("1\n");
	}
	else
	{
		printf("0\n");
	}
	return 0;
}

其实几乎就是模板稍微改了一下就出来了。当排名方法不止一种时,其实就意味着入度为0的点不止1个,而这个入度为0的点不止一个的情况可能发生在开始,也可能发生在中间

我们定义一个flag变量,如果存在入度为0的点不止一个的情况,就把flag置为1即可。

if (q.size() > 1)
{
    flag = 1;
}

③ DFS算法

e[x]存点x的邻点,tp存拓扑序列,c[x存点x的颜色。
算法的核心是变色法,一路搜一路给点变色,如果有拓扑序,每个点的颜色都会从0→-1→1,经历三次变色

1.初始状态,所有点染色为0。
2.枚举每个点,进入 x 点,把 x 染色为 -1 。然后,枚举 x 的儿子y、如果 y 的颜色为 0,说明没碰过该点,进入 y继续往下搜。

3.如果枚举完x的儿子,没发现环,则x染色为1,并把x压入tp数组。
4.如果发现有个熊孩子的颜色为 -1.说明回到了祖先节点,发现了环,则一路返回 false,退出程序。

我不常用dfs算法,但是我们还是有必要学习一下。 

我首次用动图来演示一下整个dfs搜索过程——往下搜,回溯,又继续搜(感觉效果还不错诶!)

我直接把代码贴这儿了-----》 

#include <iostream>
#include <vector>
const int N = 10010;
using namespace std;

vector<int>e[N], tp;//e[x]存x的邻点,tp存拓扑排序,c[x]存点x的颜色
int c[N];//染色数组
int m, n;

bool dfs(int x)
{
	c[x] = -1;
	for (int y:e[x])
	{
		if (c[y] < 0)//有环
		{
			return 0;
		}
		else if(!c[y])
		{
			if (!dfs(y))
				return 0;
		}
	}
	c[x] = 1;//第三步
	tp.push_back(x);
	return 1;
}

bool topsort()
{
	memset(c, 0, sizeof(c));//第一步
	for (int x = 1; x <=n; x++)//第二步
	{
		if (c[x] == 0)
		{
			if (!dfs(x))//dfs返回0,即有环
			{
				return 0;
			}
		}
	}
	reverse(tp.begin(), tp.end());//dfs是反着存的,所以把结果颠倒一下
	return 1;
}

int main()
{
	int a, b;
	scanf("%d %d", &n, &m);
	for (int i = 0; i <m ; i++)
	{
		scanf("%d %d", &a, &b);
		e[a].push_back(b);
	}
	if (!topsort())
	{
		printf("-1\n");
	}
	else
	{
		for (auto z : tp)
		{
			printf("%d\n", z);
		}
	}

	return 0;
}

二.Dijkstra算法

1.引入

之前我们学习了bfs算法,但是它有个缺点,就是不能处理带权图,而Dijkstra算法便能解决整个问题。dijkstra是一种“单源”最短路径算法,即能解决一个起点s到其他所有点的最短距离。我们依旧从具体到抽象,为大家逐层剖析。

Dijkstra算法的模型以多米诺骨牌为例,大家可以想象一下下面的场景。在图中所有的边上都排满了多米诺骨牌,相当于把多米诺骨牌看作图的边。一条边上的多米诺骨牌数量和边的权值成正比。规定所有多米诺骨牌倒下的速度是一样的。如果在一个结点推倒骨牌,那么会导致其后所有牌都相继倒下。

(视频来自网络) 

从起点s推倒,我们可以观察到从s开始,它连接的边上的的骨牌逐渐倒下,并到达所有能到达的结点。在某个结点t,可能先后有不同路线的骨牌倒过来,先倒过来的骨牌,其经过的路径,就是s到t的最短路径;后倒过来的,对确定结点t的最短路径没有贡献。从整体来看,这就是从起点s扩散到整个图的过程。

(1)在s的所有直连邻居点中,最近的邻居点a的骨牌首先到达。a是第一个确定最短路径的节点。从a到s的路径肯定是最短的

(2)而以后的骨牌分为两组,一组从a开始,找到离a最近的邻居点,另一组继续从s开始找到其他的直连邻居点。那么下一个节点b,必然是s或者a的一个直连邻居点,b是第二个确定最短路径的点

(3)按以上步骤继续操作,在每一次迭代中都能确定最短路径的一个结点

有点模糊没关系,继续看下去。

2.算法思想

算法描述:设G=(V,E)是一个带权有向图,把图中顶点集合V分成两组:

第一组为已求出最短路径的顶点集合用S表示,初始时S中只有一个起源点,以后每求得一条最短路径,就将一个点加入到集合S中,直到全部顶点都加入到S中,算法就结束了)。

第二组为其余未确定最短路径的顶点集合(用U表示),按最短路径长度的递增次序依次把第二组的顶点加入S中。在加入的过程中,总保持从源点V到S中各顶点的最短路径长度不大于从源点V到U中任何顶点的最短路径长度。此外,每个顶点对应一个距离,S中的顶点的距离就是从v到此顶点的最短路径长度,U中的顶点的距离,是从V到此顶点且只以S中的顶点为中间顶点的当前最短路径长度。

文字有点抽象不易理解?我们画图来理解》》》

这是一个图,每条边上都有一个边权。

初始时,S集合中只有A这一个起点,其余的点都在U集合中,并把它们到A点的距离都初始化为无穷大。我们尝试更新A的邻点到A的最短距离。

如下图所示,B点到A的距离更新为6,C点到A的距离更新为3。其余点都不能直接到达A点,所以还是无穷大。

 好,我们在U中找到权值最小的点,那就是C点,我们就可以把C点从U中移入S集合中,然后我们继续更新C的邻点到A的距离。

根据图中②所述,每一次将一个点i从U集合移入S集合中后,我们需要更新U中剩余顶点的信息。而更新条件是:当前点到A点的距离>当前点到i点的距离+i点到A点的距离。

 对于如图所示情况,BDE都可以到C点,再通过点C到达A点。

B:3+2  D:3+3  E:3+4 而F点还是不能通过到达C再到达A,所以距离还是无穷大。

 更新完距离后,我们再从U集合中找出权值最小的点移入S集合中,那么就是B点。

而对于B点,我们继续更新B点的邻点。只有D点可以到B,但是5+5>6,所以还是不变。那么D成为U集合中权值最小的点。我们把他移入S中。

此时我们更新D点的邻点,2+6>7,E点不更新,但是F点可以更新为9了。

......

最终,我们得到如下图所示的结果。

 3.代码实现

虽然我们演示中用到了S,U两个集合,但是实际代码中,我们继续不必开两个数组存数据,我们只需开一个状态数组来标记一个数是否出了U集合

e [u]存节点 u 的所有出边的终点和边权。
d [u]存u到源点 s 的最小距离,vis[u]标记 u 是否出圈。
1.初始时,所有点都在圈(集)内 vis=0, d [s]=0, d[其它点]=+∞;
2.从圈内选一个距离最小的点 u,打标记移出圈
3.对 u 的所有出边执行松弛操作(即尝试更新邻点 v 的最小距离);

4.重复2,3步操作,直到圈内为空。

注释我写得很详细,可以参考一下。 

#include <iostream>
#include <vector>
using namespace std;
const int N = 10010;
#define inf 0x3f3f3f3f
struct edge
{
	int v;//邻接点
	int w;//边权
};
vector<edge>e[N];//相当于一个二维数组
int d[N], vis[N];
int n, m;//n个点,m条边

void dijkstra(int s)
{
	for (int i = 0; i <= n; i++)
	{
		d[i] = inf;
	}
	d[s] = 0;//源点到源点距离为0
	for (int i = 1; i < n; i++)//枚举n-1次
	{
		int u = 0;//点0到源点距离始终为无穷大
		for (int j = 1; j <= n; j++)//寻找权值最小的那个点
		{
			if (!vis[j] && d[j] < d[u])
			{
				u = j;
			}
		}
		vis[u] = 1;//标记出圈
		for (auto ed : e[u])//枚举邻边,更新集合
		{
			int v = ed.v;
			int w = ed.w;
			if (d[v] > d[u] + w)//这就是那个更新条件
			{
				d[v] = d[u] + w;
			}
		}
	}
}

int main()
{
	int s;
	cin >> n >> m >> s;
	for (int i = 0; i < m; i++)
	{
		int a, b, c;
		cin >> a >> b >> c;
		e[a].push_back({ b,c });
	}
	dijkstra(s);
	cout << d[2]<<endl;

	return 0;
}

4.dijkstra算法的堆优化——用优先队列维护被更新的点的集合

我们观察上方的代码,发现对于每次枚举,for循环都是从点1开始的,这就导致如果点已经出圈了,还是会枚举到它,这样就产生了很多无效枚举。

我们考虑引入小根堆/大根堆来维护未出圈的元素。如果你学过堆排序,那么你应该了解什么是

小根堆:堆顶的元素一直是最小的。

大根堆:堆顶的元素一直是最大的。

如果没学过,也不着急退出,因为我们可以直接使用C++STL库里已经写好的优先队列!芜湖,感谢前辈为我们提供的便利!

因为初始化priority_queue时,C++会默认为大根堆,但是我们想要堆顶的元素是最小权值,所以我们入队时把距离取其负值,这样就解决啦。 

 创建一个 pair 类型的大根堆q{-距离,点},把距离取负值,距离最小的元素最大,一定在堆顶。
1.初始化,{0.s}入队, d[s]=0, d[其它点]=+∞;
2.从队头弹出距离最小的点 u ,若 u 扩展过则跳过,否则打标记;

3.对 u 的所有出边执行松弛操作,把{- d [ v ], v }压入队尾;

4.重复2,3步操作,直到队列为空。

//优化版本
priority_queue <pair<int, int>>q;
void dijkstra(int s)
{
	for (int i = 0; i <= n; i++)
	{
		d[i] = inf;
	}
	d[s] = 0;
	q.push({ 0,s });
	while (q.size())//当队列不空时
	{
		auto t = q.top();
		q.pop();
		int u = t.second;
		if (vis[u])
		{
			continue;//出圈了就跳过不扩展
		}
		vis[u] = 1;//标记u已经出队
		for (auto ed : e[u])
		{
			int v = ed.v; int w = ed.w;
			if (d[v] > d[u] + w)
			{
				d[v] = d[u] + w;
				q.push({ -d[v],v });
			}
		}
	}
}

5.练习 洛谷P4779

可以试试这道模板题,把我上面的代码稍作修改就可以提交了。

我顺便把两个版本都提交试了一下,第一版本所有测试点都超时了,有点离谱。

 

啰里啰唆的话

这两篇图论的文章断断续续写了很久,希望大家能够从中获取一些知识,这也是我写博客的动力。若有疑问,欢迎在评论区留言一起探讨。后续我会继续创作更多优质文章,记得点赞收藏关注~

根据大家的需要吧,再决定后续是否要更新这两个算法的刷题文章。图论还没完,floyd算法等我把动态规划文章更新了再讲解吧。

我们下篇文章再见!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值