2024 省选复习
前言
快省选了,在复习,但是不知道干什么。
所以就写点东西吧。
就是瞎写写,所以可能有很多错误,如果发现了欢迎指出。
鸽了好多东西啊所以以后可能会来补的
内容比较乱,我也不知道写了啥。
常见错误 & 注意事项
-
文件名别忘打,别打错
-
数组不能开大,也不能开小,注意空间限制,也要看清有几个0,注意可能用到的大小!
-
题目要求什么千万不能读错,最好手算一下样例
-
fhq-treap,线段树等别忘了
pushup
和pushdown
-
注意看好了无解到底要求输出什么
-
线段树,AC 自动机等别忘了
build
-
多测要清空,数组清空要彻底,有时候只清空到 \(n\) 是不行的,要把所有可能用到的范围都清空。
-
多测不仅要清空数组,还要初始化单独的变量,建议写完之后用多组不同的(是为了防止 \(\max\) 等东西没初始化但查不出来)数据测一下。
-
dp 数组的初值要赋对
-
强制转
long long
要把1ll
写在前面,但最好直接#define int long long
,以及有的题目别忘了开long long
-
memset(cnt,0,sizeof(cnt)*(n+1));
会 RE。。。 -
离散化之前要排序,且不能排错数组
-
注意区分 \(n,m\) 一类的变量
-
不要完全相信大样例
算法复习
树状数组二分
树状数组二分可以抽象成这样一类问题:存在分割点 \(q\),使得 \(\leq q\) 的位置满足某个限制,而 \(> q\) 的位置不满足该限制,求 \(q\)。
从大到小考虑 \(1\leq 2^k \leq n\),每次尝试将 \(p\) 加上 \(2^k\),由于从大到小枚举,根据树状数组的结构,\(c_{p+2^k}\) 存的值即为 \(\sum_{i=p+1}^{p+2^k} a_i\),所以直接加上这个值判断一下,若满足就 \(p\leftarrow p+2^k,s\leftarrow s+c_{p+2^k}\),否则不动,然后继续枚举。其实就是一个倍增。
但是树状数组二分只适用于在整个树状数组上二分,如果是局部,就没办法利用树状数组的结构了。所以还是需要树状数组 + 二分
原本是树状数组二分的模板题,但是用树状数组 + 二分水过去了。
其实就是要求冰火两方消耗能量较少的 2 倍,所以要让这个最小值最大。
发现其实冰系战士消耗的能量关于温度是一个上升的斜线(本质上是一个前缀和),火系下降(本质上是一个后缀和)。
所以它们的最小值最大应该是在交点处。但线不连续,所以要找两个位置,一个是最后一个 \(sf_i\geq sc_i\) 的位置,一个是第一个 \(sc_i>sf_i\) 的位置,很显然的二分。带修所以树状数组维护前缀和。
这样二分是在整个树状数组上的,根据树状数组的结构可以用类似倍增的方式 \(O(\log n)\) 过去。而不需要 \(O(\log ^2 n)\) 的树状数组 + 二分
整体二分 + 树状数组二分
似乎并不需要树状数组二分。
这题主要难在结论的推导,推出性质之后似乎就是一个简单的离线维护了。
二维树状数组
放一个二维树状数组的代码
struct BIT{
int s[maxn][maxn];
void add(int x,int y,int z){
for(int i=x;i<=n;i+=lowbit(i))
for(int j=y;j<=m;j+=lowbit(j)) s[i][j]+=z;
return ;
}
int ask(int x,int y){
int res=0;
for(int i=x;i;i-=lowbit(i))
for(int j=y;j;j-=lowbit(j)) res+=s[i][j];
return res;
}
};
二维树状数组的模板。
二维树状数组维护二维前缀和。
设 \((i,j)\) 差分数组为 \(d_{i,j}\),\(s_{i,j}\) 表示 \((i,j)\) 的二维前缀和
所以维护 \(d_{i,j},d_{i,j} \cdot i,d_{i,j} \cdot j,d_{i,j}\cdot ij\) 四个二维树状数组就可以了。。
一维的高阶前缀和的推导方式类似,维护也是开好几个树状数组。
根号分治
就是利用根号平衡的思想,对于不同的数据用不同的维护方法。本质是数据分治
咕
莫队
莫队板题。首先把区间异或和转为两个异或前缀和的异或,然后维护一个区间内异或前缀和值 为 \(i\) 的数个数。
莫队 + 值域分块
发现对于一些没法 \(O(1)\) 插入删除的东西,如果使用 \(\log\) 数据结构,整体复杂度就变为了 \(O(n\sqrt n \log n)\),无法接受。
但是其实对于所有询问,修改操作有 \(n \sqrt n\) 次,但是询问操作只有 \(n\) 次,所以我们考虑继续运用根号平衡的思想,找一些 \(O(1)\) 修改,\(O(\sqrt n)\) 查询的方式,即值域分块。
但是有的时候我们有不同的需求,比如有时候二次离线需要 \(O(\sqrt n)\) 修改,\(O(1)\) 查询,此时换一种值域分块的方式,见 P5501 的二离部分,要注意区分。
莫队二次离线
有的时候我们没有办法 \(O(1)\) 实现插入删除,也没法值域分块(比如每次插入删除会考虑一个点对一个区间的贡献),所以我们需要把这些点对区间的贡献的询问离线下来再做,即莫队二次离线。
每一次端点移动可以看为一堆点对于一些区间的贡献,一般来说这种东西具有可减性。
记 \(f(i,[l,r])\) 表示点 \(i\) 对 \([l,r]\) 的贡献,假设某次移动右端点由 \(r\) 移动到 \(r'\),贡献为
前半部分东西可以扫一遍预处理,对于后半部分,看成一段区间对一段前缀的贡献。
考虑将 \(g([r+1,r'],[1,l-1])\) 挂到 \(l-1\) 上,然后再从前往后扫加入每个点,然后暴力回答挂在这个点上的询问,由于莫队端点移动的总长度是 \(O(n\sqrt n)\) 所以可以接受。
P5501 中二次离线部分依然不能 \(O(1)\) 处理,此时发现添加点(即修改操作)是 \(n\) 次,查询是 \(O(n\sqrt n)\) 次,此时我们需要修改根号,查询 \(O(1)\) 的数据结构。
注意:
-
贡献的符号
-
有时候要注意贡献的含义,比如逆序对,是查区间内比某个数大还是比某个数小。
回滚莫队
有的时候我们维护的信息不支持删除,比如最大值最小值,这个时候需要一种不用删除的莫队,即回滚莫队。
回滚莫队的思想是,把左端点在一个块内的询问按右端点从左到右排序(所以不能奇偶排序优化了,悲),每次把左端点放在块尾,然后向右移动右端点,遇到询问就暴力左移左端点,询问完后再撤回,把左端点放回块尾。
看上去很暴力,但是复杂度是对的。
较为复杂的莫队。
咕
fhq-treap
先放个基本操作模板吧,刚才试图背诵但是打错了好多。。。
void pushup(int p){
t[p].sz=t[t[p].l].sz+t[t[p].r].sz+1; //注意这里要加 1 算上自己,和线段树不一样
}
int add(int val){
t[++tot].val=val; t[tot].dt=rand();
t[tot].sz=1; return tot;
}
void split(int p,int val,int &x,int &y){
if(!p){x=y=0; return ;}
if(t[p].val<=val) x=p,split(t[x].r,val,t[x].r,y);
else y=p,split(t[y].l,val,x,t[y].l);
pushup(p); return ; //别忘了pushup
}
int merge(int x,int y){
if(!x||!y) return x+y;
if(t[x].dt<t[y].dt){
t[x].r=merge(t[x].r,y);
pushup(x); return x;
}
else{
t[y].l=merge(x,t[y].l);
pushup(y); return y; //同样的,别忘了pushup
}
return 0;
}
板题,没啥好讲的
平衡树维护序列,区间翻转可以打一个标记,遇到标记就交换左右子树并把标记传给左右子树。
所以要注意 merge
和 split
不能漏了 pushdown
void pushdown(int p){
if(!t[p].tg) return ;
int ls=t[p].l,rs=t[p].r; t[ls].tg^=1,t[rs].tg^=1,t[p].tg=0;
swap(t[ls].l,t[ls].r),swap(t[rs].l,t[rs].r);
return ;
}
void rvs(int &p,int l,int r){
int x,y,z; spl(p,r,x,z),spl(x,l-1,x,y);
swap(t[y].l,t[y].r);
t[y].tg^=1,p=merge(merge(x,y),z);
return ;
}
大杂烩题,还要维护最大子段和。还记得当时调了好久因为少写一个 &
。
维护最大子段和需要维护四个东西:区间和 \(sum\),区间最大子段和 \(mx\),从左开始连续一段的最大和 \(lmx\),从右开始连续一段的最大和 \(rmx\)。
存在区间翻转操作,所以要注意交换左右子树的时候还要交换 \(lmx\) 和 \(rmx\)。
虽然写过一遍但再也不想写第二遍。
正解是树状数组,但可以用平衡树暴力模拟。
可持久化线段树
最大中位数有一个经典处理方法,二分答案 \(mid\) ,把 \(\geq mid\) 的设为 1
其它设为 -1,然后判断一段区间的和是否 \(\geq 0\)。(这句话我的等号是乱取的,实际上要根据题目考虑,这是细节)
这题对于询问二分答案,然后找到左端点右端点满足要求的最大子段和看看是否大于 0,这个最大子段和即 \([b,c]\) 的和加上 \([a,b-1]\) 的 \(lmx\) 加上 \([c+1,d]\) 的 \(rmx\)。
可以用主席树维护,对于每一个值开一个主席树。
挺好的一道题。
主席树+贪心
咕
可持久化并查集
即用主席树维护 \(fa,sz\) 等并查集数组。
注意区分“可撤销并查集”。
一开始要做一个最短路的预处理,用 dij。(关于 SPFA,它死了)
如果不是强制在线(毒瘤出题人(恼),可以把询问离线下来用并查集。
那强制在线怎么办?用可持久化并查集,对每个水位都保存一个版本。
细节比较多。
可持久化 trie
类似可持久化线段树,每次修改新建节点。
似乎可以不用可持久化 trie,也可以用。
首先区间异或和转异或前缀和,然后问题变为找出前 \(2k\) 大的两两异或和,用堆维护。
上面那题的加强版,做法不太一样。
咕咕咕
可持久化 trie 的板子。似乎没啥好说的?
但好像有不用可持久化的 trie 的做法。
线段树合并&分裂
线段树分治
树套树
\(O(n \log n)-O(1)\) dfs 序 LCA 科技
好神奇的做法!查询吊打倍增和树剖,常数全方位吊打欧拉序,就是空间没有树剖优秀。
以下内容借鉴魏老师博客。
设树上的两个结点 \(u,v\) 的最近公共祖先为 \(d\)。
我们不得不使用欧拉序求 LCA 的原因是在欧拉序中,\(d\) 在 \(u,v\) 之间出现过,但在 dfs 序中没有。
以下内容假设 \(dfn_u < dfn_v\)。
- 情况 1:当 \(u\) 不是 \(v\) 的祖先
设 \(v'\) 为 \(d\) 的子树包含 \(v\) 的儿子,显然 \(v'\) 在 \(u\rightarrow v\) 的 dfs 序之间。
并且对于 dfs 序而言,祖先一定出现在后代之前,所以任何 \(d\) 以及 \(d\) 的祖先均不会出现在 \(u\rightarrow v\) 的 dfs 序中。
所以我们只需要求 \(u \rightarrow v\) 的 dfs 路径之间深度最浅的点的父亲。
- 情况 2:当 \(u\) 是 \(v\) 的祖先
直接用上面的结论会查到 \(v\) 的父亲。可以特判,但是太麻烦了。
考虑令查询区间从 \([dfn_u,dfn_v]\) 变成 \([dfn_u+1,dfn_v]\)。
对于情况 1,\(u\) 显然一定不等于 \(v'\) 所以不影响,对于情况二显然正确。
综上,若 \(u=v\) LCA 显然,若 \(u \neq v\) 就查询 \([dfn_u+1,dfn_v]\) 之间所有点的父亲中深度最浅的,可以 st 表实现。
代码很好写,但是注意 st 表的一些易错点。
int mnd(int x,int y){return (dfn[x]<dfn[y])?x:y;}
void dfs(int x,int fa){
dfn[x]=++cnt,st[cnt][0]=fa;
for(int i=hd[x];i;i=eg[i].nxt)
if(eg[i].v!=fa) dfs(eg[i].v,x);
return ;
}
int lca(int x,int y){
if(x==y) return x;
if((x=dfn[x])>(y=dfn[y])) x^=y^=x^=y;
int d=l[y-x];
return mnd(st[x+1][d],st[y-(1<<d)+1][d]);
}
signed main(){
read(n),read(m),read(s);
for(int i=1;i<n;++i)
read(x),read(y),add(x,y),add(y,x);
dfs(s,0); l[1]=0;
for(int i=2;i<=n;++i) l[i]=l[i>>1]+1;
for(int i=1;i<=l[n];++i)
for(int j=1;j+(1<<i)-1<=n;++j)
st[j][i]=mnd(st[j][i-1],st[j+(1<<(i-1))][i-1]);
while(m--) read(x),read(y),writeln(lca(x,y));
return 0;
}
kruskal 重构树
根据 kruskal 求最小生成树的过程,每次连边新建一个节点,左右儿子为当前合并的两个连通块的根,此时新建的节点为合并过后连通块的根,最终得到一颗二叉树,就是 kruskal 重构树。
常用于刻画存在边权限制时图的连通情况(“只经过边权不大于/小于某个值”或“经过的最大权值最小/最小权值最大”等),一般会转化为求两点 LCA 的点权,所以要根据需要设置点权。
kruskal 重构树上点到根的路径上的点权一般是单调的。
按加入顺序加入边并建立 kruskal 重构树,点权为边的加入时间。易发现两点连通所需要的最晚加入的边的编号即两点在重构树上 LCA 的点权。
线段树维护区间 LCA,用 dfs 序求 LCA 的科技可做到 \(O(n\log n)\)。
询问最大可能的权值,即到每个白点的路径上的最大值的最大值,所以从小到大加入所有边,建立 kruskal 重构树,每次查询 \(x\) 和所有白点的 LCA 的点权。
区间修改用线段树维护 LCA,比上一题麻烦不少。
当天上午写的时候还是黑题,下午就降紫了(哭)。
枚举变身的点,就转成了两个子问题,一个是限制路径上所有点都大于等于某个值,另一个是限制所有点都小于等于某个值。
可以分别建立 kruskal 重构树,\((u,v)\) 的边权分别为 \(\min(u,v),\max(u,v)\)。
然后对于每个询问倍增找到重构树上深度最浅的点满足限制,此时这个点的子树内所有叶子结点都是可以到达的。
只需要看两棵树内可以到达的点是否有交,排列映射,线段树维护。
虚树
有时候问题只与一些点和它们的 LCA 有关,此时如果遍历整棵树会浪费时间,但如果只看这些点就可以使用很多暴力做法。
虚树的构造就是用栈维护一个最右链,按 dfs 序从小到大加入点,分类讨论。
int build(){
sort(h+1,h+k+1,cmp);
int tp; stk[++top]=h[1];
for(int i=2;i<=k;++i){
int nw=h[i],lc=lca(nw,stk[top]); tp=0;
while(dep[stk[top]]>dep[lc]){
if(tp) add(stk[top],tp); tp=stk[top--];
}
if(stk[top]!=lc) stk[++top]=lc;
if(tp) add(lc,tp); stk[++top]=nw;
}
tp=0;
while(top){
if(tp) add(stk[top],tp); tp=stk[top--]; //这一步别忘了
}
return tp;
}
就是虚树上做树形 dp。
对于两两路径和,考虑计算每一条边的贡献,即边权乘上边两端关键点个数的积。
虚树上一条边 \((u,v)(v \in son_u)\) 的边权就是 \(dep_u-dep_v\)。
先边双缩点,再建虚树,然后再在虚树上连边,缩点,然后看给定点集中的点是否都在一个强联通分量中。
代码细节较多。
虚树都写完了才发现教练把它标为 10 级知识点
点分治&点分树
当处理的问题与树的形态无关(比如统计某种路径的个数)时,考虑把路径分为经过根的和不经过根的,每次统计经过根的,然后再把树分为几个子树,在每个子树内找一个根做同样的统计。有点像 CDQ 分治。
统计经过某点的路径一般是将所有路径分为 2 段,然后计算拼起来的合法路径数量。
为了保证复杂度,每次定一棵树的重心为根。
点分树就是将一个点向它所有子树的重心连边重新构成的树,相当于将整个点分治的过程记录下来。
点分树模板题。
咕。
设当前分治中心为 \(rt\),\(v\) 到 \(rt\) 的路径上距离为 \(d\),\(u \rightarrow v\) 的路径满足条件
直接算的话左边的部分与 \(u\) 和 \(v\) 都有关,所以对式子稍作变形
这样就可以用点分治做了。
在不少题目中可能会用到类似的方式,有时候变形要复杂很多,比如 P7283 这题要拆 \(\max\) 分讨。
用到一个结论:求一个完全图的最小生成树,可以把所有边分成若干个边集,对于每个边集内求最小生成树,再把剩下的边保留再求最小生成树,可以得到原图的最小生成树。
点分治,考虑经过重心的路径产生的边集的 MST,然后加到最终的边集中,再跑 kruskal。
似乎也可以用 Boruvka 做,但不会。
调的时候一直 TLE 以为哪常数大了或者按秩合并假了结果是点分治重心找错了
用处理众数的基本套路,先二分,然后将 \(\geq mid\) 的设为 1,\(< mid\) 的设为 -1,然后问题转化找求权值最大的长度在 \([L,R]\) 的路径。
点分治,然后单调队列按秩合并。合并的时候把所有子树按子树内最大深度从小到大排序,然后先访问深度小的。这样每次初始化的复杂度是 \(O(maxdep)\),由于排过序所以 \(maxdep\) 就是当前子树深度,这样总复杂度就是 \(O(n)\) 了,不然会被扫把卡掉。
长链剖分
类似重链剖分,只是重链是以子树大小最大的儿子作为重儿子,而长链是以子树深度最大的儿子作为长儿子。
长链剖分的性质:
-
性质 1:从根节点到任意叶子结点所经过的轻边条数不超过 \(\sqrt n\)。
-
性质 2:一个结点的 \(k\) 级祖先所在长链的长度一定不小于 \(k\)。(反证法易得)
-
性质 3:每个节点所在长链末端为其子树内最深的结点。(定义)
长链剖分一般用于优化与深度相关的树形 dp,一般状态为 \(dp[i][j]\) 表示以 \(i\) 为根的子树中深度为 \(j\) 的贡献。
树上 k 级祖先也算是长剖的经典应用。这个可以用倍增做到 \(O(n\log n)-O(\log n)\),但长剖可以 \(O(n \log n)-O(1)\),虽然感觉没啥用。
先预处理出 \(f_{i,j}\) 表示从 \(i\) 向上跳 \(2^j\) 步到达的点,长链剖分预处理出每个点对应的链顶和每个链顶向上向下跳 \(len\) (长链长度)的距离到达的点(这个总长度为 \(2n\),可以用两个数组通过长链 \(dfn\) 连续的性质存)。
现在要查询 \(x\) 的 \(k\) 级祖先,考虑先向上跳 \(k'=2^{\lfloor \log_2 k \rfloor}\) 步到达点 \(x'\),设 \(x'\) 所在长链长度为 \(d\),显然 \(k'-k\leq k'\),又由性质二 \(k'\leq d\)。这个时候再跳到链顶,也能证明链顶离目标点的距离不超过 \(d\),根据预处理的结果可以一步跳到目标点。
经典的长链剖分优化 dp。
设 \(dp_{i,j}\) 表示 \(i\) 点子树中距离为 \(j\) 的点的数量,轻儿子的答案暴力更新,长儿子的答案继承到父节点。由于继承的时候整体后移一位并在前面加了一位,所以考虑倒着存,这样就只用在后面加一位了。
运用到长链性质。
分数规划 + 长链剖分优化 dp(也可以像 CF150E 一样点分治+单调队列按秩合并)
二分答案,把所有边权减去当前二分的平均值,看长度符合要求的边权和最大的路径边权和是否大于 0。
设 \(f_{i,j}\) 表示以 \(i\) 为根的子树内,向下走 \(j\) 条边的最大边权和,长链剖分优化,然后线段树维护长链剖分。
dsu on tree
这种看上去很暴力但是复杂度正确的算法真的好厉害!
每次都遍历所有子树再清空再遍历以父亲为根的树太浪费时间了,发现最后一个遍历的子树的答案可以不用清空直接继承给父亲,dsu on tree 的思想是考虑直接继承重儿子的答案。
由于每个点向上到根所经过的轻边条数不超过 \(\log n\),所以每个点最多被遍历 \(O(\log n)\) 次,因此时间复杂度 \(O(n \log n)\)。
dsu on tree 经典题。
把询问离线挂到树上然后对每个点算子树内所有深度的点的个数,对于轻儿子算完就清空,对于重儿子不清空保留。
感觉 dsu on tree 的题都差不多所以就不放了。动态维护加入和清空点的方式和莫队很像。
差分约束
用于解决一些像 \(x_1, x_2 \dots x_n\) 一些变量,还有一些形如 \(x_i-x_j \leq c\) 或者是 \(x_i-x_j \geq c\) 的限制,求一组合法解。
只要所有的限制在不等号同一侧 \(x_i,x_j\) 符号相反,就都可以表示为 \(x_j \leq x_i+c\),发现这个东西很像最短路中的 \(dis_j \leq dis_i +c\)。所以考虑建图,对于每一个 \(x_j \leq x_i+c\) 的限制从 \(i\) 向 \(j\) 连一条边权为 \(c\) 的边,然后跑最短路。
为了防止图不连通,可以建一个超级源点向所有点连边权为 \(0\) 的边,但是这同时也限制了所有变量的值 \(\leq 0\),并且会跑出来字典序最大的所有变量都为负的解。
有时候我们要求字典序最小的正数解,这个时候我们可以把所有变量都取反(体现为原图所有边的方向取反),然后跑字典序最大解。
限制形如 \(x_ik \leq x_j\),这个时候可以两边同时取对数,转化为 \(\log_2(x_i)+\log_2(k) \leq log_2(x_j)\) ,然后就和上面一样了。
Johnson 全源最短路
一种 \(O(nm\log m)\) 的全源最短路算法。对于稠密图,直接 \(O(n^3)\) 的 floyd 跑常数小还好写,但是对于稀疏图,有时候 \(O(n^3)\) 的复杂度无法通过,我们想用更快的 \(O(nm\log m)\) 的方式跑 \(n\) 遍 dijkstra。这种想法在边权全部为正的状态下是,没问题的,但是有负权就不行了,只能用 SPFA,但是关于 SPFA,___。
Johnson 核心思想是给每个点赋一个势能 \(h_i\),然后把每条边 \((u,v)\) 的边权改变为 \(w_i+h_u-h_v\),想办法把边权变正,就可以跑 dijkstra 了,最终的答案是改变边权过后的最短路减去 \(h_s-h_t\),正确性可以参考物理的势能。
那下面的问题是如何赋这个势能,我们的目标是把所有边权变正 ,所以 \(w_i+h_u-h_v \geq 0\),即 \(h_v \leq h_u+w_i\),是差分约束的形式,所以在原图上跑最短路就得到每个点的 \(h_i\),若存在负环就无解。
P5905 模板。
平面图最小割
平面图最小割 = 对偶图最短路
求一个类似网格图的图的最小割,当然可以最小割 = 最大流然后跑 dinic,但这是个平面图,可以通过转对偶图最短路来求最小割。
建对偶图就对着原图手算编号就可以。
无向图的连通性问题
边双结论:
-
若 \(a\) 和 \(b\) 边双连通,\(b\) 和 \(c\) 边双连通,则 \(a\) 和 \(c\) 边双连通。
-
\(u,v\) 边双连通当且仅当 \(u,v\) 之间不存在必经边。
-
边双内任意一条边 \((u,v)\) 存在经过 \((u,v)\) 的回路。
-
边双内任意一点 \(u\) 存在经过 \(u\) 的回路。
点双结论:
-
两点之间任意一条路径上的所有割点,就是两点之间的所有必经点。
-
若两点双有交,那么交点一定是割点。
-
一个点是割点当且仅当它属于超过一个点双。
P8436
边双模板
P8435
点双模板
广义圆方树。
众所周知边双可以缩点,缩点过后是一棵树。点双连通分量不好直接缩点因为一个割点会同时属于多个点双。广义圆方树是对于每个点双建一个点(方点),连向点双内所有点(圆点)。
圆方树的性质:
-
圆点的度数等于包含它的点双的个数。
-
圆方树上圆点和方点相间。
-
圆点是叶子当且仅当它在原图上是割点。
-
圆方树上删去一个圆点后剩余节点的连通性与原图上删去这个点相同。
-
圆方树上两个点 \(x,y\) 之间路径上的所有圆点为原图上 \(x\) 到 \(y\) 的必经点。
构建圆方树的代码:
void tarjan(int x){
dfn[x]=low[x]=++cnt;
stk.push(x);
for(int y:g[x]){
if(!dfn[y]){
tarjan(y); low[x]=min(low[x],low[y]);
if(low[y]>=dfn[x]){
++dcc; add(x,dcc+n),add(dcc+n,x);
while(stk.top()!=y){
add(stk.top(),dcc+n),add(dcc+n,stk.top());
stk.pop();
}
add(y,dcc+n),add(dcc+n,y); stk.pop();
}
}
else low[x]=min(low[x],dfn[y]);
}
return ;
}
题目中有要求不能重复经过城市,从一个点双到另一个点双必然会经过割点,此时如果再回来那一定会重复经过那个割点,而在点双内不存在必经点所以可以随便走。所以点双缩点,然后树剖维护。
考虑修改操作,每次修改一个点需要修改它本身和与它相连的所有方点,对于每个方点可以开一个 multiset
维护。但是如果修改一个割点,那要更新所有和它相连的方点,最差复杂度是单次 \(O(n)\) 这样肯定是不行的。
因此考虑一个经典 trick,定一个根,对于每个方点维护所有它的儿子节点的最小值,于是每次更新只需要更新圆点的父亲,然后询问时候如果两个点的 LCA 是方点,要额外加上 LCA 的父亲节点的贡献。
考虑建出圆方树,圆点权值为 1,方点为 0,问题转化为求这些点两两之间的路径的并的点权和减去点集的大小。
先点权转边权,最后加上 LCA 的点权。由于我不想写虚树,所以用一个结论:\(\text{一个点集两两之间的路径并}=\frac{\text{按 dfs 序排序后循环相邻两点的距离和}}{2}\) 。
P3320 也用了同样的结论。
有向图的连通性问题
一般来说都是强联通分量缩点然后 DAG 上拓扑 dp。
2-SAT
大概就是一堆限制,每个限制形如 \(x_i\) 为真/假 \(\vee\) \(x_j\) 为真/假,所有限制必须被满足,找一组合法解或者确定无解。
对于一组限制 \(P \vee Q\),连边 \(\neg P \to Q\) 和 \(\neg Q \to P\),意义为若 \(P\) 不被满足可以推出 \(Q\) 必被满足。
然后强联通分量缩点,同一个强联通分量内部的变量取值相等,若 \(x\) 和 \(\neg x\) 在同一强联通分量内部显然无解。
考虑构造一组合法解,若 \(x\) 所在强联通分量的拓扑序大于 \(\neg x\),那么 \(x\) 为真,否则 \(x\) 为假。原因是若拓扑序小的为真,可能会推出拓扑序大的为真,但是 \(x\) 和 \(\neg x\) 显然不会同时为真。
照着上面说的方法建图就行。
对于每个 \(a_i\) 取值为 \(1 \to k\),而 \(k\) 非常小 ,可以拆成 \(k\) 个变量 \(X_{i,j}\) 表示 \(a_i\) 是否小于等于 \(j\),然后 \(T_{i,j}\) 向 \(T_{i,j+1}\) 连边。由于 \(a_i\) 单调不降,\(T_{i,j} \to T_{i-1,j}\)。
对于题目中的限制:
-
\(a_i \neq x\) : \(T_{i,x} \to T_{i,x-1}\) 。
-
\(a_i+a_j \leq x\) : \(F_{i,p}\to T_{j,x-p-1}\) ,\(F_{j,p}\) 同理。
-
\(a_i+a_j \geq x\) : \(T_{i,p}\to F_{j,x-p-1}\) ,\(T_{j,p}\) 同理。
以上所有的边都要连上它们的反边。
前缀和优化建图。
二分图
也就主要是二分图染色判定和二分图匹配。还有二分图博弈
棋盘模型黑白染色是经典的二分图建模。
二分图匹配匈牙利算法的板子:
网络流及费用流
最大流 Dinic 板子:
bool bfs(){
memset(dep,0,sizeof(dep));
while(!q.empty()) q.pop();
q.push(s); dep[s]=1; nw[s]=hd[s];
while(!q.empty()){
int x=q.front(); q.pop();
for(int i=hd[x];i;i=eg[i].nxt){
int y=eg[i].v;
if(dep[y]||!eg[i].w) continue;
q.push(y); nw[y]=hd[y]; dep[y]=dep[x]+1;
if(y==t) return true;
}
}
return false;
}
int dfs(int x,int flow){
if(x==t) return flow;
int rst=flow,i,tmp;
for(i=nw[x];i&&rst;i=eg[i].nxt){
int y=eg[i].v; nw[x]=i; //注意当前弧优化!
if(!eg[i].w||dep[y]!=dep[x]+1) continue;
tmp=dfs(y,min(rst,eg[i].w));
if(!tmp) dep[y]=0;
eg[i].w-=tmp; eg[i^1].w+=tmp; rst-=tmp;
}
return flow-rst;
}
int dinic(){
int mxflow=0,flow;
while(bfs())
while(flow=dfs(s,inf)) mxflow+=flow;
return mxflow;
}
最小费用最大流 EK 板子:
bool spfa(){
memset(dis,0x3f,sizeof(int)*(n+1));
memset(inq,0,sizeof(int)*(n+1));
q.push(s),inq[s]=1;
flow[s]=inf,dis[s]=0; lst[t]=0;
while(q.size()){
int x=q.front(); q.pop(),inq[x]=0;
for(int i=hd[x];i;i=eg[i].nxt){
int y=eg[i].v;
if(!eg[i].f) continue;
if(dis[x]+eg[i].c<dis[y]){
dis[y]=dis[x]+eg[i].c;
lst[y]=i; flow[y]=min(flow[x],eg[i].f);
if(!inq[y]){
q.push(y); inq[y]=1;
}
}
}
}
return lst[t]!=0;
}
void EK(){
mxflow=0,mncst=0;
while(spfa()){
mxflow+=flow[t];
mncst+=flow[t]*dis[t];
int nw=t;
while(nw!=s){
eg[lst[nw]].f-=flow[t];
eg[lst[nw]^1].f+=flow[t];
nw=eg[lst[nw]^1].v;
}
}
}
这题是有源汇上下界最大流。
首先对于无源汇上下界可行流,对于每条边考虑把下界流满,然后每条边的流量限制改为上界减下界。但是这个时候可能有一些点不满足流量平衡,此时将源点向流进大于流出的点连边,同理流进小于流出的向汇点连边来补齐流量。
对于有源汇上下界可行流,流入源点和流出汇点的流量显然相等,所以汇点向源点连一条流量为 \(\infty\) 的边转化为无源汇上下界可行流。
而有源汇上下界最大流,就是先跑有源汇上下界可行流,记可行流为 \(flow1\),即为汇点连向源点的边的流量,然后断掉原图汇点连向源点的边从原图的源点再跑最大流 \(flow2\),最后的答案就是 \(flow1+flow2\)。
有负圈的费用流,不好跑 SPFA。
考虑把所有费用为负的边强制流满,费用变为原来的相反数。强制流满可以通过有源汇上下界网络流的方法解决,即汇点连源点并新建虚拟源汇。
(这里本来还应该有一些数论内容)
DP 及其优化
这个内容比较多,也需要深入研究,准备再开一篇博客专门写 DP。
但是省选前似乎没时间搞了。
模拟赛记录
省选联考 2022 Day1
概况
\(100+21+20=141\),感觉什么都不会。
T1 模拟,但是一开始没看懂递归展开的定义研究了好久,也不知道怎么做,还和多次展开搞混了好几次。
后来想了一个非常暴力的做法,直接暴力维护每个位置被哪些宏展开过,不能重复展开,这个可以用 bitset
。然后每次暴力一遍一遍扫去匹配看看有没有能展开的,碰到就暴力展开并维护展开的宏,如果有一次没找到能展开的就结束寻找并输出。
很暴力,感觉复杂度不对但是绝对跑不满。
然后就写了将近 2h,废。
看 T2,数数题,对于与值域无关的算法完全想不到,\(O(nK)\) 的也想不到,只会 \(O(n^2K)\),这样可以拿 20。
其实有好几个 \(O(n^2K)\) 的想法,感觉比较好写的是枚举最大值的位置再枚举最大值,以这个位置为根 dfs 整棵树计算每个位置有多少种取值,然后想办法计算方案数和权值和。
可能会出现多个最大值,这个时候钦定编号最小的为最大值,即算取值方案数的时候编号大于它的可以取到 \(mx\),小于它的不行。
然后方案数对了,但是权值和调了好久都过不了样例二,交上去的时候就估分 14。结果模拟过后上洛谷测发现 21,多过了后面一个 \(n\leq 200,K\leq 200000\) 的点的第一问,大概因为跑不满吧。
后来发现如果要对每个子树的权值和分别算贡献,应该乘上其它子树权值方案数的和而不是大小的和。如果场上调出来就有 30 了,悲。
T3 题目一看就不想看,看完了也不想写,但是发现数据范围的表很大,就看了一下,诶这题怎么送了 20 分啊,送的分还不拿那不是傻。加上第一个点的暴力还有 24 呢,比 T2 分还高。
可惜多测,第一个点的爆搜忘记每次把最大值初始化为 \(0\) 了,所以 -4,悲。
后来发现自己的 T1 代码是全机房最长。果然不会写模拟,废。
补题
见上文。
可能从一开始思路就稍微有点偏差,所以优化不下去了。我想的是先枚举点,再枚举最大值,再统计答案,这样 \(O(n^2K)\) 似乎是什么样都无法优化掉的。而如果先枚举最大值/最小值,然后再树形 dp 就有优化到 \(O(nK)\) 的可能。
假设此时钦定最小值为 \(mn\),所以我们需要找到权值在 \([mn,mn+K]\) 中的方案。这样的计算有一些问题:可能算出的答案包含一些最小值 \(> mn\) 的方案,处理也很简单,只要再做一遍 dp,减去权值在 \([mn+1,mn+K]\) 范围内的方案就行了。这个做法用换根 dp 或者是写的好一点的计数 dp 似乎可以拿到 40 分。
但是以上的做法时间复杂度与 \(K\) 有关,这显然是不行的。
记录每个点在规定权值范围后可以取值的区间为 \([l_i,r_i]\)(与原题中的 \(l,r\) 不同),发现权值区间 \([mn,mn+K]\) 移动时,第一问每个点的答案 \(a_i= r_i-l_i+1\),第二问每个点的答案 \(b_i=\frac{(l_i+r_i)(r_i-l_i+1)}{2}\) 都不会有太大的变化。其实,在一定的范围内,\(mn\) 变化时,\(a_i\) 可以看成关于 \(mn\) 的一次函数,\(b_i\) 可以看成关于 \(mn\) 的二次函数(可以想象一下一个固定的区间和一个移动的区间的交的变化情况),观察 dp 式子发现第一问答案可以看成一个关于 \(mn\) 的 \(n+1\) 次多项式,第二问可以看成一个 \(n+2\) 次多项式,第一问第二问答案的前缀和也可以看成 \(n+1\) 次多项式和 \(n+2\) 次多项式。
所以我们可以用 dp 求出前缀和的多项式,然后这题就做完了。 似乎是可以的但我不会。其实我们只是要计算这个多项式的一个点值,而我们可以 dp 求出若干个点值,所以想到插值。
然后就真的做完了。对于每个点我们算出所有的断点,这些断点把整个值域分成了若干段,段数 \(O(n)\) 级别。对于每一段内,第一问第二问答案的前缀和都是一个确定的多项式,我们需要 dp 求出至多 \(n+1\) 个点值,然后拉格朗日插值就行了。(这里的 dp 似乎是可以 \(O(n^2)\) 的?如果常数优秀是不是 \(200\) 能过 \(O(n^4)\) 啊)
省选联考 2022 Day2
概况
\(25+0+12=37\),这是真的什么都不会啊!
一开始的想法是把每个数分解,然后根据是否含质数因子表示成一个二进制数,最后的答案可以 \(\operatorname{OR}\) 卷积算,结合前面的无脑暴力能拿 40。
众所周知 30 以内有 10 个质数,所以二进制的位数枚举到 10,而我程序里面根据平时的习惯写了枚举到 \(n\),谁知道第二个《大》样例的 \(n\) 正好等于 10 呢?于是我过了第二个样例之后就没管它,愉快的 -15,沦为暴力分。
T2 一眼没思路,不会,就放弃了。
T3 一眼只会暴力,然后也就只会暴力了。
后面的时间一直在想 T1 的那种做法能不能优化,时间都耗完了也没想到换一种方式,想到容斥了但是并没有想下去。悲。
所以我是废物。
看了 22 年,JS 女生进队的最低是 173 分。
总结一下两天一共挂分 \(4+15=19\) 分,废。
补题
说实话不太想订正。
看了题解之后才发现有很多状态完全没有用,应为每个数不可能同时有两个或以上 \(> \sqrt{2000}\) 的质因数。
考虑根号分治,对于大质数和小质数分开算。把数分为不含大质数的和含哪一个大质数的几类,然后 \(OR\) 卷积卷起来就行了。
也可以用容斥做,强制一些数不满足,对于含大质数的分开算。
T2 T3 是真的不想没时间订正了。