发布于 2026-01-06 9 阅读
0

JavaScript 图数据结构详解 JavaScript 数据结构与算法

JavaScript 中的图数据结构详解

JavaScript 中的数据结构和算法

本文将探讨非线性数据结构,例如图。此外,我们还将介绍其核心概念和典型应用。

你可能经常使用涉及图和树的程序。例如,假设你想知道从工作地点到家的最短路径;你可以使用图算法来找到答案!我们将探讨这个问题以及其他有趣的挑战。

在上一篇文章中,我们探讨了数组、链表、集合、栈等线性数据结构。本文将在此基础上继续深入探讨。

您可以在Github代码库中找到所有这些实现以及更多内容:

GitHub 标志 amejiarosario / dsa.js-数据结构-算法-javascript

🥞数据结构与算法讲解及JavaScript实现(电子书)

图像

JavaScript 中的数据结构和算法

CircleCI NPM 版本 聊天

这是DSA.js 书籍的代码实现以及 NPM 包的仓库。

在这个代码库中,你可以找到 JavaScript 中算法和数据结构的实现。这些资料可以作为开发者的参考手册,也可以帮助你在面试前复习特定主题。此外,你还可以从中获得更高效的问题解决方法。

交互式数据结构

目录

安装

您可以克隆此仓库或从 NPM 安装代码:

npm install dsa.js
Enter fullscreen mode Exit fullscreen mode

然后您可以将其导入到您的程序或命令行界面中。

const { LinkedList, Queue, Stack } = require('dsa.js');
Enter fullscreen mode Exit fullscreen mode

有关所有可用数据结构和算法的列表,请参阅 index.js

特征

算法至关重要……

以下是本文将要介绍的操作概要:

  邻接表 邻接矩阵
添加顶点 O(1) O(|V| 2 )
移除顶点 O(|V| + |E|) O(|V| 2 )
addEdge O(1) O(1)
移除边缘(使用数组) O(|E|) O(1)
移除边缘(使用 HashSet) O(1) O(1)
获取相邻项 O(|E|) O(|V|)
isAdjacent(使用数组) O(|E|) O(1)
isAdjacent(使用 HashSet) O(1) O(1)
空间复杂性 O(|V| + |E|) O(|V| 2 )

图表基础知识

在深入探讨有趣的图算法之前,让我们先来明确一下命名约定和图的属性。

图是一种数据结构,其中一个节点可以有零个或多个相邻元素。

两个节点之间的连接称为。节点也可以称为顶点

度数是与一个顶点相连的边的数量。例如,purple顶点 1 的度数为 3,而blue顶点 2 的度数为 1。

如果边是双向的,那么我们就得到了一个无向图。但是,如果边是有方向的,那么我们就得到了一个有向图(简称有向图)。你可以把它想象成一条单行道(有向图)或一条双向道(无向图)。

顶点可以有指向自身的边(例如,blue节点),这称为自环

图可以包含环,这意味着如果遍历某个节点,可能会多次到达同一个节点。不含环的图称为无环图

此外,无环无向图也称为。我们将在下一篇文章中深入探讨树。

图中并非所有顶点都必须连通。可能存在孤立节点,甚至分离的子图。如果所有节点都至少有一条边,则该图为连通图。当所有节点都与其他所有节点连通时,则该图为完全图

对于完全图,每个节点都应该有#nodes - 1边。在前面的例子中,我们有七个顶点,所以每个节点有六条边。

图应用程序

当图中的边被赋予权重/成本时,我们就称其为加权图。如果图中没有权重,我们可以假设其值为 1。

加权图有很多应用,具体取决于你需要解决问题的领域。例如:

  • 航空交通(上图)

    • 节点/顶点 = 机场
    • 航线 = 两个机场之间的直飞航班
    • 权重 = 两个机场之间的距离(英里)
  • GPS导航

    • 节点 = 道路交叉口
    • 边缘 = 道路
    • 重量 = 从一个路口到另一个路口所需的时间
  • 网络路由

    • 节点 = 服务器
    • 边缘 = 数据链路
    • 重量 = 连接速度

一般来说,图表在现实世界中有很多应用,例如:

  • 电子电路
  • 航班预订
  • 行车路线
  • 电信:基站频率规划
  • 社交网络。例如,Facebook 使用图谱来推荐好友。
  • 推荐:亚马逊/Netflix 使用图表来推荐产品/电影。
  • 图表有助于规划货物运输的物流。

我们刚刚学习了图的基础知识及其一些应用。接下来,我们将探讨如何在 JavaScript 中表示图。

图的表示

图的表示方法主要有两种:

  1. 邻接表
  2. 邻接矩阵

让我们以下面的有向图为例来解释一下:

