第四课 【解雨臣的算法课】第四课:二分图与匈牙利算法——最优配对,谁也不落空

【解雨臣的算法课】第四课:二分图与匈牙利算法——最优配对,谁也不落空

“下斗组队,最怕的就是有人闲着,有人累死。好的领队,得把活儿分明白。”
——解雨臣


一、上节课回顾

上一课我们讲了前缀和与差分——用空间换时间,提前算好别现算。

胖子用差分算了一回机关次数,十分钟就搞定了,而之前他打算一个一个格子去数。

胖子:“原来提前算好这么爽!”
黑瞎子:“那当然,这叫预处理。”
胖子:“那我以后啥都先算一遍。”
我:“你内存够吗?”

今天我们讲一个配对的利器——二分图与匈牙利算法


二、故事引入:解雨臣分任务

有一次下斗,我们仨遇到了一排机关。

n 个机关,每个机关只能由特定的人解开:

  • 解雨臣:能解机关 1、2、3
  • 黑瞎子:能解机关 2、4
  • 王胖子:能解机关 3、5

每个机关只需要一个人解,每个人最多解一个机关。

问:最多能解开几个机关?

胖子说:“这简单!我先挑最难的解,剩下的你们分。”

黑瞎子说:“你那个算法不行,万一你抢了别人的关键机关呢?”

我说:“这就是二分图最大匹配问题。”


三、什么是二分图?

3.1 定义

二分图:图中的点可以分成两个集合 UV,使得所有边都连接 UV 之间的点,集合内部没有边。

U集合(人)        V集合(机关)
  解雨臣  ────────  机关1
         ────────  机关2
         ────────  机关3
  
  黑瞎子  ────────  机关2
         ────────  机关4
  
  王胖子  ────────  机关3
         ────────  机关5

3.2 二分图的判定

一个图是二分图 \iff 可以用两种颜色染色,使得相邻点颜色不同。

#include<bits/stdc++.h>

using namespace std;

const int N = 100010;

int n;
int m;
vector <int> g[N];
int color[N];

bool dfs (int u, int c) 
{
    color[u] = c;
    
    for (int v : g[u]) 
    {
        if (color[v] == c) 
        {
            return false;
        }
        
        if (color[v] == 0 && !dfs (v, 3 - c)) 
        {
            return false;
        }
    }
    
    return true;
}

四、什么是匹配?

4.1 基本概念

匹配:一组边,其中任意两条边没有公共顶点。

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

完美匹配:所有顶点都被匹配。

4.2 例子

初始:所有点未匹配

匹配1:解雨臣-机关1,黑瞎子-机关2  → 2条边(不是最大)
匹配2:解雨臣-机关2,黑瞎子-机关4,胖子-机关3 → 3条边(最大)

五、匈牙利算法

5.1 算法思想

核心思想:尝试给每个左部点找对象,如果冲突了,就让已经匹配的点尝试换一个

解雨臣说:“这就像相亲——你先约一个,如果对方已经被约了,就让那个男生换个女生,腾出位置给你。”

5.2 算法步骤

  1. 遍历左部每个点 u
  2. u 尝试匹配:
    • 遍历 u 的所有邻居 v
    • 如果 v 没匹配,直接匹配
    • 如果 v 已匹配,尝试让匹配 v 的点 match[v] 重新匹配(DFS)
  3. 如果找到增广路,匹配数 +1

5.3 代码模板

#include<bits/stdc++.h>

using namespace std;

const int N = 510;

int n1;
int n2;
int m;
vector <int> g[N];
int match[N];
bool vis[N];

bool dfs (int u) 
{
    for (int v : g[u]) 
    {
        if (vis[v]) 
        {
            continue;
        }
        
        vis[v] = true;
        
        if (match[v] == 0 || dfs (match[v])) 
        {
            match[v] = u;
            return true;
        }
    }
    
    return false;
}

