ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Matlab实现2020年数学建模B题:剪枝DFS求解资源调度优化问题

Matlab实现2020年数学建模B题:剪枝DFS求解资源调度优化问题

简介:2020年全国大学生数学建模竞赛B题(穿越沙漠)Matlab源代码,面向参赛学生、建模爱好者及指导老师。代码由作者在集中训练中花费4天完成,围绕“剩余资金最大化”目标,完整实现了距离矩阵预处理、单目标优化模型、动态规划、数学期望与随机分布对比等核心算法。资源按关卡组织,逐层递进:基础关解决矿山与村庄选择(含双地图双村庄复杂场景),后续关卡引入未知天气概率预测与沙暴风险权衡,并提供基础遍历方案与高阶概率模型双版本,便于对照理解多阶段决策问题的建模与编程实现。每个关卡的主程序与辅助函数独立成文件,函数分层清晰,可直接替换参数复用于同类赛题。包内共114个文件,以93个m脚本为主体,配合15个mat数据文件、5个asv备份文件及1个xlsx表格,整体仅66KB,轻量易用,可在Matlab中直接运行调试。目前已有1239人学习,适合竞赛复盘、课题设计及算法自学者作为参考模板。

1. 为什么 2020 年数学建模 B 题值得用 Matlab 重写一版

很多队伍拿到 2020 年数学建模 B 题时,第一反应是找现成答案,结果发现网上大部分代码是 Python 或 C++,真正能跑通的 Matlab 源代码反而难找。这道题表面上是个“穿越沙漠”的小游戏,本质却是带随机天气、价格差、负重上限的资源调度优化问题,决策树规模比想象中大得多,手算和贪心都撑不住。我当时把它当做一个两周的练手项目,用 Matlab 从零搭了一版带剪枝的深度优先搜索,发现 Matlab 在处理地图矩阵、天气序列、递归调试和结果可视化上,比 C++ 舒服很多,也比 Python 写起来更贴近“建模范式”。这篇笔记不是翻译官方解答,而是把我验证过的一整套可复现方案拆开讲,适合正在备赛、或者想用 Matlab 做一遍这道题的人照着敲。

2. 先拆模型:B 题的决策变量、约束与收益公式如何落到状态空间

2.1 一天的状态里要装哪些量

做搜索类优化题,第一步不是写代码,而是把“一个人在第几天、在哪个格子、手里剩什么”这件事定义清楚。因为之后所有的剪枝、判重、输出报告都依赖这个状态定义,定义错了后面全错。我一般会用 Matlab 的结构体来装状态,字段包括:当前位置坐标、当前天数、剩余水量、剩余食物量、当前现金,以及路径记录和动作记录。

这里的核心是“按天推进”而不是“按步推进”。每次行动一天,无论选择移动、停留还是挖矿,都要消耗资源,而且消耗的大小同时取决于天气和行动类型。所以天气序列必须放进全局变量,不能塞进每个状态里重复复制,否则内存和递归开销都会失控。

状态结构体长这样:

s = struct(); s.pos = [1 1]; % 当前坐标,格式 [行, 列] s.day = 1; % 当前是第几天,从 1 开始 s.water = 0; % 剩余水量 s.food = 0; % 剩余食物 s.money = 10000; % 剩余现金 s.path = [1 1]; % 走过的路径,每一行是一个坐标 s.act = {}; % 每日动作,例如 'stay'、'mine'、'move_4_2'

把状态做成结构体而不是用一堆分散的全局变量,最大好处是递归传参时只有一个变量,调试时直接在断点看一个结构体就能掌握全部信息。另外,path和act要跟着状态走,因为最优解最终要输出“第几天做了什么、走过了哪些格子”,没有这两个字段,算完还要回头倒推,麻烦得多。

坐标格式这里有一个小坑:Matlab 的矩阵下标是“行在前、列在后”,我习惯统一写成[x, y],但 x 对应行、y 对应列,这样读起来和原题地图一致。所有距离计算都用曼哈顿距离,也就是abs(x1-x2)+abs(y1-y2),因为每一步只能移动到上下左右相邻格,斜向走不存在。

