1. 从一车皮钢材的调度说起:运输问题到底在算什么
很多人第一次接触线性规划,都是从运输问题入门的。原因很简单:它的业务场景足够直观,数学模型又足够典型,几乎把线性规划的几个核心概念——决策变量、目标函数、约束条件、供需平衡——全都串了一遍。我在带新人的时候,也习惯拿运输问题当第一个案例,因为它能让人在半小时内建立起对"优化"这件事的具象认知。
先把场景摆出来。假设有两个钢材仓库,分别位于城北和城西,库存量分别是 35 吨和 25 吨。同时有三个工地需要钢材,需求量分别是 20 吨、25 吨、15 吨。从每个仓库到每个工地的单位运输成本不一样,因为距离、路况、装卸条件都不同。现在的问题是:怎么安排调运方案,才能让总运输成本最低?
这个问题看起来简单,但如果你手动去试,会发现组合方式并不少。两个仓库、三个工地,就有 6 条运输线路,每条线路运多少,理论上可以有无穷多种分法。真正要找到成本最低的那一组,靠拍脑袋是不行的。这就是线性规划要解决的问题——在一组线性约束下,寻找一个线性目标函数的最小值或最大值。
运输问题的数学结构有几个鲜明特征。第一,决策变量是每条线路上的运输量,通常记作 xij,表示从第 i 个仓库运往第 j 个工地的数量。第二,目标函数是所有线路运输量乘以对应单位成本的总和。第三,约束条件分两类:一类是每个仓库的发出量不能超过它的库存,另一类是每个工地的接收量必须满足它的需求。如果总供应等于总需求,就是平衡运输问题;如果不相等,就需要引入虚拟仓库或虚拟工地来配平。
GAMS 在这个场景里的角色,是一个建模语言和求解环境。它不负责替你思考业务逻辑,但能把你写下来的数学关系翻译成求解器能读懂的形式,然后调用求解器算出结果。相比直接在求解器里写矩阵,GAMS 的好处是模型的可读性极强——你写出来的东西几乎就是数学公式本身,改参数、换数据、调结构都很方便。这也是为什么在运筹学教学和工业级优化项目里,GAMS 一直有一席之地。
提示:运输问题是线性规划中最经典的入门模型之一,但它的结构可以扩展到很多实际场景,比如生产排程、库存调拨、人员分配、网络流量分配等。把运输问题吃透,后面学更复杂的模型会轻松很多。
2. GAMS 的建模语言长什么样:从集合到方程的骨架
2.1 集合声明:先把"谁参与运算"定义清楚
GAMS 建模的第一步,通常不是写公式,而是声明集合。集合就是"参与运算的对象清单"。在运输问题里,我们需要两个集合:仓库集合和工地集合。写法很直接:
Set i warehouses / north, west / j markets / site1, site2, site3 /;这段代码的意思是:定义集合 i,别名是 warehouses,包含 north 和 west 两个元素;定义集合 j,别名是 markets,包含 site1、site2、site3 三个元素。集合名后面的斜杠里,就是元素列表。GAMS 对大小写不敏感,但为了可读性,我习惯把集合名用小写,别名用有意义的英文单词。
集合声明看起来简单,但它是整个模型的地基。后面所有的参数、变量、方程,都是建立在集合的笛卡尔积之上的。比如 i 和 j 的笛卡尔积就是 2×3=6 条线路,每条线路对应一个运输量变量和一个单位成本参数。如果你集合定义错了,后面全盘皆错。
2.2 参数赋值:把业务数据填进去
集合定义好之后,接下来要把具体数据填进去。运输问题需要三类数据:每个仓库的供应量、每个工地的需求量、每条线路的单位运输成本。GAMS 里用 Parameter 来声明:
Parameter supply(i) supply capacity demand(j) demand requirement cost(i,j) unit transportation cost; supply(i) = / north 35, west 25 /; demand(j) = / site1 20, site2 25, site3 15 /; cost(i,j) = / north.site1 8, north.site2 6, north.site3 10 west.site1 9, west.site2 12, west.site3 7 /;这里有几个细节值得注意。第一,supply(i) 是一维参数,只跟仓库有关;cost(i,j) 是二维参数,跟仓库和工地都有关。第二,赋值时用斜杠包裹,元素之间用逗号分隔。第三,二维参数的写法是"仓库.工地",中间用点号连接。第四,如果某个组合没有赋值,GAMS 默认它是 0,这在运输问题里通常意味着"这条线路不通"或者"成本为零",需要根据实际情况判断是否合理。
我见过不少新手在这里踩坑:把 cost 的维度写反了,或者点号前后写错了顺序,结果求解出来的方案完全不合理。建议赋值完之后,用 display 语句把参数打印出来检查一遍:
display supply, demand, cost;这个习惯能帮你省下大量调试时间。
2.3 变量与方程:把数学公式翻译成 GAMS 语法
数据准备好之后,就进入核心部分:声明变量和方程。运输问题的变量只有一个,就是每条线路的运输量:
Positive Variable x(i,j) transportation quantity;Positive Variable 表示这个变量非负,符合运输量的物理意义。接下来是目标函数和约束条件。GAMS 里用 Equation 来声明方程,先声明再定义:
Equation obj total cost supply_con(i) supply constraint demand_con(j) demand constraint; obj.. sum((i,j), cost(i,j) * x(i,j)) =e= 0; supply_con(i).. sum(j, x(i,j)) =l= supply(i); demand_con(j).. sum(i, x(i,j)) =g= demand(j);这里出现了几个 GAMS 的核心语法元素。sum((i,j), ...) 是求和操作,对 i 和 j 的所有组合求和。=e= 表示等于,=l= 表示小于等于,=g= 表示大于等于。obj 方程写成 =e= 0 是一种常见写法,意思是"目标函数表达式等于零",求解器会自动把它识别为需要最小化的目标。
有一点需要特别说明:GAMS 里方程的定义和数学上的等式不太一样。obj.. sum(...) =e= 0 并不是说总成本等于零,而是说"目标函数就是等号左边的表达式",求解器会根据 solve 语句里的 min 或 max 来决定优化方向。这个写法初看有点别扭,但习惯了就好。
2.4 模型声明与求解:最后一步把一切串起来
方程定义完之后,需要声明模型并调用求解器:
Model transport / all /; Solve transport using lp minimizing obj;Model transport / all / 的意思是:定义一个叫 transport 的模型,包含所有已声明的方程。Solve 语句指定用 lp(线性规划)求解器,目标是最小化 obj。执行之后,GAMS 会输出求解状态、目标函数值、变量取值等信息。
整个模型写下来不到 30 行,但已经完整描述了一个运输优化问题。这就是 GAMS 的魅力:它把建模的门槛降得很低,让你能把精力集中在业务逻辑上,而不是矩阵变换和求解器接口上。
3. 供需不平衡时怎么办:虚拟节点的引入逻辑与实操
3.1 为什么总供应和总需求经常对不上
前面假设的是平衡运输问题:总供应 35+25=60 吨,总需求 20+25+15=60 吨,刚好相等。但实际业务里,这种情况反而是少数。更多时候是供应大于需求,或者需求大于供应。比如仓库总库存 60 吨,但工地只需要 50 吨,多出来的 10 吨怎么办?或者工地需要 70 吨,但仓库只有 60 吨,缺口 10 吨怎么处理?
线性规划的标准形式要求约束条件必须是等式或不等式,但求解器对"不平衡"的处理能力有限。如果直接写 supply_con(i).. sum(j, x(i,j)) =l= supply(i) 和 demand_con(j).. sum(i, x(i,j)) =g= demand(j),当总供应大于总需求时,模型可能有可行解,但求解结果里会有"剩余库存"没有明确表达;当总需求大于总供应时,模型直接不可行。
3.2 虚拟仓库与虚拟工地的构造方法
标准做法是引入虚拟节点,把不平衡问题转化为平衡问题。具体分两种情况。
第一种:总供应大于总需求。引入一个虚拟工地,它的需求量等于总供应减去总需求。从每个仓库到虚拟工地的单位运输成本设为 0,表示"剩余库存就地存放,不产生运输成本"。这样总供应和总需求就相等了,模型变成平衡运输问题。
第二种:总需求大于总供应。引入一个虚拟仓库,它的供应量等于总需求减去总供应。从虚拟仓库到每个工地的单位运输成本设为 0,表示"缺口部分不运输,相当于需求未被满足"。但这里有个隐患:如果成本为 0,求解器可能会优先让虚拟仓库满足所有需求,导致实际仓库的运输量为 0。为了避免这种情况,通常给虚拟仓库到工地的成本设一个很大的惩罚值(比如 9999),迫使求解器优先使用真实仓库。
在 GAMS 里实现虚拟节点,有两种方式。一种是直接在集合里加元素:
Set i warehouses / north, west, dummy_wh / j markets / site1, site2, site3 /;然后给 dummy_wh 赋供应量和成本。另一种是保持集合不变,在参数赋值时动态调整。我更推荐第一种,因为结构清晰,后面看代码一眼就能明白哪些是真实节点、哪些是虚拟节点。
3.3 一个完整的非平衡运输问题实例
假设现在有三个仓库,供应量分别是 30、20、25 吨,总供应 75 吨。两个工地,需求量分别是 40、30 吨,总需求 70 吨。总供应大于总需求 5 吨。引入虚拟工地 site_dummy,需求量 5 吨,所有仓库到它的成本为 0。
Set i warehouses / wh1, wh2, wh3 / j markets / site1, site2, site_dummy /; Parameter supply(i) / wh1 30, wh2 20, wh3 25 / demand(j) / site1 40, site2 30, site_dummy 5 /; Table cost(i,j) site1 site2 site_dummy wh1 7 9 0 wh2 8 6 0 wh3 5 10 0;注意这里用了 Table 来定义二维参数,比逐个赋值更直观。Table 的写法是:第一行是列名(对应 j 的元素),第一列是行名(对应 i 的元素),中间填数值。这种写法在参数较多时特别方便。
求解之后,如果 site_dummy 从某个仓库接收了 5 吨,说明那个仓库有 5 吨库存没有运出去。具体是哪个仓库,取决于哪个仓库的"剩余成本"最低。这个结果本身也有业务含义:它告诉你哪个仓库的库存应该优先保留。
注意:虚拟节点的成本设置直接影响求解结果。如果虚拟工地成本设为 0,求解器会倾向于让运输成本最高的仓库留下库存;如果设为一个较大的值,求解器会尽量让所有真实仓库都出货,虚拟节点只承担"不得不剩余"的部分。具体怎么设,取决于你的业务目标。
4. 求解结果怎么看:从输出文件里读出业务洞察
4.1 GAMS 输出文件的结构拆解
很多人第一次跑完 GAMS,看到满屏的输出就懵了。其实输出文件的结构很固定,关键信息就那么几块。我通常按这个顺序看:
第一块是编译和执行信息,包括模型有多少个方程、多少个变量、多少个非零元素。这些数字能帮你判断模型规模是否合理。如果方程数或变量数明显不对,说明集合或参数定义有问题。
第二块是求解器状态。最关键的是 "SOLVER STATUS" 和 "MODEL STATUS"。SOLVER STATUS 是 1 表示正常完成,MODEL STATUS 是 1 表示找到最优解。如果这两个数字不是 1,就要去查对应的错误代码。
第三块是目标函数值,通常显示为 "OBJECTIVE VALUE"。这是总运输成本,是你最关心的数字之一。
第四块是变量取值,显示为 "x(i,j)" 的各个分量。这里能看到每条线路具体运了多少。如果某个变量是 0,说明这条线路没有被使用。
4.2 用 display 和参数报告把结果整理清楚
GAMS 默认的输出比较冗长,直接看变量取值列表不够直观。我习惯在 solve 语句后面加几行代码,把结果整理成更易读的形式:
Parameter report(i,j) transportation plan; report(i,j) = x.l(i,j); display report; display x.l;x.l 表示变量 x 在求解后的取值(.l 是 level 的缩写)。把 x.l 赋给 report 参数,再用 display 打印,输出会更干净。如果你想把结果导出到 Excel 或 CSV,可以用 execute_unload 或者 put 语句,这里不展开,后面单独讲。
4.3 从运输方案里读出业务含义
求解结果不只是一堆数字,它背后有业务含义。比如某个仓库到某个工地的运输量特别大,说明这条线路的成本优势明显;某个仓库的供应量没有被完全使用,说明它的成本偏高或者位置不好;某个工地的需求由多个仓库共同满足,说明单一仓库无法覆盖它的需求。
我在实际项目里,通常会做两件事。第一,把运输方案和成本结构对照看,验证结果是否符合直觉。如果某个结果很反直觉,比如成本最高的线路反而运得最多,那就要检查模型是不是写错了。第二,做敏感性分析,看看如果某个仓库的供应量增加 10%,或者某条线路的成本下降 10%,总成本会怎么变。GAMS 做敏感性分析很方便,改个参数重新 solve 就行。
5. 那些年我踩过的 GAMS 坑:从语法错误到模型不可行
5.1 集合元素命名引发的血案
GAMS 对集合元素的命名有一些隐含规则。比如元素名不能以数字开头,不能包含特殊字符,长度也有限制。我见过有人用 "1号仓库" 这种中文加数字的命名,结果编译直接报错。还有人用 "site-1" 这种带连字符的名字,GAMS 会把连字符当成减号处理,导致解析错误。
安全的做法是:用纯英文字母和数字,数字不要放在开头,不要用特殊符号。如果实在需要中文注释,可以写在集合声明后面的注释里,或者用别名。
5.2 方程定义里的维度不匹配
这是新手最容易犯的错误之一。比如 supply_con(i).. sum(j, x(i,j)) =l= supply(i) 这行,sum 里的 x(i,j) 是二维的,sum 对 j 求和之后变成一维,正好和 supply(i) 匹配。如果你不小心写成 sum(j, x(j,i)),维度就对不上了,GAMS 会报 "dimension mismatch" 错误。
排查这类错误的方法:先看方程左边的维度,再看右边的维度,确保两边一致。如果涉及 sum,先确定 sum 掉哪个维度,剩下的维度就是结果的维度。
5.3 模型不可行的常见原因与排查路径
模型不可行(MODEL STATUS = 4 或 5)是另一个高频问题。运输问题里,不可行通常有这几个原因:
第一,总供应小于总需求,且没有引入虚拟仓库。这是最常见的原因。检查方法:把 supply 和 demand 分别求和,比较大小。
第二,某个约束条件写反了。比如把 =l= 写成了 =g=,导致供应约束变成了"必须大于等于供应量",而供应量是上限,自然不可行。
第三,参数赋值有误。比如某个需求量为负数,或者某个供应量为零但需求为正,导致无解。
第四,虚拟节点的成本设置不合理。比如虚拟仓库到所有工地的成本都是 0,但求解器仍然找不到可行解,可能是因为虚拟仓库的供应量没有正确设置。
排查路径:先检查供需总量,再检查约束方向,最后检查参数赋值。如果还不行,把模型简化到最小规模,逐个恢复,定位问题所在。
5.4 求解器选择与性能调优的几点经验
GAMS 支持多种线性规划求解器,常见的有 CPLEX、Gurobi、XPRESS、MINOS 等。对于运输问题这种小规模线性规划,任何求解器都能秒解。但如果模型规模扩大到几千个节点、几万条线路,求解器的选择就重要了。
我的经验是:CPLEX 和 Gurobi 在大型线性规划上表现最稳,XPRESS 也不错,MINOS 更适合非线性问题。如果只是教学或小规模测试,用默认求解器就行。如果遇到求解时间过长,可以尝试调整求解器的参数,比如设置最优性容差、开启预处理、调整单纯形法的定价策略等。但这些属于进阶内容,新手先把模型写对、跑通,再考虑调优。
6. 把运输问题模型改造成你自己的业务工具
6.1 从运输问题到生产排程的映射思路
运输问题的结构是"供应节点—需求节点—运输成本",这个结构可以映射到很多业务场景。比如生产排程:工厂是供应节点,订单是需求节点,生产成本加运输成本是"单位成本",排程问题就变成了运输问题。再比如人员分配:员工是供应节点,岗位是需求节点,员工到岗位的适配成本是"单位成本",分配问题也变成了运输问题。
关键是把业务里的"资源"和"需求"抽象成集合,把"匹配成本"抽象成参数,把"分配量"抽象成变量。一旦完成这个抽象,剩下的就是套用运输问题的标准模型。
6.2 用 GDX 文件管理数据,让模型和业务数据解耦
实际项目里,数据通常来自 Excel、数据库或 CSV 文件,而不是硬编码在 GAMS 代码里。GAMS 提供了 GDX(GAMS Data Exchange)格式来管理数据。你可以用 GDXXRW 工具把 Excel 数据读入 GDX,再用 $GDXIN 语句在 GAMS 里加载。
这样做的好处是:模型代码和数据分离,改数据不用改代码,模型可以复用。比如你有 10 个仓库、20 个工地,数据在 Excel 里维护,GAMS 代码只需要写一次,每次换数据重新加载就行。
6.3 把求解结果写回 Excel 的完整流程
求解完之后,结果通常需要写回 Excel 供业务人员查看。GAMS 提供了几种方式:用 execute_unload 把结果存成 GDX,再用 GDXXRW 写回 Excel;或者用 put 语句直接写文本文件,再用 Excel 打开。
我常用的是 put 语句,因为它简单直接:
File results /transport_results.csv/; Put results; Put "Warehouse,Market,Quantity", /; Loop((i,j), Put i.tl, ",", j.tl, ",", x.l(i,j), /; ); PutClose results;这段代码会把运输方案写成一个 CSV 文件,每行是"仓库,工地,运输量"。i.tl 和 j.tl 是集合元素的文本标签。用 Loop 遍历所有组合,把结果逐行写出。这种写法虽然原始,但足够灵活,适合快速出结果。
6.4 模型扩展的几个方向:多商品、多周期、带容量限制
标准运输问题假设单一商品、单一周期、无容量限制。实际业务往往更复杂。几个常见的扩展方向:
多商品运输:不同商品不能混装,需要为每种商品单独定义变量和约束。GAMS 里可以加一个商品集合 k,把 x(i,j) 扩展成 x(i,j,k)。
多周期运输:每个周期有独立的供需和成本,需要加一个时间集合 t,把 x(i,j) 扩展成 x(i,j,t),并增加库存平衡约束。
带容量限制:每条线路有最大运输量,需要加一个约束 x(i,j) =l= capacity(i,j)。这个扩展最简单,加一行方程就行。
这些扩展在 GAMS 里都不难实现,关键是先把标准模型吃透,再逐步叠加复杂度。我建议新手不要一上来就写复杂模型,先把两仓库三工地的标准问题跑通,再慢慢加东西。
7. 关于学习路径的一点个人体会
我刚开始学 GAMS 的时候,最大的困惑不是语法,而是"不知道模型能干什么"。后来发现,最好的学习方式不是啃手册,而是找一个真实的小问题,从头到尾做一遍。运输问题就是最好的起点:它足够简单,能在一天内跑通;又足够典型,涵盖了线性规划的核心概念。
我的建议是:先照着教程把标准运输问题敲一遍,确保能跑出结果;然后改数据,改成你自己熟悉的场景,比如快递网点调拨、食堂食材分配、图书馆图书调配;再然后加约束,比如某条线路有容量限制、某个仓库有最低出货量;最后尝试把数据从代码里分离出来,用 Excel 管理。走完这四步,你对 GAMS 和线性规划的理解会完全不一样。
另外,不要怕报错。GAMS 的错误信息其实很详细,关键是学会看。编译错误看行号和错误类型,求解错误看 MODEL STATUS 和 SOLVER STATUS,结果异常看参数赋值和约束方向。踩的坑越多,后面写模型越快。
最后分享一个小技巧:在模型开头加一行option limrow = 0, limcol = 0;,可以抑制输出文件里的方程和变量列表,让输出更干净。这个设置在教学演示时特别有用,能让你快速定位到关键结果。