图论学习笔记
目录
最小生成树
(回到目录)
emmm……好像是2020.8.18开始学的,真够久远的。
先搬上生成树定义:在一个 \(|\text{V}|\) 个点的无向连通图中,取其中 \(|\text{V}|-1\) 条边,并连接所有顶点,所得到的子图称为原图的一棵生成树。
最小生成树其实也很简单,就是原图所有的生成树里面边权和最小的那一棵,即为最小生成树。
想要求一张图的最小生成树有两种方法,一种是 \(\text{Prim}\) 算法,一种是 \(\text{Kruskal}\) 算法,由于 \(\text{Prim}\) 算法复杂度是 \(O(n^2)\) 级别的,要过一定要加堆优化,所以大部分人其实选择的都是 \(\text{Kruskal}\) 算法,这里也只讲这一种其实是因为博主不会另一种。而且出题人一般也不会卡,因此可以放心大胆的使用。
一般的 \(\text{Kruskal}\) 算法当然也不能过题,一定要加并查集来优化。当然,在我心中,\(\text{Kruskal}\) 早已经与并查集融为一体了。所以在学习 \(\text{Kruskal}\) 算法之前,一定要先学习并查集。
并查集其实也很简单,无非就是并与查两个操作,并是很简单的,只要两个点的祖先不同,就可以并到一起去,而查祖先就比较麻烦,不过背模板就好了,这里是博主常用的递归版并查集。
int find(int x)
{
if(f[x]==x) return f[x];
else return find(f[x]);
}
并查集其实也是一种强力的数据结构,可以解决许多问题,这里给出几道习题,可以研究一下。
学会了并查集之后,就可以正式开始 \(\text{Kruskal}\) 算法的学习了。让我们先看到最简单的模板题。
\(\text{Kruskal}\) 算法的核心思想其实就是将所有边按边权大小排序,每次选择最小的那一条边,并用并查集判断是否会成环,会成环,就舍弃这条边,选择次小的那条边,再继续判断,不成环,就加边权,并用并查集合并,直到选满了 \(n-1\) 条边,就可以退出循环,输出答案。
除此之外,还要有一个flag用于判断循环是否为正常退出,如果正常退出,也就是说选不满 \(n-1\) 条边,即为图不连通,无解。
模板代码如下
void kruskal()
{
int f1,f2,k,i;
k=0;
for(i=1;i<=n;i++) prt[i]=i;
for(i=1;i<=m;i++)
{
f1=read(a[i].x);
f2=read(a[i].y);
if(f1!=f2)
{
ans+=a[i].z;
prt[f1]=f2;
k++;
if(k==n-1) break;
}
}
if(k<n-1){
cout<<"orz";bj=0;return;
}
}
并查集初始化不要忘了!
最小生成树习题
最短路
建图
(回到目录)
既然是图论,不会建图怎么行!你说最小生成树? \(\text{Kruskal}\) 就是不用建图,我也没办法。
来,先介绍最简单的建图方法,邻接矩阵建图。适用算法:Floyd。
代码
int dis[1001][1001];
int main()
{
cin>>n>>m;
for(int i=1,u,v,w;i<=m;i++)
{
cin>>u>>v>>w;
dis[u][v]=dis[v][u]=w;
}
}
这也真没什么好说的,很直观,但也不常用,很容易爆空间。
第二种应当是最常用的了,即链式前向星存图。
代码实现
const int N=5e5+5;
int ver[N*2],next[N*2],w[N*2],head[N*2],tot;
void add(int x,int y,int z)
{
ver[++tot]=y;
w[tot]=z;
next[tot]=head[x];
head[x]=tot;
}
int main()
{
cin>>n>>m;
for(int i=1,u,v,w;i<=m;i++)
{
cin>>u>>v>>w;
add(u,v,w);
}
for(int i=head[u];i;i=next[i])
{
int v=ver[i],z=w[i];
/*
do something
*/
}
}
其实也就是用数组模拟链表来存图,当然这里用结构体也可以。
第三种是用vector存图,比较方便,不过如果数据过大,倍增的话有爆空间的风险,不过一般情况下是可以放心使用的。
代码实现
const int N=5e5+5;
struct edge{
int to,w;
};
vector<edge>g[N];
int main()
{
cin>>n>>m;
for(int i=1,u,v,w;i<=m;i++)
{
cin>>u>>v>>w;
edge a={v,w};
g[u].push_back(a);
}
for(int i=1;i<=n;i++)
for(int j=0;j<g[i].size();j++)
{
edge b=g[i][j];
int v=b.to,w=b.w;
/*
do something
*/
}
}
建图部分到此结束。
Floyd算法
(回到目录)
真的暴力写法,思路很简单,只要开一个 \(dis[i][j]\) 表示 \(i\) 点到 \(j\) 点之间的最短距离。然后暴力枚举每一个点作为两点中间的连接点,再更新就行。时间复杂度 \(O(n^3)\) 。
代码实现
for(k=1;k<=n;k++)
for(i=1;i<=n;i++)
for(j=1;j<=n;j++)
{
if(i!=j&&j!=k&&i!=k)
if(dis[i][k]+dis[k][j]<dis[i][j])
dis[i][j]=dis[i][k]+dis[k][j];
}
习题
SPFA算法
(回到目录)
\(\text{SPFA}\)算法在NOI2018之前,都一直是非常受欢迎的算法,因为它是队列优化的Bellman-Ford算法,时间复杂度为 \(O(km)\) ,其中的 \(k\) 是常数,所以对于随机的数据, \(\text{SPFA}\) 算法一般都能跑出十分优秀的效果。
代码实现
queue<int>q;
void spfa(int s)
{
memset(dis,0x3f,sizeof(dis));
memset(ex,0,sizeof(ex));
dis[s]=0;ex[s]=1;
q.push(s);
while(!q.empty())
{
int u=q.front();q.pop();
for(int i=head[u];i;i=next[i])
{
int v=ver[i];
if(dis[u]+w[i]<dis[v])
{
dis[v]=dis[u]+w[i];
if(!ex[v])
{
q.push(v);
ex[v]=1;
}
}
}
}
}
一般情况下用 \(\text{SPFA}\) 算法可以过掉普通题目,但是考场上不要随意使用,因为时间复杂度里面的 \(k\) 常数是不稳定的,出题人一般都会构造数据将 \(\text{SPFA}\) 的复杂度卡成 \(O(nm)\) 。
但是有些情况是非用 \(\text{SPFA}\) 不可的,那就是存在负权环的情况,只有 \(\text{SPFA}\) 算法(或者Bellman-Ford算法)可以判负环。
负环
(回到目录)
详情可以见这道模板题P3385 【模板】负环
要判负环其实也非常简单,只需要新开一个cnt数组,判断有没有一个点被连续经过了n次(n为节点个数),如果有,就有负环。
代码实现如下
if(dis[u]+w[i]<dis[v])
{
dis[v]=dis[u]+w[i];
cnt[v]=cnt[u]+1;
if(cnt[v]>=n)
{
return true;
}
if(!ex[v])
{
q.push(v);
ex[v]=1;
}
}
习题
Dijkstra算法
(回到目录)
对于没有负环的正权图来说, \(\text{Dijkstra}\) 算法可以说是绝对的利器。相较于常常被卡的 \(\text{SPFA}\) 算法来说,经过堆优化的 \(\text{Dijkstra}\) 稳定的 \(O((n+m)\log m)\) 复杂度让它往往成为绝大多数最短路问题的正解。
但是优先队列需要重载运算符的麻烦让更多人选择了更加简单好用的 \(\text{SPFA}\) ,不过在出题人卡死它的情况下, \(\text{Dijkstra}\) 往往会成为唯一的正解。
优先队列的用法很多,我们先看最简单的两种
priority_queue<int>q;
与
priority_queue<int,vector<int>,greater<int> >q;
第一种是大根堆,即元素越大的优先级越高,第二种则是小根堆,元素越小优先级越高。
但是在 \(\text{Dijkstra}\) 中,优先队列要装的元素一般都是结构体,因为我们需要取出的有两个值,一是距离,二是序号,因此我们需要重载运算符。
代码实现
struct node{
int dis,num;
bool operator<(const node &a)const{
return a.dis<dis;
}
};
priority_queue<node>q;
模板就是这样了,要用的话就根据实际情况改动即可。
OK,接下来就是完整的 \(\text{Dijkstra}\) 算法了,请见代码。
struct node{
int dis,num;
bool operator(const node &a)const{
return a.dis<dis;
}
};
priority_queue<node>q;
void dijkstra(int s)
{
memset(dis,0x3f,sizeof(dis));
memset(ex,0,sizeof(ex));
dis[s]=0;
q.push(node{0,s});
while(!q.empty())
{
node temp=q.top();q.pop();
int u=temp.num;ex[u]=0;
for(int i=head[u];i;i=next[i])
{
int v=ver[i];
if(dis[v]>dis[u]+w[i])
{
dis[v]=dis[u]+w[i];
if(!ex[v])
{
q.push(node{dis[v],v});
ex[v]=1;
}
}
}
}
}
嗯,以这个为模板跑出来的dis数组就是以s为起点,到其余点的最短路了。
让我们来实践一下吧。
这道题很模板,只要将上面的代码照抄下来,配合建图就可以A了,但是如果想用 \(\text{SPFA}\) 过这道题,除非加上各种优化,否则是绝对过不了的。
所以,如果想打出正解,还是好好学习 \(\text{Dijkstra}\) 吧。
会了?那就做几道模板题巩固一下吧。
习题
这些都是比较简单的模板题,难一点的我放在了 \(\text{SPFA}\) 的习题里面,用 \(\text{Dijkstra}\) 过一遍吧。
牢记, \(\text{SPFA}\) 能过的正权图题, \(\text{Dijkstra}\) 都能过。\(\text{SPFA}\) 过不了的题,\(\text{Dijkstra}\) 也能过。
分层图
(回到目录)
算是最短路的一类问题吧,不是算法。
学习资料:分层图
问题模型是这样的:给你一张图,叫你求从s点到t点的最短路,但不同的是你有k次机会可以使通过某一条边时,并不加边权。
我说的可能有些难懂,具体的可以看一道例题:P4568 [JLOI2011]飞行路线
思路大致是将dis数组从一维变成二维,第二维存放的是所在的层数。其实也就和动态规划有点类似,在次数没有耗尽时,对于每一条边,都有两种决策,一是选择免费通过,消耗一次机会,二是直接通过。所谓分层图,其实也就是根据使用机会的不同次数,建出k层图,再比较一下,取出每层图中的最短路中最短的那条。
看代码也许会更好理解一些(例题AC代码)
#include<iostream>
#include<cstdio>
#include<cstring>
#include<vector>
#include<queue>
#define ll long long
using namespace std;
int n,m,k;
ll dis[20010][21];
bool ex[20010][21];
struct node{
int u,dis,level;
bool operator<(const node &a)const{
return a.dis<dis;
}
};
struct edge{
int v,w;
};
vector<edge> ve[100100];
void dijkstra(int s)
{
memset(ex,0,sizeof(ex));
memset(dis,0x3f,sizeof(dis));
priority_queue<node>q;
q.push((node){s,0,0});dis[s][0]=0;
while(!q.empty())
{
node tmp=q.top();
q.pop();
int u=tmp.u,lv=tmp.level;
if(ex[u][lv])continue;
ex[u][lv]=1;
for(int i=0;i<ve[u].size();i++)
{
int w=ve[u][i].w;
int v=ve[u][i].v;
if(dis[v][lv]>dis[u][lv]+w)
{
dis[v][lv]=dis[u][lv]+w;
q.push((node){v,dis[v][lv],lv});
}
if(lv<k)
{
if(dis[v][lv+1]>dis[u][lv])
{
dis[v][lv+1]=dis[u][lv];
q.push((node){v,dis[v][lv+1],lv+1});
}
}
}
}
}
int main()
{
scanf("%d%d%d",&n,&m,&k);int s,t;
scanf("%d%d",&s,&t);
for(int i=1;i<=n;i++)ve[i].clear();
for(int i=1,x,y,z;i<=m;i++)
{
scanf("%d%d%d",&x,&y,&z);
ve[x].push_back((edge){y,z});
ve[y].push_back((edge){x,z});
}
dijkstra(s);
ll ans=dis[t][0];
for(int i=1;i<=k;i++)ans=min(ans,dis[t][i]);
printf("%lld",ans);
return 0;
}
注意要用 \(\text{Dijkstra}\),这个应该不用多说。
简单的习题k倍经验
差分约束
(回到目录)
太稀少以至于差点忘记还有这东西了……
先看模板题吧:P5960 【模板】差分约束算法
大概是会给你n个未知数与m个不等式,要你求出一组解。
不等式组一般是这样的形式
对于此类问题的解决方法一般是先建一个超级源点 \(x_0\),然后对 \(x_1,x_2,\ldots,x_n\) 分别连一条边权为0的边,再根据不等式关系来建边。
因为问题的解一般不只一组,所以求解方式也有求最短路与最长路两种,因此使用的建边方式也不一样。
对于一个不等式 \(x_j-x_i\le k\) ,如果是求最长路,就从 \(j\) 向 \(i\) 连一条边权为 \(-k\) 的边。若是求最短路,就从 \(i\) 向 \(j\) 连一条边权为 \(k\) 的边。
最终答案就是 \(x_0\) 到其余各点的最长路(最短路)。
模板代码
#include<iostream>
#include<cstdio>
#include<cstring>
#include<queue>
using namespace std;
const int Max=1e5+10;
int n,k,x,c,b,dis[Max],head[Max],tot,cnt[Max];
long long ans;
bool ex[Max];
struct node{
int to,next,w;
}a[4*Max];
inline void add(int x,int y,int z)
{
a[++tot].to=y;a[tot].w=z;
a[tot].next=head[x];head[x]=tot;
}
int main()
{
scanf("%d%d",&n,&k);
while(k--)
{
scanf("%d%d%d",&x,&c,&b);
add(x,c,-b);
}
queue<int>q;
for(int i=1;i<=n;i++)
add(0,i,0),dis[i]=-1,ex[i]=0,cnt[i]=0;
dis[0]=0;ex[0]=1;q.push(0);cnt[0]=1;
while(!q.empty())
{
int u=q.front();q.pop();
ex[u]=0;cnt[u]=0;
for(int i=head[u];i;i=a[i].next)
{
int v=a[i].to;
if(dis[v]<dis[u]+a[i].w)
{
dis[v]=dis[u]+a[i].w;
cnt[i]++;
if(cnt[i]>=n){
cout<<"NO";return 0;
}
if(!ex[v])
{
q.push(v);
ex[v]=1;
}
}
}
}
for(int i=1;i<=n;i++)
printf("%d ",dis[i]);
return 0;
}
习题
图的连通性问题
有向图的连通性
(回到目录)
先看一道例题:P3387 【模板】缩点
在这道模板题中,题目要求我们求最大的权和,点和边可以重复经过,但是点权只算一次。如果要直接去求这条路径显然行不通,因为这个题并没有保证无环,所以我们就要学习一种方法:缩点。
缩点怎么缩呢?这就需要我们学习一种新的算法,Tarjan。
然而在学习Tarjan之前,我们还需要有很多前置知识。让我们先看第一个,时间戳与追溯值,在代码中,我们分别用dfn和low两个数组来表示每个点的时间戳与追溯值。
在学习时间戳之前,我们仍然需要知道一些前置定义,摘自书上,请读者仔细观看。
给定有向图 \(G=(V,E)\) ,若存在 \(r\in V\) ,满足从 \(r\) 出发能够到达 \(V\) 中所有的点,则称 \(G\) 是一个流图,记为 \((G,r)\) ,其中 \(r\) 称为流图的源点。
在一个流图 \((G,r)\) 上从 \(r\) 出发进行深度优先遍历,每个点只访问一次。所有发生递归的边 \((x,y)\) 构成一棵以 \(r\) 为根的树,我们把它称为流图\((G,r)\)的搜索树。
现在我们可以给出时间戳的定义了:在上述深度优先遍历的过程中,按照每个节点第一次被访问的时间顺序,一次给予流图中N个节点1~N的整数标记,该标记被称为时间戳,记为dfn[x]。
搜索树中有四种边:
-
树枝边,指搜索树中的边,即x是y的父节点。
-
前向边,指搜索树中x是y的祖先节点。
-
后向边,指搜索树中y是x的祖先节点。
-
横叉边,指除了以上三种情况之外的边,它一定满足dfn[y]<dfn[x]。
在学习追溯值之前,我们先来了解一下强连通分量。
给定一张有向图,若对图中任意两节点x,y,既存在从x到y的路径,又存在从y到x的路径,则称该图为强连通图。
有向图中的极大强联通子图就称为强连通分量。我们要学习的Tarjan算法,就是可以在线性时间内求出一张有向图中所有强联通分量的算法。而缩点,也就是将图中的强联通分量缩成一个点,使原图变成一个有向无环图(DAG)。
一个环一定是一个强联通图,Tarjan算法也就是对于每个点,尽量找到与它一起能构成环的所有节点。
为此,我们需要在深度优先遍历时维护一个栈,每当我们我们找到一个新点时,就将其入栈,当我们找到一个强连通分量时,就将所有属于这个强连通分量的点弹出,再继续寻找下一个。
为了判断是否找到强联通分量,我们需要用追溯值。
设subtree(x)表示流图的搜索树以x为根的子树。x的追溯值low[x]定义为满足以下条件的节点的最小时间戳:
-
该点在栈中。
-
存在一条从subtree(x)出发的有向边,以该点为终点。
由上述定义可得出,若(u,v)为树枝边,u为v的父节点,则low[u]=min(low[u],low[v])。
若(u,v)为后向边或指向栈中的横叉边,则low[u]=min(low[u],dfn[v])。
如何判断强联通分量呢?当我们搜索一个点的子树回来之后,判断一下这个点的low值与它的dfn值是否相等,如果相等,说明其子树中的节点不能与栈中其它节点构成环,此时x到栈顶的所有节点构成一个强连通分量。
如果实在不想理解,就背模板吧,手动模拟一遍程序,也许会更容易理解。
const int N=1e5+5;
int dfn[N],low[N],st[N],co[N],top,num,cnt;
void tarjan(int x)
{
dfn[x]=low[x]=++num;
st[++top]=x;
for(int i=head[x];i;i=Next[i])
{
int y=ver[i];
if(!dfn[y])
{
tarjan(y);
low[x]=min(low[x],low[y]);
}
else if(!co[y])low[x]=min(low[x],dfn[y]);
}
if(dfn[x]==low[x])
{
co[x]=++cnt;
while(st[top]!=x)
{
co[st[top]]=cnt;
--top;
}
--top;
}
}
int main()
{
cin>>n;
/*
建边
*/
for(int i=1;i<=n;i++)
if(!dfn[i])tarjan(i);
}
好的,现在我们已经成功将图缩成了一个DAG。但是想要过这道模板题,还没有那么简单。要求一条点权最大的路径,很明显我们需要DP。
但是想要使用DP,我们需要满足两个条件,最优子结构与无后效性,最优子结构可以看出来,转移方程为 \(f[i]=f[d[i][j]]+dis[i]\),\(f[i]\) 即从此点出发可以达到的最大权和,最后再在所有\(f[i]\) 中取最大值即可。dis数组为缩点后这个点的点权。
但是想要保证无后效性,我们还需要学习一种方法:拓扑排序。
形象的说,有些东西要学必须要有前置知识,学前置知识可能还要学前前置知识,拓扑排序就是把所有点按前置的多少,即有多少点可以走一条路径抵达这个点来进行排序,最后会排出一个拓扑序列。
这样就可以保证当我们算这个点时,不会影响到前置的那些点。
运行流程简单的说,就是先找出所有入度为0,即没有前置的点,将其放入队列。然后从队头取出一个点,并弹出。之后再将这个点连出的边都删除,删除的同时判断连着的那个点是否入度为零,如果是就加入队列。这样经过几轮后,所有边都被删除了,队列空时循环结束。
拓扑排序前不要忘了处理每个点的入度,并且这里的点是缩点后的点,拓扑排序只能在DAG上进行,不要弄混。
处理入度的代码
for(int i=1,x,y;i<=tot;i++)
{
if(co[from[i]]!=co[ver[i]])
{
x=co[from[i]];y=co[ver[i]];
rb[y]++;p[x].push_back(y);d[y].push_back(x);
}
}
链式前向星存图是要有一个额外的from数组记录出发点。rb为记录入度的数组。原图中的两个点若不在一个强连通分量中,说明缩点后他们不在一个点,才能记录。p数组记录从x点到y点有一条边。d数组是dp用,记录有那些点有一条边连向x点。
拓扑排序代码
void topu()
{
queue<int>q;
for(int i=1;i<=sum;i++)
if(rb[i]==0)q.push(i);
while(!q.empty())
{
int x=q.front();q.pop();
ans[++to]=x;
for(int i=0;i<p[x].size();i++)
{
int y=p[x][i];
rb[y]--;
if(rb[y]==0)q.push(y);
}
}
}
ans数组即为记录拓扑序列的数组。
完整AC代码
#include<cstdio>
#include<iostream>
#include<vector>
#include<queue>
using namespace std;
const int N=1e4+10;
int F[N],dfn[N],low[N],head[N*10],next[N*10],ver[N*10],w[N],from[N*10],tot,stack[N*10];
int n,m,num,top,sum,co[N],dis[N],ans[N],to,maxn,rb[N];
bool vis[N];
vector<int>p[N];
vector<int>d[N];
void add(int x,int y)
{
ver[++tot]=y;next[tot]=head[x];
from[tot]=x;head[x]=tot;
}
void tarjan(int x)
{
dfn[x]=low[x]=++num;
stack[++top]=x;vis[x]=1;
for(int i=head[x];i;i=next[i])
{
int y=ver[i];
if(!dfn[y])
{
tarjan(y);
low[x]=min(low[x],low[y]);
}
else if(vis[y])low[x]=min(low[x],dfn[y]);
}
if(dfn[x]==low[x])
{
co[x]=++sum;
while(stack[top]!=x)
{
co[stack[top]]=sum;
dis[sum]+=w[stack[top]];vis[stack[top]]=0;
--top;
}
dis[sum]+=w[x];vis[x]=0;
--top;
}
}
void topu()
{
queue<int>q;
for(int i=1;i<=sum;i++)
if(rb[i]==0)q.push(i);
while(!q.empty())
{
int x=q.front();q.pop();
ans[++to]=x;
for(int i=0;i<p[x].size();i++)
{
int y=p[x][i];
rb[y]--;
if(rb[y]==0)q.push(y);
}
}
}
int main()
{
scanf("%d%d",&n,&m);
for(int i=1;i<=n;i++)scanf("%d",&w[i]);
for(int i=1,x,y;i<=m;i++)
{
scanf("%d%d",&x,&y);
add(x,y);
}
for(int i=1;i<=n;i++)if(!dfn[i])tarjan(i);
for(int i=1,x,y;i<=tot;i++)
{
if(co[from[i]]!=co[ver[i]])
{
x=co[from[i]];y=co[ver[i]];
rb[y]++;p[x].push_back(y);d[y].push_back(x);
}
}
topu();
for(int i=1;i<=sum;i++)
{
int w=ans[i];
F[w]=dis[w];
for(int j=0;j<d[w].size();j++)
F[w]=max(F[d[w][j]]+dis[w],F[w]);
}
for(int i=1;i<=sum;i++)
maxn=max(maxn,F[i]);
printf("%d",maxn);
return 0;
}
这里开始就有点难了,不过相信你可以。