简介:这份课程设计资料面向计算机相关专业学生,围绕航班查询与检索系统,完整展示C++语言下数据结构与算法的综合应用。文档以实验报告形式组织,涵盖结构体定义航班信息、链表与顺序表存储数据、队列辅助基数排序,以及二分法查询等核心知识点,并附有算法流程图和实际输出结果,便于对照理解从设计到实现的完整过程。资源为1个DOC文档,压缩包大小217KB,已吸引104人学习。通过阅读可掌握航班信息录入、排序、多条件查询与结果展示的编码思路,尤其适合正在完成数据结构课程设计、需要参考完整报告写法或验证排序与查找算法的读者。
1. 为什么航班查询课设把基数排序和二分查找组合在一起
六条航班记录、七位定长航班号、四个查询维度,乍看是“顺序表加条件遍历就能交差”的课设题。真正值得留意的是排序与检索的组合关系:先想清楚“排序结果如何反过来支撑查找”,再动手写代码,这才是数据结构课设想训练的点。这套代码在许多实验报告里都能见到同源变体,但里面藏着几处值得较真的细节,比如二分查找的 high 边界会不会越界、基数 R 的定义方式在不同编译器上的差异、时间字符串比较的格式约束。它适合两类读者:正在写数据结构课程设计、需要理顺排序和查找关系的本科生;以及手头有旧 C++ 课设代码、想快速定位其中隐患的工程师。下面按结构体建模、基数排序、查询实现、边界值验证和 Windows 编译迁移五个层次逐段拆开。
2. 航班信息结构体建模与基数排序的细节实现
2.1 flight 结构体:把多字段记录组织成逻辑单元
代码里先用DataType定义一条航班记录,再用Flight[N]形成顺序表。这个结构体本身没有复杂指针,但它决定了后续所有排序和查询代码的访问方式,先看定义:
typedef struct flight { char flight_number[10]; // 航班号,如 CA1544 char start_address[10]; // 起飞站,如 合肥 char arrived_address[10]; // 终点站,如 北京 char work_date[10]; // 班期,如 "1245" 表示周一、周二、周四、周五有班 char start_time[6]; // 起飞时间,格式 "10:55" char arrived_time[6]; // 到达时间,格式 "12:40" char FlightType[4]; // 机型,如 733、M90、CRJ int fare; // 票价,单位元 } DataType;字段长度的选择是第一步容易出错的地方:航班号CA1544实际占 6 个字符,但数组长度必须留 10,因为strcmp、strcpy都依赖结尾的\0;时间"10:55"占 5 个字符,char[6]刚好放得下终止符。FlightType只有 3 字节,是因为机型代码如CRJ后也要补\0。表里每个字段的用途可以对应到查询入口:
| 字段 | 类型 | 长度 | 典型值 | 对应查询模块 |
|---|---|---|---|---|
| flight_number | char[] | 10 | CA1544 | 按航班号二分查找 |
| start_address | char[] | 10 | 上海 | 按起飞地点查询 |
| arrived_address | char[] | 10 | 北京 | 按目的地查询 |
| start_time | char[] | 6 | 10:55 | 按起飞时间查询 |
| arrived_time | char[] | 6 | 12:40 | 按到达时间查询 |
| fare | int | 4 | 960 | 按票价范围查询 |
结构体本身属于“紧凑记录”设计:所有字段连续存放,逻辑上一个节点代表一次航班。值得注意的是程序里同时存在两个存储视角:Flight[6]是顺序表,用于查询阶段的下标访问;element[]是带表头的单链表,每个节点里既有排序用的key区,又有完整航班信息info区,供基数排序过程中通过指针移动节点。这种双结构设计并非冗余——顺序表支持O(1)随机访问,方便二分查找;链表则避免排序时大量搬移结构体数据,只需调整next指针。
2.2 链表与队列协作的 LSD 基数排序
基数排序的核心思想是不直接比较两个航班号的大小,而是从最低位到最高位逐趟分配和收集。航班号的每一位都是字符,因此把每个字符当作一个桶号,用队列数组表示桶。报告里给出了完整的radixSort,下面是我按可移植写法整理后的版本:
#define D 7 // 航班号最大位数,这里统一取 7 #define R 128 // 桶数,取 ASCII 字符集大小,保证下标合法 typedef struct Node { char key[D]; // 排序关键字,存放航班号 DataType info; // 航班完整信息 struct Node *next; } RadixNode; typedef struct QueueNode { RadixNode *f; // 桶队列头 RadixNode *e; // 桶队列尾 } Queue; void radixSort(RadixNode **plist, int d, int r) { int i, j, k; RadixNode *p, *head; head = (*plist)->next; for (j = d - 1; j >= 0; j--) { // 从最后一位开始,逐位处理 p = head; for (i = 0; i < r; i++) { // 每趟分配前清空所有桶 queue[i].f = NULL; queue[i].e = NULL; } while (p != NULL) { // 分配:把节点挂入对应桶 k = (int) p->key[j]; // 取当前位字符的 ASCII 码作桶号 if (queue[k].f == NULL) queue[k].f = p; // 桶为空,当前节点成为队头 else (queue[k].e)->next = p; // 否则挂到队尾 queue[k].e = p; p = p->next; } i = 0; while (i < r && queue[i].f == NULL) i++; // 跳过空桶,找第一个非空桶 if (i >= r) continue; head = queue[i].f; // head 指向收集链表的头 p = queue[i].e; for (i++; i < r; i++) { // 收集:把所有非空桶顺序串起来 if (queue[i].f != NULL) { p->next = queue[i].f; p = queue[i].e; } } p->next = NULL; } (*plist)->next = head; }逻辑上每一趟循环做两件事:分配和收集。分配时,将当前处理的字符作为队列下标,把节点挂到对应队列尾部,保证同桶内顺序稳定;收集时,从桶 0 到桶 r-1 依次把非空队列串成一条单链表,作为下一趟的输入。d趟结束后,链表即按航班号字典序排好。
复杂度方面:总共做d趟,每趟遍历一次链表并清空r个桶,时间开销为O(d * (n + r));空间上额外使用r个队列头尾指针和一个临时链表,属于典型的以空间换时间。当数据只有 6 条时,基数排序的实际速度优势不明显,但它有一个关键特性——稳定。稳定性意味着两趟之间不会破坏上一趟建立的次序,这对后续按班期或时间做组合排序很有意义。
有一个经常被忽略的坑:报告原文写作#define R 'a',这在实际编译时相当于把桶数组定义成 97 个元素。若p->key[j]取到字符'A',ASCII 码是 65,还在 97 之内,能“碰巧”运行;但如果航班号里出现小写字母,ASCII 码会超过 97,数组下标就越界了。我一般建议直接定义成R 128,或先转成相对字符值,例如k = p->key[j] - '0',再用R 10。这点改动虽然小,却能让代码在 VC6.0 到现代 GCC 之间保持安全。
提示:基数排序不只适用于纯数字,字符型定长关键字同样适用,前提是每个关键字长度一致。若航班号长度不统一,需要先左补齐空格再参与排序。
3. 六条航线上四类查询:二分查找与线性扫描的工程对比
3.1 航班号二分查找的边界修正
二分查找能生效,前提是数组已经有序。main()的执行顺序很关键:先调用radixSort得到按航班号升序排列的链表,再通过copy把链表内容写回Flight[],最后才进入查询菜单。也就是说,二分查找操作的是“已排序的顺序表”,这也是程序把基数排序放在最前面的原因。
void F_By_FN(flight F[]) { int low = 0, high = N - 1, mid; char Num[10]; cout << "请输入您要查询的航班号:"; cin >> Num; Cout_info1(); // 先打印表头,再逐行输出 while (low <= high) { mid = (low + high) / 2; int cmp = strcmp(Num, F[mid].flight_number); if (cmp == 0) { Cout_info2_2(F, mid); // 找到后输出该行航班信息 return; // 直接返回,避免打印“未找到”提示 } else if (cmp < 0) { high = mid - 1; // 目标在当前中点左侧 } else { low = mid + 1; // 目标在当前中点右侧 } } cout << "对不起,没有您要查找的航班号" << endl; }这里有个明显的坑:不少同源报告写的是high = N。数组下标范围是 0 到 N-1,若high = N,第一次mid = (0 + 6) / 2 = 3还没问题,但当查找目标大于所有航班号时,low会不断右移,最终可能让mid取到 6,访问F[6]造成越界。正确写法是high = N - 1。在 6 条数据下这个错误不容易暴露,因为Cout_info2_2(F, 6)读到的只是相邻内存里的脏数据,程序不一定崩溃,但输出结果会变得不可解释。
3.2 时间、地点与票价查询:顺序扫描的适用场景
航班号的查询只做精确匹配,因此能二分;但时间、地点、票价都没有建立索引,只能线性扫描。先看时间查询的实现:
// Time=1 表示按起飞时间查询,Time=2 表示按到达时间查询 void F_By_Time(flight F[], int Time) { int i; char T[6]; cout << "请输入您要查询的航班起飞/抵达时间:"; cin >> T; Cout_info1(); for (i = 0; i < N; i++) { if (Time == 1 && strcmp(T, F[i].start_time) == 0) Cout_info2_2(F, i); if (Time == 2 && strcmp(T, F[i].arrived_time) == 0) Cout_info2_2(F, i); } cout << "该时间没有航班" << endl; }这段代码把两个查询入口合并成一个函数,通过Time参数区分字段。strcmp做的是字符串精确比较,所以输入必须和表内格式完全一致,比如"10:55"不能写成"10:5",也不能写成"10:55"。报告中特别提到一个历史问题:最初用整型保存时间,导致输入16:40无法正确匹配,后来把时间字段改成字符串类型才解决。这说明,时间既有数值大小属性,又有展示格式属性;当查询条件依赖格式时,字符串反而是更直接的选择。缺点是字典序和时间的真实先后并不等价,比如"08:55"会排在"10:55"前面,这是字符串比较的天然行为,适合精确匹配,不适合范围查询。
地点查询和票价查询的思路一致。地点查询用AD参数区分起飞站和到达站,票价查询则用闭区间判断。四个查询入口的数据结构和复杂度可以放到同一张表里对比:
| 查询入口 | 操作字段 | 数据结构 | 查找方式 | 时间复杂度 |
|---|---|---|---|---|
| 按航班号 | flight_number | 有序顺序表 | 二分查找 | O(log n) |
| 按起飞/到达时间 | start_time / arrived_time | 无序顺序表 | 线性扫描 | O(n) |
| 按起飞/到达地点 | start_address / arrived_address | 无序顺序表 | 线性扫描 | O(n) |
| 按票价范围 | fare | 无序顺序表 | 线性扫描 | O(n) |
这份表可以直观看出“索引可以加速检索”。6 条数据时线性扫描无压力,但如果扩展成 6000 条航班,按时间查询就应当额外维护一个按时间排序的索引或哈希表,否则每次查询都要遍历全表。课设把 4 种查询放在一起,恰好展示了不同数据组织方式带来的代价差异。
3.3 菜单循环与命令分发
mainmenu()用switch把用户输入的数字映射到对应查询函数,整体是可用的,但有一个容易被忽略的状态管理问题:case 0里直接递归调用mainmenu(),而不是用continue或goto重新显示菜单。递归调用会使函数栈不断累加,连续按多次“显示主菜单”后再退出,栈就会一路回退,虽然对这个小程序影响不大,但属于不值得模仿的写法。更稳妥的结构是外层用一个无限循环,内部用break控制退出:
while (1) { cout << "请输入服务命令:"; cin >> y; switch (y) { case 0: continue; // 重新显示菜单,不产生新的函数栈 case 1: F_By_FN(Flight); break; case 2: F_By_Time(Flight, 1); break; case 3: F_By_Time(Flight, 2); break; case 4: F_By_Address(Flight, 1); break; case 5: F_By_Address(Flight, 2); break; case 6: F_By_fare(Flight); break; default: return; // 其他输入直接退出菜单 } cout << "是否退出?(Y/N):"; cin >> ch; if (ch == 'Y' || ch == 'y') break; }这样改后,每个查询函数返回后都能回到同一层循环,不会无限叠加调用栈;打算退出时统一走break,代码意图更清楚。
4. 链表排序结果回写顺序表与边界值验证
4.1 copy() 如何把已排序链表转回顺序表
基数排序完成后,有序数据仍然在element[]链表中,而后续查询函数都基于Flight[]数组。因此main()在显示排序结果后必须做一次数据回写,报告里的copy函数承担了这个职责:
void copy(flight F[], Node element[]) { RadixNode *p = element; p = p->next; // 跳过表头节点,从第一个真实航班开始 int i; for (i = 0; i < N && p != NULL; i++) { strcpy(F[i].flight_number, p->info.flight_number); strcpy(F[i].start_time, p->info.start_time); strcpy(F[i].arrived_time, p->info.arrived_time); strcpy(F[i].start_address, p->info.start_address); strcpy(F[i].arrived_address,p->info.arrived_address); strcpy(F[i].work_date, p->info.work_date); strcpy(F[i].FlightType, p->info.FlightType); F[i].fare = p->info.fare; // 整型直接赋值 p = p->next; // 链表指针后移 } }这里逐字段复制是必要的:flight结构体里没有指针字段,理论上也可以直接整体赋值,但逐字段复制的好处是语义清晰,且每个字符串都用strcpy保证\0一并拷贝。需要注意FlightType只有 4 字节,源数据里的机型代码如CRJ占 3 个字符,加终止符刚好放得下;如果将来机型代码加长,数组长度要同步调整。
报告中main()里有一行element[10].next = NULL,这其实是个越界操作。element[]按N+1也就是 7 个节点分配,合法下标是 0 到 6,element[10]访问了数组外的内存。在多数实现里它不会立刻崩溃,因为链表尾部的next可能本来就是NULL,但这种写法依赖运气,严格来说应当写成element[N].next = NULL。
4.2 用边界值数据验证四个查询入口
验证查询程序不能只测一组正常数据,要覆盖头部、尾部和不存在的数据。以 6 条原始航班数据为例,可以构造这样一组测试用例:
| 测试项 | 输入 | 预期输出 | 验证点 |
|---|---|---|---|
| 航班号头部 | 1 后输入 CA1544 | 合肥 北京 10:55 12:40 960 | 二分查找命中下标 0 |
| 航班号尾部 | 1 后输入 CZ3528 | 成都 厦门 15:10 16:50 1060 | 二分查找命中最末元素 |
| 航班号不存在 | 1 后输入 ZZ0000 | 提示无此航班 | 二分查找 low 越过 high |
| 起飞时间 | 2 后输入 08:55 | CZ3869 重庆 深圳 | 字符串精确匹配,时间在数据头部 |
| 到达时间不存在 | 3 后输入 23:59 | 提示无航班 | 遍历结束无匹配 |
| 票价闭区间下界 | 6 后输入 960 1100 | CA1544、CZ3869、CZ3528 | 边界值 960 应被包含 |
| 票价闭区间上界 | 6 后输入 1250 1380 | MU3682、HU1836 | 边界值 1380 应被包含 |
| 起飞地点多记录 | 4 后输入 上海 | MU5341、HU1836 两条 | 地点查询必须读完整表,不能提前退出 |
票价查询代码用的是T1 <= F[i].fare && T2 >= F[i].fare,这是闭区间比较,所以 960 和 1380 这类边界值必须出现在结果里。从测试角度看,闭区间边界最容易出现“差一”错误,比如有人会把条件写成T1 < fare或T2 > fare,导致两个边界航班被漏掉。测试用例表的价值就在这里:把边界值显式写出来,跑一次就能发现这类问题。
4.3 订单插入功能失败的常见原因分析
报告末尾写道“插入订票函数无法正常运行”,这几乎是所有定长顺序表课设的通病。Flight[N]大小固定为 6,没有预留空间,插入意味着数组写越界;同时链表版本的节点通过next串起来,经过基数排序后,链表的物理后继关系已经改变,如果插入函数还按原节点顺序找尾节点,就找不到正确位置。
我一般会这样修:顺序表预留足够容量,例如#define MAX_FLIGHT 30,并增加一个count记录当前航班的实际数量;插入时先在flight_number有序区用二分查找确定插入位置,再调用memmove把后续元素整体后移一个单位,最后写入新航班并将count加一。链表方案则更简单——插入点确定后,新的节点只需要修改前后相邻节点的next指针。两种方案的根因一致:插入操作需要关注容量和位置,而不能只把数据放进结构体就认为完成了。
5. 把课设代码移植到现代 Windows 编译环境的三个落地技巧
课程设计代码保留着早期iostream.h的风格,这在 VC6.0 时代没有问题,但放到 VS2019 或 VS2022 上会直接报找不到头文件。第一个技巧是把头文件升级为标准 C++ 形式:#include <iostream.h>改为#include <iostream>,并在文件开头加一行using namespace std;。如果代码里同时用到printf、strcpy,还需要保留<string.h>和<stdio.h>,这两者在新旧环境中都能通过。
第二个技巧是处理旧式main()声明。老代码常写成void main(),标准 C++ 要求返回int。把void main()改成int main(),并在函数末尾补上return 0;。这一步不改不影响逻辑,但能消除编译器告警,也让控制台退出码变得正常。
第三个技巧是准备一个回归验证脚本。用批处理把编译、运行、结果比对串起来,每次改动后跑一遍,能快速确认排序和查询没有被改坏:
@echo off chcp 936 >nul g++ flight.cpp -o flight.exe flight.exe < test_input.txt > result.txt fc /N result.txt expected.txttest_input.txt里按顺序存放菜单命令,例如先查航班号再查票价:
1 CA1544 6 960 1100 yfc /N会把实际输出和预期结果逐行对比。如果没有任何差异,说明本次修改没有破坏既有功能;如果某一行输出顺序变了,多半是查询函数里提前退出的逻辑出了问题。这套脚本配合 4.2 节的边界值用例,基本可以让课设代码在 Windows 命令行下稳定运行。
本文还有配套的精品资源,点击获取