1. 概述
图是一种重要的数据结构,理解图在内存中是如何存储的是非常重要的。
在本教程中,我们将解释和比较三种主要的图数据结构,并展示它们的优缺点。
2. 问题定义
在图论中,我们将节点称为顶点 ,将节点之间的连接称为边
。在处理图存储数据结构时,我们主要基于空间和时间复杂度进行比较。
2.1. 复杂度
对于图存储数据结构,我们通常关注以下几种复杂度:
- 空间复杂度:存储图所需的大致内存量
- 时间复杂度
- 连接检查复杂度:确定两个不同节点是否相邻所需的大致时间
- 邻居查找复杂度:找到某个目标节点的所有相邻节点所需的大致时间
如果两个不同的节点之间有一条边连接,我们称它们为"相邻节点"。
2.2. 图的类型
理解图通常基于两个因素来定义是很重要的。
第一个因素是图是否是加权的。 在图论中,有时我们只关心两个节点是否连接,而其他时候,我们还关心从节点 到节点
移动的代价。
在本文中,我们将展示每种数据结构在这两种情况下需要进行的更新。
第二个因素是图是否是有向的。 如果从 到
有一条边,并且我们只能从节点
移动到节点
,那么这个图就是有向的。而在无向图中,节点
和
之间的边意味着我们可以从节点
移动到节点
,反之亦然。
我们总是可以通过将 和
之间的每条边分成两条边来将任何无向图转换为有向图。因此,在本文中,我们将讨论有向图,因为它们是更一般的情况。
3. 图数据结构
图在内存中的表示方式,主要有3种
3.1. 邻接矩阵
第一种数据结构称为邻接矩阵。顾名思义,邻接矩阵在我们需要快速确定两个节点是否相邻(连接)时非常有用。邻接矩阵是一个大小为 的
布尔数组。
让我们将其命名为 ,那么我们应该有:
如果从 到
有直接边,则
。
否则,。
然而,如果处理的是加权图,那么每个单元格 将是一个
数组,包含
和
之间直接边的权重。
如果 和
之间没有边,那么
将包含一个特殊值,表示
和
之间没有直接连接。通常,我们可以使用一个很大的值,表示直接在 u 和 v 之间移动代价很高,或者是不可能的。
3.2. 邻接表
第二种数据结构是邻接表。在这种数据结构中,我们不需要存储所有不同对 和
的值。相反,对于每个节点,我们只存储其相邻节点。
为此,我们创建一个大小为 的数组
。每个单元格
将保存一个链表。链表中的每个对象将存储与索引为
的节点相连的节点
的索引。因此,每个单元格
将有一个大小为
的链表,其中
对应于与节点
相连的节点数量。
换句话说:
,其中
等于节点
的第 i 个邻居。
如果我们处理的是加权图,那么链表中的每个对象将保存两个信息:相邻节点 和
与
之间边的权重。
3.3. 边列表
最后一种数据结构是边列表。从名称可以推断,我们将图中的所有边存储在一个链表中。
让我们将这个列表称为 。链表中的每个对象将保存两个信息:节点
和节点
,表示存在一条连接节点
和节点
的边。从上面我们可以推断:
包含图中第 i 条边的信息。
如果图是有加权的,那么每个对象将包含第三条信息,即节点 u 和 v 之间的边的权重。
这种数据结构对于具有大量节点但只有少量边的图特别有用。
4. 复杂度分析
下面表展示了不同数据结构的复杂度。接下来,我们将解释每个复杂度背后的原因:
数据结构 | 空间复杂度 | 时间复杂度 | |
---|---|---|---|
Connection Checking | Neighbors Finding | ||
Adjacency Matrix | |||
Adjacency List | |||
Edges List |
4.1. 空间复杂度
- 邻接矩阵:由于邻接矩阵存储了一个大小为
的数组,因此空间复杂度为
,其中
是图中顶点的数量。
- 邻接表:首先,我们存储一个大小为
的数组,每个单元格存储图中一个节点的信息。这意味着我们首先需要
的空间复杂度来存储一个空数组。接下来,我们考虑所有链表的大小总和。由于我们只在有新边时创建额外的链表对象,所以链表大小的总和等于
,其中
是图中边的数量。因此,总的空间复杂度为
。
- 边列表:在这种数据结构中,我们只有一个存储图中所有可能边的链表。因此,空间复杂度为
。
4.2. 连接检查复杂度
- 邻接矩阵:使用邻接矩阵检查两个节点
和
是否相连非常高效。我们只需查看单元格
的值即可。因此,时间复杂度为
。
- 邻接表:要确定两个节点
和
是否相连,我们需要遍历存储在
中的链表。在最坏的情况下,
和
之间没有边,我们将进行
次迭代。因此,总复杂度为
,其中
是
的邻居数量。
- 边列表:在边列表的情况下,我们别无选择,只能遍历链表中的所有边,检查是否能找到节点
和
之间的边。复杂度为
,其中
是图中边的数量。
4.3. 邻居查找复杂度
- 邻接矩阵:要找到某个节点
的所有邻居节点,我们需要遍历第 u 行的所有单元格,以确定哪些节点与
直接相连。换句话说,我们需要检查所有的
,其中
。因此,时间复杂度为
。
- 邻接表:快速找到所有邻居节点正是邻接表的设计初衷。由于单元格
存储了一个包含所有与
相连的节点
的链表,我们只需遍历存储在
中的链表即可。这种遍历的时间复杂度为
,其中
表示节点
的邻居数量。
- 边列表:边列表可能不是快速找到所有邻居节点的最佳解决方案。我们需要遍历链表中存储的所有对象,并检查存储的节点是否为
和
。因此,时间复杂度为
,其中
是图中边的数量。
5. 优缺点
邻接矩阵在需要快速检查两个节点之间是否有直接边时很有用。然而,其主要缺点是内存占用较大。邻接矩阵在图的节点数量不太大的情况下最为有效。此外,当图几乎是完全图(每个节点几乎与所有其他节点相连)时,使用邻接矩阵可能是一个不错的选择。
另一方面,邻接表在需要频繁访问某个节点 u 的所有邻居时是一个很好的选择。在这种情况下,我们只需遍历所需的节点。邻接表的局限性在于需要检查两个节点是否有直接边时会显现出来。
不过,值得注意的是,我们可以使用邻接表的改进版本。我们可以使用更复杂的数据结构(如集合)来存储所有邻居节点,而不是使用链表。这样可以高效地遍历邻居节点,同时也能以对数时间复杂度检查两个节点是否相连。
边列表是使用最少的数据结构。主要在节点数量巨大无法存储在内存中,但边数较少的情况下使用边列表。在这种情况下,我们别无选择,只能使用边列表。因此,边列表的唯一优势是其较低的内存空间复杂度。
6. 总结
在本文中,我们介绍了三种主要的图存储数据结构。
接着,我们讨论了大多数图算法执行的主要操作的空间和时间复杂度。
最后,我们探讨了每种数据结构在空间和时间复杂度方面的优缺点,以及何时使用每种数据结构。