算法
带权高维前缀和
这个感觉很有用啊。对于一个\(n\)维,每一维长度为\(k\)的东西,并且每一维的贡献系数是固定的\(w_{y,x}\)表示一维中\(x\)对\(y\)的贡献系数。所以\(W_{Y,X}=\prod w_{y,x}\)。然后我们考虑怎么快速求这个。我们设\(f_{i,S,S'}\)表示当前处理到\(i\)维的时候,比他高的内些维都是\(S\)的这些数中,对低于\(i\)维的数是\(S'\)的所有贡献是多少。转移我们只需要把\(S\)的第\(i+1\)维删去,设为\(T\),然后枚举第\(i+1\)维,设枚举了两个\(j,k\),那么我们将\(f_{i,T,S'+j}\)和\(f_{i,T,S'+k}\),互相转移,为什么是\(S'\)和\(S'\)转移,其余数对\(S'\)贡献系数一样,所以对于转移,只需要管当前维的贡献系数就行了。我们发现设三维是冗余的,可以压成一维,像状压一样。这个做法还可以推广到从每一维长度维\(x\)变成长度为\(y\)
一个比较万能的模板
int len = pow(K, n);// 原来的长度
for(int l1 = M, l2 = 1, cnt = 0; cnt < n; l1 *= M, l2 *= M, cnt ++) {
len = len / K * M;// 变换完当前这一维的长度
for(int i = 0; i < len; i += l1) {// 枚举变换后的起点
int _i = i / M * K;// 原来的起点
for(int y = 0, v = 0; y < M; y ++, v += l2) {
for(int x = 0, u = 0; x < K; x ++, u += l2) { // 这个分别枚举第$i$维变换后和变化前分别是什么值
for(int j = 0; j < l2; j ++) {
g[i + v + j] += f[_i + u + j] * w[y][x];// 转移,使用临时数组
}
}
}
}
for(int i = 0; i < len; i ++) f[i] = g[i], g[i] = 0;
}
差分本质上是前缀和,一样
张量分解
在FWT领域有用。有了这个就不用想怎么找\(FWT\)变换了。思路是把贡献矩阵拆成若干个秩\(1\)矩阵,然后再进行正变换。这些尽可能少的秩1矩阵需要能够线性表示出贡献矩阵,然后逆变换就是高维带权前缀和。下面使用异或卷积来距离。
考虑单独的一维,贡献矩阵长这样:
其中左边是对\(C_0\)的贡献,右边是对\(C_1\)的贡献,每一个位置代表\(A_{0/1}*B_{0/1}\)对\(C_{0/1}\)的贡献。我们把他们拆成几个秩\(1\)的,发现这个两个就行了,分别是和和差。
可以线性表示是好说的。变成秩\(1\)有什么好处呢,我们可以把这两个矩阵表示成两个多项式相乘,\((A_0+A_1)*(B_0+B_1),(A_0-A_1)*(B_0-B_1)\)。所以我们正变换\(FWTA_0=A_0+A_1,FWTA_1=A_0-A_1\),\(B\)同理,然后我们对\(FWTA\)和\(FWTB\)进行点乘得到\(FWTC\),逆变换就是\(C_0=(FWTC_0+FWTC_1)/2,C_1=(FWTC_0-FWTC_1)/2\)。这个就高维前缀和的领域了。那么我们就得到了\(C\)了。复杂度的话,一般是\(k^n*k\),\(k\)是拆成多少个秩\(1\)矩阵,\(n\)是维数。
例题:AT_arc132_f [ARC132F] Takahashi The Strongest
回滚莫队/不带修莫队
这个只要解决删除操作不好操作但是添加容易,并且支持撤回操作。
主体思路是分队处理每个块。每一个块内的右端点是单调的,但是左端点不单调,但是我们可以处理每个右端点的时候把左端点撤回到最右边然后再向右添加,这样就只有加了。具体的,在P5906 【模板】回滚莫队&不删除莫队中,我们使用两个数组,\(sum,t\),\(sum\)表示左端点都为块的最右端是的答案,\(t\)表示右端点都为右端点时的答案,然后查询一个\(l,r\)就是叠加一下,复杂度就是\(O(n\sqrt{n})\)。
连续段\(dp\)
处理一个排列的计数问题,或是插入式的\(dp\)计数,我们通常是两种方案,一个是按位置从小到大进行填,这是我们插得是相对大小关系,比如一个排列的\(dp\)计数,那么我们放到位置\(i\),那么前\(i\)位就是\(1\sim i\) 的排列,插入\(i+1\)的时候,我们考虑他与前面的数的相对大小关系,把排列变为\(1\sim (i+1)\)。而另一种方案就是把值按照一定的顺序填进去,然后维护连续段和答案。注意我们只关心连续段的相对位置,而不关心绝对位置,换句话讲,每两个相邻的连续段之间空这几个格我们不关心,在以后的枚举中计算方案。一般来说,连续段\(dp\)都有几个操作:在一个段的前(后)面插入一个值;新开一个段;合并两个相邻的段。维护答案需要就题而论。
P5999 [CEOI 2016] kangaroo
题目的限制等价为\(\forall i\in(1,n)\),\(i-1,i+1\)与\(i\)的大小关系确定。而又是排列,我们考虑连续段\(dp\)。设\(f_{i,j}\)表示从小到大填到\(i\),已经有\(j\)个连续段的方案数,考虑三种操作。1. 前后插值:由于要满足限制,但是前后插\(i\),那么当前\(i\)旁边有一个小于他的,但是再向后填一定会出现\(>i\)的值出现在\(i\)左右所以不合法;2. 新开一个段:这个操作没什么问题,因为\(i\)两边都会是比他大的值。3. 合并:没问题,\(i\)两边都比他小。转移是好说的,因为题目钦定了开头结尾,所以我们需要讨论\(i\)和\(s,t\)的大小关系。
定义一个排列\(p[1\sim n]\)的权值\(f_{p}=\sum_{i=0}^{n}\max(a_{i+1}-a_i-1, 0)\)。钦定\(a_{0}=0, a_{n+1}=n+1\)。求出\(f_{p}\le m\)的排列数。
这个也是对排列进行计数的题目。我们依旧考虑从小到大插数,然后维护连续段。我们考虑贡献怎么算。当我们枚举一个\(i\)的时候,如果有一个连续段\(x\),因为要计算\(\max(a_{i+1}-a_{i}-1, 0)\),当\(x\)后面还没填数的时候,那么他后面的数\(>(i-1)\)。所以,当我们枚举完\(i\),却不给\(x\)后面填数,那么这个连续段对答案的贡献就应该\(+1\)。也就是说,我们边枚举\(i\)边计算所有连续段对答案的贡献。那么其实我们把\(x\)后面填上\(i\),其实贡献也要\(+1\)。然后在之后的操作中,贡献的就是\(i\)和他后面的数了。所以接下来就和刚刚内阁题差不多了,考虑一下刚才的三种情况就行转移就行。
虚树:
虚树就是在原树上只保留关键点和任意两个关键点的\(LCA\)的树,同时为了方便,我们应该还会保留根节点。有着点数是\(O(\text{关键点})\)。下面来说构造。把关键点按照\(dfn\)排序,排完序后将相邻的两个点的\(LCA\)加入序列,关键点也要加入这个序列。然后去重,再次按照\(dfn\)排序,将排完序后相邻的两个值\((x,y)\)将\(lca(x,y),y\)加入虚树。证明:1. 第一步保证了所有点在虚树里,这个通过归纳证明即可。2. 第二步保证所有边都加入了,这个就分讨一下每条边在什么时候加入就行了。不过这个东西也能启示我们给我们\(dfn\)序和\(LCA\)就能通过\(O(n)\)次生成一颗树了。
例题:P2495 【模板】虚树 / [SDOI2011] 消耗战
线段树分裂
整体上感觉和线段树合并有点像。例如我们要把一个至于线段树分成\(1\sim x, x\sim n\)这两颗,那么做法是递归,如果属于原树上一个节点代表的区间\(\subseteq x\sim n\),那么就断掉原来的边,给新的祖先连上。否则建一个新的节点,然后递归下去,发现这个最多会多\(\log n\)个节点,所以挺对的。把一个\(x\sim y\)的区间分裂出去也是同理的。节点上维护的是代表的这个范围内的数的个数是多少。
void split(int &c1, int &c2, int l, int r, int x, int y) {
if(!c2) return;
if(x <= l && y >= r) return c1 = c2, c2 = 0, void();
if(!c1) c1 = ++ cnt;
int mid = (l + r) / 2;
if(x <= mid) split(ls[c1], ls[c2], l, mid, x, y);
if(y > mid) split(rs[c1], rs[c2], mid + 1, r, x, y);
sum[c1] = sum[ls[c1]] + sum[rs[c1]];
sum[c2] = sum[ls[c2]] + sum[rs[c2]];
}
板子代码把。
void merge(int &c1, int c2, int l, int r) {
if(!c1 || !c2) return c1 = c1 + c2, void();
if(l == r) return sum[c1] += sum[c2], void();
int mid = (l + r) / 2;
merge(ls[c1], ls[c2], l, mid);
merge(rs[c1], rs[c2], mid + 1, r);
sum[c1] = sum[ls[c1]] + sum[rs[c1]];
}
顺便把合并的也放一下。
带修莫队
如题。就是再加一个时间轴,对每一个查询,多记录一个\(t\)表示在这个查询前最后一个修改。现在排序分三个关键字\({b_l,b_r,t}\)。然后如果现在查询的\(t\)大于现在修改到的位置\(now\),就暴力改,如果对现在的查询有影响就加上,反之亦然。\(B\)取\(n^{\frac{2}{3}}\)复杂度应该是\(n^{\frac{5}{3}}\)这个我不会证,当结论记了。
for(int i = 1; i <= num; i ++) {
int l = q[i].l, r = q[i].r, T = q[i].t;
while(R < r) {
R ++;t[c[R]] ++;if(t[c[R]] == 1) ans ++;
}
while(R > r) {
t[c[R]] --;if(t[c[R]] == 0) ans --;R --;
}
while(L < l) {
t[c[L]] --;if(t[c[L]] == 0) ans --;L ++;
}
while(L > l) {
L --;t[c[L]] ++;if(t[c[L]] == 1) ans ++;
}
while(now < T) change(++ now, L, R);
while(now > T) change(now --, L, R);
an[q[i].id] = ans;
}
板子,也是P1903 【模板】带修莫队 / [国家集训队] 数颜色 / 维护队列
树上(带修)莫队
做法就是把\(dfs\)序改变一下,当一个点第一次遍历到的时候加入序列设位置为\(L_u\),子树遍历结束的时候加入序列,那么一个路径\(x\to y\)就是,\(L_x\sim L_y\)中出现一次的点。如果\(x,y\)都不是\(lca\),那要注意这样统计不了\(lca\)和起点。我们怎么只对出现一次的点统计呢,就是设一个\(vis_u\)表示\(u\)再现在中出现了几次,暴力拓展的时候给\(vis_{x}^1\),如果\(vis_x=0\)就把颜色的贡献删掉,否则就加上。这是不带修。带修就是上面内个。例题:P4074 [WC2013] 糖果公园
FHQ treap
还是通过随机一个权值让树高的期望是\(\log n\)的。然后通过分裂和合并不带旋转。
每个节点维护:
struct node{
int val, key, siz;
}
新加节点:
void newnode(int x) {
t[++ cnt] = {x, rand(), 1};
}
按值分裂:
void split_v(int cur, int v, int &c1, int &c2) {
if(!cur) return c1 = c2 = 0, void();
if(t[cur].val <= v) {
c1 = cur;
split_v(rs[cur], v, rs[c1], c2);
pushup(c1);
}else {
c2 = cur;
split_v(ls[cur], v, c1, ls[c2]);
pushup(c2);
}
}
按排名(大小)分裂:
void split_siz(int cur, int k, int &c1, int &c2) {
if(!cur) return c1 = c2 = 0, void();
if(t[ls[cur]].siz + 1 <= k) {
c1 = cur;
split_siz(rs[cur], k - t[ls[cur]].siz - 1, rs[c1], c2);
pushup(c1);
}else {
c2 = cur;
split_siz(ls[cur], k, c1, ls[c2]);
pushup(c2);
}
}
合并
int merge(int x, int y) {
if(!x || !y) return x + y;
if(t[x].key < t[y].key) {
rs[x] = merge(rs[x], y);
pushup(x);
return x;
}else {
ls[y] = merge(x, ls[y]);
pushup(y);
return y;
}
}
新加一个点
void insert(int x) {
int rt1 = 0, rt2 = 0;
newnode(x);
split_v(Rt, x, rt1, rt2);
Rt = merge(rt1, cnt);
Rt = merge(Rt, rt2);
}
删掉一个点
void Delete(int v) {
int x = 0, y = 0, z = 0;
split_v(Rt, v, x, z);
split_v(x, v - 1, x, y);
if(y) y = merge(ls[y], rs[y]);
Rt = merge(merge(x, y), z);
}
给区间加减,就多维护一个\(tag\)就行了,然后遍历每一个的时候下放标记,可看这个CF702F
可持久化线段树的区间修改
就正常打\(tag\),然后如果要pushdown的时候,如果当前点本来就有儿子,那么这个儿子可能是以前的版本,所以不能直接改,那么我们把这个儿子先复制一下(注意,所有信息都复制,包括\(tag,ls,rs\))。然后设成新的儿子,再打标记就行了。空间复杂度就和时间复杂度相同,就是pushdown的时候可能会加节点,所以空间复杂度就和时间复杂度一样。
void add(int cur, int k) {
sum[cur].mx += k;sum[cur].mi += k;
tag[cur] += k;
}
void pushdown(int cur) {
if(!tag[cur]) return;
int x = ++ cnt;
sum[x] = sum[ls[cur]];tag[x] = tag[ls[cur]];
ls[x] = ls[ls[cur]];rs[x] = rs[ls[cur]];
ls[cur] = x;
add(ls[cur], tag[cur]);
x = ++ cnt;
sum[x] = sum[rs[cur]];tag[x] = tag[rs[cur]];
ls[x] = ls[rs[cur]];rs[x] = rs[rs[cur]];
rs[cur] = x;
add(rs[cur], tag[cur]);
tag[cur] = 0;
}
wqs二分
如果直接能看出来最终答案关于段数是凸的,或者转移式子形如\(f_{i,j}=\min_{k=1}^{i-1}f_{k,j-1}+W_{k,i}\)。其中\(W_{k,i}\)满足四边形不等式。那么能得到\(f_{i}\)关于\(j\)是凸的。考虑证明这件事情。接下来用\(f_{i}\)指代\(f_{n,i}\)。那么要证明是凸的,等价于证明\(\forall x,d, 2f(x)\le f(x-d)+f(x+d)\)。假设\(f(x-d)\)的\(dp\)顺序是\(p_0(p_{0,0}=0)\),即贡献是\(\sum_{i=1}^{x-d}W_{p_{0,i-1},p_{0,i}}\)。\(f(x+d)\)的贡献顺序\(p_1\)。那么根据抽屉原理,把\(x-d+1(p_1\text{中}\in[d,x])\)插到\(x-d,(p_0\text{中}\in[1, x]左边,最后一个数一定顶上界)\)个空(左开右闭)里面,那么一定会一个空里面会有两个数,也就是\(\exists i,p_{0,i}<p_{1,i+d}<p_{1,i+d+1}\le p_{i+1}\)。所以考虑两个长度为\(x\)的解,\(p_{0,1}\dots p_{0,i}p_{1,i+d+1}p_{1,x+d},p_{1,1}\dots p_{1,i+d}p_{0,i+1}\dots p_{0,x-d}\)。并且他们的权值和为\(t\),那么\(t=f(x-d)+f(x+d)-W_{p_{0,i},p_{0,i+1}}-W_{p_{1,i+d},p_{1,i+d+1}}+W_{p_{0,i},p_{1,i+d+1}}+W_{p_{i,d},p_{0,i+1}}\le f(x-d)+f(x+d)\),并且\(2f(x)\le t\),所以得证\(2f(x)\le f(x-d)+f(x+d)\)。证明为下凸的。有了这个我们考虑把\((i,f(i))\)放到图上,二分一个斜率\(k\),找到最小的\(f(i)-ki\)即可。这个就是\(dp\)的过程中加一个负的贡献,就\(f_{i}=\min(f_j+W_{j,i}-k)\)。并且记录一个\(cnt\)表示当前状态选了多少个。用\(cnt_n\)和要求的\(K\)比较,如果\(cnt_n<K\),那么说明斜率再大一些才能切到\(K\)点,否则就是要小一点。总的来说,\(wqs\)二分在做的事情是寻找一个刚好切\(K\)点的斜率,又知道下凸壳被切点的纵截距最小,所以判断取最小截距时是否是\(K\)。特殊情况是凸壳可能又两个线的斜率是相同的,但是这个二分过程找最小的就可以了。
while(L <= R) {
int mid = L + R >> 1;
int x = solve(mid);
if(x <= K) {
ans = mid;
an = f[n];
L = mid + 1;
}else {
R = mid - 1;
}
}
类似这样(随便复制的一道题)
斜率优化
除了李超,slope,还有一个是基于凸壳的。就是比如\(f_{r}=\min_{l}(b_l-l*r)\)。\(b_l\)是和\(f_l,l\)有关的。那么\(f_r+l*r=b_l\),把\(l\)看作\(x\),\(b_l\)看作\(y\),\(r\)看作\(k\),\(f_r\)看作\(b\)。这样维护一个下凸壳,然后找切点。这个能用是因为查询的\(k\)不断变大,切点不断右移,维护的还是一个下凸壳(因为取\(\min\)),然后就能这么做。。。这个就写一下,每次加点判断是不是一个凸壳,查答案看当前是不是最优的。就行了,复杂度\(O(n)\)强强
区间的线段并&珂朵莉树
问题是这样的,一个序列,每个序列的元素是一条线段,给定做右端点\(l_i,r_i\),然后询问\(L,R\)中\(\cup [l_i, r_i]\)的大小。加强版
介绍这个问题之前先说珂朵莉树。
我认为珂朵莉树的操作就是在区间上,把连续的,权值相同的点当作一个结点,然后不断的拆线段,合线段的过程(注意线段都是连续的),复杂度的话不会证(。用处多用来骗分啥的,但是也能解决上述问题。
珂朵莉树节点的存法如下:
struct Node{
ll l, r;// 权值相同的区间是[l,r]
int t;// 其他的量
mutable ll v;// 权值
bool operator < (const Node& tmp) const {
return l < tmp.l;
}
};
因为后面的操作影响,节点用 set维护set<Node> odt
基本操作分为两种,一个是分裂线段,\(split(x)\)表示把包含\(x\)的节点\([l,r]\)分裂成\([l,x-1],[x,r]\)。写法就是:
auto split(int x) {
auto it = odt.lower_bound({x, 0, 0});
if(it != odt.end() && it->l == x) return it;
it --;
int l = it->l, r = it->r, t = it->t;
ll v = it->v;
odt.erase(it);
odt.insert({l, x - 1, t, v});
return odt.insert({x, r, t, v}).first;
}
另外一个是给区间赋值,把\([l,r]\)的权值都赋成\(v\)。就是先把原来有\(l,r\)之间的点都删掉,如果不是整块区间的话就先分裂,然后加入\((l,r,v)\)这个点。写法:
void assign(ll l, ll r, ll v, int t) {
auto itr = split(r + 1), itl = split(l);
odt.erase(itl, itr);
odt.insert({l, r, t, v});
}
注意,必须先\(split(r+1)\),因为如果先\(split(l)\)在\(split(r+1)\)可能会导致\(itl\)被删除,就是\(split(l)\)后\(l\)和\(r\)还在一个块,此时删\(r\)就会先把\(itl\)先删了,再新建两个节点。
基本操作就没了,这道题也够用了,然后更多的操作详见OI Wiki
这道题怎么维护呢,可以类似HH 的项链就是把权值放到最近更改的地方贡献,对于这道题来说,就是先把询问离线,然后扫描区间的右端点,再维护一个树状数组,里面的\(i\)表示\([1,r]\)中的线段最后一次贡献答案是在\(i\),询问就是查询树状数组\([L,R]\)之间的权值。在\(set.erase\)的时候把原来的贡献删掉就行。然后没了。
复杂度\(O(n\log n)\),证明见OI Wiki
可撤销
可撤销背包
考虑一般的\(01\)背包。就是体积\(w_i\),\(f_{j}\)表示体积为\(j\)的方案数。不撤销就是\(f_{j}+=f_{j-w_i}\)了,当然倒序。接下来考虑撤销,\(g_{j}\)表示去掉\(i\)后体积和为\(j\)的方案数。那我们使用\(w_i\)时,其他的体积和一定为\(j-w_i\),所以使用\(w_i\)的方案数就是\(g_{j-w_i}\),所以\(f_j-g_{j-w_i}\)就是\(g_j\)了。我们考虑从多项式的角度来思考,每个物品有选和不选状态,对应一个多项式\((1+x^{w_i})\),\(x\)表示一个占位符,说是叫形式幂级数,我不会,但是对理解这个没影响。那对于所有的\(i\),\(\prod (1+x^{w_i})\),然后\(x_j\)前面的系数就是\(f_j\)。撤销\(i\)就是除以\((1+x^{w_i})\)。但是考虑\(g_j=f_j-g_{j-w_i}\)这个操作对应多项式的什么操作。由上面的推导可知,这个操作其实是在把选择\(w_i\)的贡献删掉了,也就是把选择\(w_i\)这个选项给删掉了,也就是把\((1+x_{w_i})\to (1)\)这样了。但是我们期望把\((1+x_{w_i})\)都删掉而不是剩下\(1\),只不过是剩下\(1\),这样的值刚好不变就是了。那么理解了这个是什么以后,我们思考一下其他问题,例如,\(i\)不是选或不选,而是有几个选项,选择体积为\(w_1,w_2,w_3,\dots,w_k\)这样的选项,也就是变成了\(\prod (x_{w_1}+x_{w_2}+,\dots,+x_{w_k})\),此时我们考虑是否能够令\(g_j=f_j-\sum_{j-w_{i,k}}\)。这样最后就变成了\(()\to (0)\)了所以\(g_j\)就是\(0\)了,问题就是出现在没有\(x_{0}=1\)兜底了,所以此时我们选择选择一个\(w_{i}\),把其他的都减去,最后剩下\((x_{w_i})\),那么我们给这个多项式除以\(x_{w_i}\)就行了,那么我们已经知道了\(g_{i,j}\)就是\(x_{j}\)的系数了,那除以\(x_{w_i}\)其实就是给多项式左移\(w_i\)了。例题:P15649
【不可撤销的背包】
遇到不可撤销的背包,可以采用线段树分治,缺一分治等优化复杂度。例题Loj6515。这个就是将每个数存在的时间表示出来,在时间轴上做,用线段树维护一下。
可撤销并查集
例如,要合并一些连通块并且要维护连通块之间的信息然后还要撤销一些连通块的合并。注意这里合并和撤销的顺序一定要满足栈序才能使用可撤销并查集。本质上就是把信息更改一遍再更改回来。那么这里并查集合并使用的是按并查集大小合并,也就是启发式合并,这样,找\(fa\)的时候最多走\(\log n\)层,复杂度就多个\(\log\)。具体的,如果要合并\(x,y\)所在的连通块,(钦定\(x,y\)为连通块的根,并且\(siz_x>siz_y\))。那么直接令\(fa[y]=x,siz_x+=siz_y\),然后把这个操作记录下来。删的时候就是\(fa[y]=y,siz_x-=siz_y\)。如果有其他的操作就记录其他的操作。
长剖
一种不同于重链剖分的剖分方法,就是选子树里链最长的作为重儿子。性质1:长链总和为\(n\)。性质2:从一个节点到根节点的轻边个数不超过\(\sqrt{n}\)。原因,因为是长链,所以从轻边跳上去所在的长链的长度一定比现在这个大,\(1+2+3+4\dots\),所以是\(\sqrt{n}\)个。
应用:长链剖分优化\(dp\)。核心思想是把重儿子的信息\(O(1)\)给父亲,然后暴力合并轻儿子。复杂度呢?因为一般合并轻儿子是链长,而轻儿子就是一个长链的顶点,并且一个轻儿子只会被暴力合并一次,所以就是\(O(n)\)的复杂度。至于重儿子的信息,这里使用指针做。使用已到例题来说明:CF1009F Dominant Indices。暴力就是 \(f_{i,j}=\sum_{v\in son(i)}f_{v,j-1}\)。但是时间和空间都是\(O(n^2)\)的。所以长剖。设一个\(dp\)数组,表示在所有转移过程中所用的内存都在这里面。然后设\(*f[N],*now\)分别表示\(f_u\)对应\(dp\)数组中的内存的内阁位置,\(now\)表示现在已经用到了多少的内存。为了让我们重儿子可以\(O(1)\)转移,我们期望一条长链上的点对应的内存都在一个连续段里面,所以在遇到一个长链端点的时候我们给他后面开出大小为长链长度的内存。这样,我们只需要让\(f_{hson[u]}=f_{u}+1\)就可以把重儿子\(O(1)\)给父亲了。
代码如下:
int dp[N], dep[N], hson[N], ans[N];
int *f[N], *now;
void dfs2(int u, int fa) {
f[u][0] = 1;
if(hson[u]) {
f[hson[u]] = f[u] + 1;
dfs2(hson[u], u);
ans[u] = ans[hson[u]] + 1;
if(f[u][ans[u]] == 1) ans[u] = min(0, ans[u]);
}
for(int i = head[u]; i; i = edge[i].nxt) {
int v = edge[i].to;
if(v == fa || v == hson[u]) continue;
f[v] = now;// 把他指向起点
now += dep[v];// 多开内存
dfs2(v, u);
for(int j = 0; j < dep[v]; j ++) {
f[u][j + 1] += f[v][j];// *(f[u]+j+1)与f[u][j+1]是等价的,所以能这么访问。
if(f[u][ans[u]] < f[u][j + 1]) ans[u] = j + 1;
else if(f[u][ans[u]] == f[u][j + 1]) ans[u] = min(ans[u], j + 1);
}
}
}
这样空间复杂度和时间复杂度都降到\(O(n)\)了。
一般长剖的题,状态中都会有一维是深度。
P5904 [POI 2014] HOT-Hotels 加强版
这个也是。
最小费用最大流
前置知识:\(SPFA\),网络流\(Dinic\)算法
有了网络流的基础 可以拓展到最小费用最大流了
与网络流不同 费用流中每一条边除了又一个流量\(f\)的限制,还有一个费用\(c\)(可理解为一单位流量的单价),我们要做的就是再找出最大流量的前提下,使得花费的费用最小
回顾网络流\(Dinic\)算法,我们会先对整张图进行\(bfs\)分层,然后根据分出来的层进行增广。那么我们类比一下费用流如何处理。因为要求费用最小,所以可以先将最短费用路跑出来,然后在最短路上进行增广,为什么正确呢?首先保证了最小费用,其次,当网络中还有能走到汇点的路径时还会继续增广,所以也满足了最大流(感性理解一下我不会证明)。
那么算法雏形已经确定了,即把\(Dinic\)算法中的\(bfs\)换成\(SPFA\)即可。
注意:
- 因为\(Dinic\)算法可以走回流,所以需要建反边,那么反边的费用就是原边的相反数了,这样,当经过反边时,就可以把原边对费用的贡献剪掉了
- 因为费用可能为\(0\), 所以处理最短路的时候可能出现环,因此不止要在\(SPFA\)中判断,还要再\(dfs\)中加一个判断不走重复点。在\(dfs\)中加一个判断也很好理解,流不会流向正在流的点
后缀数组SA
一些约定
- 字符串下标从\(1\)开始。
- 字符串\(s\)的长度为\(n\)。
- "后缀\(i\)"代指以第\(i\)个字符开头的后缀
后缀数组:
主要有两个数组:\(sa\)和\(rk\)。
其中,\(sa_i\)表示将所有后缀排序后第\(i\)小的后缀的编号;
\(rk_i\)表示后缀\(i\)的排名。
两个数组满足一个性质\(sa[rk[i]]=rk[sa[i]]=i\)比较显然(排名为后缀\(i\)的排名的后缀编号为\(i\),另一个同理)
height数组:
\(height[i]=lcp(sa[i],sa[i-1])\),即第\(i\)名的后缀与第\(i-1\)名的后缀的最长公共前缀
一个重要的引理为:
\(height[rk[i]]\ge height[rk[i-1]]-1\)
证明:因为\(i, i-1\)只差一位,所以一定存在一个前缀与\(i\)有长度为\(height[rk[i-1]]-1\)的前缀,又因为排序相邻的两个前缀的\(lcp\)一定大于等于其他前缀,所以该引理正确
基数排序:
就是对于有\(k\)个关键字的元素进行排序,首先按照第一个关键字排序,分出大概的\(1,2,3,\dots\)排名,此时将排名相同的元素再按照第二个关键字排序,最终排完。
拿\(2\)个关键字举例:需要一个桶\(c\)放置第一关键字出现的次数,将第一关键字放完了以后,对桶\(c\)求前缀和,此时桶里的数值就是下标的排名;还需要一个数组\(y\),\(y_i\)表示第\(i\)小的第二关键字的下标是什么。然后从大到小遍历\(y_i\),把\(y_i\)代表的坐标的排名设为\(c[x[y[i]]]\),然后将\(c[x[y[i]]]-1\)即可
基于上述知识,以下将介绍后缀数组和\(height\)的求法了:
后缀数组: 利用基数排序,如果有一个\(x\)数组,其中\(x_i\)代表的是\(s[i,i+k-1]\)的在所有长度为\(k\)的子串排序后的排名,那么就可以将\(x_i\)作为第一关键字,\(x_{i+k}\)作为第二关键字,进行基数排序,最终就可以得到所有长度为\(2k\)的子串的排名了。
\(x\)数组好说,问题在于\(y\)数组怎么\(O(n)\)求。
- 能发现,\(len\le k\)的一个后缀是没有第二行关键字的,也就是相当于其第二关键字为\(0\),排名为\(1\),所以可以直接把这些后缀加入\(y\)数组里,但是排名是不能重复的,所以可以随便给他们一个排名,只需要让他们在前\(k\)个里面就好了,因为最终还是会按照第一关键字进行排序,所以正确性不会受到影响
- 对于其他的子串,因为需要按照第二关键字排序,且长度为\(k\)的子串已经排好了,所以可以拿来直接用,那么从小往大枚举长度为\(k\)的子串的排名为\(i\)的位置,即\(sa_i\),那么如果\(sa_i>k\)则\(x[sa[i]-k]\)为第一关键字,\(x[sa[i]]\)为第二关键字,就可以直接把\(sa[i]-k\)塞到\(y\)数组里了(如果让一个\(i\)作第二关键字,则\(i-k\)就是第一关键字,前提是\(i>k\))
所以就解决了。
for(int k = 1; k <= n; k <<= 1) {
for(int i = n - k + 1; i <= n; i ++) {
y[++ num] = i;// 没有第二关键字的直接放
}
for(int i = 1; i <= n; i ++) {
if(sa[i] > k) {
y[++ num] = sa[i] - k;
}
}
for(int i = 1; i <= n; i ++) c[i] = 0;// 清空桶
for(int i = 1; i <= n; i ++) c[x[i]] ++;//塞入桶
for(int i = 1; i <= n; i ++) c[i] += c[i - 1];//前缀和
for(int i = n; i >= 1; i --) {
sa[c[x[y[i]]] --] = y[i], y[i] = 0;//更新排名
}
num = 0;
swap(x, y);//相当于把y数组变成x,x清空,此时y中存的是i,i+k-1的子串的排名
x[sa[1]] = 1;// 排名为1的长度为2k的子串的排名为1,毋庸置疑,但是sa[2]可能和sa[1]相同就不能确定了
for(int i = 2; i <= n; i ++) {
x[sa[i]] = (y[sa[i]] == y[sa[i - 1]] && y[sa[i] + k] == y[sa[i - 1] + k]) ? num : ++ num;
}// 更新x,把x更新为长度为2k的子串的排名
if(num == n) return;// 分出n个排名就结束
}
后缀自动机SAM
一些记号:
- \(|s|=len(s)\)
- \(t_0\):初始状态
- \(endpos(t)\):字符串\(s\)中子串\(t\) 的结束位置的集合,集合大小就是\(t\)在\(s\)中出现的次数
- \(link(v)\): 状态\(v\)的后缀链接
- \(len(v)\):状态\(v\)对应的最长子串的长度
- \(minlen(v)\):状态\(v\)对应的最短子串的长度
一些名词:
- 后缀链接树:\(v\to link(v)\)形成的树
- 前缀节点:一个节点\(v_i\)的所代表的最长字符串是原字符串的一个前缀,则称\(v_i\)为前缀节点
可以解决的问题:
- 在另一个字符串中搜索一个字符串的所有出现位置
- 计算给定的字符串中有多少个不同的子串
- 在另一个字符串中判断一个字符串是否出现过
- 计算给定的字符串中所有不同子串的总长度
- 字典序第 k 大子串
- 字符串的最小循环移位
- \(\cdots \cdots\)
\(\text{endpos}\):
两个子串\(t_1\)和\(t_2\)的结束位置可能完全相同:endpos(t1)=endpos(t2),把\(\text{endpos}\)相同的非空子串叫做一个等价类。
性质1:如果有两个非空子串\(v,w\)(\(|v|\le |w|\))的\(\text{endpos}\)相同,则\(v\)是\(w\)的后缀
性质2:endpos相同的子串的长度是连续的
后缀链接link:
\(v\)的后缀链接\(link(v)\)连接到的状态,对应于\(w\)的后缀中与它的\(\text{endpos}\)集合不同且最长的那个
性质1:所有后缀链接构成一棵根节点为\(t_0\)的树。(每一个点的后缀链接会连接到\(len\)严格小于该节点的点,最终一定会到达\(t_0\))
性质2:后缀链接树上的父节点的\(\text{endpos}\) 集合一定包含其儿子节点。
性质3:后缀链接树每一个节点包含的子串出现的次数,就是该节点\(endpos\)集合大小
构造SAM:
假设现在已经构造了字符串\(s\)的SAM,最后插入的字符所对应的状态为\(last\),现在要插入一个字符\(c\)(设在后缀链接的编号为\(cur\)),那么从\(last\)开始,判断该节点是否有字符\(c\)的出边,如果没有,就跳该节点的后缀链接,继续判断,如果有,则要分类讨论:(设当前跳到了\(parent\)树上的点\(p\), 点\(p\)经由字符串\(c\)转移到了点\(q\))
- 情况一:\(len(p)+1=len(q)\),此时点\(q\)代表的字符串就是点\(p\)代表的字符串加上\(c\),直接把\(cur\)连接到\(q\)即可
- 情况二:\(len(p)+1\ne len(q)\),此时点\(q\)代表的字符串集合大于点\(p\)代表的字符串加上\(c\)的集合,所以不能直接把\(cur\)连接到\(q\)即可。此时只能把点\(q\)克隆一个出来,分成两个,一个是\(clone\) , \(len(clone)=len(p)+1\),\(clone\)除了\(len\)的值其他都是\(q\)的, 另一个就是点\(q'\),和\(q\)代表的字符串是一样的,接下来操作是把\(p\)到\(t_0\)的路径上的点所有从字符\(c\)转移到\(q\)的边都变成转移到\(clone\)边,最后把\(cur\)和\(q\)的后缀链接连接到\(clone\)上
做完这些以后把\(last\)设为\(cur\)即可。
具体过程核心代码:
void insert(char c) {
int cur = ++ sz;
int p = last;
sam[cur].len = sam[p].len + 1;
while(p != -1 && !sam[p].nxt.count(c)) {
sam[p].nxt[c] = cur;
p = sam[p].link;
}
if(p == -1) {
sam[cur].link = 0;
}else {
int q = sam[p].nxt[c];
if(sam[q].len == sam[p].len + 1) {//情况一
sam[cur].link = q;
}else {//情况二
int clone = ++ sz;//克隆出一个点
sam[clone].len = sam[p].len + 1;
sam[clone].nxt = sam[q].nxt;//把clone的其他值都设为q的
sam[clone].link = sam[q].link;//与上同理
while(p != -1 && sam[p].nxt[c] == q) {// 改边
sam[p].nxt[c] = clone;
p = sam[p].link;
}
sam[q].link = sam[cur].link = clone;
}
}
last = cur;
val[cur] = 1;
}
注意:
- \(clone\)和\(q\)的出边一样,是因为\(clone\)和\(q\)合起来是原来的\(q\),所以\(clone\)和\(q\)的出边和原来的\(q\)是完全一样的,所以在代码上就是直接把\(clone\)的边复制成\(q\)的就行。
- 把\(p\)在\(parent\)树上的父节点的经过\(c\)的转移边都改到\(clone\)上,是因为将原来的点分裂,实际上是分成了一个所有字符串长度\(\le len(p) + 1\)(就是\(clone\))的和一个所有字符串长度\(>len(p)+1\)的两个点,而\(p\)在\(parent\)树上的父节点的\(len< len(p)\),所以经由\(c\) 转移后的\(len + 1\le len(p) + 1\),也就是小于剩下的点的最小长度。所以应该连向\(clone\)
- 因为分裂后的\(q\)和\(clone\)刚好能够拼起来,所以他们所包含的字符串的长度是连续的,也就是说\(minlen(p)+1=len(p)\),根据后缀链接树的定义,\(q\)应该连接到\(clone\),而\(cur\)是 \(last+c\)的得到的,又因为\(p\)是\(last\)的合法后缀,\(clone\)是\(p+c\)得到的,所以\(clone\)是\(cur\)的后缀,所以\(cur\)应该连接到\(clone\)。
由此能够解释构造过程中的具体操作了。
应用:
不同字串个数:建出来\(parent\)树后计算每个节点\(len(p) - len(link(p))\)之和即可
不同字串的长度总和:计算\(parent\)树上每个节点\(\sum_{i=minlen(p)}^{len(p)}i\)
文本串出现次数:设这个字串在\(parent\)树上的节点为\(v\),则这个子串的出现次数\(=endpos(v)\)集合大小\(=v\)子树内前缀节点的数量
\(\cdots\cdots\cdots\cdots\cdots\cdots\cdots\)
空间复杂度:
因为最多分裂\(n-1\) 次,一共有\(n\)个字符,所以在\(sam\)和\(parent\)树上一共有\(2n-1\)个点
例题:
图技术
主要有无向图中连通图计数,二分图计数,欧拉图计数。这里先不赘述因为写在其他里面了,模板是这个,现在我们讨论有向图DAG计数和强连通分量计数。这两个都是针对原图的一个生成子图进行计数
DAG计数
设\(f_S\)表示\(S\)的生成子图是\(DAG\)的方案数。考虑转移。我们利用入度为\(0\)的点进行转移。枚举\(T\subseteq S\),让\(T\)中的所有点都是入度为\(0\)的点,那么就会有\(2^{W(T,S\setminus T)}\times f_{S\setminus T}\)的值共享给\(S\)。但是考虑,如果一个\(DAG\)的入度为\(0\)的点的集合为\(T\),那么我们会枚举到\(T'\subseteq T\)而把这种方案多贡献,所以我们需要容斥。考虑容斥系数。结论是系数为\((-1)^{|T|+1}\)。证明就是\(\sum_{i=1}^{|T|}\binom{|T|}{i}(-1)^{i+1}=1\)。这样就保证了同一种情况只会算一次。所以这样我们就得到了最后的式子就是\(f_{S}=\sum_{T\subseteq S}(-1)^{|T|+1}\times 2^{w(T,S\setminus T)}\times f_{S\setminus T}\)。现在我们考虑如何求\(2^{w(T,S\setminus T)}\)。我们对于一个\(S\)求出所有的\(w(T)\)表示\(w(T,S\setminus T)\)。我们只需要从大到小枚举子集,然后找到一个\(x\in T\)然后把\(x\to S\setminus T\)。然后更新一下就行。所以就完了
强连通图计数
设\(f_S\)表示答案。我们考虑直接求太困难了,我们容斥掉。当一个图不是强连通图,那么他所点以后一定变成一个多个点的\(DAG\)所以我们考虑转成\(DAG\)计数。我们设\(h_S\)表示把\(S\)这个集合变成若干个互相独立的强连通分量,记录分成奇数个的方案减去偶数个的方案。分成互相独立的强连通分量等价于变成入度为零的点。而记录奇数-偶数是为了搞内阁容斥系数。那么答案就比较清楚了,\(f_{S}=2^{edge_S}-\sum_{T\subseteq S}h_T\times w(T,S\setminus T)\times 2^{edge_{S\setminus T}}\)。前面就是刚刚说的,后面随便搞然后缩点都是\(DAG\)。那么\(h_{S}=\sum_{lowbit(T)=lowbit(S)}f_T\times h_{S\setminus T}\)。\(lowbit\)的限制就是相当于枚举一个点所在的强连通分量。然后找。注意,我们要先更新\(h_S\),再更新\(f_S\),最后把\(f_S\)的答案在贡献给\(h_S\),因为对于\(f_S\)的枚举中,我们不能让\(S\)变成一个强连通分量,所以\(h_S\)中不能存\(f_S\)的贡献。那么就没了。其他和上面就一样了。
NTT
和FFT的原理类似。但是比较好些一点。太复杂了不过背过板子就好了,反正也没啥用
void NTT(int *A, int n, bool flag) {
if(n == 1) return;
int mid = n >> 1;
for(int i = 0; i < mid; i ++) b[i] = A[i << 1], b[i + mid] = A[i << 1 | 1];
for(int i = 0; i < n; i ++) A[i] = b[i];
NTT(A, mid, flag);NTT(A + mid, mid, flag);
int inv = flag ? iPw[n] : Pw[n];
int w = 1;
for(int i = 0; i < mid; i ++, w = 1ll * w * inv % mod) {
b[i] = Add(A[i], 1ll * w * A[i + mid] % mod);
b[i + mid] = dec(A[i], 1ll * w * A[i + mid] % mod);
}
for(int i = 0; i < n; i ++) A[i] = b[i];
}
int main() {
......
m = 1;
while(m <= n) m <<= 1;
for(int i = 2; i <= 2 * m; i <<= 1) {
Pw[i] = quick(ord, (mod - 1) / i);
iPw[i] = quick(Pw[i], mod - 2);
}
for(int i = 2; i <= 2 * m; i ++) {
Inv[i] = 1ll * Inv[mod % i] * (mod - mod / i) % mod;
}
}
需要注意的一点是\(n\)必须时刻是\(2\)的幂。
多项式exp
只有当\(F(x)\)的常数项为\(0\)时才有意义。\(B(x)=e^{F(x)}\),对两边求导\(B'(x)=B(x)F'(x)\)。再积分的\([x^n]B(x)=\frac{1}{n}[x^{n-1}]B(x)F'(x)\)设\(f_i\)为\(B\),\(g\)为\(F'\)。那么得到转移式。\(f_{n}=\frac{1}{n}\sum_{i=0}^{n}f_ig_{n-1-i}\)。那么可以先计算出左边再贡献右边。就是用\(f_{l\sim mid-1},g_{0\sim r-l}\to f_{mid\sim r}\)
具体的就看代码吧code
prufer序列
一颗\(n\)个点的生成树的个数是\(n^{n-2}\)。证明。考虑把树映射到一个序列上。当\(n>2\)时,选择一个编号最小的叶子,并把他的父亲加入序列,并删掉自己。得到了一个\((a_1,...a_{n-2})\)。一颗树只对应一个序列,并且每个点出现的次数是\(deg_i-1\),这是显然的。现在考虑手里有一个序列来构造树。假设当前剩余点集合\(S\)。\(S\)初始为\(1\sim n\)。对于\(i=1\sim n-2\)。每次考虑\(i\sim n-2\)中在\(S\)中没出现的最小的数\(x\),连\(a_i\to x\)。在\(S\)中删掉\(x\)。当\(|S|=2\),把\(S\)中的两个点连上一条边。证明:\(i=1\)时,未出现的一定是叶子,因为度数为\(1\)。找一个最小的对于找编号最小的叶子。把这个删掉后又变成子问题了,所以是正确的。不难发现这个形成了双射。所以数量就是\(n^{n-2}\)。
现在考虑\(n\)个点,但是已经确定了\(T\)条边,也就是有\(n-|T|=k\)个连通块已经确定了,问生成树数量。先把连通块缩起来,第\(i\)个连通块的大小为\(a_i\)。假设第\(i\)个连通块的度数为\(d_i\)。那么\(k\)个连通块的一个生成树对答案的贡献就是\(\prod a_{i}^{d_i}=(\prod a_i)(\prod a_i^{d_i-1})\)。还是考虑序列。\((c_1,...,c_{k-2})\)。有一个性质是第\(i\)个连通块出现的次数是\(d_i-1\),所以贡献也可以写成\((\prod a_i)(\prod a_{c_i})\)。而后面的内个在所有方案中,就是\(n^{k-2}\)。最终得到\(n^{k-2}\prod_{i=1}^{k}a_i\)。

浙公网安备 33010602011771号