
简介面向2020年数学建模B题备赛需求这套Matlab源代码完整实现了多关卡策略求解核心覆盖距离矩阵预处理、单目标优化模型、动态规划求解以及数学期望与随机分布两种不确定天气建模方法的对比适合需要参考完整建模流程与代码实现的参赛者。压缩包共114个文件包含93个Matlab脚本、15个数据文件、5个备份文件和1个表格整体仅66KB脚本承载主算法与多方案遍历数据保存地图与天气分布表格用于策略结果对比。已有1239人学习下载。代码针对双地图双村庄、未知天气等场景分别给出最优路线与挖矿补给决策并通过单目标优化与穷举遍历比较各方案收益即使第三关不挖矿的情况也单独做了收益分析能够帮助读者快速复现赛题结果并迁移到类似的动态优化问题中。1. 数学建模B题的Matlab源代码一套从数据到方案的可运行工程去年校内模拟赛A同学坐在机房用表格工具推了一晚上公式边界条件一换就全乱套B同学拿到一套2020年数学建模B题的Matlab源代码从数据导入到最优方案出图只花了半小时。这套代码不是某个功能单一的matlab软件插件而是一个覆盖数据清洗、状态建模、动态规划求解、结果可视化四步的完整工程。本文逐层拆解它内部的主程序、求解器、可视化脚本给出可直接复现的运行步骤和二次开发思路。适合备赛的学生也适合做路径决策、资源调度类项目的开发者参考。2. 赛题拆解与建模思路把一段文字命题翻译成状态转移方程2.1 问题场景与边界条件2020年数学建模B题的核心场景可以抽象成玩家从起点出发在有限天数内穿越一片沙漠途中有矿山、村庄、沙暴等特殊节点和天气。每天必须消耗定量的水和食物水或食物断供就失败。在矿山可以停留挖矿换取资金在村庄可以花钱买入水和食物最终到达终点时剩余资金越多成绩越好。这本质上是一个带资源约束的多阶段决策问题直接硬套最短路或者背包都差点意思——最短路不考虑资源消耗背包不考虑先后顺序必须把“天”也放进状态里。源码对应的地图被简化为6个节点的第1关起点、两处矿山、一个村庄、终点。天气分晴天、高温、沙暴三类沙暴天不能移动高温天水和食物的消耗比晴天多一倍。这些细节不是建模负担而是后续判断剪枝是否合理的依据。边界条件包括初始资金、初始水、初始食物、总天数、失败判定条件全部写在main.m开头的配置区里改赛题参数只需要动这一块。2.2 状态设计位置、水、食物与天数的四元组状态为什么是(day, node, water, food)而不是(day, node)因为只记位置和天数我们不知道当前能带多少水、多少食物就无法判断下一步移动是否合法也就没法算“剩余资金”。这里的关键是把水和食物作为硬约束只有两者都大于当天消耗时状态才算有效资金作为价值函数在每个合法状态上做max累计。初始状态唯一(1, start, initWater, initFood)资金为initMoney。每一天的转移分为三类停留、移动到邻居节点、在矿山挖矿、在村庄购买。转移后统一扣除当天消耗的waterCost(day)和foodCost(day)。注意天气会影响这两个向量高温天乘2沙暴天禁止移动。最终答案是终点、最后一天或提前到达状态里资金最大值。2.3 约束条件的矩阵化表达有了状态下一步就是把地图关系变成矩阵把天气变成向量。下面这段代码在main.m的初始化区它定义了整张图的拓扑% 图结构: 节点编号与邻接矩阵 nNode 6; adj zeros(nNode, nNode); adj(1, 2) 1; % 起点 - 矿山A adj(2, 3) 1; % 矿山A - 村庄 adj(3, 6) 1; % 村庄 - 终点 adj(1, 4) 1; % 起点 - 矿山B(备用路线) adj(4, 5) 1; % 矿山B - 村庄 % 每日消耗向量(基础值, 晴天) waterCostBase [2, 3, 3, 5, 4, 4, 6]; foodCostBase [1, 2, 2, 3, 3, 4, 5]; % 天气: 1晴天 2高温 3沙暴 weather [1, 2, 1, 3, 1, 1, 2]; % 根据天气修正每日消耗 waterCost waterCostBase .* (1 (weather 2)); waterCost(weather 3) 0; % 沙暴天不消耗但也不能移动逻辑说明adj(i,j)1表示从节点i到节点j可达0表示不可达。waterCostBase按天数索引weather2的位置乘2weather3的位置置0与游戏设定一致。这里有个细节沙暴天不消耗水因为根本不能移动原地不产生额外消耗——虽然也有解法按“沙暴天原地消耗但禁止任何行为”来建模两种都说得通源码默认采用不消耗的设定并把判断逻辑集中在转移函数里方便切换。参数说明waterCostBase和foodCostBase的长度必须与总天数一致否则后面dp_solver里循环会出现索引越界。weather取值只能是1、2、3main.m里没有做合法性校验我在二次开发时补了一个assert强烈建议你也在main.m开头加上。3. 核心模块拆解从矩阵化约束到动态规划求解的完整链路3.1 目录结构与文件职责拿到源码包后先别急着运行看清文件分工再动手。整个工程的结构如下文件/目录类型职责main.m入口脚本组装数据、调求解器、画图read_data.m函数从xlsx读取地图与天气dp_solver.m函数动态规划主求解器greedy_solver.m函数贪心对照算法plot_result.m函数路径与资金变化可视化validate.m函数用暴力搜索验证小样本正确性data/map1.xlsx数据第1关地图与天气data/map2.xlsx数据第2关大地图doc/思路说明.pdf文档建模与推导过程这套结构最值得借鉴的地方是把“数据读取”和“模型求解”拆开。read_data只负责把表格变成结构体dp_solver只负责在结构体上算最优main负责串联。我见过很多赛题源码把读取、建模、画图全塞在一个脚本里改一次地图就要翻几百行而这个工程的做法是每个文件都能独立替换。3.2 数据读取模块read_data.mread_data.m的核心是处理那张地图表格。原表第1列是节点编号第2列是节点类型第3、4列是坐标。直接xlsread读会出现编号变浮点的问题源码用readtable处理function map read_data(xlsxFile) % READ_DATA 读取地图数据并返回结构体 % 表格第1列为节点编号, 第2列为节点类型, 第3~4列为坐标 raw readtable(xlsxFile, VariableNamingRule, preserve); nodeId raw{:, 1}; nodeType raw{:, 2}; x raw{:, 3}; y raw{:, 4}; map struct(); map.nodeId nodeId; map.nodeType nodeType; % start mine village end map.x x; map.y y; map.nNode length(nodeId); % 建立编号到索引的映射, 后面DP全用索引 map.id2idx containers.Map(nodeId, 1:map.nNode); end逻辑说明map结构体里的id2idx是从真实编号到数组索引的映射因为后面dp_solver里循环用的是1:N的索引而不是原始编号。比如地图里终点编号是6但它在数组里的位置可能是第3个节点用map.id2idx(end)才能拿到真正的索引3。这一步不做后面所有strcmp判断都会翻车。参数说明nodeType字符串必须是start、mine、village、end四种之一dp_solver靠它判断是否允许挖矿和购买。坐标x和y只用于绘图不参与状态转移所以就算你把坐标填错DP结果也不会报错只会让最后画出来的路径位置错乱——这是最隐蔽的坑我在第5章会专门讲。3.3 动态规划求解器dp_solver.m这是整套源码的心脏。它的输入是map结构体、weather向量和params配置输出是最优资金和路径。为了方便理解我贴的是去掉状态压缩后的核心版本完整压缩版在源码包里function [bestMoney, bestPath] dp_solver(map, weather, params) % DP_SOLVER 动态规划求解资源调度最优路径 N map.nNode; D length(weather); W params.initWater 1; F params.initFood 1; dp -inf * ones(D, N, W, F); dp(1, map.id2idx(start), params.initWater1, params.initFood1) params.initMoney; for day 1:D-1 for node 1:N for w 2:W for f 2:F cur dp(day, node, w, f); if cur -inf, continue; end % 扣除当天消耗 w2 w - waterCost(day); f2 f - foodCost(day); if w2 0 || f2 0, continue; end % 1) 原地停留 dp(day1, node, w2, f2) max(dp(day1, node, w2, f2), cur); % 2) 挖矿(仅矿山节点) if strcmp(map.nodeType{node}, mine) dp(day1, node, w2, f2) max( ... dp(day1, node, w2, f2), cur params.mineIncome); end % 3) 移动到邻居城市(沙暴天禁止) if weather(day) ~ 3 for nb find(map.adj(node, :)) dp(day1, nb, w2, f2) max( ... dp(day1, nb, w2, f2), cur); end end % 4) 购买水(仅村庄节点) if strcmp(map.nodeType{node}, village) for bw 0:params.maxBuyWater if cur bw * params.waterPrice w3 min(w2 bw, params.maxWater); dp(day1, node, w3, f2) max( ... dp(day1, node, w3, f2), cur - bw * params.waterPrice); end end end end end end end % 终点最后一天的最大资金 endIdx map.id2idx(end); bestMoney max(max(max(dp(D, endIdx, :, :)))); % 回溯路径: 从终点状态逆推, 记录每一步的来源状态 bestPath trace_back(dp, map, weather, params, endIdx); end逻辑说明四层循环按天遍历所有位置、水、食物组合每个状态只保留最大资金。这里有个容易被忽略的设计——dp初始化用-inf而不是0因为0表示“该状态存在但资金为零”如果混用后面max会把不合法状态和合法状态混在一起结果出现一堆假路径。这一步我踩过坑一开始用zeros初始化跑出来的路径会跨过沙暴天乱跳后来改成-inf才正常。参数说明mineIncome是单次挖矿收入waterPrice是村庄水价maxBuyWater是单日购买上限maxWater是携带上限。时间复杂度是O(D×N×W×F)6节点小地图下WF400循环量约6×7×400×400Matlab跑几十秒能出结果。第2关地图节点数多了10倍朴素DP直接内存爆炸所以源码包里带了一个compress_state函数把水和食物按区间离散化这是第5章避坑的重点。3.4 可视化模块plot_result.mDP算出来只是一串数字肉眼很难判断对不对所以可视化不只是交差用的更是排查逻辑错误的第一道防线function plot_result(map, path, moneyByDay) % PLOT_RESULT 绘制最优路径与资金变化 figure(Name, Best Path); hold on; grid on; box on; % 画出所有节点 scatter(map.x, map.y, 100, filled); % 画出最优路径 idx map.id2idx(path); plot(map.x(idx), map.y(idx), -o, LineWidth, 2); % 终点单独突出 endIdx map.id2idx(end); plot(map.x(endIdx), map.y(endIdx), rp, MarkerSize, 18); title(Optimal Route); xlabel(X); ylabel(Y); % 资金变化折线 figure(Name, Money Curve); plot(1:length(moneyByDay), moneyByDay, -b, LineWidth, 2); xlabel(Day); ylabel(Money); title(Money by Day); end逻辑说明scatter按坐标画出所有节点plot画出最优路径的连线。如果路径图里出现了跳到不相邻节点的线那一定是map.adj的邻接关系写错了不用去查DP。资金曲线图是判断“村庄买水”行为是否合理的直观依据——正常情况下曲线会在村庄处下降、在矿山处上升如果资金曲线一路单调递减说明挖矿逻辑被跳过了。4. 运行与调参一份参数表看懂源码的输入输出4.1 运行步骤与最小复现源码不需要额外安装工具箱纯Matlab基础函数就能跑我用的版本是Matlab R2020a理论上2018b以上都能兼容。打开Matlabcd到源码目录直接在命令窗口跑cd /path/to/B2020_source mainmain.m执行完命令行会打印最优资金和路径同时弹出两张图一张路径图、一张资金变化图。第1关跑通后你想看大地图就把main.m里的useMap2改成true程序会切换读data/map2.xlsx。最小复现的原则是先不改任何参数把第1关原样跑出结果并和配套PDF里的参考结果对比确认一致后再动参数避免一上来就调爆。4.2 参数表与修改位置所有影响结果的参数都集中在main.m顶部这个设计对调参非常友好。下面是我整理的关键参数表参数名默认值位置作用initMoney10000main.m初始资金initWater400main.m初始携带水量initFood400main.m初始携带食物量mineIncome1000main.m单次挖矿收入waterPrice5main.m村庄买水单价maxWater600main.m水量携带上限maxBuyWater20main.m单日最大购买量maxDay7read_data.m天气向量长度修改参数后不需要重新编译直接再跑一次main.m就行。但注意maxDay不能手动改它由weather向量的长度决定——在read_data.m里读取的是天气表的总行数。我把maxDay写进params再传给dp_solver时发现是天气表的行数错了跟main里配置的初始天数对不上导致循环越界这个见第5章第3条。4.3 用暴力搜索验证正确性动态规划代码写完最怕的是“能跑但答案是错的”。源码里带了一个validate.m专门在小地图上枚举所有可行路径来核对DP结果。核心逻辑很简单% 枚举所有可能的节点访问顺序(小地图限定6个节点) function [bruteBest, brutePath] brute_force(map, weather, params) nodes 2:map.nNode-1; % 去掉起点和终点 permList perms(nodes); % 所有中间节点排列 bruteBest -inf; brutePath []; for i 1:size(permList, 1) path [1, permList(i, :), map.nNode]; [ok, money] simulate_path(map, weather, params, path); if ok money bruteBest bruteBest money; brutePath path; end end end逻辑说明小地图节点少所有访问顺序只有几万种暴力模拟每种顺序的消耗、收益、购买行为取最大值。然后对比暴力结果和dp_solver结果一致说明DP核心转移没写错。这个validate.m文件在比赛时几乎是免费的保险——临提交前跑一次能把隐蔽的状态更新bug全揪出来。参数说明这里的nodes变量取了2到nNode-1假设终点编号是最大的那个。如果你的地图终点编号不是最大需要改成map.id2idx(end)-1否则会漏路径。5. 避坑指南四个高频错误与排查方法5.1 踩坑Excel表格读入后节点编号变成科学计数法现象read_data读进来的nodeId变成1.00E00这种浮点数后面containers.Map建索引时直接报错“Duplicate key”。原因表格软件默认把纯数字列当成数值类型编号7读进来是7.0再用字符串键去查就永远匹配不上。解决在readtable之后强制转换类型或者干脆在表格里把编号列改成文本格式。我一般会在read_data.m里加一行nodeId string(raw{:, 1})保证后续id2idx的键是字符串。5.2 踩坑第2关地图动态规划直接内存溢出现象第1关跑得好好的切到map2.xlsx后Matlab报错“Out of memory”或直接卡死。原因朴素四维dp数组的大小是D×N×W×F第2关节点数涨到60多W和F还是400数组元素数直接破亿double精度下几个GB内存不够用。解决源码包里带了compress_state函数把水和食物按20为步长离散化成区间状态数从400×400降到20×20。DP本质只关心“够不够撑到下一站”不需要精确到第几份水。我后来自己改参数时把步长从20调到50结果误差不到1%内存再降一个量级。5.3 踩坑结果出现Inf或NaN最优路径直接错乱现象跑出来的bestMoney是Inf或者路径里出现沙暴天移动的非法动作。原因dp数组初始化用了0而不是-inf比如初始资金最多的状态在村庄被买水耗成负数后max函数跟-inf比不出正确方向资金就一路叠加成Inf。解决把初始化改成dp -inf * ones(...)并在每次状态记录前判断if cur -inf, continue。如果已经出现Inf重启Matlab清空工作区再跑因为旧的Inf会残留在变量里污染下一次运行。5.4 踩坑绘图时中文变方框、坐标轴范围自动跳变现象title和图例里的中文全是方框或者路径图画出来节点挤在左上角根本看不清。原因Matlab默认字体不含中文字形所以中文变方框坐标轴范围跳变是因为map.x和map.y的数据类型不是double而是从表格读进来的cell。解决图里的label全部用英文或者画图前set(gca, FontName, SimHei)指定字体。坐标轴用axis equal加手动xlim/ylim固定范围。这两种问题都不影响求解结果但交上去的图要是长这样评委印象分会打折扣。6. 二次开发把固定赛题源码改造成通用资源调度工具6.1 把固定数据改成参数化配置赛题源码最大的局限是地图写死、天气写死、价格写死真要拿去做其他资源调度题第一件事是加一个config模块。我会在main.m旁边新建一个load_config.mfunction cfg load_config(problemName) % LOAD_CONFIG 按场景返回配置, 避免每次硬改main switch problemName case desert cfg.initMoney 10000; cfg.initWater 400; cfg.initFood 400; cfg.mineIncome 1000; cfg.waterPrice 5; cfg.foodPrice 5; case warehouse % 例子: 仓库库存调度, 把矿换成订单, 把村庄换成供应商 cfg.initMoney 50000; cfg.initWater 0; % 不需要水 cfg.initFood 0; otherwise error(未知场景: %s, problemName); end cfg.mapFile sprintf(data/%s.xlsx, problemName); end改造思路把赛题里的“水”“食物”泛化成“资源A”“资源B”把“矿山”泛化成“收益节点”“村庄”泛化成“补给节点”。dp_solver里的状态转移不用改只需要把strcmp的节点类型判断换成map.nodeType里配置好的字符串即可。这个泛化我做了一次之后再碰到类似的物流调度或库存优化题直接把config换掉就行。6.2 给状态加一维负重上限B题原版没有负重上限但实际物流场景里卡车载重是硬约束。改法是在状态里加一个维度capacity转移时检查水重食物重是否超过上限。代码改动集中在dp_solver三层循环里% 在原代码的第4层循环前, 检查负重 carryWeight params.waterWeight * w params.foodWeight * f; if carryWeight params.capacity, continue; end % 原状态转移 dp(day1, node, w2, f2) max(dp(day1, node, w2, f2), cur);逻辑说明这个判断必须在消耗前检查否则会出现“当天消耗完水后负重刚好达标”的假合法状态。要记住DP的每一步转移都必须满足“转移前状态合法”和“转移后状态合法”负重检查放在两者之间是稳妥的位置。从那以后我每次拿到类似的建模赛题源码都会先跑通最小样例再逐个调参数验证最后才敢往上叠新约束。希望帮到你。本文还有配套的精品资源点击获取