我们有一个有4个节点的有向图。当一个顶点有一条指向自身的链接时(例如a),称为自环

邻接矩阵

邻接矩阵是使用二维数组(N×N 矩阵)表示图的一种方法。在节点的交集中,如果节点相连,则加 1(或其他权重);如果不相连0,则不加 0。-

沿用之前的例子,我们可以构建如下邻接矩阵:

  a b c d e
a 1 1 - - -
b - - 1 - -
c - - - 1 -
d - 1 1 - -
Enter fullscreen mode Exit fullscreen mode

如您所见,该矩阵按水平和垂直方向列出了所有节点。如果连接较少,我们称之为稀疏图;如果连接较多(接近最大连接数),我们称之为稠密图。如果所有可能的连接都已达到,则我们得到的是完全图

需要注意的是,对于无向图,邻接矩阵总是关于对角线对称的。然而,对于有向图(例如我们的例子),情况并非如此。

找出两个顶点之间的连接的时间复杂度是多少?

查询邻接矩阵中的两个节点是否相连需要常数时间或O(1)

空间复杂度是多少?

将图存储为邻接矩阵的空间复杂度为O(n² )其中 nn是顶点数。也可以表示为O(|V| ² )。

添加一个顶点需要多长时间?

顶点以V*xV * 矩阵的形式存储。因此,每次添加顶点时,都需要将矩阵重构为V+1*xV+1 * 矩阵。

在邻接矩阵上添加一个顶点的复杂度为O(|V| 2 )

如何获取相邻节点?

由于该矩阵是一个 VxV 矩阵,要获取给定顶点的所有相邻节点,我们需要遍历该节点所在的行,并获取该节点与其他节点的所有边。

在之前的例子中,假设我们想要找到所有与节点 b 相邻的节点b。我们需要找到包含 b 以及所有其他节点的完整行。

  a b c d e
b - - 1 - -
Enter fullscreen mode Exit fullscreen mode

我们需要访问所有节点,所以

在邻接矩阵上获取相邻节点的时间复杂度为O(|V|)

想象一下,你需要用图来表示 Facebook 社交网络。你将不得不创建一个 20 亿 x 20 亿的矩阵,其中大部分都是空的!没有人会认识其他人,最多只有几千人彼此认识。

通常情况下,我们处理的是稀疏图,因此矩阵会浪费大量空间。这就是为什么在大多数实现中,我们会使用邻接表而不是矩阵的原因。

邻接表

邻接表是表示图的最常用方法之一。每个节点都有一个与其相连的所有节点的列表。

图可以用邻接表表示,邻接表使用包含节点的数组(或哈希表)。每个节点条目都包含一个列表(数组、链表、集合等),其中列出了与其相邻的节点。

例如,在上图中,我们有a与 相连b,并且自身也形成一个自环。反过来,b又与 相连,c依此类推:

a -> { a b }
b -> { c }
c -> { d }
d -> { b c }
Enter fullscreen mode Exit fullscreen mode

您可以想象,如果您想知道一个节点是否与另一个节点相连,您需要遍历该列表。

查询邻接表中两个节点是否相连的时间复杂度为O(n),其中nn 为顶点数。也可表示为O(|V|)。

空间复杂性又如何呢?

将图存储为邻接表的空间复杂度为O(n),其中nn 是顶点数和边数之和。也可以表示为O(|V| + |E|)。

邻接表图哈希映射实现

邻接表是表示图的最常用方法。邻接表有多种实现方式:

其中一种方法是使用 HashMap。其中,`value`key是节点的值,`a`value是邻接关系数组。

const graph = {
  a: ['a', 'b'],
  b: ['c'],
  c: ['d'],
  d: ['b', 'c']
}
Enter fullscreen mode Exit fullscreen mode

图通常需要以下操作:

  • 添加和删​​除顶点
  • 添加和移除边缘

添加和删​​除顶点涉及更新邻接表。

假设我们要删除顶点b。我们可以这样做delete graph['b'];,但是我们仍然需要删除邻接表中“d”和“a”的引用。

每次移除一个节点,我们都需要遍历所有节点的列表,时间复杂度为O(|V| + |E|)。我们能做得更好吗?我们很快就会解答这个问题,但首先,让我们用更面向对象的方式实现我们的列表,这样我们就可以轻松地切换实现方式。

邻接表图面向对象实现

我们先从Node保存顶点值及其相邻顶点的类开始。我们还可以添加辅助函数,用于在列表中添加和删除附近的节点。

class Node {
  constructor(value) {
    this.value = value;
    this.adjacents = []; // adjacency list
  }

  addAdjacent(node) {
    this.adjacents.push(node);
  }

