声明:

蒟蒻对于 KD-Tree 的一点理解,写在博客里面作为笔记.

1.KD-Tree 的定义

1)关于 K-D

KD-Tree 中的 D 即为 Dimension ,意思也就是维度.

所以 KD-Tree 中的 K 也就是我们常常引用的一个常数而已.

K-D 意为 有 K 维







2)关于 Tree

KD-Tree 中的 Tree 是一棵二叉搜索树 (BST),也就是我们的平衡树.但是这棵平衡树会和普通的 BST 有所区别,即:

  • BST:数据存放在树中的每个结点(根结点、中间结点、叶子结点)中;

  • KD-Tree:数据只存放在叶子结点,而根结点和中间结点存放一些空间划分信息(例如划分维度、划分值);

综上, KD-Tree 实为一棵由有 K 维的元素组成的较为特殊的二叉搜索树.




然后KD-tree是一种支持查询平面最近点(其实是k维,但这种题目比较少)的数据结构,单次理论时间复杂度O(√n),但实际是非常快的,可以说KD-tree就是一种比较优化的暴力.





2.KD-Tree 所解决的问题

我在学一个新算法的时候,经常是先看问题,再看算法,这样显然更好理解.

关于 KD-Tree ,洛谷上的模板题我觉得便已很典型:


看完刚才的定义,应该有的读者已经有了一些想法.(反正我是哈...)

然后发现 K-D Tree 在题目中更多的是用于对当前题目的算法的优化.如 CQOI 的 K远点对.

于是接下来讲 KD-Tree 实际操作中的几个算法.





3.KD-Tree 实际操作中的算法

1) 构造一棵 KD-Tree

此处可以联想到平衡树的构造,我们需要从一个起始搜索的点开始递归.

但是 KD-Tree 的难点与与众不同之处也就在于它的 K 维.




我们在构造1维BST树时,一个1维数据根据其与树的根结点和中间结点进行大小比较的结果来决定是划分到左子树还是右子树.

同理,我们也可以按照这样的方式,将一个K维数据与KD-Tree的根结点和中间结点进行比较,只不过不是对K维数据进行整体的比较,而是选择某一个维度Di,然后比较两个K维数在该维度 Di上的大小关系,即每次选择一个维度Di来对K维数据进行划分.




然后就是通过这种比较操作,一直递归到每一个叶子节点,也就是元素集合里面的每一个元素.但是此时,又会有两个问题,一个是怎样选择当前划分的维度,另一个即为怎样使得当前构造中的这棵平衡树两边元素个数尽量均衡 (这样才能优化时间复杂度)?




对于这两个问题,引用另一博客中的原文,我觉得讲的已经很清楚了:

1): 每次对子空间的划分时,怎样确定在哪个维度上进行划分?

最简单的方法就是轮着来,即如果这次选择了在第i维上进行数据划分,那下一次就在第j(j≠i)维上进行划分,例如:j = (i mod k) + 1。想象一下我们切豆腐时,先是竖着切一刀,切成两半后,再横着来一刀,就得到了很小的方块豆腐。

可是“轮着来”的方法是否可以很好地解决问题呢?再次想象一下,我们现在要切的是一根木条,按照“轮着来”的方法先是竖着切一刀,木条一分为二,干净利 落,接下来就是再横着切一刀,这个时候就有点考验刀法了,如果木条的直径(横截面)较大,还可以下手,如果直径较小,就没法往下切了。因此,如果K维数据 的分布像上面的豆腐一样,“轮着来”的切分方法是可以奏效,但是如果K维度上数据的分布像木条一样,“轮着来”就不好用了。因此,还需要想想其他的切法。

如果一个K维数据集合的分布像木条一样,那就是说明这K维数据在木条较长方向代表的维度上,这些数据的分布散得比较开,数学上来说,就是这些数据在该维度 上的方差(invariance)比较大,换句话说,正因为这些数据在该维度上分散的比较开,我们就更容易在这个维度上将它们划分开,因此,这就引出了我 们选择维度的另一种方法:最大方差法(max invarince),即每次我们选择维度进行划分时,都选择具有最大方差维度。




2):在某个维度上进行划分时,怎样确保在这一维度上的划分得到的两个子集合的数量尽量相等,即左子树和右子树中的结点个数尽量相等?

假设当前我们按照最大方差法选择了在维度i上进行K维数据集S的划分,此时我们需要在维度i上将K维数据集合S划分为两个子集合A和B,子集合A中的数据 在维度i上的值都小于子集合B中。首先考虑最简单的划分法,即选择第一个数作为比较对象(即划分轴,pivot),S中剩余的其他所有K维数据都跟该 pivot在维度i上进行比较,如果小于pivot则划A集合,大于则划入B集合。把A集合和B集合分别看做是左子树和右子树,那么我们在构造一个二叉树 的时候,当然是希望它是一棵尽量平衡的树,即左右子树中的结点个数相差不大。而A集合和B集合中数据的个数显然跟pivot值有关,因为它们是跟pivot比较后才被划分到相应的集合中去的。好了,现在的问题就是确定pivot了。给定一个数组,怎样才能得到两个子数组,这两个数组包含的元素 个数差不多且其中一个子数组中的元素值都小于另一个子数组呢?方法很简单,找到数组中的中值(即中位数,median),然后将数组中所有元素与中值进行 比较,就可以得到上述两个子数组。同样,在维度i上进行划分时,pivot就选择该维度i上所有数据的中值,这样得到的两个子集合数据个数就基本相同了。



