案例 059 续航不够跑完全部单
案例 059 续航不够跑完全部单
摘要:满电 48 km,扣掉 15% 安全边际只剩 40.8 km,而手上 10 单往返合计 52.0 km、总收益 129.0 元——这一趟无论如何跑不完,必须弃单。本文把它写成「选单 + 排路」的混合整数模型:0-1 变量 \(x_{i}\) 决定接不接,续航约束 \(\sum d_{i}x_{i}\le E\) 卡死里程,位次变量 \(z_{i,k}\) 与起点、完工时刻 \(S_{k},C_{k}\) 卡死承诺时间窗,目标为总收益最大。Gurobi 在根节点即证最优:接 8 单、40.6 km、105.8 元,弃掉 S02、S09 两单共 23.2 元(18.0%)。与之对照,按收益里程比贪心得 98.0 元、按距离近优先得 91.7 元,分别少 7.8 元与 14.1 元。LP 松弛给出的续航影子价格是 2.181 元/km,比最优解的平均单位里程收益 2.606 元/km——边际那一公里不值钱,整块续航才值钱,这正是「差一点就够」最容易骗人的地方。
关键词:续航约束;选单背包;时间窗排路;影子价格;混合整数规划;弃单损失
1 问题提出
1.1 问题是什么
11:00 出门,电动车满电标称 48 km(满载口径)。老骑手都知道不能把 48 km 跑干:最后 15% 电量电压塌得快,实际能用的只有 40.8 km。这一批派来 10 单,往返里程(含回到取餐区)合计 52.0 km,收益合计 129.0 元。缺口 11.2 km,等于必须放弃两单左右。
十单的明细见表 1。里程从 2.4 km 的杨箕到 7.8 km 的南站,收益从 6.4 元到 22.0 元,单位里程收益 \(w_{i}/d_{i}\) 在 1.96 到 2.94 元/km 之间摆动。注意这个比值与里程并不单调:南站(7.8 km)只有 1.96 元/km,大学城(7.5 km)却有 2.93 元/km——远单里有肥有瘦,这就是「按远近挑单」必然出错的原因。
表 1 十单的里程、收益、时间窗与最优取舍(里程含返回取餐区,时刻为出门后的相对时刻折算)
| 订单 | 主要区域 | \(d_{i}\) /km | \(w_{i}\) /元 | \(w_{i}/d_{i}\) | \(s_{i}\) /min | 时间窗 | 最优 |
|---|---|---|---|---|---|---|---|
| S01 | 珠江新城 | 2.6 | 7.0 | 2.69 | 6 | 11:00—11:40 | 入选 |
| S02 | 体育西 | 3.6 | 7.9 | 2.19 | 7 | 11:00—11:55 | 弃单 |
| S03 | 东山口 | 4.1 | 11.1 | 2.71 | 7 | 11:00—12:15 | 入选 |
| S04 | 北京路 | 4.8 | 14.1 | 2.94 | 7 | 11:00—12:40 | 入选 |
| S05 | 同福路 | 5.1 | 13.5 | 2.65 | 9 | 11:00—13:05 | 入选 |
| S06 | 客村 | 6.9 | 16.0 | 2.32 | 8 | 11:30—13:35 | 入选 |
| S07 | 龙洞 | 7.2 | 15.7 | 2.18 | 5 | 11:45—14:00 | 入选 |
| S08 | 大学城 | 7.5 | 22.0 | 2.93 | 6 | 11:45—14:15 | 入选 |
| S09 | 南站 | 7.8 | 15.3 | 1.96 | 5 | 11:45—14:25 | 弃单 |
| S10 | 杨箕 | 2.4 | 6.4 | 2.67 | 6 | 11:00—11:30 | 入选 |
时间窗是第二个约束。每单有最早到店时刻 \(a_{i}\)(客户要求几点之后送)与承诺送达时刻 \(b_{i}\),单次往返耗时 \(p_{i}=60d_{i}/v+s_{i}\),\(v=18\) km/h、\(s_{i}\) 为取餐加交付的服务时间。续航管「跑不跑得回来」,时间窗管「来不来得及」,两者叠加才是真正可行的接单表。
1.2 为什么要优化
第一,弃单不是「少赚一点」,而是「少赚哪一点」。十单全接 129.0 元,实际只能拿到 105.8 元,缺口 23.2 元。这 23.2 元里有多少是「必须丢的」,有多少是「选错了丢的」,只有把可行域算清楚才知道。
第二,按里程挑单与按收益挑单会给出完全不同的答案。距离优先接满 8 单只拿 91.7 元,比价贪心接 8 单拿 98.0 元,两者都自认为「装满了一箱电」,实际差 6.3 元。同一块电池,装法不同,收益差 6.9%。
第三,续航的价格必须能报出来。多带一块电池、少绕一个路口、多留 5% 电,值多少钱?这是骑手和站长每天都在拍脑袋的问题,影子价格把它变成一个数。
1.3 Gurobi 如何求解
两步合一:选单用 0-1 变量 \(x_{i}\),排路用位次变量 \(z_{i,k}\)(第 \(k\) 个送的是不是 \(i\)),时刻用连续变量 \(S_{k},C_{k}\)。三类变量由式 (3)—(5) 串起来,续航只出现一条不等式 (2)。模型规模 10 个 \(x\)、100 个 \(z\)、20 个时刻变量,约束约 60 条,根节点即证最优,单次求解 0.02 s。影子价格另建一个只留续航约束的 LP 取 \(\lambda\)(MIP 没有 \(\pi\) 属性,这是必须绕的一步)。
2 数学模型
2.1 符号说明
\(i=1,\dots,n\) 为订单(\(n=10\));\(d_{i}\) 为往返里程(km);\(w_{i}\) 为收益(元);\(s_{i}\) 为服务时间(min);\([a_{i},b_{i}]\) 为时间窗;\(p_{i}\) 为单次往返耗时;\(E\) 为可用续航;\(E_{\max}\) 为标称续航;\(\delta\) 为安全边际;\(\varphi\) 为超里程罚金(元/km);\(y\) 为超里程量;\(v\) 为均速;\(T\) 为班次时长;\(z_{i,k}\) 为位次指派;\(S_{k},C_{k}\) 为第 \(k\) 站的出发与完工时刻。
2.2 目标函数
第一项是接单收益,第二项只在「允许少量超里程」的灵敏度实验里出现:\(\varphi=0\) 时模型会把 52 km 全接下来,\(\varphi\to\infty\) 时退化成硬约束。主模型取 \(\varphi\) 足够大(等价 \(y=0\)),因为推车的代价远高于几单收益。
2.3 约束条件
续航是硬约束,而且 \(E\) 里已经扣掉安全边际 \(\delta=0.15\):\(E=48\times0.85=40.8\) km。题给的 48 km 是满载标称值,不是可用值;把 48 直接写进约束,就是「算出来可行、出门就推车」的经典建模口径错误。
每个接了的单必须占一个位次,每个位次最多放一单。\(z\) 与 \(x\) 的这条等式是「选单」与「排路」之间唯一的桥:没有它,选出来的单就没有顺序,时间窗无从谈起。
完工时刻的递推:第 \(k\) 站的完工等于出发加往返与服务时间;出发不能早于上一站完工(不许穿越)。这里不设「回仓」环节,因为 \(d_{i}\) 已经按「往返并回到取餐区」口径给了。
时间窗的两条边:出发不能早于客户允许的最早时刻 \(a_{i}\)(早到只能等),完工不能晚于承诺时刻 \(b_{i}\)。\(M\) 取 600(班次时长 210 的两倍多),不要用 \(10^{6}\)——大 M 太大,松弛界会烂掉,分支定界多跑几十个节点。
班次总时长约束,以及影子价格的定义:\(\lambda\) 就是「多 1 km 续航,最优收益涨多少」。它由 LP 松弛的对偶解给出,是分段常数——因为背包的边际单换一次,价格就跳一档。
2.4 规模与性质
10 个 0-1 选单变量、100 个位次变量、20 个时刻变量,约束约 60 条,是非对称旅行商与背包的混合体,但因 \(d_{i}\) 已含返程、站间无需再算距离,实际退化为「带排路的背包」。可行子集只有 1010 个(1024 个子集中扣除超里程与超时窗的),规模小到可以全枚举验证——本文的帕累托前沿与收益分布就是用枚举做的,与 MIP 结果互为交叉校验。 最优解 8 单、40.6 km,与可用续航 40.8 km 只差 0.2 km。
3 Gurobi 求解实现
3.1 建模要点
for k in range(N):
md.addConstr(gp.quicksum(z[i, k] for i in range(N)) <= 1)
pr = 0.0 if k == 0 else Cv[k-1]
md.addConstr(Cv[k] == Sv[k] + gp.quicksum(p[i]*z[i, k] for i in range(N)))
md.addConstr(Sv[k] >= pr) # 不穿越
md.addConstr(Sv[k] >= gp.quicksum(A[i]*z[i, k] for i in range(N))) # 最早到店
md.addConstr(Cv[k] <= gp.quicksum(B[i]*z[i, k] for i in range(N))
+ 600.0*(1 - gp.quicksum(z[i, k] for i in range(N)))) # 承诺时刻
md.addConstr(gp.quicksum(D[i]*x[i] for i in range(N)) <= E + y, name="range")
两个实现细节。第一,位次是「空位」也允许的:\(\sum_i z_{i,k}\le 1\) 而不是 \(=1\),否则接 8 单就必须凑满 10 个位次,模型直接不可行。空位处 \(S_{k},C_{k}\) 取 0 也无害,因为目标与约束都不含它们。第二,时间窗用「和式 + 大 M」而不是「逐对 \(z\)」:\(\sum_i b_{i}z_{i,k}\) 在空位处为 0,若不加 \(M(1-\sum z)\) 这一项,空位会被强行要求 \(C_{k}\le 0\),等于禁止一切空位。
3.2 求解结果
表 2 三种选单方案的对照(可用续航 $E=40.8$ km,班次 $T=210$ min)
| 方案 | 接单数 | 总里程 /km | 续航利用率 /% | 总收益 /元 | 被弃收益 /元 | 相对最优 /% |
|---|---|---|---|---|---|---|
| 比价贪心(\(w/d\) 降序) | 8 | 37.0 | 90.7 | 98.0 | 31.0 | -7.4 |
| 距离优先(\(d\) 升序) | 8 | 36.7 | 90.0 | 91.7 | 37.3 | -13.3 |
| MIP 最优 | 8 | 40.6 | 99.5 | 105.8 | 23.2 | 0.0 |
三行读下来:比价贪心 98.0 元、距离优先 91.7 元、MIP 105.8 元。三行都是 8 单,收益却分三档——同一块电池,装法不同,差出 14.1 元。 距离优先把 7.5 km 的大学城单(22.0 元、2.93 元/km)挤出了车,换进来 7.2 km 的龙洞(15.7 元、2.18 元/km),一进一出少 6.3 元;比价贪心挑对了单,却留下 3.8 km 空电装不进任何一单。两个病灶:一个不会挑,一个不会塞。
4 结果分析

