ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

5G应急物资配送动态调度:LNS算法与Python实现

5G应急物资配送动态调度:LNS算法与Python实现

简介:本资源为2022年电工杯数学建模竞赛B题「5G网络环境下应急物资配送问题」的完整解题资料包,面向参加数学建模竞赛的高校学生、希望学习路径优化与启发式算法的进阶学习者,也可作为课程设计、大作业或工程实训的参考案例。资源共18个文件,以11个Python脚本为核心,涵盖蚁群算法、车辆路径规划与无人机协同配送等求解模块,另含3个xlsx数据附件、1份赛题PDF说明及README文档,压缩包约272KB,结构清晰便于按模块查阅。目前已有209人学习下载。读者可从中获取完整的赛题建模思路、算法实现代码与数据文件,理解卡车与无人机联合配送的建模过程、参数配置与求解流程,并借助多版本算法脚本对比不同实现方式,快速复现结果并迁移到同类应急物资调度场景中。

1. 应急物资配送遇上5G:这道题到底在考什么

2022年电工杯B题把「5G网络环境下应急物资配送」摆上台面,很多人第一反应是套一个VRP(车辆路径规划)模型就完事。但真正做过应急调度系统的人知道,这题的核心矛盾不在路径算法本身,而在于5G带来的实时性把传统「先规划后执行」的静态调度逻辑彻底掀翻了。灾区路况每分钟都在变,物资需求点随时新增,通信延迟从4G的几十毫秒压到5G的几毫秒——这意味着你的调度算法必须能在秒级完成重规划,而不是离线跑一次遗传算法就交差。

这道题适合三类人:正在做应急物流调度的工程师、想拿数学建模竞赛练手的同学、以及需要把5G低延迟特性落到实际业务场景的产品经理。它解决的不是「怎么算最短路径」这种老问题,而是「当通信不再是瓶颈时,调度系统应该长什么样」。下面我从建模思路、代码实现、参数调优到踩坑记录,把这道题拆成能直接复现的方案。

2. 从题目到数学模型:应急物资配送的约束怎么翻译成代码

2.1 先搞清楚5G在这题里到底改变了什么

传统应急物资配送建模,通信延迟通常被忽略或者当成固定常数。但在5G环境下,这个假设不成立了。5G的三个特性直接改写约束条件:增强移动宽带(eMBB)让高清路况视频能实时回传,超可靠低延迟通信(URLLC)让车辆与调度中心的双向指令延迟降到10ms以内,大规模机器通信(mMTC)让每个物资包装上传感器成为可能。

落到数学模型上,这意味着三件事。第一,需求点的物资消耗速率可以实时上报,不需要按天预估。第二,车辆位置和路况信息可以秒级更新,路径规划从静态VRP变成动态VRP。第三,调度中心可以同时监控上百台车辆和上千个需求点,计算规模上去了但通信不再是瓶颈。

我一般会把问题定义成:在时间窗约束下,最小化总配送时间和物资短缺惩罚的加权和。目标函数长这样:

min Z = α * Σ(车辆行驶时间) + β * Σ(需求点缺货惩罚) + γ * Σ(车辆超时惩罚)

其中α、β、γ是权重系数,需要根据实际场景调。灾区场景下β通常要设得很大,因为缺货的代价远高于多跑几公里。

2.2 约束条件的形式化:时间窗、载重、动态需求

把题目里的文字约束翻译成数学表达式,是建模最关键的一步。我见过太多人直接跳到写代码,结果约束漏了导致结果不可行。

时间窗约束:每个需求点i有一个可接受的服务时间窗[ei, li],车辆到达时间ti必须满足ei ≤ ti ≤ li。如果提前到了要等待,迟到了要罚。

载重约束:车辆k在任意时刻的载重不能超过Qk,且配送过程中载重单调不增(假设不回收)。

动态需求约束:在时刻t,新需求点集合N(t)可能增加,车辆需要在线重规划。这是5G环境下特有的,传统离线VRP不需要考虑。

用Python把约束写成可调用的形式:

# 需求点数据结构 class DemandNode: def __init__(self, id, x, y, demand, e, l, appear_time=0): self.id = id # 节点编号 self.x = x # 横坐标 self.y = y # 纵坐标 self.demand = demand # 物资需求量(kg) self.e = e # 时间窗最早开始 self.l = l # 时间窗最晚结束 self.appear_time = appear_time # 动态出现时刻(5G场景) # 车辆数据结构 class Vehicle: def __init__(self, id, capacity, speed, start_depot): self.id = id self.capacity = capacity # 载重上限(kg) self.speed = speed # 平均速度(km/h) self.route = [start_depot] # 已规划路径 self.load = 0 # 当前载重 self.current_time = 0 # 当前时刻

