简介:基于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),我额外维护了一个 HashMap<String, Node>,key 是车牌,value 是栈中对应的 Node 引用。入场时 put,出场时 remove。
public class ParkingLot { private final LinkedStack stack; private final WaitQueue waitQueue; private final Map<String, 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(List<CarRecord> 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的精度陷阱都讲明白,你已经比大多数只会粘贴代码的同学走前了一大截。希望这篇踩坑笔记帮到你,也祝答辩顺利。
本文还有配套的精品资源,点击获取