第七篇:图与搜索:复杂关系网的探索

引言:图的无限可能

朋友们,恭喜你来到了算法学习的又一个高峰——图(Graph)。前几篇我们探索了线性结构和树形结构,它们虽然强大,但不足以描述现实世界中更复杂的**网状(Network)**关系。而图,正是用来描绘和解决这种复杂关系的利器。

图无处不在,渗透在我们生活的方方面面:

  • 社交网络:你和朋友之间的关系,朋友和朋友之间的关系,构成了巨大的社交图。如何找到两个人之间的最短关系链?如何发现社群?

  • 地图和导航系统:城市、道路、交通流量,它们构成了巨大的交通图。我们每天使用的导航软件,就是基于图算法寻找从起点到终点的最短或最快路径。

  • 游戏寻路(Pathfinding):在游戏中,角色如何在复杂的地图中找到从 A 点到 B 点的路径?这正是图算法 (例如 A* 算法,它基于图的搜索) 的典型应用。

  • 计算机网络:互联网、局域网中的设备连接。

  • 物流配送、电路设计、推荐系统等等,背后都有图的身影。

理解图的表示、遍历和核心算法,将极大地拓展你解决复杂问题的能力,让你能够驾驭那些看似无序、实则充满内在联系的数据。


图(Graph)

图是由**顶点(Vertex,或称节点 Node)和连接这些顶点的边(Edge)**组成的数据结构。

基本概念
  • 顶点(Vertex/Node):图中的基本元素,表示实体。例如,社交网络中的“人”,地图上的“城市”。

  • 边(Edge):连接两个顶点的线,表示顶点之间的关系。例如,社交网络中的“好友关系”,地图上的“道路”。

  • 有向图(Directed Graph / Digraph):图中的边有方向,表示单向关系。例如,微博的关注关系(你关注某人,但TA不一定关注你)。

  • 无向图(Undirected Graph):图中的边没有方向,表示双向关系。例如,微信的好友关系(A是B的好友,B也一定是A的好友)。

  • 带权图(Weighted Graph):边上带有权值(Weight)的图。权值可以表示距离、成本、时间等。例如,地图上两城市之间的道路长度。

  • 连通图(Connected Graph):在一个无向图中,任意两个顶点之间都存在路径,则称之为连通图。

  • 连通分量(Connected Component):在一个无向图中,一个最大的连通子图。非连通图由多个连通分量组成。

  • 度(Degree):

    • 无向图:一个顶点的度是与该顶点相连的边的数量。

    • 有向图:

      • 入度(In-degree):指向该顶点的边的数量。

      • 出度(Out-degree):从该顶点指出的边的数量。


图的表示方法

在计算机中,我们如何存储和表示一个图呢?主要有两种方法:邻接矩阵和邻接表。

1. 邻接矩阵(Adjacency Matrix)
  • 概念:

    用一个 NtimesN 的二维数组(矩阵)来表示图,其中 N 是图中顶点的数量。

    如果顶点 i 和顶点 j 之间有边,则 matrix[i][j] 为 1(或边权值);否则为 0(或无穷大)。

    • 无向图:邻接矩阵是对称的(matrix[i][j] == matrix[j][i])。

    • 有向图:邻接矩阵可能不对称。

  • 图示(无向图为例):

    顶点:A, B, C, D (0, 1, 2, 3)

    边:(A,B), (A,C), (B,C), (C,D)

        A B C D
    A   0 1 1 0
    B   1 0 1 0
    C   1 1 0 1
    D   0 0 1 0
    
    
  • 优缺点:

    • 优点:

      • 判断任意两个顶点之间是否有边非常高效,只需 O(1) 时间查询 matrix[i][j]。

      • 方便计算顶点的度(对无向图,一行或一列的和;对有向图,入度为列和,出度为行和)。

    • 缺点:

      • 空间复杂度高:总是需要 O(N2) 的空间,即使图非常稀疏(边很少)。当 N 很大时,会造成内存浪费。

      • 查找某个顶点的所有邻居需要遍历一行,时间复杂度为 O(N)。

  • C# 示例:

    C#

    // 假设有 N 个顶点
    int numVertices = 5;
    int[,] adjacencyMatrix = new int[numVertices, numVertices];
    
    // 添加边 (0, 1)
    adjacencyMatrix[0, 1] = 1; // 无向图也需要 adjacencyMatrix[1, 0] = 1;
    // 添加带权边 (2, 3) 权重为 5
    // adjacencyMatrix[2, 3] = 5;
    
    
