
简介面向正在学习普林斯顿大学《算法》课程或希望系统刷通课后编程作业的Java学习者这份解答集围绕排序、图算法、动态规划、数据结构与复杂度分析等核心主题提供可直接对照的参考实现与思路线索适合用来校验作业完成度、理解题目建模过程并补齐实现细节。压缩包共20个文件以18个Java源码为主体另有1份Markdown说明和1份许可证文件整体约28KB源码按作业模块组织便于按主题检索和逐题研读说明文件则帮助快速了解目录用途与使用边界。目前已有245人学习下载。读者可从中获得各编程作业的完整代码框架、关键算法落地方式、边界条件处理与调试参照尤其适合在渗透模型、Kd树、词网、接缝裁剪、八数码等经典任务中对照自己的实现查漏补缺并加深对算法设计与分析的理解。1. 一份被自动评分器反复打回的 Java 算法习题解答能教你的比答案多大部分人打开一份算法习题解答仓库是想找标准答案抄一遍交差。真正值得拆的是另一面这里每个类都先被 API 契约钉死再被内存上限和运行时间卡死最后被风格检查器逐行扫描——你在业务代码里很少遇到这么苛刻的约束。这份 algs4 习题解答用纯 Java 实现覆盖并查集、双端队列、归并排序、DAG 最短路径、A* 搜索这些高频结构每一题都是独立可编译的小工程。适合两类人想照着把经典算法真正手写一遍的 Java 开发者以及想看看「带性能预算的代码」到底长什么样的进阶读者。它不教你算法思想它逼你把思想写成能过关的代码。2. 仓库骨架与技术栈从 algs4.jar 到编译脚本的落地链路剥开压缩包第一件事我一般不是找答案而是先看它怎么把十几个互不相干的作业塞进一个仓库还不打架。这个仓库的做法很朴素每个作业一个顶层目录源码、测试数据、脚本各归各位公共依赖统一放在lib/和tools/下。理解这套骨架后面无论是单题调试还是整仓编译都不会因为路径问题翻车。2.1 目录布局一题一个可独立编译的单元先看整理后的目录形态我按这个思路在本地重排过一遍比原始文件堆在一起好找得多algs4-Programming-Assignment/ ├── lib/ │ └── algs4.jar # 课程标准库In/Out/StdRandom/WeightedQuickUnionUF 等 ├── tools/ │ ├── checkstyle-*.jar # 风格检查器 │ └── checkstyle-algs4.xml # 规则集花括号位置、空格、Javadoc、行宽 ├── percolation/ │ ├── src/ │ │ ├── Percolation.java │ │ └── PercolationStats.java │ └── test/ │ ├── input20.txt │ └── input2k.txt ├── queues/ │ └── src/ │ ├── Deque.java │ ├── RandomizedQueue.java │ └── Permutation.java ├── collinear/ │ └── src/ │ ├── Point.java │ ├── BruteCollinearPoints.java │ └── FastCollinearPoints.java ├── seam/ │ └── src/ │ ├── SeamCarver.java │ └── SeamCarvingDemo.java └── puzzle/ └── src/ ├── Board.java └── Solver.java这里有个几乎所有人都要踩的细节src/下的类全部待在默认包里文件第一行不能出现package xxx;。评分器是按类名直接反射调用的你哪怕写个package percolation;本地 IDE 里跑得再顺提交上去也是零分。我一般把src目录标记成 Source Root 来获得 IDE 的补全和跳转而不是靠加包名解决这条经验在后面避坑章还会展开。测试数据和源码分开放的好处很实际In类读文件是相对于 JVM 的工作目录不是相对于.class文件所在目录。你把数据文件塞进src/在 IDE 里点运行能读到换到命令行cd到别的目录再跑就FileNotFoundException。分开之后命令行里统一用「工作目录 相对路径」的写法行为就可预期了。还有一个隐性约定每个作业目录基本都能独立编译互相之间没有引用。这意味着你可以只cd进某个目录把那一题当成一个只有两三个文件的小项目来读不用先理解整仓。对新手来说这一点很友好别一上来就想把全部源码通读一遍。2.2 编译与运行链路-cp与-d的顺序不能错这个仓库没有 Maven 也没有 Gradle就是裸的javac加java。看起来很土但它恰好让你看清 Android Studio 或 IDEA 帮你隐藏掉的那一层。我通常加一个build.sh把命令固化下来#!/usr/bin/env bash set -euo pipefail CPlib/algs4.jar OUTout mkdir -p $OUT # -d 指定 class 输出目录避免 .class 混进 src javac -encoding UTF-8 -Xlint:all -cp $CP -d $OUT \ percolation/src/*.java # 运行时 classpath 顺序先自己的类目录再第三方 jar java -cp $OUT:lib/algs4.jar PercolationStats percolation/test/input20.txt 200参数逐个说清楚这些是新手最容易含糊的地方。-encoding UTF-8是给源码里的中文注释用的不写的话在默认 GBK 的终端里编译会报「编码 GBK 的不可映射字符」。-cp在编译期只需要algs4.jar因为你自己的类都在同一批源文件里运行期则必须同时带上out和algs4.jar否则NoClassDefFoundError: edu/princeton/cs/algs4/In会直接糊脸上。-d不指定的后果是.class文件跟.java躺在同一个目录git status一片红。分隔符这件事值得单独提一句上面脚本里out:lib/algs4.jar用的是冒号那是 Linux 和 macOS 的写法Windows 的java.exe认分号out;lib\algs4.jar。写成跨平台脚本的话可以用$OUT${CP_SEP}lib/algs4.jar配合case $(uname -s)判断或者干脆在 WSL 里跑。血泪经验是classpath 分隔符写错时报错信息不会说「你分隔符错了」它只会说找不到类然后你开始怀疑人生。-Xlint:all是我自己加的仓库里不一定有。它的价值在于把「泛型数组创建」这种非受检警告显式打出来——Deque里那行(Item[]) new Object[cap]一定会触发看到这条警告你才知道自己写的是 Java 里合法的丑代码而不是编译器坏了。2.3 依赖清单algs4.jar 里哪些能直接用algs4.jar是这个仓库唯一的第三方依赖里面装的不是黑盒而是一批可以直接调用的教学级实现。摸清哪些能用、哪些作业禁止用能省掉大量重复造轮子的时间类用途作业里的常见用法In/Out文件与标准输入输出读测试数据new In(args[0])StdRandom随机数洗牌、随机队列的删除下标StdStats均值、标准差PercolationStats输出统计量StopwatchCPUCPU 计时测每轮操作的耗时比 wall clock 稳WeightedQuickUnionUF带权并查集Percolation直接用它不用自己写MinPQ/MaxPQ二叉堆A*、Dijkstra、HuffmanDigraph/EdgeWeightedDigraph图结构WordNet、Seam Carving 的 DAGPoint二维点骨架需要自己补compareTo和slopeOrder需要留意的是Point只给了字段和方法签名compareTo与斜率比较器是空的得自己填WeightedQuickUnionUF则是完整实现Percolation里直接用是合规的。判断某个类能不能用的标准很简单课程给出的 API 文档里没列出来的就当它不存在别去new Digraph做 WordNet 之外的事情然后疑惑为什么风格检查报警告。Java 版本上仓库里的代码没有用任何新语法Java 8 完全够跑Java 11 和 17 也能编译通过。反倒是checkstyle的 jar 和配置文件之间有语法绑定关系——配置文件里的module名在不同大版本间改过用新版本 jar 去加载旧配置会直接抛CheckstyleException。稳妥的做法是tools/目录里那一对配套的 jar 和 xml 原样保留别单独升级其中一个。3. 核心模块拆解Percolation 与 Deque——把契约、内存和边界一次写对如果说这个仓库有哪两题最能体现「工程约束下的算法实现」我会选渗流模型和双端队列。前者考的是你能不能在一堆虚拟节点和两份并查集之间维持语义正确后者考的是你对数组扩容、对象游离和迭代器状态的理解。两题的共同点是逻辑写对只是及格内存和时间预算才是分水岭。3.1 API 契约先定索引从 1 开始Percolation的接口是写死的一个方法名都改不得。它的行列入参是 1-based也就是open(1, 1)表示左上角越界必须抛IllegalArgumentException而不是返回 false方法参数范围异常语义Percolation(int n)n 0IllegalArgumentException建 n×n 全封闭网格open(int row, int col)1 ≤ row,col ≤ n越界抛IllegalArgumentException打开格子并连通四邻isOpen(int row, int col)同上同上该格是否已打开isFull(int row, int col)同上同上是否与顶部连通有水的等价说法numberOfOpenSites()无无已打开格子数percolates()无无顶底是否连通PercolationStats则是一个蒙特卡洛驱动反复随机开格直到渗流记录打开比例最后用StdStats.mean和StdStats.stddev输出均值、标准差和 95% 置信区间。它的构造函数签名是(int n, int trials)参数校验同样要抛异常。这两条契约决定了实现的形状你不能图省事在内部用 0-based 索引然后在外层做转换那样每个方法都得写两次边界判断还容易漏。最省心的做法是内部索引也保持 1-based只在压平成一维数组时减一次。3.2 双并查集 一维数组percolates与isFull必须分开算先看代码这段是整个仓库里最值得逐行读的实现import edu.princeton.cs.algs4.WeightedQuickUnionUF; public class Percolation { private static final byte CLOSED 0; private static final byte OPEN 1; private final int n; private final byte[] state; // 1-based 压平省内存 private final WeightedQuickUnionUF uf; // 含上、下两个虚拟点 private final WeightedQuickUnionUF ufNoBottom; // 只含上虚拟点 private final int virtualTop; private final int virtualBottom; private int openCount; public Percolation(int n) { if (n 0) throw new IllegalArgumentException(n must be positive); this.n n; this.state new byte[n * n 2]; this.uf new WeightedQuickUnionUF(n * n 2); this.ufNoBottom new WeightedQuickUnionUF(n * n 1); this.virtualTop 0; this.virtualBottom n * n 1; this.openCount 0; } private int id(int row, int col) { validate(row, col); return (row - 1) * n col; // 1..n*n 留给真实格子 } private void validate(int row, int col) { if (row 1 || row n || col 1 || col n) { throw new IllegalArgumentException( row/col out of range: row , col); } } public void open(int row, int col) { int p id(row, col); if (state[p] OPEN) return; // 幂等重复打开直接返回 state[p] OPEN; openCount; if (row 1) { uf.union(p, virtualTop); ufNoBottom.union(p, virtualTop); } if (row n) { uf.union(p, virtualBottom); } int[] dr {-1, 1, 0, 0}; int[] dc {0, 0, -1, 1}; for (int k 0; k 4; k) { int nr row dr[k], nc col dc[k]; if (nr 1 || nr n || nc 1 || nc n) continue; // 边界直接跳过 int q (nr - 1) * n nc; if (state[q] OPEN) { uf.union(p, q); ufNoBottom.union(p, q); } } } public boolean isOpen(int row, int col) { return state[id(row, col)] OPEN; } public boolean isFull(int row, int col) { int p id(row, col); return state[p] OPEN ufNoBottom.connected(p, virtualTop); } public int numberOfOpenSites() { return openCount; } public boolean percolates() { return uf.connected(virtualTop, virtualBottom); } }逻辑说明按顺序走一遍。state用byte而不是boolean[][]是为了避开二维数组的数组头开销和指针跳转对 n 2000 这种规模boolean[n2][n2]会额外带来几 MB 的指针和数组对象开销而byte[n*n2]就是一个连续块。virtualTop 0和virtualBottom n*n1是两个哨兵真实格子的 id 从 1 到 n²正好互不冲突数组长度取n*n2就够。真正关键的是为什么需要两份并查集。只用uf一份的话底部虚拟点会把最后一行所有格子连成一片一旦某格和底部连通它就和底部那片区域在同一个集合里isFull判断「是否与顶部连通」时就会出现回灌——一个明明没喝到水的格子因为底下有水而被判成满的。这个现象在讨论里常被叫作 backwash。解决办法很直接percolates()用带上下两个虚拟点的uf判断顶底连通isFull()用只有上虚拟点的ufNoBottom判断两份结构各管一件事语义就干净了。参数上还有两个容易忽略的点。open开头那个提前返回不只是省时间它还保证了并查集里不会出现重复 union 导致openCount被多加state[p] OPEN的检查必须放在自增之前。四个方向的偏移数组dr/dc我写成了方法内局部变量如果这题跑大数据被卡时间把这两个数组提成static final字段能少一点分配——不过通常没必要JIT 会把它们当常量处理。3.3 环形数组 Deque扩容、取模与迭代器快照双端队列这一题的约束很直白addFirst/addLast/removeFirst/removeLast全部 O(1) 摊还不允许用java.util.LinkedList每个元素的内存开销也要控制。链表看着简单但每个节点一个对象加上引用字段和对象头n 个元素的实际占用是理论值的两倍以上内存检查一跑就红灯。环形数组是对得上的答案import java.util.Iterator; import java.util.NoSuchElementException; public class DequeItem implements IterableItem { private static final int INIT_CAPACITY 8; private Item[] a; private int head; // 队首元素在数组中的下标 private int size; SuppressWarnings(unchecked) public Deque() { a (Item[]) new Object[INIT_CAPACITY]; // 泛型数组只能强转 head 0; size 0; } public boolean isEmpty() { return size 0; } public int size() { return size; } public void addFirst(Item item) { if (item null) throw new IllegalArgumentException(null item); if (size a.length) resize(a.length 1); head (head - 1 a.length) % a.length; // 先退一格再写注意负下标 a[head] item; size; } public void addLast(Item item) { if (item null) throw new IllegalArgumentException(null item); if (size a.length) resize(a.length 1); a[(head size) % a.length] item; size; } public Item removeFirst() { if (isEmpty()) throw new NoSuchElementException(deque is empty); Item item a[head]; a[head] null; // 必须置空否则对象游离 head (head 1) % a.length; size--; if (size 0 size a.length / 4) resize(a.length 1); return item; } public Item removeLast() { if (isEmpty()) throw new NoSuchElementException(deque is empty); int last (head size - 1) % a.length; Item item a[last]; a[last] null; size--; if (size 0 size a.length / 4) resize(a.length 1); return item; } Override public IteratorItem iterator() { return new ArrayIterator(); } private class ArrayIterator implements IteratorItem { private int offset 0; // 相对 head 的偏移不是绝对下标 private final int snapshot size; // 迭代开始时的大小 public boolean hasNext() { return offset snapshot; } public Item next() { if (!hasNext()) throw new NoSuchElementException(no more items); Item item a[(head offset) % a.length]; offset; return item; } public void remove() { throw new UnsupportedOperationException(); } } SuppressWarnings(unchecked) private void resize(int capacity) { Item[] copy (Item[]) new Object[capacity]; for (int i 0; i size; i) { copy[i] a[(head i) % a.length]; // 重排成从 0 开始的连续段 } a copy; head 0; // 重排后队首回到下标 0 } }head的语义是「队首元素在数组里的物理下标」这个定义让addLast的下标计算变成(head size) % a.lengthremoveLast变成(head size - 1) % a.length都不需要额外的 tail 指针也就不用维护两套指针的一致性。Java 的%对负数返回负值所以addFirst里必须先 a.length再取模写成(head - 1) % a.length在head 0时会得到 -1然后数组越界。缩容阈值卡在size a.length / 4而不是/2是为了避免在临界点上来回扩容缩容的抖动。size 0的判断也不能省否则空队列在最后一个remove之后会缩到长度 0下一次add就崩了。remove里那行a[head] null属于典型的内存细节数组持有的是强引用不清空的话对象即使从队列逻辑上删掉了也回收不了短时间跑百万次操作内存曲线会很漂亮时间一长就露出原形。迭代器里snapshot那一行是另一个高频翻车点。如果直接拿外层的size做上界迭代过程中调用addLast会让循环永远停不下来用迭代开始时的快照则保证遍历到的是「迭代开始时队列里的那些元素」。remove抛UnsupportedOperationException是题目要求别手贱去实现它。4. 核心模块拆解排序、DP 与 A*——三类搜索型作业的落地骨架剩下几题看起来分属不同领域但骨架是通的把问题转化成一个有向图或者一个可排序的序列然后在上面跑搜索。共线点找的是排序后的相等斜率段接缝裁剪是在 DAG 上推最短路八数码是带启发式的优先队列搜索。三题的代码量都不大难点全在「怎么把问题翻译成这些结构」这一步。4.1 Collinear Points按斜率排序后扫连续相等段这一题的暴力版要求四重循环找所有共线四点快速版要求O(n² log n)核心是对每个点 p计算它到其余所有点的斜率排序斜率相同的连续一段就落在同一条过 p 的直线上。长度 ≥ 3 的这一段除 p 外至少 3 个点就对应一条至少四点的线段端点取 p 和这一段里离 p 最远的点import java.util.ArrayList; import java.util.Arrays; import java.util.HashSet; import java.util.List; import java.util.Set; import edu.princeton.cs.algs4.Point; import edu.princeton.cs.algs4.LineSegment; public class FastCollinearPoints { private final ListLineSegment segments new ArrayList(); public FastCollinearPoints(Point[] points) { if (points null) throw new IllegalArgumentException(null array); Point[] sorted points.clone(); for (Point p : sorted) { if (p null) throw new IllegalArgumentException(null point); } Arrays.sort(sorted); // 先按坐标全序排便于去重 for (int i 1; i sorted.length; i) { if (sorted[i].compareTo(sorted[i - 1]) 0) { throw new IllegalArgumentException(duplicate points); } } if (sorted.length 4) return; SetLineSegment found new HashSet(); for (int i 0; i sorted.length; i) { Point p sorted[i]; // 只取排在 p 之后的点避免同一条线段被两个端点各扫一遍 Point[] rest Arrays.copyOfRange(sorted, i 1, sorted.length); Arrays.sort(rest, p.slopeOrder()); int j 0; while (j rest.length) { int k j; while (k 1 rest.length p.slopeTo(rest[k]) p.slopeTo(rest[k 1])) { k; } if (k - j 1 3) { // p 本身再算一个点 found.add(new LineSegment(p, rest[k])); } j k 1; } } segments.addAll(found); } public int numberOfSegments() { return segments.size(); } public LineSegment[] segments() { return segments.toArray(new LineSegment[0]); } }几个决策点值得说。只对i之后的点排序是因为sorted已经按坐标全序排过了任何一条共线线段的最小端点必然在最大端点前面只从最小端点出发扫一遍就够了省掉一半工作量和一整套去重逻辑。slopeTo返回double用直接比较是有风险的——两个数学上相等的斜率如果用不同方式算出末位可能差一个 ulp。这个风险在坐标是整数、且同一批点用的是同一个p作为起点时实际上可以忽略因为除法两边完全对称。真要不放心就在Point里把slopeTo重写成交叉相乘的比较器全程用整数比代价是要自己写排序。我一般先用double版本等评分器报出漏线段了再换。segments()每次返回新数组也是题面要求直接返回内部List的话调用方一改就污染了内部状态。HashSet去重在这里是兜底手段正常情况下同一个最小端点只会产生一条相同的线段但如果你偷懒改成对每个点都扫全量点去重就成了必需品。4.2 Seam Carving在 DAG 上自底向上推最短路径接缝裁剪把一张图看成 DAG每个像素是一个顶点每个像素指向下一行左中右三个相邻像素边权是目标像素的能量。要找的是从上到下的最小能量路径。自底向上递推比调AcyclicSP更省内存因为每个顶点只依赖下一行的三个值滚动一维数组就够但为了回溯接缝还是得留一个edgeTo// 预计算能量避免在 DP 里重复算像素梯度 private double[][] energy; private double[][] distTo; private int[][] edgeTo; private int width, height; private void solve() { distTo new double[height][width]; edgeTo new int[height][width]; // 最后一行是终点distTo 就是自身能量 for (int col 0; col width; col) { distTo[height - 1][col] energy[height - 1][col]; } // 自底向上每格从下一行的左中右取最小 for (int row height - 2; row 0; row--) { for (int col 0; col width; col) { double best Double.POSITIVE_INFINITY; int bestCol col; for (int d -1; d 1; d) { int nc col d; if (nc 0 || nc width) continue; // 边界不越界 if (distTo[row 1][nc] best) { best distTo[row 1][nc]; bestCol nc; } } distTo[row][col] energy[row][col] best; edgeTo[row][col] bestCol; } } } // 回溯从第一行 distTo 最小的列出发顺着 edgeTo 一路往下 private int[] findHorizontalSeam() { int minCol 0; for (int col 1; col width; col) { if (distTo[0][col] distTo[0][minCol]) minCol col; } int[] seam new int[height]; seam[0] minCol; for (int row 1; row height; row) { seam[row] edgeTo[row - 1][seam[row - 1]]; } return seam; }能量必须提前算好存下来这是性能上的关键。直觉写法是在solve里面现调energy(row, col)但那个方法内部要读六个邻居像素、做三次平方被调用的次数是3 * height * width量级测试图一大就超时。缓存之后每格只算一次代价是double[height][width]的内存——短边不超过 200 的规模下这点占用完全划算。edgeTo存的是「下一行走哪一列」而不是存整条接缝所以回溯时是从第一行往下顺推不是倒着走。这一点很容易写反distTo的递推方向是自底向上回溯方向是自顶向下两者相反。写错的表现是接缝能从底走到顶但完全不通energy总和跟distTo对不上。删完一条接缝还要能再删下一条removeHorizontalSeam和removeVerticalSeam里重建图像之后必须把energy和distTo全部标记失效或者重算。漏掉这一步第二次调用findVerticalSeam拿到的还是旧图的数据评分器的示例图恰好能过一上真实图片就全是花屏。4.3 8 PuzzleA* Manhattan 启发式 不走回头路八数码的可解性判断有个很巧的做法把初始状态里任意两个非空方块交换得到 twin 状态跑同样的 A*。如果 twin 有解而原状态无解说明原状态不可解——因为 16 格中恰好一半可解、一半不可解twin 一定落在另一半。求解器主干如下import edu.princeton.cs.algs4.MinPQ; public class Solver { private static class Node implements ComparableNode { private final Board board; private final int moves; private final int manhattan; private final Node prev; Node(Board board, int moves, Node prev) { this.board board; this.moves moves; this.manhattan board.manhattan(); this.prev prev; } // 优先级 已走步数 曼哈顿距离并列时优先启发值小的 public int compareTo(Node that) { int f1 this.moves this.manhattan; int f2 that.moves that.manhattan; if (f1 ! f2) return Integer.compare(f1, f2); return Integer.compare(this.manhattan, that.manhattan); } } private final Node goalNode; private final boolean solvable; public Solver(Board initial) { if (initial null) throw new IllegalArgumentException(null board); Node solved search(initial); if (solved ! null) { this.goalNode solved; this.solvable true; } else { this.goalNode search(initial.twin()); // 换 twin 再跑一次 this.solvable false; } } private Node search(Board start) { MinPQNode pq new MinPQ(); pq.insert(new Node(start, 0, null)); while (!pq.isEmpty()) { Node cur pq.delMin(); if (cur.board.isGoal()) return cur; for (Board next : cur.board.neighbors()) { // 关键剪枝不走回父节点 if (cur.prev ! null next.equals(cur.prev.board)) continue; pq.insert(new Node(next, cur.moves 1, cur)); } } return null; } }compareTo的并列打破规则不是硬性要求但对性能影响不小。两个节点 f 值相同时优先展开曼哈顿距离更小的那个等于更靠近目标能显著减少堆的规模。反过来如果完全不给次级比较MinPQ会退化成按插入顺序出队搜索树展开量能差好几倍。cur.prev ! null next.equals(cur.prev.board)这行剪枝也必须写。没有它A* 会在「走一步再走回来」的循环上反复插入节点moves一直增长、优先队列永远不空测试用例的 30 步内虽然能出结果但耗时直接翻倍。这里的判断用equals比较Board内容而不是引用因为neighbors()每次返回的是新建对象。内存是这一题真正的天花板。每个Node持有一个Board每个Board持有一个int[][]加一个缓存好的曼哈顿值优先队列里同时挂着成千上万个这样的对象。把Board里的棋盘压成一维byte[]8 个数字的取值范围是 0 到 8能省下二维数组的指针开销把manhattan在构造时算一次缓存起来而不是每次compareTo现算——后者会让堆操作的复杂度里多出一个常数因子实测差距很明显。5. 避坑与排查5 个让本地跑通、提交零分的坑这一章写的都是我自己撞过的墙。共同特征是本地看着一切正常评分跑完才发现问题而且报错信息极其含糊只有一个「0 分」或者一个超时。按现象、原因、解决三步走。现象一本地编译运行全绿提交后各项全 0 分。原因几乎必然是类名或者包声明跟契约不符。评分器不读你的main它是按固定类名去实例化并调方法的。你在文件里加了package collinear;类就变成了collinear.FastCollinearPoints它按默认包的FastCollinearPoints去加载直接ClassNotFoundException然后把整项判零。另一个变体是文件名和类名不一致FastCollinearPoint.java里写class FastCollinearPointsjavac直接报错你却在 IDE 里点绿色三角跑得飞起。解决办法是把「默认包」当成硬约束写进流程提交前在仓库根跑一遍grep -rn ^package --include*.java .有输出就说明哪里漏了。IDE 里靠 Source Root 拿补全别靠包名。现象二percolates()提前返回 true或者isFull()明显多判了。原因是只维护了一份并查集底部虚拟点把最后一行连成一片水从底往上「回灌」了。具体表现是某次open之后percolates()立刻变成 true即使网格中间还空着一大片或者一个从没和顶部连通的格子被判成满的。解决办法就是 3.2 里那套双并查集percolates()用带上下的那份isFull()用只有上虚拟点的那份。写完之后拿一个 3×3 的小例子手推一遍打开 (1,1)、(2,1)、(3,1)此时percolates()应该是 true而isFull(2,2)必须是 false。这个小例子能筛掉绝大多数回灌错误。现象三Deque那题时间或内存超限n到十万就开始抖。原因通常有两个。一是用了java.util.LinkedList或者自己写的链表节点每个元素一个对象内存是数组实现的两倍以上直接撞上内存上限。二是每次本文还有配套的精品资源点击获取