图论基础(1)

目录

前言

一、图

1.图的基本概念

2.图的表示方法

1)邻接矩阵(n<=5e3)

2)邻接表(n<=1e6)

3)优缺点

二、图的遍历

1.DFS

2.BFS

3.优缺点

 三、并查集

1.什么是并查集

2.如何实现并查集

四、拓扑排序

1.什么是拓扑排序

2.如何拓扑排序

五、总结


前言

上次课讲到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)上有一个数字k_i(0<=k_i<=n)。

电梯只有四个按钮:开,关,上,下。

上下的层数等于当前楼层上的那个数字。

当然,如果不能满足要求,相应的按钮就会失灵。

例如:3 3 1 2 5代表了k_i(k_1=3,k_2=3,……,k_5=5)。

从一楼开始,按“上”可以到4楼,按“下”是不起作用的,因为没有-2楼。

那么,从a楼到b楼至少要按几次按钮呢?若无法到达,则输出-1。

这很模板了,只不过建边的时候是向i+k_i和i-k_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(\alpha (n)),其中\alpha (n)为阿克曼函数的反函数,其增长极其缓慢,也就是说其单次操作的平均运行时间可以认为是一个很小的常数。

但通常情况下,仅使用路径压缩已经足够了,除了某些特殊情况下我们会采用启发式合并而不是路径压缩(比如,可持久化并查集,线段树分治+并查集)。

没有例题怎么办?那就不看例题了。

四、拓扑排序

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)吧!

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值