news 2026/10/27 14:35:52

银行排队系统实验代码拆解:窗口数组、链式队列与逗留时间统计排错

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
银行排队系统实验代码拆解:窗口数组、链式队列与逗留时间统计排错

简介:银行排队系统实验报告面向数据结构课程学习者与C语言初学者,围绕“n个窗口、客户随机到达并选择队列”的银行场景,给出从需求分析到程序实现的完整实验文档。内容以队列结构为主线,串联客户到达、离开、业务查询、排队查询与系统查询等模块,并说明如何记录时间、计算客户平均逗留时间,以及VIP与普通客户的取号、验证和队列写入流程;主菜单用SWITCH组织,代码涉及stdio.h、malloc.h、time.h等头文件,便于读者理解如何把生活问题转化为算法并落地为可运行程序。压缩包共1个doc文件,约191KB,适合直接查阅实验要求、设计思路与源程序分析。该资源已有902人学习,可作为课程实验参考、课程设计底稿或C语言综合练习的对照材料,帮助梳理队列应用与模块化编程思路。

1. 三个窗口、一条队伍:银行排队系统真正要回答的是什么

三个窗口、一条队伍、一天的平均逗留时间。一份银行排队系统实验报告看起来只是一次课程作业,但它要回答的问题其实很硬:当服务资源固定时,不断到来的客人该被放进哪个容器,离开时又该怎么从容器里取出来。很多人第一反应是「队列呗」,可把源码翻到底会发现,队列只承担了一半职责:窗口是定长数组,等候区才是链式队列,两者之间靠一次「补位」动作衔接。搞懂这次补位发生在哪一行、按什么顺序执行,比背十遍先进先出定义有用得多。现实里的银行排队有叫号机、窗口屏、等候区座位三个独立物件,代码把它们压缩成两个结构体,压缩过程中丢掉的信息,恰好就是平均逗留时间算不准的根源。这篇就沿着「结构选型 → 事件处理 → 时间统计 → 排错」的顺序,把这份实验报告拆到能自己重写的程度。

2. 窗口数组与等候队列:一次到达事件里的两次判定

整个系统的骨架只有两个容器,理解它们的边界,后面所有函数都只是在这条边界上做增删。先看原始定义,这段代码保留了实验报告的写法,n是窗口数:

#define n 3 struct List { int A[n + 1]; // 下标 1..n 对应 n 个窗口,存正在办理的顾客编号 int len; // 当前占用窗口的顾客数,不是数组容量 } L; struct Lnode { int data; // 顾客编号 Lnode *next; }; struct Linkqueue { Lnode *front; // 哨兵头结点,不存业务数据 Lnode *rear; // 指向队尾结点 } Q;

A开成n+1而不是n,是一个刻意的下标偏移:窗口从 1 开始编号,A[0]空着不用。这个选择在后面的时间统计里会变成一个隐蔽的坑,先记住A[0]永远等于 0。

2.1 窗口为什么用数组,而不是也用链表

窗口是「数量在编译期就确定、且需要按编号直接定位」的资源。顾客问「3 号窗口在办谁」,程序必须能一次跳到位;柜台评分要按柜台号累加,也得按下标直接取。数组天然满足这两点,L.A[3]是 O(1)。换成链表,每次查询都要从头走三个结点,代码还多出一堆指针维护。

数组的代价是「空位不连续」。假设 1 号、3 号窗口有人,2 号空着,这时来了新顾客,L.len = 2 < n = 3,判定为「有空位」,但新顾客该放哪个下标?原始daoda的写法是:

void daoda(int x) { int i = L.len + 1; // 用占用量推下一个下标 if (L.len < n) { L.A[i] = x; // 直接写到 len+1 i++; L.len++; } else { Enqueue(&Q, x); // 窗口满,进等候队列 } }

这段逻辑只有在「窗口总是从左往右依次填满、离开后立刻补位」的前提下才成立。一旦出现「2 号窗口空着但len还是 2」的状态,L.A[3]就会被覆盖。常见的实现思路是加一层线性扫描找第一个A[i] == 0的下标,或者额外维护一个空闲窗口栈,把「找空位」从 O(n) 降到 O(1)。原始代码选了最省事的一种,代价是把不变量藏在了注释里而不是代码里。

2.2 等候区为什么必须是链表

等候人数是这套模型里唯一不可预知的量。银行开门后可能半小时没人,也可能一瞬间挤进二十个。用数组开等候区,开小了溢出,开大了浪费,而且「队列整体前移」这种操作在数组上是 O(n) 的搬移。

链式队列把每次入队变成一次malloc,出队变成一次free,单次操作 O(1),内存按需增长。代价是每个结点多一个指针的开销,以及缓存局部性变差——结点在堆上散落,遍历打印排队情况时 CPU 缓存命中率很低。对三五个人的实验规模无所谓,对真实叫号系统里几百人的队列,工程上常见做法是分块链表或者环形缓冲区,兼顾 O(1) 出队和连续内存。

2.3L.len < n这个比较,保护的其实是数组上界

这行判断容易被读成「还有没有空窗口」,它实际保护的是L.A[n+1]不越界。n = 3时,len最大只能是 3,此时i = 4,正好是数组最后一个合法下标。如果哪天有人把n改成 5 却忘了同步改A的大小,或者把判断写成<=,第一批发满的顾客就会写到数组外面去,症状是「程序不崩,但某个窗口的编号莫名其妙变成了别人的」。

注意:定长数组 + 长度计数器的组合,永远要问一句「长度计数器和数组容量是不是同一个语义」。这里的len是占用量,不是容量,两者混用是这类实验代码最常见的翻车点。

2.4 入队四行代码,少一行就断链

void Enqueue(Linkqueue *Q, int elem) { Lnode *s = (Lnode *)malloc(sizeof(Lnode)); s->data = elem; s->next = NULL; // ① 新结点必须自成链尾 Q->rear->next = s; // ② 旧尾结点指向新结点 Q->rear = s; // ③ 队尾指针后移 }

三行有效操作,顺序不能换:如果先执行 ③ 再执行 ②,rear已经指向s,Q->rear->next = s就变成了s->next = s,队列自环,遍历时死循环。① 如果漏掉,新结点的next是malloc返回的随机值,下一次入队会把这条野指针接进链里,症状是打印排队人数时突然跳出几万个编号,然后段错误。

这份实验报告里的入队没有做malloc返回值检查。堆耗尽时s是NULL,紧接着s->data = elem直接崩。教学代码可以省略,但如果要把这套结构搬进任何真实场景,这行检查必须补上。malloc失败在桌面环境很罕见,在嵌入式或者长时间运行的服务里是必然会遇到的事件。

3. 出队与补位:likai里藏着的状态一致性

到达是把人放进容器,离开是把人从容器里摘出来再补一个进去。补位这个动作把数组和队列耦合在一起,也是整套代码里状态最容易写乱的地方。

3.1 哨兵头结点:front为什么不存数据

初始化只有两行:

void Initqueue() { Q.front = Q.rear = (Lnode *)malloc(sizeof(Lnode)); Q.front->next = NULL; }

这里分配了一个不存业务数据的头结点,front永远指向它,真正的队首是Q.front->next。这么做的好处是「空队列」和「非空队列」的判断统一成Q.front == Q.rear,出队时也不需要单独处理「删掉最后一个结点后front变空」这种分支——因为front本身不参与删除。

代价是多一次malloc和每次遍历都要从front->next起步。print2就是这么写的:

void print2() { int i = 0; Lnode *s = Q.front->next; // 跳过哨兵 printf("正在等候办理业务的顾客编号为:"); while (s != NULL) { printf("%d ", s->data); s = s->next; i++; } printf("\n 您的前面一共有%d 人在排队,请您稍候!", i); }

如果漏掉->next直接从front开始,哨兵结点的data是未初始化的堆内存,打印出来的第一个编号是随机数,排队人数也会多算一个。这类 bug 不会崩,只会让结果看起来「怪怪的」,最难排查。

3.2 先清位、再补位、后减容量:顺序为什么不能反

void Dlqueue_wrapper(int x) { /* 省略,见下方 likai */ } void likai(int x) { int i = 0; do { if (x > L.len) { printf("输入有误!\n请重新输入:"); scanf("%d", &x); } else { for (i = 0; i <= L.len; i++) { if (i == x) { printf("尊敬的%d 号顾客您好!\n", x); L.A[i] = 0; // ① 清空窗口 L.len--; // ② 占用数减一 if (Q.front != Q.rear) { int y = Dlqueue(&Q); // ③ 从等候区取一个 L.A[i] = y; // ④ 补进刚空出的位置 L.len++; // ⑤ 占用数加回 } } } } } while (i == 0); }

这五步的顺序构成了一个临界区。如果先执行 ③ 出队再执行 ① 清位,中间没有任何间隙,单线程下没问题;但只要把 ② 和 ⑤ 之间插入任何会读取L.len的操作(比如打印、写日志),就会出现「顾客已经走了,但系统认为窗口还占着」的瞬间状态。更麻烦的是 ③ 和 ④ 之间如果malloc/free触发异常,顾客已经从队列里摘掉,却没进窗口数组,这个人就凭空消失了。

常见的实现思路是把「出队」和「写入窗口」做成一个原子函数,或者干脆让likai返回被摘掉的顾客编号,由调用方决定怎么补。原始代码选择了最直接的写法,代价是中断安全性为零——这在教学场景无所谓,在真实系统里就是丢单。

3.3 编号等于下标吗:一个被忽略的映射

daoda里i = L.len + 1,likai里用i == x匹配顾客编号。这两处默认了「顾客编号 = 窗口数组下标」。但顾客编号是全局递增的(v++),窗口只有 3 个,第 4 个顾客的编号是 4,他根本进不了A[4],只会在队列里。等他前面的顾客离开,Dlqueue返回 4,L.A[i] = 4写进去,此时i是刚空出的窗口下标(1 到 3),不是 4。

所以A里存的确实是「顾客编号」,但检索时用的是「窗口下标」。这两个值恰好在小规模数据下容易混淆,只要队列出现过一次「编号跳跃」,用户输入编号 4 去找人,likai里的for (i = 0; i <= L.len; i++)最多只走到 3,永远匹配不上,函数静默返回,什么都没发生。这就是下面第 5 章要专门讲的坑。

正确的做法是抽象一层find_window_by_customer(int id),把「哪个窗口在服务这个编号」的查找和「窗口下标」解耦,返回窗口下标后再操作A。

3.4 空队列判断与被吞掉的错误

int Dlqueue(Linkqueue *Q) { Lnode *t; int x; if (Q->front == Q->rear) { printf("队列为空!\n"); exit(1); // 直接终止整个进程 } else { t = Q->front->next; Q->front->next = t->next; x = t->data; free(t); return x; } }

出队本身写得没问题:取哨兵的后继、断开、取数据、释放。问题在错误处理。空队列时直接exit(1),这意味着只要调用方漏判一次空队,整个程序就没了,用户之前输入的所有数据一起丢。

更合理的做法是返回一个哨兵值(比如-1)或者用输出参数 + 返回码,把「队列空」这个业务状态交给调用方处理。likai里那句if (Q.front != Q.rear)就是调用方的防护,但防护和exit同时存在,说明作者自己也不确定哪一层该负责。这种「两层都防、但防的方式互相矛盾」的写法,在实验代码里非常常见。

另外注意t->next的赋值顺序:先让哨兵指向第二个结点,再free(t)。如果先free再访问t->next,就是释放后使用,典型的堆错误,在小数据量下可能不崩,跑久了必崩。

4. 时间统计:difftime为什么在这套代码里算不出平均逗留时间

实验要求里最关键的一句话是「计算一天中客户在银行逗留的平均时间」。翻遍全部源码,这个数字从头到尾都是 0。原因不是算法难,而是三处细节叠在一起。

4.1time_t与difftime的语义

标准库的时间模型是「时间点」而不是「时间段」:time(NULL)返回从某个纪元起点到现在的秒数,difftime(t1, t0)返回两个时间点之间的秒数差,返回类型是double。要用它算逗留时间,必须为每个顾客保存「到达时刻」,离开时用「离开时刻 - 到达时刻」。

原始代码里有一个time()函数,只做了一件事:

void time() { time_t timep; time(&timep); // 取名 time,与标准库函数同名 printf("现在时刻:%s", ctime(&timep)); }

这个函数名直接遮蔽了<time.h>里的time。在 C 里函数和变量共享同一个标识符命名空间,一旦有人在文件作用域定义time_t time;,后续所有time(&timep)调用都会编译失败。原始代码在time1()里确实写了time = difftime(end, start);,把一个函数名当左值赋值——这行在严格编译器下直接报错,在宽松编译器下是一个未定义行为的定时炸弹。

4.2start/end没有赋值,difftime读的是栈上的垃圾

time1() { time_t timep; time(&timep); time = difftime(end, start); // end、start 从未初始化 }

start和end既不是全局变量,也没有作为参数传入,在这个函数里它们是未声明的标识符。即使补上time_t start, end;的局部声明,两个变量也没有被time()赋值过,difftime拿到的是栈上的随机字节。结果可能是 0,可能是几万秒,每次运行都不一样。

正确的写法需要两个明确的时间点:

#include <time.h> time_t start, end; void mark_start(void) { start = time(NULL); // 记录银行开门时刻 } double elapsed_seconds(void) { end = time(NULL); return difftime(end, start); // 返回秒数,double 类型 }

参数说明:start存开门时刻,end存当前时刻,difftime的两个参数顺序不能反,反了得到负数。返回值单位是秒,要换算成分钟或小时得自己除。

4.3allsum += A[0]:一行把结果锁死在 0 的代码

真正致命的在这里:

case 2: { likai(x); mygrade(); w++; // 离开人数 +1 allsum += A[0]; // A[0] 永远是 0 break; }

A[0]从初始化Initshuzu()开始就被设成 0,daoda只写A[1]到A[n],likai只清A[i](i ≥ 1)。所以allsum恒等于 0,最后average = allsum / w也恒等于 0。系统查询界面打印出来的「平均用时 0.00」不是算得准,是根本没算。

这类 bug 的隐蔽之处在于它「看起来在工作」:界面能开、数字能出、格式还带两位小数。验收时如果只跑一遍流程不看数值,完全发现不了。

4.4 逗留时间应该记在哪:给每个顾客配一个到达时间戳

正确模型需要把「顾客」从裸int升级成结构:

typedef struct { int id; // 顾客编号 time_t arrive_at; // 到达时刻 int window; // 占用的窗口下标,0 表示在排队 } Customer;

入队时写入arrive_at,离开时用difftime(time(NULL), arrive_at)得到本人逗留时长,累加到total_stay,除以已离开人数得到平均。窗口数组和队列里存的不再是int,而是Customer或者指向Customer的指针。

这里有个取舍:存结构体会让每个队列结点从 8 字节涨到 24 字节左右(64 位环境下time_t8 字节 +int4 字节 + 对齐填充),换来的是时间信息跟着人走。存指针则要保持一个全局的顾客表,结点释放后指针悬空,得额外管理生命周期。教学规模下直接存结构最省心。

注意:时间统计和业务逻辑一定要在同一个数据流里。原始代码把「谁离开了」和「他待了多久」拆成两个互不通信的部分,这才是平均值恒为 0 的结构性原因,不是哪一行写错了。

5. 排查清单:这五处最容易在演示时出问题

这一章按「现象 → 原因 → 解决」记录五条踩坑经验,都是跑这份代码时会真实撞上的。

5.1 密码输入时程序卡死或直接退出

现象:进入 VIP 认证,提示输入五位密码,敲完回车没反应,或者敲到第三位程序突然结束。

原因:char ch[3];只开了 3 字节,却要求输 5 位;循环条件while (ch[k - 1] != ' ')在k = 0时读ch[-1],是数组前一个字节;终止条件等的是空格字符,回车键'\n'根本不会让循环停下来。三个问题叠加,行为完全不可预期。

解决:缓冲区按最大长度加一开,循环条件改成读一个判一个,用长度上限做硬保护:

char ch[8]; int k = 0; while (k < 5) { char c = getch(); if (c == '\r') break; // 回车结束 ch[k++] = c; printf("*"); } ch[k] = '\0'; // 补字符串结束符,便于后续比较

参数说明:k < 5是硬上界,'\r'是控制台回车的实际返回值(部分环境是'\n',稳妥做法是两者都判)。循环结束后必须补'\0',否则按字符串比较时会读到缓冲区外的内容。

5.2 柜台号能输 4 和 5,但系统只有 3 个窗口

现象:评分时提示「请输入 1-3」,输入 4 却被接受,评分被记进一个不存在的柜台。

原因:guitai()的校验写成if (y < 1 || y > 5),而窗口数n是 3。评分数组sum1到sum5开了五组,校验也就跟着放了 5,两边各自为政,没人跟n对齐。

解决:把窗口数抽成唯一的真值来源,校验和数组长度都从它推导:

#define MAX_WINDOW 3 int guitai(void) { int y = 0; do { printf("请输入柜台号(1-%d):", MAX_WINDOW); scanf("%d", &y); } while (y < 1 || y > MAX_WINDOW); return y; }

用do-while而不是「错了再问一次」,是因为后者只给一次重试机会,用户连续输错两次就带着非法值往下走了。

5.3 输入一个不存在的顾客编号,程序静默通过

现象:离开界面输入编号 9,系统没有任何提示,直接跳到评分环节,离开人数还加了 1。

原因:likai的for (i = 0; i <= L.len; i++)里,如果没有任何i == x,循环正常结束,i变成L.len + 1,do-while (i == 0)判定为假,函数返回。调用方不知道「没找到」,照样执行w++和allsum += A[0]。

解决:让likai返回是否成功,调用方根据返回值决定要不要继续:

int likai(int x) { for (int i = 1; i <= L.len; i++) { if (L.A[i] == x) { // 按顾客编号匹配,不是按下标 L.A[i] = 0; L.len--; if (Q.front != Q.rear) { L.A[i] = Dlqueue(&Q); L.len++; } return 1; // 成功 } } return 0; // 没找到 }

注意匹配条件从i == x改成了L.A[i] == x,这是第 3.3 节说的编号与下标混淆的修正。返回值语义化之后,统计代码只在成功时累加,人数和时长才不会虚高。

5.4 菜单选 5 退出后,后面的代码永远执行不到

现象:case 5里写了return; getch(); system("cls");,后面两句是死代码。

原因:return在main里直接结束进程,后续语句不可达。这类残留通常是复制粘贴case分支时忘了删。

解决:要么删掉不可达语句,要么把退出改成break加一个循环标志,让程序走正常清理路径(释放队列里剩余的结点)。教学代码不清理也无所谓,但如果要把这套结构当模板复用,退出前把Q里所有结点free掉是基本素养,否则任何内存检测工具都会报一片泄漏。

5.5 平均分打印成整数,或者除以 0 崩掉

现象:柜台满意度显示3,不是3.67;或者第一个顾客评分后程序直接崩溃。

原因:ave1 = sum1 / i1;里sum1和i1如果都声明成int,整除会截断。原始代码把sum和i声明成float侥幸避开了截断,但i1初值是 0,如果评分流程在i1++之前有任何一个分支能走到除法,就是除零。

解决:累加变量用double,除法前判分母,输出用%.2f:

double sum[MAX_WINDOW + 1] = {0}; int cnt[MAX_WINDOW + 1] = {0}; void add_score(int window, int score) { sum[window] += score; cnt[window]++; } double avg_score(int window) { if (cnt[window] == 0) return 0.0; // 没人评过,返回 0 而不是崩 return sum[window] / cnt[window]; }

6. 从手工触发到离散事件仿真:这套实验代码的设计取舍

实验代码最本质的简化,是把「时间」交给了用户按键。真实银行里顾客到达是随机的,服务时长也是随机的,程序应该自己推进时间。这份报告用菜单驱动,本质上是一个状态机:用户扮演上帝,手动触发到达和离开事件。好处是每一步都可观察、可单步调试;坏处是所有统计量都依赖人工输入的节奏,跑出来的「平均逗留时间」没有统计意义。

从我自己的经验看,这类作业要真正变成能验证的模型,最少要补三件事:用指数分布生成到达间隔和服务时长,用事件队列(按时间排序的优先队列)驱动主循环,把「窗口空闲」当成一个可被到达事件消耗的资源。补完之后,程序不再需要菜单,跑一万个顾客,输出的平均等待时间才可以和排队论里的理论值对照。

维度实验代码(手工触发)离散事件仿真
时间推进用户按键事件队列按时间戳弹出
到达分布人为决定泊松过程 / 指数间隔
服务时长隐含为 0独立随机变量
统计可信度只能验证流程通不通可与理论值比对
代码复杂度一个switch搞定需要事件结构和优先队列

验证方法也很朴素:把窗口数设成 1、到达率设成服务率的一半,跑十万次,平均等待时间应该收敛到理论公式算出的量级。如果偏离一个数量级,八成是时间戳记错了位置——要么记在入队时,要么记在开始服务时,这两个统计口径算出来的「等待时间」不是一回事。实验报告里没区分,写论文时这是必须说清的定义问题。

取舍的核心在于:这份代码选择了「可读性优先、可验证性靠后」。数组加链表两个结构,把「资源」和「缓冲区」的边界划得很清楚,这是它的价值;时间统计和错误处理几乎全部欠账,这是它的局限。拿它当数据结构练习,够用;拿它当仿真工具,得重写。

从那以后我每次看到「平均逗留时间」这类指标,都会先把计时器埋点的那一行单独拎出来跑一遍,打印三个顾客的原始时间戳,确认差值不是零、不是负数、不是随机数,再往下看统计逻辑。这个习惯帮我避开了不止一次「数字很漂亮但根本没算」的翻车。希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/27 14:34:25

医学图像与文本自适应融合分类技术解析

1. 医学图像分类的现状与挑战 医学图像分类一直是计算机辅助诊断(CAD)系统的核心任务之一。在临床实践中&#xff0c;放射科医生通常需要同时参考影像数据和对应的文本报告来做出诊断决策。然而当前大多数深度学习模型仅利用图像数据&#xff0c;忽视了文本报告中蕴含的丰富临床…

作者头像 李华
网站建设 2026/10/27 14:34:17

RAP-Gen:基于检索增强与CodeT5的自动化程序修复技术

1. 项目背景与核心价值 在软件开发领域&#xff0c;程序修复&#xff08;Program Repair&#xff09;一直是个耗时且容易出错的过程。传统的人工修复方式不仅效率低下&#xff0c;而且高度依赖开发者的经验水平。RAP-Gen项目的出现&#xff0c;为这个痛点问题提供了全新的解决方…

作者头像 李华
网站建设 2026/10/27 14:34:16

Linux IO性能优化与高级技术实战

1. Linux基础IO深度解析 在Linux系统中&#xff0c;IO操作是系统性能的关键瓶颈之一。不同于上篇介绍的文件描述符和标准IO库&#xff0c;下篇我们将深入探讨更底层的IO机制和性能优化策略。实际工作中&#xff0c;我曾遇到一个生产环境案例&#xff1a;当并发请求量达到2000QP…

作者头像 李华
网站建设 2026/10/27 14:31:52

pandas综合练习:数据清洗、聚合连接与性能优化实战

“pandas 的综合练习”这几个字&#xff0c;乍看像教科书目录里的一行小标题&#xff0c;真正动手做过的人都知道&#xff0c;它更像一次全面体检——你的数据思维、代码习惯、性能意识会在这套练习里被一次性照出来。我带过几个刚转数据的同学&#xff0c;也帮业务同事改过不少…

作者头像 李华
网站建设 2026/10/27 14:31:08

光纤传感器原理:光程编码与四大物理效应解析

简介&#xff1a;本资源是一份面向电子、测控、光电类专业学生及工程技术人员的光纤传感器基础原理图解资料&#xff0c;聚焦物性型与结构型两大分类及其核心工作机制&#xff0c;解决对光纤传感技术物理本质与典型应用理解不深的问题。文档以图文结合方式系统阐述温度、压力、…

作者头像 李华
网站建设 2026/10/27 14:29:59

营销技能体系化:从用户洞察到数据驱动的实战成长路径

做营销这行&#xff0c;最怕的就是把“看过很多技巧”当成“拥有营销技能”。我在这个行业摸爬滚打了十多年&#xff0c;从传统广告到新媒体运营&#xff0c;从线下活动到私域转化&#xff0c;见过太多人收藏了一堆爆款标题公式、十条完播率提升秘诀&#xff0c;真到自己上手做…

作者头像 李华