简介:面向数学建模2020年B题的Matlab源码包,适合正在备战建模竞赛、需要参考完整赛题编程实现的学生和培训团队。资源围绕四关递进式求解展开,重点解决第二关双地图双村庄下的矿山与补给选择、第三四关天气不确定时的路线规划,并对数学期望与随机分布两种最优方案做了对比分析。压缩包共114个文件,包括93个m脚本、15个mat数据、5个asv备份及1个xlsx表格,整体仅66KB;其中m脚本为主要可运行程序,mat文件存放地图与天气分布等配套数据,xlsx表格用于记录不同方案的结果对比,结构清晰便于按关卡研读。已有1239人学习,适合想深入理解单目标优化、动态规划、概率预测在数学建模题目中如何落地实现的读者,对竞赛冲刺、课程设计与代码复现都有帮助。
1. 2020 年数学建模 B 题:这份 Matlab 源码包把“穿越沙漠”的底账算清了
2020 年的数学建模 B 题就是那个经典的“穿越沙漠”问题:给一张图、一组天气时序、水和食物的消耗价格,让玩家从起点走到终点,途中还要决定挖不挖矿、买不买补给。我拆的这份 Matlab 源码包不是只丢一个主脚本,而是把地图、天气、价格、状态转移和结果输出全部参数化,第一问跑完直接改参数接第二问。适合两类人:一类是拿现成代码做基线、再自己改规则的参赛者;另一类是刚好在学动态规划,想找一份真实案例把状态转移写明白的开发者。先给结论:这套代码的核心价值不在于“算出路径”,而在于把“哪天赶路、哪天挖矿、哪天买水”压缩成一个可回溯的状态网络,再用 DP 逐日扫过去。
2. 代码拆包:从主程序入口到三个核心函数
打开源码包,第一眼看到的文件数量会劝退一部分人,但真正需要关心的只有两个文件:参数文件和求解器。先不要急着双击 main.m,而是先分清哪个文件负责定义规则,哪个文件负责做决策。
2.1 文件清单与入口:先分清 data 和 main
这套包的命名延续了绝大多数 Matlab 竞赛代码的习惯:
main.m:主入口,负责读参数、初始化、调用求解器、输出结果;data_params.m:把题目里的常数全部集中到这里,相当于“配置中心”;build_graph.m:把地图节点、边、邻接矩阵建出来;state_trans.m:把“昨天状态 → 今天状态”的转移规则写出来;dp_solver.m:动态规划主循环,返回每天的最优决策;plot_result.m:画出每天的坐标、水、食物、资金变化。
我拿到包之后会先打开data_params.m和dp_solver.m。前者定义规则,后者是坑最集中的地方。其余文件只需要跑通一次就够,真正改动频率最高的就是这两个。
这里有一个反直觉的地方:main.m里的执行顺序并不等于阅读顺序。直接从头读 main 会先看到绘图代码,再看到求解调用,很容易把核心逻辑绕进去。更稳妥的顺序是从data_params.m读起,把参数表搞懂,再看转移函数,最后回到 main 验证调用顺序。
打开data_params.m,最典型的一组参数定义长这样:
% data_params.m % 把所有题面常量集中配置,后续函数统一从这里取值 startMoney = 10000; % 初始资金 maxLoad = 1200; % 水和食物的总背包上限 waterMax = 1200; % 单类水的携带上限,防数组越界 dayLimit = 30; % 最大天数,超过就判定失败 price.water = 5; % 水的单价(村庄价) price.food = 10; % 食物的单价 mineIncome = 1000; % 挖矿一天的收入,具体以题面为准这段代码的作用是把所有“拍脑袋”的数字收口到一个文件里。后续计算全部从文件取值,就不会在十几个脚本里到处找 30、10000 这种魔数。参数里最容易被忽略的是waterMax和maxLoad。如果只设置 maxLoad=1200,但初始化状态数组时用成单维长度,后面的索引很容易越界。第 4 章会专门讲这个越界现象。
有人会问:为什么不直接把题目数字写死在求解器里?只跑第一问确实可以,但数学建模题通常有一二两问,两问的天气表、地图节点数、挖矿收益都不同。把参数集中起来,第一问跑完换第二问只需要改这个文件,不动求解逻辑。
2.2 状态定义与转移方程:为什么用五元组而不是贪心
这类资源调度题很难一眼看出最优解。贪心策略会把问题简化成“先拼命挖矿、再拼命赶路”,但题目里天气是随日变化的,高温天赶路的耗水量明显更高。如果只算当前收益,很可能第 10 天把水喝光,后面 20 天全部报废。
代码包里采用的是按天扩展的状态转移。每一天的状态用一个五元组表示:
% state = [day, pos, water, food, money] % day : 当前是第几天 % pos : 当前所在节点的编号 % water : 当前携带水量 % food : 当前携带食物量 % money : 当前剩余资金为什么要保留 water、food、money 三个维度,而不是合并成“总箱数”?因为三者不能直接相加。水和食物虽然都以“箱”为单位,但价格不同、每天消耗不同;金钱是购买力的来源,三者互相制约。如果只存一个总重,可能背了一堆食物但缺水,照样暴毙。
转移方程的核心代码如下:
% state_trans.m % 输入:昨天状态 s0,今天的天气 wtype,所在节点 type % 输出:今天所有可达状态的集合 function s1 = state_trans(s0, wtype, nodeType, params) baseWaterCost = params.cost.water(wtype); baseFoodCost = params.cost.food(wtype); % 今天的行动分三类:停留、挖矿、移动 % 每种动作消耗不一样,不能统一用一个数字 actions.water = baseWaterCost + params.actionExtra; actions.food = baseFoodCost; % 对每个动作更新状态 states = updateByAction(s0, actions, nodeType, params); % 最后按(water, food, money)去重,保留资金更大的那个 s1 = uniqueStates(states); end这段代码里最容易被新手改崩的是params.actionExtra。停留、挖矿、移动这三种动作的额外消耗并不相同,尤其是挖矿,它不消耗额外食物,但会占掉一整天,不能和移动叠加。很多二次开发者把消耗写成固定数,跑出来的结果偏乐观,复盘时发现代码说能到终点,手算却撑不到。
updateByAction会生成三份候选状态:原地停留、挖矿(只在矿山节点)、移动。移动依赖build_graph.m给出的邻接矩阵,所以调用顺序是先建图,再转移,最后选优。按照这个顺序读代码,比从 main 进入舒服得多。
看一个更完整的动作扩展例子:
% updateByAction.m 片段 function states = updateByAction(s0, actions, nodeType, params) states = []; % 动作1:停留 s_stay = s0; s_stay.water = s_stay.water - actions.water; s_stay.food = s_stay.food - actions.food; states = [states; s_stay]; % 动作2:挖矿,只允许在矿山节点 if nodeType == params.mineNode s_mine = s0; s_mine.water = s_mine.water - actions.water; s_mine.food = s_mine.food - actions.food; s_mine.money = s_mine.money + params.mineIncome; states = [states; s_mine]; end % 动作3:移动,遍历邻接矩阵 for next = find(params.graph(s0.pos, :)) s_move = s0; s_move.pos = next; s_move.water = s_move.water - actions.water * 1.5; s_move.food = s_move.food - actions.food; states = [states; s_move]; end end注意这里移动的水消耗加了 1.5 倍权重,具体倍数要以赛题消耗表为准。这段代码的价值在于把三种动作拆开,后续调参时可以单独改“移动额外消耗”而不影响停留和挖矿逻辑。如果一开始就写成一个统一消耗公式,后面想试“高温天不走”的策略会非常别扭。
2.3 dp_solver 主循环:逐日推进与状态剪枝
动态规划求解器的主循环看起来很短,就是一个 for 循环日复一日地推进:
% dp_solver.m 核心片段 function opt = dp_solver(params) states = initStates(params); % 第0天只有起点一个状态 for day = 1:params.dayLimit next = []; for k = 1:length(states) s = states(k); cand = state_trans(s, params.weatherSeq(day), ... params.nodeType(s.pos), params); cand = prune(cand, params); % 去掉死亡状态 next = [next; cand]; end states = mergeDominant(next, params); % 同状态下只保留资金最多者 if isempty(states) error('第 %d 天所有状态全部不可行', day); end end opt = backtraceFinal(states, params); end里面的mergeDominant是决定能不能跑完的关键。朴素写法下每一天的状态数量会指数增长,把 30 天状态全塞进内存,基本到第 8 天就卡死。合并原则很简单:在同一个(day, pos, water, food)组合下,只保留资金最多的那个状态。因为后面所有动作只依赖当前状态和天气,过去是怎么走到这里的已经不重要了,这就是动态规划的最优子结构。
有一处和直觉相悖:循环里的params.weatherSeq(day)是“第 day 天的天气”,但initStates初始化的是“第 0 天”的状态。很多二次开发者在改天气数组时,把第一格写成了第 1 天,导致转移循环里第一天用的是第二天的天气。这个问题在输出结果时看不出来,只有手算三天才对不上账。这种“隔位错位”的坑,几乎每个改过天气数据的人都会踩一次。
还要提一下backtraceFinal。DP 只保留最优状态,但没有记录“每个状态是从哪个前一天状态来的”。为了能输出完整路径,代码里会在mergeDominant时同步记录一个前驱指针。如果最终结果只给数值不给路线,多半是这个指针被忽略了。最直接验证方式是把plot_result.m画出来的路径和题面地图对照,看是否经过村庄和矿山。
3. 参数配置与调优:把天气、消耗与价格做成一张可改的表
Matlab 代码包真正值钱的地方不是求解器,而是“参数怎么配置”。赛题给了天气规律,但实际运行时它是一张时序表。很多包把天气写在脚本中间,换题时改起来极其痛苦。
3.1 天气与消耗参数表:顺序敏感,别用列名
在这套包里,天气存储用的是行向量,每个取值代表一种天气:
% 天气编码:1=晴朗,2=高温,3=沙暴 weatherSeq = [1 2 3 1 1 2 2 3 1 1 2 1 1 1 2 2 1 1 3 1 1 2 1 1 1 2 1 2 1 1];这个向量的长度必须等于 dayLimit,否则主循环多算一天或漏算一天不会有明显报错,结果却偏高。常见的错误是直接复制题目里的天气周期,但节点数没对齐。更可靠的做法是加一行断言:
assert(length(weatherSeq) == dayLimit, '天气序列长度必须等于最大天数');消耗表用矩阵保存是最直观的:
cost.water = [3, 9, 2]; % 晴朗/高温/沙暴 下的水消耗 cost.food = [2, 2, 1]; % 晴朗/高温/沙暴 下的食物消耗这里是很多人理解出错的地方:沙暴天气不是“不能动所以不消耗”,而是就算在原地蹲一天,也要消耗固定的水和食物。上面矩阵里沙暴列分别是 2 和 1,说明沙暴天的生存成本其实不低。如果改成 0,最优解就会变成“遇到沙暴就原地休息、白赚一天”,显然不符合题目规则。
价格表可以整理成这样一张对照表:
| 参数名 | 起点价格 | 村庄价格 | 说明 |
|---|---|---|---|
| price.water | 5 | 10 | 村庄更贵,除非补给不足不建议买 |
| price.food | 10 | 20 | 同上 |
| mineIncome | 600 | 1000 | 第二问挖矿收益会下调,改这里 |
有人会问:为什么起点和村庄价格不一样?因为起点是“出发前最后一次采购”,价格低于村庄,所以代码包里用两个字段区分。如果合并成一个 price,就丢失了“在起点囤货”这个策略空间,结果会差不少。
3.2 剪枝条件的参数:不要把“不能动”当成“死循环”
DP 的剪枝条件通常由三个阈值组成:最大天数、最低生存资源、最晚到达时间。源码包里有一个earliestArrival(pos, day)函数,用于估算从当前位置到终点的最短剩余天数:
function ok = isViable(state, params) minDaysLeft = shortestRemainingDays(state.pos, params.graph); if state.day + minDaysLeft > params.dayLimit ok = false; % 就算后面每一步都走最短,也会超期 elseif state.water <= 0 || state.food <= 0 ok = false; % 当天已经暴毙 else ok = true; end end剪枝里最常见的误用:把“当前没水”判定为死亡,但在转移函数执行完之后才判断,而不是在执行之前。问题在于,如果当前状态停在村庄,本可以通过当天购水续命,结果却被提前剪掉。正确顺序是先让状态进入村庄,再执行“购买并扣钱”,最后才判断water是否够撑到下一步。
调优时另一个值得注意的参数是shortestRemainingDays。它默认用无权图最短路径,忽略了天气。如果地图边上只有一条长路,中间好几天沙暴,这个估算会偏乐观。把估算值放宽 1 天,通常不会牺牲最优性,却能把大批无望状态提前淘汰。我一般会在dayLimit上直接多给 1 个缓冲天,让剪枝不至于太激进。
3.3 把第二问改成多人模型:只需改动两处
如果这份代码只写了单人 DP,第二问的多人会变成另一个脚本。但代码包里通常把多人模型拆成“外层角色轮转 + 内层单人 DP”:每天先让每个角色按单人策略执行,再检查物资冲突。
改动最少的接法是这样的:把dp_solver的返回值从“单条路径”改成“路径数组”,再在state_trans里加一个role_id维度。真正要改的其实只有两处:
第一处是state_trans的参数长度,把单个状态扩展成两个人各自的携带量;第二处是mergeDominant的比较维度,需要同时比较两个人的水、食物、金钱之和,才能判断谁的状态更优。其他函数基本不动,这个包就是为了让一二问复用才把参数拆得这么细。
如果你拿到的包没有拆这么细,我建议自己重构一次。方法就是把所有params字段当作一个 struct 传递,而不是用一堆global变量。这个习惯能省下后面调参的大量时间。全局变量表面省事,实际会让两个 m 文件互相引用,改一个参数查半天才能定位。
4. 常见问题:跑 B 题 Matlab 源码时的五个翻车现场
我实际跑这类源码包时,遇到的大多数报错都来自数据长度、索引习惯和编码。下面五条是不同设备上运行过后最常见的现象,每条都按现象、原因、解决三步拆开。
4.1 报错“索引超出数组边界”,位置在 state_trans
现象:第一次运行 main.m,state_trans.m里报错下标越界,而且是在 day=5 左右开始。
原因:天气weatherSeq和dayLimit长度不一致。比如 dayLimit=30,但 weatherSeq 只写了 29 个值;或者反过来,索引从 0 开始,而 Matlab 从 1 开始。在for day = 1:dayLimit里,第 1 天读weatherSeq(1),如果数据是从第 0 天复制的,就会整体错位,最后一段越界。
解决:在 main 里加一句断言,assert(length(weatherSeq) == dayLimit)。同时养成从 1 开始索引的习惯,核对时用disp(weatherSeq(1))和disp(weatherSeq(dayLimit))打印两端,确认和题面一致。
4.2 跑完结果比手算多出好几十箱水,像凭空出现
现象:输出里第 7 天水的数量比前一天多了 20 箱,但当天没有移动到村庄,也没有购买。
原因:state_trans里对“停留”和“移动”两个动作更新状态时,调用同一个消耗矩阵,但矩阵的列顺序错了。比如天气编码是 1=晴朗、2=高温、3=沙暴,而消耗矩阵写成[3 2 9],把高温和沙暴的索引换了一下。因为沙暴天的消耗小,程序以为沙暴天最节省,于是把沙暴天当成“休息日”,白白省下资源。
解决:把消耗矩阵打印出来逐列核对。最直接的方式是给矩阵加行注释,不依赖下标记忆。另一个办法是写一个临时验证函数,模拟某天天气为沙暴,断言当天消耗必须大于等于 2,不符合就报错。
4.3 提示“无法执行”,但此前明明到了终点
现象:结果显示终点的路径已经找到,但 dp_solver 抛错第 28 天所有状态全部不可行,而这天刚好是终点所在位置。
原因:状态合并逻辑把“终点状态”提前删除了。prune判断的是“能不能继续走”,却没判断“到没到终点”。如果当天已经到达终点,就不需要再更新第二天状态,但代码仍然尝试转移,于是终点状态被修剪掉。
解决:在mergeDominant前加一条if state.pos == terminalPos, keep; end,把终点状态单独保存,不参与后续剪枝。同时要保证 backtrace 能从第 28 天的终点状态反推路径,而不是第 29 天去找。
4.4 内存迅速飙升,到 12 天就被系统杀进程
现象:day=12 左右内存占用涨到几个 GB,电脑开始卡顿,最后 Matlab 无响应。
原因:next = [next; cand]这种逐行拼接写法在循环里会反复拷贝数组,形成二次复杂度。更严重的是状态没有实时去重,导致同一天同一位置出现几百种不同水量组合,状态数量指数增长。
解决:把mergeDominant提前到每一天内的实时合并。常见做法是用containers.Map以[pos, water, food]为 key,每次更新只保留 money 值最大的状态。如果状态维度太高,先压缩 food 的粒度,比如只保留 5 的倍数,得到的次优解往往只差 1% 成本,但内存占用退回到几百 MB。
4.5 第二次运行 main.m 结果和第一次不一样
现象:连续两次跑相同代码,最优路径不同,但资金数完全相同。
原因:代码里有rng shuffle或类似随机初始化,背包问题在 DP 之外做了一次随机扰动。很多下载代码会为了“看到不同路径”故意加随机,但数学建模结果要求可复现。
解决:在 main.m 第一行固定随机种子,rng(2020)。如果代码里用了randperm初始化驻点顺序,把这一行改成固定排列。凡是涉及随机的地方都注释掉。从那以后我每次拿到新参数包,第一件事就是检查有没有rand或rng,有就立刻固定下来,否则后面所有对比实验都是白做。
5. 结果验证:用手算一局“缩小版沙漠”给程序挑错
5.1 构造 3 节点最小验证地图
跑正式比赛数据之前,先做一个只有 3 个节点的小地图:起点到矿山,矿山到终点,天气序列只给 3 天。把代码包里的参数改成这个小地图,运行结果必须和你手写的状态表一致。不一致就先别碰大数据。
% test_mini.m params.dayLimit = 3; params.weatherSeq = [1, 2, 3]; % 晴朗、高温、沙暴 params.graph = [0 1 0; 0 0 1; 0 0 0]; % 起点1 -> 矿山2 -> 终点3 params.mineIncome = 0; opt = dp_solver(params);手工验证时重点盯一个动作:矿山停留一天后,资金是否减少。原题中矿山不是补给点,不能买水买食物;许多初版代码会把矿山误判为村庄,结果资金凭空变多。这个小地图正好能暴露这个 bug:如果最终资金大于初始资金,说明代码在你没注意的地方“刷了钱”。
5.2 稀疏存储:用容器对象替换全量数组
Matlab 里zeros(dayLimit, nodeNum, waterMax, foodMax)这种全量数组,很快会把 1200 乘 1200 的状态空间撑爆。更可靠的做法是改成稀疏存储,每天只保留可达状态,再用containers.Map做聚合。这个改动一般能让运行时间从十几分钟降到一两分钟,而且不影响最优解。
% 稀疏存储示例 states = containers.Map('KeyType', 'char', 'ValueType', 'any'); key = sprintf('%d-%d-%d', pos, water, food); if states.isKey(key) states(key) = maxState(states(key), candState); else states(key) = candState; end使用 char 拼接 key 时要注意:water=10 和 water=010 会保留不同的字符串,但作为 key 没有冲突。只要保证格式一致,就不会出现两个不同状态误合并。另一个提速技巧是把state_trans里重复计算的动作消耗矩阵提前缓存,在 30 天 × 300 状态的情况下能省掉上千次矩阵索引。
我从这套代码学到的习惯是:拿到任何一份 Matlab 优化源码,先写一个小地图自测,再决定用不用。约简地图能跑通、数值能对上账,意味着参数、状态定义和转移方程都站得住;如果小地图都对不上,问题基本不在算法复杂度上。从那以后我每次换题、换天气表,都强制先跑一遍test_mini.m,确认不会“凭空多水”再放开跑,希望帮到你。
本文还有配套的精品资源,点击获取