Algorithm notes and references
Version:2024/02/03
Data Structure
1. Segment Tree Beats (segb)
from 题解 P4314 【CPU监控】 - He_Ren 的博客 - 洛谷博客 (luogu.com.cn)
lazy tag 实际上可以看作是对于该节点表示的区间的操作序列,这也是线段树的精髓所在
push_down 操作就是把父节点的操作序列接在儿子节点的序列之后(思考:为什么一定是 " 之后 " ?)
对一个点进行 push_down 操作后,该点的操作序列即被清空,因为在递归完子树后,该点答案将被更新。以下文章中的 " 操作序列 ",都指的是该点上一次进行 push_down 后(即上一次清空后)的操作序列
总感觉好像可以卡?虽然我知道复杂度是证明过的。
单调性?
评测500次 数据大小N=5e4
全负数 | 纯随机 | 仅操作是负数 | 全正数 | 仅操作是正数 | |
---|---|---|---|---|---|
Total (ms) | 53416 | 55114 | 60145 | 64113 | 65655 |
Average (ms) | 106.8320 | 110.2280 | 120.2900 | 128.2260 | 131.3100 |
分治,能够一次处理和不能一次处理。
评测500次 数据大小N=5e5
全负数 | 纯随机 | 全正数 | |
---|---|---|---|
Total (ms) | 563026 | 716822 | 755740 |
Average (ms) | 1126.0520 | 1433.6440 | 1511.4800 |
(import from A simple introduction to "Segment tree beats" - Codeforces)
Part2. The main idea
Interval min/max operations
To make the description clearer, I think it’s better to introduce an extended segment tree template.
I think most of the competitors’ templates of the lazy tag is like this ([l, r] is the node's interval and [ll, rr] is the operation's interval):
void modify(int node, int l, int r, int ll, int rr) {
if (l > rr || r < ll) return;
if (l >= ll && r <= rr) {
puttag(node); return;
}
pushdown(node);
int mid = (l + r) >> 1;
modify(node * 2, l, mid, ll, rr);
modify(node * 2 + 1, mid + 1, r, ll ,rr);
update();
}
The main idea is return whenever we can, put the tag whenever we can:
- When the operation's interval and the node's interval are no longer intersected, the information inside this subtree must not be affected. So we can return immediately.
- When the node's interval is contained by the operation's interval, all the information inside the subtree will be changed together. So we can put the tag on it and return.
In other words, we can replace the two conditions arbitrarily, i.e., we can extend the template like this:
void modify(int node, int l, int r, int ll, int rr) {
if (break_condition()) return;
if (tag_condition()) {
puttag(node); return;
}
pushdown(node);
int mid = (l + r) >> 1;
modify(node * 2, l, mid, ll, rr);
modify(node * 2 + 1, mid + 1, r, ll ,rr);
update();
}
What's the use of such a modification? In some advanced data structure tasks, it's impossible for us to put tags in such a weak condition . But we can put it when the condition is stronger. We can use this template to deal with this kind of tasks but we need to analyze the time complexity carefully. l >= ll && r <= rr
2. Persistent Segment Tree (pst)
事实上,真正可以被称为可持久化的,只有这个题:
P3834 【模板】可持久化线段树 2 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)
可持久化线段树最大的优化思想,在于重用未改变过的节点来节省空间,同时,这样的操作可以累计之前版本所有的修改,同时也保存了之前的版本信息,那么就构成了一种线段树的前缀和。
前缀和很关键,这是我们升维的依据。所以下面这几个题也是应用了可持久化的思想
P3759 [TJOI2017] 不勤劳的图书管理员 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)
P2617 Dynamic Rankings - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)
但是这看起来不像可持久化了,因为他是树状数组套权值线段树。空间优化没了。
但是你一想,如果这些题要支持修改,这要怎么办,因为前缀和是必须向后把所有位置都更新一遍的,这会导致 \(\mathcal{O}(N\log N)\) 的单次修改复杂度,不可接受。
想到在普通序列上解决前缀和修改的问题,我们用树状数组进行划分,以此优化。
在这里,类似的,我们也可以把树状数组套进来,使得每个节点上都维护表示 \([i-\operatorname{lowbit}(i),i]\) 上的一棵线段树,这样就每次只更改 \(\mathcal{O}(\log N)\) 个线段树。
这可太梦幻联动了。
至于查询,就还是按差分的思路来,后面部分的线段树们减去前面的seg们
tricks
-
回收没有贡献的节点
-
离散化减小值域
-
引用在动态开点时的使用
3. Segment Tree Merge & Split (segtms)
Split: 就是分出来然后就变成一个整体了,方便处理。不常用
Merge:\(\mathcal O(N\log N)\) 的,对于 [Vani有约会] 雨天的尾巴 /【模板】线段树合并 - 洛谷 来说,直接合并计数数组是 \(\mathcal O(N)\) 的,不优。
你说的对,但是,《线段树》是一款支持快速查询区间信息的数据结构。数组是不行的。
而且很多时候都没有那么多的节点要合并(动态开点),所以跑不满。
所以总体来说会快。Yeah。
P3521 [POI2011] ROT-Tree Rotations - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)
推荐题解
题解 P3521 【[POI2011]ROT-Tree Rotations】 - 1 - 洛谷博客 (luogu.com.cn)
\(\mathcal O(N^2 \log N)\) ?实际上,由于动态开点的缘故,不是每一次都需要合并满二叉树的
事实上,在这个题目的环境下:权值互异为排列。此时每一次的合并复杂度会更优,均摊一次为 $ \mathcal O(\log N)$
emm
使用的时候要万分注意。
现在让我们考虑归并排序做法的复杂度:
pii Merge(){
int x;cin>>x;
if(x>0){
a[++tot]=x;
return mp(tot,tot);
}
pii ll=Merge();
pii rr=Merge();
int cnt=0;
int l=ll.first,mid=ll.second,r=rr.second;
for(int i=l,j=mid+1,k=l;k<=r;k++){
if(j>r||(i<=mid&&a[i]<=a[j])){
tmp[k]=a[i++];
}else{
tmp[k]=a[j++];
cnt+=mid-i+1;
}
}
for(int i=l;i<=r;i++) a[i]=tmp[i];
int tot=len(ll)*len(rr);
ans+=min(cnt,tot-cnt);
return mp(l,r);
}
考虑每一个叶子对时间复杂度的贡献,显然是 \(x.\operatorname {depth}\) ,所以,当构造了一条有 \(n\) 个点的“左偏链”,即:
那么时间复杂度可以表示为
\[\sum_{i=0}^{i<n} {n-i} \]就是
\[\mathcal O(n^2) \]被卡咯
归并排序算法通过构造原序列为完全二叉树来保证复杂度,在不保证是完全二叉树的地方就不能保证复杂度。
4. mq on tree (mqot) [Need-to-be-fixed]
(咕咕咕)
5. Binary search tree (bst)
5.1 Splay (splay)
时间复杂度-势能分析浅谈 - Atalod的博客 - 洛谷博客 (luogu.com.cn)
前置知识:单调队列(可以在题解处学习)
在单调队列里面,包含很多入队操作和一些出队操作。时间复杂度为 Θ(n) 的标准解释是一个元素最多入队和出队一次,这个解释很清晰,但是本文讲的是势能分析法不是神仙分析法,所以下面将用势能分析来分析其时间复杂度,同时作为引入。
令势能函数 Φ(S)=S中元素个数。其中 S 代指单调队列的整个数据结构。对于任意单调队列,我们都可以计算出它的势能函数对应的值。例如,有三个元素为 1 3 5
的单调队列 S1,其势能函数 Φ(S1)=3。
以下假设将单调队列的 push
拆分为弹出
(即那个 while
循环)和 入队
(弹出后的入队)。
容易得知:
- 空单调队列的势能函数为 0(即在算法开始时,势能函数为 0)
- 每次执行一个入队操作,单调队列的势能函数增加1。
- 执行弹出的时候,弹出多少个元素,势能函数就减少多少。
- 算法结束时,势能函数非负
所以我们就可以将弹出操作弹出元素时 head--
和一次判断的开销摊到入队操作上。因为每次弹出一个元素,势能函数就会 -1
。而在入队操作的时候,势能函数 +1
。
有一篇英语阅读(完形填空),讲的是一个咖啡馆的故事。一些顾客买咖啡,会额外买一杯咖啡 for wall
,然后服务员会贴一张纸条到墙上。当一个衣衫褴褛的流浪汉买咖啡时,他可以买一杯咖啡 from wall
,服务员就会从墙上揭下一张纸条,然后提供给流浪汉一杯免费咖啡。势能函数就可以理解成这个故事里的 wall
。实际花费是顾客或流浪汉实际得到的咖啡,而均摊后的花费是顾客或流浪汉为这杯咖啡所付的钱。
由于每次 while
的开销都会使墙上的订单减少 1
,而单次入队操作只会使墙上的订单增加 1
,所以我们可以认为每次弹出和入队所花的钱都是一个常量。用势能分析的语言说,while
的开销可以由势能支付。由于柜台一分钱都没有亏,所以我们卖出的咖啡喝收到的钱一样多,所以每次弹出和入队所花的时间就可以认为是一个常量。
因此,每次弹出和入队的时间复杂度都是 Θ(1),出队的时间复杂度显然也是 Θ(1),则总时间复杂度是 Θ(n)。
这里单调队列的算法时间有一个 for
套一个 while
,虽然表面上是双重循环 Θ(n2) 的,但是最终却可以证明是 Θ(n) 卡不掉的时间复杂度。这就是摊还时间复杂度的魅力。
5.2 Persistent FHQ-Treap
FHQ是很适合可持久化的,但是带旋转的BST却不适合执行可持久化,这是何故?
可持久化的核心即进行节点公用,对于FHQ,考虑Split操作,当我们每一次访问一个点 \(x\) 的时候,我们就会把它的 \(lson\) 或 \(rson\) 归结到某一边,这一部分树是不会被改变结构的,所以我们不需要新建这一边树,但是另一半子树有可能被修改,所以递归到另一半,总结起来就是只有一条链上的的点要变,每次期望只新建 \(\mathcal O(\log N)\) 个点,效益很高。
其实不是说带了旋转就不能可持久化,就比如 有旋(划重点)treap可持久化 - Natsuzora 的博客 说:
具体来说为什么treap可以可持久化 (以下说法属于信口开河,
不保证完全不正确),由于treap认子不认父(即不需要记录父节点),而且所有操作都是自上而下(或者任意方向的单向?),这种性质决定了一些节点能够方便地同时被两个版本共用。也就是决定了这种树可以方便地可持久化
有人说splay不能可持久化是因为它需要旋转、它的形态不停地在发生改变
其实决定性原因是在于它的操作既有从上往下,又有从下往上的(splay操作)
作个比方,你现在有这样的一条路,从左往右走
(象征可持久化树上自上而下的操作)
版本①__
↘
共用部分———> 你可以很方便地从两条分路合并到中间的主路上
版本②__↗
但是如果你从右往左走
①__
↖
?<————— 你就不知道应该走哪条路了
②__↙
其实,并不是说双向的树就不能可持久化,只是代码复杂,时间效益太低(另外容易爆空间)
解读一下:
这是FHQ的可持久化样例,当我们发现左边子树不发生改变的时候,我们就直接连接它,然后向右边再行递归。
如果Splay也这样的话,下次Splay操作从下往上的时候就不知道该走那个父亲,所以所有的子树都不能重用,只能这样建了:
那么,一次Splay操作会创建 \(\mathcal O(N)\) 个节点,boom。
但是,注意到每次Splay操作之前必执行一次从上往下的递归,所以可以据此临时确定每个节点的fa,故Splay其实也是可以可持久化的。
这里应该吐槽一下Splay的神奇,实际上这种数据结构看起来很不直观,这种“可持久化”的做法也给人一种很不自然的感觉。
由于均摊分析的特征,“可持久化”Splay的复杂度会退化,例如什么样的数据能卡掉“可持久化”的 Splay - 洛谷 里提到的:
插入1e5个节点,然后在这第1e5个版本上反复删第一个节点1e5次,卡成\(O(n^2)\)
均摊分析法是针对一个数据结构说的,可持久化可以相当于复制了若干次同一个数据结构,所以不能把对于一个版本的势能分析代入整个可持久化,这种“可持久化”Splay的复杂度就是假的。不仅从逻辑上,从实际上也是这样,就比如上面的例子。
Monthly Summary 2023.10: Complex Tree Data Structure
Problems
- P4088
有 \(n\) 个三元组 \((x_i,y_i,t_i)\),现在有 \(m\) 个询问,每次给定两个数 \(a,b\),求
\[\min \{\min_{i\in [1,n]} \{\lvert a-x_i\rvert+\lvert b-y_i\rvert+t_i\},\lvert a-b\rvert\} \]形式化地说,平面上有 \(n\) 个带权的点,每一个询问,给定一个点,求这个点到其他点的曼哈顿距离加权值的最小值。
6. K-Dimensional Tree (kdt)
不劣于暴力法。
看起来,这种方法只是对点进行一些划分,以方便我们搜索时候进行优化。
但是,这种结构并没有像BST、SegT那样可以直接去掉一半(或期望一般)的答案空间。
未完待续。
7. Link Cut Tree (lct)
解决动态树问题,指的是加边删边维护森林和路径信息。
slpf的分块思想,但是由于要动态维护,所以lct就是一种动态维护slpf的方法,但是比较特殊的是,lct维护的slpf叫“实链剖分”,其实是具有很高自由度的,可以自由地选定实链,那么就可以把需要操作的路径提出来。
对于每一条实链,采用一棵Splay进行维护,对于多个Splay的链接,即虚边,采用只存父亲不认儿子的方法,该树称其为辅助树AuxTree
Splay具有很多性质,可以说刚好适配维护AuxTree。
下文,称原树为OriTree,辅助树为AuxTree
核心操作 Access
,将从OriTree上的根到x的路径设为实链。
核心思想是,不断将 x Splay到根,然后把上面的一棵父Splay和当前Splay连起来
int Access(int x) {
int p;
for (p = 0; x; p = x, x = f[x]) {
Splay(x), ch[x][1] = p, PushUp(x);
}
return p;
}
-
当前节点Splay到根
-
断开右边原先的(注:深度更深的,与上一个树冲突)实边链接,换成上一个树的根(上一个树:即上一次循环操作的那个Splay)
-
更新
最后,我们将返回最新的AuxTree 根。
同时,这个根还是两次连续 Access
操作节点的LCA。
重要操作 makeRoot
,将OriTree的根换成x,更新AuxTree
我们在需要维护路径信息的时候,一定会出现路径深度无法严格递增的情况,根据 AuxTree 的性质,这种路径是不能出现在一棵 Splay 中的。
其实可以考虑,只有Aux根所在的那个Splay被影响,因为其他的虽然在OriTree上被影响,但是其他实链的父子相对关系不会变。
所以:
-
Access
将x到根变成实链 -
Reverse
翻转根节点所在Splay,这么做使得这条实链完全上下颠倒
完了,其他的实链上的父子关系均不受影响。
感性地说,LCT 利用若干 Splay 来维护动态的树链剖分,以期实现动态树上的区间操作。
其实 Access
操作的过程和slpf跳链的过程很类似,但是,这里的时间复杂度由于是动态的,不能保证比较稳定。
(引用 Link-Cut-Tree详解 - cccwiseee - 博客园 (cnblogs.com))
不难发现所有LCT操作,如果移除了access方法后,实际上就是对Splay树的操作,即摊还时间复杂度为 \(O(log2(n))\) 。因此我们只需要证明access方法的时间复杂度即可。
先说明access的次数。这里假设我们已经了解了树链剖分的基于轻重链的时间复杂度的分析,如果不了解,可以查看我的另外一篇博客《树链剖分详解》。我们了解到每次access内部循环发生时,必然会发生偏好结点切换,偏好结点由轻孩子切换为轻孩子,或者由轻孩子切换为重孩子,或者由重孩子切换为轻孩子。由于在access构建的路径上最多有 \(log2(n)\) 个轻孩子,因此我们能保证偏好结点由轻孩子切换为轻孩子和由重孩子切换为轻孩子的次数至多为log2(n)次。接下来说明轻孩子切换为重孩子的次数,在整个程序流程中,重孩子切换发生的次数的上限为轻孩子切换为重孩子的次数加上边的总数(可以假设初始时所有重孩子都是偏好孩子,之后每次重孩子要再次变成偏好结点,必定对应之前发生的某次该重孩子变成非偏好结点)。假设发生了k次操作,则重孩子切换最多发生 \(n+klog2(n)\) 次,平摊下来,每次操作重孩子切换最多发生(n/k+log2(n))次,而由于建立树的操作存在(即将原本不相连的孤立结点通过边连接为一株树),因此k>=n-1,从而重孩子切换的平摊次数为 \(O(1+log2(n))=O(log2(n))\) 次。到此说明了每次access操作内部循环平摊下来发生了\(O(log2(n))\) 次。
接下来说明一次access中对应的 \(O(log2(n))\) 次splay操作的总时间复杂度为O(log2(n))。对于splay的时间复杂度的说明可以看我的另外一篇博客《Splay树分析》,这里不加赘述。我们认为所有routeFather引用也是Splay树的一条边,此时势能的定义依旧,s(x)为x代表的Splay子树所有结点的数目,d(x)则等价于log2(s(x))。我们记\(x0=x,x1=x0.routeFather,....,xk=xk-1.father\),而x'i表示xi经过splay操作后对应的结点。而第i次splay操作的摊还时间复杂度为
\[Ti≤3(d(x′i)−d(xi))+O(1) \]因此我们对时间复杂度进行加总得到
\[k∑i=0Ti≤3k∑i=0(d(x′i)−d(xi))+O(log2(n))≤3k∑i=0(d(x′i)−d(x′i−1))+O(log2(n))=d(x′k)−d(x′0)+O(log2(n))=O(log2(n)) \]到此时间复杂度的证明完成。而一次access操作的摊还时间复杂度为\(O(log2(n))*O(1)+O(log2(n))=O(log2(n))\)。对应的所有LCT的操作的时间复杂度也是\(O(log2(n))\)。考虑到LCT是完全建立在splay之上的,而splay已经拥有常数大的特征了,因此LCT的常数是更甚,需要小心。
当我们试图加入一个环的时候,崩溃发生了。
这是因为在Access-Splay-SpreadAll的时候会访问环形,导致死递归。
在加入了一个环形之后,动态树问题本身没有良好定义。
LCT难以维护子树的信息,因为它本质上是动态维护slpf,而子树信息难以通过链上的操作转换。
其实我们只要在树的形态改变的时候动态维护AuxTree上的子树信息就行了。
很多数据结构的操作都必须考虑,应该怎么把操作的影响限制在修改的范围当中。
对于lct的链操作,我们通过 MakeRoot
和 Access
把要操作的链变到一棵Splay里。
对于lct的树操作,我们也可以通过 Cut
和 MakeRoot
来分离出想要的子树。
那么就只需要考虑怎么动态地在树的形态改变时进行维护
所以我们只需要维护AuxTree的子树信息,然后 MakeRoot
。因为需要频繁地改变虚实边的信息,为了区别维护非实儿子节点的大小,必须把它们存下来,这是由LCT的实现结构决定的。
由于我们需要把某个子树的信息添加到它的父亲,或者从它的父亲删除,所以用这种方法做,需要信息具有可加可减性。LCT虽然常数大,但是支持理论复杂度优秀的在线操作。
如果没有可减的性质,就不能只维护一个值,每次更新只好重建信息,于是对于每个点虚子树信息维护一个平衡树。
让我们总结一下
以 P4219 [BJOI2014] 大融合 - 洛谷 为例进行分析:
在没有动态的操作时,可以使用DFS序将树上的子树操作和查询转换成序列操作。
在有动态加边,不强制在线的时候,可以用并查集动态维护树的根,连接边的时候要维护子树的信息,只需要进行一次链加的操作,这个链加比较特殊,其实是加一条“实链”(就是深度连续递增),我们有以下几种方案:
-
【在线】使用LCT(没必要)但是
其实在使用LCT的时候,我们发现在
Connect
的时候就会影响上面所有父亲节点退一步说,如果你在分析的时候,研究过加边会怎么影响子树大小,你就会发现这一点
这多么奇妙,所以注意
Connect
的时候要MakeRoot
-
【离线】树剖不能修改树的结构,实际上本题可以看作把边一个一个点亮,可以使用树链剖分进行链修改
-
【在线(Unsure)】使用动态点分治
-
【在线】使用线段树合并代替并查集进行合并,同时维护大小
-
【离线】由于没有强制在线,可以用欧拉序转换,先离线建出整棵树,然后应用树上莫队,到时候维护的所有结点的siz都是最新的
-
【离线】由于没有强制在线,可以用DFS序转换,先离线建出整棵树,然后用树上差分的思想,链接边\((x,y)\) (不妨设 \(x.fa=y\) ),在 \(x\) 处加,在 \(y.root.fa\) 处减去,防止影响当前还没有加边的父亲那边。这样,每个节点的最新siz就是查询其子树和(巧妙)。问题变成点加区间查询,用树状数组维护;或者使用、带修树上单点莫队。图例供参考(图中打x的边还没有建立)
在有动态加边,有强制在线的时候,还是可以用并查集维护树根,但是以上【离线】的维护方式都不行了
在有动态加/删边,无论强制在线或不强制在线,都不能再用并查集维护了,只能使用LCT。但是听说还有一种东西叫线段树分治,可以让并查集支持删除,那么这个事情就又开始复杂了。
(引用自 way)
由此我们可以发现:
在有条件的情况下,可以:
-
转换
子树修改点查询
\(\leftrightarrow\)点修改链查询
(例如子树加点查询,可以化成点打标记然后查到根的链上的和;例如点修改链查和,可以在每一个点上维护到根的和,然后每次点修改就会修改整个子树里的内容) -
转换
点修改子树查询
\(\leftrightarrow\)链修改点查询
(例如本题目)
(笔者注:这里的点查询不一样,指的是那种可以根据一个节点上保存的信息直接得到答案的查询)
其实上,在要查询的数据满足一些性质,例如可减性的时候,很多转化都是可行的
总结起来,有可加性是使用分块-合并数据结构(例如LCT、slpf)的关键(连可加性都没有,就只能暴力或者使用离线算法了(例如mqot)),有可减性是使用差分思想优化的关键,利用差分思想优化,其实和分块-合并数据结构很有相似之处。
让我们考虑如下表格,这些都描述了树上的操作通常需要的最优时间复杂度和做法
点修改 | 子树修改 | 链修改 | |
---|---|---|---|
点查询 | \(\mathcal O(1)\) - 直接修改 | \(\mathcal O(\log N)\) - LCT/差分转换 | |
子树查询 | |||
链查询 |
(待续)
emm
这个,到根的操作总能引起人们的遐想。
但是实际上还是非常巧妙的
8. Block forest/Round-square tree (rst)
网上很多地方没有说明白的是,用于仙人掌图(一条边至多在一个简单环中的无向图)的圆方树是对于每一个边双建方点,然后连边,割边保留不变,成为唯一的圆-圆边。
简便起见,本版块中所有的“圆”可能被r代替,同时“方”可能被s代替。
应当复习的是,点双和边双的定义:
-
点双:每一对点之间都存在至少两条简单路径,且除了端点之外,不包含其他重复的点
-
边双:类似的,每一对点之间都存在至少两条简单路径,且不包含重复的边。
可以发现这种东西拥有一些性质:
-
这种结构构成森林
-
所有r-r边都是原图的割边,且不属于任何一个环(显然)
-
原仙人掌图的子仙人掌是圆方树(rs森林)上的子树
-
换根不会导致树的心态改变
-
不存在s-s边
对于一般无向图,广义圆方树是这样定义的:对于每个点双建方点,然后连边。
注意到一个割点可能属于多个v-DCC,故:
-
仅有r-s边
-
一个度大于1的r点必为割点
可以看出,rst进行了一些信息的压缩和舍弃,就像缩点那样,去掉了很多边,对于每条边,针对不同问题,需要存储不同的信息,这些信息应该已经是压缩过了的,比如求最短路时,保存的边权就是到e-DCC的“顶点”(选定根之后,e-DCC中DFN最小的一个点,就是当发现一个e-DCC时的那个点,此时有low=dfn,我们如果太教条,看蓝书上求e-DCC的方法是先求割边再划分,就很难发现这个性质)的最短路。
关于点双,边双的一点点区别:
广义圆方树就不能用边双了?
其实上很难说。感觉一下,就是因为eDCC的 某些性质 导致它不能很好地还原出路径,处理比较复杂的图上的压缩信息。
你如果真的去做,那么你就会发现对于仙人掌树都非常复杂。
可以说,e-DCC是以边为分割,v-DCC是以点为分割。
如rst这样,使用信息压缩策略的还有Top Tree、缩点等等算法和数据结果。
对比缩点和rst,rst更多保留了图上连边的信息,又进行适当压缩。
9. Leftiest Tree (lft)
注意到 P3377 【模板】左偏树/可并堆 - 洛谷 用启发式合并是可以通过的
其实他们中间思想是有相似性的。实际上左偏树的代码中间的关键语句:
t[x].rs=Merge(t[x].rs,y);
这里左偏树不同于平衡树,不需要维护BST性质,所以不在乎往哪边插入。前面的交换语句就保证了堆性质。所以这里朝右走,就是为了尽快找到一个位置,因为交换之后就无所谓它插在哪里了。
对于普通二叉堆而言,是采用保证深度的方式保证复杂度,这使得这个结构固定而难以变动。如果强行插入整个子树,就会不能保证二叉堆的深度。
注意:常常需要通过保存root的方法找到根,但是即使一个点被删除了,它也可能被其他点的root指向,所以删掉点之后它的root要存真值。
trick 启发式合并
对于单元素插入操作为时间复杂度 \(\mathcal O(T(n))\) 的一种数据结构,或许它不支持高效合并。
在拥有若干个这种数据结构,共计 \(n\) 个元素的前提下,要想合并这 \(k\) 个集合,我们应该怎么操作呢?
启发式合并,采用把小数据结构依次插入到大数据结构上的方法,使得我们可以做到全部合并的总时间复杂度是
\[\mathcal O(n\times T(n)\log n) \]其实感觉这个界是比较紧的,不过会带一定常数。
当把小结构 \(a\) 合并到 \(b\) 上时,\(\operatorname {size}(a+b)\geq \operatorname {size}(a)\times 2\)
那么 \(a\) 中的元素至多被贡献几次呢?由于元素总数是 \(n\) ,则至多 \(\mathcal O(\log n)\) 次
单次单元素 \(\mathcal O(T(n))\) 总共如上。
骗分好用的一批,其实有的时候就是正解。
大道至简。有的时候有些操作很难维护,但是暴力好做,我们完全可以尝试暴力,有可能均摊下来就是对的,尤其是在有些数据结构的问题里面,当涉及到数据结构合并等等改变大小的操作时,要谨慎分析复杂度,避免错过正解。
但是,左偏树不能应用启发式合并
S1 Data Structure
Dynamic Programming
10. Dynamic Programming (dp)
Preface 在做DP之前
有的没的就不扯了。那三个条件已经众所周知了
关键就在于要把DP视为爆搜优化。这种优化,是靠确定一个不会重复运算子问题的搜索顺序来完成的。
所以DP会搜索所有子问题,在设计DP算法时,应该遵循与设计搜索算法一致的要求:覆盖整个状态空间 。
拿到一个爆搜找到我们枚举的关键决策点,然后用这个决策尝试刻画状态。然后确定一个计算的顺序。确定了顺序,说明这个问题具有“无后效性”。最后看能不能导出后续答案。如果可以,说明这个问题具有“最优子结构性质”。如果还发现这个过程有很多子问题重复计算,那么这个问题就具有“子问题重叠性质”。然后就可以用DP优化了。
10.1 Digital Counting (dp_digit)
这个有点板。
主要就是统计一段区间内符合某种特征的数的个数。
可以发现,要求我们统计的数的特征大致分为两个类别(有很多分法):
-
只与 \(\mathcal O(1)\) 位相关
-
与 \(\mathcal O(n)\) 位相关
第一种,如下面这个:
#include<bits/stdc++.h>
using namespace std;
const int N=15;
typedef long long ll;
ll f[N][N][N][2][2][2][2];
int num[N];
int split(ll x){
int l=0;
while(x){
num[++l]=x%10;
x/=10;
}
return l;
}
ll dfs(int pos,int ppre,int pre,bool already3,bool has4\
,bool has8,bool limited){
ll ans=0;
if(has4&&has8) return 0;
if(!pos) return already3;
if(f[pos][ppre][pre][already3][has4][has8][limited]!=-1){
return f[pos][ppre][pre][already3][has4][has8][limited];
}
int lim=(limited?num[pos]:9);
for(int i=0;i<=lim;i++){
ans+=dfs(pos-1,pre,i,(already3||(ppre==pre&&pre==i)),(has4||i==4),\
(has8||i==8),(limited&&i==lim));
}
f[pos][ppre][pre][already3][has4][has8][limited]=ans;
return ans;
}
ll calc(ll x){
int len=split(x);
if(len!=11) return 0;
memset(f,-1,sizeof f);
ll ans=0;
for(int i=1;i<=num[len];i++){
ans+=dfs(len-1,0,i,0,i==4,i==8,i==num[len]);
}
return ans;
}
int main(){
ll a,b;cin>>a>>b;
cout<<calc(b)-calc(a-1);
return 0;
}
非常套路。
而第二种,如:
[AHOI2009] 同类分布
题目描述
给出两个数\(a,b\),求出\([a,b]\)中各位数字之和能整除原数的数的个数。
输入格式
一行,两个整数\(a\)和\(b\)
输出格式
一个整数,表示答案
样例 #1
样例输入 #1
10 19
样例输出 #1
3
提示
对于所有的数据,\(1 ≤ a ≤ b ≤ 10^{18}\)
就没有那么简单。
10.2 Binary Compress (dp_compress)
更多地,状态压缩是一种描述集合的手段,使得我们可以快速枚举集合,发现状态里有集合的时候可以使用(通常某一个参量的数据范围会很小)。
作为一种压缩手段,它避免了开太高维度的数组。
有的东西,压了才能做:
P4363 [九省联考 2018] 一双木棋 chess - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)
这其实是一个状压维护轮廓线的DP,当然,也可以通过暴力搜索出所有有效状态然后编号来记录,不过思想是一样的。
最优解“结构化”
DP问题的核心还是在于要找到合适的最优解“结构化”
这个概念不好阐述,可以举个例子:
背包问题,最优解可以这么刻画结构:
设第 \(i\) 个选择的物品价值 \(w_i\) ,则最优解是 \(w_1+w_2+w_3+\dots +w_k\) ,相当于分步取物品,阶段就可以刻画成“当前在决定哪个物品”
就是要考虑最优解怎么得来的
多说无益。
搞个好题 P3694 邦邦的大合唱站队 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)
10.3 Interval DP (dp_interval)
本质特征
区间DP,就是最优解结构刻画为“区间型”的DP。这难以理解。
如果可以把最优化问题看成做出若干决策,那么区间DP就是每次决策是基于某个区间的DP,子问题可以被刻画成基于区间的子问题
事先记住,区间DP,无非考虑把枚举的区间分成几个区间合成答案、或者是两个端点
String Painter
有两个长度相等的字符串 A 和 B。这两个字符串由小写字母组成。现在你有一个强大的字符串绘画工具。借助这个工具,你可以将字符串中的一段字符改成任意你想要的字符。也就是说,在使用工具之后,这一段字符将由同一种字符组成。现在你的任务是使用字符串绘画工具将 A 改变成 B。你需要进行的最少操作次数是多少?
You Are the One
诸如《非诚勿扰》这样的电视节目一直很受欢迎。为了满足仍然单身的男孩们的需求,TJUT举办了这个节目。这个节目在小礼堂举行,所以吸引了很多男孩和女孩。现在有n个男孩报名参加。一开始,这n个男孩站成一排,一个接一个地上台。然而,导演突然得知每个男孩都有一个屌丝值D,如果第k个上台的男孩,他的不快乐值将会是(k-1)*D,因为他必须等待(k-1)个人。幸运的是,小礼堂里有一个黑暗的房间,所以导演可以暂时把男孩放进黑暗的房间,让他后面的男孩先上台。由于黑暗的房间非常狭窄,第一个进入黑暗房间的男孩将最后离开。导演希望通过黑暗房间改变男孩们的顺序,使得不快乐值的总和最小。你能帮他吗?
(原题目当然不是“屌丝值”)
其实可以看做选择若干个区间进行区间翻转操作,故采用区间DP
考虑怎么样才能覆盖整个状态空间,要调整一个人的位置,就是要采用区间翻转的方式,对于每个枚举的区间,枚举第一个人在区间中应该处于的位置,可以保证遍历所有情况(显然一个人可以处在区间中任何一个位置)
10.4 Tree-based DP (dp_tree)
树形是一种神仙的结构。
经典树形DP不再赘述。
树形DP还有一种背包型的问题,如下面这个。
[CQOI2017] 小Q的棋盘
小 Q 正在设计一种棋类游戏。
在小 Q 设计的游戏中,棋子可以放在棋盘上的格点中。某些格点之间有连线,棋子只能在有连线的格点之间移动。整个棋盘上共有 \(V\) 个格点,编号为 \(0,1,2,\cdots, V- 1\),它们是连通的,也就是说棋子从任意格点出发,总能到达所有的格点。小 Q 在设计棋盘时,还保证棋子从一个格点移动到另外任一格点的路径是唯一的。
小 Q 现在想知道,当棋子从格点 \(0\) 出发,移动 \(N\) 步最多能经过多少格点。格点可以重复经过多次,但不重复计数。
发现在每一个点,因为不确定有多少步数可用,所以就没法判断最深能走到哪里而可以保证回得去。所以把步数加入需要枚举的状态,升一维。
对于每个状态,考虑把自己拥有的空闲步数分配给子状态。这样,子状态就可以抽象为 \((c_i,w_i)\) 的一个物品,其中代价 \(c_i\) 是你为它分配的步数, 价值 \(w_i\) 是最多能访问的节点。这就是背包问题了。
另外,本题还有一个巧妙的贪心做法,这就巧妙了。
11. Virtual-tree-based DP (dp_virtual)
虚树DP基于树形DP的一类特殊问题:如果转移只和树上一些关键点有关,那么就可以把树的结构压缩,只保留关键点和他们的LCA(保持结构的必需点)。
这种题通常都有比较明显的特征,就是有这样的描述:
\[\sum cnt_i \leq M \text{ }(cnt_i\text{ is the number of the key points while }M\text{ is a constant}) \]方便起见,将 \(1\) 号节点也看作一个关键点。
常见的构建virtaltree方法略。
其实这种压缩的思想也体现在圆方树上,在处理一些问题的时候也可以利用圆方树的结构来简化运算。看看下面这道题
[HNOI2009] 无归岛
Neverland 是个神奇的地方,它由一些岛屿环形排列组成,每个岛上都生活着之中与众不同的物种。
但是这些物种都有一个共同的生活习性:对于同一个岛上的任意两个生物,他们有且仅有一个公共朋友,即对同一岛上的任意两个生物 a 和 b 有且仅有一个生物 c 既是 a 的朋友也是 b 的朋友,当然某些岛上也可能会只有一个生物孤单地生活着。
这一习性有一个明显的好处,当两个生物发生矛盾的时候,他们可以请那个唯一的公共朋友来裁决谁对谁错。 另外,岛与岛之间也有交流,具体来说,每个岛都会挑选出一个最聪明的生物做代表,然后这个生物与他相邻的两个岛的代表成为朋友。
不幸运的是,A 世界准备入侵 Neverland,作为 Neverland 的守护者,Lostmonkey 想知道在一种比较坏的情况下 Neverland 的战斗力。因为和朋友并肩作战,能力会得到提升,所以 Lostmonkey 想知道在不选出一对朋友的情况下Neverland的最大战斗力。即选出一些生物,且没有一对生物是朋友,并且要求它们的战斗力之和最大。
我们就不提拆成三角形环和大环处理的方法了,直接考虑仙人掌上最大权独立集问题。
考虑暴力计算最大权独立集的过程:
-
暴力考虑某点是否选择
-
判断解的合法性
-
更新答案
那么我们需要的关键信息,用于刻画问题的就是:
-
一个点是否被选择
-
相邻点的选择情况
考虑一个简化的问题,如果在树上求解呢?那么这两条信息就是:
-
一个点是否选择
-
子节点的选择情况(or 父节点的选择情况)
这就是 P1352 没有上司的舞会
考虑仙人掌上的问题,发现第二条信息不好处理,因为节点间的关系复杂了
但是我们发现仙人掌拥有如下的性质:
- 它有环,但是没有一个边同时在 \(2\) 个环里,如果从树的角度来看,仙人掌就是树加上边,最后满足同一个点 至多 有 \(1\) 条返祖/后向边
也就是说仙人掌虽然连边复杂了,但是对于每一个点来说,它可能同时连接在很多链/很多简单环中,但是一个环中能影响它的选择情况的,只有 \(2\) 个点,而一条链中能影响它的选择情况的,只有 \(1\) 个点。
所以,非常暴力的思想就是用DFS树来确定更新顺序,令 \(f_{x,0/1,0/1}\) 表示当前 \(x\) 这个点选/不选,且这个点所在的环(这个点不为环顶的环)的环底选/不选时的最值(环顶/底:一个环中DFS序最小的叫环顶,最大的叫环底)
转移方程会比较长,请看代码注释:
#include<bits/stdc++.h>
using namespace std;
const int N=1e5+5,M=2*N;
int n,m,val[N],f[N][2][2];
// 当前节点/是否选择当前节点/是否选择环底
int tim,dfn[N];
vector<int> mp[N];
int dp(int x,int fa){
dfn[x]=++tim;
f[x][1][0]=f[x][1][1]=val[x];
int rt=0;
for(int v:mp[x]){
if(fa==v) continue;
if(!dfn[v]){
int root=dp(v,x);
//当自己不是环顶时的返回值是自己所在的大环的环顶
if(root!=dfn[x])rt=root;
//如果当前节点的dfn等于返回值,则当前正在遍历一个环的第一条边
f[x][1][0]+=f[v][0][0];
//不论当前是否为环顶,都应该这样更新
f[x][1][1]+=f[v][0][(root==dfn[x])?0:1];
//如果是环顶, 那就必须不选环底,否则照常更新
if(root==dfn[x]){
//只要不选环顶,就对子节点的状态没有任何限制
f[x][0][0]+=max(max(f[v][0][1],f[v][0][0]),max(f[v][1][0],f[v][1][1]));
f[x][0][1]+=max(max(f[v][0][1],f[v][0][0]),max(f[v][1][0],f[v][1][1]));
}
else{
//不是环顶,照常更新
f[x][0][0]+=max(f[v][0][0],f[v][1][0]);
f[x][0][1]+=max(f[v][0][1],f[v][1][1]);
}
}else if(dfn[x]>dfn[v]){
rt=dfn[v];//如果找到返祖边
//那么这个点就是环底,依照定义,以下情况不合法
f[x][1][0]=-0x3f3f3f3f;
f[x][0][1]=-0x3f3f3f3f;
}
}
return rt;//小技巧:递归地返回所在的的环顶
}
int main(){
ios::sync_with_stdio(false);
cin>>n>>m;
for(int i=1;i<=m;i++){
int u,v;cin>>u>>v;
mp[u].push_back(v),mp[v].push_back(u);
}
for(int i=1;i<=n;i++) cin>>val[i];
dp(1,0);
cout<<max(max(f[1][0][1],f[1][0][0]),max(f[1][1][1],f[1][1][0]));
return 0;
}
关于使用圆方树解决的题解,在此我想对那篇题解做一个注解,解释一下方点的转移方法:
那篇题解是这样处理方点的:
int f0=0, f1=-1.4e8, g0=-1.4e8, g1=0;
//f是代表父节点(即环顶)不选的情况,g是代表要选的情况
for(int i=hh[u]; i; i=nt[i]){
int v=to[i]; if(v==fa)continue;
dp(v, u);
int o0=f0, o1=f1;
f0=max(o0, o1)+f[v][0], f1=o0+f[v][1];
//不选的时候,由于圆方树加边的特性,上一个扫描的儿子和当前扫描的儿子
//在原图上面是相邻的,于是可以更新
o0=g0, o1=g1;
g0=max(o0, o1)+f[v][0], g1=o0+f[v][1];
//要选环顶的时候,由于 g0 初值是inf,所以第一条直接与环顶接壤的边不会被更新到
}
f[u][0]=max(f0, f1), f[u][1]=g0;
//这里没有用 g1 更新,因为最后一个遍历的儿子必然是环底,这样做保证了不选环底
(我才不会告诉你我看了2days还不懂问了机房大佬way才懂的)
对于更扩展的问题,无向图上的最大独立集问题,很遗憾,这是一个NP-Complete问题,可以从3-SAT归约而来。
这就做完了。
12. Data-structure-optimized DP (dp_dsopt)
用什么数据结构不关键,得到转移方程是最难的
用数据结构优化DP,通常在这两个角度优化:
-
预处理(或类似预处理)出DP中所需信息
-
优化转移的时间复杂度,就是把DP直接放上数据结构
[SCOI2014] 方伯伯的玉米田
方伯伯在自己的农田边散步,他突然发现田里的一排玉米非常的不美。这排玉米一共有 \(N\) 株,它们的高度参差不齐。方伯伯认为单调不下降序列很美,所以他决定先把一些玉米拔高,再把破坏美感的玉米拔除掉,使得剩下的玉米的高度构成一个单调不下降序列。方伯伯可以选择一个区间,把这个区间的玉米全部拔高 \(1\) 单位高度,他可以进行最多 \(K\) 次这样的操作。拔玉米则可以随意选择一个集合的玉米拔掉。问能最多剩多少株玉米,来构成一排美丽的玉米。
可以发现就是求 \(K\) 次操作后不下降子序列最长多长
重点其实是结论:所有修改操作的右端点一定为 n
设修改区间为 [l,r],显然:
-
修改区间内部 元素相对大小不变。
-
左侧元素不变,[1,l−1] 的 不下降子序列 不变。
-> 则区间增高后,[1,r] 的 最长不下降子序列 长度 只会增不会减。
-
右侧元素不变,区间增高后,可能无法接到 [l,r] 元素的后面,会导致 最长不下降子序列 减小。
带修改操作的DP问题,常常是有贪心结论的,可以考虑操作后对各个方面局面的影响,尽可能地猜到结论
完了设 \(f_{i,j}\) 是以 \(i\) 结尾,已经进行 \(j\) 次影响了区间内的修改操作(废话,考虑区间外的干嘛)
发现转移点 \(f_{k,l}\) 有 \(1≤k<i, 0≤l≤j, ak+l≤ai+j\) 是三维前缀最大值
通过阶段的枚举可以消除 \(k\) 的影响,那么问题化为2维前缀最大值,可以在二维树状数组上跑DP
进一步地,可以通过 \(f\) 的单调性再优化1log
13. Monotoic-Optimized DP (dp_monotoic)
做吼了,更新一下。
单调队列优化,关键就是1D/1D DP模型中间的代价函数要能够被分成只和决策变量、只和状态变量有关的形式。
其实高妙么,不高妙。放个板子:
deque<int> q;
void Insert(int p){
while(!q.empty()&&f[q.back()]<=f[p]) q.pop_back();
q.push_back(p);
}
#define L (x[i]-(d+g))
#define R (x[i]-(d-g))
int dp(int g){
q.clear();memset(f,0xcf,sizeof f);
f[0]=0;int cur=0,res=0;
for(int i=1;i<=n;i++){
while(cur<i&&x[cur]<L) cur++;
while(cur<i&&L<=x[cur]&&x[cur]<=R) Insert(cur++);
while(!q.empty()&&x[q.front()]<L) q.pop_front();
if(!q.empty()) f[i]=f[q.front()]+<VALUE>;
res=max(res,f[i]);
}
return res;
}
这是最新我的板子,自适应区间
主要放一个技巧:
技巧一个
涉及严格单调的时候,可以通过减去一个公差为1的等差数列变成非严格单调(其实只要满足这种情况的非严格单调一定就满足严格单调)
14. Convex-hull-optimized DP (dp_convex)
对于 1D/1D (其实我认为如果把其他枚举的状态看作定值,只对最里层决策进行优化似乎也可以)
挖坑 有2个决策变量的DP能不能单调队列/斜率优化
填坑,可以。
这种题,我们通常把方程化成:
\(f_{...,j}+val(j)=f_{...i}+val(i,j)+val(i)\)
根据x、y、斜率的单调性进行讨论:
-
x单调|y非/单调|斜率单调:模板,无影响
-
x单调|y非/单调|斜率非单调:单调队列上二分
-
x非单调|y非/单调|斜率非/单调:平衡树维护凸壳
这种方法有一个关键,就是要把交叉项给分开,不能是一个函数里又有i又有j,例如:\(\max_{i\le k\le j}g_k\) ,见下:
[USACO08MAR] Land Acquisition G
Farmer John 准备扩大他的农场,眼前他正在考虑购买 \(N\) 块长方形的土地。
如果 FJ 单买一块土地,价格就是土地的面积。但他可以选择并购一组土地,并购的价格为这些土地中最大的长乘以最大的宽。比如 FJ 并购一块 \(3 \times 5\) 和一块 \(5 \times 3\) 的土地,他只需要支付 \(5 \times 5=25\) 元, 比单买合算。
FJ 希望买下所有的土地。他发现,将这些土地分成不同的小组来并购可以节省经费。 给定每份土地的尺寸,请你帮助他计算购买所有土地所需的最小费用。
一眼方程,但是这个真的可以优化吗?
如果拆不开ij,那么随着i的变化x轴所有点的坐标会变化,这变化之后是不是凸壳就难说了(P.S. 这个题特殊,满足四边形不等式)
所以先带了一个贪心性质:长宽都比另一个长方形小的可以抛弃
排序,消除之后就能优化了
15. Quadrangle-inequality-optimized DP (dp_quad)
四边形不等式是一种用来证明决策单调性的一种方法。
如果满足决策单调性,那么就可以用多种方法维护最优转移点,排除不必要决策。
DP优化:纵横对比
Optimizations | 适用范围 | 思想 | 例题 | 变式 |
---|---|---|---|---|
预处理前缀和 | 有可差分信息时 | 预处理 | 略 | 无 |
费用提前计算 | 当有信息影响后面所有转移,并且该信息与阶段刻画无关 | 降维 | [任务安排P5785] | 相当于在从前往后填表的时候干了刷表的活[P2605基站选址] |
数据结构优化 | 1. 动态计算方程中某一个值 2. 用于DP范围min/max转移 |
/ | 方伯伯的玉米田/基站选址P2605 | / |
二进制状态压缩 | 用于把集合放进状态,便于枚举和表示,避免开太多维度 | 提取模型 | 一双木棋chess(hash/轮廓线) | 不一定非得是二进制压缩(三进制),有的时候可以把集合Hash之后存进状态 |
虚树DP优化 | 优化关键点型树形DP | 去除无效转移点,信息压缩 | P2495消耗战 | 圆方树/DFS树 |
圆方树/DFS树DP | 圆方树:压缩信息/提取环 DFS树:方便地便利环 通常用于仙人掌上DP |
/ | 世界树 | 较少考察 |
单个变量优化 | 适用于决策集合只增不减的情况 | 排除无效决策 | 略 | 略 |
单调队列优化 | 适合决策集合取值范围两边单调变化的情况 | 排除无效决策 | 太多 | 关键在于,写出方程式,发现i/j分开没有交叉项,这样关于决策变量j的最值与i无关 |
斜率优化 | 适合方程有交叉项时 | / | / | - x单调|y非/单调|斜率单调:模板,无影响 - x单调|y非/单调|斜率非单调:单调队列上二分 - x非单调|y非/单调|斜率非/单调:平衡树维护凸壳 |
四边形不等式 (决策单调性) |
适合一些val比较复杂的方程 | / | / | 反正你也看不出来满不满足四边形不等式,打个表不香么 |
带权二分/wqs二分 | N/A | N/A | N/A | N/A |
16. Dynamic DP (dp_dyn)
动态动态规划,emm
适合带修改的动态规划问题。通过转化问题到序列上,然后使用线段树维护转移矩阵乘积来维护动态规划的值。
比如没有上司的舞会,黄题,加个修改M=1e5直接变紫,有点抽象
现在来记一道独立推到DDP的过程。
[NOIP2018 提高组] 保卫王国
Z 国有 \(n\) 座城市,\((n - 1)\) 条双向道路,每条双向道路连接两座城市,且任意两座城市都能通过若干条道路相互到达。
Z 国的国防部长小 Z 要在城市中驻扎军队。驻扎军队需要满足如下几个条件:
- 一座城市可以驻扎一支军队,也可以不驻扎军队。
- 由道路直接连接的两座城市中至少要有一座城市驻扎军队。
- 在城市里驻扎军队会产生花费,在编号为 \(i\) 的城市中驻扎军队的花费是 \(p_i\)。
小 Z 很快就规划出了一种驻扎军队的方案,使总花费最小。但是国王又给小 Z 提出了 \(m\) 个要求,每个要求规定了其中两座城市是否驻扎军队。小 Z 需要针对每个要求逐一给出回答。具体而言,如果国王提出的第 \(j\) 个要求能够满足上述驻扎条件(不需要考虑第 \(j\) 个要求之外的其它要求),则需要给出在此要求前提下驻扎军队的最小开销。如果国王提出的第 \(j\) 个要求无法满足,则需要输出 \(-1\)。现在请你来帮助小 Z。
国王的要求无非就是让某两次转移必须使用0/1
let \(f_{i,0/1}\) denote that node i is chosen or not, we will have:
\[f_{i,0}=\sum_{v\in Son(i)}f_{v,1}\tag1 \]\[f_{i,1}=c_i+\sum_{v\in Son(i)}\min\{f_{v,0},f_{v,1}\}\tag2 \]然后定义 \(g_{i,0/1}\) 是不选择/选择这个点时,所有轻儿子的的最小代价
\[g_{i,0}=\sum_{v\in LightSon(i)}f_{v,1}\tag3 \]\[g_{i,1}=c_i+\sum_{v\in LightSon(i)}\min\{f_{v,0},f_{v,1}\}\tag4 \]重写 \(f_{i,0/1}\) 的表达式
\[f_{i,0}=g_{i,0}+f_{v,1}\quad v\text{ is i's heavy son} \]\[f_{i,1}=g_{i,1}+\min\{f_{v,0},f_{v,1}\} \]转移矩阵
\[\begin{bmatrix} f_{x,0}&f_{x,1} \end{bmatrix}*\begin{bmatrix} \infty & g_{x,1}\\ g_{x,0}& g_{x,1} \end{bmatrix} = \begin{bmatrix} f_{y,0}&f_{y,1} \end{bmatrix}\tag {Matrix} \]注意转移顺序,实际上 \(f\) 矩阵是很多 \(g\) 矩阵乘出来的,所以注意线段树上乘法顺序和你的方程之间的对应!
S2 Dynamic Programming
String
17. Manacher (str_manacher)
字符串哈希跳过。
Manacher 算法利用中心扩展法和回文串的镜像原理重复利用计算出来的信息,保证不重复扫描扫描过的字符(每次要么不扫描,要么扫描当前 R 右边未被扫描的字符)
跟 KMP 的思想是一样的
转化方面,将偶回文串中间加了个字符转化为奇回文串处理。
18. Palindromic Tree (str_pam)[Need-to-be-fixed]
19. Z-Function (str_exkmp)
Mua了个图
其实思想和解决的问题都跟 manacher 和 KMP很像,就像是他们结合了一样。
注意 Z[i]=LCP(suffix(1),suffix(i))
Windows 画图 yyds
20. Aho-Corasick Automaton (str_autoac)
终于再一次看懂了。
让我们先从字符串匹配的角度看AC自动机
其实这里用的Trie是为了“组织”字符串,其实不是,我认为Trie主要是用来查找后缀和进行匹配,就像它的原本用途那样。然而同时匹配模式串已经在造Fail的时候做过了,所以同时匹配多个模式串的根本原因就是模式串。
这里必须明确的概念是,fail指针的含义是:((最长的(当前字符串的后缀))在Trie上可以查找到)的末尾 ,换言之,fail的意义是找到下一个可能可以继续匹配的位置,这就像KMP的fail。
所以
-
跳实边:暴力扩展一个字符
-
跳Fail:跳到下一个可能匹配的位置
每次跳Fail的时候,都会舍弃掉文本串的一个前缀,然后转移到另一个模式串的前缀和文本串的后缀重合最长的那个位置 ,在预处理 Fail 的时候,其实这等价于两个模式串的最长公共前后缀。也就是说,Trie上的点还是代表模式串的前缀,Fail只是一个“广义”Border罢了。
关于造 Fail 的过程,其实是一个不断把字符串互相匹配的过程,这里只是用了一点技巧来优化,用父节点的 Fail 来更新,就是看能否把父节点的 Fail 拓展一格。如果父节点 Fail 有我这个儿子的字符,说明能够匹配,可以扩展,如果没有这个子节点,说明不可以扩展,那么就继续跳 Fail (代码里提前下放了 Fail,按照Topo序更新就不必继续跳,路径压缩)。这揭示了 Fail 和 PAM 的 Fail 之间的联系:都指的是满足某种条件的最长后缀,都在遍历的时候尝试扩展最长后缀。利用预处理思想,将已经计算的信息利用,确定最优答案函数扩展的唯一路径,这有点像DP,这种思想就是 Manacher、KMP、PAM、AC自动机优化的根本。
从自动机的角度说,自动机是用来判定一个信号序列的,我们看看AC自动机上体现了自动机的什么特征。
有限状态自动机(Deterministic Finite Automaton,DFA)是由
- 状态集合 \(Q\);
- 字符集 \(\Sigma\);
- 状态转移函数 \(\delta:Q\times \Sigma \to Q\),即 \(\delta(q,\sigma)=q',\ q,q'\in Q,\sigma\in \Sigma\);
- 一个开始状态 \(s\in Q\);
- 一个接收的状态集合 \(F\subseteq Q\)。
组成的五元组 \((Q,\Sigma,\delta,s,F)\)。
那这东西你用 AC 自动机理解,状态集合就是字典树(图)的结点;字符集就是
a
到z
(或者更多);状态转移函数就是 \(\text{trans}(u,c)\) 的函数(即 \(\text{trans}[u][c]\));开始状态就是字典树的根结点;接收状态就是你在字典树中标记的字符串结尾结点组成的集合。KMP 自动机
KMP 自动机就是一个不断读入待匹配串,每次匹配时走到接受状态的 DFA。如果共有 \(m\) 个状态,第 \(i\) 个状态表示已经匹配了前 \(i\) 个字符。那么我们定义 \(\text{trans}_{i,c}\) 表示状态 \(i\) 读入字符 \(c\) 后到达的状态,\(\text{next}_{i}\) 表示 prefix function,则有:
\[\text{trans}_{i,c} = \begin{cases} i + 1, & \text{if }b_{i} = c \\[2ex] \text{trans}_{\text{next}_{i},c}, & \text{otherwise} \end{cases} \](约定 \(\text{next}_{0}=0\))
我们发现 \(\text{trans}_{i}\) 只依赖于之前的值,所以可以跟 KMP 一起求出来。(一些细节:走到接受状态之后立即转移到该状态的 next)
时间和空间复杂度:\(O(m|\Sigma|)\)。
对比之下,AC 自动机其实就是 Trie 上的自动机。(虽然一开始丢给你这句话可能不知所措
贴个关于自动机的好例子:浅谈DFA确定有限状态自动机 - 知乎 (zhihu.com)
❝ 请实现一个函数用来判断字符串是否表示数值(包括整数和小数)
❝ 数值(按顺序)可以分成以下几个部分:
- 若干空格
- 一个 小数 或者 整数
- (可选)一个 'e' 或 'E' ,后面跟着一个 整数
- 若干空格
❝ 小数(按顺序)可以分成以下几个部分:
- (可选)一个符号字符('+' 或 '-')
- 下述格式之一:
- 至少一位数字,后面跟着一个点 '.'
- 至少一位数字,后面跟着一个点 '.' ,后面再跟着至少一位数字
- 一个点 '.' ,后面跟着至少一位数字
❝ 整数(按顺序)可以分成以下几个部分:
- (可选)一个符号字符('+' 或 '-')
- 至少一位数字
❝ 部分数值列举如下: ["+100", "5e2", "-123", "3.1416", "-1E-16", "0123"]
❝ 部分非数值列举如下: ["12e", "1a3.14", "1.2.3", "+-5", "12e+5.4"]
对于本题而言,非常适合通过DFA解决。现在按照DFA的五元素依次进行分析。对于输入集合而言,可以看出其有效的输入字符只能是以下几种:
- 空格
- 数字
- 符号位:+正号、-负号
- 小数点
- 指数符号:e、E
下一步,则可以根据处理到字符串的哪个部分作为自动机的状态。至此不难分析出,对于本题其状态集合为:
- 起始空格
- 数字的符号位
- 整数部分
- 左侧有整数的小数点
- 左侧无整数的小数点
- 小数部分
- 指数符号
- 指数的符号位
- 指数部分的整数
- 结束空格
不难看出,自动机的初始化状态为起始空格。同时可以看到当字符串全部输入结束时,如果自动机处于以下状态之一时,则该字符串显然是可以表示为数值的。换言之,下述状态即为本自动机的可接受状态
- 整数部分
- 左侧有整数的小数点
- 小数部分
- 指数部分的整数
- 结束空格
现在根据状态集合、输入集合实际上不难分析出本自动机的转移函数。具体如下所示
节录 AC自动机学习笔记 - ouuan的博客 许可协议 CC-BY-SA-4.0
形式上,AC 自动机基于由若干模式串构成的 Trie 树,并在此之上增加了一些 fail 边;本质上,AC 自动机是一个关于若干模式串的 DFA(确定有限状态自动机),接受且仅接受以某一个模式串作为后缀的字符串。
并且,与一般自动机不同的,AC 自动机还有 关于某个模式串的接受状态(我自己起的名字..),也就是与某个模式串匹配(以某个模式串为后缀)的那些状态。
笔者注:一个模式串可能关于该文本串被接受若干次,这就是它在这之中出现的次数
fail 边是什么?
....
另外,fail 边的作用类似于 KMP 算法中的 next 数组。
fail 边怎么连?
...
我们发现一个状态的 fail 边连向的其实就是这个状态的一个自动机上最长真后缀。
为什么呢..感性理解一下,失配了就不看前几位了..
...
fail 树
由于每个点都只连出一条 fail 边,且连到的点对应的字符串长度更小,所以 fail 边构成了一棵 fail 树。
如果学过 SAM 的话,可能会发现 fail 树和 parent 树很像..实际上它们具有的性质是相同的,然而构成它们的状态不同——parent 树是所有 right 集合等价类(也就是 SAM 上的所有节点),而 fail 树是 Trie 上的每个前缀(也就是 AC 自动机上的所有节点)。
作为一个自动机,我还没讲 AC 自动机的接受状态是哪些..其实就是 Trie 树上的那些终止节点在 fail 树上的整个子树的并。
而 关于某个模式串的接受状态,也就是与某个模式串匹配(以某个模式串为后缀)的那些状态,就是那个串在 Trie 树上的终止节点在 fail 树上的子树。知道这个也就知道怎么用 AC 自动机进行多模式串匹配了(建出 fail 树,记录自动机上的每个状态被匹配了几次,最后求出每个模式串在 Trie 上的终止节点在 fail 树上的子树总匹配次数就可以了)。
笔者注:Fail树(森林)就是按topo序(从深度大向深度小连边)遍历AC自动机
与 KMP 之间的关系
放在最后面是因为我认为 KMP 并不是 AC 自动机的前置知识..然而他们之间的确有着千丝万缕的联系。
「KMP 是个自动机」
要是早有人告诉我这句话估计我早就(真正地)学会 KMP 了..
KMP 自动机的主体是一条链,加上了一些“next 边”(其实就是 AC 自动机的 fail 边)。
而 KMP 自动机之于 AC 自动机,就像 SAM 之于广义 SAM。
也就是很多人常说的一句话:AC 自动机就是 Trie 上 KMP。
21. Suffix Array (str_sa)
我们先思考 SA 的出发点是什么。
许多字符串问题都是跟子串相关的。我们应该考虑如何方便地建立子串之间的关系。
后缀树,是考虑用字典树存储后缀,欸,字典树上每个点都是代表一个前缀,那么后缀的前缀不就是子串吗?
好,现在 SA 只是解决了后缀树太过复杂的问题,没把它建出来罢了。
对于朴素地构造后缀数组,我们发现时间浪费其实在于:
-
字符串比较中的重复扫描
-
需要比较两两比较字符串,而他们实际上很可能重复很多
基于此,我们想到了基数排序:
-
同时比较多个字符串
-
从最高位开始比较,避免重复扫描
但是这位数也太多了吧!哦,我们可以倍增地进行基数排序!每次都对选定的前缀排序,然后压缩信息,这样就避免了位数太多的 issue 。
这样就得到了 \(\mathcal O(N\log^2 N)\) 的做法,goo 。
还能优化么?基数排序!把每次排序换成基数排序,其实就是一个只有两位的基数排序,您就得到了一份较大常数的近似 \(\mathcal O(N\log N)\) 做法!
当然,还能优化吗?还能!使用 DC-3
或者 SA-IS
算法,好,我不会了(前者较常用,后者表现更好)。
关于优化:
- 第二维排序的时候是大部有序的,只有超出范围的部分是逆序的,故可以省掉一维
- 其实很多时候不需要排严格 log次即可,据说优化很大
关于后缀数组的作用,当我们排序之后,越靠近的若干后缀的前缀的相似度就越高。
求后缀数组的意义,就是要利用后缀的前缀刻画子串,所以我们引入后缀的前缀:LCP
根据 LCP 的性质定义数组 height ,这样,就能够利用sa的有序性和height的子串相等性做题,通常是用来判断子串本质不同之类问题。
22. Suffix Automaton (str_sam)
非常绕,读了一下午才看懂
太菜了qwq,lph一如既往地巨,都做完广义sam开始刷习题啦
SAM 的目标,是表征全部子串,并提供表示子串后缀、前缀的包含关系的途径,由于这个原因,SAM 能够解决的问题相当多。
从自动机的角度讲,SAM 接受且仅接受一个字符串的所有后缀,但是后缀的前缀即子串
我们使用 endpos 等价类来构造这样的树,我们的目的是
-
节省空间
-
通过不重复地存储,减少重复计算,例如:
考虑建立
ababc
的后缀树,并尝试计算cbc
出现几次发现后缀树虽然可以表示字符串的所有子串,但是不能表示他们之间的包含关系,使得无法高效统计存在信息,同时,因为存储太多包含关系,后缀树也非常浪费空间
endpos等价类就可以解决这些问题,它的关键是:
-
endpos:endpos表示了子串之间的后缀包含关系
-
等价:这些字符串再扩充字符仍是后缀包含关系,揭示了子串之间的前缀关系
所以,我的结论是,
-
母树上的边(link变量)代表后缀关系
-
SAM 上的转移,代表前缀关系
这真是太巧妙了
关于“复制”操作的注解:
当遍历原先字符串的后缀,发现这个后缀可接受这个新接入的字符 c,只有后缀加上一个字符才能形成新的后缀,但是节点上可能存储非原先后缀添一个 c 形成的串,可能还会有添加若干其他字符的
你可从不同的方式理解 SAM 的思考路径:
-
为了最小表征所有后缀,可以把中间重复的也合并成一条路径
-
见 (题解 P6139)SAM/广义SAM 与 AC 自动机 之间的关系 - It's LUNATIC time! - 洛谷博客 (luogu.com.cn)
Graph
23. Minimum Tree Graph (graph_mst [Need-to-be-fixed])
这其实是有向图上面的MST
(待补)
24. Flow (graph_flow)
网络流问题原本是建立在求一个水管网络最大发送量的基础上。
后面发现由于这种水流的特性,我们可以把水流看作是问题的贡献(当然有的时候也可以是代价,就比如要建模成最小割算法的时候),把限制建模成容量的限制,就能够通过最大流/费用流/最小割的算法算出最优解。
关于这几种网络流算法,他们的基础想法都是:
-
基于贪心思想(一个流在流出去之后只会改变流向,不可能回去,答案一定是增长),先找到可行方案,然后优化答案
-
利用反向边进行退流,重新分配流量,再次优化答案
建模是很难总结的 浅谈网络流的各种建模方法 - 神志不清的时候写的laji东西 - 洛谷博客 (luogu.com.cn)
Dinic的几种复杂度 - myee - 博客园 (cnblogs.com)
25. Binary Graph Match (graph_bimatch)
A. Max-Match
匈牙利算法,也使用了增广路这个名字,而且,这个问题的性质很像网络流:一个点如果成为匹配点,则只会改变匹配的边,不会变回非匹配点。增广路,不论是在网络流还是 KM 算法里,都是一种选了绝对不亏的贪心选择,而且拥有决策包容性(这次选这条增广路,下次选增广路的时候,这次的选择不会对下一次的选择产生限制,它仍然可以生成该子问题的局部最优解,并且一定使得答案不变劣)
同时,该问题可以被建模成网络流,时间复杂度是 \(\mathcal O(M\sqrt{N})\) ,优于匈牙利算法
B. KM Algorithm
首先普通费用流不能代替 KM 算法,因为这个算法的复杂度是 \(\mathcal O(N^2M)\)。