图论

图论

图的存储及遍历

直接存储

直接存每一条边的起点、终点、边权。

struct edge{
  int u,v,w;  
}e[maxn];
for(int i=1;i<=m;i++){
    cin>>e[i].u>>e[i].v>>e[i].w;
}

这种适用于最小生成树,只需要\(n-1\)条不在集合里的边。

邻接矩阵

\(n\)个点的图,用一个\(n\times n\)的矩阵,\(a[i][j]\)表示\(i,j\)是否相连。\(a[i][j]=1\)表示\(i,j\)相连,\(a[i][j]= \infty\)表示\(i,j\)不相连。

这种存储用于没有重边的情况(或者重边可以忽略的情况),显著的好处是可以\(O(1)\)查询某条边是否存在。

邻接表

存储

struct edge{
  int u,v,w;  
};
vector<edge>g[maxn];
for(int i=1;i<=n;i++){
    cin>>u>>v>>w;
    g[u].push_back({u,v,w})
}

遍历

for(int i=1;i<=n;i++){
    for(int j=0;j<g[i].size();j++){
        cout<<g[u][i].u<<' '<<g[u][i].v<<' '<<g[u][i].w;
	}
}

查询复杂度:\(O(d^+(u))\)\(vector\)可排序,排序后二分查找,可以做到\(log\)

应用:

基本适用所有图(除需要快速查询某条边是否存在,且点数很少,可以邻接矩阵)。

尤其适用于需要将一个点的出边排序的情况。

链式前向星

本质上是用链表存储图。

存储

struct edge{
	int u,v,w,nxt;
}e[maxn]; //如果是双向边需要乘2
void add(int u,int v,int w){
	e[++cnt].nxt=hd[u];
	e[cnt].u=u;e[cnt].v=v;
	e[cnt].w=w;
	hd[u]=cnt;
}

遍历

for(int i=1;i<=n;i++){
	for(int j=hd[i];j;j=e[j].nxt){
		int u=i,v=e[j].nxt,w=e[j].w;
	}
}

适用于各种图,但是不能快速查询一条边,也不能对出边排序。

优点是边带编号,存双向边时,相邻编号的两条边分别为正向边和反向边,编号分别为\(i,i\oplus1\)(适用于网络流存反边)。

存反边

考虑异或\(1\)会改变奇偶,我们考虑如果边从\(1\)开始编号,那么正向边和反向边编号为\(1,2\),而能异或出来的是\(0,1\),所以不能这么存。

可以知道边应该从\(2\)开始编号,那么正向边和反向边就可以通过异或 \(1\)得到。

去重边

实现

1.点数较少时,开二维数组\(a[i][j]\)表示\(i,j\)是否有连边。

2.点数较多时,开\(map<pair<int,int>>\)映射。

单源最短路

\(01BFS\)

边权为\(1\)时,直接\(bfs\)就可以求最短路,用队列实现。当边权有\(0,1\)时,就需要双端队列做\(01bfs\)\(bfs\)正确性归根于向外扩展时路径长度递增,而出现边权为\(0\)时,向外扩展的路径长度不一定变大,这破坏了正常\(bfs\)的结构。这时我们就可以用双端队列做,边权为\(0\)时向前插入,边权为\(1\)时向后插入,仍然保持距离小的点在前,距离大的点在后的结构。

\(bfs\)求最短路复杂度最优,遍历\(O(n+m)\)

[P4667 BalticOI 2011 Day1] Switch the Lamp On 电路维修 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)

题解:如果不转动就是\(0\)花费,转动后通过就是\(1\)花费,注意格子坐标和格点坐标的区别,用双端队列\(deque\)\(01bfs\)即可。

\(SPFA\)

实现&判负环

\(SPFA\)\(bellman\ Ford\)的优化,普通算法过程为:

每轮进行松弛操作,称一轮松弛为对每条边\((u,v)\)进行\(dis(v)=min(dis(v),dis(u)+val(u,v))\)。松弛\(n-1\)轮即可。

证明:

\(s->u\)的最短路\(s\rightarrow p_1 \rightarrow ...\rightarrow u\)中,\(s\rightarrow p_1\rightarrow...\rightarrow p_i\)一定是\(s->p_i\)的最短路,这说明一个点的最短路一定由另一个点的最短路扩展而来。

因为最短路最多\(n-1\)条边,第\(i\)轮松弛可得到边数为\(i\)的最短路,所以只需要\(n-1\)次松弛。

考虑优化,不难发现,每次只有已经被松弛的结点所连的边才有可能进行下一次松弛,所以我们用队列维护这些可能松弛的点,这就是\(SPFA\)

queue<int>q;
int vis[maxn],dis[maxn],cnt[maxn];
void spfa(int s){
	memset(vis,0,sizeof vis);
	memset(dis,0x3f,sizeof dis);
	vis[s]=1;dis[s]=0;
	q.push(s);
	while(!q.empty()){
		int u=q.front();
		q.pop();
        vis[u]=0;//出队vis设为0
		for(int i=hd[u];i;i=e[i].nxt){
			int v=e[i].to;
			if(dis[v]>=dis[u]+e[i].val){
				dis[v]=dis[u]+e[i].val;
				cnt[v]=cnt[u]+1;
				if(cnt[v]>=n){
					puts("-1");//最短路边数大于n-1,存在负环
				}
				if(!vis[v]){//如果没入过队,就入队
					q.push(v);vis[v]=1;
				}
			}
		}
	}
}

\(Dijkstra\)

实现

\(dijkstra\)可以理解为\(BFS\)+贪心,用于求算单源最短路。从起点\(s\)出发,扩展距离它最近的点\(v\),再扩展\(v\)的点,直至所有点扩展完毕。

对出边排序,可以用邻接表存图再排序,也可以链式前向星存图,用优先队列排序。复杂度\(O((n+m)logn)\)

\(Dijkstra\)的局限在于边权不能有负值,因为该算法基于\(BFS\)扩展加贪心,这有赖于路径长度不断增加,如果出现负边权的话,会导致路径的长度变短,短于已经扩展完后求得的最短路的长度,破坏了\(BFS\)的扩散过程。

\(dis_u\)为起点到\(u\)的距离,\(D_u\)为到\(u\)的最短路,称扩展结点\(u\)表示对\(u\)的所有邻边\((u,v)\),用\(dis_u+w_{u,v}\)更新\(dis_v\)

\(dis_u=D_u\)的结点中,取出\(dis\)最小的且未扩展的点进行扩展。没有负权边,所以取出的结点的最短路长度单调不降。

如何判断\(dis_u=D_u\),事实上,只要是当前\(dis\)最小的且未扩展的点\(u\),就满足\(dis_u=D_u\)

证明:

考虑归纳证明,已经扩展了\(p_1,p_2,...,p_{k-1}\) ,在扩展时取得最短路。记\(p_k\)为当前未扩展的\(dis\)最小的点。\(p_k\)的最短路一定由\(p_i,1\le i \le k-1\)的最短路扩展,存在其他未扩展的点\(u_1,u_2,...,u_c\),假设有

\[dis({p_i})+w(p_i,u_1)+w(u_1,u_2)+...+w(u_c,p_k)<dis(p_j)+w(p_j,p_k) \]

由于边权非负,得到\(dis(p_i)+w(p_i,u_1)<dis(p_j)+w(p_j,p_k)\),即\(dis(u_1)<dis(p_k)\),这与我们假设的\(p_k\)\(dis\)最小矛盾。

简单地说,一个点的最短路由长度非严格小于它的最短路更新,\(dis\)最小的结点不可能被其他结点更新。

所以每次取\(dis_u=D_u\)的结点扩展就相当于每次取\(dis\)最小的结点,维护\(dis\)最小可以用优先队列。需要注意的是,一个结点可能有多个入边并多次入队,但是它第一次被取出时的\(dis\)一定是最短路,否则复杂度退化到\(O(m^2logm)\)

不加堆优化的复杂度是\(O(n^2+m)\),当图为稠密图时(\(m\)接近\(n^2\)),直接暴力更优。

//O(n^2)Dij
void dij(int s){
	dis[s]=0;
	for(int i=1;i<=n;i++){
		int k=0;//每次选取dis最小的点k
		for(int j=1;j<=n;j++){
			if(k==0&&(!vis[j])){
				k=j;
				continue;
			}
			if((!vis[j])&&(dis[j]<dis[k])&&(dis[j]!=inf)) k=j;
		}
		vis[k]=1;
		for(int j=0;j<g[k].size();j++){
			int v=g[k][j].v,w=g[k][j].w;
			dis[v]=min(dis[v],dis[k]+w);
		}
	}
}
int vis[maxn],dis[maxn];
typedef pair<int,int>pii;//默认以第一关键字排序,如果路径长度会爆int需要开ll
//也可以结构体封装重载运算符
priority_queue<pii,vector<pii>,greater<pii> >q;
void dij(int s){
	memset(vis,0,sizeof vis);
	memset(dis,0x3f,sizeof dis);
	dis[s]=0;
	q.push(make_pair(dis[s],s));
	while(!q.empty()){
		int u=q.top().second;
		q.pop();
		if(vis[u]) continue;//如果已经遍历过它的出边就跳过
		vis[u]=1;
		for(int i=hd[u];i;i=e[i].nxt){
			int v=e[i].to;
			if(dis[v]>=dis[u]+e[i].val){
				dis[v]=dis[u]+e[i].val;
				q.push(make_pair(dis[v],v));//松弛它的出边
			}
		}
	}
} 

