ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

鲜奶配送站点选址:距离矩阵、p-median与集合覆盖实战

鲜奶配送站点选址:距离矩阵、p-median与集合覆盖实战

简介:这份PDF整理了一道面向高校数学建模竞赛与运筹学课程训练的完整赛题,围绕鲜奶“最后一公里”配送,要求在92个网点中合理选址并规划配送站数量,兼顾时效、成本与建设投入。赛题分三问展开:先综合配送速度、配送成本与建设成本设计最经济方案;再在单程配送不超过10分钟、物流车时速20km/h的约束下确定多站点布局;最后考虑各站配送量均衡,避免资源浪费与延误。压缩包内仅含1个PDF文件,约98KB,包含题目背景、三问要求及网点坐标、订奶量、配送道路等附件数据说明,可直接用于建模练习与方案复现。已有3074人学习,适合参赛队伍、运筹优化学习者作为选址与路径问题的案例材料,也可据此梳理设施选址、时间约束和库存均衡的建模思路。

1. 92 个网点选几个站,答案其实不在坐标里

鲜奶配送站点选址的最优化问题,很多人一上手就打开地图画圆:以某个网点为圆心、3.3 公里为半径,看能盖住多少个点。我按这个思路先算过一遍,得到的方案在真实路网上有近一半网点会超时。原因并不玄学——那道 10 分钟时限的门槛,量的是车在道路上跑的距离,不是地图上两点之间的直线。更关键的是,当你把 92 个经纬度坐标变成一张 92×92 的距离矩阵时,模型的可行域就已经定型了。后面的 p-median、集合覆盖、配送量均衡,全部建立在这张矩阵上;矩阵错了,求解器再精确也只是在错误的空间里找最优。这套赛题真正值得拆的,是三个子问题如何从同一张矩阵出发,被不同的约束推向三种完全不同的数学结构。

2. 距离矩阵:为什么直线距离会让整套模型失效

2.1 经纬度到公里:一个必须做对的局部投影

附件给的坐标是经纬度,而模型的成本项、时限判定项都需要长度单位,第一步就是投影。严格做法是用球面距离公式逐对计算,但本题范围只有一座城市,几十公里量级,用局部等距投影的误差在千分之一以下,对 3.33 公里的判定半径完全够用。

import numpy as np R_EARTH = 6371.0 # 地球平均半径,km def latlon_to_xy(lon, lat): """把经纬度近似投影到局部平面,返回 km 单位的 x, y。 中心取数据自身的中位数,避免依赖任何外部地理信息。""" k = np.pi / 180.0 lat0 = np.median(lat) # 用本批数据的纬度中位数作基准 lon0 = np.median(lon) x = R_EARTH * k * np.cos(lat0 * k) * (lon - lon0) y = R_EARTH * k * (lat - lat0) return x, y

这段代码里,cos(lat0 * k)是关键:纬度一度约 111 公里,经度一度要乘上纬度的余弦才对应同样的地面长度。在中纬度城市,这个系数大约 0.85,忽略它会让东西向距离整体偏大 15%,覆盖判定直接失真。lat0取中位数而不是写死一个常数,是为了让同一份代码能复用到别处的坐标数据上,不用改参数。

投影完成之后,每个网点就落在一个平面直角坐标系里,后续所有距离都可以在这个平面上算。注意这里只是把球面近似成平面,并没有改变"距离"的语义——它仍然是两点间的直线距离。

2.2 全源最短路:Floyd 在这道题里比 Dijkstra 更划算

题目附件给了"网点之间的配送道路",本质上是一张带权无向图(也可能因为单行限制而带方向)。有了边的权值,就不该再用直线距离,而要在图上跑最短路。

规模是这里的关键决策依据:92 个节点,Floyd-Warshall 的复杂度是 O(n³),约 78 万次基本操作,在 NumPy 里向量化之后不到一秒;而 Dijkstra 单源 O(n²),跑 92 次也是同样的量级,但要写 92 次循环、维护优先队列,代码量和出错概率都更高。这种"规模小到可以暴力"的场合,全源最短路一次算完存下来,是最省心的做法——后面不管怎么改约束、怎么加情景,距离矩阵都不用重算,相当于提前给自己留了后悔药。

