每日算法 — 使用java实现KNN分类器:KD树空间划分与最近邻加速搜索

KNN(K-Nearest Neighbors,K近邻算法)是机器学习中最直观、最经典的分类算法之一。它的核心思想非常朴素:”近朱者赤,近墨者黑”——一个样本的类别由其最邻近的K个样本的类别投票决定。然而,当特征维度升高、样本量增大时,暴力遍历所有样本计算距离的效率会变得极低。本文将用Java实现从基础暴力KNN到KD树加速的完整方案,带你理解空间划分数据结构如何为机器学习算法注入高效搜索能力。

一、KNN算法原理

KNN算法的执行流程可分为三步:

  1. 计算距离:对于待分类样本,计算它与训练集中每个样本的距离(常用欧氏距离)。
  2. 选取近邻:按距离从小到大排序,选取距离最近的K个样本。
  3. 投票决策:统计这K个样本的类别标签,出现次数最多的类别即为预测结果。

K的取值对结果影响显著:K过小容易受噪声干扰导致过拟合;K过大则可能引入无关样本导致欠拟合。通常通过交叉验证选取最优K值。

二、暴力KNN的Java实现

我们先实现一个基础版本的KNN,采用欧氏距离度量,支持多维特征向量。

import java.util.*;

/**
 * 暴力KNN分类器
 * 时间复杂度:预测阶段 O(n*d),n为样本数,d为特征维度
 */
public class BruteForceKNN {

    // 样本点内部类
    static class Sample {
        double[] features;  // 特征向量
        String label;       // 类别标签

        Sample(double[] features, String label) {
            this.features = features;
            this.label = label;
        }
    }

    // 距离与标签的包装类,用于排序
    static class DistancePair implements Comparable<DistancePair> {
        double distance;
        String label;

        DistancePair(double distance, String label) {
            this.distance = distance;
            this.label = label;
        }

        @Override
        public int compareTo(DistancePair other) {
            return Double.compare(this.distance, other.distance);
        }
    }

    private List<Sample> trainingSet = new ArrayList<>();
    private int k;  // 近邻数量

    public BruteForceKNN(int k) {
        this.k = k;
    }

    /**
     * 添加训练样本
     */
    public void addSample(double[] features, String label) {
        trainingSet.add(new Sample(features, label));
    }

    /**
     * 计算欧氏距离
     */
    private double euclideanDistance(double[] a, double[] b) {
        double sum = 0.0;
        for (int i = 0; i < a.length; i++) {
            double diff = a[i] - b[i];
            sum += diff * diff;
        }
        return Math.sqrt(sum);
    }

    /**
     * 预测单个样本的类别
     */
    public String predict(double[] features) {
        // 计算到所有训练样本的距离
        List<DistancePair> distances = new ArrayList<>();
        for (Sample s : trainingSet) {
            double dist = euclideanDistance(features, s.features);
            distances.add(new DistancePair(dist, s.label));
        }

        // 按距离升序排序,取前K个
        Collections.sort(distances);

        // 统计K个近邻中各类别出现次数
        Map<String, Integer> voteCount = new HashMap<>();
        for (int i = 0; i < Math.min(k, distances.size()); i++) {
            String label = distances.get(i).label;
            voteCount.put(label, voteCount.getOrDefault(label, 0) + 1);
        }

        // 返回票数最多的类别
        String bestLabel = null;
        int maxVotes = -1;
        for (Map.Entry<String, Integer> entry : voteCount.entrySet()) {
            if (entry.getValue() > maxVotes) {
                maxVotes = entry.getValue();
                bestLabel = entry.getKey();
            }
        }
        return bestLabel;
    }

    public static void main(String[] args) {
        // 构造简单的二维分类数据集
        BruteForceKNN knn = new BruteForceKNN(3);

        // 类别A:集中在(1,1)附近
        knn.addSample(new double[]{1.0, 1.0}, "A");
        knn.addSample(new double[]{1.2, 0.8}, "A");
        knn.addSample(new double[]{0.9, 1.1}, "A");
        knn.addSample(new double[]{1.1, 1.2}, "A");

        // 类别B:集中在(4,4)附近
        knn.addSample(new double[]{4.0, 4.0}, "B");
        knn.addSample(new double[]{4.2, 3.8}, "B");
        knn.addSample(new double[]{3.9, 4.1}, "B");
        knn.addSample(new double[]{4.1, 4.2}, "B");

        // 测试样本
        double[] test = new double[]{1.5, 1.5};
        String result = knn.predict(test);
        System.out.println("预测结果: " + result);  // 输出A
    }
}

暴力实现虽然简单,但预测阶段需要遍历全部训练样本,时间复杂度为O(n*d)。当n达到百万级别时,每次预测都要执行数百万次距离计算,效率堪忧。

