🌐 图基础 — 邻接与遍历的王国

图是最强大的数据结构,社交网络、地图导航、网页链接皆由图来表达

📖 图的核心概念

图(Graph)由顶点(Vertex)边(Edge)组成,是表达实体间关系最自然的数据结构。树和链表都是图的特例。面试中,图的表示、遍历、连通性是最核心的考点。

图的两种主要表示: 邻接矩阵 (Adjacency Matrix): 邻接表 (Adjacency List): 0 1 2 3 0 → [1, 3] 0 [ 0 1 0 1 ] 1 → [0, 2] 1 [ 1 0 1 0 ] 2 → [1, 3] 2 [ 0 1 0 1 ] 3 → [0, 2] 3 [ 1 0 1 0 ] 矩阵: O(V²)空间, O(1)查边 邻接表: O(V+E)空间, 遍历邻居高效 适合: 稠密图 适合: 稀疏图(面试主流)

1. 图的分类

有向图 vs 无向图:有向图的边有方向(A→B ≠ B→A),无向图的边是双向的。面试中社交网络(好友关系)是无向图,网页链接(超链接)是有向图。
加权图 vs 无权图:加权图的边带数值(距离、成本),无权图的边只表示连接关系。最短路径问题通常是加权图。
连通性:无向图中,如果任意两个顶点都有路径相连,则图是连通的。有向图对应的概念是强连通(互相可达)。

2. Python 图操作速查

# 邻接表表示(面试推荐)
from collections import defaultdict

# 无向图
graph = defaultdict(list)
graph[0].append(1)  # 添加边 0-1
graph[1].append(0)  # 无向图需要双向添加

# 有向图
digraph = defaultdict(list)
digraph[0].append(1)  # 只添加 0→1

# 邻接矩阵(稠密图时使用)
n = 5
matrix = [[0] * n for _ in range(n)]
matrix[0][1] = 1  # 添加边 0→1

# 加权图
weighted = defaultdict(list)
weighted[0].append((1, 5))  # 边 0→1, 权重5
面试中 90% 的图题使用邻接表。只有在题目给矩阵输入(如岛屿问题)时才直接在矩阵上操作。邻接表用 defaultdict(list) 是最简洁的写法。

🎯 题目一:克隆图 (LC 133)

题目描述:给定无向连通图中一个节点的引用,返回该图的深拷贝。图中的每个节点都包含它的值 val 和其邻居的列表 neighbors

示例: 克隆一个 4 节点环图 原图: 克隆后: 1 --- 2 1' --- 2' | | | | 4 --- 3 4' --- 3' 关键: 新节点必须和原节点独立 邻居关系必须完全复制

思路分析

DFS + 哈希表:遍历原图,对每个节点创建副本。用哈希表 visited 映射原节点→副本节点,避免重复创建和死循环。
递归过程:对节点 n,如果已创建副本则直接返回;否则创建副本,存入哈希表,然后递归处理所有邻居。
时间复杂度:O(V+E),空间复杂度:O(V)
BFS 方案:用队列层序遍历,同样用哈希表记录映射。每次从队列取出原节点,为其邻居创建副本(如果尚未创建),并将新邻居加入新节点的邻居列表。

代码实现

class Solution:
    def cloneGraph(self, node: 'Node') -> 'Node':
        if not node:
            return None
        
        visited = {}  # 原节点 -> 克隆节点
        
        def dfs(n):
            if n in visited:
                return visited[n]
            # 创建克隆节点
            copy = Node(n.val)
            visited[n] = copy
            # 递归克隆所有邻居
            for neighbor in n.neighbors:
                copy.neighbors.append(dfs(neighbor))
            return copy
        
        return dfs(node)

复杂度分析

时间复杂度:O(V+E) — 每个节点和边访问一次

空间复杂度:O(V) — 哈希表存储所有节点映射 + 递归栈深度

图的遍历一定要用 visited 防止死循环!这是图和树遍历的核心区别——树没有环,图可能有。忘记 visited 是最常见的 bug。

变体练习

🎯 题目二:岛屿数量 (LC 200)

题目描述:给你一个由 '1'(陆地)和 '0'(水)组成的二维网格,计算网格中岛屿的数量。岛屿是被水包围的陆地,由相邻的陆地连接而成(水平或垂直方向相邻)。

示例网格: 1 1 0 0 0 1 1 0 0 0 岛屿1: 左上4个1 0 0 1 0 0 岛屿2: 中间1个1 0 0 0 1 1 岛屿3: 右下2个1 答案: 3 个岛屿 关键: 找到'1'就DFS/BFS淹没整个岛 → 岛屿数 = 启动DFS的次数

思路分析

DFS 沉岛法:遍历网格,遇到 '1' 就启动 DFS,将连通的所有 '1' 标记为已访问(改为 '#' 或其他字符),然后岛屿计数 +1。
每个连通分量 = 一个岛屿 = 一次 DFS
时间复杂度:O(m×n),空间复杂度:O(m×n)(递归深度)
BFS 沉岛法:用队列逐层扩展,效果相同但避免递归栈溢出。对于超大网格,BFS 更安全。
时间复杂度:O(m×n),空间复杂度:O(min(m,n))(队列大小)

代码实现

