图论
图论基础
1.图的建立
根据抽屉原理,具有至少两个顶点的简单无向图(简单图:不含有自环和重边的图)中一定存在度相同的结点。
握手定理(又称图论基本定理):对于任何无向图 G = (V,E),有 ∑d(v)=2|E|。
在任意无向图中,度数为奇数的点必然有偶数个。
对于任意有向图 G=(V,E),有 ∑d_out(v)=∑d_in(v)=|E|。
对于有向图,我们有下面两种方法建图。
对于无向图,可以把无向边看作两条方向相反的有向边。
有向图G=(V,E),Ver是点集,Edge是边集,(x,y)表示从x到y的有向边,其边权为w(x,y)。设n=|V|,m=|E|。
1.1.稠密图——邻接矩阵\(O(N^2)\)
优点:简单,可以 O(1) 查询一条边是否存在。
缺点:复杂度较大,不好处理重边的情况,不能对一个点的所有出边进行排序。
\(w[u][v]\):从点u到点v的有向边的费用。
\(w[u][v]=\begin{cases} 0,(u=v) \\ w(u,v),((u,v)\in E) \\ \infty,((u,v)\notin E) \end{cases}\)
初始化:memset(w,0x3f,sizeof w);。
遍历每条边且要知道起终点:for(int u=1;u<=n;u++) for(int v=1;v<=n;v++) if(u!=v && w[u][v]<INF) cout<<u<<' '<<v<<' '<<w[u][v]<<endl;。

1.2.稀疏图——邻接表\(O(\max (N,M))\)
vector
优点:较简单,可以对一个点的所有出边进行排序。
//无权图
vector<int> e[N];
e[u].push_back(v);//u→v
//有权图
vector<pair<int,int>> e[N];
e[u].push_back({v,wor});//u→v,w(u,v)=wor
//初始化
for(int u=1;u<=n;u++) e[u].clear();
//遍历每条边且要知道起终点
for(int u=1;u<=n;u++)
for(auto it : e[u])
cout<<u<<' '<<it.first<<' '<<it.second<<endl;

链式前向星
《数据结构·字符串和图6.邻接表》
优点:效率高,常用(网络流必须用链式前向星),边带编号(可以利用编号和位运算的成对变换判断反向边)。
缺点:不能快速查询一条边是否存在,不能对一个点的所有出边进行排序。
遍历每条边且要知道起终点:for(int u=1;u<=n;u++) for(int i=h[u];i!=0;i=ne[i]) cout<<u<<' '<<e[i]<<' '<<w[i]<<endl;。