  removeAdjacent(node) {
    const index = this.adjacents.indexOf(node);
    if(index > -1) {
      this.adjacents.splice(index, 1);
      return node;
    }
  }

  getAdjacents() {
    return this.adjacents;
  }

  isAdjacent(node) {
    return this.adjacents.indexOf(node) > -1;
  }
}
Enter fullscreen mode Exit fullscreen mode

注意,adjacent运行时间是O(1),而remove adjacentO (|E|)。如果我们用一个 🧐 代替数组呢HashSet?它的时间复杂度可能也是O(1)。但是,我们先让它能运行起来,之后再考虑如何提高速度。

让它运转起来。让它正确无误。让它更快。

好了,现在我们有了这个Node类,让我们来构建 Graph 类,它可以执行诸如添加/删除顶点和边之类的操作。

图形构造器

class Graph {
  constructor(edgeDirection = Graph.DIRECTED) {
    this.nodes = new Map();
    this.edgeDirection = edgeDirection;
  }
  // ...
}

Graph.UNDIRECTED = Symbol('directed graph'); // one-way edges
Graph.DIRECTED = Symbol('undirected graph'); // two-ways edges
Enter fullscreen mode Exit fullscreen mode

我们首先需要知道的是图是有向图还是无向图。这在添加边时会产生影响。

Graph.addEdge

要添加一条边,我们需要两个节点。一个是源节点,另一个是目标节点。

  addEdge(source, destination) {
    const sourceNode = this.addVertex(source);
    const destinationNode = this.addVertex(destination);

    sourceNode.addAdjacent(destinationNode);

    if(this.edgeDirection === Graph.UNDIRECTED) {
      destinationNode.addAdjacent(sourceNode);
    }

    return [sourceNode, destinationNode];
  }
Enter fullscreen mode Exit fullscreen mode


js

我们添加一条从源顶点到目标顶点的边。如果是无向图,由于它是双向的,我们还需要添加一条从目标节点到源节点的边。

从图的邻接表中添加一条边的运行时间为:O(1)

如果我们尝试添加一条边,但节点不存在,我们需要先创建它们。接下来我们就来创建它们!

Graph.addVertex

创建节点的方式是将其添加到this.nodesMap 中。Map 存储一个键值对,其中键key是顶点的值,而 Mapvalue本身是节点类的实例。请查看第 5-6 行:

  addVertex(value) {
    if(this.nodes.has(value)) {
      return this.nodes.get(value);
    } else {
      const vertex = new Node(value);
      this.nodes.set(value, vertex);
      return vertex;
    }
  }
Enter fullscreen mode Exit fullscreen mode

如果节点已存在,我们不希望覆盖它。因此,我们首先检查它是否已存在,如果不存在,则创建它。

从图的邻接表中添加顶点的运行时间为:O(1)

Graph.removeVertex

从图中移除一个节点稍微复杂一些。我们需要检查要删除的节点是否正在被用作相邻节点。

  removeVertex(value) {
    const current = this.nodes.get(value);
    if(current) {
      for (const node of this.nodes.values()) {
        node.removeAdjacent(current);
      }
    }
    return this.nodes.delete(value);
  }
Enter fullscreen mode Exit fullscreen mode

我们需要遍历每个顶点,然后遍历每个相邻节点(边)。

从图的邻接表中移除一个顶点的运行时间为O(|V| + |E|)。

最后,让我们移除移除边缘的实现!

Graph.removeEdge

去除边缘非常简单,类似于addEdge……

  removeEdge(source, destination) {
    const sourceNode = this.nodes.get(source);
    const destinationNode = this.nodes.get(destination);

    if(sourceNode && destinationNode) {
      sourceNode.removeAdjacent(destinationNode);

      if(this.edgeDirection === Graph.UNDIRECTED) {
        destinationNode.removeAdjacent(sourceNode);
      }
    }

    return [sourceNode, destinationNode];
  }
Enter fullscreen mode Exit fullscreen mode

addEdge两者之间的主要区别removeEdge在于:

  • 如果顶点不存在,我们就不会创建它们。
  • 我们用Node.removeAdjacent代替Node.addAdjacent

由于removeAdjacent需要遍历所有相邻顶点,因此运行时间如下:

从图的邻接表中移除一条边的运行时间为O(|E|)。

我们将探讨如何从节点中搜索值。

广度优先搜索(BFS)——图搜索

广度优先搜索是一种从初始顶点开始,首先访问所有相邻节点,从而遍历图的方法。

让我们看看如何用代码实现这一点:

  *bfs(first) {
    const visited = new Map();
    const visitList = new Queue();

    visitList.add(first);

    while(!visitList.isEmpty()) {
      const node = visitList.remove();
      if(node && !visited.has(node)) {
        yield node;
        visited.set(node);
        node.getAdjacents().forEach(adj => visitList.add(adj));
      }
    }
  }
