如何理解“图”?

图(Graph)和树比起来,这是一种更加复杂的非线性表结构。

图中的元素就叫做顶点(vertex),图中的一个顶点可以与任意其他顶点建立连接关系。这种建立的关系叫做边(edge)。跟顶点相连接的边的条数叫做顶点的度(degree)

边有方向的图叫做“有向图”。边没有方向的图就叫做“无向图”

在有向图中,我们把度分为入度(In-degree)和出度(Out-degree)

顶点的入度,表示有多少条边指向这个顶点;顶点的出度,表示有多少条边是以这个顶点为起点指向其他顶点。

带权图(weighted graph)。在带权图中,每条边都有一个权重(weight)

邻接矩阵存储方法

图最直观的一种存储方法就是,邻接矩阵(Adjacency Matrix)

邻接矩阵的底层依赖一个二维数组。对于无向图来说,如果顶点 i 与顶点 j 之间有边,我们就将 A[i][j]和 A[j][i]标记为 1;对于有向图来说,如果顶点 i 到顶点 j 之间,有一条箭头从顶点 i 指向顶点 j 的边,那我们就将 A[i][j]标记为 1。同理,如果有一条箭头从顶点 j 指向顶点 i 的边,我们就将 A[j][i]标记为 1。对于带权图,数组中就存储相应的权重。

优点:

  1. 邻接矩阵的存储方式简单、直接,因为基于数组,所以在获取两个顶点的关系时,就非常高效。
  2. 其次,用邻接矩阵存储图的另外一个好处是方便计算。这是因为,用邻接矩阵的方式存储图,可以将很多图的运算转换成矩阵之间的运算。

缺点:

  1. 对于无向图来说,如果 A[i][j]等于 1,那 A[j][i]也肯定等于 1

    无向图的二维数组中,如果我们将其用对角线划分为上下两部分,那只需要利用上面或者下面这样一半的空间就足够了,另外一半白白浪费掉了。

  2. 如果存储的是稀疏图(Sparse Matrix),顶点很多,但每个顶点的边并不多,那邻接矩阵的存储方法就更加浪费空间了。

邻接表存储方法

邻接表

每个顶点对应一条链表,链表中存储的是与这个顶点相连接的其他顶点。

有向图的邻接表存储方式,每个顶点对应的链表里面,存储的是指向的顶点

无向图的邻接表存储方式,每个顶点的链表中存储的,是跟这个顶点有边相连的顶点

图的算法实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Graph { // 无向图
private int v; // 顶点的个数
private LinkedList<Integer> adj[]; // 邻接表

public Graph(int v) {
this.v = v;
adj = new LinkedList[v];
for (int i=0; i<v; ++i) {
adj[i] = new LinkedList<>();
}
}

public void addEdge(int s, int t) { // 无向图一条边存两次
adj[s].add(t);
adj[t].add(s);
}
}

逆邻接表

逆邻接表中,每个顶点的链表中,存储的是指向这个顶点的顶点

“搜索”算法?

