前言
在上篇文章中我们学习了图的基础知识,了解了有向图无向图,入度出度的概念,以及存储图的方式。现在,我们一起开始学习图的有关算法,一起将理论知识运用到实践之中。
上篇文章传送门:数据结构:图(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算法等我把动态规划文章更新了再讲解吧。
我们下篇文章再见!