李航统计学习方法(第二版)(六):k近邻算法实现(kd tree方法)中,对kd树进行了介绍,包括,kd树的简介、kd树的建立以及kd树的搜索。在看到李航老师书中对kd树搜索算法的描述后,其中对于递归回退的操作描述不是很理解,觉得太抽象了(很可能是我自己的理解能力的问题)。对比多个源码(各个源码都还有我觉得有问题的地方,有时间我会在各博主博客下面留言,主要有:1.c++实现kdTree创建以及最近邻点查找,2. KDTree的C++实现),最后靠蛮力弄明白kdtree最近邻搜索的算法,特此记录一下。
文章目录
- 一、kd树最近邻搜索算法
- 二、一个二维kdtree的例子
- 三、kdtree最近邻搜索步骤图示
- step 1:李航书中最近邻搜索算法第一步:
- step 2:初始化当前最近距离与最近节点
- step 3:持续地从`待比较节点队列中`压出栈顶节点,执行李航老师书上的第三步中的(a)与(b):
- 结尾
一、kd树最近邻搜索算法
李航老师《统计学习方法(第二版)》中的kdtree最近邻搜索算法引出如下:
其中,算法最核心的部分是(3.a)与(3.b)这两句。我比较难以想象的是(3.b)中:
如果相交,可能在另一个子结点对应的区域内存在距目标点更近的点,移动到另一个子结点。接着,递归地进行最近邻搜索;
如何移动到另一个子结点?然后又如何递归地进行最近邻搜索?
网上找了很多博客,大部分都是学舌似的话,根本没有提供比书中这段话更多的信息。很多博客中给的例子,点到为止,一个简单的例子根本不会触发什么[移动到另一个子结点。接着,递归地进行最近邻搜索]好么!
于是寄希望于看别人实现的源码来弄清楚整个算法,因为源码必定是细节的。CSDN博主nnzzll的实现就很简洁清晰,github|nnzzll/NaiveKDTree。我借着他的代码一步一步在脑中虚拟的运行着一棵kdtree,终于知道[移动到另一个子结点。接着,递归地进行最近邻搜索]的意思了。虽然,nnzzll的代码也存在问题,我在他那篇博客下也留言了,希望之后与之有更多的交流。
不废话,下面开始用图与例子对这个过程进行说明。
二、一个二维kdtree的例子
假定我们有一个二维数据集
[
(
2
,
3
)
,
(
5
,
4
)
,
(
9
,
6
)
,
(
4
,
7
)
,
(
8
,
1
)
,
(
7
,
2
)
]
[(2, 3), (5, 4), (9, 6), (4, 7), (8, 1), (7, 2)]
[(2,3),(5,4),(9,6),(4,7),(8,1),(7,2)], 我们按照如下所示的kdtree构建算法,可以得到一棵kdtree。(注:如何构建kdtree不是本文的重点,所以不多赘述)
由二维数据集
[
(
2
,
3
)
,
(
5
,
4
)
,
(
9
,
6
)
,
(
4
,
7
)
,
(
8
,
1
)
,
(
7
,
2
)
]
[(2, 3), (5, 4), (9, 6), (4, 7), (8, 1), (7, 2)]
[(2,3),(5,4),(9,6),(4,7),(8,1),(7,2)]构建的kdtree如下图:
该kdtree表示成空间形式如下图所示:
三、kdtree最近邻搜索步骤图示
假定给定目标数据点
(
6
,
6
)
(6, 6)
(6,6),我们基于上面构建的kdtree树,来说明如何搜索得到离
(
6
,
6
)
(6, 6)
(6,6)最近的数据点。
step 1:李航书中最近邻搜索算法第一步:
按以上步骤,从kdtree根节点
(
7
,
2
)
(7,2)
(7,2)开始向下搜索,路径为
(
7
,
2
)
,
(
5
,
4
)
,
(
4
,
7
)
(7,2),(5,4),(4,7)
(7,2),(5,4),(4,7),其中,
(
4
,
7
)
(4,7)
(4,7)为叶子节点。我们将该步骤中经过的节点压入待比较节点队列(该队列为先进后出队列)。下图左边显示的是经过step1后的待比较节点队列,根节点
(
7
,
2
)
(7,2)
(7,2)在栈底部,叶子节点
(
4
,
7
)
(4,7)
(4,7)在栈顶部;下图右边显示的经过step1经过的节点(红色显示)。
step 2:初始化当前最近距离与最近节点
李航书中最近邻搜索算法第二步:以此叶结点为“当前最近点”
以step 1中的,也即待比较节点队列
的栈顶节点
(
4
,
7
)
(4,7)
(4,7)来初始化与目标点
(
6
,
6
)
(6,6)
(6,6)当前最近节点
与当前最近距离
。
通过计算
(
6
,
6
)
(6, 6)
(6,6)与
(
4
,
7
)
(4,7)
(4,7)的距离,可得当前最近距离
d
m
i
n
=
2.23
d_{min}=2.23
dmin=2.23,当前最近节点
为
(
4
,
7
)
(4,7)
(4,7)。
step 3:持续地从待比较节点队列中
压出栈顶节点,执行李航老师书上的第三步中的(a)与(b):
3.1: 取出栈顶节点为 ( 4 , 7 ) (4,7) (4,7),并执行下列操作:
--------------------kd树搜索算法(3.a)--------------------------
执行算法(3.a):如果该节点保存的实例点比当前最近点距离目标点更近,则以该实例点为
当前最近节点
。
计算目标点
(
6
,
6
)
(6,6)
(6,6)与
(
4
,
7
)
(4,7)
(4,7)的欧式距离为2.23,不比当前最近距离
2.23要小,所以不会导致当前最近节点
与当前最近距离
的更新。
--------------------kd树搜索算法(3.b)--------------------------
执行算法(3.b):检测该节点的兄弟节点(更一般的说法是
兄弟子kdtree
)(该节点父节点的另一个子树)有没有可能包含最近节点
。具体地,检查兄弟结点对应的区域是否以目标点为球心、以目标点与当前最近节点
间的距离为半径的超球体相交。如果相交,可能在另一个子节点对应的区域内存在距目标点更近的点,移动到另一个子节点。接着,递归地进行最近邻搜索。
我们得出如下图的圆与父节点
(
5
,
4
)
(5,4)
(5,4)用来划分左子树与右子树的维度为
y
y
y,也即
y
=
4
y=4
y=4有相交,说明节点
(
5
,
4
)
(5,4)
(5,4)的另一子树区域(左子树)可能存在离目标点
(
6
,
6
)
(6, 6)
(6,6)更近的节点。当前访问节点为
(
4
,
7
)
(4,7)
(4,7),在其父节点的右边,所以递归地对其父节点
(
5
,
4
)
(5,4)
(5,4)的左子树区域进行最近邻搜索。
移动到另一个子节点。接着,递归地进行最近邻搜索
经过kd树搜索算法(3.b)
,我们知道节点
(
5
,
4
)
(5,4)
(5,4)左子树可能存在最近节点,因此要对该子树进行最近邻搜索。在程序实现上有两种方式:
1)将整个最近邻搜索写成可递归的函数findNearestPoint(kdtree, pt)
;
2) 用堆栈队列实现。
用第一种方式,我们只需要在findNearestPoint(kdtree, pt)
中从叶子节点循着父节点往上,一直到根节点进行访问,中途若出现某个访问节点的兄弟子树可能存在最近节点(根据kd树搜索算法(3.b)
可判断),则用兄弟子kdtree(假定为brother_kdtree)调用findNearestPoint(brother_kdtree, pt)
,构成一个嵌套调用。代码比较美观。
用第二种方式的优点是比较直观。用一个先入后出的队列来存储待比较节点队列
,在原理上是和第一种嵌套调用方式是一样的。本文为了更直观,所以采用第二种方式。
按照step 1中的,最近邻搜索算法中如下所示的第一步,对子树进行遍历,至到叶子节点。
由于,节点
(
4
,
7
)
(4, 7)
(4,7)的兄弟子树只有一个节点
(
2
,
3
)
(2,3)
(2,3),它的根节点也是叶子节点。因此,访问节点路径中只有一个节点
(
2
,
3
)
(2,3)
(2,3),将其加入待比较节点队列
:
3.2: 取出栈顶节点为 ( 2 , 3 ) (2,3) (2,3),并执行下列操作:
--------------------kd树搜索算法(3.a)--------------------------
计算目标点
(
6
,
6
)
(6,6)
(6,6)与
(
2
,
3
)
(2,3)
(2,3)的欧式距离为5.0,不比当前最近距离
2.23要小,所以不会导致当前最近节点
与当前最近距离
的更新。
--------------------kd树搜索算法(3.b)--------------------------
由于节点 ( 2 , 3 ) (2,3) (2,3)的兄弟节点 ( 4 , 7 ) (4,7) (4,7)(或者说是以 ( 4 , 7 ) (4,7) (4,7)为根结点的子kd树)已经被访问,所以跳过该步骤。
3.3: 取出栈顶节点为 ( 5 , 4 ) (5,4) (5,4),并执行下列操作:
--------------------kd树搜索算法(3.a)--------------------------
计算目标点
(
6
,
6
)
(6,6)
(6,6)与
(
5
,
4
)
(5,4)
(5,4)的欧式距离为2.23,不比当前最近距离
2.23要小,所以不会导致当前最近节点
与当前最近距离
的更新。
--------------------kd树搜索算法(3.b)--------------------------
节点 ( 5 , 4 ) (5,4) (5,4)的父节点 ( 7 , 2 ) (7,2) (7,2)的右子树(以节点 ( 9 , 6 ) (9,6) (9,6)为根节点)是其兄弟子树,如下图可知,以 ( 6 , 6 ) (6,6) (6,6)为圆心,以2.23为半径的圆与 x = 7 x=7 x=7( ( 7 , 2 ) (7,2) (7,2)的划分维度为 x x x)相交,可知节点 ( 5 , 4 ) (5,4) (5,4)的兄弟子树(以节点 ( 9 , 6 ) (9,6) (9,6)为根节点)可能存在最近节点。
移动到另一个子节点。接着,递归地进行最近邻搜索
节点 ( 5 , 4 ) (5, 4) (5,4)的兄弟子树是以节点 ( 9 , 6 ) (9, 6) (9,6)为根节点,并且根节点只有一个左叶子节点 ( 8 , 1 ) (8,1) (8,1)。
以目标点
(
6
,
6
)
(6,6)
(6,6)为输入,对该子树进行正向搜索,找到叶子节点。由于目标点的
y
=
6
y=6
y=6不小于用于划分的分界线
y
=
6
y=6
y=6,按理应该归到节点
(
9
,
6
)
(9,6)
(9,6)的右叶子节点,可是
(
9
,
6
)
(9, 6)
(9,6)不存在右叶子节点。为了在代码上实现统一,遇到这种只有一个子节点的情况,不用按分界线来划分左和右,直接往下走到它的这个唯一子节点即可。
3.4: 取出栈顶节点为 ( 8 , 1 ) (8,1) (8,1),并执行下列操作:
--------------------kd树搜索算法(3.a)--------------------------
计算目标点
(
6
,
6
)
(6,6)
(6,6)与
(
8
,
1
)
(8,1)
(8,1)的欧式距离为5.3,不比当前最近距离
2.23要小,所以不会导致当前最近节点
与当前最近距离
的更新。
--------------------kd树搜索算法(3.b)--------------------------
节点 ( 8 , 1 ) (8,1) (8,1)无兄弟子树,故此步骤跳过。
3.5: 取出栈顶节点为 ( 9 , 6 ) (9,6) (9,6),并执行下列操作:
--------------------kd树搜索算法(3.a)--------------------------
计算目标点
(
6
,
6
)
(6,6)
(6,6)与
(
9
,
6
)
(9,6)
(9,6)的欧式距离为3.0,不比当前最近距离
2.23要小,所以不会导致当前最近节点
与当前最近距离
的更新。
--------------------kd树搜索算法(3.b)--------------------------
节点 ( 9 , 6 ) (9,6) (9,6)的兄弟子树已被访问,跳过此步骤。
3.5: 取出栈顶节点为 ( 7 , 2 ) (7,2) (7,2),并执行下列操作:
--------------------kd树搜索算法(3.a)--------------------------
计算目标点
(
6
,
6
)
(6,6)
(6,6)与
(
7
,
2
)
(7,2)
(7,2)的欧式距离为4.12,不比当前最近距离
2.23要小,所以不会导致当前最近节点
与当前最近距离
的更新。
--------------------kd树搜索算法(3.b)--------------------------
节点 ( 7 , 2 ) (7,2) (7,2)无兄弟子树,跳过此步骤。
3.6: 取出栈顶节点为NULL,也即待比较节点队列为空,结束搜索。
备注:结束搜索条件有两种:1)回退到根结点;2)栈顶节点为空。
第一种结束搜索条件是李航老师《统计学习方法(第二版)》书中的,这个结束条件需要在递归回退之前,也即李书中步骤(2)与步骤(3)之间,加一个判断根节点是否为当前最近节点的步骤,不然会漏掉可能是最近节点的根节点。此方式则将本文中的step 3.5与step 3.6合并为step 3.5:取出栈顶节点为根节点,结束搜索。
第二种则不用,更加直观,这也是本文采用的方式。
结尾
本文以二维数据集[ ( 2 , 3 ) , ( 5 , 4 ) , ( 9 , 6 ) , ( 4 , 7 ) , ( 8 , 1 ) , ( 7 , 2 ) ] 构建的kdtree为例,用(6,6)作为目标点,事实上证明 ( 6 , 6 ) (6,6) (6,6)作为目标点使这个例子非常复杂,最终把所有的节点都访问了一遍。本文也正是想采用这样一个复杂的例子,把这个kdtree最近邻搜索算法说透。
如果你愿意的话,用同样的例子去走一遍c++实现kdTree创建以及最近邻点查找与 KDTree的C++实现中给出的代码。你会发现,博主们手撸的代码能覆盖我们在网上看到的那些简单情况,但是对于本文中给出的情况却cover不了。这也说明,他们代码并没有完全复现算法的思想。
如果这篇博客有幸被两位博主看到,我先借这个机会向你们道声感谢。你们的博客的确给了我很多思考的方式,包括采用堆栈的方式来实现最近邻搜索。如对文中观点持不同意见,请狠狠踩我吧!欢迎一起交流,然后一起成长。