图 1 最优排路的时序:条长为单次往返加交付的耗时,虚线到承诺时刻
4.1 最优排路长什么样 图 1 是 Gurobi 排出来的顺序:杨箕(2.4 km)先走,因为它的承诺时刻最早(11:30);珠江新城、东山口、北京路依次跟上;客村、龙洞、大学城三单的客户要求 11:30、11:45、11:45 之后才能上门,结果一次都没有真正等过——前六单跑完已经 98 min,早就过了这些时刻,最早到店约束 \(a_{i}\) 在这套排法里是松的(图 1 的条全是同一颜色)。最后一单 189.3 min 完工,离班次终点还有 20.7 min 余量。这 20.7 min 不是浪费,而是对付红灯、等电梯、客户不接电话的缓冲——把它也排满,就是另一种推车方式。

图 2 十单的收益—里程分布,气泡面积为服务时长,虚线为影子价格与平均单位里程
4.2 收益—里程的真实形状 图 2 里最扎眼的是右上角那个点:大学城 7.5 km、22.0 元、2.93 元/km,是全场最肥的单;而南站 7.8 km 只有 15.3 元(1.96 元/km),几乎贴着影子价格线。入选的八单里最低的龙洞是 2.18 元/km,恰好压在影子价格线上;弃掉的两单一个是线下的南站(1.96),一个是刚过线的体育西(2.19)。 两条参考线的距离说明了本题的核心矛盾:平均 2.606 元/km 远高于边际 2.181 元/km,用平均值做决策会高估每一公里的价值,用边际值做决策又会漏掉「整块替换」的机会。