2.2 收益公式与购买、挖矿的取舍

这道题的经济系统可以用一个公式概括:最终收益 = 初始资金 − 购买总花费 + 挖矿总收入,但中间藏着“负重上限”和“生存约束”。玩家不可能无限买资源,水和食物各自有容量上限,而且价格在不同地点不一样,导致“在起点买够”和“到村庄再补货”会产生完全不同的现金走向。

我一般会在写搜索前先手算几笔账,把参数敏感度摸一遍。比如起点水价如果定成 10、食物定成 20,村庄水价 5、食物 10,那么同样一份资源,村庄买比起点买便宜一半。但去村庄要花路程时间,高温天还得多耗水,这就是时间成本。挖矿看起来每天收入 1000,但挖矿天消耗的水和食物几乎是停留的两倍,如果食物价格高,挖矿不见得划算。

这个问题没有固定的“最优策略模板”,只能靠搜索把所有组合找出来。搜索的目标函数很简单:到达终点时手里现金越多越好。约束就三条:水不能小于 0、食物不能小于 0、负重不能超过上限。把这三条写进状态转移检查后,剩下的就是让算法自己去试。这也是我推荐用 DFS 而不是动态规划的原因:DP 需要把状态离散到网格上,而这个题的天气序列、购买时机都是强时序依赖,DFS 配合剪枝更直白,也更方便输出决策路径。

3. 数据预处理:把地图与 30 天天气序列变成 Matlab 可读的矩阵

3.1 地图编码:用矩阵表示起点、终点、矿山、村庄、水源

B 题的地图是 6×6 的网格,里面只有五个特殊格点:起点、终点、矿山、村庄、水源,其余全是普通沙漠格。常见做法是定义一个 6×6 的数字矩阵,用不同数字标记格点类型,这样查找、判断、画图都方便。

% b2020_map.m % 地图编码:0=普通 1=起点 2=终点 3=矿山 4=村庄 5=水源 grid_n = 6; map = zeros(grid_n, grid_n); map(1, 1) = 1; % 起点 map(6, 6) = 2; % 终点 map(4, 2) = 3; % 矿山 map(3, 4) = 4; % 村庄 map(6, 2) = 5; % 水源

这段代码把地图压缩成一个矩阵,后续判断“当前格是什么类型”只需要一行:map(pos(1), pos(2))。比起用一堆 if 比较坐标,矩阵方案可读性高得多,也不会出现“坐标抄错导致代码找不到矿山”这种低级错误。

参数说明:map(4,2)=3表示第 4 行第 2 列是矿山,坐标顺序务必和状态结构体里的pos保持一致。如果你拿到的原题地图里矿山、村庄、水源的位置不同,只需要改这几行的索引值,搜索算法主体完全不用动。画图时这个矩阵也有用,可以直接用imagesc(map)看整张地图的特殊点分布,确认编码有没有写错。

3.2 天气序列输入:数组、Excel 导入和可视化检查

天气是 B 题最重要的输入,它决定每一天的消耗系数,也是剪枝时用来估计“未来还能不能活”的关键依据。天气通常用三个数字表示:1 晴朗、2 高温、3 沙暴。最容易踩的坑是天气序列和日期对不上,差一个位置整条最优路径就会变。

% weather_input.m % 示例天气序列,长度 30,正式做题时替换为题目给定数据 weather = [1 1 2 1 3 1 2 1 1 2 1 1 3 2 1 ... 1 1 2 1 3 1 2 2 1 1 1 2 1 3 1]; % 从 Excel 导入的常见写法 % weather = xlsread('weather.xlsx', 'Sheet1', 'A1:AD1'); % 可视化检查:画出 30 天的天气变化 figure; stairs(1:30, weather, 'LineWidth', 2); ylim([0.5 3.5]); yticks([1 2 3]); yticklabels({'晴朗','高温','沙暴'}); xlabel('天数'); title('30 天天气序列');

