1. 什么是图
图是网络结构的抽象模型,是一组由边链接的节点。
任何社交网络,例如 Facebook、Twitter 和 Google+,都可以用图来表示,图还可以表示任何二元关系,比如道路,航班
如下图:
1.1 图的一些术语
图在数学及技术上的概念
一个图 G = (V, E)由以下元素组成。
V:一组顶点
E:一组边,连接 V 中的顶点
如下图:
由一条边连接在一起的顶点称为相邻顶点。比如,A 和 B 是相邻的,A 和 D 是相邻的,A 和 C 是相邻的,A 和 E 不是相邻的。
一个顶点的度是其相邻顶点的数量。比如,A 和其他三个顶点相连接,因此 A 的度为 3;E和其他两个顶点相连,因此 E 的度为 2。
路径是顶点 , , …, 的一个连续序列,其中 和 是相邻的。以上图中的图为例,其中包含路径 ABEI 和 ACDG。
简单路径要求不包含重复的顶点。举个例子,ADG 是一条简单路径。除去最后一个顶点(因为它和第一个顶点是同一个顶点),环也是一个简单路径,比如 ADCA(最后一个顶点重新回到 A)。
如果图中不存在环,则称该图是无环的。如果图中每两个顶点间都存在路径,则该图是连通的
1.2 有向图和无向图
图可以是无向的(边没有方向)或是有向的(有向图)
如果图中每两个顶点间在双向上都存在路径,则该图是强连通的。例如,C 和 D 是强连通的,而 A 和 B 不是强连通的。
图还可以是未加权的或是加权的。如下图所示,加权图的边被赋予了权值
2. 图的表示
2.1 邻接矩阵
图最常见的实现是邻接矩阵。每个节点都和一个整数相关联,该整数将作为数组的索引。我们用一个二维数组来表示顶点之间的连接。如果索引为 i 的节点和索引为 j 的节点相邻,则 array[i][j] === 1
,否则 array[i][j] === 0
,如下图所示
不是强连通的图(稀疏图)如果用邻接矩阵来表示,则矩阵中将会有很多 0,这意味着我们浪费了计算机存储空间来表示根本不存在的边。例如,找给定顶点的相邻顶点,即使该顶点只有一个相邻顶点,我们也不得不迭代一整行。
邻接矩阵表示法不够好的另一个理由是,图中顶点的数量可能会改变,而二维数组不太灵活
2.2 邻接表
我们也可以使用一种叫作邻接表的动态数据结构来表示图。邻接表由图中每个顶点的相邻顶点列表所组成。存在好几种方式来表示这种数据结构。我们可以用列表(数组)、链表,甚至是散列表或是字典来表示相邻顶点列表。下面的示意图展示了邻接表数据结构。
尽管邻接表可能对大多数问题来说都是更好的选择,但以上两种表示法都很有用,且它们有着不同的性质(例如,要找出顶点 v 和 w 是否相邻,使用邻接矩阵会比较快)。
2.3 关联矩阵
还可以用关联矩阵来表示图。在关联矩阵中,矩阵的行表示顶点,列表示边。如下图所示,使用二维数组来表示两者之间的连通性,如果顶点 v 是边 e 的入射点,则 array[v][e] === 1
;否则,array[v][e] === 0
。
关联矩阵通常用于边的数量比顶点多的情况,以节省空间和内存
3. 实现一个Graph 类
邻接表实现
export default class Graph {
constructor(isDirected = false) {
this.isDirected = isDirected; // 是否有向
this.vertices = []; // 存储图中所有顶点的名字
this.adjList = new Map(); // 存储邻接表,key为顶点,邻接顶点列表作为值
}
// 向图中添加一个新的顶点
addVertex(v) {
if (!this.vertices.includes(v)) {
this.vertices.push(v); // 存入顶点数组
this.adjList.set(v, []);
}
}
// 添加顶点之间的边
addEdge(a, b) {
// 如果点不存在,将它加入顶点列表中
if (!this.adjList.get(a)) {
this.addVertex(a);
}
if (!this.adjList.get(b)) {
this.addVertex(b);
}
// 添加(有向图)
this.adjList.get(a).push(b);
// 无向图,互相添加
if (this.isDirected !== true) {
this.adjList.get(b).push(a);
}
}
// 返回顶点列表
getVertices() {
return this.vertices;
}
// 返回邻接表
getAdjList() {
return this.adjList;
}
toString() {
let s = '';
for (let i = 0; i < this.vertices.length; i++) {
s += `${this.vertices[i]} -> `;
const neighbors = this.adjList.get(this.vertices[i]);
for (let j = 0; j < neighbors.length; j++) {
s += `${neighbors[j]} `;
}
s += '\n';
}
return s;
}
}
复制代码
测试代码:
const graph = new Graph();
const myVertices = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I'];
for (let i = 0; i < myVertices.length; i++) {
graph.addVertex(myVertices[i]);
}
graph.addEdge('A', 'B');
复制代码
4. 图的遍历
广度优先搜索(breadth-first search,BFS)和深度优先搜索
图遍历可以用来寻找特定的顶点或寻找两个顶点之间的路径,检查图是否连通,检查图是否含有环,等等
图遍历算法的思想是必须追踪每个第一次访问的节点,并且追踪有哪些节点还没有被完全探索。对于两种图遍历算法,都需要明确指出第一个被访问的顶点。
完全探索一个顶点要求我们查看该顶点的每一条边。对于每一条边所连接的没有被访问过的顶点,将其标注为被发现的,并将其加进待访问顶点列表中。
为了保证算法的效率,务必访问每个顶点至多两次。连通图中每条边和顶点都会被访问到
- 深度优先搜索使用栈栈,将顶点存入栈,顶点是沿着路径被探索的,存在新的相邻顶点就去访问
- 广度优先搜索使用队列,将顶点存入队列,最先入队列的顶点先被探索
我们用三种颜色来表示顶点状态:
白色:表示该顶点还没有被访问。
灰色:表示该顶点被访问过,但并未被探索过。
黑色:表示该顶点被访问过且被完全探索过。
代码如下:
const Colors = {
WHITE: 0,
GREY: 1,
BLACK: 2
};
复制代码
首先我么你需要将所有顶点标记为未访问
const initializeColor = vertices => {
const color = {};
for (let i = 0; i < vertices.length; i++) {
color[vertices[i]] = Colors.WHITE;
}
return color;
};
复制代码
4.1 广度优先搜索
广度优先搜索算法会从指定的第一个顶点开始遍历图,先访问其所有的邻点(相邻顶点),就像一次访问图的一层。
步骤如下:
- 创建一个队列 Q。
- 标注 v 为被发现的(灰色),并将 v 入队列 Q。
- 如果 Q 非空,则运行以下步骤:
- 将 u 从 Q 中出队列;
- 标注 u 为被发现的(灰色);
- 将 u 所有未被访问过的邻点(白色)入队列;
- 标注 u 为已被探索的(黑色)。
export const breadthFirstSearch = (graph, startVertex, callback) => {
const vertices = graph.getVertices();
const adjList = graph.getAdjList();
// 将所有顶点标记为白色
const color = initializeColor(vertices);
const queue = new Queue();
// 将顶点入队
queue.enqueue(startVertex);
while (!queue.isEmpty()) {
const u = queue.dequeue();
// 取得其所有邻点的邻接表
const neighbors = adjList.get(u);
// 将该顶点标为灰色
color[u] = Colors.GREY;
// 遍历邻接表中的值
for (let i = 0; i < neighbors.length; i++) {
const w = neighbors[i];
// 如果该点未被访问过,将其标灰
if (color[w] === Colors.WHITE) {
color[w] = Colors.GREY;
// 将该点加入队列
queue.enqueue(w);
}
}
// 标黑
color[u] = Colors.BLACK;
if (callback) {
callback(u);
}
}
};
复制代码
使用BFS寻找最短路径
给定一个图 G 和源顶点 v,找出每个顶点 u 和 v 之间最短路径的距离(以边的数量计)。
const BFS = (graph, startVertex) => {
const vertices = graph.getVertices();
const adjList = graph.getAdjList();
const color = initializeColor(vertices);
const queue = new Queue();
// 储存距离
const distances = {};
// 前溯点
const predecessors = {};
queue.enqueue(startVertex);
// 初始化
for (let i = 0; i < vertices.length; i++) {
distances[vertices[i]] = 0;
predecessors[vertices[i]] = null;
}
while (!queue.isEmpty()) {
const u = queue.dequeue();
const neighbors = adjList.get(u);
color[u] = Colors.GREY;
for (let i = 0; i < neighbors.length; i++) {
const w = neighbors[i];
if (color[w] === Colors.WHITE) {
color[w] = Colors.GREY;
// 距离+1
distances[w] = distances[u] + 1;
// 发现顶点 u 的邻点 w 时,则设置 w 的前溯点值为 u
predecessors[w] = u;
queue.enqueue(w);
}
}
color[u] = Colors.BLACK;
}
return {
distances,
predecessors
};
};
复制代码
注意:如果要计算加权图中的最短路径(例如,城市 A 和城市 B 之间的最短路径——GPS 和 Google Maps 中用到的算法),广度优先搜索不合适。
Dijkstra 算法解决了单源最短路径问题。Bellman-Ford 算法解决了边权值为负的单源最短路径问题。A*搜索算法解决了求仅一对顶点间的最短路径问题,用经验法则来加速搜索过程。Floyd-Warshall 算法解决了求所有顶点对之间的最短路径这一问题
4.2 深度优先搜索
深度优先搜索算法将会从第一个指定的顶点开始遍历图,沿着路径直到这条路径最后一个顶点被访问了,接着原路回退并探索下一条路径
深度优先搜索算法不需要一个源顶点。在深度优先搜索算法中,若图中顶点 v 未访问,则访问该顶点 v。
- 标注 v 为被发现的(灰色);
- 对于 v 的所有未访问(白色)的邻点 w,访问顶点 w;
- 标注 v 为已被探索的(黑色)。
const depthFirstSearchVisit = (u, color, adjList, callback) => {
// 标注为灰色
color[u] = Colors.GREY;
if (callback) {
callback(u);
}
// 取得包含顶点 u 所有邻点的列表
const neighbors = adjList.get(u);
for (let i = 0; i < neighbors.length; i++) {
const w = neighbors[i];
// 如果未访问过,递归访问
if (color[w] === Colors.WHITE) {
depthFirstSearchVisit(w, color, adjList, callback);
}
}
color[u] = Colors.BLACK;
};
const depthFirstSearch = (graph, callback) => {
const vertices = graph.getVertices();
const adjList = graph.getAdjList();
const color = initializeColor(vertices);
for (let i = 0; i < vertices.length; i++) {
if (color[vertices[i]] === Colors.WHITE) {
depthFirstSearchVisit(vertices[i], color, adjList, callback);
}
}
};
复制代码
探索过程如下图:
探索深度优先算法
对于给定的图 G,我们希望深度优先搜索算法遍历图 G 的所有节点,构建“森林”(有根树的一个集合)以及一组源顶点(根),并输出两个数组:发现时间和完成探索时间。我们可以修改 depthFirstSearch 函数来返回一些信息
- 顶点 u 的发现时间 d[u];
- 当顶点 u 被标注为黑色时,u 的完成探索时间 f[u];
- 顶点 u 的前溯点 p[u]。
改进了的 DFS 方法的实现
const DFSVisit = (u, color, d, f, p, time, adjList) => {
// console.log('discovered ' + u);
color[u] = Colors.GREY;
// 当一个顶点第一次被发现时,我们追踪其发现时间
d[u] = ++time.count;
const neighbors = adjList.get(u);
for (let i = 0; i < neighbors.length; i++) {
const w = neighbors[i];
if (color[w] === Colors.WHITE) {
// 当它是由引自顶点 u 的边而被发现的,我们追踪它的前溯点
p[w] = u;
DFSVisit(w, color, d, f, p, time, adjList);
}
}
color[u] = Colors.BLACK;
// 当这个顶点被完全探索后,我们追踪其完成时间
f[u] = ++time.count;
// console.log('explored ' + u);
};
export const DFS = graph => {
const vertices = graph.getVertices();
const adjList = graph.getAdjList();
const color = initializeColor(vertices);
const d = {};
const f = {};
const p = {};
// 声明一个变量来追踪发现时间和完成探索时间
const time = { count: 0 };
// 我们声明数组 d、f 和 p,为图的每一个顶点来初始化这些数组
for (let i = 0; i < vertices.length; i++) {
f[vertices[i]] = 0;
d[vertices[i]] = 0;
p[vertices[i]] = null;
}
for (let i = 0; i < vertices.length; i++) {
if (color[vertices[i]] === Colors.WHITE) {
DFSVisit(vertices[i], color, d, f, p, time, adjList);
}
}
return {
discovery: d,
finished: f,
predecessors: p
};
};
复制代码
- 时间(time)变量值的范围只可能在图顶点数量的一倍到两倍(2|V|)之间;
- 对于所有的顶点 u,d[u]<f[u](意味着,发现时间的值比完成时间的值小,完成时间意思是所有顶点都已经被探索过了)
在这两个假设下,我们有如下的规则。
1 <= d [u] < f [u] <= 2|V|
如果对同一个图再跑一遍新的深度优先搜索方法,对图中每个顶点,我们会得到如下的发现/完成时间
拓扑排序——使用深度优先搜索
给定下图,假定每个顶点都是一个我们需要去执行的任务
这是一个有向图,意味着任务的执行是有顺序的。例如,任务 F 不能在任务 A之前执行。注意这个图没有环,意味着这是一个无环图。所以,我们可以说该图是一个有向无环图
当我们需要编排一些任务或步骤的执行顺序时,这称为拓扑排序(topological sorting,英文亦写作 topsort 或是 toposort)。在日常生活中,这个问题在不同情形下都会出现。例如,当我们开始学习一门计算机科学课程,在学习某些知识之前得按顺序完成一些知识储备(你不可以在上算法 I 课程前先上算法 II 课程)。
拓扑排序只能应用于 DAG。那么,如何使用深度优先搜索来实现拓扑排序呢?让我们在本节开头的示意图上执行一下深度优先搜索
graph = new Graph(true); // 有向图
myVertices = ['A', 'B', 'C', 'D', 'E', 'F'];
for (i = 0; i < myVertices.length; i++) {
graph.addVertex(myVertices[i]);
}
graph.addEdge('A', 'C');
graph.addEdge('A', 'D');
graph.addEdge('B', 'D');
graph.addEdge('B', 'E');
graph.addEdge('C', 'F');
graph.addEdge('F', 'E');
const result = DFS(graph);
复制代码
这段代码将创建图,添加边,执行改进版本的深度优先搜索算法,并将结果保存到 result变量。下图展示了深度优先搜索算法执行后,该图的发现和完成时间
现在要做的仅仅是以倒序来排序完成时间数组,这便得出了该图的拓扑排序,如下所示。
const fTimes = result.finished;
s = '';
for (let count = 0; count < myVertices.length; count++) {
let max = 0;
let maxName = null;
for (i = 0; i < myVertices.length; i++) {
if (fTimes[myVertices[i]] > max) {
max = fTimes[myVertices[i]];
maxName = myVertices[i];
}
}
s += ' - ' + maxName;
delete fTimes[maxName];
}
console.log(s);
复制代码
执行了上述代码后,我们会得到下面的输出。
B – A – D – C – F – E
注意之前的拓扑排序结果仅是多种可能性之一。如果我们稍微修改一下算法,就会有不同的结果。比如下面这个结果也是众多其他可能性中的一个。
A – B – C – D – F – E
这也是一个可以接受的结果
5. 最短路径算法
5.1 Dijkstra 算法
Dijkstra 算法是一种计算从单个源到所有其他源的最短路径的贪心算法
上图的邻接矩阵如下:
var graph = [
[0, 2, 4, 0, 0, 0],
[0, 0, 1, 4, 2, 0],
[0, 0, 0, 0, 3, 0],
[0, 0, 0, 0, 0, 2],
[0, 0, 0, 3, 0, 2],
[0, 0, 0, 0, 0, 0]
]
复制代码
const INF = Number.MAX_SAFE_INTEGER;
const minDistance = (dist, visited) => {
let min = INF;
let minIndex = -1;
for (let v = 0; v < dist.length; v++) {
if (visited[v] === false && dist[v] <= min) {
min = dist[v];
minIndex = v;
}
}
return minIndex;
};
const dijkstra = (graph, src) => {
const dist = [];
const visited = [];
const { length } = graph;
for (let i = 0; i < length; i++) { // 将所有距离初始为无限大
dist[i] = INF;
visited[i] = false;
}
dist[src] = 0; // 把源顶点到自己的距离设为 0
for (let i = 0; i < length - 1; i++) { // 找出到其余顶点的最短路径
const u = minDistance(dist, visited); // 从尚未处理的顶点中选出距离最近的顶点
visited[u] = true; // 把选出的顶点标为 visited,以免重复计算
for (let v = 0; v < length; v++) {
if (!visited[v] && graph[u][v] !== 0 && dist[u] !== INF && dist[u] + graph[u][v] < dist[v]) { // 如果找到更短的路径,则更新最短路径的值
dist[v] = dist[u] + graph[u][v];
}
}
}
return dist; // 处理完所有顶点后,返回从源顶点(src)到图中其他顶点最短路径的结果
};
复制代码
5.2 Floyd-Warshall 算法
Floyd-Warshall 算法是一种计算图中所有最短路径的动态规划算法。
const floydWarshall = graph => {
const dist = [];
const { length } = graph;
for (let i = 0; i < length; i++) { // 把 distance 数组初始化为每个顶点之间的权值
dist[i] = [];
for (let j = 0; j < length; j++) {
if (i === j) {
dist[i][j] = 0; // 顶点到自身的距离为 0
} else if (!isFinite(graph[i][j])) {
dist[i][j] = Infinity; // 如果两个顶点之间没有边,就将其表示为 Infinity
} else {
dist[i][j] = graph[i][j];
}
}
}
for (let k = 0; k < length; k++) { // 将顶点 0 到 k 作为中间点
for (let i = 0; i < length; i++) {
for (let j = 0; j < length; j++) {
if (dist[i][k] + dist[k][j] < dist[i][j]) { // i 到 j 的最短路径经过 k
dist[i][j] = dist[i][k] + dist[k][j]; // 如果一个最短路径的新的值被找到,我们要使用并存储它
}
}
}
}
return dist;
};
复制代码
6. 最小生成树
最小生成树(MST)问题是网络设计中常见的问题。想象一下,你的公司有几间办公室,要以最低的成本实现办公室电话线路相互连通,以节省资金,最好的办法是什么?
这也可以应用于岛桥问题。设想你要在 n 个岛屿之间建造桥梁,想用最低的成本实现所有岛屿相互连通
var graph = [
[0, 2, 4, 0, 0, 0],
[2, 0, 2, 4, 2, 0],
[4, 2, 0, 0, 3, 0],
[0, 4, 0, 0, 3, 2],
[0, 2, 3, 3, 0, 2],
[0, 0, 0, 2, 2, 0]
];
复制代码
6.1 Prim 算法
Prim 算法是一种求解加权无向连通图的 MST 问题的贪心算法。它能找出一个边的子集,使得其构成的树包含图中所有顶点,且边的权值之和最小
const INF = Number.MAX_SAFE_INTEGER;
const minKey = (graph, key, visited) => {
let min = INF;
let minIndex = 0;
for (let v = 0; v < graph.length; v++) {
if (visited[v] === false && key[v] < min) {
min = key[v];
minIndex = v;
}
}
return minIndex;
};
export const prim = graph => {
const parent = [];
const key = [];
const visited = [];
const { length } = graph;
for (let i = 0; i < length; i++) { // 把所有顶点(key)初始化为无限大
key[i] = INF;
visited[i] = false;
}
// 选择第一个 key 作为第一个顶点,同时,因为第一个顶点总是 MST 的根节点,所以 parent[0] = -1
key[0] = 0;
parent[0] = -1;
for (let i = 0; i < length - 1; i++) { // 对所有顶点求 MST
// 从未处理的顶点集合中选出 key 值最小的顶点
const u = minKey(graph, key, visited);
// 把选出的顶点标为 visited,以免重复计算
visited[u] = true;
for (let v = 0; v < length; v++) {
if (graph[u][v] && !visited[v] && graph[u][v] < key[v]) {
parent[v] = u; // 如果得到更小的权值,则保存 MST 路径
// 更新其权值
key[v] = graph[u][v];
}
}
}
return parent;
};
复制代码
6.2 Kruskal 算法
const INF = Number.MAX_SAFE_INTEGER;
const find = (i, parent) => {
while (parent[i]) {
i = parent[i]; // eslint-disable-line prefer-destructuring
}
return i;
};
const union = (i, j, parent) => {
if (i !== j) {
parent[j] = i;
return true;
}
return false;
};
const initializeCost = graph => {
const cost = [];
const { length } = graph;
for (let i = 0; i < length; i++) {
cost[i] = [];
for (let j = 0; j < length; j++) {
if (graph[i][j] === 0) {
cost[i][j] = INF;
} else {
cost[i][j] = graph[i][j];
}
}
}
return cost;
};
export const kruskal = graph => {
const { length } = graph;
const parent = [];
let ne = 0;
let a;
let b;
let u;
let v;
// 把邻接矩阵的值复制到 cost 数组,以方便修改且可以保留原始值
const cost = initializeCost(graph);
while (ne < length - 1) { // 当 MST 的边数小于顶点总数减 1 时
for (let i = 0, min = INF; i < length; i++) { // 找出权值最小的边
for (let j = 0; j < length; j++) {
if (cost[i][j] < min) {
min = cost[i][j];
a = u = i;
b = v = j;
}
}
}
// 检查 MST 中是否已存在这条边,以避免环路
u = find(u, parent);
v = find(v, parent);
if (union(u, v, parent)) { // 如果 u 和 v 是不同的边,则将其加入 MST
ne++;
}
cost[a][b] = cost[b][a] = INF; // 从列表中移除这些边,以免重复计算
}
return parent;
};
复制代码
6.2 常见深度、广度优先搜索考题
leetcode-cn.com/problems/bi…
leetcode-cn.com/problems/mi…
leetcode-cn.com/problems/ge…
leetcode-cn.com/problems/fi…
leetcode-cn.com/problems/wo…
leetcode-cn.com/problems/wo…
leetcode-cn.com/problems/nu…
leetcode-cn.com/problems/mi…
417. 太平洋大西洋水流问题
133. 克隆图