Loading

【笔记】二分图

二分图

二分图:可以把所有点分为两个点集,相同点集里的点之间没有边的无向图

当且仅当一个无向图中有奇环存在时,这个图不是二分图。

一、二分图判定 - 染色法

我们可以把图中的点染成两种颜色,白色是一个点集,黑色是另一个点集。

染色的规则:因为相同点集里的点之间没有边,所以每个节点的颜色必须与父节点不同

实现方法:从任意一个节点开始遍历,模拟染色的过程。当遇到已经遍历过的结点时,如果这个结点又要被染成另一种颜色,那么这个图就不是二分图。使用 DFS 或 BFS 实现。

连通块:如果图不连通,那么当所有连通块都是二分图的时候,整个图也是一张二分图。因此在遍历的过程中要对所有连通块分别染色

bool dfs(int u,int col){
	vis[u]=1;
	c[u]=col;
	for(int i=h[u];i;i=e[i].nxt){
		int v=e[i].to;
		if(vis[v]){
			if(c[u]!=c[v]) continue;
			else return 0;
		}
		if(!dfs(v,col^1)) return 0;
	}
	return 1;
}
for(int i=1;i<=n;i++){
	if(!vis[i]){
		if(!dfs(i,0)){
			cout<<"NO";
			return 0;
		}
	} 
}
cout<<"YES";

时间复杂度:\(O(n+m)\)

另:并查集判断二分图

类似扩展域并查集,对于每个边 \((u,v)\),合并其敌人关系 \((u,v+n)\)\((u+n,v)\)

判断是否出现 \((u,u+n)\)\((v,v+n)\) 即可。

bool check(){
	for(int i=1;i<=m;i++){
		Merge(e[i].u+n,e[i].v);
		Merge(e[i].u,e[i].v+n);
		if(Find(e[i].u)==Find(e[i].u+n)||Find(e[i].v)==Find(e[i].v+n)) return 0;
	}
	return 1;
}

二、二分图最大匹配 - 匈牙利算法

记二分图 \(G\langle V_1,V_2,E\rangle\)\(V_1,V_2\) 为两个点集,\(E\) 为边集。

匹配:在二分图中选择若干条边组成一个边集合,使得边集中的边连接的点两两没有公共点

最大匹配:边数量最多的匹配。

匈牙利算法:求二分图最大匹配的算法。

算法思路:考虑贪心求解。遍历集合 \(V_1\) 中的每个点 \(u\),寻找 \(u\) 的邻居结点(与 \(u\) 相连),如果 \(v\) 还没有被匹配,那么将 \(v\)\(u\) 配对;如果 \(v\) 匹配过了,那么就去寻找与 \(v\) 配对的结点 \(k\) 的其他邻居结点,如果 \(k\) 能按照同样的方式找到新的匹配,那么就把 \(k\) 与新匹配配对,把 \(v\)\(u\) 配对。如果 \(k\) 无法找到新匹配,那么 \(u\) 也就匹配失败。

增广路:寻找新匹配的路径叫做增广路。增广路由奇数条边组成,第奇数条边为非匹配边,第偶数条边为匹配边。因此匈牙利算法也可理解为寻找增广路的过程:每次找到增广路,因为增广路非匹配边比匹配边多 \(1\),所以把增广路取反,将原来的匹配边变为非匹配边,这样匹配数就多了 \(1\)。直到找不到增广路,就求得了最大匹配。

时间复杂度:由于寻找新配对结点最多遍历整张图,所以时间复杂度为 \(O(nm)\)。但实际情况往往跑不满,因为很少会让每个点都更改自己的匹配。

实现方式:维护一个数组 \(p\),记录 \(V_2\) 中的点与 \(V_1\) 中的点的配对情况,以及一个标记数组 vis 记录结点是否被遍历过。对每个结点都进行上述操作,每次清空 vis,即可求出最大匹配。

注意事项:因为匹配时永远是 \(V_1\)\(V_2\),所以建图时建单双向边均可(但必须有 \(V_1\) 指向 \(V_2\) 的边)。同时两个点集的编号可以独立也可以共用(共用就必须建单向边了),因为只有 \(V_1\) 中的点会作为 \(u\) 进行搜索,其结点编号存储在 \(p\) 中,而且边表中我们也只存从 \(V_1\) 的点出发的边;而 \(V_2\) 中结点编号只作为 \(p\) 下标。