这段代码的关键在于appear_time字段。传统VRP里所有需求点一开始就存在,但5G场景下需求点可能在配送过程中才出现。调度算法必须支持增量式插入,而不是从头重算。

2.3 动态重规划的触发条件与算法选型

什么时候触发重规划?我的经验是设三个触发器:新需求点出现、车辆实际到达时间偏离计划超过阈值、路况变化导致预计行驶时间增加超过20%。这三个条件满足任意一个就触发。

算法选型上,静态VRP用遗传算法或模拟退火都行,但动态场景下我推荐大邻域搜索(LNS)。原因很简单:LNS的destroy-repair机制天然适合增量更新,不需要每次从头搜索。具体做法是保留当前解的大部分结构,只对受影响的局部区域重新优化。

def should_replan(current_solution, new_events, threshold=0.2): """ 判断是否需要触发重规划 current_solution: 当前调度方案 new_events: 新事件列表(新需求/路况变化/时间偏差) threshold: 偏差阈值 """ for event in new_events: if event['type'] == 'new_demand': return True # 新需求必触发 elif event['type'] == 'time_deviation': if abs(event['deviation']) / event['planned_time'] > threshold: return True elif event['type'] == 'traffic_change': if event['delay_ratio'] > threshold: return True return False

参数说明:threshold默认0.2,意思是偏差超过20%才重规划。设太小会导致频繁重算,设太大响应不及时。实际调的时候可以按场景改,灾区核心区域可以降到0.1,外围区域0.3也行。

3. 用Python把调度算法跑起来:从数据生成到结果可视化

3.1 构造测试数据:模拟一个灾区配送场景

没有真实数据的时候,自己造。我一般会生成一个50个需求点、5台车的场景,需求点坐标在100x100的区域内随机分布,时间窗按正态分布生成。

import random import math def generate_scenario(num_nodes=50, num_vehicles=5, seed=42): """生成应急物资配送测试场景""" random.seed(seed) nodes = [] # 仓库作为0号节点 nodes.append(DemandNode(0, 50, 50, 0, 0, 999, 0)) for i in range(1, num_nodes + 1): x = random.uniform(0, 100) y = random.uniform(0, 100) demand = random.uniform(10, 50) # 10-50kg # 时间窗:以到达时间为中心,宽度60分钟 center = random.uniform(30, 180) e = max(0, center - 30) l = center + 30 # 20%的节点是动态出现的 appear = random.uniform(0, 60) if random.random() < 0.2 else 0 nodes.append(DemandNode(i, x, y, demand, e, l, appear)) vehicles = [Vehicle(k, 200, 40, 0) for k in range(num_vehicles)] return nodes, vehicles def distance(n1, n2): """欧氏距离""" return math.sqrt((n1.x - n2.x)**2 + (n1.y - n2.y)**2)

这段代码里appear字段控制动态节点比例,20%是个合理值。实际灾区场景可能更高,但测试用20%够了。demand范围10-50kg对应应急物资(水、食品、药品)的典型重量。

3.2 大邻域搜索的核心逻辑:destroy和repair怎么写

LNS的骨架就两步:destroy(移除部分节点)和repair(重新插入)。destroy策略我常用随机移除和最差移除结合,repair用贪婪插入加后悔值修正。

def destroy_random(solution, num_remove): """随机移除num_remove个需求点""" removed = random.sample(solution['assigned'], min(num_remove, len(solution['assigned']))) for node_id in removed: solution['assigned'].remove(node_id) return removed def repair_greedy(solution, removed, nodes, vehicles): """贪婪插入被移除的节点""" for node_id in removed: best_cost = float('inf') best_pos = None node = nodes[node_id] for v_idx, vehicle in enumerate(vehicles): # 尝试插入到路径的每个位置 for pos in range(1, len(vehicle.route)): prev = nodes[vehicle.route[pos-1]] nxt = nodes[vehicle.route[pos]] # 插入成本 = 新增距离 + 时间窗惩罚 cost = (distance(prev, node) + distance(node, nxt) - distance(prev, nxt)) # 检查载重约束 if vehicle.load + node.demand > vehicle.capacity: continue # 检查时间窗 arrival = vehicle.current_time + distance(prev, node) / vehicle.speed * 60 if arrival > node.l: cost += 1000 # 大惩罚 if cost < best_cost: best_cost = cost best_pos = (v_idx, pos) if best_pos: v_idx, pos = best_pos vehicles[v_idx].route.insert(pos, node_id) vehicles[v_idx].load += node.demand solution['assigned'].append(node_id) return solution

