[OI] 图论
图论专题总结
Author : HaneDaniko
Contest
- 二叉树遍历
- 欧拉路和欧拉回路
- 最短路
- 判断负环
- 判断最小环
- 最小生成树
- 拓扑排序
- 并查集
- 分层图和分层图最短路
- tarjan
- dfs序
- 树上公共祖先
- 差分约束
- 二分图
- 扫描线
1. 二叉树遍历
二叉树分为前序遍历,中序遍历与后序遍历. 一般来说,它们的不同仅在根节点的输出先后: 遇到就输出根节点的为前序,先遍历完左子树再输出的为中序,全部遍历完成再输出的为后序.
一段求遍历序列并输出的代码如下.
点击查看代码
void TreeSec(int root,int type){ //1=xianxu 2=zhongxu 3=houxu
if(type==1){
cout<<a[root].id;
}
if(a[root].lc!=0){
TreeSec(a[root].lc,type);
}
if(type==2){
cout<<a[root].id;
}
if(a[root].rc!=0){
TreeSec(a[root].rc,type);
}
if(type==3){
cout<<a[root].id;
}
return;
}
顺序 : A 左孩子 B 右孩子 C
ABC 分别对应前中后
2. 欧拉路
欧拉路为走一遍能不重复地走过全部边的图,若欧拉路的起点与终点相同,那么就叫欧拉回路. 是欧拉路的条件为:所有的点中,入边与出边为奇数的点有且仅有两个. 是欧拉回路的条件为:所有的点中,没有入边与出边为奇数的点.
欧拉路:从奇点开始DFS
欧拉回路:从任意点开始DFS
DFS求欧拉路
void NodeSec(int begin){
cout<<begin<<endl;//输出路径
for(int i=1;i<=n;++i){
if(e[begin][i]!=0){
e[begin][i]=0;
e[i][begin]=0;
NodeSec(i);
}
}
}
判断:遍历完后不存在没遍历的点 (开vis)
3. 最短路
3.1 FLOYED
复杂度:\(N^{3}\).
枚举中间点反复更新最短路
只能用邻接矩阵
Floyed
//prework:
for(int i=1;i<=m;++i){
for(int j=1;j<=m;++j){
if(node[i][j]==0){
node[i][j]=100000;
}
}
}
//
for(k 1-n){
for(i 1-n){
for(j 1-n){
if(w[i][j]>w[i][k]+w[k][j]){
w[i][j]=w[i][k]+w[k][j];
}
}
}
}
3.2 DIJKSTRA
朴素复杂度:\(N^{2}\).
单调队列复杂度:\(N\ log_{2}N\).
1.在没选过的点里选最近的并标记
2.松弛邻接点
3.松弛后的点入队
Dijkstra
void dij(int s){
//prework:
for(int i=1;i<=n;++i){
vis[i]=false;
if(i==s){
dis[i]=0;
}
else{
dis[i]=0x3f3f3f3f;
}
q.push(node{i,dis[i]});
}
//!!!!!!!!!!!OR THERES ANOTHER WAYS
/*
memset(vis,0,sizeof(vis));
memset(dis,0x3f,sizeof(dis));
dis[s]=0;
q.push(node{s,dis[s]});
*/
//
while(!q.empty()){
node u=q.top();
q.pop();
if(vis[u.id]){
continue;
}
vis[u.id]=true;
for(edge i:e[u.id]){
if(dis[u.id]+i.w<dis[i.to]){
dis[i.to]=dis[u.id]+i.w;
q.push(node{i.to,dis[i.to]});
}
}
}
}
3.3 SPFA
BF复杂度:\(MN\).
队列优化复杂度:$ \Theta (kn,mn)$.
BF 写法主要用于判断负环,详见 关于最小环和负权环
1.从队列中取数
2.松弛邻接边
3.没入队的松弛边入队
Spfa
void spfa(int s){
//prework:
memset(dis,0x3f,sizeof(dis));
dis[s]=0;
q.push(s);
vis[s]=true; //是否在队列里
while(!q.empty()){
int x=q.front();
q.pop();
vis[x]=false;
for(edge i:e[x]){
if(dis[i.to]>dis[x]+i.w){
dis[i.to]=dis[x]+i.w;
if(!vis[i.to]){
q.push(i.to);
vis[i.to]=true;
}
}
}
}
}
4.判断负环
4.1 BFS - 基于 SPFA
进行一次SPFA
存在点的入队次数大于n,则存在负环 (开一个cnt)
这种办法十分的慢,而且它死了。但是遇到输出路径的问题还是优先选择这个方法,
因为它可以在判环的同时求最短路
4.2 DFS - 基于递归SPFA
如果能松弛一个还在DFS中的点,说明存在环
4.2 实现
//DFS
bool spfa(int s){
vis[s]=true;
for(edge i:e[s]){
if(dis[i.to]>dis[s]+i.w){
if(vis[i.to]){
return true;
}
dis[i.to]>dis[s]+i.w;
if(spfa(i.to)==true){
reutrn true;
}
}
}
vis[s]=false;
return false;
}
5.判断最小环
5.1 TOPO判最小环
拓扑排序一遍之后就剩环了,所以可以遍历环,搜索每个还有
出度入度的点所在环
但是虽然拓扑排序找存在环很容易,遍历环却很难,因此不推荐使用.
5.2 FLOYED判无向图最小环
假设存在最小环,那么FLOYED的i,j,k可以将它分成三个部分,
环中的最大点为k, 那么ans=dis[i][j]+w[i][k]+w[j][k];
5.3 SPFA判有向图最小环
SPFA死了,不讲。不过BFS的SPFA用来判存在环还挺好用的,
写个深搜,然后开个vis就行。要是让我写存在环我就用SPFA.
5.4 并查集求最小环
并查集需要开size数组,假如合并的时候遇到了已经在相同
集合内的数,那么此时的size就是环的大小。
思路二实现
//2
//FLOYED的最小环写法稍微有点变动:
int MinCost=inf;
for(int k=1;k<=n;k++){
for(int i=1;i<k;i++){
for(int j=i+1;j<k;j++){
MinCost = min(MinCost,dis[i][j]+mp[i][k]+mp[k][j]);
}
}
for(int i=1;i<=n;i++){
for(int j=1;j<=n;j++){
dis[i][j]=min(dis[i][j],dis[i][k]+dis[k][j]);
}
}
}
/*
每次更新最小环以后都要更新一遍DIS值,主要是防止走回去。
*/
间章 关于最小环与负权环
最小环-Floyed
< \(O(N^{3})\) (\(N\)为节点数)>
关于Floyed能用于求解最小环的证明
对于一个环,我们总可以在环上找到任意三个点 \(i,j,k\) . 这三个点会把一个环分成三部分
我们设从 \(x\) 到 \(y\) 的最短距离为 \(dis[i][j]\),那么环的长度可以由这三部分的长度相加,即
显然,对于有向图,我们也有
这恰好就是Floyed的写法,而上述式子便是松弛方程.
但是有一点不太一样,在进行遍历时,一个点 \(k\) 可以进行松弛的点只能比他本身小,否则 \(dis[i][j]\) 将不是一个确定的值,且对于有向图来说,为了不重复遍历,其中一个点的值不应比另一个值要小,即假如有 \(i=2,j=3\) 那么接下来的遍历不应有 \(i=3,j=2\) .很显然,假如存在最小环,这样做纯粹在浪费时间.
另一点需要注意的是,这里可能代码里可能会出现 \(inf+inf+inf\) 的情况,所以 \(inf\) 的取值不宜太大.
代码实现
点击查看代码
int Floyd(int n){
int mc=inf;
for(int k=0;k<=n;++k){
for(int i=0;i<k;++i){
for(int j=i+1;j<k;++j){
mc=min(mc,e[i][k]+e[k][j]+dis[i][j]);
}
}
for(int i=0; i<=n;++i){
for(int j=0;j<=n;++j){
dis[i][j]=min(dis[i][j],dis[i][k]+dis[k][j]);
}
}
}
return mc;
}
下面给出了存图和初始化的示例
点击查看代码
for(int i=0;i<=n;++i){
for(int j=0;j<=n;++j){
if(i==j){
w[i][j]=dis[i][j]=0;
}
else{
w[i][j]=dis[i][j]=inf;
}
}
}
for(int i=0;i<m;++i){
cin>>u>>v>>y;
w[u][v]=dis[u][v]=y;
w[v][u]=dis[v][u]=y;
}
负权环-SPFA
< $ \Theta (kn,mn)$ (\(N\)为节点数,\(k\)为很小的常数,\(M\)为边数)>
关于SPFA
SPFA 我觉得和Dijkstra还挺像的 ,但是我背不过 ,所以简单写一下
很显然,只有上一次被松弛的结点,所连接的边,才有可能引起下一次的松弛操作。
那么我们用队列来维护「哪些结点可能会引起松弛操作」,就能只访问必要的边了。
SPFA的简单步骤大致如下 (\(Node\) 表示点的数量):
- 需要的变量 : 存图变量,\(dis[Node]\) <用于存储起点到各点的距离>,\(vis[Node]\) <用于记录节点是否在队列里> 和一个队列.
- 初始化 : \(dis\) 数组需要全部初始化为一个很大的值 (大于最大边权,小于1,073,741,823,否则两个数相加会超 \(int\) 范围变成负的,假如你用 \(memset\) ,推荐使用“\(0x3f\)”将其初始化为 \(0x3f3f3f3f\)) ,如果需要重复使用,建议初始化 \(vis\) .
- 起点的 \(dis\) 设为0, \(vis\)设为1,然后放入队列 .
- 随后,每次从队列中拿出一个值,将其 \(vis\) 修改为 0,对其所有的边进行松弛操作,如果成功松弛的边不在队列里 (\(vis\) 就是在这里用的),那么入队,\(vis\) 修改为1.
这里对SPFA和SPFA能用于判断负环的正确性不再说明.
SPFA判断负环的方法
假如我们有 \(n\) 个点,那么显然最短路经过的点没办法超过 \(n\) 个,假如超过 \(n\) 个了说明一定重复走了,所以存在负环
所以我们需要想办法记录当前最短路经过的节点数
我们只需要加上这么两句 (假设 \(i\) 刚刚松弛了 \(j\)):
cont[j]=cont[i]+1;
if(cont[j]=>n) return false;
其中 \(cont\) 是我们新开的数组,初始化为 0.
代码实现
点击查看代码
bool spfa(int s){
for(int i=1;i<=n;++i){
dis[i]=1000000000;
vis[i]=false;
cont[i]=0;
}
dis[s]=0;
vis[s]=true;
q.push(s);
cont[s]++; //这别忘了
while(!q.empty()){
int u=q.front();
q.pop();
vis[u]=false;
for(edge ed:e[u]){
int to=ed.to,w=ed.w;
if(dis[to]>dis[u]+w){
dis[to]=dis[u]+w;
if(!vis[to]){
q.push(to);
cont[to]++; //注意,这一段一定要在if里面
if(cont[to]>=n){
return true; //有负权环
}
vis[to]=true;
}
}
}
}
return false;
}
例题 P2850 [USACO06DEC] Wormholes G
点击查看代码
#include<bits/stdc++.h>
using namespace std;
struct edge{
int to,w;
};
vector<edge> e[501];
int dis[501],cont[501];
bool vis[501];
queue<int> q;
int f,n,m,w;
bool spfa(int s){
for(int i=1;i<=n;++i){
dis[i]=1000000000;
vis[i]=false;
cont[i]=0;
}
dis[s]=0;
vis[s]=true;
q.push(s);
cont[s]++;
while(!q.empty()){
int u=q.front();
q.pop();
vis[u]=false;
for(edge ed:e[u]){
int to=ed.to,w=ed.w;
if(dis[to]>dis[u]+w){
dis[to]=dis[u]+w;
if(!vis[to]){
q.push(to);
cont[to]++;
if(cont[to]>=n){
return false;
}
vis[to]=true;
}
}
}
}
return true;
}
int main(){
cin>>f;
for(int k=1;k<=f;++k){
for(int i=1;i<=500;++i){
e[i].clear();
}
cin>>n>>m>>w;
for(int i=1;i<=m;++i){
int x,y,z;
cin>>x>>y>>z;
e[x].push_back(edge{y,z});
e[y].push_back(edge{x,z});
}
for(int i=1;i<=w;++i){
int x,y,z;
cin>>x>>y>>z;
e[x].push_back(edge{y,-1*z});
}
bool flag=false;
for(int i=1;i<=n;++i){
if(spfa(i)==false){
cout<<"YES"<<endl;
flag=true;
break;
}
}
if(flag==false){
cout<<"NO"<<endl;
}
}
}
负权环-BellmanFord
< \(O(mn)\) (\(N\)为节点数,\(M\)为边数)>
当然,SPFA的代码不是很好背,但是我们可以用伟大的Bellman-Ford,这两种算法起码对菊花图来说一样快,思路十分简单:遍历 \(n-1\) 次每条边并松弛,如果还能松弛那么就有负权环 .
代码实现
点击查看代码
bool bf(){
memset(dis,0x3f,sizeof(dis));
dis[1]=0;
for(int k=1;k<=n;++k){
for(edge i:e){ //把所有边扔到结构体vector e里
if(dis[i.to]>dis[i.start]+i.w){
dis[i.to]=dis[i.start]+i.w;
}
}
}
for(edge i:e){
if(dis[i.to]>dis[i.start]+i.w){
return true; //有负权环
}
}
return false;
}
例题(见上SPFA)
点击查看代码
#include<bits/stdc++.h>
using namespace std;
struct edge{
int start,to,w;
};
vector<edge> e;
int dis[501];
int f,n,m,w;
bool bf(){
memset(dis,0x3f,sizeof(dis));
dis[1]=0;
for(int k=1;k<=n;++k){
for(edge i:e){
if(dis[i.to]>dis[i.start]+i.w){
dis[i.to]=dis[i.start]+i.w;
}
}
}
for(edge i:e){
if(dis[i.to]>dis[i.start]+i.w){
return true;
}
}
return false;
}
int main(){
cin>>f;
for(int k=1;k<=f;++k){
e.clear();
cin>>n>>m>>w;
for(int i=1;i<=m;++i){
int x,y,z;
cin>>x>>y>>z;
e.push_back(edge{x,y,z});
e.push_back(edge{y,x,z});
}
for(int i=1;i<=w;++i){
int x,y,z;
cin>>x>>y>>z;
e.push_back(edge{x,y,-z});
}
if(bf()==false){
cout<<"NO"<<endl;
}
else{
cout<<"YES"<<endl;
}
}
}
6.最小生成树
6.1 PRIM
PRIM 算法类似 DIJKSTRA. 基础步骤如下:
- 计算出每个点被选入的花费 \(dis\).
- 找到 \(dis\) 最小的未标记的点标记,表示入树,并松弛相邻的未标记点.
- 重复上述步骤.
Prim
int prim(int s){
int mst=0;
for(int i=1;i<=n;++i){
dis[i]=m[s][i];
}
dis[s]=0;
for(int i=1;i<=n-1;++i){
int mindis=0x7fffffff,minid;
for(int j=1;j<=n;++j){
if(dis[j]!=0&&dis[j]<mindis){
mindis=dis[j];
minid=j;
}
}
mst+=mindis;
dis[minid]=0;
for(int j=1;j<=n;++j){
if(dis[j]>m[minid][j]){
dis[j]=m[minid][j];
}
}
}
return mst;
}
6.2 KLUSKAL
KLUSKAL 算法是一个基于合并思想的算法,它的基本思路如下:
- 将所有边从小到大排序.
- 从小到大枚举所有边,若加入这条边不会构成回路,那么就将这条边加入树中,即合并这条边左右的两棵子树.
基于并查集的思想,我们可以对其进行实现:
- 将所有边从小到大排序.
- 从小到大枚举所有边,若这条边连接的端点与当前树不在同一个并查集,那么就合并这条边左右的两个并查集.
Kluskal
sort(a+1,a+anow+1);
for(int i=1;i<=anow;++i){
if(!ifconnect(a[i].from,a[i].to)){
connect(a[i].from,a[i].to);
tot+=a[i].w;
}
}
cout<<tot;
7.拓扑排序
7.1 拓扑排序简介
拓扑排序是针对有向无环图的,所谓有向无环图即为没有环路的有向图,这样的图必定会有一个或几个没有入边的节点与没有出边的节点. 拓扑排序就是一种对有向无环图的遍历,它保证每个点在访问时,指向这个节点的节点一定已经访问过. 我们可以如下来实现:
- 记录每条边的入度.
- 寻找入读为 \(0\) 的点并访问,将该点指向的点的入度减一.
- 重复上述步骤.
注意到该过程可用与 DIJKSTRA 算法类似的优先队列优化. 但实际上,如果一个点初始不为 \(0\),那么仅有被更新过,它才有可能入度为 \(0\),因此我们采用类似 SPFA 算法的队列实现:
实现
cin>>n;
for(int i=1;i<=n;++i){
int x;
while(1){
cin>>x;
if(x==0){
break;
}
to[i].push_back(x); //记录自身指向的节点
cont[x]++; //记录入度
}
}
for(int i=1;i<=n;++i){
if(cont[i]==0){
q.push(i);
}
}
while(!q.empty()){
int u=q.front();
q.pop();
cout<<u<<" ";
for(int i:to[u]){
cont[i]--;
if(cont[i]==0){
q.push(i);
}
}
}
7.2 拓扑排序的用途:判断回路
因为拓扑排序仅针对有向无环图,所以,假如对一个存在环路的图进行拓扑排序,那么就会剩下环. 此时我们记录一个总遍历数 \(tot\),假如 \(tot \neq n\) 那么就一定存在环.
8.并查集
并查集算作一个森林,实际上相当于一个集合. 支持快速进行 合并,查询 操作. 原理为 父亲相同的节点在同一个集合内.
-
初始,每个节点即为自己的父亲.
-
合并 \(a,b\) 及所属集合, 那么将 \(a\) 的根节点的父亲改成 \(b\),即把整棵树挪过来并接到后面.
-
查询根节点时,采用"根节点的父亲为自己"与"父亲节点的根节点为自己的根节点"的思想递归寻找,并在回溯时将这个点的父亲直接改为子树根节点,方便以后查询.
代码
int find(int id){
if(father[id]!=id){
father[id]=find(father[id]);
}
return father[id];
}
bool ifconnect(int x,int y){
if(find(x)==find(y)){
return true;
}
return false;
}
void connect(int x,int y){
if(!ifconnect(x,y)){
father[find(x)]=y;
}
}
9.分层图和分层图最短路
详见 分层图最短路.
9.1 什么是分层图最短路
分层图是指有很多个平行的图,各个平行的图之间有特殊的连接边。
分层图最短路,是在原有的求图中某两个点之间的最短路基础上增加一个条件——可以选择 \(k\) 条路,将它们的路径长度变为零(或者其他操作,常见的还是变为0,其他的我们暂不考虑),这样即可通过缩短路径长度,找出在该条件下的最短路。
9.2 为什么它叫分层图最短路
一般来说,因为我们并不知道一条边究竟被修改了还是没有,因此不如赋予它两个权值,像这样 同一条边同时有两种及以上不能同时选择的权值 我们就把这几条边看作是在不同的“图层”,这样我们就可以通过一些特殊变形来跑最短路了.
9.3 构建分层图最短路
首先,分层图要有“图层”,因此我们需要根据题意来赋予每个图层特殊的意义.
一般来说,我们有以下定义:
当对图进行 \(k\) 次操作时,分层图的图层有 \(k+1\) 层,我们设边 \(<i,j,u,v,w>\) 为从 \(u\) 到 \(v\) ,权值为 \(w\) 的有向边,且它在图层 \(i\) 到图层 \(j\) 的交界上.
那么有:
边 \(<i,i,u,v,w>\) 表示已进行了 \(i\) 次操作,且不改建当前道路,由u向v耗费w.
边 \(<i,i+1,u,v,0>\) 表示在 \(u\) 点进行一次操作,进入下一层,将该路上的 \(w[u][v]\) 变为 0 (或者其他条件).
所以,我们需要连的边就变成了:
- 每个“图层”内部正常连边
- 假如存在边 \(<i,i,u,v,w>\),那么连接边 \(<i,i+1,u,v,0>\) (前提是 \(i<k+1\) ).
大概像下面这样:
建好图后,我们再跑最短路算法就没有问题了.
10.TARJAN
10.1 强连通分量与DFS序边的分类
DFS序即为字典序最小的深度优先搜索
-
强连通:有向图中两点都能到达对方.
-
强连通分量:有向图中任意两点都强连通的子图. 单个点也为一个强连通分量.
-
边的分类
-
树边 \(tree\) : 该边指向一个未访问过的节点
-
返祖边 \(back\) : 该边指向自己的父亲节点 (DFS序的特性会保证这个点的父节点总是已经访问)
-
横叉边 \(cross\) : 该边指向一个已访问节点,但不是自己的父节点.
-
前向边 \(forward\) : 该边指向一个已访问节点,并且这个点属于自己的子树.
10.2 TARJAN算法对不同边的判断方式
Tarjan引入了 \(dfn[i],low[i]\) 两个变量用于判断边的类型. \(dfn[i]\) 表示该节点的访问次序,\(low[i]\) 表示子树中能回溯到的所有点中的最小 \(dfn\) 值,且这个点不应已属于其他强连通分量. 即取 \(dfn\) 的值可以是子树中的节点,也可以是子树中的节点通过 \(back,cross\) 访问到的节点.
根据定义,可以得出:
-
一个结点的子树内结点的 \(dfn\) 都大于该结点的 \(dfn\).
-
从根开始的一条路径上的 \(dfn\) 严格递增,\(low\) 严格非降.
\(tree\) : \(dfn[to]=0\).
\(back\) : \(vis[to]=true\).
10.3 TARJAN算法
Tarjan 算法用来搜索图中的全部强连通分量. 其步骤大致如下:
-
按DFS序从根节点搜索图. 或者从超级原点开始.
-
遇到未被访问的节点,那么递归搜索,返回后根据该节点的 \(dfn\) 值更新源节点的 \(dfn\) 值. 因为存在直接路径,所以子节点能够回溯到的未构成强连通分量的结点,源节点也一定能够回溯到.
-
遇到被访问过的未构成强连通分量的节点, 符合 \(low\) 包括的节点定义,那么直接用该点 \(dfn\) 更新源节点的 \(low\).
-
遇到被访问过的已构成强连通分量的节点,该点已处理,无用处,跳过.
-
该点DFS全部结束后,只需判断 \(dfn\) 和 \(low\) 是否相等. 因为在同一个强连通分量内,\(low\) 的值必然相等,而 \(dfn\) 和 \(low\) 相等的点即为该分量内第一个被访问到的节点. 从这个点开始按顺序寻找 \(low\) 与其相等的点即可.
我们可能还会引入一些其他的变量来存储结果,如 \(belong\) 存储每个点属于哪一个分量. \(size\) 存储每个分量的大小等等.
而对于如何判断一个点已经属于某个强连通分量的问题,Tarjan 算法引入了一个栈,进行如下表示:
-
对已访问过的节点立即入栈.
-
寻找到的强连通分量中的每个节点出栈.
-
一个点已被访问且不在栈中,那么它已经属于某个强连通分量. 若在栈中则尚未属于任何强连通分量.
略叙
- 入栈,标记,时间戳
- 搜子节点,未搜过 (\(dfn=0\)) 则搜,更新
- 已搜过且标记则更新
- 全部搜完判断相等弹栈
伪代码
TARJAN_SEARCH(int u)
vis[u]=true
low[u]=dfn[u]=++dfncnt
push u to the stack
for each (u,v) then do
if v hasn't been searched then
TARJAN_SEARCH(v) // 搜索
low[u]=min(low[u],low[v]) // 回溯
else if v has been in the stack then
low[u]=min(low[u],dfn[v])
完整代码
//vis 数组表示一个节点在不在栈内
void pbond(int s){
dfn[s]=low[s]=++cnt;
st.push(s);
vis[s]=true;
for(edge i:e[s]){
if(!dfn[i.to]){
pbond(i.to);
low[s]=min(low[s],low[i.to]);
}
else if(vis[i.to]){
low[s]=min(low[s],dfn[i.to]);
}
}
if(low[s]==dfn[s]){
int y;
++tot;
do{
y=st.top();
st.pop();
vis[y]=false;
}while(y!=s);
}
}
10.4 缩点
缩点是利用TARJAN将一个图中的每个强连通分量都看作一个点,构造一个有向无环图, 这样在处理其他问题时会更方便. 在缩点时,TARJAN算法需要维护一个 \(belong\) 数组,必要时建一个新图.
另外,一个入度为零的点的判断条件为当前其 \(dfn =0\).
有向无环图的搜索常用拓扑排序.
下面给出一份缩点维护出度的代码 (类似可用于拓扑排序):
代码
//维护belong size的tarjan
void tarjan(int s){
dfn[s]=low[s]=++cnt;
st.push(s);
vis[s]=true;
for(int i:e[s]){
if(!dfn[i]){
tarjan(i);
low[s]=min(low[s],low[i]);
}
else if(vis[i]){
low[s]=min(low[s],dfn[i]);
}
}
if(low[s]==dfn[s]){
int y;
++tot;
do{
y=st.top();
size[tot]++;
st.pop();
belong[y]=tot;
vis[y]=false;
}while(y!=s);
}
}
//对全部入度为零的点跑tarjan,也可以建超级原点处理
for(int i=1;i<=n;++i){
if(!dfn[i]){
cnt=0;
tarjan(i);
}
}
//维护出度 对一个点贡献出度的点一定是当前点的邻点
for(int i=1;i<=n;++i){
for(int j:e[i]){
if(belong[i]!=belong[j]){
outdegree[belong[i]]++;
}
}
}
实际上建图操作也很类似,这里以拓扑建图示例:
示例
void build(){
for(int i=1;i<=n;++i){
for(edge j:e1[i]){
if(belong[i]!=belong[j.to]){
e2[belong[i]].push_back(edge{belong[j.to]});
indegree[belong[j.to]]++;
}
}
}
}
10.5 无向图上的TARJAN
割点 : 去掉这个点,整个图分成两部分
割边 : 去掉这个边,整个图分成两部分
点双连通分量 : 不存在割点的分量
点双性质:
- 点双内若没有重边,任意两点间都存在至少两条不重点的路径
- 任意一个割点都在至少两个点双中
- 任意一个不是割点的点都只存在于一个点双中
边双连通分量 : 不存在割边的分量
边双性质:
- 割边不属于任意边双,而其它非割边的边都属于且仅属于一个边双
- 边双内任意两点之间都有至少两条不重边的路径
(注意这里的割点,割边相对于分量图而言,而不是全图)
10.5.1 TARJAN求割点
根据定义,求割点的关键在于判断当前点是其子节点到达其父节点的唯一路径.
根据TARJAN的特性,递归回溯到当前节点时,其子节点一定回溯完毕,而父节点的 \(dfn\) 值又永远小于当前节点. 因此: 如果全部子节点的 \(low\) 值均不小于当前节点的 \(dfn\) 值,可以判定当前点是其子节点到达其父节点的唯一路径,即为割点. 用变量表达为 \(dfn[s] \le low[i]\).
略叙
记 \(son\) , 遍历回溯判断此戳小等彼 \(low\). 若非根或多子则为割点.
割点代码
void tarjan(int s){
dfn[s]=low[s]=++cnt;
int son=0; //用处见下
for(int i:e[s]){
if(!dfn[i]){
son++;
tarjan(i);
low[s]=min(low[s],low[i]);
if(dfn[s]<=low[i]){
if(now!=root||son>1){ //不认为开始遍历的根节点能构成割点,除非其子节点数大于1
cutpoint[s]=true; //记录割点
}
}
}
else{ //没有栈了,所以不用开vis,这里可以直接else
low[s]=min(low[s],dfn[i]);
}
}
}
10.5.2 TARJAN求割边
Tarjan求割边有 \(dfn_{u} < low_{v},low_{v}=dfn_{v}\) 两种方法,这里仅概述第一种
可以发现此式与割点求法极为类似,只是少了一个等于号,这是因为割点子节点的 \(back\) 即使连到割点上也可以成立,但是割边以边为单位,显然没办法这么连.
割边因为要判断父边和重边了,所以使用链式前向星更为实用.
对于判断父边与重边. 父边的判断通常借助一个标记数组. 假如访问到的节点还没被访问过,那么访问使用的边即为其父边. 无向图中起点与终点恰好相反的一对边为重边,走重边会导致死循环. 我们可以借助 \(2n\ xor\ 1=2n+1 ,2n+1\ xor\ 1=2n\) 这样的性质来通过编号判断重边.
方法一略叙
先记前驱,回溯判断此戳小于彼 \(low\),即为割边. 下更新要 \(\neq root\).
方法一
void add(int from,int to){
id[++enow]=to;
nxt[enow]=head[from];
head[from]=enow;
}
void tarjan(int s,int root){
dfn[s]=low[s]=++cnt;
for(int i=head[s];i;i=nxt[i]){
int y=id[i];
if(!dfn[id[i]]){
tarjan(id[i],i);
low[s]=min(low[s],low[id[i]]);
if(low[id[i]]>dfn[s]){
cutline[i]=cutline[i^1]=true;
}
}
else if(i!=root^1){
low[s]=min(low[s],dfn[id[i]]);
}
}
}
Vector形式的方法一
//bridge is (pre[i],i) when cutline[i]=true
void tarjan(int s,int root){
pre[s]=root;
low[s]=dfn[s]=++cnt;
for(int i:e[s]){
if(!dfn[i]){
tarjan(i,s);
low[s]=min(low[s],low[i]);
if(low[i]>dfn[s]){
cutline[i]=true;
}
}
else if(dfn[i]<dfn[s]&&i!=root){
low[s]=min(low[s],dfn[i]);
}
}
}
方法二
void add(int from,int to){
enow++;
e[enow].to=to;
e[enow].nxt=head[from];
head[from]=enow;
}
void tarjan(int s,int root){
low[s]=dfn[s]=++cnt;
for(int i=head[s];i;i=e[i].nxt){
if(e[i].to==root){
continue;
}
if(!dfn[e[i].to]){
tarjan(e[i].to,s);
low[s]=min(low[s],low[e[i].to]);
if(low[e[i].to]>dfn[s]){
ans++;
}
}
else{
low[s]=min(low[s],dfn[e[i].to]);
}
}
}
方法二的另一种实现
void tarjan(int s,int root){
low[s]=dfn[s]=++cnt;
st.push(s);
for(int i=head[s];i;i=e[i].nxt){
if(!dfn[e[i].to]){
tarjan(e[i].to,i^1);
low[s]=min(low[s],low[e[i].to]);
if(low[e[i].to]>dfn[s]){
e[i].iscutline=e[i^1].iscutline=true;
}
}
else if(i^root){
low[s]=min(low[s],dfn[e[i].to]);
}
}
if(low[s]==dfn[s]){
int y;
tot++;
do{
y=st.top();
st.pop();
belong[y]=tot;
}while(y!=s);
}
}
10.5.3 无向图缩点
无向图中每个边双连通分量都可以看作是一个点,那么我们可以通过类似缩点的取代方法让无向图变为一个无向无环图,这样更方便我们处理.
方法一维护缩点后入度的代码
for(int i=2;i<=enow;++i){
if(e[i].iscutline){
indegree[belong[e[i].to]]++;
}
}
10.5.4 TARJAN求点双连通分量
根据上述点双性质,有一个定理,即点双中的割点在搜索树中最后出现. 那么,我们在搜索树中遇到一个割点的时候,就意味着我们找到了一个点双. 那么我们求点双的过程就是求割点的过程.
点双图解
事实上,根据点双的性质,我们也可以搜索求点双:
- 先求出全部割点
- 不断寻找未访问过的非割点进行DFS
- 遇到割点即返回
- 途径的割点与非割点属于同一个点双.
10.5.5 TARJAN求边双连通分量
根据刚才上述做法的启发,容易想到下述写法:
- 先求出全部割边,将割边的端点标记为"伪割点"
- 不断寻找未访问过的非"伪割点"进行DFS
- 遇到"伪割点"即返回
- 途径的"伪割点"与非"伪割点"属于同一个边双.
11.DFS序
DFS序是树上DFS (或指定根节点的无向图 DFS 的节点访问顺序序列),若无特殊需求,则DFS序不唯一.
DFS序有以下几个性质 :
- 在DFS序中,父节点总在子节点前方
- 在DFS序中,父节点的一颗子树所对的编号也满足DFS序,且紧跟在父节点后方.
那么,父节点 \(root\) (已知其在DFS序中编号为 \(i\) ) 的全部子节点的数量和若为 \(s\),那么这棵树在DFS序中的编号为 \([i,i+s]\).
利用这种性质,我们可以快速建立线段树,维护树上信息.
比如,下面这段代码可以完成树上单点修改与区间查询 (即查询节点与其子树权值和).
代码
#define int long long
#define tol (id*2)
#define tor (id*2+1)
#define mid (l+r)/2
int n,m,root;
vector<int> e[1000001];
vector<int> dfs;
int size[1000001]; //root i's amount of son nodes
int posi[1000001];
bool vis[1000001];
int a[1000001];
void buildgraph(int root){
dfs.push_back(root);
vis[root]=true;
for(int i:e[root]){
if(!vis[i]){ //root is i's father
buildgraph(i);
size[root]+=size[i]+1;
}
}
}
struct tree{
int l,r;
int sum;
}t[4000001];
void buildtree(int id,int l,int r){
t[id].l=l;
t[id].r=r;
if(l==r){
t[id].sum=a[dfs[l-1]];
return;
}
buildtree(tol,l,mid);
buildtree(tor,mid+1,r);
t[id].sum=t[tol].sum+t[tor].sum;
}
void change(int id,int k,int val){
if(t[id].l==t[id].r){
t[id].sum+=val;
return;
}
if(k<=t[tol].r){
change(tol,k,val);
}
if(k>=t[tor].l){
change(tor,k,val);
}
t[id].sum=t[tol].sum+t[tor].sum;
}
int sum(int id,int l,int r){
if(t[id].l>=l&&t[id].r<=r){
return t[id].sum;
}
if(t[tol].r<l){
return sum(tor,l,r);
}
if(t[tor].l>r){
return sum(tol,l,r);
}
return sum(tol,l,r)+sum(tor,l,r);
}
12.树上公共祖先
树上公共祖先即 LCA ,节点 \(x,y\) 的 LCA 定义为 \(x,y\) 深度最深的共有祖先. 因此,求 LCA 我们需要两项准备 : 预处理节点深度与父亲.
12.1 倍增法求LCA
倍增法求 LCA 不仅需要预处理父亲是谁,还要处理节点 \(x\) 向根节点走 \(2^{i}\) 步到达的祖先,这个祖先记为 \(pre[x][i]\).
我们的倍增 LCA 分为两个部分. 这两个部分都是以 "向根节点跳 \(2^{i}\) 步" 为基本操作实现的. 不难发现,当两个节点初次跳到深度相同的相同节点,那么这个节点就是他们的 LCA.
为了维护深度相同,我们需要先将它们跳到同一深度,即将较深的一个向上跳. 这是我们的第一步,即直接将 \(x\) 变为它与 \(y\) 同深度的祖先.
第二步即为将 \(x,y\) 共同跳至 LCA 点下方的节点,随后直接判断即可.
这一步的实现,为了尽可能地少遍历,我们从最大的距离开始跳. 假如跳过这一步后,\(x,y\) 的公共祖先不相等,那么就直接让 \(x,y\) 等于它们各自的公共祖先,这样做,我们从大到小遍历的过程就是不断逼近答案的过程,最后仅需一次遍历即可得出答案. 但是此时我们得出的答案是 LCA 下方的那个点,答案的父亲才是 LCA.
预处理思想: \(pre[x][i]=pre[pre[x][i-1]][i-1]\).
代码
int lca(int x,int y){
if(deep[x]<deep[y]){
swap(x,y);
}
for(int i=k;i>=0;--i){
if(deep[fa[x][i]]>=deep[y]){
x=fa[x][i];
}
}
if(x==y){
return x;
}
for(int i=k;i>=0;--i){
if(fa[x][i]!=fa[y][i]){
x=fa[x][i];
y=fa[y][i];
}
}
return fa[x][0];
}
13.差分约束
差分约束是一类 "给定不等式组,判断是否有解" 的问题. 我们可以把这类问题转化成图论求解. 具体转化如下.
假设现在有不等式组 \(a>b , b>c\) ,则 \(a>c\) . 这里用的是不等式的传递性. 我们可以把这样的传递性联想到连通性的传递性: 若 \(a\) 能到 \(b\) ,\(b\) 能到 \(c\) ,那么 \(a\) 能到 \(c\). 所以我们可以根据这个思想建图.
有的题会让我们求不等式组中元素的最小值. 根据贪心策略,我们当然要尽可能使得每个不等式中的元素都取最小的可能值. 我们定义建立的图中的边权表示 "一个不等式中的满足条件的最小元素差",这样,我们把一个图跑完,所积累的就是 "满足所有不等式的最小元素差",即为答案. 根据如上思想,我们可以按如下规则建图.
- \(a>b\),即 \(a\) 至少比 \(b\) 大 \(1\),则从 \(B\) 到 \(A\) 建边权为 \(1\) 的边.
- \(a<b\),即 \(a\) 至少比 \(b\) 小 \(1\),则从 \(A\) 到 \(B\) 建边权为 \(1\) 的边.
- \(a\ge b\),即 \(a\) 至少比 \(b\) 大 \(0\),则从 \(B\) 到 \(A\) 建边权为 \(0\) 的边.
- \(a\le b\),即 \(a\) 至少比 \(b\) 小 \(0\),则从 \(A\) 到 \(B\) 建边权为 \(0\) 的边.
- \(a=b\),等价于 \(a\le b\) 且 \(a\ge b\),所以按 \(3,4\) 步骤建图.
建好图后,就是图论算法的事情了. 判断是否有解,只需判断能否到达. 计算最小值,跑一遍最长路即可.
当然,即便不等式形如 \(a\le b-k\) 这样的形式,我们也可以通过改变边权来建图.
关于到底是跑最短路还是最长路的问题. 我们举一例来说明.
假如现在我们有一不等式 \(a-b \le k\) ,那么根据我们的最短路算法松弛操作,我们可以把它写成 \(dis[to] \ge dis[u]+w[to]\) 的形式,也就是 \(b \ge a-k\),因此 \(b=to,a=u,w[to]=-k\),所以我们才要建一条从 \(A\) 到 \(B\),权值为 \(-k\) 的有向边跑最短路. 相反,根据最长路算法,即 \(dis[to] \le dis[u]+w[to]\) ,我们有 \(a \le b+k\),所以我们才要建一条从 \(B\) 到 \(A\),权值为 \(k\) 的有向边跑最长路.
所以,我们可以总结成如下表格:
不等式 | 转化 | 最短路建边 | 最长路建边 |
---|---|---|---|
\(a-b>k\) | \(a-b\ge k+1\) | \((B,A,k+1)\) | \((A,B,-k-1)\) |
\(a-b<k\) | \(a-b\le k-1\) | \((A,B,1-k)\) | \((B,A,k-1)\) |
\(a-b\ge k\) | - | \((B,A,k)\) | \((A,B,-k)\) |
\(a-b\le k\) | - | \((A,B,-k)\) | \((B,A,k)\) |
\(a-b=k\) | \(\begin{aligned} a-b\le k\\ a-b\ge k\end{aligned}\) | \(\begin{aligned} (B,A,k)\\ (A,B,-k)\end{aligned}\) | \(\begin{aligned} (B,A,k)\\ (A,B,-k)\end{aligned}\) |
可以看出来,最长路与最短路在建边上正好相反.
14.二分图
14.1 二分图定义
二分图为一个无向图,满足:这个无向图可以被分成两部分,其中每部分内不存在边.
14.2 二分图判定
奇环法:一张图是二分图,当且仅当图中不存在奇环.
染色法:一张图是二分图,当且仅当图能被染成两种颜色,且没有相邻的点颜色相同.
一般我们判断二分图利用的是染色法.
BFS 染色法
bool check(int m){
queue<int> q;
int c[20001]={};
for(int i=1;i<=n;++i){
if(!c[i]){
q.push(i);
c[i]=1;
while(!q.empty()){
int u=q.front();
q.pop();
for(edge i:e[u]){
if(i.w>=m){
if(!c[i.to]){
q.push(i.to);
if(c[u]==1) c[i.to]=2;
else c[i.to]=1;
}
else if(c[i.to]==c[u]) return false;
}
}
}
}
}
return true;
}
14.3 匈牙利算法
假如我们在一个二分图中找到了若干边(称为匹配边),并且全部匹配边没有公共点,那么我们就称其为二分图的一个匹配.
增广路指的是按未匹配边、匹配边、未匹配边.... 的顺序,最终到达一个未匹配点的路径. 按这样的路径,依次将路径中的匹配边与未匹配边取反,最终匹配边个数会多一个.
匈牙利算法即是根据增广路性质写成的. 与增广路不同的是,匈牙利算法不管找什么边都是固定从某一侧开始,这也就决定了这个算法不会被环路卡死.
代码:
代码
bool dfs(int now){
for(int i:e[now]){
if(!vis[i]){
vis[i]=1;
if(!match[i]||dfs(match[i])){
match[i]=now;
return true;
}
}
}
return false;
}
int mat(){
memset(match,0,sizeof match);
int ans=0;
for(int i=1;i<=n;++i){
memset(vis,0,sizeof vis);
if(dfs(i)) ans++;
}
return ans;
}
14.4 二分图最大匹配,最小顶点覆盖,最大独立集
前置条件:是一个二分图
14.4.1 二分图最大匹配
题库 B D E F
定义: 边的集合,满足任何两条边都没有公共点,二分图最大匹配是这个集合的最大值.
求法: 跑一边匈牙利即可.
用途:
对于满足如下性质的问题给出答案
-
每个选择都有两种条件
-
两种条件至少选择一个时,选择有贡献
-
求所有选择的最大贡献
解决这类问题时,通常先找出选项的两个条件(可能不止两个,也可能会有变形),然后对每个选项的条件连有向边.
14.4.2 最小顶点覆盖
题库 C
定义: 点的集合,使得图中每条边在集合中都有公共点,最小顶点覆盖为该集合最小值.
求法: 数值上等于二分图最大匹配
用途:
对于满足如下性质的问题给出答案:
-
每个选择都有两种条件
-
两种条件至少选择一个时,选择成立
-
要求全部选择成立,求最少满足条件总数
4.1 4.2 判断方法 : 一个是最少,一个是最多,在题面上体现.
14.4.3 最大独立集
题库 G H I
定义: 点的集合,使得集合内任何两点都没有相连的边.
求法: 节点总数-二分图最大匹配
用途:
“没有相连的边”启示我们去寻找冲突. 没有相连的边就是没有冲突,那么按照题意,没有冲突的就是答案. 因此这类题的基本步骤是这样的:
-
寻找冲突
-
对冲突的对象连边(注意不是对冲突本身连边). 如 A 选 x,不选 y,B 选 y,不选 x,那么需要在 A 和 B 连边,而不是 x 和 y 连边.
-
以下两种写法是等价的:
代码
#1
for(int i=1;i<=n;++i){
for(int j=1;j<=n;++j){
if(like[i]==dislike[j]||dislike[i]==like[j]){
e[i].push_back(j);
}
}
}
#2
for(int i=1;i<=n;++i){
for(int j=1;j<=n;++j){
if(like[i]==dislike[j]){
e[i].push_back(j);
e[j].push_back(i);
}
}
}
14.5.一些小问题
14.5.1 建单向边还是双向边
还是那个问题,上面那两种代码是等价的,这是我们形式意义上的单向边或者双向边,虽然看起来不一样,但实际效果相同.
但是实际效果建单向边也是可行的. 因为我们在跑匈牙利的时候避免了这样的问题,而采用了每次都从左边寻找前往右边的点的这样一种方法,因此不会造成什么影响. 但这样会导致边数变成了原来的二倍,反过来跑的时候,两个对称节点就都满足条件了,因此答案会变成之前的二倍,所以真正结果是要除以二的. 相反,对真正的单向边则不需要这样.
假如你真的不知道,就多输出几个数,看看哪个能对上大样例.
14.5.2 能避免初始化的方法
匈牙利里每次 DFS 都要初始化,很费时间. 为此我们可以开一个整型的数组来记录,而不是布尔类型,每次搜索都赋一个时间戳,假若当前记录数组不等于时间戳就说明当前没访问过,这样就不用初始化了.
15.扫描线
扫描线的前置基础是 线段树
扫描线用于解决一些 覆盖问题或者重复问题, 比如说现在我有这样一道题:
给出 \(n\) 个平行于坐标轴的矩形的坐标,求它们的面积并.
面积并指的是这些矩形的最大覆盖面积.
不难发现,当坐标绝对值很大时,我们采用标记法就会开不下,当 \(n\) 很大时,遍历法又会超时,因此,我们现在急需一种算法来快速求覆盖面积.
假如我们使用一根直线从左到右扫描(这样也可以保证每个矩形的左边总会比右边先扫到,避免了 \(cover\) 为负数. \(cover\) 是什么下面会提到),可以发现直线与全部覆盖面积的交集长度仅会在直线经过矩形边缘时更新,这样的话,我们只需要遍历所有的矩形左右边缘横坐标即可,\(O(2N)\) 的复杂度是我们能够承受的. 而且我们还发现,面积并实际上就是全部的区块宽度与该区域的覆盖长度之积的和.
那么现在的问题就变为:怎么更新直线被覆盖的长度?
依照线段树能够维护区间信息的思路,我们尝试用区间信息表示覆盖长度. 引入一个变量 \(cover\) 来表示完全覆盖当前区间的矩形个数. 为什么要引入这样一个变量?因为我们有如下定理:
- 当前被完全覆盖的区间对答案的贡献就是它的长度.
- 未被完全覆盖的区间对答案的贡献就是它的子区间贡献和.
这样这个题就可以完全使用线段树来解决了. 我们将矩形靠左的边称作出边,靠右的边称作入边,显然,当遇到入边时,它覆盖的区间 \(cover\) 增加一,遇到出边时减少一,更新区间贡献时,我们遇到完全覆盖的区间就直接返回,否则继续向下递归,返回时再算出总和,这就是这道题的总体思路.
此外,因为这个题中的纵坐标值可能很大,这样开线段树的时候内存无法承受,因此需要进行 离散化,将纵坐标收缩到一个很小的区间内. 而且,因为这个题的扫描对象是矩形的左边和右边,所以实际上数组要开到 \(2n\) ,线段树要开到 \(8n\). 而且要开 long long.
参考代码
By HaneDaniko 2024/2/28.
#define tol (i*2)
#define tor (i*2+1)
#define mid ((l+r)/2)
long long n;
long long ls[10000001];
struct line{ //储存矩形左右边
long long l,r;
long long len;
int type;
bool operator<(const line& A)const{
return len<A.len;
}
}l[20000001];
struct tree{
int l,r; //线段树
int cover;
long long len;
}t[40000001];
void build(int i,int l,int r){
t[i].l=l;
t[i].r=r; //建树很朴素,不需要赋任何输入值
if(l==r){
return;
}
build(tol,l,mid);
build(tor,mid+1,r);
}
void update(int i){
if(t[i].cover){ //返回更新
t[i].len=ls[t[i].r+1]-ls[t[i].l];
}
else{
t[i].len=t[tol].len+t[tor].len;
}
}
void change(int i, long long l, long long r, int m){
if(ls[t[i].l]>=r||ls[t[i].r+1]<=l){
return; //区间修改
}
if(ls[t[i].l]>=l&&ls[t[i].r+1]<=r){
t[i].cover+=m;
update(i); //假如被完全覆盖
return;
}
change(tol,l,r,m); //否则为子区间的贡献和
change(tor,l,r,m);
update(i);
}
int main(){
cin>>n;
for(int i=1;i<=n;i++){
long long x1,y1,x2,y2;
cin>>x1>>y1>>x2>>y2;
ls[i*2-1]=x1;
ls[i*2]=x2;
l[i*2-1]=line{x1,x2,y1,1};
l[i*2]=line{x1,x2,y2,-1};
}
n*=2;
sort(l+1,l+n+1);
sort(ls+1,ls+n+1);
int tail=unique(ls+1,ls+n+1)-(ls+1);
build(1,1,tail-1);
long long ans=0;
for(int i=1;i<n;i++){
change(1,l[i].l,l[i].r,l[i].type);
ans+=t[1].len*(l[i+1].len-l[i].len);
}
cout<<ans;
return 0;
}
上述通过了一道例题来深入剖析了扫描线算法的基本流程. 当坐标为浮点数时,也可以通过离散化使用扫描线解决. 这里使用到的线段树是基础线段树,因此理论上树状数组也可以做,但因为涉及区间修改区间查询,因此写树状数组实际上不如线段树.
扫描线题的变种很多,但通常都万变不离其宗,需要维护特定的内容来表示当前区间的贡献情况. 比如下述例题:
给出 \(n\) 个平行于坐标轴的矩形的坐标,求它们覆盖区域的周长.