第 26 章 选 p 个点使总距离最小
第 26 章 选 p 个点使总距离最小
这一章要解决什么
前面已经出现过两种选址问法:一种把建设费和运费加在一起,问"开几个仓最省";一种把"每个片区都得有站"当死条件,问"怎么建最便宜"。这一章问第三种:要建几个点,上头已经定死了,只剩"建在哪几块地上"这一件事。
读完这一章,你能写出一种叫 p-中位模型 的式子:从一堆候选地里恰好挑 p 个,让所有人走到最近那个点的总路程最短。你还会看清一处容易混的地方——"每人只去一家"和"几家分着接",式子上的区别只有一个字母,落到现实里却是一份方案能不能执行的区别。
26.1 三所幼儿园,六块地
城东新区是五年前从县城改出来的,东西九公里、南北七公里,陆续交房的小区十二个。区教育局计财科的老赵手上压着一件事:这批新小区没有一所公办园,孩子要么去三公里外的老城区,要么上月费两千八的民办园。
市里按学龄人口算了一笔账,给了三个编制。多一个不批,少一个作废——编制跟着教师岗位走,空着不用,第二年就收回去。
规划局给了六块地:街道腾退的办公用地、菜市场旁的空地、老文化馆旧址、环湖绿化带里的一块、师范学校西墙外、北关外的预留地。六块都能建园,面积都够,差别只在位置。
家长关心的只有一件事:孩子从家门口走到园门口要走多远。老赵把十二个小区的幼儿数数了一遍,一共七百四十个。他想知道的不是哪块地便宜——建园的钱市里另拨,跟选哪块地无关——而是:这三所园落在哪三块地上,七百四十个孩子每天上学的总路程最短?
26.2 先把问题说清楚
先不谈数学。用最直白的话把这件事拆成三问。
表 26-1 三个问题与答案
| 他要回答的问题 | 答案 |
|---|---|
| 他能决定什么? | 六块地里办哪三所;每个小区的孩子归哪一所 |
| 他受什么限制? | 只能办三所;每个小区的孩子必须有一个确定的去处;没办园的地上接不了孩子 |
| 他想达到什么? | 七百四十个孩子"家到园"的路程加起来最短 |
矛盾就一句话:每个家长都想让孩子上最近的那所,可园只有三所,总有一些孩子要被分到远一点的地方去。
这里有两层"非此即彼",第二层常被漏掉。
第一层好懂:一块地要么办园、要么不办,没有"办半个园"。这就是0-1 变量的用武之地——只能取 0 或 1 的变量,代表现实里"做还是不做"。
第二层是这一章特有的:一个小区整片划给某一所园。要么西苑那九十六个孩子全去西环那所,要么全去师范那所,不存在"一半去这边、一半去那边"。孩子不是可以切开的货物。
这一层正好把本章和"覆盖全部片区的服务站布点"分开。那一章里,一个片区的需求是几百件互不相干的办件,可以四成给这个站、六成给那个站,分派变量是 0 到 1 的连续数;本章里,"一个小区分给两所园"这句话本身说不通,\(x_{ij}\) 只能取 0 或 1。差别只有一个字母,落到方案上就是"这份安排能不能拿去开会"。
26.3 把大白话变成数学
先让问题长出形状,如图 26-1 所示:圆点是十二个小区,面积按幼儿数画,越大孩子越多;方块是六块候选地,实心的三块就是最终选中的。