全源最短路

\(Floyd\)

实现
for(int k=1;k<=n;k++)//注意先枚举中转点
		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]);
求最小权值环

\(Floyd\)算法发现,在最外层循环为\(k\)时(尚未进行第\(k\)轮循环),此时\(dis[i][j]\)表示i到j经过\([1,k)\)中的点更新的最短路。对于一个环,至少需要三个点,设其中编号最大的点为\(w\),环上与\(w\)相邻两侧的点为\(u,v\),则在最外层循环为\(w\)时,该环的权值和为\(dis[u][v]+val(v,w)+val(u,w)\),故在循环\(k\)时,满足\(i<k,j<k\)进行更新即可。

ans=inf;
	for(int k=1;k<=n;k++)
		for(int i=1;i<k;i++)
			for(int j=1;j<i;j++)
				ans=min(ans,dis[i][j]+val[i][k]+val[k][j]);
传递闭包

判断任意两点是否连通,此时边权变为\(1、0\),取\(min\)改为或运算。

\(Johnson\)

\(Johnson\)算法可以用来解决带负权值的全源最短路。\(Johnson\)的巧妙之处在于给每个点赋予了势能\(h[i]\),正如物理中的势能,一个点到达另一个点,不管走什么路径,总变化量是相同的,这启发我们将\((u,v)\)的边权设为\(w'_{u,v}=w_{u,v}+h_u-h_v\)

考虑一条路径\(S->p_1->p_2->...->T\),原长度\(L(S,T)=w_{S,p_1}+w_{p_1,p_2}+...+w_{p_l,T}\)

新路径长度\(L'(S,T)=(h_S+w_{S,p_1}-h_{p_1})+(h_{p_1}+w_{p_1,p_2}-h_{p_1})+...+(h_{p_l}+w_{p_l,T}-h_{T})\)

\(L(S,T)=L'(S,T)-h_S+h_T\)

对于固定的\(S,T\),原图的最短路就对应到新图的最短路上长度增加了\(h_S-h_T\)。所以我们只要求出新图的最短路,对应就能求出原图的最短路。

由于\(dijkstra\)无法应用于负边权的图,而由上面的分析,我们希望可以给每个点赋予适当的势能,使得新图的边权都非负,即\(w_{u,v}+h_u-h_v>0\)。这是一个差分约束系统,即\(h_u+w_{u,v}>h_v\)。若图中无负环,则\(h\)一定存在。

求得\(h\)后对应修改边权,得到的新图的权值都非负,对\(n\)个点跑\(n\)\(dijkstra\),时间复杂度是\(O(nmlogm)\)

#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
const int maxn=3e3+5;
const int MAXN=6e3+5;
const ll inf=1e9;
typedef pair<ll,int> pii;
int n,m,cnt,hd[maxn],vis[maxn],t[maxn];
ll h[maxn],dis[maxn];
struct edge{
    int u,to,nxt,w;
}e[MAXN+maxn];
void add(int u,int v,int w){
    e[++cnt].nxt=hd[u];
    e[cnt].to=v;
    e[cnt].u=u;
    e[cnt].w=w;
    hd[u]=cnt;
}
bool spfa(int s){
    queue<int>q;
    q.push(s);
    memset(h,63,sizeof h);
    vis[s]=1;h[s]=0;
    while(!q.empty()){
        int u=q.front();
        q.pop();
        vis[u]=0;
        for(int i=hd[u];i;i=e[i].nxt){
            int v=e[i].to;
            if(h[u]+e[i].w<h[v]){
                h[v]=h[u]+e[i].w;
                if(!vis[v]){
                    q.push(v);
                    vis[v]=1;
                    t[v]++;
                    if(t[v]==n+1) return false;
                }
            }
        }
    }
    return true;
}
void dij(int s){
    priority_queue<pii,vector<pii>,greater<pii> >q;
    for(int i=1;i<=n;i++) vis[i]=0,dis[i]=inf;
    q.push(make_pair(0,s));
    dis[s]=0;
    while(!q.empty()){
        int u=q.top().second;
        q.pop();
        if(vis[u]) continue;
        vis[u]=1;
        for(int i=hd[u];i;i=e[i].nxt){
            int v=e[i].to;
            if(dis[v]>dis[u]+e[i].w){
                dis[v]=dis[u]+e[i].w;
                q.push(make_pair(dis[v],v));
            }
        }
    }
}
int main(){
    cin>>n>>m;
    for(int i=1;i<=m;i++){
        int u,v,w;
        cin>>u>>v>>w;
        add(u,v,w);
    }
    for(int i=1;i<=n;i++) add(0,i,0);
    if(!spfa(0)){
        cout<<-1<<endl;
        return 0;
    }
    // for(int i=1;i<=n;i++) cout<<h[i]<<' ';
    // cout<<endl;
    for(int i=1;i<=n;i++){
        for(int j=hd[i];j;j=e[j].nxt){
            int v=e[j].to;
            e[j].w+=h[i]-h[v];
        }
    }
    for(int i=1;i<=n;i++){
        ll ans=0;
        dij(i);
        for(int j=1;j<=n;j++){
            if(dis[j]==inf) ans+=j*inf;
            else ans+=j*(dis[j]-h[i]+h[j]);
        }
        printf("%lld\n",ans);
    }
}

次短路

次短路可以用\(Dijkstra\)算法简单处理,\(dis[u][0],dis[u][1]\)分别表示从起点出发到\(u\)的最短路径和次短路径。然后我们只需要在松弛操作时分别判断即可。

  1. \(d[u][0]+val(u,v)<d[v][0]\),路径长度小于目前最短路,将目前的次短路用目前的最短路替代,然后将最短路松弛更新。
  2. \(d[u][0]+val(u,v)=d[v][0]\),路径长度等于目前最短路,不产生任何影响,直接舍去。
  3. \(d[v][0]<d[u][0]+val(u,v)<d[v][1]\),路径长度大于目前的最短路但是小于次短路,更新\(v\)的次短路。
  4. \(d[u][0]+val(u,v)>d[v][1]\),路径长度大于目前的次短路,直接舍去。

\(k\)短路

更为一般的\(k\)短路可以用\(A^*\)算法处理。

\(A^{*}\)算法其实就相当于\(Dijkstra\)加估价函数\(BFS\),是一种启发式搜索算法。把\(s\)\(t\)的路径分为两部分\(s->i,i->t\)的路径。估价函数\(f(i)=g(i)+h(i)\)\(g(i)\)表示\(s\)\(i\)的路径长度,\(h(i)\)表示\(i\)\(t\)的路径长度。\(g(i)\)\(BFS\)求出,\(h(i)\)\(i\)\(j\)的最短路,用\(Dijkstra\)求出。

我们考虑\(Dijkstra\)算法的过程,每次都取出优先队列中的堆顶,松弛完后找到此时的最短路,所以点\(t\)\(k\)次弹出队列时,就是\(s\)\(t\)的第\(k\)短路。

实现

首先在反图上跑最短路,得到点\(i\)到终点\(t\)的估计最低成本\(h(i)\)。然后开始启发式搜索,依赖于贪心思想,我们优先搜索估价函数值\(f(i)\)最小的点,向外扩展,直到找到第\(k\)短路。在该算法过程中,可以求出各个点的前\(k\)短路。

例题:洛谷\(P2483\)\(k\)短路模板,魔法猪学院。

#include<bits/stdc++.h>
using namespace std;
const int maxn=2e5+5;
const int inf=1e8;
const double eps=1e-7;
int n,m,k,a,b,vis[5005],ans;
double dis[5005],E;//t到i的最短路
struct edge{
    int v;
    double val;
    edge(int a,double b){v=a;val=b;}//赋值操作
};
vector<edge>g[5005],G[5005];
struct node{
    int u;
    double dis;
    node(int a,double b){u=a;dis=b;}
    bool operator <(const node &x)const{return dis>x.dis;}//重载<运算符
};
void dij(int s){
    for(int i=1;i<=n;i++) dis[i]=inf,vis[i]=0;
    dis[s]=0;
    priority_queue<node>q;
    q.push(node{s,dis[s]});
    while(!q.empty()){
        int u=q.top().u;
        q.pop();
        if(vis[u]) continue;
        vis[u]=1;
        for(int i=0;i<G[u].size();i++){
            int v=G[u][i].v;
            int w=G[u][i].val;
            if(dis[u]+w<=dis[v]){
                dis[v]=dis[u]+w;
                q.push(node{v,dis[v]});
            }
        }
    }
}
struct p{//估价函数
    int v;
    double g,h;
    p(int a,double b,double c){v=a;g=b;h=c;}
    bool operator <(const p &x)const{return g+h>x.g+x.h;}//重载<运算符,按f=g+h小的排序
};
void astar(int s,int t){
    priority_queue<p>q;
    q.push(p{s,0,0});
    while(!q.empty()){
        p x=q.top();
        int u=q.top().v;
        q.pop();
        //tim[u]++;记录点u弹出的次数,对应第k短路
        if(x.g+x.h-eps>E) break;
        if(x.v==n){//到达终点
            ans++;
            E-=(x.g+x.h);
            continue;
        }
        for(int i=0;i<g[u].size();i++){
            if(x.g+g[u][i].val<=E)
                q.push(p{g[u][i].v,x.g+g[u][i].val,dis[g[u][i].v]});
        }
    }
}
int main(){
    scanf("%d%d%lf",&n,&m,&E);
    for(int i=1;i<=m;i++){
        int u,v;;
        double w;
        scanf("%d%d%lf",&u,&v,&w);
        g[u].push_back(edge{v,w});
        G[v].push_back(edge{u,w});//反图
    }
    dij(n);
    astar(1,n);
    printf("%d\n",ans);
    return 0;
}

(待施工)哈密尔顿路径

哈密尔顿回路

哈密尔顿最短路径

最短路树

默认图(弱)连通且无负环。

\(s\)上求最短路,对于\(i\ne s\)\(i\)的最短路由\(j\)扩展而来,满足\(dis(j)+val(j,i)=dis(i)\)。即\(s->i\)的最短路是通过由\(s->j\)的最短路加上 边\((j,i)\)形成的。我们希望有一个结构能够刻画\(s\)到每个点\(i\)的最短路。

在单元最短路过程中,记录每个点最短路最后一次被更新时的前驱\(f_i\),即最后由边\((j,i)\)更新最短路\(dis(i)=dis(j)+val(j,i)\),此时\(f_i=j\)。除了起始点\(s\),其余各个点从\(f_i\)\(i\)连边,形成一个有向无环图。

这样得到一棵有根叶向树,称为\(s\)出发的最短路树,树上\(s\)\(i\)的简单路径就是原图中\(s\)\(i\)的最短路。

类似的将上述建边的方向翻转,得到一棵根向树,称为到达\(s\)的最短路树。无向图不区分方向,因此从\(s\)出发的最短路树也可以是到达\(s\)的最短路树,但有向图不一定。

一张图的最短路树不是唯一的。最短路树之外的边不影响\(s\)到各个点的最短路,可以忽略。

最短路树在求解最短路变形问题时发挥了重要作用,如删边最短路

删边最短路

给定一张无向正权图,对图上每条边,求删去改变后\(1 \rightarrow n\)的最短路。

首先求出\(1\rightarrow n\)的最短路。

\[P=p_0(1) \xrightarrow{e_1} p_1 \xrightarrow{e_2} \cdots \xrightarrow{e_L} p_L(n) \]

\(e\)为当前考虑的边\((i,j)\)

\(e\notin P\),答案是显然的,删去\(e\)最短路不可能更短,故\(1\rightarrow n\)的最短路仍为\(w(P)\)

\(e\in P\)

差分约束

三角形不等式

在单源最短路中,对于\((u,v)\in E\),有\(D_u+w(u,v)\ge D_v\),称为三角形不等式。三角形不等式反映的是单源最短路长度的性质,相反,给定若干三角形不等式,求能否找到满足要求的解,就是差分约束。

差分约束

差分约束用于解决不等式组的解的问题。给出一组包含\(m\)个不等式,有\(n\)个未知数的形如如下公式的不等式组,

\[\begin{cases} x_{i_1}-x_{j_1}\leq y_1\\ x_{i_2}-x_{j_2}\leq y_2\\ ……\\ x_{i_m}-x_{j_m}\leq y_m\\ \end{cases} \]

判断不等式组是否有解,如果有解求任意一组满足次不等式组的解。

对于形如\(x_u-x_v\leq y\)的条件,我们可以移项得到\(x_v\leq x_u+y\)。这与最短路松弛时的\(dis(v)\leq dis(u)+val(u,v)\)。建一条\(u->v\)边权为\(y\)的边,将问题转化到最短路上。

为了保证图连通,还可以建一个超级源点向每个点都连长度为\(0\)的边。或者初始令所有\(dis=0\),并将所有点入队。

如果存在负环。,不妨假设组成负环所对应的不等式是\(x_{a_i}-x_{a_{i+1}}\leq y_{b_i}(1\leq i\leq k,a_{k+1}=a_1)\)。左右累加可以得到\(0\leq \sum_{i=1}^k y_{b_i}\),而负环所以存在不等式\(\sum_{i=1}^k y_{b_i}<0\)。相矛盾,故存在负环时不等式组无解。

如果不存在负环,则不等式组存在解,令\(x_i=dis_i\),即可得到该不等式组的一组解。

上文讨论的是形如\(x_u-x_v\leq y\)的不等式,而对于形如\(x_u-x_v\geq y\),移项得到形式相同的不等式。而对于\(x_u=x_v\),只需要将其拆成两个不等式\(x_u-x_v\leq0,x_v-x_u\leq0\)即可。

连通性

\(DFS\)生成树

我们先了解\(DFS\)生成树。\(DFS\)生成树存在四种边。

\(1.\)树边:每次搜索到一个还未访问的结点的时候就形成了一条树边。

\(2.\)返祖边:也叫做回边,指向祖先结点的边。

\(3.\)横叉边:搜索到了已经访问过的点,但这个点不是它的祖先。

\(4.\)前向边:搜索时访问到了自己子树中的结点。

对于\(dfn\)值和生成树中边的关系,\(u->v\)

树边:\(!dfn[v]\)\(v\)此时没有被访问,需要接着从\(v\)开始搜,是树边。

返祖边:\(dfn[v]\lt dfn[u]\)

前向边:\(dfn[u]<dfn[v]\)

横叉边:\(dfn[u]>dfn[v]\)。证明:因为按照\(dfs\)的规定,在访问\(u\)时会访问所有和它相邻且未访问的点,这些边就是树边。而此时\(u,v\)是横叉边,说明\(v\)在搜索到\(u\)之前就已经被访问了,\(dfn[v]<dfn[u]\)

割点和割边

无向图中可以互通的点构成“连通分量”,其中存在关键的点,将它们删除会把这个连通分量断成两个或多个连通分量,这个点就是割点。类似的,删除一条边把连通分量断成两个连通分量的是割边。

\(1.\)求割点

我们考虑连通分量\(G\)中割点和其\(DFS\)生成树\(T\)的关系。

定理\(1\):\(T\)的根节点\(s\)是割点,当且仅当\(s\)有两个或更多的子树。

定理\(2\):\(T\)的非根节点\(u\)是割点,当且仅当\(u\)存在一个子节点\(v\)\(v\)及其后代都没有回退到\(u\)的边。

我们考虑定理\(2\)如何实现,在求\(DFS\)树时,记\(dfn[u]\)\(u\)的访问顺序,\(low[v]\)\(v\)及其后代能回退到最小的\(dfn[u]\)。那么只要\(low[v]\geq dfn[u]\),那么就是定理\(2\)的实现,\(u\)就是割点。因为这意味着\(v\)最多只能回退到\(u\),无法连到\(u\)的祖先,也就是割点。

割点和点双是分不开的,所以我们给定一些点双和割点的性质

\((1).\)两个点双最多只有一个公共点,并且这个点一定是割点。

\((2).\)对于一个点双,其中\(dfn\)值最小的点一定是割点或者点双的根节点。

由性质\((2)\),我们可以接着考虑:

\((1).\)\(dfn\)值最小的点是割点时,它一定也是这个点双的根节点。

\((2).\)当是根节点时:

\(a.\)根节点有两个及以上的子树,它是一个割点。

\(b.\)根节点只有一个子树,它是点双的根节点。

\(c.\)没有子树,它单独视为一个点双。

所以我们会发现求割点时,其一定是点双的根,所以求割点时需要考虑根节点。

\(2.\)求割边

只要\(low[v]>dfn[u]\)就能判断割边,这表示\(v\)及其后代只能回退到\(v\)而到不了\(u\),那么边\((u,v)\)就是割边。

下面是求割点的代码,割点需要考虑根节点。\(low[v]\ge dfn[u]\)其实相当于找到了割点或者点双的根节点,而这个点需要是割点,则需要满足上述性质\((2)\)的额外讨论情况。

void tarjan(int x,int fa){
	int rc=0;
	low[x]=dfn[x]=++tim;
	for(int i=hd[x];i;i=e[i].nxt){
		int v=e[i].to;
		if(!dfn[v]){
			tarjan(v,fa);
			low[x]=min(low[x],low[v]);//同属一棵子树,low直接用后代更新
			if(low[v]>=dfn[x]&&x!=fa) cut[x]=1;//求割点
			if(x==fa) rc++;//x=fa代表这是一棵子树的根节点
		}
		low[x]=min(low[x],dfn[v]);//返祖边
	}
	if(x==fa&&rc>=2) cut[x]=1;//如果x为根节点,并且有两棵以上不相连的子树,x就是割点
}

求割边只要改成\(low[v]>dfn[x]\)即可,而且不需要考虑根节点。

边双连通分量

对于无向图的边双连通性,如果在这张图中删去一条边后,图不连通,这说明两个点\(u\)\(v\)之间一定会经过这条边,这条边叫做割边(桥)。如果一张图中不存在割边,那么称这张图为边双连通图。一张图的极大边双连通子图称为边双连通分量。一张图的边双连通分量一定是不相交的,因为如果相交,我们任意删掉一条边,它们仍然连通。这说明点的”属于同一个边双连通分量“的关系具有传递性。

边双连通还存在另一个等价定义:对于任意的两点之间,至少存在两条不相交的路径。

在无向图中,求出所有边双连通分量后,将这些双连通分量替换成一个点,可以得到一棵树。

进行\(DFS\)跑搜索树,考虑原图中不在生成树上的边,这些边的意义在于把树的链归成同一个集合。基于这个转化,我们可以在加边时,用并查集在线维护树上的边双连通块。

可以发现额外加入生成树的边只有两种:

\(1.\)\(u,v\)属于祖先-子孙关系。

\(2.\)\(u,v\)所在的子树没有相交,这导致需要找\(u,v\)\(lca\),实现起来存在难度。

我们继续考虑\(DFS\)生成树的性质,生成树是不存在横叉边的,因为如果存在横叉边\((u,v)\),其中\(u\)\(v\)更早访问,那么当\(DFS\)\(u\)点时,一定会访问\((u,v)\)这条边,将这条边作为生成树中的一条边继续搜索,而不会跳过这条边。这与横叉边的含义(指向了已访问过的非祖先结点)矛盾。

进一步考虑图中连通块的性质:记点集中\(DFS\)序最小的为\(l\),最大的为\(r\),那么代表一个区间\([l,r]\)。对于树上不同的连通块肯定只存在相离和包含两种关系,就是像\(DFS\)一样,我们考虑用一个栈维护\(DFS\)经过的点,当一个点是点集中\(DFS\)序最小的点时,弹出栈顶的一段区间就是弹出了这一个连通块。

基于上述性质,具体维护两个值\(dfn(u),low(u)\)\(dfn(u)\)表示生成树中的\(u\)\(DFS\)序,\(low(u)\)表示\(u\)经过至多一条非树边能到达的\(dfn\)值最小的点\(v\)\(dfn\)值。

遍历一个子树,当发现\(low(v)>dfn(u)\),这说明\(v\)的子树不会再连到\(u\)的祖先上,\((u,v)\)就是一条割边。当发现\(low(u)=dfn(u)\)时,说明\(u\)是连通块中深度最小的点,弹出栈顶的一端区间就是一个边双连通分量。

int cnt=1;//用到异或求反向边,前向星中cnt初值应设为1
void add(int u,int v){
    e[++cnt].nxt=hd[u];
    e[cnt].to=v;
    hd[u]=cnt;
}
void dfs(int u,int lst){
    low[u]=dfn[u]=++tim;
    stk[++top]=u;
    for(int i=hd[u];i;i=e[i].nxt){
        if(i==(lst^1)) continue;//不走反边
        int v=e[i].to;
        if(!dfn[v]){//没搜索,是dfs树上的边
            dfs(v,i);
            low[u]=min(low[u],low[v]);//同属子树
            if(low[v]>dfn[u]){//找到割边
                edges[++cnt]=make_pair(min(u,v),max(u,v));//存下割边
            }
        }
        else{//考虑额外的边,一类是割边已经判过,这个是(u,v)为祖先-子孙关系的边
            low[u]=min(low[u],dfn[v]);
        }
    }
    if(low[u]==dfn[u]){
		int v;++idx;
		do bel[v=stk[top--]]=idx;while(v!=u);
	}
    /*if(low[u]==dfn[u]){
        ++idx;
        int v=-1;
        while(v!=u){
            v=stk[top];
            bel[v]=idx;//记录点同属的边双连通分量
            top--;
        }
    }*/
}

点双连通分量

圆方树学习笔记 - 洛谷专栏 (luogu.com.cn)

在无向图中,定义割点为删去一个点后,使得图不连通的点。如果一张图不存在割点,那么我们称这张图为点双连通图。极大的点双连通子图称为点双连通分量。

点双连通分量也有一个等价定义:对于任意两点\(u,v\)之间,至少存在两条点不相交(不包含\(u,v\))的路径。

每条边都不可能同时属于两个点双连通分量。因为如果两个点双连通分量的交大于\(1\),那么我们删去任意一点交仍大于等于\(1\),这说明两个点双连通分量的交最大为\(1\)

一个点可能属于多个点双连通分量,但是一条边只能属于一个点双连通分量。

我们可以把原图变为树形结构,化为圆方树。在圆方树中,原来的每一个点对应圆点,点双连通分量对应方点,所以共有\(n+c\)个点,\(c\)是点双的数量。点双对应的方点与其点双内包含的所有点连边。

可以发现每个点双都形成一个菊花图,不同的菊花图通过割点连接,因为连接不同点双的点就是割点。

image-20241201205215734

这张图展示出了一张图对应的点双和圆方树的形态。

只有原图连通,对应的圆方树才是一棵树。如果原图有\(k\)个连通分量,那么圆方树也是\(k\)棵树的森林。

构建圆方树

\(tarjan\)求点双时,最后点双内的点出栈时,我们将这些圆点和方点连起来,点双的方点我们从\(n+1\)开始编号用于区分。

只有边才保证只属于一个点双,而一个点可能属于不同的点双中。所以我们将点放入栈中,在找到一个以\(u\)为作为根的点双后,我们执行出栈操作,但\(u\)仍需保留在栈中,因为\(u\)可能还包含在其他点双中。

求割点时我们知道,对于\(low[v]\ge dfn[u]\)相当于找到了点双的根节点或者割点,在求点双时就相当于找到了一个点双,就可以新建一个方点。

而且在求点双时\(low[v]=dfn[u]\)\(low[v]\ge dfn[u]\)都能把点双找全。只不过第一个一定是找到了点双的根节点,然后找到了这个点双。而第二个是找到了点双的割点或根节点。

[P4630 APIO2018] 铁人两项 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)

可以当成是圆方树的板子题。

题意:给定一张简单无向图,求有多少三元组\(<s,c,f>\)\(s,c,f\)互不相同,使得存在从\(s\)出发经过\(c\)到达\(f\)的简单路径。

对于简单路径有点双的很好的性质:对于一个点双中的两个点,它们之间的简单路径的并集恰好等于这个点双。

即同一个点双中两个不同的点\(u,v\)之间一定存在一条简单路径经过同一个点双内的另一点\(w\)\(u,v\)可以之间的路径可以经过点双内的任意点。

\(s,f\)的路径可以对于到点权和上,方点的权值设为点双的点数,圆点的权值设为\(-1\),这样计算\(s,f\)的权值就是不包括路径端点,所能经过的点数。合法的\(c\)的数量就是这个值。

所以最后问题就转化为求任意圆点之间路径的权值和\(\sum val(s,f)\)

我们枚举中转点\(i\),考虑每个点可以产生的贡献,点\(i\)可以是圆点或方点,方点就相当于可以选点双内任意一点为\(c\)。贡献分为两类,一类是\(s,f\)分别在\(i\)的两个子树,贡献是\(2ll*w[u]*siz[u]*siz[v]\)\(w\)是该点的权值,圆点为\(-1\),方点为点双点数。另一类是\(s,f\)分别在子树内和子树外,\(2ll*w[u]*siz[u]*(num-siz[u])\)\(num\)为总数。因为\(s,f\)的起点终点可以互换,所以答案有一个\(\times 2\)

圆方树是建出来的新图,之前建在原图上出环了,\(debug\)好久。

#include<bits/stdc++.h>
using namespace std;
const int maxn=1e5+6;
typedef long long ll;
int n,m,idx,low[maxn],dfn[maxn],stk[maxn],top,tim,idx,siz[maxn<<1],w[maxn<<1],num;
ll ans;
vector<int>G[maxn<<1],T[maxn];
void tarjan(int u){
    low[u]=dfn[u]=++tim;
    stk[++top]=u;
    num++;
    for(int i=0;i<T[u].size();i++){
        int v=T[u][i];
        if(!dfn[v]){
            tarjan(v);
            low[u]=min(low[u],low[v]);
            if(low[v]>=dfn[u]){
                ++idx;int k=0;
                w[idx]=0;
                while(k!=v){
                    k=stk[top];
                    G[k].push_back(cnt);
                    G[cnt].push_back(k);
                    ++w[idx];
                    top--;
                }
                ++w[idx];
                G[u].push_back(idx);
                G[idx].push_back(u);
            }
        }
        else low[u]=min(low[u],dfn[v]);
    }
}
void dfs(int u,int fa){
    siz[u]=(u<=n);
    for(int i=0;i<G[u].size();i++){
        int v=G[u][i];
        if(v==fa) continue;
        dfs(v,u);
        ans+=2ll*w[u]*siz[u]*siz[v];
        siz[u]+=siz[v];
    }
    ans+=2ll*w[u]*(num-siz[u])*siz[u];
}
int main(){
    scanf("%d%d",&n,&m);
    idx=n;
    memset(w,-1,sizeof w);
    for(int i=1;i<=m;i++){
        int u,v;
        scanf("%d%d",&u,&v);
        T[u].push_back(v);
        T[v].push_back(u);
    }
    for(int i=1;i<=n;i++){
        if(!dfn[i]){
            num=0;--top;
            tarjan(i);
            dfs(i,0);
        }
    }
    cout<<ans<<endl;
    return 0;              
}

强连通分量

对于有向图\(G\),图的强连通定义为\(G\)上任意的点连通。

强连通分量\((Strongly\ Connected\ Components,SCC)\),定义是极大的强连通子图。

我们现在考虑\(DFS\)生成树和强连通分量的关系,如果结点\(u\)是某个强连通分量在搜索树中的第一个结点,那么该强连通分量中的其他结点一定都在以\(u\)为根的子树中。

\(Tarjan\)算法求强连通分量

\(Tarjan\)算法基于\(DFS\),把每个强连通分量看作搜索树中的一棵子树,维护一个栈,把一棵子树内尚未被搜索的点压入栈中。

\(Tarjan\)算法维护点\(u\)的相应信息

\(1.dfn[u]\):搜索时\(u\)的次序。

\(2.low[u]\)\(u\)的子树中能够回溯到栈中的最早的点的\(dfn\)值。

类似求边双连通分量,为了横叉边的合并方便,我们考虑横叉边的终点\(v\)在栈中,说明\(v\)并不是强连通分量里深度最低的点。于是淡化\(low\)仅通过一条非树边的含义,将\(low(u)\)的改为\(u\)所能到达的\(dfn\)序最小的点的\(dfn\)序。

void dfs(int u){
    low[u]=dfn[u]=++tim;
    stk[++top]=u;
    vis[u]=1;//标记入栈
    for(int i=hd[u];i;i=e[i].nxt){
        int v=e[i].to;
        if(!dfn[v]){//树边和横叉边
            dfs(v);
            low[u]=min(low[u],low[v]);
        }
        else if(vis[v]){//已入栈,包含前向边和返祖边
            low[u]=min(low[u],dfn[v]);//返祖边中dfn[v]一定比dfn[u]大,所以不影响
        }
    }
    if(low[u]==dfn[u]){
        ++idx;
        while(stk[top-1]!=u){
            bel[stk[top]]=idx;//记录点同属的强连通分量
            vis[stk[top]]=0;//标记出栈
            top--;
        }
    }
}

拓扑排序

只有有向无环图\((DAG)\)中才存在拓扑序。

\(AOV\)

\(DAG\)不同,\(AOV\)网中顶点表示活动,边代表活动之间的先后顺序。就好像一个大工程包含若干个小工程,这些子工程存在一些先后关系,即只有做好前期准备才能进一步进行某个子工程。

\(AOV\)网中不应该有环,判断有无环可以跑拓扑序,是否死循环。

\(AOE\)

顶点代表事件,边代表活动(持续时间)。\(AOE\)中有些活动是可以并行的,所以最短时间为从起点到终点的一条最长路径。

事件(顶点)的最早发生时间

事件的最早发生时间决定了以它为顶点的活动的最早发生时间。因为事件发生需要其前驱的所有事件都完成,所以事件最早发生时间为源点到该点的路径的最大值。\(ve(i)\)表示事件\(i\)的最早发生时间,递推公式为\(ve(i)=max(ve(j)+val(i,j)),j\in pre(i)\)

事件(顶点)的最晚发生时间

在不影响工期的情况下,事件最晚发生时间记为\(vl(i)\)。它等于该事件的所有后继活动发生的最晚时间的最小值,即\(vl(i)=min(vl(j)-val(i,j)),j\in nxt(i)\)

活动(边)的最早发生时间

记为\(e(u,v)\),显然\(e(u,v)=ve(u)\)

活动(边)的最晚发生时间

记为\(l(u,v)\),表示在不影响下一个事件的情况下,活动的最晚发生时间,显然\(l(u,v)=ve(v)-val(u,v)\)

关键路径

\(AOE\)网上从源点到汇点的最长路径的长度。

关键活动

即关键路径上的活动,它的最早开始时间和最晚开始时间相等。

实现

用一个队列存入入度为\(0\)的点,遍历并删除其出边,重复此过程。

int queue<int>q;
void topo(){
	for(int i=1;i<=n;i++) if(!ind[i]) q.push(i);
	while(!q.empty()){
		int u=q.front();
		q.pop();
		for(int i=hd[u];i;i=e[i].nxt){
			int v=e[i].to;
			ind[v]--;
			if(!ind[v]) q.push(v);//无入度直接入队
		}
	}
} 
求字典序最大(小)的拓扑序

将队列换成最大堆(最小堆)实现的优先队列即可。

欧拉图

相关定义

欧拉路径:经过连通图中所有边恰好一次的迹叫做欧拉路径。

欧拉回路:经过连通图中所有边恰好一次的回路叫做欧拉回路。

欧拉图:有欧拉回路的图叫做欧拉图。

半欧拉图:存在欧拉路径但无欧拉回路的图叫做半欧拉图。

欧拉图的判定

有向图

考虑有向欧拉图 \(G\)的性质。

首先\(G\)显然是弱连通,其次,回路上从每个点出发的边数一定等于到达该点的边数,所以\(G\)中每个点的出度等于入度。

对于满足上述条件的\(G\),我们从任意点\(u\)出发不断走当前没有走过的边,得到任意回路\(C\)。不会在任意\(v\ne u\)停下,因为此时\(v\)的走过的入边数量大于走过的出边数量。

\(C\)的边删掉,并且将度数为\(0\)的点从\(G\)中删掉,得到若干连通子图\(G_i\)\(G_i\)仍满足每个点的入度等于出度,并且\(G_i\)\(C\)有交点(由\(G\)的弱连通性可证)。若\(G_i\)存在欧拉回路,则将\(G_i\)的欧拉回路插入\(C\),即可得\(G\)的欧拉回路。

归纳可得有向欧拉图的判定条件:有向图\(G\)是欧拉图,当且仅当\(G\)弱连通且每个点的入度等于出度。

半欧拉图的判定条件:有向图\(G\)是半欧拉图,当且仅当\(G\)弱连通,存在两个点的入度分别等于出度加一和减一,其余各点的入度和出度相等。

无向图

考虑无向欧拉图\(G\)的性质。

首先\(G\)显然连通。其次,对于非起始点,欧拉回路中每个点进入之后肯定会离开。对于起始点,每次离开后肯定会进入。进入和离开分别对度数有一的贡献,所以每个点的度数都是偶数。

对于满足上述条件的\(G\),我们从任意点\(u\)出发不断走当前没有走过的边,得到任意回路\(C\)。不会在任意\(v\ne u\)停下,因为此时\(v\)走过的邻边数为奇数,所以总存在没走过的邻边。如果把自环看成两条邻边,也能理解成\(v\)走过的邻边数为奇数。

\(C\)的边删掉,并且将度数为\(0\)的点从\(G\)中删掉,得到若干连通子图\(G_i\)\(G_i\)仍满足每个点的度数为偶数,并且\(G_i\)\(C\)有交点。若\(G_i\)存在欧拉回路,则将\(G_i\)的欧拉回路插入\(C\),即可得\(G\)的欧拉回路。

归纳可得无向欧拉图的判定条件:无向图\(G\)是欧拉图,当且仅当\(G\)连通,且每个点的度数都是偶数。

无向半欧拉图的判定条件:无向图\(G\)是半欧拉图,当且仅当\(G\)连通,且存在两个点度数为奇数,其余点度数都为偶数。

\(Hierholzer\)

先考虑有向图,因为无向图需要考虑重边。\(Hierholzer\)的核心是不断往当前回路的某个点中插入环。具体的,从任意点\(dfs\),找到一个任意回路\(C\),然后删掉\(C\)中的边,依次加入\(C\)的每个节点,\(p_1,p_2,...,p_{|C|}\)。在加入\(p_i\)之前,先递归找到\(p_i\)所在连通子图的欧拉回路,再将其加入\(C\),这是一个递归形式的算法。

使用链式前向星维护每个点的剩余出边(类似\(Dinic\)的当前弧优化),每条边只会被遍历一遍。用双向链表维护回路的合并,时间复杂度\(O(n+m)\)

void dfs(int u){
    for(int i=1;i<=200;i++){
        if(e[u][i]){
            e[u][i]=e[i][u]=0;//走过不能再走,删边
            dfs(i);
        }
    }
    ans[++tot]=u;
}
int main(){
    scanf("%d",&n);
    for(int i=1;i<=200;i++) fa[i]=i;
    for(int i=1;i<=n;i++){
        scanf("%s",s);
        e[s[0]][s[1]]=e[s[1]][s[0]]=1;
        merge(s[0],s[1]);//并查集判连通
        d[s[0]]++;d[s[1]]++;
    }
    for(int i=1;i<=200;i++)
        if(fa[i]==i&&d[i]) cnt++;//记录祖先,判是否连通
    if(cnt!=1){
        puts("No Solution");
        return 0;
    }
    cnt=0;
    for(int i=1;i<=200;i++){
        if(d[i]&1){//记录奇数度数的点
            cnt++;
            if(st==0) st=i;
        }
    }
    if(cnt&&cnt!=2){//存在奇数度数的点,且不等于两个,不存在欧拉路径
        puts("No Solution");
        return 0;
    }
    if(st==0){//欧拉回路
        for(int i=1;i<=200;i++)
            if(d[i]){
                st=i;
                break;
            }
    }
    dfs(st);
    for(int i=n+1;i;i--) cout<<ans[i];
    return 0;
}

无向图找环

找环上的所有点,只需要找到环上的一条边的两个点,就可以遍历出环上所有点。

并查集做法

遍历图,对一条边的两点,如果它们不属于一个集合,就将它们合并。当存在环时,肯定会遍历到一条边,而边上的两点已经同属于一个集合。

\(DFS\)做法

无向图存在环就相当于存在返祖边。用连通性的知识,\(dfn\)记录最早时间戳。如果遍历到一个比它时间戳小的,即找到了返祖边。

找负环(正环)

找负环可以用\(spfa\)算法,如果不存在负环时,正常每次\(spfa\)跑最短路只会遍历\(n\)轮,如果一个点超过了\(n\)轮,那么就说明存在负环。

同理,找正环时可以将边权取负,再跑\(spfa\)

拆点

结点有流量限制的最大流

结点有流量限制的最大流考虑拆点,对于每个点\(u\),流量限制为\(c(u)\)。我们把它拆成两个点,连上边权为\(c(u)\)的边。

分层图最短路

1.边的性质存在多种

$eg$.有$k$次不消耗代价的移动,可以拆成$k+1$层图

$eg$.经过某节点后边权减半或增加

2.经过\(k\)步到达终点的最短路

(待施工)最大独立集

这在一般图中是 \(NP\)完全问题

增广路

在介绍最大匹配前,先了解一下它的重要理论:增广路定理

增广路定理

交错路\((alternating\ path)\):始于非匹配点,且由匹配边和非匹配边相互交错形成。

增广路\((augmenting\ path)\):始于非匹配点且终于非匹配点(除了起始点)的交错路,易知增广路的边数为奇数。

增广路上非匹配边比匹配边多\(1\),如果我们将非匹配边和匹配边反转,则匹配边的数量\(+1\),且仍是交错路。

匈牙利算法中只通过这种方法增加匹配数,当找不到增广路的时候即为最大匹配。所以我们得到最大匹配的核心思路,就是枚举每个未匹配点找增广路,直到找不到增广路。

证明

事实上,对每个点只需要枚举一次即可。假设在一次对\(a-b\)的增广中,新增了以未匹配点\(x\)为起点的增广路\(P_x\),则\(P_x\)一定会与\(a-b\)路径相交(否则不可能由\(a-b\)路径增广出这条路径),由于交错路的性质,相交点的相邻两条边一定是匹配和非匹配,这说明增广前\(P_x\)就能走到\(a-b\)中的某个未匹配点,说明此前已存在以\(x\)为起点的增广路,所以已经枚举过的点不可能再作为增广路的起点。

交错树

从未匹配点\(r\)进行\(DFS,BFS\)寻找增广路的过程中形成的树称为交错树,\(r\)是交错树的根。设\(T=(V_t,E_t)\)为再寻找增广路时产生的交错树。定义:

偶点:交错树上深度为偶数的点。

奇点:交错树上深度为奇数的点。

\(2-SAT\)

\(SAT\)是适定性\((satisfiability)\)问题的简称。一般适定性问题称为\(k-SAT\),但是\(k>2\)时这个问题是\(NP\)完全的,故只研究\(2-SAT\)

二分图

二分图概念:如果无向图\(G(V,E)\)的所有点都可以分为两个集合 \(V_1,V_2\),所有边都在\(V_1,V_2\)之间,而\(V_1,V_2\)内部没有边,称\(G\)是一个二分图。

判断是否为二分图,一般用染色法,用两种颜色对顶点进行染色,要求一条边连接的两个端点的颜色不同。染色结束后,如果能实现每一条边的相邻结点的颜色都不相同,那么就是一个二分图。

一个图是二分图,当且仅当它没有边的数量为奇数的环。如果有一个边数为奇数的环,那么一定会出现相邻的同颜色的点。

染色法可以用\(dfs\)\(bfs\)实现,当搜索到点\(u\)时,检查它的邻居\(v\),如果\(v\)未染色,则把\(v\)染成和\(u\)不同的颜色;如果\(v\)已染色且与\(u\)的颜色相同,则染色失败,不是二分图。

二分图染色

bool bfs(int s){
    queue<int>q;
    q.push(s);
    vis[s]=1;
    sum[1]=1;sum[2]=0;
    while(!q.empty()){
        int u=q.front();
        q.pop();
        for(int i=hd[u];i;i=e[i].nxt){
            int v=e[i].to;
            if(vis[v]==vis[u]){//如果u,v颜色相同,染色失败
                return 1;
            }
            if(!vis[v]){//如果v没染色
                vis[v]=vis[u]%2+1;//染成和u不同的颜色
                sum[vis[v]]++;//记录不同颜色的点的数量
                q.push(v);//加入队列
            }
        }
    }
    return 0;
}
bool dfs(int u,int col){//搜索u,应该染的色是col(1,2)
    if(vis[u]){//如果已染色
        if(vis[u]==col) return 1;//如果已染色和需染色相同,染色成功
        return 0;//否则染色失败
    }
    vis[u]=col;//记录u的颜色
    sum[col]++;//记录col颜色的点的数量
    bool flag=1;
    for(int i=hd[u];i;i=e[i].nxt){
        int v=e[i].to;
        flag=flag&&dfs(v,col%2+1);//搜索u的邻居v,看是否成功
    }
    return flag;
}
int main(){
    scanf("%d%d",&n,&m);
    for(int i=1;i<=m;i++){
        int u,v;
        scanf("%d%d",&u,&v);
        add(u,v);add(v,u);
    }
    for(int i=1;i<=n;i++){
        if(vis[i]) continue;//搜索过的就不搜
        sum[1]=sum[2]=0;//初始化点的数量
        if(!dfs(i,1)){
            puts("Impossible");
            return 0;
        }
        ans+=min(sum[1],sum[2]);
    }
    cout<<ans<<endl;
    return 0;
}

二分图最大匹配

二分图匹配:给定一个二分图\(G\),在\(G\)的一个子图\(M\)中,\(M\)的边集\(\{E\}\)中任意两条边都不连在同一个顶点上,则称\(M\)是一个匹配。

二分图最大匹配:在所有匹配中边数最多的匹配。

最大流做法

改为有向边容量为\(1\),在\(V_1\)加入一个人为源点\(s\),在\(V_2\)加入一个人为汇点\(t\)\(t\)连接\(V_2\)中的每个点。那么\(s->t\)的最大流就是二分图的最大匹配。用\(Dinic\)算法,时间复杂度\(O(n^2m)\)

匈牙利算法

匈牙利算法的引入考虑一个例子,有两个点集\(V_1,V_2\)表示男生和女生,男女之间的连边代表一种暧昧关系。假设你作为月老,希望有最多对情侣(即在二分图中最多能找到多少条没有公共端点的边)。现在我们考虑怎么配对,假设首先选\(B_1\),找到与\(B_1\)有暧昧关系的\(G_1\),我们先预设他们结缘,再找\(B_2\),发现\(B_2\)\(G_1\)也有关系(现实中这有些违背伦理),\(G_1\)已经配对,这时我们返回\(B_1\),如果有其他的\(G\),那我们重新安排。

考虑我们上文介绍的增广路定理,我们考虑先从左边的未匹配点增广,由于交错路的性质,增广路上的第奇数条边都是非匹配边,第偶数条边都是匹配边,于是从左边到右边的边都是非匹配边,右边到左边的边都是匹配边。于是我们给二分图定向,问题转换成有向图中给定起点,找到一条到未匹配点的路径。相当于在图中找一条从\(s\)\(t\)的路径,\(DFS\)复杂度\(O(m)\),总共枚举\(n\)个点,总复杂度\(O(nm)\)

从左边开始增广,\(match[i]\)表示右边的点\(i\)配对的点。

int dfs(int u){
    for(int i=hd[u];i;i=e[i].nxt){
        int v=e[i].to;
        if(!vis[v]){
            vis[v]=1;//每次都只搜一遍图
            if(!match[v]||dfs(match[v])){//match[v]表示和v配对的点。
                //如果v还没有配对的点,就让u和它配对,可以增广
                //或者当前与v配对的点可以找到其他点配对,那我们重新给它们配对
                match[v]=u;
                return 1;
            }
        }
    }
    return 0;
}
int main(){
    cin>>n>>m>>k;
    for(int i=1;i<=k;i++){
        int u,v;
        cin>>u>>v;
        add(u,v);
    }
    for(int i=1;i<=n;i++){
        ans+=dfs(i);
        memset(vis,0,sizeof vis);
    }
    cout<<ans<<endl;
    return 0;
}

网络流

网络是指一种特殊的有向图,其与一般有向图不同的地方在于,有容量和源汇点。每条边有一个权值\(c(u,v)\),称为容量。图中有两个特殊的点,源点\(s\),汇点\(t\)。运用生活中的例子引入,相当于不同的水龙头通过不同的水管连接,水管有容量限制。

基本定义

一个网络是一张有向图\(G\in(V,E)\),对于每条有向边\((u,v)\in E\),存在一个流量限制\(c(u,v)\)。特别的,如果\((u,v)\notin E,c(u,v)=0\)

网络的可行流分为有源汇和无源汇,一般用\(S\)表示源点,\(T\)表示汇点。这两种的流函数\(f\)有相同的性质。

定义流函数\(f(u,v)\)是从二元有序数对\((u,v)\)到实数集\(R\)的映射,其中\(u,v\in V\)\(f(u,v)\)称为边\((u,v)\)的流量。

\(f\)满足容量限制\(f(u,v)\le c(u,v)\)。每条边都满足容量限制,每条边都不能超过容量,若\(f(u,v)=c(u,v)\),则称边\((u,v)\)满流。

\(f\)具有斜对称性质:\(f(u,v)=-f(v,u)\)

\(f\)具有流量守恒的性质:除源汇点外(无源汇网络则不存在源汇点),每个节点流入和流出的流量相等,即\(\forall i\neq S,T,\sum f(u,i)=\sum f(i,v)\)。节点不存储流量。

对于有源汇网络,根据斜对称和容量守恒性质,可以得到\(\sum f(S,i)=\sum f(i,T)\),此时这个相等的和称为当前流\(f\)的流量。

定义流\(f\)在网络\(G\)上的残量网络\(G_f=(V,E_f)\)的容量函数为\(c_f=c-f\)的网络。根据容量限制有\(c_f(u,v)\ge 0\),若有\(c_f(u,v)=0\),则认为\((u,v)\)在残量网络上不存在。换句话说,将每条边的容量减去流量后,删去满流边就是残量网络。

定义增广路\(P\)是残量网络\(G_f\)上从源点到汇点的一条路径。无源汇网络不讨论增广路。

\(V\)分成互不相交的两个点集\(A,B\),其中\(S\in A,T\in B\),这种点的划分方式称为,定义割的容量为\(\sum\limits_{u\in A}\sum\limits_{v\in B}c(u,v)\),流量为\(\sum\limits_{u\in A}\sum\limits_{v\in B}f(u,v)\)。若\((u,v)\)所属点集不同,则称有向边\((u,v)\)为割边。

最大流

给定网络\(G=(V,E)\)和源汇点,求最大流量\((Maximumu\ flow)\)

增广

\(EK\)\(dinic\)都用了不断寻找增广路能流满就流满的贪心思想。

找到残量网络\(G_f\)上一条增广路\(P\),并为\(P\)上每一条边增加\(c_f(p)=\mathop {min}\limits_{(u,v)\in P} c_f(u,v)\)的流量。为什么是这个值呢,如果增加的流量大于这个值,那么有的边不满足容量限制。而根据能流满就流满的贪心思想,增加的流量也不应该小于这个值。

我们在增广的过程中尽量流满一条增广路,同时每条边的流量在增广的过程中不会减少,这个贪心为什么是正确的。

初始网络\(G\),对于\((u,v)\in E\),建图\(c(u,v)=val\)。同时建反边\((v,u)\),令\(c(v,u)=0\)

一般网络流是基于有向图的,令反边容量为\(0\)。而无向图要求最大流时,令反边容量和正边容量相等\(c(v,u)=c(u,v)\)

在为\((u,v)\in P\)增加流量\(c_f(P)\)的时候,为反向边\((v,u)\)容量加上\(c_f(P)\),这样的目的是支持了反悔,收回给出的一部分流量。体现在\(G_f'\)上,就是新的\(G_f'\)\(c_f'(u,v)\)减少了\(c_f(P)\)\(c_f'(v,u)\)增加了\(c_f(P)\)

上述操作称为一次增广

关于增广有一个技巧:成对变换。用链式前向星建图,将每条边和它的反边连续存储,编号从\(2\)开始,第\(k\)条边对应的边就是\(k\ xor\ 1\)。为此我们的\(cnt\)初始应设为\(1\)

最大流最小割定理

网络流的一个结论:最大流等于最小割

任意一组流的流量不大于任意一组割的流量:

考虑每单位流量,流过\(u\in A,v\in B\)的割边\((u,v)\)的次数为\(to\),流过割边\((v,u)\)的次数为\(back\)。必然有\(to=back+1\),不然不可能从\(S\)流到\(T\)

每单位对割边流量之和的贡献为\(to-back=1\),因此网络总流量等于割边流量之和。

对每一种流的方案都应用上述结论,根据容量限制,我们得到流的流量\(\le\)割的容量。

存在一组流的流量等于割的容量。

我们断言最大流存在,此时残量网络不连通。若连通,则可以继续增广,这与最大行矛盾。所以这自然提供了一种割的方案,使其割的容量等于流量。

\(Ford-Fulkerson\)

网络上一条从源点\(s\)到汇点\(t\)的路径称为增广路,记录增广路的流量为\(c\),对增广路上的边\((u,v)\),我们减去流量\(c\)。需要注意的是,我们同时建一条反向边\((v,u)\),其边权加上\(c\),这使得流量可以实现回退操作,保证了最大流算法的正确性。

\(Ford\)算法的具体思路是,不断搜索增广路,每搜到一条增广路就将答案累加上这次的流量,直到搜索不到增广路,此时答案即为最大流。

该算法成立依赖于最大流最小割定理的推论,即一个流是最大流,当且仅当它的残留网络中不存在增广路。

该算法用\(DFS,BFS\)都可以实现,但是用\(BFS\)可能会绕圈子,导致搜索次数过多。

\(Edmonds-Karp\)

\(EK\)算法的核心是利用\(bfs\)寻找长度最短的增广路。为此,我们记录流向每个点的边的编号,然后不断从汇点\(T\)反推到源点\(S\)。时间复杂度\(O(nm^2)\)

注意,任意选择增广路增广,复杂度会退化成和流量相关\((FF)\),因为\(EK\)的复杂度证明需要用到增广路长度最短的性质。

\(EK\)算法的复杂度很高,只能用于小图,直接用邻接矩阵存图就行。

#include <bits/stdc++.h>
using namespace std;
const int M=5e3+5;
const int maxn=250;
typedef long long ll;
int n, m, s, t,cnt=1,hd[maxn],pre[maxn];
ll fl[maxn];
struct edge{
	int to,nxt;
	ll val;
}e[M<<1];
void add(int u,int v,ll val){
	e[++cnt].nxt=hd[u];
	e[cnt].to=v;
	e[cnt].val=val;
	hd[u]=cnt;
}
ll maxflow(int s,int t){
	ll flow=0;
	while(1){
		queue<int>q;
		memset(fl,-1,sizeof fl);
		fl[s]=1e18;
		q.push(s);
		while(!q.empty()){
			int u=q.front();
			q.pop();
			for(int i=hd[u];i;i=e[i].nxt){
				int v=e[i].to;
				if(fl[v]==-1&&e[i].val){//没有到v,且该边有容量
					fl[v]=min(fl[u],e[i].val);
					pre[v]=i;//记录到v的边的编号
					q.push(v);
				}
			}
		}
		if(fl[t]==-1) return flow;//经过一轮bfs后没有找到增广路,增广结束,返回最大流
		flow+=fl[t];
		for(int u=t;u!=s;u=e[pre[u]^1].to){//从t反推到s
			e[pre[u]].val-=fl[t];//pre存的是正边,容量减去流量
			e[pre[u]^1].val+=fl[t];//pre^1存的是反边,容量加上流量
		}
	}
}
int main() {
  	cin>>n>>m>>s>>t;
	for(int i=1;i<=m;i++){
		int u,v;
		ll w;
		cin>>u>>v>>w;
		add(u,v,w);add(v,u,0);
	}
	cout<<maxflow(s,t)<<endl;
  	return 0;
}

复杂度证明:

我们需要这样一条引理:每次增广后残量网络上\(S\)到每个节点的最短路长度不减。

\(Dinic\)

\(Dinic\)算法的核心思想是分层图以及相邻层之间增广,通过\(bfs\)\(dfs\)实现。\(bfs\)先给图分层,分层后从\(S\)开始\(dfs\)多路增广。维护当前节点和剩余流量,向下一层节点继续流。

给图分层的目的是将网络视为\(DAG\),规范增广路的形态,防止形成环。

\(Dinic\)算法有重要的当前弧优化。增广时,容量等于流量的边无用,因为下一轮增广这条边的容量限制不能提供新的容量。所以增广时,容量等于流量的边可以直接跳过,不需要每次搜索同一个点时都从邻接表头开始遍历。为此,记录从每个点出发的第一条没有流满的边,称为当前弧。每次搜索到一个点就从其当前弧开始增广。

注意,每次多路增广前每个点的当前弧都应初始化为邻接表头,因为并非一旦流量等于容量,这条边就永远无用,反向边流量的增加也会让它出现在残量网络中。

当前弧优化后的\(Dinic\)的时间复杂度为\(O(n^2m)\)

#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
const int N=205;
const int M=5005;
int n,m,s,t,T,cnt=1,hd[N],cur[N],dis[N];
struct edge{
	int to,nxt;
	ll val;
}e[M<<1];
void add(int u,int v,ll w){
	e[++cnt].nxt=hd[u];
	e[cnt].to=v;
	e[cnt].val=w;
	hd[u]=cnt;
}
ll dfs(int u,ll res){
	if(u==T) return res;
	ll flow=0;
	for(int i=cur[u];res&&i;i=e[i].nxt){
		cur[u]=i;//当前弧优化
		int v=e[i].to;
		ll c=min(res,e[i].val);
		if(dis[v]==dis[u]+1&&c){
			ll k=dfs(v,c);//dfs多路增广
			flow+=k;res-=k;
			e[i].val-=k;e[i^1].val+=k;
		}
	}
	if(!flow) dis[u]=-1;
	return flow;
}
ll maxflow(int s,int t){
	T=t;
	ll flow=0;
	while(1){
		queue<int>q;
		memcpy(cur,hd,sizeof hd);
		memset(dis,-1,sizeof dis);
		q.push(s);
		dis[s]=0;
		while(!q.empty()){
			int u=q.front();
			q.pop();
			for(int i=hd[u];i;i=e[i].nxt){
				int v=e[i].to;
				if(dis[v]==-1&&e[i].val){
					dis[v]=dis[u]+1;//bfs图分层
					q.push(v);
				}
			}
		}
		if(dis[t]==-1) return flow;//没有增广路了
		flow+=dfs(s,1e18);
	}
}
int main(){
	scanf("%d%d%d%d",&n,&m,&s,&t);
	for(int i=1;i<=m;i++){
		int u,v;ll w;
		scanf("%d%d%lld",&u,&v,&w);
		add(u,v,w);
		add(v,u,0);
	}
	cout<<maxflow(s,t);
	return 0;
}

平面图最小割与对偶图最短路

平面图

平面图是无向图,且一条边除了两个公共顶点没有其他交叉点(即任意两条边的交点只能是图的顶点)。

网格图就是典型的平面图。

由平面图的边包围而成,其中不含图的顶点的称为。包含面\(R\)的所有边组成的回路称为面的边界,回路的长度称为面的度,记为\(deg(R)\)。由平面图的边包围且无限大的面称为外部面,一个平面图有且只有一个外部面。

欧拉公式:若一个连通平面图有\(n\)个点,\(m\)条边和\(f\)个面,则有\(f=m-n+2\)

对偶图

每一个平面图\(G\)都有一个与之对应的对偶图\(G^*\)

\(G^*\)中的每个点对应\(G\)中的每个面。

对于\(G\)的每条边\(e\)

\(1.\)\(e\)属于两个面\(f_1,f_2\),则向对偶图\(G^*\)中加入边\((f_1^*,f_2^*)\)

\(2.\)\(e\)属于一个面\(f_1\),则向对偶图\(G^*\)中加入回边\((f_1^*,f_1^*)\)

可以发现对偶图的边\(e*\)和原图的边\(e\)是相交的,那么对偶图中的一个回路就对应着原图的一组割。

对偶图在最大流最小割的应用

求平面图\(G\)的最大流,由最大流-最小割定理等价于求\(G\)的最小割。

考虑建对偶图\(G^*\)\(G^*\)的回路对应\(G\)的割,但是直接建出平面图\(G\)的对偶图\(G^*\)并不清楚二者回路与割的对应关系,这是不好实现且有难度的。

因此我们更换建图方式,假设求\(G\)\(s-t\)的割,则在原平面图\(G\)中加上附加边\((s,t)\)。建立新图的对偶图,设连接\((s,t)\)得到的附加面为\(s^*\),外部面为\(t^*\)。则\(s^*-t^*\)的最短路对应了\(G\)\(s-t\)的最小割。进一步的令对偶图边权等于\(G\)对应边的容量,则\(s^*-t^*\)的最短路长度等于\(s-t\)最小割流量。

显然求一张图的最短路是比直接求最小割复杂度更优的。

\(example.P4001\)狼抓兔子

题意:

一个网格图,兔子只有两个洞,分别是\((1,1),(N,M)\)。有横边,竖边,斜边三种边,边权表示这条边能通过的最大兔子数。一只狼抓一只兔子,求安排最少的狼保证抓到所有的兔子。

题解:

原问题相当于选择一些边安排狼,使得兔子无法从\(s\)到达\(t\)。即求\(s-t\)的一组最小割,网格图是一种典型的平面图,平面图最小割就等于其对偶图最短路,连接\((s,t)\)建出对偶图求\(s^*-t^*\)的最短路即可。

无负环的费用流

费用流一般指最小费用最大流\((Minimum\ cost\ maximum\ flow,MCMF)\)

相较于一般的网络最大流,在原有网络\(G\)的基础上,每条边多了一个权值属性\(w(u,v)\)。最小费用最大流要求我们在保证最大流的基础上,求出\(\sum\limits_{(u,v)\in E}f(u,v)\times w(u,v)\)的最小值。

简单地说,\(w\)就是边每流一单位流量所需要的花费。我们要最小化这个花费,所以叫做费用流。

\(SSP\)

连续最短路算法\((Successive\ Shortest\ Path)\),算法核心思想是每次找到长度最短的增广路进行增广。且仅在网络初始无负环时得到正确答案。

基于\(EK\)实现的\(SSP\),将\(bfs\)替换成\(SPFA\)(设每条边的长度为\(w\))。

\((x,y)\)在退流流过\((y,x)\)时费用也要退掉,所以对于原网络每条边\((x,y)\),其反边\((y,x)\)的权值\(w(y,x)\)应设为\(-w(x,y)\)

时间复杂度为\(O(nmf)\),其中\(f\)为最大流量。实际使用中上界非常松,因为增广次数远远不到\(f\),并且\(spfa\)复杂度也远远不到\(nm\)

最大流不卡\(dinic\),费用流不卡\(EK\)

#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
const int N=5e3+5;
const int M=5e4+5;
const ll inf=1e18;
int n,m,s,t,cnt=1,hd[N],pre[N],vis[N];
ll dis[N],fl[N];
struct edge{
	int to,nxt;
	ll lim,cst;
}e[M<<1];
void add(int u,int v,ll lim,ll cst){
	e[++cnt].nxt=hd[u];
	e[cnt].lim=lim;
	e[cnt].cst=cst;
	e[cnt].to=v;
	hd[u]=cnt;
}
pair<ll,ll> mincost(int s,int t){
	ll flow=0,cost=0;
	while(1){
		for(int i=1;i<=n;i++) dis[i]=inf,vis[i]=0,fl[i]=-1;
		dis[s]=0;vis[s]=1;fl[s]=1e18;
		queue<int>q;
		q.push(s);
		while(!q.empty()){
			int u=q.front();
			q.pop();
			vis[u]=0;
			for(int i=hd[u];i;i=e[i].nxt){
				int v=e[i].to;
				if(dis[v]>dis[u]+e[i].cst&&e[i].lim){
					fl[v]=min(fl[u],e[i].lim);
					pre[v]=i;
					dis[v]=dis[u]+e[i].cst;
					if(!vis[v]){
						vis[v]=1;
						q.push(v);
					}
				}
			}
		}
		if(dis[t]>=inf) return make_pair(flow,cost);
		flow+=fl[t];cost+=dis[t]*fl[t];
		for(int u=t;u!=s;u=e[pre[u]^1].to){
			e[pre[u]].lim-=fl[t];
			e[pre[u]^1].lim+=fl[t];
		}
	}
	
}
int main(){
	scanf("%d%d%d%d",&n,&m,&s,&t);
	for(int i=1;i<=m;i++){
		int u,v;ll lim,cst;
		scanf("%d%d%lld%lld",&u,&v,&lim,&cst);
		add(u,v,lim,cst);
		add(v,u,0,-cst);
	}
	pair<ll,ll>ans=mincost(s,t);
	cout<<ans.first<<' '<<ans.second<<endl;
	return 0;
}

最小路径覆盖问题

给定有向图\(G(V,E)\),设\(P\)\(G\)的简单路(路径的顶点不相交)的一个集合,如果\(V\)中每个点均在\(P\)的路径中,则称\(P\)\(G\)的一个路径覆盖。

最小路径覆盖问题:给定一个有向无环图\(G\),找到一个路径覆盖集合\(P\),使得\(P\)中的路径数最少。

考虑拆点,将每个点都视为一条路径,那么初始时原图由\(n\)条路径覆盖。将\(u\)拆成两个点,\(u\)\(u+n\)\(u\)表示入点,\(u+n\)表示出点。

posted @ 2025-03-27 22:17  青涩的Lemon  阅读(108)  评论(0)    收藏  举报