2. 邻接表(Adjacency List)
  • 概念:

    为图中的每个顶点维护一个列表(或数组),存储与该顶点直接相连的所有邻居顶点。

    通常使用**数组(或哈希表)**来存储每个顶点的列表,数组的索引或哈希表的键代表顶点。

    • 无向图:如果 (u, v) 是边,则 u 的列表中包含 v,v 的列表中包含 u。

    • 有向图:如果 (u, v) 是边,则 u 的列表中包含 v,v 的列表中不包含 u(除非有反向边)。

  • 图示(与邻接矩阵相同的无向图):

    顶点:A, B, C, D (0, 1, 2, 3)

    边:(A,B), (A,C), (B,C), (C,D)

    0 (A): [1, 2]
    1 (B): [0, 2]
    2 (C): [0, 1, 3]
    3 (D): [2]
    
    
  • 优缺点:

    • 优点:

      • 空间复杂度低:只存储实际存在的边,空间复杂度为 O(N+E),其中 E 是边的数量。对于稀疏图(E 远小于 N2),邻接表比邻接矩阵节省大量空间。

      • 查找某个顶点的所有邻居非常高效,只需遍历其对应的列表,时间复杂度为 O(textdegree(V))。

    • 缺点:

      • 判断任意两个顶点之间是否有边,需要遍历其中一个顶点的邻接列表,最坏情况时间复杂度为 O(N)。

      • 实现相对复杂一点。

  • C# 示例:

    C#

    // 假设有 N 个顶点
    int numVertices = 5;
    // 使用 List<List<int>> 或者 Dictionary<int, List<int>>
    // 这里使用 List<List<int>>,索引即顶点编号
    List<List<int>> adjacencyList = new List<List<int>>(numVertices);
    for (int i = 0; i < numVertices; i++) {
        adjacencyList.Add(new List<int>());
    }
    
    // 添加边 (0, 1)
    adjacencyList[0].Add(1);
    adjacencyList[1].Add(0); // 无向图双向添加
    
    // 添加有向边 (2, 3)
    // adjacencyList[2].Add(3);
    
    
  • 实际应用更广泛:

    由于图在实际中大多是稀疏图,且许多图算法(如 BFS、DFS、Dijkstra 等)都需要遍历顶点的所有邻居,因此邻接表是图在实际应用中最常用和高效的表示方法。


图的遍历

图的遍历是指从图中某个顶点出发,系统地访问图中所有可达顶点,且每个顶点只访问一次。图的遍历是许多图算法的基础。主要有两种方法:深度优先搜索(DFS)和广度优先搜索(BFS)。

为了避免重复访问,我们需要一个 visited 集合(或布尔数组)来记录已经访问过的顶点。

1. 深度优先搜索(DFS)
  • 原理:

    DFS 类似于树的深度优先遍历(前序、中序、后序)。它从一个起始顶点开始,尽可能深地探索每个分支,直到不能再深入为止,然后回溯到上一个未访问的邻居顶点,继续探索。

  • 实现方式:

    • 递归:最常见和简洁的方式,利用系统调用栈。

    • 迭代:手动使用**栈(Stack)**来模拟递归栈。

  • 图示:

    假设图如下(顶点0到5):

    0 – 1

    | |

    2 – 3 – 4

    |

    5

    从顶点 0 开始 DFS 遍历:

    访问 0 -> 访问 1 -> 访问 3 -> 访问 2 (回溯到3,3的邻居2已访问,回溯到1,1的邻居0已访问,回溯到0,0的邻居2已访问) -> 访问 4 -> 访问 5 (回溯…)

    一种可能的顺序:0 -> 1 -> 3 -> 2 -> 4 -> 5

  • 应用:

    • 判断连通性:从一个顶点开始 DFS,如果最终 visited 集合包含了所有顶点,则图是连通的(对于无向图)。

    • 寻找路径:可以用来判断两个顶点之间是否存在路径。

    • 环检测:在有向图或无向图中检测是否存在环。

    • 拓扑排序(后面会详细介绍)。

  • 代码实现(递归):

    C#

    public class GraphDFS {
        private int numVertices;
        private List<List<int>> adj; // 邻接表
        private bool[] visited;
    
        public GraphDFS(int v) {
            numVertices = v;
            adj = new List<List<int>>(v);
            for (int i = 0; i < v; i++) {
                adj.Add(new List<int>());
            }
            visited = new bool[v];
        }
    
        public void AddEdge(int u, int v) {
            adj[u].Add(v);
            adj[v].Add(u); // 如果是无向图
        }
    
        public void DFS(int startVertex) {
            Array.Fill(visited, false); // 重置访问状态
            Console.Write("DFS Traversal (Recursive): ");
            DFSUtil(startVertex);
            Console.WriteLine();
        }
    
        private void DFSUtil(int v) {
            visited[v] = true;
            Console.Write(v + " ");
    
            foreach (int neighbor in adj[v]) {
                if (!visited[neighbor]) {
                    DFSUtil(neighbor);
                }
            }
        }
    
        // 迭代实现 DFS
        public void DFSIterative(int startVertex) {
            Array.Fill(visited, false);
            Stack<int> stack = new Stack<int>();
    
            stack.Push(startVertex);
            visited[startVertex] = true;
            Console.Write("DFS Traversal (Iterative): ");
    
            while (stack.Count > 0) {
                int v = stack.Pop();
                Console.Write(v + " ");
    
                // 遍历邻居,将未访问的邻居压入栈
                // 为了与递归结果保持一致(或特定顺序),通常倒序遍历邻居,或先压入右边/后边的邻居
                // 这里按邻接表的顺序(从左到右)遍历,所以邻居压栈顺序可能影响输出
                // 但只要能遍历所有可达节点即可
                foreach (int neighbor in adj[v]) {
                    if (!visited[neighbor]) {
                        visited[neighbor] = true;
                        stack.Push(neighbor);
                    }
                }
            }
            Console.WriteLine();
        }
    }
    
    
