test20230830
\(\text{Luogu2860}\)
给定一张 \(n\) 个点 \(m\) 条边的无向图,问至少添加多少条边才可以将图补成一张边双图。
思路点拨
警钟长鸣:图可能不连通。
我们考虑如果一个子图就是边双那么就可以不管他,自然缩边双之后考虑树上问题。怎么补全呢?就是每一次挑选出树的直径然后将叶子连边,一直操作到不可以操作为止。时间复杂度 \(O(n^2)\) 。但是我们发现这个操作等价于叶子树除二向上取整,所以可以做到 \(O(n)\) 。
\(\text{Luogu3469}\)
给定一张无向图,求每个点被封锁之后有多少个有序点对\((x,y)(1<=x,y<=n)\)满足 $x $ 无法到达 $y $ 。
思路点拨
分两类讨论:
-
\(x\) 不是割点,那么答案就是 \(2\times (n-1)\) 。
-
\(x\) 是割点,考虑建立圆方树。我们认为 \(siz_x\) 表示 \(x\) 节点子树内(含自己)有多少个圆点。那么答案的统计又分三类讨论:
-
儿子节点的贡献:\(\sum siz_v\times (n-siz_v)\) 。
-
父亲节点的贡献: \((n-siz_x)\times siz_x\)
-
自己的贡献:\(n-1\) 。
时间复杂度 \(O(n)\) 。
建造军营
有一张 \(n\) 个点, \(m\) 条边的无向图,你需要从中选择一些关键点(至少一个)和关键边使得不存在一条非关键边边满足割掉之后使两个关键点不连通。求分配关键点和关键边的方案数。
思路点拨
注意到在同一个边双联通分量内点和边可以随便选,所以考虑缩边双联通分量之后进行树形 dp 。
在 dp 之前,我们统计出每一个边双的节点个数 \(a_i\) 和边个数 \(b_i\) 。下文,我们乘一个边双就是一个树上的节点。
如果一个节点被选择了军营,并且这个它的子树外还有军营,那么他向父亲一定是需要连边的。如果一个节点被选择军营,但是他的子树外没有军营了,那么子树外的边是可以任意选的。按照常规的手段,我们定义状态 \(f_{i,0/1,0/1}\) 表示考虑到节点 \(i\) ,子树内是否存在军营被选择,子树外是否存在军营被选择的方案数对 \(1e9+7\) 取余数的值。
上述的状态转移无疑是十分复杂的,我们发现可以简化状态。如果一个节点的子树内不存在军营,不论字数外是否有军营,它的贡献都是固定的。他可能会向某些祖先选择关键边,我们在祖先处统计。由此,我们得到了终极的状态:
\(f_i\) 表示考虑到节点 \(i\) 的子树,子树内存在军营并且所有军营都连边连向子树的根的方案。\(g_i\) 表示节点 \(i\) 的子树内的边的数量,\(g\) 的转移自然不必多说。
考虑节点 \(i\) 的可能性,显然有 \(2^{a_i+b_i}\) 种分配的方案,我们以此作为 dp 的边界。对于一个儿子 \(v\),我们考虑如果这个儿子,没有被建造军营,答案就是 $f_i=f_i\times 2^{sum_v} $ 。如果建造了军营答案就是 \(f_i=f_i\times f_v\) 。最后,我们减去子树内都不选的情况也就是 \(2^{sum_i}\) 。那么节点 \(i\) 的贡献还要考虑子树外的边(不能有他连向父亲的那条边,不然会算重),\(ans=ans\times f_i\times 2^{m-sum_i-1}\) 。当然如果是根,他没有父亲,那么 \(ans=ans\times f_i\times 2^{m-sum_i}\) 。
这种做法为什么是对的,因为对于任意一种方案,只要有军营,就会在深度最小的点处被统计恰好一次,所以正确性显然。
\(\text{Luogu8719}\)
小蓝最近学习了一些排序算法, 其中冒泡排序让他印象深刻。
在冒泡排序中, 每次只能交换相邻的两个元素。
小蓝发现, 如果对一个字符串中的字符排序, 只允许交换相邻的两个字符, 则在所有可能的排序方案中, 冒泡排序的总交换次数是最少的。
例如, 对于字符串 lan 排序, 只需要 \(1\) 次交换。对于字符串 qiao 排序, 总共需要 \(4\) 次交换。
小蓝的幸运数字是 \(V\), 他想找到一个只包含小写英文字母的字符串, 对这个串中的字符进行冒泡排序, 正好需要 \(V\) 次交换。请帮助小蓝找一个这样的字符串。如果可能找到多个, 请告诉小蓝最短的那个。如果最短的仍然有多个, 请告诉小蓝字典序最小的那个。请注意字符串中可以包含相同的字符。
\(V \leqslant 10^4\)
思路点拨
十分巧妙的贪心。
我们考虑怎么求出这个字符串的最短长度,再求出最小的字典序方案。可以发现,如果一个字符串长度为 \(len\) ,其中第 \(i\) 种字符出现了 \(cnt_i\) 次,那么我在这个字符串种插入一个字符 \(x\) ,贡献就是 \(len-cnt_x\) 。由这一点可以贪心的想到,我们希望 $cnt $ 数组的大小比较均匀,所以每一种字符的数量就应该尽量接近。
我们在 \(len\) ,和数组 \(cnt\) 的基础上,暴力枚举下一个加入的字符是什么,找到他然后统计它的贡献。什么时候贡献和超过 \(n\) 了,这就是最短长度。正确的原因和上述的原理差不多,我们希望 \(cnt\) 尽量平均那么肯定是从原来的 \(cnt\) 简单修改而来的而不是将原先的 \(cnt\) 大幅改动,不然原来的解也不是最优解。
for(int i=1;i<=26;i++)
f[i]=f[i-1]+(i-1);
for(int i=0;i<26;i++) cnt[i]=1;
for(int i=27;i<=n;i++){
int mx=0;
for(int j=0;j<26;j++)
mx=max(mx,f[i-1]+i-cnt[j]);
for(int j=25;j>=0;j--)
if(f[i-1]+i-cnt[j]==mx){
f[i]=f[i-1]+(i-1)-cnt[j];
cnt[j]++;
break;
}
}
接下来考虑构造一组最优解。我们按照刚才的思路,枚举第一个可能的字符并且修改 \(cnt\) 。可以在剩下的字符以内使得贡献大于等于 \(n\) ,那么这个字符就是可以填入的。我们贪心的选择字典序最小的继续操作。这个过程本质上就是在判断如果我选择了这个字符,是否可行,如果可行,那么就继续把这组解找出来。
如果我们知道了 \(cnt\) ,怎么计算后续的贡献也和上述代码查不了太多:
int get_add(int w){
int ans=0;
for(int i=0;i<w;i++) ans+=h[i];
for(int i=w+1;i<26;i++) ans+=g[i]+h[i];
return ans;
}
bool check(int pos,int m,int ans){
for(int i=pos+1;i<=m;i++){
int mx=0,max_id=0;
for(int j=0;j<26;j++){
int w=get_add(j);
if(w>mx){
mx=w;
max_id=j;
}
}
h[max_id]++;
ans+=mx;
}//这一部分和求出最短长度的函数流程差不多,每一次找到目前最优的字符,然后增加 cnt
//贪心的结论依然成立,我们希望g+h 尽量均匀,所以这么做就是正确的
memset(h,0,sizeof(h));
if(ans>=n) return 1;
return 0;
}
void solve(int m){
int ans=0;
for(int i=1;i<=m;i++){
for(int j=0;j<26;j++){
ans+=get_add(j);
g[j]++;
if(check(i,m,ans)){
cout<<(char)('a'+j);
break;
}
else{
g[j]--;
ans-=get_add(j);
}
}
//这一部分可以看到,我们是暴力枚举最优的字符然后使用 check 函数判断是否有解
}
}
上述代码中 \(g\) 数组就是 \(cnt\) 数组, \(h\) 数组是我们在判断可行的时候另一个 \(cnt\) 数组。这里着重讲述一下重点 \(add\) 函数:
int get_add(int w){
int ans=0;
for(int i=0;i<w;i++) ans+=h[i];
for(int i=w+1;i<26;i++) ans+=g[i]+h[i];
return ans;
}
为什么在 \(0-w\) 和 \(w+1-26\) 的循环中 \(h\) 函数都可以产生贡献而 \(g\) 不可以呢?
因为 \(g\) 就是我们已经填完的字符,所以它的位置只可以在前面,那么字典序更大才有贡献。但是 \(h\) 数组指的是我们在判断是否有解的时候添加的字符,它的位置是任意的,只要可以让贡献最大化就可以,所以位置任意。如果一个字符得字典序小于 \(w\) ,我们就可以放到 \(w\) 后面;反之就放前面。这就为什么 \(h\) 可以产生贡献的原因。