天气序列有两种来源:题目直接给表格,或者在附录里给一个数组。我习惯先把数据抄进 Excel 再用xlsread读,不是因为我懒,而是因为手抄成 Matlab 数组很容易错位——30 个数字连续写在一行里,漏一个逗号、多一个空格,程序根本不会报错,但结果会变得完全不可信。可视化这一步看起来多余,其实特别值:一眼就能看出第几天是沙暴、连续高温有几段,这些信息直接影响到你判断剪枝是否合理。

参数说明:weather必须是行向量,长度等于游戏总天数,搜索主函数里会通过length(weather)来判定是否超时。如果你的题目天数不是 30,只需改主函数里的对应参数,不要写死 30。stairs画出来的阶梯图比plot更贴合“天数离散”的语义,检查沙暴分布时更直观。

4. 核心搜索:带剪枝的 DFS 在 Matlab 里的可复现实现

4.1 DFS 主循环与状态结构体

搜索部分我采用的是深度优先搜索加三个剪枝,这也是这一类“小地图、长时序、强资源约束”问题最稳的解法。DFS 的思路是:模拟玩家每一天的每一个可选动作,直到第 30 天或到达终点,然后比较所有可行方案的最终现金。状态数量虽然大,但剪枝之后实际扩展节点数可以接受,在 6×6 地图上通常几秒钟就能跑完一组参数。