算法是作用于具体数据结构之上的,深度优先搜索算法和广度优先搜索算法都是基于“图”这种数据结构的。

  1. 广度优先搜索(BFS)

    广度优先搜索(Breadth-First-Search,简称 BFS)。直观地讲,它其实就是一种“地毯式”层层推进的搜索策略,即先查找离起始顶点最近的,然后是次近的,依次往外搜索。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    public void bfs(int s, int t) {
    if (s == t) return;
    //visited 是用来记录已经被访问的顶点
    boolean[] visited = new boolean[v];
    visited[s]=true;
    //queue 是一个队列,用来存储已经被访问、但相连的顶点还没有被访问的顶点
    Queue<Integer> queue = new LinkedList<>();
    queue.add(s);
    //prev 用来记录搜索路径
    int[] prev = new int[v];
    for (int i = 0; i < v; ++i) {
    prev[i] = -1;
    }
    while (queue.size() != 0) {
    int w = queue.poll();
    for (int i = 0; i < adj[w].size(); ++i) {
    int q = adj[w].get(i);
    if (!visited[q]) {
    prev[q] = w;
    if (q == t) {
    print(prev, s, t);
    return;
    }
    visited[q] = true;
    queue.add(q);
    }
    }
    }
    }

    private void print(int[] prev, int s, int t) { // 递归打印s->t的路径
    if (prev[t] != -1 && t != s) {
    print(prev, s, prev[t]);
    }
    System.out.print(t + " ");
    }

    其中 s 表示起始顶点,t 表示终止顶点。搜索一条从 s 到 t 的路径。实际上,这样求得的路径就是从 s 到 t 的最短路径。

    visited 是用来记录已经被访问的顶点,用来避免顶点被重复访问。如果顶点 q 被访问,那相应的 visited[q]会被设置为 true。

    queue 是一个队列,用来存储已经被访问、但相连的顶点还没有被访问的顶点。因为广度优先搜索是逐层访问的,也就是说,我们只有把第 k 层的顶点都访问完成之后,才能访问第 k+1 层的顶点。当我们访问到第 k 层的顶点的时候,我们需要把第 k 层的顶点记录下来,稍后才能通过第 k 层的顶点来找第 k+1 层的顶点。所以,我们用这个队列来实现记录的功能。

    prev 用来记录搜索路径。当我们从顶点 s 开始,广度优先搜索到顶点 t 后,prev 数组中存储的就是搜索的路径。不过,这个路径是反向存储的。prev[w]存储的是,顶点 w 是从哪个前驱顶点遍历过来的。比如,我们通过顶点 2 的邻接表访问到顶点 3,那 prev[3]就等于 2。为了正向打印出路径,我们需要递归地来打印,你可以看下 print() 函数的实现方式。

    最坏情况时间复杂度:最坏情况下,终止顶点 t 离起始顶点 s 很远,需要遍历完整个图才能找到。这个时候,每个顶点都要进出一遍队列,每个边也都会被访问一次,所以,广度优先搜索的时间复杂度是 O(V+E),其中,V 表示顶点的个数,E 表示边的个数。当然,对于一个连通图来说,也就是说一个图中的所有顶点都是连通的,E 肯定要大于等于 V-1,所以,广度优先搜索的时间复杂度也可以简写为 O(E)。

    空间复杂度:广度优先搜索的空间消耗主要在几个辅助变量 visited 数组、queue 队列、prev 数组上。这三个存储空间的大小都不会超过顶点的个数,所以空间复杂度是 O(V)。

  2. 深度优先搜索(DFS)

    深度优先搜索(Depth-First-Search,简称 DFS)

    实线箭头表示遍历,虚线箭头表示回退

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    //当已经找到终止顶点 t 之后,就不再递归地继续查找了。
    boolean found = false; // 全局变量或者类成员变量

    public void dfs(int s, int t) {
    found = false;
    boolean[] visited = new boolean[v];
    int[] prev = new int[v];
    for (int i = 0; i < v; ++i) {
    prev[i] = -1;
    }
    recurDfs(s, t, visited, prev);
    print(prev, s, t);
    }

    private void recurDfs(int w, int t, boolean[] visited, int[] prev) {
    if (found == true) return;
    visited[w] = true;
    if (w == t) {
    found = true;
    return;
    }
    for (int i = 0; i < adj[w].size(); ++i) {
    int q = adj[w].get(i);
    if (!visited[q]) {
    prev[q] = w;
    recurDfs(q, t, visited, prev);
    }
    }
    }

    时间复杂度:每条边最多会被访问两次,一次是遍历,一次是回退。所以,图上的深度优先搜索算法的时间复杂度是 O(E),E 表示边的个数。

    空间复杂度:深度优先搜索算法的消耗内存主要是 visited、prev 数组和递归调用栈。visited、prev 数组的大小跟顶点的个数 V 成正比,递归调用栈的最大深度不会超过顶点的个数,所以总的空间复杂度就是 O(V)

拓扑排序

拓扑排序(Topological Sorting)是一个有向无环图(DAG, Directed Acyclic Graph)的所有顶点的线性序列。且该序列必须满足下面两个条件:

  1. 每个顶点出现且只出现一次。
  2. 若存在一条从顶点 A 到顶点 B 的路径,那么在序列中顶点 A 出现在顶点 B 的前面。

有向无环图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Graph {
private int v; // 顶点的个数
private LinkedList<Integer> adj[]; // 邻接表

public Graph(int v) {
this.v = v;
adj = new LinkedList[v];
for (int i=0; i<v; ++i) {
adj[i] = new LinkedList<>();
}
}

public void addEdge(int s, int t) { // s先于t,边s->t
adj[s].add(t);
}
}

