算法简述
本章主要内容转自:blog.csdn.net/xuezhongfen…
核心思想还是找增广路。
假设已经匹配好了一堆点,我们从一个没有匹配的节点s开始,使用BFS生成搜索树。每当发现一个节点u,如果u还没有被匹配,那么就可以进行一次成功的增广;否则,我们就把节点u和它的配偶v一同接到树上,之后把v丢进队列继续搜索。我们给每个在搜索树上的点一个类型:S或者T。我们把u标记为T型,v标记为S型。于是,搜索树的样子是这样的:
其中,实线相连的两个点是已经匹配好的;虚线表示两个点之间有边,但是没有配对。T型的用红色,S型的用黑色。在增广的过程中,带花树算法只会对S型节点做BFS。
这里有个小问题,一个S型点d在某一步扩展的时候发现了点u,如果u已经在搜索树上了(即,出现了环),怎么办?
我们规定,如果u的类型是T型,就无视这次发现(这意味着我们找到了一个长度为偶数的环,直接无视)
否则,我们找到了一个长度为奇数的环,就要进行一次“缩花”的操作!
所谓缩花操作,就是把这个环缩成一个点。缩点完成之后,还要把原来环里面的T型点统统变成S型点,之后扔到队列里去。
为什么能缩成一个点呢?我们看一个长度为奇数的环(如上图中的s-a-c-f-j-d-b),如果我们能够给它中的任意一个点找一个出度(配偶),那么环中的其他点正好可以配成对,这说明,每个点的出度都是等效的。例如,假设我们能够给图中的点d另找一个配偶(例如d'好了),那么,环中的剩下6个点正好能配成3对,一个不多,一个不少(算上d和d'一共4对刚刚好)。
如下图中蓝线所示,形成了(d,d') (a,c) (f,j) (s,b) 4对。
这就是我们缩点的思想来源。有一个劳苦功高的计算机科学家证明了:缩点之前和缩点之后的图是否有增广路的情况是相同的。 缩起来的点又叫一朵花(blossm)。注意到,组成一朵花的里面可能嵌套着更小的花。
当我们最终找到一条增广路的时候,要把嵌套着的花层层展开,还原出原图上的增广路出来。
为何在意奇环,忽略偶环?
为什么要在意奇环呢?我们假设遇到了如下图(a)中的奇环。
我们来看奇环对S型点d的影响,如图(b)。如果没有(d,j)带来的环,s只能沿着路径A(s-b-d...)进行增广。而(d,j)则使得s还能沿着路径B(s-a-c-f-j-d...)进行增广。
我们再来看奇环对T型点b的影响,如图(c)。点b和点d是匹配的,本来并不会处理b的其他未匹配边,比如(b,b'),因为(s-b-b')这条路径,明显无法扩展成增广路。但是,(d,j)带来的环,却使得沿着路径C,将可能存在包含边(b,b')的增广路。本来点b是不在bfs的队列中的,在遇到奇环缩花后,需要加入bfs队列。
总之,奇环的出现,使得奇环上的点,出现了从环的另一半进行增广的可能。 此时,奇环上的所有T型点,要变成S型点,并加入bfs队列。
为什么忽略偶环,此时也和明显了。如上文中的偶环所示,(c,f)和(f,d)均是未匹配边,偶环上的点无法沿着环的另一半进行增广。
代码详解
本章代码主要来自:lunatic.blog.csdn.net/article/det…
原文是c++,改成了java。
各变量和数组的含义。
int n; //图的节点数
List<Integer> e[n]; //e[x]中存储着和点x有边相连的其他点
Queue<Integer> queue; //BFS的队列
int match[n]; //记录匹配结果,match[a] = b 表示a和b匹配,并且同时应有match[b] = a
char mark[n]; //标记S/T型节点
int belong[n]; //维护并查集,用来表征某些点属于同一朵花
int visit[n]; //用于LCA(Least Common Ancestors,最近公共祖先),记录节点是否被访问过
int next[n]; //增广时,记录该次增广生成的BFS搜索树中的未匹配边
有三个数组都记录了边的信息
- e[n]: 记录了整个图
- match[n]: 记录了匹配的边
- next[n]: 记录了增广时BFS搜索树中的未匹配边(该数组的作用会在下文的代码解释中体现出来)
从未匹配的点开始,进行增广
void match() {
for (int i = 0; i < n; i++) if (match[i] == -1) aug(i);
}
增广
/**
* 以s为起点寻找增广路
*/
void aug(int s) {
//reset
for (int i = 0; i < n; i++) {
queue.clear();
next[i] = -1;
belong[i] = i;
mark[i] = 0;
}
mark[s] = 'S';
queue.offer(s);
//如果s未找到匹配点(即match[s] == -1),并且queue中还有点,则继续循环
while (match[s] == -1 && !queue.isEmpty()) {
int x = queue.poll();
for (int y : e[x]) {
if (match[x] == y) continue; // x与y已匹配,忽略
if (findb(x) == findb(y)) continue; // x与y同在一朵花,忽略
if (mark[y] == 'T') continue; // y是T型点,忽略
if (mark[y] == 'S') { // y是S型点,奇环缩花
int r = LCA(x, y); // r为从i和j到s的路径上的第一个公共节点
if (findb(x) != r) next[x] = y; // r和x不在同一个花朵,next标记花朵内路径
if (findb(y) != r) next[y] = x; // r和y不在同一个花朵,next标记花朵内路径
// 将整个r -- x - y --- r的奇环缩成点,r作为这个环的标记节点
group(x, r); // 缩路径r --- x为点
group(y, r); // 缩路径r --- y为点
} else if (match[y] == -1) { // y自由,增广
next[y] = x;
for (int n1 = y; n1 != -1; ) { // 交叉链取反(交叉链即指匹配边和未匹配边交替的路径)
int n2 = next[n1];
int n3 = match[n2];
match[n2] = n1;
match[n1] = n2;
n1 = n3;
}
break; // 增广成功,退出循环将进入下一阶段
} else { // y不自由,当前搜索的交叉链+y+match[y]形成新的交叉链,将match[y]加入队列作为待搜节点
next[y] = x;
queue.offer(match[y]);
mark[match[y]] = 'S'; // match[y]标记为S型
mark[y] = 'T'; // y标记成T型
}
}
}
}
可以看到,增广的主要过程,其实就是对S型节点进行BFS的过程。在遍历S型点的相连点时,根据不同情况做不同处理。
这里有个“交叉链”的说法,指的是在寻找增广路的过程中,形成的 匹配边 和 非匹配边 交替相连的路径。
我们对其中的几种情况进行进一步讨论。
- x与y已匹配
x是一个S型节点,是作为点y的匹配点被加入queue的。在遍历x的相连点时,也会遍历到点y。
- y不自由
x遍历到了相连点y,y已经和b匹配了。 这里做了几个操作。
1)将match[y],也就是b标记为S型;
2)将y标记为T型;
3)将b加入队列,坐等bfs;
4)将y指向x,next[y] = x(next数组维护了交叉链中非匹配边的指向信息,next在缩花、增广、LCA中都有用)。
- y自由,增广
增广前,搜索树的状态如下所示(这里将next数组补全)。
代码实际上就是根据n1,通过next数组取到n2,通过match数组取到n3。然后匹配(n1, n2),最后将n3作为新的n1,继续下一轮处理。
其实,(n1, n2, n3) 就像是搜索树的一个基本组成结构。这个基本结构在缩花和LCA中都会用到。
- y是S型节点,缩花。缩花涉及到的内容较多。
缩花
非匹配边(x,y)导致了环,搜索树如下图所示。橙色的线是next数组标识的指向关系,遇到环前,next数组都还是从树的下面指向上面的。
缩花的步骤:
- 找到d和j的最近公共祖先,这里是s(LCA);
- 用next维护d和j的非匹配边;
- 缩路径s --- j为点;
- 缩路径s --- d为点。
先看LCA。
/**
* 寻找点x和y的最近公共祖先
*/
int LCA(int x, int y) {
//reset
for (int i = 0; i < n; i++) {
visit[i] = -1;
}
while (true) {
if (x != -1) {
x = findb(x); // 点要对应到花上去
if (visit[x] == 1)
return x;
visit[x] = 1;
if (match[x] != -1) {
x = next[match[x]]; //往上找一步
} else x = -1;
}
//swap x and y
int tmp = x;
x = y;
y = tmp;
}
}
LCA的思路,其实是先往上找x的祖先,只找一步;然后再往上找y的祖先,也只找一步。如此交替的往上一步一步寻找。若x和y有公共祖先的话,必会出现x先访问到该祖先,然后y再次访问到(或是y先访问到该祖先,然后x再次访问到)。这样,第一个访问了两次的节点,就是公共祖先。
这里的“只找一步”,和上文中提到的(n1, n2, n3)有关。如下图,x = next[match[x]],其实就是根据n1获得n3。可以看到这里跳过了n2,因为在搜索树中,n2的孩子只会有n1一个,不可能是公共祖先。
再看next的维护。
next[x] = y
next[y] = x
这样的维护方式,和match数组一个样了。
搜索树如下图。虽说(d,j)这条边是用两条剪头线互指的,但其实在数据结构上,跟(a, c)这样的边没差了。
最后看group。
/**
* 缩花
*/
void group(int n1, int s) {
//每轮循环将n3作为并查集的根
//最后的n3将会是s,也即缩花后,会将s作为该朵花的根
//这是必要的,因为s是这朵花在这颗树上,和上面那些节点的唯一衔接点,这将在LCA中起到作用
while (n1 != s) {
int n2 = match[n1], n3 = next[n2];
// _next数组是用来标记花朵中的路径的,综合match数组来用,实际上形成了双向链表
if (findb(n3) != s) next[n3] = n2;
queue.offer(n2);
mark[n2] = 'S'; //n2必是S型点,n3必是T型点
unit(n1, n2);
unit(n2, n3); //这两步unit()使得n1和n2在并查集中的根都成为了n3
n1 = n3;// 将n3变为n1,进行下一步循环
}
}
group也是基于(n1, n2, n3)这个基本结构来做的。在group的过程中又进一步维护了next数组,最后形成的搜索树结构如下图。
小结
在尝试从一个新的未匹配点进行增广时,我们使用match和next数组维护了该次bfs过程中所形成的搜索树。其中,next数组在LCA、group和成功增广时,都辅助用来往前追溯节点。而遇到奇环时,因为奇环从顺逆两个时钟方向,都可能成功增广,所以,next协同match构成了一个双向链表。
完整代码
java版。
这里的输入和输出是基于uoj上的一般图最大匹配。
在修改代码的过程中犯过三个错误。
- 初始化的时候,遇到一对点,需要双向构建match;
- 对新的未匹配点增广时,需要清空Queue;
- vist得在每次LCA的时候就reset,而不是每次aug。
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;
import java.util.Queue;
public class Solution {
public static void main(String[] args) throws IOException {
TreeWithBlossomMain treeWithBlossomMain = new TreeWithBlossomMain();
treeWithBlossomMain.init();
treeWithBlossomMain.match();
treeWithBlossomMain.print();
}
static class TreeWithBlossomMain {
int n; //图的节点数
List<Integer> e[]; //e[x]中存储着和点x有边相连的其他点
Queue<Integer> queue; //BFS的队列
int match[]; //记录匹配结果,match[a] = b 表示a和b匹配,并且同时应有match[b] = a
char mark[]; //标记S/T型节点
int belong[]; //维护并查集,用来表征某些点属于同一朵花
int visit[]; //用于LCA(Least Common Ancestors,最近公共祖先),记录节点是否被访问过
int next[]; //增广时,记录该次增广生成的BFS搜索树中的未匹配边
/**
* 从stdin读取输入,初始化图,并为数组分配空间
*/
void init() throws IOException {
BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));
String[] str = reader.readLine().split(" ");
n = Integer.parseInt(str[0]);
int m = Integer.parseInt(str[1]);
e = new List[n];
for (int i = 0; i < n; ++i) {
e[i] = new ArrayList<>();
}
for (int i = 0; i < m; ++i) {
str = reader.readLine().split(" ");
int a = Integer.parseInt(str[0]) - 1;
int b = Integer.parseInt(str[1]) - 1;
e[a].add(b);
e[b].add(a);
}
queue = new LinkedList<>();
next = new int[n];
belong = new int[n];
mark = new char[n];
match = new int[n];
visit = new int[n];
for (int i = 0; i < n; i++) match[i] = -1;
}
/**
* 输出结果
*/
void print() {
int count = 0;
for (int i = 0; i < n; ++i) {
if (match[i] > i) { //>i,防止将一个匹配计算两次
count++;
}
}
System.out.println(count);
for (int i = 0; i < n; ++i) {
System.out.print(match[i] + 1 + " ");
}
}
/**
* 从未匹配的节点开始增广
*/
void match() {
for (int i = 0; i < n; i++) if (match[i] == -1) aug(i);
}
/**
* 以s为起点寻找增广路
*/
void aug(int s) {
//reset
for (int i = 0; i < n; i++) {
queue.clear();
next[i] = -1;
belong[i] = i;
mark[i] = 0;
}
//System.out.println("a new aug start with " + s);
mark[s] = 'S';
queue.offer(s);
//如果s未找到匹配点(即match[s] == -1),并且还有点可以继续BFS,则继续循环
while (match[s] == -1 && !queue.isEmpty()) {
int x = queue.poll();
//System.out.println("get " + x + " from queue");
for (int y : e[x]) {
//System.out.println("get " + y + " of " + x);
if (match[x] == y) continue; // x与y已匹配,忽略
if (findb(x) == findb(y)) continue; // x与y同在一朵花,忽略
if (mark[y] == 'T') continue; // y是T型点,忽略
if (mark[y] == 'S') { // y是S型点,奇环缩点
//System.out.println("find ji huan");
int r = LCA(x, y); // r为从i和j到s的路径上的第一个公共节点
if (findb(x) != r) next[x] = y; // r和x不在同一个花朵,next标记花朵内路径
if (findb(y) != r) next[y] = x; // r和y不在同一个花朵,next标记花朵内路径
// 将整个r -- x - y --- r的奇环缩成点,r作为这个环的标记节点
group(x, r); // 缩路径r --- x为点
group(y, r); // 缩路径r --- y为点
} else if (match[y] == -1) { // y自由,可以增广
next[y] = x;
for (int n1 = y; n1 != -1; ) { // 交叉链取反(交叉链即指匹配边和未匹配边交替的路径)
int n2 = next[n1];
int n3 = match[n2];
match[n2] = n1;
match[n1] = n2;
n1 = n3;
}
break; // 增广成功,退出循环将进入下一阶段
} else { // 当前搜索的交叉链+y+match[y]形成新的交叉链,将match[y]加入队列作为待搜节点
next[y] = x;
//System.out.println(match[y] + " match of " + y + " added");
queue.offer(match[y]);
mark[match[y]] = 'S'; // match[y]也是S型的
mark[y] = 'T'; // y标记成T型
}
}
}
}
/**
* 寻找点x和y的最近公共祖先
*/
int LCA(int x, int y) {
//System.out.println("LCA: " + x + " " + y);
for (int i = 0; i < n; i++) {// 每个阶段都要重新标记
visit[i] = -1;
}
while (true) {
if (x != -1) {
//System.out.println("LCA: " + x);
x = findb(x); // 点要对应到对应的花上去
//System.out.println("LCA: to root " + x);
if (visit[x] == 1)
return x;
visit[x] = 1;
if (match[x] != -1) {
x = next[match[x]];
//System.out.println("LCA: to next " + x);
} else x = -1;
}
//swap x and y
int tmp = x;
x = y;
y = tmp;
}
}
/**
* 缩花
*/
void group(int n1, int s) {
//每轮循环将n3作为并查集的根
//最后的n3将会是s,也即缩花后,会将s作为该朵花的根
//这是必要的,因为s是这朵花在这颗树上,和上面那些节点的唯一衔接点,这将在LCA中起到作用
while (n1 != s) {
int n2 = match[n1], n3 = next[n2];
// _next数组是用来标记花朵中的路径的,综合match数组来用,实际上形成了双向链表
if (findb(n3) != s) next[n3] = n2;
queue.offer(n2);
mark[n2] = 'S';
unit(n1, n2);
unit(n2, n3);
n1 = n3;
}
}
void unit(int a, int b) {
a = findb(a);
b = findb(b);
if (a != b) belong[a] = b;
}
int findb(int x) {
//寻找的过程中,还顺便优化了下belong,将并查集中所有的点都直接指向该并查集的根
return belong[x] == x ? x : (belong[x] = findb(belong[x]));
}
}
}