简介:这份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; // 写成这样避免 lo+hi 溢出 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],递归处理k+1个数。每一层递归,数的个数减一;到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里维护一个字符串数组,每次打印前先查重,已存在的表达式不输出。但要注意,字符串完全相同才能去重,(1+2)和(2+1)这种就仍然会重复输出,严格去重需要先做操作符的标准化,工作量不小。
需要顺带一提的是无解判定。不是所有 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一次性把结构体数组写进二进制文件,当时运行一切正常,答辩时老师把工程拷到另一台机器上重新编译运行,数据全乱。从那以后我再也不在课设里用二进制存结构体,老老实实写文本格式,虽然多几行代码,但至少在换环境时能自己检查文件内容。这种「能打开看的数据文件」会在关键时刻救你一命的。希望这份拆解能帮你少走几趟弯路,祝答辩顺利。
本文还有配套的精品资源,点击获取