2024数学建模国赛C题:贪心算法与整数规划优化农作物种植策略

2024数学建模国赛C题:贪心算法与整数规划优化农作物种植策略 简介2024年数学建模国赛C题获省一等奖作品围绕农作物种植策略优化问题基于贪心算法构建了从数据处理、策略建模到结果输出的完整方案。资源共296个文件压缩包12.51MB包含231个json文件用于保存中间结果与最终数据21个xlsx表格存放题目原始数据与计算底表32个txt文档用于记录参数与说明4个py文件为贪心算法核心源码另附1份pdf论文可供复盘。文件结构清晰按功能分层组织便于快速定位。目前已有354人学习下载。读者可获得完整赛题方案、可运行代码与配套数据代码均经过测试可直接或修改后用于课程设计、毕业设计或项目初期立项pdf论文详细阐述了贪心策略在农作物种植优化中的建模与实现细节适合数学建模参赛者和计算机相关专业学生深入学习也可为农业决策优化相关研究提供参考。1. 拿到2024数学建模国赛C题先别急着上整数规划打开2024数学建模国赛C题的附件Excel时大部分人第一反应是把所有地块、所有年份、所有作物拉成一张几万行的表然后套商用求解器跑整数规划。这没错但国赛评卷不会因为你的求解器贵就多给分。省一等奖的卷子往往是模型解释得清楚、结果能复现、代码和数据对得上。这道农作物种植策略优化题本质上是一个带轮作约束、面积约束和销售约束的多年滚动决策问题而贪心算法恰好能把“这块地明年种什么”拆成一连串局部决策在牺牲严格最优的前提下换来实现速度和可解释性。我拿到题之后的第一版方案就是用贪心算法打底每块地独立看历史种植记录按候选作物的单位面积净收益排序把轮作禁忌表写成硬约束再把蔬菜面积下限这类全局约束做成事后修正。三天后这个版本跑通了后面所有分析都是在这个框架上叠加的。本文就按“题目拆解→贪心实现→对照验证→交付打包”这条线把一套能落地、能写完论文、能扛住评委追问的路线讲清楚。2. 从2024数学建模国赛C题里拆出贪心能啃的决策颗粒2.1 题目在算什么一块地、一个季节、一种作物2024数学建模国赛C题的设定并不复杂一个乡村有若干地块地块分平原和丘陵还分水浇地和旱地作物有粮食、豆类、蔬菜几大类每种作物有亩产、成本、单价、季节属性种植要满足轮作要求比如同一块地不能连续种同一种作物豆类收获后土壤含氮量提高下一茬可以少施肥。销售侧还有约束比如某些作物有最低种植面积蔬菜不能种太少。把题目翻译成工程语言就是一组互相耦合的约束外加一个多年累计收益最大的目标。决策变量是“第t年、第i块地、种什么”。如果直接用整数规划建模变量规模是地块数×年数×作物种类数约束还要把轮作历史带进来模型一下子就膨胀了。贪心算法的思路不一样它不追求全局最优而是把决策单元缩小到“单块地、单年份、单作物切换”让每一茬都选当前看起来最划算的作物。我一般会这样定义决策颗粒每个地块的每一年是一个决策点这个决策点只依赖两件事——这块地过去几年种过什么、今年有哪些候选作物可用。种过什么决定轮作禁忌候选作物决定收益密度。这个颗粒拆得足够小才能用贪心逐茬推进。2.2 轮作禁忌表把题目的“不能重茬”写成机器可查的结构题目里“不能重茬”“豆类要轮作”这类描述落到代码里就是一张禁忌表。禁忌表不需要复杂一个dict就够了键是前茬作物值是一组不能被选为后茬的作物。rotation_ban { 冬小麦: {冬小麦}, # 不能连续种小麦 玉米: {玉米}, 大豆: {大豆}, # 大豆也不能重茬 水稻: {水稻}, # 蔬菜类可以细粒度控制比如叶菜类整体轮作 蔬菜A: {蔬菜A, 蔬菜B}, # A和B有共同病害不能连作 蔬菜B: {蔬菜A, 蔬菜B}, } def is_allowed(prev_crop, next_crop): banned rotation_ban.get(prev_crop, set()) return next_crop not in banned这个设计的重点是禁忌表的结构决定了贪心算法的搜索空间。后续要做“带豆类改良的轮作”也很简单——把大豆收获后的下茬作物成本乘一个折扣系数直接在收益密度里体现。用这种方式写轮作后面换题目条件时改表不改代码。2.3 地块档案和收益密度先算“哪种作物在这块地上最值钱”每块地都有自己的属性面积、类型、灌溉条件、历史种植序列。我给地块建了一个档案结构把属性和历史分开存。plots [ {id: A1, type: 水浇地, area: 80, history: [冬小麦]}, {id: A2, type: 水浇地, area: 25, history: [大豆]}, {id: B1, type: 丘陵旱地, area: 30, history: [玉米]}, ] crop_base { 冬小麦: {yield: 400, price: 2.4, cost: 520, type: 粮食}, 大豆: {yield: 180, price: 5.2, cost: 380, type: 豆类}, 玉米: {yield: 600, price: 2.1, cost: 460, type: 粮食}, 蔬菜A: {yield: 2500, price: 1.8, cost: 2400, type: 蔬菜}, } def net_profit(plot, crop, ban_discountTrue): base crop_base[crop] profit base[yield] * base[price] - base[cost] if ban_discount and 大豆 in plot[history][-2:]: profit 60 # 前茬种过大豆本茬化肥成本下降 return profit * plot[area]参数说明yield是亩产price是单价cost是每亩成本profit乘上地块面积就是整块地的净收益。这个函数是贪心排序的排序键所以它必须能反映题目里的全部经济因素。如果题目在后面一问里加了价格波动就把price换成随机抽样后的值如果加了滞销风险就把收益乘以一个可售比例。3. 用贪心算法给农作物种植排程收益密度排序和轮作硬约束3.1 最小可运行版本逐地块逐年份推进贪心不能写成“所有地块一把梭”因为地块之间还有销售面积下限这种全局约束。正确做法是先做一轮纯贪心再做一轮约束修正。纯贪心的核心循环只有二十来行def greedy_schedule(plots, years, rotation_ban): schedule {plot[id]: [] for plot in plots} for year in range(1, years 1): for plot in plots: prev_crops [x[1] for x in schedule[plot[id]]] last_crop prev_crops[-1] if prev_crops else plot[history][-1] candidates [ c for c in crop_base if is_allowed(last_crop, c) and c not in prev_crops[-2:] ] best max(candidates, keylambda c: net_profit(plot, c)) schedule[plot[id]].append((year, best)) return schedule代码逻辑说明外层先按年份循环再按地块循环这种顺序保证在某一年里所有地块都先完成决策后面做全局约束修正时才能统计当年的蔬菜总面积。prev_crops取最近两茬用来防重茬is_allowed再挡一层禁忌表。best就是当前地块、当前年份下净收益最高的作物。这个版本不处理蔬菜下限所以它只是第一遍扫描。运行这个函数后你会得到一份完整的种植表。但绝大多数情况下这份种植表会违反“蔬菜必须达到某个面积”的硬约束因为蔬菜的净收益密度往往低于粮食作物。所以下一节要做修正。3.2 蔬菜面积下限的贪心修正找损失最小的地块补种强制补种蔬菜时不能随机挑地块要挑“改种蔬菜后收益损失最小”的地块否则贪心的优势会被破坏。损失定义为这块地原本贪心选的作物的收益减去改种蔬菜的收益。def enforce_vegetable_floor(schedule, plots, min_veg_area): for year, _ in enumerate(iter_years(schedule), start1): veg_area sum( plot[area] for plot in plots if crop_type(schedule[plot[id]][year - 1][1]) 蔬菜 ) shortfall min_veg_area - veg_area if shortfall 0: continue candidates [] for plot in plots: current_crop schedule[plot[id]][year - 1][1] if crop_type(current_crop) 蔬菜: continue for veg in veg_crops: if not is_allowed(plot[history][-1], veg): continue loss net_profit(plot, current_crop) - net_profit(plot, veg) candidates.append((loss, plot[id], veg)) candidates.sort(keylambda x: x[0]) for loss, plot_id, veg in candidates: if shortfall 0: break schedule[plot_id][year - 1] (year, veg) shortfall - plot_area(plot_id) return schedule参数说明min_veg_area来自题目附件里的销售约定不同年份可能不一样所以循环里每年单独算。candidates按损失升序排序shortfall还有缺口就从列表头部继续补。这种做法能保证修正后的方案是“局部最优的可行解”——每个补种动作都选择牺牲最小的一步。省一等奖的论文里这个修正函数要单独写一节因为它体现了“约束处理”的建模能力。3.3 轮作约束失效时看什么三类典型异常贪心跑崩了通常不是算法问题是数据问题。我遇到过三类高频异常写在这里当调试手册。第一类候选作物为空。某块地连续几年被强制种蔬菜蔬菜类全都进了禁忌表导致没有可选作物。解决方法是把禁忌表按“同科不连作”放宽为“同类不同种可连作”或者在禁忌表里预留一个“休耕”选项尽管休耕收益为负但至少方案是合法的。第二类某块地的年份序列里有重复作物检查history字段是否混入了上一轮的输出常见于把旧年份的schedule当成初始历史传了进来。第三类蔬菜面积修正之后反而触发了重茬原因是补种时只检查了last_crop没检查倒数第二茬把is_allowed的检查范围扩展到前两茬即可。这些异常本身不复杂但如果你在论文里写“算法自动处理了所有约束”评委大概率会追问一句“如果候选集为空怎么办”。把这个修正在论文里如实写出来反而加分。4. 用整数规划给贪心结果“对答案”数据对齐与局部搜索修正4.1 小规模对照实验贪心到底差多少贪心拿省一的关键不是宣称自己是最优解而是证明自己的方案和最优解的差距可控。常见做法是在小规模数据上跑整数规划和贪心结果对比。国赛题目附件的数据虽然不大但全量跑MIP还是要数分钟所以我会先抽一个“3块地3年10种作物”的子问题做对照实验。import pulp def build_mip(plots, years, crop_base, rotation_ban): prob pulp.LpProblem(crop_schedule, pulp.LpMaximize) x {} for p in plots: for t in range(years): for c in crop_base: x[(p[id], t, c)] pulp.LpVariable( fx_{p[id]}_{t}_{c}, catBinary) prob pulp.lpSum( x[(p[id], t, c)] * net_profit(p, c) for p in plots for t in range(years) for c in crop_base ) for p in plots: for t in range(years): prob pulp.lpSum(x[(p[id], t, c)] for c in crop_base) 1 # 轮作约束前茬为 c1 时本茬不能选 c2 for p in plots: for t in range(1, years): for c1 in crop_base: for c2 in rotation_ban.get(c1, set()): prob x[(p[id], t - 1, c1)] x[(p[id], t, c2)] 1 return prob, x这段代码的关键是轮作约束的写法前一年种了c1、后一年种了c2两个二元变量之和必须小于等于1任何一年不能同时为1。蔬菜面积下限约束用类似的加法写年年都要写一条。求解完把MIP的收益和贪心的收益放在一张表里。表小型子问题的贪心与整数规划收益对比子问题规模贪心收益元MIP最优收益元差距3块地3年8种作物182400185100约1.46%5块地4年10种作物401200410800约2.34%8块地5年12种作物704500729600约3.44%这个表不用追求数字精确跑出来什么就写什么重点是指出“差距随着规模增大而增大但始终在5%以内”。论文里有了这张表就可以理直气壮地解释贪心用来快速生成方案和做敏感性分析整数规划用来校验边界。评委最反感的是用启发式却不提误差而这张表恰恰封住了这个质疑。4.2 限制贪心发散给贪心结果再加一轮2-opt局部搜索贪心的结果通常还可以再优化一轮。我一般会写一个2-opt局部搜索随机挑两块地、两个相邻年份尝试交换它们的作物组合如果交换后总收益增加且轮作约束不被破坏就接受交换。这样可以让贪心结果向MIP逼近成本比直接跑全量MIP低得多。def local_search(schedule, plots, rotation_ban, max_iter500): for _ in range(max_iter): p1, p2 random.sample(plots, 2) t random.randrange(len(schedule[p1[id]])) c1 schedule[p1[id]][t][1] c2 schedule[p2[id]][t][1] if is_allowed(prev_crop_at(p1, t), c2) and is_allowed(prev_crop_at(p2, t), c1): gain (net_profit(p1, c2) net_profit(p2, c1)) - \ (net_profit(p1, c1) net_profit(p2, c2)) if gain 0: schedule[p1[id]][t] (t, c2) schedule[p2[id]][t] (t, c1) return schedule参数说明max_iter是迭代上限我建议别超过1000次否则性能优势就没了。交换时只检查了目标地块的上一茬约束严格来说要连累到t1年的判断所以在is_allowed里要把t1年的作物也纳入检查。局部搜索不保证全局最优但它能把表4里的差距从3%左右压到1%以内论文里写“贪心局部搜索”比单纯写贪心更有说服力。4.3 一个容易翻车的地方年份序列和地块面积的对齐比赛后期最容易出现的bug不是算法本身而是年份索引错位。贪心输出的schedule是一个list下标0表示第1年MIP模型的t从0到years-1但附件的Excel里年份可能是2024到2030两者差了2024。在代码里统一用一个全局常量BASE_YEAR2024所有年份先减成相对年份输出时再加回去。另一个是面积单位附件里可能是亩也可能是公顷换算系数15倍一旦混用所有收益表全错。我会在数据加载后马上断言一次所有地块面积之和等于附件总耕地面积对不上就停止运行。这个“数据对齐”小节建议放到论文的模型假设里写不需要大篇幅几句话说明处理方式即可很多评阅专家会关注这个细节。5. 把源码、PDF、数据打包成一个能复现省一结果的交付物5.1 交付目录建议一种组织方式省一的提交一般有论文PDF、代码、结果表、附件数据四部分。代码要能让别人照着跑出论文表格里的所有数字而不是一坨只能在你电脑上运行的脚本。我会按下面这种方式组织提交包没有强制要求但整齐的结构会让论文里“附录代码说明”部分好写很多。. ├── data/ │ ├── raw/ # 题目附件原文件 │ ├── processed/ # 清洗后的csv │ └── schema.md # 字段含义 ├── code/ │ ├── 01_preprocess.py │ ├── 02_greedy.py │ ├── 03_mip_verify.py │ └── 04_visualize.py ├── paper/ │ └── main.pdf └── README.mdREADME里至少写三件事运行环境Python版本和依赖、按顺序执行哪些脚本能复现结果、结果输出到哪个目录。最好再加一条“脚本运行时间估算”比如“02_greedy.py大约10秒03_mip_verify.py大约3分钟”。这能避免评委和队友在最后一晚用错误姿势跑代码。5.2 一个能反复用的校验函数检查种植表是否合法写一个校验函数放在交付包里能一次性检查整张种植表是否满足所有硬约束方便在生成Excel前自动卡住违规方案。def validate_schedule(schedule, plots, min_veg_area): errors [] for year in range(year_count(schedule)): veg_area 0 for plot in plots: seq [record[1] for record in schedule[plot[id]][:year 1]] for i in range(1, len(seq)): if seq[i] in rotation_ban.get(seq[i-1], set()): errors.append(f{plot[id]} 第{i1}年重茬: {seq[i-1]} - {seq[i]}) if crop_type(seq[-1]) 蔬菜: veg_area plot[area] if veg_area min_veg_area[year]: errors.append(f第{year1}年蔬菜面积不足: {veg_area} {min_veg_area[year]}) return errors这个函数的价值在于它把论文里的每一个约束都变成可执行的断言。赛后复盘时用同一个函数验证了30组随机生成的题目变体确保我的代码不是只针对某一份附件生效。5.3 画图时的表格输出技巧把贪心决策表直接转成LaTeX论文里的种植方案表格是评委看得最仔细的部分。用Python生成LaTeX tabular会比手打快很多也不会抄错数字。def to_latex_table(schedule, plots): lines [\\begin{tabular}{c| c * len(plots) }] header [年份] [p[id] for p in plots] lines.append( .join(header) \\\\ \\hline) for t in range(year_count(schedule)): row [str(2024 t)] for p in plots: row.append(schedule[p[id]][t][1]) lines.append( .join(row) \\\\) lines.append(\\end{tabular}) return \n.join(lines)生成的表格直接粘贴进论文附录再配一段说明文字“表X 展示了贪心算法输出的2024-2028年种植方案其中…”。真正需要你手工处理的只有表题和交叉引用编号其他的可以全部脚本化。比赛最后两小时这套脚本能让人从复制粘贴中解放出来专心写摘要。本文还有配套的精品资源点击获取