返回

地点距离排序:解决取货先于送货的约束难题

javascript

带约束的地点排序:如何在满足取货先于送货的前提下按距离排序?

咱们在处理物流、配送或者行程规划的时候,经常会遇到需要对一堆地点进行排序的场景。最常见的可能就是按距离远近来排。但有时候,事情没那么简单,会加上一些额外的规矩。今天就碰上一个挺有意思的问题:怎么给一堆地点,按照离起点的距离排序,同时还得保证每个订单的取货点(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;
}

这段代码确实只解决了“找出所有唯一地点”并“按距离排序”的问题,但最重要的约束被忽略了。

一、问题难点在哪?

这个问题的核心冲突在于 “距离最近”“取货优先” 这两个目标有时候是相互矛盾的。

  1. 距离排序的破坏性 :严格按照离起点距离排序,很可能就把某个订单的送货点排在了取货点前面,违反了约束。比如取货点 A 离起点 10 公里,送货点 B 离起点 5 公里。如果只看距离,B 肯定排在 A 前面,这就错了。
  2. 约束引入的依赖关系 :每个 Order {pickup: P, delivery: D} 都意味着在最终的序列里,P 必须出现在 D 之前。这是一种偏序关系
  3. 潜在的循环依赖 :就像原问题担心的那样,如果存在订单 A {pickup: P1, delivery: D1} 和订单 B {pickup: P2, delivery: D2},并且恰好 P1 = D2D1 = P2,那么约束就变成了 P1 必须在 D1 前面,P2(也就是 D1)必须在 D2(也就是 P1)前面。这导出 D1 必须在 P1 前面,又要求 P1 必须在 D1 前面。这就是一个死循环!在这种特定的情况下,一个满足所有约束的简单线性序列 是不可能存在的。
  4. 问题本质的复杂性 :这其实是经典的带时间窗/依赖约束的车辆路径问题(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 的节点)时,我们优先选择离当前位置或起始点最近的那个节点

操作步骤/代码示例:

  1. 构建图和计算入度:

    // 假设 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 };
    }
    
  2. 执行拓扑排序(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 依赖的地点列表

或许真正需要的不是一个严格按距离排序的列表,而是:

  1. 一个满足约束的可行访问序列 :拓扑排序能给出一个(或多个)。
  2. 一个总距离较短且满足约束的访问序列 :贪心法给出一个近似解,VRP 求解器给出一个高质量解。

如果确实遇到了 P1 -> D1, D1 -> P1 这样的循环依赖,那么:

  • 要么是数据错误。
  • 要么业务规则允许地点重复访问(先去 D1 送货,再处理别处,最后回来 P2=D1 取货),那么模型就不能是简单地找一个不重复地点的序列了,而是要规划一个可能包含重复地点的路径。
  • 要么就得承认,在这个严格约束下,此路不通,需要调整业务需求或约束。

结论性的思考:

这个问题其实点出了一个常见困境:简单的排序规则无法直接处理带有复杂依赖关系和优化目标(如距离最短)的现实问题。理解约束的性质(是否可能形成环),明确优化的目标(严格距离优先?约束优先?总路径最短?),并选择合适复杂度的工具(简单启发式 vs 专业求解器),才是解决这类问题的关键。

对于原问题中提出的“只按距离排序,不管约束”的代码,它能做的很有限。要加入“取货优先”的约束,就必须采用更复杂的方法,比如上面提到的拓扑排序+启发式、贪心插入或者直接上 VRP 求解器。选哪个,就看你对结果质量的要求和愿意投入的开发复杂度了。