int main () 
{
    cin >> n1 >> n2 >> m;
    
    for (int i = 1; i <= m; i++) 
    {
        int u;
        int v;
        
        cin >> u >> v;
        g[u].push_back (v);
    }
    
    int ans = 0;
    
    for (int i = 1; i <= n1; i++) 
    {
        memset (vis, 0, sizeof (vis));
        
        if (dfs (i)) 
        {
            ans++;
        }
    }
    
    cout << ans << endl;
    
    return 0;
}

5.4 复杂度分析

  • 每次 DFS 最坏 O(E)
  • 总共 n_1 次 DFS
  • 总复杂度:O(n_1 \times E)
  • 邻接表实现,实际运行很快

六、经典例题:解雨臣的相亲大会

题目背景

解家要办相亲大会,有 n 个男生和 m 个女生。

每个男生有一个心仪女生列表。

每个女生只能和一个男生配对。

问:最多能配对多少对?

代码实现

#include<bits/stdc++.h>

using namespace std;

const int N = 1010;

int n;
int m;
int k;
vector <int> g[N];
int match[N];
bool vis[N];

bool dfs (int u) 
{
    for (int v : g[u]) 
    {
        if (vis[v]) 
        {
            continue;
        }
        
        vis[v] = true;
        
        if (match[v] == 0 || dfs (match[v])) 
        {
            match[v] = u;
            return true;
        }
    }
    
    return false;
}

int main () 
{
    cin >> n >> m >> k;
    
    for (int i = 1; i <= k; i++) 
    {
        int u;
        int v;
        
        cin >> u >> v;
        g[u].push_back (v);
    }
    
    int ans = 0;
    
    for (int i = 1; i <= n; i++) 
    {
        memset (vis, 0, sizeof (vis));
        
        if (dfs (i)) 
        {
            ans++;
        }
    }
    
    cout << ans << endl;
    
    return 0;
}

七、经典例题2:解雨臣的棋盘覆盖

题目背景

有一个 n \times n 的棋盘,某些格子有障碍物。

要用 1 \times 2 的多米诺骨牌覆盖棋盘(骨牌可以横着放或竖着放)。

问:最多能放多少块骨牌?

思路分析

关键转化:棋盘黑白染色!

  • 把棋盘按 (i+j) 的奇偶性分成黑格和白格
  • 每块骨牌必然覆盖一个黑格和一个白格
  • 这就变成了二分图匹配问题:
    • 左部:黑格
    • 右部:白格
    • 相邻的格子连边(上下左右)
  • 最大匹配数 = 最多骨牌数

代码实现

#include<bits/stdc++.h>

using namespace std;

const int N = 110;

int n;
int m;
bool block[N][N];
int id[N][N];
vector <int> g[N * N];
int match[N * N];
bool vis[N * N];
int dx[4] = {1, -1, 0, 0};
int dy[4] = {0, 0, 1, -1};

bool dfs (int u) 
{
    for (int v : g[u]) 
    {
        if (vis[v]) 
        {
            continue;
        }
        
        vis[v] = true;
        
        if (match[v] == 0 || dfs (match[v])) 
        {
            match[v] = u;
            return true;
        }
    }
    
    return false;
}

int main () 
{
    cin >> n >> m;
    
    for (int i = 1; i <= m; i++) 
    {
        int x;
        int y;
        
        cin >> x >> y;
        block[x][y] = true;
    }
    
    int cnt = 0;
    
    for (int i = 1; i <= n; i++) 
    {
        for (int j = 1; j <= n; j++) 
        {
            if (!block[i][j]) 
            {
                id[i][j] = ++cnt;
            }
        }
    }
    
    for (int i = 1; i <= n; i++) 
    {
        for (int j = 1; j <= n; j++) 
        {
            if (block[i][j] || (i + j) % 2 == 0) 
            {
                continue;
            }
            
            int u = id[i][j];
            
            for (int k = 0; k < 4; k++) 
            {
                int x = i + dx[k];
                int y = j + dy[k];
                
                if (x >= 1 && x <= n && y >= 1 && y <= n && !block[x][y]) 
                {
                    int v = id[x][y];
                    g[u].push_back (v);
                }
            }
        }
    }
    
    int ans = 0;
    
    for (int i = 1; i <= cnt; i++) 
    {
        memset (vis, 0, sizeof (vis));
        
        if (dfs (i)) 
        {
            ans++;
        }
    }
    
    cout << ans << endl;
    
    return 0;
}

