ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

数据结构课设实战:顺序表通讯录与递归24点全解析

数据结构课设实战:顺序表通讯录与递归24点全解析 简介这份Java数据结构课程设计资源整合手机通讯录模拟与24点扑克牌游戏两个经典项目面向高校学生与自学者帮助通过完整代码实践链表、哈希表、排序、递归及深度优先搜索等核心知识点与算法思路。通讯录部分实现联系人对象的动态增删、查找、修改与排序可直观体会不同存储结构的差异24点游戏则借助数组、栈和回溯法穷举运算组合串联起抽象数据类型、栈与递归在算法设计中的实际应用。压缩包共73个文件涵盖5个Java源文件、5个class编译产物、53张PNG运行截图以及XML工程配置与HTML说明文档整体仅431KB结构紧凑便于对照界面结果修改调优。目前已有649人学习下载素材完整、步骤直观适合作为课程设计参考、期末复习或Java与数据结构进阶练手的起始模板。1. 数据结构课程设计通讯录模拟练线性表24点扑克牌游戏练递归难点都不在写代码很多院校的数据结构课程设计选题池里「手机通讯录模拟」和「24点扑克牌游戏」常年并列出现。一个看着是信息管理的增删改查一个看着是数学游戏实际上这两个题一前一后卡住了课程的两个核心考点线性表操作和递归枚举。我见过不少翻车现场通讯录用链表写得头头是道结果折半查找根本没法做24点游戏盯着括号穷举代码堆了四百行答辩时却说不清自己到底枚举了哪些运算结构。这篇笔记把两条落地路径分别拆开——通讯录怎么选结构、怎么保持有序、怎么安全落盘24点怎么用「每次合并两个数」的思路一次性覆盖所有括号形态再把输入、文件、答辩的坑逐个填上。适合正在赶课程设计进度的同学也适合想用C语言把底层功底重新捡起来的工程师。2. 通讯录模拟用什么数据结构顺序表比链表更适合课设的四个理由2.1 题目真正考察的并不是写界面而是线性表操作的完整性通讯录模拟最常见的需求描述是能够添加联系人、删除联系人、修改联系人、按姓名或电话号码查找、按姓名排序、把数据保存到文件并在下次启动时读回来。剥掉外壳这就是线性表上的增、删、改、查、排序、遍历外加一个持久化。数据结构课程设计里的通讯录本质上不是软件工程题目评分点也不在界面多好看而在你是否明确说明了「我选用什么结构、为什么选它、每个操作的时间复杂度是多少」。这也就决定了选型是第一件必须想清楚的事而不是上手就写代码。数据量也是关键约束。你自己模拟的通讯录撑死几百条记录最多到千级别。这个规模下顺序表和链表在插入删除上的性能差异是微秒级的用户完全感知不到。真正能拉开差距的是查找和排序顺序表按下标随机访问是 O(1)可以做折半查找链表要找第 k 个元素必须从头走折半查找直接废掉。所以我的建议很直接除非题目白纸黑字要求用链表否则通信用顺序表。理由有四条。第一课设代码量小顺序表逻辑直白出错率低。第二需要按姓名排序时顺序表可以在数组上直接做插入排序或调用 qsort链表排序改指针非常容易写乱。第三折半查找必须依赖随机访问只有顺序表支持。第四文件保存时顺序表可以整段写出链表还得遍历。2.2 通讯录的定义与有序插入用移位代替排序把查找前提维护好顺序表定义我一般写成下面这样容量先用固定数组够用且好讲。#define MAX_CONTACTS 1000 typedef struct { char name[32]; // 姓名中文按 UTF-8 字节存 char phone[20]; // 手机号留足空间 char email[64]; // 邮箱 } Contact; typedef struct { Contact items[MAX_CONTACTS]; // 顺序表的存储区 int len; // 当前有效长度 } AddressBook;这里的len很关键它表示当前已用的记录数而不是数组总容量。所有遍历、查找、删除都基于len而不是MAX_CONTACTS否则未初始化的记录会被当成真实数据输出。插入时我选择「始终保持按姓名有序」这样查找阶段直接可以折半。// 有序插入返回 0 成功-1 容量已满 int add_contact(AddressBook *book, const Contact *c) { if (book-len MAX_CONTACTS) return -1; // 容量检查漏了就会越界 int i book-len; // 从后往前找插入位置同时把比他大的记录后移 while (i 0 strcmp(book-items[i - 1].name, c-name) 0) { book-items[i] book-items[i - 1]; i--; } book-items[i] *c; book-len; return 0; }这段代码把「插入」和「保持有序」合并成一次遍历。每次插入最多移动len个元素复杂度 O(n)对几百条数据完全够用。更能体现思考的是插入后数组始终有序所以后续查找可以用折半这就是你在报告里能写清楚的逻辑闭环。有一个多数人忽略的点strcmp对中文姓名是按字节序比较的不按拼音。也就是说「张」和「王」谁前谁后取决于 UTF-8 编码字节的大小而不是《新华字典》的拼音顺序。课程设计里按字节序排序完全可以接受但要在报告里写明排序口径是「按姓名的编码序」免得答辩被问倒。想按拼音排序需要引入拼音映射表或本地化比较函数一般课设不做这个。2.3 按姓名查找用折半查找按电话查找用线性查找两条路径不能混折半查找是这一题的高频考点前提只有一个数组必须有序。上一节的有序插入已经把前提维护住了查找函数就非常简洁。// 折半查找返回下标找不到返回 -1 int find_by_name(const AddressBook *book, const char *name) { int lo 0, hi book-len - 1; while (lo hi) { int mid lo (hi - lo) / 2; // 写成这样避免 lohi 溢出 int cmp strcmp(book-items[mid].name, name); if (cmp 0) return mid; if (cmp 0) lo mid 1; else hi mid - 1; } return -1; }注意mid的写法面试和答辩时可以主动提一句lo (hi - lo) / 2是为了防止lo hi在极端数组长度下溢出。虽然课设的一千条记录不会溢出但这个细节能让老师觉得你不是在背代码。但折半查找只适用于按姓名这种「与排序键一致」的查找。如果题目要求「按电话号码查找」电话字段没有参与排序折半查找的二分前提就不存在了。常见做法是再写一个线性查找循环比较phone字段O(n) 完成。我曾经见过有同学把电话查找也硬套折半结果时好时坏最后定位发现是电话没排序。这个坑我放在第 5 章单独讲。删除操作的逻辑相对简单折半找到下标后续元素整体前移len--记得判断下标合法性。修改则拆成「找到 重写字段」两步如果修改了姓名要重新插入或重新排序否则有序性被破坏后面的折半查找会失灵。3. 24点扑克牌游戏的核心用递归合并数而不是去拼括号3.1 为什么「枚举所有括号」是个伪需求合并两数的递归视角24点这个题很多同学一上来就想着枚举表达式四个数排列、三个运算符排列、括号结构排列然后拼接成字符串求值。这条路不是不能走但代码会非常绕而且容易漏掉某些括号形态。我见过一个最夸张的版本写了 300 行就为了枚举 5 种括号结构最后还有一种没覆盖到。更干净的做法是换一个视角括号的本质只是「先算哪两个数」。4 张牌最终都要通过 3 次二元运算合并成 1 个数那么问题可以递归地描述为——每次从当前集合中取两个数做一次加减乘除得到一个新数放回集合直到集合里只剩一个数。这个新数天然带着括号因为它的表达式就是那两个子表达式的组合。// 判断当前 n 个数能否算出 target能则返回 1 int solve24(double val[], int n) { if (n 1) { return fabs(val[0] - 24.0) 1e-6; // 浮点误差容忍 } for (int i 0; i n; i) { for (int j i 1; j n; j) { double rest[4]; // 存剩余的数 合并结果 int k 0; for (int t 0; t n; t) { if (t ! i t ! j) rest[k] val[t]; } double a val[i], b val[j]; rest[k] a b; if (solve24(rest, k 1)) return 1; rest[k] a - b; if (solve24(rest, k 1)) return 1; rest[k] b - a; if (solve24(rest, k 1)) return 1; rest[k] a * b; if (solve24(rest, k 1)) return 1; if (fabs(b) 1e-9) { // 除数接近 0 跳过 rest[k] a / b; if (solve24(rest, k 1)) return 1; } if (fabs(a) 1e-9) { // 除数接近 0 跳过 rest[k] b / a; if (solve24(rest, k 1)) return 1; } } } return 0; }这段代码的精髓在rest[k]先把没被选中的数拷进rest[0..k-1]再把合并结果放到rest[k]递归处理k1个数。每一层递归数的个数减一到n 1时检查是否等于 24。这里有三个关键点。第一为什么不用管括号因为每次「合并两个数」的顺序就是括号顺序比如先算 1/5 再算 5-1/5 再乘 5对应的表达式就是 5*(5-1/5)递归天然覆盖了所有括号形态。第二减法除法的左右两种顺序都要试a-b和b-a是不同结果a/b和b/a也是。第三除法前检查除数是否为 0用fabs(b) 1e-9而不是b ! 0因为浮点数可能算出 1e-10 这种接近 0 的值。3.2 可解性判断与表达式同步更新让程序把算式打出来只判断有没有解还不够课程设计通常要求「给出算式」。这里需要把数值和表达式字符串绑定在一起同步更新。typedef struct { double val[4]; // 当前参与运算的数 char expr[4][128]; // 每个数对应的表达式字符串 } State; void print_solution(State s, int n) { if (n 1) { if (fabs(s.val[0] - 24.0) 1e-6) printf(%s 24\n, s.expr[0]); return; } for (int i 0; i n; i) { for (int j i 1; j n; j) { // 先拷贝一份状态避免手工回溯 State t s; char left[128], right[128]; snprintf(left, sizeof(left), %s, t.expr[i]); snprintf(right, sizeof(right), %s, t.expr[j]); double a t.val[i], b t.val[j]; // 用最后一个元素覆盖被选走的第 j 个位置 if (j ! n - 1) { t.val[j] t.val[n - 1]; snprintf(t.expr[j], sizeof(t.expr[j]), %s, t.expr[n - 1]); } t.val[i] a b; snprintf(t.expr[i], sizeof(t.expr[i]), (%s %s), left, right); print_solution(t, n - 1); t.val[i] a - b; snprintf(t.expr[i], sizeof(t.expr[i]), (%s - %s), left, right); print_solution(t, n - 1); // 乘法和两种除法同理除法先判除数不为 0 } } }这个写法把状态封装成结构体递归时直接State t s整份拷贝返回后不用恢复现场避免了手工回溯的糟心事。左操作数和右操作数的字符串在拼接前先备份到left、right因为后面对t.expr[i]和t.expr[j]的覆盖会破坏原内容。snprintf统一用来拼表达式格式是(左 运算符 右)每一层递归都加括号这样输出的表达式结构完全对应运算顺序评委一眼能看懂。注意这里的t.val[i]每次循环都被重写t是局部拷贝不会影响上层状态。这套代码有一个容易踩的细节当j ! n - 1时要先把expr[n-1]挪到expr[j]再覆盖否则被选走的第 j 个位置会留下旧值递归后会造成表达式与数值错位。这种错位非常隐蔽程序不一定崩溃但输出的算式是错的。3.3 输出全部解还是只输出一个解先定去重口径再动手24点题目有个隐形的分水岭要求「输出一种解法」还是「输出全部不同解法」。很多人写到一半才发现两种要求的代码量差别很大所以动手前必须定口径。如果只输出一个解上面的递归在找到第一个解后直接return即可简单省事。如果要求输出全部解就不能提前返回而是要把每个n 1且值等于 24 的叶子节点都打印一遍。但这时会出现大量本质相同的解比如5*(5-1/5)和(5-1/5)*5只是乘法交换律交换了左右操作数程序会当成两个解输出。我一般会在报告里明确写「本程序输出所有满足条件的表达式未做交换律去重」然后把去重作为扩展功能提一句。如果想做去重最简单的办法是在main里维护一个字符串数组每次打印前先查重已存在的表达式不输出。但要注意字符串完全相同才能去重(12)和(21)这种就仍然会重复输出严格去重需要先做操作符的标准化工作量不小。需要顺带一提的是无解判定。不是所有 4 张牌都有解比如1,1,1,1无论如何组合都只能是 2、4、8、1 这类结果到不了 24。所以程序必须有一个明确的「无解」输出分支测试时也要专门准备几组无解样例这一条在第 6 章再展开。4. 把通讯录和24点合成一个可演示的菜单程序输入、文件与代码组织4.1 主菜单用 do-while 加函数指针分发避免几十个 if 嵌套课程设计一般要求两个模块在同一个程序里启动后进入主菜单再选「通讯录」或「24点」。最简单的写法是switch嵌套但每个模块内部还有子菜单全部用switch会膨胀成一大坨。更顺手的组织方式是把每个操作抽成函数主菜单用函数指针数组分发。typedef void (*MenuFunc)(AddressBook *); void add_contact_handler(AddressBook *book) { /* 读输入调 add_contact */ } void find_contact_handler(AddressBook *book) { /* 读姓名调 find_by_name */ } void list_contacts_handler(AddressBook *book) { /* 遍历打印 */ } void delete_contact_handler(AddressBook *book) { /* 查找 删除 */ } void run_address_book_menu(void) { AddressBook book {0}; load_contacts(book); MenuFunc ops[] { add_contact_handler, find_contact_handler, list_contacts_handler, delete_contact_handler }; int choice; do { printf(1 添加 2 查找 3 列表 4 删除 0 返回\n); if (scanf(%d, choice) ! 1) break; if (choice 1 choice 4) { ops[choice - 1](book); } } while (choice ! 0); save_contacts(book); // 退出时统一保存 }函数指针数组的好处是新增功能时只加一个函数和一个数组元素switch结构不用动。这里load_contacts在进入菜单前把文件读进内存save_contacts在退出菜单时统一写回避免每做一次增删都打开文件。输入校验必须在这层做手机号长度、姓名非空、菜单选项越界都要拦下来。scanf(%d, choice) ! 1这个判断很关键如果用户输入了字母scanf会失败并返回 0此时必须消费掉非法字符否则会死循环。常见做法是while (getchar() ! \n);清空输入行。这个坑我再单独列一条。4.2 通讯录文件用文本格式字段用逗号分隔读入用 fgets 加解析文件保存的格式直接影响调试效率。二进制方案一个fwrite就能写完整个数组但结构体里有字节对齐的填充位换编译器或换机器可能读不回来出了错还是黑匣子。文本方案多写几行代码但打开文件能看到内容出问题一眼定位。void save_contacts(const AddressBook *book) { FILE *fp fopen(contacts.txt, w); if (!fp) return; for (int i 0; i book-len; i) { fprintf(fp, %s,%s,%s\n, book-items[i].name, book-items[i].phone, book-items[i].email); } fclose(fp); }void load_contacts(AddressBook *book) { FILE *fp fopen(contacts.txt, r); if (!fp) return; char line[256]; while (fgets(line, sizeof(line), fp)) { if (book-len MAX_CONTACTS) break; char *name strtok(line, ,\n); char *phone strtok(NULL, ,\n); char *email strtok(NULL, ,\n); if (name phone email) { snprintf(book-items[book-len].name, 32, %s, name); snprintf(book-items[book-len].phone, 20, %s, phone); snprintf(book-items[book-len].email, 64, %s, email); book-len; } } fclose(fp); }字段用逗号分隔的原因是两个第一姓名里不会出现逗号可以做稳定的字段分隔符第二邮箱里有和点号用空格分隔会解析错乱。读入用fgets整行读再strtok切分比fscanf(%s)更安全。strtok(line, ,\n)里带上\n是为了把行尾换行符一起去掉。如果姓名里确实可能含逗号那就要改成「每行一个字段、连续三行一条记录」的格式或者用转义字符。课程设计用逗号分隔已经足够报告里写明格式约定即可。4.3 两个模块的代码怎么组织分文件编译把数据结构定义放头文件课设代码量不大但也不建议所有函数塞在一个main.c里。常见组织是三个文件address_book.h放结构体定义和函数声明address_book.c放通讯录实现game24.c放 24 点递归实现main.c放菜单和调度。// address_book.h #ifndef ADDRESS_BOOK_H #define ADDRESS_BOOK_H #define MAX_CONTACTS 1000 typedef struct { char name[32]; char phone[20]; char email[64]; } Contact; typedef struct { Contact items[MAX_CONTACTS]; int len; } AddressBook; int add_contact(AddressBook *book, const Contact *c); int find_by_name(const AddressBook *book, const char *name); void save_contacts(const AddressBook *book); void load_contacts(AddressBook *book); #endif头文件必须加#ifndef宏保护否则多个.c文件同时包含时会出现重复定义错误。编译时在命令行执行gcc main.c address_book.c game24.c -o project或者用最简单的 Makefile 管理。有些同学为了省事把MAX_CONTACTS写死在函数里这是最隐蔽的坏味道。这个宏应该只出现在头文件里所有需要容量的地方都引用它。答辩时老师如果问「容量改成 5000 条要做哪些改动」回答「只改头文件一行宏」比「翻遍全项目改数组」显然高级得多。5. 课设最容易翻车的 5 个点现象、原因、解决5.1 菜单输入被“吞掉”或者直接卡死现象第一次运行菜单正常输入数字后第二次在屏幕上一闪而过更严重时程序陷入死循环CPU 占用拉满。原因scanf(%d)读取数字后回车附带的换行符留在输入缓冲区紧接着的scanf(%c)或getchar()直接拿到这个换行符表现就是「没等输入就继续」。如果清理写得不对scanf反复失败又反复重试便成了死循环。解决统一读行再解析。scanf(%d)后立刻while (getchar() ! \n);吸收掉整行残余字符型输入用scanf( %c, cmd)%c前面加一个空格表示「跳过所有空白字符」。这个空格是血泪教训丢了它等于给自己埋雷。5.2 通讯录录满 1000 条后程序崩溃现象录到第 1001 条时程序直接崩掉或者数组越界后把不相干内存改写出现「明明没删记录却不见了」的诡异现象。原因add_contact里没有容量检查book-len超过了MAX_CONTACTS写入items[MAX_CONTACTS]越界。顺序表的数组越界往往是静默的先破坏相邻变量延迟到某个无关联操作才暴露。解决入口处加if (book-len MAX_CONTACTS) return -1;。同时在load_contacts读文件时也要检查长度因为文件里可能已经有 1200 条记录了。这个检查放在两个入口而不是只放在插入函数里才是完整的防线。5.3 24点明明有解程序却输出「无解」现象输入1 5 5 5程序直接报无解或者能出结果但算成23.999999这种近似值打印出来很难看。原因第一1 / 5在整数运算里直接截断成0后面的算式就变成5 * (5 - 0) 25离 24 差一点第二浮点数不等于数学上的精确数用 24.0判断必然翻车。解决牌面值读入后立刻转成double全程用浮点判断结果用fabs(val[0] - 24.0) 1e-6。如果需要做「整数除法必须整除」的口径那么除法分支要先判断a % b 0再允许运算但这会漏掉5*(5-1/5)这种需要小数除法的经典解。我一般直接用浮点口径在报告里说明。5.4 折半查找偶尔找不到人换台机器更明显现象按姓名查找一部分名字能找到另一部分找不到连续查找同一个人第一次成功第二次失败。原因绝大多数是插入后没有保持有序。比如用了「直接追加 查找前排序」的方案但每次查找都重新排序排序依据和查找依据不一致或者新增联系人后没触发排序。还有一版是分配了定长二维数组做字符串存储但姓名长度不同排序时strcmp读到越界字符结果不稳定。解决坚持「add_contact内完成有序插入」查找前不做任何排序操作。这样有序性是由插入函数维护的查找函数只负责二分。电话查找另写线性查找绝不复用折半。5.5 通讯录文件换电脑打开乱码程序也读不回来现象在 Windows 记事本里写的中文联系人程序读出来是乱码或者换到 Linux 环境重新编译读写完文件后数据全乱。原因Windows 记事本默认 GBK 编码保存文本Linux 下程序默认按 UTF-8 解释字节流两者对中文的编码规则完全不同字节对不上自然乱码。还有一种情况是文件里混入了\r\n读行时\r残留在字段末尾。解决统一用 UTF-8 文本文件。如果是在 Windows 下写代码编辑器保存时选择 UTF-8读行后先去掉末尾的\r再交给strtok切分。代码里尽量不要把中文字符串写死在源码中菜单提示可以写中文但存储的字段值最好在运行时从输入读入让源码文件和运行数据各管各的编码能减少一大半编码问题。6. 答辩前的自测清单三组测试样例加两个必问问题能救回不少分答辩不是现场展示一次「运行通过」就完事老师会针对关键代码追问。我建议你答辩前按这份清单自测一轮比多写两个扩展功能更稳妥。第一组是通讯录的完整流程测试分别插入「陈、安、王、张」四个人查看列表是否自动有序删除中间位置的「安」再插入「马」查看顺序是否正确退出程序重新打开核对数据与关闭前一致。这组测试覆盖了有序插入、删除、持久化三条主路径。第二组是 24 点的经典刁钻样例5 5 5 1对应解是5*(5-1/5)专门考验小数除法3 3 8 8对应解是8/(3-8/3)考验连续两次除法1 1 1 1这是标准无解样例考验无解分支。如果这三组都过了递归逻辑基本没问题。第三组是输入边界手机号输入 11 位和 12 位各试一次非法字符试一次牌面输入A K Q J验证映射是否正常。边界输入是老师最爱上手操作的部分他不会按你的正常路径走。两个必问问题也要提前准备。第一个是「为什么通讯录用顺序表不用链表」标准回答是数据规模小、需要随机访问支持折半查找、排序实现简单并补充链表在频繁中间插入时才有优势但这里插入发生在尾部或按序位置顺序表足够。第二个是「24点算法的时间复杂度」标准回答是第 n 个数时有 C(n,2) 种选数组合、6 种运算除法受限状态数约等于(4 选 2)*6*(3 选 2)*6*(2 选 2)*6 1296种叶子路径实际因为有除零和减法的对称性会更少递归深度最多 3 层所以是瞬间完成的可接受暴搜。最后说一个我自己的教训。以前做文件保存时贪图省事用fwrite一次性把结构体数组写进二进制文件当时运行一切正常答辩时老师把工程拷到另一台机器上重新编译运行数据全乱。从那以后我再也不在课设里用二进制存结构体老老实实写文本格式虽然多几行代码但至少在换环境时能自己检查文件内容。这种「能打开看的数据文件」会在关键时刻救你一命的。希望这份拆解能帮你少走几趟弯路祝答辩顺利。本文还有配套的精品资源点击获取
返回列表