左哥算法 - 图及其相关算法(一)

259 阅读7分钟

1. 图的基本概念

图是由顶点(vertex)和边(edge)组成的数据结构:

  • 顶点:图中的节点
  • 边:连接顶点的线
  • 有向图:边有方向
  • 无向图:边无方向

1. 无向图

graph TD
    A --- B
    A --- C
    B --- D
    C --- D

2. 有向图

graph TD
    A --> B
    A --> C
    B --> D
    D --> C

3. 带权图

graph TD
    A -->|5|B
    A -->|3|C
    B -->|2|D
    C -->|4|D

4. 有向无环图(DAG)

graph TD
    A --> B
    A --> C
    B --> D
    C --> D

5. 树结构

graph TD
    A --> B
    A --> C
    B --> D
    B --> E
    C --> F

6. 完全图

graph TD
    A --- B
    A --- C
    A --- D
    B --- C
    B --- D
    C --- D

这些图表更加清晰地展示了:

  1. 图的不同类型
  2. 边的方向性
  3. 权重的表示
  4. 遍历的顺序
  5. 不同节点间的关系

2. 图的表示方法

让我用图示来详细解释图的两种主要表示方法。

1. 邻接矩阵 (Adjacency Matrix)

首先看一个简单的有向图:

graph LR
    1 -->|5| 2
    2 -->|3| 3
    1 -->|4| 3

这个图的邻接矩阵表示:

// 邻接矩阵实现
class Graph {
    int[][] matrix;  // 邻接矩阵
    int N;          // 节点数量
    
    public Graph(int n) {
        N = n;
        matrix = new int[N][N];
    }
}

对应的矩阵值:

    1  2  3
1   0  5  4
2   0  0  3
3   0  0  0

说明:

  • matrix[i][j] 表示从节点i到节点j的边的权重
  • 0 表示没有边相连
  • 对角线上的值通常为0(除非有自环)

优缺点:

优点:
1. 查找两点间是否有边很快,时间复杂度O(1)
2. 容易实现和理解
3. 适合稠密图(边很多的图)

缺点:
1. 空间复杂度固定为O(N²)
2. 对于稀疏图(边较少)浪费空间
3. 添加/删除节点需要调整整个矩阵

2. 邻接表 (Adjacency List)

还是用同样的图:

graph LR
    1 -->|5| 2
    2 -->|3| 3
    1 -->|4| 3

邻接表的实现:

class Graph {
    // 图节点的定义
    class Node {
        int val;                  // 节点值
        ArrayList<Edge> edges;    // 从该节点出发的所有边
        
        public Node(int val) {
            this.val = val;
            edges = new ArrayList<>();
        }
    }
    
    // 边的定义
    class Edge {
        Node to;     // 指向的节点
        int weight;  // 边的权重
        
        public Edge(Node to, int weight) {
            this.to = to;
            this.weight = weight;
        }
    }
    
    // 存储所有节点
    HashMap<Integer, Node> nodes;
    
    public Graph() {
        nodes = new HashMap<>();
    }
}

对应的邻接表结构:

1 -> [(2,5), (3,4)]     // 节点1 连接到节点2(权重5)和节点3(权重4)
2 -> [(3,3)]            // 节点2 连接到节点3(权重3)
3 -> []                 // 节点3 没有出边

可视化表示:

graph TD
    subgraph 节点1的邻接表
    1-->|5|2
    1-->|4|3
    end
    subgraph 节点2的邻接表
    2.1[2]-->|3|3.1[3]
    end
    subgraph 节点3的邻接表
    3.2[3]
    end

优缺点:

优点:
1. 空间效率高,只需要存储实际的边
2. 适合稀疏图
3. 容易找到一个顶点的所有邻接点
4. 添加节点很容易

缺点:
1. 查找两个顶点间是否有边需要遍历,时间复杂度O(degree)
2. 实现相对复杂
3. 删除边的操作较麻烦

实际应用选择

  1. 选择邻接矩阵的情况:
- 图比较稠密
- 需要经常查询两个点之间是否有边
- 图的规模较小
- 需要频繁修改边的信息
  1. 选择邻接表的情况:
- 图比较稀疏
- 需要经常遍历节点的邻居
- 图的规模较大
- 需要频繁添加/删除节点

空间复杂度比较

假设图有V个顶点,E条边:

  • 邻接矩阵:O(V²)
  • 邻接表:O(V + E)

这就是为什么在处理大规模稀疏图时,邻接表通常是更好的选择。例如社交网络图,虽然用户(节点)很多,但每个用户的好友(边)相对较少,用邻接表更合适。

3. 图的遍历算法

(1)BFS(Breadth-First Search)(宽度优先搜索)

BFS遍历示意(层次遍历)

graph TD
    A -->|1|B
    A -->|1|C
    B -->|2|D
    C -->|2|E
    
    style A fill:#f9f,stroke:#333,stroke-width:4px
    style B fill:#bbf,stroke:#333,stroke-width:2px
    style C fill:#bbf,stroke:#333,stroke-width:2px
    style D fill:#ddf,stroke:#333,stroke-width:2px
    style E fill:#ddf,stroke:#333,stroke-width:2px

