☰
数据结构课设双题解析:通讯录顺序表与24点递归求解
2026/10/10 6:42:28 网站建设 项目流程

简介:面向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_book+pos和目标区间g_book+pos+1有重叠,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(vector<Frac> nums, vector<string> 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]; vector<Frac> restNums; vector<string> 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) vector<Frac> tNums = restNums; vector<string> 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(vector<int> cards) { vector<Frac> nums; vector<string> 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塞进结果集合再继续”,最后用set<string>去重。去重过滤的是形如(1+2)+3和1+(2+3)这类括号位置不同但算式不同的重复,以及有重复牌时全排列产生的重复输出。

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只有一种合法思路,结果列出来一大屏,绝大部分看起来是同一个式子的变形。

原因:两张牌交换位置会产生重复输出,比如(1+2)+3和(2+1)+3数值相同、字符串不同;牌型里有重复数字时,全排列也会产出重复分支。问题不在算法正确性,而在于“输出”没有去重。

解决:把所有解存入set<string>,靠字符串唯一性自动去重。如果要更严格地认为(1+2)+3和3+(1+2)是不同表达式,那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++) { vector<int> 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改为收集所有命中结果,最后用set<string>去重,就是“穷举完整”的证据,也是体现算法理解深度的地方。

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阈值调了半晚,换成分数存储之后一次通过。这份经验如果你用得上,就是这篇文章的意义所在。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询