二分图与最大匹配

二分图

二分图是一种特殊的图。

该图被划分成两个部分,称为左部和右部。

特殊点在于,仅有左部与右部之间有连边,同属于一个部分的点之间不存在边。

这样就可以得到二分图的一些性质:

  • 二分图可以用两种颜色染色,并保证相邻的结点的颜色不同。

  • 二分图中不存在奇数长度的环。

对于二分图的判定,只需要简单的 dfs 或 bfs 染色即可。

不过,二分图最经典的问题是二分图最大匹配。

二分图最大匹配

设 \(G=(V,E)\) 是一张无向图,其中,\(V\) 是顶点集,\(E\) 是边集.如果一组边 \(M\subseteq E\) 不包含自环,且两两之间没有公共顶点,那么,边集 \(M\) 就称为图 \(G\) 的一个 匹配(matching)或 独立边集(independent edge set).

最大匹配即边集最大的匹配。

对于二分图的最大匹配问题,可以将其抽象成一个经典的模型:男女配对。

问题描述(抽象版)

假设左部为男生,右部为女生;而边连接两点,代表该男生和该女生有“暧昧关系”。

此处不考虑同性恋情况,且符合一夫一妻制

若你是红娘,可以对任意有“暧昧关系”的男女牵线搭桥,问最多能撮合出多少对情侣。

注:此处用男生匹配女生为例,同时记录女生匹配上了哪个男生。

牵线过程

每找一个男生,遍历所有与当前男生有“暧昧关系”的女生。

很直觉的一个事是,当前女生没有匹配过男生,就可以把当前男生和当前女生直接匹配起来。

但是若当前女生匹配过男生,那就直接跳过吗?

不!这是我们要去看,当前女生匹配的那个男生,能不能匹配上新的女生。

如果能,那那个男生就和新的女生匹配,当前男生就和当前女生匹配。

否则,当前男生继续考虑下一个有“暧昧关系”的女生。

这便是著名的 NTR 匈牙利算法。

理解之后,代码就简单了,实质上就是一个 dfs。

auto check = [&](auto &check, int now) -> bool
{
    for (auto p : e[now])
    { // 遍历边或右部
        if (vis[p])
            continue;
        vis[p] = 1;
        if (!pd[p] || check(check, pd[p]))
        { // 可匹配当前女生的两个条件
            pd[p] = now;
            return 1;
        }
    }
    return 0;
};

int ans = 0;
for (int i = 1; i <= nl; i++)
{ // 枚举左部
	vis.assign(nr + 7, 0);
	if (check(check, i))
		ans++;
}

最后 pd 数组即为匹配结果。

有的题不进行建图可能会跑的更快,或写法更简单,这点会在例题讲解时提到。

匈牙利算法的上界是 \(O(nm)\)。

另一种二分图最大匹配的解法是网络流中的 Dinic,复杂度为 \(O(\sqrt n\cdot m)\),详情可见这篇文章。

例题讲解

讲题之前,先说一个暴论

跟矩阵有关题可以考虑二分图匹配相关。

P1129

意识到,对于同一行的两个 \(1\),无论进行什么操作,最后还是会位于同一行。列同理。

那么题就可以转化为求最多有多少个不同行不同列的 \(1\)。

若我们选择了第 \(i\) 行第 \(j\) 列的 \(1\),则第 \(i\) 行和第 \(j\) 列的其他 \(1\) 均不可选。

即第 \(i\) 行和第 \(j\) 列匹配成功。

此时便很容易想到,用行对应左部,列对应右部,跑二分图匹配即可。

int n;
cin >> n;
vector<vector<int>> a(n + 1, vector<int>(n + 1));
for (int i = 1; i <= n; i++)
    for (int j = 1; j <= n; j++)
        cin >> a[i][j];

vector<int> pd(n + 1), vis;
auto check = [&](auto &check, int now) -> bool
{
    for (int i = 1; i <= n; i++)
    {
        if (!a[now][i] || vis[i])
            continue;
        vis[i] = 1;
        if (!pd[i] || check(check, pd[i]))
        {
            pd[i] = now;
            return 1;
        }
    }
    return 0;
};