流程:

  1. 使用队列存储待访问的节点
  2. 访问队列中的节点,并将其未访问过的邻居加入队列
  3. 重复步骤2直到队列为空
public void bfs(Node start) {
    if (start == null) return;
    
    Queue<Node> queue = new LinkedList<>();
    HashSet<Node> visited = new HashSet<>();
    
    queue.offer(start);
    visited.add(start);
    
    while (!queue.isEmpty()) {
        Node cur = queue.poll();
        System.out.println(cur.value); // 处理当前节点
        
        for (Node next : cur.neighbors) {
            if (!visited.contains(next)) {
                queue.offer(next);
                visited.add(next);
            }
        }
    }
}
代码详细讲解

让我用图示来详细解释BFS(广度优先搜索)的工作原理。

BFS工作流程演示

假设我们有这样一个图:

graph TD
    1 --> 2
    1 --> 3
    2 --> 4
    2 --> 5
    3 --> 6

让我们一步步看代码的执行过程:

1. 初始化
Queue<Node> queue = new LinkedList<>();     // 创建队列
HashSet<Node> visited = new HashSet<>();    // 创建已访问集合

状态:

队列:[]
已访问:[]
2. 添加起始节点
queue.offer(start);      // 节点1入队
visited.add(start);      // 标记节点1为已访问

状态:

