不了解极大团(maximal clique)的,请看极大团这篇文章。
该算法是由Coen Bron和Joep Kerbosch在1973提出的,论文原文
参考资料:
Bron-Kerbosch算法视频介绍
极大团算法
当给出一个图之后,我们应该怎么去找出其中的极大团呢?
寻找极大团的简单思想就是:
1、生成原始图的所有子图(可能有2n-1个子图,n表示结点个数);
2、判断这些子图是不是团;
3、将不是极大团的团都删除;
在我们上面给出的图中,会有许多的子图,例如:
这里没有列出所有的子图,只有部分。
在这些子图里面,我们会发现有些并不是团,例如{1、2、3}组成的图,因为2和3之间并不相连,所以这样的图不符合要求,将其去掉。
剩下的所有子图,都是满足了团的要求的图。最后,其中还有许多,不是极大团的,例如{0、1}就不是极大团,因为有{0、1、2}的存在。这些不是极大团的团,我们也要去掉。
现在剩下的就是我们要找的极大团。
Bron-Kerbosch算法(Version 1)
这个算法主要是构造了三个集合,我们假设:
R集合记录的是当前极大团中已经加入的点。
P集合记录的是可能还能加入的点(也就是与R集合中所有点都有边存在的点,这样加入后,才会构成团)
X集合记录的是已经加入过某个极大团的点(作用是判重,因为会从每个结点开始,枚举所有的团,如果不对已经加入某个极大团的点,进行标记,可能会有重复的极大团出现)
伪代码如下:
Bron-Kerbosch Algorithm(Version 1)
R={} //已确定的极大团顶点的集合
P={v} //未处理顶点集,初始状态是所有结点
X={} //已搜过的并且属于某个极大团的顶点集合
BronKerbosch(R, P, X):
if P and X are both empty:
report R as a maximal clique
for each vertex v in P:
BronKerbosch1(R ⋃ {v}, P ⋂ N(v), X ⋂ N(v))
P := P \ {v}
X := X ⋃ {v}
基础的Born_Kerbosch算法:
1、对于每一个在集合P中的点v,我们把v加入集合R(集合P中的点与集合R中所有的点都是连接的,这样加入v,保证集合R依然是一个团),对在P集合中且与点v相连的这部分集合中寻找下一个可能加入R集合的点(意思就是更新P集合,使P集合中的点依旧可以和R集合中的点相连接,因为这里新R集合新加入了v,所以只要是原P集合中且与v相连的,那这些结点就是与新的R集合中所有结点都相连)。
2、回溯时我们把v从P集合中移出,加入X集合代表当前状态下对包含点v的极大团已经计算完毕了。
3、R集合为极大团的时候,必须要满足P与X都是空的。P存放的是还可能加入R集合的点,P集合为空代表没有点还能再加入到R集合当中,而X集合存放的是已经完成极大团计数的点,而且X集合中的点必然是与所有R集合中的点都有边存在的(因为我们每次向下进行dfs的时候,还对P集合和X集合分别进行取与R集合内都相邻的操作来保证,而且加入X集合中的点,就是从R集合中取出来的,当然会和R集合中的所有结点都有边),也即X集合中点必然可以与R集合构成极大团。如果X集合不是空的的话,可以把X集合中的点加入R集团,此时R集团依然是团,结点数比刚才增加了,说明刚才的R集合就不是极大团,那么说明R集合中的极大团是在之前计算包含X集合中的点的极大团的时候已经计算过了的,故当且仅当P、X都为空集合的时候R才是一个极大团。
算法的实例:
在最开始中,集合P会初始化为所有的结点,其他集合为空集。
首先,我们将集合P中的第一个结点,1号结点,放入到R中。这个时候,我们的遍历要进入下一层了,所以要更新集合P和集合X,这里集合X没有变化,依然是空集。集合P中的点要是和集合R中的所有结点都是连接的,显然我们只需要找到,原来的P集合中是1号集合的邻居结点的那些点就行了,这里就是{2、3},保证了P集合中的每个结点都可以和R集合中的结点想连接,其实X集合我们也会执行同样的操作,以保证X集合中的结点都是和R集合中的所有结点连接的。
同样的,我们继续将P集合中的结点放入R集合,这次放入2号结点,R集合变为{1、2},P集合取原P集合中且和2号结点相连接的结点,P集合就变为{3}。
最后同样的我们将P集合中的结点,放入R集合。此时P集合X集合都为空集,说明这个团已经不能再扩充了,所以{1、2、3}就是一个极大团。
我们从1号结点开始,先遍历了{1、2},所以还要从1开始遍历{1、3}。按照DFS的规则,会先退回到上一层,也就是R={1、2},P={3}这一层,我们会将v结点,也就是这一层里操作的结点,在这里是3号结点放入X集合中,表示它已经参与了极大团的构。在这一层里最后就变为了R={1、2},P={},X={3},因为X不为空,且P为空,所以R={1、2}不是一个极大团,然后我们要回溯到上一层了,也就是R={1}、P={2、3}、X={}这一层,再将此时的操作结点2号结点,放入X中,表示它已经属于某个极大子图,这时的三个集合变为了R={1},P={3},X={2}。
只要P集合中,还有元素,我们就会一直执行将P集合中的元素加入R集合中的操作,所以这里将3号结点加入,并且更新了P集合和X集合(就是保证P,X中的结点和R集合中的所有结点都连接)进入下一层,也就是图中所示的样子。
在这里X={2},不为空,所以{1、3}不是极大团,因为X集合中的在加入R集合后,都是会让R集合满足极大团的,显然{1、2、3}比{1、3}要大,所以只要X不为空,R就不会是极大团。
在完成判断后,退回到上一层,也就是图中的R={1},P={2、3},X={}的这一层,不过刚才有说过,这一层其实在经过将P中的2号结点加入到R后,以及后续的程序,已经变成了R={1},P={3},X={2},此时我们将正在操作的结点3号结点放入到X中,变为R={1},P={},X={2、3},此时P中无结点,X中有两个结点。说明以1开始的遍历操作以及全部完成了,我们需要再去寻找其他结点开始的遍历。
退回到上一层,也就是最开始的那一层,即R={}、P={1、2、3、4}、X={}的这一层。把操作的结点,也就是1号结点,放入X中,表示已经对其进行过查询。
再将P中的下一个结点,也就是2号结点,放入R中,同样的我们要更新P和X,保证P和X中的结点,和R中的所有结点,都是连接的,进入下一层,得到的如图所示,R={2}、P={3、4}、X={1},表示我们要由2号结点开始,寻找极大团了。
同样的,我们将P中的3号结点放入R中,同时更新P和X,因为P中的4号结点,不与3号结点相连,所以P集合变为空集,X依然为{1}。此时我们发现P为空,但是X={1},所以R={2、3}并不是一个极大团。这说明由2开始,再走3号结点这条路是行不通的,所以需要退回到上一层,也就是R={2}、P={3、4}、X={1}这一层,再将3号结点放入X中,表示已经搜过了,此时变为R={2},P={4},X={1、3}。下面就要走由2号结点开始,在到4号结点的这条路了。
将P中的4号结点放入R中,同时更新P和X,因为1和3都不与4相连,所以X集合变为空集,此时P集合也为空集,所以R={2、4}是一个极大团。
我们的遍历当然还要继续,我们现在找了从最初的图中,由1开始,已经由2开始来寻找极大团,当然还要从3开始以及从4开始,寻找极大团,结果如图所示。由3号结点开始,R={3}、P={}、X={1、2};由4号结点开始,R={4}、P={}、X={2}。X集合都不为空,所以R集合不是极大团。
每一次遍历中,所生成的集合如上图的右下角所示,可以很明显的看出两个极大团。
Bron-Kerbosch Algorithm(Version 1)完整的代码为:
int some[maxn][maxn];
//some表示P集合,第一个[maxn]表示所在的深度,后一个就是P集合中的某个结点的位置
int none[maxn][maxn]; //表示X集合,其他和some同理
int all[maxn][maxn]; //表示R集合,其他同理
int mp[maxn][manx];
void dfs(int d, int an, int sn, int nn)
//d为搜索深度,an、sn、nn分别为all(R)、some(P)、none(X)集合中顶点数,
{
if(!sn && !nn) ++ S; //sn==0,nn==0时,是一个极大团,S为极大团数量
for(int i = 0; i < sn; ++i) //遍历P中的结点,sn==0时,搜索到终点
{
int v = some[d][i]; //取出P中的第i个结点
for(int j = 0; j < an; ++j)
{
all[d+1][j] = all[d][j];
}
all[d+1][an] = v;
//这里是将取出的v结点,添加到R集合中,当然是添加到下一个深度的R集合。
int tsn = 0, tnn = 0;
//用来分别记录下一层中P集合和X集合中结点的个数
for(int j = 0; j < sn; ++j) if(mp[v][some[d][j]]) some[d+1][tsn++] = some[d][j];
//更新P集合(当然是下一层的P集合),保证P集合中的点都能与R集合中所有的点相连接
for(int j = 0; j < nn; ++j) if(mp[v][none[d][j]]) none[d+1][tnn++] = none[d][j];
//更新X集合(当然是下一层的X集合),保证X集合中的点都能与R集合中所有的点相连接
dfs(d+1, an+1, tsn, tnn);
//递归进入下一层
some[d][i] = 0, none[d][nn++] = v;
//完成后,将操作的结点,放入X中,开始下面的寻找。
}
}
Bron-Kerbosch 算法(Version 2)
在上面这个方法中,我们进行了许多不必要的判断,例如在我们找到了极大团{1、2、3}之后,依然去对{1、3},{2、3},{3}这些团进行了判断,然而这些显然不是极大团。所以现在考虑的是对其进行优化,使程序不用进行不必要的递归。
当我们将一个结点u,放入到R集合后,再取下一个结点,取的必然是u的邻居结点(因为再更新P和X时,会将不是邻居结点的结点都过滤掉)。通俗的讲就是如果取完u之后我们再取与u相邻的点v也能加入到极大团,那么我们只取u就好了,因为我们由u开始递归,已经找到了u及其邻居结点v等等结点构成的极大团了,没有必要再去从v开始寻找极大团,这会增加不必要的计算。至于v可能可以其他结点构成另一个极大团,如果这个极大团包括了u,那么由u开始就已经找到了这个极大团了;如果这个极大团不包括u,那说明这个极大团里面一定存在和u结点不相连的结点k,那没必要从v开始寻找这个极大团了,从u的非邻居结点k开始,一样可以找到这个极大团。这样对u及其邻居结点构成的极大团,只需要从u开始寻找一次就可以了,接下来就直接从u的非邻居结点k开始寻找极大团,这样可以减少许多不必要的计算。
例如上面的程序中我们从1开始寻找极大团,找到了由1及其邻居结点构成的极大团{1、2、3},接下来我们就直接从1的非邻居结点4号结点开始寻找极大团,可以找到极大团{4、2},最终所有的极大团都被找到了。其中由2和3开始的这些不必要的计算都被省略,当然在递归的内部,我们也依然使用这种思想。而我们要想进一步减少计算,我们就可以取邻居尽可能多的u,这样让我们要遍历的点尽可能减少,但是其实没必要如此,寻找合适的u也会减少一定的效率。
设定关键点 pivot vertex u,只对关键点u自身和u的非邻居结点进行查找。
伪代码如下:
Bron-Kerbosch Algorithm(Version 2)
R={} //已确定的极大团顶点的集合
P={v} //未处理顶点集,初始状态是所有结点
X={} //已搜过的并且属于某个极大团的顶点集合
BronKerbosch(R, P, X):
if P and X are both empty:
report R as a maximal clique
choose a pivot vertex u in P ⋃ X
for each vertex v in P \ N(u):
BronKerbosch1(R ⋃ {v}, P ⋂ N(v), X ⋂ N(v))
P := P \ {v}
X := X ⋃ {v}
下图是Bron-Kerbosch Algorithm(Version 1)和Bron-Kerbosch Algorithm(Version 2)的对比
在右边是没有优化的方法,我们可以看到递归的次数非常的多,有太多不必要的计算。
左边就是优化后的方法,下面讲解其中的具体步骤:
这里每次会选择邻居结点多的那个结点当作Pivot,所以一开始选择4号结点做Pivot,我们也从4号结点开始进行递归,所以进入下一层,得到R={4},P={1、2、3、5、6},X={},P中的结点经过筛选以后,将7号结点去除。
我们从P中选2号结点(因为2号结点邻居结点多),同时也将2号结点作为这一层的Pivot 结点,同时更新P集合。这一层变为{4、2},{1、3、5},{}。
我们从P中选1号结点(因为1号结点邻居结点多,当然这里向图中一样选5号结点也是可以的),同时也将1号结点作为这一层的Pivot 结点,同时更新P集合。这一层变为{4、2、1},{3},{}。
最后将P集合中的3号结点,放入R集合,此时P集合和X集合都变为空集,所以{4、2、1、3}是极大团。到目前位置,我们的操作步骤和优化之前是一样的,只是这里每次都是选取的邻居最多的结点放入(这样是为了尽可能的减少递归次数,其实按照原来的顺序也是可以的)。和之前一样的原因是,这是第一轮递归,我们在每一层放入R集合的结点,就是该层的Pivot 结点。
因为P={},所以退回到上一层,也就是{4、2、1},{3},{},然后将P中的结点3放入到X中,表示在这条路上,已经探索过这个结点了。于是变为{4、2、1},{},{3},P集合为空集,所以退回到上一层,也就是{4、2},{1、3、5},{},将1号结点移入X集合中,变为{4、2},{3、5},{1},然后在P中选取下一个结点加入R集合中,在这一层中Pivot结点是1号结点(图中为5号结点,所以多了一步,就是{4、2、3},{},{}这一步),所以凡是在P集合中并且是Pivot结点(也即是这里的1号结点)的邻居结点的,从P集合中移除。比如这里3号结点在P集合中,且3号结点是1号结点的邻居结点,所以将3号结点移除(因为我们已经通过1号结点,找到了同时包含1和3的极大团)。
最后P集合中只有5号结点了,所以将5号结点放入R集合中,并且更新P和X,变为{4、2、5},{},{},此时P和X都为空集,所以{4、2、5}是一个极大团。由4、2开始的这一层,我们已经探索完了,所以要退回到{4},{1、2、3、5、6},{}这一层。
在这一层里,Pivot结点是2号结点,所以P集合中和2是邻居结点的那些结点将不会在放入R集合中,所以将P集合中的6放入R集合中,更新P集合与X集合,得到{4、6},{},{},所以{4、6}是一个极大团。
现在由4开始的这条路径,我们也全部都走完了,所以需要退回到最开始的那一层,也就是{},{1、2、3、4、5、6、7},{},将4号结点放入X集合中,找到P集合中不和4号结点相连的那些结点,这里只有7号结点,所以将7号结点放入到R集合中,同时更新P和X,得到{7},{5},{},这里只有5号结点了,显然选择5号作为Pivot,最后变为{7、5},{},{},所以{7、5}就是一个极大团。
现在,已经找出了图中所有的极大团,而且从图中可以明显的看出,我们递归的次数是比原来要少很多的。
Bron-Kerbosch Algorithm(Version 2)完整的代码为:
#include
#include
using namespace std;
const int maxn = 130;
bool mp[maxn][maxn]; //表示结点之间的连接
int some[maxn][maxn], none[maxn][maxn], all[maxn][maxn];//分别是P集合,X集合,R集合
int n, m, ans; //n表示结点数,m表示边数,ans表示极大团数量
void dfs(int d, int an, int sn, int nn)
{
if(!sn && !nn) ++ans;
int u = some[d][0]; //选取Pivot结点
for(int i = 0; i < sn; ++i)
{
int v = some[d][i];
if(mp[u][v]) continue;
//如果是邻居结点,就直接跳过下面的程序,进行下一轮的循环。显然能让程序运行下去的,只有两种,一种是v就是u结点本身,另一种是v不是u的邻居结点。
for(int j = 0; j < an; ++j)
{
all[d+1][j] = all[d][j];
}
all[d+1][an] = v;
int tsn = 0, tnn = 0;
for(int j = 0; j < sn; ++j) if(mp[v][some[d][j]]) some[d+1][tsn++] = some[d][j];
for(int j = 0; j < nn; ++j) if(mp[v][none[d][j]]) none[d+1][tnn++] = none[d][j];
dfs(d+1, an+1, tsn, tnn);
some[d][i] = 0, none[d][nn++] = v;
if(ans > 1000) return; // 极大团数量超过1000就不再统计
}
}
int work()
{
ans = 0;
for(int i = 0; i < n; ++i) some[1][i] = i+1;
dfs(1, 0, n, 0);
return ans;
}
int main()
{
while(~scanf("%d %d", &n, &m))
{
memset(mp, 0, sizeof mp);
for(int i = 1; i <= m; ++i)
{
int u, v;
scanf("%d %d", &u, &v);
mp[u][v] = mp[v][u] = 1;
}
int tmp = work();
if(tmp > 1000) puts("Too many maximal sets of friends.");
else printf("%d\n", tmp);
}
return 0;
}