核心要点

  • 主循环三步:分配(每个样本归到最近质心)→ 更新(取簇内均值作为新质心)→ 重复直到质心稳定或达到最大迭代次数

  • 距离用向量化广播一次算出 N×K 距离矩阵,避免双重 for 循环;比较时用平方欧氏距离即可,无需开方

  • 空簇处理:若某簇无样本,重新挑一个离当前质心最远的点作为新质心,否则均值会得到 NaN

  • 初始化敏感,建议用 k-means++ 按距离平方加权采样,并多次重启取惯性(inertia)最小的结果

标准回答

K-means 通过交替优化「簇分配」与「质心更新」来最小化簇内平方和(inertia)。关键点有三:一是用广播向量化计算距离矩阵把复杂度做实在 O(NKD);二是更新时对每个簇取均值,并显式处理空簇避免 NaN;三是用 k-means++ 让初始质心彼此分散,减少陷入坏局部最优的概率。下面给出可直接运行的实现:

python
import numpy as np

def kmeans_pp_init(X, k, rng):
    """k-means++:按距离平方加权采样初始质心,使其彼此分散。"""
    n = X.shape[0]
    centroids = [X[rng.integers(n)]]              # 随机选第一个质心
    for _ in range(1, k):
        # 每个点到已有质心的最近平方距离
        d2 = np.min(((X[:, None, :] - np.array(centroids)[None, :, :]) ** 2).sum(-1), axis=1)
        probs = d2 / d2.sum()                      # 距离越远被选中概率越大
        centroids.append(X[rng.choice(n, p=probs)])
    return np.array(centroids)

def kmeans(X, k, max_iter=100, tol=1e-4, seed=0):
    rng = np.random.default_rng(seed)
    centroids = kmeans_pp_init(X, k, rng)
    for _ in range(max_iter):
        # 1) 分配:N×K 平方欧氏距离矩阵,取最近质心
        dist2 = ((X[:, None, :] - centroids[None, :, :]) ** 2).sum(-1)
        labels = dist2.argmin(axis=1)
        # 2) 更新:逐簇取均值;空簇用离当前质心最远的点替补
        new_centroids = np.empty_like(centroids)
        for j in range(k):
            members = X[labels == j]
            if len(members) == 0:
                far = ((X - centroids[j]) ** 2).sum(-1).argmax()
                new_centroids[j] = X[far]
            else:
                new_centroids[j] = members.mean(axis=0)
        # 3) 收敛判定:质心移动小于阈值即停止
        if np.linalg.norm(new_centroids - centroids) < tol:
            centroids = new_centroids
            break
        centroids = new_centroids
    inertia = ((X - centroids[labels]) ** 2).sum()  # 簇内平方和
    return labels, centroids, inertia

if __name__ == '__main__':
    rng = np.random.default_rng(42)
    X = np.vstack([rng.normal(m, 0.5, (100, 2)) for m in ([0, 0], [5, 5], [0, 5])])
    labels, centroids, inertia = kmeans(X, k=3)
    print('inertia=', round(float(inertia), 2))
    print('centroids=\n', np.round(centroids, 2))

常见误区

⚠️ 常见踩坑

比较距离时多此一举地开方(np.sqrt)拖慢且无意义;更致命的是忘记处理空簇,导致 members.mean() 返回 NaN 让质心全部污染。此外随机初始化对结果影响大,应多次重启取最小 inertia,而非只跑一次。

追问

追问 1复杂度是多少?如何优化?

单次迭代为 O(N·K·D),距离矩阵显式构造还会占用 O(N·K) 内存。优化:大数据用 Mini-batch K-means 每次只采样一批;用三角不等式(Elkan 算法)跳过大量距离计算;K 很大时用 KD-Tree/球树或近似最近邻加速分配步。

追问 2如何选择 K?

常用肘部法则(inertia 随 K 下降的拐点)、轮廓系数(Silhouette,兼顾簇内紧致与簇间分离)、或 Gap Statistic 与参考分布对比。业务上也常结合可解释性与下游指标确定 K。

延伸学习

与本题相关的知识库文章、术语、工具与行业资讯。