图论
一、图论理论基础
参见[图论理论基础]
(一)、基本概念
图论中的图是由一组顶点和一组边组成的,边连接顶点。顶点也称为[节点],代表实体,边代表顶点之间的关系。边可以是无向的,也可以是有向的。
- 无向边表示顶点之间的关系是对称的,由无向边和定点组成的图为无向图。
- 有向边则表示顶点之间的关系是有方向的。由有向边和顶点组成的图为有向图。
1、度
在无向图中,一个顶点的度是指,与该顶点相关联的边的数量。
在有向图中,顶点的度分为出度和入度。出度是指从该顶点出发的边的数量,入度是指进入该顶点边的数量。
2、路径
从一个顶点到另一个顶点的边的序列,称为路径。路径的长度是指,路径中边的数量。
如果路径中没有重复的顶点,那就是简单路径。如果路径中的起点和终点相同,那么该路径称为回路或者环。
3、连通性
在图中,如果两个顶点之间存在路径,则说明这两个顶点是连通的。如果一个图的任意两个顶点都是连通的,那么这个图就被称为连通图。
在有向图中,对于任意的两个顶点和,若同时存在和的路径,则说明该有向图是强连通的。
若将该有向图是作为无向图之后,无向图是连通的,则该图是弱连通的。
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、邻接矩阵
邻接矩阵是一个二维数组,用于表示顶点之间的连接关系里如果顶点i和j之间有边,则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();
}
}
邻接表和邻接矩阵的比较:
| 邻接矩阵 | 邻接表 | |
|---|---|---|
| 空间效率 | ||
| 增删顶点 | ,需考虑数组扩容 | |
| 判断边 | ||
| 适用场景 | 密集图,结构稳定 | 稀疏图,图结构动态变化比较大 |
(三)、图的遍历
图的遍历主要有两种,广度优先遍历(Board first search,BFS)和深度优先遍历(Deep first search, DFS)。
1、深度优先遍历
深度优先遍历就是,选择一个尚未被访问过的顶点,递归进行深度优先搜索,直到没有未访问过的邻接顶点为止。然后回溯到上一个顶点,继续访问其他未访问过的顶点。
DFS框架:
- 定义一个布尔数组
boolean[] visited,记录某一个顶点是否被访问过 - 从某个顶点开始,标记该顶点已经被访问
- 遍历的所有邻接顶点,对于每个未访问的,递归调用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框架
- 定义一个布尔数组
boolean[] visited,记录每一个及诶的那是否被访问过 - 使用一个队列来辅助遍历,将起始顶点
v入队,并标记为已经访问 - 当队列不为空的时候,取出队首元素,访问该节点,并将其所有未访问过的顶点入队,并标记为已经访问
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] = C,A -> 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,也就是说,所有叶子节点通过一步遍历即可到达根节点。因此,在使用并查集的时候,只用路径压缩的思路即可,代码实现精简,并且效率足够高。
(六)、计算复杂度分析
-
空间复杂度:,申请了一个
parent[]数组 -
时间复杂度:单次进行
find和join操作,时间复杂度接近于,实际为阿克曼函数的反函数,增加极慢。