def build_adj(n, edges): """edges: [(i, j, dist_km), ...],返回邻接矩阵。""" W = np.full((n, n), np.inf) np.fill_diagonal(W, 0.0) # 对角线必须是 0,不能留 inf for i, j, d in edges: W[i, j] = min(W[i, j], d) # 平行边取最短 W[j, i] = min(W[j, i], d) # 若数据含单行道,删掉这一行 return W def floyd(W): """返回全源最短路矩阵 D。""" D = W.copy() n = len(D) for k in range(n): # 以 k 为中转,一次性更新整张表 cand = D[:, k][:, None] + D[k, :][None, :] better = cand < D D[better] = cand[better] return D

floyd里那个D[:, k][:, None] + D[k, :][None, :]是整段的核心:把第 k 列和第 k 行做外积加法,一次生成"经过 k 的所有路径长度",再和当前矩阵逐元素比大小。better是布尔掩码,只覆盖真正变短的格子,避免全量赋值带来的额外开销。三层循环被压成一层外层循环加两次向量化广播,这是 Python 里跑 Floyd 的标准写法。

如果附件的数据里存在单行道,W[j, i]那一行必须删掉,让矩阵保持非对称。这一点很容易被忽略:很多人习惯性地把矩阵对称化,取两个方向的较小值,结果算出来的时间比实际乐观,最后方案落不了地。

2.3 距离矩阵的三种异常情况,每一种都会在不报错的情况下让模型失效

第一种:无穷大残留。Floyd 跑完之后,如果np.isinf(D).sum()不为零,说明路网本身不连通,有网点根本到不了别处。这种情况模型不会报错,只会把那个网点从所有候选中排除,或者干脆判定无解。正确做法是先做连通分量检查,把孤立点单独列出来,要么按直线距离补一条虚拟边并打标记,要么在报告里说明该点必须自建站点。

第二种:对角线不为零。如果邻接矩阵初始化时忘了np.fill_diagonal(W, 0.0),对角线会保持inf,Floyd 之后每个点到自己的距离还是inf。后果是 p-median 里"网点由自己所在的站点服务"这一项的代价变成无穷大,求解器会拼命避开所有自服务组合,选出一堆看起来莫名其妙的位置。这个错误不会抛异常,只会让目标函数值大得离谱。

第三种:绕行系数被忽略。用直线距离代替路网距离,在城市路网里通常低估 20% 到 40%。这意味着你以为 3.33 公里的覆盖圈,实际配送距离可能是 4.2 到 4.7 公里,10 分钟根本跑不完。用附件给的道路数据跑一遍最短路,这个偏差自然就消失了。反过来,如果你的数据里没有道路信息,那至少要给直线距离乘一个经验绕行系数,并在报告里明确说明这是估计值。

提示:Floyd 结果里D[i, j] == 0且i != j的情况,说明两个网点坐标重合或者中间有零长度边,建模前应该合并处理,否则会出现"两个点共用一个站点"的退化解。

这张矩阵一旦算对,后面三个子问题就都只是在它上面加约束、换目标函数而已。真正决定方案形态的,是下一层的建模选择。

3. 问题一:p-median 与"最经济"的真实含义

3.1 为什么不能先用聚类:连续选址和离散选址是两回事

很多人的第一反应是对 92 个坐标跑一遍 k-means,把聚类中心当站点。这条路走不通,而且错得很隐蔽:k-means 的质心是坐标的算术平均,几乎一定不落在任何一个网点的位置上。题目明确写了"在 92 个网点位置中选择一个或多个点建设",也就是说候选集是离散的 92 个点,不是平面上的任意位置。

这在运筹学里是两类不同的问题。连续选址对应 Weber 问题,最优解可以在平面上任意位置,用迭代法求重心;离散选址对应 p-median,候选点和需求点是同一个有限集合。k-means 恰好是 Weber 问题的一种启发式,把它套到 p-median 上,等于解了一个更宽松的版本,得到的目标值一定是真实最优值的下界,看起来"更优",实际上不可行。

