2025 题目速查
- 省选题目清单
- 有条件要上,没有条件创造条件也要上。
- 记录自己的思路在何处跑偏
- 记录下次遇到同类型题目的基本思路过程
- 不是残疾人没必要坐轮椅,免得站不起来。——茶室电网
- 看见更多的 Trick,学会更合理地思考
- 没有搞明白的神秘题目/对联赛没什么帮助的知识点见原笔记本。
- SAM/SA,ACAM/KMP,Trie 是字符串中为数不多的几个有用的结构。尤其是 SAM 要多做题发现并使用不同性质。
- 李超动态开点会爆炸,只能对于可能出现的 \(x\) 离散化。斜率优化优先考虑李超,而不是单调队列。
- 训练计划
- 专题
- 计算机语言有关的科技
- 基础算法 + 小技巧
- 思想观念
- 常见推式子技巧
- 当心被 Corner Case 做局
- 枚举·模拟·容斥
- 前缀和·差分
- 分治算法·CDQ
- 贪心·结论·构造·博弈论
- 分类讨论
- 网格图坐标还是直角坐标系
- 特别专题:一些和冒泡排序有关的结论
- 结论
- 归纳法找结论
- 对序列和找结论
- 对差分数组找结论,相邻相同是性质
- 小 Trick:最小化两个和一定的数的最大值答案就是取其和的一半的上取整。最大化最小值就是下取整。
- BFS 转移具有最优性
- 正图有后效性的时候考虑建反图
- 博弈论
- SG(公平组合游戏好帮手)
- 例题
- 打表节省 SG 时间
- 博弈论:公式做题就是快
- 博弈论 vs 博弈论 DP/贪心
- 贪心,真的是路径简化的 DP 吗?
- 仅仅考虑相邻状态(区间加一最小计数)
- 连通次大值转次大路径
- 贪心本质是挖掘题目信息来得到一些有利于思考解法和优化解法的信息和结论,而不仅仅是对搜索和 DP 的减少状态。
- 邻项交换排序与逆序对
- 通过邻项交换来把一个排列排升序的最小操作次数就是排列的逆序对个数。
- 堆贪心——不反悔但记录变化量
- 取值总和为 \(k\) 可以转换为每次 \(+1\) 的操作总数为 \(k\)。
- 反悔贪心 —— 退流?
- 按照余数分组
- 残余局面博弈(见「博弈论 DP」)
- 环状路径过定点
- 异或,加法,最短路
- 与和异或都是零
- 归纳法万岁
- 构造和角色的贪心策略有时候也是由特殊到一般来推断的。
- 边权下界的调整构造
- 调整法+正难则反
- 扣费转加费
- 联系生活实际
- 贪心结论,LIS 维护
- 贪心并非简单的排序
- 先从没有限制的情况入手
- 再考虑正难则反/走逆过程
- 贪心是一个短视的过程,不考虑后续状态是否以当前决策最优,不要想太多
- 不能排除就一定存在
- 图论建模是辅助思考的好东西
- 有单调性不一定是二分,可能是堆。
- 从样例中找信息。
- 从物体对人的贡献角度考虑,答案可能有和选法无关的理论下界。
- 记忆化搜索?记忆化贪心!
- 转换题意,构建模型
- 构造:用组合数增大增长率
- 构造:比黑白更多的染色并使用抽屉原理定次数
- 全面地讨论问题
- 正确暴力,勇敢去做
- 二分·整体二分·WQS 二分
- 倍增·ST 表
- 双指针·莫队
- 随机化乱搞
- 搜索
- DP
- 串串
- 数学
- 数学常识和技巧
- 数论
- Miller-Rabin
- EXGCD
- 专题:碰到边缘就反弹
- 小知识:用 EXGCD 解决 \(ax+by=c\) 的时候 \(c\) 是允许为负数的,因为 EXGCD 本身和 \(c\) 无关。
- 特别注意:在 \(Ax+By=C\) 求最小解之前先求出一个可行解之后 \(A,B,C\) 全都要除以一个 \(\gcd(A,B)\),不然值域和答案步长会有问题。还有,\(x\) 最小的时候 \(y\) 就是最大的。
- EXCRT
- 欧拉降幂:用于大整数的幂运算
- 卢卡斯定理:用于大整数的组合数(可能没有逆元)
- 大步小步:离散对数
- 线性筛的正确使用——以因数个数为例
- 莫反+数论分块+杜教筛:看见 \(\gcd\),公式做题就是快
- 约数个数和(重点背记)
- Poly + GF
- EGF
- 高消·线代
- 组合数学
- 线性代数
- 杂项
- DS
- 图
- 计算几何(没用的知识)
省选题目清单
有条件要上,没有条件创造条件也要上。
记录自己的思路在何处跑偏
记录下次遇到同类型题目的基本思路过程
不是残疾人没必要坐轮椅,免得站不起来。——茶室电网
看见更多的 Trick,学会更合理地思考
没有搞明白的神秘题目/对联赛没什么帮助的知识点见原笔记本。
SAM/SA,ACAM/KMP,Trie 是字符串中为数不多的几个有用的结构。尤其是 SAM 要多做题发现并使用不同性质。
李超动态开点会爆炸,只能对于可能出现的 \(x\) 离散化。斜率优化优先考虑李超,而不是单调队列。
如果在 DP 设计状态或者解决其他问题的时候缺少确定的条件,我们自己通过声称某些信息是不变的来得到确定的条件。
训练计划
每天在白天主要补基础题(20 套题单),晚上汇总笔记。
汇总笔记时知识点不写,直接写解题技巧。
下面的时间为想+写+交(看题解前)的时间:
蓝题最多 1.5 小时,绿题最多 1 小时。
看题解有助于增长见识,使得在看到同类题目时有思考方向。
省选以上内容没有时间时先暂时搁置。先补联赛能力。
Day 202~221 需要汇总此前的笔记。后续仅需要把笔记的题目和小知识放在此处。按照如下格式:
# 大知识点
## 小知识点
### 小技巧
题目
专题
有的时候我们可能看到一些常见的题目背景与形式。下面收录了一些专题以及其可能的思考过程。
MED
关于中位数,有一半(下取整)的大于等于它,有一半的小于等于它。
处理 MED 一般用二分,给小于的设置为 \(-1\),大于的设置为 \(1\),按照最大还是最小化中位数来决定给等于的设置为 \(1\) 还是 \(-1\)。
如果是最大化中位数就是判断和是否大于等于 \(0\) 来移动二分边界。
P2839(两遍):卡点:不会处理中位数。
我们每次二分答案,但是左右端点不确定怎么做呢?考虑求 \([a,b]\) 的最大后缀和与 \([c,d]\) 的最大前缀和,以及对 \([b+1,c-1]\) 求和,这样可以实现和的最大化。但是怎么确定哪里是 \(1\) 哪里是 \(-1\) 呢?我们初始首先全部设置为 \(1\),从小到大把点更新起来。每一次求 \(val\) 值的树的时候把 \(val-1\),也就是上一个设置为 \(-1\)。于是我们只要在值的树上面做就好。
MEX
作为题目的背景信息,mex 是十分常见的,因为除了数据结构二分还真不是很好维护。所以如果有 mex 我们优先考虑它优秀的贪心性质。
正向命题:对于一个数 \(x\),考虑有什么办法让这个数 \(x\) 尽可能不出现。
但是正向命题有可能并不见得容易。我们考虑其反向命题。
反向命题:对于一个数 \(x\),考虑如果其不可能是 mex,则应该满足什么性质。如果一个数可能是 mex,考虑有没有一种出现序列满足其可以是 mex。在所有可以是 mex 的数里面找最小值。
错误想法:直接求一个顺序来求出最小的 mex。
一般 mex 最小的序列不会是简单的排序,所以不要这样做。
MX-P110124(两遍):
对于每个元素 \(i\) 有两个数 \(a_i,b_i\),你可以从里面选取一个作为元素的值。你需要最小化所有元素的 mex。求这个最小化的 mex,以及可以构造出最小 mex 的方案数。两种方案不同当且仅当存在某一个方案选取元素的位置(\(a_i\) 还是 \(b_i\))不同。
卡点:变成取最小值求 mex 了,mex 计算方式错误。
只要存在一个方案满足 \(x\) 没有出现,\(x\) 就可能是 mex。
正难则反,考虑什么样的 \(x\) 一定会出现在序列里面。如果存在一个 \(i\) 满足 \(a_i=b_i=x\),则 \(x\) 一定在序列内,一定不是 mex。反之,对于每个 \(i\),如果两个数里面存在一个是 \(x\),我们就使用另一个。如果根本没有 \(x\) 就随便取一个。此时 \(x\) 没有出现在序列里面。
考虑取最小的这样的 \(x\) 作为 mex。记两个数都没有 \(x\) 的元素的个数为 \(c\),则答案就是 \(2^c\)。
计算机语言有关的科技
对拍用 chrono::system_clock::now().time_since_epoch().count() 而不用 time(0)。因为前者一微秒更新一次,后者一秒更新一次。等于是如果使用后者会大量跑相同的数据。
map 自己带 \(\log\),unordered_map 天天被卡,有一个更好的当数组用的东西。
#include<bits/extc++.h>
using namespace std;
using namespace __gnu_pbds;
gp_hash_table<int,int>mp;
查看运行时间:time ./a
查看静态内存:size ./a
查看运行时每个函数的调用次数和使用时间:
g++ -o a A.cpp -pg
time ./a
gprof ./a
这里不能开 -O2。
报告是否存在数组越界和 UB:
g++ -o a A.cpp -fsanitize=undefined
后面这个东西严格来说并不需要记住,这么打:
-fsan + Tab + =und + Tab
就直接出来了。
由于 -O2 会改代码,这里也不开 -O2。注意卡时间的时候不开这个,不然常数极大。
inline 在现在的编译器上反而增大了常数。
CF 怎么开优化:
#pragma GCC optimize(2)
#pragma GCC optimize(3)
#pragma GCC optimize("Ofast")
一般来说火车头里用这三个差不多。
关于卡常
我们不能人傻常数大。
首先 \(10^6\) 规模的读写是一定要使用快读快写的。其次 STL 供应的 Hash 函数,如 unordered_set 和 unordered_map 在 CF 上经常被卡,而 gp_hash_table 在一般情况下甚至常数更大,所以能不用一定不用。能手写查找位置的东西/字符串 Hash 就一定不要用系统的。再其次,long long 会增大时空常数,能不开绝不开。
long long常数大,只有答案开。- 取模常数大,用自然溢出(正式比赛一般不卡)。
- 加减法只维护结果奇偶性,用异或。
- \(len \times i < n\),减少判断。
我们首先把倍增时需要的转移矩阵的编号存储在一个向量里面。转移的时候就把矩阵直接赋值为第一个转移矩阵,之后正常乘法即可。由于这样是严格 \(O(\text{popcount})\) 的,常数变得更小了,就开 C++14 即可。
基础算法 + 小技巧
思想观念
枚举小技巧:考虑枚举的两维是否可能是积一定的,如果可能就转枚举这两个变成调和级数。
对于中位数,我们一般二分看是否存在一半以上的数能否大于等于 \(mid\),如能则中位数可以大于等于 \(mid\)。
离散化+特判的时空常数非常大,非必要不使用。
如果对自己的贪心方案没有信心,考虑构造一些数据 HACK 自己的解法。这需要理解自己的贪心的运作机制以及需要的信息存不存在。实在卡不出来就对拍。
没思路别烧烤,先打 BF。
调不出问题,直接重构代码。
常数大得离奇,直接重构代码。
要靠分析,不要靠记忆。
考虑区间离线:挂在一个端点上面。
卡时间减少取模,卡空间关闭 long long(见上)。
学会优化自己的过程,学会充分利用题目的性质。
牢记正难则反。例如,在沿着题面思路思考出现瓶颈时考虑能把维护的东西改为枚举并维护另一个信息。
空间能多开几位就多开,防止被卡。
简单题目一定要做熟练。
有环考虑断为链。
如果点和点有传递关系,考虑连边建模。
实现生成函数和数学题的时候一定不要写错公式,注意符号和变量。
模数非素数的除法在分母一定时可以先把模数乘上分母,除完后用原模数取模。
想不出后续过程了,列举下出现的新问题。
设计过程时注意时效性,可以在更新前后加入状态。
常见推式子技巧
\(\sum_{i=0}^{n} iC(x=i)=\sum_{i=0}^n C(x \ge i)\)
当心被 Corner Case 做局
很多时候(尤其是贪心和数学题)我们会被一些推式子的边角细节当「数据删除」整。比如是否取等,上下取整之类的。
MX-P110120(INF 遍):
卡点:考场没有想到贪心,打出了三十分的暴力。然后被 Corner Case 整了一下午加一晚上。
首先加最小值是优的,我们考虑先排序。
如果只是要求加 \(K\) 个数,那么我们直接维护个双指针,算一算拓展到答案的时候应该增加的面积即可。
但是这里要求加不同的数。发现给同一个数加两遍使得其不会被吃掉不如把两个数加一遍,因为只要多加一就不会被吃掉。
于是这个不同的限制就不存在了。
设 \(s_i\) 表示前缀和。
开一个指针 \(i\) 维护当前 \(k\) 下可以存活的最高的值所对应的最右边的下标。也就是说最左边的点可以扩展到这个值而不会出现和当前答案相等和其他以至于被判负的现象。
首先我们要加 \(k\) 个不同的数,也就是说我们在 \(i\) 之前的数的个数至少是 \(k\),所以 \(i>k\)。不满足的话就直接加到满足。
其次,由于可能出现加不满而在此前就直接判负了,我们考虑直接对于每个 v[i+1]*i-s[i]<=k*v[i+1] 的左边加一个 i(只改这么多个),得到 v[i+1]*i-s[i]+i<=k*v[i+1],就是正常的判负。尽管出题人也不太能对此进行合理解释,只是说是因为人操作在判定前,所以是「严格大于」。但是这里时间应该是相等的。反正这里得到的效果是这样的。
..#
.##
###
对于上面这个情形,就是 1 2 3。如果不增加 i,那么一个一个加的时候把整个填满和最大值一样都是三步,但是这样做在第二步就会判负。所以我们考虑增加 \(i\)。这样如果增加本行不会判负就无论增样也不会在前面被判负了,取等也没有任何问题。
出题人说这种语法细节问题只能自己理解。
最后应该满足 ans*i-s[i]+i <= k*ans 也就是 ans=(s[i]-i)/(i-k)。
在处理取等的 Corner 的时候给所有 \(a_i\) 加一个 \(i\) 有时候是有用的,这样可以把非严格变成严格。
最后自己手做了一遍。不困在这里。
也许用供需关系的一个直线和一个分段函数的交点计算可以避免那么多 Corner Case。
枚举·模拟·容斥
枚举
枚举顺序很重要
【调和级数】的 Trick:可以把枚举变成 \(O(n \log n)\) 的。
CF980D(三遍):考虑两两的积是完全平方数等价于什么。首先正负要相同,其次次数为奇数的质因子要相同。HASH+离散化之后变成区间数颜色的问题。
卡点:枚举顺序错误,枚举了颜色的集合来计算的。事实上只需要枚举区间并记录颜色是否出现过。
结果还要处理 \(0\) 的问题。
卡点:\(0\) 可以加在任何一个集合里面而不会增加集合的个数。如果当前没有集合就增加到答案是 \(1\) 的里面,但是集合的个数不会增加。
模拟
矩形内点的移动
多轮操作考虑先枚举前面几轮后加速
很多时候我们选择简化操作序列来达到减少模拟轮数的效果。
CF611F(四遍):
卡点:开始没有想到把一整个矩形平移处理,后来去计算每个点的被删除时间,再后来没有简化轮数。
神人出题人出一个大模拟。
对于矩形内每个点的移动,我们直接把整个矩形平移,每一步就记录当前还存在的格子数量和被删除的行列以及总的被删除的行列数量。
由题意得每次删除完后图上还是只有一个矩形的联通块。并且我们可以发现在第三轮之后所有删除的边的情况(删除上下左右底边)的操作和第二轮是一致的。
我们暴力模拟第一、二轮,并记录第二轮有哪些删除操作。由于删除上下底边是等价的,左右底边也是(减少行数或是列数),我们只记录在第二轮操作时每一步是减少行(甲种操作)还是列(乙种操作)或者都不减少(丙种操作)。
我们发现,对于每一个甲乙操作或者放在数列开头的丙操作,其后面如果加了若干个丙操作则这些丙操作的贡献和这个操作相同,并且此时行列的数量是不变的。考虑对于每个这种操作,记录其后面的丙操作的个数以及这个操作的种类。遍历时遍历简化的操作序列。由于只有开头可能有一个丙,所以大多数(几乎所有,因为每一轮丙操作个数 \(O(1)\))遍历操作都会删除行或者列,所以总的复杂度降低到 \(O(n+h+w)\),即可通过。
容斥
在涉及集合的枚举和计数的时候我们经常使用到容斥。
二项式反演
CF1228E(两遍):卡点:考虑 DP,结果重复计数。
考虑一行的合法数量是 \(k^n - (k-1)^n\),后面那个是不准选一的方案数。
则满足所有行的合法数是 \((k^n - (k-1)^n)^n\)。
但是这样列不一定合法。我们对于哪些列不合法进行容斥。设钦定 \(i\) 列不合法,则一行的方案数是 \((k-1)^ik^{n-i}-(k-1)^n\)。
于是 \(Ans=\sum_{i=0}^n (-1)^i \binom{n}{i} ((k-1)^ik^{n-i}-(k-1)^n)^n\)。
U608634(两遍):卡点:不会更加普遍的对于行和列直接容斥的方法。
我们按照值域从小往大考虑限制 \(K\)(设选取的是 \([1,K] \cap \Z\)),每次考虑剩余 \(R\) 行 \(C\) 列,每次钦定 \(i\) 行 \(j\) 列不合法,则 \(ans_K = \sum_{i=0}^R \sum_{j=0}^C (-1)^{i+j} \binom{R}{i} \binom{C}{j}(K)^{cov} (K-1)^{tot-cov},Ans=\prod_{K} ans_K\)。\(tot\) 表示当前剩余节点个数,\(cov\) 表示当前钦定不合法行列覆盖的节点个数。
P5123(三遍):
卡点:第一,没有把数对贡献转换到物品对人的贡献上。第二,写 bitset 暴力导致空间卡崩。第三,不知道怎么去除自己对自己的和对后面的贡献。
对于这种对属性计数的问题,一般考虑把人数挂在物品上面,每次对着持有物品属性去计数。
写完 \(O(N^2)\) 枚举之后,第一遍写了一个 bitset,直接对于持有每个物品的人取并,剩下一个都没有的就是合法的人。这样同一个数对会在两个人处算两次,答案减半。这样空间复杂度达到 \(O(\frac{N^2}{\omega})\),会爆炸。
那么只能老实用容斥了。设 \(F(x)\) 表示钦定有 \(x\) 个元素相同的人数。考虑时效性原则,每一次计数只记录自己和前面的贡献。那么总的合法数对个数就是 \(F(0)-F(1)+F(2)-F(3)+F(4)-F(5)\),此时由于只计算和前面的,\(F(0)=i-1\)。直接枚举子集记录贡献即可。由于集合具有无序性,我们先从小到大排序以便于比较。
前缀和·差分
有的时候所谓“数据结构”操作是只和操作次数有关的,直接差分操作次数即可。
AT_abc419_d(一遍):区间交换,应该是不好维护的。考虑交换两次等于没有交换,并且每个节点是独立交换的,所以直接记录操作次数即可。
分治算法·CDQ
分治算法一般是不管其他的点对,只是满足跨越分界线和有一个点在分界线上,包括新增的的点对,再来缩小分治范围。
一般是 solve(L,R) 的形式,也就是给定分治范围。
分治与构造
在构造点数上限是 \(O(n \log n)\) 时使用。
CF97B(三遍):
卡点:第一,对分治理解得不够透彻,没有形式化地把分治处理成分治范围。第二,分治范围有问题,应该是排序之后的数组元素下标范围而不是坐标范围,不然分治加二分复杂度有问题。
现在知道分治构造了。我们首先把点按照横纵坐标为第一第二关键字排序去重,之后按照点的编号分治。由于新增点对一定合法,我们直接枚举当前范围 \([L,R]\),之后把每个点的纵坐标向 \(mid\) 上面投影得到 \((mid,y)\)。之后再去分治到 \([L,mid-1],[mid+1,R]\)。如果为空或者只有一行就结束。由于范围每次缩小一半,则总点数和复杂度都是 \(T(n)=2T(\frac{n}{2})+O(n)=O(n \log n)\)。记得排序去重。
后续: 在遇到这种题目的时候考虑能不能有一个合理的分治范围,并且能不能把答案投影到某个分界线上,或者答案和分界线有什么关系,然后分治。
贪心·结论·构造·博弈论
分类讨论
有的时候题目很没良心,或者状态种类数很少的时候考虑直接去分类讨论。
网格图坐标还是直角坐标系
这两个坐标是不一样的,网格图以左上角为起始坐标,而直角坐标系是左下角。
CF1428D(三遍):
卡点:第一遍看这个题是从列上面怎么去分布点的角度考虑的,没有什么头绪。实现时混淆了网格图和坐标系。
其实并非按照列的角度考虑,而是按照行。
由于 \(a_i \le 3\),我们直接按照 \(a_i\) 的种类来分类讨论。对于 \(a_i=0\) 直接忽略。对于 \(a_i=1\),新开一行即可。对于 \(a_i=2\),因为当前行最多两个节点,本身的列上面要一个节点,所以不能新建点了,考虑运动轨迹是走到某一列直接从下面出来,而下面保证疏通的就是 \(a_i=1\) 的节点。我们找一个这种节点即可。对于 \(a_i=3\) 的节点,我们新开一行,找一个下面有障碍物的列(\(a_i \ne 0\)),直接走当前行的这一列和选定的列即可。由于有 \(n\) 行,我们新增行是没有限制的。如果找不到匹配点就无解。此时由于能匹配的点不存在行相同的(相同的已经匹配完了),这里不需要考虑最左边限制。似乎已经做完了。由于我们要保存 \(1\) 这个资源,我们在 \(a_i=3\) 的时候优先从 \(a_i\) 更大且此前出现过的点开始匹配。
后续: 不要直接排除分类讨论。
特别专题:一些和冒泡排序有关的结论
P9596(做过,重新做卡死忘记结论):卡点:不记得冒泡排序轮数是什么了。也忘记了偏序换全局的技巧。
邻项交换的轮数:每一次邻项交换可以消除一个逆序对,所以个数为逆序对的总数。
冒泡排序扫描整个序列的轮数:考虑对于一个数来说,每一轮可以且仅可以把一个大于它的数从它的前面转移到它的后面。所以轮数就是以每一个点为右端点的逆序对的个数的最大值。
逆序对个数为 \(i-\) 偏序个数。首先为了让权值和点实现一一对应,我们首先带编号进行离散化,这样保留了偏序关系,并且每个节点互不同,这样实现了一一对应,那么我们考虑一个点会对后面的节点怎样造成贡献。我们把偏序改为所有数当中不超过 \(a_i\) 的值的个数。考虑泛化条件的正确性。事实上,如果存在两个数相同,那么更大而更前的偏序个数会较后面一个为多,则答案并不影响。于是我们考虑离散化之后剩下了 \(tot\) 个数,则每次给 \(a_{pos}\) 加上 \(pos\) 而给 \([a_{pos},tot]\) 减一。求区间最大值。
也就是维护所有数当中不超过 \(a_i\) 的值的个数,用线段树维护。
结论
CF2140A(两遍):卡点:没有思考循环移位的本质:把前缀的 \(1\) 当中错位的 \(0\) 交换回来。 所以答案就是错位个数。
CF2140B(一遍):考虑把拼装的部分写作一般的式子,之后减去一个 \(x+y\) 左边就只有 \(x\) 了。答案是 \(10^9-1-x\)。
CF2139A(一遍):如果相等就是零,如果一个数是另一个的倍数就是一,否则就是二。
CF2139B(一遍):从大到小排序,最大的全选,之后每过一个少取一个。
CF2139C(一遍):从最后一个一开始每往高一位就考虑如果是 \(0\) 就是操作一,是 \(1\) 就是操作二。倒着输出。
CF2139D(三遍):卡点:没有注意三个数交换是怎么换掉逆序对的。 如果存在一个三个数的子序列满足第一个数比第二个大,第二个比第三个大就不行,否则可以。找左边第一个大于自己的和右边第一个小于自己的,而这样做就变成是否包含某个区间,这样记录端点,以及端点最右边最近的点的区间。
在算法竞赛中有一种特殊的题型,它们一般没有复杂的算法和实现,只有一个结论。能否知道结论是能否解决问题的关键。
CF1266E(三遍):
卡点:应该从每个节点的个数变化和变化是否会对于当前节点做贡献而不是最短路考虑。以及是先删除后插入,对于题目的理解能力要加强。此外,不要维护每个点的答案,而是维护总的。
考虑每个点答案的变化。注意到,每个卡牌积累时,我们可以考虑有可以拿的卡牌就拿,那么此时每个位置需要积累的时间是 \(a_i-cnt_i\),其中后者是所有优惠里面优惠对象 \(u\) 是当前卡牌 \(i\) 的,因为每次只能增加一张卡牌。所以 \(Ans \ge \sum_{i=1}^n (a_i-cnt_i)\)。
考虑不怎么严谨(结论很多时候不需要严谨证明,比如找到范围就猜是最小值)地证明答案就是 \(\sum_{i=1}^n (a_i-cnt_i)\)。对于某个卡牌 \(x\),目前已经有了 \(u(u<a(x))\) 张。
如果 \(u=a(x)-1\),那么再来拿一张或者被送到一张之后直接到 \(a(x)\) 了,但是所有优惠都是满足 \(t<a(x)\) 的,所以没有新的优惠。
如果 \(u < a(x)-1\),那么加了一张之后会有新的贡献,如果还是贡献到 \(x\),那么 \(x\) 就一直增加,和第一种情况相同。否则加到另一个卡牌 \(y\) 上面,对本身没有影响。
所以优惠不会造成本身的次数更大,也自然不会造成总的次数更大。
题面不太像中文。考虑读三遍题面,应该是先删除(无论 \(u\) 是否是 \(0\)),后插入。
考虑到维护的时候减少分类,我们不维护每个点对答案的贡献 \(tmp\),而是维护 \(cnt\),之后根据 \(cnt\) 计算答案,可以减少 Corner 问题。
归纳法找结论
【归纳法万岁】的后续。在很多结论题里面我们不需要对结论进行严格证明,而是通过找规律与寻找不变的信息的办法来查找结论。
对序列和找结论
CF1392F(两遍):
卡点:发现和不变以及顺序不影响结果之后没有更加深入地思考是否确定了和和个数则答案就唯一确定。
我们多造一些数据来找结论。发现确实如此。并且一定是形如 \([i,i+1,i+2,...]\) 这样的数组为主体,有可能会有两个相同。
考虑调整法构造。先钦定没有重复点,那么可以 \(O(1)\) 算出 \(h_1\),再根据结论,有 \(h_i=h_{i-1}+1\)。最后可能会多出 \(O(n)\)(不会超过 \(n\),不然 \(h_1\) 得更新)来,考虑从后向前每个减去 \(1\) 即可。
后续:以后找结论的时候多造一些数据。
对差分数组找结论,相邻相同是性质
CF1406D(两遍):
卡点:没有发现样例里面有的数列邻项相同以及三个序列差量之间的关系导致束手无策。
非常神经的结论题。相邻项相同,还有区间加减(可能是线段树,也可能是差分),考虑从差量入手。我们发现如果 \(a_i<a_{i-1}\)(令 \(\Delta = |a_i-a_{i-1}|\)),那么 \(c_i = c_{i-1}-\Delta,b_i=b_{i-1}\)。如果 \(a_i > a_{i-1}\),那么 \(b_i=b_{i-1}+\Delta,c_i=c_{i-1}\)。
那么我们既要区间加减又要维护差量,考虑差分。
根据上面的结论,设 \(b_i=b_1+k1,a_1=b_1+c_1\),那么答案就是 \(\max(b_n,c_1)=\max(b_1+k1,a_1-b_1)\)。我们要最小化这个值。
小 Trick:最小化两个和一定的数的最大值答案就是取其和的一半的上取整。最大化最小值就是下取整。
答案就是 \(\lceil \frac{a_1+k1}{2} \rceil\),而 \(k1=\sum_{i=2}^n \max(d_i,0),d_i=a_i-a_{i-1}(i \ge 2),d_1=a_1\),则我们只需要每次减去贡献,修改贡献再增加贡献即可。
BFS 转移具有最优性
正图有后效性的时候考虑建反图
建反图是个常见的 Trick,例如 CF625E。
CF1407E(两遍):
卡点:应该从边的连通性考虑,而不是从点。此外我们是增量构造,而不是初始就设定一个全局方案。
让我们从边的角度考虑。一条边可以走当且仅当起点的颜色和边相同。也就是说,一条边能否被访问以及访问时间都取决于起始点。如果我们正向遍历,一个点可能会有多个出点所以并不好考虑。我们维护一个点被连通的情况。从 \(n\) 开始在反图上面遍历。
假设当前决策到 \(x\),那么如果 \(x\) 的 \(0\) 出边的最短路的最小值大于 \(1\) 出边的(就是到 \(n\) 的),那么我们就把 \(x\) 染色成为 \(0\)。
考虑到这里涉及权值为 \(1\) 的最短路,考虑 BFS 拓展。那么当 \(x\) 第一次被 \(u\) 更新到时,\(u\) 是 \(x\) 的最优转移,但是我们希望最短路径尽可能长,也就不希望最优转移连通,所以我们把这样的 \(x\) 设置为和 边权 \(w\)(决定一个点是否连通的是到达它的边)相反的权值。反之就没有额外限制,直接走和边同色的点转移即可。最后如果可以到达 \(1\) 就有答案。没有访问到的点全部设置成为和 \(n\) 一样的颜色即可。当然,\(n\) 初始设置为 \(0\)(黑色)。
博弈论
不是博弈论形式的 DP,而是纯粹按照博弈论形式的博弈论。
SG(公平组合游戏好帮手)
给每个状态一种编号,则我们令必败态的 SG 值为 \(0\),并对于单个游戏的每一个状态,其 SG 值为其所有后续状态的 SG 值的 MEX。现在一个游戏可以拆分成若干个相互独立的子游戏。则这个游戏的总 SG 值为其所有独立子游戏的 SG 值的异或和。如果总 SG 值为 \(0\) 则必败,否则必胜。(此处所有胜败都是先手)
例题
CF850C(两遍):
卡点:不会分析 SG 函数,直接写的 Nim 的模板。
发现对于每个质因子的子游戏是相互独立的,直接拆分为这些子游戏。
考虑一个子游戏(其质因子为 \(p\) )的 SG 值怎么求。发现如果我们取一个 \(p^k(k \ge 1)\) 的时候就一定会把所有能取到 \(k\) 的全都取光,所以我们并不关心每个 \(k\) 有多少个。我们只需要关心每个 \(k\) 是否在局面中出现。则我们状态压缩(\(k\) 最大不会超过 \(32\))。
考虑一个状态的后续状态是什么。如果本状态为空或者只剩余 \(0\) 次方(就是 \(1\))就是必败态,SG 值是 \(0\)。考虑拿一个 \(p^i\)。那么此时 \(0 \sim i-1\) 位因为无法选中而不移动,\(i\) 位以上的位置会因为取了 \(i\) 而右移 \(i\) 位(出现位置是原地搬迁的,可以抽象成 \(p\) 位置消失而在 \(p-i\) 位置出现)。那么如果原有状态是 \(S\),则对于每个 \(i\),其后续状态是 ((S>>i)|(S&((1ll<<(i))-1)))。当然,如果 \(i\) 位置已经比最高位还高,则后续状态还是 \(S\)。我们不考虑这种状态。那么只需要搜索后续状态并存储其 SG 值,暴力求 MEX 即可。注意记忆化。并且由于本题质因子可能很大,我们直接用 map。
CF603C(一遍):第一次一遍过博弈论题。首先对于公平组合游戏考虑暴力找 SG 看看有什么性质。不难发现每个石堆相互独立,所以后续状态为 \(SG(\frac{x}{2})\) 的 \(k\) 次异或和。这只和 \(k\) 的奇偶性有关系,也就是说 \(k\) 的奇偶性和 SG 数列唯一相关。我们对于两种数列按照 \(8\) 个一排的顺序排开就很好找规律了。\(k\) 为偶数时规律是简单的,奇数时对于某些模数而言是简单的,则我们可以导入到一个简单的模数上。
打表节省 SG 时间
CF317D(三遍):
卡点:第一,SG 函数分析有误,应该和删除后剩下的指数的状态有关,例如1 2 8 32,而不仅仅是和个数有关。第二,没有发现原本的状态数不超过 \(30\) 个,可以直接打表储存 SG 值。
发现不是 \(x^k\) 的数的幂所构成的局面是相互独立的。考虑求 SG 函数。这里需要考虑状态问题,每一次某个倍数的状态(如果存在)全部去掉。由于指数个数不超过 \(30\),我们状态压缩。由于二进制下指数的状态必然是 111...10 的形式,则最多三十个,考虑打表储存SG,剩下若干个个数为 \(1\) 的局面我们直接记录个数并异或 \(1\),这样就只有根号的复杂度了。
后续:在求 SG 函数的时候考虑状态压缩,可能求法和状态而不只是个数有关。在状态数很少的时候 SG 函数直接打表储存。
博弈论:公式做题就是快
碰到一个博弈论的题目先考虑 SG 函数再去考虑 DP,如果仅仅是分胜负而不是贪心什么的。
CF87C(一遍):考虑先算算 SG 函数。发现我们可以在合理的时间内求完 \(n\) 的 SG 函数(最多每个点算一次嘛)。所以我们直接把 \(n\) 的后续状态拿出来,找一个分的堆数最小的后续状态是必败态的留给后手(有点没良心)即可。
博弈论 vs 博弈论 DP/贪心
都是两个人玩游戏。
但是问你先后手谁胜利的是 SG 函数题,而需要最大/最小化某个值的一般是 DP。贪心可以视作路径简化的贪心。
贪心,真的是路径简化的 DP 吗?
仅仅考虑相邻状态(区间加一最小计数)
一个节点能被区间加操作覆盖等价于连续段可以向右边扩展一个节点,且连续段个数不变。
P1969(一遍):古老的题目。考虑每个积木不能被此前的连续段覆盖当且仅当其左边是空格,而左边是空格等价于 \(a_i>a_{i-1}\),此时有空格的个数就是 \(a_i-a_{i-1}\),求和即可。
连通次大值转次大路径
很遗憾,有的时候贪心并不能简单视作是 DP 的简化,甚至可以说是单纯的在题意之上进行推导所得到的东西,就是你按照本能就可以发现的。这也是有的暴力 DP 为什么会没有前途的原因之一。
ABC413F(三遍):
卡点:第一遍考虑到博弈论的状态应该倒着,但是想到简单 DP 上去了,结果找不到合适的策略。第二遍看完解析,找相邻节点的次大值就直接暴力算结果炸了。
事实上长得像博弈论 DP 的题目,如果策略比较简单就直接是贪心的策略了。由于正常走路有后效性,考虑倒着决策,以所有终点距离为 \(0\),求每个点到最近的终点的距离。
由于先手起到一个拦截者的角色,我们考虑先手拦截的策略。先从特殊情况入手。我们希望构造一个先手无法拦截的情况。发现四连通的点里面总是有三个是可以使用的,也就是说如果存在两个以上的终点和节点相邻则不可能被拦截,因为最坏情况也只能拦截一个终点。
更进一步,考虑相邻点对当前节点路径长度的影响。由于决策在当时的状态下尚未发生,也就是倒推时间轴,起到了一个没有后效性的效果。所以走当前的连通点的最小值一定就是当前的最短路。而对于先手来说走最短路必然不优,因此我们发现先手只会拦截最短路,反之就会直接走最短路了。因此我们选择走次短路。也就是连通节点的次小值。
但是这样一个点可能会要多次更新。但是我们希望一次性求完。求和最短路有关的东西时我们使用 BFS。而由于 BFS 具有此前节点的最优性,则一个点被第二次到达就必然是次短路,也就是第二次到达时用的第二次到达的邻接点的距离加一。由于第二次到达之后第三次访问就不优了,考虑第二次到达之后就不再访问。与此同时,我们知道终点是不会再次访问的,所以我们直接把终点的初始的遍历次数设置为二。后续在下面。
贪心本质是挖掘题目信息来得到一些有利于思考解法和优化解法的信息和结论,而不仅仅是对搜索和 DP 的减少状态。
邻项交换排序与逆序对
给你一个原串和目标串,你可以通过原串上交换相邻字符来把原串变换成目标串,求最小操作次数。
首先如果两个串的各个字符的个数不对应相等,就一定不可能转换成功,因为转换不改变字符。
看到“邻项交换排序”想到一个结论:
通过邻项交换来把一个排列排升序的最小操作次数就是排列的逆序对个数。
有了这个结论,我们考虑把两个串转换成为排列。我们把目标串设置为升序排列。由于同一个字符是不需要排序的,也就是直接是升序,我们对于原串的每个字符,直接按照目标串里它的出现次数得到的编号(例如原串和目标串里面第 \(3\) 个出现的 a 的编号相同)赋值。之后得到一个排列升序的最小操作次数,就是逆序对的个数了。
CF1430E/P3531(一遍):就是上面的解法。
堆贪心——不反悔但记录变化量
有的时候简单的排序贪心并不能导致反悔操作,考虑一种不反悔但是记录当前状态变化量和变化量的变化量,在弹出当前最优的状态后更新全局解和变化量,再放回去的情况。
P2707(两遍):
卡点:一开始写的 \(O(nk^2)\) 的暴力 DP,结果常数小直接拿了 \(60\) 分。
考虑如果我们每次给某个点的票价增加 \(1\),这个操作我们可以执行 \(k\) 次。
取值总和为 \(k\) 可以转换为每次 \(+1\) 的操作总数为 \(k\)。
由于当前变化量变成负数了就肯定是不优的,我们只需要考虑当前变化量是正的情况。如果初始票价为负数,那么贡献就一直是零,也就没有必要加入状态了。所以我们不再考虑那个 \(\max\)。
则设当前节点参数是 \((a,b)\),票价是 \(x\),则总的收益是 \(v_1=ax-bx^2\)。则我们执行完操作后的收益是 \(v_2=a(x+1)-b(x+1)^2=v_1+a-b-2bx\)。所以变化量就是 \(a-b-2bx\)。由于票价是 \(0\) 就不用更新了,我们设票价初始是 \(1\),也就是初始变化量是 \(a-b\)。我们每次只增加变化量就等价于我们减去原贡献再加上现在的贡献。变化量还是和当前票价有关系,考虑求二阶变化量 \(-2b\)。这样我们只需要记录变化量的变化量来在 \(k\) 次拓展里面的每一次都增加变化量之后计算新变化量即可。注意,我们在变化量变成负数的时候就不再更新(变化量单调减,因为二次函数确实是凸的)。简单但常用的算法。
后续:在需要反悔而变化量很好算的时候考虑直接维护变化量。
反悔贪心 —— 退流?
CF865D:考虑反悔贪心是有一些流程的。
首先我们考虑一个比较直球的贪心,就是说一旦收益大于 \(0\) 就直接买。但是如果后面的数开价更高?
考虑把这个贪心转换成较为一般的形式使得我们可以通过给答案增加一些确定形式的公式来自动得出答案,类似于网络流(事实上有模拟费用流这种反悔贪心)。这需要我们引入一些一般性的中间变量,来得到贡献答案的一些一般性的形式。对于某只股票,设 \(A_{out}\) 表示其被卖出时的价格,\(A_{in}\) 表示其被买入时的价格。则有 \((A_{out}-A_{in})=(A_{out}-A_i)+(A_i-A_{in})\),其中 \(A_i\) 是任意的一只股票价格。
这启示我们可以把答案转换成若干个 \(A_i-A_j\) 的和,并且我们希望 \(A_j\) 更小。考虑一步到位,直接用小根堆存储现在可以作为买入价格的股票。一般来说每一个买入价格只能使用一次,但是注意到作为每一次中间变量的股票价格虽然在数学意义上同时买进一次卖出一次,但是事实上在决策时这两个操作并不会发生(被消除),其并非被实际上购买,所以我们额外存储一个中间变量的副本来进行反悔操作,不占用作为股票的这个变量的值。所以作为中间变量要第二次入队。
CF1852C(三遍):
卡点:想到区间 DP(填数)上了,但是本题是区间加一求最终结果,应该是「P1969 积木大赛」的模型。之后反悔贪心思路太复杂而没看明白。
首先看到这个题我们感觉十分亲切。首先我们把 \(K\) 变成 \(0\),求所有点加一操作完之后全部模 \(K\) 为 \(0\)。如果没有取模(就是 \(0\))这就是积木大赛。根据结论,如果我们知道每个点具体的操作次数 \(A_i\),则令 \(D_i=A_i-A_{i-1}\),答案就是 \(\sum_{i=1}^n \max(D_i,0)\)。
考虑怎么刻画(用另一种方法描述)取模。取模等价于区间可以加减若干次 \(K\)。假设我们对区间 \([l,r]\) 加 \(K\),则 \(D_l \gets D_l+K,D_{r+1} \gets D_{r+1}-K\)。我们希望小于等于 \(0\) 的 \(D_i\) 尽可能多,所以我们只对负数 \(+K\) 而对正数 \(-K\)。但是正数不一定会贡献(因为可以减去 \(K\)),于是我们维护一个堆来存储当前可能的贡献。假设当前点 \(i\)。如果 \(D_i<0\),则很显然因为 \(D_i+K>0\),则只有 \(D_i+K>0\),为可能贡献。而如果 \(D_i>0\),则我们可以从当前堆里面拿一个点作为可能的贡献,而由于 \(D_i-K<0\),我们不用对当前答案贡献。当然我们可以什么都不做,所以在贡献之前要把贡献堆里面加一个 \(D_i\)。假设后面有一个正的 \(D_k\) 抽到 \(D_i\) 了也没有关系,因为此时前面 \(D_i\) 如果是最小就直接被用掉了,而如果是这样的话前面的 \(D_i\) 就不会对后面做贡献(因为正值加 \(K\) 必然不优),也就不会出现 \(D_k\) 没有加贡献的情况。
P11301(一遍):首先给 \(K\) 加一。注意到操作次数是确定的,就是 \((B_i-A_i) \bmod K\)。然后就是上面那个题目。
按照余数分组
CF571B:最小化 \(\sum_{i=1}^{n-k} |A_i-A_{i+k}|\)。
考虑按照对 k 取模的大小分组。如果每一组都有从小到大排序,则每一组对答案的贡献就是最大跨度。有 \((k-n \mod k)\) 组是 \(\lfloor \frac{n}{k}\rfloor\) 个(甲种),其余组(乙种)个数加一。数组从小到大排序。发现个数选取没法贪心,考虑设 \(DP(i,j)\) 为甲种 \(i\) 个,乙种 \(j\) 个的最小值。直接转移当前选哪一种即可。
残余局面博弈(见「博弈论 DP」)
CF731E:选一个长度大于 1 的前缀,计算和 S,用 S 替换这个前缀,当前人物得 S 分。两人都想要最大化自己的分数-对手的分数,求分数差值。先转为前缀和。正着不好维护,考虑钦定先手第一个选的位置,这样后手最大化的策略是确定的(后手-不计开始的值的先手 是原来的状态,先手取值是定值),所以倒着转移即可。注意长度要大于1,所以先手至少取到 2 个。
环状路径过定点
P3320:从一个点出发走遍所有关键节点并回到该点的最小距离。考虑+必然走一个环,并且三角形的两边之和大于第三边,所以从关键点出发必然最优。考虑从关键点出发按照dfn排序绕环。用 set 维护点的插入和删除即可。注意 set 和其他 STL 一样是左闭右开的,不是开区间。
异或,加法,最短路
CF2119A:最短路是可做的,但是考虑更简捷的办法。
发现异或 \(1\) 对奇数等于减一,对偶数等于加一。做减法还要额外花钱,这是不优的。所以我们只在偶数的时候才使用异或作为加法费用更低的替代品(如果费用真的更低)。所以可以 \(O(1)\) 计算。
与和异或都是零
CF2119C:长度为奇数很容易:全部填 \(L\)。考虑偶数的情况。我们仍然希望大多数在前面的点填 \(L\)。考虑到如果全部这么做,异或和为 \(0\),与和为 \(L\)。我们构造一些数使得在异或和保持不变的前提下与和变为 \(0\)。
只要一个数就可以把与和变成零,两个相同的数就可以把异或和保持零。由于这个数不可能比 \(L\) 还小,所以 \(L\) 最高位下面为 \(0\) 的位置变成 \(1\) 还删掉最高位是行不通的。我们就构造一个方案:找到最小的 $i \in \Z $ 满足 \(2^i >L\)。然后用这个数去代替末尾的两个 \(L\)。不用一半,两个就好。
如果这还超出值域就不存在了。
归纳法万岁
构造和角色的贪心策略有时候也是由特殊到一般来推断的。
CF388C(一遍):考虑单个牌堆是怎么取值的。
如果只有一个元素,就归 A 了。
如果有两个元素,前一个给 A,后一个给 B。
如果有三个元素,前两个给 A,后一个给 B。
如果有多个元素,前一半给 A,后一半给 B。
考虑多个牌堆的作用是什么。
A,B 都可以首先取走一个堆的起始牌来充当那个牌堆的「先手」。结合样例,我们发现 A 和 B 都是不会容忍对方取走超过牌堆大小一半的牌的。所以只有牌堆大小为奇数者的中间那张牌的所属是需要决策的。
由于 A 先手,我们直接存储所有中间的牌的值,从大到小排序,按照 ABABAB... 的顺序取牌即可。
调整法构造
调整法构造的思想并非只应用在构造题。我们首先钦定一些点的构造,再构造一些再构造合法解,最后考虑优化。
CF3D(两遍):考虑一种比 DP 更快的办法。对于括号序列,将其转换为一个常见的数学模型:开一个计数器,左括号加一,右括号减一,要求计数器在任何时候不小于 \(0\),并且在遍历完串后等于 \(0\)。
首先考虑怎么构造合法解。对于左右括号直接计数算进答案数组。如果碰到问号,先钦定其为右括号。如果计数器变成负数了,说明应该补充左括号。从前面的问号(变成的右括号)中选取一些变成左括号,把计数器 \(+2\)(单点贡献从 \(-1\) 变成 \(+1\))。如果计数器不是负数了就不用再变了。最后特判这样构造后会不会还是负数,会就无解。
再来考虑费用问题,我们需要最小化选取括号的费用。由于此前我们全都是右括号,我们只需要最小化更新左括号所产生的新费用。对于一个节点,从右括号变化到左括号的费用是 \(C_i=A_i-B_i\)。我们希望选取 \(C_i\) 最小的括号来更新。用权值比较开一个小根堆来记录问号的下标即可。
CF1251D(一遍):对于中位数,我们一般二分看是否存在一半以上的数能否大于等于 \(mid\),如能则中位数可以大于等于 \(mid\)。
二分一个中位数的取值 \(mid\),看看我们怎么构造一个方案使得 \(mid\) 合法。自然地,我们希望让工人的总工资最小。考虑先让所有能够取到 \(mid\) 的工人都先取 \(mid\),剩下的取 \(l_i\)。如果此时的大于等于 \(mid\) 的数少于 \(tar=\lfloor \frac{n}{2} \rfloor +1\) 就直接判不合法。
考虑现在有 \(tot\) 个,那么我们取 \(tot-tar\) 个之前取到 \(mid\) 的改成取到 \(l_i\)。那么我们只需要取 \(tot-tar\) 个 \(l_i-mid\) 最小的增加费用即可。小根堆维护。最后如果总工资不超过 \(s\) 就是合法的。
边权下界的调整构造
CF715B(两遍):卡点:钦定 \(0\) 边的权值钦定错误,没有想到第二轮 Dij 是调整法。
对于这道题,我们先考虑把零边的权值设置为下界,也就是 \(1\),作为理论上能够达到的最小的距离。这样,我们直接跑出第一轮 Dij,如果最小的距离都会比 \(L\) 大就直接判无解。记此时 \(s\) 到点 \(u\) 的距离为 \(dis(0,u)\)。
接下来应该考虑怎么修正边权。设 \(rst=L-dis(0,t)\)。我们接着跑第二轮 Dij。记此时 \(s\) 到点 \(u\) 的距离为 \(dis(1,u)\)。对于一个零边 \((u,v,w)\),如果 \(dis(1,u)+w \lt dis(0,v)+rst\),那么我们就把 \(w\) 设置为 \(dis(0,v)+rst-dis(1,u)\)。这样 \(dis(1,v) = dis(0,v)+rst\)。考虑为什么这么做。也就是说, \(dis(1,t)=dis(0,t)+rst=L\)。这样构造就是合法的了。那么,我们就会发现,可能有大量边是不能修改的导致 \(dis(1,t) \ne L\),此时直接判无解。输出原图对应的修改权值的图即可。
启示: 在构造的时候,我们除了考虑首先钦定一个值之外,还应该考虑钦定下界还是上界。除此之外,在想不到调整使得末尾达到某个条件的策略的时候,我们考虑末尾要达到条件,首先应该调整什么,在反推调整过程。
调整法+正难则反
P9525(两遍):
卡点:不是 DP 是构造,如果单纯对数对统计就只能暴力计数,因为本题没法「火箭滑板」。
正着不能枚举了考虑正难则反。我们看看不合法意味着什么。这意味着有一个人存在两个值是当前最大的。
我们希望当前三个参数全部最大。我们记录每个参数和对应编号,并用三个堆维护当前最大参数和编号。设当前三个参数 \(A,B,C\) 对应的最大点编号是 \(i,j,k\)。对于这三个点,如果当前的点存在两个参数和另外两个点的权值相等(也就是最大),那么如果这个点不弹出,则后续点的参数也没有当前点大。所以当前点是不能出现在答案里的。考虑标记不能出现在答案内的点。如果当前三个点都可以使用就当然不会重复也就没有相同点了。那么我们直接输出答案。清除当前三个参数堆里面已经不能使用的点。注意当前堆顶不能直接删除,因为可能无法决策。如果直接删空了就无解。
后续: 不要想太多,考虑从「点」本身而不是「数对」入手。以及考虑最大点不合法是为什么,以及怎么调整。
扣费转加费
ZR-3321(两遍):卡点:首先想的是决策与否对答案哪个优,但是假的。
首先我们令扣费是 \(n\),这样可以修改为用操作去增加收益来减少费用。如果 \(x \to x-1\) 则可以花费 \(c_x\) 的费用获得 \(c_x+c_{x-1}\) 的收益(不会删除),则总收益是 \(c_{x-1}\)。于是设 \(dp(i)\) 表示考虑到 \(i\) 的最大总费用。\(dp(i)=\max_{p \in [0,i-2]} dp(p)+c_{i-1}\)。前缀和优化啊。顺带说明 ZR 改题不用 freopen。
联系生活实际
CF1266D(两遍):注意到操作一并不改变债务总量,但是可以改变债权人和债务人。所以说一个债权人可以通过修改关系直接找任何一个债务人,尽管他们并没有直接的债务关系。例如 \(d(a,b)>0,d(c,d)>0\) 时 \(a\) 可以欠 \(d\) 的钱,且此时债务总量可以不变。而我们直到 \(a\) 不用找 \(a\) 借钱,所以这可能和度数有关,而和原图的图论建模一点关系没有。
联系生活实际,在数码账户上,我们只关心我们进出了多少钱,而并不关心这些钱从哪里来,又到了哪里去。考虑直接记录每个人欠债的金额。如果负债为正则为债务人,负债为负则为债权人。由于此时合法方案必然存在,直接双指针补钱即可。
贪心结论,LIS 维护
CF1367F2(做了三遍):数据很大先考虑离散化,并考虑这个操作是让我们移动数据有什么性质。 发现我们可以直接通过改变操作顺序,让值在某一区间前面和后面的直接放在给定的位置。也就是说,最后不会移动的必然是一个值域上连续的不下降的子序列。直接 LIS 维护。
再考虑这道题。简单版本启示我们,一定是考虑一个上升子序列。考虑这个上升子序列有什么要求。我们来几组样例。
A A A A A:对于同一个数是允许的。
A A A+1 A+1 A+1:两个数的时候子序列也是前缀是一个数,后缀是加一。
A A+1 A+1 A+2 A+2:三个数的时候大体上也是这么排列的。
A A+1 A+2 A+1: 但是如果出现这样形式的子序列的话,第四个数是无法插入到第二个数前面和后面的,因为我们已经确定前三个数不动,所以移动不支持插入。因此在非下降的同时还应当保证作为中间的值的数已经全部取完。
那么我们应该维护以 \(a_i\) 结尾的(钦定选了当前值的)全都取同一个数的(无限制),当前数可能没有取完的,和当前的数取完的。记为 \(dp(0,i),dp(1,i),dp(2,i)\)。
我们边转移边维护每个 \(i\) 的值上一次出现的位置 \(pre(a_i)\)。那么:
这个不用解释。就是从上一个同一个值的位置多拿一个。
这个比较 Confusing。考虑怎么思考。
第一个取值:我们没有取完,那么上一个肯定也没有取完。我们从上一个没有取完的开始多取一个。
第二个取值:我们以当前点为取 \(a_i\) 段的开头,则我们不需要考虑此前有没有取同一个值(我们把这种情况放在第一个取值),钦定没有。那么上一个值的取完。
第三个取值:发现作为整个子序列的开头的段是不要求取完所有的这个值的,但是要求取的全都是这个值。所以我们统计上一个段作为开头的情况。
\(l(x),r(x)\) 表示 \(x\) 第一次和最后一次出现的位置,\(sm(x)\) 表示 \(x\) 在整个序列里面的出现次数。
由于我们不可能统计 \(x\) 的取值次数,我们只能从 \(l(x)\) 被选(可能没有取完)开始把 \(sm(x)\) 个全都选了。减一是因为 \(l(x)\) 的时候已经把 \(x\) 选了一次了。答案是 \(N-\max\set{dp}\)。
贪心并非简单的排序
先从没有限制的情况入手
再考虑正难则反/走逆过程
贪心是一个短视的过程,不考虑后续状态是否以当前决策最优,不要想太多
不能排除就一定存在
图论建模是辅助思考的好东西
CF1369E(两遍):一开始想怎么排序,然后就炸了。
考虑如果吃东西没有限制会发生什么。会出现食物剩余量是负数,但是不排除会出现非负数。这也就意味着无论怎么选,这个食物的余量不会是负数的。我们需要的就是这样稳定的信息。考虑从这样的食物入手。
接下来考虑怎么去利用这些食物。我们考虑正难则反,把人吃食物的过程转换为食物总量增加的过程,前提是增加之后食物的总量不小于 \(0\)(当前的人不吃它之后怎么排列它都不会被吃成负数)。我们发现这个构造和拓扑排序很像,直接套用拓扑排序的方案即可。此时一个人吃两个菜,考虑抽象为一个边连接了两个点。只要增加关联的点 \(v\) 即可,同时把人 \(w\) 塞进最终的排列。判合法性同拓扑排序。
由于我们最后是求出了加饭的过程(想想把吃饭画面倒放),我们应该倒着输出。
有单调性不一定是二分,可能是堆。
从样例中找信息。
CF1428E(两遍半,第一遍没冲出正解,半遍是不会证明):砍成整数长的段,看着很像 EKO,于是想到二分答案,发现有单调性,开始想 check。然后就炸了,二分最小长度,那多余步数怎么处理?直接暴力计算/二分套二分?不太现实。
考虑另一个思考方向。我们先钦定所有的萝卜没有劈开,费用为 \(\sum A_i^2\)。我们把所有萝卜扔进堆里,每一次取长度最大的萝卜劈两半(尽可能增加我们扣除的费用,因为 \(2(\frac{a}{2})^2=\frac{a^2}{2}\),所以 \(a\) 越大扣除的越多)来计算扣除的费用。这个想法是错误的。比如 1 3 100 这组数据,按照上面的劈法会劈成 25+25+50 三段,但是 33+33+34 更优。
但是这启发我们可以通过调整法修改费用来减少扣费。样例二启发我们可以通过数学计算 \(O(1)\) 算出把一个固定长的萝卜 \(x\) 砍成 \(y\) 段的费用:(y-(x%y))*(x/y)*(x/y)+(x%y)*((x/y)+1)*((x/y)+1)。
发现砍更多的段的时候新的减少费用更高,就是 \(f(x,y)-f(x,y-1) \le f(x,y+1)-f(x,y)\)。考虑怎么来的。推导出 \(f(x,y+1)+f(x,y-1) \ge 2f(x,y)=f(2x,2y)\)。因为如果是一整个萝卜则可以合并,不会出现多余整数的情况。
那么就不会出现取扣费最大的扣除费用后下一个变得很小的情况,则当前决策取最大的扣费(最小的新增费用)必然最优,直接用堆维护。
从物体对人的贡献角度考虑,答案可能有和选法无关的理论下界。
P4409(两遍):考虑破环为链,让答案为相邻两项的和的最大值。但这样是有问题的。这样做等价于在序列上让相邻的项颜色不同,但是考虑下面的样例。
5
1 1 1 1 1
这样,颜色的形式是形如 ABABA 的,满足序列上相邻两项不同。但是第一个和最后一个相邻,所以这两个也不能相同,与原本的序列矛盾。
考虑从物体对人的贡献角度思考。每种物品最多被分给 \(\frac{n}{2}\) 个人(首先不管上下取整)。那么总体上来说,我们要求的总物品种类数是不变的,所以我们需要让每个物品分到的人数最大化,所以答案的理论下界是 \(\lceil \frac{\sum a_i}{\lfloor\frac{n}{2}\rfloor} \rceil\)。就是说,我们首先钦定每个物品分给 \(\lfloor \frac{n}{2} \rfloor\) 个人,但是这样会有要求剩余,考虑如果有要求剩余就全分另一种颜色,因为我们隔一个人发肯定不会有剩余。所以答案有理论下界,答案不会小于这个下界。
记忆化搜索?记忆化贪心!
P6187(一遍半):像这种在环上行走等距离类的题型,考虑最终的图像会是一些互相独立的环。互为因数就是按照余数分环,通过 \(n=15,k=6\) 这个互相不为因数且并不互质的样例,我们发现只要不互质,最后就是若干等长的环,长度就是 \(\frac{\text{lcm}(n,k)}{k}\)。考虑对于每个环独立计算。如果互质就是一个长度为 \(n\) 的大环。我们先把 \(A\) 升序。
考虑如果只有一个环应该怎么贪心。结合样例 \(k=1\),我们想到这样一个策略:奇数位从小到大,然后接上偶数位从大到小。形如 1 3 5 6 4 2 或 1 3 5 4 2 的排布。这样更大的会在一起,同时小的也会在一起而不会导致大的无法得到更大贡献。
接下来考虑多个环怎么分组。从 \(k=2,3\) 入手。发现就是把 \(A\) 从小到大按照长度分组。
然后就做了第一版。最后两个点 TLE。发现自己是 \(O(nk)\) 的。看了一眼题解发现答案需要记忆化。首先,\(k=0\) 应该直接特判掉。其次,由于互质的数很多,\(k=1\) 的答案也要记忆化。再其次,因为互质而并非因数的数可能有 \(O(n)\) 个,是无法接受的,考虑它们的长度分布有什么规律。发现每个长度可以对应一个因数。因数个数是 \(O(\sqrt n)\) 的,所以长度的数量也是根号,而答案只和每个环的长度有关系,所以我们对长度进行记忆化,时间变成 \(O(nd(n))\) 也就是 \(O(n\sqrt n)\),就可以通过了。
转换题意,构建模型
P6908(两遍):题面和形式化题面都不太像中文,考虑自己重新复述一遍题意。就是说,构造两个栈,栈的底部是原串,你需要加入若干个匹配串,使得栈中上面的元素是下面的元素的子序列。由于不输出原串,两个栈内输出的信息可以为空。
由于一个串子序列的子序列也是这个串的子序列,我们考虑只要让当前匹配串是栈顶部的子序列即可。由于子序列的长度肯定不会大于原串长,考虑首先对串从大到小排序。
由于只能加一个栈的情况不用考虑,我们考虑特殊的情形。
对于一个同时可以加入两个栈的串,我们先把它同时加入两个栈内,并记录这一个串是否在某一个栈内。输出时如果这个串已经被输出了,就两个串都不会让它在内了。
对于一个不能加入的串,考虑可能是串内有重复的元素挤占了它的位置。对于这种情况,我们先找到第一个栈,把里面不能匹配、不为原串且在另一边存在的串删除。如果可以匹配了,就放入这个串。如果还是不能,就恢复第一个栈,在第二个栈内重复这一过程直到可以匹配,若还是不能匹配就直接判定无解。
构造:用组合数增大增长率
CF208H(两遍):构造加八百诚不我欺。首先考虑倍增构造,发现可能出现ababcabc 内前面的 a 和后面的 b 搞在一起导致增加贡献的神秘局面。换构造方式,考虑只用两个字母构造。初始出现一次的方案是 cp,那么每加一个 p 就可以增加 c 个数个贡献。但是增长率太低了。其实我们只需要往匹配串里多塞几个 c 就可以直接高速增加增长率。考虑多来几个数据。 ccp 匹配的时候每加一个 p 贡献 \(cnt(c) \choose 2\)。这大大启发了我们:匹配串有 \(C\) 个 c,贡献就是 \(cnt(c) \choose C\)。这样增长率变化是阶乘级别的,可以大大增加效率。事实上,\(C=3\) 的时候,\(200 \choose 3\) 就已经比 \(10^6\) 大了,考虑直接用 cccp 作为匹配串。
构造:比黑白更多的染色并使用抽屉原理定次数
CF1450C2(两遍):
卡点:想到给图染色了,但是只染了一种。
像这种连续联通块一类的问题,考虑给点分类使得同一个联通块内的点全部在不同的部分。并破坏一个部分与另一个部分之间的连通性即可。
考虑类似 老 C 的方块 或者 黑白染色的形式,但是按照 \((x+y) \bmod 3\) 把网格图分为三个部分。这样一个连续的三个块就一定分属三个部分。我们只需要破坏任何一个类和另一个类的连通性(不同时为某一个属性)即可。有下面几种可能。由于构造全 X 和全 O 等价,这里钦定第一个是把 O 换成 X,第二个把 X 换成 O。则有 \((0,1),(1,2),(2,0)\) 三个互不等价的情况。那么此时总的操作数是每个种类里圈和叉的个数和也就是 \(k\)。有三个操作。根据抽屉原理此时最小操作数必然不大于 \(k\) 的三分之一。多测记得实质性清空。
全面地讨论问题
CF1419E(一遍):我们考虑先给这个数分解质因子。如果分解完的质因子个数只有一个 \(p\),则所有的目标因数都是 \(p\) 的倍数,直接输出因数。
考虑两个质因子的情况。由样例一,在 \(A=pq\) 的时候序列只可能是 \([p,pq,q]\) (循环位移等价),则需要一个操作。考虑 \(A=p^aq^b(a,b \ge 2)\) 的情形。此时我们令 \([x]\) 表示 \(x\) 的倍数(注意 \([pq]\) 不包含 \(pq\) 本身)。考虑这样的序列 \(p,[p],[pq],[q],pq,q\) 是没有问题的。
继续求不少于三个质因子的情况 \(p^aq^br^cs^d\)。此时我们可以构造一个序列 \(p,[p],[spq],[pq],pq,q,[q],[pqr],[qr],qr,r,[r],[qrs],[rs],rs,s,[s],[rsp],[sp]\)。这种格式是完全没有问题的。
讨论全面是指,一开始写的时候没有仔细讨论,没有考虑是两个甚至多个质数的公倍数的情况,导致序列缺少个数。
正确暴力,勇敢去做
CF520D(两遍):
卡点:发现贪心思路之后不会处理一个点删除后对于其余点可删除性的影响。并考虑到博弈论倒着 DP 上了。
如果两人的策略并不复杂,那么我们直接贪心而不需要 DP。本题中先手希望字典序最大化,而后手希望最小化。则贪心策略就是先手取当前能取的最大值而后手取最小值。发现取的路径可能是一张图,但是我们只需要暴力维护可以取的点集。但是注意一个点可以取当且仅当其本身满足可取的条件且取完之后图还是稳定的,也即以其为支撑的点还有另外的节点作为支撑。
这也就意味着如果某个点被删除,其作为支撑点的某个点的另一个支撑点的可删除性将可能受到影响。例如
.X.
A.B
其中 A,B 在地面上,而 X 是一个点。那么原本 B 是可删除的,而我们删除 A 之后,如果再删除 B 则 X 没有支撑点了,所以 B 不可删除。这也就意味着我们在可删除集里的点可能要被弹出。具体地,只有与当前操作点的切比雪夫距离不超过二的点会受到影响。
我们开个 set 暴力维护即可。由于点的度数 \(O(1)\) 且每一次都会删除一个节点,所以总的复杂度是 \(O(n\log n)\)。每次暴力拓展点即可。
后续:在思路很离谱的时候回去想想暴力,也许正解真是搜索或者暴力。
二分·整体二分·WQS 二分
有单调性就二分,多组二分考虑整体二分。
普通的二分
有单调性就一定要考虑二分。有单调性就一定要考虑二分。有单调性就一定要考虑二分。
AGC029C(两遍):卡点:维护串的出现编号,但是编号过大。
首先字符集大小必然有单调性,二分下界。
那么对于给定字符集的方案,我们考虑这样维护。如果当前长度比上一个大,则直接向后插 \(0\),直接跳过。如果小于等于上一个,考虑直接从左边向右,只保留上一个和当前长度相等的数的位置并直接在尾部加一。那么我们要怎么刻画进位呢?考虑用 map 维护所有非零的数的位置的值。用 rbegin 可以快速找到当前最大的键值对,也就是最右边的位置。从最右边的位置一直每次删除最大的(加深记忆),直到删除完所有不合法位置。而加一操作就更好了。从目标位置开始,如果当前点加一会变成字符集大小就删掉(变成零),并把位置跳到上一个。如果有一个合法位置则加一,否则无解。
U608620(两遍):卡点:固定端点错误,应该是固定左端点二分最大长度,而不是固定右端点扫描线。
我们知道在固定左端点的时候区间 \(\gcd\) 是随着长度单调不增的,那为什么不二分呢?区间 GCD 直接用 ST 表。
整体二分
先考虑只有一个询问的时候是怎么二分的。
可以二分,可以离线,修改对答案贡献相互独立且有交换结合律并且可加,就可以整体二分。
我们设 solve(l,r,L,R) 表示答案在 \([l,r]\),操作区间是 \([L,R]\) 的解决函数。设二分答案的中点是 \(mid\),把当前操作区间分为两个部分,如果答案在 \([l,mid]\) 的放在左边,在 \([mid+1,r]\) 的放在右边。最后递归到左边和右边。
P2617:带修改区间第 \(K\) 小,修改可以改为值域上一个值的个数减一,另一个值的个数加一。那么考虑记录当前的修改和查询操作的位置,类型,和修改的值。那么这么说,每一次操作区间里面只有修改值在答案区间内才会造成影响。更进一步地,我们查询当前区间内点的个数(修改时候只维护值在左边的操作),如果左边个数不够就减去左边个数转到右边。值域只有一个值则当前询问答案就是那个值。
WQS 二分
很多时候我们看到这样一种题目:
原本是个低复杂度的简单题,加一个限制,则直接求解变得困难。这个时候考虑能否使用 WQS 二分。
WQS 二分的思想
我们拿一道题目举例子。
P2619(三遍):
卡点:没有理解 WQS 二分,编号是从零开始的。
找一个恰好 \(need\) 条白边的最小的生成树。
我们给每个会造成贡献的决策(本题是白边)一个“惩罚”值,使得每一次选择该种决策都会增加一定数量的惩罚。具体地,我们取一个值 \(k\),使得白边的权值减掉 \(k\)。设当前 MST 的值是 \(F_k(n)\),选了 \(m\) 条白边,那么事实上的答案就是 \(F(n,m)=F_k(n)+km\)。感性理解就是我们可以通过增加白边权值来减少白边的选择,也能通过减少白边权值来增加白边选择。
那么我们二分权值范围(一般直接用 \([-V,V]\),用 \(\Z\) 问题也不大,就是常数较大),每次二分一个 \(k\),考虑当前减去 \(k\) 能否使得选取个数不小于 \(need\) 即可。
但是由于我们是二分的,我们要满足 \(f(n,m)\) 关于 \(m\) 是凸的。而本题和最小点度数生成树都是凸的(一般看到 WQS 二分形式就直接用,先猜是凸的),所以 WQS 二分又被称为凸优化。
两个有单调性但并不相关的性质
考虑枚举一个二分一个。注意二分的那一个应该要保证两个性质都能够取到范围内的所有节点。
CF613B(三遍):
卡点:十分离谱的是,记得解法但是爆。就是说,一开始选择二分最小值,但是此时所有的决策点不能全部被覆盖,所以可能出现缺少最优答案的情况。
考虑换一个,枚举被调成最大值的个数(最多只有 \(N\) 个),在里面二分一个最小值 \(mid\) 判断是否合法。注意原本枚举是 \(A\) 了的就不用再考虑修改了。
离谱的卡点:原题没有 SPJ,导致原本用值当编号关键字排序再来还原的方法会过不了。
考虑直接用结构体排序,则按值排序后相同的值编号会保持原有顺序。
倍增·ST 表
我们可以直接跳 \(2^i\) 步,就是倍增。
在没有修改操作,并且每个点只有一个出边的时候走各种路径操作(尤其是周期操作)时用倍增是极好的。
周期性倍增
如何找一个点后面距离最小,次小的节点
对于有周期性的操作(例如 Alice 和 Bob 轮流操作之类),我们先单独处理倍增长度在操作的某个周期和周期之内的部分(两人轮流的时候就是走一步和两步),之后对于周期倍增即可。这样就不用判断当前操作者是谁。
如何找一个点后面距离最小次小的节点?我们直接维护一个 set,在每次加入节点之前查找当前节点,在附近找到最接近的两个大于它的和两个小于它的,按照题目的条件更新即可。由于此时节点本身没有加入,我们不会出现转移到节点本身的情况。
P1081(两遍):
卡点:周期性倍增没有简化,并且不会实现找一个点后面距离最小次小的点。
我们发现对于一个节点,从其出发的路线是确定的,可以跑的路径长度仅仅由 \(x\) 确定。则我们直接看每个点跑一步(A 操作)和两步(A,B 各自操作)会走到哪里。对于一个点 A,B 的出边就是找最大次大节点。这里高度低的优先更新即可。发现四步以上的都是 A,B 轮流操作的结果,直接在两步操作的基础上倍增长度即可。记录下总的费用(把「距离」理解为「费用」可能更加合适),A 和 B 操作的费用,以及操作之后的节点位置。我们每次把剩余费用减去操作的费用即可。这样我们就得到了两人操作的费用。
后续: 维护某个点值域上附近的信息时要善于使用 set 和自带的二分查找,以及迭代器的暴力遍历。
popcount 最短路
P1613:一条路径的费用是其长度的 popcount。求 \(1\) 到 \(n\) 的最短路。
\(n\) 只有 \(50\),所以 \(O(n^2\log n)\) 的总状态数完全没有问题。倍增写一个点走 \(2^i\) 步可以到达哪些点。则这些点和这个点的权值可以是 \(1\)。不排除点会到达自己,所以和当前距离取 \(\min\)。
之后是最短路。由于此时边数可能到达平方级别,点数又特别小,我们考虑 Floyd。
严格次小生成树
P4180(思路冲出,调试 INF 遍):
卡点:没有逐字读程序。
有自环首先判掉自环。发现生成树总边数不变,那么更大的生成树(无论是否严格)必然是由 MST 树边换非树边得到的。考虑到一个简单的构造更大的换边方案的办法:在树边两点路径上找到一个小于当前的边的树边,用本非树边替换之。这样树的连通性和边数是不变的。考虑怎样做到次小化。发现改很多边一定不优(最大变化量变大)。所以只修改一条边。而修改一条边时我们希望变化量最小,考虑选择小于边本身且最大的路径上的树边。注意在 MST 的树上路径上不存在比当前非树边权值更大的树边,否则用非树边替换一定更优。也就是说,替换边是路径上的边权最大值。至此我们分析完了非严格次小生成树。考虑严格化。由于边权最大值可能等于当前的非树边,此时我们就只能用边权的次大值替换。而路径上最大/次大值可以通过跳祖先倍增维护。求两点 LCA 同时由于两点都会跳祖先,就可以维护路径最大/次大值了。
然后调了 \(40\) 分钟。考虑下面几个卡点。
- 没有判定自环,35 pts。
- 判定自环错误,不能输入自环的同时就直接撤销当前输入而不加特判导致前一个有效输入被占领, 21 pts。
- 求次大值时符号输入错误,从小于号输入为大于,42 pts。
- 估算答案范围错误,本题答案上限是 \((N-1)V\),为 \(10^{14}\) 而不是 \(10^{13}\),所以范围最好开到 \(10^{18}\),84 pts。
同类情况处理: 自己手造数据/对拍/Corner 找各种问题,如果没有时间,直接重构。因为一个字符的错误是找不出来的。
双指针·莫队
莫队是怎么发明的
打暴力的时候我们会先按照先左后右从小到大给端点排序,但是这样右边端点移动的次数是 \(O(N^2)\)。发现给左端点分块之后左端点按照块(大小为根号)排序的话,移动的总复杂度变成 \(O(N\sqrt N)\)。很好打暴力。
莫队的奇偶排序
莫队本身就是打暴力,我们充分发挥人类智慧,令编号为 A.l/c,对于右边端点,编号是偶数的话就从小到大排,反之从大到小。
贡献问题
莫队维护的是区间扩张或收缩恰好一个点时的贡献。如果和数对个数有关,注意:
所以要解决先改颜色还是贡献的问题。
区间和/异或和为某个值的子区间个数
区间内取同一个颜色的数对个数
首先用前缀和/前缀异或和转为数对的个数。下面以更困难的区间和作为例子。
CF877F:考虑在莫队移动到某个端点时此前在数组里面存储的是此前出现的答案。那么根据时效性,就是维护其之前加入的量。而这个时候我们要关心本节点此前的合法节点应该是在左边还是右边,也就是当前节点是通过向右扩张还是向左扩张得来的。如果在左边就统计和是 \(i+k\) 的数量(右边端点),否则统计 \(i-k\) 的数量(左边端点)。因为统计的时候后面加入的节点不可能在之前被计算,所以同一个数对只会被计算一次。
CF617E(一遍):这里前后都是异或,只用写一个 add 和 del。但是注意改颜色和贡献的问题,因为 \(k=0\) 等价于求当前数的数对个数。没想到在题单里面有。
P1494(一遍):莫队模板,按照上面的贡献即可。
区间内只出现一次的数的数量
CF1000F(第九套一遍):原本开一个链表,以零号点的下一个为链头,每次从链头插入新点。但是这样做常数太大,如果只查询其中任意一个元素就查询队头,很不好。考虑换一个设计。用栈。插入时直接加入并且记录每个元素的位置,删除的时候直接把栈顶移动到删除的位置并且直接删除栈顶,清除元素下标。记得使用快读快写。
区间众数的出现次数
P3709:题目本质上就是说的区间众数。记录出现次数和出现次数的出现次数以及出现次数的次数的最大值,然后直接维护。
区间出现次数计数
由于 \(1+2+3+...\) 是 \(O(n^2)\) 的,出现过的颜色的出现个数的种类是 \(O(\sqrt n)\) 的,可以用来进行操作。
LOJ6164(一遍和提示):之前做过。对于出现次数计数,如果当前次数不为 \(0\) 了就放在链表里面,变成 \(0\) 了就拿出链表。
区间第 \(k\) 大出现次数
众所周知 \(cnt_x \in [1,n]\)。考虑值域分块,查询的时候整块暴力查,散块暴力加 \(tnt\) 值。两个参数在前面有定义。
P3730(两遍):卡点:不会值域分块。 直接值域分块计数即可。由于莫队是根号的,我们查询只要是根号就可以接受了。
随机化乱搞
随机化的目的是把一个有误差的算法执行很多遍来把误差降低到最小。
随机化乱搞的正确性保障:计算误差
CF1310D(两遍):
卡点:想到暴力 DP 和黑白染色,但是因为没有想到随机化把黑白染色否了。
暴力 DP 是并不困难的。考虑 \(dp(u,i)\) 表示节点 \(u\) 是当前位置,走了 \(i\) 步的最短路径。这样如果没有奇环限制,就做完了。
考虑加入奇环限制。我们一般是怎么判定奇环的?用黑白染色。但是这个题目如果我们直接黑白染色,很可能会因为把偶环也染成同色而导致最优答案缺失。但是这个解法有概率找到答案。考虑直接随机黑白染色若干次,强制两个颜色的点交替走,求所有答案的最小值。
考虑我们为什么这么做。我们假设有最优路径的点数是 \(k\) 个,那么这 \(k\) 个点选到黑白相间的概率是 \(\frac{1}{2^{k-1}}\)(黑白相间是两个方案)。这题 \(k \le 10\),概率最小是 \(\frac{1}{512}\)。
虽然一次选不中的概率高达 \(\frac{511}{512}\),但是我们可以随机 \(K\) 次,那么全都选不中的概率就是 \((\frac{511}{512})^K\)。取 \(K=5000\) 的时候概率就已经很低了。则我们就得到了正确答案。
后续: 如果有一个有误差的解法,那么我们考虑把这个解做很多次,这样可以减少误差。这就是乱搞的宗旨。
正则二分图匹配
LOJ180(再次做还是做了三遍):看来对于一些细节上面的问题没有思考透彻。
大体的过程就是在左边每次选一个出点,随机走一条出边到右边去,如果右边的点出现过则所谓增广路上有环,把右边的点第一次出现的位置之后的所有点都删除,最后走匹配边回来。如果匹配边没有就是增广路的结束。增广路是按照非匹配边-匹配边交替行走的,所以最后是落在非匹配边上。我们考虑倒着遍历整个增广路,遇到非匹配边就转为匹配。这样大致没有问题,但是细节问题很多。
第一,为了确保我们得到的增广路是完全随机的,我们要随机左边点作为起始节点的增广顺序。随机生成左边节点的排列即可。
第二,删除环的具体情况就是我们把所有在当前增广路最右边的点标记为从没有出现,并把增广路右端不断向左移动,直到当前拓展的位置从来没有出现过,再把拓展位置加上来。
第三,由于非匹配边的点不相同,我们直接从增广路上每次取出最后的两个点,它们就互为匹配节点。此外,一定要记得把这两个节点标记为从来没有在增广路上出现过,也就是说,我们在增广路上取点的同时对增广路完成了清空。
不过这道题也就是对正则二分图进行一个论文的乱搞。但是对于我们怎样理解增广路把匹配边和非匹配边交换所得到的匹配边数量会增加一还是有所帮助的。
搜索
01 BFS——如果边权是零或一
我们知道所有边权值都是一的时候用 BFS 跑最短路最快。其实权值只有零和一的时候也是可以用 BFS 的,但是需要特制。我们使用一个 deque,如果当前边权是零就放在队头,是一就放在队尾。每次还是取出队头转移。当然,由于一个点的最短路此时可能不会只更新一次,我们不能不再访问未访问的节点,只要节点最短路不更新我们就不转移。我们转移当且仅当最短路被更新。具体使用例见」「CF1749E」。
BFS 时间常数小且不易出错,空间和内存允许时写 BFS。记忆化就写 DFS。
想清楚再去写
在可能存在分类讨论的时候先分类讨论之后排除那些已经被考虑的状态,想清楚再去实现,以免多次排除和 HACK。
P3621(十二遍):遍数最多的一回。题目不困难,就是细节有点多。
卡点:一开始想用是否是满二叉树判断,但是有一种情况可以 HACK,那就是左边是深度更低的满二叉树而右边是深度更大的,这样是需要一次交换的。之后考虑问题不全面又吃了很多发。
叶子的深度差不能超过一,因为交换左右子树不改变叶子深度。如果所有叶子深度一致就不用交换了。
考虑只有深浅两种叶子的情况。原本是说哪边有低的就把哪个放在右边(考虑不可能左右两个子树都有高有低),但是我们允许最终的根节点的左子树里面有小有大,只要把大的放在子树左边即可。所以我们不能只是按照有没有小的去划分。同理,左右两遍是允许有一个子树有高有低的,所以不能以左右两边都有低的作为判负的标准,而是两边都有小有大。
我们暴力出九种分类讨论的情况,发现有两种是左右两边深度一致,一种是左右两边有小有大,则只剩下六种分类,我们对于每种分类去交换转移。
后续:还是要全面考虑问题,不能主观否定某种分类。
分层图 BFS
CF1209F:注意分层 BFS 的时候同层有时要放在一个向量里面,同时对向量内的所有数转移。此外,同层内也要按照数字从小到大排列转移以防止优先级高的在队列后面。
BFS 把图分层变成 DAG
在涉及最短路且边的长度(对路径长度造成的贡献)为 \(1\) 的时候,我们通过把图按照 BFS 的层数分层来把图变成 DAG 以更为方便地进行 DP 等后续处理。
CF507E(一遍过):炸掉的边的数量是总的没有坏掉的边的数量减去目标路径上没有坏掉的边的数量,而目标路径的长度是给定的,我们又希望没有坏的边的个数最大化(这样修复的边最小),所以目标路径上没有坏掉的数量是一个定值,则炸掉的边的数量是一个定值。我们直接不考虑炸的边,只用考虑修复的边。
发现最短路长度和最短路上修复边的费用只能独立计算,且每条边的长度就是 \(1\),所以我们直接用 BFS 做最短路。而且我们发现 BFS 之后分层图是一个 DAG,因为不存在从下面的层级回到上面的边。注意横叉边是不影响计算的,因为走横叉边会增加距离是更劣的。
那么我们设 \(dis(u)\) 表示点 \(1\) 到 \(u\) 的距离,\(cost(u)\) 表示点 \(1\) 到 \(u\) 的修复边的最小花费。如果当前距离更小或者是距离相等的前提下花费更小就转移距离和花费,并记录从何处转移以最终重构路径。记录哪些边在路径上。只要边是在路径上的坏边或不在路径上的好边就要修改。直接输出修改情况即可。
BFS 比 DFS 快得多
尤其是在涉及到最短路/最优解的时候。因为 BFS 不用递归就能直接退出。而且 BFS 就是按照最短路长度拓展的。但是 BFS 本身空间常数和状态数挂钩,如果节省空间还得是记忆化 DFS(实现 DP 的一个方法)。
CF514C(一遍):多个串还给你总字符数,Trie 树没跑了。考虑在 Trie 树上搜索。由于答案的深度上限就是串的长度,并且用一次非既定轨道后就不会再使用,考虑直接 BFS,搜索到长度了如果答案合法就直接退出。大胆猜测状态数量不会很多,结果一遍过了,最大点 2.5 秒,就很抽象。
平均数的处理(重复出现的套路)
很多时候,我们不方便去计算一个数组的平均数,但是需要知道平均数是否等于某个定值(例如 ABC292H)。这个时候,用浮点数容易爆精度,用分数存容易爆 long long。这个时候需要一个经典套路了。
经典套路:
这样我们就成功把平均数转换成纯和式。而纯和式是更好操作的。
CF788C(两遍):
卡点:第一,BFS 的时候直接存储的平均值导致数值爆炸。第二,没有考虑缩小搜索值域范围导致时间爆炸。
如果目标小于最小值或者大于最大值就无解。
对于卡点一,我们直接按照上面的方法把平均值转换成和式。这样就变成了直接算和为 \(0\)。
可以背包,这里选择用 BFS 暴力求。
我们发现,这里用 BFS 可能状态数量会爆炸。考虑我们只关心我们去了取了什么数,而不关心顺序。所以我们考虑这么一件事,因为答案必然存在,而在答案变成 \(0\) 之前增减幅度必然在 \([-1000,1000]\),那么我们假定我们找这个增减幅度,那么必然存在一个方案使得我们可以通过调整法,也就是如果当前和大于/小于零就让当前和减/加来使得任何时候的和都在 \([-1000,1000]\)。最多 \(O(N)\) 个数,每个数最多拓展 \(O(N)\) 轮,总复杂度 \(O(N^2)\)。输入时我们只需要判断哪些浓度存在。
ARC104D(两遍):卡点:没有转换为和。
把所有数先减去 \(x\),则我们可以选取的数变成了 \([1-x,n-x]\)。我们把它拆开变成 \([1-x,-1]\cup\set{0}\cup[1,n-x]\)。发现 \(0\) 可以任意取,有 \(K+1\) 个方案。而剩下的两个的和需要变成 \(0\),则第一个的方案数为 \([1,x-1]\) 取出的和与 \([1,n-x]\) 相同的方案数的积。
记 \(dp(i,j)\) 为取 \([1,i]\) 的数,和为 \(j\) 的方案数。因为和不超过 \(T=0.5n(n-1)k\),我们考虑直接枚举和 \(s\),则答案是 \((\sum_{s=0}^T dp(i-1,s)dp(n-i,s)(k+1))-1\),因为不能选取空集。
\(dp(0,0)=1\)。然后我们发现本题相当于若干个数为 \(k\) 的物品的多重背包。考虑怎么优化转移。发现转移形如 \(dp(i,j)\sum_{t=0}^k dp(i-1,j-ti)\)。考虑去掉 \(t \le k\) 的限制,则 \(dp(i,j)=dp(i-1,j)+dp(i,j-i)\)。然后我们减去 \((k+1)i\) 时候的限制即可实现前缀和优化转移。
ZR3305(一遍):这次碰到 Trick 是一遍切了。对调配方案背包计数。设 \(dp(i,j)\) 表示前 \(i\) 个物件混合之后和为 \(j\) 的方案数。 \(dp(i,j)\gets dp(i-1,j-w_i)+dp(i-1,j)\)。则 \(Ans=dp(n,0)-1\)。因为空集不可取。
DFS 序
一般来说图剖路径的构造可以考虑使用 DFS 序(就是回溯一起记录的版本)。
子树的刻画用 DFS 序
给你一棵树,问你每一次只能走长度为 \(k\) 的路径,则问你 \(1\) 出发的点的最短路,以及点是否可达。
AT_abc414_f(三遍):
卡点:第一次写的时候以为暴力做法是换根,直接一步跳到 \(k\) 以内的深度,结果深度小了反而不会处理了,直接向下走最大长度的链向上就好。结果样例没过就老实了。结果后面以为用长剖,心态崩了。第二遍直接解析看不明白。
事实上思路从一开始就完全错误,本题目 \(O(n^2)\) 的暴力算法就是直接 BFS,每次扩展到距离当前点的距离恰好等于 \(K\) 的点集。由于点集的大小是 \(O(n)\),而每个点又只被访问一次,所以转移边的总数是 \(O(n^2)\)。总的复杂度就是 \(O(n^2)\)。
于是乎,我们在这个 BFS 的基础上去优化。
重复出现的 Trick:最短路直接考虑搜索而不单纯考虑 DP。更多时候就是 搜索+DP。
考虑路径有两种,第一种是一个端点在另一个端点的子树里面,另一种是完全没有祖先关系。我们首先考虑前者。那么显然子树里面的点 \(v\) 就是在 \(u\) 子树内的 \(dep(v)=dep(u)+k\) 的所有的 \(v\)。接着我们考虑没有祖先关系的点对。此时两点之间必定有个 LCA。我们枚举这个 LCA。由于当前点集的长度最多是 \(k\),我们只要枚举 \(O(k)\) 个 LCA 即可。
所以此时 \(v\) 是在 LCA 子树内而不在 \(u\) 子树内的。为了后续计算深度方便,我们钦定其不在 \(LCA\) 的儿子里是 \(u\) 的祖先的点 \(U\) 的子树内,这样当前节点的深度必然相同。两个子树作差,我们可以想到 DFS 序。由于祖先的 DFS 序必然包含子孙的,我们把序列拆为两段区间即可。设点 \(U\) 子树的 dfs 序的分布属于 \([L(U),R(U)]\),则目标的 dfs 序属于 \([L(LCA),L(U)-1]\cup[R(U)+1,R(LCA)]\)。
考虑当前 \(v\) 的深度。记当前跳 LCA 得到的距离是 \(d\),则我们需要让距离为 LCA 下去 \(k-d\),也就是说 \(v\) 的深度是 \(dep(u)-d+k-d\)。注意这里就是 \(u\) 而不是当前节点 \(U\)。还是删除并遍历。
对于遍历某个具体的深度的 DFS 序在某一范围的点,我们用一个 set 存储当前深度的所有点的 DFS 序,则我们二分起点,之后直接暴力向后跳(erase 一个迭代器返回的就是下一个位置),直到超出范围或者删到底了为止。我们此时遍历到的就是点集里的所有点。直接距离增加即可。然后直接在 set 里面删除。
注意:由于处理没有祖先关系的节点的时候我们是不需要有原子树里的节点的,我们先对子树本身进行操作以便于删除。
后续: 应该从更加简单的暴力入手,并想想暴力的瓶颈在哪里并进行优化。
CF780E(两遍):
卡点:一开始想的是生成树上 DP,但不会输出路径。
考虑 DFS 树也是生成树。直接走一个大的 DFS 路径(带回溯的)。由于这是一个完整的路径,且长度不超过 \(2n\)(考虑我们等价于走 DFS 树,所以每条树边最多被访问两次)。直接用上界当长度,把序列分为 \(K\) 段即可。如果分不到 \(K\) 段就直接部署在 \(1\) 点。
折半搜索(40 的用法)
在数据范围内看到 \(40\),你会想到什么?
发现 \(40=2 \times 20\),而 \(20\) 是 \(O(2^n)\) 的复杂度上界(与容斥、状压、搜索有关)。
我们可以把搜索位置或问题规模减半,然后两边分开搜索。这样就得到了一个合法的解决方案。
CF1006F:我们按照副对角线(\(2(x+y)=n+m+1+[(n+m) \bmod 2 =0]\))把原图划分为两个部分,两边分开搜索。
时间复杂度对的,状态数量不会爆炸吗?
还真不会。考虑极限情况 \(20 \times 20\)。不难发现,路径数量和组合数密切相关。可以打个表。
1 1 1 1
1 2 3 4
1 3 6 10
1 4 10 20
对了,就是组合数表。所以如果我们用副对角线的话,状态总数就是二项式系数的和(这也是为什么不用主对角线)。所以对角线状态总数不超过 \(2^{20=}1048576\),是可接受的。
专题:【编辑字符串】与 01 串
从【编辑字符串】出发,我们对 01 串的操作(排序、轮换等)有关的构造有了更加深刻的了解。
我们一般把序列变换看成是和 0,1 个数有关的变换,换句话说,字符串本身是怎么变换的并不重要。
CF1370E(三遍):
卡点:知道判定 \(-1\) 和只需要是两串同一个位置不同值的位置作为新串,但是后面怎么贪心就不知道了。归根结底是没有弄清楚操作的本质。
其实在这之后就是一个操作:我们需要把所有的 101010...10 转换成为 010101...01,事实上其他形式的转换本质上也是干这个事。那么我们只需要搞清楚新的 \(S\) 里面有多少个这种串可以去找。那么我们考虑把字符 0 视作 \(-1\),字符 1 视作 \(+1\),那么合法串的和就是 \(0\)。那么由于有一个 \(1/0\) 就找一个 \(0/1\) 与其合并,所以我们可以合并的数量就是 \(0\) 的数量减去 \(1\) 的数量,或者取反。因为最后剩余的点是只能单独成串的。考虑按照上面的赋值法求【最大子段和】(注意不是【最长连续子段和】,加到小于 \(0\) 了就直接变成 \(0\),记录前缀最大值即可)取反再求一遍,那么就得到较大值,就是答案。
后续: 考虑如果有 01 串操作,先把它和 01 个数联系在一起想想。或者构造一个简单的数列来看看这个操作是不是只是把一个数列转换成另一个即可。
DP
DP 的本质是相邻状态之间的转移,使得找到一个初始状态到末尾状态的推理转移路径。所以我们要做的就是如何设计状态和如何转移状态。
特别注意:DP 状态设计时选择自己熟悉的,更为容易而精确的方法,例如“三位连续”直接计前两位,不要记录最长连号。
论状态的设计
状态设计的维数:考虑我们需要哪些信息来确定一个状态和相邻状态,以及其转移关系。
有的时候数据范围对 DP 设计大有帮助。例如,千数量级的一般是两维 DP。不过我们可以滚动数组优化,所以这只是我们思考如何写出基础(不带优化的)DP 的参考,具体还是看信息的维护。
论转移的设计
如果转移形式比较丑,通过扩大缩小范围把转移写成一个熟悉且好优化的形式。
线性 DP
转移的设计
考虑到线性 DP 的值域一般大得上天,我们考虑一个和值域没有关系的暴力版 DP 方程:
也就是说,我们只统计当前区间 \([j,i]\) 对答案造成的贡献,而不用为了不确定信息胡思乱想。
事实上,联赛范围内的几乎所有对 DP 的优化都是基于上面这个模式,所以不要乱设计转移对优化有好处。
任意长 \(K\) 连续段满足某种性质
对点染色
如果区间内所有长度为 \(K\) 的连续段都要满足某种性质,我们就不能只统计当前的连续段了,否则前面的段可能不达标。(前面可能是不完整的段。)
考虑所有长 \(K\) 连续段的共性是什么。我们对点按照下标以 \(1,2,\cdots,K\) 循环编号。那么在任何一个连续段内所有的编号都会出现且仅出现一次。我们钦定同一个种类的点设置为同一个余数。设 \(val(i,j)\) 表示把循环编号为 \(i\) 的所有节点的余数全部设置为 \(j\) 的最小费用。
那么我们就可以只对于长度为 \(K\) 的段本身 DP 了。设 \(dp(i,j)\) 表示已经考虑到所有循环编号为 \(i\) 的点,总的余数是 \(j\) 的最小费用。
AT_abc419_e(一遍):就是上面的过程。
题目可能有歧义
在遇到表述不够清晰的题面的时候考虑多种情况。
P3089(两遍,没看解析):
卡点:只有一个,就是原文说了“一个方向”可能是反向。
直接离散化暴力跑,发现有一个后缀最大值,直接滚动一遍,而我们发现转移区间的左端点是大于等于某个数的最左边的值,直接二分即可。注意反着跑一遍。
Is This Greedy or DP?Or We Can Just Order Some of the Figure?
事实上,贪心和动态规划都只是答题手段,这两个方法并非只能用一个,二者也并非是二元对立的。如果真的有什么实际操作方案的话,只能说,在不能排除其他方案不优的前提下,我们只能使用纯粹的动态规划来保证正确性。
CF360B:首先,如果我们修改某个数为一极大值,则答案可以无穷大。既然有单调性,考虑二分答案。考虑如何判断答案是否合法。一开始贪心地想如果从开头出发,若差分数组的值的绝对值比 \(mid\) 大就修改为 \(\pm mid\)。但是我们不能排除修改为其它的合法值是不优的。然后就寄了(WA on #6)。
没法缩小状态考虑 DP。设参数时发现有大量不确定的值,然后又桂霞了。这个时候,我们要学会钦定一些值是原序列中的数。我们并不知道修改后会是什么值,存储这些数又很不现实。所以我们考虑从不变的值入手。设 \(dp(i)\) 表示钦定点 \(i\) 不修改时前面的最小操作次数。但是我们并不知道前面的值的范围,以及要修改哪些数。我们可以枚举前面的一个不变的点 \(j\)。此时 \([i,j]\) 的跨度的范围就钦定在了 \(|a_i-a_j|\)。注意:由于此时这个值不会随任何修改而变化,要保证 \(|a_i-a_j|\le (i-j)mid\)。(最大也不过所有节点都往前面加一个 \(mid\))我们钦定中间的 \((i-j-1)\) 个点全部被修改。最后统计操作个数的时候,由于全部修改肯定是不优的,我们每一次钦定一个点 \(i\) 不修改,它后面的数全部被修改。则当前的操作次数是 \(n-i+dp_i\)。存在一个操作次数合法即可。
更多的时候考虑先打出暴力 DP,后用贪心找性质优化。
P5665(三遍):CSP-S 2019 真题(D2T2)。作为 NOIP2019(不存在) 的替代比赛,这一年的题目确实很有质量(真题诚不我欺),部分分给的也是比较足的。
卡点:写完暴力转移就直接往决策单调性上面想了(看着像斜率优化但是和决策点有关),然后就脆下。
首先看看数据范围,\(N\) 最大开到 \(4 \times 10^7\),甚至无法直接输入数据。这就提示我们正解很明显就是 \(O(n)\) 的,不可能再加东西了。而线性的最优化问题,一般不是纯粹的贪心就是单调队列把 \(O(N^2)\) 的 DP 给干掉一维。直接瞪眼出贪心不大现实,还是先从 \(O(N^2)\) 入手。
设 \(dp(i)\) 表示考虑到 \(a_i\) 的划分的最小费用。发现费用计算和决策点有关,我们记录 \(p(i)\) 表示以 \(i\) 结尾的那一段的和。那么
对于决策点 \(j\) 有 \(p(i)=s_i-s_j\)。
好了我们已经获得了 \(64\) 分的好成绩。
考虑怎么优化,发现 \(dp(i)=\min f(x)+a(x)\) 是一个亲切的形式,有很多种方法优化。首先我们看到 \((s_i-s_j)^2\) 想到斜率优化,但是决策点是个问题。我们又看到决策有单调性,但是二分队列不好写。然后就脆下了。
但是 DP 优化还有一个最简单的方法:单调队列优化。发现 \(s_i,p(i)+s_i,dp(i)\) 和决策点都是单调增的,所以选取最右边的合法节点必然最优。而一个点合法就意味着 \(p(j)+s_j \le s_i\),所以更小的 \(p(j)+s_j\) 就更有优势。而维护优势决策点(见下面)是单调队列的重要用途。直接使用单调队列就是 \(O(n)\) 的。好了到这里是 \(88\) 分,考试到这里基本上可以跑路了。
但这样还不够。我们已经写出了正解,考虑怎么直接切掉这道题。下面是一些 卡常 的小技巧。
- 这里生成数据部分要对 \(2^{30}\) 取模,而 \(x \bmod 2^k\) 等价于 \(x \And (2^k-1)\)。这样会让运算快不少。
- 本题数据范围 \(4 \times 10^7\),所以空间有限,能不开数组就直接使用变量维护。比如说 \(b\) 数组,每次只记录最后三个变量即可。并且结合数据范围,有且仅有前缀和和答案需要开
__int128。注意前缀和不开会直接爆炸。由于动态规划数组开__int128就浪费空间了,我们只记录决策点,这样从 \(n\) 一直往回跳决策点就可以记录答案了。因为每一段是决策点 \(j\) 到当前节点 \(i\),费用的和自然就是 \(\sum(s_i-s_{dp_i})^2\)。而 \(p(i)=s_i-s_{dp_i}\),又可以少开一个数组了。此外能不开long long就绝对不开。
后续: 在优化 DP 的时候第一个考虑直接单调队列优化,再考虑其他优化。
P10260:考试考的,思路非常猎奇,但是最终手切了。
首先考虑第一个部分分。看着类似 DP,考虑我们需要什么信息来作为一个「状态」。由于在后面(时间上)按下按钮的时候已经完全拉起的窗户的个数不少于前面(时间上)按下按钮的(因为窗帘只会被拉起而不会被放下),考虑首先按若干次按钮,后手动拉窗帘。设 \(DP(i)\) 表示按下按钮的次数为 \(i\) 次时的最小费用(仅仅计算按按钮锁贡献的费用)。依据题意即可转移。之后我们考虑在已经累加的按钮之后计算单独的贡献费用,对于每一个高度要分开计算费用。
令 \(M\) 为最大的 \(A_i\),则时间复杂度 \(O(NM^2)\)。
再来考虑第二个部分分。如果使用按钮不会因为具体的行内被删除完的个数作额外的费用,我们就可以对于每一行(同一高度)单独考虑其对答案的贡献。为了计算方便,我是首先从高度大的往小的考虑的。那么这一行里面高度存在(不低于当前高度)的窗帘的个数 \(x\) 和 \(t\) 的积就是我们单独处理这一行的值的贡献。而 \(s\) 是我们同时处理这一行的贡献。发现我们只需要取 \(\min(tx,s)\) 即可。\(x\) 的计数可以通过排序后用一个指针去计数快速得到。总复杂度为 \(O(n \log n)\),瓶颈在排序。
考虑第三个部分分。只有一组询问,考虑目标序列长什么样子。发现按照行单独处理这个思路是正确的。
考虑正解。我们发现对于每一行使用按钮所增加的费用是确定的。设这一行内有 \(tmp\) 个元素不存在(因为排序计数时用这个更为方便),则这一行删除的费用是 \(\min(s+k \times tmp,(n-tmp) \times t)\)。
写完这一步,综合部分分二的顺序写完跑对拍,发现有问题。由于高度为 \(M\) 的最高的那一列要删除为高度为 \(H\) 的需要删除恰好 \(P=\max(0,M-H)\) 次,我们并不需要考虑具体的行的删除序列,因为每一行都可以直接删除,删除之后不会出现窗帘高度不能表示为直方图的情况。所以我们只需要考虑把每一行的贡献排序求前缀和 \(S_i\),每到求高度为 \(H\) 的时候直接求 \(S_P\) 即可。时间复杂度 \(O(n \log n)\)。
考试不给大样例(感觉不是很有道理),对着部分分一的代码跑 \(1000\) 次对拍,没有任何问题。那么想必是过了吧。那么确实是过了。
但是即使给了大样例,也要对拍来 Hack 自己的解法。
单调数据结构与 DP
尽管单调数据结构很多时候用于 DP 的优化,但是考虑到单调数据结构预处理的信息很多时候被直接用于 DP 的设计和转移,在这里不把单调数据结构优化 DP 放在一个单独的“DP 优化”的部分,而是单独开了一个专题。
单调数据结构在 DP 中主要有几种使用。
值得一提的是,凸优化的线性实现也是单调队列。
预处理点前后的信息(单调栈,单调队列)
CF1407D(一遍):通过画图我们可以归纳出,对于向右跳到第一个大于等于或小于等于自己的最右边的情况是只用一步的。而由于合法关系是双向的(这里定义的是一个区间意义的关系,而端点互换是不影响我们判定合法性的),所以左边最右的大于等于或小于等于自己的点也可以跳到当前点。这里取等恰恰维护了相邻的点如果相等就只能相邻跳的现象。同时每个点可以向右跳一个。至此转移图像已经出来了。由于所有点都是从左往右跳的,这个图是一个 DAG,可以直接用来 DP。每一次从入点(反图的出点)选 \(j\) 来作为点 \(i\) 的上一个即可。
维护区间的 DP 值的极值,当线段树用(单调队列)
一般涉及到区间最值的 DP,我们可以使用线段树直接无脑维护(线段树有个好处就是无脑维护),当然如果区间长度是给定的,我们可以使用单调队列去维护,这样可以少一个 \(\log\)。当然比暴力维护少一维。
P2034/P2627(一遍,单调队列版本是额外学习的):对于这种“不可连续选取 \(k\) 个以上”的问题,一般来说是当前区间隔一个转移(参见 天天爱跑步 )。
线段树做法: 设 \(dp(i)\) 表示钦定 \(i\) 选中的最大和。则 \(dp(i)=\max_{j=i-k}^{i-1} mx(j-1)-s(j)+s(i),mx(i)=\max_{j=1}^i dp(i)\)。考虑两个选择区间之间不一定只能有一个空格(这里被 HACK 了一下)。SGT 无脑维护 \(mx(j-1)-s(j)\) 即可。
单调队列做法: 上面这种维护方案在单调队列维护时可能需要分类讨论(其实并不需要)。但是学了一个更加优秀的 DP 设计:设 \(dp(i,0/1)\) 表示当前节点选或者不选的最大和。则 \(dp(i,0)=\max(dp(i-1,0),dp(i-1,1))\),也就是直接继承,而 \(dp(i,1)=\max_{j=i-k}^{i-1} dp(j,0)-s(j)+s(i)\)。我们直接枚举哪一个位置不选就可以轻松规避掉连续多个空格的问题。前面区间长度是给定的,直接单调队列维护。流程:先去头(时间太早已经没用的),后取头,再去尾(尾部插入当前节点之后不再有优势的),再插入。其实也是挺无脑的。
P6563(两遍):
卡点:第一遍被做局了搞错了 \(O(N^3)\) DP的转移方向,此外问题本身最早是想成「确定在某个区间的最小费用」发现不能计算答案。第二遍单调队列出了一些细节问题。
区间 DP 只能进行区间信息的合并,也就是转移一般只能从小区间到大区间,而不是相反。
设 \(dp(i,j)\) 表示在 \([i,j]\) 内确定某个点所需要的最小费用。考虑类似线段树的分治结构,则我们枚举 \([i,j]\) 里面的 \(k\),则有
也就是自下而上而非自上而下转移。
\(a_i\) 单调不降,考虑这给我们带来什么。
像这种 \(dp(i)=\min(dp(x)+a_x)\) 的形式最好的方法就是单调队列优化,但是这里有一个碍事的 \(\max\),考虑直接把它分类讨论掉,也就是处理 \(dp(i,k),dp(k+1,j)\) 的大小关系。
考虑固定 \(l,r\) 分析 \(k\) 对于 \(dp(l,k),dp(k+1,r)\) 的影响。我们发现随着 \(k\) 的增大,前者会非严格增大,而后者会非严格减小。也就是说,必然存在一个 \(pos\) 满足比 \(k \le pos\) 时前者是较大值,反之后者是较大值。
我们还发现 \(r\) 固定时随着 \(l\) 的增大,\(pos\) 是不会减小的(这符合我们的生活常识和基本逻辑)。所以我们只需要开一个指针维护 \(pos\) 即可。
此时我们就可以把前后区分掉了。
对于 \(pos\) 之后的 \(k\),有 \(dp(i,j)=\min \{dp(l,k)+a_k \}\),由于后者必然单调不减,贡献就是 \(dp(l,pos)+a_{pos}\)。
对于其之前的有 \(dp(i,j)=\min \{dp(k+1,r)+a_k \}\)。我们可以维护一个单调队列,每次插入 \(l\) 的答案并删除编号小于等于 \(pos\) 的贡献。对答案取队列最小值作为贡献。对于两种贡献取较小值。
根据转移方程,我们的枚举顺序是正序枚举 \(r\) 并倒着枚举 \(l\)。
具体到实现上,手写队列的时候 h=1,t=0 并每次清空,判断队列非空的方法是 h <= t,不然可能会出现第一个节点不能被删除,或者队列里面天然存在空节点的情况。
后续: 如果看到一个亲切的转移考虑有没有单调性。
维护(区间定长)当前最有优势的决策点(单调队列)
相比于线段树来说,单调队列可以维护的东西更为广泛,不仅仅是最大最小求和之类,还可以维护当前哪个决策点最有优势,最有可能成为转移时的最优答案。这一点来说,单调队列起到了一个李超维护斜率优化的效果,事实上单调队列本身也广泛用于斜率优化和决策单调性,以及凸优化。这就是源于这个维护决策点。
P3572(两遍):\(O(N^2)\) 的基础 DP 是可以直接写出来的。设 \(dp(i)\) 表示从 \(1\) 到 \(i\) 的最小距离。则 \(dp(i)=\min_{j=i-k}^{i-1} dp(j)+[a(j) \le a(i)]\)。后面这个艾弗森括号很难用线段树去维护了,这里也是第一遍的卡点。
考虑用单调队列去维护更优的决策点。这里决策点更优是指距离更小或者距离相同时 \(a(i)\) 更大(转移时后续 \(+1\) 的可能更小)。那么直接用单调队列维护。毕竟去尾的时候去的就是不再有优势的点。
专题:如何用最小的移动距离把一个数列调整成单调不升或者不降
CF13C(一遍):找一些数据,我们可以发现最后序列的所有值都一定是原本序列里面存在的数。考虑简单证明。由于此时序列单调不降,我们设立的点一定是当前的最右边的点也就是最大值。那么如果我们设置在某个原本的两数之间的某个值的话,那么在其后面的值在这两个数之间的点就需要更多的调整距离。这是我们所不希望看到的。我们希望让某些点可以不用调整,所以我们把最大值放在给定的值里。当然数据范围更加不允许我们枚举值域。设 \(dp(i,j)\) 表示当前点是 \(i\),最大值(当前点值)是 \(j\) 的最小费用。发现有一个前缀最小值,这个东西可以和 \(dp\) 一样维护,只需要在 \(dp\) 完了之后记录下前缀最值即可。但是只有 62.5MB 的内存,所以滚动数组少不了。
看看数据范围再试着排除自己的解法
贪心或者 DP
在不确定怎么优化状态的时候先考虑怎么去 DP。
CF1396C(三遍):
卡点:题目没有读清楚。在消灭所有小怪之后对着 BOSS 打手枪之后 BOSS 是没有死的,应该只能相邻转移。并且原本的贪心策略(先左后右)有问题,还没有看清楚 \(r1 \le r2 \le r3\)。最后实现的时候甚至没有输入 \(a_i\)。
自己想到的正确的可能只有先打小怪以及最后设 \(dp(i,0/1)\) 表示当前已经处理完了第 \(i\) 个节点,当前 BOSS 是否存在的最小时间。
令 \(A=r1,B=r2,C=r3\)。因为 \(A \le B \le C\),所以我们的 \(C\) 因为在消灭小怪方面伤害和 \(A\) 等价,所以只是在消灭 BOSS 时用。则我们先用手枪消灭小怪后给 BOSS 打手枪和用激光消灭小怪同时导致了 BOSS 扣一血在伤害方面是等价的(所以用激光后打手枪是不合法的)。另外先用手枪消灭小怪后用 AWP 消灭 BOSS 是另一类(不留活的 BOSS)。那么除了 \(n\) 节点(相邻的节点是 \(n-1\))之外其余的节点我们都让其向左走消灭此前一个的 BOSS。因为保留两个 BOSS 或者在不相邻的地方保留 BOSS 是不优的。那么我们可以直接选择此前没有活的本身也不留活的或者留着,也可以此前留一个活的,当前节点不留活的,再走到前面去消灭那个活的,再走回来。或者本节点也留活的,再走回去消灭了再回来消灭。
至于 \(n\) 要特别转移的地方就在于它可以在消灭了上一个留了的活的节点之后不走回来,以及 \(n\) 本身不能留活的。还有,\(n\) 可以在前面都不留活的的情况下先留活的,走到 \(n-1\) 再回来打手枪。
为了写作方便我们设 \(dp(0,0)=-d\),这样我们即使是到了 \(1\) 也要先加一个 \(d\),减少分类讨论。
后续:在不确定怎么贪心的时候先 DP 看看,还有因为 DP 费用不确定,要想清楚所有可能得到的最优的转移。最后检查自己的代码有无明显的字的错误。
空间可以优化,时间不好优化
最大值可能不合法?记录一定合法的次大值!
Trick 3 在 严格次小生成树 中有所运用。我们如果用最大树边更新,可能会出现非严格的情况。所以我们记录一个次大边来维护。当然这个 Trick 同样适用于 DP 问题。
CF264C(两遍):
卡点:本来是想着用和当前颜色相同或者不同的来维护的,发现所有的都记录进数组都不太现实,发现如果用线段树的话单次还是 \(O(n^2)\),就直接桂霞,所以直接写了个暴力。
事实上我们需要的不是复杂度直接正确的解法。在过程中我们需要的是最有前途和拓展性的解法。这道题前面一个解法就比线段树有拓展性。
考虑我们只需要一个最大值。那么是不是意味着对于每个颜色,我们完全可以把位置那一维直接滚动掉?发现可以。设 \(dp(i)\) 表示颜色为 \(i\) 的到当前位置的最大值。初始设为负无穷。(不然有负数。特别地,当前答案初始值还是这个。)
但是根据原本的方程,我们需要一个颜色不为 \(c_i\) 的最大值。这里原本是用 \(O(n)\) 个数组去维护的,但是插入很不方便。考虑我们为什么需要这个。因为我们不能去直接维护此前 DP 的最大值。为什么不能?考虑最大值的颜色可能和当前颜色相同。
那么我们要维护的东西就呼之欲出了——一个和最大值颜色不同的次大值。怎么维护呢?考虑空节点对应颜色和所有颜色都不同。我们只需要在当前节点可能会更新最大值或者次大值的时候判断当前节点是否和最大值颜色相同。如果相同则只能更新最大值,这样就不会出现最大值和次大值颜色相同的情形。然后我们就找到了和当前颜色不同的节点的颜色最大值了。
后续: 如果我们不能用常见的方法优化(SGT,单调性,斜率,凸优化)就考虑状态优化,滚动一位再看看是否维护的是前/后缀最值,这个东西是可以 \(O(1)\) 维护的。千万不要轻易放弃自己的想法。
DAG 上 DP
一般来说是搜索而非拓扑排序。
DAG 上路径计数
直接把后续节点的路径个数简单相加即可。而 DAG 上对于一个点出发可达点的计数就真的只能 bitset 了。
网格 DP
计数时我们状态的相邻状态是可能的情况,一定不要先枚举确定这样的情况真实存在。
泛化条件,科学取模,设置初始值
P1373:黑白染色,每次存储当前两人的取值,如果和起始节点的颜色不同就是第二个人拿,否则第一个人拿。但是状态和转移 \(O(n^4k^2)\) 起步,非常脱离实际。
考虑泛化条件。由于每一个节点都存在方案使得其被两个人都取到,所以我们先设置每个点出发都存在一个方案。如果当前节点被第一个拿了则需要存在方案使得其上方或左方的节点被第二个拿,反之同理。这样我们就不再局限于某一特定的路径的起始和终止节点。但是 \(O(nmk^2)\) 的状态数不合法。注意一个数论知识:\((n \bmod p \pm m \bmod p) \equiv (n\pm m) \pmod p\)。所以我们直接存储两人取值作差的取模即可省掉一个 \(k\)。
博弈论 DP
常见形式
Alice 和 Bob 玩游戏,他们轮流进行操作,并希望最大化自己和对方有关的一个值,求最终的分数。
解决方案
经常玩游戏的朋友都知道,我们玩游戏时会根据当前的局面作出决策,而我们作出的决策会影响游戏的局面。也就是说,我们的决策是有后效性的。那我们还怎么 DP?对于博弈论类问题有一个性质:我们作出的决策对我们作出决策之前的局面没有影响。所以我们的决策是无“前效性”的。
所以我们只能倒着转移。而有的时候倒推不好写,我们可以使用记忆化搜索。类比数位 DP,数组只用来存储每个状态,所以维数和状态的维数是一致的。
至于当前决策如何进行,一般来说,上一轮操作的时候先后手交换了位置(后手为彼状态的“先手”),从相邻状态内获取信息,选择对先手有利的状态决策。
典例题
P7137:博弈论 DP。我们考虑倒着转移。我们应该维护当前剩余的蛋糕和优惠券的数量。设 \(DP(i,j)\) 表示剩余 \(i\) 个蛋糕和 \(j\) 个优惠券时 A(切蛋糕的人)的最大分数。那么考虑当前增加第 \(i\) 个蛋糕。如果不使用优惠,则 A 可以把蛋糕全部拿走。如果全部使用 \(i\) 个优惠,则 A 一旦划分大于一半的蛋糕就会被 B 拿走,所以只能用和的一半。接下来考虑一般性情况。我们把蛋糕 \(i\) 划分为 \(x+y=A_i\)(钦定 \(x \le y\))。那么如果 B 在本轮不使用优惠券,则 A 当然取大的,反之只能取小的了。所以当前贡献为 \(\max(DP(i-1,j-1)+x,DP(i-1,j)+y)\)。由于 B 希望自己的分数最大,我们只能最小化这个值。
最大化两个值的较小值/最小化两个值的较大值
考虑让这两个值的差尽可能更小,最好为 \(0\)。
我们继续。我们希望两个值的差较小。考虑一步到位:
(上面令当前蛋糕大小为 \(a\))
但是并不会总是相等的。
- 如果 \(x>y\) 就要交换。
- 如果 \(x<0\) 了,那么只能让 \(x=0,y=a\)。
至于选取蛋糕的顺序,有序的时候蛋糕分配的性质更多,试验可发现从小到大选蛋糕是优的。
给一个不太严谨的思考。我们知道 B 在使用优惠券时会思考把优惠券放在后面使用是否更优。而从小到大选会使得这个决策变得更为复杂,对 A 更为有利。
然后就是 DP 了。答案就是 \(DP(n,m)\)。
CF731E:选一个长度大于 1 的前缀,计算和 S,用 S 替换这个前缀,当前人物得 S 分。两人都想要最大化自己的分数-对手的分数,求分数差值。先转为前缀和。正着不好维护,考虑钦定先手第一个选的位置,这样后手最大化的策略是确定的(后手-不计开始的值的先手 是原来的状态,先手取值是定值),所以倒着转移即可。注意长度要大于1,所以先手至少取到 2 个。
平局不维护/如何在环上进行博弈转移
很多时候我们会涉及到图上博弈。一般来说如果此时博弈的图是一个 DAG,则会方便很多。但是如果有环呢?
我们这时候可以考虑记录每个点的出度(也就是在反图上的入度),由于博弈决策在时间上是无前效性的,我们考虑如果可以一直在环上面跑的话,那么当前决策是不可到达的。我们记录后续的原图上可达的点的数量,也就是一个点的剩余度数,如果后续的所有节点全部被处理了,也就是可以到达目标节点了,则后手(最大化总费用)不能选择不可到达节点转移了,我们就只能让当前后手节点转移了。在此之前我们只是对于当前决策 chkmax。
对于先手(最小化总费用)我们考虑直接用最短路即可。这样我们可以保证转移是最优的,并且不会在环上面一直跑。最后如果起点先手不可到达一个反图入度为 \(0\) 的节点(无法操作)则可以跑进环里不出来。反之就是最小距离。
这个方案的本质就是倒着转移的时候一个环等价于转移到不可到达那些无法操作的节点的节点。
AT_abc261_h(两遍):
卡点:不会环上转移博弈状态。
设 \(dp(u,0/1)\) 表示点 \(u\) 先手/后手的费用。首先如果是 DAG,则后续节点交换前后手作为状态即可,也就是 dp[u][0]=min(dp[u][0],dp[v][1]+w),dp[u][1]=max(dp[u][1],dp[v][0]+w)。
但是有环。考虑按照上面的方法转为最短路上操作。
后续:环和 SCC 不一样,看到环不能直接缩点。
区间 DP
这个东西的维数一般非常高,一般 \(O(n^3)\) 起步。
区间填色
P4170(一遍,再记忆):首先把一个区间分成两个是常见的。其次我们考虑怎么维护一个区间的填色。发现只要 \(s_i=s_j\) 就可以操作,并且操作不会使得答案更劣(两次端点操作变一次),因此我们把对左端点或者右端点去操作的范围扩展到另一个端点,因此和忽略左端点或者右端点的区间的答案相等。
插入 DP
插入 DP 是一种计数类的 DP,主要用于合法排列的计数。我们每次按照下标或者值域(一般是值域,下标插入是在数轴上面当值域)插入当前数(我们只刻画当前的数在排列里面的相对大小,因此如果相等的话原本序列相等的之后的数加一就可以)把值插入到序列里。最后再去计数。算是比较进阶的 DP。时间复杂度一般是 \(O(n^2)\) 起步。计数题最重要的是刻画每个点被计算了多少次而不是对于每个方案计数。
普通插入 DP
可能和连续段一点关系都没有。
刻画相邻两个数的大小关系
AT_dp_t(两遍):卡点:此前只做过连续段 DP 的板子,没有反应过来。
设 \(dp(i,j)\) 表示当前已经考虑序列上的下标 \(1 \sim i\),而第 \(i\) 个位置插入的值在序列的相对大小是 \(j\) 的方案数。如果 \(s_{i-1}\) 是小于,则 \(p_{i-1}<p_i\),也就是 \(\sum_{k=1}^{j-1} dp(i-1,k)\)。如果 \(s_{i-1}\) 是大于,则 \(p_{i-1}>p_i\),上一个数在原序列上的相对大小是大于等于(等于的话就先全部加一再插入)现在的,也就是 \(\sum_{k=j}^{i-1} dp(i-1,k)\)。每次前缀和优化就可以到达 \(O(n^2)\)。
ABC209F(两遍):卡点:不会刻画连续段 DP 里面的贡献计算。
对于这种最优化贡献的计算的限制,考虑提前处理掉之后转换为更为简单的排列上的限制,例如 \(w(P_{i-1}) \circ w(P_i)\) 这样的二元关系,之后去计数就会方便很多。
对于三个数有关的性质,考虑只计算其中两个数对答案的贡献。例如本题里面的 \(H_{i-1},H_i,H_{i+1}\) 三项。移除后面两个的时候顺序分两种。
先移除 \(H_i\),则总费用是 \(H_{i-1}+H_i+H_{i+1}+H_{i+1}+H_{i+2}\)。而先移除 \(H_{i+1}\) 的总费用是 \(H_i+H_{i+1}+H_{i+2}+H_{i-1}+H_i\)。删除公共部分之后我们希望增加的贡献更小,因为增加的是另一个值的贡献,我们相邻两项删除大的必定更优。则我们看 \(H_{i}\) 与 \(H_{i+1}\) 的大小关系判断谁会先删除,而如果相等就可以两个顺序都删除。令最后的 \(P_i\) 为删除时间。则变换为左右的大小关系,也就是上面那一道题目。
ABC282G(看了 Hint 一遍写完):设 \(dp(i,j,k,l)\) 表示 当前位置是 \(i\),相似度是 \(j\),排列 \(A\) 的第 \(i\) 个填写了 \(k\),\(B\) 的填写了 \(l\) (相对大小)的方案数,而转移和上面那两道题是类似的。是 \(O(n^4)\) 的。初始状态是 \(dp(1,0,1,1)=1\),因为只填写了一个数(我们不在乎当前具体填写的是什么,我们只在乎当前排列里每个数的相对大小,所以这里就是一)。
树上刻画偏序关系
P4409(两遍):卡点:不会考虑子树里面具体选了什么。
首先限制是一棵树。但是树上节点权值有偏序关系并且父子偏序关系可能是反向的。
考虑序列上的连续段 DP 是在把当前元素和上一个序列的合并。于是我们考虑把当前点 \(u\) 已经处理完的序列和 \(u\) 的儿子 \(v\) 进行合并。就是树上背包式的合并。那么为什么树上背包的复杂度是正确的?因为假设子树大小极限是 \(m\),当前子树大小为 \(k\),则暴力合并是 \(O(mk)\) 的,而暴力合并完之后应该是 \(m+k\) 个,但是极限是 \(m\),所以会减去 \(k\) 个,那么由于一共只有 \(n\) 个点,所以操作最多执行 \(\frac{n}{k}\) 次,则总的复杂度还是 \(O(nm)\)。记 \(F(i)\) 表示当前节点是 \(u\),合并完儿子 \(v\) 之后的新的 \(f(u,i)\)。记 \(f(u,i)\) 表示当前 \(u\) 排在 \(u\) 子树内相对大小第 \(i\) 个的方案数。由于我们仅仅刻画了相对大小,我们并不知道新的 \(F(i)\) 当中 \(i\) 前面具体有哪些是原本在 \(f(u,j)\) 当中的,在后面的又有哪些,所以要组合数计数。记 \(s\) 表示合并前的子树大小。那么按照出现次数当偏序关系。\(u<v\) 时:\(F(i)=\sum_{j=1}^i \sum_{k=i+1-j}^{siz(v)} \binom{i-1}{j-1}\binom{s+siz(v)-i}{s-j}dp(u,j)dp(v,k)=\sum_{j=i-siz(v)}^{i}\binom{i-1}{j-1}\binom{s+siz(v)-i}{s-j}dp(u,j)\sum_{k=i+1-j}^{siz(v)}dp(v,k)\)
前面的限制是为了让组合数合法。转换之后可以前缀和优化。
\(u>v\) 时:\(\sum_{j=i-siz(v)}^{i}\binom{i-1}{j-1}\binom{s+siz(v)-i}{s-j}dp(u,j)\sum_1^{i-j}dp(v,k)\)。
这样所有的方案自然是合法的。组合数第一个是原本在前面现在还在前面的,第二个是原本在后面现在还在后面的。
偏序个数计数
ABC267G(两遍):卡点:对于值域插入不会理解。
设 \(f(i,j)\) 表示值域上面前 \(i\) 个(元素,不是值)插入,也就是从小到大插入,有 \(j\) 个上坡的方案数。考虑 \(a_i\) 插入在什么地方不会增加。记 \(a_i\) 排序之后前面有 \(s_i\) 个数和其相同。则插在最前面,\(s_i\) 个相同数的后面,以及 \(j\) 个上坡(原上坡的右边那个比当前节点小,不会增加)插入是不会增加的。所以有 \(s_i+j+1\) 个不会增加,而 \(i-s_i-(j-1)-1\) 个(注意原本的上坡会少一个)会增加。
YukiCoder 93(两遍):卡点:不会刻画具体值的方法。
求不存在排列当中相邻两项绝对值相差(大减小)是 \(2\) 的排列的个数。连通块的容斥计数是不可用的。还是按照值域插入。插入 \(i+1\) ,考察其对答案的影响。注意到 \(i-1,i-3\) 连续的话可能不会新增段,因此我们设置 \(dp(i,j,0/1,0/1)\) 表示当前有 \(i\),连续个数是 \(j\),\(i\) 和 \(i-2\) 是否相邻,\(i-1\) 和 \(i-3\) 是否相邻。维护第一个 \(0/1\) 是为了继承用。一共三种(减少不变增加连续点对个数)总计十六个转移。
连续段 DP
概论
-连续段 DP 用来解决有类似“插入”现象的 DP 计数问题。或者说,问题的求解和当前状态转移时插入的数的具体信息密切相关,且插入时只考虑插入在连续段的两端的情况会使得问题更为简单。并且连续段和排列集形成双射(结果的排列和连续段一一对应)。此时我们设 \(dp(i,j)\) 表示已经有 \(i\) 个元素并组成了 \(j\) 个连续段的方案数。
连续段 DP 一般有三个操作。下面展示的是对于连续段组合(不考虑顺序) 的计数方法。考虑新加入一个元素时会发生什么。下面的 \(\gets\) 表示转移路径而不是赋值。
不考虑连续段变化情况而只是加入某一个连续段
此时有 \(j\) 个连续段可以加入。因此有 \(j\) 个可能。
特别注意处理排列而不是组合计数的时候插入到首尾两端的排列是不同的,后面转移的值要乘 \(2\)。
把点拿出来单独开一个连续段
考虑挑选一个连续段之间的空位来加入这个段。由于 \(j-1\) 个连续段,算上两边的空格总共有 \(j\) 个可选位置(我们根本不考虑这个可选位置具体上存不存在,因为只要有可能合法方案我们就计数)。
加了一个点在两个连续段中间使得两个连续段直接合并了
此时可以合并两个连续段的中间的空格有 \(j\) 个,因为原本有 \(j+1\) 个连续段而且不能取两边的空格。所以
这里乘的系数是点 \(i\) 选择位置造成的贡献,而不是合并的连续段位移导致的贡献。
当然上面的转移过程只是一个板式,具体问题还得具体分析。
例题
CF1515E(两遍):
卡点:由于不知道怎么处理序列插入(点 \(i\) 插入在第 \(j\) 个位置)而直接想不出来。
我们给排列上手动的点和手动的点同时带动的自动的点一个当前操作数量的编号,则有编号的是若干个连续段,并且当前插入一个手动点是插入操作,会改变连续段形态。考虑连续段 DP,设 \(dp(i,j)\) 表示 \(i\) 个元素 \(j\) 个连续段的方案数并钦定点 \(i\) 是手动点。考虑三个操作。
- 直接加入点。
这个操作事实上是两个。
第一,我们可以选择只加入手动点 \(i\)(此时我们不考虑手动点带动自动点的情况),那么 \(dp(i,j) \gets dp(i-1,j)\times 2 \times j\),加倍是因为插入前面和后面是两个排列。
第二,我们可以选择让 \(i-1\) 成为自动点,这样就是从 \(i-2\) 转移,并且由于新插入的序列必然是 \([i,i-1]\)(先手动后自动),我们直接加入两个且不用考虑排列内部交换的问题。\(dp(i,j) \gets dp(i-2,j)\times 2 \times j\)。
- 新开一个块。
这个没有自动点的问题。因为如果加入自动点就一定会和另一个块连接,所以不用考虑两个空格的问题。毕竟有合并块操作。
直接 \(dp(i,j) \gets dp(i-1,j-1) \times j\)。
- 合并两个块。
由于如果一个点的相邻两个点被选中则节点本身自动被加入且无法操作,我们不考虑加入一个点就直接使得相邻的两个连续段合并的情况。
那么合并两个块就剩下两种情况。
第一,两个连续段中间空了两格。形如 XXX..XXX。这样我们手动选中一个空格,则另一个空格自动被选中,两个块就直接合并了。\(dp(i,j)\gets dp(i-2,j+1)\times 2 \times j\)。这里所有空格都有可能是两个。由于选中哪个空格会影响排列的形态,我们乘了二。
第二,两个连续段中间空了三格,形如 XXX...XXX。这样我们选中中间的那个空格则两边的两个空格的相邻节点都在连续段内,也就自动被选中。\(dp(i,j) \gets dp(i-3,j+1) \times j\)。这里只能选中间节点,不用乘二。
初始的时候是 \(0\) 个连续段,所以 \(dp(0,0)=1\)。最后因为所有节点都被选中,答案只有一个连续段就是 \(dp(n,1)\)。而转移就是分类计数的加法。
可能并不是按照纯粹的范式思考的题目
ABC313H(两遍):卡点:把两个序列的限制合并为一个了。
对 \(b_i\) 降序,\(\min(a_i,a_{i-1})\) 降序,逐个比大小即可判断合法性。设 \(dp(i,j)\) 表示值域上 \(i\) 个数(从大到小插入)序列上 \(j\) 个连续段的方案数。则此时 \(b\) 的判定使用的是 \(b_{i-j}\)。那么我们按照合并,延续,新增三个方向转移。
P9197(两遍):卡点:不明白怎么值域降序插入以及我们维护的是什么。
我们考虑插入 \(i\) 个点(从大到小),当前连续段个数 \(j\),当前点插入之后维护的折线段(维护的是若干个线段)的费用(这样不会出现负数)\(k\),以及当前最左边和最右边是否被占领的信息 \(p,q\)。则新的 \(K=(2j-p-q)\times (a_{i-1}-a_{i})\).
我们维护的东西是把 \((i,P_i)\) 作为点插入在平面直角坐标系上,然后统计线的纵坐标的长度和从上往下动态统计长度和。
树形 DP
可以用于维护树上的信息。
树形 DP 内相邻的状态只有两种:父亲和儿子。
我们通过只能在父亲和儿子上维护信息,来保证时间复杂度正确。
如果维护祖父,则送给大家一句话:爸爸的爸爸叫爷爷。
我们维护的「当前状态」是什么?
P2279:一开始设 \(DP(i,0/1,0/1)\) 表示点和点的父亲、祖父是否被覆盖。但是这样是错误的,因为可能父亲和祖父都不被封锁时有孙子封锁了点本身,儿子封锁父亲,点本身的旁系封锁了祖父。这样就和点本身没有任何关系了。
所以这样缺乏信息。从上往下还有旁系问题,考虑从下往上转移。设 \(DP(u,0 \sim 4)\) 表示封锁点的祖父身子孙的点所在层和其下所有节点的最小封锁点。
我们称一个节点被选中为「被封锁」,称一个节点距离两步以内存在被封锁的点为「被控制」。
由于我们从下往上,所以我们能封锁祖父就必然要封锁父亲,所以 \(DP(u,i) \ge DP(u,i+1)\)。
考虑当前状态与此前状态相比多出现了什么情况。那就是满足状态的极限为本次状态,而不是上次状态的情况。因为本次状态是上次的子集。我们开始考虑。以下省略和上一个状态取较小值的过程。
对于恰好封锁祖父,因为本身子树内能封锁祖父的只有本节点,所以本节点必定封锁。至于儿子们,本节点不能封锁儿子的孙子,所以增加每个儿子能封锁孙子的最小控制节点。(尽可能减少控制节点)
考虑恰好封锁父亲。由于恰好封锁父亲的点就是儿子,此时必定有一个儿子被封锁导致父亲被控制。这个儿子不能控制其余儿子的儿子,所以其余儿子应当至少存在控制他们的儿子的节点。
至于恰好封锁本身,则本身被孙子封锁,由于我们不能直接维护孙子的信息,这里等价于仅存在一个儿子使得其父亲被封锁(就是本身,原理见上面的粗体)。由于孙子不能封锁本节点的其他儿子,我们增加使得其他儿子被控制的最小封锁节点。
如果恰好封锁儿子,等价于所有儿子恰好被封锁本身。
如果恰好封锁孙子,等价于所有儿子恰好被封锁儿子。
综上,我们应该全面考虑满足条件的情况,而不局限于常规的情形,通过引入一些信息和替换枚举顺序来减少分类讨论的情况。
贪心结论与 DP
贪心的本质是钦定了 DP 的转移路径来减少转移的状态数。
有的时候我们通过贪心的结论(本质是钦定信息)来减少状态的量,优化设计和转移。
CF77C:从根节点出发,可以到达相邻的还有剩余贡献的节点并扣除这个节点的一个贡献。求从根节点出发回到根节点的可删除贡献的最大值。
发现对于非根节点,我们需要保留一个贡献用来回到父亲。
由于我们可以重复探测一个儿子,我们并不知道每一次我们探测儿子时作出了多少个贡献。难道我们要维护每个点的剩余贡献并进行树上背包?这很不现实。
我们考虑一种策略,就是我们要压榨儿子的子树的贡献的时候就一次压榨干净,然后如果儿子还有剩余贡献,就只在儿子和父亲之间反复游走来加贡献。
这样我们就只需要维护每一个节点子树的总贡献 \(dp(u)\) 和每个节点本身的剩余贡献 \(k(u)\)。
由于我们到达点 \(u\) 时就会扣除贡献,我们先给 \(dp(u)\) 加一。(注:我们是在遍历子树回溯的时候扣除本节点贡献的,所以我们在最后一步的时候计量贡献就直接向上)我们先统计每个儿子的剩余贡献的和(注:此时儿子的子树和孙子已经走完了,所以 \(k(v)\) 就是儿子本身的剩余贡献),并从大到小给每个儿子的子树的贡献排序。从大到小加入每个儿子子树的贡献(注:此时本节点本身也会计算一个贡献)直到儿子加完,或者本节点的剩余贡献已经用完。最后,我们在本节点和儿子之间游走。注意到每个儿子在游走的过程中是等价的。我们直接对答案加 \(2\min(sm,k(u))\)(我们在本节点和儿子处都会计算贡献)并扣除本节点经过的贡献 \(\min(sm,k(u))\)。
当然根节点本身并不会在初始计算贡献,\(dp(root)\) 减一即可。
树上计数
CF629E:树上连一条边使得 u,v 两点在一个环内,求环的期望长度。
期望转计数,就是包含 u,v 的路径总长除以路径个数加一。
如果 u,v 有祖先关系,不妨设 u 为 v 的祖先,则设 v 的祖先中为 u 的儿子的点为 s,此时 u 内无 v 子树 就是 总点去掉 s 子树,而 v 子树 还是 v 子树。换根维护路径总长和路径条数。注意:\(\sum_{i=1}^n \sum_{j=1}^m A_i+B_j=m\sum A_i + n \sum B_j\)。
没有祖先关系就是正常的子树维护。
树上常见信息维护
树上直径的合并
我们知道两个树合并后直径的端点一定是原本两个树直径的四个端点选取两个。
P10238(两遍):卡点:没有先合并非询问边,导致考虑连通块的直径合并,没有合并原本的两个直径,没有取全局的直径最大值。
删除边很难维护,考虑时间倒流直接合并。先合并非询问边,之后倒着合并询问边。
P4103 的三个 DP 见下。
树上关键点距离的和:类似于树上背包,记录在推到当前儿子之前的总贡献(个数+路径和)与当前儿子的贡献,当前儿子和 u 算贡献,和此前的儿子算贡献。
树上最近关键点对:记录子树内到子树根最近的距离,转移很方便。
树上最远关键点对:记录子树内到子树根最远的和非严格次远的距离拼起来(类比直径)
树上背包——如何维护树上的不交链的信息?
我们知道,如果只维护树上的一条链的信息,我们可以考虑类似于点分治的办法,记录完全在子树内的最优链和以子树根为端点的最优链来处理。但是如果需要若干条不交链,上面这些信息就不够了。
AT_abc416_f(三遍):
卡点:第一遍,只会维护选一条链的情况。第二遍,数组开小,且 DP 的初始值应该是在搜索到的时候设立初始值而不是统一设定初始值,而且由于每个儿子都会和其前面的当前节点的儿子的子树构成对当前节点的贡献,我们应该在每一个儿子的临时数组处理完毕之后就直接把贡献写进 DP 数组。
由于链最多五条,我们可以,并且应该,在状态里记录当前选取的链的个数。而且由于子树根一旦被使用就不能再用于合并,所以子树根被使用但是不是作为端点的情况要额外标记。于是乎,我们设 \(dp(u,i,0/1/2)\) 表示当前子树根是 \(u\),子树里面已经选取了 \(i\) 条链,\(0/1/2\) 表示下面几个状况:
\(0\) 表示 \(u\) 当前尚未被使用。
\(1\) 表示 \(u\) 当前已经被使用,并且是某一个链的端点。
\(2\) 表示 \(u\) 当前已经被使用,但是并非某一个链的端点。
我们发现同时以一个点为端点的链是可以合并的,链端点的父亲也是可以直接和链端点合并的。于是转移有下面几个情况。
我们选择不合并。则 \(dp(u,i+j,p)=\max(dp(u,i,p)+dp(v,j,q))\)。其中 \(i,j,p,q\) 在对应范围任意取值。因为不合并所以链的个数简单相加,而 \(u\) 的使用情况不变。
我们选择直接把 \(u\) 本身加在以 \(v\) 为端点的某个链上。则此时 \(u\) 子树内此前出现过的和 \(u\) 没有任何关系的链也加在子树里面。因此 \(dp(u,i+j,1)=\max(dp(u,i+j,1),dp(u,i,0)+dp(v,j,1)+a_u)\)。
我们选择把一个 \(u\) 子树内的以 \(u\) 为端点的链和 \(v\) 子树内的一个以 \(v\) 为端点的链合并。这样两个段数简单相加之后,因为原本的两个链会合成一个(此时不能再取两个链,否则事实上没有合并),因此 \(dp(u,i+j-1,2)=\max(dp(u,i+j-1,2),dp(u,i,1)+dp(v,j,1))\)。
当然由于我们每一次是把当前的 \(v\) 和此前求出的 \(u\) 的子树去增加,我们记录一个临时数组来先存储答案。最后再把此前的子树和这个取较大值。
由于状态 \(1,2\) 都有点被使用,我们设置 \(dp(u,0,0)=0,dp(u,1,1)=dp(u,1,2)=a_u\)。毕竟答案肯定不会小于这个。答案就是对于点 \(1\) 子树的所有状态取最大值。
后续
在树上维护信息的时候,如果选取总数有一定限制,我们可以考虑运用刷表法(这样就不用维护总和,可以更好写),维护当前选取个数和可能对当前贡献造成影响的状态的数量。这就是树上背包。
同时,树上背包还教会了我们对于两个子树之间的贡献,考虑计算当前子树和此前出现的子树的贡献。此时可以开一个临时数组。
CF633F(一遍):维护两个不交链,就是上面的方法。
换根 DP
一般可以换根
无根树的操作如果和全局内删除某些子树的贡献有关的考虑换根。大致流程:①先求出以 1 为根的树的子树信息。②对于 u 删除其儿子 v 的贡献。③ v 重构 DP,并加上 u 此时的贡献(注:u 是当前的根,所以 u 子树撤销 v 的贡献即可)。④ 遍历到 v。⑤恢复本次操作前的数组。
CF1385F:我们考虑操作轮数和儿子能否被删成单点有关。设 \(dp(u)\) 表示 \(u\) 子树操作轮数,\(lf(u)\) 表示可以删为单点的儿子数量,\(able(u)\) 表示子树能否删为单点。然后是换根的流程:就是上面序号过程。
不能换根?直接 SGT 维护子树!
CF337D(惊人的六遍):起初想换根,发现维护子树上关键点到子树根的最大值是不能拆除换根的儿子的贡献的。所以这时候只能对于子树内外的最大距离分开维护。
发现此时用 DP 做就并不好维护了,因为存在旁系只有一个的情况,以及不好证明在最大距离转移点就是当前儿子的时候使用非严格次大值还是严格次大值。
结果是可以直接上 SGT。发现当我们从父亲换根到儿子的时候儿子的子树内到根的距离减小 \(1\),其余位置增加 \(1\)。子树加全局 MAX 为什么不用线段树呢?
怎么让不是关键节点的点不作贡献呢?我们没有必要存储关键节点并在上面二分,因为浪费时间。考虑直接给非关键点初始权值为 \(-\infty\),关键点为到 \(1\) 的距离。
卡点:在换根的时候如果不能直接通过算术转移,就考虑能不能用数据结构维护儿子子树内和子树外的贡献变化,因为换根的本质就是维护子树内外的变化量。
肩膀上一定要扛个脑袋
加法奇偶性等于异或奇偶性
为什么用这个标题呢?考虑换根的本质是维护子树内外信息的变化量,那么也就是说,在 \(O(n \log n)\) 甚至 \(O(n \log^2 n)\) 允许的前提下(一般来说出题入不会开到 \(10^7\),不然输入都是问题),我们可以用线段树无脑维护某些信息,并且这样的话我们甚至可以知道两个点的具体路径有关信息。很多时候线段树是无脑的好帮手,如果你对 DS 足够熟悉的话。
CF1060E(一遍):考虑儿子互相可达以及祖孙可达意味着什么。对了,就是意味着距离为 \(2\) 的路径的距离全部变成 \(1\),也就是说,原本 \(dis(i,j)=x\),则现在 \(dis(i,j)=\lceil \frac{x}{2} \rceil=\frac{x+[x \bmod 2 =1]}{2}\)。所以我们只需要求 \(\sum_{i,j} dis(i,j)\) 与 \(\sum_{i,j} [dis(i,j) \bmod 2 =1]\)。前者直接线段树求和,对于后者,加奇数等价于子树反转,也是无脑维护即可。总复杂度 \(O(n \log n)\),足够通过。当然,考虑正经 DP。前者 DP 是常见的。考虑到后者是完全等价于 \(\sum_{i,j} [(dep(i)+dep(j)) \bmod 2 = 1]\)(因为 LCA 部分用两次被删除了,众所周知,加法奇偶性等于异或奇偶性),甚至直接用 map 维护?
状态压缩
\(O(1)\) 维护是否有相邻位置相同
S&(S<<1) 为 \(0\) 就没有。
P1879(一遍半):
卡点:数组开小,忘记取模。
简单状态压缩题。
BONUS:维护是否有隔一个位置相同
同理。
P2704(一遍):维护本行状态和上行状态即可。合法的行状态最多六十个,无脑维护。
位运算各位相互独立,我们更多时候将其视为集合运算,直接拆位考虑。
异或用 01 Trie,与和子集有关。
专题:异或维护偏序关系
我们知道,01 Trie 的一个节点可以表示若干二进制前缀相同的数的集合。这也就意味着我们可以用 01 Trie 描述偏序关系(一个节点的两个子树的含有的串的个数(不是大小) 可以维护)。
CF1416C(三遍):
卡点:第一,使用暴力求逆序对被卡了。第二,01 Trie 维护逆序对是有时效性原则的,也就是每次到了一个【要插入 0 儿子(不是当前节点是 0 儿子)】的节点就把逆序对个数加上当前 1 儿子里面数的个数。而由于是在线插入还要维护儿子信息,考虑 DFS 插入,顺带维护下每个节点的深度。
一开始考虑的是把每一位单独考虑,如果当前异或之后逆序对个数更少就直接异或。但是这样是 \(O(n \log n \log V)\) 并且常数极大。
位运算考虑把每一位拆开考虑。我们发现如果某一位的偏序个数小于逆序对个数我们就把这一位反转掉(也就是异或的这一位是 \(1\))。考虑只维护前缀相而当前位置不同的节点,也就是字典树上面前缀相同的。而此时偏序和逆序对的总数就是两个儿子里面数的数量的乘积。
由于我们是对于深度考虑的,我们还要把每个节点的逆序对和总的数对个数贡献进节点深度(从最高位也就是 \(30\) 开始计)的总答案。
专题:与起来等于 0
CF2119C:见【构造】。
CF165E(两遍):
卡点:第一次做发现答案是 \(a_x\) 取反之后的子集,但是枚举子集判断存在性是 \(O(n2^{22})\) 的,会直接炸。然后去写 Trie 树+BFS,调不出来了。
事实上并非 Trie 树(没有表明串的总字符数),子集的总贡献的求法也并不需要枚举子集。考虑子集前缀和。我们知道枚举到一个状态的时候,其数值上更小的状态已经全部枚举完了,这自然包括其子集。于是我们只需要枚举其上一个 popcount 的状态相比于本身少了哪一位并把缺失了位数的状态的贡献转移到本身即可。这样子集的和就不需要通过枚举子集来得到了。
我们只需要找到存在方案的状态的方案,并将其转移到当前状态即可。存在一个状态即可转移。初始时默认 \(a_x\) 这一状态存在,方案为其本身。这样子集求和之后答案就是 \(a_x\) 取反后的方案了。注意位运算取反可能会导致符号位的问题,我们直接异或一个全 \(1\) 数(\(2^{22}-1\)) 即可。
后续:如果看到答案是来自某个子集的话,直接上子集前缀和而不用枚举子集。
专题的专题:你把 \(\min,\max\) 和 01 串联系在一起想想
一般如果我们希望这两个东西同时出现,我们就会二分答案或者二分一个数 \(val\),把 \(<val\) 的数设置为 \(0\),把大于等于 \(val\) 的数设置为 \(1\)。然后就有 01 串了。
CF1288D(一遍):\(m \le 8\),这个小得离谱的数据已经明示我们要状态压缩了。但是 01 串在哪里?考虑答案是有单调性的(有最大答案),直接二分答案,看看答案能不能 \(\ge mid\)。把数组按照上面这个方法压缩成 01 串直接状态压缩。由于最小值都要比 \(mid\) 大,我们最后选出来的两个串或起来一定全都是 \(1\)。但是这个性质似乎不是很好维护,我们考虑一个很熟悉的东西——与起来为 \(0\)。对的,这就是把原串取反可以得到的。所以这里小于 \(val\) 的设置为 \(1\) 即可。最后直接来一个子集求和,反串子集里面存在方案即可。和上面那道题一样,有可以取到子集的方案就直接转移了。
子集前缀和与容斥
状态压缩本质是集合运算。
容斥的时候我们反向设置状态,也就是设不满足某性质的点个数至少有某个的方案数。
CF449D(两遍):
卡点:想到子集前缀和,但是不会计算容斥系数。也不会设计容斥状态。
我们设 \(g(i)\) 表示至少有 \(i\) 个数没有取到零的方案数,那么答案自然就是 \(g(0)-g(1)+g(2)-g(3)+...\)。
考虑怎么去算 \(g\)。和子集计数有关系,考虑怎么和子集前缀和联系在一起。设 \(f(i)\) 表示 \(a_j\) 包含 \(i\) 的 \(a_j\) 的个数(注意 \(i\) 是 \(a_j\))的子集。考虑为什么这么做。这么做了之后,只要个数里面有一个被取到,那么就一定会让 \(i\) 里面为 \(1\) 数位为 \(1\),则必然满足至少 \(P=\operatorname{popcount}(i)\) 个不为 \(1\),则对 \(g(P)\) 的贡献就是 \(2^{f(i)}-1\)。因为其所有非空子集都是可取的。
考虑怎么算 \(f(i)\)。这里涉及到子集计数,考虑子集前缀和,但是这一次是子集计自己的贡献。由于我们不希望一个点会在子集里面被多次计数,我们考虑正着推(参考 01 背包和完全背包的区别)。由于我们不知道每次状态缺少哪一位,我们要先枚举缺的位后推导。
后续:容斥是反向设计状态,推完容斥式子之后直接去想想每个 DP 状态对容斥状态的贡献作为容斥的内容。
子集距离
P2396:事实上子集的距离可以通过把点权直接存在子集上点的位置来实现快速计算,可以直接预处理子集全选得到的距离。
关于子集的枚举
枚举所有集合和它们的子集的时间复杂度为 \(O(3^n)\)。
证明:
考虑其组合意义。我们假设当前方案里面有 \(i\) 个 \(1\),则子集内这些 \(1\) 可能为 \(0\) 或 \(1\)。而这些方案的数量等于在集合中选定 \(i\) 个位置使得它们为 \(1\) 的方案数。
for(int T=S;T;T=(T-1)&S);//T 为 S 子集
子集枚举题知名技巧
求点集内连边恰好出现一个连通块的情况个数。
设 \(f(S)\) 表示点集 \(S\) 连成一个联通块的方案数,\(g(S)\) 表示 \(S\) 内连出若干个联通块的方案数。
注意到 \(g(S)\ge f(S)\)。并且二者的差为多于一个联通块的情况数。
\(x\) 为 \(S\) 内一个确定的点,定义其为 \(S\) 内的最小点。
后面的原理就是 \(T\) 是一个联通块并且 \(S-T\) 内存在至少一个联通块,总共就存在至少两个了。并且由于 \(x\) 所在块不重复大小,所以计数是不重不漏的。
如果是环呢?\(g\) 怎么转移?考虑断环为链,钦定链首为子集内最小点,链尾为 \(x\),可以每次找一个新的链尾转移。
甚至可以直接把两步合并,合成环时找一个更小的点当头。
特别专题:子集 DP
AT_dp_u(一遍):如果 \(i,j\) 在一组则得到 \(a_{i,j}\) 分。一对得分只算一次。求最大的得分。设 \(dp(S)\) 表示选取集合为 \(S\) 的最大化费用。那么我们找到当前子集 \(T\),而且当前子集是 \(T\) 是在同一组里面的。则 \(dp(S) \gets dp(S - T)+w(T)\)。其中 \(w(T)\) 表示 \(T\) 连边的费用。我们可以每次枚举集合,加上 \(\log_2{\operatorname{lowbit}(T)}\) 也就是最低位和去掉最低位置的余下的部分构成的点对的贡献与余下部分内部的贡献之和。
P3959(两遍):卡点:没有设计过同一层加入的点集直接枚举为当前子集的情况。
特别注意第 \(i\) 层新增的费用是一致的。所以设 \(dp(i,S,T)\) 表示当前层数,当前连通点集,以及当前层的点集。而由于我们不可能让点和 \(S-T-R\)(\(R\) 是上一层)之外的点连边,不然层数不对,一定会更优而更早被考虑的。所以我们直接考虑 \(dp(i,S)\) 是对的。也就是 \(dp(i,S) \gets dp(i-1,S-T)+i \times w(S-T,T)\),后面那个权值是两个集合之间全部连边(如果有)的费用。以上所有 \(T\) 是 \(S\) 的非空子集。
考虑快速计算后面的权值。发现我们只要记录 \(T\) 与全集的补集的子集即可。也就是 \(O(3^n)\) 快速处理。
ARC078D(两遍):暑假的时候讲过,但是思路不对。没有注意到链上面挂的东西是没有任何限制的。
删除边一般是很难维护的,考虑我们保留一个边权最大的图出来。而我们最多保留出来的是一条链。而链上面挂的可以是任何连通图,只要这个图不和链上的其他节点有连边即可。和生成树一点关系没有。
那么我们考虑维护当前链上最右边的节点(我们从左向右插入节点),就是设 \(dp(S,i)\) 表示当前的连通点集以及最右边的节点。
ABC321G(两遍):讲过但是不会处理当前集合是一个大连通块的方案数。
期望是计数除以 \(M!\)。如果对当前集合是一个大的连通块这种限制,我们一般是考虑容斥的。
设 \(g(S)\) 表示 \(S\) 是独立的(没有和集合外面的节点连边)且 \(S\) 里面至少存在一个连通块的方案数。\(f(S)\) 表示 \(S\) 恰好是一个大的连通块的方案数。记录当前集合里面红色端子的个数是 \(P_S\),而蓝色端子个数是 \(Q_S\)。如果一个集合的两个颜色的端子数量不等则不可能是独立的。则此时 \(f(S)=g(S)=0\)。反之则 \(g(S)=P_S!\)。
那么求 \(f(S)\)。考虑钦定当前最小点 \(x\)(和最低位置有关,见上)在某个连通块 \(T(T \sub S)\) 内,则 \(f(S)=g(S)-\sum_T f(T)g(S-T)\)。那么这个连通块集合本身会出现 \((n-P_S)!\) 次,直接乘起来。
P8189(两遍):卡点:没有把排列转换为置换环。
看到排列考虑 \(i \to P_i\) 连边。在本题里假设每个人最后得到的礼物是 \(P_i\),代表人把自己的礼物给下一个人。那么由于每个点都只有一个入边和一个出边,最后必然是若干个置换环。
设 \(f(S)\) 表示恰好把 \(S\) 划分为一个环的方案数。考虑环是一个链把链首尾连接的产物。那么我们尝试去刻画一条链。设 \(g(S,i)\) 表示当前图的点集是 \(S\),当前链头为最小点 \(x\),尾部是 \(i\) 的方案数。枚举 \(j>x\),如果 \(j\) 作为礼物可以给到 \(i\),则 \(i \to j\) 是成立的。于是 \(g(S\cup\set{j},j)\gets g(S,i)\)。最后我们枚举每个可以 \(i \to x\) (注意不要反向)的 \(i\),把 \(f(S)\gets g(S,i)\)。但是我们求的是两个集合自由选择的方案数的积。记荷斯坦奶牛集合为 \(S\),则我们求的是 \(S\) 和全集 \(U-S\) 的自由选择的方案的积。设 \(h(S)\) 表示集合 \(S\) 自由选择的积。钦定 \(x\) 在某个子集 \(T\) 内部,而 \(T\) 是一整个环,则 \(h(S)=\sum_{T \sub S} f(T)h(S-T)\)。预处理合法的出点即可。
APC001F(两遍):卡点:没有见过树上路径异或的 Trick。
树上路径异或的常见的处理方法有两个。第一,记 \(b_u\) 表示 \(u\) 邻接边的权值的异或和,则每次对 \(u,v\) 两点的路径异或 \(k\) 等价于仅仅给 \(b_u,b_v\) 异或 \(k\)。全部为 \(0\) 时\(b_u\) 也是全部为 \(0\)。还有第二种方案,就是钦定一个根,记 \(c_u\) 表示根到 \(u\) 的路径异或和,而路径异或等价于给 \(c_u,c_v\) 异或 \(k\),同样也是全部为 \(0\)。这两种方法的本质是路径必定是一条链,所以只有两端的关联边是一条,中间所有点的关联边都是两条(对于方案二则类比树上差分),同时操作两条关联边就会互相抵消。
考虑把操作变成一张图。则如果对 \((u,v)\) 操作就连上边。考虑连通块的性质。发现每个连通块上面的边会在总异或和当中被两端的点各自异或一次,所以最后总的连通块的贡献会直接抵消,则一开始所有节点的权值的异或和就是零。那么我们刻画连通块的时候直接钦定合法的连通块的操作序列就是一棵树。每次剥离叶子就可行了。那么我们最后的操作次数的计算就是确定的。记连通块个数是 \(C\),则总的操作个数是 \(\sum_{\text{Connected Component } X } \#(X)-1 = n-C\) ,井号表示点的个数。那么我们要最小化操作次数,也就是最大化连通块的个数。令 \(S_x\) 表示 \(b_u=x\) 的 \(u\) 的个数。\(S_0\) 个零号节点可以直接孤立。而对于 \(S_v \gt 1\) 的节点 \(v\),最好直接先两两配对,这样会有最多的 \(\lfloor \frac{S_v}{2} \rfloor\) 个连通块。此时可能剩余一个节点。这样每个值最多剩余一个节点,而值的数量很小,也就意味着点的个数很少,可以直接状态压缩了。设 \(dp(S)\) 表示可以把 \(S\) 划分为若干个连通块的最大的连通块个数。如果 \(S\) 的异或和不为 \(0\) 就不合法,为负无穷,反之则为 \(\max(1,\max_{T \sub S} dp(T)+dp(S-T))\)。
P6097(模板,直接学习):首先不考虑没有交集这个限制,考虑或卷积。本着 FWT 的思想,我们考虑序列 \(A,B\) 的或卷积 \(C\),我们首先设计一个函数 \(f(A)\),把 \(A \to f(A),B \to f(B)\),然后 \(f(C)=f(A) \times f(B)\),并且可以便利地把 \(f(C) \to C\)。发现 \((\sum_{j \sube i} a_j)\times (\sum_{j \sube i} b_j)=\sum_{j \sube i,k \sube i} a_jb_k=\sum_{(j|k) \sube i} a_jb_k=\sum_{(j|k)\sube i} c_{j|k}=\sum_{p \sube i} c_p\)。定义 \(j \sube i \iff j|i=i\)。
于是我们得到了一个满足复合封闭的形式的函数 \(f(A)\),就是 \(f(A)_i = \sum_{j \sube i} A_j\)。这是一个经典的 SOS-DP,也就是高维前缀和的形式。我们首先枚举子集当中缺失的那一位,接着从小到大枚举所有集合,如果缺失的那一位存在就把它缺失之后的集合给转移过来。至于逆变换,就直接刻画为高维差分,也就是枚举位置之后从大到小枚举集合,减去之前的前缀和。考虑刻画没有交集这个限制。发现没有交集而并集确定可以刻画为 \(|i|+|j|=|k=i \cup j|\)。于是我们从个数角度刻画,记 \(f_{i,j}\) 表示 \(|j|=i\) 的 \(a_j\) 的值,而 \(g_{i,j}\) 表示同等条件的 \(b_j\) 的值。于是 \(c_k = \sum_{i=0}^n \sum_{j=0}^{2^n-1}\sum_{j|l=k}f_{i,j}g_{|k|-i,l}\),卷积之前首先做变换。
子集 DP 和容斥
有的时候方案数用容斥转移反而方便很多。可以用来辅助 DP。
ABC236H(三遍):卡点:不会刻画不等于点集,特判 LCM 爆 long long 炸掉了。实际上并不会炸,直接暴力维护即可。
\(a_i \ne a_j (i \ne j)\) 转换一下就是不存在一个点集 \(S\) 满足 \(\forall i,j \in S,i \ne j,a_i = a_j\)。刻画后面这个东西。发现后面这个东西是完全图上一个点集。可以很好刻画。对 \(S\) 的大小进行容斥。\(S\) 可以划分为若干个连通块 \(s_1,...,s_k\)。
则 \(Ans=\sum_{S} (-1)^{|S|} \prod_{i=1}^k \lfloor{\frac{m}{\operatorname{lcm}(s_i)}} \rfloor\)。因为所有的块满足是倍数且相等,则一定是 LCM 的倍数。
改为枚举连通块。\(Ans=\sum_{s_1 ... s_k}\prod_{i=1}^k \lfloor{\frac{m}{\operatorname{lcm}(s_i)}} \rfloor (\sum_{S \sube \text{两端点都在 s 内部的边集合} E(s_i)}[S \text{ 内部的边可以连通 } s_i] (-1)^{|S|})\) 。
考虑求后面那个东西。发现完全图上同一个个数的点集等价。设 \(f(n)\) 表示点数 \(n\) 的连通图的容斥系数的和,而 \(g(n)\) 表示点数 \(n\) 的图的容斥系数的和。
\(g(n)=\sum_{i=1}^{0.5n(n-1)} \binom{0.5n(n-1)}{i} (-1)^i = (-1+1)^{0.5n(n-1)}=[n=1]\)。钦定 \(0^0=1\)。
那么 \(f(n)=g(n)-\sum_{i=1}^{n-1} \binom{n-1}{i-1}f(i)g(n-i)=[n=1]-(n-1)f(n-1)\),也就是钦定一号节点在连通块内。
于是 \(Ans=\sum_{s_1 ... s_k}\prod_{i=1}^k \lfloor{\frac{m}{\operatorname{lcm}(s_i)}} \rfloor f(|s_i|)\)。
令 \(w(s_i)=\lfloor{\frac{m}{\operatorname{lcm}(s_i)}} \rfloor f(|s_i|)\).
考虑更加方便地枚举连通块。设 \(dp(S)\) 表示当前子集的可能的方案数。\(dp(S) \gets dp(S-T)\times w(T)\)。因为是分布计算方案的,用乘法。
LCM 直接 \(O(n2^n)\) 暴力维护减少特判。
背包 DP
背包的应用场景(贪心?背包?)
有一个序列 \(A\),选取一些 \(A_i\) 使得这些 \(A_i\) 的和为 \(S\)。
贪心的想法是类比进制位从大到小加。但是这样很容易 HACK:在序列 \([2,3,4]\) 内选数使得和为 \(5\)。如果这么贪心,选了 \(4\) 就直接桂霞了。但是答案是选 \(2,3\)。所以这样还得是背包。
我们知道背包的本质是卷积。那么我们直接把状态用 bitset 存储就能够维护可行性。事实上,\(dp_{i-w}\) 可以用 \(dp\) 左移 \(w\) 位来表示。
那么我们怎么记录转移路径呢?我们另外开一个 bitset 表示本次转移前后的 \(dp\) 的异或值。因为都是加一,所以如果异或后的值为一的说明在本次 \(dp\) 的过程里被加了贡献。直接遍历所有为 \(1\) 的位置即为 \(i\) 做了贡献的位置。
CF356D:考虑没有 \(s\) 的限制时我们怎么构造。发现可以直接构造一条链,从大到小排序,相邻的子树和大的作为父亲。考虑如何刻画总和限制。发现我们从链上剥离出一些点单独开链会导致总点权增加。并且所有的链的根的子树和就是总数。由于没有点可以做最大点的父亲,我们钦定它就是一个根。然后在其余的点内选取一些作为根,满足它们和最大点的子树和的和恰好为 \(s\)。由上面的 HACK,我们只能背包。\(70000\) 大小的数据,背包必然需要优化。除了 FWT(bushi),我们还有一个办法,那就是直接用 bitset 维护,发现 \(O(\frac{n^2}{\omega})\) 是可接受的。然后从大到小全部加到最大点的链里面就好。
背包计数的本质
背包计数的本质是一个多项式卷积。
对于一个物品 \(A_i\),若其有 \(t_i\) 中取值 \(A_{i,1} \sim A_{i,t_i}\),那么值的和为 \(M\) 的方案数为:
可以卷积去维护。对于 01 背包 每个物品多项式就是 \((1+x^{w_i})\) 而对于完全背包就是 \(\frac{1-x^{w_i+1}}{1-x}\)。
可以用生成函数维护,也可以实现可撤销背包。
可行性换方案数
背包可行性可用 bitset 维护少一个 \(\omega\),但是如果可撤销的话维护可行性就不好维护了。考虑改为方案数,这样是卷积(高维前缀和之类)就可以撤销了,只要判断方案数是否为 \(0\)。但是方案数很大,考虑转换为哈希值(取模),建议使用两个模数的双哈希以及用一些不常见的模数(例如 \(10^9+9,998442353,998244853\),还有一些著名的日期)。
MX-P110134(三遍):卡点:不会换方案数,以及没有离线。
换方案数之后先算总的方案,按照边所在的连通块分组离线,而每次删除某个块之后求询问。
数位 DP
数位 DP 一般解决与一个数的数位的变化有关的信息。所以我们一般不会同时用数位 DP 和多维计数。
注意: 数位 DP 是搜索,状态数量要严格控制在合理范围之内。
数位 DP 的本质是:我们很多时候由于状态数过多,不方便直接枚举区间内的数,我们便构造每一位并且只存储一些可能造成区分的状态来达到减少状态数量的目的。
注意
在存储状态的时候尽可能维护所有信息,进行取舍的,也应该确保舍弃后不会出现重复计算的情况。例如对数字个数计数就要记录该状态的数字个数。
一般形式
求前缀/区间中满足一些与数位有关的信息的数的数量。
数位 DP 常见的维护信息
我们应该知道数位 DP 一般会维护什么信息。这直接决定了我们怎么设计状态。
当前位置 \(pos\):搜索的时候用于判断终止条件和找位置。
是否可能达到上界 \(lim\):搜索时确定枚举数字的范围,以确保答案小于等于某个数。
下面为个性化维护,具体问题具体分析。
是否可能有前导零 \(qdl\):如果可能有并且当前选 \(0\),那么如果贡献应当排除前导零,则当前贡献不统计(例如特定数字个数或者连续个数)。当然如果前导零没有作用可以省略(例如数字和)。
当前数字和 \(sm\):就是普通的数字和,维护信息用。特别特别注意前导零对于答案的影响。
当前数字 \(ys\):记录当前数字,如果会导致状态量很大就尝试对某个数取模(例如可能的数字和)。
数字 \(w\) 的个数 \(A_w\):也是个性化维护。特别特别注意前导零对于答案的影响。
进位 \(X\):如果涉及到加法运算就要考虑进位。
减少状态数量的常见方法
可以特判。
异或不进位,加法进位。
如果要求记录原来的数对某个值取模并且模数不是很大,直接存储其取模后的值。
例如,数字和一般不超过 \(200\),如果对数字和取模则直接枚举每个可能的数字和,将其作为一个确定的模数进行操作。
不合法解一定要先行回避,直接不转移。如果无法转移就一定要排除,尽可能减小冗余空间。
不会对计数构成混淆的 DP 状态信息,我们可以不作为维数存储。例如在 ABC194F 中,求 \([1,N]\) 在十六进制下有多少个数满足有 \(K\) 个不同数字。我们记录原数中出现了哪些数字显然不现实(状态数炸裂)。考虑数字总共只有 \(16\) 种,也就是说此时 12321 和 ABCBA 这两个数在后续的状态数量是等价的。所以我们只存储当前位数和使用的数字的种类个数是不会导致重复的。所以不存储使用情况也是可以的。
在不会对时间复杂度造成影响的前提下,我们可以时间换空间,减少一些状态的存储。还是那道题,有前导零和数字达到上线的状态只占总状态数的较小部分,并且不会大量重复,所以我们可以对这类情况不存储状态。当然,这是在确保不会造成混淆计数的情况下。这需要一定的信心和评测机的速度。因为很多时候这是为非正解卡常。
数位 DP 与加法
涉及到数位 DP 和加法的,应该考虑进位的问题,也就是考虑从低位来的进位和进位到高位的量。例如 P7415 内,我们求存在多少个 \(d \in [0,D]\) 满足 \(A+d\) 和 \(B+d\) 在三进制下每一位奇偶性相同。
注意:这里不能拆开 \(A,B\) 作单独的贡献,因为两个维度的性质并不能单独拆贡献。例如(4,2)坐标都是偶数但是没有贡献。
我们分解 \(A,B,D\),并且应该注意到加法伴随进位,所以我们在数位 DP 设计时应该维护进位。考虑传统的 DP 顺序,我们应该维护 \(x,y\) 从当前位置向上一位的进位。
所以我们设计 \(DP(pos,lim,X,Y)\) 表示当前位置,当前是否达到上界,以及两个进位。
当然,我们此时并不知道低位进的位数是多少。有条件就上,没有条件创造条件也要上。我们直接枚举低位进位 \(a_1,b_1\)。
那么当前 \(A+D\) 的实际 \(pos\) 位为 \(A(pos)+a_1-3X+d(pos)\)。 \(B\) 同理。其中 \(d(pos)\) 为我们当前枚举的 \(d\) 的第 \(pos\) 位。请注意:每一位必须属于 \([0,2]\),并且 \(A\) 和 \(B\) 的当前位置的奇偶性必须相同,我们才进行转移。
最后如果整个 \(d\) 被构造完毕,即 \(pos=cnt+1\),我们的合法状态此时一定不包含进位。对其计数之。
特别注意: 加法进位的时候低位是对齐的,数位 DP 时也应该在地位对齐。所以我们不能反转进制数组(高位对齐),而是倒着从最大位数开始 DP。
1 2 3
+ 7 7
----------
2 0 0
如上面的图像,加法是低位对齐的。
特殊应用
当然,数位 DP 是在构造一个序列,所以我们可以对字符串进行类似操作。例如,求串内元素重新排序后比当前串字典序小的串的个数,在字符集很小的前提下可以直接跑数位 DP。
更为一般地,字符串排序计数有关问题可以尝试转换为数位 DP 的形式。
数位 DP 还是容斥
一般来说容斥的交集是比较好算的并且各个个数的方案有统属关系。而数位 DP 所要维护的信息很少有这种性质,所以在看到与整除,加和,数字个数等信息时考虑数位 DP。
CF507D(两遍):
卡点:不会维护后缀的 DP 就直接去做容斥,炸了就只能去暴力。
实际上后缀的信息直接从低位向高位维护即可。我们常见的数位 DP 是维护前缀的,考虑反向(从低位开始)即可。状态设计不太难。设 \(dp(i,j,0/1)\) 表示当前位置,此前的模数,以及当前是否存在后缀。有后缀就直接继承。如果没有后缀,并且当前数字的模数是 \(0\) 且当前数字不为 \(0\)(这个数不算后缀) 则直接记在有后缀的里面,反之就记在没有后缀的。我们最后统计 \(n\) 位里面不同模数的答案即可。注意:没有前导零,第 \(n\) 位从 \(1\) 开始枚举。这里没有最大数字限制,实际上是比一般的数位 DP 更加简单的。
后续:想想数位 DP 是否可能是从后缀维护的。
数位 DP 进阶
作为一种常见的计数类问题,在学习完基础的数位 DP 之后,我们来处理一些数位 DP 的更加常见的应用。
复习:记忆化搜索
P2657(一遍):不含有前导零且相邻两个数字的差至少是 \(2\)。那么我们需要是否可能达到极限,是否可能有前导零,以及上一位的取值。我们用 dp(pos,lim,qdl,pre) 表示一个状态。
转移的时候,如果当前位置有前导零则当前的 \(pre\) 不对取值范围构成影响。所以有无前导零是分开转移的。
有前导零就考虑当前数位 \(d\) 是不是 \(0\)。如果是,则 ans+=dfs(pos+1,0,1,d)(注意:因为这里取了前导零,所以当前是不能达到极限的,于是之后就可以任意取值了,这在数位 DP 当中是一个十分常见的剪枝,在之后的一道题当中会直接使用)。如果不是则 ans+=dfs(pos+1,(lim && (d==dig[pos])),0,d)。
如果当前位置不再在一个连续的前导零里面,就考虑当前的 \(pre\) 对于当前的值的选取造成的影响。发现此时 \(pre-1,pre,pre+1\) 三个值是不能取的。除此之外,ans+=dfs(pos+1,(lim && (d==dig[pos])),0,d)。
最后记忆化搜索存储答案 dfs(1,1,1,0) 。
最终的递归边界就是 \(pos=tot+1\),也就是当前位数已经超出了串的范围的时候,计数为 !qdl。当然不可能全都是前导零。事实上,为了计算方便,一般要处理前导零的 DP 不会处理到 \(0\) 本身,就算处理到了也不要怕,直接特判即可。
有的时候维护的信息比较多,但是只要思路清晰,也不难维护。
AT_abc317_f(一遍):注意数的长度和模数的值的大小的取值范围以防止奇怪错误。
由于不能是 \(0\),我们维护 dfs(pos,lim1,lim2,lim3,ys1,ys2,ys3,h1,h2,h3) 表示当前位置,三个数是否还在极限,三个数的余数,三个数是否是 \(0\)。转移就直接拓展当前取值 \(d1,d2,d3\),如果三者的异或和是 \(0\),就进行后续转移。如果 \(pos\) 超出范围,则如果 !ys1 && !ys2 && !ys3 && !h1 && !h2 && !h3 就可以加一。答案是 dfs(1,1,1,1,0,0,0,1,1,1)。
用数位 DP 同时处理多个数以及对多个数计算贡献
P4067(两遍):
卡点:没有发现打表的具体规律,也没有看出来如何数位 DP。
事实上,我们发现 \(f(i,j)=i \oplus j\) 是确定的,并且对于一个 \(2^k \times 2^k\) 的表,这可以拆分为 四个 \(2^{k-1}\) 为边长的正方形,其中两条对角线的正方形是相同的。结合 \(f(0,i)=i\) 的事实,我们可以直接快速写出这张表。
但是我们还是考虑使用数位 DP。原本我们想要计算 \(f(i,j)>k\) 时候的 \(f(i,j)\) 的和 \(S2\)。但是要处理 \(-k\),所以我们还要处理 \(f(i,j)>k\) 的 \(f(i,j)\) 的个数 \(S1\)。
由于涉及到位运算,我们把 \(n,m,k\) 全部二进制拆分掉。我们发现如果拆位,那么当前位置对和的贡献与后续状态的 \(>k\) 的节点个数有关。于是我们考虑用一个 pair 来同时对两个信息进行转移。当然了,此时我们只能通过开 \(vis\) 数组来实现标记转移状态是否被访问了。
我们用 dfs(pos,lim_n,lim_m,lim_k) 表示一个状态。也就是 \(n,m\) 是否可能达到极限,以及此前的取值是否可能全部等于 \(k\)。“可能”指的是当前节点下此前的信息是否满足条件。
由于二进制位在从高到低的时候,如果一个位置上面的数小于目标位置上的数,那么无论后面取多大都只能小于目标数,则 \(lim_k=1\) 的时候我们是不能让 \(d=dig(pos)\)。此时 \(d=d_1 \oplus d_2\)。
我们令 \(dfs\) 状态的第一项是个数,第二项是和。则令后续节点为 P=dfs(pos+1,(lim_n && (dign[pos]==d1)),(lim_m && (digm[pos]==d2)),(lim_k && (digk[pos]==d)))。那么当前状态的个数就加上后续状态的个数 P.first,而和(注意 \(pos\) 从 \(1\) 开始到 \(tot=62\) 结束) 加上 d*(1ll<<(tot-pos))*P.first+P.second。也就是后续的和的所有的数再来加上一个最高位的 \(d\)。
最终状态是 \(pos=tot+1\) 的时候的 make_pair(1,0)。事实上最终状态是递推的初始状态。答案的 \(S1,S2\) 是 dfs(1,1,1,1)。最后记得在实现时取模。
特别地,注意 \(S1 \times k \le 10^{18} \times 10^9 =10^{27}\) 可能会爆炸。在最后计算答案时先把 \(k\) 取模。
通过剪枝压缩之间和空间
有的时候搜索的状态量非常大,此时我们需要考虑剪枝来减少状态和转移总量。
AT_abc336_e(一遍):如果处理原数本身,状态量会极大。因此我们考虑数字和会有多大。\(14 \times 9 = 126\),因此总的数字和不会超过 \(130\)。我们暴力枚举数字和 \(key\)。则此时我们只要求数字和恰好等于 \(key\),且原数 \(\equiv 0\pmod {key}\) 的数的个数。由于数字和不同是不会重复计数的,答案就是所有的答案的和。注意数组大小,数位长度要开到 \(16\) 以上,最好是 \(22\)。
AT_abc194_f(再次做也卡死,两遍):七月份做了一次,看来是没有对剪枝条件有很好的理解。组合计数感觉不是很好理解,考虑还是用记忆化搜索。
如果没有极限和前导零的限制,那么我们总是能够找到一个排列使得所有同一个长度和颜色个数的填色方案等价。因此此时的填色方案仅仅和长度与个数有关。我们以 dfs(pos,lim,qdl,S) 作为填色状态。但是在 !lim && !qdl 时候我们用 dp(pos,gs=popcount(S)) 存储状态数量来减少重复访问。这样我们的访问数量就大幅度减少了。接着按照题意转移。我们还可以来个剪枝。如果 \(gs>k\) 或 \(gs+(tot-pos+1) \lt k\) 也就是多了或是填不满了就直接返回 \(0\)。这样还加上了启发式搜索,也是一个思路。
专题:如何用数位 DP 刻画加法
很多时候,我们会遇见刻画加法的问题。而加法区别于异或,最重要的一点是:加法有进位,而异或没有。所以刻画加法,最关键的是如何刻画进位。\(k\) 进制下当前值 \(val\) 最多会对 \(O(\log_k val)\) 个位置造成影响。并且因为加法是从低位往高位进位的,我们应该从低位向高位转移,而不是常见的从高向低。
CF1998G(两遍):
卡点:二进制拆位之后知道是对于位置选值累加,但是不知道怎么安排方案取值的,导致只能状态压缩暴力 DP。
事实上是可以数位 DP 的。关键是如何刻画求和。我们还是用我们最爱的记忆化搜索。设 dfs(pos,cur) 表示当前位置,以及当前位置和更高位置的数是 \(cur\),也就是和除以 \(2^{pos}\) 的值是 \(cur\)。这里主要是为了方便进位。令当前选择的数字是 \(d\)。如果选的是 \(0\) 那么当前的为的总数加上 \(cnt_{pos}\),而选 \(1\) 就一定是 $n-cnt_{pos} $ 也就是 \(0\) 的当前位置的个数。那么如果加完了之后的二进制下最后一个位置和目标的 \(s\) 是匹配的,就尝试递归到下一个 dfs(pos+1,tmp>>1),这 \(tmp\) 表示加上之后的和(特别注意:我们新维护的信息一定要使用,这里是 \(tmp\) 而不是 \(cur\))。
以及更加关键的 dfs 到了 \(pos+1\) 的时候要判断有没有多余的进位。在本题里面就是 \(cur\) 在最后超出范围的时候必然是 \(0\) 才能可行。如果说 \(d\) 取完零和一之后还是没有可行解就直接判负。为了规避重复访问,如果当前状态被访问过,也直接判负。
计数
我们在算贡献的时候不要考虑具体的方案,而是关注操作需要和改变的信息的范围。
分类加,分步乘。
先选点后维护形态
(两遍题解)ABC180F:我们考虑维护当前连通块增加上来之前和之后变化了什么信息。发现只要维护点数和边数。设 \(dp(i,j)\) 表示当前局面有 \(i\) 个点和 \(j\) 条边的方案数。由于图并非指定了每条边的情况,我们只能考虑边的个数并且钦定选出的连通块在边数允许的情况下一定是存在的。很多时候我们并不考虑具体的情况,计数时尤其如此,这个观念十分重要。
这里最大连通块大小恰好为 \(L\),这并不好维护,考虑把这个条件弱化为至多为 \(L\)。
由于度数不超过 \(2\),每一个连通块不是链就一定是一个环。为了防止重复计数,我们钦定当前最大的点一定在当前的连通块内。
首先考虑链怎么计数。枚举当前连通块的大小 \(k\)。由于链是 \(k\) 个点和 \(k\) 条边且最大点已经被选,我们是从 \(dp(i-k,j-k+1)\) 转移的。所以我们要在剩余的 \(n-(i-k)-1\) 个点内选取 \(k-1\) 个点。也就是 \(n-i+k-1 \choose k+1\) 的系数。注意到一条链在倒过来的时候是等价的,并且任意编号,所以对于长度大于 \(2\) 的边,我们给它一个 \(\frac{k!}{2}\) 的系数。当然如果只有 \(1\) 个点则系数只为 \(1\)。
再来考虑环,从 \(dp(i-k,j-k)\) 转移。选点是一致的,考虑排列的系数。注意长度为 \(1\) 的环不存在。长度大于 \(2\) 的环的点可以任意编号,但是环在旋转和对称的时候和原环是等价的,所以系数为 \(\frac{(k-1)!}{2}\)。对于长度为 \(2\) 的环,其只有一种等价的形式。
总而言之,在计数系数出现分数的时候就要特判。
差分数组有性质
生成函数维护之。
如果相邻两项的和差或者余数有一定限制,考虑从差分数组入手。
弱者才会用容斥
ABC285Ex:考虑容斥。\(S_i\) 表示钦定 \(i\) 个平方数的方案数。也就是说,\(S_i\) 表示对于 \(j \in [1,n]\),指数 \(E_j\) 拆分成至少 \(i\) 个偶数的方案数的乘积。答案就是 \(\sum_{i=0}^n (-1)^i \binom{n}{i} S_i\)。我们令 \(f_{i,k}\) 表示至少把 \(k\) 拆分成 \(i\) 个偶数的方案数。则设 \(F(i,x)=\sum_{k=0}^\infty x^k f_{i,k}\)。此时 \(S_i = \prod_{j=1}^n f_{i,E_j}\)。注意到 \(F(i)\) 可以用 GF 维护:\(F(i,x)=(x^0+x^2+...)^i+(x^0+x^1+...)^{n-i}\)。就是取 \(i\) 个偶数并取 \(n-i\) 个任意的数,总和等于 \(m\) 的方案数。推导封闭形式就可以维护。
计数?记录选取个数!
为什么是记录选取个数
如果有的时候满足题目里面某些信息是有序的,我们就直接记录其在当前位置之前的个数,而非考虑其形态。
CF2119D:我们计算考虑的范围和被选取的个数。
注意到一个点 \(i\) 一定会被前面小于 \(i\) 的 \(a_j\) 取走,且此时 \(j \ge n\),则取的点一定在当前点的后方。所以我们倒着枚举。设 \(dp(i,j)\) 表示在 \([i,n]\) 内已经选了 \(j\) 个的方案数。当前决策点为 \(i\)。
点 \(i\) 会在一个 \([a_p,p]\) 范围内被取走,也可能不会取走。
dp[i][j]+=dp[i+1][j];
dp[i][j+1]+=dp[i+1][j]*(n-i+1-j)*i;
对于第二个,由于后面已经取走了 \(j\) 个,还剩下 \((n-i+1-j)\) 个,而前面有 \(i\) 个可以取点的点。
CF367E(三遍):
卡点:第一,只是想到了没有交集的 \(O(nm^4)\) 的暴力方案却没有转换成记录个数。第二,没有利用抽屉原理确定 \(n\) 的取值范围并且被卡常。
我们断言 \(n \le m\),否则必然存在两个左端点相同,而一旦左端点相同则必然有两个区间构成包含关系。
我们发现没有包含关系就意味着区间按照左端点排序后左右端点都是有序的。而有序的就意味着我们只需要记录个数。设 \(dp(i,l,r)\) 表示前 \(i\) 个位置放 \(l\) 个左端点和 \(r\) 个右端点的方案数。那么当前位置可以不放,放左端,放右端,两个都放。我们只需要放 \(n\) 个左端点和 \(n\) 个右端点而不在乎谁会匹配谁,因为都是按照从小到大匹配,则匹配关系已经确定。此时右端点个数不得多于左端点个数。当 \(i=x\) 时因为左端点必然存在,就只考虑放左端和两个都放。
答案是 \(n! dp(m,n,n)\)。空间不够考虑滚动数组。用 umap 会被卡常。考虑 \(n\) 的范围。由于 \(n \le m\),\(n^2 \le nm = 10^5\),\(n \le \sqrt {10^5}\),取到 \(320\) 即可。由于有编号,我们给答案乘 \(n!\)。
期望,概率刻画计数
如果求所有排列的某一种权值的和,就可以考虑求随机一个排列的期望权值再乘排列的总个数。期望可以线性转移,这样做也许有帮助。
P6655(两遍):卡点:没有见过期望刻画计数的技巧。
直接计数非常困难,考虑求一个排列期望的点的个数。这个也比较困难,考虑设 \(f(i)\) 表示随机一个排列,\(i\) 是制高点的概率。则 \(f(1)=1,Ans=\sum_{i=1}^n f(i)S,S=\prod_{i=1}^n (r_i-l_i+1)\)。那么考虑一般的节点的情况。一个点是否是制高点取决于其父亲是否是制高点,则 \(f(i)=\sum_{j=l_i}^{r_i} [h_j \ge h_i] \frac{1}{r_i-l_i+1}\)。前面那个东西是可以二维数点,用主席树维护的。
概率·期望
\(E(i)=\sum{a_i P(i)}\)
期望转计数,计数拆贡献。
概率论
ABC300E(学习,两遍):卡点:不理解为什么不用刻画步数。
类比转移的 DAG 计数,这里就是 DAG 上到达某个点的路径数量。而路径计数是不在乎长度的,所以不用计数。
\(dp(i)=\frac{1}{6}\sum_{x=1}^6 dp(\frac{i}{x})\) 但是转移有环。我们考虑换掉式子之后在 DAG 上一个节点有五个入边,所以是 \(dp(i)=\frac{1}{5}\sum_{x=2}^6 dp(\frac{i}{x})\)。
ABC333F(两遍):卡点:不理解概率的推导顺序。
我们一般是考虑初态还是终态是确定的来确定是顺推还是逆推。设 \(dp(i,j)\) 表示当前有 \(i\) 个人,第 \(j\) 个人活着的概率。那么 \(dp(1,1)=1\),我们只能倒着转移。也就是用后面的存货概率去推前面的。而里面有一个 \(dp(i,1)=0.5dp(i,i)\) 的转移导致成了环。对于线性成环转移我们考虑高斯消元或者用手推导把环给消除,之后正常转移。例如本题我们考虑求出 \(dp(i,i)\) 的转移式子。
公理——期望倒着转移
根据公理,期望是倒着转移的。
其实期望是可以正着转移的,但是因为期望在转移的时候所有的常数项也是要乘概率的,而倒着转移的每一次决策的概率可以视为 \(1\),会更加方便。
CF235B(一遍):平方和考虑拆贡献。添加一个点对答案的贡献是当前连续的 o 的长度。设 \(dp(i)\) 表示 \([i,n]\) 的期望得分,\(len(i)\) 表示 \([i,n]\) 期望的连续长度。那么先考虑连续长度的贡献怎么计算。期望是线性的,也就是说我们是在原有的期望的基础之上进行贡献的转移。有 \(p(i)\) 的概率期望长度会 \(+1\),有 \(1-p(i)\) 的概率长度会变成零。所以:
考虑完总长度考虑 \(dp\) 值。有 \(p(i)\) 概率继承原有的 \(dp(i+1)\),有 \((1-p(i))\) 概率增加贡献。所以:
做完了。
P4316(两遍):卡点:BFS 求顺序会爆炸,最好求拓扑序之后在序列上转移。
设 \(dp(u)\) 表示 \(u\) 到达 \(n\) 的期望长度,按照拓扑序列从 \(u\) 到 \(v\) 的边用 \(v\) 的长度转移到 \(u\)。
P1654(一遍):和 CF235B 类似(又是 OSU),但是多一个当前连续段的期望的二次方和的转移。
期望的本质——指示器随机变量
公式做题就是快
很多时候我们要计算一个状态对总期望的贡献,但是有时候会束手无策导致破防,因为并非所有的期望都是单纯转计数。对于一个状态是否出现导致的贡献,我们引入了一个新的参数:指示器随机变量。这也就是 \(I(X)=[\text{X 发生}]\)。或者说,用指示器计算当前时间如果发生对答案的贡献 \(A(X)=I(X)val\)。配上期望的可加性,对于某些题目就可以直接公式做题。例如:
CF280C(两遍):
卡点:本来期望转计数,结果计数也不好计。
考虑用指示器解决问题。令 \(X_i\) 表示 \(i\) 被选中这个事件,\(E(X)\) 表示 \(X\) 发生的期望操作次数。那么 \(E(\text{所有点被选中})=E(\sum_{i=1}^n X_i)=\sum_{i=1}^n E(X_i)\)。
由于 \(X_i\) 只是选中一个点,所以 \(I(X_i) \in [0,1]\)
令 \(T\) 表示 \(X_i\) 发生的概率。那么 \(E(X_i) = P(T)\times 1 + P(\bar{T}) \times 0 = P(T)\)。于是 \(ans=\sum{P(i \ \text{被选中})}\)。
考虑 \(P(i \ \text{被选中})\)。此时我们搬出概率转计数。统计 \(i\) 被选中的情况数量。我们知道如果 \(i\) 的祖先被选中或者 \(i\) 本身被选中,则 \(i\) 就会被选中。所以概率就是 \(\frac{1}{dep_i}\)。对其求和即可。
后续:别只是想着期望转计数,用期望的可加性配上描述相互独立单个状态的指示器来拆期望式子。
你确定期望一定是 DP 或一定是倒推吗?
其实期望题并非全部是 DP,考虑有的期望题是「点击输入文字」的数据结构题。
AT_abc417_f(一遍,但是一发):
不算卡点:最后更新的值没有取模炸掉了。
非常生草的是,我们观察样例并根据期望的定义来说,每一次区间内的点的期望个数都会变成原本区间内期望个数的平均值。然后就做完了。区间推平区间求和,来个线段树。
DS+DP
设计区间加区间最大值。
有些信息在数据结构中被自动维护,有些则需要手动添加。
如果还有和单项有关的系数,也需要手动添加进数据结构。
区间内存在某个数就给分(AT_dp_w)
\(dp(i)\) 表示当前最大价值(钦定点 \(i\) 选这个数)
由于被钦定,所以区间不会继承前面的最大答案,答案直接取最大值(注意 SGT 内加了和,我们只能对 DP 取最大值当答案)。所以我们直接把整个 dp 数组放在 SGT 内维护。依然考虑把区间挂在右端点,每一次不是前缀加而是区间加,这样和就自动维护了。由于此时 \(dp_i\) 尚未加入线段树,所以值就是线段树上的最大值。
区间内全是某个数才给分(P9871,CF115E)
设 \(dp_i\) 表示到点 \(i\) 的最大价值。注意我们不钦定点 \(i\) 的值。
有时要离散化,甚至因为我们钦定 \([j,i]\) 是一个连续段,有时连续段还不能拼接(如 P9871),所以 \(pos(j)\) 可能是 \(j-2\) 和 \(j-1\)。
对于后面的和式,我们把区间挂在右端点,扫描右端点时对于左端前缀自动加入 SGT。答案就是 \(dp(n)\)。前面的 \(dp\) 值就存储在线段树里面。所以此时 \(dp_i\) 是拿线段树里面的东西作为转移的辅助值的,和存在问题不同。
上面两种是不同类别的转移,混淆很容易被 HACK。
DP 的其他优化
DP 的状态数一定要控制。不要弄混变量。
决策单调性(四边形不等式优化)
优化如下形式且决策点有单调性的 DP:
设计的时候记录当前 dp 的范围和决策点的范围 DP(l,r,L,R),分治即可。
更加准确地,这个决策单调性优化应该满足四边形不等式:\(w(i,j)+w(i+1,j+1) \le w(i,j+1)+w(i+1,j)\)。不过一般是打表之后直接猜。
CF838B:\(dp_{i,p}=\max_{j \in [1,i]}dp_{j-1,p-1}+col(j,i)\)。看到前缀最大值考虑决策单调性,在决策点分治区间内找当前 dp 的值 \(mid\) 的最优决策点,并向下递归。颜色就用莫队维护。
CF868F:又是一个莫队和决策单调性结合的题。注意:莫队维护的是区间扩张或收缩恰好一个点时的贡献。如果和数对个数有关,注意:
所以要解决先改颜色还是贡献的问题。
决策单调性与莫队
莫队出题经常以决策单调性为背景。因为莫队在决策单调性环境下移动是 \(O(\log n)\) 的,比主席树更优。
四边形不等式的分治
我们直接维护当前节点区间 \(mid\) 的 \(dp\) 值。考虑遍历当前决策点区间 \([L,R]\),当然如果存在和 \(mid\) 有关的限制也要满足。那么这么说,我们要维护当前决策点 \(i\),如果当前决策点可以使得当前的值更优,我们就记录当前决策点 \(k\)。更新完之后,由于左边的点的决策点也是在左边,并且决策点可以重复使用而节点不会重复计数,考虑把节点范围变成 \([l,mid-1],[mid+1,r]\),决策点范围变成 \([L,k],[k,R]\)。
四边形不等式优化 vs 斜率优化
事实上,并非所有满足上述形式且有四边形不等式性质的都适用分治算法。更多时候如果没有严格证明可以打对拍,不过大多数情况下是没有任何问题的。当然如果真的是斜率优化那么 \(w(j,i)\) 里面会有 \(i,j\) 的交叉项,例如 \(x_iy_j\)。并且一般会保证 \(j-1\) 不会出现在状态设置里面,而是使用 \(j,j+1\) 以确保不会出现数组越界的情形。
斜率优化
不同于有决策单调性限制的四边形不等式优化,只要是求最值并且可以把转移写成一次函数形式就可以直接使用斜率优化。
我们设转移类似于 \(dp(i)=\max_{j} dp(j)+a_ib_j\) 的形式,也就是说有和 \(i,j\) 有关的转移值。那么我们考虑把转移式转写为 \(y=kx+b\) 的形式,其中 \(x,y\) 和 \(i\) 有关而 \(k,b\) 和 \(j\) 有关。例如上述式中 \(y=dp(i),x=a_i,k=b_j,b=dp(j)\) 是可行的。
之后我们考虑把斜率 \(k\) 按照一个单调的形式排列,来判断我们应该维护一个上凸壳还是下凸壳。这之后我们考虑去维护一个凸壳。我们通过计算如果当前队头的下一个比当前队头优(其实谁更优并不重要,重点是存在这个斜率会导致有当前队头不优的可能而我们要舍弃)会怎么样,一般会满足一个斜率比大小的形式。之后我们去转移,并记录当前决策点——队头。我们之后选择插入当前 \(i\) 作为决策点,并维护当前凸壳。具体而言,如果队尾的上一个和尾部的连边和尾部与当前节点(我们钦定的尾部)之间不满足当前凸壳的性质,我们就直接舍弃尾部。最后插入当前节点至尾部即可。
斜率优化例题
P3195(一遍):注意分析哪个斜率更优的时候是按照 \(k\) 比 \(j\) 优去分析的,而不是 \(j\) 比 \(k\) 不优,因为这里 \(k\) 是 \(q(h+1)\),可能有斜率差值为正/负的情况而导致不等式要变号。
P2900(两遍):
特别注意:如果有因为偏序关系而不对答案造成贡献的应该直接删除,转换成为「自由选取区间内的节点」的形式。
先按照左端点为第一关键字,右端点为第二关键字排序,这样如果存在一个左右端点全都比之前的某个点小的就不用选取了。然后就有第一维单调增,第二维单调减。可以直接套斜率优化了。
P3648(两遍):
卡点:被卡常,以及一些细节没有弄清楚。
设 \(dp_{i,p}\) 表示考虑 \([1,i]\),已经被划出 \(p\) 次,第 \(p\) 次画得是 \(i\) 的最大值。
然后套斜率优化,结果 WA 爆了。
\(s_i=s_j\) 是可能的,所以此时分母是 \(s_i-s_j\),而此时斜率是无穷(不论正负)。这里取负无穷。因为 \(0\) 不能被操作,我们转移之前不放 \(0\),则 \(h=t=1\)。
下面是卡常有关的一些操作。第一,顺序不影响结果,我们用不着倒着输出路径。第二,队列不用清空,因为每一次都是更新值。只要重置首尾位置。第三,转移路径的时候不开 long long,可以有效减少常数。第四,对于仅仅和 \(i\) 有关的项 \(p(i)\),我们考虑用一个函数而非数组存。第五,double 常数比较大,考虑用叉积转写来减少常数(注意判正负)。第六,注意快读快写(尽管这里没什么用)。
后续:斜率优化要注意,double 可能爆精度和常数。
P3628(两遍):
卡点:错误地维护了下凸壳,而应该维护上凸壳。
可以套斜率优化。在本题里 \(k\) 是增加的,但是并非下凸壳。这是因为我们的凸壳方向是按照我们给定的 \((x_i,y_i)\) 来的,所以我们应通过自己作图确定每个点是分布在上凸壳还是下凸壳上面来维护。当然也可以打两个凸壳试试看。
前缀贡献放后缀
暴力判上下凸壳
取前缀最大值
由于斜率优化的时候不是上凸壳就是下凸壳,我们两个都试试。
如果前缀的贡献会直接继承过来,考虑换一个角度,一个点的贡献会被计算到所有的后缀。
对于有前缀偏序关系的状态是不会造成更优的贡献的,考虑直接取代。
U597503(两遍):
卡点:没有想到端点效应和前缀换后缀。
首先如果 \(A_i>A_{i+1}\),则我们希望把这两段合并,这样就只有前者造成贡献。于是我们取前缀最大值答案是不变的,这样区间最值就是区间的右端点。
由于上一个决策点的值是会直接继承到后面(无条件继承),我们转而设 \(dp(i,r)\) 表示考虑到点 \(i\) ,划 \(r\) 段时全局的最小费用。
从上一个决策点之后所有的点都会增加当前决策的贡献。所以有
看着就是能斜率优化的状态了,可以做到 \(O(NR)\)。我们观察各种样例,发现 \(R\) 可能不超过一百。事实上我们可以证明 \(R\) 在最优决策时是 \(O(\log n+\log V)\) 的,直接 \(R \gets \min(R,100)\) 即可。
分治优化 DP
P7244:注意到答案必定是最大值的因数,我们直接枚举判断合法,注意到合法的段可以合并,所以求不少于 \(k\) 段即可。考虑如果区间内最大坐标合法(为枚举值倍数)就单独开一段,否则这个最大坐标一定和左右两个半边的其中一个合并,我们加另一个的贡献即可,因为合并后个数还是等于原本单开一段且左边不贡献的段数。
杂项
串串
子串是前缀的后缀或者后缀的前缀。
匹配 (KMP+Z)
Z
P5410:Z 函数模板就三行,比后缀数组少一个 \(\log\),但是匹配还是用 KMP 顺手。
KMP
P3375(模板):KMP 模板是好背的。
P2375(一遍半):我们知道 \(nxt\) 指针构成一棵树。考虑树上倍增 \(O(n \log n)\),但是被卡常。
考虑把倍增的深度维度放在节点前面,内存访问更连续的时候跑得更快。
BONUS:考虑 \(O(n)\) 做法。我们完全没有必要在树上跳,因为有 Border 是 Border 和 Border 的 Border 的性质,我们直接按照求 Border 的方法,直接求完 Border 后在 Border 数组上面暴力跳,且此时暴力跳 Border 是不影响总复杂度的\(^{\text{[原因需要询问]}}\)。且由于树上父亲的编号一定小于儿子,我们可以直接 \(O(n)\) 求出每个节点的深度,这样总复杂度就是线性的。
时间复杂度证明: 我们知道 KMP 的流程是枚举文本串字符 \(i\) 和一个匹配指针 \(j\)。\(j\) 每次循环跳在 \(nxt\) 树上的父亲。由于跳的时候每跳一次深度 \(-1\),循环最多被执行 \(O(n)\) 次,考虑从深度变化计算复杂度。而循环结束的时候深度最多 \(+1\)。总的复杂度是 \(O(n+m)\)。而暴力跳的复杂度可以和循环的复杂度合并(都是跳父亲),所以总的复杂度还是 \(O(n+m)\)。
CF825F(一遍):区间 Border 的 \(O(N^2)\) 求法就是求每个后缀的 Border(子串是后缀的前缀),\(O(N^2)\) 是可以做的。设 \(dp(i)\) 表示到前 \(i\) 位的压缩串的最小长度,设我们直接(不拆分)压缩 \(s[j:i]\) 这一段子串,则如果该串存在完整循环节则压缩之(减半可能导致长度 \(+1\),那么压缩肯定更优),否则直接计这一整段。
CF126B(一遍):考虑从 \(nxt(n)\) 向前跳,如果当前 Border 在总串的出现次数(可以子树求和,因为父亲编号小于儿子,不用建树)不少于三就计算进答案。
一个串的后缀是另一个串的前缀
CF1200E(两遍):
卡点:不知道怎么优化 KMP 的串的长度。
考虑只使用有效长度。
我们可以把新串前缀和当前合并串的后缀合并(记得加分隔符)求 Border。但是如果直接加合并串,是 \(O(N^2)\) 的。
发现只有两个串中长度较小部分的长度是有效的。于是只使用这个部分。时间复杂度 \(O(\sum |S|)\)。
哈希(神秘用法)
二分+哈希可以实现 \(O(\log n)\) 比较字典序大小。
不会做串串题?使用哈希!
P7114(两遍):正解是 Z 函数,考场上想出来不太现实,考虑人类能更好想出来的算法。
一般的枚举是枚举循环节并 KMP 判断循环个数。但是考虑循环节是一段前缀,所以只需要枚举长度即可唯一确定当前循环节,而循环次数可以直接枚举,用 Hash \(O(1)\) 判定是否合法。考虑为什么这么做。因为暴力枚举是 \(O(n^2)\) 的,而枚举循环长度和循环次数是调和级数,是 \(O(n \log n)\) 的。
枚举小技巧:考虑枚举的两维是否可能是积一定的,如果可能就转枚举这两个变成调和级数。
接着考虑怎么判断奇数个数的问题。我们需要 \(1 \le |A| < |AB|\) 且 \(F(A) \le F(C)\) 的 \(A\) 的数量。考虑枚举长度时顺带维护每个前缀 \(F\) 值为 \(|A|\) 的前缀的个数(由于是前缀,可以 \(O(n)\) 预处理)。而 \(F(C)\) 也是预处理好的。我们直接用权值树状数组维护即可。接下来是卡常。所有小技巧见下。
枚举小技巧:考虑枚举的两维是否可能是积一定的,如果可能就转枚举这两个变成调和级数。
long long常数大,只有答案开。- 取模常数大,用自然溢出(正式比赛一般不卡)。
- 加减法只维护结果奇偶性,用异或。
- \(len \times i < n\),减少判断。
仁济矩阵哈希
P4398(思路正确,调了 INF 遍):矩阵哈希不困难,首先记各行内部的贡献(用一个进制),之后记录列的贡献(另一个进制),最后按照矩阵前缀和的方法来计算总贡献即可。
Trie + ACAM
合理使用 Trie 树——AC 自动机
尽管 AC 自动机并不会让我们自动 AC,但是 AC 自动机仍然并非赤石科技。甚至可以是串串里面不可多得的常用技术之一。
ACAM 的用途
我们知道 KMP 可以用来做一次匹配。那如果对于同一个文本串做多次匹配,我们就会用到 ACAM。
ACAM 的构造
第一步,对于所有的模式串建造 Trie 树。
第二步,求出每个节点的 fail 指针。
什么是 fail 指针
对于一个节点 \(u\),其表示了一个串的前缀,我们让 \(fail(u)\) 指向点 \(u\) 对应状态在 Trie
树上存在的的最长后缀状态所对应的节点。
怎么求 fail 指针
对于点 \(u\) 出边权值为 \(c\) 的儿子 \(ch(u,c)\),如果 \(ch(fail(u),c)\) 存在,那么 \(ch(u,c)\) 的指针就指向它。考虑这么想:\(fail(u)\) 是 \(u\) 存在的最长的后缀,那么同时在两个字符串的后面加一个 \(c\),显然前者还是后者的最大后缀(因为只增加一个字符)。那么如果 \(ch(fail(u),c)\) 不存在就继续向上找 \(ch(fail(fail(u)),c)\),如果一直不存在就是空串,也就是根。
具体到实现上。由于我们要保证深度更小(长度更短)的点的 fail 指针已经全部求出来了,我们要用 BFS 顺序来构造。特别地,我们应该从根节点的儿子开始搜索,而不是空串代表的根节点,因为没有后缀,而且根节点的儿子的 fail 应该指向根。对于一个不存在的节点 \(ch(u,c)\),我们直接在这个点上记录 \(ch(fail(u),c)\),也就是这个点一直向上跳可以跳到的最近的存在节点。
ACAM 的查询
考虑怎么对文本串进行查询。
先有一个暴力的思路:我们考虑每在末尾增加一个字符就维护这个串的后缀的出现串(因为我们要利用结尾的字符以防止重复计数),也就是子串是前缀的后缀的思想。而我们发现统计 Trie 树上没有的子串是没有意义的,所以我们每增加一个字符(就是在 Trie 上移动)就沿着 fail 向上跳,此时跳到的串肯定是出现在模式串的 Trie 上的,直接对其计数。
ACAM 的优化
发现一个问题:如此查询,如果 Trie 树是一条链,那么总的时间复杂度还是可以卡到 \(O(|S|^2)\),那很不好了。考虑怎么优化。
我们发现,除了根节点,fail 节点都指向一个深度更低的节点。也就是说,所有的 fail 节点构成一棵内向树。在对于点增加贡献的同时会对点的所有祖先增加贡献,所以一个节点得到的总贡献就是其子树的总贡献。所以我们可以直接对单点挂贡献,而子树贡献求和是简单的。这样时间复杂度就降低到了 \(O(|S|)\)。
ACAM 的模板(模板不计遍数)
P3808:就是正常的 ACAM,但是注意每个节点只计算一次贡献,由于一个点被访问过的话祖先一定被访问过,所以我们走到被访问过的节点时直接停止访问。
P3796:我们记录每个串在 Trie 树上的状态编号,增加贡献即可。
P5357:我们使用子树求和。注意 BFS 有没有把队列不为空写成队列为空。
ACAM 的使用
专题:最短母串问题
给你 \(N\) 个串,构造一个串使得所有的串都是这个串的子串。最小化长度和字典序。
P2322(五遍):卡点最多的一次。
卡点一:直接写了暴力匹配的状态压缩 DP,结果写假了。
卡点二:在 ACAM 上面找状态时直接和当前节点的父亲的状态取了并(这一步应该在搜索里完成)
卡点三:记录了每个点在 Trie 树上的父亲,但是事实上 ACAM 中的 Trie 树因为加了 fail 边而变成了字典图,不能直接按照树边找父亲。
卡点四:直接写哈希的 vis 以及开 long long 和 N 个串导致空间常数极大。
卡点五:在 BFS 的时候先判定是否已经被访问后找状态,导致出现了在结果树上向父亲连一条边的情况。
多个字符串的匹配问题考虑 ACAM。考虑我们怎么在 ACAM 上面匹配的。就是每个前缀跳 fail 然后向上求和。我们考虑直接对于每个点把其满足的状态(\(n \le 12\) 考虑状态压缩)和 fail 树上的祖先取并(可能有重复),并且 Trie 树上插入的时候也是取并。这样每个串串的后缀子串中可能存在的个数就被处理了。接下来构造前缀。由于儿子不存在时就会自动跳转到 fail 上,我们要同时把点和状态一起记忆化。为了查询方便,我们用两个变量建一个结果树。由于点是严格按照字典序向儿子遍历,我们每一次遍历儿子时总数新增,遍历完结果树上的当前节点加一。由于此时两个节点的顺序都是 BFS 序列,是合法的。由于我们希望最小化长度和字典序,我们使用 BFS。找到一个节点状态满足所有串串的时候(字典树上直接存储了哪些节点满足哪些状态)就在结果树上输出当前节点(注意:不是总节点,可能上一个点有兄弟/其他儿子)遍历其在 结果树上 的父亲一直到根。倒着输出。
ACAM 上 DP
Trie 树可以用来表示匹配关系。但是 Trie 树是有限的,而我们希望可以有无限的长度的串可以被构造出来匹配,并且串的构造尽可能添加有意义的节点。那么我们在 Trie 树上走到头了的时候希望可以去当前串的某个存在的后缀。于是我们找一个这样的结构。对了就是 ACAM。
AT_abc419_f(两遍):
卡点:忘记在 ACAM 上一个点的状态压缩是怎么做的了。
由于一个点的后缀也是可以匹配的,ACAM 上一个点(表示的串)实际上可以满足的是 Trie 树上节点本身和其在 fail 树上所有祖先的状态的并。
设 \(dp(len,u,S)\) 表示当前长度是 \(len\),节点是 \(u\),当前状态是 \(S\) 的方案数。由于我们一直找儿子就可以实现在 ACAM 上无限操作,我们每一次直接找儿子(没有儿子就等于跳 fail)。也就是
自然是先按顺序枚举 \(len,u,S\) 之后找出点。
常用的 Trie 树——01 Trie
P4551(一遍):树上异或路径的最大值。我们令 \(dis(u)\) 表示 \(u\) 到根路径的异或距离。那么 \(dis(u,v) = dis(u) \oplus dis(v)\)。那么我们直接枚举一个 \(dis(u)\),找一个 \(dis(v)\) 使得这个值最大就行。考虑到异或的每一位是独立的,我们直接从高到低位建立 01 Trie。发现高位选 \(1\) 一定比高位选 \(0\) 更优(考虑 \((10000)_2 > (01111)_2\)),我们贪心走,对于 \(dis(u)\) 的当前位置 \(p\),如果 \(p \oplus 1\) 在 Trie 树上存在转移边就直接走,反之就走 \(p\) 的转移边。由于走完的一整条路径(假设长度就是 \(32\) 位)必然是原本的数组里的一个数,这么构造必然是合法的。
01 Trie 与构造
CF37C(一遍):构造 \(n\) 个长度给定的 01 串,使得不存在一个串为另一个串的前缀。
长度不大于 \(1000\),直接开定长点数会达到 \(O(2^{len})\)。所以只能够动态开点。考虑一个串的前缀的长度必然不大于本身,所以我们按照区间长度从小到大构造,标记每个存在串的节点。如果一个节点的两个儿子都已经被标记则我们不能到这个节点(无路可走)。如果这样仍然走到一个无路可走的点就直接是无解。否则能走左边就走,反之走右边。
Trie 树合并
CF778C(三遍):非常难评的一道题。我们发现删除字符等价于删除某一行的节点。但是所有儿子要合并(出边相同的并为一条)。考虑怎么给 Trie 树合并。
和 SGT,FHQ 一样,Trie 树为一个儿子数量为字符集大小的树形结构。也就是说,三者的合并操作是原理相通的。我们考虑每一次新开一个节点(为了减少清空我们可以直接可持久化),其儿子为原本两个树的对应儿子合并的结果。最后直接返回节点编号。我们发现每新开一个节点等价于把原本两个树根节点的儿子合并,也就少了一个节点。所以我们记录每个节点合并其儿子所减少的节点个数 \(\Delta cnt\),对答案做贡献即可。
Manacher
SA + SAM
SAM 是增量构造。
什么时候用 SA,什么时候用 SAM
一般来说 SAM 是一个结构,而 SA 是一个算法。尽管二者可以共用的范围相当广,但是在涉及更多和串有关的问题时使用 SAM 往往可以减少思考时间和思维难度。所以,一般只在涉及 LCP 的信息时才会用 SA。
Height 数组的基本性质
一、Height 数组包含了所有区间内出现次数大于 \(1\) 的子串,因为两个不同坐标的后缀的 LCP 一定是出现了至少两次的。考虑到 Height 作为区间 Min 的时候是两个不同串的 LCP,也就是多一个出现次数。所以 Height 内一个数作为区间 MIN 的极大范围加一(自己本身也算一次)就是其在原本的字符串内的出现次数。为了防止重复计数,我们钦定左边是可以取等的,而右边不行。于是每个点作为 LCP 的范围就不会重合了。
二、对于按照 \(rk\) 排序从第 \(i\) 个开始的新增的本质不同的字符串个数:就是 \(n-sa(i)+1-h(i)\)。原因很简单,就是从 \([sa_i,n]\) 的后缀减去与 \(sa(i-1)\) 重合的 \(h(i)\) 个。并且如果串按照 \(rk\) 排了序是直接按照字典序加入的。可以用来求本质不同子串有关信息。那么自然地,有全部的不同子串个数为 \(\frac{n(n+1)}{2}-\sum h(i)\)。
和的平方类型的贡献的处理
在 \(a,b \in \N*\) 时 \((a+b)^2 \ne a^2+b^2\),所以对于和的平方,我们要拆除贡献。
考虑莫队怎么处理的,发现 \((x+1)^2-x^2=2x+1\),也就是贡献「此前的贡献量」的两倍加一。
所有后缀的 LCP 的和
这个东西在 SA 有关问题里面非常常见,一般不考虑自己和自己的 LCP 造成的贡献。
CF802I(两遍):
卡点:没有想到怎么拆平方贡献。
发现对于一个子串 \(S\)。假设此时已经出现了 \(x\) 次,那么下一次出现时贡献是 \(2x+1\)。
我们分离常数。对于后面的 \(1\),其总贡献就是串的总数 \(\frac{n(n+1)}{2}\)。常数系数直接算完贡献 \(\times 2\) 即可。考虑增加 \(x\) 的贡献。
SA 一般处理 LCP,考虑怎么和 LCP 挂钩。对于两个后缀,其 LCP 的前缀会出现至少两次,也就是说 LCP 的所有前缀此前都出现过,都会增加 \(1\) 的贡献(发现每一个当前串和它此前出现过的串都会增加一的贡献,就是拆贡献),所以答案就是 \(\sum_{i,j} \operatorname{lcp}(i,j)\)。
而 \(\sum_{i,j} \operatorname{lcp}(i,j)=\sum_{i=2}^n \sum_{j=1}^{i-1} \min(h[j+1:i]) = \sum_{i=2}^n \sum_{j=2}^{i} \min(h[j:i])\)。
从 P3804 我们联想到用单调栈存储每个 \(h_i\) 作为区间最小值的极大区间 \([pre_i,suf_i]\),那么我们统计左端点在 \([pre_i,i]\),右端点在 \([i,suf_i]\) 的区间个数乘上 \(h_i\) 长度作为总贡献,也就是 \((i-pre_i+1)(suf_i-i+1)h_i\)。
这样就可以处理很多问题了。
CF123D(一遍):求 \(\frac{1}{2}\sum_p cnt(p,s)^2+cnt(p,s)\)。一次贡献的和就是 \(\frac{n(n+1)}{2}\)。之后二次贡献用上面的方法。
P3181(两遍,八月十二日再记忆):
卡点:不知道怎么去除重复贡献,写了一个 \(O(N^3)\) 的 HASH。
发现就是求 \(\sum_{i,j} \operatorname{lcp}(i,j)\)。但是 \(i,j\) 应该在不同的串。考虑 \(i,j\) 不是这么分布的就一定要么在 \(a\) 串里要么在 \(b\) 串里,直接去除这些贡献即可。
特别注意:这样计算的 LCP 不会计算自己对自己的贡献。
还有一个和 LCP 没有任何关系的写法。为什么我们不用 SAM 呢?考虑 endpos 相同的串有 \(len(u)-len(link(u))\) 个,所以每一个串的答案自然就是 \(\sum_u ((len(u)-len(link(u)))\times \binom{size(u)}{2})\)。而 \(size(u)\) 表示当前节点 endpos 的大小。
单个串信息处理好帮手——SAM
SAM 的实现
原理比较难说明,我们考虑直接把插入背下来。我们只需要知道 SAM 的插入是增量的,就是每一次插入一个字符。每一次插入完的 \(lst\) 就是当前节点,直接处理贡献即可。
//空间开长度的两倍
int len[N],lnk[N],n,lst,tot;
map<int,int>sam[N];
void SAM_init(){
len[0]=0,link[0]=-1;lst=tot=0;
}
void SAM_insert(int c){
int x=++tot,p=lst,q;
len[x]=len[p]+1,lst=x;
while(p!=-1 && !sam[p][c]){sam[p][c]=x;p=lnk[p];}
if(p==-1){lnk[x]=0;return;}
q=sam[p][c];
if(len[q]==len[p]+1){lnk[x]=q;return;}
len[++tot]=len[p]+1,lnk[tot]=lnk[q],sam[tot]=sam[q];
lnk[q]=lnk[x]=tot;
while(p!=-1 && sam[p][c]==q){sam[p][c]=tot;p=lnk[p];}
}
//link 是关键字,要写作 lnk。
如果想不起来了的话就看一看。
一共有且只有九行代码,直接背下来。
link 描述后缀树,则子串直接刻画为后缀树上的链和 DAWG 上的路径。
SAM 的特性
本质不同子串个数
插入完每个字符之后得到一个前缀节点 \(u\),则当前前缀的本质不同子串个数是前一个加上 \(len(u)-len(link(u))\)。
endpos 大小
SAM 的一个节点存储的是若干 endpos 相同的串的集合。由 link 的定义,我们有 父亲的 endpos 必然是子树 endpos 取并集(在 endpos 包含或不交的前提下就是求和)的结果。所以子树求和就可以算出 endpos 大小。每一次插入一个节点的时候把计数器加一即可。我们不算空串。
P3804(一遍):用 SAM 做甚至更加简单。注意一个串的出现次数就是 endpos 大小。在 endpos 大小相同的时候我们又希望选择更长的,那么最长就是 \(len(u)\) 了。
parent 树的性质
本质不同子串长度和
一个点表示的是 endpos 相同的串的集合,那么我们不难得到这些串就是最长的那个串的所有后缀。
因此 parent 树上的祖先必然是子孙的后缀。因此每个除了根节点(空串)之外的点对答案的贡献(如果和 \(len(u)\) 有关)就是 \(val(len(u))-val(len(link(u)))\),其中 \(val\) 表示后缀贡献的计算结果。
经常用来进行和“本质不同子串”有关的运算。例如本质不同子串个数就是 \(len(u)-len(link(u))\)。
AT_s8pc_2_e(一遍):什么野鸡题号。计算本质不同子串的长度之和。我们知道,对于一个节点 \(u\),其最长串的所有后缀的贡献就是 \(\frac{len(u)(len(u)+1)}{2}\)。但是这包含了其父亲的节点个数,我们减去其父亲的串包含的贡献就是当前节点对于答案的净贡献了。
P4248(一遍):虽然说所有 LCP 的和用 SA 会更加方便,但是 SAM 比 SA 短啊。考虑每一个串出现两次就直接等价于这个出现两次是两个后缀的 LCP。答案里 LCP 求和部分直接转写为 $$(len(u)-len(link(u)))\times \binom{siz(u)}{2}$$。
第 \(K\) 小子串
P3975(五遍):
卡点:第一,一开始不会 SAM 就先写了 SA 写法结果是假的。第二,没有弄清楚 DAWG 上面是对路径计数还是对到达节点计数。第三,空串不能计算,结果清空根节点和一些边界条件(例如减到零是否在当前节点)没有写明白。第四,求从一个点出发的路径个数错误地使用了直接倒编号的方法,可能导致了漏计数和多次更新答案,以及数组名称混淆不清。我们直接把 endpos 大小换成权值,再来在 DAWG 上面 DFS,重复访问的节点不再更新大小。第五,错误地使用了递归查询导致可能无法跳出,考虑直接记录点来在主函数里模拟跳的过程。
由于位置不同算不同子串时一个点增加的串的数量是 endpos 的大小(SAM 里面记录的是 endpos 大小相同的一个最长的前缀,并可以维护其所有后缀),我们应该记录当前所有点 endpos 的大小。这是简单的,参照上面子树求和。
由于一个后缀的前缀(也就是子串)在 DAWG 上表示为从根节点出发得到的一段路径,所以从一个点出发的子串个数就是在 DAWG 上面从 \(u\) 出发的路径个数,而在 DAWG 上面数路径就只需要对于后续节点的路径简单相加,因为从 \(v\) 出发的所有路径加上 \(u \rarr v\) 这条边就是从 \(u\) 出发的路径。
那么我们直接搜索计数即可。考虑本质不同子串的时候增加的串的个数就是 \(1\),我们同时根据要求修改节点的 endpos 大小所带来的单点权值,之后再去做 DAG 上计数。特别注意计数之前要把根节点的单点权值和单点权值对路径权值的贡献(就是其本身)清空,因为空串是不被允许的。
考虑怎么去求第 \(K\) 小的串。一个串可以在 SAM 上用路径表示,只要从根节点出发走转移边并写出边权即可。首先由于点本身代表的串在原串中出现了权值次,所以我们要减去单点的权值(减完等于零也是在单点内的)。考虑其在 DAWG 上的后续节点。我们知道串是按照字典序排列的,所以我们考虑按照出边边权从小到大遍历,如果出点可以到达的串的总和小于(等于的时候就还是在里面的,单点同理)当前查询大小,则当前查询大小直接减去当前后续节点个数即可。而如果小于等于就直接向后续节点移动并输出当前边权。
后续:SAM 是一个非常有用的结构,可以有效率的解决问题,需要加强对 SAM 的理解。
多串处理工具——广义 SAM
可以有效刻画更多子串。已知大多数可以用 SAM 解决的串的问题都可以扩展到字典树上。这样我们就得到了一个广义 SAM。
GSAM vs ACAM
GSAM 相对于 ACAM 来说,处理子串的信息更加精密,并且其思想是基于 SAM,可以处理的问题比基于 Border 的 ACAM 更多。(事实上 ACAM 就是把 Border 扩展到字典树上的产物。)比如多个串的最长公共后缀。并且 SAM 把 endpos 等价的后缀全都放在同一个节点里面, 大大压缩了信息。不过因为信息压缩,字符串 DP 很多时候还是使用 ACAM,因为这就是字典树加上 Border 树。
一般来说,如果对于多个串进行维护,我们一般先建立字典树,在字典树上进行维护。
GSAM 的实现
我们考虑对于每个 SAM 上的节点,插入时其 last 节点是确定的。由于我们已经建立了字典树,我们就令其为当前节点的父亲在 SAM 上面的节点,字符为当前点到父亲的边的权值。直接用正常的 SAM 插入即可。由于 SAM 的插入实质上是插入若干 \(len\) 单调加一的点,考虑对于字典树进行 BFS,这样对于一个点,此前深度比它小的点就已经全部加入了。那么就从字典树根节点的儿子(字典树根节点代表空串,不用插入,因为 SAM 本身带有空串节点)开始 BFS 插入即可。
调不出来就直接重构代码。
P6139(模板,但是调了一晚上):
q=sam[p][c] 不要写成 q=ch[p][c]。
就是上述流程。调不出来就直接重构代码,会有奇迹发生。
"GSAM" 的在线构造
有的时候我们会考虑在线构造多个串的 GSAM。这个 GSAM 需要维护每个串的不同信息(比如和串编号相关)。这个时候就不能离线构造了。考虑在线构造。
然而在不考虑节点数,\(O(\sum |S|)\) 都可以过的情况下,我们可以不使用含有较多分类讨论的 GSAM 的在线构造,而是用一种相对来说更加简单的 "GSAM"——伪广义后缀自动机。
伪广义后缀自动机的构造
我们考虑每插入一个字符串就把 lst 归零。
这样构造在大多数情况下是正确的,但是会存在空节点导致答案受到影响。考虑直接特判掉所有空节点。
空节点 \(u\) 等价于 \(len(u)=len(link(u))\),在记录节点对答案贡献时忽略空节点即可。
SAM 计算贡献
由于我们把串在 SAM 上面刻画成了 DAWG 上的路径和 parent 树上的路径,我们可以 DAG 上 DP 或者树上 DP。
parent 树有这样一个性质:对于一个点,其 endpos 一定是其父亲的 endpos 的子集。反过来说,一个点的 endpos 是其儿子的并集。由于 endpos 要么包含要么不交,对于一个节点维护其与 endpos 有关的信息,只需要在 parent 树上子树求和即可。
parent 树上计算总贡献
CF616F(三遍):
卡点:一开始想 SA,但是不知道怎么判断 \(h\) 是否包含分隔符。后来学习伪广义后缀自动机,但是不会判空节点。
考虑把式子换换位置。\(ans=\max_s |s|\{\sum_i c_i p(s,i)\}\)。考虑 SAM 上面每一个点表示的是什么。SAM 上面表示的是末尾出现位置相同的一些串。也就是说它们在构建 SAM(或者 GSAM)的所有串中的出现位置是一致的,也就是括号里面的东西是一致的。考虑构建广义 SAM,由于要把所有出现的 \(c_i\) 求和,我们每次给到达的节点赋值 \(c_i\)。所以要在线构造,考虑用排除空节点的伪广义 SAM。最后 parent 子树求和就得到每个点的实际贡献。由上式,我们希望用一个节点里面最长的串,直接 \(ans=\max_i len(i)val(i)\)。注意排除空节点以防止出现空节点贡献导致 WA。与此同时,答案的下界是 \(0\),因为我们取空串即可。
数学
数学常识和技巧
最小切比雪夫距离
AT_abc419_c(一遍):给定 \(N\) 个点,求一个点使得其到所有点的切比雪夫距离的最大值最小。
由于切比雪夫距离就是横坐标差和纵坐标差的较大值,我们可以对横纵坐标分开考虑。发现我们只要对当前坐标的最小值和最大值的距离最小,直接取两个值的平均值作为当前节点的坐标即可。
Fib 与 GCD
P1306(一遍半):
要知道一个性质:\(\gcd(F_a,F_b)=F_{\gcd(a,b)}\)。然后上矩阵。
对于平方的和一定要敏感
CF1188B(两遍):求 \((a_i+a_j)(a_i^2+a_j^2) \equiv k \pmod p\) 的 \((i,j)\) 对数 \((i < j)\)。
卡点:没有发现本题要构造一个系数。
一开始直接拆开看看能不能解方程,发现这是困难的,直接枚举 \(O(N^2)\),TLE on #6。
如果看到两个数平方的和,试着在前面加一个平方的差,得到另一个平方差。对于这道题,我们在前面乘一个 \((a_i-a_j)\),这个式子就会变得非常美妙。
\(k\) 是给定的,考虑直接移项。
于是转换成了一个可以用 map 的方案。存储 \(x_i=a_i^4-a_ik\),那么答案就是 \(\sum {cnt(x_i) \choose 2}\)。
后续思路:如果涉及到数对计数的,试试看能不能找到满足数对的数自身有无相同属性,如果有就存储相同属性并在每个属性内部计数。
拆 01,减法变加法,考虑答案不可能是什么
如果只考虑奇偶性,我们会得到一个良好的性质:\(|a-b| \equiv a-b \equiv a+b \equiv a \oplus b \pmod 2\)。然后我们可以把式子修改为一些很好看的形式。
AGC043B(两遍):卡点:做法假掉了,答案不仅仅和首尾有关。
首先把邻项的差给算出来。发现之后所有数属于 \(\set{0,1,2}\)。考虑如果差存在 \(1\),则答案不可能是 \(2\),反之答案不可能是 \(1\)。
前者答案仅仅和奇偶性有关,考虑式子形如 \(f_{i-1,j} \circ f_{i-1,j+1}\)。很像组合数。但是组合数是加法。根据同余知识,我们直接把式子变换为 \(f(i,j)=f(i-1,j)+f(i-1,j+1)\)。那么我们直接令差为 \(A\),\(n\) 为 \(A\) 的大小,则我们模拟贡献发现确实和组合数有关。于是 \(Ans=\sum_{i=1}^n \binom{n-1}{i-1} A_i\)。但是这里两个数都很大而模数很小,不好直接按照组合数算,不然也许没有逆元。考虑 Lucas 定理。当然我们可以手动模拟 Lucas 的奇偶性。由于 Lucas 事实上是把两个数各自按照模数进制拆分之后在按位组合数乘积,考虑四种位置方法,发现只有 \({0 \choose 1}=0\),其余都是 \(1\)。所以只要 \(m\) 是 \(n\) 子集就是 \(1\)。因此 \(\binom{n}{m} \equiv [n\&m=m]\)。直接 \(O(n)\) 可做。
把一个数替换为整个序列的异或和
AGC016D(两遍):卡点:没有熟练理解异或两次会被抵消这一事实。
考虑如果把一个数替换为全局异或和则这个数和全局异或和会怎么变换。记序列为 \(A\),全局异或和为 \(P\)。
把 \(A_x\) 替换为 \(P\),则 新的全局异或和 $P'=\bigoplus_{i=1}^n A_i \oplus A_x \oplus P=P \oplus A_x \oplus P = A_x $。如果我们直接存储全局异或和,则一次操作就是交换全局异或和与当前节点。我们的目标是把 \(A\) 转换为 \(B\)。如果 \(A\) 算上 \(P\) (开 map 记录出现次数)减去 \(B\) 之后有负数就无解,反之就一定是多一个 \(1\),而剩下的减完了。
那么我们考虑把每个 \(b_i\) 向 \(a_i\) 连边,由于每个点都有一个出边而多了一个点,所以要么是一些环,要么是多了一个以 \(P\) 为链头的链。注意 \(P\) 可以和相同的值相互替换所以相同的值视为同一个点。那么我们从 \(P\) 出发交换当前节点和出点。如果不连通,我们就需要向链的头部(头走到尾),或者环上的任意一点连边使得图可以连通。那么我们必然连出一个欧拉图,此时操作个数就是新的边数就是 \(m+C-1\),\(C\) 为连通块个数。
排列异或下标
ZR-3320(一遍):不难发现每次异或出一个 \(2^n -1\) 必然是最优的。那么我们令 \(S=2^p-1\) 之后当前的可用区间是 \([S-n,n]\cap[1,n]\),则此时操作的点的个数是可以计算的,点对数量的计算就是直接减半,而点对和当前操作数量是取较小值的,之后剩下的数是可能可以异或出更小的值的,发现每次都是删除一个后缀,对问题递归求解即可,最好是来个记忆化。
数论
Miller-Rabin
就是费马小定理,但是指数不断除以二,测试时不断平方,开出 -1 就完成,开出 1 就跳过。
EXGCD
这个喷不了这个真得背。
int EXGCD(int a,int &x,int b,int &y){
if(!b){x=1,y=0;return a;}
int x0=0,y0=0,d=0;d=EXGCD(b,x,a%b,y);
x0=x,y0=y;
x=y0;y=x0-(a/b)*y0;//先 x 后 y 先 a 后 b
return d;
}
专题:碰到边缘就反弹
如果我们以 45°角把一个台球在矩形的台球桌上面击打的话,这个球会碰到边缘。根据物理知识我们知道台球打到边上会反弹且反射角等于入射角。那么考虑台球什么时候会到达某个台球桌的角上。
这个【反射问题】是一个同余的经典问题。首先考虑我们按照正 45°角(\(x'=y'=1\))来打击台球。如果不是这样的,我们就翻转球桌。
事实上,我们正是通过翻转球桌(而不是翻转球)来实现本题限制的一般化的。考虑在左下角在原点放置一个桌子,并对于每个公共边,向上或者向右关于这条边作对称放置一个翻转的台球桌。那么我们就成功让整个直角坐标系内部充满了台球桌,并且我们球的运动轨迹可以变成一条射线 \(y=x+b,x \ge 0\),只需要在对应的台球桌上使用其对应的球桌坐标系即可。那么最后一个角就可以表示为 \((an,bm)\)。考虑反向延长球的运动轨迹。设球的初始位置是 \((X,Y)\),那么轨迹就是 \(y=x+(Y-X)\)。于是我们就有 \(an+(Y-X)=bm \rArr an+(-b)m =(X-Y)\)。
小知识:用 EXGCD 解决 \(ax+by=c\) 的时候 \(c\) 是允许为负数的,因为 EXGCD 本身和 \(c\) 无关。
我们用 EXGCD 解决这个方程即可。注意:我们需要得到一个最小解,也就是第一次到达的角。由于此时 \(k=1\),我们最小化 \(a\) 和最小化 \(b\) 是等价的。由于 \(a\) 不带负号,我们最小化 \(a\)。
特别注意:在 \(Ax+By=C\) 求最小解之前先求出一个可行解之后 \(A,B,C\) 全都要除以一个 \(\gcd(A,B)\),不然值域和答案步长会有问题。还有,\(x\) 最小的时候 \(y\) 就是最大的。
注意如果 \(a\) 或者 \(b\) 是偶数则对应的台球桌坐标是要取反的(\(x \gets n-x \text{ or } y \gets m-y\)),还有原本是不是取反了。
CF982E(三遍):
卡点:上面的几个小标题,还有之前没有见过翻转问题的 Trick。
过程在上面。水平和竖直移动以及不移动就自己特判了。
EXCRT
令 x=ap+b=Aq+B,求一个最小的 p,然后 \(x\equiv (ap+b)\pmod {\text{lcm}(a,A)}\)。
欧拉降幂:用于大整数的幂运算
如果不互质,一直模phi加phi直到小于phi。
卢卡斯定理:用于大整数的组合数(可能没有逆元)
商的组合数乘上模数的组合数。实际上是按照进制拆分之后按位组合数乘积。
大步小步:离散对数
互质就一直除以 \(\gcd\)。
大步是根号,小步只有根号个。\(k^x=k^{At+B}\)。
线性筛的正确使用——以因数个数为例
线性筛可以筛各种积性函数,只要我们搞明白函数在素数的次方的取值。
令 \(num(i)\) 表示 \(i\) 最小素数因子出现次数。
若 \(i \bmod j = 0,j \in \text{prime}\):\(d(ij)=d(i)\frac{num(i)+2}{num(i)+1}\)
其他的不难维护。
莫反+数论分块+杜教筛:看见 \(\gcd\),公式做题就是快
莫反出 \(\gcd\) 和互质,出一个 \(dt\) 状物,把它换成 \(T\),得到一个 \(f * \mu\) 状函数,直接杜教筛,过了!
一定要这么做,不管公式多逆天都能写。
反演的时候碰到一个可以数论分块维护的东西就直接维护,不再化简。(如求 LCM 和)。
杜教筛:\(S(n)=\sum_{i=1}^n f(i)\)
约数个数和(重点背记)
P3935(一遍):首先发现 \(d=I*I\),马萨卡,杜教筛?事实上 \(g=\mu\) 那是不可能的。我们考虑这种数论题目和下取整有点关系,考虑推一推,推出个下取整来。先来个前缀和。
就是个草包题,数论分块即可。
妙妙题
P2522(一遍):常见的步骤:
- 换成前缀和。
- 把 \([\gcd(i,j)=k]\) 换成 \([\gcd(i,j)=1]\)(\(n,m\) 除以 \(k\))。
- 互质换成 \(\sum \mu(d)\) 并放在前面。
- 数论分块。
然后就完成了。
欧拉反演
实则是莫比乌斯反演,因为有 \(\mu * \operatorname{Id}=\varphi\)。
P2398(一遍):路边一条。
公式化推式子可以得到最后是
而后面那个卷积出来就是 \(\varphi\)。于是答案是
直接数论分块。
关于 \(f * \mu\) 的处理
暴力卷积:\(O(n \log n)\) 直接维护。
如果是积性函数:线性筛提前处理。
如果 \(f\) 的前缀和可以 \(O(1)\) 计算:\(g=1\),然后是杜教筛。
原根
P6091(模板不计数):\(g\) 为 \(n\) 的原根当且仅当 \(1 \le g \le n-1\) 且 \(g\) 模 \(n\) 的阶是 \(\varphi(n)\)。特别地,当 \(n\) 为素数时,\(g^i \bmod n\) 可以取到 \([1,n-1] \cap \Z\) 中的所有数。
已知只有 \(1,2,4,p^{\alpha},2p^{\alpha}\) 存在原根。其中 \(p\) 是奇素数,\(\alpha\) 是正整数。
最小原根 \(g\) 是 \(O(n^{0.25})\) 的,本题不会超过 \(32\),直接暴力枚举。
一个数是 \(n\) 原根要满足 \(g^{\varphi(n)} \equiv 1 \pmod n\) 且 \(\forall 1 \le i < \varphi(n),g^i \not\equiv 1 \pmod n\)。
\(O(\varphi(n))\) 枚举那是不可能的。考虑下面这个事实:若 \(a \perp k\),且 \(a^k \equiv 1 \pmod n\),则\(k|\varphi(n)\)。所以我们只需要判断 \(\varphi(n)\) 的所有真因数作为指数是否满足条件。进一步地,我们只需要求 \(\frac{\varphi(n)}{p}\) 作为指数是否满足条件。其中 \(p\) 为 \(\varphi(n)\) 的质因子。
并且若 \(1 \le z \le \varphi(n)\) 且 \(z \perp \varphi(n)\),则 \(z\) 也是 \(n\) 的原根。所以 \(n\) 的原根有 \(\varphi(\varphi(n))\) 个。直接排序输出。复杂度大概是 \(O(n^{0.25} \log n)\) 的,比较小。
不考的
Wilson 定理,二次剩余……
Poly + GF
高精度乘法就是暴力卷积。
OGF
普通生成函数,主要用于解决组合类计数问题。可以在草稿纸上计算转移方程的 GF,并进行多个 GF 的运算(卷积)得到一个更简便的GF,还原为转移序列。例如背包。
Catalan 数的OGF:\(\sum_{n \ge 0} \binom{2n}{n} \frac{1}{n+1} x^n\)。
Fibonacci OGF:\(\frac{x}{1-x-x^2}\).
\(F_i=1,2,3,5,8,...\)
Proof:\(F=xF+x^2 F + F_1 x - F_0 x+F_0\)
用待定系数法求通项公式。
小技巧
状态比较少可以暴力卷积。
很多时候得到一个有限的等比数列。先求和后推导。
\(1+x+x^2+...+x^n=\frac{1-x^{n+1}}{1-x}\)
乘上 \(\frac{1}{(1-x)^k}\) 等价于做 \(k\) 轮前缀和。而 \(k\) 为负数则为撤销 \(-k\) 轮前缀和。做前缀和时从后往前,撤销时从前往后。可以 \(O(nk)\) 维护,\(k\) 比较小一般为线性维护。
for(i=n;i;--i)dp[i]+=dp[i-w];//乘 (1+x^w)
for(int i=1;i<=n;++i)dp[i]-=dp[i-w];//除
牛顿二项式定理
OGF 计算中一般用于 \(n\) 为负数的情形。
EGF
这是最基本的 EGF。注意到 EGF 比 OGF 来说多了一个阶乘,而排列比组合多一个阶乘,所以 EGF 用于解决排列有关问题。
长 \(n\) 排列的 EGF:\(\hat{P}=\sum_{i \ge 0} \frac{i! x^i}{i!}=\frac{1}{1-x}\)
长 \(n\) 圆排列的 EGF:\(\hat{Q}=\sum_{i \ge 0} \frac{(i-1)! x^i}{i!}=\ln(\frac{1}{1-x})\)
所以有 \(\exp(\hat{Q})=\hat{P}\)。
错排 EGF 为不存在为自环(环长不小于 \(2\))的置换环的 EGF,为 \(\exp(-\ln(1-x)-x)\)。
OGF-EGF 互相转化
其实,这两个东西是可以互相转化的。
如果你 OGF 推出 \(\frac{x^i}{i!}\),可以转化成一个 EGF 的系数。
如果你 EGF 把阶乘删除了,也可以转为 OGF。
高消·线代
组合数学
排列与置换环
对于排列 \(p\),\(p_i\) 向 \(i\) 连边得到若干置换环。
排列与置换环图可以一一对应。
球和盒子
CF893E:改成正因子拆分。答案乘上 \(\sum_{i=0}^{\lfloor \frac{n}{2} \rfloor} \binom{n}{2i}=2^{n-1}\)。
求 \(x_1+x_2+...+x_m=k_i,x_i \ge 0\) 的方案数。
插板法可以做,答案就是 \(\binom{m+k_i-1}{k_i}\)。
然后就做完了。
下面是球和盒子问题的总结。用 \((A,B,C)\) 分别表示:
- \(A\):\(r\) 个球是否相同
- \(B\):\(n\) 个盒子是否相同
- \(C\):盒子是否允许为空
| 情况 | 方案数 |
|---|---|
| (0,0,1) | \(r^n\) |
| (1,0,0) | \(\binom{n-1}{r-1}\) |
| (1,0,1) | \(\binom{n+r-1}{r-1}\) |
| (0,0,0) | \(r!S(n,r)\) |
| (0,1,0) | \(S(n,r)\) |
| (0,1,1) | \(\sum_{k=1}^r S(n,k)\) |
| (1,1,0) | \(B(n,r)\) |
| (1,1,1) | \(\sum_{k=1}^r B(n,k)\) |
\(S(n,m)\) 表示第二类斯特林数,\(B(n,m)\) 表示 \(n\) 的 \(m-\) 分拆数。
组合数的快速运算
考虑组合意义。\(n\) 级台阶中 \(n-i\) 步,钦定其中 \(i\) 个为两步的方案数。
错排公式。
也可以容斥计算。
线性代数
关于矩阵加速
我们知道矩阵可以用来加速 DP,特别是线性递推且转移距离特别大的情况。
P6569(被卡常,两遍):我们发现可以做矩阵快速幂,并且由于转移矩阵里的数是 01,所以乘法和异或在此时是满足分配律的,可以用来做矩阵乘法。
结果用 \(O(N^3 \log N)\) 的矩阵乘法被卡常了。发现我们只需要维护一个点(左上角)的值,所以最后的乘法可以 \(O(N)\) 求。至于前面求转移矩阵,我们可以通过倍增预处理,减少大量的重复乘法。因为这里一次乘法的复杂度达到惊人的 \(10^6\),需要减少大量乘法。最后卡时间来到 \(60\) 分,开 C++ 20 才过。
卡点:不了解 C++ 的一些特性。
我们首先把倍增时需要的转移矩阵的编号存储在一个向量里面。转移的时候就把矩阵直接赋值为第一个转移矩阵,之后正常乘法即可。由于这样是严格 \(O(\text{popcount})\) 的,常数变得更小了,就开 C++14 即可。
高斯消元
准确地说现在更方便使用的是 Gauss-Jordan 消元。不同于传统的只能消为上三角的 Gauss 消元,Gauss-Jordan 消元可以直接把矩阵变换为更加漂亮的对角线矩阵,直接用来消元。每次我们消除一列。我们把当前这一列的值最大的行(如果没有被消除)给交换到当前行 \(i\),标记为已经被消除,对于不是当前行的矩阵的所有行 \(j\),定义权值是 \(w=\frac{a_{ji}}{a_ii}\),把这一行的每一列 \(l\) 全部减去 \(w\times a_{il}\)。
特别的一点是,初等矩阵的三个变换(数乘,加法,一行数乘之后加到另一行)可以用转移矩阵的乘法(左乘列变换右乘行变换)转移。
线段树维护矩阵与基本 DDP
矩阵乘法只要满足基本的运算定律就可以用矩阵维护,比如最优化 DP 里面常见的 \(\oplus = \max,\otimes = +\)。
GSS3(一遍线段树,学习 DDP): 考虑在一般的序列上我们是怎么维护最大子段和的。
\(tmp_i = \max(tmp_{i-1}+a_i,0+a_i),ans_i=\max(tmp_{i-1}+a_i,ans_{i-1}+0,0+a_i)\)
于是我们可以把这个东西刻画为矩阵的形式。使用改装的矩阵乘法。
之后就可以线段树维护矩阵乘法了。
杂项
DS
一般斜率优化优先考虑李超。
Trick+常数
数据结构删除数据:我们记录一个栈来维护哪些节点被使用过,最后只需要删除栈内部的节点就可以了。
笛卡尔树(Descartes 树)
算了不写中文了。
笛卡尔树可以用来刻画和最小值有关的贡献。举个例子:区间最小值是某个值的区间。
Descartes 树上的节点有 \(k,w\) 两个参数。而 Descartes 树是一个二叉树。其对于 \(k\) 属性满足二叉搜索树的性质(中序遍历是递增的),而对于 \(w\) 属性满足二叉堆的性质(父亲的权值不小于或者不大于儿子)。
Descartes 树的这个定义可以有一个性质:对于一个节点,其子树一定是一个连续的区间。所以,常见的刻画贡献的方法是:以 \(i\) 为 \(h\),以 \(a_i\) 为 \(w\) 对 \(a\) 序列建立 Descartes 树,这样可以得到一个以 \(a\) 为最小值或者最大值(取决于你用的是小根堆还是大根堆)的极长区间。
和虚树的单调栈建立方法类似,我们通过维护右链来 \(O(n)\) 建立 Descartes 树。
for(int i=1;i<=n;++i){
int tmp=top;
//记 tmp 为当前栈顶,top 为操作之前的栈顶
while(tmp && a[sta[tmp]]>a[i])--tmp;
//弹出所有不能作为当前节点父亲的点
rs[sta[tmp]]=i;
//把点设置为当前父亲的右儿子
if(tmp<top)ls[i]=sta[tmp+1];
//把最后一个删除的点(如果有)设置为当前节点的左儿子
sta[++tmp]=i;
//则当前节点是右链上最右的一个点,加入栈
top=tmp;
//更新当前栈顶
}
Descartes 的本质
Descartes 树可以方便地刻画一个以某个点为极值的极长连续区间,以及把极长连续区间的限制放在树上,而树上有一些不错的限制。
MX-P110182(两遍):卡点:限制太多直接晕了。
我们断言 \(Max=Len\) 的区间只有 \(O(n \log n)\) 个。考虑钦定 \(\max\) 位置之后的区间是 Descartes 树(下称 D-树)上的一个子树,那么个数一定不会多于 D-树上左右两个子树的大小的较小值,而一个子树被贡献应该满足每一个祖先的大小至少是翻倍,所以最多 \(O(n \log n)\) 个。那么我们考虑清楚地刻画左右端点的限制(比如说长度,区间内部)。我们只要枚举左子树范围和右子树范围里较小的一个扩展。D-树的右链一定要扩,有删除的也是扩的。
MX-R4T2(两遍):卡点:只会用堆。
用链表实现堆还是很高明的。值域最大 \(2^{24}\) 可以直接开这么多个链表。
我们每次和前向星一样更新链头。那么判断空就是如果头被删了就把头挪到下一个后判断空。链表上有效最小值更新是有效值为空就加一。删除的话就打删除标记更新最小值(如果答案最小值不存在就用有效最小值),而更新最小值的时候直接拿出链头,把当前值的头移动到下一个,把当前节点孤立掉,如果当前值的链表存在就插入到这个链表,不存在就把它变成链头。
扫描线
很多时候答案会在某个和一个变量有关的范围,并且可以离线。则我们把询问挂在这个变量上面并枚举这个变量,然后插入当前变量值的信息,并维护挂在当前询问上面的信息。一般用单点修改的线段树维护(我们把修改也挂在当前变量上面)。
P11364(两遍):NOIP2024 真题(T4)。
卡点:考场上看到之后第一步就没有发现,直接脆下。第一遍改题不会证明结论就没有写。
有区间询问,看起来很像数据结构题。考虑怎么把它变成数据结构题。
第一部分:解构 LCA
首先考虑区间的 LCA 怎么以更加简便的方法计算。
我们断言
也就是区间的 LCA 的深度是区间内所有相邻节点的 LCA 的深度的最小值。考虑从路径的角度去证明。我们走遍整个区间的节点,可以视为是走若干条从 \(i\) 到 \(i+1\) 的路径。而每条这样的路径上的深度最小的节点就是 \(LCA(i,i+1)\),而必然存在一对节点使得所有路径里面 \(LCA\) 深度的最小值是这对节点的 \(LCA\),不然这里根本不会被遍历到。而要想走遍区间就一定会走到这里,所以这个点就是整个区间的最近公共祖先,而其深度就是所有相邻点最近公共祖先深度的最小值。
我们从最近公共祖先本身的角度去考虑。由于我们仅仅统计区间内相邻节点 LCA 深度的最小值,而我们希望能够对于每个节点,而不是区间(数量太多),统计答案,我们希望能够找到以当前节点为 LCA,至少是以当前节点深度为 LCA 深度的极长的连续段。则令 \(a_i=dep(LCA(i,i+1))\),则我们希望能够找到以 \(a_i\) 为 LCA 深度的极长连续段,由于 \(a_i\) 是这样的连续段的最小值,我们考虑建立 Descartes 树,这样必然可以找到以当前深度为 LCA 深度的极长连续段。事实上我们并不关心所有节点是否在当前子树内,因为深度相同的连续段如果连续分布,理论上它们合并并不会对答案造成影响(毕竟不用求其 LCA,不影响 LCA 深度的最低性,本质上还是两个区间)。
记 \((l_i,r_i,k_i)\) 为区间左右端点和权值。则对于叶子节点为 \((u,u+1,a_u)\)。如果只有左子树则为 \((l_{ls(u)},u+1,a_u)\)(注意一个节点代表的右端点是 \(u+1\))。如果只有右子树则为 \((u,r_{rs(u)},a_u)\)。如果两个子树都有就是 \((l_{ls(u)},r_{rs(u)},a_u)\)。
此时只有 \(n-1\) 个极长连续段,并且这是代表区间内至少有两个点(\(i,i+1\))的连续段。如果只有一个端点,就是 \((i,i,a_i)\)。这样总共有 \(2n-1\) 个段,并且这样包含了所有深度为某个值的极长的连续段。
此时问题转换为:给定 \(tot\) 个带权线段 \((x_i,y_i,v_i)\),对于每个询问 \([l,r]\),给定一个参数 \(k\),求所有与当前区间的交的长度不少于 \(k\) 的线段的最大权值。此时就看起来很好用数据结构维护了。但是真的如此吗?
第二部分:改询问维度
区间交的长度限制看得人很不爽,考虑改为区间端点的限制。
考虑对于当前询问区间求哪些区间会对其造成贡献。
令当前询问是 \([l,r]\),当前贡献答案的带权区间是 \([x,y]\)。那么我们从小到大(非严格)把局面分成四类(区间有交):
- \((l,x,y,r)\)
- \((x,l,y,r)\)
- \((l,x,r,y)\)
- \((x,l,r,y)\)
首先前面两种的 \(y\) 都在询问区间内,也就是说 \([l,y]\) 一定会包含交。那么由于长度会不小于 \(k\),\(l+k-1 \le y \le r\) 一定成立。此时满足了 \(y-l+1 \ge k\),但是第一类的 \([x,y]\) 才是当前的交,所以 \(y-x+1 \ge k\) 也是条件。并且满足第一个条件的也就满足第二个。所以第一种对当前询问造成贡献的区间满足:
后面两种同理 \([x,r]\) 一定包含交,所以 \(y \ge r,x \le r-k+1\)。发现就算此时贡献只有当前询问区间本身也是合法的。所以第二种条件就是:
这样我们就得到了一个 \(O(n^2)\) 的暴力维护,并且看上去可以优化。
第三部分:火箭大工厂
发现很像数据结构优化。我们考虑当前对于询问区间和带权线段,各自有一个不变的信息满足某种偏序关系,而这又是作为贡献的条件之一,贡献又是和另一个变量的范围有关。我们可以把区间挂在信息上,只统计当前信息有关的区间,线段树扫描线维护。但是这里需要 chkmax 操作。发现是单点修改,看上去更加可行了。
考虑两类性质是相互独立的,应该分开计算。第二类性质看起来更加简单,考虑先算第二类。
把带权区间挂在 \(y\) 上面,倒着枚举 \(r\) 并先扩展 \(y=r\) 的部分。具体来说,就是把当前区间的权值放在线段树的 \(x\) 上。之后考虑每个挂在 \(r\) 上的询问 \((l,r,k)\)。那么这个询问在当前条件的最大值就是 \(x \in [1,r-k+1]\) 的最大值了。单点修改区间最大值,线段树即可。
接着考虑第一类性质。
把带权区间挂在 \(y-x+1\) 上面,倒着枚举 \(k\) 并先扩展 \(y-x+1=k\) 的部分。具体来说,就是把当前区间的权值放在线段树的 \(y\) 上。之后考虑每个挂在 \(k\) 上的询问 \((l,r,k)\)。那么这个询问在当前条件的最大值就是 \(y \in [l+k-1,r]\) 的最大值了。单点修改区间最大值,线段树即可。
这样我们就做完了。实现起来其实不算太长,3.3 KB 的代码长度还是比较适合作为考题的。只能说 CCF 的考题比较考察选手的综合能力。关键在于做到看到题目有清晰的思路和比较良好的实现能力。确实是比较综合的一道。
BIT
P3221:枚举顺序是个好东西。枚举当前列和当前下行 \(i\),那么最新可扩展的可作为上行的行就是 \(i-2\)。那么我们用树状数组记录和当前上行可以向上拓展的点数以及上行本身在左右可以扩展的点数去计算贡献。
CF1404C(考试,改题,第十套,做了三轮还是不能手切):
卡点:这道题的问题非常严重。第一,完全没有理解为什么 \(a_i\) 会换成 \(i-a_i\)。第二,不理解增量构造的顺序和原因。第三,对于 \(a_i<0\) 的情况没有分析就直接加入了构造范围。第四,解法全是靠死记硬背,没有理解透彻。
一、 关于 \(i-a_i\) 的构造
考虑一个点删除的时候 \(a_i\) 和 \(i\) 大概有一定关系。(事实上删除之后后面的点前移的行为都一般考虑从点到目标距离入手)。发现 \(a_i>i\) 的时候,由于位置大小不会增加,这种点不可能删除。\(a_i \le i\) 的时候可能可以删除,其中 \(a_i=i\) 会直接被删除(这里根本不需要二分,所以负数不会进入构造)。
考虑当 \(a_i < i\) 时 \(a_i\) 被删除的具体条件。发现当位置向前移动到 \(a_i\) 的时候这个数会被删除。此时的移动距离就是 \(i-a_i\)。所以,前面可以被我们删除的个数不少于 \(i-a_i\) 时我们就可以删除这个数。
接着考虑把这个条件简化。此时就直接 \(a_i \gets i-a_i\)。这样原本的 \(a_i\) 和 \(i\) 的关系直接变成:当 \(a_i < 0\) 就不可能删除,\(a_i=0\) 可以直接删除,而 \(a_i>0\) 的时候前缀 \([1,i-1]\) 范围内如果删除个数大于 \(a_i\) 就可以直接删除。
加入区间限制。由于后缀的缺失是不影响这个后缀之前的询问区间的答案的,我们考虑把询问挂在右端点上,并对答案进行增量构造,也就是每一次拓展当前的右端点并记录前缀的答案(此时计数仅仅拓展到右端点,所以答案计数的时候只需要记录后缀就是右端点为当前端点的答案)。
考虑拓展端点需要维护什么。拓展端点,需要分类讨论。我们维护 \(t_l\) 表示当前右端点是 \(r\) 的时候 \([l,r]\),也就是后缀,里面可以删除的点的个数。只有当前节点可以被删除(权值不小于零)才会对我们所维护的东西造成影响。如果 \(a_i=0\),就没有任何条件,所以不需要也不应该去二分,直接可以删除,\([1,r]\) 范围全部加一。如果 \(a_i>0\),那么只有在某些后缀中当前节点才可以被删除。
考虑哪些后缀是合法的。我们知道前面的删除个数不小于当前的值就可以了。而因为我们维护的是后缀,可以删除的个数单调不增(区间长度在减少),我们可以在前缀 \([1,r-1]\) 中(因为当前节点尚未拓展) 二分出一个最右(可能有等于的,所以是最右)的区间内点的个数 \(\ge a_i\) 的点 \(R\)。之后 \([1,R]\) 就都可以 \(+1\)。
那么此时 \(t_l\) 里面存储的就是当前的区间 \([l,r]\) 内部可删除的个数,直接记录答案。这里需要一个支持区间加单点查询的数据结构,这里使用常数更小的 BIT。
考虑一些细节问题。比如即使当前的值小于零,我们也应该对挂在当前节点的询问记录答案。
SGT+主席树
主席树本质是二维数点,维护和两维限制有关的,可以前缀扩展的信息。
P3834:普通的主席树。
纯粹的可持久化数组
P1383(一遍):就是纯粹的可持久化数组。找当前最右的一个数,然后支持版本回退。
P6166(一遍):同 P1383。
区间绝对众数
怎么空间 \(O(1)\) 时间 \(O(n)\) 求区间绝对众数?使用摩尔投票。
摩尔投票
由于主元素出现次数比一般大,区间扩大一个,如果扩大的 \(cnt=0\) 则 \(val=x\),如果和当前元素相同则增加计数器反之减少计数器。这样最后的 \(val\) 就是主元素。
那么我们考虑记录每个值的出现次数的前缀个数。如果区间的值域左半边是比询问区间长度的一半大的就往左,如果右边大就向右,反之就没有。可以把个数开两倍比较来规避浮点数。
P7261(两遍):卡点:这并非莫队可以维护的。 使用主席树,复杂度比莫队更好而且还在线。
但是维护区间众数的时候只能二分出出现次数来判断有没有次数比二分值大的,效果甚至不如暴力。
P3567(一遍):也是区间内是否存在主元素。就是上面哈。
区间 MEX
P4137(两遍):卡点:主席树维护区间颜色是维护上一个或者最右边的出现位置,而不是颜色个数。
如果到 \(r\) 的前缀的部分的最右边的出现的值小于 \(l\) 的就是没有出现的。维护最左边的合法出现值的位置二分。
主席树维护 HASH
P8860(三遍):
卡点:第一遍想成了在线维护,第二遍维护的时候没有存储原来的根。
在线维护不可做,考虑离线。如果一条边在第一次被删除则后续不会再被删除,而没有被删除说明其为必经之路,则必然不会被删除。所以我们记录每个边第一次被删除的时间 \(t_i\),如果没有被删除就是 \(q+1\)。那么此时所有的 \(t_i\) 都不会超过 \(m\)。
根据题意,最后只会保留一条从 \(1\) 到 \(n\) 的路径,然后其它所有的边都会被删除。考虑找到这条路径。
由于 \(t_i\) 路径最小值较小的路径会被首先删除,我们考虑找到路径 \(t_i\) 最小值最大的路径。而对于路径次小值也是最大化。所以我们直接求按照出现 \(t_i\) 从小到大排序之后字典序最大的串即可。
考虑如何方便地用数据结构维护。我们开一个主席树,其中 \(t(rt(i))\) 存储最优的到达 \(i\) 时候的路径 HASH 值。
而我们存储 HASH 值的时候是应该维护值域在节点内的数的个数的。
具体来说,如果我们把路径边的编号集写作 01 串的形式,例如 \(\set{1,2,4}\) 写作 \(11010000\),那么 01 串字典序最小的时候就是路径最大值。我们维护 01 串的字典序。每一次扩展的时候用 Dijkstra 扩展。到达点 \(v\) 的时候就新开一个节点 \(tmp\) 存储扩展之后的结果,用 SGT 上二分比较字典序大小。如果当前扩展的字典序更小就更新。初始时候构造一个全部出现的串作为极大值即可。特别注意:根节点编号可能在扩展之前变化,所以 BFS 时应该存储原本的主席树节点编号。
P11807(三遍):卡点:没有想到暴力排序。
考虑我们怎么快速比较两个串的字典序的大小。用二分最右边的哈希值不同的位置可以 \(O(\log len)\) 比较。每次修改只修改一个位置,主席树可以减少修改节点次数。修改维护哈希值 sort 暴力排序即可。我们直接给字符乘位置的带来的权值 \(base^{n-i}\) 即可。
SGT 历史和
对于所有子区间一类的问题用历史和解决。
更加复杂的 SGT。
线段树维护矩阵
CF718C(一遍+一遍):分别是两只 \(\log\) 和一只 \(\log\)。我们发现 Fib 数列的矩阵的和乘转移矩阵也直接变换为 Fib 数列的和。于是乎我们用线段树维护矩阵,每次区间乘转移矩阵的快速幂,合并用矩阵加法即可。BONUS:打标记的时候我们直接打矩阵乘法而不是区间加的标记这样就是一只 \(\log\),卡常都不带卡的。
李超线段树
P4097:维护折线段,区间加入线段 \(y=kx+b\)。
我们只要保留当前节点 \(x\) 处最优线段即可。当前节点 \(x\) 表示区间 \([L,R]\),那么我们对于当前节点把线段设置为标记(永久化标记,因为不能下传),但是在更新的时候可能不是在节点本身更新。考虑当前优势线段在 \(mid\) 处的 \(y\) 值会比此前的优势线段更大。具体来说,设 \(f\) 为原本的线段,\(g\) 为新加入的线段。如果 \(f(mid)<g(mid)\) 就先交换。此时 \(f(mid)>g(mid)\)。如果 \(f(L)<g(L)\) 则左边的优势线段可能改变,如果 \(f(R)<g(r)\) 则右边可能被改变。查询的时候沿着标记向下找到端点为止,一直找标记上的最优线段即可。
P4655(模板):横坐标不连续就最好用李超线段树。
注意李超线段树动态开点一定会 MLE,所以实际使用的时候最好是把点首先离散化,之后为了减少常数考虑标记永久化。
李超线段树和斜率优化
只有斜率递增,横坐标递增才能用单调队列,但是李超就不一样了。还是把方程刻画为 \(y=Kx+B\) 的形式,直接把直线插入李超。在 \(x\) 的位置找当前决策直线里面 \(y\) 最优的一个即可。
U606453(三遍):卡点:排序顺序错误,应该降序排序,然后没有后效性可以 DP,并且读题有问题,人应该是知道哪些椰子爆炸的,仅仅不知道硬度,还有李超常数很大,以后开 long long 要谨慎,比较函数应该简化。
我们只需要从上一个爆炸的椰子的下一个开始探测,则最坏的情况就是降序排列之后从上一个位置到当前爆炸椰子 \(i\),每个都探测了 \(a_i\) 次。
设 \(dp(i,T)\) 表示当前爆炸的是 \(i\),探测 \(T\) 次之后的结果。
\(dp(i,T)=-ja_i+dp(j,T-1)+ia_i\)。刻画为斜率优化直接上李超。
因为斜率和横坐标并非递增,我们不提倡使用单调队列。
P4072(一遍):发现最后是一个和划分的所有段的长度的平方的和有关的一个转移。设 \(dp(i,\theta)\) 表示走 \(\theta\) 步,考虑到前 \(i\) 个的最小和。李超线段树是好做的。
对于区间选 \(M\) 个的有效决策点,我们的有效决策点是从 \(M-1\) 开始的。dp 在开始前首先做好初始值。这样可以有效回避空状态。
区间赋值 \(A\),区间赋值 \(B\),区间维护 \(\sum AB,\sum iAB\)。
维护 \(\sum A_i,\sum B_i,\sum iA_i,\sum iB_i,\sum A_iB_i,\sum iA_iB_i\)。每一次修改的时候用推平,这样差值里面 \(A,B\) 总是有一个是给定的,直接取另一个作出贡献即可。注意:如果涉及到 \(i\) 的求和就用等差数列。
P6406(三遍):
卡点:第一次考虑用分治写结果不会处理左右端点分类讨论之后的结果。第二次线段树写了一堆奇怪的东西并取模写挂。
请注意 \(AB\) 可能是爆 int 的。本题目卡空间,注意数组不要开 long long。对于可能爆的要加 1ll。如果不知道什么问题就对拍。如果是取模爆炸/UB/数组越界考虑用 fsan。本题令 \(A_l=\text{MAX}(l,r),B_l=\text{MIN}(l,r)\),则有一个套路就是先枚举 \(r\) 后枚举 \(l\),则我们要维护 \(\sum_r \sum_l (r-l+1)A_lB_l\)。我们每次只扩展右端点 \(r\),而其改变的最大最小值的下标是可以单调栈求出来的。所以有 \(sum_r \sum_l (r+1)A_lB_l-lA_lB_l\),则我们维护 \(\sum A_iB_i\) 和 \(\sum iA_iB_i\) 即可。
AT_abc415_f(三遍):
卡点:一开始用的是二十六个维护区间最长的一的连续段的线段树,但是常数极大。第二次错误地维护了区间内是否是同一个数。
单点修改,只考虑怎么上传。发现只能写一个线段树。维护前缀,后缀,总的最长连号以及长度,最左边和最右边的数值。左右树能合并当且仅当左边的最右值等于右边的最左值。然后就做完了。
后续:注意操作常数。
P2572(INF 遍):
卡点:重构代码之前写得奇形怪状。重构之后有两个问题:第一,赋值标记下传的时候反转标记是要清理掉的,否则等价于赋值了一个相反的值。第二,【标记下传操作应该早于对线段树节点的操作和遍历儿子,不然会出现原有的反转标记不下传却被新增的赋值标记覆盖的问题,而先下传则标记里面是空的,可以保证不会消除任何新的标记。】这样反转标记一定事先下传,则赋值一定是在反转之后。这里用【线段树 2】可以理解。
一般来说,线段树标记的优先级是赋值大于乘法大于加法,赋值大于反转。
一般来说,要维护多个信息(例如最大子段和/最长连号)的时候需要用结构体存储节点信息并合并节点信息。
P2894(一遍):维护的信息和上面一样,只要加一个二分。
P1471(一遍):维护平方的和,这里拆拆贡献即可。
注意:pushdown 写在最前面的话应该特判当前是否是叶子节点。如果是的话就不用 pushdown。
区间数颜色
P1972(一遍):莫队会被卡,考虑记录每个点的同一个颜色的点的上一次的出现位置 \(pre_i\),那么就有 \(i \in [l,r] ,pre_i \in [0,l-1]\) 是合法的。二维数点是吧?使用主席树直接秒。
区间点集 LCA
pushup:只要有在点集内部的就继承。如果两半都有点在点集里面就取两半的 LCA。下传标记时不影响点集关系(是可能的,比如距离最远的)。
CF348E(未完成):
学数据结构学傻了,甚至不够深入了解 DS。
区间 LCA 是会的,但是不会换根 LCA。考虑直接拿以 \(u\) 为根时 \(u\) 最远的标记点集的 LCA。
区间反转区间求和
CF877E(一遍):维护 \(1\) 的个数即可。
区间 MEX
维护每个数最右边的出现下标。
\([1,R]\) 内最右边下标 \(<l\) 的最左的点(未出现且最小),大于 \(R\) 的不存在。
左方节点不在最右边的区间内就不存在。
然后就是二维数点。
动态开点的标记直接设置 memset。
CF915E:动态开点,反向赋值,记录非工作日数
CF1439C:区间推平,线段树二分找节点,若区间为整块节点并且整个可以购买就购买并停止递归,否则继续向下。开个全局变量维护钱数。
CF383C:按照深度的奇偶性分成两个树,子树加,只取有效的树的节点。适当的冗余状态可以减少分类讨论。
用 SGT 数颜色
定义 \(pre(i)\) 表示上一个和 \(i\) 相同颜色的出现位置。
那么每一次出现了 \(i\) 之后,\([pre(i)+1,i]\) 加一,则 SGT 内自动存储了一直到 \(i\) 的区间颜色个数 \(col([j,i])\)。
线段覆盖问题
ABC360F:
卡点: 不准直接枚举,并且和区间内端点个数没有任何关系。
正解: 考虑正难则反,从每一个线段被哪些线段交来入手。发现一个线段 \([l,r]\),能够和它交的线段 \([l,r]\) 满足 \(l \in [0,L-1],r \in [L+1,R-1]\) 或者 \(l \in [L+1,R-1],r \in [R+1,10^9]\)。注意 Corner Case。由于我们优先 \(l\) 最小,我们把 \(l\) 作为扫描线的高度,进行矩形加,查询权值最大的最下面的最左边的点。注意:同一个高度的矩形的线要一起加。扫描线要特别区分坐标是平面直角坐标系还是网格图,就是注意端点在不在矩形内,在矩形内就要在高度的上面一条线删除贡献。
特别注意 \([L,R]\) 答案的初始值是 \([0,1]\),因为 \(L<R\)。
FHQ
文艺平衡树
按照序列长度分裂的。如果左边长度够就保留左端,分离右边,反之亦然。注意左右两边不要写反。还有区间反转,下传标记的时候直接去交换左右儿子。操作的时候直接打标记。可以用来解决无法直接上下传递标记解决的操作,比如区间翻转。注意要保留随机权值来维护随机堆才能保证树的高度是 \(O(\log n)\) 的。
ABC350F:我们先不考虑括号,每次匹配到一对括号就直接标记,然后直接把括号先反转,再来统计之前的括号对数取大小写,不然很容易卡时间。
FHQ 上二分
P3165(两遍):
卡点:使用了常数极大的二分+最小值导致 TLE。
发现前缀排序完之后就不再参与排序,因此每一次交换到 \(i\) 位置上的必然是 \([i,n]\) 后缀最小值(带有编号的排序)。区间翻转肯定是文艺平衡树。考虑文艺平衡树怎么求最小值的位置。
我们对于每个节点维护其子树最小值。
发现前缀最小值随着长度减小单调不增。考虑二分一个最小值为区间最小值的最左边的前缀位置。这样是 \(O(n \log^2 n)\) 的并且常数极大。
考虑和 SGT 上二分一样在 FHQ 上二分。原问题等价于找区间被分离出来的子树中最小值是第多少个。如果左边有最小值肯定走左边,否则判断当前节点和右边。这样是 \(O(n \log n)\) 且大量减少分裂和合并操作。
FHQ 维护连通块
P3224(一遍半):初始对于每个节点开一个树根,并查集合并的同时合并两个连通块的 FHQ。考虑 DSU 一样用启发式合并,直接把小的连通块的点暴力插入大的。总时间复杂度 \(O(n \log^2 n)\)。
卡点: 判定 \(-1\) 错误。
注意: 在启发式合并的时候可能会发生代表节点所在块发生交换的情况。为了防止混淆,在不用撤销的情况下我们考虑直接把两个块(此时是同一个块)的信息全部设置为合并后的。还有 Kth 操作左边是 \(\ge val\)。
FHQ 全局加减
P1486(一遍半):
卡点:没有想清楚一个点不会增加点出现之前的标记,所以应该减掉此前的标记插入这个事实。
对于全局加减维护一个总标记,这样所有点的变化量就是点插入时的标记和求值时的标记的变化量。剩下的是常见操作。
启发式合并(小的暴力合并到大的)
P3201(两遍):卡点:只会颜色合并,不会颜色段合并。
每次合并两个颜色,考虑把个数更小的颜色直接扳出来暴力合并到大的里面去。而合并的时候增加连续段数直接考虑先减掉小的一段的连续段数(每次删掉最小的一个),之后合并元素到大的(如果左右两边 \(\pm 1\) 有元素就减少一个增加的贡献,初始增加段数是一)。合并直接用 set 即可。
其他可持久化
杂项
可以使用 PyPy3 做高精度类型的题。先把数的最大长度设置:
import sys
sys.set_int_max_str_digits(500000)
专题:动态树
给定了树的形态
P3950(两遍):
卡点:我们应该想到把连通性转为边权,以路径最小值判断是否连通。我们应该边权转点权且 LCA 部分不统计。
边权表示是否连通,点权表示点到其父亲的边权(根节点为一),单点修改路径最小值,树剖即可。
没有给定树的形态但是只维护连通性
如果要维护边权和强制在线,我们就只能使用 LCT 这种基本用不上的算法。
但是我们只需要维护连通性,所以我们要离线做,考虑一种常见的离线方式:时间线段树/线段树分治。
线段树分治
这是模拟时间轴上连续区间的贡献的好帮手。
我们对于时间轴建立线段树,对于每一个时间轴上的连续区间(比如出现时间),我们把它拆为 SGT 上的 \(O(\log n)\) 个节点,用永久化标记存储起来。(因为没有必要去下传标记)
处理完这些区间之后,我们直接遍历这个 SGT,增加当前节点的标记之后遍历左右儿子,处理完整个子树(左右子树)回溯的时候再删除当前节点的标记。
P2147(INF 遍,因为是现学):
卡点:除了 LCT 没有处理这类问题的经验。
我们发现每条边的出现可以被划分为若干个连续区间,我们首先在这些连续区间插入边。随后,我们遍历这棵树。
我们直接用并查集维护连通性。考虑怎么撤销并查集的贡献。这里我们需要用到可撤销并查集。
可撤销并查集
首先,由于我们需要保持树的形态来获得严格 \(O(\log n)\) 的复杂度,我们不能使用路径压缩,而是纯粹的启发式合并。
其次,我们使用一个栈(因为你删除操作的时候只能倒着删除,才能获得操作完之后的树的形态)去记录每个合并操作合并的连通块代表点和其在合并之前的大小。这样撤销的时候只需要让连通块代表点的父亲变成本身(因为合并之前两个点都是代表点),并且大小还原到合并前的大小即可。
我们继续。我们在开始增加本节点存储的边之前记录下当前栈的大小 \(t_0\),则遍历完整个子树就只需要把栈顶删除到 \(t_0\) 即可。
记录答案的时候,我们把每个询问挂在时间(操作编号)上,每遍历到一个叶子节点(单一时间) 就直接依据当前的连通块的情况(并查集)来得到两点是否连通。
图
存储优化
CF1209F:存储出边的时候边权就算有限也和边的入点存在一个二元组中,可以有效降低空间常数。
欧拉图
原图转度数
CF788B(难得的一遍):首先假定只要连通就一定任意删边皆可,发现这样是错误的。通过手玩强度更大的数据发现原题目等价于把可以经过两次的边看作两条边,问你删除两条边后原图是否可能让边都恰好被经过一次的路径(欧拉路径/欧拉回路)。我们先假定图连通。
首先不考虑自环,那么选取边就变成两个情况:两边共点或两边不共点。由于原图点度数全部为偶数,两边不共点删除就会新增四个奇度数点(不合法),共点删除会新增两个奇度数点(合法)。所以如果我们删除两条边就只能删除共点的,总方案数为 \(\sum_u \binom{deg(u)}{2}\)。其中 \(deg(u)\) 表示的就是排除自环之后点的度数。
考虑加上自环。由于删除自环就会使得点的度数减去二,对点度数的奇偶性没有任何影响。记自环总数为 \(S\)。则自环选取有两种情况:选取两个自环,方案数 \(S \choose 2\);选取一个自环和一个非自环边,方案数 \(S(m-S)\)。
那么总的方案数就是上述三个方案数的和。
考虑图并不连通的情况。如果存在有边(包括自环)连接的点和其他性质相同的点不连通,则方案数为零。否则即使不连通也没有影响。
图论建模
前后缀转点,串转边
ABC209E(先讲后做):前后三个字是点而串是边。有环也不一定全都是平局。考虑如果当前点是必败点则不删除直接加入新的点并把新的点刻画为必胜点。反之只有拓扑序转移,入度删完了就是必败点。
排列转置换图
排列操作是经典的图论背景。
ARC171B(两遍):排列变成若干置换链。对于 \(B_i \rarr P_{B_i}\) 的限制直接改为 \(i \to P_i\) 的连边。那么 \(\exist A_i<i\) 一定不合法,\(\exist A_i \neq i,A_j=i\) 的也不合法,因为一个点不可能既不是尾部又是尾部。
那么第一次出现的 \(A_i\) 所对应的点 \(i\) 是可以作为头部的,否则可以从更早的连过来。记录当前头部数量。如果碰到一个 \(A_i=i\) 的,则有一个链尾,我们从当前链头(包括本身,如果是)里面找一个计数。乘法原理,直接把答案乘当前链头数量并把头部减一。
前缀和转连边
前缀和是 \(l-1,r\) 之间的限制。我们可以对其连边。如果对于某操作区间有 \(s_r-s_{l-1}=0\) 一类的限制,考虑对 \((l-1,r)\) 连边搜索,这样就可以走出所有的操作区间,判断当前操作区间是否合法即可。如果有删除就直接用 set 维护,因为每个点最多被删除一次,所以复杂度是 \(O((n+m)\log n)\)。此事在 CSP-S2024 T3 中有所记载。
CF1687C(两遍):
卡点:不会对「两个数列和相等」这一条件进行转化。
我们对上式作差。事实上相等和偏序关系很多是用作差转化的。令 \(c_i=a_i-b_i\)。
满足条件就直接把 \(c_i\) 变成 \(0\)。
找到一个和为定值的限制了。转换为前缀和。
满足条件就把 \(S_i,i\in[l,r]\) 转换为 \(S_r\)。最后需要所有的 \(S_i=0\)。我们希望 \(0\) 会增加,所以我们只对 \(S_{l-1}=S_r=0\) 去操作。则我们搜索所有 \(S=0\) 的节点,以其可操作区间作为出边(正反边都建,可能会向前操作),如果两个端点都是 \(0\) 就把 \([l,r]\) 全部清空并加入搜索点,搜索完了直接 break。最后看是否全部删除即可。维护删除操作就直接维护一个 set,记录所有当前不为 \(0\) 的点的编号,二分查找,暴力删除。
后续: 相等有关限制考虑作差,和为定值考虑转前缀和。
关于环上删除连续段
很多时候我们会看到诸如「环上删除某一长度的连续段」的问题。有时这类题目不需要显式的图论建模,甚至和图论没有太大的联系,而是有一种独立的过程——「多个是两个」。
CF878B(两遍):思路偏在一开始就思考怎么建图消圈。
一般来说,这种纯粹代数的环,是通过断环为链,开多倍的序列来把这个环变成一个序列的。而我们知道,一个环(包括其首尾相接的部分)事实上只需要两倍的序列就可以了。所以我们只需要从序列本身以及两倍的序列导致的首尾相接得到的性质入手。
第一步:考虑用栈从序列本身开始消除。我们发现如果序列本身就存在连续的长度不小于 \(K\) 的连续段的话,这个连续段可以直接消除。考虑为什么这么做。因为在环拼接的时候只会增加首尾相接形成的连续段,并且我们对于连续段减去 \(K\) 的长度对于最后剩余的用于拼接/保留的连续段是没有任何影响的。考虑类似于用栈维护括号序列的方法来维护,开一个栈记录当前的连续段的种类和长度即可。记删除完后的长度为 \(C\)。
第二步:考虑用双指针处理拼接后得到的连续段。首先发现多个环的拼接等价于两个环,因为被操作序列的尾部和新重复的头部是和原本的首尾一致的。所以拼接时删除和剩余的连续段是完全一致的。这一次考虑直接用环本身。我们开两个指针 \(L,R\) 分别表示当前剩余的连续段的头部连续段和尾部连续段。如果当前的两个连续段的颜色相同且连续段的个数和大于等于 \(K\),那么删除个数就增加这个大的连续段能增加的次数,就是总长度除以 \(K\) 的值。如果两边颜色不同了/进入同一个连续段(注:此时所有连续段的长都会比 \(K\) 更小)/长度小于 \(K\) 了就再也不能删除,就跳掉。记此时能删除的个数为 \(P\)。
当然,除了第一个可能作为循环节的尾部的序列头和最后一个可能作为循环节头部的序列尾之外所有的头和尾都会被删除,所以总的删除个数为 \((m-1)P\)。
第三步:考虑最后根据是否存在二次删除来计算最终答案。如果被删除后剩余的颜色数不为一的话,就不会在最后的删除完毕之后进行二次的删除。当然,我们此时并不考虑每个连续段的构造,否则我们也不知道每个连续段(长度大于 \(K\) 的那种,especially)会删除多少个数。所以说,此时答案就是 \(mC-(m-1)P\)。否则一定只会剩下一个连续段,我们直接在这个连续段多复制 \((m-1)\) 次,并进行最后的二次消除。
综上,我们对于这种删除类问题要有顺序地删除,不能因为考虑很多步删除就直接把自己给绕晕。
关于优化建图
边数过大考虑优化建图。适用于最短路,拓扑序,连通性维护等情况。
前缀优化建图
适用于维护连通性的情况,比如 2-SAT。
P6378:对于 i 和 i+n,新建两排节点维护原来的出入关系,并在新的节点上优化建图。考虑到 i 排新排从左往右连通表示向右,i+n 新排从右往左连通表示往左。接着i新排向i+n新排的下一个点连边,此时可能会拐回到原列,但是直边并不存在。考虑 i 排向 i+n 新排的左侧连边,i+n 排向 i 新排的右侧点连边,就绕过直边同时保证连通性。
线段树优化建图
适用于区间向区间连边。
建一个自根向下的外向树和一个相反的内向树。每次从外向树将拆出的原区间连接一个新开的节点,并从这个节点连到内向树上拆开的目标区间。线段树上拆区间就是拆成若干个线段树上的节点。
区间拆前后缀优化建图
区间在固定长度时可以拆成一个前缀和一个后缀。
拆链
CF1209F:如果边上的权值增加和权值的数字有关,考虑拆成数字组成的链,这样边数变得较少且较好维护,可以BFS。
泛化条件
分层图最短路
从 CSP 考到 NOI(NOI 2025 机器人)。
CF1473E(两遍):
卡点:没有转换条件为减去一条边的权值并加上一条边的权值,没有掌握分层图最短路的形式化题面。
没有负权边,还是最短路,考虑 Dijkstra。
但是这里最短路是最大长度减最大跨度,直接维护是不可能的。
考虑泛化条件,就是可以减去一条边的权并加上另一条边权。减去的肯定越大越好,加上的必然越小越好,所以两个条件是等价的。
就像这样允许有给定次数(一般是一次)修改操作(或者空间折跃),且修改操作会有不同贡献的图论问题,我们考虑分层图,或者说,把一个图拆成若干份。
考虑一条边存在被消除,被翻倍,以及被同时加减(权值不变但操作次数已经用完)的情况。那么我们考虑把图拆成四层。第一层是原图,第二层是只减去了一条边权的,第三层是只加上了一条边权的,第四层是最终图。则第一、三层与第二、四层连加边,第一、二层与第三、四层连减边,第一、四层直接连边。此时第四层要么被直接同时处理要么恰好一次加边一次减边。为什么直接连边要特殊处理呢?因为直接连边则只有一个边权加上来,而不是先删除后翻倍得到的两个。直接跑最短路。
后续:看到这种类似于允许一次加减边权的条件先考虑能不能泛化,泛化完就去考虑怎么建分层图。
拓扑排序
拓扑排序可以用来把 DAG 转换成转移排列。当然其更加经典的应用就是判断一个图是否是个 DAG。
判断 DAG
CF1217D(一遍,不看解析):WYB 推荐的脑筋急转弯。
首先我们考虑能不能对生成树进行染色。发现 DFS 树上只有四种边:树边和前向边直接染黑,返祖边直接染白。那么所有的横叉边里面可能会有不少的环。我们又要对所有横叉边去做一遍递归,搞得我们很不爽。考虑最唐的结论。首先如果是个 DAG 就直接染一种颜色,因为没有环。接着考虑一个环里面一定有什么。对了,就是编号大的点和编号小的点连边,编号小的点和编号大的点也有连边。原本我们不希望没有环时编号大小会导致 DAG 多余染色,现在由于已经确认有环,就可以直接这样直球地染色了。只能说是个脑筋急转弯。
正图还是反图
CF625E(两遍):看到 DAG 和构造排列,猜想和拓扑排序有关。考虑直接用点在拓扑排序上的位置去作为排列上点的编号。但是这样做在图不连通的时候是有问题的。比如说下面的样例:9 1 5 2,答案就会是 1 5 2 3 4 6 7 8 9。这样是合法的,但是字典序并非最优。
观察 HACK 数据,发现如果是一条链的话,权值是从链尾往链头逐步叠加。卡点:看到这一步就直接往 DFS 上想了,而没有去想在反图上拓扑排序。
考虑为什么正解要在反图上跑拓扑排序并倒着编号,以及为什么这是对的。发现维持编号 \(n\) 的点的出度必然是 \(0\) 且在这些点内编号最大,记这个点为 \(u\)。反证法,考虑给 \(u\) 分配一个小于 \(n\) 的点权 \(x\),给另一个点 \(v\) 分配 \(n\)。我们发现由于两个点出度都是 \(0\),所以我们可以把 \(v\) 的权分配给 \(u\),并且把 \(x+1 \sim n\) 变成 \(x \sim n-1\)。由于此时 \(u>v\),且 \(u,v\) 出度全部为 \(0\),所以这样构造不影响 \(u\) 和邻点的关系。同时 \(x+1 \sim n\) 的偏序关系是不变的。由于 \(u>v\),所以这样构造也可以减小字典序。
从主观意志上讲,我们希望没有限制的独立节点在前面的编号更小。所以如果存在一个后面向前连边的情况,我们当然是希望使用后面的点的编号,来减少小编号被后面的节点使用的可能。所以我们倒着给权值。
在构造字典序最小的排列时,所以我们应该钦定 \(n\) 放在哪里,而不是 \(1\)。虽然比较反直觉,但是我们要根据更多情况来 HACK 自己的解法,并在被 HACK 后换一个角度/同一个算法反向维护。
Tree+DSU
基环树
\(n\) 个点 \(n\) 条边的无向连通图,相当于在树上多加一条边,形成了一个环,我们称呼其为基环树。
这里有两个结论。
如果每个点只有一个出边,则图必然是内向基环树森林,也就是每个节点向下走一定会走进一个环(CSP-S 2022 T3 星战的一个性质)。
如果每个点只有一个入边就是外向基环树森林。
AGC004D(两遍):卡点:贪心错误,不是从一号点出发的 \(K-1\) 步点集连边。
我们考虑反向连边。这样便于计算距离。如果当前深度(距离)与邻接点的最大深度的差恰好是 \(K-1\) 则向 \(1\) 连边并把当前深度标记为父亲的深度(便于回溯)。我们去掉 \(1\) 的出边并钦定 \(1\) 是根。因为最后要求任意长度都能在 \(1\),包括环上的点,所以点 \(1\) 自己是环。
小 Trick:区间的 LCA
P11364 第一部分:令 \(LCA([l,r])\) 表示编号在 \([l,r]\) 内的点的 LCA。简化这个 LCA 计算,使得其可以有一个可优化的形式。
首先考虑区间的 LCA 深度怎么以更加简便的方法计算。
我们断言
也就是区间的 LCA 的深度是区间内所有相邻节点的 LCA 的深度的最小值。考虑从路径的角度去证明。我们走遍整个区间的节点,可以视为是走若干条从 \(i\) 到 \(i+1\) 的路径。而每条这样的路径上的深度最小的节点就是 \(LCA(i,i+1)\),而必然存在一对节点使得所有路径里面 \(LCA\) 深度的最小值是这对节点的 \(LCA\),不然这里根本不会被遍历到。而要想走遍区间就一定会走到这里,所以这个点就是整个区间的最近公共祖先,而其深度就是所有相邻点最近公共祖先深度的最小值。
我们从最近公共祖先本身的角度去考虑。由于我们仅仅统计区间内相邻节点 LCA 深度的最小值,而我们希望能够对于每个节点,而不是区间(数量太多),统计答案,我们希望能够找到以当前节点为 LCA,至少是以当前节点深度为 LCA 深度的极长的连续段。则令 \(a_i=dep(LCA(i,i+1))\),则我们希望能够找到以 \(a_i\) 为 LCA 深度的极长连续段,由于 \(a_i\) 是这样的连续段的最小值,我们考虑建立 Descartes 树,这样必然可以找到以当前深度为 LCA 深度的极长连续段。事实上我们并不关心所有节点是否在当前子树内,因为深度相同的连续段如果连续分布,理论上它们合并并不会对答案造成影响(毕竟不用求其 LCA,不影响 LCA 深度的最低性,本质上还是两个区间)。
点分治
用来处理树上路径计数的利器。常用于解决树上满足条件的路径个数一类的问题。
P3806(模板):对于一个完全在某个点的子树内的路径,其可能的形态有两种:过这个点的和不过这个点的。而对于前者,则可以视作以这个点为某一个端点的路径拼接成的,或者就是路径本身。
由分治的思想,我们考虑处理经过节点本身的路径,再递归进入子树去考虑其他的路径。
首先我们找到树的重心,并求出以重心为根的点的子树大小。实际上只要在点的 DFS 回溯的时候如果重心没有求出才更新大小,以及求出重心之后重心在原树上的父亲的子树大小设置为总大小减去重心子树大小。 我们只遍历没有被删除的点。
则我们以重心为根去处理。首先遍历每个没有删除的儿子,并以这条父子边去更新儿子子树内的所有路径(DFS 时记录路径长度即可)。并暴力进行链合并(当前儿子的和此前出现的合并,记录遍历前后的总链个数即可)。清空链的集合并删除当前节点。遍历没有删除的儿子(父亲必然被删除),以儿子子树内点大小为总点大小找重心并求儿子子树的解。
P2634(一遍):路径长度对三取模即可。答案分子是路径长度的两倍加 \(n\),分母是 \(n^2\)。
长链剖分求树上 \(K\) 级祖先
找 \(i\) 使得 \(2^i \le K \le 2^{i+1}\),则 \(K-2^i\) 一定在 \(top(anc)\) 上下长链长度范围之内。\(anc\) 为 \(2^i\) 级祖先。注意加减的边界问题。
DSU on tree —— 另一种暴力的实现
CF208E:把深度当颜色,变成子树数颜色。
先遍历轻儿子,再遍历重儿子,加上单点和轻儿子子树的贡献。最后如果自己是轻子树,则删除自己子树的全部贡献。
虚树
这个东西只是用来缩小树的规模,方便 DP。
先设计暴力 DP,再用虚树优化。
通常把 1 作为根节点放在虚树里面,这样 DP 和原树一样。
虚树的关键点的儿子可能没有关键点。但是虚树的叶子全是关键点。
虚树的连接(二次排序,八月十二日再记忆)
为了防止不记得虚树怎么连接的。
对于所有关键点先按照 DFS 序排序,之后把相邻项的 LCA 求出来也放在数组里面,之后再次按照 DFS 序排序并去重,最后把相邻项的 LCA 和相邻项的后面那一项连边。
虚树的例题
P2495:所有关键点和根不连通的最小代价。
如果当前儿子是标记点,则其与父亲不得连通,直接加边权。否则可以让儿子自己的子树不连通,加边权和 dp(v) 的较小值。
2025/8/12:P2495(两遍):卡点:写虚树的所有节点排序的时候忘记写按照 DFS 序排序了。
P4103:先建虚树,DP 部分见【树形 DP】。
P3320:从一个点出发走遍所有关键节点并回到这个点的最小距离。本题关键点总数达到了惊人的平方级别,基本告别虚树了。详见本文【贪心】部分。
CF613D:占领多少个非关键节点才会使得所有关键点不连通。
还是虚树上 DP。考虑我们只需要知道子树最后会不会有关键点向父亲连通,记这个数为 \(lf(u)\) 并记子树内没有标记点连通的最小费用 \(dp(u)\)。
无论 \(u\) 是否是关键点,子树都不能连通,增加 dp 值。
统计儿子内有关键点向自己连通的儿子的数量 LF。
如果 \(u\) 本身被标记,则需要让所有儿子都不和 \(u\) 连通,dp(u) 加上 LF,同时有标记传到父亲。
反之,若有 \(2\) 个儿子能传标记则封锁 \(u\) 并且没有标记上传。反之封锁 \(u\) 没有意义(子树内已经不连通),直接上传。
P3233:一般虚树 DP 的流程分四个部分:DP1 处理虚树内儿子转移到父亲的,DP2 为父亲转移到儿子的,DP3 为父子边上的点,DP4 为既不在边上也不在虚树点上的点。
DP1 和 DP2 是暴力,DP3 需要倍增一个分界点(钦定 \(1\) 在树上就只要向上跳),DP4 用虚实子树和作差。
ARC086E:考虑只有同一深度的人才会同时消失,且各个深度是独立的。经典技巧:让每个深度的点当关键点建虚树跑 DP。
考虑 DP 需要什么信息。发现我们只需要知道虚树上每个点的儿子最终被拼成了一个点还是零个点。对这个东西 DP。我们钦定此时树上除了当前深度节点可能不为空外其他节点全是空的(不造成贡献)。记 \(ims(u)\) 表示 \(u\) 的虚子树内叶子节点的个数。发现没有节点上去的方案并不好记,考虑正难则反,维护有节点的方案,就是说儿子里面有且仅有一个有儿子上来。乘法原理分步计数。可以计算总的乘积再除以当前儿子选 \(0\) 的贡献乘上选 \(1\) 的。注意到当前点 \(u\) 选 \(0\) 和 \(1\) 的方案和就是 \(2^{ims(u)}\)。当然钦定为空的节点可以并非空的,所以我们对答案乘上 \(2^{n-ims(1)}\) 来做贡献。
DAG+拓扑
并查集+MST+Kruskal 重构树
线段树类型的数据结构的本质是把一个节点本身的信息和其在树(不一定是线段树)上的所有祖先的标记按照从深到浅的顺序贡献之后得到真实值。
Kruskal 重构树上维护标记
P9984(四遍):
卡点:第一遍写成了 MST 上 DFS 结果很容易举出反例;第二遍写了暴力维护发现 \(O(N^3)\);第三遍实现的时候用的是两边联通块节点的(而非节点本身的信息)维护两个块的标记导致有的节点本身的信息无法传输。
首先我们观察样例可以发现是在 MST 上操作的。对于有 MST 的问题如果要维护更多的信息可以考虑使用 Kruskal 重构树。发现在线维护不可做。
我们应该从每一个节点的角度出发离线构造所有点的哈希数列。
考虑在重构树的构建过程中一条边会合并两个联通块,考虑只有边会对答案有贡献,考虑这个贡献对于联通块,至少是节点,有什么影响。
维护哈希事实上是维护一个乘法和加法标记。标记有复合封闭性。
令左边节点(不是联通块)的信息是 \(X\),右边节点信息是 \(Y\),边的信息是 \(Z\),也就是乘 \(10\) 加编号。那么左边联通块节点的标记变为 \(combine(Z,Y)\),右边块是 \(combine(Z,X)\)。合并的方法就是标记(乘法标记)相乘,信息则左边乘右边的标记加右边的信息。也就是字符串加。
最后类似于线段树标记永久化的方法把每一个节点祖先的标记先处理了在用当前节点去加祖先(就是处理完的父亲)去合并标记。
扩展域并查集仅仅使用于所有点关系全部确定的情况。
P6765:正反向走无向路可以不碰面(速度相同)等价于两点连通且不为链,判断链可以用度数。
P4768:考虑 Kruskal 重构树在干什么。重构树子树内点在海拔最大生成树上连通并且联通块内的最小边就是 LCA 的点权。路径最小边权最大值=最大生成树上最小边权=重构树上 LCA 点权。在本题中不淹水的最好办法就是走最大生成树。如果子树根权大于 \(p\) 则联通块最小边也没淹水就是连通,反之仅可能到达子树根大于 \(p\) 的子树。我们可以倍增找到满足条件的最大子树。题目中子树越大开车可走的点越多就越优。我们记录每个点到 1 的最短路,做一遍查询子树最小值就是答案。
P1967(一遍):树上倍增不好处理边权,直接 Kruskal 重构树就只要求 LCA 了。
ABC350G:树上两点有相邻节点,要么一个点是另一个的祖父,要么就是两个点有共同的父亲。考虑合并两棵树的时候怎么取维护父亲。发现如果不是根合并,就会出现父子关系反向的现象。第一遍不会维护父亲,考虑和编号有关了。 考虑合并两棵树的时候我们一般使用并查集,并查集不改变原树形态的办法就是启发式合并,我们在跑启发式合并的同时暴力修改每个更小的子树来修改父子关系。
考虑为什么这么做是对的。每一个子树最多被合并 \(O(\log n)\) 次,因为每次大小都会翻倍。初始的时候只有 \(n\) 个子树(也就是节点)。我们考虑把子树合并理解为对子树内的节点同时进行一次「合并」操作。那么总的操作次数就是 \(O(n \log n)\)。
环限制都来了,MST 还会远吗?
CF632F(两遍):
考虑到三元环有一些限制,就是三元环上最大值必然会出现两次以上。卡点:没有对偶考虑为“在最大值加入之前只会有最多一条边在三元环内”转为 MST。
如果对偶了考虑,我们把所有的边限制转为求 MST。在求 MST 的时候必然会加入一段连续的边权相同的边区间,因为我们已经对边按照权值排了序。而我们按照对偶方案考虑,就是说在加入当前的边(可能为三元环的最大边)的时候,MST 上边所连接的两点并不连通(此时当前权值没有任何边加入 MST)。判断完之后加入当前权值的边即可。
看到环上边权大小只有一条边有一定限制的情形,想想最小/最大生成树并把被限制的节点当作此前的树边。至少环和最小生成树可能是有关系的(MST+一条非树边)。
最(K)短路 + 差分约束
SPFA 是先标记后松弛。
最小环
Floyd 直接做。注意要在 \((i,k,j)\) 路径尚未更新为最短路时统计。并且 \((i,k)\) 和 \((k,j)\) 是两条原图中存在的边。
最短路 VS 最长路
P3119(两遍):
卡点:对拍发现更新时候用的是从一出发和到达的最长路,不是最短路。
这也不难理解。缩点完了是个 DAG,我们枚举一条边 \((u,v)\) 使得其反向,则从 \(1\) 到达 \(v\) 加上从 \(u\) 回到 \(1\) 再减去重复的 SCC 即可。
我们希望能够让路径最长,因为可能存在到达某点的多条路径。 所以说应该是最长路(记录到达点的点权为边权加上起点的点权即可),而不是 BFS 最短路。
P2656(一遍半):
卡点:乘浮点数爆精度了。
缩完点是个 DAG,由于我们只能拓展一条路径,我们选择边权和点权(一个 SCC 内的边可以无限行走,直到榨干)和最大的一条路径,就是按照上文记录点权的方法得到的最长路。DAG 上跑 SPFA 是 \(O(n)\) 的。
后续: 少使用浮点数。
满足某些性质还要最小化费用的路径
考虑拆点,建立虚点或者转换费用求最短路。类比自动机的转移形式,可以自动出最短路。
P7407(三遍):卡点:没有压缩点的个数,甚至一开始的拆点多少有点问题。对于思路也没有完全理解。再次实现时代码常数极大。
考虑每次修改操作有两种。下面定义 \(s_{u,c}\) 表示 \(u\) 出边颜色为 \(c\) 的边的费用的和。
- 修改当前边颜色,费用是 \(w\)。
- 保留当前边颜色,费用是 \(s_{u,c}-w\)。
但是如果路径上边试图保留两条连续的颜色相同的边,就可能会出现两条边均被修改导致不优的情况。考虑在两个连续的同色边(必定共点),在共点的地方建一个虚点,点都向虚点连接费用为 \(0\) 的边,而虚点向每个点链接费用为 \(s_{u,c}-w\) 的边。但是拆点和颜色,颜色数量会很多导致用 map 和 pair 使用过多,常数会很大,考虑优化我们的实现。首先正常建立图,对于每一个点开临时数组 \(s_c,cnt_c\) 记录从点 \(u\) 出发的颜色为 \(c\)(定义到虚点的边颜色是 \(0\))的边的权值和和数量。如果不少于两条就建立一个虚点(直接加大编号),把当前出点向虚点和虚点向当前出点的连边记录下来即可。最后直接跑一轮最短路。由于每个点的出边最多是 \(deg(u)\) 个颜色,则最后建立的点的个数是 \(O(n+m)\) 的,而边的个数也是。所以可以直接跑最短路。
Tarjan+圆方树+2-SAT
Tarjan
注意 SCC 时 Tarjan 对于访问过的点,只有还在栈内的才用 dfn 去更新点 u 的 low 值。
和环相关的不一定是 Tarjan
CF982F(未完成)
卡点:本来是按照 Tarjan 找性质的,结果发现即使是同一个环也有可能是多个环的交,然后就炸了。
缩点找性质
在考虑连通性相关问题的时候如果看见了有向图就首先考虑缩点变成 DAG,之后拓扑排序看看有没有什么好的性质。
CF999E(一遍):首先强连通分量里面是不需要加边的,所以我们先给这张图缩个点。之后这个图变成一个 DAG。对于 DAG 按照拓扑排序的顺序(同层可以互换)分个层。发现图上除了点 \(s\) 所在 SCC,设其编号为 \(u\) 之外的所有 SCC 都可以分成两个部分:一个是从 \(u\) 出发可以到达的,另一个是可以到达 \(u\) 的。由于前者已经满足条件,我们考虑怎么对于后者加边。发现我们可以对于后者拓扑排序,也就是说对于一个后者中的点 \(v\),必然存在一个在缩完点的图上的入度为 \(0\) 的点 \(p\) 使得 \(p\) 可以到达 \(v\)。所以我们只需要对入度为 \(0\) 的点加一条边即可。由于在同一个 SCC 上的任何一个点加边等价(SCC 内的点本就可以互相到达),所以我们只需要对所有 \(u\) 不可到达且入度为 \(0\) 的 SCC 统计个数。
从这个题目出发,我们不难联想到一个非常经典的问题:
有向图求至少从多少个点出发可以走遍所有的点
有向图求至少加多少条边让整个图变成一个 SCC
P2812(一遍):就是上面那两问。第一问考虑到缩完点后所有的 SCC 构成一个 DAG,而 DAG 上所有点都可以通过入度为 \(0\) 的点到达,并且入度为 \(0\) 的点不能通过其他节点到达,所以第一问就是入度为 \(0\) 的 SCC 数量。至于第二问,考虑从出度为 \(0\) 的点向入度为 \(0\) 的点连边,此时只要所有的这些点都被覆盖即可(在前缀优化的基础上进一步优化),所以答案为两者个数的较大值。
P2002(一遍):就是第一问,过程见上。
2-SAT
用来解决“若 \(A=x\) ,则 \(B=y\)”的问题。所有数的赋值不是零就是一。就是找最大权闭合子图的一个合法解。此时存在点的关系是不确定的。注意到强连通分量的编号是缩点后倒着的拓扑序。所以选择拓扑序大,编号小的。若不成立则成了环,与缩点后是 DAG 矛盾。
注意逆否命题也要建边。
2-SAT vs 扩展域并查集
而扩展域并查集主要满足在所有点关系都是确定的的情况下把点化为不冲突的若干个(最好是两个)部分。
2-SAT 典中典
P3513:一个点不在团里就在独立集里,求方案数。
如果两个点有连边就不可能同时在独立集里,没有连边就不可能同时在团里。独立集里的两个点不可能同时入团(没有连边),同理团里的两个点连了边,不可能同时在独立集。所以最多是团和独立集分别迁出一个点放进对方。注意判断方案中团或者独立集是否为空。
P10969: 如果有 A 不能等于 x,直接 A 向 A' 连边,其中 A 表示 A 选 x 的点,A' 表示 A 不选 x 的点。此时若 A 选了 x 则 A 也要选 \(x \oplus 1\),是不合法的。
P5782(一遍):两个人必须去一个,就是两个人如果一个不去另一个必须去。两人不同时去,就是一个去了另一个就不去。然后是 2-SAT 板子。
二分图
网格图是一种典型的二分图。
二分图怎么找左右部点:黑白染色!
二分图上完美匹配的个数是否恰好是 \(1\)
P8346:首先,如果点的度数是一,则其链接的点就是匹配点,直接把它和匹配点一起删除。剩余的节点度数为零则不存在匹配,多于 \(1\) 则匹配不唯一。所以剩余的节点不存在。
没有奇环则两点之间所有路径长度奇偶性相同
P5292(两遍):卡点:暴力不会转移,没有发现回文串的内核和奇偶性有关。
先考虑暴力怎么做。首先我们考虑回文串的递归定义。一个字符,两个相同字符,以及首尾加上相同字符的回文串都是回文串。于是 \(dp(i,j)\) 表示 \(i,j\) 的轨迹(可以重复经过点和边)是否是回文串。只要有一个就可以了。我们枚举 \(i,j\) 的出边直接 BFS 扩展。
考虑我们可以在一条边上反复行走,因此我们只需要关心同一个字符边形成的连通块的奇偶性即可优化建图。如果没有奇环则所有连通轨迹的奇偶性是不会改变的,我们只保留生成树以实现连通。如果有奇环,无法实现黑白染色,我们就直接在遍历起始节点加一个自环。遍历时把连通块图上的链接当前节点和未访问邻接点的边直接加入原图就有生成树。简化图的点数不超过树边和所有自环,就是 \(2n-1\) 条。于是暴力扩展就是 \(O(n^2)\) 的。
团和独立集
两个团取点连边,求最大团的大小。
考虑这个图的对偶图(连边的不连边,不连边的连边),由于两个团里没有边,所以原图必然是二分图。求最大团变成求二分图最大独立集,这个东西等于总点数-最大匹配。
Hall 定理
定理本身:\(\text{完美匹配存在} \iff \forall V_2 \sube V_0,|V_0|\le|V_1|,|V_2|\le |N(V_2)|\)
推论:若 \(|V_0|\le |V_1|\),则最大匹配为 \(|V_0|-\max_{V_2 \sube V_0} \set {|V_2|-|N(V_2)|}\)。
ARC076D:对于这种题目,考虑刻画 \(V_2\) 还是 \(N(V_2)\)(\(V_2\) 的相邻状态集合)。本题刻画后者。则后者就是 \([1,l] \cup [r,m]\),大小就是 \(m-r+l+1\)。那么 \(|V_2|=\sum_{i=0}^m [l_i \le l][r_i \ge r]\)。扫描线维护之。注意答案初始为 \(\max(0,n-m)\)。
P3488:匹配完的情况不好处理,考虑匹配不完的情况。假设最劣的情况:选连续型号导致买的鞋最少,\(|N(V_2)|\) 最小。此时 \(|N(V_2)|=k(r-l+1+d),|V_2|=\sum_{i=l}^r t_i\)。
那么不存在的情况为霍尔定理的否定,\(\sum_{i=l}^r(t_i-k)>dk\)。最大子段和。
霍尔定理的“集合大小”改为“集合的权和”同样适用。
集合不好刻画,可以取补集。
二分图与构造
AGC025D:考虑黑白染色,从 \(x,y\) 奇偶性入手。
\(x^2+y^2=A^2\)。发现 $A^2 \bmod 4 \in \set {0,1,2} $。然后,对于 \(2\) 的情况,此时 \(x,y\) 一定两个为奇数。考虑让对角的点在不同组。直接按照横坐标奇偶性分组。对于 \(1\) 的情况,此时一奇一偶,考虑按照对角黑白染色。对 \(A,B\) 分别做一遍,按照两张图的颜色分为四个组,同组的点必定是合法的。由于抽屉原理,此时个数最大的组的个数必然不小于 \(n^2\)。
如果有 \(0\) 的情况,考虑把图整个放大到面积是原来的 \(4\) 倍,就是长宽翻倍。如下图。
.#
#.
上边是原图。
..##
..##
##..
##..
这是扩大后的图。直接把 \(A^2\) 除以 \(4\) 即可。
二分图博弈
二分图博弈很多时候和匹配密切相关。
P4055:先考虑特殊情况。如果图有完美匹配,那么先手走匹配边,后手就只能走非匹配边,那么后手(Alice)必败。
对于一个初始节点 \(x\),如果 \(x\) 可以不在最大匹配内,则从 \(x\) 出发 Alice 必胜。因为如果 Alice 负的话就一定存在增广路,就一定在最大匹配内。所以如果点一定在最大匹配内则 Bob 必胜。
求一个点是否一定在最大匹配内
一个朴素的想法是把点从图上删除,若最大匹配减小则一定在最大匹配内。
一个更简单的方法是先求一个最大匹配,如果点 \(P\) 不一定在匹配上,而 \(P\) 连接了一个在最大匹配上的点 \(Q\),则 \(Q\) 的匹配点不一定在最大匹配上。(可以用 \(Q\) 替换)我们每次搜索,看有没有可以更新的点并更新即可。
正则二分图最大匹配
从左边随机选一条边,如果边另外一头的点被访问过,说明出现了环,直接一直删除序列,直到点没有被访问过,就走到另外一头的点。(这里模拟走非匹配边)
从右边走到左边就直接走匹配边就行。走不了就停。
这样我们就得到了一条从左边出发,沿着非匹配边和匹配边交替行走到达右边的一个非匹配点的增广路。增广路上的边的匹配属性取反则匹配个数加一。
二分图的染色——Trick
网格图染色维护不存在联通块见此前的笔记。
不存在三个连续的相同颜色的点(黑白染色,无论环还是序列)
和传统的黑白染色不同的是我们允许有连续两个相同颜色的块。
我们让 \(2i-1\) 与 \(2i\) 颜色不同即可。也就是让其在图上连边。则这是一个二分图。
CF741C(两遍):
卡点:发现可以连续的块之后想了完美匹配,最大匹配,分类染色云云。想不出来,写了一个纯粹的隔一个染色。
我们在原有的图上面直接加上上述边即可。考虑这个东西为什么是二分图。
首先,如果 \((2i,2i-1)\) 这条边本来就存在,则因为每个人只会出现在一个情侣关系中,此时这是一个独立的联通块,直接忽略。那么剩下的点就全都是度数为 \(2\) 的。并且我们是两个两个点删除,所以剩下偶数个点。则此时极大的联通块内必然可以通过交替走情侣边和新增边走回原点。那么也就是说,所有联通块都是偶环,则必然是二分图。
直接对着新的二分图黑白染色。
专题:如何刻画最小割
很多时候我们需要刻画一些最小割有关的问题。如果是有向图最小割,并且是平面图(边可以不交叉)的前提下,我们可以转为对偶图的最短路。详见下面的网络流部分。
但是网格图上,如果是无向图,并且割的是点而不是边,那么此时拆点就很难满足条件了,并且如果有四连通禁止放点则根本不能刻画为网络流图。此时我们要有办法。考虑当前是按照行的从上到下的最小割。想象一下这是一条河流,那么我们拦截所有的水流的方向必然就是河两岸合法的最短路。所以我们只要求从左到右的最短路即可。
CF1749E(两遍):
卡点:一开始尝试把最小割转换为最大流,后来发现不能刻画。
按照上面的办法我们把它改为求从左到右的最短路。此时由于四连通的节点禁止放置仙人掌,所以我们只能走斜连通的路径。考虑黑白染色时所有的异色路径是相互独立的,因此只有原本就存在的仙人掌会对能否放置仙人掌造成影响。考虑判断原图上有无四连通点放置了仙人掌,如果存在就不能放。对于一个转移边,如果其到达的节点是空的,并且可以放置仙人掌,则权值是 \(1\)。如果本来就是仙人掌就是 \(0\),如果两个都不是则转移边不存在。对于最左边一列,只有可以放置仙人掌的空位(当前距离是零)和仙人掌(当前距离是一)可以作为初始节点。记录路径时不用存储上一个节点是谁,直接记录上一个点和当前节点的差量编号(有八个移动方向)即可。
后续: 不要看到最小割就直接去跑最大流,可能直接转化为最短路。
网络流
当前弧优化:搜索之前临时数组一定要全复位,tmp[u]=i; 一定要写。
每条边可以经过多次且每一次都单独收费的用费用流。
保证所有边都经过一次的用上下界流,费用下界为 1。
很多时候最大流转换位最小割。
只用一次的费用或收益考虑最小割而不是费用流。
我们一般通过拆点表示点权。
流量守恒构图:流量刻画取值,守恒刻画等式。
关于复杂度
Dinic:MF 是 Poly,但是 MCMF 不是。
方格取数模型
P2774:取的点保留,则花费最小代价使得左右不联通(左黑右白)。反向选择,最小化删除代价,割边的代价直接为边的流量。不可割的代价就是 INF。
二分图模型
LOJ526:如果有一天你看到“任意两点都不满足一些和两点有关的性质”时,把有性质的点拆成入点和出点连边,求二分图最大独立集。
奇偶优化: 注意到同奇偶是不会连边的,可以按照奇偶性黑白染色。
P4014:工件向工人连有限边,其它为无限边。最小割就是最大收益。
最小路径覆盖=总点数-最大匹配数。考虑拆点之后如果有流量视为两点所属路径被合并,个数减去一。初始有 \(n\) 条路。
时间轴模型
P2754:注意到答案的总时间不会很大,我们对于每个时间都加上各个车站在当前时间的点。对于车,它们到达某个车站的时间如果为当前时间就连可以割边(割边相当于花费),当前车站的上一个时间向本时间连 INF 边。
如果到目标车站(月球)的最大流 \(\gt K\)(我们钦定每辆车都是满员的,所以可能比 \(K\) 大)就直接输出当前时间。
最大权闭合子图模型(选 A 必选 B)
P4174:源点向人连收益边,人向机器连 INF 边,机器向汇点连费用边。割边表示扣除费用或者不获得收益。
或者这样理解:原本把所有人都获得收益,则不承担收益等价于扣除收益。
切糕模型
P3227:用来解决相邻单位取值还有距离限制的情况。
本单位取值直接每一个向本单位后一个取值连费用的流量。有距离限制 \(D\) 的,(x,y,val) 直接向 (x',x',val-D) 连边。源汇点向初始和终止节点连 INF 边,表示不可割。跑最大流。
选 A 选 B 模型
P4313:像这种选 A 或者选 B 的,我们把当前节点向源点连选 A 的收益,向汇点连选 B 的收益。割边表示不选。如果有多人同时选同一个造成贡献(假设选 A)的,新开一个点连接源点和这些人,与源点连增加收益边,与人连 INF 边。这样如果人选 B 则单点向 B 的连边被保留,若集团点保留则图仍然连通,与最小割矛盾。
P3153:男女的 \(k\) 次限制都应该存在,就是说,如果两人互相喜欢就直接连边,否则两人拆出的点连边,而拆的点和原点间有流量限制 \(K\)。
P2891:一个贡献需要同时满足两个性质。牛拆点(因为牛只吃一顿饭),左边连食物右边连饮料。
上下界网络流
无源汇上下界可行流的观念
我们先给每一条边扣除了下界流量。所以记录的入流量多于出流量是被扣除的入流量更多,因此从源点增加。汇点同理。如果源点连出的总流量等于流入汇点的总流量则流量守恒,有可行方案。
有源汇上下界最大流的流程
因为可行流具有可叠加性。
先跑一遍循环可行流(原本的汇点向源点连一条无限流量的边即可实现循环流)。如果可行就删除这条新增边,在残余网络上跑一遍源点向汇点的最大流,两个可行流和最大流相加。
最小流的话,我们要考虑怎样退流最多。因此我们删完边后从汇点向源点跑最大流得到退的最大流,可行流-最大退流才是答案。
上下界网络流的具体应用
P4043:不难题。根据题意连边。注意事项见上。
费用流是凸的,背包也是。
杂项
抽屉原理是个好东西
AGC025D
经典老题:补图连通块
CF920E(三遍):没见过的话的确想不出。
第一遍题解卡点: 没有想到暴力 \(O(N^2)\) 怎么优化。没有从点的总度数不变的角度思考最大补图点度数是多少。还想成了扩展域并查集或者别的什么东西。
考虑取原图内度数最少的一个点,并先合并其连通块。 考虑我们为什么这么做。发现补图的边很多,考虑从原图点度数下手。原图总度数就是 \(2m\)。所以一个点在原图(给定的边构成的图)中的最小度数的最大值就是 \(\lfloor \frac{2m}{n} \rfloor\)(考虑给 \(n\) 个点全都分这么多度数)。或者说,我们应该 根据抽屉原理分析在边数特别多的时候一个点的度数的最值的范围。所以原图内度数最少的点在补图上至少连接了 \(n-\frac{2m}{n}\) 个点(\(O(1)\) 的误差这里不考虑),考虑 \(n,m\) 同阶,基本上这个连通块可以覆盖大部分的点,而剩下的部分有 \(O(\frac{m}{n})\) 个点,每一个点连接 \(O(\frac{m}{n})\) 条边,所以此时暴力合并的总复杂度是 \(O(n+\frac{m^2}{n^2})\) 的,接近 \(O(n+m)\),是合法的。
第二遍题解卡点:设计合并有误,导致实现仍然是 \(O(N^2)\) 的。具体到设计上,我们考虑首先合并度数最小的点和它的邻接点,并把它和邻接点一起标记。在其他点暴力合并的时候,发现我们枚举点对 \((u,v)\) 时,\(u\) 的总移动距离是 \(O(n)\) 而 \(v\) 的是 \(O(n^2)\)。因此在 \(v\) 上卡点对不会影响总移动次数(判掉还会遍历),而 \(u,v\) 同时卡会导致新的暴力合并的连通块无法和初始块合并。所以我们选择在 \(u\) 上判断是否是此前的邻接点。
后续思路: 这是关于连通块合并(并查集)的一道经典问题。看到“补图”时考虑点的度数有没有什么限制范围。如果有,考虑首先从最大的连通块入手。
CF190E(两遍):
卡点:如果 \(M \le 10^6\),就需要注意常数。使用 unordered_set 被卡常了。
首先 \(10^6\) 规模的读写是一定要使用快读快写的。其次 STL 供应的 Hash 函数,如 unordered_set 和 unordered_map 在 CF 上经常被卡,而 gp_hash_table 在一般情况下甚至常数更大,所以能不用一定不用。能手写查找位置的东西/字符串 Hash 就一定不要用系统的。再其次,long long 会增大时空常数,能不开绝不开。最后,我们是通过控制 \(u\) 来实现标记的,但是如果 \(v\) 连接了一个标记点,并且 \(v\) 排序后编号在 \(u\) 前面,我们就不能从当前编号以后开始遍历。
后续思路: 见「关于卡常」。
同类型题目
P3452(一遍):考虑一个构造:只要有联系的两个人就分配到两个不同的办公楼,然后一模一样。
CF1242B(一遍,第九套还是一遍):考虑用 \(0\) 边连成的连通块内部是没有费用的,所以我们把原图的补图的连通块求出来,然后考虑 Kruskal 的方法是每一条边连接了两个连通块,那么合并 \(C\) 个连通块就需要 \(C-1\) 条边且这些边的权值一定是 \(1\),不然其合并的两个块是一个。所以答案就是 \(C-1\)。
欧拉回路
从奇数度数点开始走,每次删除一条边(可重集合表示边),并沿着边向下走。倒着输出。
计算几何(没用的知识)
可以不考但是一定要会,联赛考得不多,笔记原图比较连续,就放在这了。






一定长度的向量能表示的最小距离是多少?
CF2119B:场上犯迷糊,认为共线的时候最小,背包做了半天。但是:你听说过三角形吗?
向量能否表示 \(l \iff\) 向量和长度为 \(l\) 的向量能否拼成一个环(顺次链接可以回到起点)。
若干向量连边能否形成一个环?
我们知道三角形两边之和大于第三边,所以三元环的两边之和不小于第三边。
推广到多边形,如下图。

此时:
我们把所有边长从小到大排序,如果去掉最大值的和大于等于最大值就可以表示。
浙公网安备 33010602011771号