地点距离排序:解决取货先于送货的约束难题
2025-04-24 11:12:56
带约束的地点排序:如何在满足取货先于送货的前提下按距离排序?
咱们在处理物流、配送或者行程规划的时候,经常会遇到需要对一堆地点进行排序的场景。最常见的可能就是按距离远近来排。但有时候,事情没那么简单,会加上一些额外的规矩。今天就碰上一个挺有意思的问题:怎么给一堆地点,按照离起点的距离排序,同时还得保证每个订单的取货点(Pickup)必须排在它的送货点(Delivery)前面?
原问题还提到了一个担忧:如果订单 A 的取货点 P1 是订单 B 的送货点 D2,同时订单 A 的送货点 D1 恰好是订单 B 的取货点 P2,这不就形成了一个 P1 -> D1 (=P2) -> D2 (=P1) 的循环依赖了吗?这样的话,还能找到一个满足条件的线性排序吗?
原作者给出了一个简单的按距离排序的代码雏形,但没考虑这个取货先于送货的约束。
interface Order {
id: number;
pickup: Location;
delivery: Location;
}
interface Location {
id: number;
latitude: number;
longitude: number;
}
// 假设有一个计算距离的函数 (具体实现不关键)
declare function getDistance(loc1: Location, loc2: Location): number;
function getSortedUniqueLocations(orderArray: Order[]): Location[] {
if (orderArray.length === 0) return [];
const uniqueLocations = new Map<string, Location>();
// 收集所有不重复的地点
for (let i = 0; i < orderArray.length; i++) {
const order = orderArray[i];
const pickup = order.pickup;
const delivery = order.delivery;
// 用经纬度字符串作为唯一标识
const pickupKey = `${pickup.latitude},${pickup.longitude}`;
const deliveryKey = `${delivery.latitude},${delivery.longitude}`;
if (!uniqueLocations.has(pickupKey)) {
uniqueLocations.set(pickupKey, pickup);
}
if (!uniqueLocations.has(deliveryKey)) {
uniqueLocations.set(deliveryKey, delivery);
}
}
const locations = Array.from(uniqueLocations.values());
// 按距离初始点的距离排序 (没有考虑约束)
const firstLocation = orderArray[0].pickup; // 以第一个订单的取货点为参照
locations.sort((a, b) => getDistance(firstLocation, a) - getDistance(firstLocation, b));
return locations;
}
这段代码确实只解决了“找出所有唯一地点”并“按距离排序”的问题,但最重要的约束被忽略了。
一、问题难点在哪?
这个问题的核心冲突在于 “距离最近” 和 “取货优先” 这两个目标有时候是相互矛盾的。
- 距离排序的破坏性 :严格按照离起点距离排序,很可能就把某个订单的送货点排在了取货点前面,违反了约束。比如取货点 A 离起点 10 公里,送货点 B 离起点 5 公里。如果只看距离,B 肯定排在 A 前面,这就错了。
- 约束引入的依赖关系 :每个
Order {pickup: P, delivery: D}
都意味着在最终的序列里,P 必须出现在 D 之前。这是一种偏序关系 。 - 潜在的循环依赖 :就像原问题担心的那样,如果存在订单
A {pickup: P1, delivery: D1}
和订单B {pickup: P2, delivery: D2}
,并且恰好P1 = D2
且D1 = P2
,那么约束就变成了 P1 必须在 D1 前面,P2(也就是 D1)必须在 D2(也就是 P1)前面。这导出 D1 必须在 P1 前面,又要求 P1 必须在 D1 前面。这就是一个死循环!在这种特定的情况下,一个满足所有约束的简单线性序列 是不可能存在的。 - 问题本质的复杂性 :这其实是经典的带时间窗/依赖约束的车辆路径问题(Vehicle Routing Problem with Time Windows/Precedence Constraints, VRPTW/VRPPC) 或者 取送货问题(Pickup and Delivery Problem, PDP) 的简化版本。这些问题通常是 NP-hard 的,意味着对于大规模数据,找到绝对最优解非常耗时。咱们这里的目标“排序”可能只是想得到一个访问顺序,但本质上是在寻找一个满足约束的最短(或近似最短)路径。
二、咋解决呢?几种思路走起!
既然直接按距离排序行不通,那我们得换个角度。下面提供几种不同的方法,各有优劣。
思路一:拓扑排序 + 启发式距离选择 (优先满足约束)
这种方法的核心是先保证“取货优先”的约束,再在允许的范围内尽量考虑距离。
原理和作用:
- 把地点看作图的节点。
- 对于每个订单
{pickup: P, delivery: D}
,添加一条从 P 指向 D 的有向边,表示 P 必须在 D 之前访问。 - 这样就构建了一个有向图 。如果这个图里没有环(Cycle),那么它就是一个有向无环图 (DAG) 。
- 对 DAG 进行拓扑排序 ,就能得到一个满足所有 P->D 约束的节点序列。拓扑排序的结果不唯一,这给我们留下了优化的空间。
- 在拓扑排序过程中,当有多个节点可以选择(即有多个入度为 0 的节点)时,我们优先选择离当前位置或起始点最近的那个节点 。
操作步骤/代码示例:
-
构建图和计算入度:
// 假设 Location 有个唯一的 id 属性 interface Location { id: string; // 使用唯一ID,经纬度组合可能因精度问题出岔子 latitude: number; longitude: number; } // ... Order 接口定义 ... function buildGraph(orders: Order[], uniqueLocations: Map<string, Location>): { graph: Map<string, string[]>, inDegree: Map<string, number> } { const graph = new Map<string, string[]>(); // 邻接表表示图 const inDegree = new Map<string, number>(); // 记录每个节点的入度 // 初始化图和入度 uniqueLocations.forEach(loc => { graph.set(loc.id, []); inDegree.set(loc.id, 0); }); // 根据订单添加边和更新入度 orders.forEach(order => { const pickupId = order.pickup.id; const deliveryId = order.delivery.id; // 确保边不重复添加(虽然对拓扑排序影响不大,但保持清晰) if (!graph.get(pickupId)?.includes(deliveryId)) { graph.get(pickupId)?.push(deliveryId); inDegree.set(deliveryId, (inDegree.get(deliveryId) || 0) + 1); } }); return { graph, inDegree }; }
-
执行拓扑排序(Kahn 算法)并结合距离启发:
function topologicalSortWithDistanceHeuristic( orders: Order[], uniqueLocationsMap: Map<string, Location>, startLocationId: string ): Location[] | null { // 返回排序后的地点列表或 null (如果存在环) const { graph, inDegree } = buildGraph(orders, uniqueLocationsMap); const result: Location[] = []; const queue: string[] = []; // 存放当前入度为0的节点ID const locationMap = new Map(Array.from(uniqueLocationsMap.entries()).map(([_, loc]) => [loc.id, loc])); // 找到所有初始入度为0的节点 inDegree.forEach((degree, nodeId) => { if (degree === 0) { queue.push(nodeId); } }); let currentLocation: Location = locationMap.get(startLocationId)!; // 当前位置,用于距离计算 while (queue.length > 0) { // ----- 距离启发关键点 ----- // 对当前队列中入度为0的节点,按距离当前位置排序 queue.sort((idA, idB) => { const locA = locationMap.get(idA)!; const locB = locationMap.get(idB)!; return getDistance(currentLocation, locA) - getDistance(currentLocation, locB); }); // ----- 启发结束 ----- const nodeId = queue.shift()!; // 取出“最近”的那个入度为0的节点 const nodeLocation = locationMap.get(nodeId)!; result.push(nodeLocation); currentLocation = nodeLocation; // 更新当前位置 // 处理该节点的邻居(即它指向的节点) graph.get(nodeId)?.forEach(neighborId => { const currentInDegree = (inDegree.get(neighborId) || 0) - 1; inDegree.set(neighborId, currentInDegree); if (currentInDegree === 0) { queue.push(neighborId); // 新的入度为0节点加入队列 } }); } // 检查是否有环:如果结果数组长度不等于总地点数,说明图里有环 if (result.length !== uniqueLocationsMap.size) { console.error("检测到循环依赖!无法生成满足条件的线性序列。"); // 这里需要处理循环情况,比如报告错误或采用其他策略 return null; } return result; } // --- 使用示例 --- // 假设 orderArray 和 getDistance 已定义 // const uniqueLocations = ... // 获取唯一地点 Map<string, Location> // const firstLocation = orderArray[0].pickup; // // 假设 Location 接口增加了 id 属性,并且 uniqueLocations 的 key 和 Location.id 对应 // const sortedLocations = topologicalSortWithDistanceHeuristic(orderArray, uniqueLocations, firstLocation.id); // if (sortedLocations) { // console.log("排序结果:", sortedLocations.map(loc => loc.id)); // } else { // console.log("存在循环,无法排序。"); // }
进阶技巧/注意事项:
- 环检测: Kahn 算法本身就能检测环。如果在排序结束后
result.length
小于节点总数,就说明存在环。DFS(深度优先搜索)也可以用来显式检测环。 - 处理环: 如果检测到环(比如 P1 -> D1 -> P1 的情况),说明原问题在这种严格线性排序下无解。需要跟业务方确认:
- 这种情况是否实际存在?
- 是否允许一个地点被访问多次?(比如先送货D1,然后处理其他点,再回来取货P2=D1)
- 是否可以调整订单或约束?
- 距离启发策略: 上面的代码是每次从可选节点里选离 当前位置 最近的。也可以选择离 全局起点 最近的,或者结合其他因素。这取决于具体的业务场景更看重哪种“近”。
- 唯一 ID: 使用唯一的
location.id
比用经纬度字符串作 key 更可靠,避免浮点数精度问题。
思路二:贪心插入法 (逐步构建序列)
这种方法比较直观,一步步构建最终的序列,每一步都选择当前“最好”的、且满足约束的下一个地点。
原理和作用:
- 维护一个已访问地点的序列
route
和一个待访问地点的集合pending
。 - 从起始点开始。
- 循环地从
pending
中选择下一个要访问的地点:- 候选地点必须满足:要么它是一个取货点,要么它是一个送货点,并且其对应的取货点已经 在
route
里了。 - 在所有满足条件的候选地点中,选择离
route
中最后一个地点最近的那个。 - 将选中的地点加入
route
,并从pending
中移除。
- 候选地点必须满足:要么它是一个取货点,要么它是一个送货点,并且其对应的取货点已经 在
- 重复直到
pending
为空。
操作步骤/代码示例:
function greedyInsertionSort(
orders: Order[],
uniqueLocations: Location[],
startLocationId: string
): Location[] {
const locationMap = new Map(uniqueLocations.map(loc => [loc.id, loc]));
const pendingLocations = new Set(uniqueLocations.map(loc => loc.id));
const pickupToDeliveryMap = new Map<string, string>(); // <pickupId, deliveryId>
const deliveryToPickupMap = new Map<string, string>(); // <deliveryId, pickupId>
orders.forEach(order => {
pickupToDeliveryMap.set(order.pickup.id, order.delivery.id);
deliveryToPickupMap.set(order.delivery.id, order.pickup.id);
});
const route: Location[] = [];
let currentLocation = locationMap.get(startLocationId)!;
// 将起始点加入路径
route.push(currentLocation);
pendingLocations.delete(startLocationId);
const visitedPickupIds = new Set<string>(); // 记录已访问的取货点ID
if (pickupToDeliveryMap.has(startLocationId)) {
visitedPickupIds.add(startLocationId); // 如果起点本身是取货点
}
while (pendingLocations.size > 0) {
let bestNextLocation: Location | null = null;
let minDistance = Infinity;
// 遍历所有待定地点,寻找最佳下一个点
pendingLocations.forEach(locId => {
const candidateLocation = locationMap.get(locId)!;
let isCandidateValid = false;
// 判断候选点是否合法
if (deliveryToPickupMap.has(locId)) { // 如果是送货点
const pickupId = deliveryToPickupMap.get(locId)!;
if (visitedPickupIds.has(pickupId)) { // 检查对应取货点是否已访问
isCandidateValid = true;
}
} else { // 如果是取货点 (或者是与订单无关的独立点)
isCandidateValid = true;
}
if (isCandidateValid) {
const distance = getDistance(currentLocation, candidateLocation);
if (distance < minDistance) {
minDistance = distance;
bestNextLocation = candidateLocation;
}
}
});
if (bestNextLocation) {
route.push(bestNextLocation);
pendingLocations.delete(bestNextLocation.id);
currentLocation = bestNextLocation;
// 如果新加入的是取货点,记录下来
if (pickupToDeliveryMap.has(bestNextLocation.id)) {
visitedPickupIds.add(bestNextLocation.id);
}
} else {
// 如果没有找到合法的下一个地点,可能是有问题 (比如无法满足约束的孤立送货点?)
console.error("无法找到下一个满足条件的地点,可能存在无法履约的订单或孤立节点。");
// 这里需要更健壮的错误处理
break;
}
}
return route;
}
// --- 使用示例 ---
// const uniqueLocationsArray = Array.from(uniqueLocations.values());
// const sortedRoute = greedyInsertionSort(orderArray, uniqueLocationsArray, firstLocation.id);
// console.log("贪心排序结果:", sortedRoute.map(loc => loc.id));
进阶技巧/注意事项:
- 贪心陷阱: 贪心算法只看眼前最优,不保证全局最优。可能会导致整体路径非常绕。
- 处理无解: 如果循环中
bestNextLocation
始终为null
,说明在当前状态下,所有剩下的pending
地点都是送货点,但它们对应的取货点还没被访问。这可能暗示着数据问题或确实存在无法满足的约束(需要排查逻辑)。 - 效率: 每次迭代都要计算所有待定候选点的距离,如果地点数量很多,效率会比较低。可以优化查找过程。
思路三:使用现成的 VRP/TSP 求解器 (专业方案)
如果追求更好的路径质量(更短的总距离),并且对复杂度有预期,可以考虑使用专门解决车辆路径问题的库或工具。
原理和作用:
- 将问题建模为一个带约束的优化问题。
- 目标是找到一个访问所有必要地点(取货点和送货点)的序列(路径),使得总距离最短。
- 同时必须满足约束:对于每个订单,序列中取货点的位置必须在送货点之前。
- 这类求解器(如 Google OR-Tools, jsprit, OptaPlanner 等)内部使用了复杂的算法(如禁忌搜索、模拟退火、遗传算法、精确算法等)来寻找高质量解。
操作步骤/概念代码 (以 Google OR-Tools 为例):
# 这是一个 Python 示例,展示 OR-Tools 的概念
from ortools.constraint_solver import routing_enums_pb2
from ortools.constraint_solver import pywrapcp
def create_data_model(locations, orders, start_location_id):
"""准备求解器需要的数据结构"""
data = {}
location_map = {loc['id']: loc for loc in locations}
location_indices = {loc_id: i for i, loc_id in enumerate(location_map.keys())}
# ... (需要将 location_id 映射到从0开始的整数索引) ...
# 计算距离矩阵 (所有点对之间的距离)
# data['distance_matrix'] = ...
# 定义取送货对 (pickup_index, delivery_index)
data['pickups_deliveries'] = []
for order in orders:
pickup_index = location_indices[order['pickup']['id']]
delivery_index = location_indices[order['delivery']['id']]
data['pickups_deliveries'].append((pickup_index, delivery_index))
data['num_vehicles'] = 1 # 假设只有一辆车/一个序列
data['depot'] = location_indices[start_location_id] # 起点索引
return data, location_indices, location_map
def solve_vrp_pdp(data, location_indices, location_map):
"""使用 OR-Tools 求解"""
manager = pywrapcp.RoutingIndexManager(len(data['distance_matrix']),
data['num_vehicles'], data['depot'])
routing = pywrapcp.RoutingModel(manager)
# 定义距离回调
def distance_callback(from_index, to_index):
from_node = manager.IndexToNode(from_index)
to_node = manager.IndexToNode(to_index)
return data['distance_matrix'][from_node][to_node]
transit_callback_index = routing.RegisterTransitCallback(distance_callback)
routing.SetArcCostEvaluatorOfAllVehicles(transit_callback_index)
# 添加取货先于送货的约束
for pickup_index, delivery_index in data['pickups_deliveries']:
pickup_node_index = manager.NodeToIndex(pickup_index)
delivery_node_index = manager.NodeToIndex(delivery_index)
routing.AddPickupAndDelivery(pickup_node_index, delivery_node_index)
# 或者更通用的约束方式:
# routing.solver().Add(routing.VehicleVar(pickup_node_index) == routing.VehicleVar(delivery_node_index))
# routing.solver().Add(routing.CumulVar(pickup_node_index, 'distance') <= routing.CumulVar(delivery_node_index, 'distance'))
# 设置搜索参数 (可选)
search_parameters = pywrapcp.DefaultRoutingSearchParameters()
search_parameters.first_solution_strategy = (
routing_enums_pb2.FirstSolutionStrategy.PATH_CHEAPEST_ARC)
# ... 其他参数设置 ...
# 求解
solution = routing.SolveWithParameters(search_parameters)
if solution:
# 解析结果,得到路径序列
index = routing.Start(0)
route = []
while not routing.IsEnd(index):
node_index = manager.IndexToNode(index)
# 通过 node_index 反查 location_id 再获取 Location 对象
# ...
route.append(location_map[list(location_map.keys())[node_index]]) # 示例性的反查
index = solution.Value(routing.NextVar(index))
return route
else:
print('未找到解')
return None
# --- 使用示例 ---
# locations = [...] # 所有 Location 对象列表,含 id
# orders = [...] # 所有 Order 对象列表
# start_location_id = ...
# data, location_indices, location_map = create_data_model(locations, orders, start_location_id)
# optimal_route = solve_vrp_pdp(data, location_indices, location_map)
# if optimal_route:
# print("优化后的路线:", [loc['id'] for loc in optimal_route])
进阶技巧/注意事项:
- 学习曲线: 需要学习特定库的用法,设置各种参数。
- 计算成本: 对于大规模问题,求解可能需要较长时间。但通常能得到比简单启发式好得多的结果。
- 灵活性: 这类工具通常支持更复杂的约束,比如车辆容量、时间窗口、多车辆等,扩展性好。
- 不仅仅是排序: VRP 求解器给出的直接是优化后的完整路径(Route) ,而不仅仅是排序后的地点列表。这可能更符合实际需求。
思路四:反思需求本身——要的真是“排序”吗?
回到原点,有时候问题在于我们对需求的理解。原问题提到“排序 Locations by distance ... respecting constraint”。但那个循环依赖的例子恰恰说明了,不存在一个简单的、唯一的、按距离单调递增、又能满足所有 P->D 依赖的地点列表 。
或许真正需要的不是一个严格按距离排序的列表,而是:
- 一个满足约束的可行访问序列 :拓扑排序能给出一个(或多个)。
- 一个总距离较短且满足约束的访问序列 :贪心法给出一个近似解,VRP 求解器给出一个高质量解。
如果确实遇到了 P1 -> D1, D1 -> P1 这样的循环依赖,那么:
- 要么是数据错误。
- 要么业务规则允许地点重复访问(先去 D1 送货,再处理别处,最后回来 P2=D1 取货),那么模型就不能是简单地找一个不重复地点的序列了,而是要规划一个可能包含重复地点的路径。
- 要么就得承认,在这个严格约束下,此路不通,需要调整业务需求或约束。
结论性的思考:
这个问题其实点出了一个常见困境:简单的排序规则无法直接处理带有复杂依赖关系和优化目标(如距离最短)的现实问题。理解约束的性质(是否可能形成环),明确优化的目标(严格距离优先?约束优先?总路径最短?),并选择合适复杂度的工具(简单启发式 vs 专业求解器),才是解决这类问题的关键。
对于原问题中提出的“只按距离排序,不管约束”的代码,它能做的很有限。要加入“取货优先”的约束,就必须采用更复杂的方法,比如上面提到的拓扑排序+启发式、贪心插入或者直接上 VRP 求解器。选哪个,就看你对结果质量的要求和愿意投入的开发复杂度了。