简介:这是一套面向Java开发者、科研人员与学生的基础算法源码包,聚焦数值分析、线性代数与机器学习三大方向,帮助使用者在Java项目中快速嵌入数学计算与建模能力,降低从底层实现到应用验证的门槛。资源共45个文件,以36个Java源代码文件为核心,覆盖数值积分、微分方程求解、插值、最优化、矩阵运算与分解、特征值求解,以及线性回归、逻辑回归、决策树、聚类、支持向量机等基础模型;另有8个txt文本与1个png图片,用于数据样例、说明文档与结果展示,压缩包约544KB,结构清晰便于按模块查阅。目前已有342人学习。源码按数值分析、线性代数、机器学习等模块组织,读者可直接参考算法实现思路、复用类与方法,并借助配套数据文件进行实验验证,适合作为课程设计、算法研究或工程原型的参考工具。
1. 从一份 Java 源码说起:数值分析、线性代数与机器学习怎么串成一条线
很多人第一次看到“基于 Java 的数值分析、线性代数与机器学习算法设计源码”这个标题,会下意识觉得它是个大杂烩:数值分析是数学系的课,线性代数是工科基础,机器学习又是另一套生态,凭什么用 Java 把它们缝在一起?我当初也这么想,直到接手一个工业质检项目——产线边缘盒子上只允许跑 JVM,Python 环境装不了,模型还得现场更新。那一刻我才明白,这套组合不是学术拼盘,而是在 Java 工程体系里把数学算法从公式落到可维护代码的一条完整链路。
它解决的核心问题是:当你的运行环境被锁定在 Java(老系统、安卓、大数据组件、边缘网关),你依然需要自己实现矩阵分解、数值积分、梯度下降这些底层能力,而不是调一个 Python 包了事。适合谁?一是做 Java 后端但想往算法方向走的工程师,二是被部署环境逼着用 Java 写算法的同学,三是准备蓝桥杯、算法设计与分析这类考试、需要把数学公式翻译成代码的人。下面我按“数学底座 → 算法实现 → 工程落地 → 踩坑”的顺序,把这条线拆开讲透。
2. 数值分析与线性代数:Java 里怎么把公式变成能跑的代码
2.1 为什么 Java 做数值计算要先解决“精度与性能”这对矛盾
数值分析在 Java 里第一个绕不开的坑就是浮点数。double是 IEEE 754 双精度,有效位约 15~16 位十进制,做矩阵求逆、迭代求解时误差会累积。我见过一个求线性方程组Ax=b的例子,用高斯消元直接算,系数矩阵条件数一大,结果偏差能到小数点后两位,业务上直接判废。
所以 Java 数值计算的第一原则是:先判断问题是否病态,再选算法。常见做法是引入“主元选取”(pivoting),在消元时把当前列绝对值最大的行换上来,减小舍入误差放大。另一个手段是用BigDecimal做高精度,但它慢,只适合小规模、对精度极敏感的场景,比如金融计息,不适合大规模矩阵。
性能方面,Java 的 JIT 对循环优化其实不差,但要注意:避免在热点循环里创建对象。比如矩阵乘法,如果每次内层循环都new double[],GC 压力会直接吃掉收益。我一般会预先分配结果数组,用三重循环原地累加。下面这段是带主元的高斯消元,能直接抄:
// 高斯消元求解 Ax=b,带部分主元选取 public static double[] gaussSolve(double[][] A, double[] b) { int n = A.length; // 拷贝,避免修改原矩阵 double[][] a = new double[n][n]; double[] x = b.clone(); for (int i = 0; i < n; i++) { a[i] = A[i].clone(); } for (int k = 0; k < n; k++) { // 选主元:找第 k 列绝对值最大的行 int maxRow = k; for (int i = k + 1; i < n; i++) { if (Math.abs(a[i][k]) > Math.abs(a[maxRow][k])) { maxRow = i; } } // 交换行 double[] tmpRow = a[k]; a[k] = a[maxRow]; a[maxRow] = tmpRow; double tmpVal = x[k]; x[k] = x[maxRow]; x[maxRow] = tmpVal; // 消元 for (int i = k + 1; i < n; i++) { double factor = a[i][k] / a[k][k]; for (int j = k; j < n; j++) { a[i][j] -= factor * a[k][j]; } x[i] -= factor * x[k]; } } // 回代 for (int i = n - 1; i >= 0; i--) { double sum = x[i]; for (int j = i + 1; j < n; j++) { sum -= a[i][j] * x[j]; } x[i] = sum / a[i][i]; } return x; }逻辑说明:外层k控制消元列,先选主元再交换,保证除数a[k][k]不会过小;消元时从k列开始减,因为前面的列已经是 0;回代从最后一行往上算。参数上,A必须是方阵且非奇异,b长度等于n。如果a[k][k]接近 0,说明矩阵接近奇异,这时候要么换算法(如 SVD),要么提示业务方数据有问题。
2.2 线性代数在 Java 里的最小可用实现:矩阵、分解与向量化
线性代数是机器学习的骨架。Java 没有 NumPy 那种广播机制,所以你得自己封装一个Matrix类。我一般会实现这几个方法:multiply、transpose、identity、solve,再加上 LU 分解和特征值求解(幂法即可)。为什么不用现成库?因为很多老项目依赖受限,自己写反而可控。
以 LU 分解为例,它把A拆成下三角L和上三角U,之后解多个b时只需前代和回代,省去重复消元。这在机器学习里很常见,比如牛顿法每次迭代都要解H·d = -g,H 是 Hessian。下面是一个紧凑的 LU 实现:
// LU 分解,返回 L 和 U(L 对角线为 1) public static double[][][] luDecompose(double[][] A) { int n = A.length; double[][] L = new double[n][n]; double[][] U = new double[n][n]; for (int i = 0; i < n; i++) { L[i][i] = 1.0; for (int j = i; j < n; j++) { double sum = 0; for (int k = 0; k < i; k++) { sum += L[i][k] * U[k][j]; } U[i][j] = A[i][j] - sum; } for (int j = i + 1; j < n; j++) { double sum = 0; for (int k = 0; k < i; k++) { sum += L[j][k] * U[k][i]; } L[j][i] = (A[j][i] - sum) / U[i][i]; } } return new double[][][]{L, U}; }逻辑说明:按 Doolittle 算法,先算 U 的第 i 行,再算 L 的第 i 列。参数上要求A的各阶顺序主子式不为零,否则U[i][i]可能为 0。如果业务数据不满足,可以改用列主元 LU。这个实现的时间复杂度 O(n³),n 小于 500 时在 JVM 上跑得动,再大就要考虑分块或换用 EJML 这类库。
向量化方面,Java 可以用double[]加循环,也可以用Stream,但 Stream 有装箱开销,数值计算里我不推荐。如果非要并行,用Arrays.parallelSetAll或 ForkJoin,但要注意线程安全。常见做法是:小矩阵单线程,大矩阵按行分块并行。
3. 机器学习算法在 Java 里的落地:从线性回归到梯度下降
3.1 用 Java 实现线性回归:最小二乘与梯度下降两条路
线性回归是机器学习入门第一课,但在 Java 里实现它,能顺带把数值分析和线性代数串起来。两条路:一是正规方程θ = (XᵀX)⁻¹Xᵀy,直接调前面的矩阵求逆;二是梯度下降,迭代逼近。正规方程适合特征数少(n < 1000)且矩阵可逆的情况,梯度下降适合大规模、在线学习。
我一般先写正规方程版,因为它能当基准,验证梯度下降的结果对不对。下面是最小二乘的实现:
// 正规方程求解线性回归参数 theta public static double[] normalEquation(double[][] X, double[] y) { int m = X.length; // 样本数 int n = X[0].length; // 特征数 // 给 X 加一列 1,作为截距项 double[][] Xb = new double[m][n + 1]; for (int i = 0; i < m; i++) { Xb[i][0] = 1.0; System.arraycopy(X[i], 0, Xb[i], 1, n); } // 计算 X^T * X double[][] XtX = new double[n + 1][n + 1]; for (int i = 0; i <= n; i++) { for (int j = 0; j <= n; j++) { double sum = 0; for (int k = 0; k < m; k++) { sum += Xb[k][i] * Xb[k][j]; } XtX[i][j] = sum; } } // 计算 X^T * y double[] Xty = new double[n + 1]; for (int i = 0; i <= n; i++) { double sum = 0; for (int k = 0; k < m; k++) { sum += Xb[k][i] * y[k]; } Xty[i] = sum; } // 解 XtX * theta = Xty return gaussSolve(XtX, Xty); }逻辑说明:先增广一列 1 处理截距,再算XᵀX和Xᵀy,最后用高斯消元解方程。参数上,X是二维数组,y是一维标签。注意:如果特征之间高度相关(多重共线性),XᵀX会接近奇异,gaussSolve可能给出离谱结果,这时候要加正则项(岭回归)或先做特征筛选。
梯度下降版则要设学习率alpha和迭代次数。我一般用 0.01 起步,跑 1000 次,观察损失是否单调下降。如果震荡,说明alpha太大;如果下降太慢,说明太小。Java 里可以用for循环直接写,注意每次迭代要同步更新所有theta,不能边更新边用新值。
3.2 逻辑回归与分类边界:sigmoid、损失函数与阈值选择
逻辑回归虽然叫回归,其实是分类。核心是 sigmoid 函数σ(z) = 1/(1+e⁻ᶻ),把线性输出压到 (0,1)。损失函数用交叉熵,梯度推导后形式很简洁:(σ(Xθ) - y) * X。Java 实现时要注意Math.exp在 z 很大时会上溢,所以 sigmoid 要分段处理:
// 数值稳定的 sigmoid public static double sigmoid(double z) { if (z >= 0) { return 1.0 / (1.0 + Math.exp(-z)); } else { double ez = Math.exp(z); return ez / (1.0 + ez); } }逻辑说明:当 z 为正,直接算1/(1+e⁻ᶻ);当 z 为负,用等价变形eᶻ/(1+eᶻ),避免exp(-z)溢出。参数上,z 是线性组合θᵀx。这个细节在工业数据里很关键,因为特征没归一化时 z 可能到几百,不处理直接返回 NaN。
训练时,我一般会加 L2 正则,防止过拟合。阈值默认 0.5,但业务上可以调:如果漏检代价高,就把阈值降到 0.3,宁可错杀。这个阈值选择没有公式,靠验证集上的 PR 曲线定。
3.3 用 Java 做模型评估:混淆矩阵、准确率与交叉验证
模型训完不能只看训练误差。Java 里我会手写混淆矩阵和 F1,因为依赖少、可控。下面是一个评估工具:
// 计算混淆矩阵和 F1 public static void evaluate(int[] yTrue, int[] yPred) { int tp = 0, fp = 0, fn = 0, tn = 0; for (int i = 0; i < yTrue.length; i++) { if (yTrue[i] == 1 && yPred[i] == 1) tp++; else if (yTrue[i] == 0 && yPred[i] == 1) fp++; else if (yTrue[i] == 1 && yPred[i] == 0) fn++; else tn++; } double precision = tp + fp == 0 ? 0 : (double) tp / (tp + fp); double recall = tp + fn == 0 ? 0 : (double) tp / (tp + fn); double f1 = precision + recall == 0 ? 0 : 2 * precision * recall / (precision + recall); System.out.printf("TP=%d FP=%d FN=%d TN=%d%n", tp, fp, fn, tn); System.out.printf("Precision=%.4f Recall=%.4f F1=%.4f%n", precision, recall, f1); }逻辑说明:遍历样本,按真实和预测组合累加四个计数,再算指标。参数上yTrue和yPred都是 0/1 数组。如果做多分类,就每个类别单独算,再宏平均。交叉验证我一般用 5 折,手写一个打乱索引的循环即可,注意每折都要重新训练,不能共用参数。
4. 工程化与性能:Java 算法代码怎么不翻车
4.1 内存与 GC:数值计算里最容易忽视的“黑匣子”
Java 数值计算翻车,十有八九是内存。矩阵乘法如果每次内层都创建临时数组,年轻代 GC 会频繁触发,吞吐直接掉一半。我的血泪经验是:热点路径上零分配。具体做法:结果矩阵预分配,中间变量用局部double,不要用Double。如果必须用对象,考虑用ThreadLocal缓存。
另一个坑是double[][]的内存布局。Java 二维数组是数组的数组,行与行不连续,缓存命中率低。大矩阵运算时,我一般转成一维double[],用index = i * n + j访问,这样内存连续,JIT 也容易优化。下面是对比:
| 存储方式 | 内存连续性 | 缓存友好度 | 适用场景 |
|---|---|---|---|
double[][] | 行内连续,行间不连续 | 中 | 小矩阵、代码可读性优先 |
double[]一维 | 完全连续 | 高 | 大矩阵、性能敏感 |
4.2 多线程与并行:ForkJoin 在矩阵运算里的正确用法
Java 8 的Arrays.parallelSort和 ForkJoin 能加速,但用错反而更慢。矩阵乘法并行时,我一般按行分块,每个任务算结果的一部分,最后合并。注意:不要并行写同一行,否则要加锁,得不偿失。下面是一个按行并行的矩阵乘法骨架:
// 并行矩阵乘法:C = A * B public static double[][] parallelMultiply(double[][] A, double[][] B) { int n = A.length; int m = B[0].length; int k = B.length; double[][] C = new double[n][m]; java.util.stream.IntStream.range(0, n).parallel().forEach(i -> { for (int j = 0; j < m; j++) { double sum = 0; for (int p = 0; p < k; p++) { sum += A[i][p] * B[p][j]; } C[i][j] = sum; } }); return C; }逻辑说明:用IntStream.range(...).parallel()把行索引并行化,每个线程算自己的行,写自己的C[i][j],无竞争。参数上要求A的列数等于B的行数。注意:并行度不是越高越好,默认用公共 ForkJoinPool,如果和业务线程池混用,可能互相拖慢。我一般会限制并行度,或者用自定义池。
4.3 数值稳定性排查:条件数、迭代收敛与日志埋点
数值算法出问题,往往不报错,只是结果“看起来不对”。我一般会加三个检查:一是算矩阵条件数(用||A|| * ||A⁻¹||估算),大于 1e10 就警告;二是迭代算法记录每步损失,画出来看是否单调;三是关键中间结果打日志,比如梯度范数。这些埋点平时不显眼,出问题时就是后悔药。
条件数估算可以用幂法求最大最小特征值,比值就是条件数。如果太大,说明问题病态,要么换算法(如 Tikhonov 正则),要么回去检查数据是否归一化。归一化是机器学习里最便宜也最有效的稳定性手段,我一般先做 Z-score,再训练。
5. 避坑与常见问题:那些让我加班到凌晨的细节
5.1 浮点比较用==导致模型判断失效
现象:两个理论上相等的浮点数,==返回 false,导致分类阈值判断错误。原因:IEEE 754 舍入误差,0.1 + 0.2 != 0.3。解决:用Math.abs(a - b) < 1e-9做容差比较,容差大小按业务精度定,一般 1e-6 到 1e-9。
5.2 矩阵求逆直接调gaussSolve但没检查奇异
现象:程序不报错,但输出全是Infinity或NaN。原因:矩阵接近奇异,主元接近 0,除法放大误差。解决:消元前检查主元绝对值,小于阈值(如 1e-12)就抛异常或改用伪逆(SVD)。我一般会在gaussSolve里加一行判断,提前失败比事后排查省事。
5.3 梯度下降学习率设太大导致损失震荡
现象:损失函数不降反升,或者来回跳。原因:学习率超过稳定上限,更新步长跨过了极小值。解决:先设小学习率(如 0.001)跑几百轮,确认下降后再调大;或者用自适应方法(如 AdaGrad)。我习惯先画损失曲线,肉眼确认单调再继续。
5.4 训练集和测试集特征归一化不一致
现象:训练时准确率 95%,测试时 60%。原因:训练集用自身均值方差归一化,测试集却用了训练集的参数,或者反过来。解决:归一化参数必须从训练集算,然后应用到测试集。我一般把mean和std存下来,预测时复用。
5.5 多线程下共享Random导致结果不可复现
现象:同样数据,每次训练结果不一样。原因:java.util.Random线程不安全,多线程下种子被竞争。解决:用ThreadLocalRandom,或者每个线程独立Random实例。如果要求可复现,固定种子并单线程初始化。
6. 进阶技巧:用 Java 把算法源码变成可复用的工程资产
走到这里,你已经能把数值分析、线性代数和机器学习算法在 Java 里跑通了。但“能跑”和“能复用”是两回事。我最后分享一个习惯:把每个算法封装成接口 + 默认实现,比如Solver接口定义solve(double[][] A, double[] b),高斯消元和 LU 分解各做一个实现。这样业务代码只依赖接口,换算法不改调用方。
验证方法上,我一般用“已知答案”的测试用例:比如构造一个对角占优矩阵,理论解已知,跑一遍看误差是否在 1e-10 内。再比如线性回归,用y = 2x + 1生成数据,看拟合出的参数是否接近 2 和 1。这些测试跑得快,能挡住大部分回归。
| 算法 | 验证用例 | 预期误差 |
|---|---|---|
| 高斯消元 | 对角占优 3x3 矩阵 | < 1e-10 |
| 线性回归 | y=2x+1 生成 100 点 | 参数误差 < 0.01 |
| 逻辑回归 | 线性可分二维数据 | 准确率 100% |
最后一个技巧:把常用数学常量(如EPS = 1e-12)和工具方法(如sigmoid、normalize)抽到一个MathUtils类,全项目共用。这样别人接手你的代码,不用满世界找实现。我自己吃过亏,早期每个类都写一遍 sigmoid,后来改一处漏一处,加班改 bug 到凌晨。现在统一入口,省心太多。希望帮到你。
本文还有配套的精品资源,点击获取