% b2020_solver.m % 2020年数学建模B题求解主函数 % 输入:weather 为 1xN 天气行向量,params 为参数结构体(可省略) % 输出:best 结构体,含最优现金、最优路径、每日动作 function best = b2020_solver(weather, params) if nargin < 2 params = struct(); end p = def_params(params); % 全局最优记录 best = struct(); best.money = -inf; best.path = []; best.act = {}; best.nodes = 0; % 初始状态:在起点购买资源 init = struct(); init.pos = p.start_pos; init.day = 1; init.water = 0; init.food = 0; init.money = p.init_money; init.path = p.start_pos; init.act = {}; % 起点购买:先买水,再买食物,受到容量和资金双重约束 water_buy = min(p.cap_water, floor(init.money / p.price_w_start)); init.water = water_buy; init.money = init.money - water_buy * p.price_w_start; food_buy = min(p.cap_food, floor(init.money / p.price_f_start)); init.food = food_buy; init.money = init.money - food_buy * p.price_f_start; dfs(init); % ---------- 嵌套递归函数 ---------- function dfs(s) best.nodes = best.nodes + 1; % 生存与负重检查 if s.water < 0 || s.food < 0 || s.money < 0 return; end if s.water > p.cap_water || s.food > p.cap_food return; end % 到达终点:直接结算 if isequal(s.pos, p.end_pos) if s.money > best.money best.money = s.money; best.path = s.path; best.act = s.act; end return; end % 天数耗尽仍未到终点 if s.day > length(weather) return; end % 剪枝1:曼哈顿距离剪枝 md = abs(s.pos(1)-p.end_pos(1)) + abs(s.pos(2)-p.end_pos(2)); remain_days = length(weather) - s.day + 1; if md > remain_days return; end % 剪枝2:收益上界剪枝 upper = s.money + p.mine_income * max(0, remain_days - md); if upper <= best.money return; end w = weather(s.day); % 分支1:停留 ns = s; ns.day = s.day + 1; ns.water = ns.water - p.cost_stay_w(w); ns.food = ns.food - p.cost_stay_f(w); ns.path(end+1, :) = s.pos; ns.act{end+1} = 'stay'; dfs(ns); % 分支2:挖矿(只在矿山,沙暴天禁止) if isequal(s.pos, p.mine_pos) && w ~= 3 ns = s; ns.day = s.day + 1; ns.water = ns.water - p.cost_mine_w(w); ns.food = ns.food - p.cost_mine_f(w); ns.money = ns.money + p.mine_income; ns.path(end+1, :) = s.pos; ns.act{end+1} = 'mine'; dfs(ns); end % 分支3:向四个方向移动(沙暴天禁止) if w ~= 3 dirs = [-1 0; 1 0; 0 -1; 0 1]; for k = 1:4 newpos = s.pos + dirs(k, :); if newpos(1) < 1 || newpos(1) > p.grid_n ... || newpos(2) < 1 || newpos(2) > p.grid_n continue; end ns = s; ns.pos = newpos; ns.day = s.day + 1; ns.water = ns.water - p.cost_walk_w(w); ns.food = ns.food - p.cost_walk_f(w); ns.path(end+1, :) = newpos; ns.act{end+1} = sprintf('move_%d_%d', newpos(1), newpos(2)); % 到达水源:补水到上限(按题目规则调整) if isequal(newpos, p.water_pos) ns.water = p.cap_water; end % 到达村庄:补货到半容量(示例策略) if isequal(newpos, p.village_pos) need_w = max(0, p.cap_water * 0.5 - ns.water); need_f = max(0, p.cap_food * 0.5 - ns.food); cost = need_w * p.price_w_village + need_f * p.price_f_village; ns.water = ns.water + need_w; ns.food = ns.food + need_f; ns.money = ns.money - cost; end dfs(ns); end end end end % ---------- 默认参数 ---------- function p = def_params(p) if ~isfield(p, 'grid_n'), p.grid_n = 6; end if ~isfield(p, 'start_pos'), p.start_pos = [1 1]; end if ~isfield(p, 'end_pos'), p.end_pos = [6 6]; end if ~isfield(p, 'mine_pos'), p.mine_pos = [4 2]; end if ~isfield(p, 'village_pos'), p.village_pos = [3 4]; end if ~isfield(p, 'water_pos'), p.water_pos = [6 2]; end if ~isfield(p, 'cap_water'), p.cap_water = 1200; end if ~isfield(p, 'cap_food'), p.cap_food = 1200; end if ~isfield(p, 'init_money'), p.init_money = 10000; end if ~isfield(p, 'price_w_start'), p.price_w_start = 10; end if ~isfield(p, 'price_f_start'), p.price_f_start = 20; end if ~isfield(p, 'price_w_village'), p.price_w_village = 5; end if ~isfield(p, 'price_f_village'), p.price_f_village = 10; end if ~isfield(p, 'mine_income'), p.mine_income = 1000; end % 日消耗:三列分别对应 晴朗 / 高温 / 沙暴 if ~isfield(p, 'cost_walk_w'), p.cost_walk_w = [3 9 15]; end if ~isfield(p, 'cost_walk_f'), p.cost_walk_f = [6 6 10]; end if ~isfield(p, 'cost_stay_w'), p.cost_stay_w = [3 9 15]; end if ~isfield(p, 'cost_stay_f'), p.cost_stay_f = [6 6 10]; end if ~isfield(p, 'cost_mine_w'), p.cost_mine_w = [6 12 0]; end if ~isfield(p, 'cost_mine_f'), p.cost_mine_f = [12 12 0]; end end

逻辑说明:这段代码把每一天的决策拆成三个可选分支——停留、挖矿、移动。每个分支先把状态拷贝成ns,再按当天天气扣除消耗,最后递归进入下一天。重点要理解的是“先拷贝再修改”的写法,如果直接在s上修改再递归,回来之后还要恢复旧值,容易漏写导致状态错乱。拷贝方式虽然多占内存,但对这个规模的问题完全够用,而且代码逻辑清晰。

参数说明:def_params里的消耗矩阵需要特别留意。三列分别对应晴朗、高温、沙暴三种天气,比如cost_stay_w = [3 9 15]表示停留时晴天耗水 3、高温耗水 9、沙暴耗水 15。如果你拿到的题目参数不同,只改这里就好。这里的取值是我基于常见题目设定整理的默认值,不是标准答案,正式比赛务必逐项和原题表格核对。

