
简介面向Java数据结构课程设计学习者这份压缩包围绕“手机通讯录模拟”与“24点扑克牌游戏”两个经典项目展示链表、哈希表、排序、递归与回溯等核心结构的具体落地并涉及Set去重、优先队列搜索等优化思路。通讯录部分覆盖联系人增删改查与快速检索24点游戏则利用DFS与栈枚举运算组合能将课堂理论转化为可运行代码。资源为zip格式共73个文件体积仅431KB包含5个java源码与5个class字节码可直接对照运行53张png截图覆盖界面与关键运行结果8个xml配置和1个html说明便于了解工程结构。目前已有650人学习下载适合正在完成课程设计或想强化Java数据结构的同学。包内按多个test_3_version版本拆分可对比功能演进与局部优化思路省去从零搭建的周折直接获得可答辩、可扩展的课设参考。1. 这两个课设题目放一起想让你练什么数据结构课程设计里“手机通讯录模拟”和“24点扑克牌游戏”经常成对出现。前者考线性表的增删改查和文件持久化后者考递归穷举和表达式生成一个是“存得住、找得快”一个是“枚举全、算得准”。很多同学做完能跑但答辩时被追问几句就露馅通讯录删完人二分查找错乱文件关了再开读不回24点用 double 判断导致 (3,3,8,8) 这种经典牌型被直接判成无解。这篇文章按两题拆开讲选数据结构的关键理由、每个核心函数的写法、文件存取的正确姿势以及 24 点里分数运算和递归合并的细节。照着做两天内能完成一份有东西可讲的课设。2. 手机通讯录模拟顺序表选型、核心操作与文件落盘手机通讯录这个题最核心的决策不是“写多少行代码”而是“用什么容器装联系人”。我直接给结论默认顺序表也就是结构体数组除非题目白纸黑字要求“采用链表”。理由放在 2.1代码放在后面你答辩时按这个顺序讲逻辑是顺的。2.1 为什么顺序表优先于链表规模、访存模式与持久化难度先看规模。一个手机通讯录的联系人数量级就是几百条这不是海量数据场景顺序表和链表在“增删改查”上的复杂度常数差异根本体现不出来。真正决定选型的是题型特征通讯录的核心操作是“按姓名查”“按分组排序”“按序号编辑”这些操作里查找和排序是高频插入删除是低频。顺序表内存连续支持 O(1) 随机访问二分查找依赖这种连续性链表适合的场景是“头部频繁插入、按位置顺序遍历”但你做一个通讯录没有任何业务需要“插到第一个人前面”。再看持久化。课设要求“下次打开还能看到上次存的联系人”顺序表的定长结构体可以直接一次性写入文件链表则需要逐个节点序列化读回时还要重建指针关系纯属给自己加戏。这不是说链表不能做而是说在课设的有限时间内顺序表是最低风险、最容易被答辩老师认可的做法。结构体与容器定义如下#define MAX_CONTACTS 1024 #define MAX_NAME 32 #define MAX_PHONE 24 #define MAX_GROUP 16 typedef struct { char name[MAX_NAME]; char phone[MAX_PHONE]; char group[MAX_GROUP]; int starred; // 星标联系人标记1 表示星标 } Contact; Contact g_book[MAX_CONTACTS]; int g_size 0;这里两个设计点需要你理解而不是背一是name/phone/group都用定长char数组而不是std::string或字符指针这是为文件落盘服务的——指针保存的是内存地址进程结束地址失效写进文件再读回来就是“乱码”二是用全局数组加g_size记录当前有效联系人数量所有操作都基于g_size做边界判断这比用现成容器更贴近数据结构课设的训练目标手动管理存储与边界。starred用int而不是bool是为了后续按“星标分组排序”时方便写比较函数。你把这条规则记住后面对接qsort会省很多事。2.2 核心操作有序插入、按名删除、二分查找我推荐按姓名维护有序数组理由只有一个查找是通讯录最高频操作有序之后就能用二分。代价是插入和删除都要移动元素平均 O(n)但联系人才几百条这个 n 的移动成本低到可以忽略。既然数组始终保持有序那么插入的第一步是找到“第一个不小于新名字的位置”这是标准lower_bound思路int find_insert_pos(const char* name) { int lo 0, hi g_size; while (lo hi) { int mid (lo hi) / 2; if (strcmp(g_book[mid].name, name) 0) lo mid 1; else hi mid; } return lo; }这段代码对应std::lower_bound当中间元素比目标名字小说明插入点还在右边lo前移否则hi收缩到mid。循环结束时lo和hi汇聚到同一个位置就是插入点。这里有一个新手容易踩的细节hi mid而不是hi mid - 1因为目标位置可能就是mid本身。插入函数把新联系人放到定位点并用memmove把后面的元素整体后移int insert_contact(const Contact* c) { if (g_size MAX_CONTACTS) return -1; int pos find_insert_pos(c-name); memmove(g_book pos 1, g_book pos, (size_t)(g_size - pos) * sizeof(Contact)); g_book[pos] *c; g_size; return 0; }这里必须用memmove而不是memcpy源区间g_bookpos和目标区间g_bookpos1有重叠memcpy在重叠情况下行为未定义memmove才保证正确。这个差异本身就是答辩时可以主动讲的点说明你踩过边界问题。删除是插入的逆操作先二分定位确认该位置名字确实匹配再把后面元素整体前移int delete_by_name(const char* name) { int pos find_insert_pos(name); if (pos g_size || strcmp(g_book[pos].name, name) ! 0) return 0; memmove(g_book pos, g_book pos 1, (size_t)(g_size - pos - 1) * sizeof(Contact)); g_size--; return 1; }删除之后数组依然有序所以后续查找仍然可以走二分。这是“始终有序”策略的好处不需要像某些代码那样删除后重新排序。如果题目允许重名联系人按姓名删除就不安全了常见做法是改成“姓名电话号码双字段匹配”或者给每条记录加一个自增id删除时直接按id定位。我一般会在代码注释里标明这个限制避免答辩时被追问“重名怎么办”时慌乱。2.3 文件读写文件头记录数量读取前先做范围校验文件持久化是这个题最容易“跑起来没问题、展示时翻车”的部分。先记住一条血泪经验结构体里只要含指针成员就不能整体往文件里写。你可能会看到有人这么写// 错误写法含指针/string 的结构体不能这样落盘 fwrite(c, sizeof(Contact), 1, fp);写的时候系统不会报错但下次启动读回来指针字段全是早已失效的内存地址访问即崩溃或乱码。所以 2.1 里结构体才全部用定长数组这是文件落盘的前提。读写分两段。先写文件头存联系人数量再一次性写入全部记录int save_to_file(const char* path) { FILE* fp fopen(path, wb); if (!fp) return -1; fwrite(g_size, sizeof(int), 1, fp); fwrite(g_book, sizeof(Contact), g_size, fp); fclose(fp); return 0; } int load_from_file(const char* path) { FILE* fp fopen(path, rb); if (!fp) return -1; int n 0; if (fread(n, sizeof(int), 1, fp) ! 1) { fclose(fp); return -1; } // 读出数量先做范围校验防止损坏文件让数组越界 if (n 0 || n MAX_CONTACTS) { fclose(fp); return -1; } if (fread(g_book, sizeof(Contact), n, fp) ! n) { fclose(fp); return -1; } g_size n; fclose(fp); return 0; }文件头存数量有几个实际好处读取时知道一次要读多少条不用靠循环fread去猜其次可以做n的范围校验文件被截断或者被改坏时能提前发现。如果哪天你看到读取代码里写while (fread(...) 1)而没有边界检查那是一个潜在越界隐患这是负责任的改进点写入课程设计文档会加分。二进制文件的好处是简单、速度快缺点是记事本打不开。如果题目要求“保存为可查看的文本格式”把fwrite换成逐字段的fprintf即可每行一条联系人字段之间用逗号分隔读取时用fscanf按相同格式还原。文本格式需要处理“字段里本身有逗号”之类的转义问题课设阶段建议选二进制把精力留给核心算法。3. 24点扑克牌游戏有理数运算与递归合并枚举24点的考察点不是“算出 24”而是“怎么不遗漏地算出 24”。很多同学的第一个想法是随意挑两张牌试试运气跑通几组数据就觉得完成了。真正的课设标准是给定任意 4 张牌要么输出一个合法算式要么确定地告诉你无解。要做到这一点算法必须是枚举所有可能而不是碰运气。3.1 穷举规模到底有多大几千次不需要任何高级优化先算一下暴力枚举的规模。4 张牌第一轮从 4 张里选 2 张合并有 C(4,2)6 种选法每种选法有加、减、乘、除 4 种运算第二轮剩 3 个数C(3,2)3 种选法第三轮剩 2 个数只有 1 种选法但还要算 4 种运算。总组合数约在两千到四千的量级减法、除法的两个方向都算进去会到三千多。这个规模对任何现代计算机都是瞬间完成所以结论很直接穷举就是最优解不需要记忆化搜索也不需要动态规划。顺便可以算出另一个结论如果题目变成 5 张牌或 6 张牌穷举规模会爆炸式增长那才需要剪枝或换算法。课设答辩常问“你这个能不能扩展”答案就是“当前 4 张牌规模下不需要优化扩展牌数才需要”。把这话说清楚比背一堆复杂度分析更有说服力。3.2 用分数代替 double正面解决浮点误差24点最常见的翻车点是浮点判等。经典牌型 (3,3,8,8) 的一个解是 8/(3-8/3)。用 double 计算时8/3 存成 2.666...3 减掉它得到 0.333...8 除以它得到 23.999... 或 24.000...01。写fabs(x - 24) 1e-9可能恰好能过这一组但换一组牌误差会累积到判断失败于是“明明有解却输出无解”。与其调1e-9这种玄学阈值不如从根本上换成有理数分子和分母都存整数一切中间结果不转小数只在最后做一次精确整数比较。分数结构体和四则运算如下struct Frac { long long num, den; Frac(long long n 0, long long d 1) { if (d 0) { // 保证分母恒为正分子带符号 n -n; d -d; } num n; den d; } }; Frac add(const Frac a, const Frac b) { return Frac(a.num * b.den b.num * a.den, a.den * b.den); } Frac sub(const Frac a, const Frac b) { return Frac(a.num * b.den - b.num * a.den, a.den * b.den); } Frac mul(const Frac a, const Frac b) { return Frac(a.num * b.num, a.den * b.den); } Frac dvd(const Frac a, const Frac b) { if (b.num 0) return Frac(0, 0); // 除零返回无效分数由调用方过滤 return Frac(a.num * b.den, a.den * b.num); }有没有发现这里没有约分这是有意的通分后的分子分母是整数最后判断x.num 24 * x.den是精确整数比较中途约分与否不影响结果。不约分还省了求gcd的代码。中间分子分母最大值在 long long 范围内4 张牌最多合并 3 次完全不用担心溢出。dvd里除零返回Frac(0,0)是哨兵值调用方的tryPut里检查den 0就跳过。这样把“除零”从运行时异常变成显式分支程序在任何牌型下都不会崩。3.3 两两合并的递归数值与表达式同步生成有了分数运算核心枚举写起来就干净了。我的做法是“两两合并”每次从当前数字集合里任选两个数尝试所有运算合并成一个新数后递归处理剩余的数。这个思路天然覆盖了加括号的优先级——因为每次合并第一步运算括号都会被拼进表达式字符串。bool dfs(vectorFrac nums, vectorstring strs, string ans) { int n (int)nums.size(); if (n 1) { if (nums[0].num 24 * nums[0].den) { // 精确判等 ans strs[0]; return true; } return false; } for (int i 0; i n; i) { for (int j i 1; j n; j) { Frac a nums[i], b nums[j]; vectorFrac restNums; vectorstring restStrs; for (int k 0; k n; k) { if (k i || k j) continue; restNums.push_back(nums[k]); restStrs.push_back(strs[k]); } string sa strs[i], sb strs[j]; // 局部变量代替回溯每个分支独立构造新集合天然恢复现场 auto tryPut [](Frac v, string s) - bool { if (v.den 0) return false; // 过滤除零产生的 Frac(0,0) vectorFrac tNums restNums; vectorstring tStrs restStrs; tNums.push_back(v); tStrs.push_back(s); return dfs(tNums, tStrs, ans); }; if (tryPut(add(a, b), ( sa sb ))) return true; if (tryPut(mul(a, b), ( sa * sb ))) return true; if (tryPut(sub(a, b), ( sa - sb ))) return true; if (tryPut(sub(b, a), ( sb - sa ))) return true; if (tryPut(dvd(a, b), ( sa / sb ))) return true; if (tryPut(dvd(b, a), ( sb / sa ))) return true; } } return false; }几个设计点值得看。第一加法和乘法有交换律所以每种只试一个方向减法a-b和b-a结果不同除法同理两个方向都要试。第二这里用“局部变量 传值”模拟递归回溯每次tryPut都基于restNums复制出新集合递归返回后不污染上一层状态比“改数组再改回来”的方式更不容易错。第三表达式字符串每一步都带括号比如合并完a-b直接存(a-b)下一层拼出( (a-b) * c )最终结果不需要依赖运算优先级就能正确还原。主调函数这样启动string point24(vectorint cards) { vectorFrac nums; vectorstring strs; for (int v : cards) { nums.push_back(Frac(v, 1)); strs.push_back(to_string(v)); } string ans; if (dfs(nums, strs, ans)) return ans 24; return 无解; }一个隐藏细节vector传值会复制整个集合最多 4 个元素复制开销可以忽略但换来的是代码极难写错。如果团队里有熟手非要改成引用传参那就要处理“递归前压入、返回后弹出”的现场恢复非常容易遗漏课设阶段我建议保持传值写法。如果题目要求输出全部解把dfs里的return true改成“把ans塞进结果集合再继续”最后用setstring去重。去重过滤的是形如(12)3和1(23)这类括号位置不同但算式不同的重复以及有重复牌时全排列产生的重复输出。4. 课程设计避坑指南5 个我自己踩过的坑这一章写的是从“能运行”到“能稳定运行”之间必经的坎。每一条都是真实发生过的问题按“现象、原因、解决”组织你写代码时对照着查。4.1 通讯录删完人二分查找开始乱现象联系人列表用的是“始终有序”的假设但某次操作后按名字查找时明明某人存在却返回“未找到”删除还删错人。原因只有所有插入走insert_contact才能维护有序性。最常见的破坏途径是从文件load数据后直接操作没有在加载结束后调用qsort或者测试时手工给数组某个位置赋了新联系人跳过了插入函数。解决把“数组始终有序”当成一个不变量维护。加载文件后必须qsort(g_book, g_size, sizeof(Contact), cmp_name)一次任何新增数据一律走insert_contact禁止直接改写数组元素。我把这条规则写在文件加载函数注释里答辩时老师问“你怎么保证数组一直有序”这就是你要给的答案。4.2 文件里读回来是乱码或直接崩溃现象save_to_file前打印数据一切正常重启程序后load_from_file读到的姓名和电话全是乱码有时一访问就段错误。原因绝大多数是结构体里用了指针或std::string文件里保存的是内存地址而非真实数据。内存地址只在当前进程有效程序一退出就失效读回来自然全是野指针/垃圾值。解决结构体成员全部用定长char数组像 2.1 那样定义。另一个隐蔽点如果换了编译器且结构体存在#pragma pack不一致二进制文件的字段对齐会变读回来也会错课设阶段统一编译环境即可文档里注明“二进制格式不跨编译器保证”是加分项。4.3 24点碰到 (3,3,8,8) 直接报无解现象换个输入就对了唯独3 3 8 8提示无解手动一算明明有 8/(3-8/3)。原因double 的浮点误差。8/3 存成近似值中间结果每次近似最终和 24 做比较时误差累计导致判断失败。越是“除法多、小数多”的牌型越容易翻车。解决全套改用分数存储与运算就是 3.2 的实现。最后判x.num 24 * x.den这是整数精确比较不依赖任何容差阈值。如果还有人问“那1e-9可不可以”可以答单组数据也许碰巧可以作为程序令人信服的解法不行误差无法保证。4.4 除数为零导致静默放弃或崩溃现象24点求解器跑一部分牌型输出无解日志里没有任何错误提示换用带浮点代码时某些运算直接出现inf。原因枚举a/b时没判断b是否为 0。比如3 / (3 - 3)先算括号里得到 0再除就出问题。浮点版本会得到inf整数除法直接触发运行时异常或崩溃。解决dvd函数里先检查b.num 0返回哨兵分数Frac(0,0)tryPut里检查den 0跳过。这样每个除零分支都被显式过滤枚举不会漏算其他正常分支程序在任何输入下都不会崩。4.5 同一个24点算式输出了几十条现象改成“输出全部解”之后比如1 1 1 1只有一种合法思路结果列出来一大屏绝大部分看起来是同一个式子的变形。原因两张牌交换位置会产生重复输出比如(12)3和(21)3数值相同、字符串不同牌型里有重复数字时全排列也会产出重复分支。问题不在算法正确性而在于“输出”没有去重。解决把所有解存入setstring靠字符串唯一性自动去重。如果要更严格地认为(12)3和3(12)是不同表达式那set就作为最终输出集合而不是在递归里提前拦截。通常课设题要求“给出一个解即可”这一步只是为了应对“输出全部解”的追问而准备。5. 让课设代码更像工程模块划分、随机自测与扩展点第4章解决的是“能稳定运行”这一章解决的是“让代码看起来像能评审通过的作品”。三个改动都不大但能显著拉开你和“把代码堆在一个 main 里”的同学之间的差距。5.1 菜单与核心逻辑分离函数只负责一件事通讯录的程序入口最常见的问题是一个while(1)循环里塞下所有操作菜单打印、输入读取、数据处理混在一起测试时想单独验证某个功能只能从头走一遍。我的习惯是菜单只做分发核心操作全部独立成函数并且用返回值表示执行结果。int main() { load_from_file(contacts.dat); int choice; while (1) { printf(1.添加联系人 2.删除联系人 3.按姓名查找\n); printf(4.显示全部 0.退出并保存\n); scanf(%d, choice); if (choice 0) break; if (choice 1) add_contact(); else if (choice 2) delete_contact(); else if (choice 3) search_contact(); else if (choice 4) list_all(); } save_to_file(contacts.dat); return 0; }每个分支函数内部再读取具体输入这样main只负责一件事分发和保存退出。测试某个功能时可以直接写一个临时main调用insert_contact并断言返回值。24点那边同理把“输入牌型”“求解”“打印输出”拆成三个函数point24只管返回表达式或无解。5.2 用随机数据做批量自测文档里写真实统计课程设计报告里最常见的“测试”就是一张表写三组手动输入就结束。我建议你花十分钟做一个随机自测写一个内部测试函数循环 1000 组随机牌型统计有解比例和单组平均耗时。24点经典结论是大约七成多的 4 张牌组合有解你的程序跑出来应接近这个比例如果远低于七成说明枚举有遗漏。void selftest() { int total 1000, solved 0; for (int i 0; i total; i) { vectorint cards(4); for (int v : cards) v rand() % 13 1; if (point24(cards) ! 无解) solved; } printf(有解比例: %.1lf%%\n, 100.0 * solved / total); }把这段代码的统计结果写进课设文档比写“测试结果正确”四个字有说服力得多。老师看到你做了批量覆盖测试通常就不会再纠结“你怎么知道你的求解器没有漏解”。5.3 三个能写进文档的扩展点分组排序、难度分级、输出全部解扩展点不用真做但每个都要能说出实现思路这通常是答辩最后“你这个还有什么可改进的”环节的必考题。按分组显示通讯录里加一个group字段显示时qsort先按group再按name排序排序比较函数里strcmp两个字段即可。 24点难度分级把“允许中间过程出现分数”作为默认档另设一个“中间结果必须为整数”的简单档求解时把分数运算换成整数运算只在整除时才允许除法。 输出全部解把第 3.3 的dfs改为收集所有命中结果最后用setstring去重就是“穷举完整”的证据也是体现算法理解深度的地方。6. 答辩前这样准备演示脚本、追问预案与代码走读演示不是从程序打开开始而是从“你先把两个题的核心思路用一句话说清”开始。通讯录那句是“用顺序表维护按姓名有序的联系人数组增删通过二分定位和元素移动完成文件用定长结构体整体落盘”。24点那句是“用有理数避免浮点误差通过两两合并递归枚举所有运算顺序与括号形态”。这两句话背熟开场就有底气。演示顺序我建议固定成三分钟脚本。第一步跑通讯录的“增删查改”添加一个联系人、按姓名查找、再删除最后展示save_to_file已经执行第二步跑 24 点特意输入3 3 8 8让程序输出(8/(3-(8/3))) 24这是老师大概率有印象的难解牌型一次跑通比十个普通用例都有说服力第三步先把程序关闭再重新打开展示通讯录还在对应“文件持久化”这个验收点。遇到输入错误也不要慌先演示错误输入会走“无效输入请重试”的分支反而说明你做了健壮性处理。高频追问建议提前对一遍我列了五条你可以先自测老师可能问的话建议回答要点为什么用顺序表不用链表规模几百条查找排序为主顺序表支持随机访问和二分链表头插优势无业务场景删除后数组如何保持有序二分定位后整体前移删除后剩余元素仍有序不需要重新排序文件为什么用二进制定长结构体可直接整体写入文本格式要做逗号转义二进制简单且满足重开恢复24点为什么用分数不用浮点8/3 这类除法浮点误差会导致判定失败分数交叉相乘比较是精确整数运算加法乘法为什么只算一次满足交换律算反方向会产出重复分支减法除法不满足方向都要试代码走读有一个很实用的习惯注释只写“为什么”不写“是什么”。比如memmove那行的注释写“目标与源区间重叠必须用 memmove 而非 memcpy”比写“把元素后移”有价值递归里hi mid的注释写“mid 可能就是插入点不能减一”。答辩时讲到这些注释评委一听就知道你是真的调过代码而不是抄了一段自己都看不懂的逻辑。我当年做 24点也卡在浮点判等上调1e-9阈值调了半晚换成分数存储之后一次通过。这份经验如果你用得上就是这篇文章的意义所在。希望帮到你。本文还有配套的精品资源点击获取