怎么一篇文章发不出来啊():angry face:
K-D Tree 是一种可以维护高维空间信息的数据结构,其结构和平衡树比较相似。
维护高维子空间信息
以一道板子题为例:
> 在 kkk 维空间中,有一个由整点构成的可重集 SSS(初始为空),SSS 中每个点都有一个权值。记整点 AAA 的第 ddd 维坐标为 xd(A)x_d(A)xd (A)。
>
> 你需要维护三种操作:
>
> * 操作一:向 SSS 中插入点 AAA,权值为 vvv。
> * 操作二:给定整点 AAA 与整点 BBB,将满足 C∈SC\in SC∈S 且 ∀d∈{1,2,⋯ ,k},xd(A)≤xd(C)≤xd(B)\forall d\in\{1,2,\cdots,k\},x_d(A)\le x_d(C)\le x_d(B)∀d∈{1,2,⋯,k},xd (A)≤xd (C)≤xd (B) 的所有整点 CCC 的权值增加 vvv。
> * 操作三:给定整点 AAA 与整点 BBB,查询满足 C∈SC\in SC∈S 且 ∀d∈{1,2,⋯ ,k},xd(A)≤xd(C)≤xd(B)\forall d\in\{1,2,\cdots,k\},x_d(A)\le x_d(C)\le x_d(B)∀d∈{1,2,⋯,k},xd (A)≤xd (C)≤xd (B) 的所有整点 CCC 权值之和。
>
> Data Range: 1≤m≤1.5×105,k∈{2,3}1\le m\le 1.5\times 10^5,k\in\lbrace 2,3\rbrace1≤m≤1.5×105,k∈{2,3},时间限制 5.00s5.00s5.00s。
>
> 本题强制在线。
下面将使用 K-D Tree 来解决这个问题:
BUILD
和平衡树一样,若将 nnn 个 kkk 维空间上的点建一棵 K-D Tree,则可以递归建树:
* 若当前 n=0n=0n=0,则什么也不做。
* 若当前 n=1n=1n=1,则建立一棵以这个点本身为根,没有儿子结点的树,然后返回上一层。
* 若当前 n>1n>1n>1,则首先选择一个维度 ddd,把所有点按照 ddd 维度上的坐标从小到大排序。然后选择一个点 midmidmid,以 midmidmid 为根结点建树,然后把 [l,mid−1][l,mid-1][l,mid−1] 和 [mid+1,r][mid+1,r][mid+1,r] 两个区间内的点分别放到左子树和右子树上并递归处理。
其中 midmidmid 选取当前点集排序后的中间元素。
可以证明下面几种选择维度 ddd 的方法,都可以保证后续操作时间复杂度正确:
* 随机选取维度 ddd
* 轮流选取每个维度,即选取的维度的顺序为:1,2,3,…,k,1,2,3,…,k,1,2,…1,2,3,\ldots,k,1,2,3,\ldots,k,1,2,\ldots1,2,3,…,k,1,2,3,…,k,1,2,…
* 选取当前下标方差最大的维度
直接模拟上述过程,建树部分的时间复杂度为 O(nlog2n)O(n\log^2n)O(nlog2n) / O(nklog2n)O(nk\log^2n)O(nklog2n),若使用 nth_element 替代 sort 可以做到 O(nlogn)O(n\log n)O(nlogn) / O(nklogn)O(nk\log n)O(nklogn)。
其中通常使用第二种方法建树,后面的时间复杂度分析中也只会分析第二种方法。
QUERY
现在考虑在高维空间中对其一个子空间的信息做查询。
一个暴力的做法是直接递归到每一个叶子结点,单次查询时间复杂度为 O(n)O(n)O(n),显然不可行。
考虑类比平衡树的操作方法,将当前查询空间 QQQ 和当前访问的空间 SSS 之间的关系分为三类:
* Q∩S=∅Q\cap S=\varnothingQ∩S=∅
* Q⊆SQ\subseteq SQ⊆S
* Other
其中第一类情况可以直接不继续递归处理子树,第二类情况可以在建树的时候维护每个结点子树的信息,然后直接查,不继续递归处理子树。剩下的第三类情况继续递归子树处理。
考虑分析其时间复杂度:在轮流划分维度建树时,每过 kkk 次就会划分到同一个维度。因此考虑对于一个结点,处理其 k−1k-1k−1 级的 2k−12^{k-1}2k−1 个儿子结点。然后因为询问的矩形每一条边都是和坐标轴平行的,所以这样最多会切到 222 个区域,即每过 2k−12^{k-1}2k−1 个结点就只会留下 222 个结点继续递归子树。
考虑用 Master Theorem 来计算时间复杂度,有:
T(n)=2k−1T(n2k)+O(1)T(n)=2^{k-1}T(\frac{n}{2^k})+O(1) T(n)=2k−1T(2kn )+O(1)
容易计算得到有 T(n)=O(n1−1k)T(n)=O(n^{1-\frac1k})T(n)=O(n1−k1 ),因此查询操作的时间复杂度即为 O(n1−1k)O(n^{1-\frac1k})O(n1−k1 )(记住:不是 O(n1k)O(n^{\frac1k})O(nk1 ))。
INSERT
上面 K-D Tree 的操作都是静态的,现在要添加插入一个点的操作。
一个比较简单的想法是直接递归整棵 K-D Tree 到叶子结点插入然后一路 pushup 回来。但是注意到此时 K-D Tree 将不再平衡,同时其无法像平衡树一样左旋右旋或者双旋来调整时间复杂度,因此需要一些特殊的手段处理:
根号重构
一个比较容易想到的做法。设立阈值 BBB,然后对于插入操作,把插入的点单独存储到集合 SSS 中,若 ∣S∣=B|S|=B∣S∣=B,则直接暴力重构整棵 K-D Tree。
而查询的时候,先不考虑 SSS 集合内的点,在树中直接查询子空间内信息,然后对 SSS 集合内的每个点暴力将其与原有信息合并。
此时插入操作的时间复杂度为:
* 若 ∣S∣≠B|S|\neq B∣S∣=B,则时间复杂度为 O(1)O(1)O(1)
* 若 ∣S∣=B|S|=B∣S∣=B,则时间复杂度为 O(nlogn)O(n\log n)O(nlogn)
均摊一下,单点插入的时候时间复杂度即为 O(nlognB)O(\frac{n\log n}B)O(Bnlogn )。
而查询操作的时间复杂度为 O(n1−1k+B)O(n^{1-\frac 1k}+B)O(n1−k1 +B),考虑对其根号平衡,即解方程 O(nlognB)=O(n1−1k+B)O(\frac{n\log n}B)=O(n^{1-\frac1k}+B)O(Bnlogn )=O(n1−k1 +B)。通常取 B=O(nlogn)B=O(\sqrt{n\log n})B=O(nlogn ) 时最优,此时插入时间复杂度为 O(nlogn)O(\sqrt{n\log n})O(nlogn ),查询时间复杂度为 O(n1−1k+nlogn)O(n^{1-\frac 1k}+\sqrt{n\log
n})O(n1−k1 +nlogn )。
二进制分组
一种时间复杂度更优的做法。
考虑维护 O(logn)O(\log n)O(logn) 个 K-D Tree,其大小均为 222 的若干次幂,且没有两个树大小相等,且还需保证每个点被恰好划在了一个 K-D Tree 里。
每次插入一个点,就建立一个只有一个点的 K-D Tree,然后此时若存在两个 K-D Tree 的大小相等,就将其直接暴力重构。
查询的时候就直接在每一个 K-D Tree 里分别查询,然后对每个查询得到的信息合并即可。
然后根据进制理论,容易证明每次暴力重构都是从大小最小的树开始按照大小顺序连续的合并,直到一个大小 2p2^p2p 不再合并。因为合并的时间复杂度为 O(nlogn)O(n\log n)O(nlogn),而插入会合并的次数均摊是 O(logn)O(\log n)O(logn) 的,所以每次插入的均摊时间复杂度即为 O(nlog2n)O(n\log^2n)O(nlog2n),而不是 O(nlog3n)O(n\log^3n)O(nlog3n)。
然后再分析此时查询的时间复杂度。对大小为 2p2^p2p 的树而言,查询其的时间复杂度为 O((2p)1−1k)O({(2^p)}^{1-\frac 1k})O((2p)1−k1 ),因此总时间复杂度为 O((∑p=0⌊logn⌋2p)1−1k)=O(n1−1k)O((\sum\limits_{p=0}^{\lfloor\log n\rfloor}2^p)^{1-\frac1k})=O(n^{1-\frac1k})O((p=0∑⌊logn⌋ 2p)1−k1 )=O(n1−k1 )。
MODIFY
单点修改信息是容易的,直接暴力递归到叶子结点然后倒序 pushup,时间复杂度为 O(logn)O(\log n)O(logn) 且不会影响树的结构。
而若要整体修改一个空间 SSS 内的信息,暴力处理每个 x∈Sx\in Sx∈S,时间复杂度显然不对。考虑类比平衡树,在每个结点上维护 tagtagtag 标记,表示当前结点有哪些信息没有被下传到其子树内。
具体的,将当前查询空间 QQQ 和当前访问的空间 SSS 之间的关系分为三类:
* Q∩S=∅Q\cap S=\varnothingQ∩S=∅
* Q⊆SQ\subseteq SQ⊆S
* Other
对于第一种情况,无需继续递归修改,第二种情况子树内所有元素均需递归修改,所以考虑直接在当前结点上打懒惰标记 tagtagtag,然后直接返回其父节点即可。剩下的第三类情况继续递归子树处理。
而在每次 modify 和 query 操作之前,均把当前结点的信息 pushdown 到其儿子结点,就可以保证访问到该结点时,其对应的信息是正确的。
时间复杂度和 query 的时间复杂度相同,均为 O(n1−1k)O(n^{1-\frac 1k})O(n1−k1 )。
细节处理
在本题中,需要对 K-D Tree 上的每个结点,维护下面的信息:
* x[0:k−1]x[0:k-1]x[0:k−1]:表示当前结点对应的点第 0…k−10\ldots k-10…k−1 维的坐标。
* val,sumval,sumval,sum:分别表示当前结点对应的点的信息,以及其子树内所有结点对应信息的并,在本题中为点权 / 子树点权和。
* l,rl,rl,r:当前结点的左儿子 / 右儿子结点。
* L[0:k−1],R[0:k−1]L[0:k-1],R[0:k-1]L[0:k−1],R[0:k−1]:表示当前结点子树内第 0…k−10\ldots k-10…k−1 维中坐标最小 / 最大值是多少。
* sizsizsiz:表示当前结点子树内有多少个结点,即子树大小。
* tagtagtag:懒标记信息,本题中为需要懒惰下传的区间加的数值。
在 pushup 的过程中,uuu 结点更新的信息有:
* u.sum 信息,更新方法为 u.sum = (u.l).sum + (u.r).sum + u.val(注意要加上 u.val 即当前点的点权)
* 对每个维度,均重新更新 u.L,u.R 两个数组内维护的信息,同样需要加上当前结点 u.x 内对应的信息。
* 不要清空懒标记。
具体实现如下:
------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
pushdown 的过程和平衡树是类似的。
------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
build 的过程中,因为在重构的时候也会调用,所以应注意对其左右儿子的信息的处理(即在 build 之前先清空信息):
其中 dest 数组内存储了当前需要建树的空间内的所有点的信息。
------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
然后是暴力重构部分,在找到第一个合并位置的时候一定要直接 break,不然可能会出现一些奇奇怪怪的错误。同时还需要记得每次重构之前清空 dest 数组的信息。
重构之前要先清空每个需要重构的结点的懒标记,即直接暴力递归整棵树 pushdown,在下面的代码中对应 push_cache 函数。
一些奇奇怪怪的错误是比如说忘记清空 dest 数组的使用位置指针 cnt 了,或者新建结点的时候直接复用了老结点的信息。
最后剩下 modify 和 query 两个函数,这里需要特别注意特判掉当前子树根的结点的信息。一些其他的细节是特判子空间的子集关系时一定需要判掉所有的维度,而不能只判断一个维度为子集关系就直接确认关系。
(其中 loyalty 函数是修改,luminescent 函数是查询,至于为什么用这两个名字你不用管)
而在修改 / 查询的时候,需要对所有 O(logn)O(\log n)O(logn) 个 K-D Tree 均做修改 / 查询,所以主函数内应这样调用:
------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
例题
P14312 【模板】K-D TREE**
直接把刚才那几个操作合起来即可。
P4148 简单题**
(甚至比刚才那个还简单,连子空间修改操作都没了)
邻域查询
note:K-D Tree 上邻域查询的时间复杂度是没有保障的,其在随机数据下跑的很快,但是可以构造数据卡到 O(n)O(n)O(n)。
以一道板子题为例:
> 平面上给定 nnn 个点 (xi,yi)(x_i,y_i)(xi ,yi ),设 Dist(i,j)=∣xi−xj∣+∣yi−yj∣\text{Dist}(i,j)=|x_i-x_j|+|y_i-y_j|Dist(i,j)=∣xi −xj ∣+∣yi −yj ∣,求 max1≤i≤j≤nDist(i,j)\max\limits_{1\le i\le j\le n}\text{Dist}(i,j)1≤i≤j≤nmax Dist(i,j) 的值。
>
> Data Range: 1≤n≤1051\le n\le 10^51≤n≤105
常规解法是曼哈顿转切比雪夫或者拆绝对值,这里提供一种使用 K-D Tree 的做法。
先拆分上面的式子,可以看作是对每个 iii,求出 resires_iresi 表示平面上距离 iii 点最远的点的距离。那么答案就是 maxi=1nresi\max\limits_{i=1}^nres_ii=1maxn resi 。
而求 resires_iresi ,一个直接的想法是直接遍历整棵 K-D Tree 的所有结点,依次更新答案,但是这样单次查询时间复杂度为 O(n)O(n)O(n),显然无法通过。
考虑使用一些剪枝来优化运行效率:
(1)在递归到某个结点时,先处理当前根结点对应坐标的信息,这个处理的时间复杂度为 O(1)O(1)O(1)。
(2)一个比较简单的剪枝是考虑在 K-D Tree 上的每个结点都维护信息 xmax,ymax,xmin,yminx_{max},y_{max},x_{min},y_{min}xmax ,ymax ,xmin ,ymin 即当前点所在子树中所有坐标点 x,yx,yx,y 两个维度的最大值 / 最小值。遍历到某个结点时,若该结点到坐标 (xmax,ymax),(xmax,ymin),(xmin,ymax),(xmin,ymin)(x_{max},y_{max}),(x_{max},y_{min}),(x_{min},y_{max}),(x_{min},y_{min})(xmax ,ymax
),(xmax ,ymin ),(xmin ,ymax ),(xmin ,ymin ) 四个坐标点的距离都不可能比当前已经更新到的最小值要优,那么就不再遍历当前分岔。
(3)类似于 A*,考虑给每个点都设一个估价函数。例如在该问题中,估价函数就可以设为该点到询问空间内所有点的最长距离。此时按照估价函数从大到小遍历每个分岔即可。例如在二维空间中(即本问题中),就可以这样设计估价函数:
使用上述剪枝优化该算法后,时间复杂度仍为 O(n)O(n)O(n),但是在随机数据下运行效率极高。同时因为该算法本质上就是暴力搜索加剪枝,所以也可以同时轻松的找出一组最远点对。
P2479 [SDOI2010] 捉迷藏**
这个问题在刚才问题的基础上额外需要求最近点对距离,因此同样的,类比最远点对距离,设一个最近点对距离的估价函数。容易想到设该估价函数为当前点到当前所处空间的最近距离,即:
剩下的部分和刚才的分析是一样的。
P4357 [CQOI2016] K 远点对**
(弱化版,需保证数据随机)
该问题是求在欧几里得距离下第 kkk 远的点对。注意到 kkk 很小,因此考虑在 K-D Tree 搜索的过程中直接把前 kkk 远的点对全部搜出来。
具体的,用一个小根堆来维护当前所有搜到的距离。若当前搜到的距离比小跟堆堆顶的距离要更远,那么弹出堆顶,将该元素塞到堆里。最后答案即为堆顶元素。
这个题把 K-D Tree 卡了,所以只能用计算几何做法来做。
P2093 [国家集训队] JZPFAR**
刚刚那题的数据弱化版,经过卡常之后可以通过。
P4475 巧克力王国**
注意到该题保证数据以某种方式随机生成,因此考虑使用一些比较暴力的方法,如类比上面邻域查询问题时的剪枝。
容易想到先建立 K-D Tree,在树上的每个结点都维护信息 xmax,ymax,xmin,yminx_{max},y_{max},x_{min},y_{min}xmax ,ymax ,xmin ,ymin 即当前点所在子树中所有坐标点 x,yx,yx,y 两个维度的最大值 / 最小值。遍历到某个结点时,容易发现因为固定一个维度之后函数满足单调性,所以该子树内的最大值和最小值一定在下面四个值中的 min,max\min,\maxmin,max 值之内:
* axmax+bymaxax_{max}+by_{max}axmax +bymax
* axmin+bymaxax_{min}+by_{max}axmin +bymax
* axmax+byminax_{max}+by_{min}axmax +bymin
* axmin+bymaxax_{min}+by_{max}axmin +bymax
考虑到这个东西的最大值 / 最小值一定是包含实际的最大值 / 最小值的,因此可以保证正确性的递归。设上面东西求出的最大值为 mxmxmx 最小值为 mimimi,则考虑分类讨论:
* 若有 c≤mic\le mic≤mi,则此时显然该子树内所有点都不可能满足条件,直接返回上一层循环即可。
* 若有 c>mxc>mxc>mx,则此时显然该子树内所有点都可以满足条件,在 K-D Tree 上的每个结点内都额外记录一个信息 sumsumsum 表示子树内所有点的甜度值的和,直接返回当前点的 sumsumsum 值即可。
* 否则,暴力往两个子树内递归。
因为数据是随机生成的,而该启发式算法在随机数据下跑的很快,所以可以轻松通过该题。