destroy_random里num_remove一般取总节点数的10%-20%。太小了搜索不充分,太大了退化成随机重启。repair_greedy里的惩罚系数1000是个经验值,要保证时间窗违反的代价远大于距离增加。

3.3 主循环与收敛判断:什么时候停

LNS的主循环就是不断destroy-repair,接受更好的解,偶尔接受差解来跳出局部最优。停止条件用最大迭代次数加无改进次数双重判断。

def lns_solve(nodes, vehicles, max_iter=500, no_improve_limit=50): """大邻域搜索主循环""" # 初始解:按时间窗排序后顺序插入 solution = {'assigned': [], 'cost': float('inf')} current_cost = evaluate_solution(vehicles, nodes) best_cost = current_cost no_improve = 0 for iteration in range(max_iter): # 动态调整移除数量 num_remove = random.randint(3, max(3, len(solution['assigned']) // 5)) removed = destroy_random(solution, num_remove) solution = repair_greedy(solution, removed, nodes, vehicles) new_cost = evaluate_solution(vehicles, nodes) # 模拟退火接受准则 temperature = 100 * (1 - iteration / max_iter) if new_cost < current_cost or random.random() < math.exp( (current_cost - new_cost) / max(temperature, 0.1)): current_cost = new_cost if new_cost < best_cost: best_cost = new_cost no_improve = 0 else: no_improve += 1 else: # 回滚 no_improve += 1 if no_improve >= no_improve_limit: break return best_cost, vehicles

温度参数从100线性降到0,这是模拟退火的标准做法。no_improve_limit设50,意思是连续50次没改进就停。实际跑的时候可以打印每轮cost,观察收敛曲线。

4. 参数调优与动态场景适配:让算法在5G环境下真正可用

4.1 权重系数怎么定:缺货惩罚和行驶时间的权衡

目标函数里α、β、γ三个权重直接决定调度策略的倾向。我的经验是分场景设:

场景类型α(行驶时间)β(缺货惩罚)γ(超时惩罚)
紧急救援1.050.010.0
常规配送1.05.02.0
均衡模式1.020.05.0

紧急救援场景下β设50,意味着一个需求点缺货的代价相当于多跑50单位距离。这个值不是拍脑袋来的,是拿历史数据回归出来的——缺货导致的二次配送成本大约是正常配送的50倍。

调参方法:固定α=1,然后网格搜索β和γ。步长先粗后细,先按10的步长搜,找到大致范围后再按1的步长细化。

4.2 动态需求插入的响应时间优化

5G环境下用户期望的响应时间是秒级,但LNS跑一次完整迭代可能要几百毫秒。优化思路是局部重优化:新需求出现时,只对距离最近的3台车做destroy-repair,而不是全局重算。

def local_replan(new_node, vehicles, nodes, radius=30): """局部重规划:只调整新节点附近的车辆""" # 找到距离新节点最近的车辆 nearby = [] for v in vehicles: last_pos = nodes[v.route[-1]] if distance(last_pos, new_node) < radius: nearby.append(v) if not nearby: # 没有附近车辆,选载重最轻的 nearby = [min(vehicles, key=lambda v: v.load)] # 只对这些车辆做destroy-repair for v in nearby: # 移除路径中最后2个节点 removed = v.route[-2:] v.route = v.route[:-2] # 重新插入包括新节点 repair_greedy({'assigned': []}, removed + [new_node.id], nodes, [v]) return vehicles

radius参数控制局部搜索范围,默认30(对应3公里左右)。这个值要跟5G覆盖半径匹配,城区可以设小一点,郊区设大一点。

4.3 用真实路网数据替换欧氏距离

欧氏距离在建模阶段够用,但实际部署必须换成路网距离。常见做法是调地图API获取实际行驶距离和预计时间。这里不展开具体API调用,只说数据结构怎么改。

class RoadNetwork: def __init__(self): self.cache = {} # 缓存已查询的距离 def get_distance(self, n1, n2): """获取路网距离,带缓存""" key = (min(n1.id, n2.id), max(n1.id, n2.id)) if key not in self.cache: # 实际项目中这里调地图API # 这里用欧氏距离乘以1.4作为近似(路网绕行系数) self.cache[key] = distance(n1, n2) * 1.4 return self.cache[key] def get_travel_time(self, n1, n2, speed): """获取行驶时间(分钟)""" dist = self.get_distance(n1, n2) return dist / speed * 60

