代码随想录-图论

131 阅读10分钟

图论

一、图论理论基础

参见[图论理论基础]

(一)、基本概念

图论中的图是由一组顶点和一组边组成的,边连接顶点。顶点也称为[节点],代表实体,边代表顶点之间的关系。边可以是无向的,也可以是有向的。

  • 无向边表示顶点之间的关系是对称的,由无向边和定点组成的图为无向图image.png
  • 有向边则表示顶点之间的关系是有方向的。由有向边和顶点组成的图为有向图image.png

1、度

在无向图中,一个顶点的度是指,与该顶点相关联的边的数量。

在有向图中,顶点的度分为出度和入度。出度是指从该顶点出发的边的数量,入度是指进入该顶点边的数量。

2、路径

从一个顶点到另一个顶点的边的序列,称为路径。路径的长度是指,路径中边的数量。

如果路径中没有重复的顶点,那就是简单路径。如果路径中的起点和终点相同,那么该路径称为回路或者环。

3、连通性

在图中,如果两个顶点之间存在路径,则说明这两个顶点是连通的。如果一个图的任意两个顶点都是连通的,那么这个图就被称为连通图

在有向图中,对于任意的两个顶点UUVV,若同时存在UVU \rightarrow VVUV \rightarrow U的路径,则说明该有向图是强连通的。

若将该有向图是作为无向图之后,无向图是连通的,则该图是弱连通的。

4、连通分量

无向图中的极大连通子图,一个无向图可以有多个连通分量。整个图是连通的,当前仅当它只有一个连通分量。

5、强连通分量

有向图中的极大连通子图,一个有向图可以有多个强连通分量。

6、生成树

一个连通无向图的生成树是指该图的一个子图,它是一棵树,包含图中的所有顶点。生成树通常用于寻找最小生成树,即权重之和最小的生成树。

7、生成森林

一个无向图的生成森林是指,该图的每一个连通分量的生成树的集合。

(二)、图的存储

一般,图的存储方式为邻接表和邻接矩阵。

1、邻接表

邻接表是一种用数组和链表结合起来表示图的方式。数组中的每一个元素表示顶点,其指向一个链表,链表中存储的是与该顶点相连的顶点。

import java.util.ArrayList;
import java.util.LinkedList;

public class Graph {
    private int vertices; // 顶点数量
    private ArrayList<LinkedList<Integer>> adjacencyList; // 邻接表

    // 构造函数
    public Graph(int vertices) {
        this.vertices = vertices;
        adjacencyList = new ArrayList<>(vertices);

        // 初始化邻接表
        for (int i = 0; i < vertices; i++) {
            adjacencyList.add(new LinkedList<>());
        }
    }

    // 添加一条边 u->v
    public void addEdge(int u, int v) {
        adjacencyList.get(u).add(v);
    }

    // 打印图的邻接表
    public void printGraph() {
        for (int i = 0; i < vertices; i++) {
            System.out.print("顶点 " + i + " 的邻接表:");
            for (int neighbor : adjacencyList.get(i)) {
                System.out.print(neighbor + " ");
            }
            System.out.println();
        }
    }

    public static void main(String[] args) {
        Graph graph = new Graph(5); // 创建一个有5个顶点的图
        graph.addEdge(0, 1);
        graph.addEdge(0, 4);
        graph.addEdge(1, 2);
        graph.addEdge(1, 3);
        graph.addEdge(1, 4);
        graph.addEdge(2, 3);
        graph.addEdge(3, 4);

        graph.printGraph();
    }
}

2、邻接矩阵

邻接矩阵是一个二维数组,用于表示顶点之间的连接关系里如果顶点ij之间有边,则matrix[i][j]之间的值为1,否则为0

public class Graph {
    private int vertices; // 顶点数量
    private int[][] adjacencyMatrix; // 邻接矩阵

    // 构造函数
    public Graph(int vertices) {
        this.vertices = vertices;
        adjacencyMatrix = new int[vertices][vertices];
    }

    // 添加一条边 u->v
    public void addEdge(int u, int v) {
        adjacencyMatrix[u][v] = 1;
        // 如果是无向图,还需要设置 adjacencyMatrix[v][u] = 1
    }

