☰
Java实现线性规划:从单纯形法到两阶段法的完整求解器代码
2026/9/25 5:05:33 网站建设 项目流程

简介:一套面向运筹学与Java学习者的线性规划算法实现代码包,解决在Java环境下建立线性规划模型、执行标准化并利用两阶段法求最优解的问题,适合运筹学课程实践、算法学习或小型决策支持工具开发参考。资源共11个文件,以Java源码和编译后的class文件为主体,辅以IDEA项目配置(xml、iml)与Git忽略文件,整体仅13KB,轻量且可直接导入开发环境运行调试。已有3477人学习下载。代码中LP类封装了standardize、addArtificialVariables、twoStageMethod与solve等核心方法,涵盖目标函数、约束条件、非负变量转换及人工变量两阶段求解的完整逻辑;Main类借助Scanner在控制台接受用户输入的目标函数系数与约束参数,直观演示了从模型录入到最优解输出的全流程。此外还包含人工变量处理、边界情况测试等细节,可帮助读者将运筹学理论落地为可运行的Java程序,也为进一步扩展单纯形法或性能优化提供了清晰的基础。

1. 线性规划算法实现的Java版:一份能直接改的求解器底子

这次拆的是线性规划的 Java 算法实现源码包,覆盖了单纯形法主体、两阶段法扩展、几组标准算例和一份简短的实现说明。先说结论:线性规划最容易翻车的不是算法本身,而是输入数据的组织、约束符号的处理和浮点精度这三个位置。这个资源正好把模型定义、求解器、测试用例拆成了清晰的三个层次,适合正在做运筹学课程设计、刚学 Java 想找算法练手,或者工作中突然要写一个排产/分配工具的人。拿到手之后不建议先读算法核心,建议先跑它自带的一个算例,再跟着模型类读代码。

2. 建模先行:约束矩阵与目标函数在 Java 里的三种组织方式

2.1 从数学符号到字段:变量、约束、目标函数怎么落成类

单纯形法不关心业务含义,只认标准形。任何线性规划问题到最后都会被拆成目标函数系数、约束矩阵、右端项和约束符号四样东西。资源里最值得先抄的就是这个LinearProgram模型类,它把所有求解器要用的信息一次性封装好。