图 26-1 十二个小区与六块候选地
这张图告诉你什么:先看圆的差别——西苑最大(96 个孩子),北关外最小(35 个),差了近三倍,人多的那一片在目标里的话语权天然更大。再看方块的位置:西环压在左半边小区中间,东湖守在东北,中心地块在正中间。最该注意的是,选中的三块地(实心方块)里没有中心地块——它位置最好,却不在名单上,原因 26.6 节会讲清。这张图已经说明:不是"哪里最居中选哪里",而是"哪里能替最多孩子省下最多路"。
第一步:给小区和地块编号。
- 下标 \(i\):小区编号,一共 12 个,\(i=1,\dots,12\);
- 下标 \(j\):候选地编号,一共 6 块,\(j=1,\dots,6\)。
第二步:把已知数据写清楚。
- \(d_i\):小区 \(i\) 的幼儿数,单位人,比如西苑是 96——它代表现实中"这一片有多少个孩子要上学";
- \(a_{ij}\):小区 \(i\) 到候选地 \(j\) 的直线距离,单位公里,比如北窑到西环是 3.35——它代表现实中"这批孩子每天要走的单程路";
- \(p\):要办的园数,本章是 3——它代表现实中"编制给了几个"。
十二个小区的数据见表 26-2,六块候选地的位置见表 26-3。
表 26-2 十二个小区的幼儿数
| 小区 | 代号 | 坐标(km) | 幼儿数(人) |
|---|---|---|---|
| 东河新村 | d01 | (1.0, 5.8) | 62 |
| 老码头 | d02 | (2.2, 4.6) | 48 |
| 南市集 | d03 | (1.4, 2.6) | 85 |
| 新路口 | d04 | (3.0, 1.1) | 73 |
| 西苑 | d05 | (4.2, 4.2) | 96 |
| 文化宫 | d06 | (5.1, 3.0) | 58 |
| 中央公园 | d07 | (5.8, 5.5) | 44 |
| 东湖 | d08 | (7.4, 4.7) | 67 |
| 北窑 | d09 | (3.6, 6.6) | 39 |
| 师范路 | d10 | (6.6, 1.9) | 81 |
| 果园 | d11 | (8.4, 2.8) | 52 |
| 北关外 | d12 | (9.2, 5.9) | 35 |
表注:坐标以新区西南角为原点,单位公里;幼儿数为在册 3–6 岁常住儿童,合计 740 人。
表 26-3 六块候选地的位置
| 候选地 | 代号 | 坐标(km) | 备注 |
|---|---|---|---|
| 西环地块 | s1 | (2.6, 3.4) | 街道腾退办公用地,四面可开门 |
| 南市地块 | s2 | (1.8, 1.7) | 菜市场旁空地,接送顺路 |
| 中心地块 | s3 | (4.8, 3.9) | 老文化馆旧址,位置最居中 |
| 东湖地块 | s4 | (7.2, 5.8) | 环湖绿化带内,只能从南侧进 |
| 师范地块 | s5 | (6.2, 2.4) | 师范学校西墙外,可共用操场 |
| 北关地块 | s6 | (8.8, 6.4) | 北关外预留教育用地 |
表注:六块地面积与地质条件都满足建园要求,本章只比较位置,不考虑地价差异。
第三步:设两类 0-1 变量。
第一类管"这块地办不办":
- \(y_j\):候选地 \(j\) 办不办园,只能取 0 或 1。\(y_j=1\) 表示在这块地上办一所,\(y_j=0\) 表示不办。
第二类管"这个小区归哪一所":
- \(x_{ij}\):小区 \(i\) 的孩子是不是整片划给候选地 \(j\) 上的园,只能取 0 或 1。\(x_{ij}=1\) 表示划过去,\(x_{ij}=0\) 表示不划。
两类变量都只有两个取值,正是 26.2 节说的那两层"非此即彼":
第四步:把三条限制写出来。
第一条,园数正好是三个:
这条式子用了等号,26.4 节会专门讲为什么。
第二条,每个小区必须有一个确定的去处:
要是允许它小于 1,模型会把偏远的北窑直接"不安排",总路程立刻好看,可那三十九个孩子没学上。
第三条,没办园的地上接不了孩子:
这条叫关联约束,把两类变量拴在一起:\(y_j=0\) 时右边是 0,\(x_{ij}\) 只能取 0,没办园的地上一个孩子也过不来;\(y_j=1\) 时右边是 1,它就不起作用。
第五步:把"想要"写成一个数。
小区 \(i\) 的孩子划给 \(j\),这些孩子每天的单程路就是 \(d_i a_{ij}\)(人数乘距离),单位"人·公里"。全部加起来:
这个要尽量小的数就是目标函数。\(x_{ij}\) 只取 0 或 1,每一项要么整份算、要么一分不算——这就是"整片划过去"。
26.4 完整的数学模型
装起来:
四条式子各防一件事:"多办一所""漏掉一个小区""孩子被划到没建的园里去""办 0.6 所园"。这就是 p-中位模型 的标准形状——"中位"指的不是地理中心,而是"让所有人到最近设施的距离之和最小"。
"恰好 p 个"这条约束怎么加
这是本章最该说清的地方,因为等号和小于等于的差别,在这儿并不显眼。
写成等号,理由是编制:三个编制是市里按人口算出来、跟着教师岗位走的,不用就作废,家长也不会因为你少办一所就少走路。这种情况下"恰好"是硬条件。
那如果写成 \(\sum_j y_j\le 3\) 会怎样?本章这道题里,答案一模一样,还是 1283.85 人·公里、还是那三块地。原因是:目标里没有"建园要花钱"这一项,多办一所不会让任何人走得更远,只会多出一组可选的去处,所以只要还允许,模型就会把名额用满。换句话说,在这道题里"最多三个"会自动变成"正好三个"。
一旦目标里出现"每办一所花一笔钱"(前面"开几个仓最省"那一类),"最多"和"恰好"立刻分道扬镳:前者会挑最省的那几所,后者会把名额凑满。所以写模型时要问自己一句:"这个 p 是被谁定死的?" 是被编制、被预算、被指标定死的,用等号;是"最多能给这么多",用小于等于,并且把成本写进目标。
为什么 \(x\) 不能像上一章那样取比例
前面那个服务站布点的案例里,分派变量是 0 到 1 的连续数,一个片区可以由几个站分着做。这一章让 \(x_{ij}\) 只能取 0 或 1,等于把"共同承担"这条路堵死了。堵得对不对,可以算一遍验证:26.6 节的输出里有一行"一个小区可以分给几个园:1283.85(与原方案差 +0.00)"——把 \(x\) 放开成比例,目标值一分钱都没降。
原因在于目标里每一项都是"人数 × 距离",而每个小区的孩子总量固定。把一个小区拆成两份,一份给近的、一份给远的,平均下来只会更远或一样。只要园没有容量上限,"允许拆分"在数学上毫无好处,唯一的作用是让方案没法执行——你得回答"哪四十八个孩子去西环、哪四十八个去师范"。
这就是本章和上一章最本质的差别:上一章的"共同承担"是模型需要的,因为一个站装不下一个片区的量;这一章是模型不需要的,因为没有哪个园会被挤爆。换成有容量的场景(比如一所园最多收 90 个孩子),拆分就又有意义了,那时 \(x\) 取什么,要看服务量能不能真的切开。
26.5 用 Python 写出来
分五段看。
第一段:把数据读进来,顺手算出 72 个距离。
DATA = json.loads((HERE / "数据.json").read_text(encoding="utf-8"))
DEMANDS = [d["key"] for d in DATA["demands"]] # 12 个小区
SITES = [s["key"] for s in DATA["sites"]] # 6 块候选地
KIDS = {d["key"]: d["kids"] for d in DATA["demands"]} # 幼儿数(人)
P = int(DATA["p"]) # 编制给的园数
# 坐标已单独读成 POS_D / POS_S 两个字典(小区 / 候选地)
# 72 个直线距离,家长走的是路网,这里按直线折算
DIST = {(i, j): math.hypot(POS_D[i][0] - POS_S[j][0], POS_D[i][1] - POS_S[j][1])
for i in DEMANDS for j in SITES}
PAIRS = [(i, j) for i in DEMANDS for j in SITES]
距离一次算完,后面建约束、算目标都直接查这张表。PAIRS 是"小区—地块"的 72 种组合,每个组合都可能长出一个 \(x_{ij}\)。
第二段:建两类 0-1 变量。
m = gp.Model("p_median")
m.Params.OutputFlag = 0
# y 是"这块地办不办园",x 是"这个小区归不归它"
y = m.addVars(SITES, vtype=GRB.BINARY, name="y")
x = m.addVars(PAIRS, vtype=GRB.BINARY, name="x")
两行 addVars 都用 GRB.BINARY。把第二个换成连续变量,就是"允许一个小区分给几所园"的版本。
第三段:三类约束,一条一条加。
# 一、恰好开 p 所:等号,编制是死数
m.addConstr(gp.quicksum(y[j] for j in SITES) == P, name="quota")
# 二、每个小区的孩子都得有个去处
for i in DEMANDS:
m.addConstr(gp.quicksum(x[(i, j)] for j in SITES) == 1, name=f"one_{i}")
# 三、没开的园接不了孩子
for (i, j) in PAIRS:
m.addConstr(x[(i, j)] <= y[j], name=f"link_{i}_{j}")
第三条是所有选址模型里最常用的一行 x <= y:没建的东西,再便宜也用不上。
第四段:设目标,交给两个求解器。
m.setObjective(gp.quicksum(KIDS[i] * DIST[(i, j)] * x[(i, j)] for (i, j) in PAIRS),
GRB.MINIMIZE)
res = solve_both(m, name="026_选 p 个点使总距离最小")
GRB.MINIMIZE 是"越小越好"。目标里乘了两样:幼儿数决定这句话的分量,距离决定它有多长。solve_both 把同一份模型交给 Gurobi 和 COPT 各解一遍,0-1 变量会自动镜像过去。
第五段:做四个对照,看哪些写法会改答案。
m2, _, _, _ = build(mode="split") # x 放开成比例:一个小区分给几所园
m3, _, _, _ = build(mode="relax") # y 也放开:允许"办半个园"
m4, _, _, _ = build(limit="most") # 把"恰好 3 所"改成"最多 3 所"
m5, _, _, _ = build(weighted=False) # 目标里去掉幼儿数,每个小区一样重
四个 build 用同一份数据、同一批约束,只换一个开关。这四个数一会儿会一起出现在输出里。
26.6 跑起来,先看输出
在案例目录下执行 python 建模.py:
==================================================================
求解状态: OPTIMAL
最短总路程:1283.85 人·公里(740 个孩子,平均每人 1.74 km)
------------------------------------------------------------------
候选地 办不办 在园幼儿 平均距离(km)
西环地块 办园 403 人 2.07
南市地块 不办 - -
中心地块 不办 - -
东湖地块 办园 146 人 1.43
师范地块 办园 191 人 1.26
北关地块 不办 - -
------------------------------------------------------------------
每个小区的去向:
东河新村 62 人 → 西环地块 直线 2.88 km
老码头 48 人 → 西环地块 直线 1.26 km
南市集 85 人 → 西环地块 直线 1.44 km
新路口 73 人 → 西环地块 直线 2.33 km
西苑 96 人 → 西环地块 直线 1.79 km
文化宫 58 人 → 师范地块 直线 1.25 km
中央公园 44 人 → 东湖地块 直线 1.43 km
东湖 67 人 → 东湖地块 直线 1.12 km
北窑 39 人 → 西环地块 直线 3.35 km
师范路 81 人 → 师范地块 直线 0.64 km
果园 52 人 → 师范地块 直线 2.24 km
北关外 35 人 → 东湖地块 直线 2.00 km
------------------------------------------------------------------
最远的一批:北窑 走 3.35 km
一个小区可以分给几个园:1283.85 人·公里(与原方案差 +0.00)
连'开半个园'也允许:1283.85 人·公里,没有一块地取分数(与整数解同值)
'最多 3 所'而不是'恰好 3 所':1283.85 人·公里
不看幼儿数、每个小区一样重:选 西环地块、东湖地块、师范地块,按人口折算后 1283.85 人·公里
------------------------------------------------------------------
园数扫描:
p=1 总路程 2027.84 平均 2.74 km 最远 4.83 km 省下 0.00 中心地块
p=2 总路程 1548.54 平均 2.09 km 最远 4.61 km 省下 479.30 西环地块、师范地块
p=3 总路程 1283.85 平均 1.74 km 最远 3.35 km 省下 264.69 西环地块、东湖地块、师范地块
p=4 总路程 1143.35 平均 1.54 km 最远 2.95 km 省下 140.50 西环地块、中心地块、东湖地块、师范地块
p=5 总路程 1032.00 平均 1.40 km 最远 2.95 km 省下 111.35 西环地块、南市地块、中心地块、东湖地块、师范地块
p=6 总路程 984.32 平均 1.33 km 最远 2.95 km 省下 47.68 西环地块、南市地块、中心地块、东湖地块、师范地块、北关地块
双后端核对: Gurobi=OPTIMAL obj=1283.8506484622683 t=0.0072s | COPT=OPTIMAL obj=1283.850648462268 t=0.0241s | 偏差=1.77e-16
==================================================================
逐段读。
"最短总路程:1283.85 人·公里"——这是答案。除以 740,平均每人单程 1.74 公里,走路十来分钟。
"西环地块 办园 403 人 平均 2.07"——三所里最高的一栏。西环一块地接了四百零三个孩子,占五成四,比另外两所加起来还多。它不平均,但它是总路程最短的结果。
"中心地块 不办"——全场最反直觉的一行。它位置最居中,按理该首选;可它一进来,西环、师范、东湖之间"各管一片"的分工就被打乱,总路程反而更长。居中和最短不是一回事:居中的点让最远那个孩子变近,不一定让路程之和变小。
"每个小区的去向"十二行——每行只有一个去处,没有"西苑:西环 0.6、师范 0.4"这种写法,这是 \(x_{ij}\) 取 0 或 1 的直接后果:一份能直接发到家长手里的名单。
"北窑 39 人 → 西环地块 3.35 km"——全区最远的一批。北窑在城北边缘,三块当选地都不在附近,只能划给西环。这个 3.35 是本章最大的隐患,26.8 节会看到它掉到 2.95。
"一个小区可以分给几个园:1283.85(+0.00)"——把 \(x\) 放开成比例,一分钱没省。园没有容量上限,"拆开走"只会更远,所以堵死这条路代价为零。
"连'开半个园'也允许:1283.85,没有一块地取分数"——把 \(y\) 也放开,答案还是这套。这跟前一章完全不同:那一章一放开就冒出"0.6 个站"。既然松弛等于整数解,拿它当"还能再省多少"的参考就会落空。
"'最多 3 所'而不是'恰好 3 所':1283.85"——26.4 节的分析在数上对上了:没有建园成本,名额会自动用满。再抹掉人口权重、十二个小区一样重,选出来的还是这三块。说明位置本身稳,不是被西苑那 96 个孩子顶出来的;人口数据有小误差,结论不会翻。
"园数扫描"六行——p 从 1 加到 6,总路程从 2027.84 掉到 984.32,是下一节的主角。
26.7 把答案画出来
数字读完了,再看图:每个小区划给了哪所园,如图 26-2 所示;三所园的负荷差别,如图 26-3 所示。

