简介:这是一份以电梯模拟为课题的数据结构课程设计/毕业设计报告,适合计算机相关专业学生用于课程设计参考、算法设计练习与答辩准备。文档基于经典《数据结构(C语言版)》教材,完整覆盖问题分析、系统分析、概要设计、详细设计到运行测试等环节,将电梯调度抽象为多个队列与栈结构,展示了链队列和乘客栈在模拟系统中的应用思路。资源包内仅包含1个doc格式文档,整体约523KB,内容紧凑但结构完整,便于直接阅读和打印提交。该报告已被139人浏览学习,可作为独立完成课程设计、撰写设计报告的参照模板。通过研读其中的需求分析、数据结构定义与算法流程,可帮助巩固数据结构知识,提升独立分析问题和编码调试的能力。
1. 数据结构电梯模拟:别把它当成一道画图题
毕业设计里的“数据结构电梯模拟”,核心不是画一台会动的电梯,而是用数据结构与算法回答一个具体问题:面对不同楼层、不同方向的乘客请求,电梯怎么决策才公平又高效。这个题目在数据结构C语言版的课程设计、考研408复习里都是熟面孔,因为它能把队列、栈、位图、状态机串成一条完整的仿真链路。你要交付的不是动画,而是一套调度内核:参数可调、结果可复现、指标可对比,最后能支撑一份数据扎实的实验报告。适合正在做课程设计或毕设的学生,也适合拿它练离散事件模拟手感的工程师。理解这一点,后面所有设计都不会跑偏。
2. 拆解一个电梯模拟系统:数据结构选型与核心结构体
2.1 电梯模拟是在模拟什么:三张表和一个状态机
电梯模拟去掉界面动效之后,剩下的是一个离散事件系统。常见做法是把系统拆成“楼层外呼表、轿厢内呼表、电梯本体”三个对象。楼层外呼表记录每一层有没有人按上行、下行;轿厢内呼表记录电梯里乘客按下的目标楼层;电梯本体则是一个有限状态机,在待机、运行、开门三个状态之间迁移。
这三张表对应到数据结构上非常直接。外呼和内呼都是“动态增删的目标集合”,核心操作是插入、删除、查询最近目标;电梯本体则要求方向与状态不能互相矛盾。搭建这个目标模型,是整份毕业设计的地基。地基没选好,后面的调度算法写得再漂亮也跑不出可靠数据。
状态机里,IDLE 与 RUN 之间的迁移只由方向刷新函数触发,DOOR 与 RUN 之间由到站事件和关门事件触发。有人会把开门也建模成多个状态,比如开门中、等待、关门中。对毕设来说,拆成单个 DOOR 周期加一个门计时器就够了,粒度太细反而容易在门状态里插入调度逻辑,埋下后面讲的时序 bug。模型的粒度要和验证目标匹配:你要研究的是调度策略,不是门机的电气时序。
2.2 用位图还是链表组织楼层请求:选型理由与 C 语言落地
外呼请求的数据组织方式,我一般先排除链表。链表插入和删除灵活,但查找“上方最近的目标楼层”要遍历所有节点,而且删除一个目标要先找到它,遍历成本在频繁到达的请求流里会被放大。《大话数据结构》里讲线性表时反复用链表举例,但电梯这个场景里链表不是最优解。用优先队列可以快速取最近目标,可一旦要“取消某个楼层的外呼”,比如电梯已经停靠服务完,优先队列只有懒惰删除,逻辑绕,容易留 bug。
位图是和电梯调度最契合的一组。每一层占一个 bit,位 1 表示“这个楼层有请求”。查找最近目标时从当前楼层沿方向逐位扫描,循环层数就是楼层数,常数小。而且楼层数在 12 层以内时,一个 uint64_t 就装下全部目标,更新和检查都只要一次与或运算。三种结构对比如下。
| 数据结构 | 插入 | 删除指定目标 | 找最近目标 | 实际踩坑 |
|---|---|---|---|---|
| 链表 | O(1) | O(n) | O(n) | 删除要遍历,方向扫描要重写 |
| 优先队列 | O(logn) | 懒惰删除或标记,麻烦 | O(logn) | “取消目标”的状态管理复杂 |
| 位图 | O(1) | O(1) | 至多 O(楼层数) | 楼层超过位宽要分段 |
所以这套系统的数据组织我推荐位图。选型理由将来可以直接写进数据结构实验报告的复杂度分析一节,逐项对比 O(1) 和 O(n),比空谈“我用了队列”扎实得多。
2.3 核心结构体定义与初始化:可以直接抄的 C 代码
下面这组结构体是整套模拟的地基,可以直接建一个新文件存起来:
#define MAX_F 12 // 楼层数,改成 20 也够,但别超过 63 #define MAX_PEOPLE 13 // 轿厢核载,测试满载时可临时改成 2 #define FLOOR_TIME 5 // 单层运行步数,一个步长代表 0.5 秒 #define DOOR_TIME 20 // 开关门周期,单位是步 typedef enum { IDLE = 0, RUN = 1, DOOR = 2 } ElevState; typedef enum { DIR_UP = 1, DIR_DOWN = -1, DIR_STOP = 0 } Dir; typedef struct { int cur_floor; // 当前所在楼层,1 到 MAX_F Dir dir; // 当前方向 ElevState status; // 待机 / 运行 / 开门 int people; // 轿厢人数,用于满载逻辑 uint64_t stops; // 内呼位图,bit(k) = 1 表示目标楼层 k+1 } Elevator; typedef struct { uint64_t up_bits; // 各楼层上行外呼位图 uint64_t down_bits; // 各楼层下行外呼位图 } CallBoard; typedef struct { int floor; // 外呼楼层 int dir; // 1 上行,0 下行 int arrive_step; // 请求生成时的仿真步数 int serve_step; // 乘客被接上时的仿真步数,0 表示未服务 } CallRec;这里有两个关键约定。楼层从 1 开始编号,位图第 0 位对应 1 层,所以1ULL << (floor - 1)直接做映射;uint64_t最大覆盖 63 层,普通课程设计完全够用。如果题目硬性要求 64 层以上,就把位图改成两个uint64_t的数组,查找循环从段内扫描改成分段跨扫描,这是少数需要动结构的地方。CallRec是给统计模块用的,记录每个外呼的生成时间和服务时间,最后算平均等待全看它。
初始化函数长这样,指针传进来一次清干净:
void init_system(Elevator *e, CallBoard *cb) { e->cur_floor = 1; e->dir = DIR_STOP; e->status = IDLE; e->people = 0; e->stops = 0; cb->up_bits = 0; cb->down_bits = 0; }这段初始化有个值得注意的约定:电梯初始停在 1 层、方向 DIR_STOP。很多实现喜欢把初始方向设成 DIR_UP,结果模拟一开始电梯自动升到顶层再回来,白跑一趟,第一批乘客的等待时间也被抬高了。初始方向应该由第一个外呼的方向决定,而不是拍脑袋。这也是后面调度函数要在 IDLE 状态下先找第一个目标的原因。
3. 调度算法怎么选:FCFS、SSTF 与 LOOK 的参数与实现
调度算法是这份毕业设计真正出分的地方。数据结构课里的队列、排序、栈都在这里体现得淋漓尽致,而电梯和操作系统里的磁盘寻道是同一套数学模型:都在一个一维地址空间里响应离散请求,都要在效率与公平之间做取舍。算法选型可以先想清楚指标,再落到代码。
3.1 三种调度策略的定性对比:公平、效率与饥饿
先列一张对比表,表里的结论是代表性结果,不是绝对定论。
| 策略 | 核心思想 | 平均等待 | 最坏等待 | 数据结构 | 典型问题 |
|---|---|---|---|---|---|
| FCFS | 按到达顺序逐一响应 | 中 | 长 | 队列 | 电梯在低层和高层之间来回空跑 |
| SSTF | 每次响应最近请求 | 较好 | 无上界 | 优先队列或链表 | 饥饿,远端请求长时间不被服务 |
| SCAN/LOOK | 沿方向扫描,到头返回 | 好 | 有上界 | 位图加方向状态 | 参数不配合会空跑 |
FCFS 最容易实现,一个先进先出队列就能跑,但它完全不顾电梯当前的位置。请求一多,电梯就在两端之间来回摆动,平均等待看着还行,最坏等待非常难看。SSTF 的思路是贪心,每次去最近的目标,平均等待确实好,但贪心带来饥饿:只要高层持续有新请求,低层请求永远等不到头,这在答辩时会被一眼问穿。
SCAN 也叫电梯算法,先运动到边界再返回,顺路处理请求。LOOK 是它的改进版:方向不变,但当前方向没有请求时立即转向,不空跑到边界。我推荐课程设计和毕设都做 LOOK 变体,理由有三个:实现不比 SCAN 复杂;统计指标比 SCAN 好看;答辩时你能讲清楚 SCAN 与 LOOK 的差异,这在《数据结构与算法分析》那类教材里是标准考点,讲出来是加分项。
3.2 LOOK 算法的 C 语言实现:方向位图扫描与转向判断
LOOK 的决策函数就两件事:沿当前方向找下一站;找不到才转向。先写找下一站:
int find_next_stop(CallBoard *cb, Elevator *e) { if (e->dir == DIR_STOP) return 0; // 待机状态不搜索 int step = (e->dir == DIR_UP) ? 1 : -1; for (int f = e->cur_floor + step; f >= 1 && f <= MAX_F; f += step) { if (should_stop(cb, e, f)) return f; // 找到第一个该停的楼层 } return 0; // 当前方向上没有目标 }这里的核心是should_stop,它决定“顺路”的语义。
int should_stop(CallBoard *cb, Elevator *e, int floor) { uint64_t bit = 1ULL << (floor - 1); if (e->stops & bit) return 1; // 有内呼必停 if (e->people >= MAX_PEOPLE) return 0; // 满载只认内呼不认外呼 if (e->dir == DIR_UP && (cb->up_bits & bit)) return 1; if (e->dir == DIR_DOWN && (cb->down_bits & bit)) return 1; return 0; }should_stop是整段代码里最容易漏条件的地方。第一行处理内呼,第二行处理满载,第三第四行处理同向外呼。注意满载判断必须先返回 0,否则电梯会在根本进不了人的楼层不断开关门,平均等待直接崩坏。方向判断里不允许停反方向的请求,否则电梯会被同一层的对向请求拽住,造成经典的“来回开门”现象。
转向逻辑单独放一个函数,门周期结束后调用:
void plan_next(CallBoard *cb, Elevator *e) { int next = find_next_stop(cb, e); if (next == 0 && e->dir != DIR_STOP) { e->dir = (e->dir == DIR_UP) ? DIR_DOWN : DIR_UP; next = find_next_stop(cb, e); // LOOK:反方向再找一次 } if (next == 0) { e->dir = DIR_STOP; // 全局空闲,进入待机 e->status = IDLE; } else { e->status = RUN; // 继续跑或掉头跑 } }这段实现有个容易被忽略的细节:转向后立即再找一次目标。LOOK 不是 SCAN,不需要跑到楼层边界;如果反向也没有目标,就置 DIR_STOP。同时要确认find_next_stop不会把当前楼层当作目标,起始值是cur_floor + step,所以停在 6 层时不会自己服务自己,避免刚关门又判定“本层有请求”的循环。
3.3 必调参数表:单层耗时、开关门、限载与到达率
调度逻辑跑通后,参数标定决定模拟数据可信不可信。我常用的参数表如下:
| 参数宏 | 含义 | 建议值 | 调参说明 |
|---|---|---|---|
| MAX_F | 楼层总数 | 12 | 不要超过位图位宽,改大要换分段位图 |
| MAX_PEOPLE | 核载人数 | 13 | 测试满载时要临时调小到 2~3 |
| FLOOR_TIME | 单层运行步数 | 5 | 每步 0.5 秒则单层 2.5 秒;想模拟加减速就在每程头尾各加 2 步 |
| DOOR_TIME | 开关门周期 | 20 | 约 10 秒,偏保守;演示时可调到 8 |
| 到达率 | 每步新请求概率 | 0.02~0.05 | 太小看不出调度差异,太大会持续满载 |
这里重点说两个容易被忽略的点。第一,FLOOR_TIME 如果设成 1,电梯每秒过一层,开关门 20 步就显得特别长,调度算法会退化成“谁离门近谁被接”,对比实验基本失效;建议保持 4~6,让运行时间在总耗时里占主导。第二,到达率 0.02 在 10000 步仿真里会产生约 200 个请求,正好够算统计平均值;如果调到 0.1,系统每 10 步就来一个请求,电梯一直满载,这时只能看到满载下的性能,不是算法本身的差异。
4. 仿真时钟与数据驱动:让模拟可复现、可测量
调度内核写好后,很多人会急着画界面。我的习惯相反:先做可复现的数据驱动内核,界面只是内核的显示器。这样调算法时不用盯着动画猜,看几行统计输出就能定位问题。
4.1 时间片步进还是事件驱动:两条路线怎么选
仿真时钟有两种推进方式。时间片步进法每过一个固定步长就刷新所有对象的状态,简单直观,和界面刷新天然同步,适合 12 层、2 部电梯以内的小规模模拟。事件驱动法则只在事件发生时跳转时钟,效率高,但需要维护一个事件优先队列,适合大规模请求或纯理论分析。课程设计和本科毕设,我一般建议时间片步进,因为答辩要演示动画,步进法能保证界面和逻辑同一节奏。
事件驱动也有它的位置。如果题目要求对比几万条请求下的调度效率,步进法每步都要扫描所有电梯,大量时间花在“没有事件发生的空步”上;这时候把乘客到达、门开关完成、电梯到站都放进优先队列,时钟直接跳到下一个事件,跑一组对比实验能快一个数量级。两条路线都能做,但别混着用:既按步进扫状态又插入事件,会同时继承两者的时序坑。
4.2 仿真主循环与统计指标:最小可跑 C 主循环
下面这个主循环是时间片步进的骨架,放在 2.3 的结构体后面就是最小可运行版本:
#define SIM_STEPS 20000 // 仿真总步数,每步 0.5 秒 #define ARRIVE_P 0.03 // 每步生成一个新请求的概率 int main(void) { Elevator e; CallBoard cb; init_system(&e, &cb); srand(42); // 固定随机种子,否则结果不可复现 for (int step = 1; step <= SIM_STEPS; step++) { // 1) 随机生成外呼请求,登记到位图和统计数组 if (rand() < (int)(ARRIVE_P * RAND_MAX)) { int floor = rand() % MAX_F + 1; int dir = (floor == 1) ? 1 : (floor == MAX_F) ? 0 : rand() % 2; if (dir) cb.up_bits |= 1ULL << (floor - 1); else cb.down_bits |= 1ULL << (floor - 1); record_call(floor, dir, step); // 写入 CallRec } // 2) 电梯运行中,按 FLOOR_TIME 步进到下一层 if (e.status == RUN) { if (runtime_clock++ >= FLOOR_TIME) { runtime_clock = 0; e.cur_floor += (e.dir == DIR_UP) ? 1 : -1; if (should_stop(&cb, &e, e.cur_floor)) { e.status = DOOR; door_clock = DOOR_TIME; mark_served(e.cur_floor, step); // 结算等待时间 } } } // 3) 开门计时,结束后清理位图并刷新方向 if (e.status == DOOR) { if (--door_clock <= 0) { clear_arrive(&cb, &e); // 清本层内呼和双向外呼 plan_next(&cb, &e); // 转向判断,进入 RUN 或 IDLE } } } print_stats(); // 平均等待、最长等待、满载率 return 0; }主循环按“生成请求、推进运行、处理门状态”三段组织,顺序不能调换。先生成请求再推进电梯,保证“本步新到的请求不会被本步电梯响应”,这符合现实里按钮按下和电梯到站是同一瞬间的并发关系;如果先推进电梯再生成请求,同一时刻的请求会被推迟一个步长,统计结果整体偏移。边界楼层的方向做了截断:1 层只有上行,顶层只有下行,对应真实电梯按钮的设计。
统计指标主推三个:平均等待时间、最长等待时间、满载率。等待时间在mark_served里计算(serve_step - arrive_step) * 0.5,单位是秒;满载率在开门时累计people >= MAX_PEOPLE的次数除以总开门次数。这三个指标足够支撑实验报告:平均等待看整体效率,最长等待看公平性,满载率看系统容量。只报平均值的话,答辩老师基本会追问“最坏情况是多少”。
4.3 输出 CSV 而不是人眼 log:一张表撑起实验报告正文
如果你手头那份题目文档是 .doc 格式的毕业设计模板,别被章节结构吓住。它要求的通常就是问题定义、数据结构设计、算法描述、参数标定、测试对比这几节,而 CSV 汇总表正好一张一张往里贴。很多人喜欢在控制台打一堆 “UP! DOWN! OPEN!” 日志,看着热闹,真到写实验报告时无从下手。我建议内核只输出两种东西:每 500 步一行采样,结束时一行汇总。采样用来画等待时间随负载变化的曲线,汇总用来做策略对比表。格式用 CSV,导入表格软件直接用。
抽样输出长这样:
// 每 500 步打一行,后面跟 4 个 CSV 字段 if (step % 500 == 0) { printf("%d,%.2f,%.2f,%.2f\n", step, avg_wait_sec(), max_wait_sec(), load_factor()); }avg_wait_sec和max_wait_sec统计当前已完成服务的外呼,load_factor统计当前轿厢人数与限载的比值。答辩时的实验报告不用贴几十页日志,只需要导出一张 5 行对比表:同样 200 个请求、同样边界条件下,FCFS、SSTF、LOOK 的平均等待和最长等待分别是多少,再附一条等待时间随到达率变化的曲线,这就是完整的数据支撑。
5. 电梯模拟避坑实录:5 个让模型翻车的隐蔽问题
下面这五条是我在类似调度场景里反复踩过的坑,每一条都按“现象、原因、解决”讲清楚,做的时候对照检查能省下大量排错时间。
5.1 电梯刚关门就响应新外呼:门状态被跳过,事件在排队
现象:门动画还没播完,电梯已经重新启动;或者恰好关门那一帧又来一个同方向请求,电梯立刻再次开门,乘客在短时间里进出两趟。原因:主循环在 DOOR 状态里也调用了plan_next,或者door_clock减到 0 前一帧就被当作门已结束,门的真实语义被压缩成一个瞬间。解决:门状态必须单独占一个完整阶段,只有door_clock递减到 0 的那一帧才能调用plan_next;同时clear_arrive要在门结束的那一刻执行,把开门期间累计到位的请求统一清理。这个时序改成“开门期间可累计请求、关门瞬间统一清理”后,同层反向请求也能一次合并,不会再出现刚关门又开门的翻车场面。
5.2 同层对向请求等到超时:位图清理的时机错了
现象:6 层有人按上行、有人按下行。电梯下行到 6 层开门下人,关上门走了,上行那位一直等到超时;过了很久电梯又下行经过 6 层,还是不停。原因:一种实现是到站后只清当前方向的位,反方向请求永远留给反方向电梯,但单梯模型里反方向请求要等电梯转向后才能响应,转向如果发生在其它楼层,本层请求就被悬空。另一种实现是到站后把该层所有外呼都清掉,结果把还没上车的反向请求误删了。解决:以“一次开门周期为原子操作”清理,开门期间本层两个方向的位都可以清,因为这一周期内的进出乘客已经完成;关门后到达的新请求才作为下一次目标。这个语义既不会误删,也不会漏接。
5.3 顶层底层重复停靠:边界层同时命中两段行程
现象:电梯到达顶层后,又立刻在顶层开门一次,日志里出现“15 层停、15 层停”的连续记录,底层也有类似情况。原因:边界楼层既是当前行程的终点,又是反向行程的起点。转向后如果find_next_stop的起始楼层写成了cur_floor而不是cur_floor + step,电梯会立刻认为自己又到了一个新目标。解决:find_next_stop的起始楼层必须跳过当前层;转向只改变方向,不清除当前层新累计的请求位。还有一个关联隐患是位图溢出:MAX_F超过 31 时,1 << (floor - 1)在 32 位 int 里直接越界,请求位被写进符号位甚至被清零,解决是全程使用uint64_t并在结构体注释里写明楼层上限。
5.4 随机数不固定,调参像玄学:结果不可复现
现象:参数一点没改,两次运行的平均等待差出两秒;答辩时演示一次通过,自己回去调参又复现不出来。原因:srand(time(NULL))让每次仿真的请求序列都不一样,统计波动被当成算法差异。解决:仿真程序默认固定种子,随机种子作为命令行参数传入,每一次实验都记录种子值和参数组合;把单次运行的随机性和策略对比的系统性差异分开。我习惯在测试时先跑固定种子定位 bug,确认没有逻辑问题,再换 5 个种子做统计平均。这个习惯能救回大量排错时间,是我在这类题目里吃的最大一堑,也是最好的后悔药。
5.5 满载期间层层停:外呼没分内呼优先级
现象:轿厢显示满载,但电梯还是在每个有外呼的楼层开门,楼层没人能上去,等待时间全花在无效开关门上。原因:should_stop里只判断了是否有外呼,没有判断轿厢是否还能上人。解决:满载时只响应内呼,外呼位图全部忽略。为了验证这个分支,测试阶段把MAX_PEOPLE临时设成 2,让满载频繁触发,再检查should_stop是否真的返回 0。这个分支平时很少触发,但一旦触发就是性能黑洞,而且界面动画看不出来,只能靠统计满载率曲线判断。
6. 验收技巧:固定剧本与统计断言,把电梯模拟调成可提交状态
随机请求适合做整体统计,但不适合定位 bug。我常用的验收手段是准备几个“请求剧本”:一个模拟早高峰上行潮汐,一个模拟下班下行潮汐,一个模拟随机交叉。每个剧本把请求逐行写进文件,程序启动时读文件而不是靠随机数生成。剧本格式一行一条请求,依次是楼层、方向、到达步数。
1 1 10 6 1 40 10 0 120 12 0 380 3 1 900运行时用命令行参数指定剧本和种子,跑完直接断言三项指标。比如早高峰剧本要求最大等待不超过 30 秒,满载率不超过 90%;断言失败就说明这个场景下调度策略有问题,而不是数据波动。固定剧本还有一个额外好处:对拍调试时,FCFS 和 LOOK 跑同一份剧本,差异一眼可见,比两边各跑 5000 个随机请求再比平均值直观得多。
编译和回归可以用一条命令收拢:
gcc -std=c11 -O2 -o elevator main.c stats.c ./elevator --scene=udp_peak.txt --seed=42 --algo=look --csv=out.csv--scene指定剧本文件,--seed固定随机种子,--algo切换 FCFS 或 LOOK,--csv输出统计结果。把这条命令写进工程的 check 脚本,答辩前每次改动都跑一遍回归。别把界面当主要调试入口,界面是给答辩老师看的,不是给你定位问题用的;数据驱动内核对调时才是真正能干活的状态。往后做多梯扩展时思路也别变:把外呼按方向和楼层分区,每部电梯维护各自的位图,用负载均衡定期重新分配区域,比所有电梯全局抢同一批请求可靠得多。
我做这个题目时走过的弯路正好相反:先把界面画得漂漂亮亮,再往里塞调度逻辑,结果算法跑起来只能用眼睛观察,平均等待到底几秒全靠估算。后来把内核抽成命令行程序,固定种子加剧本,两天就把平均等待从 9 秒压到 4 秒,实验报告的数据全部从 CSV 里直接取。这套流程后来被我用到所有仿真类项目里:先数据,后界面,最后才是文档。希望帮到你。
本文还有配套的精品资源,点击获取