匈利亚算法的实现

在这里插入图片描述
导读:什么是最大匹配?
要了解匈牙利算法必须先理解下面的概念:

匹配:在图论中,一个「匹配」是一个边的集合,其中任意两条边都没有公共顶点。

最大匹配:一个图所有匹配中,所含匹配边数最多的匹配,称为这个图的最大匹配。

下面是一些补充概念:

完美匹配:如果一个图的某个匹配中,所有的顶点都是匹配点,那么它就是一个完美匹配。

交替路:从一个未匹配点出发,依次经过非匹配边、匹配边、非匹配边…形成的路径叫交替路。

增广路:从一个未匹配点出发,走交替路,如果途径另一个未匹配点(出发的点不算),则这条交替 路称为增广路(agumenting path)。

匈牙利算法
不讲算法证明(我也不会)。

用一个转载的例子来讲解匈牙利算法的流程。

代码实现匈牙利算法
首先是存图模板

//邻接表写法,存稀疏图
int h[N],ne[N],e[N],idx;
//n1,n2分别是两个点集的点的个数
int n1,n2,m;
void add(int a , int b)
{
e[idx] = b, ne[idx] = h[a], h[a] = idx++;
}
void init()
{
memset(h,-1,sizeof h);
}
//存边只存一边就行了,虽然是无向图。
for(int i = 0 ; i < n1 ; i ++)
{
int a,b;
cin>>a>>b;
add(a,b);
}
接下来看算法模板(c++)

//match[j]=a,表示女孩j的现有配对男友是a
int match[N];
//st[]数组我称为临时预定数组,st[j]=a表示一轮模拟匹配中,女孩j被男孩a预定了。
int st[N];

//这个函数的作用是用来判断,如果加入x来参与模拟配对,会不会使匹配数增多
int find(int x)
{
//遍历自己喜欢的女孩
for(int i = h[x] ; i != -1 ;i = ne[i])
{
int j = e[i];
if(!st[j])//如果在这一轮模拟匹配中,这个女孩尚未被预定
{
st[j] = true;//那x就预定这个女孩了
//如果女孩j没有男朋友,或者她原来的男朋友能够预定其它喜欢的女孩。配对成功,更新match
if(!match[j]||find(match[j]))
{
match[j] = x;
return true;
}

    }
}
//自己中意的全部都被预定了。配对失败。
return false;

}

//记录最大匹配
int res = 0;
for(int i = 1; i <= n1 ;i ++)
{
//因为每次模拟匹配的预定情况都是不一样的所以每轮模拟都要初始化
memset(st,false,sizeof st);
if(find(i))
res++;
}
下面用一个gif动图来演示这个整个配对的递归过程:

练习例题: 二分图的最大匹配

AC代码

#include
#include
using namespace std;
const int N = 510 , M = 100010;
int n1,n2,m;
int h[N],ne[M],e[M],idx;
bool st[N];
int match[N];

void add(int a , int b)
{
e[idx] = b, ne[idx] = h[a], h[a] = idx++;
}

void init()
{
memset(h,-1,sizeof h);
}

int find(int x)
{
//遍历自己喜欢的女孩
for(int i = h[x] ; i != -1 ;i = ne[i])
{
int j = e[i];
if(!st[j])//如果在这一轮模拟匹配中,这个女孩尚未被预定
{
st[j] = true;//那x就预定这个女孩了
//如果女孩j没有男朋友,或者她原来的男朋友能够预定其它喜欢的女孩。配对成功
if(!match[j]||find(match[j]))
{
match[j] = x;
return true;
}

    }
}
//自己中意的全部都被预定了。配对失败。
return false;

}
int main()
{
init();
cin>>n1>>n2>>m;
while(m–)
{
int a,b;
cin>>a>>b;
add(a,b);
}

int res = 0;
for(int i = 1; i <= n1 ;i ++)
{  
     //因为每次模拟匹配的预定情况都是不一样的所以每轮模拟都要初始化
      memset(st,false,sizeof st);
    if(find(i)) 
      res++;
}  

cout<<res<<endl;

}
让我们仔细看一下 find(x) 函数。我们为男生 x 找/换一个对象,尝试将女生 j 预订给他。因此,用 st[j] = true 记录。这样其他男生不会同时尝试匹配女生 j。同学们不难意识到这和 DFS 中的记录数组的作用差不多。简单来说,避免搜索过程构成环路,导致无限循环和冲突。但是,当 if (match[j] == 0 || find(match[j])) 中 find 函数递归结果返回后,为什么不需要像很多 DFS 题目那样用 st[j] = false 回溯呢?(也即是说,将女生 j 匹配给男生 x 失败,取消预订)

如果我们使用 st[j] = false 进行回溯,算法其实仍然是正确的。但是复杂度会变成指数级。

实际上,标准模板中的 st 数组是两个不同记录数组和合并。st[a] = true 实际上不仅仅表示女生 a 目前被一个男生预定了。下面的代码中,我们将会把 st 数组拆分成两个不同的数组,请尤其注意用 *** 做注释的地方。

#include <bits/stdc++.h>
using namespace std;
const int N = 510;
int n1, n2, m; // 有 n1 个男生和 n2 个女生 (n1 ≤ 500, n2 ≤ 500)。
// 他们之间可以匹配的关系有 m 个 (m ≤ 1e5)。

vector g[N]; // g[a] 为一个动态数组vector,储存了男生 a 可以匹配的所有女生。

