算法入门第七篇:图与搜索:复杂关系网的探索


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

引言:图的无限可能

朋友们,恭喜你来到了算法学习的又一个高峰——图(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' 开始,使用 DFSBFS 遍历所有与其相连的陆地,并将它们标记为已访问(例如,改为 '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) 发现 uv 已经在同一个集合中(即它们已连通),则说明添加这条边会形成环。这是Kruskal 算法(最小生成树算法之一)的基础。

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

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

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

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

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

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

  • Floyd-Warshall 算法(概念)

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

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

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

    • 时间复杂度: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 边界连通性问题)。

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

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


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

吉良吉影NeKoSuKi

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值