前端数据结构与算法之图的基础

374 阅读6分钟

图的概念

引用百科的描述:

在数学的分支图论中,图(Graph)用于表示物件与物件之间的关系,是图论的基本研究对象。一张图由一些小圆点(称为顶点或结点)和连结这些圆点的直线或曲线(称为边)组成。英国数学家西尔维斯特在 1878 年首次提出“图”这一名词。

由上可知,我们学习图的数据结构,也就是在学习图论中的一部分内容。所以当你遇到不理解的内容时, 建议去看一看图论的相关教材,也许能解决你的困惑。

通常我们用一个代表有序对的二元组表达式:G=(V,E)G = (V, E) 来表示一个图结构,其中 VV 表示顶点集,EE表示边集:

E{{x,y}:(x,y)V2,xy}E \sube \lbrace \lbrace x, y \rbrace : (x, y) \in V^2 , x \ne y \rbrace

边集由所有无序顶点对构成(换句话说,边连接了顶点对)。对于一个边 {x,y},顶点 x,y 被称作是边的端点,边则被称为连接了此两个点。

顶点间的关系(边)

在顶点集合所包含的若干个顶点之间,可能存在着某种两两关系,如果某两个点之间确实存在这种关系的话,我们就在这两个点之间连边,这样就得到了边集的一个成员,也就是一条边。如果对应到社交网络中,把顶点看做是用户,如果这两个用户被连上了一条边,就表示他们之间存在好友关系。

无向图

无向图

用边来表示好友关系的话,像聊天软件里面的好友就可以看做是双向关注的社交网络,毕竟只有添加了好友才可以聊天,删除了就不能聊天了。这种两个点之间用一条无向的边连接起来的图叫做无向图。

有向图

youxiangtu.png

像短视频里面的主播,我们可以给他点关注双击666,这样的关系就不能用无向图来表示了,因为我们关注了主播,主播不一定会关注我们,如果主播没有关注我们,那么这样的关系就是单向的,用一个有向边来连接两个顶点。如果我们跟主播互相关注了,这样可以看做是无向图的好友关系,但是我们也可以使用两条有向边来表示好友关系。所以在研究图的数据结构的时候,我们一般研究有向图,因为它可以用来表示无向图。

稀疏图和稠密图

规定:当 E<VlognE < V * logn 时,叫做稀疏图,也就是这时图里面的边很少,反之就叫做稠密图,比如用户之间都相互关注,这时就是稠密图。

顶点的度

图中某个顶点的度是通过某个顶点有多少条边算出来的,比如:某个顶点有 3 条边,那么这个顶点的度就是 3。

出度和入度

在有向图中我们会看到有的有向边是指向某个顶点的,也就是以某个顶点为终点。而有的有向边是从这个顶点出发指向其它顶点的,也就是以某个顶点为起点。

这样以某个顶点为终点的有向边的数量我们叫该顶点的入度,以某个顶点为起点的有向边的数量我们叫顶点的出度。

不难看出,在有向图中,一个顶点的度等于出度和入度之和。

图的存储结构

在研究图的数据结构之前我们还有一些先修的知识点要学习,图是按照什么方式来存储的。

邻接矩阵

邻接矩阵是用来存储图的顶点之间的关系的,通过邻接矩阵可以很方便地反映出这种关系。

定义:若图 GG 的顶点有 nn 个,分别标为:v1,v2,...,vnv_1,v_2,...,v_n,那么构造出一个 nnn * n 阶的矩阵 AnnA_{nn},让它符合这样的条件:若 (vi,vj)E(G)(v_i,v_j) \in E(G)Aij=1A_{ij} = 1,否则Aij=0A_{{ij}} = 0

通过一个例子来理解一下,假设有这样一个图:

ljjztu.png

根据邻接矩阵的定义,可以构造出如下矩阵:

[0110000000010100]\begin{bmatrix} 0 & 1 & 1 & 0 \\ 0 & 0 & 0 & 0 \\ 0 & 0 & 0 & 1 \\ 0 & 1 & 0 & 0 \end{bmatrix}

这里解释一下这个矩阵是如何构造出来的:

可以对照着这个图表理解:

ljjzbiao.png

  1. 当 i = 1, j = 1 时,明显 v1v_1自己没有边,于是矩阵的 A11=0A_{11} = 0
  2. 当 i = 1, j = 2 时,v1v_1 有指向 v2v_2 的边,于是矩阵的 A12=1A_{12} = 1
  3. 当 i = 1, j = 3 时,v1v_1 有指向 v3v_3 的边,于是矩阵的 A13=1A_{13} = 1
  4. 当 i = 1, j = 4 时,v1v_1 没有有指向 v4v_4 的边,于是矩阵的 A14=0A_{14} = 0
  5. 当 i = 2, j = 1 时,v2v_2 没有有指向 v1v_1 的边,于是矩阵的 A21=0A_{21} = 0
  6. 当 i = 2, j = 2 时,v2v_2 自己没有边,于是矩阵的 A22=0A_{22} = 0
  7. 当 i = 2, j = 3 时,v2v_2 没有有指向 v3v_3 的边,于是矩阵的 A23=0A_{23} = 0
  8. 当 i = 2, j = 4 时,v2v_2 没有有指向 v4v_4 的边,于是矩阵的 A24=0A_{24} = 0

...按照这种方法就可以把矩阵构造出来了

可以看到,矩阵中的每一行,从上到下分别表示每个顶点(A11,A21,A31A41A_{11}, A_{21}, A_{31} 和 A_{41})与其它顶点的出度的关系。如果与某个顶点有出度关系,就在某个顶点处标上 1。

