【啊哈!算法】第八章:更多精彩算法

割点,割边,tarjan算法,二分图最大匹配,匈牙利算法

Posted by x-jeff on November 3, 2023

博客为参考《啊哈!算法》一书,自己所做的读书笔记。
本文为原创文章,未经本人允许,禁止转载。转载请注明出处。

1.镖局运镖——图的最小生成树

kruskal算法:kruskal算法

2.再谈最小生成树

prim算法:prim算法

3.重要城市——图的割点

在上述无向连通图中,如果删除2号顶点后,图不再连通(即任意两点之间不能相互到达),我们称这样的顶点为割点(或者称割顶)。那么割点如何求呢?

很容易想到的方法是:依次删除每一个顶点,然后用深度优先搜索或者广度优先搜索来检查图是否依然连通。如果删除某个顶点后,导致图不再连通,那么刚才删除的顶点就是割点。这种方法的时间复杂度是O(N(N+M))。想一想有没有更好的方法呢?能找到线性的方法吗?

首先我们从图中的任意一个点(比如1号顶点)开始对图进行遍历,比如使用深度优先搜索进行遍历,下图就是一种遍历方案。从图中可以看出,对一个图进行深度优先遍历将会得到这个图的一个生成树(并不一定是最小生成树),如下图。有一点需要特别说明的是:下图中圆圈中的数字是顶点编号,圆圈右上角的数表示的是这个顶点在遍历时是第几个被访问到的,这还有个专有名词叫做“时间戳”。例如1号顶点的时间戳为1,2号顶点的时间戳为3$\cdots \cdots$我们可以用数组num来记录每一个顶点的时间戳。

现在再来介绍另外一个概念:追溯值。追溯值指的是从当前顶点出发,通过一条非搜索树上的边,能够到达的时间戳最小的顶点。我们用数组low来保存每个顶点的追溯值。

接下来详细说下数组low的计算。按照DFS的方法,从顶点1出发,顶点1的时间戳和追溯值设为$(1,1)$,其中第一个数为该顶点的时间戳,第二个数为该顶点的追溯值,初始追溯值和时间戳保持一致。

附上参考资料1中的动图例子更方便理解:

判定割点的两个条件:

  1. 如果顶点x不是根节点且有儿子,此时如有low[x的儿子]$\geqslant$num[x],那么x就是割点。
  2. 如果顶点x是根节点且有$\geqslant$2个儿子,那么x就是割点。

代码实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
#include <stdio.h>
int n,m,e[9][9],root;
int num[9],low[9],flag[9],index;//index用来进行时间戳的递增
//求两个数中较小一个数的函数
int min(int a, int b)
{
    return a < b ? a : b;
}
//割点算法的核心
void dfs(int cur, int father)//需要传入的两个参数,当前顶点编号和父顶点的编号
{
    int child=0,i,j;//child用来记录在生成树中当前顶点cur的儿子个数
    
    index++;//时间戳加1
    num[cur]=index;//当前顶点cur的时间戳
    low[cur]=index;//当前顶点cur能够访问到最早顶点的时间戳,刚开始当然是自己啦
    for(i=1;i<=n;i++)//枚举与当前顶点cur有边相连的顶点i
    {
        if(e[cur][i]==1)
        {
            if(num[i]==0)//如果顶点i的时间戳不为0,说明顶点i还没有被访问过
            {
                child++;
                dfs(i,cur);//继续往下深度优先遍历
                //更新当前顶点cur能否访问到最早顶点的时间戳
                low[cur]=min(low[cur],low[i]);
                //如果当前顶点不是根节点并且满足low[i]>=num[cur],则当前顶点为割点
                if(cur!=root && low[i]>=num[cur])
                    flag[cur]=1;
                //如果当前顶点是根节点,在生成树中根节点必须要有两个儿子,那么这个根节点才是割点
                if(cur==root && child==2)
                    flag[cur]=1;
            }
            else if(i!=father)//否则如果顶点i曾经被访问过,并且这个顶点不是当前顶点cur的父亲,则需要更新当前结点cur能否访问到最早顶点的时间戳
            {
                low[cur]=min(low[cur],num[i]);
            }
        }
    }
}
int main()
{
    int i,j,x,y;
    scanf("%d %d",&n,&m);
    for(i=1;i<=n;i++)
        for(j=1;j<=n;j++)
            e[i][j]=0;
    
    for(i=1;i<=m;i++)
    {
        scanf("%d %d",&x,&y);
        e[x][y]=1;
        e[y][x]=1;
    }
    root=1;
    dfs(1,root);//从1号顶点开始进行深度优先遍历
    
    for(i=1;i<=n;i++)
    {
        if(flag[i]==1)
            printf("%d ",i);
    }
    
    getchar();getchar();
    return 0;
}

