每日算法 — 使用java实现层次聚类:AGNES凝聚式聚类与树状图生成

层次聚类(Hierarchical Clustering)无需预先指定簇数量 $K$,通过自底向上(凝聚式 AGNES)或自顶向下(分裂式 DIANA)的策略构建数据的层次化嵌套结构,输出一棵完整的聚类树(Dendrogram)。本文用Java实现AGNES算法,并支持单链接、全链接、平均链接和Ward四种距离度量。

算法原理

AGNES凝聚式聚类

  1. 初始化:每个样本自成一簇,共 $N$ 个簇
  2. 迭代合并:计算所有簇对之间的距离,合并距离最近的两个簇
  3. 终止:当簇数量达到预设值 $K$ 或仅剩一个簇时停止

簇间距离度量

方法 公式 特点
单链接 (Single) $d(C_i, C_j) = \min_{x \in C_i, y \in C_j} d(x, y)$ 易产生链式效应,对噪声敏感
全链接 (Complete) $d(C_i, C_j) = \max_{x \in C_i, y \in C_j} d(x, y)$ 倾向于生成紧凑球形簇
平均链接 (Average) $d(C_i, C_j) = \frac{1}{ C_i
Ward法 合并后簇内方差增量最小 倾向于等大小簇,效果稳健

Java实现

import java.util.*;

/**
 * AGNES凝聚式层次聚类
 * 支持单链接、全链接、平均链接和Ward法
 */
public class HierarchicalClustering {

    public enum Linkage { SINGLE, COMPLETE, AVERAGE, WARD }

    static class Cluster {
        int id;
        List<double[]> points;   // 簇内所有点
        double[] centroid;       // 质心(Ward法需要)
        List<Cluster> children;  // 子簇(用于构建树)
        double mergeDistance;    // 合并时的距离

        Cluster(int id, double[] point) {
            this.id = id;
            this.points = new ArrayList<>();
            this.points.add(point.clone());
            this.centroid = point.clone();
            this.children = new ArrayList<>();
            this.mergeDistance = 0;
        }

        Cluster(int id, Cluster a, Cluster b, double dist) {
            this.id = id;
            this.points = new ArrayList<>(a.points);
            this.points.addAll(b.points);
            this.children = new ArrayList<>();
            this.children.add(a);
            this.children.add(b);
            this.mergeDistance = dist;
            // 重新计算质心
            int dim = a.centroid.length;
            this.centroid = new double[dim];
            for (double[] p : this.points) {
                for (int i = 0; i < dim; i++) centroid[i] += p[i];
            }
            for (int i = 0; i < dim; i++) centroid[i] /= this.points.size();
        }
    }

    private static double euclidean(double[] a, double[] b) {
        double sum = 0;
        for (int i = 0; i < a.length; i++) {
            double d = a[i] - b[i];
            sum += d * d;
        }
        return Math.sqrt(sum);
    }

    /**
     * 计算两个簇之间的距离
     */
    private static double clusterDistance(Cluster a, Cluster b, Linkage linkage) {
        switch (linkage) {
            case SINGLE:
                double min = Double.MAX_VALUE;
                for (double[] pa : a.points)
                    for (double[] pb : b.points)
                        min = Math.min(min, euclidean(pa, pb));
                return min;
            case COMPLETE:
                double max = 0;
                for (double[] pa : a.points)
                    for (double[] pb : b.points)
                        max = Math.max(max, euclidean(pa, pb));
                return max;
            case AVERAGE:
                double sum = 0;
                for (double[] pa : a.points)
                    for (double[] pb : b.points)
                        sum += euclidean(pa, pb);
                return sum / (a.points.size() * b.points.size());
            case WARD:
                // Ward距离 = 合并后簇内平方和增量
                double[] mergedCentroid = new double[a.centroid.length];
                int total = a.points.size() + b.points.size();
                for (int i = 0; i < mergedCentroid.length; i++) {
                    mergedCentroid[i] = (a.centroid[i] * a.points.size() + b.centroid[i] * b.points.size()) / total;
                }
                double wardDist = 0;
                for (double[] p : a.points) {
                    for (int i = 0; i < p.length; i++) {
                        double d = p[i] - mergedCentroid[i];
                        wardDist += d * d;
                    }
                }
                for (double[] p : b.points) {
                    for (int i = 0; i < p.length; i++) {
                        double d = p[i] - mergedCentroid[i];
                        wardDist += d * d;
                    }
                }
                return wardDist;
            default:
                throw new IllegalArgumentException("Unknown linkage");
        }
    }

    /**
     * AGNES聚类主算法
     * @param data    数据集
     * @param k       目标簇数
     * @param linkage 链接方式
     * @return        最终簇列表
     */
    public static List<Cluster> cluster(double[][] data, int k, Linkage linkage) {
        List<Cluster> clusters = new ArrayList<>();
        for (int i = 0; i < data.length; i++) {
            clusters.add(new Cluster(i, data[i]));
        }

        int nextId = data.length;
        while (clusters.size() > k) {
            // 找到距离最近的两个簇
            double minDist = Double.MAX_VALUE;
            int mergeA = -1, mergeB = -1;
            for (int i = 0; i < clusters.size(); i++) {
                for (int j = i + 1; j < clusters.size(); j++) {
                    double dist = clusterDistance(clusters.get(i), clusters.get(j), linkage);
                    if (dist < minDist) {
                        minDist = dist;
                        mergeA = i;
                        mergeB = j;
                    }
                }
            }

            // 合并
            Cluster merged = new Cluster(nextId++,
                    clusters.get(mergeA), clusters.get(mergeB), minDist);
            // 移除被合并的簇(先移除索引较大的)
            clusters.remove(Math.max(mergeA, mergeB));
            clusters.remove(Math.min(mergeA, mergeB));
            clusters.add(merged);
        }
        return clusters;
    }

    /** 打印树状图结构(缩进表示层次) */
    public static void printDendrogram(Cluster node, String indent) {
        if (node.children.isEmpty()) {
            System.out.printf("%sLeaf #%d (%.2f, %.2f)%n",
                    indent, node.id, node.centroid[0], node.centroid[1]);
        } else {
            System.out.printf("%sCluster #%d [mergeDist=%.3f, n=%d]%n",
                    indent, node.id, node.mergeDistance, node.points.size());
            for (Cluster child : node.children) {
                printDendrogram(child, indent + "  ");
            }
        }
    }

    public static void main(String[] args) {
        // 构造两组明显分离的二维数据
        double[][] data = new double[12][];
        // 簇1: 围绕 (2, 2)
        data[0] = new double[]{1.8, 2.1}; data[1] = new double[]{2.2, 1.9};
        data[2] = new double[]{2.0, 2.3}; data[3] = new double[]{1.9, 1.8};
        data[4] = new double[]{2.1, 2.2}; data[5] = new double[]{2.3, 2.0};
        // 簇2: 围绕 (7, 7)
        data[6] = new double[]{6.9, 7.1}; data[7] = new double[]{7.2, 6.8};
        data[8] = new double[]{7.0, 7.3}; data[9] = new double[]{6.8, 7.0};
        data[10] = new double[]{7.1, 7.2}; data[11] = new double[]{7.3, 6.9};

        System.out.println("=== 全链接 (Complete Linkage) ===");
        List<Cluster> result = cluster(data, 2, Linkage.COMPLETE);
        for (Cluster c : result) {
            System.out.printf("簇 #%d: %d 个点,质心 (%.2f, %.2f)%n",
                    c.id, c.points.size(), c.centroid[0], c.centroid[1]);
        }

        System.out.println("\n=== Ward法 ===");
        result = cluster(data, 2, Linkage.WARD);
        for (Cluster c : result) {
            System.out.printf("簇 #%d: %d 个点,质心 (%.2f, %.2f)%n",
                    c.id, c.points.size(), c.centroid[0], c.centroid[1]);
        }
    }
}

复杂度与优化

方面 朴素实现 优化策略
时间复杂度 $O(N^3 \cdot d)$ Lance-Williams递推公式 $O(N^2)$
空间复杂度 $O(N^2)$ 距离矩阵缓存
距离计算 每次重新计算 优先队列维护最近邻

对于 $N > 1000$ 的数据集,建议使用BIRCH或Mini-Batch K-Means等可扩展算法作为预处理,再对代表点执行层次聚类。

树状图(Dendrogram)解读

聚类树的高度表示簇间距离:
– 切割线位置决定簇数量,切割位置越高,簇越少
– 长垂直分支表示两个子簇差异显著
– 通过轮廓系数(Silhouette Score)或间隙统计(Gap Statistic)可自动确定最优切割高度

层次聚类的最大优势在于不预设簇数,让数据自身的结构决定聚类粒度,为探索性数据分析提供了强大的可视化工具。