4.2 三个剪枝条件:距离、资源、收益

DFS 不剪枝是跑不完的,因为每一天有三到六个分支,30 天下来是指数级的方案数。代码里已经写了三个剪枝,这里单独讲清楚它们为什么安全,以及参数怎么调。

第一个是曼哈顿距离剪枝:如果剩余天数连最短路径都走不完,那这个状态无论如何到不了终点,直接掐掉。这个剪枝是强安全的,因为每一步只能走一个相邻格,地图上两点之间的最短路径长度就是曼哈顿距离。唯一要注意的是remain_days的计算,length(weather) - s.day + 1表示包括当天在内的剩余天数,少算一天会误剪掉“刚好能赶到”的方案。

第二个是收益上界剪枝:就算剩下的每一天都去挖矿,而且完全不需要花时间赶路,能赚到的钱最多是多少?如果这个上限还比不上当前已经找到的最优解,就剪掉。这个剪枝偏保守,因为现实中还要赶路、还要消耗资源,但作为“追不上就算了”的判断已经够用。mine_income越大,这个剪枝越弱;remain_days - md是“去掉赶路所需时间后最多能挖矿的天数”,当它为负数时用max(0, ...)兜底,避免出现负上界干扰判断。

第三个是合法性检查:水和食物小于 0、现金小于 0 的状态直接终止。很多新手会漏掉“现金小于 0”这一条,觉得反正可以后续再赚,但题目里挖矿收入是正的,一旦现金变负就永远回不到合法区间,留着只会浪费递归栈。

这三个剪枝可以调整的旋钮不多,真正影响搜索效率的参数是消耗矩阵和初始资金。如果跑得很慢,优先检查天气序列里是不是沙暴数量太少——沙暴天不能行动,会天然砍掉大量分支,所以沙暴越多搜索越快。反过来,全晴天的算例会非常慢,这时候建议先加大mine_income的剪枝力度,或者引入一个简单贪心解作为初始best.money,让收益上界剪枝更快生效。

4.3 最优路径与每日决策的输出

搜索结束后,best结构体里已经有最优现金、完整路径和每日动作,但直接看path和act都不够直观。我习惯再加一段输出脚本,把结果转换成“第几天 + 动作 + 位置”的表格,方便写进论文或用于人工复核。

% show_result.m % 打印最优决策报告 fprintf('最优现金:%.2f\n', best.money); fprintf('路径长度:%d 天\n', size(best.path, 1) - 1); for d = 1:length(best.act) pos = best.path(d, :); act = best.act{d}; fprintf('第 %2d 天 | 位置 (%d,%d) | %s\n', ... d, pos(1), pos(2), act); end % 最后一个位置单独打印 last = best.path(end, :); fprintf('终点位置 (%d,%d)\n', last(1), last(2));

这段代码把路径和动作循环打印成一张可读的决策表。输出里最后一行的“终点位置”是从path最后一行取的,和前面的每日位置格式分开,避免读表时把终点行动日和路径节点弄混。

输出报告有一个隐藏用途:复核“沙暴天有没有违规移动”。你可以在这个脚本里加一个检查,如果某天天气是沙暴但动作不是stay,直接报错提示。这个习惯帮我抓出过好几次剪枝条件写错的问题。还有一个小技巧:把act里move_4_2这种字符串的坐标解析出来,就能逐日画出路径轨迹,和可视化脚本无缝衔接。

5. 避坑:B 题模型最常翻车的五个细节

5.1 沙暴天不是“免费停留”

现象:跑出来的最优方案里,沙暴天没有任何资源消耗,玩家原地白漂一天。原因:很多代码在写沙暴天的处理时,只想到“不能移动、不能挖矿”,忘记“停留也要喝水吃东西”。解决:沙暴天的消耗必须按停留消耗计算,而且沙暴天只能选择停留,不能走、不能挖矿。

