Skip to content

图论

更新: 9/14/2025 字数: 0 字 时长: 0 分钟

岛屿数量

java
/**
 * <p>此方法解决“岛屿数量”问题,采用<strong>深度优先搜索 (DFS)</strong> 的方式。</p>
 * <p>核心思想:将每个 '1'(陆地)视为图中的一个节点,相邻的陆地之间有边。
 * 问题转化为计算图中的连通分量数量。
 * 当我们发现一个陆地单元格时,说明我们发现了一个新岛屿。
 * 为了不重复计数这个岛屿的其它部分,我们利用 DFS 遍历这个岛屿的所有陆地单元格,
 * 并将其标记为已访问(通过将其从 '1' 改为 '0',模拟“淹没”岛屿)。
 * 每当主遍历循环中找到一个未访问的 '1',就增加岛屿计数器。</p>
 *
 * <h3>算法步骤:</h3>
 * <ol>
 *   <li><strong>主函数 {@code numIslands(grid)}:</strong>
 *     <ul>
 *       <li><strong>参数:</strong> {@code grid} 二维字符数组,代表网格。</li>
 *       <li><strong>返回:</strong> 岛屿的总数量。</li>
 *       <li><strong>基本情况:</strong>
 *         <ul>
 *           <li>{@code if (grid == null || grid.length == 0 || grid[0].length == 0) return 0;}:
 *             如果网格为空,返回 0。</li>
 *         </ul>
 *       </li>
 *       <li><strong>初始化:</strong>
 *         <ul>
 *           <li>{@code int rows = grid.length;}</li>
 *           <li>{@code int cols = grid[0].length;}</li>
 *           <li>{@code int numIslands = 0;}:初始化岛屿计数器。</li>
 *         </ul>
 *       </li>
 *       <li><strong>遍历网格:</strong>
 *         <ul>
 *           <li>使用嵌套循环 {@code for (int r = 0; r < rows; r++)} 和 {@code for (int c = 0; c < cols; c++)} 遍历每一个单元格。</li>
 *           <li><strong>发现陆地:</strong>
 *             <ul>
 *               <li>{@code if (grid[r][c] == '1')}:如果当前单元格是陆地 (并且之前没有被访问过,因为被访问过的 '1' 会变成 '0')。
 *                 <ul>
 *                   <li>{@code numIslands++;}:增加岛屿计数。</li>
 *                   <li>{@code dfs(grid, r, c);}:从当前单元格启动 DFS,将这个岛屿的所有陆地单元格都标记为 '0'。</li>
 *                 </ul>
 *               </li>
 *             </ul>
 *           </li>
 *         </ul>
 *       </li>
 *       <li>{@code return numIslands;}:返回最终的岛屿数量。</li>
 *     </ul>
 *   </li>
 *   <li><strong>辅助 DFS 函数 {@code dfs(grid, r, c)}:</strong>
 *     <ul>
 *       <li><strong>参数:</strong> {@code grid} 网格,{@code r} 当前行索引,{@code c} 当前列索引。</li>
 *       <li><strong>返回:</strong> {@code void}。此函数直接修改网格,将其陆地单元格标记为 '0'。</li>
 *       <li><strong>边界条件 (递归终止条件):</strong>
 *         <ul>
 *           <li>{@code if (r < 0 || r >= grid.length || c < 0 || c >= grid[0].length || grid[r][c] == '0')}:
 *             <ul>
 *               <li>如果当前坐标超出网格的有效范围。</li>
 *               <li>或者当前单元格是水('0'),这包括原始是水和已经被 DFS 访问过的陆地。</li>
 *               <li>满足以上任何一个条件,则停止进一步搜索,直接返回。</li>
 *             </ul>
 *           </li>
 *         </ul>
 *       </li>
 *       <li><strong>核心逻辑:</strong>
 *         <ul>
 *           <li>{@code grid[r][c] = '0';}:将当前陆地单元格标记为水,表示它已经被访问(“淹没”)了。</li>
 *           <li><strong>递归访问相邻单元格:</strong>
 *             <ul>
 *               <li>{@code dfs(grid, r + 1, c);} (向下)</li>
 *               <li>{@code dfs(grid, r - 1, c);} (向上)</li>
 *               <li>{@code dfs(grid, r, c + 1);} (向右)</li>
 *               <li>{@code dfs(grid, r, c - 1);} (向左)</li>
 *             </ul>
 *           </li>
 *         </ul>
 *       </li>
 *     </ul>
 *   </li>
 * </ol>
 *
 * <h3>性能分析:</h3>
 * <ul>
 *   <li><strong>时间复杂度:</strong> {@code O(R * C)}。
 *     <ul>
 *       <li>其中 {@code R} 是网格的行数,{@code C} 是网格的列数。</li>
 *       <li>主循环会遍历每个单元格一次 (R * C)。</li>
 *       <li>DFS 函数中,每个陆地单元格最多被访问和修改一次。因为一旦被访问,它就会被标记为 '0',不会再次进入 DFS。</li>
 *       <li>因此,总时间复杂度是 {@code O(R * C)}。</li>
 *     </ul>
 *   </li>
 *   <li><strong>空间复杂度:</strong> {@code O(R * C)} (最坏情况)。
 *     <ul>
 *       <li>主要是递归调用栈的深度。在最坏情况,整个网格都是陆地,DFS 递归深度可以达到 {@code R * C}。</li>
 *       <li>在最好情况(例如,只有一行或一列),{@code H = min(R, C)}。</li>
 *       <li>在平均情况或其他情况,取决于连通分量的形状。</li>
 *     </ul>
 *   </li>
 * </ul>
 */
