一些简单的偏序维护问题
综述
拜读了毒瘤的数据结构课件, 深受启发, 甚是谔谔, 当即感觉自己之前一直在玩泥巴.
初学数据结构, 恳请指正.
什么是偏序
其实这一点也不重要
对于一个非空集上的二元关系, 如果其满足自反性, 反对称性, 传递性, 那么称这个二元关系就是这个集合上的偏序.
一个简单的例子: 实数集上的小于等于关系是一个偏序关系.
偏序维护
现在我们有一个具有很多属性的元素, 比如有 $a_i, b_i, c_i, \ldots$
接下来对每个属性给出一些限制, 对满足这些限制的元素进行一些操作, 像 $L_1 \leq a_i \leq R_1, L_2 \leq b_i \leq R_2, L_3 \leq c_i \leq R_3, \ldots$
这就是在维护偏序了, 有时候也把 “属性” 称作 “维度”.
一些维护方法 & 例题
反正我之前是先推出一个 poly log 的数据结构做法
然后想办法去 log,到一个可以接受的复杂度
from lxl 某次洛谷网课
先是两个离线方法.
CDQ 分治
强烈推荐 __stdcall 的 CDQ 分治教程.
简单来说, CDQ 分治做了这样一件事情: 把操作离线下来, 分成两部分, 递归解决; 考虑把这两部分合并, 就要考虑左半部分操作对右半部分的影响, 然后合并.
Luogu P3810【模板】三维偏序
是一道经典题, 考虑 CDQ 分治, 先将第一维排序, CDQ 分治过程中按第二维排序, 使用树状数组统计第三维答案.
有一个细节: 可能存在相同元素, 按题意来讲这些完全相同的元素互相有贡献, 但是在 CDQ 分治的过程中只能统计左半部分对右半部分的贡献, 所以需要去重, 对重复不同次数的元素给定一个不同的权值.
时间复杂度: $O (n\log n \log k)$
代码实现
1 | // Luogu P3810 |
Luogu P3374【模板】树状数组 1
CDQ 分治可以顶替复杂的高级数据结构.
我们可以把初始值看作在 $0$ 的基础上单点修改, 把查询看作两次询问前缀和, 这样每个操作有时间, 对应操作位置, 权值三个属性.
时间这一维按给定顺序有序, CDQ 分治过程中按操作位置维护权值和即可, 注意在操作位置相同时, 按照先修改后查询的顺序统计.
代码实现
1 | // Luogu P3374 |
HDU 5126 stars
对于四维的情况, CDQ 分治可以嵌套使用. 但是 CDQ 套 CDQ 套 CDQ… 就和树套树套树… 一样没用, 不如直接写 bitset
这是一个三维数点问题, 因为要考虑操作时间的影响就是四维偏序了, 在此我使用 CDQ 套 CDQ 解决.
还是参考 __stdcall 的 CDQ 套 CDQ 教程, 把第二维按左右两部分重标号, 依此为根据统计答案.
空间内数点可仿照平面内数点的思路, 统计前缀和按坐标点容斥计算就好了.
时间复杂度大概是 $O(n \log ^3 n)$
代码实现
1 | // HDU 5126 |
整体二分
对于一个询问, 例如区间第 K 小, 有一个朴素的想法: 每次二分答案的值域, 看当前二分值是否恰好在区间的排名为 K.
这样看起来很慢, 于是可以将所有操作视为一个整体进行二分, 每次给定一个答案区间和一个操作区间, 这就是整体二分了.
Luogu P3834【模板】可持久化线段树 1(主席树)
可持久化线段树 $\times$ 静态区间第 k 小 $\checkmark$
算是整体二分的经典问题了. 直接使用之前说过的思路, 整体二分即可.
具体地说, 对于原序列上的数, 按当前二分的值域进行划分; 而对于操作, 则统计当前操作对应区间内数的个数, 以当前操作第 k 小为依据进行划分.
代码实现
1 | // Luogu P3834 |
Luogu P3242 [HNOI2015] 接水果
通过对路径两端点的 dfs 序的讨论, 路径之间的相互包含可以看作是在二维矩阵中数点, 放在这道题中就是二维矩阵中查询第 k 小了.
类似的转化还有子树内距离小于等于的点, 将 DFS 序看作一维, 深度看作另一维…
这东西可以写可持久化树套树, 我…
我当然是把矩阵拆成扫描线, 然后整体二分进行统计.
代码实现
1 | // Luogu P3242 |
遇到毒瘤出题人强制在线就挂了 = =, 所以还需要一些在线解决问题的科技.
可持久化
刚开始学可持久化的时候感觉这个好高级啊, 后来感觉…
这不就是个数据结构的前缀和吗 = =
Luogu P3834【模板】可持久化线段树 1(主席树)
建权值线段树, 动态开点.
按照初始序列建可持久化线段树, 每个位置对应的线段树, 就是包含这个位置前缀信息的线段树了.
具体地说, 先建出一个空树, 然后对于每个位置, 在上一个位置的基础上拓展, 尽量利用之前以及储存过的信息, 再建出一颗新树. 这样每次新增的节点数为 $O(\log n)$, 总共建出 $n$ 颗线段树, 总空间复杂度为 $O(n \log n + n \log n)$.
这样对于每个询问 $[L, R]$, 按照前缀和的基本思想, 在 $L-1, R$ 两颗树上进行二分就好了, 即记录当前二分到的两颗树的左子树 size 之差 (因为是求第 $k$ 小), 然后分类讨论移动到左子树还是右子树.
时间复杂度: $O(n \log n)$
代码实现
1 | // Luogu P3834 |
Luogu P3168 [CQOI2015] 任务查询系统
刚才是单点修改, 区间求和, 而现在是区间修改, 单点求和, 那么差分即可解决.
尝试了不先建出空树的写法, 感觉还行 ?
对时间建权值线段树, 把每次区间操作差分, 挂在对应的时间点上, 记录最后操作对应的节点为当前时间点的节点, 对于询问直接在对应时间点的树上二分即可.
代码实现
1 | // Luogu P3168 |
K-D Tree
解决 K 维问题 !
K-D Tree 类似于二叉搜索树, 建树选择一个维度对当前元素进行分割, 采用合适选择方法和优秀实现, 建树的时间复杂度为 $O(n \log n)$. 详情请 OI Wiki.
考虑到 K-D Tree 的结构, 插入采用类似替罪羊树的平衡方法, 如果有一个节点某一子树的大小超过限制, 就直接重构这个以这一节点为根的子树.
查询直接仿照二叉搜索树了, 注意到 K-D Tree 某些情况下可以剪枝, 可以多维护一些信息来排除掉一些子树.
可以证明, 在 $k$ 维情况下, 单次查询时间复杂度为 $O(n ^ {1 - \frac{1}{k}} + \log n )$.
不过值得注意的是, 这里的查询是类似于矩阵查询的偏序维护, 来个平面最近点对单次操作还是最劣 $O(n)$.
Luogu P4148 简单题
感觉 K-D Tree 的模板题, 没什么好说的, 把 K-D Tree 的操作汇总就好了.
然而我重构写挂了调了好久…
代码实现
1 | // Luogu P4148 |
Luogu P5471 [NOI2019] 弹跳
如果直接用 K-D Tree 建图, 如果用四分树甚至可以 AC, 可以收获 88 pts 的好成绩
卡卡常可以卡过? 为什么不尝试更快的做法呢…
这个写法似乎在 UOJ 被叉掉了 (
那么怎么做呢? 考虑模拟 Dijkstra 的过程, 把这个 K-D Tree 当成堆使用, 记录子树内最小路径长度 (用于更新答案), 以及最大路径长度 (用于剪枝).
具体地说, 因为每个点的最短距离只会被更新一次, 所以 K-D Tree 要维护
- 当前子树覆盖矩阵范围
- 当前节点到起点的最短路, 视作当前点的权值
- 子树内最小权值, 用于查找节点及更新答案
- 当前节点是否被更新过, 换言之, 在 Dijkstra 维护的点集中是否被删去
另外有一个剪枝: 维护一个子树内最大权值, 这样在修改值 (也就是一个矩阵内更新最小值) 的时候遇到 “最大值比修改值还要小” 的情况就可以剪枝.
还有一些细节, 在修改整块矩阵的时候打一个类似于线段树的标记, 以及对于已删除节点值的情况要注意分类讨论.
代码实现
1 | // Luogu P5471 |
树套树
字面意思, 嵌套数据结构.
带有巨大常数, 占用大量空间.
Luogu P3380【模板】二逼平衡树(树套树)
采用线段树套平衡树的方法, 为了好写一些, 内层平衡树我选择了 无旋 Treap.
$k$ 在区间内的排名
把区间丢到线段树上处理, 把区间拆分后得到的排名相加即可. 单次操作时间复杂度 $O(\log^2 n)$
注意一些细节: 数值 $k$ 可能在区间内的贡献可能被计算多次, 所以每次查询内层平衡树时把得到的排名 $-1$, 最后得出答案时再把自己加上.
查询区间内排名为 $k$ 的值
这个问题在普通的平衡树上可以通过二分解决, 但是在多棵平衡树的情况下进行二分好像很困难…
所以直接采用二分值域的方式, 判断当前二分的值是否在区间内排名为 $k$, 单次操作时间复杂度 $O(\log ^3 n)$.
(好像在多个 Trie 上二分很可做, 或者用树状数组套 Trie 来维护, 可以做到 $O(\log ^2 n)$, 还没写过, 值得尝试)
修改某一位值上的数值
在线段树上跑一遍单点修改的操作, 沿途把原来的值删除, 再把修改的值加入就好了. 时间复杂度 $O(\log^2 n)$.
查询 $k$ 在区间内的前驱
查询 $k$ 在区间内的后继
也是把区间丢到线段树上, 注意把前驱取 $\max$, 后缀取 $\min$. 时间复杂度 $O(\log^2 n)$
代码实现
只有靠 O2 才勉强续命的样子.
1 | // Luogu P3380 |
Luogu P3332 [ZJOI2013] K 大数查询
大概是权值线段树套普通线段树 ?
话说这种直接给出操作的数据结构应该是几年前的画风吧 ?
首先对题意进行转化, 将加入的元素看作一个带有两种属性的元素, 那么操作 1 就是加入 $(l, c), (l+1, c), \ldots, (r, c)$ 这些点, 操作 2 就是查询第一个属性 $a$ 满足 $l \le a \le r$ 的点集中第 $k$ 大.
于是就可以很自然地联想到树套树了. 但是区间加入一个数的操作不好实现, 所以就有了一个朴素的想法: 在外层线段树上维护权值, 在内层维护位置.
现在的问题就很明朗了:
对于操作 1: 在外层线段树单点插入一个值, 并在这个位置对应的线段树区间加 1, 表明这个位置加入值了.
注意到代码中使用了 “标记永久化” 的技巧, 这样会比每次下传标记要快,
但是我要卡常为什么不直接写整体二分呢?单次操作时间复杂度 $O(\log^2 n)$.
对于操作 2: 在外层线段树上二分, 和普通权值线段树的操作没什么大的差别, 只是查询子树大小的 $O(\log n)$ 而已…
单次操作时间复杂度 $O(\log^2 n)$.
代码实现
活在 O2, 以及被整体二分吊打的空气之下 = =.
1 | // Luogu P3332 |
Luogu P3759 [TJOI2017] 不勤劳的图书管理员
带权动态逆序对.
题外话: 这题有点题意杀, 不过模拟一遍样例就好了
仍然使用上一题的转化思路, 题目所求即为 $n$ 个带有两种属性的元素 $(a_i, b_i)$ 中满足 $a_i < a_j$ 且 $b_i > b_j$ 的元素对权值和.
交换位置的操作可以看作删除两个元素后加入两个新元素, 所以问题的关键在于加入 / 删除元素对答案的影响.
每次查询一个元素 $(a, b)$ 的影响, 对这个元素有贡献的, 一定满足:
第一维 $< a$, 第二维 $> b$
第一维 $> a$, 第二维 $< b$
由于记录信息具有可减性, 采用树状数组套权值线段树实现.
代码实现
1 | // Luogu P3759 |
数据结构还是学不明白啊, 真是活该退役.png
UPD on 2020.2.27
参考资料
- nzhtl1477, 简单数据结构
- __stdcall, 简易 CDQ 分治教程 & 学习笔记
- Owen_codeisking, [学习笔记] CDQ 分治和整体二分