Python-算法教程-三-

45 阅读1小时+

Python 算法教程(三)

原文:Python Algorithms

协议:CC BY-NC-SA 4.0

九、与 Edsger 和朋友从 A 到 B

两点之间的最短距离正在建设中。

noelie altito

是时候从简介回到第二个问题: 1 喀什到宁波的最短路线怎么找?如果你向任何地图软件提出这个问题,你可能会在不到一秒钟内得到答案。到目前为止,这似乎没有最初那么神秘了,你甚至有工具可以帮助你编写这样的程序。您知道,如果所有路段的长度相同,BFS 会找到最短路径,只要您的图中没有任何环,您就可以使用 DAG 最短路径算法。可悲的是,中国的路线图既包含自行车,也包含长度不等的道路。然而,幸运的是,这一章会给你有效解决这个问题所需的算法!

以免你认为这一章对编写地图软件有好处,考虑一下最短路径的抽象在其他什么情况下可能有用。例如,您可以在任何想要有效浏览网络的情况下使用它,这将包括互联网上所有类型的数据包路由。事实上,网络中充满了这样的路由算法,它们都在幕后工作。但是这种算法也用于不太明显的图形导航,比如让角色在电脑游戏中智能地移动。或者,也许你正试图找到最少的移动次数来解决某种形式的难题?这相当于在它的状态空间中找到最短的路径——这个抽象的图形代表了谜题的状态(节点)和移动(边)。还是在寻找利用货币汇率差异赚钱的方法?本章中的一个算法至少会带你走一段路(见练习 9-1)。

寻找最短路径也是其他算法中的一个重要子例程,这些算法不需要非常像图形。例如,在 n 人和 n 工作 2 之间寻找最佳可能匹配的一个通用算法需要反复解决这个问题。有一次,我开发了一个程序,试图修复 XML 文件,根据需要插入开始和结束标记,以满足一些简单的 XML 模式(规则如“列表项需要包装在列表标记中”)。事实证明,这可以通过使用本章中的一种算法来轻松解决。在运筹学、集成电路制造、机器人学等领域都有应用——只要你能说出来的。这绝对是你想了解的问题。幸运的是,尽管有些算法可能有点困难,但在前面的章节中,你已经完成了许多(如果不是大部分的话)具有挑战性的部分。

最短路径问题有几种类型。例如,您可以在有向和无向图中找到最短路径(就像任何其他类型的路径一样)。然而,最重要的区别来自于你的出发点和目的地。您是否希望找到从一个节点到所有其他节点的最短路径(单源)?从一个节点到另一个节点(单对、一对一、点对点)?从所有节点到一个(单一目的地)?从所有节点到所有其他节点(所有对)?其中两个——单源和所有线对——可能是最重要的。尽管我们有一些解决单对问题的技巧(参见后面的“在中间相遇”和“知道你要去哪里”),但没有保证能让我们比一般的单源问题更快地解决那个问题。当然,单目的地问题等价于单源版本(只需翻转有向情况的边)。所有对的问题可以通过将每个节点作为一个单独的源来解决(我们将会研究这个问题),但是也有专门的算法来解决这个问题。

传播知识

在第四章中,我介绍了放松和逐步提高的思想。在第八章中,你看到了在 Dag 中寻找最短路径的想法。事实上,DAGs 的迭代最短路径算法(清单 8-4 )不仅仅是动态规划的一个原型例子;本章还说明了算法的基本结构:我们在图的边上使用松弛来传播关于最短路径的知识。

让我们回顾一下这是什么样子的。我将使用 dicts 图的 dicts 表示,并使用 dict D来维护距离估计(上限),就像第八章中的一样。另外,我会添加一个前任字典,P,至于第五章中的很多遍历算法。这些前任指针将形成所谓的最短路径树,并允许我们重建与D中的距离相对应的实际路径。然后可以在清单 9-1 中的relax函数中排除松弛。请注意,我将D中不存在的条目视为无限。(当然,我也可以在主算法中将它们都初始化为无穷大。)

清单 9-1 。放松操作

inf = float('inf')
def relax(W, u, v, D, P):
    d = D.get(u,inf) + W[u][v]                  # Possible shortcut estimate
    if d < D.get(v,inf):                        # Is it really a shortcut?
        D[v], P[v] = d, u                       # Update estimate and parent
        return True                             # There was a change!

我们的想法是,通过尝试走捷径穿过u,来改善目前已知的到v的距离。如果这不是一条捷径,没关系。我们只是忽略它。如果捷径,我们记录新的距离并记住我们来自哪里(通过将P[v]设置为u)。我还增加了一点额外的功能:返回值表明是否实际发生了任何变化;这将在以后派上用场(尽管你不会在所有的算法中都需要它)。

下面看看它是如何工作的:

>>> D[u]
7
>>> D[v]
13
>>> W[u][v]
3
>>> relax(W, u, v, D, P)
True
>>> D[v]
10
>>> D[v] = 8
>>> relax(W, u, v, D, P)
>>> D[v]
8

正如你所看到的,对relax的第一次调用将D[v]从 13 提高到 10,因为我通过u找到了一条捷径,我已经(大概)使用 7 的距离到达了这条捷径,而这条捷径距离v只有 3。现在我不知何故发现我可以通过一条长度为 8 的路径到达v。我再次运行relax,但是这一次没有找到快捷方式,所以什么也没有发生。

正如你可能猜测的那样,如果我现在将D[u]设置为 4,并再次运行相同的relaxD[v]将会提高,这次提高到 7,将改进的估计从u传播到v。这种传播就是relax的意义所在。如果你随机放松边,距离(和它们相应的路径)的任何改进将最终在整个图中传播——所以如果你永远保持随机放松,你知道你会有正确的答案。然而,永远是一段很长的时间...