2. 广度优先搜索(BFS)
  • 原理:

    BFS 类似于树的层序遍历。它从一个起始顶点开始,先访问其所有直接邻居,然后访问这些邻居的邻居(即第二层节点),以此类推,逐层向外扩展。

  • 实现方式:

    • 手动使用队列(Queue)。
  • 图示(与 DFS 相同的图):

    从顶点 0 开始 BFS 遍历:

    访问 0

    第一层:0 的邻居 (1, 2) 入队

    弹出 1 -> 1 的邻居 (0, 3) 入队 (0 已访问)

    弹出 2 -> 2 的邻居 (0, 3) 入队 (0 已访问)

    弹出 3 -> 3 的邻居 (1, 2, 4, 5) 入队 (1, 2 已访问) -> 4, 5 入队

    弹出 4

    弹出 5

    一种可能的顺序:0 -> 1 -> 2 -> 3 -> 4 -> 5

  • 应用:

    • 最短路径(无权图):BFS 可以找到无权图中从起始顶点到所有其他可达顶点的最短路径(按边数计算)。

    • 层级遍历:按层访问节点。

    • 扩散问题:例如“腐烂的橘子”、“连通分量”等问题,可以模拟扩散过程。

  • 代码实现:

    C#

    public class GraphBFS {
        private int numVertices;
        private List<List<int>> adj; // 邻接表
    
        public GraphBFS(int v) {
            numVertices = v;
            adj = new List<List<int>>(v);
            for (int i = 0; i < v; i++) {
                adj.Add(new List<int>());
            }
        }
    
        public void AddEdge(int u, int v) {
            adj[u].Add(v);
            adj[v].Add(u); // 如果是无向图
        }
    
        public void BFS(int startVertex) {
            bool[] visited = new bool[numVertices];
            Queue<int> queue = new Queue<int>();
    
            visited[startVertex] = true;
            queue.Enqueue(startVertex);
            Console.Write("BFS Traversal: ");
    
            while (queue.Count > 0) {
                int v = queue.Dequeue();
                Console.Write(v + " ");
    
                foreach (int neighbor in adj[v]) {
                    if (!visited[neighbor]) {
                        visited[neighbor] = true;
                        queue.Enqueue(neighbor);
                    }
                }
            }
            Console.WriteLine();
        }
    }
    
    

经典面试题与解法

