【题解】CSP2019-Day2
这次考试的状态非常渣……连T1都没有想出来……
考试考完了直接就自闭了,所以没有写题解。
Emiya家的饭(meal)
当时考场上想出来是一个类似容斥的东西,但是状态不好想不下去了……
不过,这题确实是一道标准的提高组难度的计数DP题。需要的想法有:反向计数与主元法。
想法
由于“每种主要食材至多在一半的菜中被使用”这个条件比较难刻画,考虑它的相反条件:“存在一种主要食材,在超过一半的菜中被使用”。
这个相反条件看起来比之前的条件好了一点,但是感觉还是比较难刻画……
我们考虑“超过一半”这个限制有什么性质。显然,在任何方案中,至多有一种主要食材被使用了超过一半次。
这样,我们就想到枚举这一种食材,并对不合法的方案进行计数。
朴素DP
由于每道菜的烹饪方法互不相同,我们考虑沿着烹饪方法这一维进行DP。
枚举使用次数超过一半的食材\(x\),设\(f_{i,j,k}\)表示考虑前\(i\)种烹饪方法,已经选了\(j\)种食材,且食材\(x\)恰被使用了\(k\)次的方案数。设\(S_i=\sum_ja_{i,j}\),则转移式为:
最后的答案即为\(\sum_x\sum_{j<2k}f_{n,j,k}\)。
转移是\(O(1)\)的,但状态数是\(O(n^3)\)的。又因为要枚举食材\(x\),总复杂度是\(O(mn^3)\)的,会TLE。
优化
DP常见的优化策略是缩减状态或者用数据结构优化转移。然而,本题似乎没法用数据结构优化……
考虑如何缩减状态,显然只能够尝试将\(j,k\)缩成一维。由于我们最后只需要保证\(j<2k\)。将\(j\)和\(k\)换成一个存储\(2k-j\)的新的下标即可。这个下标的意义是第\(x\)种食材的使用次数减去其他食材的使用次数的值。
我们稍微改动一下上面的转移即可:
注意在程序实现中,\(k\)可能是负的,需要整体加上一个\(\ge n\)的常数。
注
抽象一下以上的思路,设\(P_i\)表示满足第\(i\)种食材的使用次数不超过一半的方案,则答案即为\(|\bigcap P_i|\)。
我们发现了一个特点:\(\overline{P_i}\cap\overline{P_j}=\varnothing\)。于是,
这是容斥原理的思路的另一种应用(还有一种:转化为一堆集合交的和)。
划分(partition)
初步
看到这题,肯定会首先想到DP啦~
可以设\(f_{i,j}\)表示DP到了\(i\),最后一段是\([i,j]\)的最小平方和。设\(s_{l,r} = \sum_{i=l}^ra_i\),则转移如下:
若采用朴素实现,总复杂度\(O(n^3)\)。
转移的优化
只需要快速求得\(\min_{l\le k\le j-1}f_{j-1,k}\)即可。
由于\(s_{i,j}\)随着\(i\)的增加而增加,所以\(l\)随着\(i\)的增加而减少,故对一个\(j\),只需要枚举所有\(i\),并维护\(l\)即可。
考场上:
由于\(s_{k,j-1}\)随着\(k\)的增加单调递减,预处理\(s_{k,j-1}\)与\(g_{l,r}=\min_{l\le k\le r}f_{r,k}\)之后,可以用二分得到\(k\)的最小取值\(l\)并得到答案。
最后是\(O(n^2\log n)\)的。
总复杂度是\(O(n^2)\)的。
再进一步
我们之前的DP已经很难优化了:状态数难以缩减,再怎么优化也止步于\(O(n^2)\)。
此时的一个常见思路是寻找决策的性质(如决策单调性)。
我们考虑寻找\(j,k\)的性质。通过打表我们可能可以找到这一个规律:
对于最优解\(ans_i = \min_j f_{i,j}\),其决策点\(j\)是最大的。同样,对其它的决策点也满足这个最大性。
从直观上来看,这个结论比较说得通:若每一个连续段的平方和都比较小,那么其总和也应该比较小。我们尝试证明这个结论。
设\(A_1\ge\dots\ge A_{l_a}\ge A_{l_a+1}=0\ge \cdots\)为满足每一个划分点都尽量大,段数尽量多的划分(\(A_i\)表示第\(i\)段的和)。考虑一个与\(A\)不同的划分\(B_1\ge \dots\ge B_{l_b}\ge B_{l_b+1}=0\ge\cdots\)。显然,有\(\sum_{i\le k}A_i\le \sum_{i\le k}B_i\)。
我们只需要证明任何满足\(\sum_{i\le k}A_i\le \sum_{i\le k}B_i\)的(不一定是合法划分的)\(B\)不是更优的即可。
若\(\sum_{i\le k}A_i= \sum_{i\le k}B_i\),则\(B=A\),\(B\)不比\(A\)更优。
设\(SA_{i}=\sum_{k\le i}A_k\)。考虑最小的\(k\)使得\(SA_k\ne SB_k\),显然\(SA_k<SB_k\)。考虑最小的\(l\)使得\(SA_l=SB_l\)。由于\(A_k<B_k\),\(\sum_{i=k}^l(A_i-B_i)=0\),故存在\(i\ge k\)使得\(A_i>B_i\)。有\(B_k>A_k\ge A_i>B_i\),故\(B_k-B_i\ge 2\),\(B\)在\([k,i]\)间的值的跨度至少为\(2\)。
考虑\(B\)的最靠后的最大值和最靠前的最小值,将他们分别\(-1\)和\(+1\),可以得到新数列\(B'\),仍然满足\(B_i\ge B_{i+1}\)与\(\sum_{i\le k}A_i\le \sum_{i\le k}B_i\)。重复操作后,可以使得\(B\)变为\(A\)(因为\(i\)在每一“堆”操作后递减)。
算法
维护\(f_i\)表示以\(i\)为结尾的,最小的最后一段的和:
由于上式等价于求最大的\(j\),满足\(f_j+s_j\le s_i\),单调队列(+指针)维护递增的\(\min_{k\le j<i}f_j+s_j\)即可。
实现
哇……这题除了要写高精度以外它甚至还卡了空间和时间……最多只能开\(1024*1024*1024/(4*10^7)/8=3\)个long long
数组……。写朴素的高精度甚至会TLE……需要用奇怪的优化方法。
注意到本题用__int128
是存的下的,我们考虑用两个long long
来保存答案:
typedef long long ll;
const ll P1 = 1e9, P2 = 1e18;
pair<ll,ll> mul(ll a, ll b) {
ll a1 = a/P1, a0 = a%P1;
ll b1 = b/P1, b0 = b%P1;
ll ans0 = a0*b0, ans1 = a1*b1;
ll tmp = a1*b0+a0*b1;
ans0 += tmp%P1*P1;
ans1 += tmp/P1;
while (ans0 >= P2) {
ans0 -= P2;
ans1++;
}
return make_pair(ans1, ans0);
}
参考
树的重心(centroid)
考场上这题我只拿到了暴力分……(即使是75分也算比较可观了……)
(本题的各种各样的)算法:换根+倍增/(以重心定根+)数据结构/数学(推式子)
思路
本题的部分分启发我们去找性质:
- 链的部分分启发我们去考虑树链剖分。
- 二叉树的部分分启发我们去考虑以重心为根的情况。
- 由于要同时考虑子树与外子树的情况,这启发我们想到换根法。
性质:
- \(u\)在根节点所在的重链上。
- 若\(u\in son_{rt}\),\(v\)为\(u\)的重心,则\(rt\)的重心为\(v\)的祖先。
- 若\(u\)为重心,只有\(u\)的重儿子(或父亲)有可能是重心。
计数方法:
- 对于每一个点,我们计算它作为重心的次数:这会导致实现偏向数据结构(BIT、可持久化线段树、树状数组等)。
- 对于每一个分割,我们去找两课树的重心:这回导致实现偏向图论类方法(树链剖分、找重儿子、倍增等)。
方法1.0:考虑点
考虑一个点\(u\),我们想要知道,它会成为几次重心。
将树在\(u\)处定根,则移除一条边相当于删除一个子树。
设其最大的子树大小为\(S=siz_w\),则可以分为两种情况:
- 删去的子树\(tree_v\not\subseteq tree_w\):则当且仅当\(S\le \lfloor (n-siz_v)/2\rfloor\),即\(siz_v\le n-2S\)时,\(u\)成为重心。(当\(0<n-2S\)时有解)
- 删去的子树\(tree_v\subseteq tree_w\):设除了\(tree_w\)最大的子树大小为\(T\),则当且仅当\(T\le \lfloor(n-siz_v)/2\rfloor\)且\(S-siz_v\le \lfloor(n-siz_v)/2\rfloor\),即\(2S-n\le siz_v\le n-2T\)时\(u\)成为重心。(总是有解)
因此,我们考虑处理出以\(u\)为根,各个儿子的\(\{siz\}\)的可重集合的情况,再查询一段区间内的\(siz\)的个数即可。
维护方法
可以用可持久化线段树(可能可以线段树合并?或者将所有查询离线化?)维护。特别地,\(fa_u\)的情况,相当于\(tree-anc_u-tree_u\)与\(\{u\}+anc_u-\{1\}\)取反。
这玩意细节又多,常数又不知道大到哪里去(差点超时),不推荐。
类似方法
方法1.1:考虑点,但是以重心为根
我们先找到重心\(rt\),并以它作为根。这样和随意选根有什么区别呢?
为了方便叙述,对于一次删边\((x,y)\)的操作(不妨设\(y\in son_x\)),称\(tree_y\)为“子树”,\(tree_{rt}-tree_y\)为“剩余部分”。
性质
性质1:对\(w\in son_{rt}\),如果删掉了\(tree_w\)内的一条边\((u,v)\),则剩余部分的重心一定不在\(tree_w\)内。
证明:因为\(2s_w\le n\),则\(2(s_w-S)\le n-2S< n-S\),故\(2(n-s_w)>n-S\),故\(w\)不可能为重心。实际上,我们可以得到一个推论:
推论2:若\(x\ne rt\)为重心,则删去的边\((u,v)\)一定不在\(tree_x\)内。
阐述:
- \(x\)为子树的重心,则删去的边一定为\(x-rt\)路径上的某一条边,不在\(tree_x\)内。
- \(x\)为剩余部分的重心,则由性质1,可知删去的边一定不在\(tree_x\)内。
因此,对一个点\(u\ne rt\),他作为重心仅当删去了一条边\((x,y)\),且:(设树减少的大小为\(S\))
- \((x,y)\)不在\(tree_u\)内。
- \(\forall v\in son(u),s_v\le (n-S)/2\),且
- \((n-S-s_u) \le (n-S)/2\)。
即\(n-2s_x\le S\le n-2g_x\)。
对于根的情况,可以另外判断。
可以用树状数组维护,具体见这篇题解。
参考
方法2:考虑边
考虑一种删边情况,我们需要快速求出,划分后的所有重心。
考虑如何求一棵(子)树的重心:若\(u\)不为重心,则重心一定在其重儿子的子树内,从根一直跳重儿子即可。
做法
我们考虑一个划分\((u,v)\),不妨设\(dep_u>dep_v\):
考虑如何求\(tree_u\)的重心:我们从\(u\)出发,一直向重儿子跳,跳到找到重心(即,重儿子的子树大小不超过原树的一半)为止。
考虑如何求出\(tree-tree_u\)的重心:我们从\(v\)出发,一直向上跳到父亲的子树不再是重子树,再一直向重儿子跳即可。(注:可以通过换根转化为上一种情况)
实现
第一次遍历时,预处理出重儿子/次重儿子,以及向重儿子的倍增数组。
第二次遍历时,用换根DP的思路,若当前点\(u\)已经为根,枚举\((u,v)\)边,将它断掉,此时只有\(u\)的重儿子发生了改变,处理掉倍增的部分即可。求出\(v\)的联通块的中心的方法类似。递归之前把\(v\)转化为根即可。
更进一步地,可以在将\(v\)转化为根之后再处理\(u\)的情况。
在实现中,注意到,当\(u\)为根时,不需要保证\(u\)的信息的正确性,只需要保证\(v\ne u\)的信息的正确性即可,因为我们实际上只需要对某一个\(v\)求出它的答案并换根,注意换根时重新计算/恢复\(u\)的信息即可。(这样做有一个好处:不需要重新计算\(v\in son_u\)以及\(v=fa_u\)的答案)
脚注
实现时犯的智障错误:
- 判断\(u\)的重儿子是否是重心的条件写错了。
- 忘记在每次
dfs1
之前清零f
与g
了 - 判断什么时候应该向下跳的
u
不代表根节点……应该在最开始做一个copy避免混淆……
参考
某神奇的\(O(n)\)做法
就如之前所说的,可以在\(O(n)\)计算出所有子树的重心,但是很难对去掉子树的部分找到一个重心单调移动的计算序列……
以重心为根,考虑删去的边在哪棵子树内:
- 不在重儿子的子树内:则枚举所有可能的子树大小,在根所在的重链上面走就好了。
- 在重儿子的子树内:
- 重儿子仍然为重儿子:此时,根仍然为重心。
- 次重儿子变为了重儿子:在次重儿子的重链上走即可。
这样就用纯图论方法完成了这题。