public int numIslands(char[][] grid) {
    if (grid == null || grid.length == 0 || grid[0].length == 0) {
        return 0; // 空网格,没有岛屿
    }
    int rows = grid.length;
    int cols = grid[0].length;
    int numIslands = 0; // 岛屿计数
    // 遍历整个网格
    for (int r = 0; r < rows; r++) {
        for (int c = 0; c < cols; c++) {
            // 如果发现一个陆地单元格 '1'
            if (grid[r][c] == '1') {
                numIslands++; // 岛屿数量加一
                // 从这个陆地单元格开始,使用 DFS 淹没整个岛屿
                // 这样这个岛屿的所有部分都被标记为 '0',后续不会重复计数
                dfs(grid, r, c);
            }
        }
    }
    return numIslands;
}

/**
 * 深度优先搜索 (DFS) 辅助函数,用于“淹没”一个岛屿。
 * 从 (r, c) 开始,将其及所有相邻的陆地单元格都标记为 '0'。
 *
 * @param grid 网格
 * @param r    当前行索引
 * @param c    当前列索引
 */
private void dfs(char[][] grid, int r, int c) {
    // 边界条件:
    // 1. 坐标超出网格范围
    // 2. 当前单元格是水 ('0') -- 包括原始是水和已经被访问过的陆地
    if (r < 0 || r >= grid.length || c < 0 || c >= grid[0].length || grid[r][c] == '0') {
        return;
    }
    // 将当前陆地单元格标记为 '0',表示已访问/已淹没
    grid[r][c] = '0';
    // 递归访问相邻的陆地单元格(上、下、左、右)
    dfs(grid, r + 1, c); // 向下
    dfs(grid, r - 1, c); // 向上
    dfs(grid, r, c + 1); // 向右
    dfs(grid, r, c - 1); // 向左
}

腐烂的橘子