1. 岛屿数量(Number of Islands)
  • 题目描述:

    给你一个由 ‘1’(陆地)和 ‘0’(水)组成的二维网格,请你计算网格中岛屿的数量。

    岛屿总是被水包围,并且每座岛屿只能由水平方向或垂直方向上相邻的陆地连接形成。你可以假设网格的四个边界都被水包围。

  • 解题思路:DFS/BFS 遍历连通分量

    这个问题是典型的连通分量问题。每个岛屿就是一个连通分量。我们可以遍历整个网格:

    1. 当遇到一个 '1'(陆地)时,说明我们发现了一个新的岛屿。岛屿数量加一。

    2. 然后,从这个 '1' 开始,使用 DFS 或 BFS 遍历所有与其相连的陆地,并将它们标记为已访问(例如,改为 '0'),以避免重复计数。

    3. 继续遍历网格,直到所有位置都被访问。

  • DFS 实现:

    C#

    public class Solution {
        public int NumIslands(char[][] grid) {
            if (grid == null || grid.Length == 0) {
                return 0;
            }
    
            int numRows = grid.Length;
            int numCols = grid[0].Length;
            int numIslands = 0;
    
            for (int r = 0; r < numRows; r++) {
                for (int c = 0; c < numCols; c++) {
                    if (grid[r][c] == '1') {
                        numIslands++;
                        // 发现新岛屿,从当前陆地开始,淹没所有相连的陆地
                        SinkIslandDFS(grid, r, c, numRows, numCols);
                    }
                }
            }
            return numIslands;
        }
    
        // DFS 辅助函数,将当前陆地及其所有相连陆地标记为 '0'(即“淹没”)
        private void SinkIslandDFS(char[][] grid, int r, int c, int numRows, int numCols) {
            // 越界或遇到水或已访问的陆地 (已改为 '0')
            if (r < 0 || r >= numRows || c < 0 || c >= numCols || grid[r][c] == '0') {
                return;
            }
    
            grid[r][c] = '0'; // 标记为已访问(淹没)
    
            // 递归向上下左右四个方向扩散
            SinkIslandDFS(grid, r + 1, c, numRows, numCols);
            SinkIslandDFS(grid, r - 1, c, numRows, numCols);
            SinkIslandDFS(grid, r, c + 1, numRows, numCols);
            SinkIslandDFS(grid, r, c - 1, numRows, numCols);
        }
    }
    
    
  • BFS 实现:

    C#

    using System.Collections.Generic;
    
    public class Solution {
        public int NumIslandsBFS(char[][] grid) {
            if (grid == null || grid.Length == 0) {
                return 0;
            }
    
            int numRows = grid.Length;
            int numCols = grid[0].Length;
            int numIslands = 0;
    
            Queue<(int, int)> queue = new Queue<(int, int)>(); // 存储坐标 (row, col)
    
            for (int r = 0; r < numRows; r++) {
                for (int c = 0; c < numCols; c++) {
                    if (grid[r][c] == '1') {
                        numIslands++;
                        grid[r][c] = '0'; // 发现新岛屿,立刻标记为已访问
    
                        queue.Enqueue((r, c)); // 将起始点入队
    
                        while (queue.Count > 0) {
                            (int currR, int currC) = queue.Dequeue();
    
                            // 定义四个方向的偏移量:右、左、下、上
                            int[] dr = {0, 0, 1, -1};
                            int[] dc = {1, -1, 0, 0};
    
                            for (int i = 0; i < 4; i++) {
                                int nr = currR + dr[i];
                                int nc = currC + dc[i];
    
                                // 检查新坐标是否在网格内,且是未访问的陆地
                                if (nr >= 0 && nr < numRows && nc >= 0 && nc < numCols && grid[nr][nc] == '1') {
                                    grid[nr][nc] = '0'; // 标记为已访问
                                    queue.Enqueue((nr, nc)); // 入队,继续扩散
                                }
                            }
                        }
                    }
                }
            }
            return numIslands;
        }
    }
    
    
  • 复杂度分析:

    • 时间复杂度:O(RtimesC),其中 R 是行数,C 是列数。每个格子最多被访问一次。

    • 空间复杂度:O(RtimesC),最坏情况下(整个网格都是陆地),DFS 的递归栈深度或 BFS 的队列大小可能达到 O(RtimesC)。