可以输入以下数据进行验证。第一行有两个数n和m,n表示有n个顶点,m表示有m条边。接下来m行,每行形如“a b”表示顶点a和顶点b之间有边。

1
2
3
4
5
6
7
8
6 7
1 4
1 3
4 2
3 2
2 5
2 6
5 6

运行结果是:

1
2

上面的代码是用的邻接矩阵来存储图,这显然是不对的,因为这样无论如何时间复杂度都会在$O(N^2)$,因为边的处理就需要$N^2$的时间。这里这样写是为了突出割点算法部分,实际应用中需要改为使用邻接表来存储,这样整个算法的时间复杂度是$O(N+M)$。

4.关键道路——图的割边

在一个无向连通图中,如果删除某条边后,图不再连通,则这条边称为割边(也称为)。下图中左图不存在割边,而右图有两条割边分别是2-5和5-6。

边$x\to y$是桥的判定条件为:low[y]>num[x]。代码实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
#include <stdio.h>
int n,m,e[9][9],root;
int num[9],low[9],index;
int min(int a, int b)
{
    return a < b ? a : b;
}
void dfs(int cur, int father)
{
    int i,j;
    index++;
    num[cur]=index;
    low[cur]=index;
    for(i=1;i<=n;i++)
    {
        if(e[cur][i]==1)
        {
            if(num[i]==0)
            {
                dfs(i,cur);
                low[cur]=min(low[i],low[cur]);
                if(low[i]>num[cur])
                    printf("%d-%d\n",cur,i);
            }
            else if(i!=father)
            {
                low[cur]=min(low[cur],num[i]);
            }
        }
    }
}
int main()
{
    int i,j,x,y;
    scanf("%d %d",&n,&m);
    for(i=1;i<=n;i++)
        for(j=1;j<=n;j++)
            e[i][j]=0;
    
    for(i=1;i<=m;i++)
    {
        scanf("%d %d",&x,&y);
        e[x][y]=1;
        e[y][x]=1;
    }
    root=1;
    dfs(1,root);
    
    getchar();getchar();
    return 0;
}

可以输入以下数据进行验证。第一行有两个数n和m。n表示有n个顶点,m表示有m条边。接下来m行,每行形如“a b”表示顶点a和顶点b之间有边。

1
2
3
4
5
6
7
6 6
1 4
1 3
4 2
3 2
2 5
5 6

运行结果是:

1
2
5-6
2-5

同割点的实现一样,这里也是用的邻接矩阵来存储图的,实际应用中需要改为使用邻接表来存储,否则这个算法就不是$O(N+M)$了,而至少是$O(N^2)$。

求割点和割边的算法称为tarjan算法

5.我要做月老——二分图最大匹配

如上图,左边的顶点代表女生,右边的顶点代表男生。如果顶点之间有边,则表示他们可以坐在一起。像这样特殊的图叫做二分图(注意二分图是无向图)。二分图的定义是:如果一个图的所有顶点可以被分为X和Y两个集合,并且所有边的两个顶点恰好一个属于集合X,另一个属于集合Y,即每个集合内的顶点没有边相连,那么此图就是二分图。对于上面的例子,我们很容易找到两种分配方案,如下。

很显然右边的分配方案更好。我们把一种分配方案叫做一种匹配。那么现在的问题就演变成求二分图的最大匹配(配对数最多)。求最大匹配最容易想到的方法是:找出全部匹配,然后输出配对数最多的。这种方法的时间复杂度是非常高的,那还有没有更好的方法呢?

首先从左边的第1号女生开始考虑。先让她与1号男生配对,配对成功后,紧接着考虑2号女生。2号女生可以与2号男生配对,接下来继续考虑3号女生。此时我们发现3号女生只能和1号男生配对,可是1号男生已经配给1号女生了,怎么办?

于是1号男生让1号女生去尝试能否与其他认识的男生坐一起。然后,1号女生找到了2号男生,但2号男生已经和2号女生配对成功了,所以2号男生就让2号女生去尝试能否与其他认识的男生坐一起。接下来,2号女生找到了3号男生,刚好3号男生是空着的,于是,2号女生和3号男生配对成功,2号男生和1号女生配对,1号男生和3号女生配对。

