启发式合并
启发式合并是一种奇妙的暴力,一般问题是需要求若干个集合之间合并后的信息。普通的暴力就是直接按照题意模拟,对要求合并的集合进行合并。而启发式合并多了一个步骤,即每次合并时将数量较少的集合合并到数量较多的集合,因为添加元素时至少要对一个集合进行遍历,所以自然而然会想到对集合大小较小的集合进行遍历。
具体操作
具体的,以 set 为例,若题目要求将 s e t A set A setA 合并到 s e t B set B setB 中,如果此时满足 A . s i z e ( ) ≤ B . s i z e ( ) A.size()\le B.size() A.size()≤B.size() ,则直接
for(auto it:A)
B.insert(it);
A.clear();
如果 A . s i z e ( ) > B . s i z e ( ) A.size() > B.size() A.size()>B.size(),则先将 s e t B set B setB合并到 s e t A set A setA 中,再对它们进行 O ( 1 ) O(1) O(1) 的swap。
for(auto it:B)
A.insert(it);
swap(A,B);
A.clear();
复杂度分析
可以证明,按这种方式进行合并,最终所有元素都在一个集合里时,总的时间复杂度是 O ( n l o g n ) O(nlogn) O(nlogn) 的。
感性证明:对于每个元素,如果会被访问到,说明肯定在操作中是处于大小较小的集合中,那么每被操作一次,则该元素所处的集合的大小至少翻倍,所以每个元素至多被操作 l o g n logn logn 次,总的有 n n n 个元素,故时间复杂度是 n l o g n nlogn nlogn 。
例题1
题意:
有 n n n 个盒子,每个盒子内初始放有某种颜色的球,接下来若干次操作,每次操作将 a a a 盒子里的球移动到 b b b 盒子中,并输出此时 b b b 盒子里不同颜色球的数量。
做法
由于题目求的是盒子内不同颜色球的数量,很容易想到可以利用 set 的去重功能来实现为每个盒子维护一个 set ,输出时就输出合并后该盒子的 s i z e ( ) size() size() 。注意合并时按照之前的操作,将较小的集合合并到较大的集合即可。
代码
#include<bits/stdc++.h>
//#define int long long
//#pragma GCC optimize(3,"Ofast","inline")//bitset配合用
#define inf 0x3f3f3f3f
#define ll long long
#define pii pair<int,int>
#define db double
using namespace std;
const int maxn=2e5+10;
const int mod=998244353;
set<int>s[maxn];
signed main(){
ios::sync_with_stdio(false);
cin.tie(0);cout.tie(0);
int n,q;cin>>n>>q;
for(int i=1;i<=n;i++){
int tem;cin>>tem;
s[i].insert(tem);
}
while(q--){
int a,b;cin>>a>>b;
if(s[b].size()>=s[a].size()){
s[b].insert(s[a].begin(),s[a].end());
s[a].clear();
}
else{
s[a].insert(s[b].begin(),s[b].end());
s[b].clear();
swap(s[a],s[b]);
}
cout<<s[b].size()<<endl;
}
//system("pause");
return 0;
}
例题2
题意
对题目所求的式子简单推导后,发现题意转化为:已知若干个箱子中分别装有哪些颜色的球,每次询问告诉你两个颜色,你要快速求出有多少个箱子同时装有这两种颜色的球。
分析
朴素的想法是先对询问离线,将询问的颜色进行捆绑,遍历每一个箱子,假设当前枚举到的是箱子 i i i ,先用一个桶维护箱子中出现的颜色,之后第二次遍历该箱子,对于枚举到的颜色 j j j ,判断询问中包含颜色 j j j 的询问里另外一个颜色是否在桶中,是的话对应询问的答案就加一。
那么对于每个箱子里每个颜色,我们都会去遍历所有以该颜色作为第一关键字的询问,如果是固定将询问里的第一个颜色当作关键字离线,很容易将时间复杂度卡到 O ( n 2 ) O(n^2) O(n2) ,所以此时就可以使用启发式合并的思想。离线询问时储存每种颜色在询问中出现的次数,捆绑时将出现次数较少的颜色作为第一关键字,多的作为第二关键字,就可以在遍历时遍历较少的颜色数量。
代码
#include<bits/stdc++.h>
#define ll long long
#define db double
#define pii pair<int,int>
using namespace std;
const int maxn=2e5+10;
const int mod=998244353;
vector<int>arr[maxn];
int ans[maxn],in[maxn],vis[maxn];
vector<pii> query[maxn];
pii temq[maxn];
int ksm(int a,int b){
ll res=1;
for(;b;b>>=1,a=1ll*a*a%mod)
if(b&1) res=1ll*res*a%mod;
return res;
}
signed main(){
ios::sync_with_stdio(0);
cin.tie(0);cout.tie(0);
int n,q;cin>>n>>q;
for(int i=1;i<=n;i++){
int num;cin>>num;
for(int j=1;j<=num;j++){
int tem;cin>>tem;
arr[i].push_back(tem);
}
}
for(int i=1;i<=q;i++){
int l,r;cin>>l>>r;
in[l]++;in[r]++;
temq[i]=pii(l,r);
}
for(int i=1;i<=q;i++){
if(in[temq[i].first]<in[temq[i].second])
query[temq[i].first].push_back(pii(temq[i].second,i));
else
query[temq[i].second].push_back(pii(temq[i].first,i));
}
for(int i=1;i<=n;i++){
for(auto it:arr[i]) vis[it]=1;
for(auto it:arr[i]){
for(auto it2:query[it]){
if(vis[it2.first]) ans[it2.second]++;
}
}
for(auto it:arr[i]) vis[it]=0;
}
for(int i=1;i<=q;i++){
if(!ans[i]) ans[i]=-1;
else ans[i]=1ll*n*ksm(ans[i],mod-2)%mod;
}
for(int i=1;i<=q;i++)
cout<<ans[i]<<"\n";
return 0;
}
树上启发式合并(dsu on tree)
那么如何将启发式合并带到树上呢?树上启发式合并的问题一般是对子树信息的离线询问,因为是对子树信息的询问,所以对于每个询问,暴力的做法就是对子树进行一次dfs,那么显然 n n n 次询问不同节点,时间复杂度会来到 O ( n 2 ) O(n^2) O(n2) 。但又因为所求是子树信息,所以对于一个父节点,我们可以通过对它几个儿子的信息进行整合从而得到父节点的答案。
以树上启发式合并的经典例题为例,树上数颜色
,该题中给定一棵树,每个结点有一个点权代表颜色,要求输出每个节点的子树中不同颜色的数量。
可以发现,这个问题跟前面盒子小球问题很像,可以看成每次操作都是子节点的盒子里的球放到父节点的盒子中。所以可以直接给每一个节点开一个 s e t set set 维护子树中不同颜色球的数量,然后一次 d f s dfs dfs ,按照 s e t set set 大小进行对应父子节点的集合合并。
但是上面那种做法其实不是正宗的书上启发式合并的写法,因为题目限制太刚好,可以直接用 s e t set set 维护答案。那么考虑下面这个问题呢?
题意
给一棵树,每个节点有点权视为颜色,定义一个子树中占主导地位的颜色是子树中出现次数最多的颜色,求每个节点的子树中,占主导地位的颜色编号和。
这道题也是问子树信息,但是明显就无法像刚刚那题一样直接用 s e t set set 来维护。于是引出书上启发式合并的标准板子写法。
总的来说,书上启发式合并的思想也和普通的启发式算法类似,注意到对于一个节点,它的几个儿子之间的答案是互相独立的,但是它们组合在一起才等于父节点的答案。参考启发式算法,我们先对节点的轻儿子进行统计答案,但不计入贡献,再对重儿子进行遍历,并保留贡献,最后再次遍历轻儿子保留贡献,此时父节点的答案就是当前维护到的贡献和。
这个过程看起来比较繁琐,但其实比较模板,真正的板子里,我们需要独立考虑的只有两个函数,即加入单点对于答案的贡献以及删除单点对于答案的贡献。该过程其实有点类似莫队算法,也是对节点加加减减,最后将此时的贡献离线计入答案。
复杂度证明(来自oiwiki):
我们像树链剖分一样定义重边和轻边(连向重儿子的为重边,其余为轻边)关于重儿子和重边的定义,可以见下图,对于一棵有 n 个节点的树:
根节点到树上任意节点的轻边数不超过 log n \log n logn 条。我们设根到该节点有 x x x 条轻边该节点的子树大小为 y y y,显然轻边连接的子节点的子树大小小于父亲的一半(若大于一半就不是轻边了),则 y < n / 2 x y<n/2^x y<n/2x,显然 n > 2 x n>2^x n>2x,所以 x < log n x<\log n x<logn。
又因为如果一个节点是其父亲的重儿子,则它的子树必定在它的兄弟之中最多,所以任意节点到根的路径上所有重边连接的父节点在计算答案时必定不会遍历到这个节点,所以一个节点的被遍历的次数等于它到根节点路径上的轻边数 +1(之所以要 +1 是因为它本身要被遍历到),所以一个节点的被遍历次数 = log n + 1 \log n+1 logn+1, 总时间复杂度则为 O ( n ( log n + 1 ) ) = O ( n log n ) O(n(\log n+1))=O(n\log n) O(n(logn+1))=O(nlogn),输出答案花费 O ( m ) O(m) O(m).
书上启发式合并板子
const int maxn=1e5+10;
vector<int>G[maxn];
int cnt[maxn],ans[maxn],col[maxn],sum;//sum全局变量,是题目询问的数值
int siz[maxn],son[maxn];//区分重儿子
int dfn=0,L[maxn],R[maxn],mp[maxn];//dfn序写法,mp将dfn序映射回原先的点
void dfs1(int u,int f){
L[u]=++dfn;
mp[dfn]=u;
siz[u]=1;
int maxson=0;
for(auto v:G[u]){
if(v==f) continue;
dfs1(v,u);
siz[u]+=siz[v];
if(siz[v]>maxson) maxson=siz[v],son[u]=v;
}
R[u]=dfn;
}
void add(int u){//加入单点的贡献
cnt[col[u]]++;
if(cnt[col[u]]==1) sum++;
}
void del(int u){//删除单点的贡献
cnt[col[u]]--;//一旦开始del,几乎都有都会清空,所以在外面一次性清零就可
if(cnt[col[u]]==0) sum--;
}
void dfs2(int u,int f,bool keep){//keep是否保留u子树对答案的贡献
for(auto v:G[u])
if(v!=f&&v!=son[u])
dfs2(v,u,0);//先遍历轻儿子,计算答案,不保留贡献
if(son[u]) dfs2(son[u],u,1);//再遍历重儿子,保留贡献
//然后加入u单点的贡献
add(u);
for(auto v:G[u])//第二次遍历轻儿子,保留贡献
if(v!=f&&v!=son[u])
for(int i=L[v];i<=R[v];i++)
add(mp[i]);
for(auto it:q[u])
ans[it]=sum;
if(!keep){
for(int i=L[u];i<=R[u];i++)
del(mp[i]);
sum=0;
}
}