\(\text{K-D tree}\) 学习笔记
\(\text{K-D tree}\) 是一种针对 \(k\) 维问题求解的算法,并且拥有出色的时空复杂度。
思想
\(\text{K-D tree}\) 本质上是一棵 \(k\) 维的二叉平衡树,这保证了其树高稳定在 \(\log n\) 附近,为求解提供了较为优异的建树模式。
\(\text{K-D tree}\) 首先将所有的 \(k\) 维点建立到一棵树上,这样在求解的时候就可以通过遍历所有结点统计答案,但这样显然不优,因此考虑分治策略。因为点与点之间互不影响,所以我们将子树的求解转化成当前结点、左子树和右子树的求解即可。
但在此时依旧没有本质优化。考虑到问题在于我们忽略了建树提供的子树信息而只在意当前结点维护的信息,我们不妨通过维护子树的一些信息来进行剪枝优化。
不难看出,对于一些点组成的集合最好的概括方式是这些点组成的极小的 \(k\) 维立方体,也就是包含所有点的最小 \(k\) 维立方体。虽然仅仅凭借这个 \(k\) 维立方体我们无法精确的找到子树中所有点的位置,但可以依靠这个 \(k\) 维立方体进行估价。
以 \(k\) 维立方体范围内的点权求和为例,不妨考虑当前子树所对应的极小 \(k\) 维立方体和给定的范围的关系:
- 当前子树的立方体被给定范围完全包含。那么此时整个子树均符合条件,返回子树和即可。
- 当前子树的立方体和给定范围没有相交部分。那么此时整个子树均不符合条件,返回 \(0\) 即可。
- 当前子树的立方体和给定范围部分有交。那么不能直接得出结果,将答案分成当前结点、左子树、右子树分别求解即可。
不难看出这样子的剪枝显然正确,接着考虑时间复杂度。显然时间复杂度只与第 \(3\) 类的子树的个数有关。更详细的,有多少个第 \(3\) 类子树,单次求解的时间复杂度就是多少。而第 \(3\) 类又分为立方体完全包含指定范围和部分相交,前者显然最多有 \(O(h)=O(\log n)\) 个点,现在考虑后者。如果让给定范围的立方体的每一条边偏移一定距离,使其不穿过任何一个点,那么此时第 \(3\) 类子树的数量就是给定立方体的边穿过的立方体数,对于一个 \(3\) 类立方体来说,其有 \(2^k\) 个孙子,这些孙子都经过了 \(k\) 维重划分,显然一条边最多经过其中的一半,也就是 \(2^{k-1}\) 个,那么就有:
\[T(n)=2^{k-1}T(\frac{n}{2^k})+O(1) \]根据主定理得到 \(T(n)=O(n^{1-\frac{1}{k}})\)。
在信息学竞赛中,遇到的问题 \(k\) 常为 \(2\),因此复杂度通常记为 \(O(\sqrt{n})\)。
代码(P4148 简单题)
int Query(int id){
if(id==0)return 0;
for(int i=0;i<K;i++){
if(mx(id,i)<l.pos[i]||mn(id,i)>r.pos[i])return 0;//如果整个立方体都不在给定范围内
}
bool flag=false;
for(int i=0;i<K;i++)flag|=(!(l.pos[i]<=mn(id,i)&&mx(id,i)<=r.pos[i]));
if(!flag)return sum(id);//如果整个立方体全部在给定范围内
int ans=0;
flag=false;
for(int i=0;i<K;i++)flag|=(!(l.pos[i]<=pos(id,i)&&pos(id,i)<=r.pos[i]));
if(!flag)ans+=cnt(id);//当前点在给定范围内
return ans+=Query(ls(id))+Query(rs(id));//继续递归求解
}
建树
为了让两边平衡,我们显然希望两边的点的个数一样多,那当前作为根节点的点一定是当前维度的中位数,直接排序的复杂度是 \(O(n\log n)\) 的,这使得建树的复杂度为 \(O(n\log^2n)\)。但实际上我们只需要让中位数在正确的位置上即可。利用快速排序的思路,将所有小于某个数的数放在该数左边,大于某个数的数放在该数右边,这样枚举到的数就在正确的位置上,我们只需要一直递归包含中位数的一侧即可,这样的复杂度是 \(O(n)\) 的。可以利用 nth_element
函数帮我们快速实现这个操作,这样建树的复杂度就是 \(O(n\log n)\) 的。
代码
#define ls(x) t[x].ls
#define rs(x) t[x].rs
#define cnt(x) t[x].cnt
#define sum(x) t[x].sum
#define pos(x,i) t[x].pos[i]
#define mn(x,i) t[x].mn[i]
#define mx(x,i) t[x].mx[i]
int n,tot,cnt;
int rt,b[N];
struct KDtree{
int ls,rs;
int cnt,sum;
int pos[K];
int mn[K],mx[K];
}t[N];
void Update(int id){
sum(id)=sum(ls(id))+sum(rs(id))+cnt(id);
for(int i=0;i<K;i++){
mn(id,i)=mx(id,i)=pos(id,i);
if(ls(id)){
mn(id,i)=min(mn(id,i),mn(ls(id),i));
mx(id,i)=max(mx(id,i),mx(ls(id),i));
}
if(rs(id)){
mn(id,i)=min(mn(id,i),mn(rs(id),i));
mx(id,i)=max(mx(id,i),mx(rs(id),i));
}
}
return ;
}
int Build(int l,int r,int dep=0){
int mid=(l+r)>>1;
nth_element(b+l,b+mid,b+r+1,[dep](int x,int y){return pos(x,dep)<pos(y,dep);});
int x=b[mid];
if(l<mid)ls(x)=Build(l,mid-1,dep^1);
if(mid<r)rs(x)=Build(mid+1,r,dep^1);
Update(x);
return x;
}
int main(){
ios::sync_with_stdio(0);
cin.tie(0);
cin>>n;
for(int i=1;i<=n;i++){
++tot;
cin>>pos(tot,0)>>pos(tot,1)>>cnt(tot);
b[++cnt]=tot;
}
rt=Build(1,cnt);
return 0;
}
增加
对于有修改的 \(\text{K-D tree}\),我们并不能保证加入后依旧平衡,所以需要重构 \(\text{K-D tree}\),常见的分治方式有根号重构和二进制分组,我一般选择理论更优的二进制分组。
根号重构
思路是存储所有需要插入的点的信息,当大小到达 \(B\) 时进行建树,修改复杂度均摊 \(O(\frac{n\log n}{B})\),查询是 \(O(B+n^{1-\frac{1}{k}})\),当 \(B=O(\sqrt{n\log n})\) 时最优,修改的复杂度为 \(O(\sqrt{n\log n})\),查询是 \(O(\sqrt{n\log n}+n^{1-\frac{1}{k}})\)。
二进制分组
思路是用多个 \(\text{K-D tree}\) 共同维护信息,第 \(i\) 个 \(\text{K-D tree}\) 的大小严格 \(2^i\)。当插入一个新节点时,将已有结点的 \(\text{K-D tree}\) 两两合并(拍扁重构),到没有能合并的子树时进行构建即可,复杂度是均摊的 \(O(n\log^2n)\)。
代码
#define ls(x) t[x].ls
#define rs(x) t[x].rs
#define cnt(x) t[x].cnt
#define sum(x) t[x].sum
#define pos(x,i) t[x].pos[i]
#define mn(x,i) t[x].mn[i]
#define mx(x,i) t[x].mx[i]
int tot,cnt;
int R[L],b[N];
struct KDtree;
void Update(int id);
int Build(int l,int r,int dep=0);
void Del(int &id){
if(id==0)return ;
b[++cnt]=id;
Del(ls(id));
Del(rs(id));
id=0;
return ;
}
int main(){
ios::sync_with_stdio(0);
cin.tie(0);
cin>>n;
for(int i=1;i<=n;i++){
++tot;
cin>>pos(tot,0)>>pos(tot,1)>>cnt(tot);
b[cnt=1]=tot;
for(int j=0;j<L;j++){
if(R[j]==0){
R[j]=Build(1,cnt);
break;
}else Del(R[j]);
}
}
return 0;
}
标签:cnt,int,复杂度,tree,笔记,学习,立方体,id,define
From: https://www.cnblogs.com/DycBlog/p/18209427