在实施Ford-Fulkerson或Dinitz算法以实现最大网络流量时,需要在图表上执行两项操作:
理想情况下,第一个操作相对于邻居数是线性的,第二个操作应该是常数。此外,图形表示所需的存储器应该相对于边缘数量是线性的(注意,对于最大网络流算法的大多数实际应用,我已经看到边缘的数量是顶点数量的线性倍数)。只有满足上述约束条件,所有对两种算法复杂性的估计才会成立。
问题在于,没有一个经典的图表表示能够满足上述要求:
使用邻接矩阵,可以在恒定时间内找到给定边的反向边。但是,迭代所有邻居相对于所有顶点的数量是线性的,并且所需的内存相对于顶点的数量是二次的。
使用这种表示,迭代所有邻居在邻居数量方面不是线性的,并且找到给定边缘的反向边缘也不是常数。
使用这种表示,我们可以在线性时间内迭代所有邻居,并且所需的存储器相对于边缘的数量是线性的。仍然,找到给定边缘的反向边缘将相对于目标顶点的邻居数量是线性的。
稍微修改一下这种表示,我们可以改进 - 如果不是邻居列表,我们保留邻居的一些二叉搜索树,我们可以找到具有对数复杂度的反向边缘。甚至更好 - 如果我们使用哈希映射而不是二进制搜索树,我们将具有不变的摊销复杂性。仍然这种表示感觉不对 - 虽然仍然是线性的,但是哈希映射具有一些内存开销。此外,它仅具有摊销的常数复杂性,因此某些操作实际上可能较慢。
所以我的问题是:什么图形表示适合实现最大网络流算法?
答案 0 :(得分: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
是对称弧的索引。
答案 1 :(得分:2)
我使用的表示有点是边列表和邻居列表之间的混合。它没有我所知道的官方名称所以我不会说出它的名字。它设法满足上述所有要求,并且只需要使用数组 - 这种结构存在于大多数(如果不是全部)流行的编程语言中。我将使用c++
进行说明,但代码应该可以轻松转换为其他语言。对于这个答案,我将假设顶点编号为0
到N-1
,并且我们的图形具有M
个边。
我们存储的图表将被定向为处理网络流时通常边缘及其反向边缘具有不同的容量(并且这些容量总和到边缘的初始容量)。
当我们处理网络流算法时,每个边缘都有容量(cap
)。此外,对于每个边缘,我将存储其目标顶点(to
)和另一个我将称为next
的值。我们还可以选择添加源顶点,但由于图形的表示方式,因此不需要它。我将假设所有这些值都适合int
类型:
struct edge {
// destination vertex
int to;
// capacity
int cap;
// next edge
int next;
};
我将所有边存储在一个数组中,此外我还有一个数组,我存储" head"每个顶点的邻居列表的元素。我将使用" head"来命名数组。元素first
。 first
应使用某个不是有效顶点数的值进行初始化,例如-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];
现在我建议的代表有两个关键因素:
first
中。将一个元素添加到单个链表中,因此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
,因为这通常是初始化的方式。这几乎就是我们使用这种表示来存储图形所需的全部内容。
让我们看看两个数组first
和edges
的内容将如何变化:
在添加任何边缘之前:
first: edges:
0 1 2 3 4 5 []
-1 -1 -1 -1 -1 -1
让我们添加边缘0 - &gt; 2,容量为7.我将分开两个步骤 - 添加直边和反边:
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-&gt; 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- &gt; 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
}
很明显,此操作与邻居数量成线性关系。
使用反直边总是在直边之后添加的事实,反向边的索引的公式非常简单。 e
中索引为edges
的边的反向边是索引为e ^ 1
的边。这适用于访问直边的反向和反向边的反向。同样,这显然是不变的,并且很容易编码。
所需的内存为O(M + N)
- 我们的edges
大小为M*2
,first
大小为N
。当然N < M
代表任何合理的图形,因此整体内存复杂度为O(M)
。此外,内存消耗将比使用邻居列表的哈希映射的解决方案的内存消耗少(至少两倍)。
此图表表示以最大可能的复杂性实现所有必需的操作,并且它几乎没有内存开销。表示的另一个优点是它只使用了大多数语言内置的非常基本的结构 - 数组。该结构也可以用于其他算法,但是快速访问反向边缘对于图算法特别有用。