java
/**
 * <p>此方法解决“腐烂的橘子”问题,采用<strong>广度优先搜索 (BFS)</strong> 的方式。</p>
 * <p>核心思想:问题要求计算腐烂所有新鲜橘子所需的最少分钟数,且腐烂过程是同时向四周扩散的,
 * 这正是 BFS 解决最短路径/层序遍历问题的典型场景。这是一个<strong>多源 BFS</strong> 问题,
 * 所有初始的腐烂橘子同时开始扩散,就像洪水从多个源头同时蔓延。</p>
 *
 * <h3>算法步骤:</h3>
 * <ol>
 *   <li><strong>初始化:</strong>
 *     <ul>
 *       <li>{@code rows}, {@code cols}:获取网格的维度。</li>
 *       <li>{@code queue} (Type: {@code Queue<int[]>}):用于存储当前分钟腐烂橘子的坐标 {@code [r, c]}。</li>
 *       <li>{@code freshOranges}:计数器,记录网格中新鲜橘子的初始总数。</li>
 *       <li>遍历整个 {@code grid}:
 *         <ul>
 *           <li>如果遇到 {@code grid[r][c] == 2} (腐烂橘子),将其坐标 {@code [r, c]} 加入 {@code queue}。</li>
 *           <li>如果遇到 {@code grid[r][c] == 1} (新鲜橘子),{@code freshOranges++}。</li>
 *         </ul>
 *       </li>
 *       <li><strong>特殊情况处理:</strong> {@code if (freshOranges == 0) return 0;}:如果一开始就没有新鲜橘子,直接返回 0 分钟。</li>
 *     </ul>
 *   </li>
 *   <li><strong>BFS 循环:</strong>
 *     <ul>
 *       <li>{@code minutes = 0;}:初始化分钟计数器。</li>
 *       <li>{@code directions = {{0, 1}, {0, -1}, {1, 0}, {-1, 0}}}:定义四个方向的偏移量。</li>
 *       <li>{@code while (!queue.isEmpty() && freshOranges > 0)}:
 *         <ul>
 *           <li><strong>处理当前层(当前分钟腐烂橘子感染新橘子):</strong>
 *             <ul>
 *               <li>{@code int size = queue.size();}:获取当前队列中腐烂橘子的数量。这些橘子将在本分钟内进行感染。</li>
 *               <li>{@code for (int i = 0; i < size; i++)}:遍历当前队列中的所有橘子:
 *                 <ul>
 *                   <li>{@code int[] rotten = queue.poll();}:取出队首的一个腐烂橘子。</li>
 *                   <li>对这个橘子的四个相邻方向进行检查:
 *                     <ul>
 *                       <li>{@code int newR = rotten[0] + dr;}</li>
 *                       <li>{@code int newC = rotten[1] + dc;}</li>
 *                       <li><strong>检查有效性:</strong> {@code if (newR >= 0 && newR < rows && newC >= 0 && newC < cols && grid[newR][newC] == 1)}
 *                         <ul>
 *                           <li>如果新坐标合法,并且是新鲜橘子:</li>
 *                           <li>{@code grid[newR][newC] = 2;}:将其标记为腐烂。</li>
 *                           <li>{@code queue.offer(new int[]{newR, newC});}:将新腐烂的橘子加入队列,它将在下一分钟感染其他橘子。</li>
 *                           <li>{@code freshOranges--;}:减少新鲜橘子计数。</li>
 *                         </ul>
 *                       </li>
 *                     </ul>
 *                   </li>
 *                 </ul>
 *               </li>
 *             </ul>
 *           </li>
 *           <li><strong>分钟数递增:</strong>
 *             <ul>
 *               <li>{@code if (freshOranges > 0)}:
 *                 {@code minutes++;}:只有当这一分钟结束后,仍然有新鲜橘子需要腐烂,并且队列里确实有新的腐烂橘子(可以在下一分钟继续感染)时,才增加分钟数。
 *                 这样可以避免当最后一批橘子腐烂后,{@code minutes} 还会多加 1 的情况。</li>
 *             </ul>
 *           </li>
 *         </ul>
 *       </li>
 *     </ul>
 *   </li>
 *   <li><strong>结果判断:</strong>
 *     <ul>
 *       <li>{@code if (freshOranges == 0)}:如果所有新鲜橘子都成功腐烂,返回 {@code minutes}。</li>
 *       <li>{@code else}:如果循环结束后仍有新鲜橘子未腐烂(说明它们是不可达的),返回 {@code -1}。</li>
 *     </ul>
 *   </li>
 * </ol>
 *
 * <h3>性能分析:</h3>
 * <ul>
 *   <li><strong>时间复杂度:</strong> {@code O(R * C)}。
 *     <ul>
 *       <li>其中 {@code R} 是网格的行数,{@code C} 是网格的列数。</li>
 *       <li>每个单元格(橘子或空单元格)最多会被访问一次(在初始化阶段加入队列,或在 BFS 阶段被检查)。</li>
 *       <li>每个新鲜橘子最多会从 {@code 1} 变为 {@code 2} 一次。</li>
 *       <li>每个腐烂橘子最多会被从队列中取出一次,并检查其四个方向的邻居。</li>
 *       <li>因此,总时间复杂度是 {@code O(R * C)}。</li>
 *     </ul>
 *   </li>
 *   <li><strong>空间复杂度:</strong> {@code O(R * C)} (最坏情况)。
 *     <ul>
 *       <li>队列中最多存储所有的腐烂橘子坐标。在最坏情况下,所有陆地在同一分钟内腐烂,队列可能包含 {@code R * C} 个元素。</li>
 *     </ul>
 *   </li>
 * </ul>
 */
