
简介这是一份Java数据结构课程设计资源包含手机通讯录模拟与24点扑克牌游戏两个经典实例面向正在学习数据结构、需要完成课程设计或想提升编程实践能力的学生。通讯录部分演示如何用集合框架存储联系人、实现添加/删除/查找/排序等操作24点游戏则结合递归与回溯思想穷举四张牌的运算组合帮助理解搜索算法和栈的应用。压缩包共73个文件以53张运行截图、8个XML配置、5个Java源码和5个class字节码为主另含工程模块文件与HTML说明整体仅431KB。截图展示了不同功能界面与执行结果源码便于对照修改配置与工程文件则辅助快速导入开发环境。目前已有652人浏览学习。通过这两个完整实例可掌握链表、哈希表、优先队列等结构在真实场景中的选型思路也能借鉴其模块化注释、异常处理等规范为后续软件开发打基础。1. 数据结构课程设计从两套经典题目学会“选型驱动编码”如果你是计算机相关专业的在校生大概率正卡在同样的处境里数据结构课听懂了链表、栈、队列、树但打开课程设计题目清单时还是不知道从哪里下手。这份资源里包含的两个题目——手机通讯录模拟和24点扑克牌游戏恰好踩中了“基于数组或链表的线性表设计与操作”和“基于穷举与回溯的算法实现”两大考核点而且有完整源码和文档可以对照。与其说是两个作业不如说是一套能直接跑通、能讲清楚设计思路的实战模板。我拆这份资源时最直观的感受是它不是把代码丢给你就完事而是帮你把选型理由、类结构、操作接口、边界处理都补齐了。也就是说你拿到的不是一段黑匣子式源码而是一条能从“数据怎么组织”一路讲到“界面怎么操作”的完整链路。适合拿来交作业、做答辩预习更适合拿来搞懂为什么通讯录用链表更合理、为什么24点必须用递归去穷举所有表达式组合。2. 为什么这两道题是“数据结构课程设计”的黄金组合2.1 通讯录模拟把线性表学成真正能被业务调用的结构通讯录模拟的本质是“对一组联系人的增删改查”这看起来简单但放到数据结构语境下就是在考你线性表到底该用顺序存储还是链式存储。顺序表即数组实现的ArrayList查询按下标走O(1)插入删除要移动元素是O(n)链表LinkedList插入删除是O(1)但查找要遍历到目标节点。通讯录的业务特征是按姓名查、按电话查、频繁新增和删除联系人这意味着你不能只看单一操作复杂度得看整个操作序列的综合成本。我当时拆这道题的源码时第一反应是去看它选的是ArrayList还是LinkedList。资源里默认走的是“链表式通讯录”设计按姓名拼音维护顺序插入这样打印出来天然是排好序的。每插入一个联系人就从头节点往后比较字符串的compareTo值找到第一个“比新联系人姓名大的节点”插在它前面。这个逻辑和经典的有序链表插入一模一样只是把数据域从单个数值换成了一个联系人对象。public void insertByOrder(Contact newContact) { Node currentNode head; Node previousNode null; while (currentNode ! null currentNode.contact.getName().compareTo(newContact.getName()) 0) { previousNode currentNode; currentNode currentNode.next; } // 当 currentNode 的姓名比新联系人姓名字典序更大或链表走完时 // 新节点就应该插在 previousNode 与 currentNode 之间 if (previousNode null) { // 新联系人字典序最小直接做新头节点 newContact.next head; head newContact; } else { previousNode.next newContact; newContact.next currentNode; } }这里有个值得说明的细节compareTo返回值小于0表示前一个字符串字典序更小也就是说遍历要继续向右走。等到发现当前节点的姓名字典序已经大于新联系人或者链表遍历完说明找到了正确的插入位置。如果previousNode是空说明要插到最前面这正好利用了链表“修改两个引用就能完成插入”的特性不需要像数组那样搬运一长串元素。2.2 24点游戏把递归穷举做出完整的算法闭环24点扑克牌游戏的考核点不在数据结构本身而在“如何去枚举所有可能的计算组合”。四张牌A到K对应数值1到13要从中选两张做加减乘除运算得到一个中间结果再和剩下两张牌继续组合直到算出24。最容易想到的方案是全排列加括号枚举但用递归实现会简洁得多。这道题的另一个隐藏考点是浮点误差。24点计算中用到除法比如8除以3得到2.666...如果直接用double比较是否等于24几乎不可能精确等于24.0。常规手法是定义一个极小误差范围比如Math.abs(result - 24) 1e-6就判定为成功。这个细节答辩的时候经常被问到也是很值得在代码里写注释说明的地方。2.3 两道题的选型逻辑可以沉淀成一套方法做完这两道题你会意识到一个普遍规律刚拿到一个课程设计题目先不要急着写代码先回答三个问题。第一这个系统的数据模型是静态还是动态会不会频繁插入删除第二对单个操作的速度要求高还是对整体流程的稳定性要求高第三是否需要保持某种有序性。通讯录要保持姓名有序且频繁插入删除所以链表合适24点核心是数组存储的四张牌加递归分支和线性表关系不大重点是算法。这种“先选结构再动手写码”的习惯其实就是课程设计真正想训练的东西。3. 24点扑克牌游戏递归穷举背后的状态管理与去重逻辑3.1 核心框架每次取两张牌计算结果回填数组24点游戏的递归框架并不复杂当前牌组里还有几张牌就从中任选两张做四种运算把结果放回牌组然后递归处理下一轮。当牌组只剩一张牌时判断它是否接近24。这里的关键是“如何处理用过的那两张牌”——很多初写者的翻车点在这里直接从数组里删掉两张再把新结果加进去结果数组长度一变递归的索引就全乱套了。我一般会这样实现用ArrayList存储当前牌值数组每次递归新建一个ArrayList把未选中的牌和一个新计算出的结果放进去这样传给下一层递归的是一个全新的状态不会影响上一层的数据。public boolean canMake24(ListDouble numbers) { if (numbers.size() 1) { return Math.abs(numbers.get(0) - 24.0) 1e-6; } for (int i 0; i numbers.size(); i) { for (int j i 1; j numbers.size(); j) { double a numbers.get(i); double b numbers.get(j); ListDouble remain new ArrayList(); for (int k 0; k numbers.size(); k) { if (k ! i k ! j) { remain.add(numbers.get(k)); } } // 对 a 和 b 做六种运算尝试 double[] results {a b, a - b, b - a, a * b, a / b, b / a}; for (double r : results) { if (Double.isFinite(r)) { remain.add(r); if (canMake24(remain)) { return true; } remain.remove(remain.size() - 1); } } } } return false; }外层的双层循环保证每两张牌的组合都被覆盖i从0开始、j从i1开始避免重复选取同一对牌。六种运算里包含a减b和b减a、a除b和b除a因为减法和除法不满足交换律必须两个方向都试。加上a加b和a乘b之后虽然加法和乘法交换后结果一样但多算一遍也无妨代码更直观。注意remain.remove这里把递归后新加进去的结果删掉是为了让remain对象在下一轮测试不同运算时保持初始状态。// 避免除数为0导致无穷大Double.isFinite 会过滤掉 NaN 与 Infinity if (Double.isFinite(r)) { ... }这段代码特别值得注意的地方在于运算结果r可能是负数、小数甚至无限大但只有有限数才有继续参与下一轮递归的意义。Double.isFinite这个判断是个常见的防御性写法如果牌组里有0或者某次运算产生NaN不过滤掉的话下一步比较Math.abs(... - 24)时会得到false但过程中可能抛出异常或产生不可预知的行为。对我来说这种防御细节就是代码能不能在导师那里过审的关键。3.2 如何只打印出“不重复”的解法表达式很多同学的24点代码能判断“有解”但打印表达式时会输出大量重复结果比如“((ab)c)d”和“(a(bc))d”在数学上等价但写法不同。答辩时被问“你的去重怎么做”是高频现场。资源里没有做完整的表达式去重但我们的目标是让输出结果看起来像人写的解法而不是机器穷举的乱码。一个可行的思路是在递归时同时维护“这个中间结果是怎么算出来的”字符串。比如(ab)得到r1就把字符串“(ab)”传给下一层。当递归回溯时如果下一层已经拼出一个解就往上返回。再去重时比较表达式的“拓扑结构”两个字符串如果只是加法或乘法的结合顺序不同但操作数和运算符本质相同可以考虑只保留一个。更简单且实用的做法是需要一个HashSet记录已经输出的完整表达式字符串遇到重复字符串就跳过。这个方法不完美但足以应付大多数课程设计答辩场景。3.3 界面与规则模块从数组到用户交互游戏部分除了核心算法还要实现发牌、用户输入表达式并校验、计时计分。常见做法是用一个简单控制台菜单循环显示四张牌接收用户输入例如“(A2)*8”解析A为1或14校验牌是否合法和表达式结构是否正确再交给计算模块求值最后比较结果是否等于24。资源里这部分代码的逻辑不难但要注意牌的表示——A、J、Q、K和10分别对应1、11、12、1310是两位数在字符串处理时要特别小心。public int parseCard(String token) { if (token.equals(A)) return 1; if (token.equals(J)) return 11; if (token.equals(Q)) return 12; if (token.equals(K)) return 13; if (token.equals(10)) return 10; return Integer.parseInt(token); }这里容易踩的坑是如果判断顺序写反先判断Integer.parseInt输入“J”就直接抛异常了。所以要先处理A、J、Q、K、10这些非纯数字情况。另外界面输入表达式时有些同学会偷懒只校验表达式算出来的结果是不是24不校验用户是否真的用了给出的四张牌。这会成为答辩老师追问的重灾区“你让用户输入2和3但用户没用到4和5怎么处理”资源里给出的方案是解析表达式时统计每个牌值出现的次数与发牌牌组比对必须完全匹配才允许求值。4. 手机通讯录模拟从排序插入到文件持久化的三层设计4.1 按姓名拼音排序插入的定制化链表通讯录系统最能体现“数据结构和业务结合”的部分就是联系人节点的设计。资源里的联系人Contact类除了name和phone还包含next指针让链表结构可以直接建立在业务对象之上。而维护有序性的核心是上一章那套按姓名compareTo决定插入位置的逻辑。class Contact { String name; String phone; Contact next; public Contact(String name, String phone) { this.name name; this.phone phone; this.next null; } }这个设计有个好处联系人本身是业务对象但链表节点的定义融合在其中不需要再单独做一个“包含数据域和指针域的Node类”。缺点是业务代码和数据结构的耦合度比较高严格来说不算最佳实践但对课程设计这个体量来说胜在直观、便于讲解。如果你想让代码更有层次感可以按“Node类包着Contact业务对象”的方式重构。4.2 通讯录功能菜单与操作状态机通讯录界面的本质是一个死循环循环里的操作码对应不同动作1添加、2删除、3修改、4按姓名查询、5按电话查询、6显示全部、7保存到文件、8退出。每一个功能背后对应链表的一个或一组操作。这里有个细节值得注意删除和修改都需要先定位目标节点而链表的按值删除需要同时记录当前节点和前驱节点否则删不掉头节点。public boolean deleteByName(String name) { if (head null) { return false; } if (head.contact.name.equals(name)) { head head.next; return true; } Contact current head; Contact previous null; while (current ! null !current.contact.name.equals(name)) { previous current; current current.next; } if (current null) { return false; } previous.next current.next; return true; }之所以要在循环外面先判断头节点是因为删除头节点时分两种情况——链表只有这一个节点和链表有多个节点。如果用统一的prev/current方式头节点没有前驱除非引入一个虚拟头节点dummyHead否则写起来比较别扭。这个资源里没有用dummyHead直接分情况处理逻辑虽然多一些但更贴近课本上的经典写法容易在答辩时讲清楚。4.3 文件持久化把链表序列化到文本再读回来通讯录如果退出就丢数据体验会很差所以课程设计要求里多半有“将联系人信息保存到文件”的条目。最简单实用的格式是CSV或自定义分隔符每行一个联系人姓名和电话用逗号隔开。保存时遍历链表把每个节点的name和phone写出去加载时按行读取逐条插入链表保持有序性。public void saveToFile(String filePath) throws IOException { try (BufferedWriter writer new BufferedWriter(new FileWriter(filePath))) { Contact current head; while (current ! null) { writer.write(current.contact.name , current.contact.phone); writer.newLine(); current current.next; } } } public void loadFromFile(String filePath) throws IOException { try (BufferedReader reader new BufferedReader(new FileReader(filePath))) { String line; while ((line reader.readLine()) ! null) { String[] parts line.split(,); if (parts.length 2) { Contact contact new Contact(parts[0], parts[1]); insertByOrder(contact); } } } }用try-with-resources写文件的好处是不管操作成功还是抛出异常writer都会自动关闭不会出现文件锁占用的问题。加载文件时调用insertByOrder而不是直接把节点接到链表尾部可以保证加载后通讯录仍然按姓名有序。这里有个边界点如果文件里的姓名有重复直接插入会出现两个相同姓名的联系人严谨的做法是插入前先查重。资源里的链表实现没有做这个校验但你可以自己加几行代码解决。5. 避坑与常见问题数组越界、浮点精度、类命名冲突的实战排错5.1 24点计算结果一直不等于24但人算明明有解现象手动验证4张牌有解但程序输出false返回无解。原因最常见的是两个。第一计算过程中用了整数除法8除以3得到2而不是2.666...第二用Math.abs(result - 24) 0去比较浮点数导致3885这类有小数参与运算的表达式结果永远差一点点。解决把所有牌的数值都用double存储除法使用浮点数除法比较时使用Math.abs(result - 24) 1e-6这个容差。另外递归时如果某个动态规划数组下标越界八成是ArrayList的索引和原始牌数组的索引混用了检查一下取牌和回填时用的是哪个下标体系。5.2 通讯录插入联系人后顺序是乱的现象明明调用了insertByOrder但打印出来姓名拼音顺序不对。原因compareTo比较的是Unicode码点不是拼音本身。比如“Chai”和“Cai”Unicode比较时是按照C-h-a-i和C-a-i的字母顺序比较而不是拼音规则。另外数据库中如果存储了中文字符compareTo的结果更不是你想象中按拼音排序的。解决如果要求真拼音排序需要引入PinyinUtil之类的工具库或把姓名转为拼音后再做比较。如果只是课程设计可以在Design Document里明确说明“按姓名字符串的字典序排序”这就能自圆其说。很多同学翻车是因为文档里写“按拼音排序”但代码实现其实是“按字典序排序”答辩时被老师指着输出结果一问就露馅了。5.3 联系人电话重复了没有做唯一性约束现象同一个电话号码可以插入多个联系人导致查询时返回多个结果界面逻辑混乱。原因资源里的insertByOrder只校验了姓名插入位置没有对手机号做唯一性校验。这不是bug是需求里没写。解决如果课程设计要求“手机未实名不可重复”在insertByOrder中加一步遍历查询发现相同phone就拒绝插入并提示。也可以在修改联系人时同步校验新电话是否已存在。加这种约束时注意不能用phone作为链表的有序键否则会打乱按姓名排序的既定顺序。5.4 24点界面输入的牌面大小写不一致导致程序崩溃现象用户输入小写的a代替A或输入“j”代替“J”程序抛NumberFormatException。原因parseCard只处理了大写字母没有处理小写字母或者用户输入了类似“face”这样的无效文本。解决在parseCard的开头统一调用toUpperCase()把输入转为大写后再匹配。同时对无效输入捕获异常返回-1或null由界面层提示“输入不合法请重新输入”。这是几乎所有用户交互型课程设计都会遇到的问题——不要假设用户会按你预期的格式输入假设越少程序越稳。5.5 文件保存后重新加载联系人顺序错乱现象保存到文件时通讯录是有序的重新loadFromFile之后顺序和保存前不一样。原因保存时遍历链表按序遍历写出的文件天然有序加载时按行读入如果调用的是直接尾部追加的add方法而不是insertByOrder顺序就会变成文件原样顺序。文件里顺序虽然是对的但加载后没有走排序插入逻辑。解决确认loadFromFile内部调用的是insertByOrder方法而不是简单的add。如果要更严谨可以在加载完成后对整个链表执行一次排序。另一个隐藏坑是加载时重复调用insertByOrder但链表头节点在加载前还没清空导致旧数据和新数据交错加载前必须先清空链表。6. 不用Git也能做的版本管理课程设计项目的“后悔药”技巧你可能会想课程设计不过是个小项目有必要用版本管理吗我的经验是越小的项目越容易“改崩了回不去”。一个下午改了30行代码结果原来能跑的功能全炸了又忘了改之前是什么样的只能从头重写——这种经历一次就能让人记住版本管理的价值。但很多同学的课程设计环境里没有Git或者不想花时间学命令行。没关系我自己积累了一套轻量级方案专门用来防止“手滑改崩”。核心思路很简单每次改代码前把当前可运行版本的整个项目目录复制一份命名为带日期的备份文件夹。比如你有一个名为PhoneBook的Java项目就在旁边创建一个PhoneBook_backup_20241015这样的目录把源代码文件copy过去。这个做法的颗粒度足够支撑课程设计——你只需要在两个关键节点备份第一次跑通基本功能时以及每个功能模块完成后。用命令行做这件事比IDE里的复制粘贴更快# 进入项目所在目录的上一级 cp -r PhoneBook PhoneBook_backup_20241015_v1 # v1跑通了开始做24点游戏模块 cp -r CardGame CardGame_backup_20241015_v1之所以不用压缩包是因为压缩和解压都要额外等待而直接复制文件夹可以随时进入备份目录运行java命令测试。如果改崩了把备份目录复制回来覆盖当前目录即可。需要留意的一点是不要在System.out.println输出里加入带时间戳的调试信息否则每次备份后diff结果都不同很难定位真正新增的代码。更进一步的做法是在main函数入口加一个运行时的参数开关比如debugMode用来控制是否打印调试信息。平时跑功能时debugMode设为false代码和输出都是干净的当需要排查问题时再开启debugMode这样备份的“干净版本”不会被调试输出污染。public static void main(String[] args) { boolean debugMode false; // 可从命令行参数解析例如 java Main --debug for (String arg : args) { if (arg.equals(--debug)) { debugMode true; } } // 后续代码根据 debugMode 决定是否打印详细状态 }这个习惯帮我避过一次大坑。做24点界面时我把printRecord输出逻辑从System.out改成了写日志文件结果改到一半发现文件写入把原来的牌面输出全吞了界面瞬间变成黑窗。幸好前一天有备份整个项目恢复到改动前的状态我又花20分钟把日志模块的代码梳理清楚才继续动工。从那以后我每次动手改一个模块之前都强制自己先看一眼“这次改动如果失败了能不能用上一次备份恢复”想不清楚就暂时不动手先写一个实现方案草稿。这份资源里的两份源码虽然不大但值得你用同样的方式去对待——跑通第一遍后立刻备份一份“通版”之后所有的个性化修改都基于这个版本进行。希望这份拆解与代码注释能帮你在课程设计中少走几步弯路把精力用在真正能锻炼数据结构思维的地方。本文还有配套的精品资源点击获取