第 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 节说的那两层"非此即彼":

\[x_{ij}\in\{0,1\},\qquad y_j\in\{0,1\} \]

第四步:把三条限制写出来。

第一条,园数正好是三个:

\[\sum_{j=1}^{6} y_j = 3 \]

这条式子用了等号,26.4 节会专门讲为什么。

第二条,每个小区必须有一个确定的去处:

\[\sum_{j=1}^{6} x_{ij} = 1,\qquad i=1,\dots,12 \]

要是允许它小于 1,模型会把偏远的北窑直接"不安排",总路程立刻好看,可那三十九个孩子没学上。

第三条,没办园的地上接不了孩子:

\[x_{ij}\le y_j,\qquad \text{对每一对}(i,j) \]

这条叫关联约束,把两类变量拴在一起:\(y_j=0\) 时右边是 0,\(x_{ij}\) 只能取 0,没办园的地上一个孩子也过不来;\(y_j=1\) 时右边是 1,它就不起作用。

第五步:把"想要"写成一个数。

小区 \(i\) 的孩子划给 \(j\),这些孩子每天的单程路就是 \(d_i a_{ij}\)(人数乘距离),单位"人·公里"。全部加起来:

\[\sum_{i=1}^{12}\sum_{j=1}^{6} d_i\,a_{ij}\,x_{ij} \]

这个要尽量小的数就是目标函数。\(x_{ij}\) 只取 0 或 1,每一项要么整份算、要么一分不算——这就是"整片划过去"。


26.4 完整的数学模型

装起来:

\[\begin{aligned} \text{最小化:}\quad & \sum_{i=1}^{12}\sum_{j=1}^{6} d_i\,a_{ij}\,x_{ij} && \text{(全区孩子的总路程)}\\[2pt] \text{满足:}\quad & \sum_{j=1}^{6} y_j = 3 && \text{(编制就三个,不多不少)} \\ & \sum_{j=1}^{6} x_{ij} = 1,\quad i=1,\dots,12 && \text{(每个小区都有确定的去处)} \\ & x_{ij}\le y_j && \text{(没办园的地上接不了孩子)} \\ & x_{ij}\in\{0,1\},\quad y_j\in\{0,1\} && \text{(办不办、归不归,都是二选一)} \end{aligned} \]

四条式子各防一件事:"多办一所""漏掉一个小区""孩子被划到没建的园里去""办 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 小结与练习

三句话总结

  1. 数量被定死的选址问题,用两类 0-1 变量写:\(y_j\) 管"这块地办不办",\(x_{ij}\) 管"这个小区归不归它",再用一行 \(x_{ij}\le y_j\) 把两者拴住。
  2. "恰好 p 个"写成等号;写成小于等于能不能同解,取决于目标里有没有"多开一个要花钱"这一项——本章没有,所以两种写法同解,换个场景就可能翻盘。
  3. p-中位模型优化的是一群人的路程之和,不是任何一个人的路程;所以最优方案里总有人走得比平均远,讲方案时要把"总路程"和"最远的那批"分开说。

想一想

  1. 如果把中心地块从候选名单里删掉,p=3 的总路程会变成多少?
    要点:p=3 的解里它本来就没被选中,删掉对 p=3 毫无影响,1283.85 不变;但 p=4、p=5 会变差,因为第四所园没有更好的地方可去。可以改 数据.json 的 sites 重跑验证。
  2. 如果西苑那 96 个孩子里有一半搬走了,答案会变吗?
    要点:26.6 节把所有幼儿数抹平过,选中的还是这三块地,说明位置本身是"结构性"的,对人口误差不敏感;变的只是目标值。
  3. 如果每所园最多只能收 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)"}}
posted @ 2026-11-13 18:05  小青蛙电气  阅读(2)  评论(0)    收藏  举报