特别特别感谢:
l m p p lmpp lmpp大佬牺牲自己宝贵时间,为我没有脾气的耐心讲解。
B e y o n d H e a v e n BeyondHeaven BeyondHeaven大佬,无偿帮我康博客,甚至和我这个陌生的蒟蒻分享自己的故事,带给了我知识和温暖。真的、真的非常谢谢宁! q w q qwq qwq
F l y Fly Fly_ F u n n y Funny Funny大佬,理会了一个无关紧要的人的丧气话,并且带给了她把这篇博文更完的动力。
真的很感谢我的教练 d i d did did,他的宽容友爱和超一流的教学水准不仅让我迈上了 O I OI OI的正轨,更让我感受到了朋友的温暖。
还有很多很多好朋友帮助了我(详见友链),真的非常感谢你们!
没有你们就没有这篇博客,我也会比现在蒻上 i n f inf inf倍。
提示:
本博客还并没有做到完全是自己总结的概念,但是我已经正在努力填坑了 Q A Q QAQ QAQ。
本文将以尽量朴(全)实(口)无(水)华(话)的语言和自己的一些奇奇怪怪的想法,为您呈现一个我眼中的图论世界。
在我自己掌握特别不好的题目/知识点旁会加以 ! \color{Red}\colorbox{Yellow}{!} ! 的标记。
所有题号以 B S O J BSOJ BSOJ为准。所有题目已经附上了题干和样例数据。题目链接我也正在尽量引用公开的大 O J OJ OJ上的题目,方便大家做题学习。注意!有些题的代码可能和附上的链接题目不能吻合,但题目的思路一定都是一致的,只是细节上的不同,具体代码对应的题干还是看我附在代码前的题干!
有什么错误纰漏的直接 Q Q QQ QQ+洛谷私信+讨论区留言,我真的超级需要您的反馈的 q w q qwq qwq。
希望能您能从这份清单中找到您的一些知识漏洞并把它们补起来!
更新信息
2020.3.29 2020.3.29 2020.3.29 1.0 1.0 1.0版本,开始新的篇章。知识点记录至拓扑排序模板题。
2020.3.30 2020.3.30 2020.3.30 && 2020.3.31 2020.3.31 2020.3.31 2.0 2.0 2.0版本,爆肝完所有知识点。
2020.5.6 2020.5.6 2020.5.6 2.5.0 2.5.0 2.5.0版本,在自己的反复体会和阅读学习后进行了对文本效果、知识内容方面修复部分问题。拓扑排序部分已删去死板概念,换为自己理解。
2020.5.7 2020.5.7 2020.5.7 2.5.1 2.5.1 2.5.1版本,书面上已经总结完了 K r u s k a l Kruskal Kruskal算法,博客上还没整完。
2020.5.8 2020.5.8 2020.5.8 2.5.2 2.5.2 2.5.2版本,书面上已经总结完了 P r i m Prim Prim算法,博客上还没整完。
2020.5.10 2020.5.10 2020.5.10 2.5.3 2.5.3 2.5.3版本,书面上已经总结完了 F l o y d Floyd Floyd算法,博客上还没整完。(发现一直以来 F l o y d Floyd Floyd大神的名字都写错了,真的对不起了呜呜呜呜)
2020.5.11 2020.5.11 2020.5.11 2.5.4 2.5.4 2.5.4版本,书面上已总结了一丢丢 S P F A SPFA SPFA,未完待续。
2020.5.12 2020.5.12 2020.5.12 2.5.4.5 2.5.4.5 2.5.4.5版本,乌龟速度更进 S P F A SPFA SPFA,废话一堆堆,未(我)完(想)待(烂)续(尾)。
2020.6.8 2020.6.8 2020.6.8 2.5.5 2.5.5 2.5.5版本,把之前的数字和字母做了优化, T a r j a n Tarjan Tarjan开始更新。不要问我为什么拖了这么久才又开始更。别问,问就是半期考试。不要问我半期考得怎么样。别问,问就是全班垫底(bushi)
2020.6.9 2020.6.9 2020.6.9 2.5.6 2.5.6 2.5.6版本,书面上已经总结完了 T a r j a n Tarjan Tarjan的基本概念,开始更新割点之类的概念。总结上 T a r j a n Tarjan Tarjan还没更完,发给了 d i d did did康康,我怀疑他不会回我。
2020.7.11 2020.7.11 2020.7.11 2.6.0 2.6.0 2.6.0版本,直接到 2.6 2.6 2.6版本是因为之前陆陆续续有在做一些修改和删减,大概可以算是 2.6.0 2.6.0 2.6.0版本了叭?……(小声)
2020.7.12 2020.7.12 2020.7.12 2.6.0 2.6.0 2.6.0版本,准备放弃了,大概是和 O I OI OI没有缘分了。在 l u o g u luogu luogu上发表了一篇丧丧的文章吧,应该没有人会理我的,希望负能量没有影响到大家 o r z orz orz
2020.7.14 2020.7.14 2020.7.14 2.6.0.1 2.6.0.1 2.6.0.1版本,在 l u o g u luogu luogu上丧气的文章, t a o x u a n y u taoxuanyu taoxuanyu大佬鼓励了我,挺好,所以还是决定在这篇文章不要烂尾,至少题目的链接啊,题干啥的还是要放一放,知识点看了一下,应该也是没有问题的。想了想还是给丧气话加上了渲染(((算是在 O I OI OI留下自己一个完整的脚印吧。
备忘录- T O TO TO D O DO DO L i s t List List
-
把原题目链接换成大 O J OJ OJ的题目链接 -
附上题目的题干和样例数据 -
(缓慢任务)删掉一些毫无营养的口水话,但是不能影响详细、搞笑(?)的风格
拓扑排序:
什么是拓扑排序?
拓扑排序,通过名字里的“排序”,我们自然知道了它的功能——排序。
那么,它怎样排序的?又是为谁排序的?
拓扑排序的简单规则
“排序”,一个多么简单的概念。在你的脑海里,“排序”是否具有以下特征呢?
- 一堆数据
- 一些规则
正如大多数排序一样,拓扑排序也具有以上的简单特征。
从“一堆数据”开始思考
不妨想想,作为一个图工作的排序,怎样才能构造出“一堆”数据呢?
答案是明显的,我们应该将图中的每一个节点取出,形成我们排序所需的一堆数据。
可是我们怎么选择取出的节点呢?真就毫无目标 r a n d ( ) rand() rand()函数随机整一个?
如果想到了这点,我们就可以开始考虑拓扑排序的规则。
怎样用“一些规则”排序?
所谓规则,就是一个算法进行操作的方法。
对于拓扑排序来说,它的操作方法便是如下 3 3 3步:
- 寻找一个入度为 0 0 0的点(为什么?
这就是“规则”,定死了的,不然你咋不问我为啥1+1=2人为规定的,不然怎么开始排序???) - 取出这个点,随之被删除的还有和这个点所有直接相连的边
- 在剩下的节点中重复第 1 1 1、 2 2 2步,直至所有节点被取出
我们对谁使用拓扑排序?/ 谁身上才能使用拓扑排序?
我们对 D A G DAG DAG——即:有向无环图使用拓扑排序。
为什么我们只能对 D A G DAG DAG进行拓扑排序?
通过刚刚的过程分析,我们可以轻易地发现一个问题:如果当前的节点没有全部被取出,但却已经发现不了入度为 0 0 0的节点,那么我们就不能愉快的继续拓扑排序了。
不妨思考一下为什么会出现这种 b u g bug bug。
一切的证据都指向有向有环图。(无向就不说了,咋整都是个环)
为啥?我们先来看看下图——一个有向有环图,来模拟一下拓扑排序的过程:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-jky5eqRY-1594772179294)(https://mrcontinue.github.io/post-images/1588862342176.png)]
第一步,我们找出了入度为 0 0 0的节点①,并且将它和它相关联的边取出。
于是乎,现在的图是这样的:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-nefSZH5I-1594772179295)(https://mrcontinue.github.io/post-images/1588862416195.png)]
这时我们可以发现一个有趣的事情。由于②、③。④三个点形成了一个环,所以现在图中没有入度为 0 0 0的点。
所以此时就会出现无法继续进行拓扑排序的问题。而这一切正是印证了拓扑排序只能在 D A G DAG DAG图里进行操作的事实。
关于拓扑排序的应用
拓扑排序一般很少有单独针对该知识点的题,但是在关键路径和平常的其他例题的辅助操作中却发挥着重要作用。所以掌握好拓扑排序是很重要的事情。
1462 1462 1462 拓扑排序(这题实在太水了,大 O J OJ OJ上根本找不到,只好把题干和样例附在这里,提交就随缘吧((()
D e s c r i p t i o n Description Description
对一个有向图(Directed Acyclic Graph简称DAG) G进行拓扑排序,是将G中所有顶点排成一个线性序列,使得图中任意一对顶点u和v,若 ∈E(G),则u在线性序列中出现在v之前。
若图中存在有向环,则不可能使顶点满足拓扑次序。
I n p u t Input Input
第1行:2个空格分开的整数n和m,分别表示图的顶点数和边数。
第2..m+1行:每行2个空格分开的整数i,j,i表示一条边的起点,j表示终点。
O u t p u t Output Output
拓扑序,顶点从1开始编号,如果有多个拓扑序,则顶点编号小的优先输出。
有环输出:no solution
S a m p l e Sample Sample I n p u t Input Input
【样例输入1】
4 4
1 3
1 4
2 3
2 4
【样例输入2】
4 4
1 2
2 3
3 4
4 1
S a m p l e Sample Sample O u t p u t Output Output
【样例输出1】
1 2 3 4
【样例输出2】
no solution
H i n t Hint Hint
【数据范围】
1≤n≤200
1≤m≤20000
本代码按字典序输出的部分:
int j=1; //从第一个点开始查找
while(j<=n&&bein[j])
j++; //统计入度为零的节点
//由于-1的bool值也视作真,所以可以标记为-1
if(j>n) return 0; //如果统计的节点超出了范围n,说明这个图有环
sum[++top]=j; //拓扑序列答案数组统计新答案
//本代码由邻接矩阵实现,按字典序输出,复杂度O(n^2)
#include <iostream>
#include <cstdio>
#define maxn 205
using namespace std;
int bein[maxn]; //bein[i]表示节点i的入度
int a[maxn][maxn]; //邻接矩阵存图
int sum[maxn],top; //拓扑序列答案数组
int n,m;
int TS()
{
for(int i=1;i<=n;i++)
{
int j=1; //从第一个点开始查找
while(j<=n&&bein[j])
j++; //统计入度为零的节点
//由于-1的bool值也视作真,所以可以标记为-1
if(j>n) return 0; //如果统计的节点超出了范围n,说明这个图有环
sum[++top]=j; //拓扑序列答案数组统计新答案,储存已取出的点
bein[j]=-1; //标记此点已经遍历
for(int k=1;k<=n;k++)
if(a[j][k]) //如果j和k之间有边相连
bein[k]--; //和j相关联的节点删除与j相连的边,即入度--
}
return 1; //如果遍历过程中没有返回过假值,则有解,返回真值
}
int main()
{
cin>>n>>m;
for(int i=1;i<=m;i++)
{
int x,y;
cin>>x>>y;
a[x][y]=1; //邻接矩阵储存单向边
bein[y]++; //输入是从x到y的连线,所以是y的入度增加
}
if(TS()) //如果有解
for(int i=1;i<=n;i++)
cout<<sum[i]<<" "; //则输出节点的答案数组
else
cout<<"no solution"<<endl; //无解输出"no solution"
return 0;
}
最小生成树(英文缩写 M S T MST MST我忘了全称叫啥了反正知道了也没啥用)
-
最小生成树:在一张带权的无向连通图中,各边权和为最小的一颗生成树即为最小生成树。
简单讲:找出连接所有点的最低成本路线。
举个栗子,下面这个图中的最小生成树就是用红线标出的:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-E61AGA65-1594772179300)(https://mrcontinue.github.io/post-images/1588863097566.png)] -
最小边原则:图中权值最小的边(如果唯一的话)一定在MST上。
为啥?试想,既然 M S T MST MST的路径一定会连接至每个节点,那么我们一定会尽量选择最小的边以到达目标节点。(不然合着有最短的路不走走绕路??)
K r u s k a l Kruskal Kruskal算法
K r u d k a l Krudkal Krudkal算法的基本知识
K r u s k a l Kruskal Kruskal算法是一种贪心算法。为什么是贪心?知道了过程你就知道了。
K r u s k a l Kruskal Kruskal的操作方法:
- 按图中的边按权值从小到大快排(为啥?详见楼上【最小边原则】,您品,您细品)。
- 按照权值从小到大依次选边,若当前选取的边加入后使生成树形成环,则舍弃当前边(原因后面会讲的);否则标记当前边已遍历并计数。
- 重复2的操作,直到生成树中包含 ( n − 1 ) (n-1) (n−1)条边为止;否则当遍历完所有的边后,都不能选取 ( n − 1 ) (n-1) (n−1)条边,表示最小生成树不存在。
从上面 K r u s k a l Kruskal Kruskal直接快排再无脑选边,当前不符合条件就直接舍弃,完全不考虑未来的步骤中,可以隐(强)约(烈)地感受到“鼠目寸光”的贪心内味。(有内味了. j p g jpg jpg)
接下来咱简要的抠一抠算法里的细节。
怎么判断当前边加入后, M S T MST MST会不会形成环?
我们先来欣赏一个美丽的环,请您尽量把它想象成一棵变异了的二叉树——因为它有环:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-OTXGZ2Fw-1594772179301)(https://mrcontinue.github.io/post-images/1588864164761.png)]
可以看见,这棵奇怪的树分外耀眼的 p o i n t point point是节点4同时拥有了两个父亲——即节点 2 2 2和节点 3 3 3。
如果它与任意一个父亲断掉连接,那么它还是一棵健康茁壮的二叉树:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-dMpdmpXV-1594772179302)(https://mrcontinue.github.io/post-images/1588948569959.png)]
所以我们可以推出,判断一棵树上有没有环,可以直接检查一个儿子是否拥有多个父亲。放在无向图里,我们可以变相理解为:多个节点是否同时联向同一个节点,即,有没有多个节点属于同一个集合。
欸?集合?判断?
有没有很熟悉的感觉?(别告诉我你没有,你就是有(大雾))
没错,你想起了并查集!就是那个可以储存所属集合、可以直接合并两个集合的神奇数据结构。
H o w e v e r How ever However,我们就是用并查集来储存所有的节点,也正好利用它的特性——可以非常简单的判断所属集合,这点,来判断新加入的节点是否会让 M S T MST MST产生环。
并查集的基本操作:
1. 1. 1. 找根节点:
int GetFather(int x)
{
if(prt[x]==x) return x;
return prt[x]=GetFather(prt[x]); //路径压缩
}
2. 2. 2. 合并操作(一般可以直接写在主函数里):
void Add(int x,int y)
{
int f1=GetFather(x);
int f2=GetFather(y);
if(f1!=f2) prt[f1]=f2;
}
D e s c r i p t i o n Description Description
如题,给出一个无向图,求出最小生成树,如果该图不连通,则输出 orz。
I n p u t Input Input
第一行包含两个整数N,M,表示该图共有N个结点和M条无向边。
接下来M行每行包含三个整数Xi,Yi,Zi,表示有一条长度为Zi的无向边连接结点 Xi,Yi。
O u t p u t Output Output
如果该图连通,则输出一个整数表示最小生成树的各边的长度之和。如果该图不连通则输出 orz。
S a m p l e I n p u t Sample Input SampleInput
4 5
1 2 2
1 3 2
1 4 3
2 3 4
3 4 3
S a m p l e O u t p u t Sample Output SampleOutput
7
H i n t Hint Hint
对于20%的数据,N≤5,M≤20。
对于40%的数据,N≤50,M≤2500。
对于70%的数据,N≤500,M≤2×10^5。
对于100%的数据,1≤N≤5000,1≤M≤2×10^5。
样例解释:
所以最小生成树的总边权为 2 + 2 + 3 = 7 2+2+3=7 2+2+3=7。
#include <iostream>
#include <cstdio>
#include <algorithm>
#define maxn 40005
using namespace std;
int n,m;
int prt[maxn]; //prt[i]表示节点i的根节点
int ans=0; //最小生成树的总权值和
bool flag; //标记是否有解
struct Edge
{
int x,y,v;
}a[maxn];
//x[i]代表a[i]这条边的起点
//y[i]代表a[i]这条边的终点
//v[i]代表a[i]这条边的权值
bool cmp(Edge x,Edge y) //结构体数组排序函数
{
return x.v<y.v;
}
int GetFather(int x) //并查集查找根节点,这里使用了状态压缩
{
if(prt[x]==x) return x;
prt[x]=GetFather(prt[x]); //压缩
return prt[x];
}
void K()
{
int f1,f2;
int k=0;
for(int i=1;i<=m;i++)
{
f1=GetFather(a[i].x);
f2=GetFather(a[i].y); //分别找出x,y所属的集合,即根节点
if(f1!=f2) //如果不属于同一集合,即不会形成环,则合并两集合
{
ans+=a[i].v; //累加最小生成树的权和总值值
prt[f1]=f2; //两集合合并
k++; //已遍历的节点累加
if(k==n-1) //如果遍历到了所有点,则停止遍历
//至于为什么是n-1,因为k的初始值是1啊qwq
break;
}
}
if(k<n-1) //如果当遍历完所有的边后 都不能选取(n-1)条边 则表示最小生成树不存在
{
cout<<"orz"<<endl;
flag=1; //标记没有最小生成树
return ;
}
}
int main()
{
cin>>n>>m;
for(int i=1;i<=m;i++)
cin>>a[i].x>>a[i].y>>a[i].v;
for(int i=1;i<=n;i++)
prt[i]=i; //并查集初始化
sort(a+1,a+m+1,cmp); //按边权从小到大排好,以便贪心操作
K();
if(!flag) //如果有最小生成树,则输出最小权值
cout<<ans<<endl;
return 0;
}