K-D Tree简单介绍

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可以方便地解决

对于每一个询问,如果当先访问到的树上节点对应子树的矩形被询问对应的矩形完全包含,那么就直接打懒标记,如果两个矩形没有交集,那么直接返回,否则就递归下去,和线段树区间操作非常相似,非常简单

小结

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板子,对于每个给定点找最近点曼哈顿距离就行


该文为本人原创,转载请注明出处

博客园传送门

Luogu传送门

posted @ 2021-11-09 19:52  人形魔芋  阅读(640)  评论(1编辑  收藏  举报