三、KD树原理

KD树(K-Dimensional Tree,K维树)是一种空间划分数据结构,通过递归地将空间沿某一维度切分为两半,将高维数据组织成一棵二叉树,从而将最近邻搜索的时间复杂度从O(n)降至O(log n)量级(平均情况)。

构建过程

  1. 选择切分维度:通常按坐标轴循环选取(第0维→第1维→…→第d-1维→第0维…)。
  2. 选择切分点:取该维度上所有点的中位数作为切分点,保证左右子树平衡。
  3. 递归构建:将小于切分点的点划入左子树,大于的划入右子树,递归执行。

搜索过程

  1. 下行搜索:从根节点出发,按切分维度决定向左或向右子树递归,直到到达叶子节点。
  2. 回溯更新:将访问过的节点与目标点的距离进行比较,维护当前最优近邻。
  3. 剪枝判断:检查目标点到当前切分超平面的距离。若该距离大于当前已知最优距离,则无需搜索另一侧子树(剪枝)。

四、KD树的Java实现

import java.util.*;

/**
 * KD树节点
 */
class KDNode {
    double[] point;     // 空间中的点坐标
    String label;       // 类别标签(用于KNN)
    int splitDim;       // 当前节点按哪个维度切分
    KDNode left, right; // 左右子树

    KDNode(double[] point, String label, int splitDim) {
        this.point = point.clone();
        this.label = label;
        this.splitDim = splitDim;
    }
}

/**
 * KD树实现,支持高效K近邻搜索
 */
public class KDTree {

    private KDNode root;
    private int dimensions;  // 数据维度

    /**
     * 构建KD树
     * @param points 点集,每行是一个样本的特征向量
     * @param labels 对应的类别标签
     */
    public KDTree(double[][] points, String[] labels) {
        if (points.length == 0) return;
        this.dimensions = points[0].length;
        List<int[]> indices = new ArrayList<>();
        for (int i = 0; i < points.length; i++) {
            indices.add(new int[]{i});
        }
        root = buildTree(points, labels, indices, 0);
    }

    /**
     * 递归构建KD树
     * @param indices 当前节点集合的索引列表
     * @param depth 当前深度,用于确定切分维度
     */
    private KDNode buildTree(double[][] points, String[] labels, 
                              List<int[]> indices, int depth) {
        if (indices.isEmpty()) return null;

        int dim = depth % dimensions;

        // 按当前切分维度排序,取中位数
        indices.sort(Comparator.comparingDouble(a -> points[a[0]][dim]));
        int mid = indices.size() / 2;
        int medianIdx = indices.get(mid)[0];

        KDNode node = new KDNode(points[medianIdx], labels[medianIdx], dim);

        // 递归构建左右子树
        List<int[]> leftIndices = indices.subList(0, mid);
        List<int[]> rightIndices = indices.subList(mid + 1, indices.size());

        node.left = buildTree(points, labels, leftIndices, depth + 1);
        node.right = buildTree(points, labels, rightIndices, depth + 1);

        return node;
    }

    /**
     * 查询目标点的K个最近邻
     */
    public List<Neighbor> findKNearest(double[] target, int k) {
        // 使用最大堆维护当前最优的K个近邻(堆顶是距离最远的)
        PriorityQueue<Neighbor> maxHeap = new PriorityQueue<>(
            (a, b) -> Double.compare(b.distance, a.distance)
        );
        searchKNN(root, target, k, maxHeap);
        return new ArrayList<>(maxHeap);
    }

    /**
     * 递归搜索K近邻
     */
    private void searchKNN(KDNode node, double[] target, int k, 
                           PriorityQueue<Neighbor> maxHeap) {
        if (node == null) return;

        // 计算当前节点到目标点的距离
        double dist = euclideanDistance(node.point, target);

        // 更新最大堆
        if (maxHeap.size() < k) {
            maxHeap.offer(new Neighbor(node.point, node.label, dist));
        } else if (dist < maxHeap.peek().distance) {
            maxHeap.poll();
            maxHeap.offer(new Neighbor(node.point, node.label, dist));
        }

        // 确定先搜索哪个子树
        int dim = node.splitDim;
        KDNode first = target[dim] < node.point[dim] ? node.left : node.right;
        KDNode second = target[dim] < node.point[dim] ? node.right : node.left;

        // 先搜索更可能包含近邻的一侧
        searchKNN(first, target, k, maxHeap);

        // 剪枝判断:如果目标点到切分超平面的距离小于当前最差近邻距离,则需要搜索另一侧
        double planeDist = Math.abs(target[dim] - node.point[dim]);
        if (maxHeap.size() < k || planeDist < maxHeap.peek().distance) {
            searchKNN(second, target, k, maxHeap);
        }
    }