矩阵中的每一列,从左到右分别表示每个顶点(A11,A12,A13A14A_{11}, A_{12}, A_{13} 和 A_{14})与其它顶点的入度的关系。如果与某个顶点有入度关系,就在某个顶点处标上 1。其实我们把出度的关系做好以后,入度的关系就自动标好了。

邻接表

邻接表也是用来描述图的顶点之间间的关系的一种工具。如果用文字法描述的话就是这样的:

3wuxiang.png

有这样一个无向图,它的邻接表就描述为:a 邻接于 b,c;b 邻接于 a,c;c 邻接于 a,b。

如果是有向图,则邻接表表示的是各顶点的出度的关系,相反逆邻接表就表示各顶点入度的关系了。

还是以邻接矩阵那个图作为例子,可以得到如下邻接表的描述:

  1. v1v_1 邻接于 v2,v3v_2,v_3
  2. v2v_2 无邻接
  3. v3v_3 邻接于 v4v_4
  4. v4v_4 邻接于 v2v_2

如果用链表来表示的话就是这样的:

v1->v2->v3

v2

v3->v4

v4->v2

这些链表的表头结点一般会存储在一个数组中,方便访问顶点。

邻接表的好处是很容易就能知道某一个顶点和哪些顶点相连接。

邻接矩阵和邻接表都可以用来存储图,它们各有好坏,比如对于有 n 个顶点的图,邻接矩阵总是需要 n2n^2 的存储空间,当边数很少的时候,会造成空间浪费。

所以,如果是稀疏图,那么一般选用邻接表来存储,如果是稠密图就选用邻接矩阵来存储。

用邻接矩阵构造图

理论学习完后,咱们来实现一下如何用邻接矩阵存储图。

/**
 * 图的结构
 * @param {*} length 邻接矩阵的阶数,也就是图的顶点数
 */
function Graph(length) {
  this.length = length;
  // 初始化存矩阵的二维数组
  this.matrix = [];
  for (let i = 0; i < length; i++) {
    // 这里也可以不用把类数组转成数组
    this.matrix[i] = Array.from(new Int8Array(length));
  }
}

定义好图的结构后,咱们来手动创建一个邻接矩阵,以邻接矩阵的图为例。

/**
 * 插入邻接矩阵数据
 * @param {*} graph 图
 * @param {*} a 0-有向图,1-无向图
 * @param {*} i 顶点,相当于矩阵下标
 * @param {*} j 顶点,相当于矩阵下标
 * @returns
 */
function insert(graph, a, i, j) {
  if (i < 0 || i >= graph.n || j < 0 || j >= graph.n) {
    return;
  }
  // 有向图就只需要一条边
  if (a === 0) {
    graph.matrix[i][j] = 1;
  } else {
    // 无向图有两条边,对称的
    graph.matrix[i][j] = 1;
    graph.matrix[j][i] = 1;
  }
}

/**
 * 输出邻接矩阵
 * @param {*} graph 图
 */
function output(graph) {
  let str = "";
  for (let i = 0; i < graph.length; i++) {
    for (let j = 0; j < graph.length; j++) {
      str += graph.matrix[i][j] + " ";
    }
    str += "\n";
  }
  console.log(str);
}

const graph = new Graph(4);

// 手动创建邻接矩阵
insert(graph, 0, 0, 1);
insert(graph, 0, 0, 2);
insert(graph, 0, 2, 3);
insert(graph, 0, 3, 1);

output(graph);
console.log(graph);

这样就完成了邻接矩阵的创建和使用。

用邻接表够造图

要构造邻接表,我们需要一个用于保存顶点的链表:

/**
 * 保存顶点的链表结点
 * @param {*} vertex 顶点
 */
function Node(vertex) {
  this.vertex = vertex;
  this.next = null;
}

然后需要邻接表的结构,邻接表里面有两个属性,一个是图的顶点数,一个是用于保存边的关系的数组,数组里面保存的是以各个顶点为头结点的链表:

/**
 * 图的邻接表结构
 * @param {*} length 长度
 */
function GraphList(length) {
  this.length = length;
  this.edges = [];
  for (let i = 0; i < length; i++) {
    this.edges[i] = null;
  }
}

接下来就要创建邻接表了:

/**
 * 按倒序插入结点(结点的顺序如何没有关系)
 * @param {*} head 头结点
 * @param {*} index 顶点
 * @returns 倒序的链表
 */
function insertNode(head, index) {
  const node = new Node(index);
  node.next = head;
  head = node;
  return head;
}

/**
 * 创建邻接表
 * @param {*} graph 图
 * @param {*} a 0-有向边,1-无向边
 * @param {*} i 顶点
 * @param {*} j 顶点
 * @returns 
 */
function insertGraph(graph, a, i, j) {
  if (i < 0 || i >= graph.length || j < 0 || j >= graph.length) {
    return;
  }
  if (a === 0) {
    graph.edges[i] = insertNode(graph.edges[i], j);
  } else {
    graph.edges[i] = insertNode(graph.edges[i], j);
    graph.edges[j] = insertNode(graph.edges[j], i);
  }
}

遍历输出邻接表

function outputGraph(graph) {
  let str = "";
  for (let i = 0; i < graph.length; i++) {
    str += i + ':';
    for (let j = graph.edges[i]; j !== null; j = j.next) {
      str += j.vertex + ' ';
    }
    str += '\n';
  }
  console.log(str);
}


const graph = new GraphList(4);
// 手动创建邻接表来做测试
insertGraph(graph, 0, 0, 1);
insertGraph(graph, 0, 0, 2);
insertGraph(graph, 0, 2, 3);
insertGraph(graph, 0, 3, 1);

console.log(graph);

outputGraph(graph);