然后解决完以上问题后,我们就可以开始构造了:

1) 在K维数据集合中选择具有最大方差的维度k,然后我们选择左右元素中对于这一位的中位数,然后按与这个中位数的大小比较,得到两个子集合;同时新建一个节点,用于存储;

2) 对两个子集合重复 1 步骤的过程,直至所有子集合都不能再划分为止;如果某个子集合不能再划分时,则将该子集合中的数据保存到叶子结点。




然后这个时候我们应该可以理解KD-Tree 的几何意义了:

在一个 k 维的面里面,有很多点,然后我们通过画出若干条直线 ( 亦或可以理解为 k 维向量) ,同时每一次每一条直线都会整个将该图分为两大块,这也就是为什么 KD-Tree 为一棵二叉树.




那么,为了更好地理解此时的分割直线,下面引用一个简单例子:

给出多个二维的点,(2,3), (5,4), (9,6), (4,7), (8,1), (7,2);利用上述算法构建一棵KD-Tree.左图是KD-Tree对应二维数据集合的一个空间划分,下图是构建的一棵KD-Tree.

KD-Tree 的笔记-LMLPHP

图2 构建的kd-tree

其中圆圈代表了中间结点(k, m),而红色矩形代表了叶子结点。叶子节点即为我们给出的数据里面的元素.

同时,在这里给出 build 部分的代码:

void build(int L,int R)
//L 和 R 为当前点的编号,在主函数里面从 build(1,n) 开始递归.
{
if(L>R) return;
int mid=(L+R)>>1;
//求出 每一维 上面的方差 储存在 var 数组中.
for(int pos=0;pos<demension;pos++) //demension 为维数.
{
double 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[mid]中.
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);
//通过C++ 自带的 nth_element 函数可以重新编排整个数组.
//保证大于当前值的的在右边,小于当前的在左边.但不会保证有序.
build(L,mid-1);
build(mid+1,R);
}





## 2) 利用 KD-Tree 进行查询
KD-Tree 的查询有以下几点:

  1. 像一棵普通的 BST 一样,从根找起.
  2. 不同之处在于,我们直到找到叶子节点才能停止.
  3. 然后我们在搜索的同时还需要进行 回溯.

    关于 回溯 :

    1) 该操作是为了找到离Q更近的“最近邻点”,即判断未被访问过的分支里是否还有离Q更近的点,它们之间的距离小于Dcur(当前已经找到的最近的点和需要查询的值的 K维最小距离).

    2) 实际操作过程:

    如果Q与其父结点下的未被访问过的分支之间的距离小于Dcur,则认为该分支中存在离P更近的数据,进入该结点,进行(1)步骤一样的查找过程,如果找到更近的数据点,则更新为当前的“最近邻点”Pcur,并更新Dcur.




    如何判断

    这个时候,我们又可以重新来到几何意义上的 KD-Tree 了.




    对于当前这个需要被查询的值如若在已经被遍历过的点中存在更优解,那么就是判断以Q为中心center和以Dcur为半径Radius的超球面(多维圆)与树分支Branch代表的超矩形之间是否相交.

    这个可以先通过二维理解,然后再扩展维数即可.

此处再引用其他博客里的一个例子:

数据点集合:(2,3), (4,7), (5,4), (9,6), (8,1), (7,2) 。

已建好的Kd-Tree:

KD-Tree 的笔记-LMLPHP

图3 构建的kd-tree

其中,左图中红色点表示数据集合中的所有点。

查询点: (8, 3) (在左图中用茶色菱形点表示)

第一次查询:

KD-Tree 的笔记-LMLPHP

图4 第一次查询的kd-tree

当前最近邻点: (9, 6) , 最近邻距离: sqrt(10),

且在未被选择的树分支中存在于Q更近的点(如茶色圈圈内的两个红色点)

回溯:

KD-Tree 的笔记-LMLPHP

图5 回溯kd-tree

当前最近邻点: (8, 1)和(7, 2) , 最近邻距离: sqrt(2)

最后,查询点(8, 3)的近似最近邻点为(8, 1)和(7, 2) .







然后此处再给出查询部分的代码:

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);
}
}

然后的话,以上就是 KD-Tree 的朴素算法了.对于单纯的利用KD-Tree 对题目算法进行优化,此算法已经足矣.

但是KD-Tree在处理实际问题的时候,有时候可能要自己根据题目意思写出不同的剪枝函数,才能够跑的块.

之后还会继续讲一种可以优化查询操作的 BBF 算法.

注:

此博客内有部分借鉴甚至直接引用此博客,在此表示感谢.

05-26 23:11