图 26-2 每个小区划给了哪所园
这张图告诉你什么:先看线从哪儿出发、往哪儿去。左边一大簇线全汇到西环那个深蓝方块上,一共六条,最远一条拉到北窑(3.35 公里)——这就是"403 人"的来源。师范那块绿方块只管三片:文化宫、师范路、果园,其中师范路只有 0.64 公里,是本图最短的一条。东湖那个橙方块管中央公园、东湖、北关外。中间空心的中心地块一条线都没有。这张图说明的是分工的形状:两所园靠得近,一所园管一大片,不是"每所各管三分之一"。

图 26-3 三所园的负荷与平均距离
这张图告诉你什么:先看柱子和菱形的高度差。西环柱子最高(403 人),头上的菱形也最高(2.07 公里)——收的人最多,人均路也最长。师范柱子第二高(191 人),菱形却最低(1.26 公里),它收的孩子几乎都在家门口。东湖居中。灰色虚线是全区平均 1.74 公里,只有西环在它上面。这张图摆出了一个常被忽略的事实:总路程最短的方案,内部并不平均。西环替全区扛了最难的一段,要改这一点,就得牺牲总路程,或者再加一所园。
26.8 换个条件,答案会怎样
一个答案只有配上"条件变了会怎样",才有决策价值。
条件一:编制给几个
把 \(p\) 从 1 试到 6,每档都重解一遍,总路程的变化如图 26-4 所示。
表 26-4 园数变化时的最优选址与总路程
| 园数 p | 最优选址 | 总路程(人·公里) | 平均每人(km) | 最远(km) | 比上一档省 |
|---|---|---|---|---|---|
| 1 | 中心地块 | 2027.84 | 2.74 | 4.83 | — |
| 2 | 西环、师范 | 1548.54 | 2.09 | 4.61 | 479.30 |
| 3 | 西环、东湖、师范 | 1283.85 | 1.74 | 3.35 | 264.69 |
| 4 | 西环、中心、东湖、师范 | 1143.35 | 1.54 | 2.95 | 140.50 |
| 5 | 西环、南市、中心、东湖、师范 | 1032.00 | 1.40 | 2.95 | 111.35 |
| 6 | 六块全办 | 984.32 | 1.33 | 2.95 | 47.68 |
表注:平均每人=总路程÷740;"比上一档省"是相邻两档总路程之差,单位人·公里。

