Java课程设计实战:通讯录模拟与24点扑克牌完整代码拆解
简介:这是一份基于Java实现的数据结构课程设计资源,面向高校计算机相关专业学生及需要完成课设的开发者。项目选取手机通讯录模拟与24点扑克牌游戏两个经典场景,分别演示链表、动态数组、哈希表、Set去重、递归/回溯算法以及栈等核心结构在实际业务中的落地方式,同时涉及增删改查、表达式穷举与结果判定等关键功能设计。压缩包为zip格式,共73个文件,其中包含53张运行效果或界面截图(png)、8个工程配置与元数据文件(xml)、5个源码文件(java)、5个编译产物(class)以及模块描述文件(iml)和说明页(html),便于对照代码理解执行流程。资源包整体约431KB,轻量易用,已有649人参与学习下载。通过源码、截图与工程结构的配合,读者可快速掌握面向对象建模、集合选型与搜索策略优化等课设核心环节,适合作为项目参考或设计思路蓝本。
1. 一份能直接写的Java课设:通讯录模拟与24点扑克牌的完整拆解
数据结构这门课卡住很多人的不是理论,而是“老师布置的课设到底怎么写代码”。手机通讯录模拟和24点扑克牌游戏是数据结构课程设计里出现频率最高的两个题,恰好覆盖线性表、递归、栈、队列这些核心考点。这套资源不是让你背代码,而是把两个题目从数据结构选型到代码实现、再到期末答辩可能被追问的问题,全部拆开来讲清楚。适合正在做课设的本科生,也适合想拿这两个题目练手、按代码反推数据结构原理的自学者。下面按我的实际开发顺序,一套套拆给你看。
2. 手机通讯录模拟:顺序表、链表与查找排序的代码骨架
2.1 先定数据结构:通讯录用顺序表还是链表
通讯录模拟题目的核心要求是增删改查,外加按姓名排序、按电话号码查找。大部分课设文档会建议用线性表,但线性表有两种实现方式,选错会影响后续写代码的复杂程度。
顺序表(ArrayList)在内存中连续存储,按下标访问是O(1),插入和删除需要移动元素。链表(LinkedList)则相反,插入删除快,但查找需要遍历。通讯录的特点是“添加联系人”和“删除联系人”频繁,且通常不会超过几百条记录。在数据量小的场景下,两种结构性能差异可以忽略,真正需要权衡的是代码的书写难度和老师答辩时的提问点。
我一般建议用顺序表实现,理由有三点:第一,课设代码量少,ArrayList的底层逻辑简单,不容易写出内存访问越界的bug;第二,按姓名排序时可以直接调Collections.sort(),避免自己实现链表排序的复杂节点交换;第三,老师追问“为什么用顺序表而不是链表”时,你有足够理由——数据量小、查询需求多于插入删除。如果老师的题目明确要求必须用链表,那就在节点类里加上prev和next指针,多写一个双向链表类,否则不要自找麻烦。
2.2 联系人对象设计:字段别乱加,够用就行
通讯录的每个条目对应一个联系人对象。很多新手上来就加十几个字段,结果排序、查找、文件读写都变得繁琐。课程设计的评分标准不是字段多,而是逻辑清晰。我这里给出一个稳妥的字段方案。
public class Contact implements Serializable { private String name; // 姓名 private String phone; // 手机号 private String email; // 邮箱 private String groupName; // 分组:家人/朋友/同事 public Contact(String name, String phone, String email, String groupName) { this.name = name; this.phone = phone; this.email = email; this.groupName = groupName; } // 必须重写toString(),方便直接打印联系人信息 @Override public String toString() { return "姓名:" + name + " | 电话:" + phone + " | 邮箱:" + email + " | 分组:" + groupName; } }这个类的设计有几个讲究之处:实现Serializable接口是为了后续做文件对象流存储,不实现的话,写ObjectOutputStream时会直接抛NotSerializableException。toString()必须重写,因为无论是显示联系人列表还是调试输出,都依赖这个方法。groupName字段不是必须的,但加上分组功能后,课设的功能列表会多一个“按分组筛选”,答辩时更好讲。
2.3 通讯录管理器:把增删改查封装成类
通讯录的核心业务逻辑应该单独放在一个管理类中,不要在main方法里堆操作。下面这代码覆盖了最常见的需求:添加、删除、修改、按姓名查找、按电话查找、按姓名排序。
import java.util.*; public class AddressBook { private List<Contact> contacts; public AddressBook() { contacts = new ArrayList<>(); } // 添加联系人 public boolean addContact(Contact c) { if (c == null || c.getName().isEmpty() || c.getPhone().isEmpty()) { System.out.println("姓名和电话不能为空"); return false; } if (findByPhone(c.getPhone()) != null) { System.out.println("该号码已存在,添加失败"); return false; } contacts.add(c); return true; } // 按姓名精确查找 public Contact findByName(String name) { for (Contact c : contacts) { if (c.getName().equals(name)) { return c; } } return null; } // 按电话号码查找 public Contact findByPhone(String phone) { for (Contact c : contacts) { if (c.getPhone().equals(phone)) { return c; } } return null; } // 删除联系人(按姓名删) public boolean deleteByName(String name) { Contact target = findByName(name); if (target == null) { System.out.println("未找到该联系人"); return false; } contacts.remove(target); return true; } // 按姓名排序:用Collections.sort,底层是归并排序 public void sortByName() { Collections.sort(contacts, new Comparator<Contact>() { @Override public int compare(Contact o1, Contact o2) { return o1.getName().compareTo(o2.getName()); } }); System.out.println("排序完成"); } }添加联系人时做了两件事:空值校验和电话查重。很多课设代码只做非空判断,忽略电话重复的问题,结果用同一个号码添加两次,后续查找时返回的对象不确定,这是答辩时的减分项。findByPhone和findByName本质都是线性查找,时间复杂度O(n)。如果老师要求“查找效率更高”,可以扩展为哈希表——按电话号码的末几位建哈希索引,但那是加分项,先把线性查找写对。
2.4 文件持久化:对象流与文本文件的选择
通讯录程序关闭后数据不能丢,必须做持久化。常见的做法有两种:对象流序列化和文本文件读写。对象流的代码更简洁,但生成的二进制文件不适合老师直接查看;文本文件可读性强,但需要自己处理字符串解析。我建议两个都写,先存成文本文件验证逻辑,再改造为对象流。
import java.io.*; public class FileUtil { // 用对象流保存联系人列表 public static void saveContacts(List<Contact> list, String path) { try (ObjectOutputStream oos = new ObjectOutputStream( new FileOutputStream(path))) { oos.writeObject(list); System.out.println("保存成功:" + path); } catch (IOException e) { e.printStackTrace(); } } @SuppressWarnings("unchecked") public static List<Contact> loadContacts(String path) { File f = new File(path); if (!f.exists()) { System.out.println("文件不存在,返回空列表"); return new ArrayList<>(); } try (ObjectInputStream ois = new ObjectInputStream( new FileInputStream(path))) { return (List<Contact>) ois.readObject(); } catch (IOException | ClassNotFoundException e) { e.printStackTrace(); return new ArrayList<>(); } } }try-with-resources写法是Java 7以后的推荐做法,相比手动close()更不容易泄露资源。loadContacts中判断文件是否存在非常关键——第一次运行程序时文件还不存在,如果不判断直接readObject(),会抛出FileNotFoundException,程序直接崩溃。反序列化时强制类型转换需要加@SuppressWarnings("unchecked"),否则编译期会有警告提示,虽然不影响运行,但被老师看到会扣代码规范的分。
2.5 主菜单逻辑:while循环加switch是最稳定的组合
主程序用死循环加switch做菜单选择,这是控制台课设最常规的写法。需要注意的点是Scanner的nextLine()和nextInt()混用时会吞换行符,导致后续输入被跳过。常见解决方案是统一用nextLine()读取整行,然后手动做类型转换。
public static void main(String[] args) { AddressBook book = new AddressBook(); List<Contact> exist = FileUtil.loadContacts("contacts.dat"); // 将文件中的联系人导入到当前通讯录 for (Contact c : exist) { book.addContact(c); } Scanner sc = new Scanner(System.in); while (true) { System.out.println("1.添加联系人 2.删除联系人 3.修改联系人 4.查找联系人 5.显示全部 0.退出"); String input = sc.nextLine(); int choice; try { choice = Integer.parseInt(input); } catch (NumberFormatException e) { System.out.println("请输入数字"); continue; } switch (choice) { case 1: System.out.print("输入姓名:"); String name = sc.nextLine(); System.out.print("输入电话:"); String phone = sc.nextLine(); System.out.print("输入邮箱:"); String email = sc.nextLine(); System.out.print("输入分组:"); String group = sc.nextLine(); book.addContact(new Contact(name, phone, email, group)); break; case 2: System.out.print("输入要删除的姓名:"); book.deleteByName(sc.nextLine()); break; case 0: FileUtil.saveContacts(book.getAllContacts(), "contacts.dat"); System.out.println("已保存,再见"); return; default: System.out.println("无效选项"); } } }Integer.parseInt(input)处理菜单输入而不是直接用nextInt(),是避免换行符问题最稳妥的做法。addContact内部已经有重复号码校验,这里不需要重复判断。退出时先保存再return,确保数据不丢。如果老师要求每次操作后自动保存,可以把saveContacts放到switch的每个case分支里,但那样频繁写磁盘,性能不必要地下降。
3. 24点扑克牌游戏:递归回溯与表达式输出的核心实现
3.1 理解题目本质:为什么说24点考的是递归思想
24点游戏的规则是:从一副扑克牌中随机抽取4张牌,每张牌的点数在1到13之间(J/Q/K按11/12/13算),用加、减、乘、除四种运算以及括号,使计算结果等于24。每张牌必须且只能用一次。
从数据结构角度看,这个题目有两个考点:第一,如何遍历所有可能的运算组合——这需要递归回溯;第二,如何输出带有括号的合法表达式——这是字符串拼接的逻辑。很多同学卡在第二点上,递归找出了24,但输出的表达式是错的,比如“1 + 2 × 3 + 4”缺少括号。
看问题的本质:4个数和3个运算符,任意排列组合,加上括号优先级,总组合数是有限的。暴力枚举的复杂度大约是C(4,1)×C(4×3的排列),在10万次量级,完全可以在毫秒级跑完。所以不需要什么启发式算法,直接递归遍历所有可能,找到第一个解就停止。
3.2 递归核心:每次合并两个数,缩小问题规模
传统递归思路是把4个数中任意取两个,做加减乘除运算,得到一个结果后,将结果与剩下两个数组成新的3个数列表,递归继续,直到只剩一个数。如果这个数接近24,则说明找到解。
import java.util.*; public class TwentyFourGame { // 保存当前递归过程中的表达式路径 private static final int TARGET = 24; private static final double EPSILON = 1e-6; public static List<String> solve(int[] nums) { List<Double> numbers = new ArrayList<>(); List<String> expressions = new ArrayList<>(); for (int n : nums) { numbers.add((double) n); expressions.add(String.valueOf(n)); } boolean found = dfs(numbers, expressions); return found ? expressions : null; } private static boolean dfs(List<Double> nums, List<String> exprs) { int size = nums.size(); if (size == 1) { // 浮点数比较不能直接用==,要允许误差 return Math.abs(nums.get(0) - TARGET) < EPSILON; } // 枚举所有两两组合 for (int i = 0; i < size; i++) { for (int j = 0; j < size; j++) { if (i == j) continue; // 取出两个数及对应的表达式 double a = nums.get(i); double b = nums.get(j); String exprA = exprs.get(i); String exprB = exprs.get(j); List<Double> nextNums = new ArrayList<>(); List<String> nextExprs = new ArrayList<>(); for (int k = 0; k < size; k++) { if (k != i && k != j) { nextNums.add(nums.get(k)); nextExprs.add(exprs.get(k)); } } // 加法:a+b nextNums.add(a + b); nextExprs.add("(" + exprA + "+" + exprB + ")"); if (dfs(nextNums, nextExprs)) return true; nextNums.remove(nextNums.size() - 1); nextExprs.remove(nextExprs.size() - 1); // 乘法:a*b nextNums.add(a * b); nextExprs.add("(" + exprA + "*" + exprB + ")"); if (dfs(nextNums, nextExprs)) return true; nextNums.remove(nextNums.size() - 1); nextExprs.remove(nextExprs.size() - 1); // 减法:a-b(注意顺序,两种都试) nextNums.add(a - b); nextExprs.add("(" + exprA + "-" + exprB + ")"); if (dfs(nextNums, nextExprs)) return true; nextNums.remove(nextNums.size() - 1); nextExprs.remove(nextExprs.size() - 1); // 除法:a/b(分母接近0时跳过) if (Math.abs(b) > EPSILON) { nextNums.add(a / b); nextExprs.add("(" + exprA + "/" + exprB + ")"); if (dfs(nextNums, nextExprs)) return true; nextNums.remove(nextNums.size() - 1); nextExprs.remove(nextExprs.size() - 1); } } } return false; } }代码中选取两个数时的条件i != j确保不会把同一个数用两次。EPSILON = 1e-6是浮点数比较的容差,因为除法运算会产生无限小数,比如7 / 3 = 2.333...,直接用==判断会导致计算结果是24却判定失败。每次尝试一种运算后要移除刚添加的元素,恢复到尝试前的状态,这就是“回溯”。a - b和a / b只试一种顺序,因为b - a会在后续循环中当i和j互换时被覆盖。
3.3 有解判定与无解处理:不能只给一种结果
24点不是所有牌面都有解。经典的1,1,1,1无论如何都算不出24。程序必须对无解情况给出明确提示,而不是死循环或空结果。主调用处需要判断solve方法返回的List是否为null。
public static void main(String[] args) { int[] cards = drawFourCards(); // 随机抽取4张牌,范围1-13 System.out.println("抽到的牌:" + Arrays.toString(cards)); List<String> result = TwentyFourGame.solve(cards); if (result != null) { System.out.println("找到解法:" + result.get(0)); } else { System.out.println("这组牌无解,请重新抽牌"); } }这里有一个常见的偷懒写法:很多教程把表达式拼接放在print语句里,不传入递归。那样虽然能验证有解,但无法输出表达式。上面代码把exprs列表作为参数一路传递,每一步运算都生成对应的完整表达式,最终结果直接可用。注意get(0)取的是最终结果对应的表达式,因为当递归结束时exprs列表只剩一个元素。需要控制重复解的话,可以在dfs返回true的出口处增加一个HashSet记录表达式去重,但课设阶段输出一个解就足够了。
3.4 效率与边界:递归深度最大是多少
这个递归模型的深度固定为3层,因为每递归一层减少一个数字。每一层的分支数是P(n,2) × 4(n是当前数字个数),第一层有4×3×4 = 48种,第二层有3×2×4=24种,第三层有2×1×4=8种,总状态数在万级,完全不需要担心性能。如果跑不出来,几乎一定是代码死循环,而不是计算量问题。
真正容易出现的问题在除法分支的边界判断。如果b是0,a/b会抛出ArithmeticException。上面的代码用Math.abs(b) > EPSILON过滤,这里的EPSILON不能太小,比如1e-10,因为b可能是两个浮点数相除后的结果,比如2/3=0.666...,本身就有误差。EPSILON=1e-6已经是足够安全的阈值。此外,牌面如果是J/Q/K,要在读取阶段先映射为11/12/13,否则parseInt会直接抛异常。
4. 课设避坑:从编译报错到答辩提问的五个常见翻车点
4.1 中文乱码:现象、原因与解决
现象:控制台输出的中文全部变成“???”,或者从文件读回的联系人姓名变成乱码。
原因:Java源码文件使用了GBK编码保存,但编译和运行时的默认字符集是UTF-8,导致字符串在内部转换时丢失信息。这是Windows环境下最常见的问题,因为Windows记事本默认保存为ANSI编码。
解决:统一使用UTF-8编码保存源文件,并且在编译时显式指定编码格式。在命令行编译时用javac -encoding UTF-8 Main.java。如果使用IDEA,在File -> Settings -> Editor -> File Encodings将全局编码和项目编码都设置为UTF-8。
4.2 Scanner的nextInt()与nextLine()混用导致输入被跳过
现象:菜单选择使用nextInt()输入数字,然后按回车,程序直接跳过后续的姓名输入,表现为“还没输入内容就提示错误”。
原因:nextInt()只读取数字部分,不会消费掉数字后面的换行符\n,紧接着的nextLine()读取到这个残留的换行符后直接返回空字符串。
解决:一是统一用nextLine()读取所有输入,再用Integer.parseInt()把字符串转成数字;二是每次调用nextInt()后额外调用一次nextLine()消费换行符。前者更干净,后者容易多发一次无用调用。注意:如果用户输入了非数字字符,Integer.parseInt()会抛出NumberFormatException,需要捕获并提示重新输入。
4.3 序列化保存文件后修改了Contact类导致无法读取
现象:昨天运行程序还能正确保存和读取通讯录,今天改了Contact类的某个字段后,运行程序加载数据时报InvalidClassException。
原因:序列化机制会在写入文件时记录一个serialVersionUID,它是根据类的字段信息自动计算的。修改类的结构后,这个UID变化,反序列化时Java检测到版本不匹配,拒绝恢复对象。
解决:在Contact类中显式声明private static final long serialVersionUID = 1L;,这个值是自己指定的,不随字段变化。这样即使以后增加字段,旧文件也能反序列化成功,新增的字段用默认值填充。这是经验教训,我吃过大亏:课设做到一半加了分组字段,结果所有测试数据全部作废。
4.4 24点递归栈溢出和结果错误同时出现
现象:运行24点求解时,某些牌面提示无解,但手工验证明明有解;或者程序直接报StackOverflowError。
原因:栈溢出几乎可以确定是递归没有正确的终止条件,比如i == j的判断缺失导致同一个数被反复使用,递归深度无限增加。结果错误的场景通常是因为浮点数直接比较相等导致误判,比如20/3*3的结果实际是19.999999而不是20。
解决:先检查递归终止条件:只剩一个数时直接判断,i和j必须不相等。再看浮点比较是否用了EPSILON容差。这两个点都用对之后仍然无解,大概率是牌面确实无解。可以在网上找一个“24点计算器”交叉验证,而不是死磕自己的代码。
4.5 答辩时的“口胡翻车”:搞不清楚时间复杂度和稳定性
现象:老师问“你选择的排序方法是什么?为什么用这个?”,学生支支吾吾回答不上来,或者把Collections.sort()说成快速排序。
原因:太多同学会调用Collections.sort,但从没看过底层实现。其实Java 8中Collections.sort对List的排序底层使用Arrays.sort,对象数组排序使用TimSort(一种稳定的归并排序变体),时间复杂度最坏O(n log n),空间复杂度O(n)。而ArrayList的内存连续存储决定了随机访问效率高,TimSort能充分利用这一点。
解决:在答辩前把每个方法的时间复杂度、空间复杂度写在一张纸上,背下来。例如顺序表的线性查找是O(n),哈希查找是O(1),Collections.sort是O(n log n)。老师提问“为什么通讯录用ArrayList而不是LinkedList”时,回答“因为支持快速随机访问,且存储连续对缓存友好”远比“大家都用这个”更容易过关。
5. 进阶验证:给课设加测试用例与答辩演示脚本
5.1 用JUnit写几组边界测试
课设代码写完不是结束,还要向老师证明它是对的。最直接的证明方式是用JUnit写几个测试用例,把典型输入和预期输出固化下来,运行测试一键通过。老师看到测试代码比看到一堆println更认可你的工程能力。
import org.junit.Test; import static org.junit.Assert.*; public class TwentyFourGameTest { @Test public void testBasicSolved() { int[] cards = {1, 2, 3, 4}; List<String> result = TwentyFourGame.solve(cards); assertNotNull("有解的情况必须返回表达式", result); } @Test public void testNoSolution() { int[] cards = {1, 1, 1, 1}; List<String> result = TwentyFourGame.solve(cards); assertNull("无解的情况必须返回null", result); } @Test public void testDuplicateCards() { int[] cards = {6, 6, 6, 6}; List<String> result = TwentyFourGame.solve(cards); assertNotNull("四个6有解", result); assertEquals("(6+6+6+6)=24", 24, evaluate(result.get(0)), 1e-6); } }testNoSolution中等号右侧的数字{1,1,1,1}是经过验证的无解组合。这类“反面测试用例”比正面用例更能显示代码的鲁棒性,也是评分时老师重点关注的部分。注意assertEquals的断言方法使用了1e-6容差,与程序内部的EPSILON保持一致,否则测试本身会不稳定。
5.2 给通讯录做压力测试的方法
通讯录模拟器的数据量通常不大,但你可以人为构造一个大规模数据来验证插入和查找的时间增长趋势。具体方法是在main方法里临时生成5000条联系人记录,统计排序和查找耗时。
// 在临时测试类中运行 long start = System.currentTimeMillis(); book.sortByName(); long end = System.currentTimeMillis(); System.out.println("5000条记录排序耗时:" + (end - start) + "ms");预期结果是排序耗时在几十毫秒量级。如果耗时超过了1秒,说明数据结构和算法选择可能有问题,比如自己实现了冒泡排序。这时可以主动在答辩时提及“在5000条数据下耗时为xx毫秒,满足实际应用需求”,这是加分项。注意压力测试的数据是随机生成的,不要让用户输入5000次。
5.3 答辩演示脚本:按这个顺序讲不慌
答辩时不要拿着代码从头念,而是按“结构 → 功能 → 特点”的顺序演示。我的习惯做法是四步走:
第一步,展示Contact类和AddressBook类的结构,指着字段说明“姓名、电话、邮箱、分组,对应线性表中的一条记录”。第二步,实际运行程序,添加三个联系人,演示查找功能,刻意输入一个重复号码展示添加失败。第三步,退出程序重新打开,加载文件,展示数据确实保存成功。第四步,打开24点游戏,输入一组已知有解的牌面4,4,10,10,展示输出((10*10)-4)/4=24的结果,再输入无解的牌面展示无解提示。
这套演示顺序覆盖了文件持久化、逻辑判断、算法输出三个核心评分维度。最后回答老师提问的时候记住一个原则:老师问“这个功能还能怎么优化”,你要回答的是“未来优化方向”,而不是承认当前代码有缺陷。比如老师问你“查找效率还能提升吗”,回答“可以引入哈希表,把电话号码映射为数组下标,将查找复杂度从O(n)降到O(1)”——哪怕你没有实现哈希版本,这段话也足以展示你对数据结构的理解深度。
对我们这些写过好几个课设的人来说,24点递归和通讯录线性表是基础中的基础。它们的好处是逻辑闭环短,从输入到输出在一百行内能讲清楚。从那以后我每次写这类课设,都会先跑一遍JUnit测试,再做十分钟的答辩模拟,确认对每个方法的时间复杂度都能脱口而出,才敢提交源码包。这些习惯不一定能让你拿到优秀,但至少能让答辩现场不冷场——希望帮到你。
本文还有配套的精品资源,点击获取