简介:2023年安徽建筑大学校内数学建模竞赛“鲜奶配送站点的最优化设置问题”赛题完整收录于这份PDF中,面向数学建模参赛者、运筹学爱好者及供应链管理研究人员。内容基于92个网点的位置坐标、订奶量与配送道路数据,将问题拆解为最经济选址、10分钟配送时限约束下的站点设置、配送量均衡优化三个子任务,并分别对应设施位置问题、车辆路径问题和库存控制模型,给出从数据解读到建模求解的完整思路。资源为单个PDF文件,压缩包大小98KB,篇幅精简但结构清晰,适合作为赛前训练、模型对比和论文写作的参考资料。目前已有3073人学习下载。通过这份PDF可快速了解多目标选址建模的流程,掌握将实际物流配送问题转化为数学优化模型的方法,对提升竞赛实战能力与运筹学应用水平均有帮助。
1. 鲜奶配送站点最优化:从92个网点里选出几个建站,本质是设施选址与路径规划的联合决策
拿到这份《鲜奶配送站点的最优化设置问题》赛题,第一反应是“这不就是选几个点当仓库吗”,但真把附件里的92个网点坐标、订奶量和配送道路铺开,你会发现事情没那么简单。鲜奶配送的“最后一公里”是瓶颈,非冷链小车、10分钟时效、配送量均衡三个条件叠加,已经不是一个简单的聚类问题。题目给了三个递进的小问:先做最经济的站点选址,再加时间窗约束,最后考虑配送量均衡。这份PDF的价值在于,它把设施选址问题(Facility Location, FLP)、车辆路径问题(VRP)和负载均衡问题串在了一起,非常适合用来练手数学建模竞赛的整套流程。适合正在备赛的学生,也适合想用优化方法解决实际物流选址问题的从业者。下面我按自己拆这类赛题的习惯,从数据预处理说到模型求解,再说到那些不写进标准答案的坑。
2. 先立理论:设施选址模型与三个子问题的数学化拆解
2.1 从网点坐标到距离矩阵:预处理是所有模型的地基
附件里给出的是网点的位置坐标和订奶量,还有网点之间的配送道路。很多同学拿到数据就直接套K-means聚类,这是最大的误区。聚类只考虑了坐标距离,但配送车必须走道路网,两点的直线距离近不代表实际配送时间短。所以第一步是把“坐标”变成“路网距离矩阵”。常见做法是:如果附件给了道路邻接关系,就用Floyd-Warshall或Dijkstra计算任意两个网点之间的最短路径距离;如果只给了坐标,那就需要根据道路信息构建邻接表,再求全源最短路径。
我一般会先把数据读进来,整理成三个结构:网点编号、坐标、订奶量;道路表(起终点、长度);以及后续算出来的距离矩阵。下面是构建距离矩阵的参考代码:
import pandas as pd import numpy as np from scipy.sparse.csgraph import floyd_warshall from scipy.sparse import csr_matrix # 假设 nodes.csv 包含 id, x, y, demand nodes = pd.read_csv('nodes.csv') # 假设 roads.csv 包含 from, to, length roads = pd.read_csv('roads.csv') n = len(nodes) # 初始化邻接矩阵为无穷大 adj = np.full((n, n), np.inf) np.fill_diagonal(adj, 0) for _, row in roads.iterrows(): i = int(row['from']) - 1 # 编号转索引 j = int(row['to']) - 1 adj[i, j] = row['length'] adj[j, i] = row['length'] # 无向图 # Floyd-Warshall 求最短路径距离 dist_matrix = floyd_warshall(csr_matrix(adj), directed=False) np.save('dist_matrix.npy', dist_matrix)这段代码的逻辑:先用大数值初始化邻接矩阵,再把道路长度填进去,最后调用Floyd-Warshall。参数说明:directed=False表示道路是双向的,如果附件中的道路有单行道信息,需要改成True;dist_matrix是对称矩阵,第 i 行第 j 列就是网点 i 到网点 j 的最短路径距离。这里要注意,距离的单位要和后续时速计算统一,附件多半给的是公里,那么时速20km/h对应的10分钟可行驶距离就是 20/60 * 10 ≈ 3.33 公里。这个数字是第一问和第二问的临界条件。
2.2 三个子问题的数学规划:p-median、覆盖模型与均衡约束
赛题三个问题对应三种经典模型。第一问“最经济配送站点设置”,本质是带建设成本和运输成本的设施选址问题。如果建设成本固定且每个站点容量足够,可以简化为 p-median 模型:给定最多 p 个站点,让所有网点到最近站点的加权重距离总和最小,这里的权重就是订奶量。不过题目说“综合考虑配送速度、配送成本和建设成本”,所以不能只 p-median,还要在目标函数里加一个“建站数量”的惩罚项。我习惯把它写成整数规划:
- 决策变量:x_j 表示是否在网点 j 建站;y_ij 表示网点 i 是否被站点 j 服务。
- 目标函数:minimize 运输成本(距离×订奶量)+ 建站成本 × 建站数量。
第二问加了“配送时间不得超过10分钟”的硬约束,也就是每个网点必须被某个站点覆盖,且距离不超过3.33公里。这变成了带半径约束的覆盖问题(Set Covering问题)的变种:在满足覆盖所有网点的前提下,最小化建站数量。第三问在第二问的基础上,要求各站点的配送量尽量均衡。所谓“配送量过低造成资源浪费,配送量过高则延误配送”,实际上是给每个站点的总配送量设了一个上下限,或者目标函数里加入配送量方差的惩罚项。
下面是第一问的整数规划模型定义,用PuLP实现:
import pulp # 假设已经计算好 dist_matrix,节点数量 n n = nodes.shape[0] demand = nodes['demand'].values # 建设成本,可设为固定值,或者随站点规模变化 build_cost = 5000 # 单位:元/个 transport_cost_per_km = 2 # 元/(公里·箱) 示例 prob = pulp.LpProblem("FreshMilkLocation", pulp.LpMinimize) x = pulp.LpVariable.dicts("build", range(n), cat='Binary') y = pulp.LpVariable.dicts("assign", ((i, j) for i in range(n) for j in range(n)), cat='Binary') # 目标:建站成本 + 运输成本 prob += pulp.lpSum(build_cost * x[j] for j in range(n)) + \ pulp.lpSum(transport_cost_per_km * demand[i] * dist_matrix[i][j] * y[i, j] for i in range(n) for j in range(n)) # 每个网点只被一个站点服务 for i in range(n): prob += pulp.lpSum(y[i, j] for j in range(n)) == 1 # 只有建站的网点才能提供服务 for i in range(n): for j in range(n): prob += y[i, j] <= x[j] # 等号约束:可以限制建站数量上限,或者不限制 # prob += pulp.lpSum(x[j] for j in range(n)) <= 5 prob.solve()参数说明:build_cost要按题目给的实际数值替换,如果题目没给,就设成一个相对值,看它对结果的影响。transport_cost_per_km是把距离费用化,这里用了每公里每箱成本,实际赛题里可能只要求“考虑配送速度”,那么可以把目标函数改为“总加权距离最小”,不用乘费用系数。注意这段代码的执行复杂度是 O(n²) 的变量数量,n=92 时大约有 8000 多个决策变量,PuLP 默认的 CBC 求解器能扛住,但如果加了很多约束,可能要等几分钟,属于正常情况。
3. 建模求解:从启发式算法到精确求解的落地路径
3.1 用Python做数据可视化和结果检验:别把黑匣子直接交给评委
模型建完,先别急着跑求解器。我做过几次数学建模比赛,发现很多同学把数据丢进优化库,输出几个站点的编号,然后就开始写论文。这是典型的黑匣子行为,评委一问为什么选这个方案就答不上来。所以第一步要做可视化:把92个网点画在散点图上,用颜色标出订奶量大小,再用线条画出道路网络。这样你能直观看到网点分布是密集还是稀疏,大概能估算出需要几个站点。
画图用matplotlib就够,但要注意中文乱码问题。给一个示例:
import matplotlib.pyplot as plt plt.rcParams['font.sans-serif'] = ['SimHei'] # 处理中文图注 plt.rcParams['axes.unicode_minus'] = False fig, ax = plt.subplots(figsize=(10, 8)) sc = ax.scatter(nodes['x'], nodes['y'], c=nodes['demand'], cmap='viridis', s=50) plt.colorbar(sc, label='订奶量') # 画道路线 for _, row in roads.iterrows(): x1 = nodes.loc[row['from']-1, 'x'] y1 = nodes.loc[row['from']-1, 'y'] x2 = nodes.loc[row['to']-1, 'x'] y2 = nodes.loc[row['to']-1, 'y'] ax.plot([x1, x2], [y1, y2], 'gray', linewidth=0.5, alpha=0.7) plt.xlabel('x 坐标') plt.ylabel('y 坐标') plt.title('92个网点分布及订奶量') plt.show()这段代码除了可视化,还能帮你检查道路连通性。如果某些网点没有道路连过去,距离矩阵里会出现无穷大,那你必须回头处理数据。另一个用途是验证求解结果:把选中的站点画出来,画一个半径为3.33公里的圆(10分钟×20km/h),看看覆盖是否合理。我总是在这一步发现自己忘了把“道路距离”和“欧式距离”混用的情况——如果数据里给的是路网距离,那你画圆时必须用路网距离转换后的等效半径,而不是直接画坐标系里的直线距离。这个坑我栽过一次,后面会再提。
3.2 第一问的贪婪启发式:K-means不行,但遗传算法可以
用整数规划求精确解是理想的,但赛题时间有限。如果求解器跑得很慢,或者结果不收敛,我会改用遗传算法实现p-median选址。为什么不用K-means?因为K-means会把站点中心放在某个非网点的坐标上,而题目要求必须从92个网点中选点,所以遗传算法的解空间设计更合适。
遗传算法的思路很简单:个体是一个长度为92的二进制串,1代表该网点建站。适应度函数就是带建站成本的总运输成本。下面是一个精简实现,用于第一问:
import random # 适应度函数 def fitness(individual, dist_matrix, demand, build_cost, n): sites = [j for j in range(n) if individual[j] == 1] if not sites: return float('inf') total_cost = build_cost * len(sites) for i in range(n): # 分配到最近的站点 nearest = min(sites, key=lambda j: dist_matrix[i][j]) total_cost += 2 * demand[i] * dist_matrix[i][nearest] # 费用系数示例 return total_cost # 初始化种群 def init_population(pop_size, n, max_sites): pop = [] for _ in range(pop_size): ind = [0]*n k = random.randint(1, max_sites) indices = random.sample(range(n), k) for idx in indices: ind[idx] = 1 pop.append(ind) return pop # 交叉、变异等略... 这是核心适应度函数参数说明:build_cost在这里会影响建站数量偏多还是偏少。如果你发现遗传算法选出来的站点数量明显多于实际需要,说明建站成本定得太低;反之如果只有1个站,说明成本定太高。demand[i]作为权重,可以让订奶量大的网点在选址时拥有更高优先级。这个函数写完后,交叉和变异都用常规的均匀交叉和单点翻转即可。注意遗传算法是随机算法,每次结果可能不同,这时候需要重复跑10次,取适应度最小的方案,并记录每次的站点集合,用来做稳健性分析。
3.3 第二问的10分钟约束:从站到点的覆盖转向站到站的分组
第二问是“任何网点到配送站行驶时间不超过10分钟”,速度20km/h,换成距离就是3.33公里。这时候问题从“选几个站点”变成了“最少需要几个站点才能覆盖所有网点”。经典的算法是贪婪集合覆盖(Greedy Set Cover):每次选一个能覆盖最多“未被覆盖网点”的候选点,直到全部覆盖。
这个算法的优点是简单、快,缺点是结果不一定是最优解,但作为竞赛的初步答案足够。下面是实现逻辑:
covered = set() sites = [] remaining = set(range(n)) while remaining: best_site = None best_covered = set() # 遍历所有网点作为候选站点 for j in range(n): if j in covered: continue can_cover = {i for i in remaining if dist_matrix[i][j] <= 3.33} # 考虑订奶量,可以加权覆盖,这里先以数量为准 if len(can_cover) > len(best_covered): best_covered = can_cover best_site = j sites.append(best_site) covered = covered | best_covered remaining = remaining - best_covered print("选择的站点编号:", sites) print("站点数量:", len(sites))参数说明:阈值3.33是根据题目“10分钟”“20km/h”算出来的。如果附件中的距离单位不是公里,而是米,那就需要改成3330米。这里有个容易被忽略的点:covered集合里存的是“已经被覆盖的网点编号”,但站点本身如果没被覆盖,它也可能在未来成为候选站点。所以我在遍历候选站点时没有排除covered中的点,只排除了已建站的点。实际运行中,你会发现贪婪结果往往比最优解多1-2个站点,这也是正常的。如果想进一步优化,可以用局部搜索:尝试替换其中一个站点,看能否减少总数,反复迭代直到无法改进。
第三问的配送量均衡,我会在贪婪覆盖的基础上加一层“负载再分配”:先给每个站点分配它覆盖的网点,计算总订奶量;然后从负载最高的站点里“挤”出一部分网点给负载最低的站点,但保持这些网点仍然在3.33公里范围内。这个调整本质上是一个带距离约束的均衡优化,可以用简单的线性规划或者启发式交换来完成。后面第五问里我会给一个验证均衡度的指标。
4. 避坑与排查:这类赛题最容易翻车的四个细节
4.1 距离矩阵的“单位与方向”不一致,导致覆盖距离错一半
现象:第二问算出的站点数量为12个,但用欧式距离画圆后发现很多网点离站点直线距离超过3.33公里。
原因:附件给的道路长度可能是“米”,也可能是“公里”,也可能包含单向道路。如果直接用原始数字和3.33比较,单位一旦搞错,覆盖圈就完全错了。另外,roads表里如果起点编号和终点编号不是从0开始,或者其他编号规则,很容易在构建邻接矩阵时漏掉某些边。
解决:在处理任何数据前,先打印几行数据,确认距离量级。比如看道路长度最大值是多少,如果最大是5000,那很可能是米;如果最大是5,就是公里。然后统一成公里。同时,检查距离矩阵的对角线是否为0,非对角线是否有无穷大。我发现一个笨但有效的办法:算完距离矩阵后,随机抽3个网点,手算它们的道路距离对比一下,避免算法写错。
4.2 把“建站数量”设成变量还是参数,目标函数结果完全不同
现象:第一问用整数规划求出的最优方案里,站点数量有8个,但直觉上3个站就够了,而且成本还更低。
原因:目标函数中建站成本系数定得太小,导致模型宁可多建几个站来省运输成本。这不是模型错,是参数没标定。赛题里往往没有给具体的建站成本和运输成本数值,需要自己设定。如果设定不当,结果自然不符合“经济性”的预期。
解决:做参数敏感性分析。把建站成本从1000到20000之间取几个档位,记录每个档位下的最优站点数量和总成本,画一条曲线。你会发现建站成本超过某个值后,站点数量急剧下降。这个拐点就是你要给评委展示的“经济性”依据。建议在论文里放一张成本-站点数量关系表。
4.3 覆盖约束只算“站点到网点”的单程距离,忽略了回程和装货时间
现象:第二问方案分配后,某站点到最远客户的距离是3.2公里,看似满足10分钟,但加上早高峰停车、交接、装货时间,实际配送超过15分钟。
原因:题目说的是“配送时间不得超过10分钟”,这个时间通常指车辆从站点出发到目标网点的行驶时间,不包括等待和卸货。但实际物流里必然有装卸时间。竞赛题不想让你考虑太复杂,但隐含条件是“配送站到网点的配送时间”而不是“最后一公里全程时间”。
解决:如果不想重新建模,可以在论文里明确说明“我们假设车辆在站点装货完毕即出发,途中无因交通导致的延迟”,把这个假设写清楚。然后你再跑一遍模型,把10分钟对应的距离阈值调整成8分钟或9分钟,看看结果变化。这种敏感性分析,很多评委反而会加分。
4.4 第三问的“均衡”被定义成方差最小,但忽略了站点服务能力上限
现象:均衡优化后,各站点配送量方差很低,但是某站点的总配送量达到了400份,远超过单车一次能装的量。
原因:题目说“配送量过高则会延误配送”,但没给出具体单车容量限制。如果只做统计均衡,不去限制最大值,结果还是可能安排一个站点服务半个市的订单。这个问题在阅卷时经常被攻击。
解决:均衡的定义要分层——先保证每个站的配送量不超过一个经验值(可以根据车辆容量×每天班次数估算),再在满足该上限的前提下最小化站点之间的配送量差异。我后来做这个题,会把第三问拆成两个阶段:第一阶段用第二问的模型求出满足覆盖和容量约束的最小站点数;第二阶段在站点集合不变的情况下,用线性规划重新分配网点,使得各站点总订奶量的方差最小。这样更合题意。
5. 进阶技巧:用距离矩阵的稀疏性加速求解,以及论文里必须画的那张覆盖图
5.1 用邻接稀疏性和剪枝把求解时间从十分钟压到十秒
第二问的贪婪覆盖里,每次选最优站点时都要计算所有未覆盖网点到所有候选站点的距离,复杂度是O(n²)。但92个网点之间并不是全连接,很多网点之间根本没有直接道路,距离矩阵里大量是无穷大。你可以利用这个稀疏性,提前把距离矩阵压缩成“每个网点最近的可达邻居列表”:
# 只保留每个网点的前 k 个最近可达网点 from scipy.sparse import issparse # 假设 dist_matrix 是 numpy 数组 neighbors = {} for i in range(n): order = np.argsort(dist_matrix[i]) # 过滤掉无穷大,只取有意义的邻居 neighbors[i] = [j for j in order if np.isfinite(dist_matrix[i][j])][:20]在贪婪覆盖时,判断网点i能否被候选站点j覆盖,不需要查矩阵坐标,直接看i在不在j的邻居列表的前缀里。配合一个预计算的布尔矩阵“can_cover[i][j] = dist_matrix[i][j] <= 3.33”,就可以把覆盖判断变成O(1)查表。这样整个贪婪算法在92个网点上几乎瞬时完成。很多同学不会去优化这一点,但赛题的附件如果换成几百个网点,这一招就能救命。
5.2 论文里最值钱的那张图:站点-网点分配关系图
评委看论文时间有限,他们第一眼看摘要,第二眼就看图。第二问的求解结果如果不配图,光列站点编号一点说服力都没有。我画这张图时会用不同颜色给每个站点的服务区域着色,在连线上标注订奶量。代码如下:
# 假设 sites 是最终站点列表,assign 是每个网点分配的站点 colors = plt.cm.tab20(np.linspace(0, 1, len(sites))) for idx, site in enumerate(sites): xs = [] ys = [] for i in range(n): if assign[i] == site: xs.append(nodes['x'][i]) ys.append(nodes['y'][i]) plt.scatter(xs, ys, c=[colors[idx]], label=f'站点{site+1}') # 连接网点与站点 for i in range(n): if assign[i] == site: plt.plot([nodes['x'][site], nodes['x'][i]], [nodes['y'][site], nodes['y'][i]], color=colors[idx], linewidth=0.3)注意,这里连线用的是直线,但实际距离是路网距离,所以在图注里要说明“直线仅示意,规划基于路网最短路径”。每次画完这张图,我都能发现模型里隐藏的问题:比如某个网点被分配到很远的站点,但明明有个更近的站点在旁边。这种情况往往是因为距离矩阵里那个“更近的站点”和该网点之间没有道路连接,被绕道了。这时候你就会真正理解为什么要用路网距离而不是坐标距离。从那以后,我每次做选址题都强制自己先画一遍分配图再跑下一个问题,这个习惯让我少改了无数次模型。希望帮到你。
本文还有配套的精品资源,点击获取