1.3.优化建图
注意算好点和边的数量。点的数量可以参考n+vidx,边的数量可以参考add操作次数。
1.3.1.不直接建边
适用条件:转移不依赖于建边。只优化空间复杂度而不优化时间复杂度。
\(e.g.\)同余最短路。
1.3.2.区间——线段树优化建图
适用条件:从序列上区间\([l_1,r_1]\)上的所有节点向区间\([l_2,r_2]\)上的所有节点连长度为w的边。
线段树上的节点储存out和in,代表该节点所对应的区间的所有点的出点和入点。
-
预处理:线段树建树中的pushup中,从儿子的出点向父亲的出点连一条权值为0的边,从父亲的入点向儿子的入点连一条权值为0的边。
-
对于序列上区间\([l_1,r_1]\)上的所有节点向区间\([l_2,r_2]\)上的所有节点连长度为w的边,建立1个虚点,从线段树上组成区间\([l_1,r_1]\)的\(O(\log N)\)区间的出点向虚点连边,从虚点向线段树上组成区间\([l_2,r_2]\)的\(O(\log N)\)区间的入点连边,在出点和入点中选一个建边为权值wor,另一个建边为权值0。
可以参考一般的线段树区间操作模板实现。
-
对于其他情况不要糊涂了:从点向点连边:直接连;从点向区间\([l,r]\)上的所有节点连边:从点向线段树上组成区间\([l,r]\)的\(O(\log N)\)区间的入点连边;从区间\([l,r]\)上的所有节点向点连边:从线段树上组成区间\([l,r]\)的\(O(\log N)\)区间的出点向该点连边。
预处理时间复杂度:\(O(N)\)。空间复杂度:\(O(N)\)。
路径向路径建边的时间复杂度:\(O(\log N)\)。空间复杂度:\(O(\log N)\)。
int h[EN],e[EM],w[EM],ne[EM],eidx;
int vidx;
struct Segmenttree
{
int l,r;
int out,in;
}tr[N*4];
void add(int u,int v,int wor)
{
e[++eidx]=v,w[eidx]=wor,ne[eidx]=h[u],h[u]=eidx;
return ;
}
void pushup(int u)
{
add(tr[u<<1].out,tr[u].out,0),add(tr[u<<1|1].out,tr[u].out,0);
add(tr[u].in,tr[u<<1].in,0),add(tr[u].in,tr[u<<1|1].in,0);
return ;
}
void build(int u,int l,int r)
{
tr[u]={l,r,++vidx,++vidx};
if(l==r)
{
//别忘了向线段树对应原图的节点连边!!!
add(l,tr[u].out,0);
add(tr[u].in,l,0);
return ;
}
int mid=(l+r)>>1;
build(u<<1,l,mid);
build(u<<1|1,mid+1,r);
pushup(u);
return ;
}
void oadd_segment(int u,int l,int r,int x,int wor)
{
if(l<=tr[u].l && tr[u].r<=r)
{
add(tr[u].out,x,wor);
return ;
}
int mid=(tr[u].l+tr[u].r)>>1;
if(l<=mid) oadd_segment(u<<1,l,r,x,wor);
if(r>mid) oadd_segment(u<<1|1,l,r,x,wor);
return ;
}
void iadd_segment(int u,int x,int l,int r,int wor)
{
if(l<=tr[u].l && tr[u].r<=r)
{
add(x,tr[u].in,wor);
return ;
}
int mid=(tr[u].l+tr[u].r)>>1;
if(l<=mid) iadd_segment(u<<1,x,l,r,wor);
if(r>mid) iadd_segment(u<<1|1,x,l,r,wor);
return ;
}
vidx=n;
build(1,1,n);
for(int i=1;i<=m;i++)
{
++vidx;
int l1=read(),r1=read(),l2=read(),r2=read(),wor=read();
oadd_segment(1,l1,r1,vidx,wor);
iadd_segment(1,vidx,l2,r2,0);
}
1.3.3.补集——前后缀优化建图
适用条件:从序列上区间\([l_1,r_1]\)(可能是1个点)的补集上的所有节点向区间\([l_2,r_2]\)(可能是1个点)的补集上的所有节点连长度为w的边。
常配合2-SAT问题。此时建边不需要权值。
opre/ipre/osuf/isuf[u]代表点u的前缀/后缀所有点的出点/入点。
- 预处理:从opre[i-1]和i向opre[i]连一条权值为0的边;从ipre[i]向ipre[i-1]和i连一条权值为0的边;从i和osuf[i+1]向osuf[i]连一条权值为0的边;从isuf[i]向i和isuf[i+1]连一条权值为0的边。注意i=1或i=n时的边界问题。
- 对于序列上区间\([l_1,r_1]\)的补集上的所有节点向区间\([l_2,r_2]\)的补集上的所有节点连长度为w的边,建立1个虚点,从opre[l1-1]和osuf[r1+1]向虚点连边,从虚点向ipre[l2-1]和isuf[r2+1]连边,在出点和入点中选一个建边为权值wor,另一个建边为权值0。注意i=1或i=n时的边界问题。
- 对于点向点连边、点和补集之间的建边的情况就不要糊涂了。
预处理时间复杂度:\(O(N)\)。空间复杂度:\(O(N)\)。
建边的时间复杂度:\(O(1)\)。空间复杂度:\(O(1)\)。
int h[EN],e[EM],w[EM],ne[EM],idx;
int vidx;
int opre[N],ipre[N],osuf[N],isuf[N];
void add(int u,int v,int wor)
{
e[++idx]=v,w[idx]=wor,ne[idx]=h[u],h[u]=idx;
return ;
}
vidx=n;
opre[1]=ipre[1]=1;
osuf[n]=isuf[n]=n;
for(int i=2;i<=n;i++)
{
opre[i]=++vidx;
ipre[i]=++vidx;
add(opre[i-1],opre[i],0),add(i,opre[i],0);
add(ipre[i],ipre[i-1],0),add(ipre[i],i,0);
}
for(int i=n-1;i>=2;i--)
{
osuf[i]=++vidx;
isuf[i]=++vidx;
add(i,osuf[i],0),add(osuf[i+1],osuf[i],0);
add(isuf[i],i,0),add(isuf[i],isuf[i+1],0);
}
for(int i=1;i<=m;i++)
{
++vidx;
int l1=read(),r1=read(),l2=read(),r2=read(),wor=read();
if(l1!=1) add(opre[l1-1],vidx,wor);
if(r1!=n) add(osuf[r1+1],vidx,wor);
if(l2!=1) add(vidx,ipre[l2-1],0);
if(r2!=n) add(vidx,isuf[r2+1],0);
}
1.3.4.树上路径——倍增优化建图
适用条件:从树上路径\((u_1,v_1)\)上的所有节点向路径\((u_2,v_2)\)上的所有节点连长度为w的边。
借鉴于倍增数组fa[u][k],oid/iid[u][k]代表共\(2^k\)个点u及其祖先节点(不包含点fa[u][k],这样定义后面代码更方便)的出点/入点。
-
令vidx=n,oid[i][0]=iid[i][0]=i。
-
预处理:从oid[i][k-1]和oid[fa[i][k-1]][k-1]向oid[i][k]连一条权值为0的边;从iid[i][k]向iid[i][k-1]和iid[fa[i][k-1]][k-1]连一条权值为0的边。
-
对于从路径\((u_1,v_1)\)上的所有节点向路径\((u_2,v_2)\)上的所有节点连长度为w的边,求出\(p_1=lca(u_1,v_1),p_2=lca(u_2,v_2)\),建立1个虚点,从直链\((u_1,p_1)\)和\((v_1,p_1)\)上的出点向虚点连边,从虚点向直链\((u_2,p_2)\)和\((v_2,p_2)\)上的入点连边,在出点和入点中选一个建边为权值wor,另一个建边为权值0。
对于直链(u,anc)上的出点向虚点和虚点向直链(u,anc)上的入点连边,若可重连边,采用ST表思想:2k(已连边)+r(未连边)=r(已连边)+2k(有的已连边有的没有);若不可重连边,采用LCA思想:点u一边倍增fa[u][k]往上跳一边连边。注意:iid/oid[i][k]是不包括点fa[i][k]的。
-
对于其他情况不要糊涂了:从点向点连边:直接连;从点向路径\((u,v)\)上的所有节点连边:从点向直链\((u,p)\)和\((v,p)\)上的入点连边;从路径\((u,v)\)上的所有节点向点连边:从直链\((u,p)\)和\((v,p)\)上的出点向该点连边。
预处理时间复杂度:\(O(N\log N)\)。空间复杂度:\(O(N\log N)\)。
路径向路径建边的时间复杂度:\(O(\log N)\)。空间复杂度:可重建边:\(O(1)\);不可重建边\(O(\log N)\)。
int h[EN],e[EM],w[EM],ne[EM],eidx;
int lg2[N];
int dep[N],fa[N][17];
int iid[N][17],oid[N][17],vidx;
void add(int u,int v,int wor)
{
e[++eidx]=v,w[eidx]=wor,ne[eidx]=h[u],h[u]=eidx;
return ;
}
//注意:iid/oid[i][k]是不包括点fa[i][k]的。
//可重连边:ST表思想:2^k(已连边)+r(未连边)=r(已连边)+2^k(有的已连边有的没有)
void oadd_chain(int u,int anc,int wor)
{
int k=lg2[dep[u]-dep[anc]+1];//+1:点u到点anc有dep[u]-dep[anc]+1个点
add(oid[u][k],vidx,wor);
//ST表思想:2^k(已连边)+r(未连边)=r(已连边)+2^k(有的已连边有的没有)
int r=dep[fa[u][k]]/*未连边的地方是从fa[u][k]开始*/-dep[anc]+1;
//if(r==0) return ;//可加可不加的特判
for(int i=16;i>=0;i--) if((r>>i)&1) u=fa[u][i]; //让u跳到r。虽然时间复杂度仍是O(\log N),但是空间复杂度是O(1)的
add(oid[u][k],vidx,wor);
return ;
}
/*不可重连边:LCA思想:点u一边倍增fa[u][k]往上跳一边连边
void oadd_chain(int u,int anc,int wor,bool choose_anc)
{
if(choose_anc)
{
for(int k=16;k>=0;k--)
if(fa[u][k]/*注意特判!*/ && dep[fa[u][k]]>=dep[fa[anc][0]])
{
add(oid[u][k],vidx,wor);
u=fa[u][k];
}
if(u==anc) add(oid[u][0],vidx,wor); //注意特判当anc是根节点时的情况
}
else
{
for(int k=16;k>=0;k--)
if(dep[fa[u][k]]>=dep[anc])
{
add(oid[u][k],vidx,wor);
u=fa[u][k];
}
}
return ;
}
*/
void iadd_chain(int u,int anc,int wor)
{
int k=lg2[dep[u]-dep[anc]+1];
add(vidx,iid[u][k],wor);
int r=dep[fa[u][k]]-dep[anc]+1;
for(int i=16;i>=0;i--) if((r>>i)&1) u=fa[u][i];
add(vidx,iid[u][k],wor);
return ;
}
vidx=n;
for(int i=1;i<=n;i++) oid[i][0]=iid[i][0]=i;
for(int k=1;k<=16;k++)
for(int i=1;i<=n;i++)
{
if(oid[i][k-1]==0 && oid[fa[i][k-1]][k-1]==0) continue; //注意是“&&”。而且不可以写成break,因为k是外层循环
oid[i][k]=++vidx;
iid[i][k]=++vidx;
add(oid[i][k-1],oid[i][k],0),add(oid[fa[i][k-1]][k-1],oid[i][k],0);
add(iid[i][k],iid[i][k-1],0),add(iid[i][k],iid[fa[i][k-1]][k-1],0);
}
//在出点和入点中选一个建边为权值wor,另一个建边为权值0
for(int i=1;i<=m;i++)
{
++vidx;
int u1=read(),v1=read(),u2=read(),v2=read(),wor=read();
int p1=lca(u1,v1),p2=lca(u2,v2);
oadd_chain(u1,p1,wor),oadd_chain(v1,p1,wor);
// oadd_chain(u1,anc,wor,true),oadd_chain(v1,anc,wor,false);
iadd_chain(u2,p2,0),iadd_chain(v2,p2,0);
}
1.3.5.位运算——拆位拆点优化建图
适用条件:给定一张图,在原图的基础上,给定正常数c,对于任意的点u,v,有边\((u,v,(u\oplus v)*c)\)。(一般求最短路)
1.3.5.1.或
-
草稿
从二进制数a到二进制数b,可以看作是首先某些数位由1变0,从a到达a&b(a和b共同为1的部分);然后某些数位由0变1,从a&b到达b。
于是借助拆位,边权可以这样计算:
- 某些数位由1变0,从a到达a&b:计算这些由1变0的数位的代价;
- 在a&b处:计算这些不变的,a和b共同为1的数位的代价;
- 某些数位由0变1,从a&b到达b:计算这些由0变1的数位的代价。
若没有编号为0的点,则新建。
拆点:
1. 原点(如下图中的0b1010);
2. 正在经历数位由1变0的点(下称为“减点”,如下图中的0b1010-);
3. 正在经历数位由0变1的点(下称为“加点”,如下图中的0b1010+)。
建边:对于每个点:
1. 原图边;
2. (原点,减点,0);
3. 枚举该点的编号的数位:
若该数位为1,则(减点,该数位变为0后的编号对应的减点,该数位*c);
若该数位为0,则(加点,该数位变为1后的编号(注意要小于n)对应的加点,该数位*c);
4. (减点,加点,该点的编号*c);
5. (加点,原点,0)。
图例:(以0b1010->0b1100为例)
0b1010
|边权:0
v
0b1010-
|边权:2^1
v
0b1000-
|边权:0b1000
v
0b1000+
|边权:2^2
v
0b1100+
|边权:0
v
0b1100
引子:从二进制数a到二进制数b,可以看作是首先某些数位由0变1,从a到达a|b;然后某些数位由1变0,从a|b到达b。
若没有编号为0的点,则新建。
拆点:
- 原点(如下图中的0b1010);
- 正在经历数位由0变1的点(下称为“加点”,如下图中的0b1010+);
- 正在经历数位由1变0的点(下称为“减点”,如下图中的0b1100-)。
建边:对于每个点:
-
原图边;
-
(原点,加点,0);
-
枚举该点的编号的数位:
若该数位为0,则(加点,该数位变为1后的编号(注意要小于n)对应的加点,0);
若该数位为1,则(减点,该数位变为0后的编号对应的减点,0);
-
(加点,减点,该点的编号*c);
-
(减点,原点,0)。
0b1010
|边权:0
v
0b1010+
|边权:0
v
0b1110+
|边权:0b1110
v
0b1110-
|边权:0
v
0b1100-
|边权:0
v
0b1100
建图的正确性:走正确路(如上图)的费用正确,走其他路的费用更高。
建图时间复杂度:\(O(N\log N)\)。空间复杂度:\(O(N\log N)\)。
-
例题
![]()
源点是1的特殊建图
首先跳多次的代价肯定超过跳一次的代价。然后考虑跳跃到i如果不是
从i子集过来,那一定不如直接从1跳过来。
因此原图基础上加上从1到其他点的跳跃边,跑一遍单源最短路。
然后每个点i求出子集j 的dis_j 的最小值加上i*c尝试更新dis_i 。
然后在此基础上再做一遍单源最短路即可。
建图时间复杂度:\(O(N)\)。空间复杂度:\(O(N)\)。
1.3.5.2.异或
对于\(u\operatorname{xor}v\)在二进制表示下只有1个1的边,直接连边;“2+个1”的边,发现只要保留“1个1”的边即可,不需要额外连边。(e.g.边\((111_2,010_2)\)=边\((111_2,110_2)\)+边\((110_2,010_2)\))
建图时间复杂度:\(O(N\log N)\)。 空间复杂度:\(O(N\log N)\)。
1.3.6.根号分治
2.图的遍历
2.1.深度优先遍历
适用条件:除最短路外的遍历。
//任意:bool vis[N];
//如果有反向边,加入下面的注释
void dfs(int u,/*无重边:int fa*//*有重边:int edge*/)
{
//任意:vis[u]=true;
for(int i=h[u];i!=0;i=ne[i])
{
int v=e[i];
if(/*任意:vis[v]*//*无重边:v==fa*//*有重边:i==edge^1*/) continue;
dfs(v/*无重边:,u*//*有重边:,i*/);
}
return ;
}
dfs(root);//如果是无根树的话可以令root=1
2.1.1.树的深度,从父节点向子结点递推
//同上的遍历方式
void dfs(int u)
{
for(int i=h[u];i!=0;i=ne[i])
{
depth[v]=depth[u]+1;
}
}
depth[root]=;
2.1.2.树的重心,从子结点向父节点递推
定义:重心是指树中的一个结点,如果将这个点删除后,剩余各个连通块中点数的最大值最小,那么这个节点被称为树的重心。
性质:
- 树的重心如果不唯一,则至多有2个,且这两个重心相邻。
- 以树的重心为根时,所有子树的大小都不超过整棵树大小的一半。
- 树中所有点到某个点的距离和中,到重心的距离和是最小的;如果有两个重心,那么到它们的距离和一样。
- 把两棵树通过一条边相连得到一棵新的树,那么新的树的重心在连接原来两棵树的重心的路径上。
- 在一棵树上添加或删除一个叶子,那么它的重心最多只移动一条边的距离。
int root,res; //root:重心;res:删除重心后分成的最大子树大小
int siz[N]; //子树u的大小
void dfs(int u)
{
vis[u]=true;
siz[u]=1; //把u节点本身加进来
int ma=0; //删除u后分成的最大子树大小
for(int i=h[u];i!=0;i=ne[i])
{
int v=e[i];
if(vis[v]) continue;
dfs(v);
siz[u]+=siz[v]; //从子结点向父节点递推
ma=max(ma,siz[v]);
}
ma=max(ma,n-siz[u]); //别忘了节点u的父节点所在的子树大小
if(ma<res)
{
res=ma;
root=u;
}
return ;
}
res=n;
dfs(1);
2.1.3.图的连通块划分
int belong[N],bidx; //belong[i]:节点i所属的连通块编号
//同上的遍历方式
void dfs(int u)
{
belong[u]=bidx;
}
for(int i=1;i<=n;i++)
if(belong[i]==0)
{
bidx++;
dfs(i);
}
2.2.广度优先遍历
适用条件:最短路。
给图分层。
int d[N]; //给图分层
void bfs()
{
memset(d,-1,sizeof d);
queue<int> q;
q.push(root);
d[root]=1;
while(!q.empty())
{
int t=q.front();
q.pop();
for(int i=h[t];i!=0;i=ne[i])
{
int v=e[i];
if(d[v]!=-1) continue;
d[v]=d[t]+1;
q.push(v);
}
}
return ;
}
2.3.当前弧优化
适用条件:每个顶点可被经过多次,但每条边只能被经过一次,保证复杂度是\(O(M)\)。
注意网络流中的当前弧优化有点不同。
for(int i=cur[u];i;i=ne[i])
{
cur[u]=ne[i];
}
3.图的序列
3.1.有向无环图的拓扑序
拓扑序不唯一\(\Leftrightarrow\)在topsort()的过程中,存在队列里有2个及以上节点的时刻。
拓扑序列的长度小于总点数\(\Leftrightarrow\)该有向图不是DAG,即存在环。
一般复杂度为 O(N+M)。
int n,m;
int h[N],e[N],ne[N],idx;
int deg[N]; //节点的入度
int q[N],hh,tt; //手写队列储存拓扑序
//当节点的入度为0时加入队列
bool topsort()
{
hh=1,tt=0;
for(int i=1;i<=n;i++) if(deg[i]==0) q[++tt]=i;
while(hh<=tt)
{
int u=q[hh++];
for(int i=h[u];i!=0;i=ne[i])
{
int v=e[i];
/*可在拓扑排序的过程中,在这里执行递推/dp
*/
deg[v]--;
if(deg[v]==0) q[++tt]=v;
}
}
return tt==n;
}
scanf("%d%d",&n,&m);
for(int i=1;i<=m;i++)
{
int u,v;
scanf("%d%d",&u,&v);
add(u,v);
deg[v]++;
}
if(!topsort())
{
puts("-1");
return 0;
}
for(int i=1;i<=tt;i++) printf("%d ",q[i]);
通过拓扑排序,可以求解有向无环图上最短路和 dp。
由此可知,图论和动态规划在一些方面上有着紧密的联系。
3.2.树的序列
3.2.1.有根树的dfs序
一般情况是先序遍历序列。
void dfs(int u)
{
a[++aidx]=u;
//同上的遍历方式
return ;
}
应用
-
时间戳dfn:dfs序表示对一棵树进行深度优先搜索得到的结点序列,而时间戳dfn表示每个结点在dfs序中的位置。
dfs序与时间戳的性质
- 在dfs序列上,以u为根的子树内的点的下标是一个连续的子区间\([dfn_u,dfn_u+siz_u-1]\)。
- 在dfs序列上,点u的祖先节点一定在点u的前面。
-
dfs生成树。
3.2.1.1.二叉树的先序、中序、后序与层次遍历序列
前3种序列(dfs序)的不同主要在于当前根节点的输出位置不同,而不是递归的顺序,第4种序列是bfs序。
先序中的“先”强调的是根节点“先”。
void dfs(int u)
{
//先序:printf("%d",u);
dfs(lson);
//中序:printf("%d",u);
dfs(rson);
//后序:printf("%d",u);
return ;
}
中序遍历+其余一种序列遍历→另一种序列遍历。由于只有中序遍历才能在递归时划分左右两棵子树,故必须知道中序遍历。
3.2.1.1.1.二叉树的中序+先序→后序
借助先序遍历中根节点在最前面可以找到中序遍历中的根节点,然后划分左右两棵子树继续递归,由于求后序遍历,故当前的根节点在两个递归回溯后输出。
char a[N],b[N]; //a:中序;b:先序
int len;
void build(int l,int r,int ll,int rr)//l、r:当前中序遍历左右端点;ll、rr:先序
{
if(l>r) return ;
//ll是当前先序遍历根节点
int u;
for(int i=l;i<=r;i++)
if(a[i]==b[ll])
{
u=i;
break;
}
//划分左右两棵子树继续递归
//不同的序列遍历在于当前根节点的输出位置,而不是递归的顺序
build(l,u-1,ll+1,ll+1+(u-1)-l);
build(u+1,r,(ll+1+(u-1)-l)+1,(ll+1+(u-1)-l)+1+r-(u+1));
//后序遍历当前的根节点在两个递归回溯后输出
printf("%c",a[u]);
return ;
}
int main()
{
scanf("%s%s",a+1,b+1);
len=bidx=strlen(a+1);
build(1,len,1,len);
return 0;
}
3.2.1.1.2.二叉树的中序+后序→先序
类似上面。
void build(int l,int r,int ll,int rr)//l、r:当前中序遍历左右端点;ll、rr:后序
{
if(l>r) return ;
//rr是当前后序遍历根节点
int u;
for(int i=l;i<=r;i++)
if(a[i]==b[rr])
{
u=i;
break;
}
//先序遍历当前的根节点在两个递归前输出
printf("%c",a[u]);
//划分左右两棵子树继续递归
//不同的序列遍历在于当前根节点的输出位置,而不是递归的顺序
build(l,u-1,ll,ll+(u-1)-l);
build(u+1,r,ll+(u-1)-l+1,(ll+(u-1)-l+1)+r-(u+1));
return ;
}
3.2.1.1.3.二叉树的中序+层次→先序/后序
与上面类似。中序序列中层次序列在前的点即为当前的根节点。找当前根节点时两重循环:第一重循环枚举层次序列,第二重循环在中序序列找与第一重循环相匹配的点——即为根节点。
下面以求先序为例:
void dfs(int l,int r)
{
if(l>r) return ;
int u;
bool flag=false;
for(int i=1;i<=len;i++)
{
for(int j=l;j<=r;j++)
if(a[j]==b[i])
{
u=j;
flag=true;
break;
}
if(flag) break;
}
//先序遍历当前的根节点在两个递归前输出
printf("%c",a[u]);
dfs(l,u-1);
dfs(u+1,r);
return ;
}
3.2.1.1.4.二叉树的先序+后序→中序计数
之所以二叉树的先序+后序不能确定中序,是因为当某一个节点只有1个儿子节点时,无法得知该儿子结点是该节点的左儿子还是右儿子。
于是下面来讨论“给定二叉树的先序遍历\(s_1\)和后序遍历\(s_2\),求出可能的中序遍历数”。
若只有1个儿子节点的节点数是cnt1,则\(ans=2^{cnt1}\)。
如何求出哪些节点只有1个儿子结点呢?
方法一:递归遍历
设当前子树的先序序列是整棵二叉树的先序序列的区间\([l_1,r_1]\),后序序列是整棵二叉树的后序序列的区间\([l_2,r_2]\)。
若\(l_1=r_1\),则当前子树的根节点没有儿子,返回。
否则\(s_1[l_1+1]\)是该子树的根节点的一个儿子,找到一个len使得\(s_2[l_2+len]=s_1[l_1+1]\),则该儿子子树的先序序列是整棵二叉树的先序序列的区间\([l_1+1,l_1+1+len]\),后序序列是整棵二叉树的后序序列的区间\([l_2,l_2+len]\),继续往下递归。
如果\(l_1+1+len+1>r_1\),即该子树的根节点的另一个儿子子树的先序遍历为空,则该子树的根节点只有一个儿子,cnt1++,返回。否则另一个儿子子树的先序序列是整棵二叉树的先序序列的区间\([l_1+1+len+1,r_1]\),后序序列是整棵二叉树的后序序列的区间\([l_2+len+1,r_2-1]\)(\(r_2-1\):减去当前子树的根节点),继续往下递归。
方法二:观察性质
结论:若先序出现AB,后序出现BA,则节点A只有1个儿子。
3.2.2.有根树的欧拉序列
因为每递归、回溯1条边就会记录1个点,再加上初始的根节点,欧拉序列的长度是2(n-1)+1=2n-1。
int seq[N*2],euler[N],eidx;
void dfs(int u,int fa)
{
++eidx;
seq[eidx]=u,euler[u]=eidx;
for(int i=h[u];i!=0;i=ne[i])
{
int v=e[i];
if(v==fa) continue;
dfs(v,u);
seq[++eidx]=u;
}
return ;
}
3.2.3.有根树的括号序列
将树上问题转化为区间问题。
先\(dfs\)求一遍欧拉序列。
\(first[x]\):\(x\)在欧拉序列中第一次出现的地方;\(last[x]\):\(x\)在欧拉序列中最后一次出现的地方。
void dfs(int u,int father)
{
seq[++sidx]=u;
fir[u]=sidx;
for(int i=h[u];i!=0;i=ne[i])
{
int v=e[i];
if(v==father) continue;
dfs(v,u);
}
seq[++sidx]=u;
las[u]=sidx;
return ;
}
从\(x\)到\(y\)(\(first[x]<first[y]\))的树上路径:
- 若\(lca(x,y)==x\),则欧拉子序列中\([first[x],first[y]]\)中只出现一次的点。
- 若\(lca(x,y)≠x\),则欧拉子序列中\([last[x],first[y]]\)中只出现一次的点以及\(lca(x,y)\)。
统计欧拉子序列中只出现一次的点:
由于欧拉子序列中点\(x\)至多出现2次:新建一个数组\(st[]\),每次\(add(x)\)或\(del(x)\)执行st[x]^=1;,由奇偶性得:利用st[x]==0 ?判断\(x\)是否在欧拉子序列中只出现一次。
3.2.4.有根树的树链剖分
3.2.5.无根树的purfer序列
为了更加简单明了的描述无根树的结构,不妨将该无根树描述为一个以 n 号节点为根的有根树,用这棵无根树的父亲序列来描述这棵树。
对于一棵n个节点的无根树:
prufer序列:长度为 n−2(第n-1项是一个定值n,因此不计入长度), \(p_1,p_2,…,p_{n−2}\)。另外\(p_{n-1}\)是一个定值n。
每次找到编号最小的叶子节点,把它的父亲节点输出,把该叶子节点删除,最终可得到长度为n-1的prufer序列且第n-1项一定是根节点:n号节点。
父亲序列:长度为n-1(令n号节点为根),\(f_1,f_2,\cdots ,f_{n-1}\)。\(f_i\):将该树看作以 n 号节点为根的有根树时,i 号节点的父节点编号。
3.2.5.1.prufer序列与无根树的父亲序列的转化
双指针\(O(N)\)。
int f[N]; //父亲序列
int p[N],pidx; //prufer序列
int out[N]; //出度
void tree_to_prufer()
{
for(int i=1;i<n;i++)
{
scanf("%d",&f[i]);
out[f[i]]++;
}
for(int u=1;pidx<=n-2;u++)
{
while(out[u]>=1) u++;
p[++pidx]=f[u];
while(pidx<=n-2 && --out[p[pidx]]==0 && p[pidx]<u)
{
int backup=f[p[pidx]];
p[++pidx]=backup;
}
}
for(int i=1;i<=n-2;i++) printf("%d ",p[i]);
return ;
}
void prufer_to_tree()
{
for(int i=1;i<=n-2;i++)
{
scanf("%d",&p[i]);
out[p[i]]++;
}
p[n-1]=n;
out[n]++;
pidx=1;
for(int u=1;pidx<=n-1;u++,pidx++)
{
while(out[u]>=1) u++;
f[u]=p[pidx];
while(pidx<=n-2 && --out[p[pidx]]==0 && p[pidx]<u)
{
f[p[pidx]]=p[pidx+1];
pidx++;
}
}
for(int i=1;i<=n-1;i++) printf("%d ",f[i]);
return ;
}
3.2.5.2.应用——有标号无根树计数
适用条件:求解满足条件的有标号无根树的个数。
一棵有标号无根树与一个prufer序列一一映射。(但不能用于树同构因为树同构要求无标号)
因此在求解满足条件的无根树的个数时,可以把问题转化为prufer序列的计数问题。
\(e.g.\)对于一张 n 个点的完全图,其生成树的个数为 \(n^{n-2}\)个。
因为 prufer 序列的长度为 n−2(第n-1项是一个定值n,因此不计入长度) ,而我们每个位置上可以填 1∼n 中的任何数。根据乘法原理可以得知生成树的个数为\(n^{n-2}\) 个。
4.最短路
建图
- 稠密图——邻接矩阵\(O(N^2)\)
- 稀疏图——邻接表\(O(N+M)\)
单源最短路
- 边权均为w——BFS\(O(N)\)
- 边权均为w或0——双端队列BFS\(O(N)\)
- 边权非负
- 稠密图——Dijkstra\(O(N^2)\)
- 稀疏图——堆优化Dijkstra\(O((M+N)\log N)\)
- 边权任意,有边数k限制——Bellman-Ford\(O(K*\min (N^2,M))\)
- 边权任意——SPFA\(O(NM)\)
多源最短路
- 稠密图——Floyd\(O(N^3)\)
- 稀疏图——Johnson\(O(NM\log M)\)
4.1.单源最短路
只需要求节点1到其他节点的最短路。
当BFS具有“单调性”时,BFS第一次出队搜到的点一定符合“最短”的性质;否则,队列为空时才能得到最优解。
Dijkstra:由于按边权排序,故初始只需要把{0,1}放入优先队列,第一次出队就是最优解。天生满足拓扑序。
spfa:由于采用一般的队列,只能重复更新直至“收敛”,故初始需要把“超级源点”或所有节点放入队列,队列为空才是最优解。不满足拓扑序。
注意有负边权判断无解条件:ans≥INF/2。
4.1.1.01边权——BFS\(O(N)\)
线性时间求解。
特别地,对于边权较小的情况,可以用拆点/拆边的方式转化为 01 最短路。
《搜索2.2.2.最短路模型》
4.1.2.非负边权——Dijkstra
Dijkstra基于贪心思想,要求全局最小值不可能再被其他节点更新,故要求边权非负。天生满足拓扑序。
vis[i]:i在出队后更新其他节点后会被打上标记。i已经更新过其他节点了,之后再用它更新会造成冗余影响效率。
4.1.2.1.稠密图——Dijkstra\(O(N^2)\)
模板题:AcWing 849. Dijkstra求最短路 I
int n,m;
int g[N][N];
int dis[N];
bool vis[N];
int dij(){
memset(dis,0x3f,sizeof dis);
dis[1]=0;
for(int i=1;i<n;i++){//重复进行n-1次,求出剩下n-1个节点的dis
//找到未标记节点中dis最小的
int t=-1;
for(int j=1;j<=n;j++){
if(!vis[j]&&(t==-1||dis[t]>dis[j])){
t=j;
}
}
vis[t]=1;//t在出队后更新其他节点后会被打上标记。t已经更新过其他节点了,之后再用它更新会造成冗余影响效率
//用全局最小值点t更新其他节点
for(int j=1;j<=n;j++){
dis[j]=min(dis[j],dis[t]+g[t][j]);
}
}
if(dis[n]== 0x3f3f3f3f) return -1;
return dis[n];
}
int main(){
cin>>n>>m;
memset(g,0x3f,sizeof g);
while(m--){
int a,b,c;
cin>>a>>b>>c;
g[a][b]=min(g[a][b],c);
}
printf("%d\n",dij());
return 0;
}
4.1.2.2.稀疏图——堆优化Dijkstra\(O((M+N)\log N)\)
模板题:AcWing 850. Dijkstra求最短路 II
用堆维护找到未标记节点中dis最小的。
由于按边权排序,故初始只需要把{0,1}放入队列,第一次出队就是最优解。
int dijkstra()
{
priority_queue<PII,vector<PII>,greater<PII>> q;//{距离,节点编号}
memset(dis,0x3f,sizeof dis);
memset(vis,false,sizeof vis);
dis[1]=0;
q.push({dis[1],1});
while(!q.empty())
{
int u=q.top().second;
q.pop();
if(vis[u]) continue;
vis[u]=true;
if(u==n) return dis[u];//第一次出队就是最优解
for(int i=h[u];i;i=ne[i])
{
int v=e[i];
if(dis[v]>dis[u]+w[i])
{
dis[v]=dis[u]+w[i];
q.push({dis[v],v});
}
}
}
return -1;//始终没入队:n无法到达
}
4.1.3.任意边权,有边数k限制——Bellman-Ford\(O(K*\min (N^2,M))\)
基于三角形不等式:如果对于一条边(x,y,z),有dist[y]≤dist[x]+z成立,则称该边满足三角形不等式。如果所有边满足三角形不等式,dist数组就是所求最短路。
扫描所有边(x,y,z),若dist[y]>dist[x]+z,则令dist[y]=dist[x]+z,使其满足三角形不等式。
注意当有负权边时,不能到达点u的判定条件是dis[u]>INF/2。
图中有负环:迭代 n-1 轮后,仍有边不满足三角形不等式(能松弛该边,不能收敛)。
4.1.3.1.经过不超过k个边
int bellman_ford(){
memset(d,0x3f,sizeof d);
d[1]=0;
for(int i=1;i<=k;i++){//用上一次的边权扩展,扩展k次,保证经过不超过k条边
memcpy(last,d,sizeof d);
//下面以稀疏图O(M)的写法为例
for(int j=1;j<=m;j++){
int w=q[j].first,u=q[j].second.first,v=q[j].second.second;
d[v]=min(d[v],last[u]+w);
}
}
return d[n];
}
cin>>k;
if(dis[n]>INF/2/*注意有负权边,不能到达的判定条件是INF/2*/) puts("impossible");
else printf("%d",dis[n]);
4.1.3.2.经过恰好k个边
模板题:AcWing 345. 牛站
Bellman-Ford\(O(K*\min (N^2,M))\)
int bellman_ford(){
memset(d,0x3f,sizeof d);
d[1]=0;
for(int i=1;i<=k;i++){
memcpy(last,d,sizeof d);
memset(d,0x3f,sizeof d);//相较于上面的模板,多了这一行,强制恰好经过k条边
/*......*/
}
}
Floyd\(O(N^3*\log k)\)
设邻接矩阵\(A^x\)表示任意两点之间恰好经过x条边的最短路,则有:\(\forall i,j\in[1,N],有(A^{a+b})[i,j]=\min\limits_{1≤k≤N}\{(A^a)[i,k]+(A^b)[k,j]\}\),其实这是一个广义的矩阵乘法:原来的乘法用加法代替,原来的加法用min代替,仍然满足结合律,因此用快速幂求解。
int n,t,s,e;
int u[N],v[N],w[N];
int g[N][N],ans[N][N];
void mul(int a[N][N],int b[N][N])
{
int res[N][N];
memset(res,0x3f,sizeof res);//保证恰好经过k条边
for(int k=1;k<=hidx/*点数*/;k++)
for(int i=1;i<=hidx;i++)
for(int j=1;j<=hidx;j++)
res[i][j]=min(res[i][j],a[i][k]+b[k][j]);
memcpy(a,res,sizeof res);
return ;
}
void qpow()
{
memcpy(ans,g,sizeof g);
while(n)
{
if(n&1) mul(ans,g);
mul(g,g);
n>>=1;
}
return ;
}
int main()
{
scanf("%d%d%d%d",&n,&t,&s,&e);
n--; //原本输入的边得到初始的g数组就是恰好经过1条边的两点距离
memset(g,0x3f,sizeof g);
for(int i=1;i<=t;i++)
{
int a,b,c;
scanf("%d%d%d",&c,&a,&b);
g[a][b]=g[b][a]=min(g[a][b],c);
}
qpow();
printf("%d\n",ans[s][e]);
return 0;
}
4.1.4.任意边权——SPFA\(O(NM)\)
4.1.4.1.SPFA求最短路+判断负环
队列优化的Bellman-Ford。
由于采用一般的队列,只能重复更新直至“收敛”,故初始需要把“超级源点”或所有节点放入队列,队列为空才是最优解。不满足拓扑序。
vis[i]:i已经在队列里了,不能重复入队。
cnt[u]:从起点到点u的最短路径包含的边数。
注意当有负权边时,不能到达点u的判定条件是dis[u]>INF/2。
图中有负环:若存在一个点 u,使得 cnt[u]≥n(此处的n是总点数。其他点共有 n-1 个,却松弛了它 ≥n 次)。此时应退出 SPFA 过程报告有负环。
int n,m;
int h[N],e[N],w[N],ne[N],idx;
int dis[N],cnt[N];//初始cnt=0
bool vis[N];
queue<int> q;
bool spfa()
{
/*求最短路
起点就是超级源点
memset(dis,0x3f,sizeof dis);
dis[1]=0;
q.push(1);
vis[1]=true;
*/
/*判断负环
没有“超级源点”,故把所有节点放入队列
memset(vis,false,sizeof vis);//判负环后直接返回,导致一些节点的vis没有清空
memset(cnt,0,sizeof cnt);
while(!q.empty()) q.pop();
for(int i=1;i<=n;i++)
{
q.push(i);
vis[i]=true;
}
*/
while(q.size())
{
int t=q.front();
q.pop();
vis[t]=false;
for(int i=h[t];i!=0;i=ne[i])
{
int v=e[i];
if(dis[v]>dis[t]+w[i])
{
dis[v]=dis[t]+w[i];
cnt[v]=cnt[t]+1;//注意是由cnt[t]+1转移而来,而不是cnt[v]++!!!
/*判断负环
if(cnt[v]>=n) return false;//无超级源点
if(cnt[v]>=n+1) return false;//有超级源点,记得算上超级源点!!!
//保险起见可以都写n+1
*/
if(!vis[v])
{
q.push(v);
vis[v]=true;
}
}
}
}
return true;
}
if(!spfa()) puts("negative");
else
{
if(dis[n]>INF/2/*注意当有负权边时,不能到达点u的判定条件是dis[u]>INF/2*/) puts("impossible");
else printf("%d\n",dis[n]);
}
4.1.4.2.差分约束
模板题:AcWing 1169. 糖果
适用条件:解形如\(x_i-x_j≤k\)的N元一次不等式组。也可以解等式组。
最多,有上界→求最短路→将不等式形式转换为x≤y+w→从y向x连一条长度为w的有向边→跑一遍最短路,存在负环无解。差分约束构造出的方案一定会把上界顶满。
最少,有下界→求最长路→将不等式形式转换为x≥y+w→从y向x连一条长度为w的有向边→跑一遍最长路,存在正环无解。差分约束构造出的方案一定会把下界顶满。
必须有一个绝对值:建立超级源点0号节点,令\(x_0=0\),列出不等式\(\forall i,x_0+w≤x_i\)。
隐含不等式约束条件
-
\(x_i\leqslant x_j+c_k\Leftrightarrow x_j\geqslant x_i+(-c_k)\)。
-
求一组最小非负整数解:令\(x_0\)等于0,列出不等式\(\forall i,x_0+0≤x_i\)。
-
严格变非严格:x<y+w→x≤y+(w-1)。
-
等式:x-y=w→x-y≥w与x-y≤w→若要求x+y最少求最长路:x≥y+w与y≥x-w;若要求x+y最多求最短路:y≤x-w与x≤y+w。
-
赋值:x=w→令\(x_0\)等于0,\(x≤x_0+w\)与\(x≥x_0+w\),再变换成等式的不等式条件。
-
区间选数:
0~k选的数肯定不比0~k-1少:s[k]-s[k-1]≥0。
每个数只能被选一次:s[k]-s[k-1]≤1(如果k是离散化后的值,则不等式应改为s[k]-s[k-1]≤num[k]-num[k-1])。
区间不等式/等式:\(L\le \sum\limits_{i=l}^rx_i\le R/\sum\limits_{i=l}^rx_i=w\)→拆成前缀和的形式:sum[r]-sum[l-1]≥L,sum[r]-sum[l-1]≤R/sum[r]-sum[l-1]=w,再变换成等式的不等式条件。
4.1.4.2.1.边权任意——SPFA\(O(NM)\)
用SPFA跑一遍最长路/最短路并判断负环。
if(!spfa()) puts("-1");
else {
LL res=0;
for(int i=1;i<=n;i++) res+=dist[i];
printf("%lld\n",res);
}
4.1.4.2.2.边权非负的最长路/边权非正的最短路——Tarjan\(O(N+M)\)
以边权非负的最长路为例:因为Tarjan判断无解(存在正环)的条件是有两个点在同一个强连通分量(同一个环)且连接他们的边权为正,所以要求边权必须非负。
参见《图论.2.2.边权非负的最长路/边权非正的最短路的差分约束》。
4.1.4.2.3.边权恒正的最长路/边权恒负的最短路——拓扑排序\(O(N+M)\)
以边权恒正的最长路为例:因为拓扑排序判断无解的条件是有环,所以要求边权必须恒正。
参见《图论.2.边权恒正的最长路/边权恒负的最短路的差分约束》。
4.1.4.2.4.\(x_i≥k*x_j\)
前置知识:
像正常差分约束那样建边,像求乘积最大/最小路那样解不等式。
关于取对数:\(x_i≥k*x_j→\log x_i ≥\log k + \log x_j\)。
关于乘法转移:直接就是\(x_i≥k*x_j\)这样的形式。
隐含不等式条件
赋值:x=w→令\(x_0\)等于1,\(x≤x_0*w\)与\(x≥x_0*w\)。
4.2.多源最短路
4.2.1.稠密图——Floyd\(O(N^3)\)
4.2.1.1.Floyd求任意两点间最短路
需要求任意两点间最短路径。
本质是动态规划或广义矩阵乘法。
\(f[k,i,j]\):经过若干个编号不超过k的节点,从i到j的最短路长度。
状态转移:\(f[k,i,j]=min(f[k-1,i,j],f[k-1,i,k]+f[k-1,k,j])\)。
边界:\(f[0,i,j]=d[i,j],\textcolor{red}{f[k,i,i]=0}\)。其中d为邻接矩阵。
k是阶段,所以必须置于最外层。
第一维可以省略。
Floyd与矩阵乘法有相通之处。
int n,m,t;
int g[N][N];
int main(){
cin>>n>>m>>t;
memset(g,0x3f,sizeof g);
for(int i=1;i<=n;i++) g[i][i]=0;
while(m--){
int a,b,c;
cin>>a>>b>>c;
g[a][b]=min(g[a][b],c);
}
for(int k=1;k<=n;k++)
for(int i=1;i<=n;i++)
for(int j=1;j<=n;j++)
g[i][j]=min(g[i][j],g[i][k]+g[k][j]);
while(t--){
int x,y;
cin>>x>>y;
if(g[x][y]>=INF/2) puts("impossible");
else cout<<g[x][y]<<endl;
}
return 0;
}
4.2.1.2.传递闭包
模板题:AcWing 343. 排序
适用条件:给定若干个元素和若干对二元关系,且关系具有传递性和单向性。“通过传递关系推导出尽量多的元素之间的关系”的问题。
本质是有向图:
- 有二元关系(i,j)\(\Leftrightarrow\)在有向图上节点i可以到达节点j。
- 关系矛盾(若关系具有非对称性,关系矛盾:存在i,j,使得(i,j)1 && (j,i)1)\(\Leftrightarrow\)该有向图存在环。
- 关系无法确定(条件不足无法确定两两关系:存在i,j,使得(i,j)0 && (j,i)0)\(\Leftrightarrow\)该有向图的拓扑序不唯一。
拓扑排序判断传递闭包关系情况
- 关系矛盾\(\Leftrightarrow\)该有向图存在环\(\Leftrightarrow\)拓扑序列的长度小于总点数。
- 关系无法确定\(\Leftrightarrow\)该有向图的拓扑序不唯一\(\Leftrightarrow\)在
topsort()的过程中,存在队列里有2个及以上节点的时刻。 - 否则,关系能全部确定且无矛盾。
Floyd求解具体、多源的传递闭包
建立邻接矩阵\(d\),其中\(d[i,j]=1\)表示有二元关系\((i,j)\),\(d[i,j]=0\)表示无二元关系\((i,j)\)。
根据题目说明,判断初始时\(d[i,i]\)是否为\(1\)。
常用bitset优化。
bool d[N][N];
int n,m;
int main()
{
scanf("%d%d",&n,&m);
//for(int i=1;i<=n;i++) d[i][i]=true;//根据题目说明,判断初始时d[i,i]是否为1
for(int i=1;i<=m;i++)
{
int x,y;
scanf("%d%d",&x,&y);
d[x][y]=d[y][x]=1; //有的题目可能只有d[x][y]=1;
}
for(int k=1;k<=n;k++)
for(int i=1;i<=n;i++)
for(int j=1;j<=n;j++)
d[i][j]|=d[i][k]&d[k][j];
//abaabaaba...
return 0;
}
4.2.1.3.类Floyd dp
4.2.2.稀疏图——\(Johnson\)思想\(O(NM\log M)\)
4.2.2.1.\(Johnson\)思想把负权边转化为非负权边\(O(NM)\)
核心:利用势能把负权边转化为非负权边。
- 新建一个虚拟节点0,从这个点向其他所有点连一条边权为 0 的边。
- spfa求出点0到其他所有点的最短路,记为\(ep_i\)。
- 把所有边(u,v,w)重新设置边权为\((u,v,w+(ep_u-ep_v))\)。
-
证明:
势能的特点:
- 势能的绝对值往往取决于设置的零势能点。
- 势能的相对值与零势能点无关,与起点到终点所走的路径无关,只与起点和终点的相对位置有关。
原图最短路转化为新图最短路
原图:\(w_{u,x}+w_{x,v}\)。
新图:\((w_{u,x}+(ep_u-ep_x))+(w_{x,v}+ep_x-ep_v)=w_{u,x}+w_{x,v}+ep_u-ep_v\)。与势能的定义吻合。
新图的最短路=原图的最短路-(起点的势能-终点的势能)。
证明转化后边权非负
根据spfa三角形不等式,新图(u,v)满足:\(ep_v≤ep_u+w(u,v)\)。\(w'(u,v)=w(u,v)+ep_u-ep_v≥0\),故边权非负。
然后就得到了非负权边的新图,后续可以使用dijkstra算法等。
4.2.2.2.\(Johnson\)算法求任意两点间最短路\(O(N*M\log M)\)
按照\(Johnson\)思想把负权边转化为非负权边后,以每个点为起点,跑 n 轮 Dijkstra 算法即可求出任意两点间的最短路了。
新图的最短路=原图的最短路-(起点的势能-终点的势能)。(证明在上面。)
注意:
- 因为求势能时点0还要向其他所有点连边,所以边数要多加N。
- 因为spfa中算上点0总共有n+1个点,所以负环的判定条件是cnt[v]>=n+1。
const int N=3e3+10,M=6e3+10+N/*注意因为求势能时点0还要向其他所有点连边,所以边数要多加N*/;
int ep[N];
bool spfa()
{
memset(ep,0x3f,sizeof ep);
ep[0]=0;
queue<int> q;
q.push(0);
vis[0]=true;
//正常执行spfa算法求点0到其他所有点的最短路
//因为spfa中算上点0总共有n+1个点,所以负环的判定条件是cnt[v]>=n+1
if(cnt[v]>=n+1) return false;
}
for(int u=1;u<=n;u++) add(0,u,0);
if(!spfa())//注意判负环
{
puts("-1");
return 0;
}
for(int u=1;u<=n;u++) for(int i=h[u];i!=0;i=ne[i]) w[i]+=ep[u]-ep[e[i]];
for(int u=1;u<=n;u++) dijkstra(u);
int u=read(),v=read();
printf("%d\n",dis[u][v]-(ep[u]-ep[v]));
4.3.分层图最短路
模板题:Luogu P1948 [USACO08JAN] Telephone Lines S
通过分层图最短路这种思想,图论上的一个点和一个状态、最短路问题和动态规划是共通的,可以利用图论看待状态转移。这是后续学习同余最短路等的基础。
定义
当原图中的同一个点可以有不同的状态时,可以将一个点分成各个状态的点,然后相应地与其他点相连。从形象的角度,原本只有一层的图被分层了。从dp的角度,相当于增加了一维状态。
下面以例题“求路径上第k+1大的边权的最小值”为例:
方法一:分层图建边
优点:形象。缺点:消耗空间大。
-
我们把样例分层,如图:
![]()
从u到v若选择免费,u则走到下一层的v,边权为0;否则走到当前层的v,边权为wor。
因为可以免费 k 次,所以要建 k+1 层图,在 k+1 层图上已经不能再往下了,即免费操作已用完。
本题的最短路转移是取max。
void spfa()
{
//……
int wor=max(dis[u],w[i]);
if(dis[v]>wor)
{
//……
}
//……
}
for(int i=1;i<=m;i++)
{
int u,v,wor;
scanf("%d%d%d",&u,&v,&wor);
//本层建边
add(u,v,wor);
add(v,u,wor);
for(int j=1;j<=k;j++)
{
//第j层和第j+1层间的建边
add(u+(j-1)*n,v+j*n,0);
add(v+(j-1)*n,u+j*n,0);
//第j+1层建边
add(u+j*n,v+j*n,wor);
add(v+j*n,u+j*n,wor);
}
}
spfa();
//1到N经过的边小于k的情况
for(int j=1;j<=k;j++) add(1+(j-1)*n,1+j*n,0);
ans=dis[n+k*n];
/*
或者不要上面的代码,也可以最后枚举层数:for(int j=0;j<=k;j++) ans=min(ans,dis[n+j*n]);
*/
方法二:分层图dp
优点:空间复杂度小。
上面的建边需要大量的空间,可以结合dp的思想:
\(dis[u,p]\):从节点1到节点u,使用了p次零代价通过一条路径。
状态转移:\(dis[v,p]=\min(dis[u,p-1],\max(dis[u,p],w))\),前者表示使用1次零代价通过(u,v,w),后者表示不使用。
边界:\(dis[1,0]=0\),\(dis[u,j]=INF,(j≥k+1)\),\(ans=\min\limits_{0≤j≤k} \{ dis[n,j]\}\)。
事实上,这个 DP 相当于对于每个结点的 k+1 个状态,想象成拆为 k+1 个不同的结点,仿佛这张图一共有 k+1 层,每一层的节点代表使用不同多次免费通行后到达的原图结点,即每个结点\(u_i\)表示使用i次免费通行权限后到达u结点。
但是,这个dp是有后效性的,可以利用迭代思想,借助SPFA算法进行动态规划直至收敛。
实际上,从最短路的角度理解,一个节点代表二元组(u,p),从(fa,p-1)到(u,p)有一条权值为0的边,从(fa,p)到(u,p)有一条权值为w的边。这就和上面的建边极为相似——NK个点,PK条边,费用取max的广义最短路问题。
int h[N],e[M],w[M],ne[M],idx; //转移还是按照原图的节点和边转移
bool vis[N];
queue<int> q;
int f[N][N]; //f[x][p]:从1到x,p条电缆免费的最小花费
void spfa(){
memset(f,0x3f,sizeof f);
f[1][0]=0;
q.push(1),vis[1]=true;
while(!q.empty()){
int u=q.front();
q.pop();
vis[u]=false;
for(int i=h[u];i!=0;i=ne[i]){
int j=e[i],dis;
for(int p=0;p<=k;p++){
dis=max(f[u][p],w[i]);
if(p-1>=0) dis=min(dis,f[u][p-1]);
if(f[j][p]>dis){
f[j][p]=dis;
if(!vis[j]) q.push(j),vis[j]=true;
}
}
}
}
return ;
}
spfa();
for(int i=0;i<=k;i++) ans=min(ans,f[n][i]);
方法三:二分+双端队列广搜
效率最高,但是扩展性窄。
二分最小花费limit,边权小于limit的边边权视为0,大于视为1,check函数里跑一遍双端广搜判断dis[n]<=k?。
4.4.拓展应用
4.4.1.求任意一个点到所有起点的最短路
建立一个虚拟源点,从它向所有起点连一条边权为 0的边。(实际上大部分多源问题都可以这样转化为单源问题,且对于多个终点也是这么处理的)
4.4.2.正向反向跑2遍图
4.4.2.最小环问题
4.4.2.1.无向图找最小环\(Floyd\)
模板题:AcWing 344. 观光之旅
很明显,这个环要至少包含3个点。
\(a[i][j]\): 邻接矩阵,边\((i,j)\)的权值;
\(d[i][j]\):从\(i\)到\(j\)经过的节点编号不超过\(k-1\)的最短路;
\(d_{pos}[i][j]\):路径追踪,存储一个中转\(k\):\(d[i][j]=min(d[i][k]+d[k][j])\)。
输出最小环长度:\(min(d[i][j]+a[j][k]+a[k][i])
\)输出最小环方案:path
memcpy(d,a,sizeof a);
for(int k=1;k<=n;k++)
{
for(int i=1;i<k-1;i++)
for(int j=i+1;j<k;j++)
if((long long)d[i][j]+a[j][k]+a[k][i]<ans) ans=d[i][j]+a[j][k]+a[k][i]; //这样d[i][j]经过的节点编号不超过k,构成的环是简单环
//放在下面以保证d[i][j]经过的节点编号不超过下一轮的k-1
for(int i=1;i<=n;i++)
for(int j=1;j<=n;j++)
d[i][j]=min(d[i][j],d[i][k]+d[k][j]);
}
int n,m,ans=INF;
int a[N][N],d[N][N],d_pos[N][N]; //a[i][j]:只经过i和j的最短路;d[i][j]:从i到j经过的节点编号不超过k-1的最短路;d_pos[i][j]:存储一个中转k:d[i][j]=min(d[i][k]+d[k][j])
vector<int> path; //具体方案
void get_path(int i,int j){
if(d_pos[i][j]==0) return ;
//存path:i -> ... -> d_pos[i][j] -> ... -> j (-> k (-> i))
get_path(i,d_pos[i][j]);
path.push_back(d_pos[i][j]);
get_path(d_pos[i][j],j);
return ;
}
int main()
{
scanf("%d%d",&n,&m);
memset(a,0x3f,sizeof a);
for(int i=1;i<=n;i++) a[i][i]=0;
for(int i=1;i<=m;i++)
{
int u,v,l;
scanf("%d%d%d",&u,&v,&l);
a[v][u]=a[u][v]=min(a[u][v],l);
}
memcpy(d,a,sizeof a);
for(int k=1;k<=n;k++)
{
for(int i=1;i<k-1;i++)
for(int j=i+1;j<k;j++)
if((LL)d[i][j]+a[j][k]+a[k][i]<ans) //这样经过的节点编号不超过k
{
ans=d[i][j]+a[j][k]+a[k][i];
path.clear();
//存path:i -> ... -> j -> k (-> i)
path.push_back(i);
get_path(i,j);
path.push_back(j);
path.push_back(k);
}
//放在下面以保证d[i][j]经过的节点编号不超过k-1
for(int i=1;i<=n;i++)
for(int j=1;j<=n;j++)
if(d[i][k]+d[k][j]<d[i][j])
{
d[i][j]=d[i][k]+d[k][j];
d_pos[i][j]=k;
}
}
if(ans==INF) puts("No solution.");
else
{
for(auto it : path) printf("%d ",it);
puts("");
}
return 0;
}
4.4.2.2.有向图找最小环 \(Dijkstra\)
输出最小环长度:\(min(d[start])\)
- 枚举起点\(start=1\)~\(n\);
- 执行堆优化的\(Dijsktra\)求单源最短路径;
- \(start\)一定是第一个从堆中取出来的节点(因为在堆优化的\(Dijsktra\)算法中:\(d[start]=0\)),扫描\(start\)所有出边扩展更新完成后,令\(d[start]=INF\),然后继续正常执行堆优化的\(Dijsktra\)算法;
- 当\(start\)第二次从堆中取出时,\(d[start]\)就是经过\(start\)的最小环长度。
int n,m,ans=INF;
int h[N],e[M],w[M],ne[M],idx;
int d[N];
bool vis[N];
void add(int a,int b,int c)
{
e[++idx]=b;
w[idx]=c;
ne[idx]=h[a];
h[a]=idx;
return ;
}
int dijkstra(int start)
{
priority_queue<PII,vector<PII>,greater<PII> > heap;
memset(vis,false,sizeof vis);
memset(d,0x3f,sizeof d);
d[start]=0;
heap.push({0,start});
while(heap.size()){
auto t=heap.top();
heap.pop();
int u=t.second;
if(vis[u]==true)
{
if(u==start) return d[u];
continue;
}
vis[u]=true;
for(int i=h[u];i!=0;i=ne[i])
{
int j=e[i];
if(d[j]>d[u]+w[i])
{
d[j]=d[u]+w[i];
heap.push({d[j],j});
}
}
if(u==start) d[u]=INF;
}
return d[start];
}
int main()
{
scanf("%d%d",&n,&m);
for(int i=1;i<=m;i++)
{
int a,b,c;
scanf("%d%d%d",&a,&b,&c);
add(a,b,c);
}
for(int i=1;i<=n;i++) ans=min(ans,dijkstra(i));
if(ans==INF) puts("-1");
else printf("%d\n",ans);
return 0;
}
4.4.3.求在1 到 N 的路径中,路径上第 k 大的边权最小是多少
备注:若存在边数<k的1 到 N 的路径,则输出0。
方法一:二分
形如“最大值最小” / “最小值最大”之类的字眼:用二分求解。
自变量:x。
因变量:y,表示对于给定的 x ,从 1 到 N 的路径中最少要经过边权大于 x 的边有多少条。
判定:y≤k
我们设原边权大于 x 的边的新边权为 1 ,小于等于 x 的边新边权为 0,求出从 1 到 N 的最短路即为 y 的值。01边权双端队列BFS求解。
- 代码
int n,m,k;
int h[N],e[M],w[M],ne[M],idx;
int dis[N]; //dis[u]:从1到u至少有多少条线路费用大于limit
bool vis[N];
deque<int> q;
void add(int a,int b,int c){
e[++idx]=b;
w[idx]=c;
ne[idx]=h[a];
h[a]=idx;
return ;
}
//双向队列BFS
bool check(int limit){
memset(dis,0x3f,sizeof dis);
memset(vis,false ,sizeof vis);
dis[1]=0;
q.push_front(1);
while(!q.empty()){
int u=q.front();
q.pop_front();
if(vis[u]) continue; //双段队列BFS是判断u而不是v
vis[u]=1;
for(int i=h[u];i!=0;i=ne[i]){
int j=e[i],d=0;
if(w[i]>limit) d=1;
if(dis[j]>dis[u]+d){
dis[j]=dis[u]+d;
if(d==0) q.push_front(j);
else q.push_back(j);
}
}
}
return dis[n]<=k-1;
}
int main(){
scanf("%d%d%d",&n,&m,&k);
for(int i=1;i<=m;i++){
int a,b,c;
scanf("%d%d%d",&a,&b,&c);
add(a,b,c),add(b,a,c);
}
//二分答案转化为判定
int l=0,r=INF;
while(l<r){
int mid=(l+r)>>1;
if(check(mid)) r=mid;
else l=mid+1;
}
if(l==INF) puts("-1");
else printf("%d\n",l);
return 0;
}
方法二:Kruskal重构树
4.4.4.最短路拓扑图
4.4.4.1.最短路的可行边
有向边(u,v,w)判断条件:dis[s][u]+w+dis[v][t]==dis[s][t]。
4.4.4.2.最短路拓扑图
最短路图:所有的最短路的可行边构成的图。
最短路拓扑图:若最短距离存在(原图不存在负环)且最短路数量存在(原图不存在零环),则最短路图一定是拓扑图。
4.4.4.3.最短路dp
由于题目需要,一般解题时题目会保证原图不存在“非正”环,此时最短路图即为最短路拓扑图。
先求一遍最短路,然后建立最短路拓扑图,之后跑dp。
4.4.4.3.1.最短路计数
题目:求节点1到节点i的最短路有多少条。
4.4.4.3.1.1.边权恒正
事实上,BFS和Dijkstra天生满足拓扑序。如果边权恒为1或正,可以用BFS或Dijikstra在求最短路时直接统计所有边中等于最小值的边数,而不必建立最短路拓扑图。
int n,m;
int h[N],e[M],ne[M],idx;
int dis[N],cnt[N];
bool vis[N];
priority_queue<PII,vector<PII>,greater<PII> > q;
void dijkstra()
{
cnt[1]=1;
while(q.size())
{
for(int i=h[u];i!=0;i=ne[i])
{
if(dis[v]>dis[u]+1)
{
cnt[v]=cnt[u];
}
else if(dis[v]==dis[u]+1) cnt[v]=(cnt[v]+cnt[u])%MOD;
}
}
return ;
}
dijkstra();
for(int i=1;i<=n;i++) printf("%d\n",cnt[i]);
4.4.4.3.1.2.边权任意
-
先跑一遍SPFA,在此过程中建立最短路拓扑图(统计入度即可,不必建边)。
deg[u]:点u在最短路拓扑图上的入度。在跑spfa的过程中,若dis[v]>dis[u]+w[i],则令deg[v]=1;若dis[v]==dis[t]+w[i],则令deg[v]++。
-
有了deg[u],就可以在最短路拓扑图上dp。根据dis[v]dis[u]+w[i]来转移,根据deg[u]0来入队。
int n,m;
int h[N],e[M],w[M],ne[M],idx;
int dis[N],cnt1[N]; //cnt1:spfa入队次数判断负环
bool vis[N];
int deg[N];
int cnt2[N]; //cnt2:最短路计数
bool spfa()
{
queue<int> q;
memset(dis,0x3f,sizeof dis);
dis[1]=0;
q.push(1);
vis[1]=true;
while(q.size())
{
int u=q.front();
q.pop();
vis[u]=false;
for(int i=h[u];i!=0;i=ne[i])
{
int v=e[i];
if(dis[v]>dis[u]+w[i])
{
dis[v]=dis[u]+w[i];
deg[v]=1;
cnt1[v]=cnt1[u]+1;
if(cnt1[v]>=n) return true;
if(!vis[v])
{
q.push(v);
vis[v]=true;
}
}
else if(dis[v]==dis[u]+w[i]) deg[v]++;
}
}
return false;
}
void topsort()
{
queue<int> q;
q.push(1);
cnt2[1]=1;
while(q.size())
{
int u=q.front();
q.pop();
for(int i=h[u];i!=0;i=ne[i])
{
int v=e[i];
if(dis[v]==dis[u]+w[i])
{
cnt2[v]=(cnt2[v]+cnt2[u])%MOD;
deg[v]--;
if(deg[v]==0) q.push(v);
}
}
}
return ;
}
scanf("%d%d",&n,&m);
for(int i=1;i<=m;i++)
{
int u,v,wor;
scanf("%d%d%d",&u,&v,&wor);
add(u,v,wor),add(v,u,wor);
}
if(spfa())
{
puts("-1");
return 0;
}
topsort();
for(int i=1;i<=n;i++) printf("%d\n",cnt2[i]);
4.4.4.3.1.3.长度为x的路径计数
4.4.4.4.无““非正”环”的最短路的必须边
方法一
将最短路图变为无向图,割边即为必须边,Tarjan边双连通分量求解。
方法二
优点:简单;缺点:有冲突,结果小概率错误。
fs[x]:起点S到图中每个点x的路径条数。ft[x]:图中每个点x到终点T的路径条数。
根据乘法原理,有向边(u,v)判断条件:fs[u]*ft[v]==fs[T]。
fs/ft的数值可能会很大,有时需要取模。
当需要避免冲突时,可以采用选取不常见的模数、选取双模数等方法。
4.4.5.第k短路
4.4.5.1.单源次短路与其计数
比单源最短路多维护一个type=0/1:当前路径是最短路/次短路。
每当最短路/次短路被更新时,就要把其放入队列。
下面以dijkstra求单源次短路为例:
//type=0/1:最短路/次短路
struct Node
{
int u,type,dis;
bool operator > (const Node &qw) const //小根堆要重载大于号
{
return dis>qw.dis;
}
};
int d[N][2],cnt[N][2];
bool vis[N][2];
void dijkstra()
{
priority_queue<Node,vector<Node>,greater<Node> > q;
memset(d,0x3f,sizeof d);
memset(cnt,0,sizeof cnt);
memset(vis,false,sizeof vis);
d[st][0]=0,cnt[st][0]=1;
q.push({st,0,d[st][0]});
while(!q.empty())
{
int u=q.top().u,type=q.top().type,dis=q.top().dis;
q.pop();
if(vis[u][type]) continue;
vis[u][type]=true;
for(int i=h[u];i!=0;i=ne[i])
{
int v=e[i];
if(d[v][0]>dis+w[i])
{
d[v][1]=d[v][0],cnt[v][1]=cnt[v][0];
q.push({v,1,d[v][1]});
d[v][0]=dis+w[i],cnt[v][0]=cnt[u][type];
q.push({v,0,d[v][0]});
}
else if(d[v][0]==dis+w[i]) cnt[v][0]+=cnt[u][type];
else if(d[v][1]>dis+w[i])
{
d[v][1]=dis+w[i],cnt[v][1]=cnt[u][type];
q.push({v,1,d[v][1]});
}
else if(d[v][1]==dis+w[i]) cnt[v][1]+=cnt[u][type];
}
}
return ;
}
4.4.5.2.第k短路
4.4.5.乘积最大/最小路
求起点到终点的一条路径,使得路径上边的权值的乘积最大/最小。
方法一:取对数
优点:直观,不会爆long long;缺点:有精度误差。
要求最大乘积,即要使 \(w_1w_2\cdots w_k\) 最大,即使 \(\log(w_1w_2\cdots w_k)=\log w_1+\log w_2+\cdots+\log w_k\) 最大。
因此对于一条边(u,v,w),从u向v连一条长度为\(\log w\)的边。跑一遍最长路/最短路,dis[s]=0,dis[v]=max/min(dis[v],dis[u]+w[i]),得到\(dis[t]\),实际的答案\(=10^{dis[t]}\)。
方法二:乘法转移
优点:无精度误差;缺点:可能会爆long long。
对于一条边(u,v,w),从u向v连一条长度为w的边。跑一遍最长路/最短路,dis[s]=1,dis[v]=max/min(dis[v],dis[u]*w[i]),得到\(dis[t]\),实际的答案就是dis[t]。
证明:取对数后跑最短路可做,则原图也可跑最短路。
w的大小是任何情况都可用SPFA,但是是否可以用Dijkstra需要根据w的大小情况与乘积最大路还是乘积最小路来考虑:
- 对于所有求乘积最大路问题
-
所有边权 0<w≤1,可用 \(\text{Dijkstra}\) ;
-
证明:该题目可以用 \(\text{Dijkstra}\) 算法做。
我们要求最大乘积,即要使 \(w_1w_2\cdots w_k\) 最大,即使 \(\log(w_1w_2\cdots w_k)=\log w_1+\log w_2+\cdots+\log w_k\) 最大。由于 \(0<w\le1\) ,故 \(\log w_p\le0(p\in[1,k])\) 。我们将所有的对数取负,即求这些相反数的最小值。综上,我们设边权为 \(-\log w_i\) ,求它们和的最小值可用 \(\text{Dijkstra}\) 做,则原题面也可用 \(\text{Dijkstra}\) 做。
-
-
priority_queue <PDI> heap;//大根堆
if (dist[b]<distance*c)
{
dist[b]=distance*c;
heap.push({dist[b],b});
}
- 有边权 >1,不可用 $\text{Dijkstra}$ 。
- 对于所有求乘积最小路问题——
- 所有边权 ≥1 ,可用 Dijkstra ;
- 所有边权 ≥0,不可用 Dijkstra 。
另:如果证不到 \(\text{Dijkstra}\) 求某道题是正确的,不管自己多么确定,还是用 \(\text{SPFA}\) 吧!爆一个点总比爆零好……
4.4.6.同余最短路\(O(点数是mod,边数是n*mod的最短路)\)
注意:mod是自选的模数。因为复杂度与mod相关,所以令mod=给定的n个整数中最小的整数。
适用条件:给定n个整数(n个整数可以重复取),求多元一次不定方程(这n个整数能否拼凑出x)/这n个整数能拼凑出多少的其他整数/求这n个整数不能拼凑出的最小或最大整数/至少要花多少代价才能拼出模k余p的数。
通过同余构造某些状态,状态之间的关系类似于两点之间的带权有向边。\(f[i+x]=\min\{f[i]+x\}\)。
对于凑数,考虑一种暴力的做法:预处理a[2~n]能凑出哪些数,对于其他的数用预处理+若干个a[1]凑出来。显然时空复杂度都不能接受,因此使用同余优化空间复杂度,以此建图,将某些问题转化为最短路问题,再使用具有优秀时间复杂度的算法求解。
同余最短路的特殊结构不会使得spfa被卡,所以当dijkstra复杂度逼近时间限制时换用spfa。
4.4.6.1.多元一次不定方程\(\sum\limits_{i=1}^{n}a_i*x_i=b\)
- 将n个整数排序、去重以及去0,还剩n'个整数,令模数mod=a[1](因为复杂度与mod相关,所以要尽可能地让mod小)。
- \(f[i]\):使用a[2~n']能得到满足\(x\% mod=i\)的最小的整数x。i的范围显然是0~mod-1。f[0]=0。
- 显然有转移:\(\forall j\in[2,n'], f[(i+a[j])\% mod]=\min \{ f[i]+a[j] \}\)。发现f[i]相当于0到i的最短路,0~mod-1是图上的点,x→(x+a[i])%mod是图上的边,跑一遍最短路求出\(f[i]\)。
int n,mod;
int a[N],aidx;
LL f[N];
for(int i=1;i<=n;i++)
{
int x;
scanf("%d",&x);
if(x!=0) a[++aidx]=x;
}
sort(a+1,a+aidx+1);
n=unique(a+1,a+aidx+1)-(a+1);
mod=a[1];
for(int i=2;i<=n;i++) /*对于某些题目记得判自环if(a[i]!=mod)*/ for(int j=0;j<mod;j++) add(j,(j+a[i])%mod,a[i]);
dijkstra();
因此我们得到了mod的剩余系的有用信息。
-
正确性
对于一个足够大的数,我们一定可以用剩余系\(f[i]\)和若干个mod凑出来;而对于某个小的数,跑最短路时已经证明最小的与该数同余mod且能被a[2~n']凑出的数大于该数,由于缺乏剩余系,因此它不能被剩余系、mod、剩余系+mod凑出来。
求有多少\(b\in[1,n]\)可以使等式存在非负整数解
\(ANS=\sum\limits_{i=0}^{mod-1} [f[i]≤n]*(\lfloor \frac{n-f[i]}{mod} \rfloor+1)\)。
+1代表剩余系凑出来的数,\(\lfloor \frac{n-f[i]}{mod} \rfloor\)代表剩余系和若干个mod凑出的数。
求最大或最小的b使等式无解
\(maxn=\max\limits_{0\le i\le mod-1 \&\& f[i]>mod}\{ f[i]-mod \}\)。
\(minn=\min\limits_{0≤i≤mod-1 \&\& f[i]>mod} \{ i \}\)。
若\(\forall i\in[0,mod-1],f[i]<mod\),则\(\forall b\in N\)都有解。
4.4.6.2.至少要花多少代价才能拼出模k余p的数
使用a[i]拼数要花费b[i]的代价。
经过前面的铺垫可知:\(\forall i\in[1,n],\forall u\in[0,k-1]\),从u向(u+a[i])%k连一条代价为b[i]的边。然后跑从0到p的最短路。
实现代码时不必真的建边,只需要对于每个u枚举i走到(u+a[i])%k即可。
特别地,如果p=0,加上一些特判:第二次出队才是答案、\(\times 10\)的拼数道具只有当u≠0的时候才能使用……
4.4.7.异或最长路
若一条路径依次经过的点的权值为\(w_1,w_2,\cdots,w_n\),则这条路径的“长度”\(W=w_1\,xor\,w_2\,xor\,\cdots\,xor\,w_n\)。
-
思路
假设某条路k被重复走了两次,那么它的权值对答案的贡献就是0,但是通过这条路径k,我们可以到达它连接的另一个点。
显然我们没法枚举1~N的每一条路,但我们可以将路径拆成两部分,第一部分是环,第二部分是链。
假设我们选择了一条从1~N的链,当然,它不一定是最优秀的。但是别忘了,我们可以选择一些环来增广这条链。举个例子:
![]()
假设k是连接这条链和某个环的某条路径,那么显然,链和环都将走过一遍,而这条路径k会被走过两遍(从链到环一遍,从环到链一遍)。根据我们上面的推论,k对答案的贡献是0。于是我们发现,我们根本就没有必要算出这条路径k!(反正贡献是0)
于是我们枚举所有环,将环上异或和扔进线性基,然后用这条链作为初值,求线性基与这条链的最大异或和。
最后说一下怎么选最开始的这条链,其实它可以随便选。我们考虑以下这种情况:
![]()
假设路径A比路径B优秀一些,而我们最开始选择了路径B。显然,A与B共同构成了一个环。如果我们发现路径A要优秀一些,那么我们用B异或上这个大环,就会得到我们想要的A!
所以这道题的算法是:找出所有环,扔进线性基,随便找一条链,以它作为初值求最大异或和就可以了。
单源汇异或最长路
线性基。
一遍dfs求出任意一个生成树,求出只经过生成树上的边所有点到源点的距离dis[i],并把所有环(可以由树边或非树边组成)的“长度”插入线性基。
源点到i的异或最长路就是dis[i]异或线性基的最大值ans=query_max(dis[i]);。
-
正确性证明
只要从源点到i至少有两条不同的路径,那么其中必定形成了环。
若经过重复的路径,异或两次没有影响。
若从一个点到一个环之间要经过无关路径,那么从这个点走到环、走过一遍环、从环走到该点中:无关路径经过2遍(异或两次没有影响),环经过一遍。
int h[N],e[M],w[M],ne[M],idx;
int dis[N];//只经过生成树上的边点i到源点的距离
bool vis[N];//判断环和生成树
int a[65];//线性基
void dfs(int u)
{
vis[u]=true;
for(int i=h[u];i!=0;i=ne[i])
{
int v=e[i];
if(vis[v]) insert(dis[u]^dis[v]^w[i]);//将环插入线性基
else
{
dis[v]=dis[u]^w[i];//任意一个生成树
dfs(v);
}
}
return ;
}
dfs(1);
printf("%lld\n",query_max(dis[n]));
多源汇异或最长路
随便选择一个源点,dis[i]:只经过生成树上的边所有点到源点的距离。执行《单源汇异或最长路》。
x到y的异或最长路就是dis[x]^dis[y]异或线性基的最大值ans=query_max(dis[x]^dis[y]);。(因为经过重复的路径,异或两次没有影响。)
一边加边一边求异或最长路
使用按秩合并并查集。
dis[i]:i到并查集父节点的距离。初始为0。
get_dis(i):i到并查集根节点的距离。
int get_dis(int x)
{
int res=dis[x];
while(x!=p[x])
{
x=p[x];
res^=dis[x];
}
return res;
}
加一条边\((u,v,w)\)时,令\(w=w\,xor\,get\_dis(u)\,xor\,get\_dis(v)\)。若\(p_u=p_v\),则形成一个环,把w插入线性基;否则,合并并查集,并令该合并过程的作为儿子的并查集根节点的dis为w。
若x到y连通,则x到y的异或最长路就是get_dis(x)^get_dis(y)异或线性基的最大值ans=query_max(get_dis(x)^get_dis(y));。
正确性证明:因为经过重复的路径,异或两次没有影响。
也可以使用路径压缩并查集。
dis[i]:i到并查集根节点的距离。初始为0。在使用dis前,一定要先调用一次find更新信息!!!
//路径压缩并查集
int find(int x)
{
if(x!=fa[x]){
//注意下面的顺序
int root=find(fa[x]);
dis[x]^=dis[fa[x]];
fa[x]=root;
}
return fa[x];
}
int u=read(),v=read(),wor=read();
int pu=find(u),pv=find(v);
wor^=dis[u]^dis[v]; //在使用dis前,一定要先调用一次find更新信息!!!
if(pu!=pv)
{
dis[pu]=wor;
fa[pu]=pv;
}
find(x);
int res=dis[x]; //在使用dis前,一定要先调用一次find更新信息!!!
4.4.8.经过一个点的代价是\(\max\{入边的边权,出边的边权\}\)的最短路
给定一个 n 个点 m 条边的无向图,经过一个点的代价是\(\max\{入边的边权,出边的边权\}\),起点的代价是出边的边权,终点的代价是入边的边权。求从起点1到终点n的最小代价。\(n≤10^5,m≤2*10^5\)。
暴力做法:把两条边a→b→c改为从a到c权值为\(\max\{w(a,b),w(b,c)\}\)的一条边。\(O(M^2)\)
考虑优化:枚举中间点b、化边为点、差分优化。
- 建立超级源点s和超级汇点t。把每一条无向边拆成2条有向边,赋予边编号,化边为点。
- 对于所有从1出发的边,从s向这些边的编号连权值为原图权值的边;对于所有结束于n的边,从这些边的编号向t连权值为原图权值的边。
- 所有边的编号向自己的反向边的编号连权值为原图权值的边。
- 将每一个点出发的所有边按照边权从小到大排序,对于排序后第i小的边\(e_{i}\)和第i+1小的边\(e_{i+1}\),从\(e_{i}\)向\(e_{i+1}\)连权值为\(w_{e_{i+1}}-w_{e_i}\)的边,从\(e_{i+1}\)向\(e_{i}\)连权值为0的边。
- 在新图上跑一边最短路,在原图上从起点1到终点n的最小代价=在新图上从起点s到终点t的最小代价。
其他性质
- 当走源汇点和正向边之间的边时,所计算的是离开 1 或来到 n 的代价;
- 当走正向边和反向边内部的边时(其实就是一个点出来的边),代表着反悔操作,也就是换了一条边走。
- 当在正向和反向边之间横跳时(如 1→5→3),代表又走过了一个节点,并计算了最大边的权值。
4.4.9.必须经过/不经过指定的边/点与支持修改1条边的边权的最短路
4.4.9.1.求1号节点到达节点x,途中必须经过1条指定的边(u,v)的最短路
正向反向图跑2遍图求出dis1[u]/dis2[u]:点1/x到点u的最短路。
对于有向边ans=min{dis1[u]+w(u,v)+dis2[v]}。
对于无向边ans=min{dis1[u]+w(u,v)+dis2[v](1→边(u,v)→n),dis1[v]+w(v,u)+dis2[u](1→边(v,u)→n)}。
4.4.9.2.求1号节点到达节点x,途中必须经过节点a、b、c、...的最短路
预处理以1、x、a、b、c、...为起点的最短路,然后DFS枚举顺序,查表相加最后取最小值。
4.4.9.3.求1号节点到达节点x,必须不经过指定的边(u,v)的最短路
如果(u,v)不是最短路径上的边,则直接输出原最短路长度。下面来讨论(u,v)是最短路径上的边的情况。
4.4.9.3.1.无向图
性质:设最短路路径为E,E包含的边为\(e_1→e_2→\cdots→e_m\)。对于每一个不属于E的点u,点1到点u/点u到点n的最短路一定经过E的一段前缀\(e_1→e_2→\cdots→e_i\)/后缀\(e_j→\cdots→e_m\)(可以为空),记l[u]=i,r[u]=j。
特别地,对于每一个属于E的点u,l/r[u]=最短路上入/出边的编号。
- 先从n到1跑一遍最短路求出属于E的点u的l[u]和r[u]和最短路路径。
- 再从1到n、n到1跑一遍最短路求出不属于E的点u的l[u]和r[u]。
- 在最短路路径上建立线段树,线段树的叶子节点i表示不经过\(e_i\)的最短路长度。对于每一条不属于E的边(u,v),把必须经过1→边(u,v)→n、1→边(v,u)→n的最短路长度分别更新线段树区间[l[u]+1,r[v]-1]、[l[v]+1,r[u]-1]最小值。答案就是对于叶子节点的单点查询。
int n,m,q;
int h[N],e[M],ne[M],id[M],idx=1;
LL w[M];
bool on_shortest_path[N];
int l[N],r[N];
LL dis1[N],dis2[N];
bool vis[N];
int pre[N]; //最短路路径追踪
int dfn[N],tidx; //在最短路路径上建立线段树
struct Segmenttree
{
int l,r;
LL mi; //类似于标记永久化写法。因为是单点查询所以可不下传懒标记
}tr[N*4];
void add(int u,int v,LL wor,int i)
{
e[++idx]=v,w[idx]=wor,id[idx]=i,ne[idx]=h[u],h[u]=idx;
return ;
}
void dijkstra(int s,LL dis[],int op)
{
priority_queue<PLI,vector<PLI>,greater<PLI> > q;
for(int i=1;i<=n;i++) dis[i]=INF;
memset(vis,false,sizeof vis);
dis[s]=0;
q.push({dis[s],s});
while(q.size())
{
int u=q.top().second;
q.pop();
if(vis[u]) continue;
vis[u]=true;
for(int i=h[u];i!=0;i=ne[i])
{
int v=e[i];
if(dis[v]>dis[u]+w[i])
{
dis[v]=dis[u]+w[i];
if(op==0) pre[v]=i^1;
else if(op==1)
{
if(!on_shortest_path[v]/*不可以写成l[v]==0,因为v可能会被更新多次信息*/) l[v]=l[u];
}
else if(op==2)
{
if(!on_shortest_path[v]) r[v]=r[u];
}
q.push({dis[v],v});
}
}
}
return ;
}
void get_shortest_path()
{
int u=1;
l[u]=0;
while(u!=n)
{
on_shortest_path[u]=true;
int i=pre[u];
dfn[id[i]]=++tidx;
r[u]=tidx;
u=e[i];
l[u]=tidx;
}
on_shortest_path[u]=true;
r[u]=tidx+1;
return ;
}
void build(int u,int l,int r)
{
tr[u]={l,r,INF};
if(l==r) return ;
int mid=(l+r)>>1;
build(u<<1,l,mid);
build(u<<1|1,mid+1,r);
return ;
}
void modify(int u,int l,int r,LL mi)
{
if(l<=tr[u].l && tr[u].r<=r)
{
tr[u].mi=min(tr[u].mi,mi);
return ;
}
int mid=(tr[u].l+tr[u].r)>>1;
if(l<=mid) modify(u<<1,l,r,mi);
if(r>mid) modify(u<<1|1,l,r,mi);
return ;
}
LL ask(int u,int x)
{
if(tr[u].l==tr[u].r) return tr[u].mi;
int mid=(tr[u].l+tr[u].r)>>1;
LL res=tr[u].mi;
if(x<=mid) res=min(res,ask(u<<1,x));
else res=min(res,ask(u<<1|1,x));
return res;
}
scanf("%d%d%d",&n,&m,&q);
for(int i=1;i<=m;i++)
{
int u,v;
LL wor;
scanf("%d%d%lld",&u,&v,&wor);
add(u,v,wor,i),add(v,u,wor,i);
}
dijkstra(n,dis2,0);
get_shortest_path();
dijkstra(1,dis1,1);
dijkstra(n,dis2,2);
build(1,1,tidx);
for(int i=1;i<=m;i++)
if(dfn[i]==0)
{
if(l[e[i*2+1]]+1<=r[e[i*2]]-1) modify(1,l[e[i*2+1]]+1,r[e[i*2]]-1,dis1[e[i*2+1]]+w[i*2]+dis2[e[i*2]]);
if(l[e[i*2]]+1<=r[e[i*2+1]]-1) modify(1,l[e[i*2]]+1,r[e[i*2+1]]-1,dis1[e[i*2]]+w[i*2+1]+dis2[e[i*2+1]]);
}
while(q--)
{
int i;
scanf("%d",&i);
printf("%lld\n",ask(1,dfn[i]));
}
4.4.9.3.2.有向图
无边权
-
算法
![]()
有边权
只能\(O(spfa)\)做到依次求出按从1走到x的顺序分别删除最短路径上的一条边之后的最短路长度。
- 按从1走到x的顺序给最短路径上的点编号dfn[u],点1为0号。删除最短路径上的边,可以通过删除标记ban[e]实现。
- 设dis1/2[u]表示当前点u到点1/x的距离。先求出最短路径上的点u到点x的距离dis2[u]。
memset(dis1,0x3f,dis1);。 - 以1为源点跑一遍spfa更新每个点u的dis1[u],要求不能经过最短路径上的边,且转移过程中若v是最短路径上的点,把pair{dis1[v]+dis2[v],dfn[v]}(即一条路径:1→非此时被删除的边→v(最短路径上的点)→回归最短路路径)放入小根堆mi。
- 对于按从1走到x的顺序分别删除最短路径上的一条边\(e_i\),不断取出小根堆mi的堆顶top直到取到top.second≥i(说明该路径“1→非此时被删除的边→v(最短路径上的点)→回归最短路路径绕过了当前被删除的这条边”),此时删除一条边\(e_i\)的答案就是top.first;如果取光了小根堆mi还没有取出top.second≥i则此时无解。把边\(e_i\)恢复,通过\(dis1[e[e_i]]=dis1[fa[e_i]]+w[e_i]\)实现。再以\(e[e_i]\)为源点,依据步骤3跑一遍spfa更新信息,不要
memset(dis1,0x3f,dis1);,而是利用上一次spfa继续更新信息。该过程本质上是依次加入最短路径上的边不断更新dis1,利用一些特性从而巧妙解决问题。
看似调用了多次spfa,但是从第二次开始的spfa本质上是依次加入最短路径上的边不断更新dis1。因此时间复杂度是\(O(spfa)\)。
int n,m,l;
int h[N],e[M],ne[M],fa[M],idx; //fa[edge]:储存有向边(u,v)的点u
LL w[M];
int shortest_path[N];
bool ban[M]; //删除边的标记
int dfn[N]; //按从1走到x的顺序给最短路径上的点编号
LL dis1[N],dis2[N]; //dis1/2[u]表示当前点u到点1/x的距离
bool st[N];
priority_queue<PLI,vector<PLI>,greater<PLI> > mi; //转移过程中若v是最短路径上的点,把pair{dis1[v]+dis2[v],dfn[v]}(即一条路径:1→非此时被删除的边→v(最短路径上的点)→回归最短路路径)放入小根堆mi
void add(int u,int v,LL wor)
{
e[++idx]=v,fa[idx]=u,w[idx]=wor,ne[idx]=h[u],h[u]=idx;
return ;
}
void spfa(int s)
{
queue<int> q;
q.push(s);
st[s]=true;
while(q.size())
{
int u=q.front();
q.pop();
st[u]=false;
for(int i=h[u];i!=0;i=ne[i])
{
if(ban[i]) continue;
int v=e[i];
if(dis1[v]>dis1[u]+w[i])
{
dis1[v]=dis1[u]+w[i];
if(dfn[v]>=0) mi.push({dis1[v]+dis2[v],dfn[v]});
if(!st[v])
{
q.push(v);
st[v]=true;
}
}
}
}
return ;
}
scanf("%d%d%d",&n,&m,&l);
for(int i=1;i<=m;i++)
{
int u,v;
LL wor;
scanf("%d%d%lld",&u,&v,&wor);
add(u,v,wor);
}
memset(dfn,-1,sizeof dfn);
dfn[1]=0;
for(int i=1;i<=l;i++)
{
scanf("%d",&shortest_path[i]);
ban[shortest_path[i]]=true;
dfn[e[shortest_path[i]]]=i;
}
dis2[n]=0;
for(int i=l;i>=1;i--) dis2[fa[shortest_path[i]]]=dis2[e[shortest_path[i]]]+w[shortest_path[i]];
memset(dis1,0x3f,sizeof dis1);
dis1[1]=0;
spfa(1);
for(int i=1;i<=l;i++)
{
while(mi.size() && mi.top().second<i) mi.pop();
if(mi.size()) printf("%lld\n",mi.top().first);
else puts("-1");
dis1[e[shortest_path[i]]]=dis1[fa[shortest_path[i]]]+w[shortest_path[i]];
spfa(e[shortest_path[i]]);
}
4.4.9.4.支持修改1条边的边权的最短路
修改分为以下几类:
-
修改的边不在1到n的最短路上,边的长度变小了。
ans=min(原最短路长度,dis1[u]+w(u,v)+dis2[v](1→边(u,v)→n),dis1[v]+w(v,u)+dis2[u](1→边(v,u)→n))
-
修改的边不在1到n的最短路上,边的长度变大了。
ans=原最短路长度。
-
修改的边在1到n的最短路上,边的长度变小了。
ans=原最短路长度-原边长+新边长。
-
修改的边在1到n的最短路上,边的长度变大了。
ans=min(原最短路长度-原边长+新边长,不经过修改的边的最短路长度)。
4.4.10.在树上求所有点到一条路径的距离
设V代表给定的路径(u,v)上的点的集合。则\(dis_{x,(u,v)}=\min\limits_{i\in V} dis_{x,i}\)。
逆向思维。对于路径上的所有点u求出\(d[u][i]\):从点u出发,不经过V中的其他点,到点i的最短距离。\(O(N)\)
4.4.11.求给定的点中最近的一对的最短距离
给定一张有向图,可能存在重边和自环。给定若干个点,求出这些点“两两最短路”的最小值(即在这些点中,最近的一对的最短距离)。
枚举比特位i,以给定的点的编号的第i位为依据分成2类“0”和“1”:先令“0”类的点的dis为0,图上其余所有点的dis初始为INF。跑一遍最短路,“1”类的点的dis即“0”类的点到“1”类的点的最小距离;再令“1”类的点的dis为0,图上其余所有点的dis初始为INF。跑一遍最短路,“0”类的点的dis即“1”类的点到“0”类的点的最小距离。
因为两个不同的数在二进制下至少有一位不同,所以枚举比特位i,分类跑2遍最短路一定能求出所有给定的点的“两两最短路”的最小值。
bool vis[N];
LL dis[N];
int love[N];
vector<int> other;
LL dijkstra()
{
for(int i=0;(1<<i)<=k;i++)//枚举比特位
{
priority_queue<PLI,vector<PLI>,greater<PLI> > q;
memset(vis,false,sizeof vis);
memset(dis,0x3f,sizeof dis);
other.clear();
for(int j=1;j<=k;j++)
if((j>>i)&1)//分类
{
dis[love[j]]=0;
q.push({0,love[j]});
}
else other.push_back(love[j]);
while(q.size())
{
auto t=q.top();
q.pop();
int u=t.second;
if(vis[u]) continue;
vis[u]=true;
for(int j=h[u];j!=0;j=ne[j])
{
int v=e[j];
if(dis[v]>dis[u]+w[j])
{
dis[v]=dis[u]+w[j];
q.push({dis[v],v});
}
}
}
for(int j=0;j<other.size();j++) ans=min(ans,dis[other[j]]);
//分类跑2遍dijkstra
memset(vis,false,sizeof vis);
memset(dis,0x3f,sizeof dis);
other.clear();
for(int j=1;j<=k;j++)
if(!((j>>i)&1))
{
dis[love[j]]=0;
q.push({0,love[j]});
}
else other.push_back(love[j]);
while(q.size())
{
auto t=q.top();
q.pop();
int u=t.second;
if(vis[u]) continue;
vis[u]=true;
for(int j=h[u];j!=0;j=ne[j])
{
int v=e[j];
if(dis[v]>dis[u]+w[j])
{
dis[v]=dis[u]+w[j];
q.push({dis[v],v});
}
}
}
for(int j=0;j<other.size();j++) ans=min(ans,dis[other[j]]);
}
return ans;
}
int main()
{
t=read();
while(t--)
{
init();
n=read(),m=read(),k=read();
for(int i=1;i<=m;i++)
{
int u=read(),v=read(),wor=read();
add(u,v,wor);
}
for(int i=1;i<=k;i++) love[i]=read();
printf("%lld\n",dijkstra());
}
return 0;
}
树
树:若一个无向连通图不含环,则称它是一个树。易知,一个 n 个点 n-1 条边的无向连通图是一个树。多个树可以组成一个森林。
5.无向图的最小生成树
“求使这些点直接或间接连通的最小费用。”
定义
一张边带权的无向图G=(V,E),n=|V|,m=|E|:有V中n个节点和E中n-1条边构成的无向连通子图被称为G的一棵生成树。边的权值之和最小的生成树被称为G的最小生成树。
定理
任意一棵最小生成树一定包含无向图中权值最小的边。
推论
给定一张无向图 G=(V,E),n=|V|,m=|E| 。从 E中选出 k<n-1 条边构成 G 的一个生成森林。若再从剩余的 m−k 条边中选 n−1−k 条添加到生成森林中,使其成为 G 的生成树,并且选出的边的权值之和最小,则该生成树一定包含这 m−k 条边中连接生成森林的两个不连通节点的权值最小的边。
5.1.稠密图——Prim\(O(N^2)\)
优点:求指定点集的最小生成树、最短路径生成树的计数问题。
设已经确定的最小生成树的节点集合为T,剩余节点集合为S。
\(d[i]\):当\(i\in S\)时:节点x与集合T中节点之间的最小权值;当\(i\in T\)时:节点x加入集合T时被选的最小边的权值。
Prim找到\(\min_{x \in S, y \in T} \{z\}\),把x从S中删除,加入到T,并把z累加到答案。
int n,m,ans;
int g[N][N];//邻接矩阵
int d[N];
bool vis[N];
int prim(){
memset(d,0x3f,sizeof d);
int res=0;
for(int i=1;i<=n;i++){
int id=-1;
for(int j=1;j<=n;j++)
if(vis[j]==0&&(id==-1||d[id]>d[j])) id=j; //判断id=-1为真就不会判断d[id]>d[j]了
vis[id]=1;
if(i!=1&&d[id]==INF) return INF;
if(i!=1) res+=d[id];
for(int j=1;j<=n;j++) d[j]=min(d[j],g[id][j]);
}
return res;
}
5.2.稀疏图——Kruskal\(O(M \log M)\)
优点:1.还可以在残余的图(已经连了一些边的图)上求最小生成树:把原来的边直接累加到答案,并用并查集连通相应的节点,然后继续在残余的图上求最小生成树;2.求最大边权最小的生成树。(而这些Prim都不行)
int n,m;
int p[N];//并查集
PIII q[M];
int find(int x){
if(p[x]!=x) p[x]=find(p[x]);
return p[x];
}
int kruskal(){
for(int i=1;i<=n;i++) p[i]=i;//建立并查集
int res=0,cnt=0;
for(int i=1;i<=m;i++){//依次扫描每条边
int w=q[i].first;
auto sb=q[i].second;
int u=sb.first,v=sb.second;
u=find(u),v=find(v);
if(u!=v){//若u,v不属于同一集合(不连通),合并集合(使之连通),把w累加到答案中(这条边构成最小生成树)。
p[u]=v;
res+=w;
//若要记录此时哪条边构成了最小生成树,则记录tr[++tidx]=i;
cnt++;
}
}
if(cnt<n-1) return INF;
return res;
}
cin>>u>>v>>w;
q[i]={w,{u,v}};
sort(q+1,q+m+1);//按权值排序
int ans=kruskal();
5.2.1.Kruskal重构树
注意点数边数均开2倍!
适用条件:不经过边权超过/大于/小于/不超过给定值的无向图图论问题。
- 把新构建出的图叫做重构树,开始重构树中只有n个孤立的点,将它们的点权视作\(-\infty\)。
- 在Kruskal算法求最小生成树的过程中,遇到一条连接两个不同集合的边(u,v),在并查集中分别找到两个集合的根,新建一个结点w,合并两个集合,并且令w为新集合的根。在重构树中将w作为(u,v)共同的父亲,即在重构树中连边(w,u),(w,v)。令w的点权为(u,v)的边权。
- 将原问题变形为树上问题。点u所在的重构树的根节点是find(u)。
struct Edge//原图的边
{
int u,v,wor;
bool operator < (const Edge &qw) const
{
return wor<qw.wor;
}
}edge[N];
struct Kruskal
{
int nidx;
int h[N],e[M],ne[M],idx;//kruskal重构树的边
int p[N],val[N],fa[N][30];//p:并查集;val[u]:u的点权;fa:倍增数组
bool vis[N];//连通性
void init()
{
nidx=n;
memset(h,0,sizeof h);
idx=0;
for(int i=1;i<=n*2;i++) p[i]=i; //注意是2*n,因为还有新建的点
memset(vis,false,sizeof vis);
return ;
}
int find(int x)
{
if(x!=p[x]) p[x]=find(p[x]);
return p[x];
}
void add(int u,int v)
{
e[++idx]=v;
ne[idx]=h[u];
h[u]=idx;
return ;
}
void dfs(int u)//转化为树上问题
{
vis[u]=true;
if(u<=n) {}//原图的点
else {}//新建的点
for(int i=h[u];i!=0;i=ne[i])
{
int v=e[i];
dfs(v);
}
return ;
}
}k;
void kruskal()
{
k.init();
sort(edge+1,edge+m+1);
for(int i=1;i<=m;i++)
{
int u=edge[i].u,v=edge[i].v,wor=edge[i].wor;
int pu=k.find(u),pv=k.find(v);
if(pu!=pv)
{
k.nidx++;
k.add(k.nidx,pu),k.add(k.nidx,pv);
k.val[k.nidx]=wor;
k.p[pu]=k.p[pv]=k.nidx;
}
}
for(int i=1;i<=n;i++) if(!k.vis[i]/*kruskal森林*/) k.dfs(k.find(i));
return ;
}
scanf("%d%d",&n,&m);
for(int i=1;i<=m;i++) scanf("%d%d%d",&edge[i].u,&edge[i].v,&edge[i].wor); //原图的边
kruskal();
性质
- 二叉树。
- 点权满足大(小)根堆性质。
- 原图的点均为Kruskal重构树的叶节点
- 对于点对(u,v),它们在原图中的所有路径中,最大边权最小的路径的最大边权为LCA(u,v)在重构树中的权值。
- 对于一个叶子结点u,它在原图中经过边权不超过x的边,能到达的点集为:找到一个深度最小的u的祖先pu,使得pu的点权不超过x,pu的子树中的叶子结点集合即为能到达的点集。
struct Kruskal
{
int get(int u,int x)
{
for(int k=18;k>=0;k--) if(fa[u][k] && val[fa[u][k]]<=x) u=fa[u][k];
return u;
}
}k;
求子树中叶子节点集合:
dfs序定义为只算叶子节点的dfs序。则子树u的叶子节点集合=[k.id[u],k.id[u]+k.siz[u]-1]。
struct Kruskal
{
int id[N],nid[N],siz[N],cnt;//dfs序
void init() {cnt=1;}
void dfs(int u)//转化为树上问题
{
if(u<=n)//原图的点
{
id[u]=cnt;
nid[cnt]=u;
siz[u]=1;
cnt++;
}
else id[u]=cnt;//新建的点
for(int i=h[u];i!=0;i=ne[i])
{
int v=e[i];
dfs(v);
siz[u]+=siz[v];
}
return ;
}
}k;
应用
由于要查询第K大/小或是求子树交的问题,所以在实战中往往配合主席树使用。
5.3.最小斯坦纳树\(O(2^K*M\log M+N*3^K)\)
给定一棵树和一些关键点,其的最小斯坦纳树可以为其虚树。虚树上的边权和即为最小权值和。
5.3.1.最小斯坦纳树
定义:给定一张无向连通图和一些关键点(个数≤10),边权为正,求出一个连通子图使得其包含所有给定的关键点,并且该子图中所有边的权值和最小,输出最小权值和。
性质:答案一定是棵树。(证明:如果答案存在环,则删去环上任意一条边,代价变小。)
如果边权为负,则根据贪心所有的负权边必选。
状压dp:无根树钦定一个根:f[i][state]:以i为根的整棵树,包含关键点集合为state的最小代价。
边界:\(f[key_i][1<<(i-1)]=0\),其余均为\(+\infty\)。
转移:
-
最终树中i的度数为1。
\(f[i][state]=\min\limits_{(j,i)\in E}\{ f[j][state]+w(j,i) \}\)。(如果i是关键点,则第二种情况会有转移)
把f[i][state]看成\(dis_{state}[i]\),发现这里的方程实际上是一个最短路。\(O(2^K*M\log M)\)
-
最终树中i的度数大于1。
\(f[i][state]=\min\limits_{s \subseteq state} \{ f[i][s]+f[i][state\operatorname{xor}s] \}\)。
枚举子集转移即可。\(O(N*3^K)\)
转移顺序:这里可以理解成一个类似背包的 DP,与i相邻的点是一个个出现的,每次多合并上一个点,所以第一重循环必须枚举升序集合state。
答案:定其中一个关键点为最终树的根root(因为最小斯坦纳树可能不包括其他点,但是一定包括关键点),ans=f[root][(1<<k)-1]。
int n,m,k,root; //root:最终树的根:ans=f[root][(1<<k)-1]
int f[N][K]; //f[i][state]:以i为根的整棵树,包含关键点集合为state的最小代价
int h[N],e[M],w[M],ne[M],idx;
bool vis[N];
priority_queue<PII,vector<PII>,greater<PII> > q;
void dijkstra(int state)
{
memset(vis,false,sizeof vis); //多次调用别忘记初始化!!!
//唯一和dijkstra模板是:原模板中的dis_{state}[i]是这里的f[i][state]。而且下面直接开始循环while(q.size()),不需要像原模板那样memset(dis,0x3f,sizeof dis)以及q.push({dis[1],1}),因为调用前已经处理好了
return ;
}
int main()
{
memset(f,0x3f,sizeof f); //边界
scanf("%d%d%d",&n,&m,&k);
for(int i=1;i<=m;i++)
{
int u,v,wor;
scanf("%d%d%d",&u,&v,&wor);
add(u,v,wor),add(v,u,wor);
}
for(int i=1;i<=k;i++)
{
int key;
scanf("%d",&key);
root=key; //定其中一个关键点为最终树的根是因为最小斯坦纳树可能不包括其他点,但是一定包括关键点
f[key][1<<(i-1)]=0; //边界
}
for(int state=1;state<(1<<k);state++) //第一重循环升序必须枚举升序集合state
{
for(int i=1;i<=n;i++)
{
for(int s=state&(state-1);s;s=state&(s-1)) f[i][state]=min(f[i][state],f[i][s]+f[i][state^s]); //最终树中i的度数大于1时的转移:枚举子集转移
if(f[i][state]<INF) q.push({f[i][state],i}); //把f[i][state]看成dis_{state}[i]
}
dijkstra(state); //最终树中i的度数为1时的转移:方程类似于最短路
}
printf("%d\n",f[root][(1<<k)-1]);
return 0;
}
5.3.2.最小斯坦纳树森林
定义:给定一张无向连通图和一些组,每个组包含一些关键点(关键点总个数≤10),边权为正,求出一个子图使得属于同一组的关键点连通,并且该子图中所有边的权值和最小,输出最小权值和。
- 先把所有的关键点放在一起,执行《5.3.1.最小斯坦纳树》的算法。
- 对组进行状态压缩:\(g[state]\):连通子图且包含组(指包含这个组所有的关键点)的集合为state的最小代价。每个\(g\)的值对应着步骤1中的一个\(f\)的值。根据state把相应的\(f\)的值取出来给\(g\)。
- \(g'[state]\):包含组(指包含这个组所有的关键点)的集合为state的最小代价。显然可以令\(g'[state]\)的初值为\(g[state]\),有子集之间的转移:\(g'[state]=\min\limits_{s \subsetneqq state}(g'[state],g'[s]+g'[state\operatorname{xor}s])\)。
- \(g[state]\)和\(g'[state]\)可使用同一个数组。\(ANS=g[(1<<gidx)-1]\)。
-
正确性
是否有这种情况:\(G_1=\{ 1,2 \},G_2=\{ 3,4,5 \}\)。\((11100)_2+(00011)_2+edge(2,4)→(11111)_2\)的情况更优?
不会,因为这种情况会被\(g[(11)_2]\)考虑到。
int gidx;
struct Key
{
int gid,id; //gid:组编号;id:原图的编号
bool operator < (const Key &qw) const
{
return gid<qw.gid;
}
}key[11];
vector<int> group[11]; //gruop[i]:储存第i组的关键点
//f[i][state]:以i为根的整棵树,包含关键点集合为state的最小代价
//g[state]:连通子图且包含组(指包含这个组所有的关键点)的集合为state的最小代价;g'[state]:包含组(指包含这个组所有的关键点)的集合为state的最小代价
//g[state]和g'[state]可使用同一个数组
int f[N][K],g[K];
int main()
{
memset(f,0x3f,sizeof f);
memset(g,0x3f,sizeof g);
scanf("%d%d%d",&n,&m,&k);
for(int i=1;i<=m;i++)
{
int u,v,wor;
scanf("%d%d%d",&u,&v,&wor);
add(u,v,wor),add(v,u,wor);
}
//分组
for(int i=1;i<=k;i++) scanf("%d%d",&key[i].gid,&key[i].id);
sort(key+1,key+k+1);
for(int i=1;i<=k;i++)
{
if(i==1 || key[i].gid!=key[i-1].gid) gidx++;
group[gidx].push_back(i);
f[key[i].id][1<<(i-1)]=0;
}
//先把所有的关键点放在一起,执行正常的最小斯坦纳树的算法
for(int state=1;state<(1<<k);state++)
{
for(int i=1;i<=n;i++)
{
for(int s=state&(state-1);s;s=state&(s-1)) f[i][state]=min(f[i][state],f[i][s]+f[i][state^s]);
if(f[i][state]<INF) q.push({f[i][state],i});
}
dijkstra(state);
}
//利用上面的最小斯坦纳树的算法得到g[state]
for(int state=1;state<(1<<gidx);state++)
{
int s=0,mi=INF;
for(int i=1;i<=gidx;i++)
if((state>>(i-1))&1)
for(auto it : group[i])
s+=1<<(it-1);
for(int i=1;i<=n;i++) mi=min(mi,f[i][s]);
g[state]=mi;
}
//g'[state]的转移
for(int state=1;state<(1<<gidx);state++)
for(int s=state&(state-1);s;s=state&(s-1))
g[state]=min(g[state],g[s]+g[state^s]);
printf("%d\n",g[(1<<gidx)-1]);
return 0;
}
输出方案
dp的路径追踪。
PIII pre[N][K]; //路径追踪:{从哪个状态转移而来,决策是哪条边}
vector<int> edge; //最小斯坦纳树的边
bool st[N][K]; //倒推状态时的记忆化搜索
void dijkstra(int state)
{
if(f[v][state]>f[u][state]+w[i])
{
f[v][state]=f[u][state]+w[i];
pre[v][state]={{u,state},i};
q.push({f[v][state],v});
}
}
void dfs(int u,int state)
{
if(st[u][state]) return ; //记忆化搜索
if(!state) return ; //超出起点边界
st[u][state]=true;
int nu=pre[u][state].first.first,nstate=pre[u][state].first.second,nedge=pre[u][state].second;
if(nedge>=2) edge.push_back(nedge);//合法的边
dfs(nu,nstate);
if(nu==u) dfs(nu,state^nstate); //若由两个状态转移而来,则倒推第二个状态
return ;
}
for(int state=1;state<(1<<k);state++)
{
for(int i=1;i<=n;i++)
{
for(int s=state&(state-1);s;s=state&(s-1))
{
if(f[i][state]>f[i][s]+f[i][state^s])
{
f[i][state]=f[i][s]+f[i][state^s];
pre[i][state]={{i,s},-1/*没有从任何边转移而来*/}; //另外一个转移而来的状态可以通过f[i][state^s]得知
}
}
if(f[i][state]<INF) q.push({f[i][state],i});
}
dijkstra(state);
}
dfs(root,(1<<k)-1);
5.4.dfs生成树\(O(N)\)
无向图的dfs生成树的性质:非树边只有返祖边(祖先,孙子)。
idx=1;//邻接表的初始化
//dfs生成树
void dfs1(int u,int fa)
{
vis[u]=true;
for(int i=h[u];i!=0;i=ne[i])
{
int v=e[i];
if(vis[v] || v==fa) continue;
tree[i]=tree[i^1]=true;//树边
dfs1(v,u);
}
return ;
}
//遍历dfs生成树
for(int i=h[u];i!=0;i=ne[i])
{
if(!tree[i]) continue;
int v=e[i];
if(v==fa) continue;
dfs2(v,u);
}
5.5.拓展应用
5.5.1.变式
-
最小直径生成树
前置知识:《图论8.2.1.树上点集的最远距离——树的直径》。
最小直径生成树:在无向图的所有生成树中,直径最小的那一棵生成树。
图的绝对中心:可以存在于一条边上或某个结点上,该中心到所有点的最短距离的最大值最小。
- 通过多源最短路算法求出图的绝对中心。
- 易知图的绝对中心是最小直径生成树的直径的中点。以图的绝对中心为起点,求出最短路径生成树,即为最小直径生成树。
5.5.2.求最小生成森林
方法一:Kruskal
直接求解。
方法二:Prim
建立一个虚拟源点,与所有节点连一条权值是0的边。
5.5.3.类Kruskal算法
5.5.3.1.把一棵树添加若干条边扩充为完全图,图的唯一最小生成树仍然是原树,求添加的边的权值最小之和
将原树的边按权值从小到大排序,执行类Kruskal算法,并查集多开一个集合大小的数组siz[]:
扫描(u,v,w)时,若u,v不连通,两个连通块应该连接siz[u]*siz[v]-1(还要记得减(u,v,w)这条边)条权值为w+1的边。然后再连接(u,v,w)。
for(int i=1;i<=n;i++) fa[i]=i,siz[i]=1;
sort(e+1,e+n);
for(int i=1;i<n;i++)
{
int u=find(e[i].u),v=find(e[i].v),w=e[i].w;
if(u!=v)
{
ans+=(siz[u]*siz[v]-1)*(w+1);//核心
siz[v]+=siz[u];//核心
fa[u]=v;
}
}
printf("%d\n",ans);
5.5.3.2.修改边权使得指定的生成树是最小生成树
执行类Kruskal算法,将原树的边按权值从小到大枚举确定修改之后的值,对于一条非指定的树边e=(u,v),要求生成树上路径(u,v)的所有边的边权都小于等于e的边权。因为一条边被赋完值之后不会再被修改,所以可以用并查集优化,也就是将确定的边直接缩起来。
int n,m;
int eidx;
struct Edge
{
int u,v,c; //c==1:指定的树边
LL wor;
}edge[N]; //存储原图的边
int h[N],e[M],dfn[M],ne[M],idx=1; //存储指定的树边。dfn[i]:边i的题目给定编号
LL w[M];
int dep[N],fa[N],fe[N]; //fe[u]:点u的父边
int p[N]; //并查集
void add(int u,int v,LL wor,int i)
{
e[++idx]=v,w[idx]=wor,dfn[idx]=i,ne[idx]=h[u],h[u]=idx;
return ;
}
void dfs(int u)
{
for(int i=h[u];i!=0;i=ne[i])
{
int v=e[i];
if(v==fa[u]) continue;
dep[v]=dep[u]+1;
fa[v]=u;
fe[v]=dfn[i];
dfs(v);
}
return ;
}
void lca(int x,int y)
{
x=find(x),y=find(y);
if(x==y) return ;
vector<int> path; //存储需要优先修改权值的树边
while(x!=y)
{
if(dep[x]<dep[y]) swap(x,y);
path.push_back(fe[x]);
p[x]=find(fa[x]); //压缩已经修改的边
x=find(fa[x]);
}
//对需要优先修改权值的树边进行处理
return ;
}
n=read(),m=read();
for(int i=1;i<=n;i++) p[i]=i;
for(int i=1;i<=m;i++)
{
edge[i].u=read(),edge[i].v=read(),edge[i].wor=read(),edge[i].c=read();
if(edge[i].c==1)
{
add(edge[i].u,edge[i].v,edge[i].wor,i);
add(edge[i].v,edge[i].u,edge[i].wor,i);
}
}
dep[1]=1;
dfs(1);
for(int i=1;i<=m;i++)
{
if(edge[i].c==1)
{
if(fa[edge[i].u]==edge[i].v) p[find(edge[i].u)]=find(edge[i].v);
else p[find(edge[i].v)]=find(edge[i].u);
}
else lca(edge[i].u,edge[i].v);
}
6.有向图的最小树形图——朱刘算法\(O(NM)\)
“在有向图上从根节点可以到达任意点。”
有向树形图的定义:对于一张有向图,它被称为有向树形图,当且仅当:
- 图中不存在环。
- 一个点的入度为 0,其余点的入度为 1。入度为 0 的点是根节点。
朱刘算法:基于贪心:
- 对于每一个点(除根节点),选出一条权值最小的入边。
- 判断选出的边中是否存在环:
tarjan()。- 如果不存在环,算法结束。
- 如果存在环(显然,任意两个环不存在公共点),则将所有的环缩点,得到新图 G′。对于原图的每一条边:
- 这条边是环内边,在新图上删去。
- 这条边终点在环内,设其终点为 v,这条边的权值为 w,v 之前选出的边权值为 w',则让这条边的权值变为 w−w′。
- 其它情况,保留原来的权值放入新图中。
- 使用新图回到第一步,不断迭代直到退出算法。最终选出的所有边的权值之和就是答案。
int n,m,root;
int d[N][N],bd[N][N]; //邻接矩阵
int pre[N]; //每个点的最小的入边的起点
void tarjan(int u) //判定+属于
{
dfn[u]=low[u]=++num;
st[++top]=u,in_st[u]=true;
int v=pre[u]; //走反向边找环
if(dfn[v]==0)
{
tarjan(v);
low[u]=min(low[u],low[v]);
}
else if(in_st[v]==true) low[u]=min(low[u],dfn[v]);
if(dfn[u]==low[u])
{
sc++;
int z;
do //只需要记录belong辅助后面判断环和判断两点是否在同一环上。不需要记录每个scc有哪些点
{
z=st[top--];
in_st[z]=false;
belong[z]=sc;
}while(z!=u);
}
return ;
}
int edmonds()
{
int res=0;
while(true) //迭代
{
//对于每个点求出最小的入边的起点(原点为1的情况已在主函数排除掉,这里循环不能从2开始,因为这里的点是每次迭代缩点后的点)
for(int i=1;i<=n;i++)
{
pre[i]=i;
for(int j=1;j<=n;j++) if(d[pre[i]][i]>d[j][i]) pre[i]=j;
}
//tarjan找环
num=sc=0;
memset(dfn,0,sizeof dfn);
for(int i=1;i<=n;i++) if(dfn[i]==0) tarjan(i);
if(sc==n) //不存在环,算法结束
{
for(int i=1;i<=n;i++) if(i!=pre[i]) res+=d[pre[i]][i]; //把选出的边加入贡献
break;
}
for(int i=1;i<=n;i++) if(i!=pre[i] && belong[i]==belong[pre[i]]) res+=d[pre[i]][i]; //存在环,把环上选出的边先加入贡献(因为后面要建新图。对于不在环上选出的边,最终算法结束时会加上)
//存在环,缩点建新图
memset(bd,0x3f,sizeof bd);
for(int i=1;i<=n;i++)
for(int j=1;j<=n;j++)
if(d[i][j]<INF) //(i,j)是一条边
{
int u=belong[i],v=belong[j];
if(u==v) continue; //环内边:在新图上删去
if(belong[pre[j]]==v) bd[u][v]=min(bd[u][v],d[i][j]-d[pre[j]][j]); //终点在环内的边:在新图上权值变为d[i][j]-d[pre[j]][j]
else bd[u][v]=min(bd[u][v],d[i][j]); //其他情况:在新图上保留原来权值
}
memcpy(d,bd,sizeof d);
n=sc; //更新点数
}
return res;
}
int main()
{
scanf("%d%d%d",&n,&m,&root);
memset(d,0x3f,sizeof d);
for(int i=1;i<=m;i++)
{
int u,v,wor;
scanf("%d%d%d",&u,&v,&wor);
if(u==v || v==root) continue; //排除自环和指向root的边
d[u][v]=min(d[u][v],wor); //邻接矩阵排除重边
}
//检查连通性特判无解
if(!check())
{
puts("-1");
return 0;
}
printf("%d\n",edmonds());
return 0;
}
7.最近公共祖先LCA
一棵有根树,若节点z即是节点x的祖先,又是y的祖先,则称z是x、y的公共祖先,其中深度最大的一个被称为x、y的最近公共祖先。
理解LCA:
向上标记法:从点x向上走到根节点,并标记所有经过的节点。然后从点y向上走到根节点,遇到的第一个已标记的节点就是LCA(x,y)。单次复杂度\(O(N)\)。
可以每次找深度比较大的那个点,让它向上跳。显然在树上,这两个点最后一定会相遇,相遇的位置就是想要求的 LCA。 (或者先向上调整深度较大的点,令他们深度相同,然后再共同向上跳转,最后也一定会相遇。)这种方法可以求出从点u到点v的简单路径上的点。单次复杂度\(O(dis(u,v))\)。
LCA具有交换律和结合律。
7.1.重链剖分在线求LCA\(O(N+Q\log N)-O(N)\)
时间复杂度:\(O(N+Q\log N)\)。空间复杂度:\(O(N)\)。
int dep[N],siz[N],fa[N],son[N];
int top[N];
void dfs1(int u)
{
dep[u]=dep[fa[u]]+1,siz[u]=1,son[u]=0;
for(int i=h[u];i!=0;i=ne[i])
{
int v=e[i];
if(v==fa[u]) continue;
fa[v]=u;
dfs1(v);
siz[u]+=siz[v];
if(siz[son[u]]<siz[v]) son[u]=v;
}
return ;
}
void dfs2(int u,int t)
{
top[u]=t;
if(!son[u]) return ;
dfs2(son[u],t);
for(int i=h[u];i;i=ne[i])
{
int v=e[i];
if(v==fa[u] || v==son[u]) continue;
dfs2(v,v);
}
return ;
}
int lca(int u,int v)
{
while(top[u]!=top[v])
{
if(dep[top[u]]<dep[top[v]]) swap(u,v);
u=fa[top[u]];
}
return dep[u]<dep[v] ? u : v;
}
// fa[rt]=0; //注意初始化fa
dfs1(rt);
dfs2(rt,rt);
cin>>u>>v;
cout<<lca(u,v)<<endl;
7.2.dfs序+ST表在线求LCA\(O(N\log N+Q)-O(N\log N)\)
当u=v时,lca(u,v)是u。当u≠v时,lca(u,v)是dfs序上下标在\([dfn_u+1,dfn_v]\)中的深度最小的任意节点的父亲。
一种避免记录每个结点的父亲和深度的方法是直接在 ST 表的最底层记录父亲,比较条件是取时间戳较小的结点。
时间复杂度:\(O(N\log N+Q)\)。空间复杂度:\(O(N\log N)\)。
int dfn[N],num;
int lg2[N],st[N][20];
void dfs(int u,int fa)
{
dfn[u]=++num;
st[dfn[u]][0]=fa;
for(int i=h[u];i;i=ne[i])
{
int v=e[i];
if(v==fa) continue;
dfs(v,u);
}
return ;
}
void st_lca()
{
for(int i=2;i<=n;i++) lg2[i]=lg2[i>>1]+1;
for(int k=1;1+(1<<k)-1<=n;k++)
for(int l=1;l+(1<<k)-1<=n;l++)
{
if(dfn[st[l][k-1]]<dfn[st[l+(1<<(k-1))][k-1]]) st[l][k]=st[l][k-1];
else st[l][k]=st[l+(1<<(k-1))][k-1];
}
return ;
}
int lca(int u,int v)
{
if(u==v) return u;
if(dfn[u]>dfn[v]) swap(u,v);
int k=lg2[dfn[v]-(dfn[u]+1)+1];
if(dfn[st[dfn[u]+1][k]]<dfn[st[dfn[v]-(1<<k)+1][k]]) return st[dfn[u]+1][k];
else return st[dfn[v]-(1<<k)+1][k];
}
dfs(rt,0);
st_lca();
cin>>u>>v;
cout<<lca(u,v)<<endl;
7.3.Tarjan算法离线求LCA\(O(\max\{N,Q\alpha(N+Q,N)\})-O(\max\{N,Q\})\)
本质上是并查集优化的向上标记法。
使用路径压缩/路径压缩和按秩合并优化的并查集:时间复杂度:\(O(\max\{N,Q\alpha(N+Q,N)\})\)。空间复杂度:\(O(\max\{N,Q\})\)。
int n,m,root;
int h[N],e[M],ne[M],idx;
int p[N]; //并查集
bool vis[N]; //是否遍历完成即将回溯
int ans[N];
vector<PII> q[N]; //离线储存询问
void tarjan(int u,int fa)
{
for(int i=h[u];i!=0;i=ne[i])
{
int v=e[i];
if(v==fa) continue;
tarjan(v,u);
p[v]=u;
}
vis[u]=true; //注意这行代码要在下行的上方,否则将不会处理询问LCA(u,u)
for(auto it : q[u]) if(vis[it.x]) ans[it.y]=find(it.x);
return ;
}
scanf("%d%d%d",&n,&m,&root);
for(int i=1;i<n;i++)
{
int u,v;
scanf("%d%d",&u,&v);
add(u,v),add(v,u);
}
for(int i=1;i<=m;i++)
{
int x,y;
scanf("%d%d",&x,&y);
q[x].push_back({y,i}),q[y].push_back({x,i});
}
for(int i=1;i<=n;i++) p[i]=i;
tarjan(root,-1);
for(int i=1;i<=m;i++) printf("%d\n",ans[i]);
7.4.其他方法求LCA
-
树上倍增法在线求LCA\(O(N\log N+Q\log N)-O(N\log N)\)
优点:可以与其他倍增数组结合,解决树上倍增问题。前置知识简单。
时间复杂度:\(O(N\log N+Q\log N)\)。空间复杂度:\(O(N\log N)\)。
int n,m,root;
int h[N],e[M],ne[M],idx;
int depth[N],fa[N][20];
void init_lca(int u,int father)
{
for(int i=h[u];i!=0;i=ne[i])
{
int v=e[i];
if(v==father) continue;
depth[v]=depth[u]+1;
fa[v][0]=u;
for(int k=1;k<=17;k++)
fa[v][k]=fa[fa[v][k-1]][k-1];
init_lca(v,u);
}
return ;
}
int lca(int a,int b){
if(depth[a]<depth[b]) swap(a,b);
for(int i=17;i>=0;i--)
if(depth[fa[a][i]]>=depth[b])
a=fa[a][i];
if(a==b) return a; //这里不要忘记!!!
for(int i=17;i>=0;i--)
if(fa[a][i]!=fa[b][i]){
a=fa[a][i];
b=fa[b][i];
}
return fa[a][0];
}
depth[root]=1;//这里不要忘记!!!
init_lca(root,-1);
int x,y;
cin>>x>>y;
int p=lca(x,y);//此时p就是x和y的最近公共祖先
-
欧拉序+ST表在线求LCA\(O(N\log N+Q)-O(N\log N)\)
优点:可以与其他欧拉序数组结合,解决树上欧拉序问题。
使用欧拉序把树上问题转化为线性问题,两个点的最近公共祖先就是两个点的欧拉序区间深度最小的节点,将问题转化为RMQ问题。
注意欧拉序列的长度是2n-1。
时间复杂度:\(O(N\log N+Q)\)。空间复杂度:\(O(N\log N)\)。
int euler[N],seq[N*2],sidx;
int dep[N],lg2[N*2],st[N*2][20];
void dfs(int u,int fa)
{
++sidx;
seq[sidx]=u,euler[u]=sidx;
for(int i=h[u];i!=0;i=ne[i])
{
int v=e[i];
if(v==fa) continue;
dep[v]=dep[u]+1;
dfs(v,u);
seq[++sidx]=u;
}
return ;
}
void st_lca()
{
for(int i=2;i<=2*n-1;i++) lg2[i]=lg2[i>>1]+1;
for(int i=1;i<=2*n-1;i++) st[i][0]=seq[i];
for(int k=1;1+(1<<k)-1<=2*n-1;k++)
for(int l=1;l+(1<<k)-1<=2*n-1;l++)
{
if(dep[st[l][k-1]]<=dep[st[l+(1<<(k-1))][k-1]]) st[l][k]=st[l][k-1];
else st[l][k]=st[l+(1<<(k-1))][k-1];
}
return ;
}
int lca(int x,int y)
{
if(euler[x]>euler[y]) swap(x,y);
int k=lg2[euler[y]-euler[x]+1];
if(dep[st[euler[x]][k]]<=dep[st[euler[y]-(1<<k)+1][k]]) return st[euler[x]][k];
else return st[euler[y]-(1<<k)+1][k];
}
dfs(s,-1);
st_lca();
cin>>u>>v;
cout<<lca(u,v)<<endl;
7.5.拓展应用
-
点集LCA
给定一棵 \(n\) 个点的以点 \(rt\) 为根的树,请回答下面 \(q\) 次询问:给定点集中的点的 \(\text{LCA}\) 是谁?
以点 \(rt\) 为根预处理 \(\text{dfs}\) 序。
“给定点集中的点的 \(\text{LCA}\)”是“给定点集中的 \(\text{dfs}\) 序最小的和最大的两个点的 \(\text{LCA}\)”。
-
换根LCA
给定一棵 \(n\) 个点的无根树,请回答下面 \(q\) 次询问:当以点 \(rt_i\) 为根时,点 \(u_i,v_i\) 的 \(\text{LCA}\) 是谁?
选定一个点为根(下文选定点 \(1\) 为根)预处理 \(\text{LCA}\)。
“当以点 \(rt_i\) 为根时,点 \(u_i,v_i\) 的 \(\text{LCA}\)”是“当以点 \(1\) 为根时,在点 \(rt_i,u_i\) 的 \(\text{LCA}\)、点 \(rt_i,v_i\) 的 \(\text{LCA}\) 和点 \(u_i,v_i\) 的 \(\text{LCA}\) 中深度最大的点”。
-
换根区间LCA
给定一棵 \(n\) 个点的无根树,请回答下面 \(q\) 次询问:当以点 \(rt_i\) 为根时,编号在 \([l_i,r_i]\) 中的点的 \(\text{LCA}\) 是谁?
选定一个点为根(下文选定点 \(1\) 为根)预处理 \(\text{dfs}\) 序和 \(\text{LCA}\)。
在以 \(\text{dfs}\) 序为下标的点的编号的序列上,在以 \(rt_i\) 为根的子树对应的区间和它的两个补区间(即在该序列上,下标为 \([1,\text{dfs}_{rt_i}-1],[\text{dfs}_{rt_i},\text{dfs}_{rt_i}+\text{size}_{rt_i}-1],[\text{dfs}_{rt_i}+\text{size}_{rt_i},n]\) 的三个区间,其中 \(\text{size}_{rt_i}\) 表示以 \(rt_i\) 为根的子树的点数)中各自找到最靠近区间左端点和右端点的编号在 \([l_i,r_i]\) 中的点,下文把这六个点称为关键点。
“当以点 \(rt_i\) 为根时,编号在 \([l_i,r_i]\) 中的点的 \(\text{LCA}\)”是“当以点 \(rt_i\) 为根时,这六个关键点的 \(\text{LCA}\)”。
-
LCA点集
给定一个点集V,求满足\(\forall u,v\in V,\text{LCA}(u,v)\in V'\)的点集V'。
按照时间戳将V中的点排序,排序后将相邻两个节点(包括首尾)的lca加入V',最后对V'去重。
性质:
- \(\forall u,v\in V\cup V',\text{LCA}(u,v)\in V'\)。
- V'的大小是\(O(|V|)\)。
- 《8.4.拓展应用5.》。
-
求树上包含给定点集V的连通块的边集的总长度最小值
按照时间戳将V中的点排序,排序后累加相邻两个节点(包括首尾)之间的路径长度为res,答案=res/2。
-
树上倍增法
例题:次小生成树
-
求三个点的“集合点”,要求从三个点走到“集合点”的路径不重合:“集合点”是三点的两两间深度最大的lca处。
-
公式:对于已按入栈序排列的若干个点\(a_1,a_2,\cdots,a_n\)在树上构成的最小连通块,这些点在树上构成的最小连通块大小\(=\sum\limits_{i=1}^{n}dep[a_i]-\sum\limits_{i=2}^{n}dep[\text{LCA}(a_{i-1},a_{i})]-dep[\text{LCA}(a_1,a_2,\cdots,a_n)]+1\)。
Bonus:如何用线段树维护?在入栈序上建立线段树,线段树上第i个叶子节点代表入栈序中第i个原图节点的信息。上面式子的第一部分很好维护。对于第二、三部分,线段树新建mi、ma信息储存当前区间入栈序最小、大的原图节点编号,在pushup中,\(tr[u].w_2=tr[tr[u].lson].w_2+dep[lca(tr[tr[u].lson].ma,tr[tr[u].rson].mi)]+tr[tr[u].rson].w_2,tr[u].w_3=dep[lca(tr[u].mi,tr[u].ma)]\)。把关键点依次插入即可。
8.树上距离
8.1.树上两点的距离
利用“以前缀和求区间和”简化求树上两点间的距离的复杂度。
树上两点的距离
树上两点的距离:连接这两点的简单路径上的边权的和。
先\(O(N)\)预处理d[u]:点u到根节点的路径的权值和。
- 以边为权值\(O(\text{LCA})\)
inline int dis(int u,int v)
{
int p=lca(u,v);
return d[u]+d[v]-2*d[p];
}
- 以点为权值\(O(\text{LCA})\)
inline int dis(int u,int v)
{
int p=lca(u,v);
return d[u]+d[v]-d[p]-d[fa[p][0]]; //而不是减2倍的d[p],否则会多减p这一个节点
}
树上两点的异或距离
树上两点的异或距离:连接这两点的简单路径上的边权的异或和。
先\(O(N)\)预处理d[u]:u到根节点的路径的异或和。
-
以边为权值\(O(1)\)
此时不需要lca!!!
inline int dis(int u,int v)
{
return d[u]^d[v];
}
-
以点为权值\(O(\text{LCA})\)
a[u]:点u的权值。注意lca参与计算用的是a[p]而不是d[p]!!!
inline int dis(int u,int v)
{
int p=lca(u,v);
return d[u]^d[v]^**a[p]**;//注意lca参与计算用的是a[u]而不是dis[u]!!!
}
8.2.树上最远距离
8.2.1.树上点集的最远距离——树的直径
树上点集中的最远的两个点的距离或者简单路径被称为由该点集(构成的子树)的直径。
8.2.1.1.树形dp求树的直径
缺点:不能知道树的直径的具体路径。
d[u]:以点u为根的子树中点u到叶子节点的最远距离。
时空复杂度:\(O(N)\)。
int d[N]; //d[i]:以点i为根的子树中点i到叶子节点的最远距离
void dp(int u,int fa)
{
for(int i=h[u];i!=0;i=ne[i])
{
int j=e[i];
if(j==fa) continue;
dp(j,u);
//注意下面的代码两行顺序不能反!!!
length=max(length,d[u]+d[j]+w[i]);
d[u]=max(d[u],d[j]+w[i]);
}
return ;
}
8.2.1.2.两次BFS/DFS求树的直径
缺点:边权必须非负。
结论:当边权非负时,树上从点u出发能到达的点集中的最远的点一定是该点集的直径的两端点之一。
时空复杂度:\(O(N)\)。
int dep[N]; //dep[i]:在以u为根的树上,i的深度
int bfs(int u)
{
memset(dep,-1,sizeof dep);
dep[u]=0;
q.push(u);
int p=u;
while(!q.empty())
{
int t=q.front();
q.pop();
p=t;//因为队列满足单调性,所以最后出队的点一定是深度最大的点
for(int i=h[t];i!=-1;i=ne[i])
{
int j=e[i];
if(dep[j]==-1)
{
dep[j]=dep[t]+1;
/*如果要记录路径,加上下面的代码:
//fa[i]:记录直径路径;从哪条边走到i
fa[j]=i; //注意是记录边
*/
q.push(j);
}
}
}
return p;
}
int p=bfs(1);
int q=bfs(p); //p是q的祖先
printf("%d\n",dep[q]);
8.2.1.3.合并边权非负的树上两点集的直径
已知边权非负的树上两点集的直径的四个端点,求出该两点集的并集的直径的两个端点。
由《图论8.2.1.2.两次BFS/DFS求树的直径》的结论可知:并集的直径的两个端点一定是在两点集的直径的四个端点中最远的两个端点。
8.2.1.4.动态维护边权非负的树的直径
给定n个点,动态连n-1条边,最后保证连成1棵树,边权非负。求每次连完1条边后,该边所在树的直径长度。
根据《图论8.2.1.3.合并边权非负的树上两点集的直径》,对每个点开一个并查集,储存并查集的根和该连通块(树)的两直径端点。每合并两棵树时,在原来两棵树的直径的四个端点中选出距离最远的两个端点作为新的树的直径,并输出该直径长度。
求两个端点的距离
-
离线做法\(O(N\log N)\)
先把最终的树建出来。由于树上两点之间的距离是确定的,因此最终的树上两个端点之间的距离=此时两个端点之间的距离。
-
在线做法\(O(N\log^2 N)\)
如果把并查集v合并到并查集u,那么整棵树v中的每个点i的dep[i]和fa[i][k]都需要重新计算,因此使用启发式合并。
补充
-
连一条边合并2棵树,如果要求连完边之后的树的直径最小,则选择2棵树的直径的“中点”(即树的中心)连边。设原来2棵无权树的直径分别是\(d_1,d_2\),则新的直径\(=\max\{d_1,d_2,\lceil \frac{d_1}{2} \rceil+1+\lceil \frac{d_2}{2} \rceil\}\)。
-
证明
当每次连完1条边合并两棵树时,新的树的直径的端点一定是原来两棵树的直径的四个端点中之二。
所以连边时只需关心原来两棵树的直径的四个端点的两两距离。
若连边的一个端点不在原直径上,则可通过移动端点法证明该端点移动到直径上更优。所以连边的两个端点一定在直径上。
设直径的四个端点到在各自直径上的连边的端点的距离分别为\(x,d_1-x,y,d_2-y\)。
则新的直径\(=\max\{d1,d2,x+1+y,x+1+d2-y,d1-x+1+y,d1-x+1+d2-y\}\)。现在要让这个最大值最小。
以\(\max\{x+1+y,x+1+d_2-y\}=\max\{y,d_2-y\}\)为例:此时当\(y=\frac{d}{2}\)时,该最大值最小。
同理,当选择2棵树的直径的中点连边时,新的直径有最小值\(=\max\{d_1,d_2,\lceil \frac{d_1}{2} \rceil+1+\lceil \frac{d_2}{2} \rceil\}\)。
求无权树的直径的“中点”:求出直径长度d。选出直径的深度最大的端点v。让v利用倍增数组fa[v][k]向上跳\(\lfloor\frac{d}{2}\rfloor\)的距离就可跳到中点。
证明:在以任何一个点为根的树中,直径的深度最大的端点到直径的两个端点的最近公共祖先的距离≥直径长度的一半。
有权树的情况参考无权树。
-
8.2.1.5.树的直径的必须边
求出直径dia[]。
再求出d2[i]:直径上的第i个点不经过直径上的点能到达的最远的点的距离;d3[i][0/1]:直径上的第i个点与直径左/右端点的距离。
然后令点r从直径的左端点开始往右走直到d3[r][1]d2[r],令点l从点r开始往左走直到d3[l][0]d2[l]。
则直径上点l与点r之间的边是树的直径的必须边。
时空复杂度:\(O(N)\)。
8.2.2.树上两点集的最远距离
树上两点集的最远距离是在两点集中各取一个点,它俩的距离的最大值。
由《图论8.2.1.3.合并边权非负的树上两点集的直径》可知:树上两点集间的最远距离是分别在两点集的直径的两个端点中各取一个端点,它俩的距离的最大值。
8.2.3.树的中心
定义:在树中,如果点u作为根节点时,从点u出发的最长链最短,那么称点u这棵树的中心。
一般情况讨论的是无权树。
性质:
- 树的中心一定是树的直径的“中点”。
- 树的中心不一定唯一。但在边权为正时最多有2个,且这两个中心是相邻的。
- 边权为正时,树上所有点到其最远点的路径一定交会于树的中心。
- 当树的中心为根节点时,其到达直径端点的两条链分别为以其为端点的最长链和次长链。
- 当通过在两棵树间连一条边以合并为一棵树时,连接两棵树的中心可以使新树的直径最小。
- 无权树的中心到其他任意节点的距离不超过树直径的一半向上取整。
8.2.3.1.树形dp求树的中心
-
第一次dfs,求出\(d1_u\)(以点u为根的子树中以点u为端点的最长链(的长度))和\(d2_u\)(以点u为根的子树中以点u为端点的次长链(的长度))。
-
第二次dfs,求出\(d3_u\)(以点u为根的子树外以点u为端点的最长链(的长度))。
\(d3_{son_u} =\max\{[son_u\not\in d1_{u}]d1_{u},[son_u\not\in d2_{u}]d2_{u},d3_{u}\}+w_{u,son_u} =\max\{[d1_{son_u}+w_{u,son_u}\not=d1_{u}]d1_{u},[d1_{son_u}+w_{u,son_u}==d1_{u}]d2_{u},d3_{u}\}+w_{u,son_u}\)。
-
找到点u使得\(\max(d1_u,d3_u)\)最小,则点u是树的中心。
时空复杂度:\(O(N)\)。
8.2.3.2.两次BFS/DFS求树的直径进而求树的中心
缺点:边权必须非负。
树的中心一定是树的直径的“中点”。
时空复杂度:\(O(N)\)。
8.3.树上点到点集的最近距离
点分树求解。
点分树的一些性质:
- 对于任意两点 \(u\) 和 \(x\),设 \(c\) 是它们在点分树上的最近公共祖先(LCA),有dist(u,x)=dist(u,c)+dist(x,c)。
- 对任意点分树祖先 \(c'\)(包括 \(c\)),有dist(u,x)≤dist(u,c′)+dist(x,c′),且等号在 \(c' = c\) 时成立。
为每个分治中心 \(c\) 维护一棵动态开点线段树:
- 键:点的原始编号(范围 \([1, n]\))。
- 值:该点到 \(c\) 的距离 \(\text{dist}(x, c)\)。
- 维护区间最小值。
若点集具有特殊性,则可能有不用动态开点线段树的方法。
9.树上差分
适用条件:路径/子树操作+所有操作结束后再进行询问。
利用“差分序列的后/前缀和是原序列”简化路径/子树操作的复杂度。
序列→树:区间操作→路径/子树操作,后缀和→自下而上的子树和,前缀和→自上而下的祖先和。
树上差分与树链剖分对于路径/子树操作的区别:就像是差分与树状数组的区别,树上差分要求所有操作结束后再进行询问 并且 单个差分数组只能维护路径或者子树操作并且复杂度是\(O(N)\)的,树链剖分可以做到动态维护 并且 可以同时维护路径以及子树操作 并且 复杂度是\(O(N\log^2 N)\)。
路径操作
-
边的覆盖
差分数组\(f\):\((x,y)\)之间的边的权值加\(1\):令\(f[x]\)权值加1,\(f[y]\)权值加\(1\),\(f[lca(x,y)]\)权值减2。
最后对这棵树进行一次深度优先遍历:
计数数组\(sum[x]\):\(sum[x]\)是以\(x\)为根的子树各节点的权值之和:\(sum[x]=sum[所有子结点]+f[x]\)。
\(sum[x]\)就是\(x\)与它的父亲节点之间的边被覆盖的次数。 -
点的覆盖
差分数组\(b\):\((x,y)\)之间的点的权值加\(1\):令\(b[x]\)权值加1,\(b[y]\)权值加\(1\),\(b[lca(x,y)]\)权值减1,\(b[father(lca(x,y))]\)权值减1。
最后对这棵树进行一次深度优先遍历:
计数数组\(c\):\(c\)是\(b\)的子树和:\(c[x]=c[所有子结点]+b[x]\)。
\(c[x]\)就是节点\(x\)被覆盖的次数。
子树操作
一般是点的覆盖。
- 方法一:修改子树的根节点,最后自上而下地求祖先和。
- 方法二:求出dfs序,因为以u为根的子树内的点的下标是一个连续的子区间\([dfn_u,dfn_u+siz_u-1]\),所以可以转化为序列问题。
题型1
形如“给树上一条路径上的每个点都插入1个物品+最后查询每个点的信息”。
- 若信息满足减法性质:开一个全局计数数组。刚递归到点u时先令点u减去计数数组的信息(减去兄弟子树的信息);即将回溯时再令点u加上计数数组的信息。
- 否则,使用线段树合并,儿子向父亲合并,即将回溯时令点u加上线段树的信息。
10.基环树
基环树通常与dp结合。
定义
一张n个点、n条边、无自环、无重边的图。(如果不连通就是基环树森林)
构造方式:对于一个有N个点,N-1条边的树,我们再其中任意选两个没有边直接相连的点,把它们连起来,就构成了一个有N个点,N条边的基环树。更形象地,可以认为基环树就是在一个简单环的一些节点上都挂了一棵树。
充分必要条件
有向树:外向树:每个点都有唯一的入边;内向树:每个点都有唯一的出边。
无向树:从每个点出发有一条唯一的边。
如果基环树中的每条边都有方向,那么它可以分为外向树和内向树两种。首先,环上的边的指向要一样,即都是顺时针 / 逆时针。对于其它树,如果都是从子节点指向父节点,那就是内向树;如果都是从父节点指向子节点,那就是外向树。无论是内向树还是外向树,建图时都建成外向树,这样才能实现遍历。
无向图的基环树一般考察树上求距离。
有向图的基环树,一定要满足是内向树或外向树才可以dp。
10.1.无向图——类基环树上两点的距离(最值)模型
求基环树的直径
考虑一棵基环树,选出的两个点有两种可能:
- 两个点位于同一棵树上。对于这种情况,很容易用树形DP来求解。
- 两个点位于不同树上。对于这种情况,两点之间的路径一定会经过这个环,我们要最大化两个点的最长链和两个点的环上距离的和。这个时候我们可以采取类似仙人掌图的做法,使用单调队列优化。
#include<bits/stdc++.h>
using namespace std;
typedef long long LL;
const int N=1e6+5,M=2*N;
int n;
LL res,ans;
bool vis[N];
LL q[N]; //滑动窗口
//建边的变量
int h[N],e[M],w[M],ne[M],idx=1;//本题边的编号必须从2开始(判断反向边)
//往回遍历构造新环的变量
//将所有环上的点都记录cir[]数组上,同一个环上的点在一段连续的区间上
//第i个环的起点是cir[]上第ed[i-1]+1个点,终点是cir[]上第ed[i]个点
int fa_u[N],fa_w[N],cir[M],ed[M],cidx; //fa_u[i]:栈的遍历中第i个点的父亲;fa_w[i]:栈的遍历中第i个点到父亲节点的距离;cir[i]:所有环中第i个点的编号;ed[i]:第i个环的截止位置是cir[]上的第ed[i]个点;cidx:环的编号
bool in_stack[N];
//基环树直径的变量
LL d[M],sum_cir[M],sum[M]; //d[i]:在以i为根的子树,从i出发能到达最远的距离;sum_cir[i]:环上的从i到cir[]起点的距离前缀和;sum[i]:环破成的链上从i到cir[]起点的距离前缀和
void add(int a,int b,int c){
e[++idx]=b;
w[idx]=c;
ne[idx]=h[a];
h[a]=idx;
return ;
}
void dfs_c(int u,int from){
vis[u]=in_stack[u]=true; //记录入栈
for(int i=h[u];i!=0;i=ne[i]){
// 如果是反向边则跳过,必须用边来判断,因为存在1->2、2->1
if(i==(from^1)) continue;
int j=e[i];
fa_u[j]=u,fa_w[j]=w[i]; //记录栈的路径
if(!vis[j]) dfs_c(j,i);
else if(in_stack[j]){ //如果遍历到栈中的点,说明形成了环
LL tot=w[i]; //前缀和
cidx++;
ed[cidx]=ed[cidx-1]; //从上个环的截至位置+1开始记录这个环
for(int k=u;k!=j;k=fa_u[k]){ //从当前往回遍历构造新环
cir[++ed[cidx]]=k;
sum_cir[k]=tot; //环形k到j的最远距离
tot+=fa_w[k];
}
//不要忘记把j加进去
sum_cir[j]=tot;
cir[++ed[cidx]]=j;
}
}
in_stack[u]=false; //还原现场
return ;
}
//求树上直径:树形dp
LL dfs_d(int u){ //求以u为根节点的子树的最大深度
vis[u]=true;
LL d0=0,d1=0; //第一大+第二大=直径
for(int i=h[u];i!=0;i=ne[i]){
int j=e[i];
if(vis[j]) continue;
LL d=dfs_d(j)+w[i];
if(d>=d0) d1=d0,d0=d; //注意这里要取等,因为这样d0的值赋给d1,d1就变大了
else if(d>d1) d1=d;
}
res=max(res,d0+d1); //不经过环的情况
return d0;
}
int main(){
scanf("%d",&n);
for(int i=1;i<=n;i++){
int a,l;
scanf("%d%d",&a,&l);
add(i,a,l),add(a,i,l);
}
for(int i=1;i<=n;i++) if(!vis[i]) dfs_c(i,-1);
memset(vis,0,sizeof vis);
for(int i=1;i<=ed[cidx];i++) vis[cir[i]]=true; //将所有环上的点设为不可遍历,为了后面求d[]
for(int i=1;i<=cidx;i++){ //遍历所有的环
int size=0; //当前基环树的环的大小
res=0; //当前基环树的直径
for(int j=ed[i-1]+1;j<=ed[i];j++){ //遍历环上的每一个点
size++;
int k=cir[j];
d[size]=dfs_d(k); //求以当前点为根的子树的最大深度
sum[size]=sum_cir[k];
}
//破环成链,前缀和数组和d[]数组延长一倍
for(int j=1;j<=size;j++){
d[size+j]=d[j];
sum[size+j]=sum[j]+sum[size];//
}
//做一遍滑动窗口,比较依据是d[k] - sum[k]
int hh=0,tt=-1;
for(int j=1;j<=size*2;j++){
while(hh<=tt && q[hh]<=j-size) hh++;
if(hh<=tt) res=max(res,d[j]+d[q[hh]]+sum[j]-sum[q[hh]]); //经过环的情况
while(hh<=tt && d[j]-sum[j]>=d[q[tt]]-sum[q[tt]]) tt--;
q[++tt]=j;
}
ans+=res;
}
printf("%lld\n",ans);
return 0;
}
10.2.有向图——基环树dp
环形dp和树形dp的结合。
- 先假设题目给定的是一棵普通的树,考虑树形dp来设计方程。
- 建图时都建成外向树(每个点都有唯一的入边,fa[]构成了内向树),这样才能实现遍历。但是注意理清楚谁的被选择会影响谁。
- 在基环树森林中,对于一棵基环树在环上找到任意一点作为当前的根节点。利用环形dp的技巧——两次dp,强制断开相连,强制断开根节点与其父节点的边,一次以根节点被选择为基础跑一遍树形dp,一次以根节点不被选择为基础跑一遍树形dp。
- 如果(不)选择根节点会对其他点有限制条件,因为在树形dp中已经强制加上限制条件,所以res=max(f[root][0],f[root][1]);。
- 如果(不)选择根节点会对其他点没有限制条件,因为根结点必须(不)选才能使得没有限制条件,所以res=max(f[root][(0)1]);。
- 在树形dp中,不能走指向根节点的边(因为在上面已经强制断开了)。同时在即将回溯时,如果当前节点是根节点的父节点,则考虑根节点在树形dp之前的选或不选对当前节点的影响,不合法情况赋值为\(-\infty\)。其余的地方正常树形dp
- 最终答案是各棵基环树的值之和或最值。
int n,ans;
bool flag; //根节点强制选或不选
//建边的变量
int h[N],e[N],ne[N],idx;
//基环树的变量
int root,fa[N],vis[N]; //vis[i]:节点i是否在已经扫描过的一棵内向树上,而不是dp递归的st[]数组
//树形dp的变量
int f[N][2]; //f[i][bool]:以i为根的子树,i是否选择的方案
//因为建的是有向边,所以不需要st[]数组
void dp(int u)
{
vis[u]=true; //u在当前在扫描过的一棵外向树
for(int i=h[u];i!=0;i=ne[i])
{
int v=e[i];
if(v==root) continue; //不能走指向根节点的边(因为之前已经强制断开了)
dp(v);
f[u][0]//abaabaaba
f[u][1]//abaabaaba
}
if(fa[root]==u && flag) //u是root的父节点且root必须选择,考虑对f[u]的影响,不合法情况赋值为-INF
return ;
}
int main()
{
scanf("%d",&n);
for(int i=1;i<=n;i++)
{
scanf("%d",&fa[i]);
add(fa[i],i); //建图时都建成外向树(每个点都有唯一的入边)。但是注意理清楚谁的被选择会影响谁
}
for(int i=1;i<=n;i++)//基环树森林
{
if(vis[i]) continue; //已经在扫描过的一棵外向树
int res=0;
//利用fa[](fa[]构成了内向树)找到环上的一个点,并定根
root=i;
while(!vis[fa[root]])
{
vis[root]=true;
root=fa[root];
}
//强制断开环
//选择根节点
flag=true;
dp(root);
//如果选择根节点会对其他点有限制条件,因为在树形dp中已经强制加上限制条件,所以res=max(f[root][0],f[root][1]);
//如果选择根节点会对其他点没有限制条件,因为根结点必须选才能使得没有限制条件,所以res=max(f[root][1]);
//不选择根节点
memset(f,0,sizeof f); //注意第二次dp要memset
flag=false;
dp(root);
//如果不选择根节点会对其他点有限制条件,因为在树形dp中已经强制加上限制条件,所以res=max(f[root][0],f[root][1]);
//如果不选择根节点会对其他点没有限制条件,因为根结点必须不选才能使得没有限制条件,所以res=max(f[root][0]);
ans+=res; //最终答案是各棵基环树的值之和或最值:ans=max/min(ans,res);
}
printf("%d\n",ans);
return 0;
}
11.仙人掌
任意一条边至多只出现在一个简单环的无向连通图。
基环树是一种特殊的仙人掌。
建图应用
- 先把仙人掌转化为圆方树。
- 再使用树的算法。
- 考虑如果题目给定的就是一颗普通树,如何求解。
- 考虑仙人掌上的路径\(\Leftrightarrow\)圆方树的路径。
- 遍历时,找到当前点,需要对当前点分类讨论:当前点是圆点、方点(代表环,路径经过该环)。
点数=min(原图的点数2,原图的边数),边数=点数3(原图无向图要2倍,圆方树有向图要1倍)。
11.1.圆方树\(O(N)\)
有向树(从父节点指向儿子)。
-
任取一点为根。原图的点都作为圆点。
-
对环变形。
建立一个方点。从环上到根节点最近的点(简称环的“头”)向方点连一条权值为0的有向边。从方点向环上其他点连一条权值为“头”到该点的最短距离的边。
-
其余边=原图边+方向。
因为要对环变形,因此使用tarjan点双连通算法。
int n,m;
int h1[N],h2[N],e[M],w[M],ne[M],idx=1; //h1:仙人掌;h2:圆方树
int dfn[N],low[N],num,newid;
int fu[N],fe[N],fw[N],s[N],scir[N]; //环的变量。fu[u]:tarjan的遍历中u的父节点;fe[u]:tarjan的遍历中u的父边;fw[u]:tarjan的遍历中u的父边的边权;s[u]:u所在环的距离前缀和(用于求环上最短距离);scir[u]:u所在环的环长(用于求环上最短距离)
void build_circle(int u,int v,int wor)
{
int sum=wor;
for(int i=v;i!=u;i=fu[i])
{
s[i]=sum;
sum+=fw[i];
}
s[u]=scir[u]=sum;
newid++; //建立方点
add2(u,newid,0);
for(int i=v;i!=u;i=fu[i])
{
scir[i]=sum;
add2(newid,i,min(s[i],sum-s[i]));
}
return ;
}
void tarjan(int u,int in_edge)
{
dfn[u]=low[u]=++num;
for(int i=h1[u];i!=0;i=ne[i])
{
int v=e[i];
if(dfn[v]==0)
{
fu[v]=u,fe[v]=i,fw[v]=w[i];
tarjan(v,i);
low[u]=min(low[u],low[v]);
if(dfn[u]<low[v]) add2(u,v,w[i]);//v怎么走都走不到u的祖先节点,说明(u,v)肯定不是环上边,因此直接建圆方树的边
}
else if(i!=(in_edge^1)) low[u]=min(low[u],dfn[v]);
}
for(int i=h1[u];i!=0;i=ne[i])
{
int v=e[i];
if(dfn[u]<dfn[v] && fe[v]!=i/*v有两个父节点,说明找到一个环,并且u是环的“头”*/) build_circle(u,v,w[i]);
}
return ;
}
scanf("%d%d",&n,&m);
newid=n;
for(int i=1;i<=m;i++)
{
int u,v,wor;
scanf("%d%d%d",&u,&v,&wor);
add1(u,v,wor),add1(v,u,wor);
}
tarjan(1,-1);
11.2.求仙人掌上任意两点的最短距离
- 如果题目给定的就是一颗普通树,则使用树上差分求树上任意两点的最短距离。
- 当前点:树上差分中的p=lca(x,y):
- p是圆点:两点不在环上交汇,\(dis_仙(x,y)=dis_树(x,y)\),用求树上任意两点的最短距离求解。
- p是方点:两点在环上交汇,设xc:x向上走到环的第一个点,yc:y向上走到环的第一个点。\(dis_仙(x,y)=dis_树(x,xc)+dis_{树上环}(xc,yc)+dis_树(yc,y)\)。其中\(dis_树(x,xc)\)和\(dis_树(y,yc)\)用求树上任意两点的最短距离求解,\(dis_{树上环}(xc,yc)\)用求环上任意两点的最短距离(详见《动态规划5.3.求一个简单环上任意两点的最短距离》)求解。
int xc,yc;
int dis[N]; //树上差分。dis[u]:u到根节点的最短距离
int depth[N],fa[N][20]; //lca(辅助树上差分)
int lca(int x,int y)
{
if(depth[x]<depth[y]) swap(x,y);
for(int k=13;k>=0;k--) if(depth[fa[x][k]]>=depth[y]) x=fa[x][k];
if(x==y) return x;
for(int k=13;k>=0;k--)
if(fa[x][k]!=fa[y][k])
{
x=fa[x][k];
y=fa[y][k];
}
xc=x,yc=y; //注意记录x向上走到环的第一个点(因为fa[xc][0]=方点,所以xc就是x向上走到环的第一个点)
return fa[x][0];
}
void dfs(int u) //求dis[u]
{
for(int i=h2[u];i!=0;i=ne[i])
{
int v=e[i];
dis[v]=dis[u]+w[i];
dfs(v);
}
return ;
}
//圆方树tarjan(1,-1)之后:
depth[1]=1;
init_lca(1);
dfs(1);
while(q--)
{
int x,y;
scanf("%d%d",&x,&y);
int p=lca(x,y);
if(p<=n) printf("%d\n",dis[x]+dis[y]-dis[p]*2); //求树上任意两点的最短距离
else
{
int dx=dis[x]-dis[xc],dy=dis[y]-dis[yc]; //求树上任意两点的最短距离
int dxy=min(abs(s[xc]-s[yc]),scir[xc]-abs(s[xc]-s[yc])); //求环上任意两点的最短距离
printf("%d\n",dx+dxy+dy);
}
}
11.3.仙人掌的直径
-
如果题目给定的就是一颗普通树,则使用bfs(对仙人掌不适用)或树形dp。
-
当前点:树形dp遍历的当前点:
树形dp:d[u]:u到叶子节点最远的距离。详见《图论7.1.树形dp求树的直径》。
- p是圆点:两条路径至多一条路径经过环,ans=maxn+smaxn,正常树形dp求解。
- p是方点:两条路径都经过环,对于当前的环,环上每个点i的权值为d[i],将题目转化为求一个环上两点的最大代价d[i]+d[j]+dis(i,j),也就是《环路运输》这道题(详见《动态规划5.1.破环成链转化为线性dp》中破环成链转化为线性dp)。
//注意本题中边权全为1
int d[N]; //树形dp。d[u]:u到叶子节点最远的距离
int q1[N],q2[N],qidx; //《环路运输》
void dfs(int u)
{
//树形dp求树的直径
int d1=0,d2=0;
for(int i=h2[u];i!=0;i=ne[i])
{
int v=e[i];
dfs(v);
if(d[v]+w[i]>d1) d2=d1,d1=d[v]+w[i];
else if(d[v]+w[i]>d2) d2=d[v]+w[i];
}
d[u]=d1;
if(u<=n) ans=max(ans,d1+d2); //树形dp求树的直径
else //《环路运输》
{
qidx=0;
q1[++qidx]=-INF; //因为环的“头”在u<=n的情况会统计到,因此这里不统计“头”也可以
for(int i=h2[u];i!=0;i=ne[i]) q1[++qidx]=d[e[i]];
for(int i=1;i<=qidx;i++) q1[i+qidx]=q1[i];
//注意本题中边权全为1
int hh=1,tt=0;
for(int i=1;i<=qidx*2;i++)
{
while(hh<=tt && i-q2[hh]>qidx/2) hh++;
if(hh<=tt) ans=max(ans,q1[i]+i+q1[q2[hh]]-q2[hh]);
while(hh<=tt && q1[i]-i>=q1[q2[tt]]-q2[tt]) tt--;
q2[++tt]=i;
}
}
return ;
}
//圆方树tarjan(1,-1)之后:
dfs(1);
printf("%lld\n",ans);
12.树的其他问题
《数据结构‧字符串和图8.树上问题》
《动态规划3.树形dp》
LCA
有向无环图
有向无环图(Directed Acyclic Graph,DAG):若一个有向图不含环,则称它是一个有向无环图。
.拓扑排序
有向无环图的解决方法。
有向无环图的拓扑序可以直接递推/dp。
至于拓扑序是从前往后递推还是从后往前递推,取决于状态转移和拓扑序(拓扑关系是从前面指向后面)的关系。
.1.有向无环图的拓扑排序
参见《图论3.1.有向无环图的拓扑序》。
若要求字典序最大/最小的拓扑排序,将队列替换成优先队列即可。
.2.边权恒正的最长路/边权恒负的最短路的差分约束
以边权恒正的最长路为例:
因为拓扑排序判断无解的条件是有环,所以要求边权必须恒正。
- 跑一遍拓扑排序,求拓扑序。
- 若拓扑排序过程中存在环(即拓扑序列的长度小于总点数),则一定存在正环(这也就是为什么拓扑排序求最长路的差分约束要求边权必须恒正)。
- 既可以在拓扑排序的过程中直接递推/dp,也可以在得到了有向无环图的拓扑序后在拓扑序上递推/dp,下面求距离是从前往后递推。
在拓扑排序的过程中直接递推/dp
bool topsort()
{
// dis[0]=0;//绝对值
q[++tt]=0;
while(hh<=tt)
{
int u=q[hh++];
for(int i=h[u];i!=0;i=ne[i])
{
int v=e[i];
dis[v]=max(dis[v],dis[u]+w[i]); //在拓扑排序的过程中直接递推/dp
deg[v]--;
if(deg[v]==0) q[++tt]=v;
}
}
return tt==n+1;
}
for(int i=1;i<=n;i++)
{
add(0,i,100);
deg[i]++;
}
for(int i=1;i<=m;i++)
{
int a,b;
scanf("%d%d",&a,&b);
add(b,a,1);
deg[a]++;
}
if(!topsort())
{
puts("Poor Xed");
return 0;
}
for(int i=0;i<=n;i++) ans+=dis[i];
在得到了有向无环图的拓扑序后在拓扑序上递推/dp
int q[N],hh=1,tt=0; //手写队列储存拓扑序
bool topsort()
{
q[++tt]=0;
while(hh<=tt)
{
int u=q[hh++];
for(int i=h[u];i!=0;i=ne[i])
{
int v=e[i];
deg[v]--;
if(deg[v]==0) q[++tt]=v;
}
}
return tt==n+1;
}
if(!topsort())
{
puts("Poor Xed");
return 0;
}
//dis[0]=0;//绝对值
for(int i=1;i<=tt;i++) //在得到了有向无环图的拓扑序后在拓扑序上递推/dp
{
int u=q[i];
for(int j=h[u];j!=0;j=ne[j])
{
int v=e[j];
dis[v]=max(dis[v],dis[u]+w[j]);
}
}
for(int i=1;i<=n;i++) ans+=dis[i];
.3.有向无环图上的dp
.3.1.拓扑排序
动态规划的状态转移就是对应着图上的一个序列,而如果转移得到的图是有向无环图的话,便可以求出拓扑序直接用dp的集合思想解决。
至于拓扑序是从前往后递推还是从后往前递推,取决于状态转移和拓扑序(拓扑关系是从前面指向后面)的关系。
.3.2.记忆化搜索
一个节点可能会被2个以上的父节点访问,此时多次向下递归就会浪费时间,我们采用记忆化搜索。
void dp(int u)
{
if(f[u]!=-1) return ;
f[u]=0;//注意这里要赋值为0,否则下面的运算结果就会被减1
for(int i=hc[u];i!=0;i=nc[i])
{
int v=ec[i];
dp(v);
f[u]=max(f[u],f[v]+wc[i]);
}
f[u]+=val[u];
return ;
}
连通图
.Tarjan算法
.1.无向图与双连通分量
缩完点成一棵树。
边双\(e-Dcc\)
删边不连通
缩完点成一棵树求最长链
点双\(v-Dcc\)
删点不连通
若较难判断题目是\(e-Dcc\)还是\(v-Dcc\),把“8”字图代入题意,若整张图属于1个连通块,则是\(e-Dcc\);若整张图属于2个连通块,则是\(v-Dcc\)。
-
图片
![]()
![]()
.1.1.边双连通分量
判定割边(桥)
-
图片
![]()
int dfn[N],low[N],num;
bool bridge[M*2];
void tarjan(int u,int in_edge)
{
dfn[u]=low[u]=++num;
for(int i=h[u];i!=0;i=ne[i])
{
int v=e[i];
if(!dfn[v])
{
tarjan(v,i);
low[u]=min(low[u],low[v]);
if(dfn[u]<low[v]/*注意是搜索树上的子节点而不是原图!!!*/) bridge[i]=bridge[i^1]/*不要忘记*/=true;
}
else if(i!=(in_edge^1)) low[u]=min(low[u],dfn[v]);
}
return ;
}
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(!dfn[i]) tarjan(i,-1);
for(int i=2;i<=idx;i+=2) if(bridge[i]) printf("%d %d\n",e[i^1],e[i]);
属于
-
图片
![]()
int belong[N],dcc;
//注意下面的写法
void dfs(int u)
{
belong[u]=dcc;
for(int i=h[u];i!=0;i=ne[i])
{
int v=e[i];
if(belong[v]==0 && !bridge[i]) dfs(v);//注意判定条件
}
return ;
}
for(int i=1;i<=n;i++)
if(belong[i]==0)
{
++dcc;
dfs(i);
}
缩点建边
-
图片
![]()
int hc[N],ec[M],nc[M],cidx=1;
void add_c(int u,int v)
{
ec[++cidx]=v;
nc[cidx]=hc[u];
hc[u]=cidx;
return ;
}
for(int i=2;i<=idx;i+=2)//注意循环的是边的编号,这样可以知道边的起点——反向边终点e[i^1],根据原边建边
{
int u=e[i],v=e[i^1];
if(belong[u]==belong[v]) continue;
add_c(belong[u],belong[v]),add_c(belong[v],belong[u]);//注意把反向边加进去
}
.1.2.点双连通分量
由于割点单独作为一个节点,注意点数开2倍!
判定割点+属于
-
图片
![]()
![]()
![]()
一个割点可能属于多个v-Dcc,一个v-Dcc可能包含多个割点。
“属于”部分加上下面注释的代码。
int root;
int dfn[N],low[N],num;
bool cut[N];
/*属于:
int dcc;
vector<int> vdcc[N];
stack<int> st;
*/
void tarjan(int u)
{
int flag=0;
dfn[u]=low[u]=++num;
/*属于:
if(u==root && h[u]==0) //孤立点
{
vdcc[++dcc].push_back(u);
return ;
}
st.push(u);
*/
for(int i=h[u];i!=0;i=ne[i])
{
int v=e[i];
if(!dfn[v])
{
tarjan(v);
low[u]=min(low[u],low[v]);
if(dfn[u]<=low[v]/*注意是搜索树上的子节点而不是原图!!!*/) //这里还包含了一个隐含条件:在搜索树上u是v的父节点
{
flag++;
if(u!=root || flag>=2) cut[u]=true;
//注意,不满足上面的条件,而是点u满足(dfn[u]<=low[v] && u==root && flag==1)时也要执行下面的代码,因为dfn[u]<=low[v]的现实意义是点v与“点u及其上面”的图“割”开了,所以仍然是一个双连通分量,只不过割点不是点u而是该双连通分量的其他节点
/*属于:
dcc++;
int z;
do
{
z=st.top();
st.pop();
vdcc[dcc].push_back(z);
}while(z!=v);//注意这里是不等于v
vdcc[dcc].push_back(u);
*/
}
}
else low[u]=min(low[u],dfn[v]);
}
return ;
}
scanf("%d%d",&n,&m);
for(int i=1;i<=m;i++)
{
int u,v;
scanf("%d%d",&u,&v);
if(u==v) continue;
add(u,v),add(v,u);
}
for(int i=1;i<=n;i++)
if(!dfn[i])
{
root=i; //注意森林
tarjan(i);
}
for(int i=1;i<=n;i++) if(cut[i]) printf("%d ",i);
缩点建边
-
图片
![]()
int hc[N],ec[M],nc[M],cidx=1;
int vbel[N],cut_id[N];
int newid=dcc;
for(int i=1;i<=n;i++) if(cut[i]) cut_id[i]=++newid;
for(int i=1;i<=dcc;i++)
for(int j=0;j<vdcc[i].size();j++)
{
int x=vdcc[i][j];
if(cut[x])//注意是割点向vdcc连边,与原图的边没有关系
{
add_c(i,cut_id[x]);
add_c(cut_id[x],i);
}
else vbel[x]=i;
/*处理每条边属于哪个v-Dcc
for(int j=0;j<vdcc[i].size();j++)
{
int x=vdcc[i][j];
for(int k=h[x];k!=0;k=ne[k]) if(vbel[e[k]]==i/*防止割点在其他vdcc的边放入本vdcc*/) ebel[k>>1]=i; //k>>1:因为一开始建图时建了双向边e1、e2,所以后面询问时输入的边的编号e有:e==e1>>1==e2>>1
}
*/
}
.1.3.无向图的必经边
\(e-Dcc\)判定、属于、缩点+\(Lca\)求树上距离。
缩点图的每条边都是原图的“必经边”,故缩点后只需要求树上距离即可。
int n,m,q;
int h[N],e[M*2],ne[M*2],idx=1;
int dfn[N],low[N],num;
bool bridge[M*2];
int belong[N],dcc;
int hc[N],ec[M*2],nc[M*2],cidx=1;
int depth[N],fa[N][20];
int main()
{
scanf("%d%d",&n,&m);
for(int i=1;i<=m;i++)
{
int x,y;
scanf("%d%d",&x,&y);
add(x,y),add(y,x);
}
for(int i=1;i<=n;i++) if(dfn[i]==0) tarjan(i,-1);
for(int i=1;i<=n;i++)
if(belong[i]==0)
{
dcc++;
dfs(i);
}
for(int i=2;i<=idx;i++)
{
int u=e[i],v=e[i^1];
if(belong[u]==belong[v]) continue;
add_c(belong[u],belong[v]);
}
depth[belong[1]]=1;
init_lca(belong[1],-1);
scanf("%d",&q);
while(q--)
{
int a,b,p,res;
scanf("%d%d",&a,&b);
p=lca(belong[a],belong[b]);
res=depth[belong[a]]+depth[belong[b]]-depth[p]*2;
printf("%d\n",res);
}
return 0;
}
.1.4.无向图的必经点
\(v-Dcc\)判定、属于、缩点+\(Lca\)求树上距离(以点为权值,树上差分)
注意
- 原图边和\(v-Dcc\)缩点边没有关系!原图的所有边都属于各自的\(v-Dcc\),而\(v-Dcc\)是缩点图上的节点,\(v-Dcc\)缩点边是割点向\(v-Dcc\)连边。故询问的边\(\Leftrightarrow\)询问某一个\(v-Dcc\)——缩点图上的节点;
- 注意在树上距离以点为权值的问题中:\(ans=f[x]+f[y]-f[lca(x,y)]-f[fa[lca(x,y)][0]]\),而不是减\(2\)倍的\(f[lca(x,y)]\),否则会多减\(p\)这一个节点
int n,m,q;
int h[N],e[M],ne[M],idx=1;
int root;
int dfn[N],low[N],num;
bool cut[N];
int dcc;
int st[N],top;
vector<int> vdcc[N];
int hc[N],ec[M],nc[M],cidx=1;
int vbel[N],ebel[M],cut_id[N]; //vbel:原点属于哪个v-Dcc;ebel:原边属于哪个v-Dcc;cut_id:原割点在缩点图上的编号
int depth[N],fa[N][20],f[N]; //f[i]:树上差分,缩点图上从根节点到i有几个割点
void init_lca(int u,int father)
{
if(u>dcc/*当u是割点时*/) f[u]++;
for(int i=hc[u];i!=0;i=nc[i])
{
int v=ec[i];
if(v==father) continue;
depth[v]=depth[u]+1;
fa[v][0]=u;
f[v]=f[u]; //注意f是缩点图上从根节点到i有几个割点
for(int k=1;k<=17;k++) fa[v][k]=fa[fa[v][k-1]][k-1];
init_lca(v,u);
}
return ;
}
int main()
{
while(scanf("%d%d",&n,&m),n || m)
{
init();
for(int i=1;i<=m;i++)
{
int x,y;
scanf("%d%d",&x,&y);
if(x==y) continue;
add(x,y),add(y,x);
}
for(int i=1;i<=n;i++)
if(dfn[i]==0)
{
root=i;
top=0;
tarjan(i);
}
int newid=dcc;
for(int i=1;i<=n;i++) if(cut[i]) cut_id[i]=++newid;
for(int i=1;i<=dcc;i++)
{
for(int j=0;j<vdcc[i].size();j++)
{
int x=vdcc[i][j];
if(cut[x])
{
add_c(i,cut_id[x]);
add_c(cut_id[x],i);
}
vbel[x]=i; //在本题中,为了下面的代码方便,割点属于当前的v-Dcc
}
//预处理每条边属于哪个v-Dcc
for(int j=0;j<vdcc[i].size();j++)
{
int x=vdcc[i][j];
for(int k=h[x];k!=0;k=ne[k]) if(vbel[e[k]]==i) ebel[k>>1]=i; //k>>1:因为一开始建图时建了双向边e1、e2,所以后面询问时输入的边的编号e有:e==e1>>1==e2>>1
}
}
for(int i=1;i<=newid;i++)
if(depth[i]==0)
{
depth[i]=1;
init_lca(i,-1);
}
scanf("%d",&q);
while(q--)
{
int s,t,p;
scanf("%d%d",&s,&t);
s=ebel[s],t=ebel[t];
p=lca(s,t);
printf("%d\n",f[s]+f[t]-f[p]-f[fa[p][0]]); //注意在树上距离以点为权值的问题中:不是减2倍的f[p],否则会多减p这一个节点
}
}
return 0;
}
.2.有向图与强连通分量
缩完点成为1个有向无环图。
模板题:AcWing 368. 银河
-
图片
![]()
![]()
判定+属于
-
图片
![]()
![]()
int dfn[N],low[N],num;
int belong[N];
int st[N],top;
bool in_st[N];
int sc;
vector<int> scc[N];
void tarjan(int u)
{
dfn[u]=low[u]=++num;
st[++top]=u,in_st[u]=true;
for(int i=h[u];i!=0;i=ne[i])
{
int v=e[i];
if(dfn[v]==0)
{
tarjan(v);
low[u]=min(low[u],low[v]);
}
else if(in_st[v]==true) low[u]=min(low[u],dfn[v]);
}
if(dfn[u]==low[u]) //!!!要在这里判断!!!
{
sc++;
int z;
do{
z=st[top--];
in_st[z]=false;
scc[sc].push_back(z);
belong[z]=sc;
}while(z!=u);//注意这里是不等于u
}
return ;
}
scanf("%d%d",&n,&m);
for(int i=1;i<=m;i++)
{
int u,v;
scanf("%d%d",&u,&v);
add(u,v);
}
for(int i=1;i<=n;i++) if(!dfn[i]) tarjan(i);
缩点建边
int hc[N],ec[M],wc[M],nc[M],cidx=1;
void add_c(int u,int v,int wor)
{
ec[++cidx]=v;
wc[cidx]=wor;
nc[cidx]=hc[u];
hc[u]=cidx;
return ;
}
for(int u=1;u<=n;u++)//注意循环的是点的编号,根据原边建边
for(int i=h[u];i!=0;i=ne[i])
{
int v=e[i];
if(belong[u]==belong[v]) continue;
add_c(belong[u],belong[v]);
}
.2.1.有向图的必经边、必经点
.2.1.1.有向无环图的必经边、必经点
- 在原图中按照拓扑序进行动态规划,求出起点S到图中每个点x的路径条数fs[x];
- 在反图中按照拓扑序进行动态规划,求出图中每个点x到终点T的路径条数ft[x]。
显然,fs[T]:从S到T的路径总条数。根据乘法原理:
- 对于一条有向边(x,y),若fs[x]*ft[y]=fs[T],则(x,y)是从S到T的必经边。
- 对于一个点x,若fs[x]*ft[x]=fs[T],则x是从S到T的必经点。
fs/ft的数值可能会很大,有时需要取模。
当需要避免冲突时,可以采用选取不常见的模数、选取双模数等方法。
.2.1.2.有向图的必经边、必经点
.2.2.边权非负的最长路/边权非正的最短路的差分约束
模板题:AcWing 368. 银河
以边权非负的最长路的差分约束为例:
因为Tarjan判断无解(存在正环)的条件是有两个点在同一个强连通分量(同一个环)且连接他们的边权为正,所以要求边权必须非负。
- 跑一遍tarjan缩点建边。
- 若建边过程中有两个点在同一个强连通分量(同一个环):1.连接他们的边权为正,一定存在正环无解(这也就是为什么Tarjan求最长路的差分约束边权必须非负)2.否则,这个环内的所有边权都是0,我们可以把他们看作一个点,因此缩点时要统计每个强连通分量的大小。
if(dfn[u]==low[u])
{
sc++;
int z;
do{
z=st[top--];
in_st[z]=false;
scc[sc].push_back(z);
sizes[sc]++;//统计强连通分量的大小
belong[z]=sc;
}while(z!=u);
}
- 然后我们就得到了一张有向无环图。强连通分量的编号顺序的倒序就是拓扑序。有向无环图的拓扑序可以直接递推/dp,下面求距离是从前往后递推。
//绝对值
for(int i=1;i<=n;i++) add(0,i,1);
dis[0]=0;
tarjan(0);
for(int u=0;u<=n;u++)
for(int i=h[u];i!=0;i=ne[i])
{
int v=e[i];
if(belong[u]==belong[v])
{
if(w[i]>0)
{
puts("-1");
return 0;
}
}
else add_c(belong[u],belong[v],w[i]); //上面的return 0不一定成立,所以不要省略else
}
for(int u=sc;u>=1;u--) //强连通分量的编号顺序的倒序就是拓扑序
for(int i=hc[u];i!=0;i=nc[i])
{
int v=ec[i];
dis[v]=max(dis[v],dis[u]+wc[i]);
}
for(int i=1;i<=sc;i++) ans+=(LL)sizes[i]*dis[i];
printf("%lld\n",ans);
.欧拉图.
定义
欧拉路径:如果图G中的一个路径包括每个边恰好一次,则该路径称为欧拉路径。俗称一笔画问题。
欧拉回路:如果一个回路是欧拉路径,则称为欧拉回路。
结论
- 对于所有边都是连通的无向图
- 存在欧拉路径的充分必要条件是:度数为奇数的点只能有\(0\)或\(2\)个。
- 存在欧拉回路的充分必要条件是:不存在度数为奇数的点。
- 对于所有边都是连通的有向图
- 存在欧拉路径的充分必要条件是:要么所有点的出度都等于入度,要么除了两个点以外,所有点的出度都等于入度,在另外两个点中,一个点的出度比入度多\(1\)(起点),另一个点的入度比出度多\(1\)(终点)。
- 存在欧拉回路的充分必要条件是:所有点的出度都等于入度。
注意判断边(不是点)是否连通
for(int i=1;i<=n;i++){
if(h[i]!=-1){
dfs(i);
break;
}
}
求欧拉路径和欧拉回路
注意:欧拉路径/欧拉回路强调的是不重不漏遍历所有的边,而不是点。因此不考虑孤立点。
下面的代码:op:1:欧拉路径;2:欧拉回路。type:1:无向图;2:有向图。输出边的欧拉序列(反向边输出相反数)和点的欧拉序列。
在回溯后记录边:把走到“死胡同”的边或者走到终点的边先记录,这些边一定是最后输出(倒序输出)的。
int op,type; //op:1:欧拉路径;2:欧拉回路;type:1:无向图;2:有向图
int n,m,start; //start:欧拉路径的起点
int h[N],e[M],ne[M],idx=1;
bool used[M]; //目的:标记这条边已访问,无向图还会标记反向边
int eseq[M],eidx; //边的欧拉序列
int vseq[N],vidx; //点的欧拉序列
int din[N],dout[N];
void add(int u,int v)
{
e[++idx]=v;
ne[idx]=h[u];
h[u]=idx;
return ;
}
bool check()
{
if(type==1)
{
int cnt=0;
for(int i=1;i<=n;i++) if((din[i]+dout[i])&1) cnt++,start=i;
if(op==1 && cnt!=0 && cnt!=2) return false;
if(op==2 && cnt!=0) return false;
}
else
{
int cnt=0;
for(int i=1;i<=n;i++)
if(din[i]!=dout[i])
{
if(abs(din[i]-dout[i])==1)
{
cnt++;
if(dout[i]-din[i]==1) start=i;
}
else return false;
}
if(op==1 && cnt!=0 && cnt!=2) return false;
if(op==2 && cnt!=0) return false;
}
}
void dfs(int u)
{
//注意不可以写成for(int i=h[u];i!=0;i=ne[i],h[u]=ne[h[u]]),否则递归过程中又遇到点u会出现问题
for(int &i=h[u];i!=0;i=ne[i])
{
if(used[i]) continue; //找到一条尚未访问的边
//标记已访问
used[i]=true;
if(type==1) used[i^1]=true;
//此处备份边的编号,因为当h[u]被更改时,i也会被更改
int res;
if(type==1)
{
res=i/2;
if(i&1) res=-res;
}
else res=i-1;
int v=e[i];
dfs(v);
eseq[++eidx]=res; //回溯时记录边
}
vseq[++vidx]=u; //边全部遍历完时记录点
return ;
}
int main()
{
scanf("%d%d",&op,&type);
scanf("%d%d",&n,&m);
for(int i=1;i<=m;i++)
{
int u,v;
scanf("%d%d",&u,&v);
add(u,v);
if(type==1) add(v,u);
din[v]++,dout[u]++;
}
if(!check())
{
puts("NO");
return 0;
}
if(op==1) dfs(start);
else
for(int i=1;i<=n;i++)
if(h[i]!=0)
{
dfs(i);
break;
}
//不连通(不包括孤立点)
if(eidx<m)
{
puts("NO");
return 0;
}
//注意倒序输出。因为边有方向所以输出顺序不可颠倒,且因为回溯时记录所以倒序输出才是正序
puts("YES");
for(int i=eidx;i>=1;i--) printf("%d ",eseq[i]);
puts("");
for(int i=vidx;i>=1;i--) printf("%d ",vseq[i]);
puts("");
return 0;
}
若要求字典序最小,只要从某个点开始搜的时候,从小到大搜每个点即可(vector
.2-SAT
特征:每个节点只有2种可能的取值+存在性问题→2-SAT。
定义
有N个变量(只有两种可能的取值,\(e.g.\)选或不选/要么选A要么选B)和M个条件(对变量的两种取值的限制)。求是否存在对N个变量的合法赋值,使M个条件均得到满足。并输出其中1种方案。
与网络流的区别:网络流要求最优,2-SAT只要求存在且复杂度一般是\(O(N)\)的。
解决方法
-
建立2*N个节点的有向图,每个\(A_i\)对应2个节点:i和i+N,分别表示\(\neg A_i\)和\(A_i\);
-
考虑每个条件,形如“若变量\(A_i\)赋值\(A_{i,p}\),则变量\(A_j\)必须赋值\(A_{j,p}\)”,\(p,q\in \{0,1\}\)。从i+pN到j+qN连一条有向边。
上述条件还蕴含着成对出现的逆否命题,也要从j+(1-q)N到i+(1-p)N连一条有向边。
-
用tarjan算法求出有向图中所有的强连通分量。同一个强连通分量中的点一个成立全部成立。
- 若存在i,满足i和i+N在同一强连通分量中,问题矛盾无解。
- 若不存在任何这样的i,则问题一定有解。同一个\(A_i\),优先赋值拓扑排序编号大的点(因为拓扑排序编号小的点可能会指向拓扑排序编号大的点),又由于belong[]编号相当于反图的拓扑序,故优先belong[]小的点。
常见隐藏条件
核心:将条件转化为“若变量\(A_i\)赋值\(A_{i,p}\),则变量\(A_j\)必须赋值\(A_{j,p}\)”。
- \(a\lor b\)\(\Rightarrow\)建边\(\neg a → b\) + \(\neg b → a\):若a假,则b一定真;若b假,则a一定真;
- \(a推出 b\)\(\Rightarrow\)$a→b \(+\) \neg b → \neg a$
- a=1\(\Rightarrow\)\(\neg a → a\):若能走到\(\neg a\),就让其自相矛盾;
- a=0\(\Rightarrow\)\(a → \neg a\):类似上面;
- \(a \operatorname{and} b=0\)\(\Rightarrow\)$a → \neg b $+ \(b → \neg a\)
- \(a \operatorname{and} b=1\)\(\Rightarrow\)$\neg a → a $+ \(\neg b → b\):相当于a=1,b=1;
- \(a \operatorname{or} b=0\)\(\Rightarrow\)$a → \neg a $+ \(b → \neg b\):相当于a=0,b=0;
- \(a \operatorname{or} b=1\)\(\Rightarrow\)$\neg a → b $+ \(\neg b → a\)
- \(a \operatorname{xor} b=0\)\(\Rightarrow\)$a → b \(+\)\neg a→\neg b\(+\)b→a\(+\)\neg b→\neg a$
- \(a \operatorname{xor} b=1\)\(\Rightarrow\)$a →\neg b \(+\)\neg a→ b\(+\)b→\neg a\(+\)\neg b→ a$
- 若选了\(a_0\)必不选\(b_0\)\(\Rightarrow\)\(a_0 → b_1\) + \(b_0 → a_1\)
- 点集中至多选一个点\(\Rightarrow\)\(a\rightarrow \neg b,a\rightarrow \neg c,\cdots\)+前后缀优化建图(\(\neg a,\neg b,\neg c,\cdots,a,b,c,\cdots,\neg a,\neg b,\neg c,\cdots\))
//i:0/假;i+n:1/真
scanf("%d%d",&n,&m);
while(m--)
{
int ;
scanf("");
add(,);
//根据条件建有向边
}
for(int i=1;i<=2*n;i++) if(!dfn[i]) tarjan(i);
for(int i=1;i<=n;i++)
if(belong[i]==belong[i+n])
{
puts("IMPOSSIBLE");
return 0;
}
puts("POSSIBLE");
for(int i=1;i<=n;i++)
if(belong[i]<belong[i+n]) printf("0 ");
else printf("1 ");
注意:2-SAT的tarjan只需要知道每个点属于哪个强连通分量,不需要知道每个强连通分量包含哪些点:
do
{
y=st[top--];
in_st[y]=false;
belong[y]=sc;
//不需要scc[sc].push_back(y);
}while(u!=y);
环
.判断环的存在性
.1.有向图
- dfs
bool cir;//是否存在环
bool vis[N],in_st[N];//vis[u]:节点u是否遍历过;in_st[u]:节点u是否在当前递归栈中
void dfs(int u)
{
vis[u]=in_st[u]=true;
for(int i=h[u];i;i=ne[i])
{
int v=e[i];
if(!vis[v]) dfs(v);
else if(in_st[v]) cir=true;
}
in_st[u]=false;
return ;
}
for(int i=1;i<=n;i++)
if(!vis[i])
dfs(i);
- tarjan
bool cir;//是否存在环
int dfn[N],low[N],num;
int st[N],top;
bool in[N];
void tarjan(int u)
{
dfn[u]=low[u]=++num;
st[++top]=u,in[u]=true;
for(int i=h[u];i;i=ne[i])
{
int v=e[i];
if(dfn[v]==0)
{
tarjan(v);
low[u]=min(low[u],low[v]);
}
else if(in[v]) low[u]=min(low[u],dfn[v]);
}
if(dfn[u]==low[u])
{
int z,siz=0;
do
{
z=st[top--];
in[z]=false;
siz++;
}while(z!=u);
if(siz>1) cir=true;
}
return ;
}
for(int i=1;i<=n;i++)
if(!dfn[i])
tarjan(i);
-
拓扑序列
拓扑序列的长度小于总点数。
.2.无向图
-
并查集
遍历每一条边(u,v):若u,v属于不同的集合,则合并u,v所属的两个集合;否则,存在环。
-
dfs
int idx=1;
bool cir;//是否存在环
bool vis[N];
void dfs(int u,int from)
{
vis[u]=true;
for(int i=h[u];i;i=ne[i])
{
if(i==(from^1)) continue;
int v=e[i];
if(!vis[v]) dfs(v,i);
else cir=true;
}
return ;
}
for(int i=1;i<=n;i++)
if(!vis[i])
dfs(i,0);
.环的计数
.1.无向图三元环计数
考虑给所有的无向边定向:如果一条边两个端点的度数不一样,则令度数较小的点→度数较大的点;否则,令编号较小的点→编号较大的点。
显然,新图是有向无环图,且原图中的三元环一定一一对应着新图中的所有形如u→v,u→w,v→w的子图。现在只需要枚举u的出边u→v,再枚举v的出边v→w,然后检查w是否满足u→w即可。
时间复杂度:\(O(M\sqrt M)\)。证明。
二分图
.二分图
一张无向图的N个节点(N≥2)可以分成A、B两个非空集合,其中\(A\cap B=\varnothing\),并且同一集合内的点之间都没有边相连,那么称这张无向图为二分图,A、B分别成为二分图的左部和右部。
最大匹配数=最小点覆盖=总点数-最大独立集=总点数-最小路径覆盖。
.1.二分图的判定和建立
.1.1.染色判断法
核心:一个图是二分图\(\Leftrightarrow\)图中不存在奇数环\(\Leftrightarrow\)染色法无矛盾
int n,m;
vector<int> edge[N];
int color[N];
bool flag=true;
int h[N],e[M],ne[M],idx;
bool dfs(int u,int c)
{
color[u]=c;
for(auto it : edge[u])
{
if(color[it]==c) return false;
if(color[it]==0 && !dfs(it,3-c)) return false;
}
return true;
}
for(int i=1;i<=m;i++)
{
int u,v;
scanf("%d%d",&u,&v);
edge[u].push_back(v),edge[v].push_back(u);
}
for(int i=1;i<=n;i++)
{
if(color[i]==0) flag=dfs(i,1);
if(flag==false){
puts("No");
return 0;
}
}
puts("Yes");
//二分图的建立
for(int i=1;i<=n;i++)
if(color[i]==1)
for(auto it : edge[i])
add(i,it);
.1.2.扩展域并查集判断法
p[u]:点u所在的集合;p[u+N]:点u对立的集合。
对于每一条边(u,v):先判断find(u)==find(v)是否成立。若成立则直接返回原图不是二分图;否则令p[find(u)]=find(v+N),p[find(v)]=find(u+N);,继续下一条边。若每一条边的过程中find(u)==find(v)均不成立,则返回原图是二分图。
for(int i=1;i<=m;i++)
{
int u,v;
scanf("%d%d",&u,&v);
if(find(u)==find(v))
{
puts("No");
return 0;
}
p[find(u)]=find(v+n);
p[find(v)]=find(u+n);
}
puts("Yes");
.2.二分图的匹配
.2.1.二分图的最大匹配
核心:一个匹配是最大匹配\(\Leftrightarrow\)图中不存在增广路径
.2.1.1.匈牙利算法(增广路算法)\(O(NM)\)
实际运行时间远小于\(O(NM)\)。
\(vis[i]\):右部节点有没有被重复访问。
- 先把二分图分为左右节点;
- 再向右部节点连边。
int n1,n2,m,tot;
int e[M],ne[M],h[N],ma[M],idx;
bool vis[N]; //vis[i]:右部节点有没有被重复访问
bool find(int x){
//开始向右部节点连边
for(int i=h[x];i!=0;i=ne[i]){
int j=e[i];
if(!vis[j]){
vis[j]=1;
if(ma[j]==0||find(ma[j])){
ma[j]=x;
return true;
}
}
}
return false;
}
//注意现在还没有连边,只是把二分图分为左右节点
for(int i=1;i<=n1;i++){
memset(vis,false,sizeof vis);
if(find(i)) tot++;
}
cout<<tot<<endl;
.2.1.2.最大流
实际上匈牙利算法属于最大流。
参见《图论.2.2.1.二分图最大匹配模型》。
.2.1.3.建模应用
- “0要素”:节点能分成独立的两个集合,每个集合内部有0条边;
- “1要素”:每个节点只能与1条匹配边相连。
- 例题1:棋盘覆盖
- 1:每个格子只能被1张骨牌覆盖
- 0:两个\(x+y\)是奇数的格子(或\(x+y\)是偶数的格子)不能被同一张骨牌覆盖
左部节点 -> 右部节点
x+y是奇数的格子 骨牌 x+y是偶数的格子
骨牌最大数->求最大匹配?
const int N=105;
int gox[]={0,0,1,-1};
int goy[]={1,-1,0,0};
int n,t,ans;
PII match[N][N];
bool mapp[N][N],vis[N][N]; ////vis[i]:右部节点有没有被重复访问
bool dfs(PII t){
//开始向右部节点连边
for(int i=0;i<4;i++){
int x=t.first+gox[i],y=t.second+goy[i];
if(x<1 || x>n || y<1 || y>n || mapp[x][y] || vis[x][y]) continue;
vis[x][y]=true;
if(match[x][y].first==0 || dfs(match[x][y])){
match[x][y]=t;
return true;
}
}
return false;
}
int main(){
scanf("%d%d",&n,&t);
while(t--){
int x,y;
scanf("%d%d",&x,&y);
mapp[x][y]=true;
}
//注意现在还没有连边,只是把二分图分为左右节点
for(int i=1;i<=n;i++)
for(int j=1;j<=n;j++){
if(mapp[i][j] || (i+j)%2==0) continue;
memset(vis,0,sizeof vis);
if(dfs({i,j})) ans++;
}
printf("%d\n",ans);
return 0;
}
- 例题2:車的放置
- 1:每行每列只能放1个车;
- 0:同一个车不能既在第\(i_1\)行又在第\(i_2\)行 <-> 两行放不了同一个车(列也同理)。
左部节点 -> 右部节点
行节点 车 列节点
车最大数->求最大匹配?
int n,m,t,ans;
int match[N];
bool mapp[N][N],vis[N]; //vis[i]:右部节点有没有被重复访问
bool dfs(int x){
//开始向右部节点连边
for(int i=1;i<=m;i++){
if(vis[i] || mapp[x][i]) continue;
vis[i]=true;
if(match[i]==0 || dfs(match[i])){
match[i]=x;
return true;
}
}
return false;
}
int main(){
scanf("%d%d%d",&n,&m,&t);
while(t--){
int x,y;
scanf("%d%d",&x,&y);
mapp[x][y]=true;
}
//注意现在还没有连边,只是把二分图分为左右节点
for(int i=1;i<=n;i++){
memset(vis,false,sizeof vis);
if(dfs(i)) ans++;
}
printf("%d\n",ans);
return 0;
}
.2.2.二分图的多重匹配
参见《图论.2.2.2.二分图多重匹配模型》。
.2.3.二分图的带权匹配(最优匹配)
参见《图论.2.2.2.二分图多重匹配模型》。
.2.4.二分图的完备匹配
图的完美匹配
若图G的一个匹配M覆盖了G的所有点,则称M是G的完美匹配。
显然一个图存在完美匹配的必要条件是图的点数为偶数,一个二分图存在完美匹配的另一个必要条件是两个点集相等。
二分图的完备匹配
设二分图\(G=<V_1, V_2, E>\),且\(|V_1| \leq |V_2|\)。对于G的一个匹配M,若\(V_1\)中的所有点都是匹配点,则称M是G的完备匹配(有时也称作\(V_1\)到\(V_2\)的完备(完美)匹配、\(V_1-\)完美匹配、G的完美匹配)。
显然完美匹配是一种特殊的完备匹配,完备匹配是一种特殊的最大匹配。
霍尔定理
设二分图\(G=<V_1, V_2, E>, |V_1| \leq |V_2|\),则G中存在\(V_1\)到\(V_2\)的完备匹配\(\Leftrightarrow\)对于任意的\(S \subset V_1\),均有\(|S|\leq|N(S)|\Leftrightarrow \max\{|S|-|N(S)|\}\le 0\),其中\(N(S)=\bigcup\limits_{v_1 \in S,(v_1,v_2)\in E}v_2\),是S的邻域。
一般霍尔定理判定二分图完备匹配会考在具有特殊性质的二分图,此时霍尔定理判定的复杂度较低。
-
简要题意:给定一张左部n个点、右部m个点的二分图:左部的第i个点会向右部的前\(a_i\)个点连边。再给定一个操作序列\(\{op_i=\{x_i,y_i\}\}\),执行操作\(op_i\)相当于给左部的第\(x_i\)个点和右部的第\(y_i\)个点各增加1个可匹配次数。然后有q次询问,每次询问给定l,r,p,执行操作\(op_{l\sim r}\)后,是否存在一个极大的匹配M,满足M为右部的第1m-p个点到左部点的完美匹配,但不为右部的第m-p-1m个点到左部点的完美匹配。
-
M为右部的第1m-p个点到左部点的完美匹配:霍尔定理。该二分图具有特殊性质:左部的点向右部的前缀连边。因此对于右部的点集$S_1,S_2$,若$S_1$中的最小编号=$S_2$中的最小编号,则$N(S_1)=N(S_2)$。又因为霍尔定理只关心最大值$\max{|S|-|N(S)|}$,所以右部的第1m-p个点是否存在完美匹配,霍尔定理只需要关心m-p个点集\(S_i=\{v_{i\sim m-p}\},1\le i\le m-p\)的\(\max\limits_{1≤i≤m-p}\{|S_i|-|N(S_i)|\}\)是否≤0。而不是所有的\(2^{m-p}-1\)个点集\(S\subset V=\{v_{1\sim m-p}\}\),这样就降低了霍尔定理判定的复杂度。
-
M不为右部的第m-p-1~m个点到左部点的完美匹配:注意这里是“存在不”,而不是“不存在”,所以该条件不能用霍尔定理。设当前询问下,左部可匹配次数≥1的\(a_i\)最小的点为x,右部权值≥1的编号最大的点为y。则原条件成立等价于\(a_x<y\)。
证明:
- 当\(a_x≥y\)时,每个左部点都可以匹配每个右部点,显然任意的极大匹配M都是右部的完美匹配。
- 当\(a_x<y\)时,即使存在1个右部的完美匹配M',设M'中x与y'匹配、x'与y匹配,也可以通过调整:改为x'与y'匹配,此时x无法与y匹配,得到一个满足条件的极大匹配M。
此外还需要特判右部第m-p-1~m个点是否存在可匹配的点。
直接模拟上述的分析过程可以在得到25分。再使用差分的技巧可以做到50分,再使用线段树+莫队的数据结构可以做到100分。由于这里的重点是霍尔定理的运用,因此下面的代码是25分的代码。
-
int n,m,c,q;
int a[N];
PII op[N];
scanf("%d%d%d%d",&n,&m,&c,&q);
for(int i=1;i<=n;i++) scanf("%d",&a[i]);
for(int i=1;i<=c;i++) scanf("%d%d",&op[i].x,&op[i].y);
while(q--)
{
int l,r,p;
scanf("%d%d%d",&l,&r,&p);
//M为右部的第1~m-p个点到左部点的完美匹配:霍尔定理
int maxs=0;
for(int i=1;i<=m-p;i++)
{
int s=0,ns=0;
for(int j=l;j<=r;j++) if(i<=op[j].y && op[j].y<=m-p) s++;
for(int j=l;j<=r;j++) if(a[op[j].x]>=i) ns++;
maxs=max(maxs,s-ns);
}
if(maxs>0)
{
puts("No");
continue;
}
//M不为右部的第m-p-1~m个点到左部点的完美匹配:设当前询问下,左部可匹配次数≥1的a_i最小的点为x,右部权值≥1的编号最大的点为y。则原条件成立等价于a_x<y
int minl=m,maxr=1;
for(int i=l;i<=r;i++)
{
minl=min(minl,a[op[i].x]);
maxr=max(maxr,op[i].y);
}
if(minl>=maxr)
{
puts("No");
continue;
}
//特判右部第m-p-1~m个点是否存在可匹配的点
bool flag=false;
for(int i=l;i<=r;i++)
if(op[i].y>=m-p+1)
{
flag=true;
break;
}
if(!flag)
{
puts("No");
continue;
}
puts("Yes");
}
.2.5.二分图最大匹配的必须边和可行边
完美匹配
设求最大匹配后,新的有向图G'=原图的非匹配边+原图的匹配边的反向边。
(u,v)是二分图最大匹配的必须边\(\Leftrightarrow\)(u,v)是当前二分图的匹配边并且新的有向图G'中u和v属于不同的强连通分量。
(u,v)是二分图最大匹配的可行边\(\Leftrightarrow\)(u,v)是当前二分图的匹配边或者新的有向图G'中u和v属于相同的强连通分量。
最大匹配
设图G'是网络流求解二分图最大匹配后的残留网络。注意包括源点和汇点,不包括流量为0的边。
(u,v)是二分图最大匹配的必须边\(\Leftrightarrow\)(u,v)的流量是1并且在G'中u和v属于不同的强连通分量。
(u,v)是二分图最大匹配的可行边\(\Leftrightarrow\)(u,v)的流量是1或者在G'中u和v属于相同的强连通分量。
.3.二分图的最小点覆盖
定义
给定一张图,从中选出尽量少的点,使得每一条边的两个端点都至少有一个是被选定的。
Konig定理
最小点覆盖的点数=最大匹配数
-
证明
证明:一般证明A=BA=BA=B的思路就是证明A≥B,B≥AA\ge B,B\ge A A≥B,B≥A或者A≥BA\ge BA≥B且\(A 可以等于B\)。这里我们采取后者。
-
如何证明最小点覆盖≥\ge≥最大匹配数?
显然对于任一合法匹配,由于匹配边之间没有公共点,因此我们有多少匹配数就至少需要多少最小点覆盖,对于最大匹配也是如此,得证。
-
如何证明最小点覆盖可以等于最大匹配数?
一般来说这一步的证明都是通过构造完成的。下面给出构造方法:
- 先求一遍最大匹配。
- 从左部的每一个匹配点出发,做一遍增广,标记所有经过的点。
- 可以证明左部所有未被标记的点和右部所有被标记的点构成了我们的最小点覆盖的一种方案,且点数等于最大匹配数。
由于这个证明实在是麻烦,笔者的水平实在有限,大家先记住就行了。感兴趣的同学可以钻研一下这个问题。
-
.3.1.建模应用
“\(**2**\)要素”:每条边有\(2\)个端点,二者至少选择一个。
-
例题1:机器任务
\(2\):每个任务要么在机器\(A\)以模式\(a[i]\)执行,要么在机器\(B\)以模式\(b[i]\)执行,二者必选其一。
忽略0
左部节点 -> 右部节点
机器A 任务 机器B
模式类型(忽略0)最小数->求最小点覆盖?
int n,m,k,ans;
int match[N];
bool g[N][M],vis[N]; //g:邻接矩阵;vis:右部节点有没有被重复访问
bool dfs(int u)
{
//开始向右部节点连边
for(int i=1;i<m;i++) //注意是从1~m-1(0已经一开始执行任务了)
{
if(vis[i] || !g[u][i]) continue;
vis[i]=true;
if(match[i]==0 || dfs(match[i]))
{
match[i]=u;
return true;
}
}
return false;
}
int main()
{
while(scanf("%d",&n),n)
{
ans=0;
memset(g,0,sizeof g);
memset(match,0,sizeof match);
scanf("%d%d",&m,&k);
while(k--)
{
int i,a,b;
scanf("%d%d%d",&i,&a,&b);
if(a==0 || b==0) continue; //是0就可以一开始执行任务,不计入次数
g[a][b]=true;
}
//注意现在还没有连边,只是把二分图分为左右节点
for(int i=1;i<n;i++) //注意是从1~n-1(0已经一开始执行任务了)
{
memset(vis,false,sizeof vis);
if(dfs(i)) ans++;
}
printf("%d\n",ans);
}
return 0;
}
-
例题2:泥泞的区域
\(2\):每块泥地\(*(i,j)\)要么被第\(i\)行的一块木板盖住,要么被第\(j\)行的一块木板盖住,二者必选其一。
因为木板不可以盖住干净地面,所以木板只能放在连续的一行(或列)的\(*\),故我们把连续的一行(或列)的\(*\)视作同一个节点。
左部节点 -> 右部节点
行节点 木板 列节点
木板最小数->求最小点覆盖?
int n,m,ans;
char mapp[N][N];
//新建节点的变量:连续的一行(或列)的*视作同一个节点
int ridx,cidx;
int row[N][N],col[N][N];
//二分图的变量
bool vis[M],g[M][M];
int match[M];
bool dfs(int u)
{
for(int i=1;i<=cidx;i++)
{
if(vis[i] || !g[u][i]) continue;
vis[i]=true;
if(match[i]==0 || dfs(match[i]))
{
match[i]=u;
return true;
}
}
return false;
}
int main()
{
scanf("%d%d",&n,&m);
for(int i=1;i<=n;i++)
for(int j=1;j<=m;j++)
{
cin>>mapp[i][j];
if(mapp[i][j]=='*')
{
if(mapp[i][j-1]=='*') row[i][j]=row[i][j-1]; //连续的*作为同一个节点
else row[i][j]=++ridx; //否则新建一个节点
if(mapp[i-1][j]=='*') col[i][j]=col[i-1][j];
else col[i][j]=++cidx;
g[row[i][j]][col[i][j]]=true;
}
}
for(int i=1;i<=ridx;i++)
{
memset(vis,false,sizeof vis);
if(dfs(i)) ans++;
}
printf("%d\n",ans);
return 0;
}
.4.二分图的最大独立集
定义
给定一张图,从中选出最多的点,使得点之间是没有边的。
(最大团:给定一张图,从中选出最多的点,使得任意两个点之间至少有一条边相连)
(注:最大团是和最大独立集是一个互补的关系)
性质
-
最大匹配数=总点数-最大独立集
-
证明
根据最大独立集的定义,求最大独立集的点数\(\Leftrightarrow\)从二分图中,去掉最少的点能够破坏所有的\(\Leftrightarrow\)去掉二分图中的最小点覆盖\(\Leftrightarrow\)两个集合的总点数-最大匹配数
-
-
无向图G的最大团等于其补图G'的最大独立集。
(注:\(G'=(V,E')\)被称为\(G=(V,E)\)的补图,其中$E'= { (x,y) \notin E } $ 。)用途:补图转化思想能成为解答的突破口!
求一个图\(G\)的最大团\(\Leftrightarrow\)求其补图\(G'\)的最大独立集
对于一般无向图,最大团、最大独立集是\(NPC\)问题。
.4.1.建模应用
- “0要素”:节点能分成独立的两个集合,每个集合内部有0条边;
- “1要素”:每个节点只能与1条匹配边相连;
- 正向求解最大匹配较为困难,需要逆向求解。
- 某些棋盘问题要求不能互相攻击。
- 例题:骑士放置
- 1:每个格子只能放1个骑士;
- 0:\(x+y\)是奇数的格子不一定能攻击到所有偶数格子(所以要先把二分图分为左右节点,再向右部节点连边。),但是一定不能攻击到奇数格子。(\(x+y\)是偶数也同理)
左部节点 -> 右部节点
x+y是奇数的格子 骑士 x+y是偶数且能被左部格子攻击到的格子
骑士个数最大数->求最大独立集?
int gox[]={1,1,-1,-1,2,2,-2,-2};
int goy[]={2,-2,2,-2,1,-1,1,-1};
int n,m,k,res; //res:最大匹配数
PII match[N][N];
bool mapp[N][N],vis[N][N];
bool dfs(PII t)
{
//开始向右部节点连边
for(int i=0;i<8;i++)
{
int x=t.first+gox[i],y=t.second+goy[i];
if(x<1 || x>n || y<1 || y>m || vis[x][y] || mapp[x][y]) continue;
vis[x][y]=true;
if(match[x][y].first==0 || dfs(match[x][y]))
{
match[x][y]=t;
return true;
}
}
return false;
}
int main()
{
scanf("%d%d%d",&n,&m,&k);
for(int i=1;i<=k;i++)
{
int x,y;
scanf("%d%d",&x,&y);
mapp[x][y]=true;
}
//注意现在还没有连边,只是把二分图分为左右节点
for(int i=1;i<=n;i++)
for(int j=1;j<=m;j++)
{
if((i+j)%2==0 || mapp[i][j]) continue;
memset(vis,false,sizeof vis);
if(dfs({i,j})) res++;
}
printf("%d\n",(n*m-k)-res);
return 0;
}
.5.有向无环图的最小路径(点)覆盖
.5.1.有向无环图的最小路径(点)覆盖
定义
给定一个有向无环图,求出用最少的互不相交的路径,将所有的点覆盖住。(其中路径的互不相交定义为点和边都不重复,等价于点不重复。)
性质
最大匹配数=总点数-最小路径覆盖
建图
把点u拆成入点u和出点u+N。若原图有边(u,v),则在二分图上令点u+N指向点v。二分图左部是u+N、右部是u。
.5.2.二分图最小路径重复点覆盖
定义
给定一个有向无环图,求出用最少的路径,将所有的点覆盖住。
解决方法
若\(x \rightarrow y\),\(y \rightarrow z\),则可以直接令\(x \rightarrow z\),这样就可以视作重复走\(y\)这个节点。
-
证明
我们可以考虑对于原图的一条重复路径点覆盖的方案。由于已经做了传递闭包问题,我们可以直接通过其它新建的边跳过那些之前已经经过的点,从而得到新图的一条路径点覆盖方案。同理,从新图也可以用类似的方法展开得到原图,因此两边方案是等价的,我们可以用这种方法得到正解。
具体实现
传递闭包
- 在原图\(G\)上求传递闭包,得到新图\(G'\)。
bool g[N][M]; //g[i][j]=true:从i到j连一条有向边
for(int k=1;k<=n;k++)
for(int i=1;i<=n;i++)
for(int j=1;j<=n;j++)
g[i][j]|=g[i][k]&g[k][j];
- 原图\(G\)的一个最小路径重复点覆盖\(\Leftrightarrow\)求新图\(G'\)的最小路径点覆盖。
建图
把点u拆成入点u和出点u+N。若新图\(G'\)有边(u,v),则在二分图上令点u+N指向点v。二分图左部是u+N、右部是u。
-
例题:捉迷藏
const int N=410; //把点u拆成入点u和出点u+N,注意点数开2倍
int n,m,res; //res:最大匹配数
int match[N];
bool f[N][N],g[N*2][N*2],vis[N]; //f:原图G与传递闭包后的新图G';g:二分图
bool dfs(int u)
{
for(int i=1;i<=n;i++)
{
if(vis[i] || !g[u][i]) continue;
vis[i]=true;
if(match[i]==0 || dfs(match[i]))
{
match[i]=u;
return true;
}
}
return false;
}
scanf("%d%d",&n,&m);
for(int i=1;i<=m;i++)
{
int x,y;
scanf("%d%d",&x,&y);
f[x][y]=true;
}
//传递闭包
for(int k=1;k<=n;k++)
for(int i=1;i<=n;i++)
for(int j=1;j<=n;j++)
f[i][j]|=f[i][k]&f[k][j];
//建立二分图
for(int i=1;i<=n;i++)
for(int j=1;j<=n;j++)
if(i!=j && f[i][j])
g[i+n][j]=true;
for(int i=1+n;i<=n+n;i++)
{
memset(vis,false,sizeof vis);
if(dfs(i)) res++;
}
printf("%d\n",n-res);
平面图
.平面图
定义
平面图:图G能画在平面S上,且除顶点外无边相交(几何意义上的)。
边界:包围每个面的所有边组成的回路称为该面的边界。
-
其他定义
极大平面图:在简单平面图G的任意不相邻顶点间添加一条边,所得图均为非平面图。
无限面与有限面:设G是平面图,由G的边将G所在的平面划分成若干个区域,每个区域称为G的一个面,其中面积无限的面称为无限面或外部面,面积有限的称为有限面或内部面。
面的次数:边界的长度(每条边的长度都是1)称为该面的次数。
平面图中所有面的次数之和等于边数的 2 倍。
若G为n(n≥3)阶简单的连通平面图,G为极大平面图当且仅当G的每个面的次数均为 3。
判定定理
\(K_{3,3}\)(左部右部点数均为3的二分图,边数最少的非平面图)和\(K_5\)(点数为5的完全图,点数最少的非平面图)不是平面图。
图G是平面图当且仅当G不含与\(K_5\)或\(K_{3,3}\)同胚的子图。(若两个图\(G_1\)与\(G_2\)同构,或通过反复插入或消去 2 度顶点后是同构的,则称二者是同胚的。)
图G是平面图当且仅当G中没有可以收缩到\(K_5\)或\(K_{3,3}\)的子图。
欧拉公式
对于连通的平面图:\(v-e+p=2\)。v:点数;e:边数;p:面数(包括无限面)。
\(\Rightarrow\)对于v≥2的平面图:e≤3*v-6。
对偶图
原平面图G的每个面看作一个点,每条边两边的面连一条边得到的新图G'。
G'也是一个平面图。
对于G的无向边,在G'上该边两边的面连一条无向边。
对于G的有向边,在G'上该边绕自己的几何中心统一顺时针或逆时针旋转\(90^\circ\)得到一条有向边。
..1.平面图最小割与对偶图最短路
平面图的最小割=其对偶图的最短路。(降低求最小割的复杂度从\(O(NM^2)\)到\(O(M\log M)\))
按照一般的定义,对偶图有限面只向一个无限面连边。为了网络流解题需要,无限面分为源点S和汇点T。
- 在原平面图找出无限面的边界,边界上的边强行改成有向边,方向是顺从原平面图源点到汇点的方向。
- 将边界上的有向边绕自己的几何中心统一顺时针或逆时针旋转\(90^\circ\)。若有向边是指向内部的,则在对偶图上从S向终点的面连一条长度为原边的容量的有向边;若指向外部,则从起点的面向T。
- 其他平面图的边按照《对偶图》的方法转化为对偶图的边。
- 然后在对偶图上求S到T的最短路即可。
..1.网格图平面图转对偶图
建图时根据网格图和题目输入数据的性质简易建图。
..2.平面图转对偶图
网络流
.网络流
适用条件:一个集合的最值。
网络流的难点在于建图。
检验建图正确性:\(原问题的一个方案\Leftrightarrow流网络的一条可行流\)
.1.基础知识
-
流网络\(G=(V,E)\),不考虑反向边。
流网络具有可叠加性。
-
可行流\(f\),不考虑反向边。
充要条件\(\begin{cases} 容量限制0≤f(u,v)≤c(u,v) \\ 流量守恒 \forall x \in \{x|x\in V ,x \notin \{S,T\}\},\sum\limits_{(v,x)\in E} f(v,x)=\sum\limits_{(x,v)\in E} f(x,v) \end{cases}\)
可行流的流量=源点流出的流量 - 流入源点的流量。
最大流是指最大可行流。
-
残留网络\(G_f=\{V_f,E_f\},(V_f=V,E_f=\{E,E中所有反向边\})\),考虑反向边。
\(c'(u,v)=\begin{cases} c(u,v)-f(u,v),(u,v)\in E \\ f(v,u),(v,u)\in E \end{cases}\)
\(f+f'\)也是\(G\)的一个可行流,\(|f+f'|=|f|+|f'|\)。证明:从容量限制和流量守恒两方面证明。
-
增广路径:在\(G_f\)中,沿着容量大于0的边,能够从源点走向汇点的路径。增广路径上的流量一定大于0。
网络G→一种可行流f→残留网络G'→在G'找增广路径找到G'的一种可行流→G的更大的可行流\(f+f'\)→...
-
割\([S,T]\):对于一个流网络\(G=(V,E)\),如果对其进行一次划分操作后分为两部分\(S,T\),满足:\(\begin{cases} S⋃T=V,S⋂T=\varnothing \\ 源点s∈S,汇点t∈T \end{cases}\)。
割的数量有\(2^{|V|-2}\)种:除了源点和汇点外,每个点都有两种分割的选择。
割的容量\(c(S,T)=\sum\limits_{u\in S,v\in T,(u,v)\in E}c(u,v)\)。注意:割的容量只考虑从S指向T的边,不考虑从T指向S的边。
割的流量\(f(S,T)=\sum\limits_{a\in S,b\in T,(a,b)\in E}f(a,b)-\sum\limits_{c\in T,d\in S,(d,c)\in E}f(c,d)\)。注意:割的流量既考虑从S指向T的边,又考虑从T指向S的边。
最小割是指在所有割中容量最小的割。
定理:
-
对于流网络的任意一个割\([S,T]\)和任意一个流\(f\),有\(f(S,T)\le c(S,T)\)成立。
-
对于流网络的任意一个割\([S,T]\)和任意一个流\(f\),有\(|f(S,T)|=|f|\)成立。
-
\(|f|\le c(S,T)\),即最大流\(\le\)最小割。
-
(最大流最小割定理):对于一个流网络G,以下三个条件相互等价:\(f\)是最大可行流\(\Leftrightarrow\)\(f\)对应的残留网络\(G_f\)中不存在增广路\(\Leftrightarrow\)存在一个割\([S,T]\),满足\(|f|=c(S,T)\)
证明:三个分割的集合\(X\)、\(Y\)、\(Z\),有性质:
- \(f(X,Y)=-f(Y,X)\)
- \(f(X,X)=0\)
- \(f(Z,X\cup Y)=f(Z,X)+f(Z,Y)\)
- \(f(X\cup Y,Z)=f(X,Z)+f(Y,Z)\)
-
由定理3和定理4得:\(\begin{cases} 最大流≤最小割 \\ 最大流 ≥ |f| = c(S,T) ≥ 最小割 \end{cases}\)\(\Leftrightarrow\)最大流==最小割。
-
-
流的费用:对于给定的流网络中的每一条边,我们不仅赋予它容量,还赋予它一个费用\(w\left(u,v\right)\)。那么对于原网络的一个可行流,我们定义它的费用\(w_f=\sum\limits_{e\in E}\left(f_e\times w_e\right)\)。
费用流:所有最大可行流中费用的最小值/最大值。因此,费用流也被称为最小费用最大流/最大费用最大流。
-
一条边的流量\(\Leftrightarrow\)残留网络其反向边的容量
一条边满流\(\Leftrightarrow\)残留网络其正向边的容量是0
还原残留网络:
f[i]+=f[i^1],f[i^1]=0;
技巧
- 多源汇:建立一个超级源点S和一个超级汇点T,S向所有的源点连一条容量为INF的边,所有的汇点向T连一条容量为INF的边。然后正常跑一遍最大流/最小割/费用流即可。
- 点数计算:在写代码前分析(注意算上S、T和拆点);边数计算:在写代码后看代码中有几次
add()操作(注意算上反向边*2!!!)。 - 使网络拥有容量维度和距离维度:分层图(或者称之为状态机)
第1天 第2天 ...
(u_1,day_1) (u_1,day_2)
(u_2,day_1) (u_2,day_2)
...
3类边:1.源边、汇边;2.状态移动边$(u_x,day_n)→(u_y,day_{n+1})$;3.状态不变边$(u_x,day_n)→(u_x,day_{n+1})$。
-
当图的大小与k成正相关,应该从小到大依次枚举k,k在k-1的图的基础上新建一些边,这样就可以利用k-1的残留网络直接增广。(比二分快)
-
当操作有时间先后顺序时,可以考虑将每个时间看作一个点,若该操作是第一次执行,建一条源点指向该时间点的边;否则,建一条该操作上一次执行的时间点指向该时间点的边。
-
无向图:建反向边是对答案是没有影响的。把残余网络的边权改成反向边的边权。
e.g.节点u和v之间连一条无向边:
/*
@ --> @ -->
^ | <-> @ @
|- @ <- <--
*/
void add(int u,int v,int c1,int c2)
{
e[++idx]=v,f[idx]=c1,ne[idx]=h[u],h[u]=idx;
e[++idx]=u,f[idx]=c2,ne[idx]=h[v],h[v]=idx;
return ;
}
add(u,v,1,1);
.2.最大流
最大可行流。
求最大流
while() {找增广路→更新残留网络}边界:(最大流最小割定理)\(f\)是最大可行流\(\Leftrightarrow\)\(f\)对应的残留网络\(G_f\)中不存在增广路。
建图应用
-
先把原问题转化为流网络G;
-
再使得并证明原问题每一个可行解s与流网络每一个可行流f一一对应;(先不考虑最大。此条件成立后自然原问题的最大值\(\Leftrightarrow\)最大流)
使s→f:从容量限制和流量守恒两方面考虑建图。
使f→s:验证流网络是否满足原问题的限制。
-
使得并证明原问题与最大流在数量上有单调关系。
此时原问题的最大值等价于求最大流。
.2.1.求最大流
.2.1.1.EK算法\(O(NM^2)\)
const int INF=1e8;
int n,m,S,T;
int h[N],e[M],f[M],ne[M],idx=1; //f[edge]:残留网络容量;因为反向边成对变换,故idx从2开始
int mi[N],pre[N]; //mi[ver]:求增广路到达点ver的可行流(各边流量最小值);pre[ver]:到达点ver所走的边的编号
int q[N]; //队列
bool vis[N];//防止走环
//建正向边和反向边
void add(int u,int v,int c)
{
e[++idx]=v,f[idx]=c,ne[idx]=h[u],h[u]=idx;
e[++idx]=u,f[idx]=0,ne[idx]=h[v],h[v]=idx;
return ;
}
//在残留网络上找到一条增广路
bool bfs()
{
memset(vis,false,sizeof vis);
mi[S]=INF;
int hh=0,tt=-1;
q[++tt]=S,vis[S]=true;
while(hh<=tt)
{
int t=q[hh++];
for(int edge=h[t];edge!=0;edge=ne[edge])
{
int ver=e[edge];
if(!vis[ver] && f[edge]!=0)
{
mi[ver]=min(mi[t],f[edge]);
pre[ver]=edge; //记录增广路以便更新残留网络
if(ver==T) return true;
q[++tt]=ver,vis[ver]=true;
}
}
}
return false;
}
int ek()
{
int r=0;
while(bfs()) //在残留网络上找到一条增广路
{
r+=mi[T]; //合并可行流答案
for(int ver=T;ver!=S;ver=e[pre[ver]^1]) f[pre[ver]]-=mi[T],f[pre[ver]^1]+=mi[T]; //更新残留网络
}
return r;
}
int main()
{
scanf("%d%d%d%d",&n,&m,&S,&T);
for(int i=1;i<=m;i++)
{
int u,v,c;
scanf("%d%d%d",&u,&v,&c);
add(u,v,c);
}
printf("%d\n",ek());
return 0;
}
.2.1.2.Dinic算法\(O(N^2M)\)
优化思路:当前弧优化+同时找多条增广路径。
const int INF=1e8;
int n,m,S,T;
int h[N],e[M],f[M],ne[M],idx=1;
int depth[N],cur[N]; //depth:bfs深度,分层;cur:当前弧优化
//通过给图分层找多条增广路径+当前弧优化预处理
bool bfs()
{
memset(depth,-1,sizeof depth);
queue<int> q;//新建空队列
q.push(S),depth[S]=0,cur[S]=h[S];
while(q.size())
{
int t=q.front();
q.pop();
for(int edge=h[t];edge!=0;edge=ne[edge])
{
int ver=e[edge];
if(depth[ver]==-1 && f[edge]!=0)
{
depth[ver]=depth[t]+1;
cur[ver]=h[ver];
if(ver==T) return true;
q.push(ver);
}
}
}
return false;
}
//找增广路径
int find(int u,int limit)
{
if(u==T) return limit;//注意是返回limit而不是INF
int flow=0;
for(int edge=cur[u];edge!=0 && flow<limit/*重要剪枝优化*/;edge=ne[edge])
{
cur[u]=edge;
int ver=e[edge];
if(depth[ver]==depth[u]+1 && f[edge]!=0)
{
int t=find(ver,min(f[edge],limit-flow));
if(t==0) depth[ver]=-1; //删点
f[edge]-=t,f[edge^1]+=t;
flow+=t;
}
}
return flow;
}
int dinic()
{
int r=0,flow; //flow:一条增广路径的可行流
while(bfs()) while(flow=find(S,INF)) r+=flow;
return r;
}
printf("%d\n",dinic());
Dinic算法在特殊图上的复杂度
网络流的复杂度为\(O(增广轮数*单轮增广复杂度)\)。
下面的图为原图。
-
一般图
设点u的入边和出边的容量和分别为in_u和out_u。
增广轮数\(O(\min(N,\sqrt{\sum\limits_u\min(in_u,out_u)}))\),单轮增广复杂度\(O(NM)\)。
\(O(NM\min(N,\sqrt{\sum\limits_u\min(in_u,out_u)}))\)。
-
单位容量网络图
各边的容量均为1的网络图。
\(O(M\min(N^\frac{2}{3},M^\frac{1}{2}))\)。
-
单位网络图
除源汇点外各点的入度或出度不超过1的单位容量网络图。
二分图最大匹配模型是一个单位网络图。
\(O(M\sqrt N)\)。
-
有向无环图
\(O(NM)\)。
.2.2.最大流之上下界可行流
.2.2.1.无源汇上下界可行流
对于每条边的流量,我们给它增添一个下界限制(即\(c_l(u,v)\le f(u,v)\le c_u(u,v)\)),流网络不包含源点和汇点,所有点都应该满足流量守恒。判断是否有可行流。
因为无源汇,所以可以自行新建合适的源汇点。
把原图转化成一张正常的流网络G'
消去容量下界:\(0\le f(u,v)-c_l(u,v)\le c_u(u,v)-c_l(u,v)\)。记\(f'(u,v)=f(u,v)-c_l(u,v),c'(u,v)=c_u(u,v)-c_l(u,v)\),我们要让f'成为G'的一个可行流,且G'的容量限制为c'。
此时G'满足容量限制,但是不满足流量守恒:不平衡流量=\(\sum\limits_{v\in V}c_l(v,u)-\sum\limits_{v\in V}c_l(u,v)\)。
- 若\(c_{in}\ge c_{out}\)(被减去的流入的量比被减去的流出的量多,即实际流入的量少),我们就从S向u连一条容量为\(c_{in}-c_{out}\)的边。
- 若\(c_{in}<c_{out}\),我们就从u向T连一条容量为\(c_{out}-c_{in}\)的边。
由于从源点出发所有的流都是补充的不守恒流量,因此原图的所有可行流和新图的所有满流是一一对应的。可行流的可行判定经过转化就是跑到最大流是满流。
int n,m,S,T;
int h[N],e[M],f[M],l[M],ne[M],idx=1; //l:流量下界
int depth[N],cur[N]; //depth:bfs深度,分层;cur:当前弧优化
int A[N],more; //不守恒流量,A[u]:点u 被减去的流入的量 - 被减去的流出的量
int q[N];
void add(int a,int b,int c,int d)
{
e[++idx]=b,f[idx]=d-c,l[idx]=c,ne[idx]=h[a],h[a]=idx;
e[++idx]=a,f[idx]=0,ne[idx]=h[b],h[b]=idx;
return ;
}
scanf("%d%d",&n,&m);
S=0,T=n+1; //自行新建源汇点
for(int i=1;i<=m;i++)
{
int a,b,c,d;
scanf("%d%d%d%d",&a,&b,&c,&d);
add(a,b,c,d);
A[a]-=c,A[b]+=c; //计算不守恒流量
}
for(int i=1;i<=n;i++)
if(A[i]>0) add(S,i,0,A[i]),more+=A[i]; //被减去的流入的量多,即实际流入的量少,从源点补充不守恒流量
else if(A[i]<0) add(i,T,0,-A[i]); //被减去的流出的量多,即实际流出的量少,向汇点释放不守恒流量
if(dinic()!=more) puts("NO"); //因为从源点出发所有的流都是补充的不守恒流量,所以新图最大流必须满流
else
{
puts("YES");
for(int i=2;i<=m*2;i+=2) printf("%d\n",f[i^1]+l[i]); //一条边的流量=残留网络其反向边的容量
}
.2.2.2.有源汇上下界可行流与最大流
对于每条边的流量,我们给它增添一个下界限制,流网络包含源点和汇点,所有点都应该满足流量守恒。求源点到汇点的最大流。
类似《.2.3.1.无源汇上下界可行流》,我们通过这种构造方式建出一张新图\(G'\)和虚拟源汇点S、T。为使真实源汇点s、t满足流量无限,我们新建一条从t到s容量无限的边,与此同时,s到t的可行流$f'_{0,s \rarr t} $=残留网络从t到s的边的流量(流量守恒)=残留网络从t到s的反向边的容量。
$f'{0,s \rarr t} $相应地也是原图的一种可行流,在原图上的任意一个 s 到 t 的可行流 $f' \(,考虑让两个流相加,则:\)|f'{0,s \rarr t} + f'|
= |f'{0,s \rarr t}| + |f'|$。
求最大流,由于$f'{0,s \rarr t} \(固定,我们要让\) f'$ 最大,即求 s 到 t 的最大流,即使得 s 到 t 之间不存在增广路径。
具体算法流程见代码。
scanf("%d%d%d%d",&n,&m,&s,&t);
S=0,T=n+1;
for(int i=1;i<=m;i++)
{
int a,b,c,d;
scanf("%d%d%d%d",&a,&b,&c,&d);
add(a,b,d-c);
A[a]-=c,A[b]+=c;
}
for(int i=1;i<=n;i++)
if(A[i]>0) add(S,i,A[i]),more+=A[i];
else if(A[i]<0) add(i,T,-A[i]);
add(t,s,INF); //新建一条从t到s容量无限的边
if(dinic()<more) puts("No Solution");//判断可行流
else
{
int res=f[idx];//注意是s到t的可行流f'_{0,s \rarr t}=残留网络从t到s的反向边的容量,而不是新图的最大流
//转化为原图
S=s,T=t;
f[idx]=f[idx^1]=0; //删除从t到s容量无限的边
printf("%d\n",res+dinic());
}
.2.2.3.有源汇上下界最小流
类似《.2.3.2.有源汇上下界最大流》,我们只要让$ f'{s \rarr t}$ 最小,即让$ f' \(最大,也就是使求出的\)f'_{0,s \rarr t} \(从t到s返还更多的流量,即减去\) t \rarr s $的最大流。
//把《.2.3.2.有源汇上下界最大流》的代码的最后的else改成下面的代码即可
else
{
int res=f[idx];
S=t,T=s;//!!!
f[idx]=f[idx^1]=0;
printf("%d\n",res-dinic());//!!!
}
.2.2.4.设置初始流量和流量不守恒的处理
有时为了解题简便,初始设置一些边具有想要的流量,但这可能导致流量不守恒。
类似《图论.2.3.1.无源汇上下界可行流》,设置这些边的流量的上下界,然后处理流量不守恒。
.2.3.最大流的关键边
定义:在跑一遍最大流之后,若增大某一边的容量,可以使最大流增大,则称该边为关键边。
关键边的满足条件:在跑一遍最大流之后,f(u,v)==c(u,v)且在残留网络中存在从S→u的路径和v→T的路径(不能经过容量为0的边)。
bool s_key[N],t_key[N];
//sign:当从t开始倒着遍历时,要求的是v->T的路径(而不是遍历时T->v的路径)不能经过容量为0的边,故判断的是反向边的容量
void dfs(int u,int sign,bool key[])
{
key[u]=true;
for(int i=h[u];i!=0;i=ne[i])
{
int v=e[i];
if(key[v] || f[i^sign]==0) continue;
dfs(v,sign,key);
}
return ;
}
dinic();
dfs(S,0,s_key);
dfs(T,1,t_key);
for(int i=2;i<=m*2;i+=2)
{
int u=e[i^1],v=e[i];
if(s_key[u] && t_key[v] && f[i]==0) ans++;//是关键边
}
.2.4.最大流的判定
最大边权最小/最小边权最大——二分
void add(int u,int v,int wor){}//在这里不涉及容量c
bool check(int x)
{
for(int i=2;i<=idx;i++)
{
if(w[i]<=x) f[i]=c;
else f[i]=0;//把大于x的边权的边的容量设为0,不走这条边
}
if(dinic()>=k) return true;//若只走小于等于x的边权的边即可满足条件
else return false;
}
int l=w_min,r=w_max;
while(l<r)
{
int mid=(l+r)>>1;
if(check(mid)) r=mid;
else l=mid+1;
}
printf("%d\n",l);
求最小时间——从小到大枚举时间
使网络拥有容量维度和距离维度:分层图(或者称之为状态机)
3类边:1.源边、汇边;2.状态移动边\((u_x,day_n)→(u_y,day_{n+1})\);3.状态不变边\((u_x,day_n)→(u_x,day_{n+1})\)。
第1天 第2天 ...
(u_1,day_1) (u_1,day_2)
(u_2,day_1) (u_2,day_2)
...
因为图的大小与时间成正相关,所以从小到大依次枚举时间,新的一天在前一天的图的基础上新建一些边,这样就可以利用前一天的残留网络直接增广。(比二分快)
.3.最小割
适用条件:连通性问题(去边改变连通性,去点可以通过拆点转化为去边)、集合划分问题,以边为最小讨论单位。
所有割中容量最小的割。
,因为最小割的定义、求解过程都是只涉及到容量,不涉及流量。一般最小割的思路是把题目转化成一个集合划分求最优解的问题。
-
简单割:对于一个流网络的割\([S,T]\),如果割边都与源点或汇点相连,则称这样一个割是流网络的一个简单割。
性质:从S到T的路径中一定经过s和t。
-
最小割≠去掉一些边使s、t不连通,因为最小割是s、t两个集合之间的边,后者还包含一个集合内部的边。最小割=先去掉负权边+去掉一些边使s、t不连通。
-
“割边”一定满流,满流不一定是“割边”。否则不满足最大流最小割定理。容量在有向图/无向图都只会被算1次。
-
最小割模型通常解决的问题是连通性问题(去边改变连通性,去点可以通过拆点转化为去边)、集合划分问题,以边为最小讨论单位。
-
强制使点u和S在同一集合内:****
add(S,i,INF,0); -
强制令某边不成为割边:****
f[i]=INF;;强制令某边成为割边:****f[i]=f[i^1]=0;。 -
在连通性问题中,如果涉及到删点,可以考虑到拆点的技巧。
-
网格图通常可以通过黑白染色把它变成一个二分图。
-
若题意为删除边权和最小的一些边,使图不连通,则令容量等于边权。
-
对于某些正向求最大值较难的题目,可以通过正难则反:合法值=总值-不合法值,因此需要最小化不合法值\(\Rightarrow\)转化为最小割问题。
.3.1.求最小割
建图应用
- 先把某问题的划分方式转化为流网络的割的方式;
- 在使得并证明原问题每一个划分方式与流网络的每一个割一一对应;(先不考虑最小。此条件成立后自然原问题的最小值\(\Leftrightarrow\)最小割)
- 使得并证明权值\(\Leftrightarrow\)容量,且原问题与最小割在数量上有单调关系。
此时原问题的等价于求最小割。
求最小割
最小割==最大流。
用《.2.1.2.Dinic算法》求出最大流,最小割等于最大流。
求具体方案
由于“割边”一定满流,因此在残留网络上从S出发,沿着所有f>0的边(包括反向边)记录点集\(V_S\),根据题目含义判断是否要减去S,下面以要减去S为例。
- 求点集\(V_S\)。
bool st[N];//是否属于V_S(不包括S、T)
int cnt1;//点集大小
void dfs(int u)
{
if(u!=S)
{
st[u]=true;
cnt1++;
}
for(int i=h[u];i!=0;i=ne[i])
{
int v=e[i];
if(!st[v] && f[i]!=0) dfs(v);
}
}
- 求原图的割边:一定是从S到T的正向边(起点在S,终点在T。因为是无向图,所以只考虑正向边即可)。而不考虑从T到S的边。
bool cut[M];
int cnt2;//割边数量
for(int i=2;i<=idx;i+=2)//只考虑正向边即可
{
int u=e[i^1],v=e[i];
if((st[u] && !st[v]) || (!st[u] && st[v]))
{
cut[i>>1]=true;
cnt2++;
}
}
- 将割边\(\Rightarrow\)原题方案:考虑在上面证明的流网络的割的方式\(\Rightarrow\)某问题的划分方式的过程。
.3.2.最小割的可行边与必须边
定义:对于一条边(u,v),如果存在一个最小割方案使这条边成为割边,则该边为可行边(即最小割的并集)。如果对于任意最小割方案这条边都包含在里面,则称其为必须边(即最小割的交集)。显然,必须边一定是可行边。
可行边:1.最大流满流;2.残留网络中没有u→v的增广路径。
必须边:可行边的2个条件+3.残留网络中存在S→u和v→T的增广路径。
判断有无增广路径:利用dinic()中的bfs()。并向函数多传两个参数:bfs(int S,int T)。if(!bfs(u,v)) ;。
找到一种所有割边的编号字典序最小的最小割:
sort(edge+1,edge+idx+1,cmp);//贪心,先将所有边按编号排序
for(int i=2;i<=idx;i+=2)//从编号小向编号大依次枚举边
if(f[i]==0 && !bfs(edge[i].u,edge[i].v))//“一种最小割”:可行边。1.满流;2.残留网络中没有u→v的增广路径
{
ans.push_back(i);
//强制选i为割边:恢复所有边的流量,令f[i]=0,重新跑一遍dinic()!!!
for(int j=2;j<=idx;j+=2) f[j]+=f[j^1],f[j^1]=0;
f[i]=0;
dinic();
}
.3.3.最大权闭合子图
适用条件:1.有依赖关系。建一条依赖关系的反方向的边。\(e.g.\)若选B必须先选\(A_1,...,A_x\),则建一条从B到\(A_1,...,A_x\)的边;2.形如“要获得什么价值,就必须先付出什么代价”的若干条限制条件,最终要求最大净获利(价值-代价)。从价值向代价连边,价值的点权是正数,代价的点权是负数。
定义:给定一张有向图,选择一个点集V',满足点集内部的点不能有边指向外面(但是外面的点可以有边指向点集内部),所有以点集内部的点为起点的边构成边集E',使得子图G'=(V',E')的点权之和最大。
- 从S向所有权值为正数的点连一条容量为该点权值的边。
- 从所有权值为负数的点向T连一条容量为该点权值的绝对值(注意容量不可以为负数!!!)的边。
- 对于原图的所有边保持不变,并设其容量为\(+\infty\)。
- \(ANS=(\sum\limits_{w[v]>0}w[v])-c[S,T]\),即原图G中的正点权之和减最小割。
具体方案:最大权闭合子图=V_S-{s},即最小割的方案点集V_S去除源点s。
.3.4.最大密度子图
定义:给定一张无向图,选择一个子图G'=(V',E'),满足对于所有E'中的边(u,v),u、v必须在点集V'中(可以有单独的点,不可以有单独的边),使得\(\dfrac{|E'|}{|V'|}\)最大。
01分数规划。设当前二分的值为mid,要求最大化|E'|-mid*|V'|≥0。
-
设\(dg[v]\):v的度数。(可以看作所有与v相连的边的边权之和,所有边的边权为1)
-
容量必须非负,且此题允许给容量加偏移量delta。\(delta=\max\{0,-(2*mid-dg[v]) \}\)
-
从s向每个点连一条容量为delta的有向边。
-
原图的每条边的容量设为1。
-
从每个点向汇点t连一条有向边,设容量为\(delta+2*mid−dg[v]\)。
-
|E'|-mid|V'|=(deltan-c[S,T])/2。
注意下面的比较应该换成浮点数精度比大小!
const double EPS=1e-8;
int dg[N];
double delta;
struct Edge
{
int u,v;
}edge[M];
void build(double x)
{
idx=1,delta=0;
memset(h,0,sizeof h);
for(int i=1;i<=m;i++) add(edge[i].u,edge[i].v,1,1);
for(int i=1;i<=n;i++) delta=max(delta,-(x*2-dg[i]));
for(int i=1;i<=n;i++)
{
add(S,i,delta,0);
add(i,T,delta+x*2-dg[i],0);
}
return ;
}
bool dinic(double x)
{
build(x);
double r=0,flow;
while(bfs()) while(flow=find(S,INF)) r+=flow;
if((delta*n-r)/2>0) return true;
else return false;
}
for(int i=1;i<=m;i++)
{
int u,v;
scanf("%d%d",&u,&v);
edge[i]={u,v};
dg[u]++,dg[v]++;
}
double l=low,r=up;
while(r-l>EPS)
{
double mid=(l+r)/2;
if(dinic(mid)) l=mid;
else r=mid;
}
- 若带有非负边权,即最大化\(\dfrac{\sum\limits_{e\in E'}w_e}{|V'|}\),则将|E'|替换成\(\sum\limits_{e\in E'}w_e\),将dg[v]表示成所有与v相连的边的边权之和,将原图的每条边的容量设为\(w_e\),\(\sum\limits_{e\in E'}w_e-mid*|V'|=(delta*n-c[S,T])/2\)。
- 若带有点权和非负边权,即最大化\(\dfrac{\sum\limits_{e\in E'}w_e+\sum\limits_{v\in V'}w_v}{|V'|}\),则除了加上第7条的步骤外,将\(delta=\max\{ 0,-(2*mid-dg[v]-2*w_v) \}\),将每个点连向汇点的边的容量设为\(2*mid-dg[v]-2*w_v\),\(\sum\limits_{e\in E'}w_e+\sum\limits_{v\in V'}w_v-mid*|V'|=(delta*n-c[S,T])/2\)。
具体方案:最大密度子图=V_S-{s}。即最小割的方案点集V_S去除源点s。
.3.5.最小割之多选一模型
适用条件:有n个点,每个点有两种选择,分别有不同价值。除此以外有一些bonus:有若干组组合,对于属于同一个组合中的点,如果它们选择相同,则会得到一个该组合的额外价值。每个点只能选择一种,求最大价值。
正难则反。合法价值=所有价值总和(注意额外价值是一个)-不合法价值。因此需要最小化不合法价值\(\Rightarrow\)最小割。
不合法价值(割边的价值):
-
每个点只能选一个方面,所以必须断开另一个方面。
对于每个点i,从S向i连一条容量为i选择方面1的收益的有向边,从i向T连一条容量为i选择方面2的收益的有向边。这样为了达成最小割,两边必然割掉一边。
-
一个组合中的点有一个点和其它不同,就必须断开这个组合的价值。
对于每一个组合\(\{ v_1,v_2,\cdots,v_k \}\),新建出虚拟的点\(v_S\)和\(v_T\),从S向\(v_S\)连一条容量是该组合都选择方面1的收益的有向边,从\(v_S\)向\(v_1,v_2,\cdots,v_k\)各连一条容量是\(+\infty\)的有向边(这样一来,如果但凡该组合中有一个点选择连向S的边作为割边(不选方面1),都会强制要求选择S到\(v_S\)的边作为割边,因此正确性显然。);从\(v_T\)向T连一条容量是该组合都选择方面2的收益的有向边,从\(v_1,v_2,\cdots,v_k\)向\(v_T\)各连一条容量是\(+\infty\)的有向边。
.3.6.最小割求按位运算
拆位。
以异或值之和最小为例:把数分成第k位为0与第k位为1,显然集合内部的费用为0,两个集合之间的费用为1,可将原问题转化为最小割问题。
.3.7.平面图最小割与对偶图最短路
.4.费用流
边(u,v,c,w)中的费用w指的是该边每1单位的流量需要花费w的费用。
所有最大可行流中费用的最小值/最大值。
- 如果可以在不求最大流的基础上求最优费用流,则在spfa中当费用为负时停止即可。
- 最小费用流:spfa()最短路
memset(dis,0x3f,sizeof dis);;最大费用流:spfa()最长路memset(dis,-0x3f,sizeof dis);而不是赋值0。注意反向边w是正向边的相反数。 - 如果在求完最小(大)费用流后要求最大(小)费用流:最短路\(\xLeftrightarrow{w=-w}\)最长路。
建图应用
可行流的集合可能是整数可行流、整数满流、最大(小)可行流、简单割……
- 先把原问题转化为流网络G;(先不考虑价值)
- 再使得并证明原问题每一个可行解s与流网络每一个整数可行流f一一对应;(再把费用加在流网络上)
- 使得并证明原问题与费用流在数量上有单调关系。
此时原问题的等价于求费用流。
.4.1.求费用流
.4.1.1.EK算法
最小(大)费用可以有负(正)费用(因此反向边不会影响),保证没有负(正)权回路。
把ek算法的bfs()换成spfa()即可。
建反向边时,费用w[i^1]应该取相反数:-w[i]。
const int INF=1e8;
int n,m,S,T;
int h[N],e[M],f[M],w[M],ne[M],idx=1; //f[edge]:残留网络容量;因为反向边成对变换,故idx从2开始
int mi[N],dis[N],pre[N]; //mi[ver]:求增广路到达点ver的可行流(各边流量最小值);w[i]:从源点到达点i的最小费用;pre[ver]:到达点ver所走的边的编号
int q[N]; //队列
bool vis[N];//防止走环
//建正向边和反向边
void add(int u,int v,int c,int wor)
{
e[++idx]=v,f[idx]=c,w[idx]=wor,ne[idx]=h[u],h[u]=idx;
e[++idx]=u,f[idx]=0,w[idx]=-wor,ne[idx]=h[v],h[v]=idx;
return ;
}
//在残留网络上找到一条费用最小的增广路
bool spfa()
{
memset(dis,0x3f,sizeof dis);
mi[T]=0; //!!!因为下面的判定条件涉及到mi[T],所以要记得初始化!!!
mi[S]=INF,dis[S]=0;
queue<int> q;
q.push(S),vis[S]=true;
while(!q.empty())
{
int t=q.front();
q.pop(),vis[t]=false;
for(int edge=h[t];edge!=0;edge=ne[edge])
{
int ver=e[edge];
if(dis[ver]>dis[t]+w[edge] && f[edge]!=0)
{
dis[ver]=dis[t]+w[edge];
mi[ver]=min(mi[t],f[edge]);
pre[ver]=edge; //记录增广路以便更新残留网络
if(!vis[ver])
{
q.push(ver);
vis[ver]=true;
}
}
}
}
return mi[T]>0;
}
void ek(int &flow,int &cost)
{
flow=cost=0;
while(spfa()) //在残留网络上找到一条增广路
{
flow+=mi[T],cost+=mi[T]*dis[T]; //合并可行流答案
for(int ver=T;ver!=S;ver=e[pre[ver]^1]) f[pre[ver]]-=mi[T],f[pre[ver]^1]+=mi[T]; //更新残留网络
}
return ;
}
int main()
{
scanf("%d%d%d%d",&n,&m,&S,&T);
for(int i=1;i<=m;i++)
{
int u,v,c,wor;
scanf("%d%d%d%d",&u,&v,&c,&wor);
add(u,v,c,wor);
}
int flow,cost;
ek(flow,cost);
printf("%d %d\n",flow,cost);
return 0;
}
流网络中不包含源点和汇点,所有点都应该满足流量守恒。判断对于给定的流网络,是否有可行流。
.4.1.2.有负/正权回路的最小/大费用流
适用条件:允许一个不经过 s,t 的环整体加上一个流量。事实上,若不允许这种情况的出现,则此问题是在哈密顿路的基础上的NPC问题。
对于一条负费用的边,根据贪心先让其强制满流。然后加入一条边(v,u,c,-w)用于退流。
强制满流:利用有源汇上下界费用流。
- 对于满流的流量,因为要强制满流而不能简单的直接累加,所以加一条有上下界流量的边在跑ek时加入满流的流量。令下界=上界=给定的容量。因为此时建立这条边的容量=上界-下界=0,所以不需要实际建边,只要记录不平衡容量即可。
- 对于满流的费用,因为在上面没有实际建边而在ek中无法计算此费用,所以直接累加。注意这里wor要乘上c。
- 加入一条边(v,u,c,-w)用于退流。
- 其余的边正常建,然后跑有源汇上下界费用流即可。
void ek(int &flow,int &cost) {/*ek里面不需要令flow=cost=0,而是直接累加*/}
int flow=0,cost=0; //注意初始化。在计算中直接累加
scanf("%d%d%d%d",&n,&m,&s,&t);
S=0,T=n+1;
//处理边
for(int i=1;i<=m;i++)
{
int u,v,c,wor;
scanf("%d%d%d%d",&u,&v,&c,&wor);
if(wor>=0) add(u,v,c,wor);
else //负费用边,先强制满流
{
A[u]-=c,A[v]+=c; //对于满流的流量,因为要强制满流而不能简单的直接累加,所以加一条有上下界流量的边在跑ek时加入满流的流量。令下界=上界=给定的容量。因为此时建立这条边的容量=上界-下界=0,所以不需要实际建边,只要记录不平衡容量即可
cost+=c*wor; //对于满流的费用,因为在上面没有实际建边而在ek中无法计算此费用,所以直接累加。注意这里wor要乘上c
add(v,u,c,-wor); //用于退流的边
}
}
//有源汇上下界费用流
for(int i=1;i<=n;i++)
if(A[i]>0) add(S,i,A[i],0);
else if(A[i]<0) add(i,T,-A[i],0);
add(t,s,INF,0);
ek(flow,cost);
flow=f[idx];
S=s,T=t;
f[idx]=f[idx^1]=0;
ek(flow,cost);
printf("%d %d\n",flow,cost);
.4.2.费用流之上下界可行流
与《图论.2.2.最大流之上下界可行流》一样,只不过边多了费用这一属性,而为了流量守恒而用于补给和释放流量的边的费用为0。
.4.3.费用流之路径取数模型
源汇
k条路径:《图论.1.1.技巧》1.多源汇技巧。若有l条路径在同一起点i,则建一条边即可add(S,i,l,0)(同一终点也类似)。
按照原图的路径在网络流上建立有向边。
费用
- 数在边上:边的费用设为该数值。
- 数在点上:拆点。点权\(\Rightarrow\)把点拆成入点和出点,在入点与出点之间连一条费用为点权的边,把点权转化为边权。
- 其余的边的费用设为0。
容量
- 一条公共边至多走k条路径(多条路径不能在边相交(没有公共边)):把边的容量设为k(1)。(当没有公共边的限制时,记得把连向汇点的边的容量改为INF!)
- 一个公共点至多走k条路径(多条路径不能在点相交(没有公共点)):拆点。把点拆成入点和出点,从入点向出点连一条容量为k(1)的边。
- 一个数字只能取k次:拆点。把点拆成入点和出点,从入点向出点连2条边:容量为k,权值为数值(取数);容量无限,权值为0(数只能取k次)。
以上3个内容可以叠加。
- 其余的边的容量设为INF。
.4.4.费用流之“一流对多流”模型
-
闲话
倘若按照常规的二分图思路,就很难去满足一个区间会覆盖\([l_i,r_i]\)上的点的条件。因为一个点可能会被多个区间所包含,且选择了一个区间后流量必须完美流向所有\([l_i,r_i]\)上的点与该区间相连的边而不能流向\([l_i,r_i]\)上某些点与其他区间\([l_j,r_j]\)(\([l_i,r_i]\)与\([l_j,r_j]\)又有可能有重叠)相连的边。
因此我们很难让某点的流量实际增加。我们需要建“反流边/分流边”来解决这种“一流对多流”模型,既保证了最大流,又没有实际把流量加到点上。
“反流边”的建图方式。无源汇上下界费用流。
- 建立n+1个点,对于点i,从i向i+1连一条容量为\([x_i,y_i]\),费用为0的边,把点的限制转化为边的容量。
- 对于区间\([l_j,r_j]\),从\(r_j+1\)向\(l_j\)连一条容量为\([x_j,y_j]\),费用为\(z_j\)的边。(“反流边”,每一个单位流量流过这条边表示选择一个区间\([l_j,r_j]\)。)
- 若费用为负/正求最短/长路,则需要再套用《.4.1.2.有负/正权回路的最小/大费用流》。
-
“分流边”的建图方式
设区间i费用为\(w_i\),选择次数限制为\(k_i\);点i的被覆盖次数限制为\([x_i,y]\)次。
- 除源汇点外建立n+1个点,对于数轴上每一个点i,从i向i+1连一条容量为\(y-x_i\),费用为0的边,把点的限制转化为边的容量。(最大流肯定是满流y。建边时令该边容量为\(y-x_i\),则为了使得满流,必须有流量经过至少\(x_i\)条下文3中的“分流边”,对应到原问题也就是至少选择\(x_i\)个区间覆盖,符合题意。)
- 从源点向数轴的起点连一条容量为y,费用为0的边。从数轴的终点向汇点连一条容量为y,费用为0的边。(保证1中最大流是满流y)
- 对于每一个区间\([l_j,r_j]\),从\(l_j\)向\(r_j+1\)连一条容量为\(k_j\),费用为\(w_j\)的边。(“分流边”,每一个单位流量流过这条边代表选择一个区间\([l_j,r_j]\))
.4.5.费用流计算期望
.5.网络流与二分图
.5.1.最大流与二分图
.5.1.1.二分图最大匹配模型
网络流24题1.飞行员配对方案问题
求左图(编号1~m)与右图(编号m+1~n)的二分图最大匹配数并输出方案。
建图(以下很重要)
定义源点S=0,汇点T=n+1。
从S向左图连一条容量为1的有向边,从左图各点向相应的可以匹配的右图各点连一条容量为1的有向边(右部不可以向左部连边!!!),从右图各点向T连一条容量为1的有向边,求出最大流即为答案。
具体方案是\(e[i]\in [m+1,n] \&\& f[i]==0\)的有向边e[i^1]与e[i]匹配。
边数M=2(m+m(n-m)+(n-m))数组别越界了!
-
图片
![]()
scanf("%d%d",&m,&n);
S=0,T=n+1;
for(int i=1;i<=m;i++) add(S,i,1);
for(int i=m+1;i<=n;i++) add(i,T,1);
int i,j;
while(scanf("%d%d",&i,&j),i!=-1) add(i,j,1);
printf("%d\n",dinic());
for(int i=1;i<=m;i++)
for(int j=h[i];j!=0;j=ne[j])
if(e[j]>m && e[j]<=n && f[j]==0) printf("%d %d\n",e[j^1],e[j]);//注意判定条件是f[j]==0
.5.1.2.二分图多重匹配模型
网络流24题2.圆桌问题
求左图(编号1~m,每个点至多与\(r_i\)条边相连)与右图(编号m+1~m+n,每个点至多与\(c_i\)条边相连)的二分图最大匹配数并输出方案。
建图(以下很重要)
定义源点S=0,汇点T=n+m+1。
从S向左图点i连一条容量为\(r_i\)的有向边,从左图各点向相应的可以匹配的右图各点(本题是所有右图各点)连一条容量为1的有向边(右部不可以向左部连边!!!),从右图点i向T连一条容量为\(c_i\)的有向边,求出最大流即为答案。
判断是否左图所有的点匹配数均达到上限\(r_i\):\(dinic()==\sum\limits_{i=1}^{m} r_i ?\)
具体方案是\(e[i]\in [m+1,m+n] \&\& f[i]==0\)的有向边e[i^1]与e[i]匹配。
边数M=2(m+mn+n)数组别越界了!
scanf("%d%d",&m,&n);
S=0,T=n+m+1;
for(int i=1;i<=m;i++)
{
int r;
scanf("%d",&r);
add(S,i,r);
tot+=r;
}
for(int i=1;i<=m;i++)
for(int j=1;j<=n;j++)
add(i,j+m,1);
for(int i=1;i<=n;i++)
{
int c;
scanf("%d",&c);
add(i+m,T,c);
}
if(dinic()!=tot) puts("0");
else
{
puts("1");
for(int i=1;i<=m;i++)
{
for(int j=h[i];j!=0;j=ne[j])
if(e[j]>m && e[j]<=m+n && f[j]==0) printf("%d ",e[j]-m);
puts("");
}
}
return 0;
.5.1.3.二分图最大匹配的必须边和可行边
设跑一遍最大流求最大匹配后,新的有向图G'=原图的非匹配边+原图的匹配边的反向边。
(u,v)是二分图最大匹配的必须边\(\Leftrightarrow\)(u,v)是当前二分图的匹配边且新的有向图G'中u和v属于不同的强连通分量。
(u,v)是二分图最大匹配的可行边\(\Leftrightarrow\)(u,v)是当前二分图的匹配边或新的有向图G'中u和v属于相同的强连通分量。
- 染色法建立二分图。
- 跑一遍最大流求最大匹配。
- tarjan求强连通分量。
int n,m,S,T;
int aidx;
PII ans[M];
//染色法建立二分图的变量
vector<int> edge[N];
int color[N];
//最大流的变量
int h[N],e[M],f[M],ne[M],idx=1;
int depth[N],cur[N];
//tarjan的变量
int dfn[N],low[N],num;
int belong[N];
int st[N],top;
bool in_st[N];
int sc;
void dfs(int u,int c)
{
color[u]=c;
for(auto it : edge[u]) if(color[it]==0) dfs(it,3-c);
return ;
}
void tarjan(int u)
{
dfn[u]=low[u]=++num;
st[++top]=u,in_st[u]=true;
for(int i=h[u];i!=0;i=ne[i])
{
int v=e[i];
if(f[i]==0) continue;//新的有向图=非匹配边+匹配边的反向边
if(dfn[v]==0)
{
tarjan(v);
low[u]=min(low[u],low[v]);
}
else if(in_st[v]) low[u]=min(low[u],dfn[v]);
}
if(dfn[u]==low[u])
{
sc++;
int z;
do
{
z=st[top--];
in_st[z]=false;
belong[z]=sc;
}while(z!=u);
}
return ;
}
int main()
{
scanf("%d%d",&n,&m);
S=0,T=n+1;
for(int i=1;i<=m;i++)
{
int u,v;
scanf("%d%d",&u,&v);
edge[u].push_back(v),edge[v].push_back(u);
}
//建立二分图
for(int i=1;i<=n;i++) if(color[i]==0) dfs(i,1);
//网络流求最大匹配
for(int i=1;i<=n;i++)
if(color[i]==1)
{
add(S,i,1);
for(auto it : edge[i]) add(i,it,1);
}
else add(i,T,1);
dinic();
//tarjan求强连通分量
for(int i=S;i<=T;i++) if(dfn[i]==0) tarjan(i);
//判断二分图最大匹配必须边
for(int i=1;i<=n;i++)
if(color[i]==1)
for(int j=h[i];j!=0;j=ne[j])
{
int v=e[j];
if(v==S || v==T) continue; //这里要预防S->i的反向边等
if(f[j]==0/*满流:当前二分图的匹配边*/ && belong[i]!=belong[v]/*属于不同的强连通分量*/)
{
ans[++aidx]= i<v ? make_pair(i,v) : make_pair(v,i);
}
}
printf("%d\n",aidx);
sort(ans+1,ans+aidx+1);
for(int i=1;i<=aidx;i++) printf("%d %d\n",ans[i].first,ans[i].second);
return 0;
}
.5.2.最小割与二分图
.5.2.1.最小点权覆盖
适用条件:“两者至少选择一个”。
在一张点有非负点权的二分图内,求一个点集,使得原图的每一条边至少有一个点在点集内,求点集的最小总权值并输出方案。
负点权:根据贪心先把负点全部选上,再把负点去掉按照下面非负点权的做法做。
建图(非负点权,以下很重要)
定义源点S=0,汇点T=n+1。
从S向左图各点i连一条容量为点i的权值的有向边,从左图各点向相应的可以匹配的右图各点连一条容量为+INF的有向边(右部不可以向左部连边!!!),从右图所有各点i向T连一条容量为点i的权值的有向边(注意不是通过连了边的右图的点向T连边。因为一个右点可能由多个左点连边),求出最小割即为答案。
具体方案是点集的数量为割边的数量,\((u,v)\in 割边 \&\& u==S\)的v和\((u,v)\in 割边 \&\& v==T\)的u。
.5.2.2.最大点权独立集
适用条件:“两者至多选择一个”。
在一张点有非负点权的二分图内,求一个点集,使得点集中任意两点在原图中均没有边直接相连,求点集的最大总权值并输出方案。
最大点权独立集=总权值-最小点权覆盖。转化为最小点权覆盖问题。
负点权:转化为最小点权覆盖后会处理负点权。若所有的点权均为负值,则根据题意输出0或者最大的负点权。
具体方案=所有的点-最小点权覆盖的点集。
.5.3.费用流与二分图
.5.3.1.费用流之二分图最优匹配
网络流24题.分配问题
求左图(编号1~n)与右图(编号n+1~2*n)的二分图最小费用流和最大费用流。
相当于《.2.2.1.二分图最大匹配模型》的最大流的流网络再加上费用变成费用流的流网络。
建图参考《.2.2.1.二分图最大匹配模型》。
scanf("%d",&n);
S=0,T=2*n+1;
for(int i=1;i<=n;i++) add(S,i,1,0);
for(int i=1;i<=n;i++)
for(int j=1;j<=n;j++)
{
int wor;
scanf("%d",&wor);
add(i,j+n,1,wor);
}
for(int i=1;i<=n;i++) add(i+n,T,1,0);
printf("%d\n",ek());
for(int i=2;i<=idx;i+=2)
{
f[i]+=f[i^1],f[i^1]=0;//还原现场
w[i]=-w[i],w[i^1]=-w[i^1]; //转变为求最大可行流中费用的最大值
}
printf("%d\n",-ek()); //注意负号要取回来
.6.建图模型
.6.1.拆点
拆点是网络流很重要的技巧!
如果对边有限制的话,我们就把容量设置到边上就可以了;但如果我们要对点进行限制的时候呢?这个时候就要用到拆点的技巧了。
拆点特指将一个点拆成入点和出点,在入点与出点之间连边,并将点的限制转移到入点与出点之间的边的容量上。
为了建边方便一般令\(u_入=i\),\(u_出=i+N\)。
a b a 对v的限制 b
u--->v--->w -> u--->v入--->v出--->w
add(v入,v出,对v的限制)
add(u,v,a) add(u,v入,a)
add(v,w,b) add(v出,w,b)
-
最大流。
-
三分图。
因为每个点只能选1次,所以我们把中间的点拆成入点和出点,在入点与出点之间连一条容量为1的边。
-
对点的选择次数的限制
把点拆成入点和出点,在入点与出点之间连一条容量为1的边。
-
对点的流入/流出次数的限制
把点拆成入点和出点,在入点与出点之间连一条容量为流入/流出次数限制的边。
-
-
最小割。
- 去除至少多少点使图不连通?\(\Rightarrow\)把点拆成入点和出点,在入点与出点之间连一条容量为1的边,其他边的容量设为INF,去除多少边使图不连通?即最小割。
- 一个点有两种操作:将所有射入点 i 的边移除、将所有从点 i 射出的边移除\(\Rightarrow\)把点拆成入点和出点。
-
费用流。
点权\(\Rightarrow\)把点拆成入点和出点,在入点与出点之间连一条费用为点权的边,把点权转化为边权
.6.2.矩阵行列限制
“行i最多选a_i个点,列i最多选b_i个点,节点i(x_i,y_i)最多被选c_i次”。
源点向行i连容量为a_i的边,行x_i向列y_i连容量为c_i的边,列i向汇点连容量为b_i的边。




















浙公网安备 33010602011771号