简介:这份资源面向运筹学学习者与Java开发者,提供线性规划算法的完整可运行实现,帮助理解从数学模型到代码落地的全过程。包内共11个文件,以Java源码与编译后的class文件为核心,辅以IntelliJ工程配置的xml、iml及gitignore等,压缩包约13KB,体量轻便,导入IDE即可阅读调试。代码围绕LP类展开,涵盖模型标准化、人工变量引入、两阶段法求解与主求解入口,Main类通过控制台接收目标函数系数、约束系数与右端常数并输出最优解,数据结构上使用数组与列表存储系数和解信息。已有3479人学习下载,适合课程实验、算法作业与自学参考,读者可据此掌握标准型转换、松弛变量与人工变量的处理思路,并在此基础上扩展测试用例、优化求解性能,积累运筹学算法的实际编程经验。
1. 线性规划算法实现:一个 Java 版单纯形法求解器能跑多快
第一次在项目里遇到排产优化,我下意识想调 Python 的求解库,结果部署环境只认 JVM,连个原生依赖都不让加。最后翻出这份 Java 版线性规划算法实现,把单纯形法从教科书公式落成能直接new出来调用的类,才算把活干完。它干的事很明确:给定一组决策变量、一个目标函数和若干约束条件,求出最优解与目标值,覆盖标准型最大化问题、等式与不等式约束、松弛变量引入、单纯形表迭代、无界与无可行解判定这些运筹学核心环节。适合两类人:一是要在 Java 后端里嵌优化能力的开发者,二是想拿一份能调试的代码把单纯形法真正看懂的学生或转行者。下面按「能干什么、怎么跑、怎么改」拆开讲。
2. 项目结构与技术选型:为什么是纯 Java 手写单纯形法
2.1 目录骨架与模块职责
这份资源是典型的轻量工程结构,没有 Spring、没有 Maven 多模块,核心就是一组普通 Java 类加一个入口。按常见组织方式,目录大致长这样:
linear-programming-java/ ├── src/ │ ├── main/ │ │ ├── java/ │ │ │ └── lp/ │ │ │ ├── LinearProgram.java // 问题建模:目标函数、约束、变量 │ │ │ ├── SimplexSolver.java // 单纯形法主循环 │ │ │ ├── SimplexTableau.java // 单纯形表:矩阵、基变量、检验数 │ │ │ ├── Constraint.java // 单条约束:系数、关系符、右端项 │ │ │ ├── Solution.java // 求解结果:状态、最优解、目标值 │ │ │ └── SolverStatus.java // 枚举:OPTIMAL/UNBOUNDED/INFEASIBLE │ │ └── resources/ │ └── test/ │ └── java/lp/SimplexSolverTest.java // 若干标准算例断言 ├── pom.xml // 或 build.gradle └── README.mdLinearProgram负责收集用户输入,SimplexTableau负责把问题转成表格形态并做行变换,SimplexSolver是调度者,Solution把结果封装成对象返回。这种拆分的好处是:想换求解算法(比如对偶单纯形、内点法),只要新增一个 Solver 实现,建模层不用动。我一般会把Constraint设计成不可变对象,避免迭代过程中被意外改写,这是血泪经验——早期版本里约束系数被行变换污染,排查了半天才发现是引用共享。
2.2 技术选型:零第三方依赖的取舍
整个项目只依赖 JDK 标准库,矩阵运算用double[][]手写,没有引入 Apache Commons Math 或 ojAlgo。这个选择在工程上有明确理由:一是部署环境受限时,少一个 jar 就少一份冲突风险;二是单纯形法的矩阵规模通常不大(几十到几百个变量),手写双重循环的性能完全够用,没必要为通用矩阵库付出学习成本。代价是数值稳定性要自己兜底,浮点比较必须带容差,这点后面避坑章节会细说。
构建工具用 Maven 或 Gradle 都行,pom.xml里只需要 JUnit 做测试。JDK 版本建议 8 及以上,代码里如果用了var或List.of就要求 11+,拿到源码先看pom.xml的maven.compiler.source确认。运行入口一般是一个带main方法的 Demo 类,构造问题、调用求解、打印结果三步走。
提示:如果源码里没有
pom.xml,说明是纯 IDE 工程,用javac直接编译src/main/java下所有文件即可,注意包名与目录层级对应。
2.3 从建模到求解的调用链
理解调用链比背公式有用。用户先new LinearProgram(),逐个addConstraint()并setObjective(),然后交给SimplexSolver.solve(program)。Solver 内部第一步做标准化:把<=约束补松弛变量、>=约束补剩余变量并引入人工变量、自由变量拆成两个非负变量。第二步构建初始单纯形表,把目标函数系数取负放进检验数行。第三步进入主循环:选入基变量(检验数为负且最小)、选离基变量(最小比值检验)、做枢轴变换,直到所有检验数非负或判定无界。最后从基变量里还原出原始变量的取值,组装Solution返回。这条链路每一步都能打断点,比看公式推导直观得多。
3. 核心代码拆解:单纯形表构建与枢轴迭代
3.1 问题建模类的字段设计
先看建模层。LinearProgram要存目标函数系数、约束列表、变量个数和优化方向。常见写法:
public class LinearProgram { private final int variableCount; // 决策变量个数 private final double[] objective; // 目标函数系数,长度 = variableCount private final List<Constraint> constraints = new ArrayList<>(); private boolean maximize = true; // 默认最大化 public LinearProgram(int variableCount) { this.variableCount = variableCount; this.objective = new double[variableCount]; } // 设置第 i 个变量的目标系数 public LinearProgram setObjectiveCoefficient(int i, double c) { objective[i] = c; return this; } public LinearProgram addConstraint(Constraint c) { constraints.add(c); return this; } }字段全部final或私有,通过链式方法修改,保证建模阶段状态可控。variableCount单独存而不是从objective.length推,是为了在用户还没设满系数时也能校验约束长度。maximize标志很关键:最小化问题在标准化时要把目标系数整体取反,求解完再把目标值取反回来,这个转换漏掉就会得到符号相反的结果,属于新手高频翻车点。
3.2 标准化:松弛变量与人工变量的引入
单纯形法要求所有约束是等式、所有变量非负。标准化这一步决定了后续表格的列数。核心逻辑:
// 将原始问题转为标准型,返回扩展后的系数矩阵与基变量下标 private StandardForm standardize(LinearProgram lp) { int m = lp.getConstraints().size(); int n = lp.getVariableCount(); int slackCount = 0, artificialCount = 0; for (Constraint c : lp.getConstraints()) { if (c.getType() == ConstraintType.LE || c.getType() == ConstraintType.GE) slackCount++; if (c.getType() == ConstraintType.GE || c.getType() == ConstraintType.EQ) artificialCount++; } int totalCols = n + slackCount + artificialCount; double[][] a = new double[m][totalCols]; double[] b = new double[m]; int[] basis = new int[m]; int slackIdx = n, artIdx = n + slackCount; for (int i = 0; i < m; i++) { Constraint c = lp.getConstraints().get(i); System.arraycopy(c.getCoefficients(), 0, a[i], 0, n); b[i] = c.getRightHandSide(); if (b[i] < 0) { // 右端项为负时整行取反,关系符翻转 for (int j = 0; j < totalCols; j++) a[i][j] = -a[i][j]; b[i] = -b[i]; } switch (c.getType()) { case LE: a[i][slackIdx] = 1.0; basis[i] = slackIdx++; break; case GE: a[i][slackIdx] = -1.0; slackIdx++; a[i][artIdx] = 1.0; basis[i] = artIdx++; break; case EQ: a[i][artIdx] = 1.0; basis[i] = artIdx++; break; } } return new StandardForm(a, b, basis, n, totalCols); }这段代码有三个参数要点。slackCount统计<=和>=约束数量,因为两者各需要一个松弛或剩余变量;artificialCount统计>=和=约束,因为这两类需要人工变量构造初始可行基。右端项为负时整行取反是必须的,否则初始基解会出现负值,违反非负约束。basis[i]记录第 i 行当前哪个变量在基里,后续枢轴变换要同步更新它。人工变量引入后,目标函数里要给它一个大 M 惩罚(大 M 法)或走两阶段法,源码里通常选大 M 法,实现简单但 M 取值要足够大,太小会压不住人工变量。
3.3 枢轴变换:入基、离基与行消元
主循环是性能与正确性的核心。每次迭代做三件事:找入基列、找离基行、做高斯消元。代码骨架:
while (true) { int enter = -1; double minReduced = -EPS; for (int j = 0; j < cols; j++) { // 选检验数最小且为负的列入基 if (reduced[j] < minReduced) { minReduced = reduced[j]; enter = j; } } if (enter == -1) return SolverStatus.OPTIMAL; // 所有检验数非负,最优 int leave = -1; double minRatio = Double.MAX_VALUE; for (int i = 0; i < rows; i++) { // 最小比值检验选离基行 if (a[i][enter] > EPS) { double ratio = b[i] / a[i][enter]; if (ratio < minRatio) { minRatio = ratio; leave = i; } } } if (leave == -1) return SolverStatus.UNBOUNDED; // 入基列全非正,无界 pivot(a, b, reduced, leave, enter); // 枢轴变换 basis[leave] = enter; }EPS是浮点容差,通常取1e-9到1e-6。入基规则用「最负检验数」是 Dantzig 规则,收敛快但可能循环;如果遇到退化问题反复迭代,可以换成 Bland 规则(选下标最小的负检验数列),保证有限步终止。最小比值检验里只考虑a[i][enter] > EPS的行,等于零或负系数的行不参与,否则会算出负的右端项。pivot方法先归一化主元行,再对其他行做消元,同时更新检验数行,这一步的循环顺序写错会导致检验数没同步,表现为迭代不收敛。
3.4 结果还原与状态判定
求解结束后,原始变量的值要从最终表里按基变量下标取。Solution封装:
public class Solution { private final SolverStatus status; private final double[] variables; // 原始决策变量取值 private final double objectiveValue; public static Solution optimal(double[] vars, double obj) { return new Solution(SolverStatus.OPTIMAL, vars, obj); } // 无界、无可行解用对应工厂方法,variables 为 null }还原时遍历basis,若basis[i] < originalVarCount,则variables[basis[i]] = b[i],其余变量为 0。目标值用原始目标系数点乘变量得到,不要直接读检验数行里的常数项——那是取反后的值,容易符号搞错。状态判定除了OPTIMAL和UNBOUNDED,还要处理大 M 法下人工变量仍留在基里且值大于零的情况,此时判INFEASIBLE。这三个状态覆盖了运筹学里线性规划的全部结局,调用方按状态分支处理即可。
4. 跑起来:编译、算例与结果验证
4.1 环境准备与编译命令
拿到源码先确认 JDK 版本,命令行java -version看一眼。有 Maven 的话直接:
cd linear-programming-java mvn -q clean compile mvn -q test # 跑标准算例断言 mvn -q exec:java -Dexec.mainClass="lp.Demo"没有 Maven 就手动编译,注意包名lp对应目录lp/:
javac -d out $(find src/main/java -name "*.java") java -cp out lp.Demo-d out指定输出目录,find把所有源文件喂给编译器。如果报「找不到符号」,多半是某个类没被find匹配到,检查文件扩展名和目录层级。跑测试时如果断言失败,先看失败算例的期望值和实际值差多少,差一个符号通常是最大化/最小化转换没做,差很大则可能是枢轴选行选错。
4.2 一个可对照的标准算例
用经典的生产计划问题验证:两种产品,利润分别 3 和 5,资源约束为x1 <= 4、2x2 <= 12、3x1 + 2x2 <= 18,求最大利润。建模代码:
LinearProgram lp = new LinearProgram(2) .setObjectiveCoefficient(0, 3) .setObjectiveCoefficient(1, 5) .addConstraint(new Constraint(new double[]{1, 0}, ConstraintType.LE, 4)) .addConstraint(new Constraint(new double[]{0, 2}, ConstraintType.LE, 12)) .addConstraint(new Constraint(new double[]{3, 2}, ConstraintType.LE, 18)); Solution s = new SimplexSolver().solve(lp); System.out.println(s.getStatus()); // OPTIMAL System.out.println(Arrays.toString(s.getVariables())); // [2.0, 6.0] System.out.println(s.getObjectiveValue()); // 36.0手算验证:交点(2,6)处目标值3*2+5*6=36,与程序输出一致。这个算例的好处是约束都是<=,只涉及松弛变量,适合第一次跑通时排除人工变量的干扰。跑通后再换成含>=和=的算例,观察大 M 法是否正确把人工变量赶出基。我一般会准备三组算例:唯一最优、多重最优(某个检验数为 0)、无界,分别验证状态判定分支。
4.3 结果怎么读:变量、目标值与状态
Solution.getVariables()返回的是原始决策变量,长度等于建模时的variableCount,松弛变量和人工变量不会出现在里面。getObjectiveValue()已经处理过最大化/最小化的符号转换,直接就是业务语义上的目标值。状态为UNBOUNDED时变量数组为null,调用方必须先判状态再取值,否则空指针。状态为INFEASIBLE说明约束互相矛盾,这时候该回头检查业务约束而不是怀疑求解器。把这三个字段打印出来对照手算,是验证移植是否正确的最快方式。
5. 避坑与排查:单纯形法落地时的五个高频翻车点
5.1 浮点比较不带容差导致死循环
现象:某些算例迭代几百次不退出,检验数在-1e-16附近反复横跳。原因:直接用reduced[j] < 0判断入基,浮点误差让本该为 0 的检验数被判成负数,选了个无效列。解决:所有比较统一用EPS容差,入基条件写成reduced[j] < -EPS,比值检验写成a[i][enter] > EPS,最优判定写成所有检验数>= -EPS。容差取1e-9对大多数业务规模够用,变量上千时可放宽到1e-7。
5.2 右端项为负没做行翻转
现象:初始基解出现负值,后续比值检验算出负数,结果完全错乱。原因:标准化时只补了松弛变量,没检查b[i] < 0。解决:对b[i] < 0的约束整行乘以 -1,同时关系符翻转(<=变>=),翻转后重新判断该补松弛还是人工变量。这一步顺序不能反,先翻转再补变量,否则补出来的系数符号是错的。
5.3 大 M 取值不当压不住人工变量
现象:明明有可行解,却返回INFEASIBLE,或者人工变量残留在基里。原因:M 取1e6但目标系数本身量级到1e8,惩罚相对太小。解决:M 取目标系数最大绝对值的一百倍以上,或者干脆改用两阶段法,第一阶段单独最小化人工变量之和,数值上更稳。业务数据量级差异大时,两阶段法是更保险的选择。
5.4 最小化问题忘了目标系数取反
现象:求最小值的问题,程序返回的目标值符号相反,变量取值也不对。原因:标准化时只处理了约束,没把maximize=false的目标系数取反。解决:进入求解前若为最小化,把目标系数整体乘 -1,求解完把目标值再乘 -1 还原。这个转换要放在标准化之前,且只做一次,重复取反会绕回原符号,排查时容易看花眼。
5.5 退化问题引发循环迭代
现象:目标值不再改进,但迭代次数持续增长,基变量组合周期性重复。原因:退化基可行解下,最小比值出现并列,Dantzig 规则可能循环。解决:换 Bland 规则,入基选下标最小的负检验数列,离基在并列时选下标最小的行,能保证有限步终止。性能会略降,但换来确定性,生产环境里确定性比那点速度重要。
6. 二次开发:把求解器接进业务系统的改造思路
拿到这份代码,多数人不是拿来跑算例,而是要嵌进排产、配载、预算分配这类业务里。改造的第一步是加一层建模适配:业务对象(订单、车辆、班次)到LinearProgram的映射单独写一个 Converter,别让业务代码直接碰系数数组。这样约束规则变了只改 Converter,求解器保持稳定。第二步是数值校验,业务数据常有空值、负库存、超大系数,进求解器前统一做量纲归一化,把系数缩放到同一数量级,能显著降低浮点误差引发的误判。
第三步是结果解释。Solution只给变量值和目标值,业务方要的是「哪个订单分到哪条线」,所以在 Converter 旁边再写一个反解器,把变量下标映射回业务实体。第四步是性能兜底,变量规模上到几千时,手写双重循环的枢轴变换会成为瓶颈,常见做法是把系数矩阵换成稀疏存储,或者对入基列选择加一个候选集限制。验证方法很直接:拿历史人工排产结果做对照,目标值不应劣于人工方案,差异超过 5% 就逐约束排查建模是否漏项。
| 改造点 | 改动位置 | 验证方式 |
|---|---|---|
| 业务建模适配 | 新增 Converter 类 | 单元测试覆盖典型订单组合 |
| 量纲归一化 | 标准化前预处理 | 对比归一化前后目标值偏差 |
| 结果反解 | 新增 ResultMapper | 抽样人工核对分配结果 |
| 稀疏化提速 | SimplexTableau 内部 | 大算例压测迭代耗时 |
我踩过最深的一个坑是直接拿业务原始系数进求解器,量级从 0.001 到 100000 混在一起,容差怎么调都不对,最后加了归一化才稳定。从那以后我每次接新的优化场景,都强制先跑一遍量纲检查和退化算例,确认数值行为正常再谈业务逻辑。希望这份拆解帮到你,把单纯形法从公式变成手里能改能调的工具。
本文还有配套的精品资源,点击获取