2. 克隆图(Clone Graph)
  • 题目描述:

    给你无向连通图中的一个节点的引用。返回该图的深拷贝(即,复制图)。

    图中的每个节点都包含一个 val(int 类型)和一个 neighbors 列表(List)。

  • 解题思路:DFS/BFS 遍历与哈希表记录已访问节点,处理循环引用

    图的克隆问题涉及到深拷贝,关键在于处理循环引用。由于图是连通的,并且节点之间可能相互指向,普通的递归深拷贝会陷入无限循环。我们需要一个机制来记录哪些节点已经被创建并拷贝过。

    核心思路:使用哈希表 Dictionary<Node, Node> 来存储原始节点到其克隆节点的映射。

    • DFS 实现:

      1. 如果当前节点为 null,直接返回 null。

      2. 如果当前节点已经存在于哈希表中(说明它已经被克隆过),直接返回其克隆节点。

      3. 创建一个新的克隆节点 cloneNode,其 val 与原节点相同。

      4. 将原节点和 cloneNode 存入哈希表:map[node] = cloneNode。

      5. 遍历原节点的每一个邻居 neighbor:

        a. 递归地调用克隆函数 CloneGraph(neighbor),得到邻居的克隆节点。

        b. 将这个克隆节点加入 cloneNode 的 neighbors 列表中。

      6. 返回 cloneNode。

    • BFS 实现:

      1. 如果当前节点为 null,直接返回 null。

      2. 创建一个哈希表 map 和一个队列 queue。

      3. 创建起始节点的克隆节点 cloneRoot,并将其存入哈希表:map[node] = cloneRoot。

      4. 将原起始节点 node 加入队列。

      5. 循环直到队列为空:

        a. 从队列中取出当前原节点 currNode。

        b. 获取 currNode 对应的克隆节点 currCloneNode (从 map 中获取)。

        c. 遍历 currNode 的所有邻居 neighbor:

        i. 如果 neighbor 尚未被克隆(不在 map 中):

        1. 创建 neighbor 的克隆节点 cloneNeighbor。

        2. 将 neighbor 和 cloneNeighbor 存入 map。

        3. 将 neighbor 加入队列。

        ii. 将 neighbor 对应的克隆节点(从 map 中获取)添加到 currCloneNode 的 neighbors 列表中。1

      6. 返回 cloneRoot。2

  • 代码实现(DFS):3

    C#

    using System.Collections.Generic;
    
    // Definition for a Node.
    public class Node {
        public int val;
        public IList<Node> neighbors;
    
        public Node() {
            val = 0;
            neighbors = new List<Node>();
        }
    
        public Node(int _val) {
            val = _val;
            neighbors = new List<Node>();
        }
    
        public Node(int _val, List<Node> _neighbors) {
            val = _val;
            neighbors = _neighbors;
        }
    }
    
    public class Solution {
        // 使用字典来存储原节点到克隆节点的映射,避免重复克隆和处理循环引用
        private Dictionary<Node, Node> visitedMap = new Dictionary<Node, Node>();
    
        public Node CloneGraph(Node node) {
            if (node == null) {
                return null;
            }
    
            // 如果节点已经被访问并克隆过,直接返回其克隆版本
            if (visitedMap.ContainsKey(node)) {
                return visitedMap[node];
            }
    
            // 创建新节点(克隆)
            Node cloneNode = new Node(node.val);
            // 存储映射关系,表示这个原节点已经被克隆
            visitedMap[node] = cloneNode;
    
            // 递归克隆邻居节点,并添加到新节点的邻居列表中
            foreach (Node neighbor in node.neighbors) {
                cloneNode.neighbors.Add(CloneGraph(neighbor));
            }
    
            return cloneNode;
        }
    }
    
    
  • 复杂度分析:

    • 时间复杂度:O(N+E),其中 N 是顶点数,E 是边数。每个顶点和每条边只被访问一次。

    • 空间复杂度:O(N),用于存储 visitedMap 和递归栈空间(DFS)或队列空间(BFS)。


