目录
前言
上次课讲到DFS/BFS搜索的优化(剪枝优化和算法优化),今天我们扩展到图论。
讲到图论,还得先从图说起。
一、图
记得那是一个雨夜,我发着高烧,妈妈送我去医院,我病好了 我在医院见到了一幅图。

我惊叹于图的渺小,甚至瞬间就可以从1遍历到n。
我惊叹于图的广大,甚至怎样也跑不完所有路径。
我惊叹于图的优美,甚至只有点与边两种元素。
我惊叹于图的粗犷,甚至点与边组合出千奇百怪的模样。
我惊叹于图的子孙满堂,甚至可以生成无限的子图。
……
从以上文字中,你可以得出哪些信息?
好了回归正题,图(gragh)就是一种抽象概念,由若干节点及连边组成,记作G={V,E}。V即Vertex顶点,E即Edge连边。而图论就是在对图进行研究。
了解一下图的基本概念吧!
1.图的基本概念
顶点:顶点是图的基本单位,也称为节点。
边:连接两个顶点的线段,可以是无向或有向的。一条边可以记做为(u,v)。
在无向图中,若存在一条边(u,v),表示可以从u点直接走到v点,或从v点走向u点。但若在有向图中,存在一条边(u,v),表示可以从u节点直接走向v节点,但不能从v点走向u点。
无向图:图中的边没有方向,即(u,v)和(v,u)是同一条边。
有向图:图中的边有方向,即(u,v)和(v,u)不是同一条边。
重边:两个顶点之间的多条边。
自环:顶点到自身的边。
简单图:表示不含有重边和自环的图。
多重图:允许有重边和自环的图。
边权:一般表示经过这一条边的代价,若无则可视为1。
路径:从起点到终点的一个可行方案(一般路径上的边不重复)。
环/回路:起点和终点相同的路径。
度数:一个顶点的度是连接该顶点的边的数量。在有向图中,度分为入度和出度。
连通图:任意两个顶点之间都有路径相连的无向图。
强连通图:任意两个顶点之间都有路径相连的有向图。
2.图的表示方法
常见的表示方法有两种:邻接表和邻接矩阵。
1)邻接矩阵(n<=5e3)
我们建立一个二维数组v[N][N],v[i][j]==0表示点i到点j无边,否则表示有边,边权为v[i][j]。
1 — > 2 <—> 3
这样一个图的v为:
0 1 0
0 0 1
0 1 0
其中,对角线一般设为0。
邻接矩阵可通过v[x][y]=1实现。 //v[x][y]=value
2)邻接表(n<=1e6)
我们建立N个动态数组vector<pair<int,int>> son[N],son[i]中的元素{j,v}表示i到j有一条边权为v的边。若无边权,则建立vector<int> son[N],son[i]中的元素j表示i到j有一条边。
1 —> 2 —> 3
| \
v _|
4 <— 5 <—> 6
(有点抽象)这样一个无边权的有向图,其son为:
1:2,4 //表示1到2和4各有一条边
2:3,6 //表示2到3和6各有一条边
3: //表示3没有边
4: //表示4没有边
5:4,6 //表示5到4和6各有一条边
6:5 //表示6到5有一条边
邻接表可通过son[x].push_back(y)实现。 //son[x].push_back({y,v})
3)优缺点
邻接矩阵和邻接表各有优缺:
邻接矩阵支持快速修改边权和判断是否有边等操作,但空间复杂度一般较大,n太大时无法存储。
邻接表空间复杂度更小,即使n很大也能存储,但修改边权和判断是否有边等操作很慢很麻烦。
根据需要,自行选择。
二、图的遍历
通常有DFS和BFS的遍历(不同于上次所讲)。
1.DFS
深搜在图的遍历里和上节课所讲的类似,直接给代码:
void dfs(int x)
{
if(vis[x]) return ;
vis[x]=1;
//进行一些操作
for(auto y:son[x])
{
//进行一些操作
dfs(y);
}
//邻接矩阵为:
//for(int y=1;y<=n;y++)
//{
// if(v[x][y]==初值) continue;
// //进行一些操作
// dfs(y)
//}
}
2.BFS
广搜同理,直接给代码:
queue<int> q;
void bfs(int x)
{
q.push(x);
while(q.size())
{
int x=q.front();
q.pop();
//进行一些操作
vis[x]=1;
for(auto y:son[x])
{
if(!vis[y])
{
//进行一些操作
q.push(y);
}
}
}
}
3.优缺点
这二位可谓是典中典,题目肯定不会这么模板,所以还需要合理的运用。
比如,对于判断无向图的连通性,我们只需要从任意一个点开始跑一遍深搜或者广搜就行了。如果所有顶点的vis都被标记了,则证明图是联通的,否则图就是不连通的。
注意:DFS可以存储路径,但时间复杂度高,容易超时;BFS时间复杂度低,但有些操作无法实现或很困难。大家需要不同情况具体分析。
没有例题怎么办?来个简单的典中典的题练练手吧。
有一天我做了一个梦,梦见了一种很奇怪的电梯。
大楼的每一层楼都可以停电梯,而且第i层楼(1<=i<=n)上有一个数字
(0<=
<=n)。
电梯只有四个按钮:开,关,上,下。
上下的层数等于当前楼层上的那个数字。
当然,如果不能满足要求,相应的按钮就会失灵。
例如:3 3 1 2 5代表了
(
=3,
=3,……,
=5)。
从一楼开始,按“上”可以到4楼,按“下”是不起作用的,因为没有-2楼。
那么,从a楼到b楼至少要按几次按钮呢?若无法到达,则输出-1。
这很模板了,只不过建边的时候是向i+
和i-
建边。为了省时,我们考虑BFS。
提供一种思想,在BFS过程中建边,省去原先预处理边的时间和空间。残缺代码如下:
const int N=210; int n,a,b,to[N],ans[N]; queue<int> q; void bfs(int x) { q.push(x); while(q.size()) { int x=q.front(); q.pop(); if(x==b) return ; if(to[x]==0) continue; if(x+to[x]<=n&&ans[x+to[x]]==0) ans[x+to[x]]=ans[x]+1,q.push(x+to[x]); if(x-to[x]>=1&&ans[x-to[x]]==0) ans[x-to[x]]=ans[x]+1,q.push(x-to[x]); } } int main() { n=read(),a=read(),b=read(); for(int i=1;i<=n;i++) to[i]=read(); if(a==b) cout<<0; else bfs(a),cout<<(ans[b]?ans[b]:-1); return 0; }
三、并查集
1.什么是并查集
“爸爸的爸爸叫爷爷,爷爷的爸爸叫太爷……”耳熟能详的乐曲,让我不禁想到:
假如我和你有同一个父亲,父亲和另一个人又同一个父亲(即爷爷),那我们五个就是一家人了!
这就如下图所示:
爷爷┐ ┌
/ \
另一个人 父亲
┐ ┌
/ \
我 你
事实上,这就是一个并查集。
所谓并查集,就是一种用于管理元素所属集合的数据结构,实现为一个森林,其中每棵树表示一个集合,树中的节点表示对应集合中的元素。
比如,我们五个人就是由一颗树组成的森林,我们属于一个集合(一棵树)。
并查集,“并”为合并,“查”为查询。所以并查集支持两种操作:
1.合并:将两个元素所处的集合合并为一个集合。
2.查询:查询某个元素所处树的根节点,可以用于判断两个元素是否处于同一个集合。
并查集在经过优化后可以支持单个元素的删除、移动,使用动态开点线段树可以实现可持久化并查集(这里不再讲解)。But,并查集无法快速实现集合的分离。
2.如何实现并查集
如何实现合并和查询呢?
1.初始化
一开始,每个顶点独处一个集合,父亲节点为自己。
int f[N];//f[x]表示x的父亲节点 for(int i=1;i<=n;++i) f[i]=i;
2.查询
这里可以递归,不断查找自己父亲节点的父亲即可。
int getf(int x) { return f[x]==x?x:getf(f[x]); }
3.合并
考虑将它们的祖宗节点合并,即让一方的祖宗节点的父亲等于另一方。
void merge(int x,int y) { x=getf(x),y=getf(y); if(x!=y) f[x]=y; }
4.优化
古希腊掌管并查集的神,向前辈托梦给出了两种常用的优化方式:路径压缩和启发式合并。
1)路径压缩:在getf递归过程中,可以令沿途的节点的父亲直接等于最终找到的祖宗节点。
int getf(int x) { return f[x]==x?x:f[x]=getf(f[x]); }2)启发式合并:若两个元素中,x所处的集合更小,则若让f[getf(y)]=f[getf(x)]的话会增大时间复杂度。这是因为,之后再去getf(y所在集合的所有点)时都会加一时间复杂度,久而久之就慢了。反之,x所处集合小,所以影响小,会更快一些。所以,我们考虑令较小的集合向较大的集合合并。Size数组存储集合大小。
void merge(int x,int y) { x=getf(x),y=getf(y); if(x==y) return; if(Size[x]>Size[y]) swap(x,y); f[x]=y,Size[y]+=Size[x]; }
并查集的空间复杂度:O(n);
时间复杂度:
仅使用路径压缩或仅使用启发式合并时间复杂度为O(n log(n))。
两者一起使用,并查集的每个操作平均时间仅为O(),其中
为阿克曼函数的反函数,其增长极其缓慢,也就是说其单次操作的平均运行时间可以认为是一个很小的常数。
但通常情况下,仅使用路径压缩已经足够了,除了某些特殊情况下我们会采用启发式合并而不是路径压缩(比如,可持久化并查集,线段树分治+并查集)。
没有例题怎么办?那就不看例题了。
四、拓扑排序
1.什么是拓扑排序
在图论中,拓扑排序是一个有向无环图(DAG, Directed Acyclic Graph)的所有顶点的线性序列,且该序列必须满足下面两个条件:
1.每个顶点出现且只出现一次。
2.若存在一条从顶点A到顶点B的路径,那么在序列中顶点A出现在顶点B的前面。
注意:有向无环图(DAG)才有拓扑排序,非DAG图一般没有真正的拓扑排序。
2.如何拓扑排序
请自行画出一个DAG,并执行以下操作:
1.从DAG中选择一个入度为零的顶点并输出。
2.从图中删除该顶点和所有以它为起点的有向边。
重复1和2,直到当前的DAG为空或当前图中不存在入度为零的顶点为止。后一种情况说明有向图中必然存在环。
我们可以使用一个数组表示每个点的入度,当一个点的入度为0时才可进入序列,并且将该点的所有邻接点的入度减一,这样不断重复下去,便可得到一个拓扑序列。
残缺代码如下:
int n,m,ru[100010];
vector<pii> son[100010];
priority_queue<int,vector<int>,greater<int>> q;
void topsort()
{
for(int i=1;i<=n;i++) if(!ru[i]) q.push(i),ans[++cnt]=i;
while(q.size())
{
int x=q.top();
q.pop();
for(auto y:son[x])
{
//进行一些操作
if(!--ru[y.first])
{
ans[++cnt]=y;
q.push(y.first);
}
}
}
}
int main()
{
n=read(),m=read();
for(int i=1,x,y,w;i<=m;i++)
{
x=read(),y=read(),w=read();
son[x].push_back({y,w}),ru[y]++;
}
topsort();
//则ans数组存储了拓扑排序结果
return 0;
}
事实上,即使是有向有环图也可以求拓扑排序。
这里补充一种思想:把环整体视作一个点。因为环的内部是无法拓扑排序的,但环整体和其他顶点可以有拓扑排序。于是,我们得到了有向有环图的拓扑排序。
五、总结
今天终于不水了!!!
写了这么多,忽然发现图论基础才讲了一点。剩下的等以后再说吧!
今天学习了图、图的DFS/BFS遍历、并查集、拓扑排序。
但是实际上,最小生成树,最短路,可持久化并查集,线段树分治+并查集,这些都还没写呢!
期待明天的图论基础(2)吧!

被折叠的 条评论
为什么被折叠?



