带花树算法总结

803 阅读7分钟

算法简述

本章主要内容转自: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搜索树中的未匹配边

有三个数组都记录了边的信息

  1. e[n]: 记录了整个图
  2. match[n]: 记录了匹配的边
  3. 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型点的相连点时,根据不同情况做不同处理。
这里有个“交叉链”的说法,指的是在寻找增广路的过程中,形成的 匹配边 和 非匹配边 交替相连的路径。

我们对其中的几种情况进行进一步讨论。

  1. x与y已匹配
    x是一个S型节点,是作为点y的匹配点被加入queue的。在遍历x的相连点时,也会遍历到点y。

  1. 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中都有用)。

  1. y自由,增广
    增广前,搜索树的状态如下所示(这里将next数组补全)。
    代码实际上就是根据n1,通过next数组取到n2,通过match数组取到n3。然后匹配(n1, n2),最后将n3作为新的n1,继续下一轮处理。
    其实,(n1, n2, n3) 就像是搜索树的一个基本组成结构。这个基本结构在缩花和LCA中都会用到。

  1. y是S型节点,缩花。缩花涉及到的内容较多。

缩花

非匹配边(x,y)导致了环,搜索树如下图所示。橙色的线是next数组标识的指向关系,遇到环前,next数组都还是从树的下面指向上面的。

缩花的步骤:

  1. 找到d和j的最近公共祖先,这里是s(LCA);
  2. 用next维护d和j的非匹配边;
  3. 缩路径s --- j为点;
  4. 缩路径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上的一般图最大匹配
在修改代码的过程中犯过三个错误。

  1. 初始化的时候,遇到一对点,需要双向构建match;
  2. 对新的未匹配点增广时,需要清空Queue;
  3. 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]));
    }

  }
}