public int orangesRotting(int[][] grid) {
    if (grid == null || grid.length == 0 || grid[0].length == 0) {
        return 0; // 空网格,无需腐烂
    }
    int rows = grid.length;
    int cols = grid[0].length;
    int freshOranges = 0; // 新鲜橘子计数
    Queue<int[]> queue = new LinkedList<>(); // BFS 队列,存储腐烂橘子的坐标
    // 第一次遍历网格,初始化队列和新鲜橘子数量
    for (int r = 0; r < rows; r++) {
        for (int c = 0; c < cols; c++) {
            if (grid[r][c] == 2) {
                queue.offer(new int[]{r, c}); // 初始腐烂的橘子加入队列
            } else if (grid[r][c] == 1) {
                freshOranges++; // 统计新鲜橘子数量
            }
        }
    }
    // 如果没有新鲜橘子,则不需要时间,直接返回 0
    if (freshOranges == 0) {
        return 0;
    }
    int minutes = 0; // 记录经过的分钟数
    // 定义四个方向的偏移量:右、左、下、上
    int[][] directions = {{0, 1}, {0, -1}, {1, 0}, {-1, 0}};
    // BFS 核心循环:当队列不空且还有新鲜橘子时继续
    // Queue.isEmpty() 负责判断是否所有可达橘子都已处理
    // freshOranges > 0 负责判断是否还有橘子未腐烂(可能不可达)
    while (!queue.isEmpty() && freshOranges > 0) {
        int size = queue.size(); // 获取当前层有多少腐烂橘子,这些橘子将在这一分钟内感染新橘子
        // 处理当前层的所有腐烂橘子
        for (int i = 0; i < size; i++) {
            int[] rotten = queue.poll(); // 取出当前腐烂橘子
            int r = rotten[0];
            int c = rotten[1];
            // 检查其四个方向的相邻单元格
            for (int[] dir : directions) {
                int newR = r + dir[0];
                int newC = c + dir[1];
                // 检查新坐标是否在网格内,并且是一个新鲜橘子
                if (newR >= 0 && newR < rows && newC >= 0 && newC < cols && grid[newR][newC] == 1) {
                    grid[newR][newC] = 2; // 将新鲜橘子标记为腐烂
                    queue.offer(new int[]{newR, newC}); // 新腐烂的橘子加入队列,将在下一分钟感染
                    freshOranges--; // 新鲜橘子数量减一
                }
            }
        }

        // 注意:只有当这一分钟确实有新的橘子被感染并加入队列时(即队列不为空),
        // 且仍有新鲜橘子未腐烂时,才增加分钟数。
        // 否则,如果队列已经空了(表示没有新的橘子可以腐烂了),minutes 不应再增加。
        // 另一种写法是,在内层循环结束后,如果 freshOranges 在本次循环中减少了,
        // 且 outer queue is not empty, minutes++ (更严谨,但本方法中由于 freshOranges>0 检查,
        // 最终会收敛,不额外增加分钟数)
        if (!queue.isEmpty()) { // 这一层感染结束后,如果队列还有元素,说明下一分钟还有橘子要腐烂
            minutes++;
        }
    }
    // 最终判断:如果所有新鲜橘子都腐烂了,返回分钟数
    if (freshOranges == 0) {
        return minutes;
    } else {
        // 否则,说明有新鲜橘子无法被腐烂
        return -1;
    }
}

课程表