int match[N]; // match[a] = b: 女生 a 目前匹配了男生 b
bool st1[N]; // st[a] = true 说明女生 a 目前被一个男生预定了
bool st2[N]; // st[a] = true 我们曾经尝试为已经有男朋友的女生 a 换对象,但是失败了

bool find(int x) { // 为单身狗 x 找一个对象, (或) x的女朋友被别人预定,给x换一个对象
// 如果成功,返回true
for (int j: g[x]) { // j 是可以与男生 x 匹配的女生之一

//*** // 我们曾经尝试为已经有男朋友的女生 j 换对象,但是失败了。
// 我们可以由此声称之后永远无法成功(后文详证),所以跳过以避免重复运算
if(st2[j]) continue;

    if (st1[j]) continue; // 女生 j 目前被一个男生预定了,跳过
    st1[j] = true; // 将女生 j 预定给男生 x

    // 如果女生 j 没有对象, 或者
    // 女生 j 在前几轮深搜中已预定有对象,但我们成功给她的对象换了个新对象
    if (match[j] == 0 || find(match[j])) {
        match[j] = x;

//*** // 我们有了 st2 用来记录换对象失败的女生。这里 st1 回溯不影响复杂度
st1[j] = false; // 回溯
return true;
}

//*** // 我们有了 st2 用来记录换对象失败的女生。这里 st1 回溯不影响复杂度
st1[j] = false; // 回溯
st2[j] = true;
}
return false;
}

int main() {
ios::sync_with_stdio(0), cin.tie(0), cout.tie(0);
cin >> n1 >> n2 >> m;
for (int i = 1; i <= m; i++) {
int a, b;
cin >> a >> b;
g[a].push_back(b); // 读取一条数据,男生 a 可以匹配女生 b
}

int res = 0;
for (int i = 1; i <= n1; i++) { // 尝试为每个男生做一轮深搜找对象(要求成功后总匹配数增加1)。

//*** // st2 不需要重置,因为换对象失败一次的女生到程序结束都不可能成功换对象
// 对于所有的预定 st1[j] = true,我们都用 st1[j] = false 回溯了。st1本身就是默认状态。
// 所以和标准模板不同,st1 也不需要重置。
if (find(i)) res++;
}

cout << res;
return 0;

}
上面的代码中,我们将 st 数组拆分成了两个数组。st1 将女生暂时预定给一个男生,st2 则记录所有换对象失败的女生。在标准模板中,st 实际上同时起到了 st1 和 st2 两个数组的两种作用。

不难发现,为了证明算法的正确性,我们要证明:换对象失败了一次的女生,到程序结束也都不可能成功换对象。

让我们想想,什么时候我们能够成功给一个有对象的女生 B 换对象?在我们的算法中,我们可能尝试给一个男生 A 找对象,然后我们试图将男生 A 匹配给女生 B。但女生 B 已经有对象男生 C,所以我们又尝试为男生 C 换女生 D 作新对象… 直到我们为男生 M 换一个新对象:一个目前单身的女生 N。整个交换流程终止于一个单身女生,这样我们才成功为有对象的女生 B 换了对象。

最重要的两个要点:

  1. 匈牙利算法中,一个有伴侣的人,无论男女,不会重新变成单身狗
  2. 若我们尝试给一个有对象的女生换个对象,如果成功,整个交换链条终止于一个单身女性。

根据 2,如果给女生 B 换对象失败,说明经过她搜索不到结束于一个单身女性的交换链条。我们只有等到一个新的单身女性出现才可能由失败转向成功。又根据 1,不会凭空出现一个新的单生狗,所以交换链条永远等不到一个新的单身女性。

由此,换对象失败了一次的女生,到程序结束也都不可能成功换对象。我们可以跳过她们避免重复运算。这样就证明了上面的代码的算法正确性。

当然,上面的口胡也不完全严谨,只要能理解关键就行。

合并 st1 和 st2
不难发现,我们可以简单地合并 st1 和 st2 为 st 数组。

  1. 删除所有含 st2 的代码行
  2. 删除所有 st1 的回溯
  3. 由于 st1 没有回溯,main()中要重置 st1
  4. 将 st1 更名为st

不难理解,合并前与合并后,算法逻辑没有区别。这样我们就得到了标准模板。相信读者已经理解为什么标准模板不需要回溯 st 了。

补充
实际上,合并前与合并后,算法逻辑是有一点点小区别的。举例来说,假设合并后,st[a] = true 此时表示我们尝试为女生 a 换对象但失败了。我们知道换对象失败一次的女生到程序结束都不可能成功换对象。但是,main 函数中会将 st 重置。这显然导致合并后比合并前多了一些重复计算。好在这种重复运算相对较少,不影响复杂度。

此外,我还有一篇以增广路角度来看这个问题的笔记,对复杂度的计算进行了一些简单的讨论,不过没必要看。

https://www.acwing.com/solution/content/54355/

算法增强
复杂度实际上为 O(n12×n2)O(n12×n2),这表明当两侧的点的数量相差悬殊时,应该选择点较少的一侧作为男生。
算法的本质是找增广路,最多找nn次就够了。这表明在运行本算法之前,可以先做一次较优的匹配。这将极大减少存在的增广路,而且这并不复杂(遍历一遍每个男生,如果存在还没匹配过的可以匹配的女生就匹配下。显然,这个预处理复杂度为O(n^2)。随后main方法中的for循环不应该对已经有匹配的男生进行深搜,需要跳过,因为进行深搜的前提是该男生是单生狗,否则匹配数不会增加)。
本题使用网络流的解法可以达到理论更低的复杂度,但由于常数较大,所以实际上一般效率差不多。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值