public class LinearProgram { // 目标函数系数,下标从 0 开始,长度为 n private final double[] c; // 约束矩阵 A,m 行 n 列,a[i][j] 表示第 i 条约束中第 j 个变量的系数 private final double[][] a; // 约束右端项 b,长度为 m private final double[] b; // 约束符号:-1 表示 <=,0 表示 =,1 表示 >= private final int[] sense; // 求解状态:0 未求解,1 找到最优,2 无界,3 无可行解 private int solveStatus; // 最优解向量,长度为 n private double[] solution; public LinearProgram(double[] c, double[][] a, double[] b, int[] sense) { this.c = c; this.a = a; this.b = b; this.sense = sense; this.solveStatus = 0; } }

这套字段设计和数学标准形是一一对应的。sense数组是整个模型里最容易写错的地方,它用 -1/0/1 三个整数表达不等号方向,比直接用字符串<={省内存,也比布尔值isLessEqual更灵活,因为等号约束需要单独占一个状态。这里的n是原始变量个数,也就是业务里真正要决策的量,比如生产计划里的产品数量;m是约束条数。后续单纯形法构建增广矩阵时,n和m共同决定初始矩阵的列数。

2.2 输入数据组织:为什么推荐用数组而不是字符串表达式

常见做法是直接在调用方把数组构造好,但更利于批量测试的是从一个简单文本文件加载。资源里给了一套固定格式:第一行是变量数n和约束数m,第二行是目标函数系数,后面每一行是一条约束的系数、右端项、符号。这样设计是为了让测试用例能独立成文本文件,每次改需求不用重新编译。

public static LinearProgram loadFromFile(String path) throws IOException { try (BufferedReader br = new BufferedReader(new FileReader(path))) { String[] nm = br.readLine().trim().split("\\s+"); int n = Integer.parseInt(nm[0]); int m = Integer.parseInt(nm[1]); double[] c = new double[n]; double[][] a = new double[m][n]; double[] b = new double[m]; int[] sense = new int[m]; String[] cLine = br.readLine().trim().split("\\s+"); for (int j = 0; j < n; j++) { c[j] = Double.parseDouble(cLine[j]); } for (int i = 0; i < m; i++) { String[] parts = br.readLine().trim().split("\\s+"); for (int j = 0; j < n; j++) { a[i][j] = Double.parseDouble(parts[j]); } b[i] = Double.parseDouble(parts[n]); sense[i] = Integer.parseInt(parts[n + 1]); } return new LinearProgram(c, a, b, sense); } }

注意这里把右端项放在第n个位置、符号放在第n+1个位置,是因为一行约束的系数正好占前n个。解析完成后立刻构造模型对象,而不是返回一个 Map 或 List,这样后续所有方法都能直接复用模型里的字段。用空格分隔比 CSV 更省事,缺点是不能在约束系数里带空格类的可读注释,所以我一般会把说明性文字放在同名的.md文件里,而不是混进数据文件。字符串表达式那种"2*x1 + x2 <= 100"的输入方式在教科书里很常见,但表达式解析会引入大量字符串处理代码,而且不好排查空格、括号带来的问题,资源选择数组和文本文件是为了把注意力留在算法上。

2.3 为什么变量个数和约束个数最好在建对象时校验一次

拿到模型类之后,我建议在构造函数里做一次维度校验。资源里的版本简化了一些,实际使用时a.length应该等于b.length,a[i].length应该等于c.length,这些不一致会在后续构建单纯形表时直接造成数组越界。

public LinearProgram(double[] c, double[][] a, double[] b, int[] sense) { if (a.length != b.length || a.length != sense.length) { throw new IllegalArgumentException("约束矩阵行数与 b、sense 长度不一致"); } for (int i = 0; i < a.length; i++) { if (a[i].length != c.length) { throw new IllegalArgumentException("第 " + i + " 行约束列数与变量数不一致"); } } this.c = c; this.a = a; this.b = b; this.sense = sense; this.solveStatus = 0; }

这个校验是很多新手会跳过的步骤,但恰恰是它能把“运行到一半数组越界”变成“构造时直接报错”。从排错成本看,构造时发现问题的代价远低于求解迭代过程中发现问题。后面所有章节的代码都建立在这个模型类之上,先把维度校验写好,后面改数据格式、加人工变量时才不会到处找空指针。

3. 单纯形法落地:从标准形到主元迭代的完整代码路径

3.1 标准形与松弛变量:为什么每一行都要加一个单位列

单纯形法要求在初始表里每个约束都对应一个基变量。对于<=型约束,加一个系数为 1 的松弛变量就能直接作为初始基;对于>=型约束,需要加剩余变量但系数是 -1,不能直接当基变量,这正是第四章两阶段法的由来。资源先把只含<=约束的纯标准形跑通,因此构建单纯形表时会为每个约束补一列松弛变量。

private double[][] buildTableau() { int rows = m + 1; int cols = n + m + 1; double[][] t = new double[rows][cols]; // 目标函数行:把 c 取负填入前 n 列 for (int j = 0; j < n; j++) { t[0][j] = -c[j]; } // 约束行:系数填入 a,松弛变量填对角线位置,最右列放 b for (int i = 0; i < m; i++) { for (int j = 0; j < n; j++) { t[i + 1][j] = a[i][j]; } t[i + 1][n + i] = 1.0; t[i + 1][cols - 1] = b[i]; } return t; }

目标行取-c是因为单纯形法判断最优时看的是目标函数行的非负性。如果目标是最大化,则目标行全部非负时达到最优;如果是最小化,则可以把最小化问题等价转换成最大化-c,然后走同一套逻辑。松弛变量列放在n + i位置,正好形成一个从第n列开始的单位矩阵,这样初始基就是松弛变量本身,基变量取值等于b。最后边加一列右端项,行数和列数在构造t时就固定下来,后面所有操作都在这个二维数组里完成。

3.2 进基与出基:主元选择的两步判定

每次迭代要决定哪个变量进基、哪个变量出基。进基变量的选择看目标函数行:找最小负系数的列,因为把它从 0 增大能最快改进目标函数值。出基变量看最小比值规则:只在进基列系数为正的行里比右端项 / 系数,比值最小的行先达到 0,所以那一行的基变量出基。

private int choosePivotColumn(double[] objRow) { int pivotCol = -1; double minVal = -1e-9; for (int j = 0; j < objRow.length - 1; j++) { if (objRow[j] < minVal) { minVal = objRow[j]; pivotCol = j; } } return pivotCol; } private int choosePivotRow(double[][] t, int pivotCol) { int pivotRow = -1; double minRatio = Double.MAX_VALUE; for (int i = 1; i < t.length; i++) { double aij = t[i][pivotCol]; if (aij > 1e-9) { double ratio = t[i][t[0].length - 1] / aij; if (ratio < minRatio) { minRatio = ratio; pivotRow = i; } } } return pivotRow; }

进基判断使用-1e-9而不是严格的< 0,是为了跳过那些因为浮点误差变成-1e-12的数值,这类微小负值通常不是真正的进基候选。出基判断只取系数大于1e-9的行,如果某列系数全都不大于这个阈值,说明增加该变量不会让任何基变量降到 0,问题就是无界的。最小比值规则保证迭代后所有基变量仍然非负,这是单纯形法的几何意义所在:每次都沿着可行域的边移动到一个更优的顶点。

3.3 主元变换:一行归一化、其余行消去

选中主元行和主元列之后,先让主元变成 1,再用它消去其他行的同类列。

private void pivot(double[][] t, int pivotRow, int pivotCol) { int rows = t.length; int cols = t[0].length; double pivotVal = t[pivotRow][pivotCol]; for (int j = 0; j < cols; j++) { t[pivotRow][j] /= pivotVal; } for (int i = 0; i < rows; i++) { if (i == pivotRow) continue; double factor = t[i][pivotCol]; if (Math.abs(factor) < 1e-12) continue; for (int j = 0; j < cols; j++) { t[i][j] -= factor * t[pivotRow][j]; } } }

这段代码的本质就是高斯消元。主元行除以主元值后,主元位置变成 1;其他行减去factor倍的主元行,让它们在主元列的位置变成 0。跳过factor绝对值小于1e-12的行是纯性能优化,那些行在主元列上本来就是 0,消不掉也不需要消。注意这里没有记录基变量列表,单纯形表的行顺序在迭代中保持不变,基变量信息可以从表结构里反推:每一行单位列所在的位置就是该行基变量的列索引。这个简化省去了索引数组的维护,代价是提取解时多一步遍历。

3.4 迭代循环、终止条件与解提取

单纯形法的外层循环就是把选主元、换基、消元反复执行,直到目标函数行没有负系数。为了防止退化导致的循环问题,迭代次数要设一个上限。

public boolean solve() { double[][] t = buildTableau(); int maxIter = 100 * (m + n); for (int iter = 0; iter < maxIter; iter++) { int pivotCol = choosePivotColumn(t[0]); if (pivotCol < 0) { solveStatus = 1; extractSolution(t); return true; } int pivotRow = choosePivotRow(t, pivotCol); if (pivotRow < 0) { solveStatus = 2; return false; } pivot(t, pivotRow, pivotCol); } solveStatus = 2; return false; } private void extractSolution(double[][] t) { solution = new double[n]; for (int j = 0; j < n; j++) { int oneRow = -1; boolean isUnit = true; for (int i = 1; i < t.length; i++) { double v = t[i][j]; if (Math.abs(v) > 1e-9) { if (oneRow != -1) { isUnit = false; break; } oneRow = i; } } if (isUnit && oneRow != -1) { solution[j] = t[oneRow][t[0].length - 1]; } else { solution[j] = 0.0; } } }

maxIter设成100 * (m + n)的经验值,是从血泪里总结出来的:理论上单纯形法迭代次数不会超过组合数,但退化情况下可能来回兜圈子,设一个合理上限比无限循环好接受。extractSolution的逻辑是检查每个变量列是否恰好只有一个非零元素,且这个非零元素是 1,如果是,则说明该变量的值就是那一行的右端项;否则变量不在基里,取 0。这种判断方式比维护基变量索引更稳,因为它在数值扰动下仍能给出近似正确的解。

4. 两阶段法扩展:没有可行初始基时如何不被卡死

4.1 什么场景必须用两阶段法

纯标准形单纯形法要求所有约束都是<=且右端项非负。现实中更多问题是混合约束:有>=,有=,甚至右端项本身是负数。>=加剩余变量后系数为 -1,在松弛变量矩阵里形成不了单位列;=约束一开始就没有松弛变量可用。下面是约束类型与补齐列的对应关系。

约束类型需要补的变量初始基是否可用是否要人工变量
<=松弛变量,系数 1是否
>=剩余变量,系数 -1否是
=无否是

两阶段法的思路就是:面对无法直接形成单位矩阵的约束,人为插入人工变量凑出一个单位矩阵,先求一个辅助目标函数让所有人工变量归零;如果人工变量归不了零,说明原问题无可行解。这正是资源里solve()入口会先检查sense数组的原因。

4.2 Phase I:把目标函数临时换成人工变量之和

Phase I 的做法是在每个需要人工变量的等式约束里插入人工变量,把目标函数临时改成人工变量总和的最小化。在实现上,构建初始单纯形表后,要把目标函数行中所有基变量列的系数消成 0,否则单纯形法会得出“人工变量已经是 0 但目标行还有负值”的错误结论。

private double[][] buildPhaseOneTableau() { // 统计需要人工变量的约束数 int artificial = 0; for (int i = 0; i < m; i++) { if (sense[i] >= 0) { artificial++; } } int rows = m + 1; int cols = n + m + artificial + 1; double[][] t = new double[rows][cols]; // 目标行:人工变量列系数设为 1,其余为 0 for (int j = n + m; j < n + m + artificial; j++) { t[0][j] = 1.0; } // 把基变量列消成 0,保证迭代从可行基出发 for (int i = 1; i < rows; i++) { for (int j = 0; j < cols; j++) { if (t[i][j] == 1.0) { double factor = t[0][j]; for (int k = 0; k < cols; k++) { t[0][k] -= factor * t[i][k]; } } } } return t; }

这里把人工变量列放在最后面,方便在 Phase II 直接丢弃它们。完成“消基”处理后,对这张表再跑一次第一章的单纯形迭代。如果最终目标行最小值大于容差,说明无法把所有人工变量归零,原问题无可行解;如果归零,就把当前单纯形表作为 Phase II 的初始表。

4.3 Phase II:换回原目标函数继续迭代

Phase I 结束时单纯形表已经处于一个可行基状态,但目标函数是辅助的,需要替换成原问题目标函数行,同时把人工变量列删除。替换后还要再次把当前基变量列消成 0,因为目标行只要含有正的基变量系数,就无法直接用于最优判定。

private double[][] buildPhaseTwoTableau(double[][] phaseOneTable) { int artificialStart = n + m; int newCols = artificialStart + 1; double[][] t = new double[m + 1][newCols]; // 复制约束部分,丢弃人工变量列 for (int i = 1; i <= m; i++) { System.arraycopy(phaseOneTable[i], 0, t[i], 0, newCols); } // 恢复原目标:前 n 列填 -c,其余 0 for (int j = 0; j < n; j++) { t[0][j] = -c[j]; } // 基变量列消 0 for (int i = 1; i <= m; i++) { int baseCol = findBaseColumn(t, i); double factor = t[0][baseCol]; if (Math.abs(factor) > 1e-12) { for (int j = 0; j < newCols; j++) { t[0][j] -= factor * t[i][j]; } } } return t; }

findBaseColumn可以在当前的约束行里找值为 1 且其余行该列为 0 的列,也可以沿用 Phase I 记录的基变量索引,显然后者更省事。Phase II 拿到这张表后,再走一遍单纯形迭代,出来的就是原问题的最优解。抖动的地方在于删除人工变量列时表格宽度变了,所有索引都要重新对齐,建议把新旧列数打印出来核对一遍。

4.4 求解状态标志:最优、无界、无可行解的返回约定

资源把求解结果统一为几个整数状态码,方便调用方通过getStatus()判断结果,而不是靠异常来传播信息。

状态码含义出现条件
0未求解还没调用solve()
1最优目标行无负系数,且 Phase I 人工变量归零
2无界进基列所有系数都小于等于 0
3无可行解Phase I 结束后人工变量和大于容差

无界判断要放在主元选择里,不能等循环结束才看。因为无界时迭代永远选不出主元行,如果choosePivotRow返回 -1 后还用这个结果去pivot,必然空指针。状态码方案配合前面的loadFromFile,可以在测试脚本里一次性批量判断几十个算例是否正常,这是后面验证章节的基础设施。

5. 避坑笔记:精度、退化与数据预处理里的五个坑

5.1 纯循环:算法反复进基出基,跑到天荒地老

现象:单纯形法迭代次数远超正常范围,程序长时间不退出,甚至迭代到上限后输出NaN或乱码。

原因:退化情况下,某些基变量取值为 0,换基后目标函数值不增加。单纯形法退化为循环,反复围绕同一个可行域顶点打转。纯理论里这个坑叫“循环”,在实现里表现为无限迭代。

解决:资源里采用了 Bland 最小下标规则,即进基时选最小负系数列、出基时选最小下标行,而不是任选一个。这个规则虽然会让每次迭代的目标值改进更慢,但严格保证了不会循环。我更建议有效果更快的做法:在迭代循环里记录最近几次的目标函数值,如果连续 3 次完全相同,就切换到 Bland 规则。这样既保留 Dantzig 规则的速度,又不会卡死。

5.2 浮点精度:最优解对不上手算结果

现象:求解结果和手算结果差距在1e-4量级,比如本应取整的答案变成36.00000007,回代验证时被判成违反约束。

原因:单纯形法每个主元变换都包含除法,double运算会累积误差。迭代次数越多,误差越大。这是所有数值算法的共性,不是具体某行代码的问题。

解决:统一设置容差tol = 1e-9,所有判定逻辑用Math.abs(x) < tol替代严格等于 0。提取解时如果某个分量与整数差值小于1e-6,就直接四舍五入。切忌在每一处都手工调容差,那样后面维护时会非常痛苦。把这个常量放在求解器内部一个静态字段里,调试时只改一处。

5.3 右端项为负:初始基直接不可行

现象:读入数据时某行b[i] = -50,构建单纯形表后基变量为 -50,迭代出来的“最优解”明显违反业务常识。

原因:标准形要求所有右端项非负,单纯形法的所有比值判定都建立在b >= 0的前提上。教科书例题默认满足这个条件,但真实数据经常出现负的右端项,比如库存余额、现金流这类可能为负的业务量。

解决:在构建单纯形表之前做行变换,如果b[i] < 0,把该行所有系数和右端项同时乘以 -1,并把约束符号翻转。<=变>=,>=变<=,=保持。这步必须和人工变量插入逻辑配合,顺序颠倒了后面 Phase I 会多算甚至算错。

5.4 冗余约束与线性相关行:主元选择撞上除零

现象:约束矩阵里存在线性相关的行,比如某一行刚好是另一行的两倍。运行时某个主元列出现两行比值完全相等,消元后数值变得极其不稳定。

原因:预处理不充分。线性相关的约束虽然不改变可行域,但会让单纯形表里的数值关系脆弱,主元消元时分母极小,放大浮点误差。

解决:在加载数据后增加一个冗余检查函数,用高斯消元把约束矩阵化成行阶梯形,统计有效行数。如果有效行少于m,打印警告,并把冗余行筛除后再构建模型。资源里的文本格式加载器没有自动筛除,是需要使用者补的一环。我的习惯是写一个preprocess()方法在solve()入口调用,统一完成负右端项翻转、冗余行删除两步。

5.5 max 和 min 的符号混用:最优解总感觉反着来

现象:求解结果与业务预期对不上,明明是最大化利润,算出来却是某个极小值;或者目标函数值符号反了。

原因:单纯形法内部统一按最大化处理,但调用方传入的目标函数可能是最小化。如果构建单纯形表时没有把min c^T x等价转换成max -c^T x,目标行符号就会错。

解决:在LinearProgram里增加一个maximize布尔字段,构造模型时就固定求解方向。solve()内部构建目标行时统一用maximize ? -c[i] : c[i]填入,解出来后在输出层再反转目标函数值。最好在模型类的注释里写明“目标函数始终按业务方向存储,单纯形表内部按最大化处理”,这样读代码的人不会被正负号绕晕。

6. 把结果变可信:标准例题回归、随机压力与单纯形表调试

6.1 标准例题回归:先跑一组能查到答案的算例

设计一个简单的生产排产算例,结果可以手算验证。

public static void main(String[] args) { double[] c = {3.0, 2.0}; double[][] a = { {2.0, 1.0}, {1.0, 3.0} }; double[] b = {100.0, 120.0}; int[] sense = {-1, -1}; LinearProgram lp = new LinearProgram(c, a, b, sense); lp.solve(); double[] x = lp.getSolution(); System.out.println("x1=" + x[0] + ", x2=" + x[1]); System.out.println("obj=" + (3 * x[0] + 2 * x[1])); }

这个例子对应最大化3x1 + 2x2,约束2x1 + x2 <= 100、x1 + 3x2 <= 120,两个变量非负。手算顶点可以验证最优解是x1=36、x2=28,目标值164。第一次跑通这个例子后,我对求解器核心的正确性就有了基本信任。之后每改一次代码,先跑这个例子确认没有被改坏。

6.2 随机压力与回代验证:把解代回约束里才算数

单靠一个标准例题只能证明“例子能跑通”,不能证明“一般情况不会翻车”。随机生成约束矩阵并构造一个必然可行的解,是更高性价比的验证手段。

public static LinearProgram randomFeasible(int n, int m, Random rnd) { double[][] a = new double[m][n]; double[] b = new double[m]; double[] x = new double[n]; for (int j = 0; j < n; j++) { x[j] = rnd.nextInt(5) + 1; } for (int i = 0; i < m; i++) { double sum = 0; for (int j = 0; j < n; j++) { a[i][j] = rnd.nextInt(10) + 1; sum += a[i][j]; } b[i] = sum + rnd.nextInt(10); } int[] sense = new int[m]; Arrays.fill(sense, -1); return new LinearProgram(new double[n], a, b, sense); }

随机生成时先偷偷造一个x,再让右端项大于等于A * x,这样能保证至少存在一个可行解,不会把问题变成无可行解,方便专心检查最优性。求解后必须回代验证:把解代回每个约束,判断是否在容差范围内满足符号方向。这一步能顺手抓出“解向量维度对不上”和“约束符号翻转漏了一行”这两类问题。我在本地写了一个循环,随机生成 500 个算例,逐个检查状态码和回代结果,跑完再手动看几个边界值。

6.3 打印单纯形表调试:肉眼比断点更快定位

错误出现在迭代中后期时,断点调试效率很低,因为要反复跳进跳出。更实用的办法是在pivot方法里加一行可开关的打印,把每次迭代后的表输出到控制台。

private void logTableau(double[][] t, int iter) { if (!debugMode) return; System.out.println("--- iteration " + iter + " ---"); for (int i = 0; i < t.length; i++) { for (int j = 0; j < t[0].length; j++) { System.out.printf("%8.2f ", t[i][j]); } System.out.println(); } System.out.println(); }

看表时只需要盯两个位置:目标函数行还有没有负值,右端项列有没有出现负值。目标行持续有负值但迭代卡在某一对行列之间,基本就是主元选择逻辑的问题;右端项出现负数,说明上一步主元行选错了,最小比值规则被破坏。这个打印方法培养出的读表习惯,比任何 IDE 断点都耐用。从那以后我每次改约束加载或主元选择逻辑,都强制走一遍标准例题回归加随机压力两关,这个习惯帮我挡掉了不少低级翻车。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询