What is KD tree
-
KD树是K-dimension tree的缩写,是对数据点在k维空间(如二维 ( x , y ) (x,y) (x,y),三维 ( x , y , z ) (x,y,z) (x,y,z),k维 ( x , y , z . . ) (x,y,z..) (x,y,z..))中划分的一种数据结构,主要应用于多维空间关键数据的搜索(如:范围搜索和最近邻搜索)。本质上说,KD-树就是一种平衡二叉树。
-
举个例子,大家可以看下面这张图片。
- 对于平面点集的划分分为两种,按照x划分(也就是图中竖线,将点尽量分为左右各一半)和按照y划分(也就是图中横线,将点尽量分为上下各一半)。
- KD树算法实际上就是来确定左图中这些分割线的(多维空间就是分割面了)。
-
再来一个更容易理解的例子,来自OI Wiki
How to build KD tree
KD tree结构体组成
- Splitting axis
- Splitting value
- Data
- Left pointer
- Right pointer
构建策略
- Divide based on order of point insertion
- 事先不知道点的整体分布,而是来一个点插入一个点
- Divide by finding median
- 实现知道点的整体分布情况
- Divide perpendicular to the axis with widest spread
- 分割的轴可能不交替
- ……
基于点插入的顺序进行划分
- 整体的构建思路其实就和在平面划分点集是一样的,先比较 x x x坐标,再比较 y y y坐标。
Node *insertRec(Node *root, int point[], unsigned depth)
{
if (root == NULL)
return newNode(point);
// 确定比较的维度
unsigned cd = depth % k;
// 与root的 cd 维数据进行对比
if (point[cd] < (root->point[cd]))
root->left =insertRec(root->left, point, depth + 1);
else
root->right =insertRec(root->right, point, depth + 1);
return root;
}
Node* insert(Node *root, int point[])
{
return insertRec(root, point, 0);
}
-
例如,加入点的插入顺序为 (3, 6), (17, 15), (13, 15), (6, 12), (9, 1), (2, 7), (10, 19),那么构建的KD树应当展现为
-
像二叉搜索树 (BST) 一样,我们期望插入操作的时间复杂度为 O ( l o g n ) O(log \ n) O(log n),因为操作需要沿从根节点到叶节点的路径进行,而在平衡树中其深度为 l o g n log \ n log n(其中 n 为节点数,等同于点的数量)。
-
然而,存在一个明确的退化情况:如果后续插入的点在每个维度上的坐标都持续增大,那么树结构就会退化成一条链(线形结构),其高度为 O ( n ) O(n) O(n)。考虑到节点深度累加 1 + 2 + ⋅ ⋅ ⋅ + n = O ( n 2 ) 1+2+···+n = O(n²) 1+2+⋅⋅⋅+n=O(n2),在最坏情况下,构建一棵 KD 树可能需要二次方时间。
-
然而很多情况下,我们都是已经知道点集的全体情况的,这时候我们就可以对算法进行优化来降低复杂度。
基于中位数的构建
-
关键在于将点集精确地均分到两个子树中,是理论上最优的分割策略。这意味着分割操作应当基于中位数(值)来进行。
-
下面给出一个构建示例。
-
有人就问了,为啥这么麻烦啊,只通过 x x x坐标排序不好吗?为啥还要弄个交替的?那么假如我们面临的数据是下面这样的,我们该如何建树呢?
-
依照姓名排序,找到中位值。
-
依据年龄排序,找到两棵子树的中位数。
-
最后就是依据绩点排序,我们来看一下整体步骤。
-
KD tree的Insert , delete和search
Insert
- 插入策略其实和构建策略一样,还是每个维度的数据依次比较,决定往左子树走还是往右子树走。
Delete
分割维度 c d = 结点深度 d e p t h % 空间维度数 k 分割维度cd=结点深度depth\ \%\ 空间维度数k 分割维度cd=结点深度depth % 空间维度数k
其实就是在决定到这一层的数据是依据x
的顺序还是y
的顺序还是别的维度的数据顺序。
- 如果当前节点不是要删除的节点
- 那么搜索找到要删除的点。
- 如果是当前结点是待删除结点
root
且有右子树- 在
root
的 右子树 中沿着当前维度cd
查找 最小值 节点min
。注意:这里是找右子树中cd
维度上的最小值,这类似于二叉搜索树(BST)中用右子树的最小值(中序后继)来替换被删除节点。
- 在
- 如果是当前结点是待删除结点
root
,没有右子树但是有左子树- 在
root
的 左子树 中,沿着当前维度cd
查找 最小值 节点min
。 - 注意:这里的逻辑与标准 BST 删除稍有不同,标准 BST 在只有左子树时通常用左子树的最大值(中序前驱)替换。这里选择在左子树中仍然查找最小值。
- 在
- 如果是当前结点是待删除结点
root
而且是叶子结点- 直接释放该叶子节点的内存。
Node *deleteNodeRec(Node *root, int point[], int depth)
{
// Given point is not present
if (root == NULL)
return NULL;
// Find dimension of current node
int cd = depth % k;
// If the point to be deleted is present at root
if (arePointsSame(root->point, point))
{
// 2.b) If right child is not NULL
if (root->right != NULL)
{
// Find minimum of root's dimension in right subtree
Node *min = findMin(root->right, cd);
// Copy the minimum to root
copyPoint(root->point, min->point);
// Recursively delete the minimum
root->left = deleteNodeRec(root->right, min->point, depth+1);
}
else if (root->left != NULL) // same as above
{
Node *min = findMin(root->left, cd);
copyPoint(root->point, min->point);
root->right = deleteNodeRec(root->left, min->point, depth+1);
}
else // If node to be deleted is leaf node
{
delete root;
return NULL;
}
return root;
}
我们来看两个例子。
Search
我们要研究的KD tree search主要分为两类,exact search
和range search
,即查找某个确定的值和查找在确定范围内的所有值。
Exact search
-
KD树的精确搜索其实和我们上面说过的KD树构建策略的第一种很像,大了就往左子树找,小了就往右子树找。
-
伪代码大概如下所示
def exact_search(node, query): while node is not None: axis = node.axis key = node.data[axis] q_val = query[axis] if q_val < key: node = node.left elif q_val > key: node = node.right else: # q_val == key # 检查当前节点是否完全匹配 if node.data == query: return node else: # 继续向右子树搜索 node = node.right return None # 未找到匹配点
Range search
1.核心概念
-
范围查询 (Range Query): 给定一个查询范围(通常是多维空间中的一个矩形区域),找出数据集中所有落入该范围内的点。
-
节点区域 (Region of a Node): 在KD树中,每个节点隐式地定义了一个空间区域。
-
根节点的区域是整个空间。
-
非根节点的区域由其所有祖先节点的分割超平面(splitting hyperplanes)共同限定。具体来说,一个节点的区域是其父节点区域根据父节点的分割维度和分割值切分后,属于该节点(左子树或右子树)的那一部分。
/*KD-Tree Node data structure*/ class Node { public: Node* left; // 左子节点指针 (left child) Node* right; // 右子节点指针 (right child) int begin; // 起始索引 [闭区间 (start index [close]) int end; // 结束索引 开区间) (end index (open)) Dimension dimension; // 切割维度 (cut dimension) double pivot; // 切割值 (cut value) // 构造函数 Node(int b, int e, Dimension dim, double piv); // 判断是否为叶子节点 bool IsLeaf() const; // 计算左子树的边界框 (Region/Envelope) BBox LeftSubTreeEnvelope(const BBox& current_entext) const; // 计算右子树的边界框 (Region/Envelope) BBox RightSubTreeEnvelope(const BBox& current_entext) const; };
-
听上去很难懂对吧?下面我们来看几张图片,看一看KD树每一层节点的划分是什么样的。
-
节点的区域无需显式存储,可以在遍历树时根据父节点的区域和分割信息动态计算出来。实际上就是一个不断逼近的过程。
// BBox 类的构造函数实现 BBox::BBox(double x0, double x1, double y0, double y1) : xmin(x0), xmax(x1), ymin(y0), ymax(y1), {} // 计算某节点的左子树的Region范围 BBox Node::LeftSubTreeEnvelope(const BBox& current_entext) const { BBox leftRegion(current_entext); // 继承父节点的region switch (dimension) { case X: leftRegion.xmax = pivot; // x方向最大值设为此节点分割值 pivot break; case Y: leftRegion.ymax = pivot; // y方向最大值设为此节点分割值 pivot break; } return leftRegion; } // 计算某节点的右子树的Region范围 BBox Node::RightSubTreeEnvelope(const BBox& current_entext) const { BBox rightRegion(current_entext); // 继承父节点的region switch (dimension) { case X: rightRegion.xmin = pivot; // 修改x方向最小值为此节点分割值 pivot break; case Y: rightRegion.ymin = pivot; // 修改y方向最小值为此节点分割值 pivot break; } return rightRegion; }
-
2.范围搜索算法流程
- 范围搜索算法通常采用递归方式遍历KD树,其核心思想是利用节点的区域信息来剪枝,避免搜索不必要的子树。设查询范围为
Q
(Query Range),当前节点为v
,v
对应的区域为R(v)
。
- 起始: 从根节点开始递归搜索。
- 递归步骤 (对于当前节点
v
):- 检查节点数据点: 判断存储在节点
v
上的数据点本身是否在查询范围Q
内。如果在,则报告(记录)该点。 - 检查区域与查询范围的关系:
- 情况一:
R(v)
与Q
完全不相交 (No Intersection):R(v)
中的所有点都不可能在Q
内。- 剪枝: 停止搜索该节点及其整个子树。
- 情况二:
R(v)
完全包含在Q
内 (Fully Contained):R(v)
中的所有点都在Q
内。- 报告子树: 报告(记录)以
v
为根的整个子树中的所有点。停止对该子树的进一步递归细分检查。
- 情况三:
R(v)
与Q
部分相交 (Partial Intersection):R(v)
中可能有一部分点在Q
内。- 递归搜索子节点:
- 左子节点 (
lc
): 如果v
有左子节点lc
,计算lc
的区域R(lc)
。如果R(lc)
与Q
相交(即使是部分相交),则递归地对lc
执行范围搜索。 - 右子节点 (
rc
): 如果v
有右子节点rc
,计算rc
的区域R(rc)
。如果R(rc)
与Q
相交(即使是部分相交),则递归地对rc
执行范围搜索。
- 左子节点 (
- 情况一:
- 检查节点数据点: 判断存储在节点
-
算法关键点总结
-
仅搜索相交区域: 只需搜索其区域 (Region) 与查询范围 (Query Region) 相交的节点。
-
报告完全包含的子树: 如果一个节点的区域完全被查询范围包含,则报告其整个子树中的所有点。
-
递归处理部分包含: 如果一个节点的区域仅部分被查询范围包含(即部分相交),则需要递归地查询其子树(同时检查该节点自身的数据点是否在查询范围内)。
-
-
我们通过图片来展示一次搜索的全过程。我们的目标就是搜索二维平面上在红框中的所有点。
// --- 数据结构定义 --- 结构体 Point: 属性: x, y (坐标) 结构体 BBox: // 边界框 (Bounding Box) 属性: xmin, xmax, ymin, ymax (边界坐标) 结构体 Node: // KD树节点 属性: left (左子节点指针), right (右子节点指针) 属性: begin, end (覆盖的点在全局点列表中的索引范围 [begin, end)) 属性: dimension (分割维度 X 或 Y) 属性: pivot (分割值) // --- 辅助函数/操作 (假设已存在) --- FUNCTION Intersects(bbox1, bbox2): // 检查两个边界框是否相交 返回布尔值 (True/False) FUNCTION ContainsBox(containerBox, containedBox): // 检查 containerBox 是否完全包含 containedBox 返回布尔值 (True/False) FUNCTION ContainsPoint(box, point): // 检查 box 是否包含 point (含边界) 返回布尔值 (True/False) FUNCTION IsLeaf(node): // 检查节点是否为叶子节点 返回布尔值 (True/False) // (通常检查 node.left 和 node.right 是否都为 NULL) FUNCTION CalculateLeftChildBox(node, parentBox): // 计算左子节点的边界框 返回 BBox FUNCTION CalculateRightChildBox(node, parentBox): // 计算右子节点的边界框 返回 BBox // --- 主要递归搜索函数 --- FUNCTION RangeSearchRecursive(node, queryBox, nodeBox, points, resultList): // 参数: // node: 当前节点 // queryBox: 查询范围 (BBox) // nodeBox: 当前节点对应的区域 (BBox) // points: 全局点列表 // resultList: 存储结果点的列表 (输入/输出参数) // 1. 基本情况:节点为空 IF node IS NULL THEN RETURN // 结束此路径搜索 END IF // 2. 剪枝 1:节点区域与查询范围完全不相交 (情况一) IF NOT Intersects(nodeBox, queryBox) THEN RETURN // 停止搜索此子树 END IF // 3. 优化/剪枝 2:节点区域完全包含在查询范围内 (情况二) IF ContainsBox(queryBox, nodeBox) THEN // 报告此子树下的所有点 CALL ReportSubtree(node, points, resultList) RETURN // 无需再细分此子树 END IF // --- 如果到达此处,说明节点区域与查询范围部分相交 (情况三) --- // [注释: 检查节点 'v' 自身数据点 - 仅当节点结构直接存储点时适用] // [Note: Check point at node 'v' itself - only if node structure directly stores a point] // IF NodeStoresPoint(node) AND ContainsPoint(queryBox, node.pointData) THEN // ADD node.pointData TO resultList // END IF // 4. 处理叶子节点 (如果部分相交且是叶子) IF IsLeaf(node) THEN // 遍历此叶子节点关联的点索引范围 FOR i FROM node.begin TO node.end - 1 DO // 检查点是否在查询范围内 IF ContainsPoint(queryBox, points[i]) THEN ADD points[i] TO resultList // 添加到结果列表 END IF END FOR RETURN // 叶子节点处理完毕 END IF // 5. 处理内部节点 (如果部分相交且是内部节点) // 计算子节点区域 leftChildBox = CalculateLeftChildBox(node, nodeBox) rightChildBox = CalculateRightChildBox(node, nodeBox) // 递归搜索左子节点 (如果其区域与查询范围可能相交 - 相交检查在递归调用开始时进行) CALL RangeSearchRecursive(node.left, queryBox, leftChildBox, points, resultList) // 递归搜索右子节点 (如果其区域与查询范围可能相交 - 相交检查在递归调用开始时进行) CALL RangeSearchRecursive(node.right, queryBox, rightChildBox, points, resultList) END FUNCTION // --- 报告子树所有点的辅助函数 --- FUNCTION ReportSubtree(node, points, resultList): // 参数: // node: 子树的根节点 // points: 全局点列表 // resultList: 存储结果点的列表 IF node IS NULL THEN RETURN END IF // 假设 node.begin 和 node.end 定义了此子树(及其所有后代)覆盖的 points 列表中的索引范围 // (注意:如果索引仅在叶子节点有效,此函数需要改为递归遍历以查找所有叶子) FOR i FROM node.begin TO node.end - 1 DO // [可选] 添加索引有效性检查 // IF i >= 0 AND i < Size(points) THEN ADD points[i] TO resultList // END IF END FOR END FUNCTION // --- 范围搜索入口函数 --- FUNCTION RangeSearch(rootNode, queryBox, points, initialBox): // 参数: // rootNode: KD树的根节点 // queryBox: 查询范围 (BBox) // points: 全局点列表 // initialBox: 覆盖所有点的初始边界框 (BBox) // 返回: 包含在 queryBox 内的点列表 // 创建空的结果列表 resultList = New List() // 处理空树情况 IF rootNode IS NULL THEN RETURN resultList END IF // 调用递归函数开始搜索 CALL RangeSearchRecursive(rootNode, queryBox, initialBox, points, resultList) // 返回结果 RETURN resultList END FUNCTION
3. 示例说明
- 从根节点开始,根据节点区域与查询矩形的相交情况,决定是剪枝、报告整个子树还是继续递归。
- 灰色节点表示其区域与查询矩形部分相交,需要进一步检查子节点。
- 当遇到节点区域完全包含于查询矩形时(虽然示例中没有明确画出这种情况,但理论上应报告整个子树),或当叶子节点的区域与查询矩形相交时,检查叶子节点中的点是否在查询矩形内。
- 最终报告所有落入查询矩形内的点(如示例中的 P6, P11)。即使某些节点的区域与查询矩形相交(如包含 P3, P12, P13 的节点),如果这些点本身不在查询矩形内,也不会被报告。
复杂度
- KD树的范围搜索在平均情况和特定条件下性能较好,但最坏情况下的查询时间复杂度可能退化到
O(n)
(其中n为点数)。