java
/**
 * <p>此方法解决“课程表”问题,采用<strong>深度优先搜索 (DFS) 和状态标记</strong>来检测有向图中的环。</p>
 * <p>核心思想:将课程和先修课程关系构造成一个有向图。如果图中存在环,则无法完成所有课程;如果无环,则可完成。
 * DFS 可以用来检测环。在 DFS 遍历过程中,我们使用一个 {@code visited} 数组来标记节点的三种状态:</p>
 *
 * <ol>
 *     <li>{@code 0}:未被访问。</li>
 *     <li>{@code 1}:正在访问中(当前在递归栈中,表示正在探索从它出发的路径)。</li>
 *     <li>{@code 2}:已访问完成(所有从它出发的路径都已探索完毕,并且没有发现环)。</li>
 * </ol>
 *
 * <h3>算法步骤:</h3>
 * <ol>
 *   <li><strong>构建图:</strong>
 *     <ul>
 *       <li>{@code List<List<Integer>> graph = new ArrayList<>();}:使用邻接表表示图。{@code graph.get(i)} 存储所有依赖于课程 {@code i} 的课程。
 *         即,{@code graph[bi]} 包含 {@code ai},意味着 {@code bi -> ai} (要学 {@code ai} 必须先学 {@code bi})。</li>
 *       <li>根据 {@code prerequisites} 数组,填充 {@code graph}。</li>
 *     </ul>
 *   </li>
 *   <li><strong>状态数组 {@code visited}:</strong>
 *     <ul>
 *       <li>{@code int[] visited = new int[numCourses];}:大小为 {@code numCourses}。</li>
 *       <li>初始所有元素都为 {@code 0} (未访问)。</li>
 *     </ul>
 *   </li>
 *   <li><strong>主函数 {@code canFinish(numCourses, prerequisites)}:</strong>
 *     <ul>
 *       <li>遍历所有课程 {@code i} 从 {@code 0} 到 {@code numCourses - 1}:
 *         <ul>
 *           <li>{@code if (visited[i] == 0)}:如果当前课程 {@code i} 未被访问过,对其启动 DFS。</li>
 *           <li>{@code if (!dfs(i, graph, visited))}:如果 DFS 返回 {@code false} (表示发现了环),则直接返回 {@code false}。</li>
 *         </ul>
 *       </li>
 *       <li>如果所有课程都 DFS 完毕且没有发现环,则返回 {@code true}。</li>
 *     </ul>
 *   </li>
 *   <li><strong>DFS 辅助函数 {@code dfs(course, graph, visited)}:</strong>
 *     <ul>
 *       <li><strong>参数:</strong> {@code course} 当前正在访问的课程,{@code graph} 邻接表,{@code visited} 状态数组。</li>
 *       <li><strong>返回:</strong> {@code boolean}。{@code true} 表示从 {@code course} 开始的路径无环,{@code false} 表示有环。</li>
 *       <li><strong>基本情况 (递归终止条件):</strong>
 *         <ul>
 *           <li>{@code if (visited[course] == 1)}:<strong>发现环!</strong>
 *             <ul>
 *               <li>如果当前课程 {@code course} 正处于“正在访问中”状态(即它在当前的递归栈上),
 *                 而我们又再次遇到了它,说明存在一条回边,形成了环。返回 {@code false}。</li>
 *             </ul>
 *           </li>
 *           <li>{@code if (visited[course] == 2)}:<strong>已访问完成</strong>
 *             <ul>
 *               <li>如果当前课程 {@code course} 已经完全访问过(并且之前已经确认它无环),直接返回 {@code true}。</li>
 *             </ul>
 *           </li>
 *         </ul>
 *       </li>
 *       <li><strong>核心逻辑:</strong>
 *         <ul>
 *           <li>{@code visited[course] = 1;}:将当前课程标记为“正在访问中”。</li>
 *           <li><strong>遍历其所有邻居 (prerequisites 的后续课程):</strong>
 *             <ul>
 *               <li>{@code for (int nextCourse : graph.get(course))}:</li>
 *               <li>{@code if (!dfs(nextCourse, graph, visited))}:如果从任何一个 {@code nextCourse} 开始的 DFS 发现了环,则当前课程也位于环中。返回 {@code false}。</li>
 *             </ul>
 *           </li>
 *           <li>{@code visited[course] = 2;}:<strong>所有子孙节点都已访问完毕且无环</strong>,将当前课程标记为“已访问完成”。</li>
 *           <li>返回 {@code true} (表示从当前课程开始的路径无环)。</li>
 *         </ul>
 *       </li>
 *     </ul>
 *   </li>
 * </ol>
 *
 * <h3>性能分析:</h3>
 * <ul>
 *   <li><strong>时间复杂度:</strong> {@code O(V + E)}。
 *     <ul>
 *       <li>其中 {@code V} 是课程数量 {@code numCourses} (节点数),{@code E} 是先修课程关系的数量 (边数)。</li>
 *       <li>构建图需要 {@code O(V + E)}。</li>
 *       <li>每个节点和每条边在 DFS 中最多被访问一次。</li>
 *       <li>因此,总时间复杂度是 {@code O(V + E)}。</li>
 *     </ul>
 *   </li>
 *   <li><strong>空间复杂度:</strong> {@code O(V + E)}。
 *     <ul>
 *       <li>邻接表 {@code graph} 占用 {@code O(V + E)} 空间。</li>
 *       <li>{@code visited} 数组占用 {@code O(V)} 空间。</li>
 *       <li>递归调用栈的深度在最坏情况下为 {@code O(V)} (例如,一条不间断的长链)。</li>
 *       <li>因此,总空间复杂度是 {@code O(V + E)}。</li>
 *     </ul>
 *   </li>
 * </ul>
 */