    // 打印图的邻接矩阵
    public void printGraph() {
        System.out.println("顶点之间的邻接矩阵:");
        for (int i = 0; i < vertices; i++) {
            for (int j = 0; j < vertices; j++) {
                System.out.print(adjacencyMatrix[i][j] + " ");
            }
            System.out.println();
        }
    }

    public static void main(String[] args) {
        Graph graph = new Graph(5); // 创建一个有5个顶点的图
        graph.addEdge(0, 1);
        graph.addEdge(0, 4);
        graph.addEdge(1, 2);
        graph.addEdge(1, 3);
        graph.addEdge(1, 4);
        graph.addEdge(2, 3);
        graph.addEdge(3, 4);

        graph.printGraph();
    }
}

邻接表和邻接矩阵的比较:

邻接矩阵邻接表
空间效率O(V2)O(V^2)O(V+E)O(V + E)
增删顶点O(1)O(1),需考虑数组扩容O(1)O(1)
判断边O(1)O(1)O(E/V)O(E / V)
适用场景密集图,结构稳定稀疏图,图结构动态变化比较大

(三)、图的遍历

图的遍历主要有两种,广度优先遍历(Board first search,BFS)和深度优先遍历(Deep first search, DFS)。

1、深度优先遍历

深度优先遍历就是,选择一个尚未被访问过的顶点,递归进行深度优先搜索,直到没有未访问过的邻接顶点为止。然后回溯到上一个顶点,继续访问其他未访问过的顶点。

DFS框架:

  1. 定义一个布尔数组boolean[] visited,记录某一个顶点是否被访问过
  2. 从某个顶点viv_i开始,标记该顶点已经被访问
  3. 遍历viv_i的所有邻接顶点wjw_j,对于每个未访问wjw_j,递归调用DFS函数
public void DFS(int startVertex) {
    boolean[] visited = new boolean[vertices];
    DFSUtil(startVertex, visited);
}

private void DFSUtil(int v, boolean[] visited) {
    visited[v] = true;
    System.out.print(v + " ");

    for (int neighbor : adjacencyList.get(v)) {
        if (!visited[neighbor]) {
            DFSUtil(neighbor, visited);
        }
    }
}

2、广度优先遍历

从某个节点出发,先访问该顶点,然后一次访问其所有未访问过的邻接顶点,再按照这些邻接顶点的顺序一次访问其未访问过的邻接顶点,直到所有的节点都被访问过为止。

BFS框架

  1. 定义一个布尔数组boolean[] visited,记录每一个及诶的那是否被访问过
  2. 使用一个队列来辅助遍历,将起始顶点v入队,并标记为已经访问
  3. 当队列不为空的时候,取出队首元素,访问该节点,并将其所有未访问过的顶点入队,并标记为已经访问
