图
public class Graph<T> {
private Map<T, List<Edge>> map;
public Graph() {
map = new HashMap<>();
}
public void insert(T a, T b, int weight, boolean isDirected) {
insert(a, b, weight);
if (!isDirected)
insert(b, a, weight);
}
private void insert(T a, T b, int weight) {
List<Edge> edges = map.getOrDefault(a, new LinkedList<>());
edges.add(new Edge(a, b, weight));
map.put(a, edges);
if (!map.containsKey(b))
map.put(b, new LinkedList<>());
}
class Edge {
T a;
T b;
int weight;
Edge(T a, T b, int weight) {
this.a = a;
this.b = b;
this.weight = weight;
}
}
}
概念
- 无向图:边是顶点的无序对
- 有向图:边是顶点的有序对
- 弧:有向图的边
- 网:边带权的图
- 子图:边和顶点都是某图的子集的图为该图的子集
- 完全图:所有顶点互连的无向图
- 稀疏图:边或弧的个数 $e < nlogn $
- 稠密图:边或弧的个数 (e >= nlogn)
- 度:顶点关联的边数
- 入度:指向该顶点的边数
- 出度:该顶点指出的边数
- 简单路径:顶点不重复出现
- 连通图:无向图任两个顶点间有路径相通
- 强连通图:有向图任两个顶点间有一条有向路径
- 连通分量:非连通图中各个极大连通子图
- 生成树:(n) 个顶点的连通图,其中 (n-1) 条边和 (n) 个顶点构成的级小连通子图
深度优先
public void dfs(T node) {
dfs(node, new HashSet<T>());
}
private void dfs(T node, Set<T> visited) {
visited.add(node);
System.out.print(node + " ");
List<Edge> edges = map.get(node);
for (Edge e : edges) {
if (visited.contains(e.b))
continue;
dfs(e.b, visited);
}
}
广度优先
// 类似于树的层次遍历
// 多一步判断顶点是否访问过
public void bfs(T node) {
Queue<T> queue = new LinkedList<>();
Set<T> visited = new HashSet<>();
queue.add(node);
visited.add(node);
System.out.print(node + " ");
while (!queue.isEmpty()) {
node = queue.poll();
List<Edge> edges = map.get(node);
for (Edge e : edges) {
if (visited.contains(e.b))
continue;
queue.add(e.b);
visited.add(e.b);
System.out.print(e.b + " ");
}
}
System.out.println();
}
最小生成树
最小生成树(MST):各边 权值总和最小 的生成树
性质:
令 (G = (V, E, W)) 为一个带权连通图,(T) 为 (G) 的最小生成树。
对任一不在 (T) 中的边 (uv),如果将 (uv) 加入 (T) 中会产生一回路,使得 (uv) 是回路中权值最大的边。
Prim 普里姆算法
- 初始:选一顶点作为生成 MST 的起始点,加到 MST 中。
- 迭代:顶点分为两类,MST 中的,和非 MST 中的。选取一条边,要求:
- 一端连接 MST 中的顶点,一端连接非 MST 中的顶点
- 权值最小
将该边和对应顶点添加到 MST 中。
- 终止:循环 n - 1 次,找到 MST 的 n 个顶点和 n - 1 条边。
Prim 利用的是 贪心 的思想。
注:Prim 算法 不适用 于有向图。
1 2
a ---> b <--- c
< -------------
3
如上所示,假如选取弧 a-->b
算法将无法继续进行。
public Graph<T> prim(T start) {
Graph<T> mst = new Graph<T>();
// 初始化 map 保存非 MST 顶点到 MST 顶点的最短路径
Map<T, Edge> lowestCostMap = new HashMap<>();
for (T node : map.keySet()) {
lowestCostMap.put(node, new Edge(node, null, Integer.MAX_VALUE));
}
lowestCostMap.remove(start);
while (lowestCostMap.size() > 0) {
// start 为 MST 中新加入的顶点 更新最短路径
updateLowestCost(lowestCostMap, start);
// 寻找最短路径
Edge edge = new Edge(null, null, Integer.MAX_VALUE);
for (T n : lowestCostMap.keySet()) {
Edge e = lowestCostMap.get(n);
if (e.weight < edge.weight) {
edge = e;
}
}
// 非连通图
if (edge.b == null)
break;
mst.insert(edge.a, edge.b, edge.weight, false);
lowestCostMap.remove(edge.b);
start = edge.b;
}
return lowestCostMap.size() > 0 ? null : mst;
}
// 更新每个顶点的最短路径
private void updateLowestCost(Map<T, Edge> lowestCostMap, T v) {
for (Edge e : map.get(v)) {
if (!lowestCostMap.containsKey(e.b))
continue;
int newLowestCost = e.weight;
if (newLowestCost < lowestCostMap.get(e.b).weight) {
lowestCostMap.put(e.b, e);
}
}
}
Prim 以 顶点 为主导,因此更适用于 稠密图,时间复杂度为 (O(V^2) quad V 为顶点数)。
Kruskal 克鲁斯卡尔算法
将所有顶点加入到 MST 中,将原图中的边按权值由小到大排序,考察各条边:
- 若边的两个顶点属于 MST 两个不同的连通分量,则将此边加入 MST,同时把两个连通分量连接为一个连通分量
- 若边的两个顶点属于 MST 同一个连通分量,则舍去,以免造成回路
如此下去,直到连通分量数为 1。
Kruskal 利用的是 贪心 的思想。
注:与 Prim 类似,Kruskal 同样不能用于有向图。
public Graph<T> kruskal() {
Graph<T> mst = new Graph<T>();
// 预处理:对边排序 将顶点转换成数字 初始化并查集的 id
List<Edge> edgeList = new LinkedList<>();
Set<T> visited = new HashSet<>();
Map<T, Integer> convertMap = new HashMap<>();
for (T n : map.keySet()) {
List<Edge> edges = map.get(n);
for (Edge e : edges) {
if (visited.contains(e.b))
continue;
edgeList.add(e);
}
visited.add(n);
convertMap.put(n, convertMap.size());
}
int num = convertMap.size();
int[] id = new int[num];
for (int i = 0; i < num; i++) {
id[i] = i;
}
Collections.sort(edgeList, new Comparator<Edge>() {
@Override
public int compare(Edge o1, Edge o2) {
return o1.weight - o2.weight;
}
});
for (Edge e : edgeList) {
if (num == 1) // 连通分量数为 1 即已经连通
break;
int p = findId(id, convertMap.get(e.a));
int q = findId(id, convertMap.get(e.b));
if (p == q)
continue;
mst.insert(e.a, e.b, e.weight);
id[p] = q;
num--;
}
return mst;
}
private int findId(int[] id, int i) {
while (id[i] != i)
i = id[i];
return i;
}
Kruskal 以 边 为主导,因此更适用于 稀疏图,时间复杂度为 (ElogE quad E 为边数),来源于对边排序的时间。
拓扑排序
可用于判断有向图是否为 无环图,安排活动的先后顺序(如先修课的安排)等。
- 选一个入度为 0 的顶点,放到拓扑排序序列中
- 删除该顶点以及由它出发的所有弧
- 重复1,2,直到没有入度为 0 的顶点
- 若图中还有剩余顶点,说明有回路
public List<T> topSort() {
Map<T, Integer> inDegreeMap = new HashMap<>();
Queue<T> queue = new LinkedList<>(); // 保存入度为 0 的顶点
for (T node : map.keySet()) {
inDegreeMap.put(node, 0);
}
for (T node : map.keySet()) {
List<Edge> edges = map.get(node);
for (Edge edge : edges) {
inDegreeMap.put(edge.b, inDegreeMap.get(edge.b) + 1);
}
}
for (T node : map.keySet()) {
if (inDegreeMap.get(node) == 0)
queue.add(node);
}
List<T> res = new ArrayList<>();
while (queue.size() > 0) {
T cur = queue.poll();
res.add(cur);
for (Edge edge : map.get(cur)) {
int num = inDegreeMap.get(edge.b) - 1;
inDegreeMap.put(edge.b, num);
if (num == 0)
queue.add(edge.b);
}
}
return res.size() == map.size() ? res : null;
}
时间复杂度:
- 计算所有顶点的入度 O(E)
- 初始建立入度为 0 的顶点队列 O(V)
- 每个顶点入、出队各一次 O(V)
- 每条边执行一次入度减 1 操作 O(E)
总的时间复杂度为 O(V + E)
最短路径
Dijkstra 迪杰斯特拉算法
Dijkstra 求的是从一个 源点 到其它各点的最短路径。
- 初始:集合 (S) 为已求得最短路径的顶点,初始只包含源点 (v_0),集合 (T) 为未求得最短路径的顶点。
- 迭代:(S) 中每加入一个新的顶点 (u),更新 (v_0) 到 (T) 中顶点的最短路径长度,并选择其中最小值,加入到 (S) 中。
(最短路径长度_{new} = Math.min(最短路径长度_{old}, 顶点 u 的最短路径长度 + u 到该顶点的路径长度)) - 终止:循环 (n - 1) 次。
可见,Dijkstra 与 Prim 非常相像,区别在于最短路径的选取:
- Prim:到 MST 中所有顶点中长度最短的路径
- Dijkstra:到源点长度最短的路径
// 整体思路与 Prim 基本一致
public Map<T, Integer> dijkstra(T start) {
Map<T, Integer> res = new HashMap<>();
res.put(start, 0);
// 初始化 map 保存源点到未计算最短路径的顶点的最短距离
Map<T, Integer> lowestCostMap = new HashMap<>();
for (T n : map.keySet()) {
lowestCostMap.put(n, Integer.MAX_VALUE);
}
lowestCostMap.remove(start);
while (lowestCostMap.size() > 0) {
// start 为新计算最短路径的顶点 更新最短路径长度
updateLowestCost(lowestCostMap, start, res.get(start));
// 选取其中最小值
int minCost = Integer.MAX_VALUE;
T node = null;
for (T n : lowestCostMap.keySet()) {
int cost = lowestCostMap.get(n);
if (cost <= minCost) { // 这里是 <= 因为有可能不存在路径 即 cost = Integer.MAX_VALUE
minCost = cost;
node = n;
}
}
lowestCostMap.remove(node);
res.put(node, minCost);
start = node;
}
return res;
}
// 与 Prim 的区别在于 newLowestCost 的计算
private void updateLowestCost(Map<T, Integer> lowestCostMap, T v, int cost) {
for (Edge e : map.get(v)) {
if (!lowestCostMap.containsKey(e.b))
continue;
int newLowestCost = e.weight + cost;
if (newLowestCost < lowestCostMap.get(e.b)) {
lowestCostMap.put(e.b, newLowestCost);
}
}
}
时间复杂度也与 Prim 相同,为 (O(V^2))。
注:Dijkstra 算法 不适用 于权值有负数的图。
Dijkstra 每次迭代将一个新的顶点加入到集合 (S) 中,即已经求出了源点到该顶点的最短路径。
加入后,只更新与该顶点相连的顶点的最短路径信息。
如果存在权为负数的边,那么就可能存在一条比当前得出的最短路径还要短的路径。这就产生了矛盾。
Floyd 弗洛伊德算法
Floyd 求的是 每一对 顶点的最短路径。
- 初始:从顶点 i 到顶点 j 的最短路径中间可能有 n 个顶点作为桥梁。先初始化一个二维矩阵 d 保存路径长度,路径中间没有其他顶点,即 d[i][j] = 连接 i 和 j 的边的长度。
- 迭代:对于每一个顶点 (k),将 (k) 作为桥梁,检查 d[i][k] + d[k][j] 和 d[i][j] 的大小关系。每循环一次,代表以前 (k) 个顶点作为桥梁的路径已经检查过。
- 终止:(k = 1, 2, 3, ... n),进行 (n) 次试探,代表所有顶点作为桥梁的路径已经全部被检索过。
Floyd 利用的是 动态规划 的思想。
public Map<T, Map<T, Integer>> floyd() {
Map<T, Map<T, Integer>> res = new HashMap<>();
// 预处理:将顶点转换成数字 初始化矩阵
Map<T, Integer> convertMap = new HashMap<>();
int n = map.size();
int[][] d = new int[n][n];
for (int i = 0; i < n; i++) {
Arrays.fill(d[i], Integer.MAX_VALUE);
d[i][i] = 0;
}
for (T node : map.keySet()) {
res.put(node, new HashMap<>());
convertMap.put(node, convertMap.size());
}
for (T node : map.keySet()) {
for (Edge edge : map.get(node)) {
d[convertMap.get(edge.a)][convertMap.get(edge.b)] = edge.weight;
}
}
for (int k = 0; k < n; k++) {
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
if (d[i][k] == Integer.MAX_VALUE || d[k][j] == Integer.MAX_VALUE)
continue;
d[i][j] = Math.min(d[i][k] + d[k][j], d[i][j]);
}
}
}
for (T i : map.keySet()) {
for (T j : map.keySet()) {
Map<T, Integer> tmp = res.get(i);
tmp.put(j, d[convertMap.get(i)][convertMap.get(j)]);
}
}
return res;
}
时间复杂度:(O(V^3))