public boolean canFinish(int numCourses, int[][] prerequisites) {
    // 1. 构建图 (邻接表表示)
    // graph[i] 存储所有依赖于课程 i 的课程 (即从 i 指向 j 的边),
    // 换句话说,要学 graph[i] 中的课程,需要先学 i。
    List<List<Integer>> graph = new ArrayList<>();
    for (int i = 0; i < numCourses; i++) {
        graph.add(new ArrayList<>());
    }
    for (int[] prerequisite : prerequisites) {
        int course = prerequisite[0]; // 要学习的课程
        int preCourse = prerequisite[1]; // 先修课程
        // 添加从 preCourse 到 course 的边
        // 表示如果学习 course,则 preCourse 必须是其前置依赖
        graph.get(preCourse).add(course);
    }
    // 2. 状态数组
    // visited[i] = 0: 课程 i 未被访问
    // visited[i] = 1: 课程 i 正在访问中 (在当前 DFS 递归栈中)
    // visited[i] = 2: 课程 i 已访问完成 (从它出发的所有路径都已探索完毕且无环)
    int[] visited = new int[numCourses];
    // 3. 对每门课程进行 DFS 遍历,检测是否存在环
    for (int i = 0; i < numCourses; i++) {
        // 如果课程 i 尚未被访问过,则对其启动 DFS
        if (visited[i] == 0) {
            // 如果 DFS 返回 false,说明存在环,无法完成所有课程
            if (!dfs(i, graph, visited)) {
                return false;
            }
        }
    }
    // 如果所有课程都 DFS 完毕且没有发现环,则可以完成所有课程
    return true;
}

/**
 * 深度优先搜索 (DFS) 辅助函数,用于检测图中是否存在环。
 *
 * @param course  当前正在访问的课程
 * @param graph   邻接表表示的图
 * @param visited 课程访问状态数组
 * @return 如果从当前课程开始的路径中存在环,返回 false;否则返回 true。
 */
private boolean dfs(int course, List<List<Integer>> graph, int[] visited) {
    // 1. 如果当前课程正在访问中 (visited[course] == 1),说明遇到了一个回边,存在环
    if (visited[course] == 1) {
        return false;
    }
    // 2. 如果当前课程已经访问完成 (visited[course] == 2),说明从它出发的路径已经确认无环,直接返回 true
    if (visited[course] == 2) {
        return true;
    }
    // 3. 将当前课程标记为“正在访问中”
    visited[course] = 1;
    // 4. 遍历当前课程的所有邻居 (即依赖于当前课程的后续课程)
    for (int nextCourse : graph.get(course)) {
        // 如果从任何一个 nextCourse 开始的 DFS 发现了环,则当前路径也存在环
        if (!dfs(nextCourse, graph, visited)) {
            return false; // 发现环,立即返回 false
        }
    }
    // 5. 当前课程的所有邻居都已访问完毕且无环,将当前课程标记为“已访问完成”
    visited[course] = 2;
    // 6. 从当前课程开始的路径无环
    return true;
}
java
/**
 * <p>此方法解决“课程表”问题,采用<strong>广度优先搜索 (BFS) 的 Kahn 算法</strong>,基于入度表。</p>
 * <p>核心思想:如果一个有向无环图 (DAG) 存在拓扑排序,那么一定可以通过不断移除入度为 0 的节点来完成排序。
 * 如果最终无法移除所有节点,则说明图中存在环。
 * 因此,我们可以维护每个课程的<strong>入度</strong>(有多少门课程是它的先修课程),并使用 BFS 来进行拓扑排序模拟。</p>
 *
 * <h3>算法步骤:</h3>
 * <ol>
 *   <li><strong>构建图和入度表:</strong>
 *     <ul>
 *       <li>{@code List<List<Integer>> graph = new ArrayList<>();}:使用邻接表表示图。{@code graph.get(i)} 存储所有依赖于课程 {@code i} 的课程。
 *         即,对于 {@code [ai, bi]},我们添加从 {@code bi} 到 {@code ai} 的边,表示 {@code bi} 是 {@code ai} 的先修课程。</li>
 *       <li>{@code int[] inDegree = new int[numCourses];}:大小为 {@code numCourses},{@code inDegree[i]} 存储课程 {@code i} 的入度。</li>
 *       <li>根据 {@code prerequisites} 数组,填充 {@code graph} 和 {@code inDegree}:
 *         <ul>
 *           <li>{@code graph.get(preCourse).add(course);} ({@code preCourse} 是 {@code course} 的先修课程)</li>
 *           <li>{@code inDegree[course]++;} ({@code course} 的入度增加 1)</li>
 *         </ul>
 *       </li>
 *     </ul>
 *   </li>
 *   <li><strong>初始化队列:</strong>
 *     <ul>
 *       <li>{@code Queue<Integer> queue = new LinkedList<>();}:创建一个队列,用于存储入度为 0 的课程。</li>
 *       <li>遍历所有课程 {@code i} 从 {@code 0} 到 {@code numCourses - 1}:
 *         <ul>
 *           <li>{@code if (inDegree[i] == 0)}:如果课程 {@code i} 的入度为 0,说明它没有先修课程,可以最先学习。将其加入队列。</li>
 *         </ul>
 *       </li>
 *     </ul>
 *   </li>
 *   <li><strong>BFS 拓扑排序:</strong>
 *     <ul>
 *       <li>{@code int count = 0;}:记录已经完成(即入队并出队)的课程数量。</li>
 *       <li>{@code while (!queue.isEmpty())}:当队列不为空时循环:
 *         <ul>
 *           <li>{@code int course = queue.poll();}:从队列中取出一门可以学习的课程。</li>
 *           <li>{@code count++;}:已完成课程数量加 1。</li>
 *           <li><strong>更新邻居的入度:</strong>
 *             <ul>
 *               <li>{@code for (int nextCourse : graph.get(course))}:遍历所有依赖于当前完成课程 {@code course} 的后续课程 {@code nextCourse}。</li>
 *               <li>{@code inDegree[nextCourse]--;}:将 {@code nextCourse} 的入度减 1,因为其先修课程 {@code course} 已经完成。</li>
 *               <li>{@code if (inDegree[nextCourse] == 0)}:如果 {@code nextCourse} 的入度变为 0,说明它现在所有先修课程都已完成,可以学习了。将其加入队列。</li>
 *             </ul>
 *           </li>
 *         </ul>
 *       </li>
 *     </ul>
 *   </li>
 *   <li><strong>结果判断:</strong>
 *     <ul>
 *       <li>{@code if (count == numCourses)}:如果 {@code count} 等于总课程数 {@code numCourses},说明所有课程都成功进行了拓扑排序,即图中无环。返回 {@code true}。</li>
 *       <li>{@code else}:如果 {@code count} 小于 {@code numCourses},说明有部分课程无法入队完成(因为它们的入度永远不会变为 0),这说明图中存在环。返回 {@code false}。</li>
 *     </ul>
 *   </li>
 * </ol>
 *
 * <h3>性能分析:</h3>
 * <ul>
 *   <li><strong>时间复杂度:</strong> {@code O(V + E)}。
 *     <ul>
 *       <li>其中 {@code V} 是课程数量 {@code numCourses} (节点数),{@code E} 是先修课程关系的数量 (边数)。</li>
 *       <li>构建图和入度表需要 {@code O(V + E)}。</li>
 *       <li>BFS 过程中,每个节点最多入队和出队一次,每条边最多被访问一次(当其起点的课程出队时)。</li>
 *       <li>因此,总时间复杂度是 {@code O(V + E)}。</li>
 *     </ul>
 *   </li>
 *   <li><strong>空间复杂度:</strong> {@code O(V + E)}。
 *     <ul>
 *       <li>邻接表 {@code graph} 占用 {@code O(V + E)} 空间。</li>
 *       <li>{@code inDegree} 数组占用 {@code O(V)} 空间。</li>
 *       <li>队列在最坏情况下(例如,所有课程都在同一“层”),可能包含 {@code O(V)} 个元素。</li>
 *       <li>因此,总空间复杂度是 {@code O(V + E)}。</li>
 *     </ul>
 *   </li>
 * </ul>
 */