离散化带来的额外好处是建模成本低:决策变量天然是 0-1 的,不需要处理连续坐标,也不用担心站点设在马路中间或者河面上。

3.2 目标函数:把"经济"拆成运输成本和建设成本

设网点集合为 $I$,候选站点集合 $J$(本题 $I = J$),$w_i$ 是网点 $i$ 的订奶量,$d_{ij}$ 是路网最短路距离,$c$ 是单位重量单位距离的运输成本,$f_j$ 是站点 $j$ 的建设成本。决策变量 $x_j \in {0,1}$ 表示是否建站,$y_{ij} \in {0,1}$ 表示网点 $i$ 是否由站点 $j$ 服务。

$$ \min \sum_{j \in J} f_j x_j + c \sum_{i \in I} \sum_{j \in J} w_i d_{ij} y_{ij} $$

约束有三条:每个网点恰好被服务一次 $\sum_j y_{ij} = 1$;只有建了站才能提供服务 $y_{ij} \le x_j$;变量取 0-1。

这里有一个容易写错的地方:$w_i$ 必须乘进运输成本项。鲜奶的订奶量差异可能达到一个数量级,如果只按距离最小选点,等于假设所有网点的需求一样大,结果会把站点堆在网点密集但订奶量很低的区域,而把大户晾在远处。加权和不加权得到的方案,在总成本上能差出百分之十几。

第二个要理解的点是:固定 $p$ 和让成本自己决定 $p$,在数学上是等价的。如果建设成本是常数 $F$,目标可以写成 $\min \text{运输成本} + F \cdot p$。此时不需要额外约束 $\sum_j x_j = p$,求解器会自己找到最优的站点数。反过来,如果你想看"多建一个站能省多少运费",那就固定 $p$ 从小到大扫一遍,画出 $C(p) + F \cdot p$ 的曲线,拐点就是经济的平衡位置。两种写法得到的是同一条曲线上的不同点。

3.3 求解规模与实现:8556 个 0-1 变量意味着什么

92 个候选点、92 个需求点,$y$ 变量最多 8464 个,加上 $x$ 变量共 8556 个,约束约 8500 条。这个规模用开源 MILP 求解器(分支定界 + 割平面)可以直接跑,但如果路网稀疏,很多 $(i, j)$ 对根本不可达,为它们建变量纯属浪费。更聪明的做法是只给可达对建变量。

import numpy as np from scipy.optimize import milp, LinearConstraint, Bounds from scipy.sparse import coo_matrix def solve_p_median(D, w, fixed_cost, unit_cost=1.0, max_p=None): """ D: n*n 路网最短路矩阵(km),不可达为 inf w: n 维订奶量 fixed_cost: 单站建设成本,需与运输成本同量纲 """ n = len(w) # 只为可达对建 y 变量 pairs = [(i, j) for i in range(n) for j in range(n) if np.isfinite(D[i, j])] m = len(pairs) pos = {pr: k for k, pr in enumerate(pairs)} nv = n + m # 目标:建设成本 + 加权运输成本 c = np.zeros(nv) c[:n] = fixed_cost for k, (i, j) in enumerate(pairs): c[n + k] = unit_cost * w[i] * D[i, j] rows, cols, vals = [], [], [] # 约束 1:每个网点被服务恰好一次 for i in range(n): for j in range(n): if (i, j) in pos: rows.append(i); cols.append(n + pos[(i, j)]); vals.append(1.0) lb1, ub1 = np.ones(n), np.ones(n) # 约束 2:y_ij <= x_j for k, (i, j) in enumerate(pairs): rows.append(n + k); cols.append(n + k); vals.append(1.0) rows.append(n + k); cols.append(j); vals.append(-1.0) lb2, ub2 = np.full(m, -np.inf), np.zeros(m) A = coo_matrix((vals, (rows, cols)), shape=(n + m, nv)).tocsr() cons = [LinearConstraint(A, np.concatenate([lb1, lb2]), np.concatenate([ub1, ub2]))] if max_p is not None: # 需要限制站点数时再加这条 Aeq = np.zeros((1, nv)); Aeq[0, :n] = 1.0 cons.append(LinearConstraint(Aeq, [1], [max_p])) res = milp(c, constraints=cons, integrality=np.ones(nv), bounds=Bounds(np.zeros(nv), np.ones(nv))) chosen = np.where(res.x[:n] > 0.5)[0].tolist() return res.fun, chosen, res

