简介基于Java的数据结构课程设计——停车场管理系统面向正在学习Java与数据结构的学生是可直接导入IDE运行的完整项目。系统提供Swing用户界面支持车辆存取、停车位满时自动转入候车区等核心功能通过自定义链表队列管理车位用栈处理候车区逆序调度直观展示了FIFO与LIFO结构的实际应用。压缩包共十五个文件包含六个源代码文件与九个编译后文件大小仅17KB其中链表队列、栈、车辆信息、数据管理等相关类划分清晰方便对照学习。已有两千六百四十人学习下载适合作为课程设计参考或数据结构实践素材。通过阅读源码可掌握链表队列与栈的链式实现、图形界面事件监听、异常处理等技巧对完成同类管理系统项目很有借鉴价值。1. 停车场管理系统是数据结构课设里性价比最高的一道题能用栈、队列、哈希和排序做出一个能答辩的完整项目每年数据结构的课程设计季“数据结构Java课程设计停车场管理系统”都是出现频率最高的题目之一。这个题目的讨喜之处在于业务足够简单却要求你在一个完整流程里做数据结构选型车位占用是栈、等待入场是队列、车牌反查是哈希表、账单输出是排序。做一遍这套流程等于把线性表、链表、栈、队列、哈希和排序全部亲手实现了一遍还能顺手把Java的文件读写和异常处理练熟。这套东西做完不只课设能交差后面数据库课程设计、Java大作业甚至毕业设计都能直接复用里面的模型。下面按我的实际实现路线拆开讲从需求建模一路写到代码、计费、落盘和答辩避坑。2. 先别急着写代码停车场里的“挪车”和“排队”天生就是栈和队列课程设计题里通常有两种停车场景很多同学上来就按固定车位写结果答辩时被问一句“车被挡住了怎么办”就卡住。实际上老师出这个题想考的几乎都是第一批场景一条窄车道进出、车位按顺序排列最里面的车要出来后面的车必须全部挪开等目标车出去之后再按原顺序倒回来。“后进先出”这四个字写进实验报告容易真正映射到代码时你要先确认自己写的是巷道式停车场还是随意停放的平面停车场。2.1 巷道式停车场用栈模拟“挪车”平面停车场才用顺序表巷道式停车场的游戏规则是每个车位只能从栈顶一侧进车车辆从入口开进来一定停在最外的空位要取一辆非栈顶的车得先把堵在它外面的车全部弹出到一个临时位置再把临时位置的车按相反顺序压回来。这种操作和栈的 pop 再 push 完全一致所以核心结构选链栈不用ArrayList代替。ArrayList虽然也能实现后进先出但它中间删除元素要搬移数据复杂度同样是O(n)可你没法向答辩老师解释“为什么明明有栈不用非要改造顺序表”。平面停车场或者地下车库那种“随便停”的模型才更适合用ArrayList。每个车位是独立的车进来找一个空位停车出去直接开走跟顺序无关。很多课设题目描述里写“停车场内只有一个窄道”那你就应该直接上链栈并且要设计一个临时栈来承接挪车过程。这两种模型在实验报告里反差很大前者讲“利用栈的特性模拟车辆避让”后者讲“利用顺序表模拟随机停放”老师一眼就能看出你是不是真读懂了题。我一般会先画一张业务动作表入场对应 push取车对应 pop中间夹一次临时栈倒腾满位对应栈满异常等待对应队列入队。画完这张表再动笔代码结构基本就固定下来了。2.2 从业务动作反推核心类结构车辆记录、链栈、等待队列、停车场门面根据上面的动作表我会拆出四个类职责单一答辩时也好讲类名数据结构职责CarRecord普通Java类车牌、入场时间、出场时间、费用LinkedStack自写链栈管理车位占用提供入场、挪车出场WaitQueue自写链表队列停车位满时的等待车辆管理ParkingLot组合门面类对外提供 enter/leave 方法串联栈和队列CarRecord这个类不能省。很多新手把车牌和入场时间直接存在栈节点里等要生成停车记录时发现数据散落各处还得再遍历一遍栈。正确的做法是让栈节点持有CarRecord对象栈只负责节点顺序车辆信息全部收在CarRecord里。字段设计如下public class CarRecord { private String plate; // 车牌号核心标识 private LocalDateTime enterTime; // 入场时间 private LocalDateTime leaveTime; // 出场时间出场时填 private BigDecimal fee; // 应收费用出场时计算 public CarRecord(String plate, LocalDateTime enterTime) { this.plate plate; this.enterTime enterTime; } // 每个字段保留 getter / setter }用LocalDateTime而不是Date或String是因为计费时要算时长差LocalDateTime配合Duration能直接得到分钟数避免手写时间格式转换。等将来扩展成Spring Boot项目LocalDateTime也能被Jackson直接序列化不用额外写转换器。这是一个从课程设计一开始就值得养成的习惯。WaitQueue里的队列节点只存车牌不存CarRecord因为等待车辆还没入场没有入场时间。当栈出现空位时从队列头取出车牌再new一个CarRecord压入栈。这里有个细节等待队列从队头出、队尾进和超市排队一个道理千万别做成从队尾取。3. 用Java落地核心模型链栈管车位、队列管等待、HashMap做车牌索引结构设计完成后落码阶段的核心就是把“挪车”这个动作写对。下面这段链栈实现是这个系统的骨架入场、出场、挪车全在这里面完成。我写代码时会把Node设计成静态内部类它只服务于LinkedStack没有必要单独建一个文件。3.1 手写链栈模拟巷道车位实现入场和“被挡住的车先挪开再归位”public class LinkedStack { private Node top; // 栈顶指针 private int size; // 当前车辆数 private final int capacity; // 车位总数 private static class Node { CarRecord record; Node next; Node(CarRecord record) { this.record record; } } public LinkedStack(int capacity) { this.capacity capacity; this.size 0; this.top null; } // 入场车位满则返回 false由调用方决定是否进入队列 public boolean enter(CarRecord record) { if (size capacity) { return false; } Node newNode new Node(record); newNode.next top; top newNode; size; return true; } // 取车目标车不在栈顶时先把上面的车挪到临时栈 public CarRecord leave(String plate) { if (top null) { return null; } LinkedStack temp new LinkedStack(capacity); Node target null; while (top ! null) { Node cur top; top top.next; size--; if (cur.record.getPlate().equals(plate)) { target cur; break; } temp.pushNode(cur); } while (!temp.isEmpty()) { pushNode(temp.popNode()); } return target null ? null : target.record; } private void pushNode(Node node) { node.next top; top node; size; } private Node popNode() { if (top null) return null; Node cur top; top top.next; size--; return cur; } public boolean isEmpty() { return size 0; } public int getSize() { return size; } }这段代码里最重要的是 leave 方法前半段目标车不是栈顶时把阻塞车辆全部弹出到 temp 栈直到找到目标车后半段再把 temp 栈的车全部压回原栈。整个过程维持了车辆相对顺序这正是栈模拟挪车的核心逻辑。temp 栈用同样的 LinkedStack 类capacity 参数传原栈容量防止极端情况下挪出车辆数超过原容量。参数设计上有两个注意点leave 接收的是车牌 plate 而不是 CarRecord因为调用方只从用户输入拿到车牌返回值是 CarRecord调用方随后需要读取 record 里的入场时间来计算费用。如果返回 null说明没有这辆车这一步是业务错误不是栈结构错误调用方要给出“未找到车辆”的提示。还有一个边界如果同一辆车 enter 了两次这个实现会删除靠近栈顶的那次记录所以在入口处必须做车牌防重。3.2 等待区用链表队列进队、出队和自动补位停车位满时车辆进入等待区这是一个典型的 FIFO 场景。队列我同样用链表实现不直接用 LinkedList原因是课设要求展示数据结构能力自写队列反而更好答。链式队列只需要维护 head 和 tail 两个指针public class WaitQueue { private Node head; // 队头出队方向 private Node tail; // 队尾入队方向 private int size; private static class Node { String plate; Node next; Node(String plate) { this.plate plate; } } public void enqueue(String plate) { Node newNode new Node(plate); if (tail null) { head newNode; } else { tail.next newNode; } tail newNode; size; } public String dequeue() { if (head null) { return null; } String plate head.plate; head head.next; if (head null) { tail null; } size--; return plate; } public boolean isEmpty() { return size 0; } public int getSize() { return size; } }dequeue 里最容易漏的是当 head 变为 null 时tail 也必须置空否则下一次 enqueue 时 tail.next 会空指针异常。这个 bug 我第一次写时踩过原因是队列中最后一个元素出队后tail 还指着那个已经出队的节点。等待队列本身不感知停车场是否有空位它只负责“先进先出”真正的调度逻辑在 ParkingLot 门面类里public void tryEnterFromQueue() { while (!waitQueue.isEmpty()) { String plate waitQueue.dequeue(); CarRecord record new CarRecord(plate, LocalDateTime.now()); boolean ok stack.enter(record); if (!ok) { // 这里说明车位在调度过程中又被占满重新入队 waitQueue.enqueue(plate); break; } } }这段调度代码有个参数决策CarRecord 的入场时间应该取“真正进入停车场的时间”还是取“开始排队的时间”课程设计通常取前者因为排队不占用车位计费也从入场开始。如果把排队时间算进去用户会投诉“我排队半小时也要收费”。所以 enqueue 存的是车牌字符串等真正有空位时才 new CarRecord时间自然就是入场时间。3.3 为什么用 HashMap 做车牌索引而不是遍历栈上面这个实现里leave 方法每一次都要从栈顶一路往下找时间复杂度 O(n)。如果停车场有 50 个车位取一辆最里面的车要比较 50 次性能倒还好但答辩时老师会追问“你的查询效率是多少”。为了把查找降到 O(1)我额外维护了一个 HashMapString, Nodekey 是车牌value 是栈中对应的 Node 引用。入场时 put出场时 remove。public class ParkingLot { private final LinkedStack stack; private final WaitQueue waitQueue; private final MapString, Node index; // 车牌 - 栈节点 public boolean enter(String plate) { if (index.containsKey(plate)) { return false; // 重复入场防护 } CarRecord record new CarRecord(plate, LocalDateTime.now()); boolean ok stack.enter(record); if (ok) { // index 存的是栈节点不是 CarRecord index.put(plate, stack.findNode(plate)); return true; } waitQueue.enqueue(plate); return false; } }这里有个关键点栈节点的 next 指针在挪车时会被不断重新连接但 Node 对象本身没有变所以 HashMap 里存的引用始终有效。存索引时不要存 CarRecord也不要存“第几个车位”因为挪车后位置就变了存对象引用才能保证随时拿到正确的栈节点。出场时先把 HashMap 的 key 删掉再调 leave否则会出现“车出去了索引还在”的脏数据。HashMap 的缺点也是必然的它破坏了链表结构的纯粹性多了一份空间开销。但这是经典的“用空间换时间”案例课设里讲出来反而是加分项。遇到 hash 冲突时Java 8 后的 HashMap 会转红黑树这属于 JDK 实现细节答辩时能提一句“HashMap 在链表过长时自动树化”会很加分。4. 让系统真正可用计费、持久化和账单排序一个都不能少核心车辆管理做完系统还只是半成品。老师验收时不会只让你演示车辆进出一定会看收费对不对、数据能不能存下来、报表能不能按金额排序。这一章的价值是把课程设计从“数据结构demo”升级成“可以演示的完整系统”。4.1 计费策略免费时长、阶梯单价与BigDecimal的精度取舍计费规则的常规设计是进场前 15 分钟免费超过 15 分钟后按小时计费前 2 小时每小时 3 元超过 2 小时部分每小时 5 元单日最高 30 元封顶。这个规则简单但有三个边界条件要测试刚好 15 分钟、刚好 2 小时、跨天。跨天封顶可以直接按自然日算但课设里为了简化我会把“单日”改成“单次停车累计 24 小时内封顶 30 元”。实现上必须用 BigDecimal不能用 double。double 计算 0.1 小时费用可能出 0.30000000000000004 这种结果打印出来很丢人。BigDecimal 构造时要用字符串new BigDecimal(3)不要 new BigDecimal(3.0)。计算代码如下public BigDecimal calcFee(LocalDateTime enterTime, LocalDateTime leaveTime) { long minutes Duration.between(enterTime, leaveTime).toMinutes(); BigDecimal fee BigDecimal.ZERO; if (minutes FREE_MINUTES) { return fee; // 15分钟内免费 } long billableMinutes minutes - FREE_MINUTES; BigDecimal hours BigDecimal.valueOf(billableMinutes) .divide(BigDecimal.valueOf(60), 2, RoundingMode.CEILING); if (hours.compareTo(BigDecimal.valueOf(2)) 0) { fee hours.multiply(new BigDecimal(3)); } else { BigDecimal firstPart new BigDecimal(6); // 前2小时共6元 BigDecimal extra hours.subtract(BigDecimal.valueOf(2)) .multiply(new BigDecimal(5)); fee firstPart.add(extra); } return fee.min(new BigDecimal(30)); // 24小时封顶 }计费单位是小时并且向上取整是常见方案避免用户觉得“超时 1 分钟也算 1 小时”不公平。如果你想让规则更细可以改成按 30 分钟为单位但那段逻辑大同小异核心是 BigDecimal 全程参与乘除避免浮点误差。分钟数直接用 Duration.toMinutes() 获取不用自己算秒再除 60。4.2 文件持久化用一个文本文件存出场记录不急着上数据库课设阶段建议别连 MySQL写了增删改查反而冲淡数据结构主题。一个文本文件足够应付“系统重启后数据还在”的验收要求。每次车辆出场并完成计费后向 parking_record.txt 追加一行字段用逗号分隔。这种格式就是 CSV后面换数据库时可以直接导入。public void saveRecord(CarRecord record) { String line String.join(,, record.getPlate(), record.getEnterTime().toString(), record.getLeaveTime().toString(), record.getFee().toString()); try (BufferedWriter writer new BufferedWriter(new FileWriter(parking_record.txt, true))) { writer.write(line); writer.newLine(); } catch (IOException e) { System.err.println(记录落盘失败 e.getMessage()); } }FileWriter 构造器里的第二个参数 true 是追加模式这是最容易忽略的点。第一次写覆盖模式第二次出场记录会把第一次覆盖掉实验报告里这种错误一旦出现直接暴露“文件IO没学过”。整个方法被 try-with-resources 包住writer 在 try 结束后自动关闭不用手动 flush。如果系统要做并发版本FileWriter 需要加锁但课程设计是单线程演示不需要这个复杂度。落盘的数据里 leaveTime 和 fee 在入场时是没有的所以 saveRecord 只能在出场时调用。启动系统时如果要做“历史账单查询”就把这个文件按行读进来转成 CarRecord 列表读的时候每个字段都要做非空校验防止文件末尾有空行导致 split 后数组越界。4.3 报表按停车费用排序用插入排序演示“近乎有序”场景历史记录读进来后要按费用从高到低排序输出。这是数据结构排序算法在真实业务里最简单的应用。我选择手写插入排序而不是调用 Collections.sort因为课设要求展示排序算法过程插入排序对“按时间写入、费用乱序”的记录集效果足够好。public static void insertionSortByFee(ListCarRecord list) { for (int i 1; i list.size(); i) { CarRecord key list.get(i); int j i - 1; while (j 0 list.get(j).getFee().compareTo(key.getFee()) 0) { list.set(j 1, list.get(j)); j--; } list.set(j 1, key); } }这段代码判断条件里用的是 compareTo 而不是减法因为 fee 是 BigDecimal减法得到的不是“大于0/小于0”的语义。compareTo 返回负数说明前一个费用更小按降序排列时要把大的往前挪所以条件是 list.get(j).getFee().compareTo(key.getFee()) 0。插入排序是稳定排序如果两条记录费用相同会保持它们在文件中的先后顺序也就是入场时间顺序这正是报表里希望看到的效果。排序之外还可以顺手统计“总营收”“停车时长均值”和“单日最高收费”这些都是 O(n) 遍历就能拿到的数据。答辩时老师问“你用了哪些算法”你就能答出插入排序加线性扫描比只做 CRUD 完整得多。5. 避坑数据结构实验报告里最容易翻车的5个写法与说法这一章全是血泪经验。课设代码写对不难难的是答辩时逻辑自洽。下面五条是我见过以及自己踩过的高频问题每一条都按“现象→原因→解决”拆开看完可以直接对着检查自己的代码。5.1 栈顶指针没更新入场就丢车现象连续入场三辆车显示车位占用数为 2第三辆不见了。原因enter 方法先判断 size然后 new 出节点直接给 top 赋值 new 节点但忘了把新节点的 next 指向原来的 top旧节点就断了。这种错通常发生在手写链栈时没有遵循“先连后断”的链表操作纪律。解决先 newNode.next top再 top newNode。写完后用三辆车做用例检查每次入场后 top.getPlate 是否是最后入场那辆。5.2 队列出队后 tail 清空不及时再次入队空指针现象等待队列只有一辆车出队后再次 enqueue 报 NullPointerException。原因dequeue 里把 head 移到 head.next但 head 为 null 后没有把 tail 置空下一次 enqueue 执行 tail.next newNode 时tail 还是旧节点旧节点已经被逻辑删除next 指针已经是 null于是空指针。解决dequeue 方法里当 head 变为 null 时强制把 tail 也置为 null。这是链式队列的经典边界条件几乎是每次手写队列必考的坑。5.3 HashMap 索引与栈节点不同步计费时间错乱现象同一辆车出场后再进场第二天出场显示的费用只有 1 元明显是上次入场时间被复用了。原因enter 把车牌放进 HashMap但 leave 时只从栈里删节点忘了 index.remove(plate)。第二次 enter 时index.containsKey(plate) 判断命中以为车还在场里直接返回 false 或者引用了旧节点。解决在 leave 成功找到目标车后第一时间执行 index.remove(plate)。如果你在 enter 里做“通过 HashMap 查重”这个误判才不会被触发。常见做法是index.remove 和栈 pop 在同一个操作里完成谁也不要晚一步。5.4 计费出现 0.30000000000000004答辩被当场嘲笑现象15 分钟免费后的 0.3 元费用打印出来是一长串浮点数。原因用 double 做小时计算0.1 在二进制里是无限循环小数误差累积后打印难堪。这是课程设计里很典型的“看着能用其实精度已经错了”。解决计费字段全部用 BigDecimal构造时使用字符串入参除法时指定精度和 RoundingMode.CEILING。如果坚持用 double至少要在打印前做 DecimalFormat 格式化但报表二次计算还是会有误差不如一步到位。5.5 实验报告画的是顺序栈代码写的链栈答辩圆不回来现象报告里的结构示意图是数组式栈代码却是链表栈老师对照着问“你这图里 top 下标和链表头指针对应关系是什么”直接答不上来。原因网上找的模板图和自己的实现错位抄图时没改。这个问题的严重性不在于图与代码哪个对而在于暴露出报告不是自己写的。解决链栈图就画节点和箭头顺序栈图就画带下标的方块。如果代码里用了 HashMap 做索引图上也一定要画出来答辩时主动讲“这是为了把查找从 O(n) 降到 O(1)”反而变成亮点。报告里的时间复杂度和空间复杂度表也要按真实实现填写不要照抄网络模板。提示答辩前可以做一次“代码朗读”从头把 enter 到 leave 的完整流程对着数据结构图走一遍。能走通这五条坑就基本都避开了。6. 把系统做厚一步用单元测试钉死核心路径再决定要不要上Spring Boot前五章的内容已经能让你交出一份完整的课设。如果你还有余力建议做两件事第一是给核心逻辑写几个 JUnit 测试用例第二是评估这个系统要不要继续扩展。这两步并不冲突前者能保证重构安全性后者决定了这个项目的未来方向。6.1 用JUnit把入场、挪车、出场、计费四条路径钉死写测试不是为了凑代码量而是为了后面改代码时不至于把核心逻辑改崩。最少要覆盖四个场景正常入场出场、车位满时排队、车不在场内时离场、阻挡车辆挪车顺序。最后这个最有价值Test public void leaveWithBlocker_shouldPreserveOrder() { ParkingLot lot new ParkingLot(2); lot.enter(A); lot.enter(B); // B 堵在 A 外面 CarRecord recordA lot.leave(A); // 必须先把 B 挪走再让 A 出去 assertNotNull(recordA); assertEquals(A, recordA.getPlate()); assertEquals(1, lot.stackSize()); // 此时场内应只剩 B assertEquals(B, lot.queryFirstPlate()); }这个测试断言了两个关键行为A 能成功出场且出场后 B 仍然留在场内。如果挪车逻辑把 B 弄丢第三个断言就会失败。计费同理单独写一个测试传入固定 enterTime 和 leaveTime验证 15 分钟免费、2 小时边界和封顶三条规则。JUnit 测试跑绿之后你再动栈的内部结构就大胆很多这也是后面做 Spring Boot 改造的前提。6.2 从课设到毕设的这条改造路线要克制有不少人做完课设想继续扩展成 Spring Boot 项目我不反对但有一点必须提醒核心数据结构一定要保留。常见做法是把 LinkedStack 和 WaitQueue 原样保留在 service 层只把 ParkingLot 暴露成 REST 接口用 Spring Boot 的 Controller 接收 HTTP 请求再调用 ParkingLot 的 enter 和 leave。这样既保留了面试时可以讲的“手写栈HashMap索引”又增加了 Web 层的工程能力。如果为了赶时髦直接把栈替换成数据库表那这个项目的灵魂就没了。数据结构的价值在于让你理解“用什么结构承载什么业务”Spring Boot 是壳壳可以换内里的栈和队列不该丢。我自己的习惯是课设做完先把测试跑一遍然后抽出一个核心模型包将来不管做课设还是毕业设计都能直接引用这份已经验证过的代码。这门课设的难点从来不是“写出能运行的程序”而是“能解释清楚为什么这样设计”。把栈和队列的选择依据、HashMap的索引作用、BigDecimal的精度陷阱都讲明白你已经比大多数只会粘贴代码的同学走前了一大截。希望这篇踩坑笔记帮到你也祝答辩顺利。本文还有配套的精品资源点击获取