K-维树(K-d Tree)的最近邻搜索算法
问题描述:
给定一个包含n个点的k维空间数据集,以及一个目标查询点q,如何高效地找到数据集中距离q最近的那个点?这个问题被称为最近邻搜索(Nearest Neighbor Search, NNS)。K-d树是一种用于组织k维空间中点数据的数据结构,它能够显著加速这类搜索操作,其平均时间复杂度可以达到O(log n),最坏情况下为O(n)。
解题过程:
第一步:理解K-d树的结构
首先,你需要明白K-d树是一个二叉树。每个节点代表k维空间中的一个点。树的构建过程是递归的,每一层都使用一个不同的维度来划分空间。
- 划分维度选择:通常,我们循环地使用各个维度。例如,在二维空间(k=2)中,根节点使用x轴划分,下一层使用y轴划分,再下一层又使用x轴,如此循环。
- 划分值选择:在选定的划分维度上,我们选择当前点集在该维度上的中位数作为划分值。这样做的目的是尽量保证树的左右子树包含的节点数量平衡,从而让树的高度接近O(log n)。
第二步:构建K-d树(简要回顾)
为了进行搜索,我们通常需要先构建K-d树。假设我们有一组二维点:[(2,3), (5,4), (9,6), (4,7), (8,1), (7,2)]。
- 根节点:第一层按x轴划分。将所有点按x坐标排序:[ (2,3), (4,7), (5,4), (7,2), (8,1), (9,6) ]。中位数是(7,2)。所以根节点是(7,2),其划分超平面是直线x=7。
- 左子树:包含x坐标小于7的点,即[(2,3), (5,4), (4,7)]。在第二层,按y轴划分。排序后得到[ (2,3), (5,4), (4,7) ],中位数是(5,4)。所以根节点的左子节点是(5,4),划分超平面是y=4。
- 右子树:包含x坐标大于7的点,即[(8,1), (9,6)]。在第二层按y轴划分,中位数是(9,6)。所以根节点的右子节点是(9,6),划分超平面是y=6。
- 递归地进行上述过程,直到所有点都被插入。
第三步:最近邻搜索算法
这是最核心的部分。搜索算法是一个从根节点开始的递归过程,它巧妙地利用了树的结构来避免检查所有点。算法步骤如下:
-
初始化:设置一个变量
best,用于记录当前找到的最近邻点,以及best_dist,记录当前最近邻点到查询点q的距离。初始时,best可以为空,best_dist设为无穷大。 -
递归搜索(核心函数):
a. 从根节点开始:首先,像在二叉搜索树中一样,从根节点开始递归地向下搜索。比较q在当前节点划分维度上的值与当前节点在该维度上的值,决定是进入左子树还是右子树。
* 例如,在根节点(7,2),划分维度是x轴。如果q的x坐标小于7,则进入左子树;否则进入右子树。b. 到达叶节点(或空节点):当到达一个叶节点(或空节点)时,回溯(Backtrack)。在回溯过程中,执行以下关键操作:
* 更新最近邻:计算当前节点到q的距离。如果这个距离小于best_dist,则更新best和best_dist为当前节点和当前距离。
* 检查另一侧子树(剪枝的关键):检查另一侧子树(即之前没有进入的那个子树)中是否可能存在更近的点。判断的依据是:以q为圆心,以best_dist为半径画一个超球体。检查这个超球体是否与当前节点的分割超平面相交。
* 如何检查? 计算q到分割超平面的距离。如果这个距离小于当前的best_dist,那么意味着在超球体的另一侧可能存在一个点,它到q的距离比best_dist更小。因此,我们必须递归地搜索另一侧子树。
* 如果距离大于等于best_dist,那么另一侧子树的所有点都不可能比当前best更近,因此可以安全地跳过(剪枝) 对另一侧子树的搜索,这大大提高了效率。 -
算法结束:当整个递归过程完成时,
best中存储的就是找到的最近邻点。
第四步:一个具体的例子
假设我们已经在上述点集上构建好了K-d树,现在要查询点q = (8, 5)的最近邻。
- 初始化:
best = None,best_dist = ∞。 - 递归搜索:
- 访问根节点(7,2):划分维度是x轴。q.x=8 > 7,所以进入右子树。
- 访问节点(9,6):划分维度是y轴。q.y=5 < 6,所以进入左子树(但(9,6)没有左子树,所以到达空节点,开始回溯)。
- 回溯到节点(9,6):
- 计算距离:dist((9,6), (8,5)) = √((9-8)² + (6-5)²) = √(1+1) = √2 ≈ 1.41。
- 更新:
best = (9,6),best_dist = √2。 - 检查另一侧(右)子树:当前分割超平面是y=6。q到该超平面的距离是|5-6|=1。因为1 < √2 (当前
best_dist),所以必须搜索另一侧(右)子树(尽管(9,6)没有右子树,但算法逻辑上会进行检查和搜索)。
- 访问节点(9,6)的右子树:(为空,回溯)。
- 回溯到根节点(7,2):
- 计算距离:dist((7,2), (8,5)) = √((7-8)² + (2-5)²) = √(1+9) = √10 ≈ 3.16。大于当前的
best_dist(√2),所以不更新。 - 检查另一侧(左)子树:当前分割超平面是x=7。q到该超平面的距离是|8-7|=1。因为1 < √2 (当前
best_dist),所以必须搜索另一侧(左)子树。
- 计算距离:dist((7,2), (8,5)) = √((7-8)² + (2-5)²) = √(1+9) = √10 ≈ 3.16。大于当前的
- 进入根节点的左子树,访问节点(5,4):划分维度是y轴。q.y=5 > 4,所以进入右子树。
- 访问节点(4,7):(叶节点,开始回溯)。
- 回溯到节点(4,7):
- 计算距离:dist((4,7), (8,5)) = √((4-8)² + (7-5)²) = √(16+4) = √20 ≈ 4.47。大于
best_dist(√2),不更新。 - 检查另一侧(左)子树:(为空,回溯)。
- 计算距离:dist((4,7), (8,5)) = √((4-8)² + (7-5)²) = √(16+4) = √20 ≈ 4.47。大于
- 回溯到节点(5,4):
- 计算距离:dist((5,4), (8,5)) = √((5-8)² + (4-5)²) = √(9+1) = √10 ≈ 3.16。大于
best_dist(√2),不更新。 - 检查另一侧(左)子树:当前分割超平面是y=4。q到该超平面的距离是|5-4|=1。因为1 < √2 (当前
best_dist),所以必须搜索另一侧(左)子树。
- 计算距离:dist((5,4), (8,5)) = √((5-8)² + (4-5)²) = √(9+1) = √10 ≈ 3.16。大于
- 访问节点(5,4)的左子树,访问节点(2,3):(叶节点,开始回溯)。
- 回溯到节点(2,3):
- 计算距离:dist((2,3), (8,5)) = √((2-8)² + (3-5)²) = √(36+4) = √40 ≈ 6.32。大于
best_dist(√2),不更新。 - 检查另一侧子树:(为空,回溯)。
- 计算距离:dist((2,3), (8,5)) = √((2-8)² + (3-5)²) = √(36+4) = √40 ≈ 6.32。大于
- 算法结束:最终得到最近邻点是(9,6),距离约为1.41。
关键点总结:
- K-d树最近邻搜索的高效性源于其剪枝(Pruning) 能力。通过检查“超球体是否与分割超平面相交”,它能够避免搜索大量不可能包含更近点的分支。
- 算法是深度优先搜索(DFS) 和回溯(Backtracking) 的结合。
- 在高维空间中,K-d树的效率会下降(所谓的“维度灾难”),因为剪枝效果变差,可能退化为接近线性扫描。