//p:匹配的节点。vis:是否遍历过。
bool dfs(int u){
	if(vis[u]++) return 0;
	for(int i=h[u];i;i=e[i].nxt){
		int v=e[i].to;
		if(!p[v]||dfs(p[v])){
			p[v]=u;
			return 1;
		}
	}
	return 0;
}
for(int i=1;i<=n;i++){
	if(dfs(i)) ans++;
	for(int j=1;j<=n;j++) vis[j]=0;//每次清空vis,只用为左部点清空
}

时间复杂度 \(O(n(n+m))\)

衍生问题:二分图最小点覆盖、二分图最大独立集、最小路径覆盖

最小点覆盖:在无向图中,选最少的点,使得每条边两端至少有一个被选中。二分图中,最小点覆盖=最大匹配(一般图中为 NP Hard 问题)。

最大独立集:在无向图中,选最多的点,使得每条边两端至多有一个被选中。也就是一个点集,点集中的各点没有关系。最大独立集和最小点覆盖互补,任何一个最小点覆盖,它的补集一定是最大独立集。二分图中,最大独立集=总点数-最大匹配(一般图中为 NP Hard 问题)。

最大独立集的构造:选择所有未匹配点,及每条匹配边中一个端点,此时选中点两两没有关系且点数最大。从每个左部未匹配点出发跑交替路,从左部到右部是未匹配边,从右部回来是匹配边。此时左部访问到的节点是所有左部未匹配点和匹配边的左端点,右部访问到的节点是匹配边的右端点。左部访问到的点右部未访问到的点即是一个最大独立集。由于最大独立集合最小点覆盖互补,最小点覆盖即为左部未访问到的点和右部访问到的点。反证法易证。

最小路径覆盖:在有向图中,选择最少的简单路径,覆盖所有点集,且各路径点集之间不允许有交集,要求路径数最少。有向无环图可进行拆点(每个点 \(u\) 拆成两个点 \(ul\) 入点和 \(ur\) 出点,有向边 \((u,v)\) 变为无向边 \((ur,vl)\),因为原图是有向无环图,所以拆点后为二分图,出入点分别成为新图的左部和右部)。有向无环图的最小路径覆盖=原图结点数-拆点后二分图最大匹配数(一般有向图中为 NP Hard 问题)。

三、Hall 定理

二分图完美匹配:设有二分图 \(G\langle V_1,V_2,E\rangle\),边集 \(M\) 为其中的最大匹配,且 \(|M|=|V_1|\),则 \(M\)\(V_1\)\(V_2\) 的完美匹配。

设有二分图 \(G\langle V_1,V_2,E\rangle\)\(|V_1|\le|V_2|\),如果 \(G\) 中存在 \(V_1\)\(V_2\) 的完美匹配,当且仅当对于任意的点集 \(S \subset V_1\),均有 \(|S| \le |N(S)|\),其中 \(N(S)\)\(S\) 的邻居集合,即与 \(S\) 点集有边相连的所有点的集合(全部在 \(V_2\) 集合里)。

四、例题

1. Luogu P1525 [NOIP 2010 提高组] 关押罪犯

最大值最小,二分答案枚举最大值,并将边权 \(>mid\) 的边保留作为二分图中的边,若能染色成功,则合法。找到最小的合法 \(mid\)

#include<iostream>
#include<algorithm>
#include<cstring>
#include<cstdio>
using namespace std;
const int maxn=2e4+10;
const int maxm=1e5+10;
struct Node{
	int to,nxt,w;
}e[2*maxm];
int n,m;
int d[maxm],c[maxn],mid;
int h[maxn],tot;
bool vis[maxn];
void Add(int u,int v,int w){
	tot++;
	e[tot].to=v;
	e[tot].nxt=h[u];
	e[tot].w=w;
	h[u]=tot;
} 
bool dfs(int u,int col){
	vis[u]=1;
	c[u]=col;
	for(int i=h[u];i;i=e[i].nxt){
		int v=e[i].to;
		if(e[i].w<=d[mid]) continue;
		if(vis[v]){
			if(c[u]!=c[v]) continue;
			else return 0;
		} 
		if(!dfs(v,col^1))return 0;
	}
	return 1;
}
bool check(){
	memset(vis,0,sizeof(vis));
	memset(c,0,sizeof(c));
	for(int i=1;i<=n;i++){
		if(!vis[i]){
			if(!dfs(i,0)) return 0;
		}
	}
	return 1;
}
int bfind(){
	int l=0,r=2*m;
	while(l<r){
		mid=(l+r)/2;
		if(check()) r=mid;
		else l=mid+1;
	}
	return l;
}
int main(){
	scanf("%d%d",&n,&m);
	for(int i=1;i<=m;i++){
		int u,v,w;
		scanf("%d%d%d",&u,&v,&w);
		d[i]=w;
		Add(u,v,w);
		Add(v,u,w);
	}
	if(m==1){
		cout<<0;
		return 0;
	} 
	sort(d+1,d+1+m);
	cout<<d[bfind()];
	return 0; 
}