八、二分图的其他应用

问题类型 转化方法
最大匹配 匈牙利算法
最小点覆盖 最大匹配 = 最小点覆盖(König定理)
最大独立集 总点数 - 最小点覆盖
最小路径覆盖 总点数 - 最大匹配(DAG)
棋盘覆盖 黑白染色转二分图

König定理

在二分图中,最大匹配数 = 最小点覆盖数

最小点覆盖:选最少的点,使得所有边至少有一个端点被选。

最大独立集:选最多的点,使得任意两点之间没有边。
$$\text{最大独立集} = \text{总点数} - \text{最小点覆盖} = \text{总点数} - \text{最大匹配}$$


九、解雨臣踩过的坑

坑1:vis 数组每轮重置

// 错误:vis 只重置一次
bool vis[N];
for (int i = 1; i <= n1; i++) 
{
    dfs(i);  // 第二次 dfs 时 vis 还是 true
}

// 正确:每轮重置
for (int i = 1; i <= n1; i++) 
{
    memset(vis, 0, sizeof(vis));
    dfs(i);
}

解家箴言vis 标记的是本轮尝试中访问过的右部点,每轮都要重置。

坑2:match 数组含义混淆

match[v] = u;  // v是右部点,u是左部点

解家箴言:统一方向,别混了。通常 match[右部点] = 左部点

坑3:图不是二分图

匈牙利算法只适用于二分图。如果不是二分图,需要其他算法(如带花树)。
解家箴言:先用染色法确认是不是二分图。

坑4:左右部点数不对称

// 只从左部出发
for (int i = 1; i <= n1; i++) 
{
    dfs(i);
}

解家箴言:只遍历左部点,右部点不主动发起匹配。


十、课后习题(解家的考验)

基础题

  1. 最大匹配:给定二分图,求最大匹配数。(直接套模板)

  2. 课程表:有 n 门课,m 个老师。每门课需要一个老师,每个老师最多教一门课。已知每个老师能教的课程列表,求最多能开多少门课。

进阶题

  1. 解雨臣的保镖:有 n 个任务和 m 个保镖。每个保镖只能保护特定的一些任务。但有个限制:如果一个保镖被分配了任务,他旁边的保镖(编号相邻)就不能再分配任务。求最多能完成多少任务。(提示:把保镖按奇偶分成两组)

  2. 棋盘放置:在 n \times n 的棋盘上放车(象棋里的车),要求它们互不攻击。某些格子不能放。求最多能放多少个车。(提示:每行每列最多一个,行和列建二分图)

挑战题(来自青铜门的考验)

  1. 解雨臣的密码锁:有一个 n \times m 的密码盘,每个格子是 0 或 1。每次操作可以翻转一行或一列的所有格子。问能否通过若干次操作,让所有格子都变成 0?如果能,最少需要多少次操作?

提示:设 x_i 表示第 i 行是否翻转,y_j 表示第 j 列是否翻转。每个格子 (i,j) 的最终值是 a_{i,j} \oplus x_i \oplus y_j。要让它等于 0,即 x_i \oplus y_j = a_{i,j}。这就变成了二分图染色问题。


十一、下集预告

“匹配就像下斗组队——每个人都有自己的特长,得找到最合适的搭档,才能发挥最大作用。”
——解雨臣

下一课,最短路算法——如何在墓室里找到最快的逃生路线。

胖子总爱乱跑,每次都找不到出口。学了这课,他就能自己找到路了。

下课。


(本系列课程由解雨臣独家赞助,信友队论坛首发。转载需注明出处。)

1 个赞

厉害

1 个赞