本文共--字 阅读约--分钟 | 浏览: -- Last Updated: 2022-02-19
图由边的集合及顶点的集合组成,边由顶点对(v1, v2)定义,v1 和 v2 分别是图中的两个顶点,顶点也有权重,也称为成本。如果一个图的顶点对是有序的,则可以称之为有向图。如下:
如果图是无序的,则称之为无序图或无向图。
图中的一系列顶点构成路径,路径中所有的顶点都由边连接。路径的长度用路径中第一个顶点到到最后一个顶点之间边的数量表示。
由指向自身的顶点组成的路径称为环,环的长度为0。
图是至少有一条边的路径,且路径的第一个点和最后一个顶点相同,无论是有向图还是无向图,只要是没有重复边或重复顶点的圈,就是一个简单圈。除了第一个和最后一个顶点之外,路径的其他顶点有重复的圈被称为平凡圈。
如果两个顶点之间有路径,那么这两个顶点就是强连通的,反之亦然。如果有向图的所有的顶点都是强连通的,那么这个图是也是强连通的。
// 顶点类,有两个数据成员,一个用于标识顶点,另一个是表明这个顶点是否被访问过。
function Vertex(label, wasVisited) {
this.lable = label;
this.wasVisited = wasVisited;
}
将所有的顶点保存到数组中,在图类里,可以通过它们在数组中的位置引用它们。
我们将表示图的边的方法称为邻接表或者邻接表数组,这种方法将边存储为由顶点的相邻顶点列表构成的数组。
如下无向图,转换成邻接表如下:
另一种表示图边的方法被称为邻接矩阵,它是一个二维数组,其中的元素表示两个顶点之间是否有一条边,上例转换为邻接矩阵如下:
function Graph(v) {
this.vertices = v; // 顶点的数量
this.edges = 0; // 边的数量
this.adj = []; // adjvex 邻接域,表示边,即上面说的邻接表 邻接矩阵
for (var i = 0; i < this.vertices; i++) {
this.adj[i] = []; // 初始化一个空数组来存储所有的相邻顶点
}
this.addEdge = addEdge;
this.showGraph = showGraph;
}
// 增加v-w两个顶点之间的连接关系,即在v的邻接表中加入w 同时也在w的邻接表中加入v
// 然后将边的数量加1
function addEdge(v, w) {
this.adj[v].push(w);
this.adj[w].push(v);
this.edges++;
}
// 打印所有顶点及其相邻顶点列表
function showGraph() {
for (var i = 0; i < this.vertices; ++i) {
var str = i + " -> ";
for (var j = 0; j < this.vertices; ++j) {
if (this.adj[i][j] != undefined) {
str += this.adj[i][j] + " "
}
}
console.log(str);
}
}
var g = new Graph(4);
g.addEdge(0, 1)
g.addEdge(0, 2)
g.addEdge(1, 2)
g.addEdge(2, 3)
g.showGraph()
// 0 -> 1 2
// 1 -> 0 2
// 2 -> 0 1 3
// 3 -> 2
深度优先搜索包括从一条路径的其实顶点开始追溯,直到到达最后一个顶点,然后回溯,继续追溯下一条路径,直到到达最后的顶点,如此往复,直到没有路径为止。
实现:深度优先搜索算法比较简单,访问一个没有访问过的顶点,将它标记为已访问,再递归地去访问在初始顶点的邻接表中其他没有访问过的点。
function Graph(v) {
// ...
// 维护一个marked数组,用来标记该顶点是否被访问过
this.marked = [];
for (var i = 0; i < this.vertices; i++) {
this.marked[i] = false; // 默认都是false
}
// 新增一个深度优先搜索的函数
this.dfs = dfs;
}
function dfs(v) {
this.marked[v] = true;
if (this.adj[v] != undefined) {
console.log("当前访问的顶点是: " + v);
}
// this.adj[v] 即初始访问顶点的邻接表
// w 邻接表中的各个顶点
this.adj[v].forEach(w => {
if (!this.marked[w]) { // 没有访问过则递归
this.dfs(w);
}
})
}
var g2 = new Graph(5)
g2.addEdge(0, 1)
g2.addEdge(0, 2)
g2.addEdge(1, 3)
g2.addEdge(2, 4)
g2.showGraph();
g2.dfs(0)
// 0 -> 1 2
// 1 -> 0 3
// 2 -> 0 4
// 3 -> 1
// 4 -> 2
// 当前访问的顶点是: 0
// 当前访问的顶点是: 1
// 当前访问的顶点是: 3
// 当前访问的顶点是: 2
// 当前访问的顶点是: 4
图示如下:
如上描述,如果顶点3除了与顶点1邻接外,还与其他顶点有邻接,会依然往更深处去寻找,形成一条从初始顶点开始能访问到的最长路径。
广度优先搜索从第一个顶点开始,尝试访问尽可能靠近它的顶点,本质上,这种搜索在图上是逐层移动的,首先检查最靠近第一个顶点的层,再逐渐向下移动到离起始顶点最远的层。
当通过邻接表来表示图的时候,可以这样理解,深度优先搜索就是先探索列的纵深,而广度优先搜索是先探索行的行宽。
算法原理如下:
代码如下:
function bfs(s) {
var queue = [];
this.marked[s] = true;
queue.push(s); // 维护一个队列
do {
var v = queue.shift(); // 循环过程会不断push,通过shift依次从队首开始访问
if (this.adj[v] != undefined) {
console.log("当前访问的顶点是: " + v);
}
this.adj[v].forEach(w => {
if (!this.marked[w]) {
this.marked[w] = true;
queue.push(w);
}
})
} while (queue.length > 0);
}
var g3 = new Graph(5)
g3.addEdge(0, 1)
g3.addEdge(0, 2)
g3.addEdge(1, 3)
g3.addEdge(2, 4)
g3.showGraph();
g3.bfs(0)
// 0 -> 1 2
// 1 -> 0 3
// 2 -> 0 4
// 3 -> 1
// 4 -> 2
// 当前访问的顶点是: 0
// 当前访问的顶点是: 1
// 当前访问的顶点是: 2
// 当前访问的顶点是: 3
// 当前访问的顶点是: 4
示例中的图与深度优先搜索示例中的一致,只不过优先横向访问了。
针对此示例,其中的 queue 是这样一个处理流程:
在执行广度优先搜索时,会自动查找从一个顶点到另一个顶点的最短路径,例如,要查找从顶点A到顶点D的最短路径,我们首先会查找从A到D是否有任何一条单边路径(即先搜索邻接表的行),接着查找两边的路径,以此类推。这正是广度优先搜索的过程,因此我们可以轻松的修改广度优先搜索算法,找出最短路径。
function Graph(v) {
// ....
this.edgeTo = [];
this.pathTo = pathTo;
}
// 修改bfs方法,在广度搜索的时候,记录一下,到达某一顶点应该先经过哪个顶点。
function bfs(v) {
var queue = [];
this.marked[s] = true;
queue.push(s);
do {
var v = queue.shift();
if (this.adj[v] != undefined) {
console.log("当前访问的顶点是: " + v);
}
this.adj[v].forEach(w => {
if (!this.marked[w]) {
// 记录了顶点w可以由顶点v到达 以下表述都以0为起点,因为bfs传入的s为0
// 在此例中 edgeTo 为 [empty, 0, 0, 1, 2]
// 即表示 顶点1、2由顶点0可以达到, 顶点3可以由顶点1到达,顶点4可以由顶点2到达。
this.edgeTo[w] = v;
this.marked[w] = true;
queue.push(w);
}
})
} while (queue.length > 0);
}
// 查找路径
function pathTo(start, end) {
var v = end;
if (!this.marked[v]) {
return undefined;
}
var path = [];
// 从终点开始往回起点找,所以结束的条件为找到了起点
// i是终点到起点回经过的各个点 start是起点
// this.edgeTo[i] 得到的是 => 到达i顶点需要经过上一个顶点
for (var i = v; i != start; i = this.edgeTo[i]) {
path.push(i)
}
path.push(start)
return path;
}
var g4 = new Graph(5)
g4.addEdge(0, 1)
g4.addEdge(0, 2)
g4.addEdge(1, 3)
g4.addEdge(2, 4)
g4.bfs(0) // 完成广度搜索
var paths = g4.pathTo(0, 4); // 查找0到4的路径
console.log(paths.reverse().join('-')) // 0-2-4
在工程实践中,一个工程项目常常由若干个子项组成,这些子项目间往往有两种关系:
现实场景中,比如大学里某个专业的课程学习,有些课程是基础课,它们可以独立于其他课程,没有前导课程;有些课程必须在某些基础课完成后才能开始学习,这类问题都可以用有向无环图来进行描述。
而拓扑排序就是将这一个有向无环图(没有回路)转换成一个线性的排序,即解决这样一个问题:假设我一段时间内只能学习一门课程,应该如何学习?
拓扑排序之后就可能是 CS1 -> CS2 -> 汇编语言 -> 数据结构 -> 操作系统 -> 算法,当然了,答案是多种的。
常见的解决步骤如下:
拓扑排序算法被拆分为两个函数,第一个函数 topSort
,会设置排序进程并调用一个辅助函数 topSortHelper
,然后显示排序好的顶点列表。
主要工作是在递归函数 topSortHelper
中完成的,这个函数会将当前顶点标为已访问,然后递归访问当前顶点邻接表中的每个相邻顶点,标记这些顶点为已访问,最后将当前顶点压入栈。
function Graph() {
// ...
this.vertexList = []; // 用来存储各个顶点的数据,使输出指向具体的数据
this.topSortHelper = topSortHelper;
this.topSort = topSort;
}
function topSort() {
// 某个“最深”顶点的邻接表访问完成后,就将该顶点先入栈,输出的时候后出栈。
// 即递归时,下面哪个顶点的forEach是最先执行完成的, this.adj[v] 中的 v。
var stack = [];
var visited = new Array(5).fill(false); // 初始都标记为未访问
for (var i = 0; i < this.vertices; i++) {
if (visited[i] == false) {
this.topSortHelper(i, visited, stack)
}
}
// 栈结构,所以倒序输出
for (var i = stack.length-1; i >= 0; i--) {
if (stack[i] != undefined && stack[i] !== false) {
console.log(this.vertexList[stack[i]]);
}
}
}
// 类似于深度优先搜索
function topSortHelper(v, visited, stack) {
visited[v] = true;
this.adj[v].forEach(w => {
if (!visited[w]) {
this.topSortHelper(w, visited, stack)
}
})
stack.push(v)
}
var g = new Graph(6)
g.addEdge(0, 1)
g.addEdge(1, 2)
g.addEdge(1, 3)
g.addEdge(2, 4)
g.addEdge(2, 5)
g.vertexList = ['CS1', 'CS2', '数据结构', '汇编语言', '操作系统', '算法']
g.topSort();
// CS1
// CS2
// 汇编语言
// 数据结构
// 算法
// 操作系统