3.14 好题分享
咕咕咕
3.15 讲课
主题是值域扫描。
往往有一些题,题目限制不是相对大小关系,而是跟具体的数值有关。如果没有特殊的性质,这种题往往只能沿着值
域去扫描,亦或者较简单的题中直接记录到状态里,这里主要讲沿着值域去扫。
AT_ddcc2017_final_d なめらかな木
看到 \(|c_u-c_v|\le 2\) 这种限制,你会发现如果你对子树设状态,那么很难想出一个多项式的复杂度。这个时候可以考虑在值域上 dp。
首先肯定要记录状态 \(i,x,y\) 表示当前考虑到了 \(i\) 这个数,\(i\) 填到了 \(x\),\(i-1\) 填到了 \(y\)。然后我们将 \(x,y\) 从树上割掉,发现最多只会形成 \(7\) 个连通块,每个连通块要么全出现过,要么全没出现过,于是可以用 \(2^7\) 的状态表示每个连通块是否出现过。时间复杂度 \(\mathcal{O}(n^3\times 2^7)\)。
P9479 [NOI2023] 桂花树
仍然是考虑在值域上做操作。我们发现最终的树要满足 \(1\sim n\) 构成的虚树与原树结构相同,相当于把节点 \(n+1\sim m\) 依次插入原树,那么插入的方式有以下几种:
- 插入到某个节点的儿子
- 在一条边上新建一个节点,插入到这个节点上
- 在一条边上新建一个虚点,然后插入到这个节点的儿子上
- 插入到某一个虚点
(在虚树上 dp 一般都是这些方式,可以记住)
然后还需要满足 \(lca(i,j)\le \max(i,j)+k\) 的限制,因为是按节点编号顺序加入的,所以操作 1、2 一定都是满足要求的。那么限制就相当于在 3 操作中新建一个虚点,你需要在后面 \(k\) 次操作内选一个点插入到这个虚点。
那么同一时刻只有最近 \(k\) 次内才会有没有插入的虚点的情况。我们可以记录一个状态 \(S\),第 \(i\) 位表示倒数第 \(i\) 次操作是否是插入虚点而且没有被填。那么此刻如果第 \(k\) 是 \(1\),就必须要填这一位。我们设当前要插入的点是 \(i+1\),那么虚树大小 \(siz=i+popcount(S)\)。
接下来考虑转移,设 \(g_S,f_S\) 分别表示上一轮、这一轮,状态为 \(S\) 的方案数。
先看当前 \(S\) 是否第 \(k\) 位为 \(1\),如果是就必须填,有 \(f_{S<<1\&(2^k-1)}\larr g_S\)。否则有转移:
- 插入到某个节点的儿子上:\(f_{S<<1}\larr g_S\times siz\)
- 在一条边上新建一个节点,插入到这个节点上:\(f_{S<<1}\larr g_S\times (siz-1)\)
- 在一条边上新建一个虚点,然后插入到这个节点的儿子上:\(f_{S<<1|1}\larr g_S\times (siz-1)\)
- 插入到某一个虚点,枚举 \(j\),则 \(f_{(S^(1<<j))<<1}\larr g_S\)
代码
const int N = (1<<10)+5,mod = 1e9+7;
int pop[N],n,m,k;
ll f[N],g[N];
int main()
{for(int i = 0;i < N;i++)pop[i] = pop[i>>1]+(i&1);for(int c = rd(),t = rd();t--;){n = rd();m = rd();k = rd();int up = (1<<k)-1;for(int i = 1;i < n;i++)rd();memset(f,0,sizeof f);f[0] = 1;for(int i = n;i < n+m;i++){for(int S = 0;S <= up;S++)g[S] = f[S],f[S] = 0;for(int S = max(1,up+1>>1);S <= up;S++)(f[(S<<1)&up] += g[S]) %= mod;for(int S = 0;S <= up>>1;S++){for(int j = 0;j < k;j++)if(S>>j&1)(f[((S^1<<j)<<1)&up] += g[S]) %= mod;int siz = i+pop[S];(f[S<<1] += g[S]*(2*siz-1)) %= mod;(f[S<<1|1] += g[S]*(siz-1)) %= mod;}}printf("%lld\n",f[0]);}return 0;
}
树数叔术
手玩一下发现一些性质,比如 \(0,1\) 最多只有一个,\(0\sim m\) 中每个颜色都必须出现至少一次。然后这道题也可以明显的看出是按颜色从小到大一次插入。于是有结论:
一种方案合法当且仅当对于每一个颜色 \(i\),要么 \(i\) 出现了一次,要么 \(i\) 出现了多次且在颜色 \([0,1]\) 构成的虚树中,颜色 \(i\) 没有出现在叶子节点上。
证明必要性:如果一个颜色没有出现,那么选择 \(n\) 个点就不合法。如果一个颜色出现了多次且有一个在叶子节点上,那么选择这个叶子结点的补集就不合法。所以这两个条件都是必要的。
证明充分性:考虑归纳,如果颜色 \([0,i-1]\) 都满足条件了,此时颜色 \(i\) 只出现了一次,那么选择的集合中包括和不包括 \(i\) 都是合法的。如果出现了多次,考虑从任意部分截断,两边都至少有一部分 \([0,i-1]\),那么 \(min,mex\) 都与 \(i\) 无关,所以也满足条件。
接下来考虑如何 dp。有两种思路。第一种:
设状态 \(f_{i,x,y}\) 表示前 \(i\) 个颜色,虚树上虚点有 \(j\) 个,虚树大小为 \(k\),的方案数。加入颜色分成在叶子节点上加入一个或者在边上加入正整数个两种情况。然后还要考虑给点重标号,所以先求出每次标号的方案数,假设这次染色染了 \(x\) 个颜色,那么最后还要乘上 \(\frac{n!}{\prod x_i!}\)。因为模数有可能不是质数,所以要转化 \(\frac{n!}{\prod x_i!} = \prod{s_i\choose x_i}\),\(s_i\) 是 \(x_i\) 的前缀和。
因为还要枚举每次同一种颜色加了多少个并求出方案数,那么对于每个 \(i\),记状态 \(g_{i,x,y}\) 表示当前颜色 \(i\) 填了 \(j\) 个,虚树上虚点有 \(j\) 个,虚树大小为 \(k\) 的方案数。每次转移先 dp 求出 \(g\) 的答案(在边上加正整数个点),然后在叶子节点上加一个点这种情况再更新 \(g\),最后更新回 \(f\)。最后一步更新有 \(f_{i,x,y} \larr g_{j,x,y}*{y-x\choose j}\)。然后考虑 \(g\) 的转移。
- 填一个虚点:\(g_{j+1,x-1,y}\larr g_{j,x,y}\times x\)
- 在一条边上新建一个点:\(g_{j+1,x,y+1}\larr g_{j,x,y}\times (y-1)\)
然后是用 \(f\) 转移到 \(g\):
- 加在某个节点的儿子上,\(g_{1,x,y+1}\larr f_{i,x,y}\times y\)
- 如果是新建一个虚点并加在其儿子上,那么有 \(g_{1,x+1,y+2}\larr f_{i,x,y}\times (y-1)\)
把这几部分拼起来就有了 \(\mathcal{O}(n^4)\) 的做法:
代码
f[0][0][1] = 1;
for(int i = 1;i <= m;i++)
{memset(g,0,sizeof g);memcpy(g[0],f[i-1],sizeof f[i-1]);for(int j = 0;j <= n;j++)for(int x = 0;x <= n;x++)for(int y = x+i;y <= n;y++){ll now = g[j][x][y];if(x)(g[j+1][x-1][y] += now*x) %= mod;(g[j+1][x][y+1] += now*(y-1)) %= mod;}for(int x = 0;x <= n;x++)for(int y = x+i;y <= n;y++){ll now = f[i-1][x][y];(g[1][x][y+1] += now*y) %= mod;(g[1][x+1][y+2] += now*(y-1)) %= mod;}for(int j = 1;j <= n;j++)for(int x = 0;x <= n;x++)for(int y = x+i;y <= n;y++)(f[i][x][y] += g[j][x][y]*C[y-x][j]) %= mod;
}
cout << f[m][0][n] << endl;
做法二:
我们考虑在加入一个叶子节点时,直接加入一条链上去,表示这条链上的点都可以任意染色。那么设 \(f_{i,j,k}\) 表示前 \(i\) 个颜色,钦定了后面有 \(j\) 个颜色只在树内填,虚树大小为 \(k\) 的方案数。然后在加链的时候直接依次标号每个点的顺序,于是最后答案乘个 \(n!\) 即可。
那么 \(f_{i-1,j,k}\) 加入一条长度为 \(x\) 的链为:\(f_{i,j,k+x}\larr f_{i-1,j,k}\times k\times j^{x-1}\),这部分可以用前缀和优化。
然后还有两种情况:
- 如果当前颜色是在树内的,有 \(f_{i,j,k}\larr f_{i-1,j+1,k}\)。
- 这样计算有可能会出现颜色 \(i\) 在树内,且在之前的计算中依次都没有出现过,不满足条件,需要减去这一部分的方案数,为 \(f_{i-1,j,k}\)
总时间复杂度为 \(\mathcal{O}(n^3)\)
代码
for(int i = 0;i <= n;i++)f[0][i][1] = 1;
for(int i = 1;i <= m;i++)for(int j = 0;j <= n;j++)for(int k = i,s = 0;k <= n;s = (1ll*s*j+f[i-1][j][k]*k)%mod,k++)f[i][j][k] = (s+f[i-1][j+1][k]-f[i-1][j][k]+mod)%mod;
ans = f[m][0][n];
for(int i = 1;i <= n;i++)(ans *= i) %= mod;
cout << ans << endl;
AT_dwacon6th_prelims_e Span Covering
一个比较经典的连续段 dp。我们首先把 \(L_i\) 按长度排序。然后设 \(f_{i,j,k}\) 表示加入了前 \(i\) 条线段,一共形成了 \(j\) 个连续段,总长度为 \(k\) 的方案数。那么答案就是 \(f_{n,1,X}\)。考虑加入一条长度为 \(L_i\) 的线段时的转移:
- 单独成一段:\(f_{i,j+1,k+L_i}\larr f_{i-1,j,k}\)
- 被之前的线段完全包含,那么可以放的位置有 \(k-j\times (L_i-1)\) 个,\(f_{i,j,k}\larr f_{i-1,j,k}\times(k-j\times (L_i-1))\)
- 部分与之前的线段的一部分相交,枚举总长度增加了 \(x\),\(f_{i,j,k+x}\larr f_{i-1,j,k}\times 2j\)
- 合并了两个段,枚举总长度增加了 \(x\),放的方案数有 \((j-1)\times (L-x+1)\),\(f_{i,j-1,k+x}\larr f_{i-1,j,k}\times (j-1)\times (L-x+1)\)
然后因为这题有很多无用状态,所以直接暴力做就可以了,或者用前缀和优化,时间复杂度为 \(\mathcal{O}(n^2X)\)。
代码
const int N = 105,M = 505,mod = 1e9+7;
int a[N],n,X;
ll f[N][M],g[N][M],s1[N][M],s2[N][M];
void solve(int x)
{for(int i = 1;i <= n;i++)for(int j = 1;j <= X;j++){s1[i][j] = (s1[i][j-1]+f[i][j])%mod;s2[i][j] = (s2[i][j-1]+f[i][j]*j)%mod;g[i][j] = f[i][j];f[i][j] = 0;}for(int i = 1;i <= n;i++)for(int j = x;j <= X;j++){int k = max(0,j-x-1);ll sum1 = s1[i][j-1]-s1[i][k]+mod,sum2 = s1[i+1][j-1]-s1[i+1][k]+mod,sum3 = s2[i+1][j-1]-s2[i+1][k]+mod;f[i][j] = (g[i-1][j-x]*i+g[i][j]*(j-i*(x-1)+mod)+2*i*sum1+i*((x-j+1+mod)*sum2%mod+sum3))%mod;}
}
int main()
{n = rd();X = rd();for(int i = 1;i <= n;i++)a[i] = rd();sort(a+1,a+n+1);f[1][a[n]] = 1;for(int i = n-1;i;i--)solve(a[i]);cout << f[1][X] << endl;return 0;
}
3.17 联考
拿到题先看三道题,感觉 T1 比较可做,于是开始想。
我先想的是用一些数据结构去维护,比如平衡树,但是发现复杂度都不对。然后在手玩了大量性质后发现,对前一半序列或后一半序列中的某一个数,在归并加入这个数时,一定会把后面比它小的数一起加进去。于是在原序列会分成很多段,然后再在值域上用一个线段树维护即可。然后写完这道题大概是 10:00。
接下来开始看 T2。第一眼我没看到所有字符串要全部相同,以为是简单题,然后随便写了个发现第四个样例就挂了。然后又想了下,发现可以下每个字符串后面加不同的二进制串,这样可以获得 41 分。接下来又想了想,发现需要大量分讨,而且我也不太会证明上界,于是就跳了。
然后又去看 T3。T3 首先想知道一个字符串,最少划分成几个子序列。然后就设计出一个 \(\mathcal{O}(n^4)\) 的 dp,然后观察发现不需要记录当前是第几位,然后就有了 \(\mathcal{O}(n^3)\) 的做法。这个时候看到恰好 \(k\) 次,直接把大样例的表打出来,惊奇的发现有凸性,就可以直接用 wqs 二分。后面的部分应该有决策单调性,但是我不太会快速求贡献,于是放弃了。
最终得分:100+41+61=202。
总结
省选考炸了,缓了好久才缓过来。这次考试状态还行。
T1 题解
发现归并加入某个数时,一定会把这个数后面的比它小的数都加进去,于是把序列化分成很多段。那么在第一次排序后,每次排序都是把 \(\frac n 2\) 所在段切开,然后再分别插入进去。这个东西可以用值域线段树维护,每个点存储是否有以这个数为开头的段,如果有对应了原序列的哪个区间。每次切割直接把后面的段再暴力切割加入值域线段树,最多分割 \(n\) 次。然后找每个位置的答案可以线段树上二分,于是复杂度为 \(\mathcal{O}(n\log n)\)。
代码
#include <iostream>
#include <cstdio>
#include <cstring>
#include <algorithm>
#define ls x<<1
#define rs x<<1|1
#define lson x<<1,l,mid
#define rson x<<1|1,mid+1,r
#define ll long long
#define getchar() (p1==p2&&(p2=(p1=buf)+fread(buf,1,1<<21,stdin),p1==p2)?EOF:*p1++)
using namespace std;
const int N = 2e5+5,M = 1e6+5;
int t[N<<2],ans[M],a[N],b[N],st[N],top,n,m,pos = 1;
bool flag;
struct node{int l,r;}c[N];
struct ques{int t,i,id;}q[M];
void Do()
{for(int i = 1;i <= n;i++)b[i] = a[i];for(int i = 1,j = 1,k = n/2+1;i <= n;i++)a[i] = b[j<=n/2&&(k>n||b[j]<b[k])?j++:k++];
}
void modify(int x,int l,int r,int i,node v)
{if(l == r){c[i] = v;t[x] = v.r-v.l+1;return ;}int mid = l+r>>1;i <= mid?modify(lson,i,v):modify(rson,i,v);t[x] = t[ls]+t[rs];
}
int query(int x,int l,int r,int k)
{if(l == r)return a[c[l].l+k-1];int mid = l+r>>1;return k <= t[ls]?query(lson,k):query(rson,k-t[ls]);
}
void up(int l,int r)
{for(int i = l;i <= r;i = b[i])modify(1,1,n,a[i],{i,min(r,b[i]-1)});}
void solve(int x,int l,int r,int k)
{if(l == r){if(c[l].l+k > c[l].r)flag = 1;else up(c[l].l+k,c[l].r);c[l] = {c[l].l,c[l].l+k-1};t[x] = k;return ;}int mid = l+r>>1;k <= t[ls]?solve(lson,k):solve(rson,k-t[ls]);t[x] = t[ls]+t[rs];
}
char buf[1<<21],*p1,*p2;
inline int rd()
{char c;int f = 1;while(!isdigit(c = getchar()))if(c=='-')f = -1;int x = c-'0';while(isdigit(c = getchar()))x = x*10+(c^48);return x*f;
}
int main()
{freopen("magic.in","r",stdin);freopen("magic.out","w",stdout);n = rd();m = rd();for(int i = 1;i <= n;i++)a[i] = rd();for(int i = 1;i <= m;i++)q[i] = {rd(),rd(),i};sort(q+1,q+m+1,[](ques x,ques y){return x.t < y.t;});while(pos <= m&&!q[pos].t)ans[q[pos].id] = a[q[pos].i],pos++;Do();st[top = 0] = n+1;for(int i = n;i;i--){while(top&&a[i] > a[st[top]])top--;b[i] = st[top];st[++top] = i;}up(1,n);for(int i = 1;;i++){while(pos <= m&&(flag||q[pos].t == i))ans[q[pos].id] = query(1,1,n,q[pos].i),pos++;if(flag||pos > m)break;solve(1,1,n,n/2);}for(int i = 1;i <= m;i++)printf("%d\n",ans[i]);return 0;
}
T2 题解:
发现对于每一个等价类,我们可以将第一类阵营所有属于这个等价类的点标 A,其余标 C,第二类阵营中对应标 A,其余标 B,然后再补二进制来满足互不相同的限制。这个时候最坏情况有 \(m=n+2\),而这种情况只有两种 corner,分别对这两种 corner 进行微调。
代码没写(
T3 题解:
先考虑一个给定的字符串最少划分为几段,我们可以删去一个前缀极长的 A 段和后面对应的 B 段,然后重复这个操作。我们发现如果将第 \(i\) 个 A 和 B 匹配一定是优的。然后所有相交的区间可以一次匹配,那么答案可以转化为所有不相交的区间个数。我们设 \(f_{i,j}\) 表示用了 \(i\) 次操作,匹配到了第 \(j\) 个 A 的最小代价。
转移的贡献就是要把一段区间的 A 都排到指定位置。设 \(sum_i\) 表示第 \(i\) 个 A 前面 B 的个数,因为把一个 A 交换到 B 后一定不优,于是重排第 \(x\) 个 A 的贡献就是 \(\max(0,sum_x-j)\)。于是有转移:
直接做就是 \(\mathcal{O}(n^3)\),然后发现这个贡献有四边形不等式,于是可以 wqs 二分,复杂度为 \(\mathcal{O}(n^2\log n)\)。
接着考虑怎么快速计算 \(w(j,i) = \sum\limits_{x=j+1}^i \max(0,sum_x-j)\)。因为 \(sum_x\) 是不降的,所以一定是一个前缀 \(\max\) 取 \(0\),后缀取 \(sum_x-j\)。设 \(p_j\) 表示最后一个 \(sum_x < j\) 的 \(x\),于是 \(x\in[j+1,p_j]\) 时,\(\max\) 取 \(0\),其余取 \(sum_x-j\),我们对 \(sum_x\) 求一遍前缀和,于是:
然后把这个式子展开,就可以斜率优化了。
代码
#include <iostream>
#include <cstdio>
#include <cstring>
#include <algorithm>
#define ll long long
#define getchar() (p1==p2&&(p2=(p1=buf)+fread(buf,1,1<<21,stdin),p1==p2)?EOF:*p1++)
using namespace std;
const int N = 2e6+5,inf = 0x3f3f3f3f;
int q[N],g[N],n,k;char s[N];
ll f[N],sum[N],p[N];
inline ll Y(int j){return f[j]-sum[p[j]]+j*p[j];}
inline double slope(int i,int j){return (Y(i)-Y(j))*1.0/(i-j);}
int check(ll k)
{int l = 1,r = 1;q[1] = 0;for(int i = 1;i <= n;i++){while(l < r&&slope(q[l],q[l+1]) <= i)l++;int j = q[l];g[i] = g[j]+1;f[i] = f[j]+sum[i]-sum[p[j]]-k-j*(i-p[j]);while(l < r&&slope(q[r-1],q[r]) >= slope(q[r],i))r--;q[++r] = i;}return g[n];
}
char buf[1<<21],*p1,*p2;
inline int rd()
{char c;int f = 1;while(!isdigit(c = getchar()))if(c=='-')f = -1;int x = c-'0';while(isdigit(c = getchar()))x = x*10+(c^48);return x*f;
}
inline char gc()
{char c;while((c = getchar()) <= ' ');return c;}
int main()
{freopen("purple.in","r",stdin);freopen("purple.out","w",stdout);n = rd();k = rd();for(int i = 1,ca = 0,cb = 0;i <= n*2;i++)if((s[i] = gc()) == 'A')sum[++ca] = cb;else cb++,p[cb] = max(cb,ca);for(int i = 1;i <= n;i++)sum[i] += sum[i-1];ll l = -1ll*n*n,r = 0;while(l < r){ll mid = l+r>>1;check(mid) >= k?r = mid:l = mid+1;}check(l);cout << l*k+f[n] << endl;return 0;
}
3.18 讲课
主题是 OI 中的各种背包问题。