K-D Tree
简介
K-D Tree全称 K-Dimensional Tree,也就是 \(K\) 维树,是一种高效的树形结构
K-D Tree与平衡树(平衡二叉查找树)比较类似,不同在于平衡树每个节点仅仅维护一个值,而K-D Tree所维护的信息可能是 \(2\) 维甚至更高的,那么怎么样才能保证均摊复杂度呢?下面就来说一说
实现
存储与维护
K-D Tree一般需要存储多维点信息和树上节点信息,下面是存储多维点的例子
struct point
{
int d[k];
const bool operator < (point p)const
{
return d[wd]<p.d[wd];
}
}
树上节点一般维护子树中每一维信息的最大值和最小值,以及左右儿子编号还有自己对应的一个节点的信息,下面是K-D Tree树上节点的例子
struct Tree
{
int ch[2];
point cur,mn,mx;
};
维护方法就是与线段树类似的pushup,可操作性很强,从左右儿子更新自己的信息,有点区别的就是需要注意更新时要加上自己节点所对应的信息,下面是维护节点信息(子树多维信息最大值最小值)的pushup例子
void pushup(Tree p)
{
for(int i=0;i<2;++i)//枚举左右儿子
if(p.ch[i])//如果子节点存在
for(int j=0;j<2;++j)//就更新信息
p.mn.d[j]=min(p.mn.d[j],kd[p.ch[i]].mn.d[j]),//更新第j维最小值
p.mx.d[j]=max(p.mx.d[j],kd[p.ch[i]].mx.d[j]);//更新第j维最大值
}
建树
K-D Tree主要通过建树来保证树的形态较为平衡,对于树上的每一层,我们都选择一个维度进行划分,以选择的维度为分辨器来分割节点,主要有 轮转法,随机法和最大方差法,选择之后只要使用函数 nth_element() 来选择中位数,以中位数为划分点,小于中位数的为左儿子,大于中位数的为右儿子,递归建树即可,下面来说说维度划分
轮转法 利用取模运算,每一层一次选择不同的维度进行划分,例如一共有 \(k\) 个维度,那么,第一层用第 \(1\) 维,第 \(2\) 层用第 \(2\) 维……第 \(i\) 层用第 \(i\%k\) 维
随机法 顾名思义利用随机数随机选择一个维度进行划分
最大方差法 计算当前要建树所有点在各个维度的方差,选择方差最大的维度进行划分
建树的复杂度为 \(\Theta(n\log n)\)
下面是一个例子,二维的数据以轮转法划分
void build(int &pos,int l,int r,int dep)
{
pos=0;//先赋值为0让父节点的子节点编号为0
if(l>r)return;//如果为空直接返回
int mid=(l+r)>>1;//找到最中间的位置
pos=mid;//以最中间位置的下标为当前节点的下标
wd=dep&1;//维度划分
nth_element(node+l,node+mid,node+r+1);//找到中位数
build(kdt[pos].ch[0],l,mid-1,dep+1);//建左儿子
build(kdt[pos].ch[1],mid+1,r,dep+1);//建右儿子
pushup(pos);//更新维护的数据,根据具体题目调整
}
查询
K-D Tree的查询操作也很多样,我认为我讲的不够清晰,推荐大家看 Luogu 上@光与影的彼岸的一篇博客,讲的非常详细清晰 浅谈偏序问题与K-D Tree - 光与影的彼岸 - 洛谷博客
我就只简单说一下求平面最近点和最远点的方法
首先考虑朴素算法,将每个节点和所有节点比较,求出所有点对的距离,时间复杂度 \(\Theta(n^2)\)
怎么优化呢?我们可以像A*算法一样设计一个估价函数,根据K-D Tree的划分性质和节点维护的信息,每个节点对应的子树最大值最小值可以看做一个一个的矩形,估价函数则是估计当前点到矩形的最短距离(注意这里估价函数同样需要满足任何估计代价必须小于实际代价),如果当前找到的最短距离都比估价要小,则可以放弃继续查询这棵子树,节省了大量的时间
查询的期望复杂度为 \(\Theta(n\sqrt n)\)
这也是为什么大多数K-D Tree都是作为骗分的算法,但是由于建树方法多种多样,所以卡K-D Tree也并不是那么轻松
下面给出求平面最近点的例子
inline int sqr(int x){return x*x;}//平方
inline int dis2(point x,point y){return sqr(x.d[0]-y.d[0])+sqr(x.d[1]-y.d[1]);}//欧几里得距离平方
inline int fmin(point x,Tree y)//估价函数
{
int f=0;
for(int i=0;i<2;++i)
f+=sqr(max(y.mn.d[i]-x.d[i],0.0))+sqr(max(x.d[i]-y.mx.d[i],0.0));
return f;
}
int maxans=-1;
inline void querymin(Tree x,point y)
{
if(x.cur!=y) minans=min(minans,dis2(x.cur,y));//最近点要注意不能判断到自己,否则最近点就都是自身了
int fl=INF,fr=INF;//估价初始化为正无穷,保证在没有儿子的时候不会继续往下进入死循环
if(x.ch[0]) fl=fmin(y,kd[x.ch[0]]);//左儿子估价
if(x.ch[1]) fr=fmin(y,kd[x.ch[1]]);//右儿子估价
if(fl>=fr)//优先走估价更小的子节点
{
if(fr<minans)querymin(kd[x.ch[1]],y);//估价满足就走右儿子
if(fl<minans)querymin(kd[x.ch[0]],y);//估价满足就走左儿子
}
else
{
if(fl<minans)querymin(kd[x.ch[0]],y);
if(fr<minans)querymin(kd[x.ch[1]],y);
}
}
插入删除
插入操作从根节点开始一直往被划分的方向向下走,直到找到一个相同的点或者找到的节点没有对应子节点,如果找到一个相同的点就维护一个 \(cnt\) 累加即可,若找到的节点没有子节点就申请一个新节点,并且把新的节点编号作为找到节点的左儿子或者右儿子
删除操作和替罪羊树的删除操作比较像,用和插入操作相同的方法找到对应节点之后,为节点打上被删除标记,上传信息的时候特判不要加入自己的信息即可
插入和删除操作结束之后都需要上传标记
需要注意的是,如果只是单纯插入删除点,在操作次数多了之后K-D Tree可能退化为一条链,导致效率变低,为了避免这种问题,我们需要考虑使用类似平衡树的方法来维持树的平衡,但是Splay,有旋Treap之类的旋转操作显然会破坏K-D Tree的性质,我们这时就需要替罪羊树的重构操作,将树直接拍扁然后重新建子树就能尽可能维护树的平衡
小结
K-D Tree能非常方便地处理多维数据,而且可扩展性较强,可以方便地支持各种操作,码量也并不算很大,只是复杂度较高,不过在OI考场上绝对是一种优秀的骗分方法,在大多数情况下都能获得很高的分数,下面给出几道例题,可以尝试一下
Luogu P1429 平面最近点对(加强版) 枚举每一个点,K-D Tree找最近点,并记录最小值即可,复杂度 \(\Theta(n^{\frac32})\)
Luogu P6247 [SDOI2012]最近最远点对 同上题,只是多了一个最远点
Luogu P4357 [CQOI2016]K 远点对 注意到 \(k\) 很小,直接暴力把所有点的最远的 \(k\) 个点放入大小为 \(k\) 小根堆,最后堆顶就是答案
Luogu P4169 天使玩偶/SJY摆棋子 带插入K-D Tree板子,对于每个给定点找最近点曼哈顿距离就行
该文为本人原创,转载请注明出处