资讯动态

Java实现K近邻算法:机器学习入门实战指南

发布时间:2026/10/20 13:46:27 来源:尧图企业网站定制
“看着满屏的Python机器学习教程如果你是个Java后端程序员是不是隐隐有点焦虑”这个场景我见过太多次了。组里新来的实习生打开各种教程要么是Pandas配Matplotlib要么是PyTorch配CUDA到他真正需要落地一个小功能时却发现自己最顺手的Java里不知道该怎么写。这一篇是我准备长期更新的“机器学习入门”系列的第一篇用Java实现K近邻算法也就是大家常说的KNN。KNN在机器学习里属于“懒学习”算法它不需要像神经网络那样吭哧吭哧训练半天而是把带标签的样本存下来等新样本来了再现场算距离。这套算法原理简单、代码量小、结果肉眼可见特别适合作为你进入机器学习领域的第一个算法。无论你是学生、刚转行的Java工程师还是想在现有业务系统里加一点分类能力的后端开发者看完这篇都能直接跑起来。1. 为什么用Java实现KNN从入门角度聊聊思路1.1 说句公道话Python很香但Java真没必要排斥先回应一个几乎每次讲KNN都会被问到的问题“学机器学习不都用Python吗为什么非要用Java”我的答案很直接因为不是所有人都有条件、有必要把技术栈切到Python。很多企业里现成的服务是Java写的尤其是金融、电商、企业级系统数据库连接池、消息队列、权限管理全是Java生态。你为了一个几万条样本的分类需求单独搭一套Python服务可以但运维成本、部署成本、团队学习成本都摆在那里。反过来Java本身完全能写机器学习算法尤其是KNN这种逻辑清晰的算法用Java实现反而能帮你把底层细节抠得更明白。还有一个很现实的原因Java基础扎实的人学算法用自己最熟悉的语言会少一层“语言陌生感”的干扰。我见过不少同学看公式推导的时候头头是道一到写代码就卡在Python语法上这种挫败感其实是可以避免的——你本来就熟悉Java的集合、循环、类设计剩下要学的就只是算法本身。当然我不是说让你用Java手写所有的机器学习算法。但入门阶段亲手实现一遍KNN你对“距离度量”“K值选择”“样本投票”这些概念的印象绝对比直接调库深得多。库给你的是一次性答案手写给你的是一整套问题拆解的思路。1.2 KNN算法的直觉开卷考试式分类KNN全称K-Nearest Neighbors中文叫K近邻。它的核心思想用大白话说就是“物以类聚人以群分”。你想想生活中怎么判断一个人大概是什么圈子看他和谁走得近。KNN干的事情一模一样判断一个新样本属于哪一类就看它周围最近的K个“邻居”属于哪一类然后少数服从多数。这里面有个非常关键的点KNN的训练阶段其实什么都没干。你没有像线性回归那样去求权重也没有像神经网络那样去反向传播只是把带标签的训练样本原封不动存起来。所以它也叫“基于实例的学习”英文叫Instance-Based Learning又被戏称为“懒学习”Lazy Learning。计算全部发生在预测阶段新样本来了才临时去算它和每个训练样本之间的距离。我用一个生活化的例子辅助理解。假设你刚搬到一个新小区想知道附近哪家餐馆好吃。你站在楼下问了5个人其中3个人都说东边那家火锅店好1个人说西边面馆好1个人说南边烧烤好。于是你得出一个结论大概率大家都觉得东边那家不错。这就是KNN。这里“问几个人”就是K值“怎么判断谁离你近”就是距离度量最后“少数服从多数”就是投票机制。理解了这三个要素你已经理解KNN的全部算法骨架了。接下来的实现本质上就是在Java里把这套生活逻辑翻译成代码。1.3 KNN适合什么场景不适合什么作为入门算法KNN的优点和缺点同等明显先把边界画清楚再用它心里才有底。适合的场景有这么几个特征数据特征是数值型的、样本量在几十万以内、类别边界不算太复杂。实际生产中KNN最常出现在推荐系统里的“找相似用户”、风控里的“黑样本附近风险高”、图像识别入门阶段的“手写数字分类”这些场景。它最大的好处是不用训练上线快加新数据几乎零成本解释起来也简单业务方问你“为什么预测成这个类别”你可以直接把最近的几个邻居拉出来给他看。不适合的场景也很多。最典型的是高维数据——特征一旦上百维距离计算会变得非常不稳定这就是常说的“维度灾难”。样本量特别大时也有问题因为每个预测都要遍历全部训练样本数据越多越慢根本不划算。另外如果特征里有很多类别型数据比如颜色、城市、职业直接算距离会非常别扭需要先编码。但话说回来“第一个练手的算法”这个定位KNN是极佳的。代码简单、问题直观、调参路径清晰你会非常直观地看到数据、参数和结果之间的关系。这种反馈对新手来说太重要了。2. 动手准备环境、数据与归一化2.1 需要哪些环境JDK就够了实现KNN你不需要安装任何机器学习框架也不需要引入任何第三方依赖。只要你的机器上有JDK 8以上版本再加一个文本编辑器或者IDE就够了。我用的是标准的Java工程结构但哪怕你只在记事本里写两个.java文件也能编过跑通。这里还是建议你用IDEA或者Eclipse新建一个空工程包名随意比如用com.demo.knn方便后续扩展。为什么要强调“零依赖”因为入门的核心目的是把算法原理搞懂。你一旦引入某个开源机器学习库KNN就变成一行调用了虽然能出结果但内部发生了什么对你来说依然是个黑盒。自己做一遍你会踩到排序、精度、比较器这些真实的坑这些坑日后搞工程时照样会碰到早点遇到不是坏事。至于构建工具用Maven或者Gradle都可以但本例中并没有需要管理的依赖不用也不是不行。JDK版本建议用较新的因为后面If你想用record简化数据类JDK 16以上才支持。入门阶段我用传统一点的写法兼容性更好。2.2 数据集选择经典花卉三分类数据KNN入门最经典的数据集我建议你用花卉分类数据。这个数据集有150条样本每条样本包含4个数值特征花萼长度、花萼宽度、花瓣长度、花瓣宽度对应的标签是3个花卉品种。这个数据集在教学界的地位相当于编程界的“Hello World”你以后看其他算法入门资料八成也会碰到它。为了让你不用下载文件也能跑通我这里先手工造一个小型迷你版数据放在代码里。真实使用的时候你可以把数据文件放在classpath下用BufferedReader按行读取按逗号分隔解析成特征数组和标签。我建议你从迷你版开始因为数据少你可以把每一条样本的距离、排序结果、投票过程全部打印出来逐行验证比直接拿150条数据一顿跑完要有感觉得多。我简单给一个迷你数据集的造型一共9条样本前3条是品种A中间3条是品种B最后3条是品种C特征就用两条“花部尺寸”和两条“花瓣尺寸”的简化表示。注意这里特征值我是刻意让不同品种有一定间距这样KNN能比较好地分出来让你第一遍跑就有效果。2.3 归一化处理一不做距离计算就要出问题这是很多人第一次写KNN最容易忽略、也最容易翻车的一步。想一个问题欧氏距离计算的是各维度差值的平方和。假如特征1的取值范围是0到10特征2的取值范围是0到1那么即使特征2对分类非常关键它的差异在距离公式里也会被特征1的巨大数值完全淹没。换句话说你没有显式地给特征设置权重但数值范围本身已经潜移默化地给大范围特征加了权重。解决这个问题最常用的方法是min-max归一化把每个特征缩放到0到1之间。公式很简单normalized (value - min) / (max - min)对应Java代码是这样public static double[][] normalize(double[][] data, double[] min, double[] max) { int rows data.length; int cols data[0].length; double[][] result new double[rows][cols]; for (int i 0; i rows; i) { for (int j 0; j cols; j) { result[i][j] (data[i][j] - min[j]) / (max[j] - min[j]); } } return result; }还有一个更隐蔽的问题你必须注意归一化的时候min和max必须只从训练集里计算然后再应用到测试集。如果把训练集和测试集混在一起求min和max测试集的信息就已经“泄漏”到训练环节里了最后评估出来的准确率会虚高。这个错误特别常见我当年也犯过等做到第4章的评估部分你就知道为什么我要反复强调它。3. 核心实现KNN的三个骨架方法3.1 第一步样本封装用DataPoint把特征和标签绑一起要存储训练数据先定义一个简单的样本类。特征用一个double数组标签用一个String。这个类不需要任何额外逻辑就是个纯数据载体public class DataPoint { double[] features; String label; public DataPoint(double[] features, String label) { this.features features; this.label label; } }为什么不用别的数据结构而单独建一个类因为后面你会有训练集、测试集、预测结果好多个列表如果特征和标签分开存对应关系很容易搞乱。封装成一个对象List 传来传去可读性好得多也符合Java的面向对象习惯。如果你用的JDK比较新完全可以把这类简化成recordpublic record DataPoint(double[] features, String label) {}record自动生成equals、hashCode和toString入门代码会更清爽。不过传统写法能让你更清楚地看到数据流转新手我建议先用class等跑通了再简化。3.2 第二步距离度量欧氏距离的Java写法距离度量是KNN的灵魂。最常见的欧氏距离就是空间中两点之间的直线距离公式是distance sqrt((x1-y1)^2 (x2-y2)^2 ... (xn-yn)^2)Java实现如下public static 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); }这里有几个值得说的小细节。一个是计算平方时我用diff * diff而不是Math.pow(diff, 2)前者性能更好代码也不难看懂。另一个是a和b的长度必须一致入门阶段直接假设所有样本特征维度相同但真实项目中你最好加一个长度校验否则数组越界会让人一头雾水。除了欧氏距离KNN还可以用曼哈顿距离、余弦相似度等我在后文会专门讲它们的使用场景。但入门阶段先把欧氏距离吃透就够了因为它几何意义直观连续数值特征下效果也让人满意。你可以把欧氏距离理解为“地图上直线飞行的距离”曼哈顿距离则是“只能横竖走街区的距离”两者没有绝对好坏只在数据分布不同时各有优势。3.3 第三步分类器主干排序加投票有了距离计算KNN分类器的骨架就是三个步骤算距离、取前K个、投票定标签。第一步遍历所有训练样本计算待测样本到它们的距离把“距离标签”作为一个组合存起来。第二步按距离从小到大排序取前K个。第三步统计这K个标签的票数票数最多的就是预测结果。对应Java代码如下public static String knnClassify(double[] sample, ListDataPoint trainData, int k) { ListScoredLabel scored new ArrayList(); for (DataPoint dp : trainData) { double dist euclideanDistance(sample, dp.features); scored.add(new ScoredLabel(dist, dp.label)); } scored.sort(Comparator.comparingDouble(s - s.distance)); MapString, Integer votes new HashMap(); for (int i 0; i k; i) { ScoredLabel sl scored.get(i); votes.put(sl.label, votes.getOrDefault(sl.label, 0) 1); } String bestLabel null; int maxVotes -1; for (Map.EntryString, Integer entry : votes.entrySet()) { if (entry.getValue() maxVotes) { maxVotes entry.getValue(); bestLabel entry.getKey(); } } return bestLabel; } class ScoredLabel { double distance; String label; ScoredLabel(double distance, String label) { this.distance distance; this.label label; } }这里我用了简单的全部排序也就是每次预测都要对所有训练样本排序一次时间复杂度取决于排序的复杂度。入门阶段完全够用数据量上来以后可以换成最小堆或者先过滤部分样本。关于性能优化后文有一节专门讲。投票这一段如果你熟悉Java 8的Stream可以写得更简洁return votes.entrySet().stream() .max(Comparator.comparingInt(Map.Entry::getValue)) .get().getKey();不过Stream写法可读性因人而异长流程调试起来反而不方便。我推荐上面传统的for循环写法逻辑一目了然后期加断点也好排查。3.4 完整代码串起来跑一次写个main方法手工造几条迷你数据把上面三个部分串起来。我这里用一个简单示例特征只有两维三个类别每个类别三个样本再加一个未知样本做验证。public static void main(String[] args) { ListDataPoint trainData new ArrayList(); trainData.add(new DataPoint(new double[]{1.0, 1.1}, A)); trainData.add(new DataPoint(new double[]{1.2, 1.0}, A)); trainData.add(new DataPoint(new double[]{1.0, 1.3}, A)); trainData.add(new DataPoint(new double[]{3.0, 3.1}, B)); trainData.add(new DataPoint(new double[]{3.2, 3.0}, B)); trainData.add(new DataPoint(new double[]{3.1, 3.4}, B)); trainData.add(new DataPoint(new double[]{5.0, 5.1}, C)); trainData.add(new DataPoint(new double[]{5.2, 5.0}, C)); trainData.add(new DataPoint(new double[]{5.1, 5.3}, C)); double[] sample new double[]{3.05, 3.15}; String result knnClassify(sample, trainData, 3); System.out.println(预测结果为 result); }按直觉这个未知样本应该在B附近你跑一遍大概率是B。运行成功的那一刻你的第一个KNN分类器就成了。我的建议是先不要急着换成完整数据集把这九条数据各种组合试一遍你会非常直观地理解“邻居”的概念。4. 跑通测试从训练集划分到调K值4.1 留出法划分训练集和测试集自己写的KNN怎么判断它好不好答案很简单拿一部分数据当“考卷”让模型预测这些看不到答案的样本再比对真实标签。这种把数据拆成训练集和测试集的做法叫“留出法”。通常保留70%到80%做训练剩下20%到30%做测试。划分的时候要注意两点第一要随机打乱再划分否则数据按标签排序前半段全是A类后半段全是C类测试起来一点意义都没有第二最好固定随机种子这样每次跑出来的结果一致方便你对比不同参数的效果。Java里面打乱列表用Collections.shuffle然后指定随机种子ListDataPoint allData new ArrayList(trainData); Collections.shuffle(allData, new Random(42)); int splitIndex (int) (allData.size() * 0.7); ListDataPoint trainSet new ArrayList(allData.subList(0, splitIndex)); ListDataPoint testSet new ArrayList(allData.subList(splitIndex, allData.size()));这里我特别提醒一个Java细节subList返回的是原列表的视图不是独立副本如果你后面要修改列表会直接影响原数据。所以这里最好像上面代码一样再用new ArrayList包装一层稳妥。真实项目里数据划分还要考虑“分层抽样”也就是按类别比例抽取防止某类样本在训练集或测试集中太少。入门阶段先不用管但你要知道有这个坑。等数据量大了类别不平衡的时候随便shuffle一次可能就把某个小类全划进测试集了准确率会剧烈波动。4.2 计算准确率看到数字才算入门有了训练集和测试集就可以计算准确率了。遍历测试集对每个样本调用knnClassify把预测结果和真实标签比对统计正确率int correct 0; for (DataPoint testPoint : testSet) { String predicted knnClassify(testPoint.features, trainSet, 3); if (predicted.equals(testPoint.label)) { correct; } } double accuracy correct / (double) testSet.size(); System.out.printf(准确率: %.2f%%%n, accuracy * 100);第一次看到准确率数字你就正式迈过“知道概念”到“能验证效果”的门槛了。这时你才会真正理解为什么前面强调“测试集不能让模型看到”因为如果拿训练集自己测自己KNN几乎能拿到100%——每个样本离它自己距离为0一定排在第一位这种虚假的好成绩会让你误以为模型很强。如果你是第一次接触机器学习评估建议除了准确率再打印一下测试集中每个类别的正确数、错误类型、被预测错的样本离最近邻居距离是多少。把这些信息打出来你才算真正“看见”KNN在做什么。我当时把这个简单的可视化日志功能加上后对算法理解的深度完全不一样了。一个小提示如果样本太少比如30条测试数据里错2条准确率就变化很大这是正常现象不代表代码有bug。数据量不够时更稳的评估手段是交叉验证后文会提到。4.3 K值动手调一遍过拟合与泛化KNN里K的选择是门学问。K太小模型过于敏感一个噪声样本就可能带偏结果也就是“过拟合”K太大远处的样本也被拉进来投票类别边界变得很模糊容易分类错误也就是“欠拟合”。最好的学习方法不是看公式而是自己动手。写一个循环让K从1变到10每次打印准确率你看一眼趋势就全明白了for (int k 1; k 10; k) { int correct 0; for (DataPoint testPoint : testSet) { String predicted knnClassify(testPoint.features, trainSet, k); if (predicted.equals(testPoint.label)) correct; } System.out.printf(k%d, accuracy%.2f%%%n, k, correct * 100.0 / testSet.size()); }跑完你会发现不是K越大越好也不是K越小越好而是存在一个中间值。经验规则是K大约取样本量的平方根比如150条训练数据K可以试着取12或13同时通常选奇数因为奇数可以避免投票平票的情况。不过入门阶段不建议你在调K上花太多时间。用一句话记住核心思路K太小容易学过头K太大容易学不到位。等到后面学了交叉验证你可以把数据切成多份轮流当验证集用系统化的方法寻找最优K而不是手动瞎试。5. 高频踩坑与排查实录5.1 K值与平票奇数还是偶数前面提到了平票这里展开说一下。假设你的数据有三类K取偶数可能出现两个类各得2票的情况。这时算法不知道选谁好只能随便选一个结果不可控。平票背后有三种常见处理思路。第一种最简单K直接选奇数这在二分类场景下能完全规避平票但多分类时奇数K也可能出现平票比如三类数据K5票型可能是2、2、1。第二种思路是距离加权投票也就是越近的邻居权重越大。投票时不看人数而是把每个邻居的权值相加权值可以取1/距离或1/(距离epsilon)这样即使票数一样更近的那一方也能胜出。第三种思路是样本均衡化在前K个邻居中如果某个类别的总体样本数占比特别高适当惩罚它的票数防止“人多势众”的类别吃掉所有边界样本。对于入门阶段我建议你先把奇数K定下来平票时用一个简单的兜底规则如果票数一样取距离最近的那个邻居的标签。这样就算遇到平票也不会直接抛异常结果还算说得过去。5.2 特征量纲与信息泄漏两个经典翻车点这两个坑我特别想拎出来再说一遍因为它们都不像语法错误那样会立刻暴露而是会让你的模型“悄无声息地变差”或者“虚假地变好”。特征量纲问题是这样的如果你把花瓣长度和宽度直接丢进距离公式长度范围可能是1到6宽度范围可能是0到2那么距离计算时长度维度几乎主导了一切KNN相当于在说“我只关心花瓣长度”这显然不是你想要的。解决办法就是第2章说的归一化。跑之前先打印每个特征的min和max如果发现范围差异超过一个数量级基本就是需要归一化的信号。我的经验是宁可多归一化一次也不要等踩坑后回头补。信息泄漏则是另一个方向。你用全部数据的min和max做归一化之后测试集的数据分布信息已经提前进入了训练环节。这在算法逻辑上似乎没什么但会让最终的准确率报告中“水分很大”。想象一场闭卷考试监考老师提前把答案范围念了一遍考试成绩自然虚高。正确的做法是先划分训练集和测试集然后用训练集单独计算每个特征的min和max再对测试集应用同套参数。这个习惯尽早养成后面学其他特征工程时受益无穷。5.3 数据量大时的性能优化路线我见过很多人拿KNN跑完小型数据集后直接往几十万条的数据上套结果查询响应慢得想砸电脑。这不是KNN不行而是你没有为它的“懒”付出代价。KNN没有训练过程代价全在查询端每条新样本预测的时间复杂度大约是O(n*d)n是训练样本数d是特征维度。数据量和维度一涨时间就灾难性地上升。优化路线大致有这么几个阶段。第一阶段先做数据清洗把明显是噪声的样本删掉或者对样本做聚类用聚类中心代表一个大类第二阶段改进数据结构使用KD Tree或者Ball Tree这类空间索引可以把查询复杂度降到对数级别第三阶段降维比如用PCA把孩子维特征压到低维空间让距离计算不再被冗余维度拖慢第四阶段如果数据规模到了海量级别就要上近似最近邻方法比如局部敏感哈希牺牲一点点精度换取极大的速度提升。但这些都是后话。入门阶段我只有一个建议先把纵向做深把一条样本从“进来到给出预测”的完整路径打印出来观察到底哪里慢。只有亲手体会到全量排序在数据量增长后的痛苦你才会真正理解为什么需要索引和近似方法。过早优化不仅增加学习负担也容易让你迷失在复杂的工程细节里。5.4 Java代码实现中容易翻车的细节最后这部分说几个写KNN时很常见、但报错信息不明显的Java问题。第一个是Double.NaN。归一化时如果某个特征的max等于min除以0会得到NaN。更麻烦的是NaN一旦进入距离排序排序结果会变得莫名其妙甚至导致分类器直接返回错误标签。解决方法是先在归一化函数里判断分母是否为0如果为0直接把该特征常量设成0就好。第二个是空指针和越界。k的值不能大于训练集大小否则取前k个邻居时会数组越界。所以入口地方要加一个校验如果k超范围直接抛出异常并给出清晰提示。第三个是Comparator对NaN的处理。Java里Double.compare和Comparator.comparingDouble对NaN的行为可能和你直觉不同。最简单的办法是确保进入排序的数据里没有NaN前面那个坑就正好会引发这个副作用。第四个是集合视图问题。subList和Arrays.asList都是视图而非副本改动会映射到原对象上。在前面的数据划分代码里我已经用new ArrayList包了一层这是多年被坑出来的习惯。第五个是多线程问题如果你的KNN要通过接口对外服务注意SimpleDateFormat和Collections.sort都是非线程安全的。入门阶段先不用纠结但如果在Servlet里直接用迟早会踩。我的建议是先跑对单线程再考虑锁、并发或者换成无状态设计。我从第一次手写KNN到现在最大的感触是这个算法虽然简单却帮我把机器学习的核心流程完整串了一遍——数据准备、归一化、模型评估、调参、性能优化、工程细节。后面你再去学逻辑回归、决策树、SVM这套流程框架都是通用的。如果你想继续往下走下一步可以试着把KNN的投票改成距离加权或者用交叉验证去精选K值甚至把代码重构一下让它能接受文件输入、打印混淆矩阵。别小看这个“入门”算法把它玩透你对整个机器学习是怎么运转的已经有了一个准确而且立体的大纲。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