最大团
1 问题引入
给定一个无向图,定义如下概念:
- 团:节点之间两两相连的子图。
- 极大团:不能再继续扩大的团。
- 最大团:一张无向图中最大的极大团。
现在的问题是怎样求出一个图的最大团。实际上这个问题是 NP-hard 的,也就是没有多项式解法。但是有复杂度较为优秀的指数级算法。
主流算法有两种:状压 dp 和 Bron-Kerbosch 算法。后一种算法复杂度更低,前一种较为简单。
2 状压 dp 解法
先考虑朴素状压怎么办。记录下每一个点向外扩展的点集 \(P_i\),然后 \(O(2^n)\) 枚举子图,然后检查每一个点 \(i\),看 \(P_i\) 是否包含这个子图中的所有点。这样的复杂度是 \(O(2^nn)\) 的。
接下来我们使用状压 dp + meet in the middle 优化复杂度,可以达到 \(O(2^{n/2}n)\)。
考虑枚举前一半的点集,对于这些点构成的任意集合 \(S\),求出 \(f(S)\) 表示 \(S\) 点集中最大团的大小。然后枚举后一半的点集,对于任意一个集合 \(T\),求出 \(T\) 中每一个点的 \(P_i\) 的交集 \(R\)。然后判断 \(T\) 是不是团,如果是,则用 \(cnt(T)+f(R)\) 来更新答案(其中 \(cnt(T)\) 为 \(T\) 子集大小)。
那么整体代码如下:
#include <bits/stdc++.h>
#define int long long
using namespace std;
const int Maxn = 2e5 + 5;
const int Inf = 2e9;
int n, m;
int out[45];
int f[1 << 20];
int lowbit(int x) {
return x & (-x);
}
signed main() {
ios::sync_with_stdio(0);
cin.tie(0), cout.tie(0);
cin >> n >> m;
for(int i = 1; i <= m; i++) {
int u, v;
cin >> u >> v;
out[u] |= (1ll << (v - 1));//记录每一个点的出点
out[v] |= (1ll << (u - 1));
}
int ans = 0;
int x = (n >> 1), y = n - x;
for(int i = 1; i < (1 << x); i++) {//枚举前一半的点集
bool flg = 1;
for(int j = 1; j <= x; j++) {
if(!((i >> (j - 1)) & 1)) continue;
if((out[j] & i) != (i ^ (1 << (j - 1)))) {//判断是不是完全图
flg = 0;
}
}
if(flg) {
f[i] = __builtin_popcount(i);//直接记录下每一个团的 f 值
ans = max(ans, f[i]);
}
}
for(int i = 1; i < (1 << x); i++) {
for(int j = 1; j <= x; j++) {//用每一个团去更新剩下的子图
if(!((i >> (j - 1)) & 1)) continue;
f[i] = max(f[i], f[i ^ (1 << (j - 1))]);
}
}
for(int i = 1; i < (1 << y); i++) {//枚举后一半点集
bool flg = 1;
for(int j = 1; j <= y; j++) {
if(!((i >> (j - 1)) & 1)) continue;
if(((out[j + x] >> x) & i) != (i ^ (1 << (j - 1)))) {//判断是不是完全图
flg = 0;
}
}
if(!flg) continue;
int S = (1 << x) - 1;
for(int j = 1; j <= y; j++) {
if(!((i >> (j - 1)) & 1)) continue;
S &= out[j + x];//求出点交集
}
ans = max(ans, f[S] + __builtin_popcount(i));//更新答案
}
return 0;
}
3 Bron-Kerbosch 算法
BK 算法的核心其实是搜索,我们每次加入一个点,看当前点集能否构成最大团。如果能继续递归,否则就进行回溯。显然这样做是非常不优的,而 BK 算法其实就是对它的优化。该算法复杂度(据说)是 \(O(3^{n/3})\),证明我也不会,但是它肯定是比上面的状压做法优的。
BK 算法制定了三个集合 \(R,P,X\)。\(R\) 表示当前正在找的极大团中的点,\(P\) 表示有可能加入极大团中的点,\(X\) 表示已经在极大团中的点。
现在我们进行如下操作:
- 初始化 \(R,X\) 为空集,\(P\) 为所有点。
- 将 \(P\) 中顶部元素 \(u\) 取出,设 \(W(u)\) 表示所有与 \(u\) 相邻的点,则递归集合 \(R\cup u,P\cap W(u),X\cap W(u)\)。
- 此时如果 \(P,X\) 均为空,则 \(R\) 中的所有元素构成一个极大团。
- 将 \(u\) 从 \(P\) 中删去,加到 \(X\) 中。
- 重复 \(2,3\) 步,知道 \(P\) 集合为空。
其实整体思路还算明确,我们分析一下:
- 当我们取出一个点加入 \(R\) 中时,此时有可能加入极大团的点一定需要在 \(W(u)\) 中,因此 \(P\) 要和它取交集。
- \(X\) 的作用只是为了判重,避免找到重复的极大团。当取出一点后,\(X\) 里面剩下的可能会与当前极大团重复的点也一定要在 \(W(u)\) 中,因此也要取交集。
- 当 \(P,X\) 均为空时,则这个团没有被找到过,且无法再被扩展,自然就是一个新的极大团。
最后我们还有一个小优化:当我们从 \(P\) 中取出一点 \(u\),如果 \(v\) 与 \(u\) 相连,则它在本层递归中会被加入到极大团中;而在下一层递归我们仍然有可能取出 \(v\) 加入极大团,这样会造成重复。所以在这一层,我们只取 \(u\) 即可,到下一层再取和 \(u\) 相连的点 \(v\)。这被称为关键点优化。
最后代码如下:
int ans;
int g[45][45];
int r[45][45], p[45][45], x[45][45];
void dfs(int d, int R, int P, int X) {
if(!P && !X) {//P,X 集合都不为空
ans = max(ans, R);//则 R 中元素构成极大团
return ;
}
int u = p[d][1];//关键点优化
for(int i = 1; i <= P; i++) {
int v = p[d][i];
if(g[u][v]) continue;//相邻不必在此递归
for(int j = 1; j <= R; j++) {
r[d + 1][j] = r[d][j];
}
r[d + 1][R + 1] = v;//取并集
int nP = 0, nX = 0;
for(int j = 1; j <= P; j++) {//取交集
if(g[v][p[d][j]]) p[d + 1][++nP] = p[d][j];
}
for(int j = 1; j <= X; j++) {//取交集
if(g[v][x[d][j]]) x[d + 1][++nX] = x[d][j];
}
dfs(d + 1, R + 1, nP, nX);
p[d][i] = 0, x[d][++X] = v;//加入 v
}
}

浙公网安备 33010602011771号