Python 算法教程(四)
十五、附录 D:练习提示
要解决任何问题,有三个问题要问自己:首先,我能做什么?第二,我能读什么?第三,我能问谁呢?
—吉米·罗恩
第一章
1-1.随着机器速度越来越快,内存越来越大,它们可以处理更大的输入。对于糟糕的算法来说,这最终会导致灾难。
1-2.一个简单且相当可扩展的解决方案是对每个字符串中的字符进行排序并比较结果。(理论上,计算字符频率,可能使用collections.Counter ,会更好。)一个真正糟糕的解决方案是比较一个字符串和另一个字符串的所有可能排序。我不能夸大这个解决方案有多差;其实算法不会比这个差太多。随意编码,看看你能检查多大的字谜。我打赌你不会走远的。
第二章
2-1.你将会十次使用同一个列表。绝对不是个好主意。(比如试着运行a[0][0] = 23; print(a)。)
2-2.一种可能性是使用大小为 n 的三个数组;让我们称它们为A、B和C,以及已经分配了多少条目的计数,m. A是实际的数组,B、C和m形成了用于检查的额外结构。仅使用C中的第一个m条目,它们都是B中的索引。当你执行A[i] = x时,你也设置B[i] = m和C[m] = i然后递增m(也就是m += 1)。你能看到这如何给你你需要的信息吗?将此扩展到二维邻接数组应该非常简单。
2-3.如果 f 是 O ( g ,那么就有一个常数 c 这样对于n>n0,f(n)≤CG(n)。这意味着使用常数 1/ c 满足 g 为ω(f)的条件。同样的逻辑反过来也成立。
2-4.让我们看看它是如何工作的。根据定义,blogb n=n。这是一个等式,所以我们可以两边取对数(底数 a )得到 loga(blogbn)= logan。因为 logxy=ylogx(标准对数法则),我们可以把这个写成(logab)(logbn= loga从这个结果得出的结论是,logan和 logbn之间的差别只是一个常数因子(logab),当我们使用渐近符号时,这个常数就消失了。
2-5.我们想弄清楚,随着 n 的增加,不等式kn≥c**nj是否最终成立,对于某些常数 c 。为了简单起见,我们可以设置 c = 1。我们可以取两边的对数(底数 k )(它不会翻转不等式,因为它是一个增函数),留给我们的是找出n≥jlogkn是否在某个点,这是由(增)线性函数支配对数的事实给出的。(你应该自己验证一下。)
2-6.这可以用一个叫做变量替换 的小技巧轻松解决。像练习 1-5 中一样,我们设了一个试探性的不等式,nk≥LGn,想说明它对大的 n 成立。再次,我们取两边的对数,得到kLGn≥LG(LGn)。双重对数可能看起来很可怕,但我们可以非常优雅地避开它。我们不关心指数如何超越多项式,只关心它在某个时刻发生。这意味着我们可以替换我们的变量——我们设置 m = lg n 。如果增加 m 就能得到我们想要的结果,那么增加 n 就能得到。这给了我们 km ≥ lg m ,这和练习 2-5 中的一样!
2-7.在 Python 列表中,任何涉及到查找或修改某个位置的事情通常都要花费恒定的时间,因为它们的底层实现是数组。你必须遍历一个链表来做到这一点(平均一半的列表),给出一个线性运行时间。一旦你知道了位置,交换东西在两者中都是不变的。(看能不能实现线性时间链表反转。)修改列表结构(通过插入或删除元素,除了在末尾)对于数组(和 Python 列表)通常是线性的,但是在许多情况下对于链表可以在恒定时间内完成。
2-8.对于第一个结果,我将坚持这里的上半部分,并使用 O 符号。下半部分(ω)完全等效。总和O(F)+O(G)是两个函数之和,比如说 F 和 G ,这样(对于足够大的 n ,还有一些c)F(n)≤cf*((你明白为什么两者可以使用相同的 c 了吗?)也就是说,对于足够大的 n ,我们会有F*(n)+G(n)≤c(F(n)+G(n), 也就是简单的说F(n)+G(n)是O(F(n)+G(n),这就是我们想要证明的。 f g 案大部分相当(有一点皱纹与 c 有关)。表明 max(θ(f),θ(g)=θ(max(f, g ))遵循类似的逻辑。最令人惊讶的事实可能是 f + g 是 O (max( f , g ),或者 max( f , g )是ω(f+g)——也就是说最大值至少增长了这很容易解释,因为f+g≤2 max(f, g )。
2-9.当像这样显示语句的等价性时,通常通过列表从一个到下一个显示蕴涵,然后从最后一个到第一个。(你可能也想直接展示一些其他的含义;有 30 种可供选择。)这里有几个提示让你开始。1②:想象树是定向的。然后每条边代表一个父子关系,除了根以外的每个节点都有一条父边,给出n–1 条边。2③:通过逐个添加n–1 条边,逐渐构建 T 。不允许连接已经在 T 中的节点(它是无环的),所以每条边必须用来连接一个新节点到 T ,这意味着它将被连接。
2-10.这是第三章中计数的开胃菜,你可以通过归纳法证明结果(《??》第四章中深入讨论的一种技术)。不过,有一个简单的解决方案(与《??》第二章中的演示非常相似)。给每个父节点(内部节点)两个虚拟的冰淇淋甜筒。现在,每位家长都给孩子每人一个蛋卷冰淇淋。唯一没有冰淇淋卡住的是根。所以,如果我们有了 n 叶子和 m 内部节点,我们现在可以看到 2 m (最初指定的冰淇淋甜筒数量)等于m+n–1(除了根之外的所有节点,每个节点有一个甜筒),这意味着m=n–1。这就是我们正在寻找的答案。很整洁,是吧?(这是一个很好的计数技术的例子,我们用两种不同的方法来计数同一事物,并利用了两个计数必须相等的事实——在这个例子中,是冰淇淋甜筒的数量。)
2-11.给节点编号(任意)。从低到高调整所有边的方向。
2-12.优点和缺点取决于你用它做什么。例如,它可以有效地查找边权重,但不太适合迭代图的节点或节点的邻居。您可以通过使用一些额外的结构来改进这一部分(例如,一个全局节点列表,如果您需要的话,或者一个简单的邻接表结构,如果需要的话)。
第三章
3-1.你可以尝试用归纳法甚至递归来做这件事!
3-2.从重写到(n2–n)/2 开始。然后先去掉常数因子,剩下n2–n。之后就可以把 n 掉了,因为是以n2 为主。
3-3.二进制编码向我们展示了 2 的哪些次方包含在一个和中,并且每个都只包含一次。假设第一个 k 次方(或二进制数)让我们表示任意数直到 2k–1(我们的归纳假设;对于 k = 1)明显成立。现在,尝试使用练习中提到的属性来显示另一个数字(即,允许添加 2 的下一个幂)将让您表示最多 2k+1–1 的任何数字。
3-4.其中一个基本上是在可能的值上的for循环。另一个是二分法,在第六章中有更详细的讨论。
3-5.这一点从分子式的对称性来看是相当明显的。另一种理解方式是,有多少种留下 k 个元素,就有多少种移除 k 个元素的方式。
3-6.提取sec[1:]的动作需要复制n–1 个元素,这意味着算法的运行时间变成了握手和。
3-7.这很快产生握手和。
3-8.在解开递归时,得到 2 { 2T(n–2)+1 }+1 = 22T(n–2)+2+1,最终变成一个加倍和,1 + 2 + … + 2 i 。要得到基本情况,你需要设置 i = n ,这样你得到的幂的总和最多为 2 n ,也就是θ(2n)。
3-9.类似于练习 3-8,但是这里的解开给你 2 { 2T(n–2)+(n–1)}+n= 22T(n–2)+2(n–1)+n。过一会儿,你得到一个相当复杂的和,它有 2 个IT(n–I)作为它的支配被加数。设置 i = n 给你 2I。(我希望这个粗略的推理没有完全说服你;你应该用归纳法检查一下。)
3-10.这是一个工整的:取两边的对数,得出 logxlogy= logylogx。现在,只需注意这两者都等于 log x log y 。(看为什么?)
3-11.递归调用之外发生的事情基本上是序列的两个部分合并成一个序列。首先,我们只假设递归调用返回的序列与参数的长度相同(即lft和rgt不改变长度)。while循环遍历这些元素,弹出元素直到其中一个为空;这最多是线性的。reverse也最多是线性的。res列表现在由从lft和rgt弹出的元素组成,最后,剩余的元素(在lft或rgt中)被组合(线性时间)。剩下的唯一一件事就是显示从mergesort返回的序列的长度与其参数的长度相同。你可以用长度为seq的归纳来做这件事。(如果这仍然有点挑战性,也许你可以在第四章中学到一些技巧?)
3-12.这将为我们提供在 f ( n )内的握手和,意味着递归现在是T(n)= 2T(n/2)+θ(n2)。即使是对主定理基本熟悉的人也应该会告诉你,二次部分占优势,意味着 T ( n )现在是θ(n2)——比原来大幅度的差!
第四章
4-1.在 E 上尝试归纳,并“向后”进行归纳步骤,如内部节点计数示例所示。基本情况( E = 0 或 E = 1)是琐碎的。假设公式对于E–1 成立,并且考虑具有 E 边的任意连通平面图。尝试移除一条边,并假设(目前)较小的图仍然是连通的。那么边缘的移除已经减少了一个边缘计数,并且它一定已经合并了两个区域,减少了一个区域计数。公式对此成立,意思是V–(E–1)+(F–1)= 2,相当于我们要证明的公式。现在看看你是否能处理移除一条边断开图的情况。(提示:您可以将归纳假设应用于每个连接的组件,但这会将无限区域计算两次,因此您必须对此进行补偿。)也试着在 V 和 F 上使用感应。哪个版本适合你的口味?
4-2.这实际上是一个棘手的问题,因为任何一系列的休息都会给你相同的“运行时间”n–1。你可以用归纳法展示这一点,如下所示(用 n =1 给出一个平凡的基本情况):第一次中断会给你一个有 k 个正方形的矩形和一个有n–k的矩形(其中 k 取决于你在哪里中断)。这两个都比 n 小,所以通过强归纳,我们假设它们每个的断点个数分别为k–1 和n–k–1。加上这些,加上第一次休息,我们得到整个巧克力的n–1。
4-3.你可以把这表示成一个图的问题,其中一条边 uv 意味着 u 和 v 相互认识。您试图找到最大的子图(即具有最多的节点数),其中每个节点 v 都有一个度d(v)≥k。归纳又一次拯救了我们。基础案例是 n = k + 1,这里只有当图是完整的,你才能解决问题。归约(归纳假设)就是,你可能已经猜到了,你可以解决n–1 的问题,解决 n 的方法是要么(1)看到所有节点的度数都大于等于 k (大功告成!)或(2)找到单个节点移除并解决其余的(通过归纳假设)。事实证明,你可以删除任何你喜欢的度数小于 k 的节点,因为它永远不会成为解的一部分。(这有点像排列问题——如果需要删除一个节点,就直接删除它。)
加分题提示:注意 d /2 是边与节点的比值(在全图中),只要你删除度小于等于 d /2 的节点,那个比值(对于剩余子图)就不会减少。只要不断删除,直到你达到这个极限。剩下的图有一个非零的边节点比(因为它至少和原始图一样大),所以它必须是非空的。此外,因为我们不能删除更多的节点,每个节点的度数都大于 d /2(也就是说,我们已经删除了所有度数较小的节点)。
4-4.虽然有许多方法可以表明只有两个中心节点,但最简单的方法可能是首先构建算法(使用归纳法),然后使用它来完成证明。 V = 0、1 或 2 的基本情况非常简单——可用的节点都在中心。除此之外,我们希望将问题从 V 简化为V–1。事实证明,我们可以通过移除一个叶节点来做到这一点。对于 V > 2,没有一个叶节点可以是中心的(它的邻居将总是“更中心”,因为它的最长距离会更低),所以我们可以只移除它并忘记它。该算法直接遵循:继续移除叶子(可能通过保持度数/计数再次实现),直到所有剩余节点都是同等中心的。现在应该很明显,这发生在 V 最大为 2 时。
4-5.这是一个位像拓扑排序, 除了我们可能有循环,所以不能保证我们会有入度为零的节点。这实际上相当于寻找一条有向的哈密尔顿路径,而这条路径在一般的图中可能根本就不存在(而且找出来真的很难;见第十一章),但是对于一个有向边的完整图(图论中实际上叫做锦标赛),这样的路径(即沿着边的方向访问每个节点一次的路径)将一直存在。我们可以直接做单元素约简——我们去掉一个节点,把剩下的排序(用归纳假设是可以的;基本情况是琐碎的)。现在的问题变成了我们是否(以及如何)能够插入这最后一个节点,或者骑士。要看出这是可能的,最简单的方法就是简单地在他(或她)击败的第一个对手之前插入骑士(如果有这样的对手;否则,将他放在最后)。因为我们选择了第一个,之前的骑士一定打败了他,所以我们保留了想要的排序类型。
4-6.由此可见,在做归纳的时候,注意细节是多么重要。对于 n =2,这个论证就不成立了。即使归纳假设对于n–1 为真(基本情况, n =1),在这种情况下,两个集合之间没有重叠,因此归纳步骤中断!注意,如果你能以某种方式表明任何两匹马的颜色相同(也就是说,将基本情况设置为 n =2),那么归纳将(显然)有效。
4-7.关键不在于它应该适用于任何有 n 片叶子的树,因为我们已经假设了这种情况。重要的是,这个论点对任何有 n 叶子的树都成立,事实也确实如此。无论你选择哪一棵有 n 片叶子的树,你都可以删除一片叶子和它的父节点,构造一棵有效的有n–1 片叶子和n–2 个内部节点的二叉树。
4-8.这只是一个直接应用规则的问题。
4-9.一旦我们找到一个人(如果我们曾经找到过的话),我们知道这个人不可能指向其他任何人,或者那个人不会被移除。因此,他(或她)一定是指着自己(或者说,是指着自己的椅子)。
4-10.快速浏览一下代码应该会告诉你这是握手循环(B 的构造在每次调用中占用线性时间)。
4-11.尝试排序序列(的“数字”)。使用计数排序作为一个子程序,用一个参数告诉你哪个数字排序。然后从最后一个数字到第一个数字循环,对每个数字排序一次。(注意:您可以对数字使用归纳法来证明基数排序是正确的。)
4-12.算出每个区间(值域)必须有多大。然后,您可以将每个值除以这个数字,向下舍入,以找出将其放在哪个桶中。
4-13.我们假设(如第二章中所讨论的那样)我们可以对足够大的数使用常数时间运算来处理整个数据集,这包括dI。因此,首先,找到所有字符串的这些计数,将它们作为一个单独的“数字”添加然后,您可以使用计数排序按这个新数字对数字进行排序,到目前为止的总运行时间为θ(≘dI+n)=θ(≘dI)。具有相同位数长度的每个数字“块”现在可以单独排序(使用基数排序)。(你看到这如何仍然给出总运行时间θ(≘dI)以及我们如何实际上最终得到所有正确排序的数字了吗?)
4-14.将它们表示为两位数,其中每一位的取值范围为 1… n 。(你看这个怎么做?)然后你可以使用基数排序,给你一个总的线性运行时间。
4-15.列表理解具有二次运行时间复杂度。
4-16.关于运行实验的一些提示,见第二章。
4-17.它不能放在这个点之前,只要我们不把它放在后面,它就不能在任何依赖它的东西之后结束(因为没有循环)。
4-18.例如,您可以通过随机排列节点,并为每个节点添加随机数量的前向边来生成 Dag。
4-19.这个和原著挺像的。现在,您必须维护剩余节点的出度,并将每个节点插入到已经找到的节点之前。(记住不要在列表的开头插入任何东西;相反,追加,然后在最后反转,以避免二次运行时间。)
4-20.这是算法思想的直接递归实现。
4-21.一个简单的归纳解决方案是删除一个间隔,解决其余的问题,然后检查初始间隔是否应该加回来。问题是,你必须将这个时间间隔与所有其他时间间隔进行比较,给出一个二次运行时间。但是,您可以改进这个运行时间。首先,将区间按其左端点排序,使用归纳假设,你可以解决第n–1 个第一区间的问题。现在,扩展假设:假设您也可以在n–1 个区间中找到最大的右端点。你看到归纳步骤是如何在恒定时间内完成的了吗?
4-22.不是随机选择配对 u 、 v ,而是简单地遍历每一个可能的配对,给出一个二次运行时间。(您是否看到这必然会为您提供每个城镇的正确答案?)
4-23.为了表明 foo 很难,你必须将条减少为 foo 。为了展示 foo 是容易的,你必须将 foo 减少到 baz 。
第五章
5-1.渐近运行时间将是相同的,但是您可能会获得更多的开销(也就是说,一个更高的常数因子),因为您不是通过内置操作添加大量对象,而是为每个对象运行更慢的自定义 Python 代码。
5-2.试试把归纳证明变成递归算法。(你可能还想看看弗勒里的算法。)
5-3.尝试重建用于无向图的归纳论证(和递归算法)——它实际上是相同的。与 Trémaux 算法的链接如下:因为你被允许在每个方向上遍历每个迷宫通道一次,所以你可以将通道视为两个方向相反的有向边。这意味着所有交叉点(结点)将具有相等的入度和出度,并且您可以保证找到沿每条边行走两次的路线,每个方向一次。(请注意,在本练习给出的更一般的情况下,您不能使用 Trémaux 的算法。)
5-4.这只是遍历由像素组成的网格的简单问题,相邻的像素充当邻居。为此通常使用 DFS ,但是任何遍历都可以。
5-5.我相信有很多方法可以使用这个线程,但是如果你不能做任何其他类型的标记,一种可能是像 DFS(或 IDDFS)堆栈一样使用它。你可能会多次造访同一个房间,但至少你不会骑自行车。
5-6.在迭代版本中根本没有真正表现出来。一旦从堆栈中弹出所有的“遍历后代”,它就会隐式地发生。
5-7.正如练习 5-6 中所解释的,在迭代 DFS 中,代码中有回溯发生的点,所以我们不能只在某个特定的地方设置结束时间(就像在递归中一样)。相反,我们需要在堆栈中添加一个标记。例如,我们可以添加表单(u, v)的边,而不是将u的邻居添加到堆栈中,在所有这些边之前,我们会推送(u, None),指示u的回溯点。
5-8.假设在拓扑排序中,节点 u 必须在 v 之前。如果我们首先从(或通过) v 运行 DFS,我们将永远无法到达 u ,因此 v 将在我们(在稍后的某个时间点)开始一个从或通过 u 运行的新 DFS 之前结束。目前为止,我们是安全的。另一方面,如果我们先通过 u 。然后,因为 u 和 v 之间存在(直接或间接)依赖关系(即路径),所以我们会到达 v ,它会(再次)在 u 之前结束。
5-9.例如,你可以在这里提供一些函数作为可选参数。
5-10.如果有一个循环,DFS 将总是尽可能地遍历该循环(可能是在从一些弯路返回之后)。这意味着它最终会回到进入循环的地方,形成一个后沿。(当然,它可能已经沿着某个另一个周期穿过了这个边缘,但这仍然会使它成为后边缘。)所以如果没有后沿,就不可能有任何循环。
5-11.其他遍历算法也能够通过在遍历树中找到从被访问节点到其祖先之一的边(后边)来检测循环。然而,确定这种情况何时发生(也就是说,区分后边缘和交叉边缘)不一定那么容易。然而,在无向图中,为了找到一个循环,你所需要的是到达一个节点两次,并且检测它是容易的,不管你使用什么遍历算法。
5-12.假设你找到了到某个节点 u 的前向和交叉边。因为没有方向限制,DFS 不会在没有探索其所有外边缘的情况下回溯超过 u ,这意味着它已经在另一个方向上遍历了假设的前向/交叉边缘!
*5-13.这只是记录每个节点的距离,而不是它的前一个节点的距离,起始节点从零开始。你只需在前任的距离上加 1,而不是记住前任。(当然,你可以两者兼而有之。)
5-14.这个问题的好处是,对于一个边缘 uv ,如果你把 u 涂成白色, v 一定是黑色(反之亦然)。这是一个我们以前见过的想法:如果问题的约束迫使你做某事,那么在构建解决方案时,这一定是一个安全的步骤。因此,你可以简单地遍历图形,确保你用不同的颜色给邻居着色;如果,在某些时候,你不能,没有解决办法。否则,你已经成功地创建了一个两党。
5-15.在强组件中,每个节点都可以到达其他节点,因此每个方向上至少有一条路径。如果边缘颠倒了,还是会有。另一方面,任何像这样由两条路径连接的而不是的对也不会在反转之后,所以也不会有新的节点被添加到强分量中。
5-16.假设 DFS 从 X 中的某个地方开始。然后,在某个时候,它将迁移到 Y。我们已经知道,如果没有回溯,它就无法返回 SCC 图是非循环的),所以在我们返回 X 之前,Y 中的每个节点都必须接收一个结束时间。换句话说,在 Y 中的所有节点都结束之后,X 中至少有一个节点将结束。
5-17.试着找一个简单的例子,这个例子会给出错误的答案。(你可以用一个非常小的图来做。)
第六章
6-2.渐近运行时间将是相同的。然而,比较的次数从上升到。要了解这一点,请分别考虑二进制和三进制搜索的递归B(n)=B(n/2)+1 和T(n)=T(n/3)+2(基本情况为 B (1) = T (1 你可以(通过归纳)表示出B(n)<LGn+1<T(n)。
6-3.如练习 6-2 所示,比较的次数不会下降;然而,还可以有其他优势。例如,在 2-3 树中,3 节点帮助我们平衡。在更一般的 B 树中,大节点有助于减少磁盘访问次数。请注意,在 B 树的每个节点的中使用二分搜索法是很常见的。
6-4.您可以遍历树,在对左右子树的递归调用之间打印或产生每个节点键(以便遍历)。
6-5.首先你找到它的节点;姑且称之为 v 。如果是叶子,去掉就好了。如果是只有一个子节点的内部节点,就用它的子节点替换它。如果节点有两个子节点*,在左子树中找到最大的(最右边的)节点,或者在右子树中找到最小的(最左边的)节点——这是您的选择。现在用这个后代的键和值替换 v 中的键和值,然后删除这个后代。(为了避免使树不必要的不平衡,你应该在左版本和右版本之间切换。)*
6-6.我们正在插入 n 个随机值,所以我们每插入一个值,它在目前为止插入的 k 中最小的概率(包括这个值)是 1/ k 。如果是,最左边节点的深度增加 1。(为了简单起见,我们假设根的深度是 1,而不是习惯上的 0。)这意味着节点深度是 1 + 1/2 + 1/3 + … + 1/ n ,一个和称为 n 次谐波数,或 H n 。有趣的是,这个和是θ(LGn)。
6-7.假设你和你左边的孩子交换位置,结果是比你右边的孩子大。您刚刚破坏了堆属性。
6-8.每个父母都有两个孩子,所以你需要移动两步才能到达下一个的孩子;因此,节点 i 的子节点位于 2I+1 和 2 i + 2。如果你看不出这是如何工作的,试着把节点按顺序画出来,就像它们被放在数组中一样,树的边在父节点和子节点之间形成弧形。
6-9.当考虑标准实现时,要看到构建一个堆是如何线性的可能有点棘手,标准实现从叶子的上面开始,一层一层地遍历节点,在每个节点上执行对数运算。这看起来几乎是对数线性的。然而,我们可以将它重新表述为一个等价的分治算法,这是我们更熟悉的一种算法:首先从左子树开始堆,然后从右子树开始堆,然后修复根。递推成为T(n)= 2T(n/2)+θ(LGn),我们知道(比如通过主定理)是线性的。
6-10.首先,堆让您可以直接访问最小(或最大)节点。当然,这也可以通过维护指向搜索树中最左边(或最右边)节点的直接指针来实现。其次,堆允许您轻松地维护平衡,并且因为它是完全平衡的,所以它可以被简洁地表示,从而导致非常低的开销(例如,您为每个节点保存一个引用,并且您可以将值保存在相同的内存区域中)。最后,构建(平衡的)搜索树需要对数线性时间,而构建堆需要线性时间。
6-13.对于随机输入,实际上不会有什么不同(除了额外函数调用的成本)。然而,总的来说,这意味着没有一个单一的输入能保证总是引出最坏情况的行为。
6-15.这里你可以使用鸽笼原理(如果你试图把超过 n 只鸽子放进 n 个鸽笼,那么至少有一个鸽笼可以容纳至少两只鸽子)。将正方形分成边长为 nn/2 的四块。如果你有四个以上的点,其中一个必须包含至少两个点。通过简单的几何,这些方块的对角线小于 d ,所以这是不可能的。
6-16.在开始之前,只需对数据进行一遍检查,去掉同位置的点。它们已经被排序了,所以寻找重复项只是一个线性时间的操作。现在运行算法时,沿中线的切片最多能装下六个点(看出为什么了吗?),所以你现在最多需要比较 y 序列中的五个点。
6-17.这类似于如何使用排序的下限来证明凸包问题的下限:您可以将实数的元素唯一性简化为最近对问题。只需将你的数字绘制成x-轴上的点(线性时间,渐近小于手头的界限)并找到最接近的一对。如果这两点是相同的,那么元素不是唯一的;否则,他们就是。因为唯一性不能在小于对数线性的时间内确定,所以最接近的配对问题不可能更有效。
6-18.关键的观察是,包含总和为零或负值的切片的初始部分是没有意义的(您总是可以丢弃它并获得相同或更高的总和)。同样,丢弃一个总和为正的初始部分也没有任何意义(包含它将给出更高的总和)。因此,我们可以从左侧开始求和,始终保持迄今为止的最佳和(以及相应的区间)。一旦总和为负,我们将 I(起始索引)移动到下一个位置,并从那里重新开始总和。(你要说服自己这真的管用;或许用归纳法证明?)
第七章
7-1.这里有很多种可能(比如从美制里掉几个硬币)。一个重要的例子是旧的英国体系(1,2,6,12,24,48,60)。
7-2.这只是观察一个基数为 k 的数字系统如何工作的一种方式。这一点在 k = 10 的情况下特别容易看出来。
7-3.当你考虑是否包含最大的剩余元素时,包含它总是值得的,因为如果你不这样做,剩余元素的总和无法弥补损失的价值。
7-4.假设杰克是第一个被他最合适的妻子吉尔拒绝的人,她为了亚当拒绝了他。据推测,亚当还没有被他最合适的妻子爱丽丝拒绝,这意味着他至少和她一样喜欢吉尔。考虑一个稳定的配对,杰克和吉尔在一起。(这肯定是存在的,因为吉尔是杰克可行的妻子。)在这种配对中,吉尔当然还是更喜欢亚当。然而,我们知道亚当更喜欢吉尔而不是爱丽丝——或者任何其他可行的妻子——所以这种匹配终究是不稳定的!换句话说,我们有一个矛盾,否定了我们的假设,即某个男人没有和他最合适的妻子配对。
7-5.假设杰克和爱丽丝结婚了,吉尔和亚当结婚了。因为吉尔是杰克最合适的妻子,他会更喜欢她而不是爱丽丝。因为配对稳定,吉尔肯定更喜欢亚当。这适用于吉尔有另一个丈夫的任何稳定的配对——这意味着她更喜欢任何其他可行的丈夫而不是杰克。
7-6.如果你背包的容量能被所有不同的增量整除,那么贪婪算法肯定会起作用。例如,如果一件物品易碎的增量为 2.3,另一件物品易碎的增量为 3.6,而你的背包容量可以被 8.28 整除,那么你就没问题,因为你有一个足够好的“分辨率”。(你认为我们还能允许什么变化吗?这个想法的其他含义?)
7-7.这相当直接地来自于树形结构。因为这些代码都给了我们独特的、确定性的指令,告诉我们如何从树根导航到树叶,所以当我们到达时,或者我们到达了哪里时,从来没有任何疑问。
7-8.我们知道 a 和 b 是出现频率最低的两项;这意味着 a 的频率低于(或等于)c 的频率,同样适用于 b 和 d 。如果 a 和 d 具有相等的频率,我们将把所有的不等式(包括 a ≤ b 和 c ≤ d )夹在中间,所有四个频率都相等。
7-9.以所有文件大小相等、不变的情况为例。那么平衡的合并树会给我们一个对数线性合并时间(典型的分治)。然而,如果我们使合并树完全不平衡,我们将得到二次运行时间(例如,就像插入排序一样)。现在考虑一组文件,其大小是 2 的幂,最大为 n /2。最后一个文件的大小是线性的,在一个平衡的合并树中,它将包含对数数量的合并,这意味着我们将得到(至少)对数线性时间。现在考虑霍夫曼算法会做什么:它总是合并两个最小的文件,它们的总和大约是下一个文件的大小(也就是说,比下一个文件小一个)。我们得到一个幂的和,最终得到一个线性合并时间。
7-10.作为解决方案的一部分,您需要至少具有相同权重的两条边。例如,如果在两个不同的边上使用了两次最低权重,则(至少)有两种解决方案。
7-11.因为所有生成树中的边的数量是相同的,我们可以通过简单地否定权重来做到这一点(也就是说,如果一条边的权重为 w ,我们会将其改为——w,并找到最小生成树。
7-12.我们需要在一般情况下展示这一点,在这种情况下,我们有一组我们知道将进入解决方案的边。子问题是剩下的图,我们想证明在剩下的图中找到一个最小生成树,并且这个最小生成树与我们所拥有的(没有循环)相兼容,将会给我们一个全局最优解。像往常一样,我们用矛盾的方式展示这一点,假设我们可以找到这个子问题的非最优解,这个解会给我们一个更好的全局解。这两个子解决方案都与我们现有的方案兼容,因此它们可以互换。显然,用最优解替换非最优解会提高全局和,这给了我们矛盾。
7-13.克鲁斯卡尔的算法总是找到一个最小生成森林,在连通图的情况下,它变成了一个最小生成树。Prim 的算法可以用一个循环来扩展,比如深度优先搜索,这样它就可以在所有组件中重新开始。
7-14.它仍然会运行,但不一定会找到最便宜的遍历(或 min-cost arborescence )。
7-15.因为你可以用这个来排序实数,它有一个对数线性的下界。(这与凸包的情况类似。)你只需使用这些数字作为 x 坐标,并使用相同的 y 坐标。最小生成树将是从第一个数字到最后一个数字的路径,给你排序。
7-16.我们需要证明的是,分支树具有(至多)对数高度。组件树的高度等于组件中的最高等级。仅当两个相同高度的组件树被合并时,该等级才增加,然后增加 1。在每个联合中增加某些等级的唯一方法是以平衡的方式合并组件,给出对数最终等级(和高度)。在不增加任何等级的情况下进行一些循环不会有帮助,因为我们只是在树中“隐藏”节点而不改变它们的等级,给了我们更少的工作。换句话说,没有办法获得比组件树的对数高度更高的高度。
7-17.都被堆的对数运算隐藏了。在最坏的情况下,如果每个节点只添加一次,这些操作在节点数量上会是对数的。现在,它们在边数上可能是对数的,但是由于边数在节点数上是多项式的(二次),所以这只是一个常数差:θ(LGm)=θ(LGn2)=θ(LGn)。
7-18.具有最早开始时间的间隔可能覆盖该组的整个剩余时间,这可能都是不重叠的。如果我们想用最早的开始时间,我们同样注定会失败,因为我们总是只能得到一个元素。
7-19.我们必须对它们进行分类,但是在这之后,扫描和排除可以在线性时间内完成(你知道怎么做吗?).换句话说,总的运行时间由排序决定,通常是对数线性的。
第八章
8-1.不用检查参数元组是否已经在缓存中,只需检索它并捕捉可能发生的KeyError,如果它是而不是的话。使用一些不存在的值(比如None)和get可能会带来更好的性能。
8-2.看待这个问题的一种方式可能是计算子集。每个元素要么在子集中,要么不在子集中。
8-3.对于fib,你只需要每一步之前的两个值,而对于two_pow,你只需要保持你已经有的值翻倍。
8-5.只需使用第五章中的“前任指针”思想。如果您正在执行向前版本,请在每个节点中存储您所做的选择(也就是说,您遵循的是哪个出边)。如果你正在做相反的版本,存储你从哪里到每个节点。
8-6.因为拓扑排序还是要访问每条边。
8-7.您可以让每个节点观察它的前一个节点,然后在开始节点中显式地触发估计值的更新(给它一个零值)。观察员将被告知变化,并可以相应地更新他们自己的估计,从而触发他们的观察员的新的更新。这在许多方面与本章中基于松弛的解决方案非常相似。不过,这个解决方案可能有点“操之过急”。因为级联更新是即时触发的(而不是让每个节点一次完成它的输出或输入更新),该解决方案实际上可能具有指数级的运行时间。(你看怎么样?)
8-8.这可以通过多种方式表现出来——但其中一种方式是简单地看看这个列表是如何构建的。使用bisect添加每个对象(或者追加或者覆盖一个旧元素),它会按照排序顺序找到合适的位置来放置它。通过归纳,end会被排序。(你能想出其他方法来看这个列表必须排序吗?)
8-9.当新元素大于最后一个元素或者end为空时,不需要bisect。您可以添加一个if语句来检查这一点。它可能会让代码更快,但它可能会让代码可读性稍差。
8-10.就像在 DAG 最短路径问题中一样,这将涉及到记住“你从哪里来”,也就是说,保持对前辈的跟踪。对于二次版本,您可以不使用前置指针,只需在每一步复制前置指针列表。它不会影响渐近运行时间(复制所有那些列表将是二次的,但那是你已经有的),并且对实际运行时间和内存占用的影响应该是可以忽略的。
8-11.这在许多方面与 LCS 电码非常相似。如果你需要更多的帮助,你可以在网上搜索 levenshtein 距离 python 。
8-12.就像其他算法一样,你可以跟踪做出了哪些选择,对应于你在“子问题 DAG”中遵循的边
8-13.你可以交换序列和它们的长度。
8-14.你可以用最大公约数除 c 和 w 中的所有元素。
8-16.运行时间是伪多项式,这意味着它仍然是指数形式。您可以轻松地提高背包容量,使运行时间变得不可接受,同时保持实际问题实例的规模较小。
8-19.您可以添加一组虚拟叶节点来表示失败的搜索。每个叶节点将代表树中实际存在的两个节点之间的所有不存在的元素。你必须在总数中分别处理这些。
第九章
9-1.您必须以某种方式修改算法或图形,以便可以使用负加法周期的检测机制来找到汇率乘积最终大于 1 的乘法周期。最简单的解决方案是简单地通过取它们的对数并求反来转换所有的权重。然后你可以使用标准版的贝尔曼-福特,一个 负循环会给你你所需要的。(你看怎么样?)当然,为了实际上将用于任何事情,您应该计算出如何输出这个循环中涉及的节点。
9-2.这不是问题,不会比 DAG 最短路径问题更严重。哪一个在排序中先结束并不重要,因为另一个(随后出现的)无论如何都不能用于创建快捷方式。
9-3.它突然给你一个伪多项式运行时间(相对于最初的问题实例)。你知道为什么吗?
9-4.这要看你怎么做了。多次添加节点不再是一个好主意,您可能应该进行一些设置,以便在运行relax时可以直接访问和修改队列中的条目。然后,您可以在恒定时间内完成这一部分,而从队列中的提取现在将是线性的,您将得到二次运行时间。对于一个稠密的图,这实际上是很好的。
9-5.如果存在负周期,事情可能会出错——但在这种情况下,贝尔曼-福特算法会引发一个例外。除此之外,我们可以转向三角不等式。我们知道h(v)≤h(u)+w(u, v )对于所有节点 u 和 v 。这意味着 w '( u ,v)=w(u,v)+h(u)–h(v)≥0,根据需要。
9-6.我们可能会保留最短路径,但我们不一定能保证权重是非负的。
9-9.这需要很少的改变。你可以使用一个(二进制,布尔型)邻接矩阵来代替权重矩阵。当看到你是否能改善一条路径时,你不会使用加法和最小值;相反,你会看到那里是否有一条新的道路。换句话说,你可以使用A[u, v] = A[u, v] or A[u, k] and A[k, v]。
9-10.更严格的停止标准告诉我们,一旦 l + r 大于我们目前找到的最短路径,就停止,我们已经证明这是正确的。当两个方向都产生(并因此访问)同一个节点时,我们知道已经探索了通过该节点的最短路径;因为它本身就是我们探索过的那些中的一个,所以它一定大于或等于我们探索过的那些中最小的一个。
9-11.无论你选择哪条边,我们都知道 d ( s , u ) + w ( u ,v)+d(v, t )小于目前为止找到的最短路径的长度,也就是小于或等于 l +这意味着 l 和 r 都将经过路径的中点,无论它在哪里。如果中点在一条边内,就选择这条边;如果它正好在一个节点上,选择路径上的任一相邻边就可以了。
9-14.考虑从 v 到 t 的最短路径。修改后的成本可以用两种方式表示。第一种为 d ( v ,t)–h(v)+h(t),与 d ( v ,t)–h(v)相同表达该修改成本的另一种方式是作为各个修改的边权重的总和;通过假设,这些都是非负的(即 h 可行)。所以我们得到 d ( v ,t)—h(v)≥0,或者 d ( v ,t)≥h(v)。
第十章
10-1.只需将每个节点 v 拆分成两个节点 v 和v’,并添加所需容量的边vv’。然后,所有的内边缘将留在 v 处,而来自 v 的所有外边缘将被移动到 v 。
10-2.你可以修改算法,或者修改数据。例如,您可以将每个节点一分为二,在它们之间有一条单位容量的边,并赋予所有剩余的边无限的容量。那么最大流将允许您识别顶点不相交的路径。
10-3.我们知道运行时间是 O ( m 2 ),那么我们要做的就是构造一个二次运行时间发生的情境。一种可能性是除了 s 和 t 之外还有 m /2 个节点,每个节点都有一个从 s 到 t 的边。在最坏的情况下,遍历将访问来自 s 的所有不饱和外边缘,这(通过握手和)给了我们二次运行时间。
10-4.只需将每个边沿 uv 换成 uv 和 vu ,两者容量相同。当然,在这种情况下,你可能会同时结束双向流动。这真的不是问题——要找出通过无向边的实际流量,只需从另一个中减去一个。结果的符号将指示流动的方向。(有些书为了简化剩余网络的使用,避免节点之间两个方向都有边。这可以通过用虚拟节点将两个有向边中的一个一分为二来实现。)
10-6.例如,你可以给水源一个容量(如练习 10-1 中所述),等于所需的流量值。如果可行,最大流量将具有该值。
10-8.您可以通过找到最小切割来解决这个问题,如下所示。如果只有在 B 也参加的情况下,客人 A 才会参加,则将具有无限容量的边(A,B)添加到您的网络中。如果可以避免的话,该边将永远不会穿过切口(向前)。你邀请的朋友会在 cut 的源端,而其他人会在 sink 端。您的兼容性可以建模如下:任何正兼容性被用作来自源的边的容量,而任何负兼容性被用作到源的边的容量,否定。然后,该算法将最小化穿过切割的这些边的总和,将您喜欢的边保留在源端,将您不喜欢的边保留在接收端(尽可能)。
10-9.因为每个人都有一个最喜欢的座位,所以左侧的每个节点都有一条到右侧的边。这意味着增补路径都由单个不饱和边组成-因此所描述的行为等同于增补路径算法,我们知道该算法将给出最佳答案(也就是说,它会将尽可能多的人配对到他们最喜欢的座位)。
10-10.将两轮的组表示为节点。给来自源的第一组 in-edge,容量为 k 。类似地,给第二组出边缘到接收器,同样具有容量 k 。然后将所有第一组的边添加到所有第二组,所有第二组的容量都为 1。每个流动单元就是一个人,如果你能够从源头(或者到汇点)饱和边缘,你就成功了。然后每组将有 k 人,第二组的每个人最多有一个来自第一组的人。
10-11.该解决方案将供应/需求思想与最小成本流程相结合。用一个节点代表每个星球。还要为每个乘客类型添加一个节点(也就是说,为乘客出发地和目的地的每个有效组合添加一个节点)。将每个行星链接到 i < n 到行星 i +1,容量等于飞船的实际载重量。乘客类型节点被给予与该类型乘客数量相等的供给(即,想要从 i 到 j 的乘客数量)。考虑节点 v ,代表想要从行星 i 前往行星 j 的乘客。这些要么能去,要么不能去。我们通过添加一条从 v 到 i 的边和另一条到 j 的边来表示这个事实。然后,我们向节点 j 添加一个需求,等于 v 处的供应。(换句话说,我们确保每个星球都有一个需求,考虑到所有想去那里的乘客。)最后,我们在( v , i )上添加一个成本,该成本等于从 i 到 j 的旅行费用,除了它是负数。这代表我们在 v 处为每位乘客赚取的金额。我们现在找到了一个关于这些供给和需求的可行的最小成本流。这种流动将确保每个乘客要么被路由到他们想要的起点(意味着他们将进行旅行),然后通过行星到行星的边缘到达他们的目的地,增加我们的收入,要么他们沿着零成本边缘被直接路由到他们的目的地(意味着他们不会进行旅行)。
第十一章
11-1.因为对分的运行时间是对数的。即使所讨论的值域的大小是问题大小的指数函数,实际运行时间也只会是线性的。(你看出为什么了吗?)
11-2.因为它们都在 NP 中,而 NP 中的所有问题都可以归结为任意 NP-完全问题(由 NP-完全性的定义)。
11-3.因为运行时间是 O ( nW ),其中 W 是容量。如果 W 在 n 中是多项式,那么运行时间也是多项式。
11-4.从带有任意一个 k 的版本中得到的简化非常简单:简单地添加*–k*作为一个元素。
11-5.应该清楚的是,我们可以将子集和问题的无界版本简化为无界背包(只需设置与所讨论的数字相等的权重和值)。挑战是从无界到有界的情况。这基本上是一个数字杂耍的问题,这个杂耍有几个要素。我们仍然希望保持权重,以便优化最大化它们。然而,除此之外,我们需要添加某种约束,以确保每个数字最多使用一个。我们分开来看这个约束的事情。对于 n 个数字,我们可以尝试使用 2 的幂来创建 n 个“槽位”,用 2 个I来表示数字 i 。然后,我们可以拥有 21+…+2+n的容量,并运行我们的最大化。然而,这还不够。最大化不会在意我们有一个 2 n 的实例还是两个 2n–1的实例。我们可以添加另一个约束:我们用 2I+2n+1来表示编号 i ,并将产能设置为 21+…+2n+n2n+1。为了最大化,填充从 1 到 n 的每个槽仍然是值得的,但是现在它只能包括 2nn+1的 n 个出现,因此 2 n 的单个实例将优于 2n–1的两个实例。但是我们还没有完成…这只是让我们强制最大化每个数字中的一个,这并不是我们真正想要的。相反,我们希望每个项目有两个版本,一个表示包含该数字,另一个表示不包含该数字。如果包含了编号 i 我们就加 w i ,如果排除了就加 0。我们还会有一个原始容量, k 。这些约束从属于“每个插槽一个项目”的东西,所以我们真的希望在我们的表示中有两个“数字”。我们可以通过将时隙约束数乘以一个巨大的常数来实现。如果我们的数中最大的是 B ,我们可以用 nB 乘以约束,应该是万无一失的。于是,产生的方案是用下面两个新数字来表示原始问题中的数字 w i ,分别表示包含和排除:(2n+1+2InB+wI容量变成(n2n+1+2n+…+21)nB+k。
11-6.很容易将三色简化为任意k-着色为k3;你只是把两种或两种以上的颜色混在一起。
11-7.在这里,你可以减少任何数量的东西。一个简单的例子是使用子图同构来检测集团。
11-8.您可以通过在两个方向上添加有向边(反平行边)来简单地模拟无向边。
11-9.您仍然可以使用红绿蓝方案来模拟方向,然后使用之前从有向汉密尔顿循环到有向汉密尔顿路径的简化(您应该验证这如何以及为什么仍然有效)。不过,还有一个选择。考虑如何将无向哈密尔顿圈问题化为无向哈密尔顿路问题。选择某个节点 u ,添加三个新节点, u '、 v 和 v ',以及(无向)边( v 、 v ')和( u 、 u ')。现在在 v 和 u 的每个邻居之间添加一条边。如果原来的图有一个哈密尔顿圈,这个新的图显然会有一个哈密尔顿路径(只需断开 u 与它在圈里的一个邻居的连接,在两端加上 u 和v’)。更重要的是,这种暗示是双向的:新图中的哈密尔顿路径必须有 u 和 v 作为它的端点。如果我们去掉 u '、 v 和 v ',我们就剩下一条从 u 到相邻的 u 的哈密尔顿路径,我们可以把它们连接起来,得到一个哈密尔顿循环。
11-10.这(不足为奇)与另一个方向的减少正好相反。您可以添加一个新节点,而不是拆分现有节点。将此节点连接到其他节点。当且仅当原始图中存在哈密尔顿路径时,新图中才会有哈密尔顿圈。
11-11.我们可以顺藤摸瓜。Dag 中的最长路径可用于查找哈密尔顿路径,但仅在 Dag 中。这将再次让我们找到有向图中的有向哈密尔顿圈,当我们在单个节点上分割它们时,这些有向哈密尔顿圈变成了有向无环图(或者,通过摆弄归约,非常接近于此的东西)。然而,我们用来将 3-SAT 简化为有向哈密尔顿圈的有向图与此完全不同。诚然,我们可以在 s 和 t 节点中看到这种结构的暗示,以及从 s 到 t 的边的大致向下方向,但是每一行都是满的反平行边,并且能够沿任一方向前进对证明是至关重要的。因此,如果我们进一步假设非循环性,事情就会在这里分解。
11-12.这里的推理与练习 11-11 中的很相似。
11-13.正如正文中所讨论的,如果物体大于背包的一半,我们就完成了。如果它稍微少一点(但不到背包的四分之一),我们可以包括两个,再次填满了一半以上。唯一剩下的情况是它是否更小。无论哪种情况,我们都可以继续前进,直到越过中线——因为这些物体非常小,它不会延伸到中线的另一端而给我们带来麻烦。
11-14.这个其实很简单。首先,随机排列节点。这会给你两个 Dag,由从左到右的边和从右到左的边组成。其中最大的一个必须包含至少一半的边,给你一个 2-近似值。
11-15.假设所有的节点都是奇数度的(这将给予匹配尽可能大的权重)。这意味着循环将只由这些节点组成,并且循环的每第二条边将是匹配的一部分。因为我们选择了最小值匹配,我们当然选择了两个可能的交替序列中最小的一个,确保权重最多是整个周期的一半。因为我们知道三角形不等式成立,放松我们的假设和删除一些节点不会使循环或匹配更昂贵。
11-16.在这里尽情发挥创造力吧。也许,你可以试着单独添加每个对象,或者你可以添加一些随机的对象?或者你可以一开始就运行贪婪边界——尽管这已经在第一次扩张中发生了…
11-17.直觉上,你从这些物品中获得了最大可能的价值。不过,看看你是否能拿出更有说服力的证据。
11-18.这需要一些概率论的知识,但也没那么难。让我们看一个单个子句,其中每个文字(要么是变量,要么是它的否定)要么是真,要么是假,两种结果的概率都是 1/2。这意味着整个子句为真的概率是 1-(1/2)3= 7/8。如果我们只有一个子句,这也是预期为真的子句数。如果我们有 m 子句,我们可以预期有 7 m /8 个真子句。我们知道 m 是最优上界,那么我们的近似比就变成了 m /(7 m /8) = 8/7。很漂亮,你不觉得吗?
11-19.这个问题现在足够表达解决(例如)最大独立集问题,这是 NP 难的。所以,你的问题也是 NP 难的。一种简化如下:将每个客人的兼容性设置为 1,并为原始图中的每条边添加冲突。如果你现在可以在不邀请彼此不喜欢的客人的情况下最大化相容和,你就找到了最大独立集。
11-20.NP-硬度可以很容易地建立,甚至对于 m = 2,通过从分割问题中减少。如果我们可以分配作业,使机器同时完成,这显然将最小化完成时间——如果我们可以最小化完成时间,我们也将知道它们是否可以同时完成(即,值是否可以被划分)。近似算法也很简单。我们依次考虑每个作业(以某种任意的顺序),并将它分配给当前具有最早完成时间(即最低工作负载)的机器。换句话说,这是一种直截了当的贪婪方法。证明它是一个 2-近似有点困难。设 t 为最佳完成时间。首先,我们知道没有作业持续时间大于 t 。其次,我们知道平均完成时间不能超过 t ,因为完全平均的分布是我们能得到的最好结果。让 M 成为我们贪婪方案中最后完成的机器,让 j 成为该机器上的最后一个作业。由于我们的贪婪策略,我们知道在 j 的开始时间,所有其他机器都在忙,所以这个开始时间在平均完成时间之前,因此在 t 之前。 j 的持续时间必须也小于 t ,所以将这个持续时间加到它的开始时间,我们得到一个小于 2 t 的值……这个值就是我们的完成时间。
11-21.如果你愿意,你可以重用清单 11-2 的基本结构。一种简单的方法是依次考虑每个作业,并尝试将它分配给每台机器。也就是说,你的搜索树的分支因子将是 m 。(请注意,机器内作业的顺序并不重要。)在下一级搜索中,您可以尝试放置第二个职务。该状态可以由一列 m 台机器的结束时间来表示。当您尝试将一个作业添加到一台机器上时,您只需将它的持续时间添加到完成时间上;当你回溯时,你可以再次减去持续时间。现在你需要一个边界。给定一个部分解决方案(一些计划作业),您需要给出最终解决方案的乐观值。例如,在部分解决方案中,我们永远不能在最晚完成时间之前完成,所以这是一个可能的界限。(也许你能想到更好的界限?)在开始之前,您必须将您的解值初始化为最优解的上限(因为我们正在最小化)。越紧越好(因为它增加了你的修剪能力)。这里你可以使用练习 11-20 中的近似算法。*