图论总结(未完结)
图论总结
5.13 开始讲图论的,我咕咕咕到了 5.20。。。
图论相对于 DP 和数学期望那块,算是简单的了。感觉就是要多总结多找题感吧。
这里从基础概念开始,总结一些非常非常基础的东西。由于我太菜似乎也总结不出什么深奥的东西/kk。
更新日志
upd on 2022.9.26:增加了分层图的总结与代码实现,并附了相关例题。
概念
注:这里是总结了一部分我认为比较重要或者比较难的概念,可能并不全面,全面的图的概念见图论相关概念 - OI Wiki。
图
图(Gragh)是一个二元组 \(G=(V(G),E(G))\)。其中 \(V(G)\) 是点集,\(E(G)\) 是边集。
根据不同的分类标准可以把图分为不同的种类:
按照边是否有边权把图分为无权图和有权图;按照图是否连通分为连通图和非连通图;按照边是否有向分为有向图和无向图……
度数
与一个顶点 \(v\) 相连的边的条数称作该顶点的度数(degree),记作 \(d(v)\)。
对于有向图,一个点的度数又可以分为入度和出度。对于一个点 \(v\),以该点为终点的边的条数叫点 \(v\) 的入度,以该点为起点的边的条数叫点 \(v\) 的出度。
重边
若 \(E\) 中存在两个完全相同的元素(边)\(e_1,e_2\),则它们被称作(一组)重边。
生成子图
在图 \(G=(V(G),E(G))\) 中,选一些点和边,若这些点和边构成了一棵树,则这是这些点和边构成了一张生成子图。
连通图/强联通图
在一个无向图上的任意两个点可以互相到达,那么这张图叫做连通图。
在一个有向图上的任意两个点可以互相到达,那么这张图叫做强连通图。
稀疏图/稠密图
若一张图的边数远小于其点数的平方,那么它是一张稀疏图。
若一张图的边数接近其点数的平方,那么它是一张稠密图。
图的存储
图有三种存储方式。
邻接矩阵
方法
定义一个二维数组 \(a_{u,v}\) 表示节点 \(u\) 到 \(v\) 之间是否有边;有边,则 \(a_{u,v}=1\),反之,则 \(a_{u,v}=0\)。
对于有权图,\(a_{u,v}\) 可以存储 \(u\) 到 \(v\) 的边权。
时间复杂度
查询两点间是否有边:\(O(1)\)。
遍历一个点所有出边:\(O(n)\)。
遍历整张图:\(O(n^2)\)。
空间复杂度:\(O(n^2)\)。
优点
可以在 \(O(1)\) 的时间里查询两点间是否有边。
缺点
-
只适用于图无重边的情况;
-
对于点数较多的图,空间复杂度太大,无法接受;
-
遍历一个点的所有出边和遍历整张图时间复杂度较大,难以接受。
代码实现
int a[maxn][maxn];
for(int i=1;i<=m;i++)//输入 m 条边
{
int u,v,w;
u=read();v=read();w=read();//无权图不需要输入 w
a[u][v]=w;//无权图:a[u][v]=1;
//无向图:a[v][u]=w;
}
邻接表
使用一个可以作为动态数组的数据结构(vector 或者 basic_string)来存边。
方法
定义 basic_string<int>edge[maxn]
(或 vector),edge[u]
就表示点 \(u\) 所有出边信息。每次遇到一个 \(u,v,w\),就连边,具体见下方实现代码。
时间复杂度
查询两点 \(u,v\) 之间是否有边:\(O(d(u))\)。
遍历一个点所有出边:\(O(d(u))\)。
遍历整张图:\(O(n+m)\)(\(n\) 是点数,\(m\) 是边数)。
空间复杂度:\(O(m)\)(注意无向图需要开 2 倍空间)。
优点
-
遍历整张图和遍历一个所有出边的时间复杂度均较小;
-
空间复杂度较小;
-
尤其适用于需要对一个点的所有出边进行排序的场合。
-
适用于稠密图。
缺点
判断两点间是否有边的时间复杂度较大。
代码实现(这里以有向无环图为例)
struct Node{int v,w;}//v 另一个端点,w 表示边权。
basic_string<Node>edge[maxn];
//存边
for(int i=1;i<=m;i++)
{
int u,v,w;
u=read();v=read();w=read();
edge[u]+=Node{v,w};
}
//遍历一个点所有出边
for(Node y:edge[x])
{
//y.v 即为终点
//y.w 即为边权
}
链式前向星
由于这玩意写起来太麻烦我实在懒得用。不过为了方便以后复(重)习(开)还是总结一下吧。
方法
将邻接表换成类链表的形式即可。
对于一个点 \(u\),定义 \(head_u\) 表示以 \(u\) 为起点的第一条边编号,\(to_u\) 表示当前边的终点,\(nxt_i\) 表示 \(u\) 的第 \(i\) 条边的下一条边的编号,\(cnt\) 表示当前图中总共有多少边。
逆序存边。每次连一条边 \(u,v\) 时:
-
边数 \(cnt++\);
-
将该边的 \(nxt\) 设为原 \(head_u\);
-
新的 \(head_u\) 设为当前边数 \(cnt\);
-
\(to_u\) 设为 \(v\)。
(这里说的比较简略,若想要更加深刻的了解链式前向星或没看懂的,可以移步链式前向星(详解)_Stephencurry‘s csdn的博客-CSDN博客_链式前向星)
时间复杂度:同邻接表时间复杂度。
空间复杂度:同邻接表空间复杂度。
优点
前两条同邻接表。
然而其实我也不知道它有什么其它的优点(至少现在未体会到)。先引(chao)用(xi) OIWiki 的一句话,等日后有所体会再回来补充吧:
优点是边是带编号的,有时会非常有用,而且如果
cnt
的初始值为奇数,存双向边时i ^ 1
即是i
的反边(常用于 网络流)。
缺点
-
判断两点间是否有边的时间复杂度较大;
-
不能方便地对一个点的出边进行排序;
-
写起来显然比前两种麻烦的多(
所以用这玩意干啥)。
代码实现
int head[maxn],to[maxn],nxt[maxn];
int cnt;
void add(int u,int v)//连一条从 u 到 v 的边
{
nxt[++cnt]=head[u];
head[u] = cnt;
to[cnt] = v;
}
// 遍历 u 的出边
for(int i=head[u];i;i=nxt[i])
{
int v = to[i];
}
最小生成树
定义
生成树:对于一个无向连通图 \(G=(V,E),n=|V|,m=|E|\),由 \(V\) 中的全部 \(n\) 个节点和 \(E\) 中的 \(n-1\) 条边构成的无向联通子图叫做图 \(G\) 的一棵生成树。
最小生成树:对于一个无向连通图,其边权和最小的生成树就叫做这个无向连通图的最小生成树(Minimum Spanning Tree),简称 MST。
注:最小生成树存在的前提是无向连通图,非无向连通图没有生成树。(非连通图只有最小生成森林)
Kruskal 算法
基本思想
利用贪心思想。
在任意时刻,都从剩余的边中选出一条权值最小,且该边两个端点不属于同一棵树(不连通),把该边加入 MST 中。
步骤
-
对于每一个节点单独建立一个并查集;
-
将图上所有的边从小到大排序;
-
遍历每一条边。
-
判断连接这条边的两个节点是否在同一个集合内。
若不在,则将它们连边,同时加入到一个集合里。
若在,则
continue
。 -
直到加入 \(n-1\) 条边,即形成了一棵树,结束遍历。
时间复杂度
排序+并查集:\(O(m \log m)\)。
证明
咕咕咕
懒得写(之后补上吧,最近要总结的东西多了点)
代码实现
struct Node{int u,v,w;}e[maxm];//结构体存边
int fa[maxn];//并查集
int n,m,ans;
int find(int x)
{
if(x!=fa[x])fa[x]=find(fa[x]);
return fa[x];
}
void add(int x,int y)
{
x=find(x);y=find(y);
fa[x]=y;
}
int cmp(Node a,Node b){return a.w<b.w;}//按照边权从小到大排序
void Kruskal()
{
sort(e+1,e+m+1,cmp);
for(int i=1;i<=m;i++)
{
if(find(x)!=find(y))//若不在一个集合则合并。
{
add(x,y);
ans+=e[i].w;//ans 表示最小生成树边权和。
}
}
}
int main()
{
n=read();m=read();
for(int i=1;i<=m;i++)e[i].u=read(),e[i].v=read(),e[i].w=read();
Kruskal();
cout<<ans<<'\n';
}
例题
P1967 [NOIP2013 提高组] 货车运输 - 洛谷:经典 LCA+Kruskal 练手题。
P2323 [HNOI2006]公路修建问题 - 洛谷:分别考虑两种公路,两边 Kruskal。0
P4047 [JSOI2010]部落划分 - 洛谷:可以加深对 Kruskal 本质的理解。
P2245 星际导航 - 洛谷(这题和货车运输本质上一样)
Prim 算法
基本思想
依然是贪心思想。
随便选择一个点作为起始点(加入到连通块中),然后每次从剩下的点中选择与当前连通块(最小生成树)距离最短的点加入到连通块中,直到所有的点都被加到这个连通块为止。那么这个连通块就是最小生成树。
步骤
-
定义一个数组 \(dis_i\) 表示节点 \(i\) 到当前联通块的距离;
-
随便选择一个点,加入到连通块(最小生成树)中;
-
更新所有剩下的节点到 \(i\) 的距离;
-
选择一个距离当前连通块距离最近的点 \(t\) 加入到连通块中;
-
对于所有剩下的节点 \(i\),判断 \(dis_i\) 是否大于 \(t\) 与 \(i\) 的距离;若大于,则 \(dis_i\) 更新为 \(t\) 到 \(i\) 的距离。
-
重复步骤 2-5,直至所有的点加入到连通块中为止,则连通块即为最小生成树。
时间复杂度
\(O(n^2)\),优先队列优化:\(O(m \log n)\)。
证明
适用范围
相对于 Kruskal:在稠密图尤其是完全图上,暴力 Prim 的复杂度比 Kruskal 优,但 不一定 实际跑得更快。
实现代码
int calc(int a,int b){return (x[a]-x[b])*(x[a]-x[b])+(y[a]-y[b])*(y[a]-y[b]);}
//计算两点间距离
void add(int x)//将一个点加入连通块。
{
for(int i=1;i<=n;i++)
{
if(vis[i])continue;
if(i==x)continue;
dis[i]=min(dis[i],calc(i,x));
}
vis[x]++;
}
void Prim()
{
dis[1]=0;
add(1);
int T=n-1;
while(T--)
{
int now=0;
for(int i=1;i<=n;i++)
{
if(!vis[i]&&dis[i]<dis[now])now=i;
}
add(now);
ans+=double(sqrt(dis[now]));
}
}
signed main()
{
n=read();
for(int i=1;i<=n;i++)
{
x[i]=read();y[i]=read();
}
memset(dis,0x3f3f3f,sizeof(dis));//别忘了 dis 数组刚开始要赋值为 INF。
Prim();
cout<<ans<<endl;
}//懒得打一遍所以直接复制粘贴了我 公路修建 的代码。
例题
P1265 公路修建 - 洛谷:典型的 Prim 例题,用 Kruskal 会 MLE。
次小生成树
注:由于本人语文太差,所以以下内容部分借鉴了 OIWiki 上次小生成树讲解。
非严格次小生成树
定义
在一张无向图中,边权和最小的满足边权和 \(\ge\) 最小生成树边权和的生成树。
求解步骤
-
求出无向图的最小生成树,设其边权和为 \(val\);
-
遍历每条未被选中的边 \(e=(u,v,w)\);
-
找到最小生成树上 \(u\) 到 \(v\) 边权最大的一条边 \(e'=(u',v',w')\):用在 货车运输 那道题里的思路,在倍增求 LCA 的过程中维护每个节点到其 \(2^i\) 级祖先的最大边权;
-
用 \(e\) 替换 \(e'\),可以得到一条边权和 \(val'=val-e'+e\) 的生成树;
-
由于求的是次小,所以只需要在上述所有求得的 \(val'\) 取最小值即可。
严格次小生成树
定义
在一张无向图中,边权和最小的满足边权和严格 \(>\) 最小生成树边权和的生成树。
求解步骤
考虑在求严格次小生成树的过程中稍作改动。
在刚刚的求解过程中,之所以是非严格大于,是因为最小生成树保证生成树中 \(u\) 到 \(v\) 路径上的边权最大值一定不大于其它从 \(u\) 到 \(v\) 路径的边权最大值。即:我们在用 \(e\) 替换 \(e'\) 时,两者边权可能是相等的。
解决方法:在倍增求 LCA 的过程中维护每个节点到其 \(2^i\) 级祖先的最大边权的同时维护严格次大边权,当最大边权与原最小生成树上最大边权相等,用严格次大值替换;
时间复杂度
\(O(m \log m)\)。
代码实现
//摘抄我的 P4180 严格次小生成树
struct P{int u,v,w;}e[maxn<<1];
int f[maxn],vis[maxn],fa[maxn][25],tt[5];
int maxx[maxn][25],minn[maxn][25],dep[maxn];
//maxx 表示的是
int n,m,ans,ss;
struct Node{int v,w;};
basic_string<Node>edge[maxn<<1];
int cmp(P a,P b){return a.w<b.w;}
int find(int x)
{
if(x!=f[x])f[x]=find(f[x]);
return f[x];
}
void add(int x,int y)
{
x=find(x);y=find(y);
f[x]=y;
}
void Kruskal()//求最小生成树
{
sort(e+1,e+m+1,cmp);
for(int i=1;i<=m;i++)
{
if(find(e[i].u)!=find(e[i].v))
{
add(e[i].u,e[i].v);
ss+=e[i].w;
vis[i]++;
edge[e[i].u]+=Node{e[i].v,e[i].w};
edge[e[i].v]+=Node{e[i].u,e[i].w};
}
}
}
void dfs(int x,int fath)//dfs 过程中倍增求出最大和次大边权
{
dep[x]=dep[fath]+1;
fa[x][0]=fath;
minn[x][0]=-INF;
for(int i=1;i<=20;i++)
{
fa[x][i]=fa[fa[x][i-1]][i-1];
tt[1]=maxx[x][i-1];tt[2]=maxx[fa[x][i-1]][i-1];
tt[3]=minn[x][i-1];tt[4]=minn[fa[x][i-1]][i-1];
sort(tt,tt+4);
maxx[x][i]=tt[3];
int t=2;
while(t>=0&&tt[t]==tt[3])t--;
if(t<0)minn[x][i]=-INF;
else minn[x][i]=tt[t];
}
for(Node y:edge[x])
{
if(y.v==fath)continue;
maxx[y.v][0]=y.w;
dfs(y.v,x);
}
}
int lca(int u,int v)
{
if(dep[u]<dep[v])swap(u,v);
for(int i=0;i<=20;i++)
{
if((dep[u]-dep[v])&(1<<i))u=fa[u][i];
}
if(u==v)return u;
for(int i=20;i>=0;i--)
{
if(fa[u][i]!=fa[v][i]){u=fa[u][i];v=fa[v][i];}
}
return fa[u][0];
}
int query(int x,int y,int val)
{
int ret=-INF;
for(int i=20;i>=0;i--)
{
if(dep[fa[x][i]]>=dep[y])
{
if(val!=maxx[x][i])ret=max(ret,maxx[x][i]);
else ret=max(ret,minn[x][i]);
x=fa[x][i];
}
}
return ret;
}
void work()
{
ans=INFLL;
for(int i=1;i<=m;i++)
{
if(!vis[i])
{
int LCA=lca(e[i].u,e[i].v);
int x=query(e[i].u,LCA,e[i].w);
int y=query(e[i].v,LCA,e[i].w);
int kk=max(x,y);
if(kk!=-INF)ans=min(ans,ss-kk+e[i].w);
}
}
if(ans==INFLL)cout<<-1<<endl;
else cout<<ans<<endl;
}
signed main()
{
n=read();m=read();
for(int i=1;i<=n;i++)f[i]=i;
for(int i=1;i<=m;i++){e[i].u=read();e[i].v=read();e[i].w=read();}
Kruskal();
dfs(1,0);
work();
return 0;
}
Kruskal 重构树
定义/步骤
-
前三步同 Kruskal 算法;
-
判断连接这条边的两个节点是否在同一个集合内。
若不在,则:
-
将他们连边;
-
新建一个点,点权为加入边的边权;
-
将两个集合的根节点分别设为新建点的左儿子和右儿子
-
将两个集合和新建点合并成一个集合。将新建点设为根。
-
-
直到加入 \(n-1\) 条边,即形成了一棵树,结束遍历。
那么形成的这个有 \(n\) 个叶子节点的二叉树,我们就叫做 Kruskal 重构树。
性质
-
是一棵有根二叉树,根节点是最后新建节点;
-
若原图联通,则 Kruskal 重构树会比原图多 \(n-1\) 个节点(连了 \(n-1\) 条边嘛);
-
上述定义下(即:边权从小到大排序),节点 \(u\) 到 \(v\) 路径上最大边权的最小值 = Kruskal 重构树上 \(lca(u,v)\);
-
边权从大到小排序,节点 \(u\) 到 \(v\) 路径上最小边权的最大值 = Kruskal 重构树上 \(lca(u,v)\)。
适用范围
求图上两点路径上最大边权最小值/最小边权最大值。
代码实现(以最小边权最大为例)
//复制粘贴我 货车运输 的代码。码风可能和现在有所区别。但~~懒得改了~~
int find(int x)
{
if(x!=f[x]){f[x]=find(f[x]);}
return f[x];
}
void dfs(int x,int fath,int v)
{
dep[x]=dep[fath]+1;
sum[x][0]=v;
fa[x][0]=fath;
for(int i=1;i<=20;i++)
{
fa[x][i]=fa[fa[x][i-1]][i-1];
sum[x][i]=min(sum[x][i-1],sum[fa[x][i-1]][i-1]);
}
for(Node y:edge[x])
{
if(y.v==fath)continue;
dfs(y.v,x,y.w);
}
}
int work(int x,int y)
{
if(dep[x]<dep[y])swap(x,y);
int ans=100000;
for(int i=0;i<=20;i++)
{
if((dep[x]-dep[y])&(1<<i))
{
ans=min(ans,sum[x][i]);
x=fa[x][i];//注意这两句位置不能换!!!
}
}
if(x==y)return ans;
for(int i=20;i>=0;i--)
{
if(fa[x][i]!=fa[y][i])
{
ans=min(ans,min(sum[x][i],sum[y][i]));
x=fa[x][i];
y=fa[y][i];
}
}
int t=sum[x][0];
if(fa[x][0]!=y) t=min(t,sum[y][0]);
return min(t,ans);
}
int main()
{
memset(sum,0x3f3f3f,sizeof(sum));
n=read();m=read();
for(int i=1;i<=m;i++)
{
e[i].x=read();e[i].y=read();e[i].z=read();
}
sort(e+1,e+m+1,cmp);
for(int i=1;i<=n;i++)f[i]=i;
for(int i=1;i<=m;i++)
{
int a=find(e[i].x),b=find(e[i].y);
if(f[a]==b)continue;
f[a]=b;
edge[e[i].x]+=((Node){e[i].y,e[i].z});
edge[e[i].y]+=((Node){e[i].x,e[i].z});
}
for(int i=1;i<=n;i++)
{
if(f[i]==i)dfs(i,i,100000);
}
int q=read();
while(q--)
{
int x,y;// q 组询问,每次求 x 和 y 路径上最小边权最大值。
x=read();y=read();
int X=find(x),Y=find(y);
if(X!=Y)cout<<-1<<'\n';
else cout<<work(x,y)<<'\n';
}
return 0;
}
例题
P1967 [NOIP2013 提高组] 货车运输 - 洛谷:可以看做是生成树,也可以看做是 Kruskal 重构树求最小边权最大。
P2245 星际导航 - 洛谷:几乎和 货车运输 一样,只是求的是最大边权最小值。
P7834 [ONTAK2010] Peaks 加强版 - 洛谷:离散化+Kruskal 重构树+树上倍增+主席树。
拓扑排序
定义
感觉自己完全叙述不出来于是抄了度娘
对一个有向无环图(Directed Acyclic Graph简称DAG)G进行拓扑排序,是将G中所有顶点排成一个线性序列,使得图中任意一对顶点u和v,若边<u,v>∈E(G),则u在线性序列中出现在v之前。通常,这样的线性序列称为满足拓扑次序(Topological
Order)的序列,简称拓扑序列。简单的说,由某个集合上的一个偏序得到该集合上的一个全序,这个操作称之为拓扑排序。 ——百度百科
可以看出,拓扑排序的目标是将所有的节点排序,使得排在前面的点不依赖排在后面的点。
其实这也就是 DP 求解的本质。
我们在 DP 中学到,一个 DP 问题,求解大的状态依赖于小的状态。
只有当小状态求解完成之后,才能获取大状态的解。
这些依赖关系形成了 DAG,即:
-
自顶向下 + 记忆化的求解,对应自顶向下的拓扑排序。
-
自底向上的 DP 求解,对应自底向上的拓扑排序。
(这一点会在我之后复(重)习(开)DP 后提到,如果不咕的话)
求解步骤
-
建立一个空队列 \(q\);
-
在图上找到所有入度为 0 的点,将它们入队;
-
对于所有在队列中的点:
-
出队;
-
遍历所有与它们连边的点 \(i\),将它们出度减一;
若此时 \(i\) 的入度为 0,则将其入队。
-
-
重复步骤 2-3;
优化/拓展
对于求字典序最大/最小的拓扑排序:可将队列换成优先队列实现。
时间复杂度
设 DAG 有 \(n\) 个点 \(m\) 条边。
普通队列下:\(O(n+m)\)。
优先队列求字典序最大/最小:\(O(n \log n+m)\)。
代码实现
for(int i=1;i<=m;i++)
{
int u,v;
u=read();v=read();
edge[u]+=v;
in[v]++;//in[v] 表示 v 的入度。
}
for(int i=1;i<=n;i++)//将第一轮所有入度为 0 的点入队。
{
if(!in[i])q.push(i);
}
while(!q.empty())
{
int now=q.front();
q.pop();
for(int y:edge[now])
{
in[y]--;
if(!in[y])q.push(y);
}
}
例题
P1347 排序 - 洛谷:去年 9 月份写的,已经差不多忘了题意了,但似乎挺板的(?)
P7113 [NOIP2020] 排水系统 - 洛谷:比较板的题,注意最后一个点会爆 ull,所以请使用 int128 或者把一个数压成两个数的方法存储。
P3243 [HNOI2015]菜肴制作 - 洛谷:反向 topsort+优先队列求字典序。
P1983 [NOIP2013 普及组] 车站分级 - 洛谷
最短路
一些说明/概念
单源最短路径:图上一个点到其它所有点的最短路径;
多源最短路径:图上每个点分别作为起点和终点的最短路径;
下面要说明的几种算法都是针对有权有环图而言的。
在 DAG 上可以直接用 topsort 来求最短路径;
在无权图上可以直接 BFS。
Floyd 算法
适用范围
Floyd 算法用于求解多源最短路。
求解过程
定义 \(dp_{k,x,y}\) 表示从 \(x\) 到 \(y\) 只经过编号 \(\le k\) 的节点的最短路径。
初始化:\(dp_{0,x,y}=e(x,y)\)。
特殊地,所有的 \(dp_{0,x,x}=0(x=y)\);若 \(x,y\) 不连边,则 \(dp_{0,x,y}=\infty\)(设成 \(\infty\) 是因为后面转移的时候要取最小值)。
考虑转移。有两种情况:
-
经过编号为 \(k\) 的点:\(dp_{k,x,y}=dp_{k-1,x,k}+dp_{k-1,k,y}\)。
-
不经过编号为 \(k\) 的点:\(dp_{k,x,y}=dp_{k-1,x,y}\)。
上述两种情况取最小值即可。
那么对于所有的 \(x,y\),\(dp_{n,x,y}\) 即为答案。
空间复杂度 \(O(n^3)\),\(n\) 稍大就会 MLE,考虑优化。
可以发现第一维的 \(k\) 只与上一层的 \(k-1\) 有关,所以可以省略。
那么有:
复杂度
时间复杂度:\(O(n^3)\)。
空间复杂度:\(O(n^2)\)。
代码实现
//注意 k 是最外层循环,i 是次外层,j 是内层。
//不能是以 i,j,k 的顺序循环。
memset(dp,0x3f,sizeof(dp));
for(int i=1;i<=m;i++)
{
int u,v,w;
u=read();v=read();w=read();
dp[u][v]=min(dp[u][v],w);
}
for(int k=1;k<=n;k++)
{
for(int x=1;x<=n;x++)
{
for(int y=1;y<=n;y++)
{
dp[x][y]=min(dp[x][y],dp[x][k]+dp[k][y]);
}
dp[x][x]=0;
}
}
拓展:Floyd 寻找无向图最小环
考虑 Floyd 算法 的求解过程,当外层循环 \(k\) 开始时,\(dp_{x,y}\) 表示的是经过节点不超过 \(k-1\) 的节点从 \(x\) 到 \(y\) 的最短路长度。
所以,\(\min \{dp_{i,j}+a_{j,k}+a_{k,i}\}\) 满足以下两个条件的最小环长度:
-
由编号不超过 \(k\) 的节点构成;
-
经过节点 \(k\)。
上述 \(i,j\) 实际上就是与 \(k\) 相邻的两个点。
对于 \(k \in [1,n]\),利用上述进行计算,取最小值即可。
例题
P2935 [USACO09JAN]Best Spot S - 洛谷:比较板的一道基础题。
P1119 灾后重建 - 洛谷:Floyd 变形,有助于加深对于 Floyd 算法的理解;
dijkstra 算法
适用范围
适用于求单源最短路径。但只适用于所有边长度都是非负数的图。
其实在一些要求多源最短路径的问题中,也可以以每个点为起点,跑 \(n\) 轮 dijkstra 求得多源最短路。
求解思路
定义一个数组 \(dis_i\) 表示从起点到 \(i\) 的最短路径的长度,\(vis_i\) 表示节点是否被标记。
不断利用类似 Prim 算法的贪心策略,选取一个最短路最小的点,并用这个点更新与这个点相连的所有点的最短路。
求解步骤
-
初始化所有节点 \(i\) 的 \(vis_i=0\);
-
初始化起点 \(dis_s=0\),其余 \(dis_i= \infty\);
-
找出一个未被标记的,\(dis_x\) 最小的节点 \(x\),标记 \(x\);
-
遍历节点 \(x\) 的所有出边 \((x,y,w)\),若 \(dis_y>dis_x+w\),则 \(dis_y=dis_x+w\);
-
重复步骤 2-3,直至所有节点被标记。
时间复杂度
普通 dij:\(O(n^2)\)。
优先队列优化:
对于一条边的更新:\(O(\log n)\);
总时间复杂度:\(O(m \log n)\)。
代码实现
struct Node
{
int v,w;
bool operator<(const Node &t)const
{
return w>t.w;
}
};
basic_string<Node>edge[maxn<<1];
priority_queue<Node>q;
int dis[maxn],vis[maxn];
int n,m,s;
void dijkstra(int s)
{
for(int i=1;i<=n;i++)dis[i]=(1ll<<31)-1;
dis[s]=0;
q.push(Node{s,dis[s]});
while(!q.empty())
{
Node now=q.top();
q.pop();
if(vis[now.v])continue;
vis[now.v]++;
for(Node y:edge[now.v])
{
if(dis[y.v]>dis[now.v]+y.w)
{
dis[y.v]=dis[now.v]+y.w;
q.push(Node{y.v,dis[y.v]});
}
}
}
}
例题
P1144 最短路计数 - 洛谷:比较简单的一道计数题。
P4673 [BalticOI 2005]Bus Trip - 洛谷:抽象化的图,比较典型的例题,之后会考虑写一篇题解专门来记录一下这个典型的转化。
P5468 [NOI2019] 回家路线 - 洛谷:虽然正解并非 dij,但可以 AC(雾),思想上和 Bus Trip 具有异曲同工之处,之后同样会写一篇题解加以记录。
P7473 [NOI Online 2021 入门组] 重力球 - 洛谷:可以姑且理解为 dij+BFS,同样很经典。
P3953 [NOIP2017 提高组] 逛公园 - 洛谷:dij+topsort ,两边 dij 反向建图典型例题。
Bellman-ford & SPFA
适用范围
-
也适用于单源最短路径。
与 dijkstra 不同的是,可以用于有负权的边。
-
可用于判断负环。
Bellman-ford 求解步骤
-
初始化起点 \(dis_i=0\),其余 \(dis_i= \infty\);
-
扫描所有边 \((x,y,w)\),若 \(dis_y>dis_x+w\),则 \(dis_y=dis_x+w\);
-
重复步骤 2,直至所有边被更新完。
SPFA 优化
-
初始化一个队列
queue
; -
将起点入队;
-
取出队头元素 \(x\),扫描其所有出边 \((x,y,w)\),若 \(dis_y>dis_x+w\),则 \(dis_y=dis_x+w\);
-
将 \(y\) 入队;
-
重复步骤 3-4,直至队列为空;
时间复杂度
Bellman-ford:\(O(nm)\);
SPFA:虽然一般情况下较快,最坏情况下可能被卡成 \(O(nm)\),考场上一般若无负权不使用!而使用 dijkstra。
代码实现
//SPFA 优化
void SPFA(int s)
{
for(int i=1;i<=n;i++)dis[i]=(1ll<<31)-1;
dis[s]=0;
q.push(s);
while(!q.empty())
{
int now=q.top();
q.pop();
for(Node y:edge[now])
{
if(dis[y.v]>dis[now]+y.w)
{
dis[y.v]=dis[now]+y.w;
q.push(y.v);
}
}
}
}
例题
P1073 [NOIP2009 提高组] 最优贸易 - 洛谷:反向建图+两遍 SPFA;
P1462 通往奥格瑞玛的道路 - 洛谷:SPFA+二分答案;
P1979 [NOIP2013 提高组] 华容道 - 洛谷:正解是 SPFA+BFS。
分层图
题目特征
一般用于在求最短路/最长路相关问题时要求“特殊技能“只能使用 \(k\) 次时。
注:这里的特殊技能一般有以下几点:
- 将一条边边权变为 \(t\);
- 将一条边反向;
- 图中有两类边,一类边只能经过 \(k\) 次。
求解思路
- 将最原始的图作为初始图(第 \(0\) 层),根据所给操作将图分为 \(k\) 层;
- 第 i 层图代表进行了 i 次操作后的图。
- 将图分为 \(k\) 层后,从 \(s\) 到 \(t+n\times k\) 的最短路就是所求最短路。
求解步骤
- 对于每一条边 \((u,v)\),在每一个第 \(i\) 层内都让 \(u+i\times n\) 与 \(v+i\times n\) 连边;
- 若求是最大 \(k\) 次,则将每一层的 \(t+i\times n\) 与 \(t+(i+1)\times n\) 连边。
- 对于特殊操作,在层与层之间根据题意连边;
- 求 \(s\) 到 \(t+n\times k\) 的最短路。
代码实现
//以无向图为例。
n=read();m=read();k=read();
int s,t;
s=read();t=read();
for(int i=1;i<=m;i++)
{
int u,v,w;
u=read();v=read();w=read();
edge[u]+=Node{v,w};
edge[v]+=Node{u,w};
for(int j=1;j<=k;j++)
{
edge[u+(j-1)*n]+=Node{v+j*n,0ll};
edge[v+(j-1)*n]+=Node{u+j*n,0ll};
edge[u+j*n]+=Node{v+j*n,w};
edge[v+j*n]+=Node{u+j*n,w};
}
}
for(int i=1;i<=k;i++)edge[t+(i-1)*n]+=Node{t+i*n,0ll};
dijkstra(s);
例题
前四道题是分层图模板题,第五道题是分层图+缩点+SPFA。
P1948 [USACO08JAN]Telephone Lines S
P2939 [USACO09FEB]Revamping Trails G
P3119 [USACO15JAN]Grass Cownoisseur G
差分约束系统
问题描述
给出一组包含 \(m\) 个不等式,有 \(n\) 个未知数的形如:
的不等式组,求任意一组满足这个不等式组的解。
求解
首先,对于差分约束的每个约束条件 \(x_{c_k}-x_{c'_k}\le y_k\),可以变形为 \(x_{c_k}\le x_{c'_k}+y_k\)。
观察会发现和 dijkstra 里面的转移式 \(dis_y=\)
咕咕咕……
post on 2022.6.6:由于最近艾教讲了图的连通性相关内容但是我感觉我没完全理解,而又恰好要去做连通性相关的题目,所以这部分先咕一段时间,等我总结完连通性相关内容之后再回过头来补上,包括这个部分下面的负环和另一个板块欧拉回路部分也会之后回来补上。
图的连通性
关于 Tarjan 算法的一些概念
搜索树相关
在一张无向连通图中,任选一个节点出发进行 DFS,每个点只访问一次,所有发生递归的边 \((x,y)\) 构成了一棵树,这棵树就叫做搜索树。
对于一般的(非联通)无向图,DFS 一遍形成的是搜索森林。
时间戳
在 DFS 的过程中,按照每个节点第一次被访问的时间顺序,依次给图中的 \(n\) 个节点 \(1-n\) 的整数标记,该标记被称为时间戳,记为 \(dfn_x\)。
回溯值
定义
设 \(subtree(x)\) 表示无向图上搜索树中以 \(x\) 为根的子树。
\(x\) 的回溯值 \(low_x\) 定义为满足以下条件的节点的最小时间戳:
-
\(subtree(x)\) 中的节点;
-
通过一条不在搜索树上的边,能够达到的 \(subtree(x)\) 的节点。
求解步骤
假设计算的是节点 \(x\) 的回溯值 \(low_x\)。
-
初始化 \(low_x=dfn_x\);
-
扫描从 \(x\) 出发的每条边 \((x,y)\):
-
若 \((x,y)\) 是搜索树上边,令 \(low_x=\min(low_x,low_y)\);
-
若 \((x,y)\) 不是搜索树上边,则 \((x,y)\) 是返祖边,令 \(low_x=\min(low_x,dfn_y)\)。
-
割边/桥
定义
给定一张无向连通图 \(G=(V,E)\),对于一条边 \(e \in E\),若从图中删去边 \(e\) 后,\(G\) 分裂成两个不相连的子图,则称 \(e\) 为 \(G\) 的割边或桥。
求解
首先,割边一定是搜索树上的边,因为如果不是搜索树上的边,删边之后仍然存在生成树。
其次,对于一条搜索树上的边 \((x,y)\),\(x\) 是 \(y\) 的父亲。当且仅当 \(low_y>dfn_x\) 时,该边是 \(G\) 的割边。因为 \(y\) 无法通过搜索树以外的边到达除了他子树以外(\(x\) 或比 \(x\) 更早访问)的任何点。
代码实现
void dfs(int x,int pre)
{
dfn[x]=low[x]=++tot;
int ret=0;
for(auto nxt:edge[x])
{
int y=nxt.first,id=nxt.second;
if(!dfn[y])
{
dfs(y,id);
low[x]=min(low[x],low[y]);
if(low[y]>dfn[x])cout<<x<<"->"<<y<<"是割边"<<'\n';
}
else if(id!=pre)low[x]=min(low[x],dfn[y]);
}
return ;
}
割点
模板题目链接
定义
给定一张无向连通图 \(G=(V,E)\),对于一个节点 \(x \in V\),若从图中删去节点 \(x\) 以及所有与 \(x\) 相连的边后,\(G\) 分裂成两个或两个以上不相连的子图,则称 \(x\) 是 \(G\) 的割点。
求解
分类讨论。
-
\(x\) 不是搜索树根节点:当且仅当搜索树上存在 \(x\) 的一个子节点 \(y\),满足:\(low_y \ge dfn_x\);
-
\(x\) 是搜索树根节点:当且仅当搜索树上至少存在两个子节点 \(y_1,y_2\) 满足上述条件。
代码实现
int tot;
int dfn[maxn],low[maxn];
basic_string<pair<int,int> >edge[maxn];
basic_string<int>ans;
void dfs(int x,int pre)
{
dfn[x]=low[x]=++tot;
int ret=0;
for(auto nxt:edge[x])
{
int y=nxt.first,id=nxt.second;
if(!dfn[y])
{
dfs(y,id);
low[x]=min(low[x],low[y]);
if(low[y]>=dfn[x])ret++;
}
else if(id!=pre)low[x]=min(low[x],dfn[y]);
}
if(ret>=2||(ret==1&&pre!=0))ans+=x;
return ;
}
int main()
{
int n,m;
n=read();m=read();
for(int i=1;i<=m;i++)
{
int x,y;
x=read();y=read();
edge[x]+=mk(y,i);
edge[y]+=mk(x,i);
}
for(int i=1;i<=n;i++)
{
if(!dfn[i])dfs(i,0);
}
sort(ans.begin(),ans.end());
cout<<ans.size()<<'\n';
for(int i:ans)cout<<i<<" ";
cout<<'\n';
return 0;
}
例题
P3469 [POI2008]BLO-Blockade - 洛谷:有助于加深对割点本质的理解。
无向图的双连通分量
双连通图
定义
点双连通图:若一张无向连通图中不存在割点,则称之为点双联通图;
边双连通图:若一张无向连通图中不存在桥,则称之为边双联通图;
定理
-
一张无向连通图是点双连通图,当且仅当满足下列两个条件之一:
-
图的顶点数不超过 2;
-
图中任意两点都同时包含在至少一个简单环(即:不自交的环)中。
-
-
一张无向连通图是边双连通图,当且仅当满足任意一条边都包含在至少一个简单环中。
证明:
相比于结论,证明似乎就不是那么重要了(大雾),先咕着,其它东西整理完再说。。。
边双连通分量(e-DCC)
模板题链接
定义
无向图的极大边双连通子图被称为边双联通分量,简称:e-DCC。
求解
求出无向图中所有的桥,把桥都删除后,无向图会分成若干个连通块,每一个连通块就是一个边双连通分量。
具体实现上,可以先用 Tarjan 算法标记出所有的桥边。然后对于整个无向图进行一次 DFS,遍历过程中不访问桥边,划分出每个连通块。
代码实现
int dfn[maxn],low[maxn],vis[maxm],book[maxn],tot;
basic_string<pair<int,int> >edge[maxn];
void dfs(int x,int pre)
{
dfn[x]=low[x]=++tot;
int ret=0;
for(auto nxt:edge[x])
{
int y=nxt.first,id=nxt.second;
if(!dfn[y])
{
dfs(y,id);
low[x]=min(low[x],low[y]);
if(low[y]>dfn[x])vis[id]++;
}
else if(id!=pre)low[x]=min(low[x],dfn[y]);
}
}
void dfs2(int x,int pre)
{
book[x]++;
for(auto nxt:edge[x])
{
int y=nxt.first,id=nxt.second;
if(!vis[id]&&!book[y])dfs2(y,id);
}
}
int main()
{
int n,m;
n=read();m=read();
for(int i=1;i<=m;i++)
{
int x,y;
x=read();y=read();
edge[x]+=mk(y,i);
edge[y]+=mk(x,i);
}
for(int i=1;i<=n;i++)
{
if(!dfn[i])dfs(i,0);//第一个 dfs 用来求所有的桥。见上文求割边。
}
int ans=0;
for(int i=1;i<=n;i++)
{
if(!book[i])ans++,dfs2(i,0);//第二个 dfs 用来标记删掉桥边每个连通块上的点。
}
cout<<ans<<'\n';
return 0;
}
缩点
可以把每个 e-DCC 看做一个节点,把桥边 \((x,y)\) 看做连接 \(x\) 和 \(y\) 所在 e-DCC 的无向边,就会产生一棵树(在非连通无向图下,会产生森林)。
这就是 e-DCC 的缩点。
例题
[USACO06JAN]Redundant Paths G - 洛谷:从边双角度来看比较板,但难点并非边双。
点双连通分量(v-DCC)
模板题链接
定义
无向图的极大点双连通子图被称为点双连通分量。
误区
注意:点双连通分量不是单纯的“删除割点后图中剩余的连通块”。
求解
为了求出点双连通分量,需要在 Tarjan 算法的过程中维护一个栈,并按照如下的方法维护栈中元素:
-
当一个节点第一次被访问时,把该节点入栈;
-
当 \(low_y \ge dfn_x\) 成立时:
-
从栈顶不断弹出节点,直至节点 \(y\) 被弹出;
-
刚才弹出的所有节点与节点 \(x\) 一起构成一个 v-DCC。
-
代码实现
void dfs(int x)
{
dfn[x]=low[x]=++tot;
stk[++sz]=x;
if(x==rt&&edge[x].size()==0)
{
dcc[++cnt].push_back(x);
return ;
}
for(int i=edge[x].size()-1;i>=0;i--)//为什么要反着来啊。。。/kk
//for(int y:edge[x])
{
int y=edge[x][i];
if(!dfn[y])
{
dfs(y);
low[x]=min(low[x],low[y]);
if(low[y]>=dfn[x])
{
cnt++;
int z;
while(true)
{
z=stk[sz--];
dcc[cnt].push_back(z);
if(z==y)break;
}
dcc[cnt].push_back(x);
}
}
else low[x]=min(low[x],dfn[y]);
}
}
int main()
{
int n,m;
n=read();m=read();
for(int i=1;i<=m;i++)
{
int u,v;
u=read();v=read();
if(u==v)continue;
edge[u]+=v;
edge[v]+=u;
}
for(int i=1;i<=n;i++)
{
if(!dfn[i])rt=i,dfs(i);
}
for(int i=1;i<=cnt;i++)
{
for(int y:dcc[i])cout<<y<<" ";
cout<<'\n';
}
return 0;
}
傻逼题目鬼知道为什么反着跑一边就不挂了……
说明:是这样的,这道题的模板题非常有病。题目中说:
对于第i行,输出第i个点双连通分量的每个点。(顺序不分前后)
但是他妈的题目本身没写 SPJ 啊……所以你不管怎么写,顺序只要不是按照它给的标程(即链式前向星)的顺序那么你就一定会 WA。。。无语。。
TM 傻逼题浪费我一个晚上+早上一个小时。。。
缩点
由于目前还未遇到相关题目,所以打算等到我遇到相关题目后再来补充,那个时候理解应该更加深刻。
有向图的强连通分量
概念
强连通图
给定一张有向图,对于图中任意两点 \(x\) 和 \(y\),既存在 \(x\) 到 \(y\) 的路径,又存在 \(y\) 到 \(x\) 的路径,则称该图为强连通图。
强连通分量
有向图的极大强连通子图被称为强连通分量,简称:SCC。
做了一些缩点题目之后,发现对于强联通分量/图而言,其运用最广泛和最关键的性质就在于强联通分量/图中的两点间可以相互到达,这是极其关键的。
强连通分量求解
和无向图一样,求解有向图的强连通分量时,也应该先计算回溯值 \(low_x\)。
求解步骤:
-
当节点 \(x\) 第一次被访问时,把 \(x\) 入栈,初始化 \(low_x=dfn_x\);
-
扫描从 \(x\) 出发的每条边 \((x,y)\):
-
若 \(y\) 未被访问过,则说明 \((x,y)\) 是搜索树上边,递归访问 \(y\),从 \(y\) 回溯后,令 \(low_x=\min(low_x,low_y)\);
-
若 \(y\) 被访问过且 \(y\) 在栈中,则令 \(low_x=\min(low_x,low_y)\)。
-
-
从 \(x\) 回溯前,判断是否有 \(dfn_x=low_x\),若成立,则不断从栈中弹出节点,直至 \(x\) 出栈。
在求解 \(low_x\) 的过程中,若 \(dfn_x=low_x\),则栈中从 \(x\) 到栈顶的所有节点构成了一个强连通分量。
我们可以用一个数组 \(col_x\) 表示 \(x\) 在哪个强连通分量中。
缩点
求解过程
与无向图 e-DCC 的缩点类似,我们可以把每个 SCC 看做一个点。
对原图中的每条有向边 \((x,y)\),若 \(col_x \ne col_y\),则连一条从 \(col_x\) 到 \(col_y\) 的边。
最后我们会得到一张 DAG。
可能在一些题目中,会与拓扑排序,DP 结合。
关于模板题
这里专门增加一些对模板题的说明。
因为其实这道题目并非单纯的真正的模板,实际上它是和 DP 相结合。
并且由于刚开始并没有吃透这道模板题,导致我后面的抢掠计划卡了好一阵子。我真菜。
题意:求图上经过点权和最大的路径。允许多次经过一条边或者一个点,但是,重复经过的点,权值只计算一次。
首先由于可以经过一条边或一个点多次,所以我们可以把整张图缩点为若干个不同的强联通分量(满足任意两点间可以互相到达)。
缩完点后,问题就转化为:
已知一个 DAG,每个点有点权,求一条路径使得路径经过点权和最大。
那么我们可以对每个点 dfs 一遍,然后求出以每个点为起点的点权和最大值。(而在这道题中,由于起点已知,我们只需要从起点开始 dfs 就行了。)
dfs 的过程中,维护一个数组 \(dp_x\) 表示以 \(x\) 作为起点所能经过的最大点权和是多少。
那么有:
代码实现
//感觉上面已经讲得很清楚了,所以这里就不注释了。/kk
void dfs(int x)
{
dfn[x]=low[x]=++tot;
stk[++sz]=x;vis[x]++;
for(int y:edge[x])
{
if(!dfn[y])//bug2:dfn[x]
{
dfs(y);
low[x]=min(low[x],low[y]);
}
else if(vis[y]) low[x]=min(low[x],dfn[y]);
}
if(dfn[x]==low[x])
{
cnt++;
while(true)
{
int t=stk[sz--];
vis[t]=0;
col[t]=cnt;
val[cnt]+=a[t];
if(t==x)break;
}
}
}
void dfs2(int x)
{
if(book[x])return ;
book[x]++;dp[x]=val[x];
for(int y:edge2[x])
{
dfs(y);
dp[x]=max(dp[x],dp[y]+val[x]);
}
}
int main()
{
int n,m;
n=read();m=read();
for(int i=1;i<=n;i++)a[i]=read();
for(int i=1;i<=m;i++)
{
int u,v;
u=read();v=read();
edge[u]+=v;
}
//cout<<n<<endl;
for(int i=1;i<=n;i++)
{
if(!dfn[i])dfs(i);
}
//cout<<n<<endl;
for(int i=1;i<=n;i++)
{
for(int j:edge[i])
{
if(col[i]!=col[j])edge2[col[i]]+=col[j];//bug:edge2[i]+=j;
}
}
int ans=0;
for(int i=1;i<=cnt;i++)dfs2(i);
for(int i=1;i<=cnt;i++)ans=max(ans,dp[i]);
cout<<ans<<'\n';
return 0;
}
例题
P2341 [USACO03FALL / HAOI2006] 受欢迎的牛 G - 洛谷
P2746 [USACO5.3]校园网Network of Schools - 洛谷:比较板的一道题,双倍经验:加强版
P3627 [APIO2009]抢掠计划 - 洛谷:模板题的变形,缩点与图上 DP 相结合,非常好的一道练手题。
P2515 [HAOI2010]软件安装 - 洛谷:缩点+树形 DP。
ABC #256 E:有向图上大小大于 1 的连通块中最小点权之和。
P2656 采蘑菇 - 洛谷:缩点+图论 DP(最短路)好题。
一些经验
在遇到以下类似题目特征时,我们应该考虑到缩点:
-
\(A\) 依赖于 \(B\)/\(A\) 必须在 \(B\) 之前;
-
图上一个点或一条边可以经过多次;
-
只要给 \(A\) xxx,\(B\) 才能 xxx。
说的相对抽象,具体还是要自己做题才能体会到。
参考资料
网络资料
纸质资料
算法竞赛进阶指南 - 0x60 图论
深入浅出程序设计竞赛进阶篇(书稿) - 图论
图论基础 - aqx.pptx
(以上参考资料按总结顺序排序。)
后记
post on 2022.6.21:
其实我早在一周前这个东西已经基本完工了,今天又补充了一些图的连通性相关例题以及一些新的体会。
到现在为止,还有差分约束系统,负环,以及欧拉回路的部分没有整理总结完毕,其它板块均已完结。
莫名其妙变成了有史以来我写过的最长的总结——9300 多字。
图论还是蛮有意思的诶。(其实是 OI 有意思,只要是 OI 不管是啥板块都挺有意思的。)
后面会考虑把其它没总结完的部分补完。(所以字数可能会破万……)
upd on 2022.9.26:
已经破万了。(在加入了分层图部分的总结之后)
To be continued……