代码里pairs的构造把不可达对过滤掉,变量数从 8556 降到实际可达对的数量。y_ij <= x_j这条约束用稀疏矩阵的-1.0系数表示,是标准写法;如果手写循环容易把符号写反,变成y_ij + x_j <= 0,那模型会直接判为不可行,而求解器只告诉你"infeasible",不告诉你哪条写错了——这正是很多人卡住半天的地方。

fixed_cost必须和运输成本同量纲。如果建设成本用"万元"、运输成本用"元/公斤·公里",两者差四个数量级,求解器的数值精度会受严重影响,表现为分支定界跑很久、给出一个明显不合理的解。常见的处理是把所有成本统一换算成"元",或者把目标函数整体除以一个标度因子。

求解规模还可以进一步压。p-median 的 LP 松弛有个很有用的性质:它的最优解里 0-1 变量的分数部分通常极少,很多时候 LP 松弛直接就给出整数解。这意味着即使用分支定界,搜索树也很浅。如果数据量再大一个量级,就该换启发式了——贪心构造初始解,再做 1-swap 局部搜索:

def greedy_then_swap(D, w, k, rounds=50): """贪心建站 + 1-swap 局部搜索,作为大规模下的近似解。""" n = len(w) chosen = [int(np.argmin((w[:, None] * D).sum(axis=0)))] # 种子点 while len(chosen) < k: best_j, best_val = -1, np.inf for j in range(n): if j in chosen: continue cand = chosen + [j] val = (w * D[:, cand].min(axis=1)).sum() if val < best_val: best_j, best_val = j, val chosen.append(best_j) # 1-swap:尝试把已选点换成未选点,有改进就接受 cur = (w * D[:, chosen].min(axis=1)).sum() for _ in range(rounds): improved = False for a in range(len(chosen)): for j in range(n): if j in chosen: continue trial = chosen.copy(); trial[a] = j val = (w * D[:, trial].min(axis=1)).sum() if val < cur - 1e-9: chosen, cur, improved = trial, val, True if not improved: break return chosen, cur

贪心的复杂度是 O(k·n²),1-swap 每一轮 O(k·n²),在 92 个点的规模上跑得飞快。它的价值不只是快:当你用 MILP 求出精确解之后,可以用贪心解和精确解比一下 gap,如果差距在 1% 以内,说明启发式在小规模上可信,将来数据量放大十倍时可以放心用启发式。这种"用精确解校准启发式"的做法,是建模里很实用的一招。

4. 硬约束的代价:10 分钟覆盖与配送量均衡如何重排解空间

4.1 3.33 公里是路网距离,不是一个圆

第二问给的条件是配送时间不超过 10 分钟、车速 20 km/h。直接换算:$20 \times \frac{10}{60} = 3.333$ 公里。这个数字是路网距离的上界,不是直线距离。

这两者的差别在实践中相当大。城市路网的绕行系数通常在 1.2 到 1.4 之间,也就是说 3.33 公里的路网距离,对应的直线距离只有 2.4 到 2.8 公里。如果你按直线距离画一个 3.33 公里的圆去判定覆盖,被圈进去的网点里有相当一部分实际配送距离超过 3.33 公里,方案在纸面上满足约束,落地就超时。

正确的做法是在第 2 章算出的距离矩阵上做覆盖判定:$a_{ij} = 1$ 当且仅当 $D_{ij} \le 3.333$。这样就得到了一个 92×92 的 0-1 覆盖矩阵,每一行代表一个网点能被哪些候选站点覆盖。