Kahn 算法

贪心算法思想

定义数据结构的时候,如果 s 需要先于 t 执行,那就添加一条 s 指向 t 的边。

如果某个顶点入度为 0, 也就表示,没有任何顶点必须先于这个顶点执行,那么这个顶点就可以执行了。

先从图中,找出一个入度为 0 的顶点,将其输出到拓扑排序的结果序列中,并且把这个顶点从图中删除(也就是把这个顶点可达的顶点的入度都减 1)。

循环执行上面的过程,直到所有的顶点都被输出。最后输出的序列,就是满足局部依赖关系的拓扑排序。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public void topoSortByKahn() {
int[] inDegree = new int[v]; // 统计每个顶点的入度
for (int i = 0; i < v; ++i) {
for (int j = 0; j < adj[i].size(); ++j) {
int w = adj[i].get(j); // i->w
inDegree[w]++;
}
}
LinkedList<Integer> queue = new LinkedList<>();
for (int i = 0; i < v; ++i) {
if (inDegree[i] == 0) queue.add(i);
}
while (!queue.isEmpty()) {
int i = queue.remove();
System.out.print("->" + i);
for (int j = 0; j < adj[i].size(); ++j) {
int k = adj[i].get(j);
inDegree[k]--;
if (inDegree[k] == 0) queue.add(k);
}
}
}

时间复杂度:

每个顶点被访问了一次,每个边也都被访问了一次,所以,Kahn 算法的时间复杂度就是 O(V+E)(V 表示顶点个数,E 表示边的个数)。

DFS 算法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public void topoSortByDFS() {
// 先构建逆邻接表,边s->t表示,s依赖于t,t先于s
LinkedList<Integer> inverseAdj[] = new LinkedList[v];
for (int i = 0; i < v; ++i) { // 申请空间
inverseAdj[i] = new LinkedList<>();
}
for (int i = 0; i < v; ++i) { // 通过邻接表生成逆邻接表
for (int j = 0; j < adj[i].size(); ++j) {
int w = adj[i].get(j); // i->w
inverseAdj[w].add(i); // w->i
}
}
boolean[] visited = new boolean[v];
for (int i = 0; i < v; ++i) { // 深度优先遍历图
if (visited[i] == false) {
visited[i] = true;
dfs(i, inverseAdj, visited);
}
}
}

private void dfs(
int vertex, LinkedList<Integer> inverseAdj[], boolean[] visited) {
for (int i = 0; i < inverseAdj[vertex].size(); ++i) {
int w = inverseAdj[vertex].get(i);
if (visited[w] == true) continue;
visited[w] = true;
dfs(w, inverseAdj, visited);
} // 先把vertex这个顶点可达的所有顶点都打印出来之后,再打印它自己
System.out.print("->" + vertex);
}

第一部分是通过邻接表构造逆邻接表。

邻接表中,边 s->t 表示 s 先于 t 执行,也就是 t 要依赖 s。在逆邻接表中,边 s->t 表示 s 依赖于 t,s 后于 t 执行。

第二部分是递归处理每个顶点。

对于顶点 vertex 来说,先输出它可达的所有顶点,也就是说,先把它依赖的所有的顶点输出了,然后再输出自己。

时间复杂度:

每个顶点被访问两次,每条边都被访问一次,所以时间复杂度也是 O(V+E)。

最短路径算法

将整个地图抽象成一个有向有权图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public class Graph { // 有向有权图的邻接表表示
private LinkedList<Edge> adj[]; // 邻接表
private int v; // 顶点个数

public Graph(int v) {
this.v = v;
this.adj = new LinkedList[v];
for (int i = 0; i < v; ++i) {
this.adj[i] = new LinkedList<>();
}
}

public void addEdge(int s, int t, int w) { // 添加一条边
this.adj[s].add(new Edge(s, t, w));
}

private class Edge {
public int sid; // 边的起始顶点编号
public int tid; // 边的终止顶点编号
public int w; // 权重
public Edge(int sid, int tid, int w) {
this.sid = sid;
this.tid = tid;
this.w = w;
}
}
// 下面这个类是为了dijkstra实现用的
private class Vertex {
public int id; // 顶点编号ID
public int dist; // 从起始顶点到这个顶点的距离
public Vertex(int id, int dist) {
this.id = id;
this.dist = dist;
}
}
}

