图
图是一种较线性表和树更加复杂的数据结构。主要是表示节点之间多对多的关系。
在计算机科学中,图是一种网络结构的抽象模型,它是一组由边连接的顶点组成。一个图*G = (V, E)*由以下元素组成:
- V:一组顶点
- E:一组边,连接V中的顶点
- 注意:顶点的集合是非空的
无向边、有向边、无向图、有向图、稀疏图、稠密图、出度、入度这些相关概念之后会补充。
首先,我们知道了一个数据结构之后,我们要知道如何去表示它。
表示图一般有四种方式:邻接矩阵、邻接表、十字链表、邻接多重表
首先介绍一下邻接矩阵的表示方式:
邻接矩阵
在邻接矩阵中,我们用一个二维数组来表示图中顶点之间的连接,如果两个顶点之间存在连接,则这两个顶点对应的二维数组下标的元素的值为1,否则为0。下图是用邻接矩阵方式表示的图:
还会用一个一维数组表示顶点个数
var v=[A,B,C,D,E,F,G,H,I]
如果是加权的图,我们可以将邻接矩阵中二维数组里的值1改成对应的加权数。邻接矩阵方式存在一个缺点,如果图是非强连通的,则二维数组中会有很多的0,这表示我们使用了很多的存储空间来表示根本不存在的边。另一个缺点就是当图的顶点发生改变时,对于二维数组的修改会变得不太灵活。**
优点**:可以很清楚的知道每个节点的出度、入度和顶点之间的关系。
缺点:当图是一个稀疏图时,既顶点很多,但是边很少的情况下,此时依然使用邻接矩阵的话,会浪费很多的存储空间。
针对上面的缺点,所以我们有了邻接表的方式。使用数组加链表的形式,来表示稀疏图。节省存储空间。
邻接矩阵解决最小生成树和最短路径问题
最小生成树问题
解决这个问题,你首先要知道,什么是生成树?
生成树:在图论的数学领域中,如果连通图 G(V,E) 的一个子图是一棵包含 G 的所有顶点及 |V|-1 条边的树,则该子图称为 G(V,E) 的生成树(SpanningTree)。生成树是连通图的包含图中的所有顶点的极小连通子图。一个图的生成树可以有多颗。 简单来说就是包含n个顶点和n-1条边的连通子图。称之为生成树
最小生成树问题是指:所有的生成树中权值和最小的即为最小生成树。
应用
- 乡镇间电缆布线设计
- 办公楼房网络设计
- 建筑间电路设计
适用对象
- 加权无向图
- 连通图
- 对于不连通的图,可以求所有是 连通分量 的最小生成树形成最小森林
得到最小生成树的两个经典算法:Prim算法和Kruskal算法
-
prime算法
基本思想:从图中任意一个顶点开始,每次选择与当前顶点集距离最近的顶点,将对应的边加入到树中,直至所有顶点被处理完。
所以就是根据邻接矩阵中, 找顶点集中权值最小的边即可。只是要去除每次访问的边。
/** * 邻接矩阵 * 值为顶点与顶点之间边的权值,0表示无自环,一个大数表示无边(比如10000) * */ const MAX_INTEGER = Number.MAX_SAFE_INTEGER;//没有的边 const MIN_INTEGER = Number.MIN_SAFE_INTEGER;//没有自环 const matrix= [ [MIN_INTEGER, 9, 2, MAX_INTEGER, 6], [9, MIN_INTEGER, 3, MAX_INTEGER, MAX_INTEGER], [2, 3, MIN_INTEGER, 5, MAX_INTEGER], [MAX_INTEGER, MAX_INTEGER, 5, MIN_INTEGER, 1], [6, MAX_INTEGER, MAX_INTEGER, 1, MIN_INTEGER] ]; /** * 边对象 * */ function Edge(begin, end, weight) { this.begin = begin; this.end = end; this.weight = weight; } Edge.prototype.getBegin = function () { return this.begin; }; Edge.prototype.getEnd = function () { return this.end; }; Edge.prototype.getWeight = function () { return this.weight; }; /*class Edge { constructor(begin, end, weight) { this.begin = begin; this.end = end; this.weight = weight; } getBegin() { return this.begin; } getEnd() { return this.end; } getWeight() { return this.weight; } }*/ // 上面的是准备工作。 /** * Prim算法 * 以某顶点为起点,逐步找各顶点上最小权值的边构建最小生成树,同时其邻接点纳入生成树的顶点中,只要保证顶点不重复添加即可 * 使用邻接矩阵即可 * 优点:适合点少边多的情况 * @param matrix 邻接矩阵 * @return Array 最小生成树的边集数组 * */ function prim(matrix) { const rows = matrix.length, cols = rows, result = [], savedNode = [0];//已选择的节点 let minVex = -1, minWeight = MAX_INTEGER; for (let i = 0; i < rows; i++) { let row = savedNode[i], edgeArr = matrix[row]; for (let j = 0; j < cols; j++) { if (edgeArr[j] < minWeight && edgeArr[j] !== MIN_INTEGER) { minWeight = edgeArr[j]; minVex = j; } } //保证所有已保存节点的相邻边都遍历到 if (savedNode.indexOf(minVex) === -1 && i === savedNode.length - 1) { savedNode.push(minVex); result.push(new Edge(row, minVex, minWeight)); //重新在已加入的节点集中找权值最小的边的外部边 i = -1; minWeight = MAX_INTEGER; //已加入的边,去掉,下次就不会选这条边了 matrix[row][minVex] = MAX_INTEGER; matrix[minVex][row] = MAX_INTEGER; } } return result; }
-
Kruskal算法
其主要的思路就是:遍历所有的边,按权值从小到大排序,每次选取当前权值最小的边,只要不构成回环,则加入生成树。
-
邻接矩阵转化为边集数组。
与Prim算法不同,Kruskal算法是从最小权值的边开始的,所以使用边集数组更方便。所以需要将邻接矩阵转成边集数组,并且按照边的权重从小到大排序。
/** * 邻接矩阵转边集数组的函数 * @param matrix 邻接矩阵 * @return Array 边集数组 * */ function changeMatrixToEdgeArray(matrix) { const rows = matrix.length, cols = rows, result = []; for (let i = 0; i < rows; i++) { const row = matrix[i]; for(let j = 0 ; j < cols; j++) { if(row[j] !== MIN_INTEGER && row[j] !== MAX_INTEGER) { result.push(new Edge(i, j, row[j])); matrix[i][j] = MAX_INTEGER; matrix[j][i] = MAX_INTEGER; } } } result.sort((a, b) => a.getWeight() - b.getWeight()); return result; }
-
Kruskal算法的具体实现
Kruskal算法的一个要点就是避免环路,这里采用一个数组来保存已纳入生成树的顶点和边(连线),其下标是边(连线)的起点,下标对应的元素值是边(连线)的终点。下标对应的元素值为0,表示还没有以它为起点的边(连线)。
/** * kruskal算法 * 遍历所有的边,按权值从小到大排序,每次选取当前权值最小的边,只要不构成回环,则加入生成树 * 邻接矩阵转换成边集数组 * 优点:适合点多边少的情况 * @param matrix 邻接矩阵 * @return Array 最小生成树的边集数组 * */ function kruskal(matrix) { const edgeArray = changeMatrixToEdgeArray(matrix), result = [], //使用一个数组保存当前顶点的边的终点,0表示还没有已它为起点的边加入 savedEdge = new Array(matrix.length).fill(0); for (let i = 0, len = edgeArray.length; i < len; i++) { const edge = edgeArray[i]; const n = findEnd(savedEdge, edge.getBegin()); const m = findEnd(savedEdge, edge.getEnd()); console.log(savedEdge, n, m); //不相等表示这条边没有与现有生成树形成环路 if (n !== m) { result.push(edge); //将这条边的结尾顶点加入数组中,表示顶点已在生成树中 savedEdge[n] = m; } } return result; } /** * 查找连线顶点的尾部下标 * @param arr 判断边与边是否形成环路的数组 * @param start 连线开始的顶点 * @return Number 连线顶点的尾部下标 * */ function findEnd(arr, start) { //就是一直循环,直到找到终点,如果没有连线,就返回0 while (arr[start] > 0) { start = arr[start]; } return start; }
-
最短路径问题
一、Dijkstra算法的思路
Dijkstra算法是针对单源点求最短路径的算法。
其主要思路如下:
-
将顶点分为两部分:已经知道当前最短路径的顶点集合Q和无法到达顶点集合R。
-
定义一个距离数组(distance)记录源点到各顶点的距离,下标表示顶点,元素值为距离。源点(start)到自身的距离为0,源点无法到达的顶点的距离就是一个大数(比如Infinity)。
-
以距离数组中值为非Infinity且值最小(即当前距离最短)的顶点V为中转跳点,假设V跳转至顶点W的距离加上顶点V至源点的距离还小于顶点W至源点的距离,那么就可以更新顶点W至源点的距离。即下面distance[V] + matrix[V][W] < distance[W],那么distance[W] = distance[V] + matrix[V][W]。
-
重复上一步骤,即遍历距离数组,同时无法到达顶点集合R为空。
它的邻接矩阵如下:
二、求解步骤
第一步:假设源点为V0,那么目前最短路径的顶点集合Q中就只有{V0}和无法到达顶点集合R中有{V1, V2, V3, V4}
第二步:初始化distance数组,就是下面这样
第三步:以distance数组中值为非Infinity且值最小的顶点为中转跳点,这一步就是V0,依照如果distance[V] + matrix[V][W] < distance[W],那么distance[W] = distance[V] + matrix[V][W]的规则,distance数组就会变成下面这样,同时集合Q变成了{V0, V1, V2, V4},集合R变成了{V3},即已访问数组为[true, true, true, false, true]。同时计算出当前距离源点最短的节点,即V2。
第四步:重复第三步的方法,然后变成以V2为中转跳点,更新距离数组,同时更新已访问数组,变成[true, true, true, true, true]。
之后同理,遍历所有节点之后,输出
三、代码实现
这个代码没有考虑权值为负数的情况,还没验证负数的情况,目前是按照权值为正数实现的,之后考虑完善。
同时这是针对单源点求最短路径,如果求全图各顶点的最短路径,只需要遍历顶点然后使用Dijkstra算法,这样算上Dijkstra算法本身的时间复杂度,总的复杂度会是O(n^3)。
/**
* Dijkstra算法:单源最短路径
* 思路:
* 1. 将顶点分为两部分:已经知道当前最短路径的顶点集合Q和无法到达顶点集合R。
* 2. 定义一个距离数组(distance)记录源点到各顶点的距离,下标表示顶点,元素值为距离。源点(start)到自身的距离为0,源点无法到达的顶点的距离就是一个大数(比如Infinity)。
* 3. 以距离数组中值为非Infinity的顶点V为中转跳点,假设V跳转至顶点W的距离加上顶点V至源点的距离还小于顶点W至源点的距离,那么就可以更新顶点W至源点的距离。即下面distance[V] + matrix[V][W] < distance[W],那么distance[W] = distance[V] + matrix[V][W]。
* 4. 重复上一步骤,即遍历距离数组,同时无法到达顶点集合R为空。
*
* @param matrix 邻接矩阵,表示图
* @param start 起点
*
*
*
* 如果求全图各顶点作为源点的全部最短路径,则遍历使用Dijkstra算法即可,不过时间复杂度就变成O(n^3)了
* */
function Dijkstra(matrix, start = 0) {
const rows = matrix.length,//rows和cols一样,其实就是顶点个数
cols = matrix[0].length;
if(rows !== cols || start >= rows) return new Error("邻接矩阵错误或者源点错误");
//初始化distance
let distance = new Array(rows).fill(Infinity);
// 初始化访问节点
let visited = new Array(rows).fill(false);
distance[start] = 0;
for(let i = 0; i < rows; i++) {
// 更新节点访问
visited[start] = true
// 达到不了的顶点不能作为中转跳点
if(distance[start] < Infinity) {
for(let j = 0; j < cols; j++) {
//通过比较distance[start] + matrix[start][j]和distance[j]的大小来决定是否更新distance[j]。
if(matrix[start][j] + distance[start] < distance[j]) {
distance[j] = matrix[start][j] + distance[start];
}
}
}
// 找到当前最短路径顶点作为中转跳点
let minIndex = -1;
let min = Infinity;
for(let k = 0; k < rows; k++) {
if (!visited[k] && distance[k] < min) {
min = distance[k];
minIndex = k;
}
}
start = minIndex
}
return distance;
}
/**
* 邻接矩阵
* 值为顶点与顶点之间边的权值,0表示无自环,一个大数表示无边(比如10000)
* */
const MAX_INTEGER = Infinity;//没有边或者有向图中无法到达
const MIN_INTEGER = 0;//没有自环
const matrix= [
[MIN_INTEGER, 9, 2, MAX_INTEGER, 6],
[9, MIN_INTEGER, 3, MAX_INTEGER, MAX_INTEGER],
[2, 3, MIN_INTEGER, 5, MAX_INTEGER],
[MAX_INTEGER, MAX_INTEGER, 5, MIN_INTEGER, 1],
[6, MAX_INTEGER, MAX_INTEGER, 1, MIN_INTEGER]
];
console.log(Dijkstra(matrix, 0));//[ 0, 5, 2, 7, 6 ]
四、优化
从上面的解析步骤中可以发现,当前如果所有节点都已经访问过,其实就已经拿到最优解,此时可以结束代码,直接输出。所以最终可以优化代码为如下:
function Dijkstra(matrix, start = 0) {
const rows = matrix.length,//rows和cols一样,其实就是顶点个数
cols = matrix[0].length;
function Dijkstra(matrix, start = 0) {
const rows = matrix.length,//rows和cols一样,其实就是顶点个数
cols = matrix[0].length;
if(rows !== cols || start >= rows) return new Error("邻接矩阵错误或者源点错误");
//初始化distance
let distance = new Array(rows).fill(Infinity);
// 初始化访问节点
let visited = new Array(rows).fill(false);
distance[start] = 0;
// 存在节点未访问则循环
while(visited.some(item => !item)) {
// 更新节点访问
visited[start] = true
// 达到不了的顶点不能作为中转跳点
if(distance[start] < Infinity) {
for(let j = 0; j < cols; j++) {
//通过比较distance[start] + matrix[start][j]和distance[j]的大小来决定是否更新distance[j]。
if(matrix[start][j] + distance[start] < distance[j]) {
distance[j] = matrix[start][j] + distance[start];
}
}
}
// 找到当前最短路径顶点作为中转跳点
let minIndex = -1;
let min = Infinity;
for(let k = 0; k < rows; k++) {
if (!visited[k] && distance[k] < min) {
min = distance[k];
minIndex = k;
}
}
start = minIndex
}
return distance;
}
五、带路径节点
改变distance数组的元素,不在只记录距离,同时记录“中转节点”。
function Node(val, pre) {
this.val = val // 当前距离
this.pre = pre || null // 中转(前置)节点
}
function Dijkstra(matrix, start = 0) {
const rows = matrix.length,//rows和cols一样,其实就是顶点个数
cols = matrix[0].length;
if(rows !== cols || start >= rows) return new Error("邻接矩阵错误或者源点错误");
//初始化distance
let distance = new Array(rows)
for (let i =0;i<rows;++i) {
distance[i] = new Node(Infinity)
}
// 初始化访问节点
let visited = new Array(rows).fill(false);
distance[start] = new Node(0);
// 存在节点未访问则循环
while(visited.some(item => !item)) {
// 更新节点访问
visited[start] = true
// 达到不了的顶点不能作为中转跳点
if(distance[start].val < Infinity) {
for(let j = 0; j < cols; j++) {
//通过比较distance[start] + matrix[start][j]和distance[j]的大小来决定是否更新distance[j]。
if(matrix[start][j] + distance[start].val < distance[j].val) {
distance[j].val = matrix[start][j] + distance[start].val;
distance[j].pre = start
}
}
}
// 找到当前最短路径顶点作为中转跳点
let minIndex = -1;
let min = Infinity;
for(let k = 0; k < rows; k++) {
if (!visited[k] && distance[k].val < min) {
min = distance[k].val;
minIndex = k;
}
}
start = minIndex
}
return distance;
}
邻接表
图的另外一种实现方式是邻接表,它是对邻接矩阵的一种改进。邻接表由图中每个顶点的相邻顶点列表所组成。如下图所示,我们可以用数组、链表、字典或散列表来表示邻接表。
优点:节省了存储空间
适用场景:稀疏图和拓扑排序。
在javacript中,在我做过的题目中,一般采取以下的操作方式。
- 首先用一维数组记录每个顶点的入度数,其中数组的坐标表示顶点。
- 然后用一个Map来记录每个顶点的出度。
邻接表解决拓扑排序问题
这里我们想问以下,邻接表这种数据结构一般用来处理什么样的问题呢?
一般邻接表表示的方式,是有来解决拓扑排序问题的。
什么是拓扑排序?
比如吃自助火锅,有一套约定俗成的流程,首先先打开包装,然后放入粉、佐料、菜,然后在加水,最后盖上盖子;这是有一套先后顺序的,你不可能没打开包装就放佐料,也可以说这是有一套依赖关系的,盖盖子依赖加水,加水依赖放入粉、佐料、菜,继而依赖打开包装
这种关系通常使用有向图来表示,如果这套流程能够成功的帮助你最后吃到火锅(无环),那这种依赖顺序就是拓扑排序,即拓扑排序是针对有向无环图的
说到底,就是要求各个节点之间有先后关系。然后我们在有向图中箭头表示这种先后关系。
例题:你这个学期必须选修 numCourse
门课程,记为 0
到 numCourse-1
。
在选修某些课程之前需要一些先修课程。 例如,想要学习课程 0 ,你需要先完成课程 1 ,我们用一个匹配来表示他们:[0,1]
给定课程总量以及它们的先决条件,请你判断是否可能完成所有课程的学习?
示例 1:
输入: 2, [[1,0]]
输出: true
解释: 总共有 2 门课程。学习课程 1 之前,你需要完成课程 0。所以这是可能的。
示例 2:
输入: 2, [[1,0],[0,1]]
输出: false
解释: 总共有 2 门课程。学习课程 1 之前,你需要先完成课程 0;并且学习课程 0 之前,你还应先完成课程 1。这是不可能的。
提示:
- 输入的先决条件是由 边缘列表 表示的图形,而不是 邻接矩阵
- 你可以假定输入的先决条件中没有重复的边
1 <= numCourses <= 10^5
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-vii7eYSl-1601215042339)(https://camo.githubusercontent.com/9fc1e20ea3122d82a7a082427f437ebb1f32421f/687474703a2f2f7265736f757263652e6d757969792e636e2f696d6167652f32303230303631393031353335352e706e67)]
所以我们可以使用 邻接表 来表示有向图中各个节点的依赖关系,同时维护一个入度表,则入度表中入度为 0
的节点所表示的课程是可以立即开始学习的(没有先决条件条件或先觉条件已完成)
拓扑排序的基本步骤:
-
创建一个队列,并将临接表中所有入度为
0
的节点放入队列中 -
若队列非空,则从队列中出队第一个节点,
numCourse —
(学习该课程),然后将将依赖该课程所有临接节点的入度减
1
- 若减
1
后节点入度为0
,则该课程又是可立即学习课程,将该节点添加到队尾 - 若减
1
后节点入度不为0
,则继续遍历下一节点
- 若减
-
当队列为空,检查
numCourses === 0
(所有课程是否全部学习结束)即可 -
let canFinish = function(numCourses, prerequisites) { // 如果没有先决条件,即所有的课程均没有依赖关系 // 直接返回 true if (prerequisites.length === 0) { return true } // 维护入度表 (一维数组) let inDegree = new Array(numCourses).fill(0) // 维护临接表 (Map存储入度信息,键是顶点,值是支持的顶点,也就是弧尾) let adj = new Map() for (let e of prerequisites) { inDegree[e[0]]++ if(!adj.has(e[1])) adj.set(e[1], []) let vEdge = adj.get(e[1]) vEdge.push(e[0]) } let queue = [] // 首先加入入度为 0 的结点 for (let i = 0; i < numCourses; i++) { if (inDegree[i] === 0) { queue.push(i) } } while (queue.length > 0) { // 从队首移除 var v = queue.shift() // 出队一门课程 numCourses-- if(!adj.has(v)) continue // 遍历当前出队结点的所有临接结点 for(let w of adj.get(v)) { inDegree[w]-- if (inDegree[w] === 0) { queue.push(w) } } } return numCourses === 0 }
ush(e[0])
}
let queue = []
// 首先加入入度为 0 的结点
for (let i = 0; i < numCourses; i++) {
if (inDegree[i] === 0) {
queue.push(i)
}
}
while (queue.length > 0) {
// 从队首移除
var v = queue.shift()
// 出队一门课程
numCourses--
if(!adj.has(v)) continue
// 遍历当前出队结点的所有临接结点
for(let w of adj.get(v)) {
inDegree[w]--
if (inDegree[w] === 0) {
queue.push(w)
}
}
}
return numCourses === 0
}