    /**
     * 欧氏距离计算
     */
    private double euclideanDistance(double[] a, double[] b) {
        double sum = 0.0;
        for (int i = 0; i < a.length; i++) {
            double diff = a[i] - b[i];
            sum += diff * diff;
        }
        return Math.sqrt(sum);
    }

    /**
     * 近邻结果包装类
     */
    static class Neighbor {
        double[] point;
        String label;
        double distance;

        Neighbor(double[] point, String label, double distance) {
            this.point = point.clone();
            this.label = label;
            this.distance = distance;
        }
    }
}

五、基于KD树的KNN分类器

将KD树与投票机制结合,形成高效的KNN分类器:

/**
 * 基于KD树加速的KNN分类器
 */
public class KDTreeKNN {

    private KDTree kdTree;
    private int k;

    public KDTreeKNN(int k) {
        this.k = k;
    }

    /**
     * 训练:构建KD树
     */
    public void fit(double[][] features, String[] labels) {
        this.kdTree = new KDTree(features, labels);
    }

    /**
     * 预测单个样本
     */
    public String predict(double[] sample) {
        List<KDTree.Neighbor> neighbors = kdTree.findKNearest(sample, k);

        // 投票统计
        Map<String, Integer> votes = new HashMap<>();
        for (KDTree.Neighbor n : neighbors) {
            votes.put(n.label, votes.getOrDefault(n.label, 0) + 1);
        }

        String bestLabel = null;
        int maxCount = -1;
        for (Map.Entry<String, Integer> entry : votes.entrySet()) {
            if (entry.getValue() > maxCount) {
                maxCount = entry.getValue();
                bestLabel = entry.getKey();
            }
        }
        return bestLabel;
    }

    /**
     * 批量预测
     */
    public String[] predictBatch(double[][] samples) {
        String[] results = new String[samples.length];
        for (int i = 0; i < samples.length; i++) {
            results[i] = predict(samples[i]);
        }
        return results;
    }

    /**
     * 评估准确率
     */
    public double evaluate(double[][] testFeatures, String[] testLabels) {
        int correct = 0;
        for (int i = 0; i < testFeatures.length; i++) {
            if (predict(testFeatures[i]).equals(testLabels[i])) {
                correct++;
            }
        }
        return (double) correct / testFeatures.length;
    }
}

六、完整测试:鸢尾花数据集分类

我们用经典的鸢尾花数据集(Iris Dataset)进行测试。该数据集包含150个样本,每个样本有4维特征(花萼长、花萼宽、花瓣长、花瓣宽),分为3个类别(山鸢尾、变色鸢尾、维吉尼亚鸢尾)。

/**
 * KNN分类器测试主类
 */
public class KNNMain {

