简介:2025年数据结构期末课程设计综合包,面向高校计算机专业正在备战课程设计或复习数据结构的学生。压缩包将飞机票管理系统、Trie树与后缀树应用、交通咨询系统设计、简单搜索引擎四个典型题目集中在一起,覆盖航班查询、购退改签、字符串检索、路线规划与网页搜索等场景,适合作为C++课程设计的代码参考与模块拆解模板。包内共1398个文件,总大小10.5MB,文件构成以1326个idx索引文件为主,另含17个h头文件、16个cpp源文件、4个ui界面、5个csv数据表、4个json配置、3个pro工程文件,以及makefile、exe、PDF、docx说明文档等,基本涵盖源码、界面、数据、配置、构建与说明多个层次。已有82人学习下载。参考这套资料,可以对照工程目录识别各系统模块对应关系,重点理解Trie树自动补全、后缀树模式匹配、图论最短路与搜索引擎索引构建等数据结构落地写法;借助csv数据和PDF说明还能复现交通咨询、机票管理等典型流程,是课程设计前快速补齐实践经验的实用素材。
1. 一份 C++ 数据结构课程设计包:四个选题正好覆盖期末高频考点
期末前两周,老师甩过来一份 C++ 数据结构课程设计清单,四个题目分别是飞机票管理系统、Trie 树与后缀树的应用、交通咨询系统设计、简单搜索引擎。我拆开这份压缩包的第一反应是:它几乎把线性表、树、图、检索这四类数据结构高频考点全包圆了,而且每个题目都足够撑起一次完整的课设答辩。它不是单个项目的源码,而是一套可以照着改、照着讲的作业框架——机票系统练链表和文件持久化,Trie 和后缀树练字符串结构,交通咨询练最短路径的工程取舍,搜索引擎练倒排索引与排序。适合两类人:一类是期末要交课程设计、不想从零肝起的学生;另一类是准备面试、想把理论落成可运行代码的从业者。
2. 飞机票管理系统:线性表、排序与文件持久化的一条龙
2.1 为什么课程设计总爱用机票系统
课程设计题目千千万,老师偏爱机票系统是有原因的。一个完整的机票管理流程,涉及航班录入、查询、订票退票、航班列表输出、按价格排序、数据存盘——这几乎把线性表的增删查改全部考点串成了一个业务闭环。相比图书管理,机票系统多了一个余票数量约束,你得考虑库存不为负这种边界条件,答辩时能多聊几句。
数据结构上用数组还是链表,是第一个要拍板的问题。数组内存连续、支持随机访问,但删除中间一个航班需要把后续元素全部前移,插入同理;链表每次定位要 O(n) 遍历,但插入和删除在已知前驱时是 O(1)。课设规模一般不超过几百条记录,链表的劣势不明显,优势是代码结构能体现指针操作,老师一眼就能看出你掌握了结构体指针。常见做法是选链表,并且坚持按航班号有序插入,这样查询时可以提前终止遍历。
2.2 链表维护:按航班号有序插入的代码细节
先定义航班节点。这里用定长数组而不是 string,是为了后续读写文件方便,也不引入额外的动态内存管理负担:
struct Flight { char id[10]; // 航班号,例如 CA1831 char start[20]; // 起点城市 char dest[20]; // 终点城市 char date[12]; // 日期,格式 YYYY-MM-DD double price; // 票价 int seats; // 余票数量 Flight* next; // 指向下一条记录 Flight() : price(0), seats(0), next(nullptr) {} };每个字段对应业务上一个真实属性,next 指针是链表的核心。构造函数把 next 置空,这一步很重要——后面读取文件时如果忘记置空,遍历链表会直接越界访问,这是最常见的翻车点之一。
插入操作要维护有序性。核心思路是找到第一个航班号大于新节点的位置,把新节点插在它前面:
Flight* insertSorted(Flight* head, Flight* newFlight) { // 空链表或新节点应插在头部 if (head == nullptr || strcmp(newFlight->id, head->id) < 0) { newFlight->next = head; return newFlight; } Flight* cur = head; // 找到第一个 id 大于新节点的位置,插在它前面 while (cur->next != nullptr && strcmp(cur->next->id, newFlight->id) < 0) { cur = cur->next; } newFlight->next = cur->next; cur->next = newFlight; return head; }参数是旧链表头指针和新节点指针,返回值是新的头指针。因为新节点可能插在头部,头指针会变,所以必须把新头返回给调用方。这里用 strcmp 比较航班号,天然支持字典序;如果比较条件写成<=,会把重复航班号插到相同节点后面,导致重复数据出现,后文会专门讲这个坑。查询航班时配合findFlight先做查重再插入:
Flight* findFlight(Flight* head, const char* id) { Flight* cur = head; while (cur != nullptr) { if (strcmp(cur->id, id) == 0) return cur; cur = cur->next; } return nullptr; }购票逻辑就是对返回的节点做seats--,但减之前要判断余票是否已经为 0。这个边界判断单独拎出来写,别在菜单函数里内联,答辩时能省很多解释成本。
2.3 文件读写与排序:把数据落盘,再按票价排出去
课程设计的要求通常是程序退出后数据不丢,所以必须做持久化。最稳妥的方案是每行一条记录、字段用空格分隔的文本文件。好处是出错了可以用记事本直接打开排查,Excel 也能读:
void saveToFile(Flight* head, const string& filename) { ofstream fout(filename); if (!fout.is_open()) { cout << "无法打开文件 " << filename << endl; return; } Flight* cur = head; while (cur != nullptr) { fout << cur->id << " " << cur->start << " " << cur->dest << " " << cur->date << " " << fixed << setprecision(2) << cur->price << " " << cur->seats << "\n"; cur = cur->next; } fout.close(); }fixed << setprecision(2)是格式化输出的关键,让票价固定为两位小数,读回来时精度不会漂移。如果城市名里带空格,这种空格分隔方案会读乱,我一般建议城市名用单字或拼音,省掉一整类编码问题。
读取文件时有个细节很多人中招:不能直接把临时结构体拷贝给新节点,因为临时对象的 next 指针是悬空的。正确做法是先new一个节点,再逐字段赋值:
Flight* loadFromFile(const string& filename) { ifstream fin(filename); if (!fin.is_open()) return nullptr; Flight* head = nullptr; Flight tmp; while (fin >> tmp.id >> tmp.start >> tmp.dest >> tmp.date >> tmp.price >> tmp.seats) { Flight* node = new Flight(); strcpy(node->id, tmp.id); strcpy(node->start, tmp.start); strcpy(node->dest, tmp.dest); strcpy(node->date, tmp.date); node->price = tmp.price; node->seats = tmp.seats; // node->next 已经在构造函数里被置空 head = insertSorted(head, node); } fin.close(); return head; }new Flight(tmp)这种写法看起来省事,但会把 tmp 里未初始化的 next 指针也复制过去。这是血泪经验——我见过好几个人的课设在这一步内存访问越界,程序跑起来就崩,连菜单都没显示出来。
最后是排序。链表排序不需要开额外数组,用插入排序即可:从原链表逐个摘下节点,按票价插入新链表。这里按降序输出,票价高的排前面:
Flight* sortByPrice(Flight* head) { Flight* sorted = nullptr; Flight* cur = head; while (cur != nullptr) { Flight* next = cur->next; // 先记住下一个节点 if (sorted == nullptr || cur->price > sorted->price) { cur->next = sorted; sorted = cur; } else { Flight* p = sorted; while (p->next != nullptr && p->next->price >= cur->price) { p = p->next; } cur->next = p->next; p->next = cur; } cur = next; } return sorted; }注意整个排序过程只改指针,不 new 任何新节点,否则会造成内存泄漏。降序条件是cur->price > sorted->price,如果要升序,把比较符号反过来即可。链表排序后原来的 head 就丢掉了,菜单里要让用户明确这次排序是临时视图还是直接替换链表,否则下次操作顺序就乱了。
3. Trie 树与后缀树:从前缀匹配到子串查找的两套思路
3.1 Trie 树:空间换时间的前缀查询结构
Trie 树又叫字典树,核心思想是把公共前缀合并存储。每条边对应一个字符,从根到某个节点的路径就是一个字符串的前缀。插入和查询的复杂度都是 O(词长),跟字典里有多少词无关,这是它对比哈希表的优势——哈希表能精确查词,但做不了前缀匹配。
课程设计里的典型场景是:给一个前缀,返回所有以它开头的词。哈希表做不到,Trie 天然支持。节点定义用固定数组还是哈希表,取决于字符集。只处理小写字母时数组最简单:
struct TrieNode { TrieNode* children[26]; // 26 个小写字母 bool isEnd; // 是否有词在这里结束 int count; // 经过该节点的词数 TrieNode() : isEnd(false), count(0) { for (int i = 0; i < 26; i++) children[i] = nullptr; } };count 字段是容易被忽略的亮点:每个单词插入时,沿途所有节点的 count 都加 1。这样“统计某个前缀出现在多少词里”就变成了 O(len) 的查询,而不是遍历整棵树。
插入和查询逻辑:
class Trie { public: Trie() { root = new TrieNode(); } void insert(const string& word) { TrieNode* cur = root; for (char c : word) { int idx = c - 'a'; if (cur->children[idx] == nullptr) { cur->children[idx] = new TrieNode(); } cur = cur->children[idx]; cur->count++; } cur->isEnd = true; } bool search(const string& word) { TrieNode* cur = root; for (char c : word) { int idx = c - 'a'; if (cur->children[idx] == nullptr) return false; cur = cur->children[idx]; } return cur->isEnd; } bool startsWith(const string& prefix) { TrieNode* cur = root; for (char c : prefix) { int idx = c - 'a'; if (cur->children[idx] == nullptr) return false; cur = cur->children[idx]; } return true; } private: TrieNode* root; };参数上需要注意c - 'a'的映射只对连续小写字母成立;如果输入混入大写或数字,idx 会越界或错位。稳妥做法是插入前先tolower转换,或者把 children 数组改成unordered_map<char, TrieNode*>。数组方案胜在简单,unordered_map 方案胜在字符集自由,中文分词场景几乎必须用 map,这个选择会在后文避坑部分再展开。
3.2 后缀树换成后缀数组:暴力建法与适用边界
后缀树是 Trie 的进阶形态:把字符串的所有后缀插入一棵压缩 Trie,能在 O(m) 时间内查任意子串。但压缩树的边合并、后缀链接实现代价高,课程设计里直接写后缀树是自找麻烦。我一般会建议用后缀数组替代——把所有后缀排序后存在数组里,功能上覆盖了 90% 的子串查询需求,代码量少一个数量级。
暴力构建后缀数组的思路很直接:生成所有后缀,排序,记录每个后缀在原串中的起始位置:
vector<int> buildSuffixArray(const string& s) { int n = s.size(); vector<string> suffixes; for (int i = 0; i < n; i++) { suffixes.push_back(s.substr(i)); // 后缀 } sort(suffixes.begin(), suffixes.end()); vector<int> sa(n); for (int i = 0; i < n; i++) { // 后缀的长度等于 n - 起始位置,反推起始下标 sa[i] = n - suffixes[i].size(); } return sa; }s.substr(i)会复制子串,总复制量是 O(n²),所以暴力法只适合几千字符规模的实验数据。如果题目要求处理长文本,需要用倍增法把构建降到 O(n log n),但课程设计答辩一般不会追问到这个深度,暴力法把“后缀数组是什么”讲清楚就够了。
有了后缀数组,判断某个模式串是不是原串的子串,可以二分查找:
bool containsPattern(const string& text, const string& pattern) { vector<int> sa = buildSuffixArray(text); int lo = 0, hi = sa.size() - 1; while (lo <= hi) { int mid = (lo + hi) / 2; string suffix = text.substr(sa[mid]); int cmp = suffix.compare(0, pattern.size(), pattern); if (cmp == 0) return true; if (cmp < 0) lo = mid + 1; else hi = mid - 1; } return false; }suffix.compare(0, pattern.size(), pattern)的意思是取 suffix 从位置 0 开始、长度等于 pattern.size() 的子串与 pattern 比较。每次二分都会复制一次后缀字符串,性能不算好,但胜在逻辑直白、不怕写错。真正要理解的是:后缀数组中相邻项共享公共前缀,所以二分查找子串是可行的,这也是所有字符串匹配进阶算法的根基。
3.3 Trie 与后缀数组的选择对比
两个结构放在一起做对比,是答辩时的加分项。核心区别在于:Trie 擅长前缀查询,后缀数组擅长子串查询。
| 维度 | Trie 树 | 后缀数组 |
|---|---|---|
| 核心能力 | 前缀匹配、词频统计 | 子串查找、重复子串检测 |
| 构建复杂度 | O(词长 × 词数),逐字插入 | 暴力 O(n² log n),倍增 O(n log n) |
| 查询复杂度 | O(len) | O(len log n) |
| 内存占用 | 每节点 26 个指针,较浪费 | 一个 int 数组,紧凑 |
| 典型场景 | 搜索引擎输入提示、敏感词过滤 | 文本查重、生物序列匹配 |
如果题目是“统计某前缀出现多少次”,Trie 的 count 字段在插入时就统计好了,查询一次遍历完事。如果题目是“找出字符串中重复出现的片段”,后缀数组排完序后相邻项比较公共前缀就行,Trie 反而要额外维护子树信息。两个都写的好处是,老师问“为什么这个场景不用另一个”,你能直接给出内存和复杂度两方面的理由。
4. 交通咨询系统:最短路径从图构建到查询的工程取舍
4.1 邻接矩阵与邻接表:数据规模决定建模方式
交通咨询系统的本质是带权图最短路径问题。城市是顶点,城市间的道路是边,边的权值可以是距离或时间。建模方式的选择取决于数据规模:城市数少于 50 时用邻接矩阵,代码直观、调试方便;城市数多且边稀疏时用邻接表,省内存。课程设计几乎都是前者,因为数据量小到矩阵浪费的那点空间可以忽略。
图结构定义如下:
const int MAX_CITY = 50; const double INF = 1e9; // 表示不连通 struct TrafficGraph { int cityCount; // 城市数量 double dist[MAX_CITY][MAX_CITY]; // 两城市间的距离 char cityName[MAX_CITY][20]; // 城市名称 };初始化时把整个矩阵填 INF,对角线填 0,然后按输入的边信息赋值。注意要处理重复边——同一对城市之间可能有多条路,取最小值作为权值。这个细节很常见:输入文件里如果重复给了一条更短的路,不判断就直接覆盖,会得到错误的最短路径。
城市名称和城市编号的映射用数组下标对应即可,不需要哈希表。菜单里提示用户输入城市编号还是城市名,是一个体验分水岭;我一般会做一个findCityIndex函数,按名字查到编号,查不到提示重新输入。
4.2 Dijkstra 单源最短路径:前驱数组记录完整路线
Dijkstra 适用于边权非负的图,交通距离天然满足。它的贪心策略是:每次从未访问节点中选出距离起点最近的节点 u,用 u 去松弛它的邻居。这里的“松弛”指用dist[u] + 边权去尝试更新dist[v]。
实现时除了 dist 数组,还要维护 path 前驱数组,记录每个节点的上一站。很多人只输出距离不输出路线,答辩时被问“这条路经过哪些城市”就卡住了:
void dijkstra(const TrafficGraph& g, int start, double* dist, int* path) { bool visited[MAX_CITY] = {false}; for (int i = 0; i < g.cityCount; i++) { dist[i] = g.dist[start][i]; path[i] = (dist[i] < INF) ? start : -1; } visited[start] = true; dist[start] = 0; for (int k = 0; k < g.cityCount; k++) { int u = -1; double minDist = INF; for (int i = 0; i < g.cityCount; i++) { if (!visited[i] && dist[i] < minDist) { minDist = dist[i]; u = i; } } if (u == -1) break; // 剩下的节点都不连通 visited[u] = true; for (int v = 0; v < g.cityCount; v++) { if (!visited[v] && g.dist[u][v] < INF && dist[u] + g.dist[u][v] < dist[v]) { dist[v] = dist[u] + g.dist[u][v]; path[v] = u; // 记录 v 的上一站是 u } } } }path 初始化的逻辑是:start 能直达的节点,path 设为 start;不能直达的设为 -1。回溯打印路线时,从终点递归回起点:
void printPath(int start, int end, int* path) { if (end == start) { printf("%s", cityName[start]); return; } printPath(start, path[end], path); printf(" -> %s", cityName[end]); }递归终止条件是end == start,不是path[end] == -1。如果起点不可达,path[end] 会一路回溯到 -1 然后死循环,所以调用前要先判断 dist[end] 是否小于 INF。这个边界在避坑章会专门讲。
4.3 Floyd 多源最短路径:三重循环与适用条件
Floyd 算法适合求任意两点之间的最短路径,代码极短,三重循环就完了:
void floyd(TrafficGraph& g) { for (int k = 0; k < g.cityCount; k++) { for (int i = 0; i < g.cityCount; i++) { for (int j = 0; j < g.cityCount; j++) { if (g.dist[i][k] + g.dist[k][j] < g.dist[i][j]) { g.dist[i][j] = g.dist[i][k] + g.dist[k][j]; } } } } }k 是中间点,i 和 j 是端点,循环顺序不能乱——k 必须放在最外层。如果写成for i / for j / for k,部分轮次的结果会用到未完整更新的中间状态,最终答案可能错误。这是个很隐蔽的正确性问题,很多人跑小数据碰巧对,换一组数据就翻车。
两个算法的取舍直接看需求:
| 维度 | Dijkstra | Floyd |
|---|---|---|
| 求解目标 | 单源到所有点 | 所有点到所有点 |
| 时间复杂度 | O(n²) | O(n³) |
| 边权限制 | 不能有负权 | 不能有负权环 |
| 路径还原 | path 前驱数组 | 需要另开 path 矩阵 |
| 适用场景 | 频繁查某一城市出发 | 任意两城市互查 |
课设答辩时,一个常被问的问题是“为什么不全部用 Floyd”。答案是:查询次数少时 Dijkstra 更快,而且 Dijkstra 打印单条路径更直观;Floyd 适合一次算完存起来,后续所有查询 O(1) 查表。两个都实现,并在菜单里让用户选择“查单个城市出发”还是“查任意两城市”,功能上就完整了。
5. 避坑与常见问题:课程设计跑不通的五个典型现场
5.1 数据与编码类:中文乱码与航班号重复
现象一:从文本文件里读出的城市名,打印到控制台全是乱码。
原因:Windows 控制台默认代码页是 GBK,文件保存成了 UTF-8,或者反过来。C 语言课程设计用 printf 输出中文最容易踩这个坑。
解决:统一编码。最简单的方案是文件和控制台都用 GBK 保存,记事本另存时选 ANSI;如果坚持用 UTF-8,程序里要执行setlocale(LC_ALL, "zh_CN.UTF-8")。我个人的习惯是城市名直接用拼音首字母,比如 Beijing、Shanghai,彻底避开编码问题。
现象二:同一个航班号能被录入两次,链表里出现两条相同记录,重新启动后数据翻倍。
原因:插入前没有调用 findFlight 查重。insertSorted 的有序插入只保证顺序,不保证唯一。
解决:在新增航班的函数里先执行findFlight(head, id),查到就提示并返回,查不到才 new 节点插入。这个检查步骤单独写成addFlight函数,不要在菜单 case 里直接写链操作,否则改起来到处都是。另外,loadFromFile 读取时也会调用 insertSorted,如果文件里本身就重复,读进来照样重复。所以文件生成时就要保证唯一性,两个入口都要堵住。
5.2 树与内存管理:Trie 析构的递归释放
现象三:Trie 树程序退出时卡死,或者报内存泄漏。
原因:TrieNode 没有写析构函数,new 出来的节点全部泄漏;如果写了析构但没递归处理 children,只释放了根节点,深层节点全部悬空。
解决:给 TrieNode 写递归析构函数:
TrieNode::~TrieNode() { for (int i = 0; i < 26; i++) { delete children[i]; // 递归释放所有子树 } }delete children[i]会自动调用子节点的析构函数,一层层释放到底。注意如果树很深,递归可能爆栈,但这种场景在课设里几乎不会出现。如果非要更稳妥,可以用队列做 BFS 逐层释放。课程设计的 Trie 树高度受限于词长,递归析构已经足够。
5.3 图算法与检索:路径回溯与中文分词
现象四:Dijkstra 输出路线少了一站,从北京到上海只打印了“上海 -> 济南”,开头缺了“北京”。
原因:printPath 的递归终止条件写成了path[end] == -1,而起点 start 的前驱被初始化为 -1,递归打印到起点前一个城市就停了,起点本身没被打印。
解决:终止条件改用end == start,像 4.2 节那样递归打印,start 作为递归出口一定会被输出。另外调用 printPath 之前必须判断dist[end] < INF,否则不可达的终点会让 path 一路回溯到 -1,造成死循环。我每次答辩前都会单测这条路径输出,输入直达、中转、不可达三组用例各跑一遍。
现象五:搜索引擎输入“数据结构”,返回了一堆无关文档,把词拆得七零八落。
原因:按单个字符分词,把“数据结构”拆成了“数”“据”“结”“构”,倒排索引里每个字都有单独的文档列表,查询时四个列表取交集,结果当然是空或者混乱。
解决:维护一份词典,采用正向最大匹配分词——从句子开头尝试匹配最长的词典词。在没有第三方分词库的前提下,这是最简单可行的中文分词方案。搜索引擎的倒排索引本身没有问题,问题出在分词粒度;分词粒度错了,后面全部白搭。
6. 简单搜索引擎:倒排索引合并与 Top-K 排序的验证技巧
6.1 倒排索引合并与最小堆 Top-K
搜索引擎的核心是倒排索引:每个词对应一个文档 ID 列表。查询时把多个词的列表做交集,再按词频打分,取分数最高的 K 篇文档返回。这里的“取 Top-K”是个典型技巧——不要每次都全排序,维护一个小顶堆,堆顶是当前最小的分数,新文档只要比堆顶大就替换:
struct DocScore { int docId; int score; }; struct Cmp { bool operator()(const DocScore& a, const DocScore& b) const { return a.score > b.score; // 小顶堆:分数小的在堆顶 } }; void topK(vector<DocScore>& docs, int k) { priority_queue<DocScore, vector<DocScore>, Cmp> pq; for (auto& d : docs) { if (pq.size() < k) { pq.push(d); } else if (d.score > pq.top().score) { pq.pop(); // 把堆顶最小的淘汰 pq.push(d); // 新分数更高的进来 } } // 堆里剩下的就是分数最高的 k 篇文档 }dq的小顶堆用Cmp定义,分数最小的在堆顶,淘汰时 O(log k) 完成调整。整体复杂度 O(n log k),比全排序的 O(n log n) 快,而且内存占用固定为 k 个元素。如果 k 很小,比如 10,这个优化效果非常明显。
6.2 验证用例:先跑边界再谈正确性
搜索引擎的验证不能用“感觉对了”来糊弄。我一般准备 5 篇短文,每篇几百字,人工标好预期结果,然后按固定顺序跑三组用例。
第一组是空查询和查不到的词:空查询应该返回提示而不是崩溃;生僻词如“量子蝴蝶”应该返回空列表。第二组是单字词,比如搜“数”,这属于有效查询,返回的是包含这个字的所有文档。第三组是双词组合,比如“数据结构”,验证分词和倒排索引合并是否协同工作。顺序不能乱——先确认基础检索正常,再测组合逻辑,否则出了问题不好定位是哪一层出错。从那以后我每次写完检索类作业,都强制自己把这三组用例完整跑一遍再提交,这个习惯帮我挡掉了不少答辩现场的尴尬。希望帮到你。
本文还有配套的精品资源,点击获取