还有一个细节容易被漏掉:10 分钟是"配送时间",如果严格算,装卸货、找门牌、上楼的时间都要从这 10 分钟里扣。假设每个点装卸 1 分钟,实际可用行驶时间就只有 9 分钟,对应 3.0 公里。赛题正文没给这部分数据,所以按纯行驶时间算 3.33 公里是合理的;但在方案说明里点出这个假设,会让模型的可信度高一个档次。

4.2 集合覆盖:把"选哪里"变成"能不能覆盖"

第一问是 min-sum 结构,目标是让总成本最小;第二问加上硬时限之后,问题的性质变了——先要保证可行,再谈成本。数学上这不是 p-median 加一条约束,而是另一类模型:集合覆盖。

$$ \min \sum_{j \in J} f_j x_j \quad \text{s.t.} \quad \sum_{j:, D_{ij} \le R} x_j \ge 1 ;; \forall i \in I, \quad x_j \in {0,1} $$

约束的含义是:每个网点至少被一个已建站点覆盖。如果所有站点的建设成本相同,目标就退化成"最少建几个站",这是经典的集合覆盖问题。

def set_cover(D, R, fixed_cost=None): """R: 覆盖半径(km)。返回选中的站点集合。""" n = len(D) cover = (D <= R + 1e-9) # 92x92 的布尔覆盖矩阵 nv = n c = np.ones(nv) if fixed_cost is None else np.asarray(fixed_cost, float) A = cover.astype(float) # 每行是一个网点的覆盖约束 cons = LinearConstraint(A, np.ones(n), np.full(n, np.inf)) res = milp(c, constraints=[cons], integrality=np.ones(nv), bounds=Bounds(np.zeros(nv), np.ones(nv))) return np.where(res.x > 0.5)[0].tolist(), res.fun

cover矩阵直接用布尔比较生成,D <= R + 1e-9里的容差是为了防止浮点误差把恰好等于 3.333 的点判成不可覆盖。约束的下界是 1,上界是正无穷——写成>= 1而不是== 1,因为一个网点被多个站点覆盖是允许的,只是不经济。如果把这里写成等式,模型会立刻不可行。

集合覆盖的规模在这道题里非常小:92 个元素、92 个候选集合,约束矩阵 92×92,精确求解是秒级的。这跟 p-median 的 8000 多个变量完全不是一个量级——约束结构变了,求解难度也变了。

还有一个可以拿来做交叉验证的量:覆盖矩阵每行的和。如果某个网点的行和是 0,说明它在 3.33 公里内没有任何候选站点,必须自己建站,这是硬性下界。如果所有行和都大于 0,最少站点数的下界可以由"两两不可同时覆盖"的最大点集给出——找一组网点,任意两个之间的距离都超过 $2R$,那么每个这样的点都需要一个独立的站点。这个下界和贪心解一比,就能知道当前方案离最优还有多远。

4.3 均衡约束的线性化:辅助变量与不可行的边界

第三问要求各站点的配送量均衡。配送量太低浪费资源,太高延误配送。这句话翻译成数学,有三种写法,各有代价。

第一种是上下界约束:$L \le \sum_i w_i y_{ij} \le U$,对所有建了站的 $j$ 成立。这是线性的,写进去最省事,但 $L$ 和 $U$ 要人给定,给定得不好就无解。

第二种是最小化最大负荷:引入辅助变量 $z$,加约束 $z \ge \sum_i w_i y_{ij}$ 对所有 $j$,目标里最小化 $z$。这是 min-max 形式,也是线性的,而且不需要人为指定阈值,模型自己找最均衡的方案。

第三种是方差或基尼系数,能真正刻画"均衡程度",但是非线性的,92 个点的规模下要么线性化引入大量辅助变量,要么直接上非线性求解器,性价比很低。实践中用前两种就够了。

