Python 算法教程(二)

部署运行你感兴趣的模型镜像

原文:Python Algorithms

协议:CC BY-NC-SA 4.0

五、遍历:算法学的万能钥匙

你在一条狭窄的走廊里。这种情况持续了几米,在一个门口结束。沿着走廊走了一半,你可以看到一个拱门,那里有几级台阶向下延伸。你会走向门口(转到 5),还是蹑手蹑脚地走下台阶(转到 344)?

—史蒂夫·杰克逊,混乱的城堡

一般来说,图形是一种强大的心理(和数学)结构模型;如果你能把一个问题表述成一个处理图形的问题,即使它看起来不像图形问题,你可能离解决它又近了一步。碰巧还有一个非常有用的思维模型用于图形算法——一个万能钥匙,如果你愿意的话。 1 该骨架关键是遍历:发现并随后访问图中的所有节点。这不仅仅是关于明显的图。例如,想想 GIMP 或 Adobe Photoshop 等绘画应用如何用单一颜色填充一个区域,即所谓的泛色填充。这是您在这里学到的知识的应用(见练习 5-4)。或者您想序列化一些复杂的数据结构,并需要确保检查其所有组成对象?这就是遍历。列出文件系统一部分中的所有文件和目录?管理软件包之间的依赖关系?更多遍历。

但是遍历不仅仅是直接有用;这是许多其他算法的关键组成部分和潜在原则,比如《??》第九章和《??》第十章中的算法。例如,在第十章的中,我们将尝试将 n 人与 n 份工作相匹配,其中每个人的技能仅与部分工作相匹配。该算法的工作原理是,先暂时给人们分配工作,然后在需要其他人接替时再重新分配。这种重新分配然后可以触发另一个重新分配,可能导致级联。正如你将看到的,这种级联包括在人和工作之间来回移动,以一种之字形模式,从一个闲散的人开始,到一个可用的工作结束。这是怎么回事?你猜对了:遍历。

我将从几个角度介绍这个想法,并在几个版本中,尽可能地将各个部分联系起来。这意味着涵盖了两个最著名的基本遍历策略, 深度优先搜索广度优先搜索,建立了一个稍微复杂一点的基于遍历的算法,用于查找所谓的强连通分量。

遍历是有用的,因为它让我们在一些基本归纳的基础上构建一个抽象层。考虑寻找一个图的连通分量的问题(见图 5-1 举例)。正如你在《??》第二章中所回忆的,一个图是连通的,如果从每个节点到其他每个节点都有一条路径,并且如果连通分量是(单独)连通的最大子图。寻找连通分量的一种方法是从图中的某个地方开始,逐渐增长到一个更大的连通子图,直到我们不能再前进为止。我们如何确定我们已经重建了一个完整的组件?

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 5-1 。有三个连通分量的无向图

我们来看下面这个相关的问题。说明可以对连通图中的节点进行排序, v 1v 2 ,。。。, v n ,这样对于任何一个 i = 1。。。 n ,超过v1 的子图。。。,vI 相连。如果我们可以展示这一点,并且我们可以弄清楚如何进行排序,我们就可以遍历一个连接组件中的所有节点,并知道它们何时用完。

我们如何做到这一点?归纳思考,我们需要从I–1 到 i 。我们知道在I-1 首节点上的子图是连通的。接下来呢?因为任何一对节点之间都有路径,所以考虑第一个I-1 节点中的节点 u 和其余节点中的节点 v 。在从 uv 的路径上,考虑到目前为止我们已经构建的组件中的最后一个节点,以及之外的第一个节点*。让我们称它们为 xy 。很明显,它们之间肯定有一条边,所以将 y 添加到我们不断增长的组件的节点中,可以保持它的连接,我们已经展示了我们想要展示的内容。*

我希望您能看到最终的过程实际上是多么简单。这只是添加连接到组件的节点的问题,我们通过跟踪一条边来发现这样的节点。有趣的一点是,只要我们继续以这种方式将新节点连接到我们的组件,我们就在构建一棵。这棵树叫做遍历树,是我们正在遍历的组件的生成树。(当然,对于有向图,它只跨越我们可以到达的节点。)

为了实现这一过程,我们需要跟踪这些“边缘”或“前沿”节点,它们仅在一条边之外。如果我们从单个节点开始,边界将只是它的邻居。当我们开始探索时,新访问的节点的邻居将形成新的边缘,而我们现在访问的那些节点将落入其中。换句话说,我们需要将边缘作为某种集合来维护,在这里我们可以删除我们访问的节点并添加它们的邻居,除非它们已经在列表中或者我们已经访问过它们。它变成了我们想要访问但还没有抽出时间去做的节点列表。你可以认为我们已经访问过的那些已经被检查过了。

对于那些玩过像龙与地下城(Dungeons & Dragons)这样的老派角色扮演游戏的人来说,图 5-2 可能有助于澄清这些想法。它显示了一个典型的地牢地图。 2 把房间(和走廊)想象成节点,把它们之间的门想象成边。这里有一些多重边缘(门),但这真的不是问题。我还在地图上添加了一个“你在这里”的标记,以及一些指示你如何到达那里的轨迹。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 5-2 。一个典型角色扮演地牢的局部遍历。把房间想象成节点,把门想象成边缘。遍历树是由你的轨迹定义的;边缘(遍历队列)包括相邻的房间,没有足迹的浅色房间。剩余的(黑暗的)房间还没有被发现

请注意,有三种房间:你实际参观过的房间(有轨道穿过的房间),你因为看到他们的门而知道的房间,以及你还不知道的房间(变暗的)。未知房间(当然)通过已知但未被访问的房间的边界与被访问的房间分开,就像在任何类型的遍历中一样。清单 5-1 给出了这个通用遍历策略的一个简单实现(注释指的是图而不是地牢)。 3

清单 5-1 。遍历用邻接集表示的图的连通分量

def walk(G, s, S=set()):                        # Walk the graph from node s
    P, Q = dict(), set()                        # Predecessors + "to do" queue
    P[s] = None                                 # s has no predecessor
    Q.add(s)                                    # We plan on starting with s
    while Q:                                    # Still nodes to visit
        u = Q.pop()                             # Pick one, arbitrarily
        for v in G[u].difference(P, S):         # New nodes?
            Q.add(v)                            # We plan to visit them!
            P[v] = u                            # Remember where we came from
    return P                                    # The traversal tree

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 提示set类型的对象可以让你在其他类型上执行集合操作!例如,在清单 5-1 中,我在difference方法中使用字典P,就好像它是一个(它的键的)集合。这也适用于其他的可重复项,例如listdeque,以及其他的 set 方法,例如update

关于这个新代码的一些事情可能不会立即显现出来。例如,S参数是什么,为什么我要使用字典来记录我们访问过的节点(而不是一个集合)?S参数现在并不那么有用,但是当我们试图找到连接的组件时(接近本章末尾),我们将需要它。基本上,它代表了一个“禁区”——一组我们在遍历过程中可能没有访问过但被告知要避开的节点。至于字典P,我用它来代表前辈。每次我们添加一个新的节点到队列中,我设置它的前任;也就是说,当我找到它的时候,我确定我记得我是从哪里来的。这些前辈将一起形成遍历树。如果您不关心树,您当然可以使用一组访问过的节点(我将在本章后面的一些实现中这样做)。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 注意无论是在将节点添加到队列的同时将它们添加到这种“已访问”集合中,还是稍后将它们从队列中弹出,通常都不重要。这确实会影响到你需要在哪里添加“如果被访问过…”不过,检查一下。在这一章中,你会看到通用遍历策略的几个版本。

walk函数将遍历单个连通分量(假设图是无向的)。为了找到所有的组件,你需要在节点上将它包装成一个循环,就像清单 5-2 中的一样。

清单 5-2 。查找连接的组件

def components(G):                              # The connected components
    comp = []
    seen = set()                                # Nodes we've already seen
    for u in G:                                 # Try every starting point
        if u in seen: continue                  # Seen? Ignore it
        C = walk(G, u)                          # Traverse component
        seen.update(C)                          # Add keys of C to seen
        comp.append(C)                          # Collect the components
    return comp

walk函数返回它访问过的节点的前任映射(遍历树),我在comp列表(连接组件)中收集这些。我使用seen集合来确保我不会从一个先前已经访问过的组件中的节点开始遍历。请注意,即使操作seen.update(C)C的大小上是线性的,对walk的调用已经完成了同样多的工作,所以从渐近线来看,它不会花费我们任何东西。总而言之,像这样寻找组件是θ(E+V)因为每个边和节点都要探索。 4

walk函数实际上并没有做那么多。尽管如此,在许多方面,这段简单的代码是本章的主干,也是理解你将要学习的许多其他算法的万能钥匙。或许值得研究一下。试着在你选择的图上手动执行算法(例如图 5-1 中的那个)。你看到如何保证探索整个连接组件了吗?需要注意的是,节点从Q.pop 返回的顺序与无关。无论如何,整个组件都将被探索。然而,这个顺序是定义行走行为的关键元素,通过调整它,我们可以得到一些现成的有用算法。

要遍历其他几个图形,参见图 5-3 和图 5-4 。(有关这些示例的更多信息,请参见附近的侧栏。)

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 5-3 。1759 年,柯尼斯堡(今天的加里宁格勒)的桥梁。插图摘自《数学研究》第一卷(卢卡斯,1891 年,第 22 页)

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 5-4 。一个十二面体, ,目标是追踪边,这样你就可以精确地访问每个顶点一次。该插图摘自《数学研究》第二卷(卢卡斯,1896 年,第 205 页)

在加里宁格勒跳岛

听说过柯尼斯堡七桥(现称加里宁格勒)?1736 年,瑞士数学家莱昂哈德·欧拉遇到了一个处理这些问题的难题,许多居民很长时间以来一直试图解决这个难题。问题是,你能从镇上的任何地方开始,穿过所有七座桥一次,然后回到你开始的地方吗?(你可以在图 5-3 中找到桥梁的布局。)为了解决这个难题,欧拉决定抽象出细节。。。发明了图论。似乎是个好的开始,不是吗?

正如你可能注意到的,图 5-3 中的河岸和岛屿的结构是一个多重图;比如 A 和 B 之间,A 和 c 之间有两条边,那其实并不影响问题。(我们可以很容易地在这些边的中间虚构一些岛来得到一个普通的图。)

欧拉最终证明了,当且仅当一个(多)图是连通的,并且每个节点都有偶数度时,访问该图的每条边恰好一次并到达起点是可能的。由此产生的封闭行走(粗略地说,可以不止一次访问节点的路径)被称为欧拉游,或者欧拉回路,这样的图就是欧拉。(你很容易看出柯尼斯堡不是欧拉;它的所有顶点都是奇数次。)

不难看出,连通性和偶数度节点是必要条件(不连通性显然是一个障碍,奇数度节点必然会在某个时候阻止你的旅程)。不太明显的是,它们是充分条件。我们可以用归纳法证明这一点(大惊喜,嗯?),但是我们需要对我们的诱导参数小心一点。如果我们开始移除节点或边,简化的问题可能不再是欧拉问题,我们的归纳假设将不再适用。让我们不要担心连通性。如果简化的图是不连通的,我们可以将假设应用于每个连通的部分。但是偶数度呢?

我们被允许按我们想要的频率访问节点,所以我们将移除(或“用完”)的是一组边。如果我们从访问的每个节点中去掉偶数条边,我们的假设将适用。这样做的一种方法是删除一些封闭遍历的边(当然,不一定要访问所有节点)。问题是这样的封闭行走是否会一直存在于欧拉图中。如果我们只是从某个节点开始走, u ,我们进入的每一个节点都会从偶数度到奇数度,所以我们可以放心的再次离开它。只要我们从不两次访问一个边缘,我们最终会回到 u

现在,假设归纳假设是,任何具有偶数度节点且边少于 E 条的连通图都有一条包含每条边恰好一次的闭行走。我们从 E 边开始,去掉任意封闭行走的边。我们现在有一个或多个欧拉分量,每个分量都包含在我们的假设中。最后一步是在这些组件中组合欧拉旅行。我们的原始图是连通的,所以我们移除的封闭行走必然会连接组件。最终的解决方案由这种组合行走组成,每个组件都有一个“迂回”的欧拉游。

换句话说,决定一个图是否是欧拉的是很容易的,找到一个欧拉之旅也不是很难(见练习 5-2)。然而,欧拉之旅有一个更成问题的亲戚:汉密尔顿循环。

哈密尔顿循环是以爱尔兰数学家威廉·罗恩·汉密尔顿爵士的名字命名的(除了别的以外),他提出它是一个游戏(称为阿科斯游戏 ),目标是访问十二面体(一个 12 面的柏拉图立体,或 d12)的每个顶点正好一次,然后回到你的原点(见图 5-4 )。更一般地说,哈密尔顿圈是包含整个图的所有节点的子图(恰好一次,因为它是真圈)。我相信你可以看到,柯尼希斯堡是哈密顿的(也就是说,它有一个哈密顿圈)。证明十二面体是哈密顿的有点难。事实上,在一般的图中寻找哈密尔顿路径是一个困难的问题——一个没有有效算法的问题(在第十一章中有更多关于这个的内容)。考虑到问题是如此相似,这有点奇怪,你不觉得吗?

轻而易举的事

1887 年的深秋,一位法国电信工程师正在精心打理的花园迷宫中漫步,看着树叶开始变黄。当他走过迷宫的通道和十字路口时,他认出了一些绿色植物,并意识到他一直在转圈。作为一个有创造力的人,他开始思考如何避免这个错误,如何找到最好的出路。他记得小时候有人告诉他,如果他在每个十字路口都向左拐,他最终会找到出路,但他很容易看到这样一个简单的策略是行不通的。如果在他到达出口之前,他的左转把他带回到他开始的地方,他就被困在一个无限的循环中。不,他需要另想办法。当他最终摸索着走出迷宫时,他灵光一现。他冲回家拿起笔记本,准备开始勾画他的解决方案。

好吧,事实可能不是这样。我承认,这都是我编的,甚至是年份。然而,19 世纪 80 年代末,一位名叫 Trémaux 的法国电信工程师发明了一种穿越迷宫的算法。我一会儿就会谈到这个问题,但首先让我们探索一下“继续左转”策略(也称为左手法则*),看看它是如何工作的,以及什么时候不工作。*

*不允许循环

考虑图 5-5 中的迷宫。如你所见,其中没有循环;它的底层结构是一棵树,如右图所示。在这种情况下,“把一只手放在墙上”的策略会很有效。 6 了解其工作原理的一种方法是观察迷宫实际上只有一面内壁(或者,换句话说,如果你在里面放墙纸,你可以使用一个连续的长条)。看外面的方块。只要你不被允许创建循环,你画的任何障碍都必须在一个确切的地方连接到它,这不会给左手定则带来任何问题。按照这种遍历策略,您将发现所有节点,并对每个通道遍历两次(每个方向一次)。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 5-5 。一棵树,被画成一个迷宫和一个更传统的图表,叠加在迷宫上

左手规则被设计为由实际行走迷宫的个体执行,仅使用局部信息。为了牢牢把握到底发生了什么,我们可以放弃这种观点,递归地制定同样的策略*。 7 一旦你熟悉了递归思想,这样的公式可以更容易地看出一个算法是正确的,这是最简单的递归算法之一。对于一个基本的实现(假设树的一个标准图形表示),见清单 5-3 。*

*清单 5-3 。递归树遍历

def tree_walk(T, r):                            # Traverse T from root r
    for u in T[r]:                              # For each child. . .
        tree_walk(T, u)                         # ... traverse its subtree

就迷宫的比喻而言,如果你站在一个十字路口,你可以向左或向右走,你首先穿过迷宫向左的部分,然后是向右的部分。就这样。很明显(也许借助于一点归纳),这个策略将遍历整个迷宫。请注意,这里只明确描述了在每个通道中向前行走的动作。当你遍历以节点 u 为根的子树时,你向前走到 u 并从那里开始工作新的段落。最终还是要回归根本, r 。像这样回溯,越过你自己的轨迹,叫做回溯 ,隐含在递归算法中。每次递归调用返回时,您会自动回溯到发起调用的节点。(你看到这个回溯行为是如何符合左手定则的吗?)

想象一下,有人在迷宫的一面墙上戳了一个洞,相应的图形突然有了一个循环。也许他们在节点 e 打破了死胡同北边的墙。如果你从 e 开始向北走,你可以一直向左走,但你永远不会穿过整个迷宫——你会一直绕圈子。 8 这是我们在遍历一般图时面临的问题。 9 清单 5-1 中的总体思路给了我们一个解决这个问题的方法,但是在我开始之前,让我们看看我们的法国电报工程师想出了什么。

如何停止兜圈子

douard Lucas 于 1891 年在他的数学记录的第一卷中描述了 Tremaux 穿越迷宫的算法。卢卡斯在他的介绍中写道: 10

要从任何一个起点完整地穿过迷宫的所有通道两次,只需遵循 Trémaux 提出的规则,在每个十字路口的入口或出口做上标记。这些规则可以总结如下:尽可能避免经过你已经走过的十字路口,避免走你已经走过的通道。这难道不是一种同样适用于日常生活的谨慎做法吗?

在本书的后面,他继续更详细地描述了这个方法,但是它真的很简单,前面的引用很好地涵盖了主要思想。而不是标记每个入口或出口(比如说,用一支粉笔),让我们只说你有泥泞的靴子,这样你就可以看到我们自己的足迹(就像在图 5-2 中)。然后,Trémaux 会告诉你开始朝任何方向走,每当你走到死胡同或你已经走过的十字路口时,就往回走(以避免循环)。你不能穿越一个通道超过两次(一次向前,一次向后),所以如果你在原路返回到一个十字路口,你会向前走进一个未被探索的通道,如果有的话。如果没有*,你就继续原路返回(进入另一个只有一组脚印的通道)。 11*

这就是算法。一个有趣的观察是,尽管您可以选择几个段落进行向前遍历,但总是只有一个可用于回溯。你知道为什么吗?可能有两个*(或更多)的唯一方法是,如果你从一个十字路口向另一个方向出发,然后没有原路返回。不过,在这种情况下,规则规定你应该而不是进入十字路口,而是立即原路返回。(这也是为什么你永远不会在同一个方向上穿越两次的原因。)*

*我在这里使用“泥泞的靴子”描述的原因是为了使回溯真正清晰;这与递归树遍历中的规则完全一样(同样,它相当于左手规则)。事实上,如果递归地表述,Trémaux 的算法就像树遍历一样,只是增加了一点内存。我们知道我们已经访问了哪些节点,并假装有一堵墙阻止我们进入它们,实际上模拟了一个树形结构(这就是我们的遍历树)。

参见清单 5-4 中 Trémaux 算法的递归版本。在这个公式中,俗称深度优先搜索,是最基本(也是最重要)的遍历算法之一。 12

清单 5-4 。递归深度优先搜索

def rec_dfs(G, s, S=None):
    if S is None: S = set()                     # Initialize the history
    S.add(s)                                    # We've visited s
    for u in G[s]:                              # Explore neighbors
        if u in S: continue                     # Already visited: Skip
        rec_dfs(G, u, S)                        # New: Explore recursively

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 注意与清单 5-1 中的walk函数相反,在这里的循环中对G[s]使用difference方法是错误的,因为S在递归调用中可能会改变,你很容易多次访问一些节点。

深入!

深度优先搜索(DFS)从其递归结构中获得一些最重要的属性。一旦我们开始处理一个节点,我们就要确保在继续处理之前,遍历我们可以从它到达的所有其他节点。然而,正如在第四章中提到的,递归函数总是可以被重写为迭代函数,可能用我们自己的栈来模拟调用栈。DFS 的这种迭代公式可能是有用的,既可以避免填满调用堆栈,也可以使算法的某些属性更加清晰。幸运的是,为了模拟递归遍历,我们需要做的是在算法中使用一个堆栈而不是集合,就像清单 5-1 中的中的walk。清单 5-5 显示了这种迭代 DFS。

清单 5-5 。迭代深度优先搜索

def iter_dfs(G, s):
    S, Q = set(), []                            # Visited-set and queue
    Q.append(s)                                 # We plan on visiting s
    while Q:                                    # Planned nodes left?
        u = Q.pop()                             # Get one
        if u in S: continue                     # Already visited? Skip it
        S.add(u)                                # We've visited it now
        Q.extend(G[u])                          # Schedule all neighbors
        yield u                                 # Report u as visited

除了使用堆栈(一个后进先出,或者 LIFO 队列,在本例中使用appendpop由一个列表实现),这里还有一些调整。例如,在我最初的walk函数中,队列是一个集合,,所以我们绝不会冒险让同一个节点被安排多次访问。一旦我们开始使用其他队列结构,情况就不一样了。我已经通过在添加它的邻居之前检查一个节点在S中的成员资格(也就是说,我们是否已经访问过该节点)解决了这个问题。

为了使遍历更加有用,我还添加了一个yield语句,它将允许您按照 DFS 顺序遍历图节点。例如,如果你在变量G中有来自图 2-3 的图形,你可以尝试如下:

>>> list(iter_dfs(G, 0))
[0, 5, 7, 6, 2, 3, 4, 1]

值得注意的一点是,我刚刚在一个有向图上运行了 DFS,而我只讨论了它如何在无向图上工作。实际上,DFS 和其他遍历算法对有向图同样有效。然而,如果你在一个有向图上使用 DFS,你不能期望它探索整个连通的部分。例如,对于图 2-3 中的图,从 a 之外的任何其他开始节点遍历将意味着 a 永远不会被看到,因为它没有入边。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 提示要在一个有向图中找到连通的部分,第一步可以很容易地构建底层无向图。或者你可以简单地浏览图表并添加所有的反向边。这对其他算法也很有用。有时,你甚至可能不构建无向图;当使用有向图时,简单地考虑两个方向上的每个边就足够了。

你也可以用 Trémaux 的算法来思考这个问题。你仍然可以双向穿越每个(定向)通道,但是你只能沿着边缘方向向前前进,并且你必须逆着边缘方向返回*。*

事实上,iter_dfs函数的结构非常接近我们可能实现的一般遍历算法——其中只需要替换队列。让我们加强walk到更成熟的traverse ( 清单 5-6 )。

清单 5-6 。一个通用的图遍历函数

def traverse(G, s, qtype=set):
    S, Q = set(), qtype()
    Q.add(s)
    while Q:
        u = Q.pop()
        if u in S: continue
        S.add(u)
        for v in G[u]:
            Q.add(v)
        yield u

这里默认的队列类型是set,使其类似于原来的(任意)walk。您可以很容易地定义一个堆栈类型(使用我们通用队列协议的适当的addpop方法),可能如下所示:

class stack(list):
    add = list.append

先前的深度优先测试可以重复如下:

>>> list(traverse(G, 0, stack))
[0, 5, 7, 6, 2, 3, 4, 1]

当然,实现各种遍历算法的特殊用途版本也是很好的,即使它们可以用几乎相同的形式表达。

深度优先时间戳和拓扑排序(再次)

如前所述,记住并避免以前访问过的节点是防止我们绕圈子(或者更确切地说,循环)的原因,没有循环的遍历自然会形成一棵树。这种遍历树根据它们的构造方式有不同的名称;对于 DFS,它们被恰当地命名为深度优先树(或 DFS 树)。与任何遍历树一样,DFS 树的结构是由访问节点的顺序决定的。DFS 树特有的一点是,节点 u 的所有后代在从发现 u 到我们回溯它的时间间隔内被处理。

为了利用这个属性,我们需要知道算法何时回溯,这在迭代版本中可能有点困难。尽管你可以从清单 5-5 中扩展迭代 DFS 来跟踪回溯(见练习 5-7),我将在这里扩展递归版本(清单 5-4 )。参见清单 5-7 中的版本,该版本为每个节点添加了时间戳:一个用于它被发现的时间(发现时间,或d),一个用于我们回溯它的时间(完成时间,或f)。

清单 5-7 。带时间戳的深度优先搜索

def dfs(G, s, d, f, S=None, t=0):
    if S is None: S = set()                     # Initialize the history
    d[s] = t; t += 1                            # Set discover time
    S.add(s)                                    # We've visited s
    for u in G[s]:                              # Explore neighbors
        if u in S: continue                     # Already visited. Skip
        t = dfs(G, u, d, f, S, t)               # Recurse; update timestamp
    f[s] = t; t += 1                            # Set finish time
    return t                                    # Return timestamp

参数df应该是映射(例如字典)。DFS 属性然后声明:( 1)每个节点在 DFS 树中其后代的之前被发现,并且(2)每个节点在 DFS 中其后代的之后结束。这直接来自算法的递归公式,但是你可以很容易地做一个归纳证明来说服自己这是真的。

这个性质的一个直接结果是,我们可以使用 DFS 进行拓扑排序,这已经在第四章中讨论过了。如果我们在 DAG 上执行 DFS,我们可以简单地根据它们的完成时间降序排列节点,并且它们将被拓扑排序。然后,每个节点 u 将在 DFS 树中其所有后代之前,这些后代将是从 u 可到达的任何节点,即依赖于 u 的节点。在这种情况下,了解算法如何工作是有好处的。我们可以简单地在定制 DFS 的过程中执行拓扑排序*,而不是首先调用我们的时间戳 DFS,在回溯时追加节点,如清单 5-8 所示。 13*

清单 5-8 。基于深度优先搜索的拓扑排序

def dfs_topsort(G):
    S, res = set(), []                          # History and result
    def recurse(u):                             # Traversal subroutine
        if u in S: return                       # Ignore visited nodes
        S.add(u)                                # Otherwise: Add to history
        for v in G[u]:
            recurse(v)                          # Recurse through neighbors
        res.append(u)                           # Finished with u: Append it
    for u in G:
        recurse(u)                              # Cover entire graph
    res.reverse()                               # It's all backward so far
    return res

在这个新的拓扑排序算法中,有几件事情值得注意。首先,我显式地在所有节点上包含了一个for循环,以确保遍历了整个图。(练习 5-8 要求你证明这是可行的。)检查一个节点是否已经在历史集合中(S)现在正好放在recurse中,所以我们不需要把它放在两个for循环中。另外,因为recurse是一个内部函数,可以访问周围的作用域(特别是Sres),所以唯一需要的参数是我们要遍历的节点。最后,记住我们希望节点根据它们的完成时间以反向排序。这就是为什么res列表在返回之前是反转的。

这个 topsort 在回溯每个节点时对它们执行一些处理(它将它们附加到结果列表中)。DFS 在节点上回溯的顺序(也就是它们结束时间的顺序)被称为后序,而它首先访问它们的顺序被称为前序。这些时候的加工被称为前序后序加工。(练习 5-9 要求你在 DFS 中为这种处理添加通用钩子。)

节点颜色和边缘类型

在描述遍历时,我区分了三种节点:我们不知道的节点、我们队列中的节点和我们已经访问过的节点(其邻居现在在队列中)。有些书(如第一章中提到的 Cormen 等人的算法简介)介绍了一种节点着色的形式,这在 DFS 中尤为重要。每个节点一开始都被认为是白色的;它们在发现时间和结束时间之间是灰色的,之后是黑色的。为了实现 DFS,你并不真的需要这种分类,但是这对于理解它是有用的(或者,至少,如果你要阅读使用颜色的文本,了解它是有用的)。

根据 Trémaux 的算法,灰色交叉点是我们已经看到但已经避开的;黑色的十字路口是我们被迫第二次进入的路口(在原路返回时)。

这些颜色也可以用来对 DFS 树中的边进行分类。如果一条边 uv 被探索并且节点 v 是白色的,那么这条边就是一条树边——也就是说,它是遍历树的一部分。如果 v 是灰色的,那就是所谓的后沿,它可以追溯到 DFS 树中的一个祖先。最后,如果 v 为黑色,则边缘为所谓的前边缘横边缘。前向边是遍历树中到后代的边,而交叉边是任何其他边(即,不是树、后向边或前向边)。

请注意,您可以在不使用任何显式颜色标签的情况下对边进行分类。假设一个节点的时间跨度是从它的发现时间到它的结束时间的间隔。后代的时间跨度将包含在其祖先的时间跨度中,而与祖先无关的节点将具有不重叠的时间间隔。因此,您可以使用时间戳来判断某个东西是后沿还是前沿。即使使用颜色标签,您也需要参考时间戳来区分前向边缘和交叉边缘。

你可能不太需要这个分类,尽管它有一个重要的用途。如果你找到了一个后沿,这个图就包含了一个循环,如果你没有找到,它就没有。(练习 5-10 要求你展示这个。)换句话说,您可以使用 DFS 来检查一个图是否是 DAG(或者,对于无向图,是树)。练习 5-11 要求你考虑其他的遍历算法如何实现这个目的。

无限迷宫和最短(未加权)路径

到目前为止,DFS 过于急切的行为还不是问题。我们让它在迷宫(图)中自由活动,在它开始原路返回之前,它尽可能向某个方向偏离。但是,如果迷宫非常大,这可能会有问题。也许我们在寻找的东西,比如一个出口,就在我们出发的地方附近;如果 DFS 向不同的方向出发,它可能在内不会返回。如果迷宫是无限的,它将永远不会回来,即使不同的遍历可能在几分钟内找到出口。无限迷宫听起来可能有些牵强,但它们实际上非常类似于一种重要的遍历问题——在状态空间中寻找解决方案。