这个坑特别隐蔽,因为表面上看“什么都没干”确实不该消耗资源,但题目逻辑里人在沙漠里活着就要消耗水粮,不是“动了才消耗”。我在检查结果时,会专门统计每个沙暴天的资源变化,如果哪次沙暴天资源数字没变,基本可以断定代码写错了。

5.2 步数与天数搞混,路径短的错觉

现象:最优路径看起来只有 6 天,但手算发现从起点到终点的最短路径都不止 6 天,说明步数计算有误。原因:有些代码把“移动一格”等价于“一天”,忽略了停留、挖矿这些同样耗时的非移动动作,最终把总天数压缩了。解决:明确“一天一次决策”,无论移动还是停留都算一天,路径长度和天数不能混用。

我一般会用曼哈顿距离做一个硬校验:如果最优方案的总天数小于任意格点到终点的最短距离,那方案一定不合法。这个校验可以在输出脚本里自动做,不用人工盯。

5.3 负重上限下的“全押”陷阱

现象:起点购买时把全部现金都买了水和食物,结果负重超过上限,代码直接剪掉所有分支,最终报“无可行解”。原因:把负重上限和资金上限混为一谈,以为只要钱够就能买。解决:购买量必须同时满足“容量上限”和“资金上限”,可以用min(cap, floor(money/price))的方式取交集。

这个坑在初始资金充足时最容易翻车。比如初始资金 10000,起点水和食物单价也不贵,直接按 10000 去算购买量,必然超负重。我在def_params里单独设置了cap_water和cap_food,但如果你在你的实现里没用这两个变量,就要特别注意。

5.4 水源补水补多了,高温天照样断粮

现象:在水源把水补满,结果后面连续三天高温,水耗太快,还没到村庄就断水。原因:只看“当前水位低就补水”,没有结合未来天气估计需求。解决:补水策略不能简单“补满”或“补到某个固定值”,要结合未来几天的天气序列和到下一个补给点的距离做前瞻。

我的代码里水源补水直接写的是ns.water = p.cap_water,这是一种偏保守的示例策略,实际做题时它不一定最优。如果你发现最优结果里玩家在水源补到满却断水,说明真正的问题是后续路上的高温天太多,单纯“补满”已经不够,需要调整路线或在村庄买更多水。这种问题用敏感性分析最容易暴露,改天气分布看结果变化。

5.5 村庄买货只看单价,漏算时间成本

现象:方案里玩家绕远路去村庄买便宜水和食物,结果多花了两天路程,这两天消耗的资源比省下的钱还多。原因:只比较了单价差,没有把“绕路导致额外消耗”和“延迟到达导致少挖矿”计入总账。解决:把村庄补给作为一个可选分支,而不是必选动作,让搜索自己去判断值不值得去。

我在代码里把村庄购买写成了“到达后自动补到半容量”,这本身就是一种固定策略,不是全局最优。更完整的做法是把购买量也作为决策变量,比如按 50 为一个步长枚举“买多少水、买多少食物”,然后用 DFS 搜索所有组合。这样会把状态空间放大,但配合剪枝后仍然可算。判断村庄值不值得去,最终要看总现金而不是单价。

6. 验证:用手算算例、路径图、敏感性分析确认代码没算错

6.1 用手算算例给代码对答案

搜完拿到结果后,第一件事不是写论文,而是验证。我习惯构造一个极端简单的小算例:把天气全部设成晴朗,地图保持不变,这样每一步的消耗都是固定值,手算能推出来。比如只走最短路径,从(1,1)到(6,6)的曼哈顿距离是 10,按晴天行走消耗水 3、食物 6 计算,全程至少耗水 30、食物 60。如果把容量调到充足、初始资金只够买这些资源,那最终最优解就应该约等于“初始资金减掉购买成本”。

这个算例的作用不是验证最优性,而是验证代码的基础逻辑:消耗是否正确、天数是否算对、终点结算是否合理。跑出来的结果如果和手算差很多,先别急着调剪枝,回去查状态转移代码。