图 3 三种选单方案的总收益与续航利用率
4.3 三方案对比 图 3 三根柱:98.0、91.7、105.8 元;右轴是续航利用率 90.7%、90.0%、99.5%。MIP 只比比价贪心多 7.8 元(7.4%),接单数相同,但多跑 3.6 km;距离优先也接了 8 单,收益却低 14.1 元(13.3%)。 更关键的是那条利用率曲线:比价贪心只用了 90.7%,白留 3.8 km 的电;距离优先用了 90.0%,也留 4.1 km。贪心算法在背包问题上的通病不是「选错单」,而是「装不进去就停手」,剩下的里程谁也补不上。

图 4 三方案的累计里程阶梯,越过 40.8 km 就意味着推车
4.4 电是怎么一公里一公里没的 图 4 把三方案的累计里程画成阶梯,横轴是第几单。比价贪心在第 8 单停在 37.0 km,距离优先停在 36.7 km,两条线都离 40.8 km 的虚线有一段明显空白,而 MIP 那条一路顶到 40.6 km——这 3.6 km 的差别,就是 7.8 元的来源。 图里那条 48 km 的实线与 40.8 km 虚线之间的粉色带是安全边际 7.2 km:它的存在不是为了好看,而是为了让「算出来刚好够」变成「实际也够」。

