网络流算法的合适图表示

4
实现最大网络流的Ford-FulkersonDinitz算法时,需要对图执行两个操作:
  • 迭代给定顶点的所有邻居
  • 查找给定边的反向边(在增广路径上添加流时需要进行图修改)。
理想情况下,第一个操作应该是与邻居数量成线性关系的,第二个操作应该是常量级别的。此外,图形表示所需的内存应该与边数成线性关系(请注意,对于我看到的大多数最大网络流算法的实际应用,边数约为顶点数的线性倍数)。仅当满足上述约束条件时,对两种算法的复杂性的所有估计才有效。
问题在于,经典图形表示中没有一种能够满足上述要求:

邻接矩阵

使用邻接矩阵,可以在常数时间内找到给定边的反向边。然而,遍历所有邻居与顶点总数成线性关系,所需内存与顶点数的平方成二次关系。
边列表
使用此表示法,遍历所有邻居将不会与邻居数成线性关系,同时对于给定边查找反向边也不是常数时间。
邻居列表
使用此表示法,我们可以在线性时间内遍历所有邻居,所需内存与边数成线性关系。但是,对于给定边查找反向边将与目标顶点的邻居数成线性关系。
通过稍微修改这种表示法,我们可以改进它 - 如果我们不使用邻居列表,而是保留一些邻居的二叉搜索树,我们可以用对数复杂度找到反向边。更好的是 - 如果我们使用哈希映射而不是二叉搜索树,我们将具有摊销常数复杂度。但是,这种表示法仍然不太合适 - 尽管仍然是线性的,但哈希映射具有一些内存开销。此外,它只具有分摊常数复杂度,因此某些操作实际上可能更慢。
问题
所以我的问题是:什么图形表示适合实现最大网络流算法?
2个回答

3
我会将Ivaylo的表示描述为“边连续”。还有一种“端点连续”的表示法,我认为它被广泛使用。我曾经在不同的时间实现了这两种方法。
缺乏硬性数据,我的假设是,对于通常的网络流算法而言,“端点连续”表示法比“边连续”表示法更好,因为每次扫描弧时,“边连续”需要进行随机访问,而“端点连续”则需要在推送弧上的流量时进行(这可能是在扫描弧之前)。这种表示法的明显优点是支持非对称图形(对于网络流来说不那么相关)。这种表示法的明显缺点是更改图形的拓扑结构速度较慢。
该表示法由两个数组组成。其中,具有n+1个元素的“first”存储了指定尾部的第一个弧的索引。额外的条目是m,即总弧数,因此具有尾部v的弧的索引从“first[v]”(含)到“first[v+1]”(不含)。在Ivaylo的图中,
[0] = 0->1, [1] = 0->2,
[2] = 1->0, [3] = 1->2, [4] = 1->3,
[5] = 2->0, [6] = 2->1, [7] = 2->3, [8] = 2->4,
[9] = 3->1, [10] = 3->2, [11] = 3->5,
[12] = 4->2, [13] = 4->5,
[14] = 5->3, [15] = 5->4,

数组 first

0, 2, 5, 9, 12, 14, 16.

弧本身存储在以下结构类型的m元素数组中。

struct Arc {
    int head;
    int capacity;
    int symmetric;
};

symmetric是对称弧的索引。


你为什么要将反向边存储在一起?我的意思是,我知道如何修改你的代码以将直线边存储在相邻的内存单元中,但你这样做有什么原因吗? - Ivaylo Strandjev
@IvayloStrandjev 弧应以“0->1,0->2,1->0,1->2”等顺序存储。我纠正了一些拼写错误并添加了元素索引。 - David Eisenstat
这就是 hi_pr 和其他所有开源求解器的做法。我目前正在研究并行最大流算法,这是我测试过的静态图表现最高效的表示方法,无论它值得多少。同时,将反向剩余容量或原始边缘容量与剩余容量一起存储也有助于进行反向 BFS,从而避免由于反向边缘查找(例如在 Push-Relabel 的全局重标记阶段)而导致的缓存未命中。 - Niklas B.

2
我使用的表示方法是边缘列表和邻居列表的混合体。我不知道它是否有官方名称,所以我不会给它命名。它能够满足上述所有要求,并且只需要使用数组——这是大多数(如果不是全部)流行编程语言中都存在的结构。我将使用进行说明,但代码应该很容易翻译成其他语言。对于本答案,我假设顶点从0N-1编号,我们的图具有M条边。
我们存储的图将是有向的,因为在处理网络流时,通常边及其反向边具有不同的容量(这些容量加起来等于边的初始容量)。
一条边
由于我们正在处理网络流算法,每条边都有容量(cap)。除此之外,对于每条边,我将存储其目标顶点(to)和另一个我称之为next的值。我们还可以选择添加源顶点,但由于图的表示方式,这不是必需的。我假设所有这些值都适合于int类型:
struct edge {
  // destination vertex
  int to;
  // capacity
  int cap;
  // next edge
  int next;
};

存储图

我将所有边存储在一个数组中,此外我还会有一个额外的数组,用于存储每个顶点邻居列表的“头”元素。我将把带有“头”元素的数组命名为firstfirst应该被初始化为一些不是有效顶点编号的值,例如-1:

