2026/10/17 10:05:55

K-Means聚类算法详解:C++从零实现原理与优化实践

K-Means聚类算法详解:C++从零实现原理与优化实践 聚类是机器学习里最基础也最常用的无监督学习任务之一K-Means又是很多人上手时接触的第一个聚类算法。这次我决定不调库直接用C从零实现一遍K-Means过程比想象中更“涨功力”。数据怎么存、质心怎么初始化、迭代怎么收敛、空簇怎么处理每个环节都会逼着你去面对真实工程问题而不是停留在“背公式”的阶段。这篇文章把《机器学习聚类之K-Means算法C实现》这个项目的思路、代码和调试经过完整写下来适合刚掌握C语法、正准备进入机器学习方向的读者也适合想系统复习一遍聚类实现细节的人。全文按“原理梳理→代码设计→核心实现→优化改造→问题排查”的顺序展开你能直接拿走核心代码也能避开我踩过的几个坑。1. 整体设计与思路拆解1.1 K-Means到底在解决什么问题一句话理解K-Means给定N个样本每个样本有D维特征把它分成K个集合让每个集合内部的样本尽可能靠近自己的质心。数学上的目标函数是所有样本到所属簇质心的平方距离之和也就是SSESum of Squared Errors。写成公式就是argmin ∑_{j1}^{K} ∑_{x∈S_j} ||x - μ_j||²其中μ_j是第j个簇的质心S_j是第j个簇的样本集合。值得注意的是这个优化问题本身是NP难的直接求全局最优很不现实。所以经典K-Means使用的是迭代式启发方法每一步都在降低SSE最后收敛到一个局部最优解。局部最优不一定是全局最优这一点务必记住后面很多坑都从这里来。把算法想象成一次贴纸分组游戏会更好理解先找K个“组长”每个样本按照“离哪个组长最近就跟谁”的原则归队归完队之后重新计算每个组所有成员的重心把重心当作新的组长然后重复这个过程。每次重复都包含两个核心步骤分配assignment和更新update。分配是给每个样本找最近的质心更新是用簇内样本的平均坐标作为新质心。代码里的主循环本质就是这两个动作反复执行。1.2 为什么偏要用C写而不是直接调库现在Python生态里一行KMeans(n_clustersK).fit(data)就出结果用C从零写似乎有点自找麻烦。但我的实际经验是调库会绕过实现细节而C强迫你管理内存、容器和类型把算法的每一个环节都暴露出来。你写完一遍之后再回去看Python库的源码或者参数文档感觉完全不一样很多以前靠猜的概念比如n_init、max_iter、tol一下子都变成了有实感的工程选择。从性能角度说K-Means在很多实时场景和嵌入式设备上仍有需求。C实现可以精确控制数据结构的内存布局、访问顺序和并行粒度这是解释型语言很难做到的。做一个基础骨架之后想改成Mini-Batch K-Means、K-Medoids或者给分配阶段加上多线程并行都只要替换其中几行核心逻辑开发成本非常低。1.3 工程模块怎么划分我没有把全部代码塞进一个文件里而是分了三个文件这个习惯帮我在调试时省了不少时间kmeans_cpp/ ├── main.cpp ├── kmeans.h └── kmeans.cpp头文件kmeans.h放类声明、数据结构和接口定义kmeans.cpp放算法实现main.cpp只负责制造测试数据、调用聚类类、打印结果。这样划分的目的不是凑目录结构而是让KMeans类可以被独立测试。比如我自己写过一个很小的单元测试直接构造一组已知答案的样本调fit之后检查每一个样本的簇标签是否符合预期。如果你把算法代码和测试数据混在一起这类验证做起来会非常难受。2. 核心细节解析与实操要点2.1 样本、质心、簇该怎么表示数据结构的选择直接决定代码的清晰度。很多教材喜欢用二维数组X[N][D]表示样本但在C里我更推荐用struct Point加std::vector的组合struct Point { std::vectordouble dims; int id -1; Point() default; explicit Point(const std::vectordouble data, int pid -1) : dims(data), id(pid) {} };推荐的理由有三点第一后续很可能要带上样本编号、标签或者其他元信息struct天然方便扩展第二std::vectordouble能支持不同维度算法代码不用写死维度数第三底层数组在内存里是连续分布的访问缓存局部性比链表一类结构好得多。实际运行大样本时这个选择带来的性能差异非常明显。质心本身也可以直接复用Point类型只不过它不一定要对应某个真实样本。簇归属信息我建议用一个和样本一一对应的std::vectorint assignments_来存值域是0到K-1。这样更新质心时只要遍历一遍样本累加对应簇的坐标即可不需要额外维护每个簇的成员列表。很多新手会把簇成员集合单独存成vectorvectorint不仅浪费内存更新时还容易踩迭代器失效的坑。我建议尽量用轻量标签数组必要的时候再按需构建临时簇成员表。2.2 欧氏距离与平方距离技巧K-Means最常用的距离度量是欧氏距离。教科书上一般写sqrt(sum((x_i - y_i)²))但在分配那一步我们只需要比较每个样本到不同质心的距离哪个更小并不需要知道绝对距离到底是多少。既然sqrt是单调函数比较平方距离和比较开方后的距离结果完全一致。所以在实现里我直接用平方距离inline double dist2(const Point a, const Point b) { double sum 0.0; for (size_t i 0; i a.dims.size(); i) { double diff a.dims[i] - b.dims[i]; sum diff * diff; } return sum; }一次分配阶段要算N*K次距离省掉开方能省大量浮点运算。这个优化几乎是零成本我建议直接写进代码里。不过要注意数据异常值如果某个特征取值特别大平方之后可能累积到非常大的数虽然double通常扛得住但为了数值稳定性最好先做特征预处理下一节会专门说。2.3 初始质心从随机到K-Means初始质心的选择直接决定K-Means是快速收敛还是陷进局部最优。最简单的方式是从样本里随机挑K个点当质心代码好写但稳定性很差。我做过一个简单实验同一份数据、同一个K值只改变随机种子最终SSE能相差百分之三四十。一个工程上限很高的改进是K-Means初始化。它的思路很直白第一个质心随机选选后面的质心时每个样本被选中的概率与它到已有最近质心的距离平方成正比。这样初始质心会尽量分散开不容易出现几个质心挤在同一片区域的情况。实现起来也不复杂std::vectorPoint kmeansPlusPlusInit(const std::vectorPoint data, int k, unsigned seed) { std::mt19937 gen(seed); std::uniform_int_distributionint pickSample(0, (int)data.size() - 1); std::vectorPoint centers; centers.push_back(data[pickSample(gen)]); std::vectordouble minDist2(data.size(), 1e20); while ((int)centers.size() k) { double sum 0.0; for (size_t i 0; i data.size(); i) { double d2 dist2(data[i], centers.back()); if (d2 minDist2[i]) minDist2[i] d2; sum minDist2[i]; } double r std::uniform_real_distributiondouble(0.0, sum)(gen); double acc 0.0; size_t picked centers.size(); for (size_t i 0; i data.size(); i) { acc minDist2[i]; if (acc r) { picked i; break; } } centers.push_back(data[picked]); } return centers; }我通常建议默认就用K-Means初始化因为它只多花了一次预处理遍历的时间但换来的是明显更稳定的聚类质量。对初学者来说也可以先把随机初始化跑通再用K-Means替换对比效果。下表是我常用初始化策略的直觉对比初始化方法实现复杂度稳定性适用场景完全随机低差教学演示、基线测试K-Means中好通用场景推荐默认最远点采样中中等簇形状比较规则时随机划分后取重心较高中等极少用仅特殊需求2.4 特征预处理不能省K-Means基于距离对特征尺度极其敏感。一个特征取值0到1另一个特征取值0到10000后者在欧氏距离计算里几乎占据绝对权重聚类结果等于只看了一个维度。所以在正式跑算法之前我一般会做z-score标准化每个特征减去均值再除以标准差。离群点严重时还要考虑是否剔除或者改用更稳健的聚类方法。很多人发现SSE偏高、结果不合理最后查了半天源头就是没有做数据预处理。这个问题特别隐蔽因为代码逻辑一点没错错在输入数据的“口径”。3. 实操过程与核心环节实现3.1 基础工具代码先打底动手写KMeans类之前我先写了两个基础工具距离函数和初始化函数。距离函数就是上面的dist2。初始化函数这里我默认使用K-Means同时留一个随机初始化接口方便对比。一个小习惯是凡是涉及随机数的地方都要允许传入种子。这样你复现实验结果时不会被“随机”坑到怀疑人生。下面是我在类里预留的接口结构class KMeans { public: KMeans(int k, int max_iters 100, double eps 1e-6); void fit(const std::vectorPoint data); std::vectorint getAssignments() const { return assignments_; } std::vectorPoint getCentroids() const { return centroids_; } double computeSSE(const std::vectorPoint data) const; private: int k_; int max_iters_; double eps_; std::vectorPoint centroids_; std::vectorint assignments_; };computeSSE看起来不起眼但它是我调试时的主力工具。每次迭代结束打印一次SSE整个算法的行为曲线一目了然正常情况SSE应该不断下降并趋于稳定如果某个版本出现了上升几乎可以断定质心更新逻辑出了问题。3.2 迭代主循环分配与更新核心代码的逻辑其实很短。我给每个样本找最近的质心记录归属然后按簇累加样本坐标除以簇大小得到新质心最后检查标签有没有变化没有变化就认为收敛void KMeans::fit(const std::vectorPoint data) { int n (int)data.size(); if (n 0 || k_ 0 || k_ n) return; assignments_.assign(n, -1); centroids_ kmeansPlusPlusInit(data, k_, 20240517); for (int iter 0; iter max_iters_; iter) { bool changed false; // 分配阶段 for (int i 0; i n; i) { double bestDist2 std::numeric_limitsdouble::max(); int bestIdx 0; for (int j 0; j k_; j) { double d2 dist2(data[i], centroids_[j]); if (d2 bestDist2) { bestDist2 d2; bestIdx j; } } if (assignments_[i] ! bestIdx) { assignments_[i] bestIdx; changed true; } } if (!changed) break; // 更新阶段累加每个簇的坐标和样本数 std::vectorint counts(k_, 0); std::vectorPoint newCentroids(k_); for (int j 0; j k_; j) { newCentroids[j].dims.assign(data[0].dims.size(), 0.0); } for (int i 0; i n; i) { int cid assignments_[i]; counts[cid]; for (size_t d 0; d data[i].dims.size(); d) { newCentroids[cid].dims[d] data[i].dims[d]; } } for (int j 0; j k_; j) { if (counts[j] 0) { for (size_t d 0; d newCentroids[j].dims.size(); d) { newCentroids[j].dims[d] / counts[j]; } centroids_[j] newCentroids[j]; } // 如果counts[j] 0说明出现空簇第4章专门讲处理方式 } } }这段代码里最容易出错的地方有两个。第一初始化newCentroids时必须给每个质心的dims开好长度否则后面累加会越界。第二累加时一定要先确认assignments_[i]是有效的簇编号否则会出现把样本累加进错误簇的问题。我在初版代码里就犯过“先覆盖旧质心再拿新质心去累加其他簇”的错误结果质心全漂到了一个点SSE不降反升。后面我会专门讲这一类调试经验。3.3 拿一个小数据集手把手验算写完代码不能直接上大数据集我先构造了一个能用肉眼确认结果的二维样本集合(1, 1) (1, 2) (2, 2) (5, 5) (6, 5) (6, 6)设K2假设初始质心随机选到了(1, 1)和(6, 6)。第一次分配阶段的结果是前三个点(1,1)、(1,2)、(2,2)离(1,1)更近归簇0后三个点(5,5)、(6,5)、(6,6)离(6,6)更近归簇1。更新质心的时候簇0取横坐标均值(112)/3 4/3 ≈ 1.33纵坐标均值(122)/3 5/3 ≈ 1.67簇1的质心变成(17/3 ≈ 5.67, 16/3 ≈ 5.33)。第二次迭代里每个点到新质心的距离和上一次远近关系完全一致所以分配不变算法终止。从这个小例子能直观看到K-Means迭代本质是在“换质心→重新分组→再换质心”之间循环直到分组不再变化。这个例子值得亲手跑一遍因为如果你实现的代码输出结果和上面不同那基本可以确定某个环节有问题直接复盘就好。3.4 结果输出与简单可视化算法本身不负责画图但输出格式会影响你验证的效率。我习惯把结果打印成样本id 坐标 簇编号的格式同时输出最终SSEid0 (1.00, 1.00) - cluster 0 id1 (1.00, 2.00) - cluster 0 id2 (2.00, 2.00) - cluster 0 id3 (5.00, 5.00) - cluster 1 id4 (6.00, 5.00) - cluster 1 id5 (6.00, 6.00) - cluster 1 SSE 2.66667如果你希望用外部工具画图最简单的做法是让程序额外输出一个CSV文件里面保持同样的字段。二维数据还可以用散点图直接观察簇是否合理。不要小看这一步很多时候你算法“看起来没问题”但画成图之后立刻能发现某个簇被吃掉、两个质心窝在同一个区域里等肉眼可见的问题。4. 优化与稳健性处理4.1 随机初始化导致局部最优多次运行取最优经典K-Means对初始质心敏感单次运行不能代表真实效果。我常用的稳健方案非常简单运行R次每次用不同随机种子保留SSE最小的一次结果。K-Means本身迭代很快R10到20带来的额外开销完全可接受尤其是中小规模样本。实际代码里只需要在外面包一层循环void fitBest(const std::vectorPoint data, int runs, unsigned baseSeed) { double bestSSE std::numeric_limitsdouble::max(); for (int r 0; r runs; r) { KMeans tmp(k_, max_iters_, eps_); tmp.fitWithSeed(data, baseSeed r); double sse tmp.computeSSE(data); if (sse bestSSE) { bestSSE sse; centroids_ tmp.getCentroids(); assignments_ tmp.getAssignments(); } } }这种做法其实和很多成熟机器学习库里n_init参数做的事是一样的。你从零实现的时候可能不需要做得太复杂但至少要意识到“只看一次运行结果”会误导你判断算法效果。把多次运行取最优做成默认行为之后聚类稳定性会提升很多。4.2 空簇怎么处理迭代过程中某簇没有样本这个问题迟早会遇到。常见原因包括K值设得比实际簇数大、初始质心挤在一起、数据分布非常不均衡。处理空簇有几种常见策略直接删除空簇让K减1。代码简单但破坏了“事先指定K”的语义实际使用时要谨慎。把空簇的质心重新随机选一个样本。最通用且实现简单。把空簇质心放到离所有样本最远的位置。目的是继续探索空旷区域但受离群点影响较大。保留旧质心不动并打一条警告日志。最保守适合调试阶段。我自己的默认做法是先保留旧质心不更新同时打印警告。这是因为我更希望算法稳定收敛而不是在迭代中途突然改变质心导致SSE震荡。等排查清楚空簇原因后再决定是否换成随机重选策略。实际调试时空簇大部分情况是K设大了这时候先调小K比改代码更有效。4.3 收敛判断的三种写法收敛判断方式不只一种我见过三种常见实现各有取舍。第一种是判断分配标签是否变化代码里changed标志就是这个作用。它直观且开销小但有个问题质心可能还在极缓慢地移动只是没有样本因此切换簇算法就提前结束了。这样的结果通常足够好因为样本归属已经稳定。第二种是判断质心移动量平方和是否小于阈值eps。这种方式更精细可以保证质心几乎不再变化但阈值需要现场调。eps设太小会增加迭代次数设太大又会让算法过早停止。第三种是监控SSE变化百分比比如当|SSE_new - SSE_old| / SSE_old 0.001时停止。这个指标更贴近“聚类质量”但每次都要算一遍SSE代价稍高。我在生产代码里用的是主判断“分配标签是否变化”同时用max_iters_兜底。如果再严谨一点可以同时检查一下质心位移二者满足其一就退出。对大多数场景来说分配标签不变已经足够稳定不用把收敛条件搞得太复杂。4.4 性能调优的几个方向首先是去掉根号计算这个前面已经做到。其次是提前终止分配阶段一旦发现没有任何样本切换簇就可以直接跳出循环。第三个方向是并行化分配阶段每个样本找最近质心的过程相互独立完全可以用#pragma omp parallel for或者std::thread并行化。我在一个接近百万样本的实验里四线程并行分配阶段能把单轮迭代时间压缩一半以上效果很直接。如果样本维度很高、K也很大可以考虑用空间索引结构比如KD树来加速最近邻查找。但说实话一般中小规模数据完全不需要。更实用的扩展是Mini-Batch K-Means思想每轮随机抽一小批样本只根据这批样本更新质心。它牺牲了一点最终精度换来了更大的数据吞吐量适合处理超大样本场景。只要你基础版本能写明白改成Mini-Batch其实只是把“全量样本遍历”换成“小批次随机采样遍历”逻辑骨架完全一样。4.5 特征标准化实现补充前面提到过预处理这里给一个直接能用的标准化函数void standardize(std::vectorPoint data) { if (data.empty()) return; size_t dim data[0].dims.size(); for (size_t j 0; j dim; j) { double mean 0.0; for (const auto p : data) mean p.dims[j]; mean / (double)data.size(); double var 0.0; for (const auto p : data) { double diff p.dims[j] - mean; var diff * diff; } var / (double)data.size(); double stddev std::sqrt(var); for (auto p : data) { p.dims[j] (p.dims[j] - mean) / (stddev 1e-12); } } }最后除了加1e-12之外我还会检查一下标准差是否真的接近0。如果某个特征是常数那么它无论如何都不会影响距离比较直接让该维变成0也可以。标准化这个环节看似和聚类算法无关但实际项目里没有它K-Means经常会出现一些非常反直觉的结果。5. 常见问题与排查技巧实录5.1 为什么每次运行结果都不一样这是初学者问我最多的问题。原因只有一个K-Means的随机初始化。同一份数据不同初始质心可能收敛到不同局部最优解所以每次结果不同是正常现象不是代码Bug。要处理它有几个办法。如果你需要复现实验固定随机种子是最简单的。但固定种子只是让结果可复现并没有提升结果质量。真正提升稳定性要靠K-Means初始化加上多次运行取最优。我自己的经验是先固定种子做单元测试确认代码正确再用多次运行看真实分布。不要一上来就追求“每次结果完全一致”这在K-Means里本来就不现实。5.2 SSE降不下来怎么办SSE偏高时很多人第一反应是改代码但我的排查顺序完全不同。第一检查K值是否选小了。如果数据真实有三个簇你硬设K2SSE当然降不下来。可以跑一组不同K的对比画出SSE随K变化的曲线找“手肘点”。这个概念说起来玄实际操作其实就是从K1到K8各跑一遍看SSE下降幅度从哪开始变缓。第二检查特征标准化。特征尺度相差悬殊时欧氏距离会被大数值特征主导K-Means等于在错误的度量空间里聚类。这个原因特别容易忽略因为算法输出看起来“有结果”但怎么解释都不对。第三检查离群点。质心是簇内样本的均值一个极端离群点就能把质心拽走让更多正常样本被错误分配。如果你数据里能看到明显的偏远离群点可以先考虑剔除或者换用K-Medoids这类对离群点更稳健的方法。第四也是最容易被忽视的检查数据形状。K-Means假设簇是凸形且大小相近。如果数据是月牙形、环形或者簇密度差异很大K-Means效果天然不好。这种问题不是调参能解决的建议换DBSCAN等密度聚类算法。5.3 卡死与不收敛有一次我遇到算法卡到最大迭代次数都没有满足收敛条件打印中间结果后发现一个很隐蔽的问题数据里有大量重复样本点导致质心在两个位置之间不断来回震荡分配标签也跟着抖动形成了“循环震荡”。这种情况下错误的收敛条件永远不会满足。解决办法是先对重复样本做去重或合并权重然后适当放宽收敛阈值。另外还有一个常见问题是标准化之后某个维度接近0但距离函数里的小数误差积累起来让质心每次只移动1e-12那么一点永远到不了阈值。这种情况我会在收敛判断里加入“质心位移小于eps”的判断或者直接依赖分配标签变化因为标签都不变了继续迭代也没有意义。5.4 调试要点与打印技术我给自己的代码调试定了三条铁律。第一玩具数据集先行。用三个或者六个点结果能手动算出来逻辑是否出错一眼就看出来了。第二每次迭代打印SSE。SSE曲线必须是单调下降的如果某一步上升问题几乎一定出在质心更新阶段。第三单步调试质心更新。我犯错最多的地方就是累加新质心时把旧质心覆盖得太早导致后一个簇的累加结果里混进了前一个簇的新坐标。还有一个小技巧写好分配函数之后单独写个测试函数给定一组已知质心和样本检查每个样本的标签是否符合手工计算结果。这类测试成本不高但能让你在集成进完整算法前就把最底层的逻辑验证掉。遇到奇怪的聚类结果时我通常第一步就是重新跑这个最小测试确认底层没有回归。说到最后我真正想强调的是“分步验证”的价值。K-Means的代码单看很短但每一个小环节都能藏坑。我当初就是在一次重构时为了省一点存储把newCentroids的累加改成了原地更新结果质心更新顺序出现问题导致整个聚类结果全乱。后来我养成了一个习惯任何改动都先用上面那个六样本数据集跑一遍再上真实数据。这个小习惯帮我拦下了至少两位数的低级Bug。如果你也想自己从零写K-Means我的建议是先别急着加K-Means这些“进阶配置”老老实实用随机初始化把最基本版本写对把六样本样例跑通再逐步加上优化模块。每一步出现问题时你都知道该去哪一环排查。这整个项目做完之后我最大的收获不是代码本身而是想明白了一个朴素道理所谓懂一个算法不是能默写公式而是能在代码里准确表达“数据长什么样、簇怎么归、质心怎么移”。能有这样一次完整的动手实践比看十篇教程都管用。