最终通过这种连锁反应,配对数从原来的2对变成了3对,增加了1对。接下来介绍两个概念:

  1. 交替路:从一个未匹配点出发,依次经过非匹配边、匹配边、非匹配边……形成的路径叫交替路。
  2. 增广路:从一个未匹配点出发,走交替路,如果途径另一个未匹配点(出发点不算),则这条交替路称为增广路。

例如上例中所得到的增广路:

从顶点3出发,红色箭头表示未匹配边,绿色箭头表示匹配边,经过交替路后,到达另一个未匹配点3‘。增广路有一个重要特点:非匹配边比匹配边多一条(因为增广路的起点是未匹配点,与之相连的边肯定是未匹配边;增广路的终点也是未匹配点,与之相连的边也肯定是未匹配边,由交替路的定义可知,增广路从未匹配边开始,接着是匹配边,……,最后一条边还是未匹配边,所以增广路的未匹配边肯定是比匹配边多1的)。因此,研究增广路的意义是改进匹配。只要把增广路中的匹配边和非匹配边的身份交换即可。由于中间的匹配节点不存在其他相连的匹配边,所以这样做不会破坏匹配的性质。交换后,图中的匹配边数目比原来多了1条。我们可以通过不停地找增广路来增加匹配中的匹配边和匹配点。找不到增广路时,达到最大匹配(这是增广路定理)。

上述例子的完整过程见上图。绿色边为匹配边,红色边为未匹配边。算法整体描述见下:

  1. 首先从任意一个未被配对的点$u$开始,从点$u$的边中任意选一条边(假设这条边是$u \to v$)开始配对。如果此时点$v$还没有被配对,则配对成功,此时便找到了一条增广路(只不过这条增广路比较简单)。如果此时点$v$已经被配对了,那就要尝试进行“连锁反应”。如果尝试成功了,则找到一条增广路,此时需要更新原来的配对关系。这里要用一个数组match来记录配对关系,比如点$v$与点$u$配对了,就记作$\text{match} [v]=u$和$\text{match} [u]=v$。配对成功后,记得要将配对数加1。配对的过程我们可以通过深度优先搜索来实现,当然广度优先搜索也可以。
  2. 如果刚才所选的边配对失败,要从点$u$的边中再重新选一条边,进行尝试。直到点$u$配对成功,或者尝试过点$u$所有的边为止。
  3. 接下来继续对剩下没有被配对的点一一进行配对,直到所有的点都尝试完毕,找不到新的增广路为止。
  4. 输出配对数。

用下面动图展示一个更复杂的例子:

完整实现代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
#include <stdio.h>
int e[101][101];
int match[101];
int book[101];
int n,m;
int dfs(int u)
{
    int i;
    for(i=1;i<=n;i++)
    {
        if(book[i]==0 && e[u][i]==1)
        {
            book[i]=1;//标记点i已访问过
            //如果点i未被配对或者找到了新的配对
            if(match[i]==0 || dfs(match[i]))
            {
                //更新配对关系
                match[i]=u;
                match[u]=i;
                return 1;
            }
        }
    }
    return 0;
}

int main()
{
    int i,j,t1,t2,sum=0;
    scanf("%d %d",&n,&m);//n个点m条边
    for(i=1;i<=m;i++)//读入边
    {
        scanf("%d%d",&t1,&t2);
        e[t1][t2]=1;
        e[t2][t1]=1;//这里是无向图
    }
    for(i=1;i<=n;i++)
        match[i]=0;
    for(i=1;i<=n;i++)
    {
        for(j=1;j<=n;j++)
            book[j]=0;//清空上次搜索时的标记
        if(dfs(i))
            sum++;//寻找增广路,如果找到,配对数加1
    }
    printf("%d",sum);
    getchar();getchar();
    return 0;
}

可以输入以下数据进行验证。注:1、2、3为女生,4、5、6为男生。

1
2
3
4
5
6
6 5
1 4
1 5
2 5
2 6
3 4

运行结果是:

1
3

如果二分图有n个点,那么最多找到n/2条增广路径。如果图中共有m条边,那么每找一条增广路径最多把所有边遍历一遍,所花时间是m。所以总的时间复杂度是$O(NM)$。该算法被称为匈牙利算法

6.参考资料

  1. 60 分钟搞定图论中的 Tarjan 算法(一)
  2. [算法]轻松掌握tarjan割点&桥算法
  3. 二分图的最大匹配
  4. 浅浅演示一个有趣的匹配算法