int first[N];
// in c++ we can also use memset
for (int i = 0; i < N; ++i) {
  first[i] = -1;
}

由于最大网络流算法的实现方式,对于每条边,我们应该添加一条容量为0的反向边。因此,存储边的数组大小实际上是2*M

edge edges[M * 2];

现在我提出的表示方法有两个关键点:
  1. 我们形成一个给定顶点的邻居的(单向)链表,并且每个链表的头(第一个)元素的索引存储在数组 first 中。
  2. 对于每条边,我们在边的数组中紧跟其反向边添加。
向单向链表添加元素只需在 add_edge 函数中略作修改 - 我们还应该添加反向边。为了简化代码,我将假设我们有一个变量 edges_num,它代表我们已经添加的边数,并且我将像全局变量一样使用它。我实现了一个 add_edge 函数,它接受三个参数 - 源顶点、目标顶点和边的容量:
int edges_num = 0;
inline void add_edge(int from, int to, int cap) {
  edges[edges_num].to = to;
  edges[edges_num].cap = cap;
  edges[edges_num].next = first[from];
  first[from] = edges_num++;

  edges[edges_num].to = from;
  edges[edges_num].cap = 0;
  edges[edges_num].next = first[to];
  first[to] = edges_num++;
}

请注意,反向边的容量通常会被初始化为0。这基本上就是我们使用这种表示法来存储图所需的全部内容。
一个例子
让我们看看两个数组firstedges的内容如何更改:
在添加任何边之前:
first:                edges:
 0  1  2  3  4  5     []
-1 -1 -1 -1 -1 -1   

我们添加容量为7的边0->2。我将分开两个步骤-添加正向边和反向边:

first:                edges:
 0  1  2  3  4  5     [{to: 2, cap: 7, next: -1}]
 0 -1 -1 -1 -1 -1  

现在来说反向边:

first:                edges:
 0  1  2  3  4  5     [{to: 2, cap: 7, next: -1}, {to: 0, cap: 0, next: -1}]
 0 -1  1 -1 -1 -1  

现在让我们添加0->1(容量为5):

first:                edges:
 0  1  2  3  4  5     [{2, 7, -1}, {0, 0, -1}, {1, 5, 0}, {0, 0, -1}]
 2 -1  1 -1 -1 -1  

请注意,索引为2的边具有下一个值为0,这表明0是具有源0的下一条边。我将继续添加边:
2->1 容量为1:
first:                edges:
 0  1  2  3  4  5     [{2, 7, -1}, {0, 0, -1}, {1, 5, 0}, {0, 0, -1}, {1, 1, 1},
 2  5  4 -1 -1 -1      {2, 0, -1}]

现在,我们快进添加2->3(容量11),2->4(容量8),1->3(容量4),4->5(容量3)和3->5(容量6),按照相同的顺序:

first:                edges:
 0  1  2  3  4  5     [{2, 7, -1}, {0, 0, -1}, {1, 5, 0}, {0, 0, -1}, {1, 1, 1},
 2 10  8 14 12 15      {2, 0, -1}, {3, 11, 4}, {2, 0, -1}, {4, 8, 6}, {2, 0, -1},
                       {3, 4, 5}, {1, 0, 7}, {5, 3, 9}, {4, 0, -1}, {5, 6, 11}, 
                       {3, 0, 13}]

希望这个示例能清晰地展示表示法的工作原理。

遍历所有相邻顶点

对于给定顶点v的所有相邻顶点进行迭代非常简单 - 只需要遍历一个单链表:

for (int cv = first[v]; cv != -1; cv = edges[cv].next) {
   // do something
}

很明显,这个操作在邻居数量上是线性的。

访问反向边

利用反向边总是在直接边之后添加的事实,反向边的索引公式非常简单。在edges中,索引为e的边的反向边是索引为e ^ 1的边。这适用于访问直接边和反向边的反向边。同样,这显然是恒定的,并且非常容易编码。

内存消耗

所需的内存为O(M + N) - 我们有大小为M * 2edges和大小为Nfirst。当然,对于任何合理的图形而言,N < M,因此总体内存复杂度为O(M)。此外,内存消耗将比使用哈希映射作为邻居列表的解决方案少得多(至少两倍)。

总结

这种图形表示实现了所有所需的操作,具有最佳可能的复杂性,并且内存开销很小。该表示法的另一个优点是它仅使用了大多数语言内置的非常基本的结构 - 数组。这个结构也可以用于其他算法,但是对图形算法来说,快速访问反向边特别有用。


我不知道这种表示的正式名称,但我会将其描述为边连续的邻接表表示法。我喜欢它用于动态图,但对于网络流,我倾向于认为头部或尾部连续的邻接表表示法更友好于缓存。 - David Eisenstat
@DavidEisenstat,您所说的“头部或尾部连续对应项”是什么意思?也许您可以将此方法作为另一种备选答案添加进去? - Ivaylo Strandjev
这让我想起了e-maxx :) 我相信这里的想法是尽量少的代码,而不是内存效率,因为在紧密循环中(BFS/DFS邻居扫描或Push-Relabel中的可接受弧搜索),它会产生大量的缓存未命中。 - Niklas B.

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接