【超详细!题解|两种做法】洛谷P3196 [HNOI2008] 神奇的国度[MCS算法]
温馨提示:本题有大量引理,看完证明直接背结论即可。
前置知识:
团:图中的一个顶点子集,其中任意两个不同的顶点之间都有边相连,即该子集诱导出的子图是一个完全图。
弦图:指图中每一个长度大于3的环(圈)都至少有一条弦的图,当然弦图也可以没有环。
没有环的特例:树、路径。
弦:连接环上两个不相邻顶点的边。
完美消除序列:图中顶点的一个特定排列顺序,满足:
对于序列中的每个顶点
,在序列中位于
之后的所有顶点中,v的邻点构成一个团,即这些邻点之间两两相连。
看不懂?我来举个例子。
这是一个弦图:
A/ \B---C| /D---E
边集为:
我们来看看 是不是完美消除序列:
顶点
(序列位置1):
- 后续顶点:
的邻点:只有
- 验证:单个顶点
自然形成团 ✓
- 消除
后,图变为
组成的"四边形加对角线"
- 后续顶点:
顶点
(序列位置2):
- 后续顶点:
的邻点(在后续顶点中):
- 验证:
和
之间有边,
形成团
- 消除
后,图变为:三角形
- 后续顶点:
顶点
(序列位置3):
- 后续顶点:
的邻点(在后续顶点中):
- 验证:
和
之间有边,
形成团
- 消除
后,图变为:边
- 后续顶点:
顶点
(序列位置4):
- 后续顶点:
的邻点(在后续顶点中):
- 验证:单个顶点
形成团
- 消除
后,图变为:单个顶点
- 后续顶点:
顶点
(序列位置5):
- 没有后续顶点
- 验证:条件满足
得: 是完美消除序列。
同学们可以自己试一下, 也是完美消除序列。
但是 不是,因为删除
后,后续节点
中的邻点
不成环。
其实完美消除序列就像拆房子,每次拆掉最外围的点,这样内部才能一直保持稳定(成团)。
说回本题的前置知识:
弦图判定基础:一个无向图是弦图当且仅当它存在完美消除序列。
证明:
必要性:如果一个图是弦图,那么它存在完美消除序列。
引理:弦图中至少存在一个单纯点(其邻点构成一个团的顶点)。
反证引理:如果弦图不存在单纯点,如果这个弦图没有环,那么就是树或者路径,
从叶子节点和路径的开头或结尾往前遍历,就是完美消除序列。
那假设这个图有环,考虑这个图包含度数最小点的最小环。
最小环大小必定为 ,不然必须有弦,那就不是最小环,矛盾。
长度为 的环肯定有单纯点,因为去掉那个度数最小的点,剩下两个点连通。
至此,弦图肯定存在单纯点。
充分性:如果一个图存在完美消除序列,那么它是弦图。
同学们自己想下,证明不难,意会即可,这里不多赘述。
讲了一堆,现在回到本题:
题意:
给一个弦图,求它的最小染色数。
最小染色定义(最小点色数):为图中每个顶点分配一种颜色,使得相邻顶点颜色不同。
(为啥是弦图?因为三角关系符合定义。
那为啥是最小染色呢?因为要求相互连边的点不能组成一队,求最少队数,和最小染色一样)
解析:
求弦图的最小染色,实际上就是求弦图中的最大团的大小 。
因为团内节点都互相连边,颜色必须不同,所以最小染色数 最大团的大小
。
然而,该弦图中不可能还有更多颜色的需求,因为最多只有 个节点是互相相邻的。
所以有最小染色数 最大团的大小
。
那如何求最大团的大小呢?
我们想到了完美消除序列的每个点与后续点中的邻点组成的团。
定理:在弦图的任何完美消除序列中,最大团必定是某个顶点与其后续邻点构成的团。
证明:设最大团 第一个在完美消除序列中出现的点为
,
为
的邻点集合,
为
的后续节点的集合。
那么就有:
又因为 是最大团,所以
大小刚好和
相等。
很好,我们就只要求出一个完美消除序列就好。
先看代码,我待会再解释:
#include<bits/stdc++.h>
using namespace std;
const int N=1e4+10;
template<typename T> void qread(T &x){x=0; int f=1; char c=getchar();for(; !isdigit(c); c=getchar()) if(c=='-') f=-1;for(; isdigit(c); c=getchar()) x=x*10+(c-'0');x*=f;
}
int lab[N], p[N], n;
bool v[N], mp[N][N];
void solve(){memset(lab, 0, sizeof(lab));memset(v, 0, sizeof(v));for(int i=n; i>=1; i--){int t=0;for(int j=1; j<=n; j++){if(!v[j] && ((t==0) || (lab[j]>lab[t])))t=j;}v[t]=1; p[i]=t;for(int j=1; j<=n; j++){if((v[j]==0) && (mp[t][j]==1))lab[j]++;}}
}
int main(){int m; qread(n); qread(m);for(int i=1; i<=m; i++){int x, y; qread(x); qread(y);mp[x][y]=mp[y][x]=1;} solve();int ans=0;for(int i=1; i<=n; i++)ans=max(ans, lab[i]+1);printf("%d\n", ans);return 0;
}
别急,我知道你要问什么:
solve()
for(int i=n; i>=1; i--)
这里为什么要倒着遍历?
其实这段代码的算法叫MCS(最大势搜索),代码中的 lab 数组就是各个点的“势”。
lab[i] 代表在当前求出的完美消除序列中,
在点 i 后面且是点 i 邻点的点的数量,也就是能和点 i 组成团的点数。
最后取答案的时候 lab 加 1,就是因为团的点数量不光包含后续邻点,还有点 i 自己。
而 solve 函数是为了求出倒序的完美消除序列,每一步都找出当前图中最大势的点。
最大势点是与已选的点连边最多的点,这样一步步选下来,
会发现(最后一个选的点)完美消除序列的最开始的点就是和所有点连边最少的点。
符合我们“拆房子要一开始先拆掉外围的点”。
那这样为什么能求出完美消除序列?
还有一开始 t 的选点,大家的 lab 都为 0,那岂不是随机选的吗?
先回答第一个问题,用反证法。
假设对于点 ,我们倒序先选了它的邻点
和
。
也就是 和
在
的后续节点集合中,但却不相连边。
那么整张图的一部分一定长这个样子:
z / \
x ...\ /y
为什么是一个环?因为如果没有环,就没理由 mcs 的时候先选 和
不选
。
发现了华点:长度大于等于 的环没有弦!不符合弦图的定义。
所以 mcs 求出的一定是完美消除序列。
再来说第二个问题,其实 mcs 一开始还真就是随机选的点。
再来看回这张图:
A/ \B---C| /D---E
我们发现无论是选点 这种不怎么单纯的点,
还是选点 这种清纯小白花作为最后一个点,
都能求出完美消除序列。
(同学们自己试一下,这里我提供 和
作为参考)
那究竟为什么可以随便选点作为最后一个点呢?
其实这要说到弦图的定义:
弦图移除任意顶点后,剩余图仍是弦图。
反证:如果剩余图不是弦图,那就是有长度大于等于 的环。
而那个被移除的点肯定不在环上,不然环就断了。
但我们不可能移除一个点只移除一条弦,所以剩余图是弦图。
所以无论去掉哪个点,弦图都还是弦图,肯定能求出完美消除序列。
也就是可以随便选点作为最后一个点,剩下点也还是弦图,根据归纳法,易证随便选没事。
(最后一个点可以随便选,但第一个点可不能!!拆房子只能从外拆,第一个点必须是单纯点)
接下来上带注释的豪华版代码:
#include<bits/stdc++.h>
using namespace std;
const int N=1e4+10;
template<typename T> void qread(T &x){x=0; int f=1; char c=getchar();for(; !isdigit(c); c=getchar()) if(c=='-') f=-1;for(; isdigit(c); c=getchar()) x=x*10+(c-'0');x*=f;
}
int lab[N], p[N], n; //lab数组存点的势,p数组存完美消除序列
bool v[N], mp[N][N]; //v标记点是否被选进序列,mp是邻接表
void solve(){memset(lab, 0, sizeof(lab));memset(v, 0, sizeof(v));for(int i=n; i>=1; i--){int t=0;for(int j=1; j<=n; j++){if(!v[j] && ((t==0) || (lab[j]>lab[t]))) t=j;//选出没进过序列且势最大的点 }v[t]=1; p[i]=t; //标记,进序列 for(int j=1; j<=n; j++){if((v[j]==0) && (mp[t][j]==1))lab[j]++; //邻点加势 }}
}
int main(){int m; qread(n); qread(m);for(int i=1; i<=m; i++){int x, y; qread(x); qread(y);mp[x][y]=mp[y][x]=1;} solve();int ans=0;for(int i=1; i<=n; i++)ans=max(ans, lab[i]+1); //求后续邻点组成的最大团,别忘了加上点 i printf("%d\n", ans);return 0;
}
还有另一种时间复杂度更小的做法,本质理论是一样的 mcs。
因为每次 lab 只加 ,数据范围也不大,可以考虑造个桶,不同的 lab 值放不同的格子。
建个双向链表,桶里格子的每个节点都有 pre 和 next(前仆和后继)。
多开 n 个节点代表每个格子的头节点,头节点不存东西,
只是每次查找的时候看看头节点的 next 有没有值,来判断这个格子里有没有节点。
每次插入的时候将节点 x 插到格子的最前面。
光这么说有点空,直接看代码反而更好理解。
线性时间复杂度的代码:
#include<bits/stdc++.h>
using namespace std;
template<typename T> void qread(T &x){x=0; int f=1; char c=getchar();for(; !isdigit(c); c=getchar()) if(c=='-') f=-1;for(; isdigit(c); c=getchar()) x=x*10+(c-'0');x*=f;
}
const int N=1e4+10;
int pre[2*N], nxt[2*N];
bool v[N];
int p[N], lab[N];
vector<int> G[N];
void push(int x){pre[nxt[x]=nxt[N+lab[x]]]=x; // x的下一个为原先第一个节点,原先第一个的上一个为 x nxt[pre[x]=N+lab[x]]=x; //x 的上一个为头节点,头节点的下一个为 x
}
void del(int x){pre[nxt[x]]=pre[x];nxt[pre[x]]=nxt[x];
}
int main(){int n, m; qread(n); qread(m);for(int i=1; i<=m; i++){int x, y; qread(x); qread(y);G[x].push_back(y);G[y].push_back(x);}for(int i=1; i<=n; i++) push(i);int now=0, ans=0; // now就是当前遍历到哪一个 lab值 memset(v, 0, sizeof(v));memset(lab, 0, sizeof(lab));for(int i=n; i>=1; i--, now++){while(nxt[N+now]==0) now--; //等于 0就是这个格子里没东西 int x=nxt[N+now];del(x);p[i]=x; v[x]=1; //存进完美消除数组里 int sum=0; //统计团大小 for(int y: G[x]){if(!v[y]){ // y没遍历过,因为是倒序,所以 y是 x的后续相邻节点 del(y);lab[y]++;push(y);}else ++sum;}ans=max(ans, sum+1); //求团大小别忘了 x本身 }printf("%d\n", ans);return 0;
}