图 5 可用续航从 33 km 放到 48 km 的最优收益与弃单数
4.5 续航的主灵敏度 图 5 左轴最优收益、右轴弃单数。可用续航从 33 km 放到 48 km,收益从 86.9 元升到 111.5 元,弃单从 3 单降到 2 单。曲线是阶梯状而不是直线,而且弃单数不是单调的(3→4→3→2):续航从 33 km 放到 36 km 时,模型宁可少接一单、把剩下的里程让给更肥的远单,收益反而涨了 5.5 元。 这正是整数规划与线性规划的分野,也是「续航多 1 km 值多少钱」只能回答成区间的原因。 本案的 40.8 km 落在 105.8 元那一档的右端,再往下走 2.4 km 就会掉一单。

图 6 可用续航与超里程罚金二维扫描下的净收益,黄框为本案参数
4.6 续航 × 罚金 图 6 是 4×5 热图,行是超里程罚金 \(\varphi\),列是可用续航。同一行从左到右变深(续航越长越赚),同一列从上到下变浅(罚金越高越不敢超),最浅的格子出现在右上。 定睛看 \(\varphi=1\) 那一行:净收益 103.2—111.5 元,比硬约束下还高,因为模型宁可按 1 元/km 买超里程也不肯弃单——这说明「超里程」的真实代价如果只有换电的 2 元,那么硬约束本身就过于保守;而 \(\varphi=16\) 那一列在本案续航下净收益 105.8 元,与硬约束解一字不差,说明本案的「不许超」等价于「罚金 16 元/km 以上」。

图 7 平均车速对最优收益与弃单数的影响(第三维)
4.7 车速这第三个维度 图 7 左轴收益、右轴弃单数,横轴均速。车速从 14 km/h 提到 24 km/h,收益从 94.2 元升到 105.8 元。注意这条曲线在 18 km/h 之后的抬升变缓:再快也接不了新单,因为瓶口是续航不是时间。 反之在 16 km/h 以下,时间窗开始咬人,弃单数抬头——这就是「续航约束与时间窗约束谁先紧」的切换点,本案恰好在续航紧、时间松的一侧。

图 8 续航—收益前沿与超续航不可行区,三个圆点为三方案
4.8 前沿与不可行区 图 8 的横轴是本趟总里程、纵轴是收益,曲线是 1024 个子集枚举出来的前沿(每个里程上限下的最大收益)。曲线一直爬到 45.5 km 的 111.5 元:多出来的 4.9 km 能让模型换一整套单——舍掉东山口(11.1 元)与杨箕(6.4 元),换回体育西(7.9 元)与南站(15.3 元),净赚 5.7 元。续航缺口 4.9 km 的直接代价就是 5.7 元。 40.8 km 右侧的灰区是「必须弃单」的不可行区,全部十单那一点(52 km、129.0 元)根本进不了可行域。三个圆点里,距离优先那个点低得最明显:它在 36.7 km 处就停手了,离前沿还有 14.1 元。