图 26-4 园数从 1 加到 6 时的总路程
这张图告诉你什么:横轴是园数,纵轴是总路程,线上的每个点标着当期的最优值,点与点之间蓝色小字是"多办这一所省下的路程"。先看整体形状——一路往下,但越来越平:从一所加到两所省 479,加到三所省 265,加到四所省 141,加到五所省 111,加到六所只省 48。再看红色虚线,它标着编制给的 3 所。这条线右边还有一大截空间,说明"加园"这件事在数学上一直有效,只是越来越不值。落到决策上:第四个编制能再压下 140.50,只有第三个编制的 264.69 的一半——这个数就是去市里争编制时该说的话。
条件二:第三所与第四所的差别
第三所和第四所差在哪里,如图 26-5 所示。

图 26-5 p=3 与 p=4 的方案对比
这张图告诉你什么:两幅是同一张地图,(b)比(a)多了一个紫色方块——中心地块。先看它接手了谁:北窑那条长线(3.35 公里)不再拉到西环,改由中心地块接(2.95 公里);西苑和文化宫也转了过来。再看西环:(a)里它管六片,(b)里只剩四片,403 人降到 268 人,从"一家扛一半"变成"正常一所"。判断很直接:第四所园不是锦上添花,它把最远的那批孩子从 3.35 公里拉到了 2.95 公里,同时把西环的超载卸掉一半。若评价标准里除了"总路程"还有"公平",第四所的说服力比它省下的 140.50 更大。
条件三:四种改法的对照
表 26-5 同一份数据、四种改法下的目标值
| 写法 | 目标值(人·公里) | 与整数解之差 | 说明 |
|---|---|---|---|
| 整数解(本章模型) | 1283.85 | — | 三块地、整片划拨 |
| \(x\) 放开成比例 | 1283.85 | +0.00 | 允许一个小区分给几所园,目标不变 |
| \(y\) 也放开成比例 | 1283.85 | +0.00 | 允许"办半个园",解里没有分数 |
| 改成"最多 3 所" | 1283.85 | +0.00 | 名额会自动用满 |
| 目标里去掉幼儿数 | 1283.85 | +0.00 | 选中的还是这三块地 |
表注:四种改法都由同一份 build() 换开关得到;"与整数解之差"为正表示更差。
五个数一模一样,在整数规划里不常见。它说明这道题的答案很"结实":怎么放松、怎么改口径,选址都不动。它也让"松弛"在这道题上失效了——松弛通常给出一个乐观下界,这里下界和答案重合,没给额外信息。
顺便说说两个求解器
两个后端的逐项对比,如图 26-6 所示。