Dijkstra 算法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
// 因为Java提供的优先级队列,没有暴露更新数据的接口,所以我们需要重新实现一个
private class PriorityQueue { // 根据vertex.dist构建小顶堆
private Vertex[] nodes;
private int count;
public PriorityQueue(int v) {
this.nodes = new Vertex[v+1];
this.count = v;
}
public Vertex poll() { // TODO: 留给读者实现... }
public void add(Vertex vertex) { // TODO: 留给读者实现...}
// 更新结点的值,并且从下往上堆化,重新符合堆的定义。时间复杂度O(logn)。
public void update(Vertex vertex) { // TODO: 留给读者实现...}
public boolean isEmpty() { // TODO: 留给读者实现...}
}

public void dijkstra(int s, int t) { // 从顶点s到顶点t的最短路径
int[] predecessor = new int[this.v]; // 用来还原最短路径
Vertex[] vertexes = new Vertex[this.v];
for (int i = 0; i < this.v; ++i) {
vertexes[i] = new Vertex(i, Integer.MAX_VALUE);
}
PriorityQueue queue = new PriorityQueue(this.v);// 小顶堆
boolean[] inqueue = new boolean[this.v]; // 标记是否进入过队列
vertexes[s].dist = 0;
queue.add(vertexes[s]);
inqueue[s] = true;
while (!queue.isEmpty()) {
Vertex minVertex= queue.poll(); // 取堆顶元素并删除
if (minVertex.id == t) break; // 最短路径产生了
for (int i = 0; i < adj[minVertex.id].size(); ++i) {
Edge e = adj[minVertex.id].get(i); // 取出一条minVetex相连的边
Vertex nextVertex = vertexes[e.tid]; // minVertex-->nextVertex
if (minVertex.dist + e.w < nextVertex.dist) { // 更新next的dist
nextVertex.dist = minVertex.dist + e.w;
predecessor[nextVertex.id] = minVertex.id;
if (inqueue[nextVertex.id] == true) {
queue.update(nextVertex); // 更新队列中的dist值
} else {
queue.add(nextVertex);
inqueue[nextVertex.id] = true;
}
}
}
}
// 输出最短路径
System.out.print(s);
print(s, t, predecessor);
}

private void print(int s, int t, int[] predecessor) {
if (s == t) return;
print(s, predecessor[t], predecessor);
System.out.print("->" + t);
}

用 vertexes 数组,记录从起始顶点到每个顶点的距离(dist)

  1. 首先将所有顶点的 dist 都初始化为无穷大(Integer.MAX_VALUE),起始顶点的 dist 值初始化为 0,然后将其放到优先级队列中。

  2. 从优先级队列中取出 dist 最小的顶点 minVertex,然后考察这个顶点可达的所有顶点(nextVertex)

  3. 如果 minVertex 的 dist 值加上 minVertex 与 nextVertex 之间边的权重 w 小于 nextVertex 当前的 dist 值,也就是说,存在另一条更短的路径,它经过 minVertex 到达 nextVertex。把 nextVertex 的 dist 更新为 minVertex 的 dist 值加上 w。把 nextVertex 加入到优先级队列中。
  4. 重复这个过程,直到找到终止顶点 t 或者队列为空。

predecessor 数组的作用是为了还原最短路径,它记录每个顶点的前驱顶点。最后,通过递归的方式,将这个路径打印出来。

inqueue 数组是为了避免将一个顶点多次添加到优先级队列中。更新了某个顶点的 dist 值之后,如果这个顶点已经在优先级队列中了,就不要再将它重复添加进去了。

Dijkstra 算法

时间复杂度

while 循环最多会执行 V 次(V 表示顶点的个数),而内部的 for 循环的执行次数不确定,跟每个顶点的相邻边的个数有关,分别记作 E0,E1,E2,……,E(V-1)。如果把这 V 个顶点的边都加起来,最大也不会超过图中所有边的个数 E(E 表示边的个数)。

for 循环内部的代码涉及从优先级队列取数据、往优先级队列中添加数据、更新优先级队列中的数据,这样三个主要的操作。优先级队列是用堆来实现的,堆中的这几个操作,时间复杂度都是 O(logV)(堆中的元素个数不会超过顶点的个数 V)。

时间复杂度就是 O(E*logV)

查看评论