public void BFS(int startVertex) {
        boolean[] visited = new boolean[vertices];
        Queue<Integer> queue = new LinkedList<>();
        visited[startVertex] = true;
        queue.add(startVertex);

        while (!queue.isEmpty()) {
            int v = queue.poll();
            System.out.print(v + " ");

            for (int neighbor : adjacencyList.get(v)) {
                if (!visited[neighbor]) {
                    visited[neighbor] = true;
                    queue.add(neighbor);
                }
            }
        }

二、并查集理论基础

(一)、并查集的应用

首先简单介绍一下什么是并查集。所谓并查集,就是一种用于处理动态连通性的树形数据结构,主要用于高效管理不相交集合的合并与查询操作。

并查集的应用,换句话说,可以用并查集解决什么问题?并查集的核心功能就是用来快速判断两个元素是否属于同一集合。例如,社交网络中的好友关系、图的环路检测、最小生成树等。

并查并查,其核心操作有两个,一个是,另一个是

  • :所谓并,就是将两个元素添加到同一个集合
  • :所谓查,就是判断两个元素是否是同一个集合

(二)、并查集原理

假设要表示A | B | C三个元素在同一个集合中,应该如何去表示呢?

比较好的思路,就是将这三个元素构成一个有向图,即A -> B | B -> C,用代码表示为parent[A] = B | parent[B] = CA -> C之间是一个有向图。

具体的,将顶点u | v所在的边加入并查集的方法如下:

void join(int u, int v) {
    u = find(u); // 寻找u的根
    v = find(v); // 寻找v的根
    if (u == v) return; // 二者是同一个根
    parent[u] = v; 
}

寻根的逻辑是怎么样的呢?假设parent[A] = B | parent[B] = C,我们可以从A找到根C,也可以从B找到根C。通过数组下标找到数组的元素,一层一层寻根的过程,

int find(int u) {
    if (u == parent[u]) return u; // 根是自己,直接返回
    else return find(parent[u]);
}

当然,对于根C,其根为自己本身。因此,在对parent[]数组进行初始化的时候,需要将所有的根节点初始化为自己,即parent[i] = i

如何判断两个节点是否在同一个集合之内呢?只需要判断两个集合的根是否相同即可,即find(u) == find(v)

(三)、路径压缩

find()方法是一个从叶子节点到根节点的递归过程,叶子节点的深度决定了递归过程的时间复杂度。最理想的情况下,就是所有的叶子节点都在同一个根节点上,这样树的深度为1,一步就能够找到根节点。

如何去实现这样的效果呢?

要想实现这样的效果,就需要路径压缩,即将所有的非根节点,都直接指向根节点。在代码实现中如下:

int find(int u) {
    if (u != parent[u]) {
        parent[u] = find(parent[u]); // 将 u 的父节点通过递归挂载的根节点
    }
    return parent[u];
}

(四)、通用模板

通过以上的分析,并查集的通用模板也呼之欲出了。

class UnionFind { 
    private int[] parent;  // 父节点数组,parent[i]表示i的父节点 
    private int[] rank;    // 秩数组,记录树的深度 
    private int count;     // 集合数量(可选) 
    
    // 初始化并查集,每个元素自成一个集合 
    public UnionFind(int n) { 
        parent = new int[n]; 
        rank = new int[n]; 
        count = n; 
        for (int i = 0; i < n; i++) { 
        parent[i] = i;  // 初始父节点指向自己 
        rank[i] = 0;    // 初始秩为0 
        } 
    } 
    
    // 查找根节点(路径压缩优化) 
    public int find(int x) { 
        if (parent[x] != x) { 
            parent[x] = find(parent[x]);  // 路径压缩,直接指向根 
        } 
        return parent[x]; 
    } 
    
    // 合并两个集合
    public void union(int u, int v) { 
        u = find(u); // 寻找u的根
        v = find(v); // 寻找v的根
        if (u == v) return; // 二者是同一个根
        parent[u] = v; 
    } 
    
    // 判断是否连通 
    public boolean isConnected(int x, int y) { 
        return find(x) == find(y); 
    } 
    
    // 返回当前集合数量(可选) 
    public int getCount() { 
        return count; 
    } 
}

(五)、按秩合并

什么是秩?在二叉树中,秩表示一个树的深度。如果合并两个二叉树,并且要求合并后二叉树的高度尽可能的低,该怎么去做呢?

最好的办法,就是让秩小的,即较矮的二叉树,合并到较高的二叉树上去,并且挂载的叶子结点一定要尽可能的靠近根节点。

同理,在join函数中如何合并两棵多叉树呢?一定是秩(rank)较小的数合并到秩较大的树上去。这样可以保证合并之后的数的rank最小,降低在树上查询的路径的长度。

在代码层面上,可以创建一个数组int[] rank表示当前每个节点的深度,即秩,并初始化为1。在join函数中,找到了两个节点的根之后,比较两个根节点的秩,谁的秩更加小,就将其作为根节点的挂载点。如果两个秩相等,则被挂载的根节点的秩要加1。

void join(int u, int v) {
    v = find(v);
    u = find(u);
    rank[u] <= rank[v] ? parent[u] = v 
                       : parent[v] = u;  
    if (root[u] == root[v] && v != u) rank[v]++;           
}

当然,我们注意到,使用了按秩合并就没法进行路径压缩了,因为路径压缩之后的秩是不准确的,无法保证按秩合并的正确性。

此外,路径压缩中,所有的节点的秩最大为2,也就是说,所有叶子节点通过一步遍历即可到达根节点。因此,在使用并查集的时候,只用路径压缩的思路即可,代码实现精简,并且效率足够高。

(六)、计算复杂度分析

  • 空间复杂度O(N)O(N),申请了一个parent[] 数组

  • 时间复杂度:单次进行findjoin操作,时间复杂度接近于O(1)O(1),实际为阿克曼函数的反函数α(n)\alpha (n),增加极慢。