public boolean canFinish(int numCourses, int[][] prerequisites) {
    // 1. 构建图和入度表
    List<List<Integer>> graph = new ArrayList<>();
    int[] inDegree = new int[numCourses]; // 存储每个课程的入度
    for (int i = 0; i < numCourses; i++) {
        graph.add(new ArrayList<>());
    }
    for (int[] prerequisite : prerequisites) {
        int course = prerequisite[0];    // 依赖课程
        int preCourse = prerequisite[1]; // 先修课程
        // 添加从 preCourse 到 course 的边
        // 表示要学 course,必须先学 preCourse
        graph.get(preCourse).add(course);
        inDegree[course]++; // course 的入度加 1
    }
    // 2. 初始化队列:将所有入度为 0 的课程加入队列
    Queue<Integer> queue = new LinkedList<>();
    for (int i = 0; i < numCourses; i++) {
        if (inDegree[i] == 0) {
            queue.offer(i);
        }
    }
    // 3. BFS 拓扑排序过程
    int count = 0; // 记录已完成(即入队并出队)的课程数量
    while (!queue.isEmpty()) {
        int course = queue.poll(); // 取出队首课程,表示可以学习这门课程了
        count++; // 完成的课程数量加 1
        // 遍历当前课程的所有邻居 (即依赖于当前课程的后续课程)
        for (int nextCourse : graph.get(course)) {
            inDegree[nextCourse]--; // 将 nextCourse 的入度减 1,因为它的一门先修课程 course 已经完成
            // 如果 nextCourse 的入度变为 0,说明它现在所有先修课程都已完成,可以学习
            if (inDegree[nextCourse] == 0) {
                queue.offer(nextCourse); // 将 nextCourse 加入队列
            }
        }
    }
    // 4. 判断结果:如果完成的课程数量等于总课程数量,则无环,可以完成所有课程
    return count == numCourses;
}