2. Luogu P1640 [SCOI2010] 连续攻击游戏

以属性值为左部点,武器为右部点,建立二分图。选择武器属性值就是二分图匹配的过程。由于求 \(\operatorname{mex}-1\),将属性值从小到大依次匹配,输出最后一个匹配成功的属性值。

此题来不及每次清空 vis,于是我们每次遍历都给代表已访问的 vis 换一个数,这样相当于一开始全都没访问。

分析时间复杂度。正常匈牙利算法时间复杂度为 \(O(nm)\),但此题我们只对 \(m\)(属性值)进行搜索,单次搜索也最多跑 \(m\) 次,复杂度就是 \(O(m^2)\),足够通过。

#include<bits/stdc++.h>
using namespace std;
const int N=1e6+10;
const int M=4e6+10;
int n,ans;
int h[N],tot;
int vis[N],p[N],now;
struct Node{
    int to,nxt;
}e[M];
void Add(int u,int v){
    tot++;
    e[tot].to=v;
    e[tot].nxt=h[u];
    h[u]=tot;
}
bool dfs(int u){
    if(vis[u]==now) return 0;
    vis[u]=now;
    for(int i=h[u];i;i=e[i].nxt){
        int v=e[i].to;
        if(!p[v]||dfs(p[v])){
            p[v]=u;
            return 1;
        }
    }
    return 0;
}
int main(){
    ios::sync_with_stdio(false);
    cin.tie(0),cout.tie(0);
    cin>>n;
    for(int i=1;i<=n;i++){
        int x,y;
        cin>>x>>y;
        Add(x,i);
        Add(y,i);
    }
    now=1;
    for(int i=1;i<=10000;i++,now++){
        if(dfs(i)) ans++;
        else break;
    }
    cout<<ans;
    return 0;
}

3. Luogu P5182 棋盘覆盖

  • 每个格子上最多放一个骨牌
  • 骨牌占用相邻两个格子

骨牌相当于相邻的两个格子连边。对上下左右相邻的格子建无向边。边只在相邻的点之间,此时如果我们对相邻格子染上不同的颜色(类似国际象棋棋盘),相同颜色的归为一个点集,可以发现是二分图。然后跑二分图最大匹配。时间复杂度 \(O(n^2m^2)\)

需要注意,如果我们对所有点连无向边,且对所有点跑匹配,最终一对匹配的两个点都会算上,答案需要 \(\div 2\)。如果只对一种颜色的格子向另一种颜色连有向边,匹配时也只跑一种颜色的格子,就不用担心这个问题,时空复杂度也更优。判断颜色的方式是 \(i+j\) 的奇偶性。

#include<bits/stdc++.h>
using namespace std;
const int N=1e5+10;
int n,t,ans;
int h[N],tot;
int a[110][110];
int p[N];
bitset<N> vis;
struct Node{
    int to,nxt;
}e[N];
void Add(int u,int v){
    tot++;
    e[tot].to=v;
    e[tot].nxt=h[u];
    h[u]=tot;
}
bool dfs(int u){
    if(vis[u]) return 0;
    vis[u]=1;
    for(int i=h[u];i;i=e[i].nxt){
        int v=e[i].to;
        if(!p[v]||dfs(p[v])){
            p[v]=u;
            return 1;
        }
    }
    return 0;
}
int main(){
    ios::sync_with_stdio(false);
    cin.tie(0),cout.tie(0);
    cin>>n>>t;
    for(int i=1;i<=t;i++){
        int x,y;
        cin>>x>>y;
        a[x][y]=1;
    }
    for(int i=1;i<=n;i++){
        for(int j=1;j<=n;j++){
            if(a[i][j]) continue;
            if(i<n&&!a[i+1][j]) Add((i-1)*n+j,i*n+j),Add(i*n+j,(i-1)*n+j);
            if(j<n&&!a[i][j+1]) Add((i-1)*n+j,(i-1)*n+j+1),Add((i-1)*n+j+1,(i-1)*n+j);
        }
    }
    for(int i=1;i<=n*n;i++){
        vis=0;
        if(dfs(i)) ans++;
    }
    cout<<ans/2;
    return 0;
}