但是,像 DFS 一样,由于过于急切而迷失方向,不仅仅是大型图表中的问题。如果我们寻找从我们的开始节点到所有其他节点的最短路径*(暂时不考虑边权重),DFS 很可能会给我们错误的答案。看看图 5-6 中的例子。所发生的是,DFS,在它的渴望中,继续前进,直到通过一个弯路到达 c ,可以这么说。如果我们想要找到到所有其他节点的最短路径(如右图所示),我们需要更加保守。为了避免走弯路并“从后面”到达一个节点,我们需要一次一步地推进我们的遍历“边缘”。首先访问一步之外的所有节点,然后访问两步之外的所有节点,依此类推。*

*外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 5-6 。大小为四的循环的两次遍历。深度优先树(左侧突出显示)不一定包含最小路径,这与最短路径树(右侧突出显示)相反

为了与迷宫隐喻保持一致,让我们简单地看一下另一种迷宫探索算法,它是由 ystein(又名 Oystein) Ore 在 1959 年描述的。就像 Trémaux 一样,Ore 要求您在通道入口和出口处做标记。假设你从十字路口 a 开始。首先,你访问一个通道之外的所有十字路口,每次都返回到你的起点。如果你跟踪的任何一个通道都是死胡同,一旦你返回,你就把它们标记为关闭。任何带你去你已经去过的十字路口的通道也被标记为关闭(在两端)。

在这一点上,你想要开始探索两个步骤(也就是通道)之外的所有交叉点*。标记并浏览来自 a 的开放通道之一;它现在应该有两个标记。假设你最终到达十字路口 b 。现在,遍历(并标记)从 b 开始的所有开放通道,如果它们通向你已经看到的死胡同或交叉路口,确保关闭它们。完成后,返回到 a 。一旦你回到 a ,你就继续其他开放段落的过程,直到它们都得了两个分数。(这两个标记意味着你已经在通道中两步之外穿过了十字路口。)*

让我们跳到第步第步。 14 您已经访问了所有距离n–1 步远的十字路口,所以从 a 开始的所有开放通道现在都有n1 标记。在 a 旁边的任何路口的开放通道,比如你之前去过的 b ,上面都会有n—2 的标记,以此类推。要访问距离您的起点 n 的所有路口,您只需移动到 a 的所有邻居(例如 b ),在这样做的同时向通道添加标记,并按照相同的程序访问距离它们n*–1 的所有路口(根据归纳假设,这将有效)。*

同样,像这样只使用本地信息可能会使簿记有点乏味(并且解释有点混乱)。然而,就像 Trémaux 的算法在递归 DFS 中有一个非常接近的亲戚一样,Ore 的方法可以用一种可能更适合我们计算机科学大脑的方式来表述。结果是所谓的迭代深化深度优先搜索,或 IDDFS 、 15 ,它简单地包括运行具有迭代递增深度限制的深度约束 DFS。

清单 5-9 给出了一个相当简单的 IDDFS 实现。它保存了一个名为yielded的全局集合,由第一次发现并因此产生的节点组成。内部函数recurse基本上是一个具有深度限制的递归 DFSd。如果限制为零,则不会递归地探索更多的边。否则,递归调用会受到限制d-1iddfs函数中的主for循环遍历从 0(只访问并产生开始节点)到len(G)-1(最大可能深度)的每个深度限制。但是,如果在达到这样的深度之前已经发现了所有节点,那么循环就中断了。

清单 5-9 。迭代深化深度优先搜索

def iddfs(G, s):
    yielded = set()                             # Visited for the first time
    def recurse(G, s, d, S=None):               # Depth-limited DFS
        if s not in yielded:
            yield s
            yielded.add(s)
        if d == 0: return                       # Max depth zero: Backtrack
        if S is None: S = set()
        S.add(s)
        for u in G[s]:
            if u in S: continue
            for v in recurse(G, u, d-1, S):     # Recurse with depth-1
                yield v
    n = len(G)
    for d in range(n):                          # Try all depths 0..V-1
        if len(yielded) == n: break             # All nodes seen?
        for u in recurse(G, s, d):
            yield u

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 注意如果我们在探索一个无界图(比如一个无限状态空间),寻找一个特定的节点(或者一种节点),我们可能只是不断尝试更大的深度限制,直到找到我们想要的节点。

IDDFS 的运行时间并不完全清楚。与 DFS 不同,它通常会多次遍历许多边和节点,因此线性运行时间远远不能保证。比如你的图是一条路径,你从一端开始 IDDFS,运行时间将是二次。然而,这个例子是相当病态的;如果遍历树向外分支一点,它的大部分节点将在底层(就像在第三章的淘汰赛中一样),所以对于许多图来说,运行时间将是线性的或接近线性的。

试着在一个简单的图上运行iddfs,您将看到节点将从离开始节点最近到最远的顺序产生。返回所有距离为 k 的,然后返回所有距离为 k + 1 的,以此类推。如果我们想要找到实际的距离,我们可以很容易地在iddfs函数中执行一些额外的簿记,并产生距离和节点。另一种方法是维护一个距离表(类似于我们前面使用的 DFS 的发现和完成时间)。事实上,我们可以有一个距离字典和一个遍历树中的父字典。这样,我们可以检索实际的最短路径,以及距离。现在让我们专注于路径,而不是修改iddfs来包含额外的信息,我们将把它构建到另一个遍历算法 : 广度优先搜索 (BFS) 。

事实上,使用 BFS 进行遍历比使用 IDDFS 要容易得多。您只需使用带有先进先出队列的通用遍历框架(清单 5-6 )。 事实上,这是与 DFS 唯一显著的区别:我们用 FIFO 代替了 LIFO(见清单 5-10 )。结果是,较早发现的节点将被较早地访问,我们将一层一层地探索图,就像在 IDDFS 中一样。不过,这样做的好处是,我们不需要多次访问任何节点或边,所以我们回到了有保证的线性性能。 16

清单 5-10 。广度优先搜索

def bfs(G, s):
    P, Q = {s: None}, deque([s])                # Parents and FIFO queue
    while Q:
        u = Q.popleft()                         # Constant-time for deque
        for v in G[u]:
            if v in P: continue                 # Already has parent
            P[v] = u                            # Reached from u: u is parent
            Q.append(v)
    return P

如你所见,bfs函数类似于iter_dfs,来自清单 5-5 。我用一个 deque 替换了这个列表,我跟踪遍历树中哪些节点已经接收了一个父节点(也就是说,它们在P中),而不是记住我们访问过哪些节点(S)。要提取到节点u的路径,您可以简单地在P中“向后走”:

>>> path = [u]
>>> while P[u] is not None:
...     path.append(P[u])
...     u = P[u]
...
>>> path.reverse()

当然,您也可以在 DFS 中自由使用这种父字典,或者使用yield来迭代 BFS 中的节点。练习 5-13 要求你修改代码来寻找距离(而不是路径)。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 提示将 BFS 和 DFS 形象化的一种方式是浏览网页。如果你一直跟随链接,然后在完成一个页面后使用后退按钮,你就会得到 DFS。回溯有点像“撤销”BFS 更像是在一个新窗口(或标签页)中打开你已经打开的链接,然后在你完成每一页后关闭窗口。

实际上,只有一种情况下 IDDFS 比 BFS 更好:当搜索一棵大树(或者一些“形状”像树的状态空间)时。因为没有循环,所以我们不需要记住我们访问过哪些节点,这意味着 IDDFS 只需要存储返回起始节点的路径。 17 换句话说,在这些情况下,IDDFS 可以节省大量内存,几乎没有或根本没有渐进减速。

黑盒:DEQUE

正如已经几次简要提到的,Python 列表产生了很好的堆栈(LIFO 队列),但是很差(FIFO 队列)。附加到它们需要恒定的时间(至少在许多这样的附加平均时),但是从前面弹出(或插入)需要线性时间。对于 BFS 这样的算法,我们想要的是一个双端队列、或双端队列。这样的队列通常被实现为链表(其中追加/前置和两端的弹出是常数时间操作),或者所谓的循环缓冲区— 数组,其中我们跟踪第一个元素(头部)和最后一个元素(尾部)的位置。如果头部或尾部移动超出了数组的末端,我们就让它“绕”到另一边,我们使用 mod ( %)操作符来计算实际的索引(因此有了术语循环)。如果我们完全填满数组,我们可以将内容重新分配到一个更大的数组,就像动态数组一样(参见第二章中list的“黑盒”侧栏)。

幸运的是,Python 在标准库中的collections模块中有一个 deque 类。除了在侧执行的appendextendpop等方法外,还有等效,称为appendleftextendleftpopleft。在内部,deque 被实现为一个由组成的双向链表,每个块都是一个单独元素的数组。虽然在渐近上等同于使用单个元素的链表,但这减少了开销,并使其在实践中更有效。例如,如果表达式d[k]是一个普通的列表,那么它需要遍历队列d的第一个k元素。如果每个块都包含b元素,那么你只需要遍历k//b块。

强连通分量

虽然像 DFS、IDDFS 和 BFS 这样的遍历算法本身就很有用,但是我在前面提到过遍历作为底层结构在其他算法中的作用。您将在接下来的许多章节中看到这一点,但是我将用一个经典的例子来结束这一章——这是一个相当棘手的问题,只要对基本遍历有所了解就可以很好地解决。

问题是找到强连通分量 (SCCs) ,有时简称为强分量。SCC 是连接组件的直接模拟,我在本章开始时向您展示了如何找到它。连通分量是一个极大子图,其中如果忽略边方向(或者如果图是无向的),所有节点都可以到达彼此。然而,为了得到连接的组件,你需要遵循边的方向;因此,SCCs 是从任意节点到任意其他节点有向路径的极大子图。例如,在现代优化编译器中,寻找 SCC 和类似结构是数据流分析的重要部分。

考虑图 5-7 中的图表。和我们开始用的那个(图 5-1 )挺像的;虽然有一些额外的边,这个新图形的 SCCs 由与无向原始图形的连接组件相同的节点组成。正如您所看到的,在(突出显示的)强组件中,任何节点都可以到达任何其他节点,但是如果您尝试向其中任何节点添加其他节点,该属性就会失效。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 5-7 。有三个 SCC(突出显示)的有向图:A、B 和 C

想象一下在这个图上执行 DFS(可能从几个起点开始遍历,以确保覆盖整个图)。现在考虑强组件 A 和 B 中节点的完成时间。如您所见,从 A 到 B 有一条边,但没有办法从 B 到 A。这对完成时间有影响。你可以确定 A 会晚于 b 完成,也就是说 A 中的最后完成时间会晚于 b 中的最后完成时间,看看图 5-7 ,应该很明显为什么会这样。如果你从 B 开始,你永远不能进入 A,所以 B 会在你甚至开始(更不用说完成)遍历 A 之前完全结束。然而,如果你从 A 开始,你知道你永远不会卡在那里(每个节点都可以到达其他节点),所以在完成遍历之前,你最终迁移到 B,你必须在回溯到 A 之前完全完成那个(在这种情况下,还有 C)

事实上,一般来说,如果从任意一个强分量 X 到另一个强分量 Y 有一条边,那么 X 中的最后完成时间将晚于 Y 中的最晚完成时间,其推理与我们的例子相同(参见练习 5-16)。我的结论是基于这样一个事实,即你不能从 B 到达 A——事实上,这是 SCC 通常的工作方式,因为 SCC 形成了 DAG!因此,如果从 X 到 Y 有一条边,那么从 Y 到 X 就不会有任何路径。

考虑图 5-7 中突出显示的组件。如果您将它们收缩为单个“超级节点”(将边保留在原来有边的地方),您最终会得到一个图,我们称之为 SCC 图,如下所示:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

这显然是一个 DAG,但是为什么这样的 SCC 图总是是非循环的呢?假设 SCC 图中有一个循环。这意味着你可以从一个 SCC 到另一个 SCC,然后再回来。你觉得有问题吗?是的,完全正确:第一个 SCC 中的每个节点都可以到达第二个 SCC 中的每个节点,反之亦然;事实上,这样一个周期中的所有 SCC 将组合成一个单个 SCC ,这与我们最初认为它们是独立的假设相矛盾。

现在,假设你翻转了图中的所有边。这不会影响 SCC 中哪些节点属于同一个节点(见练习 5-15),但它影响 SCC 图。在我们的例子中,你不能再走出 A,如果你穿越了 A,在 B 开始了新一轮,你不能从中逃脱,只剩下 c 和…等一下…我只是在那里找到了强组件,不是吗?为了在一般情况下应用这个想法,我们总是需要在原始图中没有任何入边的 SCC 中开始(也就是说,在翻转后没有出边)。基本上,我们在 SCC 图的拓扑排序中寻找第一个 SCC。(然后我们会继续第二个,以此类推。)回顾我们最初的 DFS 推理,如果我们从具有最晚完成时间的节点开始遍历,那就是我们要去的地方。事实上,如果我们通过减少结束时间来选择最终遍历的起点,我们就能保证一次完全探索一个 SCC,因为反向边会阻止我们移动到下一个 SCC。

这种推理可能有点难以理解,但主要思想并不难理解。如果从 A 到 B 有一条边,A 将比 B 有更晚的(最终)完成时间。如果我们根据减少的完成时间选择(第二次)遍历的起点,这意味着我们将在 B 之前访问 A。现在,如果我们反转所有的边,我们仍然可以探索整个 A,但我们不能继续到 B,这使我们一次只能探索一个 SCC。

下面是算法的概要。请注意,我没有“手动”使用 DFS 并按照完成时间对节点进行反向排序,而是简单地使用了dfs_topsort函数,为我完成了这项工作。 18

  1. 在图上运行dfs_topsort,产生一个序列seq
  2. 反转所有边缘。
  3. 运行一次完整的遍历,从seq开始选择起点(按顺序)。

关于这一点的实现,见清单 5-11 。

清单 5-11 。寻找强连通分量的 Kosaraju 算法

def tr(G):                                      # Transpose (rev. edges of) G
    GT = {}
    for u in G: GT[u] = set()                   # Get all the nodes in there
    for u in G:
        for v in G[u]:
            GT[v].add(u)                        # Add all reverse edges
    return GT

def scc(G):
    GT = tr(G)                                  # Get the transposed graph
    sccs, seen = [], set()
    for u in dfs_topsort(G):                    # DFS starting points
        if u in seen: continue                  # Ignore covered nodes
        C = walk(GT, u, seen)                   # Don't go "backward" (seen)
        seen.update(C)                          # We've now seen C
        sccs.append(C)                          # Another SCC found
    return sccs

如果你试着在图 5-7 中的图上运行scc,你应该得到三组{ abcd};{ efg};还有{ ih }。 19 注意,在调用walk时,我现在已经提供了S参数,使其避开之前的 SCCs。因为所有的边都指向后面,所以除非明确禁止,否则很容易开始遍历这些边。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传然而,这是行不通的(如练习 5-17 所要求的)。

目标和修剪

本章讨论的遍历算法将访问它们能到达的每一个节点。然而,有时您正在寻找一个特定的节点(或一种节点),并且您希望尽可能忽略图中的大部分内容。这种搜索称为目标导向的、和,忽略遍历的潜在子树的行为称为修剪。例如,如果您知道您正在寻找的节点在起始节点的 k 步内,那么运行深度限制为 k 的遍历将是一种修剪形式。二分法搜索或在搜索树中搜索(在第六章的中讨论)也涉及修剪。您不必遍历整个搜索树,只需访问可能包含您要查找的值的子树。树的构造使得你通常可以在每一步丢弃大多数子树,从而产生高效的算法。

知道你要去哪里也可以让你先选择最有希望的方向(所谓的最佳优先搜索)。 这是 A算法的一个例子,在第九章中讨论过。如果你正在搜索一个可能解决方案的空间,你也可以评估一个给定方向的前景*(也就是说,我们沿着这条边能找到多好的最佳解决方案?).通过忽略那些不会帮助你提高到目前为止发现的最好水平的边缘,你可以大大加快速度。这种方法称为分支和绑定 ,在第十一章的中讨论。

摘要

在这一章中,我已经向你展示了在图中移动的基本原理,不管它们是否有方向。这种遍历的思想直接或从概念上构成了本书后面将要学习的许多算法以及后面可能会遇到的其他算法的基础。我使用了迷宫遍历算法的例子(比如 Trémaux 和 Ore ),尽管它们主要是作为更计算机友好的方法的起点。遍历一个图的一般过程包括维护一个您已经发现的节点的概念性待办事项列表(一个队列),在这里您可以检查那些您实际访问过的节点。列表最初只包含开始节点,在每一步中,您访问(并检查)其中一个节点,同时将它的邻居添加到列表中。列表中项目的排序(时间表)在很大程度上决定了你正在进行的遍历类型:例如,使用 LIFO 队列(堆栈)进行深度优先搜索(DFS),而使用 FIFO 队列进行广度优先搜索(BFS)。DFS 相当于一种相对直接的递归遍历,它允许您找到每个节点的发现和完成时间,后代节点的发现和完成时间间隔将在祖先节点的发现和完成时间间隔之内。BFS 有一个有用的特性,可以用来寻找从一个节点到另一个节点的最短(未加权)路径。DFS 的一个变种,叫做迭代深化 DFS ,也有这个属性,但是它对于在大树中搜索更有用,比如在第十一章中讨论的状态空间。

如果一个图由几个相连的部分组成,你需要为每个部分重新开始一次遍历。为此,您可以遍历所有节点,跳过已经访问过的节点,然后从其他节点开始遍历。在有向图中,这种方法可能是必要的,即使图是连通的,因为边方向可能会阻止您到达所有节点。为了找到一个有向图的连通部分——图中所有节点可以互相到达的部分——需要一个稍微复杂一点的过程。这里讨论的算法,Kosaraju 的算法,首先找到所有节点的完成时间,然后在转置图(所有边都反转的图)中运行遍历,使用递减的完成时间来选择起始点。

如果你好奇的话…

如果你喜欢遍历,不用担心。我们很快会做更多这样的事情。你也可以找到关于 DFS、BFS 和 SCC 算法的细节,例如,在 Cormen 等人的书中讨论的(见“参考文献”,第一章)。如果你对寻找强分量感兴趣,在本章的“参考”部分有关于 Tarjan 和 Gabow(或者更确切地说,Cheriyan-Mehlhorn/Gabow)算法的参考。

练习

5-1.在清单 5-2 中的components函数中,一次用整个组件更新可见节点集。另一种选择是在walk中逐个添加节点。那会有什么不同(或者,也许,没有那么不同)?

5-2.如果你面对一个图,其中每个节点的度数都是偶数,你会如何寻找欧拉之旅?

5-3.如果有向图中的每个节点都有相同的入度和出度,你可以找到一个有向欧拉之旅。为什么会这样?你会怎么做,这和 Trémaux 的算法有什么关系?

5-4.图像处理中的一个基本操作是所谓的泛色填充,其中图像中的一个区域用单一颜色填充。在绘画应用(如 GIMP 或 Adobe Photoshop)中,这通常是通过油漆桶工具来完成的。你如何实现这种填充?

5-5.在希腊神话中,当阿里阿德涅帮助忒修斯战胜牛头怪并逃离迷宫时,她给了他一团羊毛线,让他可以重新找到出路。但是,如果忒修斯在进来的时候忘记系好外面的线,并且只有在彻底迷路的时候才想起那个球,那该怎么办呢?

5-6.在递归 DFS 中,当您从一个递归调用返回时,会发生回溯。但是在迭代版本中回溯去了哪里?

5-7.写一个 DFS 的非递归版本,它可以决定完成时间。

5-8.在dfs_topsort ( 清单 5-8 )中,递归 DFS 从每个节点开始(尽管如果节点已经被访问过,它会立即终止)。即使起始节点的顺序完全是任意的,我们如何确定我们将得到一个有效的拓扑排序?

5-9.编写一个 DFS 版本,其中有钩子(可重写函数),允许用户按照前后顺序执行定制处理。

5-10.证明当(且仅当)DFS 找不到后边缘时,被遍历的图是非循环的。

5-11.如果你想使用除 DFS 之外的其他遍历算法在有向图中寻找环,你会面临什么挑战?为什么不用无向图面对这些挑战?

5-12.如果您在无向图中运行 DFS,您将不会有任何前向或交叉边。为什么会这样?

5-13.编写一个 BFS 版本,找出从起始节点到其他每个节点的距离,而不是实际路径。

5-14.正如在第四章中提到的,如果你能把节点分成两个集合,使得没有邻居在同一个集合中,那么这个图就叫做二部图。另一种思考方式是将每个节点涂成黑色或白色(例如),这样相邻节点就不会有相同的颜色。展示对于任何无向图,如果存在这样的二分图(或双色图),你将如何找到它。

5-15.如果你反转一个有向图的所有边,强连通分量保持不变。这是为什么呢?

5-16.设 X 和 Y 是同一个图的两个强连通分量, G 。假设从 X 到 y 至少有一条边,如果在 G 上运行 DFS(根据需要重新启动,直到所有节点都被访问过),那么 X 中最晚的结束时间将总是晚于 y 中最晚的,这是为什么呢?

5-17.在 Kosaraju 的算法中,我们通过从初始 DFS 开始递减完成时间来找到最终遍历的开始节点,并且我们在转置图中执行遍历(即,所有边都反转)。为什么我们不能只使用原始图中的上升完成时间?

参考

Cheriyan,j .和 Mehlhorn,K. (1996 年)。随机存取计算机上的稠密图和网络算法。 Algorithmica ,15(6):521-549。

利特伍德高等教育学院(1949 年)。数学的万能钥匙:复杂代数理论的简单叙述。哈钦森&有限公司。

卢卡斯,是的。(1891 年)。数学娱乐第 1 卷,第二版。gau thier-villers 和 son,打印机-图书管理员。http://archive.org线上可用。

卢卡斯,是的。(1896 年)。数学娱乐第 2 卷,第二版。gau thier-villers 和 son,打印机-图书管理员。http://archive.org线上可用。

俄勒冈州,1959 年。迷宫之旅。数学老师,52:367-370。

塔尔詹河(1972 年)。深度优先搜索和线性图算法。 SIAM 计算学报,1(2): 146-160。


1 我从达德利·欧内斯特·利特伍德的数学万能钥匙中“偷”出了这一章的副标题。

如果你不是游戏玩家,请随意把这里想象成你的办公大楼、梦想家园或任何你喜欢的地方。

3 我将在下面使用带有邻接集的字典作为默认表示,尽管许多算法也可以很好地与第二章中的其他表示一起工作。通常,重写一个算法来使用不同的表示也不会太难。

4 这是本章所有遍历算法的运行时间,除了(有时)IDDFS。

5 嘿,连牛顿和苹果的故事都是杜撰的。

6a 开始追溯你的行程,你应该以节点顺序 abcdefghdc 结束 cblba

当然,如果你真的面对现实生活中的迷宫,这个递归版本会更难使用。

就这样,一个洞穴探险者可以变成洞穴人。

9 人们在野外漫步时似乎也最终会绕圈子。美国陆军的研究表明,出于某种原因,人们更喜欢去南方(只要他们有自己的方向)。当然,如果您的目标是完全遍历,这两种策略都不是特别有用。

10 我的翻译。

即使你的靴子没有沾上泥,你也可以进行同样的程序。只要确保清楚地标记入口和出口(比如用粉笔)。在这种情况下,当你来到一个旧的十字路口时,做两个标记并立即开始原路返回是很重要的。

12 事实上,在某些上下文中,术语回溯被用作递归遍历,或者深度优先搜索的同义词。

13dfs_topsort函数也可用于通过减少完成时间来对一般图的节点进行排序,这在寻找强连通组件时是需要的,这将在本章稍后讨论。

14 换句话说,让我们进行归纳思考。

15 添加那种标记当然是可能的,并且是一种修剪的形式,这将在本章后面讨论。

另一方面,我们将从一个节点跳到另一个节点,这在现实生活的迷宫中是不可能实现的。

17 要想有任何内存储蓄,你就得去掉S设置。因为您将遍历一棵树,这不会引起任何麻烦(即遍历循环)。

18 这看起来像是作弊,因为我在非 DAG 上使用拓扑排序。这个想法只是通过减少完成时间来对节点进行排序,这正是dfs_topsort在线性时间中所做的。

19 其实,walk会为每个强分量返回一个遍历树。****

六、分裂、结合和征服

分而治之,一个健全的座右铭;
团结带领,更好的自己。

——约翰·沃尔夫冈·冯·歌德,

这一章是三章中的第一章,讲述众所周知的设计策略。本章讨论的策略,分而治之(或简称为 D & C),是基于以一种提高性能的方式分解你的问题。您划分问题实例,递归地解决子问题,组合结果,从而征服问题——这种模式反映在章节标题中。?? 1

树形问题:关于平衡的一切

我之前提到过子问题图的概念:我们将子问题视为节点,将依赖关系(或归约)视为边。这种子问题图的最简单结构是一棵树。每个子问题可能依赖于一个或多个其他问题,但是我们可以独立地解决这些其他子问题。(当我们去除这种独立性时,我们最终会遇到第八章中提到的那种重叠和纠缠。)这种直接的结构意味着,只要我们能找到适当的约简,就可以直接实现我们算法的递归公式。

你已经有了理解分治算法所需的所有拼图。我已经讨论过的三个想法涵盖了要点:

  • 分治循环,在第三章
  • 强感应,在第四章
  • 递归遍历,在第五章

递归告诉您一些关于所涉及的性能的信息,归纳为您提供了理解算法如何工作的工具,递归遍历(树中的 DFS)是算法的原始框架。

直接实现归纳步骤的递归公式并不新鲜。例如,在第四章中,我向你展示了一些简单的排序算法是如何实现的。在分而治之的设计方法中,一个重要的增加是平衡。这就是强归纳的用武之地:我们不想递归地实现从 n -1 到 n 的步骤,而是想从 n /2 到 n 。也就是说,我们采用大小为 n /2 的解决方案,并构建大小为 n 的解决方案。不是(归纳地)假设我们可以解决尺寸为 n -1 的子问题,而是假设我们可以处理尺寸小于 n 的所有子问题。

你会问,这和平衡有什么关系?想想弱诱导的例子。我们基本上将问题分成两部分:一部分大小为 n -1,另一部分大小为 1。假设归纳步骤的成本是线性的(这种情况很常见)。那么这就给了我们递归T(n)=T(n-1)+T(1)+n。这两个递归调用非常不平衡,我们基本上以握手循环结束,结果运行时间是二次的。如果我们设法在两个递归调用中更均匀地分配工作会怎么样?也就是说,我们能把问题简化成两个大小相似的子问题吗?在这种情况下,递归变为T(n)= 2T(n/2)+n。这也应该很熟悉:这是典型的分治递归,它产生一个对数线性(θ(nLGn))运行时间——一个巨大的改进。