Enter fullscreen mode Exit fullscreen mode

如您所见,我们使用了一种Queue先进先出 (FIFO) 算法,其中第一个节点也是第一个被访问的节点。

我们还使用了JavaScript 生成器,请注意*函数前面的 `$('$('$('$('$('$('$('$('$('$('$('$('$('$('$('$('$('$('$('$('$('$('$('$('$('$('$(1$( '$(1$( '$( '$( '$( '$( '$( '$( '$( '$( ')')))))))))))))。'$( '$( ... '')))))))

以下是如何使用我们刚刚创建的广度优先搜索算法的示例:

  const graph = new Graph(Graph.UNDIRECTED);

  const [first] = graph.addEdge(1, 2);
  graph.addEdge(1, 3);
  graph.addEdge(1, 4);
  graph.addEdge(5, 2);
  graph.addEdge(6, 3);
  graph.addEdge(7, 3);
  graph.addEdge(8, 4);
  graph.addEdge(9, 5);
  graph.addEdge(10, 6);

  bfsFromFirst = graph.bfs(first);

  bfsFromFirst.next().value.value; // 1
  bfsFromFirst.next().value.value; // 2
  bfsFromFirst.next().value.value; // 3
  bfsFromFirst.next().value.value; // 4
  // ...
Enter fullscreen mode Exit fullscreen mode

您可以在测试用例中找到更多用法示例。接下来我们来看深度优先搜索!

深度优先搜索(DFS)——图搜索

深度优先搜索是另一种从初始顶点开始遍历图的方法,它递归地找到每个顶点的第一个相邻节点。

深度优先搜索(DFS)的迭代实现与广度优先搜索(BFS)相同,只是DFS使用迭代器而不是Queue迭代器Stack

  *dfs(first) {
    const visited = new Map();
    const visitList = new Stack();

    visitList.add(first);

    while(!visitList.isEmpty()) {
      const node = visitList.remove();
      if(node && !visited.has(node)) {
        yield node;
        visited.set(node);
        node.getAdjacents().forEach(adj => visitList.add(adj));
      }
    }
  }
Enter fullscreen mode Exit fullscreen mode

我们可以按如下方式测试我们的图表。

  const graph = new Graph(Graph.UNDIRECTED);

  const [first] = graph.addEdge(1, 2);
  graph.addEdge(1, 3);
  graph.addEdge(1, 4);
  graph.addEdge(5, 2);
  graph.addEdge(6, 3);
  graph.addEdge(7, 3);
  graph.addEdge(8, 4);
  graph.addEdge(9, 5);
  graph.addEdge(10, 6);

  dfsFromFirst = graph.dfs(first);
  visitedOrder = Array.from(dfsFromFirst);
  const values = visitedOrder.map(node => node.value);
  console.log(values); // [1, 4, 8, 3, 7, 6, 10, 2, 5, 9]
Enter fullscreen mode Exit fullscreen mode

如您所见,BFS 和 DFS 的图基本相同,但访问节点的顺序却截然不同。BFS 按顺序从 1 到 10 访问节点,而 DFS 则尽可能深入地访问每个节点。

图的时间和空间复杂度

我们已经了解了图的一些基本操作,例如如何添加和删除顶点和边。以下是我们目前所学内容的总结:

  邻接表 邻接矩阵
空间 O(|V| + |E|) O(|V| 2 )
添加顶点 O(1) O(|V| 2 )
移除顶点 O(|V| + |E|) O(|V| 2 )
addEdge O(1) O(1)
移除边缘(使用数组) O(|E|) O(1)
移除边缘(使用 HashSet) O(1) O(1)
获取相邻项 O(|E|) O(|V|)
isAdjacent(使用数组) O(|E|) O(1)
isAdjacent(使用 HashSet) O(1) O(1)

如您所见,邻接表在几乎所有操作中都更快。邻接矩阵唯一优于邻接表的操作是检查节点是否与其他节点相邻。但是,如果我们把实现方式从数组改为哈希集合,也能达到常数时间复杂度 :)

概括

正如我们所见,图可以帮助我们模拟许多现实生活中的场景,例如机场、社交网络、互联网等等。我们介绍了一些最基本的算法,例如广度优先搜索(BFS)和深度优先搜索(DFS)。此外,我们还研究了实现方式的权衡,例如邻接表和邻接矩阵。订阅我的简讯,不要错过我的任何文章,因为我们很快将学习许多其他应用,例如寻找节点之间的最短路径以及其他令人兴奋的图算法!

文章来源:https://dev.to/amejiarosario/graph-data-structs-for-beginners-5edn