int ans = 0;
for (int i = 1; i <= n; i++)
{
    vis.assign(n + 1, 0);
    if (check(check, i))
        ans++;
}
cout << (ans == n ? "Yes" : "No") << '\n';

P10937

若当第 \(i\) 行第 \(j\) 列放了一个車,第 \(i\) 行和第 \(j\) 列的其他位置都不能在放置任何車了。

同上,建图进行二分图匹配。

不过,特殊的是,此题的障碍代表断了一条边。

int n, m, t;
cin >> n >> m >> t;
vector<vector<int>> ban(n + 7, vector<int>(m + 7));
for (int i = 1; i <= t; i++)
{
    int x, y;
    cin >> x >> y;
    ban[x][y] = 1;
}

vector<int> pd(m + 7), vis;
auto check = [&](auto &check, int now) -> bool
{
    for (int p = 1; p <= m; p++)
    {
        if (ban[now][p] || vis[p])
            continue;
        vis[p] = 1;
        if (!pd[p] || check(check, pd[p]))
        {
            pd[p] = now;
            return 1;
        }
    }
    return 0;
};
int ans = 0;
for (int i = 1; i <= n; i++)
{
    vis.assign(m + 7, 0);
    if (check(check, i))
        ans++;
}
cout << ans << '\n';

P10939

首先,国际象棋的棋盘是黑白相交的。

简单分析可知,骑士一步能攻击到的位置,一定是从黑色块到白色块,或者从白色块到黑色块。

即相同色块之间不会连边,满足二分图性质。

那么就可以将黑色块看成左部,白色块看成右部。

一个骑士能攻击到的点最多有 \(8\) 个,也就是一个点最多发出 \(8\) 条边。

边数较少,可直接遍历而非建图。

int n, m, t;
cin >> n >> m >> t;

struct node
{
    int x, y;
    node() {}
    node(int x, int y) : x(x), y(y) {}
};
vector<vector<int>> ban(n + 7, vector<int>(m + 7));
for (int i = 1; i <= t; i++)
{
    int x, y;
    cin >> x >> y;
    ban[x][y] = 1;
}

auto id = [&](int x, int y) -> int
{
    return (x - 1) * m + y;
};
auto pos = [&](int id) -> node
{
    return node((id - 1) / m + 1, (id - 1) % m + 1);
};

array<int, 8> dx = {1, 1, 2, 2, -1, -1, -2, -2},
              dy = {2, -2, 1, -1, 2, -2, 1, -1};
vector<int> pd(n * m + 7), vis;
auto check = [&](auto &check, int now) -> bool
{
    auto [x, y] = pos(now);
    for (int i = 0; i < 8; i++)
    {
        int xx = x + dx[i], yy = y + dy[i];
        if (xx < 1 || xx > n || yy < 1 || yy > m || ban[xx][yy])
            continue;
        int p = id(xx, yy);
        if (vis[p])
            continue;
        vis[p] = 1;
        if (!pd[p] || check(check, pd[p]))
        {
            pd[p] = now;
            return 1;
        }
    }
    return 0;
};

int ans = 0;
for (int i = 1; i <= n; i++)
{
    for (int j = 1; j <= m; j++)
    {
        if (ban[i][j] || (i & 1) != (j & 1))
            continue;

        vis.assign(n * m + 7, 0);
        if (check(check, id(i, j)))
            ans++;
    }
}
cout << n * m - t - ans << '\n';

代码中有关键一句

if (ban[i][j] || (i & 1) != (j & 1))
	continue;

回忆之前的所有题目,不管是邻接表还是邻接矩阵,左右部的编号都是重复的。

而且边都只存在于左部指向右部。

这样就可以保证就算编号重复也不会混乱。

而 (i & 1) != (j & 1) 可以保证处理的一定是同一种颜色,也就是只处理左部。

双倍经验

posted @ 2026-04-22 14:04  Zvelig1205  阅读(20)  评论(0)    收藏  举报