    public static void main(String[] args) {
        // 模拟鸢尾花数据集(截取部分样本用于演示)
        double[][] features = {
            // 山鸢尾 (Setosa)
            {5.1, 3.5, 1.4, 0.2}, {4.9, 3.0, 1.4, 0.2}, {4.7, 3.2, 1.3, 0.2},
            {4.6, 3.1, 1.5, 0.2}, {5.0, 3.6, 1.4, 0.2}, {5.4, 3.9, 1.7, 0.4},
            {4.6, 3.4, 1.4, 0.3}, {5.0, 3.4, 1.5, 0.2}, {4.4, 2.9, 1.4, 0.2},
            {4.9, 3.1, 1.5, 0.1}, {5.4, 3.7, 1.5, 0.2}, {4.8, 3.4, 1.6, 0.2},
            // 变色鸢尾 (Versicolor)
            {7.0, 3.2, 4.7, 1.4}, {6.4, 3.2, 4.5, 1.5}, {6.9, 3.1, 4.9, 1.5},
            {5.5, 2.3, 4.0, 1.3}, {6.5, 2.8, 4.6, 1.5}, {5.7, 2.8, 4.5, 1.3},
            {6.3, 3.3, 4.7, 1.6}, {4.9, 2.4, 3.3, 1.0}, {6.6, 2.9, 4.6, 1.3},
            {5.2, 2.7, 3.9, 1.4}, {5.0, 2.0, 3.5, 1.0}, {5.9, 3.0, 4.2, 1.5},
            // 维吉尼亚鸢尾 (Virginica)
            {6.3, 3.3, 6.0, 2.5}, {5.8, 2.7, 5.1, 1.9}, {7.1, 3.0, 5.9, 2.1},
            {6.3, 2.9, 5.6, 1.8}, {6.5, 3.0, 5.8, 2.2}, {7.6, 3.0, 6.6, 2.1},
            {4.9, 2.5, 4.5, 1.7}, {7.3, 2.9, 6.3, 1.8}, {6.7, 2.5, 5.8, 1.8},
            {7.2, 3.6, 6.1, 2.5}, {6.5, 3.2, 5.1, 2.0}, {6.4, 2.7, 5.3, 1.9}
        };

        String[] labels = {
            "Setosa", "Setosa", "Setosa", "Setosa", "Setosa", "Setosa",
            "Setosa", "Setosa", "Setosa", "Setosa", "Setosa", "Setosa",
            "Versicolor", "Versicolor", "Versicolor", "Versicolor",
            "Versicolor", "Versicolor", "Versicolor", "Versicolor",
            "Versicolor", "Versicolor", "Versicolor", "Versicolor",
            "Virginica", "Virginica", "Virginica", "Virginica",
            "Virginica", "Virginica", "Virginica", "Virginica",
            "Virginica", "Virginica", "Virginica", "Virginica"
        };

        // 构建KD树加速的KNN分类器
        KDTreeKNN knn = new KDTreeKNN(3);
        knn.fit(features, labels);

        // 测试样本
        double[][] testSamples = {
            {5.0, 3.4, 1.5, 0.2},    // 应为Setosa
            {6.0, 2.9, 4.5, 1.5},    // 应为Versicolor
            {6.8, 3.0, 5.5, 2.1},    // 应为Virginica
            {4.8, 3.1, 1.6, 0.2},    // 应为Setosa
            {5.7, 2.6, 3.5, 1.0},    // 应为Versicolor
        };
        String[] expected = {"Setosa", "Versicolor", "Virginica", "Setosa", "Versicolor"};

        System.out.println("=== KD树加速KNN分类结果 ===");
        for (int i = 0; i < testSamples.length; i++) {
            String pred = knn.predict(testSamples[i]);
            String status = pred.equals(expected[i]) ? "✓" : "✗";
            System.out.printf("测试%d: 预测=%s, 真实=%s %s%n", 
                i + 1, pred, expected[i], status);
        }

        // 准确率评估(用训练集做近似评估)
        double accuracy = knn.evaluate(features, labels);
        System.out.printf("\n训练集回测准确率: %.2f%%%n", accuracy * 100);

        // 性能对比:暴力KNN vs KD树KNN
        System.out.println("\n=== 性能对比 ===");
        performanceCompare(features, labels, testSamples[0]);
    }

    /**
     * 暴力KNN与KD树KNN性能对比
     */
    private static void performanceCompare(double[][] features, String[] labels, 
                                            double[] testSample) {
        int iterations = 10000;

        // 暴力KNN
        BruteForceKNN bruteKnn = new BruteForceKNN(3);
        for (int i = 0; i < features.length; i++) {
            bruteKnn.addSample(features[i], labels[i]);
        }

        long start1 = System.nanoTime();
        for (int i = 0; i < iterations; i++) {
            bruteKnn.predict(testSample);
        }
        long bruteTime = System.nanoTime() - start1;

        // KD树KNN
        KDTreeKNN kdKnn = new KDTreeKNN(3);
        kdKnn.fit(features, labels);

        long start2 = System.nanoTime();
        for (int i = 0; i < iterations; i++) {
            kdKnn.predict(testSample);
        }
        long kdTime = System.nanoTime() - start2;

        System.out.printf("暴力KNN %d次预测耗时: %.2f ms%n", iterations, bruteTime / 1e6);
        System.out.printf("KD树KNN %d次预测耗时: %.2f ms%n", iterations, kdTime / 1e6);
        System.out.printf("加速比: %.2fx%n", (double) bruteTime / kdTime);
    }
}

七、复杂度分析

方案 构建复杂度 平均搜索复杂度 最坏搜索复杂度 空间复杂度
暴力KNN O(1) O(n*d) O(n*d) O(n*d)
KD树KNN O(n log n) O(log n) ~ O(n^(1-1/d)) O(n) O(n*d)

其中n为样本数,d为维度。KD树在低维数据(d < 20)下效果显著;当维度超过20时,由于”维度灾难”,KD树的剪枝效果会大幅下降,此时可考虑局部敏感哈希(LSH)或球树(Ball Tree)等替代方案。

八、总结

本文实现了从暴力遍历到KD树加速的完整KNN分类器,核心要点如下:

  • KNN 是最直观的机器学习分类算法,通过近邻投票实现决策,无需显式训练模型。
  • KD树 通过空间二分切分和剪枝策略,将最近邻搜索的平均复杂度从O(n)降至O(log n)级别。
  • 剪枝条件 是KD树高效搜索的关键:只有当目标点到切分超平面的距离小于当前最优距离时,才需要搜索另一侧子树。
  • 实际应用中,KD树特别适合低维空间的大规模数据集;对于高维稀疏数据,建议探索近似最近邻算法。

通过这个游戏般直观的空间划分思想,我们不仅加速了KNN,也理解了数据结构如何为算法赋能——这是从”暴力美学”走向”优雅高效”的必经之路。