绕行系数1.4是城市路网的典型值,郊区可以降到1.2,山区可能到1.8。这个系数直接影响时间窗可行性判断,设小了会导致实际配送迟到。

5. 避坑指南:这道题我踩过的五个坑

5.1 时间窗约束写成硬约束导致无解

现象:算法跑了几百轮,始终找不到可行解,所有车辆路径都违反时间窗。

原因:把时间窗写成了硬约束(违反就不可行),但实际场景中时间窗是可以软化的。灾区配送不可能100%准时,迟到10分钟和迟到1小时的性质完全不同。

解决:改成软约束,用分段惩罚函数。迟到0-15分钟罚10,15-30分钟罚50,超过30分钟罚200。这样算法有梯度可循,不会一开始就卡死。

5.2 动态节点插入时忘记更新车辆载重

现象:重规划后车辆实际装载量超过容量,但算法显示可行。

原因:repair_greedy里只检查了插入时的载重,但destroy阶段移除节点后没有同步减少vehicle.load。

解决:destroy和repair必须成对更新载重。我一般在destroy后立即执行vehicle.load -= node.demand,repair时再加回来。这个bug很隐蔽,因为最终解的载重看起来是对的,但中间过程可能超载。

5.3 模拟退火温度设太高导致解震荡

现象:cost曲线剧烈波动,始终不收敛。

原因:初始温度100对这个问题规模来说太高了。温度高意味着接受差解的概率大,算法在解空间里乱跳。

解决:初始温度按T0 = -ΔE / ln(P0)来算,其中ΔE是初始解和随机解的平均cost差,P0设0.7。实际调的时候可以先跑10轮取平均cost差,再算T0。

5.4 5G延迟参数设成0导致模型失真

现象:仿真结果显示调度指令瞬间到达,但实际部署时车辆响应有明显延迟。

原因:建模时把5G延迟当成了0。虽然5G的URLLC能做到10ms以内,但端到端延迟还包括服务器处理时间、协议栈开销,实际可能在50-100ms。

解决:在时间计算里加一个固定延迟项comm_delay,默认0.1秒。这个值对路径规划影响不大,但对实时性要求高的场景(比如避障)必须考虑。

5.5 需求点坐标用经纬度直接算距离

现象:距离计算结果偏差很大,路径明显绕远。

原因:经纬度是球面坐标,直接算欧氏距离在纬度高的地方误差可达30%以上。

解决:先用Haversine公式算球面距离,或者把经纬度投影到平面坐标系(UTM投影)再算欧氏距离。小范围场景(城市级)用等距圆柱投影就够了。

6. 进阶技巧:用滚动时域策略处理持续动态场景

前面讲的LNS加局部重规划,适合需求点偶尔新增的场景。但如果需求持续不断出现——比如灾区每隔几分钟就有新的求救信号——就需要滚动时域策略。核心思想是不要等所有需求都到齐再规划,而是每隔一个时间窗口(比如5分钟)做一次规划,只考虑当前已知的需求,执行第一个时间段的方案,然后滚动到下一个窗口。

具体实现上,把时间轴切成T个窗口,每个窗口内跑一次LNS,但只锁定前一个窗口的决策,后面的可以调整。这样既保证了实时性,又避免了频繁重算。

def rolling_horizon(nodes, vehicles, window_size=5, total_time=120): """滚动时域调度""" all_routes = [] current_time = 0 while current_time < total_time: # 获取当前时间窗口内已知的需求 active_nodes = [n for n in nodes if n.appear_time <= current_time + window_size] # 跑LNS求解 cost, planned_vehicles = lns_solve(active_nodes, vehicles, max_iter=100) # 只执行第一个window_size时段的决策 for v in planned_vehicles: executed = [] for node_id in v.route: node = nodes[node_id] if node.e <= current_time + window_size: executed.append(node_id) all_routes.append((v.id, executed)) current_time += window_size return all_routes

window_size设5分钟是个经验值。设太小会导致规划频繁但每次信息不足,设太大响应不及时。实际调的时候可以按需求到达率来定:到达率高就设小一点,到达率低就设大一点。

验证滚动时域策略的效果,我一般看两个指标:平均响应时间(从需求出现到车辆出发)和总配送成本。好的策略应该在响应时间上明显优于全局重算,而成本增加不超过10%。

最后说个血泪教训:这道题我第一版代码跑了三天才发现时间窗的单位搞错了——题目给的是分钟,我按小时算的,结果所有车辆都「准时」得离谱。后来养成了一个习惯,所有跟时间相关的变量都在命名里带单位,比如arrival_time_min、service_duration_min,再也没翻过车。希望帮到你。

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

RELATED READING

延伸阅读

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