以下总结纯属个人学习理解,如有不对还望留言改正。参考文章博客地址如下:
https://blog.csdn.net/likika2012/article/details/39619687
https://blog.csdn.net/zhjchengfeng5/article/details/7855241
https://www.joinquant.com/view/community/detail/c2c41c79657cebf8cd871b44ce4f5d97
https://zhuanlan.zhihu.com/p/22557068
https://www.cnblogs.com/dirge/p/6091241.html
https://leileiluoluo.com/posts/kdtree-algorithm-and-implementation.html
感谢几位大神的详细总结;
首先要学习kdtree就要先理解二叉树,因为实现kdtree的数据结构是基于二叉树思想来实现的。
二叉树顾名思义就是一个根节点有两个子节点;二叉树思想:
二叉查找树(Binary Search Tree,BST),是具有如下性质的二叉树(来自wiki):
1)若它的左子树不为空,则左子树上所有结点的值均小于它的根结点的值;
2)若它的右子树不为空,则右子树上所有结点的值均大于它的根结点的值;
3)它的左、右子树也分别为二叉排序树;如图所示:
一个二叉树结构永远满足:即根结点的左子树中所有结点的值均小于根结点的值,而根结点的右子树中所有结点的值均大于根结点的值。将一个1维数据集用一棵BST树存储后,当我们想要查询某个数据是否位于该数据集合中时,只需要将查询数据与结点值进行比较然后选择对应的子树继续往下查找即可,查找的平均时间复杂度为:O(logN),最坏的情况下是O(N)。
Kd-Tree与一维二叉查找树之间的区别:
二叉查找树:数据存放在树中的每个结点(根结点、中间结点、叶子结点)中;
Kd-Tree:数据只存放在叶子结点,而根结点和中间结点存放一些空间划分信息(例如划分维度、划分值);
节点的状态:分裂点(split_point) 分裂方式(split_method) 左儿子(left_son) 右儿子(right_son)
我们建树的规则就是节点的状态中的:分裂方式(split_method)
想必读者已经看见上面的关键字了:分裂点 分裂方式,为什么反复的出现分裂这两个字呢?难道建一颗 K-D tree 还要分裂什么,分裂空间?
对,K-D tree的建立就是分裂空间的过程!
怎么建树呢?
建树依据:
先计算当前区间 [ L , R ] 中(这里的区间是点的序号区间,而不是我们实际上的坐标区间),每个点的坐标的每一维度上的方差,取方差最大的那一维,设为 d,作为我们的分裂方式(split_method ),把区间中的点按照在 d 上的大小,从小到大排序,取中间的点 sorted_mid 作为当前节点记录的分裂点,然后,再以 [ L , sorted_mid-1 ] 为左子树建树
, 以 [sorted_mid+1 , R ] 为右子树建树,这样,当前节点的所有状态我们便确定下来了:
split_point= sorted_mid
split_method= d
left_son = [ L , sorted_mid-1 ]
right_son = [ sorted_mid+1 , R ]
将其推广到K维空间,即kdtree(一般常用即三维即可)。
总体思想就是:首先确定哪个维度的方差大,按照维度大的维度进行从小到大排序,寻找中点,在此维度上以该中点作为分割线,分别建立左右子树;然后在左右子树上重复此过程。
树是建了,那么查询呢?
查询过程:
查询,其实相当于我们要将一个点“添加”到已经建好的 K-D tree 中,但并不是真的添加进去,只是找到他应该处于的子空间即可,所以查询就显得简单的毒攻了
每次在一个区间中查询的时候,先看这个区间的分裂方式是什么,也就是说,先看这个区间是按照哪一维来分裂的,这样如果这个点对应的那一维上面的值比根节点的小,就在根节点的左子树上进行查询操作,如果是大的话,就在右子树上进查询操作
每次回溯到了根节点(也就是说,对他的一个子树的查找已经完成了)的时候,判断一下,以该点为圆心,目前找到的最小距离为半径,看是否和分裂区间的那一维所构成的平面相交,要是相交的话,最近点可能还在另一个子树上,所以还要再查询另一个子树,同时,还要看能否用根节点到该点的距离来更新我们的最近距离。为什么是这样的,我们可以用一幅图来说明:
在查询到左儿子的时候,我们发现,现在最小的距离是 r = 10 ,当回溯到父亲节点的时候,我们发现,以目标点(10,1)为圆心,现在的最小距离 r = 10 为半径做圆,与分割平面 y = 8 相交,这时候,如果我们不在父亲节点的右儿子进行一次查找的话,就会漏掉
(10,9) 这个点,实际上,这个点才是距离目标点 (10,1) 最近的点
由于每次查询的时候可能会把左右两边的子树都查询完,所以,查询并不是简单的 log(n) 的,最坏的时候能够达到 sqrt(n)。。。
#include <iostream>
#include <cstdio>
#include <cstring>
#include <cmath>
#include <algorithm>
#include <vector>
#include <string>
#include <queue>
#include <stack>
#define INT_INF 0x3fffffff
#define LL_INF 0x3fffffffffffffff
#define EPS 1e-12
#define MOD 1000000007
#define PI 3.141592653579798
#define N 60000
using namespace std;
typedef long long LL;//定义类型LL代表Long Long
typedef unsigned long long ULL;
typedef double DB;
struct data//定义数据结构
{
LL pos[10];
int id;
} T[N] , op , point;
int split[N],now,n,demension;
bool use[N];
LL ans,id;
DB var[10];
bool cmp(data a,data b)//参数传入结构
{
return a.pos[split[now]]<b.pos[split[now]];//比较函数
}
void build(int L,int R)
{
if(L>R) return;
int mid=(L+R)>>1;
//求出 每一维 上面的方差
for(int pos=0;pos<demension;pos++)
{
DB ave=var[pos]=0.0;
for(int i=L;i<=R;i++)
{
ave+=T[i].pos[pos];
}
ave/=(R-L+1);
for(int i=L;i<=R;i++)
{
var[pos]+=(T[i].pos[pos]-ave)*(T[i].pos[pos]-ave);
}
var[pos]/=(R-L+1);
}
//找到方差最大的那一维,用它来作为当前区间的 split_method
split[now=mid]=0;
for(int i=1;i<demension;i++)
{
if(var[split[mid]]<var[i]) split[mid]=i;
}
//对区间排排序,找到中间点
nth_element(T+L,T+mid,T+R+1,cmp);
build(L,mid-1);
build(mid+1,R);
}
void query(int L,int R)
{
if(L>R) return;
int mid=(L+R)>>1;
//求出目标点 op 到现在的根节点的距离
LL dis=0;
for(int i=0;i<demension;i++)
{
dis+=(op.pos[i]-T[mid].pos[i])*(op.pos[i]-T[mid].pos[i]);
}
//如果当前区间的根节点能够用来更新最近距离,并且 dis 小于已经求得的 ans
if(!use[T[mid].id] && dis<ans)
{
ans=dis; //更新最近距离
point=T[mid]; //更新取得最近距离下的点
id=T[mid].id; //更新取得最近距离的点的 id
}
//计算 op 到分裂平面的距离
LL radius=(op.pos[split[mid]]-T[mid].pos[split[mid]])*(op.pos[split[mid]]-T[mid].pos[split[mid]]);
//对子区间进行查询
if(op.pos[split[mid]]<T[mid].pos[split[mid]])
{
query(L,mid-1);
if(radius<=ans) query(mid+1,R);
}
else
{
query(mid+1,R);
if(radius<=ans) query(L,mid-1);
}
}
int main()
{
while(scanf("%d%d",&n,&demension)!=EOF)
{
//读入 n 个点
for(int i=1;i<=n;i++)
{
for(int j=0;j<demension;j++)
{
scanf("%I64d",&T[i].pos[j]);
}
T[i].id=i;
}
build(1,n); //建树
int m,q; scanf("%d",&q); // q 个询问
while(q--)
{
memset(use,0,sizeof(use));
for(int i=0;i<demension;i++)
{
scanf("%I64d",&op.pos[i]);
}
scanf("%d",&m);
printf("the closest %d points are:\n",m);
while(m--)
{
ans=(((LL)INT_INF)*INT_INF);
query(1,n);
for(int i=0;i<demension;i++)
{
printf("%I64d",point.pos[i]);
if(i==demension-1) printf("\n");
else printf(" ");
}
use[id]=1;
}
}
}
return 0;
}
详细解释:详细解释:详细解释:详细解释:详细解释:详细解释:详细解释:
1维划分原则:在构造1维BST树时,一个1维数据根据其与树的根结点和中间结点进行大小比较的结果来决定是划分到左子树还是右子树
将二叉树思想运用到kdtree中,首先需要确定怎样划分左子树和右子树,即一个K维数据是依据什么被划分到左子树或右子树的。
K维划分原则:将一个K维数据与Kd-tree的根结点和中间结点进行比较,只不过不是对K维数据进行整体的比较,而是选择某一个维度Di(i代表维度x,y,z等),然后比较两个K维数在该维度Di上的大小关系,即每次选择一个维度Di来对K维数据进行划分,相当于用一个垂直于该维度Di的超平面将K维数据空间一分为二,平面一边的所有K维数据在Di维度上的值小于平面另一边的所有K维数据对应维度上的值。也就是说,我们每选择一个维度进行如上的划分,就会将K维数据空间划分为两个部分,如果我们继续分别对这两个子K维空间进行如上的划分,又会得到新的子空间,对新的子空间又继续划分,重复以上过程直到每个子空间都不能再划分为止。(此过程会涉及两个疑问:1.每次对子空间的划分时,怎样确定在哪个维度上进行划分;2。在某个维度上进行划分时,怎样确保在这一维度上的划分得到的两个子集合的数量尽量相等,即左子树和右子树中的结点个数尽量相等)
针对于第一个疑问:
最简单的方法就是轮着来,即如果这次选择了在第i维上进行数据划分,那下一次就在第j(j≠i)维上进行划分,例如:j = (i mod k) + 1(此处是先+1再求余还是先求余数再+1,没确定????)。想象一下我们切豆腐时,先是竖着切一刀,切成两半后,再横着来一刀,就得到了很小的方块豆腐。
可是“轮着来”的方法是否可以很好地解决问题呢?再次想象一下,我们现在要切的是一根木条,按照“轮着来”的方法先是竖着切一刀,木条一分为二,干净利落,接下来就是再横着切一刀,这个时候就有点考验刀法了,如果木条的直径(横截面)较大,还可以下手,如果直径较小,就没法往下切了。因此,如果K维数据的分布像上面的豆腐一样,“轮着来”的切分方法是可以奏效,但是如果K维度上数据的分布像木条一样,“轮着来”就不好用了。因此,还需要想想其他的切法。
如果一个K维数据集合的分布像木条一样,那就是说明这K维数据在木条较长方向代表的维度上,这些数据的分布散得比较开,数学上来说,就是这些数据在该维度上的方差(invariance)比较大,换句话说,正因为这些数据在该维度上分散的比较开,我们就更容易在这个维度上将它们划分开,因此,这就引出了我们选择维度的另一种方法:最大方差法(max invarince),即每次我们选择维度进行划分时,都选择具有最大方差维度。
基于方差的维度选择举例:
假设有6个二维数据点{(2,3),(5,4),(9,6),(4,7),(8,1),(7,2)},数据点位于二维空间内,如下图所示。为了能有效的找到最近邻,k-d树采用分而治之的思想,即将整个空间划分为几个小部分,首先,粗黑线将空间一分为二,然后在两个子空间中,细黑直线又将整个空间划分为四部分,最后虚黑直线将这四部分进一步划分。
6个二维数据点{(2,3),(5,4),(9,6),(4,7),(8,1),(7,2)}构建kd树的具体步骤为:
- 确定:split域=x。具体是:6个数据点在x,y维度上的数据方差分别为39,28.63,所以在x轴上方差更大,故split域值为x;
- 确定:Node-data = (7,2)。具体是:根据x维上的值将数据排序,6个数据的中值(所谓中值,即中间大小的值)为7,所以Node-data域位数据点(7,2)。这样,该节点的分割超平面就是通过(7,2)并垂直于:split=x轴的直线x=7;
- 确定:左子空间和右子空间。具体是:分割超平面x=7将整个空间分为两部分:x<=7的部分为左子空间,包含3个节点={(2,3),(5,4),(4,7)};另一部分为右子空间,包含2个节点={(9,6),(8,1)};
如上算法所述,kd树的构建是一个递归过程,我们对左子空间和右子空间内的数据重复根节点的过程就可以得到一级子节点(5,4)和(9,6),同时将空间和数据集进一步细分,如此往复直到空间中只包含一个数据点。
与此同时,经过对上面所示的空间划分之后,我们可以看出,点(7,2)可以为根结点,从根结点出发的两条红粗斜线指向的(5,4)和(9,6)则为根结点的左右子结点,而(2,3),(4,7)则为(5,4)的左右孩子(通过两条细红斜线相连),最后,(8,1)为(9,6)的左孩子(通过细红斜线相连)。如此,便形成了下面这样一棵k-d树,如下左图。右图为 k-d树的数据结构。
****基于上例延申的最近邻查询即k=1的Knn。给定点(2.1,3.1),查询此点的最近邻点。
星号表示要查询的点(2.1,3.1)。通过二叉搜索,顺着搜索路径很快就能找到最邻近的近似点,也就是叶子节点(2,3)。而找到的叶子节点并不一定就是最邻近的,最邻近肯定距离查询点更近,应该位于以查询点为圆心且通过叶子节点的圆域内。为了找到真正的最近邻,还需要进行相关的‘回溯'操作。也就是说,算法首先沿搜索路径反向查找是否有距离查询点更近的数据点。
以查询(2.1,3.1)为例:
- 二叉树搜索:先从(7,2)点开始进行二叉查找,然后到达(5,4),最后到达(2,3),此时搜索路径中的节点为<(7,2),(5,4),(2,3)>,首先以(2,3)作为当前最近邻点,计算其到查询点(2.1,3.1)的距离为0.1414,
- 回溯查找:在得到(2,3)为查询点的最近点之后,回溯到其父节点(5,4),并判断在该父节点的其他子节点空间中是否有距离查询点更近的数据点。以(2.1,3.1)为圆心,以0.1414为半径画圆,如下图所示。发现该圆并不和超平面y = 4交割,(注意此处是因为与右子空间无交集),因此不用进入(5,4)节点右子空间中(图中灰色区域)去搜索;
- 最后,再回溯到(7,2),以(2.1,3.1)为圆心,以0.1414为半径的圆更不会与x = 7超平面交割,因此不用进入(7,2)右子空间进行查找。至此,搜索路径中的节点已经全部回溯完,结束整个搜索,返回最近邻点(2,3),最近距离为0.1414。*****定点(2,4.5),查询此点的最近邻点。
- 同样先进行二叉查找,先从(7,2)查找到(5,4)节点,在进行查找时是由y = 4为分割超平面的,由于查找点为y值为4.5,因此进入右子空间查找到(4,7),形成搜索路径<(7,2),(5,4),(4,7)>,但(4,7)与目标查找点的距离为3.202,而(5,4)与查找点之间的距离为3.041,所以(5,4)为查询点的最近点;
- 以(2,4.5)为圆心,以3.041为半径作圆,如下图所示。可见该圆和y = 4超平面交割,所以需要进入(5,4)左子空间进行查找,也就是将(2,3)节点加入搜索路径中得<(7,2),(2,3)>;于是接着搜索至(2,3)叶子节点,(2,3)距离(2,4.5)比(5,4)要近,所以最近邻点更新为(2,3),最近距离更新为1.5;
- 回溯查找至(5,4),直到最后回溯到根结点(7,2)的时候,以(2,4.5)为圆心1.5为半径作圆,并不和x = 7分割超平面交割,如下图所示。至此,搜索路径回溯完,返回最近邻点(2,3),最近距离1.5。
- ******(上述两次实例表明,当查询点的邻域与分割超平面两侧空间交割时,需要查找另一侧子空间,导致检索过程复杂,效率下降。)
来源:CSDN
作者:袁小姐
链接:https://blog.csdn.net/m0_37957160/article/details/103870848