在内存中存储图形的三种方式,优缺点


90

有三种方法可以将图形存储在内存中:

  1. 节点作为对象,边缘作为指针
  2. 包含编号的节点x和节点y之间的所有边缘权重的矩阵
  3. 编号节点之间的边列表

我知道怎么写这三者,但是我不确定我是否已经想到了每一种的优点和缺点。

这些将图形存储在内存中的每种方式的优缺点是什么?


3
我只在图形非常连通或很小的情况下才考虑矩阵。对于稀疏连接的图,对象/指针或边列表方法都将提供更好的内存使用。我很好奇除了存储之外我还忽略了什么。;)
sarnold

2
它们的时间复杂度也有所不同,矩阵为O(1),其他表示形式可能会根据您要查找的内容而有很大差异。
msw

1
我记得前一段时间读过一篇文章,描述了将图形实现为矩阵而不是指针列表的硬件优势。除了您正在处理连续的内存块外,在任何给定时间,您的大部分工作集很可能都位于L2缓存中,所以我对此不太记得。另一方面,节点/指针列表可能是通过内存散布的,可能可能需要不命中高速缓存的访存。我不确定我是否同意,但这是一个有趣的想法。
nerraga

1
@Dean J:只是一个关于“节点作为对象而边缘作为指针表示”的问题。您使用哪种数据结构在对象中存储指针?是清单吗?
Timofey 2012年

4
通用名称是:(1)等效于邻接表,(2)邻接矩阵,(3)边缘表
Evgeni Sergeev

Answers:


51

分析这些的一种方法是根据内存和时间复杂度(取决于您要如何访问图形)。

将节点存储为具有相互指针的对象

  • 此方法的内存复杂度为O(n),因为您拥有的对象数与节点的数量一样多。所需的(指向节点的)指针数量最多为O(n ^ 2),因为每个节点对象都可以包含最多n个节点的指针。
  • 对于访问任何给定节点,此数据结构的时间复杂度为O(n)。

存储边缘权重矩阵

  • 对于矩阵,这将是O(n ^ 2)的存储复杂度。
  • 这种数据结构的优点是访问任何给定节点的时间复杂度为O(1)。

根据在图形上运行的算法以及节点数量,您必须选择合适的表示形式。


3
我相信,如果您还将节点存储在单独的数组中,则在对象/指针模型中进行搜索的时间复杂度仅为O(n)。否则,您将需要遍历图以搜索所需的节点,不是吗?无法在O(n)中遍历任意图中的每个节点(但不一定遍历每个边),可以吗?
巴里·弗里特曼

@BarryFruitman我很确定你是正确的。BFS为O(V + E)。另外,如果要搜索未连接到其他节点的节点,则永远找不到它。
WilderField

10

还有两件事要考虑:

  1. 通过将权重存储在矩阵中,矩阵模型更容易使其具有加权边缘图。对象/指针模型需要将边缘权重存储在并行数组中,这需要与指针数组同步。

  2. 与无向图相比,对象/指针模型在有向图上的工作效果更好,这是因为指针需要成对维护,这可能会变得不同步。


1
您的意思是,指针需要与无向图成对维护,对吗?如果是有向的,则只需将一个顶点添加到特定顶点的邻接列表中,但是如果是无向的,则必须在两个顶点的邻接列表中都添加一个顶点?
FrostyStraw

@FrostyStraw是的,完全是。
巴里·弗鲁特曼

8

正如一些人指出的那样,对象指针方法存在搜索困难的问题,但是对于诸如构建二进制搜索树之类的事情却很自然,因为那里有很多额外的结构。

我个人喜欢邻接矩阵,因为它们使用代数图论的工具使各种问题变得更加容易。(例如,邻接矩阵的k次幂给出从顶点i到顶点j的长度为k的路径数。在求k次幂之前添加一个单位矩阵,以获得长度<= k的路径数。拉普拉斯算子的n-1个未成年人,以获取生成树的数量...依此类推。)

但是每个人都说邻接矩阵是昂贵的内存!它们只是右半边:当图形的边很少时,可以使用稀疏矩阵来解决此问题。稀疏矩阵数据结构可以完全完成仅保留邻接表的工作,但仍具有标准矩阵运算的全部范围,可为您提供两全其美的方法。


7

我认为您的第一个示例有点模棱两可-节点作为对象,边缘作为指针。您可以通过仅存储指向某个根节点的指针来跟踪这些情况,在这种情况下,访问给定节点可能效率不高(例如,您想要节点4 –如果未提供节点对象,则可能必须搜索它) 。在这种情况下,您还会丢失图表中从根节点无法访问的部分。我认为f64 Rainbow就是这种情况,他说访问给定节点的时间复杂度为O(n)。

否则,您还可以使数组(或哈希图)充满指向每个节点的指针。这允许O(1)访问给定节点,但会稍微增加内存使用量。如果n是节点数,e是边数,则此方法的空间复杂度将为O(n + e)。

矩阵方法的空间复杂度将沿着O(n ^ 2)的线(假设边是单向的)。如果图形稀疏,矩阵中将有很多空单元格。但是,如果您的图是完全连接的(e = n ^ 2),则与第一种方法相比是有利的。如RG所述,如果将矩阵分配为一个内存块,则使用此方法还可能减少缓存丢失,这可能会使跟踪图形周围的许多边变得更快。

第三种方法在大多数情况下可能是最节省空间的-O(e)-但会使查找给定节点的所有边缘成为O(e)琐事。我想不出这会很有用的情况。


边缘列表对于Kruskal算法是很自然(“对于每个边缘,请以并集查找形式进行查找”)。而且,Skiena(第2版,第157页)在其库Combinatorica(该库是许多算法的通用库)中谈到了边列表作为图的基本数据结构。他确实提到原因之一是Mathematica的计算模型所施加的约束,而Mathematica正是Combinatorica所处的环境。
Evgeni Sergeev


4

还有另一种选择:节点作为对象,边也作为对象,每个边同时在两个双向链接的列表中:来自同一节点的所有边的列表和进入同一节点的所有边的列表。

struct Node {
    ... node payload ...
    Edge *first_in;    // All incoming edges
    Edge *first_out;   // All outgoing edges
};

struct Edge {
    ... edge payload ...
    Node *from, *to;
    Edge *prev_in_from, *next_in_from; // dlist of same "from"
    Edge *prev_in_to, *next_in_to;     // dlist of same "to"
};

内存开销很大(每个节点2个指针,每个边缘6个指针),但是您得到了

  • O(1)节点插入
  • O(1)边缘插入(给出指向“ from”和“ to”节点的指针)
  • O(1)边缘删除(给定指针)
  • O(deg(n))节点删除(给定指针)
  • O(deg(n))寻找节点的邻居

该结构还可以表示一个相当普通的图:带有循环的面向多重图(即,您可以在相同的两个节点之间具有多个不同的边,包括多个不同的循环-边从x到x)。

有关此方法的详细说明,请参见此处


3

好的,因此,如果边缘没有权重,则矩阵可以是二进制数组,并且在这种情况下,使用二进制运算符可以使处理过程变得非常非常快。

如果图是稀疏的,则对象/指针方法似乎效率更高。专门将对象/指针保持在数据结构中以将它们哄骗到单个内存中也是一个不错的计划,或者是使它们保持在一起的任何其他方法。

邻接列表(仅是连接节点的列表)似乎是迄今为止内存效率最高的,但也可能是最慢的。

逆转向图是容易与所述矩阵表示,且易于与邻接表,但与该对象/指针表示没有那么大。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.