图 9 全部可行子集的收益分布与累计概率
4.9 可行子集的收益分布 图 9 是满足续航的 977 个子集的收益直方图(右轴为累计概率;时间窗可行的子集共 1010 个)。分布的中位数是 63.2 元,最优 105.8 元已经落在最右侧不足 1% 的尾部,次优子集只有 102.6 元——也就是说,从 1024 种接法中挑对,只比「随便挑一个好点的」多赚不到 3.2 元。 这个分布解释了为什么老骑手的经验往往够用:在 10 单的规模上,前 5% 与最优的差距是几块钱;但一旦订单数到 30,尾部会迅速拉开。

图 10 单位里程收益与续航影子价格的对照,线下即应弃的单
4.10 每一单的边际账 图 10 把十单按单位里程收益排开,横线是影子价格 2.181 元/km。线以下的南站(1.96 元/km)必弃;另一张被弃的体育西(2.19 元/km)只比线高 0.013,照样被弃——因为总里程要砍 11.2 km,弃掉南站只省 7.8 km,还差 3.4 km 必须再砍一单,而 3.6 km 的体育西是全场第二便宜的牺牲(7.9 元)。 这就是单约束背包的「整块替换」效应:影子价格只对「边际那一单」成立,对「补不齐的那块缺口」无效。 单位里程最高的三单是 S04、S08、S03(2.94、2.93、2.71 元/km),全部入选——说明比价排序在「挑谁」这一层是对的,错的是它不会回头看剩下的 3.8 km 能换什么。
5 结论
- 续航不够时,正确答案是弃 2 单(10 单里接 8 单),弃掉 23.2 元,保留 105.8 元。 具体是 S02、S09(7.9、15.3 元、3.6、7.8 km):一单是单位里程最差的南站,另一单是与别人抢续航的体育西。弃单率 18.0% 已经是把 40.8 km 用到 40.6 km(利用率 99.5%)之后的最小损失。
- 比价贪心差 7.8 元(7.4%),距离优先差 14.1 元(13.3%),差距不在「选哪单」而在「装不装得满」。 两个贪心都留了 3.8 与 4.1 km 的空电,因为它们的停止规则是「下一单放不进就收手」,而 MIP 会回头换一整块。这条差距可以换算成钱:一箱电按 105.8 元算,空 4 km 电就是白扔 10.4 元。
- 续航的影子价格是 2.181 元/km,不到平均单位里程收益 2.606 元/km 的 83.7%。 这个差不是模型不准,而是边际与平均的差别:多 1 km 只能给「最后一单」用,边际那一单的单位收益最低。 所以「续航多 5 km 能多赚 5×2.606=13.0 元」是错的,正确的说法是「可能多赚 10.9 元,也可能一分不多」——图 5 的阶梯就是证据。
- 安全边际必须写进参数,而不是指望约束兜底。 \(E=48\times(1-0.15)=40.8\) km,比标称少 7.2 km。如果把 48 直接写进约束,最优收益会跳到 111.5 元(多 5.7 元),代价是要真跑 45.5 km——比可用的 40.8 km 多 4.7 km,正好是推车的距离。 本题的「差一点」不是修辞:最优解离上限只差 0.2 km,任何一次绕路都吃掉了这个余量。
- 时间窗在本案不紧,但它决定了「续航松下来之后」的解。 车速降到 16 km/h 以下,弃单数开始抬头、收益掉到 100.6 元;车速提上去,收益也不再涨(105.8 元封顶)。这说明两约束的关系是「串行」而不是「并行」:先被续航卡住,续航放宽后才轮到时间窗。 站点排班时应先看这批单的里程总量与电池容量,再谈时效——顺序反了,优化就是在算一个不存在的瓶颈。
参考文献
[1] Gurobi Optimization LLC. Gurobi Optimizer Reference Manual[Z]. 2024.
[2] Kellerer H, Pferschy U, Pisinger D. Knapsack Problems[M]. Berlin: Springer, 2004.
[3] Martello S, Toth P. Knapsack Problems: Algorithms and Computer Implementations[M]. Chichester: John Wiley & Sons, 1990.
[4] 司守奎, 孙玺菁. 数学建模算法与应用[M]. 3 版. 北京: 国防工业出版社, 2021.

浙公网安备 33010602011771号