3. 课程表(Course Schedule)
  • 题目描述:

    你总共有 numCourses 门课需要选,课程编号从 0 到 numCourses - 1。给你一个数组 prerequisites ,其中 prerequisites[i] = [ai, bi] 表示选修课程 ai 之前 必须 先修学 bi 。

    例如,如果 [0, 1] 表示你必须先修完课程 1 才能修完课程 0 。

    如果能完成所有课程,返回 true;否则,返回 false。

  • 解题思路:拓扑排序(Topological Sort)与判断有向无环图(DAG)

    这个问题是典型的拓扑排序问题。课程和先决条件构成了有向图。如果一个有向图存在环,则无法完成所有课程(例如,A -> B -> C -> A,形成一个死循环)。因此,问题等价于判断图是否为有向无环图(DAG),并找到一个拓扑排序序列。

    拓扑排序(Topological Sort):对一个有向无环图(DAG)的所有顶点进行线性排序,使得对于图中的任意一条有向边 (u, v),u 在排序中都出现在 v 之前。

    主要有两种算法实现拓扑排序:

    1. Kahn 算法(基于 BFS,入度):

      • 概念:

        1. 计算每个顶点的入度(有多少条边指向它)。

        2. 将所有入度为 0 的顶点加入队列。

        3. 循环直到队列为空:

          a. 从队列中取出一个顶点 u,将其加入拓扑排序结果列表。

          b. 对于 u 的每个邻居 v,将其入度减 1。

          c. 如果 v 的入度变为 0,则将 v 加入队列。

        4. 如果最终拓扑排序结果列表中的顶点数量等于图中的总顶点数量,则说明图是 DAG,存在拓扑排序;否则,图存在环。

      • 判断有向无环图(DAG):Kahn 算法自然地提供了判断 DAG 的方法。如果最终排序的顶点数量不等于总顶点数量,则图中存在环。

    • DFS 算法(基于回溯):

      • 概念:

        利用 DFS 遍历图。在 DFS 过程中,需要维护三种状态来检测环:

        • visited:已访问过且已处理完的节点(已加入拓扑序列)。

        • visiting:正在递归栈中的节点(正在访问)。

        • 未访问:尚未访问的节点。

          如果在 DFS 过程中,遇到一个 visiting 状态的节点,说明存在环。

          当一个节点的 DFS 遍历完成后(所有邻居都被访问或处理完),将其加入拓扑序列的头部(因为是逆后序)。

  • Kahn 算法 (BFS) 实现:

    C#

    using System.Collections.Generic;
    
    public class Solution {
        public bool CanFinish(int numCourses, int[][] prerequisites) {
            // 1. 构建邻接表和计算入度数组
            List<List<int>> adj = new List<List<int>>(numCourses);
            for (int i = 0; i < numCourses; i++) {
                adj.Add(new List<int>());
            }
    
            int[] inDegree = new int[numCourses]; // 记录每个课程的入度
    
            foreach (int[] prerequisite in prerequisites) {
                int course = prerequisite[0]; // 0 依赖于 1
                int preCourse = prerequisite[1];
                adj[preCourse].Add(course); // preCourse -> course
                inDegree[course]++;
            }
    
            // 2. 将所有入度为 0 的课程加入队列
            Queue<int> queue = new Queue<int>();
            for (int i = 0; i < numCourses; i++) {
                if (inDegree[i] == 0) {
                    queue.Enqueue(i);
                }
            }
    
            int coursesTaken = 0; // 记录已完成的课程数量
    
            // 3. BFS 遍历
            while (queue.Count > 0) {
                int course = queue.Dequeue();
                coursesTaken++;
    
                // 遍历当前课程的后续课程
                foreach (int nextCourse in adj[course]) {
                    inDegree[nextCourse]--; // 移除对 nextCourse 的依赖
                    if (inDegree[nextCourse] == 0) {
                        queue.Enqueue(nextCourse); // 如果 nextCourse 入度变为 0,加入队列
                    }
                }
            }
    
            // 4. 判断是否所有课程都能完成
            return coursesTaken == numCourses;
        }
    }
    
    
  • 复杂度分析:

    • 时间复杂度:O(N+E),其中 N 是课程数量(顶点数),E 是先决条件数量(边数)。构建图和遍历图都只访问一次顶点和边。

    • 空间复杂度:O(N+E),用于存储邻接表和入度数组、队列。


