简介:一份基于C++编程语言的火车调度模拟程序,面向正在学习数据结构、算法设计与C++面向对象开发的编程学习者。程序以N列火车依次进站为背景,通过队列、栈等容器模拟车站缓冲与出站流程,借助深度优先搜索或回溯策略输出所有可能的出站顺序,直观呈现调度算法的核心思想与求解过程。压缩包内共1个文件,为单个cpp源程序,体积仅447B,代码体量小而完整,便于逐行研读与二次修改。该资源已有621人学习浏览,具有一定学习热度。阅读源码时,可以深入理解结构体与类的定义、iostream输入输出流、异常处理以及函数模块化设计等C++关键特性;同时学会将抽象排列组合问题转化为可运行的调度算法,掌握参数校验、循环遍历与结果格式化输出的实用技巧,适合用于课程设计、毕业设计或算法竞赛备赛。
1. 火车调度模拟的C++源程序:先拆调度模型,再碰可视化
火车调度模拟这个C++源程序包,拿到手第一件事不是编译,而是先搞清楚它用的是事件驱动仿真还是时间片轮转。绝大多数人翻车都是因为把它当成了画轨道的可视化Demo,真正值钱的部分是后端的调度模型:时机、时序、冲突。做课设、做毕设、或者想理解列车怎么在单线上不乱跑,这套源码都能直接改着用。源码里没有花哨的图形界面,核心是一套可复现的闭塞区间分配逻辑,编译产物会输出列车运行事件流水,供你核对每一列车的进出站时刻与会让结果。
2. 数据结构先行:区段、列车与事件队列的C++建模
火车调度模拟的代码写起来不难,难在抽象。我得先把真实铁轨上的“区段”“列车”“调度命令”翻译成C++对象,翻译得好,后续调度算法就是查表和比较大小;翻译得差,后面加一个会让逻辑就要推翻重来。
2.1 轨道拓扑:用线性区段数组,而不是邻接表
一条单线铁路,从甲站到乙站,中间隔着若干个区间。这个结构本质是线性的。第一版我习惯用邻接表建模,后来发现是给自己挖坑:调度关心的不是“哪个节点连着哪个节点”,而是“某个区段当前被谁占用、下一段空闲不空闲”。邻接表能表达拓扑,但表达不了占用状态。
更直接的方案是把轨道切成区段数组,每个区段记录自己的长度、占用列车的编号、方向锁和双向相邻指针。侧线、会让站就当成带额外等待属性的特殊区段节点,运行时通过标志位区别。
enum class Direction { A_TO_B, B_TO_A, NONE }; struct TrackSection { int id; int length; // 区段长度,单位米 int occupiedBy; // 占用列车ID,-1表示空闲 Direction dirLock; // 方向锁,防止对向列车进入 TrackSection* nextToB; // 沿A->B方向的下一个区段 TrackSection* nextToA; // 沿B->A方向的下一个区段 bool isStation; // 是否为会让站/车站 int stationDelay; // 计划停车时长,单位毫秒 };这段代码定了整个仿真的基座。occupiedBy是关键字段,调度算法里所有的冲突检测都是围绕它做的。dirLock这个字段很多人第一版会漏,没有它,双向单线很快就会死锁,后面我们会单独讲。isStation标志位让同一个结构既能表示区间也能表示车站,不需要额外建类。
2.2 列车对象:状态机是防呆的核心
列车不能只用一个“当前位置”表示。列车在真实调度里有一套生命周期:等待发车、区间运行、站内停车、终到。如果你用散装的布尔变量去记录这些状态,调度逻辑一复杂就乱成一团。
enum class TrainState { WAITING_DEPARTURE, // 在始发站等待发车 RUNNING, // 正在区间运行 STOPPED_AT_STATION, // 在站内停车 ARRIVED // 到达终点,退出仿真 }; struct Train { int trainId; TrainState state; int speed; // 运行速度,单位 km/h int currentSection; // 当前所在区段索引 int nextSection; // 下一个要申请的区段索引 int planDepartTime; // 计划发车时刻(相对时间0的毫秒数) int departDelayAllowance; // 允许晚点时间,超过则丢弃该计划 };状态机的好处是让调度主循环非常干净:每个时间片,程序只需要问“列车当前状态是什么,下一步允许做什么”。planDepartTime是排图时定死的,仿真的目标是尽量贴着这个时刻走。departDelayAllowance是工程里必须有的参数——真实调度里列车不可能准点到秒,源码里给一个容差窗口,超过就报警,避免仿真无限追一颗永远追不上的时刻表。
列车状态迁移有一个表可以直接写进注释里:
| 当前状态 | 条件 | 迁移到 |
|---|---|---|
| WAITING_DEPARTURE | 计划时刻到达且前方区段空闲 | RUNNING |
| RUNNING | 到达下一站区段边界 | STOPPED_AT_STATION |
| STOPPED_AT_STATION | 停车时长耗尽 | RUNNING 或 ARRIVED |
| RUNNING | 到终点站 | ARRIVED |
2.3 事件队列:priority_queue 驱动的时间推进
第一版我写的是时间片轮转,每 100ms 扫描一次所有列车,效率低且精度差。后来改成事件驱动:不扫描时间,只按“下一件该做的事”跳着推进。列车进入区段、离开区段、到达车站、从车站发车,全是事件。事件放进一个小顶堆,谁的时刻小谁先出队。
struct SimEvent { int time; // 事件触发时刻,毫秒 int trainId; int sectionId; EventType type; // ENTER_SECTION, LEAVE_SECTION, ARRIVE_STATION, DEPART // priority_queue 默认大顶堆,这里取反实现小顶堆 bool operator<(const SimEvent& other) const { return time > other.time; } };operator<的写法值得多说一句:C++ 的priority_queue默认是最大值优先,要让时间最早的先出队,比较函数要反过来写。这个反直觉的细节是编译能过、逻辑全反的高发区,我在第四部分的踩坑记录里会展开。事件驱动还有另一个好处——它天然支持“同一时刻多件事”的并行处理:出队时把所有time相等的先归为一组,再按事件类型排序处理,这个细节也留到避坑部分。
3. 调度逻辑:闭塞区间分配与冲突检测的两个核心函数
数据结构搭好后,调度算法的骨架就是两个函数:一个负责判断“列车能不能进入目标区段”,另一个负责在双向运行的线路上打破死锁。这两个函数写出正确版本,调度模拟的核心就跑通了。
3.1 区间占用检查:先查锁再查车
进入区间检查的职责是防止两列车同时占用同一区段,以及防止对向列车在同一条单线上脸对脸。检查顺序必须先锁后车:如果方向锁被对向列车占着,即使占用表显示空闲,也不能进,否则会出现“两列车同时从两端开进一个空区间”的翻车现场。
bool canEnterSection(Train& train, TrackSection& target) { // 1. 方向锁冲突检查 if (target.dirLock != Direction::NONE && target.dirLock != train.heading) { return false; // 对向列车正在使用或已预约该区段 } // 2. 占用检查 if (target.occupiedBy != -1) { return false; // 区段内已有列车 } // 3. 连续追踪间隔检查:前车速度比后车低时强制拉开距离 Train* leader = getTrainById(target.occupiedBy); if (leader && leader->speed < train.speed) { return false; // 按最小追踪间隔过滤,这里简化为速度条件 } return true; }参数说明:target.dirLock表示当前区段被预约给了哪个方向,NONE表示双方向均可进入。检查完后,调用方要把target.occupiedBy改成当前列车ID,同时把方向锁设为该车行进方向。第3步的追踪间隔是我额外加的安全垫,真实调度里这是“最小间隔 N 分钟”的硬约束,仿真里选择一个保守版本:前车速度低于后车时强制拒绝,防止虚拟追尾,也防止输出数据出现“两列车跑在同一区间”这种没法交差的日志。
这个函数有个边界场景必须注意:列车请求进入的区段可能紧贴终点站,getTrainById拿到的可能是一列已经到达、尚未移除的列车。所以我在实现里规定,ARRIVED状态的列车不参与占用检查,相当于到站即释放全线路占用,否则站内会让和折返会全部卡死。
3.2 会让与死锁消解:方向优先级和超时回退
单线双向调度最难的不是查占用,而是两列车对开、单线又只有一段会让侧线时的死锁。死锁的典型形态:A列车从甲站向乙站占用了1号区段,B列车从乙站向甲站占用了3号区段,侧线2号区段空着,但A要等B离开3号才能进2,B要等A离开1号才能进2,互相等待,谁也没法动。
我的做法是引入“方向优先级 + 超时回退”两层机制。方向优先级在发车时确定:同一时刻只允许一个方向的列车获准进入单线区间。超时回退负责破局:如果一列车等待进入区段超过设定阈值(比如3000ms仿真时间),强制降低优先级,抽出侧线,让对向列车先通过。
void resolveDeadlock(vector<Train>& waitingTrains) { // 找出等待时间最长的列车,优先放行 auto* victim = &waitingTrains[0]; for (auto& t : waitingTrains) { if (t.waitSince > victim->waitSince) { victim = &t; } } // 该车占用的所有区段全部释放,车辆退回上一站 for (auto& sec : roadNetwork) { if (sec.occupiedBy == victim->trainId) { sec.occupiedBy = -1; sec.dirLock = Direction::NONE; } } // 回退后强制让它在车站多停一个间隔 victim->state = TrainState::STOPPED_AT_STATION; victim->stopRemainTime = deadlockPenaltyMs; }这个函数名字叫resolveDeadlock,但实际运行中它更像一个兜底安全网。我不会主动去检测图论意义上的死锁,因为调度模拟里的死锁绝大多数是“局部互等”,检测死锁的复杂度和收益完全不成比例,不如直接按等待时间回退。参数说明:waitSince是列车进入等待队列的时刻,deadlockPenaltyMs我一般设成 5000,比正常会让时间略长,这样不会因为过于频繁的回退干扰正常排图。
实际使用中还有更柔性的解法:在排图阶段直接错开发车时刻,从源头避免对向列车在单线窄桥相遇。自动排图的滑动窗口算法我放在第五部分,配合优先队列的事件机制,可以让大多数列车在运行过程中根本不触碰到回退逻辑。
4. 避坑排查:列车穿模、死锁卡死与时间乱序的五条血泪经验
这部分是我自己反复踩过的坑,有些是逻辑疏漏,有些是C++本身的陷阱。每一条都不是看一眼就能发现的,报错信息也帮不上忙,只有跑出来的数据彻底不对你才会回头查。
4.1 现象:两列车同时出现在同一区段,日志却显示它们互不知情
原因是我在“离开区段”和“进入区段”两个事件都安排在同一仿真时刻处理,并且先处理进入,后处理离开。前车还没离开,后车已经进来了。occupiedBy只有一个人格,它记不住前车,后车一写入,前车的占用记录就丢失了。
解决:同一时刻的事件必须先处理LEAVE_SECTION,再处理ENTER_SECTION。我在事件出队后的处理函数里先按类型排序,释放类事件优先执行。
// 同一时刻事件处理顺序:LEAVE 先于 ENTER sort(batch.begin(), batch.end(), [](const SimEvent& a, const SimEvent& b) { return (a.type == LEAVE_SECTION) > (b.type == LEAVE_SECTION); });这个排序看着简单,但没有它,几乎所有多列车模拟都会在某个边界时间点炸一次。从那以后,我写仿真事件处理器都有一个默认习惯:把所有事件先按时间分组,再按“释放-占用-状态变更”的顺序处理。
4.2 现象:模拟跑到中途完全卡死,没有任何报错,日志停在同一条
原因:两列对向列车在单线区间互相锁死,方向锁和占用表都指向对方,谁也不让。我的第一版完全没有回退机制,调度器只会检查“能不能进”,不会处理“进不去怎么办”。
解决:加超时回退,同时把方向锁的检查挪到发车阶段。发车时如果判断对向列车已经申请了前方区间,直接延迟发车,不进入等待队列。另一个技巧是设置一个仿真时钟上限,如果推进时间超过该值且事件队列仍非空,直接判定死锁并输出最后的占用表快照——这样不会在调试时静默挂起。
4.3 现象:仿真时间越走越偏,每列车的到站时刻都对不上排图
原因:我用的是double存储时间,每次计算运行时长都要做一次乘法和除法,累计误差在小数点后两位逐渐放大,跑到半小时仿真时间后偏差到了几十秒。排图数据是整数时刻,对不上。
解决:所有仿真内部时间一律换成整数int,单位固定为毫秒。区段长度除以速度得到毫秒数,四舍五入取整。整数上的加法不产生误差,时刻表、事件队列、比较器全部整数化。这是为了让时间戳可严格比对,整数时刻还能用==判断事件是否同时发生,浮点数做不到这一点。
4.4 现象:priority_queue 里先出队的永远是发车最晚的列车
原因:priority_queue默认是最大值优先,我没改比较器,把所有事件按时间降序排了。更隐蔽的是,写了自定义比较器但方向取反又写错,导致队列行为时好时坏。
解决:比较器里return time > other.time才能得到小顶堆效果。我每次写完会用三连测做验证:插入(100, 200, 300),逐个弹出,确认输出的顺序为100, 200, 300。这个小测试只要五秒钟,但能省掉一整晚的排查时间。
4.5 现象:编译通过,运行到一半vector越界,崩溃
原因:列车的nextSection字段指向了不存在的区段索引。通常是终点站处理没做边界判断:列车到达终点后仍然继续请求下一个区段,而线路上根本没有“下一个”。
解决:在请求进入区段前,先判断currentSection是否为最后一个区段。如果是,直接置ARRIVED状态,跳过所有进入流程。同时把区段数组封装成按索引取指针的辅助函数,越界时返回nullptr并打日志。我不建议用断言直接退出,因为仿真跑到半夜的文件日志 + 崩溃很折磨人,输出一条“train 3 attempted to leave terminal”比segment fault更容易定位。
5. 进阶:把事件流画成时序图,并用滑动时间窗自动生成排图
源码包自带的运行日志是文字事件流,肉眼检查几列列车还行,项目上需要给导师和别人演示时,白纸黑字的日志不够直观。我自己一般写一个极简的Python脚本,把C++生成的CSV事件读进来,用文本字符画一张时间-区间占用时序图。字符画的好处是运行环境零依赖,任何系统都能直接出图。
# timeline.py import sys sections = ["甲站", "区段1", "会让站", "区段2", "乙站"] events = [] # (time, train_id, section_idx, is_enter) for line in open(sys.argv[1]): parts = line.strip().split(",") t, tid, sid, typ = int(parts[0]), int(parts[1]), int(parts[2]), parts[3] events.append((t, tid, sid, typ)) occupancy = {sid: {} for sid in range(len(sections))} for t, tid, sid, typ in sorted(events): if typ == "ENTER": occupancy[sid][tid] = t elif typ == "LEAVE": start = occupancy[sid].pop(tid) print(f"{sections[sid]:<6} 列车{tid:<2} {t/1000:8.1f}s - {start/1000:8.1f}s")参数说明:CSV每行五个字段,分别是事件发生时刻、列车编号、区段索引、事件类型。occupancy字典记录每列车进入区段的起始时刻,遇到LEAVE就打印一条占用区间记录。
排图方面,我提供一个滑动时间窗自动排图的做法:按列车优先级排序,逐列插入时刻表。每插入一列车就检查其完整运行路径上的所有区段占用,与已有列车冲突则将发车时刻顺延一个最小间隔,并重新检查全路径,直到无冲突或超出允许晚点窗口。这个算法的复杂度是 O(NMT),N 是列车数,M 是区段数,T 是顺延重试次数,对于几十列列车来说完全够用。
自动排图输出了各列车在每个区段的进出时刻,再用上面的字符图验证,一眼就能看出哪列列车在哪个会让站待避了多久。如果所有列车都只在自己应该在的位置等待,没有两列车同时占用任意区段的记录,这套排图就是可交付的状态。
从那以后,我每次跑模拟前都强制自己走一遍流程:先清空占用表,再检查方向锁,最后把事件队列的时间戳排序验证一遍。这套习惯救了我很多次,写调度模拟的人最容易自信过头,等把两列对开的列车放到同一条单线上的那一刻,所有你以为没问题的地方都可能翻车。希望帮到你。
本文还有配套的精品资源,点击获取