简介:数学建模与工程优化中,运输调度与供应链成本控制是高频出现的实际问题,其核心常归结为图论路径搜索与非线性规划的组合求解。理解这类问题的通用建模思路,往往比套用现成算法更有价值。在钢管订购与运输问题中,需要把铁路、公路构成的网络抽象为邻接矩阵,用深度优先遍历搜索所有可行路径,再依据分段运价计算最小费用;随后将单位费用矩阵引入带购买费、运输费与铺设费的目标函数,并通过0-1开关处理钢厂最小起订量约束。借助MATLAB的fmincon工具,可在连续域中求解非线性约束优化结果,再通过灵敏度分析评估各参数对总成本的影响。该方案不仅适用于竞赛题目,也能迁移到多源多点网络下的物流优化、物资调运与工程成本分析场景,为处理带阶梯定价和离散决策的运输问题提供了完整范式。
1. 钢管订购和运输数学建模:七天备赛最该先吃透的一道题
钢管订购和运输数学建模,是数学建模竞赛里少有的“图论路径搜索 + 非线性规划 + 灵敏度分析”三合一的综合题。我当年备赛时花了一周啃这道题,数据要从铁路、公路、管道三类网络里自己抽,模型要同时管住七个钢厂的生产上限和五百单位最小起订门槛,最后还要用 MATLAB 的 fmincon 跑非线性约束优化。这几年研究生数学建模竞赛的 E 题也常见类似套路:给一张多点位网络,让你在成本约束下做流量分配。想备赛的同学或正在做供应链调运方案的人,把这道题拆透,比刷十道简单线性规划都值。下面这份论文加完整 MATLAB 程序,我按自己的理解重新梳理了一遍。
2. 先把网络图画成矩阵:39 节点邻接表与深度优先遍历求最小费用
2.1 图一怎么抽象:铁路负权、公路正权、39 个交点
拿到题目第一件事不是建模,而是把图一那张铁路、公路、管道交织的图变成计算机能读的数据。图一里除了七个钢厂 S1 到 S7、十五个站点 A1 到 A15,还有很多铁路与公路的交叉点,论文里用了一个 39 维数组来存这些节点之间的关系。
做法是给图上所有交点统一编号,然后两两检查是否有铁路或公路连接。有铁路连接的,在邻接矩阵里填负数(距离取负),有公路连接的填正数,没有连接的直接填无穷大。为什么铁路用负数?这是论文里的一个编码技巧——搜索路径时通过数字正负判断这段路属于铁路还是公路,后面计算费用时要分别按铁路运价表和公路运价处理。
% 邻接矩阵初始化,39个节点 adj = inf(39, 39); % 铁路连接:用负数记录里程,例如节点5到节点8的铁路里程120km adj(5, 8) = -120; adj(8, 5) = -120; % 公路连接:用正数记录里程,例如节点5到节点9的公路里程45km adj(5, 9) = 45; adj(9, 5) = 45;这里正负号只是个标记,实际求路径时要把铁路段、公路段分开累计。这类题普遍要先做数据规范化处理——把所有里程数据整理成统一的邻接矩阵,后面所有算法才能往上挂。
2.2 为什么要深度优先遍历而不是直接调最短路
一般看到“找最小费用路径”会先想到 Dijkstra 或 Floyd。但这道题有一个坎:铁路运价是分段函数,不是按里程线性增长的——300 公里以内运价 20 万元,301 到 350 公里运价 23 万元,351 到 400 公里 26 万元,每段价格都不同,1000 公里以上每增加 100 公里加 5 万元。公路运价却是固定的每公里 0.1 万元。
这意味着一条路径的总费用不能只靠“边权相加”得到。比如一条 500 公里的铁路,运价不是简单的某个单价乘以 500,而是要把里程代入分段函数查表。Dijkstra 这类最短路算法要求边权可加,但铁路费用本身是里程的分段非线性函数,直接跑最短路得到的是“里程最小”而不是“费用最小”的路径。
论文的做法是深度优先遍历,也就是 DFS。图一规模不大,节点就是那 39 个,钢厂到站点之间的可选路径数量有限,DFS 可以把所有连通路径都搜出来,对每条路径分别累加铁路段费用和公路段费用,再比较取最小。
function [minCost, bestPath] = dfsSearch(adj, startNode, targetNode) % 深度优先遍历所有路径 % adj: 39x39邻接矩阵,负数代表铁路,正数代表公路 % startNode: 钢厂节点编号 % targetNode: 站点节点编号 visited = zeros(1, 39); currentPath = []; minCost = inf; bestPath = []; dfs(startNode, targetNode, 0, 0, 0); function dfs(node, target, railDist, roadDist, depth) if node == target % 到达目标站点,按分段运价计算铁路费用加公路费用 railCost = railwayCost(railDist); roadCost = roadDist * 0.1; totalCost = railCost + roadCost; if totalCost < minCost minCost = totalCost; bestPath = currentPath; end return; end visited(node) = 1; for nextNode = 1:39 if adj(node, nextNode) < inf && ~visited(nextNode) % 负数表示铁路段,正数表示公路段 if adj(node, nextNode) < 0 dfs(nextNode, target, railDist + abs(adj(node, nextNode)), roadDist, depth + 1); else dfs(nextNode, target, railDist, roadDist + adj(node, nextNode), depth + 1); end end end visited(node) = 0; % 回溯 end end参数上要特别注意:railDist 和 roadDist 是分开累计的,不能把铁路里程和公路里程混在一起。在所有钢厂到所有站点都跑一遍 DFS 后,就得到了一张 7 行 15 列的“纯运输费用”表,不含出厂价。DFS 在这类题里的好处是能拿到完整路径集合,方便后续人工检查某条路是否绕了远路。坏处是节点一多会指数爆炸——但 39 个节点完全可控。
2.3 单位费用矩阵 C:从纯运费到含销价的决策依据
DFS 搜完得到的是运输费,但模型里真正用的矩阵是“含出厂销价的总费用”,论文附录里叫 C 矩阵。这一步非常关键,很多人在这里翻车。C 矩阵第 i 行第 j 列表示从钢厂 i 调一单位钢管到站点 j 的“购买价 + 运输费”,即 p_i 加上 DFS 求出的纯运费。
比如 S7 钢厂到 A15 站点的纯运输费只有 2 万元,但 S7 的出厂销价是 160 万元/单位,所以 C(7,15) = 162 万元/单位。而论文表一里写的“最小费用”其实是纯运费,没有加销价——读表一的时候要自己加上销价才能和附录代码里的 C 矩阵对上。
% 构造含销价的单位费用矩阵 C (7x15) % fare 是 DFS 算出的纯运输费用矩阵 fare = [...]; % 7行15列,元素为纯运输费 price = [160 155 155 160 155 150 160]; % 七家钢厂出厂销价 C = zeros(7, 15); for i = 1:7 C(i, :) = fare(i, :) + price(i); % 运输费 + 销价 endC 矩阵就是整个优化模型里最关键的一张表,后面无论是目标函数还是约束条件,全部围绕它展开。从结果上看,S1 到 A1 的 C 值为 170.7 万元/单位,S7 到 A15 为 162 万元/单位,这些都是把销价算进去后的决策依据。
3. 多元非线性模型搭建:目标函数三块费用与约束条件逐项落地
3.1 目标函数拆解:购买费、干线运费、铺设费各自怎么算
模型的决策变量有两类:一是钢厂 i 往站点 j 运送的钢管量 x(i,j),单位是 km 对应的钢管单位数;二是每个站点 A_j 向右铺设的钢管量 rl(j)。总费用 W 拆成三块:
第一块是购买费加干线运输费。购买费就是各厂采购量乘以销价,干线运输费就是运量乘以 C 矩阵里对应的单位总费用。论文里把这两项合并成对 C(i,j) 求和,因为 C 已经包含销价。
第二块是站点到铺设地点的运输费,这是最容易理解错的地方。钢管从站点 A_j 运到左右两侧的铺设点,不是按直线距离算运费,而是假设每车钢管沿管道边走边卸、以 1km 为单位分段铺设。向右铺设 rl(j) 公里时,第一公里运距 1km,第二公里运距 2km,以此类推,所以运输费是一个等差数列求和:0.1 × rl(j)(rl(j)+1)/2。向左铺设部分同理,用两站点间距减去向右铺设量得到向左铺设量。
目标函数写成 MATLAB 就是:
function f = myfun(XX, C, N) % XX: 8x15 决策变量矩阵 % x = XX(1:7,:) 是各厂到各站点运量 % rl = XX(8,:) 是各站点向右铺设量 x = XX(1:7, :); rl = XX(8, :); L = [104 301 750 606 194 205 201 680 480 300 220 210 420 500]; f = 0; % 第一块:购买费 + 干线运输费 for i = 1:7 for j = 1:15 f = f + N(i) * x(i, j) * C(i, j); end end % 第二块:站点到铺设地点的运输费,等差数列求和 for j = 1:14 rj = rl(j); lj = L(j) - rl(j); % 向左铺设量 f = f + 0.1 * (rj * (rj + 1) / 2 + lj * (lj + 1) / 2); end end参数说明:N 是一个 7 维 0/1 开关向量,表示哪些钢厂参与采购。N(i)=0 代表直接放弃这个厂,模型里就不会从它那里订货。论文问题一最终用的是 N = [1 1 1 0 1 1 0],也就是 S4 和 S7 不参与。这个开关是处理“最少生产 500 单位”门槛的辅助手段,后面专门讲。
3.2 最小 500 单位的 0-1 约束:先松弛再回判
题目里有一条硬性约束:一个钢厂如果承担制造,至少生产 500 个单位。这本质是“要么不买,要么至少买 500”,属于带 0-1 变量的混合整数非线性规划。MATLAB 的 fmincon 不直接支持整数变量,论文的处理办法是先把 500 门槛松弛掉,假设钢厂可以生产任意小于上限的数量,求解完再看各厂产量。
具体做法是:先不加 500 约束跑一遍,如果某厂产量恰好为 0,那就当它不参与;如果产量落在 0 到 500 之间,就需要人工处理。论文里 S4 产量直接为 0,S7 产量小于 500。对 S7 做了两种假设:一是产量归 0,二是强制其生产 500 单位并重新求解。对比后发现 S7 产量归 0 时总费用 6102786.1 万元,强制生产 500 时总费用 6102797.1 万元,前者更优,所以最终方案里 S7 不采购。
% 松弛500约束后的产量约束写法 % m(i) 为第 i 个钢厂实际总产量,s(i) 为产量上限 function [c, ceq] = mycon(XX, C, N, s) x = XX(1:7, :); rl = XX(8, :); L = [104 301 750 606 194 205 201 680 480 300 220 210 420 500]; m = zeros(1, 7); for i = 1:7 m(i) = sum(N(i) * x(i, :)); end % 不等式约束:产量不超过上限 c = m - s; % 等式约束:各站点供需平衡 ceq = []; for j = 2:14 inflow = sum(N .* x(:, j)); ceq(j - 1) = inflow - rl(j) + rl(j - 1) - L(j - 1); end ceq(14) = sum(N .* x(:, 1)) - rl(1); % 第一站只有向右 ceq(15) = rl(15); % 最后一站向右铺设量为0 ceq(16) = sum(m) - 5171; % 总产量等于管道总需求 end注意 c = m - s 这里的 s 是传入的产量上限向量,如果做灵敏度分析,s(t) 会被临时减去 50 再传入,这样同一个约束函数就能复用。等式约束 ceq(16) 里的 5171 是管道总长度,单位是公里,对应题目里 A1 到 A15 全线的总需求。
3.3 相邻站点约束:向右向左铺设量必须吞掉整段管道
铺设环节的关键约束是:任意两个相邻站点 A_j 和 A_{j+1} 之间的管道,必须由 A_j 向右铺的一部分和 A_{j+1} 向左铺的一部分完全覆盖。也就是说,A_j 向右铺设量 rl(j),加上 A_{j+1} 向左铺设量 L(j) - rl(j+1) ,恰好等于两站点间距 L(j)。
这条约束写成等式就是 ceq(j) = influx_j - rl(j) - (L(j-1) - rl(j-1)) = 0,其中 influx_j 是所有钢厂送往站点 A_j 的总量。这里有一个很隐蔽的细节:第 j 个站点向左铺设的量,等于它和前一个站点间距减去前一个站点向右铺设量,这个量被自动消掉了,模型里不需要额外定义向左铺设变量,只需要 15 个向右铺设变量 rl。
把这三个约束和前面目标函数放一起,整个问题一就变成一个完整的带非线性约束的优化模型。调用 fmincon 时用 active-set 算法,最大函数评估次数设到 50000,初始解全取 0,跑完就能得到 7 个钢厂往 15 个站点的运量分配和每个站点向右铺设量。
4. MATLAB 中用 fmincon 求解:代码结构、参数配置与结果回读
4.1 主函数骨架:决策变量升维与 options 配置
fmincon 要求决策变量是一个向量,但问题一的决策变量天然是矩阵形式,所以论文里用了一个 8 行 15 列的矩阵 XX 存全部变量,其中前 7 行是各厂到各站点的运量,第 8 行是各站点向右铺设量。MATLAB 的 fmincon 会把矩阵按列展开当成向量处理,只要目标函数和约束函数里自行 reshape 回来就行。
% 问题一主程序骨架 function [x, fval] = solveProblem1() x0 = zeros(8, 15); % 初始解全0 vlb = zeros(8, 15); % 所有变量下界为0 N = [1 1 1 0 1 1 0]; % 参与采购的钢厂开关 C = loadCostMatrix(); % 7x15,含销价单位费用 s = [800 800 1000 2000 2000 2000 3000]; % 产量上限 options = optimset('LargeScale', 'off', ... 'Algorithm', 'active-set', ... 'MaxFunEvals', 50000); [x, fval] = fmincon(@(XX) myfun(XX, C, N), x0, ... [], [], [], [], vlb, [], ... @(XX) mycon(XX, C, N, s), options); end注意几个配置点:LargeScale 必须关掉,否则 active-set 算法不生效;MaxFunEvals 设到 50000 是因为目标函数里有两层循环,默认的 3000 次往往不够收敛;下界 vlb 全 0 保证运量和铺设量不为负。初值 x0 全 0 其实是有点赌的成分,active-set 对初值敏感,后面排查章节我会专门说这个问题。
4.2 目标函数与约束函数的传参技巧
myfun 和 mycon 除了传 XX,还要传 C、N、s 等外部参数。fmincon 的函数句柄用 @(XX) myfun(XX, C, N) 这种方式做参数绑定,避免用全局变量。这是我比较推荐的写法——全局变量在多次调用求灵敏度时会互相污染,匿名函数绑定参数干净很多。
灵敏度分析就是在这个主函数外面再套一层循环:每次把某个钢厂价格加 5 或减 5,重新调一次 fmincon,记录总费用变化。论文里对七个钢厂分别做销价 ±5 万、产量上限 ±50 单位的扰动,计算灵敏度值后比较大小。
% 灵敏度分析:单独改变第t个钢厂的价格,其余不变 function sens = sensitivityPrice(t) baseCost = 6102786.1; % 基准总费用,万元 basePrice = [160 155 155 160 155 150 160]; % 价格增加5万元 priceNew = basePrice; priceNew(t) = priceNew(t) + 5; [~, f_new] = solveWithPrice(priceNew); deltaCost = f_new - baseCost; sens = (deltaCost / baseCost) / (5 / basePrice(t)); end这里灵敏度公式用的是变化率之比:总费用变化百分比除以参数变化百分比。论文表四的结果里,S6 价格变化时灵敏度最大,说明 S6 的销价对总费用影响最大;S4 和 S7 因为没参与采购,灵敏度为 0。产量上限灵敏度同理,但只需考虑 S1、S2、S3 三家——因为求解结果里只有它们顶到了生产上限,其余厂产量远低于上限,上限改变不影响结果。
4.3 结果回读:把 x 矩阵还原为订购计划和总费用
fmincon 返回的 x 是 8×15 矩阵,第一行到第七行分别是七家钢厂到 15 个站点的运量,第八行是 15 个站点向右铺设量。订购量就是把每行加起来,各厂订购量和总费用结果如下:
% 从 x 矩阵计算各厂订购量 m = zeros(1, 7); for i = 1:7 m(i) = sum(N(i) * x(i, :)); end disp(m); % 结果是 [800 800 1000 0 1190.5 1180.5 0],单位为单位钢管最终问题一的最优订购方案是:S1 订 800、S2 订 800、S3 订 1000、S4 不订、S5 订 1190、S6 订 1180、S7 不订,总订购量 5171 单位,正好等于管道全长。总费用为 6102786.1 万元。对比表三中让 S7 强制生产 500 单位的方案,总费用会多出 11 万元,所以最终采纳 S7 停产方案。
有一点要提醒:fmincon 返回的订购量可能是小数,比如 1190.5、1180.5,但实际钢管铺设按整公里计。论文在输出订购方案时做了取整处理,不足 1km 的按 1km 算,但总费用仍按连续解计算。严格来说这里有小瑕疵——取整后的方案应该重新代入目标函数核算总费用。我一般会在报告里加一步“取整回验”,把取整后的订购量重新算一遍总费用,误差通常在几万元以内,不影响结论。
5. 避坑与常见问题:从建模竞赛到实际工程都容易翻车的六个点
5.1 500 单位门槛处理不当导致可行域错误
现象:直接在约束里写“要么为0,要么≥500”,fmincon 直接报错或收敛到不可行解。
原因:fmincon 只能处理连续可导约束,0 到 500 之间的跳跃区间是一个离散断点,非线性规划求解器无法处理这类非凸可行域。
解决:先松弛求解,观察结果中哪些厂产量落入 (0, 500) 区间,再逐个做“归 0”和“强制 500”两方案对比。论文里 S7 就是这样处理的,归 0 总费用 6102786.1 万元,强制 500 总费用 6102797.1 万元,选便宜的。后续我遇到类似最低起订量的约束都这个套路,先连续解再人工修正。
5.2 表一纯运费与 C 矩阵含销价混淆
现象:手算校核时发现用表一的数据代入目标函数,总费用对不上论文结果。
原因:表一的“最小费用”是 DFS 求出的纯运输费,不含出厂销价;附录代码里的 C 矩阵是运输费 + 销价的合计。比如 S7 到 A15 表一写 2,实际 C(7,15) 应为 162。
解决:读论文时先确认每张表的统计口径。凡是用 C 矩阵算总费用,必须加上各厂销价;凡是和表一直接比较,只能看运输成本差异。我建议拿到这种资源后,把所有表头和数据复制到 Excel 里,逐列核对公式再往下走。
5.3 铺设费的等差数列公式把重复路段算了两遍
现象:自己建模时把“站点到铺设地点的运输费”理解成每个站点到管道两端所有点的距离之和,结果铺设费比论文结果大一倍。
原因:管道全线的每一段,只会被从它旁边那个站点运过去的钢管覆盖一次。如果第 j 站向右铺 rl(j),那么这段管道上的钢管是从 j 站运出的;下一段由 A_{j+1} 向左铺设,钢管从 A_{j+1} 运出。中间不会有一段管道同时被两个站点服务。
解决:严格按照“向右铺设量 rl(j) + 向左铺设量(L(j-1)-rl(j-1)) = 站点流入量”的等式建模,向右和向左两部分各算各的等差数列,不重叠。
5.4 不足整公里按整公里计算的取整时机
现象:fmincon 返回的订购量出现 1190.5 这类小数,直接四舍五入后回代目标函数,总费用和论文有差。
原因:模型的连续解假设钢管可以无限细分,但题目明确不足整公里按整公里计算,实际铺设量必须是整数。
解决:取整后再回代验算。论文表四里 5S 订购量取 1190、6S 取 1181,就是从 1190.5 和 1180.5 取整来的。我会在代码里加一行 ceil 或 round 后重新算一遍目标函数,确保最终报告里的总费用与订购方案一致。
5.5 灵敏度分析盲目覆盖所有钢厂
现象:对七家钢厂做产量上限灵敏度,发现 S4、S5、S6、S7 的灵敏度全是 0,不知道该怎么解释。
原因:S4 和 S7 本身就不参与采购(N=0),上限变化当然不影响结果;S5、S6 的产量离上限还很远,上限微调同样不影响解。真正有约束力的只有 S1、S2、S3 三家。
解决:灵敏度分析前先看解的结构。哪个厂产量顶到上限,哪个厂价格对成本占比大,才有分析价值。论文结论里“S1 生产上限变化影响最大、S6 销价变化影响最大”就是在排除无效对象后得出的。
5.6 fmincon 全 0 初值收敛不稳定
现象:换一台电脑或换一个 MATLAB 版本,跑出来的订购方案会偶尔不一样。
原因:fmincon 的 active-set 算法是局部搜索算法,初值不同会落到不同局部最优解。全 0 初值虽然可行,但不保证全局最优。
解决:我的习惯是用多个初值跑几遍,比如把每个钢厂初始产量设为 500 或上限的一半,再比较各个结果的总费用,取最小且约束满足的那个。这道题规模小,多跑几遍成本很低。
6. 从线形到树形再到 n 维网络:模型推广与一个验证技巧
6.1 问题 3 的树形推广具体怎么改
问题三把管道从一条线变成一个树形网络,站点从 15 个变成 21 个(A1 到 A21),部分站点要向三个方向铺设。核心改动只有两处:一是决策变量从 8×15 变成 10×21,前 7 行还是运量,第 8 行、第 9 行、第 10 行分别记录每个站点向三个方向铺设的量;二是目标函数里的铺设费用部分,要把每个分支节点的三方向等差数列都算进去。
% 问题三目标函数:三个方向铺设费 function f = myfun2(XX, C, N) x = XX(1:7, :); % 7x21 运量 rl = XX(8:10, :); % 3x21 三个方向铺设量 f = 0; % 干线购买与运输费用 for i = 1:7 for j = 1:21 f = f + N(i) * x(i, j) * C(i, j); end end % 树形关键分支节点的三方向铺设费 for j = 2:14 f = f + 0.1 * (rl(1, j) * (rl(1, j) + 1) / 2 + ... rl(2, j) * (rl(2, j) + 1) / 2); end % 节点19和20是树形分支点,需单独处理第三方向 f = f + 0.1 * (rl(1, 19) * (rl(1, 19) + 1) / 2 + ... rl(2, 19) * (rl(2, 19) + 1) / 2); f = f + 0.1 * (rl(1, 20) * (rl(1, 20) + 1) / 2 + ... rl(2, 20) * (rl(2, 20) + 1) / 2); end约束条件也要跟着改,核心思路不变:每个站点的总流入量等于它向各方向铺设量之和,树形分支点有几个方向就列几项。论文问题三求得的总费用是 6104148.1 万元,订购量 S5 增加到 1450、S6 增加到 1853。这套思路往后推到更复杂的网状图也可以,只要图连通且能列出站点间距离,DFS 找路径、fmincon 求运量的大框架完全不变。
6.2 一个验证技巧:拿取整结果做一次全量回代
我拆这类数模论文有个固定动作:把拿到的订购方案当成已知量,自己重新算一遍总费用。具体来说是三步:第一步,把表四里每家厂的订购量按站点拆回去,算出购买费和干线运输费;第二步,根据每个站点的向右铺设量,用等差数列公式算铺设费;第三步,三项相加,看是否等于论文给出的总费用。这个验证不需要跑任何优化,几行 MATLAB 就能完成。
% 全量回代验证 x_plan = [...]; % 表四的 7x15 订购方案 rl_plan = [...]; % 各站点向右铺设量 total = 0; for i = 1:7 for j = 1:15 total = total + N(i) * x_plan(i, j) * C(i, j); end end for j = 1:14 rj = rl_plan(j); lj = L(j) - rj; total = total + 0.1 * (rj * (rj + 1) / 2 + lj * (lj + 1) / 2); end disp(total);这个数如果和论文一致,说明模型和方案都读懂了;如果对不上,九成是表一和 C 矩阵口径混了,或者铺设费等差数列写错。从那以后我每拿到一份数模代码,都强制自己先把结果回代一遍,确认费用于是对得上再往下改模型,这个习惯帮我避开过不少别人论文里的笔误和单位坑,希望帮到你。
本文还有配套的精品资源,点击获取