class Solution:
    def numIslands(self, grid: List[List[str]]) -> int:
        if not grid:
            return 0
        rows, cols = len(grid), len(grid[0])
        count = 0
        
        def dfs(r, c):
            # 越界或不是陆地,返回
            if r < 0 or r >= rows or c < 0 or c >= cols or grid[r][c] != '1':
                return
            # 标记已访问(沉岛)
            grid[r][c] = '#'
            # 向四个方向扩展
            dfs(r + 1, c)
            dfs(r - 1, c)
            dfs(r, c + 1)
            dfs(r, c - 1)
        
        for r in range(rows):
            for c in range(cols):
                if grid[r][c] == '1':
                    dfs(r, c)
                    count += 1
        return count

复杂度分析

时间复杂度:O(m×n) — 每个格子最多访问一次

空间复杂度:O(m×n) — 最坏情况递归深度为 m×n

"沉岛法"直接修改原数组,省去了额外的 visited 数组。如果题目要求不能修改原数组,就用 visited 集合。面试时先问清楚是否可以修改输入。

变体练习

🎯 题目三:岛屿的最大面积 (LC 695)

题目描述:给定一个包含 01 的二维二进制矩阵,找出矩阵中最大的岛屿面积。岛屿的面积是连通的 1 的数量。

示例网格: 0 0 1 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 0 0 0 0 1 1 0 1 0 0 0 0 0 0 0 0 0 1 0 0 1 1 0 0 1 0 1 0 0 最大岛屿面积: 6 0 1 0 0 1 1 0 0 1 1 1 0 0 (右下方6个连续的1) 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 1 1 1 0 0 0 0 0 0 0 0 0 0 1 1 0 0 0 0

思路分析

DFS 计数法:在 LC 200 的基础上,DFS 不只标记已访问,还要返回当前连通分量的面积。每访问一个 1,面积 +1,递归累加四个方向的面积。
全局维护最大面积即可。
时间复杂度:O(m×n),空间复杂度:O(m×n)

代码实现

class Solution:
    def maxAreaOfIsland(self, grid: List[List[int]]) -> int:
        if not grid:
            return 0
        rows, cols = len(grid), len(grid[0])
        max_area = 0
        
        def dfs(r, c):
            if r < 0 or r >= rows or c < 0 or c >= cols or grid[r][c] != 1:
                return 0
            grid[r][c] = 0  # 标记已访问
            area = 1  # 当前格子
            area += dfs(r + 1, c)
            area += dfs(r - 1, c)
            area += dfs(r, c + 1)
            area += dfs(r, c - 1)
            return area
        
        for r in range(rows):
            for c in range(cols):
                if grid[r][c] == 1:
                    max_area = max(max_area, dfs(r, c))
        return max_area

复杂度分析

时间复杂度:O(m×n) — 每个格子访问一次

空间复杂度:O(m×n) — 递归深度最坏为 m×n

LC 695 和 LC 200 的核心区别:200 是计数(有多少连通分量),695 是求最大连通分量的大小。解题框架完全相同,DFS 的返回值不同。掌握框架,一通百通!

变体练习

🔬 图遍历的两种范式

DFS 和 BFS 是图遍历的两大基石,它们在面试中的应用场景有所不同:

特性DFS(深度优先)BFS(广度优先)
数据结构栈(递归或显式栈)队列
遍历顺序一条路走到底逐层扩展
空间复杂度O(V) 栈深度O(V) 队列大小
适合问题连通性、路径搜索、回溯最短路径、层序遍历
岛屿问题✅ 沉岛法✅ 层序扩展
最短路❌ 不保证最短✅ 无权图最短路

BFS 遍历图的标准模板

from collections import deque, defaultdict

def bfs_graph(graph, start):
    visited = set([start])
    queue = deque([start])
    while queue:
        node = queue.popleft()
        for neighbor in graph[node]:
            if neighbor not in visited:
                visited.add(neighbor)
                queue.append(neighbor)
    return visited

DFS 遍历图的标准模板

def dfs_graph(graph, node, visited=None):
    if visited is None:
        visited = set()
    visited.add(node)
    for neighbor in graph[node]:
        if neighbor not in visited:
            dfs_graph(graph, neighbor, visited)
    return visited
Python 递归深度默认 1000,大图 DFS 可能栈溢出。解决方案:(1) 用 BFS 替代;(2) 用 sys.setrecursionlimit(10**5) 提高限制;(3) 用显式栈模拟递归。
成就解锁:图论先锋 — 掌握图的基本表示、DFS/BFS遍历、连通分量检测三大核心技能
LeetCode AC验证:LC 133 Clone Graph ✅ | LC 200 Number of Islands ✅ | LC 695 Max Area of Island ✅

📝 课后练习

  1. LC 997 找到小镇的法官(图入度出度)
  2. LC 841 钥匙和房间(图连通性)
  3. LC 130 被围绕的区域(边界DFS)
  4. LC 463 岛屿的周长(岛屿变体)
  5. LC 733 图像渲染(简单DFS/BFS)

🔑 本课要点回顾

📚 扩展阅读

思考题:如果一个网格中有对角线连接(8方向而非4方向),岛屿数量会如何变化?DFS 方向数组该如何修改?提示:只需将4个方向扩展为8个方向。