ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

SpringBoot集成GraphHopper实现多途经点路径规划与TSP优化

SpringBoot集成GraphHopper实现多途经点路径规划与TSP优化

简介:这是一套基于Java SpringBoot与GraphHopper实现的多途径点路径规划设计源码,面向物流配送、户外导航等需要多点位优化的Java开发者,用于解决多个途经点排序与最优路径计算问题。压缩包共23个文件,包内包含4个Java源文件、2个OSM格式地图数据,以及属性配置、Maven构建、路径索引、可执行JAR包等辅助资源,整体大小约31.72MB。截至目前已有325人学习下载。源码目录结构划分清晰,从地图数据导入、路径索引构建到接口封装均有对应代码,借助SpringBoot可快速部署为独立服务。GraphHopper本身内置多种路由算法,开发者能够调整途经点约束、交通权重或扩展业务逻辑;配套的构建脚本和配置文件也便于跨平台运行与二次开发,适合课程设计、毕业设计及小型物流调度系统原型搭建。

1. 多途径点路径规划不是“多传几个坐标”:这个源码方案到底解决了什么

做物流派单、门店巡检或者外卖调度时,你会遇到一个和普通导航完全不同的需求:不仅要回答“从 A 到 B 怎么走”,还要在一串坐标里找出“先去哪、再去哪、最后回哪”的访问次序。基于 GraphHopper 的 Java SpringBoot 多途径点路径规划设计源码,解决的就是这个问题——用 GraphHopper 做离线路网算路,用 TSP 思路重排途经点,最后由 SpringBoot 把整个流程暴露成 REST 接口。它适合已经在用 Java 技术栈、不想被在线地图 API 的单次调度数和计费限制住的后端团队,也适合需要自定义路权、需要离线部署的调度系统。下面从选型、建图到 TSP 优化一层层拆开。

2. 先搭对地基:GraphHopper 在 SpringBoot 里的嵌入方式与路网数据准备

2.1 为什么选 GraphHopper:离线路网算路与在线 API 的分界线在哪

多途径点规划的第一步不是写算法,而是选一个能稳定回答“任意两点之间沿路网走需要多久”的算路内核。在线地图 API 在简单导航场景下很省事,但进入批量计算场景就会碰到几个硬约束:单日配额要按调用量买、并发会被限流、企业内部坐标数据出域还要过合规评估。更麻烦的是,当你需要按“避开高速”“优先走国道”“货车限行时段”这类自定义规则做路权控制时,在线 API 的开放程度往往不够。

GraphHopper 是 Java 生态里少见的开源离线路由引擎,它直接读 OpenStreetMap 的 pbf 路网文件,在本地建图后提供 Dijkstra、A*、CH(Contraction Hierarchies)等算路算法。因为是纯 Java 库,嵌入 SpringBoot 进程很自然,不需要额外起服务。下面这张对比表能说清楚它和两类常见方案之间的差异:

对比维度GraphHopper 嵌入式在线地图 APIOR-Tools 类优化库
路网数据本地 OSM pbf,离线可用厂商云端,按次计费不自带路网,须自己提供距离矩阵
两点算路有,且可自定义 weighting有,但受配额限制无
多途经点排序自己补 TSP 逻辑部分厂商提供付费 API有,但矩阵来源要自己解决
自定义路权支持自定义 weighting 模型受厂商开放能力限制不涉及
部署形态打进 SpringBoot 进程外网 HTTP 调用独立算法库

所以我的结论很直接:多途点规划这种“既要反复算两点成本、又要自己控制排序逻辑”的场景,GraphHopper 做算路底座是最稳的。OR-Tools 这类库不是不能做,而是整个链路里少了路网这一环,你还是得先有一个能出距离/时间矩阵的引擎。

2.2 SpringBoot 里嵌 GraphHopper:pom 与配置类的第一版

