算法概述
k-d树(k-dimensional tree)是由Jon Bentley于1975年提出的空间划分数据结构,通过递归地沿某一维度的中位数值将k维空间二分,构建一棵平衡二叉搜索树。与四叉树的固定四等分不同,k-d树按维度循环选择划分轴,能够优雅地扩展到任意维度空间。
k-d树在高维最近邻搜索、碰撞检测、机器学习中的KNN算法、图像特征匹配等领域有着不可替代的作用。本文用Java实现一棵完整的k-d树,支持动态插入、最近邻搜索、K近邻搜索和正交范围查询。
核心原理
k-d树的构建遵循以下规则:
1. 选择划分维度:在深度 (d) 处,选择维度 (d \mod k) 作为划分轴
2. 选择划分点:取该维度上的中位数作为节点值,将剩余点分为左右两子树
3. 递归构建:对左右子集分别递归执行上述过程
划分超平面垂直于当前维度,将空间一分为二。搜索时利用超平面的几何性质进行剪枝,避免遍历整棵树。
Java实现
项目结构
kdtree/
├── KDPoint.java // k维点封装
├── KDNode.java // k-d树节点
├── KDTree.java // k-d树核心实现
├── KNNClassifier.java // KNN分类器应用
└── Main.java // 测试入口
基础数据结构
import java.util.Arrays;
/**
* k维空间点
*/
public class KDPoint {
public final double[] coords; // 坐标数组
public final Object data; // 附加数据
public final int dim; // 维度
public KDPoint(double[] coords) {
this(coords, null);
}
public KDPoint(double[] coords, Object data) {
this.coords = Arrays.copyOf(coords, coords.length);
this.data = data;
this.dim = coords.length;
}
/**
* 获取指定维度的坐标值
*/
public double get(int axis) {
return coords[axis];
}
/**
* 欧几里得距离平方
*/
public double distSq(KDPoint other) {
double sum = 0;
for (int i = 0; i < dim; i++) {
double diff = coords[i] - other.coords[i];
sum += diff * diff;
}
return sum;
}
public double dist(KDPoint other) {
return Math.sqrt(distSq(other));
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder("(");
for (int i = 0; i < dim; i++) {
sb.append(String.format("%.2f", coords[i]));
if (i < dim - 1) sb.append(", ");
}
sb.append(")");
return sb.toString();
}
}
k-d树核心实现:KDTree.java
import java.util.*;
/**
* k-d树实现
* 支持构建、插入、最近邻搜索、K近邻搜索、范围查询
*/
public class KDTree {
private KDNode root;
private final int k; // 维度数
private int size;
/**
* k-d树节点
*/
private static class KDNode {
KDPoint point;
int axis; // 划分维度
KDNode left, right;
KDNode(KDPoint point, int axis) {
this.point = point;
this.axis = axis;
}
}
/**
* 按指定维度比较点的比较器
*/
private static class AxisComparator implements Comparator<KDPoint> {
private final int axis;
AxisComparator(int axis) {
this.axis = axis;
}
@Override
public int compare(KDPoint a, KDPoint b) {
return Double.compare(a.get(axis), b.get(axis));
}
}
public KDTree(int k) {
if (k <= 0) throw new IllegalArgumentException("维度必须为正");
this.k = k;
this.size = 0;
}
/**
* 从点集构建平衡的k-d树
* 时间复杂度: O(n log n)
*/
public void build(List<KDPoint> points) {
if (points == null || points.isEmpty()) return;
root = buildRecursive(new ArrayList<>(points), 0);
size = points.size();
}
/**
* 递归构建
*/
private KDNode buildRecursive(List<KDPoint> points, int depth) {
if (points.isEmpty()) return null;
int axis = depth % k;
// 按当前维度排序并取中位数
points.sort(new AxisComparator(axis));
int medianIdx = points.size() / 2;
KDNode node = new KDNode(points.get(medianIdx), axis);
// 递归构建左右子树
node.left = buildRecursive(points.subList(0, medianIdx), depth + 1);
node.right = buildRecursive(points.subList(medianIdx + 1, points.size()), depth + 1);
return node;
}
/**
* 插入单个点
*/
public void insert(KDPoint point) {
if (point == null || point.dim != k) {
throw new IllegalArgumentException("点维度不匹配");
}
root = insertRecursive(root, point, 0);
size++;
}
private KDNode insertRecursive(KDNode node, KDPoint point, int depth) {
if (node == null) {
return new KDNode(point, depth % k);
}
int axis = node.axis;
if (point.get(axis) < node.point.get(axis)) {
node.left = insertRecursive(node.left, point, depth + 1);
} else {
node.right = insertRecursive(node.right, point, depth + 1);
}
return node;
}
/**
* 最近邻搜索
* @param target 目标点
* @return 最近的点
*/
public KDPoint nearest(KDPoint target) {
if (root == null) return null;
BestResult best = new BestResult();
nearestRecursive(root, target, best);
return best.point;
}
private void nearestRecursive(KDNode node, KDPoint target, BestResult best) {
if (node == null) return;
// 计算当前节点与目标的距离
double distSq = node.point.distSq(target);
if (distSq < best.distSq) {
best.distSq = distSq;
best.point = node.point;
}
int axis = node.axis;
double diff = target.get(axis) - node.point.get(axis);
// 决定优先搜索的子树
KDNode near = diff < 0 ? node.left : node.right;
KDNode far = diff < 0 ? node.right : node.left;
// 先搜索近侧子树
nearestRecursive(near, target, best);
// 剪枝:如果目标点到划分超平面的距离已大于当前最优,无需搜索远侧
if (diff * diff < best.distSq) {
nearestRecursive(far, target, best);
}
}
private static class BestResult {
double distSq = Double.POSITIVE_INFINITY;
KDPoint point = null;
}
/**
* K近邻搜索
* @param target 目标点
* @param k_nn 近邻数量
* @return 按距离排序的K个最近点
*/
public List<KDPoint> kNearest(KDPoint target, int k_nn) {
if (root == null || k_nn <= 0) return new ArrayList<>();
// 最大堆,按距离排序
PriorityQueue<KDPoint> heap = new PriorityQueue<>(
(a, b) -> Double.compare(b.distSq(target), a.distSq(target))
);
kNearestRecursive(root, target, k_nn, heap);
List<KDPoint> result = new ArrayList<>(heap);
result.sort(Comparator.comparingDouble(p -> p.distSq(target)));
return result;
}
private void kNearestRecursive(KDNode node, KDPoint target, int k_nn,
PriorityQueue<KDPoint> heap) {
if (node == null) return;
double distSq = node.point.distSq(target);
// 如果堆未满,或当前点比堆顶更近,则加入
if (heap.size() < k_nn) {
heap.offer(node.point);
} else if (distSq < heap.peek().distSq(target)) {
heap.poll();
heap.offer(node.point);
}
int axis = node.axis;
double diff = target.get(axis) - node.point.get(axis);
KDNode near = diff < 0 ? node.left : node.right;
KDNode far = diff < 0 ? node.right : node.left;
kNearestRecursive(near, target, k_nn, heap);
// 剪枝条件:远侧超平面距离大于堆中最远点的距离
double heapMaxDistSq = heap.size() < k_nn ? Double.POSITIVE_INFINITY
: heap.peek().distSq(target);
if (diff * diff < heapMaxDistSq) {
kNearestRecursive(far, target, k_nn, heap);
}
}
/**
* 正交范围查询:查找轴对齐矩形区域内的所有点
*/
public List<KDPoint> rangeQuery(double[] min, double[] max) {
List<KDPoint> result = new ArrayList<>();
rangeQueryRecursive(root, min, max, result);
return result;
}
private void rangeQueryRecursive(KDNode node, double[] min, double[] max,
List<KDPoint> result) {
if (node == null) return;
// 检查当前点是否在范围内
boolean inRange = true;
for (int i = 0; i < k; i++) {
if (node.point.get(i) < min[i] || node.point.get(i) > max[i]) {
inRange = false;
break;
}
}
if (inRange) {
result.add(node.point);
}
int axis = node.axis;
// 根据划分决定搜索哪些子树
if (min[axis] <= node.point.get(axis)) {
rangeQueryRecursive(node.left, min, max, result);
}
if (max[axis] >= node.point.get(axis)) {
rangeQueryRecursive(node.right, min, max, result);
}
}
public int size() {
return size;
}
}
KNN分类器应用:KNNClassifier.java
/**
* 基于k-d树的K近邻分类器
* 应用于鸢尾花分类等机器学习任务
*/
public class KNNClassifier {
public static class Sample {
KDPoint features;
String label;
public Sample(double[] features, String label) {
this.features = new KDPoint(features, this);
this.label = label;
}
}
private KDTree tree;
private final int k;
public KNNClassifier(int k) {
this.k = k;
}
/**
* 训练:构建k-d树
*/
public void train(List<Sample> samples) {
if (samples == null || samples.isEmpty()) return;
List<KDPoint> points = new ArrayList<>();
for (Sample s : samples) {
points.add(s.features);
}
tree = new KDTree(samples.get(0).features.dim);
tree.build(points);
}
/**
* 预测:查找K近邻并投票
*/
public String predict(double[] features) {
if (tree == null) throw new IllegalStateException("未训练");
KDPoint target = new KDPoint(features);
List<KDPoint> neighbors = tree.kNearest(target, k);
// 投票
Map<String, Integer> votes = new HashMap<>();
for (KDPoint p : neighbors) {
Sample s = (Sample) p.data;
votes.merge(s.label, 1, Integer::sum);
}
return votes.entrySet().stream()
.max(Map.Entry.comparingByValue())
.map(Map.Entry::getKey)
.orElse("unknown");
}
public static void main(String[] args) {
// 模拟鸢尾花数据(简化版,仅2维特征)
List<Sample> trainData = Arrays.asList(
new Sample(new double[]{5.1, 3.5}, "setosa"),
new Sample(new double[]{4.9, 3.0}, "setosa"),
new Sample(new double[]{4.7, 3.2}, "setosa"),
new Sample(new double[]{4.6, 3.1}, "setosa"),
new Sample(new double[]{5.0, 3.6}, "setosa"),
new Sample(new double[]{7.0, 3.2}, "versicolor"),
new Sample(new double[]{6.4, 3.2}, "versicolor"),
new Sample(new double[]{6.9, 3.1}, "versicolor"),
new Sample(new double[]{5.5, 2.3}, "versicolor"),
new Sample(new double[]{6.5, 2.8}, "versicolor"),
new Sample(new double[]{6.3, 3.3}, "virginica"),
new Sample(new double[]{5.8, 2.7}, "virginica"),
new Sample(new double[]{7.1, 3.0}, "virginica"),
new Sample(new double[]{6.3, 2.9}, "virginica"),
new Sample(new double[]{6.5, 3.0}, "virginica")
);
KNNClassifier classifier = new KNNClassifier(3);
classifier.train(trainData);
// 测试样本
double[][] tests = {
{5.0, 3.4}, // 应接近setosa
{6.7, 3.0}, // 应接近versicolor
{6.4, 3.1}, // 应接近virginica
};
System.out.println("=== KNN分类测试 ===");
for (double[] test : tests) {
String pred = classifier.predict(test);
System.out.printf("特征(%.1f, %.1f) -> 预测类别: %s%n",
test[0], test[1], pred);
}
}
}
测试入口:Main.java
public class Main {
public static void main(String[] args) {
System.out.println("=== k-d树多维空间索引测试 ===\n");
// 测试1:2D基础操作
System.out.println("--- 测试1:2D最近邻 ---");
List<KDPoint> points2d = Arrays.asList(
new KDPoint(new double[]{2, 3}),
new KDPoint(new double[]{5, 4}),
new KDPoint(new double[]{9, 6}),
new KDPoint(new double[]{4, 7}),
new KDPoint(new double[]{8, 1}),
new KDPoint(new double[]{7, 2})
);
KDTree tree2d = new KDTree(2);
tree2d.build(points2d);
System.out.println("构建2D树,点数: " + tree2d.size());
KDPoint target = new KDPoint(new double[]{9, 2});
KDPoint nearest = tree2d.nearest(target);
System.out.println("目标点: " + target);
System.out.println("最近点: " + nearest + " (距离: " + target.dist(nearest) + ")");
// 测试2:3D K近邻
System.out.println("\n--- 测试2:3D K近邻 ---");
List<KDPoint> points3d = new ArrayList<>();
java.util.Random rand = new java.util.Random(42);
for (int i = 0; i < 20; i++) {
points3d.add(new KDPoint(new double[]{
rand.nextDouble() * 100,
rand.nextDouble() * 100,
rand.nextDouble() * 100
}));
}
KDTree tree3d = new KDTree(3);
tree3d.build(points3d);
KDPoint target3d = new KDPoint(new double[]{50, 50, 50});
List<KDPoint> kNearest = tree3d.kNearest(target3d, 5);
System.out.println("目标点: " + target3d);
System.out.println("5个最近邻:");
for (int i = 0; i < kNearest.size(); i++) {
KDPoint p = kNearest.get(i);
System.out.printf(" #%d: %s (距离: %.2f)%n",
i + 1, p, target3d.dist(p));
}
// 测试3:范围查询
System.out.println("\n--- 测试3:正交范围查询 ---");
List<KDPoint> inRange = tree3d.rangeQuery(
new double[]{30, 30, 30},
new double[]{70, 70, 70}
);
System.out.println("范围 [30,70]x[30,70]x[30,70] 内点数: " + inRange.size());
inRange.forEach(p -> System.out.println(" " + p));
// 测试4:大规模性能
System.out.println("\n--- 测试4:性能测试 (10万点3D) ---");
List<KDPoint> bigData = new ArrayList<>();
for (int i = 0; i < 100000; i++) {
bigData.add(new KDPoint(new double[]{
rand.nextDouble() * 1000,
rand.nextDouble() * 1000,
rand.nextDouble() * 1000
}));
}
long start = System.currentTimeMillis();
KDTree bigTree = new KDTree(3);
bigTree.build(bigData);
long buildTime = System.currentTimeMillis() - start;
System.out.println("构建10万点3D树耗时: " + buildTime + "ms");
start = System.currentTimeMillis();
for (int i = 0; i < 1000; i++) {
bigTree.nearest(new KDPoint(new double[]{
rand.nextDouble() * 1000,
rand.nextDouble() * 1000,
rand.nextDouble() * 1000
}));
}
long queryTime = System.currentTimeMillis() - start;
System.out.println("1000次最近邻查询耗时: " + queryTime + "ms");
// 测试5:KNN分类器
System.out.println("\n--- 测试5:KNN分类器 ---");
KNNClassifier.main(args);
}
}
复杂度分析
| 操作 | 平均时间复杂度 | 最坏时间复杂度 |
|---|---|---|
| 构建(平衡) | (O(n \log n)) | (O(n \log n)) |
| 插入 | (O(\log n)) | (O(n)) |
| 最近邻搜索 | (O(\log n)) | (O(n)) |
| K近邻搜索 | (O(k \log n)) | (O(n \log k)) |
| 范围查询 | (O(m + \log n)) | (O(n)) |
空间复杂度:(O(n)),每个点存储一次。
搜索剪枝原理
最近邻搜索的高效性来源于几何剪枝。假设当前最优距离为 (d),目标点到当前节点划分超平面的距离为 (\Delta):
– 若 (\Delta^2 \geq d^2),则远侧子树中不可能存在更近的点,直接剪枝
– 否则,必须搜索远侧子树
这一剪枝策略使得平均搜索路径长度仅为 (O(\log n)),而非 (O(n))。
适用场景
- 机器学习:KNN分类/回归、特征向量检索
- 计算机视觉:SIFT/SURF特征点匹配
- 地理信息:多维地理坐标查询
- 推荐系统:用户/物品向量相似度搜索
- 物理模拟:粒子系统的近邻搜索
与四叉树的对比
| 特性 | k-d树 | 四叉树 |
|---|---|---|
| 划分方式 | 按维度循环二分 | 固定四等分 |
| 维度扩展 | 任意k维 | 主要2D(3D为八叉树) |
| 构建策略 | 自顶向下(静态) | 自底向上(动态) |
| 最近邻精度 | 精确 | 精确 |
| 最佳场景 | 高维KNN、静态数据 | 动态插入、范围查询 |
扩展方向
- 平衡k-d树:定期重建以维持平衡
- BBF(Best-Bin-First):近似最近邻,优先队列控制搜索范围
- FLANN/Annoy:大规模高维近似最近邻库
- 球树(Ball Tree):用超球面划分,在高维某些分布下更优
总结
k-d树是多维空间搜索的经典数据结构。通过Java实现,我们理解了按维度循环二分构建二叉树的优雅思想,以及基于超平面距离的几何剪枝策略。在机器学习KNN、特征匹配、空间数据库等需要高效多维查询的场景中,k-d树以其简洁的结构和优秀的平均性能,成为空间索引的核心工具之一。掌握k-d树,是深入理解空间算法和机器学习工程实践的重要一步。