图 26-6 Gurobi 与 COPT 的逐项对比
这张图告诉你什么:从上往下逐行对。状态都是 OPTIMAL,总路程都是 1283.85;六块地的"办/不办"一行行对下来完全一致(西环、东湖、师范办,其余三块不办);十二个小区的去向也一行行对上了。唯一不同的是耗时,一个七毫秒、一个二十四毫秒,这个规模下没有参考价值。真正要确认的是:两个后端对"整片划拨"这件事的理解是一致的——\(x\) 的 0-1 性在镜像过去之后没有丢,答案也没有变。
26.9 小结与练习
三句话总结
- 数量被定死的选址问题,用两类 0-1 变量写:\(y_j\) 管"这块地办不办",\(x_{ij}\) 管"这个小区归不归它",再用一行 \(x_{ij}\le y_j\) 把两者拴住。
- "恰好 p 个"写成等号;写成小于等于能不能同解,取决于目标里有没有"多开一个要花钱"这一项——本章没有,所以两种写法同解,换个场景就可能翻盘。
- p-中位模型优化的是一群人的路程之和,不是任何一个人的路程;所以最优方案里总有人走得比平均远,讲方案时要把"总路程"和"最远的那批"分开说。
想一想
- 如果把中心地块从候选名单里删掉,p=3 的总路程会变成多少?
要点:p=3 的解里它本来就没被选中,删掉对 p=3 毫无影响,1283.85 不变;但 p=4、p=5 会变差,因为第四所园没有更好的地方可去。可以改数据.json的sites重跑验证。 - 如果西苑那 96 个孩子里有一半搬走了,答案会变吗?
要点:26.6 节把所有幼儿数抹平过,选中的还是这三块地,说明位置本身是"结构性"的,对人口误差不敏感;变的只是目标值。 - 如果每所园最多只能收 300 个孩子,模型要怎么改?
要点:加一条 \(\sum_i d_i x_{ij}\le 300\,y_j\)。这时"整片划拨"就装不下了——403 个孩子挤在西环一所园里,会被逼着拆开,\(x\) 要么允许取比例,要么把某个小区整个挪走。这正是"有容量"和"没容量"的分水岭。
动手改一改
把 数据.json 里 p 从 3 改成 4,重跑 python 建模.py。看三件事:总路程掉到 1143.35 没有;多出来的是不是中心地块;西环的在园幼儿从 403 掉到多少。再把 p 改回 3,把北窑的坐标从 (3.6, 6.6) 往南挪到 (3.6, 5.0),看它改投哪所园、总路程降多少——这一改能让你看清"挪一个点"和"加一所园"哪种更划算。
附录 代码与数据
以下为可复现材料,不计入正文字数。运行 建模.py 即可重算正文全部数字;数据.json 是输入,结果.json 是输出。
全文合 23,699 字符(正文 13,259+代码 8,591+JSON 1,525),未超 25000 上限
A.1 建模.py(全文)
"""案例 026 · 选 p 个点使总距离最小
城东新区有 12 个住宅小区、6 块候选地,教育局只给 3 个幼儿园编制。
选哪 3 块地,能让全区孩子"家到园"的总路程最短——这是选址里的 p-中位模型。
运行:
python 建模.py
输出:终端打印选中的三块地、每个小区的入园去向与走多远,双后端结果写入 结果.json
"""
import json
import math
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE.parents[2] / "src"))
import gurobipy as gp
from gurobipy import GRB
from kit import brief, save_result, solve_both
# ---------------------------------------------------------------- 读数据
DATA = json.loads((HERE / "数据.json").read_text(encoding="utf-8"))
DEMANDS = [d["key"] for d in DATA["demands"]] # 12 个小区的代号
SITES = [s["key"] for s in DATA["sites"]] # 6 块候选地的代号
DNAME = {d["key"]: d["name"] for d in DATA["demands"]} # 小区中文名
SNAME = {s["key"]: s["name"] for s in DATA["sites"]} # 候选地中文名
KIDS = {d["key"]: d["kids"] for d in DATA["demands"]} # 幼儿数(人)
POS_D = {d["key"]: (d["x"], d["y"]) for d in DATA["demands"]} # 小区坐标(km)
POS_S = {s["key"]: (s["x"], s["y"]) for s in DATA["sites"]} # 候选地坐标(km)
P = int(DATA["p"]) # 恰好要开的园数
SCAN_P = DATA["scan_p"] # p 的扫描点
TOTAL_KIDS = sum(KIDS.values()) # 全区幼儿总数(人)
# 小区 i 到候选地 j 的直线距离(km);家长走的是路网,这里按直线折算
DIST = {(i, j): math.hypot(POS_D[i][0] - POS_S[j][0], POS_D[i][1] - POS_S[j][1])
for i in DEMANDS for j in SITES}
PAIRS = [(i, j) for i in DEMANDS for j in SITES]
# ---------------------------------------------------------------- 建模
def build(p=P, mode="binary", limit="equal", weighted=True):
"""把"在哪三块地上办园"翻译成 p-中位模型。
变量:y[j] 候选地 j 开不开园(0-1;mode="relax" 时放开成 0–1 连续)
x[i,j] 小区 i 的孩子去不去 j 这所园(0-1;mode="split"/"relax" 时放开成比例)
约束:恰好开 p 所(limit="most" 时改成"最多 p 所");
每个小区的孩子必须有园可去;没开的那块地一个孩子也接不了
目标:所有孩子"家到园"的路程之和最小,单位人·公里
"""
m = gp.Model("p_median")
m.Params.OutputFlag = 0
# 变量:y 是"这块地办不办园",只有办与不办两种状态,没有"办半个"
y = m.addVars(SITES, vtype=GRB.CONTINUOUS if mode == "relax" else GRB.BINARY,
name="y")
# x 是"这个小区归哪所园",取 1 表示整片划过去
x = m.addVars(PAIRS, vtype=GRB.BINARY if mode == "binary" else GRB.CONTINUOUS,
name="x")
cons = {}
# 约束一:恰好开 p 所(编制是死数,多一个不批、少一个作废)
if limit == "equal":
cons["quota"] = m.addConstr(gp.quicksum(y[j] for j in SITES) == p,
name="quota")
else:
cons["quota"] = m.addConstr(gp.quicksum(y[j] for j in SITES) <= p,
name="quota")
# 约束二:每个小区的孩子都得有个去处,不能哪个园都不归
for i in DEMANDS:
cons[("one", i)] = m.addConstr(gp.quicksum(x[(i, j)] for j in SITES) == 1,
name=f"one_{i}")
# 约束三:只有开了的园才接得了孩子(没开的地上不能凭空冒出教室)
for (i, j) in PAIRS:
cons[("open_only", i, j)] = m.addConstr(x[(i, j)] <= y[j], name=f"link_{i}_{j}")
# 目标:孩子数 × 距离,逐对相加
coef = (lambda i: KIDS[i]) if weighted else (lambda i: 1.0)
m.setObjective(gp.quicksum(coef(i) * DIST[(i, j)] * x[(i, j)] for (i, j) in PAIRS),
GRB.MINIMIZE)
return m, y, x, cons
def read_plan(m, y, x):
"""把解读成"开了哪几所 + 每个小区去哪 + 走多远"。"""
if m.SolCount == 0:
return None
open_set = [j for j in SITES if y[j].X > 0.5]
assign = {}
for i in DEMANDS:
# 每行只有一个 1(二进制时),直接取最大的那一格
j = max(SITES, key=lambda k: x[(i, k)].X)
assign[i] = {"site": j, "km": round(DIST[(i, j)], 2), "kids": KIDS[i]}
load = {j: sum(assign[i]["kids"] for i in DEMANDS if assign[i]["site"] == j)
for j in SITES}
# 每个园的平均接送距离:按孩子数加权,空园记 0
avg = {}
for j in SITES:
served = [i for i in DEMANDS if assign[i]["site"] == j]
avg[j] = round(sum(KIDS[i] * DIST[(i, j)] for i in served) / load[j], 3) \
if served else 0.0
obj = round(float(m.ObjVal), 2)
return {
"open": open_set,
"assign": assign,
"load": load,
"site_avg_km": avg,
"objective": obj,
"avg_km": round(obj / TOTAL_KIDS, 3),
"max_km": max(a["km"] for a in assign.values()),
"max_from": max(DEMANDS, key=lambda i: assign[i]["km"]),
"frac_open": [j for j in SITES if 1e-6 < y[j].X < 1 - 1e-6],
}
def scan_p():
"""p 从 1 加到 6,看总路程怎么掉,顺带记下每个 p 的最优选址。"""
rows = []
prev = None
for p in SCAN_P:
m, y, x, _ = build(p=p)
m.optimize()
plan = read_plan(m, y, x)
rows.append({
"p": p,
"obj": plan["objective"],
"avg": plan["avg_km"],
"max": plan["max_km"],
"open": plan["open"],
"assign": {i: plan["assign"][i]["site"] for i in DEMANDS},
"load": plan["load"],
"save": round(prev - plan["objective"], 2) if prev is not None else 0.0,
})
prev = plan["objective"]
return rows
def variant(mode="binary", p=P, limit="equal", weighted=True):
"""跑一个对照模型,返回目标值、开的地块、以及分数地块清单。"""
m, y, x, _ = build(p=p, mode=mode, limit=limit, weighted=weighted)
m.optimize()
if m.SolCount == 0:
return {"obj": None, "open": [], "frac": []}
frac = [{"site": j, "value": round(float(y[j].X), 3)}
for j in SITES if 1e-6 < y[j].X < 1 - 1e-6]
return {"obj": round(float(m.ObjVal), 2),
"open": [j for j in SITES if y[j].X > 0.5],
"frac": frac}
def weighted_value(open_set):
"""给定开哪几块地,算"每个孩子都去最近的那所"的总路程(人·公里)。"""
total = 0.0
for i in DEMANDS:
total += KIDS[i] * min(DIST[(i, j)] for j in open_set)
return round(total, 2)
# ---------------------------------------------------------------- 运行
def main():
m, y, x, cons = build()
# 同一份模型交给两个求解器各解一次
res = solve_both(m, name="026_选 p 个点使总距离最小")
plan = read_plan(m, y, x)
scan = scan_p()
split = variant("split") # 允许一个小区分给几个园
relax = variant("relax") # 连"开半个园"也允许
most = variant("binary", limit="most") # 最多 3 所,而非恰好 3 所
flat = variant("binary", weighted=False) # 不看幼儿数、每个小区一样重
res["case"] = {
"p": P,
"demands": DEMANDS,
"sites": SITES,
"kids": KIDS,
"dist": {f"{i}|{j}": round(DIST[(i, j)], 3) for (i, j) in PAIRS},
"total_kids": TOTAL_KIDS,
"plan": plan,
"objective": res["gurobi"]["obj"],
"scan": scan,
"split_objective": split["obj"],
"relax_objective": relax["obj"],
"relax_frac": relax["frac"],
"most_objective": most["obj"],
"equal_weight": {
"open": flat["open"],
"own_objective": flat["obj"],
"weighted_objective": weighted_value(flat["open"]),
},
}
save_result(res, HERE / "结果.json")
# ---------------- 终端输出(书中要逐行解释这些行)----------------
print("=" * 66)
print("求解状态:", res["gurobi"]["status"])
print(f"最短总路程:{plan['objective']:.2f} 人·公里"
f"({TOTAL_KIDS} 个孩子,平均每人 {plan['avg_km']:.2f} km)")
print("-" * 66)
print(" 候选地 办不办 在园幼儿 平均距离(km)")
for j in SITES:
mark = "办园" if j in plan["open"] else "不办"
if j in plan["open"]:
print(f" {SNAME[j]:<8} {mark:>4} {plan['load'][j]:5d} 人"
f" {plan['site_avg_km'][j]:5.2f}")
else:
print(f" {SNAME[j]:<8} {mark:>4} - -")
print("-" * 66)
print("每个小区的去向:")
for i in DEMANDS:
a = plan["assign"][i]
print(f" {DNAME[i]:<6} {KIDS[i]:3d} 人 → {SNAME[a['site']]} 直线 {a['km']:.2f} km")
print("-" * 66)
print(f"最远的一批:{DNAME[plan['max_from']]} 走 {plan['max_km']:.2f} km")
print(f"一个小区可以分给几个园:{split['obj']:.2f} 人·公里"
f"(与原方案差 {split['obj'] - plan['objective']:+.2f})")
if relax["frac"]:
frac = "、".join(f"{SNAME[f['site']]} {f['value']:.2f} 所" for f in relax["frac"])
print(f"连'开半个园'也允许:{relax['obj']:.2f} 人·公里,其中 {frac}")
else:
print(f"连'开半个园'也允许:{relax['obj']:.2f} 人·公里,没有一块地取分数"
f"(与整数解同值)")
print(f"'最多 3 所'而不是'恰好 3 所':{most['obj']:.2f} 人·公里")
print(f"不看幼儿数、每个小区一样重:选 {('、'.join(SNAME[j] for j in flat['open']))}"
f",按人口折算后 {weighted_value(flat['open']):.2f} 人·公里")
print("-" * 66)
print("园数扫描:")
for row in scan:
who = "、".join(SNAME[j] for j in row["open"])
print(f" p={row['p']} 总路程 {row['obj']:8.2f} 平均 {row['avg']:4.2f} km"
f" 最远 {row['max']:4.2f} km 省下 {row['save']:6.2f} {who}")
print("双后端核对:", brief(res))
print("=" * 66)
if __name__ == "__main__":
main()
A.2 数据.json
{"story":{"city":"城东新区","topic":"三所公办幼儿园落在哪三块地上","walk":"孩子从家门口走到园门口","quota":"教育局只给三个编制,多一个不批,少一个作废"},"unit":{"distance":"km","kids":"人","objective":"人·公里"},"p":3,"scan_p":[1,"……此处截断:本数组原共 6 项,仅保留前 1 项,完整见同名 json"],"demands":[{"key":"d01","name":"东河新村","x":1.0,"y":5.8,"kids":62,"note":"老小区,年轻家庭多,园位缺口最大的一块"},"……此处截断:本数组原共 12 项,仅保留前 1 项,完整见同名 json"],"sites":[{"key":"s1","name":"西环地块","x":2.6,"y":3.4,"note":"街道腾退的办公用地,四面都能开门"},"……此处截断:本数组原共 6 项,仅保留前 1 项,完整见同名 json"]}
A.3 结果.json
{"name":"026_选 p 个点使总距离最小","gurobi":{"status":"(更深层内容已略去,完整见同名 json)","runtime_s":"(更深层内容已略去,完整见同名 json)","obj":"(更深层内容已略去,完整见同名 json)","values":"(更深层内容已略去,完整见同名 json)","gap":"(更深层内容已略去,完整见同名 json)","nodes":"(更深层内容已略去,完整见同名 json)","iters":"(更深层内容已略去,完整见同名 json)"},"copt":{"status":"(更深层内容已略去,完整见同名 json)","runtime_s":"(更深层内容已略去,完整见同名 json)","obj":"(更深层内容已略去,完整见同名 json)","values":"(更深层内容已略去,完整见同名 json)","gap":"(更深层内容已略去,完整见同名 json)"},"compare":{"same_status":"(更深层内容已略去,完整见同名 json)","rel_diff_obj":"(更深层内容已略去,完整见同名 json)","note":"(更深层内容已略去,完整见同名 json)"},"case":{"p":"(更深层内容已略去,完整见同名 json)","demands":"(更深层内容已略去,完整见同名 json)","sites":"(更深层内容已略去,完整见同名 json)","kids":"(更深层内容已略去,完整见同名 json)","dist":"(更深层内容已略去,完整见同名 json)","total_kids":"(更深层内容已略去,完整见同名 json)","plan":"(更深层内容已略去,完整见同名 json)","objective":"(更深层内容已略去,完整见同名 json)","scan":"(更深层内容已略去,完整见同名 json)","split_objective":"(更深层内容已略去,完整见同名 json)","relax_objective":"(更深层内容已略去,完整见同名 json)","relax_frac":"(更深层内容已略去,完整见同名 json)","most_objective":"(更深层内容已略去,完整见同名 json)","equal_weight":"(更深层内容已略去,完整见同名 json)"}}

浙公网安备 33010602011771号