4. 腐烂的橘子(Rotting Oranges)
  • 题目描述:

    在一个 mtimesn 的网格中,每个单元格可以是:

    • 0 代表空单元格。

    • 1 代表新鲜橘子。

    • 2 代表腐烂的橘子。

      每分钟,任何与腐烂橘子相邻(上、下、左、右)的新鲜橘子都会腐烂。

      返回所有橘子腐烂所需的最少分钟数。如果不可能让所有新鲜橘子都腐烂,则返回 -1。

  • 解题思路:多源 BFS 问题

    这道题是经典的多源 BFS 问题。它类似于水波扩散,从多个起始点(所有腐烂的橘子)同时开始扩散。BFS 最适合解决这种最短路径(或最少步数)的问题。

    步骤:

    1. 初始化:

      • 将所有初始腐烂的橘子(2)加入 BFS 队列。

      • 统计新鲜橘子(1)的数量。

      • 记录经过的分钟数 minutes。

    2. BFS 过程:

      • 每一轮 BFS 循环代表一分钟的流逝。

      • 在每一轮开始时,记录当前队列中的橘子数量 currentLevelSize。这些橘子是上一个时间点腐烂的,它们将导致它们周围的新鲜橘子在本轮腐烂。

      • 循环 currentLevelSize 次:

        a. 从队列中取出一个腐烂橘子。

        b. 检查其四个方向的邻居:

        i. 如果邻居是新鲜橘子 (1):将其变为腐烂 (2),新鲜橘子数量减 1,并将新腐烂的橘子加入队列。

      • 如果当前轮有新的橘子腐烂,minutes 加 1。

    3. 判断结果:

      • BFS 结束后,如果新鲜橘子数量为 0,则返回 minutes。

      • 否则,表示有新鲜橘子无法腐烂,返回 -1。

  • 代码实现:

    C#

    using System.Collections.Generic;
    
    public class Solution {
        public int OrangesRotting(int[][] grid) {
            if (grid == null || grid.Length == 0) {
                return 0;
            }
    
            int numRows = grid.Length;
            int numCols = grid[0].Length;
    
            Queue<(int, int)> queue = new Queue<(int, int)>();
            int freshOranges = 0; // 统计新鲜橘子数量
    
            // 1. 初始化队列,将所有初始腐烂橘子入队,并统计新鲜橘子
            for (int r = 0; r < numRows; r++) {
                for (int c = 0; c < numCols; c++) {
                    if (grid[r][c] == 2) {
                        queue.Enqueue((r, c));
                    } else if (grid[r][c] == 1) {
                        freshOranges++;
                    }
                }
            }
    
            // 如果没有新鲜橘子,直接返回 0 分钟
            if (freshOranges == 0) {
                return 0;
            }
    
            int minutes = 0;
            // 定义四个方向的偏移量:右、左、下、上
            int[] dr = {0, 0, 1, -1};
            int[] dc = {1, -1, 0, 0};
    
            // 2. BFS 过程
            while (queue.Count > 0) {
                int levelSize = queue.Count; // 当前轮次(当前分钟)需要处理的腐烂橘子数量
                bool didRotAny = false;      // 标记本轮是否有新鲜橘子腐烂
    
                for (int i = 0; i < levelSize; i++) {
                    (int currR, int currC) = queue.Dequeue();
    
                    for (int j = 0; j < 4; j++) {
                        int nr = currR + dr[j];
                        int nc = currC + dc[j];
    
                        // 检查新坐标是否在网格内,且是新鲜橘子
                        if (nr >= 0 && nr < numRows && nc >= 0 && nc < numCols && grid[nr][nc] == 1) {
                            grid[nr][nc] = 2; // 标记为腐烂
                            freshOranges--;  // 新鲜橘子数量减一
                            queue.Enqueue((nr, nc)); // 新腐烂的橘子入队
                            didRotAny = true; // 本轮有橘子腐烂
                        }
                    }
                }
    
                // 如果本轮有橘子腐烂,分钟数增加
                if (didRotAny) {
                    minutes++;
                } else {
                    // 如果本轮没有橘子腐烂,但队列里还有橘子,说明这些橘子都是已经腐烂过的,
                    // 并且它们周围已经没有新鲜橘子可腐烂了,可以提前结束循环
                    // (其实可以不加这个,因为 freshOranges==0 会提前判断)
                }
            }
    
            // 3. 判断结果
            return freshOranges == 0 ? minutes : -1;
        }
    }
    
    
  • 复杂度分析:

    • 时间复杂度:O(RtimesC)。每个单元格最多入队和出队一次。

    • 空间复杂度:O(RtimesC)。最坏情况下所有橘子都在队列中。


补充算法(概念性介绍,知晓即可)

这些是图领域更高级和复杂的算法,在面试中通常只会要求你了解其概念、用途和复杂度。

1. 并查集(Union-Find Set)
  • 概念:

    一种用于处理一些不相交集合(Disjoint Sets)的抽象数据类型。它支持两种主要操作:

    • 并(Union):合并两个集合。

    • 查(Find):查找元素所属的集合的代表元素(通常是根节点),可以判断两个元素是否在同一个集合中。

      并查集通常采用树形结构实现,并通过**路径压缩(Path Compression)和按秩/大小合并(Union by Rank/Size)**优化,使其操作接近 O(alpha(N))(阿克曼函数的反函数,非常接近常数时间)。

  • 应用:

    • 连通分量:判断图中两个顶点是否连通,或者统计图中有多少个连通分量。

    • 判断图是否有环:在构建图时,如果添加一条边 (u, v) 发现 u 和 v 已经在同一个集合中(即它们已连通),则说明添加这条边会形成环。这是Kruskal 算法(最小生成树算法之一)的基础。

    • 网络连接、好友关系、游戏中的联盟系统等。