实现 Trie (前缀树)

java
/**
 * <p>此类实现了 Trie (前缀树) 数据结构,包含初始化、插入单词、搜索单词和搜索前缀的功能。</p>
 * <p>Trie 是一种树形数据结构,它利用字符串的公共前缀来减少存储空间,并能高效地检索字符串。</p>
 *
 * <h3>核心思想:</h3>
 * <p>Trie 由一系列节点组成,每个节点代表一个字符。从根节点到任意一个节点的路径构成一个字符串前缀。
 * 为了区分一个节点是否是完整单词的结尾,我们会在节点中设置一个布尔标记 {@code isEndOfWord}。</p>
 *
 * <p>每个 {@code TrieNode} 包含:
 *   <ul>
 *     <li>{@code children}: 一个数组 (或哈希表) 用于存储子节点。由于本题假设只包含小写英文字母,可以使用大小为 26 的数组,索引 {@code 0} 对应 {@code 'a'},{@code 1} 对应 {@code 'b'} 等。</li>
 *     <li>{@code isEndOfWord}: 一个布尔值,如果从根节点到当前节点的路径构成一个已插入的完整单词,则为 {@code true}。</li>
 *   </ul>
 * </p>
 */
public class Trie {
    // Trie 的根节点
    private TrieNode root;

    /**
     * TrieNode 类定义:
     * 每个节点包含一个子节点数组和一个布尔标记。
     */
    private static class TrieNode {
        TrieNode[] children; // 存储 26 个小写英文字母的子节点
        boolean isEndOfWord; // 标记从根到当前节点是否构成一个完整的单词

        public TrieNode() {
            children = new TrieNode[26]; // 默认全部为 null
            isEndOfWord = false;
        }
    }

    /**
     * 初始化前缀树对象。
     */
    public Trie() {
        root = new TrieNode();
    }

    /**
     * 向前缀树中插入字符串 word。
     * 遍历单词的每个字符,如果路径不存在则创建新节点。
     * 最后将最后一个字符对应的节点的 isEndOfWord 标记为 true。
     *
     * <h3>时间复杂度:</h3> O(L),其中 L 是待插入单词的长度。
     * 因为我们需要遍历单词的每个字符,每个字符的操作是常数时间。
     *
     * @param word 要插入的字符串
     */
    public void insert(String word) {
        TrieNode current = root;
        for (char c : word.toCharArray()) {
            int index = c - 'a'; // 计算字符对应的索引 (0-25)
            if (current.children[index] == null) {
                // 如果当前字符对应的子节点不存在,则创建新节点
                current.children[index] = new TrieNode();
            }
            // 移动到下一个节点
            current = current.children[index];
        }
        // 标记最后一个节点为单词的结尾
        current.isEndOfWord = true;
    }

    /**
     * 如果字符串 word 在前缀树中,返回 true(即,在检索之前已经插入);否则,返回 false。
     * 遍历单词的每个字符,如果路径中断(某个子节点为 null),则表示单词不存在。
     * 最终到达的节点的 isEndOfWord 必须为 true。
     *
     * <h3>时间复杂度:</h3> O(L),其中 L 是待搜索单词的长度。
     *
     * @param word 要搜索的字符串
     * @return 如果单词存在于 Trie 中,返回 true;否则返回 false。
     */
    public boolean search(String word) {
        TrieNode current = root;
        for (char c : word.toCharArray()) {
            int index = c - 'a';
            if (current.children[index] == null) {
                // 路径中断,单词不存在
                return false;
            }
            current = current.children[index];
        }
        // 检查是否到达一个完整单词的结尾
        return current.isEndOfWord;
    }

    /**
     * 如果之前已经插入的字符串 word 的前缀之一为 prefix,返回 true;否则,返回 false。
     * 遍历前缀的每个字符,只要路径存在即可。不需要检查 isEndOfWord。
     *
     * <h3>时间复杂度:</h3> O(L),其中 L 是待搜索前缀的长度。
     *
     * @param prefix 要搜索的前缀
     * @return 如果有任何一个单词以 prefix 开头,返回 true;否则返回 false。
     */
    public boolean startsWith(String prefix) {
        TrieNode current = root;
        for (char c : prefix.toCharArray()) {
            int index = c - 'a';
            if (current.children[index] == null) {
                // 路径中断,前缀不存在
                return false;
            }
            current = current.children[index];
        }
        // 如果能成功遍历完所有前缀字符,说明该前缀存在
        return true;
    }
}

贡献者

The avatar of contributor named as LI SIR LI SIR

页面历史