简介:这份数据结构期末作业资源聚焦银行排队系统的完整实现,面向正在学习队列、优先级调度与文件I/O的计算机专业学生,尤其适合需要完成课程设计或期末大作业的读者参考。压缩包共9个文件,约1.03MB,包含cpp源码、txt数据文件、cbp项目配置、depend依赖记录、layout布局文件以及编译生成的exe与o文件,覆盖从源码到可执行程序的完整链路。资源核心在于用两个队列分别管理VIP与普通用户,通过优先检查VIP队列实现优先级服务,并借助C++ STL的queue容器完成入队、出队与队头读取,同时演示了从文本文件读取用户到达时间与身份信息并解析为队列输入的过程。已有3362人学习下载,读者可据此理解多队列设计、控制流程逻辑与文件I/O操作,并对照项目结构快速复现和调试自己的银行排队模拟程序。
1. 银行排队系统:为什么它是数据结构课设里最值得死磕的一个
银行排队系统这个题目,几乎每届数据结构作业都会出现。很多人第一反应是“不就是个队列吗”,然后花两小时写完一个先进先出,交上去发现连及格线都够呛。问题出在:真实的银行排队根本不是单一队列。窗口有普通窗口和 VIP 窗口,客户分个人业务和对公业务,柜员有忙有闲,客户还会因为等太久直接走人。这些细节背后对应的是队列、优先队列、链表、时间模拟四套数据结构的组合运用。
这个题目的价值在于,它逼着你把“抽象数据类型”和“真实业务约束”对上号。做透了,你能掌握离散事件模拟的基本套路,这套思路在流量调度、任务编排、订单撮合里是通用的。做不透,就只是一个把数字从队尾搬到队头的练习。这篇文章面向正在做这个作业的学生,也面向想用一个小项目把队列和模拟串起来的人。下面从建模、选结构、写代码到排错,一步步拆开讲。
2. 先把业务模型定死:三类客户、两种窗口、一个时钟
2.1 离散事件模拟到底在模拟什么
银行排队系统的本质是一个离散事件模拟。时间不是连续流动的,而是被“事件”切成一段一段:客户到达是一个事件,客户开始办理是一个事件,客户办理完成离开是一个事件。你不需要每毫秒去更新状态,只需要维护一个按时间排序的事件序列,每次取出最早的事件处理,然后推进时钟。
这个思路的好处是效率极高。假设一天有 500 个客户,你只需要处理大约 1500 个事件,而不是模拟 28800 秒的每一秒。常见做法是用一个优先队列(最小堆)来存事件,按事件时间排序。每次弹出堆顶,把时钟拨到那个时刻,处理完再生成后续事件塞回去。
这里有一个容易翻车的地方:事件时间必须严格单调递增。如果生成后续事件时算错了时间,比如服务完成时间早于开始时间,整个模拟就会乱套。我一般会在事件结构体里加一个自增序号作为第二关键字,时间相同时按序号排,避免堆里出现不确定顺序。
2.2 客户类型和窗口类型怎么映射到数据结构
先定义清楚业务规则。假设银行有两类窗口:普通窗口只服务个人客户,综合窗口可以服务个人客户和对公客户。客户到达时按概率分布随机决定类型。个人客户占多数,对公客户占少数但办理时间长。
客户实体需要这些字段:到达时间、类型、业务耗时、优先级。窗口实体需要:窗口编号、类型、当前服务的客户、空闲时间点。队列方面,普通窗口前挂一个普通队列,综合窗口前挂两个队列——一个个人队列一个对公队列,综合窗口空闲时优先取对公队列(因为对公客户少但耗时长,积压了会影响满意度)。
VIP 客户的处理有两种常见方案。方案一是单独开 VIP 窗口,方案二是 VIP 客户插入普通队列头部。方案一更简单,方案二更贴近真实但实现时要小心:如果 VIP 客户不断到达,普通客户可能永远排不到,这就是优先级反转。我一般建议作业里用方案一,逻辑清晰,也好向老师解释。
2.3 用参数表把随机性管起来
模拟离不开随机数,但随机数不能散落在代码各处。把所有概率和分布参数集中到一张配置表里,改参数不用翻代码。
| 参数名 | 含义 | 建议取值 | 备注 |
|---|---|---|---|
| arrive_rate | 客户到达速率(人/分钟) | 0.5~2.0 | 泊松分布均值 |
| vip_ratio | VIP 客户占比 | 0.1~0.2 | 均匀分布判定 |
| corp_ratio | 对公客户占比 | 0.15~0.3 | 在非 VIP 中判定 |
| personal_service | 个人业务耗时均值(分钟) | 3~6 | 指数分布 |
| corp_service | 对公业务耗时均值(分钟) | 8~15 | 指数分布 |
| window_count | 普通窗口数 | 2~4 | 整数 |
| vip_window_count | VIP 窗口数 | 1 | 整数 |
| sim_duration | 模拟总时长(分钟) | 480 | 一个工作日 |
这张表的意义在于:你可以用不同参数组合跑多组实验,观察平均等待时间怎么变化。作业里如果能附上“窗口数从 2 增加到 4,平均等待时间下降 60%”这样的结论,分数会好看很多。
3. 队列选型:数组队列、链表队列和优先队列各管什么
3.1 普通队列用循环数组还是链表
普通窗口前的客户队列,操作只有入队和出队,先进先出。用循环数组实现的好处是内存连续、缓存友好,缺点是容量固定,需要预估最大排队人数。用链表实现的好处是容量无上限,缺点是指针跳转和内存分配开销。
我的建议是:如果模拟时长固定、到达率已知,用循环数组,容量设为“最大可能排队人数 + 余量”。计算方法是:到达率 × 模拟时长 × 1.5。比如到达率 2 人/分钟、模拟 480 分钟,最多 960 人到达,容量设 1500 足够。如果不想算,直接用链表,省心。
循环数组队列的关键是判空和判满。用 front 和 rear 两个下标,front 指向队头元素,rear 指向队尾元素的下一个位置。判空条件是 front == rear,判满条件是 (rear + 1) % capacity == front。注意这里牺牲了一个存储单元来区分空和满,这是常见做法。
// 循环数组队列定义 #define QUEUE_CAPACITY 1500 typedef struct { Customer data[QUEUE_CAPACITY]; int front; // 队头下标 int rear; // 队尾下标的下一个位置 } CircularQueue; // 初始化 void queue_init(CircularQueue *q) { q->front = 0; q->rear = 0; } // 判空 int queue_empty(CircularQueue *q) { return q->front == q->rear; } // 判满:牺牲一个单元 int queue_full(CircularQueue *q) { return (q->rear + 1) % QUEUE_CAPACITY == q->front; } // 入队 int queue_push(CircularQueue *q, Customer c) { if (queue_full(q)) return -1; // 队满,入队失败 q->data[q->rear] = c; q->rear = (q->rear + 1) % QUEUE_CAPACITY; return 0; } // 出队 int queue_pop(CircularQueue *q, Customer *c) { if (queue_empty(q)) return -1; // 队空,出队失败 *c = q->data[q->front]; q->front = (q->front + 1) % QUEUE_CAPACITY; return 0; }这段代码里,QUEUE_CAPACITY是容量,实际可用元素数是QUEUE_CAPACITY - 1。queue_push和queue_pop返回 -1 表示失败,调用方必须检查返回值。很多人的翻车点就在这里:入队不检查满没满,数组越界写坏了其他变量,程序跑着跑着结果就飘了。
3.2 优先队列(最小堆)怎么实现事件调度
事件调度需要每次取出时间最早的事件,这正是最小堆的拿手好戏。堆用数组存储,下标从 0 开始。父节点下标 i,左孩子 2i+1,右孩子 2i+2。插入时把新元素放到数组末尾然后上浮,删除堆顶时把末尾元素移到堆顶然后下沉。
// 事件结构体 typedef struct { double time; // 事件发生时间 int seq; // 同时间事件的序号,保证稳定排序 int type; // 0=到达 1=开始服务 2=服务完成 int customer_id; // 关联客户 int window_id; // 关联窗口 } Event; // 最小堆 typedef struct { Event data[MAX_EVENTS]; int size; } MinHeap; // 比较两个事件:时间小的优先,时间相同序号小的优先 int event_less(Event a, Event b) { if (a.time != b.time) return a.time < b.time; return a.seq < b.seq; } // 上浮 void heap_up(MinHeap *h, int i) { while (i > 0) { int parent = (i - 1) / 2; if (event_less(h->data[i], h->data[parent])) { Event tmp = h->data[i]; h->data[i] = h->data[parent]; h->data[parent] = tmp; i = parent; } else break; } } // 下沉 void heap_down(MinHeap *h, int i) { while (1) { int left = 2 * i + 1; int right = 2 * i + 2; int smallest = i; if (left < h->size && event_less(h->data[left], h->data[smallest])) smallest = left; if (right < h->size && event_less(h->data[right], h->data[smallest])) smallest = right; if (smallest == i) break; Event tmp = h->data[i]; h->data[i] = h->data[smallest]; h->data[smallest] = tmp; i = smallest; } } // 插入事件 void heap_push(MinHeap *h, Event e) { h->data[h->size] = e; heap_up(h, h->size); h->size++; } // 弹出堆顶 Event heap_pop(MinHeap *h) { Event top = h->data[0]; h->data[0] = h->data[h->size - 1]; h->size--; heap_down(h, 0); return top; }event_less是堆的核心比较函数。先比时间,时间相同比序号。序号在生成事件时全局自增,保证不会有两个事件完全相等。heap_push先放末尾再上浮,heap_pop先把末尾移到堆顶再下沉。这两个操作的时间复杂度都是 O(log n),n 是事件数。
3.3 窗口空闲列表用数组还是链表
窗口数量通常不多(几个到十几个),用数组存窗口状态就够了。每个窗口记录:编号、类型、是否空闲、当前客户 ID、预计空闲时间。当客户到达时,遍历窗口数组找空闲且类型匹配的窗口。如果找到,直接开始服务;如果没找到,客户入对应队列。
遍历数组是 O(n),但 n 很小,不值得优化。如果非要优化,可以维护一个空闲窗口的链表,但作业里没必要,反而增加出错概率。我见过有人用链表维护空闲窗口,结果删除节点时忘了改前驱指针,窗口凭空消失,模拟结果完全不对。
4. 核心调度逻辑:从客户到达 to 离开的完整链路
4.1 事件循环的骨架
整个模拟的主循环就是不断从堆里取事件、处理事件、生成新事件。伪代码如下:
void run_simulation(SimConfig *cfg) { MinHeap events; heap_init(&events); // 生成第一个到达事件 Event first_arrival = { .time = generate_arrival_time(cfg), .seq = next_seq++, .type = EVENT_ARRIVAL, .customer_id = next_customer_id++ }; heap_push(&events, first_arrival); double clock = 0.0; while (events.size > 0) { Event e = heap_pop(&events); clock = e.time; // 时钟推进到事件时刻 switch (e.type) { case EVENT_ARRIVAL: handle_arrival(&events, &clock, e, cfg); break; case EVENT_START_SERVICE: handle_start_service(&events, &clock, e, cfg); break; case EVENT_FINISH_SERVICE: handle_finish_service(&events, &clock, e, cfg); break; } } }clock变量记录当前模拟时间。每次弹出事件后,把 clock 设为事件时间。注意不要用 clock 去生成随机数种子,否则每次跑结果都一样。随机数种子应该在模拟开始前设一次。
handle_arrival负责:记录客户到达统计、判断客户类型、找空闲窗口或入队、生成下一个到达事件。handle_start_service负责:更新窗口状态、生成服务完成事件。handle_finish_service负责:释放窗口、从队列取下一个客户、如果队列非空则生成开始服务事件。
4.2 到达事件的处理细节
void handle_arrival(MinHeap *events, double *clock, Event e, SimConfig *cfg) { // 统计到达人数 stats.total_arrived++; // 判断客户类型 Customer c; c.id = e.customer_id; c.arrive_time = e.time; c.type = determine_customer_type(cfg); // 个人/对公/VIP // 找空闲且类型匹配的窗口 int wid = find_idle_window(c.type); if (wid >= 0) { // 窗口空闲,直接开始服务 Event start = { .time = *clock, .seq = next_seq++, .type = EVENT_START_SERVICE, .customer_id = c.id, .window_id = wid }; heap_push(events, start); } else { // 没有空闲窗口,入队 enqueue_customer(c); stats.total_queued++; } // 生成下一个到达事件 if (*clock < cfg->sim_duration) { Event next = { .time = *clock + generate_interarrival(cfg), .seq = next_seq++, .type = EVENT_ARRIVAL, .customer_id = next_customer_id++ }; if (next.time < cfg->sim_duration) { heap_push(events, next); } } }determine_customer_type用随机数决定客户类型。find_idle_window遍历窗口数组,返回第一个空闲且类型匹配的窗口下标,找不到返回 -1。enqueue_customer根据客户类型把客户放入对应队列。
注意next.time < cfg->sim_duration这个判断:如果下一个到达时间超过了模拟结束时间,就不再生成到达事件。但已经入队的客户还要继续服务完,所以主循环不能因为到达事件没了就退出,要等所有事件处理完。
4.3 服务完成与队列出队
void handle_finish_service(MinHeap *events, double *clock, Event e, SimConfig *cfg) { int wid = e.window_id; Window *w = &windows[wid]; // 记录客户离开时间和等待时间 Customer *c = &customers[e.customer_id]; c->leave_time = *clock; c->wait_time = c->service_start_time - c->arrive_time; stats.total_wait_time += c->wait_time; stats.total_served++; // 释放窗口 w->idle = 1; w->current_customer = -1; // 从队列取下一个客户 Customer next_c; if (dequeue_customer(w->type, &next_c)) { // 队列非空,立即开始服务 Event start = { .time = *clock, .seq = next_seq++, .type = EVENT_START_SERVICE, .customer_id = next_c.id, .window_id = wid }; heap_push(events, start); } }dequeue_customer根据窗口类型从对应队列取客户。综合窗口优先取对公队列,对公队列空了再取个人队列。这个优先级顺序会影响对公客户的平均等待时间,可以在实验里对比两种策略。
wait_time的计算是service_start_time - arrive_time,不是leave_time - arrive_time。后者包含了服务时间,不是真正的等待时间。这个细节很多人搞混,导致统计结果偏大。
5. 避坑与排查:模拟结果不对时先查这五个地方
5.1 平均等待时间是负数
现象:跑完模拟,统计输出里平均等待时间是负数。原因:service_start_time在客户入队时没有正确设置,或者事件处理顺序错了,先处理了服务完成再处理开始服务。解决:在handle_start_service里设置c->service_start_time = *clock,确保这个赋值发生在生成服务完成事件之前。另外检查堆的比较函数,确保时间早的事件先弹出。
5.2 队列长度只增不减
现象:模拟跑了一段时间后,队列越来越长,窗口却有空闲。原因:find_idle_window的类型匹配逻辑写错了,比如把个人客户匹配到了对公窗口,或者窗口释放后idle标志没置回 1。解决:在handle_finish_service里确认w->idle = 1被执行了。再检查find_idle_window里的类型判断,个人客户只能匹配普通窗口和综合窗口,对公客户只能匹配综合窗口。
5.3 模拟时间不推进,死循环
现象:程序卡住不输出结果,CPU 占用高。原因:事件时间没有递增,堆里不断弹出同一个时间的事件,或者生成新事件时时间算错导致新事件时间等于当前时间。解决:在生成下一个到达事件时,generate_interarrival必须返回正数。如果用了指数分布,要确保随机数不为 0。另外检查heap_pop之后size有没有减 1,忘了减会导致堆顶永远是同一个元素。
5.4 随机数每次跑结果一样
现象:多次运行程序,输出结果完全相同。原因:随机数种子固定,或者根本没设种子。解决:在main函数开头调用srand(time(NULL))。如果是在线评测系统,time(NULL) 可能返回相同值,可以用srand(time(NULL) + getpid())。但注意,如果作业要求结果可复现,那就固定种子,这是另一回事。
5.5 统计数据和手工计算对不上
现象:模拟输出的服务人数和到达人数不一致,或者平均等待时间和手算差很多。原因:统计变量在错误的地方累加,比如客户入队时加了等待时间,出队时又加了一次。解决:把统计逻辑集中到一个函数里,每次客户离开时统一更新。另外注意模拟结束时队列里还有客户没服务完,这些客户的等待时间不能算进平均值,否则会偏大。可以在最后输出“队列剩余人数”作为参考。
6. 让作业加分:多组实验对比与可视化输出
6.1 用参数扫描找出窗口数的最优解
作业如果只跑一组参数,结论很单薄。我一般会做参数扫描:固定到达率和服务时间,把普通窗口数从 2 增加到 6,每组跑 10 次取平均,记录平均等待时间和窗口利用率。窗口利用率 = 总服务时间 / (窗口数 × 模拟时长)。理想情况下,利用率在 70% 到 85% 之间比较健康,太低浪费人力,太高客户等太久。
// 参数扫描示例 for (int wc = 2; wc <= 6; wc++) { double total_wait = 0.0; double total_util = 0.0; for (int trial = 0; trial < 10; trial++) { SimConfig cfg = default_config(); cfg.window_count = wc; SimResult r = run_simulation(&cfg); total_wait += r.avg_wait_time; total_util += r.window_utilization; } printf("窗口数=%d, 平均等待=%.2f 分钟, 利用率=%.1f%%\n", wc, total_wait / 10, total_util / 10 * 100); }这段代码跑完会输出一张表,直接放进作业报告里就是现成的实验数据。注意每次 trial 要重新初始化随机数种子,否则 10 次结果一样。如果要求可复现,就固定种子但改变窗口数。
6.2 输出时间线日志方便排查
模拟过程中把关键事件打到日志文件里,格式为“时间 事件类型 客户ID 窗口ID”。出问题时打开日志一看就知道哪个环节断了。日志不用太详细,到达、开始服务、服务完成三类事件足够。可以在编译时用宏控制日志开关,交作业时关掉,调试时打开。
#ifdef DEBUG_LOG fprintf(logfile, "%.2f ARRIVE cust=%d type=%d\n", *clock, c.id, c.type); #endif这个技巧在排查“为什么这个客户等了 100 分钟”这类问题时特别有用。顺着日志找到客户的到达时间,再看窗口的空闲时间,很快就能定位是窗口不够还是调度逻辑有问题。
6.3 用 Python 画等待时间分布图
C 语言负责模拟,输出 CSV 文件,每行一个客户的等待时间。然后用 Python 的 matplotlib 画直方图。这样作业报告里既有代码又有图,说服力强很多。
import matplotlib.pyplot as plt import csv wait_times = [] with open('wait_times.csv', 'r') as f: reader = csv.reader(f) for row in reader: wait_times.append(float(row[0])) plt.hist(wait_times, bins=30, edgecolor='black') plt.xlabel('Waiting Time (minutes)') plt.ylabel('Number of Customers') plt.title('Distribution of Customer Waiting Time') plt.savefig('wait_dist.png', dpi=150) plt.show()这段 Python 代码读 CSV 画直方图。如果等待时间分布明显右偏,说明有少数客户等了很久,可以考虑增加窗口或调整队列策略。如果分布接近对称,说明系统比较均衡。
6.4 一个我踩过的坑:浮点数比较
模拟时间用 double 存储,比较两个时间是否相等时不能用==。比如判断“客户到达时间是否等于窗口空闲时间”,应该用fabs(a - b) < 1e-9。我当初没注意这个,导致某些客户明明窗口空闲却被判定为需要排队,平均等待时间凭空多了好几分钟。后来把所有时间比较都改成带容差的函数,问题消失。这个坑在 C 语言里很常见,写模拟程序时尤其要注意。
希望帮到你。
本文还有配套的精品资源,点击获取