资讯动态

Spring Boot公交查询系统毕设实战:数据库设计到BFS换乘算法

发布时间:2026/10/17 20:48:02 来源:尧图企业网站定制
每年做毕业设计找我聊选题的同学特别多。大多数人上来就说要做XX管理系统无非是用户管理、订单管理、增删改查页面堆一堆等答辩的时候老师问一句你系统的难点是什么整个人就僵住了。但如果是公共交通查询系统情况会完全不一样——这是一个大家每天都能感知到的场景天然具备真实的数据结构和算法深度既不冷门也不至于烂大街。公共交通查询系统的核心价值是给用户输入起点站和终点站系统返回可选的公交线路、经过站点、换乘方案输入线路号能看到这条线路的完整走向输入站点名能知道有哪些线路经过这里。这些事情看起来简单但落到数据库设计和后端实现上涉及多对多关系建模、图论搜索算法、数据清洗、接口性能优化是一道非常完整的全栈考题。这篇文章是我做这个项目的全记录从选题理由、技术选型、数据库设计到线路查询、站点查询、换乘算法、性能优化再到实际开发中遇到的坑和答辩时被追问的问题一次性讲清楚。无论你是准备做类似毕设的在校生还是想练手Spring Boot的开发者这篇文章都能给你一套可以直接落地的方案。1. 项目全景为什么公共交通查询系统是个聪明的选题1.1 毕设选题的第一原则场景真实、边界清晰技术类毕业设计最怕两件事一是题目太虚需求全靠编二是题目太窄根本没有发挥空间。公共交通查询系统恰好两头都占不到它来源于城市生活中再普通不过的需求——出门怎么坐车因此需求描述不需要任何想象力导师一听就明白答辩时也不会在业务背景上纠缠太久。更重要的是它的数据模型是天然的多对多关系。一条线路包含几十个站点一个站点可能同时被十几条线路覆盖这种关系和图书管理系统里用户-订单那种一对多关系完全不是一个量级。你在设计表结构、写查询语句、处理数据冗余时会真实感受到关系型数据库建模是怎么一回事。换乘查询这个功能又给项目带来了算法深度。你不可能用简单的SQL查出从A站到B站换乘最少的方案它本质上是图论问题需要把公交网络抽象成一张图再用广度优先搜索BFS来求解。这一下子就把项目从CRUD管理系统拉高到了有算法的系统答辩时的讨论空间完全不一样。1.2 从用户需求反推核心功能做系统之前我习惯先站在用户角度把所有功能列出来标出优先级和实现难度再做取舍。公共交通查询系统的典型用户无非想知道四类事情功能用户意图实现难度备注线路查询输入2路看它从哪到哪、经过哪些站低核心功能必做站点查询输入人民广场看有哪些线路经过中核心功能必做换乘方案从火车站到机场给出换乘路线高亮点功能必做周边站点给我经纬度找500米内的公交站中加分项可选历史记录/收藏记住常用线路和站点低可选视时间定我最后只做了前三个核心功能加周边站点查询。历史记录和收藏因为涉及用户体系会增加不少工作量对核心展示没有决定性的帮助可以先砍掉。项目做到这一步功能已经足够完整答辩时也可以说后续可以扩展留有余地。2. 技术选型与系统架构不盲目追新2.1 为什么是Spring Boot而不是其他方案选题定下来之后技术栈的选择其实没有太多悬念。Spring Boot在Java Web领域已经是事实上的标准内置Tomcat、自动配置、Starter机制把十年前SSH那套繁琐的XML配置全部干掉开发效率高一大截。对毕设来说最重要的是生态成熟遇到问题在搜索引擎里一搜就有答案不会卡在某个冷门配置上出不来。有人会问为什么不用Python的Flask或Django不是说它们不好而是对计算机专业的学生来说Java的强类型和面向对象建模能力更适合这种多实体关联的业务场景。站点、线路、线路站点关联天然可以抽象成Entity、Repository、Service、Controller分层结构写起来思路清晰答辩讲架构也方便。而且Spring Boot在Java就业市场中的占比很高做这个项目顺带把主流框架的套路练了一遍一举两得。版本上我选的是Spring Boot 2.7.x JDK 8没有用最新的Spring Boot 3.x。原因很简单JDK 8在很多学校机房和面试场景里还是主力2.7.x的资料也多遇到问题好查。如果你本机已经装了JDK 17用3.x也不是不行唯一要注意的是3.x里javax包换成了jakarta包很多老教程的import语句直接复制会报错新手容易被这个坑绊一下。2.2 前后端分离还是服务端渲染这个选择要看你做毕设的定位。如果只是为了交差服务端渲染是成本最低的方案用Thymeleaf模板引擎直接渲染HTML后端一个工程搞定部署也简单不用考虑跨域问题。但如果你想让项目在简历上更有分量我建议做前后端分离后端用Spring Boot提供纯JSON接口前端用一个Vue项目。我实际选择的是前后端分离。原因是公共交通查询系统本身是一个查询密集型应用接口语义清晰非常适合用JSON来交互。前端用一个单页应用展示线路详情、换乘方案的地图感界面会比传统模板页面灵活得多。代价是要多维护一个前端工程部署时也要多一步但对完整项目经验的积累很有帮助。答辩时也可以说我考虑了前后端分离架构这比单纯的CRUD页面要有说服力。2.3 项目目录与分层思路包结构我按经典分层来做这样写起来清爽答辩时也好讲entity数据库实体类比如Station、Route、RouteStationrepository数据访问层使用Spring Data JPAservice业务逻辑层处理线路查询、换乘搜索等controller接口层只做参数接收和结果返回dto接口出入参对象避免直接把实体返回给前端config配置类比如跨域、缓存配置common统一返回包装、异常处理有一个容易忽略的点接口返回值不要直接吐实体对象。实体里可能有关联关系、懒加载代理直接序列化容易出循环引用或者多余字段。我统一用一个Result包装类返回code、message、data三个字段前端只用判断code就能知道请求是否成功这个习惯在真实项目中也是通用的。3. 数据库建模与数据准备查询系统的地基3.1 三张核心表的设计思路公共交通查询系统最核心的表就三张站点表、线路表、线路站点关联表。我第一次设计时想复杂了给线路表里加了很多运营属性结果发现大部分根本用不上。兜兜转转最终保留了最精简有用的字段。站点表station的核心字段是站点名称、经度、纬度。经纬度非常重要后面做周边站点查询和换乘方案排序都要用到。注意站点名称可能会重复例如人民广场在不同城区可能都有所以站点不能只按名称区分要有独立主键。线路表route的核心字段是线路编号、线路名称、起点站、终点站、运营开始时间和结束时间。一条线路往往有上下行两个方向比如1路从A站到B站返回时从B站到A站走的站点序列不完全相同。如果图省事把两个方向合并成一条记录换乘算法会算出不存在的路径。所以我在route表里加了direction字段上下行分两条记录存一条线路编号对应两条route记录这是非常关键的设计决策。线路站点关联表route_station是连接线路和站点的桥梁。它至少要包含三个字段线路ID、站点ID、站点序号seq_no。站点序号表示该站是这条线路的第几站换乘算法按这个排序才能还原出完整的行驶路径。如果还要展示站间距可以加一个distance_from_start字段表示距离起点站的距离。建表SQL可以参考这样的结构CREATE TABLE station ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL, lng DECIMAL(10, 6), lat DECIMAL(10, 6), district VARCHAR(50), address VARCHAR(200), UNIQUE KEY uk_station_name_lnglat (name, lng, lat) ) ENGINE InnoDB DEFAULT CHARSET utf8mb4; CREATE TABLE route ( id BIGINT PRIMARY KEY AUTO_INCREMENT, route_no VARCHAR(20) NOT NULL, route_name VARCHAR(100), start_station VARCHAR(100), end_station VARCHAR(100), direction TINYINT COMMENT 0-上行 1-下行, start_time VARCHAR(10), end_time VARCHAR(10), UNIQUE KEY uk_route_no_direction (route_no, direction) ) ENGINE InnoDB DEFAULT CHARSET utf8mb4; CREATE TABLE route_station ( id BIGINT PRIMARY KEY AUTO_INCREMENT, route_id BIGINT NOT NULL, station_id BIGINT NOT NULL, seq_no INT NOT NULL COMMENT 站点在线路中的顺序, distance_from_start DECIMAL(10, 2) COMMENT 距起点距离单位公里, KEY idx_route_id (route_id), KEY idx_station_id (station_id) ) ENGINE InnoDB DEFAULT CHARSET utf8mb4;这里我把route_no和direction做成联合唯一键防止同一条线路的上下行重复录入。站点表也做了名称和经纬度的唯一约束避免同一位置出现多个幽灵站。这类细节在答辩时主动讲出来老师会觉得你真的思考过数据质量问题。3.2 数据从哪里来模拟数据与真实数据的平衡数据是做这个项目最容易卡住的地方。真实公交数据涉及版权和接口授权个人项目跑起来比较麻烦纯手工造几百条数据又累又容易出逻辑漏洞。我的做法是写一个Python脚本参考某地图开放平台的公开公交线路格式生成了某市一套模拟数据一共20条线路、约800个站点、3000多条线路站点关联记录。规模虽然不大但足够支撑功能和算法验证。生成数据时有两个地方要特别注意。一是站点名称要有合理的重复比如火车站这种换乘大站必须出现在多条线路上否则换乘查询根本没法测。二是线路要有环路和断头路不能所有线路都简单地首尾相连否则图结构太规整算法测试不出边界情况。数据清洗上最大的坑是编码问题。线路名和站点名里可能有生僻字、繁体字MySQL必须设置utf8mb4字符集Java连接串里也要加上characterEncodingutf8参数否则中文存进去再查出来就是问号。我一开始没用utf8mb4结果人民广场存成了?? 修了一晚上。3.3 为换乘查询做数据预热换乘查询如果每次实时去数据库里多次join性能会很难看。我的做法是应用启动完成后把站点和线路的关系加载到内存里构建一个以站点ID为key、以经过该站点的线路列表为value的Map。Component public class TransferDataLoader implements ApplicationRunner { Resource private RouteStationRepository routeStationRepository; Resource private StationRepository stationRepository; private MapLong, ListRouteStation stationToRoutes; private MapLong, Station stationMap; Override public void run(ApplicationArguments args) { ListStation stations stationRepository.findAll(); stationMap stations.stream().collect(Collectors.toMap(Station::getId, s - s)); ListRouteStation relationList routeStationRepository.findAll(); stationToRoutes relationList.stream().collect( Collectors.groupingBy(RouteStation::getStationId) ); } }这里的关键是groupingBy按站点ID把经过它的所有关联记录分组。换乘BFS搜索时从一个站点出发能坐哪些线路直接从这个Map里取O(1)时间复杂度非常快。启动预热虽然占用一点内存但换乘查询的响应时间能控制在几十毫秒以内这笔交易很划算。4. 核心功能模块实现线路查询与站点查询4.1 线路查询接口的完整实现线路查询的业务逻辑很直接根据前端传来的线路编号比如2路或者2匹配到所有相关线路然后按方向列出经过的站点。这里面最容易写错的地方是一条线路有上下行查询时要把两个方向都返回并且每个方向内的站点顺序必须严格按照seq_no升序排列。Controller层我只做参数校验和结果封装RestController RequestMapping(/api/route) public class RouteController { Resource private RouteService routeService; GetMapping(/{routeNo}) public ResultListRouteDetailDTO getRouteDetail(PathVariable String routeNo) { if (routeNo null || routeNo.trim().isEmpty()) { return Result.error(线路号不能为空); } return Result.success(routeService.getRouteDetail(routeNo.trim())); } }Service层是业务核心先查线路再查线路站点关联最后组装站点明细。JPA里如果直接用实体关联查询很容易出现N1问题也就是查了一条线路之后每查一个关联站点都发一条SQL。避免的办法是使用JOIN FETCH一次性把关联数据查出来Service public class RouteServiceImpl implements RouteService { Resource private RouteRepository routeRepository; Resource private RouteStationRepository routeStationRepository; Resource private StationRepository stationRepository; Override public ListRouteDetailDTO getRouteDetail(String routeNo) { ListRoute routes routeRepository.findByRouteNoOrderByDirectionAsc(routeNo); if (routes.isEmpty()) { throw new BusinessException(未找到该线路); } ListRouteDetailDTO result new ArrayList(); for (Route route : routes) { ListRouteStation rsList routeStationRepository .findByRouteIdOrderBySeqNoAsc(route.getId()); ListStationInfoDTO stations rsList.stream() .map(rs - { Station station stationRepository.findById(rs.getStationId()).orElse(null); if (station null) { return null; } StationInfoDTO dto new StationInfoDTO(); dto.setStationId(station.getId()); dto.setStationName(station.getName()); dto.setSeqNo(rs.getSeqNo()); return dto; }) .filter(Objects::nonNull) .collect(Collectors.toList()); RouteDetailDTO dto new RouteDetailDTO(); dto.setRouteId(route.getId()); dto.setRouteNo(route.getRouteNo()); dto.setStartStation(route.getStartStation()); dto.setEndStation(route.getEndStation()); dto.setDirection(route.getDirection()); dto.setStartTime(route.getStartTime()); dto.setEndTime(route.getEndTime()); dto.setStations(stations); result.add(dto); } return result; } }这里我特意没有在Route实体里用OneToMany直接关联RouteStation而是单独用Repository按条件查询。这样做牺牲了一点ORM的便利但换来的是SQL可控、不会莫名触发懒加载异常。对于毕业设计这种规模的项目清晰比花哨重要。4.2 站点查询与关键字的模糊搜索站点查询的输入是关键字输出是匹配到的站点以及经过这些站点的线路。最朴素的SQL是WHERE name LIKE %关键字%但这里有个性能陷阱前导通配符会让普通索引失效。如果站点表数据量小问题不大我测试时800个站点查询也在毫秒级可以接受。如果想要更专业一点可以给站点名称加一个全文索引。MySQL的全文索引对中文支持不算特别好需要ngram分词器。在真实项目中很多系统会引入搜索引擎但一个毕业设计做成这样已经足够我在答辩时的说法是当前数据量下LIKE查询满足需求若扩展到全量数据可引入全文索引或搜索引擎这个回答既诚实又体现你有扩展思维。站点重名问题也必须处理。比如人民广场在多个区域都有用户输入这个名字查询结果应该把所有同名站点都返回并且每个站点下列出经过它的线路。前端可以在展示时加上区域或经纬度来区分。Service层返回结构可以这样设计public class StationSearchDTO { private Long stationId; private String stationName; private String district; private BigDecimal lng; private BigDecimal lat; private ListString routeNos; // 经过该站点的线路编号列表 }4.3 周边站点查询一个优雅的加分项周边站点查询实现起来不算复杂但对用户体验提升非常明显。用户打开系统输入自己当前的经纬度系统返回500米或1公里内的公交站点。这里用到的核心是球面距离公式也就是Haversine公式。MySQL 5.7以上直接提供了ST_Distance_Sphere函数可以按经纬度算球面距离Query(value SELECT s.*, ST_Distance_Sphere(point(s.lng, s.lat), point(?1, ?2)) AS distance FROM station s HAVING distance ?3 ORDER BY distance LIMIT 10, nativeQuery true) ListStationDistanceDTO findNearbyStations(double lng, double lat, double radius);注意ST_Distance_Sphere返回单位是米所以传入的radius就是500或1000。用原生SQL时DTO里的distance字段是一个别名的映射我在实际开发中发现如果不加HAVING distance ?3MySQL会直接报错因为别名不能在WHERE里引用。这个细节花了一点时间才排查清楚。5. 换乘方案查询从BFS到工程化实现5.1 把公交网络抽象成图换乘查询是公交查询系统最核心的算法也是整个项目里最有含金量的部分。我先讲清楚图是怎么建的把每个站点看成图的顶点每一条线路看成一组连接关系。但这里有个容易理解错的地方换乘最少的问题不能简单地把相邻两个站点都当成一条边来做BFS那样算出来的最短路径很可能是步行距离最短而非换乘最少。换乘最少问题的正确打开方式是从站点A出发选择一条经过该站点的线路L沿着L可以一路坐到这条线上的任意站点都不算换乘如果中途下车换到另一条线路M这才算一次换乘。所以BFS的步数应该是换乘次数每个状态要记录当前坐的是哪条线路。用生活化的方式理解你站在站台上面前停了10路、20路、30路你选择坐上20路的那一刻就是一次决策20路经过10个站只要你没下车这10个站之间都不需要再做决策。只有当你想换到30路的时候才需要再花一次决策成本。BFS按决策次数逐层扩展第一次到达终点站的方案就是换乘最少的方案。5.2 BFS换乘算法的代码实现我定义了一个内部类来表示搜索状态public class TransferNode { private Long stationId; // 当前站点 private Long routeId; // 当前乘坐的线路 private int transferCount; // 已经换乘的次数 private ListLong stationPath; // 经过的站点路径 private ListLong routePath; // 经过的线路路径 public TransferNode(Long stationId, Long routeId, int transferCount, ListLong stationPath, ListLong routePath) { this.stationId stationId; this.routeId routeId; this.transferCount transferCount; this.stationPath new ArrayList(stationPath); this.routePath new ArrayList(routePath); } }搜索的时候用一个队列做BFS再用一个Map记录每个站点在不同线路下已经出现的换乘次数用于剪枝public TransferResult findTransfer(Long startStationId, Long endStationId, int maxTransfer) { QueueTransferNode queue new LinkedList(); MapString, Integer visited new HashMap(); ListRouteStation startRsList stationToRoutes.get(startStationId); if (startRsList null) { return TransferResult.empty(起点站不存在); } for (RouteStation rs : startRsList) { Route route routeMap.get(rs.getRouteId()); ListLong stationPath new ArrayList(); stationPath.add(startStationId); ListLong routePath new ArrayList(); routePath.add(rs.getRouteId()); TransferNode node new TransferNode(startStationId, rs.getRouteId(), 0, stationPath, routePath); queue.offer(node); visited.put(key(startStationId, rs.getRouteId()), 0); } while (!queue.isEmpty()) { TransferNode node queue.poll(); if (node.getTransferCount() maxTransfer) { continue; } if (node.getStationId().equals(endStationId)) { return buildResult(node); } ListRouteStation currentLineStations routeStationMap.get(node.getRouteId()); for (RouteStation rs : currentLineStations) { Long nextStationId rs.getStationId(); if (node.getStationPath().contains(nextStationId)) { continue; } // 沿当前线路继续坐不增加换乘次数 ListLong nextStationPath new ArrayList(node.getStationPath()); nextStationPath.add(nextStationId); TransferNode nextNode new TransferNode(nextStationId, node.getRouteId(), node.getTransferCount(), nextStationPath, node.getRoutePath()); queue.offer(nextNode); } // 在当前站点换乘其他线路 ListRouteStation crossLineList stationToRoutes.get(node.getStationId()); for (RouteStation rs : crossLineList) { Long nextRouteId rs.getRouteId(); if (nextRouteId.equals(node.getRouteId())) { continue; } String visitKey key(node.getStationId(), nextRouteId); int nextTransferCount node.getTransferCount() 1; if (nextTransferCount maxTransfer) { continue; } Integer prevCount visited.get(visitKey); if (prevCount ! null prevCount nextTransferCount) { continue; } visited.put(visitKey, nextTransferCount); ListLong nextRoutePath new ArrayList(node.getRoutePath()); nextRoutePath.add(nextRouteId); TransferNode nextNode new TransferNode(node.getStationId(), nextRouteId, nextTransferCount, node.getStationPath(), nextRoutePath); queue.offer(nextNode); } } return TransferResult.empty(未找到合适的换乘方案); }这段代码有一个关键的剪枝逻辑同一个站点同一条线路如果之前已经用更少的换乘次数访问过那就没必要再入队。如果不做这个剪枝环路线路上会无限循环站点一多直接就内存溢出了。我最初版本漏了这个测试时一个很小的图就卡死排查了半天才发现问题。换乘次数上限我设置成了3次也就是最多允许4条线路接驳。真实城市公交出行中超过3次换乘的路径几乎没有实际意义同时限制深度可以防止算法在稀疏图上空转。5.3 换乘结果的排序与返回格式BFS第一次找到终点时换乘次数一定是最少的但实际使用中同样是换乘1次可能是走2站然后换乘也可能是走20站再换乘后者体验明显更差。所以我的策略是继续BFS把所有小于等于最大换乘次数的方案都收集起来然后按换乘次数优先、总经过站数其次的规则排序取前3条返回。前端拿到换乘方案之后可以分段渲染每一段显示乘坐X路从A站到B站共N站这样界面展示非常直观。接口返回格式大致是{ code: 200, data: [ { transferCount: 1, totalStations: 12, segments: [ { routeNo: 10路, startStation: 火车站, endStation: 广场东, stationCount: 5 }, { routeNo: 20路, startStation: 广场东, endStation: 机场, stationCount: 7 } ] } ] }答辩时老师很喜欢问为什么不用Dijkstra。我的理解是Dijkstra解决的是带权最短路径如果权重是时间或者距离当然可以用但最少换乘次数的权重是等值的每次换乘代价为1BFS天然按层扩展每层就是一次换乘复杂度更低也更贴合问题本质。如果要坐车时间最短可以把站间距离或时间作为边权切换到Dijkstra我当时在系统里也预留了策略接口只是没有完整实现这个话术在答辩现场很加分。6. 实操过程记录从初始化到联调通过6.1 环境准备与项目初始化环境的坑往往比代码坑更难处理。我用的组合是JDK 8 Maven 3.8 Spring Boot 2.7.18 MySQL 8.0。打开Spring Initializr依赖选了Spring Web、Spring Data JPA、MySQL Driver、Lombok、Validation。如果你的前端做前后端分离还要记得在Config里写一个跨域过滤器否则前端访问接口会被浏览器拦下来。application.yml的基本配置长这样server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/transit?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver jpa: hibernate: ddl-auto: update show-sql: true properties: hibernate: format_sql: true cache: type: caffeineddl-auto: update对于毕设项目非常省事实体类改完字段重启自动帮你同步表结构。但注意这只能用于开发环境如果换字段类型或删字段自动更新是不可靠的正式环境一定要用Flyway这类迁移工具。答辩时可以说开发阶段用update验证生产环境应当使用版本化迁移体现工程质量意识。6.2 核心接口的开发顺序我建议的开发顺序是实体类 - Repository - Service - Controller - 前端联调。这看起来像废话但真有很多人先写Controller再补Service最后实体还没建调试时到处报空指针。实际开发时我踩过的最大的坑是JPA的懒加载。查线路列表没问题但查线路详情时因为实体关系用了ManyToOne当Jackson序列化Route对象时可能触发对Station的懒加载而Session已经关闭直接抛出LazyInitializationException。解决方法是DTO层兜底绝不在实体上直接序列化而是手动把需要的数据复制到DTO里。一旦你这么做了会发现懒加载的问题瞬间消失。至于接口调试我用的是常见的接口调试工具比如Postman这类。先把线路查询调通再调站点查询再调周边站点最后集中火力处理换乘。每调通一个接口就提交一次代码别等到最后统一提交否则出了Bug都不知道是哪次改动引起的。6.3 缓存优化与SQL调优查询系统的性能优化点在读。线路详情和换乘结果都是高频查询但数据本身变化很慢非常适合加缓存。我用Spring Cache加Caffeine做了两级本地缓存Service public class RouteServiceImpl implements RouteService { Cacheable(value routeDetail, key #routeNo) Override public ListRouteDetailDTO getRouteDetail(String routeNo) { // 去数据库查 } }实测效果非常明显第一次查询可能需要50毫秒加了缓存之后稳定在个位数毫秒。因为数据是启动时加载的内存数据甚至可以不查数据库直接返回但用Spring Cache的好处是代码侵入小逻辑清晰。SQL调优方面我重点看route_station表的两条查询路径按routeId查、按stationId查这两个字段都建了索引。用EXPLAIN看执行计划确认是走索引的没有出现全表扫描。如果想再压一下性能可以给(route_id, seq_no)建联合索引因为线路详情的查询永远是按线路ID加序号排序的联合索引能直接覆盖查询。7. 常见问题与排查技巧实录7.1 高频报错与解决方案速查表我把自己开发中遇到的高频问题整理成了一张表遇到类似报错可以直接对照处理报错信息可能原因解决方案Access denied for user rootlocalhost数据库账号密码错误检查application.yml配置确认密码无误Loading class com.mysql.jdbc.DriverMySQL驱动类名写错新版MySQL驱动类名改为com.mysql.cj.jdbc.DriverLazyInitializationException实体懒加载跨Session访问改用DTO组装数据不要直接返回实体Cannot deserialize value...前端参数类型和后端不一致检查Controller入参类型JSON字段名是否匹配中文乱码数据库连接串和目标表字符集不对URL加characterEncodingutf8表建为utf8mb4端口被占用上次服务没有正常关闭杀掉占用8080端口的进程或换端口换乘查询内存溢出BFS剪枝不完善给每个站点线路组合做好visited记录其中LazyInitializationException是最容易让新手崩溃的因为它报错的位置在Jackson序列化阶段看起来完全没有逻辑其实只要坚持接口一定返回DTO这个原则这类问题就能从根上避免。7.2 换乘查询返回空结果的排查思路换乘接口做完第一次测试时我连续试了好几组起终点都返回未找到合适换乘方案。我没有急着改代码而是先做了三件事第一打印BFS搜索的起点线路集合确认起点站确实有线路经过第二检查终点站是否在可达集合里做一个简单的连通性检测第三逐步减少maxTransfer上限看看是不是搜索深度不够。最终发现的问题很有意思我的数据生成脚本里有一条环线它从某站出发绕一圈回到同一站BFS在沿这条线路扩展时遇到的站点都已经被其他节点访问过剪枝策略把很多其实没被合理访问的节点活生生剪掉了。也就是说visited记录只考虑了站点线路的组合却没有把换乘次数纳入判断的最优维度导致部分换乘次数更少的可能性被错误剪掉。修正办法是在visited里保存当前最优换乘次数只有新的换乘次数更小才允许重新访问。这种问题在书本上很难看到只有实际构造出环线数据、跑出错误结果之后才会真正理解BFS剪枝的边界条件。我把这个Bug原原本本地写进了项目文档里答辩时当作分析问题和解决问题能力的例子来讲比任何包装都有说服力。7.3 答辩环节的高频追问与应对思路答辩时老师问我的几个问题基本是所有做这个题目都会被问到的提前准备一下很有必要。第一个问题你的数据是真实数据吗 我如实回答是模拟数据但数据结构参考了公开的公交数据格式同时说明数据量对验证功能足够如果需要接入真实数据只需要写一个数据导入适配器。这个回答既诚实又表明你考虑过数据来源。第二个问题换乘算法为什么不直接查数据库 我的解释是换乘本质是图搜索数据库的SQL不适合表达最少换乘这种跨线路的路径搜索所以用内存图加BFS。同时说明为什么数据量不大时可以内存加载如果站点数量达到百万级可以改用图数据库或分片缓存。这个扩展思路会让答辩老师觉得你有全局视野。第三个问题系统还有什么可以改进的地方 我列了三点引入实时到站数据、加入发车间隔推算总耗时、把换乘策略改成多目标优化最少时间、最少步行、最少换乘可切换。这些不是空话而是结合公共交通领域的真实痛点提出来的方向老师一听就知道你对这个领域有过思考。做这个项目的一点个人心得整个项目从选题到最后文档完稿我大概花了三周时间。回头复盘最花时间的不是写代码而是设计数据结构和换乘算法那两天。有一个很深的体会毕业设计不是完成度竞赛而是思考深度展示。同样一个换乘函数有人直接网上抄一段Dijkstra改改有人能讲清楚为什么在最少换乘场景下BFS比Dijkstra更合适后者的分数一定高得多因为老师想看到的是你能独立建模、独立决策。最后再分享一个小技巧开发之前先花半天时间用一张纸上把所有站点的线路关系画出来哪怕只是简化版。这张图不仅是你的设计草图更是答辩时能亮出来的素材。我答辩时把这张手绘图扫描进PPT里配合BFS的层次扩展过程讲解换乘逻辑效果比放一堆代码截图好太多了。如果你也想做类似的系统我的建议是先定数据再定算法最后写接口。顺序搞反了后续返工的成本远比你想象的高。现在这套站点-线路-关联表的建模思路换个马甲就能用在地铁查询、园区摆渡车、校内班车等场景里复用价值极高。做一次不亏。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