队列:[1]
已访问:[1]
3. 开始BFS循环
while (!queue.isEmpty()) {
    Node cur = queue.poll();  // 取出队首节点
    System.out.println(cur.value);  // 处理当前节点

第一轮:

graph TD
    1((1))-->2
    1-->3
    2-->4
    2-->5
    3-->6
    
    style 1 fill:#f96,stroke:#333,stroke-width:4px

状态:

处理节点:1
队列:[]
已访问:[1]
4. 处理邻居节点
for (Node next : cur.neighbors) {
    if (!visited.contains(next)) { //如果节点next没有被访问过,则执行
        queue.offer(next);
        visited.add(next);
    }
}

处理节点1的邻居(2和3):

graph TD
    1((1))-->2((2))
    1-->3((3))
    2-->4
    2-->5
    3-->6
    
    style 1 fill:#f96,stroke:#333,stroke-width:4px
    style 2 fill:#9cf,stroke:#333,stroke-width:4px
    style 3 fill:#9cf,stroke:#333,stroke-width:4px

状态:

队列:[2, 3]
已访问:[1, 2, 3]
5. 继续循环

处理节点2:

graph TD
    1((1))-->2((2))
    1-->3((3))
    2-->4((4))
    2-->5((5))
    3-->6
    
    style 1 fill:#f96,stroke:#333,stroke-width:4px
    style 2 fill:#f96,stroke:#333,stroke-width:4px
    style 3 fill:#9cf,stroke:#333,stroke-width:4px
    style 4 fill:#9cf,stroke:#333,stroke-width:4px
    style 5 fill:#9cf,stroke:#333,stroke-width:4px

状态:

队列:[3, 4, 5]
已访问:[1, 2, 3, 4, 5]

处理节点3:

graph TD
    1((1))-->2((2))
    1-->3((3))
    2-->4((4))
    2-->5((5))
    3-->6((6))
    
    style 1 fill:#f96,stroke:#333,stroke-width:4px
    style 2 fill:#f96,stroke:#333,stroke-width:4px
    style 3 fill:#f96,stroke:#333,stroke-width:4px
    style 4 fill:#9cf,stroke:#333,stroke-width:4px
    style 5 fill:#9cf,stroke:#333,stroke-width:4px
    style 6 fill:#9cf,stroke:#333,stroke-width:4px

状态:

队列:[4, 5, 6]
已访问:[1, 2, 3, 4, 5, 6]
关键点解释
  1. 队列的作用

    • 保证按层次访问
    • 先进先出(FIFO)特性确保同层节点优先处理
  2. visited集合的作用

    • 防止重复访问节点
    • 避免死循环
  3. 访问顺序特点

    • 按照离起始节点的距离从近到远
    • 同一层的节点会被连续访问
  4. 时间复杂度

    • O(V + E),V是节点数,E是边数
    • 每个节点和边都只会被访问一次
实际应用场景
  1. 社交网络中的"六度人脉"
  2. 地图软件寻找最短路径
  3. 网络爬虫按层次抓取网页
  4. 树的层次遍历

BFS的核心思想就是"一圈一圈"地向外扩展,就像水面上的波纹一样,从起点向四周扩散。这种遍历方式保证了我们能够找到从起点到任意可达节点的最短路径(按边数计算)。

(2)DFS (Depth-First Search)(深度优先搜索)

DFS遍历示意(深度优先)

graph TD
    A -->|1|B
    A -->|4|CDFS
    B -->|2|D
    B -->|3|E
    
    style A fill:#f9f,stroke:#333,stroke-width:4px
    style B fill:#bbf,stroke:#333,stroke-width:2px
    style D fill:#ddf,stroke:#333,stroke-width:2px
    style E fill:#ddf,stroke:#333,stroke-width:2px
    style C fill:#eef,stroke:#333,stroke-width:2px

流程:

  1. 访问当前节点
  2. 递归访问未访问过的邻居节点
  3. 标记已访问的节点
public void dfs(Node node, Set<Node> visited) {
    if (node == null) return;
    
    visited.add(node);
    System.out.println(node.value); // 处理当前节点
    
    for (Node next : node.neighbors) {
        if (!visited.contains(next)) {//如果节点next没有被访问过,则执行
            dfs(next, visited);
        }
    }
}
代码详细讲解

让我详细讲解DFS(深度优先搜索)的工作原理。

DFS工作流程演示

假设我们有这样一个图:

graph TD
    1 --> 2
    1 --> 3
    2 --> 4
    2 --> 5
    3 --> 6
代码分解讲解
  1. 函数定义
public void dfs(Node node, Set<Node> visited)
  • node: 当前要访问的节点
  • visited: 记录已经访问过的节点的集合
  • 类似于走迷宫时带着一个记事本,记录走过的路
  1. 基础判断
if (node == null) return;
  • 安全检查,如果节点为空就返回
  • 就像走迷宫时碰到死胡同,需要返回
  1. 访问当前节点
visited.add(node);
System.out.println(node.value);
  • 将当前节点标记为已访问
  • 处理当前节点(这里是打印值)
  • 相当于在记事本上记下:"我来过这里"
执行流程演示

让我们看看从节点1开始的DFS过程:

  1. 访问节点1
graph TD
    1((1))-->2
    1-->3
    2-->4
    2-->5
    3-->6
    
    style 1 fill:#f96,stroke:#333,stroke-width:4px

状态:

visited = [1]
正在处理:节点1
  1. 访问节点2(1的第一个邻居):
graph TD
    1((1))-->2((2))
    1-->3
    2-->4
    2-->5
    3-->6
    
    style 1 fill:#f96,stroke:#333,stroke-width:4px
    style 2 fill:#f96,stroke:#333,stroke-width:4px

状态:

visited = [1, 2]
正在处理:节点2
  1. 访问节点4(2的第一个邻居):
graph TD
    1((1))-->2((2))
    1-->3
    2-->4((4))
    2-->5
    3-->6
    
    style 1 fill:#f96,stroke:#333,stroke-width:4px
    style 2 fill:#f96,stroke:#333,stroke-width:4px
    style 4 fill:#f96,stroke:#333,stroke-width:4px

状态:

visited = [1, 2, 4]
正在处理:节点4
DFS的特点
  1. 递归特性
dfs(next, visited);  // 递归调用
  • 像走迷宫时,遇到岔路口就一直往一个方向走到底
  • 走不通了再回溯到上一个岔路口尝试其他路径
  1. 访问标记
if (!visited.contains(next))  // 检查是否访问过
  • 防止重复访问节点
  • 避免在有环的图中陷入死循环
完整执行顺序示例
graph TD
    1((1/第1步))-->2((2/第2步))
    1-->3((3/第5步))
    2-->4((4/第3步))
    2-->5((5/第4步))
    3-->6((6/第6步))
    
    style 1 fill:#f96,stroke:#333,stroke-width:4px
    style 2 fill:#f96,stroke:#333,stroke-width:4px
    style 3 fill:#f96,stroke:#333,stroke-width:4px
    style 4 fill:#f96,stroke:#333,stroke-width:4px
    style 5 fill:#f96,stroke:#333,stroke-width:4px
    style 6 fill:#f96,stroke:#333,stroke-width:4px

访问顺序:1 → 2 → 4 → 5 → 3 → 6

实际应用场景
  1. 迷宫探索

    • 一直走到走不通为止
    • 然后回溯尝试其他路径
  2. 文件系统遍历

    • 深入遍历文件夹
    • 类似于树形结构的遍历
  3. 游戏中的路径查找

    • 寻找从起点到终点的路径
    • 特别适合需要遍历所有可能路径的场景
关键点总结
  1. 递归实现

    • 代码简洁
    • 自然表达深度优先的特性
  2. 访问标记

    • 避免重复访问
    • 防止死循环
  3. 回溯特性

    • 自动返回到上一个决策点
    • 尝试所有可能的路径

DFS就像是一个不撞南墙不回头的探索者,一直往深处走,直到走不通了才回头尝试其他路径,这种特性使它特别适合用来解决迷宫类问题或需要遍历所有可能路径的场景。

总结

图的算法核心要点:

  1. 理解图的基本表示方法(邻接矩阵/邻接表)
  2. 掌握两种基本遍历方式(BFS/DFS)
  3. 熟悉常用算法的应用场景
  4. 注意处理环和重复访问的问题

图的算法通常都需要:

  • 访问标记:防止重复访问
  • 队列/栈:辅助实现遍历
  • 哈希表:存储额外信息