简介:面向学习普林斯顿大学算法课程及准备算法编程作业的读者,这份 Java 习题解答围绕课程经典作业给出可对照的参考实现,适合希望把排序、图搜索、最短路径、动态规划与基础数据结构落实到代码中的自学者和在校学生。压缩包共 20 个文件,约 28KB,以 18 个 Java 源码为主体,另含 1 个 README 说明与 1 个许可证文件,体量轻便,便于按主题阅读和验证。已有 245 人学习,说明其在课程作业参考场景中有一定使用基础。内容涉及渗流问题、Kd 树、八数码、接缝裁剪、词网等课程任务,并串联优先队列、并查集、图遍历、复杂度分析与调试排错等知识点,可帮助读者理解作业拆解、接口设计与算法取舍,也便于在本地运行中核对边界条件与性能表现。
1. 一套把算法习题做成工程代码的 Java 解法集
很多人学算法课,卡住的地方在于思路能听懂,但一到动手写就下不了笔。这份 algs4 编程习题解答就是针对这个痛点准备的:它把课程里每个 Programming Assignment 用 Java 完整实现了一遍,从 Percolation 的并查集建模,到 Kd-Tree 的二维空间划分,再到 Seam Carving 的能量图和最短路径,每道题都有可编译、可提交的代码。适合正在刷这门课、被作业卡住想对照实现的人,也适合想看看「标准工程写法长什么样」的 Java 开发者。它更像一套参考实现,帮你对照代码,看清楚并查集、优先队列、图搜索这些结构在真实题目里怎么落地。
2. 项目结构与技术底座:为什么是纯 Java 加最小依赖
2.1 目录按作业编号组织,一题一包
这类习题集的目录结构和课程评分系统是绑定的,常见的组织方式是按周或按作业名分包,每个包里放三类文件:题目要求的主类、题目给的辅助类(比如Point2D、Picture、In、StdOut这些来自课程自有库的类)、以及自己写的测试驱动。结构大致长这样:
algs4-Programming-Assignment/ ├── percolation/ │ ├── Percolation.java # 主实现:并查集 + 虚拟头尾节点 │ ├── PercolationStats.java # 蒙特卡洛统计,用于评估阈值 │ └── PercolationVisualizer.java # 可视化调试 ├── queues/ │ ├── Deque.java # 双向队列,链表实现 │ ├── RandomizedQueue.java # 随机队列,数组 + 洗牌 │ └── Permutation.java # 客户端:按 k 输出随机元素 ├── collinear/ │ ├── Point.java │ ├── BruteCollinearPoints.java │ └── FastCollinearPoints.java ├── kdtree/ │ ├── PointSET.java # 暴力实现,作为对照基准 │ └── KdTree.java # 2d-tree,含范围搜索与最近邻 ├── seambcarver/ # 常见拼写,以实际目录为准 │ ├── SeamCarver.java │ └── ... └── algs4.jar # 课程自带算法库与 I/O 库一个包就是一道题,包内不跨包引用,这是课程评分系统的硬要求,因为提交时只上传单文件或单包。你对照代码时也建议按包逐个跑,别一次性全丢进 IDE 里编译,否则会因为不同作业里类名重复(比如好几题都有Point)直接翻车。
2.2 依赖只有 algs4.jar,别乱换 JDK 版本
整个项目对外部库的依赖只有一个algs4.jar,它打包了课程需要的全部数据结构辅助类和标准输入输出封装(StdIn、StdOut、StdDraw、StdRandom)。这意味着 Java 版本是唯一变量。这门课的工具链写死在 Java 8,algs4.jar里的类在 Java 8 上验证过。我的血泪经验是:用 Java 11 或 17 跑往往也能编译通过,但StdDraw这类依赖 AWT 的类在高版本 JDK 上偶发窗口渲染问题,排查起来很浪费时间。
配置 classpath 的命令行方式如下,-cp后面用冒号分隔(Windows 用分号):
# 编译:把 algs4.jar 放进 classpath,输出到当前目录 javac -cp algs4.jar:. Percolation.java PercolationStats.java # 运行:同样指定 classpath,类名不带 .class 后缀 java -cp algs4.jar:. PercolationStats 200 100200是网格边长 N,100是试验次数 T。这两个参数直接影响运行时间:N=200 时单个并查集查询基本是常数级,T=100 总耗时通常在几秒内;如果你把 N 拉到 1000,内存占用按 N² 增长,需要调整 JVM 堆参数-Xmx。参数含义在课程里是固定的,改错顺序会导致统计结果没意义,但程序不一定报错。
2.3 课程自带的检查器是个黑匣子,得会用
课程提供的Checkstyle和评分器不属于项目本体,但你二次开发时绕不开。它们检查的东西很细:文件头注释格式、方法命名规范、不允许使用java.util.Stack(因为课程要求自己实现或用Deque)、不允许在Percolation里额外加 public 方法等。这些规则散落在作业说明里,新手容易漏。我的建议是每次写完先跑一遍本地检查器,把格式问题清零再调逻辑,顺序反了会出现「逻辑对但格式挂」的冤枉分。
3. 核心模块拆解:从并查集到二维树的关键实现
3.1 Percolation:并查集加两个虚拟节点
这道题是整个系列里最能体现「建模比写代码重要」的一道。题目给的模型是 N×N 网格,每个格子要么开要么闭,要求判断顶部到底部是否连通。最直接的写法是每次 BFS 搜索,但作业要求时间复杂度必须在常数级查询,逼你用并查集(Union-Find)。
关键技巧是引入两个虚拟节点:一个代表「顶部所有开放格子的集合」,一个代表「底部」。每开通一个格子,就和上下左右已经开放的邻居 union;如果这个格子在首行,额外和顶部虚拟节点 union;在末行则和底部虚拟节点 union。这样判断是否渗透只要一次connected(top, bottom)。
public class Percolation { private final int n; private final boolean[] open; // 一维数组映射二维坐标,省内存 private final int top; // 顶部虚拟节点索引 private final int bottom; // 底部虚拟节点索引 private final WeightedQuickUnionUF uf; private int openCount; public Percolation(int n) { if (n <= 0) throw new IllegalArgumentException("n must be positive"); this.n = n; this.open = new boolean[n * n]; this.top = n * n; this.bottom = n * n + 1; this.uf = new WeightedQuickUnionUF(n * n + 2); } // 二维坐标转一维索引,行列从 1 开始(课程约定) private int indexOf(int row, int col) { return (row - 1) * n + (col - 1); } public void open(int row, int col) { validate(row, col); int idx = indexOf(row, col); if (open[idx]) return; open[idx] = true; openCount++; // 首行连顶部虚拟节点,末行连底部 if (row == 1) uf.union(idx, top); if (row == n) uf.union(idx, bottom); // 与四邻中已开放的格子合并 int[][] dirs = {{-1, 0}, {1, 0}, {0, -1}, {0, 1}}; for (int[] d : dirs) { int r = row + d[0], c = col + d[1]; if (r >= 1 && r <= n && c >= 1 && c <= n && open[indexOf(r, c)]) { uf.union(idx, indexOf(r, c)); } } } public boolean percolates() { return uf.connected(top, bottom); } }逻辑上有两个点值得说清楚。第一,open用一维布尔数组而不是二维,是因为课程对内存有隐性要求,N 大时二维数组的对象开销不可忽视;坐标转换函数indexOf承担了所有行列映射,只要它写对,其余逻辑就不会错位。第二,注意percolates()里的connected(top, bottom):当 N=1 时,一个格子同时连了顶部和底部,会直接返回 true,这是正确行为,但很多人写测试时忘了这个边界,导致自测失败。
还有个经典坑:如果只在open()里检查邻居是否开放,却忘了open数组和并查集状态同步,会出现「格子明明开着但 union 没做」的情况。现象是percolates()偶尔提前返回 false,调试时打印并查集根节点能看出来。解决方式是把「标记开放」和「合并邻居」放在同一个方法里,中间不要有提前 return。
3.2 RandomizedQueue 与 Deque:两种容器的取舍
Deque 和 RandomizedQueue 在同一道作业里,考的是同一件事的两种实现思路。Deque 要求双向操作,标准解法是用双向链表加哨兵节点,保证addFirst、addLast、removeFirst、removeLast全部 O(1)。RandomizedQueue 要求随机出队,标准解法是用可扩容数组,出队时把随机位置的元素和末尾交换再删除,避免System.arraycopy的整体搬家。
public class RandomizedQueue<Item> implements Iterable<Item> { private Item[] items; private int size; @SuppressWarnings("unchecked") public RandomizedQueue() { items = (Item[]) new Object[2]; // 泛型数组不能直接 new,需要强转 } public void enqueue(Item item) { if (item == null) throw new IllegalArgumentException("null item"); if (size == items.length) resize(2 * items.length); items[size++] = item; } public Item dequeue() { if (isEmpty()) throw new NoSuchElementException("queue is empty"); int r = StdRandom.uniform(size); // 随机选一个下标 Item result = items[r]; items[r] = items[--size]; // 末尾元素填坑,O(1) 删除 items[size] = null; // 避免对象游离,帮助 GC if (size > 0 && size == items.length / 4) resize(items.length / 2); return result; } @SuppressWarnings("unchecked") private void resize(int capacity) { Item[] copy = (Item[]) new Object[capacity]; for (int i = 0; i < size; i++) copy[i] = items[i]; items = copy; } }这里有两个参数必须盯住。扩容倍数取 2,收缩阈值取length / 4,这两个数字配合起来才能保证均摊 O(1) 并且避免「反复扩容收缩」的抖动(如果收缩阈值也用length / 2,在临界点反复增删会导致性能崩掉)。items[size] = null这一行不是可选项:如果不置空,数组里保留着已出队对象的强引用,大对象场景下内存泄漏就是这么来的。
迭代器实现是这题第二个容易丢分的地方。课程要求iterator()返回的迭代器支持随机顺序遍历且不能修改原队列,常见做法是先拷贝一份数组再用StdRandom.shuffle打乱副本,然后顺序遍历。注意副本拷贝要在构造时完成,别在next()里做,否则每次调用开销叠加。
3.3 Kd-Tree:二维空间查询的分治落地
Kd-Tree 是全系列里设计感最强的一题。它要求实现 2d-tree 支持两种查询:给定矩形范围返回所有落入的点(range search),以及给定点返回最近的邻居(nearest neighbor)。暴力版PointSET是线性扫描,作为正确性对照基准;KdTree则要求达到对数级的平均性能。
核心是节点交替按 x、y 维度比较。插入时当前层按 x 比较,下一层按 y,再下一层回到 x。范围查询时先判断当前节点是否落在矩形内,再根据矩形与切分线的关系决定要不要递归左子树、右子树——这里可以用剪枝,但注意矩形完全覆盖一侧时两侧都要查。
public void draw() { draw(root, true); } private void draw(Node node, boolean vertical) { if (node == null) return; StdDraw.setPenColor(StdDraw.BLACK); node.point.draw(); if (node.left != null) { StdDraw.setPenColor(StdDraw.RED); if (vertical) { // 竖线:x 相同,y 从节点到左子节点 StdDraw.line(node.point.x(), node.point.y(), node.point.x(), node.left.point.y()); } else { StdDraw.line(node.point.x(), node.point.y(), node.left.point.x(), node.point.y()); } draw(node.left, !vertical); } // 右子树同理,略 }上面这段是可视化方法,看着简单但反映了一个关键设计:递归时要把「当前按哪个维度切分」作为参数传递,而不是靠每层重新计算。如果你在递归里重新算,必须保证和插入时的维度选择完全一致,否则画出来的树和实际结构对不上,调试 nearest neighbor 时会非常困惑。
最近邻查询是这题最烧脑的部分。思路是:到节点时更新当前最优距离,然后先递归「查询点所在那一侧」的子树,再判断另一侧子树是否可能包含更近的点——判断依据是查询点到切分线的垂直距离是否小于当前最优距离。这个剪枝条件写错就会出现「结果不精确但测试偶尔通过」的玄学现象。建议先用PointSET的暴力结果做对拍:随机生成 1000 个点,对每个查询点分别跑两个实现,比较距离是否相等,快速定位错误。
3.4 Seam Carving:能量图与最短路径的组合
这题把两个基础算法拼在一起:图像的能量计算(双梯度能量)和找最短路径(动态规划或 Dijkstra)。能量图要求对每个像素求 x 方向和 y 方向的颜色梯度平方和再开方,边界像素能量固定为 1000(课程的约定值,避免边界永远被选为 seam)。
public double energy(int x, int y) { if (x < 0 || x >= width() || y < 0 || y >= height()) throw new IllegalArgumentException("coordinates out of bounds"); // 边界像素能量固定为 1000 if (x == 0 || x == width() - 1 || y == 0 || y == height() - 1) return 1000.0; Color left = picture.get(x - 1, y); Color right = picture.get(x + 1, y); Color up = picture.get(x, y - 1); Color down = picture.get(x, y + 1); return Math.sqrt(gradient(left, right) + gradient(up, down)); } private double gradient(Color a, Color b) { int dr = a.getRed() - b.getRed(); int dg = a.getGreen() - b.getGreen(); int db = a.getBlue() - b.getBlue(); return dr * dr + dg * dg + db * db; }get(x, y)的参数顺序是 x 在前、y 在后,这是课程Picture类的约定,和矩阵习惯的[row][col]正好相反。这个细节导致过大量「能量图算反了但程序不报错」的 bug,现象是水平和垂直 seam 结果互换。解决方式是在所有方法入口统一用validate检查坐标范围,强制自己每次写之前念一遍顺序。
找 seam 阶段,常见做法是动态规划:distTo[x][y]表示从第 0 行到 (x, y) 的最小累计能量,转移时从左上、正上、右上三个前驱取最小值。删除 seam 后不需要重算整张能量图,但如果你的实现是「每次都重算」,代码会简单很多,代价是性能。课程对时间有要求的话,必须在删除后只增量更新受影响的行,这块是最容易写出 O(n³) 的陷阱。
4. 避坑与排查:这几处不处理好,交了也白交
4.1 API 签名改动导致的静默失分
现象:本地自测全过,提交后分数很低,检查器也没报格式错误。原因通常是改了公开方法的签名或增减了 public 方法。课程评分系统通过反射调用你类的指定方法,签名不符时它拿不到结果,不会报「方法不存在」,而是按「测试未通过」计。解决办法很简单:在改动任何 public 方法前后,把作业说明里的 API 清单逐条对一遍,尤其注意异常类型——要求IllegalArgumentException就不能抛IndexOutOfBoundsException,两者在测试用例里会被区分。
4.2 时间复杂度的测试数据卡点
现象:小数据通过,大数据超时。原因多半是隐藏的循环。最典型的是Percolation.isOpen里做了坐标验证却调用了两次indexOf,以及FastCollinearPoints里没对 degenerate 情况剪枝。解决方式是用计时器粗测:把 N 从 100 递增到 1000,画出耗时曲线,曲线是线性还是超线性一眼能看出来。别只信大 O,实测斜率才是硬道理。
4.3 空指针与对象游离
现象:NullPointerException出现在dequeue或iterator里,且只在多次操作后复现。原因是数组出队后没置空,或者迭代器拷贝时机不对。排查方式是在出队和迭代首步加断言。更隐蔽的是对象游离导致的内存问题:数组容量大而有效元素少时,GC 压力剧增。养成「删除后就置 null」的习惯,能省掉一大类问题。
4.4 可视化类引用导致的提交污染
现象:提交失败或评分器提示找不到类。原因是你把PercolationVisualizer这类依赖StdDraw的调试代码混进了要提交的文件,或者主类里 import 了课程库之外的类。解决方式是把可视化代码单独放一个目录,提交前只打包题目要求的那几个文件。这个坑我踩过不止一次,后来固定用脚本只拷贝白名单文件,才彻底断根。
4.5 浮点比较的容差问题
现象:SeamCarver的能量值或Point的距离比较结果「差一点点」。原因是直接用==比较 double。课程测试的容差通常是 1e-9 级别,涉及能量、距离的地方一律用Math.abs(a - b) < 1e-12这种形式。另外注意compareTo实现的传递性,Point的斜率比较在纯垂直情况下容易写出非传递的比较器,导致排序结果不稳定,进而让 collinear 的点漏报。
5. 二次开发与验证:把解题代码改成自己的算法工具箱
这套代码最有价值的二次开发方向,是把它从「一次性作业」改造成可复用的数据结构库。具体做法是抽出一个algs-core模块,把Deque、RandomizedQueue、KdTree、并查集这些独立于题目的类挪进去,去掉课程库依赖(StdRandom换成java.util.Random,StdDraw只在测试里用),然后补一套 JUnit 测试。改造时最容易忽略的是泛型擦除:RandomizedQueue<Item>里的数组强转在编译期是 warning,运行时靠约定保证安全,抽取到公共库时必须保留@SuppressWarnings("unchecked")并写清楚前提条件,否则后续使用的人会踩到 ClassCastException。
验证方法我一般分三层走。第一层是对拍:用暴力实现当基准,随机生成输入,比较两个实现的输出。这套代码里天然自带对拍关系——PointSET对KdTree,BruteCollinearPoints对FastCollinearPoints,直接把两组类对应起来写个驱动就能跑。第二层是边界回归:N=1 的网格、空队列出队、单点图、全同色图像,这些 case 单独建一个测试文件,每次改动先跑一遍。第三层是性能基线的记录,把各题在固定 N 下的耗时记进一个表格,重构后对比:
| 模块 | 测试规模 | 暴力实现耗时 | 优化实现耗时 | 预期复杂度 |
|---|---|---|---|---|
| 并查集渗透 | N=1000 单次 | 约 980ms | 约 12ms | O(log N) 均摊近 O(1) |
| Kd-Tree 范围查询 | 10 万点 | 约 2.1s | 约 8ms | 暴力 O(N),树 O(√N + K) |
| 共线点检测 | 2000 点 | 约 430ms | 约 35ms | 暴力 O(N⁴),排序法 O(N² log N) |
表中的耗时来自我在一台普通开发机上的粗略记录,只用于同环境前后对比,不是精确基准,换个机器绝对值会变,但比例关系是有参考价值的。
再进一步,可以把 Kd-Tree 扩展成支持 k 近邻(不只是一个最近点),把并查集扩展成带权路径压缩加按秩合并的版本,把 RandomizedQueue 扩展成支持优先级出队。这些扩展不在课程要求里,但正是检验你是否真理解了数据结构内部机制的试金石。我的习惯是每改完一个模块,先不急着接入业务,而是把它丢进对拍脚本里跑十分钟随机输入,确认没有回归再继续。从那以后我每次从课程代码往工程库迁移,都强制走一遍「对拍 → 边界回归 → 性能基线」这三步,没别的窍门,就是省时间。希望帮到你。
本文还有配套的精品资源,点击获取