def balanced_p_median(D, w, fixed_cost, lam=0.5): """成本与最大负荷的加权折中。lam 越大越偏向均衡。""" n = len(w) pairs = [(i, j) for i in range(n) for j in range(n) if np.isfinite(D[i, j])] m = len(pairs) z_idx = n + m # 最后一个变量是最大负荷 z nv = n + m + 1 c = np.zeros(nv) c[:n] = (1 - lam) * fixed_cost for k, (i, j) in enumerate(pairs): c[n + k] = (1 - lam) * w[i] * D[i, j] c[z_idx] = lam # 均衡项的权重 rows, cols, vals = [], [], [] # 每个网点被服务一次 for i in range(n): for j in range(n): if (i, j) in pairs: rows.append(i); cols.append(n + pairs.index((i, j))); vals.append(1.0) lb1, ub1 = np.ones(n), np.ones(n) # y_ij <= x_j for k, (i, j) in enumerate(pairs): rows.append(n + k); cols.append(n + k); vals.append(1.0) rows.append(n + k); cols.append(j); vals.append(-1.0) lb2, ub2 = np.full(m, -np.inf), np.zeros(m) # z >= sum_i w_i * y_ij,对每个 j for j in range(n): r = n + m + 1 + j rows.append(r); cols.append(z_idx); vals.append(1.0) for k, (i, jj) in enumerate(pairs): if jj == j: rows.append(r); cols.append(n + k); vals.append(-w[i]) lb3 = np.zeros(n); ub3 = np.full(n, np.inf) A = coo_matrix((vals, (rows, cols)), shape=(n + m + n, nv)).tocsr() cons = [LinearConstraint(A, np.concatenate([lb1, lb2, lb3]), np.concatenate([ub1, ub2, ub3]))] res = milp(c, constraints=cons, integrality=np.ones(nv), bounds=Bounds(np.zeros(nv), np.ones(nv))) return res.fun, np.where(res.x[:n] > 0.5)[0].tolist()

z >= sum_i w_i * y_ij这一组约束是整段的核心:$z$ 被所有站点的负荷从下方顶住,最小化 $z$ 就等于最小化最大负荷。$z$ 本身可以是连续变量,不需要整数性,因为它是负荷的上界,取到最优时自然等于某个站点的实际负荷。

一个必须警惕的现象是:如果目标里只有均衡项、没有成本项,最优解会退化。因为把 92 个网点全部建站,每个站只服务自己,最大负荷就是单个网点的最大订奶量,这已经是理论下界。所以均衡项必须和建设成本、运输成本联合优化,lam就是在两者之间调权重。实际做的时候更好的办法是 ε 约束:固定最大负荷不超过某个值,在这个前提下最小化成本,然后扫描这个阈值,得到一条帕累托曲线,让决策者自己挑一个点。

不可行的边界也要先算清楚。加均衡约束之后,可行域可能变空。最典型的情况是阈值 $U$ 小于最大单点订奶量——那个网点无论怎么分配,自己这一份就超标了。所以建模前先算 $\max_i w_i$,把它作为 $U$ 的硬下界;再算总订奶量除以允许的站点数上界,作为 $L$ 的参考值。这个预处理能省掉大量"求解器返回 infeasible、但不知道哪里错"的时间。

把三个子问题连起来看,会发现它们的结构差异极大:第一问是 min-sum,第二问是覆盖可行性,第三问是 min-max。同一份数据、同一张距离矩阵,仅仅因为约束的性质不同,就要换三种模型和三种求解策略。这也是这道赛题真正的训练价值所在。

5. 避坑与排查:这类选址题最容易翻车的五个地方

5.1 距离与数据层面

现象:算出来的 10 分钟覆盖圈看起来很大,落地配送却大面积超时。原因是用直线距离代替了路网距离,低估了 20% 到 40% 的绕行。解决办法是老老实实用附件给的道路数据跑一遍 Floyd 最短路,把距离矩阵存成文件,后面所有子问题共用同一份。血泪经验是:这份矩阵一旦算对就冻结,别在中途改投影参数,否则前后结果对不上还找不到原因。

现象:Floyd 跑完,某些网点到其他所有点的距离都是 inf,但求解器不报错,只是把它们排除在服务范围外。原因是路网不连通,或者附件里的边表存在只给了一个方向的记录。解决方式是先统计np.isinf(D).sum(),非零就做连通分量分析,把孤立点单独列出,明确在报告中说明处理方式——补虚拟边还是强制自建站点,两种都可以,但不能假装没看见。