2. 最短路径算法
  • Dijkstra 算法(概念):

    • 用途:解决单源最短路径问题,即从图中一个指定源点到所有其他可达顶点的最短路径。

    • 限制:边的权值必须是非负数。

    • 思想:使用贪心策略,每次从“未确定最短路径”的顶点中选择距离源点最近的那个顶点,并更新其邻居的距离。通常结合优先队列(或小根堆)实现。

    • 时间复杂度:O(ElogN) 或 O(E+NlogN)(使用优先队列)。

  • Floyd-Warshall 算法(概念):

    • 用途:解决所有顶点对之间的最短路径问题,即计算图中任意两个顶点之间的最短路径。

    • 限制:可以处理负权边,但不能处理负权环(否则最短路径没有定义)。

    • 思想:动态规划。通过中间顶点 k 来逐步更新任意两点 i 和 j 之间的最短路径。

    • 时间复杂度:O(N3)。

3. 最小生成树(MST)
  • 概念:

    对于一个带权无向连通图,最小生成树是它的一个子图,它包含了图中所有的顶点,并且只包含足够形成一棵树的边(即 N−1 条边),使得这些边的权值之和最小。

  • Prim 算法(概念):

    • 思想:从一个起始顶点开始,逐步将距离当前生成树最近的边加入树中,直到包含所有顶点。类似 Dijkstra 算法,也常结合优先队列。

    • 时间复杂度:O(ElogN) 或 O(E+NlogN)。

  • Kruskal 算法(概念):

    • 思想:将所有边按权值从小到大排序,然后依次遍历边。如果添加当前边不会形成环(使用并查集判断),则将其加入最小生成树。

    • 时间复杂度:O(ElogE) 或 O(ElogN)(排序和并查集操作)。


总结与练习

本篇我们深入探索了图这一强大的非线性数据结构,它能够描述现实世界中各种复杂的网状关系。我们学习了图的基本概念、两种主要表示方法(邻接矩阵和邻接表)及其优缺点。

我们重点掌握了图的两种核心遍历算法:深度优先搜索(DFS)和广度优先搜索(BFS),并理解了它们在不同场景下的应用。通过岛屿数量、克隆图、课程表和腐烂橘子这四个经典面试题的解析,你已经能够将这些遍历算法活学活用。

最后,我们概念性地介绍了更高级的图算法,如拓扑排序、并查集、最短路径算法(Dijkstra、Floyd-Warshall)和最小生成树算法(Prim、Kruskal)。这些算法是图论的精髓,掌握它们的思想将让你在复杂问题面前游刃有余。

本篇核心知识点回顾:

  • 图的基本概念:顶点、边、有向/无向、带权、连通性、度。

  • 图的表示:邻接矩阵 (O(N2) 空间,判断边 O(1)) 和 邻接表 (O(N+E) 空间,实际更常用)。

  • 图的遍历:

    • DFS:用栈(或递归)实现,擅长深度探索,如找路径、环检测。

    • BFS:用队列实现,擅长层级探索,如无权最短路径、扩散问题。

  • 经典面试题:

    • 岛屿数量:DFS/BFS 连通分量。

    • 克隆图:DFS/BFS + 哈希表处理循环引用。

    • 课程表:拓扑排序(Kahn 算法/DFS 算法),判断 DAG。

    • 腐烂的橘子:多源 BFS。

  • 补充算法:并查集、Dijkstra、Floyd-Warshall、Prim、Kruskal(理解概念和用途)。

课后练习(推荐力扣 LeetCode 题目):

  1. 岛屿数量 (Number of Islands):LeetCode 200。

  2. 矩阵中的连通分量 (Max Area of Island):LeetCode 695(和岛屿数量类似)。

  3. 克隆图 (Clone Graph):LeetCode 133。

  4. 课程表 (Course Schedule):LeetCode 207。

  5. 课程表 II (Course Schedule II):LeetCode 210(要求返回拓扑排序结果)。

  6. 腐烂的橘子 (Rotting Oranges):LeetCode 994。

  7. 钥匙和房间 (Keys and Rooms):LeetCode 841(简单图遍历)。

  8. 判断二分图 (Is Graph Bipartite?):LeetCode 785(BFS/DFS 染色问题)。

  9. 被围绕的区域 (Surrounded Regions):LeetCode 130(DFS/BFS 边界连通性问题)。

图是计算机科学中最复杂也最迷人的领域之一。大量的实际问题都可以抽象成图论问题并用相应的算法解决。通过本篇的学习和练习,你将对图的结构和算法有更深的理解,为解决更复杂的工程问题打下坚实基础。

下一篇,我们将进入算法设计的高级技巧——动态规划,学习如何化繁为简,解决那些看似无从下手的问题!你准备好了吗?


Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