每日算法 — 使用java实现DBSCAN密度聚类:基于邻域扩展的无监督分组算法

算法简介

DBSCAN(Density-Based Spatial Clustering of Applications with Noise)是一种经典的基于密度的聚类算法。与K-means等基于距离的算法不同,DBSCAN能够发现任意形状的簇,并自动识别噪声点。算法的核心思想是:通过检查每个数据点邻域内的点密度,将高密度区域划分为簇,将低密度区域标记为噪声。

核心思想

DBSCAN算法依赖两个关键参数:

  • ε(eps):邻域半径,定义了一个点的邻域范围
  • MinPts:最小点数,一个核心点邻域内至少包含的点数

根据这两个参数,点被分为三类:

  1. 核心点(Core Point):邻域内包含至少MinPts个点的点
  2. 边界点(Border Point):在核心点邻域内但自身邻域不满足MinPts条件的点
  3. 噪声点(Noise Point):既不是核心点也不是边界点的点

算法从任意一个未访问的核心点开始,通过密度可达性不断扩展簇,直到所有点都被处理完毕。

Java实现

import java.util.*;

/**
 * DBSCAN密度聚类算法实现
 * 
 * @author AlgorithmDaily
 */
public class DBSCAN {

    // 邻域半径
    private double eps;
    // 最小点数阈值
    private int minPts;
    // 数据点列表
    private List<Point> points;
    // 簇ID计数器,0表示噪声
    private int clusterId;

    /**
     * 数据点内部类
     */
    public static class Point {
        double x, y;
        int clusterId;      // 所属簇ID,0为未分配,-1为噪声
        boolean visited;    // 是否已访问

        public Point(double x, double y) {
            this.x = x;
            this.y = y;
            this.clusterId = 0;
            this.visited = false;
        }

        /**
         * 计算与另一个点的欧几里得距离
         */
        public double distanceTo(Point other) {
            double dx = this.x - other.x;
            double dy = this.y - other.y;
            return Math.sqrt(dx * dx + dy * dy);
        }

        @Override
        public String toString() {
            return String.format("(%.2f, %.2f)[cluster=%d]", x, y, clusterId);
        }
    }

    public DBSCAN(double eps, int minPts) {
        this.eps = eps;
        this.minPts = minPts;
        this.clusterId = 0;
    }

    /**
     * 执行DBSCAN聚类
     * @param points 待聚类的数据点列表
     * @return 聚类结果,每个簇包含一组点
     */
    public List<List<Point>> cluster(List<Point> points) {
        this.points = points;
        this.clusterId = 0;

        // 重置所有点状态
        for (Point p : points) {
            p.visited = false;
            p.clusterId = 0;
        }

        List<List<Point>> clusters = new ArrayList<>();

        for (Point point : points) {
            if (point.visited) continue;

            point.visited = true;
            // 获取该点的ε邻域内的所有点
            List<Point> neighbors = getNeighbors(point);

            // 邻域点数不足MinPts,标记为噪声
            if (neighbors.size() < minPts) {
                point.clusterId = -1;
            } else {
                // 发现新的簇
                clusterId++;
                List<Point> cluster = new ArrayList<>();
                expandCluster(point, neighbors, cluster);
                clusters.add(cluster);
            }
        }

        return clusters;
    }

    /**
     * 获取指定点的ε邻域内的所有点
     */
    private List<Point> getNeighbors(Point center) {
        List<Point> neighbors = new ArrayList<>();
        for (Point p : points) {
            if (center.distanceTo(p) <= eps) {
                neighbors.add(p);
            }
        }
        return neighbors;
    }

    /**
     * 扩展簇:从核心点开始,密度可达的所有点加入同一簇
     */
    private void expandCluster(Point corePoint, List<Point> neighbors, List<Point> cluster) {
        // 核心点加入当前簇
        corePoint.clusterId = clusterId;
        cluster.add(corePoint);

        // 使用队列进行广度优先扩展
        Queue<Point> queue = new LinkedList<>(neighbors);

        while (!queue.isEmpty()) {
            Point current = queue.poll();

            if (!current.visited) {
                current.visited = true;
                List<Point> currentNeighbors = getNeighbors(current);

                // 如果当前点也是核心点,将其邻域点加入队列继续扩展
                if (currentNeighbors.size() >= minPts) {
                    queue.addAll(currentNeighbors);
                }
            }

            // 如果点尚未分配簇(包括之前被标记为噪声的点),加入当前簇
            if (current.clusterId <= 0) {
                current.clusterId = clusterId;
                cluster.add(current);
            }
        }
    }

    /**
     * 获取所有噪声点
     */
    public List<Point> getNoisePoints() {
        List<Point> noise = new ArrayList<>();
        for (Point p : points) {
            if (p.clusterId == -1) {
                noise.add(p);
            }
        }
        return noise;
    }

    /**
     * 主程序:演示DBSCAN聚类
     */
    public static void main(String[] args) {
        // 生成测试数据:两个密度不同的簇加一些噪声点
        List<Point> data = new ArrayList<>();
        Random random = new Random(42);

        // 簇1:中心(2, 2)的密集簇
        for (int i = 0; i < 50; i++) {
            data.add(new Point(
                2 + random.nextGaussian() * 0.3,
                2 + random.nextGaussian() * 0.3
            ));
        }

        // 簇2:中心(6, 6)的密集簇
        for (int i = 0; i < 40; i++) {
            data.add(new Point(
                6 + random.nextGaussian() * 0.4,
                6 + random.nextGaussian() * 0.4
            ));
        }

        // 添加一些噪声点
        for (int i = 0; i < 10; i++) {
            data.add(new Point(
                random.nextDouble() * 10,
                random.nextDouble() * 10
            ));
        }

        // 执行DBSCAN聚类
        DBSCAN dbscan = new DBSCAN(0.8, 5);
        List<List<Point>> clusters = dbscan.cluster(data);

        System.out.println("=== DBSCAN聚类结果 ===");
        System.out.println("参数:eps=" + dbscan.eps + ", minPts=" + dbscan.minPts);
        System.out.println("发现 " + clusters.size() + " 个簇");

        for (int i = 0; i < clusters.size(); i++) {
            System.out.println("簇 " + (i + 1) + ": " + clusters.get(i).size() + " 个点");
        }

        List<Point> noise = dbscan.getNoisePoints();
        System.out.println("噪声点: " + noise.size() + " 个");
    }
}

复杂度分析

  • 时间复杂度:最坏情况下为 $O(n^2)$,其中 $n$ 是数据点数量。这是因为每个点都需要计算与其他所有点的距离。如果使用空间索引结构(如R树或k-d树),可以优化到 $O(n \log n)$。
  • 空间复杂度:$O(n)$,需要存储所有点的邻域信息和访问状态。

算法特点与适用场景

优点:
– 无需预先指定簇的数量
– 能够发现任意形状的簇
– 对噪声点具有鲁棒性

缺点:
– 参数 eps 和 minPts 的选择对结果影响很大
– 对高维数据效果不佳(维度灾难)
– 密度不均匀的数据集效果较差

适用场景:图像分割、异常检测、地理空间数据分析、客户分群等。