接入方式按 Maven + Spring Boot 3.x 为例。依赖上只引入 graphhopper-core 一个核心包,pbf 解析、路网构建、路径搜索都在里面。部分旧版本会把 OSM 读取拆成单独的 reader 模块,如果你用的版本装不上 core 就请先核对 Maven Central 上的模块清单。

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.graphhopper</groupId> <artifactId>graphhopper-core</artifactId> <version>以 Maven Central 稳定版为准</version> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency>

pom 里我只放最少的启动依赖。GraphHopper 的核心类会连带把 OSM reader、空间索引这些模块拉进来,不需要额外加地理工具包。lombok 是为了让 DTO 代码短一点,不喜欢的可以换成手写 getter/setter。

接着是配置项绑定。在 application.yml 里把“路网文件路径、图缓存路径、profile”三个经常变的参数拎出来:

graphhopper: osm-file: /data/osm/beijing.osm.pbf graph-cache: /data/graph-cache profile: car

图缓存目录是关键。GraphHopper 第一次启动会读 pbf 建图,这次非常耗时;之后第二次启动直接加载目录里的图文件,能做到秒级。这个行为对应下面的importOrLoad()。

@Configuration @EnableConfigurationProperties(GraphHopperProperties.class) public class GraphHopperConfig { @Bean(destroyMethod = "close") public GraphHopper graphHopper(GraphHopperProperties props) { GraphHopper hopper = new GraphHopper(); hopper.setOSMFile(props.getOsmFile()); hopper.setGraphHopperLocation(props.getGraphCache()); // profile 是算路方式:car 走汽车路网,bike 走骑行路网,foot 走步行路网。 // 这里用同一个名称同时设置 vehicle 和 weighting=fastest, // 表示“按最快到达”为优化目标。 hopper.setProfiles(new Profile(props.getProfile()) .setVehicle(props.getProfile()) .setWeighting("fastest") .setTurnCosts(false)); // importOrLoad 是一个幂等方法: // 缓存目录里没有图数据时读 pbf 建图,有就直接加载。 hopper.importOrLoad(); return hopper; } }

这段代码逻辑说明很直白:setOSMFile指定原始路网,setGraphHopperLocation指定建图缓存位置,setProfiles告诉引擎按什么规则算路。更需要留意的是setTurnCosts(false),它决定要不要考虑“转弯惩罚”。如果业务模拟的是卡车进城、小区内部配送这类对转向敏感的路网,这个参数要打开并配合 turn cost 配置,否则算出来会走一些在地面上根本拐不过来的路口。

2.3 路网数据准备:裁剪一块能跑起来的 OSM 区域

第一次跑通时千万别直接下载全国 pbf,那个文件动辄几十 GB,建图能建到你怀疑人生。常见做法是先裁一块城市范围。OSM 原始文件在 Geofabrik 下载后,用 osmium 工具按经纬度裁剪即可:

osmium extract --bbox=116.2,39.8,116.6,40.0 \ china-latest.osm.pbf \ -o beijing.osm.pbf

裁剪范围四个数字分别是西经、南纬、东经、北纬。我习惯先用一个略大于业务覆盖范围的 bbox,把缓冲区加进去——如果 bbox 卡得太紧,途经点落在边缘时很容易因为附近路网缺失而不匹配。

裁剪完的文件放到前面 yml 配置的osm-file路径下,第一次启动加-Xmx4g之类的 JVM 参数。建图阶段内存不够是最常见的翻车点,城市级 pbf 通常 4G 堆内存起步。启动完成后进 /data/graph-cache 目录看一下,出现节点文件、边文件、空间索引文件,就说明建图成功,后续不要再改 profile 和 pbf 路径了,否则缓存会失效重建。

3. 多途经点并不只是“多传几个参数”:排序与路线生成的两层架构

3.1 顺序模式:多点一次性穿成一条路线

GraphHopper 的/route支持一次传入多个坐标点,它保证返回的路径依次经过这些点。这个“依次经过”和“访问顺序经过优化”是两回事——先说前者。在 SpringBoot 里暴露一个接口,接收一组有序坐标,直接调 GraphHopper 算路:

@RestController @RequestMapping("/api/v1/route-plan") public class RoutePlanController { private final RoutePlanningService planningService; public RoutePlanController(RoutePlanningService planningService) { this.planningService = planningService; } @PostMapping("/order") public RoutePlanResponse order(@RequestBody RoutePlanRequest request) { request.setMode("order"); return planningService.plan(request); } }

Service 层把坐标依次填进 GHRequest:

GHRequest routeReq = new GHRequest(); for (Point p : request.getPoints()) { routeReq.addPoint(new GHPoint(p.getLat(), p.getLng())); } routeReq.setProfile(request.getProfile()); GHResponse rsp = graphHopper.route(routeReq); if (rsp.hasErrors()) { throw new IllegalStateException("路径计算失败: " + rsp.getErrors()); } ResponsePath path = rsp.getBest();

这里需要注意一个细节:GHRequest 的多个 point 之间是按给定顺序连接的,GraphHopper 不会因为“这样走更绕”就帮你重排。它是路网算路引擎,不是路径优化引擎。所以顺序模式适合“用户已经确定拜访顺序”的场景,比如按预约时间依次上门;而如果只给一串店、想自动找最优顺序,得用下面的优化模式。

3.2 优化模式:用时间矩阵加 2-opt 把途经点重排

优化模式要做的事情本质上是 TSP:给定起点、若干途经点、终点,找一条经过所有途经点且首尾固定的最短访问顺序。GraphHopper 不解决排序,但能提供排序所需的“两两之间行驶时间”。常见做法是先算出 N×N 的时间矩阵,再交给 TSP 算法求解。

矩阵计算最可控的实现就是循环调用 GraphHopper 的 route 接口。N 个点之间两两组合,复杂度 O(N^2),N 在几十以内都可以接受:

private double[][] buildTimeMatrix(List<Point> points, String profile) { int n = points.size(); double[][] matrix = new double[n][n]; for (int i = 0; i < n; i++) { for (int j = 0; j < n; j++) { if (i == j) { matrix[i][j] = 0; continue; } GHRequest req = new GHRequest( points.get(i).getLat(), points.get(i).getLng(), points.get(j).getLat(), points.get(j).getLng()); req.setProfile(profile); GHResponse rsp = graphHopper.route(req); if (rsp.hasErrors()) { throw new IllegalStateException("矩阵计算失败: " + rsp.getErrors()); } // 用秒作为统一成本单位,后续排序和路线校验保持一致 matrix[i][j] = rsp.getBest().getTime() / 1000.0; } } return matrix; }

这段代码有两个参数说明。一是 profile 必须与最终路径计算保持一致,矩阵用汽车时间,最终渲染路线却用步行 profile,排序结果必然失真。二是rsp.getBest()在一些较新版本里改成了rsp.getPaths().get(0),编译报错时按 IDE 提示换一下就好,方法名不同语义一样。

拿到矩阵之后,TSP 排序我用最近邻初始化 + 2-opt 局部优化。2-opt 是工程上性价比最高的改良手段,代码简单、没有额外依赖,几十个点以内效果足够:

public class TspOptimizer { private final double[][] cost; private final int[] route; public TspOptimizer(double[][] cost) { this.cost = cost; this.route = new int[cost.length]; } public int[] optimize() { nearestNeighbor(); twoOpt(); return route; } private void nearestNeighbor() { int n = cost.length; boolean[] visited = new boolean[n]; visited[0] = true; route[0] = 0; int cur = 0; for (int i = 1; i < n - 1; i++) { int next = -1; double best = Double.MAX_VALUE; for (int j = 1; j < n - 1; j++) { if (!visited[j] && cost[cur][j] < best) { best = cost[cur][j]; next = j; } } visited[next] = true; route[i] = next; cur = next; } // 最后一个下标固定为终点 route[n - 1] = n - 1; } private void twoOpt() { int n = route.length; boolean improved = true; while (improved) { improved = false; for (int i = 1; i < n - 2; i++) { for (int j = i + 1; j < n - 1; j++) { double oldCost = cost[route[i - 1]][route[i]] + cost[route[j]][route[j + 1]]; double newCost = cost[route[i - 1]][route[j]] + cost[route[i]][route[j + 1]]; if (newCost < oldCost - 1e-9) { reverse(i, j); improved = true; } } } } } private void reverse(int from, int to) { while (from < to) { int tmp = route[from]; route[from] = route[to]; route[to] = tmp; from++; to--; } } }

代码的核心逻辑在第 25 行附近:最近邻先给出一个“每次取最近点”的初始解,2-opt 再不断尝试翻转某一段子路径,如果翻转后总成本下降就保留。注意route[0]和route[n-1]被固定为起点和终点,2-opt 的循环范围i从 1 到n-3、j从i+1到n-2,不会动首尾。这是和通用 TSP 实现的区别——如果终点不固定,物流回仓场景就崩了。

排序完成后,把坐标按新顺序重新提交一次 GraphHopper route 请求,拿到的就是完整路径。这里有一个性能优化点:没必要在排序阶段和路线阶段各调一遍两两算路。更优的做法是一次性把矩阵结果缓存起来,排序后直接从缓存里取起终点和途经点之间的邻接路段,再拼完整 path;但拼 path 涉及子路径接口拼接,源码第一次实现时先走“矩阵排序 + 一次完整 route”,逻辑清晰、不容易翻车。

3.3 两个必调参数:成本指标与点路匹配距离的影响

第一个必调参数是成本指标。时间矩阵里用getTime(),那么验证排序结果、对比优化前后效果也必须看时间。如果你用的是距离矩阵去做排序,业务上考核的却是“总耗时最短”,两种指标在不同交通状态下可能给出完全不同的访问顺序,这是很多源码第一版“优化后反而更慢”的原因。

第二个必调参数是点路匹配距离。前端采集的坐标往往有 GPS 漂移,落在建筑物中心,甚至掉进河里。GraphHopper 默认会把坐标吸附到最近路边,但这个吸附半径不够时,点位会被吸附到完全不合理的路上。实现层面可以通过 GHRequest 的setSnapDistance调整,常用经验值是 100 到 200 米:

routeReq.setSnapDistance(150);

这个参数的含义是“允许坐标点向最近路网吸附的最大距离”。设置太小,偏航点匹配不上;设置太大,弱路网区域会把点吸到相隔几条街的道路上。我一般先取 100 跑几个样本,观察点与最近道路的横向距离分布再决定。另外,setSnapPreventions可以用来排除某些路类,比如把 ferry(渡轮)排除掉,避免算路自动给出一段坐船的诡异路径。

4. 从 Demo 到源码级的工程结构:Controller、Service、TSP 优化器如何分工

4.1 一版可直接照着改的工程目录

如果只是验证可行性,一个 Controller 加一个 Service 就够了。但要把这套东西放进正式项目里,我建议按下表的模块边界来切,避免把所有逻辑堆在一个类里:

层级典型类职责边界
接口层RoutePlanControllerJSON 入参校验、REST 语义、统一异常转换
应用层RoutePlanningService编排“排序 + 算路 + 拼响应”完整流程
计算层MatrixService封装两点矩阵计算,可替换为 GraphHopper Matrix API
算法层TspOptimizer纯 TSP 算法,不依赖任何 Spring 和 GraphHopper 类型
模型层RoutePlanRequest / RoutePlanResponse / Point只承载数据,不含逻辑

目录落下来是这样:

src/main/java/com/example/routeplanner/ RouteplannerApplication.java config/ GraphHopperProperties.java GraphHopperConfig.java controller/ RoutePlanController.java service/ RoutePlanningService.java MatrixService.java service/impl/ RoutePlanningServiceImpl.java tsp/ TspOptimizer.java model/ Point.java RoutePlanRequest.java RoutePlanResponse.java src/main/resources/ application.yml data/ osm/beijing.osm.pbf graph-cache/

这里最有价值的边界是TspOptimizer独立成纯算法类。它不 import 任何 GraphHopper 类,输入一个 double 二维数组,输出一个 int 数组。这样你可以直接在单元测试里暴力穷举对比 2-opt 的质量,也可以将来把 2-opt 换成 OR-Tools 或者模拟退火,不影响其它模块。

4.2 Service 层把“算点序”和“画路线”解耦

Service 实现类的核心方法plan()里只做编排——调用 MatrixService 算矩阵、调用 TspOptimizer 排序、调用 GraphHopper 生成最终路线。不把算法细节掺进来:

@Service public class RoutePlanningServiceImpl implements RoutePlanningService { private final GraphHopper graphHopper; private final MatrixService matrixService; public RoutePlanningServiceImpl(GraphHopper graphHopper, MatrixService matrixService) { this.graphHopper = graphHopper; this.matrixService = matrixService; } @Override public RoutePlanResponse plan(RoutePlanRequest request) { List<Point> points = request.getPoints(); if (points.size() < 3) { throw new IllegalArgumentException("至少需要起点、一个途经点、终点"); } List<Point> sortedPoints; if ("optimize".equalsIgnoreCase(request.getMode())) { double[][] timeMatrix = matrixService.buildTimeMatrix(points, request.getProfile()); int[] order = new TspOptimizer(timeMatrix).optimize(); sortedPoints = new ArrayList<>(points.size()); for (int index : order) { sortedPoints.add(points.get(index)); } } else { // order 模式按提交顺序直接走,不重排 sortedPoints = points; } GHRequest routeReq = new GHRequest(); for (Point p : sortedPoints) { routeReq.addPoint(new GHPoint(p.getLat(), p.getLng())); } routeReq.setProfile(request.getProfile()); routeReq.setSnapDistance(150); GHResponse rsp = graphHopper.route(routeReq); if (rsp.hasErrors()) { throw new IllegalStateException("路径计算失败: " + rsp.getErrors()); } ResponsePath path = rsp.getBest(); return buildResponse(path, sortedPoints, request.getProfile()); } }

MatrixService.buildTimeMatrix的内部实现就是上一章那段两两 route 的循环。这个拆分带来的直接好处是:将来如果途点数量涨到上百个,你只需要把MatrixService里的实现从“循环调 route”换成“请求独立的 GraphHopper Matrix 服务”,Service 和 Controller 一行都不用改。

参数说明要提一点:request.getProfile()在矩阵和最终路线里必须传同一个值。这段代码里 mode=optimize 时矩阵用 profile 算,最终路线也用同一个 profile 算,顺序模式的路线同样用它,三个环节口径一致,不会出现排序用汽车时间、算路线却切到步行这种事故。

4.3 API 响应:Geometry、耗时与距离怎么一起给前端

前端要画路线,最省事的交付形态是 GeoJSON 的 LineString。GraphHopper 返回的ResponsePath里有PointList,循环取出经纬度拼成 GeoJSON 坐标数组即可:

private RoutePlanResponse buildResponse(ResponsePath path, List<Point> sortedPoints, String profile) { RoutePlanResponse resp = new RoutePlanResponse(); resp.setProfile(profile); resp.setPoints(sortedPoints); // 总耗时、总距离直接取 GraphHopper 的 ResponsePath resp.setDurationSeconds(path.getTime() / 1000.0); resp.setDistanceMeters(path.getDistance()); // PointList 转 GeoJSON LineString PointList pointList = path.getPoints(); List<double[]> coordinates = new ArrayList<>(); for (int i = 0; i < pointList.size(); i++) { coordinates.add(new double[]{pointList.getLon(i), pointList.getLat(i)}); } resp.setGeometry(coordinates); return resp; }

这里有个很容易忽略的坑:PointList.size()返回的路径点数量和途经点数量不是一回事。GraphHopper 返回的完整路径可能包含几千个形状点(shape points),它们描述的是道路弯曲走向;而sortedPoints是业务上要展示的“途经点坐标”。前端描线用 geometry,展示标注用 points,两个字段分开给,职责就清楚了。

Response 的 DTO 大概长这样:

@Data public class RoutePlanResponse { private String profile; private List<Point> points; private double durationSeconds; private double distanceMeters; private List<double[]> geometry; }

最后,异常处理上我给一条血泪经验:GraphHopper 的错误信息大多以列表形式放在rsp.getErrors()里,直接toString()出来的信息往往难读。建议把每个Throwable的 message 单独拎出来,拼进业务异常里,否则线上排查时会因为日志里一堆全限定类名而浪费半小时。

5. 落地排查:GraphHopper + SpringBoot 高频故障与处理

5.1 SpringBoot 版本太高导致 GraphHopper 启动失败

现象:SpringBoot 3.x 项目一启动就报NoSuchMethodError或ClassNotFoundException,指向类似javax.servlet的类。

原因:SpringBoot 3 把 JavaEE 迁移到了 Jakarta EE,GraphHopper 的某个传递依赖如果还引用旧的 javax 命名空间,类加载就会失败。这本质上是版本兼容问题,不是说 GraphHopper 不能用,而是引入的版本太旧。

解决:把 graphhopper-core 升到支持 Jakarta 的近期稳定版,并在 pom 里固定版本号,不要用 LATEST。如果业务被老版本绑定动不了,就把 SpringBoot 降到 2.x 系列,同时用 Java 11 或 17 对应版本。我的建议是优先升级 GraphHopper,因为它对 SpringBoot 的依赖很少,升级成本通常比降级整个框架低。

5.2 图缓存目录没有 graph 文件,启动直接报错

现象:第二次启动时日志出现类似Cannot load graph at ...或者No filesystem found,应用起不来。

原因:importOrLoad()第一次运行成功后,缓存目录里应该有完整的图数据文件;如果中途把graph-cache路径改了,或者第一次建图失败但目录残留了半成品文件,第二次就会加载失败。

解决:删掉残缺的缓存目录,重新执行一次建图。这个操作是安全的,图数据是从 pbf 重新生成的,不会丢业务数据。如果想避免线上误操作,把图缓存路径放到独立挂载盘,并监控 pbf 文件大小、目录内文件数量的变化,建图失败时自动清理重跑。

5.3 坐标点掉进水面,路线绕了个大圈

现象:某个途经点明明在路边,算出来的路线却先开去一公里外绕了个圈,看起来极不合理。

原因:坐标吸附出了问题。GPS 坐标漂移或前端把点传成了楼宇中心坐标时,GraphHopper 会找最近的可行路边吸附;但如果附近有水面、封闭小区、高架桥下穿这类弱路网区域,最近的路可能在几百米外甚至隔着一条河。

解决:先用setSnapDistance(150)放大吸附范围,再通过setSnapPreventions把水、渡轮这类不可能通行的路类排除。如果依旧匹配不上,就在业务层加一个“点位路网匹配检查”的预校验:请求到达后先调一次 route 从该点走去最近一个已知点,距离异常就直接告警返工,把脏数据堵在源头。

5.4 CH 模式下切换 profile 报了 routing profile 不匹配

现象:启动时只配了 car 一个 profile,运行中想让请求方传 foot 或 bike,结果 GraphHopper 直接抛异常,说使用的 profile 没有对应图数据。

原因:CH(Contraction Hierarchies)预处理是为单一 profile 的单一 weighting 准备的加速结构。如果你在 setProfiles 里只配了 car,而某个请求传了 foot,CH 结构里就没有 foot 的边权重,自然算不出路由。

解决:两个思路。一是按 profile 建多个图缓存,car、foot 各一个目录,用配置项区分;二是关闭 CH,回退到非 CH 模式,GraphHopper 会用不带预处理的方式实时算路,损失一部分性能但能支持动态 profile。原型阶段用第二个思路足够,生产环境按业务流量决定要不要做多图隔离。

5.5 优化结果比顺序模式更差:矩阵指标与路线指标不一致

现象:mode=optimize 算出来的总耗时比用户手写的顺序模式还长,大家开始怀疑 TSP 算法有问题。

原因:大概率不是 2-opt 的问题,而是矩阵成本指标和最终路线指标不一致。比如矩阵里用了getDistance()做排序,最终展示和对比却用getTime()。道路类型不同、红绿灯密度不同,距离短的路段未必时间短;排序朝着距离优化,当然可能让总时间恶化。

解决:统一成本函数。矩阵、排序、最终验证全部用getTime(),单位统一成秒。另外在 TSP 单元测试里加一条断言:对任意一组已知点,2-opt 输出的路径总成本必须小于等于最近邻初始解的成本,否则说明 2-opt 实现有边界 bug。成本函数统一之后,优化模式通常能比顺序模式节省 5% 到 15% 的总时间,这个数字和途经点分布有关,但方向一定是变好而不是变差。

6. 让它真正上生产的三个进阶技巧

6.1 给矩阵加缓存,别让每次请求都重复算路

多途经点优化的性能瓶颈几乎都集中在矩阵计算上,N 个点需要做 N×(N-1) 次 route 调用。实际业务里订单地址会重复,完全可以把“坐标列表特征值 → 时间矩阵”的映射缓存半小时到一小时。用 Caffeine 做一个简单的缓存封装,key 用坐标列表的 hash,value 用 double[][],命中直接跳过矩阵计算:

Cache<String, double[][]> matrixCache = Caffeine.newBuilder() .maximumSize(512) .expireAfterWrite(Duration.ofMinutes(30)) .build(); String key = points.stream() .map(p -> p.getLat() + "," + p.getLng()) .collect(Collectors.joining("|")); double[][] matrix = matrixCache.get(key, k -> buildTimeMatrix(points, profile));

注意坐标列表的拼接顺序会影响 key,所以缓存键必须和输入顺序一一对应;如果业务允许起点、途经点换顺序,就把坐标先排序再生成 key。

6.2 用穷举验证 2-opt 的实现质量

2-opt 是启发式算法,不保证全局最优。在途点少于等于 6 个时,你可以直接穷举所有排列,验证 2-opt 的结果和最优解的差距。这个方法能快速抓到代码逻辑错误,也能让你对自己的实现质量心里有数:

@Test void twoOptShouldMatchBruteForceForSmallN() { // 随机生成 6 个点的对称成本矩阵 int n = 6; double[][] cost = randomSymmetricMatrix(n); int[] heuristic = new TspOptimizer(cost).optimize(); int[] brute = bruteForceBestRoute(cost); double heuristicCost = totalCost(heuristic, cost); double bruteCost = totalCost(brute, cost); assertTrue(heuristicCost <= bruteCost * 1.05, "2-opt 偏离最优解不应超过 5%"); }

如果你跑出来偏离超过 5%,先检查 2-opt 的固定首尾实现,再考虑加大局部搜索轮次。我习惯把“穷举对比”写进 CI,每次改算法都能立刻看到回归。

最后的最后,讲一个自己的教训:第一版多途点实现里为了省内存,用距离矩阵做排序、用时间做展示,结果上线后司机实际跑出的路程比原来手排的还长,被运营追着问了一个礼拜。后来把所有环节的成本函数统一,才把这个“优化模块”真正变成可交付的功能。这套源码方案的思路并不复杂:GraphHopper 管路网算路,TSP 管访问顺序,SpringBoot 管接口编排。先跑通最小闭环,再逐步把矩阵缓存、多 profile、快照清洗补上去;每补一层,都要用真实路线数据做一次回归对比。希望帮到你。

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

RELATED READING

延伸阅读

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