图 6-1 和 6-2 以递归树的形式展示了这两种方法的区别。注意,节点的数量是相同的——主要的影响来自于工作在这些节点上的分布。这看起来像是魔术师的把戏;工作去哪了?重要的认识是,对于简单的非平衡逐步方法(图 6-1 ,许多节点被分配了高工作负荷,而对于平衡的分治方法(图 6-2 ),大多数节点只有很少的工作要做。例如,在非平衡递归中,总会有大约四分之一的调用的成本至少为 n /2,而在平衡递归中,无论 n 的值是多少,都只有三个*。这是一个非常显著的差异。*

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6-1 。非平衡分解,具有线性除法/组合成本和二次运行时间总计

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6-2 。分而治之:一种平衡的分解,具有线性的划分/组合成本和总的对数线性运行时间

让我们试着在实际问题中认识这种模式。天际线问题 2 就是一个相当简单的例子。给你一个三元组的排序序列( LHR ),其中 L 是建筑物的左侧x-坐标, H 是其高度, R 是其右侧x-坐标。换句话说,从一个给定的有利位置看,每一个三元组代表一个建筑的(矩形)轮廓。你的任务是从这些单独的建筑轮廓构建一个天际线。

图 6-3 和 6-4 说明了这个问题。在图 6-4 中,一座建筑正被添加到现有的天际线上。如果天际线被存储为指示水平线段的三元组列表,则可以通过以下方式在线性时间内添加新建筑物:( 1)在天际线序列中寻找建筑物的左侧坐标,( 2)提升所有低于该建筑物的坐标,直到(3)找到建筑物的右侧坐标。如果新建筑的左右坐标在一些水平线段的中间,那么它们需要被一分为二。为简单起见,我们可以假设从覆盖整个天际线的零高度线段开始。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6-3 。一组建筑轮廓和由此产生的天际线

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6-4 。将建筑物(虚线)添加到天际线(实线)

这种合并的细节在这里并不那么重要。重点是我们可以在线性时间内给天际线增加一个建筑。使用简单(弱)归纳,我们现在有了我们的算法:我们从一个单一的建筑开始,并不断增加新的建筑,直到我们完成。当然,这个算法的运行时间是二次的。为了改善这一点,我们想改用强诱导——分而治之。我们可以通过注意到合并两个天际线并不比合并一个建筑和一个天际线更困难来做到这一点:我们只是以“锁步”的方式遍历两个天际线,只要一个比另一个值高,我们就使用最大值,在需要的地方分割水平线段。利用这种洞察力,我们有了第二个改进的算法:为所有建筑创建天际线,首先(递归地)基于一半的建筑创建两条天际线,然后将它们合并。这个算法,我相信你可以看到,有一个对数线性运行时间。练习 6-1 要求你实际实现这个算法。

标准 D&C 算法

上一节提到的递归 skyline 算法举例说明了分治算法的典型工作方式。输入是一组(也许是一个序列)元素;在至多线性时间内,将元素划分成大小大致相等的两组,在每一半上递归运行算法,并且也在至多线性时间内组合结果。当然可以修改这种标准形式(在下一节中您将看到一个重要的变化),但是这种模式包含了核心思想。

清单 6-1 勾画了一个通用的分治功能。您可能会为每个算法实现一个定制版本,而不是使用这样的通用函数,但是它确实说明了这些算法是如何工作的。我在这里假设在基本情况下简单地返回S是可以的;当然,这取决于combine函数如何工作。 3

清单 6-1 。分治方案的一般实现

def divide_and_conquer(S, divide, combine):
    if len(S) == 1: return S
    L, R = divide(S)
    A = divide_and_conquer(L, divide, combine)
    B = divide_and_conquer(R, divide, combine)
    return combine(A, B)

图 6-5 是同一模式的另一个例子。图的上半部分表示递归调用,而下半部分表示返回值的组合方式。一些算法(如快速排序,在本章后面描述)在的上半部分完成大部分工作(除法),而一些算法在的下半部分(组合)更加活跃。关注组合的算法中最著名的例子可能是合并排序(在本章的后面会有描述),它也是分治算法的一个典型例子。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6-5 。分治算法中的划分、递归和组合

对半搜索

在研究更多符合通用模式的例子之前,让我们看一个与相关的模式,它丢弃了一个递归调用。你已经在我之前提到的二分搜索法(二分法)中看到了这一点:它将问题分成相等的两半,然后只在这两半的一个上重现。这里的核心原则还是平衡。考虑一下在完全不平衡的搜索中会发生什么。如果你还记得《??》第三章中的“想一个粒子”游戏,不平衡解就相当于问“这是你的粒子吗?”对于宇宙中的每一个粒子。不同之处仍然包含在图 6-1 和图 6-2 中,只是每个节点中的工作(对于这个问题)是不变的,我们实际上只是沿着从根到叶的路径执行工作。

二分搜索法似乎并不那么有趣。当然,这很有效率,但是搜索一个有序的序列…这难道不是一个有限的应用领域吗?嗯,不,不是真的。首先,该操作本身作为其他算法的一个组成部分可能很重要。其次,也许同样重要的是,二分搜索法可以成为寻找事物的一种更普遍的方法。例如,这种想法可以用于数值优化,如牛顿法,或在调试你的代码。尽管手动进行“二分法调试”可能足够有效(“代码在到达这个print语句之前崩溃了吗?”),在一些修订控制系统(RCS)中也有使用,比如 Mercurial 和 Git。

它是这样工作的:你使用一个 RCS 来跟踪你代码中的变化。它存储了许多不同的版本,可以说你可以“回到过去”,随时检查旧代码。现在,假设你遇到了一个新的 bug,你很想找到它,这是可以理解的。你的 RCS 能帮上什么忙?首先,您为您的测试套件编写一个测试——如果有 bug,它会检测出来。(调试时,这总是一个很好的第一步。)您确保设置了测试,以便 RCS 可以访问它。然后,您要求 RCS 在您的历史记录中查找错误出现的位置。它是怎么做到的?大惊喜:二分搜索法。假设您知道该错误出现在修订版 349 和 574 之间。RCS 将首先将您的代码恢复到修订版 461(在两者之间)并运行您的测试。窃听器在吗?如果是这样,你知道它出现在 349 年到 461 年之间。如果不是,出现在 462 年到 574 年之间。起泡,冲洗,重复。

这不仅仅是二分法用途的一个简单例子;它还很好地说明了其他几点。首先,它表明您不能总是使用已知算法的常规实现,即使您并没有真正修改它们。在这种情况下,RCS 背后的实现者很可能必须自己实现二分搜索法。其次,这是一个很好的例子,说明减少基本操作的数量可能是至关重要的——比高效地实现事情更重要。编译您的代码和运行测试套件无论如何都很慢,所以您希望尽可能少地这样做。

黑盒:平分

二分搜索法可以应用在许多设置中,但是在标准库中的bisect模块中有直接的“在排序序列中搜索值”版本。它包含了bisect函数,该函数按预期工作:

>>> from bisect import bisect
>>> a = [0, 2, 3, 5, 6, 8, 8, 9]
>>> bisect(a, 5)

嗯,这有点像你所期待的…它不会返回已经存在的 5 的位置。相反,它报告插入新的 5 的位置,确保它被放置在具有相同值的所有现有项目的之后。事实上,bisectbisect_right的别称,还有一个bisect_left:

>>> from bisect import bisect_left
>>> bisect_left(a, 5)

为了提高速度,bisect模块是用 C 实现的,但在早期版本(Python 2.4 之前)中,它实际上是一个普通的 Python 模块,而bisect_right的代码如下(加上我的注释):

def bisect_right(a, x, lo=0, hi=None):
if hi is None:                              # Searching to the end
hi = len(a)
while lo < hi:                              # More than one possibility
mid = (lo+hi)//2                        # Bisect (find midpoint)
if x < a[mid]: hi = mid                 # Value < middle? Go left
else: lo = mid+1                        # Otherwise: go right
return lo

如您所见,实现是迭代的,但它完全等同于递归版本。

在这个模块中还有另外一对有用的函数:insort(?? 的别名)和insort_left。这些函数找到正确的位置,就像它们的bisect对应函数一样,然后实际插入元素。虽然插入仍然是线性操作,但至少搜索是对数的(并且实际的插入代码实现得相当高效)。

遗憾的是,bisect库的各种函数不支持key参数,例如在list.sort中使用的参数。您可以使用所谓的装饰、排序、取消装饰(或者,在本例中,装饰、搜索、取消装饰)模式,或者简称为 DSU,来实现类似的功能:

>>> seq = "I aim to misbehave".split()
>>> dec = sorted((len(x), x) for x in seq)
>>> keys = [k for (k, v) in dec]
>>> vals = [v for (k, v) in dec]
>>> vals[bisect_left(keys, 3)]

或者,你可以做得更简洁:

>>> seq = "I aim to misbehave".split()
>>> dec = sorted((len(x), x) for x in seq)
>>> dec[bisect_left(dec, (3, ""))][1]

如您所见,这涉及到创建一个新的修饰列表,这是一个线性操作。显然,如果我们在每次搜索之前都这样做,那么使用bisect就没有意义了。但是,如果我们可以在搜索之间保留修饰列表,那么这个模式可能会有用。如果序列一开始就没有排序,我们可以像前面的例子一样,将 DSU 作为排序的一部分。

遍历搜索树…带修剪

二分搜索法是最棒的。这是最简单的算法之一,但它真的很强大。不过,有一个问题:要使用它,必须对值进行排序。现在,如果我们能把它们保存在一个链表中,那就不成问题了。对于我们想要插入的任何对象,我们只需用二分法(对数)找到位置,然后插入它(常数)。问题是——那行不通。二分搜索法需要能够在常数时间内检查中间值,这是我们用链表做不到的。当然,使用数组(比如 Python 的列表)也无济于事。这有助于分割,但会破坏插入。

如果我们想要一个对搜索有效的可修改的结构,我们需要某种中间地带。我们需要一个类似于链表的结构(这样我们就可以在常量时间内插入元素),但仍然允许我们执行二分搜索法。根据这一节的标题,你可能已经想通了整件事,但是请耐心听我说。我们在搜索时首先需要的是在常量时间内访问中间项。所以,假设我们保持一个直接的链接。从那里,我们可以向左或向右,我们需要访问左半部分或右半部分的中间元素。因此…我们可以只保留从第一项到这两项的直接链接,一个“左”引用和一个“右”引用。

换句话说,我们可以将二分搜索法的结构表示为一个显式的树形结构!这样的树很容易修改,我们可以在对数时间内从根到叶遍历它。因此,搜索实际上是我们的老朋友遍历——但是有修剪。我们不想遍历整个树(导致所谓的线性扫描)。除非我们是从有序的值序列中构建树,否则“左半部分的中间元素”这一术语可能并不那么有用。相反,我们可以考虑我们需要什么来实现我们的修剪。当我们查看根时,我们需要能够修剪其中一个子树。(如果我们在一个内部节点中找到了我们想要的值,并且该树不包含重复的值,我们当然不会继续在或者子树中继续。)

我们需要的一样东西就是所谓的搜索树属性:对于一个根在 r 的子树,左边子树中的所有值都是小于*(或等于)r* 的值,而右边子树中的值都是大于*。换句话说,子树根处的值将子树一分为二。具有该属性的示例树如图 6-6 所示,其中节点标签表示我们正在搜索的值。像这样的树结构在实现集合时会很有用;也就是说,我们可以检查给定的值是否存在。然而,为了实现一个映射*,每个节点都将包含一个我们所寻找的键和一个我们想要的值。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6-6 。一个(完美平衡的)二叉查找树,突出显示了 11 的搜索路径

通常,你不会批量构建一个树(尽管有时这很有用);使用树的主要动机是它们是动态的,您可以一个接一个地添加节点。要添加一个节点,你需要搜索它应该在哪里,然后在那里添加一个新的叶子。例如,图 6-6 中的树可能是通过最初添加 8,然后添加 12、14、4 和 6 而构建的。不同的排序可能会得到不同的树。

清单 6-2 给出了一个二叉查找树的简单实现,以及一个包装器,让它看起来有点像字典。你可以这样使用它,例如:

>>> tree = Tree()
>>> tree["a"] = 42
>>> tree["a"]
42
>>>  "b" in tree
False

如您所见,我已经将插入和搜索实现为独立的函数,而不是方法。这样它们也可以在None节点上工作。(当然不一定要那样做。)

清单 6-2 。插入并在二叉查找树中搜索

class Node:
    lft = None
    rgt = None
    def __init__(self, key, val):
        self.key = key
        self.val = val

def insert(node, key, val):
    if node is None: return Node(key, val)      # Empty leaf: add node here
    if node.key == key: node.val = val          # Found key: replace val
    elif key < node.key:                        # Less than the key?
        node.lft = insert(node.lft, key, val)   # Go left
    else:                                       # Otherwise...
        node.rgt = insert(node.rgt, key, val)   # Go right
    return node

def search(node, key):
    if node is None: raise KeyError             # Empty leaf: it's not here
    if node.key == key: return node.val         # Found key: return val
    elif key < node.key:                        # Less than the key?
        return search(node.lft, key)            # Go left
    else:                                       # Otherwise...
        return search(node.rgt, key)            # Go right

class Tree:                                     # Simple wrapper
    root = None
    def __setitem__(self, key, val):
        self.root = insert(self.root, key, val)
    def __getitem__(self, key):
        return search(self.root, key)
    def __contains__(self, key):
        try: search(self.root, key)
        except KeyError: return False
        return True

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 注意清单 6-2 中的实现不允许树包含重复的键。如果使用现有键插入新值,旧值将被覆盖。这很容易改变,因为树结构本身并不排除重复。

排序数组、树和字典:选择、选择

二分法(在排序数组上)、二分搜索法树和 dicts(也就是散列表)都实现了相同的基本功能:它们让您可以高效地搜索。尽管如此,还是有一些重要的区别。二分法速度很快,开销很小,但是只适用于排序数组(比如 Python 列表)。并且排序后的数组很难维护;添加元素需要线性时间。搜索树的开销更大,但它是动态的,允许您插入和删除元素。然而,在许多情况下,散列表以dict的形式成为了明显的赢家。它的平均渐近运行时间是常数(与二分法和搜索树的对数运行时间相反),与实际接近,开销很小。

散列要求你能够为你的对象计算一个散列值。在实践中,你几乎总是可以这样做,但在理论上,二分法和搜索树在这里更灵活一些——它们只需要比较对象,并找出哪个更小。 4 这种对排序的关注也意味着搜索树将允许你以排序的顺序访问你的值——要么全部,要么只是一部分。树也可以扩展到多维工作(搜索超矩形区域内的点),或者扩展到更奇怪的搜索标准形式,其中散列可能很难实现。还有更常见的情况,散列法不能立即适用。例如,如果您想要最接近您的查找关键字的条目,那么搜索树将是一个不错的选择。

选择

我将用一个你在实践中可能不会经常用到的算法来结束这一节的“对半搜索”,但这将二分法的思想引向了一个有趣的方向。此外,它为快速排序(下一节)设置了阶段,这是经典之一。

问题是在线性时间中,找到无序序列中的第 k 个最大数。最重要的情况可能是找到中间值——如果序列被排序,那么将会是位于中间位置的(即(n+1)//2)的元素。有趣的是,作为该算法如何工作的副作用,它还允许我们识别哪些对象比我们寻找的对象小。这意味着我们将能够找到运行时间为θ(n)的最小的 k (同时也是最大的 n - k )元素,这意味着 k 的值无关紧要!

这可能比乍看起来更奇怪。运行时间限制排除了排序(除非我们可以计数出现次数并使用计数排序,如第四章中所讨论的)。任何其他明显的算法寻找 k 最小的对象将使用一些数据结构来跟踪它们。例如,您可以使用一种类似于插入排序的方法:在序列的开头或者在一个单独的序列中保留到目前为止找到的最小的对象。

如果你跟踪其中哪个最大,检查主序列中的每个大的对象会很快(只是一个常量时间检查)。但是,如果你需要添加一个对象,并且你已经有了 k ,你就必须删除一个。当然,你会去掉最大的,但是你必须找出哪一个现在是最大的。你可以对它们进行排序(也就是说,接近插入排序),但是运行时间无论如何都是θ(NK)。

从这个(渐进地)上一步将是使用一个,本质上将我们的“部分插入排序”转换为“部分堆排序”,确保堆中的元素永远不会超过 k 个。(有关更多信息,请参见关于二进制堆的“黑盒”侧栏、heapq和 heapsort。)这会给你一个运行时间θ(nLGk),对于一个相当小的 k ,这几乎与θ(n)相同,并且它让你迭代主序列而不用在内存中跳跃,所以实际上它可能是选择的解决方案。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 提示如果你在 Python 中寻找 iterable 中的 k 最小(或最大)的对象,如果你的 k 相对于对象总数来说很小,你可能会使用heapq模块中的nsmallest(或nlargest)函数。如果 k 很大,你应该对序列进行排序(或者使用sort方法或者使用sorted函数)并挑选出第 k 个对象。对您的结果进行计时,看看什么效果最好——或者只选择能让您的代码尽可能清晰的版本。

那么,我们如何才能采取下一步,渐进地,完全消除对 k 的依赖呢?事实证明,保证线性最坏情况有点棘手,所以让我们把重点放在平均情况上。现在,如果我告诉你尝试应用分而治之的想法,你会怎么做?第一个线索可能是我们的目标是一个线性运行时间;什么样的“除以二”循环会这样做?就是单次递归调用的那种(相当于淘汰赛总和):T(n)=T(n/2)+n。换句话说,我们通过执行线性工作将问题分成两半(或者,现在,平均分成两半),就像更规范的分治法一样,但我们设法消除了一半,使我们更接近二分搜索法。为了设计这个算法,我们需要弄清楚的是,如何在线性时间内划分数据,以便我们最终将所有对象分成两半。

和往常一样,系统地浏览我们所掌握的工具,并尽可能清晰地描述问题,会让我们更容易找到解决方案。我们已经到达了这样一个点,我们需要将一个序列分成两半,一个由小的组成,另一个由大的组成。我们不需要保证这一半是相等的——只需要保证它们平均起来是相等的。一个简单的方法是选择其中一个值作为所谓的枢轴,并用它来划分其他值:所有比枢轴小的都在左半部分结束,而那些比枢轴大的在右半部分结束。清单 6-3 给出了分区和选择的一种可能实现。注意,这个版本的分区主要是可读的;练习 6-11 让你看看你是否能去掉一些开销。这里写道 select,它返回第 k 个最小的元素;如果您想拥有所有的 k 最小元素,您可以简单地重写它以返回lo而不是pi

清单 6-3 。分区和选择的简单实现

def partition(seq):
    pi, seq = seq[0], seq[1:]                   # Pick and remove the pivot
    lo = [x for x in seq if x <= pi]            # All the small elements
    hi = [x for x in seq if x > pi]             # All the large ones
    return lo, pi, hi                           # pi is "in the right place"

def select(seq, k):
    lo, pi, hi = partition(seq)                 # [<= pi], pi, [>pi]
    m = len(lo)
    if m == k: return pi                        # We found the kth smallest
    elif m < k:                                 # Too far to the left
        return select(hi, k-m-1)                # Remember to adjust k
    else:                                       # Too far to the right
        return select(lo, k)                    # Just use original k here

线性时间选择,保证!

本节实现的选择算法被称为随机选择(尽管随机版本通常比这里更随机地选择枢轴;参见练习 6-13)。它允许您在线性的预期的时间内进行选择(例如,找到中间值),但是如果在每一步中枢选择都很糟糕,那么您最终会遇到握手循环(线性工作,但是大小只减少 1),从而导致二次运行时间。虽然这种极端的结果在实践中不太可能发生(尽管,再次参见练习 6-13),但你事实上也可以在最坏的情况下避免它。

事实证明,保证支点在序列中只占很小的百分比(也就是说,不在任何一端,或者距离它恒定的步数)就足以保证运行时间是线性的。1973 年,一群算法专家(Blum、Floyd、Pratt、Rivest 和 Tarjan)提出了一个版本的算法,给出了这种保证。

算法有点复杂,但核心思想足够简单:首先将序列分成五个一组,或者其他一些小常数。例如,使用简单的排序算法,找出每个中值。到目前为止,我们只使用了线性时间。现在,递归地使用线性选择算法,在这些中间值中找到中间值*。这是可行的,因为中间值的数量小于原始序列的大小——这仍然有点令人费解。得到的值是一个保证足够好以避免退化递归的枢轴—在您的选择中使用它作为枢轴。*

换句话说,该算法以两种方式递归使用:第一,在中间值序列上,找到一个好的枢轴,第二,在原始序列上,使用这个枢轴。

由于理论上的原因,了解这种算法是很重要的,因为它意味着选择可以在有保证的线性时间内完成,但你可能永远不会在实践中使用它。

对半排序

最后,我们到达了与分治策略最相关的主题:排序。我不打算深入研究这个问题,因为 Python 已经有了有史以来最好的排序算法之一(参见本节后面关于 timsort 的“黑盒”侧栏),并且它的实现非常高效。事实上,list.sort是如此的高效,你可能会认为它是替代其他渐近线稍微好一点的算法的第一选择(例如,对于选择)。尽管如此,本节中的排序算法是最著名的算法之一,所以您应该了解它们是如何工作的。此外,它们是分而治之用于设计算法的一个很好的例子。

我们先来考虑算法设计的名人之一:C. A. R. Hoare 的快速排序。它与上一节的选择算法密切相关,这也是由于 Hoare(有时也被称为快速选择)。扩展很简单:如果 quickselect 表示带有修剪的遍历——在递归树中找到一条向下到第 k 个最小元素的路径——那么 quicksort 表示完全遍历,这意味着每 k 找到一个的解决方案。哪个是最小的元素?第二小?诸如此类。通过将它们都放入它们的位置,序列被排序。清单 6-4 显示了快速排序的一个版本。

清单 6-4 。快速排序

def quicksort(seq):
    if len(seq) <= 1: return seq                # Base case
    lo, pi, hi = partition(seq)                 # pi is in its place
    return quicksort(lo) + [pi] + quicksort(hi) # Sort lo and hi separately

正如你所看到的,算法很简单,只要你有分区。(练习 6-11 和 6-12 要求你重写快速排序和分区,以产生一个就地排序算法。)首先,它将序列分成我们知道必须在pi左边的序列和必须在右边的序列。然后这两半被递归排序(通过归纳假设是正确的)。将两部分连接起来,枢轴在中间,保证会产生一个排序的序列。因为我们不能保证分区会适当地平衡递归,我们只知道快速排序在平均 ?? 的情况下是对数线性的——在最坏的情况下是二次的。 6

Quicksort 是分而治之算法的一个例子,它在递归调用的之前的做它的主要工作,在中分割它的数据(使用分区)。组合部分比较琐碎。不过,我们可以反过来做:简单地将我们的数据一分为二,保证一个平衡的递归(和一个不错的最坏情况运行时间),然后努力合并,或者说合并结果。这正是合并排序所做的。就像我们从本章开始的天际线算法从插入单个建筑到合并两个天际线,合并排序从在排序序列中插入单个元素(插入排序)到合并两个排序序列。

你已经在第三章 ( 清单 3-2 )中看到了合并排序的代码,但是我在这里再重复一遍,加上一些注释(清单 6-5 )。

清单 6-5 。合并排序

def mergesort(seq):
    mid = len(seq)//2                           # Midpoint for division
    lft, rgt = seq[:mid], seq[mid:]
    if len(lft) > 1: lft = mergesort(lft)       # Sort by halves
    if len(rgt) > 1: rgt = mergesort(rgt)
    res = []
    while lft and rgt:                          # Neither half is empty
        if lft[-1] >=rgt[-1]:                   # lft has greatest last value
            res.append(lft.pop())               # Append it
        else:                                   # rgt has greatest last value
            res.append(rgt.pop())               # Append it
    res.reverse()                               # Result is backward
    return (lft or rgt) + res                   # Also add the remainder

理解这是如何工作的现在应该比在第三章中更容易一点。注意,编写合并部分是为了说明这里发生了什么。如果您在 Python 中实际使用合并排序(或类似的算法),您可能会使用heapq.merge来进行合并。

黑盒:TIMSORT

隐藏在list.sort中的算法是由 Tim Peters 发明(并实现)的,他是 Python 社区中的知名人士之一。 7 该算法被恰当地命名为 timsort ,取代了早期的算法,该算法进行了大量调整以处理特殊情况,如升序和降序值段等。在 timsort 中,这些情况由通用机制处理,因此性能仍然存在(在某些情况下,性能有了很大提高),但算法更干净、更简单。算法还是有点太复杂,这里就不详细解释了;我会试着给你一个快速的概述。更多详情,请看出处。 8

Timsort 是归并排序的近亲。这是一个就地算法,因为它合并段并将结果留在原始数组中(尽管在合并期间它使用了一些辅助内存)。然而,它不是简单地将数组对半排序,然后合并它们,而是从头开始,寻找已经排序的段*(可能相反),称为运行。在随机数组中,不会有很多,但在许多种真实数据中,可能会有很多——这使算法明显优于普通合并排序和最好情况下的线性运行时间(这涵盖了除了简单获得已经排序的序列之外的许多情况)。*

*当 timsort 遍历序列,识别游程并将它们的边界推送到堆栈上时,它使用一些启发式方法来决定何时合并哪些游程。这种想法是为了避免合并不平衡,这种不平衡会给你一个二次运行时间,同时仍然利用数据中的结构(即运行)。首先,任何真正短的游程都被人为地扩展和排序(使用稳定的插入排序)。第二,为栈上最顶端的三个游程维护以下不变量:ABC(其中A在顶端):len(A) > len(B) + len(C)len(B) > len(C)。如果违反了第一个不变量,则将AC中较小的一个与B合并,结果替换堆栈中合并的游程。第二个不变量可能仍然不成立,并且合并继续,直到两个不变量都成立。

该算法还使用了一些其他技巧,以获得尽可能快的速度。如果你感兴趣的话,我建议你查看一下来源。如果你不想读 C 代码,你也可以看看 timsort 的纯 Python 版本,它是 PyPy 项目的一部分。 10 他们的实现有极好的注释,写得很清楚。(PyPy 项目在附录 A 中讨论。)

我们排序能有多快?

关于排序的一个重要结果是,合并排序等分治算法是最优;对于任意值(我们可以计算出哪个更大),在最坏的情况下,不可能比ω(nLGn)做得更好。一个重要的例子是当我们对任意实数进行排序时。 11

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 计数排序及其亲属(在第四章中讨论)似乎打破了这一规则。请注意,我们不能对任意值进行排序——我们需要能够计算出现的次数,这意味着对象必须是可散列的,并且我们需要能够在线性时间内对值范围进行迭代。

我们是怎么知道的?道理其实挺简单的。第一个观点:因为值是任意的,我们假设我们只能计算出其中一个是否大于另一个,所以每个对象的比较都可以归结为是/否的问题。第二个洞见: n 元素的排序数为 n !我们要找的正是其中之一。那会给我们带来什么?我们又回到了“想象一个粒子”,或者,在这种情况下,“想象一个排列。”这意味着我们最多只能使用ω(LGn!)是/否问题(比较),以获得正确的排列(即,对数字进行排序)。而且刚好 lg n !渐近等价于 n lg n12 换句话说,最坏情况下的运行时间是ω(LGn!)=ω(nLGn)。

你说,我们如何达到这种等价?最简单的方法就是只使用斯特林近似 ,它表示 n !就是θ(n??n??)。取对数,鲍勃就是你的叔叔。 13 现在,我们推导出最坏情况的界限;使用信息论(我不会在这里深入讨论),事实上,有可能表明这个界限在平均情况下也成立。换句话说,在一个非常真实的意义上*,*除非我们对数据的取值范围或分布有实质性的了解,否则对数线性是我们能做的最好的事情。

还有三个例子

在用稍微高级(可选)的部分结束本章之前,这里有三个例子。前两个涉及计算几何(分治策略经常有用),而最后一个是一个相对简单的数列问题(有一些有趣的变化)。我只是勾画了解决方案,因为重点主要是为了说明设计原则。

最接近对

问题:你在平面上有一组点,你想找到彼此最接近的两个点。第一个浮现在脑海中的想法可能是使用蛮力:对于每一个点,检查所有其他的点,或者至少是我们还没有看到的点。当然,根据握手和,这是一个二次算法。通过分而治之,我们可以得到对数线性。

这是一个相当有趣的问题,所以如果你喜欢解谜,在阅读我的解释之前,你可能想试着自己解决它。您应该使用分而治之(并且得到的算法是对数线性的)的事实是一个强烈的暗示,但是解决方案决不是显而易见的。

该算法的结构几乎直接遵循(类似合并排序的)对数线性分治模式:我们将把点分成两个子集,递归地找到每个子集中最近的一对,然后在线性时间内合并结果。借助归纳/递归(和分治模式)的力量,我们现在已经将问题简化为这种合并操作。但是在发挥我们的创造力之前,我们可以再剥离一点:合并的结果必须是(1)左边最近的一对,(2)右边最近的一对,或者(3)两边各有一个点组成的一对。换句话说,我们需要做的是找到“跨越”分割线的最接近的一对。在这样做的时候,我们也有一个涉及到的距离的上限(从左侧和右侧最接近的对的最小值)。

深入问题的本质后,让我们看看事情会变得多糟糕。让我们假设,目前,我们已经按照它们的 y 坐标对中间区域(宽度为 2 d )中的所有点进行了排序。然后我们想按顺序浏览它们,考虑其他点,看看我们是否能找到比 d (目前发现的最小距离)更近的点。对于每一点,我们必须考虑多少其他的“邻居”?

这就是解决方案的关键之处:在中线的任一侧,我们知道所有点至少相距 d 的距离。因为我们要寻找的是一对在相距最的距离,横跨中线,我们需要考虑的只是在任何时候高度 d (和宽度 2 d )的垂直切片。这个区域能容纳多少个点?

图 6-7 说明了这种情况。我们对左右之间的距离没有下限,所以在最坏的情况下,我们可能在中间线上有重合点(突出显示)。除此之外,很容易证明,在一个 d × d 正方形内最多可以容纳四个最小距离为 d 的点,我们在正方形的两边都有;参见练习 6-15。这意味着在这样的切片中,我们总共最多需要考虑八个点,这意味着我们当前的点最多需要与其下七个邻居进行比较。(其实考虑一下下邻居就够了;参见练习 6-16。)

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6-7 。最坏的情况:在中间区域的垂直切片中有八个点。切片的大小为 d×2d,两个中间点(高亮显示)代表一对重合点

我们完成了;唯一剩下的问题是按照 x -和y-坐标排序。我们需要 x 排序能够在每一步将问题分成两半,我们需要 y 排序在合并时进行线性遍历。我们可以保留两个数组,每个数组对应一个排序顺序。我们将在 x 数组上做递归除法,所以这很简单。对 y 的处理不是很直接,但仍然很简单:当用 x 划分数据集时,我们基于 x 坐标划分 y 数组。当组合数据时,我们合并它们,就像在合并排序中一样,从而在只使用线性时间的同时保持排序。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 注意为了让算法工作,我们从每个递归调用中返回点的整个子集,排序。必须在副本上过滤离中线太远的点。

你可以把这看作是加强归纳假设的一种方式(正如在第四章第一节中所讨论的),以获得期望的运行时间:我们不仅仅假设我们可以在更小的点集中找到最近的点,我们假设我们可以把点重新排序*。*

*凸包

这里还有另一个几何问题:想象一下把 n 个钉子钉在一块木板上,然后用橡皮筋捆住它们;橡皮筋的形状是钉子代表的点的所谓凸包。它是包含这些点的最小凸 14 区域,即在这些点的“最外层”之间有线的凸多边形。参见图 6-8 中的示例。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6-8 。点集及其凸包

到目前为止,我肯定你在怀疑我们将如何解决这个问题:沿着 x 轴将点集分成相等的两半,并递归地求解它们。唯一剩下的部分是两个解的线性时间组合。图 6-9 提示我们需要什么:我们必须找到上下公切线。(它们是切线基本上意味着它们与前面和后面的线段形成的角度应该向内弯曲。)

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6-9 。通过寻找上下公切线来组合两个较小的凸包(虚线)

在不涉及实现细节的情况下,假设您可以检查一条线是否是任一半的上切线。(下半部分的工作方式类似。)然后你可以从半的最右边的点和半的最左边的点开始。只要您的点之间的线不是左边部分的上切线,您就沿着子壳逆时针移动到下一个点。然后你对右半边做同样的动作。您可能需要多次这样做。顶部固定后,对下部切线重复该过程。最后,删除切线之间的线段,就完成了。

多快能找到一个凸包?

各个击破的方案运行时间为 O ( n lg n )。寻找凸包的算法有很多,有些渐进地更快,运行时间低至 O ( n lg h ),其中 h 是凸包上的点数。最糟糕的情况当然是所有物体都落在船体上,我们又回到θ(nLGn)。事实上,在最坏的情况下,这可能是最好的时机——但是我们怎么知道呢?

我们可以使用第四章中的想法,通过减少来显示硬度。从本章前面的讨论中我们已经知道,在最坏的情况下,对实数进行排序是ω(nLGn)。这与您使用的算法无关;你简直不能做得更好。这不可能。

现在,观察排序可以简化为凸包问题。如果你想对 n 个实数进行排序,你只需将这些数作为坐标 x ,并添加坐标 y ,使它们位于一条平缓的曲线上。例如,你可以有 y = x 2 。如果你为这个点集找到一个凸包,那么这些值将按照排序的顺序排列在凸包上,你可以通过遍历它的边来找到排序。这种减少本身只需要线性时间。

想象一下,你有一个比对数线性更好的凸包算法。通过使用线性归约,您随后会有一个比对数线性更好的排序算法。但那是不可能的!换句话说,因为存在从排序到寻找凸壳的简单(这里是线性)简化,所以后一个问题至少和前一个问题一样困难。因此…对数线性是我们能做的最好的。

最大切片

这里是最后一个例子:你有一个包含实数的序列A,你想找到一个片(或段)A[i:j],以便sum(A[i:j])被最大化。你不能只选择整个序列,因为其中也可能有负数。 15 这个问题有时会出现在股票交易的情境中——序列中包含了股票价格的变化,你想找到能给你带来最大利润的区间。当然,这个演示有点缺陷,因为它要求你事先知道股票的所有运动。

一个显而易见的解决方案如下所示(where n=len(A)):

result = max((A[i:j] for i in range(n) for j in range(i+1,n+1)), key=sum)

生成器表达式中的两个for子句简单地遍历每个合法的起点和终点,然后我们取最大值,使用A[i:j]的总和作为标准(key)。这个解决方案可能因其简洁而获得“聪明”的分数,但它并不真的那么聪明。这是一个很幼稚的蛮力解法,它的运行时间是立方(也就是θ(n3)!换句话说,是真的烂。

我们如何避免这两个显式的for循环可能并不明显,但是让我们从避免隐藏在 sum 中的循环开始。一种方法是在一次迭代中考虑所有长度为 k 的区间,然后转移到 k +1,依此类推。这仍然会给我们一个二次方数量的间隔来检查,但是我们可以使用一个技巧来使扫描成本线性:我们正常地计算第一个间隔的总和,但是每次间隔被向右移动一个位置,我们简单地减去现在落在它之外的元素,并且我们添加新元素:

best = A[0]
for size in range(1,n+1):
    cur = sum(A[:size])
    for i in range(n-size):
        cur += A[i+size] - A[i]
        best = max(best, cur)

这也好不了多少,但至少现在我们减少到了运行时间的二次方。尽管如此,我们没有理由放弃这里。

让我们看看分而治之能给我们带来什么。当你知道要寻找什么时,算法——或者至少是一个粗略的轮廓——几乎是自己写出来的:将序列一分为二,在每一半中找到最大的切片(递归地),然后查看是否有更大的切片横跨中间(如最近点的例子)。换句话说,唯一需要创造性解决问题的是找到跨越中间的最大部分。我们可以进一步减少,该切片将必然包括从中间延伸到左侧的最大切片和从中间延伸到右侧的最大切片。我们可以在线性时间内,通过简单地从中间向任一方向遍历和求和,分别找到这些。

因此,我们有了这个问题的对数线性解。不过,在完全离开之前,我要指出这里的,事实上,也是一个线性解;参见练习 6-18。

真正的分工:多重处理

分治设计方法的目的是平衡工作负载,使每个递归调用花费尽可能少的时间。不过,你可以更进一步,将工作分配给个独立的处理器(或内核)。如果您有大量的处理器可以使用,那么理论上,您可以做一些漂亮的事情,比如在对数时间内找到一个序列的最大值或和。(你看怎么样?)

在一个更现实的场景中,您可能没有无限的处理器供您使用,但是如果您想利用现有处理器的能力,multiprocessing模块可以成为您的朋友。并行编程通常使用并行(操作系统)线程来完成。虽然 Python 有线程机制,但它不支持真正的并行执行。不过,你做的是使用并行进程,这在现代操作系统中非常有效。multiprocessing模块为您提供了一个接口,使处理并行进程看起来有点像线程。

树木平衡…以及平衡 16

如果我们将随机值插入二分搜索法树,平均来说,它将会非常平衡。然而,如果我们运气不好,我们可能最终得到一个完全不平衡的树,基本上是一个链表,就像图 6-1 中的那样。搜索树的大多数真实用途包括某种形式的平衡,即一组重新组织树的操作,以确保它是平衡的(当然,不破坏它的搜索树属性)。

有大量不同的树结构和平衡方法,但它们通常基于两个基本操作:

  • **节点分裂(和合并)。**节点允许有两个以上的子节点(和一个以上的键),在某些情况下,一个节点会变成过满。然后它被分成两个节点(可能会使它的父节点溢出)。
  • **节点旋转。**这里我们还是用二叉树,但是我们交换边。如果 xy 的父代,我们现在让 y 成为 x 的父代。为此, x 必须接管 y 的一个子节点。

这在理论上可能有点令人困惑,但是我会更详细地介绍一下,我相信您会看到它是如何工作的。让我们首先考虑一个叫做 2-3 树的结构。在普通二叉树中,每个节点最多可以有两个子节点,并且每个子节点都有一个键。不过,在 2-3 树中,我们允许一个节点有一个或两个键,最多有三个子节点。左子树中的任何内容现在都必须小于键中最小的子树,右子树中的任何内容都必须大于键中最大的子树,中间子树中的任何内容都必须介于两者之间。图 6-10 显示了一个 2-3 树的两种节点类型的例子。**

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6-10 。2-3 树中的节点类型

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 2-3-树是 B-树的一个特例,B-树构成了几乎所有数据库系统的基础,基于磁盘的树被用于地理信息系统和图像检索等不同的领域。重要的扩展是 B 树可以有成千上万个键(和子树),每个节点通常作为一个连续的块存储在磁盘上。使用大数据块的主要动机是最大限度地减少磁盘访问次数。

搜索一个 2-3 节点非常简单——只是一个带有修剪的递归遍历,就像普通的二叉查找树一样。但是插入需要一点额外的注意。就像在二叉查找树中一样,您首先搜索可以插入新值的适当叶。但是,在二叉查找树中,这将始终是一个 None 引用(即一个空的子节点),您可以将新节点“附加”为现有节点的子节点。但是,在 2-3 树中,您总是会尝试将新值添加到一个现有的叶子中。(但是,添加到树中的第一个值必然需要创建一个新节点;对任何树来说都一样。)如果节点中有空间(也就是说,它是一个 2 节点),您只需添加值。如果没有,你有三把钥匙要考虑(已经有两把和你的新钥匙)。

解决方案是分割节点,将三个值中间的移动到父节点。(如果你正在分裂根,你将不得不制造一个新的根。)如果现在已经满了,你就需要拆分和*,以此类推。这种分裂行为的重要结果是所有的叶子都在同一层,这意味着树是完全平衡的。*

现在,虽然节点分裂的概念相对容易理解,但现在让我们继续使用更简单的二叉树。你看,可以使用 2-3 树的思想,而不是真正的实现为 2-3 树。我们可以只用二进制节点来模拟整个事情!这样做有两个好处:第一,结构更简单、更一致;第二,你可以学习旋转(一般来说是一项重要的技术),而不必担心全新的平衡方案!

我将向你们展示的“模拟”被称为 AA 树,以它的创造者阿恩·安德森命名。在众多基于旋转的平衡方案中,AA 树确实以其简单性脱颖而出(尽管如果你是这类事物的新手,还有很多东西需要你去琢磨)。AA 树是一棵二叉树,所以我们需要看看如何模拟 3 节点来达到平衡。你可以在图 6-11 中看到这是如何工作的。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6-11 。AA 树中的两个模拟 3 节点(突出显示)。注意,左边的是反的,必须修理

这个图同时向你展示了几件事情。首先,您将了解如何模拟一个 3 节点:您只需将两个节点连接起来,作为一个伪节点(如突出显示的)。第二,图中说明了的想法。每个节点被分配一个级别(一个数字),所有叶子的级别为 1。当我们假设两个节点形成一个 3 节点时,我们简单地给它们相同的级别,如图中的垂直位置所示。第三,3 节点“内部”的边(称为水平边)可以只指向右边。这意味着最左边的子图说明了一个非法的节点,必须使用一个右旋转进行修复:使 c 成为 d 的左子节点, d 成为 b 的右子节点,最后,使 d 的旧父节点成为 b 的父节点。转眼间。你得到了最右边的子图(这是有效的)。换句话说,中间子的边缘和水平边缘交换位置。这个操作叫做歪斜

还有另一种形式的非法情况可能发生,并且必须通过循环来解决:过满的伪节点(即 4 节点)。这在图 6-12 中显示。这里我们有三个链接在同一层的节点( cef )。我们想要模拟一个拆分,其中中间的键( e )将被向上移动到父键( a ),就像在 2-3 树中一样。在这种情况下,只需旋转 ce ,使用左旋即可。这基本上与我们在图 6-11 中所做的正好相反。换句话说,我们将 c 的子指针从 e 下移至 d ,并将 e 的子指针从 d 上移至 c 。最后,我们将 a 的子指针从 c 移动到 e 。为了以后记住 ae 现在形成一个新的 3 节点,我们增加了 e 的等级(见图 6-12 )。这个操作叫做*(自然够了)。*

*外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6-12 。一个过满的伪节点,以及修复左旋转的结果(交换边(e,d)和(c,e)),以及使 e 成为一个的新子节点

就像在标准的不平衡二叉树中一样,将一个节点插入到 AA 树中;唯一的区别是您在之后执行一些清理工作(使用skewsplit)。完整的代码可以在清单 6-6 中找到。如您所见,清理(一个对skew的调用和一个对split的调用)是作为递归中回溯的一部分执行的——因此节点在回溯到根的路径上被修复。这到底是怎么回事?

沿着路径往下的操作实际上只能做一件影响我们的事情:它们可以将另一个节点放到“我们的”当前模拟节点中。在叶级别,每当我们添加一个节点时都会发生这种情况,因为它们都有 1 级。如果当前节点在树中处于更高的位置,我们可以在当前(模拟的)节点中获得另一个节点,如果一个节点在拆分过程中被上移的话。无论哪种方式,现在突然出现在我们级别上的这个节点可以是左子节点或右子节点。如果是一个的孩子,我们倾斜(做一个右旋转),我们已经摆脱了这个问题。如果是的孩子,一开始就不是问题。然而,如果它是一个右,我们有一个过满的节点,所以我们做一个分割(左旋转)并将我们模拟的 4 节点的中间节点提升到父节点的级别。

这很难用语言来描述——我希望代码足够清晰,让你明白发生了什么。(不过,这可能需要一些时间和令人挠头的事情。)

清单 6-6 。二叉查找树,现在有了 AA 树平衡

class Node:
    lft = None
    rgt = None
    lvl = 1                                     # We've added a level...
    def __init__(self, key, val):
        self.key = key
        self.val = val

def skew(node):                                 # Basically a right rotation
    if None in [node, node.lft]: return node    # No need for a skew
    if node.lft.lvl != node.lvl: return node    # Still no need
    lft = node.lft                              # The 3 steps of the rotation
    node.lft = lft.rgt
    lft.rgt = node
    return lft                                  # Switch pointer from parent

def split(node):                                # Left rotation & level incr.
    if None in [node, node.rgt, node.rgt.rgt]: return node
    if node.rgt.rgt.lvl != node.lvl: return node
    rgt = node.rgt
    node.rgt = rgt.lft
    rgt.lft = node
    rgt.lvl += 1                                # This has moved up
    return rgt                                  # This should be pointed to

def insert(node, key, val):
    if node is None: return Node(key, val)
    if node.key == key: node.val = val
    elif key < node.key:
        node.lft = insert(node.lft, key, val)
    else:
        node.rgt = insert(node.rgt, key, val)
    node = skew(node)                           # In case it's backward
    node = split(node)                          # In case it's overfull
    return node

我们能确定 AA 树是平衡的吗?事实上我们可以,因为它忠实地模拟了 2-3 树(用 level 属性表示 2-3 树中的实际树级)。在模拟的 3 节点内有一个额外的边的事实不会超过任何搜索路径的两倍,所以渐近搜索时间仍然是对数的。

黑盒:二进制堆、堆质量和堆排序

一个优先级队列 是在第五章的中讨论的 LIFO 和 FIFO 队列的概括。不是只根据添加的项目来排序,而是每个项目都有一个优先级,并且您总是检索剩余的优先级最低的项目。(您也可以使用最大优先级,但是通常不能在同一个结构中同时使用这两种优先级。)这种功能作为几个算法的组成部分是很重要的,比如 Prim 的,用于寻找最小生成树(第七章),或者 Dijkstra 的,用于寻找最短路径(第九章)。实现优先级队列的方法有很多,但可能最常用的数据结构是二进制堆。(还有其他种类的堆,但是非限定术语通常指的是二进制堆。)

二进制堆是完整的二叉树。这意味着它们尽可能地平衡,树的每一层都被填满,除了(可能)最低的一层,它尽可能从左边填满。不过,可以说它们结构中最重要的方面是所谓的堆属性:每个父节点的值都小于两个子节点的值。(这适用于最小堆;对于最大堆,每个父堆都更大。)因此,根在堆中具有最小的值。该属性类似于搜索树,但又不完全相同,事实证明,在不牺牲树的平衡的情况下,堆属性更容易维护。您永远不会通过拆分或旋转堆中的节点来修改树的结构。您只需要交换父节点和子节点来恢复堆属性。例如,要“修复”一个子树的根(它太大了),只需将其与其最小的子树交换,然后递归地修复该子树(如果需要的话)。

heapq模块包含了一个有效的堆实现,它使用一个通用的“编码”在列表中表示它的堆:如果a是一个堆,那么a[i]的子代可以在a[2*i+1]a[2*i+2]中找到。这意味着根(最小的元素)总是在a[0]中找到。您可以使用heappushheappop函数从头开始构建一个堆。你也可以从一个包含很多值的列表开始,你想把它做成一个堆。在这种情况下,您可以使用heapify功能。 18 它基本上修复了每一个子树根,从右下方开始,向左上方移动。(事实上,通过跳过叶子,它只需要在数组的左半部分工作。)得到的运行时间是线性的(见练习 6-9)。如果你的列表已经排序,那么它已经是一个有效的堆了,所以你可以不去管它。

下面是一个逐块构建堆的示例:

>>> from heapq import heappush, heappop
>>> from random import randrange
>>> Q = []
>>> for i in range(10):
...         heappush(Q, randrange(100))
...
>>> Q
[15, 20, 56, 21, 62, 87, 67, 74, 50, 74]
>>>  [heappop(Q) for i in range(10)]
[15, 20, 21, 50, 56, 62, 67, 74, 74, 87]

就像bisect一样,heapq模块是用 C 实现的,但是它做了一个普通的 Python 模块。例如,下面是一个函数的代码(来自 Python 2.3),该函数将一个对象向下移动,直到它比它的两个子对象都小(再次引用我的注释):

def sift_up(heap, startpos, pos):
newitem = heap[pos]                         # The item we're sifting up
while pos > startpos:                       # Don't go beyond the root
parentpos = (pos - 1) >>1               # The same as (pos - 1) // 2
parent = heap[parentpos]                # Who's your daddy?
if parent <= newitem: break             # Valid parent found
heap[pos] = parent                      # Otherwise: copy parent down
pos = parentpos                         # Next candidate position
heap[pos] = newitem                         # Place the item in its spot

注意,原来的函数被称为_siftdown,因为它在列表中向下筛选值*。不过,我更愿意把它看作是在堆的隐式树结构中向上筛选*。还要注意,就像bisect_right一样,实现使用了循环而不是递归。**

**除了heappop,还有heapreplace,它会弹出最小的项,同时插入一个新元素,比一个heappop后面跟着一个heappush要高效一点。heappop操作返回根(第一个元素)。为了保持堆的形状,最后一个项目被移动到根位置,并从那里向下交换(在每个步骤中,与其最小的子级交换),直到它小于它的两个子级。heappush操作正好相反:新元素被添加到列表中,并与其父元素重复交换,直到它大于其父元素。这两个操作都是对数的(也是在最坏的情况下,因为堆保证是平衡的)。

最后,该模块(从 2.6 版开始)有实用函数mergenlargestnsmallest,分别用于合并排序后的输入和查找 iterable 中的 n 个最大和最小的项。与模块中的其他函数不同,后两个函数采用与list.sort相同的key参数。您可以用 DSU 模式在其他函数中模拟这一点,如bisect侧栏中所述。

尽管在 Python 中您可能永远不会以这种方式使用它们,但是堆操作也可以形成一种简单、高效、渐进最优的排序算法,称为堆排序 。它通常使用 max-heap 实现,首先对序列执行heapify,然后重复弹出根(如在heappop中),最后将它放入现在为空的最后一个槽。渐渐地,随着堆的缩小,原始数组从右边开始填充最大的元素,第二大的元素,依此类推。换句话说,堆排序基本上是选择排序,其中堆用于实现选择。因为初始化是线性的,并且每个 n 选择是对数的,所以运行时间是对数线性的,即最优的。

摘要

分而治之的算法设计策略包括将一个问题分解成大小大致相等的子问题,求解子问题(通常通过递归),然后组合结果。这很有用的主要原因是工作负载是平衡的,通常从二次到对数线性运行时间。这种行为的重要例子包括合并排序和快速排序,以及寻找最接近的对或点集的凸包的算法。在某些情况下(例如当搜索排序序列或选择中间元素时),除了一个子问题之外,所有的子问题都可以被修剪,从而在子问题图中产生从根到叶的遍历,产生甚至更有效的算法。

子问题结构也可以显式表示,就像在二分搜索法树中一样。搜索树中的每个节点都大于其左子树中的后代,但小于其右子树中的后代。这意味着二分搜索法可以被实现为从根开始的遍历。平均而言,简单地随意插入随机值将产生足够平衡的树(导致对数搜索时间),但也有可能使用节点分裂或旋转来平衡树,以保证在最坏情况下的对数运行时间。

如果你好奇的话…

如果你喜欢二分法,你应该查一下插值搜索,对于均匀分布的数据,它的平均用例运行时间为 O (lg lg n )。为了实现除排序序列、搜索树和哈希表之外的集合(即有效的成员检查),你可以看看 Bloom filters 。如果你喜欢搜索树和相关的结构,那里有很多。你可以找到大量不同的平衡机制(红黑树AVL 树八字树),其中一些是随机的(树状图,还有一些只是抽象地表示树(跳过列表)。还有专门的树结构的整个家族,用于索引多维坐标(所谓的空间访问方法)和距离(度量访问方法)。其他要检查的树结构有间隔树四叉树八叉树

练习

6-1.编写一个 Python 程序,实现天际线问题的解决方案。

6-2.在每个递归步骤中,二分搜索法将序列分成大约相等的两部分。考虑三元搜索,将序列分成三个部分。它的渐近复杂度是多少?关于二进制和三进制搜索中的比较次数,你能说些什么?

6-3.与二分搜索法树相比,多路搜索树的意义是什么?

6-4.如何在线性时间内按排序顺序从二叉查找树中提取所有键?

6-5.如何从二叉查找树中删除节点?

6-6.假设您将 n 个随机值插入一个最初为空的二叉查找树。最左边(也就是最小的)节点的平均深度是多少?

6-7.在最小堆中,当向下移动一个大节点时,你总是与最小的孩子交换位置。为什么这很重要?

6-8.堆编码是如何(或为什么)工作的?

6-9.为什么建堆的操作是线性的?

6-10.为什么不用一个平衡的二叉查找树来代替堆呢?

6-11.编写一个 partition 版本,将元素就地分区(也就是说,按照原始顺序移动它们)。你能让它比清单 6-3 中的那个更快吗?

6-12.使用练习 6-11 中的就地分区,重写快速排序以就地排序元素。

6-13.比如说,您使用random.choice重写了 select 以选择枢轴。那会有什么不同呢?(注意,同样的策略可以用来创建一个随机快速排序。)

6-14.实现一个使用关键函数的 quicksort 版本,就像list.sort一样。

6-15.证明边长为 d 的正方形最多可以容纳四个点,这四个点至少相距 d 的距离。

6-16.在最接近对问题的分治解决方案中,您最多可以检查中间区域点中的接下来的七个点,这些点按 y 坐标排序。展示如何轻松地将这个数字减少到 5。

6-17.元素唯一性问题是确定一个序列的所有元素是否唯一。这个问题在实数的最坏情况下有一个被证明的对数线性下界。表明这意味着最接近的配对问题在最坏的情况下也具有对数线性下界。

6-18.你如何在线性时间内解决最大切片问题?

参考

安德森(1993 年)。平衡搜索树变得简单。在算法和数据结构研讨会会议录 (WADS),第 60-71 页。

拜耳公司(1971 年)。虚拟内存的二进制 B 树。在 ACM SIGFIDET 研讨会关于数据描述、访问和控制的会议记录中,第 219-235 页。

布卢姆,m .,弗洛伊德,R. W .,普拉特,v .,里维斯特,R. L .,和塔尔詹,R. E. (1973)。选择的时间限制。计算机与系统科学学报,7(4):448-461。

de Berg,m .,Cheong,o .,van Kreveld,m .,和 Overmars,M. (2008)。计算几何:算法与应用,第三版。斯普林格。


1 注意,一些作者使用征服项作为递归的基本情况,产生稍微不同的排序:划分、征服和组合。

2Udi Manber 在他的算法简介中描述的(参见第四章中的“参考文献”)。

3 例如,在 skyline 问题中,您可能希望将基本 case 元素( LHR )拆分成两对( LH )和( RH ),因此combine函数可以构建一个点序列。

4 其实,更灵活的说法未必完全正确。有许多对象(如复数)可以被散列,但不能比较大小。

5 在统计学中,中位数也定义为偶数长度的序列。然后是两个中间元素的平均值。这不是我们担心的问题。

6 理论上,我们可以使用 select 的保证线性版本来找到中间值,并以此为支点。不过,这在实践中不太可能发生。

7 Timsort 实际上也是 Java SE 7 中使用的,用于数组排序。

8 参见例如源代码中的文件listsort.txt(或者在线,http://svn.python.org/projects/python/ trunk/Objects/listsort.txt)。

9 你可以在http://svn.python.org/projects/python/trunk/Objects/listobject.c找到实际的 C 代码。

10https://bitbucket.org/pypy/pypy/src/default/rpython/rlib/listsort.py

当然,实数通常并不那么随意。只要你的数字使用固定的位数,你就可以使用基数排序(在第四章中有提到)在线性时间内对数值进行排序。

12 我觉得太酷了,想在句子后面加个感叹号…但是考虑到主题,我想这可能有点令人困惑。

13 实际上,这种近似在本质上并不是渐近的。如果你想知道细节,你可以在任何好的数学参考书中找到。

14

15 我仍然假设我们想要一个非空的间隔。如果结果是一个负数,你可以用一个空的区间来代替。

这一节有点难,但对于理解这本书的其余部分并不重要。随意浏览,甚至完全跳过。不过,在本节的后面,您可能希望阅读关于二进制堆、 heapq 和 heapsort 的“黑盒”侧栏。

在某种程度上,AA 树是 BB 树的一个版本,或者是由鲁道夫·拜尔在 1971 年提出的作为 2-3 树的二进制表示的二进制 B 树。

18 将这个操作称为构建堆并为修复单个节点的操作保留名称是很常见的。因此, build-heap 在除了叶子之外的所有节点上运行 heapify 。*****

七、贪婪是好事?证明一下!

伙计,这不是够不够的问题。

——戈登·盖柯,华尔街

所谓的贪婪算法是短视的,因为它们孤立地做出每个选择,做此时此地看起来好的事情。在许多方面,急切的不耐烦的可能是它们更好的名字,因为其他算法通常也试图找到尽可能好的答案;只是贪婪的人拿走了此刻能得到的,而不是担心未来。设计和实现一个贪婪的算法通常很容易,当他们工作时,往往是非常高效的。主要问题是展示他们做了工作——如果他们真的做了。这就是“证明它”的原因章节标题的一部分。

本章讨论给出正确(最优)答案的贪婪算法;我将在第十一章中重温设计策略,在那里我将把这个要求放宽到“几乎正确(最优)”。

一步一步保持安全

贪婪算法的常见设置是一系列选择(正如您将看到的动态编程)。贪婪包括根据当地信息做出每个选择,做看起来最有希望的事情,而不考虑背景或未来的后果,然后,一旦做出选择,就永远不要回头。如果这能带来一个解决方案,我们必须确保每个选择都是安全的——不会破坏我们未来的前景。您将会看到许多关于我们如何确保这种安全性的例子(或者说,我们如何证明一个算法是安全的),但是让我们从“一步一步”的部分开始。

用贪婪算法解决的这类问题通常会逐步建立一个解决方案。它有一组“解决方案片段”,可以组合成部分的、最终完整的解决方案。这些部分可以以复杂的方式组合在一起;可能有许多方法来组合它们,并且一旦我们使用了某些其他的,一些部分可能不再适合。你可以把这想象成一个有许多可能解决方案的拼图游戏(见图 7-1 )。拼图图片是空白的,拼图块比较规整,可以在几个位置组合使用。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 7-1 。部分解决方案,和一些贪婪地排序的块(从左到右考虑),下一个贪婪的选择被突出显示

现在给每个拼图块添加一个值。这是您将该特定部分融入完整解决方案所获得的奖励金额。接下来的目标是找到一种方式来铺设拼图,让你获得最高的总价值——也就是说,我们有一个优化问题。一般来说,解决这样的组合优化问题根本不是一件简单的任务。您可能需要考虑放置这些片段的每一种可能方式,从而产生指数级(可能是阶乘)运行时间。

假设你从顶部开始一行一行地填充拼图,那么你总是知道下一块拼图应该放在哪里。在这种情况下,贪婪的方法非常简单,至少对于选择要使用的棋子来说是如此。只需按价值递减排序,逐个考虑。如果一块不合适,你就扔掉它。如果合适,你就用它,不用考虑以后的作品。

即使不考虑正确性(或最优性)的问题,很明显这种算法需要几样东西才能运行:

  • 一组候选元素,或个片段,附带一些
  • 检查部分解决方案是否有效或可行的一种方式

因此,部分解决方案被构建为解决方案片段的集合。我们依次检查每一部分,从最有价值的部分开始,然后添加每一部分,得到一个更大的、仍然有效的解决方案。当然,还可以添加一些微妙的东西(例如,总值不必是元素值的总和,我们可能想知道什么时候完成,而不必穷尽元素集),但这只是一个原型描述。

这类问题的一个简单例子是找零——试图用尽可能少的硬币和钞票凑成一个给定的总数。比如说,有人欠你 43.68 美元,给你一张百元大钞。你是做什么的?这个问题之所以是一个很好的例子,是因为我们都本能地知道在这里做什么是正确的 1 :我们从最大的面额开始,然后一路向下。每一张钞票或硬币都是一块拼图,我们正试图准确地覆盖 56.32 美元这个数字。我们可以考虑对一堆钞票和硬币进行分类,而不是对它们进行分类,因为每种钞票和硬币都有很多。我们按降序对这些堆栈进行排序,并开始分发最大面额的,如以下代码所示(使用美分,以避免浮点问题):

>>> denom = [10000, 5000, 2000, 1000, 500, 200, 100, 50, 25, 10, 5, 1]
>>> owed = 5632
>>> payed = []
>>> for d in denom:
...     while owed >=d:
...         owed -= d
...         payed.append(d)
...
>>> sum(payed)
5632
>>> payed
[5000, 500, 100, 25, 5, 1, 1]

大多数人可能很少怀疑这是可行的;这似乎是显而易见的事情。事实上,它是可行的,但是这个解决方案在某些方面非常脆弱。即使稍微改变可用面额的列表也会破坏它(见练习 7-1)。计算出贪婪算法将对哪些货币起作用并不简单(尽管已经有了算法),而且一般问题本身还没有解决。事实上,它与背包问题密切相关,背包问题将在下一节讨论。

让我们转向一个不同类型的问题,与我们在第四章中处理的匹配相关。电影结束了(许多人认为电视剧明显更好),小组决定出去跳探戈,他们再次面临匹配问题。每对人都有一定的相容性,他们用数字表示,他们希望所有对的相容性之和尽可能高。同性舞伴在探戈中并不少见,所以我们不必局限于双方的情况——我们最终会遇到最大重量匹配问题。在这种情况下(或就此而言,在双边情况下),贪婪一般不会起作用。然而,由于某种奇怪的巧合,所有兼容数字恰好是两个的的不同幂。现在,发生了什么? 2

让我们首先考虑一下贪婪算法是什么样子,然后看看为什么它会产生最佳结果。我们将一点一点地构建一个解决方案——让每一部分都是所有可能的配对,而部分解决方案是一组配对。只有当每个人最多参与其中一对时,这样的部分解才是有效的。算法大致如下:

  1. 列出可能的配对,按兼容性降序排列。
  2. 从列表中选择第一个未使用的配对。
  3. 这对中有人已经被占用了吗?如果有,丢弃它;否则,使用它。
  4. 单子上还有别的对子吗?如果是,请转到 2。

正如您稍后将看到的,这与 Kruskal 的最小生成树算法非常相似(尽管认为不管边权重如何都有效)。这也是一个相当典型的贪婪算法。其正确性另当别论。使用不同的 2 的幂是一种欺骗,因为它会让几乎所有贪婪的算法都起作用;也就是说,只要你能得到一个有效的解,你就会得到一个最优的结果(见练习 7-3)。尽管这是欺骗,但它说明了这里的中心思想:做出贪婪的选择是安全的。使用剩余夫妇中最合适的一对将 永远】至少和其他选择一样好。 3

在接下来的几节中,我将向您展示一些众所周知的问题,这些问题可以使用贪婪算法来解决。对于每个算法,你会看到它是如何工作的,为什么贪婪是正确的。在本章快结束时,我将总结一些证明正确性的通用方法,你可以用它们来解决其他问题。

渴望的追求者和稳定的婚姻

事实上,有一个经典的匹配问题可以被贪婪地解决:稳定的婚姻问题 。这个想法是,一个群体中的每个人都有他或她想和谁结婚的偏好。我们希望看到每个人都结婚,我们希望婚姻稳定*,这意味着没有男人喜欢婚外也喜欢他的女人。(为了简单起见,我们在这里忽略同性婚姻和一夫多妻制。)*

大卫·盖尔和劳埃德·沙普利设计了一个简单的算法来解决这个问题。这种提法在性别上相当保守,但如果性别角色颠倒过来,肯定也行得通。该算法运行多个轮*,直到没有未订婚的男人。每一轮包括两个步骤:

  1. 每个没有订婚的男人都向他还没有邀请的女人中他最喜欢的一个求婚。
  2. 每个女人都(暂时)与她最喜欢的追求者订婚,并拒绝其他人。

这可以被视为贪婪,因为我们现在只考虑可用的最爱(男性和女性)。你可能会反对说,这只是有点贪婪,因为我们没有锁定目标,直接走向婚姻;如果有更感兴趣的求婚者出现,女性可以解除婚约。即便如此,一旦一个男人被拒绝,他就永远被拒绝了,这意味着我们保证了进步和二次最坏情况运行时间。

为了证明这是一个最优且正确的算法,我们需要知道每个人都会结婚,而且婚姻是稳定的。一旦一个女人订婚,她就保持订婚状态(尽管她可能会取代她的未婚夫)。我们不可能被一对未婚情侣困住,因为在某个时候,男方会向女方求婚,而女方会(暂时)接受他的求婚。

我们怎么知道婚姻是稳定的?假设斯佳丽和斯图尔特都结婚了,但不是彼此。有没有可能他们暗地里更喜欢对方而不是现在的配偶?不。如果是这样,斯图尔特早就向她求婚了。如果她接受了那个提议,她一定后来找到了她更喜欢的人;如果她拒绝了,她已经有了一个更好的伴侣。

虽然这个问题看起来很傻很琐碎,但其实不然。例如,它被用于一些大学的录取和分配医学生到医院工作。事实上,有整本书(如唐纳德·克努特、丹·古斯菲尔德和罗伯特·w·欧文的书)专门讨论这个问题及其变种。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

所有的女孩。 你知道我永远不会离开你。只要她和别人在一起就不会。(http://xkcd.com/770 )

背包问题

在某种程度上,这个问题是前面讨论过的变革问题的概括。在那个问题中,我们使用硬币面额来确定部分/全部解决方案是否有效(不要给太多/给准确的数量),硬币的数量衡量最终解决方案的质量。背包问题用不同的术语来描述:我们有一组想要随身携带的物品,每一个都有一定的重量和 ?? 值;然而,我们的背包有一个最大容量(总重量的上限),我们希望最大化我们得到的总价值。

背包问题涵盖了很多应用。每当你要选择一组有价值的对象(内存块、文本片段、项目、人),其中每个对象都有一个单独的值(可能与金钱、概率、新近性、能力、相关性或用户偏好相关),但你受到一些资源的约束(无论是时间、内存、屏幕空间、重量、体积还是其他任何东西),你很可能正在解决背包问题的一个版本。还有一些特殊情况和密切相关的问题,如子集和问题、第十一章中讨论的,以及前面讨论的找零问题。这种广泛的适用性也是它的弱点——这使得它成为一个如此难以解决的问题。一般来说,问题越有表现力,就越难找到有效的算法。幸运的是,有一些特殊的情况我们可以用不同的方式来解决,正如你将在接下来的章节中看到的。

分数背包

这是背包问题中最简单的一个。在这里,我们不需要包括或排除整个对象;例如,我们可能会在背包里塞满豆腐、威士忌和金粉(为一次有点奇怪的野餐做准备)。然而,我们不需要允许任意分数。例如,我们可以使用克或盎司的分辨率。(我们可以更加灵活;参见练习 7-6。)你将如何处理这个问题?

这里最重要的是找到价值与重量的比率。例如,大多数人会同意金粉每克价值最高(尽管这可能取决于你用它做什么);假设威士忌介于两者之间(尽管我肯定有人会对此提出异议)。在这种情况下,为了充分利用我们的背包,我们会把它装满金粉——或者至少是我们现有的金粉。如果用完了,我们就开始加威士忌。如果我们喝完威士忌后还有剩余的空间,我们就用豆腐把它全部填满(并开始害怕打开包装收拾这一团乱)。

这是贪婪算法的一个典型例子。我们直奔好的(或者至少是昂贵的)东西。如果我们使用一个离散的重量测量,这可能会更容易看到;也就是说,我们不需要担心比率。我们基本上有一套单独的金粉、威士忌和豆腐,我们根据它们的价值对它们进行分类。然后,我们(从概念上)把克一个一个的打包。

整数背包

假设我们放弃了片段,现在需要包含整个对象——这种情况在现实生活中更有可能发生,无论您是在编程还是打包行李。然后问题突然变得更加难以解决了。现在,假设我们仍然在处理对象的类别,那么我们可以从每个类别中添加一个整数(即对象的数量)。每个类别都有一个固定的权重和值,适用于所有对象。比如所有的金条重量一样,价值一样;这同样适用于瓶装威士忌(我们坚持单一品牌)和袋装豆腐。现在,我们该怎么办?

整数背包问题有两种重要情况——有界和无界情况。有界的情况假设我们在每个类别中有固定数量的对象,【4】,无界的情况让我们想用多少就用多少。可悲的是,贪婪在这两种情况下都行不通。事实上,这两个都是未解决的问题,在某种意义上,没有已知的多项式算法来解决它们。然而,?? 还是有希望的。正如你将在下一章看到的,我们可以使用动态编程在伪多项式时间内解决问题,这在许多重要情况下可能已经足够好了。此外,对于无界的情况,事实证明贪婪的方法并不坏!或者,更确切地说,它至少有一半好,这意味着我们永远不会得到少于一半的最佳值。稍加修改,有界版本也能得到同样好的结果。贪婪近似的概念将在第十一章的中详细讨论。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 这主要是背包问题的一个初步“尝试”。我会在第八章的中更彻底地处理整数背包问题的解决方案。

霍夫曼算法

霍夫曼算法是贪婪的另一个经典。假设你在某个紧急中心工作,人们在那里寻求帮助。你试图将一些简单的是/否问题放在一起,以帮助来电者诊断急性医疗问题,并决定适当的行动方案。您有一个应该涵盖的条件列表,以及一组诊断标准、严重程度和发生频率。您首先想到的是构建一个平衡的二叉树,在每个节点中构造一个问题,将可能条件的列表(或子列表)分成两半。不过,这似乎太简单了;这个清单很长,包括许多非临界条件。不知何故,你需要考虑严重程度和发生频率。

开始简化任何问题通常是个好主意,所以你决定把重点放在频率上。你意识到平衡二叉树是基于 均匀概率的假设——如果某些项目更有可能,将列表分成两半是不行的。例如,如果病人有一半的机会失去知觉,这就是要问的事情——即使“病人有皮疹吗?”可能会把列表从中间分开。换句话说,你想要一个加权平衡:你想要预期的问题数量尽可能低。您希望最小化从根到叶的遍历的预期深度

你会发现这个想法也可以用来解释严重性。您可能希望对最危险的情况进行优先排序,以便快速识别(“患者有呼吸吗?”),代价是让病情不太严重的患者等待几个额外的问题。在一些健康专家的帮助下,你可以通过结合频率(概率)和所涉及的健康风险,给每种情况一个成本权重来做到这一点。你对树形结构的目标还是一样的。如何最小化所有叶子的深度 ( u ) × 重量 ( u )之和 u

这个问题当然也有其他的应用。事实上,最初的(也是最常见的)应用是通过可变长度编码压缩——更紧凑地表示文本。文本中的每个字符都有出现的频率,您希望利用这些信息给出不同长度的字符编码,以便最小化任何文本的预期长度。同样,对于任何字符,您都希望最小化其编码的预期长度。

你看出这和前面的问题有什么相似之处了吗?考虑一下你只关注给定医疗条件的可能性的版本。现在,我们不是最小化识别某种疾病所需的是/否问题的数量,而是最小化识别一个字符所需的比特数。是/否答案和位唯一地标识了二叉树中叶子的路径(例如,零= = 和一= = )。 5 例如,考虑字符 af 。图 7-2 给出了对它们进行编码的一种方式(暂时忽略节点中的数字)。例如, g (由突出显示的路径给出)的代码将是 101。因为所有的字符都在树叶中,所以当解码一个用这种方法压缩过的文本时,不会有歧义(见练习 7-7)。没有有效代码是另一个代码的前缀,这一特性产生了术语前缀代码

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 7-2 。a–I 的霍夫曼树,频率/权重为 4、5、6、9、11、12、15、16 和 20,代码 101 表示的路径(右、左、右)突出显示

该算法

让我们先设计一个贪婪算法来解决这个问题,然后证明它是正确的(这当然是关键的一步)。最明显的贪婪策略可能是从出现频率最高的字符开始,一个接一个地添加字符(叶子)。但是我们应该在哪里添加它们呢?另一种方法(稍后你会在克鲁斯卡尔的算法中再次看到)是让部分解决方案由几个树片段组成,然后重复地由 ?? 组合 ??。当我们合并两棵树时,我们添加一个新的共享根,并赋予它一个等于其子树之和的权重,也就是先前的根。这正是图 7-2 中节点内数字的含义。

清单 7-1 显示了一种实现霍夫曼算法的方法。它将部分解决方案维护为一个森林,每棵树都表示为嵌套列表。只要森林中至少有两棵独立的树,就会挑出两棵最轻的树(根部重量最低的树),合并,然后放回原处,并赋予新的根部重量。

清单 7-1 。霍夫曼算法

from heapq import heapify, heappush, heappop
from itertools import count

def huffman(seq, frq):
    num = count()
    trees = list(zip(frq, num, seq))            # num ensures valid ordering
    heapify(trees)                              # A min-heap based on frq
    while len(trees) > 1:                       # Until all are combined
        fa, _, a = heappop(trees)               # Get the two smallest trees
        fb, _, b = heappop(trees)
        n = next(num)
        heappush(trees, (fa+fb, n, [a, b]))     # Combine and re-add them
    return trees[0][-1]

下面是一个如何使用代码的示例:

>>> seq = "abcdefghi"
>>> frq = [4, 5, 6, 9, 11, 12, 15, 16, 20]
>>> huffman(seq, frq)
[['i', [['a', 'b'], 'e']], [['f', 'g'], [['c', 'd'], 'h']]]

在实现中有几个细节值得注意。它的主要特性之一是使用堆(来自heapq)。重复选择和组合未排序列表的两个最小元素会给我们一个二次运行时间(线性选择时间,线性迭代次数),而使用堆会将其减少到对数线性(对数选择和重新添加)。但是,我们不能直接将树添加到堆中;我们需要确保它们按频率分类。我们可以简单地添加一个元组(freq, tree),只要所有频率(即权重)都不同,这就可以工作。然而,一旦森林中的两棵树具有相同的频率,堆代码就必须比较这两棵树,看哪一棵树更小——然后我们很快就会遇到未定义的比较。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 注意在 Python 3 中,不允许比较["a", ["b", "c"]]"d"这样不兼容的对象,会引发一个TypeError。在早期版本中,这是允许的,但排序通常没有太大意义;无论如何,实施更可预测的键可能都是一件好事。

一个解决方案是在两者之间添加一个字段,这个字段保证对所有对象都不同。在这种情况下,我简单地使用了一个计数器,产生了(freq, num, tree),其中使用任意的num打破了频率关系,避免了直接比较(可能无法比较的)树。 6

如你所见,生成的树结构相当于图 7-2 中的所示。

当然,要使用这种技术压缩和解压缩文本,您需要一些预处理和后处理。首先,您需要计算字符数以获得频率(例如,使用来自collections模块的Counter类)。然后,一旦你有了霍夫曼树,你必须找到所有字符的代码。你可以用一个简单的递归遍历来实现,如清单 7-2 所示。

清单 7-2 。从霍夫曼树中提取霍夫曼码

def codes(tree, prefix=""):
    if len(tree) == 1:
        yield (tree, prefix)                    # A leaf with its code
        return
    for bit, child in zip("01", tree):          # Left (0) and right (1)
        for pair in codes(child, prefix + bit): # Get codes recursively
            yield pair

例如,codes函数产生适合在dict构造函数中使用的(char, code)对。要使用这样的字典来压缩代码,您只需遍历文本并查找每个字符。为了解压缩文本,您宁愿直接使用霍夫曼树,使用输入中的位来遍历它(即,确定您应该向左还是向右);我将把细节留给读者作为练习。

第一个贪婪的选择

我相信你可以看到霍夫曼代码将让你忠实地编码一个文本,然后再次解码——但是它怎么可能是最优的(在我们正在考虑的代码类别中)?也就是说,为什么使用这个简单、贪婪的过程,任何叶子的预期深度都被最小化了?

正如我们通常所做的,我们现在转向归纳法:我们需要证明我们从头到尾都是安全的——贪婪的选择不会给我们带来麻烦。我们常常可以将这个证明拆分成两部分,也就是通常所说的( i ) 贪婪选择性质和( ii ) 最优子结构(例如,参见 Cormen 等人在第一章的“参考文献”部分)。贪婪选择属性意味着贪婪选择给了我们一个新的部分解,它是最优解的一部分。最优子结构与第八章的材料非常密切相关,它意味着问题的剩余部分,在我们做出选择之后,是否能够像原始问题一样得到解决——如果我们能够找到子问题的最优解,我们可以将它与我们的贪婪选择结合起来,从而得到整个问题的解决方案。换句话说,最优解是从最优子解构建的。

为了展示 Huffman 算法的贪婪选择属性,我们可以使用一个交换参数(例如,参见第一章的“参考”部分中的 Kleinberg 和 Tardos)。这是一种通用技术,用来表明我们的解决方案至少和最优方案一样好(因此是最优的),或者在这种情况下,存在一个我们贪婪选择的解决方案,至少有这么好。“至少一样好”的部分是通过采用一个假设的(完全未知的)最优解,然后逐渐将其变为我们的解(或者,在这种情况下,包含我们感兴趣的位的解)而不使它变得更糟来证明的。

Huffman 算法的贪婪选择包括将两个最轻的元素作为同级叶子放置在树的最低层。(注意,我们担心的只是第个贪婪的选择;最优子结构将处理其余的归纳。)我们需要证明这是安全的——存在一个最优解,其中两个最轻的元素实际上是底层的兄弟树叶。通过定位另一个最优树开始交换论证,其中这两个元素是而不是最低层的兄弟。让 ab 是最低频率的元素,并且假设这个假设的最优树具有 cd 作为最大深度处的兄弟树叶。我们假设 ab 轻(具有较低的权重/频率),并且 cd 轻。 7 在这种情况下,我们也知道 ac 轻, bd 轻。为简单起见,让我们假设 ad 的频率不同,因为否则证明是简单的(见练习 7-8)。

如果我们交换 ac 会发生什么?然后互换 bd ?首先,我们现在有了作为底层兄弟的 ab ,这是我们想要的,但是预期的叶子深度发生了什么变化呢?您可以在这里修改加权和的完整表达式,但简单的想法是:我们在树中上移了一些重节点和下移了一些轻节点和*。这意味着一些短路径现在在总和中被给予较高的权重,而一些长路径被给予较低的权重。总而言之,总成本不可能增加。(事实上,如果深度和权重都不同,我们的树会更好,我们有一个矛盾的证明,因为我们假设的替代最优方案不存在——贪婪的方法是最好的。)*

走完剩下的路

这是证明的前半部分。我们知道做出第一个贪婪选择是可以的(贪婪选择属性),但是我们需要知道使用贪婪选择(最优子结构)来保持是可以的。不过,我们需要先处理剩下的子问题是什么*。更好的是,我们希望它有和原来一样的结构,这样感应机制就能正常工作。换句话说,我们希望将事情简化为一个新的、更小的元素集,我们可以为其构建一个最佳树,然后展示如何在此基础上进行构建。*

这个想法是将前两个组合的叶子视为一个新元素,忽略它是一棵树的事实。我们只担心它的根源。然后,子问题就变成了为这组新元素找到一棵最优树——通过归纳,我们可以假设这是正确的。剩下的唯一问题是,一旦我们通过再次包括它的叶子节点,将这个节点扩展回三节点子树,这个树是否是最优的;这是给我们引导步骤的关键部分。

假设我们的两片叶子是 ab ,频率为 f ( a )和 f ( b )。我们将它们聚集成一个单个节点,频率为f(a)+f(b),并构建一个最优树。让我们假设这个组合节点在深度 D 结束。那么它对总树成本的贡献就是D×(f(a)+f(b)。如果我们现在展开这两个子节点,它们的父节点不再对成本有贡献,但是叶子(现在在深度 D + 1)的总贡献将是(D+1)×(f(a)+f(b)。换句话说,全解的成本超过最优子解f(a)+f(b)。我们能确定这是最优的吗?

是的,我们可以,我们可以用矛盾来证明,假设它是而不是最优。我们变出另一棵更好的树——假设它也有 ab 作为底层兄弟。(根据上一节的讨论,我们知道存在这样的最优树。)再一次,我们可以折叠 ab ,我们最终得到子问题的一个解决方案,这个解决方案比我们得到的解决方案更好…但是我们得到的解决方案根据假设是最优的!换句话说,我们找不到比包含最优子解更好的全局解。

最佳合并

虽然霍夫曼算法通常用于构造最佳前缀码,但还有其他方式来解释霍夫曼树的属性。正如最初解释的那样,人们可以把它看作一棵决策树,其中期望的遍历深度是最小的。不过,我们也可以在解释中使用内部节点的权重,从而产生一个相当不同的应用。

我们可以将霍夫曼树视为一种微调的分治树,其中我们不像第六章中的那样进行平面平衡,而是将叶子权重考虑在内。然后,我们可以将叶权重解释为子问题的大小,如果我们假设组合(合并)子问题的成本是线性的(分而治之中经常出现这种情况),则所有内部节点权重的总和代表所执行的总工作量。

例如,这方面的一个实际例子是合并已排序的文件。合并大小为 nm 的两个文件需要在 n + m 中线性花费时间。(这类似于关系数据库中的连接问题或 timsort 等算法中的序列合并问题。)换句话说,如果你把图 7-2 中的叶子想象成文件,把它们的权重想象成文件大小,那么内部节点就代表了整个合并的成本。如果我们能够最小化内部节点的总和(或者,等价地,所有节点的总和),我们将找到最佳的合并时间表。(练习 7-9 要求你证明这真的很重要。)

我们现在需要证明霍夫曼树确实可以最小化节点权重。幸运的是,我们可以根据前面的讨论来证明这一点。我们知道,在霍夫曼树中,所有叶子的深度乘以权重之和是最小的。现在,考虑每个叶子如何对所有节点的总和做出贡献:叶子权重作为被加数在其每个祖先节点中出现一次——这意味着总和完全相同!即sum(weight(node) for node in nodes)sum(depth(leaf)*weight(leaf) for leaf in leaves)相同。换句话说,霍夫曼算法正是我们进行最佳合并所需要的。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 提示Python 标准库有几个处理压缩的模块,包括zlibgzipbz2zipfiletarzipfile模块处理 ZIP 文件,它使用基于霍夫曼码的压缩。 8

最小生成树

现在让我们看看贪婪问题最广为人知的例子:寻找最小生成树。这个问题由来已久——至少从 20 世纪初就存在了。1926 年,捷克数学家奥塔卡尔·borůvka 首次解决了这个问题,试图为摩拉维亚建造一个廉价的电网。从那以后,他的算法被重新发现了很多次,它仍然是今天已知的一些最快的算法的基础。我将在本节中讨论的算法(Prim 的和 Kruskal 的)在某种程度上更简单,但具有相同的渐近运行时间复杂度( O ( m lg n ,对于 n 节点和 m 边)。 9 如果你对这个问题的历史感兴趣,包括经典算法的反复重新发现,可以看看 Graham 和 Hell 的论文《关于最小生成树问题的历史》。(例如,你会看到 Prim 和 Kruskal 并不是唯一声称拥有其同名算法的人。)

我们基本上是在寻找连接一个加权图的所有节点的最便宜的方法,因为我们只能使用它的边的子集来完成这项工作。解决方案的成本就是我们使用的边的加权和。这在建设电网、构建公路或铁路网络的核心、设计电路,甚至是执行某种形式的集群(在这种情况下,我们只需要几乎连接所有节点)时会很有用。最小生成树也可以用作第一章中介绍的旅行销售代表问题的近似解决方案的基础(见第十一章对此的讨论)。

连通无向图 G 的生成树 T 具有与 G 相同的节点集和边的子集。如果我们将一个边权重函数与 G 相关联,那么边 e 具有权重 w ( e ,那么生成树的权重 w ( T ),就是 T 中每条边 ew ( e 之和。在最小生成树问题中,我们想在 G 上找到一棵具有最小权重的生成树。(注意可能不止一个。)还要注意,如果 G 断开,它将没有也没有生成树,所以在下面,通常假设我们正在处理的图是连通的。

在第五章中,你看到了如何使用遍历构建生成树;构建最小生成树也可以像这样一步一步来构建,这就是贪婪的来源:我们逐渐通过一次添加一条边来构建树。在每一步,我们都在我们的建造程序允许的范围内选择最便宜的(或最轻的)边。这个选择就是局部最优(也就是贪心的)不可撤销。这个问题的主要任务,或者任何其他贪婪问题,变成显示这些局部最优选择导致全局最优解。

最短的边

考虑图 7-3 。让边权重对应于绘制时节点之间的欧几里德距离(即实际边长)。如果你要为这个图构造一个生成树,你会从哪里开始?你能确定其中有某种优势吗?或者至少包含某个边缘是安全的?当然( ei )看起来很有希望。它太小了!事实上,它是所有边中最短的一条,也是权重最低的一条。但这就够了吗?

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 7-3 。欧几里得图及其最小生成树(突出显示)

事实证明,的确如此。考虑任何没有的生成树的最小重量边( ei )。生成树必须包括 ei (根据定义),因此它还将包括从 ei 的单一路径。如果我们现在将( ei )添加到混合中,我们将得到一个循环*,并且为了回到一个合适的生成树,我们将不得不删除这个循环的一条边——哪条都没关系。因为( ei )是最小的,移除任何其他的边会产生比我们开始时更小的树。正确换句话说,任何不包括最短边的树都可以变小,所以最小生成树必须*包括最短边。(正如你将看到的,这是克鲁斯卡尔算法背后的基本思想。)**

如果我们考虑所有的边都发生在一个节点上,会怎么样呢——我们能得出什么结论吗?比如看一下 b 。根据生成树的定义,我们必须以某种方式将 b 连接到其余部分,这意味着我们必须包括或者 ( bd ) 或者 ( ba )。同样,选择两者中最短的一个似乎很有诱惑力。再一次,贪婪的选择被证明是非常明智的。我们再一次用反证法证明了选择是次等的:假设用( ba )更好。我们将构建包含( ba )的最小生成树。然后,为了好玩,我们会添加( bd ),创建一个循环。但是,嘿——如果我们去掉( ba ),我们就有了另一个生成树,因为我们把一条边换成了一条更短的边,所以这个新树肯定更小。换句话说,我们有一个矛盾,没有( bd )的那个一开始就不可能最小。而这个是 Prim 算法背后的基本思想,我们将在 Kruskal 的算法之后再看。

事实上,这两个想法都是涉及削减的更普遍原则的特例。切割只是将图节点划分为两个集合,在这种情况下,我们感兴趣的是在这两个节点集合之间通过的边。我们说这些边缘穿过切口。例如,想象在图 7-3 中画一条垂直线,正好在 dg 之间;这将产生由五条边交叉的切口。到现在为止,我相信你已经明白了:我们可以确定包含穿过切割的最短边是安全的,在这种情况下( dj )。争论再次完全相同:我们建立一个替代树,它必须包括至少一个穿过切割的其他边(为了保持图的连接)。如果我们然后添加( dj ),则至少穿过切割的其他较长边中的一条将是与( dj )相同循环的一部分,这意味着移除另一条边将是安全的,从而给出更小的生成树。

您可以看到前两个想法是这种“穿过切割的最短边”原则的特殊情况:选择图中的最短边将是安全的,因为它在它参与的每个切割中都是最短的,选择与任何节点相关的最短边将是安全的,因为它是切割上的最短边,将该节点与图的其余部分分开。在下文中,我对这些想法进行了扩展,将它们变成了两个用于寻找最小生成树的成熟的贪婪算法。第一个(Kruskal 的)接近典型的贪婪算法,而第二个(Prim 的)使用遍历原则,并在顶部添加了贪婪选择。

其余的呢?

表现出第一个贪婪的选择是可以的是不够的。我们需要证明剩下的问题是同一问题的一个较小的实例——我们的归约可以安全地用于归纳。换句话说,我们需要建立最优的子结构。这并不太难(练习 7-12),但这里有另一种方法可能更简单:我们证明我们的解是最小生成树的一部分(一个子图)的不变量。只要解决方案不是生成树,我们就不断添加边(也就是说,只要还有边不会形成循环),所以如果这个不变量为真,算法必须以完整的最小生成树终止。

那么,不变量成立吗?最初,我们的部分解决方案是空的,这显然是一个部分的最小生成树。现在,归纳地假设我们已经建立了一些部分的最小生成树 T ,并且我们添加了一条安全边(也就是说,一条不产生循环并且是穿过一些切割的最短的边)。显然,新的结构仍然是一个森林(因为我们小心翼翼地避免创建循环)。同样,上一节中的推理仍然适用:在包含 T 的生成树中,包含该安全边的生成树将比不包含该安全边的生成树小。因为(根据假设),包含 T 的树中至少有一棵是最小生成树,包含 T 和安全边的树中至少有一棵也会是最小生成树。

克鲁斯卡尔算法

这种算法接近于本章开始时概述的一般贪婪方法:对边进行排序并开始挑选。因为我们在寻找边,所以我们按照长度(或重量)的增加对它们进行排序。唯一的问题是如何检测会导致无效解决方案的边缘。使我们的解决方案无效的唯一方法是添加一个循环,但是我们如何检查呢?一个简单的解决方案是使用遍历;每当我们考虑一条边( uv ),我们就从 u 开始遍历我们的树,看看是否有路径到达 v 。如果有,我们就丢弃它。不过,这似乎有点浪费;在最坏的情况下,遍历检查将花费我们部分解决方案的线性时间。

我们还能做什么?我们可以维护到目前为止我们的树中的一组节点,然后对于一个预期的边( uv ),我们将查看两者是否都在解决方案中。这将意味着排序边缘是主导;检查每个边缘可以在恒定的时间内完成。这个计划只有一个致命的缺陷:行不通。如果我们能保证部分解在每一步都是连接的,那么将会起作用(这就是我们在 Prim 算法中要做的),但是我们不能。因此,即使两个节点是我们目前解决方案的一部分,它们可能在不同的树中,连接它们将是完全有效的。我们需要知道的是它们不在同一个树中。**

让我们通过让解决方案中的每个节点知道它属于哪个组件(树)来尝试解决这个问题。我们可以让组件中的一个节点作为代表、,然后组件中的所有节点都可以指向这个代表。这就留下了组合组件的问题。如果合并组件的所有节点都必须指向同一个代表,那么这个组合(或联合)将是一个线性操作。我们能做得更好吗?我们可以试试;例如,我们可以让每个节点指向另一个节点,我们将沿着这个链直到到达代表(它将指向它自己)。然后,加入只是一个代表点指向另一个代表点的问题(恒定时间)。没有直接的保证证明链会有多长,但至少这是第一步。

这就是我在清单 7-3 中所做的,使用地图C来实现“指向”正如你所看到的,每个节点最初是它自己的组件的代表,然后我重复地用新的边连接组件,按照排序的顺序。请注意,我实现的方式是,我期望一个无向图,其中每条边只表示一次(也就是说,使用它的一个方向,任意选择)。和往常一样,我假设图中的每个节点都是一个键,尽管可能有一个空的权重图(也就是说,如果u没有外边缘,那么就是G[u] = {})。

清单 7-3 。克鲁斯卡尔算法的简单实现

def naive_find(C, u):                           # Find component rep.
    while C[u] != u:                            # Rep. would point to itself
        u = C[u]
    return u

def naive_union(C, u, v):
    u = naive_find(C, u)                        # Find both reps
    v = naive_find(C, v)
    C[u] = v                                    # Make one refer to the other

def naive_kruskal(G):
    E = [(G[u][v],u,v) for u in G for v in G[u]]
    T = set()                                   # Empty partial solution
    C = {u:u for u in G}                        # Component reps
    for _, u, v in sorted(E):                   # Edges, sorted by weight
        if naive_find(C, u) != naive_find(C, v):
            T.add((u, v))                       # Different reps? Use it!
            naive_union(C, u, v)                # Combine components
    return T

天真的克鲁斯卡尔很管用,但并不那么好。(什么,名字泄露了?)在最坏的情况下,我们需要在naive_find中遵循的引用链可能是线性的。一个相当明显的想法可能是总是让naive_union中两个组件的较小的指向较大的,给我们一些平衡。或者我们可以从平衡树的角度考虑更多,给每个节点一个等级或高度。如果我们总是让最低位的代表点指向最高位的代表点,那么调用naive_findnaive_联合的总运行时间为O(mLGn)(见练习 7-16)。**

这实际上没什么问题,因为排序操作的起点是θ(mLGn*)。 12 不过,这个算法中还有一个常用的技巧,叫做路径压缩。它需要在执行find时“拉动指针”,确保我们在途中检查的所有节点现在都直接指向代表。直接指向代表的节点越多,后面的find s 中的事情应该进行得越快,对吗?可悲的是,这到底如何以及为什么会有帮助背后的原因对我来说太复杂了,我无法在这里深入讨论(尽管我会推荐 Sect。21.4 在 Cormen 等人的算法简介中,如果你感兴趣的话)。不过最终的结果是,union s 和find s 最坏情况下的总运行时间是O((n),其中 α ( n )是几乎一个常数。事实上,你可以假设 α ( n ) ≤ 4,对于 n 的任何一个看似遥远的值。关于findunion的改进实现,参见清单 7-4 。

清单 7-4 。克鲁斯卡尔算法

def find(C, u):
    if C[u] != u:
        C[u] = find(C, C[u])                    # Path compression
    return C[u]

def union(C, R, u, v):a
    u, v = find(C, u), find(C, v)
    if R[u] > R[v]:                             # Union by rank
        C[v] = u
    else:
        C[u] = v
    if R[u] == R[v]:                            # A tie: Move v up a level
        R[v] += 1

def kruskal(G):
    E = [(G[u][v],u,v) for u in G for v in G[u]]
    T = set()
    C, R = {u:u for u in G}, {u:0 for u in G}   # Comp. reps and ranks
    for _, u, v in sorted(E):
        if find(C, u) != find(C, v):
            T.add((u, v))
            union(C, R, u, v)
    return T

总而言之,克鲁斯卡尔算法的运行时间是θ(mLGn),这个时间来自于排序。

请注意,您可能希望以不同的方式表示生成树(即,不是边的集合)。在这方面,算法应该很容易修改——或者你可以基于边集T构建你想要的结构。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 注意克鲁斯卡尔算法中使用的子问题结构是一个拟阵的例子,其中可行的部分解是简单的集合——在这种情况下,是无圈边集。对于拟阵来说,贪婪是有用的。规则如下:可行集的所有子集也必须是可行的,较大的集合必须有可以扩展较小集合的元素。

普里姆算法

克鲁斯卡尔的算法在概念层面上很简单——它是对生成树问题的贪婪方法的直接翻译。正如您刚才看到的,有效性检查有些复杂。在这方面,Prim 的算法要简单一点。13Prim 算法的主要思想是从一个起始节点开始遍历图,总是添加连接到树的最短边。这是安全的,因为如前所述,该边将是穿过我们的部分解决方案的切口的最短的一条边。

这意味着 Prim 的算法只是另一种遍历算法,如果你读过《??》第五章,这应该是一个熟悉的概念。正如在那一章中所讨论的,遍历算法之间的主要区别是我们的“待办事项”列表的排序——在我们发现的未访问节点中,我们将遍历树扩展到下一个节点?在广度优先搜索中,我们使用了一个简单的队列(即一个deque);在 Prim 的算法中,我们简单地用一个用堆实现的优先级队列、替换这个队列,使用heapq库(在第六章的的“黑盒”侧栏中讨论)。

然而,这里有一个重要的问题:最有可能的是,我们将发现指向已经在我们的队列中的节点的新边。如果我们发现的新边比前一个边短,我们应该根据这个新边调整优先级*。然而,这可能相当麻烦。我们需要在堆中找到给定的节点,改变优先级,然后重新构造堆,使其仍然正确。您可以通过从每个节点到它在堆中的位置的映射来做到这一点,但是在执行堆操作时您必须更新该映射,并且您不能再使用heapq库。*

不过,事实证明还有另一种方法。一个非常好的解决方案,也适用于其他基于优先级的遍历(如 Dijkstra 算法和 A*,在第九章的中讨论),就是简单地多次添加节点*。每次你找到一个节点的边,你就以适当的权重将该节点添加到堆(或其他优先级队列)中,并且你不关心它是否已经在那里。为什么这可能行得通?*

** 我们使用的是优先级队列,所以如果一个节点被添加了多次,当我们删除它的一个条目时,它将是权重最低的那个(那时),也就是我们想要的那个。

  • 我们确保不会将同一个节点多次添加到我们的遍历树中。这可以通过恒定时间的成员检查来确保。因此,任何给定节点的所有队列条目都将被丢弃,只有一个除外。
  • 多次加法不会影响渐近运行时间(见练习 7-17)。

对实际运行时间也有重要的影响。(更)简单的代码不仅更容易理解和维护;它的开销也少了很多。因为我们可以使用超快的heapq库,最终结果很可能是性能的大幅提升。(如果你想尝试更复杂的版本,这在许多算法书籍中都有使用,当然欢迎你。)

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 重新添加一个权重较低的节点相当于一个松弛,如第四章所述。正如您将看到的,我还将 predecessor 节点添加到队列中,使得任何显式的放松都是不必要的。然而在第九章实现 Dijkstra 的算法时,我使用了一个单独的relax函数。这两种方法是可以互换的(所以你可以让 Prim 的??,Dijkstra 的不带??)。

你可以在清单 7-5 的中看到我版本的 Prim 算法。因为heapq还不像list.sort和 friends 那样支持排序键,所以我在堆中使用(weight, node)对,当节点弹出时丢弃权重。除了使用堆之外,代码类似于清单 5-10 中广度优先搜索的实现。这意味着这里的许多理解应该是免费的。

清单 7-5 。普里姆算法

from heapq import heappop, heappush

def prim(G, s):
    P, Q = {}, [(0, None, s)]
    while Q:
        _, p, u = heappop(Q)
        if u in P: continue
        P[u] = p
        for v, w in G[u].items():
            heappush(Q, (w, u, v))
    return P

注意,与kruskal不同,在清单 7-4 中,清单 7-5 中的prim函数假设图G是一个无向图,其中两个方向都被显式表示,因此我们可以很容易地在两个方向上遍历每条边。 14

与 Kruskal 的算法一样,您可能希望用不同于我在这里所做的方式来表示生成树。重写这部分应该很容易。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 注意Prim 算法中使用的子问题结构是 greedoid 的一个例子,它是拟阵的简化和概括,其中我们不再要求可行集的所有子集都是可行的。可悲的是,拥有一个 greedoid 本身并不能保证贪婪会奏效——尽管这是朝着正确方向迈出的一步。

略有不同的视角

在他们对最小生成树算法的历史概述中,Ronald L. Graham 和 Pavol Hell 概述了三种他们认为特别重要并且在该问题的历史中起到核心作用的算法。前两种算法通常归属于 Kruskal 和 Prim(尽管第二种算法最初是由 vojtch jarník 在 1930 年制定的),而第三种算法最初是由 Boru˚ vka 描述的。格雷厄姆和赫尔简明扼要地解释了算法如下。部分解决方案是一个生成森林,由一组片段(组件,树)组成。最初,每个节点都是一个片段。在每一次迭代中,边被添加,连接片段,直到我们有一个生成树。

算法 1: 添加连接两个不同片段的最短边。

算法 2: 添加一条最短的边,将包含根的片段连接到另一个片段。

算法 3: 对于每一个片段,添加连接它和另一个片段的最短边。

对于算法 2,在开始时任意选择根。对于算法 3,假设所有的边权重都不同,以确保不会出现循环。如您所见,所有三种算法都基于相同的基本事实,即切割的最短边是安全的。此外,为了有效地实现它们,您需要能够找到最短的边,检测两个节点是否属于同一个片段,等等(如正文中对算法 1 和 2 的解释)。尽管如此,这些简短的解释还是有助于记忆,或者有助于鸟瞰正在发生的事情。

贪婪起作用。但是什么时候?

虽然归纳法通常被用来证明贪婪算法是正确的,但是还有一些额外的“技巧”可以使用。我已经在这一章中使用了一些,但在这里我将尝试给你一个概述,使用一些涉及时间间隔的简单问题。事实证明,有很多这种类型的问题可以通过贪婪算法来解决。我不包括这些的代码;实现非常简单(尽管实际实现它们可能是一个有用的练习)。

跟上最好的

这就是 Kleinberg 和 Tardos(在算法设计中)所说的保持领先。这个想法是为了表明,当你一步一步地构建你的解决方案时,贪婪算法将总是得到至少与假设的最优算法会得到的一样远。一旦你到达终点,你就证明了贪婪是最理想的。这种技术在解决一个常见的贪婪问题时很有用:资源调度

该问题涉及选择一组兼容的间隔。通常,我们认为这些间隔是时间间隔(见图 7-4 )。兼容性仅仅意味着它们不应该重叠,因此这可以用于对在特定时间段内使用资源(如演讲厅)的请求进行建模。另一个例子是让 you 成为“资源”,让时间间隔成为你想参加的各种活动。无论哪种方式,我们的优化任务是选择尽可能多的相互兼容(不重叠)的区间。为了简单起见,我们可以假设没有起点或终点是相同的。处理相同的值并不困难。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 7-4 。一组随机区间,其中最多可以找到四个相互兼容的区间(例如 a、c、e 和 g)

这里有两个明显的贪婪选择的候选:如果我们在时间轴上从左到右,我们可能想要从首先开始的间隔或者首先结束的间隔开始,消除任何其他重叠的间隔。我希望很清楚,第一个选择是行不通的(练习 7-18),这就让我们来证明另一个行不通。

算法(大致)如下:

  1. 在解决方案中包括完成时间最短的间隔。
  2. 移除与步骤 1 中的间隔重叠的所有剩余间隔。
  3. 还有剩余的间隔吗?转到步骤 1。

在图 7-4 的中设置的间隔上运行该算法,得到高亮显示的一组间隔( aceg )。由此产生的解决方案显然是有效的;也就是说,其中没有任何重叠的音程。这将是一般情况;我们只需要证明它是最优的,也就是说,我们有尽可能多的区间。让我们尝试应用保持领先的理念。

假设我们的区间按照相加的顺序是:I1Ik,假设最优解给出了区间j1jm。我们想表明,k = m 。假设最佳间隔按结束(和开始)时间排序。 15 为了说明我们的算法保持在最优算法的前面,我们需要说明对于任意的 rki r 的结束时间至少早于 j r 的结束时间,我们可以用归纳法证明这一点。

对于 r = 1,显然是正确的:贪婪算法选择 i 1 ,这是完成时间最短的元素。现在,让 r > 1,并假设我们的假设对 r - 1 成立。那么问题就变成了贪婪算法在这一步是否有可能“落后”。也就是说, i r 的完成时间现在有可能大于 j r 的完成时间吗?答案显然是否定的,因为贪婪算法也可以选择 j r (它与jr-1兼容,因此也与Ir-1兼容,后者至少完成得更早)。

所以,贪婪算法跟上最好,一直到最后。然而,这种“保持”只涉及结束时间,而不是间隔的数量。我们需要证明跟上会产生最优解,我们可以通过矛盾来做到:如果贪婪算法是不是最优,那么 m > k 。对于每一个 r ,包括 r = k ,我们知道 i r 至少早于 j r 完成。因为 m > k 一定有一个区间jr+1我们没有用。这必须在 j r 之后开始,因此在 i r 之后开始,这意味着我们可以拥有——事实上,拥有 包含它。换句话说,我们有一个矛盾。

不比完美差

这是我在展示霍夫曼算法的贪婪选择属性时使用的一种技术。它包括展示你可以将一个假设的最优解转化为贪婪的解,而不会降低质量。克莱恩伯格和塔多斯称之为交换论点。让我们来扭转一下音程问题。不再有固定的开始和结束时间,我们现在有一个持续时间和一个截止时间,你可以自由安排时间间隔——让我们称之为任务——只要它们不重叠。当然,你也有一个给定的开始时间。

然而,任何超过期限的任务都会招致与其延迟相等的惩罚,并且您希望最小化这些延迟的最大值。从表面上看,这似乎是一个相当复杂的调度问题(事实上,许多调度问题真的很难解决)。然而,令人惊讶的是,你可以通过一个超级简单的贪婪策略找到最佳时间表:总是执行最紧急的任务。对于贪婪算法来说,正确性证明比算法本身更难。

贪婪的解决方案没有漏洞。我们一完成一项任务,就开始下一项。还会有至少一个没有缺口的最优解——如果我们有一个有缺口的最优解*,我们总是可以将它们封闭起来,导致后面的任务提前完成。此外,贪婪解决方案将没有反转(在其他具有更早截止时间的作业之前调度的作业)。我们可以证明,所有没有间隙或反转的解都具有相同的最大延迟。这两种解决方案的区别仅在于具有相同期限的任务的顺序,并且这些任务必须被连续安排。在这样的连续块中的任务中,最大延迟仅取决于最后一个任务,并且该延迟不取决于任务的顺序。*

唯一有待证明的是,存在一个没有间隙或反演的最优解,因为它与贪婪解是等价的。这个证明有三个部分:

  • 如果最优解有反转,则有两个连续的任务,其中第一个任务的截止日期比第二个晚。
  • 切换这两个可以消除一个反转。
  • 消除这种反转不会增加最大延迟。

第一点应该够明显了。在两个反向任务之间,一定有某个时间点,截止日期开始减少,给我们两个连续的反向任务。至于第二点,交换任务显然去除了一个倒置,并且没有新的倒置产生。第三点需要一点小心。交换任务 ij (所以 j 现在先来)可能会潜在地增加只有 i 的迟到;所有其他任务都是安全的。在新的时间表中, i 完成之前 j 完成的地方。因为(假设)i 的截止日期比 j 的截止日期晚,所以延迟不可能增加。这样,证明的第三部分就完成了。

应该清楚的是,这些部分一起示出了贪婪调度最小化最大延迟。

保持安全

这是我们开始的地方:为了确保贪婪算法是正确的,我们必须确保过程中的每个贪婪步骤都是安全的。这样做的一种方式是两部分方法,显示(1)贪婪选择属性,即贪婪选择与最优性兼容,以及(2)最优子结构,即剩余的子问题是一个较小的实例,也必须以最优方式解决。例如,贪婪选择属性可以使用交换参数来显示(就像对霍夫曼算法所做的那样)。

另一种可能性是将安全视为不变量。或者,用 Michael Soltys 的话说(参见第四章的“参考资料”部分),我们需要证明,如果我们有一个有希望的部分解决方案,贪婪的选择将产生一个新的、更大的解决方案,也就是有希望的。如果部分解决方案可以扩展为最优解决方案,则它是有希望的。这是我在“其余的怎么办?”一节中采用的方法本章前面;在那里,一个解决方案是有希望的,如果它包含在(因此,可以扩展到)一个最小生成树中。显示“当前的部分解决方案是有希望的”是贪婪算法的一个不变量,因为你一直在做出贪婪的选择,这是你真正需要的。

让我们考虑最后一个涉及时间间隔的问题。问题很简单,算法也很简单,但正确性证明相当复杂。 16 它可以作为一个例子来说明一个相对简单的贪婪算法是正确的。

这一次,我们再次有了一组有截止日期的任务,以及一个开始时间(比如现在)。然而,这一次,这些都是很难的最后期限——如果我们不能在最后期限前完成任务,我们就根本不能接受它。此外,每个任务都有一个给定的利润与之关联。像以前一样,我们一次只能执行一项任务,我们不能把它们分成几部分,所以我们在寻找一组我们实际上能做的工作,这给我们带来尽可能大的总利润。然而,为了简单起见,这一次所有的任务花费相同的时间——一个时间步长。如果 d 是最晚的截止日期,以从起点开始的时间步长来衡量,我们可以从一个空的调度开始,空出 d 个时间段,然后用任务填充这些时间段。

这个问题的解决方案在某种程度上是加倍贪婪。首先,我们从利润最大的任务开始,考虑利润递减的任务;这是第一个贪婪的部分。接下来是第二部分:我们根据任务的截止日期,将每个任务放在它能占用的最晚的空闲位置。如果没有空闲的、有效的槽,我们就放弃这个任务。一旦我们完成了,如果我们还没有填满所有的空位,我们当然可以提前执行任务,以便消除差距——这不会影响利润或允许我们执行任何更多的任务。为了感受这个解决方案,你可能想实际实现它(练习 7-20)。

这个解决方案听起来很有吸引力;我们优先考虑有利可图的任务,并通过尽可能将它们推向截止日期,确保它们使用最少的我们宝贵的“早期时间”。但是,再说一次,我们不会依赖直觉。我们将使用一点归纳法,表明当我们以这种贪婪的方式添加任务时,我们的时间表仍然是有希望的。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 注意下面的演示不涉及任何深奥的数学或火箭科学,更多的是非正式的解释,而不是完整的技术证明。尽管如此,这有点复杂,可能会伤害你的大脑。如果你觉得不能胜任,可以直接跳到章节摘要。

一如既往,最初的空解决方案是有希望的。在超越基本情况的过程中,重要的是要记住,只有使用剩余的任务、将时间表扩展为最佳时间表*,时间表才是真正有希望的,因为这是我们被允许扩展时间表的唯一方式。现在,假设我们有一个有希望的部分时间表 p,它的一些位置被填满,一些没有。P 是有希望的这一事实意味着它可以扩展到一个最优的时间表—让我们称之为 s。另外,让我们假设 T 是正在考虑的下一个任务。*

我们现在有四种情况要考虑:

  • t 放不下 P,因为截止日期前没房了。在这种情况下,T 影响不了什么,所以一旦 T 被丢弃,P 还是有希望的。
  • t 将会放入 P 中,它的最终位置与 S 中的位置相同,在这种情况下,我们实际上是向 S 延伸,所以 P 仍然是有希望的。
  • t 会合适,但最终会到别的地方。这似乎有些麻烦。
  • t 会适合,但是 S 不包含。也许更麻烦的是。

很明显,我们需要解决最后两种情况,因为它们似乎离最优调度 s 越来越远。事实是,可能有不止一个最优调度——我们只需要表明,在添加 T 之后,我们仍然可以到达其中的一个*。*

首先,让我们考虑这样一种情况,我们贪婪地添加 T,它并不在 S 中的相同位置,然后我们可以建立一个几乎像 S 的调度,除了 T 已经与另一个任务 T '交换了位置。让我们称这另一个时间表为 S。根据构造,T 尽可能晚地放置在 S '中的,这意味着它必须在 S '中早放置,相反,T '必须在 S 中晚放置,因此在 S '中早*。这意味着我们不能在构造 S '的时候破坏 T '的期限,所以这是一个有效的解决方案。此外,因为 S 和 S '包含相同的任务,利润必须相同。*

剩下的唯一情况是 T 是最优调度 S 中调度的而不是*。同样,让 S ‘几乎像 S 一样*。唯一的区别是我们已经用我们的算法调度 T,有效地“覆盖”了 S 中的一些其他任务 T’。我们没有违反任何截止日期,所以 S '是有效的。我们还知道,我们可以从 P 到 S '(通过几乎遵循到达 S 所需的步骤,只是用 T 代替 T ')。*

*最后一个问题就变成了,S '和 S 有一样的利润吗?我们可以通过矛盾来证明这一点。假设 T '比 T 有更大的利润,这是 S 能有更高利润的唯一方法。如果是这种情况,贪婪算法将在 T 之前考虑 T’。由于在 T’的截止日期之前至少有一个空闲时隙,贪婪算法将调度它,必然在与 T 不同的位置,因此在与 S 不同的位置。但是我们假设我们可以将 P 扩展到 S,如果它在不同的位置有任务,我们就有矛盾。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

摘要

贪婪算法的特点是如何做决定。在逐步构建解决方案的过程中,每个添加的元素都是在添加时看起来最好的*,而不考虑之前发生了什么或之后会发生什么。这种算法通常很容易设计和实现,但是要证明它们是正确的(也就是最优的)通常是具有挑战性的。一般来说,你需要证明做出贪婪的选择是安全的——如果你的解决方案是有希望的,也就是说,它可以扩展到一个最优方案,那么贪婪选择后的方案是也是有希望的。总的原则,一如既往,是归纳法,虽然有一些更专业的想法可能是有用的。例如,如果你可以证明一个假设的最优解可以被修改成贪婪解而不损失质量,那么贪婪解就是最优的。或者,如果你能证明在解决方案构建过程中,贪婪的部分解决方案在某种意义上能跟上一个假设的最优解决方案序列,一直到最终的解决方案,你可以(稍微小心一点)用它来证明最优。*

*本章讨论的重要贪婪问题和算法包括背包问题(选择具有最大值的项目的重量有界子集),其中分数版本可以被贪婪地解决;霍夫曼树,可用于创建最佳前缀码,并通过组合部分解决方案中的最小树来贪婪地构建;以及最小生成树,可以使用 Kruskal 的算法(保持添加最小的有效边)或 Prim 的算法(保持连接离你的树最近的节点)来构建。

如果你好奇的话…

关于贪婪算法有一个很深的理论,我在这一章中还没有真正触及,它涉及到拟阵、拟阵和所谓的拟阵嵌入。虽然拟阵的东西有点难,而且拟阵嵌入的东西会很快变得令人困惑,但拟阵并不真的那么复杂,它们对一些贪婪的问题提供了一个优雅的视角。(拟阵更一般,拟阵嵌入是三者中最一般的,实际上涵盖了所有贪心问题。)关于拟阵的更多信息,你可以看看 Cormen 等人的书(参见第一章的“参考”部分)。

如果你对为什么做出改变的问题通常很难感兴趣,你应该看看第十一章的材料。如前所述,对于许多货币系统,贪婪算法工作得很好。David Pearson 设计了一种算法,用于检查任何给定货币的是否是这种情况;如果你感兴趣,你应该看看他的论文(参见“参考文献”)。

如果你发现你需要建立最小有向生成树,从某个开始节点分支,你不能使用 Prim 的算法。关于用于寻找这些所谓的最小成本树状结构的算法的讨论可以在 Kleinberg 和 Tardos 的书中找到(参见第一章的“参考”部分)。

练习

7-1.举一组面额的例子,会打破给零钱的贪心算法。

7-2.假设你有面值是某个整数的幂的硬币 k > 1。为什么你能确定贪婪算法在这种情况下会起作用?

7-3.如果某个选择问题中的权重是 2 的唯一幂,贪婪算法通常会最大化权重和。为什么呢?

7-4.在稳定婚姻问题中,我们说两个人之间的婚姻,比如说杰克和吉尔,是可行的如果在杰克和吉尔结婚的地方存在稳定的配对。显示盖尔-沙普利算法将匹配每个男人与他的最高排名可行的妻子。

7-5.吉尔是杰克最合适的妻子。表明杰克是吉尔的最坏的可行的丈夫。

7-6.假设你想装进背包的各种东西是可以部分分割的。也就是说,你可以把它们在某些间隔均匀的点上分开(比如一块糖分成正方形)。不同的项目在它们的断裂点之间具有不同的间距。贪婪算法还能工作吗?

7-7.证明你从霍夫曼代码中得到的代码是没有歧义的。也就是说,当解码一个霍夫曼编码的文本时,你总是可以确定符号边界在哪里,哪些符号在哪里。

7-8.在霍夫曼树的贪婪选择性质的证明中,假设 ad 的频率不同。如果他们不是呢?

7-9.表明一个坏的合并时间表可以给出一个更差的运行时间,渐近地,比一个好的,这真的取决于频率。

7-10.(连通)图在什么情况下可以有多棵最小生成树?

7-11.你将如何构建一棵最大生成树(也就是边权重和最大的树)?

7-12.证明最小生成树问题有最优子结构。

7-13.如果图不连通,克鲁斯卡尔的算法会发现什么?你如何修改 Prim 的算法来做同样的事情?

7-14.如果你在一个有向图上运行 Prim 的算法会发生什么?

7-15.对于平面上的 n 个点,没有算法能在最坏的情况下比 loglinear 更快地找到最小生成树(使用欧氏距离)。怎么会这样

7-16.展示如果使用 union by rank,对unionfindm 调用的运行时间将为 O ( m lg n )。

7-17.展示当在遍历过程中使用二进制堆作为优先级队列时,每次遇到节点时添加一次不会影响渐进运行时间。

7-18.在从左到右选择一组区间的最大非重叠子集时,为什么不能使用基于开始次的贪婪算法?

7-19.寻找最大非重叠区间集的算法的运行时间是多少?

7-20.实现调度问题的贪婪解决方案,其中每个任务都有成本和严格的截止日期,并且所有任务都需要相同的时间来执行。

参考

盖尔和沙普利(1962)。大学录取和婚姻的稳定性。美国数学月刊,69(1):9-15。

格雷厄姆,R. L .和地狱,P. (1985 年)。最小生成树问题的历史。 IEEE 计算史上的年鉴,7(1)。

古斯菲尔德和欧文(1989 年)。稳定的婚姻问题:结构和算法。麻省理工学院出版社。

Helman,p .,Moret,B. M. E .和 Shapiro,H. D. (1993 年)。贪婪结构的精确刻画。 SIAM 离散数学杂志,6(2):274-283。

Knuth,D. E. (1996 年)。稳定婚姻及其与其他组合问题的关系:算法数学分析导论。美国数学学会。

Korte,B. H .,洛瓦斯,l .,和施拉德,r .(1991)希腊人。斯普林格出版社。

Nešetřil、米尔科瓦和 nešetřilová(2001 年)。奥塔卡尔·borůvka 论最小生成树问题:1926 年论文、评论、历史的翻译。离散数学,233(1-3):3-36。

皮尔逊博士(2005 年)。变更问题的多项式时间算法。运筹学快报,33(3):231-234。


1 不,不是跑去买漫画书。

2 这个版本问题的创意来自迈克尔·索尔提斯(参见第四章中的参考文献)。

3 为了安全起见,让我强调一下,这种贪婪的解决方案在一般情况下会而不是起作用,使用任意的一组权重。二的不同力量是这里的关键。

4 如果我们单独查看每个对象,这通常被称为 0-1 背包,因为我们可以从每个对象中取 0 或 1。

5 不仅零表示还是并不重要,哪些子树在左边,哪些在右边也不重要。打乱它们对解决方案的最优性没有影响。

6 如果heapq库的未来版本允许你使用一个关键函数,比如在list.sort中,你当然就不再需要这个元组包装了。

7 他们也可能有等于的权重/频率;这并不影响争论。

顺便问一下,你知道得克萨斯州霍夫曼的邮政编码是 77336 吗?

9 实际上,你可以把 Borůvka’s 算法和普里姆的结合起来,得到一个更快的算法。

10 只要我们假设边权重为正,你明白为什么结果不能包含任何循环了吗?

在这种表示和一种两边都有边的表示之间来回穿梭并不困难,但我会把细节留给读者作为练习。

12 我们在排序 m 边,但是我们也知道 mO ( n 2 ),而且(因为图是连通的), m 是ω(n)。因为θ(LGn2)=θ(2 . LGn)=θ(LGn)所以我们得到结果。

13 其实,差异是骗人的。Prim 的算法基于遍历和堆——我们已经讨论过这些概念——而 Kruskal 的算法引入了一种新的不相交集机制。换句话说,简单性的差异主要是视角和抽象的问题。

14 正如我在讨论克鲁斯卡尔算法时提到的,添加和删除这样的冗余反向边是相当容易的,如果你需要这样做的话。

15 因为区间不重叠,所以按起止时间排序是等价的。

16 这个问题的版本可以在 Soltys 的书里找到(见第四章的“参考文献”)和 Cormen 等人的书里找到(见第一章的“参考文献”)。我的证明严格遵循 Soltys 的,而 Cormen 等人选择证明问题形成一个拟阵,这意味着贪婪算法将对它起作用。******

八、纠结的依赖和记忆

两次 ,adv .一次过于频繁。

——比尔斯,A·,魔鬼的字典

你们中的许多人可能知道 1957 年是编程语言的诞生年。对于算法学家来说,今年发生了一件可能更有意义的事情:理查德·贝尔曼出版了他的开创性著作动态编程。虽然贝尔曼的书本质上主要是数学的,根本不是真正针对程序员的(考虑到时间,也许可以理解),但他的技术背后的核心思想为许多非常强大的算法奠定了基础,它们形成了任何算法设计者都需要掌握的坚实的设计方法。

术语动态编程(或者简称为 DP)对于新手来说可能有点混乱。这两个词的用法与大多数人想象的不同。这里的编程指的是做出一系列选择(如“线性编程”),因此与这个术语在电视上的用法比在编写计算机程序上更为相似。动态仅仅意味着事物会随着时间而变化——在这种情况下,每个选择都取决于前一个选择。换句话说,这种“动态主义”与您将要编写的程序没有什么关系,只是对问题类的描述。用贝尔曼自己的话说,“我认为动态编程是一个好名字。这是连国会议员都不会反对的事情。所以我把它当成了我活动的保护伞。” 2

当应用于算法设计时,DP 的核心技术是缓存。你像以前一样递归地/归纳地分解你的问题——但是你允许子问题之间的重叠。这意味着一个简单的递归解决方案可以很容易地达到每个基本情况的指数次数;然而,通过缓存这些结果,这种指数级的浪费可以被削减掉,并且结果通常是令人印象深刻的高效算法和对问题更深入的洞察

通常,DP 算法颠倒递归公式,使其迭代并逐步填充一些数据结构(如多维数组)。另一种选择——我认为特别适合 Python 等高级语言——是直接实现递归公式,但缓存返回值。如果使用相同的参数进行多次调用,结果将直接从缓存中返回。这被称为记忆化

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 注意尽管我认为记忆化使 DP 的基本原理变得清晰,但我在整章中始终如一地重写迭代程序的记忆化版本。虽然记忆化是很好的第一步,可以让您获得更多的洞察力和原型解决方案,但是有些因素(例如有限的堆栈深度和函数调用开销)在某些情况下可能会使迭代解决方案更可取。

DP 的基本思想非常简单,但是需要一点时间来适应。根据另一位这方面的权威埃里克·v·德纳多的说法,“大多数初学者觉得它们都很奇怪,很陌生。”我会尽最大努力坚持核心思想,不迷失在形式主义中。此外,通过将重点放在递归分解和记忆化上,而不是迭代 DP 上,我希望到目前为止我们在本书中所做的所有工作的联系应该非常清楚。

在进入本章之前,这里有一个小难题:假设你有一个数字序列,你想找到它的最长的递增(或者,更确切地说是非递减 ) 子序列——或者其中的一个,如果有更多的话。子序列由按原始顺序排列的元素子集组成。因此,例如,在序列[3, 1, 0, 2, 4]中,一个解将是[1, 2, 4]。在清单 8-1 中,你可以看到这个问题的一个相当紧凑的解决方案。它使用高效的内置函数来完成工作,比如来自itertoolssortedcombinations,所以开销应该很低。然而,该算法是一个简单的强力解决方案:生成每个子序列,并逐个检查它们是否已经排序。在最坏的情况下,这里的运行时间显然是指数级的。

写一个强力解决方案对理解问题是有用的,甚至可能有助于获得一些更好算法的想法;如果你能找到几种改进的方法,我不会感到惊讶。然而,实质性的改进可能有点困难。比如,你能找到一个二次算法吗(有点挑战性)?对数线性的呢(相当难)?一会儿我会告诉你怎么做。

清单 8-1 。最长增长子序列问题的一个简单解法

from itertools import combinations

def naive_lis(seq):
    for length in range(len(seq), 0, -1):       # n, n-1, ... , 1
        for sub in combinations(seq, length):   # Subsequences of given length
            if list(sub) == sorted(sub):        # An increasing subsequence?
                return sub                      # Return it!

不要重复你自己

你可能听说过干原则:不要重复自己。它主要用于你的代码,意思是你应该避免多次编写相同(或几乎相同)的代码,依靠各种形式的抽象来避免剪切粘贴编码。这当然是编程最重要的基本原则之一,但这不是我在这里谈论的内容。本章的基本思想是避免你的算法重复出现。这个原理非常简单,甚至很容易实现(至少在 Python 中是这样),但是随着我们的进展,您将会看到这里的魔力非常深刻。

但是让我们从几个经典开始:斐波那契数和帕斯卡三角。你可能以前遇到过这些,但是“每个人”都使用它们的原因是它们很有教育意义。不要害怕——我将在这里对解决方案进行扭曲,我希望这对大多数人来说是新的。

Fibonacci 数列被递归地定义为从两个 1 开始,随后的每一个数都是前两个数的和。这很容易实现为一个 Python 函数 3 :

>>> def fib(i):
...     if i < 2: return 1
...     return fib(i-1) + fib(i-2)

让我们试一试:

>>> fib(10)
89

似乎是正确的。让我们大胆一点:

>>> fib(100)

啊哦。好像挂了。显然出了问题。我将会给你一个解决方案,对于这个特殊的问题来说,这个解决方案绝对是多余的,但是实际上你可以用它来解决本章中的所有问题。它是清单 8-2 中的简洁的小memo函数。这个实现使用嵌套的作用域来给包装的函数提供内存——如果你愿意,你可以很容易地使用带有cachefunc属性的类来代替。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 注意在 Python 标准库的functools模块中其实有一个等价的 decorator,叫做lru_cache(从 Python 3.2 开始可用,或者在 Python 2.7 4 functools32包中)。如果将它的maxsize参数设置为None,它将作为一个完整的记忆装饰器工作。它还提供了一个cache_clear方法,您可以在算法的两次使用之间调用它。

清单 8-2 。纪念装饰家

from functools import wraps

def memo(func):
    cache = {}                                  # Stored subproblem solutions
    @wraps(func)                                # Make wrap look like func
    def wrap(*args):                            # The memoized wrapper
        if args not in cache:                   # Not already computed?
            cache[args] = func(*args)           # Compute & cache the solution
        return cache[args]                      # Return the cached solution
    return wrap                                 # Return the wrapper

在进入memo实际做什么之前,让我们试着使用它:

>>> fib = memo(fib)
>>> fib(100)
573147844013817084101

嘿,成功了!但是……为什么呢?

一个记忆的函数 5 的想法是缓存它的返回值。如果您用相同的参数再次调用它,它将简单地返回缓存的值。您当然可以将这种缓存逻辑放在您的函数中,但是memo函数是一个更可重用的解决方案。它甚至被设计成用作装饰器 6 :

>>> @memo
... def fib(i):
...     if i < 2: return 1
...     return fib(i-1) + fib(i-2)
...
>>> fib(100)
573147844013817084101

正如你所看到的,简单地用@memo标记fib就可以大大减少的运行时间。我仍然没有真正解释如何或为什么。

事情是,斐波纳契数列的递归公式有两个子问题,它有点像看起来像一个分治的事情。主要区别在于子问题有纠缠不清的依赖关系。或者,换句话说,我们面临和重叠的子问题。这在斐波纳契数的这个相当愚蠢的相对关系中可能更加清楚:2 的幂的递归公式:

>>> def two_pow(i):
...     if i == 0: return 1
...     return two_pow(i-1) + two_pow(i-1)
...
>>> two_pow(10)
1024
>>> two_pow(100)

还是很恐怖。试试加@memo,瞬间得到答案。,你可以尝试做如下的改变,这实际上相当于:

>>> def two_pow(i):
...     if i == 0: return 1
...     return 2*two_pow(i-1)
...
>>> print(two_pow(10))
1024
>>> print(two_pow(100))
1267650600228229401496703205376

我已经将递归调用的数量从两个减少到一个,从指数运行时间减少到线性运行时间(分别对应于递归 3 和 1,来自表 3-1 )。神奇的是,这相当于记忆化版本的功能。第一个递归调用将正常执行,一直到底部(i == 0)。然而,在此之后的任何调用都将直接进入缓存,只给出恒定量的额外工作。图 8-1 说明了不同之处。如你所见,当多个层次上存在重叠的子问题(即相同数量的节点)时,冗余计算会迅速变成指数级。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 8-1 。递归树显示记忆化的影响。节点标签是子问题参数

我们来解决一个稍微有用一点的问题 7 :计算二项式系数(见第三章)。 C ( nk )的组合意义是你可以从一组大小为 n 的集合中得到的 k 大小的子集的个数。第一步,几乎总是,是寻找某种形式的简化或递归分解。在这种情况下,我们可以使用一个你在使用动态编程 8 时会多次看到的想法:我们通过以某个元素是否被包含为条件来分解问题。也就是说,如果元素包含在中,我们得到一个递归调用,如果元素不包含在中,我们得到另一个递归调用。(你知道如何用这种方式解读two_pow吗?参见练习 8-2。)

为了做到这一点,我们通常认为元素是有序的,因此对 C ( nk )的单个评估只会担心是否应该包括元素编号 n 。如果包含在中,我们就要统计剩余 n -1 个元素的 k -1 个大小的子集,简单来说就是 C ( n -1, k -1)。如果不包括,我们就必须寻找大小为 kC ( n -1, k )的子集。换句话说:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

此外,我们还有以下基本情况: C ( n ,0) = 1 用于单个空集, C (0, k ) = 0, k > 0 用于空集的非空集。

这种递归公式对应于通常被称为“??”的帕斯卡三角(??)(以其发现者之一布莱士·帕斯卡的名字命名),尽管它是由伟大的中国数学家朱世杰在 1303 年首次发表的,他声称它是在第二个千年早期被发现的。图 8-2 展示了如何将二项式系数放置在一个三角形图案中,使得每个数字都是上面两个数字的和。这意味着(从零开始计数)对应于 n ,而(该行左边从零开始计数的单元格编号)对应于 k 。例如,值 6 对应于 C (4,2),可以计算为 C (3,1) + C (3,2) = 3 + 3 = 6。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 8-2 。帕斯卡三角形

解释该模式的另一种方式(如图所示)是路径计数。如果你只向下走,越过虚线,从最上面的单元格到其他每个单元格,有多少条路径?这将我们引向相同的循环——我们可以从上面左边的单元格或者从上面右边的单元格。因此,路径的数量是两者之和。这意味着,如果你在向下的路上随机选择左/右,这些数字与通过它们的概率成正比。这正是在日本游戏弹球或 Plinko 中发生的事情价格合适。在那里,一个球从顶部落下,落在一些规则网格(例如图 8-2 中的六边形网格的交叉点)中的球钉之间。我将在下一节回到这个路径计数——它实际上比现在看起来更重要。

C ( nk )的代码很琐碎:

>>> @memo
>>> def C(n,k):
...     if k == 0: return 1
...     if n == 0: return 0
...     return C(n-1,k-1) + C(n-1,k)
>>> C(4,2)
6
>>> C(10,7)
120
>>> C(100,50)
100891344545564193334812497256

不过,你应该在有和没有@memo的情况下都尝试一下,让自己相信这两个版本之间的巨大差异。通常,我们将缓存与一些常数因子加速联系起来,但这完全是另一个大概。对于我们将要考虑的大多数问题,记忆化意味着指数和多项式运行时间的不同。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 注意本章中的一些记忆算法(特别是背包问题的算法,以及本节中的算法)是伪多项式,因为我们得到的多项式运行时间是输入中的之一的函数,而不仅仅是它的大小。请记住,这些数字的范围与其编码大小(即用于编码的位数)呈指数关系。

事实上,在大多数动态编程的演示中,没有使用记忆函数。递归分解是算法设计的一个重要步骤,但它通常被视为一个数学工具,而实际实现是“颠倒的”——一个迭代版本。正如你所看到的,有了像@memo decorator 这样的简单帮助,记忆化的解决方案可以变得非常简单,我认为你不应该回避它们。它们将帮助你摆脱讨厌的指数爆炸,而不会妨碍你漂亮的递归设计。

然而,正如之前所讨论的(在第四章,你可能有时想要重写你的代码以使它迭代。这可以使它更快,并且避免在递归深度过大时耗尽堆栈。还有另一个原因:迭代版本通常基于特殊构造的缓存,而不是我的@memo中使用的通用“参数元组键化的字典”。这意味着您有时可以使用更有效的结构,比如 NumPy 的多维数组,可能与 Cython 结合使用(参见附录 A),或者甚至只是嵌套列表。这种定制的缓存设计使得在更低级的语言中使用 DP 成为可能,而像我们的@memo装饰器这样的通用抽象解决方案通常是不可行的。请注意,尽管这两种技术经常一起使用,但是您当然可以自由地使用带有更通用缓存的迭代解决方案,或者为您的子问题解决方案使用带有定制结构的递归解决方案。

让我们把算法反过来,直接填写帕斯卡三角形。为了简单起见,我将使用一个defaultdict作为缓存;例如,可以随意使用嵌套列表。(另请参见练习 8-4。)

>>> from collections import defaultdict
>>> n, k = 10, 7
>>> C = defaultdict(int)
>>> for row in range(n+1):
...     C[row,0] = 1
...     for col in range(1,k+1):
...         C[row,col] = C[row-1,col-1] + C[row-1,col]
...
>>> C[n,k]
120

基本上同样的事情正在发生。主要的区别是,我们需要找出缓存中哪些单元格需要填充,我们需要找到一个安全的顺序来完成它,这样当我们将要计算C[row,col]时,单元格C[row-1,col-1]C[row-1,col]已经被计算了。有了记忆化的函数,我们就不用担心这两个问题:它会递归地计算任何需要的东西。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 提示可视化带有一两个子问题参数的动态规划算法(比如这里的 nk )的一个有用方法是使用一个(真实的或想象的)电子表格。例如,尝试在电子表格中计算二项式系数,方法是用 1 填充第一列,用 0 填充第一行的其余部分。将公式=A1+B1 放入单元格 B2,并将其复制到其余单元格。

有向无环图中的最短路径

动态规划的核心是顺序决策问题的思想。你做的每一个选择都会导致一个新的局面,你需要找到让你达到你想要的局面的最佳选择顺序。这类似于贪婪算法的工作方式——只是它们依赖于哪个选择现在看起来最好*,而一般来说,你必须不那么短视,并考虑未来的影响。*

*典型的顺序决策问题是在有向无环图中找到从一个节点到另一个节点的路径。我们将决策过程的可能状态表示为单个节点。外边缘代表我们在每种状态下可能做出的选择。边是有权重的,找到一组最优选择就相当于找到一条最短路径。图 8-3 给出了一个 DAG 的例子,其中从节点 a 到节点 f 的最短路径被突出显示。我们应该如何着手寻找这条道路呢?

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 8-3 。拓扑排序的有向无环图。边标有权重,从 a 到 f 的最短路径已经突出显示

应该清楚这是一个连续的决策过程。你从节点 a 开始,你可以选择沿着边到 b 或者沿着边到 f 。一方面,edge to b 看起来很有希望,因为它太便宜了,而 one to f 很诱人,因为它直奔目标。然而,我们不能采用这样简单的策略。例如,已经构建了该图,以便沿着我们访问的每个节点的最短边,我们将沿着最长的路径。

和前面几章一样,我们需要归纳思考。让我们假设我们已经知道我们可以移动到的所有节点的答案。假设从一个节点 v 到我们的终点节点的距离是 d ( v )。设 edge ( uv )的边缘权重为 w ( uv )。然后,如果我们在节点 u 中,我们已经(通过归纳假设)知道了每个邻居 vd ( v ),所以我们只需要沿着边到邻居 v ,这最小化了表达式 w ( uv+d( 换句话说,我们最小化第一步和从那里开始的最短路径的总和。

当然,我们并不真的知道所有邻居的 d ( v )的值,但是对于任何电感设计来说,它会通过递归的魔力自行处理。唯一的问题是重叠的子问题。例如,在图 8-3 中,寻找从 bf 的距离需要寻找从例如 df 的最短路径。但是找到从 cf 的最短路径也是如此。我们的情况与斐波那契数列、two_pow或帕斯卡三角形完全相同。如果我们直接实现递归求解,一些子问题将被求解指数倍。而对于那些问题,记忆化的魔力去除了所有冗余,我们最终得到了一个线性时间算法(也就是说,对于 n 节点和 m 边,运行时间为θ(n+m)。

在清单 8-3 的中可以找到一个直接的实现(使用类似边权重函数的 dicts 表示的 dicts)。如果您从代码中删除@memo,您最终会得到一个指数算法(对于相对较小的几乎没有边的图来说,它可能仍然工作得很好)。

清单 8-3 。递归、记忆的 DAG 最短路径

def rec_dag_sp(W, s, t):                        # Shortest path from s to t
    @memo                                       # Memoize f
    def d(u):                                   # Distance from u to t
        if u == t: return 0                     # We're there!
        return min(W[u][v]+d(v) for v in W[u])  # Best of every first step
    return d(s)                                 # Apply f to actual start node

在我看来,清单 8-3 中的实现相当优雅。它直接表达了算法的归纳思想,同时抽象出记忆。然而,这不是表示该算法的经典方式。像在许多其他 DP 算法中一样,这里通常做的是将算法“颠倒”并使其迭代。

DAG 最短路径算法的迭代版本通过一步一步地传播部分解决方案来工作,使用在第四章的中介绍的松弛思想。 9 由于我们表示图的方式(也就是说,我们通常通过出边而不是入边来访问节点),它可以有助于反转归纳设计:与其考虑我们想要去哪里,不如考虑我们想要来自哪里。然后,我们希望确保一旦到达节点 v ,我们已经从所有 v 的前任传播了正确答案。也就是说,我们已经放松了它的内边缘。这就提出了一个问题——我们如何确定我们已经做到了?

知道的方法是按拓扑排列节点,如图 8-3 中的所示。关于递归版本(在清单 8-3 中)的巧妙之处在于不需要单独的拓扑排序。递归隐式执行 DFS,并按照拓扑排序顺序自动执行所有更新*。但是,对于我们的迭代解决方案,我们需要执行单独的拓扑排序。如果你想完全摆脱递归,你可以使用清单 4-10 中的topsort;如果你不介意,你可以使用清单 5-7 中的dfs_topsort(尽管你已经非常接近记忆化递归解决方案)。清单 8-4 中的函数dag_sp向你展示了这个更常见的迭代解决方案。*

*清单 8-4 。DAG 最短路径

def dag_sp(W, s, t):                            # Shortest path from s to t
    d = {u:float('inf') for u in W}             # Distance estimates
    d[s] = 0                                    # Start node: Zero distance
    for u in topsort(W):                        # In top-sorted order...
        if u == t: break                        # Have we arrived?
        for v in W[u]:                          # For each out-edge ...
            d[v] = min(d[v], d[u] + W[u][v])    # Relax the edge
    return d[t]                                 # Distance to t (from s)

迭代算法的思想是,只要我们已经从你的每一个可能的前任(也就是那些在拓扑排序中较早的)中放松了每一条边,我们必然已经放松了中的所有*-边到。利用这一点,我们可以归纳地表明,当我们在外for循环中到达它时,每个节点接收到正确的距离估计。这意味着一旦我们到达目标节点,我们将找到正确的距离。*

找到与这个距离相对应的实际路径也并不难(见练习 8-5)。你甚至可以从开始节点构建整个最短路径树,就像第五章中的遍历树一样。(但是,您必须删除break语句,并一直进行到最后。)注意,一些节点,包括那些在拓扑排序顺序中早于开始节点的节点,可能根本不能到达,并且将保持它们的无限距离。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 注意在这一章的大部分时间里,我专注于寻找一个解决方案的最优,而不需要额外的簿记来重建产生那个值的解决方案。这种方法使演示更简单,但实际上可能不是您想要的。一些练习要求你扩展算法以找到实际的解;你可以在背包问题的最后找到一个例子。

各种 DAG 最短路径

虽然基本算法是相同的,但是有许多方法可以在 DAG 中找到最短路径,并且通过扩展,可以解决大多数 DP 问题。你可以递归地做,用记忆化,或者你可以迭代地做,用放松。对于递归,您可以从第一个节点开始,尝试各种“后续步骤”,然后对剩余部分进行递归,或者如果您的图形表示允许,您可以查看最后一个节点,尝试“之前的步骤”,然后对初始部分进行递归。前者通常更自然,而后者更接近迭代版本中发生的情况。

现在,如果你使用迭代版本,你也有两个选择:你可以从每个节点的中松弛出边*(按照拓扑排序的顺序),或者你可以将所有的边松弛到每个节点的中。后者更明显地产生正确的结果,但是需要通过向后跟随边来访问节点。当你用一个隐式 DAG 处理一些非图形问题时,这并不像看起来那么牵强。(例如,在本章后面讨论的最长增长子序列问题中,查看所有向后的“边”可能是一个有用的视角。)*

向外放松,称为达到,完全等同于你放松所有的边缘。如前所述,一旦你到达一个节点,它所有的内边都会被放松。然而,有了 reaching,你可以做一些递归版本中很难的事情(或者放松 in-edges):修剪。例如,如果您只对查找距离 r 以内的所有节点感兴趣,您可以跳过距离估计值大于 r 的任何节点。你仍然需要访问每一个节点,但是在放松的时候你可能会忽略很多边。不过,这不会影响渐进运行时间(练习 8-6)。

请注意,在 DAG 中查找最短的路径与查找最长的路径非常相似,甚至可以计算 DAG 中两个节点之间的路径的数量。后一个问题正是我们之前对帕斯卡三角所做的;同样的方法也适用于任意的 DAG。不过,对于一般的图形来说,这些事情就不那么容易了。在一般的图中寻找最短的路径有点困难(事实上,第九章专门讨论了这个话题),而寻找最长的路径是一个未解决的问题*(参见第十一章了解更多关于这个的信息)。*

最长增长子序列

尽管在 DAG 中寻找最短路径是典型的 DP 问题,但是您将遇到的许多 DP 问题(可能是大多数)与(显式)图没有任何关系。在这些情况下,您必须自己找出 DAG 或顺序决策过程。或者可能更容易从递归分解的角度考虑它,忽略整个 DAG 结构。在这一节中,我将遵循这两种方法来解决本章开头介绍的问题:寻找最长的非减子序列。(这个问题通常被称为“最长增长子序列”,但是我在这里允许结果中有多个相同的值。)

我们直接进行归纳,以后可以更多的用图的方式来思考。为了进行归纳(或递归分解),我们需要定义我们的子问题——许多 DP 问题的主要挑战之一。在许多与序列相关的问题中,从前缀的角度考虑可能是有用的——我们已经弄清楚了关于前缀我们需要知道的一切,归纳步骤是为另一个元素弄清楚事情。在这种情况下,这可能意味着我们已经为每个前缀找到了最长的递增子序列,但这还不够。我们需要加强我们的归纳假设,这样我们就可以实际执行归纳步骤。相反,让我们试着找出在每个给定位置结束于的最长的递增子序列。

如果我们已经知道如何为第一个 k 位置*,找到它,那么我们如何为位置 k + 1 找到它呢?一旦我们走到这一步,答案就非常简单了:我们只需查看以前的位置,并查看那些元素比当前位置小的位置。在这些序列中,我们选择位于最长子序列末尾的那个。直接递归实现会给我们带来指数级的运行时间,但是记忆化又一次摆脱了指数级的冗余,如清单 8-5 所示。再一次,我已经集中精力寻找解决方案的长度*;扩展代码以找到实际的子序列并不难(练习 8-10)。

清单 8-5 。最长增长子序列问题的记忆递归解法

def rec_lis(seq):                               # Longest increasing subseq.
    @memo
    def L(cur):                                 # Longest ending at seq[cur]
        res = 1                                 # Length is at least 1
        for pre in range(cur):                  # Potential predecessors
            if seq[pre] <= seq[cur]:            # A valid (smaller) predec.
                res = max(res, 1 + L(pre))      # Can we improve the solution?
        return res
    return max(L(i) for i in range(len(seq)))   # The longest of them all

让我们也做一个迭代版本。在这种情况下,差别真的很小——很像图 4-3 中的镜像图。由于递归的工作方式,rec_lis将按顺序(0,1,2 …)解决每个位置的问题。在迭代版本中,我们所需要做的就是用查找来切换递归调用,并将整个事情封装在一个循环中。参见清单 8-6 中的实现。

清单 8-6 。最长增长子序列问题的基本迭代解法

def basic_lis(seq):
    L = [1] * len(seq)
    for cur, val in enumerate(seq):
        for pre in range(cur):
            if seq[pre] <= val:
                L[cur] = max(L[cur], 1 + L[pre])
    return max(L)

我希望你能看到递归版本的相似之处。在这种情况下,迭代版本可能和递归版本一样容易理解。

现在,把它想象成一个 DAG:每个序列元素都是一个节点,并且从每个元素到后面每个更大的元素都有一条隐含的边——也就是说,到一个递增子序列中允许的后继元素(见图 8-4 )。瞧啊!我们现在正在解决 DAG 最长路径问题。这在basic_lis函数中非常清楚。我们没有显式表示的边,所以它必须查看每个先前的元素,看它是否是有效的前置元素,但是如果是,它只是放松 in-edge(这就是带有max表达式的行所做的)。我们是否可以通过在决策过程中使用这个“前一步”(即这个 in-edge 或这个有效的前任)来改进当前位置的解决方案? 10

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 8-4 。一个数字序列和隐含的 DAG,其中每条路径是一个递增的子序列。突出显示了一个最长的递增子序列

正如你所看到的,有不止一种方法来看待大多数 DP 问题。有时你想把重点放在递归分解和归纳上;有时你宁愿尝试嗅出一些 DAG 结构;有时候,再一次,看看你面前的东西是值得的。在这种情况下,这将是序列。这个算法仍然是二次的,你可能已经注意到了,我把它叫做基本 _lis …那是因为我还有另一个锦囊妙计。

该算法中的主要时间消耗是检查先前的元素,以在那些有效的前置元素中找到最佳的。你会发现在一些 DP 算法中就是这种情况——内部循环致力于线性搜索。如果是这种情况,可能值得尝试用一个二进制搜索来代替它。在这种情况下,这怎么可能一点都不明显,但是简单地知道我们在寻找什么——我们试图做什么——有时会有所帮助。我们试图做某种形式的簿记,这将让我们在寻找最佳前任时执行二分法。

一个至关重要的观点是,如果不止一个前导终止长度为 m 的子序列,我们使用哪一个都没关系——它们都会给我们一个最佳答案。比如说,我们想只保留其中的一个人;我们应该保留哪一个?唯一安全的选择是保留其中最小的一个,因为这不会错误地阻止任何后来的元素在其上构建。让我们归纳地说,在某一点,我们有一个端点序列end,其中end[idx]是我们看到的长度idx+1递增的子序列中最小的端点(我们从 0 开始索引)。因为我们是在序列上迭代,所以这些都会比我们当前的值val早发生。我们现在需要的是一个扩展end的归纳步骤,找出如何给它加上val。如果我们能做到这一点,在算法的最后len(end)会给我们最终的答案——最长递增子序列的长度。

end序列必然不会减少(练习 8-8)。我们想找到最大的idx这样的end[idx-1] <= val。这将为我们提供val所能贡献的最长序列,因此在end[idx]处添加val将会改善当前结果(如果我们需要添加的话)或者减少该位置处的当前端点值。添加之后,end序列仍然具有之前的属性,因此感应是安全的。好消息是——我们可以使用(超快的)bisect函数找到idx11 你可以在清单 8-7 中找到最终代码。如果你愿意,你可以去掉一些对bisect的调用(练习 8-9)。如果你想提取实际的序列,而不仅仅是长度,你需要增加一些额外的簿记(练习 8-10)。

清单 8-7 。最长增长子序列

from bisect import bisect

def lis(seq):                                   # Longest increasing subseq.
    end = []                                    # End-values for all lengths
    for val in seq:                             # Try every value, in order
        idx = bisect(end, val)                  # Can we build on an end val?
        if idx == len(end): end.append(val)     # Longest seq. extended
        else: end[idx] = val                    # Prev. endpoint reduced
    return len(end)                             # The longest we found

这就是最长的增长子序列问题。在我们深入一些众所周知的动态编程的例子之前,先回顾一下我们到目前为止所看到的内容。用 DP 解题的时候,还是用递归分解或者归纳思维。你仍然需要证明一个最优或正确的全局解依赖于你的子问题的最优或正确的解(最优子结构,或最优性原理)。与分而治之的主要区别在于,你可以有重叠的子问题。事实上,这种重叠是发展伙伴关系存在的理由。你甚至可以说你应该寻找一个有重叠的分解*,因为消除重叠(有记忆)会给你一个有效的解决方案。除了“带重叠的递归分解”的观点之外,您通常可以将 DP 问题视为顺序决策问题,或者视为在 DAG 中寻找特殊(例如,最短或最长)路径。这些视角都是等价的,但是可以不同的拟合各种问题。*

序列比较

比较序列的相似性是许多分子生物学和生物信息学中的一个关键问题,其中涉及的序列通常是 DNA、RNA 或蛋白质序列。除了其他用途之外,它还被用来构建系统发育(即进化)树——哪个物种是哪个物种的后代?它还可以用来寻找患有某种疾病的人或对某种特定药物敏感的人共有的基因。不同种类的序列或字符串比较也与许多种类的信息检索相关。例如,你可能搜索“空间之外的颜色”,并期望找到“空间之外的颜色”——为了实现这一点,你使用的搜索技术需要以某种方式知道这两个序列足够相似。

有几种比较序列的方法,其中许多比人们想象的更相似。例如,考虑寻找两个序列之间的最长公共子序列 (LCS)以及寻找它们之间的编辑距离的问题。LCS 问题类似于最长递增子序列问题——除了我们不再寻找递增子序列。我们正在寻找也出现在第二个序列中的子序列。(比如星际行者 12星巴克的 LCS 就是斯塔克。编辑距离(也称为 Levenshtein 距离)是将一个序列变成另一个序列所需的最小编辑操作(插入、删除或替换)次数。(例如,企业次棱镜的编辑距离为 4。)如果我们不允许替换,这两者实际上是等价的。最长的公共子序列是当以尽可能少的编辑将一个序列编辑到另一个序列中时保持不变的部分。在任一序列中,每隔一个字符必须插入或删除。因此,如果序列的长度是 mn 并且最长公共子序列的长度是 k ,则没有替换的编辑距离是 m+n- 2 k

这里我将重点讨论 LCS,把编辑距离留给一个练习(练习 8-11)。同样,和以前一样,我将把自己限制在解决方案的成本上(即 LCS 的长度)。按照标准模式增加一些额外的簿记,让你找到底层结构(练习 8-12)。对于一些相关的序列比较问题,请参见本章末尾附近的“如果你好奇…”一节。

尽管如果你没有接触过本书中的任何技术,设计一个多项式算法来寻找最长的公共子序列会非常困难,但是使用我在本章中讨论的工具却非常简单。至于所有的 DP 问题,关键是设计一组我们可以相互关联的子问题(也就是一个纠缠依赖的递归分解)。将子问题集合想象成由一组索引等参数化的通常会有所帮助。这些就是我们的归纳变量。 13 在这种情况下,我们可以使用序列的前缀*(就像我们在最长递增子序列问题中使用单个序列的前缀一样)。任何一对前缀(由它们的长度标识)都会产生一个子问题,我们希望在一个子问题图(即依赖 DAG)中把它们联系起来。*

假设我们的序列是 ab 。如同一般的归纳思维一样,我们从两个任意的前缀开始,通过它们的长度 ij 来识别。我们需要做的是将这个问题的解决方案与其他一些问题联系起来,其中至少有一个前缀更小。直觉上,我们想要从任一序列的末尾临时砍掉一些元素,通过我们的归纳假设解决由此产生的问题,然后将这些元素粘回去。如果我们沿着任一序列坚持弱归纳(减一),我们会得到三种情况:从 a 、从 b 或从两者中截取最后一个元素。如果我们只从一个序列中删除一个元素,它将被排除在 LCS 之外。然而,如果我们从两个中去掉最后一个,会发生什么取决于这两个元素是否等于*。如果是的话,我们可以用它们来扩展 LCS 一个!(如果不是,他们对我们就没用了。)

事实上,这给了我们整个算法(除了几个细节)。我们可以将 ab 的 LCS 长度表示为前缀长度 ij 的函数,如下所示:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

换句话说,如果任一前缀为空,则 LCS 为空。如果最后的元素相等,则该元素是 LCS 的最后一个元素,我们递归地找到剩余部分(即前面的部分)的长度。如果最后的元素不等于,我们只有两个选择:要么切断 a 要么切断 b 的元素。因为可以自由选择,所以取两个结果中最好的。清单 8-8 给出了这个递归解决方案的一个简单的记忆实现。

清单 8-8 。LCS 问题的记忆递归解法

def rec_lcs(a,b):                               # Longest common subsequence
    @memo                                       # L is memoized
    def L(i,j):                                 # Prefixes a[:i] and b[:j]
        if min(i,j) < 0: return 0               # One prefix is empty
        if a[i] == b[j]: return 1 + L(i-1,j-1)  # Match! Move diagonally
        return max(L(i-1,j), L(i,j-1))          # Chop off either a[i] or b[j]
    return L(len(a)-1,len(b)-1)                 # Run L on entire sequences

这种递归分解可以很容易地被看作是一个动态决策过程(我们是从第一个序列、第二个序列还是两者中砍掉一个元素?),它可以表示为一个 DAG(见图 8-5 )。我们从由完整序列表示的节点开始,并尝试找到返回到表示两个空前缀的节点的最长路径。重要的是要清楚这里的“最长路径”是什么,也就是说,边权重是多少。我们可以扩展 LCS(这是我们的目标)的唯一时间是当我们砍掉两个相同的元素时,当节点被放置在网格中时,由对角线的 DAG 边表示,如图图 8-5 。这些边的权重为 1,而其他边的权重为零。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 8-5 。LCS 问题的基本有向无环图,其中水平边和垂直边的成本为零。从一个角到另一个角的最长路径(即对角线最多的路径)会高亮显示,其中对角线代表 LCS

出于通常的原因,您可能想要反转解决方案并使其迭代。清单 8-9 给出了一个版本,通过只保存 DP 矩阵的当前行和前一行来节省内存。(不过,你可以多存一点;参见练习 8-13。)注意cur[i-1]对应递归版本中的L(i-1,j),而pre[i]pre[i-1]分别对应L(i,j-1)L(i-1,j-1)

清单 8-9 。最长公共子序列(LCS)的迭代解法

def lcs(a,b):
    n, m = len(a), len(b)
    pre, cur = [0]*(n+1), [0]*(n+1)             # Previous/current row
    for j in range(1,m+1):                      # Iterate over b
        pre, cur = cur, pre                     # Keep prev., overwrite cur.
        for i in range(1,n+1):                  # Iterate over a
            if a[i-1] == b[j-1]:                # Last elts. of pref. equal?
                cur[i] = pre[i-1] + 1           # L(i,j) = L(i-1,j-1) + 1
            else:                               # Otherwise...
                cur[i] = max(pre[i], cur[i-1])  # max(L(i,j-1),L(i-1,j))
    return cur[n]                               # L(n,m)

背包反击

在第七章中,我承诺给你一个整数背包问题的解决方案,有有界和无界两个版本。是时候兑现这个承诺了。

回想一下,背包问题涉及一组对象,每个对象都有一个权重和一个。我们的背包还有一个容量。我们想在背包里装满物品,这样(1)总重量小于或等于容量,并且(2)总价值最大化。假设物体 i 有重量 w [ i ]和值 v [ i ]。让我们先做无界的——这稍微简单一点。这意味着每个对象都可以使用任意多次。

我希望你开始从本章的例子中看到一种模式。这个问题正好符合这个模式:我们需要以某种方式定义子问题,将它们递归地相互关联,然后确保每个子问题只计算一次(通过使用记忆,隐式或显式)。问题的“无界性”意味着使用常见的“in 或 out”思想(尽管我们将在有界版本中使用它)来限制我们可以使用的对象有点困难。相反,我们可以简单地用背包容量参数化我们的子问题,也就是说,使用归纳法。

如果我们说 m ( r )是我们用一个(剩余)容量 r 所能得到的最大值,那么 r 的每一个值都给了我们一个子问题。递归分解基于使用或不使用最后一个单位的能力。如果我们不用,我们有m(r)=m(r-1)。如果我们真的使用它,我们必须选择正确的对象来使用。如果我们选择对象 i (假设它将适合剩余容量),我们将有m(r)=v[I]+m(r-w**I),因为我们将添加 i ,的值

我们可以(再一次)把这看作一个决策过程:我们可以选择是否使用最后一个容量单位,如果我们确实使用了它,我们可以选择添加哪个对象。因为我们可以选择任何我们想要的方式,我们只是在所有的可能性中取最大值。记忆化处理了递归定义中的指数冗余,如清单 8-10 所示。

[清单 8-10 。无界整数背包问题的记忆递归解法

def rec_unbounded_knapsack(w, v, c):            # Weights, values and capacity
    @memo                                       # m is memoized
    def m(r):                                   # Max val. w/remaining cap. r
        if r == 0: return 0                     # No capacity? No value
        val = m(r-1)                            # Ignore the last cap. unit?
        for i, wi in enumerate(w):              # Try every object
            if wi > r: continue                 # Too heavy? Ignore it
            val = max(val, v[i] + m(r-wi))      # Add value, remove weight
        return val                              # Max over all last objects
    return m(c)                                 # Full capacity available

这里的运行时间取决于容量和对象的数量。每个记忆调用m(r)只计算一次,这意味着对于容量 c ,我们有θ(c)个调用。每个调用都经过所有的 n 对象,所以得到的运行时间是θ(cn)。(这可能会在接下来的等价迭代版本中更容易看到。另请参见练习 8-14,了解提高运行时间常数的方法。)注意,这是而不是多项式运行时间,因为 c 可以随着实际问题大小(位数)呈指数增长。如前所述,这种运行时间被称为伪多项式,对于合理大小的容量,该解决方案实际上非常有效。

清单 8-11 显示了该算法的迭代版本。正如您所看到的,这两个实现实际上是相同的,除了递归被替换为一个for循环,缓存现在是一个列表。 14

清单 8-11 。无界整数背包问题的迭代解法

def unbounded_knapsack(w, v, c):
    m = [0]
    for r in range(1,c+1):
        val = m[r-1]
        for i, wi in enumerate(w):
            if wi > r: continue
            val = max(val, v[i] + m[r-wi])
        m.append(val)
    return m[c]

现在让我们来看看可能更著名的背包问题——0-1 背包问题。在这里,每个对象最多只能使用一次。(您可以很容易地将它扩展到多次,要么稍微调整一下算法,要么在问题实例中多次包含同一个对象。)这是一个在实际情况中经常出现的问题,在第七章中讨论过。如果你玩过有库存系统的电脑游戏,我肯定你知道这有多令人沮丧。你刚刚杀死了一些强大的怪物,并找到了一堆战利品。你试着捡起来,但是发现你的负担太重了。现在怎么办?哪些物品你应该保留,哪些应该留下?

这个版本的问题和无界的很像。主要的区别是,我们现在为子问题添加了另一个参数:除了限制容量,我们还添加了“in 或 out”的概念,并限制了允许使用的对象数量。或者,更确切地说,我们指定哪个对象(按顺序)是“当前正在考虑的”,并且我们使用强归纳,假设所有子问题,其中我们或者考虑较早的对象,具有较低的容量,或者两者都可以递归地解决。

现在我们需要将这些子问题联系起来,并从子解决方案中构建一个解决方案。设 m ( kr )是我们用前 k 个对象和剩余容量 r 所能拥有的最大值。那么,很明显,如果 k = 0 或者 r = 0,我们就会得到 m ( kr ) = 0。对于其他情况,我们必须再次审视我们的决定是什么。对于这个问题,决策比无界问题简单;我们只需要考虑是否要包含最后一个对象, i = k -1。如果我们没有,我们就会有 m ( kr)=m(k-1, r )。实际上,我们只是“继承”了尚未考虑 i 的情况下的最优值。注意,如果w[I>r,我们别无选择,只能放下物体。

但是,如果对象足够小,我们可以包括它,这意味着 m ( kr)=v[I]+m(k-1,r-w**I),这与无界情况非常相似,除了额外的情况因为我们可以自由选择是否包含对象,所以我们尝试了两个选项,并使用两个结果值中的最大值。同样,记忆消除了指数冗余,我们最终得到类似于清单 8-12 中的代码。

[清单 8-12 。0-1 背包问题的记忆递归解法

def rec_knapsack(w, v, c):                      # Weights, values and capacity
    @memo                                       # m is memoized
    def m(k, r):                                # Max val., k objs and cap r
        if k == 0 or r == 0: return 0           # No objects/no capacity
        i = k-1                                 # Object under consideration
        drop = m(k-1, r)                        # What if we drop the object?
        if w[i] > r: return drop                # Too heavy: Must drop it
        return max(drop, v[i] + m(k-1, r-w[i])) # Include it? Max of in/out
    return m(len(w), c)                         # All objects, all capacity

在像 LCS 这样的问题中,简单地寻找一个解的值可能是有用的。对 LCS 来说,最长公共子序列的长度给了我们两个序列有多相似的概念。然而,在许多情况下,您希望找到产生最佳成本的实际解决方案。清单 8-13 中的迭代背包版本构造了一个额外的表,称为P,因为它的工作有点像遍历(第五章)和最短路径算法(第九章)中使用的前任表。0-1 背包解的两个版本与无界解具有相同的(伪多项式)运行时间,即θ(cn)。

清单 8-13 。0-1 背包问题的迭代解法

def knapsack(w, v, c):                          # Returns solution matrices
    n = len(w)                                  # Number of available items
    m = [[0]*(c+1) for i in range(n+1)]         # Empty max-value matrix
    P = [[False]*(c+1) for i in range(n+1)]     # Empty keep/drop matrix
    for k in range(1,n+1):                      # We can use k first objects
        i = k-1                                 # Object under consideration
        for r in range(1,c+1):                  # Every positive capacity
            m[k][r] = drop = m[k-1][r]          # By default: drop the object
            if w[i] > r: continue               # Too heavy? Ignore it
            keep = v[i] + m[k-1][r-w[i]]        # Value of keeping it
            m[k][r] = max(drop, keep)           # Best of dropping and keeping
            P[k][r] = keep > drop               # Did we keep it?
    return m, P                                 # Return full results

既然背包函数返回了更多的信息,我们可以用它来提取实际包含在最优解中的对象集。例如,您可以这样做:

>>> m, P = knapsack(w, v, c)
>>> k, r, items = len(w), c, set()
>>> while k > 0 and r > 0:
...     i = k-1
...     if P[k][r]:
...         items.add(i)
...         r -= w[i]
...     k -= 1

换句话说,通过简单地保留一些关于所做选择的信息(在这种情况下,保留或删除考虑中的元素),我们可以逐渐将自己从最终状态追溯到初始条件。在这种情况下,我从最后一个对象开始,检查P[k][r]看它是否包含在内。如果是,我从r中减去它的重量;如果不是,我就不去管r(因为我们仍有全部可用容量)。在这两种情况下,我都减少了k,因为我们已经看完了最后一个元素,现在想看看倒数第二个元素(具有更新的容量)。你可能想说服自己,这个回溯操作有一个线性的运行时间。

同样的基本思想可以用在本章的所有例子中。除了给出的核心算法(通常只计算最优的,您可以跟踪每一步做出了什么选择,然后在找到最优值后返回。

二元序列分割

在结束本章之前,让我们看一下另一种典型的 DP 问题,其中一些序列以某种方式被递归划分。你可以认为这是给序列加上括号,这样我们就可以从,比如,ABCDE 到((AB)((CD)E))。这有几个应用,例如下面的:

  • 矩阵链乘法:我们有一个矩阵序列,我们想把它们全部相乘得到一个单一的矩阵。我们不能交换它们(矩阵乘法是不可交换的),但我们可以把括号放在我们想要的地方,这可能会影响所需的运算数量。我们的目标是找到括号(唷!)给出最低数量的操作。
  • 解析任意上下文无关语言 : 16 任何上下文无关语言的语法都可以重写为乔姆斯基范式,其中每个产生式规则要么产生一个终结符、空字符串,要么产生一对非终结符 ABAB 。解析一个字符串基本上等同于设置括号,就像矩阵例子一样。每个带括号的基团代表一个非末端基团。
  • 最优搜索树 : 这是霍夫曼问题的一个更难的版本。目标是相同的——最小化预期的遍历深度——但是因为它是一个搜索树,我们不能改变叶子的顺序,贪婪算法不再有效。同样,我们需要的是一个括号,对应于树形结构。 17

这三个应用非常不同,但问题本质上是相同的:我们希望分层分割序列,以便每个片段包含两个其他片段,我们希望找到这样一种分区,它可以优化一些成本或值(在解析的情况下,值只是“有效”/“无效”)。递归分解就像分治算法一样工作,如图 8-6 所示。在当前间隔内选择分裂点,产生两个子间隔,这两个子间隔被递归地划分。如果我们要创造一个基于排序序列的平衡的二叉查找树,那就是全部了。使用中间的元素(或者两个中间元素中的一个,对于偶数长度的区间)作为分割点(即根),递归地创建平衡的左右子树。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 8-6 。递归序列分割应用于最佳搜索树。区间中的每个根产生两个子树,对应于左右子区间的最佳划分

现在,我们将不得不加强我们的游戏,虽然,因为分裂点没有给出,就像平衡分治的例子。不,现在我们需要尝试多个分割点,选择最好的一个。事实上,在一般情况下,我们需要尝试每一个可能的分裂点。这是一个典型的 DP 问题——在某些方面就像在 Dag 中寻找最短路径一样。DAG 最短路径问题封装了 DP 的顺序决策视角;这个序列分解问题体现了“带重叠的递归分解”的观点。子问题是各种各样的区间,除非我们记住我们的递归,否则它们将被解决指数倍。还要注意,我们已经得到了最优子结构:如果我们最初在最优(或正确)点分割序列,那么两个新片段必须被最优分割,这样我们才能得到最优(正确)解。 18

作为一个具体的例子,让我们用最优搜索树。 19 正如我们在第七章中构建霍夫曼树时,每个元素都有一个频率,我们想要最小化一个二叉查找树的期望遍历深度(或搜索时间)。但是在这种情况下,输入是排序的,我们不能改变它的顺序。为了简单起见,让我们假设每个查询都是针对树中实际存在的一个元素。(参见练习 8-19 了解解决方法。)归纳思考,我们只需要找到正确的根节点,两个子树(在更小的区间上)就会自己搞定(见图 8-6 )。再一次,为了简单起见,让我们只考虑最优成本的计算。如果您想要提取实际的树,您需要记住哪些子树根产生了最优子树成本(例如,将它存储在root[i,j])。

现在我们需要弄清楚递归关系;假设我们知道子树的成本,我们如何计算给定根的成本?单个节点的贡献类似于霍夫曼树中的贡献。然而,在那里,我们只处理树叶,成本是预期的深度。对于最优搜索树,我们可以以任何节点结束。此外,为了不使根的成本为零,让我们计算一下预期访问的节点数(即预期深度+ 1)。节点 v 的贡献则是p(v)×(d(v)+1,其中 p ( v )是其相对频率, d ( v )是其深度,我们对所有节点求和得到总成本。(这正好是p(vd(v)的 1 +总和,因为 p ( v )总和为 1。)

e(i,j)为区间[i:j]的期望搜索成本。如果我们选择r作为我们的根,我们可以将成本分解为e(i,j) = e(i,r) + e(r+1,j) + something。对e的两次递归调用代表了在每个子树中继续搜索的预期成本。但是,缺少的something是什么呢?我们必须加上p[r],寻找根的概率,因为这将是它的预期成本。但是我们如何解释这两个子树的额外边呢?这些边将增加子树中每个节点的深度,这意味着除了根之外的每个节点 v 的每个概率p[v]都必须添加到结果中。但是,嘿——正如所讨论的,我们也将增加p[r]!换句话说,我们需要将区间中所有节点的概率相加。给定根r的一个相对简单的递归表达式可能如下:

e(i,j) = e(i,r) + e(r+1,j) + sum(p[v] for v in range(i, j))

当然,在最终的解决方案中,我们会尝试range(i, j)中的所有r并选择最大值。尽管还有更大的改进空间:表达式的sum部分将对二次方数量的重叠区间求和(每个可能的ij对应一个区间),并且每个和具有线性运行时间。本着 DP 的精神,我们找出重叠部分:我们引入表示总和的记忆函数s(i,j),如清单 8-14 所示。如您所见,s是在常量时间内计算的,假设递归调用已经被缓存(这意味着计算每个 sum s(i,j)花费的时间是常量)。代码的其余部分直接来自前面的讨论。

清单 8-14 。期望最优搜索成本的记忆递归函数

def rec_opt_tree(p):
    @memo
    def s(i,j):
        if i == j: return 0
        return s(i,j-1) + p[j-1]
    @memo
    def e(i,j):
        if i == j: return 0
        sub = min(e(i,r) + e(r+1,j) for r in range(i,j))
        return sub + s(i,j)
    return e(0,len(p))

总而言之,这个算法的运行时间是立方的。渐近上限很简单:有二次方数量的子问题(即区间),我们对每个子问题内部的最佳根进行线性扫描。其实下界也是三次的(这个展示起来有点棘手),所以运行时间是θ(n3)。

至于以前的 DP 算法,迭代版本(清单 8-15 )在许多方面与记忆版本相似。为了以安全的(即拓扑排序的)顺序解决问题,它先解决一定长度的所有区间k,然后再解决更大的区间。为了简单起见,我使用了一个 dict(或者更具体地说,一个自动提供零的defaultdict)。你可以很容易地重写实现来使用,比方说,一个列表的列表。(不过,注意,只需要一个三角半矩阵,而不是完整的 nn 。)

清单 8-15 。最优搜索树问题的迭代解法

from collections import defaultdict

def opt_tree(p):
    n = len(p)
    s, e = defaultdict(int), defaultdict(int)
    for k in range(1,n+1):
        for i in range(n-k+1):
            j = i + k
            s[i,j] = s[i,j-1] + p[j-1]
            e[i,j] = min(e[i,r] + e[r+1,j] for r in range(i,j))
            e[i,j] += s[i,j]
    return e[0,n]

摘要

本章讨论一种称为动态编程(DP)的技术,当子问题的依赖关系纠缠在一起时(也就是说,我们有重叠的子问题),直接的分治解决方案会产生指数级的运行时间。术语动态规划最初应用于一类顺序决策问题,但现在主要用于解决技术,其中执行某种形式的缓存,以便每个子问题只需要计算一次。实现的一种方法是直接在体现算法设计的递归分解(即归纳步骤)的递归函数中加入缓存;这叫做记忆化。不过,反转记忆化的递归实现,将它们变成迭代的实现,通常是有用的。在本章中使用 DP 解决的问题包括计算二项式系数、在 Dag 中寻找最短路径、寻找给定序列的最长递增子序列、寻找两个给定序列的最长公共子序列、利用有限和无限的不可分物品从背包中获得最大价值,以及构建最小化预期查找时间的二分搜索法树。

如果你好奇的话…

好奇?关于动态编程?你很幸运——有很多关于 DP 的 rad 资料。网络搜索应该会出现很多很酷的东西,比如竞争问题。如果您对语音处理或一般的隐马尔可夫模型感兴趣,您可以寻找维特比算法,它是许多种 DP 的一个很好的心理模型。在图像处理领域,可变形轮廓(也称为)是一个很好的例子。

如果你认为序列比较听起来很酷,你可以看看 Gusfield 和 Smyth 的书(参见参考资料)。关于动态时间弯曲和加权编辑距离(本章没有讨论的两个重要变化)的简要介绍,以及对齐的概念,你可以看看 Christian Charras 和 Thierry Lecroq 的优秀教程“序列比较”。 20 对于 Python 标准库中的一些序列比较优度,可以查看difflib模块。如果你安装了 Sage,你可以看看它的背包模块(http://sage.numerical.knapsack)。

关于动态编程最初是如何出现的,请看 Stuart Dreyfus 的论文“理查德·贝尔曼论动态编程的诞生”对于 DP 问题的例子,你真的打不过 Lew 和 Mauch 他们关于这个主题的书讨论了大约 50 个问题。(不过,他们书中的大部分内容都偏重于理论。)

练习

8-1.重写@memo,这样就可以减少一次字典查找。

8-2.如何将two_pow视为采用了“进或出”的理念?“进还是出”对应的是什么?

8-3.写 fibtwo_pow 的迭代版本。这应该允许您使用恒定的内存量,同时保留伪线性时间(即参数 n 中的时间线性)。

8-4.本章计算帕斯卡三角形的代码实际上填充了一个矩形,其中不相关的部分就是简单的零。重写代码以避免这种冗余。

8-5.扩展递归或迭代代码,以查找 DAG 中最短路径的长度,从而返回实际的最佳路径。

8-6.为什么边栏“各种 DAG 最短路径”中讨论的修剪不会对渐近运行时间有任何影响,即使在最好的情况下?

8-7.在面向对象的观察者模式中,几个观察者可以注册一个可观察对象。当可观察值发生变化时,这些观察者就会得到通知。这个想法如何被用来实现 DAG 最短路径问题的 DP 解决方案?它与本章讨论的方法有何相似或不同之处?

8-8.在 lis 函数中,我们如何知道 end 不减?

8-9.你如何减少在 lis平分的调用次数?

8-10.将递归或迭代解扩展到最长递增子序列问题,使其返回实际的子序列。

8-11.实现一个函数来计算两个序列之间的编辑距离,要么使用记忆,要么使用迭代 DP。

8-12.如何找到 LCS 的底层结构(即实际的共享子序列)或编辑距离(编辑操作的序列)?

8-13.如果在lcs中比较的两个序列有不同的长度,你如何利用它来减少函数的内存使用?

8-14.你如何修改 wc 来(潜在地)减少无界背包问题的运行时间?

8-15.清单 8-13 中的背包解决方案让你找到最佳解决方案中包含的实际元素。以类似的方式扩展其他背包解决方案之一。

8-16.当整数背包问题被认为是困难的、未解决的问题时,我们怎么能开发出有效的解决方案呢?

8-17.子集和的问题你也会在第十一章中看到。简而言之,它要求你从一组整数中挑选一个子集,使得这个子集的和等于一个给定的常数, k 。基于动态编程实现这个问题的解决方案。

8-18.与寻找最优二分搜索法树密切相关的一个问题是矩阵链乘法问题,在正文中简要提及。如果矩阵 AB 分别具有维度 n × mm × p ,那么它们的乘积 AB 将具有维度 n × p ,我们用乘积 nmp 来近似计算这个乘法的成本(元素乘法的次数)。设计并实现一个算法,寻找一个矩阵序列的括号,使得执行所有矩阵乘法的总成本尽可能低。

8-19.我们构建的最优搜索树仅仅基于元素的频率。我们可能还想考虑搜索树中不是的各种查询的频率。例如,我们可以获得一种语言中所有单词的频率,但只在树中存储一些单词。你如何考虑这些信息?

参考

Bather,J. (2000 年)。决策理论:对动态规划和顺序决策的介绍。约翰·威利&儿子有限公司

贝尔曼,R. (2003 年)。动态编程。多佛出版公司。

德纳多(2003 年)。动态规划:模型与应用。多佛出版公司。

德雷福斯,S. (2002 年)。理查德·贝尔曼论动态规划的诞生。运筹学,50(1):48-51。

弗雷德曼法学博士(1975 年)。关于最长增长子序列长度的计算。离散数学,11(1):29-35。

古斯菲尔德博士(1997 年)。字符串、树和序列的算法:计算机科学和计算生物学。剑桥大学出版社。

Lew a .和 Mauch h .(2007 年)。动态编程:一种计算工具。斯普林格。

史密斯,B. (2003 年)。字符串中的计算模式。艾迪森-韦斯利。


这一年,约翰·巴科斯的团队发布了第一个 FORTRAN 编译器。许多人认为这是第一个完整的编译器,尽管第一个编译器是在 1942 年由格蕾丝·赫柏编写的。

2 参见理查德·贝尔曼关于动态编程的诞生中的参考文献。

3 有些定义以零和一开头。如果你想那样,就用return i代替return 1。唯一的区别是将序列索引移动一位。

4

5 那是记的,不是记的

6 使用functools模块中的wraps装饰器不会影响功能。它只是让被修饰的函数(比如fib)在包装后保留它的属性(比如它的名字)。有关详细信息,请参见 Python 文档。

7 这仍然只是说明基本原理的一个例子。

8 比如,这个“进还是不进?”方法用于解决背包问题,在这一章的后面。

9 这种方法也与 Prim 的和 Dijkstra 的算法密切相关,还有 Bellman-Ford 算法(见第七章和 9 )。

10 实际上,对于最长增长子序列问题,我们寻找所有路径中最长的*,而不仅仅是任意两个给定点之间的最长路径。*

11 这个极其聪明的小算法是由迈克尔·l·弗雷德曼在 1975 年首次描述的。

12 使用天行者这里给出了稍微没那么有趣的 LCS Sar

13 当然,通常情况下归纳法只对一个整数变量起作用,比如问题大小。该技术可以很容易地扩展到多个变量,但是,归纳假设适用于至少有一个变量更小的情况。

14 你可以用m = [0]*(c+1)预分配列表,如果你愿意,然后用m[r] = val代替append

15 对象索引 i = k -1 只是个方便。我们不妨把 m ( kr)=v**k-1】+m(k-1,r-w[k-1))。

[16 如果解析对你来说完全陌生,可以随意跳过这个要点。或者调查一下?

17 你可以在 Cormen 等人的算法简介的第 15.5 节和 Donald E. Knuth 的计算机编程艺术第 3 卷“排序和搜索”的第 6.2.2 节中找到关于最优搜索树的更多信息(参见第一章的“参考资料”部分)。

18 你当然可以设计某种成本函数,所以这个不是的情况,但是我们不能再使用动态编程(或者,实际上,递归分解)了。感应不起作用。

你应该自己尝试一下矩阵链(练习 8-18),如果你愿意的话,甚至可以尝试一下解析。

20***

您可能感兴趣的与本文相关的镜像

Python3.9

Python3.9

Conda
Python

Python 是一种高级、解释型、通用的编程语言,以其简洁易读的语法而闻名,适用于广泛的应用,包括Web开发、数据分析、人工智能和自动化脚本

考虑可再生能源出力不确定性的商业园区用户需求响应策略(Matlab代码实现)内容概要:本文围绕“考虑可再生能源出力不确定性的商业园区用户需求响应策略”展开,结合Matlab代码实现,研究在可再生能源(如风电、光伏)出力具有不确定性的背景下,商业园区如何制定有效的需求响应策略以优化能源调度和提升系统经济性。文中可能涉及不确定性建模(如场景生成与缩减)、优化模型构建(如随机规划、鲁棒优化)以及需求响应机制设计(如价格型、激励型),并通过Matlab仿真验证所提策略的有效性。此外,文档还列举了大量相关的电力系统、综合能源系统优化调度案例与代码资源,涵盖微电网调度、储能配置、负荷预测等多个方向,形成一个完整的科研支持体系。; 适合人群:具备一定电力系统、优化理论和Matlab编程基础的研究生、科研人员及从事能源系统规划与运行的工程技术人员。; 使用场景及目标:①学习如何建模可再生能源的不确定性并应用于需求响应优化;②掌握使用Matlab进行商业园区能源系统仿真与优化调度的方法;③复现论文结果或开展相关课题研究,提升科研效率与创新能力。; 阅读建议:建议结合文中提供的Matlab代码实例,逐步理解模型构建与求解过程,重点关注不确定性处理方法与需求响应机制的设计逻辑,同时可参考文档中列出的其他资源进行扩展学习与交叉验证。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值