4. Luogu P1129 [ZJOI2007] 矩阵游戏

根据交换操作:同一行的黑格,不论怎么交换,永远在同一行。同一列的同理。

最终目标:对角线为黑。即每一行、每一列都有一个黑格。即存在 \(n\) 个黑格,他们互不在一行、一列。

我们选中一个黑格,就不希望与他同行、同列的再被选。每一行、每一列只被一个黑格选中,类似二分图匹配。对于每个黑格,其横纵坐标分别作为二分图的左、右部点,黑格作边。若最后所有的横纵坐标均能匹配成功,则有解。否则无。

时间复杂度 \(O(n^2)\)

#include<bits/stdc++.h>
using namespace std;
int T,n;
int g[210][210];
int p[210];
bitset<210> vis;
bool dfs(int u){
    if(vis[u]) return 0;
    vis[u]=1;
    for(int i=1;i<=n;i++){
        if(g[u][i]){
            if(!p[i]||dfs(p[i])){
                p[i]=u;
                return 1;
            }
        }
    }
    return 0;
}
int main(){
    ios::sync_with_stdio(false);
    cin.tie(0),cout.tie(0);
    cin>>T;
    while(T--){
        cin>>n;
        for(int i=1;i<=n;i++){
            p[i]=0;
            for(int j=1;j<=n;j++){
                cin>>g[i][j];
            }
        }
        bool flag=1;
        for(int i=1;i<=n;i++){
            vis=0;
            if(!dfs(i)){
                flag=0;
                break;
            }
        }
        if(flag) cout<<"Yes\n";
        else cout<<"No\n";
    }
    return 0;
}

5. UVA1194 Machine Schedule

每个任务有两种选择,两种选择至少选一个,求最少选几个。

因此按两台机器的模式为点,任务为边,建立二分图。问题转化为二分图的最小点覆盖,选最少的点覆盖所有边,可以归约为最大匹配问题。

#include<bits/stdc++.h>
using namespace std;
const int N=210;
const int M=1e3+10;
int n,m,k;
int h[N],tot;
int p[N],ans;
bitset<N> vis;
struct Node{
    int to,nxt;
}e[M];
void Add(int u,int v){
    tot++;
    e[tot].to=v;
    e[tot].nxt=h[u];
    h[u]=tot;
}
bool dfs(int u){
    if(vis[u]) return 0;
    vis[u]=1;
    for(int i=h[u];i;i=e[i].nxt){
        int v=e[i].to;
        if(!p[v]||dfs(p[v])){
            p[v]=u;
            return 1;
        }
    }
    return 0;
}
int main(){
    ios::sync_with_stdio(false);
    cin.tie(0),cout.tie(0);
    while(cin>>n&&n){
        cin>>m>>k;
        ans=tot=0;
        memset(h,0,sizeof(h));
        memset(p,0,sizeof(p));
        for(int i=1;i<=k;i++){
            int x,u,v;
            cin>>x>>u>>v;
            Add(u,v+n);
        }
        for(int i=1;i<=n;i++){
            vis=0;
            if(dfs(i)) ans++;
        }
        cout<<ans<<'\n';
    }
    return 0;
}

6.Luogu P10939 骑士放置

一个格子若放了马,那其周围的八个日字上的格子都不能放。我们可以把这些格子之间连上边,此时变成一张二分图。要求最多能放多少个马,也就是最多多少个格子之间没有连边,也就是二分图最大独立集,用总点数(\(N\times M-T\))减去最大匹配。

不难发现,日字连边连接的两点,如果放在黑白相间的国际象棋棋盘上,两点的颜色一定不同。因此我们也可以只对一种颜色往另一种颜色连有向边,对一种颜色跑最大匹配。