现象:p-median 的解里出现"两个网点共用一个站点位置"或者"站点服务距离为 0 的一堆点"。原因是坐标数据里有重合点,或者零长度边。解决办法是在建模前做一次坐标去重,把距离小于 10 米的点合并,订奶量相加。这类数据清洗不改变模型,但不做会让结果解释起来很别扭。

5.2 建模层面

现象:用 k-means 聚类,得到的中心点不在任何网点上,方案没法落地。原因是把离散选址当成了连续选址。题目明确限定在 92 个网点中选,候选集和需求集是同一个集合,正确模型是 p-median 而不是 Weber 问题。判断方法很简单:看你的解是不是都落在原始坐标集合里,如果不是,模型就选错了。

现象:目标函数里漏了订奶量,选出来的站点全挤在网点密集区,大户反而离得远。原因是只优化了距离和,没有加权。鲜奶网点的订奶量差异可能到十倍量级,不加权的解在小需求点上浪费了覆盖能力。解决办法是把 $w_i$ 乘进运输成本项,或者在报告里明确说明"本模型假设各网点需求均等",但那基本等于放弃了这道题的核心考点。

现象:约束y_ij <= x_j写成了y_ij + x_j <= 1或者符号写反,求解器直接返回 infeasible。原因是这条约束的语义是"没建站就不能服务",逻辑蕴含在 0-1 变量下等价于 $y_{ij} - x_j \le 0$。写反之后,任何建站组合都无法同时满足"每个点被服务一次"和"服务点必须是已建的",模型自然空。排查方法是先不加这条约束跑一遍,如果可行,问题就出在这条上。

5.3 求解与结果解释层面

现象:建设成本用万元、运输成本用元,求解器跑很久,给出的方案明显不合理。原因是目标函数里两项差了四个数量级,分支定界的数值精度受影响。解决办法是统一量纲,全用元或者全用万元;如果差距仍然很大,给目标整体除以一个标度因子,让各项落在同一量级。这个问题在混合整数规划里很常见,但报错信息永远不会告诉你"是你的单位问题"。

现象:贪心加局部搜索跑出来的解,比 MILP 精确解差 15%,但不知道差在哪。原因是 1-swap 陷入了局部最优,而 p-median 的解空间有大量平台。解决办法是加扰动做多起点重启,或者用 MILP 的 LP 松弛值作为下界,算出当前启发式解的真实 gap。如果 gap 在 2% 以内,说明解已经够好;如果超过 10%,说明起点选得有问题,得换构造策略。用精确解校准启发式,是这类小规模问题里最划算的一步。

现象:加了均衡约束之后模型无解,但把约束放宽一点点又有解了,边界非常敏感。原因是均衡阈值贴近了理论下界。解决办法是先算清楚可行域:$U$ 的下界是最大单点订奶量,$L$ 的上界是总订奶量除以最少站点数,把这两个数打出来,再设阈值。如果还是紧,就用 ε 约束扫描,把不可行的区间明确标出来,而不是让求解器反复返回 infeasible。

6. 设计取舍:为什么我建议先把距离矩阵冻结再谈模型

回过头看这三个子问题,最值得说的取舍其实不在模型层面,而在数据处理顺序上。

很多人习惯边建模边算距离,第一问用直线距离跑一遍,第二问想起来要路网再算一遍,第三问又因为要加约束重算一次。这样做的问题不是慢,而是三次结果的基准不一致:第一问的最优站点在直线距离下是 A,第二问在路网距离下变成 B,你没法解释"为什么加了时限约束,站点反而变多了"——因为两次用的根本不是同一张距离表。

我一般会这么处理:把 Floyd 的结果存成.npy文件,三个子问题全部从这一个文件读。这样第一问的最优解在第二问里可以直接拿来做初始解,第三问加均衡约束后解的变化也能归因到约束本身,而不是数据差异。这个习惯看起来只是工程细节,但它决定了你的结果能不能被交叉验证。

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

RELATED READING

延伸阅读

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