Java数据结构课设实战:通讯录管理+24点穷举算法全解析
简介这是一份面向高校计算机相关专业学生的数据结构课程设计资源以Java语言实现两个经典实训项目手机通讯录模拟与24点扑克牌游戏。前者利用ArrayList/LinkedList/HashMap等容器完成联系人的增删改查后者通过递归、回溯、深度优先搜索及栈结构穷举四张扑克牌的运算组合充分体现链表、哈希表、树、优先队列等数据结构在真实场景中的运用并涵盖排序、查找、去重等常见操作技巧。资源包共73个文件压缩后仅431KB包含5个Java源码文件、53张运行效果截图、8个XML配置、5个class编译产物以及HTML说明文档目录按项目工程结构组织便于按模块查阅源码与效果图。已有652人学习浏览。整套资源以完整可运行的项目为例覆盖从需求分解、数据存储结构选型到递归搜索算法实现的完整链路适合课程设计参考、实验报告撰写和答辩准备。1. 数据结构课程设计要解决的两件事通讯录数据组织与24点穷举判题第一次拿到这个题目时很多同学的第一反应是“这不就是两个小游戏嘛”结果一到写设计文档就卡住了。手机通讯录模拟的核心不是增删改查界面而是“联系人按什么结构存、按什么索引找最快、分组显示时怎么不丢数据”24点扑克牌游戏的核心也不是随机发牌而是“四个数字、三种运算符、五种括号形态怎么穷举才能不重不漏地算出 24”。这两道题放在同一个课程设计里本质是在考察一件事你能不能根据数据的访问模式选出合适的数据结构和算法。这套方案面向的是正在准备 Java 数据结构课设的同学也适合想快速搭出可演示、可答辩、可验收完整项目的开发者。下文从需求、设计、实现到答辩逐层拆开讲。2. 需求分析与技术选型功能清单、非功能约束与 Java 集合框架的取舍2.1 功能需求通讯录八项操作与24点四种计算模式把题目拆成功能点是写设计文档的第一步也是答辩时老师一定会翻的第一页。通讯录模拟这边我按常见的课设评分标准整理出八项必做功能添加联系人、删除联系人、修改联系人、按姓名查找、按电话查找、按首字母分组显示、按姓名排序、文件保存与读取。其中“按首字母分组显示”是这道题区别于一般增删改查系统的关键点它要求程序能把“张三”自动归到 Z 组能统计每组人数还能在 A 到 Z 之间空组时给出明确的空提示。24点游戏这边常见要求可以收敛为四种模式随机发牌、玩家输入表达式并判题、程序穷举输出全部解、无解时自动换牌。随机发牌要注意“四张牌允许重复”这是很多同学容易忽略的规则细节玩家判题需要支持加减乘除和嵌套括号程序穷举则是整个课设里算法密度最高的部分它同时涉及全排列、运算符组合和括号结构三层枚举。把功能列表先写清楚后面的数据结构和代码实现才有据可依。2.2 非功能需求与运行环境约束非功能需求决定了方案的上限。这道题在课设场景下至少要满足三个约束判题必须容忍浮点误差因为 1/3、1/7 这类运算在 double 下无法精确表示通讯录数据在程序重启之后不能丢所以要落文件程序的入口和菜单必须是循环结构用户输错不能直接崩溃。运行环境上建议统一为 JDK 8 以上、纯控制台实现不强制引入 Swing。控制台版本虽然界面朴素但评审老师能直接看到每个数据结构在运行时的行为反而更容易讲清楚设计意图。这里有一个常见的选型分歧通讯录要不要用数据库我的建议是课设阶段不用。一方面MySQL 会把“数据结构”考察点稀释成“SQL 写得熟不熟”另一方面单机课设引入数据库会增加环境配置成本答辩现场连不上库就是事故。文件存储足够展示数据持久化能力而且能把 HashMap、LinkedList 这些集合类的序列化细节暴露给学生这恰恰是加分项。2.3 技术选型为什么是 Java 集合框架 文件存储技术选型要从“数据访问模式”倒推而不是从“我会什么”正推。通讯录的访问模式可以概括为按名字查、按电话查、按首字母查全部、按顺序遍历。HashMap 的查找复杂度是 O(1)非常适合精确匹配LinkedList 适合在分组内做频繁插入删除ArrayList 适合存储全量数据并支持按索引随机访问。三者组合使用比单一数据结构覆盖全部场景更合理。24点的访问模式则是“穷举 回溯”适合用递归实现。递归在这里不是炫技而是因为表达式天然是一棵二叉树——每个运算符把两个操作数合并成一棵子树。使用 Java 集合框架里的 HashSet 做表达式去重能避免同一组牌因为数字交换顺序产生重复解。文件存储用 CSV 格式即可不要用 Java 原生序列化因为 CSV 可以直接用文本编辑器检查答辩时打开文件就能看到数据说服力更强。以下交付物清单是这类课设的标准配置可以在开题阶段就交给老师确认。交付物内容说明验收关注点源码工程Java 控制台工程含两个独立模块编译无警告、可一键运行设计说明文档需求分析、数据结构设计、算法流程、测试用例章节完整、图文对应演示脚本按固定步骤录屏或现场演示覆盖全部功能和边界用例3. 总体架构设计三层模块划分与核心数据结构的选型依据3.1 总体结构界面、业务、数据三层的调用关系整个项目采用三层结构界面层、业务逻辑层、数据层。界面层只负责菜单打印、读入用户输入和结果展示不写任何算法业务逻辑层封装通讯录的增删改查和24点的穷举判题是数据结构的主要载体数据层负责 CSV 文件的读取与写出。三层之间用接口衔接这样答辩时可以说“界面层完全不感知底层是文件存储还是数据库”体现模块化设计意识。调用关系用文字描述就是用户在控制台选择菜单 → 界面层调用业务层对应方法 → 业务层操作数据层结构或递归算法 → 返回结果给界面层打印。两道题共用一个主菜单通过“1.通讯录 2.24点 0.退出”切换两个子系统之间不共享数据避免耦合。工程目录建议按view、service、model、store四个包组织类名要能直接看出职责比如AddressBookService、Game24Solver、ContactStore。3.2 通讯录的数据结构选型哈希表分组与顺序表存储的搭配通讯录模块我采用“ArrayList 两个 HashMap 索引”的组合设计。全量联系人存在ArrayList里保证遍历和排序方便第一个HashMapCharacter, LinkedListContact以姓名首字母为键存储每个字母分组下的联系人链表第二个HashMapString, Contact以电话号码为键提供 O(1) 精确查找。为什么分组用 LinkedList 而不是 ArrayList因为删除某个联系人时需要从分组中摘除链表删除只要改指针而顺序表删除需要搬移后续元素。这个组合的核心思想是“一份数据多个索引”。数据本体存在ArrayList中首字母索引和电话索引都只保存引用不复制数据。添加联系人时三个结构同步更新删除联系人时三个结构必须同步删除。这带来的代价是写操作变复杂但换取了读操作的高效符合通讯录“查找频繁、写入较少”的真实使用场景。答辩时如果老师问“为什么不直接用 TreeMap 按字母排序”可以回答TreeMap 能排序但不能按“字母分组”直接返回一个独立的组分组展示需要按组遍历HashMap 在分组读取上更直接。3.3 24点的数据结构选型递归表达式树与运算序列枚举24点的本质是从四个数字出发不断将两个数合成一个数最终得到一个数并判断是否为 24。这个“合成”过程正好对应表达式二叉树叶子节点是数字内部节点是运算符每个内部节点把左右子树的值算出来再往上传递。因此求解器不需要显式构造树对象只需要用数组模拟“剩余数字池”每次递归取两个数合并递归深度从 4 降到 1 时判断结果。这种“选两数合并”的递归写法比枚举固定括号模板更完备。固定模板通常只写三种形态比如((a op b) op c) op d、a op (b op (c op d))、(a op b) op (c op d)但并没有证明这三个模板覆盖了全部二叉树形态而合并法天然遍历所有可能的二叉树结构不会漏解。这里还需要两个辅助结构一个是 double 数组保存中间计算结果另一个是 String 数组同步保存对应的表达式文本。两个数组在递归中同步裁剪最终输出解时才不至于只给一个“能算出”的结论而说不清计算过程。4. 核心实现通讯录哈希索引与24点递归穷举的完整代码4.1 通讯录核心实现联系人模型与哈希索引的同步维护先定义联系人模型。这里有一个很容易翻车的设计点ID 才是主键姓名不唯一电话号码在真实场景下虽然可以近似唯一但课设里用户可能输错重复电话因此电话索引存储时要用“覆盖写”还是“拒绝写”要提前定好。我选择 ID 自增为主键电话索引遇到重复时拒绝添加并提示避免静默覆盖数据。public class Contact { private final int id; // 主键自增用于精确定位 private String name; // 姓名允许重复 private String phone; // 手机号索引唯一 private String email; // 邮箱可空 private char initial; // 姓名首字母自动计算 public Contact(int id, String name, String phone, String email) { this.id id; this.name name; this.phone phone; this.email email; this.initial Character.toUpperCase(name.charAt(0)); } // getter / setter 省略 }接着是通讯录服务类。核心是三个结构的一致性问题添加时三个结构都要写入删除时三个结构都要移除查询时按场景选择最合适的索引。以下代码展示添加与删除的同步维护逻辑public class AddressBookService { private final ListContact allContacts new ArrayList(); private final MapCharacter, ListContact initialIndex new HashMap(); private final MapString, Contact phoneIndex new HashMap(); private int nextId 1; public boolean addContact(String name, String phone, String email) { if (phoneIndex.containsKey(phone)) { return false; // 电话冲突拒绝写入 } Contact c new Contact(nextId, name, phone, email); allContacts.add(c); // computeIfAbsent该字母分组不存在则先创建链表再追加 initialIndex .computeIfAbsent(c.getInitial(), k - new LinkedList()) .add(c); phoneIndex.put(phone, c); return true; } public boolean deleteById(int id) { Contact target null; for (Contact c : allContacts) { if (c.getId() id) { target c; break; } } if (target null) return false; allContacts.remove(target); initialIndex.get(target.getInitial()).remove(target); phoneIndex.remove(target.getPhone()); return true; } public ListContact findByInitial(char ch) { return initialIndex.getOrDefault(ch, Collections.emptyList()); } public Contact findByPhone(String phone) { return phoneIndex.get(phone); } }这段代码里最关键的不是增删本身而是initialIndex和phoneIndex的同步维护。删除时如果漏掉phoneIndex.remove就会出现“删除之后按电话还能查到”的诡异现象这种 bug 在答辩演示时被发现会非常尴尬。另一个设计点是computeIfAbsent它把“判断分组是否存在 创建链表 添加元素”三步缩成一步避免先get再判断空指针的繁琐写法。电话冲突时返回false而不是直接覆盖是为了保证索引的唯一性约束。4.2 通讯录核心实现分组展示与 CSV 文件持久化分组展示要输出 A 到 Z 全部 26 组空组也要显示“无联系人”这样界面不会显得像出了 bug。排序直接用ArrayList提供的sort方法按姓名拼音排序需要用到中文转拼音工具课设里如果不想引第三方库可以退化为按姓名字符串的字典序排序并在文档里注明简化方案。文件持久化采用 CSV 格式写入时显式指定 UTF-8 编码这是很多同学踩过坑的地方——直接FileWriter写中文打开文件看到乱码原因就是用了平台默认编码。public void saveToFile(String path) throws IOException { try (BufferedWriter writer new BufferedWriter( new OutputStreamWriter(new FileOutputStream(path), StandardCharsets.UTF_8))) { writer.write(id,name,phone,email,initial); writer.newLine(); for (Contact c : allContacts) { writer.write(String.format(%d,%s,%s,%s,%c, c.getId(), c.getName(), c.getPhone(), c.getEmail() null ? : c.getEmail(), c.getInitial())); writer.newLine(); } } }保存文件时要注意两点第一try-with-resources可以保证文件流一定关闭避免资源泄漏第二联系人中间如果有逗号或换行符直接拼 CSV 会破坏列结构稳妥的做法是加载时按行读取并用split(,, -1)解析但字段本身要提前约定不允许包含逗号。读取文件后必须重建nextId否则重启程序后新增联系人的 ID 会从 1 重新计数和旧数据冲突。这个细节体现了对数据生命周期的完整考虑写进设计文档会很加分。4.3 24点核心实现选两数合并的递归穷举与浮点容差24点的求解器是整个项目里最有算法分量的部分。输入四张牌的牌面数字输出所有能算出 24 的表达式。这里采用双数组递归法nums保存当前剩余数字exprs保存每个数字对应的表达式字符串每次从剩余数字中选一对尝试六种运算加法、左减、右减、乘法、左除、右除把结果放回数组末尾继续递归。public class Game24Solver { private static final double EPS 1e-6; private final SetString solutions new HashSet(); public SetString solve(int[] cards) { solutions.clear(); double[] nums new double[4]; String[] exprs new String[4]; for (int i 0; i 4; i) { nums[i] cards[i]; exprs[i] String.valueOf(cards[i]); } dfs(nums, exprs, 4); return solutions; } private void dfs(double[] nums, String[] exprs, int n) { if (n 1) { // 浮点误差必须用容差判断不能直接 24 if (Math.abs(nums[0] - 24) EPS) { solutions.add(exprs[0]); } return; } double[] nextNums new double[n - 1]; String[] nextExprs new String[n - 1]; for (int i 0; i n; i) { for (int j i 1; j n; j) { double a nums[i], b nums[j]; String ea exprs[i], eb exprs[j]; int k 0; for (int t 0; t n; t) { if (t ! i t ! j) { nextNums[k] nums[t]; nextExprs[k] exprs[t]; k; } } // 六种运算加、左减、右减、乘、左除、右除 double[] vals { a b, a - b, b - a, a * b, (Math.abs(b) EPS) ? a / b : Double.NaN, (Math.abs(a) EPS) ? b / a : Double.NaN }; String[] symbols {, -, -, *, /, /}; String[] orders { ( ea eb ), ( ea - eb ), ( eb - ea ), ( ea * eb ), ( ea / eb ), ( eb / ea ) }; for (int m 0; m 6; m) { if (Double.isNaN(vals[m])) continue; // 跳过除零 nextNums[n - 2] vals[m]; nextExprs[n - 2] orders[m]; dfs(nextNums, nextExprs, n - 1); } } } } }这段代码的效率足够课设使用最坏情况下的递归调用数约 4 选 2 乘 6 再乘 3 选 2 乘 6即 2592 次毫秒级完成。EPS容差是判题正确性的关键float 和 double 在计算机里是二进制浮点数像 1/3 这种循环小数无法精确表示累积计算后可能出现 23.999999999999996。误差阈值取 1e-6 即可保证既能识别真正的 24又不会把 24.000001 误判成解。除零判断也用了EPS而不是 0因为中间结果可能出现 0.0000000001 这种近似零值如果直接判b 0会漏掉真正的除零保护。4.4 24点核心实现玩家表达式判题与无解处理玩家输入表达式时程序要按中缀表达式求值。实现思路是“中缀转后缀 栈计算”用两个栈分别保存运算符和操作数遇到数字入操作数栈遇到运算符弹出高优先级的运算符处理遇到左括号压栈、右括号一直弹出到左括号。这个实现过程本身就是数据结构栈的典型应用答辩时可以直接指出来。public boolean checkPlayerExpression(String expr, int[] cards) { expr expr.replace( , ); DequeDouble numbers new ArrayDeque(); DequeCharacter ops new ArrayDeque(); int i 0; while (i expr.length()) { char c expr.charAt(i); if (Character.isDigit(c)) { int num 0; while (i expr.length() Character.isDigit(expr.charAt(i))) { num num * 10 (expr.charAt(i) - 0); i; } numbers.push((double) num); continue; } if (c () { ops.push(c); } else if (c )) { while (!ops.isEmpty() ops.peek() ! () { calcTop(numbers, ops); } ops.pop(); // 弹出左括号 } else if (-*/.indexOf(c) 0) { // 当前运算符优先级不高于栈顶时先算栈顶 while (!ops.isEmpty() precedence(ops.peek()) precedence(c)) { calcTop(numbers, ops); } ops.push(c); } i; } while (!ops.isEmpty()) calcTop(numbers, ops); return Math.abs(numbers.pop() - 24) EPS; }这个判题逻辑有一个隐藏要求玩家输入的数字必须跟发出的牌一致。如果玩家随便输入 1111 也算出 4判题器会误判为“不等于24”但更严谨的做法是先把表达式里的数字提取出来和发出的四张牌排序后比较数字不一致直接提示“输入的数字与牌面不符”。这个校验要在表达式求值之前做防止玩家钻空子。无解处理上程序穷举之后如果solutions为空界面层提示“这组牌无解按任意键换一组”并自动重新发牌保证演示过程不卡壳。5. 常见问题与避坑指南浮点误差、括号遗漏、重名覆盖等五个翻车点5.1 浮点误差导致“能算出24却被判错”现象手动计算 8/(3-8/3) 等于 24程序却输出“不能算出24”或者玩家输入合法表达式被判定为错误。原因是 double 类型无法精确表示 1/38/3 参与多步运算后结果可能是 23.999999999999996直接用 24比较必然失败。解决方法是引入误差阈值所有判等统一写成Math.abs(result - 24) 1e-6同时穷举器里对除零的判定也要用Math.abs(divisor) EPS而不是divisor 0因为中间结果可能是一个极小的非零值直接判零会放跑真正的除零错误。5.2 括号结构枚举不全漏掉对半组合现象程序能算出部分解但明显少解比如 1,3,4,6 这组牌明明有多种解法程序只输出一种。翻车原因通常是代码用了固定的括号模板只枚举了((a op b) op c) op d这一种左结合结构漏掉了(a op b) op (c op d)这种对半结构。解决方法是放弃模板枚举改用“每次选两个数合并”的递归写法让算法自己产生全部二叉树形态。一个验证办法是拿这组最刁钻的数据测试1,5,5,5 的解法是 (5-1/5)*5它既不是纯左结合也不是纯右结合任何模板枚举法都要仔细核对才能覆盖。5.3 通讯录重名覆盖数据静默丢失现象添加了两个同名的联系人删除其中一人后另一个也找不到了。原因是把姓名当成了 HashMap 的键重名时后写入的覆盖先写入的索引里只剩一条记录。解决方法是引入自增 ID 作为主键电话作为唯一索引姓名索引改成MapString, ListContact存储同名联系人列表。删除时按 ID 定位数据本体再从电话索引和姓名索引中同步移除。这个教训可以延伸到设计文档里凡是用户可重复的属性都不能当主键。5.4 文件写入后中文乱码现象通讯录保存到 CSV 文件重新打开后中文全部变成乱码。原因是FileWriter默认使用操作系统的平台编码在中文 Windows 上通常是 GBK而读取时用 UTF-8 解析就全错位了。解决方法是写入和读取都显式指定StandardCharsets.UTF_8用OutputStreamWriter包一层FileOutputStream。另一个关联坑是 CSV 字段里出现逗号或换行会破坏列对齐建议在写入前对字段做校验拒绝包含逗号或换行的输入。5.5 菜单循环读入非法输入导致程序崩溃现象主菜单输入一个字母程序直接抛出InputMismatchException退出。原因是Scanner.nextInt()遇到非数字输入会抛异常且不消费掉非法字符下次循环继续抛。解决方法是先用hasNextInt()预检或者捕获异常后调用scanner.next()清空缓冲区。菜单循环的结构建议是“显示菜单 → 读入 → 分发 → 回到菜单”非法输入走continue重新显示菜单24点子菜单里的“换牌”和“返回主菜单”也要能正常回到上一层避免出现“进去了出不来”的死循环。6. 验收方法与答辩技巧从演示脚本到边界用例的完整准备6.1 演示脚本先功能后边界覆盖全部评分点答辩演示的顺序决定了老师的第一印象。我一般会让演示脚本按“通讯录完整操作 → 24点玩家模式 → 24点穷举模式 → 文件持久化验证”四段走。通讯录段从添加三个联系人开始其中两个故意取同名、一个取英文名展示重名不覆盖、首字母分组正确然后做按电话查找、修改、删除再切到分组浏览让老师看到 Z 组下出现刚才添加的联系人。24点玩家模式输入 (5-1/5)*5先展示判题通过穷举模式再跑同一组牌对比玩家解和解法器输出的一致性。最后重启程序打开通讯录菜单直接看到上次保存的数据还在。6.2 答辩高频问题与回答要点老师最常问的问题集中在四个方向为什么用 HashMap、哈希冲突怎么办、递归会不会栈溢出、浮点数怎么判断相等。回答 HashMap 时要突出 O(1) 查找和分组折叠哈希冲突要答“拉链法冲突链用链表”递归深度这个问题要主动说“四张牌最多递归合并三次深度为 3不存在栈溢出如果把规则扩展成 10 张牌才需要考虑迭代改造”浮点数判等必须带出EPS1e-6这个细节。建议把验证用例写进文档1,5,5,5 和 3,3,8,8 这两个经典边界一定要能算出 241,1,1,1 这样的无解组要能触发“无解换牌”分支。答辩前还有一道工序值得做把自己当用户把所有菜单选项全部乱点一遍输入字母、输入空字符串、输入超长电话、输入负数保证程序不崩溃。我第一次带课设评审时就有同学在演示删除联系人时输入了一个不存在的 ID程序直接空指针退出当场被扣了稳定性分数。从那以后我每次提交前都强制走一遍“非法输入遍历”清单把每个输入点都变成兜底提示而不是崩溃点。这套准备方法不只能帮你拿高分更能让你在回答“如果用户乱输怎么办”时底气十足希望帮到你。本文还有配套的精品资源点击获取