#include<bits/stdc++.h>
using namespace std;
const int N=2e4+10;
const int M=2e5+10;
const int dx[9]={0,1,2,2,1,-1,-2,-2,-1};
const int dy[9]={0,2,1,-1,-2,-2,-1,1,2};
int n,m,t,ans;
int a[110][110];
int h[N],tot;
int vis[N],p[N],now;
struct Node{
    int to,nxt;
}e[M];
void Add(int u,int v){
    tot++;
    e[tot].to=v;
    e[tot].nxt=h[u];
    h[u]=tot;
}
int calc(int x,int y){
    return (x-1)*m+y;
}
bool dfs(int u){
    if(vis[u]==now) return 0;
    vis[u]=now;
    for(int i=h[u];i;i=e[i].nxt){
        int v=e[i].to;
        if(!p[v]||dfs(p[v])){
            p[v]=u;
            return 1;
        }
    }
    return 0;
}
int main(){
    ios::sync_with_stdio(false);
    cin.tie(0),cout.tie(0);
    cin>>n>>m>>t;
    for(int i=1;i<=t;i++){
        int x,y;
        cin>>x>>y;
        a[x][y]=1;
    }
    for(int i=1;i<=n;i++){
        for(int j=1;j<=m;j++){
            int xx,yy;
            if((i+j)%2||a[i][j]) continue;
            for(int k=1;k<=8;k++){
                xx=i+dx[k],yy=j+dy[k];
                if(xx>=1&&xx<=n&&yy>=1&&yy<=m&&!a[xx][yy]) Add(calc(i,j),calc(xx,yy));
            }
        }
    }
    now=1;
    for(int i=1;i<=n;i++){
        for(int j=1;j<=m;j++){
            if((i+j)%2||a[i][j]) continue;
            now++;
            if(dfs(calc(i,j))) ans++;
        }
    }
    cout<<n*m-t-ans;
    return 0;
}

7. Luogu P2764 最小路径覆盖问题

先考虑怎么用路径覆盖。一开始可以把每个点都看成一个路径,此时能覆盖全图。

然后,因为有向边的存在,路径之间可以拼接。但一个点可以合并两次,一次是与某一个出边连接的点所在的路径合并,一次是与某一个入边连接的点所在的路径合并。

这令我们非常不爽。因此直接把每个点 \(u\) 拆了,拆成一个入点 \(ul\) 和一个出点 \(ur\),专门连入边、出边。这样每个点只合并一次,就能实现路径的拼接。

于是路径就从这样:

pZbYqNF.png

变成了这样:

pZbYon0.png

此时一个点就只与一个点匹配了,未匹配的点是路径的端点。

一开始,路径个数是 \(n\)。每拼接两个路径,路径个数就 \(-1\)。要求最小路径覆盖,自然需要合并次数尽可能多,也就是两个点匹配尽可能多。于是我们一开始对整张无环图拆点,问题就转化为了以左部点为出点、右部点为入点的二分图最大匹配。最后答案即为 \(n-\) 匹配数。

最后考虑怎么输出这些路径。观察上图,没有匹配的入点所对应的出点一定是路径的起点。根据这个找到起点,搜到终点依次输出即可。

#include<bits/stdc++.h>
using namespace std;
const int N=310;
const int M=2e4+10;
struct Node{
    int to,nxt;
}e1[M],e2[M];
int n,m,ans;
int h1[N],h2[N],tot;
int p[N],ind[N];
bitset<M>vis;
void Add1(int u,int v){
    tot++;
    e1[tot].to=v;
    e1[tot].nxt=h1[u];
    h1[u]=tot;
}
void Add2(int u,int v){
    tot++;
    e2[tot].to=v;
    e2[tot].nxt=h2[u];
    h2[u]=tot;
}
bool dfs1(int u){
    if(vis[u]) return 0;
    vis[u]=1;
    for(int i=h1[u];i;i=e1[i].nxt){
        int v=e1[i].to;
        if(!p[v]||dfs1(p[v])){
            p[v]=u;
            return 1;
        } 
    }
    return 0;
}
void dfs2(int u){
    cout<<u<<' ';
    for(int i=h2[u];i;i=e2[i].nxt){
        int v=e2[i].to;
        dfs2(v);
    }
}
int main(){
    ios::sync_with_stdio(false);
    cin.tie(0),cout.tie(0);
    cin>>n>>m;
    for(int i=1;i<=m;i++){
        int u,v;
        cin>>u>>v;
        Add1(u,v+n);//1~n 出点,n+1~2n 入点
    }
    for(int i=1;i<=n;i++){
        if(dfs1(i)) ans++;
        vis=0;
    }
	tot=0;
    for(int i=n+1;i<=2*n;i++){
        if(p[i]){
            Add2(p[i],i-n);//将匹配的出入点用原结点重新建图
            ind[i-n]++;//统计入度
        }
    }
    for(int i=1;i<=n;i++){
        if(!ind[i]){//找到起点,搜索
            dfs2(i);
            cout<<'\n';
        } 
    }
    cout<<n-ans;
    return 0;
} 
posted @ 2025-12-12 22:50  Seqfrel  阅读(62)  评论(0)    收藏  举报