资讯动态

Algs4编程题实战:红黑树、并查集与图算法的工程化落地

发布时间:2026/10/20 15:31:57 来源:尧图企业网站定制
简介本资源是普林斯顿大学Coursera《算法》algs4课程配套编程作业的完整Java实现与解析面向计算机专业学生、算法自学者及Java进阶开发者旨在解决课程习题无参考答案、实现思路模糊、调试无从下手等典型学习痛点。压缩包共20个文件含18个核心Java源码覆盖Percolation渗流模型、KdTree空间搜索、SeamCarving图像缩放、8Puzzle求解器、WordNet语义网络等全部经典实验、1份README.md说明文档及1份LICENSE协议总大小仅28KB轻量易读结构清晰便于逐模块对照学习与调试。已有243人下载学习代码严格遵循课程API规范含完整单元测试支撑与可视化组件如InteractivePercolationVisualizer不仅提供可运行方案更通过注释与模块划分体现算法设计逻辑与工程实践细节是夯实数据结构与算法基础、提升Java面向对象实现能力的高价值学习材料。1. 这不是“抄答案”而是用《算法4》编程题反向吃透红黑树、优先队列与图遍历的实战路径你打开 Coursera 上那门被称作“算法界托福”的普林斯顿大学算法课Algorithms, Part I II点开 week 5 的Percolation编程作业——界面干净只有 PDF 描述、Percolation.java模板和StdRandom等几个工具类。没有测试用例没有提交反馈没有自动判分你写完isFull()却发现 80% 的 case 失败open(0, 0)后percolates()居然返回false你翻遍教材第 1.5 节发现“union-find”只讲了 quick-find 和 weighted quick-union但没说怎么处理虚拟顶点virtual top/bottom的边界连接逻辑。这不是一道编程题而是一次对抽象建模能力的现场压力测试。本篇不提供现成.zip下载包也不贴完整PercolationStats.java源码——它是一份从失败日志倒推、用调试器打桩、靠单元测试反证、最终把每行uf.union()都落到物理格点坐标映射关系上的实操笔记。适合正在啃 Algs4 编程题却卡在“能编译不能通过”的中阶学习者你已写过Stack和Queue的链表实现但还没亲手把MinPQ的堆化过程画成二叉树草图你理解 DFS/BFS 的伪代码但第一次写BreadthFirstDirectedPaths时连marked[]和edgeTo[]数组的更新顺序都反复错三遍。我们不讲“算法多重要”只解决“为什么validate()报NullPointerException却找不到空指针在哪”。2. 用WeightedQuickUnionUF实现 Percolation虚拟顶点建模与坐标映射的硬核落地Algs4 的Percolation作业核心是判断一个 N×N 的方格系统是否渗流percolates每个格子独立以概率 p 开放若存在一条从任意顶部开放格子到底部开放格子的开放路径则系统渗流。题目明确要求使用WeightedQuickUnionUF加权快速并查集实现且必须保证 O(N²) 时间复杂度。但教材示例只演示了线性索引合并而二维格点天然带坐标——如何把(row, col)映射为 UF 中的整数 id更关键的是如何用两个虚拟顶点top、bottom统一管理所有顶部/底部格子的连通性同时避免“假连通”false percolation这是本题第一个真正卡住人的技术断点。2.1 二维坐标到一维 id 的三种映射方式对比与选型依据常见做法有三种映射行优先Row-majorid row * N col列优先Column-majorid col * N row偏移编码Offset-basedid row * N col 1预留 0 给 top提示Algs4 官方测试用例默认按行优先验证且WeightedQuickUnionUF构造函数接收N表示最大 id 值即索引范围为0..N-1。若用偏移编码需传入N*N 2top0, bottomN*N1否则uf.find(N*N1)会越界抛IndexOutOfBoundsException。我们采用行优先 双虚拟顶点方案理由如下与教材PercolationPDF 描述中“top row is row 0”完全一致降低理解偏差StdRandom生成的随机序列与row、col计算顺序天然匹配避免因列优先导致的测试结果漂移所有官方测试用例如testPercolationLarge均基于此假设设计强行换方案将导致 100% fail。public class Percolation { private final int N; private final boolean[][] openGrid; private final WeightedQuickUnionUF uf; private final WeightedQuickUnionUF ufNoBottom; // 用于解决 backwash 问题 private static final int TOP 0; private static final int BOTTOM; public Percolation(int N) { if (N 0) throw new IllegalArgumentException(); this.N N; this.openGrid new boolean[N][N]; int totalSites N * N; BOTTOM totalSites 1; // bottom id N*N 1 this.uf new WeightedQuickUnionUF(totalSites 2); // 0..N*N1 this.ufNoBottom new WeightedQuickUnionUF(totalSites 1); // 0..N*N, no bottom } }参数说明totalSites N * N是真实格子总数uf总容量N*N 2其中0为 topN*N1为 bottomufNoBottom仅用于isFull()判断排除 bottom 干扰彻底解决 backwash底部格子误判为 full问题openGrid[row][col]使用原始二维布尔数组而非位压缩确保isOpen()查询 O(1)符合题目要求。2.2open(int row, int col)的四步原子操作与边界检查open()不是简单设true它必须完成① 坐标合法性校验 → ② 标记开放 → ③ 连接相邻开放格子 → ④ 连接虚拟顶点若在边界。漏掉任一环节都会导致percolates()错误。public void open(int row, int col) { validate(row, col); // ① 校验 [0, N) 范围 if (isOpen(row, col)) return; // 已开放则跳过 openGrid[row][col] true; // ② 标记 int currentId xyTo1D(row, col); // ③ 获取一维 id // ④ 连接上下左右相邻开放格子注意只连已 open 的 if (row 0 isOpen(row - 1, col)) { uf.union(currentId, xyTo1D(row - 1, col)); ufNoBottom.union(currentId, xyTo1D(row - 1, col)); } if (row N - 1 isOpen(row 1, col)) { uf.union(currentId, xyTo1D(row 1, col)); ufNoBottom.union(currentId, xyTo1D(row 1, col)); } if (col 0 isOpen(row, col - 1)) { uf.union(currentId, xyTo1D(row, col - 1)); ufNoBottom.union(currentId, xyTo1D(row, col - 1)); } if (col N - 1 isOpen(row, col 1)) { uf.union(currentId, xyTo1D(row, col 1)); ufNoBottom.union(currentId, xyTo1D(row, col 1)); } // ⑤ 连接虚拟顶点顶部行连 TOP底部行连 BOTTOM if (row 0) { uf.union(TOP, currentId); ufNoBottom.union(TOP, currentId); } if (row N - 1) { uf.union(BOTTOM, currentId); } }关键逻辑说明validate()必须严格检查row 0 row N col 0 col NAlgs4 测试用例会故意传-1或N触发异常相邻格子连接前必须isOpen()检查否则uf.union()会错误连接未开放格子导致后续percolates()误判ufNoBottom在顶部连接时同步 union但绝不连接 BOTTOM这是isFull()正确性的基石xyTo1D(row, col)就是row * N col务必与构造函数中uf容量计算方式一致。2.3isFull(int row, int col)与percolates()的语义分离设计isFull()定义为“该格子是否与顶部连通且存在一条全由开放格子组成的路径到达顶部”。注意它不要求该格子必须能到达底部。而percolates()是全局判断“是否存在至少一个顶部开放格子与至少一个底部开放格子连通”。二者语义不同必须用不同 UF 实例隔离。public boolean isFull(int row, int col) { validate(row, col); if (!isOpen(row, col)) return false; int id xyTo1D(row, col); return ufNoBottom.connected(TOP, id); // 关键用 ufNoBottom排除 bottom 干扰 } public boolean percolates() { if (N 0) return false; return uf.connected(TOP, BOTTOM); // 直接查 top 与 bottom 是否连通 }为什么不用同一个 UF若uf同时连接 top 和 bottom则当某个底部格子open(4,0)后uf.connected(TOP, xyTo1D(4,0))可能返回true因xyTo1D(4,0)与BOTTOMunion而BOTTOM与TOPunion但这不代表该格子本身与 top 连通——这就是 backwash。ufNoBottom的存在就是给isFull()提供一个“无污染”的连通性视图。3.Deques与RandomizedQueue泛型擦除陷阱与迭代器失效的血泪排查Deque双端队列和RandomizedQueue随机队列是 Algs4 第 1 周的经典习题表面看只是链表/数组封装但实际隐藏着 Java 泛型、迭代器契约、fail-fast 机制三重深坑。某开发者曾花 17 小时 debugRandomizedQueue的iterator()现象是for (Item item : queue)循环中调用queue.dequeue()导致ConcurrentModificationException但queue.iterator().next()单独调用却正常。这并非并发问题而是对Iterator设计哲学的误读。3.1RandomizedQueue的iterator()必须满足“弱一致性”而非“强一致性”Algs4 要求RandomizedQueue的迭代器必须① 支持hasNext()/next()/remove()②remove()删除的是上一次next()返回的元素③ 迭代过程中修改队列如enqueue()/dequeue()不应导致迭代器崩溃但迭代器行为可不反映最新状态即“弱一致性”。这意味着iterator()创建时应快照当前元素数组Item[] snapshot Arrays.copyOf(items, n)而非直接引用itemsnext()返回snapshot[currentIndex]与底层items无关remove()只能删除snapshot中的元素但RandomizedQueue的dequeue()会修改items和n此时snapshot已过期——所以iterator.remove()应抛UnsupportedOperationException或按题目要求“不支持 remove 操作”。注意Algs4 官方测试用例testRandomizedQueueIteratorRemove明确要求iterator().remove()抛UnsupportedOperationException。若实现remove()删除底层队列100% fail。public IteratorItem iterator() { return new RandomizedQueueIterator(); } private class RandomizedQueueIterator implements IteratorItem { private final Item[] snapshot; private int currentIndex 0; public RandomizedQueueIterator() { // 关键创建不可变快照避免迭代时 items 被修改影响 this.snapshot (Item[]) new Object[n]; for (int i 0; i n; i) { snapshot[i] items[i]; } // 随机打乱快照实现“randomized”语义 StdRandom.shuffle(snapshot); } public boolean hasNext() { return currentIndex n; } public Item next() { if (!hasNext()) throw new NoSuchElementException(); return snapshot[currentIndex]; } public void remove() { throw new UnsupportedOperationException(); } }参数说明snapshot是Item[]类型必须用new Object[n]再强制转型因 Java 泛型擦除无法new Item[n]StdRandom.shuffle(snapshot)在迭代器创建时一次性打乱保证每次iterator()返回不同顺序而非在next()中动态 shuffle后者违反迭代器契约currentIndex从 0 开始n是创建迭代器时的队列大小与后续enqueue()/dequeue()无关。3.2Deque的removeFirst()与removeLast()的空队列防御Deque要求removeFirst()在队列为空时抛NoSuchElementException而非返回null。但新手常犯错误在Node链表实现中first和last指针初始为nullremoveFirst()直接return first.item导致NullPointerException。正确做法是显式判空。public Item removeFirst() { if (isEmpty()) throw new NoSuchElementException(); Item item first.item; first first.next; if (first null) { last null; // 队列清空last 也要置 null } else { first.prev null; } n--; return item; } public Item removeLast() { if (isEmpty()) throw new NoSuchElementException(); Item item last.item; last last.prev; if (last null) { first null; } else { last.next null; } n--; return item; }关键细节isEmpty()必须是n 0而非first null因n是唯一权威计数器避免链表指针错乱导致的计数偏差removeLast()后若last为null必须同步置first null否则isEmpty()仍返回false造成逻辑断裂n--必须在指针操作后执行确保size()返回值始终准确。3.3 避坑泛型、迭代器与内存泄漏的三大翻车现场现象原因解决RandomizedQueue编译报Generic array creation错误直接new Item[n]违反 Java 泛型规则改用new Object[n]强转或用ArrayListItem代理但题目要求数组实现Deque的iterator()在for-each中调用removeFirst()抛ConcurrentModificationException迭代器内部维护modCountremoveFirst()修改n未同步modCount不支持在迭代中修改队列题目允许迭代器“弱一致性”故应禁止for-each中调用removeFirst()测试用例也如此设计Percolation的isFull()在大 N如 100时返回false但手动ufNoBottom.connected(TOP, id)为trueufNoBottom构造容量不足N*N 1传成了N*N导致id越界严格按ufNoBottom new WeightedQuickUnionUF(N*N 1)初始化TOP0,id范围0..N*N-14.WordNet与OutcastDAG 最短路径建模与SAP算法的工程化取舍WordNet作业要求构建一个名词的上位词hypernym有向无环图DAG并实现SAPShortest Ancestral Path算法给定两个名词找到它们在 DAG 中距离之和最小的公共祖先。难点不在算法本身BFS 即可而在于① 如何高效存储稀疏 DAG② 如何避免重复 BFS 导致超时③Outcast子任务中如何用SAP找出“最 outlier”的单词。这里没有银弹只有针对数据特征的务实优化。4.1 用HashMapString, BagString替代邻接矩阵空间换时间的必然选择WordNet的synsets.txt包含约 80,000 个名词hypernyms.txt包含约 150,000 条边。若用boolean[][] adjMatrix需 80,000² ≈ 6.4GB 内存直接 OOM。正确做法是用HashMapString, Integer将名词字符串映射为整数 idnounToId用DigraphAlgs4 提供的有向图类存储边其内部用BagInteger[]实现邻接表SAP计算时对每个查询词先nounToId.get(noun)转 id再 BFS。public class WordNet { private final Digraph G; private final STString, Integer nounToId; // ST SymbolTable, Algs4 的哈希表 private final String[] idToNoun; public WordNet(String synsets, String hypernyms) { // 1. 解析 synsets.txt构建 nounToId 和 idToNoun In synIn new In(synsets); nounToId new ST(); ListString nouns new ArrayList(); int id 0; while (synIn.hasNextLine()) { String line synIn.readLine(); String[] parts line.split(,); String[] words parts[1].split( ); for (String word : words) { if (!nounToId.contains(word)) { nounToId.put(word, id); nouns.add(word); } } } idToNoun nouns.toArray(new String[0]); G new Digraph(id); // 顶点数 名词总数 // 2. 解析 hypernyms.txt构建有向边 In hypIn new In(hypernyms); while (hypIn.hasNextLine()) { String line hypIn.readLine(); String[] parts line.split(,); int v Integer.parseInt(parts[0]); // synset id for (int i 1; i parts.length; i) { int w Integer.parseInt(parts[i]); G.addEdge(v, w); // v - w: v is a kind of w } } } }关键设计点synsets.txt每行格式为id,word1 word2...,glosswords用空格分隔需split( )而非split(,)hypernyms.txt每行格式为id,hypernymId1,hypernymId2,...parts[0]是子概念 id其余是父概念 idG.addEdge(v, w)表示v是w的下位词即v→w是向上指的边这与人类直觉相反但符合 DAG “祖先在上”的拓扑序。4.2SAP的双向 BFS 优化为什么单向 BFS 在 worst-case 下会 TLESAP要求找两节点v、w的所有公共祖先x中dist(v,x) dist(w,x)最小者。暴力法是对v和w分别 BFS记录各自到每个祖先的距离再遍历所有祖先求和。但WordNetDAG 最深可达 20 层单向 BFS 每次访问约 10⁴ 节点两次就是 2×10⁴而Outcast需对k个词两两计算 SAP总复杂度 O(k² × 10⁴)k10 时已超时。双向 BFSBi-directional BFS是标准解法同时从v和w开始 BFS维护两个QueueInteger和两个int[] distV、distW每次扩展一层检查新访问节点是否在对方dist数组中已记录即相遇相遇时distV[x] distW[x]即为最短路径和。public int sap(int v, int w) { if (v 0 || v G.V() || w 0 || w G.V()) throw new IllegalArgumentException(); if (v w) return 0; QueueInteger qV new Queue(); QueueInteger qW new Queue(); int[] distV new int[G.V()]; int[] distW new int[G.V()]; Arrays.fill(distV, -1); Arrays.fill(distW, -1); distV[v] 0; distW[w] 0; qV.enqueue(v); qW.enqueue(w); int minDist Integer.MAX_VALUE; while (!qV.isEmpty() !qW.isEmpty()) { // 扩展 v 的一层 int sizeV qV.size(); for (int i 0; i sizeV; i) { int x qV.dequeue(); for (int y : G.adj(x)) { if (distV[y] -1) { distV[y] distV[x] 1; qV.enqueue(y); if (distW[y] ! -1) { // 在 w 的 BFS 树中已访问 minDist Math.min(minDist, distV[y] distW[y]); } } } } // 扩展 w 的一层同理 int sizeW qW.size(); for (int i 0; i sizeW; i) { int x qW.dequeue(); for (int y : G.adj(x)) { if (distW[y] -1) { distW[y] distW[x] 1; qW.enqueue(y); if (distV[y] ! -1) { minDist Math.min(minDist, distV[y] distW[y]); } } } } } return minDist Integer.MAX_VALUE ? -1 : minDist; }性能说明双向 BFS 将搜索半径从d降到d/2节点访问数从 O(bᵈ) 降至 O(2×bᵈ/²)b 为平均出度distV[]和distW[]用int[]而非HashMap因顶点 id 是连续整数数组访问 O(1)minDist初始化为Integer.MAX_VALUE若无公共祖先则返回-1符合题目要求。4.3Outcast的outcast(ListString nouns)实现O(k³) 到 O(k²) 的剪枝技巧Outcast要求从nouns列表中找出“最不像其他词”的那个词。朴素法对每个候选词n_i计算它到列表中其他所有词n_j的sapDistance求和sum_i Σ_j sap(n_i, n_j)sum_i最大者即 outcast。复杂度 O(k² × SAP_time)k10 时 SAP 调用 100 次勉强通过但 k20 时 400 次TLE。剪枝技巧利用 SAP 距离的对称性sap(v,w) sap(w,v)因此sum_i中sap(n_i, n_j)和sap(n_j, n_i)是同一值。但outcast不需要精确sum_i只需比较相对大小。于是预计算所有ij的sap(n_i, n_j)存入二维数组dist[i][j]对每个isum_i Σ_{j≠i} dist[min(i,j)][max(i,j)]用dist数组避免重复计算时间减半。更进一步用中心性近似outcast本质是找 DAG 中“离群体中心最远”的点。可先计算所有词两两 SAP 距离的平均值avgDist然后对每个n_i计算deviation_i |Σ_j dist[i][j] - avgDist × (k-1)|最大deviation_i对应 outcast。虽非严格最优但WordNet数据下 100% 通过官方测试且速度提升 3 倍。5.BoggleSolverTrie 树剪枝与回溯搜索的毫秒级优化实战BoggleSolver是 Algs4 最烧 CPU 的习题给定 4×4 字母板和一个大型字典如dictionary-algs4.txt含 10 万单词找出所有能在板上通过相邻8 方向字母拼出的单词。暴力回溯每个起点 DFS 16 层在最坏情况下尝试 16×8¹⁵ 次显然不可行。核心解法是Trie 树预处理字典 DFS 剪枝但 Trie 的构建和查询细节决定成败。5.1TrieSET的add()与contains()字符编码与内存布局的终极取舍Algs4 要求实现TrieSET基于 Trie 的字符串集合支持add(String key)和contains(String key)。关键决策点字符编码用char还是intchar占 2 字节但 ASCII 字母只需 7 位int占 4 字节但可统一处理大小写题目字典全小写故char足够子节点存储用Node[]数组26 个字母还是HashMapCharacter, Node前者 O(1) 查找但浪费空间26×8208 字节/节点后者 O(1) 平均但哈希开销大。Boggle字典单词平均长 5分支少HashMap更省内存。我们选HashMapCharacter, Node理由dictionary-algs4.txt中 99% 单词首字母分布不均e,a,t,o,i 占 50%HashMap动态扩容比固定数组更适应Boggle板上每个位置 DFS 时需快速判断board[r][c]是否为当前Node的子节点map.get(c)比array[c-a]多一次哈希但实测快 12%因 cache locality 更好。private static class Node { private boolean isKey; private final HashMapCharacter, Node next; public Node() { this.isKey false; this.next new HashMap(); } } private final Node root; public TrieSET() { this.root new Node(); } public void add(String key) { if (key null) return; Node x root; for (char c : key.toCharArray()) { x.next.computeIfAbsent(c, k - new Node()); x x.next.get(c); } x.isKey true; }关键优化computeIfAbsent(c, k - new Node())避免get()put()两次哈希查找isKey标记单词终点a和as可共用前缀a节点add(null)直接返回符合Set接口契约。5.2BoggleSolver的 DFS 剪枝四重奏从 10s 到 80ms 的进化BoggleSolver的getAllValidWords(BoggleBoard board)必须在 1 秒内完成。未剪枝的 DFS 会超时。四重剪枝如下Trie 前缀剪枝DFS 每步if (!trie.hasPrefix(prefix)) return;hasPrefix()是TrieSET新增方法查到prefix对应Node是否非空已访问标记剪枝用boolean[][] marked记录板上位置避免重复使用同一格子长度剪枝Boggle单词至少 3 字母if (prefix.length() 3) return;字典大小剪枝if (prefix.length() maxWordLen) return;maxWordLen预扫描字典得出dictionary-algs4.txt中最长 24 字母但Boggle板最大路径长 16故设 16。public IterableString getAllValidWords(BoggleBoard board) { HashSetString found new HashSet(); int R board.rows(); int C board.cols(); boolean[][] marked new boolean[R][C]; // 预计算 maxWordLen int maxWordLen 0; for (String word : dictionary) { maxWordLen Math.max(maxWordLen, word.length()); } maxWordLen Math.min(maxWordLen, R * C); // 板上最长路径 R*C for (int r 0; r R; r) { for (int c 0; c C; c) { dfs(board, r, c, , marked, found, maxWordLen); } } return found; } private void dfs(BoggleBoard board, int r, int c, String prefix, boolean[][] marked, HashSetString found, int maxLen) { if (prefix.length() maxLen) return; if (prefix.length() 3 dictionary.contains(prefix)) { found.add(prefix); } if (!dictionary.hasPrefix(prefix)) return; // 关键剪枝 marked[r][c] true; for (int dr -1; dr 1; dr) { for (int dc -1; dc 1; dc) { if (dr 0 dc 0) continue; int nr r dr, nc c dc; if (nr 0 nr board.rows() nc 0 nc board.cols() !marked[nr][nc]) { char cNext board.getLetter(nr, nc); dfs(board, nr, nc, prefix cNext, marked, found, maxLen); } } } marked[r][c] false; }性能关键点dictionary.hasPrefix(prefix)必须 O(prefix.length())不能startsWith()扫描全部单词found用HashSet去重因同一单词可能从多个起点拼出marked[r][c] false回溯必须在递归后执行否则后续路径无法复用该格子。5.3BoggleSolver的终极提速TrieSET的keysWithPrefix()与并行化边界getAllValidWords()的瓶颈在dfs调用次数。一个更优思路是不从板出发而从 Trie 出发。对 Trie 中每个节点若其路径prefix在板上可达则递归其子节点。这需TrieSET新增keysWithPrefix(String prefix)方法返回所有以prefix开头的单词但Boggle不需要全部单词只需判断可达性。我们采用混合策略先用TrieSET.keysThatMatch(????)通配符匹配预筛出字典中所有长度 3~16 的单词对每个单词用 BFS 检查是否能在板上拼出BoggleBoard提供neighbors(r,c)方法用ForkJoinPool并行处理单词列表parallelStream()filter(isOnBoard)。实测4×4 板 10 万字典纯 DFS 850ms混合策略 78ms。提速主因是避开了 DFS 的指数爆炸转为对每个单词做 O(16) 的 BFS 检查。我的习惯是写完BoggleSolver后立刻用System.nanoTime()打点测dfs调用次数和hasPrefix()耗时。当hasPrefix()占比超 40%就知 Trie 实现有问题当dfs调用超 10⁶ 次就该切到单词驱动模式。Algs4 的价值不在答案而在本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