这就是 relax 游戏(在第四章的中简要提及)的用武之地:我们希望通过尽可能少的调用relax来实现正确性。我们能侥幸逃脱的具体数量取决于我们问题的确切性质。例如,对于 Dag,我们可以避开每个边一个调用——这显然是我们所能期望的最好结果。稍后您会看到,对于更一般的图,我们实际上也可以得到更低的值(尽管总运行时间更长,并且不允许负权重)。然而,在深入讨论之前,让我们先来看看一些重要的事实,这些事实可能会有所帮助。在下文中,假设我们从节点s开始,并且我们将D[s]初始化为零,而所有其他距离估计被设置为无穷大。设d(u,v)为从uv的最短路径的长度。

  • d(s,v) <= d(s,u) + W[u,v]。这是一个三角形不等式的例子。
  • d(s,v) <= D[v]。对于除了s之外的vD[v]最初是无限的,只有当我们找到实际的捷径时,我们才减少它。我们从不“作弊”,所以它仍然是一个上限。
  • 如果没有到节点v的路径,那么放松永远不会使D[v]低于无穷大。那是因为我们永远找不到改善D[v]的捷径。
  • 假设到v的最短路径由从su的路径和从uv的边组成。现在,如果在将边缘从u放松到v之前的任何时候D[u]是正确的(即D[u] == d(s,u),那么D[v]在之后的任何时候都是正确的。由P[v]定义的路径也是正确的。
  • [s, a, b, ... , z, v]是从sv的最短路径。假设所有的边(s,a)(a,b)、...,(z,v)中的路径已经被放宽了顺序。然后D[v]P[v]就正确了。如果在此期间执行了其他放松操作,则没有关系。

在继续之前,你应该确保你理解为什么这些陈述是正确的。这可能会使这一章的其余部分更容易理解。

疯狂放松

随意放松有点疯狂。然而,疯狂放松可能不是。假设你放松了所有的边缘。如果你愿意,你可以随意地做这件事——没关系。只要确保你看完了所有的。然后你再做一次——也许是以另一种顺序——但是你又一次穿过了所有的边。一次又一次。直到一切都没有改变。

Image 提示想象一下,每个节点根据自己目前获得的最短路径,不断大声叫价,向其外部邻居提供最短路径。如果任何一个节点得到一个比它已经得到的更好的报价,它就转换它的路径供应商并相应地降低它的出价。

至少对于第一次尝试来说,这似乎不是一个不合理的方法。不过,有两个问题摆在面前:要多久才会有任何改变(如果我们真的到了那一步的话),以及当这一切发生的时候,你能确定你已经得到了正确的答案吗?

我们先考虑一个简单的案例。假设所有的边权重都是相同且非负的。这意味着relax操作只有在找到由较少边组成的路径时才能找到捷径。那么,当我们放松了所有的边缘之后,会发生什么呢?至少,s的所有邻居都有正确答案,并且在最短路径树中将s设置为它们的父节点。根据我们放松边缘的顺序,树可能会蔓延得更远,但我们不能保证这一点。我们再放松一下怎么样?好吧,如果没有别的,这棵树至少会再延伸一层。事实上,在最坏的情况下,最短路径树会一层一层地蔓延,就好像我们在执行一些极其低效的 BFS 一样。对于一个有 n 个节点的图,任何路径的最大边数是 n -1,所以我们知道 n -1 是我们需要的最大迭代次数。

不过,一般来说,我们不能对我们的优势做这么多假设(或者如果可以,我们应该只使用 BFS,它会做得很好)。因为边可以具有不同的(甚至可能是负的)权重,所以后面几轮的relax操作可能会修改前面几轮中的前趋指针集。例如,在一轮之后,s的邻居vP[v]设置为s,但是我们不能确定这是正确的!也许我们会通过其他节点找到一条到v的更短的路径,然后P[v]会被覆盖。那么,在一轮放松所有边缘之后,我们能知道什么?

回想一下上一节列出的最后一个原则:如果我们沿着从s到节点v的最短路径放松所有的边,那么我们的答案(由DP组成)将是正确的。具体来说,在这种情况下,我们将放松所有最短路径上的所有边...由一条边组成的。我们不知道这些路径在哪里,请注意,因为我们(还)不知道有多少条边进入各种最优路径。尽管连接s和它的邻居的一些P边很可能不是最终的,但是我们知道正确的边肯定已经存在了。

故事是这样的。在 k 轮放松图中的每条边之后,我们知道由 k 条边组成的所有最短路径都已经完成。按照我们之前的推理,对于一个有 n 个节点和 m 条边的图,它最多需要 n -1 轮直到我们完成,给我们一个运行时间θ(nm)。当然,这只需要是最坏情况下的运行时间,如果我们添加一个检查:在上一轮中有什么变化吗?如果什么都没有改变,继续下去就没有意义了。我们甚至可能会放弃整个 n -1 计数,而只有依赖于该检查。毕竟,我们刚刚推理出,我们永远不会需要超过 n -1 轮,所以检查将最终停止算法。正确没有吗?不。有一个问题:消极循环。

你看,负循环是最短路径算法的敌人。如果我们没有负循环,那么“没有变化”的条件将会很好,但是加入一个负循环,我们的估计可以永远保持改进。因此...只要我们允许负面边缘(为什么我们不能?),我们需要迭代计数作为保障。关于这一点的好消息是,我们可以使用计数来检测负周期:不是运行 n -1 轮,而是运行 n 轮,看看在最后一次迭代中是否有任何变化。如果我们确实得到了改善(这是我们本不应该得到的),我们会立即得出结论“是一个负面循环造成的!”我们宣布我们的答案无效并放弃。

Image 别误会。即使存在负循环,也完全有可能找到最短路径。答案不允许包含循环,所以负循环不会影响答案。只是在允许负循环的情况下找到最短路径是一个未解决的问题(见第十一章)。

我们现在已经到达了这一章的第一个合适的算法:贝尔曼-福特(见清单 9-2 )。这是一个单源最短路径算法,允许任意有向或无向图。如果图中包含一个负循环,算法将报告这一事实并放弃。

清单 9-2 。贝尔曼-福特算法

def bellman_ford(G, s):
    D, P = {s:0}, {}                            # Zero-dist to s; no parents
    for rnd in G:                               # n = len(G) rounds
        changed = False                         # No changes in round so far
        forin G:                             # For every from-node...
            forin G[u]:                      # ... and its to-nodes...
                if relax(G, u, v, D, P):        # Shortcut to v from u?
                    changed = True              # Yes! So something changed
        if not changed: break                   # No change in round: Done
    else:                                       # Not done before round n?
        raise ValueError('negative cycle')      # Negative cycle detected
    return D, P                                 # Otherwise: D and P correct

请注意,贝尔曼-福特算法的这个实现与许多演示的不同之处在于它包含了changed检查。那张支票给了我们两个好处。首先,如果我们不需要所有的迭代,它让我们提前终止;第二,它让我们检测在最后一次“多余的”迭代中是否发生了任何变化,这表明了一个负循环。(没有这种检查的更常见的方法是添加一段单独的代码来实现最后一次迭代,并带有自己的变更检查。)

因为这个算法是其他几个算法的基础,所以我们要确保清楚它是如何工作的。考虑第二章中的加权图示例。我们可以将其指定为字典中的字典,如下所示:

a, b, c, d, e, f, g, h = range(8)
G = {
    a: {b:2, c:1, d:3, e:9, f:4},
    b: {c:4, e:3},
    c: {d:8},
    d: {e:7},
    e: {f:5},
    f: {c:2, g:2, h:2},
    g: {f:1, h:6},
    h: {f:9, g:8}
}

图表的直观展示见图 9-1 。假设我们调用bellman_ford(G, a)。会发生什么?如果我们想找出更多的细节,我们可以使用调试器,或者也许是tracelogging包。为了简单起见,假设我们添加了几个print语句,向我们显示放松的边界,以及对D的赋值,如果有的话。假设我们也按照排序的顺序迭代节点和邻居(使用sorted),以获得确定性的结果。

9781484200568_Fig09-01.jpg

图 9-1 。一个加权图的例子

然后,我们得到一个打印输出,开始如下所示:

(a,b)    D[b] = 2
(a,c)    D[c] = 1
(a,d)    D[d] = 3
(a,e)    D[e] = 9
(a,f)    D[f] = 4
(b,c)
(b,e)    D[e] = 5
(c,d)
(d,e)
(e,f)
(f,c)
(f,g)    D[g] = 6
(f,h)    D[h] = 6
(g,f)
(g,h)
(h,f)
(h,g)

这是第一轮贝尔曼-福特;如你所见,它一次穿过了所有的边。打印输出将继续下一轮,但不会给D赋值,因此函数返回。这里有些草率:距离估计值D[e]首先被设置为 9,这是从a直接到e的距离。只有先放松(a,b),再放松(b,e),我们才会发现一个更好的选择,即长度为 5 的路径abe。然而,我们已经相当幸运,因为我们只需要一次通过边缘。让我们看看是否可以让事情变得更有趣,并迫使算法在稳定下来之前再做一轮。有什么办法吗?一种方法是:

G[a][b] = 3
G[a][c] = 7
G[c][d] = -4

现在我们有了一条通过fd的好路线,但是我们在第一轮中找不到:

(a,b)    D[b] = 3
(a,c)    D[c] = 7
(a,d)    D[d] = 3
(a,e)    D[e] = 9
(a,f)    D[f] = 4
(b,c)
(b,e)    D[e] = 6
(c,d)
(d,e)
(e,f)
(f,c)    D[c] = 6
(f,g)    D[g] = 6
(f,h)    D[h] = 6
(g,f)
(g,h)
(h,f)
(h,g)

我们已经在第一轮将D[c]降到了 6,但是当我们到达那个点的时候,我们已经放松了(c,d),在那个优势不能给我们任何改善的时候,因为D[c]是 7,D[d]已经是 3。然而,在第二轮,你会看到

(c,d)    D[d] = 2

到了第三轮,事情就会稳定下来。

在离开例子之前,让我们试着引入一个负循环。让我们使用原始权重,并做如下修改:

G[g][h] = -9

让我们去掉不改变D的松弛,让我们在打印输出中加入一些整数。然后我们得到以下结果:

# Round 1:
(a,b)    D[b] = 2
(a,c)    D[c] = 1
(a,d)    D[d] = 3
(a,e)    D[e] = 9
(a,f)    D[f] = 4
(b,e)    D[e] = 5
(f,g)    D[g] = 6
(f,h)    D[h] = 6
(g,h)    D[h] = -3
(h,g)    D[g] = 5
# Round 2:
(g,h)    D[h] = -4
(h,g)    D[g] = 4
# Round 3:
(g,h)    D[h] = -5
(h,g)    D[g] = 3
# Round 4:
(g,h)    D[h] = -6
(h,f)    D[f] = 3
(h,g)    D[g] = 2

...

# Round 8:
(g,h)    D[h] = -10
(h,f)    D[f] = -1
(h,g)    D[g] = -2
Traceback (most recent call last):
  ...
ValueError: negative cycle

我已经删除了一些回合,但我相信你可以看到这种模式:在第三轮之后,ghf的距离估计值反复减少一。鉴于只有 8 个节点,他们甚至在第 8 轮中也这样做,这一事实提醒我们存在负循环。这并不意味着没有解决方案——只是意味着持续放松不会为我们找到它,所以我们提出了一个例外。

当然,只有当我们真的能够到达时,负循环才是一个问题。让我们尝试消除边缘(f,g),例如通过使用del G[f][g]。现在至少f不会参与这个循环,但是我们还有gh来改进彼此的估计,使之超出正确的范围。然而,如果我们也去掉(f,h),我们的问题就消失了!

(a,b)    D[b] = 2
(a,c)    D[c] = 1
(a,d)    D[d] = 3
(a,e)    D[e] = 9
(a,f)    D[f] = 4
(b,e)    D[e] = 5

图还是连通的,负循环还在,只是我们的遍历从来没有到达。如果这让你不舒服,请放心:到gh的距离是正确的。它们都是无限的,这是理所应当的。然而,如果你试图调用bellman_ford(G, g)bellman_ford(G, h),这个循环又可以到达了,所以你会得到一系列的动作,每一轮都有几次更新,最后是负循环异常。

9781484200568_unFig09-01.jpg

枕边细语。 也许我应该试试韦克斯勒?( http://xkcd.com/69

寻找隐藏的匕首

贝尔曼-福特算法很棒。在许多方面,这是本章中最容易理解的算法:放松所有的边,直到我们知道一切都必须是正确的。对于任意图,这是一个很好的算法,但是如果我们可以做一些假设,我们就可以(通常情况下)做得更好。您应该还记得,对于 Dag,单源最短路径问题可以在线性时间内解决。在这一节中,我将处理一个不同的约束。我们仍然可以有周期,但是没有负边权重。(事实上,这是在大量实际应用中出现的情况,例如在引言中讨论的那些。)这不仅意味着我们可以忘记消极的周期忧郁;这将让我们得出某些结论,当不同的距离是正确的,导致运行时间的实质性改善。

我在这里建立的算法,是由算法超级大师 Edsger W. Dijkstra 在 1959 年设计的,可以有几种解释,理解它为什么正确可能有点棘手。我认为把它看作 DAG 最短路径算法的近亲是有用的,重要的区别是它必须发现隐藏的 DAG

你看,即使我们正在处理的图可以有任何它想要的结构,我们可以认为一些边是不相关的。为了开始,我们可以想象我们已经知道从开始节点到其他每个节点的距离。我们当然不知道,但是这种假想的情况可以帮助我们推理。想象一下,根据节点之间的距离,从左到右对节点进行排序。会发生什么?对于一般情况来说——不多。然而,我们假设我们没有负的边权重,这就有所不同了。

因为所有的边都是正的,所以在我们假设的排序中,能够对节点的解做出贡献的唯一节点将位于其左边的*。不可能在右边找到一个节点来帮助我们找到一条捷径,因为这个节点离我们更远,只有当它有一个负后沿时才能给我们一条捷径。后沿对我们来说完全没用,也不是问题结构的一部分。剩下的就是一个 DAG,我们想要使用的拓扑排序正是我们开始时假设的排序:节点按照它们的实际距离排序。该结构的图示见图 9-2 。(我一会儿再回到问号。)*

9781484200568_Fig09-02.jpg

图 9-2 。逐渐揭开隐藏的匕首。节点标有它们的最终距离。因为权重为正,所以后向边(虚线)不会影响结果,因此是不相关的

不出所料,我们现在碰到了解决方案中的主要缺口:它完全是循环的。在揭示基本的问题结构(分解成子问题或找到隐藏的 DAG)时,我们假设我们已经解决了问题。不过,这个推理仍然是有用的,因为我们现在有了特定的东西可以寻找。我们想要找到排序——我们可以用我们可靠的工具——归纳法来找到它!

再次考虑图 9-2 。假设突出显示的节点是我们在归纳步骤中试图识别的节点(意味着较早的节点已经被识别,并且已经有了正确的距离估计)。就像在普通的 DAG 最短路径问题中一样,我们将放松每个节点的所有外边缘,只要我们已经识别出它并确定了它的正确距离。这意味着我们已经放松了所有早期节点的边缘。我们还没有放宽后面的节点的外边缘,但是正如所讨论的,它们无关紧要:这些后面的节点的距离估计是上界,后边缘具有正的权重,所以它们不可能对捷径有贡献。

这意味着(通过前面的松弛特性或第八章中对 DAG 最短路径算法的讨论)下一个节点必须具有正确的距离估计。也就是说,图 9-2 中高亮显示的节点现在肯定已经得到了正确的距离估计,因为我们已经放松了前三个节点的所有边。这是一个非常好的消息,剩下的就是找出是哪个节点。我们还是不知道顺序是什么,记得吗?我们一步一步地进行拓扑排序。

当然,只有一个节点可能是下一个节点: 3 具有最低距离估计的节点。我们知道它是排序中的下一个,我们知道它有一个正确的估计。因为这些估计值是上限,所以后面的节点不可能有更低的估计值。很酷,不是吗?现在,通过归纳,我们解决了这个问题。我们只是按照距离顺序放松每个节点的所有外边缘——这意味着总是接下来选择估计值最低的一个。

这种结构与 Prim 的算法非常相似:带优先级队列的遍历。就像 Prim 的一样,我们知道在我们的遍历中没有发现的节点不会被放松,所以我们(还)对它们不感兴趣。在我们已经发现(并放松)的那些中,我们总是想要优先级最低的那个。在 Prim 的算法中,优先级是链接回遍历树的边的权重;在 Dijkstra 的,优先是距离估计。当然,当我们找到快捷方式时,优先级可以改变(就像新的可能的生成树边可以降低 Prim 的优先级一样),但是就像在清单 7-5 中一样,我们可以简单地将同一个节点多次添加到我们的堆中(而不是试图修改堆条目的优先级),而不会损害正确性或运行时间。结果可以在清单 9-3 中找到。它的运行时间是对数线性的,或者更具体地说,是θ((m+n)LGn),其中 m 是边数, n 是节点数。这里的理由是,您需要一个(对数)堆操作,用于(1)从队列中提取每个节点和(2)释放每个边。 4 只要你有ω(n)条边,对于你从开始节点可以到达θ(n个节点的图,运行时间可以简化为θ(mLGn)。

清单 9-3 。迪杰斯特拉算法

from heapq import heappush, heappop

def dijkstra(G, s):
    D, P, Q, S = {s:0}, {}, [(0,s)], set()      # Est., tree, queue, visited
    while Q:                                    # Still unprocessed nodes?
        _, u = heappop(Q)                       # Node with lowest estimate
        ifin S: continue                     # Already visited? Skip it
        S.add(u)                                # We've visited it now
        forin G[u]:                          # Go through all its neighbors
            relax(G, u, v, D, P)                # Relax the out-edge
            heappush(Q, (D[v], v))              # Add to queue, w/est. as pri
    return D, P                                 # Final D and P returned

Dijkstra 的算法可能类似于 Prim 的算法(为队列设置了另一组优先级),但它也与另一个老宠儿密切相关:BFS 。考虑边权重是正整数的情况。现在,用 w -1 条未加权的边替换一条权重为 w 的边,连接一条虚拟节点路径(见图 9-3 )。我们正在毁掉我们得到一个高效解决方案的机会(见练习 9-3),但是我们知道 BFS 会找到一个正确的解决方案。事实上,它将以与 Dijkstra 算法非常相似的方式完成:它将在每条(原始)边上花费与其权重成比例的时间,因此它将按照与起始节点的距离顺序到达每个(原始)节点。

9781484200568_Fig09-03.jpg

图 9-3 。虚拟节点模拟的边的重量或长度

这有点像沿着每条边设置了一系列多米诺骨牌(多米诺骨牌的数量与重量成比例),然后在开始节点倾斜第一个多米诺骨牌。一个节点可以从多个方向到达,但是我们可以通过观察哪些多米诺骨牌位于其他方向之下来判断哪个方向获胜。

如果我们用这种方法开始,我们可以将 Dijkstra 的算法视为通过“模拟”BFS 或多米诺骨牌(或流动的水或传播的声波,或...),而不必费心单独处理每个虚拟节点(或 domino)。相反,我们可以把我们的优先级队列想象成一条时间轴,在这条时间轴上,我们标记了通过不同的路径到达节点的不同时间。我们向下看一条新发现的边的长度,然后想,“多米诺骨牌什么时候能沿着这条边到达那个节点?”我们将边将花费的时间(边权重)加到当前时间(到当前节点的距离)上,并将结果放在时间轴(我们的堆)上。我们对第一次到达的每个节点都这样做(毕竟,我们只对最短的路径感兴趣),并且我们继续沿着时间轴移动到达其他节点。当我们再次到达同一个节点时,在时间线的后面,我们简单地忽略它。??

我已经清楚了 Dijkstra 算法与 DAG 最短路径算法的相似之处。这在很大程度上是动态编程的应用,尽管递归分解不像 DAG 那样明显。为了得到一个解,它也使用贪婪,因为它总是移动到当前具有最低距离估计的节点。将二进制堆作为优先级队列,甚至有点分而治之的意思;总而言之,这是一个很好的算法,使用了你到目前为止学到的很多东西。花些时间完全理解它是很值得的。

所有人对抗所有人

在下一节中,您将看到一个非常酷的算法,用于查找所有节点对之间的最短距离。这是一种特殊用途的算法,即使图形有很多边也是有效的。不过,在这一节中,我将快速介绍一种方法,将之前的两种算法——Bellman-Ford 和 Dijkstra 的算法——结合成一种真正在稀疏图(即边相对较少的图)中闪耀的算法。这是约翰逊的算法,它似乎在许多算法设计的课程和书籍中被忽略了,但它真的很聪明,而且鉴于你已经知道的东西,你几乎可以免费得到它。

Johnson 算法的动机如下:当解决稀疏图的所有对最短路径问题时,简单地从每个节点使用 Dijkstra 算法实际上是一个非常好的解决方案。这本身并没有激发出新的 ?? 算法...但问题是 Dijkstra 的算法不允许负边缘。对于单源最短路径问题,除了使用 Bellman-Ford 之外,我们没有太多办法。然而,对于所有对的问题,我们可以允许自己做一些初始的预处理,使所有的权重为正。

这个想法是添加一个新的节点 s ,零权重边到所有现有节点,然后从 s 运行贝尔曼-福特。这将给我们一个距离——让我们称之为h(v)——从 s 到我们图中的每个节点 v 。然后我们可以使用 h 来调整每条边的权重:我们定义新的权重如下: w '( uv)=w(uv)+h(u)-h(这个定义有两个非常有用的性质。首先,它向我们保证了每个新的权重w*’(uv )都是非负的(这是根据三角形不等式得出的,正如本章前面所讨论的;另请参见练习 9-5)。第二,我们没有把我们的问题搞砸!也就是说,如果我们用这些新的权重找到最短路径,那些路径也将是用原始的权重的最短路径(尽管有其他长度)。这是为什么呢?*

这可以用一个叫做伸缩总和 的好主意来解释:一个像(A-b)+(b-c)+的总和...+ ( y - z )会像望远镜一样坍缩,给我们一个z。原因是,每隔一个被加数,前面加一次加号,后面加一次减号,所以它们的和都是零。在约翰逊的算法中,同样的事情会发生在每一条修改了边的路径上。对于这样一条路径中的任何一条边( uv ),除了第一条或最后一条,都会通过加上 h ( u )减去 h ( v )来修改权重。下一个边缘v 作为其第一个节点,并将加上 h* ( v ),将其从总和中移除。类似地,前一条边将减去 h ( u ),移除该值。*

唯一有点不同的两条边(在任何路径中)是第一条和最后一条。第一个不是问题,因为 h ( s )将为零,并且 w ( sv )对于所有节点 v 被设置为零。但是最后一个呢?没问题。是的,我们将以最后一个节点 v 减去 h ( v )而结束,但是所有在该节点结束的路径都是如此——最短路径仍然是最短的。

转换也不会丢弃任何信息,所以一旦我们使用 Dijkstra 算法找到了最短路径,我们就可以反向转换所有的路径长度。使用类似的伸缩论证,我们可以看到,通过基于转换后的权重从我们的答案中加上 h ( v )并减去 h ( u ),我们可以获得从 uv 的最短路径的实际长度。这给了我们在清单 9-4 中实现的算法。 6

清单 9-4 。约翰逊算法

from copy import deepcopy

def johnson(G):                                 # All pairs shortest paths
    G = deepcopy(G)                             # Don't want to break original
    s = object()                                # Guaranteed unused node
    G[s] = {v:0 forin G}                     # Edges from s have zero wgt
    h, _ = bellman_ford(G, s)                   # h[v]: Shortest dist from s
    del G[s]                                    # No more need for s
    forin G:                                 # The weight from u ...
        forin G[u]:                          # ... to v ...
            G[u][v] += h[u] - h[v]              # ... is adjusted (nonneg.)
    D, P = {}, {}                               # D[u][v] and P[u][v]
    forin G:                                 # From every u ...
        D[u], P[u] = dijkstra(G, u)             # ... find the shortest paths
        forin G:                             # For each destination ...
            D[u][v] += h[v] - h[u]              # ... readjust the distance
    return D, P                                 # These are two-dimensional

Image 注意不需要检查对bellman_ford的调用是否成功或者是否发现了负循环(在这种情况下 Johnson 的算法不起作用),因为如果图中有的负循环,bellman_ford就会引发异常。

假设 Dijkstra 算法的θ(mLGn)运行时间,Johnson 的只是慢了 n 的一个因子,给我们θ(MnLGn),这比 Floyd-Warshall 的三次运行时间(稍作讨论)要快,对于稀疏图(即边相对较少的图)。 7

约翰逊算法中使用的变换与 A*算法的潜在功能密切相关(参见本章后面的“知道你要去哪里”),它类似于第十章中的最小成本二分匹配问题中使用的变换。这里的目标也是确保正的边权重,但是情况略有不同(边权重在迭代之间不断变化)。

牵强附会的子问题

虽然 Dijkstra 的算法肯定是基于动态编程的原则,但由于需要随时发现子问题的顺序(或子问题之间的依赖关系),这一事实在一定程度上被掩盖了。我在这一节中讨论的算法是由 Roy、Floyd 和 Warshall 独立发现的,是 DP 的一个典型例子。它基于记忆递归分解,并且在其普通实现中是迭代的。它的形式看似简单,但设计极其巧妙。在某些方面,它是基于第八章讨论的“进或出”原则,但由此产生的子问题,至少乍一看,似乎是高度人为和牵强的。

在许多动态规划问题中,我们可能需要寻找一组递归相关的子问题,但是一旦我们找到它们,它们通常看起来很自然。例如,想想 DAG 最短路径中的节点,或者最长公共子序列问题的前缀对。后者说明了一个有用的原则,可以扩展到不太明显的结构:限制我们可以使用的元素。例如,在 LCS 问题中,我们限制前缀的长度。在背包问题中,这稍微人工一些:我们为对象发明了一个排序,并将自己限制在第 k 个对象上。然后,子问题由这个“允许集”和背包容量的一部分来参数化。

在所有对最短路径问题中,我们可以使用这种形式的限制,以及“输入或输出”原则,来设计一组非显而易见的子问题:我们对节点进行任意排序,并限制我们可以使用多少个节点——也就是说,首先是k—作为形成路径的中间节点。现在,我们已经使用三个参数对我们的子问题进行了参数化:

  • 起始节点
  • 结束节点
  • 我们被允许通过的最高节点数

除非你对我们的进展有所了解,否则增加第三项可能看起来完全没有意义——它怎么能帮助我们限制我们被允许做的事情呢?我相信你可以看到,这个想法是分割解决方案空间,将问题分解成子问题,然后将这些子问题连接成一个子问题图。链接是通过基于“输入或输出”的思想创建递归依赖来实现的:节点 k ,输入还是输出?

d ( uvk )为从节点 u 到节点 v 的最短路径的长度,如果只允许使用第 k 个第一节点作为中间节点。我们可以将问题分解如下:

d ( uvk)= min(d(uvk1), d ( ukk1)+d

就像在背包问题中,我们正在考虑是否包括 k 。如果我们不包括它,我们简单地使用现有的解决方案,我们可以使用 k 找到没有的最短路径,这是 d ( uvk—1)。如果包含了,我们必须使用从到 k 的最短路径(即 d ( ukk—1))以及从 k* (即 d ( kv请注意,在所有这三个子问题中,我们使用的是第k1 个节点,因为要么我们排除了第 k 个节点,要么我们明确地将它用作端点而不是中间节点。这保证了我们对子问题的大小排序(即拓扑排序)——没有循环。

你可以在清单 9-5 中看到结果算法。(实现使用第八章中memo装饰器。)注意,我假设节点是范围 1 内的整数... n 这里。如果你使用其他节点对象,你可以有一个包含任意顺序节点的列表V,然后在min部分使用V[k-1]V[k-2]代替kk-1。还要注意返回的D图的形式是D[u,v]而不是D[u][v]。我还假设这是一个全权重矩阵,所以从uv没有边的话D[u][v]就是inf。如果你愿意,你可以很容易地修改这一切。

清单 9-5 。Floyd-Warshall 算法的记忆递归实现

def rec_floyd_warshall(G):                                # All shortest paths
    @memo                                                 # Store subsolutions
    def d(u,v,k):                                         # u to v via 1..k
        if k==0: return G[u][v]                           # Assumes v in G[u]
        return min(d(u,v,k-1), d(u,k,k-1) + d(k,v,k-1))   # Use k or not?
    return {(u,v): d(u,v,len(G)) forinforin G}   # D[u,v] = d(u,v,n)

让我们试试迭代版本。假设我们有三个子问题参数( uvk ),我们将需要三个for循环来迭代处理所有子问题。似乎有理由认为我们需要存储所有的子解,这导致了立方内存的使用,但就像 LCS 问题一样,我们可以减少这种情况。 8 我们的递归分解只将阶段 k 中的问题与阶段k1 中的问题联系起来。这意味着我们只需要两张距离图——一张用于当前迭代,一张用于前一次迭代。但是我们可以做得更好...

就像使用relax时一样,我们在这里寻找快捷方式。阶段 k 的问题是“与我们现有的相比,通过节点 k 会提供捷径吗?”如果D是我们当前的距离图,而C是之前的距离图,我们得到了:

D[u][v] = min(D[u][v], C[u][k] + C[k][v])

现在考虑一下,如果我们始终使用单一距离图,会发生什么情况:

D[u][v] = min(D[u][v], D[u][k] + D[k][v])

意思现在稍微不太清楚,看起来有点绕,但是真的没有问题。我们在寻找捷径,对吗?值D[u][k]D[k][v]将是真实路径的长度(因此是最短距离的上限),所以我们没有作弊。此外,它们不会大于C[u][k]C[k][v],因为我们从不增加地图中的值。因此,唯一可能发生的事情就是D[u][v]更快地找到正确答案——这当然没问题。结果是我们只需要一个单一的二维距离图(也就是说,二次方内存与三次方内存相反),我们将通过寻找快捷方式来不断更新它。在许多方面,结果非常(尽管不完全)像贝尔曼-福特算法的二维版本(见清单 9-6)。

清单 9-6 。弗洛伊德-沃肖尔算法,仅距离

def floyd_warshall(G):
    D = deepcopy(G)                             # No intermediates yet
    forin G:                                 # Look for shortcuts with k
        forin G:
            forin G:
                D[u][v] = min(D[u][v], D[u][k] + D[k][v])
    return D

您会注意到,我开始使用图形本身的副本作为候选距离图。这是因为我们还没有尝试通过任何中间节点,所以唯一的可能性是直接边,由原始权重给出。还要注意,关于顶点是数字的假设完全消失了,因为我们不再需要明确地参数化我们所处的阶段。只要我们在先前结果的基础上,尝试为每个可能的中间节点创建快捷方式,解决方案将是相同的。我希望你会同意最终的算法是超级简单的,尽管它背后的推理可能并不简单。

不过,如果有一个P矩阵也不错,就像约翰逊的算法一样。正如在许多 DP 算法中一样,构建实际的解决方案很好地依赖于计算最优值——您只需要记录做出了哪些选择。在这种情况下,如果我们通过k找到一个快捷方式,那么P[u][v]中记录的前任必须替换为P[k][v],也就是属于快捷方式最后“一半”的前任。最终算法可以在清单 9-7 中找到。原始的P获得由边链接的任何不同节点对的前任。此后,每当D更新时,P就会更新。

清单 9-7 。弗洛伊德-沃肖尔算法

def floyd_warshall(G):
    D, P = deepcopy(G), {}
    forin G:
        forin G:
            if u == v or G[u][v] == inf:
                P[u,v] = None
            else:
                P[u,v] = u
    forin G:
        forin G:
            forin G:
                shortcut = D[u][k] + D[k][v]
                if shortcut < D[u][v]:
                    D[u][v] = shortcut
                    P[u,v] = P[k,v]
    return D, P

注意这里使用shortcut < D[u][v]而不是shortcut <= D[u][v]很重要。尽管后者仍然会给出正确的距离,但您可能会遇到最后一步是D[v][v],这将导致P[u,v] = None的情况。

Floyd-Warshall 算法可以很容易地被修改来计算图的传递闭包(Warshall 算法)。请参见练习 9-9。

在中间相遇

Dijkstra 算法的子问题解决方案——及其未加权特例 BFS 的子问题解决方案——在图上向外扩散,就像池塘里的涟漪。如果你想要的只是从 A 到 B,或者使用习惯的节点名称,从 st ,这意味着“波纹”必须经过许多你并不真正感兴趣的节点,如图 9-4 中的左图所示。另一方面,如果你同时从起点和终点开始遍历(假设你可以反向遍历边),在某些情况下,两个波纹可以在中间相遇,这样可以节省很多工作,如右图所示。

9781484200568_Fig09-04.jpg

图 9-4 。单向和双向“波纹”,表示通过遍历找到从 s 到 t 的路径所需的工作

请注意,尽管图 9-4 的“图形证据”可能令人信服,但它当然不是一个正式的论证,也没有给出任何保证。事实上,尽管本节和下一节的算法为单源、单目的地最短路径提供了实际的改进,但没有哪种点对点算法比普通的单源问题具有更好的渐近最坏情况行为。当然,两个半径为原半径一半的圆将有一半的总面积,但是图形的行为不一定像欧几里得平面。我们当然希望在运行时间上有所改进,但这就是所谓的启发式算法。这种算法是基于有根据的猜测,并且通常根据经验进行评估。我们可以确信它不会比 Dijkstra 的算法更差,渐进地说——这都是为了提高实际运行时间。

为了实现 Dijkstra 算法的这个双向版本,让我们首先稍微修改一下原始版本,使其成为一个生成器,这样我们就可以只提取“meetup”所需的子解。这类似于第五章中的一些遍历函数,比如iter_dfs ( 清单 5-5 )。这种迭代行为意味着我们可以完全丢弃距离表,只依赖优先级队列中保存的距离。为了简单起见,我不会在这里包含前置信息,但是您可以通过向堆中的元组添加前置来轻松扩展解决方案。要获得距离表(就像最初的dijkstra),你可以简单地调用dict(idijkstra(G, s))。代码见清单 9-8 。

清单 9-8 。作为生成器实现的 Dijkstra 算法

from heapq import heappush, heappop

def idijkstra(G, s):
    Q, S = [(0,s)], set()                       # Queue w/dists, visited
    while Q:                                    # Still unprocessed nodes?
        d, u = heappop(Q)                       # Node with lowest estimate
        ifin S: continue                     # Already visited? Skip it
        S.add(u)                                # We've visited it now
        yield u, d                              # Yield a subsolution/node
        forin G[u]:                          # Go through all its neighbors
            heappush(Q, (d+G[u][v], v))         # Add to queue, w/est. as pri

注意,我已经完全放弃了使用relax——它现在隐含在堆中。或者说,heappush是新的relax。重新添加具有更好估计的节点意味着它将优先于旧条目,这相当于用 relax 操作覆盖旧条目。这类似于第七章中 Prim 算法的实现。

既然我们已经一步一步地接触到了 Dijkstra 的算法,构建一个双向版本就不是太难了。我们在原始算法的 to 和 from 实例之间交替,扩展每个波纹,一次扩展一个节点。如果我们继续下去,这会给我们两个完整的答案——从 st 的距离,以及从 ts 的距离,如果我们沿着边向后走。当然,这两个答案是一样的,这使得整个练习毫无意义。想法是一旦涟漪相遇就停下来。一旦idijkstra的两个实例产生了同一个节点,跳出循环似乎是个好主意。

这就是算法中唯一真正的问题所在:你从 st 开始遍历,一直移动到下一个最近的节点,所以一旦两个算法都移动到(也就是说,产生了)同一个节点,那么这两个算法沿着最短路径相遇似乎是合理的,对吗?毕竟,如果您只是从 s 开始遍历,那么您可以在到达 t 时立即终止(即idijkstra让步)。可悲的是,正如很容易发生的那样,我们的直觉(或者至少是我的直觉)在这里让我们失望了。图 9-5 中的简单例子应该可以澄清这种可能的误解;但是哪里的是最短路径呢?我们怎么知道停下来是安全的?

9781484200568_Fig09-05.jpg

图 9-5 。第一个会合点(突出显示的节点)不一定沿着最短路径(突出显示的边)

事实上,一旦两个实例相遇就结束遍历是没问题的。然而,为了找到最短路径,打个比方,当算法执行时,我们需要保持警惕。我们需要保持到目前为止找到的最佳距离,每当一条边( uv )放松,并且我们已经有了从 su 的距离(通过向前遍历)和从 vt 的距离(通过向后遍历),我们需要检查用( uv )连接路径是否会改进我们的最佳解决方案

事实上,我们可以把停止标准收紧一点(见练习 9-10)。我们不需要等待两个实例都访问同一个节点,我们只需要查看它们已经走了多远——也就是它们已经到达的最近距离。这些不能减少,所以如果他们的总和至少和我们目前找到的最佳路径一样大,我们找不到更好的,我们就完了。

尽管如此,仍有一个挥之不去的疑问。前面的论点可能会让你相信我们不可能通过继续下去找到任何更好的路径,但是我们怎么能确定我们没有错过任何路径呢?假设我们找到的最佳路径长度为。导致终止的两个距离是 lr ,所以我们知道 l + rm (停止判据)。现在,假设有一条从 st 的路径,该路径比 m 短*。为此,路径必须包含一条边( uv ),使得 d ( su ) < ld ( vt ) < r (见练习 9-11)。这意味着 uv 分别比当前节点更靠近 st ,所以这两个节点肯定都已经被访问过(产生过)。在两者都被放弃的时候,我们对迄今为止的最佳解决方案的维护应该已经找到了这条道路——一个矛盾。换句话说,算法是正确的。*

到目前为止,这整个跟踪最佳路径的业务需要我们访问 Dijkstra 算法的内部。我更喜欢idijkstra给我的抽象,所以我将坚持使用这种算法的最简单版本:一旦我从两次遍历中接收到相同的节点就停止,然后在之后扫描最佳路径*,检查连接两半的所有边。如果您的数据集是可以从双向搜索中获益的那种类型,那么这种扫描不太可能成为太大的瓶颈,但是当然,您可以随意使用分析器并进行调整。完成的代码可以在清单 9-9 中找到。来自itertoolscycle函数给了我们一个迭代器,它将从其他迭代器重复地给我们值,从头到尾重复地产生它的值。在这种情况下,这意味着我们在向前和向后方向之间循环。*

清单 9-9 。Dijkstra 算法的双向版本

from itertools import cycle

def bidir_dijkstra(G, s, t):
    Ds, Dt = {}, {}                              # D from s and t, respectively
    forw, back = idijkstra(G,s), idijkstra(G,t)  # The "two Dijkstras"
    dirs = (Ds, Dt, forw), (Dt, Ds, back)        # Alternating situations
    try:                                         # Until one of forw/back ends
        for D, other, step in cycle(dirs):       # Switch between the two
            v, d = next(step)                    # Next node/distance for one
            D[v] = d                             # Remember the distance
            ifin other: break                 # Also visited by the other?
    except StopIteration: return inf             # One ran out before they met
    m = inf                                      # They met; now find the path
    forin Ds:                                 # For every visited forw-node
        forin G[u]:                           # ... go through its neighbors
            if notin Dt: continue             # Is it also back-visited?
            m = min(m, Ds[u] + G[u][v] + Dt[v])  # Is this path better?
    return m                                     # Return the best path

注意,这段代码假设G是无向的(也就是说,所有的边在两个方向上都是可用的),并且所有的节点u都是G[u][u] = 0。你可以很容易地扩展算法,这样就不需要那些假设了(练习 9-12)。

知道你要去哪里

到目前为止,您已经看到了遍历的基本思想是非常通用的,通过简单地使用不同的队列,您可以得到几种有用的算法。例如,对于 FIFO 和 LIFO 队列,您可以获得 BFS 和 DFS,通过适当的优先级,您可以获得 Prim 和 Dijkstra 算法的核心。本节描述的算法称为 A*,通过再次调整优先级来扩展 Dijkstra 的算法。

如前所述,A算法使用了类似于 Johnson 算法的思想,尽管目的不同。Johnson 的算法转换所有边权重以确保它们是正的,同时确保最短路径仍然是最短的。在 A中,我们希望以类似的方式修改边,但这一次的目标不是使边为正——我们假设它们已经为正(因为我们是在 Dijkstra 算法的基础上构建的)。不,我们想要的是通过使用我们要去的地方的信息来引导遍历到正确的方向:我们想要使远离我们的目标节点的边比那些使我们更接近它的边更昂贵。

Image 注意这类似于第十一章中讨论的分支定界策略中使用的最佳优先搜索。

当然,如果我们真的知道哪些边缘会让我们走得更近,我们可以通过贪婪来解决整个问题。我们只是沿着最短的路径前进,不走任何旁道。A算法的好处在于,它填补了 Dijkstra 算法和这种假设的理想情况之间的空白,在 Dijkstra 算法中,我们不知道我们要去哪里,而在这种假设的理想情况下,我们知道我们要去哪里的确切位置。它引入了一个势函数* ,或者说启发式 h ( v ),这是我们对剩余距离的最佳猜测, d ( vt )。一分钟后你会看到,Dijkstra 的算法作为特例“掉出”A*,当 h ( v ) = 0 时。同样,如果我们可以用魔法设置h(v)=d(vt ),那么算法将直接从 s 前进到 t

那么,它是如何工作的呢?我们定义修改后的边缘权重来获得伸缩和,就像我们在约翰逊算法中所做的那样(尽管你应该注意到这里符号被调换了): w '( uv ) = w ( uv)-h(u)+h(v伸缩和保证了最短路径仍然是最短的(就像在 Johnson 的例子中一样),因为所有路径长度都改变了相同的量,h(t)-h(s)。如您所见,如果我们将启发式规则设置为零(或者,实际上,任何常数),权重都不会改变。

显而易见,这种调整反映了我们奖励方向正确的优势、惩罚方向错误的优势的意图。对于每个边的权重,我们加上潜在的下降*(启发式),这类似于重力的工作方式。如果你把一个弹球放在一个凹凸不平的桌子上,它将开始向一个降低其势能的方向运动。在我们的例子中,算法将被引导到导致剩余距离下降的方向——这正是我们想要的。*

A算法等价于修改图上的 Dijkstra 算法,所以如果 h可行就是正确的,意味着 w '( uv )对于所有节点 uv 都是非负的。按照D*[v]-h(s)+h(v)的递增顺序扫描节点,而不是简单的 D v 。因为 h ( s )是一个常见的常量,所以我们可以忽略它,只需将 h ( v )添加到我们现有的优先级中。这个总和是我们对从 s 经由 vt 的最短路径的最佳估计。如果 w '( uv )可行, h ( v )也将是 d ( vt )上的一个下界(见练习 9-14)。

实现所有这些的一个(非常常见的)方法是使用类似于原始的dijkstra的东西,并在将节点推到堆上时简单地将 h ( v )添加到优先级中。最初的距离估计在D中仍然可用。然而,如果我们想简化事情,使用堆(如在idijkstra中),我们需要实际使用权重调整,以便对于一个边( uv ),我们也减去 h ( u )。这是我在清单 9-10 中采用的方法。如你所见,在返回距离之前,我已经确保删除了多余的 h ( t )。(考虑到a_star函数正在打包的算法 punch,它相当简短而甜蜜,你说呢?)

[清单 9-10 。A*算法

from heapq import heappush, heappop
inf = float('inf')

def a_star(G, s, t, h):
    P, Q = {}, [(h(s), None, s)]                # Preds and queue w/heuristic
    while Q:                                    # Still unprocessed nodes?
        d, p, u = heappop(Q)                    # Node with lowest heuristic
        ifin P: continue                     # Already visited? Skip it
        P[u] = p                                # Set path predecessor
        if u == t: return d - h(t), P           # Arrived! Ret. dist and preds
        forin G[u]:                          # Go through all neighbors
            w = G[u][v] - h(u) + h(v)           # Modify weight wrt heuristic
            heappush(Q, (d + w, u, v))          # Add to queue, w/heur as pri
    return inf, None                            # Didn't get to t

正如你所看到的,除了对u == t增加的检查之外,与 Dijkstra 算法的唯一不同之处实际上是对权重的调整。换句话说,如果你愿意,你可以在修改了权重的图上使用直接点对点版本的 Dijkstra 算法(也就是说,包括u == t检查的算法),而不是为 A*使用单独的算法。

当然,为了从 A*算法中获得任何好处,您需要一个好的启发式算法。当然,这个函数应该是什么在很大程度上取决于你试图解决的确切问题。例如,如果您正在导航一个路线图,您会知道从一个给定节点到您的目的地的直线欧几里得距离必须是一个有效的启发式(下限)。事实上,这对于平面上的任何运动都是一个有用的启发,比如怪物在电脑游戏世界里走来走去。但是,如果有很多死胡同和曲折,这个下限可能不是很准确。(参见“如果你好奇……”部分寻找替代方案。)

A算法也用于搜索解空间,我们可以将其视为抽象(或隐含)图。例如,我们可能想要解决魔方 9 或者刘易斯·卡罗尔所谓的字梯*谜题。事实上,让我们试一试后一个难题(没有双关语的意思)。

单词阶梯是从一个起始单词开始构建的,比如 lead ,而你想以另一个单词结束,比如 gold 。你逐步建立阶梯,每一步都使用实际的单词。要从一个单词转到另一个单词,您可以替换单个字母。(还有其他版本,允许你添加或删除字母,或者允许你交换字母。)所以,举例来说,你可以通过单词 loadgoadleadgold 。如果我们把某个字典中的每个单词解释为我们图中的一个节点,我们可以在相差一个字母的所有单词之间添加边。我们可能不想显式地构建这样的结构,但是我们可以“伪造”它,如清单 9-11 所示。

清单 9-11 。带有单词阶梯路径的隐式图

from string import ascii_lowercase as chars

def variants(wd, words):                        # Yield all word variants
    wasl = list(wd)                             # The word as a list
    for i, c in enumerate(wasl):                # Each position and character
        for oc in chars:                        # Every possible character
            if c == oc: continue                # Don't replace with the same
            wasl[i] = oc                        # Replace the character
            ow = ''.join(wasl)                  # Make a string of the word
            if ow in words:                     # Is it a valid word?
                yield ow                        # Then we yield it
        wasl[i] = c                             # Reset the character

class WordSpace:                                # An implicit graph w/utils

    def __init__(self, words):                  # Create graph over the words
        self.words = words
        self.M = dict()                         # Reachable words

    def __getitem__(self, wd):                  # The adjacency map interface
        if wd not in self.M:                    # Cache the neighbors
            self.M[wd] = dict.fromkeys(self.variants(wd, self.words), 1)
        return self.M[wd]

    def heuristic(self, u, v):                  # The default heuristic
        return sum(a!=b for a, b in zip(u, v))  # How many characters differ?

    def ladder(self, s, t, h=None):             # Utility wrapper for a_star
        ifis None:                           # Allows other heuristics
            def h(v):
                return self.heuristic(v, t)
        _, P = a_star(self, s, t, h)            # Get the predecessor map
        ifis None:
            return [s, None, t]                 # When no path exists
        u, p = t, []
        whileis not None:                    # Walk backward from t
            p.append(u)                         # Append every predecessor
            u = P[u]                            # Take another step
        p.reverse()                             # The path is backward
        return p

WordSpace类的主要思想是它作为一个加权图工作,这样它可以与我们的a_star实现一起使用。如果G是一个WordSpaceG['lead']将是一个字典,其他单词(如'load''mead')作为关键字,1 作为每个边的权重。我使用的默认启发式算法只是简单地计算单词不同的位置。

使用WordSpace类很容易,只要你有某种单词表。许多 UNIX 系统都有一个名为/usr/share/dict/words/usr/dict/words的文件,每行一个单词。如果你没有这样的文件,你可以从http://ftp.gnu.org/gnu/aspell/dict/en那里得到一个。如果你没有这个文件,你可以在网上找到它(或类似的东西)。例如,您可以像这样构造一个WordSpace(删除空白并将所有内容规范化为小写):

>>> words = set(line.strip().lower() for line in open("/usr/share/dict/words"))
>>> G = WordSpace(words)

当然,如果你得到了你不喜欢的单词阶梯,你可以随意地从其中删除一些单词。 10 一旦你有了自己的WordSpace,该出发了:

>>> G.ladder('lead', 'gold')
['lead', 'load', 'goad', 'gold']

很整洁,但也许不是那么令人印象深刻。现在尝试以下方法:

>>> G.ladder('lead', 'gold', h=lambda v: 0)

我只是简单地用一个完全没有信息的方法代替了启发式方法,基本上是把我们的 A* 变成了 BFS(或者,更确切地说,是在一个未加权图上运行的 Dijkstra 算法)。在我的电脑上(和我的单词表),运行时间的差异是相当明显的。事实上,使用第一种(默认)启发式算法时的加速因子接近 100!11

摘要

与前几章相比,这一章更集中于在网络状的结构和空间中寻找最佳路径,换句话说,就是图中的最短路径。本章算法中使用的一些基本思想和机制在本书前面已经介绍过了,所以我们可以逐步构建我们的解决方案。所有最短路径算法共有的一个基本策略是寻找条捷径,或者通过使用relax函数或类似函数(大多数算法都这样做),通过沿着路径的一个新的可能的倒数第二个节点,或者通过考虑一条由两个子路径组成的捷径,往返于某个中间节点(Floyd-Warshall 的策略)。基于松弛的算法以不同的方式处理事物,基于它们对图形的假设。贝尔曼-福特算法简单地尝试依次构建每个边的捷径,并重复这个过程至多 n -1 次迭代(如果仍有改进的潜力,则报告负循环)。

你在第八章中看到,有可能比这更有效率;对于 Dag,只要我们按照拓扑排序的顺序访问节点,就可以只放松每条边一次。对于一般的图来说,topsort 是不可能的,但是如果我们不允许负边,我们可以找到一种拓扑排序,这种排序尊重那些重要的边——也就是说,根据节点与起始节点的距离对节点进行排序。当然,我们不知道这种排序是如何开始的,但我们可以通过始终选取剩余的距离估计值最低的节点来逐步构建它,就像 Dijkstra 的算法一样。我们知道这是要做的事情,因为我们已经放松了所有可能的前一个的外边缘,所以排序顺序中的下一个现在必须有正确的估计——唯一可能的是具有最低上限的那个。

当查找所有节点对之间的距离时,我们有几个选项。例如,我们可以从每个可能的开始节点运行 Dijkstra 算法。这对于相当稀疏的图来说非常好,事实上,即使边不都是正的,我们也可以使用这种方法!我们首先运行 Bellman-Ford,然后调整所有的边,这样我们(1)保持路径的长度等级(最短的仍然是最短的),并且(2)使边权重为正。另一种选择是使用动态规划,就像在 Floyd-Warshall 算法中一样,其中每个子问题都由它的起始节点、结束节点和允许我们通过的其他节点的数量(以某种预定的顺序)来定义。

没有已知的方法可以找到从一个节点到另一个节点的最短路径,渐近地,比找到从开始节点到所有其他节点的最短路径更好。尽管如此,还是有一些启发性的方法可以在实践中给予改进。其中之一是双向搜索*,从开始节点和结束节点“同时”执行遍历,然后在两者相遇时终止,从而减少需要访问的节点数量(或者我们希望如此)。另一种方法是使用启发式“最佳优先”方法,使用启发式函数引导我们在不太有希望的节点之前找到更有希望的节点,如 A算法。

*如果你好奇的话...

大多数算法书都会给你寻找最短路径的基本算法的解释和描述。不过,一些更高级的启发式算法,比如 A*,通常会在人工智能书籍中讨论。在那里,您还可以找到关于如何使用这种算法(以及其他相关算法)来搜索复杂的解决方案空间的全面解释,这些解决方案空间看起来一点也不像我们一直在使用的显式图结构。对于人工智能这些方面的坚实基础,我衷心推荐罗素和诺维格的精彩著作。对于 A*算法的启发,你可以尝试在网上搜索“最短路径”以及“地标”或“ALT”

如果你想在渐近前沿推动 Dijkstra 算法,你可以研究 Fibonacci 堆。如果您将二进制堆替换为 Fibonacci 堆,Dijkstra 的算法将获得改进的渐进运行时间,但您的性能仍有可能受到影响,除非您正在处理非常大的实例,因为 Python 的堆实现非常快,而用 Python 实现的 Fibonacci 堆(相当复杂的事情)可能不会如此。但仍然值得一看。

最后,您可能希望将 Dijkstra 算法的双向版本与 A*的启发式机制结合起来。不过,在此之前,您应该对这个问题进行一些研究——这里有一些陷阱,可能会使您的算法无效。Nannicini 等人的论文(见“参考文献”)提供了一个(稍微先进的)关于这一点和使用基于界标的试探法(以及随时间变化的图形的挑战)的信息来源。

练习

9-1.在某些情况下,货币之间的汇率差异使得从一种货币兑换到另一种货币成为可能,这种情况会持续下去,直到一种货币回到原来的货币,从而获得利润。你如何使用贝尔曼-福特算法来检测这种情况的存在?

9-2.如果多个节点与起始节点的距离相同,在 Dijkstra 算法中会发生什么?现在还正确吗?

9-3.为什么像图 9-3 中的那样用虚拟节点来表示边长是一个非常糟糕的主意?

9-4.如果用一个未排序的列表而不是二进制堆来实现 Dijkstra 算法,它的运行时间会是多少?

9-5.为什么我们能确定约翰逊算法中调整后的权重是非负的?有可能出错的情况吗?

9-6.在约翰逊的算法中, h 函数基于贝尔曼-福特算法。为什么我们不能用一个任意的函数呢?它会消失在伸缩总和中吗?

9-7.实现 Floyd-Warshall 的记忆化版本,这样它可以像迭代一样节省内存。

9-8.扩展 Floyd-Warshall 的记忆版本来计算一个P表,就像迭代一样。

9-9.你将如何修改 Floyd-Warshall 算法,使其检测路径的存在,而不是寻找最短路径(Warshall 算法)?

9-10.为什么双向 Dijkstra 算法的更严格停止标准的正确性意味着原始算法的正确性?

9-11.在 Dijkstra 算法的双向版本的正确性证明中,我假设了一条比我们迄今为止发现的最佳路径更短的假设路径,并声明它必须包含一条边( uv ),使得 d ( su ) < ld ( v为什么会这样呢?

9-12.重写bidir_dijkstra,这样就不需要输入图是对称的,有零权重的自边。

9-13.实现 BFS 的双向版本。

9-14.为什么在 w 可行的情况下, h ( v )是 d ( vt )上的一个下界?

参考

迪杰斯特拉,E. W. (1959)。关于图的两个问题的注记。数字数学,1(1):269-271。

Nannicini,g .,Delling,d .,Liberti,l .,和 Schultes,D. (2008)。时间相关快速路径的双向 A搜索。在第七届国际实验算法会议记录*中,计算机科学讲义,334-346 页。

Russell,s .和 Norvig,P. (2009 年)。人工智能:现代方法,第三版。普伦蒂斯霍尔。


1 别急,我会在第十一章里重温“瑞典游”的问题。

2 最小费用二部匹配问题,在第十章中讨论。

好吧,我在这里假设不同的距离。如果多个节点具有相同的距离,则可能有多个候选节点。练习 9-2 要求你展示接下来会发生什么。

4 你可能会注意到,为了保持代码简单,回溯S的边在这里也被放松了。这对正确性或渐进运行时间没有影响,但是如果您愿意,您可以自由地重写代码来跳过这些节点。

*在 Dijkstra 算法的一个更传统的版本中,每个节点只被添加一次,但是它的估计在堆内被修改,如果一些更好的估计出现并覆盖它,你可以说这个路径被忽略。

6 如你所见,我只是实例化object来创建节点s。每个这样的实例都是唯一的(也就是说,它们在==下不相等),这使得它们对于添加的虚拟节点以及其他形式的 sentinel 对象非常有用,这些对象需要与所有合法值不同。

7 称一个图稀疏的一个常见标准是,例如 mO ( n )。不过,在这种情况下,只要 mO(n2/LGn,约翰逊的意志(渐近地)与弗洛伊德-沃肖尔的意志相匹配,这就允许了相当多的优势。另一方面,Floyd-Warshall 具有非常低的恒定开销。

你也可以在内存化版本中做同样的内存节省。参见练习 9-7。

9 实际上,当我为第一版写这一章时,已经证明(使用 35 年的 CPU 时间)魔方最难的位置需要 20 步(见www.cube20.org)。

10 举个例子,在处理我的炼金术例子时,我去掉了像阿尔盖多多拉这样的词。

11 那个数是 100,不是 100 的阶乘。(当然也不是 100 的 11 次方。)***

十、匹配、切割和流动

快乐的生活是个人的创造,无法从食谱中复制。

—米哈里·契克森米哈,心流:最佳体验心理学

虽然上一章给出了一个问题的几种算法,但这一章描述的是一种具有多种变化和应用的算法。核心问题是寻找网络中的最大流,我将使用的主要解决策略是 Ford 和 Fulkerson 的增广路径法。在解决整个问题之前,我将引导您解决两个更简单的问题,它们基本上是特例(它们很容易被简化为最大流)。这些问题,即二分匹配和不相交路径,本身有许多应用,可以通过更专门的算法来解决。您还会看到最大流问题有一个对偶,即最小割问题,这意味着您将同时自动解决这两个问题。最小割问题有几个有趣的应用,看起来与最大流问题非常不同,即使它们真的密切相关。最后,我会给你一些扩展最大流问题的方法,通过增加成本,寻找最大流的最便宜的,为最小成本二分匹配等应用铺平道路。

最大流问题及其变种几乎有无穷的应用。Douglas B. West 在他的书中(见第二章中的“参考文献”)给出了一些相当明显的例子,比如确定道路和通信网络的总容量,甚至是研究电路中的电流。Kleinberg 和 Tardos(参见第一章中的“参考资料”)解释了如何将形式主义应用于调查设计、航班调度、图像分割、项目选择、棒球淘汰以及分配医生休假。Ahuja、Magnanti 和 Orlin 已经写了关于这个主题的最全面的书籍之一,并且涵盖了工程、制造、调度、管理、医学、国防、通信、公共政策、数学和运输等不同领域的 100 多个应用。虽然算法适用于图形,但这些应用不需要完全像图形一样。例如,谁会认为图像分割是一个图形问题?在本章后面的“一些应用”一节中,我将带您浏览这些应用。如果您对如何使用这些技术感到好奇,您可能想在继续阅读之前快速浏览一下该部分。

贯穿本章的总体思想是,我们试图最大限度地利用网络,从一端移动到另一端,尽可能多地推动某种物质——无论是二分匹配的边、边不相交的路径还是流的单元。这和上一章谨慎的图形探索有点不同。尽管如此,增量改进的基本方法仍然存在。我们反复寻找方法来稍微改进我们的解决方案,直到它不能变得更好。你会看到取消*的想法是关键——我们可能需要删除以前解决方案的部分内容,以使其整体更好。

Image 注意我在本章的实现中使用了福特和富尔克森的标记方法。另一个关于增加路径搜索的观点是,我们正在穿越一个剩余网络。这个想法将在本章后面的边栏“剩余网络”中解释。

二分匹配

我已经向你展示了双方匹配的想法,在第四章第一节中的暴躁的电影观众和第七章第三节中的稳定的婚姻问题中。一般来说,一个图的匹配 是边的节点不相交子集。也就是说,我们选择一些边,使得没有两条边共享一个节点。这意味着每条边匹配两对——因此得名。一种特殊的匹配适用于二分图,这种图可以分成两个独立的节点集(没有边的子图),如图 10-1 中的图。这正是我们在电影观众和婚姻问题中一直在处理的那种匹配,比一般的那种要容易处理得多。当我们谈论二分匹配时,我们通常想要一个最大的匹配,一个包含最大数量的边的匹配。这意味着,如果可能的话,我们想要一个完全匹配的*,一个所有节点都匹配的节点。这是一个简单的问题,但在现实生活中很容易发生。比方说,你正在给项目分配人员,图表显示了谁想做什么。完美的搭配会让每个人都满意。 1*

*9781484200568_Fig10-01.jpg

图 10-1 。一个二部图,有一个(非最大)匹配(粗边)和一条从 b 到 f 的增广路径(高亮)

我们可以继续使用稳定婚姻问题的比喻——我们将放弃稳定,努力让每个人都找到他们可以接受的对象。为了想象发生了什么,假设每个男人都有一枚订婚戒指。我们想要的是让每个男人把他的戒指给其中一个女人,这样就没有一个女人有一个以上的戒指。或者,如果这是不可能的,我们想把尽可能多的戒指从男人身上转移到女人身上,仍然禁止任何女人拥有一个以上的戒指。一如既往,为了解决这个问题,我们开始寻找某种形式的归约或归纳步骤。一个显而易见的想法是找出一对注定在一起的恋人,从而减少我们需要担心的情侣数量。然而,要保证任何一对都是最大匹配的一部分并不容易,除非,例如,它是完全隔离的,像图 10-1 中的 dh

更适合这种情况的方法是迭代改进,,如第四章中所讨论的。这与第九章中放松的使用密切相关,因为我们将一步一步地改进我们的解决方案,直到我们无法再改进为止。我们还必须确保改进停止的唯一原因是解决方案是最优的——但我会回到这一点。让我们从寻找一些循序渐进的改进方案开始。让我们说,在每一轮中,我们试图将一枚额外的戒指从男子手中转移到女子手中。如果我们幸运的话,这将立刻给我们答案——也就是说,如果每个男人都把戒指给那个他认为最合适的女人。但是,我们不能让任何浪漫的倾向蒙蔽了我们的视线。这种方法很可能不会那么顺利。再次考虑图 10-1 中的图表。假设在我们的前两次迭代中, ae 一个环, cg 一个环。这给了我们一个由两对组成的试探性匹配(用黑色的粗边表示)。现在我们转向 b 。他要做什么?

让我们遵循一个有点类似于第七章中提到的盖尔-沙普利算法的策略,当有新的追求者接近时,女性可以改变主意。事实上,让我们命令他们总是做。所以当 b 询问 g 时,她将当前戒指归还给 c ,接受来自 b 的戒指。换句话说,她取消了c 的婚约。(这种取消的思想对于本章所有的算法都是至关重要的。)但是现在 c 是单一的,如果我们要确保迭代确实带来改进,我们就不能接受这种新情况。我们立即四处寻找 c 的新伴侣,在这个例子中是 e 。但是如果 c 将他归还的戒指交给 e ,她必须取消与 a 的婚约,归还他的戒指。他又把这个传递给 f ,我们就完成了。在这一次之字形交换之后,戒指沿着高亮显示的边缘来回传递。此外,我们现在已经将夫妇的数量从两个增加到三个( a + f,b + g ,以及 c + e )。

事实上,我们可以从这个特别的程序中提取出一个通用的方法。首先,我们需要找到一个不匹配的人。(如果不能,我们就完了。)然后,我们需要找到一些约定和取消的交替序列,以便我们以约定结束。如果我们能发现这一点,我们就知道肯定有一个约会比取消的多,增加了一对。我们只是尽可能长时间地寻找这样的曲折。

我们正在寻找的之字形是从左侧一个不匹配的节点到右侧一个不匹配的节点的路径。按照接合环的逻辑,我们看到路径只能移动到右边穿过已经在匹配中的而不是的边(建议),并且它只能移动左边穿过在匹配中的边(取消)。这样的路径(如图 10-1 中突出显示的路径)被称为扩充路径,,因为它扩充了我们的解决方案(也就是说,它增加了参与计数),我们可以通过遍历找到扩充路径。我们只需要确保我们遵循规则——我们不能遵循右边匹配的边或左边不匹配的边。

剩下的就是确保我们确实能够找到这样的扩充路径,只要还有改进的空间。虽然这看起来似乎很合理,但是为什么一定是这样的还不是很明显。我们想表明的是,如果有改进的空间,我们可以找到一条增强的途径。这意味着我们有一个当前的匹配 M ,还有一些我们还没有找到的更大的匹配 M 。现在考虑这两者之间的对称差中的边——也就是说,这些边在其中一个对称差中,但不在两个对称差中。让我们称 M 中的边为红色,称M’中的边为绿色。

这种混乱的红绿边缘实际上会有一些有用的结构。例如,我们知道每个节点最多关联两条边,每种颜色一条边(因为它不可能有两条来自相同匹配的边)。这意味着我们有一个或多个相连的组件,每个组件都是曲折的路径或交替颜色的循环。因为 MM 大,我们必须至少有一个组件的绿色边比红色边多,唯一可能发生的方式是在一条路径中——一条以绿色边开始和结束的奇数长度的路径。

你看到了吗?没错。这条绿-红-绿的道路将会是一条增广的道路。它的长度是奇数,所以一端在男性一侧,一端在女性一侧。第一条和最后一条边是绿色的,这意味着它们不是我们原始匹配的一部分,所以我们可以开始增加。(这基本上是我对所谓的伯奇引理的理解。)

在实施这一战略时,有很大的创造性空间。一个可能的实现如清单 10-1 所示。tr函数的代码可以在清单 5-10 中找到。参数XY是节点的集合(可迭代对象),代表图G的二分。运行时间可能不明显,因为边在执行期间被打开和关闭,但是我们知道在每次迭代中有一对被添加到匹配中,所以迭代次数是 O ( n ),对于 n 节点。假设 m 条边,寻找增广路径基本上就是遍历一个连通分支,也就是 O ( m )。那么,总共运行时间是 O ( nm )。

清单 10-1 。使用扩充路径寻找最大二部匹配

from itertools import chain

def match(G, X, Y):                             # Maximum bipartite matching
    H = tr(G)                                   # The transposed graph
    S, T, M = set(X), set(Y), set()             # Unmatched left/right + match
    while S:                                    # Still unmatched on the left?
        s = S.pop()                             # Get one
        Q, P = {s}, {}                          # Start a traversal from it
        while Q:                                # Discovered, unvisited
            u = Q.pop()                         # Visit one
            ifin T:                          # Finished augmenting path?
                T.remove(u)                     # u is now matched
                break                           # and our traversal is done
            forw = (v forin G[u] if (u,v) not in M)  # Possible new edges
            back = (v forin H[u] if (v,u) in M)      # Cancellations
            forin chain(forw, back):         # Along out- and in-edges
                ifin P: continue             # Already visited? Ignore
                P[v] = u                        # Traversal predecessor
                Q.add(v)                        # New node discovered
        while u != s:                           # Augment: Backtrack to s
            u, v = P[u], u                      # Shift one step
            ifin G[u]:                       # Forward edge?
                M.add((u,v))                    # New edge
            else:                               # Backward edge?
                M.remove((v,u))                 # Cancellation
    return M                                    # Matching -- a set of edges

Image 柯尼希定理陈述了对于二部图,最大匹配问题的对偶就是最小顶点覆盖问题。换句话说,这些问题是等价的。

不相交的路径

寻找匹配的增广路径法也可以用于更一般的问题。最简单的概括可能是计算条边不相交的路径 而不是条边2 边不相交的路径可以共享节点,但不能共享边。在这种更一般的情况下,我们不再需要把自己局限于二分图。然而,当我们允许一般的有向图时,我们可以自由地指定路径的起点和终点。最简单(也是最常见)的解决方案是指定两个特殊的节点, st ,称为接收器。(这样的图通常被称为 s - t 图,或 s - t 网络。)然后,我们要求所有路径从 s 开始,到 t 结束(隐含地允许路径共享这两个节点)。这个问题的一个重要应用是确定一个网络的边连通性——在该图断开之前(或者,在这种情况下,在 s 不能到达 t 之前)可以删除(或者“失败”)多少条边?

另一个应用是在多核 CPU 上寻找通信路径。您可能有许多二维布局的内核,并且由于通信的工作方式,不可能通过相同的交换点路由两个通信通道。在这些情况下,找到一组不相交的路径至关重要。注意,这些路径可能更自然地被建模为顶点不相交,而不是边不相交。详见练习 10-2。此外,只要您需要将每个源核心与一个特定的接收核心配对,您就有了一个被称为 多商品流问题的版本,这里不讨论这个问题。(参见“如果你好奇…”以获得一些提示。)

*你可以在算法中直接处理多个源和汇,就像清单 10-1 中的一样。如果这些源和汇中的每一个都只能包含在一条路径中,并且您不关心哪个源与哪个汇配对,那么将问题简化为单源、单汇的情况会更容易。你可以通过添加 st 作为新节点,并引入从 s 到你的所有源和从你的所有汇到 t 的边。路径的数量将是相同的,重建您正在寻找的路径只需要再次剪掉 st 。事实上,这种减少使得最大匹配问题成为不相交路径问题的特例。如你所见,解决问题的算法也非常相似。

不要考虑完整的路径,能够孤立地看待问题的较小部分将是有用的。我们可以通过引入两个规则来做到这一点:

  • 除了 st 之外,进入任何节点的路径数必须等于从该节点出去的路径数。
  • 至多路径可以通过任何给定的边。

考虑到这些限制,我们可以使用遍历来找到从 st 的路径。在某种程度上,我们无法找到更多的路径,而不与我们已经拥有的一些路径重叠。不过,我们可以再次使用上一节中的增加路径的思想。参见,例如,图 10-2 。第一轮遍历建立了一条从 s 经由 cbt 的路径。现在,任何进一步的进展似乎都被这条路径阻碍了——但是增加路径的想法让我们通过取消从 cb边来改进解决方案。

9781484200568_Fig10-02.jpg

图 10-2 。一个发现了一条路径(粗边)和一条增广路径(高亮显示)的 s-t 网络

取消的原理就像二分匹配一样。当我们寻找一条增加的路径时,我们从 s 移动到 a ,然后到 b 。在那里,我们被边缘 bt 挡住了。此时的问题是 b 有来自 ac两条输入路径,但只有一条输出路径。通过取消边缘 cb ,我们已经解决了 b 的问题,但是现在在 c 处有一个问题。这和我们在二分匹配中看到的级联效应是一样的。在这种情况下, c 有一条来自 s 的传入路径,但是没有传出路径——我们需要为该路径找到一个路径。我们通过继续我们的路径通过 dt 来做到这一点,如图 10-2 中的突出显示所示。

如果你在某个节点 u增加一个输入边沿或者取消一个输出边沿,那么那个节点就会过度拥挤。它将有更多的路径进入而不是离开,这是不允许的。你可以通过增加一个输出边缘或者取消一个输入边缘来解决这个问题。总而言之,这解决了从 s 开始寻找路径的问题,沿着未使用的边的方向,使用过的边逆着的方向。任何时候你能找到这样一条增加的路径,你也会发现一条额外的不相交的路径。

清单 10-2 展示了实现这个算法的代码。和以前一样,tr函数的代码可以在清单 5-10 中找到。

清单 10-2 。使用标记遍历来计数边不相交路径以寻找扩充路径

from itertools import chain

def paths(G, s, t):                             # Edge-disjoint path count
    H, M, count = tr(G), set(), 0               # Transpose, matching, result
    while True:                                 # Until the function returns
        Q, P = {s}, {}                          # Traversal queue + tree
        while Q:                                # Discovered, unvisited
            u = Q.pop()                         # Get one
            if u == t:                          # Augmenting path!
                count += 1                      # That means one more path
                break                           # End the traversal
            forw = (v forin G[u] if (u,v) not in M)  # Possible new edges
            back = (v forin H[u] if (v,u) in M)      # Cancellations
            forin chain(forw, back):         # Along out- and in-edges
                ifin P: continue             # Already visited? Ignore
                P[v] = u                        # Traversal predecessor
                Q.add(v)                        # New node discovered
        else:                                   # Didn't reach t?
            return count                        # We're done
        while u != s:                           # Augment: Backtrack to s
            u, v = P[u], u                      # Shift one step
            ifin G[u]:                       # Forward edge?
                M.add((u,v))                    # New edge
            else:                               # Backward edge?
                M.remove((v,u))                 # Cancellation

为了确保我们已经解决了这个问题,我们仍然需要证明相反的情况——只要还有改进的空间,就总会有增加的途径。展示这一点最简单的方法是使用连通性的概念:我们必须去掉多少条边才能将 st 分开(这样就没有路径从 st )?任何这样的集合都代表一个 s - t 割,一个划分为两个集合 ST ,其中 S 包含 sT 包含 t 。我们称从 ST 的边为定向边分隔符*。然后,我们可以证明以下三个语句是等价的:*

  • 我们已经发现了 k 条不相交的路径,并且有一个大小为 k 的边分隔符。
  • 我们已经找到了不相交路径的最大数量。
  • 没有增加的路径。

我们主要想表明的是,最后两个语句是等价的,但有时通过第三个语句更容易,比如本例中的第一个语句。

很容易看出第一个暗示着第二个。姑且称分隔符 F 。任何 s - t 路径在 F 中必须至少有一条边,这意味着 F 的大小至少与不相交的 s - t 路径的数量一样大。如果分隔符的大小与我们找到的不相交路径的数量相同,显然我们已经达到了最大值。

证明第二个陈述暗示第三个陈述很容易通过矛盾来完成。假设没有改进的空间,但我们仍然有一个增强的路径。如前所述,这条增加的路径可以用来改进解决方案,所以我们有一个矛盾。

唯一需要证明的是,最后一个陈述隐含了第一个陈述,这就是整个连通性思想作为垫脚石的好处。想象你已经执行了算法,直到你用完了增加的路径。让 S 是你在最后一次遍历中到达的节点集,让 T 是剩余的节点。很明显,这是一个 s - t 切。考虑这个切口的边缘。从 ST 的任何前向边都必须是您发现的不相交路径之一的一部分。如果不是,您应该在遍历过程中跟随它。出于同样的原因,从 TS 的任何边都不能是其中一条路径的一部分,因为你可以取消它,从而到达 T 。换句话说,从 ST 的所有边都属于你的不相交路径,并且因为其他方向的边都不属于你的不相交路径,所以前向边必须都属于它们自己的路径,这意味着你有 k 条不相交路径和一个大小为 k 的分隔符。

这可能有点复杂,但是直觉告诉我们,如果我们找不到一条增加的路径,那么在某个地方一定有一个瓶颈,我们一定已经填补了它。无论我们怎么做,都无法获得更多路径通过这个瓶颈,所以算法一定找到了答案。(这个结果是门格尔定理的一个版本,,它是最大流最小割定理的一个特例,稍后你会看到。)

那么这一切要持续多长时间?每次迭代由从 s 开始的相对直接的遍历组成,对于 m 边,其运行时间为 O ( m )。每一轮都给了我们另一条不相交的路径,而且明明最多有 O ( m ),意思是运行时间是O(m2)。练习 10-3 要求你证明在最坏的情况下这是一个紧界。

Image 门格尔定理是对偶的另一个例子:从 st 的边不相交路径的最大数等于 st 之间的最小割。这是最大流最小割定理的一个特例,稍后讨论。

最大流量

这是这一章的中心问题。它形成了二分匹配和不相交路径的一般化,并且是最小割问题的镜像(下一节)。与不相交路径情况的唯一区别在于,我们没有将每条边的容量 设置为 1,而是将其设为任意正数。如果容量是一个正整数,您可以把它看作是可以通过它的路径的数量。更一般地说,这里的比喻是某种形式的物质在网络中流动,从源头到汇点,容量代表了有多少单元可以流过给定边的限制。(你可以把这看作是配对中来回传递的订婚戒指的概括。)一般来说,流本身就是若干个流单元对每个单元的赋值(即从边到数的函数或映射),而流的大小大小则是通过网络推送的总量。(例如,这可以通过找到流出源头的净流量来找到。)注意,尽管流网络通常被定义为有向网络,你也可以在无向网络中找到最大流(练习 10-4)。

让我们看看如何解决这个更普遍的情况。一个天真的方法是简单地分割边缘,就像第九章中 BFS 的天真延伸(图 9-3)。不过现在,我们想把它们纵向分开,如图 10-3 中的所示。就像带有串行虚拟节点的 BFS 给你一个 Dijkstra 算法如何工作的好主意一样,我们带有并行虚拟节点的扩充路径算法非常接近用于寻找最大流的完整福特-富尔克森算法的工作方式。不过,与 Dijkstra 的情况一样,实际算法可以一次处理更多的流,这意味着虚拟节点方法(一次只能饱和一个单位的容量)的效率低得令人绝望。

9781484200568_Fig10-03.jpg

图 10-3 。由虚拟节点模拟的边缘容量

让我们看一下技术细节。就像在 0-1 的情况下,我们有两条规则来确定我们的流如何与边和节点交互。如您所见,它们与不相交的路径规则非常相似:

  • 除了 st 之外,进入任何节点的流量必须等于从该节点流出的流量。**
  • 最多有 c ( e )个单位的流量可以通过任意给定的边。

这里, c ( e )是 edge e容量。就像对于不相交的路径,我们需要沿着边的方向,所以沿着边的流返回总是零。一个遵守我们两个规则的流程被称为可行

不过,这可能是你需要放松和集中注意力的地方。我接下来要说的其实并不复杂,但可能会有点混乱。我被允许逆着一条边的方向推动水流,只要已经有一些水流向正确的方向。你知道这是怎么回事吗?我希望前两节已经为你做好了准备——这完全是取消流量的问题。如果我有一个单位的流量从 a 流向 b ,我可以取消那个单位,实际上是把一个单位推向另一个方向。最终结果为零,因此没有实际流向错误的方向(这是完全禁止的)。

这个想法让我们创建增加的路径,就像以前一样:如果你在某个节点 u 沿输入边增加 k 个单位的流量,或者取消输出边上的 k 个单位的流量,那个节点就会溢出。流入的流量会比流出的多,这是不允许的。您可以通过沿着输出边添加 k 个流单位或者通过在输入边上取消 k 个流单位来解决这个问题。这正是你在 0-1 的情况下所做的,除了 k 总是 1。

在图 10-4 中,显示了同一流程网络的两种状态。在第一状态中,流已经沿着路径s-c-b-t被推动,给出总流值 2。这种流动阻碍了沿边缘的任何进一步改进。正如你所看到的,增加的路径包括一个向后的边缘。通过取消从 cb 的一个流量单位,我们可以从 c 经由 dt 发送一个额外的单位,达到最大值。

9781484200568_Fig10-04.jpg

图 10-4 。通过增广路径增广前后的流量网络(突出显示)

如本节所解释的,一般的福特-富尔克森方法 不给出任何运行时间保证。事实上,如果无理数容量(包含平方根等)被允许,迭代增加可能永远不会终止。对于实际应用来说,使用无理数可能不太现实,但即使我们把自己限制在有限精度的浮点数,甚至是整数,我们还是会遇到麻烦。考虑一个非常简单的网络,有源、宿和另外两个节点, uv 。两个节点都有从源到宿的边,都具有容量 k 。我们还有一个从 uv 的单位产能优势。如果我们继续选择增加通过边缘 uv 的路径,在每次迭代中增加和取消一个单位的流,这将在终止之前给我们 2 k 次迭代。

这个运行时间有什么问题?它是伪多项式——实际问题规模的指数。我们可以很容易地提高容量,从而增加运行时间,而不需要占用太多的空间。令人恼火的是,如果我们更聪明地选择了增补路线(例如,完全避开边缘 uv ,我们将在两轮中完成,而不管容量 k

幸运的是,这个问题有一个解决方案,它给我们一个多项式的运行时间,不管容量是多少(甚至是无理数!).事实是,Ford-Fulkerson 并不是一个完全指定的算法,因为它的遍历是完全任意的。如果我们选择 BFS 作为遍历顺序(从而总是选择最短的扩充路径),我们最终会得到所谓的埃德蒙兹-卡普算法, ,这正是我们正在寻找的解决方案。对于 n 节点和 m 边,埃德蒙兹-卡普在 O ( nm 2 )时间内运行。然而,这种情况并不完全明显。为了得到彻底的证明,我推荐在 Cormen 等人的书中查找该算法(参见第一章中的“参考文献”)。大致思路是:每条最短增广路径在 O ( m )时间内找到,当我们沿着它增广流量时,至少有一条边是饱和的(流量达到容量)。每次边缘饱和时,离源的距离(沿着增加的路径)必须增加,这个距离最多是 O ( n )。因为每个边沿最多可以饱和 O ( n )次,所以我们得到的是 O ( nm )次迭代,总运行时间为O(nm2)。

对于一般的福特-富尔克森方法(因此也是埃德蒙兹-卡普算法)的正确性证明,参见下一节,最小切割。但是,正确性证明确实假设了终止性,如果您避免不合理的容量或者如果您简单地使用 Edmonds-Karp 算法(它具有确定的运行时间),这是有保证的。

清单 10-3 中给出了一个基于 BFS 的扩充遍历。清单 10-4 中的显示了完整的福特-富尔克森方法的实现。为简单起见,假设 st 是不同的节点。默认情况下,该实现使用基于 BFS 的增强遍历,这为我们提供了 Edmonds-Karp 算法。主函数(ford_fulkerson)非常简单,与本章前面的两个算法非常相似。主while循环继续下去,直到不可能找到一条增加的路径,然后返回流程。每当发现一条增加的路径时,它被回溯到s,将路径的容量加到每个前向边上,并从每个反向边上减去(消除)它。

清单 10-3 中的bfs_aug函数类似于前面算法中的遍历。它使用一个deque来获得 BFS,并使用P图构建遍历树。如果有一些剩余容量(G[u][v]-f[u,v] > 0,它只遍历前向边缘,如果有一些流量要取消(f[v,u] > 0),它只遍历后向边缘。标记 包括设置遍历前趋(在P中)和记住有多少流可以传输到这个节点(存储在F中)。这个流量值是(1)我们设法传输到前一个的流量和(2)连接边上的剩余容量(或反向流量)的最小值。这意味着一旦我们到达t,路径的总松弛度(我们可以推动通过它的额外流量)是F[t]

Image 注意如果你的能力是整数,增量也总是整数,导致一个整数流。这是使最大流问题(以及解决它的大多数算法)得到如此广泛应用的性质之一。

清单 10-3 。用 BFS 和标号寻找增广路径

from collections import deque
inf = float('inf')

def bfs_aug(G, H, s, t, f):
    P, Q, F = {s: None}, deque([s]), {s: inf}   # Tree, queue, flow label
    def label(inc):                             # Flow increase at v from u?
        ifinor inc <= 0: return           # Seen? Unreachable? Ignore
        F[v], P[v] = min(F[u], inc), u          # Max flow here? From where?
        Q.append(v)                             # Discovered -- visit later
    while Q:                                    # Discovered, unvisited
        u = Q.popleft()                         # Get one (FIFO)
        if u == t: return P, F[t]               # Reached t? Augmenting path!
        forin G[u]: label(G[u][v]-f[u,v])    # Label along out-edges
        forin H[u]: label(f[v,u])            # Label along in-edges
    return None, 0                              # No augmenting path found

清单 10-4 。福特-富尔克森法(默认为埃德蒙兹-卡普算法)

from collections import defaultdict

def ford_fulkerson(G, s, t, aug=bfs_aug):       # Max flow from s to t
    H, f = tr(G), defaultdict(int)              # Transpose and flow
    while True:                                 # While we can improve things
        P, c = aug(G, H, s, t, f)               # Aug. path and capacity/slack
        if c == 0: return f                     # No augm. path found? Done!
        u = t                                   # Start augmentation
        while u != s:                           # Backtrack to s
            u, v = P[u], u                      # Shift one step
            ifin G[u]: f[u,v] += c           # Forward edge? Add slack
            else:         f[v,u] -= c           # Backward edge? Cancel slack

剩余网络

一个经常用来解释福特-富尔克森方法及其相关方法的抽象概念是剩余网络。剩余网络Gf是相对于原始流网络 G 以及流 f 定义的,并且是在寻找扩充路径时使用的表示遍历规则的方式。在 G

换句话说,我们在 G 中的特殊增广遍历现在变成了在 G f 中的完全正常的遍历。当在剩余网络中不再有从源到宿的路径时,该算法终止。虽然这个想法主要是形式上的,使得使用普通的图论来推理增强成为可能,但是如果你愿意(练习 10-5),你也可以显式地实现它,作为实际图形的动态视图。这将允许您直接在剩余网络上使用 BFS 的现有实现,以及(稍后您将看到)贝尔曼-福特和迪克斯特拉。

最小切割

就像零一流产生了门格尔定理一样,更一般的流问题给了我们福特和富尔克森的最大流最小截定理,我们可以用类似的方式证明它。

  • 我们已经找到了一个大小为 k 的流,并且有一个容量为 k 的流*。* ** 我们已经找到了最大流量。* 没有增加的路径。*

*证明这一点将给我们两件事:它将表明福特-富尔克森方法是正确的,它意味着我们可以用它来寻找最小割,这本身就是一个有用的问题。(我会回到这个话题。)

和零一的情况一样,第一个明显意味着第二个。每个流量单位都必须通过任何一个 s - t 切割,所以如果我们有一个容量切割 k ,那就是流量的上限。如果我们有一个流量等于一个切割的容量,那么这个流量一定是最大的,而这个切割一定是最小的。这就是所谓的二元性的一个例子。

从第二个陈述(我们已经达到最大值)到第三个陈述(没有增加的路径)的含义再次被矛盾证明。假设我们已经达到了最大值,但是仍然有一个增加的路径。然后我们可以用这条路来增加我们的流量,这是一个矛盾。

最后一步(没有增加路径意味着我们有一个等于流的切割)再次使用遍历来构造一个切割。也就是说,我们让 S 是我们在最后一次迭代中可以到达的节点集, T 是余数。任何穿过切口的前沿都必须是饱和的,否则我们就会穿过它。同样,任何后向边都必须是空的。这意味着通过切口的流量正好等于其容量,这就是我们想要展示的。

最小割有几个看起来不像最大流问题的应用。例如,考虑以最小化两个处理器之间通信的方式将进程分配给两个处理器的问题。假设其中一个处理器是 GPU,进程在两个处理器上有不同的运行时间。有些更适合 CPU,而有些应该在 GPU 上运行。然而,可能会有这样的情况,一个安装在 CPU 上,一个安装在 GPU 上,但是两者之间会进行大量的通信。在这种情况下,我们可能希望将它们放在同一个处理器上,只是为了降低通信成本。

我们如何解决这个问题?例如,我们可以建立一个无向流网络,将 CPU 作为源,将 GPU 作为宿。每个进程都有一条通向源和接收器的边,其容量等于在该处理器上运行所需的时间。我们还在进行通信的进程之间添加了边,其容量代表了它们在不同处理器上的通信开销(额外的计算时间)。然后,最小割会以总成本尽可能小的方式在两个处理器上分配进程——如果我们不能归结为最小割问题,这是一个不小的任务。

总的来说,你可以把全流网络形式主义看作是一种特殊的算法机器,你可以用它通过归约来解决其他问题。任务变成了构建某种形式的流网络,其中最大流或最小割代表原始问题的解决方案。

二元性

这一章有几个对偶的例子:最大二部匹配是最小二部顶点覆盖的对偶,最大流是最小割的对偶。还有几个类似的例子,比如最大张力问题,它是最短路径问题的对偶。一般来说,对偶涉及两个优化问题,原始的和对偶的,其中两个有相同的优化成本,解决一个将解决另一个。更具体地说,对于一个最大化问题 A 和一个最小化问题 B,如果 A 的最优解小于或等于 B 的最优解,我们有弱对偶*,如果它们相等(对于最大流最小割的情况),我们有强对偶。如果你想知道更多关于对偶性的知识(包括一些更高级的内容),看看 Go 和 Yang 的《最优化中的对偶性与变分不等式》。*

*最便宜的流和指派问题 4

在离开流量这个话题之前,我们先来看看一个重要的、相当明显的延伸;我们来找最便宜的最大流量。也就是说,我们仍然希望找到最大流量,但是如果有多种方法可以达到相同的流量大小,我们希望找到最便宜的方法。我们通过向边添加成本来形式化这一点,并将总成本定义为所有边 e 上的w(e)f(e)之和,其中 wf 分别是成本和流函数。也就是说,成本是给定边缘上每单位流量的*。*

一个直接的应用是二分匹配问题的扩展。我们可以继续使用零一流公式,但会增加每个边的成本。然后,我们有了最小成本二分匹配(或分配)问题的解决方案,在引言中暗示:通过找到最大流,我们知道我们有一个最大匹配,通过最小化成本,我们得到我们正在寻找的匹配。

这个问题通常简称为最小费用流。这意味着,我们不是寻找最便宜的最大流量,而是简单地寻找给定数量的最便宜的流量。例如,问题可能是“给我一个大小为 k 的流,如果这样的流存在的话,确保你尽可能便宜地构建它。”例如,你可以构造一个尽可能大的流,直到 k 的值。这样,找到最大流量(或最小成本最大流量)只需将 k 设置为一个足够大的值。事实证明,仅仅关注最大流量就足够了;我们可以通过简单的简化优化到一个特定的流量值,而不需要修改算法(见练习 10-6)。

Busacker 和 Gowen 提出的解决最小费用流问题的思想是:寻找 最便宜的增广路径。也就是说,在遍历步骤中,对加权图使用最短路径算法,而不仅仅是 BFS。唯一的问题是,为了找到最短路径,向后遍历的边的成本是无效的。(毕竟它们是用来取消流量的。)

如果我们可以假设成本函数是正的,我们可以使用 Dijkstra 的算法来找到我们的扩充路径。问题是,一旦你把一些流从 u 推到 v ,我们就可以突然遍历(虚构的)反向边 vu ,它有一个成本。换句话说,Dijkstra 的算法在第一次迭代中会工作得很好,但是在那之后,我们就完蛋了。幸运的是,埃德蒙兹和卡普想到了一个巧妙的方法来解决这个问题——一个与约翰逊算法中使用的方法非常相似的方法(见第九章)。我们可以通过以下方式调整所有权重:( 1)使它们都为正,以及(2)沿着所有遍历路径形成伸缩和,确保最短路径仍然是最短的。

假设我们正在执行算法,并且我们已经建立了一些可行的流程。设 w ( uv )为边权重,根据增加路径遍历的规则进行调整(即沿着有剩余容量的边不变,沿着正向流的后向边取反)。让我们再次(即,就像在约翰逊的算法中一样)设置h(v)=d(sv ),其中距离是相对于 w 计算的。然后我们可以定义一个调整后的权重,我们可以用它来寻找下一个增强路径: w '( uv)=w(uv)+h(u)-h(v使用与第九章中相同的推理,我们看到该调整将保留所有最短路径,特别是从 st 的最短增补路径。**

实现基本的 Busacker-Gowen 算法基本上是一个在bfs_aug ( 清单 10-3 )的代码中用例如 Bellman-Ford(见清单 9-2 )替换 BFS 的问题。如果你想使用 Dijkstra 的算法,你只需使用修改后的权重,如前所述(练习 10-7)。关于基于贝尔曼-福特的实现,见清单 10-5 。(该实现假设边权重由单独的地图给出,因此W[u,v]是从uv的边的权重或成本。)注意,来自福特-富尔克森标记方法的流标记已经与贝尔曼-福特的 relax 操作合并——两者都在label函数中执行。要做任何事情,你们都必须找到一条更好的路径,并且在新的边缘有一些空闲容量。如果是这种情况,距离估计和流标签都被更新。

Busacker-Gowen 方法的运行时间取决于您选择的最短路径算法。我们不再使用埃德蒙兹-卡普方法,所以我们失去了它的运行时间保证,但是如果我们使用整数容量并寻找价值流 k ,我们保证最多 k 次迭代。5 假设 Dijkstra 的算法,总运行时间变成O(kmLGn)。对于最小代价二分匹配, k 将是 O ( n ),因此我们将得到O(nmLGn)。

从某种意义上来说,这是一个贪婪的算法,我们逐步建立流量,但在每一步中增加尽可能少的成本。凭直觉,这似乎应该行得通,事实上也确实如此,但证明同样多可能有点挑战性——事实上,以至于我在这里不打算详细说明。如果你想阅读证明(以及关于运行时间的更多细节),可以看一下 Dieter Jungnickel 在图、网络和算法中关于循环的章节。 6 你可以在 Kleinberg 和 Tardos 的算法设计中找到最小代价二分匹配特例的更简单的证明(参见第一章“参考文献”)。

清单 10-5 。布萨克-戈恩算法,使用贝尔曼-福特进行增强

def busacker_gowen(G, W, s, t):                 # Min-cost max-flow
    def sp_aug(G, H, s, t, f):                  # Shortest path (Bellman-Ford)
        D, P, F = {s:0}, {s:None}, {s:inf,t:0}  # Dist, preds and flow
        def label(inc, cst):                    # Label + relax, really
            if inc <= 0: return False           # No flow increase? Skip it
            d = D.get(u,inf) + cst              # New possible aug. distance
            if d >= D.get(v,inf): return False  # No improvement? Skip it
            D[v], P[v] = d, u                   # Update dist and pred
            F[v] = min(F[u], inc)               # Update flow label
            return True                         # We changed things!
        forin G:                             # n = len(G) rounds
            changed = False                     # No changes in round so far
            forin G:                         # Every from-node
                forin G[u]:                  # Every forward to-node
                    changed |= label(G[u][v]-f[u,v], W[u,v])
                forin H[u]:                  # Every backward to-node
                    changed |= label(f[v,u], -W[v,u])
            if not changed: break               # No change in round: Done
        else:                                   # Not done before round n?
            raise ValueError('negative cycle')  # Negative cycle detected
        return P, F[t]                          # Preds and flow reaching t
    return ford_fulkerson(G, s, t, sp_aug)      # Max-flow with Bellman-Ford

一些应用

正如最初所承诺的,我现在将概述本章中一些技术的一些应用。我不会给你所有的细节或实际代码——如果你想对这些材料有更多的体验,你可以在 尝试实现这些解决方案。

棒球淘汰赛。这个问题的解决方案由 Benjamin L. Schwartz 于 1966 年首次发表。如果你像我一样,你可以放弃棒球的背景,想象这是一场骑士比武的循环赛(如第四章中所讨论的)。无论如何,想法是这样的:你有一个部分完成的锦标赛(棒球相关或其他),你想知道某个队,比如火星绿皮肤队,是否有可能赢得锦标赛。也就是说,如果他们总共最多能赢 W 场比赛(如果他们赢下剩下的每一场比赛),有没有可能达到其他球队都没有超过 W 场胜利的局面?

这个问题如何通过减少到最大流来解决并不明显,但是让我们试一试。我们将建立一个具有完整流的网络,其中每个流单元代表一个剩余的游戏。我们创建节点 x 1 ,…, x n 来表示其他团队,以及节点pij来表示每对节点 x ix j 。除此之外,当然我们还有源 s 和宿 t 。从 s 到每个组节点添加一条边,从每个对节点到 t 添加一条边。对于一对节点pij,加上来自 x ix j 的边,容量为无穷大。从对节点pijt 的边得到一个容量,该容量等于在 x ix j 之间剩余的游戏数。如果团队 x i 已经赢了 w i 游戏,那么从 sxI的边得到一个 W - w**

我说过,每个流量单位代表一个游戏。想象一下从 st 跟踪单个单元。首先,我们来到一个团队节点,代表赢得这场比赛的团队。然后我们来到一个 pair 节点,代表我们面对的是哪一队。最后,沿着一条边移动到 t ,我们吞噬了一个代表所讨论的两支球队之间的一场比赛的容量单位。我们可以将所有优势饱和到 t 中的唯一方法是,如果所有的剩余的比赛都可以在这些条件下进行——也就是说,没有一支队伍赢得的比赛总数超过 W 场。因此,找到最大流量就给了我们答案。要获得更详细的正确性证明,请参见 Douglas B. West 的图论介绍的第 4.3 节(参见第二章的参考资料)或 B. L. Schwartz 的原始资料部分完成的比赛中可能的赢家

选择代表。 Ahuja 等人描述了这个有趣的小问题。一个小镇,有 n 居民, x 1 ,…, x n 。还有 m 社、c1、…、 c mk 政党、 p 1 、…、 p k 。每个居民至少是一个俱乐部的成员,并且只能属于一个政党。每个俱乐部必须提名一名成员代表它参加市议会。不过,有一个问题:属于党派 p i 的代表人数最多只能是 u i 。有可能找到这样一组代表?再次,我们减少到最大流量。通常情况下,我们将问题的对象表示为节点,并将它们之间的约束表示为边和容量。在这种情况下,每个居民、俱乐部和聚会都有一个节点,还有源 s 和接收器 t

流动的单位代表了代表。因此,我们给每个俱乐部一个来自 s 的优势,容量为 1,代表他们可以提名的单个人。从每个俱乐部中,我们给属于那个俱乐部的每个人增加一个优势,因为他们形成了候选人。(这些边上的容量并不重要,只要它至少是 1。)注意,每个人可以有多个 in-edge(即属于多个俱乐部)。现在,将居民的优势添加到他们的政党中(每人一个)。这些边的容量也是 1(这个人只能代表一个俱乐部)。最后,将来自各方的边添加到 t 中,这样来自各方的边 p i 的容量为 u i ,限制了理事会中的代表人数。找到一个最大流量将会给我们带来一组有效的提名。

当然,这个最大流解决方案只给了一个有效的提名集,不一定是我们想要的。我们可以假设政党能力 u i 是基于民主原则(某种形式的投票);代表的选择不应该同样基于俱乐部的偏好吗?也许他们可以举行投票,以表明他们有多希望每个成员代表他们,所以成员得到的分数,比如说,等于他们的投票百分比。然后,我们可以尝试最大化这些分数的总和,同时仍然确保提名在全球范围内有效。明白我的意思了吗?完全正确:我们可以扩展 Ahuja 等人的问题,在从俱乐部到居民的边上增加一个成本(例如,等于 100 分),然后我们解决最小成本最大流问题。事实上,我们得到了最大的流量,这将保证提名的有效性,而成本最小化将根据俱乐部的偏好给我们最好的妥协。

休假中的医生。克莱恩伯格和塔尔多斯(见第一章中的“参考文献”)描述了一个有点类似的问题。不同的对象和约束,但想法仍然有些相似。问题是给医生分配假期。每个假期必须至少指派一名医生,但如何做到这一点是有限制的。首先,每个医生只在一些假期有空。第二,每个医生最多只能工作 c 天。第三,在每个假期中,每个医生只能在一天工作。你知道如何将流量降到最大吗?

同样,我们有一组相互之间有约束的对象。除了接收器 s 和源 t 之外,我们至少需要每个医生一个节点,每个假期一个节点。我们从 s 给每个医生一个容量为 c 的 in-edge,代表每个医生可以工作的天数。现在我们可以开始将医生与日期直接联系起来,但是我们如何表达假期期间的想法呢?我们可以为每个医生添加一个节点,但是每个医生在每个时期都有单独的约束,所以我们需要更多的节点。每个医生每个假期都有一个节点,每个节点都有一个出边。例如,每个医生都有一个圣诞节节点。如果我们将这些外部边缘上的能力设置为 1,则医生在每个周期中不能工作超过一天。最后,我们将这些新的周期节点与医生有空的日子联系起来。因此,如果 Zoidberg 博士在圣诞节期间只能在平安夜和圣诞节工作,我们就在这两个日期加上他的圣诞节节点的外边缘。

最后,每一个假期都有一个优势。我们在这些方面设置的容量取决于我们是否希望找到多少医生,或者我们是否希望每个假期只有一名医生。无论哪种方式,找到最大流量会给我们我们正在寻找的答案。就像我们扩展前面的问题一样,我们可以再次考虑偏好,通过添加成本,例如在从每个医生的假期节点到个人假期的边上。然后,通过寻找最小成本流,我们不仅会找到一个可能的解决方案,我们还会找到一个引起最少总体不满的方案。

**供给与需求。**想象一下你正在管理某种形式的行星递送服务(或者,如果你喜欢一个不那么奇特的例子,一家运输公司)。你正试图计划一些商品的分销,例如波普勒。每个星球(或海港)都有一定的供给或需求(以每月的 popplers 来衡量),而你在这些星球之间的航线有一定的运力。我们如何对此建模?

事实上,这个问题的解决方案给了我们一个非常好的工具。不仅仅是解决这个特定的问题(无论如何,这只是对潜在的流问题的一个不加掩饰的描述),让我们更一般地描述一些事情。你有一个类似于我们到目前为止看到的网络,除了我们不再有一个源或一个汇。相反,每个节点 v 都有一个电源 b ( v )。该值也可以是负的,代表需求。为了简单起见,我们可以假设供给和需求的总和为零。我们现在想知道的不是找到最大流量,而是使用可用的供给是否能满足需求。我们称之为相对于 b可行流程。

我们需要一个新的算法吗?幸运的是,没有。归约又一次拯救了我们。给定一个有供给和需求的网络,我们可以构建一个简单的流量网络,如下所示。首先,我们添加一个源 s 和一个宿 t 。然后,具有供应的每个节点 vs 获得一个输入边,其供应作为容量,而具有需求的每个节点获得一个输出边到 t ,其需求作为容量。我们现在解决这个新网络的最大流问题。如果流浸透了到汇点的所有边(就此而言,还有来自源点的边),我们就找到了一个可行的流(我们可以通过忽略 st 及其边来提取它)。

一致矩阵 舍入**。**你有一个浮点数矩阵,你想把所有的数都四舍五入成整数。每一行和每一列都有一个总和,你也要对这些总和进行四舍五入。您可以自由选择在每种情况下是向上舍入还是向下舍入(也就是说,是使用math.floor还是math.ceil),但是您必须确保每行和每列中舍入数字的总和与舍入的列或行总和相同。(您可以将此视为一个标准,该标准寻求在舍入后保留原始矩阵的一些重要属性。)我们称这样的舍入方案为一致舍入

这看起来很数字,对吧?您可能不会立即想到图表或网络流。实际上,如果除了容量(这是一个上限)之外,我们首先在每个边中引入流的下界*,这个问题会更容易解决。这给了我们一个新的初始障碍:找到一个关于边界的可行流。一旦我们有了一个可行的流量,只要稍微修改一下福特-富尔克森方法就可以找到最大流量,但是我们如何找到这个可行的初始流量呢?这远不如找到一个可行的供给和需求流程那么容易。我将在这里简单描述一下主要观点——详情请参考 Douglas B. West 的《图论介绍》中的第 4.3 节,或者 Ahuja 等人的《网络流》中的第 6.7 节。*

第一步是添加一条从 ts 的边,其容量为无穷大(下界为零)。我们现在不再有一个流量网络,但我们可以寻找一个 循环,而不是寻找一个流量。一个循环就像一个流,除了它在每个节点都有流量守恒。换句话说,没有任何源头或汇点可以免于保护。循环不会在某个地方出现,在另一个地方消失;它只是在网络中“四处移动”。我们仍然有上限和下限,所以我们现在的任务是找到一个可行循环*(这将给出原始图中的可行流)。

如果一条边 e 分别有上下限 l ( e )和 u ( e ),我们定义c(e)=u(e)-l(e)。(此处的命名选择反映了我们稍后将使用它作为一种能力。)现在,对于每个节点 v ,设l??—(v)为其入边的下界之和,而l+(v)为其出边的下界之和。基于这些值,我们定义b(v)=l(v)–l+(v)。因为每个下限对其源节点和目标节点都有贡献,所以 b 值的总和为零。

现在,足够神奇的是,如果我们找到一个关于容量 c 和供给与需求 b 的可行流程(如前一个问题所讨论的),我们也将找到一个关于下限和上限 lu 的可行循环。为什么会这样?可行的循环必须遵守 lu ,并且流入每个节点的流量与流出的流量相当。如果我们能找到任何具有这些属性的循环,我们就完成了。现在,让f'(e)=f(e)–l(e)。然后我们可以通过简单地要求 0≤f'(e)≤c(e)来强制执行 f 的上下限,对吗?

现在考虑流动和循环守恒。我们要确保进入一个节点的环流等于从该节点流出的环流。假设进入节点的总流量fv 减去流出节点的流量 v 等于b(v)——这正是我们的供给/需求问题的守恒要求。 f 会怎么样?假设 v 有一个输入边和一个输出边。现在,假设入边的下界为 3,出边的下界为 2。这意味着 b ( v ) = 1。我们需要比流入多一个单位的流出量。假设流入为 0,流出为 1。当我们将这些流动转换回环流时,我们必须加上下限,使内环流和外环流都为 3,所以总和为零。(如果这看起来令人困惑,试着改变一下想法,我相信它们会“一拍即合”)

现在我们知道了如何找到一个有下界的可行流(首先简化为可行循环,然后再简化为有供给和需求的可行流)。这和矩阵舍入有什么关系?设x1,…, x n 表示矩阵的行,设 y 1 ,…, y m 表示列。还要添加一个源 s 和一个宿 s 。给每一行一个来自 s 的 in-edge,代表行总和,给每一列一个到 t 的 out-edge,代表列总和。另外,从每一行到每一列添加一条边,表示矩阵元素。每个边沿 e 代表一个真实值r。设置 l ( e ) = floor(r)u ( e ) = ceil(r)。相对于 lu 而言,从 st 的可行流程将给出我们所需要的——一致的矩阵舍入。(你看怎么样?)

摘要

本章处理单个核心问题,寻找流网络中的最大流,以及专门版本,如最大二部匹配和寻找边不相交路径。您还看到了最小割问题是最大流问题的对偶,以一个解决方案的价格为我们提供了两个解决方案。解决最小费用流问题也是密切相关的,只需要我们切换遍历方法,使用最短路径算法来寻找最便宜的扩充路径。所有解决方案的基本思想都是迭代改进,反复寻找一条增加的路径,让我们改进解决方案。这是一般的 Ford-Fulkerson 方法,它通常不保证多项式运行时间(或者甚至是终止,如果你使用无理的容量)。使用 BFS 寻找边数最少的扩充路径被称为 Edmonds-Karp 算法,它很好地解决了这个问题。(注意,这种方法不能用于最小成本的情况,因为我们必须找到相对于容量的最短路径,而不是边数。)最大流问题及其相关问题是灵活的,适用于相当多的问题。面临的挑战是找到合适的减排方案。

如果你好奇的话…

关于各种流算法,确实有大量的资料。例如,有 Dinic 的算法,它是 Edmonds-Karp 算法的近亲(它实际上比它更早,并且使用相同的基本原理),有一些技巧可以稍微提高运行时间。或者你有 push-relabel 算法,在大多数情况下(除了稀疏图)比 Edmonds-Karp 快。对于二分匹配的情况,您有 Hopcroft-Karp 算法,它通过执行多个同时遍历来改进运行时间。对于最小成本二分匹配,也有众所周知的匈牙利算法,以及更近一些的真正会飞的启发式算法,比如 Goldberg 和 Kennedy 的成本缩放算法(CSA) 。如果你想深入挖掘增加路径的基础,也许你想读读 Berge 的原始论文,“图论中的两个定理”?

还有更高级的流动问题,包括边缘流动的下限,或所谓的环流,没有源或汇。还有多商品流问题,对此没有有效的专用算法(你需要用一种叫做线性规划的技术来解决)。对于一般的图形,还有匹配问题——即使是最小成本版本。这方面的算法比本章中的要复杂得多。

关于流的一些血淋淋的细节的第一站可能是教科书,如 Cormen 等人的算法简介(见第一章中的“参考”部分),但是如果你想要更多的广度,以及大量的示例应用,我推荐由 Ahuja,Magnanti 和 Orlin 的网络流:理论,算法和应用。你可能还想看看福特和富尔克森的开创性著作《网络中的流动》。

练习

10-1.在一些应用中,例如当通过交换点路由通信时,让节点具有容量,而不是(或除了)边缘,可能是有用的。你如何将这种问题简化为标准的最大流问题?

10-2.如何找到顶点不相交的路径?

10-3.证明用于寻找不相交路径的扩充路径算法的最坏情况运行时间是θ(m2),其中 m 是图中的边数。

10-4.你如何在一个无向网络中找到流量?

10-5.实现一个包装器对象,它看起来像一个图,但是动态地反映了一个底层流网络的剩余网络。使用遍历算法的简单实现来实现本章中的一些流算法,以找到增加的路径。

10-6.你如何将流量问题(寻找一个给定大小的流量)简化为最大流量问题?

10-7.使用 Dijkstra 算法和权重调整实现最小成本流问题的解决方案。

10-8.在练习 4-3 中,你邀请朋友参加聚会,并希望确保每个客人至少认识在场的其他人。你已经意识到事情有点复杂。你比其他人更喜欢一些朋友,用真实值兼容性来表示,可能是负面的。你也知道,只有在某些其他客人参加的情况下,许多客人才会参加(尽管这种感觉不一定是相互的)。你将如何选择一个可行的潜在客人子集,最大化你与他们的兼容性总和?(你可能还想考虑那些其他人来了而不来的客人。不过,这有点难——请看练习 11-19。)

10-9.在第四章第一节,四个脾气暴躁的电影观众试图找出他们的座位安排。问题的一部分是,除非能买到自己喜欢的,否则他们谁也不愿意换座位。假设他们的脾气稍微好一点,并且愿意根据需要交换位置以获得最佳解决方案。现在,一个最佳的解决方案可以通过在免费座位上添加边来找到,直到你用完为止。使用本章中的二分匹配算法的简化来说明这一点。

10-10.你正在为 n 人开一个团队建设研讨会,你正在做两个练习。在这两个练习中,您希望将人群划分为由 k 组成的组,并且您希望确保在第二轮中没有人与他们在第一轮中所在的组属于同一组。你如何用最大流量解决这个问题?(假设 n 能被 k 整除。)

10-11.你被一家星际客运服务公司(或者,不那么想象的话,一家航空公司)雇佣去分析它的一次飞行。宇宙飞船按顺序降落在行星 1… n 上,并且可以在每一站搭载或放下乘客。你知道有多少乘客想从每个 i 星球到其他每个 j 星球,以及每次旅行的费用。设计一个算法,使整个行程的利润最大化。(该问题基于 Ahuja 等人的网络流中的应用 9.4。)

参考

阿胡贾,R. K .,马格南蒂,T. L .,和奥林,J. B. (1993 年)。网络流:理论、算法和应用。普伦蒂斯霍尔。

贝尔热,C. (1957)。图论中的两个定理。美国国家科学院院刊 43(9):842–844。http://www.pnas.org/content/43/9/842.full.pdf

布萨克,R. G .科芬,S. A .和戈恩,P. J. (1962)。三种常见的网络流问题及其解决方法。工作人员文件 RAC-SP-183,研究分析公司,作战后勤处。http://handle.dtic.mil/100.2/AD296365

福特和富尔克森(1957 年)。求最大网络流的简单算法及其在希区柯克问题中的应用。加拿大数学杂志,9:210–218。http://smc.math.ca/cjm/v9/p210

福特和富尔克森(1962 年)。网络中的流量。兰德公司 R-375-PR 技术报告。http://www.rand.org/pubs/reports/R375

Jungnickel 博士(2007 年)。图、网络和算法,第三版。斯普林格。

吴俊杰、杨晓清(2002)。最优化和变分不等式中的对偶。最优化理论与应用。泰勒&弗朗西斯。

戈德堡和肯尼迪(1995 年)。指派问题的有效成本比例算法。数学编程,71:153–178。http://theory.stanford.edu/~robert/papers/csa.ps

施瓦茨,B. L. (1966 年)。部分完成的锦标赛中可能的赢家。暹罗评论,8(3):302–308。http://jstor.org/pss/2028206


1 如果你允许他们指定一个偏好度,这就变成了更一般的最小成本二分匹配,或者指派问题。虽然这是一个非常有用的问题,但解决起来有点困难——我稍后会谈到这一点。

2 这个问题在某些方面类似于第八章中的路径计数。然而,主要的区别在于,在这种情况下,我们计算了所有可能的路径(如帕斯卡三角形),这通常会导致大量的重叠,否则记忆将毫无意义。这种重叠在这里是不允许的。

3 门格尔定理的证明也不依赖于流动的概念。

这一节有点难,但对于理解这本书的其余部分并不重要。随意浏览,甚至完全跳过。不过,你可能想读一下前几段,了解一下这个问题。

这当然是伪多项式,所以明智地选择你的容量。

6 也可在线:http://books.google.com/books?id=NvuFAglxaJkC&pg=PA299

7 注意,这里的总和是内边缘下限减去外边缘下限——与我们对流量求和的方式相反。这正是问题的关键。******

十一、难题和(有限的)马虎

最好是好的敌人。

—伏尔泰

这本书显然是关于算法问题解决的。到目前为止,重点一直是算法设计的基本原则,以及许多问题领域中重要算法的例子。现在,我给你看一下算法的另一面:硬度。尽管为许多重要而有趣的问题找到高效的算法肯定是可能的,但令人悲伤的事实是,大多数问题真的很难。事实上,大多数问题都很难,试图解决它们几乎没有意义。然后,重要的是要认识到困难,表明问题是难以解决的(或者至少很有可能如此),并且知道除了简单地放弃之外还有什么选择。

这一章有三个部分。首先,我将解释世界上最大的未解问题之一的潜在思想——以及它如何适用于你。第二,我将在这些想法的基础上,向你们展示一些极其困难的问题,你们很可能会以这样或那样的形式遇到。最后,鉴于本章前两部分的令人沮丧的消息,我将向你展示如何遵循伏尔泰的智慧,并稍微放松你的要求,可以让你比看起来可能的更接近你的目标。

当您阅读下面的内容时,您可能想知道所有的代码都到哪里去了。需要明确的是,这一章的大部分内容都是关于那种太难的问题。这也是关于你如何发现一个给定问题的困难。这很重要,因为它探索了我们的程序实际上可以做什么的外部边界,但它并没有真正导致任何编程。只有在本章的最后三分之一,我才会关注(并给出一些代码)近似法和试探法。这些方法将允许你找到可用的解决方案来解决那些太难的问题,这些问题很难得到最优的、有效的、普遍的解决。他们通过利用一个漏洞实现了这一点——事实上,在现实生活中,我们可能满足于在这三个轴中的一些或所有轴上“足够好”的解决方案。

提示跳到这一章看似更有内容的部分,具体的问题和算法可能很有诱惑力。如果你想理解这一点,我强烈建议尝试一下更抽象的部分,至少从头开始略读这一章以获得一个概述。

归约归约

从第四章开始,我一直在时不时地讨论削减。大部分时间,我一直在谈论把问题简化成你知道如何解决的问题——要么是你正在处理的问题的较小实例,要么是一个完全不同的问题。这样,你也就有了这个新的未知问题的解决方案,实际上证明了它很简单(或者,至少,你可以解决它)。在《??》第四章快结束的时候,我引入了一个不同的想法:向另一个方向减少来证明的硬度。在第六章的中,我用这个想法给出了解决凸包问题的任何算法的最坏情况运行时间的下界。现在我们终于到达了这种技术完全在家的点。事实上,定义复杂性类别(和问题难度)是大多数教科书中通常使用的简化方法。不过,在深入讨论之前,我想从基础层面上彻底说明一下这种硬度证明是如何工作的。这个概念非常简单(虽然证明本身当然不需要),但是出于某种原因,许多人(包括我自己)总是把它搞反了。也许——仅仅是也许——下面这个小故事可以在你试图回忆它是如何工作的时候帮到你。

假设你来到一个小镇,这里的主要景点之一是一对双峰。当地人亲切地称这两个兄弟为 Castor 和 Pollux,以希腊和罗马神话中的孪生兄弟命名。有传言说在 Pollux 山顶上有一个被遗忘已久的金矿,但是许多冒险者已经迷失在这座危险的山中。事实上,已经有太多不成功的尝试试图到达金矿,以至于当地人开始相信这是不可能的。你决定出去走走,亲自看看。

在当地的路边小店买了甜甜圈和咖啡后,你出发了。走了一小段路后,你到了一个可以相对清晰地看到群山的有利位置。从你站的地方,你可以看到 Pollux 看起来真的像一个地狱般的攀登——陡峭的表面,深深的峡谷,周围布满荆棘。另一方面,蓖麻看起来像登山者的梦想。两边坡度平缓,似乎有许多扶手一直通到顶部。你不能确定,但看起来这可能是一次不错的攀登。可惜金矿不在上面。

你决定仔细看看,拿出双筒望远镜。这时你会发现一些奇怪的事情。在 Castor 的顶部似乎有一个小塔,上面有一条滑索一直延伸到 Pollux 的顶峰。立刻,你放弃了任何爬蓖麻的计划。为什么呢?(如果你没有立即看到它,它可能值得思考一下。) 1

当然,在第四章和第六章关于硬度的讨论中,我们已经看到了确切的情况。滑索使得从 Castor 到 Pollux 变得很容易,所以如果 Castor 很容易的话,早就有人发现金矿了。 2 这是一个简单的反命题:如果 Castor 很容易,Pollux 也会很容易;Pollux 不容易,Castor 也不容易。当我们想证明一个问题(Castor)很难的时候,这正是我们要做的。我们拿一些我们知道很难的东西(Pollux)来说明使用我们新的未知的东西很容易解决这个难题(我们发现了一条从 Castor 到 Pollux 的拉链线)。

正如我之前提到的,这本身并不令人困惑。然而,当我们开始谈论减排时,很容易混淆。例如,我们在这里将 Pollux 简化为 Castor,这对您来说是不是很明显?减少的部分是 zip line,它让我们可以像使用 Pollux 的解决方案一样使用 Castor 的解决方案。换句话说,如果你想证明问题 X 是难的,那就找一些难的问题 Y,化归到 X。

Image 注意滑索在与减速相反的方向。至关重要的是你不能混淆,否则整个想法就会瓦解。归约这个词在这里的意思基本上是“哦,那很简单,你只要……”换句话说,如果你把 A 归约为 B,你就是在说“你想解 A?这很简单,你只需解决 b。”或者在这种情况下:“你想缩放 Pollux?这很简单,只需缩放 Castor(并采取滑索)。”换句话说,我们将 Pollux 的伸缩性降低到 Castor 的伸缩性(而不是反过来)。

这里有几件事值得注意。首先,我们假设 zip line易于使用。如果不是滑索而是一条你必须平衡的水平线呢?这真的很难——所以它不会给我们任何信息。就我们所知,人们可能很容易到达卡斯特的顶峰;他们可能无论如何也到不了 Pollux 上的金矿,所以我们知道些什么?另一个是相反方向的减少也没有告诉我们什么。从 Pollux 到 Castor 的一条滑索不会影响我们对 Castor 的估计。那么,如果你能从 Pollux 到 Castor 呢?你无论如何也无法到达波利克斯的顶峰!

考虑图 11-1 的图。节点代表问题,,边代表简单的减少(也就是说,渐进地,它们无关紧要)。底部的粗线是为了说明“地面”,从某种意义上说,未解决的问题是“天上的”,而解决它们就相当于将它们减少到零,或将其接地。第一幅图展示了未知问题 u 被简化为已知的简单问题 e 的情况。从 e 到地面有一个简单的减少,这代表了 e 容易的事实。因此,将 u 连接到 e 为我们提供了一条从 u 到地面的路径——一个解决方案。

9781484200568_Fig11-01.jpg

图 11-1 。归约的两种用途:将未知问题归约为简单问题,或将困难问题归约为未知问题。在后一种情况下,未知问题一定和已知问题一样难

现在看第二张图片。在这里,一个已知的难题被简化为未知问题 u 。我们能有一条从 u 到地面的边吗(就像图中的灰色边)?那会给我们一条从 h 到地面的路径——但是这样的路径不可能存在,否则 h 不会很难!

在下文中,我将使用这一基本思想,不仅表明问题是困难的,而且还将定义一些困难的概念。正如你可能(也可能没有)注意到的,这里的术语 hard 有些模糊。它基本上可以有两种不同的含义:

  • 这个问题很棘手——任何解决它的算法都必须是指数级的。
  • 我们不知道这个问题是否棘手,但从来没有人能够找到它的多项式算法。

第一个意思是这个问题对计算机来说很难解决,而第二个意思是对人来说也很难(也许对计算机来说也是如此)。再看一下图 11-1 中最右边的图像。“努力”的两种含义在这里是如何起作用的?让我们举第一个例子:我们知道这个 h 很难处理。高效解决是不可能的。对 u 的解决方案(即降低到地)意味着对 h 的解决方案,因此不存在这样的解决方案。所以, u 也一定是顽固性的。

第二种情况有点不同——这里的困难在于缺乏知识。我们不知道问题 h 是否棘手,尽管我们知道似乎很难找到解决方案。核心见解仍然是,如果我们把 h 减少到 u ,那么 u 至少和 h 一样硬。如果说 h 难对付,那么 u 也难对付。此外,许多人试图找到解决问题的方法,这一事实使得我们成功的可能性变得更小,这也意味着我们不太可能是易驾驭的。为解决 h 问题付出的努力越多,如果 u 变得容易控制(因为那样的话 h 也会变得容易控制)就会越令人吃惊。事实上,这正是一系列实际重要问题的情况:我们不知道这些问题是否棘手,但大多数人仍然坚信它们是棘手的。让我们仔细看看这些流氓问题。

子问题归约

虽然通过使用归约来显示困难的想法可能有点抽象和奇怪,但是有一个特例(或者在某些方面,一个不同的视角)可能很容易理解:如果你的问题有一个困难的子问题,那么这个问题作为一个整体(显然)是困难的。换句话说,如果解决你的问题意味着你也必须解决一个众所周知的难题,那么你基本上就不走运了。例如,如果你的老板让你制作一个反重力悬浮滑板,你可能会做很多工作,比如制作滑板本身或者在一个漂亮的图案上绘画。然而,实际上解决绕过重力的问题使得整个努力从一开始就注定失败。

那么,这是如何减少的呢?这是一种简化,因为你仍然可以用你的问题来解决困难的子问题。换句话说,如果你能够建造一个反重力悬浮滑板,那么你的解决方案就可以(再次,非常明显)用于规避重力。和大多数削减一样,困难的问题甚至没有真正转化;只是嵌入了一个(相当不相关的)语境。或者考虑一般排序的最坏情况运行时间的对数线性下限。如果您要编写一个程序,它接收一组对象,对它们执行一些操作,然后按排序顺序输出关于对象的信息,那么在最坏的情况下,您可能不会比对数线性更好。

但为什么是“可能”?因为这取决于是否有真正的减少。你的程序可以被想象成“分类机器”吗?如果我可以随心所欲地使用你的程序,有没有可能给它输入一些对象,让我对任何实数进行排序?如果是,那么界限成立。如果没有,那么也许没有。例如,也许排序是基于可以使用计数排序的整数?或者也许你实际上自己创建了排序键,所以对象可以按你喜欢的任何顺序输出?你的问题是否足够表达的问题——是否能表达一般的排序问题。事实上,这是本章的关键观点之一:问题的难度是一个表达的问题。

已经不在堪萨斯了?

当我为第一版写下这一章时,一篇科学论文在网上发表后,兴奋才刚刚开始在互联网上消退,该论文声称已证明解决了所谓的 P 对 NP 问题,并得出结论说 P 不等于 NP。尽管新出现的共识是证明是有缺陷的,但这篇论文引起了极大的兴趣——至少在计算机科学界。此外,具有类似主张(或者相反,P 等于 NP)的不太可信的论文不断定期出现。自 20 世纪 70 年代以来,计算机科学家和数学家一直在研究这个问题,这个解决方案甚至获得了一百万美元的奖金。尽管在理解这个问题上已经取得了很大的进展,但似乎还没有真正的解决方案。为什么这么难?为什么它如此重要?而 P 和 NP 到底是什么?

问题是,我们真的不知道我们生活在一个什么样的世界。用绿野仙踪来打个比方——我们可能认为我们生活在堪萨斯州,但是如果有人证明 P = NP,我们肯定已经不在堪萨斯州了。相反,我们会置身于某种类似于奥兹的仙境,一个拉塞尔·因帕利亚佐命名为算法的世界。 5 你说 Algorithmica 有什么了不起?在 Algorithmica,引用一首众所周知的歌曲,“你永远不会换袜子,小股酒精流从岩石上流淌下来。”更严重的是,生活会少很多问题。如果你能陈述一个数学问题,你也能自动解决它。事实上,程序员不再需要告诉计算机做什么——他们只需要给出想要的输出的清晰描述。几乎任何一种优化都是微不足道的。另一方面,密码学现在会变得非常困难,因为破解密码会变得非常非常容易。

问题是,P 和 NP 看起来是非常不同的野兽,尽管它们都是问题的类别。事实上,它们是一类决策问题,可以用来回答的问题。这可能是一个问题,例如“是否存在一条从 st 的路径,其权重至多为 w ?”或者“有没有一种方法可以把物品装进这个背包,让我得到至少 v 的价值?”第一类,P,被定义为由那些我们可以在多项式时间**(在最坏的情况下)内解决的问题组成。换句话说,如果你把我们到目前为止看到的几乎所有问题都变成决策问题,结果将属于 p。**

NP 似乎有一个更宽松的定义 6 :它包括任何可以在多项式时间内被称为 非确定性图灵机或 NTM 的“神奇计算机”解决的决策问题。这就是 NP 中的 N 的来源——NP 代表“非确定性多项式”。据我们所知,这些非决定论的机器超级强大。基本上,在他们需要做出选择的任何时候,他们都可以猜,而且通过魔法*,他们总是猜对。听起来很棒,对吧?

例如,考虑在图中找到从 st 的最短路径的问题。你已经知道很多关于如何用更…非数学类的算法做到这一点。但是如果你有一个 NTM 呢?你可以从 s 开始,看看邻居。你应该走哪条路?谁知道呢——猜猜看。因为你正在使用的机器,你将永远是正确的,所以你将神奇地沿着最短的路径行走,不走弯路。例如,对于 DAG 中的最短路径这样的问题,这可能看起来不像是一个巨大的胜利。这是一个可爱的聚会把戏,当然,但运行时间将是线性的。

但是考虑一下第一章中的第一个问题:尽可能高效地游览一次瑞典的所有城镇。还记得几年前我说过用最先进的技术解决这个问题花了大约 85 个 CPU 年吗?如果有一个 NTM,每个城镇只需要一个计算步骤。即使你的机器是带手摇曲柄的机械,它也应该在几秒钟内完成计算。这个看起来很强大吧?而且神奇?

描述 NP(或者,就此而言,非确定性计算机)的另一种方式是看解决问题和检查解决方案之间的区别。我们已经知道解决问题意味着什么。如果我们要检查一个决策问题的解决方案,我们需要的不仅仅是“是”或“否”——我们还需要某种证明,或者证书*(这个证书需要是多项式大小)。例如,如果我们想知道是否存在从 st 的路径,证书可能就是实际的路径。换句话说,如果你解决了问题,发现答案是“是”,你可以用这个证明来说服我这是真的。换句话说,如果你设法证明了某个数学陈述,你的证明可能就是证明。

那么,一个属于 NP 的问题的要求是,我能够在多项式时间内检查任何“是”答案的证书。非确定性图灵机可以通过简单地猜测证书来解决任何这样的问题。魔法,对吧?

嗯,也许…你看,这就是问题所在。我们知道 P 是而不是神奇的——它充满了我们非常清楚如何解决的问题。NP 看起来像是一大类问题,任何能解决所有这些问题的机器都将超越这个世界。问题是,在 Algorithmica 中,有一种叫做 NTM 的东西。或者,更确切地说,我们非常普通、单调的计算机(确定性图灵机)将被证明是一样强大的。他们一直都有魔力!如果 P = NP,我们可以解决任何有实际(可验证)解决方案的(决策)问题。

同时,回到堪萨斯…

好吧,Algorithmica 是一个神奇的世界,如果我们真的生活在其中,那将会非常棒——但很有可能,我们不是。十有八九,发现证据和检查证据之间有着非常真实的区别——在解决问题和每次简单地猜测正确的解决方案之间。所以如果我们还在堪萨斯,我们为什么要关心这些?

因为它给了我们一个非常有用的硬度概念。你看,我们有一群卑鄙的小动物组成了一个叫 NPC 的班级。这代表“NP-complete”,这些是所有 NP 中最难的个问题。更准确地说,NPC 的每个问题至少和 NP 中的其他问题一样难。我们不知道这些问题是否棘手,但是如果你要解决其中一个棘手的问题,你会自动把我们都送到 Algorithmica!虽然世界人口可能会为不用再换袜子而高兴,但这不太可能发生(我希望上一节强调了这一点)。这将是非常惊人的,但似乎完全不可行。

这不仅会非常奇怪,而且考虑到巨大的优势和巨大的努力,仅仅是为了打破其中一个生物,四十年的失败(到目前为止)似乎会增强我们的信心,相信你不会是成功的那个。至少短期内不会。换句话说,NP 完全问题可能很棘手(对计算机来说很难),但迄今为止,它们肯定对人类来说很难。

9781484200568_unFig11-01.jpg

NP-完成。 一般方案给你 50%的小费。( http://xkcd.com/287 )

但是这一切是如何运作的呢?为什么杀死一个 NPC 怪物会让所有的 NP 完全崩溃,让我们陷入算法世界?让我们回到我们的简化图。看一下图 11-2 。现在,假设所有的节点都代表 NP 中的问题(也就是说,目前我们把 NP 视为“整个世界的问题”)。左图说明了完整性的概念。在一类问题中,一个问题 c完备如果该类中的所有问题都可以“容易地”化为 c7 在这种情况下,我们讨论的类是 NP,如果它们是多项式,那么归约就“容易”了。换句话说,一个问题 c 是 NP 完全的,如果(1) c 本身在 NP 中,并且(2)NP 中的每一个问题都可以在多项式时间内化简为 c

9781484200568_Fig11-02.jpg

图 11-2 。NP 完全问题是 NP 中至少和其他问题一样难的问题。即 NP 中的所有问题都可以归结为它

(NP 中的)每一个问题都可以归结为这些棘手的问题,这意味着它们是核心——如果你能解决它们,你就能解决 NP 中的任何问题(突然间,我们不再是在堪萨斯了)。该图应该有助于澄清这一点:解决 c 意味着添加一个从 c 到地面的实心箭头(将其化为零),这立即给我们提供了一条从 NP 中的每个其他问题到地面的路径,经由 c

我们现在用归约来定义 NP 中最困难的问题,但是我们可以稍微扩展这个概念。在图 11-2 中的右图说明了我们如何使用缩减过渡,用于硬度校样,例如我们之前讨论过的那些(例如,像在图 11-1 中右边的那个)。我们知道 c 是硬的,所以简化为 u 证明 u 是硬的。我们已经知道这是如何工作的,但是这个图说明了为什么在这个例子中它是正确的一个稍微更技术性的原因。通过将 c 减少为 u ,我们现在已经将 u 放置在 c 原来所在的相同位置。我们已经知道 NP 中的每个问题都可以归结为 c (意味着它是 NP 完全的)。现在我们也知道,每一个问题都可以通过 c 归结为 u 。换句话说, u 也满足 NP-完全性的定义——如图所示,如果我们能在多项式时间内解决它,我们将建立 P = NP。

到目前为止,我只讨论了决策问题。这样做的主要原因是,它使形式推理中的许多事情(其中大部分我不会在这里介绍)变得更加容易。即便如此,这些想法也适用于其他类型的问题,例如我们在本书中处理的许多优化问题(将在本章后面处理)。

例如,考虑寻找最短的瑞典之旅的问题。因为不是决策问题,不在 NP。即便如此,这也是一个非常困难的问题(从“人类很难解决”和“很可能难以解决”的意义上来说),就像 NP 中的任何事情一样,如果我们发现自己在 Algorithmica 中,它会突然变得很容易。这两点我们分开考虑。

术语完全性是为一个类中最难的问题保留的,所以 NP 完全问题是 NP 的类霸王。不过,我们也可以对可能不属于这个类别的问题使用相同的硬度标准。也就是说,任何问题至少与 NP 中的任何问题一样困难(由多项式时间缩减确定),但不需要本身在 NP 中。这样的问题叫做 NP 难。这意味着 NP 完全问题的 NPC 类的另一个定义是,它包括 NP 中所有的 NP 困难问题。是的,通过一个图寻找最短的路线(比如通过瑞典的城镇)是一个 NP-hard 问题,称为旅行推销员(或销售代表)问题,或者通常只是 TSP。我稍后将回到那个问题。**

关于另一点:如果 P = NP,为什么像这样的优化问题会很容易?关于如何使用证书找到实际的路线等等,还有一些技术细节,但是让我们只关注 NP 的是-否性质和我们在 TSP 问题中寻找的数字长度之间的区别。为了简单起见,我们假设所有的边权重都是整数。此外,因为 P = NP,我们可以在多项式时间内解决决策问题的是和否实例(参见侧栏“不对称、共 NP 和算法的奇迹”)。一种方法是将决策问题作为一个黑箱,对最优答案进行二分搜索法。

例如,我们可以对所有的边权重求和,我们得到 TSP 旅行的成本的上限 C ,下限为 0。然后我们初步猜测最小值是 C /2,并解决决策问题“是否存在长度最多为 C /2 的游览?”我们在多项式时间内得到“是”或“否”,然后可以继续平分值范围的上半部分或下半部分。练习 11-1 要求你展示产生的算法是多项式的。

Image 提示这种用黑盒一分为二的策略也可以用在其他情况下,甚至在复杂性类的上下文之外。如果您有一个算法可以让您确定一个参数是否足够大,您可以一分为二,以对数因子为代价找到正确/最佳的值。相当便宜,真的。

换句话说,尽管复杂性理论主要关注决策问题,但优化问题并没有什么不同。在许多情况下,你可能会听到人们使用术语 NP-完全,而他们真正的意思是 NP-难。当然,你应该小心把事情做对,但是你是否表明一个问题是 NP 难的还是 NP 完全的,对于争论它的困难性的实际目的来说并不那么重要。(只要确保你的削减是在正确的方向上!)

不对称、共 NP 和算法的奇迹

NP 的类别是不对称定义的。它由所有决策问题组成,这些问题的 yes 实例可以用 NTM 在多项式时间内解决。但是,请注意,我们没有提到任何关于 no 实例的内容。因此,举例来说,很明显,如果有一个旅游团恰好游览瑞典的每个城镇一次,一个 NTM 会在合理的时间内回答“是”。然而,如果答案是“不”,它可能需要一段甜蜜的时间。

这种不对称背后的直觉很容易理解,真的。这个想法是,为了回答“是”,NTM 只需要(通过“魔法”)找到一个单一的选择集合,导致这个答案的计算。然而,为了回答“否”,它需要确定不存在这样的计算。虽然这看起来非常不同,但是我们并不知道它是否是 ??。你看,这里我们有另一个复杂性理论中的“相对问题”: NP 对 co-NP。

co-NP 类是 NP 问题的补集类。对于每一个“是”的回答,我们现在都想要“不是”,反之亦然。如果 NP 是真正不对称的,那么这两个类是不同的,尽管它们之间有重叠。例如,所有的 P 都位于它们的交叉点上,因为 P 中的 yes 和 no 实例都可以用 NTM 在多项式时间内解决(就此而言,还可以用确定性图灵机解决)。

现在考虑如果在 NP 和 co-NP 的交集中发现 NP 完全问题 FOO 会发生什么。首先,NP 中的所有问题都归结为 NPC,所以这将意味着 NP 的所有将在 co-NP 中(因为我们现在可以通过 FOO 处理它们的补码)。NP 之外的 co-NP 还会有问题吗?考虑这样一个假设的问题吧。它的补语 co-BAR 应该在 NP 中,对吗?但是因为 NP 在 co-NP 里面,所以 co-BAR也会在 co-NP 里面。这意味着它的补码 BAR 应该在 NP 中。但是,但是,但是……我们假设它是 NP 的外的*——矛盾!*

换句话说,如果我们在 NP 和 co-NP 的交集中找到单个 NP-完全问题,我们将证明 NP = co-NP,并且不对称已经消失。如上所述,所有的 P 都在这个交点上,所以如果 P = NP,我们也有 NP = co-NP。这意味着在 Algorithmica 中,NP 是令人愉快的对称的。

注意,这个结论经常被用来论证在 NP 和 co-NP 的交集中的问题很可能不是 NP 完全,因为(强烈)认为 NP 和 co-NP 是不同的。例如,没有人找到分解数字问题的多项式解,这就形成了许多密码学的基础。然而问题存在于 NP 和 co-NP 中,所以大多数计算机科学家认为这是而不是 NP 完全。

但是你从哪里开始呢?你从那里去哪里?

我希望现在基本的想法已经很清楚了:NP 类包括所有的决策问题,这些问题的“是”答案可以在多项式时间内得到验证。NPC 类由 NP 中最难的问题组成;NP 中的所有问题都可以在多项式时间内归结为这些。p 是我们可以在多项式时间内解决的 NP 问题的集合。由于类的定义方式,如果 P 和 NPC 之间有一点重叠,我们就有 P = NP = NPC。我们还建立了,如果我们有一个从 NP 完全问题到 NP 中其他问题的多项式时间约简,那么第二个问题也一定是 NP 完全的。(自然,所有的 NP 完全问题都可以在多项式时间内相互约化;参见练习 11-2。)

这给了我们看起来有用的硬度概念——但是到目前为止,我们甚至还没有确定存在NP 完全问题,更不用说发现了一个。我们该怎么做?库克和莱文来救援了!

在 20 世纪 70 年代初,史蒂文·库克证明了确实存在这样的问题,不久之后,列昂尼德·莱文独立地证明了同样的事情。他们都证明了一个叫做 布尔可满足性或 SAT 的问题是 NP 完全的。这个结果以他们两个的名字命名,现在被称为库克-莱文定理。这个定理给了我们起点,它相当高级,我在这里无法给你一个完整的证明,但我会试着勾勒出主要思想。(例如,Garey 和 Johnson 给出了充分的证明;请参见“参考资料”部分。)

SAT 问题取一个逻辑公式,比如(A or not B) and (B or C),问有没有办法让它成真(也就是满足它)。在这种情况下,当然有。例如,我们可以设置A = B = True。为了证明这是 NP 完全的,考虑 NP 中的任意问题 FOO,以及如何将其简化为 SAT。这个想法是首先构造一个 NTM,它将在多项式时间内求解 FOO。从定义上来说这是可能的(因为 FOO 在 NP 中)。然后,对于 FOO 的给定实例 bar (也就是说,对于机器的给定输入),您将(在多项式时间内)构造一个逻辑公式(多项式大小),表示如下:

  • 机器的输入是
  • 这台机器工作正常。
  • 机器停下来回答“是”

棘手的部分是你如何用布尔代数来表达它,但是一旦你这么做了,很明显 NTM 实际上是由这个逻辑公式给出的 SAT 问题模拟的。如果公式是可满足的——也就是说,如果(且仅当)我们可以通过为各种变量(代表机器做出的神奇选择等)赋予真值来实现它,那么原始问题的答案应该是“是”。

概括一下,库克-莱文定理说 SAT 是 NP 完全的,这个证明基本上给了你一个用 SAT 问题模拟 ntm 的方法。这适用于基本 SAT 问题及其近亲电路 SAT,其中我们使用逻辑(数字)电路,而不是逻辑公式。

这里的一个重要思想是,所有逻辑公式都可以写成所谓的 合取范式 (CNF),也就是说,作为子句的合取(一系列and s),其中每个子句都是一系列or s。变量的每次出现可以是形式A或其否定形式not A。这些公式可能一开始就不在 CNF 中,但是它们可以被自动(高效地)转换。例如,考虑公式A and (B or (C and D))。与 CNF 的另一个公式完全等价:A and (B or C) and (B or D)

因为任何公式都可以被有效地改写成(不太大的)CNF 版本,所以 CNF SAT 是 NP 完全的就不足为奇了。有意思的是,即使我们将每个子句的变量个数限制为 k ,得到所谓的 k -CNF-SAT(或者简单的说 k -SAT)问题,只要 k > 2,仍然可以表现出 NP-完全性。你会看到很多 NP 完全性证明都是基于 3-SAT 是 NP 完全的事实。

2-SAT NP 完全吗?谁知道…

当处理复杂类时,您需要注意特殊情况。例如,背包问题的变体(或者子集和,稍后您会遇到)被用于加密。事实是,背包问题的许多情况都很容易解决。事实上,如果背包容量以多项式为界(作为物品数量的函数),问题就在 P(见练习 11-3)。如果在构造问题实例时不小心,加密很容易被破解。

我们和 k -SAT 也有类似的情况。对于 k ≥3,这个问题是 NP 完全的。然而,对于 k =2,它可以在多项式时间内求解。或者考虑最长路径问题。一般来说这是 NP 难的,但是如果你碰巧知道你的图是一个 DAG,你可以在线性时间内求解。事实上,在一般情况下,即使是最短路径问题也是 NP 难的。这里的解决方案是假设不存在负循环。

如果您不使用加密,这种现象是个好消息。这意味着,即使你遇到了一个问题,它的一般形式是 NP 完全的,你需要处理的具体实例可能是在 p 中,这是一个你可能称之为硬度不稳定性的例子。稍微调整一下你的问题的需求会有很大的不同,让一个棘手的问题变得容易处理,甚至让一个无法决定的问题(比如停机问题)变得可以决定。这就是近似算法(稍后讨论)如此有用的原因。

这是否意味着 2-SAT 不是 NP 完全的?事实上,没有。得出这个结论是一个容易陷入的陷阱。只有当 P ≠ NP 时才成立,否则 P 中的所有问题都是 NP 完全的。换句话说,我们的 NP 完全性证明对于 2-SAT 是失败的,我们可以证明它在 P 中,但是我们不知道它在 NPC 是 ?? 而不是 ??。

现在我们有了一个起点:SAT 和它的好朋友,Circuit SAT 和 3-SAT。然而,仍然有许多问题需要研究,复制库克和莱文的壮举似乎有点令人生畏。例如,你如何证明 NP 中的每一个问题都可以通过寻找一个城镇之旅来解决?

这是我们(最终)开始着手削减的地方。让我们来看一个相当简单的 NP 完全问题,即寻找哈密尔顿圈。我已经在第五章中提到了这个问题(在侧栏“在加里宁格勒跳岛”)。问题是确定一个有 n 个节点的图是否有一个长度为 n 的圈;也就是说,你能准确地访问每个节点一次,然后沿着图的边回到你的起点吗?

这看起来并不像 SAT 问题那样具有表现力——毕竟,在 SAT 问题中,我们可以使用命题逻辑的完整语言——所以对 ntm 进行编码似乎有点多。如你所见,事实并非如此。汉密尔顿循环问题和 SAT 问题一样具有表现力。我的意思是,从 SAT 到哈密尔顿圈问题有一个多项式时间的缩减。换句话说,我们可以使用汉密尔顿循环问题的机制来创建一个 SAT 解决机器!

我将带你了解细节,但在此之前,我想请你记住你脑海中的大图:我们正在做的事情的总体想法是,我们将一个问题作为一种机器来处理,我们几乎要给那台机器编程来解决一个不同的问题。这种简化就是隐喻编程。考虑到这一点,让我们看看如何将布尔公式编码为图形,以便用一个哈密尔顿循环来表示满意度…

为了简单起见,让我们假设我们想要满足的公式是 CNF 形式的。我们甚至可以假设 3-SAT(虽然这并不是真正必要的)。这意味着我们需要满足一系列子句,在每一个子句中,我们需要满足至少一个元素,这些元素可以是变量(如A)或它们的否定(not A)。真值需要用路径和循环来表示,那么假设我们将每个变量的真值编码为一个路径的方向

这个想法在图 11-3 中有说明。每个变量都由单行节点表示,这些节点用反平行边链接在一起,这样我们就可以从左到右或者从右到左移动。一个方向(比如从左到右)表示变量被设置为,而另一个方向表示。只要我们有足够的节点,节点的数量并不重要。 8

9781484200568_Fig11-03.jpg

图 11-3 。一个“行”,代表我们试图满足的布尔表达式的变量。如果循环从左向右通过,变量为真;否则,它就是假的

在我们开始尝试对实际公式进行编码之前,我们希望强制我们的机器将每个变量设置为两个可能的逻辑值中的一个。也就是说,我们要确保任何哈密尔顿循环都会经过每一行(方向给我们真值)。我们还必须确保循环在从一行到下一行时可以自由转换方向,这样变量就可以彼此独立地赋值。我们可以通过用两条边将每一行连接到下一行,在两端的锚点处(在图 11-3 中突出显示),如图图 11-4 所示。

9781484200568_Fig11-04.jpg

图 11-4 。这些行是链接在一起的,所以当从一个变量到下一个变量时,哈密尔顿循环可以保持或改变它的方向,让 A 和 B 彼此独立地为真或为假

如果我们只有如图图 11-4 所示的一组连接的行,那么图中就没有哈密尔顿圈。我们只能从一排走到下一排,没有办法再站起来。然后,对基本行结构的最后一点修改是在顶部添加一个源节点 s (具有到第一行的左右锚点的边)和在底部添加一个接收器节点 t (具有来自最后一行的左右锚点的边),然后添加从 ts 的边。

在继续之前,你应该说服自己,这个结构确实做了我们想要它做的事情。对于 k 变量,我们到目前为止构建的图将具有 2 个 k 不同的哈密尔顿循环,一个用于变量的真值的每个可能赋值,真值由给定行中向左或向右的循环表示。

既然我们已经在汉密尔顿机器中编码了将真值赋给一组逻辑变量的想法,我们只需要一种方法来编码涉及这些变量的实际公式。我们可以通过为每个子句引入一个节点来做到这一点。一个哈密尔顿循环将必须精确地访问这些中的每一个一次。诀窍是将这些子句节点挂接到我们现有的行上,以利用这些行已经编码了真值这一事实。我们进行设置,以便循环可以通过子句节点从路径中绕道,但是只有在正确的方向上才是*。因此,例如,如果我们有子句(A or not B),我们将向 A 行添加一个迂回,要求循环从左到右,并且我们向 B 行添加另一个迂回(通过相同的子句节点),但是这次是从右到左(因为not)。我们需要注意的唯一一件事是,没有两条弯路可以链接到相同位置的行——这就是为什么我们需要在每一行中有多个节点,这样我们就有足够多的节点用于所有的子句。你可以在图 11-5 中看到我们的例子是如何工作的。*

9781484200568_Fig11-05.jpg

图 11-5 。使用子句节点(突出显示)对子句(A 或 not B)进行编码,并添加要求 A 为真(从左到右)和 B 为假(从右到左)的迂回路径,以满足子句(即访问节点)

以这种方式对子句进行编码后,只要每个子句的变量中至少有一个具有正确的真值,就可以满足每个子句,让它绕过子句节点。因为一个哈密尔顿循环必须访问每个节点(包括每个子句节点),所以满足公式的-部分。换句话说,逻辑公式是可满足的,当且仅当在我们构建的图中存在哈密尔顿圈。这意味着我们已经成功地将 SAT(或者更具体地说,CNF-SAT)简化为 Hamilton 圈问题,从而证明后者是 NP 完全的!这有那么难吗?

好吧,确实有点难。至少你自己想这样的事情会很有挑战性。幸运的是,许多 NP 完全问题比 SAT 和汉密尔顿圈问题更相似,正如你将在下文中看到的。

没完没了的故事

这个故事还有更多。事实上,这个故事还有很多,你不会相信的。复杂性理论是一个独立的领域,有的结果,更不用说复杂性类了。(为了一瞥正在被研究的类的多样性,你可以去复杂性动物园。)

这个领域的一个形成性例子是一个比 NP 完全问题难得多的问题:艾伦·图灵的停顿问题(在第四章中提到)。它只是要求你确定一个给定的算法是否会终止于一个给定的输入。为了理解为什么这实际上是不可能的,假设您有一个函数halt,它将一个函数和一个输入作为其参数,这样如果A(X)终止,那么halt(A, X)将返回 true,否则返回 false。现在,考虑以下函数:

def trouble(A):

调用halt(A, A)确定A在应用于自身时是否暂停。这样还舒服吗?如果你评价trouble(trouble)会怎么样?基本上,它停了就不停,不停就停……我们有一个悖论(或者矛盾),意思是halt不可能存在。停顿问题是无法决定的。换句话说,解决它是不可能的。

但你认为不可能很难?正如一位伟大的拳击手曾经说过的,不可能就是一切。事实上,有一种东西叫做高度不可判定,或者“非常不可能”对于这些有趣的介绍,我推荐大卫·哈雷尔的电脑有限公司:他们真的不能做什么

怪兽之家

在这一节中,我将简要介绍几千个已知的 NP 完全问题中的几个。请注意,这里的描述同时服务于两个目的。第一个,也是最明显的,目的是给你一个大量困难问题的概述,这样你就可以更容易地认识到(并证明)你在编程中可能遇到的任何困难。我可以通过简单地列出(并简要描述)问题来给你一个概述。然而,我也想给你一些硬度证明如何工作的例子,所以我将在这一节描述相关的减少。

背包的回归

这一节的问题大多是关于选择子集的。这是一种你在很多场合都会遇到的问题。也许你正试图选择在一定的预算内完成哪些项目?还是把不同大小的箱子装进尽可能少的卡车里?或者,也许你正试图用一组箱子装满一组固定的卡车,这将给你带来尽可能多的利润?幸运的是,这些问题中的许多在实践中有相当有效的解决方案(例如第八章中的背包问题的伪多项式解决方案和本章稍后讨论的近似),但是如果你想要一个多项式算法,你可能就不走运了。 9

Image 伪多项式解只为一些 NP-hard 问题所知。事实上,对于很多 NP 难的问题,你找不到的伪多项式解,除非 P = NP。Garey 和 Johnson 称这些为强意义上的 NP 完全。(更多细节,请参见他们的书《计算机和棘手问题》中的第 4.2 节。)

背包问题现在应该很熟悉了。我在第七章的中重点讨论了分数版本,在第八章的中,我们使用动态编程构造了一个伪多项式解。在这一节中,我将研究背包问题本身和它的几个朋友。

先说看似简单的事情, 10 所谓的分区问题。这看起来真的很无辜——这只是公平分配的问题。最简单的形式是,划分问题要求你获取一个数字列表(比如说整数),并将其划分为两个和相等的列表。将 SAT 简化为分区问题有点复杂,所以我只想请你在这一点上相信我(或者,更确切地说,参见 Garey 和 Johnson 的解释)。

不过,从分区问题转移到其他问题要容易得多。因为看起来复杂性很低,所以使用其他问题来模拟分区问题会很容易。就拿箱包装的问题来说吧。这里我们有一组大小在 0 到 k 范围内的物品,我们想把它们装进大小为 k 的箱子里。从划分问题中进行简化相当容易:我们只需将 k 设置为数字总和的一半。现在,如果装箱问题设法将数字塞进两个箱子,那么划分问题的答案是肯定的;否则,答案是否定的。这意味着装箱问题是 NP 难的。

另一个众所周知的简单陈述的问题是所谓的子集和问题。这里你又一次有了一组数字,你想找到一个子集,它的总和等于某个给定的常数, k 。再次,找到一个减少是足够容易的。例如,我们可以通过(再次)将 k 设置为数字之和的一半来减少划分问题。子集和问题的一个版本将 k 锁定为零——尽管这个问题仍然是 NP 完全的(练习 11-4)。

现在,让我们看看实际的(整数的,非分数的)背包问题。先处理 0-1 版本。如果我们愿意,我们可以从划分问题中再次约简,但我认为从子集和中约简更容易。背包问题也可以被公式化为一个决策问题,但是假设我们正在使用我们以前见过的相同优化版本:我们希望最大化项目值的总和,同时保持项目大小的总和低于我们的容量。让每一项都是子集和问题中的一个数,让价值和重量都等于那个数。

现在,我们能得到的最好的可能答案是我们与背包容量完全匹配。只要把容量设为 k ,背包问题就会给出我们所寻求的答案:能否把背包装满,等价于能否找到一笔 k

为了总结这一节,我将简单地触及一个最明显的问题: 整数编程。这是线性规划技术的一个版本,其中线性函数在一组线性约束下被优化。然而,在整数编程中,你还要求变量只能取整数值——这打破了所有现有的算法。这也意味着你可以减少各种各样的问题,这些背包式的问题就是一个明显的例子。事实上,我们可以证明 0-1 整数规划这种特殊情况是 NP 难的。假设背包问题的每一项都是一个变量,可以取值为 0 或 1。然后在这些基础上建立两个线性函数,分别以值和权重作为系数。您可以根据值优化一个,并根据权重将一个限制在容量以下。结果会给你背包问题的最优解。 11

无界积分背包呢?在第八章中,我算出了一个伪多项式解,但是真的是 NP 难吗?当然,它看起来确实与 0-1 背包关系密切,但这种对应关系并不十分密切,因此减少是明显的。事实上,这是一个很好的机会来试着做一个缩减——所以我将指导你做练习 11-5。

派系和色彩

让我们从数字子集转移到寻找图形中的结构。这些问题中有许多是关于冲突的。例如,您可能正在为一所大学编写一个日程安排软件,并且您正试图最小化涉及教师、学生、班级和礼堂的时间冲突。祝你好运。或者你正在编写一个编译器,你想通过找出哪些变量可以共享一个寄存器来最大限度地减少使用的寄存器数量?和以前一样,您可能在实践中找到可接受的解决方案,但是您可能无法在一般情况下最优地解决大型实例。

我已经多次讨论过二分图——其节点可以分成两个集合的图,这样所有的边都在集合之间(也就是说,没有边连接同一集合中的节点)。另一种方式可以看作是双色,其中你将每个节点涂成黑色或白色(举例来说),但是你要确保没有邻居有相同的颜色。如果这是可能的,那么这个图就是二部图。

现在,如果你想看看一个图是否是三分的,也就是说,你是否能管理一个三色的?事实证明,这并不容易。(当然,一个k-给 k > 3 上色也不容易;参见练习 11-6。)把 3-SAT 简化为三色,其实也没那么难。然而,这有点复杂(就像本章前面的哈密尔顿循环证明一样),所以我只告诉你它是如何工作的。

基本上,您构建一些专门的组件或小部件,就像 Hamilton 循环证明中使用的行一样。这里的想法是首先创建一个三角形(三个相连的节点),其中一个代表真,一个代表假,一个是所谓的节点。对于一个变量A,然后创建一个三角形,其中包含一个节点A,一个节点not A,第三个节点是基节点。这样,如果A获得与真实节点相同的颜色,那么not A将获得虚假节点的颜色,反之亦然。

此时,为每个子句构建一个小部件,将Anot A的节点链接到其他节点,包括真节点和假节点,因此找到三色的唯一方法是变量节点之一(形式为Anot A)获得与真节点相同的颜色。(如果你尝试一下,你可能会找到这样做的方法。如果你想要完整的证明,可以在几本算法书里找到,比如 Kleinberg 和 Tardos 的那本;参见第一章中的“参考资料”。)

现在,假设k-着色是 NP-完全的(对于 k > 2),那么寻找一个图的色数-你需要多少种颜色。如果色数小于等于 k ,那么k-着色问题的答案是肯定的;否则,答案是否定的。这种问题可能看起来很抽象,很没用,但事实远非如此。这对于需要确定某些类型的资源需求的情况来说是一个基本问题,例如,对于编译器和并行处理都是如此。

让我们来看看确定一个代码段需要多少寄存器(某种有效的内存槽)的问题。要做到这一点,你需要弄清楚哪些变量将被同时使用。变量是节点,任何冲突都用边来表示。冲突仅仅意味着两个变量同时被使用,因此不能共享一个寄存器。现在,找到可以使用的最小数量的寄存器相当于确定这个图的色数。

k 的近亲——着色就是所谓的 小团体掩护问题(又称分派系)。正如你可能记得的,团就是一个完全的图,尽管这个术语通常用于指一个完全的子图。在这种情况下,我们希望将一个图分成几个集团。换句话说,我们希望将节点分成几个(不重叠的)集合,这样在每个集合中,每个节点都相互连接。一会儿我会告诉你为什么这是 NP-hard,但是首先,让我们仔细看看集团。

简单地确定一个图是否有一个给定大小的团是 NP 完全的。假设你正在分析一个社交网络,你想看看是否有一群 k 人,其中每个人都是彼此的朋友。没那么容易…优化版本,max-clique,当然至少一样难。从 3-SAT 到 clique 问题的简化再次涉及到创建逻辑变量和子句的模拟。这里的想法是为每个子句使用三个节点(每个字面量一个节点,无论它是变量还是它的否定),然后在所有节点之间添加边,这些节点代表与兼容的字面量,也就是那些可以同时为真的节点。(换句话说,你在除了变量和它的否定之间的所有节点之间添加边,比如Anot A。)

你做不是,但是,在一个子句里面加边*。这样,如果您有 k 子句,并且您正在寻找一个大小为 k 的小团体,那么您就是在强制每个子句中的至少有一个节点在这个小团体中。这样一个集团将代表一个有效的赋值给变量的真值,你将通过找到一个集团解决 3-SAT 问题。(科尔曼等人给出了详细的证明;参见第一章中的“参考资料”。)*

小团体问题有一个非常相近的亲戚——一阴一阳,如果你愿意的话——叫做 独立组问题。这里的挑战是找到一组 k 的独立节点(即彼此没有任何边的节点)。优化版本是寻找图中最大的独立集。这个问题可以应用于资源调度,就像图着色一样。例如,在某种形式的交通系统中,如果一个十字路口的不同车道不能同时使用,那么它们就是冲突的。你用表示冲突的边拼凑一个图,最大的独立集将给出在任何时候都可以使用的最大数量的车道。(在这种情况下,更有用的当然是找到一个将划分为独立集合的;我会回来的。)

你看到家族和小团体的相似之处了吗?没错。这是完全一样的,除了我们现在想要的不是边,而是没有边。为了解决独立集问题,我们可以简单地解决图的补图上的团问题——其中每个边都已被移除,每个缺失的边都已被添加。(换句话说,邻接矩阵中的每个真值都被求逆了。)类似地,我们可以使用独立集问题来解决小团体问题——因此我们已经简化了两种方法。

现在让我们回到小团体掩护的想法。我确信你可以看到,我们也可以在补图中寻找一个独立集覆盖(也就是说,将节点划分成独立集)。问题的重点是找到一个由 k 个小集团(或独立集)组成的封面,优化版试图最小化 k 。请注意,在独立的集合中没有冲突(边),因此同一集合中的所有节点都可以接收相同的颜色。换句话说,寻找一个k-团划分本质上等价于寻找一个k-着色,我们知道这是 NP-完全的。等价地,两个优化版本都是 NP 难的。

另一种覆盖是顶点(或节点)覆盖,它由图中节点的子集组成,并覆盖边。也就是说,图中的每条边都与封面中的至少一个节点相关联。决策问题要求你找到一个最多由 k 个节点组成的顶点覆盖。我们马上就会看到,当图有一个至少由 n - k 个节点组成的独立集合时,这种情况就会发生,其中 n 是图中节点的总数。这给了我们一个双向的归约,就像集团和独立集之间的归约一样。

这种简化非常简单。基本上,一个节点集是一个顶点覆盖当且仅当其余的节点形成一个独立的集合。考虑不在顶点覆盖中的任意一对节点。如果他们之间有一个优势,它不会被覆盖(一个矛盾),所以他们之间不可能有优势。因为这适用于封面外的任何一对节点,所以这些节点形成一个独立的集合。(当然,单个节点可以独立工作。)

这种暗示也是反过来的。假设你有一个独立的集合——你明白为什么剩下的节点必须形成一个顶点覆盖了吗?当然,任何没有连接到独立集的边都将被剩余的节点覆盖。但是如果一条边连接到你的一个独立节点呢?嗯,它的另一端不可能在独立集合中(那些节点没有连接),这意味着边被外部节点覆盖。换句话说,顶点覆盖问题是 NP 完全的(或者在其优化版本中是 NP 困难的)。

最后,我们有集合覆盖问题,它要求你找到一个所谓的大小至多为 k 的集合覆盖(或者,在优化版本中,找到最小的一个)。基本上,你有一个集合 S 和另一个集合 F ,由 S 的子集组成。 F 中所有集合的并集等于 S 。你试图找到一个覆盖了所有元素的子集。要对此有一个直观的理解,可以从节点和边的角度来考虑。如果 S 是一个图的节点, F 是边(也就是节点对),你将试图找到覆盖(关联)所有节点的最小数量的边。

Image 小心这里用的例子就是所谓的边缘覆盖问题。虽然它是集合覆盖问题的一个有用的例子,但是你不应该得出结论说边覆盖问题是 NP 完全的。事实上,它可以在多项式时间内解决。

应该很容易看出集合覆盖问题是 NP 难的,因为顶点覆盖问题基本上是一个特例。只要让 S 是一个图的边, F 由每个节点的邻居集组成,你就大功告成了。

路径和电路

这是我们最后一组动物——我们正在接近这本书开头的那个问题。这种材料主要与有效导航有关,当对您必须经过的位置(或州)有要求时。例如,你可能试图为一个工业机器人设计出运动模式,或者一些电子电路的布局。你可能不得不再次满足于近似值或特殊情况。我已经展示了寻找一个哈密顿循环是多么令人畏惧的前景。现在,让我们看看是否可以从这些知识中找出一些其他硬路径和电路相关的问题。

首先,我们来考虑方向的问题。我给出的对哈密尔顿圈的检查是 NP-完全的证明是基于使用一个有向图(并且,因此,找到一个有向圈)。无向的情况呢?看起来我们丢失了一些信息,早期的证据在这里不成立。然而,通过一些 widgetry,我们可以用无向图模拟方向!

这个想法是将有向图中的每个节点分成三个,基本上用长度为 2 的路径来代替。想象一下给节点着色:将原始节点着色为蓝色,但是添加了红色的入节点和绿色的出节点。所有有向入边现在都变成了链接到红色入节点的无向边,出边链接到绿色出节点。显然,如果原来的图有一个哈密尔顿圈,那么新的图也会有。挑战在于从另一个方面得到暗示——我们需要“如果且仅如果”来使缩减有效。

想象我们的新图有汉密尔顿圈。这个循环的节点颜色可以是“…红色、蓝色、绿色、红色、蓝色、绿色…”或者“…绿色、蓝色、红色、绿色、蓝色、红色…”在第一种情况下,蓝色节点将表示原始图中的有向哈密尔顿循环,因为它们仅通过它们的入节点(表示原始的入边)进入,并通过出节点离开。在第二种情况下,蓝色节点将代表一个反向定向汉密尔顿循环——这也告诉我们需要知道什么(也就是说,我们在另一个方向上有一个可用的定向汉密尔顿循环)。

所以,现在我们知道有向和无向哈密尔顿圈基本上是等价的(见练习 11-8)。所谓的哈密尔顿路径问题呢?这类似于循环问题,除了你不再需要结束在你开始的地方。看起来可能会容易一点?不好意思。没有骰子。如果你能找到一条哈密尔顿路径,你可以用它来找到一个哈密尔顿圈。让我们考虑一下有向情况(无向情况见练习 11-9)。取任意一个节点 v 的内外边。(如果没有这样的节点,就不可能有哈密顿圈。)将其分成两个节点, vv ,保持所有的入边指向 v ,所有的出边从 v 开始。如果原始图有一个哈密尔顿循环,转换后的图将有一条哈密尔顿路径,从 v 开始,到 v 结束(我们基本上只是在 v 处剪短了循环,形成了一条路径)。相反,如果新图有哈密尔顿路径,那么它必须v 开始(因为它没有内边),同样,它必须在 v 结束。通过将这些节点合并在一起,我们在原始图中得到一个有效的哈密尔顿圈。

Image 注意上一段的“相反地……”部分确保我们在两个方向都有暗示。这一点很重要,这样在使用归约时“是”和“否”的答案都是正确的。然而,这并不而不是意味着我在两个方向上都减少了。

现在,也许你开始看到最长路径问题的问题,我已经提到过几次了。事情是,找到两个节点之间的最长路径将让您检查汉密尔顿路径的存在!您可能不得不使用每一对节点作为搜索的终点,但这只是一个二次因素,缩减仍然是多项式的。正如我们所见,图是否有向并不重要,增加权重只是将问题一般化。(非循环情况见练习 11-11。)

最短路径呢?在一般情况下,寻找最短路径完全等价于寻找最长路径。你只需要否定所有的边权重。然而,当我们在最短路径问题中不允许负循环时,就像在最长路径问题中不允许正循环一样。在这两种情况下,我们的归约都失败了(练习 11-12),我们不再知道这些问题是否是 NP 难的。(事实上,我们坚信它们是而不是,因为我们可以在多项式时间内解决它们。)

Image 注意当我说我们不允许负循环时,我指的是图中的*。对于路径本身中的负循环没有特别的禁令,因为它们被假定为简单路径*,因此根本不能包含任何循环,无论是负循环还是其他循环。**

**现在,终于,我开始了解为什么很难找到一个最佳的瑞典之旅这个伟大的(或者,到现在为止,也许不是那么伟大的)谜团。如上所述,我们正在处理旅行推销员问题,或 TSP。这个问题有几个变种(大多数也是 NP 难的),但我将从最简单的一个开始,其中你有一个加权无向图,你想找到一条通过所有节点的路线,使路线的加权和尽可能小。实际上,我们试图做的是找到最便宜的汉密尔顿循环——如果我们能够找到,我们也已经确定那里汉密尔顿循环。换句话说,TSP 也一样辛苦。

9781484200568_unFig11-02.jpg

旅行推销员问题。 最佳线性规划割平面技术的复杂度是多少?我到处都找不到它。男人,加菲猫的家伙没有这些问题… ( http://xkcd.com/399 )

但是还有另一个 TSP 的常见版本,其中图被假设为完成。在一个完整的图中,总会有一个哈密尔顿圈(如果我们至少有三个节点),所以归约实际上不再起作用了。现在怎么办?实际上,这并不像看起来那样有问题。通过将多余边的边权重设置为某个非常大的值,我们可以将以前的 TSP 版本简化为图必须完整的情况。如果它足够大(大于其他权重的总和),我们将找到一条穿过原始边的路径,如果可能的话。

但是,对于许多实际应用来说,TSP 问题似乎过于普遍。它允许完全任意的边权重,而许多路线规划任务不需要这种灵活性。例如,规划穿过地理位置的路线或机器人手臂的移动只需要我们能够在欧几里得空间中表示距离。这给了我们更多关于这个问题的信息,这应该会使它更容易解决——对吗?再次抱歉。不。显示欧几里德 TSP 是 NP-hard 有点复杂,但是让我们看一个更一般的版本,它仍然比一般的 TSP 具体得多:度量 TSP 问题

A 公制 是一个距离函数 d ( ab ),度量两点 ab 之间的距离。然而,这不一定是直线的欧几里德距离。例如,在制定飞行路线时,您可能想要测量沿测地线(沿地球表面的曲线)的距离,在布置电路板时,您可能想要分别测量水平和垂直距离,将两者相加(产生所谓的曼哈顿距离出租车距离)。有许多其他的距离(或类似距离的函数)可以作为度量标准。要求是它们是对称的、非负的实值函数,仅从一个点到其自身的距离为零。还有,他们需要遵循三角不等式 : d ( ac)≤d(ab)+d(bc )。这只是意味着两点之间的最短距离直接由度量给出——你不能通过穿过一些其他点找到捷径。

证明这仍然是 NP 难的并不太难。我们可以从哈密尔顿循环问题中减少。因为三角不等式,我们的图必须是完整的。 13 仍然,我们可以让原来的边得到一个权值,而增加的边,一个权值,比如说,两个(仍然不打破东西)。度量 TSP 问题将给出度量图的最小权 Hamilton 圈。因为这样的圈总是由相同数量的边(每个节点一条)组成,当且仅当在原始的任意图中存在哈密尔顿圈时,它将由原始的(单位权)边组成。

尽管度量 TSP 问题也是 NP 难的,但在下一节中,您将看到它在一个非常重要的方面不同于一般的 TSP 问题:对于度量情况,我们有多项式近似算法,而近似一般的 TSP 本身就是 NP 难的问题。

当事情变得艰难时,聪明人会变得草率

正如我所承诺的,在向你展示了许多看起来相当无辜的问题实际上难以想象的困难之后,我将向你展示一条出路:草率。我在前面提到了“硬度的不稳定性”的概念,即使是对问题需求的微小调整也能让你从完全糟糕变得非常好。您可以进行多种调整——我将只介绍两种。在这一节中,我将向你展示如果你在寻找最优时允许一定比例的草率会发生什么;在下一节中,我们将看看算法设计的“手指交叉”学派。

让我首先阐明近似的概念。基本上,我们将允许算法找到一个可能不是最优的解决方案,但其值最多是一个给定的百分比。更常见的是,这个百分比被作为一个因子,或近似比率给出。例如,对于比率 2,最小化算法将保证我们的解至多是最优解的两倍,而最大化问题将给出我们的解至少是最优解的一半。 14 让我们回到我在第七章中所做的承诺,来看看这是如何运作的。

我说的是,无界整数背包问题可以用贪婪来近似到两倍以内。至于精确的贪婪算法,在这里设计解决方案是琐碎的(只需使用与分数背包相同的贪婪方法);问题是证明它是正确的。如果我们不断添加具有最高单位价值(即价值-重量比)的物品类型,我们怎么能保证达到至少一半的最佳价值呢?当我们不知道最佳值是什么时,我们怎么能知道这个呢?

这是近似算法的关键点。我们不知道近似值与最佳值的确切比例——我们只是保证它会变得多糟糕。这意味着,如果我们在上得到最优能得到有多好的估计,我们可以用它来代替实际的最优,我们的答案仍然有效。让我们考虑最大化的情况。如果我们知道最优值永远不会比 A 大*,并且我们知道我们的近似值永远不会比 B 小*,我们就可以确定这两者的比值永远不会大于 A/B**

**对于无界背包,你能想出你能达到的价值的某个上限吗?好吧,我们没有比用具有最高单位价值的项目类型填满背包更好的了(有点像无限分数解决方案)。这样的解决方案很可能是不可能的,但是我们肯定不能做得更好。设这个乐观界限为 a。

能不能给我们的近似值一个下界 B,或者至少说一下 A/B 的比值?考虑您添加的第一个项目。假设它使用了一半以上的容量。这意味着我们不能添加更多的这种类型,所以我们已经比假设的 A 更糟了。但是我们用最好的物品类型填充了至少一半背包,所以即使我们现在停止,我们知道 A/B 最多是 2。如果我们设法增加更多项目,情况只会有所改善。

如果第一项没有使用超过一半的容量怎么办? 16 好消息,各位:我们又可以增加一项同类了!事实上,我们可以继续添加这类项目,直到我们使用了至少一半的容量,确保近似比率的界限仍然成立。

有许许多多的近似算法——仅关于这个主题的书就有很多。如果你想更多地了解这个话题,我建议你去买一本(Williamson 和 Shmoys 的《近似算法的设计》和 Vijay V. Vazirany 的《?? 近似算法》都是很好的选择)。不过,我将向您展示一个特别漂亮的算法,用于近似度量 TSP 问题。

我们要做的是,再次找到某种无效的、乐观的解决方案,然后调整它,直到我们得到一个有效的(但可能不是最优的)解决方案。更具体地说,我们的目标是某个东西(不一定是有效的哈密尔顿循环),它的权重至多是最优解的两倍,然后使用捷径(三角形不等式保证不会使事情变得更糟)调整和修复那个东西,直到我们实际上得到一个哈密尔顿循环。那么这个周期也将至多是最佳长度的两倍。听起来像个计划,不是吗?

然而,有什么东西离汉密尔顿圈只有几条捷径,而长度至多是最优解的两倍?我们可以从更简单的开始:什么能保证重量不大于最短的汉密尔顿圈?我们知道怎么找到的东西?最小生成树!好好想想。汉密尔顿圈连接所有节点,而连接所有节点的绝对最便宜的方法是使用最小生成树。

然而,一棵树不是一个循环。TSP 问题的思想是,我们将访问每个节点,从一个节点走到下一个节点。我们当然也可以沿着树的边缘访问每个节点。如果特里莫是一名推销员,他可能会这么做(见第五章)。换句话说,我们可以以深度优先的方式沿着边,回溯到其他节点。这给了我们一个图的封闭行走,而不是一个循环(因为我们正在重新访问节点和边)。不过,想想这段封闭路程的重量吧。我们沿着每条边走两次,所以它是生成树重量的两倍。让这成为我们乐观(但无效)的解决方案。

度量案例的伟大之处在于,我们可以跳过回溯,走捷径。我们不用沿着已经看到的边往回走,访问已经经过的节点,我们可以直接去下一个未访问的节点。由于三角不等式,我们保证这不会降低我们的解决方案,所以我们最终得到一个近似比率界限 2!(这种算法通常被称为“绕树两圈”算法,尽管你可能会认为这个名字没有多大意义,因为我们只绕树一圈。)

实现这个算法可能看起来并不完全简单。实际上,有点像。一旦我们有了生成树,我们需要做的就是遍历它,避免多次访问节点。仅仅报告在 DFS 期间发现的节点实际上会给我们提供我们想要的解决方案。你可以在清单 11-1 中找到这个算法的实现。你可以在清单 7-5 的中找到prim的实现。

清单 11-1 。“绕树两圈”算法,度量 TSP 的 2-近似

from collections import defaultdict

def mtsp(G, r):                                 # 2-approx for metric TSP
    T, C = defaultdict(list), []                # Tree and cycle
    for c, p in prim(G, r).items():             # Build a traversable MSP
        T[p].append(c)                          # Child is parent's neighbor
    def walk(r):                                # Recursive DFS
        C.append(r)                             # Preorder node collection
        forin T[r]: walk(v)                  # Visit subtrees recursively
    walk(r)                                     # Traverse from the root
    return C                                    # At least half-optimal cycle

有一种方法可以改进这种近似算法,这种方法在概念上很简单,但在实践中相当复杂。它被称为克里斯托菲德斯算法,其思想是在生成树的奇数度节点之间创建一个最小成本匹配,而不是遍历树的边缘两次。我们已经知道生成树并不比最优圈差。还可以看出,最小匹配的权重不大于最优周期的一半(练习 11-15),所以总的来说,这给了我们一个 1.5 的近似值,这是迄今为止该问题已知的最佳界限。问题是,寻找最小成本匹配的算法相当复杂(它肯定比寻找最小成本二分匹配差得多,如第十章中的所讨论的那样),所以我不打算在这里详述。

假设我们可以找到一个距离最优值 1.5 倍的度量 TSP 问题的解决方案,即使该问题是 NP-hard 的,但可能有点令人惊讶的是,找到这样一个近似算法——或在最优值的固定因子内的任何近似——本身就是 TSP 的 NP-hard 问题(即使 TSP 图是完整的)。事实上,这是几个问题的情况,这意味着我们不一定依赖近似作为所有 NP-hard 优化问题的实际解决方案。

为了了解为什么近似 TSP 是 NP 难的,我们从哈密尔顿循环问题简化到近似。你有一个图,你想知道它是否有哈密尔顿圈。为了得到 TSP 问题的完整图形,我们添加任何丢失的边,但是我们确保给它们巨大的边权重。如果我们的近似比是 k ,我们确保这些边权重大于 km ,其中 m 是原始图中的边数。那么,如果我们能找到原图的哈密尔顿之旅,那么新图的最佳之旅将至多是 m ,如果我们包括甚至一条新边,我们将打破我们的近似保证。这意味着,如果(且仅当)原始图中存在哈密尔顿圈,新图的近似算法将找到它——这意味着近似至少是一样困难的(即,NP 困难)。

拼命寻求解决方案

我们已经看到了硬度不稳定的一种方式——有时找到接近最优的解决方案比找到最优的解决方案要容易得多。不过,还有另一种马虎的方式。你可以创建一个算法,基本上是一个蛮力解决方案,但使用猜测来尽量避免计算。如果运气好的话,如果你要解决的问题不是很难,你可能会很快找到解决方案!换句话说,这里的草率不是关于解决方案的质量,而是关于运行时间的保证。

这有点像快速排序,它有二次最坏情况运行时间,但在平均情况下是对数线性的,常数因子非常低。对困难问题的大部分推理都是关于我们能对最坏情况下的性能给出什么样的保证,但实际上,这可能不是我们所关心的全部。事实上,即使我们不在 Russel Impagliazzo 的幻想世界 Algorithmica 中,我们也可能在他的另一个世界中,他称之为 Heuristica。这里,NP-hard 问题在最坏的情况下仍然是棘手的,但是在平均的情况下它们是易处理的。即使情况不是这样,它肯定通过使用启发式方法,我们可以经常解决看起来不可能的问题。

这方面有很多方法。例如,在第九章的中讨论的 A*算法可用于搜索整个解决方案空间,以便找到一个正确或最优的解决方案。还有人工进化和模拟退火这样的启发式搜索技术(见本章后面的“如果你好奇……”)。不过,在这一节中,我将向您展示一个非常酷而且实际上非常简单的想法,它可以应用于本章中讨论的那些难题,但也可以作为解决任何类型的算法问题的快捷方式,甚至是那些有多项式解的问题。这可能是有用的,因为你想不出自定义算法,或者因为你的自定义算法太慢。

这项技术被称为 分支定界,在人工智能领域尤为知名。甚至有一个特殊版本(称为 alpha-beta 修剪 )用于玩游戏的程序。(例如,如果你有一个国际象棋程序,很可能其中会有一些分支和界限。)实际上,分支定界是解决 NP 难问题的主要工具之一,包括整数规划这样的一般性和表达性问题。尽管这种令人敬畏的技术遵循非常简单的模式,但很难以完全通用的方式实现。如果您要使用它,您可能需要实现一个针对您的问题定制的版本。

分支定界法,或称 B&B,是基于逐步构建解决方案,有点像许多贪婪算法(见第七章)。事实上,考虑哪个新的积木块往往是贪婪地选择的,导致所谓的最佳先分支定界。然而,不是完全致力于这个新的构建块(或这种扩展解决方案的方式),而是考虑所有的可能性。从本质上来说,我们正在处理一个蛮力解决方案。不过,能让这一切都起作用的是,通过推理探索的前景如何(或者更确切地说,前景如何),整个探索的途径都可以被修剪掉。

为了更具体,让我们考虑一个具体的例子。事实上,让我们再来看一个我们以前用几种方法处理过的问题,0-1 背包问题。1967 年,Peter J. Kolesar 发表了论文“背包问题的分支定界算法”,他在其中准确地描述了这种方法。正如他所说,“分支定界算法通过反复将所有可行解分成越来越小的子类,最终获得最优解。”这些“类”是我们通过构造部分解决方案得到的。

例如,如果我们决定将项目 x 包含在背包中,我们已经隐式地构造了包含 x 的所有解的类。当然,还有这个类的补充,所有做不做的解都包括 x 。我们将需要检查这两个类,除非我们能以某种方式得出结论,其中一个不能包含最优。你可以把这想象成一个树形的状态空间,这是第五章中提到的概念。每个节点由两个集合定义:背包中包含的物品和背包中不包含的物品。任何剩余的项目尚未确定。

在这个(抽象的,隐含的)树结构的根中,没有对象被包含或排除,所以所有的都是不确定的。为了将一个节点扩展成两个子节点(分支部分,,我们决定其中一个未决定的对象,并通过 include 得到一个子节点,通过 exclude 得到另一个子节点。如果一个节点没有未决定的项目,它就是一片叶子,我们不能继续下去。

应该清楚的是,如果我们完全探索这个树,我们将检查包含和排除的对象的每一种可能的组合(一种蛮力解决方案)。分支定界算法的整体思想是将修剪添加到我们的遍历中(就像在二分法和搜索树中一样),因此我们尽可能少地访问搜索空间。对于近似算法,我们引入了上界和下界。对于一个最大化问题,我们在最优解上使用一个下限(基于我们目前所发现的),在任何给定子树的解上使用一个上限(基于一些启发)。换句话说,我们在比较对最优值的保守估计和对给定子树的乐观估计。如果子树包含的保守界限比乐观界限更好,则该子树不能保持最优,因此它被修剪掉(包围部分的*)。*

在基本情况下,最优值的保守界限就是我们迄今为止发现的最佳值。当 B&B 开始运行时,让这个界限尽可能的高是非常有益的,所以我们可能想先在这上面花些时间。(例如,如果我们正在寻找一个度量 TSP 旅行,这是一个最小化问题,我们可以将初始上限设置为我们的近似算法的结果。)然而,为了使我们的背包例子简单,让我们只跟踪最佳解决方案,从零值开始。(练习 11-16 要求你对此进行改进。)

剩下的唯一难题是如何找到部分解的上限(表示搜索空间的子树)。如果我们不想失去实际解,这个界必须是一个真实的上界;我们不想排除基于过于悲观预测的子树。话又说回来,我们不应该太乐观(“这可能有无穷大的值!耶!”)因为那样我们就永远不能排除任何东西。换句话说,我们需要找到一个尽可能紧(低)的上限。一种可能性(也是 Kolesar 使用的一种)是假装我们正在处理分数背包问题,然后对其使用贪婪算法。这个解不会比我们寻找的实际最优解更差(练习 11-17),事实证明这是一个非常接近实际目的的解。

你可以在清单 11-2 的中看到 0-1 背包 B&B 的一个可能的实现。为了简单起见,代码只计算最优解的。如果您想要实际的解决方案结构(包括哪些项目),您将需要添加一些额外的簿记。正如您所看到的,不是为每个节点显式地管理两个集合(包括和排除的项目),而是只使用到目前为止包括的项目的权重和值,用一个计数器( m )指示哪些项目已经被考虑(按顺序)。每个节点都是一个生成器,它将(在提示时)生成任何有希望的子节点。

Image 注意在清单 11-2 中使用的nonlocal关键字让你修改周围范围内的变量,就像global让你修改全局范围一样。然而,这个特性是 Python 3.0 中的新特性。如果您想在早期的 Pythons 中获得类似的功能,只需用sol = [0]替换最初的sol = 0,然后使用表达式sol[0]而不仅仅是sol来访问该值。(有关更多信息,请参见 PEP 3104,可从http://legacy.python.org/dev/peps/pep-3104获得。)

这个故事的寓意是…

好吧。这一章可能不是书中最容易的一章,而且在日常编码中如何使用这里的一些主题可能也不完全清楚。为了阐明这一章的要点,我想我会试着给你一些建议,告诉你当你遇到一个棘手的问题时该怎么做。

  • 首先,遵循第四章中的前两条解题建议。你确定你真的了解这个问题吗?你是否已经在到处寻找简化(例如,你是否知道任何看起来有一点相关的算法)?
  • 如果你被难住了,再次寻找缩减,但是这次是从一些已知的 NP-hard 问题,而不是从你知道如何解决的问题。如果你找到一个,至少你知道这个问题很难,所以没有理由自责。
  • 考虑一下第四章的最后一点解决问题的建议:有没有什么额外的假设可以让问题不那么严重?最长路径问题通常是 NP 难的,但是在 DAG 中,你可以很容易地解决它。
  • 你能介绍一些松弛吗?如果你的解决方案不需要 100%最优,也许有一个近似算法可以使用?你可以设计一个或者研究这个主题的文献。如果你不需要多项式最坏情况的保证,也许像分支定界这样的东西可以工作?

清单 11-2 。用分枝定界策略求解背包问题

from __future__ import division
from heapq import heappush, heappop
from itertools import count

def bb_knapsack(w, v, c):
    sol = 0                                     # Solution so far
    n = len(w)                                  # Item count

    idxs = list(range(n))
    idxs.sort(key=lambda i: v[i]/w[i],          # Sort by descending unit cost
              reverse=True)

    def bound(sw, sv, m):                       # Greedy knapsack bound
        if m == n: return sv                    # No more items?
        objs = ((v[i], w[i]) forin idxs[m:]) # Descending unit cost order
        for av, aw in objs:                     # Added value and weight
            if sw + aw > c: break               # Still room?
            sw += aw                            # Add wt to sum of wts
            sv += av                            # Add val to sum of vals
        return sv + (av/aw)*(c-sw)              # Add fraction of last item

    def node(sw, sv, m):                        # A node (generates children)
        nonlocal sol                            # "Global" inside bb_knapsack
        if sw > c: return                       # Weight sum too large? Done
        sol = max(sol, sv)                      # Otherwise: Update solution
        if m == n: return                       # No more objects? Return
        i = idxs[m]                             # Get the right index
        ch = [(sw, sv), (sw+w[i], sv+v[i])]     # Children: without/with m
        for sw, sv in ch:                       # Try both possibilities
            b = bound(sw, sv, m+1)              # Bound for m+1 items
            if b > sol:                         # Is the branch promising?
                yield b, node(sw, sv, m+1)      # Yield child w/bound

    num = count()                               # Helps avoid heap collisions
    Q = [(0, next(num), node(0, 0, 0))]         # Start with just the root
    while Q:                                    # Any nodes left?
        _, _, r = heappop(Q)                    # Get one
        for b, u in r:                          # Expand it ...
            heappush(Q, (b, next(num), u))      # ... and push the children

    return sol                                  # Return the solution

如果其他方法都失败了,你可以实现一个看起来合理的算法,然后用实验来看看结果是否足够好。例如,如果你在安排讲课以尽量减少学生的课程冲突(这是一种很容易 NP 难的问题),你可能不需要保证结果是最优的,只要结果足够好。 20

摘要

这一章是关于难题和一些你可以做的事情来处理它们。有许多类(看起来)困难的问题,但是在这一章中最重要的一个是 NPC,NP 完全问题的类。NPC 构成了 NP 的核心,这类决策问题的解可以在多项式时间内得到验证——基本上是任何实际应用中的每一个决策问题。NP 中的每个问题都可以在多项式时间内归结为 NPC 中的每个问题(或任何所谓的 NP 难问题),这意味着如果任何 NP 完全问题都可以在多项式时间内解决,那么 NP 中的每个问题也可以在多项式时间内解决。大多数计算机科学家认为这种情况不太可能发生,尽管还没有证据证明这两种情况。

NP-完全和 NP-困难问题举不胜举,它们在许多情况下都会突然出现。这一章让你体验了这些问题,包括它们的硬度的简要证明草图。这种证明的基本思想是依靠 Cook-Levin 定理,该定理认为 SAT 问题是 NP 完全的,然后减少多项式时间,或者从其他一些我们已经证明是 NP 完全或 NP 困难的问题。

为实际处理这些难题而暗示的策略是基于受控的马虎。近似算法可让您精确控制您的答案与最优解的差距,而分支定界等启发式搜索方法可保证您获得最优解,但可能需要不确定的时间来完成。

如果你好奇的话…

有很多书讨论计算复杂性、近似算法和启发式算法;请参阅“参考资料”部分了解一些想法。

有一个领域我还没有涉及到,那就是所谓的 元启发式,一种启发式搜索的形式,它给出的保证很少,但却惊人的强大。例如,有人工进化,所谓的遗传程序设计,或 GP,作为其最著名的技术之一。在 GP 中,你维护一个虚拟的结构群体,通常被解释为小的计算机程序(尽管它们可能是 TSP 问题中的哈密尔顿循环,或者任何你想要构建的结构)。在每一代中,你评估这些个体(例如,在解决 TSP 问题时计算它们的长度)。最有希望的被允许有后代——在下一代中的新结构,基于父母,但有一些随机的修改(简单的突变,甚至几个父母结构的组合)。其他元启发式方法基于熔化的材料在缓慢冷却时的行为(模拟退火),当避开你最近寻找的区域时你可能如何搜索东西(禁忌搜索),甚至一群昆虫样的解决方案可能如何在状态空间中移动(粒子群优化)。

练习

11-1.我们已经看到了几种情况,其中算法的运行时间取决于输入中的一个值,而不是输入的实际大小(例如,0-1 背包问题的动态编程解决方案)。在这些情况下,运行时间被称为伪多项式 ,它是问题大小的指数函数。为什么对一个特定的整数值进行二等分是一个例外?

11-2.为什么每一个 NP 完全问题都可以化简为每隔一个?

11-3.如果背包问题的容量被一个项目数为多项式的函数所有界,那么问题就在 p 中,为什么?

11-4.证明即使目标和 k 固定为零,子集和问题也是 NP 完全的。

11-5.描述从正整数子集和问题到无界背包问题的多项式时间简化。(这可能有点挑战性。)

11-6.为什么一个四色或者任何一个 k > 3 的 k 色并不比一个三色容易?

11-7.同构的一般问题,找出两个图是否具有相同的结构(即,如果你忽略节点的标签或身份,它们是否相等),不知道是 NP 完全的。子图同构的相关问题是,虽然。这个问题要求你确定一个图是否有与另一个图同构的子图。说明这个问题是 NP 完全的。

11-8.你如何用有向版本模拟无向的汉密尔顿圈问题?

11-9.你如何将无向汉密尔顿圈问题(有向或无向)简化为无向汉密尔顿路径问题?

11-10.你如何将汉密尔顿路径问题简化为汉密尔顿圈问题?

11-11.为什么本节给出的证明不能让我们得出在 DAG 中寻找最长路径是 NP 完全的结论?削减在哪里分解?

11-12.为什么我们没有证明没有正圈的最长路问题是 NP 完全的?

11-13.在无界背包问题的贪婪 2 近似中,为什么我们可以确定我们可以填满超过一半的背包(假设至少有一些对象可以放进去)?

11-14.假设你有一个有向图,你想找到最大的没有圈的子图(可以说是最大的子 DAG)。您将通过相关边的数量来测量大小。不过,你认为这个问题似乎有点挑战性,所以你决定满足于 2-近似。描述这样一个近似值。

11-15.在克里斯托菲德斯的算法中,为什么会有总权重至多等于最佳哈密尔顿圈的一半的奇数度节点的匹配?

11-16.在 0-1 背包问题的分枝定界解中,对最优下界的起始值做了一些改进。

11-17.为什么贪婪分数解永远不会比 0-1 背包中的实际最优差?

11-18.考虑最优化问题 MAX-3-SAT(或 MAX-3-CNF-SAT),其中你试图使 3-CNF 公式中的尽可能多的子句为真。这显然是 NP 难的(因为它可以用来解决 3-SAT),但有一个奇怪的有效和奇怪的简单随机近似算法:只需为每个变量抛硬币。表明在一般情况下,这是一个 8/7-近似(假设没有子句同时包含一个变量及其否定)。

11-19.在练习 4-3 和 10-8 中,你开始建立一个选择邀请朋友参加聚会的系统。您对每个来宾都有一个数字兼容性,并且您希望选择一个子集来提供最高的兼容性总和。有些客人只有在其他人在场的情况下才会来,而你设法适应了这一限制。然而,你意识到如果其他人在场,一些客人会拒绝来。表明解决问题突然变得困难了很多。

11-20.您正在编写一个并行处理系统,它将批处理作业分配给不同的处理器,以便尽可能快地完成所有工作。你有 n 个任务的处理时间,你要在 m 个相同的处理器之间分配这些时间,这样最终的完成时间是最小的。说明这是 NP 难的,描述并实现一个近似比为 2 的算法求解。

11-21.使用分支定界策略,编写一个程序,找出练习 11-20 中调度问题的最优解。

参考

阿罗拉和巴拉克(2009 年)。计算复杂性:一种现代方法。剑桥大学出版社。

Crescenzi,G. A .,Gambosi,g .,Kann,v .,Marchetti-Spaccamela,a .,和 Protasi,M. (1999 年)。复杂性与逼近:组合优化问题及其逼近性质。斯普林格。附录在线:ftp://ftp.nada.kth.se/Theory/Viggo-Kann/compendium.pdf

Garey,M. R .和 Johnson,D. S. (2003 年)。计算机和难处理性:NP 完全理论指南。弗里曼公司。

o . gold Reich(2010 年)。P、NP 和 NP-完全性:计算复杂性的基础。剑桥大学出版社。

Harel 博士(2000 年)。计算机有限公司:他们真正不能做的事情。牛津大学出版社。

Hemaspaandra,L. A .和 Ogihara,M. (2002 年)。复杂性理论伴侣。斯普林格。

霍赫鲍姆博士,编辑(1997 年)。NP 难问题的近似算法。PWS 出版公司。

因帕利亚佐(1995 年)。个人对一般情况复杂性的看法。在第十届复杂性结构理论年会 (SCT '95),第 134–147 页。http://cseweb.ucsd.edu/~russell/average.ps

Kolesar,P. J. (1967 年)。背包问题的分枝定界算法。管理学,13(9):723–735。http://www.jstor.org/pss/2628089

Vazirani,V. V. (2010 年)。近似算法。斯普林格。

Williamson 博士和 Shmoys 博士(2011 年)。近似算法的设计。剑桥大学出版社。http://www.designofapproxalgs.com


你可以假设从 Pollux 上取下很容易。也许有水滑道?所有这些都是在 Pollux 变得坚不可摧之前建造的。也许有一个岩石滑坡?

一个经济学教授和一个学生漫步在校园里。“看,”学生喊道,“路上有一张 100 美元的钞票!”“不,你错了,”聪明的脑袋回答道。这不可能。如果真的有一张 100 美元的钞票,肯定会有人捡起来的。”(摘自补偿*,作者 G. T .米尔科维奇和 J. M .纽曼。)

3Vinay Deolalikar。 P 不等于 NP 。2010 年 8 月 6 日。

4

5 实际上,Impagliazzo 对 Algorithmica 的定义也允许一些稍微不同的场景。

注“似乎”我们真的不知道 P = NP,所以这个定义实际上可能是等价的。

7

8 我们当然需要坚持用多项式的节点数。

对于这一节和接下来的两节,你可能想试着展示在最初段落中的例子实际上是 NP-hard。

为了更容易理解这些章节中的论点,我通常会从看似简单的问题进展到更有表现力的问题(使用简化)。当然,在现实中,它们都一样具有表现力(也很难)——但有些问题比其他问题更好地隐藏了这一点。

11 如果你没有完全理解,不要担心,这不是很重要。

12 除非我们要把相对论或者地球曲率考虑进去...

13 任何无限的距离都会打破它,除非它完全没有边或者只由两个节点组成。

14 注意,我们总是用两者(最优和近似)中较大的除以较小的。

15

16 注意这里“以例证明”的用法。这是一个非常有用的技术。

不过,我猜他会想到更好的办法。

18 你可能想自己验证一下,任何图中奇数度节点的个数都是偶数。

如果你最小化,边界当然会被交换。

如果你想变得有趣,你可以研究一些源于人工智能领域的启发式搜索方法,比如遗传编程和禁忌搜索。见“如果你好奇……”部分了解更多信息。*******

十二、附录 A:加大油门:加速 Python

让它工作,让它正确,让它快速。

-肯特贝克

这个附录是对调整您的实现的常量因素的一些选项的一个小小的窥视。尽管这种优化在很多情况下不会取代正确的算法设计——尤其是当你的问题变得很大时——让你的程序运行速度提高十倍确实是有用的。

在寻求外部帮助之前,您应该确保您正在充分利用 Python 的内置工具。我在整本书中给了你一些提示,包括listdeque的正确用法,以及bisectheapq如何在适当的情况下给你带来巨大的性能提升。作为一名 Python 程序员,您也足够幸运,能够轻松使用最先进、最高效(也是最有效实现的)的排序算法之一(list.sort),以及一个真正通用的快速哈希表(dict)。您甚至会发现itertoolsfunctools可以提升代码的性能。 1

此外,在选择技术时,确保只优化必须优化的内容。优化确实会使您的代码或工具设置变得更加复杂,所以要确保这是值得的。如果你的算法“足够好”并且你的代码“足够快”,用另一种语言比如 C 引入扩展模块可能不值得。当然,什么是足够的由你来决定。(有关计时和分析代码的一些提示,请参见第二章。)

请注意,本附录中讨论的包和扩展主要是关于优化单处理器代码,或者通过提供高效实现的功能,或者通过让您创建或包装扩展模块,或者通过简单地加速您的 Python 解释器。将处理任务分配给多个内核和处理器无疑也是一个很大的帮助。multiprocessing模块可以是一个起点。如果您想探索这种方法,您也应该能够找到许多用于分布式计算的第三方工具。例如,您可以查看 Python Wiki 中的并行处理页面。

在接下来的几页中,我将描述一些加速工具。在这方面有几个努力,当然景观是一个不断变化的:新的项目不时出现,一些旧的褪色和死亡。如果你认为这些解决方案听起来很有趣,你应该去看看它的网站,并考虑它的社区的规模和活动——当然,还有你自己的需求。有关网站 URL,请参见附录后面的表 A-1 。

熊猫、松鼠、鼠尾草和熊猫。NumPy 是一个有着悠久历史的包。它基于旧的项目,如 Numeric 和 numarray,其核心是实现一个多维数字数组。除了这个数据结构之外,NumPy 还有几个有效实现的函数和操作符,它们作用于整个数组,因此当您从 Python 中使用它们时,函数调用的数量被最小化,让您无需编译任何自定义扩展就可以编写高效的数值计算。作为 NumPy 的补充,Theano 可以优化数值数组上的数学表达式。 SciPy 和 Sage 是更加雄心勃勃的项目(尽管 NumPy 是他们的构建模块之一),收集了一些用于科学、数学和高性能计算的工具(包括本附录后面提到的一些工具)。Pandas 更适合于数据分析,但是如果它的数据模型适合您的问题实例,那么它既强大又快速。一个相关的工具包是 Blaze,如果您正在处理大量半结构化数据,它会有所帮助。

PyPy,Pyston,长尾小鹦鹉,Psyco 和空载燕子。加速你的代码的一个最少干扰的方法是使用实时(JIT)编译器。在过去,您可以将 Psyco 与 Python 安装一起使用。安装 Psyco 后,您只需导入psyco模块并调用psyco.full()就可以获得潜在的显著加速。Psyco 会在程序运行时将 Python 程序的一部分编译成机器码。因为它可以观察你的程序在运行时发生了什么,所以它可以进行静态编译器不能进行的优化。例如,Python 列表可以包含任意值。但是,如果 Psyco 注意到您的给定列表似乎只包含整数,它可以假设将来也会是这种情况,并编译这部分代码,就好像您的列表是一个整数数组一样。可悲的是,就像几个 Python 加速解决方案一样,Psyco,引用其网站上的话来说,“无人维护,死气沉沉。”然而,它的遗产在皮比依然存在。

PyPy 是一个更加雄心勃勃的项目:用 Python 重新实现 Python 。当然,这并不能直接提高速度,但是平台背后的想法是为分析、优化和翻译代码提供大量的基础设施。基于这个框架,就有可能进行 JIT 编译(Psyco 中使用的技术正被移植到 PyPy 上),甚至翻译成某种高性能语言,如 c。用于实现 PyPy 的 Python 的核心子集被称为 RPython (代表限制的 Python* ),并且已经有工具将这种语言静态编译成高效的机器代码。*

在某种程度上,unload Swallow 也是 Python 的 JIT 编译器。更准确地说,它是 Python 解释器的一个版本,使用了所谓的低级虚拟机(LLVM)。与标准解释器相比,该项目的目标是加速 5 倍。然而,这一目标尚未实现,该项目的活动似乎已经停止。

Pyston 是 Dropbox 开发的一个类似的、更新的基于 LLVM 的 Python JIT 编译器。在撰写本文时,Pyston 仍然是一个年轻的项目,只支持该语言的一个子集,而且还不支持 Python 3。然而,它已经在许多情况下击败了标准 Python 实现,并且正在积极开发中。Parakeet 也是一个相当年轻的项目,引用网页上的话来说,“使用类型推断、数据并行数组操作符和许多黑魔法来使您的代码运行得更快。”

GPULib、PyStream、PyCUDA 和 PyOpenCL。这四个包让你使用图形处理器(GPU)来加速你的代码。它们不提供像 Psyco 这样的 JIT 编译器会提供的那种插入式加速,但是如果你有一个强大的 GPU,为什么不使用它呢?项目中, PyStream 较老,Tech-X Corporation 的努力已经转移到较新的 GPULib 项目。它为使用 GPU 的各种形式的数值计算提供了一个高级接口。如果你想用 GPU 加速你的代码,你可能也想试试 PyCUDA 或者 PyOpenCL。

派热克斯、西顿、农巴和谢德金。这四个项目让你把 Python 代码翻译成 C、C++或者 LLVM 代码。 Shedskin 将普通的 Python 代码编译成 C++,而 Pyrex 和 Cython(Pyrex 的一个分支)主要面向 C。在 cy thon(及其前身 Pyrex)中,您可以向代码中添加可选的类型声明,例如声明变量是(并将永远是)整数。在 Cython 中,也有对 NumPy 数组的互操作性支持,允许你编写低级代码来有效地访问数组内容。我在自己的代码中使用了这种方法,对于合适的代码,加速因子高达 300–400。由 Pyrex 和 Cython 生成的代码可以直接编译成一个可以导入 Python 的扩展模块。如果您想从 Python 中生成 C 代码,Cython 是一个安全的选择。如果您只是寻求加速,特别是对于面向数组和数学密集型代码,您应该考虑 Numba ,它在导入时生成 LLVM 代码。有了 NumbaPro 中可用的高级功能,甚至还有 GPU 支持。

SWIG、F2PY 和 Boost.Python. 这些 工具让你分别包装 C/C++、Fortran、C++代码。虽然您可以编写自己的包装器代码来访问您的扩展模块,但是使用这样的工具可以消除工作中的许多烦琐——并且使结果更有可能是正确的。例如,当使用 SWIG 时,在 C(或 C++)头文件上运行命令行工具,就会生成包装器代码。使用 SWIG 的一个好处是,除了 Python 之外,它还可以为许多其他语言生成包装器,例如,您的扩展也可以用于 Java 或 PHP。

ctypes、llvm-py 和 CorePy2。这些是 模块,让你在你的 Python 代码中操作低级代码对象。ctypes 模块允许您在内存中构建 C 对象,并使用这些对象作为参数调用共享库中的 C 函数(如 dll)。llvm-py 包为您提供了前面提到的 llvm 的 Python API,它允许您构建代码,然后高效地编译它。如果你愿意,你可以用它来构建你自己的编译器(也许是你自己的语言?)在 Python 中。CorePy2 还允许您操纵和有效地运行代码对象,尽管它是在汇编级别上工作的。(注意,ctypes 是 Python 标准库的一部分。)

**编织、缠绕和嵌接。**这些 三个包让你直接在你的 Python 代码中使用 C(或者其他一些语言)。这是通过将 C 代码保存在多行 Python 字符串中,然后动态编译来完成的。然后,使用 ctypes 这样的接口工具,Python 代码就可以使用生成的代码对象了。

**其他工具。**显然,还有很多其他工具,根据您的需要,它们可能比这些工具更有用。例如,如果你想减少内存使用而不是时间,那么 JIT 就不适合你——JIT 通常需要大量内存。相反,您可能想看看 Micro Python,它被设计为具有最小的内存占用,并且适合在微控制器和嵌入式设备上使用 Python。而且,谁知道呢,也许你甚至不需要使用 Python。也许您正在 Python 环境中工作,并且您想要一种高级语言,但是您希望您的所有代码都非常快。虽然这可能是 Pythonic 式的异端邪说,但我建议看看朱莉娅。虽然它是一种不同的语言,但它的语法应该为任何 Python 程序员所熟悉。它还支持调用 Python 库,这意味着 Julia 团队正在与 IPython、 2 等 Python 项目合作,它甚至已经成为 SciPy 会议讲座的主题。 3

表 A-1 加速工具网站的 URLs】

|

工具

|

网站

| | --- | --- | | 火焰 | http://blaze.pydata.org | | 助推。计算机编程语言 | http://boost.org | | 辛迪 | http://www.cs.tut.fi/~ask/cinpy | | CorePy2 | https://code.google.com/p/corepy2 | | ctypes(类型) | http://docs.python.org/library/ctypes.html | | 西通 | http://cython.org | | CyToolz | https://github.com/pytoolz/cytoolz | | F2PY | http://cens.ioc.ee/projects/f2py2e | | GPULib | http://txcorp.com/products/GPULib | | 朱莉娅 | http://julialang.org | | llvm py | http://mdevan.org/llvm-py | | 微型 Python | http://micropython.org | | 努姆巴 | http://numba.pydata.org | | NumPy | http://www.numpy.org | | 熊猫 | http://pandas.pydata.org | | 长尾小鹦鹉 | http://www.parakeetpython.com | | 并行处理 | https://wiki.python.org/moin/ParallelProcessing | | 普西科 | http://psyco.sf.net | | 皮库达 | http://mathema.tician.de/software/pycuda | | pinline | http://pyinline.sf.net | | PyOpenCL | http://mathema.tician.de/software/pyopencl | | PyPy | http://pypy.org | | 派莱克斯耐热硬质玻璃 | http://www.cosc.canterbury.ac.nz/greg.ewing/python/Pyrex | | 网络电视 | http://code.google.com/p/pystream | | python 直译器 | https://github.com/dropbox/pyston | | 明智的 | http://sagemath.org | | 我的天啊 | http://scipy.org | | 谢德金 | http://code.google.com/p/shedskin | | 大喝 | http://swig.org | | 提亚诺 | http://deeplearning.net/software/theano | | 空载燕子 | http://code.google.com/p/unladen-swallow | | 织法 | http://docs.scipy.org/doc/scipy/reference/weave.html |

然而,如果你正在编写充满迭代器的函数式代码,并且你确实想要一个外部的提升,你可能想要看看 CyToolz。

2 例如参见http://jupyter.org

3

十三、附录 B:问题和算法列表

如果你有船体问题,我为你感到难过,孩子;我有 99 个问题,但违规不是一个。

—匿名 1

本附录没有列出书中提到的每个问题和算法,因为讨论一些算法只是为了说明一个原理,而一些问题只是作为某些算法的例子。然而,最重要的问题和算法在这里用一些对正文的参考被勾画出来。如果你不能通过查阅这个附录找到你要找的东西,那就看看索引。

在本附录的大部分描述中, n 指的是问题大小(比如一个序列中元素的个数)。对于图的特殊情况,虽然, n 是指节点数, m 是指边数。

问题

**小集团和独立集团。**一个集团 是一个图,其中每对节点之间都有一条边。这里感兴趣的主要问题是在一个更大的图中寻找一个团(即把一个团识别为一个子图)。图中的独立集是指没有一对节点通过边相连的节点集。换句话说,找一个独立集相当于取边集的补集,找一个小团体。寻找一个k-团(由 k 个节点组成的团)或者寻找一个图中最大的团(最大团问题)是 NP-hard。(更多信息,参见第十一章。)

**最亲密的一对。**给定欧几里得平面上的一组,找出彼此最接近的两个点。这可以使用分治策略在线性时间内解决(见第六章)。

**压缩和最优决策树。**一棵 霍夫曼树是一棵的树,它的叶子有权重(频率),它们的权重乘以深度的和尽可能小。这使得这种树对于构造压缩码是有用的,并且当结果的概率分布已知时,这种树可以作为决策树。霍夫曼树可以使用霍夫曼算法来构建,在第七章 ( 清单 7-1 )中有描述。

连通和强连通分量。无向图是连通的,如果有一条路径从每个节点到其他节点。有向图是连通的,如果它的底层无向图是连通的。连通分量是连通的最大子图。举例来说,可以使用诸如 DFS ( 清单 5-5 )或 BFS ( 清单 5-9 )之类的遍历算法来找到连接的组件。如果在一个有向图中有一条从每个节点到其他节点的(有向)路径,称之为连通。强连通分量(SCC) 是强连通的极大子图。可以使用 Kosaraju 的算法找到 SCC(清单 5-10 )。

**凸包。**一个凸包是欧几里得平面中包含一组点的最小凸区域。使用分治策略可以在对数线性时间内找到凸包(见第六章)。

**寻找最小值/最大值/中值。**寻找一个序列的最小值和最大值可以通过简单的扫描在线性时间内找到。给定线性时间准备,可以使用二进制堆在恒定时间内重复查找和提取最大值或最小值。使用选择或随机选择,也可以在线性(或预期线性)时间内找到序列的第 k 个最小元素(kk=n/2 的中值)。(更多信息,参见第六章。)

流与切问题 **。**在边上有流量容量的网络中,可以推送多少个单位的流量?那就是最大流量问题。一个等价的问题是找到最能限制流量的边容量集;这就是 min-cut 问题。这些问题有几种版本。例如,您可以将成本添加到边中,并找到最大流量中最便宜的流量。你可以在每条边上加一个下界,寻找一个可行的流。您甚至可以在每个节点中添加单独的供应和需求。这些问题将在第十章中详细讨论。

图形着色。 尝试给图的节点上色,这样就不会有邻居共享一种颜色。现在尝试用给定数量的颜色来做这件事,或者甚至找到最低的数量(图中的色数)。一般来说这是一个 NP 难问题。然而,如果要求您查看一个图是否是二色的(或二分的),这个问题可以使用简单遍历在线性时间内解决。寻找团覆盖的问题等价于寻找独立集覆盖,这是一个与图着色相同的问题。(参见第十一章了解更多关于图形着色的信息。)

磕磕绊绊的问题。确定给定的算法是否会随着给定的输入而终止。问题在一般情况下是不可判定的(即不可解的)(见第十一章)。

汉密尔顿循环/路径和 TSP …以及欧拉旅行。几个 路径和子图问题都可以高效解决。但是,如果您想对每个节点只访问一次,那就麻烦了。任何涉及这个约束的问题都是 NP 难的,包括寻找一个哈密尔顿圈(访问每个节点一次并返回),一个哈密尔顿路径(访问每个节点一次,不返回),或者一个完整图的最短旅行(旅行推销员/销售代表问题)。无论是有向还是无向的情况,问题都是 NP 难的(见第十一章)。然而,访问每条恰好一次的相关问题——找到所谓的欧拉之旅——可以在多项式时间内解决(参见第五章)。TSP 问题是 NP 难的,即使对于特殊情况,例如使用平面中的欧几里德距离,但是对于这种情况,以及对于任何其他度量距离,它可以有效地近似到 1.5 倍以内。然而,一般来说,近似 TSP 问题是 NP 难的。(更多信息见第十一章。)

**背包问题和整数规划。**背包问题涉及在一定的约束条件下,选择一组物品中有价值的子集。在(有界)分数的情况下,你有一定量的一些物质,每种物质都有一个单位值(单位重量的值)。你也有一个可以承载一定最大重量的背包。(贪婪的)解决方案是尽可能多地获取每种物质,从单位价值最高的物质开始。对于整数背包问题,你只能拿整个项目,不允许分数。每样东西都有重量和价值。对于有界情况(也称为 0-1 背包),每种类型的对象数量有限。(另一种观点是,你有一套固定的物品,你要么拿,要么不拿。)在无界的情况下,您可以从一组对象类型中的每一个中获取您想要的数量(当然,仍然考虑您的承载能力)。称为子集和问题的特殊情况涉及选择一组数的子集,使得该子集具有给定的和。这些问题都是 NP 难的(见第十一章,但是承认基于动态规划的伪多项式解(见第八章)。如前所述,分数背包问题甚至可以使用贪婪策略在多项式时间内解决(参见第七章)。在某种程度上,整数规划是背包问题的推广(因此显然是 NP 难的)。它是简单的线性规划,其中变量被约束为整数。

**最长递增子序列。**寻找给定序列中元素按升序排列的最长子序列。这可以使用动态规划在对数线性时间内解决(见第八章的)。

**匹配。**有许多匹配问题,都涉及到将一些对象链接到其他对象。本书讨论的问题是二部匹配和最小代价二部匹配(第十章)和稳定婚姻问题(第七章)。二分匹配(或最大二分匹配)涉及在二分图中寻找边的最大子集,使得子集中没有两条边共享一个节点。最小成本版本做同样的事情,但是最小化这个子集上的边成本的总和。稳定的婚姻问题有点不一样;在那里,所有的男人和女人都有对异性成员的偏好排名。一套稳定的婚姻的特点是,你找不到一对愿意拥有对方而不是现在的另一半。

**最小生成树。**生成树是一个子图,它的边在原图的所有节点上形成一棵树。最小生成树是最小化边成本总和的树。例如,可以使用克鲁斯卡尔算法(列表 7-4 )或普里姆算法(列表 7-5 )找到最小生成树。因为边的数量是固定的,所以可以通过简单地否定边权重来找到最大生成树。

**分割和装箱。**划分 涉及将一组数分成两个和相等的集合,而装箱问题涉及将一组数打包成一组“箱”,使得每个箱中的和低于某个限制,并且箱的数量尽可能少。这两个问题都是 NP 难的。(参见第十一章。)

**SAT,巡回赛-SAT,k-CNF-SAT。**这些都是满意度问题(SAT)的变种,它要求你确定一个给定的逻辑(布尔)公式是否为真,如果你被允许将变量设置为你想要的任何真值。circuit-SAT 问题简单地使用逻辑电路而不是公式, k -CNF-SAT 涉及合取范式的公式,其中每个子句由 k 文字组成。对于 k = 2,后者可以在多项式时间内求解。其他的问题,以及 k -CNF-SAT 对于k2,都是 NP 完全的。(参见第十一章。)

**搜索。**这个是一个非常普遍又极其重要的问题。您有一个键,并希望找到一个相关的值。例如,这就是 Python 等动态语言中变量的工作方式。这也是如今你在网上几乎能找到任何东西的方式。两个重要的解决方案是哈希表(见第二章的和二分搜索法或搜索树(见第六章的)。给定数据集中对象的概率分布,可使用动态规划构建最佳搜索树(见第八章)。

**序列比较。**你可能想要比较两个序列,以了解它们有多相似(或不相似)。一种方法是找到两者共同的最长子序列(最长公共子序列),或者找到从一个序列到另一个序列的基本编辑操作的最小数量(所谓的编辑距离,或 Levenshtein 距离)。这两个问题或多或少是等价的;更多信息参见第八章。

**序列修改。**在链表中间插入一个元素代价很小(常数时间),但是寻找给定位置代价很大(线性时间);对于数组,情况正好相反(常量查找、线性插入,因为所有后面的元素都必须移位)。不过,对于这两种结构来说,附加都可以很便宜地完成(见第二章中list的“黑盒”边栏)。

**集合和顶点覆盖。**一个一个顶点覆盖是覆盖(即相邻)图的所有边的顶点的集合。集合覆盖是这一思想的推广,其中节点被子集代替,并且您想要覆盖整个集合。问题在于限制或最小化节点/子集的数量。这两个问题都是 NP 难的(见第十一章)。

**最短路径。**这个问题涉及到寻找从一个节点到另一个节点,从一个节点到所有其他节点(反之亦然),或者从所有节点到所有其他节点的最短路径。一对一、一对一和一对一的情况以同样的方式解决,通常对未加权图使用 BFS,对 DAG 使用 DAG 最短路径,对非负边权重使用 Dijkstra 算法,在一般情况下使用 Bellman–Ford。为了在实践中加快速度(虽然不影响最坏情况下的运行时间),还可以使用双向 Dijkstra,或 A算法。对于所有对最短路径问题,选择的算法可能是 Floyd-Warshall 或(对于稀疏图)Johnson 算法。如果边是非负的,Johnson 算法(渐近地)等价于从每个节点运行 Dijkstra 算法(可能更有效)。(关于最短路径算法的更多信息,参见第五章和第九章。)注意最长*路问题(对于一般图)可以用来求哈密尔顿路,也就是说它是 NP 难的。这实际上意味着最短路径问题在一般情况下也是 NP 难的。然而,如果我们不允许图中有负循环,我们的多项式算法将会起作用。

**排序和元素唯一性。**排序 是一个重要的操作,也是其他几个算法必不可少的子程序。在 Python 中,通常使用list.sort方法或sorted函数进行排序,这两种方法都使用 timsort 算法的高效实现。其他算法包括插入排序、选择排序和 gnome 排序(所有这些都有二次运行时间),以及堆排序、合并排序和快速排序(它们是对数线性的,尽管这仅适用于快速排序的一般情况)。关于二次排序算法的信息,见第五章;关于对数线性(分治)算法,见第六章。判定一组实数是否包含重复项不能(在最坏的情况下)用比对数线性更好的运行时间来解决。通过归约,排序也不能。

拓扑排序。 对一个 DAG 的节点进行排序,使所有的边都指向同一个方向。如果边表示依赖性,则拓扑排序表示尊重依赖性的排序。这个问题可以通过引用计数的形式来解决(参见第四章)或者使用 DFS(参见第五章)。

**遍历。**这里的问题是访问一些连通结构中的所有对象,通常表示为图或树中的节点。这个想法可以是要么访问每个节点,要么只访问那些需要解决某个问题的节点。忽略图或树的一部分的后一种策略被称为修剪 ,并且被用于(例如)搜索树和分支定界策略。关于遍历的更多内容,请参见第五章。

算法和数据结构

**2-3 棵树。**平衡的树形结构,允许在最坏情况下θ(LGn时间内进行插入、删除和搜索。内部节点可以有两到三个子节点,在插入过程中,根据需要通过拆分节点来平衡树。(参见第六章。)

*A。**启发式引导的单源最短路径算法。适合大搜索空间。不是选择具有最低距离估计值的节点(如 Dijkstra 的),而是使用具有最低启发值(距离估计值和剩余距离猜测值之和)的节点。最坏情况下的运行时间与 Dijkstra 算法相同。(参见清单 9-10 。)

AA 树。 2-3-trees 使用节点旋转模拟了一个节点级别编号的二叉树。插入、删除和搜索的最坏情况运行时间为θ(LGn)。(参见清单 6-6 。)

贝尔曼-福特。加权图中从一个节点到所有其他节点的最短路径。沿着每条边寻找捷径 n 次。没有负周期,在n–1 次迭代后保证正确答案。如果最后一轮有所改善,则检测到负循环,算法放弃。运行时间θ(nm)。(参见清单 9-2 。)

双向 Dijkstra。 Dijkstra 的算法从开始和结束节点同时运行,交替迭代到两个算法中的每一个。当两者在中间相遇时,找到最短的路径(尽管在这一点上必须小心)。最坏情况下的运行时间就像 Dijkstra 算法一样。(参见列表 9-8 和列表 9-9 。)

**二分搜索法树。**一个二叉树结构,其中每个节点都有一个键(通常还有一个关联值)。后代键由节点键划分:较小的键放在左边的子树中,较大的键放在右边。平均来说,任何节点的深度都是对数的,给出了期望的插入和搜索时间θ(LGn)。但是,如果没有额外的平衡(比如在 AA 树中),树会变得不平衡,给出线性运行时间。(参见清单 6-2 。)

**平分,二分搜索法。**一种搜索程序,其工作方式类似于搜索树,通过重复将排序序列中感兴趣的区间减半。通过检查中间元素并决定所寻找的值必须位于左侧还是右侧来执行减半。运行时间θ(LGn)。一个非常有效的实现可以在bisect模块中找到。(参见第六章。)

**分支和捆绑。**一种通用算法设计方法。通过构建和评估部分解决方案,以深度优先或最佳优先的顺序搜索解决方案空间。为最佳值保留保守估计,而为部分解决方案计算乐观估计。如果乐观估计比保守估计差,则不扩展部分解,并且算法回溯。常用于解决 NP 难问题。(参见清单 11-2 中的获得 0-1 背包问题的分支定界解决方案。)

广度优先搜索(BFS)。 逐层遍历一个图(可能是一棵树),从而也识别(未加权)最短路径。通过使用 FIFO 队列跟踪发现的节点来实现。运行时间θ(n+m)。(参见清单 5-9 。)

**桶排序。**对给定区间内均匀分布的数值进行排序,方法是将区间分成 n 个大小相等的桶,并将值放入其中。预期的存储桶大小是恒定的,因此可以使用(例如)插入排序对它们进行排序。总运行时间θ(n)。(参见第四章。)

**Busacker–go Wen。**通过使用福特-富尔克森方法中最便宜的扩充路径,找到网络中最便宜的最大流(或给定流值的最便宜的流)。这些路径是使用贝尔曼-福特或(经过一些权重调整)Dijkstra 算法找到的。运行时间通常取决于最大流量值,伪多项式也是如此。对于最大流量 k ,运行时间为 O ( km lg n )。(参见清单 10-5 。)

**克里斯托菲德斯算法。**度量 TSP 问题的近似算法(近似比界限为 1.5)。找到一个最小生成树,然后在树的奇数度节点中找到一个最小匹配 2 ,根据需要进行短路以对图进行有效浏览。(参见第十一章。)

**计数排序。**在θ(n)时间内对取值范围小(最多有θ(n个连续值)的整数进行排序。其工作原理是对出现次数进行计数,并使用累积计数将数字直接放入结果中,并在进行过程中更新计数。(参见第四章。)

**DAG 最短路径。**寻找从一个节点到 DAG 中所有其他节点的最短路径。工作原理是找到节点的拓扑排序,然后从左到右放松每个节点的所有出边(或者所有入边)。也可以(因为缺少循环)用来寻找最长的路径。运行时间θ(n+m)。(参见清单 8-4 。)

深度优先搜索。 通过深入然后回溯来遍历图(可能是树)。通过使用 LIFO 队列跟踪发现的节点来实现。通过跟踪发现时间和结束时间,DFS 还可以用作其他算法(如拓扑排序或 Kosaraju 算法)中的子例程。运行时间θ(n+m)。(参见清单 5-4 、 5-5 和 5-6 )。)

**迪杰斯特拉的算法。**在赋权图中找出从一个节点到所有其他节点的最短路径,只要没有负的边权重。遍历图,使用优先级队列(堆)重复选择下一个节点。优先级是节点的当前距离估计。每当从被访问的节点发现快捷方式时,这些估计被更新。运行时间是θ((m+n)LGn),如果图是连通的,简单来说就是θ(mLGn)。

双头队列。 FIFO 使用链表(或数组链表)实现的队列,这样在两端插入和提取对象可以在恒定的时间内完成。一个有效的实现可以在collections.deque类中找到。(参见第五章中关于该主题的“黑盒”侧栏。)

**动态数组,向量。**在数组中有额外的容量,所以追加是有效的。通过将内容重新定位到一个更大的数组,以常数因子增长它,当它填满时,追加可以在平均(摊销)时间内保持不变。(参见第二章。)

埃德蒙兹-卡普。Floyd–war shall 方法的具体实例,其中使用 BFS 执行遍历。在θ(nm2 时间内求最小费用流。(参见清单 10-4 。)

弗洛伊德-沃肖尔。寻找从每个节点到所有其他节点的最短路径。在迭代 k 中,只有第一个 k 节点(按某种顺序)被允许作为路径上的中间节点。从k–1 扩展包括检查通过第一个k–1 节点往返于 k 的最短路径是否比直接通过这些节点更短。(即,对于每个最短路径,节点 k 要么被使用,要么不被使用。)运行时间为θ(n3)。(参见清单 9-6 。)

福特-富尔克森。一个解决最大流问题的通用方法。该方法包括重复遍历该图以找到所谓的增加路径,即流量可以增加(增加)的路径。如果有额外的容量,可以沿着边缘增加流量,或者如果沿着边缘有流量,可以沿着边缘向后增加(即取消)。因此,遍历可以沿着有向边向前和向后移动,这取决于穿过它们的流。运行时间取决于使用的遍历策略。(参见清单 10-4 。)

**盖尔-沙普利。**发现一组稳定的婚姻给出了一组男性和女性的偏好排名。任何没有订婚的男人都会向他们还没有求婚的最喜欢的女人求婚。每个女人都会在目前的追求者中选择自己喜欢的(可能会和未婚夫在一起)。可以用二次运行时间来实现。(参见第七章中的侧栏“热切的追求者和稳定的婚姻”。)

**侏儒排序。**一个简单的二次运行时间排序算法。可能不是你在实践中会用到的算法。(参见清单 3-1 。)

**哈希,散列表。**查找 向上一个键得到相应的值,就像在一个搜索树中一样。条目存储在一个数组中,它们的位置通过计算关键字的(伪随机的,类似于)哈希值找到。给定一个好的散列函数和数组中足够的空间,插入、删除和查找的预期运行时间是θ(1)。(参见第二章。)

**成堆,堆积如山。**堆是高效的优先级队列。使用线性时间预处理,min- (max-)堆将让您在常数时间内找到最小(最大)的元素,并在对数时间内提取或替换它。添加一个元素也可以在对数时间内完成。从概念上讲,堆是一个完整的二叉树,其中每个节点都比其子节点小(大)。修改时,可以用θ(LGn运算修复该属性。实际上,堆通常使用数组实现(节点编码为数组条目)。一个非常有效的实现可以在heapq模块中找到。Heapsort 类似于 selection sort,只是未排序的区域是一个堆,因此查找最大元素 n 次的总运行时间为θ(nLGn)。(参见第六章中关于堆、heapq和堆排序的“黑盒”侧栏。)

**霍夫曼算法。**构建霍夫曼树,例如,可用于构建最佳前缀码。最初,每个元素(例如,字母表中的字符)被制成单节点树,权重等于其频率。在每次迭代中,选取两个最轻的树,将它们与一个新的根合并,并赋予新树一个等于原来两个树权重之和的权重。这可以在对数线性时间内完成(或者,实际上,如果频率被预先分类,则可以在线性时间内完成)。(参见清单 7-1 。)

**插入排序。**一个简单的二次运行时间排序算法。它的工作方式是在数组的初始排序段中重复插入下一个未排序的元素。对于小数据集,它实际上比更高级(和最优)的算法更可取,如合并排序或快速排序。(但是,在 Python 中,如果可能的话,应该使用list.sortsorted。)(见清单 4-3 。)

**插值搜索。**类似于普通的二分搜索法,但是使用区间端点之间的线性插值来猜测正确的位置,而不是简单地查看中间元素。最坏情况下的运行时间仍然是θ(LGn,但是对于均匀分布的数据,平均情况下的运行时间是 O (lg lg n )。(在第六章的部分的“如果你好奇……”中提到。)

**迭代深化 DFS。**DFS 的重复运行,其中每一次运行都有一个它可以遍历的距离的限制。对于具有一些扇出的结构,运行时间将与 DFS 或 BFS 相同(即θ(n+m))。关键是它具有 BFS 的优点(它找到最短的路径,保守地探索大的状态空间),具有 DFS 较小的内存占用。(参见清单 5-8 。)

**约翰逊算法。**寻找从每个节点到所有其他节点的最短路径。基本上从每个节点运行 Dijkstra 的。然而,它使用了一个技巧,以便它也可以处理负边权重:它首先从新的开始节点运行 Bellman-Ford(向所有现有节点添加边),然后使用结果距离来修改图的边权重。修改后的权重都是非负的,但是被设置为使得原始图中的最短路径也将是修改后的图中的最短路径。运行时间θ(MnLGn)。(参见清单 9-4 。)

**Kosaraju 的算法。**使用 DFS 找到强连接的组件。首先,节点按其完成时间排序。然后反转边,运行另一个 DFS,使用第一个排序选择开始节点。运行时间θ(n+m)。(参见清单 5-11 。)

**克鲁斯卡尔的算法。**通过重复添加不产生循环的最小剩余边来寻找最小生成树。这种循环检查可以(用一些小技巧)非常有效地执行,所以运行时间主要由边的排序决定。总而言之,运行时间为θ(mLGn)。(参见清单 7-4 。)

**链表。**代表序列的数组的另一种选择。尽管一旦找到正确的条目,修改链表是很便宜的(常数时间),但是找到这些条目通常需要线性时间。链表的实现有点像路径,每个节点指向下一个节点。注意 Python 的list类型实现为数组,而不是链表。(参见第二章。)

**合并排序。**原型分治算法。它把要排序的序列分成两半,递归排序两半,然后线性时间合并排序后的两半。总运行时间为θ(nLGn)。(参见清单 6-5 。)

**矿氏算法。**一种算法,通过标记通道入口和出口来亲自穿越实际的迷宫。在许多方面类似于迭代深化 DFS 或 BFS。(参见第五章。)

**普里姆的算法。**通过重复添加最靠近树的节点来增长最小生成树。它本质上是一种遍历算法,使用优先级队列,就像 Dijkstra 的算法一样。(参见清单 7-5 。)

**基数排序。**按数字(元素)对数字(或其他序列)进行排序,从最低有效位开始。只要数字的数量是恒定的,并且可以在线性时间内对数字进行排序(例如,使用计数排序),总运行时间就是线性的。重要的是,对数字使用的排序算法是稳定的。(参见第四章。)

**随机选择。**找到中值,或者一般来说,第 k 阶统计量(第 k 阶最小元素)。有点像“半快速排序”它随机(或任意)选择一个 pivot 元素,并将其他元素划分到 pivot 的左边(较小的元素)或右边(较大的元素)。然后搜索继续在右边部分进行,有点像二分搜索法。不能保证完全平分,但是期望的运行时间仍然是线性的。(参见清单 6-3 。)

**选择。**相当不现实,但保证线性,随机选择的同胞。它的工作方式如下:将序列分成五个一组。使用插入排序找到每个中值。使用 select 递归查找这些中间值的中间值。用这个中线作为支点,分割元素。现在在正确的一半上运行 select。换句话说,它类似于随机选择——不同之处在于,它可以保证某个百分比最终会位于中枢的任一侧,从而避免完全不平衡的情况。实际上,这不是一个你可能会在实践中用到的算法,但是了解它是很重要的。(参见第六章。)

**选择排序。**一个简单的二次运行时间排序算法。非常类似于插入排序,但不是重复地将下一个元素插入排序的部分,而是重复地在未排序的区域中查找(即选择)最大的元素(并与最后一个未排序的元素交换)。(参见清单 4-4 。)

**蒂姆索特。**一种基于归并排序的超副本原地排序算法。在没有任何处理特殊情况的显式条件的情况下,它能够考虑部分排序的序列,包括反向排序的片段,因此可以比看起来可能的速度更快地对许多真实世界的序列进行排序。在list.sortsorted中的实现也真的很快,所以如果你需要排序,那就是你应该使用的。(参见第六章中 timsort 上的“黑盒”侧栏。)

**通过引用计数进行拓扑排序。**排序一个 DAG 的节点,使所有的边从左到右。这是通过计算每个节点的入边数来实现的。入度为零的节点保持在队列中(可能只是一个集合;顺序无所谓)。从队列中取出节点,并按拓扑排序顺序放置。当您这样做时,您会减少该节点有边的节点的计数。如果它们中的任何一个达到零,它们就被放入队列中。(参见第四章。)

**用 DFS 进行拓扑排序。**对 DAG 节点进行拓扑排序的另一个算法。这个想法很简单:执行一个 DFS 并按照逆完成时间对节点进行排序。要轻松获得线性运行时间,您可以简单地将节点添加到您的订单中,因为它们在 DFS 中接收它们的完成时间。(参见清单 5-7 。)

**特雷莫算法。**和 Ore 的算法一样,这种算法被设计成在走迷宫时由人来执行。一个执行 Tremaux 算法的人所追踪到的模式,本质上和 DFS 是一样的。(参见第五章。)

**绕树两圈。**度量 TSP 问题的近似算法,保证产生的解的成本至多是最优解的两倍。首先,它构建一个最小生成树(小于最优值),然后它“绕过”该树,走捷径以避免两次访问同一条边。由于公制,这保证比走每边两次更便宜。这最后一次遍历可以通过前序 DFS 来实现。(参见清单 11-1 。)


1 戏谑地归于 Lt. Cdr。《星际迷航:下一代》的乔治·拉·福吉。

2 注意,在一般(可能是非二元)图中寻找匹配不在本书的讨论范围之内。

十四、附录 C:图术语

他打了一个赌,我们可以从地球上 15 亿居民中找出任何一个人,通过最多五个熟人,其中一个是他认识的,他可以找到那个被选中的人。

-脆脆脆香脆香脆香脆香脆香脆香脆香脆香脆香脆香脆香脆香脆香脆香脆香脆香脆香脆香脆香脆香脆

下面的介绍大致基于赖因哈德·迪斯特尔的图论和邦-詹森和古丁的有向图 的第一章,以及科尔曼等人的算法简介的附录(注意,不同书籍之间的术语和符号可能不同;它不是完全标准化的。)如果你认为似乎有很多东西需要记住和理解,你可能不必担心。是的,前面可能会有很多新单词,但大部分概念都很直观和直接,它们的名字通常有意义,更容易记住。

所以……一个 是一个抽象的网络,由节点(或顶点),通过(或)连接而成。更正式地说,我们将一个图定义为一对集合, G = ( VE ),其中节点集 V 是任意有限集,边集 E 是一组(无序的)节点对。 2 我们把这个叫做 V 上的图*。我们有时也会写 V ( G )和 E ( G ),来表示集合属于哪个图。 3 图通常用网络图来说明,像图 C-1 中的那些(暂时忽略灰色高亮)。例如图 C-1 中称为G1 的图,可以用节点集 V = { abcdef }和边集E来表示***

*你不必总是严格区分图和它的节点和边集。例如,我们可能会谈到图 G 中的一个节点 u ,实际上意味着在 V ( G ),或者等价地,一条边{ uv }在 G 中,意味着在 E ( G )。

Image 在渐近表达式中直接使用集合 VE 是很常见的,比如θ(V+E),用来表示图形大小的线性度。在这些情况下,集合应该被解释为它们的基数(即大小),更正确的表达应该是θ(|V|+|E|),其中| |是基数运算符。

9781484200568_AppC-01.jpg

图 C-1 。各种类型的图和有向图

基本的图形定义给出了我们通常所说的无向图,它有一个近亲:有向图,或有向图。唯一不同的是,边不再是无序的对,而是有序的对:节点 uv 之间的边现在要么是从 uv 的边( uv )要么是从vu )换句话说,在有向图 G 中, E ( G )是关于 V ( G )的关系。图 C-1 中的图形G3 和 G4 是边缘方向用箭头表示的有向图。注意G3 在 ad 之间有所谓的反平行边,也就是说边是双向的。这是可以的,因为( ad )和( da )是不一样的。但是,平行边(即相同的边,重复的边)是不允许的,无论是在图中还是在有向图中。(这是因为边形成一个集合。)还要注意,一个无向图不能在一个节点和它自己之间有一条边,尽管这在一个有向图中是可能的(所谓的自循环),惯例是不允许的。**

Image 注意所做的允许诸如平行边和自循环之类的事情。如果我们构建我们的网络结构,使得我们可以有多条边(也就是说,这些边现在形成了一个多重集),并且自循环,我们称之为(可能有向)伪图。一个没有自循环的伪图仅仅是一个多重图。还有更奇特的版本,比如超图,每条边可以有多个节点。

尽管图和有向图是完全不同的东西,但我们处理的许多原理和算法在这两种情况下都同样适用。因此,有时在更一般的意义上使用术语是很常见的,包括有向图和无向图。还要注意,在许多情况下(比如当穿过或者在图中“四处移动”时),无向图可以用有向图来模拟,用一对反向平行的有向边来代替每个无向边。这通常是在将图作为数据结构实际实现时完成的(在第二章中有更详细的讨论)。如果很清楚一条边是有向还是无向的,或者关系不大,我有时会写 uv 而不是{ uv }或( uv )。

一条边的事件发生在它的两个节点上,称为它的端节点。即 uv 入射在 uv 上。如果边是有向的,我们说它离开(或者事件从 ) u 并且它进入(或者事件到 ) v 。我们分别称 uv 为其。如果无向图中有一条边 uv ,则节点 uv的邻居,称为邻居。一个节点 v 的邻居集合,也称为 v邻域,有时写成 N ( v )。比如G1 中 b 的邻域 N ( b )是{ acd }。如果所有节点都是两两相邻的,那么这个图叫做完全(参见图 C-1 中的 G 2 )。对于一个有向图,边 uv 意味着 v 与 u 相邻*,但是只有当我们也有一条反平行边 vu 时,反过来才成立。(换句话说,与 u 相邻的节点是那些我们可以从 u 沿着从它开始的正确方向的边“到达”的节点。)*

入射在一个节点 v 上的(无向)边数(即 N ( v )称为其,常写成 d ( v )。比如在G1(图 C-1 )中,节点 b 的度为 3,而 f 的度为 0。(零度节点称为孤立。)对于有向图,我们可以将这个数字分成入度(输入边的数量)和出度(输出边的数量)。我们还可以将节点的邻域划分为 - 邻域中的*,有时称为父节点,以及外邻域,或子节点。*

一个图可以是另一个图的一部分。我们说一个图 H = ( WF )是 G = ( VE )的子图或者反过来说 GH 的 ,如果 W 也就是说,我们可以通过(可能)去掉一些节点和边,从 G 得到 H 。在图 C-1 中,突出显示的节点和边表示一些示例子图,这些子图将在下文中详细讨论。如果 HG 的子图,我们常说 G 包含 H 。我们说如果 W = VH 跨越 G 。也就是说,一个生成子图是一个覆盖原图所有节点的子图(比如图G4 图 C-1 中的子图)。

路径是一种特殊的图,当它们以子图的形式出现时,人们主要感兴趣。一条路径是,通常由一系列(不同的)节点标识,例如 v 1v 2 、…、 v n ,连续节点对之间有边(仅):??【E】= {v1v注意,在有向图中,路径必须沿着边的方向;也就是说,路径中的所有边都指向前方。路径的长度就是它的边数。我们说这是一条 v 1vn之间的路径(或者,在导演的情况下,从 v 1v n )。在示例图G2 中,高亮显示的子图是 be 之间的一条路径,例如长度为 3。如果一条路径 P 1 是另一条路径 P 2 的子图,我们说 P 1P 2子路径。例如, G 2 中的路径 bada*、 de 都是 bad的子路径*****

该路径的近亲是周期。通过将路径的最后一个节点连接到第一个节点来构建一个循环,如G3(图 C-1 )中通过 abc 的(有向)循环所示。一个圈的长度也是它包含的边的数量。就像路径一样,循环必须遵循边的方向。

Image 注意这些定义不允许路径自身交叉,也就是说,包含循环作为子图。一个更一般的类似路径的概念,通常被称为行走,仅仅是节点和边的交替序列(也就是说,它本身不是一个图),这将允许节点和边被多次访问,特别是,将允许我们“循环行走”相当于一个循环的是一个封闭行走,它在同一个节点开始和结束。为了区分没有循环的路径和普通的步行,有时使用术语简单路径

到目前为止所讨论的概念的一个普遍概括是引入了边权重(或成本长度)。每个边 e = uv 被分配一个实数, w ( e ),有时写成 w ( uv ),通常表示与该边相关联的某种形式的成本。例如,如果节点是地理位置,则权重可以表示道路网络中的行驶距离。一个图 G 的权重 w ( G )简单来说就是 G 中所有边 ew ( e )之和。然后,我们可以将路径和循环长度的概念分别推广到路径 P 和循环 Cw ( P )和 w ( C )。最初的定义对应于每条边的权重为 1 的情况。两个节点之间的距离是它们之间最短路径的长度。(寻找这样的最短路径在书中有广泛的论述,主要是在第九章。)

如果一个图包含每对节点之间的一条路径,那么它就是连通的。如果所谓的底层无向图 (即忽略边方向后得到的图)是连通的,那么我们说这个图是连通的。在图 C-1 中,唯一没有连接的图是G1。一个图的连通的最大子图称为它的连通分量。在图 C-1 中,G1 有两个连通分量,而其他只有一个(每个),因为图本身是连通的。

Image 这里使用的术语极大是指某物不能被扩展而仍具有给定的性质。例如,在某种意义上,连通分量是最大的,因为它不是一个更大的图(有更多节点或边的图)的子图,也是连通的。

在计算机科学和其他领域,有一类图特别受到关注:不含圈的图,或无圈图。 非循环图有向和无向两种变体,并且这两种变体具有相当不同的性质。先重点说一下无向的那种。

无向无环图的另一个术语是森林,森林的相连部分被称为 。换句话说,一棵树就是一个连通的森林(即由单个连通分量组成的森林)。比如G1 是一片有两棵树的森林。在一棵树中,度数为 1 的节点称为叶子(或外部节点)、 4 ,而所有其他节点称为内部节点。例如,G1 中较大的树有三片叶子和两个内部节点。较小的树只包含一个内部节点,尽管在少于三个节点的情况下谈论叶子和内部节点可能没有太大意义。

Image 注意有 0 或 1 个节点的图被称为琐碎的,往往会使定义比必要的更复杂。在很多情况下,我们只是忽略了这些情况,但有时记住它们可能很重要。例如,作为归纳的起点,它们可能非常有用(在第四章的中有详细介绍)。

树有几个有趣和重要的属性,其中一些与整本书的特定主题有关。不过,我会在这里给你一些。设 T 是一个无向图,有 n 个节点。那么下面的陈述是等价的(练习 2-9 要求你证明事实确实如此):

  1. T 是树(即无环且连通)。
  2. T 是非循环的,有n–1 条边。
  3. T 是连通的,有n–1 条边。
  4. 任何两个节点都由一条路径连接。
  5. T 是无循环的,但是给它添加任何新的边都会产生一个循环。
  6. T 是连通的,但是移除任何一条边都会产生两个连通的分量。

换句话说,这些关于 T 的陈述中的任何一个,就其本身而言,都和其他任何一个一样具有特征。例如,如果有人告诉你在 T 中的任何一对节点之间正好有一条路径,你马上就知道它是连通的,有n–1 条边,并且它没有圈。

通常,我们通过选择一个根节点(或者简单地说根节点)来锚定我们的树。结果被称为根树、,与我们目前看到的自由树相对。(如果从上下文可以清楚一棵树是否有根,我将简单地在有根和自由两种情况下使用非限定术语 tree 。)像这样挑出一个节点让我们定义向上和向下的概念。矛盾的是,计算机科学家(以及一般的图形理论家)倾向于将根放在顶部,将叶放在底部。(我们或许应该多出去走走……)。对于任何节点, up 都是在根的方向上(沿着节点和根之间的单一路径)。向下则是任何其他方向(自动向树叶方向)。注意,在一个有根的树中,根被认为是一个内部节点,而不是一片叶子,,即使它碰巧有一个度

正确定位后,我们现在定义一个节点的深度为它到根的距离,而它的高度是到任何叶子的最长向下路径的长度。树的高度就是根的高度。例如,考虑图 C-1 中G1 中较大的树,让 a (高亮显示)为根。树的高度是 3,而深度,比如说, cd 是 2。一个级别由具有相同深度的所有节点组成。(本例中,0 级由 a ,1 级 b ,2 级 cd ,3 级e组成。)

这些方向也允许我们定义其他关系,使用家谱中相当直观的术语(奇怪的是,我们只有单亲)。你的上一级邻居(也就是更接近根的邻居)是你的,而你的下一级邻居是你的5 (根当然没有父,叶子也没有子。)更一般来说,你向上走能到达的任何节点都是祖先,而你向下走能到达的任何节点都是后代。跨越节点 v 及其所有后代的树被称为以 v 为根的子树。

Image 注意与一般的子图相反,术语子树通常不适用于所有碰巧是树的子图——尤其是当我们谈论有根树的时候。

其他类似的术语一般都有其明显的含义。例如,兄弟节点是具有共同父节点的节点。有时候,兄弟姐妹是按排序的,这样我们就可以谈论节点的“第一个孩子”或“下一个兄弟姐妹”。在这种情况下,该树被称为一棵有序树

正如第五章中所解释的,很多算法都是基于遍历,系统地探索图,从某个初始起点(一个起始节点)开始。尽管探索图形的方式可能不同,但它们有一些共同点。只要它们遍历整个图,它们都会产生生成树6 (生成树就是恰好是树的简单生成子图。)遍历产生的生成树 ,称为遍历树 ,以起始节点为根。在处理单个算法时,将重新讨论其工作原理的细节,但是图 C-1 中的图G4 说明了这个概念。突出显示的子图就是这样一个遍历树,根在 a 。注意,从 a 到树中其他节点的所有路径都遵循边方向;有向图中的遍历树总是这样。

Image 注意一个有向图,它的底层图是一棵有根树,并且所有的有向边都指向远离根的方向(也就是说,所有的节点都可以通过从根开始的有向路径到达),这个有向图被称为树形图,尽管我将主要把这样的图简单地称为树。换句话说,有向图中的遍历确实给了你一个遍历树形图。术语定向树既用于有根(无方向)树,也用于树状树,因为有根树的边具有远离根的隐含方向。

术语疲劳设置了吗?振作起来——只剩下一个图形概念了。如上所述,有向图可以是无环的,就像无向图一样。有趣的是,这些图形通常看起来不太像有向树的森林。因为基本的无向图可以是任意循环的,一个有向无环 ,或 DAG 可以有任意的结构(见练习 2-11),只要边指向正确的方向——也就是说,它们指向不存在有向循环。在样本图G4 中可以看到这样的例子。

Dag 作为依赖性的表示是非常自然的,因为循环依赖性通常是不可能的(或者至少是不期望的)。例如,节点可能是大学课程,一条边( uv )将表明课程 uv 的先决条件。理清这种依赖关系是第五章中拓扑排序部分的主题。Dag 也是动态编程技术的基础,在第八章中讨论。


阿尔伯特-拉斯洛·巴拉巴希在他的书 链接:网络的新科学(基础书籍,2002)中引用的话。

2 你可能根本没想到这是个问题,但你可以假设 VE 不重叠。

3 即使我们给集合取了其他的名字,这些函数仍然被称为 VE 。例如,对于一个图形 H = ( WF ),我们会得到V(H)=WE(H)=F

4 不过正如后面解释的,根不被认为是叶。此外,对于只包含两个相连节点的图,将它们都称为叶子有时没有意义。

5 注意,这与有向图中的内外邻域是同一个术语。一旦我们开始确定树边的方向,这两个概念就一致了。

6 只有从起始节点可以到达所有节点时才成立。否则,遍历可能不得不在几个地方重新开始,导致一个跨越森林。生成林的每个组件都有自己的根。*