% verify_simple.m % 构造全晴天 30 天,跑一遍并与手算对比 weather_all_sunny = ones(1, 30); best_simple = b2020_solver(weather_all_sunny); disp(best_simple.money);

这里要注意,b2020_solver的默认参数里包含矿山和村庄逻辑,所以全晴天算例挖矿是允许的,手算时要把“绕路去矿山挖矿”的方案也大致估算一遍,才能判断代码给出的最优解是否合理。如果手算的直达方案是划算的,而代码找出了一个更优的挖矿方案,应该感到高兴,那说明搜索分支是完整的。

6.2 把方案画到地图上:路径可视化脚本

决策表看得再仔细也不如一张路径图直观,尤其是要检查“有没有绕远路”“有没有在矿山附近反复横跳”。我常用的画法是把地图铺成网格,特殊格点用不同颜色标出来,再叠加最优路径的折线。

% plot_path.m % 路径可视化 figure; hold on; grid_size = 6; for i = 1:grid_size for j = 1:grid_size text(j, i, sprintf('(%d,%d)', i, j), ... 'HorizontalAlignment', 'center'); end end % 标记特殊地点 plot(1, 1, 'go', 'MarkerFaceColor', 'g', 'DisplayName', '起点'); plot(6, 6, 'ro', 'MarkerFaceColor', 'r', 'DisplayName', '终点'); plot(2, 4, 'mp', 'MarkerFaceColor', 'm', 'DisplayName', '矿山'); plot(4, 3, 'bs', 'MarkerFaceColor', 'b', 'DisplayName', '村庄'); plot(2, 6, 'k^', 'MarkerFaceColor', 'k', 'DisplayName', '水源'); % 画路径 path = best.path; plot(path(:,2), path(:,1), 'r-', 'LineWidth', 2); xlim([0.5 grid_size+0.5]); ylim([0.5 grid_size+0.5]); legend('Location', 'best');

这段代码里要注意一个坐标映射:path存的是[行, 列],而plot的横纵坐标习惯是[x, y],所以我写的是plot(path(:,2), path(:,1)),把列号映射到横轴、行号映射到纵轴。如果你直接plot(path(:,1), path(:,2)),路径会沿着矩阵的转置方向显示,看起来就像地图上下翻转,容易误判。

可视化最大的价值在于“人工校验”。比如路径里出现了斜向移动,说明移动分支有 bug;比如路径绕了一个大圈却只挖了一天矿,说明剪枝可能误伤了好方案,这时候回到剪枝条件去调参数。

6.3 参数敏感性:改资金、改天气分布看趋势

代码能跑出结果只是第一步,真正让它可信的是参数敏感性。我会做两组实验:第一组改初始资金,从 5000 加到 20000,看最优策略里的挖矿天数和村庄购买次数怎么变;第二组改沙暴天气的数量,比如把 30 天里沙暴从 5 个加到 10 个,看最优收益是否下降、路线是否避开了矿山。

这些实验不需要改搜索主体,只改params里的字段和weather数组。比如想验证“初始资金越高越应该挖矿”,就循环调用b2020_solver,记录每种资金下的mine_days。如果趋势是“资金越高挖矿越多”,说明代码对经济系统的建模是自洽的;如果出现“资金翻倍但挖矿天数反而下降”,就要怀疑剪枝是不是误判了挖矿路径。

这类敏感性分析的另一个作用是找“边界”。比如当初始资金降到某个值以下,玩家连直达终点的资源都买不起,代码应该返回无可行解,而不是强行输出一个买不起资源的方案。这个边界值可以手算出来,和代码结果对照,是验证合法性检查最有力的手段。

我每次重写一版这类搜索代码,都会保留这组验证脚本。它们不需要写进论文,但能在比赛最紧张的提交前帮我确认代码没有“算了个寂寞”。如果你也打算用 Matlab 重做一遍这道题,建议从最简单的天气序列开始,跑通之后再加挖矿、再加村庄购买,一步一验证。希望这篇笔记能让你的复现过程少走点弯路。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进