算法思维-全-
算法思维(全)
原文:
zh.annas-archive.org/md5/9bf868aabed5f4af2752724606a3ea3a译者:飞龙
前言

我假设你已经学会了使用像 C、C++、Java 或 Python 这样的编程语言……并且我希望你已经被它吸引了。很难向非程序员解释为什么通过编程解决问题是如此有意义且有趣的。
我还希望你已经准备好将你的编程技能提升到一个新高度。我有幸帮助你做到这一点。
我们将做什么
我本可以从教授你一些炫酷的新技巧开始,告诉你它们为何有用,并将它们与其他炫酷技巧进行比较,但我不会那样做。这些内容会被静置一旁,等待着某个机会来临——如果某个机会真能出现的话。
相反,在这本书中,我所做的就是提出问题:困难的问题。这些是我希望你无法解决的问题,是我希望会让你当前的方法陷入困境的问题。你是程序员,你想解决问题。现在是时候学习那些炫酷的技巧了。本书的全部内容就是提出难题,然后通过将你所知道的与你所需要的知识连接起来,解决这些问题。
这里你不会看到传统的教科书题目。你不会找到一个最优的矩阵链相乘方法,也不会计算斐波那契数列。我保证:你不会解决汉诺塔问题。市面上有很多优秀的教科书可以做这些事情,但我怀疑很多人并不会被这类谜题所吸引。
我的做法是使用你之前未见过的新问题。每年,成千上万的人参加编程竞赛,这些竞赛需要新的问题来衡量参与者能独立解决什么问题,而不是看谁能最快地用谷歌搜索。这些问题很吸引人,它们在经典问题的基础上加入了新的转折和背景,挑战人们找到新的解决方案。这些问题涵盖了似乎无尽的编程和计算知识。我们可以通过选择合适的问题,尽情学习。
让我们从一些基础开始。数据结构是组织数据的一种方式,目的是让所需的操作更快。算法是解决问题的一系列步骤。有时我们可以在不使用复杂数据结构的情况下制作快速算法;而有时候,正确的数据结构能大大提升速度。我的目标不是将你培养成一名竞赛程序员,虽然我会把那当作一种愉快的副产品。相反,我的目标是通过竞赛编程中的问题来教授你数据结构和算法——并在这个过程中享受乐趣。你可以通过* daniel.zingaro@gmail.com *联系我。如果你学到了东西,给我发邮件。如果你笑了,给我发邮件。
第二版新增内容
我非常享受与读者讨论本书第一版的机会。他们的反馈促成了本新版本中的许多变化和改进。
我对全书做了一些小的改进和补充,但以下是一些重要的更新亮点:
第一章 我移除了复合词问题,因为这个问题可以通过不需要哈希表的方法来解决。现在我们有了一个关于社交网站密码的问题。此外,我还简化了这一章的代码,以帮助没有 C/C++编程背景的读者,并增加了关于哈希表效率的更多信息。
第三章 我增加了更多关于如何发现动态规划中需要的子问题的指导。
第四章 这一章是全新的,重点讲解了更高级的记忆化和动态规划的应用。这是读者频繁提出的需求,我很高兴能够将这一部分加入其中。你将学习如何从反向的角度看待动态规划问题(以及为什么要这样做),如何在子问题数组中处理更多维度,并且如何进一步优化动态规划代码,尤其是在它还不够快速的时候。
第五章,之前是第四章 我加入了如何在使用动态规划和图之间做选择的指导。
第八章,之前是第七章 我进一步讨论了为什么我们将堆实现为数组而不是显式的树。
第十章 这一章是全新的,教你如何使用随机化,这个主题在书籍中并不常见。随机化是一种可以帮助你设计简单且快速的算法的技术。你将使用两种类型的随机化算法来解决那些否则会非常困难的问题。你还将学习如何在遇到问题时,判断是否需要使用随机化技术。
本书的读者群体
本书适合任何想学习如何解决复杂问题的程序员。你将学习许多数据结构和算法,它们的优点,它们可以帮助你解决的各种问题,以及如何实现它们。读完本书后,你会成为一个更优秀的程序员!
你是否正在参加一门数据结构和算法的课程,却被一堆定理和证明搞得不知所措?情况不一定非得如此。这本书可以作为你的辅导书,帮助你快速理解核心内容,以便你能够编写代码并解决问题。
你是否在为下一次编程面试寻找优势?你需要能够比较和对比不同的解决问题的方法,选择最合适的数据结构或算法,并能解释和实现你的解决方案。在阅读本书的过程中,你将反复练习这些技能。再也不需要害怕哈希表、递归、动态规划、树、图或堆了!
你是一个独立学习者,正在朝着数据结构和算法的专家目标努力吗?如果没有找到合适的资源,从互联网上拼凑学习内容会很累,而且可能会导致知识漏洞。本书将为你提供扎实的基础和一致的展示,帮助你成为专家。
如下节所述,本书中的所有代码都是用 C 编程语言编写的。然而,这不是一本学习 C 的书。如果你之前有 C 或 C++ 的编程经验,那就直接开始吧。如果你之前用过 Java 或 Python 这类语言,我猜你通过阅读会掌握大部分需要的知识,但你可能希望回顾一些 C 的概念,特别是在第一次遇到时。我会使用指针和动态内存分配,所以无论你之前有什么经验,你可能都需要复习这些主题。我推荐的最佳 C 书籍是 K. N. King 的《C 程序设计:现代方法》(第二版)。即使你对 C 没问题,也建议读一读。它非常优秀,任何时候遇到 C 相关的问题,都是极好的伴侣。
我们的编程语言
我选择使用 C 作为本书的编程语言,而不是 C++、Java 或 Python 这类高级语言。我会讨论为什么这样做,并且解释我在 C 相关的其他一些决策。
为什么使用 C?
使用 C 的主要原因是我希望从零开始教你数据结构和算法。当我们需要哈希表时,我们自己构建它。不会依赖字典、哈希映射或其他语言的类似数据结构。当我们不知道字符串的最大长度时,我们会构建一个可扩展的数组:我们不会让语言为我们处理内存分配。我希望你能完全明白发生了什么,绝不藏私。使用 C 帮助我实现这个目标。
在本书中,像我们这样用 C 解决编程问题,是你决定继续学习 C++ 时的有用入门。如果你决定深入竞争性编程,你会高兴地发现,C++ 是最受竞争性编程人员欢迎的语言,因为它有丰富的标准库,并且能够生成优化速度的代码。
静态关键字
常规的局部变量存储在所谓的 调用栈 中。每次调用一个函数时,部分调用栈内存会用来存储局部变量。然后,当函数返回时,这些内存会被释放出来,以供其他局部变量使用。不过,调用栈较小,不适合存储本书中我们会遇到的一些庞大数组。这时,static 关键字就派上用场了。将 static 用于局部变量时,会将其存储持续时间从自动变为静态,这意味着该变量在函数调用之间保持其值。副作用是,这些变量 不会 与常规局部变量存储在同一内存区域,否则它们的值在函数结束时会丢失。相反,它们被存储在自己独立的内存段中,不必与调用栈上的其他内容竞争。
使用 static 关键字时需要注意的一点是,这些局部变量只会被初始化一次!快速示例请参见 示例 1。
int f(void) {
❶ static int x = 5;
printf("%d\n", x);
x++;
}
int main(void) {
f();
f();
f();
return 0;
}
示例 1:带有 static 关键字的局部变量
我在局部变量 x ❶ 上使用了 static。没有它,你应该会看到 5 被打印三次。然而,由于使用了 static,你应该看到如下输出:
5
6
7
包含文件
为了节省空间,我没有包括应该在 C 程序开头添加的 #include 行。如果你包含以下内容,就不会出错:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
释放内存
与 Java 或 Python 不同,C 语言要求程序员释放所有手动分配的内存。模式是使用 malloc 分配内存,使用该内存,然后使用 free 释放内存。
然而,由于两个原因,我在这里没有释放内存。首先,释放内存会增加杂乱,分散代码的主要教学目的。其次,这些程序的生命周期很短:你的程序将在几个测试用例上运行,完事儿就结束了。操作系统在程序终止时会回收所有未释放的内存,所以即使你多次运行程序,也无需担心。当然,在实践中不释放内存是非常不负责任的:没有人会喜欢一个随着运行而消耗越来越多内存的程序。如果你想练习释放内存,你可以在本书中展示的程序里加入 free 调用。
主题选择
数据结构和算法的领域太广泛,无法被一本书(或者这位作者)所包容。我使用了三个标准来帮助我决定哪些主题值得纳入其中。
首先,我选择了具有广泛适用性的主题:每个主题不仅可以用来解决书中的相应问题,还能解决许多其他问题。在每一章中,我至少关注两个问题。我通常用第一个问题来介绍数据结构或算法以及它的一个典型应用。其他问题则旨在让你了解数据结构或算法的更多用途。例如,在第六章中,我们研究了 Dijkstra 算法。如果你在 Google 上搜索它,你会发现 Dijkstra 算法用于寻找最短路径。的确,在本章的第一个问题中,我们就是用它来做这个事情。然而,在第二个问题中,我们进一步改进 Dijkstra 算法,不仅找出最短路径,还计算最短路径的数量。我希望随着你深入每一章,你能更多地了解每种技术的应用范围、约束和细节。
其次,我选择了那些实现不会淹没周围讨论的主题。我希望任何问题的解决方案的代码量不超过 150 行。那包括读取输入、解决问题本身和产生输出。如果某个数据结构或算法的实现需要 200 或 300 行代码,那出于实际原因,它就不适合了。
第三,我选择了那些能够支持正确性论证的主题,我希望这些论证既令人信服又直观。教授你特定的数据结构和算法当然是我的目标之一,因为我假设你来这里是为了学习强大的问题解决方法及其实现。同时,我也希望你对为什么你所学的内容有效感兴趣,所以我在更为隐秘的目标上也做了一些努力:说服你相信数据结构或算法是正确的。这里不会有正式的证明或类似的内容。不过,如果我在我的隐秘目标上取得了成功,那么你将在学习数据结构或算法的同时,也学到正确性的问题。不要仅仅满足于追踪代码并惊叹它每次都能神奇地运行。没有魔法,能够使代码运行的洞察就在你手边,就像代码本身一样。
如果你想深入了解本书的内容,建议从附录 B 开始。在那里,我加入了一些与第一章、第三章、第五章、第八章、第九章和第十章相关的附加材料。
许多读者通过在阅读本书的同时进行练习或阅读额外的资料将受益。章节末尾的“注释”部分指出了额外的资源,其中一些包含了更多的例子和样例题。还有一些在线资源提供了精选的、分类整理的问题及其解题策略。我找到的最全面的资源是 Steven Halim 和 Felix Halim 的解题方法页面;请参见 https://cpbook.net/methodstosolve。
编程竞赛平台
我选择的每个问题都可以在一个编程竞赛平台上找到。许多这样的竞赛平台存在,每个平台通常包含数百个问题。我尽量保持我们使用的竞赛平台数量较少,但又足够多,以便给我选择最合适问题的灵活性。对于每个竞赛平台,你需要一个用户名和密码;现在就设置好你的账户是值得的,这样在阅读本书时,你就不用中途停下来去设置账户了。以下是我们将使用的竞赛平台:
Codeforces https://codeforces.com
DMOJ https://dmoj.ca
POJ http://poj.org
SPOJ http://spoj.com
UVa https://uva.onlinejudge.org
每个问题的描述开始时都会指明该问题可以在某个竞赛平台上找到,并给出你应使用的特定问题代码以访问该问题。
尽管竞赛平台上的一些问题由个人贡献者编写,但其他问题则来自于著名的竞赛。以下是一些本书中问题来源的竞赛:
国际信息学奥林匹克(IOI) 这是一个为高中生举办的著名年度比赛。每个参赛国最多派出四名选手,但每名选手单独参赛。比赛持续两天,每天都有多个编程任务。
加拿大计算机竞赛(CCC)和加拿大信息学奥林匹克(CCO) 这些是由滑铁卢大学组织的年度高中生竞赛。CCC(即第一阶段)在各个学校举行,表现最好的选手将进入第二阶段(CCO),即在滑铁卢大学举行的竞赛。第二阶段表现最好的选手将代表加拿大参加 IOI。作为一名高中生时,我参加了 CCC,但我从未进入 CCO——我连接近的机会都没有。
克罗地亚信息学公开赛(COCI) 这个在线比赛每年举办多次。表现优异的选手将被选入克罗地亚的 IOI 代表队。
全国中学生信息学奥林匹克(NOIP) 这是中国的一个年度比赛,类似于加拿大的 CCC。表现最好的选手将被邀请参加中国全国信息学奥林匹克(NOI)。NOI 的优胜者有资格参加进一步的训练,并可能被选拔进入中国 IOI 队。
南非编程奥林匹克(SAPO) 这项比赛每年举办三轮。比赛的难度逐渐增加,从第一轮到第二轮,再到最终轮。比赛成绩用于选拔代表南非参加 IOI 的学生。
美国计算奥林匹克(USACO) 这项在线比赛每年举办几次,其中最具挑战性的比赛是美国公开赛。在每次比赛中,你将遇到四个难度级别的题目:青铜(最简单)、白银、黄金和铂金(最难)。比赛成绩用于选拔美国 IOI 队伍。
中北美东部地区编程竞赛(ECNA) 在这项为大学生举办的年度比赛中,表现最好的选手将被邀请参加年度国际大学生程序设计竞赛(ICPC)世界总决赛。与这里的其他比赛不同,学生们是单独竞争的,而 ECNA 和世界总决赛则是团队比赛。
DWITE 这是一个旨在帮助学生为年度比赛做准备的在线编程比赛。不幸的是,DWITE 现在已经不再举行,但那些旧的题目——它们真的很不错!——依然可以使用。
参见附录 C 了解本书中每个问题的来源。
当你提交代码解决问题时,评审会编译你的程序并在测试用例上运行。如果你的程序通过了所有的测试用例,并且在规定时间内完成,那么你的代码会被接受为正确;评审会显示AC表示接受的解答。如果你的程序未通过一个或多个测试用例,那么你的程序将不被接受;在这种情况下,评审会显示WA(“错误答案”)。一种常见的结果是程序运行过慢,评审会显示TLE(“超时”)。请注意,TLE并不意味着你的代码其他部分是正确的:如果你的代码超时,评审将不再运行后续的测试用例,所以可能有一些WA的 bug 被隐藏在TLE背后。
在本书出版时,我为每个问题提供的解决方案均能在规定时间内通过所有测试用例并满足指定的评审要求。在这些基本要求的基础上,我的目标是让代码具有可读性,并且选择清晰性优于速度。这是一本关于教授数据结构与算法的书,而不是让程序在完成任务的前提下追求更高的性能。
问题描述的结构
在解决问题之前,我们必须清楚自己被要求做什么。这种精确性不仅仅体现在理解任务本身上,还体现在我们应该如何读取输入并产生输出。因此,每个问题开始时都会有三个组成部分的描述:
问题 在这里,我提供了问题的背景和我们需要做的事情。仔细阅读这部分材料很重要,以便你能完全理解我们要解决的问题。有时,误读或误解一些看似微小的词语可能会导致错误的解决方案。例如,第三章中的一个问题要求我们购买“至少”一定数量的苹果:如果你只购买“恰好”那数量的苹果,程序将无法通过某些测试用例。
输入 问题的作者提供了测试用例,必须通过所有测试用例,提交才会被认为是正确的。我们的责任是从输入中读取每个测试用例,以便我们可以处理它。我们怎么知道有多少个测试用例呢?每个测试用例的每一行包含什么内容?如果是数字,它们的范围是什么?如果是字符串,它们的长度可以是多少?所有这些信息都在这里提供。
输出 如果一个程序能够输出正确的答案,但因为输出格式不正确而未通过测试用例,这会让人非常沮丧。问题描述中的输出部分明确规定了我们应该如何生成输出。例如,它会告诉我们每个测试用例应产生多少行输出,每行应包含什么内容,是否需要在测试用例之间或之后添加空行,等等。此外,我还提供了问题的时间限制:如果程序未能在时间限制内为所有测试用例输出解决方案,那么程序将无法通过。
我已经重写了每个问题的文本,以便在整个文档中保持一致的呈现方式。尽管有这些修改,我的描述传达的信息将与官方描述相同。
本书中大多数问题,我们会从标准输入读取数据并将输出写入标准输出。(只有两个问题不涉及标准输入和输出;它们在第七章中。)这意味着我们应该使用诸如scanf、getchar、printf等 C 语言函数,而不是显式地打开和关闭文件。
启动问题:食物排队
让我们通过一个示例问题描述来熟悉一下。我会在过程中提供一些评论(括号内),引导你关注重要的部分。一旦我们理解了问题,我想不出比解决它更好的办法了。与书中的其他问题不同,我们将能够使用我希望你已经掌握的编程结构和思想来解决它。如果你能独立解决这个问题,或者轻松跟随我的解决方案,那我认为你已经为接下来的内容做好准备。如果你遇到严重的困难,可能需要回顾一些编程基础,或者先解决一些其他入门问题再继续。
这是 DMOJ 问题lkp18c2p1。(你可能现在就想去 DMOJ 网站搜索这个问题,以便我们完成代码后提交。)
问题描述
有n条排队等候食物的队伍。我们知道每条队伍中已经排队的人数。接着,m个新的人将到达,他们会加入到人数最少的队伍(即人数最少的队伍)。我们的任务是确定每个新加入的人会加入哪个队伍以及该队伍中的人数。
(花点时间理解上面的段落。接下来会有一个示例,如果有什么不清楚的,可以通过结合上面的段落和下面的示例来解决。)
这是一个示例。假设有三条队伍,队伍 1 有 3 个人,队伍 2 有 2 个人,队伍 3 有 5 个人。然后,四个新的人到达。(在继续读下去之前,试着计算一下这种情况会发生什么。)第一个人加入队伍 2,队伍 2 现在有 3 个人。第二个人加入队伍 1,队伍 1 现在有 4 个人。第三个人加入队伍 2,队伍 2 现在有 4 个人。第四个也是最后一个人加入队伍 1,队伍 1 现在有 5 个人。
输入
输入包含一个测试用例。输入的第一行包含两个正整数,n和m,分别表示排队的人数行数和新来的人数。n和m的最大值为 100。输入的第二行包含n个正整数,表示新来的人到达前每条队伍的人数。这些整数的最大值为 100。
以下是上述测试用例的输入:
3 4
3 2 5
(请注意,这里只有一个测试用例。因此,我们应该期望读取恰好两行输入。)
输出
对于每个新加入的m个人,输出一行,包含他们所加入队伍的人数。
上述测试用例的正确输出是:
2
3
3
4
解决该测试用例的时间限制是三秒。(鉴于我们每个测试用例最多要处理 100 个新加入的人,三秒足够了。我们不需要任何复杂的数据结构或算法。)
解决问题
对于涉及难以手动构建的数据结构的问题,我可能会先从读取输入开始。否则,我倾向于把这部分代码留到最后。原因是我们通常可以通过传入示例值来测试我们编写的函数;直到我们准备好解决整个问题时,才需要担心解析输入。
我们需要维护的关键数据是每条行中的人数。合适的存储方式是使用数组,每条行一个索引。我们将使用一个名为lines的变量来存储这个数组。
每个新到的人都会选择加入一条最短的行,所以我们需要一个辅助函数来告诉我们哪一行是最短的。该辅助函数见清单 2。
int shortest_line_index(int lines[], int n) {
int j;
int shortest = 0;
for (j = 1; j < n; j++)
if (lines[j] < lines[shortest])
shortest = j;
return shortest;
}
清单 2:最短行的索引
现在,给定一个lines数组以及n和m,我们可以解决一个测试用例,相关代码见清单 3:
void solve(int lines[], int n, int m) {
int i, shortest;
for (i = 0; i < m; i++) {
shortest = shortest_line_index(lines, n);
printf("%d\n", lines[shortest]);
❶ lines[shortest]++;
}
}
清单 3:解决问题
在for循环的每次迭代中,我们调用辅助函数来获取最短行的索引。然后我们打印该最短行的长度。接着,这个人加入该行:因此我们必须将该行中的人数加一 ❶。
剩下的就是读取输入并调用solve函数;这在清单 4 中完成。
#define MAX_LINES 100
int main(void) {
int lines[MAX_LINES];
int n, m, i;
scanf("%d%d", &n, &m);
for (i = 0; i < n; i++)
scanf("%d", &lines[i]);
solve(lines, n, m);
return 0;
}
清单 4:主函数
将我们的shortest_line_index、solve和main函数整合起来,并在顶部添加所需的#include语句,就能得到一个完整的解决方案,可以提交给评测系统。在提交时,务必选择正确的编程语言:对于本书中的程序,你需要选择 GCC、C99、C11,或评测系统所指定的 C 语言编译器。
如果你想在提交代码之前在本地测试它,有几种方法。由于我们的程序是从标准输入读取的,你可以做的一件事是运行程序并手动输入测试用例。对于小型测试用例,这是一种合理的做法,但重复这样做会很繁琐,特别是对于大型测试用例。(你可能还需要在输入后发出一个文件结束控制代码,例如在 Windows 上按 CTRL-Z,或在其他操作系统上按 CTRL-D。)一个更好的方法是将输入存储在文件中,然后使用输入重定向从命令提示符将程序输入重定向到该文件,而不是键盘。例如,如果你将当前问题的测试用例存储在文件food.txt中,且编译后的程序名为food,那么可以尝试:
$ food < food.txt
这样就可以轻松地处理多个测试用例:只需更改food.txt中的内容,然后再次使用输入重定向运行程序。
恭喜你!你已经解决了第一个问题。而且,你现在了解了我们在本书中处理每个问题的游戏计划,我们将使用我在这里给出的相同通用结构。我们首先会理解问题本身并通过一些例子来演示。接着,我们将开始编写代码来解决问题。不过,我们不一定会一次就写对。也许我们的代码会太慢,或者无法通过某些特定的测试用例。这没关系!我们将学习新的数据结构和算法,然后重新应对问题。最终,我们将解决每一个问题——而每次解决后,我们都会比开始时更了解并成为更好的程序员。
让我们开始吧。
在线资源
本书的补充资源,包括可下载的代码和额外的练习题,可以在https://nostarch.com/algorithmic-thinking-2nd-edition找到。
注释
《食品线》最初来自 2018 年 LKP 竞赛 2,由 DMOJ 主办。
第一章:哈希表**

计算机程序多么频繁地需要搜索信息,是否为了在数据库中查找用户资料,或者检索客户订单。没有人喜欢等待一个缓慢的搜索完成。
在本章中,我们将解决两个依赖于能够执行高效搜索的问题。第一个问题是确定集合中的所有雪花是否相同。第二个问题是确定有多少密码可以用于登录某人的账户。我们希望正确解决这些问题,但我们将看到一些正确的方法实在太慢了。我们将能够通过使用一种被称为哈希表的数据结构来大幅提升性能,我们将详细探讨它。
本章最后,我们将探讨第三个问题:确定如何从一个单词中删除字母以得到另一个单词。在这里,我们将看到盲目使用新数据结构的风险——在学习新东西时,往往会有尝试在所有地方使用它的冲动!
问题 1:独特的雪花
这是 DMOJ 问题cco07p2。
问题
我们被给定了一组雪花,我们必须确定这些雪花中是否有相同的。
一个雪花由六个整数表示,每个整数代表雪花一条臂的长度。例如,这是一个雪花:
3, 9, 15, 2, 1, 10
雪花也可以包含重复的整数,例如:
8, 4, 8, 9, 2, 8
两个雪花相同意味着什么呢?我们通过几个例子逐步理解这个定义。
首先,我们来看这两个雪花:
1, 2, 3, 4, 5, 6
和
1, 2, 3, 4, 5, 6
这些显然是相同的,因为一个雪花中的整数与另一个雪花中对应位置的整数匹配。
这是我们的第二个例子:
1, 2, 3, 4, 5, 6
和
4, 5, 6, 1, 2, 3
这些也是相同的。我们可以通过从第二个雪花的1开始并向右移动来验证这一点。我们看到整数1、2和3,然后,当我们绕到左边时,看到4、5和6。这两部分加在一起就构成了第一个雪花。
我们可以将每个雪花看作是一个圆形,如图 1-1 所示。

图 1-1:两个相同的雪花
这两个雪花是相同的,因为我们可以从第二个雪花中的1开始,顺时针移动,得到第一个雪花。
我们来试一个不同类型的例子:
1, 2, 3, 4, 5, 6
和
3, 2, 1, 6, 5, 4
从目前为止所见,我们可以推断这些雪花并不相同。如果我们从第二个雪花中的1开始,向右移动(当到达右端时绕到左边),得到的是1, 6, 5, 4, 3, 2。这与第一个雪花中的1, 2, 3, 4, 5, 6差得远。
然而,如果我们从第二个雪花中的1开始并向左移动,而不是向右移动,那么我们确实得到 1, 2, 3, 4, 5, 6!从1开始向左移动,得到 1, 2, 3,然后再绕回右侧,继续向左收集 4, 5, 6。在图 1-2 中,这相当于从第二个雪花中的1开始,按逆时针方向移动。

图 1-2:另外两个相同的雪花
这是雪花相同的第三种方式:如果两个雪花在我们按逆时针方向遍历数字时匹配,则它们相同。
综合来看,我们可以得出结论:如果两个雪花相同,或者我们可以通过右移其中一个雪花(顺时针方向)使它们相同,或者我们可以通过左移其中一个雪花(逆时针方向)使它们相同,那么这两朵雪花就是相同的。
输入
第一行输入是一个整数n,表示我们将处理的雪花数量。值n将在 1 和 100,000 之间。接下来的n行表示每个雪花,每行包含六个整数,每个整数至少为 0,最多为 10,000,000。
输出
我们的输出将是一个单行文本:
-
如果没有相同的雪花,输出
No two snowflakes are alike.(没有两个雪花是相同的)。 -
如果至少有两个相同的雪花,输出
Twin snowflakes found.(找到双胞胎雪花)。
解决测试用例的时间限制为一秒。
简化问题
解决竞争编程挑战的一般策略是首先处理问题的简化版本。让我们通过去掉一些复杂性来热身一下这个问题。
假设我们不是处理由多个整数构成的雪花,而是处理单个整数。我们有一组整数,我们想知道是否有任何两个是相同的。我们可以使用 C 语言的==运算符来测试两个整数是否相等。我们可以测试所有整数对,如果找到任何一对相同的整数,我们就停止并输出
Twin integers found.
如果没有找到相同的整数,我们将输出
No two integers are alike.
让我们创建一个identify_identical函数,使用两个嵌套循环来比较整数对,如清单 1-1 所示。
void identify_identical(int values[], int n) {
int i, j;
for (i = 0; i < n; i++) {
❶ for (j = i + 1; j < n; j++) {
if (values[i] == values[j]) {
printf("Twin integers found.\n");
return;
}
}
}
printf("No two integers are alike.\n");
}
清单 1-1:查找相同的整数
我们通过values数组将整数传递给函数。我们还传递n,即数组中的整数数量。
请注意,我们的内层循环从i + 1开始,而不是从0开始❶。如果我们从0开始,最终j会等于i,我们会将一个元素与其自身进行比较,导致错误的正面结果。
让我们使用这个小的main函数来测试identify_identical:
int main(void) {
int a[5] = {1, 2, 3, 1, 5};
identify_identical(a, 5);
return 0;
}
运行代码,你将从输出中看到我们的函数正确地识别出了一对匹配的1。通常,在本书中我不会提供太多的测试代码,但在过程中你需要亲自动手操作并测试代码。
解决核心问题
让我们拿到identify_identical函数,并尝试修改它来解决雪花问题。为此,我们需要对代码做两项扩展:
-
我们必须一次处理六个整数,而不是一个。这里使用二维数组应该会很合适:每一行将代表一个雪花,六列(每列对应一个元素)。
-
正如我们之前看到的,两个雪花可以有多种方式是相同的。不幸的是,这意味着我们不能仅仅使用
==来比较雪花。我们需要考虑“向右移动”和“向左移动”的条件(更不用说,C 语言中的==本身也无法比较数组的内容!)。正确地比较雪花将是我们算法的主要更新内容。
首先,让我们编写一对辅助函数:一个用于检查“向右移动”,另一个用于检查“向左移动”。每个辅助函数都接受三个参数:第一个雪花、第二个雪花以及第二个雪花的起始点。
向右检查
这是identical_right函数的函数签名:
int identical_right(int snow1[], int snow2[], int start)
为了判断雪花是否相同,通过“向右移动”进行扫描,我们从索引0开始扫描snow1,从索引start开始扫描snow2。如果发现对应的元素不相等,则返回0,表示没有找到相同的雪花。如果所有对应的元素都匹配,则返回1。可以把0看作是“假”,1看作是“真”。
在清单 1-2 中,我们第一次尝试编写这个函数的代码。
// bugged!
int identical_right(int snow1[], int snow2[], int start) {
int offset;
for (offset = 0; offset < 6; offset++) {
❶ if (snow1[offset] != snow2[start + offset])
return 0;
}
return 1;
}
清单 1-2:识别向右移动的相同雪花(有错误!)
正如你可能注意到的,这段代码并不会像我们希望的那样工作。问题出在start + offset ❶。如果start = 4,offset = 3,那么start + offset = 7。问题出在snow2[7],因为snow2[5]是我们允许访问的最大索引。
这段代码没有考虑到我们必须将snow2的索引回绕到左侧。如果代码即将使用错误的索引(比如6或更大),我们应该通过减去六来重置索引。这样,我们就可以继续使用索引0而不是索引6,使用索引1而不是索引7,以此类推。让我们再试一次,清单 1-3。
int identical_right(int snow1[], int snow2[], int start) {
int offset, snow2_index;
for (offset = 0; offset < 6; offset++) {
snow2_index = start + offset;
if (snow2_index >= 6)
snow2_index = snow2_index - 6;
if (snow1[offset] != snow2[snow2_index])
return 0;
}
return 1;
}
清单 1-3:识别向右移动的相同雪花
这段代码可以工作,但我们仍然可以改进它。此时,许多程序员可能会考虑做一个更改,使用%,即取模运算符。%运算符计算余数,所以x % y返回x除以y的余数。例如,9 % 3等于 0,因为 9 除以 3 没有余数;10 % 4等于 2,因为 10 除以 4 余 2。
我们可以在这里使用模运算来帮助实现循环行为。注意,0 % 6 等于 0,1 % 6 等于 1,……,5 % 6 等于 5。每个数字都小于 6,因此它们自己就是除以 6 的余数。数字 0 到 5 对应着 snow2 的合法索引,所以 % 运算符不会改变它们,这很好。对于我们问题中的索引 6,6 % 6 等于 0:6 能够被 6 整除,没有余数,从而使我们回到起点。这正是我们想要的循环行为。
让我们更新 identical_right 以使用 % 运算符。示例 1-4 展示了新的函数。
int identical_right(int snow1[], int snow2[], int start) {
int offset;
for (offset = 0; offset < 6; offset++) {
if (snow1[offset] != snow2[(start + offset) % 6])
return 0;
}
return 1;
}
示例 1-4:使用模运算识别向右移动的相同雪花
是否使用这个“模运算技巧”由你决定。它节省了一行代码,是一种许多程序员都能识别的常见模式。然而,它并不总是容易应用,哪怕是类似的循环行为,比如 identical_left。我们现在就来看看这个。
检查向左移动
identical_left 函数与 identical_right 非常相似,唯一的区别是我们需要向左移动,然后再回绕到右边。当向右遍历时,我们需要小心不要错误地访问索引 6 或更大的值;而这次,我们需要小心不要访问索引 -1 或更小的值。
不幸的是,我们的模运算解决方案在这里不能直接使用。在 C 语言中,-1 / 6 等于 0,余数是 -1,因此 -1 % 6 等于 -1。我们需要的是 -1 % 6 等于 5。
让我们不使用模运算来实现这个。我们在示例 1-5 中提供了 identical_left 函数的代码。
int identical_left(int snow1[], int snow2[], int start) {
int offset, snow2_index;
for (offset = 0; offset < 6; offset++) {
snow2_index = start - offset;
if (snow2_index <= -1)
snow2_index = snow2_index + 6;
if (snow1[offset] != snow2[snow2_index])
return 0;
}
return 1;
}
示例 1-5:识别向左移动的相同雪花
注意这个函数与示例 1-3 的相似性。我们所做的只是将偏移量相减而不是相加,并将 6 的边界检查改为 -1 的边界检查。
综合起来
有了这两个辅助函数 identical_right 和 identical_left,我们终于可以编写一个函数,告诉我们两朵雪花是否相同。示例 1-6 给出了一个实现此功能的 are_identical 函数的代码。我们只需测试每个可能的起始点在 snow2 中向右和向左移动即可。
int are_identical(int snow1[], int snow2[]) {
int start;
for (start = 0; start < 6; start++) {
❶ if (identical_right(snow1, snow2, start))
return 1;
➋ if (identical_left(snow1, snow2, start))
return 1;
}
return 0;
}
示例 1-6:识别相同的雪花
我们通过在 snow2 中向右移动 ➊ 来测试 snow1 和 snow2 是否相同。如果它们在这个标准下相同,我们返回 1(即 true)。然后,我们同样检查向左移动的标准 ➋。
在继续之前,值得暂停一下,测试 are_identical 函数在一些示例雪花对上的效果。请在继续之前先进行测试!
解决方案 1:成对比较
当我们需要比较两朵雪花时,我们直接调用 are_identical 函数,而不是使用 ==。比较两朵雪花现在就像比较两个整数一样简单。
让我们修改之前的identify_identical函数(列表 1-1),使其能够使用新的are_identical函数(列表 1-6)处理雪花。我们将对雪花进行逐对比较,根据是否找到相同的雪花输出两条消息之一。代码见列表 1-7。
void identify_identical(int snowflakes[][6], int n) {
int i, j;
for (i = 0; i < n; i++) {
for (j = i + 1; j < n; j++) {
if (are_identical(snowflakes[i], snowflakes[j])) {
printf("Twin snowflakes found.\n");
return;
}
}
}
printf("No two snowflakes are alike.\n");
}
列表 1-7:寻找相同的雪花
这个关于雪花的identify_identical函数几乎和列表 1-1 中处理整数的identify_identical函数一模一样,符号逐一对应。我们所做的只是用一个比较雪花的函数替换了==。
读取输入
我们还没有准备好提交给评测系统。我们还没有写代码从标准输入中读取雪花。请回顾一下本章开头的问题描述。我们需要读取一行包含整数n,告诉我们雪花的数量,然后读取接下来的n行,每行表示一片雪花。
列表 1-8 是一个main函数,它处理输入,然后调用列表 1-7 中的identify_identical函数。
#define SIZE 100000
int main(void) {
❶ static int snowflakes[SIZE][6];
int n, i, j;
scanf("%d", &n);
for (i = 0; i < n; i++)
for (j = 0; j < 6; j++)
scanf("%d", &snowflakes[i][j]);
identify_identical(snowflakes, n);
return 0;
}
列表 1-8: 主 函数(Solution 1)
注意到雪花(snowflakes)数组是一个静态(static)数组➊。这是因为数组非常大;如果不使用静态数组,所需的空间很可能会超过函数可用的内存量。我们使用static将数组放置在一个单独的内存块中,在那里空间不是问题。然而,使用static时要小心。普通的局部变量在每次调用函数时会被初始化,而static变量会保留上一次函数调用时的值(参见第 xxvi 页的“Static 关键字”)。
另外请注意,我们已经分配了一个包含 100,000 片雪花的数组➊。你可能会担心这是浪费内存。如果输入只有几片雪花怎么办?对于竞赛编程问题,通常可以为最大问题实例硬编码内存要求:无论如何,测试用例很可能会在最大规模下对你的提交进行压力测试!
剩下的部分很简单。我们使用scanf读取雪花的数量,并用这个数字来确定外层for循环的迭代次数。每次迭代时,我们会在内层for循环中循环六次,每次读取一个整数。然后,我们调用identify_identical函数,生成相应的输出。
将这个main函数与我们写的其他函数结合起来,就得到一个完整的程序,可以提交给评测系统。试一下……你应该会看到一个“时间限制超出”错误。看来我们还有工作要做!
诊断问题
我们的第一个解决方案太慢了,因此出现了“时间限制超出(Time-Limit Exceeded)”错误。我们来理解一下为什么。
在我们这里的讨论中,我们假设没有相同的雪花。这是我们代码的最坏情况,因为这样它不会提前停止处理。
我们第一个解决方案慢的原因是 Listing 1-7 中的两个嵌套 for 循环。这些循环将每个雪花与其他所有雪花进行比较,当雪花数量 n 很大时,会导致进行大量的比较。
让我们计算一下我们的程序进行的雪花比较次数。由于我们可能会比较每对雪花,因此我们可以将这个问题重新表述为求雪花对的总数。例如,如果我们有四个编号为 1、2、3 和 4 的雪花,那么我们的方案会进行六次雪花比较:雪花 1 和 2、1 和 3、1 和 4、2 和 3、2 和 4、以及 3 和 4。每一对由选择一个 n 雪花作为第一个雪花,再选择一个剩下的 n – 1 个雪花作为第二个雪花。
对于第一个雪花的每个 n 决策,我们对第二个雪花有 n – 1 个决策。这一共会有 n(n – 1) 次决策。然而,n(n – 1) 计算的是比较的总次数,但它重复计算了真实的雪花比较次数——例如,它同时包括了比较 1 和 2 以及比较 2 和 1。我们的解决方案只进行一次比较,所以我们可以除以 2,从而得到 n(n – 1)/2 次雪花比较,适用于 n 个雪花。
这可能看起来没什么大不了的,但让我们代入一些 n 的值到 n(n – 1)/2 中看看会发生什么。代入 10 得到 10(9)/2 = 45。进行 45 次比较对于任何计算机来说都轻松可以完成,并且可以在毫秒内完成。那如果 n = 100 呢?那会得到 4,950:还是没问题。看起来对于小的 n 我们还可以应付,但问题陈述中说我们最多可以有 100,000 个雪花。试着代入 100,000 到 n(n – 1)/2 中:得到 4,999,950,000 次雪花比较。如果你在一台普通笔记本电脑上运行 100,000 个雪花的测试用例,可能需要三分钟左右。这太慢了——我们最多需要一秒钟,而不是几分钟!对于今天的计算机,可以将每秒能执行的步骤数大约看作 3000 万。试图在一秒钟内进行接近 50 亿次雪花比较是不可行的。
如果我们展开 n(n – 1)/2,会得到 n²/2 – n/2。这里最大的指数是 2。所以,算法开发者通常将这种算法称为 O(n²) 算法,或者叫做 二次时间算法。O(n²) 被读作“n 平方的大 O”,你可以理解为它表示工作量的增长速度与问题规模的平方成正比。关于大 O 的简要介绍,请参见 附录 A。
我们需要做这么多比较,因为相同的雪花可能出现在数组的任何位置。如果有办法把相同的雪花聚集在一起,我们就能快速判断某个雪花是否属于一个相同的配对。或许我们可以尝试通过排序来将相同的雪花靠得更近?
排序雪花
C 语言有一个名为 qsort 的库函数,我们可以用它来对数组进行排序。关键要求是一个比较函数:它接收两个待排序元素的指针,如果第一个元素小于第二个,则返回负整数,若相等则返回 0,若第一个元素大于第二个,则返回正整数。我们可以使用 are_identical 来判断两个雪花是否相同;如果相同,则返回 0。
那么,一个雪花小于或大于另一个雪花是什么意思呢?我们很容易就想在这里达成某种任意的规则。例如,我们可以说,"更小" 的雪花是其第一个不同元素比另一雪花对应元素小的那个雪花。我们在清单 1-9 中做了这个操作。
int compare(const void *first, const void *second) {
int i;
const int *snowflake1 = first;
const int *snowflake2 = second;
if (are_identical(snowflake1, snowflake2))
return 0;
for (i = 0; i < 6; i++)
if (snowflake1[i] < snowflake2[i])
return -1;
return 1;
}
清单 1-9:排序用的比较函数
不幸的是,这种排序方法并不能帮助我们解决问题。你可能会尝试编写一个程序,使用排序将相同的雪花排列在一起,以便能够快速找到它们。但这是一个四个雪花的测试用例,可能会在你的笔记本电脑上失败:
4
3 4 5 6 1 2
2 3 4 5 6 7
4 5 6 7 8 9
1 2 3 4 5 6
第一和第四个雪花是相同的——但是可能会输出消息No two snowflakes are alike.。出了什么问题?
以下是 qsort 在执行过程中可能学到的两个事实:
-
雪花 4 小于雪花 2。
-
雪花 2 小于雪花 1。
从这个例子中,qsort 可以得出雪花 4 小于雪花 1,而无需直接比较雪花 4 和雪花 1!这里它依赖于“小于”的传递性。如果 a 小于 b,且 b 小于 c,那么 a 当然应该小于 c。看来我们对“更小”和“更大”的定义最终还是很重要的。
不幸的是,目前并不清楚如何在雪花上定义“小于”和“大于”,以满足传递性。如果你感到失望,也许可以安慰自己,因为我们将能够开发一个不使用排序的更快解决方案。
一般来说,利用排序将相似的值聚集在一起是一种有用的数据处理技巧。作为额外的好处,好的排序算法运行速度很快——肯定比 O(n²) 快,但我们在这里无法使用排序。
解决方案 2:做更少的工作
比较所有雪花的配对并尝试对雪花进行排序,结果证明工作量太大。为了朝着下一个、也是最终的解决方案迈进,让我们探索避免比较显然不相同的雪花的思路。例如,如果我们有雪花
1, 2, 3, 4, 5, 6
和
82, 100, 3, 1, 2, 999
这些雪花肯定不可能是相同的。我们甚至不应该浪费时间去比较它们。
第二个雪花中的数字与第一个雪花中的数字差异很大。为了设计一种方法来检测两个雪花是否不同,而无需直接比较它们,我们可以从比较雪花的第一个元素开始,因为 1 和 82 差别很大。但现在考虑这两种雪花:
3, 1, 2, 999, 82, 100
以及
82, 100, 3, 1, 2, 999
尽管 3 与 82 差异很大,这两个雪花是相同的。我们需要做的远不止只看第一个元素。
判断两个雪花是否可能相同的快速测试是使用它们元素的和。当我们将两个示例雪花的元素求和时,1, 2, 3, 4, 5, 6的总和为 21,82, 100, 3, 1, 2, 999的总和为 1,187。我们说,第一个雪花的代码是 21,第二个雪花的代码是 1,187。
我们的希望是将“21 代码的雪花”放进一个箱子,将“1,187 代码的雪花”放进另一个箱子,然后我们就再也不需要将 21 和 1,187 的雪花进行比较了。我们可以为每个雪花执行这样的分箱操作:将它的元素加起来,得到一个代码,然后将它与所有具有相同代码代码的雪花一起存储。
当然,找到两个代码为 21 的雪花并不能保证它们是相同的。例如,1, 2, 3, 4, 5, 6和16, 1, 1, 1, 1, 1的代码都是 21,但它们肯定不是相同的。
这没关系,因为我们的“和”规则旨在排除明显不相同的雪花。这使我们能够避免比较所有的配对——这是方案 1 中低效的根源——只比较那些没有被过滤掉的、显然不同的配对。
在方案 1 中,我们将每个雪花按顺序存储在数组中:第一个雪花存储在索引0,第二个存储在索引1,以此类推。在这里,我们的存储策略不同:求和代码决定了雪花在数组中的位置!也就是说,对于每个雪花,我们计算它的代码,并将该代码作为存储雪花的索引。
我们需要解决两个问题:
-
给定一朵雪花,我们如何计算它的代码?
-
当多个雪花具有相同代码时,我们该怎么办?
让我们先处理计算代码的问题。
计算求和代码
乍一看,计算代码似乎很简单。我们可以像这样将每个雪花中的所有数字加起来:
int code(int snowflake[]) {
return (snowflake[0] + snowflake[1] + snowflake[2]
+ snowflake[3] + snowflake[4] + snowflake[5]);
}
这对许多雪花有效,例如1, 2, 3, 4, 5, 6和82, 100, 3, 1, 2, 999,但考虑一下具有大数字的雪花,例如
1000000, 2000000, 3000000, 4000000, 5000000, 6000000
我们计算出的代码是21000000。我们计划将这个代码作为数组中的索引来存储雪花,因此为了容纳它,我们必须声明一个可以存储 2100 万个元素的数组。由于我们最多使用 100,000 个元素(每个雪花一个),这将是一个极为浪费内存的做法。
我们将继续使用一个可以容纳 100,000 个元素的数组。我们需要像之前一样计算雪花的代码,但之后必须将该代码强制转换为0到99999之间的数字(我们数组中的最小和最大索引)。实现这一点的一种方法是再次使用%(模)运算符。对一个非负整数取模x会得到一个介于 0 和x – 1 之间的整数。无论雪花的和是多少,只要对其取模 100,000,我们就能得到一个有效的数组索引。
这种方法有一个缺点:像这样取模会强制更多不同的雪花最终得到相同的代码。例如,1, 1, 1, 1, 1, 1和100001, 1, 1, 1, 1, 1的和是不同的——6和100006——但一旦对它们取模 100,000,结果都是6。这是一个可以接受的风险:我们只希望这种情况不会发生得太频繁;当它发生时,我们将进行必要的逐对比较。
我们将计算雪花的和并对其取模,如清单 1-10 中所示。
#define SIZE 100000
int code(int snowflake[]) {
return (snowflake[0] + snowflake[1] + snowflake[2]
+ snowflake[3] + snowflake[4] + snowflake[5]) % SIZE;
}
清单 1-10:计算雪花代码
雪花碰撞
在解决方案 1 中,我们使用了以下片段将一个雪花存储在snowflakes数组的索引i处:
for (j = 0; j < 6; j++)
scanf("%d", &snowflakes[i][j]);
之所以有效,是因为二维数组的每一行只存储一个雪花。
然而,现在我们必须处理1, 1, 1, 1, 1, 1和100001, 1, 1, 1, 1, 1类型的碰撞问题,因为它们最终会得到相同的模代码,而该代码作为雪花在数组中的索引,我们需要在同一个数组元素中存储多个雪花。也就是说,每个数组元素将不再是一个雪花,而是零个或多个雪花的集合。
存储多个元素在同一数组索引中的一种方法是使用链表,一种将每个元素与下一个元素连接起来的数据结构。在这里,雪花数组中的每个元素将指向链表中的第一个雪花;剩余的雪花可以通过next指针访问。
我们将使用典型的链表实现。每个snowflake_node包含一个雪花和指向下一个雪花的指针。为了收集这两个组件,我们将使用一个结构体。我们还会使用typedef,这让我们可以后续使用snowflake_node代替完整的struct snowflake_node:
typedef struct snowflake_node {
int snowflake[6];
struct snowflake_node *next;
} snowflake_node;
这一变化需要更新两个函数,main和identify_identical,因为这两个函数使用了我们之前的二维数组。
新的主函数
你可以在清单 1-11 中看到更新后的main代码。
int main(void) {
❶ static snowflake_node *snowflakes[SIZE] = {NULL};
➋ snowflake_node *snow;
int n, i, j, snowflake_code;
scanf("%d", &n);
for (i = 0; i < n; i++) {
➌ snow = malloc(sizeof(snowflake_node));
if (snow == NULL) {
fprintf(stderr, "malloc error\n");
exit(1);
}
for (j = 0; j < 6; j++)
➍ scanf("%d", &snow->snowflake[j]);
➎ snowflake_code = code(snow->snowflake);
➏ snow->next = snowflakes[snowflake_code];
❼ snowflakes[snowflake_code] = snow;
}
identify_identical(snowflakes);
// deallocate all malloc'd memory, if you want to be good
return 0;
}
清单 1-11:解决方案 2 的main函数
让我们逐步分析这段代码。首先,注意到我们将数组的类型从一个二维数字数组更改为一个一维指向雪花节点的指针数组 ➊。我们还声明了snow ➋,它将指向我们分配的雪花节点。
我们使用malloc为每个snowflake_node分配内存 ➌。当我们读取并存储了一个雪花的六个数字 ➍后,我们使用snowflake_code来保存雪花的代码 ➎,该代码是通过我们在列表 1-10 中编写的函数计算得出的。
最后一步是将雪花添加到snowflakes数组中,这相当于向链表中添加一个节点。我们通过将雪花插入到链表的开头来完成此操作。我们首先将插入节点的next指针指向链表中的第一个节点 ➏,然后将链表的起始点指向插入的节点 ❼。这里的顺序很重要:如果我们反转这两行的顺序,之前已经在链表中的元素将无法访问!
请注意,就正确性而言,我们在链表中添加新节点的位置并不重要。它可以添加到开头、结尾或中间——这由我们决定。所以我们应该选择最快的方式,而将节点添加到开头是最快的,因为这不需要遍历整个链表。如果我们选择将元素添加到链表的末尾,则必须遍历整个链表。如果这个链表有一百万个元素,我们就得跟随next指针一百万次,直到到达末尾——那会非常慢!
让我们看一个关于main函数如何工作的快速示例。以下是测试用例:
4
1 2 3 4 5 6
8 3 9 10 15 4
16 1 1 1 1 1
100016 1 1 1 1 1
snowflakes中的每个元素最初都是NULL,即空链表。当我们向snowflakes中添加元素时,元素将开始指向雪花节点。第一个雪花的数字之和为 21,因此它进入了索引21。第二个雪花进入了索引49。第三个雪花进入了索引21。此时,索引21处的链表包含两个雪花:16, 1, 1, 1, 1, 1后面跟着1, 2, 3, 4, 5, 6。
那第四个雪花呢?它又进入了索引21,现在我们在该位置有了一个包含三个雪花的链表。请参见图 1-3 了解我们构建的哈希表。

图 1-3:包含四个雪花的哈希表
索引21中有多个雪花。这是否意味着我们有相同的雪花?不!这强调了这样一个事实:一个包含多个元素的链表不足以证明我们有相同的雪花。我们必须比较这些元素中的每一对,才能正确地得出结论。这是谜题的最后一块拼图。
新的 identify_identical 函数
我们需要identify_identical来在每个链表中进行所有雪花的成对比较。列表 1-12 展示了实现该功能的代码。
void identify_identical(snowflake_node *snowflakes[]) {
snowflake_node *node1, *node2;
int i;
for (i = 0; i < SIZE; i++) {
❶ node1 = snowflakes[i];
while (node1 != NULL) {
➋ node2 = node1->next;
while (node2 != NULL) {
if (are_identical(node1->snowflake, node2->snowflake)) {
printf("Twin snowflakes found.\n");
return;
}
node2 = node2->next;
}
➌ node1 = node1->next;
}
}
printf("No two snowflakes are alike.\n");
}
列表 1-12:在链表中识别相同的雪花
我们从node1开始,位于链表的第一个节点➊。我们使用node2从node1右侧的节点➋开始遍历,直到链表的末尾。这样,我们将链表中的第一个雪花与该链表中所有其他雪花进行比较。然后我们将node1移动到第二个节点➌,并将第二个雪花与右侧的每个雪花进行比较。我们一直重复这个过程,直到node1到达链表的末尾。
这段代码与解决方案 1 中的identify_identical非常相似(见列表 1-7),它对所有雪花进行了两两比较。相比之下,我们的新代码仅在单个链表内进行两两比较。但如果有人构造了一个测试用例,将所有雪花都放入同一个链表中呢?那样的话,性能是不是会和解决方案 1 一样差?是的,确实会,但在没有这样的恶意数据时,我们的性能就很好。花一点时间提交解决方案 2 给评测系统,亲自看看效果。你应该会发现我们找到了一个更高效的解决方案!我们做的是使用了一种叫做哈希表的数据结构。接下来我们会深入了解哈希表。
哈希表
哈希表由两部分组成:
-
一个数组。数组中的位置称为桶。
-
哈希函数,它接收一个对象并返回其代码作为数组的索引。
哈希函数返回的代码称为哈希码;该代码决定了对象存储或哈希的位置。
仔细看看我们在列表 1-10 和 1-11 中的操作,你会发现我们已经有了这两项内容。那个code函数,它接收一个雪花并生成它的代码(一个 0 到 99,999 之间的数字),就是一个哈希函数;而那个snowflakes数组就是桶数组,每个桶里包含一个链表。
哈希表设计
设计哈希表涉及许多设计决策。这里我们将讨论其中的三项。
第一个决策是大小。在《独特的雪花》项目中,我们使用了 100,000 的数组大小。我们也可以选择使用更小或更大的数组。较小的数组节省内存。例如,在初始化时,50,000 个元素的数组存储的NULL值是 100,000 个元素数组的一半。然而,较小的数组会导致更多的对象落入同一个桶中。当多个对象落入同一个桶时,我们说发生了碰撞。碰撞过多的问题在于它们会导致长链表。理想情况下,所有的链表都应该很短,这样我们就不需要遍历并处理很多元素。较大的数组可以避免一些碰撞。
总结一下,我们这里存在一个内存-时间的权衡。哈希表太小会导致碰撞泛滥,哈希表太大则会浪费内存。一般来说,尝试选择一个合理的数组大小,比如最大元素数量的 20%、50%或 100%,作为哈希表中元素的预计数量。
在“独特的雪花”示例中,我们使用了 100,000 的数组大小来匹配雪花的最大数量;如果我们被限制使用更少的内存,较小的数组也能很好地工作。
第二个考虑因素是我们的哈希函数。在“独特的雪花”示例中,我们的哈希函数将雪花的数字加起来,取模 100,000。重要的是,这个哈希函数保证了,如果两个雪花是相同的,它们将被哈希到同一个桶中。(当然,如果它们不相同,它们也可能被哈希到同一个桶中。)这就是为什么我们可以在链表内查找,而不是在链表之间查找相同雪花的原因。
在使用哈希表解决问题时,我们所用的哈希函数应该考虑到什么情况下两个对象是相同的。如果两个对象相同,那么哈希函数必须将它们哈希到同一个桶中。如果两个对象必须完全相同才能被认为是“相同的”,我们可以进行复杂的混淆,使得对象与桶之间的映射远比我们在雪花示例中做的更为复杂。查看清单 1-13 中的oaat(逐个处理)哈希函数,了解一个例子。
#define hashsize(n) ((unsigned long)1 << (n))
#define hashmask(n) (hashsize(n) - 1)
unsigned long oaat(char *key, unsigned long len, unsigned long bits) {
unsigned long hash, i;
for (hash = 0, i = 0; i < len; i++) {
hash += key[i];
hash += (hash << 10);
hash ^= (hash >> 6);
}
hash += (hash << 3);
hash ^= (hash >> 11);
hash += (hash << 15);
return hash & hashmask(bits);
}
int main(void) { // sample call of oaat
char word[] = "hello";
// 2¹⁷ is the smallest power of 2 that is at least 100000
❶ unsigned long code = oaat(word, strlen(word), 17);
printf("%u\n", code);
return 0;
}
清单 1-13:一个复杂的哈希函数
为了像我们在main函数中那样调用oaat ➊,我们需要传入三个参数:
key 我们希望哈希的数据(这里是我们正在哈希的word字符串)
len 数据的长度(这里是word字符串的长度)
bits 我们希望得到的哈希码的位数(这里是 17)
哈希码的最大值是bits次方减去 1。例如,如果我们选择 17,那么 2¹⁷ - 1 = 131,071 就是哈希码的最大值。
oaat是如何工作的?在for循环内部,它首先将当前字节的键值添加进去。这个部分类似于我们在计算雪花中的数字和时做的事情(清单 1-10)。这些左移和异或操作的目的是将键值进行混合。哈希函数通过这种混合实现雪崩效应,也就是说,键的位发生细微变化时,会导致键的哈希值发生巨大变化。除非你故意为这个哈希函数创建了病态数据或插入了大量的键,否则很难发生多次碰撞。这突显了一个重要的观点:对于单个哈希函数,总是存在一组数据会导致大量碰撞,进而带来糟糕的性能。像oaat这样的复杂哈希函数无法防止这一点。不过,除非我们担心恶意输入,否则通常可以使用一个相当不错的哈希函数,并假设它能有效地分散数据。
的确,这就是我们使用哈希表解决方案(解决方案 2)来处理独特雪花问题如此成功的原因。我们使用了一个优秀的哈希函数,将许多不同的雪花分配到不同的桶中。由于我们没有保护代码免受攻击,因此不需要担心某个恶意人物研究我们的代码并找到方法造成数百万次碰撞。
对于我们的第三个也是最后一个设计决策,我们需要考虑使用什么作为桶。在独特雪花中,我们将链表作为每个桶的存储结构。像这样使用链表被称为链式法。
另一种方法被称为开放寻址法,每个桶最多存放一个元素,并且没有链表。为了解决碰撞问题,我们会遍历桶,直到找到一个空桶。例如,假设我们尝试将一个对象插入到桶号 50 中,但桶 50 已经被占用了。那么我们可能会尝试桶 51,再试桶 52,再试桶 53,直到找到一个空桶。不幸的是,这种简单的序列在哈希表中存储了许多元素时,可能导致较差的性能,因此在实践中通常会使用更复杂的查找方案。
链接法通常比开放寻址法更容易实现,这也是我们在独特雪花问题中使用链接法的原因。然而,开放寻址法确实有一些优点,包括通过不使用链表节点来节省内存。
为什么使用哈希表?
使用哈希表大大加速了我们解决独特雪花问题的方案。在典型的笔记本电脑上,处理包含 100,000 个元素的测试用例只需几分之一秒的时间!不需要进行元素之间的配对比较,也不需要排序,只需对一堆链表进行一些处理。
回想一下我们使用的数组大小是 100,000。我们程序中最多可以处理的雪花数量也是 100,000。如果我们给定 100,000 个雪花,并假设每个雪花都放入自己的桶中,那么每个链表中将只有一个雪花。如果我们运气稍差,可能会有几个雪花发生碰撞并进入同一个桶。不过,在没有病态数据的情况下,我们预计每个链表最多只有几个元素。因此,在一个桶内进行所有成对比较只需要很少的常数步骤。我们预计哈希表能提供一个线性时间的解决方案,因为我们在每个n个桶中都只进行常数次数的步骤。所以我们预计需要大约n步,而不是我们在解决方案 1 中使用的n(n – 1)/2 公式。从大 O 的角度来看,我们预计会得到一个O(n)的解决方案。
每当你在解决一个问题时发现自己反复搜索某个元素时,可以考虑使用哈希表。哈希表将慢速的数组搜索转换为快速查找。对于某些问题,你可能能通过排序数组而不是使用哈希表来解决。此时,可以使用一种叫做二分查找的技术(在第七章中讨论)来快速查找已排序数组中的元素。但通常——就像在独特雪花问题和我们接下来要解决的问题中——那是行不通的。哈希表来救援!
问题 2:登录混乱
让我们再来看一个问题,并注意一个天真的解决方案如何依赖于慢速搜索。然后,我们将引入哈希表来实现显著的加速。我们会比解决独特雪花问题时快一点,因为现在我们知道该注意什么了。
这是 DMOJ 问题coci17c1p3hard。
问题
要登录社交网站上的账户,你会期望只有你的密码有效——没有人能用其他密码进入你的账户。例如,假设你的密码是dish。(这是一个非常弱的密码——千万不要在任何地方使用它!)要登录你的账户,某人需要输入准确的dish作为密码。这就是登录的工作原理。
但现在想象一下,你想加入一个(希望是理论上的)社交网络网站,这个网站有一个重大的安全问题:除了你的密码,其他密码也可以用来进入你的账户!具体来说,如果某人尝试一个包含你密码作为子字符串的密码,那么他们就能进入。如果你的密码是dish,那么像brandish和radishes这样的密码也能用来进入你的账户,因为字符串dish包含在其中。你不知道该为你的账户选择什么密码——所以你会在不同的时刻问自己:“如果我选择了这个密码,有多少当前用户的密码能够进入我的账户?”
我们需要支持两种类型的操作:
添加 使用给定的密码注册一个新用户。
查询 给定一个提议密码 p,返回当前用户密码中有多少个可以用来进入密码为 p 的账户。
输入
输入包含以下几行:
-
包含 q 的一行,表示要执行的操作次数。q 介于 1 和 100,000 之间。
-
q 行,每行执行一个添加或查询操作。
以下是可以在 q 行中执行的操作:
-
添加操作以数字
1开头,后跟一个空格和新用户的密码。它表示一个新用户已加入,并使用提供的密码。此操作不会产生任何输出。 -
查询操作以数字
2开头,后跟一个空格和提议的密码 p。它表示我们应当输出能够进入密码为 p 的账户的当前用户密码的数量。
所有在这些操作中提供的密码都是由 1 到 10 个小写字母组成。
输出
输出每个查询操作的结果,每行一个。
解决测试用例的时间限制是三秒钟。
解决方案 1:查看所有密码
让我们通过一个测试用例来确保我们完全理解需要做什么。
❶ 6
➋ 2 dish
1 brandish
1 radishes
1 aaa
➌ 2 dish
➍ 2 a
从第一行 ➊ 可以看出我们需要执行 6 次操作。第一次操作 ➋ 问我们有多少个现有用户的密码能够进入一个密码为 dish 的账户。好吧,当前没有任何用户,所以答案是 0!
接下来,我们添加三个用户密码,然后进行下一个查询操作 ➌。现在我们要查询 dish 在这三个密码中的情况。你可能会想,我们需要遍历现有的密码,统计其中有多少个包含 dish。 (嗯,搜索!这给了我们第一个提示,可能需要使用哈希表。)如果你这样做,你会发现两个密码——brandish 和 radishes——包含了 dish。所以答案是 2。
那么最后一个查询 ➍ 呢?我们要查找包含 a 的密码。如果你遍历这三个现有的密码,你会发现它们都有 a!因此,答案是 3。
完成!完整测试用例的正确输出是:
0
2
3
如果我们实现刚才使用的解决策略,最终可能会得到像 清单 1-14 这样的内容。
❶ #define MAX_USERS 100000
#define MAX_PASSWORD 10
int main(void) {
static char users[MAX_USERS][MAX_PASSWORD + 1];
int num_ops, op, op_type, total, j;
char password[MAX_PASSWORD + 1];
int num_users = 0;
scanf("%d", &num_ops);
for (op = 0; op < num_ops; op++) {
scanf("%d%s", &op_type, password);
➋ if (op_type == 1) {
strcpy(users[num_users], password);
num_users++;
➌ } else {
total = 0;
for (j = 0; j < num_users; j++)
if (strstr(users[j], password))
total++;
printf("%d\n", total);
}
}
return 0;
}
清单 1-14:解决方案 1
问题描述中说,我们最多会有 100,000 次操作。如果每次都是添加操作,那么我们会得到 100,000 个用户 ➊,并且不能超过这个数量。
对于每个添加操作 ➋,我们将新密码复制到我们的用户数组中。而对于每个查询操作 ➌,我们遍历所有现有用户的密码,检查其中有多少个密码包含了提议的密码作为子字符串。
就像我们对独特雪花问题的第一个解决方案一样,这个解决方案的速度不足以及时通过测试用例。因为我们这里使用的是一个 O(n²) 算法,其中 n 是查询的数量。
我们能够迅速将用户密码添加到数组中——这没有问题。让我们速度变慢的是查询操作,因为每次查询都需要扫描所有现有的用户密码。这就是二次时间行为的来源。例如,假设一个测试案例开始时添加了 50,000 个用户密码,然后再进行 50,000 次查询。总共需要大约 50,000 × 50,000 = 2,500,000,000 步。超过了 20 亿步;在我们允许的三秒时间限制内,显然无法完成这么多步骤。
解决方案 2:使用哈希表
我们需要加快查询操作的速度。我们将使用哈希表来实现这一点。但是怎么做呢?难道我们不需要将每个查询密码与所有现有密码进行比较吗?不!继续阅读,我们将从另一个角度解决这个问题。
如何使用哈希表
对于每个查询操作,如果我们能够直接在哈希表中查找所需的密码,以确定有多少现有用户密码可以进入其账户,那就太好了。例如,一旦我们添加了密码为brandish、radishes和aaa的用户,那么我们就能够在哈希表中查找dish并得到一个值2。但是,在我们添加这三个用户密码的同时,我们怎么知道需要跟踪dish的情况呢?我们并不知道哪些密码会在之后被查询。
好吧,因为我们无法预知未来,不如对每个用户密码的每个子字符串都加一。这样一来,如果我们以后需要查找任何子字符串的总数,就已经做好准备了。
关注brandish密码。如果我们考虑每个子字符串,那么我们会为b、br、bra、bran、brand、brandi、brandis、brandish、r、ra等子字符串递增总数。别担心:如果我们处理所有这些子字符串,我们肯定会碰到dish并对它进行递增。当我们对radishes进行相同的子字符串处理时,我们会再次递增dish。因此,dish的总数最终会达到 2,正如我们需要的那样。
你可能会担心我们在这里做得过于冗余,处理大量的子字符串密码,而其中绝大部分不会被查询。然而,请记住,从问题描述中我们知道密码最多有 10 个字符。每个子字符串都有一个起始点和一个终止点。在一个 10 个字符的密码中,只有 10 个可能的起始点和 10 个可能的终止点,所以一个密码中子字符串的最大数量是 10 × 10 = 100。由于我们最多有 100,000 个用户密码,每个密码最多有 100 个子字符串,我们在哈希表中最多会存储 100,000 × 100 = 10,000,000 个子字符串。这肯定会占用一些兆字节的内存,但这完全不值得担心。我们正在用一点内存换取在需要时能够查找任何密码总数的能力。
与“独特雪花”问题类似,我们的解决方案将使用链表的哈希表。我们还需要一个哈希函数。这里我们不会使用像雪花哈希函数那样的方式,因为它会导致像cat和act这样的密码发生冲突(它们是字母重排的)。与“独特雪花”问题不同,密码不仅应该通过字母来区分,还应该通过字母的位置来区分。当然,某些冲突是不可避免的,但我们应该尽力减少它们的发生频率。为此,我们将使用清单 1-13 中的那个“狂野”的oaat哈希函数。
搜索哈希表
我们将使用以下节点在哈希表中存储密码:
#define MAX_PASSWORD 10
typedef struct password_node {
char password[MAX_PASSWORD + 1];
int total;
struct password_node *next;
} password_node;
这个节点类似于“独特雪花”中的snowflake_node,但我们现在还增加了一个total成员,用来跟踪该密码的总计数。
现在我们可以编写一个辅助函数来在哈希表中搜索给定的密码。查看清单 1-15 获取代码。
#define NUM_BITS 20
password_node *in_hash_table(password_node *hash_table[], char *find) {
unsigned password_code;
password_node *password_ptr;
❶ password_code = oaat(find, strlen(find), NUM_BITS);
➋ password_ptr = hash_table[password_code];
while (password_ptr) {
➌ if (strcmp(password_ptr->password, find) == 0)
return password_ptr;
password_ptr = password_ptr->next;
}
return NULL;
}
清单 1-15:搜索密码
这个in_hash_table函数接收一个哈希表和一个要查找的密码。如果找到该密码,函数将返回指向对应password_node的指针;否则,返回NULL。
该函数通过计算密码的哈希码 ➊,并利用该哈希码找到合适的链表来进行搜索 ➋。然后,它检查链表中的每个密码,寻找匹配项 ➌。
向哈希表添加数据
我们还需要一个函数,用于在哈希表中将给定密码的数量加一。查看清单 1-16 获取代码。
void add_to_hash_table(password_node *hash_table[], char *find) {
unsigned password_code;
password_node *password_ptr;
❶ password_ptr = in_hash_table(hash_table, find);
if (!password_ptr) {
password_code = oaat(find, strlen(find), NUM_BITS);
password_ptr = malloc(sizeof(password_node));
if (password_ptr == NULL) {
fprintf(stderr, "malloc error\n");
exit(1);
}
strcpy(password_ptr->password, find);
➋ password_ptr->total = 0;
password_ptr->next = hash_table[password_code];
hash_table[password_code] = password_ptr;
}
➌ password_ptr->total++;
}
清单 1-16:将密码的总数加一
我们使用in_hash_table函数 ➊来判断密码是否已经存在于哈希表中。如果没有,我们将它添加到哈希表,并暂时将其计数设置为 0 ➋。将每个密码添加到哈希表的技术与“独特雪花”问题中的方法相同:每个桶都是一个链表,我们将每个密码添加到这些链表的开头。
接下来,无论密码是否已经存在,我们都将其总数增加 ➌。这样,我们刚添加的密码的total将从 0 增加到 1,而已存在的密码则只是将其total增加。
main函数,第一版
准备好编写main函数了吗?我们的第一次尝试在清单 1-17 中。
// bugged!
int main(void) {
❶ static password_node *hash_table[1 << NUM_BITS] = {NULL};
int num_ops, op, op_type, i, j;
char password[MAX_PASSWORD + 1], substring[MAX_PASSWORD + 1];
password_node *password_ptr;
scanf("%d", &num_ops);
for (op = 0; op < num_ops; op++) {
scanf("%d%s", &op_type, password);
➋ if (op_type == 1) {
for (i = 0; i < strlen(password); i++)
for (j = i; j < strlen(password); j++) {
strncpy(substring, &password[i], j - i + 1);
substring[j - i + 1] = '\0';
➌ add_to_hash_table(hash_table, substring);
}
➍ } else {
➎ password_ptr = in_hash_table(hash_table, password);
➏ if (!password_ptr)
printf("0\n");
else
printf("%d\n", password_ptr->total);
}
}
return 0;
}
清单 1-17:主函数(有 Bug!)
为了确定哈希表的大小,我们使用了这段奇怪的代码:1 << NUM_BITS ➊。我们在列表 1-15 中将NUM_BITS设置为 20;1 << 20是计算 2²⁰的快捷方式,结果是 1,048,576(oaat哈希函数要求哈希表的元素数量是 2 的幂)。记住,我们的最大用户数量是 100,000;我选择的哈希表大小大约是这一最大值的 10 倍,以考虑到每个密码会插入多个字符串。较小或较大的哈希表也能正常工作。
对于每个添加操作 ➋,我们通过使用add_to_hash_table辅助函数 ➌来为每个子字符串增加总数。对于每个查询操作 ➍,我们使用in_hash_table辅助函数 ➎来检索密码的总数;如果密码不在哈希表中 ➏,那么我们输出0。
将我们所有的函数放在一起,试试运行我们的代码!记得这个测试用例吗?
6
2 dish
1 brandish
1 radishes
1 aaa
2 dish
2 a
输出应该是:
0
2
3
不幸的是,我们的代码给出了以下结果:
0
2
5
等等,5?这个5从哪里来的?
看看密码aaa。里面有多少个a的子字符串?有三个!我们将找到其中的每一个,从而使a的总数增加三次。但这不合理:aaa最多应该让a的总数增加一次,而不是多次。毕竟,aaa只是一个密码。
主函数,第 2 版
我们需要做的是确保,对于每个密码,它的每个子字符串只计数一次。为此,我们将维护一个数组,记录当前密码生成的所有子字符串。在使用一个子字符串之前,我们会检查,确保该子字符串还没有被使用过。
我们在这里引入了一个新的搜索,所以值得思考是否需要一个新的子字符串哈希表。虽然我们确实可以为此添加另一个哈希表,但我们不需要这样做:正如我们之前所说,每个密码的子字符串数量不会太多,因此通过它们进行线性搜索(即逐个元素搜索)会足够快。
查看列表 1-18 了解最后的调整。
❶ int already_added(char all_substrings[][MAX_PASSWORD + 1],
int total_substrings, char *find) {
int i;
for (i = 0; i < total_substrings; i++)
if (strcmp(all_substrings[i], find) == 0)
return 1;
return 0;
}
int main(void) {
static password_node *hash_table[1 << NUM_BITS] = {NULL};
int num_ops, op, op_type, i, j;
char password[MAX_PASSWORD + 1], substring[MAX_PASSWORD + 1];
password_node *password_ptr;
int total_substrings;
char all_substrings[MAX_PASSWORD * MAX_PASSWORD][MAX_PASSWORD + 1];
scanf("%d", &num_ops);
for (op = 0; op < num_ops; op++) {
scanf("%d%s", &op_type, password);
if (op_type == 1) {
total_substrings = 0;
for (i = 0; i < strlen(password); i++)
for (j = i; j < strlen(password); j++) {
strncpy(substring, &password[i], j - i + 1);
substring[j - i + 1] = '\0';
➋ if (!already_added(all_substrings, total_substrings, substring)) {
add_to_hash_table(hash_table, substring);
strcpy(all_substrings[total_substrings], substring);
total_substrings++;
}
}
} else {
password_ptr = in_hash_table(hash_table, password);
if (!password_ptr)
printf("0\n");
else
printf("%d\n", password_ptr->total);
}
}
return 0;
}
列表 1-18:一个新的辅助函数和修正过的主函数
我们有一个新的already_added辅助函数 ➊,它用来告诉我们当前密码的find子字符串是否已经存在于all_substrings数组中。
在main函数中,注意我们现在检查是否已经遇到当前子字符串 ➋。如果没有,只有在此时我们才将其添加到哈希表中。
现在是时候将我们的代码提交给评审了。加油!像《唯一的雪花》一样,使用哈希表带来的加速效果将时间复杂度从O(n²)改进为O(n),这对于三秒的时间限制来说已经足够快了。
问题 3:拼写检查
有时,问题看起来可以通过某种方式解决,因为它们与其他问题相似。这里有一个问题,看起来哈希表很合适,但经过进一步思考,我们发现哈希表过于复杂,超出了实际要求。
这是 Codeforces 问题 39J(拼写检查)。(找到它的最简单方法是在线搜索 Codeforces 39J)。
问题
在这个问题中,我们给定了两个字符串,第一个字符串比第二个字符串多一个字符。我们的任务是确定从第一个字符串中删除一个字符到达第二个字符串的方式数量。例如,从 favour 到 favor 只有一种方法:我们可以删除第一个字符串中的 u 字符。
从 abcdxxxef 到 abcdxxef 有三种方法:我们可以从第一个字符串中删除任意一个 x 字符。
该问题的背景是拼写检查器。第一个字符串可能是 bizzarre(一个拼写错误的单词),第二个字符串可能是 bizarre(一个正确的拼写)。在这种情况下,有两种方法可以修正拼写错误——删除第一个字符串中的两个 z 中的任意一个。然而,问题更为一般,和实际的英语单词或拼写错误无关。
输入
输入是两行,第一行是第一个字符串,第二行是第二个字符串。每个字符串最多可以有一百万个字符。
输出
如果没有办法从第一个字符串中删除一个字符以获得第二个字符串,输出 0。否则,输出两行:
-
第一行输出从第一个字符串中删除一个字符以获得第二个字符串的方式数量。
-
第二行输出一个以空格分隔的列表,列出可以从第一个字符串中删除的字符的索引,以获得第二个字符串。问题要求我们从
1开始索引字符串,而不是从0(虽然这有点麻烦,但我们会小心)。
例如,对于以下输入:
abcdxxxef
abcdxxef
我们将输出:
3
5 6 7
5 6 7 是第一个字符串中三个 x 字符的索引,因为我们是从一开始计数(而不是从零)。
解决测试用例的时间限制是两秒。
思考哈希表
我花了非常多的时间去寻找驱动本书章节的题目,这些题目决定了我可以教你有关相关数据结构或算法的内容。我需要这些问题的解决方案在算法上足够复杂,但问题本身要简单到足以让我们理解要求并保留相关细节。我真的以为我找到了这种类型的哈希表问题,正好适合这一章节。然后我去解决它。
在问题 2,登录混乱中,我们作为输入得到了密码。这是件好事,因为我们只需将密码的每个子字符串放入哈希表中,然后根据需要使用哈希表进行查找。而在这里,在问题 3 中,我们没有得到任何这样的字符串列表来插入。毫不气馁,当我第一次尝试解决这个问题时,我创建了一个哈希表,并将第二个(即较短)字符串的每个前缀插入其中。例如,对于单词abc,我会插入a、ab和abc。我还为第二个字符串的后缀创建了另一个哈希表。对于单词abc,我会插入c、bc和abc。有了这些哈希表,我开始考虑第一个字符串的每个字符。删除每个字符相当于将字符串分成一个前缀和一个后缀。我们只需使用哈希表检查前缀和后缀是否都存在。如果它们存在,那么删除这个字符就是我们将第一个字符串转换为第二个字符串的一种方式。
这种技巧很诱人,对吧?想试试看吗?
我没有考虑到的一点是,每个字符串的长度可能达到一百万个字符。我们当然不能将所有的前缀和后缀本身存储在哈希表中——那会占用太多内存。我尝试过在哈希表中使用指针指向前缀和后缀的开始和结束位置。这解决了内存使用的问题,但并没有让我们摆脱每次使用哈希表进行搜索时都要比较这些超长字符串的问题。在《独特的雪花》和《登录混乱》中,哈希表中的元素很小:一个雪花 6 个整数,密码 10 个字符。那没什么。然而,在这里,情况不同:我们可能有一百万个字符的字符串!比较这么长的字符串非常耗时。
另一个时间杀手是计算这些字符串的前缀和后缀的哈希码。我们可能会在一个长度为 900,000 的字符串上调用oaat,然后在一个多了一个字符的字符串上再次调用它。这会重复执行第一次oaat调用中的所有工作,而我们只想将一个额外的字符加入到正在哈希的字符串中。
然而,我坚持了下去。我心里一直认为哈希表是解决此问题的正确方式,因此没考虑其他选择。此时,我本应该重新审视这个问题。然而,我学到了关于增量哈希函数的知识,这种哈希函数在生成与之前哈希元素非常相似的元素的哈希码时非常快速。例如,如果我已经拥有了abcde的哈希码,那么使用增量哈希函数计算abcdef的哈希码会非常快速,因为它可以依赖于已完成的abcde的工作,而不需要从头开始。
另一个见解是,如果比较过长的字符串代价太高,我们应该尽量避免进行比较。我们可以仅仅寄希望于我们的哈希函数足够好,并且测试用例足够幸运,不会发生碰撞。如果我们在哈希表中查找某个元素,并且找到了匹配……好吧,我们希望它确实是一个有效的匹配,而不是我们在碰到假阳性时运气不好。如果我们愿意做出这个让步,那么我们可以使用比本章到目前为止所用的哈希表数组更简单的结构。在数组prefix1中,每个索引i给出了第一个字符串长度为i的前缀的哈希值。在数组prefix2中,每个索引i给出了第二个字符串长度为i的前缀的哈希值。在另外两个数组中,我们可以对第一个字符串的后缀和第二个字符串的后缀做类似的操作。
下面是一些代码,展示了如何构建prefix1数组:
// long long is a very large integer type in C99
unsigned long long prefix1[1000001];
prefix1[0] = 0;
for (i = 1; i <= strlen(first_string); i++)
❶ prefix1[i] = prefix1[i - 1] * 39 + first_string[i];
其他数组可以类似构建。
在这里使用无符号整数非常重要。在 C 语言中,溢出对于无符号整数是定义明确的,但对于有符号整数则不然。如果一个单词足够长,我们肯定会发生溢出,因此我们不希望出现未定义行为。
现在我们可以使用这些数组来确定前缀或后缀是否匹配。例如,要确定第一个字符串的前i个字符是否等于第二个字符串的前i个字符,只需检查prefix1[i]和prefix2[i]是否相等。
注意,给定prefix1[i - 1]的哈希值,计算prefix1[i]的哈希值需要做的工作非常少:仅仅是一次乘法操作,再加上新的字符➊。为什么要乘以 39 并加上字符呢?为什么不使用其他方法作为哈希函数?说实话,因为我选择的方法在 Codeforces 的测试用例中没有导致任何碰撞。是的,我知道,这样的答案不够令人满意。
不用担心:还有更好的方法!为了达到这个目标,我们将更仔细地看待问题,而不是直接跳到哈希表的解决方案。
临时解决方案
让我们再仔细思考一个早期的例子:
abcdxxxef
abcdxxef
假设我们从第一个字符串中删除了f(索引9)。这样会使第一个字符串与第二个字符串相等吗?不,所以9不会出现在我们以空格分隔的索引列表中。这两个字符串有很长的匹配前缀。确切来说,有六个匹配字符:abcdxx。之后,两个字符串开始分歧,第一个字符串有一个x,而第二个字符串有一个e。如果我们不解决这个问题,那么我们就不可能希望这两个字符串相等。f的位置太靠右,它的删除不会让两个字符串相等。
这引出了我们的第一个观察:如果最长公共前缀的长度(在我们的例子中是六,即abcdxx的长度)是p,那么我们删除字符的选择只能是索引小于等于p + 1 的字符。在我们的例子中,我们应该考虑删除索引小于等于 7 的字符:a、b、c、d、第一个x、第二个x和第三个x。删除索引大于p + 1 的字符无法修复位于索引p + 1 处的不同字符,因此无法使字符串相等。
请注意,并不是所有的删除操作都有效。例如,删除第一个字符串中的a、b、c或d并不能得到第二个字符串。只有删除每一个x才能得到第二个字符串。因此,虽然我们有一个上界来限制需要考虑的索引(≤ p + 1),我们仍然需要一个下界。
要考虑下界,可以考虑从第一个字符串中删除a。这样会使两个字符串相等吗?不会。这个推理与上一段类似:在a右侧有不同的字符,删除a也无法修复这些字符。如果最长公共后缀(在我们的例子中为四,即xxef的长度)是s,那么我们应该考虑删除第一个字符串的最后s + 1 个字符。从索引的角度来看,我们只关注那些大于等于n - s的索引,其中n是第一个字符串的长度。在我们的例子中,这告诉我们只需要考虑索引大于等于 9 - 4 = 5 的情况。在上一段中,我们曾提到应只关注小于等于 7 的索引。综合来看,我们发现索引5、6和7是删除后能将第一个字符串转变为第二个字符串的索引。如图 1-4 所示,关键在于那些同时出现在前缀和后缀中的索引:这些字符的删除是有效的。

图 1-4:最长前缀与最长后缀的重叠
通常情况下,感兴趣的索引范围从n - s到p + 1。对于这个范围内的任何一个索引,我们知道从p + 1 开始,两个字符串在该索引之前是相同的。我们也知道从n - s开始,两个字符串在该索引之后是相同的。因此,一旦我们删除该索引,两个字符串将变得完全相同。如果该范围为空,则没有任何索引的删除能够将第一个字符串转换为第二个字符串,因此此时输出0。否则,我们使用for循环遍历这些索引,并用printf输出以空格分隔的索引列表。让我们来看一下代码吧!
最长公共前缀
我们在清单 1-19 中有一个辅助函数来计算两个字符串的最长公共前缀的长度。
int prefix_length(char s1[], char s2[]) {
int i = 1;
while (s1[i] == s2[i])
i++;
return i - 1;
}
清单 1-19:计算最长公共前缀
这里,s1是第一个字符串,s2是第二个字符串。我们使用1作为字符串的起始索引。从索引1开始,循环会继续,只要相应的字符相等。(在像abcde和abcd这样的情况下,e无法与abcd结尾的空终止符匹配,因此i会正确地为5。)当循环终止时,索引i是第一个不匹配字符的索引;因此,i - 1就是最长公共前缀的长度。
最长公共后缀
现在,为了计算最长公共后缀,我们使用列表 1-20。
int suffix_length(char s1[], char s2[], int len) {
int i = len;
while (i >= 2 && s1[i] == s2[i - 1])
i--;
return len - i;
}
列表 1-20:计算最长公共后缀
这段代码与列表 1-19 非常相似。不同的是,这次我们是从右到左比较,而不是从左到右。基于这个原因,我们需要len参数,它给出了第一个字符串的长度。我们允许进行的最终比较是i == 2。如果是i == 1,我们就会访问s2[0],这不是字符串的有效元素!
主函数
最后,我们在列表 1-21 中得到了我们的main函数。
#define SIZE 1000000
int main(void) {
❶ static char s1[SIZE + 2], s2[SIZE + 2];
int len, prefix, suffix, total;
➋ gets(&s1[1]);
➌ gets(&s2[1]);
len = strlen(&s1[1]);
prefix = prefix_length(s1, s2);
suffix = suffix_length(s1, s2, len);
➍ total = (prefix + 1) - (len - suffix) + 1;
➎ if (total < 0)
➏ total = 0;
❼ printf("%d\n", total);
❽ for (int i = 0; i < total; i++) {
printf("%d", i + len - suffix);
if (i < total - 1)
printf(" ");
else
printf("\n");
}
return 0;
}
列表 1-21:主 函数
我们使用SIZE + 2作为两个字符数组的大小➊。我们需要读取的最大字符数为一百万,但我们需要一个额外的元素来存储空字符终止符。除此之外,我们还需要一个元素,因为我们从索引1开始索引字符串,"浪费"了索引0。
我们读取了第一个➋和第二个字符串➌。注意,我们将指针传递给每个字符串的索引1:因此,gets从索引1开始存储字符,而不是从索引0开始。在调用我们的辅助函数后,我们计算可以从s1中删除的索引数量,以得到s2 ➍。如果这个数字为负数 ➎,我们将其设置为0 ➏。这使得printf调用是正确的 ❼。我们使用for循环 ❽打印正确的索引。我们希望从len - suffix开始打印,因此我们将len - suffix添加到每个整数i。
提交给评测系统时,可能需要选择 GNU G++而不是 GNU GCC。
就是这样:一个线性时间的解决方案。我们不得不进行一些艰难的分析,但在此之后我们能够不使用复杂的代码,也不需要哈希表。考虑哈希表之前,问问自己,问题中有没有任何因素会使得哈希表难以使用?是否真的需要搜索,或者问题中是否有某些特性使得根本不需要这种搜索?
总结
哈希表是一种数据结构:它是一种组织数据的方法,使某些操作变得快速。哈希表加速了对指定元素的查找。为了加速其他操作,我们需要其他数据结构。例如,在第八章中,我们将学习堆,这是一种数据结构,当我们需要快速识别数组中的最大或最小元素时,它非常有用。
数据结构是组织和操作数据的通用方法。哈希表适用于各种问题,超出了这里展示的内容;希望你现在能有良好的直觉,知道在何时可以使用哈希表。注意观察其他效率高的解决方案,看看哪些问题因为反复的、缓慢的查找而受到制约。
备注
独特的雪花问题最早出自 2007 年加拿大计算机奥林匹克竞赛。
登录混乱问题来源于 2017 年克罗地亚信息学奥林匹克竞赛第一轮的一个题目。
拼写检查问题最早出自 2010 年 Codeforces 举办的学校队伍竞赛#1。前缀-后缀的解决方案(在我最终放弃哈希表方案后使用)来源于这个链接上的一篇帖子。
在我们的哈希表代码中,我们使用了malloc来分配链表的节点。有时可以完全避免使用malloc和节点结构。如果你有兴趣了解如何做到这一点,请参阅附录 B 中的“独特的雪花:隐式链表”部分。
oaat哈希函数由 Bob Jenkins 设计(参见http://burtleburtle.net/bob/hash/doobs.html)。
有关哈希表应用和实现的更多信息,请参见 Tim Roughgarden(2018 年)的《Algorithms Illuminated (Part 2): Graph Algorithms and Data Structures》。
第二章:树与递归

本章我们将讨论两个需要处理和解答层次数据问题的题目。第一个问题是关于从邻里收集糖果,第二个问题则是关于家谱查询。由于循环是处理数据集合的自然方式,我们会先尝试使用循环。但很快我们就会发现,这些问题挑战了我们通过循环轻松表达的能力,这将促使我们改变思考和解决问题的方式。本章结束时,你将了解递归,这是一种在解决问题时应用的技巧,特别是当问题的解决方案涉及到对更简单、更小问题的解决方案时。
问题 1:万圣节糖果
这是 DMOJ 问题 dwite12c1p4。
问题
万圣节到了,这是一个通常涉及穿上戏服、从邻居那里获得糖果以及吃到肚子痛的节日。在这个问题中,你要尽可能高效地收集某个特定邻里的所有糖果。这个邻里有着严谨而奇特的形状。图 2-1 展示了一个示例邻里。

图 2-1:一个示例邻里
带有数字的圆圈是房子。每个数字表示你访问该房子时将获得的糖果数量。糖果值最多是两位数。顶部的圆圈是你的起始位置。没有数字的圆圈是街道交叉口,你可以选择接下来要走的方向。连接圆圈的线条是街道。从一个圆圈移动到另一个圆圈就相当于走一条街道。
让我们思考一下你如何通过这个邻里移动。从顶部圆圈开始。如果你沿着右边的街道走,你会到达一个交叉口。如果你从这个圆圈沿右边的街道继续走,你将到达一个房子并收集到 41 块糖果。然后你可以沿两条街道走回顶部,回到起始位置。这样你总共走了四条街道并收集了 41 块糖果。
然而,你的目标是收集所有的糖果,并通过走最少的街道来实现这一目标。你可以在收集完所有糖果后立即结束步行;不需要返回到起始圆圈。
输入
输入由恰好五行组成,每行是最多 255 个字符的字符串,描述一个邻里。
如何用字符串编码一个图形?这与第一章中的独特雪花问题不同,后者中的每个雪花只是六个整数。在这里,我们有圆圈、连接圆圈的线条以及一些圆圈中的糖果值。
与独特的雪花问题一样,我们可以通过最初忽略一些完整问题的复杂性来简化问题。因此,我将推迟输入方式的说明。虽然如此,我可以给你一个预告:有一种相当巧妙且简洁的方式将这些图形表示为字符串,敬请期待。
输出
我们的输出将是五行文本,每一行对应一个输入行。每一行输出包含两个由空格分隔的整数:获取所有糖果所需的最小步数和获得的糖果总量。
解决测试用例的时间限制是两秒钟。
二叉树
在图 2-2 中,我在图 2-1 的基础上,添加了非房子圆圈中的字母。这些字母与问题无关,不会影响我们的代码,但它们使我们能够唯一地引用每个圆圈。

图 2-2:带有字母标签的示例邻域
我们的万圣节任务问题中的邻域特定形状被称为二叉树。二叉和树这两个词在这里都很重要。让我们从树的定义开始解释。
定义树
树是一种由节点(圆圈)和节点之间的边(表示街道的线)组成的结构。顶部的节点——H 圆圈——被称为根。你经常会看到顶点与节点同义使用;在本书中,我将坚持使用“节点”这一术语。
树中的节点具有父子关系。例如,我们说 H 是 F 和 G 的父节点,因为从 H 到 F 和从 H 到 G 都有边。我们也说 F 和 G 是 H 的子节点。更具体地说,F 是 H 的左子节点,而 G 是 H 的右子节点。任何没有子节点的节点被称为叶子。在当前问题中,具有糖果值的节点(即房子)是叶子节点。
计算机科学家在讨论树时使用的许多术语,源自家谱树的概念。例如,F 和 G 是兄妹,因为它们有相同的父母。E 是 H 的后代,因为 E 可以通过从 H 向下遍历树来到达。
树的高度由我们可以从根节点到叶子节点的最大边数决定。那么,我们的示例树的高度是多少呢?好吧,这里有一条我们可以遍历的下行路径:H 到 G 到 7。这条路径有两条边(H 到 G 和 G 到 7),所以它的高度至少是二。然而,我们可以找到一条更长的下行路径!以下是最长的下行路径之一:H 到 F 到 E 到 D 到 C 到 B 到 4。这条路径有六条边。自己验证一下,这里没有比这更长的下行路径了。该树的高度是六。
树具有非常规律、可重复的结构,这有助于我们处理它们。例如,如果我们移除根节点 H,以及从 H 到 F 和从 H 到 G 的边,我们最终会得到两个子树(图 2-3)。

图 2-3:一棵树被分成两部分
请注意,这两个子树各自都是一个合法的树:它们有根节点、节点和边,以及正确的结构。我们可以进一步将这些树拆分成更小的部分,每一部分都将是一个树。树可以看作是由更小的树组成的,而每棵小树又由更小的树组成,如此类推。
定义二叉树
在树的上下文中,二叉仅仅意味着我们树中的每个节点最多有两个子节点。一个二叉树中的节点可以没有子节点,或者只有一个子节点,或者有两个子节点,但不能有更多。我们当前问题中的二叉树实际上比这更为受限:每个节点必须恰好有零个或两个子节点——你永远不会看到只有一个子节点的节点。这样的二叉树,其中每个非叶子节点恰好有两个子节点,称为完全二叉树。
解决示例实例
现在,让我们在我们的示例树上解决万圣节糖果收集问题(图 2-2)。我们需要返回我们必须走的最少街道数以及糖果的总数。我们先从后者开始,因为计算总糖果数较为简单。
我们可以手动计算糖果总数:只需将所有房子节点中的糖果值加起来。这样做,我们得到 7 + 41 + 72 + 3 + 6 + 2 + 15 + 4 + 9 = 159。
现在,让我们来弄清楚收集所有糖果必须走的最少街道数。树的遍历方式有关系吗?毕竟,你必须访问每一所房子——也许你最快的路线是避免多次访问同一所房子。
让我们按访问左子节点再访问右子节点的策略遍历这棵树。按照这个策略,你访问节点的顺序是:H, F, A, 72, A, 3, A, F, E, 6, E, D, C, B, 4, B, 9, B, C, 15, C, D, 2, D, E, F, H, G, 7, G, 41。注意你最后停下来的地方是 41 号房,而不是 H:收集完糖果后,你不需要返回起始位置。那条路径有 30 条边。(路径中有 31 个节点,路径中的边数总是节点数减去 1。)走 30 条街道是你能做的最好的选择吗?
实际上,你可以做得更好:最有效的路线只需要走 26 条街道。现在花点时间试着找到这种更优化的遍历方式。就像在 30 条街道的遍历中一样,你需要多次访问非房子节点,并且希望每个房子只访问一次,但通过在最后访问的房子上采取策略,你可以节省四次走街道的时间。
表示二叉树
为了在代码中创建解决方案,我们需要找到一种表示邻里树的方法。正如你将看到的,将输入中表示树的字符串转换为明确表示节点之间关系的树结构是很方便的。在这一节中,我将提供这些树结构。虽然我们还不能读取字符串并将其转换为树,但我们可以硬编码树。这样就为我们提供了一个起点,开始解决问题。
定义节点
在上一章解决“独特雪花”问题时,我们使用了一个链表来存储雪花链。每个雪花节点包含了雪花本身,同时也包含指向链中下一个雪花的指针:
typedef struct snowflake_node {
int snowflake[6];
struct snowflake_node *next;
} snowflake_node;
我们可以使用类似的结构体来表示二叉树。在我们的邻里树中,房子节点有糖果值,而其他节点没有。尽管我们有这两种节点类型,但只用一个节点结构就足够了。我们只需要确保房子节点有正确的糖果值;我们甚至不会初始化非房子节点的candy值,因为我们反正不会查看这些值。
这为我们提供了这个起点:
typedef struct node {
int candy;
// ... what else should we add?
} node;
在链表中,每个节点都指向链中的下一个节点(如果没有下一个节点,则为NULL)。从一个节点,我们只能移动到另一个节点。相比之下,在树结构中,一个单一的next指针是不够的,因为一个非叶子节点将有左子节点和右子节点。我们需要每个节点两个指针,正如示例 2-1 所示。
typedef struct node {
int candy;
struct node *left, *right;
} node;
示例 2-1: 节点 结构体
很明显,parent在这里没有包含。我们是否应该再加上一个*parent,让我们可以访问节点的父节点以及它的子节点?这对于某些问题来说是有用的,但在“万圣节大作战”中并不是必须的。我们需要一种方式来向上移动树(从子节点到父节点),但我们可以通过隐式方式做到这一点,而不需要明确地跟踪父指针。稍后你会看到更多关于这一点的内容。
构建树
有了这个node类型后,我们现在可以构建示例树了。我们从下往上工作,将子树联合起来,直到到达根节点。让我们在示例树上演示这一过程的开始。
我们从示例树底部的 4 号和 9 号节点开始。然后我们可以将它们组合在一个新的父节点下,创建以 B 为根的子树。
这是 4 号节点:
node *four = malloc(sizeof(node));
four->candy = 4;
four->left = NULL;
four->right = NULL;
这是一个房子节点,所以我们记得给它一个糖果值。还需要将它的左子节点和右子节点设置为NULL。如果我们不这么做,它们将保持未初始化,指向未定义的内存,如果我们尝试访问这些节点,就会出现问题。
现在考虑 9 号节点。这是另一座房子,因此代码结构完全相同:
node *nine = malloc(sizeof(node));
nine->candy = 9;
nine->left = NULL;
nine->right = NULL;
现在我们有了两个节点。它们还不是树的一部分,它们只是独立存在。我们可以将它们联合在一个公共父节点下,像这样:
node *B = malloc(sizeof(node));
B->left = four;
B->right = nine;
这个 B 节点被赋予了一个指向 4 房子的左指针和一个指向 9 房子的右指针。它的 candy 成员没有初始化,这没关系,因为非房子节点本来就没有合理的 candy 值。
图 2-4 描述了我们目前为止生成的内容。

图 2-4:我们硬编码树中的前三个节点
在继续创建 C 子树之前,让我们做一点清理工作。创建一个房子节点需要四个步骤:分配节点,设置糖果值,将左子节点设置为 NULL,将右子节点设置为 NULL。类似地,创建一个非房子节点需要做三件事:分配节点,将左子节点设置为某个现有子树,将右子节点设置为另一个现有子树。我们可以将这些步骤封装在辅助函数中,而不是每次都输入它们,如列表 2-2 所示。
node *new_house(int candy) {
node *house = malloc(sizeof(node));
if (house == NULL) {
fprintf(stderr, "malloc error\n");
exit(1);
}
house->candy = candy;
house->left = NULL;
house->right = NULL;
return house;
}
node *new_nonhouse(node *left, node *right) {
node *nonhouse = malloc(sizeof(node));
if (nonhouse == NULL) {
fprintf(stderr, "malloc error\n");
exit(1);
}
nonhouse->left = left;
nonhouse->right = right;
return nonhouse;
}
列表 2-2:用于创建节点的辅助函数
让我们重写之前的 four、nine、B 代码,使用这些辅助函数,同时加上 15 和 C 节点:
node *four = new_house(4);
node *nine = new_house(9);
node *B = new_nonhouse(four, nine);
node *fifteen = new_house(15);
node *C = new_nonhouse(B, fifteen);
图 2-5 描述了我们的五节点树。

图 2-5:我们硬编码树中的前五个节点
注意,节点 C 的左子节点是一个非房子节点(在我们的代码中是 B),右子节点是一个房子节点(在我们的代码中是 fifteen)。我们的 new_nonhouse 函数允许这种不对称性(一个非房子子节点和一个房子子节点):每个都只是一个节点。我们可以随意混合使用非房子节点和房子节点。
此时,我们已经有了一个以节点 C 为根的五节点子树。我们应该能够使用 C 节点来访问树中存储的糖果值。(我们也可以使用 B、four、nine 和 fifteen 来访问树的部分内容,因为分阶段构建树会留下节点变量的残余,但稍后我们会构建一个将字符串转换为树的函数,它只会提供树的根节点,所以在这里不要通过使用这些变量来作弊。)
这里有个快速练习:这将打印什么?
printf("%d\n", C->right->candy);
如果你说是 15,那你是对的!我们访问了 C 的右子节点,即 fifteen 房子节点,然后我们访问了 fifteen 的糖果值。
这个怎么样?
printf("%d\n", C->left->right->candy);
这应该输出 9:先左后右,我们从 C 到达了 nine。现在试试这个:
printf("%d\n", C->left->left);
天哪!在我的笔记本上,我得到的值是 10752944。为什么?原因是我们打印的是一个指针值,而不是糖果值。我们必须小心这里。
最后,这将打印什么?
printf("%d\n", C->candy);
这给了我们一个无用的数字。我们在这里打印的是非房子节点的 candy 成员,但只有房子节点的 candy 值才有意义。
我们现在准备好开始解决这个问题了。完成代码来构建示例树,我们就可以继续前进了。
收集所有糖果
我们有两个主要任务:计算收集所有糖果所需的最少街道数和计算树中糖果的总量。我们将为每个任务编写一个辅助函数,从计算糖果总量开始,这是两个任务中较为简单的一个。这个辅助函数的签名如下:
int tree_candy(node *tree)
该函数接受一个指向树根节点的指针,并返回一个整数,这个整数将是树中糖果的总量。
如果我们处理的是链表,我们可以像在解决唯一雪花问题时那样使用循环。循环的主体将处理当前节点,然后使用节点的 next 成员来前进到下一个节点。在每一步,只有一个地方可以去:继续沿着链表向下走。然而,二叉树的结构更为复杂。每个非叶子节点都有左子树和右子树。每棵子树都必须遍历,否则我们就会错过处理树的一部分!
为了展示树的遍历过程,我们将返回到我们的示例树(图 2-2):从节点 H 开始,我们应该先去哪儿?我们可以向右移动到 G,然后再向右移动到 41,收集那里 41 个糖果。然后呢?我们到了死胡同,还有更多的糖果需要收集。记住,每个非叶子节点仅存储指向其左子节点和右子节点的指针,而不是指向其父节点的指针。一旦到了 41,我们就无法返回到 G。
从头开始,我们需要从 H 移动到 G,并记录下我们必须稍后处理 F 子树——否则我们将无法返回到 F 子树。
一旦到了 G,我们类似地需要移动到 41,并记录下我们必须稍后处理 7 子树。当我们到达 41 时,发现没有子树需要处理,我们已经记录了两个待处理的子树(F 和 7)。
也许接下来我们选择处理 7 子树,这样糖果总数就是 41 + 7 = 48。之后,我们将处理 F 子树。关于从 F 出发去哪里做出任何决策都会导致某棵子树未被处理,所以我们也需要记录下来。
也就是说,如果我们使用循环,对于每个非叶子节点,我们必须做两件事:选择一个子树先处理,并记录下另一个子树待处理。选择一个子树相当于跟随 left 或 right 指针——这没有问题。然而,记录信息以便稍后访问另一个子树会更棘手。我们需要一种新工具。
将待处理的子树存储在栈中
在任何时刻,我们可能有多个子树待我们稍后访问。我们需要能够将另一个子树添加到这个集合中,并在准备好处理时将子树移除并返回。
我们可以使用一个数组来管理这些记录。我们将定义一个大数组,可以容纳任意数量的待处理子树的引用。为了告诉我们有多少子树正在等待,我们将保持一个highest_used变量,用来追踪数组中正在使用的最高索引。例如,如果highest_used是2,这意味着索引0、1和2包含待处理子树的引用,而数组的其余部分目前没有使用。如果highest_used是0,这意味着只有索引0在使用。为了表示数组中没有部分正在使用,我们将highest_used设置为-1。
向数组中添加元素最简单的位置是索引highest_used + 1。如果我们尝试在其他地方添加元素,首先需要将现有元素向右移动;否则,我们会覆盖掉某个现有元素!类似地,从数组中移除元素最简单的位置是highest_used。使用其他索引则需要将元素向左移动,以填补被移除元素留下的空位。
使用这种方案,假设我们首先添加对子树 F 的引用,然后添加对子树 7 的引用。这将把 F 子树放在索引0的位置,把 7 子树放在索引1的位置。此时,highest_used的值为1。现在,当我们从这个数组中移除一个元素时,你认为会移除哪个子树:F 子树还是 7 子树?
7 子树被移除了!一般来说,最近添加的元素是被移除的元素。
计算机科学家称这种方式为后进先出(LIFO)访问。提供 LIFO 访问的数据集合被称为堆栈。向堆栈中添加元素称为推入,从堆栈中移除元素称为弹出。堆栈的顶部指的是下一个将从堆栈中弹出的元素;也就是说,堆栈的顶部是最近被推入的元素。
现实生活中到处都有堆栈。假设你有一些刚洗过的盘子,并将它们一一放置在橱柜的架子上。你放置的最后一个盘子(推入)将位于堆栈的顶部,它也将是你取出盘子时(弹出)最先拿走的盘子。这就是后进先出(LIFO)。
堆栈也支持文字处理器中的撤销功能。假设你先输入一个单词,然后输入第二个单词,再输入第三个单词。现在你点击撤销。第三个单词将被删除,因为它是你最后输入的单词。
实现堆栈
让我们实现堆栈。首先,我们将数组和highest_used打包成一个结构体。这样可以将堆栈的变量集中在一起,并允许我们创建任意数量的堆栈。(在《Halloween Haul》游戏中,我们只需要一个堆栈,但你可能会在其他需要多个堆栈的环境中使用此代码。)以下是我们的定义:
#define SIZE 255
typedef struct stack {
node * values[SIZE];
int highest_used;
} stack;
回想一下,每行输入最多为 255 个字符。每个字符最多表示一个节点。我们处理的每棵树最多有 255 个节点,这也是为什么我们的values数组有 255 个元素空间的原因。此外,注意values中的每个元素都是node *类型,即指向node的指针。我们本可以直接存储节点,而不是节点的指针,但那样会降低内存效率,因为树中的节点在加入栈时会被重复存储。
我们将为栈的每个操作创建一个函数。首先,我们需要一个new_stack函数来创建一个新栈。接下来,我们需要push_stack和pop_stack函数,分别用来向栈中添加和从栈中移除元素。最后,我们会有一个is_empty_stack函数,用来告诉我们栈是否为空。
new_stack函数在列表 2-3 中提供。
stack *new_stack(void) {
❶ stack *s = malloc(sizeof(stack));
if (s == NULL) {
fprintf(stderr, "malloc error\n");
exit(1);
}
➋ s->highest_used = -1;
return s;
}
列表 2-3:创建栈
首先,我们为栈分配内存 ➊。然后,我们将highest_used设置为-1 ➋;回想一下,这里的-1表示栈为空。注意,我们在这里并没有对s->values中的元素进行初始化:我们的栈是空的,所以它的值不相关。
我将stack_push和stack_pop放在列表 2-4 中,以突出它们实现的对称性。
void push_stack(stack *s, node *value) {
❶ s->highest_used++;
➋ s->values[s->highest_used] = value;
}
node * pop_stack(stack *s) {
➌ node * ret = s->values[s->highest_used];
➍ s->highest_used--;
➎ return ret;
}
列表 2-4:栈的推入与弹出
在push_stack中,我们首先为一个新元素腾出空间 ➊,然后将value放入那个空闲位置 ➋。
我们的pop_stack函数负责移除索引为highest_used的元素。然而,如果它只是这么做,那么这个函数就没那么有用:我们可以调用它,它会为我们弹出元素,但它不会告诉我们弹出了什么!为了解决这个问题,我们将待移除的栈元素存储在ret中 ➌。然后,通过将highest_used减去一来移除该元素 ➍。最后,我们返回被移除的元素 ➎。
我没有在push_stack或pop_stack中包括错误检查。注意,如果你尝试推入超过最大元素数目,push_stack会失败——但我们很安全,因为我们已经将栈的大小设置为能够容纳任何输入的大小。同样地,如果你尝试从空栈中弹出元素,pop_stack也会失败——但我们会小心检查栈是否为空,在弹出之前确保栈不为空。当然,通用栈应当更加健壮!
我们将使用is_empty_stack(见列表 2-5)来判断栈是否为空,is_empty_stack使用==来检查highest_used是否为-1。
int is_empty_stack(stack *s) {
return s->highest_used == -1;
}
列表 2-5:判断栈是否为空
在我们计算树中糖果的总数之前,让我们通过一个小的独立示例来练习栈的代码,具体见列表 2-6。我鼓励你花几分钟时间自己追踪这个示例。预测一下会发生什么!然后,运行代码并检查输出是否符合你的预期。
int main(void) {
stack *s;
s = new_stack();
node *n, *n1, *n2, *n3;
n1 = new_house(20);
n2 = new_house(30);
n3 = new_house(10);
push_stack(s, n1);
push_stack(s, n2);
push_stack(s, n3);
while (!is_empty_stack(s)) {
n = pop_stack(s);
printf("%d\n", n->candy);
}
return 0;
}
清单 2-6:使用堆栈的示例
让我们来看看这个示例做了什么。首先我们创建一个新的堆栈 s。然后我们创建三个房子节点:n1 有 20 个糖果,n2 有 30 个糖果,n3 有 10 个糖果。
我们将这些(单节点)子树推入堆栈:首先推入 n1,然后是 n2,再是 n3。只要堆栈不为空,我们就从堆栈中弹出一个元素,并打印它的糖果值。元素从堆栈中弹出的顺序与它们被推入的顺序相反,因此我们会得到 printf 调用的结果:10、30、20。
堆栈解决方案
我们现在有了一种追踪待处理子树的方法:每当我们决定处理哪个子树时,就将另一个子树放入堆栈。对于计算糖果总量来说,堆栈给了我们一种方法来推入子树(帮助我们记住这个子树),并在准备好时弹出子树(帮助我们处理子树)。
我们也可以使用 队列,一种以 先进先出(FIFO) 顺序提供元素的数据结构,这将改变子树访问的顺序和我们添加糖果到总量的顺序,但最终结果是一样的。我选择了堆栈,因为它比队列更容易实现。
现在我们准备使用堆栈来实现 tree_candy 了。我们需要处理两种情况:第一种是当我们看到非房子节点时该怎么做,第二种是当我们看到房子节点时该怎么做。
为了判断当前节点是否是非房子节点,我们可以检查其left和right指针。对于非房子节点,这两个指针都不为 NULL,因为它们指向子树。如果确认我们正在查看的是非房子节点,我们将左子树的指针存入堆栈,然后继续遍历右子树。处理非房子节点的代码如下:
if (tree->left && tree->right) {
push_stack(s, tree->left);
tree = tree->right;
}
否则,如果 left 和 right 都是 NULL,那么我们正在查看一个房子节点。房子节点有糖果,因此我们应该做的第一件事是将该房子节点的糖果值加到总糖果量中:
total = total + tree->candy;
这是一个房子节点,因此树中不再有进一步的分支。如果堆栈为空,说明处理完毕:空堆栈表示没有更多待处理的子树。如果堆栈不为空,则需要从堆栈中弹出一个子树并处理它。处理房子节点的代码如下:
total = total + tree->candy;
if (is_empty_stack(s))
tree = NULL;
else
tree = pop_stack(s);
使用堆栈的完整代码 tree_candy 请见 清单 2-7。
int tree_candy(node *tree) {
int total = 0;
stack *s = new_stack();
while (tree != NULL) {
if (tree->left && tree->right) {
push_stack(s, tree->left);
tree = tree->right;
} else {
total = total + tree->candy;
if (is_empty_stack(s))
tree = NULL;
else
tree = pop_stack(s);
}
}
return total;
}
清单 2-7:使用堆栈计算糖果总量
设 n 为树中节点的数量。每次执行 while 循环时,tree 是不同的节点。因此我们每个节点只会访问一次。每个节点最多会被推入和弹出堆栈一次。总的来说,每个节点参与的步骤是常数级别的,所以这个算法是线性时间,或者说是 O(n) 的。
一个完全不同的解决方案
我们的tree_candy函数是有效的,但它并不是最简单的解决方案。我们必须实现一个栈的功能。我们需要跟踪待处理的树。每当我们遇到死胡同时,我们需要回溯到待处理的树。由于两个原因,在树上编写函数时,像我们这样使用栈可能不是理想的解决策略:
-
每当我们需要朝一个方向移动,但之后又必须回头走另一个方向时,我们就不得不使用这种栈式代码。树的处理经常出现需要这种模式的问题。
-
基于栈的代码复杂度与问题的复杂度成正比。加总树中所有糖果并不算太难,但本章后面我们将解决的其他相关问题则更加具有挑战性。这些问题不仅需要一个待处理树的栈,还需要控制流信息来跟踪我们在每棵树上需要执行的处理。
我们将重写代码,使其能够在更高的抽象层次上工作,完全消除代码和思维过程中的栈。
递归定义
我们基于栈的tree_candy函数关注的是解决问题所需的特定步骤:将这个推入栈中,在树中朝某个方向移动,当遇到死胡同时从栈中弹出,处理完整个树时停止。我现在将给出另一种解决方案,它关注的是问题的结构。这种方法通过将问题分解为更小的子问题来解决主要问题。该解决方案包括两个规则:
规则 1 如果树的根是一个房屋节点,那么树中的糖果总量等于该房屋中的糖果数量。
规则 2 如果树的根是一个非房屋节点,那么树中的糖果总量等于左子树中的糖果总量加上右子树中的糖果总量。
这被称为递归定义。如果一个定义通过引用子问题的解决方案来提供问题的解决方案,那么这个定义就是递归的。规则 2 就是在这里我们看到它的实际应用。我们关注的是解决计算树中糖果总量的原始问题。根据规则 2,这个总量可以通过将两个更小问题的解决方案相加来计算:左子树中的糖果总量和右子树中的糖果总量。
就在这个时候,我班上的学生通常会开始满脸困惑。这个描述怎么能解决问题呢?即使能,如何将这种东西写成代码呢?书籍和教程常常给递归定义蒙上神秘的面纱,要求相信但不去理解。然而,这并不需要盲目信任或胆大妄为。
让我们通过一个小例子来理解为什么这种递归定义是正确的。
考虑这个由一座房子和四块糖果组成的树:

规则 1 立即告诉我们这棵树的答案是四。以后我们看到这棵树时,只需要记住答案是四。
现在,考虑这棵只包含一个房子的树,里面有九颗糖果:

再次,规则 1 适用,告诉我们答案是九:当我们稍后看到这棵树时,我们只需要回答答案是九。
现在,让我们解决更大树的问题:

这次,规则 1 不适用:树的根是一个非房节点,而不是房节点。然而,我们得到了规则 2 的帮助,规则 2 告诉我们这里的糖果总数是左子树的糖果总数加上右子树的糖果总数。我们已经知道左子树的糖果总数是四:这是一棵我们之前见过的树。同样,我们知道右子树的糖果总数是九:我们也见过那棵树。因此,根据规则 2,整个树有 4 + 9 = 13 颗糖果。记住这个,当我们再次看到这棵树时!
再往前看一点。这是另一棵单房树,它有 15 颗糖果:

规则 1 告诉我们这棵树的糖果总数是 15 颗。记住这个!现在考虑一棵五节点的树:

规则 2 在这里适用,因为根是一个非房节点。我们需要左子树的糖果总数和右子树的糖果总数。我们已经知道左子树的糖果总数,因为我们记得之前的答案是 13。没有必要再去左子树重新计算什么:我们已经知道答案。同样,我们也知道右子树的糖果总数,因为它是 15。根据规则 2,整个树的糖果总数是 13 + 15 = 28 颗糖果。
你可以继续使用这个逻辑来找到越来越大树的糖果总数。正如我们在这里的例子中所做的那样,先解决较小的子树,再解决较大的子树。这样,规则 1 或规则 2 总是适用,较小子树的答案会在需要时知道。
让我们将规则 1 和规则 2 编码为 C 函数;请参见 列表 2-8。
int tree_candy(node *tree) {
❶ if (!tree->left && !tree->right)
return tree->candy;
➋ return tree_candy(tree->left) + tree_candy(tree->right);
}
列表 2-8:使用递归计算糖果总数
注意规则 1 和规则 2 如何在这里直接表示。我们有一个 if 语句,当左子树和右子树都是 NULL 时条件为真 ➊。没有子树意味着 tree 是一个房节点。因此我们应该应用规则 1,这正是我们所做的。具体地,我们返回房节点 tree 中的糖果数量。如果规则 1 不适用,我们知道 tree 是一个非房节点,我们可以应用规则 2,返回左子树中的糖果加上右子树中的糖果 ➋... 但是这里我们暂停。
规则 2 如何在这里工作?左子树的糖果总量是通过在左子树上调用 tree_candy 得到的。对于右子树也是如此:要获得右子树的糖果总量,我们在右子树上调用 tree_candy。但是我们已经在 tree_candy 函数中了!
从函数内部调用自身被称为递归调用。一个进行递归调用的函数被称为使用了递归。在这一点上,你能犯的最大错误之一就是试图追踪计算机在递归发生时发生了什么。我会避免给出计算机如何组织这些递归调用的底层细节。(可以说,它使用栈来跟踪待处理的函数调用。这与我们之前在代码中使用栈来解决 tree_candy 问题非常相似!因此,我们的递归代码,像我们基于栈的代码一样,是一个 O(n) 解决方案。)
我反复看到试图手动追踪递归调用所带来的困境。这是一种错误的抽象层级。让计算机以同样的方式执行它,就像你不加思考地让计算机执行循环或函数调用一样。
这是我建议你理解递归代码的方式:
-
如果树的根节点是一个房子,返回它的糖果数量。
-
否则,树的根节点是一个非房子节点。返回左子树的糖果总量加上右子树的糖果总量。
编写递归代码时很容易出错。一个常见的错误是无意中丢弃本应返回的信息。以下错误的实现展示了这个问题:
// bugged!
int tree_candy(node *tree) {
if (!tree->left && !tree->right)
return tree->candy;
❶ tree_candy(tree->left) + tree_candy(tree->right);
}
我们的 bug 是在递归调用 ➊ 中没有返回任何值,因为没有 return 关键字。我们应该返回总和,而不是抛弃它。
另一个常见的错误是对不是当前问题更小子问题的对象进行递归调用。这里有一个例子:
// bugged!
int tree_candy(node *tree) {
if (!tree->left && !tree->right)
return tree->candy;
❶ return tree_candy(tree);
}
看看第二个 return 语句 ➊。如果我告诉你树中糖果的总量是通过计算树中糖果的总量得到的,我想你一定会很恼火——但这正是它所体现的规则。这个函数在树的根是非房子节点时不起作用:它会不断使用内存来保存待处理的函数调用,直到程序崩溃。
递归练习
在继续解决万圣节糖果问题之前,让我们通过编写与tree_candy类似的两个函数来练习递归。
首先,给定一棵满二叉树的根节点指针,我们要返回树中节点的数量。如果该节点是叶子节点,那么树中只有一个节点,因此1是正确的返回值。否则,我们遇到的是一个非叶子节点,树中节点的数量是一个(即该节点)加上左子树节点的数量再加上右子树节点的数量。也就是说,两个规则如下:
规则 1 如果树的根是一个叶子节点,那么树中节点的数量等于1。
规则 2 如果树的根是一个非叶子节点,那么树中节点的数量等于1加上左子树中节点的数量,再加上右子树中节点的数量。
像规则 1 这样的规则被称为基本情况,因为它可以直接解决,无需使用递归。像规则 2 这样的规则被称为递归情况,因为它的解决需要递归地解决更小的子问题。每个递归函数至少需要一个基本情况和一个递归情况:基本情况告诉我们在问题简单时该怎么做,而递归情况告诉我们在问题复杂时该怎么做。
将这些规则转换为代码会得到示例 2-9 中的函数。
int tree_nodes(node *tree) {
if (!tree->left && !tree->right)
return 1;
return 1 + tree_nodes(tree->left) + tree_nodes(tree->right);
}
示例 2-9:计算节点的数量
接下来,我们来写一个函数,返回树中叶子节点的数量。如果节点是叶子节点,则返回1。如果节点是非叶子节点,那么该节点不是叶子节点,因此不计算在内;需要计算的是左子树中叶子节点的数量和右子树中叶子节点的数量。代码在示例 2-10 中给出。
int tree_leaves(node *tree) {
if (!tree->left && !tree->right)
return 1;
return tree_leaves(tree->left) + tree_leaves(tree->right);
}
示例 2-10:计算叶子节点的数量
这段代码与示例 2-9 中的代码唯一的区别是最后一行缺少了1 +。递归函数通常非常相似,但可以计算出非常不同的结果!
最小街道数的步行路径
我已经说了这么多,你可能需要重新回顾一下问题描述来重新定位自己。现在我们知道如何计算出糖果的总量了,但那只是两个必需输出中的一个。我们还需要输出获得所有糖果所需走的最少街道数量。想要猜出我们将如何解决这个问题吗?我们会使用递归!
计算街道的数量
之前,我提供了图 2-2 中树的 30 条街道步行路径。我还要求你找到一个更好,实际上是最优的 26 条街道路径。这个最优路径通过利用收集完最后一颗糖果后就可以结束步行的事实,节省了四条街道。问题描述中并没有要求步行返回树的根节点。
如果我们确实将步行路径包括返回到树的根节点怎么办?确实这样会得到错误的答案,因为我们走的街道比要求的多。不过,返回根节点也大大简化了问题。我们就不需要为如何巧妙地进行步行以最小化街道数而烦恼了。(毕竟,我们最终会回到根节点,所以我们不需要精心安排最后访问的房子是个好选择。)也许我们可以通过返回根节点而超过最小值,然后减去走的多余街道?这是我们的赌注!
让我们按照tree_candy的相同计划来定义基本情况和递归情况。
如果树的根是房屋节点——从这个房屋开始并返回到这个房屋,我们需要走多少街道?答案是零!不需要走任何街道。
如果根节点是一个非房屋节点怎么办?返回到图 2-3,在这里我将树分成了两部分。假设我们已经知道了行走 F 子树所需的街道数和行走 G 子树所需的街道数。这些可以递归计算。然后,将 H 及其两条边加回来。现在我们还需要走多少街道?我们必须从 H 走一条街道到 F,完成 F 子树后,再从 F 走一条街道返回 H。对于 G 也是类似:从 H 走到 G,然后在完成 G 子树后从 G 返回 H。这四条额外的街道是递归计算之外的。
这里是我们的两个规则:
规则 1 如果树的根是房屋节点,那么我们走的街道数是零。
规则 2 如果树的根是一个非房屋节点,那么我们走的街道数是左子树所需的街道数加上右子树所需的街道数,再加上4。
在这一阶段,你应该开始更熟练地将这些规则转换成代码了。列表 2-11 提供了一个实现。
int tree_streets(node *tree) {
if (!tree->left && !tree->right)
return 0;
return tree_streets(tree->left) + tree_streets(tree->right) + 4;
}
列表 2-11:计算返回根节点所需的街道数
如果你从 H 开始走图 2-2,收集所有糖果,并最终回到 H,你将走 32 条街道。无论你如何行走这棵树,只要每个房屋只访问一次,并且不重复走街道,你都会走 32 条。我们可以走的最少街道数是 26 条,无需返回根节点。从 32 减去 26 等于 6,所以通过回到根节点,我们多走了六条街道。
因为没有要求返回根节点,所以合理的做法是安排我们的行走,使得最后一个访问的房屋尽可能远离根节点。例如,最终在拥有 7 块糖果的房屋停下来就是一个糟糕的选择,因为无论如何我们离 H 只有两条街道——但看看那些远在底部的 4 号和 9 号房屋。将我们的行走结束在这些房屋之一将是很棒的。如果我们以 9 号房屋结束行走,那么我们可以节省六条街道:从 9 到 B,从 B 到 C,从 C 到 D,从 D 到 E,从 E 到 F,最后从 F 到 H。
计划是让我们的遍历以距离根节点最远的房子结束。如果那座房子距离根节点六条街道,意味着从根节点到某个叶子有六条边。这正是树的高度定义!如果我们能计算出树的高度——我敢打赌是递归地——那么我们可以从tree_streets给出的值中减去树的高度。这样就能让我们到达离根节点最远的房子,从而节省最大数量的街道。
顺便提一下,实际上没有必要知道哪座房子是最远的,甚至也不需要知道如何执行一次遍历来确保那座房子是最后一个。我们要做的只是说服自己,我们可以构建一条路径,使得那座房子成为最后一个。我将通过图 2-2 给出一个简短的论证,希望能说服你。从 H 开始,比较 F 和 G 子树的高度,完全遍历高度较小的那一棵——在这个例子中是 G。然后,重复这个过程,使用 F 的子树。比较 A 和 E 子树的高度,完全遍历 A 子树(因为它的高度比 E 的要小)。一直这样做,直到所有子树都被遍历;你访问的最后一座房子将是离 H 最远的房子。
计算树的高度
现在让我们继续讨论tree_height和我们规则 1-规则 2 递归方法的另一种表现。
由单个房子组成的树的高度是零,因为没有任何边可以遍历。
对于根节点是非房子的树,再次参阅图 2-3。F 子树的高度为五,G 子树的高度为一。我们可以递归地解决这些子问题。原始树的高度(包括 H)是五和一的最大值加一,因为从 H 出发的边增加了到每个叶子的边数。
这一分析给出了以下两条规则:
规则 1 如果树的根是房子节点,那么树的高度为零。
规则 2 如果树的根是一个非房子节点,那么树的高度是左子树高度和右子树高度的最大值再加一。
参见清单 2-12 中的代码。我们有一个小的max辅助函数,用于告诉我们两个数字中的最大值;否则,tree_height没有什么惊讶之处。
int max(int v1, int v2) {
if (v1 > v2)
return v1;
else
return v2;
}
int tree_height(node *tree) {
if (!tree->left && !tree->right)
return 0;
return 1 + max(tree_height(tree->left), tree_height(tree->right));
}
清单 2-12:计算树的高度
现在我们有了tree_candy来计算糖果的总量,tree_streets和tree_height来计算最小街道数。将这三者结合起来,给我们一个能在给定树的情况下解决问题的函数;见清单 2-13。
void tree_solve(node *tree) {
int candy = tree_candy(tree);
int height = tree_height(tree);
int num_streets = tree_streets(tree) - height;
printf("%d %d\n", num_streets, candy);
}
清单 2-13:给定一棵树来解决问题
尝试在你在“构建树”中创建的树上调用这个函数,参见第 43 页。
读取输入
我们现在已经非常接近了,但还没有完全解决。是的,如果我们手中有一棵树,我们可以解决这个问题,但请记住,问题的输入是文本行,而不是树。我们必须先将每一行转换为一棵树,然后才能对其应用tree_solve。最终,我们终于准备好揭示树是如何作为文本表示的。
将树表示为字符串
我将通过几个例子向你展示文本和其树形结构之间的对应关系。
首先,一个单一房屋节点的树仅仅通过糖果值的文本来表示。例如,这棵树(其节点的糖果值是四):

表示如下:
4
一棵根节点是非房屋节点的树(递归地!)按照以下顺序表示:一个左括号、第一个较小的树、一个空格、第二个较小的树和一个右括号。这里的第一个较小的树是左子树,第二个较小的树是右子树。例如,这棵三节点的树

表示如下:
(4 9)
类似地,这里是一个五节点的树:

这个五节点的树表示如下:
((4 9) 15)
这里,左子树是(4 9),右子树是15。
以规则的形式,我们有以下内容:
规则 1 如果文本是整数c的数字,那么树就是一个带有c糖果的单一房屋节点。
规则 2 如果文本以左括号开始,那么树的根节点是一个非房屋节点。在左括号之后,文本包含树的左子树、一个空格、树的右子树和右括号。
读取非房屋节点
我们的目标是编写具有以下签名的read_tree函数:
node *read_tree(char *line)
它接受一个字符串,并返回相应的树。
让我们从实现规则 2 开始,因为规则 1 涉及将字符转换为整数的微妙工作。
规则 2,这个递归规则,要求我们对read_tree进行两次调用:一次读取左子树,一次读取右子树。让我们看看我们能走多远:
node *tree;
tree = malloc(sizeof(node));
if (line[0] == '(') {
❶ tree->left = read_tree(&line[1]);
➋ tree->right = read_tree(???);
return tree;
}
在为树的根分配内存后,我们发起递归调用来读取左子树 ➊。我们传递一个指向 line 索引 1 的指针,以便递归调用接收到的字符串不包括索引 0 处的左括号。然而,在下一行中,我们遇到了问题 ➋。我们该从哪里开始读取右子树?换句话说,左子树有多少个字符?我们不知道!我们本可以写一个单独的函数来找出左子树的结束位置。例如,我们可以计算开括号和闭括号的数量,直到它们相等,但这似乎有些浪费:如果 read_tree 已成功读取左子树,那么那个递归调用肯定知道左子树在哪里结束吧?如果只有一种方法能将这个信息传回给原始的 read_tree 调用,它就能利用这个信息来确定应该将字符串的哪一部分传递给第二个递归调用。
向递归函数添加参数是一种通用且强大的方法来解决这类问题。每当递归调用有一些信息未通过其返回值传递,或者需要某些未传递的信息时,可以考虑添加一个参数。如果该参数是指针类型,它既可以用于将附加信息传递给递归调用,也可以用来接收返回的信息。
对于我们的目的,我们希望能够告诉递归调用它的字符串从哪里开始。同时,我们希望递归调用能够在完成时告诉我们应该从哪里继续处理字符串。为此,我们将添加一个整数指针参数 pos。然而,我们不想将这个参数添加到 read_tree 中,因为 read_tree 的调用者不需要知道这个额外的参数。read_tree 的调用者应该只需传递一个字符串,而不关心这个内部实现的 pos 参数。
我们将保持 read_tree 的签名不变,仅保留 line 参数。然后,read_tree 将调用 read_tree_helper,而 read_tree_helper 拥有这个 pos 参数,并引发递归。
列表 2-14 给出了 read_tree 代码。它将一个指针指向 0 传递给 read_tree_helper,因为索引 0(字符串的开始位置)是我们希望开始处理的地方。
node *read_tree(char *line) {
int pos = 0;
return read_tree_helper(line, &pos);
}
列表 2-14:调用我们的助手,传递一个指向 int 的指针
我们现在准备好再次尝试实现规则 2:
node *tree;
tree = malloc(sizeof(node));
if (line[*pos] == '(') {
❶ (*pos)++;
tree->left = read_tree_helper(line, pos);
➋ (*pos)++;
tree->right = read_tree_helper(line, pos);
➌ (*pos)++;
return tree;
}
该函数将被调用,pos指向树的第一个字符,因此我们首先将pos向前移动一个字符,以跳过开括号➊。现在,pos正好位于左子树的起始位置。然后我们递归地调用来读取左子树。这个递归调用将更新pos,指向左子树后的字符索引。由于左子树后面是一个空格,我们跳过这个空格➋。现在我们位于右子树的起始位置;我们递归地抓取右子树,然后跳过闭括号➌,它是最初跳过的开括号的匹配闭括号➊。跳过闭括号很重要,因为这个函数负责处理整个子树,包括它的闭括号。如果我们没有跳过这个闭括号,调用该函数的人可能会看到一个闭括号,而他们本来应该看到一个空格。跳过闭括号后,剩下的工作就是返回我们的树。
读取一个房屋节点
解决完规则 2 后,我们来处理规则 1。在我们能取得更大进展之前,我们需要能够将字符串的一部分转换为整数。让我们编写一个小程序,确保我们能够做到这一点。这个程序将接受一个字符串,我们假设它代表一个房屋节点,并输出它的糖果值。令人吃惊的是,如果我们不小心,可能会得到令人困惑的结果。请注意:在清单 2-15 中,我们并没有小心。
#define SIZE 255
// bugged!
int main(void) {
char line[SIZE + 1];
int candy;
gets(line);
candy = line[0];
printf("%d\n", candy);
return 0;
}
清单 2-15:读取糖果值(有 bug!)
运行这个程序并输入数字4。
你可能会看到52作为输出。再运行一次,输入数字9,你可能会看到57。现在再试一下输入0,你可能会看到48。最后,试试输入从0到9的每个数字。你应该会发现每个输出值与0的输出值偏差相同。如果0输出 48,那么1将输出 49,2输出 50,3输出 51,依此类推。
我们在这里看到的是每个数字的字符代码。关键点是,整数的字符代码是连续的。因此,我们可以通过减去零的字符代码,将整数放入正确的范围。修正后,我们得到清单 2-16 中的代码。试试看吧!
#define SIZE 255
int main(void) {
char line[SIZE + 1];
int candy;
gets(line);
candy = line[0] - '0';
printf("%d\n", candy);
return 0;
}
清单 2-16:读取糖果值
这个小程序适用于单个数字的整数。然而,Halloween Haul 的描述要求我们也能处理两位数的糖果整数。假设我们先读到数字2,然后是数字8。我们希望将这两个数字组合起来,得到整数28。我们可以通过将第一个数字乘以 10(这样就得到 20),然后再加上 8(总共是 28)来实现。清单 2-17 是另一个小测试程序,帮助我们检查我们是否做对了。在这里,我们假设会输入一个两位数的字符串。
#define SIZE 255
int main(void) {
char line[SIZE + 1];
int digit1, digit2, candy;
gets(line);
digit1 = line[0] - '0';
digit2 = line[1] - '0';
candy = 10 * digit1 + digit2;
printf("%d\n", candy);
return 0;
}
清单 2-17:读取一个两位数的糖果值
这就是规则 1 所需的全部内容,我们可以写出如下代码:
--snip--
tree->left = NULL;
tree->right = NULL;
❶ tree->candy = line[*pos] - '0';
➋ (*pos)++;
if (line[*pos] != ')' && line[*pos] != ' ' &&
line[*pos] != '\0') {
➌ tree->candy = tree->candy * 10 + line[*pos] - '0';
➍ (*pos)++;
}
return tree;
我们首先将左子树和右子树设置为NULL;毕竟我们是在创建一个树节点。接下来,我们取一个字符并将其转换为数字❶,然后跳过该数字➋。现在,如果这个糖果值只有一位数字,那么我们就已经正确存储了它的值。如果是两位数字,那么我们需要将第一位数字乘以 10 并加上第二位数字。因此,我们需要确定糖果值是一个数字还是两个数字。如果我们看到的不是一个右括号、空格或者字符串末尾的空字符,那么我们就必须在看第二位数字。如果第二位数字存在,我们将其加入到糖果值中➌,并移动到下一个数字 ➍。
清单 2-18 展示了我们为规则 2 和规则 1 编写的代码。
node *read_tree_helper(char *line, int *pos) {
node *tree;
tree = malloc(sizeof(node));
if (tree == NULL) {
fprintf(stderr, "malloc error\n");
exit(1);
}
if (line[*pos] == '(') {
(*pos)++;
tree->left = read_tree_helper(line, pos);
(*pos)++;
tree->right = read_tree_helper(line, pos);
(*pos)++;
return tree;
} else {
tree->left = NULL;
tree->right = NULL;
tree->candy = line[*pos] - '0';
(*pos)++;
if (line[*pos] != ')' && line[*pos] != ' ' &&
line[*pos] != '\0') {
tree->candy = tree->candy * 10 + line[*pos] - '0';
(*pos)++;
}
return tree;
}
}
清单 2-18:将字符串转换为树
剩下的就是构建一个整洁的main函数,读取每个测试用例并解决它!清单 2-19 就是所需的一切。
#define SIZE 255
#define TEST_CASES 5
int main(void) {
int i;
char line[SIZE + 1];
node *tree;
for (i = 0; i < TEST_CASES; i++) {
gets(line);
tree = read_tree(line);
tree_solve(tree);
}
return 0;
}
清单 2-19:主函数
我们已经成功使用递归解决了这个问题,你应该能通过将我们的解法提交给判题系统来验证这一点。
为什么使用递归?
有时候很难知道递归是否能为一个问题提供简洁的解决方案。这里有一个明显的标志:每当一个问题可以通过组合解决更小子问题的解决方案来解决时,你应该尝试使用递归。在本章的所有递归代码中,我们都是通过解决正好两个子问题来解决更大的问题。这类“两子问题”的问题非常常见,但有些问题可能需要解决三个、四个或更多的子问题。
你怎么知道将一个问题分解为子问题可以帮助你解决原始问题,且你又怎么知道这些子问题是什么呢?我们将在第三章中重新探讨这些问题,当时我们会基于在这里学到的内容,研究记忆化和动态规划。与此同时,想一想,如果有人告诉你较小子问题的解决方案,你是否能够轻松地解决给定的问题。例如,回想一下计算树中糖果总数的问题。这不是一个简单的问题。如果有人告诉你左子树和右子树中的糖果总数,问题就变得容易了。通过知道子问题的解决方案使问题变得更简单,这强烈暗示了递归的适用性。
让我们继续讨论另一个递归非常有用的问题。当你阅读问题描述时,试着找出递归会在何时、为何出现。
问题 2:后代距离
现在,我们将从二叉树转向一般树,其中节点可以有多个子节点。
这是 DMOJ 题目ecna05b。
问题
在这个问题中,我们给定了一个家族树和一个指定的距离d。树中每个节点的得分是它在距离d处的后代数量。我们的任务是输出得分较高的节点;我将在输出部分详细说明需要输出多少节点。为了理解我所说的指定距离的后代,请看图 2-6 中的家族树。

图 2-6:一个示例家族树
考虑 Amber 节点。Amber 有四个孩子,所以她在距离一的地方有四个后代。Amber 还有五个孙子:五个在距离二的节点。一般来说,我们可以说,对于任何节点,距离d处的后代数量是该节点到树中恰好距离d的节点数。
输入
输入的第一行给出接下来将要处理的测试用例的数量。每个测试用例包含以下几行:
-
一行包含两个整数,n和d,其中n告诉我们该测试用例还有多少行,d指定了感兴趣的后代距离。
-
n行用于构建树。每行包含一个节点的名称,一个整数m,以及m个节点名称,表示该节点的子节点。这些名称最多 10 个字符长。这些行的顺序可以是任意的——父节点行不要求在其后代节点行之前。
每个测试用例最多包含 1,000 个节点。
下面是一个可能的输入,用于生成图 2-6 中的示例树,要求输出距离为二的后代最多的节点:
1
7 2
Lucas 1 Enzo
Zara 1 Amber
Sana 2 Gabriel Lucas
Enzo 2 Min Becky
Kevin 2 Jad Cassie
Amber 4 Vlad Sana Ashley Kevin
Vlad 1 Omar
输出
每个测试用例的输出有两部分。
首先,输出以下一行:
Tree i:
其中 i 为1表示第一个测试用例,2表示第二个测试用例,以此类推。
然后,输出具有高分的节点信息(节点的得分是它在距离d处的后代数量),按得分从高到低排序。对于得分相同的节点,按字母顺序输出它们的名称。
使用以下规则确定输出多少个名称:
-
如果有三个或更少的名字具有距离为d的后代,则输出它们所有。
-
如果有超过三个名称在距离d处有后代,则首先输出得分最高的前三个名称。然后,输出每个得分与第三名相同的名称。例如,如果我们有得分为八、八、二、二、二、一、一的后代名称,我们将输出五个名称的信息:得分为八、八、二、二和二的节点。
对于每个需要输出的名称,我们输出一行,包含该名称,后跟一个空格,再后跟它在距离d处的后代数量。
每个测试用例的输出与下一个测试用例的输出之间由空行分隔。
以下是上面示例输入的输出:
Tree 1:
Amber 5
Zara 4
Lucas 2
解决测试用例的时间限制为 0.6 秒。
读取输入
这个问题与《万圣节大采购》问题之间的一个有趣区别是,我们不再处理二叉树了。在这里,一个节点可以有任意数量的孩子。我们需要更改节点结构,因为left和right指针将不再适用。相反,我们将使用一个children数组来存储孩子们,并使用一个整数num_children来记录数组中存储的孩子数量。我们还将有一个name成员来存储节点的名字(如 Zara、Amber 等),以及一个score成员,当我们计算距离d的后代数时使用。我们的node结构体定义见列表 2-20。
typedef struct node {
int num_children;
struct node **children;
char *name;
int score;
} node;
列表 2-20: 节点 结构体
在《万圣节大采购》中,树是通过递归定义的表达式开始的,我们可以从中递归地读取左右子树。但在这里情况不同:节点可以按任意顺序出现。例如,我们可能会看到
Zara 1 Amber
Amber 4 Vlad Sana Ashley Kevin
我们在这里先了解了 Zara 的孩子 Amber,而不是先了解 Amber 的孩子。然而,我们也可能会看到
Amber 4 Vlad Sana Ashley Kevin
Zara 1 Amber
我们在这里先了解了 Amber 的孩子,而不是 Zara 的孩子!
我们知道,从文件中读取的节点和父子关系,在处理完成后会形成一棵单一的树。然而,在处理这些行时,并没有保证我们始终会得到一棵树。例如,我们可能会读取以下几行
Lucas 1 Enzo
Zara 1 Amber
这告诉我们 Enzo 是 Lucas 的孩子,Amber 是 Zara 的孩子,但目前为止我们只知道这些。我们这里有两个不相连的子树,需要后续的行来连接这些子树。
由于这些原因,随着我们读取这些行,维持一棵单一的、连接的树是不可能的。相反,我们将维护一个指向节点的指针数组。每当我们看到一个之前没有见过的名字时,我们就会创建一个新节点,并将该节点的指针添加到数组中。因此,拥有一个辅助函数来搜索数组并告诉我们是否之前见过某个名字将会非常有价值。
查找节点
列表 2-21 实现了一个find_node函数。nodes参数是一个指向节点的指针数组,num_nodes给出数组中指针的数量,name是我们要搜索的名字。
node *find_node(node *nodes[], int num_nodes, char *name) {
int i;
for (i = 0; i < num_nodes; i++)
if (strcmp(nodes[i]->name, name) == 0)
return nodes[i];
return NULL;
}
列表 2-21:查找节点
线性搜索是逐个元素查找数组的一种方法。在我们的函数中,我们使用线性搜索来查找nodes,而且……等等!我们难道不是在搜索一个数组吗?这正是哈希表的用武之地(参见第一章)。我鼓励你自己尝试使用哈希表,并比较性能。为了简化处理,而且因为最多只有 1000 个节点,我们将继续使用这种(较慢的)线性搜索。
我们对数组中的每个名字与目标名字进行字符串比较。如果 strcmp 返回 0,意味着字符串相等,此时我们返回指向相应节点的指针。如果遍历完数组仍未找到该名字,我们返回 NULL,表示没有找到该名字。
创建一个节点
当在数组中找不到一个名字时,我们需要创建一个包含该名字的节点。这将涉及到对 malloc 的调用,并且我们会看到在程序的其他部分也需要用到 malloc。因此,我编写了一个辅助函数 malloc_safe,在需要时可以调用。见清单 2-22:它只是一个常规的 malloc,但是加入了错误检查:
void *malloc_safe(int size) {
char *mem = malloc(size);
if (mem == NULL) {
fprintf(stderr, "malloc error\n");
exit(1);
}
return mem;
}
清单 2-22: malloc_safe 函数
清单 2-23 中的 new_node 辅助函数使用 malloc_safe 来创建一个新节点。
node *new_node(char *name) {
node *n = malloc_safe(sizeof(node));
n->name = name;
n->num_children = 0;
return n;
}
清单 2-23: 创建一个节点
我们为新节点分配内存,然后设置节点的 name 成员。接着,我们将节点的孩子数量设置为 0。这里我们使用零是因为我们可能不知道该节点有多少个孩子。例如,假设我们读取树的第一行是:
Lucas 1 Enzo
我们知道 Lucas 有一个孩子,但我们不清楚 Enzo 有多少个孩子。当 new_node 的调用者获得相关信息时,可以将孩子的数量设置为新的值。对于 Lucas 来说,这一过程立即发生,但对于 Enzo,则不会。
构建家族树
现在我们准备读取并构建树。清单 2-24 给出了这个函数。这里的 nodes 是一个指向节点的指针数组,空间由调用此函数的代码分配;num_lines 表示要读取的行数。
#define MAX_NAME 10
int read_tree(node *nodes[], int num_lines) {
node *parent_node, *child_node;
char *parent_name, *child_name;
int i, j, num_children;
int num_nodes = 0;
❶ for (i = 0; i < num_lines; i++) {
parent_name = malloc_safe(MAX_NAME + 1);
scanf("%s", parent_name);
scanf("%d", &num_children);
➋ parent_node = find_node(nodes, num_nodes, parent_name);
if (parent_node == NULL) {
parent_node = new_node(parent_name);
nodes[num_nodes] = parent_node;
num_nodes++;
}
else
➌ free(parent_name);
➍ parent_node->children = malloc_safe(sizeof(node) * num_children);
➎ parent_node->num_children = num_children;
for (j = 0; j < num_children; j++) {
child_name = malloc_safe(MAX_NAME + 1);
scanf("%s", child_name);
child_node = find_node(nodes, num_nodes, child_name);
if (child_node == NULL) {
child_node = new_node(child_name);
nodes[num_nodes] = child_node;
num_nodes++;
}
else
free(child_name);
➏ parent_node->children[j] = child_node;
}
}
return num_nodes;
}
清单 2-24: 将行转换成树
外层的 for 循环 ➊ 会对每一行输入(共有 num_lines 行)执行一次。每一行包含一个父母的名字以及一个或多个孩子的名字;我们首先处理父母的名字。我们分配内存,读取父母的名字,并读取父母的孩子数量。然后,我们使用 find_node 辅助函数来确定这个节点是否之前出现过 ➋。如果没有出现过,我们就使用 new_node 辅助函数创建一个新节点,将新节点的指针存储在 nodes 数组中,并增加节点数量。如果该节点已经在 nodes 数组中,我们就释放父母名字的内存,因为它将不再被使用 ➌。
接下来,我们为父节点的子指针分配内存 ➍,并存储子节点的数量 ➎。然后我们处理子节点;每个子节点的处理方式与父节点类似。一旦子节点存在并设置好其成员,我们将一个指针存储在父节点的 children 数组 ➏ 中。请注意,没有为子节点编写分配任何内存或设置子节点数量的代码,像我们为父节点所做的那样。如果我们之前见过某个子节点的名称,那么当第一次遇到这个名称时,它的子节点已经设置好了。如果这是我们第一次看到这个名称,那么当我们之后得知它的子节点时,会设置它的子节点;如果该子节点是叶子节点,它的子节点数量将保持初始化值 0。
最后,我们返回树中节点的数量。当我们想要处理每个节点时,我们需要这个数据。
从一个节点的后代数量
我们需要为每个节点计算距离 d 处的后代数量,以便找到具有最多此类后代的节点。一个更为谦逊的目标,也是本节的目标,是计算从一个节点出发的距离 d 处的后代数量。我们将以这个签名编写函数:
int score_one(node *n, int d)
n 是我们想要计算的节点,其后代在距离 d 处的数量。
如果 d 为 1,那么我们想知道 n 的子节点数量。我们可以做到这一点:我们已经为每个节点存储了一个 num_children 成员。我们所要做的就是返回它:
if (d == 1)
return n->num_children;
如果 d 大于 1,那么该怎么办?也许可以先在更熟悉的二叉树上下文中思考一下。这里再次展示万圣节大礼包的二叉树(图 2-2):

假设我们有一个二叉树的节点,想要知道它在某个指定距离处的后代数量。如果我们知道左子树和右子树中距离该距离的后代数量,这是否有帮助?
还不完全对。例如,假设我们想知道 H 节点在距离 2 处的后代数量。我们计算 F 节点和 G 节点在距离 2 处的后代数量。这样做没有帮助,因为这些后代离 H 的距离是 3!我们不关心距离 H 为 3 的节点。
我们该如何解决这个问题?我们计算 F 节点在距离 1 处的后代数量,以及 G 节点在距离 1 处的后代数量!每个节点离 H 的距离是 2。
因此,要计算任意距离 d 处的后代数量,我们需要计算左子树中距离 d – 1 的后代数量,以及右子树中距离 d – 1 的后代数量。
在家族树的上下文中,节点可以有超过两个子节点,我们稍微概括一下:距离 d 的后代数量是每个子树中距离 d – 1 的后代数量之和。那么,我们如何找到每个子树中距离 d – 1 的后代数量呢?是时候用递归了!
这里有一些规则供我们使用。给定一个节点 n:
规则 1 如果 d 等于 1,则在距离 d 的后代数量等于 n 的子节点数量。
规则 2 如果 d 大于 1,则在距离 d 的后代数量等于每个子树中距离 d – 1 的后代数量之和。
相应的代码在列表 2-25 中给出。
int score_one(node *n, int d) {
int total, i;
if (d == 1)
return n->num_children;
total = 0;
for (i = 0; i < n->num_children; i++)
total = total + score_one(n->children[i], d - 1);
return total;
}
列表 2-25:一个节点的后代数量
所有节点的后代数量
要计算所有节点在距离 d 的后代数量,我们只需将 score_one 放入循环中(参见列表 2-26)。
void score_all(node **nodes, int num_nodes, int d) {
int i;
for (i = 0; i < num_nodes; i++)
nodes[i]->score = score_one(nodes[i], d);
}
列表 2-26:所有节点的后代数量
这是我们在每个 node 结构体中使用 score 成员的地方:当这个函数运行时,score 将保存每个节点的后代数量。现在我们只需找出哪些节点具有最高的分数!
排序节点
在我们失败的雪花排序尝试中(参见“诊断问题”章节,见第 9 页),我们遇到了 C 语言中的 qsort 函数。我们可以在这里使用 qsort 来排序我们的节点。我们需要按照距离 d 的后代数量从高到低进行排序。如果节点在距离 d 的后代数量上有并列情况,我们就按字母顺序对这些节点进行排序。
要使用 qsort,我们需要编写一个比较函数,该函数接受两个元素的指针,并返回一个负整数(如果第一个元素小于第二个),返回 0(如果它们相等),或返回一个正整数(如果第一个元素大于第二个)。我们的比较函数在列表 2-27 中给出。
int compare(const void *v1, const void *v2) {
const node *n1 = *(const node **)v1;
const node *n2 = *(const node **)v2;
if (n1->score > n2->score)
return -1;
if (n1->score < n2->score)
return 1;
return strcmp(n1->name, n2->name);
}
列表 2-27:用于排序的比较函数
任何 qsort 比较函数,如这个函数,具有相同的签名:它接受两个 void 指针。这些指针是 const 的,表示我们不应该对它们指向的元素进行任何修改。
在进行比较或其他方式访问底层元素之前,必须先对 void 指针进行类型转换。记住,qsort 会将我们数组中的两个元素的指针传递给 compare,但是因为我们的数组是指针的数组,传递给 compare 的实际上是指向元素指针的指针。因此,我们首先将 void 指针转换为 const node** 类型,然后使用 * 运算符来获取 n1 和 n2 的值,这样它们的类型就是 const node*。现在我们可以将 n1 和 n2 用作节点的指针了。
我们开始比较存储在每个节点中的分数。这些分数已经通过计算距离d的后代数得出。如果n1的后代数比n2多,我们返回-1表示n1应该排在n2之前。类似地,如果n1的后代数比n2少,我们返回1表示n1应该排在n2之后。
因此,只有当n1和n2在距离d的后代数相同时,才能到达最后一行。在这里,我们希望通过对节点的名称进行排序来打破平局。我们通过strcmp实现这一点,它会返回一个负数、零或正数,分别表示第一个字符串在字母顺序上小于、等于或大于第二个字符串。
输出信息
排序节点后,输出的名称是nodes数组开头的那些。清单 2-28 给出了生成该输出的函数:
void output_info(node *nodes[], int num_nodes) {
int i = 0;
❶ while (i < 3 && i < num_nodes && nodes[i]->score > 0) {
printf("%s %d\n", nodes[i]->name, nodes[i]->score);
i++;
➋ while (i < num_nodes && nodes[i]->score == nodes[i - 1]->score) {
printf("%s %d\n", nodes[i]->name, nodes[i]->score);
i++;
}
}
}
清单 2-28:输出节点
变量i统计我们已经输出的节点数。外部while循环➊由三个条件控制,这三个条件共同决定是否允许我们输出更多节点。如果三个条件都为真,我们知道需要更多输出,因此进入该while循环的主体。然后,我们打印当前节点的信息,并增加i,使我们查看下一个节点。现在,只要这个新节点与前一个节点相同,我们就想继续输出节点,而不考虑“三个节点最大限制”规则。内部while循环➋的条件编码了这个逻辑:如果还有更多节点且当前节点的分数与前一个节点相同,那么我们进入内部while循环的主体,并打印相关节点的信息。
主函数
剩下的就是将我们的函数连接在一起,并添加处理测试用例的逻辑。我们在清单 2-29 中完成了这部分。
#define MAX_NODES 1000
int main(void) {
int num_cases, case_num;
int n, d, num_nodes;
❶ node **nodes = malloc_safe(sizeof(node) * MAX_NODES);
scanf("%d", &num_cases);
for (case_num = 1; case_num <= num_cases; case_num++) {
➋ printf("Tree %d:\n", case_num);
scanf("%d %d", &n, &d);
num_nodes = read_tree(nodes, n);
score_all(nodes, num_nodes, d);
qsort(nodes, num_nodes, sizeof(node*), compare);
output_info(nodes, num_nodes);
➌ if (case_num < num_cases)
printf("\n");
}
return 0;
}
清单 2-29:主 函数
我们首先为能够组成一个测试用例的最大节点数分配指针➊。然后,我们读取测试用例的数量,并针对每个测试用例循环一次。回想一下,每个测试用例需要两部分输出:关于测试用例编号的信息和关于相关节点的信息。第一部分通过一次printf调用来处理➋。对于第二部分,我们开始依赖之前的函数:我们读取树,解决每个节点的问题,排序节点,然后输出所需的信息。
代码的底部有一个检查,用于判断是否已经是最后一个测试用例➌;这是为了在测试之间输出一个空行。
总结
递归解决方案是优秀的,简单的,清晰的,易于构思,易于理解,并且容易证明正确……
至少,如果你阅读了足够多关于递归的资料并与足够多的递归爱好者交流,你大概会有这样的感觉。专家们的观点是显而易见的。然而,通过我的学生们,我发现递归的宣传方式和实际学习过程中存在一定的脱节。要真正理解专家的观点,确实需要时间和练习。如果你觉得递归的解决方案难以设计和信任,不必担心,继续努力!许多教师和作者都有自己介绍递归的方式和示例。我鼓励你寻找更多关于递归的资料,来补充我在这里提供的内容,这比书中的任何其他话题都更加重要。
在下一章,我们将继续探索递归,并针对不同类型的问题进行优化。
备注
《万圣节购物》最初来源于 2012 年 DWITE 编程竞赛第 1 轮。 《后代距离》最初来源于 2005 年东中北美地区编程竞赛。如果你想要一本关于递归的长篇书籍,可以参考 Eric Roberts(2005 年)的《Thinking Recursively with Java》。
第三章:备忘录化和动态规划

在这一章中,我们将研究三个看似可以通过递归解决的问题。正如你所看到的,虽然理论上我们可以使用递归,但在实践中它会导致工作量的爆炸,使得问题变得不可解。别担心:你将学到两种强大的相关技巧,叫做备忘录化和动态规划,它们将带来惊人的性能提升,把运行时间从小时或天缩短到秒。在下一章中,我们将提升难度,使用这些技巧解决两个更具挑战性的问题。一旦你掌握了这些技巧,你将能够解决数百个其他编程问题。如果你要读本书的一章,那就读这一章。
问题 1:汉堡狂热
这是 UVa 问题10465。
问题描述
一位名叫霍默·辛普森的人喜欢吃喝。他有t分钟来吃汉堡和喝啤酒。有两种汉堡。一个汉堡需要m分钟吃完,另一个汉堡需要n分钟吃完。
霍默喜欢汉堡比啤酒更多,所以他想花整个t分钟吃汉堡。然而,这并不总是可能的。例如,如果 m = 4,n = 9,t = 15,那么无论如何组合 4 分钟和 9 分钟的汉堡,他都不能刚好花 15 分钟吃完汉堡。在这种情况下,他会尽可能多地吃汉堡,然后把剩下的时间用来喝啤酒。我们的任务是确定霍默能吃多少个汉堡。
输入
我们将继续读取测试用例,直到没有更多输入。每个测试用例由一行三个整数表示:m,吃第一种汉堡所需的分钟数;n,吃第二种汉堡所需的分钟数;t,霍默将花费的分钟数来吃汉堡和喝啤酒。每个m,n,和t值都小于 10,000。
输出
对于每个测试用例:
-
如果霍默可以刚好花t分钟吃汉堡,那么输出他可以吃到的最多汉堡数。
-
否则,输出霍默在尽量多吃汉堡的情况下能吃到的最多汉堡数,并输出空间和剩余的分钟数(在这段时间里,他会喝啤酒)。
解决测试用例的时间限制为三秒。
制定计划
让我们从考虑几个不同的测试用例开始。这里是第一个:
4 9 22
在这个例子中,第一种汉堡需要 4 分钟吃完(m = 4),第二种汉堡需要 9 分钟吃完(n = 9),霍默有 22 分钟可以花费(t = 22)。这是一个霍默可以通过吃汉堡填满全部时间的例子。霍默能吃到的最多汉堡数是三个,因此3是这个测试用例的正确输出。
荷马应该吃的三种汉堡是一个 4 分钟的汉堡和两个 9 分钟的汉堡。这样他需要花费 1 × 4 + 2 × 9 = 22 分钟,正好符合要求。不过请注意,我们并没有被要求标明每种汉堡的数量。我们唯一被要求的是输出汉堡的总数量。当我在下面提供每种汉堡的数量时,我这样做只是为了提供证据,证明所提的输出是合理的。
这是另一个测试用例:
4 9 54
再次,荷马可以通过吃汉堡填满整个时间。这里的正确输出是 11,通过吃九个 4 分钟的汉堡和两个 9 分钟的汉堡得到的。与 4 9 22 测试用例不同,在这里荷马有多种方法可以用汉堡恰好填满 54 分钟。例如,他可以吃六个 9 分钟的汉堡——这也能填满 54 分钟——但请记住,如果我们能够填满整个 t 分钟,那么我们需要输出 最大 的汉堡数量。
正如问题描述中所提到的,荷马并不总能完全用吃汉堡的方式填满 t 分钟。让我们以我在问题描述中给出的例子作为下一个测试用例来研究:
4 9 15
荷马应该吃多少个汉堡呢?他最多可以通过吃三个 4 分钟的汉堡来吃完三个汉堡。这样一来,荷马将花费 12 分钟吃汉堡,剩下的 15 – 12 = 3 分钟用来喝啤酒。所以他吃了三个汉堡,并且有 3 分钟的喝啤酒时间,输出为 3 3。我们是否解决了这个测试用例?
我们还没有解决!仔细重新阅读问题描述并专注于这一点:“输出荷马能吃到的最大数量的汉堡,以最大化他吃汉堡的时间。”也就是说,当荷马无法用吃汉堡的方式填满整个时间时,我们希望最大化他吃汉堡的 时间,然后输出他能在这段时间内吃到的最大数量的汉堡。对于 4 9 15,正确的输出实际上是 2 2:第一个 2 表示他吃了两个汉堡(一个 4 分钟的汉堡和一个 9 分钟的汉堡,共花费 13 分钟),第二个 2 表示他需要花 2 分钟(15 – 13)喝啤酒。
在 4 9 22 和 4 9 54 测试用例中,我们被要求分别解决 22 分钟和 54 分钟的情况。我们发现确实有一种方法可以用汉堡填满整个时间,所以我们可以报告最大数量的汉堡作为我们的解决方案。然而,在 4 9 15 的情况下,我们发现没有办法完全用吃汉堡的方式填满 15 分钟。想到我们的代码时,我们该如何处理呢?我们怎样才能得出答案是 2 2 呢?
一种思路是,我们可以接下来尝试用 4 分钟和 9 分钟的汉堡准确填满 14 分钟。如果成功,那么我们就得到了答案:我们报告霍默在准确 14 分钟内能吃的最大汉堡数量,然后是 1,霍默花在喝啤酒上的分钟数。这将最大化霍默吃汉堡的时间。我们已经知道吃汉堡 15 分钟是不可能的,所以 14 分钟是下一个最佳选择。
让我们看看 14 分钟能否成功。我们能用 4 分钟和 9 分钟的汉堡准确填满 14 分钟吗?不能!就像 15 分钟的情况一样,这也是不可能的。
但我们可以通过吃两个汉堡来准确填满 13 分钟:一个 4 分钟汉堡和一个 9 分钟汉堡。这就给霍默留了 2 分钟喝啤酒。这就证明了2 2是正确的输出。
总结来说,我们的计划是确定霍默是否能在准确的t分钟内吃汉堡。如果可以,那么任务完成:我们报告霍默能够吃的最大汉堡数量。如果不行,那么接下来我们要确定霍默是否能在准确的t – 1 分钟内吃汉堡。如果可以,那么任务完成,我们报告霍默能吃的最大汉堡数量以及他花在喝啤酒上的时间。如果不行,那么我们将继续尝试t – 2 分钟,再尝试t – 3 分钟,依此类推,直到找到可以完全用吃汉堡填满的时间。
优化问题的表征
以4 9 22测试案例为例。无论我们提出哪种汉堡和啤酒的组合,解决方案都必须正好用 22 分钟完成,而且必须能用 4 分钟和 9 分钟的汉堡来实现。符合问题规则的解决方案被称为可行解决方案。不符合规则的解决方案称为不可行解决方案。例如,让霍默花 4 分钟吃汉堡,18 分钟喝啤酒是可行的。让霍默花 8 分钟吃汉堡,18 分钟喝啤酒则是不可行的,因为 8 + 18 并不等于 22。让霍默花 5 分钟吃汉堡,17 分钟喝啤酒也是不可行的,因为我们无法用 4 分钟和 9 分钟的汉堡来凑满 5 分钟的汉堡时间。
汉堡热潮是一个优化问题。优化问题涉及从所有可行解中选择最好的——也就是最优的解决方案。可能会有许多可行解,质量各异。有些会很差,比如花 22 分钟喝啤酒。其他解可能接近但不是完全最优——可能相差一两分钟。当然,也会有一些是最优解。我们的目标是从所有可能的解中识别出最优解。
假设我们正在解决一个案例,其中第一种汉堡需要m分钟吃,第二种汉堡需要n分钟吃,而我们希望尝试花exactly t分钟吃汉堡。
如果t = 0,那么正确的输出是0,因为我们可以通过吃零个汉堡填满整个 0 分钟。接下来,我们将重点讨论t大于 0 时该怎么做。
让我们思考一下,最优解在t分钟内应该是什么样子的。当然,我们不可能知道任何具体的内容,比如“霍默先吃一个 4 分钟的汉堡,然后是一个 9 分钟的汉堡,再来一个 9 分钟的汉堡,接下来……”我们还没有做出任何解决方案,所以得到这种具体的细节只是痴心妄想。
然而,有一件事我们可以说,并非痴心妄想。它既简单到你可能会疑惑我为何要提及,又强大到它的核心包含了无数优化问题的解决策略。
事实如下:假设霍默可以通过吃汉堡来正好填满t分钟。(如果这个假设不成立,那么我们可以再尝试用t – 1 分钟、t – 2 分钟,依此类推。)他吃的最后一个汉堡,完成了他的t分钟,必须是一个m分钟的汉堡或一个n分钟的汉堡。
那么,最后一个汉堡怎么可能是别的呢?霍默只能吃m分钟和n分钟的汉堡,因此他吃的最后一个汉堡只有两种选择,最优解的结尾也只有两种可能。
如果我们知道霍默在一个最优解中吃的最后一个汉堡是一个m分钟的汉堡,那么我们就知道他还剩下t – m分钟可以花费。必须有一种方法来用汉堡填充这t – m分钟,而不喝啤酒:记住,我们假设霍默可以用吃汉堡来度过整个t分钟。如果我们能以最优方式度过这t – m分钟,让霍默吃到最多的汉堡,那么我们就得到了原始问题t分钟的最优解。我们会计算他在t – m分钟内能吃多少个汉堡,然后加上一个m分钟的汉堡来填补剩余的m分钟。
现在,假设我们知道霍默在最优解中吃的最后一个汉堡是一个n分钟的汉堡?那么他还有t – n分钟可以花费。同样,由于整个t分钟都花在吃汉堡上,我们知道霍默在前t – n分钟内一定能吃汉堡。如果我们能以最优方式度过这t – n分钟,那么我们就得到了原始问题t分钟的最优解。我们会计算他在t – n分钟内能吃多少个汉堡,然后加上一个n分钟的汉堡来填补剩余的n分钟。
现在,我们似乎完全进入了荒诞的领域。我们刚才假设知道了最后一个汉堡是什么!然而,我们怎么可能知道这个呢?我们确实知道最后一个汉堡是一个m分钟的汉堡或者一个n分钟的汉堡。我们肯定不知道它是哪一个。
美妙的事实是,我们不需要知道确切答案。我们可以假设最后一个汉堡是一个m分钟的汉堡,并在这种选择下最优地解决问题。然后我们再做另一个假设——假设最后一个汉堡是一个n分钟的汉堡,并在这种选择下最优地解决问题。在第一种情况下,我们有一个t - m分钟的子问题需要最优解决;在第二种情况下,我们有一个t - n分钟的子问题需要最优解决。每当我们用子问题的解法来描述一个问题的解法时,我们就应该尝试递归方法,就像我们在第二章中做的那样。
解决方案 1:递归
让我们尝试一个递归解法。我们将首先编写一个辅助函数,用来精确求解t分钟。完成后,我们将依赖这个函数来求解精确的t分钟、t - 1 分钟、t - 2 分钟,依此类推,直到我们能够完全用汉堡填满一些分钟数。
辅助函数:求解分钟数
为了解决每个问题和子问题实例,我们需要三样东西:测试用例中的m和n,以及当前实例的t值。因此,我们将编写以下函数的主体:
int solve_t(int m, int n, int t)
如果霍默能恰好花t分钟吃汉堡,那么我们将返回他能够吃的最大汉堡数量。如果他无法恰好花t分钟吃汉堡——这意味着他至少需要花费 1 分钟喝啤酒——那么我们将返回-1。返回值为0或更大的数表示我们仅用汉堡就解决了问题;返回值为-1表示无法仅用汉堡解决问题。
如果我们调用solve_t(4, 9, 22),我们期望返回值为3:这表示霍默在恰好 22 分钟内能吃的最大汉堡数量是 3 个。如果我们调用solve_t(4, 9, 15),我们期望返回值为-1:没有任何 4 分钟和 9 分钟的汉堡组合能恰好给我们 15 分钟。
我们已经确定了当t = 0 时该怎么做:在这种情况下,我们没有时间,霍默吃零个汉堡:
if (t == 0)
return 0;
这是我们递归的基准情况。为了实现这个函数的其余部分,我们需要上一节的分析。记住,要精确解决t分钟的问题,我们需要考虑霍默最后吃的那个汉堡。也许它是一个m分钟的汉堡。为了验证这个可能性,我们将求解t - m分钟的子问题。当然,最后的汉堡只有在我们至少有m分钟时间时,才可能是一个m分钟的汉堡。这个逻辑可以如下编程实现:
int first;
if (t >= m)
first = solve_t(m, n, t - m);
else
first = -1;
我们使用first来存储t - m子问题的最优解,-1表示“没有解”。如果t >= m,则表示有可能最后一个汉堡是m分钟的汉堡,因此我们进行递归调用来计算霍默在恰好t - m分钟内能够吃到的最优汉堡数量。如果可以精确解决,递归调用会返回大于-1的数字,如果无法解决,则返回-1。如果t < m,则不进行递归调用:我们将first = -1,表示m分钟的汉堡不能是最后一个汉堡,也无法在恰好t分钟内参与最优解。
那么,假设最后一个汉堡是n分钟的情况呢?这个情况的代码类似于m分钟汉堡的情况,只不过这次将结果存储在变量second中,而不是first:
int second;
if (t >= n)
second = solve_t(m, n, t - n);
else
second = -1;
让我们总结一下当前的进展:
-
变量
first是t - m子问题的解。如果它是-1,那么我们无法用汉堡完全填满t - m分钟。如果是其他值,它则表示霍默可以在恰好t - m分钟内吃的最优汉堡数量。 -
变量
second是t - n子问题的解。如果它是-1,那么我们无法用汉堡完全填满t - n分钟。如果是其他值,它则表示霍默可以在恰好t - n分钟内吃的最优汉堡数量。
有可能first和second都为-1。如果first为-1,则意味着m分钟的汉堡不能是最后一个汉堡。如果second为-1,则意味着n分钟的汉堡不能是最后一个汉堡。如果最后一个汉堡既不能是m分钟的汉堡,也不能是n分钟的汉堡,那么我们就没有其他选项,只能得出结论,无法在恰好t分钟内解决这个问题:
if (first == -1 && second == -1)
return -1;
否则,如果first、second或两者都大于-1,那么我们至少可以构造一个解决方案来解决恰好t分钟的问题。在这种情况下,我们从first和second的最大值开始,选择更好的子问题解。如果我们将该最大值加一,从而包括最后一个汉堡,我们就得到了原问题在恰好t分钟的最优解:
return max(first, second) + 1;
完整的函数请参考列表 3-1。
int max(int v1, int v2) {
if (v1 > v2)
return v1;
else
return v2;
}
int solve_t(int m, int n, int t) {
int first, second;
if (t == 0)
return 0;
if (t >= m)
❶ first = solve_t(m, n, t - m);
else
first = -1;
if (t >= n)
➋ second = solve_t(m, n, t - n);
else
second = -1;
if (first == -1 && second == -1)
➌ return -1;
else
➍ return max(first, second) + 1;
}
列表 3-1:求解恰好 t 分钟的情况
花几分钟时间了解这个函数的作用是值得的——即使你已经确信它是正确的。
让我们从solve_t(4, 9, 22)开始。first ❶的递归调用求解了 18 分钟(22 - 4)的子问题。该递归调用返回2,因为 Homer 在恰好 18 分钟内能吃的最大汉堡数是 2 个。second ➋的递归调用求解了 13 分钟(22 - 9)的子问题。该递归调用也返回2,因为 Homer 在恰好 13 分钟内能吃的最大汉堡数也是 2 个。也就是说,在这种情况下,first和second的值都是2;再加上最后四分钟或九分钟的汉堡,原始问题(恰好 22 分钟)的解就是3 ➍。
现在让我们尝试solve_t(4, 9, 20)。first ❶的递归调用求解了 16 分钟(20 - 4)的子问题,并返回了4,但second ➋的递归调用又如何呢?它需要解决 11 分钟(20 - 9)的子问题,但无法通过吃 4 分钟和 9 分钟的汉堡恰好填满 11 分钟!因此,这个第二次递归调用返回-1。first和second中的最大值是4(first的值),因此我们返回5 ➍。
到目前为止,我们已经看到过一个例子,其中两个递归调用都返回相同数量的汉堡子问题解,以及一个只有一个递归调用返回子问题解的例子。现在,让我们看一个每个递归调用都返回子问题解,但其中一个比另一个更优的情况!考虑solve_t(4, 9, 36)。first ❶的递归调用返回了8,这是 Homer 在恰好 32 分钟(36 - 4)内能吃的最大汉堡数。second ➋的递归调用返回了3,这是 Homer 在恰好 27 分钟(36 - 9)内能吃的最大汉堡数。8和3中的最大值是8,因此我们返回9作为整体解 ➍。
最后,尝试solve_t(4, 9, 15)。first ❶的递归调用要求解决恰好 11 分钟(15 - 4)的子问题,但由于无法用这些汉堡填满 11 分钟,它返回了-1。second递归调用 ➋ 的结果类似:解决恰好 6 分钟(15 - 9)的子问题也是不可能的,因此它也返回了-1。因此,无法解决恰好 15 分钟的问题,所以我们返回-1 ➌。
solve 和 main 函数
回想一下在“制定计划”部分提到的内容,见于第 78 页,如果我们能够通过吃汉堡填满恰好t分钟,那么我们就能吃到最多的汉堡。否则,Homer 就必须至少花费一分钟喝啤酒。为了计算他必须花费的喝啤酒的时间,我们尝试计算恰好t - 1 分钟、t - 2 分钟,依此类推,直到找到一个可以通过吃汉堡填满的分钟数。幸运的是,通过我们的solve_t函数,我们可以将t参数设置为任何我们想要的值。我们可以从给定的t值开始,然后根据需要依次调用t - 1、t - 2等值。我们在示例 3-2 中实现了这个计划。
void solve(int m, int n, int t) {
int result, i;
❶ result = solve_t(m, n, t);
if (result >= 0)
➋ printf("%d\n", result);
else {
i = t - 1;
➌ result = solve_t(m, n, i);
while (result == -1) {
i--;
➍ result = solve_t(m, n, i);
}
➎ printf("%d %d\n", result, t - i);
}
}
示例 3-2:解法 1
首先,我们针对准确的 t 分钟解决问题 ❶。如果结果至少为零,那么我们输出最大数量的汉堡 ➋ 并停止。
如果 Homer 无法在整个 t 分钟内吃完汉堡,我们将 i 设置为 t - 1,因为 t - 1 是我们应该尝试的下一个最佳分钟数。然后我们针对这个新的 i 值 ➌ 来解决问题。如果结果不是 -1,则表示成功,跳过 while 循环。如果没有成功,while 循环将继续执行,直到我们成功解决一个子问题。在 while 循环中,我们递减 i 的值并解决那个更小的子问题 ➍。while 循环最终会终止;例如,我们肯定能用汉堡填满零分钟。一旦跳出 while 循环,我们就找到了可以用汉堡填满的最大分钟数 i。此时,result 将保存最大数量的汉堡,t - i 是剩余的分钟数,所以我们输出这两个值 ➎。
就是这样。我们在 solve_t 中使用递归来解决准确的 t。我们在不同的测试用例上测试了 solve_t,一切看起来都很不错。无法准确求解 t 并不成问题:我们在 solve 中使用一个循环,逐一尝试分钟数,从大到小。现在我们只需要一个 main 函数来读取输入并调用 solve;清单 3-3 提供了代码。
int main(void) {
int m, n, t;
while (scanf("%d%d%d", &m, &n, &t) != -1)
solve(m, n, t);
return 0;
}
清单 3-3:主函数
啊,这是一个和谐的时刻。我们现在准备将解法 1 提交给评审。请现在提交。我会等待……再等待……再等待。
解法 2:记忆化
解法 1 失败了,不是因为它不正确,而是因为它太慢。如果你将解法 1 提交给评审,你将收到一个 “超时” 错误。还记得在第一章中的 Unique Snowflakes 问题中我们收到的 “超时” 错误吗?当时,低效的表现是做了不必要的工作。而在这里,正如我们很快就会看到的,低效的原因并不在于做了不必要的工作,而是做了必要的工作一遍又一遍。
问题描述中提到,t 可以是小于 10,000 的任意分钟数。那么,下面这个测试用例应该不会有问题:
4 2 88
m 和 n 的值,4 和 2,都非常小。相对于 10,000,t 的值 88 也非常小。你可能会感到惊讶和失望,我们在这个测试用例上的代码可能无法在三秒的时间限制内运行。在我的笔记本上,它大约需要 10 秒钟。这是一个微不足道的 88 测试用例,居然要花 10 秒钟。既然我们说到这,不如再试一个稍大的测试用例:
4 2 90
我们所做的只是将 t 从 88 增加到 90,但是这个小小的增量对运行时间产生了不成比例的影响:在我的笔记本上,这个测试用例大约需要 18 秒——几乎是 88 测试用例的两倍!使用 t 值为 92 进行测试时,运行时间几乎再次翻倍,依此类推。不管计算机有多快,你不太可能跑到 t 值甚至是 100。通过从这个趋势推算,我们可以看出,运行代码所需的时间是难以想象的,尤其是在 t 值达到数千时。这样的算法,被称为 指数时间算法,其中问题规模的固定增量会导致运行时间的翻倍。
我们已经确认代码运行较慢——但为什么?哪里存在低效?
计算函数调用次数
我将采用方案 1,并添加一些代码来统计 solve_t 被调用的次数;请参见 列表 3-4 中的新 solve_t 和 solve 函数。现在,我们有了一个全局变量 total_calls,在进入 solve 时初始化为 0,并在每次调用 solve_t 时增加 1。该变量类型为 long long;long 或 int 根本不足以捕捉函数调用的爆炸性增长。
unsigned long long total_calls;
int solve_t(int m, int n, int t) {
int first, second;
❶ total_calls++;
if (t == 0)
return 0;
if (t >= m)
first = solve_t(m, n, t - m);
else
first = -1;
if (t >= n)
second = solve_t(m, n, t - n);
else
second = -1;
if (first == -1 && second == -1)
return -1;
else
return max(first, second) + 1;
}
void solve(int m, int n, int t) {
int result, i;
➋ total_calls = 0;
result = solve_t(m, n, t);
if (result >= 0)
printf("%d\n", result);
else {
i = t - 1;
result = solve_t(m, n, i);
while (result == -1) {
i--;
result = solve_t(m, n, i);
}
printf("%d %d\n", result, t - i);
}
➌ printf("Total calls to solve_t: %llu\n", total_calls);
}
列表 3-4:方案 1,已加装计数器
在 solve_t 的开始,我们将 total_calls 增加 1 ❶ 来统计这个函数的调用次数。在 solve 中,我们将 total_calls 初始化为 0 ➋,这样每次处理测试用例时,调用次数会被重置。对于每个测试用例,代码会打印出 solve_t 被调用的次数 ➌。
如果我们用这个输入来试试:
4 2 88
4 2 90
我们得到以下输出:
44
Total calls to solve_t: 2971215072
45
Total calls to solve_t: 4807526975
我们已经进行了数十亿次调用!
考虑给定的 m、n、t 测试用例。我们的 solve_t 函数有三个参数,但只有第三个参数 t 会发生变化。因此,solve_t 只有 t + 1 种不同的调用方式。例如,如果测试用例中的 t 值是 88,那么唯一可以调用 solve_t 的值是 88、87、86,依此类推。一旦我们知道了某个 t 值的答案,比如 86,就没有理由再计算这个答案了。
在这些数十亿次的调用中,只有大约 88 或 90 次是独立的。我们得出结论,相同的子问题被解决了惊人的次数。
记住我们的答案
下面是我们调用次数惊人数量的一些直觉。如果我们调用 solve_t(4, 2, 88),它会进行两个递归调用:一个是 solve_t(4, 2, 86),另一个是 solve_t(4, 2, 84)。到这里一切正常。现在考虑 solve_t(4, 2, 86) 这个调用,它将进行两个递归调用,其中第一个是 solve_t(4, 2, 84)——这正是 solve_t(4, 2, 88) 进行的递归调用之一!因此,solve_t(4, 2, 84) 这部分工作会被执行两次。一次就足够了!
然而,不谨慎的重复才刚刚开始。考虑两个solve_t(4, 2, 84)调用。根据上一段的推理,我们看到每个调用最终会导致两个solve_t(4, 2, 80)调用,总共四个调用。再次强调,调用一次就够了!
好吧,如果我们能记住第一次计算时的答案,那就足够了。如果我们记住第一次计算solve_t的答案,那么以后需要这个答案时就可以直接查找。
记住,不要重新计算。这就是备忘录化技术的格言。备忘录化来源于单词memoize,意思是像备忘录一样存储。这个词虽然笨拙,但在广泛使用。
使用备忘录优化涉及三个步骤:
-
声明一个足够大的数组来保存所有可能子问题的解。在《汉堡狂热》中,
t小于 10,000,所以一个包含 10,000 个元素的数组就足够了。这个数组通常被命名为memo。 -
将
memo的元素初始化为表示“未知值”的值。 -
在递归函数的开始,添加代码检查子问题的解是否已经解决。这涉及到检查
memo的相应索引:如果那里是“未知值”,那么我们必须现在解决这个子问题;否则,答案已经存储在memo中,我们只需返回它,而不进行进一步的递归。每次我们解决一个新子问题时,都将其解存储在memo中。
让我们将解决方案 1 与备忘录化结合起来。
实现备忘录优化
声明和初始化memo数组的合适位置是在solve中,因为这是针对每个测试用例首次触发的函数。我们将使用值-2来表示未知值;我们不能使用正数,因为它们可能与汉堡的数量混淆,而且我们不能使用-1,因为我们已经用-1来表示“没有解”。更新后的solve函数见清单 3-5。
#define SIZE 10000
void solve(int m, int n, int t) {
int result, i;
❶ int memo[SIZE];
for (i = 0; i <= t; i++)
memo[i] = -2;
result = solve_t(m, n, t, memo);
if (result >= 0)
printf("%d\n", result);
else {
i = t - 1;
result = solve_t(m, n, i, memo);
while (result == -1) {
i--;
result = solve_t(m, n, i, memo);
}
printf("%d %d\n", result, t - i);
}
}
清单 3-5:解决方案 2,已实现备忘录优化
我们使用针对任何测试用例的最大可能大小声明memo数组❶。然后我们从0循环到t,并将该范围内的每个元素设置为-2。
我们对solve_t的调用也有一个小但重要的变化。现在我们将memo和其他参数一起传递;这样,solve_t就可以检查memo来判断当前子问题是否已经解决,如果没有,则更新memo。
更新后的solve_t代码见清单 3-6。
int solve_t(int m, int n, int t, int memo[]) {
int first, second;
❶ if (memo[t] != -2)
return memo[t];
if (t == 0) {
memo[t] = 0;
return memo[t];
}
if (t >= m)
first = solve_t(m, n, t - m, memo);
else
first = -1;
if (t >= n)
second = solve_t(m, n, t - n, memo);
else
second = -1;
if (first == -1 && second == -1) {
memo[t] = -1;
return memo[t];
} else {
memo[t] = max(first, second) + 1;
return memo[t];
}
}
清单 3-6:精确求解 t 分钟,已实现备忘录优化
游戏计划与解决方案 1 中的计划相同,清单 3-1:如果t为0,则解决基本情况;否则,解决t - m分钟和t - n分钟,并使用较好的结果。
我们将备忘录化与这个结构紧密结合。当我们检查t的解决方案是否已经存在于memo数组中时,时间的巨大缩短就显现出来❶,如果存在,就返回存储的结果。没有再纠结于最终汉堡需要* m 还是 n *分钟。没有进一步的递归。我们所做的只是立即从函数返回。
如果我们在memo中没有找到解决方案,那我们就得做点工作。工作内容和之前一样——不过每当我们准备返回结果时,我们都会先将其存储到memo中。在每一个return语句之前,我们都会将即将返回的值存储在memo中,以便我们的程序保持它的记忆。
测试我们的备忘录化
我通过展示两件事证明了方案 1 注定会失败:小的测试用例运行时间过长,而且这种慢速是由于调用了过多的函数导致的。方案 2 在这些指标上表现如何?
使用在方案 1 中表现最好的输入来尝试方案 2:
4 2 88
4 2 90
在我的笔记本电脑上,所花的时间几乎可以忽略不计。
调用了多少次函数?我鼓励你像我们在方案 1 中做的那样,为方案 2 添加监控代码(参见清单 3-4)。如果你这么做并使用上述输入运行它,你应该会得到以下输出:
44
Total calls to solve_t: 88
45
Total calls to solve_t: 90
当t是88时调用了 88 次。当t是90时调用了 90 次。方案 2 和方案 1 之间的差异就像夜与几亿天的区别。我们已经从一个指数时间算法转变为一个线性时间算法。具体来说,我们现在有一个O(t)算法,其中t是霍默的分钟数。
现在是评判时间。如果你提交方案 2,你会发现我们通过了所有的测试用例。
这无疑是一个里程碑,但它不是霍默和他的汉堡故事的终章。我们将能够使用一种叫做动态规划的技术,从我们的代码中去除递归。
方案 3:动态规划
我们将通过明确方案 2 中递归的目的,架起从备忘录化到动态规划的桥梁。考虑清单 3-7 中的solve_t代码;它和清单 3-6 中的代码一样,只不过我现在突出了两个递归调用。
int solve_t(int m, int n, int t, int memo[]) {
int first, second;
if (memo[t] != -2)
return memo[t];
if (t == 0) {
memo[t] = 0;
return memo[t];
}
if (t >= m)
❶ first = solve_t(m, n, t - m, memo);
else
first = -1;
if (t >= n)
➋ second = solve_t(m, n, t - n, memo);
else
second = -1;
if (first == -1 && second == -1) {
memo[t] = -1;
return memo[t];
} else {
memo[t] = max(first, second) + 1;
return memo[t];
}
}
清单 3-7:精确求解t分钟,重点在递归调用
在第一次递归调用❶时,可能会发生两种截然不同的情况。第一种是递归调用在备忘录中找到了其子问题的解决方案并立即返回。第二种是递归调用没有在备忘录中找到子问题的解决方案,在这种情况下,它会执行自己的递归调用。第二次递归调用➋也是如此。
当我们进行递归调用并且在备忘录中找到了其子问题的解决方案时,我们不得不思考为什么当初要进行递归调用。递归调用唯一能做的,就是检查备忘录并返回;我们本可以自己做这件事。然而,如果子问题的解决方案不在备忘录中,那么递归调用确实是必要的。
假设我们可以安排这样一种情况,使得memo数组始终保存我们需要查找的下一个子问题的解决方案。当t为5时,我们想知道最优解吗?它就在memo中。当t为18时呢?它也在memo中。由于始终可以在备忘录中找到子问题的解决方案,我们就不再需要递归调用;我们可以直接查找解决方案。
这里展示了备忘录化和动态规划之间的区别。使用备忘录化的函数会递归调用来解决子问题。也许子问题已经解决,也许没有——无论如何,当递归调用返回时,子问题就会被解决。而使用动态规划的函数则是以组织工作的方式确保在需要时子问题已经解决。这样,我们就不再需要使用递归:我们只需查找解决方案。
备忘录化使用递归来确保一个子问题被解决;动态规划确保子问题已经被解决,因此不需要递归。
我们的动态规划解决方案摒弃了solve_t函数,并系统地解决solve中的所有t值。清单 3-8 给出了代码。
void solve(int m, int n, int t) {
int result, i, first, second;
int dp[SIZE];
❶ dp[0] = 0;
for (i = 1; i <= t; i++) {
➋ if (i >= m)
➌ first = dp[i - m];
else
first = -1;
➍ if (i >= n)
second = dp[i - n];
else
second = -1;
if (first == -1 && second == -1)
➎ dp[i] = -1;
else
➏ dp[i] = max(first, second) + 1;
}
❼ result = dp[t];
if (result >= 0)
printf("%d\n", result);
else {
i = t - 1;
result = dp[i];
while (result == -1) {
i--;
❽ result = dp[i];
}
printf("%d %d\n", result, t - i);
}
}
清单 3-8:使用动态规划的解决方案 3
动态规划数组的标准名称是dp。我们本可以将其命名为memo,因为它与备忘录表的作用相同,但为了遵循惯例,我们称其为dp。一旦声明了数组,我们就解决基础情况,明确存储零分钟的最佳解决方案是吃零个汉堡❶。然后,我们有一个控制子问题解决顺序的循环。在这里,我们从最小的分钟数(1)到最大的分钟数(t)依次解决子问题。变量i决定了正在解决的子问题。在循环内部,我们检查是否有必要将* m *分钟的汉堡作为最后一个汉堡来测试➋。如果是这样,我们就在dp数组中查找i - m子问题的解答➌。
注意,我们如何仅通过查找数组中的值➌,而不使用任何递归。我们之所以能够这样做,是因为我们知道,由于i - m小于i,我们已经解决了i - m子问题。这正是我们按顺序从最小到最大解决子问题的原因:较大的子问题需要较小子问题的解决方案,因此我们必须确保那些较小的子问题已经解决。
下一个 if 语句 ➍ 类似于之前的语句 ➋,处理最后一个汉堡是 n 分钟汉堡的情况。像之前一样,我们通过 dp 数组查找子问题的解。我们可以确定,i - n 子问题已经解决,因为 i - n 的迭代发生在此 i 迭代之前。
现在我们已经解决了两个必需的子问题。剩下的就是将最优解存储到 dp[i] ➎ ➏ 中。
一旦我们构建了 dp 数组,解决了子问题 0 到 t,我们就可以随时查找子问题的解。因此,我们只需查找子问题 t ❼ 的解,如果有解就打印出来,如果没有解,就逐渐查找更小子问题的解 ❽。
和我们使用记忆化的解法一样,这也是一个线性时间的解法。通常,动态规划和记忆化解法的效率相同,但动态规划解法的效率可能更容易识别,因为它使用的是循环而非递归。
在继续之前,让我们看一个 dp 数组的例子。对于以下的测试用例:
4 9 15
dp 数组的最终内容是:

我们可以通过跟踪 Listing 3-8 中的代码来确认每个子问题的解。例如,dp[0],即 Homer 在零分钟内最多能吃的汉堡数量,是 0 ❶。dp[1] 是 -1,因为两个检查 ➋ ➍ 都失败了,意味着我们存储了 -1 ➎。
作为最后一个例子,我们将逆向推理 dp[12] 是如何得到值 3 的。由于 12 大于 4,第一个检查通过 ➋。接着我们将 first 设置为 dp[8] ➌,它的值是 2。同样,由于 12 大于 9,第二个检查通过 ➍,然后我们将 second 设置为 dp[3],其值为 -1。因此,first 和 second 的最大值为 2,所以我们将 dp[12] 设置为 3,比这个最大值多 1 ➏。
请随时将我们的动态规划解法提交给评测系统。它应该像我们的记忆化解法一样通过所有测试用例。那这两种解法有哪个更好吗?我们到底什么时候应该使用记忆化,什么时候应该使用动态规划呢?继续阅读,答案即将揭晓。
记忆化和动态规划
我们通过四个步骤解决了汉堡热潮问题。首先,我们描述了最优解必须具备的特征;其次,我们写出了递归解法;第三,我们添加了记忆化;最后,我们通过显式地从最小子问题到最大子问题解决问题,消除了递归。这四个步骤为解决许多其他优化问题提供了一般性方案。
步骤 1:最优解的结构
第一步是展示如何将一个问题的最优解分解成更小子问题的最优解。在《汉堡狂热》中,我们通过推理 Homer 吃的最终汉堡来完成这一点。它是一个 m 分钟汉堡吗?那就剩下填充 t – m 分钟的子问题。如果它是一个 n 分钟汉堡呢?那就剩下填充 t – n 分钟的问题。当然,我们并不知道它是哪种,但我们可以简单地解决这两个子问题来找出答案。
我们必须确保我们的子问题比原问题更 小。如果它们不是,那我们最终将无法达到基本情况。第五章 中的问题就作为了一个例子,说明了当子问题无法按照大小轻松排序时,我们需要做什么。
我们还要求,一个问题的最优解不仅包含一些子问题的解,而且包含这些子问题的 最优 解。让我们在这里明确这一点。
在《汉堡狂热》中,假设最优解中的最终汉堡是一个 m 分钟汉堡时,我们认为 t – m 子问题的解是整体 t 问题解的一部分。而且,t 的最优解必须包含 t – m 的最优解:如果不包含,那么 t 的解就不是最优的,因为我们可以通过使用更好的 t – m 解来改进它!一个类似的论证可以用来说明,如果最优解中的最后一个汉堡是 n 分钟汉堡,那么剩余的 t – n 分钟应该用 t – n 的最优解来填充。
让我通过一个例子来解释一下。假设 m = 4,n = 9,t = 54。最优解的值是 11。存在一个最优解 S,其中最后一个汉堡是一个 9 分钟汉堡。我声称 S 必须由这个 9 分钟的汉堡和一个 45 分钟的最优解组成。45 分钟的最优解是 10 个汉堡。如果 S 在前 45 分钟使用了某个非最优解,那么 S 就不是一个最优的 11 汉堡解。例如,如果 S 在前 45 分钟使用了一个非最优的 5 汉堡解,那么它在 54 分钟的总汉堡数就只有 6 个!
如果一个问题的最优解由子问题的最优解组成,我们说该问题具有 最优子结构。如果一个问题具有最优子结构,那么本章中的技术很可能适用。
我读过并听说过一些人声称,使用记忆化或动态规划来解决优化问题是公式化的,意思是说一旦你见过一个这样的题目,就能见到所有题目,你只需要转动把手应对新的问题。我不这么认为。这样的观点忽视了两方面的挑战:一是如何表征最优解的结构,二是如何识别最初就能确定这种方法会带来有用的结果。
例如,在讨论汉堡热潮的最优解结构时,我曾特别强调要关注准确的 t 分钟。因此,在我们的代码中,我们未必能一次性查到答案——我们需要先检查是否有针对准确的 t 分钟的解答,再检查准确的 t - 1 分钟,再检查准确的 t - 2 分钟,依此类推,直到找到解答。但我们难道不能找到一种方式来表征最优解,这样就不需要像这样逐步搜索了吗?当然可以,但那会导致这个问题的整体解法更为复杂。(我们将在本章稍后看到这种替代方法,作为解决问题 3 的有效手段。)
我的观点只是发现最优解的形式可能出奇地困难。可以使用备忘录法和动态规划解决的广泛问题意味着,通过尽可能多地练习并从中总结经验,是唯一的前进之路。
步骤 2:递归解法
步骤 1 不仅表明备忘录法和动态规划会导致解决方案,而且还为我们留下了一个递归解法来解决问题。为了求解原始问题,可以尝试每种最优解的可能性,利用递归最优地解决子问题。在汉堡热潮中,我们认为,对于准确的 t 分钟,最优解可能是一个 m 分钟的汉堡和一个准确的 t - m 分钟的最优解,或者是一个 n 分钟的汉堡和一个准确的 t - n 分钟的最优解。因此,必须解决 t - m 和 t - n 子问题,且因为这些子问题比 t 更小,所以我们用递归来解决它们。一般来说,递归调用的次数取决于可供选择的候选解的数量,它们在争取成为最优解的过程中竞争。
步骤 3:备忘录法
如果我们成功完成了步骤 2,那么我们就有了问题的正确解答。然而,正如我们在《汉堡热潮》中看到的那样,这样的解答可能需要耗费极其不合理的时间来执行。罪魁祸首是相同的子问题被一遍又一遍地解决,这就是所谓的重叠子问题现象。实际上,如果没有重叠子问题,我们就可以直接停止:递归本身是可行的。回想一下第二章和我们在其中解决的两个问题。我们单靠递归成功解决了这些问题,之所以可行,是因为每个子问题只被解决一次。例如,在《万圣节糖果》中,我们计算了树中糖果的总量。两个子问题是计算左右子树中糖果的总量。这些问题是独立的:解决左子树的子问题不可能需要关于右子树的信息,反之亦然。
如果没有子问题重叠,我们可以直接使用递归。然而,当出现子问题重叠时,就该使用备忘录技术了。正如我们在解决《汉堡狂热》问题时学到的那样,备忘录意味着我们在第一次解决子问题时将其解决方案存储起来。然后,无论何时将来需要该子问题的解决方案时,我们都可以直接查找,而不是重新计算。是的,子问题仍然有重叠,但现在它们只被解决一次,就像在第二章中一样。
第 4 步:动态规划
很可能,第 3 步得到的解决方案已经足够快了。这样的解决方案仍然使用递归,但没有重复工作的风险。正如我在接下来的段落中所解释的,有时我们想要消除递归。只要我们按顺序先解决小的子问题再解决大的子问题,就可以做到这一点。这就是动态规划:用循环代替递归,显式地按从小到大的顺序解决所有子问题。
那么,哪种更好呢:备忘录还是动态规划?对于许多问题,它们大致是等效的,在这些情况下,你可以选择自己更为舒适的方式。就我个人而言,我更倾向于使用备忘录。我们将在本章后面的第 3 个问题中看到一个例子,其中memo和dp表有多个维度。在这样的题目中,我经常难以准确设置dp表的所有基础情况和边界条件。
备忘录技术按需解决子问题。例如,考虑《汉堡狂热》测试案例,其中有一种汉堡需要 2 分钟吃完,一种汉堡需要 4 分钟吃完,总时间为 90 分钟。备忘录解决方案永远不会解决奇数分钟的问题,例如 89、87 或 85,因为这些子问题不能通过从 90 中减去 2 和 4 的倍数来得到。相比之下,动态规划会解决所有子问题,直到 90 分钟为止。这里的区别似乎有利于备忘录解决方案;确实,如果子问题空间的大部分从未被使用,那么备忘录可能比动态规划更快。然而,这必须与递归代码固有的开销进行权衡,考虑到所有函数的调用和返回。如果你愿意,尝试编写两种解决方案,并看看哪个更快也无妨!
你通常会看到人们将备忘录解决方案称为自顶向下解决方案,而将动态规划解决方案称为自底向上解决方案。之所以叫“自顶向下”,是因为为了求解较大的子问题,我们需要递归到较小的子问题。在“自底向上”解决方案中,我们从底部开始——即最小的子问题——逐步解决到顶部。
备忘录化和动态规划对我来说非常迷人。它们能够解决许多类型的问题;我不知道还有哪种算法设计技巧能与之媲美。我们在本书中学到的许多工具,例如第一章中的哈希表,提供了有价值的加速。事实上,即使没有这些工具,我们仍然可以解决许多问题实例——虽然可能无法在规定时间内通过裁判的验证,但可能仍然能在实际中派上用场。然而,备忘录化和动态规划是不同的。它们让递归思想变得生动,将那些令人惊讶地慢的算法变得令人惊讶地快。我希望通过这一章和下一章能将你吸引进来,并且希望你不要在这两章结束时就停下。
问题 2:捞钱者
在《汉堡狂潮》中,我们通过考虑两个子问题来解决每个问题。在这里,我们将看到一个例子,其中每个问题可能需要解决更多的子问题。
这是 UVa 问题10980。
问题描述
你想买苹果,所以你去了一个苹果商店。商店有一个购买一个苹果的价格——例如 $1.75。商店还有 m 种定价方案,每种方案给出购买 n 个苹果的价格 p。例如,一种定价方案可能是三只苹果总价 $4.00;另一种方案可能是两只苹果总价 $2.50。你想买 至少 k 只苹果,并且尽可能便宜。
输入
我们会读取测试用例,直到没有更多输入。每个测试用例由以下几行组成:
-
一行包含购买一个苹果的价格,接着是该测试用例的定价方案数量 m。m 的最大值为 20。
-
m 行,每行包含一个整数 n 和购买 n 个苹果的总价格 p。n 的取值范围为 1 到 100。
-
一行包含若干整数,每个整数 k 介于 0 到 100 之间,表示想要购买的苹果数量。
输入中的每个价格都是一个浮动小数,精确到小数点后两位。
在问题描述中,我给出了一个苹果的示例价格为 $1.75。我还给出了两个定价方案的例子:三只苹果价格 $4.00 和两只苹果价格 $2.50。使用这些数据,假设我们想要确定分别购买至少一个苹果和至少四个苹果的最低价格。以下是此测试用例的输入:
1.75 2
3 4.00
2 2.50
1 4
输出
对于每个测试用例,输出如下:
-
一行包含
案例c:,其中 c 是从 1 开始的测试用例编号。 -
对于每个整数 k,一行包含
购买k个苹果,价格 $*d*,其中 d 是购买至少 k 个苹果的最低价格。
以下是上面示例输入的输出:
Case 1:
Buy 1 for $1.75
Buy 4 for $5.00
解决测试用例的时间限制是三秒。
描述最优解
问题描述指定我们需要尽可能便宜地购买至少 k个苹果。这意味着购买恰好k个苹果只是一个选项;如果购买更多苹果更便宜,我们也可以购买超过k个苹果。我们将从尝试解决恰好k个苹果的问题开始,就像我们在《汉堡热潮》中解决恰好t分钟的问题一样。当时,我们在必要时找到了从恰好t分钟转到更少分钟数的方法。希望我们在这里能做类似的事情,从k个苹果开始,找到k、k + 1、k + 2 等的最便宜成本。如果没坏的话……
在仅仅回顾本章标题并急于深入记忆化和动态规划之前,让我们确认我们是否真的需要这些工具。
哪个更好:以总价$4.00 购买三个苹果(方案 1),还是以总价$2.50 购买两个苹果(方案 2)?我们可以通过计算每个定价方案的单个苹果成本来尝试回答这个问题。在方案 1 中,我们有$4.00/3 = 每个苹果$1.33,而在方案 2 中我们有$2.50/2 = 每个苹果$1.25。看起来方案 2 比方案 1 更好。假设我们还能以$1.75 购买一个苹果,这看起来比这两个方案更差。我们因此得到从最便宜到最贵的单个苹果成本依次为:$1.25,$1.33,$1.75。
现在,假设我们想购买恰好 k个苹果。这个算法怎么样:每一步都使用最便宜的单个苹果成本,直到我们买了k个苹果?
如果我们想买正好四个苹果,那么我们应该从方案 2 开始,因为它让我们以最优的单个苹果价格购买苹果。使用方案 2 一次,我们花费$2.50 买两个苹果,然后剩下两个苹果需要购买。我们可以再使用方案 2,购买另外两个苹果(现在一共四个苹果),再花费$2.50。我们总共花费了$5.00 买四个苹果,确实我们无法做得更好。
请注意,仅仅因为一个算法直观或者在一个测试案例中有效,并不意味着它在一般情况下是正确的。这个使用最优单个苹果价格的算法是有缺陷的,并且有测试案例证明了这一点。试着在继续之前找出这样的测试案例!
这里有一个问题:假设我们只想买三个苹果,而不是四个。我们再次从方案 2 开始,以$2.50 购买两个苹果。现在我们只剩下一个苹果要买——唯一的选择是支付$1.75 购买这个苹果。总花费是$4.25——但其实有更好的方法。也就是说,我们本来应该使用方案 1 一次,花费$4.00:是的,它的单个苹果成本高于方案 2,但通过避免支付一个更贵的苹果,弥补了这一点。
很容易开始给我们的算法附加额外的规则,试图修复它;例如,“如果有一个定价方案正好适合我们需要的苹果数量,那么就使用它。”但是,假设我们想买正好三个苹果。通过添加一个方案,其中商店以 100.00 美元出售三个苹果,我们就能轻松破坏这个增强版的算法。
在使用备忘录法和动态规划时,我们会尝试所有可用的选项来寻找最优解,然后选择最佳的那个。在《汉堡狂潮》中,霍默是否以m分钟汉堡或n分钟汉堡结束?我们不知道,所以我们尝试两者。相比之下,贪心算法是一种只尝试一个选项的算法:当时看起来是最佳选择的那个。
像上面那样使用每个苹果的最佳价格就是一种贪心算法的例子,因为它在每一步都选择不考虑其他选项的动作。有时,贪心算法是有效的。而且,由于它们通常比动态规划算法运行得更快,且更容易实现,一个有效的贪心算法可能比一个有效的动态规划算法更好。对于这个问题,看起来贪心算法——无论是上面提到的还是其他可能想到的——都不够强大。
在《汉堡狂潮》中,我们推理出,如果吃汉堡花了t分钟,那么最优解中的最后一个汉堡必须是m分钟汉堡或n分钟汉堡。对于当前的问题,我们想说类似的话:购买k个苹果的最优解必须以某种有限的方式结束。这里有一个声明:如果可用的定价方案是方案 1、方案 2,……,方案m,那么我们做的最后一件事必须是使用这m个定价方案中的一个。没有其他的选择,对吧?
好吧,这并不完全正确。在一个最优解中,我们最终做的事情可能是买一个苹果。我们总是可以选择这一选项。与《汉堡狂潮》中解决两个子问题不同,我们解决的是m + 1 个子问题:每一个价格方案一个子问题,还有一个是买一个苹果的问题。
假设购买k个苹果的最优解以支付p美元购买n个苹果为结束。我们需要以最优方式购买k – n个苹果,并将该成本加到p中。我们需要确认,购买k个苹果的整体最优解包含了购买k – n个苹果的最优解。这就是备忘录法和动态规划的最优子结构要求。像《汉堡狂潮》一样,最优子结构确实成立。如果一个k的解没有使用k – n的最优解,那么这个k的解最终不能是最优的:它不如我们基于k – n的最优解得到的解。
当然,我们不知道在解决方案的最后应该做什么来使其最优。是使用方案 1,方案 2,方案 3,还是只买一个苹果?谁知道呢?和任何记忆化或动态规划算法一样,我们只需要尝试所有的方案,然后选择最优的一个。
在我们查看递归解法之前,请注意,对于任何数字k,我们总能找到一种方法买到正好k个苹果。不论是一个苹果,两个苹果,还是五个苹果,或者其他数量,我们都可以买到。原因在于我们始终可以选择买一个苹果,而且可以买任意多次。与《汉堡狂热》中的情况相比,那里有些t值使得t分钟无法用现有的汉堡填充。由于这种差异,在这里我们不需要担心递归调用在更小的子问题上找不到解决方案的情况。
解法 1:递归
和《汉堡狂热》一样,首先要做的是编写一个辅助函数,来解决特定数量苹果的问题。
辅助函数:求解苹果数量
让我们编写 solve_k 函数,其功能类似于我们为《汉堡狂热》编写的 solve_t 函数。函数签名如下:
double solve_k(int num[], double price[], int num_schemes,
double unit_price, int num_apples)
除最后一个参数外,其他这些参数直接来自当前的测试用例。以下是每个参数的作用:
num 一个数组,表示每种定价方案对应的苹果数量。例如,如果我们有两个定价方案,第一个是三颗苹果,第二个是两颗苹果,那么这个数组就是 [3, 2]。
price 一个数组,表示每个定价方案的价格。例如,如果我们有两个定价方案,第一个价格为 4.00,第二个价格为 2.50,那么这个数组就是 [4.00, 2.50]。注意,num 和 price 一起提供了关于定价方案的所有信息。
num_schemes 定价方案的数量。它是测试用例中的 m 值。
unit_price 一个苹果的价格。
num_apples 我们要购买的苹果数量。
solve_k 函数返回购买正好 num_apples 个苹果的最小花费。
solve_k 的代码在 Listing 3-9 中给出。除了单独研究这段代码,我强烈建议你将它与《汉堡狂热》中的 solve_t 函数进行比较 (Listing 3-1)。你注意到了哪些不同之处?为什么会有这些差异?记忆化和动态规划解法共享一个共同的代码结构。如果我们能掌握这个结构,就能专注于每个问题的差异性。
❶ double min(double v1, double v2) {
if (v1 < v2)
return v1;
else
return v2;
}
double solve_k(int num[], double price[], int num_schemes,
double unit_price, int num_apples) {
double best, result;
int i;
➋ if (num_apples == 0)
➌ return 0;
else {
➍ result = solve_k(num, price, num_schemes, unit_price,
num_apples - 1);
➎ best = result + unit_price;
for (i = 0; i < num_schemes; i++)
➏ if (num_apples - num[i] >= 0) {
❼ result = solve_k(num, price, num_schemes, unit_price,
num_apples - num[i]);
❽ best = min(best, result + price[i]);
}
return best;
}
}
Listing 3-9:求解正好 num_apples 个苹果的最小花费
我们从一个小小的min函数 ❶开始:我们需要它来比较不同的解决方案并选择更小的一个。在《汉堡狂潮》中,我们使用了类似的max函数,因为我们想要最大数量的汉堡。这里,我们想要最小的成本。有些优化问题是最大化问题(如《汉堡狂潮》),而另一些是最小化问题(如《钱袋贪婪》)——仔细阅读问题陈述,确保你朝着正确的方向进行优化!
如果我们被要求求解0个苹果 ➋该怎么办?我们返回0 ➌,因为购买零个苹果的最低费用正好是$0.00。这是一个基本情况,就像在《汉堡狂潮》中填写零分钟一样。与递归一样,任何优化问题至少需要一个基本情况。
如果我们不在基本情况中,那么num_apples将是一个正整数,我们需要找到购买恰好这么多苹果的最优方式。变量best用于跟踪到目前为止找到的最优(最小成本)选项。
一种选择是最优地解决购买num_apples - 1个苹果的问题 ➍,并加上最后一个苹果的费用 ➎。
现在我们遇到了这个问题与《汉堡狂潮》之间的一个大结构性差异:递归函数内部的循环。在《汉堡狂潮》中,我们不需要循环,因为我们只有两个子问题要尝试。我们只需尝试第一个子问题,然后尝试第二个。而在这里,我们每个定价方案对应一个子问题,我们必须逐个处理它们。我们检查当前定价方案是否可以使用 ➏:如果它的苹果数量不大于我们需要的数量,那么我们可以尝试。我们递归调用来解决由此定价方案所产生的子问题 ❼。(这与之前递归调用时减去一个苹果 ➍ 类似。)如果该子问题的解决方案加上当前方案的价格是我们目前为止找到的最佳选项,那么我们就相应地更新best ❽。
求解函数
我们已经最优地解决了购买k个苹果的问题,但问题陈述中有一个细节我们还没有处理:“你想要至少买* k个苹果,并且尽可能便宜。”为什么“至少k个苹果”与“恰好k个苹果”之间的差异重要呢?你能找到一个测试用例,在这个用例中,买超过k个苹果反而比买恰好k*个苹果便宜吗?
给你出一个题。假设一个苹果的价格是$1.75。我们有两种定价方案:方案 1 是四个苹果$3.00,方案 2 是两个苹果$2.00。现在,我们想要买至少三个苹果。这个测试用例的输入形式如下:
1.75 2
4 3.00
2 2.00
3
购买三个苹果的最便宜方式是花费$3.75:一个苹果$1.75,另外两个苹果使用方案 2 花费$2.00。但是,我们可以通过实际购买四个苹果而不是三个来花更少的钱。购买四个苹果的最便宜方式是使用方案 1 一次,费用仅为$3.00。因此,这个测试用例的正确输出是:
Case 1:
Buy 3 for $3.00
(这个输出有点混乱,因为我们实际上买了四个苹果,而不是三个,但输出Buy 3是正确的。我们始终输出要求购买的苹果数量,无论我们是否购买更多的苹果以节省费用。)
我们需要的是一个像在清单 3-2 中对汉堡狂热问题中使用的solve函数那样的函数。在那里,我们通过尝试越来越小的值直到找到解决方案。而这里,我们将尝试越来越大的值,并在过程中跟踪最小的解决方案。以下是代码的初步尝试:
double solve(int num[], double price[], int num_schemes,
double unit_price, int num_apples) {
double best;
int i;
❶ best = solve_k(num, price, num_schemes,
unit_price, num_apples);
➋ for (i = num_apples + 1; i < ???; i++)
best = min(best, solve_k(num, price, num_schemes,
unit_price, i));
return best;
}
我们将best初始化为购买准确num_apples个苹果的最优解决方案❶。然后,我们使用for循环尝试越来越多的苹果数量➋。for循环会在...呃,等一下。我们怎么知道什么时候可以安全停止?也许我们被要求购买 3 个苹果,但最便宜的方式是购买 4 个、5 个、10 个甚至 20 个苹果。在《汉堡狂热》中我们没有这个问题,因为我们是在向下推进,朝零的方向,而不是向上。
我们可以从问题的输入规范中找到一个节省游戏的观察:它说每个定价方案中的苹果数量最多为 100 个。这个信息如何帮助我们呢?
假设我们被要求至少购买 50 个苹果。那么,购买 60 个苹果是不是最好的选择?当然!也许对于 60 个苹果的最优解决方案中,最终定价方案是 20 个苹果。然后,我们可以将这 20 个苹果与 40 个苹果的最优解决方案结合起来,得到总共 60 个苹果。
假设我们再次购买 50 个苹果。购买准确的 180 个苹果有意义吗?好吧,想想购买 180 个苹果的最佳解决方案。我们使用的最终定价方案最多能买 100 个苹果。在使用这个最终定价方案之前,我们至少已经买了 80 个苹果,而且比买 180 个苹果时更便宜。关键是,80 个苹果仍然大于 50 个!因此,购买 80 个苹果比购买 180 个苹果更便宜。如果我们至少想要 50 个苹果,那么购买 180 个苹果不是最优选择。
实际上,对于 50 个苹果,我们应该考虑购买的最大苹果数量是 149 个。如果我们购买 150 个或更多苹果,那么去掉最终定价方案就能找到更便宜的方式来购买 50 个或更多苹果。
问题的输入规范不仅限制了每个定价方案中苹果的数量为 100,还限制了要购买的苹果总数为 100。在要求购买 100 个苹果的情况下,我们应该考虑购买的最大苹果数量是 100 + 99 = 199 个。考虑到这一观察,最终得到了清单 3-10 中的solve函数。
#define SIZE 200
double solve(int num[], double price[], int num_schemes,
double unit_price, int num_apples) {
double best;
int i;
best = solve_k(num, price, num_schemes, unit_price, num_apples);
for (i = num_apples + 1; i < SIZE; i++)
best = min(best, solve_k(num, price, num_schemes,
unit_price, i));
return best;
}
清单 3-10:解决方案 1
现在我们只需要一个main函数,就可以开始提交给评测系统了。
主函数
让我们写一个main函数吧。请参见示例 3-11。它并不是完全独立的,但我们只需要一个辅助函数get_number,我会在下面详细描述。
#define MAX_SCHEMES 20
int main(void) {
int test_case, num_schemes, num_apples, more, i;
double unit_price, result;
int num[MAX_SCHEMES];
double price[MAX_SCHEMES];
test_case = 0;
❶ while (scanf("%lf%d ", &unit_price, &num_schemes) != -1) {
test_case++;
for (i = 0; i < num_schemes; i++)
➋ scanf("%d%lf ", &num[i], &price[i]);
printf("Case %d:\n", test_case);
more = get_number(&num_apples);
➌ while (more) {
result = solve(num, price, num_schemes, unit_price, num_apples);
printf("Buy %d for $%.2f\n", num_apples, result);
more = get_number(&num_apples);
}
➍ result = solve(num, price, num_schemes, unit_price, num_apples);
printf("Buy %d for $%.2f\n", num_apples, result);
}
return 0;
}
示例 3-11:主函数
我们首先使用scanf尝试从输入中读取下一个测试用例的第一行❶。接下来的scanf调用➋位于一个嵌套循环中,它读取每个定价方案的苹果数量和价格。
请注意,每个scanf格式字符串的末尾都有一个空格,这确保了我们总是位于每行的开始位置。这一点非常重要,尤其是当我们到达要求购买苹果数量的那一行时,因为我们将使用一个辅助函数,该函数假设我们在行的开始位置。
为什么我们需要一个辅助函数呢?嗯,我们不能随便继续调用scanf来读取这些苹果数量,因为我们需要能够在遇到换行符时停止。这就是为什么我们使用我的get_number辅助函数,它的具体描述在下面。它返回1,如果还有更多的数字要读取,返回0,如果这是行中的最后一个数字。我们在解决测试用例的循环中调用这个函数➌。我们还需要在循环下方添加一些代码➍:当循环因读取到最后一个数字而终止时,我们仍然需要解决那个最后的测试用例。
get_number的代码见示例 3-12。
int get_number(int *num) {
int ch;
int ret = 0;
ch = getchar();
❶ while (ch != ' ' && ch != '\n') {
ret = ret * 10 + ch - '0';
ch = getchar();
}
➋ *num = ret;
➌ return ch == ' ';
}
示例 3-12:获取整数的函数
这个函数使用一种类似于示例 2-17 的方法来读取一个整数值。只要我们还没有遇到空格或换行符❶,循环就会继续。当循环终止时,我们将读取到的值存储到传递给此函数调用的指针参数中➋。我们使用这个指针参数,而不是返回值,因为返回值有另一个作用:表示这是否是行中的最后一个数字➌。也就是说,如果get_number返回1(因为它读取的数字后面有一个空格),则表示该行上还有更多数字;如果返回0,则表示这是该行的最后一个数字。
现在我们有了一个完整的解决方案,但它的性能非常慢。即使是看似小的测试用例也会花费很长时间,因为无论如何我们都会处理高达 299 个苹果的情况。
哎呀,我们来对这个东西进行记忆化吧。
解决方案 2:记忆化
我们在solve中引入了memo数组(见示例 3-5),用于对“汉堡狂热”进行记忆化处理。之所以这样做,是因为每次调用solve都是针对一个独立的测试用例。然而,在“贪婪者”问题中,我们有一行代码,每个整数指定了要购买的苹果数量,我们需要解决每个问题。在完全解决测试用例之前,丢弃memo数组是非常浪费的!
因此,我们将在 main 函数中声明并初始化 memo。更新后的 main 函数在列表 3-13 中。
int main(void) {
int test_case, num_schemes, num_apples, more, i;
double unit_price, result;
int num[MAX_SCHEMES];
double price[MAX_SCHEMES];
❶ double memo[SIZE];
test_case = 0;
while (scanf("%lf%d ", &unit_price, &num_schemes) != -1) {
test_case++;
for (i = 0; i < num_schemes; i++)
scanf("%d%lf ", &num[i], &price[i]);
printf("Case %d:\n", test_case);
➋ for (i = 0; i < SIZE; i++)
➌ memo[i] = -1;
more = get_number(&num_apples);
while (more) {
result = solve(num, price, num_schemes, unit_price, num_apples, memo);
printf("Buy %d for $%.2f\n", num_apples, result);
more = get_number(&num_apples);
}
result = solve(num, price, num_schemes, unit_price, num_apples, memo);
printf("Buy %d for $%.2f\n", num_apples, result);
}
return 0;
}
列表 3-13:主函数,通过实现记忆化
我们声明 memo 数组 ❶,并将 memo 中的每个元素初始化为 -1(表示“未知”) ➋ ➌。注意,memo 的初始化仅在每个测试用例中进行一次。唯一的其他变化是我们将 memo 作为新参数传递给 solve 函数的调用。
solve 函数的新代码在列表 3-14 中给出。
double solve(int num[], double price[], int num_schemes,
double unit_price, int num_apples, double memo[]) {
double best;
int i;
best = solve_k(num, price, num_schemes, unit_price, num_apples, memo);
for (i = num_apples + 1; i < SIZE; i++)
best = min(best, solve_k(num, price, num_schemes, unit_price, i, memo));
return best;
}
列表 3-14:解决方案 2,通过实现记忆化
除了在参数列表的末尾添加 memo 作为一个新参数外,我们还将 memo 传递给 solve_k 函数的调用。就这些。
最后,来看看将 solve_k 函数进行记忆化所需的修改。我们将在 memo[num_apples] 中存储购买精确 num_apples 个苹果的最小费用。参见列表 3-15。
double solve_k(int num[], double price[], int num_schemes,
double unit_price, int num_apples, double memo[]) {
double best, result;
int i;
❶ if (memo[num_apples] != -1)
return memo[num_apples];
if (num_apples == 0) {
memo[num_apples] = 0;
return memo[num_apples];
} else {
result = solve_k(num, price, num_schemes, unit_price,
num_apples - 1, memo);
best = result + unit_price;
for (i = 0; i < num_schemes; i++)
if (num_apples - num[i] >= 0) {
result = solve_k(num, price, num_schemes, unit_price,
num_apples - num[i], memo);
best = min(best, result + price[i]);
}
memo[num_apples] = best;
return memo[num_apples];
}
}
列表 3-15:通过实现记忆化解决精确购买 num_apples 个苹果的问题
请记住,当使用记忆化解决问题时,我们首先要检查解是否已经知道 ❶。如果 num_apples 子问题的值存储了除了 -1 以外的任何值,我们就返回它。否则,像任何记忆化函数一样,在返回之前,我们会将新的子问题解存储在 memo 中。
我们现在达到了这个问题的自然结束点:这个记忆化的解决方案可以提交给评测系统,并且应该能通过所有测试用例。如果你想进一步练习动态规划,这里有一个完美的机会,可以将这个记忆化解决方案转换为动态规划的解决方案!否则,我们就先搁置这个问题。
问题 3:冰球竞争
我们前面解决的两个问题使用了一维的 memo 或 dp 数组。现在,让我们看看一个需要使用二维数组的情况。
我住在加拿大,所以我想我们在读这本书的时候不能不提冰球。冰球是一项团队运动,就像足球一样……只是有进球。
这是 DMOJ 问题 cco18p1。
问题描述
鹅队进行了 n 场比赛,每场比赛有两种结果之一:鹅队获胜(W)或鹅队失利(L)。(没有平局比赛。) 对于他们的每一场比赛,我们知道他们是赢了还是输了,也知道他们进了多少球。例如,我们可能知道他们的第一场比赛获胜(W),并且他们在这场比赛中打进了四个球。(他们的对手,无论是谁,肯定输了并且进球数少于四个。)鹰队也进行了 n 场比赛,同样每场比赛有鹰队获胜(W)或鹰队失利(L)。同样,对于他们的每场比赛,我们知道他们是赢了还是输了,也知道他们进了多少球。
这些队伍所参加的部分比赛可能是相互之间的比赛,但也有其他队伍,部分比赛可能是与这些其他队伍进行的。
我们没有关于谁与谁对阵的信息。我们可能知道鹅队赢得了一场比赛,并且在那场比赛中他们进了四个球,但我们不知道他们的对手是谁——他们的对手可能是鹰队,也可能是其他队伍。
对抗性比赛是指鹅队和鹰队对阵的比赛。
我们的任务是确定在对抗性比赛中可能打入的最大进球数。
输入
输入包含一个测试用例,相关信息分布在以下五行中:
-
第一行包含n,每个队伍参加的比赛数。n的范围是 1 到 1,000 之间。
-
第二行包含一个长度为n的字符串,其中每个字符是
W(胜利)或L(失败)。这一行告诉我们鹅队每场比赛的结果。例如,WLL表示鹅队赢得了第一场比赛,输了第二场比赛,输了第三场比赛。 -
第三行包含n个整数,表示鹅队每场比赛中打入的进球数。例如,
4 1 2表示鹅队在第一场比赛中进了四个球,在第二场比赛中进了一个球,在第三场比赛中进了两个球。 -
第四行与第二行类似,但它告诉我们每场比赛中鹰队的比赛结果。
-
第五行与第三行类似,但它告诉我们每场比赛中鹰队的进球数。
输出
输出是一个整数:可能的对抗性比赛中打入的最大进球数。
解决测试用例的时间限制是 0.6 秒。
关于对抗性比赛
在跳到最优解的结构之前,让我们通过一些测试用例来确保我们完全理解所问的内容。
我们从这一行开始:
3
WWW
2 5 1
WWW
5 7 8
这里根本不可能有任何对抗性比赛。对抗性比赛,和任何比赛一样,要求一个队伍赢,另一个队伍输——但鹅队赢得了所有比赛,鹰队也赢得了所有比赛,所以鹅队和鹰队不可能互相对阵。由于没有可能的对抗性比赛,因此没有在对抗性比赛中打入的进球数。正确的输出是0。
现在让我们假设鹰队输掉了所有比赛:
3
WWW
2 5 1
LLL
5 7 8
现在有对抗性比赛吗?答案仍然是否定的!鹅队以两球获胜了他们的第一场比赛。要使这场比赛成为对抗性比赛,必须是鹰队输掉的那场比赛,并且鹰队在那场比赛中进球少于两球。然而,鹰队进球最少的是五个,因此这些比赛不能成为与鹅队第一场比赛的对抗性比赛。同样,鹅队以五个进球赢得了他们的第二场比赛,但鹰队没有输掉过进球数为四个或更少的比赛。也就是说,鹅队的第二场比赛不能成为对抗性比赛。类似的分析表明,鹅队的第三场比赛也不能成为对抗性比赛。再次地,0是正确的输出。
让我们跳过这些零情况。来看一个:
3
WWW
2 5 1
LLL
4 7 8
我们已经修改了雄鹰队的第一场比赛,使得他们打进四球而不是五球,这足以产生一场可能的对抗赛!具体来说,大雁队的第二场比赛(大雁队赢了并打进了五球)可能是与雄鹰队的第一场比赛(雄鹰队输了并打进了四球)的对抗赛。那场比赛打进了九个进球。由于我们不能再包含其他的对抗赛,也不能再向总进球数中添加任何进球。所以,这里的正确输出是9。
现在考虑这个:
2
WW
6 2
LL
8 1
看看每支队伍最后一场比赛的结果:大雁队赢了并打进了两球,而雄鹰队输掉了比赛并打进了一球。那可能是一场对抗赛,总共有三球。每支队伍的第一场比赛不可能是对抗赛(大雁队以六球获胜,而雄鹰队不可能以八球输掉同一场比赛),所以我们不能再添加更多的进球。3是正确的输出吗?
不是!我们选择了错误的配对,匹配了那些最后的比赛。我们应该做的是将大雁队的第一场比赛与雄鹰队的第二场比赛匹配。这可能是一次对抗赛,并且这场比赛打进了七个进球。这次我们做对了:正确的输出是7。
让我们再看一个例子。在读我的答案之前,尝试自己找出最大总进球数:
4
WLWW
3 4 1 8
WLLL
5 1 2 3
正确的输出是20,这是通过两场对抗赛得到的:第二场大雁队比赛与第一场雄鹰队比赛(那场比赛进了 9 球),以及第四场大雁队比赛与第四场雄鹰队比赛(那场比赛进了 11 球)。
你是否预测正确的输出是25?如果是的话,我们不允许将大雁队的第一场比赛与雄鹰队的第三场比赛配对,称之为x。每支队伍的比赛都是按顺序进行的,因此,如果我们使用x作为对抗赛,那么就不能将大雁队的第二场比赛(在x之后进行)与雄鹰队的第一场比赛(在x之前进行)配对。
表征最优解
考虑一下这个问题的最优解:一个最大化对抗赛中进球数的解决方案。这种最优解可能是什么样的呢?假设每支队伍的比赛从 1 到n编号。
选项 1
一种选择是,最优解使用大雁队和雄鹰队的最后一场比赛作为对抗赛。那场比赛有一定数量的进球:称之为g。然后,我们可以去掉这两场比赛,分别在大雁队前n–1 场和雄鹰队前n–1 场比赛中优化地解决更小的子问题。这个子问题的解,加上g,就是总体的最优解。不过,需要注意的是,这种选择只有在这两场n比赛真的是对抗赛的情况下才能使用。例如,如果两队在那场比赛中都有W(胜利),那么这场比赛就不能算作对抗赛,选项 1 就不适用了。
还记得上一节中的这个测试用例吗?
4
WLWW
3 4 1 8
WLLL
5 1 2 3
这是选项 1 的一个例子:我们匹配最右边的两个得分,8和3,然后最优地解决其余比赛的子问题。
选项 2
另一个选项是最优解与这些最终的比赛完全无关。在这种情况下,我们去掉鹅队的比赛n和鹰队的比赛n,并在鹅队的前n - 1 场比赛和鹰队的前n - 1 场比赛上最优解子问题。
这里是上一节的一个测试案例,展示了选项 2:
3
WWW
2 5 1
LLL
4 7 8
右侧的1和8不是最优解的一部分。其他比赛的最优解是整体最优解。
到目前为止,我们已经涵盖了使用两个比赛n的得分的情况和没有使用任何比赛n得分的情况。我们完成了吗?
为了证明我们没有完成,考虑一下来自上一节的这个测试案例:
2
WW
6 2
LL
8 1
选项 1,通过匹配2和1,导致竞争比赛中的最大进球数为三。选项 2,通过去掉2和1,导致竞争比赛中的最大进球数为零。然而,这里的整体最大值是七。因此,仅使用选项 1 和选项 2 来覆盖最优解类型是不完整的。
我们在这里需要能够做的是从鹅队的比赛中去掉一场,但不去掉鹰队的比赛。具体来说,我们希望去掉鹅队的第二场比赛,然后解决由鹅队的第一场比赛和两场鹰队比赛组成的子问题。为了对称性,我们也应该能够去掉鹰队的第二场比赛,并解决由鹰队的第一场比赛和鹅队的两场比赛组成的子问题。让我们把这两个额外的选项加进去。
选项 3
我们的第三个选项是最优解与鹅队的比赛n无关。在这种情况下,我们去掉鹅队的比赛n,并在鹅队的前n - 1 场比赛和鹰队的前n场比赛上最优解子问题。
选项 4
我们的第四个也是最后一个选项是最优解与鹰队的比赛n无关。在这种情况下,我们去掉鹰队的比赛n,并在鹅队的前n场比赛和鹰队的前n - 1 场比赛上最优解子问题。
选项 3 和选项 4 会引起问题解决方案结构的变化——无论该解决方案是使用递归、记忆化还是动态规划。在本章之前的问题中,我们的子问题只由一个变化的参数来表征:t 代表汉堡狂热问题中的时间,k 代表钱贪问题中的苹果数量。如果没有选项 3 和选项 4,我们也能用单一的参数 n 来解决冰球竞争问题。这个 n 参数反映的是我们正在解决鹅队前 n 场比赛和鹰队前 n 场比赛的子问题。然而,加入选项 3 和选项 4 后,这些 n 值不再绑定在一起:其中一个可以改变,而另一个不受影响。例如,如果我们正在解决与鹅队前五场比赛相关的子问题,这并不意味着我们必须考虑鹰队前五场比赛。同理,涉及鹰队前五场比赛的子问题也不会影响我们对于鹅队比赛场次的了解。
因此,我们需要两个参数来处理我们的子问题:i,代表鹅队比赛的场次数,和 j,代表鹰队比赛的场次数。
对于给定的优化问题,子问题的参数数量可能是一个、两个、三个或更多。当遇到一个新问题时,我建议从一个子问题参数开始。然后,思考可能的最佳解决方案选项。也许每个选项可以通过解决一个参数的子问题来解决,这种情况下就不需要额外的参数。然而,有时候会遇到一个或多个选项需要解决一个无法通过单一参数来确定的子问题。在这种情况下,第二个参数通常会有所帮助。
增加额外子问题参数的好处在于它提供了更大的子问题空间来解决我们的优化问题。代价则是需要解决更多的子问题。保持参数数量小——一个、两个,或者最多三个——是设计快速优化问题解决方案的关键。
在继续之前,我想强调一个重要的区别:我们解决汉堡狂热问题和钱贪问题的方式与我们解决冰球竞争问题的方式不同。在前两个问题中,我们专注于解决恰好 t 分钟或者恰好 k 个苹果的情况。而在这里,相反,我们并没有强制要求我们的子问题使用特定的比赛。例如,涉及鹅队前 i 场比赛的子问题,并不强制要求使用鹅队的第 i 场比赛。这个区别是通过分析最优解的结构时产生的副产品。每次我们使用动态规划时,我们都需要选择是否使用“恰好”。如果我们选择在冰球竞争问题中使用“恰好”,那么最终的代码将会更慢、更复杂。我已经把那段代码包含在了本书的在线资源中——等你完成这部分后可以去看看!
如果你在将一个问题分解为更小的子问题时遇到困难,或者在有效解决子问题时遇到困难,不妨尝试添加或移除“exactly”并再试一次。我们将在下一章继续练习识别子问题。
解决方案 1:递归
现在是时候进入我们的递归解决方案了。以下是我们这次将编写的solve函数的签名:
int solve(char outcome1[], char outcome2[], int goals1[],
int goals2[], int i, int j)
前四个参数直接来自当前的测试用例,而第五和第六个是子问题的参数。以下是参数的简要描述:
outcome1 鹅队的W和L字符数组
outcome2 鹰队的W和L字符数组
goals1 鹅队进球数的数组
goals2 鹰队进球数的数组
i 我们在这个子问题中考虑的鹅队比赛数量
j 我们在这个子问题中考虑的鹰队比赛数量
最后两个参数——特定于当前子问题的参数——是递归调用时唯一会改变的参数。
如果我们将每个数组的起始索引设置为0,就像 C 语言数组的标准做法一样,我们必须记住,某场比赛k的信息不在索引k上,而是在索引k - 1上。例如,关于第四场比赛的信息将位于索引3。为了避免这种情况,我们将从索引1开始存储比赛信息,这样关于第四场比赛的信息就会位于索引4。这样我们就能减少一个错误!此外,这样做还使得0这个值可以用来表示零场比赛。
递归解决方案的代码见列表 3-16。
❶ int max(int v1, int v2) {
if (v1 > v2)
return v1;
else
return v2;
}
int solve(char outcome1[], char outcome2[], int goals1[],
int goals2[], int i, int j) {
➋ int first, second, third, fourth;
➌ if (i == 0 || j == 0)
return 0;
➍ if ((outcome1[i] == 'W' && outcome2[j] == 'L' &&
goals1[i] > goals2[j]) ||
(outcome1[i] == 'L' && outcome2[j] == 'W' &&
goals1[i] < goals2[j]))
➎ first = solve(outcome1, outcome2, goals1, goals2, i - 1, j - 1) +
goals1[i] + goals2[j];
else
first = 0;
➏ second = solve(outcome1, outcome2, goals1, goals2, i - 1, j - 1);
❼ third = solve(outcome1, outcome2, goals1, goals2, i - 1, j);
❽ fourth = solve(outcome1, outcome2, goals1, goals2, i, j - 1);
❾ return max(first, max(second, max(third, fourth)));
}
列表 3-16:解决方案 1
这是一个最大化问题:我们希望最大化对抗赛中进球的数量。我们从一个max函数❶开始——我们将在需要确定最佳选项时使用它。然后我们声明四个整数变量,每个选项一个➋。
让我们从基本情况开始:如果i和j都为0,我们应该返回什么?这个子问题是关于前零场鹅队比赛和零场鹰队比赛的。由于没有比赛,当然也没有对抗赛,而既然没有对抗赛,就没有进球。因此,我们应该返回0。
但这并不是唯一的基本情况。例如,考虑鹅队打零场比赛(i = 0),而鹰队打三场比赛(j = 3)的子问题。与前面提到的情况一样,这里也不可能有对抗赛,因为鹅队没有比赛!当鹰队打零场比赛时也会出现类似的情况:即使鹅队打了一些比赛,也没有任何一场是与鹰队对阵的。
这涵盖了所有基本情况。也就是说,如果i的值为0 或者 j的值为0,那么我们在对抗赛中进球的数量为零➌。
基本情况解决后,我们现在必须尝试四种可能的最优解,并选择最佳的一个。
选项 1
请记住,只有当最后一场大雁比赛和最后一场雄鹰比赛是对抗赛时,此选项才有效。使这场比赛成为对抗赛有两种方式:
-
大雁获胜,雄鹰失利,且大雁的进球数多于雄鹰。
-
大雁失利,雄鹰获胜,且大雁的进球数少于雄鹰。
我们编码这两种可能性 ➍。如果比赛可以是对抗赛,我们计算此选项的最优解 ➎:它由前i - 1场大雁比赛和前j - 1场雄鹰比赛的最优解加上对抗赛中的总进球数构成。
选项 2
对于这个情况,我们解决子问题,考虑前i - 1场大雁比赛和前j - 1场雄鹰比赛➏。
选项 3
在这里,我们解决子问题,考虑前i - 1场大雁比赛和前j场雄鹰比赛 ❼。注意,i改变了,但j没有改变。这正是为什么我们需要两个子问题参数,而不是一个。
选项 4
我们解决子问题,考虑前i场大雁比赛和前j - 1场雄鹰比赛 ❽。再次强调,一个子问题参数改变了,而另一个没有;幸运的是,我们不需要将它们保持在相同的值!
完成了:first、second、third和fourth——这四个是我们最优解的唯一四种可能性。我们希望得到这四个中的最大值,这就是我们计算并返回的 ❾。最内层的max调用计算third和fourth的最大值。向外推进,接下来的max调用计算这个赢家与second的最大值。最后,最外层的调用计算这个赢家与first的最大值。
我们快完成了。现在我们只需要一个main函数来读取五行输入并调用solve函数。代码见 Listing 3-17。
#define SIZE 1000
int main(void) {
int i, n, result;
❶ char outcome1[SIZE + 1], outcome2[SIZE + 1];
➋ int goals1[SIZE + 1], goals2[SIZE + 1];
➌ scanf("%d ", &n);
for (i = 1; i <= n; i++)
scanf("%c", &outcome1[i]);
for (i = 1; i <= n; i++)
scanf("%d ", &goals1[i]);
for (i = 1; i <= n; i++)
scanf("%c", &outcome2[i]);
for (i = 1; i <= n; i++)
scanf("%d", &goals2[i]);
result = solve(outcome1, outcome2, goals1, goals2, n, n);
printf("%d\n", result);
return 0;
}
Listing 3-17: 主函数
我们声明结果(W和L)❶以及进球数数组➋。这里的+ 1是因为我们选择从1开始索引。如果我们只使用SIZE,那么有效的索引会从 0 到 999,而我们需要的是包括索引 1,000。
然后我们读取第一行的整数 ➌,它给出大雁和雄鹰比赛的场次。%d之后和引号之前有一个空格。这个空格使得scanf读取整数后的空白字符。关键是,这样会读取到行尾的换行符,否则我们使用scanf读取单个字符时,换行符会被包含进来……接下来我们就会这样做!
我们读取大雁的W和L信息,然后读取大雁的进球数信息。接着我们对雄鹰做同样的操作。最后,我们调用solve。我们希望在考虑所有n场大雁比赛和所有n场雄鹰比赛的情况下解决问题,这也是为什么最后两个参数是n的原因。我们知道如何一次性调用solve来得到答案;不同于我们对汉堡狂热和钱贪心问题的解法,我们不需要进行搜索。
你有可能将这个解决方案提交给评测系统吗?“超时”错误应该不奇怪。
解决方案 2:备忘录
在《汉堡狂热》和《钱袋族》中,我们为备忘录使用了一维数组。那是因为我们的子问题只有一个参数:分别是分钟数和苹果数。相比之下,《冰球对决》中的子问题有两个参数,而不是一个。我们因此需要一个二维的备忘录数组,而不是一维的。元素 memo[i][j] 用来保存前 i 场 Geese 比赛和前 j 场 Hawks 比赛的子问题解决方案。除了将备忘录从一维切换为二维外,技术保持不变:如果解决方案已存储,则返回;如果未存储,则计算并存储它。
更新后的 main 函数见于清单 3-18。
int main(void) {
int i, j, n, result;
char outcome1[SIZE + 1], outcome2[SIZE + 1];
int goals1[SIZE + 1], goals2[SIZE + 1];
static int memo[SIZE + 1][SIZE + 1];
scanf("%d ", &n);
for (i = 1; i <= n; i++)
scanf("%c", &outcome1[i]);
for (i = 1; i <= n; i++)
scanf("%d ", &goals1[i]);
for (i = 1; i <= n; i++)
scanf("%c", &outcome2[i]);
for (i = 1; i <= n; i++)
scanf("%d", &goals2[i]);
for (i = 0; i <= SIZE; i++)
for (j = 0; j <= SIZE; j++)
memo[i][j] = -1;
result = solve(outcome1, outcome2, goals1, goals2, n, n, memo);
printf("%d\n", result);
return 0;
}
清单 3-18:主函数,已实现备忘录
请注意,memo 数组非常庞大——每个维度中有超过 1,000 个元素,因此总共有超过 100 万个元素——所以我们像在清单 1-8 中一样将数组声明为静态。
备忘录的 solve 函数见于清单 3-19。
int solve(char outcome1[], char outcome2[], int goals1[],
int goals2[], int i, int j, int memo[SIZE + 1][SIZE + 1]) {
int first, second, third, fourth;
if (memo[i][j] != -1)
return memo[i][j];
if (i == 0 || j == 0) {
memo[i][j] = 0;
return memo[i][j];
}
if ((outcome1[i] == 'W' && outcome2[j] == 'L' &&
goals1[i] > goals2[j]) ||
(outcome1[i] == 'L' && outcome2[j] == 'W' &&
goals1[i] < goals2[j]))
first = solve(outcome1, outcome2, goals1, goals2, i - 1, j - 1, memo) +
goals1[i] + goals2[j];
else
first = 0;
second = solve(outcome1, outcome2, goals1, goals2, i - 1, j - 1, memo);
third = solve(outcome1, outcome2, goals1, goals2, i - 1, j, memo);
fourth = solve(outcome1, outcome2, goals1, goals2, i, j - 1, memo);
memo[i][j] = max(first, max(second, max(third, fourth)));
return memo[i][j];
}
清单 3-19:解决方案 2,已实现备忘录
这个解决方案通过了所有测试用例,并且运行速度很快。如果我们只是想解决这个问题,我们现在可以停止,但在这里我们有机会进一步探索并在此过程中深入学习动态规划。
解决方案 3:动态规划
我们刚刚看到,为了备忘录化这个问题,我们需要一个二维的 memo 数组,而不是一维数组。为了开发一个动态规划的解决方案,我们相应地需要一个二维的 dp 数组。我们在清单 3-18 中声明了 memo 数组如下:
static int memo[SIZE + 1][SIZE + 1];
我们也将对 dp 数组进行同样的操作:
static int dp[SIZE + 1][SIZE + 1];
和 memo 数组一样,元素 dp[i][j] 将保存前 i 场 Geese 比赛和前 j 场 Hawks 比赛的子问题解决方案。那么我们的任务就是解决这些子问题,并在完成后返回 dp[n][n]。
在针对优化问题的备忘录解决方案中,确定解决子问题的顺序不是我们的责任。我们发起递归调用,然后这些调用会返回其对应子问题的解决方案。然而,在动态规划解决方案中,确定解决子问题的顺序是我们的责任。我们不能随意解决子问题,因为那样某些子问题的解决方案在需要时可能无法获取。
例如,假设我们想填写 dp[3][5]——那是第一场 Geese 比赛和前五场 Hawks 比赛的单元格。再回头看看四个选项,看看哪一个是最优解。
-
选项 1 要求我们查找
dp[2][4]。 -
选项 2 也要求我们查找
dp[2][4]。 -
选项 3 要求我们查找
dp[2][5]。 -
选项 4 要求我们查找
dp[3][4]。
我们必须安排这些dp元素,以便在我们想要存储dp[3][5]时,它们已经被存储。
对于只有一个参数的子问题,通常从最小索引到最大索引解决这些子问题。对于有多个参数的子问题,情况就不那么简单了,因为有很多种填充数组的顺序。只有一些顺序能保证在我们需要时,子问题的解已经可用。
对于冰球对抗问题,如果我们已经存储了dp[i - 1][j - 1](选项 1 和选项 2),dp[i - 1][j](选项 3)和dp[i][j - 1](选项 4),那么我们就可以解决dp[i][j]。一种我们可以使用的顺序是,先解决所有的dp[i - 1]子问题,再解决任何dp[i]子问题。例如,这样会使得dp[2][4]在dp[3][5]之前被解决,这正是我们需要的,以满足选项 1 和选项 2。同时,这也会使得dp[2][5]在dp[3][5]之前被解决,这正是我们需要的,以满足选项 3。也就是说,在解决第i行之前先解决第i - 1行满足了选项 1 到选项 3。
为了满足选项 4,我们可以从最小的j索引到最大的j索引解决dp[i]子问题。例如,这将先解决dp[3][4],再解决dp[3][5]。
总结来说,我们首先从左到右解决第0行的所有子问题,然后从左到右解决第1行的所有子问题,以此类推,直到解决完第n行的所有子问题。
solve函数见清单 3-20。
int solve(char outcome1[], char outcome2[], int goals1[],
int goals2[], int n) {
int i, j;
int first, second, third, fourth;
static int dp[SIZE + 1][SIZE + 1];
for (i = 0; i <= n; i++)
dp[0][i] = 0;
for (i = 0; i <= n; i++)
dp[i][0] = 0;
❶ for (i = 1; i <= n; i++)
➋ for (j = 1; j <= n; j++) {
if ((outcome1[i] == 'W' && outcome2[j] == 'L' &&
goals1[i] > goals2[j]) ||
(outcome1[i] == 'L' && outcome2[j] == 'W' &&
goals1[i] < goals2[j]))
first = dp[i - 1][j - 1] + goals1[i] + goals2[j];
else
first = 0;
second = dp[i - 1][j - 1];
third = dp[i - 1][j];
fourth = dp[i][j - 1];
dp[i][j] = max(first, max(second, max(third, fourth)));
}
➌ return dp[n][n];
}
清单 3-20:解决方案 3,使用动态规划
我们首先初始化基础情况的子问题,这些子问题是至少有一个索引为0的子问题。然后,我们进入双重for循环 ❶ ➋,控制非基础情况子问题的解决顺序。我们首先遍历行 ❶,然后遍历每行中的元素 ➋,正如我们所论述的,这是一个有效的子问题解决顺序。一旦我们填充了表格,我们就返回原问题的解决方案 ➌。
我们在这里解决的是n²个子问题,每个子问题需要的步骤数是常数。因此,我们已经实现了一个 O(n²) 的解决方案。
我们可以将二维动态规划算法产生的数组可视化为一个表格。这有助于我们理解数组元素是如何被填充的。让我们来看一下以下测试用例的最终数组:
4
WLWW
3 4 1 8
WLLL
5 1 2 3
这是得到的数组:

例如,考虑计算第4行第2列的元素,或者从dp表的角度看,就是dp[4][2]。这是前四场鹅队比赛和前两场鹰队比赛的子问题。查看鹅队的第四场比赛和鹰队的第二场比赛,我们看到鹅队以 8 个进球获胜,鹰队以 1 个进球失利,因此这场比赛可能是一场竞争比赛。所以选项 1 是一个可能的选项。这场比赛打了 9 个进球。然后,我们将第3行第1列的值加到这 9 个进球上,得到的还是 9。这给我们总共 18 个进球。这是目前为止的最大值——现在我们必须尝试选项 2 到选项 4,看看它们是否更好。如果你这么做,你会发现它们的值恰好都是 9。因此,我们将 18,所有可用选项中的最大值,存储在dp[4][2]中。
这里唯一真正关心的量,当然,是位于最顶端、最右侧单元格中的数值,对应的子问题是允许“鹅队”进行n场比赛,以及“鹰队”进行n场比赛。这个数值 20 就是我们返回的最优解。表格中的其他数值只是帮助我们推进计算该 20 的过程。
在main函数中,我们对清单 3-17 中的代码做了一个小改动:唯一需要做的就是移除传递给solve的第二个n,结果如下:
result = solve(outcome1, outcome2, goals1, goals2, n);
空间优化
我在“第 4 步:动态规划”一节中提到过,记忆化和动态规划大致上是等效的。大致上,因为有时选择其中一个方法会带来一些好处。冰球竞争问题提供了一个典型的优化示例,这是我们在使用动态规划时可以执行的优化,但在使用记忆化时无法做到。这个优化不是速度上的,而是空间上的。
这里是关键问题:在解决dp数组第i行的子问题时,我们访问的是哪几行?回顾四个选项,唯一使用的行是i - 1(上一行)和i(当前行)。没有i - 2、i - 3或其他任何行。因此,保持整个二维数组在内存中是浪费的。假设我们正在解决第 500 行的子问题。我们只需要访问第 500 行和第 499 行。我们完全可以不保留第 498 行、第 497 行或其他任何行在内存中,因为我们再也不会用到它们。
我们可以不使用二维表格,而仅通过两个一维数组来完成:一个用于上一行,一个用于当前正在解决的行。
清单 3-21 实现了这个优化。
int solve(char outcome1[], char outcome2[], int goals1[],
int goals2[], int n) {
int i, j, k;
int first, second, third, fourth;
static int previous[SIZE + 1], current[SIZE + 1];
❶ for (i = 0; i <= n; i++)
➋ previous[i] = 0;
for (i = 1; i <= n; i++) {
for (j = 1; j <= n; j++) {
if ((outcome1[i] == 'W' && outcome2[j] == 'L' &&
goals1[i] > goals2[j]) ||
(outcome1[i] == 'L' && outcome2[j] == 'W' &&
goals1[i] < goals2[j]))
first = previous[j - 1] + goals1[i] + goals2[j];
else
first = 0;
second = previous[j - 1];
third = previous[j];
fourth = current[j - 1];
current[j] = max(first, max(second, max(third, fourth)));
}
➌ for (k = 0; k <= SIZE; k++)
➍ previous[k] = current[k];
}
return current[n];
}
清单 3-21:实现空间优化的解法 3
我们将previous初始化为全零❶ ➋,从而解决了第0行的所有子问题。在代码的其余部分,每当我们之前提到第i - 1行时,我们现在使用previous。此外,每当我们之前提到第i行时,我们现在使用current。一旦新的一行完全解决并存储在current中,我们将current复制到previous ➌ ➍,以便current可以用于解决下一行。
摘要
我已经展示了我认为是备忘录化和动态规划的核心内容:阐明最优解的结构,开发递归算法,通过备忘录化加速它,并可选择通过填表来替代递归。
然而,还有更多的内容值得学习。如果我向你展示如何仅通过改变视角来破解一些棘手的动态规划问题,你会感兴趣吗?如果我告诉你,我们可以通过三维或更多维度来解决最困难的动态规划问题呢?
在下一章中,你将学习这些内容以及更多。到时见!
注释
《冰球竞争》最初来源于 2018 年加拿大计算机奥林匹克竞赛。
有时候,你不仅需要知道最优解的值,还需要知道为了达到这个解应该做出的决策。查看附录 B 中的“汉堡狂热:重建解决方案”部分,了解如何做到这一点的示例。
许多算法教材深入探讨了备忘录化和动态规划的理论与应用。我最喜欢的教材是 Jon Kleinberg 和Éva Tardos 的《算法设计》(2006 年版)。
第四章:高级记忆化与动态规划

在本章中,我们将继续讨论记忆化与动态规划。你不需要阅读本章内容就能继续阅读本书。但如果你希望深入理解,还是可以从中学到更多内容。我们将看到如何通过改变视角使动态规划问题变得更容易,如何在子问题数组中处理超过二维的问题,并将技能拓展到超越我们至今所见的“优化解法”问题。我们还将进一步练习基础知识。通过本章,你将成为动态规划的高手。
问题 1:跳跃者
本章我们将从一个可以通过我们在第三章学到的内容解决的动态规划问题开始。和第三章一样,我们能够通过关注最优解的结尾来解决问题。不过,我们会发现这并不是唯一的解法。具体来说,我们将看到,我们可以选择不关注最优解的结尾,而是关注它的开头。你可能会觉得这种第二种方法比第一种方法更直观,如果不是对这个问题,那么也许对其他问题来说更合适。一旦你学会了这种视角的转变,你将拥有两种方法来解决下一个动态规划问题。
这是 DMOJ 问题crci07p2。
问题
尼古拉正在玩一个由 n 个方格组成的游戏。最左边的方格是方格 1,最右边的方格是方格 n。尼古拉从方格 1 开始,想要到达方格 n。为了实现这一目标,她需要进行一次或多次跳跃。她的第一次跳跃必须是从方格 1 跳到方格 2。之后,跳跃规则如下:
-
尼古拉可以跳跃到右侧,跳跃的方格数比上一次跳跃多 1。例如,如果尼古拉上次跳了 3 个方格,那么她这次可以向右跳 4 个方格。
-
尼古拉可以跳跃到左侧,跳跃的方格数与上次跳跃相同。例如,如果尼古拉上次跳了 3 个方格,那么她这次可以向左跳 3 个方格。
我将使用跳跃距离一词来指代某次跳跃中移动的方格数。
这里有效的方格是从 1 到 n 的方格。因此,如果一次跳跃将尼古拉带到方格 1 左侧或方格 n 右侧,那么该跳跃是不允许的。
每个方格都有进入成本。每当尼古拉跳跃时,她需要支付她落脚的方格的进入成本。
我们想要确定尼古拉从方格 1 跳跃到方格 n 的最小总成本。
输入
输入包含以下几行:
-
一行包含 n,表示行中方格的数量。n 的值在 2 到 1,000 之间。
-
n 行,每行给出一个方格的进入成本。这些行的第一行是方格 1 的进入成本,第二行是方格 2 的进入成本,以此类推。每个进入成本是一个介于 1 到 500 之间的整数。
输出
输出尼古拉从第 1 格到第n格的最小总成本。
解决测试用例的时间限制是 0.6 秒。
通过一个例子来推导
让我们通过一个测试用例来确保我们清楚自己需要做什么。这里是:
7
3
5
1
9
7
2
3
尼古拉从第 1 格开始,必须到达第 7 格。记住,第一次跳跃必须跳到第 2 格,以下是一条可能的路线:
第 1 格到第 2 格
成本 5。
最近的跳跃距离现在是 1。
第 2 格到第 4 格
成本 9。
最近的跳跃距离现在是 2。
第 4 格到第 7 格
成本 3。
最近的跳跃距离现在是 3。
我们到达了第 7 格!总成本是 5 + 9 + 3 = 17。然而,这不是最小的总成本。试着在继续之前找到最小的成本。
这是我们如何得到最小总成本的方法:
第 1 格到第 2 格
成本 5。
最近的跳跃距离现在是 1。
第 2 格到第 1 格
成本 3。
最近的跳跃距离保持为 1。
第 1 格到第 3 格
成本 1。
最近的跳跃距离现在是 2。
第 3 格到第 6 格
成本 2。
最近的跳跃距离现在是 3。
第 6 格到第 3 格
成本 1。
最近的跳跃距离保持为 3。
第 3 格到第 7 格
成本 3。
最近的跳跃距离现在是 4。
这次的总成本是 5 + 3 + 1 + 2 + 1 + 3 = 15。
解法 1:逆向推导
在我们编写任何代码之前,我们需要确定子问题,并了解如何利用这些子问题来表征最优解的结构。
寻找子问题
我们需要多少个子问题参数?我们能只用一个吗?
如果我们只有一个子问题参数,那么我们可以用它来跟踪尼古拉在哪一格。但那样的话,我们怎么知道给定子问题中哪些跳跃是允许的呢?想想从第 1 格到第 4 格的最优解结束时的情况。为了更接近基本情况,我们需要知道尼古拉在第 4 格之前在哪一格,这样我们才能递归调用到那个较早的格子。例如,如果我们知道尼古拉用了 2 的跳跃距离到达第 4 格,那么我们就知道她在到达第 4 格之前肯定是在第 2 格或第 6 格。但我们并不知道尼古拉使用的跳跃距离——它不是我们的子问题参数之一。这行不通。
我们再试一次,仅使用一个子问题参数。如果我们用它来跟踪最近的跳跃距离呢?那么我们就没有一个子问题参数来告诉我们尼古拉在哪一格!没有知道尼古拉在哪一格,我们就无法知道何时到达基本情况格。
看起来我们需要两个子问题参数:一个告诉我们尼古拉在哪一格,另一个告诉我们她跳到那一格所用的跳跃距离。
对于这些参数中的每一个,我们需要决定是否使用“确切”。在第三章中,我们看到了在解决《汉堡狂热》和《贪婪者》时使用“确切”子问题的例子。我们还看到了在解决《冰球对抗赛》时没有使用“确切”的例子。如果我们在《冰球对抗赛》中使用了“确切”,我们的子问题就会强制特定的比赛被匹配为对抗赛,这在那个问题中是不需要的。
在这里,知道尼古拉究竟在哪个方格是有意义的。我们可以利用这一点来确定她在最近一次跳跃之前究竟在哪个方格……嗯,不完全是。我们还需要知道她用来跳到当前方格的确切跳跃距离。然后我们可以通过当前方格和跳跃距离来精确推算出尼古拉一定是从哪个地方来的。也就是说,我们需要对这两个子问题的参数都要求“确切”。
问题描述规定,第一次跳跃没有选择:必须从方格 1 跳到方格 2。我们不需要担心在子问题中维护这个条件,而是直接忽略它:我们的子问题将告诉我们从方格 2 跳到另一个方格的最小费用。稍后,我们会加上从方格 1 到方格 2 的跳跃费用,这样就能得到最终解答。
然而,我们不能随便使用从方格 2 跳跃的任意顺序。想象一下,如果我们从方格 2 的解答开始,跳跃距离是 3 会发生什么?我们需要先从方格 1 跳到方格 2,哦哦,跳跃距离为 3 是不允许紧随其后的!我们需要确保我们的子问题只求解那些可行的解,因为我们会将从方格 1 到方格 2 的跳跃放在前面。我们称这样的解答为可连接的。
好的,现在我们准备好解决子问题了!具有i和j参数的子问题将告诉我们,从方格 2 到确切的方格i,使用最终跳跃距离为j的可连接解答的最小费用。
这个子问题的定义相当复杂。让我们用上一节中的测试用例来确定几个关于定义如何工作的例子。
当i = 7,j = 3 时,子问题的解是什么?这在问我们,从方格 2 开始,最终到达方格 7,并且最后的跳跃距离是 3 的最佳可连接解答是什么?答案是 12,尼古拉从方格 2 跳到方格 4(费用 9),再跳到方格 7(费用 3)。(记住,在这些子问题中,我们忽略了从方格 1 到方格 2 的跳跃费用。)
那么,i = 7,j = 4 的子问题解答是什么?答案是 10:尼古拉可以先从方格 2 跳到方格 1(费用 3),再跳到方格 3(费用 1),跳到方格 6(费用 2),再跳回方格 3(费用 1),最后跳到方格 7(费用 3)。
那么i = 7,j = 2 呢?试试,你应该会发现这是不可能的:没有办法找到一个从方格 2 跳到方格 7,并且最终跳跃距离为 2 的可连接解答。
再来一个:i = 2 和 j = 1。我们已经处在方格 2 了。此外,j = 1 意味着我们需要一个距离为 1 的跳跃才能到达方格 2。没问题:我们将在前面加上的从方格 1 到方格 2 的跳跃正是这种跳跃!因此,这里的答案是 0。嗯,我想我们可能刚刚找到一个基础情况。
描述最优解
考虑一个具有某个值i和某个值j的子问题。这个子问题的最优解可能是什么样子的?
选项 1
一个选项是这个最优解以跳向右边结束。j值给出了这个跳跃的距离,所以我们知道这个跳跃一定是从方格i – j跳过来的。为了向右跳跃j,尼古拉必须已经通过跳跃距离j – 1 到达了她之前的方格。(为什么是j – 1?因为向右跳跃需要的跳跃距离比前一次跳跃多 1。)因此,这个选项的解决方案是方格i – j和跳跃距离j – 1 的子问题的解决方案,再加上方格i的进入成本。
哎呀,像这样倒着思考解决方案可能会让人困惑!例如,我们正在讨论跳到右边,但我们却使用了一个位于左边的方格i – j。但记住:我们在寻找尼古拉从哪个方格跳过来的,所以如果她从那个方格跳到右边,那么她确实是从一个编号较小的方格跳过来的。
选项 2
我们的第二个选择是这个最优解以跳向左边结束。j值给出了这个跳跃的距离,所以我们知道这个跳跃一定是从方格i + j跳过来的。为了向左跳跃j,尼古拉必须已经通过跳跃距离j到达了她之前的方格。所以这个选项的解决方案是方格i + j和跳跃距离j的子问题的解决方案,再加上方格i的进入成本。
解决一个子问题
我们跳过裸递归解决方案,改为处理一个带备忘录的解决方案。为此,我们将从编写以下solve_ij函数签名的代码开始:
int solve_ij(int cost[], int n, int i, int j,
int memo[SIZE + 1][SIZE + 1])
这个函数解决给定的i和j值的子问题。以下是每个参数的作用:
cost 进入成本数组。方格 1 的进入成本是cost[1],方格 2 的进入成本是cost[2],以此类推。
n 行中的方格数。
i 这个子问题的结束方格。
j 这个子问题的最终跳跃距离。
memo 备忘录数组。
这个函数的代码在列表 4-1 中给出。
#define SIZE 1000
int min(int v1, int v2) {
if (v1 < v2)
return v1;
else
return v2;
}
int solve_ij(int cost[], int n, int i, int j, int memo[SIZE + 1][SIZE + 1]) {
int first, second;
❶ if (memo[i][j] != -2)
return memo[i][j];
➋ if (i == 2 && j == 1) {
memo[i][j] = 0;
return memo[i][j];
}
➌ if (i - j >= 1 && j >= 2)
first = solve_ij(cost, n, i - j, j - 1, memo);
else
first = -1;
➍ if (i + j <= n)
second = solve_ij(cost, n, i + j, j, memo);
else
second = -1;
➎ if (first == -1 && second == -1) {
memo[i][j] = -1;
return memo[i][j];
} else if (second == -1) {
memo[i][j] = first + cost[i];
return memo[i][j];
} else if (first == -1) {
memo[i][j] = second + cost[i];
return memo[i][j];
} else {
memo[i][j] = min(first, second) + cost[i];
return memo[i][j];
}
}
列表 4-1:解决一个子问题
正如我们在第三章中解决“汉堡狂热”时所做的那样,我们在memo数组中使用-2 ➊来表示一个子问题尚未解决。这里使用的典型值是-1,但我们将使用-1来表示我们已经尝试解决一个子问题,但它没有解决方案。
正如我们在上一节中发现的,一个重要的基础情况是,当我们在方块 2 上,需要用距离为 1 的跳跃到达该方块 ➋。在这种情况下,我们返回0:从方块 2 到方块 2 没有任何成本!
接下来,我们尝试两种选项以找到最佳解决方案。我们使用first变量来保存选项 1 的值,second变量来保存选项 2 的值。
选项 1
这是尼古拉跳到右侧以到达方块i的选项。为了使用此选项,我们需要确保尼古拉来自一个有效的方块。我们还需要确保j至少为2 ➌,这可以保证她是通过至少 1 的跳跃距离到达前一个方块的。毕竟,说尼古拉用 0 的跳跃距离到达一个先前的方块是没有意义的!
选项 2
这是尼古拉跳到左侧以到达方块i的选项。与之前的选项一样,我们需要确保尼古拉来自一个有效的方块 ➍。
一个或两个选项可能无法找到解决方案;在这种情况下,我们使用-1的值。如果两个选项都失败,那么我们返回-1 ➎。其余的代码决定哪个选项最好,并返回该选项加上cost[i]作为解决方案。
最优解在哪里?
现在我们可以解决任何我们想要的子问题。那么,我们应该调用哪个solve_ij来找到原始问题的解决方案呢?
难题:一次调用不够!我们需要做一些搜索,就像我们在第三章解决《汉堡热潮》和《钱袋贼》问题时一样。但为什么呢?
我们知道我们必须精确地停在方块n上,所以我们不需要在这里做搜索。我们不知道的是我们应该用什么跳跃距离作为最后一跳。我们应该以 2 的跳跃距离结束吗?3?4?谁知道?我们需要尝试所有并选择最好的一个。有关代码,请参见列表 4-2。
int solve(int cost[], int n) {
int i, j, best, result;
❶ static int memo[SIZE + 1][SIZE + 1];
for (i = 1; i <= SIZE; i++)
for (j = 1; j <= SIZE; j++)
memo[i][j] = -2;
➋ best = -1;
➌ for (j = 1; j <= n; j++) {
result = solve_ij(cost, n, n, j, memo);
if (result != -1) {
if (best == -1)
➍ best = cost[2] + result;
else
➎ best = min(best, cost[2] + result);
}
}
return best;
}
列表 4-2:解决方案 1
这个函数是我们设置memo数组 ➊ 的地方,以便它在所有对solve_ij的调用之间共享。
best变量保存我们迄今为止找到的最佳解决方案。它从-1 ➋ 开始,每当我们找到更好的解决方案时,就会更新 ➍ ➎。小心:我们需要在这里添加cost[2],以重新计算从方块 1 到方块 2 的跳跃。
注意,我们正在循环遍历1到n之间的最终跳跃距离 ➌。没有必要尝试任何大于n的最终距离,因为它们无法到达有效的方块。也就是说,我们保证找到最佳的最终跳跃距离,因为我们正在尝试所有有效的可能性。
主要函数
是时候总结这个解决方案了。我们只需要一个main函数来读取输入并调用solve。代码见列表 4-3。
int main(void) {
int i, n;
int cost[SIZE + 1];
scanf("%d ", &n);
for (i = 1; i <= n; i++)
scanf("%d", &cost[i]);
printf("%d\n", solve(cost, n));
return 0;
}
列表 4-3: 主要 函数
如果你将我们的代码提交给评审,你应该能够在时间限制内通过所有测试用例。
但我们仍然可以做得更好。
解决方案 2:前向公式化
我们先来谈谈为什么解决方案 1 很快,然后再讨论为什么我们仍然可以改进它。
解决方案 1 有多快?
解决方案 1 是一个 O( n²) 算法。从自底向上的动态规划解法中,我们可以更容易看到这一点,里面有明显的两个嵌套循环,但我们仍然可以使用备忘录解法来说明这一点。我们的 solve_ij 函数(见 Listing 4-1)最多填充 memo 中的每个 n² 元素一次。为了填充每个元素,我们的代码会做两次递归调用。因此,我们可以说我们的代码最多做 2n² 次递归调用。除此之外,solve_ij 的每次递归调用会执行一个常数步骤来决定是使用 first 还是 second 作为最优解的一部分。因此,solve_ij 在所有递归调用中都需要 O( n²) 的时间。
一排格子的最大数量是 1,000。我们的 O( n²) 算法因此大致执行了 1,000² = 1,000,000 步。在 0.6 秒的时间限制内我们可以轻松完成。
好的,那么解决方案 1 很快。那么我们还可以改进什么呢?
反向 vs. 正向
也许你一直被问题描述中尼古拉的跳跃方式与我们在备忘录解法中思考跳跃的方式之间的不一致所困扰。问题描述告诉我们尼古拉从第一跳开始每次跳跃时能做什么。也就是说,它关注的是尼古拉现在在哪里以及她接下来能做什么。相比之下,我们的备忘录解法关注的是尼古拉到达了哪里,以及她是如何到达那里的。例如,我们不是想着“尼古拉从第 2 格跳到第 4 格”,而是要想着“尼古拉通过从第 2 格跳跃到第 4 格达到了第 4 格”。我们必须从问题的反向思考!
除了这些额外的思维挑战之外,我们的反向表达方式还对我们需要编写的代码量产生了影响。还记得我们在 Listing 4-2 中需要搜索所有可能的最终跳跃距离吗?之所以需要这样做,是因为我们不知道最优解是如何结束的。也许它以跳跃距离 2 结束,或者以跳跃距离 3 结束,或者以跳跃距离 4 结束,依此类推。但我们确实知道它是如何开始的:从第 1 格到第 2 格,跳跃距离是 1!只要我们跟踪最近的跳跃距离,我们就永远不需要猜测它可能是什么。也就是说,如果我们从正向解决问题(关注我们现在的位置),而不是从反向解决问题(关注我们最终的位置),我们将能够完全避免跳跃距离的搜索。我们将能够到达最优解的终点,正好需要我们想要的最终跳跃距离。
再次寻找子问题
我们将坚持使用之前的两个子问题参数的思路。因为我们需要一个参数告诉我们尼古拉所在的格子,另一个参数告诉我们她用来到达该格子的跳跃距离。
这一次,参数 i 和 j 的子问题将告诉我们从方格 i 到方格 n 的最小成本解,前提是方格 i 是通过跳跃距离正好为 j 到达的。
我们需要一些例子来说明!我们将再次使用在 第 127 页 中的“通过实例演示”的测试用例。
让我们从子问题 i = 2 和 j = 1 开始。这是在询问我们从方格 2 出发,最终到达方格 7,并且通过跳跃距离 1 到达方格 2 的最佳解。(请注意,我们完全不关心最终带我们到方格 n 的跳跃距离,它可以是任何值!)这里的答案是 10:尼古拉可以从方格 2 跳到方格 1(花费 3),然后跳到方格 3(花费 1),再跳到方格 6(花费 2),然后跳到方格 3(花费 1),最后跳到方格 7(花费 3)。嘿,这和从反向推导得到的 i = 7, j = 4 子问题的答案是一样的!但是在反向推导中,我们必须尝试所有可能的最终跳跃距离,以确认这是最优的,而现在不再需要了。
现在考虑任何 i = n 的子问题。答案是什么?是 0:我们已经在方格 n 上了!我们不关心 j 是什么,因为我们不在乎是如何到达方格 n 的。我们又找到了基础情况。
再次描述最优解
我们需要重新做一下优化子结构的工作,参见解决方案 1。
选项 1
另一种选择是尼古拉接着向右跳跃。j 值给出了当前方格的跳跃距离。向右跳跃的距离是 j + 1,因此尼古拉将着陆在方格 i + j + 1。此选项的解决方案是方格 i + j + 1 的进入成本,再加上方格 i + j + 1 和跳跃距离 j + 1 的子问题的解。
选项 2
我们的第二种选择是尼古拉接着向左跳跃。向左跳跃的距离是 j,因此尼古拉将着陆在方格 i–j。因此,此选项的解决方案是方格 i–j 的进入成本,再加上方格 i–j 和跳跃距离 j 的子问题的解。
求解所需的子问题
现在我们可以编写一个 solve 函数来解决我们选择的子问题。查看 列表 4-4 中的代码。
int solve(int cost[], int n, int i, int j,
int memo[SIZE + 1][SIZE + 1]) {
int first, second;
if (memo[i][j] != -2)
return memo[i][j];
if (i == n) {
memo[i][j] = 0;
return memo[i][j];
}
❶ if (i + j + 1 <= n)
first = solve(cost, n, i + j + 1, j + 1, memo);
else
first = -1;
➋ if (i - j >= 1)
second = solve(cost, n, i - j, j, memo);
else
second = -1;
if (first == -1 && second == -1) {
memo[i][j] = -1;
return memo[i][j];
} else if (second == -1) {
memo[i][j] = cost[i + j + 1] + first;
return memo[i][j];
} else if (first == -1) {
memo[i][j] = cost[i - j] + second;
return memo[i][j];
} else {
memo[i][j] = min(cost[i + j + 1] + first, cost[i - j] + second);
return memo[i][j];
}
}
列表 4-4:解决方案 2
如同在反向推导中(参见 列表 4-1),我们需要实现最优解的两种选项。在每种情况下,我们首先检查下一个方格是否是有效的方格 ❶ ➋,然后再考虑跳跃。
主函数
为了找到原问题的解,我们应该调用 solve 函数吗?
这次不是一个技巧性问题——我们完全知道需要解决哪个子问题!我们需要解决 i = 2(从方格 2 开始)和 j = 1(通过跳跃距离 1 到达方格 2)的子问题。我们可以在读取输入的 main 函数中包含这一部分。查看 列表 4-5 中的代码。
int main(void) {
int i, j, n, result;
int cost[SIZE + 1];
static int memo[SIZE + 1][SIZE + 1];
scanf("%d ", &n);
for (i = 1; i <= n; i++)
scanf("%d", &cost[i]);
for (i = 1; i <= SIZE; i++)
for (j = 1; j <= SIZE; j++)
memo[i][j] = -2;
result = cost[2] + solve(cost, n, 2, 1, memo);
printf("%d\n", result);
return 0;
}
清单 4-5: 主 函数
对于这个问题,我更喜欢使用前向解法,但你也可以选择反向解法——这是个人的选择!
当我在做备忘录法或动态规划解法时,我总是从尝试制定反向解法开始。反向解法对我来说通常更自然,因为它更贴近我对递归的思考方式。然而,如果我卡住太久,或者当问题的前向描述与反向表述之间产生摩擦时,我会尝试使用前向解法。把这两种互补的方法都放在你的动态规划工具箱里!
动态规划怎么回事?
我们已经有了一个非常好的前向备忘录解法。尽管如此,你可能还是想开发一个动态规划解法。如果你这么做,里面会有一些惊喜等着你!
想回我们如何在第三章中使用动态规划解决《冰球对抗》。在第 119 页的“解决方案 3:动态规划”中,我们学到了解决子问题的顺序。在《冰球对抗》中,我们通过从左到右解决第 0 行的所有子问题,再从左到右解决第 1 行的所有子问题,依此类推,来确定了解决子问题的顺序。
但这个顺序现在对我们不起作用。
为了解决dp[i][j],我们需要什么?从选项 1 来看,我们需要已经解决了dp[i + j + 1][j + 1];也就是说,我们需要来自更高编号列的某些内容。从选项 2 来看,我们需要已经解决了dp[i - j][j];也就是说,我们需要来自当前列较低编号行的某些内容。综合来看,我们需要先解决第n列的所有子问题,然后解决第n-1列的所有子问题,依此类推。在每一列内部,我们需要先解决第 1 行的子问题,然后是第 2 行,依此类推。
这是我们可以用来按正确顺序解决子问题的双重for循环:
for (j = n; j >= 1; j--)
for (i = 1; i < n; i++) {
code to fill in dp[i][j]
}
解决子问题时,永远要小心顺序!
如果你想看到完整的动态规划解法,请查看本书的在线资源。否则,我们准备继续在这里解决另一个动态规划问题。一个真正棘手的题目,你已经准备好迎接它了。
问题 2:构建方式
到目前为止,我们解决的四个动态规划问题(在第三章中的三个和本章中的一个)要求我们最大化(《汉堡热情》和《冰球对抗》)或最小化(《钱财贪婪者》和《跳跃者》)解的值。我想以一个稍有不同口味的问题结束本章:我们不再寻找最佳解,而是计算可能解的数量。
另一个出现的差异是我们子问题数组的维度数。在前四个问题中,我们使用了一维或二维数组来存储子问题的解。为了这个问题,我们需要更多的维度。
这是 DMOJ 问题noip15p5。
问题
我们给定了一个源字符串 a,一个目标字符串 b,以及一个整数 k。我们希望通过将 a 的 k 个子字符串拼接在一起,来构建 b。
我们需要遵循一些规则:
-
我们必须按照它们在 a 中出现的顺序来使用 a 的子字符串。
-
我们不允许使用任何空子字符串——每个子字符串必须至少包含一个字符。
-
我们不允许使用重叠的子字符串:如果我们在一个子字符串中使用了 a 中的某个字符,那么我们不能在其他子字符串中再次使用该字符。
-
我们必须使用 a 中恰好 k 个子字符串来构建 b。
例如,假设 a 是 xxyzxyz,b 是 xxyz,k 是 3。一种通过拼接 a 的三个子字符串来构建 b 的方法是从 a 开头取 x,然后取右边的 xy,最后从 a 末尾取 z。将这些子字符串拼接在一起,我们得到了 xxyz,正好是我们需要的字符串 b。
现在,从 a 中构建 b 的方法可能有很多种。但要注意,只要我们遵守规则,构建 b 的方式并不重要——每种方式都是一样好的。因此,讨论构建 b 的最佳方式没有意义。那么,我们要在本问题中解决的,并不是构建 b 的最佳方式,而是可以构建 b 的总方式数量。在上面的示例中,有 11 种方法。你能找到它们全部吗?
输入
输入包含一个测试用例,其信息分布在以下三行中:
-
第一行包含三个数字:字符串 a 的长度,字符串 b 的长度,以及整数 k,它表示我们必须从 a 中取出 k 个子字符串来构建 b。字符串 a 的长度在 1 到 1,000 之间,字符串 b 的长度在 1 到 200 之间,k 的值在 0 到 200 之间。
-
第二行包含字符串 a。
-
第三行包含字符串 b。
输出
输出我们可以使用 a 中恰好 k 个子字符串来构建 b 的方式数量。
这个数字可能非常大。为了避免整数溢出的情况,问题要求我们输出这个数字对 1,000,000,007 取模后的结果。
解决该测试用例的时间限制是两秒。
通过一个示例进行演示
你能找到我在问题描述中给出的示例中构建 b 的所有 11 种方式吗?让我们确保你找到了所有方式,因为我们将以这个作为本问题的示例来进行分析。
这里是以测试用例形式给出的示例:
7 4 3
xxyzxyz
xxyz
方便参考,以下是字符串 a 中字符的索引:

如何使用恰好三个子字符串从 xxyzxyz 构建出 xxyz 呢?在下面,我将用索引范围表示每个子字符串;例如,0-0 指的是 a 中索引为 0 的字符组成的子字符串,而 1-2 指的是 a 中索引为 1 和 2 的字符组成的子字符串。
以下是五种做法:
-
0-1 (
xx),5-5 (y),6-6 (z) -
1-1 (
x),4-5 (xy),6-6 (z) -
0-0 (
x), 4-5 (xy), 6-6 (z) -
0-1 (
xx), 2-2 (y), 6-6 (z) -
0-0 (
x), 1-2 (xy), 6-6 (z)
注意,在这些情况下,我们的最终子字符串是来自 a 的最后一个字符 z。也就是说,我们已经将最终的子字符串固定为那个 z,然后从 xxyzxy 中寻找构建 xxy 的方式。我们开始看到通过解决更小的子问题来找到初始问题的解决方法。
还有六种可能的方式。我们把它们列出来:
-
1-1 (
x), 4-4 (x), 5-6 (yz) -
0-0 (
x), 4-4 (x), 5-6 (yz) -
0-0 (
x), 1-1 (x), 5-6 (yz) -
0-1 (
xx), 2-2 (y), 3-3 (z) -
0-0 (
x), 1-2 (xy), 3-3 (z) -
0-0 (
x), 1-1 (x), 2-3 (yz)
加上之前的五种方式,我们现在一共有 5 + 6 = 11 种解决方案。
解法 1:使用“准确的”子问题
我们需要识别出我们的子问题,然后利用它们找到所有最优解。我们将尝试采用反向公式。
寻找子问题
这次我们需要多少个子问题参数?
我们需要跟踪我们在字符串 a 中的位置,并且需要跟踪我们在字符串 b 中的位置。这个是一个不错的开始,但还不够。我们还需要一个子问题参数来告诉我们还需要提取多少个子字符串。这是我们第一次使用三个子问题参数!
在本章前面我们解决“跳跃者”问题时,使用了“准确的”子问题参数,效果不错。像往常一样,我们需要决定是否对每个子问题参数使用这个方法。对于记录还需要提取多少个子字符串的参数,使用“准确的”是有意义的,因为我们需要正好提取这么多个子字符串(而不是例如提取最多这么多的子字符串)。对于那些记录我们在 a 和 b 中当前所在位置的子问题参数,使用“准确的”就不那么明确了。我们就先对这些参数也使用“准确的”,然后看看结果如何。接下来,我们将使用符号 s[i..j] 来表示从字符串 s 的索引 i 到索引 j(包括 j)的字符。
具有参数 i、j 和 k 的子问题将告诉我们从 a[0..i] 中正好选择 k 个子字符串来构建正好是 b[0..j] 的方法数,并且要求右边的子字符串必须恰好以字符 a[i] 结尾。
让我们回到我们的例子,以便更清楚地说明这些子问题如何工作。对于 i = 6,j = 3,k = 3 的子问题,我们的答案是什么?(不要太快回答“11”!)
由于 a[0..6] 是整个 a,而 b[0..3] 是整个 b,这个子问题是在询问整个 a 和 b 字符串的情况。所以我们在寻找从 a 中选择正好三个子字符串来构建 b 的所有方法,且要求右边的子字符串必须恰好以 a[6] 中的 z 结尾。
答案是 8!这些就是——前五个是使用 a 中的最终 z 作为其自身子字符串的,而剩下的三个则使用 a 中的最终 yz 作为其自身子字符串:
-
0-1 (
xx)、5-5 (y)、6-6 (z) -
1-1 (
x)、4-5 (xy)、6-6 (z) -
0-0 (
x)、4-5 (xy)、6-6 (z) -
0-1 (
xx)、2-2 (y)、6-6 (z) -
0-0 (
x)、1-2 (xy)、6-6 (z) -
1-1 (
x)、4-4 (x)、5-6 (yz) -
0-0 (
x)、4-4 (x)、5-6 (yz) -
0-0 (
x)、1-1 (x)、5-6 (yz)
那么为什么我们不能包括缺失的解法,比如 0-1 (xx)、2-2 (y)、3-3 (z)? 难道这些不应该算吗?
我们在这里不能使用它们的原因是它们没有使用 a[6] 中的最终 z。它们不满足我们所使用的“精确”子问题的要求。
也就是说,到目前为止我们已经有了八种方法,我们确实需要得到 11 种。仅仅看 i = 6、j = 3、k = 3 的子问题是不够的。我们可以通过查看 i = 3、j = 3、k = 3 的子问题来找到剩下的三种方法。这些方法如下:
-
0-1 (
xx)、2-2 (y)、3-3 (z) -
0-0 (
x)、1-2 (xy)、3-3 (z) -
0-0 (
x)、1-1 (x)、2-3 (yz)
正如我们在本章中对《跳跃者》的逆向构造以及《汉堡狂热》和《钱财贪婪者》在第三章中的处理一样,我们需要进行一些后处理来找出我们需要的内容。在其他问题中,我们搜索了相关的子问题来发现最优解;在这里,我们将搜索相关的子问题来找到所有可能的解。
描述方法
考虑前一节中描述的 i、j、k 子问题。满足此子问题有两类方法。在每一类中,我们要求 a[i] 和 b[j] 是相同的字符——如果不是,那么子问题的要求就没有得到满足,答案为零。
类别 1
一类方法涉及将 a[i] 作为最终的子字符串。如果我们用完 a[i] 上的一个子字符串,那么就剩下 k – 1 个子字符串要处理,所以我们会继续解决第三个参数为 k – 1 的子问题。同样,我们会继续使用 j – 1 作为第二个参数,因为我们刚刚用过字符 b[j]。
那么第一个参数怎么办——我们该怎么做?难道我们不只是像处理 j 和 k 一样,继续使用 i – 1 吗?
不一定!确实,继续使用 i – 1 是一种可能性,但也有其他方法。我们需要能够跳过 a 中的一些字符,并在更早的地方继续。也就是说,我们可能希望将 b[j – 1] 与 a[i – 2] 或 a[i – 3] 或更早的字符匹配。我们需要在这里加一个循环,尝试第一个参数的所有值,直到 i – 1,同时固定第二个参数 j – 1 和第三个参数 k – 1。
使用我们的运行示例,再次考虑 i = 6,j = 3 和 k = 3 的子问题。我们知道这个子问题的答案是 8。在这 8 个答案中,有 5 个属于类别 1。我们从 i = 5,j = 2 和 k = 2 的子问题得到 3 个解,另外 2 个来自 i = 2,j = 2 和 k = 2 的子问题。
类别 2
另一类解法涉及使用至少两个字符作为最终子串。
对于这个问题,我们有一个巧妙的技巧可以使用。假设我们解决了具有参数 i – 1,j – 1 和 k 的子问题(没错:第三个参数是 k,不是 k – 1!)。事实证明,这就是我们需要解决的唯一子问题!
该子问题使用所有的 k 个子串来寻找通过将 a[i – 1] 与 b[j – 1] 匹配的解决方案。但这些解决方案中的每一个都可以扩展为一个解决方案,通过将 a[i](或 b[j]—它们是相同的)附加到最终子串上,从而将 a[i] 与 b[j] 匹配。同样,任何一个匹配 a[i] 与 b[j] 并且使用 k 个子串的类别 2 中的解决方案,都对应于一个匹配 a[i – 1] 与 b[j – 1] 并使用 k 个子串的子问题的解决方案。总之,这里解的数量与 i – 1,j – 1,k 子问题的解数量是完全相同的。
在我们的运行示例中,i = 6,j = 3 和 k = 3 的子问题在这一类中有三个解。我们通过查找 i = 5,j = 2 和 k = 3 的子问题的答案得到这些解。
注意,除了这两类外,不可能有其他类别。类别 1 使用单字符字符串 a[i] 作为最终子串;类别 2 使用一个更长的字符串,其中以 a[i] 结尾作为最终子串。不能有其他的情况。
解决一个子问题
正如我们在本章前面解决《跳跃者》时所做的那样,我们将跳过没有修饰的递归解法,直接跳到记忆化解法。我们需要一个函数来告诉我们给定 i、j 和 k 值的子问题的答案。这个函数的签名如下:
int solve_ijk(char a[], char b[], int i, int j, int k,
int memo[MAX_A][MAX_B][MAX_K + 1])
这里,a 是源字符串;b 是目标字符串;i、j 和 k 是三个子问题参数;memo 是记忆化数组。
这个函数的代码见 Listing 4-6。
#define MAX_A 1000
#define MAX_B 200
#define MAX_K 200
#define MOD 1000000007
int solve_ijk(char a[], char b[], int i, int j, int k,
int memo[MAX_A][MAX_B][MAX_K + 1]) {
int total, q;
if (memo[i][j][k] != -1)
return memo[i][j][k];
❶ if (j == 0 && k == 1 && a[i] == b[j]) {
memo[i][j][k] = 1;
return memo[i][j][k];
}
➋ if (i == 0 || j == 0 || k == 0) {
memo[i][j][k] = 0;
return memo[i][j][k];
}
➌ if (a[i] != b[j]) {
memo[i][j][k] = 0;
return memo[i][j][k];
}
total = 0;
➍ for (q = 0; q < i; q++)
➎ total = (total + solve_ijk(a, b, q, j - 1, k - 1, memo)) % MOD;
➏ total = (total + solve_ijk(a, b, i - 1, j - 1, k, memo)) % MOD;
memo[i][j][k] = total;
return memo[i][j][k];
}
Listing 4-6: 解决一个子问题
当我们在 第三章 中解决《冰球对抗赛》时,我们将数组的索引从 0 改为从 1 开始,这使得我们的解法变得稍微简单一些。然而,在这里,我决定从 0 开始索引。你确实可以在这里使用《冰球对抗赛》的索引技巧;我之所以没有使用,是为了避免在空字符串参与时需要定义子问题的含义。
让我们从基本情况开始,逐步分析代码。
我们的第一个基本情况是当j为0,k为1,且a[i]和b[j]是相同字符 ➊时。在这里,我们要求的是在a[i]与b[0]匹配的前提下,构建单字符字符串b[0]的方式数。答案是1,因为我们可以使用a[i]作为匹配!
现在,在任何其他情况下,当i为0,或j为0,或k为0 ➋时,答案是0。例如,如果i为0且j大于0,那么我们正在被要求从一个只有一个字符的字符串中选择子字符串,来构建一个包含多个字符的字符串。这是不可能的!
实际上,这里还有一个基本情况 ➌,它在a[i]和b[j]不相等时触发。在这种情况下,我们立刻返回0,因为无法用a[i]的最终字符匹配b[j]。
现在我们得到了实现这两个类别的代码。每个类别都会向total变量贡献其部分答案。
对于类别 1,我们需要一个循环 ➍,它尝试a的每个相关结束点,并将子问题的答案加到total ➎中。这里我们使用了模运算符,这是根据问题描述的要求。
对于类别 2,我们不需要循环:我们只需解决一个直接给出答案的子问题 ➏。
收集解决方案
我们需要使用所有i的值调用solve_ijk。这是因为a的任何字符都可能与b的最右边字符匹配。以所需方式调用solve_ijk的代码在清单 4-7 中。
int solve(char a[], char b[], int a_length, int b_length,
int num_substrings) {
int i, j, k, result;
static int memo[MAX_A][MAX_B][MAX_K + 1];
for (i = 0; i < a_length; i++)
for (j = 0; j < b_length; j++)
for (k = 0; k <= num_substrings; k++)
memo[i][j][k] = -1;
result = 0;
for (i = 0; i < a_length; i++) {
result = result + solve_ijk(a, b, i, b_length - 1, num_substrings, memo);
result = result % MOD;
}
return result;
}
清单 4-7:解决方案 1
在我们之前的动态规划问题中,我们使用了min或max来找到最佳解决方案。但请注意,在清单 4-6 和清单 4-7 中,我们并没有这样做。相反,因为我们想找到所有解决方案的总数,所以我们通过使用+将所有内容加在一起。
主函数
本解决方案的main函数在清单 4-8 中。
int main(void) {
int a_length, b_length, num_substrings;
char a[MAX_A + 1], b[MAX_B + 1];
scanf("%d%d%d", &a_length, &b_length, &num_substrings);
scanf("%s", a);
scanf("%s", b);
printf("%d\n", solve(a, b, a_length, b_length, num_substrings));
return 0;
}
清单 4-8: 主 函数
现在我们已经得到了这个问题的完整解决方案。可以提交给评测系统了。
准备好失望吧。
解决方案 2:增加更多子问题
本书中第一次出现“时间限制超出”错误是使用了记忆化技术时发生的。问题是,即使我们使用了记忆化或动态规划,我们仍然可能需要考虑算法的效率。一些问题允许多种解决方案,尽管这些解决方案比指数时间更快,但它们在效率上可能有所不同。
解决方案 1 的运行时
假设我们用 m 表示源字符串的长度,用 n 表示目标字符串的长度,用 k 表示我们需要处理的子字符串的数量。我们的方法可能最终需要解决大约 mnk 个子问题。对于每个子问题,我们都会被类别 1 的解决方案“重击”,这些方案要求我们遍历源字符串,给我们的运行时间再增加一个 m 的因素。那么,问题是,我们的算法的步骤数与 m²nk 成正比。为了看看这个问题有多严重,我们这里代入这些变量的最大值:m 为 1,000,n 为 200,k 为 200。这样计算结果是 1,000² × 200 × 200 = 40,000,000,000。四百亿!而我们需要在两秒钟内完成所有这些工作?根本不可能。我们需要做得更好。正如我们将看到的,我们能够去掉 m 中的一个因子,并将这个问题的解决时间降到 O(mnk) 时间。
在这一点上,我们可能考虑两种常见的策略。第一种是丢弃现有的子问题,然后用新的子问题重新尝试。我曾经尝试过这么做;你可以查阅这本书的在线资源,看看我得出了什么结果。结果没有帮助。
在这种情况下,丢弃这些子问题是一个不必要的极端举动。毕竟,我们已经有了一个正确且相对高效的解决方案;唯一的问题是我们需要为每个类别 1 中的子问题使用的循环。
第二种策略,也是我们在这里会成功的策略,是将新的子问题添加到现有的子问题中。第二种策略可能看起来违反直觉。你可能会认为,如果我们的程序已经太慢了,那么添加和解决更多的子问题只会进一步拖慢速度。但是,如果我们的新子问题实际上能帮助我们更快地解决现有的子问题呢?特别是,如果它们为我们提供了一种不需要循环就能解决类别 1 中子问题的方法呢?如果我们还能高效地解决这些新子问题呢?那我们就发财了!是的,我们将解决更多的子问题,但我们可以通过高效解决每个子问题来弥补这一点。让我们试试吧!
新的子问题
还记得清单 4-6 中的这段代码吗?
for (q = 0; q < i; q++)
total = (total + solve_ijk(a, b, q, j - 1, k - 1, memo)) % MOD;
这是加总类别 1 中所有方式的循环。它是我们需要消除的循环。
这个q循环保持j - 1和k - 1不变,并将第一个子问题的参数从0变到i - 1。也就是说,它首先解决 0, j – 1, k – 1 的子问题,然后是 1, j – 1, k – 1 的子问题,再然后是 2, j – 1, k – 1 的子问题,以此类推。
如果我们能够一次性查找所有这些子问题答案的总和,那该有多棒!那样我们就根本不需要 q 循环了。这正是我们的新子问题将为我们做的事情。例如,具有参数 i = 4、j 和 k 的新子问题将是我们五个旧子问题的和:这些旧子问题的 j 和 k 值相同,但 i 值为 0、1、2、3 或 4。也就是说,新的子问题不再要求 a[i] 与 b[j] 是相同的字符。以下是我们旧子问题和新子问题的定义。
旧
具有参数 i、j 和 k 的旧子问题将告诉我们从 a[0..i] 中恰好选择 k 个子字符串来精确构建 b[0..j] 的方式数,且有一个限制条件,即最右边的子字符串恰好以字符 a[i] 结尾。
新
新的子问题,参数为 i、j 和 k,将告诉我们从 a[0..i] 中恰好选择 k 个子字符串来精确构建 b[0..j] 的方式数。
现在,借助这些新子问题,让我们回到我们的运行示例。对于新子问题 i = 6、j = 3 和 k = 3 的答案是什么?这次答案真的是 11!这个子问题是在没有限制的情况下,询问我们从 a 中选择恰好三个子字符串来构建 b 的所有方式。我们知道有 11 种方式。注意,作为一个额外的好处,我们不再需要像在列表 4-7 中那样解决多个子问题。我们只需要一个子问题的答案:那个包含 a 的所有字符、b 的所有字符以及子字符串 k 数量的子问题。
解决所需的子问题
让我们跳转到新代码。对于每一组三个值 i、j 和 k,我们将存储不止一个,而是两个子问题的答案。我们将使用 C 结构体来收集这两个答案:
typedef struct pair {
int end_at_i;
int total;
} pair;
end_at_i 成员将存储我们旧子问题的解(来自解决方案 1 的那些),而 total 成员将存储我们新子问题的解。我们的 memo 数组仍然是三维数组,每个元素现在包含 end_at_i 和 total。其他可能的设计包括两个独立的三维记忆数组,或一个四维数组,其中新维度的长度为 2。
现在我们可以编写解决我们旧子问题和新子问题的代码。
在我们之前所有的记忆化和动态规划代码中,我们必须为每个子问题参数的设置找到一个答案。但在这里,我们需要为结构体的每个成员找到两个答案。请在阅读代码时留意这一点。可以在列表 4-9 中查看。
pair solve(char a[], char b[], int i, int j, int k,
pair memo[MAX_A][MAX_B][MAX_K + 1]) {
int total, end_at_i;
if (memo[i][j][k].total != -1)
return memo[i][j][k];
if (j == 0 && k == 1) {
❶ if (a[i] != b[j]) {
if (i == 0)
total = 0;
else
➋ total = solve(a, b, i - 1, j, k, memo).total;
➌ memo[i][j][k] = (pair){0, total};
} else {
if (i == 0)
total = 1;
else
total = 1 + solve(a, b, i - 1, j, k, memo).total;
memo[i][j][k] = (pair){1, total};
}
return memo[i][j][k];
}
if (i == 0 || j == 0 || k == 0) {
memo[i][j][k] = (pair){0, 0};
return memo[i][j][k];
}
if (a[i] != b[j])
end_at_i = 0;
else {
➍ end_at_i = (solve(a, b, i - 1, j - 1, k - 1, memo).total +
➎ solve(a, b, i - 1, j - 1, k, memo).end_at_i);
end_at_i = end_at_i % MOD;
}
➏ total = (end_at_i + solve(a, b, i - 1, j, k, memo).total) % MOD;
memo[i][j][k] = (pair){end_at_i, total};
return memo[i][j][k];
}
列表 4-9:解决方案 2
与解决方案 1 中一样,这里的一个关键情况是当 j 为 0 且 k 为 1 时。我们需要考虑两个重要的子情况。
让我们从a[i]和b[j]是不同字符的情况开始 ➊。我们知道此时end_at_i的解为0,就像在解法 1 中一样。但total的解可能不为 0。例如,如果i为4,并且a的前三个字符与b[0]相同,那么我们的答案就是3。为了获得所有解,我们进行递归调用,查找使用a中从索引i - 1之前的字符匹配的总数 ➋。然后我们在memo数组中存储我们计算的total答案和end_at_i答案0 ➌。
我们处理子案例的方式,当a[i]和b[j]是相同字符时类似;我们只需要将end_at_i和total都加 1,以考虑a[i]和b[j]之间的匹配。
现在让我们着手解决当前的end_at_i子问题。回想一下,类别 1 涉及使用 a[i] 作为最终子字符串,而类别 2 涉及使用至少两个字符作为最终子字符串。
讲解类别 2 会更容易,所以我们先讲解这个。该代码几乎与之前一样:我们使用子问题参数i - 1、j - 1和k ➎来检索end_at_i解的数量。
那么,关于我们为类别 1 编写的新代码怎么样呢?这是我们胜利的时刻,因为我们可以通过直接查找所需的total子问题来一步解决这种子问题 ➍!
还有最后一件事我们需要做。毕竟,我们现在有了新的total子问题。我们有责任解决它们,确保它们在查找时是正确的。幸运的是,我们也能快速解决每个total子问题。为此,我们利用了刚刚找到的使用a[i]的解的数量。我们只需将它加到i - 1的旧总数上,得到最终的总数 ➏。
在这里,end_at_i子问题和total子问题之间有一个很好的相互作用,我们利用它们来高效地解决对方。这是我们添加新的total子问题能够帮助我们的核心原因:它们加速了end_at_i子问题的计算,并且通过快速解决相关的end_at_i子问题,我们也可以回报这个帮助,快速解决total子问题。
主要函数
现在我们只需要我们的main函数了。请参见清单 4-10 中的代码。
int main(void) {
int a_length, b_length, num_substrings, i, j, k, result;
char a[MAX_A + 1];
char b[MAX_B + 1];
static pair memo[MAX_A][MAX_B][MAX_K + 1];
scanf("%d%d%d", &a_length, &b_length, &num_substrings);
scanf("%s", a);
scanf("%s", b);
for (i = 0; i < a_length; i++)
for (j = 0; j < b_length; j++)
for (k = 0; k <= num_substrings; k++)
memo[i][j][k] = (pair){-1, -1};
result = solve(a, b, a_length - 1, b_length - 1, num_substrings, memo).total;
printf("%d\n", result);
return 0;
}
清单 4-10: 主要 函数
如果你将这个解决方案提交给评测系统,你应该能够按时通过所有测试用例。
总结
在本章中,我们学到了动态规划问题可以正向或反向解决,有时选择哪种方式非常重要。我们还学到了我们的初始解决方法可能不够快,这时我们需要重新考虑我们的子问题或添加新的子问题。
还觉得不过瘾吗?你可能会很高兴地知道,动态规划相关的思想经常在其他算法中出现。在下一章中,例如,你将看到我们再次存储结果以供后续查找。而在第七章中,你将看到一个问题,其中动态规划起到了辅助作用,加速了主算法所需的计算。
注释
Jumper 最初来自 2007 年克罗地亚信息学区域竞赛。Ways to Build 最初来自 2015 年省级信息学奥林匹克。
第五章:图形与广度优先搜索

在本章中,我们将研究三种问题,要求我们用最少的步数解决谜题。骑士多快能追上兵?学生在体育课上爬绳子多快?我们能以多低的成本将一本书从一种语言翻译成其他目标语言?广度优先搜索(BFS)是解决这些问题的统一算法。BFS 解决了这些问题,并且更广泛地应用于我们想要以最少步数解决谜题的场景。在此过程中,我们将学习图形,这是建模和解决涉及物体及物体间连接问题的强大工具。
问题 1:骑士追击
这是 DMOJ 问题ccc99s4。
问题描述
这个问题涉及两个玩家,一个是兵,一个是骑士,在棋盘上进行对弈。(别担心:你不需要了解任何关于国际象棋的知识。)
棋盘有r行,第一行在底部,r行在顶部。棋盘有c列,第一列在左侧,c列在右侧。
兵和骑士各自从棋盘上的一个方格开始。兵先移动,然后骑士移动,然后是兵,再是骑士,依此类推,直到游戏结束。每轮必须进行移动:不能停留在当前方格。
兵无法选择要怎么移动:每轮它都会向上一格移动。
相比之下,骑士每次移动都有最多八个选择:
-
向上 1,向右 2
-
向上 1,向左 2
-
向下 1,向右 2
-
向下 1,向左 2
-
向上 2,向右 1
-
向上 2,向左 1
-
向下 2,向右 1
-
向下 2,向左 1
我说“最多八个选择”,而不是“恰好八个选择”,因为任何让骑士超出棋盘的移动都是不允许的。例如,如果棋盘有 10 列,骑士在第 9 列,那么任何让骑士向右移动两列的行为都是不允许的。
以下图示显示了骑士的可用移动:
| f | e | |||||
| b | a | |||||
| K | ||||||
| d | c | |||||
| h | 9 | |||||
这里,骑士用K表示,从a到h的每个字母代表其可能的移动。
游戏结束时会发生以下三种情况:骑士获胜,游戏和棋(即平局),或骑士失败。
胜利 如果骑士移动并停在与兵相同的方格上,而兵还未到达顶排,骑士就获胜。为了获胜,必须是骑士做出这个移动;如果是兵移动并停在骑士上,这不算骑士获胜。
和棋 游戏是和棋如果骑士移动并停在兵前方的方格上,而兵还没有到达顶排。同样,必须是骑士做出这个移动;唯一的例外是,如果骑士从兵上方一格开始,游戏可以一开始就为和棋。
Loss 如果兵在游戏结束之前到达了顶部行,骑士就输了。也就是说,如果兵在骑士踏上兵或站在兵上方的格子之前到达顶部行,那么骑士就输了。一旦兵到达顶部行,骑士将不再被允许移动。
目标是确定骑士的最佳结果及达到该结果所需的骑士步数。
输入
输入的第一行给出将要处理的测试用例数量。每个测试用例由六行组成:
-
棋盘的行数,范围在 3 到 99 行之间
-
棋盘的列数,范围在 2 到 99 列之间
-
兵的起始行
-
兵的起始列
-
骑士的起始行
-
骑士的起始列
可以保证兵和骑士有不同的起始位置,并且骑士起始位置至少有一个可用的移动。
输出
对于每个测试用例,输出一行,内容为以下三条消息之一:
-
如果骑士可以获胜,输出
Win in *m* knight move(s). -
如果骑士无法获胜,但能导致僵局,输出
Stalemate in *m* knight move(s). -
如果骑士无法获胜或导致僵局,输出
Loss in *m* knight move(s).
这里,m 是骑士所需的最少步数。
解决测试用例的时间限制为一秒。
最优移动
一个真正的两人游戏,比如井字棋或象棋,给每个玩家选择下一步的机会。然而,在这里,只有骑士有选择权。兵的移动是固定的,我们随时都知道兵的位置。幸运的是,因为如果两方都有选择权,这个问题将会变得更加复杂。
骑士可能有多种方式获胜或导致僵局。假设骑士能够获胜。每种骑士获胜的方式需要一定的步数;我们想要确定最少的步数。
探索棋盘
让我们通过这个输入稍微探索一下:
1
7
7
1
1
4
6
该测试用例的棋盘有七行七列。兵从第 1 行第 1 列开始,骑士从第 4 行第 6 列开始。
在最优移动下,骑士可以在三步内获胜。下面的图示展示了骑士如何实现这一点:
| 7 | |||||||
|---|---|---|---|---|---|---|---|
| 6 | K2 | ||||||
| 5 | K1 | ||||||
| 4 | K3 P3 | K | |||||
| 3 | P2 | ||||||
| 2 | P1 | ||||||
| 1 | P | ||||||
| 1 | 2 | 3 | 4 | 5 | 6 | 7 |
这里,K 用来表示骑士的起始位置,P 用来表示兵的起始位置。K1、K2 和 K3 分别表示骑士在第 1 步、第 2 步和第 3 步后的位置;P1、P2 和 P3 也同样表示兵的移动位置。
坐标(x,y)指的是第 x 行,第 y 列。如预期的那样,兵只是沿着自己的列向上移动,从(1,1)到(2,1),到(3,1),最终到达(4,1)。然而,骑士的移动方式如下:
-
从(4,6)开始,它向上移动一步并向左移两步到达(5,4)。兵的位置在(2,1)。
-
从(5,4)开始,它向上移动一步并向左移两步到达(6,2)。兵的位置在(3,1)。
-
从(6,2)开始,它向下移动两步并向左移一步到达(4,1)。兵就在这个位置!
骑士获胜的方式还有其他几种。例如,如果骑士有些放松,它也可能会这样:
| 7 | |||||||
|---|---|---|---|---|---|---|---|
| 6 | K2 | ||||||
| 5 | K4 P4 | K1 | |||||
| 4 | P3 | K3 | K | ||||
| 3 | P2 | ||||||
| 2 | P1 | ||||||
| 1 | P | ||||||
| 1 | 2 | 3 | 4 | 5 | 6 | 7 |
骑士在四步后才会吃掉兵,而不是三步。虽然骑士最终获胜,但这并不是它能做到的最快方式。我们这里需要报告最少三步,而不是四步。
假设我们有一个算法来确定骑士从起点到某个目标的最少移动步数。我们可以通过这个算法确定骑士到达每个兵的位置所需的步数;如果骑士能够与兵同时到达,那么骑士获胜。如果骑士无法获胜,那么我们可以以类似的方式处理和棋的情况。也就是说,我们可以确定骑士到达每个兵上方位置所需的步数;如果在某一点,骑士能够到达兵上方的格子,我们就达成了和棋。
为了设计这样的算法,我们可以从骑士的起点开始探索棋盘。棋盘上只有一个方格是可以在零步内到达的,那就是骑士的起点。从这里开始,我们可以发现那些在一步内可达的方格。从那些一步内可达的方格,我们可以发现那些在两步内可达的方格。我们可以用这些两步内可达的方格来找到三步内可达的方格,以此类推。直到我们找到目标位置为止;此时,我们就知道到达那里所需的最小步数。
让我们用之前相同的测试用例演示这一过程:七行七列,骑士从(4,6)开始。(我们暂时忽略兵。)为了验证我们手动得出的三步结果,我们将计算骑士从(4,6)到(4,1)所需的最小步数。
在下图中,方格内的数字表示骑士从起点到该位置的最小距离。如上所述,唯一可以在零步内到达的方格是骑士的起点(4, 6)。我们将这视为探索的第 0 轮:
| 7 | |||||||
|---|---|---|---|---|---|---|---|
| 6 | |||||||
| 5 | |||||||
| 4 | 0 | ||||||
| 3 | |||||||
| 2 | |||||||
| 1 | |||||||
| 1 | 2 | 3 | 4 | 5 | 6 | 7 |
从(4,6)开始,我们尝试所有八个可能的移动,来识别一步之遥的方格。我们不能上移 1 并右移 2,也不能下移 1 并右移 2,因为那样会越过棋盘的右边界。这就留下了六个距离一步之遥的方格。这是第一轮:
| 7 | |||||||
|---|---|---|---|---|---|---|---|
| 6 | 1 | 1 | |||||
| 5 | 1 | ||||||
| 4 | 0 | ||||||
| 3 | 1 | ||||||
| 2 | 1 | 1 | |||||
| 1 | |||||||
| 1 | 2 | 3 | 4 | 5 | 6 | 7 |
我们还没有找到(4,1),所以继续前进。我们从第一轮中发现的六个新方格出发进行探索,这将产生距离两步之遥的方格。例如,考虑方格(6,5);从那里可以到达的方格如下:
-
上移 1,右移 2:(7,7)
-
上移 1,左移 2:(7,3)
-
下移 1,右移 2:(5,7)
-
下移 1,左移 2:(5,3)
-
上移 2,右移 1:(无效)
-
上移 2,左移 1:(无效)
-
下移 2,右移 1:(4,6)
-
下移 2,左移 1:(4,4)
这些方格距离起点两步之遥——除了(4,6),我们之前已经填入了它的值(0)!查看所有从一步之遥的方格出发的有效移动,将我们带到第二轮,即距离两步之遥的方格。
| 7 | 2 | 2 | 2 | ||||
|---|---|---|---|---|---|---|---|
| 6 | 2 | 1 | 2 | 1 | |||
| 5 | 2 | 1 | 2 | 2 | |||
| 4 | 2 | 2 | 0 | ||||
| 3 | 2 | 1 | 2 | 2 | |||
| 2 | 2 | 1 | 2 | 1 | |||
| 1 | 2 | 2 | 2 | ||||
| 1 | 2 | 3 | 4 | 5 | 6 | 7 |
注意到没有其他方格距离起点两步之遥。所有距离两步之遥的方格都必须来自距离起点一步之遥的方格,我们已经探索了所有可能的一步之遥的方格。
仍然没有(4,1),所以继续前进。从所有距离两步之遥的方格出发进行探索,我们得到了第三轮,距离三步之遥的方格:
| 7 | 3 | 2 | 3 | 2 | 3 | 2 | |
|---|---|---|---|---|---|---|---|
| 6 | 3 | 2 | 3 | 1 | 2 | 1 | |
| 5 | 3 | 2 | 1 | 2 | 3 | 2 | |
| 4 | 3 | 2 | 3 | 2 | 3 | 0 | 3 |
| 3 | 3 | 2 | 1 | 2 | 3 | 2 | |
| 2 | 3 | 2 | 3 | 1 | 2 | 1 | |
| 1 | 3 | 2 | 3 | 2 | 3 | 2 | |
| 1 | 2 | 3 | 4 | 5 | 6 | 7 |
就这样:方格(4,1)被填上了值 3。因此,从(4,6)到(4,1)至少需要三步。如果我们没有在这里找到(4,1),我们将继续:我们可以继续寻找四步之遥的方格,五步之遥的方格,以此类推。
这种技术——首先找到所有与目标零步之遥的格子,然后是一步之遥、两步之遥,依此类推——称为广度优先搜索,简称 BFS。广度指的是完整的范围。BFS 之所以这样命名,是因为我们从每个格子开始,首先探索所有可达的格子,再继续向其他格子扩展。BFS 非常快速,内存高效,而且实现起来很简洁。每当你想要找出从一个位置到另一个位置的最短距离时,调用 BFS 绝对是个强力的选择。让我们开始吧!
实现广度优先搜索
首先,我们定义几个类型,以便稍微清理一下代码。每个棋盘位置由一行和一列组成,因此我们将这两个元素使用结构体打包在一起:
typedef struct position {
int row, col;
} position;
棋盘是一个二维数组,我们也可以为它定义一个类型。我们将让它保存整数,这些整数表示移动次数。棋盘最多有 99 行和 99 列,但我们额外分配了一行和一列,这样我们就可以从1开始索引行和列,而不是从0:
#define MAX_ROWS 99
#define MAX_COLS 99
typedef int board[MAX_ROWS + 1][MAX_COLS + 1];
最后,让我们为保存 BFS 过程中发现的位置创建一个数组类型。我们将使它足够大,以便能够容纳棋盘上所有可能的格子:
typedef position positions[MAX_ROWS * MAX_COLS];
现在我们准备开始广度优先搜索(BFS)了。我们需要一个函数来确定骑士从起始点到指定目标的最小移动次数。(回想一下我们的计划是找到每个棋子位置所需的最小移动次数。)这是我们将要实现的函数的签名:
int find_distance(int knight_row, int knight_col,
int dest_row, int dest_col,
int num_rows, int num_cols)
参数knight_row和knight_col给出了骑士的起始位置,dest_row和dest_col给出了目标位置。参数num_rows和num_cols分别给出了棋盘的行数和列数;我们需要这些来判断一个移动是否有效。该函数返回骑士从起始位置到目标位置的最小移动次数。如果骑士无法到达目标位置,则返回-1。
有两个关键数组驱动 BFS:
cur_positions 这个数组保存了当前轮 BFS 中发现的位置。例如,它可能保存的是第 3 轮发现的所有位置。
new_positions 这个数组保存了在下一轮广度优先搜索(BFS)中发现的位置。例如,如果cur_positions保存的是第 3 轮发现的位置,那么new_positions将保存第 4 轮发现的位置。
代码见清单 5-1。
int find_distance(int knight_row, int knight_col,
int dest_row, int dest_col,
int num_rows, int num_cols) {
positions cur_positions, new_positions;
int num_cur_positions, num_new_positions;
int i, j, from_row, from_col;
board min_moves;
for (i = 1; i <= num_rows; i++)
for (j = 1; j <= num_cols; j++)
min_moves[i][j] = -1;
➊ min_moves[knight_row][knight_col] = 0;
➋ cur_positions[0] = (position){knight_row, knight_col};
num_cur_positions = 1;
➌ while (num_cur_positions > 0) {
num_new_positions = 0;
for (i = 0; i < num_cur_positions; i++) {
from_row = cur_positions[i].row;
from_col = cur_positions[i].col;
➍ if (from_row == dest_row && from_col == dest_col)
return min_moves[dest_row][dest_col];
➎ add_position(from_row, from_col, from_row + 1, from_col + 2,
num_rows, num_cols, new_positions,
&num_new_positions, min_moves);
add_position(from_row, from_col, from_row + 1, from_col - 2,
num_rows, num_cols, new_positions,
&num_new_positions, min_moves);
add_position(from_row, from_col, from_row - 1, from_col + 2,
num_rows, num_cols, new_positions,
&num_new_positions, min_moves); add_position(from_row, from_col, from_row - 1, from_col - 2,
num_rows, num_cols, new_positions,
&num_new_positions, min_moves);
add_position(from_row, from_col, from_row + 2, from_col + 1,
num_rows, num_cols, new_positions,
&num_new_positions, min_moves);
add_position(from_row, from_col, from_row + 2, from_col - 1,
num_rows, num_cols, new_positions,
&num_new_positions, min_moves);
add_position(from_row, from_col, from_row - 2, from_col + 1,
num_rows, num_cols, new_positions,
&num_new_positions, min_moves);
add_position(from_row, from_col, from_row - 2, from_col - 1,
num_rows, num_cols, new_positions,
&num_new_positions, min_moves);
}
➏ num_cur_positions = num_new_positions;
for (i = 0; i < num_cur_positions; i++)
cur_positions[i] = new_positions[i];
}
return -1;
}
清单 5-1:使用 BFS 计算骑士最小移动次数
我们做的第一件事是清空min_moves数组,将所有值设置为-1;这意味着我们还没有计算出移动次数。唯一我们知道最小移动次数的方格是骑士的起始方格,因此我们将其初始化为0 ➊。这个起始方格也是启动 BFS 的方格 ➋。然后,while循环会继续执行,只要最近一轮 BFS 发现至少一个新方格 ➌。在while循环中,我们检查每个这样的方格。如果我们发现了目标方格 ➍,我们就返回它的最小移动次数。否则,我们继续探索。
从给定的方格探索所有八个移动是通过八次调用一个名为add_position的辅助函数来完成的,该函数将新的方格添加到new_positions并相应地更新num_new_positions。重点是前四个参数:它们提供当前的行和列,以及通过其中一个八个移动得出的新行和新列。例如,第一个调用 ➎ 是向上移动两步并向右移动一步。我们稍后会查看add_position的代码。
我们已经遍历了cur_positions中的每个方格,并找到了距离一个移动距离的新方格。这完成了 BFS 的一个回合。为了准备下一轮,我们跟踪新方格的数量 ➏ 并将所有新方格从new_positions复制到cur_positions。这样,while循环的下一次迭代将使用这些新方格,并从这些方格中找到进一步的新方格。
如果我们执行到代码末尾,仍然没有找到目标方格,那么我们返回-1——目标方格无法从骑士的起始位置到达。
现在看一下那个add_position辅助函数;请参见清单 5-2。
void add_position(int from_row, int from_col,
int to_row, int to_col,
int num_rows, int num_cols,
positions new_positions, int *num_new_positions,
board min_moves) {
struct position new_position;
if (to_row >= 1 && to_col >= 1 &&
to_row <= num_rows && to_col <= num_cols &&
min_moves[to_row][to_col] == -1) {
➊ min_moves[to_row][to_col] = 1 + min_moves[from_row][from_col];
new_position = (position){to_row, to_col};
new_positions[*num_new_positions] = new_position;
(*num_new_positions)++;
}
}
清单 5-2:添加位置
if语句有五个条件,只有当所有条件都为真时,to_row和to_col才是一个有效的位置:行必须至少为 1,列必须至少为 1,行不能超过行数,列不能超过列数,最后一个条件是min_moves[to_row][to_col] == -1,这是什么意思呢?
最终条件是用来判断我们是否已经遇到过这个方格。如果没有遇到过,那么它的值将为-1,我们可以立即为它设置移动次数。如果它已经有其他值,那么它一定是在 BFS 的早期回合中被发现的,因此它已经有了一个比现在能给它的值更小的移动次数。也就是说,任何非-1的值都意味着最小的移动次数已经设置好,我们不应更改它。
如果所有五个条件都通过了,那么我们就发现了一个新方格。我们在上一次 BFS 中发现了(from_row, from_col)的位置,并在当前轮次发现了(to_row, to_col)。因此,最少步数到达(to_row, to_col)是到达(from_row, from_col)的最少步数加一 ➊。由于(from_row, from_col)是从上一次 BFS 中得来的,我们已经将其值存储在min_moves中,因此我们可以直接查找其值,而无需重新计算。
你可能会在这里看到备忘录化和动态规划的影子。没错:广度优先搜索(BFS)使用了相同的技巧,即查找已计算的结果,而不是重新计算。然而,BFS 中并没有真正的基于子问题解来最大化或最小化解,或者通过合并更小的解来形成更大的解。因此,算法开发者通常不会把 BFS 称为动态规划算法,而是将其归类为搜索或探索算法。
骑士的最佳结果
我们已经把 BFS 封装成了find_distance函数。现在,让我们计算棋子沿着列向上移动时的步数,并使用find_distance来判断骑士是否能够赶到棋子的位置。例如,如果棋子需要三步才能到达某个地方,而骑士也正好需要三步才能到达,那么骑士就会在三步内获胜。如果骑士无法获胜,我们可以尝试一种类似的技术来应对僵局:让棋子再次向上走,检查骑士是否能导致僵局。如果没有可能的僵局,那么骑士就输了。我已经在列表 5-3 中实现了这个逻辑。solve函数有六个参数:棋子的起始行和列、骑士的起始行和列,以及棋盘的行数和列数。它会打印出一行输出,表示骑士是否获胜、僵局或失败。
// bugged!
void solve(int pawn_row, int pawn_col,
int knight_row, int knight_col,
int num_rows, int num_cols) {
int cur_pawn_row, num_moves, knight_takes;
➊ cur_pawn_row = pawn_row;
num_moves = 0;
while (cur_pawn_row < num_rows) {
knight_takes = find_distance(knight_row, knight_col,
cur_pawn_row, pawn_col,
num_rows, num_cols);
➋ if (knight_takes == num_moves) {
printf("Win in %d knight move(s).\n", num_moves);
return;
}
cur_pawn_row++;
num_moves++;
}
➌ cur_pawn_row = pawn_row;
num_moves = 0;
while (cur_pawn_row < num_rows) {
knight_takes = find_distance(knight_row, knight_col,
cur_pawn_row + 1, pawn_col,
num_rows, num_cols);
if (knight_takes == num_moves) {
printf("Stalemate in %d knight move(s).\n", num_moves);
return;
}
cur_pawn_row++;
num_moves++;
}
➍ printf("Loss in %d knight move(s).\n", num_rows - pawn_row - 1);
}
列表 5-3:骑士的最佳结果(有错误!)
让我们通过分成三部分来研究这段代码,先抓住它的整体框架。
第一部分是检查骑士是否能获胜的代码。我们首先将棋子的行保存到一个新变量中 ➊——我们将移动棋子的行,因此需要记住它最初所在的行。while循环会一直执行,直到棋子到达最上面的一行。每次循环时,我们都会计算骑士需要多少步才能到达与棋子相同的位置。如果骑士能在棋子到达时同时到达 ➋,那么骑士就可以获胜。如果骑士无法获胜,棋子将到达棋盘顶部,我们将继续执行while循环之后的部分。
这就是第二段代码开始的地方 ➌。它的任务是判断骑士是否能造成和棋。该段代码与第一段相同,只是在while循环中检查骑士到达兵上方行的位置所需的步数,而不是兵的行位置。
第三段是一个单独的代码行 ➍,只有在骑士无法获胜或和棋时才会执行。这段代码只是输出失败信息。
这就是我们处理单个测试用例的方式。为了读取和处理所有测试用例,我们需要一个简单的main函数;它和列表 5-4 一样简单。
int main(void) {
int num_cases, i;
int num_rows, num_cols, pawn_row, pawn_col, knight_row, knight_col;
scanf("%d", &num_cases);
for (i = 0; i < num_cases; i++) {
scanf("%d%d", &num_rows, &num_cols);
scanf("%d%d", &pawn_row, &pawn_col);
scanf("%d%d", &knight_row, &knight_col);
solve(pawn_row, pawn_col, knight_row, knight_col, num_rows, num_cols);
}
return 0;
}
列表 5-4: 主 函数
感觉不错吧?我们现在有了完整的解决方案。我们使用广度优先搜索(BFS)来优化骑士所需的移动次数。我们在检查骑士的胜利、和棋和失败情况。现在把这个解决方案提交给评审。你还感觉不错吗?
骑士反转
在之前的章节中,我曾给你提供一些正确但速度太慢的解决方案,无法通过测试用例。相反,我在这里为骑士追逐问题提供的解法是错误的:有些测试用例会输出错误结果。我们的代码也恰好过于缓慢。
让我们来修复这个问题!
使我们的代码正确
我们的代码是错误的,因为它没有考虑到骑士有时会太快!也就是说,骑士可以在兵到达之前就到达兵的位置。因此,要求骑士和兵的移动次数完全相同的测试是过于严格的。
一个测试用例可以澄清这一点:
1
5
3
1
1
3
1
这是一个五行三列的棋盘;兵从第 1 行第 1 列开始,骑士从第 3 行第 1 列开始。以下是我们当前代码对此测试用例的输出:
Loss in 3 knight move(s).
(输出是3,而不是4,因为当兵到达顶行时,骑士不允许再移动。)这意味着不存在一个胜利或和棋位置,在该位置骑士的最少移动次数与兵的移动次数相同。至少这是事实。不过,骑士仍然可以在这里获胜,并且可以在两步内做到这一点。花点时间尝试找出骑士如何做到这一点吧!
如果兵在(2, 1)的位置,骑士不可能在一步内获胜。然而,经过两步之后,兵会到达(3, 1),此时骑士也有可能在两步后到达(3, 1)。以下是骑士可以采取的动作:
-
第 1 步:从(3, 1)到(5, 2)。
-
第 2 步:从(5, 2)回到(3, 1)。
骑士到达(3, 1)的最少步数是零——毕竟这是骑士的起始位置。通过先去其他地方再返回,骑士不仅可以在零步内到达(3, 1),也可以在两步内到达。
这是一个自检:将骑士的起始位置从(3, 1)改为(5, 3)。你能弄明白骑士如何在三步内获胜吗?
概括来说,我们可以说,如果骑士能够在最少的m步内到达某个方格,那么它也可以在m + 2 步、m + 4 步等步数内到达该方格。它所要做的只是不断去其他方格再返回。
这对我们的解决方案意味着,在每一步,骑士有两种方式可以获胜或和棋:一种是因为它的最少步数与兵的步数相同,另一种是因为它的最少步数是一个大于兵步数的偶数。
也就是说,之前是:
if (knight_takes == num_moves) {
我们需要的是:
if (knight_takes >= 0 && num_moves >= knight_takes &&
(num_moves - knight_takes) % 2 == 0) {
在这里,我们正在测试的是,兵的移动次数与骑士的移动次数之间的差是否是二的倍数。
列表 5-3 中有两个不正确的代码实例;更改这两个后,得到列表 5-5 中的(正确的!)代码。
void solve(int pawn_row, int pawn_col,
int knight_row, int knight_col,
int num_rows, int num_cols) {
int cur_pawn_row, num_moves, knight_takes;
cur_pawn_row = pawn_row;
num_moves = 0;
while (cur_pawn_row < num_rows) {
knight_takes = find_distance(knight_row, knight_col,
cur_pawn_row, pawn_col,
num_rows, num_cols);
➊ if (knight_takes >= 0 && num_moves >= knight_takes &&
(num_moves - knight_takes) % 2 == 0) {
printf("Win in %d knight move(s).\n", num_moves);
return;
}
cur_pawn_row++;
num_moves++;
}
cur_pawn_row = pawn_row;
num_moves = 0;
while (cur_pawn_row < num_rows) {
knight_takes = find_distance(knight_row, knight_col,
cur_pawn_row + 1, pawn_col,
num_rows, num_cols);
➋ if (knight_takes >= 0 && num_moves >= knight_takes &&
(num_moves - knight_takes) % 2 == 0) {
printf("Stalemate in %d knight move(s).\n", num_moves);
return;
}
cur_pawn_row++;
num_moves++;
}
printf("Loss in %d knight move(s).\n", num_rows - pawn_row - 1);
}
列表 5-5:骑士的最佳结果
正如承诺的那样,我们所做的只是更改了两个条件 ➊ ➋。现在代码通过了判定。
一个正确性论证
如果你确信正确性,可以跳过这一部分。否则,我现在想解决一个你可能有的疑虑。
假设骑士用m步到达了一个方格,且这个方格比兵的移动提前了偶数步。还假设骑士可以随意离开并重新访问这个方格,每次都会在m + 2 步、m + 4 步等步数后回到该方格,最终在这里追上兵。如果骑士能用其他移动顺序,在m + 1 步,或者m + 3 步等奇数步后追上兵,那就很可怕了,因为那样加上奇数步可能会提供比加偶数步更小的最短步数。幸运的是,这种情况不会发生。
试试这个小实验:选择一个起始点和目的地,找出骑士从起始点到目的地所需的最少步数。这个步数就是m。现在试着找到一种方法,让骑士从同样的起始点到同样的目的地,恰好用一步更多的步数,或者三步更多的步数,依此类推。例如,如果最快的方式需要两步,试着找到一个需要三步的方式。你是做不到的。
每次骑士移动都会让行号或列号变化两个单位,另一个变化一个单位。例如,它可能把行号从六变成四,把列号从四变成五。改变一个数字两个单位不会改变该数字是偶数还是奇数,但改变一个数字一个单位会让该数字从偶数变为奇数,反之亦然。也就是说,就偶数或奇数而言,每次移动都会保持其中一个数字(行号或列号)不变,改变另一个数字。当一个数字从偶数变为奇数或反之时,我们说它的奇偶性发生了变化。
设k为奇数。现在我们准备来看为什么骑士不能用m步和m + k步到达同一目的地。假设骑士可以用m步到达格子s,其中m[1]步改变行的奇偶性,m[2]步改变列的奇偶性。
假设m[1]和m[2]都是偶数。如此一来,这些步伐不会改变行或列的奇偶性:如果我们从某个数字开始,并将其奇偶性翻转偶数次,那么它的奇偶性不会改变。如果我们进行其他一系列的移动,且它改变了行的奇偶性奇数次或改变了列的奇偶性奇数次,那么该序列不能到达s,因为它将落在一个与s具有不同行或列奇偶性的格子上。
现在,m是总步数,m[1] + m[2]是偶数:两个偶数相加得偶数。但是,m + k是奇数,因为它是一个偶数和一个奇数的和。由于m + k是奇数,它不能由改变行奇偶性的偶数步和改变列奇偶性的偶数步构成;至少有一个必须是奇数,从而改变行或列的奇偶性。这就是为什么这些m + k步不能让骑士落在s的原因!(还有三种其他情况——m[1]偶数,m[2]奇数;m[1]奇数,m[2]偶数;m[1]奇数,m[2]奇数——但我跳过了这些。它们的分析是类似的。)
时间优化
我们当前的解决方案(示例 5-5)可能会进行很多 BFS 调用。每当兵向上一行移动时,我们就使用 BFS(通过调用find_distance)来判断骑士是否能在该位置抓住兵。
假设兵从(1,1)位置开始。我们从骑士的起始点运行 BFS 到(1,1),并探索了一些格子。假设骑士在这里无法抓住兵。接着,我们需要从骑士的起始点运行 BFS 到(2,1)。这也会探索一些格子。然而,(1,1)和(2,1)非常接近,以至于第二次 BFS 可能会重新探索很多在第一次 BFS 调用中已发现的最短距离。不幸的是,我们每次的 BFS 调用是独立的,所以第二次 BFS 会重新做很多第一次 BFS 已完成的工作。第三次调用又会重复前两次 BFS 做的工作,如此循环。
的确,广度优先搜索(BFS)很快,我将在下一节中详细解释原因。不过,减少 BFS 调用的次数是值得尝试的。
我有个好消息:我们可以将 BFS 调用的次数减少到仅仅一次!回想一下我们在列表 5-1 中的 BFS 代码。我们曾使用代码➍来缩短 BFS 过程,如果找到了目标位置就停止。但是,如果去掉这段代码,BFS 将遍历整个棋盘,计算每个方格的最短距离。做出这个改变后,我们只需要调用一次 BFS,然后就可以结束。从那时起,我们只需查看min_moves数组中的数据。
去做吧!对代码进行必要的更改,使得 BFS 仅被调用一次。
我们一起讨论的代码在提交给判题系统时需要 0.1 秒。经过“仅调用一次 BFS”的优化后,代码只需 0.02 秒,速度提升了 500%。更重要的是,这项优化展示了 BFS 不仅可以用来找到从起始位置到其他某个位置的最短距离,还可以用来找到从起始位置到所有其他位置的距离。我将在下一节中稍微多谈一些 BFS,之后继续阅读下去,因为我认为 BFS 的灵活性会让你感到惊讶。
图与广度优先搜索(BFS)
BFS 是一个强大的搜索算法,正如我们在解决骑士追逐问题时看到的那样。要运行 BFS,我们需要所谓的图。在解决骑士追逐问题时,我们并没有考虑图的概念——或者说,我们可能不知道它是什么!——但实际上,BFS 背后确实存在一个图。
什么是图?
图 5-1 是我们的第一个图的例子。

图 5-1:骑士移动的图
像树一样,图由节点(框框)和节点之间的边(线条)组成。在这个图中,边表示有效的骑士移动。例如,从(5, 1)节点,骑士可以沿着一条边移动到(4, 3),或者沿着另一条边移动到(3, 2)。(5, 1)没有其他的边,因此从这个位置没有其他骑士的移动。
现在我可以解释我们是如何隐式地使用图来解决骑士追逐问题的。假设(5, 1)是骑士的起始位置。我们的 BFS 尝试从那里进行八个移动,但其中六个会导致位置超出棋盘;用图论术语来说,这六个不算是(5, 1)的边。BFS 发现从(5, 1)出发,只有两个节点是可以通过边到达的: (4, 3)和(3, 2)。接下来,探索将继续进行,查找从这两个节点可以到达的其他节点,以此类推。
我将图形布局为网格,以反映底层棋盘,但图形的绘制方式并没有实际意义。真正重要的是节点和边。即使我将节点杂乱无章地分布开来,图形仍能传达相同的含义。然而,当图形基于某些底层几何结构时,以对应的方式展示图形就显得更为直观,便于理解。
为了解决骑士追逐问题,我们并不需要在代码中显式地表示图,因为在我们探索棋盘时,我们已经弄清楚了从每个节点出发的可用移动(边)。然而,有时我们确实需要在代码中显式表示一个图,类似于我们在第二章中对树的表示。我们将在问题 3 中看到如何做。
图与树
图和树有很多相似之处。它们都用于表示节点之间的关系。事实上,每一棵树都是一个图,但也有一些图不是树。图更一般化,可以表示树无法表示的内容。
首先,图(但不是树)允许循环。如果一个图中我们可以从一个节点出发,经过一些边返回到这个节点,并且没有使用任何重复的边或节点,那么这个图中就有一个循环。(循环中的第一个和最后一个节点是唯一重复的。)回顾图 5-1,在这个图中有一个循环:(5, 3) → (4, 5) → (3, 3) → (4, 1) → (5, 3)。
第二,图(但不是树)可以是有向的。我们到目前为止看到的树和图都是无向的,这意味着如果两个节点a和b通过一条边连接,那么我们既可以从a到b,也可以从b到a。 图 5-1 中的图是无向的;例如,我们可以沿着一条边从(5, 3)到(4, 5),并用同一条边从(4, 5)回到(5, 3)。然而,有时我们只想允许单向旅行,而不允许反向。有向图是指每条边表示允许的旅行方向。图 5-2 展示了一个有向图。

图 5-2:有向图
请注意,在图 5-2 中,如何可以从 E 节点移动到其他每一个节点,但无法从这些节点中的任何一个移动到 E 节点。边是单向的。
有向图在无向图可能导致信息丢失的情况下非常有用。在我的计算机科学系,每个课程都有一个或多个先修课程。例如,我们有一门 C 编程课程,要求学生已经学习过我们的软件设计课程。一个有向边“软件设计 → C 编程”表示了这个关系。如果我们使用无向边,虽然我们仍然知道这些课程之间有关系,但我们就无法知道课程必须按照什么顺序来修。 图 5-3 展示了一个小的先修课程图。

图 5-3:课程先修图
图表比树更一般化的第三个特点是图可以是不连通的。到目前为止我们看到的所有树和图都是连通的,这意味着你可以从任意一个节点到达任何其他节点。现在看看图 5-4 中的不连通图。

图 5-4:不连通的课程先修图
它是断开的,因为例如,你不能从编程入门跳到世界史前。当一个图自然由多个独立部分组成时,断开图是非常有用的。
图上的 BFS
我们可以在无向图(正如我们在“骑士追逐问题”中所做的那样)或有向图上运行广度优先搜索(BFS)。算法是相同的:我们从当前节点开始,遍历可能的移动并探索它们。BFS 被称为最短路径算法:在从起始节点到其他节点的所有路径中,BFS 会给我们返回最短的一条(以边数为标准)。只要我们关心的是最小化边数,它就能解决单源最短路径问题,因为它找到的是从单一源(或起始)节点到其他节点的最短路径。
为了让 BFS 运行更快,我们需要控制的不是图是有向图还是无向图,而是我们调用 BFS 的次数和图中的边数。BFS 调用的运行时间与从起始节点可达的边数成正比。这是因为 BFS 每次检查每条边,判断它是否会发现一个新的节点。我们称 BFS 为线性时间算法,因为它的工作量是与边数成线性关系的:如果 5 条边需要 5 步来探索,那么 10 条边需要 10 步。我们将使用边的数量来估算 BFS 执行的步数。
在“骑士追逐问题”中,我们有一个r行和c列的棋盘。每个节点最多有八条边,因此棋盘总共有最多 8rc条边。因此,运行一次 BFS 需要 8rc步。对于最大的棋盘 99×99,这还不到 80,000 步。如果我们像在清单 5-5 中那样调用 BFS r次,那么我们就需要进行 8r²c步。现在 99×99 的棋盘看起来就不那么理想了:可能需要超过 700 万步。这就是为什么减少 BFS 调用次数会有如此大的帮助!
每当一个问题涉及一组对象(如棋盘位置、课程、人员、网站等)以及这些对象之间的关系时,将该问题建模为图通常是一个好方法。一旦将问题建模为图,你就可以利用图论中大量的快速算法。BFS 就是其中之一。
图论与动态规划
有时候很难判断是使用动态规划还是图论来解决一个问题。一个明显的标志通常是是否存在循环:如果有循环,那么你需要使用图论。
我们在第三章和第四章中解决的所有问题都没有循环。在《汉堡热潮》中,我们使用了更少的时间递归。在《贪婪的商人》中,我们使用了更少的苹果进行递归。在《冰球对决》中,我们使用了更少的比赛进行递归。我们总是向下走——没有办法回到更高的时间、苹果或比赛数值,从而形成循环。
这个结论对于 第四章中的跳跃者来说更难以察觉,但它确实成立。回想我们之前的前向推导。如果我们向右跳,那么我们就会递归地增加跳跃距离。如果我们向左跳,那么我们会以相同的跳跃距离递归,但方格编号会更小。没有办法从某个子问题开始,然后通过这些跳跃回到原点。你可能尝试先向左跳,再向右跳,但向右跳时,跳跃距离会增加一个单位,而你永远无法再减少它。
没有循环!
问题 2:爬绳
在骑士追逐问题中,我们明确给出了一个棋盘,游戏将在其上进行。在这里,我们不会直接给出棋盘,所以我们需要自己推导出来。策略仍然是使用 BFS 模拟有效的移动。
这是 DMOJ 问题 wc18c1s3。
问题
Bob 被要求在体育课上爬一根绳子。绳子是无限长的,但 Bob 被要求至少达到 h 米的高度。
Bob 从 0 米的高度开始。他知道如何跳升恰好 j 米,但这就是他唯一能做的跳跃——所以如果 j 是 5,他就不能跳升四米、六米或其他任意的米数。另外,Bob 知道如何下落,他可以下落任何米数:一米、两米、三米,以此类推。
每一次跳跃或下落都算作一次移动。例如,如果 Bob 跳升五米,下落两米,再跳升五米,然后下落八米,那么 Bob 将做出四次移动。
现在,这就是有趣的部分:Alice 在绳索的某些段落上撒了痒粉。如果某段绳索的高度从 a 变化到 b,那么从 a 到 b 的整个段落,包括端点 a 和 b,都会有痒粉。痒粉对 Bob 移动的影响如下:
-
如果跳跃 j 米会让 Bob 落到痒粉上,那么 Bob 不能跳跃。
-
如果跳跃会让 Bob 落到痒粉上,那么 Bob 就不能下落特定的米数。
目标是确定 Bob 到达高度 h 或更高所需的最少移动次数。
输入
输入包含一个测试用例,包含以下行:
-
一行包含三个整数:h、j 和 n。h 告诉我们 Bob 必须达到的最低高度,j 是 Bob 每次能跳跃的距离,n 是 Alice 撒上痒粉的段数。每个整数最大为 1,000,000,且 j 不超过 h。
-
n 行,每行包含两个整数。第一个整数给出一段有痒粉的绳索的起始高度,第二个整数给出结束高度。每个整数最大为 h - 1。
输出
输出 Bob 达到高度 h 或更高所需的最少移动次数。如果 Bob 无法达到高度 h 或更高,则输出 -1。
解决测试用例的时间限制为 1.8 秒。
解法 1:寻找移动次数
让我们通过直接与骑士追逐问题进行比较来开始。注意,在这两种情况下,我们的目标都是最小化移动的次数。无论是棋盘上的骑士,还是绳子上的鲍勃,目标都是一样的。虽然骑士在二维棋盘上移动,而鲍勃在一维绳子上移动,但这只是改变了我们如何描述每个位置。BFS(广度优先搜索)不会在意从二维到一维的变化。如果有什么不同的话,减少一个维度反而让事情变得简单了一些!
那么每个位置的可能移动次数呢?骑士最多有八种移动方式。相对而言,鲍勃的可移动次数随着他的当前位置而增加。例如,如果鲍勃处于高度 4,并且他能跳升 5 个单位,那么他有五种可能的移动方式:跳升 5,降下 1,降下 2,降下 3,或降下 4。如果鲍勃在高度 1000,那么他有 1001 种可能的移动方式!因此,我们在确定可用的移动次数时,必须考虑鲍勃的当前位置。
那痒粉呢?骑士追逐问题中并没有类似的东西。让我们通过一个测试案例来看看我们遇到的情况:
10 4 1
8 9
鲍勃必须达到 10 或更高的高度。他可以跳升 4 个单位。因此,如果没有痒粉,他就能从高度 0 跳到 4,再跳到 8,最后跳到 12。这是三次移动。
不过,鲍勃不能这样做!他不能从 4 跳到 8,因为 8 处有痒粉(痒粉从 8 到 9)。考虑到痒粉的影响,解决方案是四次移动。例如,鲍勃可以从 0 跳到 4,然后降到 3,再跳到 7,最后跳到 11。那从 7 到 11 的跳跃轻松避开了痒粉。
从 4 到 8 的移动似乎是可行的,因为鲍勃能够跳升 4 个单位,但实际上它不可行,因为有痒粉。这与骑士的某些不可行的移动类似,后者是因为该移动会导致骑士超出棋盘范围。对于这些无效的骑士移动,我们在 BFS 中进行了检测,并没有将它们添加到下一轮的位置中。我们将以类似的方式处理痒粉:任何导致鲍勃落到痒粉上的移动都会在我们的 BFS 代码中被禁止。
说到那些无效的骑士移动,它们会让骑士超出棋盘范围,我们在这里需要担心这种情况吗?绳子是无限长的,所以我们不会通过让 Bob 不断攀爬而违反任何规则。然而,某个时刻我们确实需要停止;否则,BFS 将永远在寻找并探索新位置。我将引用《Moneygrubbers》第三章中的洞察力,这帮助我们在购买苹果时解决了一个非常相似的困境。我们曾说过,如果要求我们购买 50 个苹果,那么我们应该考虑最多购买 149 个苹果,因为每种定价方案最多给我们 100 个苹果。在这里,请记住从问题描述中得知,j,Bob 的跳跃距离,最多为h,目标最小高度。因此,我们不应该让 Bob 的高度达到 2h或更高。想一想,当我们第一次让 Bob 达到高度 2h或更高时会发生什么。在前一次移动中,Bob 的高度是 2h – j ≥ h,那样就比将 Bob 移到高度 2h少了一步!因此,让 Bob 达到 2h或更高的高度不能是将他至少带到高度h的最快方式。
实现广度优先搜索
我们将紧密跟随当时在骑士追击问题中所做的工作,只在必要时进行修改。那时,每个骑士的位置由行和列组成,因此我们创建了一个结构体来保存这两个信息。而现在,绳上的位置只是一个整数,因此我们不需要结构体。我们将为“棋盘”和 BFS 发现的位置创建类型定义:
#define SIZE 1000000
typedef int board[SIZE * 2];
typedef int positions[SIZE * 2];
我想,称绳子为棋盘可能有点奇怪,但它和骑士追击问题中相应类型定义的作用是一样的,所以我们还是使用这个词吧。
我们最终将进行一次 BFS 调用,这个调用将计算 Bob 从零高度到达每个有效位置的最小移动次数。BFS 的代码在列表 5-6 中给出——可以与列表 5-1 中的find_distance代码进行对比。(特别是,将其与我希望你在阅读《时间优化》一节后编写的代码进行比较,参见第 168 页。)
void find_distances(int target_height, int jump_distance,
int itching[], board min_moves) {
static positions cur_positions, new_positions;
int num_cur_positions, num_new_positions;
int i, j, from_height;
for (i = 0; i < target_height * 2; i++)
➊ min_moves[i] = -1;
min_moves[0] = 0;
cur_positions[0] = 0;
num_cur_positions = 1;
while (num_cur_positions > 0) {
num_new_positions = 0;
for (i = 0; i < num_cur_positions; i++) {
from_height = cur_positions[i];
➋ add_position(from_height, from_height + jump_distance,
target_height * 2 - 1,
new_positions, &num_new_positions,
itching, min_moves);
➌ for (j = 0; j < from_height; j++)
add_position(from_height, j,
target_height * 2 - 1,
new_positions, &num_new_positions,
itching, min_moves);
}
num_cur_positions = num_new_positions;
for (i = 0; i < num_cur_positions; i++)
cur_positions[i] = new_positions[i];
}
}
列表 5-6:Bob 使用 BFS 的最小移动次数
这个find_distances函数有四个参数:
target_height Bob 必须达到的最小高度。它是测试用例中的h值。
jump_distance Bob 可以跳跃的距离。它是测试用例中的j值。
itching 一个数组,用于指示哪里有痒粉。如果itching[i]为0,则在高度i处没有痒粉;否则,表示有痒粉。(展望未来,我们需要根据测试用例中给出的痒绳段来构建这个数组。但我们能够做到这一点,这样就不需要担心具体的绳段本身了:我们只需索引这个数组即可。)
min_moves 存储到达每个位置的最少移动次数的棋盘。
如在列表 5-1 中为骑士追逐初始化,我们将棋盘上的每个位置初始化为-1 ➊,这意味着 BFS 尚未找到该位置。与这里对board的其他操作一样,这种初始化操作索引的是一维(而非二维!)数组。除此之外,结构与骑士追逐的 BFS 代码非常相似。
然而,代码中有一个有趣的结构变化,增加了位置的处理。Bob 只有一个跳跃距离,因此只有一个跳跃动作需要考虑 ➋:Bob 从from_height开始,如果是有效位置,最终到达from_height + jump_distance。我们可以使用target_height * 2 - 1来获得 Bob 允许达到的最大高度。对于下落,我们不能硬编码 Bob 可用的跳跃;这些跳跃取决于 Bob 当前的高度。为了解决这个问题,我们使用一个循环 ➌ 来考虑所有从 0(地面)到不包括from_height(Bob 当前高度)的目标高度。这个循环是与骑士追逐 BFS 唯一显著的不同。
为了完成我们的 BFS 代码,我们需要实现 add_position 辅助函数。该代码在列表 5-7 中给出。
void add_position(int from_height, int to_height, int max_height,
positions new_positions, int *num_new_positions,
int itching[], board min_moves) {
if (to_height <= max_height && itching[to_height] == 0 &&
min_moves[to_height] == -1) {
min_moves[to_height] = 1 + min_moves[from_height];
new_positions[*num_new_positions] = to_height;
(*num_new_positions)++;
}
}
列表 5-7:添加一个位置
Bob 想从from_height移动到to_height。如果通过三个测试,这个移动是允许的。首先,Bob 不能跳跃超过最大允许高度。其次,他不能跳到有痒粉的位置。第三,min_moves棋盘上最好不要已经为to_height记录了移动次数:如果其中已经有一个值,则意味着有更快速的方式到达to_height。如果通过了这些测试,那么我们找到了一个新的有效位置;我们设置到达那里所需的移动次数,然后将其作为下次 BFS 的位置。
寻找最佳高度
Bob 最终可能达到的目标位置有很多种。它可能是测试用例中的目标高度 h。然而,取决于 j 和痒粉的影响,它可能会更高。我们知道每个位置达到所需的最少移动次数。现在我们要做的是检查所有候选位置,选择那个最小化移动次数的位置。该代码在列表 5-8 中给出。
void solve(int target_height, board min_moves) {
➊ int best = -1;
int i;
for (i = target_height; i < target_height * 2; i++)
➋ if (min_moves[i] != -1 && (best == -1 || min_moves[i] < best))
best = min_moves[i];
printf("%d\n", best);
}
列表 5-8:最少移动次数
有可能 Bob 无法到达目标高度,因此我们将best初始化为-1 ➊。对于每个候选高度,我们检查 Bob 是否能够到达。如果可以,并且这样做比当前的最少移动次数 best ➋ 更快,那么我们相应地更新 best。
现在我们已经有了处理测试用例并输出结果的所有代码。剩下的就是读取输入了。列表 5-9 中的main函数完成了这一部分。
int main(void) {
int target_height, jump_distance, num_itching_sections;
static int itching[SIZE * 2] = {0};
static board min_moves;
int i, j, itch_start, itch_end;
scanf("%d%d%d", &target_height, &jump_distance, &num_itching_sections);
for (i = 0; i < num_itching_sections; i++) {
scanf("%d%d", &itch_start, &itch_end);
➊ for (j = itch_start; j <= itch_end; j++)
➋ itching[j] = 1;
}
find_distances(target_height, jump_distance, itching, min_moves);
solve(target_height, min_moves);
return 0;
}
列表 5-9:主 函数
正如大数组的常见做法,我们将 itching 和 min_moves 设置为静态。itching 数组的元素初始化为 0,这意味着绳索上还没有痒粉。对于绳索上有痒粉的每一段,我们遍历范围 ➊ 中的每个整数,并将对应的 itching 元素设置为 1 ➋。一旦完成对痒段的遍历,itching 数组的每个索引告诉我们该位置的绳索上是否有痒粉(值为 1)或没有(值为 0)。我们不再关心单独的痒段本身——我们已经在 itching 中得到了所需的全部信息。
就是这样。我们得到了一个使用单次 BFS 调用的解决方案。是时候提交给评测系统了。正如有人说的那样,Bob’s your uncle……
或者,希望他会,但他还没准备好。因为你应该会收到一个 “超时” 错误,代码会出错。
解决方案 2:重构
让我们运行逐渐增大的测试用例,以了解我们的运行时间如何增长。为了简化,我们将不使用任何痒粉。以下是第一个测试用例:
30000 5 0
这是一个至少目标高度为 30,000,跳跃距离为 5 的问题。在我的笔记本上,这大约需要 8 秒。现在让我们将目标高度再翻倍:
60000 5 0
我估计这里需要大约 30 秒。这几乎是之前情况的四倍时间。我们早已超过了 1.8 秒的时间限制,但我们再做一次,目标高度再翻倍:
120000 5 0
这使得运行时间极其缓慢,达到了 130 秒,几乎是上一个测试用例的四倍增长。也就是说,看起来输入大小翻倍会导致运行时间乘以四。这并不像我们在 第三章 中解决“汉堡狂热问题”的“解决方案 2:记忆化”时看到的那样灾难性,但显然还是太慢了。
太多的下降边
在 “图上的 BFS” 这一节中,第 172 页,我曾警告过使用 BFS 时需要控制两件事:调用 BFS 的次数和图中边的数量。对于 BFS 调用次数,我们已经做到最好,因为我们只调用了一次 BFS。为了进一步基于 BFS 寻找解决方案,我们需要减少图中边的数量。
让我们看一下 图 5-5 中的小例子图。然后我们可以推测更大的例子,并看到为什么我们的代码会如此缓慢。

图 5-5:Bob 的移动图
该图展示了从高度 0 到高度 7 可用的移动,如果我们假设 Bob 可以跳跃 3 个单位。这是一个有向图的例子;例如,请注意,存在从 6 到 5 的移动,但没有从 5 到 6 的移动。
图中包含了跳跃边(jump edges)和跌落边(fall edges),跳跃边表示 Bob 可能的跳跃,跌落边表示 Bob 可能的跌落。跳跃边从底部到顶部,而跌落边则从顶部到底部。例如,从高度 0 跳到高度 3 是一条跳跃边;上述从 6 到 5 的边是跌落边。
跳跃边的数量根本不值得担心。我们每个节点最多只有一条跳跃边。如果我们有 n 个节点,那么我们最多有 n 条跳跃边。如果我们决定将高度限制提高到 8,而不是 7,那么我们只需添加一条新的跳跃边。
然而,跌落边的数量增长速度要快得多。请注意,从高度 1 有一条跌落边,从高度 2 有两条跌落边,从高度 3 有三条跌落边,依此类推。也就是说,对于高度为 h 的绳索,我们总共有 1 + 2 + 3 + ... + h 条跌落边。如果我们想知道给定绳索高度的跌落边数量,我们可以将 1 到该高度的整数加起来。然而,有一个更方便的公式可以让我们更快速地得到答案。它是 h(h + 1)/2。例如,对于高度为 50 的绳索,我们有 50(51)/2 = 1,275 条跌落边。对于高度为两百万的绳索,我们将有超过两万亿条跌落边。
回到第一章,我们在“诊断问题”部分(第 9 页)看到了一个非常相似的公式,当时我们在计算雪花对的数量。和那个公式一样,我们这里的公式是二次的,即 O(h²),正是这种在跌落边上的二次增长影响了我们的算法。
改变动作
如果我们要减少图中的边数,那么我们必须改变图中编码的可用动作。我们不能改变 Bob 在体育课上玩的实际游戏规则,但我们可以改变我们图中模型的游戏动作。当然,只有当在新图上进行 BFS 搜索得到的结果与旧图一致时,我们才可以改变图。
这里有一个重要的教训。将现实世界问题中的可用动作逐一映射到图中是非常诱人的。我们在骑士追逐问题中就是这么做的,并且成功地解决了问题。虽然这可能很诱人,但并不是必须的。我们可以构造一个不同的图,拥有更理想的节点或边的数量,只要该图仍然能给出原问题的答案。
假设我们想从五米的高度下落一段距离。一种可能是下落四米。实际上,按照方案 1 解决问题的话,会有一条从高度 5 到 1 的下落边。然而,另一种看待这个下落的方式是把它看作是四次每次下落一米。也就是说,我们可以想象 Bob 从 5 米掉到 4 米,再掉到 3 米,接着掉到 2 米,最后掉到 1 米。也就是说,我设想每一条下落边的长度恰好为一米。没有从 5 米到 3 米,或从 5 米到 2 米,或者从 5 米到 1 米,甚至从 5 米到 0 米的那些下落边。每个节点只会有一条下落边,让我们下降一米。这应该大大减少下落边的数量!
不过我们得小心。不能把每一个一米的小跌落都算作一次动作。如果 Bob 掉落了四米,使用了四次一米的跌落边,那么我们仍然应该把它算作一次动作,而不是四次动作。
假设我们有两条绳子(0 和 1),而不是一条。绳子 0 是我们一直有的那条,Alice 布置的,可能上面有痒粉。绳子 1 是我们新设的,目的是建模。它没有痒粉。而且,当 Bob 在绳子 1 上时,他不能向上移动。
当 Bob 想要进行一个下降动作时,他将从绳子 0 移动到绳子 1\。他会一直保持在绳子 1 上,尽情下降,直到他想停止为止。然后,在绳子 0 上没有痒粉的地方,他可以通过回到绳子 0 来结束他的下降动作\。具体来说,现在我们有以下几种动作:
-
当 Bob 在绳子 0 上时,他有两种可能的动作:跳升j米,或者移动到绳子 1\。每个动作都需要消耗一次移动。
-
当 Bob 在绳子 1 上时,他有两种可能的动作:下落一米,或者移动到绳子 0\。每个动作都不算移动。没错,这些动作是免费的!
Bob 像以前一样使用绳子 0 跳升。当他想要下落时,他移动到绳子 1(这花费他一次移动),在绳子 1 上自由下落(这不花费任何移动),然后再回到绳子 0(这也不花费移动)。整个下落过程对 Bob 来说只消耗了一次移动。完美——这和之前一样!没有人会知道我们使用了两条绳子而不是一条。
将图 5-5 与其大量的边进行对比,看看图 5-6,后者描绘了双绳操作。

图 5-6:使用两条绳子的 Bob 动作图
事实上,我们确实是把节点数加倍了,但这没关系:对于 BFS 来说,我们关心的不是节点数,而是边的数量。在这一方面,我们就轻松多了。每个节点最多有两条边:在绳子 0 上,我们有一条跳跃边和一条移到绳子 1 的边;在绳子 1 上,我们有一条下落边和一条移到绳子 0 的边。也就是说,对于高度h,我们大约有 4h条边。这个是线性的!我们避免了那种复杂的二次h²的情况。
我在每条边上标注了是否需要消耗一步(1)或者不需要(0)。这是我们第一次遇到加权图,每条边都有一个权重或代价。
添加位置
我们已经绕回到一个二维的棋盘。(你好,骑士追逐!)我们需要一个维度来表示 Bob 的高度,第二个维度表示 Bob 所在的绳索。对于第二个维度,标准术语是状态。当 Bob 在绳索 0 上时,我们说他处于状态 0;当 Bob 在绳索 1 上时,我们说他处于状态 1。从现在开始,我们将使用“状态”而不是“绳索”。
这里是新的typedef定义:
typedef struct position {
int height, state;
} position;
typedef int board[SIZE * 2][2];
typedef position positions[SIZE * 4];
我们将不再从find_distances开始(像本章之前那样),而是从add_position函数开始。没错,是函数的复数形式,因为我们将把每种类型的移动编码成各自的函数。移动有四种类型:跳跃向上、下落、从状态 0 到状态 1 的移动,以及从状态 1 到状态 0 的移动。因此,我们需要四个add_position函数。
跳跃向上
跟踪跳跃边的代码见示例 5-10。
void add_position_up(int from_height, int to_height, int max_height,
positions pos, int *num_pos,
int itching[], board min_moves) {
➊ int distance = 1 + min_moves[from_height][0];
if (to_height <= max_height && itching[to_height] == 0 &&
➋ (min_moves[to_height][0] == -1 ||
min_moves[to_height][0] > distance)) {
min_moves[to_height][0] = distance;
pos[*num_pos] = (position){to_height, 0};
(*num_pos)++;
}
}
示例 5-10:添加一个位置:向上跳跃
这个函数涉及从from_height跳跃到to_height。这种移动仅在状态 0 中允许;因此,每当我们索引min_moves时,第二个索引将使用0。
这段代码与示例 5-7 类似,但做了一些重要的修改。首先,我将new_positions改为pos,并将num_new_positions改为num_pos。我们将在讲解完四个函数后,讨论为什么要将这些参数名改为更通用的名称。
其次,为了方便比较四个函数,我添加了一个distance变量 ➊,表示通过from_height到达to_height所需的步数。这里,它比到from_height的最小步数多一步,因为我们为这次跳跃付出了一次移动。
最后,我修改了 if 条件中检查是否找到了新位置的部分 ➋。这是因为一个位置可能通过一个算作一步的边被发现,但它也可能稍后通过一条不算作移动的边被重新发现。我们希望允许最小步数通过那些不消耗代价的边进行更新和改进。(跳跃向上不是一条不消耗代价的边,因此我们在这里不需要这个修改;但为了在四个函数之间保持一致性,我仍然保留了这个改动。)
下落
现在,让我们来看一下示例 5-11 中给出的下落代码。
void add_position_down(int from_height, int to_height,
positions pos, int *num_pos,
board min_moves) {
➊ int distance = min_moves[from_height][1];
if (to_height >= 0 &&
(min_moves[to_height][1] == -1 ||
min_moves[to_height][1] > distance)) {
min_moves[to_height][1] = distance;
pos[*num_pos] = (position){to_height, 1};
(*num_pos)++;
}
}
示例 5-11:添加一个位置:下落
下落只能发生在状态 1 中;这就是为什么每当我们访问min_moves时,第二个索引是1。另外,这里没有使用痒粉的情节。Bob 可以在状态 1 中随意下落,不必担心痒粉。最后,关于计算距离的一个关键点是,距离中没有加上+ 1 ➊!
记住:这不算作一步移动。
切换状态
还有两个函数。首先是列表 5-12 中的从状态 0 移动到状态 1 的函数。
void add_position_01(int from_height,
positions pos, int *num_pos,
board min_moves) {
int distance = 1 + min_moves[from_height][0];
if (min_moves[from_height][1] == -1 ||
min_moves[from_height][1] > distance) {
min_moves[from_height][1] = distance;
pos[*num_pos] = (position){from_height, 1};
(*num_pos)++;
}
}
列表 5-12:添加位置:从状态 0 移动到状态 1
接着是从状态 1 移动到状态 0 的函数,见列表 5-13。
void add_position_10(int from_height,
positions pos, int *num_pos,
int itching[], board min_moves) {
int distance = min_moves[from_height][1];
if (itching[from_height] == 0 &&
(min_moves[from_height][0] == -1 ||
min_moves[from_height][0] > distance)) {
min_moves[from_height][0] = distance;
pos[*num_pos] = (position){from_height, 0};
(*num_pos)++;
}
}
列表 5-13:添加位置:从状态 1 移动到状态 0
从状态 0 移动到状态 1 需要一步,但从状态 1 移动到状态 0 则不需要。同样需要注意的是,只有在该高度没有痒粉时,我们才允许从状态 1 移动到状态 0。如果没有这个检查,我们就有可能在有痒粉的绳索段上停下来,这就违反了规则。
0-1 BFS
现在是时候将状态纳入到来自列表 5-6 的find_distances代码中了。不过,我们最好小心一些,以免误算步数。
这是一个示例。我将用(h, s)来表示 Bob 在高度h的状态s下。假设 Bob 可以跳跃三步。Bob 从(0, 0)开始,并且到达那里不需要任何移动。从(0, 0)开始探索时,我们会找到(0, 1)作为一个新位置,并记录到达那里需要一步。这将被加入到下一轮 BFS 的位置中。我们还会发现(3, 0),并同样记录到达那里需要一步。这也是下一轮 BFS 的位置。这就是标准的 BFS 操作。
从(3, 0)开始探索时,我们会发现新的位置(3, 1)和(6, 0)。这两个位置都会被加入到下一轮 BFS 中,并且都能在最少两步内到达。
然而,我们需要小心位置(3, 1)。我们知道(2, 1)可以从这里到达,因此很容易把它加到下一轮的 BFS 中。但是如果这么做,我们就不再做 BFS 了。我们应该把那些距离当前轮中位置只有一步之遥的位置放入下一轮 BFS 中。那么,(2, 1)离(3, 1)多一步吗?不!它们到(0, 0)的步数是相同的,因为在状态 1 中掉落是免费的。
也就是说,(2, 1)不会进入下一轮的 BFS。它会进入当前轮的 BFS,就像(3, 1)以及所有最小移动数为二的其他位置一样。
总结一下,每当我们沿着一条需要花费一步的边移动时,我们会将新位置添加到下一轮 BFS 中。这就是我们一直以来的做法。然而,当我们沿着一条免费边移动时,我们则将其添加到当前轮的 BFS 中,以便它能与其他距离相同的位置信息一起处理。这就是我们在“添加位置”部分中移开new_positions和num_new_positions的原因,参见第 182 页。有两个函数确实会将步数加到新位置,但另外两个会将步数加到当前的位置。
这种 BFS 的变种被称为 0-1 BFS,因为它适用于边的移动费用为零或一的图。
最后,是时候进行 BFS 了。可以在 列表 5-14 中查看。
void find_distances(int target_height, int jump_distance,
int itching[], board min_moves) {
static positions cur_positions, new_positions;
int num_cur_positions, num_new_positions;
int i, j, from_height, from_state;
for (i = 0; i < target_height * 2; i++)
for (j = 0; j < 2; j++)
min_moves[i][j] = -1;
min_moves[0][0] = 0;
cur_positions[0] = (position){0, 0};
num_cur_positions = 1;
while (num_cur_positions > 0) {
num_new_positions = 0;
for (i = 0; i < num_cur_positions; i++) {
from_height = cur_positions[i].height;
from_state = cur_positions[i].state;
➊ if (from_state == 0) {
add_position_up(from_height, from_height + jump_distance,
target_height * 2 - 1,
new_positions, &num_new_positions,
itching, min_moves);
add_position_01(from_height, new_positions, &num_new_positions,
min_moves);
} else {
add_position_down(from_height, from_height - 1,
cur_positions, &num_cur_positions, min_moves);
add_position_10(from_height,
cur_positions, &num_cur_positions,
itching, min_moves);
}
}
num_cur_positions = num_new_positions;
for (i = 0; i < num_cur_positions; i++)
cur_positions[i] = new_positions[i];
}
}
列表 5-14:使用 0-1 BFS 计算 Bob 的最小移动次数
新代码检查当前位置是否处于状态 0 或状态 1 ➊。每种情况下,都有两种移动需要考虑。在状态 0 中,使用的是新位置(即下一轮 BFS 的位置);在状态 1 中,使用的是当前的位置。
main 和 solve 函数怎么办?对于 main,我们可以使用解决方案 1 中的相同函数。对于 solve,我们只需要在每次索引 min_moves 时添加状态 0。如果您进行这些更改并提交给判题系统,您将看到所有测试都能通过,且时间充裕。
问题 3:书籍翻译
在骑士追逐和绳索攀爬问题中,输入中没有显式的图需要读取;BFS 在探索过程中逐步生成图。现在,我们将看到一个问题,其中图已经提前呈现给我们。
这是 DMOJ 问题 ecna16d。
问题
您已经用英语写了一本新书,并且希望将这本书翻译成 n 种其他目标语言。您找到 m 名翻译员。每个翻译员知道如何翻译两种语言之间的内容,并且会按给定的费用进行翻译。例如,某个翻译员可能知道如何以 1,800 美元的费用翻译西班牙语和孟加拉语之间的内容;这意味着您可以要求该翻译员以 1,800 美元将西班牙语翻译成孟加拉语,或将孟加拉语翻译成西班牙语。
要到达一个给定的目标语言,可能需要多次翻译。例如,您可能希望将您的书从英语翻译成孟加拉语,但两种语言之间没有翻译员。您可能需要先将书从英语翻译成西班牙语,然后再从西班牙语翻译成孟加拉语。
为了减少翻译错误,您将最小化达到每种目标语言所需的翻译次数。如果有多种方式可以以最小的翻译次数达到某个目标语言,则选择成本最小的一种。您的目标是最小化每种目标语言的翻译次数;如果有多种方式可以实现此目标,则选择总成本最小的一种。
输入
输入包含一个测试用例,由以下几行组成:
-
一行包含两个整数 n 和 m。n 是目标语言的数量;m 是翻译员的数量。最多有 100 种目标语言和最多 4,500 名翻译员。
-
一行包含 n 个字符串,每个字符串表示一种目标语言。
English不是目标语言。 -
m 行,每行给出一个翻译员的信息。每行包含三个由空格分隔的标记:一种语言、第二种语言和它们之间的正整数翻译费用。每对语言最多有一个翻译员。
输出
输出将书籍翻译成所有目标语言的最小货币成本,同时最小化到每种目标语言的翻译次数。如果无法将书籍翻译成所有目标语言,输出 Impossible。
解这个测试用例的时间限制是 0.6 秒。
阅读语言名称
我们不直接使用语言名称——英语、西班牙语等——而是将每种语言与一个整数关联。英语将是语言 0,其他目标语言将分配一个大于 0 的唯一整数。然后我们可以继续使用整数,就像我们在本章的其他问题中所做的那样。
这里有一个问题:问题描述没有告诉我们语言名称的最大长度。因此,我们无法硬编码一个最大语言名称长度,比如 16 或者 100,因为我们无法控制输入。为了应对这种情况,我们使用了一个 read_word 辅助函数;见清单 5-15。
/* based on https://stackoverflow.com/questions/16870485 */
char *read_word(int size) {
char *str;
int ch;
int len = 0;
str = malloc(size);
if (str == NULL) {
fprintf(stderr, "malloc error\n");
exit(1);
}
➊ while ((ch = getchar()) != EOF && (ch != ' ') && (ch != '\n')) {
str[len++] = ch;
if (len == size) {
size = size * 2;
➋ str = realloc(str, size);
if (str == NULL) {
fprintf(stderr, "realloc error\n");
exit(1);
}
}
}
➌ str[len] = '\0';
return str;
}
清单 5-15:读取单词
read_word 函数接受一个初始大小,我们希望它能满足大部分或所有语言名称的需求。当我们调用该函数时,我们会提供一个初始大小为 16,因为这涵盖了我们可能看到的大多数语言名称。我们可以使用 read_word 来读取字符➊,直到数组达到最大长度;如果数组已满但语言名称仍未结束,它将使用 realloc 来将数组长度加倍➋,从而创建更多空间以读取更多字符。我们小心地用空字符来终止 str➌;否则它就不是一个有效的字符串!
构建图
现在让我们来构建一个图,来自输入数据。这将帮助我们探索每种语言之间允许的翻译。
让我们来处理一个小的测试用例:
3 5
Spanish Bengali Italian
English Spanish 500
Spanish Bengali 1800
English Italian 1000
Spanish Italian 250
Bengali Italian 9000
你能构建出这个图吗?哪些是节点,哪些是边?它是无向图还是有向图?是加权图还是无权图?
一如既往,边表示允许的移动;在这里,一个移动对应于两种语言之间的翻译。因此,节点就是语言。一个从语言 a 到语言 b 的边意味着这两种语言之间有翻译者。翻译者可以从 a 翻译到 b 或反之——所以图是无向的。它也是加权的,因为每条边(即翻译)都有一个权重(翻译成本)。该图如图 5-7 所示。

图 5-7:翻译图
为了到达所有目标语言的总翻译成本是:英语到西班牙语是 500 美元,英语到意大利语是 1000 美元,西班牙语到孟加拉语是 1800 美元。总共是 3300 美元。不要被那个迷人的 250 美元的西班牙语–意大利语翻译所吸引:使用它将导致英语到意大利语的距离为 2,但记住我们需要的是最短的距离,即使这意味着需要花费更多的钱。事实上,我们之所以能够在这里使用 BFS,正是因为我们首先关注的是每个目标语言的最小边数,而不是总体最小成本。对于后者,我们需要更强大的工具,这些将在第六章中介绍。
为了存储图,我将使用所谓的邻接列表。(如果从a到b有一条边,那么说节点b是节点a的邻接节点;这就是“邻接列表”名称的来源。)这只是一个数组,每个节点有一个索引,数组中的每个索引存储一个包含该节点的边的链表。我们使用边的链表而不是边的数组,因为我们无法提前知道涉及给定节点的边的数量。
这里是常量和typedef:
#define MAX_LANGS 101
#define WORD_LENGTH 16
typedef struct edge {
int to_lang, cost;
struct edge *next;
} edge;
typedef int board[MAX_LANGS];
typedef int positions[MAX_LANGS];
一个edge有一个to_lang和一个cost—这很合理。然而,它没有from_lang,因为我们已经能够根据邻接列表中边的位置推断出from_lang。
在第二章中,当存储树时,我们使用的是struct node,而不是struct edge。在第二章中专注于节点的原因是,节点是与信息相关的实体,比如糖果值和后代数量。在当前问题中,我们专注于边的实现,使用struct edge,因为正是这些边(而不是节点)与信息(如翻译成本)相关。
在链表的开头添加元素是最简单的。这种选择的一个副作用是,节点的边将以我们读取它们的相反顺序出现在链表中。例如,如果我们从节点 1 读取到节点 2 的边,然后从节点 1 读取到节点 3 的边,那么在我们的链表中,我们会发现指向节点 3 的边会出现在指向节点 2 的边之前。在追踪代码时不要因此而感到意外。
现在我们准备好查看图是如何构建的。它在列表 5-16 中给出的main函数中。
int main(void) {
static edge *adj_list[MAX_LANGS] = {NULL};
static char *lang_names[MAX_LANGS];
int i, num_targets, num_translators, cost, from_index, to_index;
char *from_lang, *to_lang;
edge *e;
static board min_costs;
scanf("%d%d ", &num_targets, &num_translators);
➊ lang_names[0] = "English";
for (i = 1; i <= num_targets; i++)
➋ lang_names[i] = read_word(WORD_LENGTH);
for (i = 0; i < num_translators; i++) {
from_lang = read_word(WORD_LENGTH);
to_lang = read_word(WORD_LENGTH);
scanf("%d ", &cost);
from_index = find_lang(lang_names, from_lang);
to_index = find_lang(lang_names, to_lang);
e = malloc(sizeof(edge));
if (e == NULL) {
fprintf(stderr, "malloc error\n");
exit(1);
}
e->to_lang = to_index;
e->cost = cost;
e->next = adj_list[from_index];
➌ adj_list[from_index] = e;
e = malloc(sizeof(edge));
if (e == NULL) {
fprintf(stderr, "malloc error\n");
exit(1);
}
e->to_lang = from_index;
e->cost = cost;
e->next = adj_list[to_index];
➍ adj_list[to_index] = e;
}
find_distances(adj_list, num_targets + 1, min_costs);
solve(num_targets + 1, min_costs);
return 0;
}
列表 5-16: 构建图的主函数
lang_names数组将整数(数组索引)映射到语言名称。我们为英语赋值为 0,正如承诺的那样 ➊。然后我们将每个整数 1、2、…,映射到我们读取的语言名称 ➋。
记住图是无向的:如果我们从a到b添加一条边,那么我们也必须添加从b到a的边。因此,对于每个翻译者,我们将图中添加两条边:一条从from_index到to_index ➌,一条从to_index到from_index ➍。这些from_index和to_index索引是由find_lang生成的,该函数用于查找语言名称;见列表 5-17。
在底部对辅助函数的调用中,我们使用num_targets + 1而不是num_targets,因为num_targets表示目标语言的数量;+ 1让我们在处理的语言总数中包括英语。
int find_lang(char *langs[], char *lang) {
int i = 0;
while (strcmp(langs[i], lang) != 0)
i++;
return i;
}
列表 5-17:查找语言
BFS
列表 5-18 中的add_position代码与我们在本章早些时候学习的其他add_position函数类似。
void add_position(int from_lang, int to_lang,
positions new_positions, int *num_new_positions,
board min_moves) {
if (min_moves[to_lang] == -1) {
min_moves[to_lang] = 1 + min_moves[from_lang];
new_positions[*num_new_positions] = to_lang;
(*num_new_positions)++;
}
}
列表 5-18:添加位置
现在我们准备好进行 BFS 了;见列表 5-19。
void find_distances(edge *adj_list[], int num_langs, board min_costs) {
➊ static board min_moves;
static positions cur_positions, new_positions;
int num_cur_positions, num_new_positions;
int i, from_lang, added_lang, best;
edge *e;
for (i = 0; i < num_langs; i++) {
min_moves[i] = -1;
min_costs[i] = -1;
}
min_moves[0] = 0;
cur_positions[0] = 0;
num_cur_positions = 1;
while (num_cur_positions > 0) {
num_new_positions = 0;
for (i = 0; i < num_cur_positions; i++) {
from_lang = cur_positions[i];
➋ e = adj_list[from_lang];
while (e) {
add_position(from_lang, e->to_lang,
new_positions, &num_new_positions, min_moves);
e = e->next;
}
}
➌ for (i = 0; i < num_new_positions; i++) {
added_lang = new_positions[i];
e = adj_list[added_lang];
best = -1;
while (e) {
➍ if (min_moves[e->to_lang] + 1 == min_moves[added_lang] &&
(best == -1 || e->cost < best))
best = e->cost;
e = e->next;
}
min_costs[added_lang] = best;
}
num_cur_positions = num_new_positions;
for (i = 0; i < num_cur_positions; i++)
cur_positions[i] = new_positions[i];
}
}
列表 5-19:使用 BFS 的翻译最小成本
对于每种语言,我们将使用min_costs存储可能用于发现该语言的最小成本边。回顾图 5-7,我们将为西班牙语存储 500,为意大利语存储 1000,为孟加拉语存储 1800。在另一个函数中,我们将把这些数字加起来,得到所有翻译的总成本。
最小的移动次数只对这个函数有意义,而对外部世界没有影响,所以我们将其声明为局部变量 ➊。外部世界关心的只是min_costs。
尝试每个可能的移动相当于遍历当前节点的边的链表 ➋。这为我们提供了所有new_positions。现在我们知道了下一轮 BFS 中发现的语言,但我们还不知道添加每种语言的成本。问题是,从cur_positions到new_positions可能有多条边。再次参考图 5-7。孟加拉语需要两次翻译,所以它在 BFS 的第二轮中被发现——但我们需要的边是西班牙语的,而不是意大利语的。
因此,我们有了一个新的for循环 ➌,这是我们在本章中尚未见过的。变量added_lang跟踪每个新位置(即下一轮 BFS 的语言位置)。我们找到added_lang与当前 BFS 轮次中任何已发现节点之间的最便宜边。每种语言的距离将比added_lang少一,这解释了if语句中第一个条件 ➍。
总成本
一旦我们将成本存储起来,接下来要做的就是将它们加起来,以得到翻译成所有目标语言的总成本。代码见列表 5-20。
void solve(int num_langs, board min_costs) {
int i, total = 0;
for (i = 1; i < num_langs; i++)
➊ if (min_costs[i] == -1) {
printf("Impossible\n");
return;
} else {
total = total + min_costs[i];
}
➋ printf("%d\n", total);
}
列表 5-20:最小总成本
如果任何目标语言不可达,任务就无法完成 ➊。否则,我们将打印出累积的总成本 ➋。
现在,你已经准备好提交给评审了。Sabasa!
总结
在本章中,我们写了大量的代码。当然,我希望这些代码能为你解决自己图形问题提供一个起点。不过,从长远来看,我希望你记住的是,建模作为解决问题过程中的早期步骤的重要性。将问题用广度优先搜索(BFS)来表述,把骑士、绳索和翻译这些领域统一为图形这一单一领域。通过 Google 搜索“如何爬绳子”是不会得到有用结果的(除非你真的爬上了一根绳子)。而搜索“广度优先搜索”则会提供你愿意阅读的代码示例、解释和案例。如果你阅读程序员在评测网站上留下的评论,你会发现他们讨论的是算法层面的内容,而非问题的特定方面。通常,他们会简洁地说“BFS 问题”来表达观点。你正在学习这种建模语言,以及如何从模型转化为可工作的代码。在下一章,我们将继续介绍图形建模,重点讲解加权图的完整概念。
注释
《骑士追逐》最初来自 1999 年加拿大计算机竞赛。《绳索攀爬》最初来自 2018 年 Woburn 挑战赛在线第一轮高级组。《书籍翻译》最初来自 2016 年东中北美地区编程竞赛。
在考虑广度优先搜索中的多个相似操作时,我们可以使用一种技巧来减少需要编写的代码。你可以在 附录 B 的《骑士追逐:编码移动》中查看这一技巧的实现。
在本章中,我们学习了广度优先搜索,但如果你继续学习图形算法,可能也会想研究深度优先搜索(DFS)。我推荐 Tim Roughgarden(2018)编写的《Algorithms Illuminated (Part 2): Graph Algorithms and Data Structures》一书,进一步了解广度优先搜索、深度优先搜索和其他图形算法。
第六章:加权图中的最短路径

本章对我们在第五章中学习的最短路径问题进行了推广。在第五章中,我们的重点是找出解决问题所需的最小步数。现在,假设我们关心的不是最小步数,而是最短时间或距离?想象一下使用 GPS 应用程序回家。也许有一条只需要 10 分钟的单行道回家。也许有另一条路线,虽然需要经过三条街,但总共只需 8 分钟。我们可能会选择走这三条街,因为它们节省了时间。
在本章中,我们将学习 Dijkstra 算法,用于在加权图中找到最短路径。我们将使用它来确定在时间限制内能够逃脱迷宫的实验鼠数量,以及从某人家到他们祖母家的最短路径数量。我特别选择了那个关于祖母的例子,来重温我们在第五章中发现的一个结论:经过适当修改,像 BFS 和 Dijkstra 这样的算法能够做的不仅仅是“找到最短路径”。我们正在学习算法——这些算法理应广为人知——同时也在积累灵活的问题解决工具。让我们开始吧!
问题 1:实验鼠迷宫
这是 UVa 问题 1112。
问题
一个迷宫由单元格和通道组成。每条通道从某个单元格 a 到另一个单元格 b,并且行走这条通道需要 t 时间单位。例如,从单元格 2 到单元格 4 可能需要 5 时间单位。现在考虑反向通道:从单元格 4 到单元格 2 可能需要 70 时间单位,或者从单元格 4 到单元格 2 可能根本没有通道——a → b 和 b → a 通道是独立的。迷宫中的一个单元格被指定为出口单元格。
每个单元格中都有一只实验鼠,包括出口单元格。实验鼠已经被训练成尽可能快速地走到出口单元格。我们的任务是确定在规定时间内能够到达出口单元格的实验鼠数量。
输入
第一行输入给出测试用例的数量,后面跟着一个空行。每一对测试用例之间也有一个空行。每个测试用例包含以下几行:
-
一行包含 n,迷宫中的单元格数量。单元格编号从 1 到 n;n 至多为 100。
-
一行包含 e,出口单元格。e 介于 1 和 n 之间。
-
一行包含 t,实验鼠到达出口单元格的整数时间限制。t 至少为零。
-
一行包含 m,迷宫中通道的数量。
-
m 行,每行描述迷宫中的一条通道。每行包含三个整数:第一个单元格 a(介于 1 和n之间),第二个单元格 b(介于 1 和n之间),以及从 a 到 b 行走所需的时间(至少为零)。
输出
对于每个测试用例,输出在时间限制t内到达出口单元格e的老鼠数量。每个测试用例的输出之间由一个空行分隔。
解决测试用例的时间限制——是针对我们的代码,而不是老鼠——是三秒钟。
从 BFS 继续
老鼠迷宫问题与第五章中的三个问题有一些关键的相似之处。我们可以将老鼠迷宫建模为一个图,其中节点是迷宫单元格,边是通道。这个图是有向的(就像绳索攀爬问题那样),因为从单元格a到单元格b的通道并不能告诉我们从b到a的通道是否存在。
第五章中的三个问题的核心算法是广度优先搜索(BFS)。BFS 的杀手特性是它能够找到最短路径。巧合的是,我们在老鼠迷宫中也需要最短路径。它们能帮助我们确定每只老鼠到达出口单元格所需的时间。
然而,所有关于相似性的讨论掩盖了一个关键的区别:老鼠迷宫图是加权的:每条边上都有一个任意的整数,表示穿越该边所需的时间。具体例子请参见图 6-1。

图 6-1:老鼠迷宫图
假设出口单元格是单元格 4。单元格 1 到单元格 4 所需的最短时间是多少?单元格 1 到单元格 4 之间有一条直接的边,所以如果我们在计算边的数量(如 BFS 中那样),答案将是1。然而,我们这里不关心边的数量:我们更关心的是以边的权重总和来衡量的最短路径。1 → 4 边的权重是 45,这不是最短路径。单元格 1 到单元格 4 的最短路径是这样的一条三边路径:从单元格 1 到单元格 3(花费 6 个时间单位),再从单元格 3 到单元格 2(花费 2 个时间单位),最后从单元格 2 到单元格 4(花费 9 个时间单位)。这总共是 6 + 2 + 9 = 17 个时间单位。正因为如此,BFS 在这种情况下就不适用了,我们需要采用不同的算法。
但稍等:第五章中也有一些加权图,我们在其中使用了 BFS。怎么回事?回顾图 5-6,这是一个绳索攀爬图,其中有些边的权重是 1,有些边的权重是 0。我们能够在这里使用 BFS,原因仅仅是边的权重受到严格限制。现在回头看看图 5-7,这是一个书籍翻译图。这是一个完全加权的图,边的权重是任意的。我们在这里也能使用 BFS,但那是因为主要的距离度量是边的数量。一旦 BFS 确定了一个节点的边的距离,只有在此之后,边的权重才会发挥作用,帮助我们以尽可能低的成本添加节点。
然而,鼠标迷宫与边的数量毫无关系。从a到b的路径可能有 100 条边,总时间为 5 单位。另一条从a到b的路径可能只有 1 条边,时间为 80 单位。BFS 会发现第二条路径,而我们想要的是第一条路径。
在加权图中寻找最短路径
BFS 通过逐步识别离起始节点越来越远的节点(按边数计算)来运作。我在这一节中展示的算法与此类似:它通过总边权来识别离起始节点越来越远的最短路径。
BFS 按照回合组织工作,每一回合中发现的节点比当前回合的节点距离起始节点多一条边。我们无法使用这种回合的思路来寻找加权图中的最短路径,因为我们最近发现的最短路径不一定有助于我们找到新节点的最短路径。我们必须稍微多做些工作,才能找到下一个最短路径。
为了演示,我们来通过图 6-1 找到从节点 1 到图中每个节点的最短路径。这将告诉我们鼠标从单元格 1 到出口单元格需要多长时间。
对于每个节点,我们将维护两条信息:
done 这是一个布尔变量。如果为 false,意味着我们还没有找到该节点的最短路径;如果为 true,表示我们已经找到了。只要一个节点的done值为 true,我们就完成了该节点:它的最短路径永远不会再改变。
min_time 这是从起始节点到此节点的最短路径距离,按总时间计算,使用一条所有其他节点均已完成的路径。随着越来越多的节点完成,min_time可以减小,因为我们有更多通往该节点的路径选择。
从节点 1 到节点 1 的最短路径是 0:没有地方可去,也没有边可走。我们从这里开始,节点 1 的min_time为 0,其他节点没有min_time信息:
| node | done | min_time |
|---|---|---|
| 1 | false | 0 |
| 2 | false | |
| 3 | false | |
| 4 | false | |
| 5 | false |
接下来,我们将节点 1 标记为已完成,然后根据从节点 1 到其他节点的边权设置各自的min_time。以下是我们接下来的快照:
| node | done | min_time |
|---|---|---|
| 1 | true | 0 |
| 2 | false | 12 |
| 3 | false | 6 |
| 4 | false | 45 |
| 5 | false | 7 |
现在,下面这个陈述是我们所做工作的核心:从节点 1 到节点 3 的最短路径是 6,并且我们永远无法做得比 6 更好。我选择节点 3 作为我的陈述对象,因为它是尚未完成节点中min_time值最小的节点。
现在声称答案是 6 似乎有些大胆。如果存在另一条更短的路径到达节点 3,或者是通过一些其他节点最终到达节点 3 的路径呢?
这就是为什么那种情况不可能发生,以及为什么我们可以确定 6 是正确答案的原因。假设从节点 1 到节点 3 存在一条更短的路径 p。这条路径必须从节点 1 开始,并通过某个边 e 离开节点 1。然后它必须经过零个或多个其他边,并到达节点 3。仔细想想:e 已经至少需要 6 时间单位,因为 6 是从节点 1 到其他节点所需的最短时间。路径 p 上的任何其他边只会增加这个时间,因此不可能存在总时间少于 6 单位的路径 p!
所以,节点 3 已完成:我们知道它的最短路径。现在我们需要使用节点 3 来检查是否能够改善任何尚未完成的节点的最短路径。请记住,最短时间 值是通过已完成节点计算的最短路径。到达节点 3 需要 6 时间单位,并且从节点 3 到节点 2 有一条边,花费 2 时间单位,因此我们现在有了一条从节点 1 到节点 2 仅需 8 时间单位的路径。因此,我们将节点 2 的 最短时间 值从 12 更新为 8。以下是当前的状态:
| 节点 | 已完成 | 最短时间 |
|---|---|---|
| 1 | true | 0 |
| 2 | false | 8 |
| 3 | true | 6 |
| 4 | false | 45 |
| 5 | false | 7 |
节点 2、4 和 5 尚未完成。现在我们能将哪个节点标记为完成呢?答案是节点 5:它的 最短时间 最小。我们能否利用节点 5 更新其他最短路径?节点 5 确实有一条指向节点 2 的边,但从节点 1 到节点 5 需要 7 时间单位,然后再从节点 5 到节点 2 需要 21 时间单位,总共需要的时间(7 + 21 = 28)比我们原来的从节点 1 到节点 2 的路径(8 时间单位)要长。所以我们不会改变节点 2 的 最短时间。因此,下一次更新的唯一变化是将节点 5 标记为完成。
| 节点 | 已完成 | 最短时间 |
|---|---|---|
| 1 | true | 0 |
| 2 | false | 8 |
| 3 | true | 6 |
| 4 | false | 45 |
| 5 | true | 7 |
还有两个节点要处理。节点 2 的min_time为 8,节点 4 的min_time为 45。和往常一样,我们选择较小的值,最终确定从节点 1 到节点 2 的最短路径为 8。再次强调,从节点 1 到节点 2 的最短路径无法小于 8。任何从节点 1 到节点 2 的较短路径p,必须先经过一些已完成的节点,并且在某一时刻,会第一次穿越一条从已完成节点到未完成节点的边。我们称这条边为x → y,其中x是已完成的节点,而y是未完成的节点。这就是p从节点 1 到达节点y的方式。接下来,它可以随意从节点y到节点 2……但这一切都是多余的。从节点 1 到节点y已经至少需要 8 个时间单位:如果少于 8 个单位,那y的min_time值就会小于 8,而我们本应选择将y标记为已完成,而不是节点 2。无论p如何从节点y到节点 2,都会增加更多的时间。所以p不能比 8 更短。
添加节点 2 后,我们有两条边可以检查是否存在更短的路径。从节点 2 到节点 1 有一条边,但这没有帮助,因为节点 1 已经完成。从节点 2 到节点 4 有一条 9 时间单位的边。这条边有帮助!从节点 1 到节点 2 需要 8 个时间单位,然后 2 → 4 的边需要 9 个时间单位,总共是 17 个时间单位。这比我们之前从节点 1 到节点 4 需要的 45 个时间单位要好。下面是下一个快照:
| 节点 | 已完成 | 最短时间 |
|---|---|---|
| 1 | true | 0 |
| 2 | true | 8 |
| 3 | true | 6 |
| 4 | false | 17 |
| 5 | true | 7 |
只有一个节点,即节点 4,尚未完成。由于其他所有节点都已完成,我们已经找出了它们的最短路径。因此,节点 4 不能帮助我们找到任何新的、更短的路径。我们可以将节点 4 标记为已完成,并得出结论:
| 节点 | 已完成 | 最短时间 |
|---|---|---|
| 1 | true | 0 |
| 2 | true | 8 |
| 3 | true | 6 |
| 4 | true | 17 |
| 5 | true | 7 |
在第 1 单元格的老鼠到达出口单元格 4 时,总共需要 17 个时间单位。我们可以对每个其他节点重复这个过程,找出每只老鼠到达出口单元格所需的时间,然后计算按时到达的老鼠数量。
这个算法被称为迪杰斯特拉算法(Dijkstra’s algorithm),以计算机科学的先驱和影响力人物埃德斯杰·W·迪杰斯特拉(Edsger W. Dijkstra)命名。给定一个起始节点s和一个加权图,它可以计算出从s到图中每个节点的最短路径。这正是我们解决老鼠迷宫问题所需要的。让我们读取输入来构建图形,然后看看如何实现迪杰斯特拉算法。
构建图形
基于你到目前为止构建树和图的经验,这里不会有太多惊讶。我们将像在上一章的书籍翻译问题中一样构建图(见第 189 页中的“构建图”)。唯一的不同是,之前的图是无向图,而我们这里的图是有向图。好消息是,我们直接给出了节点编号,而不需要在语言名称和整数之间进行映射。
为了方便测试,这里提供了一个输入,对应于图 6-1:
1
5
4
➊ 12
9
1 2 12
1 3 6
2 1 26
1 4 45
1 5 7
3 2 2
2 4 9
4 3 8
5 2 21
该12 ➊ 给出了老鼠到达出口的时间限制。(你可以验证,三只老鼠可以在这个时间限制内到达出口;这三只老鼠分别位于单元 2、3 和 4。)
和书籍翻译问题一样,我们将使用图的邻接表表示法。每条边包含指向的单元、行走该边所需的时间以及下一个指针。
这是所需的常量和typedef:
#define MAX_CELLS 100
typedef struct edge {
int to_cell, length;
struct edge *next;
} edge;
图表由main函数读取,见列表 6-1。
int main(void) {
static edge *adj_list[MAX_CELLS + 1];
int num_cases, case_num, i;
int num_cells, exit_cell, time_limit, num_edges;
int from_cell, to_cell, length;
int total, min_time;
edge *e;
scanf("%d", &num_cases);
for (case_num = 1; case_num <= num_cases; case_num++) {
scanf("%d%d%d", &num_cells, &exit_cell, &time_limit);
scanf("%d", &num_edges);
➊ for (i = 1; i <= num_cells; i++)
adj_list[i] = NULL;
for (i = 0; i < num_edges; i++) {
scanf("%d%d%d", &from_cell, &to_cell, &length);
e = malloc(sizeof(edge));
if (e == NULL) {
fprintf(stderr, "malloc error\n");
exit(1);
}
e->to_cell = to_cell;
e->length = length;
e->next = adj_list[from_cell];
➋ adj_list[from_cell] = e;
}
total = 0;
for (i = 1; i <= num_cells; i++) {
➌ min_time = find_time(adj_list, num_cells, i, exit_cell);
➍ if (min_time >= 0 && min_time <= time_limit)
total++;
}
printf("%d\n", total);
if (case_num < num_cases)
printf("\n");
}
return 0;
}
列表 6-1:用于构建图的 main 函数
输入规范说在测试用例数量后会有一个空行,每对测试用例之间也有一个空行。然而,使用scanf时我们无需担心这一点:当读取数字时,scanf会跳过它遇到的所有前导空白(包括换行符)。
我们为每个测试用例做的第一件事是通过将每个单元的边表设置为NULL ➊来清空邻接表。如果不这么做,会导致一个可怕的 bug,其中每个测试用例都会包含前一个测试用例的边。(我知道,因为我犯过这个错误,结果三小时后才发现。)我们有责任为每个测试用例清空数据!
在初始化每个边时,我们将其添加到from_cell的链表中 ➋。我们不会向to_cell的链表添加任何内容,因为图是有向图(不是无向图)。
该问题要求我们找到每个单元到出口单元的最短路径。因此,对于每个单元,我们调用find_time ➌,它是一个实现了 Dijkstra 算法的辅助函数。我们接下来会编写这个函数。给定起始单元i和目标单元exit_cell,如果没有路径,它返回-1,否则返回最短路径时间。每个到达出口单元需要time_limit单位时间或更少的单元,都会使得total增加 1 ➍。一旦考虑完每个单元的最短路径,total就会被输出。
实现 Dijkstra 算法
现在是实现 Dijkstra 算法的时候了,按照“加权图中寻找最短路径”部分提供的概要来实现,见第 200 页。这是我们将要实现的函数签名:
int find_time(edge *adj_list[], int num_cells,
int from_cell, int exit_cell)
这四个参数分别对应邻接表、单元格数量、起始单元格和出口单元格。Dijkstra 算法将计算从起始单元格到所有其他单元格的最短路径时间,包括出口单元格。一旦计算完成,我们可以返回到出口单元格的最短路径时间。这个看起来可能有点奢侈,计算到所有单元格的最短路径后,只保留通往出口单元格的最短路径而抛弃其他路径。我们可以进行多种优化,接下来会讨论这些优化。现在,让我们先实现一个简单可用的版本。
Dijkstra 算法的主体通过两个嵌套的 for 循环实现。外层 for 循环每个单元格运行一次;每次迭代将一个单元格标记为已完成,并使用该新单元格更新最短路径。内层 for 循环进行最小值计算:它在所有未完成的单元格中找到 min_time 值最小的单元格。代码见 Listing 6-2。
int find_time(edge *adj_list[], int num_cells,
int from_cell, int exit_cell) {
static int done[MAX_CELLS + 1];
static int min_times[MAX_CELLS + 1];
int i, j, found;
int min_time, min_time_index, old_time;
edge *e;
➊ for (i = 1; i <= num_cells; i++) {
done[i] = 0;
min_times[i] = -1;
}
➋ min_times[from_cell] = 0;
for (i = 0; i < num_cells; i++) {
min_time = -1;
➌ found = 0;
➍ for (j = 1; j <= num_cells; j++) {
➎ if (!done[j] && min_times[j] >= 0) {
➏ if (min_time == -1 || min_times[j] < min_time) {
min_time = min_times[j];
min_time_index = j;
found = 1;
}
}
}
❼ if (!found)
break;
done[min_time_index] = 1;
e = adj_list[min_time_index];
while (e) {
old_time = min_times[e->to_cell];
➑ if (old_time == -1 || old_time > min_time + e->length)
min_times[e->to_cell] = min_time + e->length;
e = e->next;
}
}
➒ return min_times[exit_cell];
}
Listing 6-2: 使用 Dijkstra 算法找到通往出口单元格的最短路径
done 数组的目的是指示每个单元格是否已完成:0表示“未完成”,1表示“已完成”。min_times 数组的目的是存储从起始单元格到每个单元格的最短路径距离。
我们使用 for 循环 ➊ 来初始化这两个数组:它将所有 done 值设置为 0(假),将 min_times 值设置为 -1(未找到)。然后,我们将 from_cell 的 min_times 设置为 0 ➋,表示从起始单元格到它自身的最短路径距离为零。
found 变量跟踪 Dijkstra 算法是否能发现新单元格。在每次外层 for 循环迭代时,它初始为 0(假),如果找到一个单元格,则设置为 1(真)——但是,怎么可能找不到单元格呢?例如,在本章之前,我们已经找到了所有单元格。然而,可能存在这样的图形,其中起始单元格与某些其他单元格之间没有路径。在这些图形中,Dijkstra 算法无法找到某些单元格;当无法找到新单元格时,就该停止了。
现在我们进入内层 for 循环 ➍,它的任务是识别下一个最短路径将被找到的单元格。这个循环将把 min_time_index 设置为最短路径已找到的单元格的索引,并将 min_time 设置为最短路径的时间。符合条件的单元格是那些既未完成又有一个至少为 0(即非 -1)的 min_times 值的单元格 ➎。我们需要单元格未完成,因为已完成的单元格已经有了最终的最短路径。我们还需要 min_times 值至少为 0:如果它是 -1,表示该单元格尚未找到,因此我们不知道它的最短路径是什么。如果还没有符合条件的单元格,或者当前单元格的路径比我们当前已知的最短路径更短 ➏,我们就更新 min_time 和 min_time_index,并将 found 设置为 1,标志着我们成功找到了一个单元格。
如果没有找到单元格,那么我们就停止 ➐。否则,我们将识别出的单元格标记为完成,并循环遍历它的出边来寻找更短的路径。对于每一条边 e,我们检查该单元格是否提供到 e->to_cell 的更短路径 ➑。那个可能的更短路径是 min_time(从 from_cell 到 min_time_index 的时间)加上走过边 e(从 min_time_index 到 e->to_cell 的时间)。
在查看边 e 时,难道我们不应该先验证 e->to_cell 是否已经完成,然后再检查我们是否找到了更短的路径 ➑ 吗?虽然我们可以添加那个检查,但它不会产生任何效果。已完成的单元格已经拥有最终确定的最短路径;不可能再找到更短的路径了。
通过计算到所有单元格的最短路径,我们肯定已经计算出了到出口单元格的最短路径。最后要做的就是返回那个时间 ➒。
结束了!继续提交给评测系统吧。代码应该能够通过所有的测试用例。
两个优化
有一些方法可以加速 Dijkstra 算法。最广泛适用且显著的加速是通过一种叫做 堆 的数据结构实现的。在我们当前的实现中,找到下一个要标记为完成的节点非常昂贵,因为我们需要扫描所有未完成的节点,找出具有最短路径的那个节点。堆使用树将这种慢速的线性搜索转换为快速搜索。由于堆在许多场景中都很有用,不仅限于 Dijkstra 算法,我将在稍后的第八章中讨论它们。在这里,我会提供几个更具体于老鼠迷宫问题的优化方法。
记住,一旦一个单元格被标记为完成,我们就不再改变它的最短路径。因此,一旦我们将出口单元格标记为完成,我们就得到了它的最短路径。之后,就没有必要再为其他单元格找到最短路径了。我们可以提前终止 Dijkstra 算法。
然而,我们还是可以做得更好。对于一个包含 n 个单元格的迷宫,我们可以执行 Dijkstra 算法 n 次,每次对一个单元格执行。对于单元格 1,我们计算所有的最短路径——然后只保留到出口单元格的最短路径。对于单元格 2、单元格 3,依此类推,我们丢弃所有找到的最短路径,除了那些涉及出口单元格的路径。
相反,可以考虑只运行一次 Dijkstra 算法,将出口单元格作为起始单元格。Dijkstra 算法会找到从出口单元格到单元格 1,从出口单元格到单元格 2,依此类推的最短路径。然而,这并不是我们想要的,因为图是有向的:从出口单元格到单元格 1 的最短路径不一定是从单元格 1 到出口单元格的最短路径。
这里再次展示图 6-1:

正如我们之前发现的,从单元格 1 到单元格 4 的最短路径是 17,但从单元格 4 到单元格 1 的最短路径是 36。
从 Cell 1 到 Cell 4 的最短路径使用了边 1 → 3、3 → 2 和 2 → 4。如果我们打算从 Cell 4 开始运行 Dijkstra 算法,那么它需要找到边 4 → 2、2 → 3 和 3 → 1。每一条边都是原始图中边的反向。图 6-2 显示了反向图。

图 6-2:图 6-1 的反向版本
现在我们可以运行 Dijkstra 算法——只需调用一次!——从 Cell 4 开始,恢复到所有节点的最短路径。
就实现而言,我们需要生成反向图,而不是原始图。这可以在main函数中完成(见 Listing 6-1),在读取图时进行处理。替代方法是:
e->to_cell = to_cell;
e->length = length;
e->next = adj_list[from_cell];
adj_list[from_cell] = e;
我们需要的是:
e->to_cell = from_cell;
e->length = length;
e->next = adj_list[to_cell];
adj_list[to_cell] = e;
也就是说,边现在指向from_cell,并且它会被添加到to_cell的链表中。如果你进行这个修改并调整代码,使其只调用一次 Dijkstra 算法(从出口单元开始),你将得到一个更快的程序。试试看!
Dijkstra 算法
Dijkstra 算法接管了 BFS 的工作。BFS 在无权图中根据边的数量找到最短路径;而 Dijkstra 算法在加权图中根据边的权重找到最短路径。
与广度优先搜索(BFS)类似,Dijkstra 算法从一个起始节点开始,并从那里找到到图中每个节点的最短路径。与 BFS 类似,它解决的是单源最短路径问题,不同的是,Dijkstra 算法是在加权图上运行,而不是无权图。
公平地说,Dijkstra 算法也可以在无权图中找到最短路径。只需将无权图中的每条边赋予权重 1。现在,当 Dijkstra 算法找到最短路径时,它实际上最小化了路径中的边数,这正是 BFS 所做的。
那为什么不使用 Dijkstra 算法来解决每个最短路径问题,无论是无权图还是加权图呢?实际上,确实存在一些问题,在这些问题中很难决定使用 BFS 还是 Dijkstra 算法。例如,我怀疑很多人会选择使用 Dijkstra 算法而不是(修改过的)BFS 来解决第五章中的绳子攀爬问题。当任务明确是最小化移动次数时,BFS 应当是首选:它通常比 Dijkstra 算法更容易实现,而且运行得稍微快一点。但无论如何,Dijkstra 算法并不慢。
Dijkstra 算法的运行时间
让我们来描述一下 Dijkstra 算法的运行时间,如 Listing 6-2 所示。我们使用n来表示图中的节点数。
初始化循环➊重复n次,每次迭代执行一个常数步骤,因此总工作量与n成正比。接下来的初始化步骤➋是一个单一步骤。无论我们说初始化需要n步还是n + 1 步都没关系,因此我们忽略这个 1,认为它需要n步。
Dijkstra 算法的真正工作现在开始。它的外层 for 循环最多迭代 n 次。在每次迭代中,内层 for 循环会执行 n 次以找到下一个节点。因此,内层 for 循环总共会迭代 n² 次。每次迭代执行固定量的工作,因此内层 for 循环的总工作量与 n² 成正比。
Dijkstra 算法的另一个工作是遍历每个节点的边。总共有 n 个节点,所以每个节点最多有 n 条离开的边。因此,我们需要进行 n 步来遍历一个节点的边,并且我们必须对每个节点做这件事。这又是 n² 步。
总结一下,我们在初始化时有 n 工作,在内层 for 循环中有 n² 工作,在检查边时有 n² 工作。最大的指数是 2,所以这是一个 O(n²) 或二次时间复杂度的算法。
在第一章中,我们对一个二次时间复杂度的“独特雪花”算法嗤之以鼻,抛弃它,选择了一个线性时间的算法。从这个意义上说,我们开发的 Dijkstra 算法实现并不算太惊艳。然而,从另一个角度来看,它确实令人印象深刻,因为它在 n² 时间内解决了 n 个问题,每个问题对应从起始节点出发的最短路径。
我选择在本书中介绍 Dijkstra 算法,但还有许多其他最短路径算法。有些算法能够一次性找到图中所有节点对之间的最短路径。这样就解决了 所有节点对最短路径 问题。一个这样的算法叫做 Floyd-Warshall 算法,它的时间复杂度是 O(n³)。有趣的是,我们也可以用 Dijkstra 算法找到所有节点对的最短路径,而且速度一样快。我们可以运行 Dijkstra 算法 n 次,每次从不同的起始节点开始。那就是 n 次调用 n² 算法,总共的工作量是 O(n³)。
无论是加权还是无加权,单源还是所有节点对,Dijkstra 算法都能处理。它真的无法阻挡吗?错!
负权重边
到目前为止,我们在本章中做了一个隐含假设:边的权重是非负的。例如,在老鼠迷宫中,边的权重代表走过每条边所需的时间;走一条边肯定不会让时间倒退,因此没有边的权重是负的。同样,在许多其他图中,负权重的边不会出现,因为它们没有实际意义。例如,考虑一个图,其中节点是城市,边是城市间的航班费用。没有航空公司会为我们乘坐他们的航班付费,因此每条边的费用是非负的美元。
现在考虑一个游戏,其中一些操作给我们加分,而另一些操作则扣分。后者的操作对应于 负权重边。因此,负权重边偶尔会出现。那么 Dijkstra 算法如何应对呢?让我们用图 6-3 中的示例图来看看。

图 6-3:带有负权边的图
让我们尝试从节点 A 寻找最短路径。和往常一样,Dijkstra 算法首先将最短路径 0 指定给节点 A,并将 A 设置为已完成。从 A 到 B 的距离是 3,从 A 到 C 的距离是 5,但从 A 到 D 的距离尚未定义(并且留空):
| 节点 | 已完成 | 最短距离 |
|---|---|---|
| A | true | 0 |
| B | false | 3 |
| C | false | 5 |
| D | false |
Dijkstra 算法接着决定将到 B 的最短路径定为 3,并将 B 标记为已完成。它还更新了到 D 的最短路径:
| 节点 | 已完成 | 最短距离 |
|---|---|---|
| A | true | 0 |
| B | true | 3 |
| C | false | 5 |
| D | false | 4 |
由于 B 已完成,我们声称从 A 到 B 的最短路径是 3,但这会出问题,因为 3 不是从 A 到 B 的最短路径。最短路径应该是 A → C → B,总权重为 -495。为了好玩,我们就继续在这些可疑的情况下进行,看看 Dijkstra 算法会怎么做。下一个完成的节点是 D:
| 节点 | 已完成 | 最短距离 |
|---|---|---|
| A | true | 0 |
| B | true | 3 |
| C | false | 5 |
| D | true | 4 |
到 D 的最短路径也错了!它应该是 -494。因为除了 C 外所有节点都已完成,所以 C 无法再做任何事情:
| 节点 | 已完成 | 最短距离 |
|---|---|---|
| A | true | 0 |
| B | true | 3 |
| C | true | 5 |
| D | true | 4 |
即使我们让 Dijkstra 算法将最短路径更改为 B(从 3 改为 -495),到 D 的最短路径仍然是错误的。我们必须以某种方式再次处理 B,即使 B 已完成。我们需要一种方法来表示:“嘿,我知道我说 B 已完成,但我改变主意了。”无论如何,我展示的经典 Dijkstra 算法在这个例子中是错误的。
一般来说,当图的边有负权时,Dijkstra 算法是无法工作的。对此,你可以考虑探索 Bellman-Ford 算法 或前面提到的 Floyd-Warshall 算法。
让我们继续进行另一个问题,这次我们不必担心负权边。我们将再次使用 Dijkstra 算法,或者更确切地说,我们将调整 Dijkstra 算法来解决关于最短路径的全新问题。
问题 2:奶奶规划师
有时,我们不仅会被要求给出最短路径的距离,还会要求进一步提供关于最短路径的信息。这个问题就是这样一个例子。
这是 DMOJ 问题 saco08p3。
问题
Bruce 正在计划去奶奶家旅行。那里有 n 个城镇,编号为 1 到 n。Bruce 从城镇 1 出发,他的奶奶住在城镇 n。每对城镇之间都有一条道路,并且我们给出了每条道路的长度(距离)。
布鲁斯希望带着一盒饼干到达奶奶家,因此他必须在途中购买饼干。一些镇有饼干店;布鲁斯必须至少经过一个饼干镇才能到达奶奶家。
我们的任务有两个方面。首先,我们必须确定布鲁斯从起点到奶奶家的最短距离,并且途中必须经过至少一个饼干店。这个最短距离并没有告诉我们布鲁斯有多少种路径可以到达奶奶家。也许只有一种方法可以做到,所有其他路径都需要更长的距离,或者可能有几条路径,它们的最短距离是相同的。因此,第二个任务是确定这些最短路径的数量。
输入
输入包含一个测试用例,由以下几行组成:
-
一行包含 n,即镇的数量。镇的编号从 1 到 n。镇的数量介于 2 到 700 之间。
-
n 行,每行包含 n 个整数。第一行给出了从镇 1 到每个镇的道路距离(先是镇 1,再是镇 2,依此类推);第二行给出了从镇 2 到每个镇的道路距离;依此类推。从一个镇到其自身的距离为零;其他每个距离至少为 1。从镇 a 到镇 b 的距离与从镇 b 到镇 a 的距离相同。
-
一行包含 m,即有饼干店的镇的数量。m 至少为 1。
-
一行包含 m 个整数,每个整数表示一个有饼干店的镇的编号。
输出
在一行中输出以下内容:
-
从镇 1 到镇 n 的最短距离(途中经过饼干店)
-
一个空格
-
最短距离路径的数量,模 1,000,000
解决此测试用例的时间限制为一秒。
邻接矩阵
这里的图表示方式与 第五章中的《老鼠迷宫》和《书籍翻译问题》不同。在这两个问题中,每条边都以一个节点、另一个节点和边权的形式提供。例如,考虑以下情况:
1 2 12
这意味着从节点 1 到节点 2 有一条边,边权为 12。
在《奶奶规划师问题》中,图是以 邻接矩阵 的形式表示的,邻接矩阵是一个二维数字数组,其中给定的行列坐标表示该行和列的边的权重。
这是一个示例测试用例:
4
0 3 8 2
3 0 2 1
8 2 0 5
2 1 5 0
1
2
顶部的 4 告诉我们有四个镇。接下来的四行是邻接矩阵。让我们关注其中的第一行:
0 3 8 2
这一行给出了离开镇 1 的所有边。镇 1 到镇 1 的边权为 0,镇 1 到镇 2 的边权为 3,镇 1 到镇 3 的边权为 8,镇 1 到镇 4 的边权为 2。
下一行,
3 0 2 1
同样地,这对镇 2 也适用,依此类推。
请注意,任意一对镇之间都有边连接;也就是说,图中没有缺失的边。这样的图称为 完全图。
这个邻接矩阵有一些冗余。例如,在第 1 行第 3 列,它表示从城镇 1 到城镇 3 的边权重为8。然而,由于问题中规定从城镇 a 到城镇 b 的路程与从城镇 b 到城镇 a 的路程相同,因此我们在第 3 行第 1 列再次看到这个8。 (因此我们处理的是无向图。)我们在对角线也有0,这明确表示从某个城镇到其自身的距离为零。我们将忽略这些。
构建图形
这个问题最终将要求我们展现创意,不是一次,而是两次。首先,我们需要强制让路径经过一个有饼干店的城镇。在这些路径中,我们要找到最短的一条。其次,我们需要跟踪的不仅是最短路径,还要统计有多少种方式可以实现最短路径。双倍的乐趣,我说!
让我们从输入中读取测试用例并构建图形。此时,我们已经准备好进行下一步了。有了图形后,我们将准备好处理接下来的问题。
计划是读取邻接矩阵,在此过程中构建我们的邻接列表。由于邻接矩阵没有明确提供城镇的索引,我们需要自己跟踪这些索引。
可以直接读取并使用邻接矩阵,完全避免使用邻接列表表示法。每一行 i 给出到每个城镇的距离,因此我们可以在 Dijkstra 算法中直接循环遍历第 i 行,而不是循环遍历 i 的邻接列表。由于图是完全图,我们甚至不需要浪费时间跳过不存在的边。然而,为了与我们已经做过的保持一致,我们这里还是使用邻接列表。
这是一个常量和edge结构体,我们将使用它:
#define MAX_TOWNS 700
typedef struct edge {
int to_town, length;
struct edge *next;
} edge;
读取图形的代码见于列表 6-3。
int main(void) {
static edge *adj_list[MAX_TOWNS + 1] = {NULL};
int i, num_towns, from_town, to_town, length;
int num_stores, store_num;
static int store[MAX_TOWNS + 1] = {0};
edge *e;
scanf("%d", &num_towns);
➊ for (from_town = 1; from_town <= num_towns; from_town++)
for (to_town = 1; to_town <= num_towns; to_town++) {
scanf("%d", &length);
➋ if (from_town != to_town) {
e = malloc(sizeof(edge));
if (e == NULL) {
fprintf(stderr, "malloc error\n");
exit(1);
}
e->to_town = to_town;
e->length = length;
e->next = adj_list[from_town];
adj_list[from_town] = e;
}
}
➌ scanf("%d", &num_stores);
for (i = 1; i <= num_stores; i++) {
scanf("%d", &store_num);
store[store_num] = 1;
}
solve(adj_list, num_towns, store);
return 0;
}
列表 6-3:构建图形的 主 函数
读取城镇数量后,我们使用一个双重for循环来读取邻接矩阵。外层for循环的每次迭代➊负责读取一行,具体来说,就是读取from_town这一行。为了读取该行,我们有一个内层for循环,它会读取每个to_town的length值。现在我们知道了边的起始点、终点以及边的长度。接下来,我们想要添加这条边,但前提是它不是从某个城镇返回到自身的 0 权重边。如果这条边是连接两个不同城镇➋的,那么我们就将它添加到from_town的邻接表中。由于图是无向图,我们还必须确保这条边最终会被添加到to_town的邻接表中。在 Listing 5-16 中解决“书籍翻译”问题时,我们必须显式地这么做。但在这里,我们不需要这么做,因为当我们处理to_town的这一行时,它会在后续自动被添加。举个例子,如果from_town是1且to_town是2,那么 1 → 2 的边现在会被添加。稍后,当from_town是2且to_town是1时,2 → 1 的边会被添加。
接下来只需要读取哪些城镇有饼干店的信息,从有饼干店的城镇数量➌开始。为了跟踪这些城镇,我们使用数组store,其中store[i]为1(真)表示城镇i有饼干店,为0(假)表示没有饼干店。
处理一个奇怪的测试案例
让我们通过处理“邻接矩阵”中的测试案例来更好地理解这个问题,见第 214 页。对应的图形在图 6-4 中给出,其中c代表饼干城镇。

图 6-4:奶奶图
Bruce 从城镇 1 出发,必须到达城镇 4。城镇 2 是唯一有饼干店的城镇。最短路径是多少?虽然 Bruce 可以直接从城镇 1 飞速到达城镇 4,沿途经过距离为 2 的边,但这不是问题的可行解。记住,我们需要确保任何拟议的最短路径都必须包含一个有饼干店的城镇。对于这个特定的图形,这意味着我们必须包括城镇 2。(在其他测试案例中,可能会有多个城镇有饼干店;我们需要做的是包括一个或多个这样的城镇。)
这是一个从城镇 1 到城镇 4 的可行路径:1 → 2(距离 3) → 4(距离 1)。这条路径的总距离是 4,确实是从城镇 1 到城镇 4 的最短路径,且经过了城镇 2。
不过,这并不是唯一的最优路径。还有一条路径,正如下所示:1 → 4(距离 2)→ 2(距离 1)→ 4(距离 1)。这个路径有点奇怪,因为我们访问了城镇 4,也就是奶奶的家,两次。我们首先从城镇 1 到城镇 4,但不能在这里结束路径,因为我们还没有拿到饼干盒。然后我们从城镇 4 到城镇 2,拿到了饼干盒。最后我们从城镇 2 到城镇 4,这是我们第二次访问城镇 4,不过这次我们带着饼干盒,因此我们得到了一个可行的路径。
看起来这条路径是有环的,因为我们先到达城镇 4,然后又再次到达城镇 4。然而,从另一个角度来看,这根本没有环。当我们第一次访问城镇 4 时,手里没有饼干盒;当我们第二次访问城镇 4 时,手里有了饼干盒。因此这两次访问并不是重复的:虽然城镇 4 被访问了两次,但每次的状态(是否携带饼干盒)是不同的。
现在我们看到,同一个城镇最多只能访问两次。如果一个城镇被访问了三次,那么这三次访问中必须有两次发生在同一状态下。也许访问 1 和访问 2 都处于“没有拿饼干盒”状态。那就真的形成了一个环,而我们需要花费一定的距离来遍历这个环,所以去掉它会缩短路径。
因此,知道我们在哪个城镇是不够的。我们还需要知道是否已经拿到饼干盒。
我们之前曾经遇到过类似的问题,解决过《第五章》的绳子攀爬问题。在第五章中,我们讨论了增加第二根绳子来得到一个更合适的模型。我们将在这里重温这个想法,通过添加一个状态来告诉我们是否正在携带饼干盒。在状态 0 中,不携带饼干盒;在状态 1 中,携带饼干盒。一个可行的路径是指任何到达奶奶家时处于状态 1 的路径。到达奶奶家时如果处于状态 0,那就不能算作可行路径的结束。
看一看图 6-5,它在图 6-4 的基础上引入了饼干状态。再次说明,c表示一个饼干城镇。没有箭头的边是无向的,但现在我们也有一些有向边。

图 6-5:带有饼干状态的奶奶图
下面是我们创建这个图的步骤:
-
添加四个新的城镇节点,每个原始城镇对应一个新节点。原始节点位于状态 0;新节点位于状态 1。
-
保留所有原始边,除了那些离开小镇 2(有饼干店的小镇)的边。如果我们在状态 0 下到达小镇 2,那么我们已经过渡到状态 1,因此离开(2,0)的唯一边是指向(2,1)的有向边。它是一个 0 权重的边,因为更改状态不需要时间。虽然 Dijkstra 算法不能在含有负权边的图上信赖(请参阅第 211 页中的“负权边”),但 0 权重的边是可以的。
-
使用与原先连接状态 0 节点相同的边,连接状态 1 的节点。
当我们处于状态 0 并到达一个有饼干店的小镇时,我们购买一盒饼干并最终到达状态 1。一旦我们进入状态 1,图中就没有办法返回状态 0,因为没有办法丢失饼干盒。
我们从小镇 1 的状态 0 开始。我们必须到达小镇 4 的状态 1。这要求我们最终从状态 0 过渡到状态 1,然后通过状态 1 的边到达小镇 4。当有多个小镇有饼干店时,问题变得越来越棘手,因为我们必须选择哪个饼干小镇将我们从状态 0 带到状态 1。嗯,这对我们来说可能很棘手,但对 Dijkstra 算法来说却不是,因为我们只是在图中寻找最短路径。
任务 1:最短路径
到目前为止,我们讨论了如何将问题建模为图并找到最短路径距离,但还没有讨论如何找到最短路径的数量。我将依次处理这两个子任务。在这一小节的结尾,我们将解决问题的一半,正确打印最短路径距离。但对于路径数量,我们暂时不会打印任何内容,所以我们仍然会失败所有测试用例。别担心:在下一小节中,我们将研究如何从代码中引出路径的数量。是时候使用 Dijkstra 算法了!
在我们的新模型中(使用状态 0 和状态 1),我们从输入读取的图不再对应于我们用 Dijkstra 算法探索的图。一个思路是从原始图的邻接表表示中生成新图的邻接表表示。也就是说,从一个空图开始,图中有两倍的节点数,并添加所有必需的边。虽然这样做是可行的,但我认为更简单的方法是保持图的原样,在 Dijkstra 算法的代码中逻辑上添加状态。(在解决第五章中的绳索攀爬问题时,我们没有太多选择,因为输入没有包含图。)
我们将编写一个函数,签名如下:
void solve(edge *adj_list[], int num_towns, int store[])
这里,adj_list是邻接表,num_towns是小镇的数量(也是奶奶的小镇数量),store告诉我们给定的i小镇是否有饼干店。
现在我们将按照与老鼠迷宫(Listing 6-2)相同的方式进行。不过,在每一步,我们都会问状态对代码的影响,并做出相应的修改。让我们通过 Listing 6-4 中的代码来一步步走过。将此代码与 Listing 6-2 进行对比,突出其中的相似性。
void solve(edge *adj_list[], int num_towns, int store[]) {
static int done[MAX_TOWNS + 1][2];
static int min_distances[MAX_TOWNS + 1][2];
int i, j, state, found;
int min_distance, min_town_index, min_state_index, old_distance;
edge *e;
➊ for (state = 0; state <= 1; state++)
for (i = 1; i <= num_towns; i++) {
done[i][state] = 0;
min_distances[i][state] = -1;
}
➋ min_distances[1][0] = 0;
➌ for (i = 0; i < num_towns * 2; i++) {
min_distance = -1;
found = 0;
for (state = 0; state <= 1; state++)
for (j = 1; j <= num_towns; j++) {
if (!done[j][state] && min_distances[j][state] >= 0) {
if (min_distance == -1 || min_distances[j][state] < min_distance) {
min_distance = min_distances[j][state];
min_town_index = j;
min_state_index = state;
found = 1;
}
}
}
if (!found)
break;
➍ done[min_town_index][min_state_index] = 1;
➎ if (min_state_index == 0 && store[min_town_index]) {
old_distance = min_distances[min_town_index][1];
if (old_distance == -1 || old_distance > min_distance)
min_distances[min_town_index][1] = min_distance;
} else {
➏ e = adj_list[min_town_index];
while (e) {
old_distance = min_distances[e->to_town][min_state_index];
if (old_distance == -1 || old_distance > min_distance + e->length)
min_distances[e->to_town][min_state_index] = min_distance +
e->length;
e = e->next;
}
}
}
❼ printf("%d\n", min_distances[num_towns][1]);
}
Listing 6-4:使用 Dijkstra 算法到奶奶家的最短路径
从一开始,我们就看到了状态对我们数组的影响,因为done和min_distances现在是二维数组。第一维是按城镇编号索引的,第二维是按状态索引的。在我们的初始化 ➊中,我们小心地初始化了两个状态的元素。
我们的起点是城镇 1,状态 0,因此这是我们初始化为0的距离 ➋。
和往常一样,我们希望继续运行 Dijkstra 算法,直到找不到新的节点。我们有num_towns个城镇,但每个城镇在状态 0 和状态 1 中都有,所以我们最多需要找到num_towns * 2个节点 ➌。
嵌套的state和j循环一起找到下一个节点。当这些循环完成 ➍时,两个重要的变量将被设置:min_town_index给出城镇的索引,min_state_index给出状态的索引。
我们的下一步取决于我们所在的状态以及城镇是否有饼干店。如果我们处于状态 0 且位于有饼干店的城镇 ➎,那么我们忽略adj_list,仅考虑转换到状态 1。记住,从[min_town_index][0]到[min_town_index][1]的转换距离为0,因此我们到[min_town_index][1]的新路径与到[min_town_index][0]的最短路径具有相同的距离。按照典型的 Dijkstra 方式,如果我们新路径更短,就更新最短路径。
否则,我们处于状态 0,但不在有饼干店的城镇,或者我们处于状态 1。此时,当前城镇的所有可用边正是输入图中的边,所以我们检查所有来自min_town_index的边 ➏。现在,我们进入老鼠迷宫的领域,使用边e寻找新的更短路径。只需注意,在所有地方都使用min_state_index,因为这些边都不会改变状态。
最后一步是打印最短路径距离 ➐。我们使用num_towns作为第一个索引(这是奶奶的城镇),并使用1作为第二个索引(表示一个饼干盒正在被携带)。
如果你在“邻接矩阵”一节的测试案例上运行我们的程序(见第 214 页),你应该得到正确的输出4。实际上,对于任何测试案例,我们都会输出最短路径。接下来,让我们讨论最短路径的数量。
任务 2:最短路径的数量
只需进行几处修改,就能增强 Dijkstra 算法,使其不仅找到最短路径的距离,还能找到最短路径的数量。这些修改很微妙,因此我将先通过几个步骤的例子,给你一些直觉,帮助你理解我们所做的修改为什么是合理的。然后,我会展示新的代码,并给出更详细的正确性证明。
通过一个例子来讲解
让我们从节点 (1,0) 开始,追踪 图 6-5 上的 Dijkstra 算法。除了跟踪每个节点是否完成以及到每个节点的最短距离外,我们还会跟踪 num_paths,即到该节点的最短路径数量。我们将看到,每当找到一条更短的路径时,由 num_paths 计算的路径会被丢弃。
首先,我们初始化起始节点 (1, 0) 的状态。我们将其最短距离设置为 0,并标记为已完成。由于从起始节点到自身有一条距离为 0 的路径(即没有边的路径),因此我们将其路径数量设置为 1。然后,我们使用从起始节点出发的边来初始化其他节点,并将每个节点的路径数量设置为 1(即从起始节点出发的路径)。这就是我们的第一个快照:
| node | done | min_distance | num_paths |
|---|---|---|---|
| (1,0) | true | 0 | 1 |
| (2,0) | false | 3 | 1 |
| (3,0) | false | 8 | 1 |
| (4,0) | false | 2 | 1 |
| (1,1) | false | ||
| (2,1) | false | ||
| (3,1) | false | ||
| (4,1) | false |
接下来怎么办?如同 Dijkstra 算法的惯例,我们会扫描未完成的节点,并选择一个具有最小 min_distance 值的节点。因此,我们选择节点 (4,0)。Dijkstra 算法保证该节点的最短路径已经确定,因此我们可以将其标记为已完成。然后,我们必须检查从 (4,0) 出发的边,看看是否能找到到其他节点的更短路径。我们确实找到了到 (3,0) 的更短路径:之前是 8,但现在是 7,因为我们可以先到达 (4,0)(距离为 2),然后再从 (4,0) 到 (3,0)(距离为 5)。那么,(3,0) 的最短路径数量应该是多少呢?它原来是 1,因此很容易想到把它设置为 2。然而,2 是错误的,因为这样会计算原来距离为 8 的路径,而那不再是最短路径。正确的答案是 1,因为只有一条距离为 7 的路径。
从 (4,0) 到 (2,0) 有一条边,我们不应该太快忽略它。到 (2,0) 的旧最短路径是 3。那么,(4,0) 到 (2,0) 的边为我们带来了什么?它给我们提供了一条更短的路径吗?好吧,(4,0) 的距离是 2,而从 (4,0) 到 (2,0) 的边的距离是 1,因此我们有了一种新的到达 (2,0) 的方法,距离是 3。这不是一条更短的路径,但它是 另一条 最短路径!也就是说,先到达 (4,0),然后通过边到 (2,0) 给我们提供了到达 (2,0) 的新方法。新的方法数量等于到 (4,0) 的最短路径数量,即只有一条。因此,得到 (2,0) 的最短路径总数是 1 + 1 = 2 条。
这一切总结在下一个快照中:
| 节点 | 已完成 | 最小距离 | 路径数量 |
|---|---|---|---|
| (1,0) | true | 0 | 1 |
| (2,0) | false | 3 | 2 |
| (3,0) | false | 7 | 1 |
| (4,0) | true | 2 | 1 |
| (1,1) | false | ||
| (2,1) | false | ||
| (3,1) | false | ||
| (4,1) | false |
下一步完成的节点是(2,0)。从(2,0)到(2,1)有一条权重为 0 的边,而且从(2,0)到达(2,1)的距离是 3,因此我们也有一条到(2,1)的最短路径,距离为 3。到(2,0)的最短路径有两种方式,所以到(2,1)也有两种方式。现在的情况如下:
| 节点 | 已完成 | 最小距离 | 路径数量 |
|---|---|---|---|
| (1,0) | true | 0 | 1 |
| (2,0) | true | 3 | 2 |
| (3,0) | false | 7 | 1 |
| (4,0) | true | 2 | 1 |
| (1,1) | false | ||
| (2,1) | false | 3 | 2 |
| (3,1) | false | ||
| (4,1) | false |
下一步完成的节点是(2,1),正是这个节点找到了到目的地(4,1)的最短路径距离。从(2,1)到(2,1)有两条最短路径,所以到(4,1)也有两条最短路径。节点(2,1)还找到到(1,1)和(3,1)的新最短路径。现在的情况如下:
| 节点 | 已完成 | 最小距离 | 路径数量 |
|---|---|---|---|
| (1,0) | true | 0 | 1 |
| (2,0) | true | 3 | 2 |
| (3,0) | false | 7 | 1 |
| (4,0) | true | 2 | 1 |
| (1,1) | false | 6 | 2 |
| (2,1) | true | 3 | 2 |
| (3,1) | false | 5 | 2 |
| (4,1) | false | 4 | 2 |
节点(4,1)是下一个出列的节点,因此我们得到了答案:最短路径是 4,最短路径数量是 2。(在我们的代码中,目的地处不会有停止条件,所以 Dijkstra 算法会继续执行,为其他节点找到最短路径和最短路径数量。我鼓励你坚持完成这个例子,直到最后。)
就是这样,算法的工作原理。可以通过两个规则总结如下:
规则 1 假设我们使用节点u找到到节点v的更短路径。那么,到v的最短路径数量就是到u的最短路径数量。(所有以前到v的路径都无效,并且不再计算,因为我们现在知道它们不是最短路径。)
规则 2 假设我们使用节点u找到到节点v的路径,并且该路径的距离与当前到v的最短路径相同。那么,到v的路径数量就是我们已经为v找到的最短路径数量,加上到u的最短路径数量。(所有以前到v的路径仍然有效。)
假设我们关注某个节点n,并观察在运行过程中它的最短距离和最短路径数量的变化。我们并不知道通往n的最短路径是什么:现在可能已经有了最短路径,也可能后来 Dijkstra 算法会找到一条更短的路径。如果我们现在已经有了最短路径,那么最好将通往n的最短路径数量累积起来,因为最终我们可能需要这个值来计算其他节点的最短路径数量。如果现在没有最短路径,那么回过头来看,我们可能会无意义地累积了最短路径数量。不过没关系,因为当我们找到更短的路径时,我们会重置最短路径数量。
代码
为了解决这个任务,我们可以从清单 6-4 开始,并进行必要的更改以找到最短路径的数量。更新后的代码可以在清单 6-5 中找到。
#define MOD 1000000
void solve(edge *adj_list[], int num_towns, int store[]) {
static int done[MAX_TOWNS + 1][2];
static int min_distances[MAX_TOWNS + 1][2];
➊ static int num_paths[MAX_TOWNS + 1][2];
int i, j, state, found;
int min_distance, min_town_index, min_state_index, old_distance;
edge *e;
for (state = 0; state <= 1; state++)
for (i = 1; i <= num_towns; i++) {
done[i][state] = 0;
min_distances[i][state] = -1;
➋ num_paths[i][state] = 0;
}
min_distances[1][0] = 0;
➌ num_paths[1][0] = 1;
for (i = 0; i < num_towns * 2; i++) {
min_distance = -1;
found = 0;
for (state = 0; state <= 1; state++)
for (j = 1; j <= num_towns; j++) {
if (!done[j][state] && min_distances[j][state] >= 0) {
if (min_distance == -1 || min_distances[j][state] < min_distance) {
min_distance = min_distances[j][state];
min_town_index = j;
min_state_index = state;
found = 1;
}
}
}
if (!found)
break;
done[min_town_index][min_state_index] = 1;
if (min_state_index == 0 && store[min_town_index]) {
old_distance = min_distances[min_town_index][1];
➍ if (old_distance == -1 || old_distance >= min_distance) {
min_distances[min_town_index][1] = min_distance;
➎ if (old_distance == min_distance)
num_paths[min_town_index][1] += num_paths[min_town_index][0];
else
num_paths[min_town_index][1] = num_paths[min_town_index][0];
➏ num_paths[min_town_index][1] %= MOD;
}
} else {
e = adj_list[min_town_index];
while (e) {
old_distance = min_distances[e->to_town][min_state_index];
if (old_distance == -1 ||
old_distance >= min_distance + e->length) {
min_distances[e->to_town][min_state_index] = min_distance +
e->length;
❼ if (old_distance == min_distance + e->length)
num_paths[e->to_town][min_state_index] +=
num_paths[min_town_index][min_state_index];
else
num_paths[e->to_town][min_state_index] =
num_paths[min_town_index][min_state_index];
➑ num_paths[e->to_town][min_state_index] %= MOD;
}
e = e->next;
}
}
}
➒ printf("%d %d\n", min_distances[num_towns][1], num_paths[num_towns][1]);
}
清单 6-5:到奶奶家的最短路径和最短路径数量
我添加了一个num_paths数组,用来跟踪我们为每个节点找到的路径数量 ➊,并将其所有元素初始化为0 ➋。num_paths中唯一不为零的元素是我们的起始节点(1,0),它的路径数量为 1,距离为 0(即从起始节点开始并没有经过任何边的路径) ➌。
剩下的工作是更新num_paths。如我们所讨论的,有两种情况。如果我们找到一条更短的路径,那么旧的路径数量就不再有效。如果我们找到另一条路径,它的当前路径距离相同,那么我们就将旧的路径数量加上。这第二种情况如果不小心就会出错,因为我们需要在检查“大于”的同时,还需要加入等于的检查 ➍。如果我们一直使用本章中用到的代码,
if (old_distance == -1 || old_distance > min_distance) {
那么,只有在找到更短的路径时,路径数量才会更新;无法从多个源累积最短路径。相反,我们使用>=而不是>。
if (old_distance == -1 || old_distance >= min_distance) {
这样我们就可以找到更多的最短路径,即使最短路径本身没有变化。
现在我们可以准确地实现我们之前讨论的两种更新路径数量的情况。我们必须执行这两种情况两次,因为代码中有两个地方,Dijkstra 算法可能会找到最短路径。第一个添加 ➎ 是针对从状态 0 开始的 0 权重边的代码。如果最短路径与之前相同,我们就加上;如果现在有了新的更短路径,我们就重置。第二个相同的代码添加 ➐ 是针对循环遍历当前节点的出边的代码。在这两种情况下,我们都使用模运算符 ➏ ➑ 来确保路径数不超过 1,000,000。
最终需要做的更改是在最后更新printf调用 ➒,现在它也会打印到奶奶家的最短路径数量。
你准备好提交给评审了。在我们最终总结之前,先讨论一下正确性。
算法正确性
在我们的 Grandma Planner 图中没有负权边,所以我们知道 Dijkstra 算法会正确地找到所有最短路径距离。图中有一些 0 权重的边——每个饼干城镇从状态 0 到对应的状态 1 的城镇之间都有这样的边——但是 Dijkstra 算法在找到最短路径时处理这些边没有问题。
然而,我们必须仔细思考 0 权重边对找到最短路径“数量”的影响。如果我们允许任意的 0 权重边,那么可能会有一个 无限 多的最短路径。看看 图 6-6,其中我们有从 A 到 B、从 B 到 C 和从 C 到 A 的 0 权重边。例如,从 A 到 C 的最短路径是 0,我们有无限多这样的路径:A → B → C,A → B → C → A → B → C,依此类推。

图 6-6:一个具有无限多最短路径的图
幸运的是,0 权重边的循环在 Grandma Planner 图中实际上不会出现。记住,所有的道路距离至少是 1。假设从节点 u 到节点 v 存在一条 0 权重的边。这意味着 u 在状态 0,v 在状态 1。我们永远无法从 v 返回到 u,因为我们的图没有提供从状态 1 返回状态 0 的方法。
最后,我要提出以下观点:一旦节点被设置为完成,我们就已经找到了它的最短路径总数。考虑一个算法执行过程,其中它给出了错误的最短路径数。我们的算法正常运行,找到最短路径和最短路径数量……然后,突然,它第一次出错。它将某个节点 n 设置为完成,但它错过了找出其中一些最短路径。我们需要证明这个错误是不会发生的。
假设某些最短路径到 n 以某条边 m → n 结束。如果 m → n 的权重大于 0,那么到 m 的最短路径比到 n 的最短路径要短。(这就是到 n 的最短路径减去 m → n 的权重。)Dijkstra 算法通过找到越来越远的节点来工作,因此节点 m 必须在此时被标记为完成。当 Dijkstra 算法将 m 设置为完成时,它会遍历所有从 m 出发的边,包括 m → n。由于 m 的路径数量已经正确设置(m 已完成,且 Dijkstra 算法尚未犯错),Dijkstra 算法会将这些路径全部计入 n 的路径数中。
那么,如果 m → n 是一条 0 权重的边呢?我们需要先完成 m,然后才能完成 n;否则,当探索离开 m 的边时,m 的路径数量将不可信。我们知道,0 权重的边是从状态 0 中的一个节点到状态 1 中的一个节点,因此 m 必须在状态 0 中,n 必须在状态 1 中。到 m 的最短路径必须与到 n 的最短路径相同,因为 0 权重边对 m 的最短路径没有影响。那么,在某个时刻,当 m 和 n 都未完成时,Dijkstra 算法将不得不选择下一个完成的节点。它最好选择 m;而它会选择 m,因为根据我写的代码,在存在平局的情况下,它会选择状态 0 中的节点,而不是状态 1 中的节点。
我们需要小心谨慎:我们实际上是在侥幸取巧。以下是一个测试用例,说明为什么我们必须先处理状态 0 节点,再处理状态 1 节点:
4
0 3 1 2
3 0 2 1
1 2 0 5
2 1 5 0
2
2 3
在这个例子中跟踪我们修改后的 Dijkstra 算法。如果你有选择下一个完成的节点的机会,选择一个来自状态 0 的节点。如果你这样做,你将得到正确的答案:最短路径距离为四,最短路径数量为四条。然后,再次跟踪算法,这次通过选择来自状态 1 的节点来打破平局。你仍然会得到正确的最短路径距离四,因为 Dijkstra 算法对平局如何打破并不敏感。但我们修改后的 Dijkstra 算法是敏感的,证明这一点的是,你应该得到两条最短路径,而不是四条。
总结
Dijkstra 算法旨在寻找图中的最短路径。在本章中,我们已经了解了如何将问题实例建模为合适的加权图,然后使用 Dijkstra 算法。此外,Dijkstra 算法像 第五章中的广度优先搜索(BFS)一样,可以作为解决相关但不同问题的指南。在“祖母计划者”问题中,我们通过对 Dijkstra 算法进行适当修改,找到了最短路径的数量。我们不需要从头开始。我们并不总是被要求找到最短路径。如果 Dijkstra 算法只是一个坚定不移的算法,只为寻找最短路径而设计,那它在上下文发生变化时就没有任何帮助。事实上,我们会学到一个强大的算法,但它的性质是非黑即白的。幸运的是,Dijkstra 算法的应用范围更广。如果你继续研究图算法,超出本书所包含的内容,你很可能会再次看到 Dijkstra 算法的思想。尽管可能存在数百万个问题,但算法远少于此。最好的算法通常是那些基于灵活的思想的算法,它们可以超越最初的目的。
注释
老鼠迷宫问题最初来源于 2001 年西南欧洲区域赛。祖母计划者问题最初来自 2008 年南非编程奥林匹克赛决赛。
想了解更多图搜索及其在竞争性编程问题中的应用,我推荐 Steven Halim 和 Felix Halim(2020)的 《Competitive Programming 4》。
第七章:二分查找

本章的内容完全是关于二分查找的。如果你不知道二分查找是什么——太棒了!我很高兴有机会教你一种系统的、高效的技术,用来在成千上万的可能解中找到最佳解。如果你知道二分查找是什么,并且认为它只是用来搜索已排序的数组——也太棒了!你将会学到,二分查找不仅仅是为了这个。在本章中,我们将不会搜索已排序的数组,甚至一次也不做。
最小化喂养蚂蚁所需的液体量、最大化岩石之间最小跳跃距离、寻找城市中最佳的居住区域以及通过切换开关打开洞穴门,这些问题有什么共同点?让我们一探究竟。
问题 1:喂蚂蚁
这是 DMOJ 问题 coci14c4p4。
问题
Bobi 有一个形状像树的玻璃箱。树的每一条边都是一根管道,液体沿着这些管道流下。某些管道是超级管道,可以增加通过它们的液体量。Bobi 在树的每一片叶子上放置了一只他的宠物蚂蚁。(是的,这个背景设定有点牵强,我不会装作没有意识到,但这个问题本身是非常棒的。)
每根管道都有一个百分比值,表示通过它的液体占总液体的百分比。例如,假设某个节点 n 有三根向下的管道,这些管道的百分比值分别是 20%、50% 和 30%。如果 20 升液体到达节点 n,那么 20% 的管道会得到 20 × 0.2 = 4 升,50% 的管道会得到 20 × 0.5 = 10 升,30% 的管道会得到 20 × 0.3 = 6 升。
现在考虑超级管道。对于每个超级管道,Bobi 决定它的特殊行为是开启还是关闭。如果它关闭,那么它像普通管道一样工作。如果它开启,那么它会将接收到的液体量平方。
Bobi 将液体倒入树的根部。他的目标是给每只蚂蚁至少提供它所需的液体,并且尽可能少地倒入液体。
让我们通过研究一个样本玻璃箱来具体化这个描述;见 图 7-1。

图 7-1:一个样本玻璃箱
我已经将节点编号为 1 到 6;叶子节点(2、3、5 和 6)有一个额外的注释,标明了每只蚂蚁所需的液体量。我还在每条边上标注了它的百分比值。注意,从给定节点出发的向下管道的百分比值总和总是 100%。
树中有一条超级管道,从节点 1 到节点 4;我已用较粗的边表示这一管道。假设 20 升液体被倒入根节点。超级管道获得 20 升液体的 30%,即 6 升。如果超级管道的特殊行为关闭,那么 6 升液体会通过它。然而,如果超级管道的特殊行为开启,那么,6 升液体不会流过,而是 6² = 36 升液体流过。
输入
输入包含一个测试用例,包含以下行:
-
一行包含 n,树中节点的数量。n 的值介于 1 到 1,000 之间。树的节点编号从 1 到 n,根节点是节点 1。
-
n – 1 行用于构建树。每一行表示一条管道,包含四个整数:连接该管道的两个节点,管道的百分比值(介于 1 和 100 之间),以及管道是否是超级管道(0 表示不是,1 表示是)。
-
一行包含 n 个整数,每个整数表示该节点上蚂蚁所需的液体升数。每只蚂蚁需要 1 到 10 升液体。对于任何非叶节点(即没有蚂蚁的节点),给定值为-1。
这里是一个可能生成图 7-1 中示例植物箱的输入:
6
1 2 20 0
1 3 50 0
1 4 30 1
4 5 50 0
4 6 50 0
-1 2 9 -1 7 8
请注意,第一行(此处为整数 6)表示树中节点的数量,而不是构建树的行数。构建树的行数(在本例中为五行)总是比节点数少一行。(为什么总是少一行?每一行构建树时,实际上告诉我们一个节点的父节点信息。除了根节点外,每个节点都有一个父节点,因此我们需要 n – 1 行来描述所有的 n – 1 个父节点。)
输出
输出 Bobi 必须倒入树根的最少升数,以便喂养所有蚂蚁。输出结果保留四位小数。正确的输出保证不会超过 2,000,000,000(20 亿)。
解决测试用例的时间限制是 0.6 秒。
一种新的树问题类型
如同在第二章中一样,我们现在处于树的领域。如果我们想探索一个植物箱树,那么可以使用递归。(像广度优先搜索(BFS)这样的完整图搜索算法是多余的,因为没有循环。)
在第二章的两个问题中,我们的解决方案是基于树的结构和节点中存储的值:
-
在《万圣节糖果运输》中,我们通过将叶节点中的值相加来计算总糖果数,并通过树的高度和形状来计算总街道行走数。
-
在《后代距离》中,我们通过使用每个节点的子节点数量来计算所需距离的后代数。
也就是说,我们需要的——糖果的数量、身高、树的形状——这些信息就存在树结构里,编码在其中。在当前问题中,我们需要找到 Bobi 必须倒入的最小升数——但树中并没有类似的值!树中有管道的百分比、超级管道状态以及蚂蚁的食量信息,但没有直接告诉我们应该倒入多少液体。特别是,超级管道的液体平方行为使得蚂蚁所需液体量与应倒入的液体量之间的关系变得不清晰。
因为树不会直接给我们需要的值,我就随便挑一个数值——比如,10 升。好了,Bobi,把 10 升倒进去。
我希望你对我刚才做的事非常怀疑,随便选择一个数字。你应该会对 10 升是答案感到惊讶。毕竟,我是从空中凭空选择了 10 升。你可能还会惊讶于,事实上,我们通过尝试 10 升并观察结果,可以学到很多东西。
我们再用一下图 7-1。假设我们向根部倒入 10 升液体。10 升的 20%是 2 升,因此 2 升液体会到达节点 2 的蚂蚁。完美:那只蚂蚁需要 2 升液体,所以我们正好倒入了足够的液体。接下来继续。
由于 10 升的 50%是 5,节点 3 的蚂蚁得到 5 升液体。现在我们有麻烦了:那只蚂蚁需要 9 升液体,而 5 升显然不够。更糟糕的是:节点 1 和节点 3 之间的管道不是超级管道,所以我们无能为力,只能宣告 10 升不是解答。
我们可以通过随便挑选另一个升数来继续,类似地模拟那个新数字下的液体流动。然而,由于 10 升不足,现在我们应该将我们的“随便挑选”范围限制为仅大于 10的数值。因为 10 升不足,任何更小的数值也都不足。尝试 2 升、7 升、9.5 升或任何少于 10 升的数值都没有意义。它们都太少了。
接下来,我们试试 20 升。这次,节点 2 的蚂蚁得到 4 升,这完全没问题,因为那只蚂蚁只需要 2 升。节点 3 的蚂蚁得到 10 升,这也没问题,因为那只蚂蚁只需要 9 升。
节点 1 和节点 4 之间的管道吸收了总液体的 30%,也就是 20 升中的 6 升。然而,这条管道是超级管道!如果我们使用它的特殊行为,这 6 升液体会被管道提升至 6² = 36 升,因此 36 升液体到达节点 4。现在,节点 5 和节点 6 的蚂蚁没问题了:每只蚂蚁得到 18 升,而它们只需要 7 升(节点 5)和 8 升(节点 6)。
与 10 升不同,20 升是一个可行的解,但它是最优(即最小)解吗?也许是,也许不是。我们可以确定的是,测试任何大于 20 升的液体量没有意义。我们已经得到了 20 升作为一个可行的解;为什么要尝试更差的值,比如 25 或 30 呢?
我们现在将问题简化为在 10 到 20 升之间找到最优解。我们可以不断选择数字,在每一步缩小范围,直到范围足够小,以至于其中一个端点可以作为准确的解。
在一般情况下,我们应该先选择多少升液体?最优解可能高达 20 亿,因此从 10 开始可能会相差甚远。而且,一旦我们测试了一个液体数量,接下来应该选择多少呢?最优解可能比我们当前的猜测要大得多或小得多,所以每次增加或减少 10 可能无法帮助我们取得多大进展。
这些都是好问题,值得回答……但还不到时候。让我们首先处理如何读取输入(以便可以探索树结构),以及如何判断一个液体数量是否是可行的解决方案。然后,我们将看到一个超快的算法,用于搜索巨大的范围。20 亿的范围?我们吃得了这个早餐。
读取输入
在第二章中,我们使用了一个node结构体来表示树的核心结构。然后,在第五章《书籍翻译》中,我们使用了图的邻接表表示法,并使用了一个edge结构体。在那里,我们学到了使用node结构体还是edge结构体取决于是节点还是边缘承载额外的属性。在当前的问题中,边缘承载了信息(一个百分比和一个超管道状态),但叶节点也承载信息(每只蚂蚁所需的液体量)。因此,使用两者的edge结构体和node结构体既合理又具有吸引力。然而,为了更好地模拟邻接表的使用,我选择仅使用edge结构体。如同问题描述所说,我们从 1 开始对节点进行编号,但由于没有node结构体,我们没有地方存储每只蚂蚁所需的液体量。因此,我们通过一个liquid_needed数组来扩展邻接表,其中liquid_needed[i]给出了节点i中蚂蚁所需的液体量。
这是我们将在代码中使用的常量和typedef:
#define MAX_NODES 1000
typedef struct edge {
int to_node, percentage, superpipe;
struct edge *next;
} edge;
如同在《书籍翻译》(第五章)和第六章中的两个问题一样,我们可以通过next指针将这些edge结构体连接在一起,形成一个边的链表。如果一个边在节点i的链表中,那么我们就知道该边的父节点是i。to_node成员告诉我们该边与父节点连接的子节点;percentage是一个介于 1 到 100 之间的整数,表示该管道(边)的百分比值;superpipe是一个标志位,如果该管道是超级管道则其值为1,如果是普通管道则为0。
现在,我们可以从输入中读取树,如列表 7-1 所示。
int main(void) {
static edge *adj_list[MAX_NODES + 1] = {NULL};
static int liquid_needed[MAX_NODES + 1];
int num_nodes, i;
int from_node, to_node, percentage, superpipe;
edge *e;
scanf("%d", &num_nodes);
for (i = 0; i < num_nodes - 1; i++) {
scanf("%d%d%d%d", &from_node, &to_node, &percentage, &superpipe);
e = malloc(sizeof(edge));
if (e == NULL) {
fprintf(stderr, "malloc error\n");
exit(1);
}
e->to_node = to_node;
e->percentage = percentage;
e->superpipe = superpipe;
e->next = adj_list[from_node];
➊ adj_list[from_node] = e;
}
for (i = 1; i <= num_nodes; i++)
➋ scanf("%d", &liquid_needed[i]);
solve(adj_list, liquid_needed);
return 0;
}
列表 7-1:构建树的主函数*
这段代码类似于列表 5-16(书籍翻译)中的代码,但更简洁。具体来说,每条边都是从输入中读取的,成员被设置好后,再将其添加到from_node的边列表中➊。你可能会期望会为to_node添加对应的边,因为图是无向的,但我省略了这些边:液体沿树向下流动,而不是向上流动,因此添加反向边会不必要地复杂化探索树的代码。
一旦读取了边的信息,剩下的就是读取每个蚂蚁所需的液体量。我们将使用liquid_needed数组来存储这些信息➋。adj_list和liquid_needed的组合包含了我们需要了解的测试用例的所有信息。
测试可行性
我们的下一个里程碑是:确定给定的液体量是否为可行解。这是一个关键步骤,因为一旦我们有了一个可以测试可行性的函数,我们就能够利用它逐步缩小搜索范围,直到找到最优解。以下是我们将要编写的函数签名:
int can_feed(int node, double liquid,
edge *adj_list[], int liquid_needed[])
在这里,node是树的根节点,liquid是我们倒入树根的液体量,adj_list是树的邻接表,liquid_needed是每个蚂蚁所需的液体量。如果liquid足够喂养蚂蚁(即,liquid是可行的解决方案),我们将返回1,否则返回0。
我们在整个章节中(第二章)都在编写树的递归函数。让我们思考一下,是否还能再次使用递归。
记住,要使用递归,我们需要一个基准情况——一个可以不通过递归解决的情况。幸运的是,我们有一个!如果树只有一个叶子节点,我们就可以立即判断liquid是否足够。如果liquid大于或等于该叶子节点中蚂蚁所需的液体量,那么我们就有一个可行的解决方案;否则,我们就没有。
我们可以通过检查liquid_needed中的相应值来判断一个节点是否是叶子节点:如果值为-1,那么它不是叶子节点;否则,它就是叶子节点。(我们也可以使用邻接表来检查该节点的链表是否为空。)下面是我们得到的结果:
if (liquid_needed[node] != -1)
return liquid >= liquid_needed[node];
现在,考虑递归情况。假设某棵树的根节点有p条向下的管道(即,p个子节点)。我们已经知道根节点输入的液体量。通过管道的百分比值,我们可以确定每条管道进入的液体量;通过超级管道的状态,我们可以确定到达每条管道底端的液体量。如果足够的液体到达每条管道的底端,那么根节点输入的液体量就是足够的,我们应该返回1。否则,某条管道到达底端的液体量不足,我们应该返回0。这表明我们应该进行p次递归调用,每次调用处理一条从根节点出发的管道。我们将在一个循环中使用邻接表遍历每一条管道。
函数的完整代码见清单 7-2。
int can_feed(int node, double liquid,
edge *adj_list[], int liquid_needed[]) {
edge *e;
int ok;
double down_pipe;
if (liquid_needed[node] != -1)
return liquid >= liquid_needed[node];
e = adj_list[node];
➊ ok = 1;
while (e && ok) {
down_pipe = liquid * e->percentage / 100;
if (e->superpipe)
➋ down_pipe = down_pipe * down_pipe;
if (!can_feed(e->to_node, down_pipe, adj_list, liquid_needed))
➌ ok = 0;
e = e->next;
}
return ok;
}
清单 7-2:测试液体量的可行性
ok变量用于跟踪liquid是否是树的可行解。如果ok为1,则解决方案仍然可行;如果ok为0,则肯定不可行。我们将ok初始化为1 ➊,如果通过某条管道的液体量不足,我们就将其设为0 ➌。如果函数执行到最后,ok仍然为1,说明所有管道的需求都得到了满足,我们可以得出liquid是可行的结论。
我们通过使用管道的百分比值来确定进入每条管道的液体量。如果该管道是超级管道,我们就对该值进行平方 ➋ ……等等,等一下!问题描述说,Bobi 可以决定是否使用每条超级管道的特殊行为。然而,在这里,我们只是毫不犹豫地对液体量进行平方,从而总是使用特殊行为。
我们之所以能够这样做,是因为平方会使数值变大:比较 2 和 2² = 4,3 和 3² = 9,依此类推。由于我们想知道给定的液体量是否可行,而且使用超级管道的特殊行为没有惩罚,因此我们可以尽可能地生成更多的液体。或许我们能不用某些超级管道的特殊行为,但并没有人要求我们节约。
不用担心平方会让小于 1 的正值(例如 0.5)变得更小。0.5² = 0.25,所以在这种情况下,我们确实不希望激活超级管道的行为。每只蚂蚁至少需要 1 升液体。然而,如果我们在某个节点上只剩下 0.5 升液体,那么无论我们做什么,都无法为该节点子树中的蚂蚁提供足够的液体。即使我们不平方该值,最终也会返回0。
让我们通过继续在第 233 页的《一种新的树问题》中的工作,展示can_feed函数有多么有用。我们在那里展示了 10 升对问题描述中的示例实例来说是不够的。将列表 7-1 底部的solve调用注释掉(别担心,我们很快会编写那个solve函数),然后添加一个can_feed的调用来测试 10 升的液体:
printf("%d\n", can_feed(1, 10, adj_list, liquid_needed));
你应该看到0的结果,这意味着 10 升不够。我们也展示了 20 升是足够的。将can_feed的调用修改为测试 20 升而不是 10 升:
printf("%d\n", can_feed(1, 20, adj_list, liquid_needed));
你应该看到1的结果,这意味着 20 升足够。
现在,我们知道 10 不够,但 20 够。让我们进一步缩小这个范围。试试 15,你应该看到0的输出。所以,似乎 15 还不够。我们的最优答案现在大于 15,最多为 20。
接下来试试 18:你应该看到 18 是足够的。那 17 呢?不,17 不够,17.5 和 17.9 也不行。事实证明,最优解确实是 18。
够了,不再进行这种临时搜索了。让我们将其系统化。
寻找解决方案
从问题描述中,我们知道最优解最多是 20 亿。因此,存在一个庞大的搜索空间,最优解就在其中。我们的目标是尽可能快地缩小这个空间,通过避免浪费任何一次猜测来实现。
很容易浪费一次猜测。例如,如果我们从 10 开始猜测,而最优解实际上是 20 亿,那么我们基本上浪费了那次猜测:我们做的只是排除了 0 到 10 之间的数字。确实,如果最优解是 8 的话,猜 10 会很棒,因为那一步可以将范围缩小到 0 到 10,我们很快就能找到 8。然而,像这样随意猜测并不值得,因为偶尔运气好一次也无法弥补我们猜测几乎没有用的高概率情况。正因为如此,当有人让你猜测 1 到 1000 之间的数字时,你不会选择从 10 开始猜。没错,如果他们说“更低”,你看起来像个明星,但如果他们说“更高”,也就是最有可能的情况,那你基本上就浪费了第一次猜测。
为了确保每次猜测都能获得尽可能多的信息,我们总是猜测范围的中间值。为此,我们维护两个变量,low和high,分别表示当前范围的低端和高端。然后,我们计算范围的中间值mid,测试mid的可行性,并根据我们的发现更新low或high。我们将在列表 7-3 中实现这一策略。
#define HIGHEST 2000000000
void solve(edge *adj_list[], int liquid_needed[]) {
double low, high, mid;
low = 0;
high = HIGHEST;
➊ while (high - low > 0.00001) {
➋ mid = (low + high) / 2;
➌ if (can_feed(1, mid, adj_list, liquid_needed))
high = mid;
else
low = mid;
}
➍ printf("%.4lf\n", high);
}
列表 7-3:寻找最优解
初始化low和high很重要,这样它们的范围就能保证包含最优解。我们始终保持low小于或等于最优解,high大于或等于最优解。我们将low初始化为 0;因为每只蚂蚁至少需要 1 升,所以 0 升肯定小于或等于最优解。我们将high初始化为 20 亿,因为根据问题描述,20 亿是最优解的最大值。
while循环条件会在循环结束时迫使low和high之间的范围非常小 ➊。我们需要四位精度,因此在0.00001的小数点后有四个零。
循环体内的第一件事是计算范围的中间值。我们通过取low和high的平均值来实现这一点,将结果存储在mid中 ➋。
现在是时候测试mid升是否可行了,使用can_feed ➌。如果mid可行,我们已经知道猜测任何大于mid的值都是浪费时间。因此,我们将high = mid,将范围限制在最大值mid。
如果mid不可行,那么猜测任何比mid小的值都会浪费时间。因此,我们将low = mid,将范围限制在最小值mid。
一旦循环终止,low和high会非常接近。我们打印high ➍,但打印low同样有效。
这种技巧,我们通过不断将范围一分为二,直到它非常小,被称为二分查找。它是一个出奇精巧且强大的算法,剩下的本章问题将进一步证明这一点。它也非常快速,能够轻松处理数十亿或数万亿的范围。
提交解决方案给评测系统,然后我们继续。关于二分查找,还有很多要学的东西。
二分查找
"喂养蚂蚁"是二分查找得以发挥优势的典型问题。这类问题有两个关键要素;如果你在面对的新问题中看到了这些要素,那么尝试二分查找是值得的。
要素 1:硬性最优性与容易可行性 对于一些问题,找到最优解很困难。幸运的是,在许多这种情况下,确定某个提议的解决方案是否可行要容易得多。这就是“喂养蚂蚁”问题的情况:我们不知道如何直接找到最优解,但我们知道如何判断某个升数是否可行。
要素 2:不可行–可行分割 我们需要问题展现出一个特性,即在不可行解和可行解之间有一个边界。所有位于边界一侧的解都必须是不可行的,而另一侧的所有解都必须是可行的。在“喂养蚂蚁”问题中,小值是不可行的,大值是可行的。可以想象从小到大的值,并询问每个值是不可行的还是可行的。在这样做的过程中,我们会先看到一堆不可行的值,然后看到一个可行的值;在我们找到第一个可行值后,就再也看不到不可行值了。假设我们尝试了一个 20 升的值,并发现它是不可行的。这意味着我们仍然处于不可行的搜索空间中,必须继续搜索更大的值。如果 20 升是可行的,那么我们就进入了可行的搜索空间,应该搜索更小的值。(如果不满足要素 2,二分查找就失去了作用。例如,假设我们有一个问题,小值不可行,大值可行,而更大的值又不可行。我们尝试了一个 20 的值并发现它不可行。别想再关注 20 以后的值:因为我们无法知道 10 以下的值是否不可行,10 到 15 之间的值是否可行,最终最优解可能是 10。)如果搜索空间从可行变为不可行,而不是从不可行变为可行,也是可以的。我们的下一个问题将提供这样的例子。
二分查找的运行时间
二分查找如此强大的原因在于它只需一次迭代就能取得巨大的进展。例如,假设我们在一个二十亿的范围内搜索最优解。二分查找的单次迭代就能丢弃掉一半的范围,剩下只有十亿的范围。想象一下:仅仅通过一个if语句和一次对mid变量的更新,我们就取得了十亿的进展!如果二分查找需要q次迭代来搜索一个十亿的范围,那么只需要再加一次迭代,q + 1,就能搜索一个二十亿的范围。与范围宽度相比,迭代次数增长得非常缓慢。
二分查找将一个范围n缩小到 1 所需的迭代次数大致是将n除以 2 直到得到 1 所需要的次数。例如,假设我们从 8 开始一个范围。经过一次迭代,范围会缩小到最多 4。经过两次迭代,范围会缩小到最多 2。经过三次迭代,范围会缩小到 1。此外,如果我们不关心精度的小数位,那么就到此为止:三次迭代。
有一个数学函数叫做二进制对数,给定值n,它告诉你需要将n除以 2 多少次才能得到 1 或更小的值。它写作 log[2] n,或者当讨论明确表明底数为 2 时,简写为 log n。例如,log[2] 8 是 3,log[2] 16 是 4。log[2] 2,000,000,000(即 20 亿)是 30.9,因此大约需要 31 次迭代才能将这个范围缩小到 1。
二分查找是一个对数时间算法的例子。因此我们说它是O(log m)。 (通常你会在这里使用n而不是m,但在本节后面我们将使用n表示其他含义。)要将范围减少到 1,m是范围的初始宽度。然而,在“喂蚁”问题中,我们需要进一步处理,获取四位小数的精度。那么,m在这里代表什么呢?
现在是时候公开我们在“喂蚁”问题中如何使用二分查找了:我们做的不仅仅是 log[2] 2,000,000,000 次二分查找迭代,因为我们不会在范围宽度为 1 时停止。而是,一旦我们在小数点后获得四位精度时就停止。加上五个零,我们就得到了我们所做的迭代次数:log[2] 200,000,000,000,000 向上取整为 48 次。仅需 48 次迭代,就能从一个困惑的万亿范围中得出四位小数精度的解。这就是二分查找的魅力所在。
在一个n节点的树上,can_feed函数(见清单 7-2,“喂蚁”问题)需要线性时间;即,时间与n成正比。我们称该函数为 log[2] m × 10⁴次,其中m是范围的宽度(在测试用例中为 20 亿)。这与 log m的工作量成正比。因此,总体来说,我们需要进行n次工作,总共需要 log m次。这是一个O(n log m)算法。它不是完全线性的,因为有额外的 log m因素,但仍然非常快速。
确定可行性
我最喜欢二分查找算法的一点是,判断一个值是否可行通常需要使用其他类型的算法。也就是说,外部我们使用二分查找,但在内部——为了测试每个值是否可行——我们使用其他算法。这个“其他算法”可以是任何东西。在“喂蚁”问题中,它是树搜索。在我们下一个问题中,它将是贪心算法。在我们的第三个问题中,它将是动态规划算法。在本书中我们不会看到这一点,但确实有一些问题需要运行图算法来检查可行性。你在前几章学到的那些内容都会再次派上用场。
确定可行性通常需要相当的创造力(只是希望不需要像寻找最优解那样多的创造力!)。
搜索已排序数组
如果你在读这一章之前已经熟悉二分查找,那么大概率是在查找已排序数组的上下文中。一个典型的场景是,给定一个数组 a 和一个值 v,我们想要找到 a 中第一个大于或等于 v 的最小索引。例如,如果我们给定数组 {-5, -1, 15, 31, 78} 和 v 为 26,我们会返回索引 3,因为索引 3 处的值(31)是第一个大于或等于 26 的值。
为什么二分查找在这里有效?看一下这两个关键要素:
关键要素 1 如果没有二分查找,找到最优值需要在数组中进行耗时的扫描。因此,获得最优解很困难,但判断可行性却很容易:如果我给你一个索引 i,你可以通过比较 a[i] 和 v,立即告诉我 a[i] 是否大于或等于 v。
关键要素 2 任何小于 v 的值都会出现在大于或等于 v 的值之前——记住,a 是已排序的!也就是说,不可行的值出现在可行值之前。
确实,二分查找可以用来在对数时间内找到数组中的合适索引;稍后在第十章中,我们将用它来解决这个问题。但我们在用二分查找解决“喂养蚂蚁”问题时,并没有看到数组的存在。不要仅仅在有数组需要查找时才考虑二分查找。二分查找比这更加灵活。
问题 2:河流跳跃
接下来我们将看到一个问题,需要使用贪心算法来判断可行性。
这是 POJ 问题 3258。
问题
河流的长度为 L,沿途放置了若干石头。河流的起点位置是 0(起点石头),终点位置是 L(终点石头),还有 n 块石头在这两者之间。例如,在一条长度为 12 的河流上,我们可能会在以下位置放置石头:0,5,8,12。
一头牛从第一块石头(位置 0)开始,从那里跳到第二块石头,再从第二块跳到第三块石头,依此类推,直到跳到河流尽头的石头(位置 L)。它的最小跳跃距离是任何相邻两块石头之间的最小距离。在上述例子中,最小跳跃距离是 3,体现了位置 5 和位置 8 之间的距离。
农场主约翰对牛的短跳跃感到厌烦,因此他想尽可能增加最小跳跃距离。他不能移除位置 0 或位置 L 的石头,但他可以移除 m 块其他的石头。
在上述例子中,假设农场主约翰能够移除一块石头。他的选择是移除位置 5 或位置 8 的石头。如果他移除位置 5 的石头,最小跳跃距离将是 4(从位置 8 到位置 12)。然而,他不应该这样做,因为如果他移除位置 8 的石头,那么最小跳跃距离将变为 5(从位置 0 到位置 5),这样更好。
我们的任务是通过移除 m 块岩石,最大化农民约翰可以达到的最小跳跃距离。
输入
输入包含一个测试用例,包含以下几行:
-
一行包含三个整数 L(河流的长度),n(岩石的数量,不包括起始和结束位置的岩石),以及 m(农民约翰可以移除的岩石数量)。L 的值在 1 到 1,000,000,000(一十亿)之间,n 的值在 0 到 50,000 之间,m 的值在 0 到 n 之间。
-
n 行,每行给出一个岩石的位置(整数)。没有两个岩石会处于同一位置。
输出
输出最大可实现的最小跳跃距离。对于上面的例子,我们将输出5。
解决该测试用例的时间限制是两秒钟。
贪心思想
在第三章中,解决 Moneygrubbers 问题时,我们介绍了贪心算法的思想。贪心算法做出当前看起来最有希望的选择,而不考虑其选择的长期后果。这样的算法往往很容易提出:只需要说明它用来做出下一个选择的贪心规则。例如,在解决 Moneygrubbers 问题时,我提出了一个贪心算法,选择每个苹果成本最低的选项。那个贪心算法是错误的。这个教训值得记住:虽然提出一个贪心算法很容易,但找到一个正确的贪心算法并不容易。
我没有为贪心算法单独写一章,原因有两个。首先,它们不像其他算法设计方法(例如动态规划)那样广泛适用。其次,当它们确实有效时,往往是由于一些微妙的、特定问题的原因。多年来,我曾多次被看似正确但最终是有缺陷的贪心算法欺骗。通常需要仔细的正确性证明,才能区分哪些是正确的,哪些只是看起来对。
然而,贪心算法确实在第六章中以隐蔽的方式出现了——这一次是正确的,形式上是 Dijkstra 算法。算法学家通常将 Dijkstra 算法归类为贪心算法。一旦算法宣告一个节点的最短路径已找到,它就永远不会回到这个决定上。它一旦做出决定,就不允许未来的发现影响过去的决策。
贪心算法将重新出现。几年前,当我第一次接触到河流跳跃问题时,我的直觉是我可以使用贪心算法来解决它。我想知道你是否会觉得提出的算法像我一样自然。这里是贪心规则:找到两块最接近的岩石,移除离其邻近岩石最近的那块,并重复这一过程。
让我们回到问题描述中的例子。这里它作为一个测试用例:
12 2 1
5
8
为了方便,这里是岩石的位置:0、5、8 和 12。我们允许移除一块岩石。最接近的两块岩石分别位于位置 5 和 8,因此贪心规则将移除其中一块。位置 8 的岩石与右侧邻居的距离为 4;位置 5 的岩石与左侧邻居的距离为 5。因此,贪心算法会移除位置 8 的岩石。在这个例子中,它是正确的。
让我们增加一个更大的例子,看看贪心算法会怎么做。假设河流的长度为 12,允许移除两块岩石。这里是测试案例:
12 4 2
1
3
8
9
岩石的位置是 0、1、3、8、9 和 12。贪心算法会怎么做?最接近的岩石分别位于位置 0 和 1,以及位置 8 和 9。我们必须选择一对——我们选择 0 和 1。由于不允许移除位置 0 的岩石,我们移除位置 1 的岩石。剩下的岩石位置是 0、3、8、9 和 12。
现在,最接近的岩石位于位置 8 和 9。位置 9 和 12 之间的距离小于位置 8 和 3 之间的距离,因此贪心算法会移除位置 9 的岩石。剩下的岩石位置是 0、3、8 和 12。这里的最小跳跃距离,以及正确答案,是 3。贪心算法再次胜利。
不是吗?不断移除两块岩石之间最小的距离。我们怎么可能做得比这个更好呢?贪心算法令人着迷。
可惜的是,贪心算法并不正确。我鼓励你在我在下一段揭示答案之前,尝试找出一个反例。
这里是一个反例:
12 4 2
2
4
5
8
我们允许移除两块岩石。岩石的位置是 0、2、4、5、8 和 12。贪心规则识别位置 4 和 5 的岩石为最接近的岩石。由于位置 4 和 2 之间的距离小于位置 5 和 8 之间的距离,它会移除位置 4 的岩石。剩下的岩石位置是 0、2、5、8 和 12。
现在,贪心规则识别位置 0 和 2 的岩石为最接近的一对。由于不允许移除位置 0 的岩石,它移除位置 2 的岩石。剩下的岩石位置是 0、5、8 和 12。这里的最小跳跃距离是 3。贪心算法犯了一个错误,因为最大可实现的最小跳跃距离是 4。为了证明这一点,不要移除位置 2 和 4 的岩石,而是移除位置 2 和 5 的岩石。那样我们剩下的位置是 0、4、8 和 12。
问题出在哪儿?通过将位置 4 的岩石作为第一步移除,贪心算法创建了一个涉及跳跃距离 2 和跳跃距离 3 的情况。它只能用第二步修正其中一个,所以它无法产生一个大于 3 的最小跳跃距离。
我不知道有哪种贪心算法能直接解决这个问题。像“喂蚁”问题一样,这是一个不容易正面解决的难题。幸运的是,我们不必直接解决它。
测试可行性
在第 240 页的“二分查找”中,我提到过两个信号,指向二分查找的解决方案:测试可行性比求解最优解更容易,并且搜索空间从不可行到可行(或从可行到不可行)。我们将看到,河流跳跃问题在这两方面都符合。
不直接求解最优解,而是先解决另一个问题:是否可以实现至少d的最小跳跃距离?如果我们能解决这个问题,那么就可以使用二分查找找到d的最大可行值。
这是结束上一小节的测试用例:
12 4 2
2
4
5
8
我们可以移除两块岩石。岩石的位置分别是 0、2、4、5、8 和 12。
这里有一个问题:为了实现至少 6 的最小跳跃距离,最少需要移除多少块岩石?我们从左到右检查。位置 0 的岩石必须保留——这一点在问题描述中已明确指出。接下来,很明显我们无法选择如何处理位置 2 的岩石:我们必须移除它。如果不移除,它将导致位置 0 和位置 2 之间的距离小于 6。因此,我们移除了一块岩石。剩下的岩石是 0、4、5、8 和 12。
现在,考虑位置 4 的岩石——我们是保留它还是移除它?我们再次被迫移除它。如果我们保留它,那么位置 0 和位置 4 之间的距离将小于 6。这是我们的第二次移除,剩下的岩石是 0、5、8 和 12。
位置 5 的岩石也必须移除,因为它距离位置 0 的岩石只有 5 的距离。这是我们的第三次移除,剩下的岩石是 0、8 和 12。
位置 8 的岩石也必须移除!它距离位置 0 足够远,但离位置 12 太近。这是我们的第四次移除,最终只剩下 0 和 12 两块岩石。
所以需要四次移除才能实现至少 6 的最小跳跃距离,但我们只能移除两块岩石。因此,6 不是一个可行的解决方案。它太大了。
3 是一个可行的解决方案吗?也就是说,通过移除两块岩石,能实现至少 3 的最小跳跃距离吗?我们来看看。
位置 0 的岩石保留。位置 2 的岩石必须移除。这是我们的第一次移除,剩下的岩石是 0、4、5、8 和 12。
位于位置 4 的岩石可以保留:它距离位置 0 的距离大于 3。但是,位置 5 的岩石必须移除,因为它离位置 4 的岩石太近。这是我们的第二次移除,剩下的岩石是:0、4、8 和 12。
位置 8 的岩石没有问题:它与位置 4 和位置 12 的距离足够远。我们完成了:我们只需移除两块岩石,就能实现至少 3 的最小跳跃距离。所以,3 是可行的。
我们似乎正在朝着一个检查可行性的贪心算法靠近。规则是这样的:按顺序考虑每块岩石,如果它离之前保留的岩石太近,就移除它。同时检查我们保留的最右边的岩石,如果它离河流的尽头太近,就移除它。然后,计算我们移除的岩石数量;这个数量告诉我们,在允许移除的岩石数量下,所提议的最小跳跃距离是否可行。(需要明确的是,这是一个检查指定跳跃距离可行性的贪心算法提议,而不是一次性找到最优解的算法。)
这个算法的代码在清单 7-4 中。
int can_make_min_distance(int distance, int rocks[], int num_rocks,
int num_remove, int length) {
int i;
int removed = 0, prev_rock_location = 0, cur_rock_location;
if (length < distance)
return 0;
for (i = 0; i < num_rocks; i++) {
cur_rock_location = rocks[i];
➊ if (cur_rock_location - prev_rock_location < distance)
removed++;
else
prev_rock_location = cur_rock_location;
}
➋ if (length - prev_rock_location < distance)
removed++;
return removed <= num_remove;
}
清单 7-4:测试跳跃距离的可行性
该函数有五个参数:
distance 我们正在测试可行性的最小跳跃距离
rocks 一个数组,给出每块岩石的位置,河流的起始和结束岩石不包括在内
num_rocks rocks数组中岩石的数量
num_remove 我们允许移除的岩石数量
length 河流的长度
如果distance是一个可行的解,函数返回1(真),否则返回0。
变量prev_rock_location跟踪我们保留的最新岩石的位置。在for循环内部,cur_rock_location保存我们当前考虑的岩石的位置。接下来我们进行关键的测试,决定是否保留或移除当前岩石 ➊。如果当前岩石离前一个岩石太近,那么我们移除当前岩石,并将移除次数加一。否则,我们保留当前岩石,并相应地更新prev_rock_location。
当循环终止时,我们已经计算出必须移除的岩石数量。好吧……差不多。我们还需要检查我们保留的最右边的岩石是否离河流的尽头太近 ➋。如果太近,我们就移除那块岩石。(不用担心移除位置 0 处的岩石。如果我们真的已经移除所有岩石,那么prev_rock_location会是 0。但是,length - 0 < distance不可能为真;如果是这样,程序在函数开头的if语句就会提前返回。)
现在我们确保了没有岩石彼此之间的最小跳跃距离过近,且没有不必要地移除岩石。我们怎么可能做得比这更好呢?贪心算法的魅力……不过,我们又来了。上次发生这种情况时,在第 244 页的《贪心思路》一节中,贪心算法结果是错误的。不要被几个巧合成功的例子说服。不要让我用甜言蜜语让你相信一切都好。
在继续之前,我想给出一个相当精确的论证,说明为什么这个贪心算法是正确的。具体来说,我将展示它移除了为实现至少d的最小跳跃距离所需的最少岩石数量。我将假设d最大为河流的长度;否则,贪心算法会立即并正确地判断实现至少d的最小跳跃距离是不可行的
对于从左到右的每块岩石,我们的贪心算法决定是保留岩石还是移除它。我们的目标是展示它与最优解的每一步操作一致。当贪心算法决定保留一块岩石时,我们将展示最优解也保留那块岩石。当贪心算法决定移除一块岩石时,我们将展示最优解也移除那块岩石。如果贪心算法做的与最优解完全相同,那么它得到的结果一定是正确的。在这个例子中,“最优解”将用来指代最优解。对于每块岩石,我们有四种可能性:贪心算法和最优解都移除岩石,贪心算法和最优解都保留岩石,贪心算法移除它但最优解保留它,贪心算法保留它但最优解移除它。我们必须证明第三种和第四种情况不可能发生。
在我们继续讨论四种情况之前,再次考虑从这些岩石位置移除两块岩石:0, 2, 4, 5, 8, 和 12。当问是否有可能实现至少 3 的最小跳跃距离时,我们已经看到贪心算法会移除位置 2 和 5 的岩石,剩下的位置是 0, 4, 8, 和 12。所以我们可能会预期最优解也会移除这两块岩石。尽管这是最优解,另一种最优解是移除位置 2 和 4 的岩石,剩下的位置是 0, 5, 8, 和 12。这是另一种通过移除两块岩石来实现至少 3 的最小距离的方法,并且它和贪心算法产生的结果一样好。与其匹配那个最优解,我们同样高兴地匹配一个最优解。我们不关心贪心算法匹配哪个最优解:所有最优解都是等价的最优解。
我们有一个我们希望贪心算法匹配的最优解S。贪心算法开始运行,一段时间内没有出现差异:它做的和S做的一样。贪心算法至少在位置 0 的岩石上做了正确的事:那块岩石无论如何都必须保留。
因此,贪心算法从左到右查看岩石,做正确的事情,保留岩石和移除岩石,就像最优解S一样……然后,哗啦,贪心算法和S在处理某些岩石时产生了分歧。我们考虑一下贪心算法和S产生分歧的第一块岩石。
贪心算法移除它,但最优解保留它。
贪心算法只会在石头距离另一块石头太近时才移除它。如果贪心算法移除了一块石头,因为它离左边的石头小于d,那么S也一定移除了那块石头。因为这是第一次不一致,S包含了和贪心算法相同的左侧石头。所以如果S没有移除这块石头,那它就会有两块石头的距离小于d。然而,这是不可能的:S是一个最优(并且必然可行的)解,其中所有石头之间的距离至少为d。因此,我们可以得出结论,S确实移除了那块石头,和贪心算法一致。类似的推理表明,如果贪心算法移除一块石头,因为它离河流的尽头太近,那么S也一定会移除它。
贪心算法保留它,但最优算法移除它。
我们不能让贪心算法和S在这里匹配,但没关系,因为我们可以形成一个新的最优解U,它保留了这块石头。设r为当前的石头;贪心算法保留的,而S移除的。考虑一个新的石头集合T,它包含了和S完全相同的石头,外加石头r。因此,T比S少移除了一块石头。正因为如此,T不可能是一个可行解。如果是的话,那它就比S(多了一块石头)更好,这与S是最优解的事实相矛盾。由于S和T之间唯一的不同是T有石头r,所以一定是r导致了T不可行。因此,在T中,r必须比石头r[2]更接近右边的石头r[2]。我们知道,r[2]不可能是河流的尽头的石头,因为那样贪心算法就不会保留r(因为r会离河流尽头太近)。因此,r[2]是允许被移除的某个石头。
现在,考虑另一个新的石头集合U,它和T完全相同,只是它没有石头r[2]。我们可以说,U和S包含相同数量的石头:我们从S中加入了一块石头r,得到T,然后从T中移除了一块石头r[2],得到U。此外,U没有任何距离小于d的石头,因为它不包含那块问题石头r[2]。也就是说,U是一个最优解,就像S一样。关键是,U包含石头r!因此,贪心算法同最优解U一致,保留了r。
在继续之前,让我们先测试一下可行性测试器。这是如何在我们整个章节中使用的示例上调用它的方法:
int main(void) {
int rocks[4] = {2, 4, 5, 8};
printf("%d\n", can_make_min_distance(6, rocks, 4, 2, 12));
return 0;
}
上面的代码问是否可能通过移除两块石头来达到至少 6 的最小跳跃距离。答案是“不行”,所以输出应该是0(假)。将第一个参数从6改为3,现在你在问是否能够实现至少 3 的最小跳跃距离。再次运行程序,输出应该是1(真)。
很好:现在我们有了一种检查可行性的方法。是时候使用二分查找来为我们提供最优解了。
寻找解法
为了使用二分查找,让我们改编示例 7-3 中的代码。在“喂蚁”问题中,我们需要在小数点后达到四位精度。然而在这里,我们希望优化最小跳跃距离,而这一定是一个整数值,因为所有岩石的位置都是整数。所以,我们将在high和low之间的差值为 1 时停止,而不是达到四个小数位的精度。示例 7-5 给出了新的代码。
// bugged!
void solve(int rocks[], int num_rocks,
int num_remove, int length) {
int low, high, mid;
low = 0;
high = length;
while (high - low > 1) {
mid = (low + high) / 2;
➊ if (can_make_min_distance(mid, rocks, num_rocks, num_remove, length))
➋ low = mid;
else
➌ high = mid;
}
printf("%d\n", high);
}
示例 7-5:搜索最优解(有错误!)
在每次迭代中,我们计算范围的中点mid,并使用我们的辅助函数测试它的可行性 ➊。
如果mid是可行的,那么所有小于mid的值也是可行的,因此我们更新low,以切掉范围的下半部分 ➋。注意与示例 7-3 的对比:在那里,一个可行的mid意味着所有大于mid的值都是可行的,所以我们切掉的是范围的上半部分。
如果mid是不可行的,那么所有大于mid的值也都是不可行的,因此我们更新high,以切掉范围的上半部分 ➌。
不幸的是,这个二分查找并不正确。要了解原因,可以在这个测试用例上运行它:
12 4 2
2
4
5
8
你应该得到一个5的输出,但最优解实际上是4。
啊,我知道该怎么做了。让我们将底部的printf调用修改为输出low而不是high。当循环终止时,low将比high小 1,因此这一更改将导致输出4而不是5。新的代码见示例 7-6。
// bugged!
void solve(int rocks[], int num_rocks,
int num_remove, int length) {
int low, high, mid;
low = 0;
high = length;
while (high - low > 1) {
mid = (low + high) / 2;
if (can_make_min_distance(mid, rocks, num_rocks, num_remove, length))
low = mid;
else
high = mid;
}
printf("%d\n", low);
}
示例 7-6:搜索最优解(仍有错误!)
这解决了有问题的测试用例,但现在我们会错过这个测试用例:
12 0 0
这是一个完全有效的测试用例,虽然有点奇怪:河流的长度是 12,而且没有岩石。最大可达到的最小跳跃距离是 12,但我们的二分查找在这个示例中返回了11。再次地,我们差了一。
二分查找被认为是极难正确实现的。那里的>应该改成>=吗?应该是mid还是mid + 1?我们想要的是low + high还是low + high + 1?如果你继续研究二分查找问题,你最终会遇到这些问题。我不知道还有哪个算法的错误密度像二分查找这么高。
让我们在下一次尝试时小心一些。假设我们始终知道low及所有小于low的值是可行的,而high及所有大于high的值是不可行的。这样的声明叫做不变式,它意味着在代码运行时始终为真。
当循环终止时,low将比high小 1。如果我们成功地保持了不变式,那么我们知道low是可行的。我们还知道,low之后的任何值都不可能是可行的:接下来是high,而不变式告诉我们high是不可行的。所以low将是最大可行值,我们需要输出low。
然而,在这一切中,我们假设我们可以在代码开始时使不变式成立,并且之后始终保持它成立。
让我们从循环之前的代码开始。这段代码并不一定能使不变式成立:
low = 0;
high = length;
low是可行的吗?当然可以!至少为 0 的最小跳跃距离总是可以实现的,因为每次跳跃都有非零的距离。high是不可行的吗?嗯,可能是的,但如果我们在移除允许的岩石数量后能跳过整个河流呢?那么length就是可行的,我们的不变式被打破了。这里是一个更好的初始化方法:
low = 0;
high = length + 1;
现在high显然是不可行的:当河流的长度为length时,我们无法实现最小跳跃距离为length + 1。
接下来我们需要解决循环中的两种可能性。如果mid是可行的,那么我们可以设置low = mid。不变式是成立的,因为low及其左侧的所有值都是可行的;如果mid不可行,那么我们可以设置high = mid。不变式仍然成立,因为high及其右侧的所有值都是不可行的。因此,在这两种情况下,我们都保持不变式。
我们现在可以看到,代码中没有任何东西使不变式失效,因此当循环结束时,我们可以安全地输出low。正确的代码见清单 7-7。
void solve(int rocks[], int num_rocks,
int num_remove, int length) {
int low, high, mid;
low = 0;
high = length + 1;
while (high - low > 1) {
mid = (low + high) / 2;
if (can_make_min_distance(mid, rocks, num_rocks, num_remove, length))
low = mid;
else
high = mid;
}
printf("%d\n", low);
}
清单 7-7:搜索最优解
对于长度为L的河流,我们总共调用can_make_min_distance大约 log L次。如果我们有n个岩石,那么can_make_min_distance(见清单 7-4)的时间复杂度是O(n)。因此,解决这个问题的算法时间复杂度是O(n log L)。
读取输入
我们快到了。剩下的就是读取输入并调用solve。代码见清单 7-8。
#define MAX_ROCKS 50000
int compare(const void *v1, const void *v2) {
int num1 = *(const int *)v1;
int num2 = *(const int *)v2;
return num1 - num2;
}
int main(void) {
static int rocks[MAX_ROCKS];
int length, num_rocks, num_remove, i;
scanf("%d%d%d", &length, &num_rocks, &num_remove);
for (i = 0; i < num_rocks; i++)
scanf("%d", &rocks[i]);
➊ qsort(rocks, num_rocks, sizeof(int), compare);
solve(rocks, num_rocks, num_remove, length);
return 0;
}
清单 7-8:读取输入的主函数
我们一直通过从左到右思考岩石的位置来分析这个问题,即从最小位置到最大位置。然而,岩石的输入顺序是任意的。问题描述中没有任何保证岩石会按顺序排列。
过去一段时间了,但我们在第二章解决后代距离问题时确实使用了qsort对节点进行排序。排序岩石比排序那些节点要容易一些。我们的比较函数compare接受两个整数指针,并返回第二个减去第一个的结果。如果第一个整数小于第二个,它返回负整数;如果两个整数相等,则返回0;如果第一个整数大于第二个,它返回正整数。我们使用这个比较函数和qsort对岩石进行排序 ➊。然后我们用排序后的岩石数组调用solve。
如果你将这个解决方案提交给判题系统,你应该能看到所有测试用例都通过了。
问题 3:生活质量
到目前为止,在本章中我们已经看到了两种检查可行性的方法:树的递归遍历和贪心算法。现在,我们将看到一个例子,其中我们将使用动态编程(第三章)中的思想来高效地检查可行性。
这是本书中的第一个问题,我们不需要从标准输入读取数据,也不需要写入标准输出。我们将编写一个函数,函数名由裁判指定。我们将使用裁判传递的数组代替标准输入,使用函数返回的正确值代替标准输出。这样做非常好:我们根本不需要麻烦 scanf 和 printf!
顺便提一下,这也是我们在世界冠军编程竞赛(IOI 2010)中的第一个问题。你一定能搞定!
这是 DMOJ 问题 ioi10p3。
问题描述
城市由一个矩形网格的块组成。每个块由它的行和列坐标标识。城市有 r 行,从上到下编号为 0 到 r – 1,并且有 c 列,从左到右编号为 0 到 c – 1。
每个块都有一个唯一的质量排名,其值在 1 到 rc 之间。例如,如果我们有七行七列,那么每个块的排名将是从 1 到 49 的某种排列。请参见表 7-1 中的城市示例。
表 7-1: 一个示例城市
| 0 | 1 | 2 | 3 | 4 | 5 | 6 | |
|---|---|---|---|---|---|---|---|
| 0 | 48 | 16 | 15 | 45 | 40 | 28 | 8 |
| 1 | 20 | 11 | 36 | 19 | 24 | 6 | 33 |
| 2 | 22 | 39 | 30 | 7 | 9 | 1 | 18 |
| 3 | 14 | 35 | 2 | 13 | 31 | 12 | 46 |
| 4 | 32 | 37 | 21 | 3 | 41 | 23 | 29 |
| 5 | 42 | 49 | 38 | 10 | 17 | 47 | 5 |
| 6 | 43 | 4 | 34 | 25 | 26 | 27 | 44 |
矩形的中位数质量排名是指该矩形中一半质量排名小于它,另一半质量排名大于它。例如,考虑表 7-1 左上角的五行三列(5×3)矩形。它由 15 个质量排名组成:48、16、15、20、11、36、22、39、30、14、35、2、32、37 和 21。中位数质量排名是 22,因为有七个数字小于 22,另外七个大于 22。
我们将得到整数 h 和 w,它们分别指定候选矩形的高度(行数)和宽度(列数)。我们的任务是确定任意一个 h 行 w 列矩形的最小中位数质量排名。(在这个问题中,低质量排名对应于高质量;因此,找到最小的中位数质量排名实际上对应于找到一个高质量的城市生活区。)
我们用 (x, y) 来表示第 x 行,第 y 列。假设 h 为 5,w 为 3。那么,对于表 7-1 中的城市,我们会将 13 作为最小的中位数质量排名。中位数质量排名为 13 的矩形的左上坐标是 (1, 3),右下坐标是 (5, 5)。
输入
标准输入中没有任何需要读取的内容。我们所需的所有信息都将通过函数参数从评测系统传入。以下是我们将要编写的函数签名:
int rectangle(int r, int c, int h, int w, int q[3001][3001])
这里,r 和 c 分别是城市的行数和列数。同样,h 和 w 分别是候选矩形的行数和列数;h 最多为 r,w 最多为 c。并且保证 h 和 w 都是奇数。(为什么是奇数呢?因为两个奇数相乘结果是奇数,所以候选矩形的块数 hw 将是奇数。在这种情况下,中位数是精确定义的:质量等级的中位数,即剩余质量等级中有一半小于它,另一半大于它。如果我们有一个偶数个质量等级,比如 2、6、4 和 5,这时的中位数会是什么?我们将不得不在 4 和 5 之间做出选择。好在问题作者为我们省去了这个选择。)
最后的参数 q 给出了块的质量等级。例如,q[2][3] 给出了第 2 行第 3 列块的质量等级。注意 q 上的维度告诉我们城市中的最大行数和列数:每个方向都是 3,001。
输出
我们不会在标准输出上生成任何内容。相反,在刚才描述的 rectangle 函数中,我们将返回最小的中位质量等级。
解答测试用例的时间限制为 4.5 秒。
排序每个矩形
如果不使用二分查找,朝着高效解决方案的方向推进会很困难,但我们还是要尝试一下。这样做会让我们练习遍历所有候选矩形。接下来的小节我们会讨论二分查找。
首先,我们需要一些常量和类型定义:
#define MAX_ROWS 3001
#define MAX_COLS 3001
typedef int board[MAX_ROWS][MAX_COLS];
就像我们在第五章中做的那样,每当我们需要一个正确大小的二维数组时,我们将使用 board。
假设你给定了一个矩形的左上角和右下角坐标,并要求你计算该矩形中各个块的中位质量等级。你该如何做呢?
排序可以帮助我们。将质量等级从小到大排序,然后选出中间索引位置的元素。例如,假设我们有这 15 个质量等级:48、16、15、20、11、36、22、39、30、14、35、2、32、37 和 21。如果我们将它们排序,得到 2、11、14、15、16、20、21、22、30、32、35、36、37、39 和 48。共有 15 个质量等级,我们只需要取第八个,即 22,这就是我们的中位数。
有一些稍微更快的算法可以直接找到中位数,而不是通过排序这条“风景优美”的路。排序算法提供了一种需要 O(n log n) 时间的中位数查找方法;但是有一种复杂的 O(n) 算法可以找到中位数,如果你感兴趣的话,我鼓励你去查阅一下。
不过,我们不打算走这条路。在这一小节中,我们所做的事情将非常慢,以至于任何改进的中位数查找算法都无法带来任何帮助。
清单 7-9 给出了查找给定矩形中位数的代码。
int compare(const void *v1, const void *v2) {
int num1 = *(const int *)v1;
int num2 = *(const int *)v2;
return num1 - num2;
}
int median(int top_row, int left_col, int bottom_row, int right_col,
board q) {
static int cur_rectangle[MAX_ROWS * MAX_COLS];
int i, j, num_cur_rectangle;
num_cur_rectangle = 0;
for (i = top_row; i <= bottom_row; i++)
for (j = left_col; j <= right_col; j++) {
cur_rectangle[num_cur_rectangle] = q[i][j];
num_cur_rectangle++;
}
➊ qsort(cur_rectangle, num_cur_rectangle, sizeof(int), compare);
return cur_rectangle[num_cur_rectangle / 2];
}
清单 7-9:查找给定矩形的中位数
median函数的前四个参数通过指定左上角的行和列,以及右下角的行和列,来限定矩形。最后一个参数q保存质量排名。我们使用一维数组cur_rectangle来累积矩形的质量排名。嵌套的for循环遍历矩形中的每个块,并将块的质量排名添加到cur_rectangle中。收集完质量排名后,我们可以将它们传递给qsort ➊。然后我们就能准确地知道中位数的位置——它位于数组的中间——所以我们只需返回它。
有了这个函数,我们现在可以开始循环遍历每个候选矩形,跟踪其中中位数质量排名最小的一个。查看清单 7-10 中的代码。
int rectangle(int r, int c, int h, int w, board q) {
int top_row, left_col, bottom_row, right_col;
➊ int best = r * c + 1;
int result;
for (top_row = 0; top_row < r - h + 1; top_row++)
for (left_col = 0; left_col < c - w + 1; left_col++) {
➋ bottom_row = top_row + h - 1;
➌ right_col = left_col + w - 1;
➍ result = median(top_row, left_col, bottom_row, right_col, q);
if (result < best)
best = result;
}
return best;
}
清单 7-10:查找所有候选矩形中的最小中位数
变量best跟踪我们目前找到的最好的(最小的)中位数。我们一开始将其设为一个很大的值,远大于任何候选矩形的中位数 ➊。没有矩形的中位数会是r * c + 1,这意味着它的一半质量排名大于r * c,但根据题目描述,没有质量排名会大于r * c。嵌套的for循环考虑矩形的每个可能的左上角坐标。这给了我们上行和左列,但我们还需要底行和右列来调用median函数。为了计算底行,我们取上行,加上h(候选矩形的行数),然后减去 1 ➋。在这里很容易犯下“偏差一”的错误,但这个- 1是必须的。如果上行是 4 且h是 2,那么我们希望底行是 4 + 2 – 1 = 5;如果我们将底行设为 4 + 2 = 6,那么矩形就会有三行,而不是我们希望的两行。我们用类似的计算来找到右列 ➌。通过这四个坐标,我们调用median函数计算矩形的中位数 ➍。其余代码会在我们找到更好的中位数时更新best。
我们已经完成了这个解决方案。没有main函数,因为评测系统直接调用rectangle函数,但没有main函数意味着我们无法在自己的计算机上测试代码。为了测试,你可以添加一个main函数,但在提交给评测系统时,记得去掉它。
这是一个关于表格 7-1 中城市的main函数示例:
int main(void) {
static board q = {{48, 16, 15, 45, 40, 28, 8},
{20, 11, 36, 19, 24, 6, 33},
{22, 39, 30, 7, 9, 1, 18},
{14, 35, 2, 13, 31, 12, 46},
{32, 37, 21, 3, 41, 23, 29},
{42, 49, 38, 10, 17, 47, 5},
{43, 4, 34, 25, 26, 27, 44}};
int result = rectangle(7, 7, 5, 3, q);
printf("%d\n", result);
return 0;
}
运行程序时,你应该看到输出为13。
随时可以将我们的解决方案提交给评测系统,去掉main函数后,代码会通过几个测试用例,但在其他用例上会超时。
为了了解为什么我们的代码这么慢,我们集中讨论r和c都是相同数值m的情况。为了展示最坏情况,令h和w都等于m/2。(我们不希望矩形太大,因为那样矩形就不多了;我们也不希望它们太小,因为那样每个都很容易处理。)我们median函数中最慢的部分是调用qsort。它接收一个包含m/2 × m/2 = m²/4 个值的数组。在大小为n的数组上,qsort需要执行n log n步。将n替换为m²/4,得到(m²/4) log(m²/4) = O(m² log m)。所以我们已经比二次方还慢——而我们所做的只是计算一个矩形的中位数!rectangle函数总共调用median m²/4 次,因此我们的总运行时间是O(m⁴ log m)。这个四次方的时间复杂度使得这个解法只能用于非常小的实例。
这里有两个瓶颈。第一个是对每个矩形进行排序。第二个是对每个矩形从头开始做大量工作。使用二分查找可以解决第一个问题,而一个巧妙的动态规划技巧可以解决第二个问题。
使用二分查找
为什么我们应该对二分查找带来速度提升感到乐观呢?首先,在上一小节中,我们看到直接寻找最优解是一个昂贵的任务;我们依赖排序的做法比m⁴算法稍慢。其次,我们还有另一个例子,说明所有不可行解在前,后面跟着所有可行解。假设我告诉你,没有一个矩形的中位质量排名在五以下。那么寻找质量排名为五、四、三或者任何小于五的矩形就没有意义了。反过来,假设我告诉你,存在一个中位质量排名不超过五的矩形。那么寻找质量排名为六、七或者任何大于五的矩形也就没有意义了。这正是二分查找的理想应用场景。
在喂蚂蚁问题中,小值是不可行的,大值是可行的。在河流跳跃问题中,小值是可行的,大值是不可行的。在这里,我们回到了喂蚂蚁的情况:小值是不可行的,大值是可行的。因此,我们需要对河流跳跃的不变式进行修改,交换解空间中可行和不可行部分的位置。
这是我们将使用的不变式:low及其以下的所有值都是不可行的;high及其以上的所有值是可行的。这告诉我们,当我们完成时应该返回high,因为它是最小的可行值。代码在示例 7-11 中,除了非常相似于示例 7-7 之外,其他部分没有什么不同。
int rectangle(int r, int c, int h, int w, board q) {
int low, high, mid;
low = 0;
high = r * c + 1;
while (high - low > 1) {
mid = (low + high) / 2;
if (can_make_quality(mid, r, c, h, w, q))
high = mid;
else
low = mid;
}
return high;
}
示例 7-11: 搜索最优解
为了完成这项工作,我们需要实现can_make_quality来测试可行性。
测试可行性
下面是我们将要编写的可行性检查函数的签名:
int can_make_quality(int quality, int r, int c, int h, int w, board q)
在“排序每个矩形”一节中,位于第 256 页,我们被迫计算每个矩形的中位数质量排名。现在情况不再是这样:我们只需确定某个矩形的中位数是否最多是某个截止的quality排名值。
这是一个更简单的问题,不需要排序步骤。关键的观察点是:具体的值本身不再重要;重要的是每个值与quality之间的关系。为了利用这个观察点,我们将所有小于或等于quality的值替换为-1,将所有大于quality的值替换为 1。然后,我们将这些-1 和 1 的值相加,针对给定的矩形。如果-1 的值至少与 1 的值一样多(即,相对于quality,小值和大值的数量至少相同),那么和将是零或负数,我们可以得出结论:这个矩形的中位数质量排名是quality或更小。
让我们做一个例子。这里是表 7-1 左上角 5×3 矩形的 15 个质量排名:48, 16, 15, 20, 11, 36, 22, 39, 30, 14, 35, 2, 32, 37, 和 21。这个矩形的中位数质量排名是 16 或更小吗?将每个值替换为-1(如果小于或等于 16)或 1(如果大于 16)。得到的新值如下:1, -1, -1, 1, -1, 1, 1, 1, 1, -1, 1, -1, 1, 1, 和 1。如果将这些加起来,结果是 5。这意味着大值比小值多五个,我们必须得出结论:这个矩形不可能有 16 或更小的中位数。如果我们想知道 30 是否可行,我们可以在将这些数字替换为-1 和 1 后得到:1, -1, -1, -1, -1, 1, -1, 1, -1, -1, 1, -1, 1, 1 和-1。将这些加起来,总和是-3。
啊哈!所以 30 是一个可行的中位数。关键是,我们在没有任何排序的情况下做出了这个可行–不可行的决策。
我们需要遍历每个矩形,测试它是否具有quality或更小的中位数质量排名。列表 7-12 正是做了这件事。
int can_make_quality(int quality, int r, int c, int h, int w, board q) {
➊ static int zero_one[MAX_ROWS][MAX_COLS];
int i, j;
int top_row, left_col, bottom_row, right_col;
int total;
for (i = 0; i < r; i++)
for (j = 0; j < c; j++)
➋ if (q[i][j] <= quality)
zero_one[i][j] = -1;
else
zero_one[i][j] = 1;
for (top_row = 0; top_row < r - h + 1; top_row++)
for (left_col = 0; left_col < c - w + 1; left_col++) {
bottom_row = top_row + h - 1;
right_col = left_col + w - 1;
total = 0;
for (i = top_row; i <= bottom_row; i++)
for (j = left_col; j <= right_col; j++)
➌ total = total + zero_one[i][j];
if (total <= 0)
return 1;
}
return 0;
}
列表 7-12:测试质量的可行性
我们不能仅仅用-1 和 1 摧毁q数组,因为那样我们就不能使用原始的质量排名来测试其他quality值。因此,我们使用一个新数组来存储-1 和 1 的值 ➊。注意,这个数组的填充是基于每个值是否小于或等于(-1)或大于(1)我们正在检查的截止quality参数 ➋。
然后我们像在列表 7-10 中一样遍历每个矩形。我们将所有的-1 和 1 值加起来 ➌,并返回1(true),如果它的中位数质量排名足够小。
好的!我们绕过了排序—聪明吧?我们在这一小节中所做的工作对于快速解决问题至关重要,但我们还没有到达最终解决方案,因为如果你计算嵌套循环的数量,你会发现总共有四个。
在“排序每个矩形”的结尾,我们观察到我们第一个解法——没有任何二分查找!——是一个非常慢的 O(m⁴ log m),其中 m 是城市中的行数或列数。在这里,我们的可行性检查已经是 m⁴;再乘以二分查找的对数因子,似乎我们并没有取得什么进展。
哦,但我们已经进展了!只是被太多嵌套循环锁住,涉及过多的重复计算。动态规划现在将帮助我们完成余下的工作。
一种更快速的可行性测试方法
假设我们从表 7-1 开始,并且关心任何 5×3 的矩形是否有一个中位数质量排名小于等于 16。将所有小于或等于 16 的值改为–1,所有大于 16 的值改为 1,结果就是表 7-2。
表 7-2: 替换了质量排名的城市
| 0 | 1 | 2 | 3 | 4 | 5 | 6 | |
|---|---|---|---|---|---|---|---|
| 0 | 1 | –1 | –1 | 1 | 1 | 1 | –1 |
| 1 | 1 | –1 | 1 | 1 | 1 | –1 | 1 |
| 2 | 1 | 1 | 1 | –1 | –1 | –1 | 1 |
| 3 | –1 | 1 | –1 | –1 | 1 | –1 | 1 |
| 4 | 1 | 1 | 1 | –1 | 1 | 1 | 1 |
| 5 | 1 | 1 | 1 | –1 | 1 | 1 | –1 |
| 6 | 1 | –1 | 1 | 1 | 1 | 1 | 1 |
我们可能会从求和坐标为(0, 0)的 5×3 矩形的元素开始。正如我们在前一节中看到的,那个矩形的和是 5。接下来,也许我们想要求和坐标为(0, 1)的 5×3 矩形的元素。在这里加总所有 15 个数字就是我们在上一小节中所做的事情。然而,这样做未能利用我们在计算第一个矩形的和时所做的工作。事实上,第二个矩形与第一个矩形有 10 个值是重复的。我们应该能够避免这种重复劳动,处理这个以及所有其他矩形。
避免重复工作的关键就是高效地解决所谓的二维区间和查询问题。一维的情况使用类似的思路,但在更简单的上下文中,因此我们会简要研究一下,然后再回到解决生活质量问题。(第八章将专门讨论区间查询,所以敬请期待!)
一维区间和查询
这是一个一维数组:

如果要求找出从索引 2 到索引 5 的数组和,我们可以直接将该范围内的值相加:15 + 9 + 12 + 4 = 40。这样并不快速,特别是如果我们被要求计算整个数组的和时,就显得非常不方便。然而,如果我们只需要回答几个这样的查询,我们可以通过直接相加相应的值来应付。
现在,想象一下我们被成百上千个查询困扰。如果这样做一次预处理能让我们以后更快地回答这些查询,那就非常值得了。
考虑“索引 2 到 5”的查询。如果我们能查找到从索引 0 到 5 的和呢?这个和是 48。那不是我们需要的 40,虽然它离我们需要的值很接近。之所以不对,是因为它包含了索引 0 和索引 1 的值,这些我们现在需要排除掉。如果我们能查到从索引 0 到 1 的和呢?这个和是 8。如果我们从 48 中减去这个 8,就得到了 40。
那么需要的就是一个新的数组,其中索引i保存从索引 0 到索引i的所有值的和。这个新数组包含在下面表格中的前缀和行中:

无论查询是什么,现在我们都可以通过前缀和数组快速回答:要计算从索引a到b的区间和,只需取索引b的值,并减去索引a – 1 的值。对于从 2 到 5 的区间,我们得到 48 – 8 = 40,对于从 1 到 6 的区间,我们得到 59 – 6 = 53。这些都是常数时间的答案,永远不会改变,我们所做的仅仅是对数组进行一次预处理。
二维区间和:查询
让我们回到二维的质量排名世界。对每个矩形求和太慢了,所以我们将一维的做法扩展到二维。具体来说,我们将生成一个新的数组,其中索引(i, j)保存矩形(其左上角坐标为(0, 0),右下角坐标为(i, j))的元素和。
让我们再次查看一下表格 7-2。
| 0 | 1 | 2 | 3 | 4 | 5 | 6 | |
|---|---|---|---|---|---|---|---|
| 0 | 1 | –1 | –1 | 1 | 1 | 1 | –1 |
| 1 | 1 | –1 | 1 | 1 | 1 | –1 | 1 |
| 2 | 1 | 1 | 1 | –1 | –1 | –1 | 1 |
| 3 | –1 | 1 | –1 | –1 | 1 | –1 | 1 |
| 4 | 1 | 1 | 1 | –1 | 1 | 1 | 1 |
| 5 | 1 | 1 | 1 | –1 | 1 | 1 | –1 |
| 6 | 1 | –1 | 1 | 1 | 1 | 1 | 1 |
相应的前缀数组在表格 7-3 中。(可能叫它“前缀数组”有点奇怪,但为了与一维情况的术语一致,我们暂且这么称呼。)
表格 7-3: 用于二维区间和查询的数组
| 0 | 1 | 2 | 3 | 4 | 5 | 6 | |
|---|---|---|---|---|---|---|---|
| 0 | 1 | 0 | –1 | 0 | 1 | 2 | 1 |
| 1 | 2 | 0 | 0 | 2 | 4 | 4 | 4 |
| 2 | 3 | 2 | 3 | 4 | 5 | 4 | 5 |
| 3 | 2 | 2 | 2 | 2 | 4 | 2 | 4 |
| 4 | 3 | 4 | 5 | 4 | 7 | 6 | 9 |
| 5 | 4 | 6 | 8 | 6 | 10 | 10 | 12 |
| 6 | 5 | 6 | 9 | 8 | 13 | 14 | 17 |
在我们担心如何快速构建这个数组之前,让我们先弄清楚这个数组告诉了我们什么。第 4 行第 2 列的值给出了左上角坐标为(0, 0)、右下角坐标为(4, 2)的矩形的和。我们在“测试可行性”中已经看到,这个和是 5,事实上,这就是这个数组中该位置的值。
我们如何利用已经计算过的其他值来计算(4, 2)位置的值 5?我们需要从表 7-2 中获得它的值,然后加上它上面范围内的值,再加上它所在行左边的所有值。我们可以通过巧妙使用表 7-3 中的数组来做到这一点,正如表 7-4 所示。
表 7-4: 快速计算给定和
| 0 | 1 | 2 | 3 | 4 | 5 | 6 | |
|---|---|---|---|---|---|---|---|
| 0 | • ■ | • ■ | • | ||||
| 1 | • ■ | • ■ | • | ||||
| 2 | • ■ | • ■ | • | ||||
| 3 | • ■ | • ■ | • | ||||
| 4 | ■ | ■ | 1 | ||||
| 5 | |||||||
| 6 |
我们需要从 1 开始,捕获包含圆圈的单元格(上面的那些),捕获包含方框的单元格(左边的那些),并将它们加起来。我们可以通过查找第 3 行、第 2 列的元素来捕获包含圆圈的单元格。我们还可以通过查找第 4 行、第 1 列的元素来捕获包含方框的单元格。然而,将它们加在一起会重复计算同时包含圆圈和方框的单元格(既在上面又在左边的那些),但这不是问题,因为第 3 行、第 1 列的元素正好捕获了那些圆圈和方框的单元格,其减法可以抵消重复计算。总的来说,我们得到了 1 + 2 + 4 – 2 = 5,正是我们所期望的。只要我们从上到下、从左到右工作,我们就能通过每个单元格仅进行两次加法和一次减法来构建这个数组。
我们现在知道如何构建类似表 7-3 中的数组。那么,接下来呢?
“那又怎样?”这使我们能够快速计算任何矩形的和。假设我们想计算一个矩形的和,它的左上角坐标是(1, 3),右下角坐标是(5, 5)。我们不能仅仅使用表 7-3 中第 5 行第 5 列的值 10。它捕获了矩形中的所有元素,但也包括了我们不想要的部分:它包括了在矩形外部(上面或左边)的元素。然而,正如在一维情况下一样,我们可以调整该值,使它只包括矩形中的元素。有关如何做,请参见表 7-5。在这个表中,目标矩形的单元格用星号标记。
表 7-5: 快速计算矩形的和
| 0 | 1 | 2 | 3 | 4 | 5 | 6 | |
|---|---|---|---|---|---|---|---|
| 0 | • ■ | • ■ | • ■ | • | • | • | |
| 1 | ■ | ■ | ■ | ★ | ★ | ★ | |
| 2 | ■ | ■ | ■ | ★ | ★ | ★ | |
| 3 | ■ | ■ | ■ | ★ | ★ | ★ | |
| 4 | ■ | ■ | ■ | ★ | ★ | ★ | |
| 5 | ■ | ■ | ■ | ★ | ★ | ★ | |
| 6 |
这次,我们需要减去包含圆形的单元格和包含方形的单元格。我们可以从第 0 行,第 5 列得到圆形的单元格,从第 5 行,第 2 列得到方形的单元格。但减去这两者会导致双重减去同时包含圆形和方形的单元格,因此我们需要加回第 0 行,第 2 列的单元格。也就是说,我们有 10 – 2 – 8 + (–1) = –1,这是矩形的和。
这是这个计算的一般表达式:
sum[bottom_row][right_col] - sum[top_row - 1][right_col] -
sum[bottom_row][left_col - 1] + sum[top_row - 1][left_col - 1]
这将在接下来的代码中使用。
二维范围和:代码
我们准备将所有内容整合在一起——–1 和 1 的思路,构建前缀数组,并利用前缀数组进行快速矩形求和——在示例 7-13 中。
int can_make_quality(int quality, int r, int c, int h, int w, board q) {
static int zero_one[MAX_ROWS][MAX_COLS];
static int sum[MAX_ROWS + 1][MAX_COLS + 1];
int i, j;
int top_row, left_col, bottom_row, right_col;
int total;
➊ for (i = 0; i < r; i++)
for (j = 0; j < c; j++)
if (q[i][j] <= quality)
zero_one[i][j] = -1;
else
zero_one[i][j] = 1;
for (i = 0; i <= c; i++)
sum[0][i] = 0;
for (i = 0; i <= r; i++)
sum[i][0] = 0;
➋ for (i = 1; i <= r; i++)
for (j = 1; j <= c; j++)
sum[i][j] = zero_one[i - 1][j - 1] + sum[i - 1][j] +
sum[i][j - 1] - sum[i - 1][j - 1];
➌ for (top_row = 1; top_row <= r - h + 1; top_row++)
for (left_col = 1; left_col <= c - w + 1; left_col++) {
bottom_row = top_row + h - 1;
right_col = left_col + w - 1;
total = sum[bottom_row][right_col] - sum[top_row - 1][right_col] -
sum[bottom_row][left_col - 1] + sum[top_row - 1][left_col - 1];
if (total <= 0)
return 1;
}
return 0;
}
示例 7-13:快速测试质量的可行性
第一步是构建zero_one数组 ➊,就像我们在示例 7-12 中做的那样。第二步是构建前缀和数组sum ➋。我们将使用从 1 开始的索引,而不是从 0 开始,这样我们在稍后处理数组边缘的单元格时就不必担心越界问题。最后,第三步是使用前缀和数组来快速计算每个矩形的和 ➌。注意每个矩形如何能在常数时间内求和!我们为第二步的预处理工作付出了代价,但每次在不逐一求和的情况下计算矩形和时,这个工作都会带来回报。
与示例 7-12 相比,我们从for循环中去掉了两层嵌套。因此,这是一个 O(m² log m) 的算法,足够快,可以通过所有测试用例。加油吧!然后好好休息一下,因为在本章结束之前,我们还需要解决一个大问题。
问题 4:洞穴门
又一个 IOI 决赛题目?来吧!这个问题在本章中是独特的,因为它使用二分查找的目的是快速定位到目标元素,而不是找到最优解。就像我们在生活质量问题中做的一样,我们不会从标准输入读取任何内容,也不会将任何内容写入标准输出。相反,我们将通过调用评测机提供的函数来了解问题实例并提交答案。在阅读问题描述时,尝试预测为什么在这里二分查找仍然适用。
这是 DMOJ 问题 ioi13p4。
问题
你正站在一条狭长洞穴的入口,想要穿过洞穴到达另一边。你必须通过 n 扇门:第一扇是门 0,第二扇是门 1,依此类推。
每扇门可以是开着的或关闭的。你可以穿过任何开着的门,但无法穿越或看到关闭的门。所以如果门 0 和门 1 是开的,但门 2 是关的,那么你只能到达门 2,而无法再继续前进。
在洞穴入口处有一个由 n 个开关组成的面板。与门一样,开关的编号从 0 开始。每个开关可以处于上(0)或下(1)的位置。每个开关与一个不同的门相关联,并决定该门是开还是关。如果开关设置为正确的位置,则其关联的门是开的;否则,门是关的。你不知道哪个开关与哪个门关联,也不知道开关应该处于上还是下的位置才能打开门。例如,也许开关 0 与门 5 相关联,并且该开关必须处于下位置才能打开门 5。而开关 1 可能与门 0 相关联,且该开关必须处于上位置才能打开门 0。
你可以将开关设置为你选择的任何位置,然后穿越洞穴来确定第一个关闭的门。你有足够的体力进行最多 70,000 次这样的操作。你的目标是确定每个开关的正确位置(0 或 1)以及与之关联的门。
我们必须编写具有以下签名的函数:
void exploreCave(int n)
其中,n 是门和开关的数量(介于 1 和 5,000 之间)。为了实现这个函数,你需要调用评测系统提供的两个函数。接下来会描述这两个函数。
输入
我们不从标准输入读取任何内容。了解问题实例的唯一方法是调用评测系统提供的 tryCombination 函数。其签名是:
int tryCombination(int switch_positions[])
参数 switch_positions 是一个长度为 n 的数组,给出每个开关的位置(0 或 1)。即,switch_positions[0] 给出开关 0 的位置,switch_positions[1] 给出开关 1 的位置,依此类推。tryCombination 函数模拟了如果我们将开关设置为 switch_positions 中的状态并穿越洞穴时会发生什么。如果某些门仍然关闭,函数返回第一个关闭的门的编号;否则,返回 -1,表示所有门都已打开。
输出
我们并没有将任何内容写入标准输出。相反,当我们准备好时,我们通过调用评测系统提供的answer函数来提交答案。其函数签名是:
void answer(int switch_positions[], int door_for_switch[])
我们只有一次机会:当我们调用 answer 时,之后不能再做其他任何操作,因此最好第一次就提交正确的答案。参数 switch_positions 是我们提议的开关位置,格式与 tryCombination 相同。参数 door_for_switch 是我们提议的开关与门之间的关联:door_for_switch[0] 给出开关 0 对应的门,door_for_switch[1] 给出开关 1 对应的门,依此类推。
调用 tryCombination 的次数,而不是执行时间,是这里的稀缺资源。我们最多允许进行 70,000 次调用;如果超过这个次数,程序将被终止,我们无法解决问题。
解决子任务
该问题的作者将问题分为五个子任务。第五个子任务是我在这里展示的完整问题。其他子任务对问题实例施加了额外的约束,使得问题变得更容易。
我喜欢问题作者使用子任务,特别是在我难以解决问题时。我可以依次解决每个子任务,并在此过程中不断改进我的解决方案,直到最终解决整个问题。此外,即使我无法解决整个问题,我仍然可以获得我能够解决的子任务的积分。
洞门问题中的第一个子任务是解决当每个开关i与门号i关联时的问题。也就是说,开关 0 与门 0 关联,开关 1 与门 1 关联,依此类推。我们需要推导出每个开关的正确位置(0 或 1)。
别担心:我们不会在解决完全这个问题之前停止。但让我们先从解决子任务 1 开始,这样我们可以专注于正确调用tryCombination和answer评测函数,然后再解决问题的其他方面。
我们无法访问两个评测函数的代码,所以无法在本地编译和运行我们的程序。(如果你想在本地设置,你可以搜索“IOI 2013 tasks”并找到洞门问题的测试数据和模板,但你不需要做这些就可以跟随这里的讨论。)每当我们想测试我们的进展时,可以将代码提交给评测系统。特别是,当我们成功解决了子任务 1 并提交代码时,评测系统应该会给我们一些积分。子任务 1 的代码见列表 7-14。
void exploreCave(int n) {
int switch_positions[n], door_for_switch[n];
int i, result;
for (i = 0; i < n; i++) {
➊ switch_positions[i] = 0;
➋ door_for_switch[i] = i;
}
for (i = 0; i < n; i++) {
➌ result = tryCombination(switch_positions);
if (result == i) // door i is closed
➍ switch_positions[i] = 1;
}
➎ answer(switch_positions, door_for_switch);
}
列表 7-14:解决子任务 1
首先,我们使用for循环将每个开关的位置设置为0 ➊,并将门i与开关i关联起来 ➋。我们将在需要时更新开关的位置,但(根据子任务的约束)我们没有理由再次触及门与开关的关联。
第二个for循环遍历每个开关。它的任务是判断当前的开关是保持在位置 0 还是切换到位置 1。我们来处理第一次迭代,当i为0时。我们调用tryCombination ➌,它返回给我们第一个关闭的门的编号。如果它返回0,说明开关 0 没有设置正确;如果开关 0 设置正确,门 0 应该是打开的,tryCombination应该返回一个非零的数字。因此,如果门 0 是关闭的,我们将开关 0 的位置从0改为1 ➍。这样就打开了门 0,我们可以继续处理门 1。
当i为 1 时,我们再次调用tryCombination。我们不会得到0的结果,因为我们的代码已经完成了确保门 0 是打开的工作。如果我们得到1的结果,意味着门 1 是关闭的,我们必须将开关 1 从位置 0 切换到位置 1。
一般来说,我们可以说,当我们开始新的循环迭代时,所有门从门i - 1及之前的门都是开着的。如果门i是关着的,那么我们将开关i的状态从0改为1;否则,门i已经是开着的,开关i已经是正确的设置。
一旦我们完成了第二个for循环,我们就已经找到了每个开关的正确位置。我们通过调用answer函数➎将这一信息传达给评测系统。
我建议将这段代码提交给评测系统,验证你是否正确地调用了tryCombination和answer。当你准备好后,我们将继续解决真正的难题。
使用线性搜索
我们解决了子任务 1,除了让我们有所了解外,还有一个好处。那就是我们方案中的一个策略为我们铺平了道路。这个策略是弄清楚如何打开每一扇门,并且确保这扇门在以后不会再干扰我们。
在解决子任务 1 时,我们首先关注门 0 并让它打开。一旦门 0 打开,我们就不再改变它的开关。解决了门 0 之后,我们接着关注门 1 并让它打开。一旦门 1 打开,我们也不再改变它的开关。对我们来说,门 0 和门 1 已经解决;接下来可以从门 2 开始。我们继续按照这种方式,一个个地解决每扇门,直到所有的门都打开。
在子任务 1 中,我们已经准确地知道每个开关对应哪扇门。无需进行搜索来确认这种对应关系。但为了完整地解决问题,我们确实需要搜索,因为我们不知道哪个开关控制当前的门。我们从让门 0 关闭开始。然后我们在开关中进行搜索。我们改变当前开关的位置,并询问门 0 是否已经打开。如果没有,那么这就不是正确的开关。如果打开了,那么我们就找到了门 0 的开关。从此以后我们保持门 0 开启,并且重复这一过程来解决门 1:让门 1 关闭,然后在开关中循环,找到能够打开它的开关。
我们先从列表 7-15 中给出的新exploreCave代码开始。这段代码很简洁,因为它将搜索的工作委托给了一个辅助函数。
void exploreCave(int n) {
int switch_positions[n], door_for_switch[n];
int i;
for (i = 0; i < n; i++)
➊ door_for_switch[i] = -1;
for (i = 0; i < n; i++)
➋ set_a_switch(i, switch_positions, door_for_switch, n);
answer(switch_positions, door_for_switch);
}
列表 7-15: exploreCave 函数
就像解决子任务 1 一样,switch_positions的每个元素最终都会是 0 或 1,表示每个开关的位置。door_for_switch表示每个开关所关联的门。我们初始化door_for_switch中的每个元素为-1 ➊,表示每个开关所关联的门是未知的。当某个开关所关联的门确定后,我们会相应地更新switch_positions[i]和door_for_switch[i]。
快问快答:如果door_for_switch[5]是8,那意味着什么?是开关 5 与门 8 相关联,还是门 5 与开关 8 相关联?
是前者!在继续之前,请确保你理解这一点。
对于每个门 i,我们调用 set_a_switch 辅助函数 ➋。它的任务是遍历开关,找出与门 i 关联的开关,并确定该开关应该设置为位置 0 还是位置 1。
set_a_switch 的代码见 Listing 7-16。
void set_a_switch(int door, int switch_positions[],
int door_for_switch[], int n) {
int i, result;
int found = 0;
for (i = 0; i < n; i++)
if (door_for_switch[i] == -1)
➊ switch_positions[i] = 0;
result = tryCombination(switch_positions);
if (result != door) { // door is open
for (i = 0; i < n; i++)
if (door_for_switch[i] == -1)
➋ switch_positions[i] = 1;
}
i = 0;
while (!found) {
if (door_for_switch[i] == -1) {
➌ switch_positions[i] = 1 - switch_positions[i];
result = tryCombination(switch_positions);
➍ if (result != door)
found = 1;
else
i++;
}
else
i++;
}
door_for_switch[i] = door;
}
Listing 7-16:使用线性搜索查找并设置当前门的开关
door 参数决定了我们接下来要解决的门。
我们从遍历开关开始。我们将开关的位置设置为 0 ➊,但仅对那些尚未与门关联的开关进行此操作。(记住,如果某个开关已经与门关联,我们不希望再改变该开关的位置。)
当所有相关的开关位置都设置为 0 时,我们判断当前的门是开着还是关着。如果它开着,我们希望将其关上,以便稍后逐一改变开关的位置,看看哪个开关能打开它。为了关闭门,我们只需将所有开关的位置设置为 1 ➋。这样有效,因为当所有开关的位置都是 0 时,门是开的;其中一个开关控制着这扇门,所以当该开关的位置发生变化时,门就会关闭。
门关闭时,是时候寻找能够打开它的开关了。对于每个尚未与门关联的开关,我们切换它的位置,从 0 改为 1 或从 1 改为 0 ➌。注意,减去位置 1 会改变位置:如果之前是 1,现在就是 0;如果之前是 0,现在就是 1。然后,我们检查门的状态。如果它打开了 ➍,那么我们就找到了关联的开关!如果它仍然关闭,那么这个开关不是正确的,循环继续进行。
我们在 set_a_switch 中做的是一个线性搜索,遍历所有剩余的开关。我们可能最多有 5,000 个开关,因此查找单个门的开关可能需要最多 5,000 次 tryCombination 调用。
我们可以最多调用 tryCombination 70,000 次。如果运气不好,第一个门的搜索需要 5,000 次调用,第二个门需要 4,999 次调用,第三个门需要 4,998 次调用,以此类推,那么我们最多只能处理 14 扇门,超过这个数量就会超出限制。14 扇门并不多。我们可能有 5,000 扇门——我们甚至还没接近这个数量!这就是线性搜索的极限。
使用二分搜索
数字 5,000(最大门数)和 70,000(最大 tryCombination 调用次数)微妙地编码了二分搜索作为一种可行解决策略的事实。注意,log[2] 5,000 向上取整是 13。如果我们能找到一种使用二分搜索的方法,那么它只需要 13 次 tryCombination 调用就能找出当前门的开关,而不是 5,000 次。如果每扇门需要 13 次调用,我们有 5,000 扇门,那么总共就是 13 × 5,000 = 65,000 次调用。每扇门还需要额外的一次调用来判断门是否关闭,但即便如此,我们仍应该能够在 70,000 次调用限制内完成。
二分查找如何在这里使用?它一定与每步消除一半开关范围有关。在继续之前,花些时间思考这个问题!
我将通过一个例子来解释这个想法。假设我们有八扇门和八个开关,且门 0 当前是关闭的。如果我们翻转开关 0,而门 0 没有打开,那么我们得到的信息很少:我们只知道开关 0 与门 0 无关。(这就像在猜一个 1 到 1000 之间的数字时,从“1”开始。)更好的做法是翻转一半的开关。那么,我们翻转开关 0、1、2 和 3。无论这对门 0 有何影响,我们学到的东西会很多。如果门 0 依然关闭,那么开关 0 到 3 与门 0 无关,我们只需关注开关 4 到 7。如果门 0 现在打开了,我们知道开关 0 到 3 中有一个与门 0 关联,接下来我们只关注开关 0 到 3。一步:消除一半的范围。我们继续这样做,直到找到与门 0 关联的开关(以及它的位置)。
假设我们一直进行下去,每次都将开关的范围对半切割,直到只剩下一个开关。假设我们发现开关 6 与门 0 关联。我们就设置开关 6,使门 0 打开。门保持这样。当我们下次解决门 1,或者以后解决任何其他门时,我们会小心不要改变开关 6 的位置。
我现在可以展示这个问题的二分查找解决方案。新的set_a_switch代码见于清单 7-17。exploreCave函数与之前一样(见清单 7-15)。
void set_a_switch(int door, int switch_positions[],
int door_for_switch[], int n) {
int i, result;
int low = 0, high = n-1, mid;
for (i = 0; i < n; i++)
if (door_for_switch[i] == -1)
switch_positions[i] = 0;
result = tryCombination(switch_positions);
if (result != door) {
for (i = 0; i < n; i++)
if (door_for_switch[i] == -1)
switch_positions[i] = 1;
}
➊ while (low != high) {
mid = (low + high) / 2;
for (i = low; i <= mid; i++)
if (door_for_switch[i] == -1)
switch_positions[i] = 1 - switch_positions[i];
➋ result = tryCombination(switch_positions);
if (result != door) {
high = mid;
for (i = low; i <= mid; i++)
if (door_for_switch[i] == -1)
switch_positions[i] = 1 - switch_positions[i];
}
else
low = mid + 1;
}
door_for_switch[low] = door;
➌ switch_positions[low] = 1 - switch_positions[low];
}
清单 7-17:使用二分查找找到并设置当前门的开关
与清单 7-16 相比,唯一的实际变化是将线性查找替换为二分查找。在每次评估二分查找条件➊之前,我们会安排当前的门被关闭。特别是,一旦low和high相等,循环终止时,门仍然是关闭的。然后我们只需将开关low的位置调整到打开门的位置。
现在让我们研究一下二分查找本身。在每次迭代中,我们计算中点mid,然后改变前半部分开关的位置(但仅限于那些尚未与门关联的开关)。这对当前门产生了什么影响➋?有两种可能性:
门现在已经打开了。我们现在知道我们要找的开关在low和mid之间,因此我们丢弃所有大于mid的开关。我们还会将low和mid之间的每个开关恢复到这一轮之前的状态。这样门又关闭了,为下一轮迭代做准备。
门仍然是关着的。 我们想要的开关因此位于mid + 1和high之间,所以我们抛弃所有mid或更小的开关。我们就做这么多!这里没有开关被翻转,因为门依然是关着的,正如我们想要的那样。
当我们完成二分查找时,low和high将相等,它们告诉我们当前门所关联的开关。此时当前门仍然是关着的,因此我们翻转开关将其打开 ➌。
不再有任何警告:我们有一个干净、快速、基于二分查找的解决方案。将其提交给评审,应该能通过所有测试用例。
总结
有时候,找到一个最优解比检查某个提议的解是否可行要困难得多。应该给树浇多少水?我不知道。10 升水足够吗?这个问题我可以解决。
当条件合适时,二分查找可以将一个困难的优化问题转化为一个更简单的可行性检查问题。有时候感觉就像是在作弊!我们只是为二分查找增加了一个额外的对数因子。对数因子几乎是免费的。作为回报,我们可以处理一个更简单的问题。
我并不是说二分查找是解决本章问题的唯一方法。例如,像我们在第二章中做的那样,也可以在不使用二分查找的情况下解决《喂蚂蚁》问题,但我发现这种方法比本章介绍的解法更复杂。一些可以通过二分查找解决的问题,也可以通过动态规划来解决,但同样,采用这种方法可能非常具有挑战性,而且在实际应用中也很难获得多少回报。
我的观点是,二分查找可以提供既几乎和其他任何方法一样快又更容易设计的解法。如果你不信,可以重新审视本章中的每个问题,这次考虑如何在不使用二分查找的情况下解决它。但实际上,如果你正在处理一个问题,并且发现可以使用二分查找,那就直接使用它,不要回头看。
注释
《喂蚂蚁》最初来自 2014 年克罗地亚信息学奥林匹克竞赛第四轮。《河流跳跃》最初来自 2006 年 12 月美国计算机奥林匹克竞赛银奖组。《生活质量》最初来自 2010 年国际信息学奥林匹克竞赛。《洞门》最初来自 2013 年国际信息学奥林匹克竞赛。
二分查找是一个通用算法设计技巧的体现,称为分治法 (D&C)。D&C 算法通过解决一个或多个独立的子问题,然后将这些解决方案结合起来,来解决原始问题。二分查找仅解决一个子问题——即对应于我们知道包含解决方案的输入部分的子问题。其他 D&C 算法通常解决两个或更多的子问题;在第十章中,我们将看到这样的一个例子。要了解其他通过 D&C 算法高效解决的问题,请参见 Tim Roughgarden(2017 年)的《算法启示录(第一部分):基础》。
第八章:堆和线段树

数据结构通过组织我们的数据,使得某些操作得以加速。例如,在第一章中,我们学习了哈希表,它能加速在集合中查找指定元素的过程。
在本章中,我们将学习两种新的数据结构:堆和线段树。堆是你需要的结构,当你需要找到最大(或最小)元素时;线段树则是你需要的结构,当你需要对数组的片段进行查询时。在我们的第一个问题中,我们将看到堆如何将慢速的最大值计算转化为快速的堆操作;在我们的第二个和第三个问题中,我们将看到线段树如何以类似的方式进行更一般的数组查询。
问题 1:超市促销
这是 SPOJ 问题PRO。
问题
在超市中,每位顾客挑选他们想购买的商品,然后通过收银台结账。一旦顾客支付完成,顾客会收到一张收据,上面标明了他们购买商品的总费用。例如,如果某人挑选了一些商品,总费用为 18 美元,那么他们收据上会写明 18 美元。我们不关心单个商品的费用。
超市正在进行一项持续n天的促销活动。在促销期间,每张收据都会放入一个抽奖箱中。在每一天结束时,两个收据将被从抽奖箱中取出:一个最大费用的收据x,和一个最小费用的收据y。产生最大费用收据的顾客将获得价值x - y美元的奖品。(不必担心超市如何通过收据识别顾客。)这两张收据被移除后将不再出现,但当天的其他收据将继续保留在抽奖箱中(并可能在未来的某天被移除)。
保证每天结束时,抽奖箱中至少会有两张收据。
我们的任务是计算超市在促销活动中发放的总奖金额。
输入
输入包含一个测试用例,由以下几行组成:
-
一行包含n,表示促销活动的持续天数。n的范围是 1 到 5000 之间。
-
n行,每行表示促销活动中的一天。每行以整数k开始,表示当天有k张收据。该行接着包含k个整数,表示当天每张收据的费用。k的范围是 0 到 100,000;每张收据的费用是一个正数,且不超过 1,000,000。
整个促销活动期间产生的收据总数最多为 1,000,000 张。
输出
输出超市发放的总奖金额。
解决此测试用例的时间限制为 0.6 秒。
解决方案 1:数组中的最大值和最小值
对于本书中的许多问题,设计一个正确的算法已经是一个挑战,更不用说高效的算法了。至少对于当前的问题,正确性似乎并不那么难。确定每天的奖金只需要在选票箱中搜索最大费用,然后再搜索一次最小费用。也许这已经足够高效了?
让我们来看一个测试案例:
2
16 6 63 16 82 25 2 43 5 17 10 56 85 38 15 32 91
1 57
记住,每行收据的第一个数字告诉我们收据的数量,而不是收据费用。在第一天之后,并在移除任何收据之前,我们有这 16 个收据费用:
6 63 16 82 25 2 43 5 17 10 56 85 38 15 32 91
最大收据费用是 91,最小的是 2。那两张收据被移除,它们贡献了 91 – 2 = 89 的奖金。移除 91 和 2 后剩下的收据如下:
6 63 16 82 25 43 5 17 10 56 85 38 15 32
现在我们进入第二天。我们加上 57 得到:
6 63 16 82 25 43 5 17 10 56 85 38 15 32 57
现在最大值是 85,最小值是 5,所以奖金是 85 – 5 = 80。这个促销活动的总奖金因此是 89 + 80 = 169。
一种实现思路是将收据存储在一个数组中。要移除一个收据,我们可以像刚才那样直接移除它。这将涉及将后续的收据向左移动,以填补空缺的数组项。但更简单的做法是保持收据在原位置,并为每个收据关联一个 used 标志。如果 used 为 0,则表示该收据尚未使用;如果 used 为 1,则表示它已被使用并且在逻辑上已被移除(因此我们从此以后应该忽略它)。
这里有一些常量和 receipt 结构:
#define MAX_RECEIPTS 1000000
#define MAX_COST 1000000
typedef struct receipt {
int cost;
int used;
} receipt;
我们需要一些辅助函数来识别并移除最大和最小的收据费用,所以我们现在就来实现这些函数。清单 8-1 提供了代码。
int extract_max(receipt receipts[], int num_receipts) {
int max, max_index, i;
❶ max = -1;
for (i = 0; i < num_receipts; i++)
➋ if (!receipts[i].used && receipts[i].cost > max) {
max_index = i;
max = receipts[i].cost;
}
➌ receipts[max_index].used = 1;
return max;
}
int extract_min(receipt receipts[], int num_receipts) {
int min, min_index, i;
➍ min = MAX_COST + 1;
for (i = 0; i < num_receipts; i++)
➎ if (!receipts[i].used && receipts[i].cost < min) {
min_index = i;
min = receipts[i].cost;
}
➏ receipts[min_index].used = 1;
return min;
}
清单 8-1:查找并移除最大和最小费用
移除并返回最大值的操作通常称为 extract-max。同样,移除并返回最小值的操作称为 extract-min。
这些函数的操作非常相似。extract_max 函数将 max 设置为 -1 ❶,这个值小于任何收据费用。当它找到一个“真实的”收据费用时,max 会被设置为该费用,从那时起,它会跟踪到目前为止找到的最大费用。类似的推理可以解释为什么 extract_min 用一个比任何有效费用都高的值来初始化 min ➍。请注意,在每个函数中,只有那些 used 值为 0 ➋ ➎ 的收据会被考虑,并且每个函数都会将识别出的收据的 used 值设置为 1 ➌ ➏。
有了这两个辅助函数后,我们可以编写一个 main 函数来读取输入并解决问题。这里有一个有趣的地方是,读取输入和解决问题是交替进行的:我们读取一些输入(第一天的收据),计算那天的奖金,再读取一些输入(第二天的收据),计算那天的奖金,以此类推。这个过程在 清单 8-2 中得到了实现。
int main(void) {
static struct receipt receipts[MAX_RECEIPTS];
int num_days, num_receipts_today;
int num_receipts = 0;
❶ long long total_prizes = 0;
int i, j, max, min;
scanf("%d", &num_days);
for (i = 0; i < num_days; i++) {
scanf("%d", &num_receipts_today);
for (j = 0; j < num_receipts_today; j++) {
scanf("%d", &receipts[num_receipts].cost);
receipts[num_receipts].used = 0;
num_receipts++;
}
max = extract_max(receipts, num_receipts);
min = extract_min(receipts, num_receipts);
total_prizes += max - min;
}
printf("%lld\n", total_prizes);
return 0;
}
清单 8-2:用于读取输入并解决问题的主函数
唯一需要注意的地方是total_prizes变量的类型 ❶。一个整数或长整数可能不足够。一个典型的长整数可以容纳最大约 40 亿的值;而总奖金金额可能高达 5000 × 1,000,000,即 50 亿。长长整数可以容纳几十亿、上万亿,甚至更多的整数,所以在这里使用长长整数是完全安全的。
外层的for循环针对每一天运行一次,内层的for循环读取当天的每个收据。一旦当天的收据被读取,我们就提取最大收据、提取最小收据,并更新总奖金金额。
这是问题的完整解决方案。它正确地输出了我们的示例测试用例中的169,你应该花点时间确保自己相信它在一般情况下是正确的。
不幸的是,这太慢了,你会收到一个“超时”错误。
我们可以通过思考最坏的测试用例来探索这种低效。假设促销活动持续 5000 天,并且在前 10 天中每天都会收到 100,000 个收据。那么在第 10 天之后,数组中将有大约一百万个收据。找到最大值和最小值需要在数组中进行线性扫描。然而,由于我们每天只删除两个收据,所以促销活动期间数组中几乎一直会有接近一百万个收据。因此,我们需要考虑 5000 天,几乎每天都需要大约一百万次的操作来找到最大值,再加上另一百万次操作来找到最小值。那就是大约 5000 × 2,000,000,或者 100 亿次操作!在严格的时间限制下,这是不可能解决的。如果我们能加快最大值和最小值的计算速度就好了。
让我们快速排除排序作为可能的改进方法。如果我们保持收据数组有序,那么查找并移除最大值将是常数时间操作,因为最大值会在最右侧的索引。查找最小值也可以是常数时间操作,但移除最小值将是线性时间操作,因为我们需要将其他所有元素向左移动。排序还会破坏添加收据的效率:当我们不排序时,可以直接将其放在数组的末尾,但如果排序了,我们必须找到合适的位置。排序并不是答案,答案是堆。
最大堆
我们将从快速查找并提取数组中的最大元素开始。这只解决了问题的一半——我们还需要能够做同样的事情来处理最小值——但我们会在后面讲到。
查找最大值
看看图 8-1 中的树。它有 13 个节点,分别对应以下 13 个收据(我们的示例测试用例中的前 13 个收据):6, 63, 16, 82, 25, 2, 43, 5, 17, 10, 56, 85 和 38。

图 8-1:一个最大堆
快点——在那棵树中,最大收据的成本是多少?
它是 85,且位于根节点。如果有人向你承诺某个树的最大元素位于其根节点,那么你只需返回根节点的元素,而无需搜索或遍历整棵树。
我们的计划是维护这棵树,使最大账单的费用始终位于根节点。我们必须保持警惕,因为我们将会受到两种事件的干扰,它们可能会破坏我们的树:
一个新的账单到来了。 我们需要弄清楚如何重新组织树以纳入这个账单。这个新账单甚至可能比树中的所有元素都要大,在这种情况下,我们需要将账单移动到根节点。
从树中提取一个账单。 我们需要弄清楚如何重新组织树,以确保剩下的最大元素位于根节点。
当然,我们必须快速执行这些插入和提取操作。特别是,我们需要比线性时间还要快,因为线性时间扫描数组正是我们陷入困境的原因!
什么是最大堆?
图 8-1 是一个最大堆的例子。这里的“最大”意味着这棵树让我们能够快速找到最大元素。
最大堆有两个重要性质。首先,它是一个完全二叉树。这意味着树中的每一层都是满的(即没有缺失的节点),除了最底层,其节点是从左到右依次填充的。在图 8-1 中,注意到每一层都是完全满的。虽然最底层并未完全满,但这没关系,因为它的节点是从左边开始填充的。(不要将这里的完全二叉树与第二章中的满二叉树混淆。)
最大堆是一个完全二叉树这一事实并不会直接帮助我们找到最大元素、插入元素或提取最大元素,但它确实导致了堆的极速实现,正如我们将看到的那样。
其次,一个节点的值大于或等于其子节点的值。(图 8-1 中的值都是不同的,因此父节点的值严格大于其子节点的值。)这就是所谓的最大堆顺序性质。
考虑图 8-1 中一个值为 56 的节点。正如所承诺的,56 的值大于它的子节点(10 和 25)的值。这一性质在整个树中都成立,这就是为什么最大值必须位于根节点的原因。其他每个节点都有一个值更大的父节点!
向最大堆中插入元素
当一个新账单到达时,我们将其插入到最大堆中,但必须小心操作,以确保最大堆顺序性质得以保持。
从图 8-1 开始,让我们插入 15。只有一个位置可以放入它,而不破坏完全树的性质:在底层,位于 38 的右侧(见图 8-2)。

图 8-2:插入 15 的最大堆
它确实是一个完整的二叉树,但最大堆序列属性是否仍然成立?是的!15 的父节点是 16,并且 16 大于 15,正如我们所要求的那样。没有额外的操作需要进行。
现在考虑一个更复杂的情况。我们将 32 插入到图 8-2,得到图 8-3。

图 8-3:插入 32 后的最大堆
这里有点麻烦。插入 32 破坏了最大堆顺序属性,因为它的父节点 16 小于 32。(在这里和后续的图中,粗边表示最大堆顺序违规。)我们可以通过交换 16 和 32 来修复这个问题,如图 8-4 所示。

图 8-4:修复了最大堆顺序违规的最大堆
啊,顺序已经恢复:此时 32 必须大于它的两个子节点。它大于它的子节点 16,因为这就是我们进行交换的原因,它也大于另一个子节点 15,因为 15 曾是 16 的子节点。通常,执行这样的交换可以保证新节点和其子节点之间的最大堆顺序属性得以维持。
我们回到了一个最大堆,并且只用了一个交换就解决了问题。不过,可能需要更多的交换,我现在将通过将 91 插入到图 8-4 来演示。请参见图 8-5 查看结果。

图 8-5:插入 91 后的最大堆
我们不得不在树的底部开始一个新层级,因为之前的底层已满。然而,我们不能将 91 作为 5 的子节点,因为它违反了最大堆序列的属性。交换一下就能修复这个问题……嗯,差不多吧。参见图 8-6。

图 8-6:91 向上移动后的最大堆
我们修复了 5 和 91 之间的问题,但现在我们又遇到了 17 和 91 之间的新问题。我们可以通过另一次交换来解决这个问题;请参见图 8-7。

图 8-7:91 再次向上移动的最大堆
我们再次遇到了一个最大堆序列违规,这次是在 63 和 91 之间。然而,注意到违规正在向树的上方移动,越来越接近根节点。在最坏的情况下,我们将把 91 上移到树的根节点。正如这里所发生的那样,因为 91 是最大元素。还需要两次交换才能完成:第一次交换见图 8-8。

图 8-8:91 再次上移后的最大堆
第二次交换见图 8-9。

图 8-9:修复了堆序违规的最大堆
我们再次得到了一个最大堆!我们只需执行四次交换来修复 16 个元素的堆,而这只是对于一个冒泡到根节点的值。如我们所见,插入其他没有冒泡到根节点的值会比这更快。
从最大堆中提取
每次促销结束时,我们都需要从最大堆中提取最大值。与插入一样,我们必须小心地修复树,以确保它再次成为最大堆。我们将看到,这个过程与插入非常相似,只不过这次是一个值向下冒泡,而不是向上冒泡。
让我们从图 8-1 开始,提取最大值。这里是那张图:

提取最大值将 85 移除作为树的根节点,但我们需要在根节点放置其他节点;否则,我们将不再拥有一棵树。我们可以使用的唯一节点是底层最右侧的节点,也就是说,我们可以将 85 和 38 交换,得到图 8-10。

图 8-10:提取 85 后的最大堆
我们刚刚从树的底部取了一个小值并把它顶到顶部。通常情况下,这样做会破坏最大堆顺序属性。这里确实发生了这种情况,因为 38 小于 63 和 82。
我们将通过交换来再次修复最大堆顺序属性。与插入不同,提取给我们提供了一个选择:我们应该交换 38 和 63,还是交换 38 和 82?交换 38 和 63 并不能解决根节点的问题,因为 82 最终会成为 63 的子节点。交换 38 和 82 才是正确的选择。通常情况下,我们要与较大的子节点进行交换,这样就能修复较大子节点及其新子节点之间的最大堆顺序属性。图 8-11 展示了交换 38 和 82 后的结果。

图 8-11:38 向下移动后的最大堆
我们还没有完全解决问题——在 38 和 43 之间仍然存在一个最大堆顺序违反。好消息是,最大堆顺序违反正在向树的下方移动。如果我们不断地将违反下移,那么在最坏的情况下,当 38 到达树的底部时,我们将再次得到一个最大堆。
让我们交换 38 和 43;请参见图 8-12。

图 8-12:38 向下移动后的最大堆
现在 38 已经放置在合适的位置,我们恢复了最大堆顺序属性。
最大堆的高度
插入和提取每层最多执行一次交换:插入将节点向上交换,提取将节点向下交换。插入和提取快吗?这取决于最大堆的高度:如果高度很小,那么这些操作会很快。因此,我们需要理解最大堆中元素数量与最大堆高度之间的关系。
看一下图 8-13,我绘制了一个包含 16 个节点的完整二叉树。

图 8-13:一个包含 16 个节点的完整二叉树(节点编号从上到下,从左到右)
我已经将节点从上到下、从左到右进行了编号。这就是为什么根节点是 1;它的两个子节点是 2 和 3;它们的子节点是 4、5、6 和 7,以此类推。我们可以观察到,每一层的新节点编号都是 2 的幂:根节点是 1,下面一层从 2 开始,再下面一层从 4 开始,然后是 8,接着是 16。也就是说,我们需要将节点数量加倍才能再增加一层。这就像二分查找一样,将元素数量加倍只会导致循环再多进行一次。因此,像二分查找一样,完整二叉树的高度,进而最大堆的高度是O(log n),其中n是树中的元素数量。
我们成功了!插入到最大堆的时间复杂度是O(log n)。从最大堆中提取的时间复杂度也是O(log n)。我们再也不需要被O(n)线性时间的操作拖慢速度了。
最大堆作为数组
最大堆其实就是一个二叉树,而我们知道如何实现二叉树。虽然可以这样实现最大堆,但其实相当有挑战性。
回想一下我们在第二章中解决的 Halloween Haul 问题。我们使用了一个带有指向左子节点和右子节点的指针的node结构体。这样足以让我们将一个值向下冒泡到最大堆中。然而,这对于将一个值向上冒泡到最大堆中就不够了,因为这样做需要访问父节点。因此,我们可能会添加一个父指针:
typedef struct node {
fields for receipts
struct node *left, *right, *parent;
} node;
即使我们像那样添加了parent,我们仍然只能直接访问树的根节点。在插入一个值时,我们应该如何快速找到插入的位置?在提取时,我们又该如何快速找到最底层的最右节点?其实,有更好的方法。
让我们再次使用图 8-13,我已将节点从上到下、从左到右进行了编号。节点 16 的父节点是 8。节点 12 的父节点是 6。节点 7 的父节点是 3。那么,节点编号和父节点编号之间有什么关系呢?
答案是:除以 2!16/2 = 8。12/2 = 6。7/2 = 3。嗯,最后那个其实是 3.5,所以只需要去掉小数部分。
我们通过整数除以 2 来向上移动树形结构。让我们看看如果我们反过来做,改为乘以 2 会发生什么。8 × 2 = 16,因此乘以 2 会将我们从 8 移动到它的左子节点。然而,大多数节点有两个子节点,我们也可能想从一个节点移动到它的右子节点。我们也可以做到这一点:只需在左子节点的编号上加 1。例如,我们可以通过 6 × 2 = 12 从 6 移动到它的左子节点,也可以通过 6 × 2 + 1 = 13 从 6 移动到它的右子节点。(13/2 和 6 之间的关系是为什么从子节点到父节点时可以安全地舍弃 0.5 的一个例子。)
这些节点之间的关系仅在最大堆是完全二叉树的情况下才成立。一般来说,二叉树的结构可能更为混乱,可能在某些地方有长链节点,而在其他地方有短链节点。除非我们插入占位符节点来维持树的“完全性”,否则我们不能仅通过乘以和除以 2 来轻松遍历这样的树。如果树非常不平衡,这将浪费大量内存。
如果我们将最大堆存储在数组中——首先是根节点,然后是它的子节点,再是它们的子节点,依此类推——那么数组中节点的索引对应于它的节点编号。为了与图 8-13 中的编号匹配,我们必须从 1 开始索引,而不是 0。(虽然可以从索引 0 开始,但那样会导致节点之间的关系稍显复杂:索引 i 处节点的父节点位于 (i – 1)/2 处,子节点则位于索引 2i + 1 和 2i + 2 处。)
这里再次是图 8-1,13 个收据费用的堆:

这是相应的数组:

数组中的索引 6 对应的值是 43。43 的左子节点是多少?为了回答这个问题,只需查找索引 6 × 2 = 12 的数组值:是 2。43 的右子节点是多少?查找索引 6 × 2 + 1 = 13:是 38。43 的父节点是多少?查找索引 6/2 = 3:是 82。不管我们当前关注的是树中的哪个节点,我们都可以通过数组和一点数学运算来轻松找到其子节点或父节点。
实现最大堆
我们堆中的每个元素将同时包含一个收据索引和一个收据费用。这是我们在提取收据时需要了解的两条信息。
这是结构体:
typedef struct heap_element {
int receipt_index;
int cost;
} heap_element;
现在我们准备实现一个最大堆。两个关键操作是插入堆和从堆中提取最大值。让我们从插入堆开始;请参见清单 8-3。
void max_heap_insert(heap_element heap[], int *num_heap,
int receipt_index, int cost) {
int i;
heap_element temp;
❶ (*num_heap)++;
➋ heap[*num_heap] = (heap_element){receipt_index, cost};
➌ i = *num_heap;
➍ while (i > 1 && heap[i].cost > heap[i / 2].cost) {
temp = heap[i];
heap[i] = heap[i / 2];
heap[i / 2] = temp;
➎ i = i / 2;
}
}
清单 8-3:插入最大堆
max_heap_insert函数接受四个参数。前两个是堆的相关信息:heap是存储最大堆的数组,num_heap是指向堆中元素数量的指针。之所以使用指针,是因为我们需要将堆中的元素数量增加 1,并让调用者知晓这一变化。后两个参数是针对新接收的元素:receipt_index是我们要插入的接收元素的索引,cost是它的关联成本。
我们首先通过增加堆中的元素数量来进行初始化 ❶,然后将新的接收元素存储在新的堆槽中 ➋。变量i跟踪新插入元素在堆中的索引 ➌。
我们无法保证仍然拥有一个最大堆。我们刚刚插入的元素可能比它的父节点更大,因此我们需要执行必要的交换操作。这就是while循环的作用 ➍。
while循环继续的条件有两个。首先,我们需要i > 1,因为如果i等于 1,就没有父节点。(记住,堆的索引从 1 开始,而不是从 0 开始。)第二,我们需要该节点的接收成本大于其父节点的接收成本。while循环的主体执行交换操作,然后将我们从当前节点移动到其父节点 ➎。啊,没错,我们又用了通过除以 2 的方式来向上移动树结构。这种简洁、优雅且正确的代码是最好的类型。
现在让我们来看看从最大堆中提取的过程。示例 8-4 提供了相关代码。
heap_element max_heap_extract(heap_element heap[], int *num_heap) {
heap_element remove, temp;
int i, child;
❶ remove = heap[1];
➋ heap[1] = heap[*num_heap];
➌ (*num_heap)--;
➍ i = 1;
➎ while (i * 2 <= *num_heap) {
➏ child = i * 2;
if (child < *num_heap && heap[child + 1].cost > heap[child].cost)
❼ child++;
❽ if (heap[child].cost > heap[i].cost) {
temp = heap[i];
heap[i] = heap[child];
heap[child] = temp;
❾ i = child;
} else
break;
}
return remove;
}
示例 8-4:从最大堆中提取最大值
我们从堆的根节点开始,保存即将提取的接收元素 ❶。然后,我们用最底层、最右侧的节点替换根节点 ➋,并将堆中的元素数量减少 1 ➌。新的根元素可能不满足最大堆顺序,因此我们使用变量i来跟踪它在堆中的位置 ➍。接下来,就像在示例 8-3 中一样,我们使用while循环来执行必要的交换操作。这次,while循环的条件 ➎表示节点i的左子节点在堆中;如果没有左子节点,那么节点i就没有子节点,也就不会发生堆顺序违规。
在循环内部,child被设置为左子节点 ➏。然后,如果右子节点存在,我们会检查它的成本是否比左子节点更高。如果是,我们将child设置为右子节点 ❼。此时,child是最大的子节点,我们检查它是否涉及堆排序违规 ❽。如果是,我们就执行交换操作。最后,我们向下移动树结构 ❾,准备检查是否有其他堆排序违规。
注意,如果节点和它的最大子节点已经正确排序,会发生什么:我们会break跳出循环,因为树中不会再有堆排序违规。
函数的最后一步是返回最大费用的收据。我们可以使用它来帮助确定当天的奖品,并确保我们再也不会考虑这张收据。不过,首先,让我们了解最小堆,这样我们就可以同时提取最小值和最大值。
最小堆
一个最小堆允许我们快速插入新的收据并提取最小费用的收据。
定义与操作
猜猜看?你几乎已经掌握了关于最小堆的一切,因为它们与最大堆几乎完全相同。
最小堆是一个完全二叉树。它的高度为O(log n),其中n是堆中元素的数量。我们可以像存储最大堆一样,将其存储在数组中。要查找节点的父节点,将其下标除以 2;要查找左子节点,将其下标乘以 2;要查找右子节点,将其下标乘以 2 并加 1。这里没有新东西。
唯一的新概念是最小堆顺序属性:一个节点的值小于或等于其子节点的值。这导致根节点处的值是最小值,而不是最大值。正是这个特性使得我们能够快速执行最小值提取操作。
让我们再次考虑以下 13 个收据费用:6, 63, 16, 82, 25, 2, 43, 5, 17, 10, 56, 85 和 38。 图 8-14 展示了这些费用的最小堆。

图 8-14:一个最小堆
向最小堆插入数据和从最小堆中提取最小值的操作类似于相应的最大堆操作。
插入时,将新节点添加到底层所有节点的右侧,或者如果底层已满,则开始一个新的一层。然后将该节点向上交换,直到它成为根节点或大于等于其父节点。
为了提取最小值,将根节点替换为最底层最右边的值,然后将该值向下交换,直到它成为叶节点或小于等于其子节点的值。
实现最小堆
实现最小堆就是用最大堆的代码进行复制和粘贴。只需要更改函数名,并将比较符号从>改为<。就这么简单。请参见清单 8-5 了解插入代码。
void min_heap_insert(heap_element heap[], int *num_heap,
int receipt_index, int cost) {
int i;
heap_element temp;
(*num_heap)++;
heap[*num_heap] = (heap_element){receipt_index, cost};
i = *num_heap;
while (i > 1 && heap[i].cost < heap[i / 2].cost) {
temp = heap[i];
heap[i] = heap[i / 2];
heap[i / 2] = temp;
i = i / 2;
}
}
清单 8-5:向最小堆插入数据
清单 8-6 给出了最小值提取的代码。
heap_element min_heap_extract(heap_element heap[], int *num_heap) {
heap_element remove, temp;
int i, child;
remove = heap[1];
heap[1] = heap[*num_heap];
(*num_heap)--;
i = 1;
while (i * 2 <= *num_heap) {
child = i * 2;
if (child < *num_heap && heap[child + 1].cost < heap[child].cost)
child++;
if (heap[child].cost < heap[i].cost) {
temp = heap[i];
heap[i] = heap[child];
heap[child] = temp;
i = child;
} else
break;
}
return remove;
}
清单 8-6:从最小堆中提取最小值
这里有大量的代码重复!实际上,你可以编写更通用的heap_insert和heap_extract函数,并将比较函数作为参数传递(就像qsort一样)。不过,为了简化理解,我们暂时不考虑这个,保持代码原样。
解决方案 2:堆
现在我们已经了解了最大堆和最小堆,准备好迎接这个问题的第二轮挑战。
我们只需要一个main函数,它读取输入并利用堆快速插入和提取收据。参见清单 8-7 了解代码。当你阅读时,会遇到两个while循环。究竟它们在做什么呢?
int main(void) {
❶ static int used[MAX_RECEIPTS] = {0};
➋ static heap_element max_heap[MAX_RECEIPTS + 1];
static heap_element min_heap[MAX_RECEIPTS + 1];
int num_days, receipt_index_today;
int receipt_index = 0;
long long total_prizes = 0;
int i, j, cost;
int max_num_heap = 0, min_num_heap = 0;
heap_element max_element, min_element;
scanf("%d", &num_days);
for (i = 0; i < num_days; i++) {
scanf("%d", &receipt_index_today);
for (j = 0; j < receipt_index_today; j++) {
scanf("%d", &cost);
➌ max_heap_insert(max_heap, &max_num_heap, receipt_index, cost);
➍ min_heap_insert(min_heap, &min_num_heap, receipt_index, cost);
receipt_index++;
}
➎ max_element = max_heap_extract(max_heap, &max_num_heap);
while (used[max_element.receipt_index])
max_element = max_heap_extract(max_heap, &max_num_heap);
used[max_element.receipt_index] = 1;
➏ min_element = min_heap_extract(min_heap, &min_num_heap);
while (used[min_element.receipt_index])
min_element = min_heap_extract(min_heap, &min_num_heap);
used[min_element.receipt_index] = 1;
total_prizes += max_element.cost - min_element.cost;
}
printf("%lld\n", total_prizes);
return 0;
}
列表 8-7:使用堆解决问题的主函数
我们有一个used数组 ❶,它将为每个收据存储一个1,如果它已经被使用过,存储0,如果未使用。最大堆 ➋和最小堆比used数组多一个元素;这就是我们在堆中不使用索引 0 的原因。
对于给定的一天,我们将每个收据的索引插入到最大堆 ➌ 和最小堆 ➍ 中。然后,我们从最大堆 ➎ 中提取一个收据,并从最小堆 ➏ 中提取一个收据。这就是那两个while循环发挥作用的地方,循环直到我们得到一个尚未使用的收据。让我解释一下发生了什么。
当我们从最大堆中提取一个收据时,最好也能从最小堆中提取它,这样两个堆总是包含完全相同的收据。注意,实际上我们并没有从最小堆中提取那个相同的收据。为什么?因为我们根本不知道这个收据在最小堆中的位置!稍后的某个时刻,这个收据可能会从最小堆中提取出来——但是它已经被使用过了,所以我们想丢弃它,而不是再次处理它。
相反的情况也可能发生,因为我们从最小堆中提取一个收据并将其留在最大堆中。稍后,这个已使用的收据可能会从最大堆中提取出来。我们需要忽略它,并再次从最大堆中提取。
所以这就是while循环的作用:忽略已经被其中一个堆处理过的收据。
可能需要一个新的测试用例。它是:
2
2 6 7
2 9 10
这里的奖金是 7 – 6 = 1 来自第一天,10 – 9 = 1 来自第二天,所以总奖金是 2。
在阅读完第一天的两个收据后,每个堆都保存着这两个收据。对于最大堆,我们有:
| receipt_index | cost |
|---|---|
| 1 | 7 |
| 0 | 6 |
对于最小堆,我们有:
| receipt_index | cost |
|---|---|
| 0 | 6 |
| 1 | 7 |
然后我们进行堆提取,从每个堆中移除一个收据。这里是最大堆剩下的内容:
| receipt_index | cost |
|---|---|
| 0 | 6 |
下面是最小堆剩下的内容:
| receipt_index | cost |
|---|---|
| 1 | 7 |
收据 0 仍然在最大堆中,收据 1 仍然在最小堆中。然而,它们已经被使用了,所以我们最好不要再使用它们。
现在考虑第二天。收据 2 和 3 被添加到每个堆中,所以对于最大堆,我们有:
| receipt_index | cost |
|---|---|
| 3 | 10 |
| 0 | 6 |
| 2 | 9 |
对于最小堆,我们有:
| receipt_index | cost |
|---|---|
| 1 | 7 |
| 2 | 9 |
| 3 | 10 |
当我们从最大堆中提取时,得到的是收据 3。太好了。但是,当我们从最小堆中提取时,得到的是收据 1。没有while循环来丢弃它,这会是个大麻烦,因为收据 1 已经被使用过了。
在一天结束时,一个或两个 while 循环可能会迭代多次。如果这种情况持续发生,日复一日,我们就需要关注对程序效率的影响。不过,请注意,收据最多只能从堆中移除一次。如果堆中有 r 个收据,那么最多可以从堆中提取 r 次,不论它们是集中在同一天,还是跨越多天。
是时候提交给评审了。与解决方案 1 因为缓慢的搜索而浪费时间不同,我们基于堆的解决方案应该能在时间限制内顺利通过所有测试用例。
堆
如果你有一个不断输入的值流,并且在任何时刻可能需要处理最大值或最小值,那么你需要使用堆。最大堆用于提取和处理最大值;最小堆用于提取和处理最小值。
堆可以用来实现 优先队列。在优先队列中,每个元素都有一个优先级,决定了它的重要性。在一些应用中,重要元素的优先级是较大的数字,在这种情况下应该使用最大堆;在其他应用中,重要元素的优先级是较小的数字,这时应使用最小堆。当然,如果我们需要同时处理高优先级和低优先级元素,可以使用两个堆,就像我们在解决超市促销问题时所做的那样。
另外两个应用
我发现最小堆比最大堆使用得更频繁。让我们探讨两个可以使用最小堆的例子。
堆排序
有一个著名的排序算法叫做 堆排序,现在我们理解了最小堆后,可以实现它。我们所要做的就是将所有的值插入到最小堆中,然后逐个提取最小值。这些提取会依次取出最小的值、第二小的值、第三小的值,依此类推,从而得到按从小到大排序的值。这实际上只有四行代码。查看清单 8-8 了解详细内容。
#define N 10
int main(void) {
static int values[N] = {96, 61, 36, 74, 45, 60, 47, 6, 95, 93};
static int min_heap[N + 1];
int i, min_num_heap = 0;
// Heapsort. 4 lines!
for (i = 0; i < N; i++)
min_heap_insert(min_heap, &min_num_heap, values[i]);
for (i = 0; i < N; i++)
values[i] = min_heap_extract(min_heap, &min_num_heap);
for (i = 0; i < N; i++)
printf("%d ", values[i]);
printf("\n");
return 0;
}
清单 8-8:堆排序
我们在这里插入的是整数,因此你应该将 min_heap_insert 和 min_heap_extract 修改为使用并比较整数,而不是 heap_element 结构体。
堆排序执行 n 次插入和 n 次提取。堆实现每一次操作的时间复杂度为对数 n,因此堆排序是一个 O(n log n) 算法。这与最快的排序算法的最坏情况运行时间相同。(C 语言中的 qsort 函数中的 q 可能源自 快速排序,一种在实际中比堆排序更快的排序算法。我们将在第十章中遇到快速排序。)
Dijkstra 算法
Dijkstra 算法(第六章)花费大量时间寻找下一个处理的节点。它通过搜索节点距离来找到最小的那个。为了加速 Dijkstra 算法,我们可以使用最小堆!这一点在附录 B 的《Dijkstra 算法:使用堆》中有演示。
选择数据结构
数据结构通常只适用于少数几种操作类型。没有一种万能数据结构可以让所有操作都变快,因此你需要根据所解决的问题选择合适的数据结构。
回想一下 第一章,我们学习了哈希表数据结构。我们是否可以用哈希表来解决超市促销问题?
不!哈希表适用于加速搜索我们要查找的特定项目。哪些雪花可能与雪花 s 相似?单词 c 是否在这个单词列表中?这些是你希望从哈希表中查询的问题。数组中最小的元素是什么?哈希技术在这方面没有帮助。你得通过哈希表进行搜索,而这与直接搜索普通数组没有区别。我们的任务是选择一个专门为当前任务设计的数据结构。对于在数组中查找最小元素,那个数据结构就是最小堆。
与任何通用数据结构一样,堆可以用来解决意想不到的多种问题——但堆的数据结构本身保持不变,就像你在这里学到的一样。所以,虽然我们不再解决另一个堆问题,但接下来我们将遇到一个需要新数据结构——线段树——的问题。与堆一样,线段树仅能加速少数几种操作类型。即便如此,线段树在处理问题时的加速效果仍然令人印象深刻,正是这些加速正是我们所需要的。
问题 2:构建 Treaps
在这个问题中,我们将构建一个 treap 的表示。Treap 是一种灵活的数据结构,可以解决多种搜索问题,如果你感兴趣,我鼓励你深入了解 treap。这里我们只关心构建一个 treap,而不是使用它。当然,为了我们的目的,我会提供关于 treap 的所有必要信息。
这是 POJ 问题 1785。
问题
Treap 是一种二叉树,每个节点都有一个标签和一个优先级。图 8-15 显示了一个示例 treap,其中大写字母是标签,正整数是优先级。我已经通过斜杠将每个节点的标签和优先级分开。例如,根节点的标签是 C,优先级是 58。

图 8-15:Treap 示例
Treap 必须满足两个条件:一个是关于其标签的,另一个是关于其优先级的。
首先,让我们来谈谈标签。对于任何节点 x,其左子树中的标签都小于 x 的标签,右子树中的标签都大于 x 的标签。这就是 二叉搜索树(BST) 的特性。
你可以验证图 8-15 中的 treap 是否符合这个标签属性。对于我们的字母标签,如果一个标签在字母表中出现在另一个标签之前,则该标签小于另一个标签。以根节点为例,它的标签是 C。它左子树中的所有标签都小于 C,而右子树中的所有标签都大于 C。另一个例子是考虑标签为 G 的节点。它左子树中的所有标签——D、E 和 F——都小于 G。那么它右子树中的所有标签呢?嗯,右子树中没有标签,所以不用检查!
其次,我们来谈谈优先级。对于任何节点 x,其子节点的优先级都小于 x 的优先级。嘿,这就像最大堆顺序属性一样!
再次看看根节点。它的优先级是 58。它的孩子最好有更低的优先级——它们的优先级分别是 54 和 56。那么那个优先级为 55 的 G 节点呢?我们需要它的孩子有更低的优先级——它确实如此,优先级是 49。
这就是一个 treap:一个二叉树,其标签满足二叉搜索树(BST)属性,优先级满足最大堆顺序(max-heap-order)属性。注意,没有形状的要求:treap 可以具有任何结构。显然,不像堆那样有完整树的要求。
在这个问题中,我们提供了每个节点的标签/优先级。我们的任务是为这些节点组装并输出一个 treap。
输入
输入包含零个或多个测试用例。每行输入以一个整数 n 开头。每个 n 的范围是 0 到 50,000。如果 n 为 0,则表示没有更多的测试用例需要处理。
如果 n 大于零,那么它表示测试用例中节点的数量。紧接着 n 是 n 个用空格分隔的标记,每个标记对应一个节点。每个标记的形式是 L/P,其中 L 是标签,P 是该节点的优先级。标签是字母字符串;优先级是正整数。所有标签都是唯一的,所有优先级也是唯一的。
这是可能的输入,它生成了图 8-15 中的 treap:
11 A/54 I/16 K/39 E/36 B/42 G/55 D/49 H/56 C/58 J/40 F/5
0
输出
对于每个测试用例,输出 treap 每行一个。以下是 treap 所需的格式:
(<left_subtreap><L>/<P><right_subtreap>)
这里,<left_subtreap> 是左子 treap,
是根节点的优先级,<right_subtreap> 是右子 treap。子 treap 以相同的格式输出。
这是与示例输入对应的输出:
((A/54(B/42))C/58(((D/49(E/36(F/5)))G/55)H/56((I/16)J/40(K/39))))
解决测试用例的时间限制是两秒钟。
递归输出 Treaps
让我们再次考虑我们的示例节点,并推理如何从这些节点生成一个 treap。以下是这些节点:
A/54 I/16 K/39 E/36 B/42 G/55 D/49 H/56 C/58 J/40 F/5
请记住,treap 的优先级必须遵守最大堆顺序属性。特别地,这意味着具有最大优先级的节点必须是根节点。此外,由于输入保证所有优先级都是不同的,因此只有一个节点具有最大优先级。所以就这样确定了:根节点必须是 C/58。
现在我们必须为每个其他节点决定它应该进入 C 的左子堆树还是右子堆树。这些节点的优先级都小于 58,因此优先级不能帮助我们做出左–右划分——但二叉搜索树(BST)属性能!堆树的 BST 属性告诉我们,左子堆树中的标签必须小于 C,右子堆树中的标签必须大于 C。因此,我们可以将剩余节点分成两组,一组用于左子堆树,一组用于右子堆树,具体如下:
A/54 B/42
I/16 K/39 E/36 G/55 D/49 H/56 J/40 F/5
也就是说,左子堆树将包含节点 A 和 B,右子堆树将包含节点 I、K、E、G 等。
现在,我们完成了!我们将原始问题分解为两个更小的子问题,形式完全相同。我们被要求为 11 个节点生成一个堆树。我们已经将这个问题简化为为两个节点生成堆树和为八个节点生成堆树。我们可以递归地解决这些问题!
让我们明确一下我们将使用的具体规则。对于基准情况,我们可以使用一个零节点的堆树,这不需要输出任何内容。对于递归情况,我们将根节点识别为具有最高优先级的节点,然后将剩余的节点分成标签较小的和标签较大的节点。我们输出一个左括号,递归输出较小标签的堆树,输出堆树的根节点,输出较大标签的堆树,最后输出一个右括号。
对于我们的示例输入,我们将输出一个左括号。接着我们输出左子堆树:
(A/54(B/42))
然后是根节点:
C/58
然后是右子堆树:
(((D/49(E/36(F/5)))G/55)H/56((I/16)J/40(K/39)))
最后是一个右括号。
按标签排序
在开始编写代码之前,我还有一个实现思路。到目前为止,我描述的方式似乎需要我们实际创建一个新数组,包含小标签节点,用于传递给第一次递归调用;另一个新数组包含大标签节点,用于传递给第二次递归调用。这将导致数组之间频繁复制。幸运的是,我们可以通过一开始就按标签从小到大排序节点,避免这些操作。然后,我们只需告诉每个递归调用它负责的数组的起始和结束索引。
例如,如果我们按标签排序我们的示例输入,结果是:
A/54 B/42 C/58 D/49 E/36 F/5 G/55 H/56 I/16 J/40 K/39
然后我们可以告诉第一次递归调用生成前两个节点的子堆树,第二次递归调用生成后八个节点的子堆树。
解决方案 1:递归
这里有一些常量和一个结构体:
#define MAX_NODES 50000
#define LABEL_LENGTH 16
typedef struct treap_node {
char * label;
int priority;
} treap_node;
我们不知道标签的长度,所以我们将初始大小设置为 16。你会看到我们调用一个read_label函数来读取每个标签;如果 16 的长度不足,该函数会分配更多内存,直到标签能够适配。(这可能有点过头,因为测试用例似乎使用的标签最多只有五个字母,但安全起见总比出错好。)
主函数
让我们看一下main函数,正如在示例 8-9 中给出的那样。它使用了一些辅助函数——我们刚才提到的read_label和用于比较 treap 节点的compare——并调用solve函数来实际输出 treap。我们稍后会讨论这些函数。
int main(void) {
static treap_node treap_nodes[MAX_NODES];
int num_nodes, i;
scanf("%d ", &num_nodes);
while (num_nodes > 0) {
for (i = 0; i < num_nodes; i++) {
treap_nodes[i].label = read_label(LABEL_LENGTH);
scanf("%d ", &treap_nodes[i].priority);
}
qsort(treap_nodes, num_nodes, sizeof(treap_node), compare);
solve(treap_nodes, 0, num_nodes - 1);
printf("\n");
scanf("%d ", &num_nodes);
}
return 0;
}
示例 8-9:用于读取输入并解决问题的主函数
在读取混合数字和字符串的程序中,要小心使用scanf。在这里,每个输入的数字后面都有空白字符,我们不希望这些空格字符出现在后续标签的前面。为了读取并丢弃这些空格,我们在每个%d scanf格式说明符后面加上一个空格。
辅助函数
我们使用scanf来读取优先级,但不读取标签。标签是通过示例 8-10 中的read_label函数来读取的。
/* based on https://stackoverflow.com/questions/16870485 */
char *read_label(int size) {
char *str;
int ch;
int len = 0;
str = malloc(size);
if (str == NULL) {
fprintf(stderr, "malloc error\n");
exit(1);
}
❶ while ((ch = getchar()) != EOF && (ch != '/')) {
str[len++] = ch;
if (len == size) {
size = size * 2;
str = realloc(str, size);
if (str == NULL) {
fprintf(stderr, "realloc error\n");
exit(1);
}
}
}
str[len] = '\0';
return str;
}
示例 8-10:读取标签
我们在示例 5-15 中曾经用过基本相同的函数。唯一的区别是这次我们在/字符处停止读取,这个字符将标签与优先级分开❶。像往常一样,qsort需要一个比较函数,而我们需要的比较函数在示例 8-11 中给出,它通过标签比较节点。
int compare(const void *v1, const void *v2) {
const treap_node *n1 = v1;
const treap_node *n2 = v2;
return strcmp(n1->label, n2->label);
}
示例 8-11:排序的比较函数
strcmp函数作为比较函数工作得非常好,因为如果第一个字符串按字母顺序小于第二个字符串,它返回负整数;如果两个字符串相等,则返回0;如果第一个字符串按字母顺序大于第二个字符串,则返回正整数。
输出 Treap
在我们进入主要部分——solve函数之前,我们需要一个辅助函数来返回具有最大优先级的节点的索引。这个函数在示例 8-12 中提供。它是从left索引到right索引的慢速线性搜索(这应该让你感到担忧!)。
int max_priority_index(treap_node treap_nodes[], int left, int right) {
int i;
int max_index = left;
for (i = left + 1; i <= right; i++)
if (treap_nodes[i].priority > treap_nodes[max_index].priority)
max_index = i;
return max_index;
}
示例 8-12:找到最大优先级
现在我们准备好输出 treap 了!请参见示例 8-13 中的solve函数。
void solve(treap_node treap_nodes[], int left, int right) {
int root_index;
treap_node root;
❶ if (left > right)
return;
➋ root_index = max_priority_index(treap_nodes, left, right);
root = treap_nodes[root_index];
printf("(");
➌ solve(treap_nodes, left, root_index - 1);
printf("%s/%d", root.label, root.priority);
➍ solve(treap_nodes, root_index + 1, right);
printf(")");
}
示例 8-13:解决问题
该函数接受三个参数:treap 节点的数组和确定我们希望构建 treap 的节点范围的left和right索引。为了构建包含所有节点的 treap,来自main的初始调用将传递0作为left,num_nodes - 1作为right。
这个递归函数的基本情况出现在 treap 中没有节点时❶。在这种情况下,我们只需返回,而不输出任何内容。没有节点,就没有输出。
否则,对于left和right之间的节点,我们找到具有最大优先级的节点的索引➋。这就是 treap 的根节点,它将问题分为两部分:输出标签较小节点的 treap 和输出标签较大节点的 treap。我们通过递归调用分别解决这两个子问题➌ ➍。
就这样:我们的第一个解决方案。老实说,我觉得它相当不错。实际上,它做对了两件重要的事。首先,它一次性排序所有节点,因此每次调用 solve 只需要它的 left 和 right 索引。其次,它使用递归轻松处理了本来可能令人头疼的输出 treap 的过程。
然而,将此代码提交给评测系统后,你会发现一切都停滞不前,因为需要进行线性搜索以找到具有最大优先级的节点(Listing 8-12)。它到底有什么问题?什么样的 treap 会触发其最坏情况下的性能?我们接下来会讨论这个问题。
范围最大查询(RMQ)
在上一章中,我们讨论了解决范围和查询问题。这个问题问的是:“给定一个数组 a、左索引 left 和右索引 right,从 a[left] 到 a[right] 的所有元素的 和 是多少?”
在构建 Treaps 时,我们需要解决一个相关的问题,称为 范围最大查询(RMQ) 问题。问题是:“给定一个数组 a、左索引 left 和右索引 right,从 a[left] 到 a[right] 的所有元素中,哪个元素的索引最大?”(对于某些问题,可能只需要得到最大元素本身,而不是索引,但在构建 Treaps 时,我们需要索引。)
在解决方案 1 中,我们提供了 Listing 8-12 中的 RMQ 实现。它从 left 遍历到 right,检查是否找到了一个节点的索引,其优先级高于我们到目前为止发现的节点。我们对每个子 treap 调用该函数,每次调用都涉及对数组活动区间的线性搜索。如果这些线性搜索大多是在小的数组区间内进行的,那么我们或许可以应付过去。然而,有些输入会导致许多搜索出现在数组的大区间上。以下是我们可能从输入中读取的节点列表:
A/1 B/2 C/3 D/4 E/5 F/6 G/7
我们扫描所有七个节点,找出 G/7 作为具有最大优先级的节点。然后,我们递归输出小标签节点的 treap,并递归输出大标签节点的 treap。不幸的是,第一个递归调用得到的是除了 G/7 节点之外的所有节点,而第二个递归调用则是在零个节点上进行。第一个递归调用得到的是:
A/1 B/2 C/3 D/4 E/5 F/6
现在,我们需要再扫描这六个元素以识别具有最高优先级的节点。我们将 F/6 识别为该节点,将其设为此子 treap 的根节点,然后再进行两个递归调用。然而,第一次递归调用仍然需要处理所有剩余的节点,导致又一次昂贵的数组扫描。这个昂贵的数组扫描模式可能会持续,直到没有节点剩余为止。
一般来说,我们可以说,对于n个节点,第一个 RMQ 可能需要n步,第二个可能需要n – 1 步,以此类推,一直到 1 步。总共是 1 + 2 + 3 + . . .+ n步。这个公式的封闭形式是n(n + 1)/2。在第一章中,我们在“诊断问题”部分的第 9 页看到过一个非常类似的公式。我们也可以得出类似的结论,我们在这里做的是O(n²)(二次方)工作。
这里有另一种方式来观察我们正在做O(n²)的工作。舍弃前n/2 个最小的项,专注于剩余的n/2 个较大的项。(假设n是偶数,这样n/2 就是整数。)这将剩下n + (n – 1) + (n – 2) + . . . + (n/2 + 1)。这里有n/2 个项,每一个都大于n/2,因此它们的和至少为(n/2)(n/2) = n²/4。这是二次方的!
因此,线性搜索来解决 RMQ 问题是不理想的。
在上一章中,我们使用了前缀数组来加速区间和查询。现在回顾一下这个方法,因为我马上要问你一个问题:我们能用这个技术来解决 RMQ 吗?
很遗憾,不行。(或者幸运的是不行,因为这样我可以教你我最喜欢的数据结构之一。)要从索引 2 到索引 5 求和,我们可以查找索引 5 的前缀和并减去索引 1 的前缀和。因为减法可以抵消加法:索引 5 的前缀和包含了索引 1 的前缀和,所以我们只需把后者从中减去。遗憾的是,我们不能用相同的方式“撤销”最大值计算。如果索引 5 之前的元素最大值是 10,而索引 1 之前的元素最大值也是 10,那么索引 2 到索引 5 的最大值是多少?谁知道!当那 10 被去掉后,它可能是索引 2、3、4 或 5 中的任意一个。一个巨大的早期元素阻止了后来的元素对前缀数组的任何更改。当那个巨大的元素消失时,我们就迷失了方向。相比之下,前缀和数组中的每个元素都会留下它的痕迹。
作为最后的努力,让我们尝试堆。我们能用最大堆来解决 RMQ 吗?不,再次不行。最大堆给我们的是整个堆的最大元素,而不能限制它仅仅对某个特定范围有效。
是时候尝试一些新方法了。
线段树
走开,treap,走开!我们稍后会回到 treap,就在我们有了 RMQ 更好的实现之后。
线段树是一个完全二叉树,其中每个节点都与基础数组的某个特定区间相关联。(在构建 Treap 时,基础数组是优先级数组。)每个节点存储其区间查询的答案。对于 RMQ,每个节点存储其区间内最大元素的索引,但线段树也可以用于其他查询。这些区间被排列成这样,少数几个区间可以组合起来回答任何查询。
段
线段树的根节点覆盖整个数组。因此,如果我们被要求查询整个数组的 RMQ,我们只需看根节点,就能一步解决。对于其他查询,我们需要使用其他节点。根节点有两个子节点:左子节点覆盖数组的前半部分,右子节点覆盖数组的后半部分。这些节点每个都有两个子节点,进一步细分段,依此类推,直到我们得到仅包含一个元素的段。
图 8-16 展示了一个支持对八元素数组进行查询的线段树。每个节点都标注了它的左右端点。线段树中暂时没有 RMQ 相关信息;目前我们只关注段本身。

图 8-16:一个八元素数组的线段树
请注意,随着我们在树中每下降一层,段的大小会减半。例如,根节点段覆盖八个元素,每个子节点段覆盖四个元素,它们的子节点段覆盖两个元素,依此类推。像堆一样,线段树的高度是 log n,其中 n 是数组中的元素数量。我们可以通过每层做常量量的工作来回答任何查询,因此每次查询的时间复杂度是 O(log n)。
图 8-16 是一个完整的二叉树。通过我们对堆的学习,我们知道该如何处理这些:将它们存储在数组中!然后,我们可以使用相同的数学方法来找到父节点的子节点,这在处理线段树时会用到。
现在,我将给你展示另一个线段树,它揭示了一些意外的内容。请看图 8-17。

图 8-17:一个 11 元素数组的线段树
这根本不是一个完整的二叉树,因为底层没有从左到右填满!例如,节点 2-2 没有子节点,尽管 3-4 有。
不过没关系。我们会继续将线段树存储在数组中。我们将继续通过将节点索引乘以 2 来获得左子节点,乘以 2 再加 1 来获得右子节点。唯一发生的事就是数组会有一些浪费。例如,图 8-17 中数组元素的顺序如下,其中*表示未使用的元素:
0-10
0-5 6-10
0-2 3-5 6-8 9-10
0-1 2-2 3-4 5-5 6-7 8-8 9-9 10-10
0-0 1-1 * * 3-3 4-4 * * 6-6 7-7 * * * * * *
这种浪费确实使得确定线段树所需的数组元素个数变得稍微困难一些。
如果底层数组的元素数 n 是 2 的幂,那么我们可以安全地使用一个能够容纳 2n 元素的线段树。例如,计算 图 8-16 中的节点数量:它需要 15 个节点,少于 8 × 2 = 16。(2n 是安全的,因为所有小于 n 的 2 的幂加起来正好是 n – 1。例如,4 + 2 + 1 = 7,少于 8。)如果 n 不是 2 的幂,则 2n 不够用。为证明这一点,不妨看看 图 8-17,它需要一个包含 31 个元素的数组(大于 2 × 11 = 22)才能容纳。
覆盖线段树中的更多元素时,我们需要更大容量的线段树数组——那么它应该有多大呢?假设我们有一个包含 n 元素的底层数组,想要为其构建一颗线段树。我认为,为了安全起见,线段树应该分配一个包含 4n 元素的数组。
设 m 为大于或等于 n 的最小 2 的幂。例如,如果 n 是 11,则 m 是 16。我们可以在一个包含 2m 元素的数组中存储 m 个元素的线段树。由于 m ≥ n,因此一个包含 2m 元素的数组足以存储 n 个元素的线段树。
幸运的是,m 不会太大:它最多是 n 的两倍。(最坏的情况发生在 n 的值刚好超过 2 的幂时。例如,如果 n 是 9,那么 m 是 16,这几乎是 9 的两倍。)因此,如果我们需要一个包含 2m 元素的数组,并且 m 最多是 2n,那么 2m 至少是 2 × 2n = 4n。
初始化区间
在线段树的每个节点中,我们将存储三件事:其区间的左索引、右索引和区间内最大元素的索引。我们将在处理第三项之前初始化前两项。
这是我们将用于线段树节点的结构体:
typedef struct segtree_node {
int left, right;
int max_index;
} segtree_node;
为了初始化每个节点的left和right成员,我们将编写以下函数签名的主体:
void init_segtree(segtree_node segtree[], int node,
int left, int right)
我们假设segtree是一个具有足够空间来存储线段树的数组。node参数是线段树的根索引;left和right是其区间的左和右索引。init_segtree的初始调用如下所示:
init_segtree(segtree, 1, 0, num_elements - 1);
这里的num_elements是底层数组中的元素数量(例如,treap 中的节点数)。
我们可以使用递归来实现init_segtree。如果left和right相等,则我们有一个包含一个元素的区间,不需要进行细分。否则,我们进入递归情况,需要将区间分成两部分。Listing 8-14 提供了代码。
void init_segtree(segtree_node segtree[], int node,
int left, int right) {
int mid;
segtree[node].left = left;
segtree[node].right = right;
❶ if (left == right)
return;
➋ mid = (left + right) / 2;
➌ init_segtree(segtree, node * 2, left, mid);
➍ init_segtree(segtree, node * 2 + 1, mid + 1, right);
}
Listing 8-14: 初始化线段树区间
我们首先将left和right的值存储在节点中。然后,我们检查基本情况❶,如果不需要子节点,则从函数返回。
如果需要子节点,我们计算当前区间的中点➋。然后,我们需要为 left 到 mid 之间的索引构建左侧段树,为 mid + 1 到 right 之间的索引构建右侧段树。这是通过两个递归调用实现的:一个是左侧 ➌,另一个是右侧 ➍。注意,我们如何使用 node * 2 移动到左子节点,使用 node * 2 + 1 移动到右子节点。
填充段树
初始化段树后,接下来是将每个节点所在区间内最大元素的索引添加到该节点中。作为示例,我们需要一个段树和一个数组,段树将基于这个数组。对于段树,我们使用图 8-17,而对于数组,我们使用“按标签排序”中的 11 个优先级,见第 303 页。图 8-18 展示了填充后的段树。每个节点的最大索引列在其区间端点下方。

图 8-18:一个段树和一个优先级数组
让我们进行几个快速检查。考虑树底部的 0-0 节点。那是一个只有索引 0 的区间,所以最大元素的索引只能是 0。这听起来像是一个基准情况!
现在考虑节点 6-10。该节点表示 7 是从索引 6 到索引 10 之间最大元素的索引。索引 7 处是 56,您可以验证这是该区间内的最大元素。为了快速计算这一点,我们可以使用存储在 6-10 的子节点中的最大索引:左子节点表示 7 是 6-8 区间的所需索引,右子节点表示 9 是 9-10 区间的所需索引。那么对于 6-10,我们实际上只有两个选择:索引 7 或索引 9,这些是从这些子树返回的元素。这听起来像是一个递归情况!
没错:我们将使用递归来填充树,就像初始化树的区间时那样。清单 8-15 给出了代码。
int fill_segtree(segtree_node segtree[], int node,
treap_node treap_nodes[]) {
int left_max, right_max;
❶ if (segtree[node].left == segtree[node].right) {
segtree[node].max_index = segtree[node].left;
➋ return segtree[node].max_index;
}
➌ left_max = fill_segtree(segtree, node * 2, treap_nodes);
➍ right_max = fill_segtree(segtree, node * 2 + 1, treap_nodes);
➎ if (treap_nodes[left_max].priority > treap_nodes[right_max].priority)
segtree[node].max_index = left_max;
else
segtree[node].max_index = right_max;
➏ return segtree[node].max_index;
}
清单 8-15:添加最大值
segtree 参数是存储段树的数组;我们假设它已经通过清单 8-14 初始化。node 参数是段树的根索引,treap_nodes 是一个包含 treap 节点的数组。我们在这里需要 treap 节点是为了访问它们的优先级,但除此之外,这与 treap 没有关系。你可以轻松地将 treap 节点替换为任何你需要的东西,以解决给定问题。
这个函数返回段树根节点的最大元素索引。
代码首先进行基准情况检查:即节点是否仅覆盖一个索引❶。如果是,那么该节点的最大索引就是它的左索引(或者右索引,它们毕竟是相同的)。然后,我们返回该最大索引➋。
如果我们不在基础情况中,那么我们正在查看一个跨越多个索引的区间。我们递归调用左子树 ➌。该调用计算该子树中每个节点的max_index值,并返回该子树根节点的max_index值。然后,我们对右子树 ➍ 也做同样的操作。接着,我们比较从这些递归调用中得到的索引 ➎,选择优先级更高的那个,并相应地设置该节点的max_index。最后,我们做的事情是返回这个最大索引 ➏。
以这种方式填充树需要线性时间:对于每个节点,我们需要做一个固定量的工作来找到它的最大索引。
查询线段树
让我们回顾一下。我们在尝试解决构建 Treap 的问题时遇到了瓶颈,因为我们没有一个快速的方法来响应区间最大查询。因此,我们花了很多时间来开发线段树,决定如何选择区间,如何设置线段树数组的大小,以及如何为每个节点存储最大元素的索引。
当然,所有这些线段树的内容如果不能提供快速查询,就毫无意义。最后,到了回报的时刻:使用线段树来获得快速查询。现在是行动时刻!别担心——它涉及的仅仅是我们至今在使用线段树时已经习惯的递归方式。
为了理解这一点,我们将对图 8-18 进行一些示例查询。这里再次展示该图:

对于我们的第一个查询,我们做 6-10。这个范围仅涵盖了根节点 0-10 范围的一部分,因此返回根节点的最大索引并不合适。相反,我们将询问根节点的每个子节点获取最大相关索引,并用这些答案来返回总体的最大索引。根节点的左子节点涵盖了范围 0-5,而这个范围与我们的 6-10 完全没有重叠。左递归调用对我们没有任何帮助。然而,根节点的右子节点恰好覆盖了 6-10 范围。对右子节点的递归调用将返回 7,这是我们应返回的结果:7 是 6-10 范围内最大元素的索引。
对于我们的第二个查询,我们做 3-8。同样,我们将询问根节点的每个子节点获取最大相关索引——不过这次两个子节点都会有返回值,因为 3-8 同时与 0-5 和 6-10 有重叠。左子节点的递归调用返回 3,而右子节点的递归调用返回 7。在根节点,我们只需要将索引 3 处的元素与索引 7 处的元素进行比较。索引 7 处的元素较大,所以这是我们的答案。
我通常不展开递归,但在这里我会破例,因为我认为这可能会有所帮助。让我们进一步深入左子树的递归调用。我们仍然在查询 3-8,节点的区间是 0-5。0-5 的左子树是 0-2。0-2 与我们的 3-8 查询范围没有任何交集,所以不考虑。剩下的是 3-5 节点来完成工作。重要的是,3-5 完全包含在我们所需的 3-8 范围内,因此我们在这里停止,并从 3-5 的递归调用中返回 3。
查询线段树的节点会落入三种情况之一,我们在这里的示例中已经看到过它们。情况 1 是节点与查询范围没有交集,情况 2 是节点的区间完全包含在查询范围内,情况 3 是节点的区间部分包含在查询范围内,同时也包含不在查询范围内的索引。
我建议在查看代码之前先暂停,在手动做几个查询示例。特别是,尝试查询 4-9。你会注意到它需要沿着树向下追踪两条长路径。这是最坏的情况:我们在树的顶部分裂成两个节点,然后沿着这两条路径一直追踪到底。通过更多的示例,尤其是更大的线段树,你可以证明这些路径不会进一步分裂成各自的两条长路径。因此,虽然查询线段树确实比堆操作做了更多的工作——有时是追踪两条路径而不是一条——但它仍然在每一层访问较少的节点,保持了O(log n) 的运行时间。
查询线段树的代码见列表 8-16。
int query_segtree(segtree_node segtree[], int node,
treap_node treap_nodes[], int left, int right) {
int left_max, right_max;
❶ if (right < segtree[node].left || left > segtree[node].right)
return -1;
➋ if (left <= segtree[node].left && segtree[node].right <= right)
return segtree[node].max_index;
➌ left_max = query_segtree(segtree, node * 2,
treap_nodes, left, right);
➍ right_max = query_segtree(segtree, node * 2 + 1,
treap_nodes, left, right);
if (left_max == -1)
return right_max;
if (right_max == -1)
return left_max;
➎ if (treap_nodes[left_max].priority > treap_nodes[right_max].priority)
return left_max;
return right_max;
}
列表 8-16:查询线段树
函数参数与列表 8-15 类似,唯一不同的是我们添加了查询的left和right索引。代码依次处理这三种情况。
在情况 1 中,节点与查询没有任何交集。这个情况发生在查询范围结束在节点的区间开始之前,或者查询范围开始在节点的区间结束之后 ❶。我们返回-1,表示该节点没有最大索引可以返回。
在情况 2 中,节点的区间完全包含在查询范围内 ➋。因此,我们返回该节点区间的最大索引。
剩下的是情况 3,节点的区间与查询范围部分重叠。我们会进行两次递归调用:一次获取左子树的最大索引 ➌,一次获取右子树的最大索引 ➍。如果其中一个返回-1,则返回另一个。如果它们都返回有效索引,则选择元素较大的索引 ➎。
解决方案 2:线段树
我们最终要做的事情是修改我们第一个解决方案(特别是清单 8-9 中的main函数和清单 8-13 中的solve函数),以使用线段树。这不需要太多工作:我们只需要调用我们已经编写的线段树函数。
清单 8-17 包含了新的main函数。
int main(void) {
static treap_node treap_nodes[MAX_NODES];
❶ static segtree_node segtree[MAX_NODES * 4 + 1];
int num_nodes, i;
scanf("%d ", &num_nodes);
while (num_nodes > 0) {
for (i = 0; i < num_nodes; i++) {
treap_nodes[i].label = read_label(LABEL_LENGTH);
scanf("%d ", &treap_nodes[i].priority);
}
qsort(treap_nodes, num_nodes, sizeof(treap_node), compare);
➋ init_segtree(segtree, 1, 0, num_nodes - 1);
➌ fill_segtree(segtree, 1, treap_nodes);
➍ solve(treap_nodes, 0, num_nodes - 1, segtree);
printf("\n");
scanf("%d ", &num_nodes);
}
return 0;
}
清单 8-17:添加线段树后的主函数
唯一的新增部分是声明线段树 ❶,调用初始化线段树的段 ➋,调用计算每个线段树节点的最大索引 ➌,以及传递线段树给solve函数的新参数 ➍。
新的solve函数本身见于清单 8-18。
void solve(treap_node treap_nodes[], int left, int right,
segtree_node segtree[]) {
int root_index;
treap_node root;
if (left > right)
return;
❶ root_index = query_segtree(segtree, 1, treap_nodes, left, right);
root = treap_nodes[root_index];
printf("(");
solve(treap_nodes, left, root_index - 1, segtree);
printf("%s/%d", root.label, root.priority);
solve(treap_nodes, root_index + 1, right, segtree);
printf(")");
}
清单 8-18:使用线段树解决问题(已添加)
唯一实质性的变化是调用query_segtree来实现 RMQ ❶!
呼!我们得努力工作了一番。这个线段树的解决方案应该能在时间限制内通过所有评测用例。不过,最终它是值得的,因为线段树能够巧妙地嵌入到各种问题的快速解决方案中。
线段树
线段树在实际中有几个其他名称,包括区间树、比赛树、顺序统计树和范围查询树。更糟糕的是,“线段树”一词也用于指代与我们在这里学习的完全不同的数据结构!或许通过我所选的术语,我不经意地与某个特定的程序员群体取得了共识。
无论你称它们为什么,线段树都是学习算法和有意从事竞赛编程的人的必备结构。在一个包含n元素的底层数组上,你可以在O(n)时间内构建一棵线段树,并在O(log n)时间内查询一个区间。
在《构建 Treaps》中,我们使用线段树解决了 RMQ,但线段树也可以用于其他查询。如果你能够通过快速合并两个子查询的答案来回答一个查询,那么线段树很可能是首选工具。那最小值范围查询呢?对于线段树,你只需取子节点答案的最小值(而不是最大值)。那范围求和查询呢?对于线段树,你只需取子节点答案的总和。
也许你会想知道,线段树是否只在底层数组元素保持不变时才能应用。例如,在构建 Treaps 中,Treap 节点从不发生变化,因此我们的线段树永远不会与数组中存储的数据不同步。事实上,许多线段树问题都有这个特点:查询一个不修改的数组。然而,线段树的一个巧妙的附加特性是,即使底层数组允许变化,它们也可以继续使用。问题 3 向你展示了这是如何实现的,并且它还向我们展示了一种我们以前没有见过的新类型查询。
问题 3:两数之和
这次没有上下文——这只是一个纯粹的线段树问题。如你所见,我们需要支持对数组的更新,且我们所需要的查询与 RMQ 不同。
这是 SPOJ 问题 KGSS。
问题描述
给定一个整数序列 a[1]、a[2]、……、a[n],其中每个整数至少为 0。(可以把序列看作一个从索引 1 开始的数组,而不是从索引 0 开始。)
我们需要支持对序列进行两种类型的操作:
更新 给定整数 x 和 y,将 a[x] 改为 y。
查询 给定整数 x 和 y,返回从 a[x] 到 a[y] 范围内两个元素的最大和。
输入
输入包含一个测试用例,由以下几行组成:
-
一行包含 n,序列中元素的个数。n 的取值范围是 2 到 100,000。
-
一行包含 n 个整数,每个整数依次表示从 a[1] 到 a[n] 的序列元素。每个整数至少为 0。
-
一行包含 q,表示要在序列上执行的操作数。q 的取值范围是 0 到 100,000。
-
q 行,每行给出一个更新或查询操作,将在序列上执行。
以下是可以在 q 行中执行的操作。
更新 更新操作由字母 U、一个空格、一个整数 x、一个空格和一个整数 y 组成。它表示 a[x] 应该被修改为 y。例如,U 1 4 表示将 a[1] 从当前值更改为 4。x 的取值范围是 1 到 n 之间,y 至少为 0。此操作不产生任何输出。
查询 查询操作由字母 Q、一个空格、一个整数 x、一个空格和一个整数 y 组成。它表示我们需要输出从 a[x] 到 a[y] 范围内两个元素的最大和。例如,Q 1 4 要求我们输出从 a[1] 到 a[4] 范围内的两个元素的最大和。x 和 y 的取值范围是 1 到 n,且 x 小于 y。
输出
输出每个查询操作的结果,每行一个结果。
解决该测试用例的时间限制为 1 秒。
填充线段树
在构建 Treap 时,我们需要段树给我们基础数组的索引,用于表征递归和拆分 Treap 节点。然而这次,没有理由在段树中存储索引。我们关心的是元素的和,而不是这些元素的索引。
我们将像在第 310 页的“初始化段”中一样初始化段树的段。现在,我们需要让段树从索引 1 开始覆盖数组,而不是从索引 0 开始,否则没有什么新的地方。图 8-19 展示了一个支持七元素数组的段树。它覆盖了索引 1 到 7,而不是 0 到 6,以符合问题描述。

图 8-19:一个七元素数组的段树
将清单 8-14 中的代码添加到你的程序中。
现在,让我们考虑如何填充每个节点,使其包含其段内两个元素的最大和。假设我们已经找到了节点 1-2 中两个元素的最大和,并且已经找到了节点 3-4 中两个元素的最大和。我们想要找出节点 1-4 中两个元素的最大和。我们该怎么做呢?
当我们在解决 RMQ 时,生活是轻松的,因为一个节点的最大值就是其子节点的最大值。例如,如果左子树中的最大值是 10,右子树中的最大值是 6,那么它们的父节点的最大值就是 10。没有什么意外。相比之下,使用这个“两个元素的最大和”段树时,情况有点奇怪。
假设我们有这四个序列元素:10、8、6 和 15。段 1-2 中两个元素的最大和是 18,而段 3-4 中两个元素的最大和是 21。那段 1-4 的答案是 18 还是 21?都不对!答案是 10 + 15 = 25。如果我们只知道左边的 18 和右边的 21,我们无法推算出 25。我们需要左右子节点告诉我们更多关于它们段的信息——不仅仅是“嘿,这是我两个元素的最大和”。
需要明确的是,有时候仅仅从每个子节点获取两个元素的最大和确实足够了。考虑这个序列:10、8、6 和 4。段 1-2 中两个元素的最大和是 18,而段 3-4 中两个元素的最大和是 10。段 1-4 中两个元素的最大和是 18,恰好是其子段 1-2 的答案——但那是运气好!
一个段的两个元素的最大和至多有三种选择。(如果节点的子节点没有有效的最大和,则选项少于三种。)这些选项如下:
选项 1 最大和在左子节点。这就像我们刚才做的幸运情况。我们从左子节点得到答案。
选项 2 最大和在右子节点。这是另一个幸运的情况,答案就是右子节点给我们的。
选项 3 最大和包括来自左子节点和右子节点的一个元素。这需要更多的工作,因为答案不是子节点的最大和之一。这正是我们需要从子节点获得更多信息的地方。
如果某段的最大两元素和由左侧的一个元素和右侧的一个元素组成,那么它必须使用左侧的最大元素和右侧的最大元素。让我们回到序列 10、8、6 和 15。这里的最大和是一个例子,涉及左侧的一个元素(10)和右侧的一个元素(15)。注意,这些分别是左侧和右侧段中的最大元素。没有办法从每一侧取一个元素,比这更好。
现在我们看到段树节点提供了什么信息。除了外界关心的——两个元素的最大和——我们还需要单独的最大元素。这两部分关于子段的信息结合起来,帮助我们填写父段的信息。
图 8-20 展示了为数组构建的一个段树示例。注意,每个节点包含 maxsum(两个元素的最大和)和 maxelm(最大元素)。

图 8-20:段树及其对应的数组
计算每个节点的最大元素是我们知道如何做的:这正是我们在构建 Treaps 时解决的 RMQ 问题。
这就留下了每个节点的最大两元素和。首先,我们将包含单元素段(如 1-1、2-2 等)的节点的最大和设置为特殊值 –1。这样做的原因是,这些段中甚至没有两个元素可以选择!–1 提醒我们,父节点的最大和不能是这个子节点的最大和。
每个其他节点的最大和是基于其子节点的最大和来设置的。考虑节点 1-7。它有三个选择来获得最大和。我们可以从左子节点获得最大和 25,或者从右子节点获得最大和 12,或者从左子节点获得最大元素 15,右子节点获得最大元素 9,然后将两者加起来得到 15 + 9 = 24。在这三者中,25 是最大的数字,所以我们选择它。
我们特别处理假设的 –1 最大和值,以突出显示这些值不能作为父节点最大和的选项。请留意接下来的代码。
我们将使用一个结构体来表示段树节点:
typedef struct segtree_node {
int left, right;
int max_sum, max_element;
} segtree_node;
我们将使用另一个结构体来表示我们从 fill_segtree 和 query_segtree 函数中返回的内容:
typedef struct node_info {
int max_sum, max_element;
} node_info;
我们需要 node_info,因为它允许我们同时返回最大和和最大元素;如果没有结构体,只返回一个整数是远远不够的。
用于计算每个段的最大和和最大元素的代码见列表 8-19。
int max(int v1, int v2) {
if (v1 > v2)
return v1;
else
return v2;
}
node_info fill_segtree(segtree_node segtree[], int node,
int seq[]) {
node_info left_info, right_info;
❶ if (segtree[node].left == segtree[node].right) {
segtree[node].max_sum = -1;
segtree[node].max_element = seq[segtree[node].left];
➋ return (node_info){segtree[node].max_sum, segtree[node].max_element};
}
➌ left_info = fill_segtree(segtree, node * 2, seq);
right_info = fill_segtree(segtree, node * 2 + 1, seq);
➍ segtree[node].max_element = max(left_info.max_element,
right_info.max_element);
➎ if (left_info.max_sum == -1 && right_info.max_sum == -1)
➏ segtree[node].max_sum = left_info.max_element +
right_info.max_element;
❼ else if (left_info.max_sum == -1)
segtree[node].max_sum = max(left_info.max_element +
right_info.max_element,
right_info.max_sum);
❽ else if (right_info.max_sum == -1)
segtree[node].max_sum = max(left_info.max_element +
right_info.max_element,
left_info.max_sum);
else
❾ segtree[node].max_sum = max(left_info.max_element +
right_info.max_element,
max(left_info.max_sum, right_info.max_sum));
return (node_info){segtree[node].max_sum, segtree[node].max_element};
}
清单 8-19:添加最大和最大元素
当区间仅包含一个元素时,我们进入基准情况 ❶。我们将最大和设置为特殊的-1值,这表示此处没有有效的两个元素的和,并且我们将最大元素设置为区间内的唯一元素。然后我们返回最大和和最大元素 ➋。
否则,我们进入递归情况。我们使用left_info来保存左区间的信息,使用right_info来保存右区间的信息。每个变量都通过递归调用进行初始化 ➌。
如我们所讨论的,区间内的最大元素就是左侧区间的最大元素和右侧区间的最大元素中的最大值 ➍。
现在考虑两个元素的最大和。如果两个子节点都没有最大和 ➎,那么我们知道每个子节点的区间只包含一个元素。此父节点因此只有两个元素在其区间内,将这些元素相加是得到最大和的唯一选择 ➏。
接下来,如果左子节点只有一个元素而右子节点有多个元素 ❼,我们该怎么做呢?此时我们有两个选择来确定父节点的最大和。第一个选择是将每一半的最大元素相加。第二个选择是取右区间的最大和。我们使用max来选择这两者中的最佳方案。当右子节点只有一个元素,而左子节点有多个元素时,情况类似 ❽。
最后的情况是当两个子节点都有多个元素 ❾。此时我们有三种选择:从每一半中添加最大元素,取左区间的最大和,或取右区间的最大和。
查询区间树
我们刚刚做的填充区间信息的工作现在将在查询区间树的代码中再次派上用场。见清单 8-20。
node_info query_segtree(segtree_node segtree[], int node,
int seq[], int left, int right) {
node_info left_info, right_info, ret_info;
❶ if (right < segtree[node].left || left > segtree[node].right)
return (node_info){-1, -1};
➋ if (left <= segtree[node].left && segtree[node].right <= right)
return (node_info) {segtree[node].max_sum, segtree[node].max_element};
left_info = query_segtree(segtree, node * 2, seq, left, right);
right_info = query_segtree(segtree, node * 2 + 1, seq, left, right);
if (left_info.max_element == -1)
return right_info;
if (right_info.max_element == -1)
return left_info;
ret_info.max_element = max(left_info.max_element, right_info.max_element);
if (left_info.max_sum == -1 && right_info.max_sum == -1) {
ret_info.max_sum = left_info.max_element + right_info.max_element;
return ret_info;
}
else if (left_info.max_sum == -1) {
ret_info.max_sum = max(left_info.max_element + right_info.max_element,
right_info.max_sum);
return ret_info;
}
else if (right_info.max_sum == -1) {
ret_info.max_sum = max(left_info.max_element + right_info.max_element,
left_info.max_sum);
return ret_info;
}
else {
ret_info.max_sum = max(left_info.max_element + right_info.max_element,
max(left_info.max_sum, right_info.max_sum));
return ret_info;
}
}
清单 8-20:查询区间树
这段代码的结构与清单 8-16 中的 RMQ 代码相似。如果节点的区间与查询范围完全没有交集 ❶,我们返回一个结构,其中最大和和最大元素均为-1。我们可以使用-1作为最大元素的特殊值,告诉我们没有来自递归调用的信息。
如果节点的区间完全位于查询范围内 ➋,那么我们返回该节点的最大和和最大元素。
最后,如果节点的区间与查询范围部分重叠,那么我们遵循与在清单 8-19 中填充区间信息时相同的逻辑。
更新区间树
当序列数组的一个元素被更新时,我们必须调整区间树以保持同步。否则,对区间树的查询将使用现在已经过时的数组元素,因此可能会得出与当前数组内容不符的结果。
一种选择是从头开始,忽略树中已有的任何段信息。我们可以通过每次更新数组元素时重新执行代码清单 8-19 来实现。这当然可以使段树恢复到最新状态,因此正确性不是问题。
效率是一个问题,然而!重建段树需要 O(n) 时间。若仅有 q 次更新操作,没有任何查询操作,就可能大大降低我们的性能。这样,n 的工作需要执行 q 次,总的性能复杂度为 O(nq)。如果你考虑到没有段树时更新的成本,尤其让人沮丧:它们在数组上是常数时间操作!我们无法承受将常数时间换成线性时间。然而,我们 可以 将常数时间换成对数时间,因为后者非常接近常数时间。
我们逃避线性时间工作的方式是认识到,在更新数组的某个元素时,只需要更新少量的段树节点。拆卸整个树来进行一次更新,实在是过度反应了。
让我通过例子来解释我的意思。这里再次展示图 8-20:

现在,假设下一个操作是 U 4 1,意味着序列的索引 4 应该被更改为值 1(原本是 15)。新的段树和数组如 图 8-21 所示。

图 8-21:段树及其对应的数组,展示数组更新后的状态
注意,只有三个节点发生了变化。节点 4-4 必须变化,因为它所在段的唯一元素发生了改变。然而,这个变化的影响不能扩展得太远:唯一能够变化的节点是 4-4 的祖先节点,因为只有它们的段中才包含索引 4!实际上,在这个例子中,你可以确认,发生变化的节点仅为三个祖先节点:3-4、1-4 和 1-7。因此,最糟糕的情况下,我们从树的叶子节点走到根节点,沿路径更新节点。由于树的高度为 O(log n),所以该路径上只有 O(log n) 个节点。
只要我们不在段树中对无效部分进行递归浪费时间,最终的更新过程将是 O(log n)。代码清单 8-21 给出了相关代码。
node_info update_segtree(segtree_node segtree[], int node,
int seq[], int index) {
segtree_node left_node, right_node;
node_info left_info, right_info;
❶ if (segtree[node].left == segtree[node].right) {
segtree[node].max_element = seq[index];
return (node_info) {segtree[node].max_sum, segtree[node].max_element};
}
left_node = segtree[node * 2];
right_node = segtree[node * 2 + 1];
➋ if (index <= left_node.right ) {
➌ left_info = update_segtree(segtree, node * 2, seq, index);
➍ right_info = (node_info){right_node.max_sum, right_node.max_element};
} else {
right_info = update_segtree(segtree, node * 2 + 1, seq, index);
left_info = (node_info){left_node.max_sum, left_node.max_element};
}
segtree[node].max_element = max(left_info.max_element,
right_info.max_element);
if (left_info.max_sum == -1 && right_info.max_sum == -1)
segtree[node].max_sum = left_info.max_element +
right_info.max_element;
else if (left_info.max_sum == -1)
segtree[node].max_sum = max(left_info.max_element +
right_info.max_element,
right_info.max_sum);
else if (right_info.max_sum == -1)
segtree[node].max_sum = max(left_info.max_element +
right_info.max_element,
left_info.max_sum);
else
segtree[node].max_sum = max(left_info.max_element +
right_info.max_element,
max(left_info.max_sum, right_info.max_sum));
return (node_info) {segtree[node].max_sum, segtree[node].max_element};
}
代码清单 8-21:更新段树
这个函数设计为在给定 index 位置的数组元素已经更新之后调用。每次调用此函数时,必须确保 node 是一个段树的根节点,并且该段树的段包含 index。
我们的基本情况是当线段仅包含一个元素 ❶。由于我们不会进行递归调用,除非index在节点的线段内,因此我们知道这个线段正好包含我们所需的索引。因此,我们将节点的max_element更新为seq[index]中当前存储的值。我们不更新max_sum:它保持为-1,因为这个线段仍然只有一个元素。
现在假设我们不在基本情况中。我们有一个节点,并且我们知道其元素之一——index——已经被更新。那么,完全没有理由进行两次递归调用,因为节点的子节点中只有一个可以包含更新后的元素。如果index在左子节点中,那么我们想在左子节点上进行递归调用来更新左子树。如果index在右子节点中,那么我们想在右子节点上进行递归调用来更新右子树。
为了确定index属于哪个子节点,我们将其与左子节点的最右端索引进行比较。如果index在左子节点的段落结束之前 ➋,那么我们需要在左侧进行递归调用;否则,我们需要在右侧进行递归调用。
让我们稍微谈谈在左侧进行递归调用的情况➌;右侧递归调用的else分支是类似的。我们进行递归调用来更新左子树,并返回更新后的信息。对于右子树,我们只需继承原来的内容 ➍——那里没有更新发生,所以不会有变化。
剩下的代码与清单 8-19 类似。
主要功能
我们现在准备使用我们改进过的线段树来解决问题。main函数的代码在清单 8-22 中给出。
#define MAX_SEQ 100000
int main(void) {
static int seq[MAX_SEQ + 1];
static segtree_node segtree[MAX_SEQ * 4 + 1];
int num_seq, num_ops, i, op, x, y;
char c;
scanf("%d", &num_seq);
for (i = 1; i <= num_seq; i++)
scanf("%d", &seq[i]);
init_segtree(segtree, 1, 1, num_seq);
fill_segtree(segtree, 1, seq);
scanf("%d", &num_ops);
for (op = 0; op < num_ops; op++) {
scanf(" %c%d%d ", &c, &x, &y);
❶ if (c == 'U') {
seq[x] = y;
update_segtree(segtree, 1, seq, x);
➋ } else {
printf("%d\n", query_segtree(segtree, 1, seq, x, y).max_sum);
}
}
return 0;
}
清单 8-22:用于读取输入并解决问题的主要函数
这里唯一需要强调的是处理操作的逻辑。如果下一个操作是更新操作 ❶,我们通过更新数组元素并随后更新线段树来响应。否则,操作是查询操作 ➋,我们通过查询线段树来响应。
是时候提交代码了。评测系统应该会喜欢这个快速、基于线段树的解决方案。
总结
在本章中,我们学习了如何实现和使用堆和线段树。像所有有用的数据结构一样,这些数据结构支持少数几个高效的操作。通常,数据结构并不会单独解决问题。更典型的是,你已经有了一个速度合理的算法,而数据结构帮助你使其更高效。例如,我们在第六章中实现的 Dijkstra 算法已经做得很好,但如果加上一个最小堆,它会做得更好。
每当你重复执行相同类型的操作时,你应该寻找机会通过数据结构来增强你的算法。你是在数组中查找指定的元素吗?那么哈希表就是你需要的。你是在寻找最大值或最小值吗?那就用堆吧。你是在查询数组的区间吗?那就使用线段树。那如果你要判断两个元素是否在同一集合中呢?嗯,那你得看下一章了!
注释
超市促销问题最初来自 2000 年波兰信息学奥林匹克竞赛,第三阶段。构建 Treap 最初来自 2004 年乌尔姆大学本地竞赛。两数之和最初来自 2009 年库鲁克舍特拉在线编程竞赛。
关于线段树和许多其他数据结构的更多内容,我推荐 Matt Fontaine 的Algorithms Live!系列视频(见* algorithms-live.blogspot.com *)。Matt 的线段树视频让我想到了在每个节点中显式地存储left和right区间索引。(你看到的大部分线段树代码都没有这样做,而是将这些索引作为额外的函数参数传递,这让我总是很难理清楚。)
第九章:UNION-FIND**

我们在 第五章 和 第六章 中使用了邻接表数据结构及其算法来解决图的问题。这是一个高效的数据结构,无论是哪种图问题都能使用。然而,如果我们限制要解决的问题类型,我们可以设计出更高效的数据结构。限制问题的范围稍微一点,我们可能就无法做得比邻接表更好。限制得太多,几乎没人会使用我们的数据结构,因为它解决不了他们关心的问题。限制问题的范围恰到好处,你就得到了合并查找(union-find)数据结构,这是本章的主题。它解决图问题——并非所有问题,只有一些。对于它能够解决的问题,它的效率远高于通用图数据结构。
跟踪社交网络中的社区、维护朋友与敌人的群体、以及将物品组织到指定的抽屉中,都是图问题。重要的是,这些问题是特殊的图问题,可以通过使用合并查找方法以惊人的速度解决。让我们开始吧!
问题 1:社交网络
这是 SPOJ 问题 SOCNETC。
问题
你被要求编写一个程序,跟踪社交网络中的人和社区。
总共有 n 个人,编号为 1, 2, …, n。
社区 是一个人加上这个人的朋友、朋友的朋友、朋友的朋友的朋友,以此类推。例如,如果人 1 和人 4 是朋友,而人 4 和人 5 是朋友,那么这个社区就包含这三个人:1、4 和 5。处于同一社区的人彼此之间都是朋友。
每个人从一个孤立的社区开始;随着人与人之间友谊的建立,个人的社区可以逐渐扩大。
你的程序必须支持三种操作:
Add 使得两个提供的人成为朋友。如果这个操作发生,并且这些人在此之前不在同一个社区,那么现在他们将属于同一个(更大的)社区。
Examine 报告两个提供的人是否在同一个社区。
Size 报告提供的某个人所在社区中的人数。
你的程序将在资源有限的计算机上运行,因此有一个参数 m,给出了社区中最多可容纳的人数。我们要求忽略任何会导致社区人数超过 m 的 Add 操作。
输入
输入包含一个测试用例,由以下几行组成:
-
输入一行,包含 n,表示社交网络中的人数,以及 m,表示一个社区允许的最大人数。n 和 m 的取值范围是 1 到 100,000 之间。
-
一行包含整数 q,表示接下来操作的数量。q 的取值范围是 1 到 200,000 之间。
-
q 行,每行对应一个操作。
每一行 q 可以是以下操作之一:
-
Add 操作的形式是
Ax y,其中 x 和 y 是两个人。 -
Examine 操作的格式是
Ex y,其中 x 和 y 是两个人。 -
Size 操作的格式是
Sx,其中 x 是一个人。
Output
对于 Add 操作没有输出。每个 Examine 和 Size 操作的输出会在其单独的一行显示。
Examine 对于 Examine 操作,如果两个人在同一个社区中,输出Yes,否则输出No。
Size 对于 Size 操作,输出该人所在社区的人数。
解决该测试用例的时间限制为一秒。
建模为图
在第五章和第六章中,我们详细地练习了如何将问题框架转化为图的探索。我们弄清楚了该用什么作为节点,什么作为边,然后使用 BFS 或 Dijkstra 算法来探索图。
我们也可以将社交网络建模为图。节点代表社交网络中的人。如果测试用例告诉我们x和y是朋友,那么我们可以在节点x和节点y之间添加一条边。图是无向的,因为两个人之间的友谊是相互的。
与我们之前在第五章和第六章中解决的问题相比,一个关键的区别是社交网络图是动态的。每次我们处理两个尚未成为朋友的人的 Add 操作时,都会向图中添加一条新边。与第五章中的书籍翻译问题相比,这里我们最初就知道所有语言和翻译人员,因此可以一次性构建图并且不需要更新它。
我们通过一个测试用例来演示图的增长过程,并观察图如何帮助我们实现三个必需的操作(Add、Examine 和 Size)。如下所示:
7 6
11
A 1 4
A 4 5
A 3 6
E 1 5
E 2 5
A 1 5
A 2 5
A 4 3
S 4
A 7 6
S 4
我们从七个人和没有任何友谊连接开始,如下所示:

A 1 4操作使人员 1 和 4 成为朋友,因此我们在这两个节点之间添加了一条边:

A 4 5操作同样适用于人员 4 和 5:

对于A 3 6,我们得到:

下一步操作是E 1 5,它询问人员 1 和 5 是否在同一个社区。图会为我们回答这个问题:如果从节点 1 到节点 5 有路径(或者从节点 5 到节点 1 也可以),那么他们在同一个社区;否则,他们不在同一个社区。在这种情况下,他们在同一个社区;从节点 1 到节点 4 再到节点 5 的路径就是从节点 1 到节点 5 的路径。
下一步操作是E 2 5。节点 2 和节点 5 之间没有路径,因此这两个人不在同一个社区中。
接下来是A 1 5,这将在节点 1 和节点 5 之间添加一条边。(注意我们如何交替进行修改图的操作和查询图的操作。)结果如下:

这条边的增加导致了一个环,因为它在两个已经属于同一社区的人之间增加了一个友谊链接。因此,这条新边对社区的数量或大小没有任何影响。我们本可以省略它,但我决定在这里包含所有允许的友谊链接。
现在考虑A 2 5,它确实将两个社区合并:

接下来是A 4 3,它再次将两个社区合并:

现在我们有了第一个 Size 操作:S 4。在人物 4 的社区中有多少人?这相当于确定从节点 4 可达的节点数。共有六个这样的节点,唯一不可达的节点是节点 7,因此答案是6。
现在,考虑A 7 6。我们必须添加节点 7 和 6 之间的边……等等!这条边会导致形成一个包含所有七个人的新社区,但测试用例强制要求任何给定社区的最大人数为六人。我们必须忽略这个 Add 操作。
因此,最后一个操作S 4的答案与之前相同:6。
我们的测试用例的正确输出是:
Yes
No
6
6
这个例子展示了实现每个操作所需的内容。对于 Add,我们将一条新边添加到图中,除非这条边会导致一个社区里的人数过多。对于 Examine,我们确定两个节点之间是否存在路径,或者等价地,是否一个节点可以从另一个节点到达。我们可以使用 BFS 来实现!对于 Size,我们确定从给定节点可达的节点数。我们可以再次使用 BFS!
解决方案 1:BFS
让我们分两步来看这个基于图的解决方案。首先,我将展示处理操作的main函数,随着操作的进行逐步构建图。然后,我将展示 BFS 代码。
主函数
我们需要一个常量和一个结构体来开始:
#define MAX_PEOPLE 100000
typedef struct edge {
int to_person;
struct edge *next;
} edge;
main函数在清单 9-1 中给出。它读取输入,并通过逐步构建和查询图来响应操作。
int main(void) {
static edge *adj_list[MAX_PEOPLE + 1] = {NULL};
static int min_moves[MAX_PEOPLE + 1];
int num_people, num_community, num_ops, i;
char op;
int person1, person2;
edge *e;
int size1, size2, same_community;
scanf("%d%d", &num_people, &num_community);
scanf("%d", &num_ops);
for (i = 0; i < num_ops; i++) {
scanf(" %c", &op);
❶ if (op == 'A') {
scanf("%d%d", &person1, &person2);
➋ find_distances(adj_list, person1, num_people, min_moves);
➌ size1 = size(num_people, min_moves);
same_community = 0;
➍ if (min_moves[person2] != -1)
same_community = 1;
➎ find_distances(adj_list, person2, num_people, min_moves);
➏ size2 = size(num_people, min_moves);
❼ if (same_community || size1 + size2 <= num_community) {
e = malloc(sizeof(edge));
if (e == NULL) {
fprintf(stderr, "malloc error\n");
exit(1);
}
e->to_person = person2;
e->next = adj_list[person1];
adj_list[person1] = e;
e = malloc(sizeof(edge));
if (e == NULL) {
fprintf(stderr, "malloc error\n");
exit(1);
}
e->to_person = person1;
e->next = adj_list[person2];
adj_list[person2] = e;
}
}
❽ else if (op == 'E') {
scanf("%d%d", &person1, &person2);
find_distances(adj_list, person1, num_people, min_moves);
if (min_moves[person2] != -1)
printf("Yes\n");
else
printf("No\n");
}
❾ else {
scanf("%d", &person1);
find_distances(adj_list, person1, num_people, min_moves);
printf("%d\n", size(num_people, min_moves));
}
}
return 0;
}
清单 9-1: main 函数,用于处理操作
正如我们在第五章的书籍翻译中以及在第六章中的问题中所做的那样,我们使用图的邻接表表示法。
让我们看看代码如何处理这三种操作类型,首先从添加操作(Add ❶)开始。我们调用辅助函数find_distances ➋。正如我们稍后会看到的,这个函数实现了广度优先搜索(BFS):它填充min_moves,找出从person1到每个人的图中最短路径,对于任何不可达的人,使用-1表示。然后,我们调用辅助函数size ➌,它使用min_moves中的距离信息来确定person1所在社区的大小。接下来,我们确定person1和person2是否在同一社区:如果person2可以从person1到达,那么它们就在同一个社区 ➍。我们需要这些信息来决定是否添加边:如果两个人已经在同一个社区,那么可以安全地添加这条边,而无需担心创建一个违反社区最大人数限制的社区。
找到person1的社区大小后,我们对person2的社区做相同的处理:首先对person2调用 BFS ➎,然后计算该社区的大小 ➏。
现在,如果没有新的社区,或者新的社区足够小 ❼,那么我们将边添加到图中。实际上,我们添加两条边,因为请记住,图是无向的。
其他操作的代码较少。对于检查操作(Examine ❽),我们运行 BFS 并检查person2是否可以从person1到达。对于大小操作(Size ❾),我们运行 BFS,然后计算从person1可达的节点数。
BFS 代码
我们在这里需要的 BFS 代码与我们在第五章中解决书籍翻译问题时编写的 BFS 代码非常相似,除了没有书籍翻译的费用。参见清单 9-2。
void add_position(int from_person, int to_person,
int new_positions[], int *num_new_positions,
int min_moves[]) {
if (min_moves[to_person] == -1) {
min_moves[to_person] = 1 + min_moves[from_person];
new_positions[*num_new_positions] = to_person;
(*num_new_positions)++;
}
}
void find_distances(edge *adj_list[], int person, int num_people,
int min_moves[]) {
static int cur_positions[MAX_PEOPLE + 1], new_positions[MAX_PEOPLE + 1];
int num_cur_positions, num_new_positions;
int i, from_person;
edge *e;
for (i = 1; i <= num_people; i++)
min_moves[i] = -1;
min_moves[person] = 0; cur_positions[0] = person;
num_cur_positions = 1;
while (num_cur_positions > 0) {
num_new_positions = 0;
for (i = 0; i < num_cur_positions; i++) {
from_person = cur_positions[i];
e = adj_list[from_person];
while (e) {
add_position(from_person, e->to_person,
new_positions, &num_new_positions, min_moves);
e = e->next;
}
}
num_cur_positions = num_new_positions;
for (i = 0; i < num_cur_positions; i++)
cur_positions[i] = new_positions[i];
}
}
清单 9-2:使用 BFS 查找最短距离
查找社区的大小
最后一个需要编写的辅助函数是size,它返回给定人所在社区的人数。参见清单 9-3。
int size(int num_people, int min_moves[]) {
int i, total = 0;
for (i = 1; i <= num_people; i++)
if (min_moves[i] != -1)
total++;
return total;
}
清单 9-3:一个人社区的大小
在这个函数中,假设min_moves已经由find_distances填充。每个min_moves值不是-1的人,因此都是可达的。我们使用total来累加这些可达的人。
就是这样:一个基于图的解决方案。对于每个q操作,我们运行一次 BFS。最坏情况下,每个操作都会向图中添加一条边,因此每次 BFS 调用的工作量最多为q。因此,我们有一个O(q²)的算法,即二次算法。
在第五章中,我建议你不要运行太多次 BFS。最好如果能做到的话只调用一次 BFS。即使是少数几次调用也可以。毕竟,在解决第 151 页的骑士追击问题时,我们通过为每个棋子位置调用一次 BFS 来避免了重复。相同的想法适用于第六章中的 Dijkstra 算法:尽量少调用。在这里,少数几次调用也是可以的。我们在第 198 页上用约 100 次 Dijkstra 调用解决了老鼠迷宫问题,速度已经足够快。过度使用图搜索还没有让我们付出代价。
然而,这个问题现在确实让我们吃到了苦头。如果你将解决方案提交给评审,你会收到一个“超时限制”错误——而且甚至离超时还差得远。我正在我的笔记本上玩一个示例,社交网络中有 100,000 个人和 200,000 个操作。这些操作在添加(Add)、检查(Examine)和大小(Size)操作之间平均分配。我们的基于图的解决方案需要超过两分钟才能运行。你即将学习一种名为并查集(union-find)的新数据结构,在同一个示例上,它的运行速度快了 300 倍。并查集是一个效率怪兽。
并查集(Union-Find)
由于两个原因,图上的广度优先搜索(BFS)并不是解决社交网络问题的一个理想方案。首先,它生成了太多!它会确定人之间的最短路径。例如,它可能会告诉我们,人 1 和人 5 之间的最短路径是 2,但谁在乎呢?我们只想知道两个人是否在同一个社区里。它们是如何最终进入同一个社区的,以及连接它们的友谊链并不重要。
其次,它记住的太少——或者说,它根本不记得:BFS 在每次调用时都会从头开始。但是,想一想这有多浪费。例如,添加操作仅向图中添加一条边。社区不可能与之前有太大的不同。BFS 根本不利用过去的信息,而是重新处理整个图,在下一个操作时从头开始。
目标是设计一个数据结构,它不记住任何关于最短路径的信息,并且在建立新友谊时只做一点点工作。
操作
添加(Add)操作将两个社区合并为一个。(嗯,当合并后的社区过大或者两个人已经在同一个社区时,它什么都不做,但如果它有作用,它会将两个社区合并。)这种操作在算法世界中被称为并集(Union)。通常,并集通过一个更大的集合替代两个集合,包含它们的所有元素。
检查操作告诉我们两个提供的人是否在同一个社区。实现这一点的一种方法是指定每个社区的一个元素作为其代表元素。例如,一个包含人物 1、4 和 5 的社区可能将 4 作为其代表;一个包含人物 3 和 6 的社区可能将 3 作为其代表。人物 1 和 5 在同一个社区吗?是的,因为人物 1 所在社区的代表(4)与人物 5 所在社区的代表(4)相同。人物 4 和 6 在同一个社区吗?不是,因为人物 4 所在社区的代表(4)与人物 6 所在社区的代表(3)不同。
确定一个人的社区代表被称为查找(Find)。我们可以通过两个查找来实现检查(Examine):查找第一个人的社区代表,查找第二个人的社区代表,然后进行比较。
由于 Add 是合并操作,Examine 是查找操作,因此实现这两种操作的数据结构被称为并查集(union-find)数据结构。
一旦我们实现了合并和查找操作,我们就能够很好地支持大小操作了。我们所做的就是存储每个社区的大小,并确保在进行合并时更新大小。然后,我们就可以通过返回相应社区的大小来响应每个大小操作。
基于数组的方法
一种思路是使用一个数组community_of,该数组表示每个人所属社区的代表。例如,如果人物 1、2、4 和 5 在同一个社区,人物 3 和 6 在同一个社区,人物 7 有自己的社区,那么数组可能是这样的:

对于一个只有一个人的社区,代表没有选择的余地。这就是为什么人物 7 的代表是 7。对于一个有多人的社区,代表可以是社区中的任何人。例如,这次我们使用 6 作为代表,但我们也可以使用 3。
使用这个方案,我们可以在常数时间内实现查找操作。我们所做的就是查找目标人物的代表,如下所示:
int find(int person, int community_of[]) {
return community_of[person];
}
你做不到比这更好了!
不幸的是,当我们实现合并操作时,这个方案就会失效。我们唯一的选择是将一个社区的所有代表换成另一个社区的代表。它看起来是这样的:
void union_communities(int person1, int person2,
int community_of[], int num_people) {
int community1, community2, i;
community1 = find(person1, community_of);
community2 = find(person2, community_of);
for (i = 1; i <= num_people; i++)
if (community_of[i] == community1)
community_of[i] = community2;
}
我在这里忽略了社交网络社区的最大大小限制,以避免分散注意力。代码使用find将community1和community2分别设置为person1和person2所在社区的代表。然后,它遍历所有人,将community1中的任何人更改为community2。其效果是community1被吸收到community2中,community1消失。
如果你基于我在这里给出的代码构建并提交了完整的解决方案,你应该会看到它仍然会出现“超时限制”错误。我们需要一种比遍历所有人员更好的方法来联合两个社区。
基于树的方法
最高效的联合查找数据结构是基于树的。每个集合表示为一棵树,树的根节点作为该集合的代表。我将通过图 9-1 中的示例来描述这如何运作。

图 9-1:基于树的联合查找数据结构
这里有三棵树,因此有三个不同的社区:一个社区有人物 1、2、4 和 5;一个社区有人物 3 和 6;一个社区有人物 7。每棵树的根节点——人物 5、6 和 7——作为社区的代表。
我用箭头表示树的边,从子节点指向父节点。你之前在本书中没见过这种表示方式。现在这么做是为了强调我们在导航这些树时的方式。当我描述如何在树中支持查找(Find)和联合(Union)时,我们会看到有必要向上移动树(从子节点到父节点),而不是向下移动。
我们从查找(Find)开始。给定一个人,我们需要返回这个人的代表。我们可以通过向上移动适当的树结构,直到到达根节点。例如,让我们在图 9-1 中找到人物 1 的代表。由于 1 不是根节点,我们移动到 1 的父节点。人物 4 不是根节点,所以我们继续移动到 4 的父节点。人物 5 是根节点,所以我们结束了:5 是 1 的代表。
将这种“跳树”方法与我们在第 341 页的“基于数组的方法”进行对比。在基于数组的方法中,我们只需一步查找代表,而在树结构中,我们必须向上移动树直到找到根节点。这听起来有些危险——如果树变得非常高怎么办?——但我们很快会看到这种担忧是多余的,因为我们能够控制树的高度。
现在让我们来谈谈联合(Union)。给定两个人,我们希望将他们的两棵树合并。从正确性的角度来看,如何将两棵树合并并不重要。然而,正如在查找的上下文中提到的,保持树的高度较小是有帮助的。如果我们将一棵树插入到另一棵树的底部,可能会不必要地增加合并树的高度。为了避免这种情况,我们将一棵树直接插入到另一棵树的根节点下。想要了解这种方式的效果,请参见图 9-2,我已经将根节点为 5 的树与根节点为 6 的树进行了合并。

图 9-2:联合后基于树的联合查找数据结构
我选择将 6 作为合并树的根节点。我们也可以选择将 5 作为合并树的根节点。(这里有个小提示:为什么 5 会是更好的选择?我们在讨论联合查找优化时会看到原因。)
现在我们有足够的信息来设计解决社交网络问题的并查集方案。
解法 2:并查集
在第八章讨论了堆和线段树之后,你可能不会惊讶我们将把并查集数据结构存储在一个数组中!
并查集树不一定是二叉树,因为它们的节点可以有任意数量的子节点。所以我们不能像在第八章那样通过乘 2 或除 2 来移动这些树。但幸运的是,我们只需要支持从子节点到父节点的遍历。我们所需要的只是一个数组,它将任何给定的节点映射到它的父节点。我们可以使用parent数组来做到这一点,其中parent[i]给出节点i的父节点。
回想一下图 9-1,我们有三个社区:一个包含人物 1、2、4 和 5;一个包含人物 3 和 6;另一个包含人物 7。下面是与该图对应的parent数组:

如果我们想找到人物 1 所在社区的代表,该怎么办呢?索引 1 的值是 4,这告诉我们 4 是 1 的父节点;索引 4 的值是 5,这告诉我们 4 的父节点是 5;索引 5 的值是 5,这意味着 5 是……5 的父节点?当然不是!每当parent[i]的值与i相同,就意味着我们已经到达了树的根节点。(区分根节点的另一种常见技巧是使用-1,因为它不能与有效的数组索引混淆。虽然我在本书中不会使用它,但你可能会在你找到的其他代码中遇到它。)
主函数
现在我们准备好一些代码了。让我们从清单 9-4 中给出的main函数开始。(它比清单 9-1 简短得多。一般来说,并查集的代码比较简洁。)
int main(void) {
❶ static int parent[MAX_PEOPLE + 1], size[MAX_PEOPLE + 1];
int num_people, num_community, num_ops, i;
char op;
int person1, person2;
scanf("%d%d", &num_people, &num_community);
➋ for (i = 1; i <= num_people; i++) {
parent[i] = i;
size[i] = 1;
}
scanf("%d", &num_ops);
for (i = 0; i < num_ops; i++) {
scanf(" %c", &op);
if (op == 'A') {
scanf("%d%d", &person1, &person2);
➌ union_communities(person1, person2, parent, size, num_community);
}
else if (op == 'E') {
scanf("%d%d", &person1, &person2);
➍ if (find(person1, parent) == find(person2, parent))
printf("Yes\n");
else
printf("No\n");
}
else {
scanf("%d", &person1);
➎ printf("%d\n", size[find(person1, parent)]);
}
}
return 0;
}
清单 9-4:处理操作的 main 函数
除了我已经描述过的parent数组外,还有一个size数组 ❶。对于每个代表节点i,size[i]表示该社区中的人数。切记不要使用非代表节点查询社区的大小。一旦某人不是代表节点,我们就不会再更新size值了。
使用for循环来初始化parent和size ➋。对于parent数组,我们让每个人都是自己的代表,这相当于让每个人都在自己的集合中。由于每个集合只有一个人,我们将每个size值设置为 1。
为了实现 Add,我们调用union_communities辅助函数 ➌。它将person1和person2的社区联合起来,前提是遵循num_community的大小约束。我们很快会看到它的代码。
为了实现 Examine,我们调用两次find ➍。如果两次调用返回相同的值,那么这些人属于同一个社区;否则,他们不属于同一个社区。
最后,为了实现 Size,我们使用size数组,查找该人的集合代表 ➎。
接下来,我将提供 find 和 union_communities 的实现,这将完成该实现。
find 函数
find 函数以一个人作为参数,并返回该人的代表。请参阅 清单 9-5。
int find(int person, int parent[]) {
int community = person;
while (parent[community] != community)
community = parent[community];
return community;
}
清单 9-5: find 函数
while 循环会不断向上遍历树,直到找到根节点。这个根节点代表了该社区,因此返回该根节点。
union 函数
union_communities 函数接收两个人,除了 parent 数组、size 数组和 num_community 限制之外,还将他们的两个社区合并。(我本来想将这个函数命名为 union,但因为 union 是 C 语言的保留字,所以不允许这么命名。)请参阅 清单 9-6 了解代码。
void union_communities(int person1, int person2, int parent[],
int size[], int num_community) {
int community1, community2;
❶ community1 = find(person1, parent);
➋ community2 = find(person2, parent);
if (community1 != community2 &&
size[community1] + size[community2] <= num_community) {
➌ parent[community1] = community2;
➍ size[community2] = size[community2] + size[community1];
}
}
清单 9-6: union_communities 函数
首先,我们为每个人的社区找到代表 ❶ ➋。并操作需要满足两个条件:首先,两个社区必须不同;其次,两个社区的大小之和不能超过最大允许的社区大小。如果这两个条件都满足,那么我们就执行并操作。
我选择将 community1 合并到 community2 中。也就是说,community1 将消失,community2 将吸收 community1。为了实现这一点,我们必须适当修改 parent 和 size。
在这个并操作之前,community1 是一个社区的根节点,但现在我们希望 community1 以 community2 作为其父节点。所以,这正是我们要做的 ➌!任何之前以 community1 为代表的人,现在将以 community2 为代表。
在 size 方面,community2 拥有它之前所有的人,以及它从 community1 继承来的所有人。因此,大小是之前的大小加上 community1 的大小 ➍。
就这些!请随意将这个解决方案提交给评测系统。它应该在时间限制内完成并通过所有测试用例。
不过,我也许原本希望它没有在时间限制内通过——因为我手上有两个高级的并查集优化,我真的很想教给你们。
嘿,咱们就做这些吧!虽然对于这个问题可能有点过头,但它们能提供如此大的速度提升,所以我们会在本章中贯穿使用这些优化,再也不必担心时间限制了。
优化 1:按大小合并
我们的并查集解决方案通常运行得很快,但也可以设计出让它运行缓慢的测试用例。以下是最糟糕的测试用例:
7 7
7
A 1 2
A 2 3
A 3 4
A 4 5
A 5 6
A 6 7
E 1 2
社区 1 和社区 2 合并后,得到的社区与社区 3 合并,得到的社区与社区 4 合并,依此类推。经过六次并操作后,我们得到如 图 9-3 所示的树形结构。

图 9-3:基于树的并查集数据结构的一个糟糕案例
我们有一条很长的节点链,遗憾的是,Find 和 Union 操作可能最终遍历整个链条。例如,E 1 2会对人物 1 和人物 2 执行 Find 操作,每次都几乎访问所有节点。当然,七个节点的链条很小,但我们可以复制合并模式,生成任意长度的巨大链条。这样,我们可以迫使 Find 和 Union 操作变为线性时间;如果总共有 q 次操作,我们可以迫使基于树的并查集算法花费 O(q²) 的时间。这意味着,在最坏情况下,基于树的解决方案在理论上不比 BFS 更好。它在实践中优于 BFS,因为大多数测试用例不会生成长链条的节点……但某些测试用例可能会!
等一下!为什么我们要让这些专横的测试用例逼迫我们生成这些糟糕的树结构?我们不关心并查集数据结构的外观。特别是,每当执行 Union 操作时,我们可以选择哪个旧的代表节点成为合并后的社区的代表。与其总是把第一个社区并入第二个社区,我们应该做出选择,生成最佳的树形结构。将图 9-3 的乱象与图 9-4 的美妙效果做比较。

图 9-4:一种优化过的基于树的并查集数据结构
人物 2 是根节点,其他所有人都只与它相差一条边。不管接下来执行的是 Union 还是 Find,我们都能非常高效地完成操作。
如何让我们的代码生成图 9-4 而不是图 9-3 呢?这种优化叫做按大小合并。每当你准备合并两个社区时,应该将人数较少的社区合并到人数较多的社区中。
在我们讨论的测试用例中,我们从A 1 2开始。两个社区各有一个人,所以选择保留哪一个并不重要;我们选择保留社区 2。现在社区 2 有了两个人:它原本的成员和来自社区 1 的成员。接下来执行A 2 3时,我们比较社区 2(大小为 2)与社区 3(大小为 1)。我们会保留社区 2,因为它比社区 3 大。现在社区 2 有了三个人。那么A 3 4呢?这给社区 2 带来了一个新成员。我们继续进行操作,将一个又一个人并入社区 2。
按大小合并确实可以中和最糟糕的测试用例,但仍然有一些测试用例的树结构需要进一步优化,从节点到根的路径还很长。比如:
9 9
9
A 1 2
A 3 4
A 5 6
A 7 8
A 8 9
A 2 4
A 6 8
A 4 8
E 1 5
按大小合并产生了图 9-5。

图 9-5:按大小合并的一个糟糕案例
虽然有些节点确实位于根节点下方,但现在还有一些节点离根更远(最严重的就是节点 1)。不过,树形结构相当平衡,显然比我们在按大小合并优化之前看到的长链结构要好。
接下来,我将展示使用按大小合并时树的最大高度是O(log n),其中n是总人数。这意味着 Find 或 Union 操作需要O(log n)的时间,因为 Find 只是沿树向上遍历,而 Union 只是执行两个 Find 操作并更改父节点。
假设我们选择一个任意节点* x ,并思考 x 与其根节点之间的边数可以增加多少次。当 x 的社区吸收另一个社区时, x 与其根节点之间的边数不变,因为它所在社区的根节点保持不变。然而,当 x 的社区被另一个社区吸收时, x 和其新根节点之间的边数比之前多一个:从 x *到新根节点的路径,就是到旧根节点的路径加上一个额外的边,连接到新根节点。
因此,将* x 和它的根节点之间的边数设置上限,实际上就是确定 x *所属的社区被吸收到另一个社区的最大次数。
假设* x 所在的社区大小为四。它是否可以被一个大小为二的社区吸收?绝对不行!记住,我们正在使用按大小合并。 x 的社区只能被另一个至少和它一样大的社区吸收。在这个例子中,另一个社区的大小必须至少为四。所以,我们从一个大小为四的社区出发,最终进入一个至少大小为 4 + 4 = 4 × 2 = 8 的社区。也就是说,当 x *的社区被吸收到另一个社区时,它的大小至少翻倍。
从一个大小为一的社区开始,* x 的社区被吸收,现在它所在的社区至少有两个成员。它再次被吸收,现所在的社区至少有四个成员。再一次被吸收后,它所在的社区至少有八个成员。这种增长方式不能无限进行。它必须在 x 的社区包含所有 n 个人时停止。从 1 开始,我们最多能将其翻倍多少次,直到达到n*?答案是 log n,这也是为什么任何节点与其根节点之间的边数被限制为 log n的原因。
使用按大小合并可以将线性时间减少为对数时间。更好的是,我们不需要编写太多新代码来实现此优化。实际上,对于社交网络问题,我们已经在维护社区的大小——我们只需要利用这些大小来决定哪个社区被另一个社区吸收。清单 9-7 给出了新的代码。与清单 9-6 对比,你会发现我们几乎做的和之前一样。
void union_communities(int person1, int person2, int parent[],
int size[], int num_community) {
int community1, community2, temp;
community1 = find(person1, parent);
community2 = find(person2, parent);
if (community1 != community2 &&
size[community1] + size[community2] <= num_community) {
❶ if (size[community1] > size[community2]) {
temp = community1;
community1 = community2;
community2 = temp;
}
➋ parent[community1] = community2;
size[community2] = size[community2] + size[community1];
}
}
清单 9-7: 使用按大小合并的union_communities函数
默认情况下,代码选择 community2 来吸收 community1。如果 community2 大于或等于 community1 的大小,这是正确的做法。如果 community1 的大小大于 community2 ❶,那么我们交换 community1 和 community2 的位置,反转它们的角色。之后,community2 一定是更大的社区,我们可以继续将 community1 吸收进 community2 ➋。
优化 2:路径压缩
让我们重新审视一下产生了图 9-5 的测试案例。这一次,让我们构建树并持续执行相同的 Examine 操作:
9 9
13
A 1 2
A 3 4
A 5 6
A 7 8
A 8 9
A 2 4
A 6 8
A 4 8
E 1 5
E 1 5
E 1 5
E 1 5
E 1 5
E 1 5 操作有点慢,每次都需要进行长时间的根节点遍历。例如,为了查找人员 1 的代表,我们从节点 1 到节点 2,再到节点 4,最后到节点 8。现在我们知道节点 1 的代表是节点 8。我们也会对人员 5 进行类似的遍历,但这种知识是短暂的,因为我们不会记住它。每一次 E 1 5 操作都需要我们重新执行查找人员 1 和人员 5 的工作,重新学习上次学到的内容。
在这里,我们又有机会通过控制树的结构来获益。记住,树的具体形状并不重要:重要的是同一社区的人位于同一棵树中。因此,一旦我们确定了某人社区的根节点,我们就可以将此人移到根节点下作为子节点。与此同时,我们也可以将该人的祖先直接移到根节点下。
再次考虑图 9-5,假设我们接下来执行 E 1 5。如果我们仅使用按大小合并优化,那么这个 Examine 操作(就像任何 Examine 操作一样)不会改变树的结构。然而,如果我们使用一种叫做 路径压缩 的优化,如图 9-6 所示,看看会发生什么。

图 9-6:路径压缩示例
这样很好,对吧?查找节点 1 会导致节点 1 和节点 2 成为根节点的子节点;查找节点 5 会导致节点 5 成为根节点的子节点。通常,路径压缩会将路径上的每个节点作为根节点的子节点。因此,查找这些节点的速度会非常快。
要在 find 函数中实现路径压缩,我们可以从提供的人员开始,进行两次遍历到达树的根节点。第一次遍历定位树的根节点;这是任何 find 函数都会执行的遍历。第二次遍历确保路径上的每个节点都将根节点作为其父节点。列表 9-8 实现了新的代码。与列表 9-5 比较,可以看到新增的是第二次遍历。
int find(int person, int parent[]) {
int community = person, temp;
❶ while (parent[community] != community)
community = parent[community];
➋ while (parent[person] != community) {
temp = parent[person];
parent[person] = community;
person = temp;
}
return community;
}
列表 9-8:带有路径压缩实现的 find 函数
这段代码分为两个阶段。第一阶段是第一个 while 循环 ❶,它使得 community 保存了社区的代表(根节点)。有了这个代表,第二阶段由第二个 while 循环 ➋ 捕捉,它会从 person 出发,追溯到树的根节点下方的路径,并更新每个节点的 parent 为树的根节点。temp 变量用来存储当前节点的旧父节点。通过这种方式,即使将当前节点设为树的根节点,我们仍然可以继续访问它的旧父节点。(在实际中,你可能会看到一种惊人简洁但隐晦的路径压缩编码方法。做好心理准备,然后查看 附录 B 中的“压缩路径压缩”部分。)
通过同时使用按大小合并和路径压缩,仍然有可能单次并查集的 Union 或 Find 操作需要 O(log n) 时间。然而,考虑到所有 Union 和 Find 操作的平均时间——尽管从技术上讲并不是常数——实际上是常数。运行时分析基于一个叫做 反阿克曼函数 的函数,它增长得非常非常,非常 慢。我不会定义反阿克曼函数或展示它如何在运行时分析中出现,但我想让你了解这个结果有多强大。
对数函数增长很慢,所以我们从这开始。对一个巨大的数字取对数会得到一个非常小的数字。例如,log 1,000,000,000 只有大约 30。然而,对数并不是常数:通过使用足够大的 n 值,你可以使 log n 变得和你想的一样大。
反阿克曼函数也不是常数,但与对数函数不同,你在实际应用中永远不会从中得到 30 这个值。你可以将 n 变得和你想的一样大,甚至大到计算机中能表示的最大数字,而反阿克曼函数的值最多也只有 4。你可以将带有按大小合并和路径压缩的并查集看作是每次操作平均只需要四步!
并查集
并查集数据结构加速了图论问题的解决,尤其是那些主要操作是 Union 和 Find 的问题。这对于诸如 第五章 和 第六章 中的某些问题没有帮助,因为这些问题需要计算节点之间的距离。但是,当并查集适用时,邻接表和图搜索就显得过于复杂且速度太慢。
关系:三个要求
并查集操作的是一个对象集合,其中每个对象最开始属于自己的集合。在任何时候,属于同一集合的对象是等价的,无论“等价”在我们正在解决的问题中意味着什么。例如,在社交网络问题中,同一集合(社区)中的人是等价的,因为他们都是朋友。
并查集要求对象之间的关系满足三个标准。首先,对象必须与自己有关系。在社交网络中的友谊关系,这意味着每个人都是自己的朋友。符合这一标准的关系称为自反关系。
其次,关系必须是无方向的:我们不能同时有x是y的朋友,并且y不是x的朋友。符合这一标准的关系称为对称关系。
第三,关系必须是传递的:如果x是y的朋友,而y是z的朋友,那么x也应该是z的朋友。符合这一标准的关系称为传递关系。
如果这些标准中的任何一个不满足,那么我们所讨论的并查操作就会失效。例如,假设我们有一个不满足传递性的友谊关系。如果我们知道x是y的朋友,我们无法确定x的朋友是否是y的朋友。因此,我们不能将x的社交圈和y的社交圈合并;这可能会把不是朋友的人错误地放在同一个集合中。
一个具有自反性、对称性和传递性的关系称为等价关系。
选择并查集
在判断是否可以应用并查集时,你需要问自己这个问题:我需要在对象之间保持什么样的关系?它是自反的、对称的、传递的吗?如果是,并且主要操作可以映射为查找和合并,那么你应该考虑将并查集作为一个可行的解决方案策略。
每个并查集问题背后都有一个图论问题,可以通过邻接表和图搜索进行建模(虽然效率较低!)。与我们在社交网络问题中所做的不同,在本章剩下的题目中,我们不会通过图论的“风景路线”来解决问题。
优化
我介绍了两种并查集优化:按大小合并和路径压缩。它们能有效防止糟糕的测试用例,并且通常无论测试用例如何,都能提升性能。它们每个只需要几行代码,因此我推荐在可能的情况下使用它们。
“只要可能”并不等同于“总是”。不幸的是,有些并查集问题并不适合这些优化。我还没有遇到路径压缩会带来问题的情形,但有时我们需要记住集合合并的顺序,在这种情况下,我们不能通过按大小合并树的根节点。你将在问题 3 中看到一个不能使用按大小合并的例子。
问题 2:朋友与敌人
你可能会担心我们支持的“添加”操作仅限于类似社交网络问题中的操作:x 和 y 是朋友;x 和 y 上同一所学校;x 和 y 住在同一座城市——之类的情况。事实上,我们也能支持其他类型的添加信息。x 和 y 不是朋友。嗯……这个有点意思,它告诉我们 x 和 y 不在同一集合中,而不是他们是朋友。那么并查集现在如何工作呢?继续往下看!
这是 UVa 问题 10158。
问题
两个国家正在交战。你已获准参加他们的和平会议,在会议期间,你可以听到一对对的人互相交谈。会议上有 n 个人,编号为 0, 1, . . . , n – 1。最开始,你对谁是朋友(同一国家的公民)或敌人(敌国的公民)一无所知。你的任务是记录有关谁是朋友或敌人的信息,并根据你目前掌握的情况回答查询。
你必须支持四个操作:
SetFriends 记录提供的两个人是朋友。
SetEnemies 记录提供的两个人是敌人。
AreFriends 报告你是否确定知道提供的两个人是朋友。
AreEnemies 报告你是否确定知道提供的两个人是敌人。
友谊是一个等价关系:它是自反的(x 是 x 的朋友),对称的(如果 x 是 y 的朋友,那么 y 是 x 的朋友),且是传递的(如果 x 是 y 的朋友,且 y 是 z 的朋友,那么 x 也是 z 的朋友)。
敌对关系不是等价关系。它是对称的:如果 x 是 y 的敌人,那么 y 也是 x 的敌人。然而,它既不是自反的,也不是传递的。
关于友谊和敌对关系,我们需要了解更多的内容。假设 x 有一些朋友和敌人,y 也有一些朋友和敌人,然后我们得知 x 和 y 是敌人。那么我们学到了什么?我们直接知道 x 和 y 是敌人——但这还不是全部。我们还可以得出结论:x 的敌人和 y 的所有朋友都是朋友。(假设 Alice 和 Bob 是敌人,David 和 Eve 是朋友——然后我们得知 Alice 和 David 是敌人。我们应该得出结论,Bob 和 David 以及 Eve 是朋友。)类似地,我们也可以得出结论,y 的敌人和 x 的所有朋友都是朋友。用一句话来总结:敌人的敌人是朋友。
现在假设 x 有一些朋友和敌人,y 也有一些朋友和敌人——但这次我们得知 x 和 y 是朋友。在这种情况下,我们还应该得出结论,x 的敌人和 y 的敌人是朋友。(坚持一下,随着一些例子的展开,我们会将这一切具体化。)
输入
输入包含一个测试用例,由以下几行组成:
-
一行包含 n,即参加会议的总人数。n 小于 10,000。
-
零行或多行,每行对应一个操作。
-
一行包含三个整数,第一个是 0。这表示测试用例的结束。
每行操作具有相同的格式:一个操作码,后跟两个人(x 和 y)。
-
SetFriends 操作的形式是
1x y。 -
SetEnemies 操作的形式是
2x y。 -
AreFriends 操作的形式是
3x y。 -
AreEnemies 操作的形式是
4x y。
输出
每个操作的输出位于其单独的一行。
-
如果 SetFriends 操作成功,则不会产生任何输出。如果与已知信息冲突,则输出
-1并忽略该操作。 -
如果 SetEnemies 操作成功,则不会产生任何输出。如果与已知信息冲突,则输出
-1并忽略该操作。 -
对于 AreFriends 操作,如果两个人是已知的朋友,则输出
1,否则输出0。 -
对于 AreEnemies 操作,如果两个人是已知的敌人,则输出
1,否则输出0。
解决测试用例的时间限制是三秒。
增强并查集
如果我们只需要处理 SetFriends 和 AreFriends 操作,那么我们可以直接应用并查集算法,就像在解决社交网络问题时那样。我们会为每一组朋友保留一个集合。像社交网络中的 Add 操作,SetFriends 将作为一个 Union 操作,将两组朋友合并为一个更大的集合。像社交网络中的 Examine 操作,AreFriends 将作为 Find 操作,用于确定两人是否在同一个集合中。
我们可以从只解决这两个操作的问题开始……其实,你知道吗?我相信你现在就能在没有我任何其他帮助的情况下解决这个有限的问题。我能提供帮助的地方可能是解释如何将 SetEnemies 和 AreEnemies 合并到问题中。
增强:敌人
增强数据结构是指在数据结构中存储额外信息,以支持新的或更快的操作。在并查集数据结构中维护每个集合的大小就是增强的一个例子:你可以在没有它的情况下实现数据结构,但有了它,你可以快速报告集合的大小并执行按大小合并操作。
当现有的数据结构几乎可以做你想做的事时,你应该考虑增强。关键是要识别一种合适的增强方式,能够添加所需的功能,同时不显著减慢其他操作的速度。
我们已经有了一个支持 SetFriends 和 AreFriends 的并查集数据结构。它维护每个节点的父节点以及每个集合的大小。我们将增强这个数据结构,以支持 SetEnemies 和 AreEnemies。更重要的是,我们将做到这一点,而不会显著减慢 SetFriends 和 AreFriends 操作的速度。
假设我们被告知 x 和 y 是敌人。从问题描述中,我们知道我们必须将 x 的敌人集合与 y 的集合合并,并将 y 的敌人集合与 x 的集合合并。那么,x 的敌人是谁?y 的敌人又是谁?使用标准的并查集数据结构,我们并不知道。这就是为什么我们需要扩展并查集数据结构的原因。
除了每个节点的父节点和每个集合的大小外,我们还将跟踪每个集合的敌人。我们将这些敌人存储在一个名为 enemy_of 的数组中。假设 s 是某个集合的代表。如果该集合没有敌人,则我们将确保 enemy_of[s] 存储一个无法与人混淆的特殊值。如果该集合有一个或多个敌人,则 enemy_of[s] 将告诉我们其中一个敌人。
对的:是其中的一个,而不是所有。只知道每个集合中的一个敌人就足够了,因为我们可以利用这个敌人来找到该敌人集合中每个人的代表。
现在让我们解决两个测试用例。它们将为接下来的实现做准备。我展示的图示是概念性的,并不完全对应于实现的具体做法。特别地,我在图示中不会使用按大小合并或路径压缩,但我们将在实现中加入这些优化以提高性能。
测试用例 1
回想一下,SetFriends 操作的代码是 1,SetEnemies 操作的代码是 2。
这是我们的第一个测试用例:
9
1 0 1
1 1 2
1 3 4
1 5 6
❶ 2 1 7
➋ 2 5 8
➌ 1 2 5
0 0 0
前四个操作是 SetFriends 操作。由于没有人有敌人,这些操作就像社交网络问题中的 Add 操作一样执行。图 9-7 显示了这些操作后的数据结构状态。

图 9-7:四次 SetFriends 操作后的数据结构
接下来是我们第一次的 SetEnemies 操作 ❶,它表示人物 1 和 7 是敌人。这意味着 1 的集合中的每个人都是 7 的集合中每个人的敌人。为了将此操作纳入数据结构,我们在这两个集合的根之间添加了连接:从 2(1 的集合的根)到 7 的连接,以及从 7(7 的集合的根)到 1 的连接。(你也可以决定后者应该是从 7 到 2 的连接;这也是可以的。)此操作的结果见 图 9-8。在本图及随后的图中,敌人连接以虚线表示;在我们的实现中,敌人连接将通过上述的 enemy_of 数组来实现。

图 9-8:SetEnemies 操作后的数据结构
接下来是人物 5 和 8 之间的 SetEnemies 操作 ➋;执行此操作可能会得到 图 9-9。

图 9-9:另一轮 SetEnemies 操作后的数据结构
现在是进行最终操作 ➌ 的时候了,这个操作表明人物 2 和人物 5 是朋友。这样,人物 2 的集合和人物 5 的集合就合并成了一个更大的朋友集合,正如预期的那样。或许令人惊讶的是,我们还合并了两个敌人集合。具体来说,我们将人物 2 的敌人集合与人物 5 的敌人集合合并。毕竟,如果我们知道两个人在同一个国家,那么他们各自的敌人集合必定在另一个国家合并。执行这两个 Union 操作后的结果如图 9-10 所示。

图 9-10:最终执行 SetFriends 操作后的数据结构
我没有从人物 2 到人物 7 和从人物 7 到人物 1 画出敌人链接,是因为我们仅在根节点之间维护敌人链接。一旦一个节点不再是根节点,我们就再也不会用它来寻找敌人。
从这个测试用例中可以学到两个关键点:一个集合的敌人存储在该集合的根节点上,且一个 SetFriends 操作需要执行两个 Union 操作,而不是一个。那么,当一个集合已经有敌人,并且该集合参与 SetEnemies 操作时,我们该怎么做呢?这就是我们下一个测试用例的内容。
测试用例 2
我们的第二个测试用例与第一个的不同之处仅在于其最终操作:
9
1 0 1
1 1 2
1 3 4
1 5 6
2 1 7
2 5 8
❶ 2 2 5
0 0 0
在最终操作之前,数据结构如图 9-9 所示。最终操作 ❶ 现在是 SetEnemies 操作,而不是 SetFriends 操作。人物 2 的集合已经有一个敌人,现在它从人物 5 的集合中获得了新的敌人。因此,我们需要将人物 2 的敌人集合与人物 5 的集合合并。同样,人物 5 的集合已经有敌人,现在又从人物 2 的集合中获得了新的敌人,所以我们需要将人物 5 的敌人集合与人物 2 的集合合并。
这两个 Union 操作的结果如图 9-11 所示。

图 9-11:最终执行 SetEnemies 操作后的数据结构
在了解了这个背景后,我们准备好进行实现了!
主要功能
让我们从 main 函数开始,它在列表 9-9 中给出。它读取输入,并为我们支持的四个操作调用一个辅助函数。
#define MAX_PEOPLE 9999
int main(void) {
static int parent[MAX_PEOPLE], size[MAX_PEOPLE];
static int enemy_of[MAX_PEOPLE];
int num_people, i;
int op, person1, person2;
scanf("%d", &num_people);
for (i = 0; i < num_people; i++) {
parent[i] = i;
size[i] = 1;
❶ enemy_of[i] = -1;
}
scanf("%d%d%d", &op, &person1, &person2);
while (op != 0) {
➋ if (op == 1)
if (are_enemies(person1, person2, parent, enemy_of))
printf("-1\n");
else
set_friends(person1, person2, parent, size, enemy_of);
➌ else if (op == 2)
if (are_friends(person1, person2, parent))
printf("-1\n");
else
set_enemies(person1, person2, parent, size, enemy_of);
➍ else if (op == 3)
if (are_friends(person1, person2, parent))
printf("1\n");
else
printf("0\n");
➎ else if (op == 4)
if (are_enemies(person1, person2, parent, enemy_of))
printf("1\n");
else
printf("0\n");
scanf("%d%d%d", &op, &person1, &person2);
}
return 0;
}
列表 9-9:处理操作的 主要 功能
请注意,作为初始化的一部分,我们将每个 enemy_of 值设置为 -1 ❶。这是我们用来表示“没有敌人”的特殊值。
为了实现 SetFriends ➋,我们首先检查这两个人是否已经是敌人。如果是,我们输出 -1;如果不是,我们调用 set_friends 辅助函数。SetEnemies ➌ 的实现遵循相同的模式。对于 AreFriends ➍ 和 AreEnemies ➎,我们调用一个辅助函数来确定条件是否为真,并相应地输出 1 或 0。
查找与合并
我将在这里介绍 Find 和 Union 函数;它们将由我们的辅助函数 set_friends、set_enemies、are_friends 和 are_enemies 调用。Find 函数在列表 9-10 中给出。我们已经在其中实现了路径压缩!
int find(int person, int parent[]) {
int set = person, temp;
while (parent[set] != set)
set = parent[set];
while (parent[person] != set) {
temp = parent[person];
parent[person] = set;
person = temp;
}
return set;
}
列表 9-10: find 函数
Union 函数在 列表 9-11 中给出。按大小联合:你最好相信它!
int union_sets(int person1, int person2, int parent[],
int size[]) {
int set1, set2, temp;
set1 = find(person1, parent);
set2 = find(person2, parent);
if (set1 != set2) {
if (size[set1] > size[set2]) {
temp = set1;
set1 = set2;
set2 = temp;
}
parent[set1] = set2;
size[set2] = size[set2] + size[set1];
}
❶ return set2;
}
列表 9-11: union_sets 函数
Union 函数有一个在我们之前的 Union 代码中没有的特性:它返回结果集的代表元素 ❶。接下来我们将讨论 SetFriends 操作,您会看到我们在其中使用了这个返回值。
SetFriends 和 SetEnemies
SetFriends 操作在 列表 9-12 中实现。
void set_friends(int person1, int person2, int parent[],
int size[], int enemy_of[]) {
int set1, set2, bigger_set, other_set;
❶ set1 = find(person1, parent);
➋ set2 = find(person2, parent);
➌ bigger_set = union_sets(person1, person2, parent, size);
➍ if (enemy_of[set1] != -1 && enemy_of[set2] != -1)
➎ union_sets(enemy_of[set1], enemy_of[set2], parent, size);
➏ if (bigger_set == set1)
other_set = set2;
else
other_set = set1;
❼ if (enemy_of[bigger_set] == -1)
enemy_of[bigger_set] = enemy_of[other_set];
}
列表 9-12:记录两个人是朋友
我们首先确定每个人的代表元素:set1 是 person1 的代表 ❶,set2 是 person2 的代表 ➋。由于这两组人现在应该彼此成为朋友,因此我们将它们合并成一个更大的集合 ➌。我们将 union_sets 的返回值存储在 bigger_set 中;我们很快会用到它。
我们已经将 person1 的集合和 person2 的集合联合起来,但这还没有完成,因为——记得我们第一次测试时说的——我们可能还需要将一些敌人联合在一起。具体来说,如果 set1 有敌人,而 set2 也有敌人,那么我们需要将这些敌人联合成一个更大的集合。这正是代码的功能:如果两个集合都有敌人 ➍,我们将这些敌人集合 ➎ 联合起来。
现在很容易认为我们已经完成了。我们已经执行了所需的朋友集合联合和敌人集合联合——还有什么可做的呢?好吧,假设 set1 有一些敌人,而 set2 没有。那么,set2 的代表元素的 enemy_of 值就是 -1。现在,也许 set1 最终会并入 set2,使得 set2 成为更大的集合。如果我们就此停手不再做任何事情,那么 set2 将无法找到它的敌人!因为 set2 的代表元素的 enemy_of 值仍然是 -1——这显然不对,因为 set2 现在确实有敌人了。
这是我们在代码中处理这个问题的方式。我们已经有了 bigger_set,它表示是哪个集合——set1 还是 set2——通过联合 set1 和 set2 得到的。我们使用 if–else 来将 other_set 设置为另一个集合 ➏:如果 bigger_set 是 set1,那么 other_set 就是 set2,反之亦然。然后,如果 bigger_set 没有敌人 ❼,我们将从 other_set 复制敌人链接。这样,bigger_set 就能确保能找到它的敌人,如果 set1 或 set2 或两者都有敌人。
现在是时候进行 SetEnemies 操作了。请查看 列表 9-13。
void set_enemies(int person1, int person2, int parent[],
int size[], int enemy_of[]) {
int set1, set2, enemy;
set1 = find(person1, parent);
set2 = find(person2, parent);
❶ enemy = enemy_of[set1];
if (enemy == -1)
➋ enemy_of[set1] = person2;
else
➌ union_sets(enemy, person2, parent, size);
➍ enemy = enemy_of[set2];
if (enemy == -1)
enemy_of[set2] = person1;
else
union_sets(enemy, person1, parent, size);
}
列表 9-13:记录两个人是敌人
我们再次通过查找每个集合的代表,分别存储在set1和set2中。然后我们查找set1的敌人 ❶。如果set1没有敌人,则将person2设为其敌人 ➋。如果set1有敌人,那么我们进入第二个测试用例的范围。我们将set1的敌人集合与person2的集合合并 ➌,这样确保person2以及person2的所有朋友都成为person1的敌人。
这样就处理了set1。现在我们对set2做同样的操作 ➍,如果它还没有敌人,则将其敌人设为person1,否则将其敌人集合与person1的集合合并。
重要的是,这个函数保持了敌人关系的对称性:如果从person1可以找到敌人person2,那么从person2也能找到敌人person1。考虑person1和person2的set_enemies调用。如果person1没有敌人,那么它的敌人就是person2,但如果person1有敌人,那么它的敌人集合将增加person2。对称地,如果person2没有敌人,那么它的敌人就是person1,如果person2有敌人,那么它的敌人集合也将增加person1。
AreFriends 和 AreEnemies
AreFriends 操作实际上就是检查两个人是否在同一个集合中,或者等价地,是否有相同的代表。这可以通过两次调用 Find 来完成,如清单 9-14 所示。
int are_friends(int person1, int person2, int parent[]) {
return find(person1, parent) == find(person2, parent);
}
清单 9-14:判断两个人是否是朋友
我们只剩下最后一个操作了!我们可以通过检查一个人是否在另一个人的敌人集合中来实现 AreEnemies。代码见清单 9-15。
int are_enemies(int person1, int person2, int parent[],
int enemy_of[]) {
int set1, enemy;
set1 = find(person1, parent);
enemy = enemy_of[set1];
❶ return (enemy != -1) &&
(find(enemy, parent) == find(person2, parent));
}
清单 9-15:判断两个人是否是敌人
要让person2成为person1的敌人,必须满足两个条件 ❶。首先,person1必须有敌人。其次,person2必须在其敌人集合中。
嘿!我们是不是也应该检查一下person1是否是person2的敌人?不,没必要,因为敌人关系是对称的。如果person2不是person1的敌人,那么就没有必要检查person1是否是person2的敌人。
就是这样!我们已经成功地扩展了普通的并查集数据结构,以包含朋友和敌人信息。如果你将代码提交给评测系统,应该能够通过所有测试用例。那么如果超时怎么办?通过大小合并和路径压缩,时间限制也无法阻止我们。
问题 3:抽屉事务
在社交网络和朋友与敌人问题中,我们能够使用大小合并和路径压缩来加速实现。在下一个问题中,我们将赋予每个集合的根更多的意义。我们将无法使用大小合并,因为根的选择很重要。在你阅读问题描述时,想一想为什么会这样!
这是 DMOJ 问题coci13c5p6。
问题
米尔科有 n 个物品散落在他的房间里,和 d 个空抽屉。这些物品编号为 1, 2, ……,n;抽屉编号为 1, 2, ……,d。每个抽屉最多能容纳一个物品。米尔科的目标是逐一考虑每个物品,如果可能将其放入一个抽屉,否则将其扔掉。
每个物品有恰好两个允许放置的抽屉:抽屉 A 和抽屉 B。(这是为了组织目的。毕竟,我们可不想把万圣节糖果和蚂蚁放在一起。)例如,我们可能允许将物品 3 放在抽屉 7(A)或抽屉 5(B)中。
为了确定每个物品的处理方式,我们依次使用以下五条规则:
-
如果抽屉 A 是空的,将物品放入抽屉 A 并停止。
-
如果抽屉 B 是空的,将物品放入抽屉 B 并停止。
-
如果抽屉 A 已满,将抽屉 A 中的现有物品移到它的另一个抽屉;如果那个抽屉也已满,将它的现有物品移到它的另一个抽屉;以此类推。如果这个过程会终止,将物品放入抽屉 A 并停止。
-
如果抽屉 B 已满,将抽屉 B 中的现有物品移到它的另一个抽屉;如果那个抽屉也已满,将它的现有物品移到它的另一个抽屉;以此类推。如果这个过程会终止,将物品放入抽屉 B 并停止。
-
如果我们无法通过前四条规则放置物品,我们就将物品扔掉。
由于规则 3 和规则 4,放置一个物品可能会导致其他物品移动到它们的其他抽屉。
我们需要输出每个物品是被保留还是被扔掉。
输入
输入包含一个测试用例,包含以下行:
-
一行包含 n,表示物品数量,以及 d,表示抽屉数量。n 和 d 的值在 1 到 300,000 之间。
-
n 行,每行包含两个整数 a 和 b,表示该物品的抽屉 A 是 a,抽屉 B 是 b。a 不会与 b 相同。
输出
每个物品的输出单独占一行。对于每个物品,若物品被放入抽屉中,输出 LADICA;若物品被扔掉,输出 SMECE。(这两个词来自原始的 COCI 问题描述:ladica 是克罗地亚语中“抽屉”的意思,smece 是克罗地亚语中“垃圾”的意思。)
解决此测试用例的时间限制为 1 秒。
等效抽屉
这里有一个有趣的场景:我们把一个新物品放入抽屉 1——但是,哎呀,抽屉 1 已经满了。它的现有物品的另一个抽屉是抽屉 2。于是我们将现有物品移到抽屉 2,哎呀,又满了。它的现有物品的另一个抽屉是抽屉 6。唉——抽屉 6 也满了!我们将它的现有物品移到它的另一个抽屉,抽屉 4。呼!抽屉 4 是空的,所以我们停止。
在最终填满抽屉 4 的过程中,我们移动了三个现有物品:从抽屉 1 移到抽屉 2,从抽屉 2 移到抽屉 6,最后从抽屉 6 移到抽屉 4。然而,这些特定的移动对我们来说并不重要。我们只需要知道抽屉 4 最终是满的。
在添加新项目之前,抽屉 1、2、6 和 4 有一个共同点:如果你试图把一个物品放入其中任何一个抽屉,最终抽屉 4 会被填满。这就是这四个抽屉等价的含义。例如,如果你直接把物品放入抽屉 4,抽屉 4 会立刻被填满。如果你把物品放入抽屉 6,抽屉 6 中原有的物品会移动到抽屉 4,再次导致抽屉 4 被填满。如果你把物品放入抽屉 2,或者如我们在这个示例开始时看到的,如果你把物品放入抽屉 1,抽屉 4 会被填满。抽屉 4 是一个空抽屉,链条在此终止,考虑到我们的并查集数据结构,我们可以看到它将作为其集合的代表。我们的集合代表总是空抽屉;集合中的其他每个抽屉都会被填满。
为了使这一切更具实际意义,我们通过两个测试用例来演示。在第一个测试用例中,我们将看到每个物品都能被放入抽屉。第二个测试用例中,我们会看到一些 SMECE:有些物品我们无法放入抽屉。
测试用例 1
这是我们的第一个测试用例:
6 7
1 2
2 6
6 4
5 3
5 7
2 5
我们有七个抽屉,每个抽屉开始时都是空的,并且属于自己的集合。我将每个集合列在自己的行上,并用斜体标出每个集合的代表:
1
2
3
4
5
6
7
在继续之前,最好回顾一下问题描述中的规则。
第一个物品是 1 2;这是抽屉 A 的 1 和抽屉 B 的 2。由于抽屉 1 是空的,这个物品会被放入抽屉 1(使用规则 1)。此外,抽屉 1 和 2 会被合并到同一个集合中:将新物品放入抽屉 1 或抽屉 2 会导致同一个抽屉——抽屉 2 被填满。这里是我们集合的下一个快照:
1 2
3
4
5
6
7
注意,新集合的代表是抽屉 2。使用抽屉 1 作为代表是不正确的:这会错误地表明抽屉 1 是空的!这就是为什么我们不会使用按大小合并的方法:它可能会选择错误的根节点作为结果集合的代表。
现在考虑第二个项目:2 6。抽屉 2 是空的,所以我们将物品放入其中(再次使用规则 1)。现在,将物品放入抽屉 1、2 或 6 会导致抽屉 6 被填满,因此我们将抽屉 1 和 2 与抽屉 6 合并:
1 2 6
3
4
5
7
抽屉 6 是空的,所以将物品放入抽屉 6 会立刻填满它。将物品放入抽屉 2 会导致该抽屉的现有物品移动到抽屉 6,再次填满抽屉 6。将物品放入抽屉 1 会导致其现有物品移动到抽屉 2,抽屉 2 中现有的物品会移动到抽屉 6……,因此抽屉 6 再次被填满。这就是为什么我们可以将这三个抽屉放在同一个集合中,并将抽屉 6 作为其代表。
下一个物品是 6 4。我们知道该怎么做(再次使用规则 1):
1 2 6 4
3
5
7
下一个物品是 5 3。同样,这没有问题(使用规则 1):
1 2 6 4
5 3
7
到目前为止,我们处理的每个物品都通过使用规则 1 成功了。当然,情况不必总是这样,正如下一个物品 5 7 所示:规则 1 不适用,因为抽屉 5 已经满了。然而规则 2 适用,因为抽屉 7 是空的。因此,这个物品被放入抽屉 7。合并后的集合的空抽屉是抽屉 3,因此它就是我们的代表,如下一个快照所示:
1 2 6 4
5 7 3
我们还有一个物品要处理,而且它挺有趣的:2 5。规则 1 适用吗?不适用,因为抽屉 2 已满。规则 2 适用吗?不适用,因为抽屉 5 已满。规则 3 适用吗?适用!它适用是因为抽屉 2 的集合中有一个空抽屉(抽屉 4)。我们该怎么做?
本例中的论点是,抽屉 2 的集合和抽屉 5 的集合应该合并,像这样:
1 2 6 4 5 7 3
我来解释一下为什么这样可行。物品 2 5 最终被放入抽屉 2:原有的物品从抽屉 2 移到抽屉 6,再从抽屉 6 移到抽屉 4。抽屉 4 现在被填满,因此它不能再作为其集合的代表。事实上,唯一相关的空抽屉是抽屉 3,所以我们实际上希望抽屉 3 能作为集合的代表。抽屉 5、7 和 3 肯定属于同一个集合:将物品放入其中任何一个最终都会填满抽屉 3,因为在我们引入 2 5 物品之前,它们就已经在同一个集合中。
现在需要解释为什么抽屉 1、2、6 和 4 也应该在抽屉 3 的集合中。抽屉 2 没问题:将物品放入抽屉 2 会把其原有物品移到抽屉 5。抽屉 5 在抽屉 3 的集合中,所以我们知道接下来会发生什么:抽屉 3 最终会被填满。
抽屉 1 也没问题:将一个物品放入抽屉 1 会把其原有的物品移到抽屉 2,从这里我们可以使用前一段中的论证来说明抽屉 3 会被填满。类似的逻辑适用于抽屉 6 和 4。例如,如果我们将物品放入抽屉 4,然后“撤销”填充抽屉 2 时发生的移动,抽屉 4 的原有物品会移回抽屉 6,抽屉 6 的原有物品会移回抽屉 2,现在我们回到了前一段中的情况。
每个物品都被放入了一个抽屉,因此正确的输出如下:
LADICA
LADICA
LADICA
LADICA
LADICA
LADICA
让我们从这个测试案例中提取一个一般性原则。假设我们正在处理物品 x y,并且该物品最终位于 x 的集合中。然后我们将 x 的集合和 y 的集合合并,保持 y 的代表作为合并集合的代表。
为什么这是正确的?想一想当我们试图把一个项目放入并集的集合中时,这个集合的组成部分是x的旧集合和y的旧集合。将它放入y的集合中的某个抽屉依然会填充y的代表,因为我们根本没有改变y的集合。将它放入x的抽屉也会填充y的代表,因为我们把x的现有项目移到y,然后我们就回到了将项目放入y集合的抽屉的情况。唯一剩下的选择是将新项目放入x集合中的抽屉z(与x不同)。从抽屉z到抽屉x有一条抽屉链;沿着这条链移动物品会填充抽屉x,然后y的代表也会被填充。
如果我们正在处理项目x y,而该项目最终进入了y的集合呢?在这种情况下,两个集合的角色会反转。特别地,我们将保持x的集合的代表作为并集集合的代表。
测试用例 2
现在让我们看看如何产生SMECE。这是我们的第二个测试用例:
7 7
1 2
2 6
6 4
❶ 1 4
2 4
1 7
7 6
前三个项目是LADICA,并且结果呈现出一个熟悉的状态:
1 2 6 4
3
5
7
现在,有个不同的情况:项目1 4 ❶。第一次,我们看到一个抽屉 A 和抽屉 B 在同一个集合中。它因此不会为这个集合提供新的空抽屉。也就是说,使用规则 2 填充抽屉 4(所以它是一个LADICA),但是它不会给我们任何可以并集的集合。抽屉 1、2、6 和 4 进入了一种新的状态,在这种状态下,任何物品都无法成功放入它们!如果你尝试,你将永远在其中循环。例如,尝试将物品放入抽屉 1。我们可以把抽屉 1 的现有物品推到抽屉 2,然后把抽屉 2 的现有物品推到抽屉 6,然后把抽屉 6 的现有物品推到抽屉 4,抽屉 4 的现有物品被推到抽屉 1,抽屉 1 的现有物品推到抽屉 2,抽屉 2 的现有物品推到抽屉 6,依此类推,直到我撞到我的书的页面限制。
在我们的实现中,我们将通过为该集合指定代表 0 来标记这一状态:
1 2 6 4 0
3
5
7
现在我们已经非常接近一个SMECE了。如果有任何项目,其中的两个抽屉都在这个集合中,那么就无法放置它。看看我们下一个项目:2 4。我们能把它放入抽屉 2 吗?不行;它已经满了。那抽屉 4 呢?也不行;它也满了。我们能沿着抽屉 2 的链找到一个空抽屉吗?不行。有没有从抽屉 4 到空抽屉的链?不行。四次失败。SMECE。
接下来,我们处理项目1 7。这个将通过使用规则 2 来处理。因此我们执行一个并集(因为它是一个LADICA)——但要注意:因为这是另一个并集,它会给我们一个没有空抽屉的集合!这是结果:
1 2 6 4 7 0
3
5
最后的项目是7 6,这又是一个SMECE,因为没有四条LADICA规则适用:抽屉 7 和 6 在同一个集合中,而且这个集合没有空抽屉。
这个测试用例的正确输出是:
LADICA
LADICA
LADICA
LADICA
SMECE
LADICA
SMECE
在我们的测试用例中,唯一没有探讨的规则是规则 4。我建议你在继续之前先玩一下规则 4。特别是,你可以验证每次应用规则 4 时,合并后集合的代表将是 0。
现在是实现阶段了!
主函数
让我们从main函数开始,它从输入中读取每个项目并进行处理。代码在清单 9-16 中给出。
#define MAX_DRAWERS 300000
int main(void) {
static int parent[MAX_DRAWERS + 1];
int num_items, num_drawers, i;
int drawer_a, drawer_b;
scanf("%d%d", &num_items, &num_drawers);
❶ parent[0] = 0;
for (i = 1; i <= num_drawers; i++)
parent[i] = i;
for (i = 1; i <= num_items; i++) {
scanf("%d%d", &drawer_a, &drawer_b);
➋ if (find(drawer_a, parent) == drawer_a)
➌ union_sets(drawer_a, drawer_b, parent);
➍ else if (find(drawer_b, parent) == drawer_b)
➎ union_sets(drawer_b, drawer_a, parent);
➏ else if (find(drawer_a, parent) > 0)
❼ union_sets(drawer_a, drawer_b, parent);
❽ else if (find(drawer_b, parent) > 0)
❾ union_sets(drawer_b, drawer_a, parent);
else
printf("SMECE\n");
}
return 0;
}
清单 9-16:处理项目的主函数
像往常一样,parent数组记录了并查集数据结构中每个节点的父节点。项目的编号从 1 开始,因此我们可以安全地使用 0 作为那些永远不能放入新项目的抽屉的代表。我们将 0 的代表设置为 0 ❶,以表示这个集合像其他所有集合一样,最初是空的。
现在,让我们来看看这五个规则。我们通过一次find调用和一次union调用来实现四个LADICA规则。如果这些规则都不适用,那么我们就在SMECE的情况下。我们逐一来看每个LADICA规则。
对于规则 1,我们需要知道drawer_a是否为空。记住,每个抽屉集合(不包括“0”集合)都有且只有一个空抽屉,而这个空抽屉是该集合的代表。find函数返回给定集合的代表。将这两个事实结合起来,我们可以得出结论,只有当drawer_a为空时,find才会返回drawer_a ➋。
如果我们处于规则 1 的情况,那么我们需要将drawer_a的集合与drawer_b的集合合并。因此,我们调用union_sets ➌。不过要小心:记住,我们必须使drawer_b的代表成为新集合的代表,因为drawer_a的集合已经没有空抽屉了,drawer_a已经满了。为了实现这一点,我们将使用一种不按照大小进行并查集合并的union_sets实现。它保证我们传递的第二个参数(在这里是drawer_b)将成为合并后集合的代表。它还负责输出LADICA消息。我们将在下一小节中看到这段代码。
对于规则 2,我们需要知道drawer_b是否为空。我们再次使用find来检查这个 ➍,如果适用这个规则,就执行并查集操作 ➎。这一次,我们以相反的顺序调用union_sets,使得drawer_a的代表成为合并后集合的代表。
对于规则 3,我们需要知道drawer_a的集合是否有空抽屉。一个集合只有在其代表元素为 0 时才没有空抽屉。我们使用find来检查这个条件 ➏:如果find返回的代表不是 0,那么这个集合就有空抽屉。如果适用这个规则,我们就执行预期的并查集操作 ❼。在下一小节中,我们将看到union_sets如何负责将集合适当地移到“0”集合。
最后,对于规则 4,我们需要知道 drawer_b 的集合是否有空的抽屉。逻辑与规则 3 相同:使用 find 检查该集合是否有空的抽屉 ❽;如果有,则执行 Union ❾。
Find 和 Union
Find 函数见 Listing 9-17。它使用了路径压缩。这是件好事,因为我刚提交了一个没有路径压缩的解法,并收到了“超时”错误。#PathCompressionWins。
int find(int drawer, int parent[]) {
int set = drawer, temp;
while (parent[set] != set)
set = parent[set];
while (parent[drawer] != set) {
temp = parent[drawer];
parent[drawer] = set;
drawer = temp;
}
return set;
}
Listing 9-17:find函数
Union 函数见 Listing 9-18。
void union_sets(int drawer1, int drawer2, int parent[]) {
int set1, set2;
set1 = find(drawer1, parent);
set2 = find(drawer2, parent);
❶ parent[set1] = set2;
➋ if (set1 == set2)
➌ parent[set2] = 0;
printf("LADICA\n");
}
Listing 9-18:union_sets函数
正如之前承诺的,这里没有按大小合并:我们总是使用 set2,即 drawer2 的集合,作为新的集合 ❶。
此外,每当放置一个物品且其抽屉属于同一集合 ➋ 时,我们将结果集合的代表设置为 0 ➌。以后每当对这个结果集合的任何元素调用 find 时,将返回 0,正确地表示该集合再也不能放置任何物品。
就这样:这是一个 50 行的并查集解决方案,解决了本书中最具挑战性的问题之一。请将你的代码提交给评测系统!
总结
在本章中,我们学习了如何高效地实现并查集数据结构。在本书所有数据结构中,并查集是令我最惊讶的一个,因为它的应用范围非常广泛。“真的吗?这是一个并查集问题?”我经常有这样的想法。当我们解决《朋友与敌人》或《Drawer Chore》问题时,也许你也有过这样的想法。无论如何,你很可能会遇到其他看似与这里的例子完全不同的问题,但并查集依然适用。
幸运的是,考虑到其广泛的适用性和快速的性能,我们不需要大量的代码来实现并查集:只需要为 Union 写几行代码,为 Find 写几行代码。此外,你可能会发现,一旦我们了解了树的数组表示法,代码并不难理解。即使是优化,比如按大小合并和路径压缩,也需要很少的代码。
注释
Drawer Chore 最初来源于 2013 年克罗地亚开放信息学竞赛的第五轮。我在 COCI 网站上找到了“0 代表”这一概念(见 hsin.hr/coci/archive/2013_2014)。
第十章:随机化

回想一下我们在第七章学习的二分查找。当时我们不是在回答“最优解是什么?”的问题,而是在问“这个特定的值是不是最优解?”在解决喂蚂蚁问题时,你可能会觉得我随意挑选值的做法很荒唐,想知道那到底怎么可能有效。但事实证明,这种做法非常有效,现在我们知道了。
你想要比二分查找更荒唐的东西吗?那就直接猜一个完全随机的解法吧。那怎么可能行得通呢?究竟是什么使得在特定问题下,随机猜测能成为一种可行的策略?即使我们已经有了解法,随机猜测还能帮我们解决问题吗?令人惊讶的结论在等着我们。
问题 1:羊羹
羊羹是一种日本糖果,味道甜美,质地类似果冻或橡皮糖。买一块大块的羊羹,切成小块,搭配一些水果,冷藏一下,享受一份清爽的……哦,抱歉,我们还是回到算法吧。
这是 DMOJ 问题dmpg15g6。
问题描述
两个朋友找到了一个包含n块的羊羹。这些块按顺序编号为 1, 2, ... , n。每一块羊羹有一个特定的味道,每个朋友只会吃那些味道相同的部分。
一块羊羹由从第l块到第r块的所有部分组成。如果一个朋友能找到至少三分之一的羊羹部分有相同的味道,那么他们会对这块羊羹感到高兴。例如,如果一块羊羹有 9 块,那么朋友需要找到 9/3 = 3 块味道相同的部分。为了让两个朋友都对这块羊羹感到高兴,他们需要各自找到三分之一的味道相同的部分。
朋友们将查询不同的羊羹部分;对于每个部分,我们需要确定他们两个是否都会对这块羊羹感到高兴。
输入
输入由以下几行组成:
-
一行包含n,即羊羹的块数,和m,即可能的味道数。n和m的范围在 1 到 200,000 之间。
-
一行包含n个整数,表示从第 1 块到第n块的羊羹部分的味道。每个整数表示一种味道,范围在 1 到m之间。
-
一行包含q,即朋友们的查询数。q的范围在 1 到 200,000 之间。
-
q行,每行对应一个查询。每一行包含两个整数l和r,表示从第l块到第r块的羊羹部分。
输出
每个查询的输出单独一行。对于每个查询:
-
如果两个朋友都对这块羊羹感到高兴,输出
YES。 -
否则,输出
NO。
解决测试用例的时间限制为 1.4 秒。
随机选择一块
让我们从一个测试用例开始:
14 4
1 3 4 2 1 1 2 4 1 2 2 4 1 1
3
3 11
8 11
5 6
这里的羊羹有 14 块,长得像这样:

需要处理三个查询。第一个查询从第 3 块开始,到第 11 块结束。因此,我们关心的是这块羊羹:

这块羊羹有 9 块,因此我们要确定每个朋友是否能找到 9/3 = 3 块相同口味的部分。而他们是可以的!第一个朋友可以吃 3 块口味 1,第二个朋友可以吃 3 块口味 2。(这里有 4 块口味 2,但第四块对于我们来说多余了。)因此,针对这个问题,我们的输出应该是 YES。
想一想我们如何编写代码来判断朋友们是否满意这块羊羹。一般来说,通过逐一检查羊羹中的每一块来做这个判断会太慢了;毕竟,一块羊羹可能有多达 20 万块部分。到目前为止,这个问题我们已经很熟悉了;通常做法是使用一些巧妙的数据结构来加速查询。
但是我们这里要做一些不太寻常的事情。我希望你再次看那块羊羹,并随机挑选其中的一块。羊羹中有口味 1 和口味 2 的部分,所以你可能会挑到其中一个口味。如果是的话,那你就找到了满足第一个朋友的方法。如果没有,请再随机挑选一块羊羹。你可能会得到口味 1 或口味 2。还是没有?那就再试第三次。如果你是随机挑选的话,你很快就会在少数几次尝试中挑到口味 1 或口味 2。
假设你已经为第一个朋友找到了口味 1。现在,再做一次,这次为第二个朋友。这个过程会稍微困难一些:因为三个口味 1 的部分已经没有了,所以你必须选择口味 2。不过,还是试着随机挑选几次,我相信你最终会挑到口味 2 的一块。
我们要写的程序将做的事情与你刚才做的完全一样:随机挑选部分,试图找到一种口味让每个朋友都感到满意。
假设这两位朋友都对这块羊羹感到满意。这意味着羊羹中有一个口味在 2/3 的部分出现,或者有两个不同的口味各自出现在 1/3 的部分。不管怎样,我们都有 2/3 的机会通过随机选择一块来让第一个朋友满意。如果成功了,那我们就完成了第一个朋友的任务,接着转向第二个朋友。如果失败了,我们就再试一次,这时我们有新的 2/3 的成功机会。如果第二次也失败了,我们就再试第三次、第四次,直到成功为止。
我们稍后会弄清楚需要多少次尝试。不过,现在我可以保证,次数不会太多。这个直觉可以通过掷硬币实验来理解。
想象一下,你正在和一个公平的硬币玩游戏。如果你抛硬币,结果是正面朝上,你就赢了。如果是反面朝上,你就得再试一次。可以将硬币正面朝上视为让朋友开心,而反面朝上则意味着朋友不开心,并且你需要再试一次。你预计需要抛硬币多少次才能出现正面?不多吧?如果羊羹中有一种口味让朋友开心,我们就只会抛少量的反面,然后就能找到正面。
在开始进行所有随机化和抛硬币的讨论之前,我们正在处理中一个测试用例,所以让我们先完成这个部分,再继续往下讲。
第二个查询从第 8 块开始,到第 11 块结束。对应的羊羹块是:

不幸的是,这两个朋友对这个结果不满意。每个朋友至少需要找到 4/3 =
块相同口味的糖果;由于每个口味的糖果块数是整数,实际上我们需要的是至少 2 块相同口味的糖果。我们可以用口味 2 满足第一个朋友的需求,但第二个朋友就没法满足了。我们需要在这里输出NO。
第三个查询从第 5 块开始,到第 6 块结束。这是羊羹的这一部分:

每个朋友只需要一块指定口味的糖果。所以我们可以使用口味 1 让两个朋友都开心:我们可以把一块给第一个朋友,把另一块给第二个朋友!因此,正确的输出是YES。
生成随机数
我们需要一种方法来生成随机数,以便随机选择羊羹的块。我们将使用 C 语言的rand函数来完成这一任务。
如果我们调用rand并传入整数x,我们要求rand给我们x种可能性中的一个。具体来说,我们会得到一个在0到x - 1范围内的随机整数。例如,如果我们调用rand(4),我们会得到0、1、2或3。
现在,我们如何使用rand来随机选择羊羹块呢?我们可以使用“宽度”一词来表示羊羹块的数量。例如,从第 8 块到第 11 块的宽度是 4。在这种情况下,我们需要rand返回8、9、10或11。我们可以先调用rand(4),因为我们需要rand从四个可能的值中选择一个。这会返回0、1、2或3。这是一个不错的开始,但这些数字不在正确的范围内。为了解决这个问题,我们只需加上 8,将这个值调整到我们需要的 8–11 范围内。
在代码中,生成给定宽度width和起始点left的随机数可以参考列表 10-1 来实现。
int random_piece(int left, int width) {
❶ return (rand() % width) + left;
}
列表 10-1:随机选择一块
代码执行了我们刚刚概述的计划:它生成了一个从0到width - 1之间的随机数,然后加上起始点left ❶。
确定块数
假设我们正在处理一个特定的查询。我们将选择该查询块中的一个随机片段。这个片段的味道是否让其中一个或两个朋友开心?为了回答这个问题,我们需要能够快速确定该味道在块中出现的次数。
因此,对于我们来说,拥有一个每个味道的排序数组将非常方便,它可以为我们提供该味道的片段。我称这种数组为味道数组。
让我们回到测试用例中的羊羹:

味道 1 的数组是[1, 5, 6, 9, 13, 14];味道 2 的数组是[4, 7, 10, 11];依此类推。如同承诺的那样,这些数组已经排序:片段编号按从小到大的顺序排列。稍后我们会看到如何生成这些味道数组;现在我们暂且假设它们已经存在。
我们可以使用这样的数组来确定给定味道在某个块中的片段数量。例如,我们可以使用数组[1, 5, 6, 9, 13, 14]得出结论:在 3-11 块中有三个味道 1 的片段:片段 5、片段 6 和片段 9。
让我们盘点一下。我们有了要解决的查询。我们有一个随机的味道,需要检查它。我们有了味道数组:一个排序的该味道的片段编号数组。我们需要确定这些片段中有多少个在查询范围内。
我们可以通过线性查找味道数组来做到这一点。但那会太慢——每次查询都需要线性时间。
如果你回想一下第七章,你可能会想知道是否可以在这里使用二分查找。的确可以,因为数组是排序的!实际上,我们需要调用二分查找两次,而不是一次,但这不会改变我们能够在对数时间内找到所需内容的事实。
我们将编写一个二分查找函数,传入一个味道数组pieces和一个整数at_least,返回数组中第一个大于或等于at_least的值的索引。
在我们开始之前,我们应该确保这个函数的规范确实能满足我们的需求。为此,让我们用它来找出 3-11 块中味道 1 的片段数量。
为了找出味道 1 在该块中的片段起始位置,我们可以调用我们的函数,传入数组[1, 5, 6, 9, 13, 14]和at_least值为3。我们将得到结果1。这告诉我们,索引为 1 的片段是这个味道中编号至少为 3 的第一个片段。这个片段是片段 5,它确实是我们要找的第一个片段。
Flavor 1 的片段在该区间结束在哪里?我们也可以算出来!只需用相同的数组调用我们的函数,不过这次设置at_least值为12。为什么是 12?因为那是第一个不在该区间内的片段。如果我们这样调用,就会得到4的结果。这指的是索引 4 处的片段,即片段 13。这是这个味道在 3-11 区间外的第一个片段。一般来说,要弄清楚某个味道的结束位置,我们可以调用二分查找函数,传入比区间右端大 1 的at_least值。
现在我们知道相关片段的起始位置(索引 1)和结束位置(就在索引 4 的左边)。如果从 4 中减去 1,我们得到 3,这正是 3-11 区间中 Flavor 1 的片段数量。
让我们编写二分查找函数的代码。(稍后,我们将看到调用此函数两次的函数。)正如你在《寻找解决方案》中所学到的,在第 250 页,编写二分查找函数的正确方法是先找出不变式。我们的不变式将包含两部分:所有low索引之前的值都小于< at_least,以及所有high或更大的索引上的值都大于等于>= at_least。参见示例 10-2 中的代码。除了前述的pieces和at_least参数外,我们还有一个参数num_pieces,它给出了pieces数组中片段的数量。
int lowest_index(int pieces[], int num_pieces, int at_least) {
int low, high, mid;
❶ low = 0;
➋ high = num_pieces;
➌ while (high - low >= 1) {
mid = (low + high) / 2;
if (pieces[mid] < at_least)
low = mid + 1;
else
high = mid;
}
➍ return low;
}
示例 10-2:查找第一个满足条件的值
我们需要在循环之前使不变式的两部分都成立。对于第一部分,注意不变式并没有对low索引上的值做任何声明;它仅对low左边的值做出声明。因此,我们可以在循环之前将low设置为0 ❶;现在low左边没有任何值,所以这一部分的不变式得到了满足。
对于第二部分,不变式对high或更大的索引上的所有值进行了声明。由于我们对数组中的任何值都不了解,我们需要让这一部分的不变式声明为空。我们可以通过将high设置为数组右端之外的位置来实现 ➋:这样high和数组末尾之间就没有有效索引了。
while循环中的代码保持着不变式。我鼓励你如果愿意,可以自己检查这一点,但你已经有了很多二分查找的练习,所以如果你不想检查,我也不怪你!
while循环条件 ➌ 确保当循环终止时,low和high相等。不变式告诉我们,所有在low左边的值都太小,并且low是第一个>= at_least的值的索引。这就是为什么当函数终止时我们返回low的原因 ➍。
接下来,正如之前承诺的,我们将调用该函数两次,以确定某种味道的片段在给定区间内的数量。参见示例 10-3 中的代码。
int num_in_range(int pieces[], int num_pieces, int left, int right) {
❶ int left_index = lowest_index(pieces, num_pieces, left);
➋ int right_index = lowest_index(pieces, num_pieces, right + 1);
➌ return right_index - left_index;
}
列表 10-3:确定给定口味的块数在范围内的情况
这里,pieces参数是口味数组,left和right参数表示该块的最左和最右边的块。代码首先找到块中口味开始的位置的索引❶。然后找到口味结束位置的右边的索引➋。最后,它从第二个索引中减去第一个索引来确定该块中该口味的块数➌。
猜测口味
到这一步,我们已经知道该怎么做:一旦我们猜到一个口味,就用二分查找来检查猜测的口味是否让一位或两位朋友感到高兴。现在我们需要处理生成这些猜测的代码。
我们的整体策略可以分为三个步骤:
步骤 1 计算出让一位朋友高兴所需要的块数。
步骤 2 尝试让第一位朋友高兴。首先猜测一块。如果该块的口味让朋友高兴,那么我们完成了;否则,再猜一次。一直猜直到成功或者猜测用完为止。我们有可能找到一个口味,它如此常见,不仅让第一位朋友高兴,还能让两位朋友都高兴。如果发生这种情况,我们就输出YES并停止,而不执行步骤 3。
步骤 3 尝试使用我们为第一位朋友使用的相同策略来让第二位朋友高兴。如果我们恰好猜到了让第一位朋友高兴的口味,那么我们需要忽略它并继续下一次尝试,因为这个口味并不常见到足以让两位朋友都高兴。
我们将为以下签名编写函数:
void solve(int yokan[], int *pieces_for_flavor[],
int num_of_flavor[], int left, int right)
以下是每个参数的用途:
yokan Yōkan 口味的数组;yokan[1]是第一块的口味,yokan[2]是第二块的口味,依此类推。(我们从索引 1 开始而不是 0,因为在这个问题中,块的编号从 1 开始。)我们需要这个数组,以便可以选择一个随机的块。
pieces_for_flavor 口味数组的数组。每个口味数组都按从小到大的块编号排序。例如,pieces_for_flavor[1]可能是数组[1, 5, 6, 9, 13, 14],表示所有的口味 1 的块。我们需要这些数组,以便能够进行二分查找。
num_of_flavor 给出每个口味块数的数组;num_of_flavor[1]是口味 1 的块数,num_of_flavor[2]是口味 2 的块数,依此类推。也就是说,这个数组告诉我们每个口味数组中有多少元素。
left 当前查询的起始索引。
right 当前查询的结束索引。
这个函数的代码在列表 10-4 中。阅读代码时要注意三个步骤——找出使朋友高兴的临界值,首先让第一位朋友高兴,然后让第二位朋友高兴。
void solve(int yokan[], int *pieces_for_flavor[],
int num_of_flavor[], int left, int right) {
int attempt, rand_piece, flavor, result;
int width = right - left + 1;
❶ double threshold = width / 3.0;
int first_flavor = 0;
➋ for (attempt = 0; attempt < NUM_ATTEMPTS; attempt++) {
➌ rand_piece = random_piece(left, width);
flavor = yokan[rand_piece];
➍ result = num_in_range(pieces_for_flavor[flavor],
num_of_flavor[flavor], left, right);
➎ if (result >= 2 * threshold) {
printf("YES\n");
return;
}
➏ if (result >= threshold)
❼ first_flavor = flavor;
}
if (first_flavor == 0) {
printf("NO\n");
return;
}
❽ for (attempt = 0; attempt < NUM_ATTEMPTS; attempt++) {
rand_piece = random_piece(left, width);
flavor = yokan[rand_piece];
❾ if (flavor == first_flavor)
continue;
result = num_in_range(pieces_for_flavor[flavor],
num_of_flavor[flavor], left, right);
if (result >= threshold) {
printf("YES\n");
return;
}
}
printf("NO\n");
}
列表 10-4:解决问题
对于第 1 步,我们确定使朋友开心的块的数量 ❶。
对于第 2 步,我们开始为第一个朋友猜测口味 ➋。for 循环使用了一个尚未定义的 NUM_ATTEMPTS 常量。我们将在完成这个函数的讲解后再决定这个数字。在 for 循环中,我们从当前的 Yōkan 块中选择一个随机块 ➌,然后调用我们的 num_in_range 辅助函数,获取该块中与随机块相同口味的块的数量 ➍。
我们的随机口味是否让一个或两个朋友开心?首先,我们检查该口味是否如此普遍,以至于能让两个朋友都开心。具体来说,如果该口味出现的频率为 2/3(也就是两倍的阈值),那么它可以用来让两个朋友都开心 ➎。这样的话,我们就完成了:我们直接输出YES并返回。如果这个口味没有让两个朋友都开心,它可能仍然足够让第一个朋友开心,所以我们接下来检查这一点 ➏。我们还记录下为第一个朋友找到的口味 ❼。
如果在所有的猜测中,我们未能为第一个朋友找到口味,那么我们输出NO并停止。
如果我们为第一个朋友找到了口味,那么我们继续进行第 3 步 ❽,尝试为第二个朋友找到口味。这个逻辑和我们为第一个朋友所做的非常相似。唯一的不同是要确保我们不会不小心使用已经为第一个朋友使用过的口味 ❾。
如果我们到达函数的底部,意味着我们未能为第二个朋友找到口味。在这种情况下,我们输出NO。
我们需要多少次尝试?
最后,让我们回答需要多少次尝试才能确保极高的成功概率。
我们假设每个查询询问的是一个 Yōkan 的块,其中恰好三分之一的块是某种口味 x,另三分之一是另一种口味 y,其余的块则分布在其他各种口味之间。这将是我们面临的最难的查询类型。我们可能会碰巧遇到一个查询,其中某种口味出现了 50%、70% 或 85% 的时间,对于这些情况,我们的猜测会更容易一些。但我们专注于最难的查询类型,因为如果我们能解决这个,那么我们就知道其他的查询也能解决。
不用担心如果你以前没有做过概率相关的工作。概率只是一个在 0 到 1 之间的值。如果某件事的概率是 0,那么它永远不会发生;如果某件事的概率是 1,那么它每次都会发生。你可以将概率值乘以 100 来转换成百分比。例如,当抛硬币时,正面朝上的概率是 0.5;乘以 100,我们就可以看到它有 0.5 × 100 = 50% 的机会正面朝上。我们还需要一些其他的概率规则,但我会在之后解释这些。
我们随便挑一个猜测次数,看看我们能做得如何。比如 10 次?我们先算出让第一个朋友高兴的概率。在第一次猜测时,我们有 2/3 的成功概率。这是因为在这块石板中,有 2/3 的碎片属于两种每种出现 1/3 的口味之一。那么失败的概率是多少呢?这里只有两种结果:成功和失败。它们的概率加起来必须等于 1,因为这两种结果中总有一个会发生。所以我们可以通过从 1 中减去成功的概率来找出失败的概率。这样算出失败的概率是 1 – 2/3 = 1/3。
所有 10 次猜测都失败的概率是多少?为了发生这种情况,我们需要在每次猜测时都独立失败。第一次猜测失败的概率是 1/3,第二次猜测失败的概率是 1/3,第三次猜测失败的概率是 1/3,以此类推。我们可以用一个规则来计算所有这些 10 次独立猜测都失败的概率:将所有概率相乘。我们看到,所有 10 次猜测都失败的概率是(1/3)¹⁰,约等于 0.000017。
现在我们能够计算这个朋友的成功概率了:1 – 0.000017 = 0.999983。
这意味着成功的概率超过 99.99%。我们做得很好!
在第一个朋友成功的前提下,让第二个朋友高兴的概率是多少?对于这个问题,每次尝试的成功概率是 1/3,而不是 2/3,因为第一个朋友的口味已经没有了。如果你从 1/3 开始计算,你会得到第二个朋友的成功概率大约是 0.982658。这个概率超过 98.2%!我们依然很有希望。
现在我们得到了第一个朋友的成功概率和在第一个朋友成功的前提下,第二个朋友的成功概率。但我们更关心的是两个朋友都成功的概率。为了计算这个,我们可以将两个成功的概率相乘。这样算下来,我们发现让两个朋友都高兴的总体概率是 0.999983 × 0.982658 = 0.982641。
这个概率超过 98.2%。相当不错,对吧?不幸的是,不是这样的。如果我们只处理一个查询,这个概率就没问题了。但我们可能需要处理多达 200,000 个查询。并且每个查询都必须完全正确。如果我们有哪怕一个错了,我们就会失败。
假设你把一个球投进篮筐,每次投掷的成功概率是 98.2%。你投一个球。这个球很可能会进。现在假设你投 100 个球。你可能会搞砸其中几个。如果你投 200,000 个球呢?你把每个球都投进篮筐的概率接近于 0。
尽管 10 次尝试已经是一次不错的尝试,但仍然不够。我们需要更多。通过一些反复试探,我最终决定每个朋友使用 60 次尝试。如果你使用 60 次猜测而不是 10 次进行计算,你应该能找到每个查询的成功概率大约是 0.99999999997。
那真是好多 9!但是我们需要这些,因为否则从 1 个查询增加到 200,000 个查询时,我们的概率将大幅下降。为了找到 200,000 个查询的成功概率,我们可以将每个查询的成功概率的 200,000 次方:0.99999999997^(200,000) = 0.999994。
看起来我们丢失了一些 9。尽管如此,这仍然是一个极高的概率,这次是针对每个查询都正确的概率,而不仅仅是一个。
我们终于准备好设置我们的 NUM_ATTEMPTS 常量了。我们来使用 60:
#define NUM_ATTEMPTS 60
填充风味数组
我们已经几乎准备好 main 函数了;只是还需要一个小的辅助函数。
这个辅助函数将接收 yokan(Yōkan 数组)和 num_pieces(Yōkan 中的片数),并生成我们在 solve 函数中使用的 pieces_for_flavor 风味数组。有关代码,请参见 清单 10-5。
#define MAX_FLAVORS 200000
void init_flavor_arrays(int yokan[], int num_pieces,
int *pieces_for_flavor[]) {
❶ static int cur_of_flavor[MAX_FLAVORS + 1];
int i, flavor, j;
for (i = 1; i <= num_pieces; i++) {
flavor = yokan[i];
➋ j = cur_of_flavor[flavor];
pieces_for_flavor[flavor][j] = i;
cur_of_flavor[flavor]++;
}
}
清单 10-5:填充风味数组
该函数假设 pieces_for_flavor 中的每个数组已经分配了内存;这是接下来要编写的 main 函数的责任。
我们使用一个本地的 cur_of_flavor 数组 ❶ 来跟踪每个风味已经找到的片数。在 for 循环内,我们使用这个数组来确定存储当前片数的索引 ➋。
主函数
我们终于到了 main 函数!请查看 清单 10-6。
#define MAX_PIECES 200000
int main(void) {
static int yokan[MAX_PIECES + 1];
static int num_of_flavor[MAX_FLAVORS + 1];
static int *pieces_for_flavor[MAX_FLAVORS + 1];
int num_pieces, num_flavors, i, num_queries, l, r;
❶ srand((unsigned) time(NULL));
scanf("%d%d", &num_pieces, &num_flavors);
➋ for (i = 1; i <= num_pieces; i++) {
scanf("%d", &yokan[i]);
num_of_flavor[yokan[i]]++;
}
➌ for (i = 1; i <= num_flavors; i++) {
➍ pieces_for_flavor[i] = malloc(num_of_flavor[i] * sizeof(int));
if (pieces_for_flavor[i] == NULL) {
fprintf(stderr, "malloc error\n");
exit(1);
}
}
➎ init_flavor_arrays(yokan, num_pieces, pieces_for_flavor);
scanf("%d", &num_queries);
for (i = 0; i < num_queries; i++) {
scanf("%d%d", &l, &r);
➏ solve(yokan, pieces_for_flavor, num_of_flavor, l, r);
}
return 0;
}
清单 10-6: 主函数 *
在我们能够使用 C 的 rand 函数之前,我们需要使用 srand 函数通过一个种子来初始化随机数生成器。种子决定了生成的随机数序列。我们不想每次使用相同的种子,否则每次生成的数列都会一样。一个有效的方法是使用当前时间作为种子,这样每次运行程序时随机数都会不同。我们可以通过 C 的 time 函数来实现这一点 ❶。要使用该函数,您需要在程序顶部添加 #include <time.h>。
这里有两个重要的 for 循环。第一个 ➋ 填充 yokan 数组,并使用 num_of_flavor 数组来跟踪每种风味的片数。为什么我们需要知道每种风味的片数?因为如果不知道这一点,我们就不知道应该为每个风味数组分配多大的内存。第二个 for 循环 ➌ 负责为风味数组分配内存。它使用 num_of_flavor 来确定每个风味数组的确切大小 ➍。
在这些 for 循环之后,我们调用我们的辅助函数来填充我们刚刚分配内存的风味数组 ➎。
然后我们开始处理查询!对于每个查询,我们调用我们的solve函数 ➏,根据需要打印YES或NO。
如果你将我们的代码提交给评测系统,你应该会发现它在时间限制内通过了所有测试用例。如果你的代码是正确的,但你还是没通过某个测试用例,截图保存:你可能再也不会见到这种情况了。
随机化
随机化算法有两种主要类型。我们刚刚了解了一种。让我们在这里扩展一下这一种,并预览另一种。
蒙特卡罗算法
我们用来解决 Yōkan 问题的算法,其中有可能得到错误答案的算法叫做蒙特卡罗算法。使用这种算法时,关键问题是:我们应该尝试多少次?尝试次数和成功概率之间存在权衡:随着我们增加尝试次数,我们提高了成功的概率,但也让算法变慢。我们通常想找到一个平衡点,在这个平衡点上,成功概率足够高,同时算法仍然足够快。当然,什么是“足够高”的成功概率取决于我们使用该算法的目的。如果是解决编程竞赛问题?99%的成功概率就足够了。(如果算法失败,没关系:再跑一次就行了。)但是对于那些影响到人们健康与安全的算法来说,99%就不行。
在我们解决 Yōkan 问题的方案中,如果我们回答YES,那么我们可以确保是正确的。我们只有在面对能够解释为什么朋友们对这块糕点满意的确切口味时才会回答YES。相反,如果我们回答NO,那么我们可能是错误的。也许朋友们并不喜欢这块糕点——但也有可能是我们运气不好,一直挑到不好的口味。因为这两种回答中只有一种能是错的,所以我们说我们的算法具有单边错误。有些蒙特卡罗算法在YES和NO两种情况下都有可能出错;这些算法被称为双边错误。
一个蒙特卡罗算法帮助我们解决了 Yōkan 问题,因为随机找到一个有用口味的概率非常高。或许你会觉得 1/3 或 2/3 的成功概率并不那么出色,确实,只有三分之一或三分之二时间成功的算法是不可接受的。但是记住,这些概率只是我们的起点。在多次尝试之后,我们将把这种每次尝试的高概率转化为每次回答的高概率。
蒙特卡洛算法在其他问题中也很有用。考虑一个包含 n 个节点的图,假设我们将这些节点分成两个组。将这些节点分组的方式大约有 2^n 种,因为每个节点都有两个选择可以放在哪个组中。这种将节点分为两组的方式被称为割。最小割问题询问,在这 2^n 种分法中,哪一种割的边数最少。现在,如果我们只是随机选择一个割,那么每次尝试成功的概率将是 1/2^n,这非常糟糕,根本无法作为蒙特卡洛算法的一个好开端。不过,确实存在一个蒙特卡洛算法解决这个问题,并且它依赖于一个惊人的事实:每次尝试的成功概率可以提升到 1/n²。与 1/2^n 相比,成功的概率 1/n² 确实非常高。
如果你能找到一种方法,使得每次尝试的成功概率“异常高”,那么你已经在开发一个有用的蒙特卡洛算法的路上了。只需要增加尝试的次数,直到达到你想要的整体成功概率。
拉斯维加斯算法
蒙特卡洛算法通常是快速且几乎总是正确的。相比之下,拉斯维加斯算法总是正确的,且几乎总是快速的。(这些算法被赋予与赌场相关的名字,旨在唤起赌博的概念:使用蒙特卡洛算法,我们是在与正确性赌博,而使用拉斯维加斯算法,则是在与速度赌博。)
假设我们有一个对于绝大多数测试用例非常快速,但对于少数剩余的测试用例非常慢的算法。我们可能仍然可以部署这个算法;我们只需要希望那些致命的测试用例不会经常出现。
但我们可以做得更好,一种方法是通过使用拉斯维加斯算法。在这种算法中,我们会随机化算法运行时做出的决策。因为算法没有固定的步骤序列,所以没有人能设计出一个能可靠地拖慢它的测试用例,因为没有人知道算法在该测试用例上会做出什么决策!
为什么拉斯维加斯算法能够有效?我喜欢 Ethan Epperly 的文章《为什么使用随机化算法?》(参见 www.ethanepperly.com/index.php/2021/08/11/why-randomized-algorithms/)。假设你和朋友进行多轮石头剪子布游戏。一种方法是使用固定模式,例如石头、剪子、布、石头、剪子、布、石头、剪子……这种方法可能能行得通一段时间——但最终你的朋友会搞明白你在做什么,然后你就永远赢不了一局了。他们会一直选择能战胜你的选项。一种更好的方法是随机决定每一轮该做什么。如果你这么做,你就在使用拉斯维加斯算法。你的朋友根本不知道接下来会发生什么!在我们编写的这种算法代码中,我们会随机化我们的选择,以确保没有固定的测试用例能够迫使我们表现不佳。
很久以前,在第一章中,我们使用哈希表解决了两个问题。在每个问题中,我们都选择了一个特定的哈希函数,并且直接使用它。一个恶意行为者可以通过故意引发大量哈希碰撞,使这些解决方案变得极其缓慢。我们可以通过拉斯维加斯算法来解决这个问题:我们不再一成不变地使用一个哈希函数,而是让程序每次运行时随机选择使用哪一个哈希函数。这样做就叫做随机哈希。
随机哈希是一个常用的拉斯维加斯算法。但是,还有一个比这个更常用的拉斯维加斯算法。我们将在问题 2 中看到它。首先,让我们讨论一下我们是否真的需要随机化。
确定性算法与随机化算法
确定性算法是一种不使用随机性的算法。我们在本书前九章中讨论的所有算法都是确定性算法,哇,我们从这些算法中获得了大量的收益。那么,为什么不直接忘记随机化算法,坚持使用确定性算法呢?为什么要玩猜测游戏,虽然随机化算法的成功概率达到 99.9999%,但我们可以通过确定性算法实现 100%的成功呢?
原因是,开发一个快速的随机化算法比开发一个快速的确定性算法要容易。如果你感兴趣,可以尝试不使用随机化来解决 Yōkan 问题。虽然这是可能的,但需要一些在随机化算法中不需要的额外想法。
有些问题中,当前最好的随机化算法和最好的确定性算法之间的效率差距非常大。例如,判断一个数是否为质数的随机化算法的时间复杂度是 O(n²),但我们在确定性算法方面所能做到的最好的是 O(n⁶)。
同样,使用确定性算法快速解决下一个问题是比较困难的。幸运的是,我们不需要这样做。我们来做一些随机化!
问题 2:瓶盖和瓶子
这是 DMOJ 问题 cco09p4。
问题描述
我们有 n 个瓶盖和 n 个瓶子。每个瓶盖和瓶子都有唯一的尺寸,并且每个瓶子都有一个完美匹配的瓶盖。
我们的目标是将瓶盖与对应的瓶子匹配起来。(我见过这个问题被换一种说法:匹配螺母和螺栓、钥匙和锁、帽子和人的头等。你可以根据自己的需求使用其中一个,更容易理解的话就选择它。)
这些瓶盖和瓶子具有非常相似的尺寸,因此我们不能直接比较两个瓶盖或两个瓶子。我们唯一能做的就是尝试将一个瓶盖放到一个瓶子上,从而了解瓶盖是太小、正合适还是太大。
为了解决这个问题,我们通过查询与逐步报告我们的答案与评审互动,直到问题解决。(这有点像我们在解决 第七章 中的洞门问题时与评审的互动方式,但这里我们是通过输入输出与评审通信,而不是通过调用函数。)
输入与输出
由于输入与输出在这个问题中是交替进行的,因此我们将它们一起考虑。
开始时,我们读取整数 n,它告诉我们必须匹配的瓶盖和瓶子的数量。n 在 1 到 10,000 之间。瓶盖从 1 到 n 编号,瓶子也是如此。
在读取了 n 之后,我们可以通过两种方式与评审进行互动。
查询 我们可以通过输出 0 cap_num bottle_num 来进行查询。这个查询请求评审告诉我们编号为 cap_num 的瓶盖与编号为 bottle_num 的瓶子之间的关系。我们需要从输入中读取数据来获得查询的答案。如果瓶盖对于瓶子来说太小,答案是 -1;如果瓶盖和瓶子匹配,答案是 0;如果瓶盖对于瓶子来说太大,答案是 1。
报告 我们可以通过输出 1 cap_num bottle_num 向评审报告部分答案,这表示我们将编号为 cap_num 的瓶盖与编号为 bottle_num 的瓶子匹配。评审不会返回任何东西让我们读取。
我们最终需要向评审做 n 次报告,以便将每个 n 个瓶盖与一些不同的瓶子匹配。我们可以随意混合和匹配查询与报告,也就是说,并没有要求所有查询必须在所有报告之前完成,或类似的限制。
我们最多可以进行 500,000 次查询。
解决子任务
对于本书中的大多数问题,我们从标准输入读取,按照问题的要求操作,然后在标准输出上输出答案。通常情况下,例如上一章中的每个问题,我们从标准输入读取的是指示我们下一步要做什么的操作。而这个瓶盖和瓶子问题的交互方式稍有不同。我们不是响应评审的查询,而是我们向评审提出查询。
我们最后一次与裁判进行非标准交互是在第七章解决洞门问题时。在那里,我们先解决了一个小的子任务,而不是整个问题,以确保我们正确地进行交互。我们也可以这样开始这里的任务。
这个问题的第一个子任务保证n最多为 700。我们想到的第一个算法可能会发出很多查询;希望通过 700 个帽子和瓶子,我们至少能通过这些测试用例。
我们需要弄清楚哪个瓶子与每个帽子匹配。那么,为什么不一个一个地遍历这些帽子,然后分别询问每个帽子对应哪个瓶子呢?如果我们这样做,就会得到列表 10-7 中的代码。
int main(void) {
int n, cap_num, bottle_num, result;
❶ scanf("%d", &n);
for (cap_num = 1; cap_num <= n; cap_num++)
for (bottle_num = 1; bottle_num <= n; bottle_num++) {
➋ printf("0 %d %d\n", cap_num, bottle_num);
➌ scanf("%d", &result);
if (result == 0) {
➍ printf("1 %d %d\n", cap_num, bottle_num);
break;
}
}
return 0;
}
列表 10-7:解决子任务 1
让我们确保交互是正确的。我们从读取n的值开始❶,这告诉我们有多少个帽子和瓶子。然后,我们有一个双重for循环,考虑将每个帽子与每个瓶子进行匹配。对于每个帽子-瓶子组合,我们会查询裁判 ➋。我们知道裁判会给出回应,所以我们接下来读取这个回应 ➌。如果我们找到了匹配,我们会告诉裁判 ➍。
在本地测试这个程序有点费脑筋,但我们还是来试试吧。我们需要扮演裁判的角色,一致地回答程序提出的查询。
我们将通过一个包含三个帽子和瓶子的测试用例来进行处理。为了“扮演裁判”,我们需要为帽子和瓶子确定一些尺寸,以便我们能够一致地回答查询。程序永远不会知道这些尺寸,但作为裁判,我们需要它们,这样我们才能知道一个帽子是否太小或太大。让我们约定帽子的尺寸如下:

并且瓶子的尺寸如下:

如果程序正确处理这个测试用例,它将把 Cap 1 与 Bottle 2 匹配,把 Cap 2 与 Bottle 1 匹配,把 Cap 3 与 Bottle 3 匹配。
运行我们的程序。从键盘上输入3,表示n的值是3。
现在程序开始发出查询。你会看到它们出现在程序的输出部分。第一个查询是0 1 1,它在询问我们关于 Cap 1 和 Bottle 1 之间的关系。Cap 1(大小 23)比 Bottle 1(大小 85)小,所以我们在这里需要输入-1。继续输入——一旦你完成,我们将收到另一个查询。
我们收到的下一个查询是0 1 2,它在询问我们关于 Cap 1 和 Bottle 2 的情况。这个帽子和瓶子是匹配的,所以输入0。程序正确报告 Cap 1 与 Bottle 2 匹配。
现在我们的程序已经弄清楚如何匹配 Cap 1,它应该继续处理 Cap 2。正如我们从下一个查询中看到的那样,它确实做到了:0 2 1。Cap 2 和 Bottle 1 匹配,所以我们需要输入0作为回应。程序现在正确报告 Cap 2 与 Bottle 1 匹配。
现在程序要做的就是处理瓶盖 3。它询问查询0 3 1,我们必须输入-1,因为瓶盖 3 比瓶子 1 小。然后它询问0 3 2,我们再次输入-1。最后,我们得到查询0 3 3;由于瓶盖 3 与瓶子 3 匹配,我们输入0。当我们这么做时,程序应该正确报告瓶盖 3 与瓶子 3 匹配……然后我们完成了!程序成功地匹配了瓶盖和瓶子。
如果你将我们的代码提交给评审,应该能够通过一些测试用例。
我们没有通过更多测试的原因在于要求我们最多进行 500,000 次查询。从我们嵌套的for循环可以看出,我们的算法是O(n²)。如果有 10,000 个瓶盖和瓶子,我们最多可能进行 10,000² = 100,000,000 次查询。这太多了!我们需要新的思路来完成剩下的子任务。
解法 1:递归
如果我们坚持选择一个瓶盖,然后找出哪个瓶子匹配它,我们需要更好地利用评审给我们的信息。
瓶盖和瓶子的堆叠
在我们用来解决子任务的算法中,我们询问评审瓶盖 1 与瓶子 1、瓶盖 1 与瓶子 2、瓶盖 1 与瓶子 3 之间的关系,依此类推,直到找到与瓶盖 1 匹配的瓶子。也许我们最后在瓶子 5000 上找到了匹配。这花了很多功夫!花费 5000 次查询,我们才匹配了一个瓶盖和一个瓶子,仅此而已。对于下一个瓶盖,我们又得从头开始。
然而,在匹配第一个瓶盖的过程中,我们丢失了很多信息,而这些信息可以帮助我们稍后更容易地匹配其他瓶盖。特别是,到目前为止,我们没有处理评审给出的“太小”和“太大”的信息。
如果你是手动操作的,如何使用评审提供的信息呢?你可以做的一件事是将瓶子分为两堆:一堆是小瓶子,另一堆是大瓶子。假设我们发现瓶盖 1 对瓶子 1 来说太小了。把那个瓶子丢进大堆里。然后,也许我们发现瓶盖 1 对瓶子 2 来说太大了。那瓶子就进入小堆。瓶盖 1 对瓶子 3 来说太大吗?那个瓶子也进小堆。对每个瓶子都这么做。
现在我们有了两堆瓶子。也许这些是子问题?希望我们能够解决每个子问题,从而解决原始问题。
这可能感觉像是动态规划解法的开端,但实际上不是,因为这两个子问题并没有重叠。解决一个子问题对另一个子问题没有任何帮助。那么,递归呢?我们可以使用它吗?
为了使递归工作,我们需要确保每个子问题都是原始问题的一个更小的版本。我们原始的问题有一堆瓶盖和瓶子。但是,到目前为止,我们的子问题只有瓶子。那么,这些瓶子应该配什么瓶盖呢?我们需要找到一种方法,将瓶盖分为小瓶盖和大瓶盖。一旦我们做到这一点,我们就会真正拥有两个子问题:一个是针对小瓶盖和瓶子的,另一个是针对大瓶盖和瓶子的。
以下是我们将使用的总体步骤。
步骤 1 选择一个瓶盖作为我们的子问题。
步骤 2 遍历瓶子。如果瓶盖小于瓶子,将瓶子放入大瓶子的堆中;如果瓶盖大于瓶子,将瓶子放入小瓶子的堆中。在此步骤的某个时刻,我们会找到与瓶盖匹配的瓶子。把这个瓶子叫做匹配瓶子。报告瓶盖和匹配瓶子之间的配对。
在步骤 2 结束时,我们将得到我们需要的两堆瓶子。现在,瓶盖的处理...
步骤 3 遍历瓶盖。如果当前瓶盖小于匹配瓶子,将瓶盖放入小瓶盖的堆中;如果当前瓶盖大于匹配瓶子,将瓶盖放入大瓶盖的堆中。在此步骤结束时,我们将拥有我们需要的两堆瓶盖。
步骤 4 递归地解决小瓶盖和小瓶子的子问题。
步骤 5 递归地解决大瓶盖和大瓶子的子问题。
像二分查找一样,这是一个分治算法的例子。我们将瓶盖和瓶子分解成更小的独立子问题,然后递归地解决每个子问题。
主函数
在我们实现算法之前,有一些设置工作需要完成;我们先把这些处理掉。请参见清单 10-8 中的main函数。
#define MAX_N 10000
int main(void) {
int n, i;
❶ int cap_nums[MAX_N], bottle_nums[MAX_N];
scanf("%d", &n);
for (i = 0; i < n; i++) {
➋ cap_nums[i] = i + 1;
➌ bottle_nums[i] = i + 1;
}
solve(cap_nums, bottle_nums, n);
return 0;
}
清单 10-8: 主 函数
这里有两个重要的数组:cap_nums和bottle_nums ❶。我们将初始化这些数组,使它们分别包含所有瓶盖的编号 ➋ 和所有瓶子的编号 ➌。这将是我们处理的瓶盖和瓶子的起点。在接下来的代码中,我们将对这些瓶盖和瓶子的更小子集进行递归调用。
实现我们的算法
现在,让我们将这五步算法转化为代码。请参见清单 10-9。
void *malloc_safe(int size) {
char *mem = malloc(size);
if (mem == NULL) {
fprintf(stderr, "malloc error\n");
exit(1);
}
return mem;
}
void solve(int cap_nums[], int bottle_nums[], int n) {
int small_count, big_count, cap_num, i, result, matching_bottle;
int *small_caps = malloc_safe(n * sizeof(int));
int *small_bottles = malloc_safe(n * sizeof(int));
int *big_caps = malloc_safe(n * sizeof(int));
int *big_bottles = malloc_safe(n * sizeof(int));
if (n == 0)
return;
small_count = 0;
big_count = 0;
❶ cap_num = cap_nums[0];
➋ for (i = 0; i < n; i++) {
printf("0 %d %d\n", cap_num, bottle_nums[i]);
scanf("%d", &result);
➌ if (result == 0) {
printf("1 %d %d\n", cap_num, bottle_nums[i]);
matching_bottle = bottle_nums[i];
} else if (result == -1) {
big_bottles[big_count] = bottle_nums[i];
big_count++;
} else {
small_bottles[small_count] = bottle_nums[i];
small_count++;
}
}
small_count = 0;
big_count = 0;
➍ for (i = 0; i < n; i++) {
printf("0 %d %d\n", cap_nums[i], matching_bottle);
scanf("%d", &result);
if (result == -1) {
small_caps[small_count] = cap_nums[i];
small_count++;
} else if (result == 1) {
big_caps[big_count] = cap_nums[i];
big_count++;
}
}
➎ solve(small_caps, small_bottles, small_count);
➏ solve(big_caps, big_bottles, big_count);
}
清单 10-9:解决方案 1
在实现我们五步算法的代码之前,我们有一些malloc调用。我们需要这些调用,以便为我们将要创建的瓶盖和瓶子的堆分配内存。
对于步骤 1,我们必须选择一个瓶盖。如果我们直接选择第一个瓶盖,这会让问题变得简单。是的,就这么做❶,然后继续。没什么好看。
对于步骤 2,我们使用 for 循环遍历所有瓶子 ➋。对于每个瓶盖,在这个循环中的 if 语句里有三种可能性。第一种情况下,我们找到匹配 ➌,然后告诉评测系统这个匹配并记住匹配的瓶子以便后续使用。第二种情况下,瓶盖太小,无法匹配瓶子,我们就把瓶子放进大瓶子堆里。第三种情况下,瓶盖太大,无法匹配瓶子,我们就把瓶子放进小瓶子堆里。
步骤 3 与步骤 2 类似,但这次我们遍历所有瓶盖 ➍ 而不是所有瓶子。如果瓶盖太小,无法匹配瓶子,我们就把瓶盖扔进小瓶盖堆里。如果瓶盖太大,无法匹配瓶子,我们就把瓶盖扔进大瓶盖堆里。一定要小心不要弄错!很容易搞错一个 -1 或 1,把瓶盖或瓶子放进错误的堆里。
对于步骤 4,我们通过递归调用 ➎ 解决“小瓶盖和小瓶子”的子问题。
最后,对于步骤 5,我们通过另一个递归调用 ➏ 解决“大瓶盖和大瓶子”的子问题。
就是这样!将小的部分放入一个子问题,将大的部分放入另一个子问题,并递归地解决这两个子问题。很巧妙,对吧?……对吧?
不幸的是,它还不够巧妙。如果你将我们的代码提交给评测系统,你会发现它在通过所有测试用例之前就超时了。
不过,我们离解决这个问题已经很近了。我们需要做的就是添加随机化。为什么我们当前的解决方案还不够好?随机化又是如何解决这个问题的呢?这些问题的答案会在接下来的部分讲解。
解决方案 2:添加随机化
在我们看到如何添加随机化之前,让我们先准确了解一下是什么样的测试用例会击败解决方案 1。
为什么解决方案 1 会很慢
我们的 solve 函数的每次调用都会操作一些瓶盖和一些瓶子。我们选择的瓶盖将瓶盖和瓶子分成两组:一组是小瓶盖和小瓶子,另一组是大瓶盖和大瓶子。无论我们选择哪个瓶盖,都能得到正确的行为。
话虽如此,我们选择的瓶盖确实会对解决问题所需的查询次数产生重大影响。我们用来解决子任务的算法(第 10-7 列表)是一个 O(n²) 算法,它的速度太慢了。因此,为了说明解决方案 1 为何太慢,我们可以通过展示它也有一些测试用例,处理这些用例时需要 O(n²) 的时间。
我们将使用的测试用例直觉上可能觉得它应该是一个简单的测试用例。但这种直觉是错误的。
和往常一样,对于这个问题,我们有 n 个瓶盖和 n 个瓶子。瓶盖的大小从瓶盖 1 到瓶盖 n 依次增大。例如,我们可以说瓶盖 1 的大小是 1,瓶盖 2 的大小是 2,瓶盖 3 的大小是 3,依此类推。对于瓶子也是一样:瓶子 1 的大小是 1,瓶子 2 的大小是 2,瓶子 3 的大小是 3,依此类推。
现在,我们的方案 1 算法会做什么呢?在第一次调用solve时,它会选择第一个分界点来拆分分界点和瓶子。它会遍历这些分界点,这需要 n 次查询,然后遍历瓶子,这又需要 n 次查询。所以到目前为止是 2n 次查询。那么,我们的子问题呢?它们的大小是否相似,还是非常不均衡?
它们是极度不均衡的!没有任何分界点或瓶子比所选的分界点小。所以,“较小的分界点和瓶子”子问题是空的。那么,“较大的分界点和瓶子”子问题就包含了所有剩余的 n – 1 个分界点和瓶子。
那么,带有 n – 1 个分界点和瓶子的子问题会发生什么呢?同样,我们会选择第一个分界点,这次是分界点 2。我们将通过第一个for循环进行 n – 1 次查询,然后通过第二个循环再进行 n – 1 次查询。这对于这个子问题来说是 2(n – 1) 次查询。而且,源自这个子问题的两个子问题是极度不均衡的:一个空子问题和一个包含 n – 2 个分界点和瓶子的子问题。
这里的情况非常类似于我们在解决构建 Treaps 时遇到的第 307 页的情况。在每种情况下,我们都希望能将问题均匀地拆分成两个子问题。但如果没有得到这样的拆分,我们最终做的工作量是二次的。在这里,我们将执行 2n 次查询,然后是 2(n – 1) 次查询,再然后是 2(n – 2) 次查询,以此类推。我们将进行的查询总数是 2(1 + 2 + 3 + . . . + n),这就是 O(n²)。
真糟糕!所有这些复杂的拆分和递归,结果我们还是被困在 O(n²)。
我们将随机化的内容
在方案 1 中,我们选择了第一个分界点并围绕它拆分了问题。这个第一个分界点决定了什么是“较小”的,什么是“较大”的。正如我们刚才看到的,如果这个分界点的拆分效果不佳,那么我们的算法可能会变成二次时间复杂度。
你可能会想,是否可以通过做出一个“更聪明”的分界点选择来避免这种不好的表现。也许我们应该选择最右边的分界点?不幸的是,前一小节中的测试用例也会破坏这一选择。也许我们应该选择中间的分界点?当然可以,但那时就可能有人设计出一种测试用例,其中中间的分界点总是最小的分界点。那样我们又会回到二次时间复杂度的情况了。
这里最好的做法是随机选择我们的分界点!每次需要一个分界点时,我们都调用rand来获取它。如果我们这样做,没有任何测试用例能可靠地导致性能差,因为在每次运行时,我们会做出不同的选择,影响算法的执行方式。这将我们的确定性算法转化为拉斯维加斯算法。
将这种随机化与我们在解决 Yōkan 时使用的随机化进行对比。在 Yōkan 中,随机化决定了我们是否得到了正确答案。而在“分界点与瓶子”问题中,我们总是能得到正确答案;随机化决定了我们得到答案的速度。
添加随机化
我们只需要对解法 1 做两个修改。首先,我们需要像在解决“羊羹”问题时那样,在列表 10-8 中的main函数中添加srand的调用。
第二步,我们需要在列表 10-9 中选择一个随机的枢纽。与其选择第一个枢纽:
cap_num = cap_nums[0];
我们选择一个随机的:
cap_num = cap_nums[rand() % n];
如果你做了这两个修改,并将更新后的代码提交给评测系统,你应该会发现它通过了所有的测试用例。随机化再次起作用了!
信不信由你,在解决这个问题的过程中,我们也偷偷学会了计算机科学中最著名的算法之一。接下来我们来看一下。
快速排序
我们解决盖子和瓶子问题的关键思想是选择一个盖子,然后使用该盖子将问题拆分为两个子问题:一个子问题处理小的部分,另一个处理大的部分。这个思想最著名的应用是一个叫做快速排序的排序算法。
实现快速排序
快速排序是众多可以用来排序数组的算法之一;在实践中,它是其中之一……最快的。在“盖子和瓶子”问题中,用来拆分问题的项是一个盖子;在快速排序中,用来拆分数组的值被称为枢纽。
快速排序的代码与我们用来解决盖子和瓶子问题的代码类似。请查看列表 10-10 中的代码。
#define N 10
void *malloc_safe(int size) {
char *mem = malloc(size);
if (mem == NULL) {
fprintf(stderr, "malloc error\n");
exit(1);
}
return mem;
}
void swap(int *x, int *y) {
int temp = *x;
*x = *y;
*y = temp;
}
void quicksort(int values[], int n) {
int i, small_count, big_count, pivot_index, pivot;
int *small_values = malloc_safe(n * sizeof(int));
int *big_values = malloc_safe(n * sizeof(int));
if (n == 0)
return;
small_count = 0;
big_count = 0;
❶ pivot_index = rand() % n;
➋ swap(&values[0], &values[pivot_index]);
pivot = values[0];
➌ for (i = 1; i < n; i++) {
if (values[i] > pivot) {
big_values[big_count] = values[i];
big_count++;
} else {
small_values[small_count] = values[i];
small_count++;
}
}
quicksort(small_values, small_count);
quicksort(big_values, big_count);
➍ for (i = 0; i < small_count; i++)
values[i] = small_values[i];
➎ values[small_count] = pivot;
➏ for (i = 0; i < big_count; i++)
values[small_count + 1 + i] = big_values[i];
}
int main(void) {
static int values[N] = {96, 61, 36, 74, 45, 60, 47, 6, 95, 93};
int i;
srand((unsigned) time(NULL));
quicksort(values, N);
for (i = 0; i < N; i++)
printf("%d ", values[i]);
printf("\n");
return 0;
}
列表 10-10:快速排序
我们选择一个随机的枢纽索引 ❶,并将枢纽移动到数组的左端 ➋。我们要把枢纽移开,这样就不会丢失它——我们稍后需要将它放到正确的位置。
接下来,我们遍历数组中的所有其他值 ➌,根据需要将它们添加到big_values或small_values中。一旦完成,我们将做出两个递归调用,分别对小值和大值进行排序。
接下来,我们要做的是将所有内容拼接在一起:首先是小值,然后是枢纽,最后是大值。我们将小值复制到values数组的开头 ➍,然后复制枢纽 ➎,最后复制大值 ➏。
接下来,我们将看到为什么我们解决盖子和瓶子问题的解法如此快速。虽然我们会用盖子和瓶子的问题来讨论,但我们学到的关于运行时间的知识也直接适用于快速排序的运行时间。
最坏情况和预期运行时间
我们的盖子和瓶子问题的解法是一种拉斯维加斯算法。运行时间取决于我们随机选择的每个盖子将问题拆分为两个子问题的效果。如果我们每次都选到糟糕的盖子,运行时间将是O(n²)。但那是最坏情况的表现;我们引入随机化的原因就是为了让这种情况极不可能发生。因此,算法设计者通常不关注这种算法的最坏情况运行时间,而是更关注预期运行时间,即告诉我们在实际情况中可能发生的情况。
那么,我们对于瓶盖和瓶子的随机化解决方案,实践中会期望什么样的表现呢?我们已经知道如果运气极差会发生什么:我们会得到 O(n²) 的性能。如果我们运气极好,恰好选择能够完美划分每个问题一半的瓶盖,会发生什么呢?
首先,我们选择瓶盖并进行 2n 次查询,就像我们一直做的那样。如果这个瓶盖完美地将问题一分为二,那么我们将需要处理两个子问题,每个子问题包含 n/2 个瓶盖和瓶子。这两个子问题中的每一个在递归之前将会各自生成 2n/2 = n 次查询。因此,这两个包含 n/2 个瓶盖和瓶子的子问题将会生成 2n 次查询,就像我们原始问题一样。
现在,如果每个包含 n/2 个瓶盖和瓶子的子问题被完美地划分,那么我们会得到四个大小为 n/4 的问题。每个问题在递归之前会生成 2n/4 = n/2 次查询,总共会有 2n 次查询。
总结来说,我们为原始大小为 n 的问题进行了 2n 次查询,为大小为 n/2 的子问题总共进行了 2n 次查询,为大小为 n/4 的子问题总共进行了 2n 次查询,以此类推。我们最多只能进行 log n 次这样的操作,直到到达基本情况的子问题。所以,我们总共进行了 2n 次查询,共进行 log n 次,因此总共进行了 O(n log n) 次查询。
另一种理解 O(n log n) 上界的方法是通过 递归树。这样的树能够描述每次递归调用中所做的工作量。当我们一直得到完美的划分时,我们的递归树就像 图 10-1 中的那样。

图 10-1:一个具有完美划分的递归树
请注意,每个节点都分裂成两个下方的节点,表示每个问题都被分成了两部分。每个节点中的数量表示为了解决该子问题而直接进行的查询次数。例如,顶部的 2n 表示初始调用会生成 2n 次查询。它并不是在说整个算法中总共会有 2n 次查询,而是说初始调用会进行 2n 次查询,然后才会递归。请注意,这棵树中的每一层——顶部的节点、下方的两个节点,以及下方的四个节点——总共会生成 2n 次查询。如果我们画出整棵树,它大约会有 log n 层。因此,总的来说,我们有 O(n log n) 次查询。
现在,O(n log n) 是一件很棒的事,但到目前为止,我所讨论的只是当算法遇到完美划分时会发生的情况。然而,算法通常不会如此幸运,就像它通常也不会遇到极度不幸的糟糕瓶盖选择一样。
结果表明,预期的运行时间非常接近于由超级幸运情况预测的运行时间,而不是超级不幸情况。例如,让我们想象每次选择一个帽子时,一个子问题将以 90%的帽子和瓶子结束,而另一个子问题将以剩余的 10%的帽子和瓶子结束。你可能会认为这相当不幸。但即使在这种情况下,我们的算法仍然是O(n log n)!我们最大的子问题将从大小n,变成大小n/(10/9),然后变成大小n/(10/9)²,依此类推。也就是说,我们不是每次都除以 2,而是每次都除以 10/9。我们需要多少次这样做才能达到基本情况?它仍然是对数的!对数的底数变化了——从 log[2]变为 log[10/9]——但仍然是对数。没错:即使我们变得这么不幸,我们仍然不会达到O(n²)。
总结
当我在算法课上教授随机化时,我经常会有一些困惑的学生。“真的,丹?选择随机数?这感觉像是我在学习真正算法之前会尝试的事情。”但正如我在本章中所展示的,随机化并不是新手动作。与确定性算法相比,随机化算法可以更快速,更容易设计。
在第七章中,我给出了这样的建议:如果你看到可以用二分查找解决问题的机会,就去做吧。谁在乎是否有一个稍微更高效的解决方案不使用二分查找呢?我在这里也提供类似的建议:如果你看到可以利用随机化的机会,并且你可以容忍随机性对正确性或运行时间的影响,那就去做吧。谁在乎是否存在一个确定性算法:即使你可以想出一个(这可能不容易!),实际上它可能在实践中也会更慢。
注释
Yōkan 最初来自 2015 年 Don Mills 编程盛典编程竞赛,金牌部分。Caps and Bottles 最初来自 2009 年加拿大计算奥林匹克竞赛。
有一种方法可以减少我们解决 Caps and Bottles 问题所需的内存量。这是一个适用于快速排序实现的巧妙技巧。在附录 B 的“Caps and Bottles: In-Place Sorting”中查看它。
对于深入了解排序算法,我推荐 Gregory J.E. Rawlins(1991 年)的Compared to What?:An Introduction to the Analysis of Algorithms。这是一本老书但是经典之作。有许多排序算法:有些慢,有些快;有些合并排序后的片段,有些分割并排序片段。该书比较和对比了许多排序算法。这也是最初让我了解 Caps and Bottles 问题的书籍。
第十一章:后记

我写这本书是为了教你如何思考和设计数据结构和算法。在这个过程中,我们学习了计算机科学中的许多经典思想。哈希表使我们摆脱了昂贵的线性查找。树组织层次化的数据。递归解决那些需要解决子问题的复杂问题。记忆化和动态规划即便在子问题重叠时,也能保持递归的高效。图可以概括树能表示的内容。广度优先搜索和 Dijkstra 算法在图中寻找最短路径;由于图的普遍性,“路径”可以有很多含义。二分查找将“解决这个”问题转化为“检查这个”问题。堆让查找最小或最大元素变得高效;线段树在其他类型查询中也有类似的作用。并查集加速了维护等价节点集合的图问题。随机化使我们更容易设计高效算法,并保护我们免受最坏情况的测试案例。这真是一个不小的清单,我希望你对所学到的内容感到满意。我也希望我能帮助你深入理解这些数据结构和算法为何有用,为什么它们如此高效,以及我们能从它们的设计中学到什么。
我写这本书是为了激励你思考并设计数据结构和算法。我使用了编程问题,希望你能觉得它们引人入胜(让你想要解决它们)且具有挑战性(让你不得不学习如何解决它们)。也许你是被这些问题本身所激励。也许你是被计算机科学家提出并解决问题的方式所激励。也许你迫切希望解决对你个人有意义的问题。不管怎样,我希望我能帮助你提升技能和动力,去追求重要的事物。
本书中的编程问题有一个好处,那就是它们耐心地等待我们去解决。它们不会改变。它们不会适应——但我们会。当我们卡住时,我们可以离开,学习新知识,然后回来再试。现实世界中的问题显然不会把它们的精确输入和输出呈现给我们。它们的一些特性可能随时间变化。我们需要找到这些问题也在耐心等待的方式。
我写这本书是为了教学。感谢你信任我并抽出时间阅读我所要表达的内容。
第十二章:**A
算法运行时间

本书中我们解决的每个竞争编程问题都有一个规定的时间限制,限制了我们的程序允许运行的最长时间。如果我们的程序超时,评测系统就会终止程序并显示“超时”错误。时间限制的设计目的是防止算法上过于简单的解决方案通过测试用例。问题的作者已经有了某些模型解法,并通过设置时间限制来判断我们是否展示了这些解法的思想。因此,除了正确性之外,我们还需要让程序运行得足够快。
时机的选择……以及其他一些因素
大多数算法书籍在讨论运行时间时并不使用时间限制。然而,本书中经常出现时间限制和执行时间的概念。其主要原因是,这些时间可以帮助我们直观地理解程序的效率。我们可以运行程序并测量它所花费的时间。如果我们的程序太慢,超过了问题的时间限制,那么我们就知道我们需要优化当前的代码或找到一种全新的方法。我们不知道评测系统使用的是哪种计算机,但在我们自己的计算机上运行程序仍然有参考价值。假设我们在笔记本电脑上运行程序,发现它在某个小的测试用例上花了 30 秒。如果问题的时间限制是 3 秒,那么我们可以确信我们的程序显然还不够快。
然而,仅仅专注于执行时间是有限的。以下是五个原因:
执行时间取决于计算机。 正如前面所提到的,给我们的程序计时只告诉我们在一台计算机上运行程序所花费的时间。这是非常具体的信息,并且它几乎无法帮助我们理解在其他计算机上运行时会发生什么。在阅读本书时,你可能还会注意到,即使在同一台计算机上,程序的执行时间也会有所不同。例如,你可能在一个测试用例上运行程序,发现它需要 3 秒钟;然后你可能再运行一次,同样的测试用例,结果却是 2.5 秒或 3.5 秒。造成这种差异的原因是操作系统正在管理你的计算资源,并根据需要将它们分配到不同的任务中。操作系统所做的决策会影响程序的运行时间。
执行时间取决于测试用例。 在一个测试用例上对我们的程序进行计时,只能告诉我们程序在该测试用例上运行了多长时间。假设我们的程序在一个小测试用例上运行需要一秒钟,这看起来可能很快,但关于小测试用例的真相是:每个合理的解决方案都能解决这些问题。如果我让你排序几个数字,或优化地安排几个事件,或者做其他什么,你可以用你第一个正确的想法迅速完成。那么,真正有趣的,是大测试用例。它们是算法独创性体现的地方。我们的程序在大测试用例上或巨大的测试用例上需要多久才能完成?我们不知道。我们也需要在这些测试用例上运行程序。即便我们这样做,也可能会有特定类型的测试用例会导致较差的表现。我们可能会被误导,认为我们的程序比实际更快。
程序需要实现。 我们无法对一个没有实现的东西进行计时。假设我们在考虑一个问题,并想出了一个解决方案。它快吗?虽然我们可以通过实现它来了解,但提前知道这个想法是否可能导致一个快速的程序会更好。你不会实现一个你一开始就知道会错误的程序。同样,知道一个程序一开始就太慢,也会很好。
计时无法解释缓慢。 如果我们发现程序太慢,那么接下来的任务就是设计一个更快的程序。然而,单纯地计时并不能为我们提供程序为何缓慢的洞见。它只是慢而已。此外,如果我们设法想出一个可能改进程序的办法,我们需要实现它才能看到它是否有效。
执行时间不容易传达。 基于上述许多原因,使用执行时间与他人讨论算法效率是困难的。“我的程序在去年购买的这台电脑上运行需要两秒钟,测试用例包含八只鸡和四个鸡蛋,使用我用 C 语言编写的程序。你的程序呢?”
不用担心:计算机科学家已经设计出一种符号来解决计时的这些不足之处。它与计算机无关,与测试用例无关,也与特定的实现无关。它揭示了为什么一个程序会变慢。它容易传达。它被称为大 O 符号,接下来就会介绍。
大 O 符号
大 O 是计算机科学家用来简洁描述算法效率的记法。它将每个算法归入少数几个效率类别中的一个。效率类别告诉你一个算法有多快,或者等价地说,它做了多少工作。算法越快,做的工作越少;算法越慢,做的工作越多。每个算法都属于一个效率类别;效率类别告诉你相对于它必须处理的输入量,算法做了多少工作。要理解大 O,我们需要了解这些效率类别。我在这里将介绍三种:线性时间、常数时间和平方时间。
线性时间
假设我们提供一个递增顺序的整数数组,我们想返回其中的最大整数。例如,给定以下数组:
[1, 3, 8, 10, 21]
我们希望返回21。
一种方法是追踪到目前为止找到的最大值。每当我们找到比当前最大值更大的值时,就更新最大值。清单 A-1 实现了这个思想。
int find_max(int nums[], int n) {
int i, max;
max = nums[0];
for (i = 0; i < n; i++)
if (nums[i] > max)
max = nums[i];
return max;
}
清单 A-1:在递增整数数组中查找最大值
代码将max设置为nums数组中索引为0的值,然后通过循环遍历数组,寻找更大的值。不要担心循环的第一次迭代将max与其自身进行比较:那只是一次不必要的工作。
与其为特定的测试用例计时,不如思考这个算法根据数组大小所做的工作量。假设数组有五个元素。我们的程序做了什么?它在循环前执行了一个变量赋值,然后在循环中迭代了五次,最后返回结果。如果数组有 10 个元素,那么我们的程序做的也类似,只不过这次它在循环中迭代了 10 次而不是 5 次。那么,如果有一百万个元素呢?我们的程序会迭代一百万次。现在我们可以看到,循环前的赋值和循环后的返回相比,循环所做的工作量更为庞大。尤其是当测试用例变得非常大时,关键是循环的迭代次数。
如果我们的数组有n个元素,那么循环会迭代n次。在大 O 记法中,我们说这个算法是O(n)。可以这样理解:对于一个包含n个元素的数组,算法的工作量与n成正比。O(n)算法被称为线性时间算法,因为问题规模与所做工作的数量之间存在线性关系。如果我们将问题规模加倍,那么工作量也会加倍,从而使运行时间加倍。例如,如果在一个包含二百万个元素的数组上运行需要一秒钟,我们可以预期在一个包含四百万个元素的数组上运行需要大约两秒钟。
请注意,我们不需要运行代码就能得出这个结论。我们甚至不需要写出代码。(好吧……是的,我确实写了代码,但那只是为了让算法更清晰。)说一个算法是 O(n),向我们提供了问题规模和运行时增长之间的基本关系。无论我们使用什么计算机,或者查看哪个测试案例,这都是成立的。
常数时间复杂度
我们知道数组中有一些信息我们还没有利用:整数是按递增顺序排列的。因此,最大的整数一定在数组的末尾。我们可以直接返回它,而不是通过对数组进行穷举搜索最终找到它。列表 A-2 展示了这一新思路。
int find_max(int nums[], int n) {
return nums[n - 1];
}
列表 A-2:在递增整数数组中找到最大值
这个算法根据数组大小做多少工作?有趣的是,数组的大小已经不再重要!无论数组有 5 个元素,10 个元素,还是一百万个元素,算法都只访问并返回 nums[n - 1],即数组的最后一个元素。算法并不关心大小。在大 O 符号中,我们说这个算法是 O(1)。它被称为 常数时间算法,因为它所做的工作量是恒定的,随着问题规模的增大并不会增加。
这是最好的算法类型。无论数组多大,我们都可以预期大致相同的运行时间。它显然比线性时间算法要好,后者随着问题规模的增大而变得更慢。不过,并不是所有有趣的问题都能通过常数时间算法解决。例如,如果我们给定的是一个无序的数组,而不是递增的数组,那么常数时间算法就不适用了。我们不可能仅查看固定数量的数组元素,就能保证找到最大值。
另一个例子
请看列表 A-3 中的算法:它是 O(n) 还是 O(1) 或者其他什么?(注意,我没有列出函数和变量的定义,以避免我们想要编译和运行它。)
total = 0;
for (i = 0; i < n; i++)
total = total + nums[i];
for (i = 0; i < n; i++)
total = total + nums[i];
列表 A-3:这是什么样的算法?
假设数组 nums 有 n 个元素。第一个循环执行 n 次,第二个循环也执行 n 次。总共有 2n 次迭代。作为第一次尝试,直观上可以说这个算法是 O(2n)。虽然这么说技术上是正确的,但计算机科学家通常会忽略 2,直接写作 O(n)。
这可能看起来有点奇怪,因为这个算法的速度是列表 A-1 中的算法的两倍,但我们却宣称它们都是 O(n)。原因在于我们的记法在简洁性和表现力之间的平衡。如果我们保留 2,那么或许更准确,但这也会掩盖它是一个线性时间算法这一事实。无论是 2n 还是 3n,或者任何乘以 n 的数字,它的基本线性运行时增长是不会改变的。
二次时间复杂度
我们现在已经看到了线性时间算法(在实际中非常快速)和常数时间算法(比线性时间算法还要快)。接下来让我们看一下比线性时间还要慢的算法。代码在示例 A-4 中。
total = 0;
for (i = 0; i < n; i++)
for (j = 0; j < n; j++)
total = total + nums[j];
示例 A-4:一个二次时间算法
与示例 A-3 相比,请注意,现在的循环是嵌套的,而不是顺序的。外层循环的每次迭代都会导致内层循环的n次迭代。外层循环迭代n次。因此,内层循环的总迭代次数以及更新total的次数是n²。(外层循环的第一次迭代需要n的工作量,第二次需要n的工作量,第三次需要n的工作量,以此类推。总数是n + n + n + …… + n,其中我们加上n的次数是n。)
在大 O 符号中,我们说这个算法是O(n²)。它被称为二次时间算法,因为“二次”是指数学中 2 的幂次。
现在我们来探讨为什么二次时间算法比线性时间算法慢。假设我们有一个需要n²步的二次时间算法。在问题规模为 5 时,它需要 5² = 25 步;在问题规模为 10 时,它需要 10² = 100 步;在问题规模为 20 时,它需要 20² = 400 步。注意,当我们将问题规模加倍时,所做的工作是四倍增加的。这比线性时间算法要差得多,因为在后者中,问题规模加倍时,工作量只会加倍。
不要惊讶,2n²步、3n²步等算法也被归类为二次时间算法。大 O 符号隐藏了n²项前面的部分,就像它隐藏了线性时间算法中n项前面的部分一样。
如果我们有一个算法,它需要 2n² + 6n步呢?这也是一个二次时间算法。我们正在将 2n²的二次运行时间与 6n的线性运行时间相加。结果仍然是一个二次时间算法:二次部分的四倍增长很快就会主导线性部分的两倍增长。
本书中的大 O 符号
关于大 O 符号还有很多可以讲的内容。它有一个正式的数学基础,计算机科学家用它来严格分析算法的运行时间。除了我在这里介绍的三种效率类别外,还有其他效率类别(如果需要,我会介绍本书中出现的其他几种)。如果你有兴趣深入了解,肯定还有更多的内容可以学习,但我在这里介绍的已经足够满足我们的需求了。
本书中的大 O 符号通常根据需要出现。我们可能会为一个问题寻求初步的解决方案,却发现收到评测系统的“超时”错误。在这种情况下,我们需要理解自己哪里出了问题,而这种分析的第一步就是理解我们的运行时间如何随着问题规模的变化而增长。大 O 分析不仅能确认代码运行缓慢的事实,而且通常能揭示出代码中的具体瓶颈。然后,我们可以利用这种更深入的理解来设计更高效的解决方案。
第十三章:B
因为我无法抗拒

在本附录中,我包括了一些与本书中研究的某些问题相关的附加材料。我认为这个附录是可选的:它并不涉及我认为对学习数据结构和算法目标至关重要的内容。然而,如果你渴望更深入了解某个问题,这个附录将适合你。
Unique Snowflakes:隐式链表
通常在编译时我们并不知道程序需要多少内存。如果你曾经问过:“我应该把这个数组做多大?”或者“这个数组够大吗?”那么你就亲身体验过 C 语言数组的局限性:我们必须选择一个数组大小,但在数组开始填充之前,我们可能不知道需要多大的数组。在许多这种情况下,链表能巧妙地解决问题。每当我们需要新内存来存储数据时,我们只需在运行时调用malloc来向链表添加一个节点。
在第一章的第一个问题——Unique Snowflakes 中,我们使用链表将位于同一桶中的雪花串联起来。对于每个我们读取的雪花,我们使用malloc为每一个雪花分配内存。如果我们读取了 5,000 个雪花,我们就会进行 5,000 次malloc调用。这些malloc调用所耗费的时间可能会积累起来。
等等!我们刚才说过,当我们不知道需要多少内存时,链表是有用的。而在 Unique Snowflakes 问题中,我们确实知道!或者,至少我们知道我们需要的最大内存:它是存储最多 100,000 个雪花所需的内存。
这引出了几个问题。为什么我们要使用malloc呢?有没有办法避免使用malloc和链表呢?事实上,我们可以通过一种不使用malloc且能使速度翻倍的方式来解决 Unique Snowflakes 问题。怎么做呢?
关键思想是预先分配一个数组,存储我们可能使用的最大节点数(100,000)。这个数组称为nodes,它存储所有(现在是隐式的)链表中的节点。nodes中的每个元素是一个整数,表示其节点列表中下一个节点的索引。让我们通过解读一个示例nodes数组来理解这一点:
[-1, 0, -1, 1, 2, 4, 5]
假设我们知道其中一个列表从索引6开始。索引6的值是5,这告诉我们索引5是列表中的下一个节点。同样,索引5告诉我们索引4是列表中的下一个节点。索引4告诉我们索引2是列表中的下一个节点。那么索引2,值为-1呢?我们将-1作为我们的NULL值:它表示没有“下一个”元素。我们已经发现了索引6、5、4和2的列表。
在那个数组中还有一个非空列表。假设我们知道这个列表从索引3开始。索引3告诉我们索引1是列表中的下一个节点。索引1告诉我们索引0是列表中的下一个节点。然后就结束了——索引0是-1,所以列表结束了。我们已经发现了索引3、1和0的列表。
这就是nodes数组。如果某个索引的值为-1,则表示这是链表的结束。否则,它给出了链表中下一个元素的索引。
注意,nodes数组并没有告诉我们链表的起始位置。我们必须假设我们知道链表头节点的索引分别是6和3。我们是怎么知道的呢?通过使用另一个数组heads,它给出了链表中第一个节点的索引。如果某个元素不是链表的起始节点,heads会使用-1表示。
我们的无malloc解决方案在main函数中使用了三个数组:snowflakes、nodes和heads。snowflakes数组存储实际的雪花数据,以便我们根据nodes和heads中的索引查找雪花。以下是这三个数组:
static int snowflakes[SIZE][6];
static int heads[SIZE];
static int nodes[SIZE];
只有两个函数需要调整才能从链表过渡到我们在这里使用的隐式链表:identify_identical和main。这些调整是语法层面的,而不是功能层面的:identify_identical仍然执行列表中所有雪花的两两比较,而main仍然读取雪花并构建链表。
新的identify_identical函数见 Listing B-1—请与之前在 Listing 1-12 中的内容进行对比!
void identify_identical(int snowflakes[][6], int heads[],
int nodes[]) {
int i, node1, node2;
for (i = 0; i < SIZE; i++) {
node1 = heads[i];
while (node1 != -1) {
➊ node2 = nodes[node1];
while (node2 != -1) {
if (are_identical(snowflakes[node1], snowflakes[node2])) {
printf("Twin snowflakes found.\n");
return;
}
➋ node2 = nodes[node2];
}
➌ node1 = nodes[node1];
}
}
printf("No two snowflakes are alike.\n");
}
Listing B-1:在隐式链表中识别相同的雪花
在for循环内,node1被设置为当前列表的头节点。如果这个列表为空,则外部的while循环对于这个节点不会执行。如果列表不为空,那么通过使用nodes数组,node2被设置为node1之后的节点➊。我们不是使用像node2 = node2->next这样的链表代码,而是再次使用nodes数组来查找下一个节点➋ ➌。
新的main函数见 Listing B-2。
int main(void) {
static int snowflakes[SIZE][6];
static int heads[SIZE];
static int nodes[SIZE];
int n;
int i, j, snowflake_code;
for (i = 0; i < SIZE; i++) {
heads[i] = -1;
nodes[i] = -1;
}
scanf("%d", &n);
for (i = 0; i < n; i++) {
for (j = 0; j < 6; j++)
scanf("%d", &snowflakes[i][j]);
snowflake_code = code(snowflakes[i]);
➊ nodes[i] = heads[snowflake_code];
➋ heads[snowflake_code] = i;
}
identify_identical(snowflakes, heads, nodes);
return 0;
}
Listing B-2:隐式链表的 main 函数
假设我们刚读取了一片雪花,并将其存储在snowflakes数组的第i行。我们希望这片雪花成为它所在链表的头节点。为此,我们将旧的链表头节点存储在nodes[i]➊处,然后将链表头节点设置为雪花i➋。
花点时间将这个解决方案与我们的链表解决方案进行比较。你更喜欢哪个?没有malloc的解决方案对你来说是更难理解还是更容易理解?请提交两个版本给评审员;这种加速值得吗?
汉堡热潮:重构解决方案
在第三章中,我们解决了三个问题——汉堡热潮、贪财者和冰球对抗——这些问题涉及最小化或最大化解决方案的值。在汉堡热潮中,我们最大化了霍默吃汉堡的时间;我们给出了一个答案,比如2 2,意味着两个汉堡和两分钟喝啤酒。在贪财者中,我们最小化了购买苹果所需的金钱;我们给出了一个答案,比如买 3 个,花费$3.00。在冰球对抗中,我们最大化了对抗比赛中的进球数;我们给出了一个答案,比如20。
但是请注意,我们在这里做的是给出最优解的值,而不是给出最优解本身。我们并没有指明应该吃哪些汉堡,或者如何购买苹果,或者哪些游戏是竞争性游戏。
绝大多数优化问题在竞赛编程中要求最优解的值,这是第三章和第四章的重点。然而,如果我们愿意,也可以使用记忆化和动态规划来返回最优解本身。
让我们用“汉堡狂热”作为例子,看看这是如何做的。给定以下测试用例:
4 9 15
让我们不仅输出最优解的值,还输出最优解本身,像这样:
2 2
Eat a 4-minute burger
Eat a 9-minute burger
第一行是我们之前的内容;其他行则构成了最优解本身,证明2 2确实是可以实现的。
输出最优解的值被称为重构或恢复解。以上两个词都表明我们已经有了可以拼接成最优解的各个部分。确实如此:我们所需要的就在memo或dp数组中。在这里,我们使用dp数组;memo数组也可以以相同的方式使用。
我们将为这个函数签名编写主体:
void reconstruct(int m, int n, int dp[], int minutes)
回想一下,我们有m分钟和n分钟的汉堡。m和n参数就是这些值,并且来自当前的测试用例。dp参数是由清单 3-8 中的动态规划算法生成的数组。最后,minutes参数是吃汉堡所花费的时间。该函数将按行打印应在最优解中吃的汉堡数量。
在最优解中,霍默最后应该吃哪个汉堡?如果我们从头开始解决这个问题,那么我们并不知道答案。我们需要看看如果选择一个m分钟的汉堡作为最后一个会发生什么,同时也要看看如果选择一个n分钟的汉堡作为最后一个会发生什么。实际上,这就是我们在第三章中解决这个问题时所做的。记住,现在我们有了dp数组可以使用。这个数组将告诉我们哪种选择是最好的。
关键的思路是:查看dp[minutes - m]和dp[minutes - n]。这两个值都已经可以访问,因为dp数组已经构建完成。哪个值更大,就告诉我们应该选择哪一个作为最后的汉堡。也就是说,如果dp[minutes - m]更大,那么最后一个是m分钟的汉堡;如果dp[minutes - n]更大,那么最后一个是n分钟的汉堡。(如果dp[minutes - m]和dp[minutes - n]相等,那么你可以随意选择最后一个是m分钟还是n分钟的汉堡。)
这种推理与清单 3-8 中构建dp数组的方式相似。在那里,我们选择了first和second的最大值;这里,我们则是反向推断出动态规划算法所做的选择。
一旦我们推导出最终的汉堡,我们会去掉吃掉那个汉堡所花的时间,然后重复这个过程。我们一直进行下去,直到剩下零分钟为止,这时我们的重构就完成了。清单 B-3 给出了该函数的代码。
void reconstruct(int m, int n, int dp[], int minutes) {
int first, second;
while (minutes > 0) {
first = -1;
second = -1;
if (minutes >= m)
first = dp[minutes - m];
if (minutes >= n)
second = dp[minutes - n];
if (first >= second) {
printf("Eat a %d-minute burger\n", m);
minutes = minutes - m;
} else {
printf("Eat a %d-minute burger\n", n);
minutes = minutes - n;
}
}
}
清单 B-3:重构解决方案
该函数应在清单 3-8 的两个地方调用,每次在printf调用后。第一次是:
reconstruct(m, n, dp, t);
第二个是:
reconstruct(m, n, dp, i);
我鼓励你以同样的风格重构“Moneygrubbers”和“Hockey Rivalry”问题的最优解。
骑士追击:编码移动
在第五章的骑士追击问题中,我们设计了一个 BFS 算法,用来找出骑士从起点到每个方格所需的步数。骑士有八个可能的移动,我们在代码中写出了每一个(见清单 5-1)。例如,下面是我们让骑士探索上移 1 和右移 2 的做法:
add_position(from_row, from_col, from_row + 1, from_col + 2,
num_rows, num_cols, new_positions,
&num_new_positions, min_moves);
这是我们对上移 1 和左移 2 的做法:
add_position(from_row, from_col, from_row + 1, from_col - 2,
num_rows, num_cols, new_positions,
&num_new_positions, min_moves);
那里有严重的代码重复:唯一的变化是加号变成了减号!事实上,所有八个移动都以非常相似的方式编码,只是在一些加号、减号和 1、2 之间进行调整。这样的做法很容易出错。
幸运的是,有一种巧妙的技术可以避开这种代码重复。它适用于许多要求探索多维(如行和列)隐式图的题目。
这是问题描述中第五章中展示的骑士的八个可能移动:
-
上 1,右 2
-
上 1,左 2
-
下 1,右 2
-
下 1,上 2
-
上 2,右 1
-
上 2,左 1
-
下 2,右 1
-
下 2,左 1
让我们首先关注行,并写下每个移动如何改变行号。第一个移动使行号增加 1,第二个移动也如此。第三和第四个移动则使行号减少 1。第五和第六个移动使行号增加 2,第七和第八个移动使行号减少 2。以下是这些数值的数组:
int row_dif[8] = {1, 1, -1, -1, 2, 2, -2, -2};
它叫做row_dif,因为它给出了当前行与移动后行号之间的差异。
现在我们对列做同样的事情。第一个移动使列号增加 2,第二个移动使列号减少 2,依此类推。作为一个数组,列的差值为:
int col_dif[8] = {2, -2, 2, -2, 1, -1, 1, -1};
这两个平行数组的有用之处在于,它们描述了每个移动对当前行和列的影响。row_dif[0]和col_dif[0]中的数字告诉你,第一个移动会使行增加 1,列增加 2,row_dif[1]和col_dif[1]中的数字告诉你,第二个移动会使行增加 1,列减少 2,依此类推。
现在,我们不需要输入八个几乎完全相同的add_position调用,而是可以使用一个包含八次迭代的循环,只需在其中输入一次add_position调用。以下是实现方式,使用一个新的整数变量m来循环遍历移动:
for (m = 0; m < 8; m++)
add_position(from_row, from_col,
from_row + row_dif[m], from_col + col_dif[m],
num_rows, num_cols, new_positions,
&num_new_positions, min_moves);
这样更好!更新你在第五章中的骑士追击代码,并与判题系统一起测试。你应该仍然能通过所有的测试用例,且代码的运行速度不会明显变快或变慢,但你已经消除了相当多的重复代码,这是一个胜利。
我们这里只有八个移动,因此我们能够在第五章中的骑士追击游戏中生还,而没有使用这种编码技巧。然而,如果我们有更多的移动,重复调用add_position就不再可行了。我们在这里看到的方式扩展性更好。
Dijkstra 算法:使用堆
在第六章中,我们学习了 Dijkstra 算法,它用于在加权图中找到最短路径。我们实现的 Dijkstra 算法的运行时间是O(n²),其中n是图中节点的数量。Dijkstra 算法大部分时间都在寻找最小值:每次迭代时,它都必须找到距离最小的节点,且这些节点尚未完成处理。
然后,在第八章中,我们学习了最大堆和最小堆。最大堆在这里没有用处——但是最小堆有用,因为它的作用是快速找到最小值。因此,我们可以使用最小堆来加速 Dijkstra 算法。这简直是计算机科学的天堂配对。
最小堆将保存所有已发现且尚未完成的节点。它也可能包含一些已发现但已完成的节点。不过没关系:就像我们在第八章中使用堆解决超市促销问题时做的那样,我们只需要忽略从最小堆中出来的已完成节点。
老鼠迷宫:使用堆进行追踪
让我们增强第六章中对老鼠迷宫问题的解决方案,使用最小堆。这是我们在那里使用的图形(图 6-1):

在第六章中,我们从节点 1 开始追踪 Dijkstra 算法。让我们再次做一次这项工作,这次使用最小堆。每个堆元素将包含一个节点和到达该节点所需的时间。我们将看到,堆中可能有多个相同节点的出现。然而,由于它是一个最小堆,我们将能够使用每个节点的最小时间来处理它。
在接下来的每个最小堆快照中,我按照它们在堆数组中存储的顺序排列了行。
我们从只有节点 1 在堆中的状态开始,时间为 0。我们没有其他节点的时间信息。因此,我们的快照如下:
最小堆
| 节点 | 时间 |
|---|---|
| 1 | 0 |
其余状态
| 节点 | 已完成 | 最小时间 |
|---|---|---|
| 1 | false | 0 |
| 2 | false | |
| 3 | false | |
| 4 | false | |
| 5 | false |
从最小堆中提取出唯一的元素节点 1。然后我们处理节点 1,更新到节点 2、3、4 和 5 的最短路径,并将这些节点放入最小堆中。现在的状态如下:
最小堆
| 节点 | 时间 |
|---|---|
| 3 | 6 |
| 2 | 12 |
| 5 | 7 |
| 4 | 45 |
其余状态
| 节点 | 已完成 | 最小时间 |
|---|---|---|
| 1 | true | 0 |
| 2 | false | 12 |
| 3 | false | 6 |
| 4 | false | 45 |
| 5 | false | 7 |
节点 3 是接下来从最小堆中弹出的,它为节点 2 提供了一个更短的路径。因此,我们将另一个节点 2 加入堆中,这个节点的路径比之前更短。现在的状态如下:
最小堆
| 节点 | 时间 |
|---|---|
| 5 | 7 |
| 2 | 8 |
| 4 | 45 |
| 2 | 12 |
其余状态
| 节点 | 已完成 | 最小时间 |
|---|---|---|
| 1 | true | 0 |
| 2 | false | 8 |
| 3 | true | 6 |
| 4 | false | 45 |
| 5 | false | 7 |
下一个弹出的节点是节点 5。它没有导致任何最短路径更新,所以没有新的节点被加入堆中。现在的状态如下:
最小堆
| 节点 | 时间 |
|---|---|
| 2 | 8 |
| 2 | 12 |
| 4 | 45 |
其余状态
| 节点 | 已完成 | 最小时间 |
|---|---|---|
| 1 | true | 0 |
| 2 | false | 8 |
| 3 | true | 6 |
| 4 | false | 45 |
| 5 | true | 7 |
节点 2 是下一个从最小堆中弹出的节点——具体来说是时间为 8 的那个,而不是时间为 12 的那个!它导致了节点 4 最短路径的更新,因此节点 4 会重新出现在最小堆中。结果如下:
最小堆
| 节点 | 时间 |
|---|---|
| 2 | 12 |
| 4 | 45 |
| 4 | 17 |
其余状态
| 节点 | 已完成 | 最小时间 |
|---|---|---|
| 1 | true | 0 |
| 2 | true | 8 |
| 3 | true | 6 |
| 4 | false | 17 |
| 5 | true | 7 |
下一个从最小堆中弹出的节点是节点 2。又来了!节点 2 已经完成,所以我们直接从堆中提取它,不做任何其他处理。我们当然不会再次处理这个节点。剩下的状态如下:
最小堆
| 单元格 | 时间 |
|---|---|
| 4 | 17 |
| 4 | 45 |
其余状态
| 节点 | 已完成 | 最小时间 |
|---|---|---|
| 1 | true | 0 |
| 2 | true | 8 |
| 3 | true | 6 |
| 4 | false | 17 |
| 5 | true | 7 |
两个节点 4 会依次从最小堆中提取。第一个节点 4 不会导致任何最短路径更新——其他所有节点都已经完成——但是会将节点 4 标记为已完成。第二个节点 4 因此会被跳过。
在大多数基于堆的教材实现中,Dijkstra 算法假设有一种方法可以减少堆中节点的最短路径距离。这样,节点就可以在堆中更新,而无需存在多个相同的节点。然而,在第八章中我们开发的堆并不支持这种“减少”操作。请放心,我们这里采用插入而非更新的方法,具有相同的最坏情况时间复杂度。那么,究竟是什么呢?
设图中有 n 个节点,m 条边。我们最多处理每条边 u → v 一次,当 u 从堆中提取时。每条边至多会导致一次堆插入,因此我们最多插入 m 个元素。那么堆的最大大小就是 m。我们只能提取已经插入的元素,因此最多有 m 次提取操作。总共是 2m 次堆操作,每次操作最多花费 log m 时间。因此,我们得到了一个 O(m log m) 的算法。
将此与第六章中的 O(n²) 实现进行比较。当边的数量相对于 n² 较小时,基于堆的实现明显更优。例如,如果有 n 条边,则基于堆的实现是 O(n log n),这比第六章中的 O(n²) 运行时间要快得多。如果边的数量很大,那么选择哪种实现就不那么重要了。例如,如果有 n² 条边,堆的实现是 O(n² log n),尽管比 O(n²) 稍慢,但依然有竞争力。如果你无法预见图中边的数量较少还是较多,使用堆是一个安全的选择:唯一的成本是在边多的图中额外的 log n 因子,但在边少的图中获得更好的性能,这是值得付出的代价。
老鼠迷宫:使用堆的实现
现在让我们使用堆来解决老鼠迷宫问题。我们为堆元素使用以下结构:
typedef struct heap_element {
int cell;
int time;
} heap_element;
我不会在这里重复最小堆插入代码(清单 8-5)或提取代码(清单 8-6)。唯一的变化是将比较对象从 cost 改为 time;这部分留给你完成。
main 函数与第六章中的相同(清单 6-1)。我们需要做的只是替换 find_time(清单 6-2),使用最小堆代替线性搜索。该代码可以在清单 B-4 中找到。
int find_time(edge *adj_list[], int num_cells,
int from_cell, int exit_cell) {
static int done[MAX_CELLS + 1];
static int min_times[MAX_CELLS + 1];
➊ static heap_element min_heap[MAX_CELLS * MAX_CELLS + 1];
int i;
int min_time, min_time_index, old_time;
edge *e;
int num_min_heap = 0;
for (i = 1; i <= num_cells; i++) {
done[i] = 0;
min_times[i] = -1;
}
min_times[from_cell] = 0;
min_heap_insert(min_heap, &num_min_heap, from_cell, 0);
➋ while (num_min_heap > 0) {
min_time_index = min_heap_extract(min_heap, &num_min_heap).cell;
if (done[min_time_index])
➌ continue;
min_time = min_times[min_time_index];
done[min_time_index] = 1;
e = adj_list[min_time_index];
➍ while (e) {
old_time = min_times[e->to_cell];
if (old_time == -1 || old_time> min_time + e->length) {
min_times[e->to_cell] = min_time + e->length;
➎ min_heap_insert(min_heap, &num_min_heap,
e->to_cell, min_time + e->length);
}
e = e->next;
}
}
return min_times[exit_cell];
}
清单 B-4:使用 Dijkstra 算法和堆求解最短路径
每个单元格最多可以向最小堆中添加 MAX_CELLS 个元素,并且最多有 MAX_CELLS 个单元格。因此,如果我们为 MAX_CELLS * MAX_CELLS 个元素加一分配空间,就可以避免溢出最小堆,因为我们从 1 开始索引,而不是从 0 开始 ➊。
主 while 循环会一直执行,只要最小堆中有元素 ➋。如果从最小堆提取的节点已经完成,那么我们就不在其迭代上做任何操作 ➌。否则,我们像往常一样处理出边 ➍,当找到更短的路径时,将节点添加到最小堆 ➎。
压缩路径压缩
在第九章中,你学习了路径压缩,这是对基于树的并查集数据结构的优化。我们在列表 9-8 中看到了它在社交网络问题中的代码。那样写,带有两个 while 循环,并不是你在实际中看到的代码样式。
我通常不喜欢停留在晦涩的代码上——我希望书中没有给你展示过这样的代码——但在这里我做个例外,因为你可能会在某个时刻遇到一个特别复杂的、一行实现的路径压缩代码。它展示在列表 B-5 中。
int find(int p, int parent[]) {
return p == parent[p] ? p : (parent[p] = find(parent[p], parent));
}
列表 B-5:路径压缩的实际应用
我将 person 改为 p,使代码只占一行(因为可读性已经差到这种程度,为什么不呢?)。
这里有很多内容:? : 三元条件运算符,使用 = 赋值运算符的结果,甚至还有递归。我们将分三步来解开这个问题。
步骤 1:不再使用三元条件语句
? : 运算符是一种返回值的 if-else 形式。程序员通常在希望节省空间时使用它,将整个 if 语句压缩到一行中。一个简单的例子如下:
return x >= 10 ? "big" : "small";
如果 x 大于或等于 10,则返回 "big";否则,返回 "small"。
? : 运算符被称为 三元 运算符,因为它有三个操作数:第一个表达式是我们正在测试其真值的布尔表达式,第二个表达式是当第一个表达式为真时的结果,第三个是当第一个表达式为假时的结果。
让我们重写列表 B-5,使用标准的 if...else 语句,而不是三元 if:
int find(int p, int parent[]) {
if (p == parent[p])
return p;
else
return parent[p] = find(parent[p], parent);
}
这样稍微好一点。现在我们明确看到代码有两条路径:一条是当 p 已经是根节点时,另一条是当 p 不是根节点时。
步骤 2:更清晰的赋值运算符
你认为这段代码做了什么?
int x;
printf("%d\n", x = 5);
答案是它打印出 5!你知道 x = 5 会把 5 赋给 x,但它也是一个值为 5 的表达式。没错:= 赋值,但它也返回存储在变量中的值。这也是我们可以执行以下操作的原因:
a = b = c = 5;
将相同的值赋给多个变量。
在路径压缩代码中,我们在同一行上有一个返回语句和一个赋值语句。那一行既赋值给 parent[p],又返回该值。让我们将这两个操作分开:
int find(int p, int parent[]) {
int community;
if (p == parent[p])
return p;
else {
community = find(parent[p], parent);
parent[p] = community;
return community;
}
}
我们明确找到了 p 的代表,将 parent[p] 赋值给该代表,然后返回该代表。
步骤 3:理解递归
现在我们将递归独立成一行:
community = find(parent[p], parent);
find函数从它的参数到树的根进行路径压缩,并返回树的根。因此,这次递归调用执行了从p的父节点到树根的路径压缩,并返回树的根。这样就处理了除p本身之外的所有路径压缩。我们还需要将p的父节点设置为树的根,这可以通过以下方式实现:
parent[p] = community;
就这样:证明了一行路径压缩代码确实有效!
瓶盖和瓶子:就地排序
在第十章中,我们使用快速排序中一个著名的“拆分”思想解决了“瓶盖和瓶子”问题。如果你回头查看列表 10-9,你会发现我们在算法运行过程中分配了大量的额外内存。具体来说,在每次调用solve时,我们都会使用malloc为四个数组分配内存:小瓶盖、小瓶子、大瓶盖和大瓶子。
通过直接在cap_nums和bottle_nums数组中进行拆分,我们有可能避免使用额外的内存。这不会减少我们需要进行的查询次数,但确实减少了程序使用的内存。这也是在实现快速排序时,常见的一种优化方法。
为了使这个方法有效,我们需要跟踪小值和大值之间的边界。我们将维护一个名为border的变量来实现这一点。一旦我们完成了遍历所有瓶盖和瓶子,border变量将准确告诉我们问题在哪里被分成了两部分;我们需要它来进行递归调用。请参见列表 B-6,该列表展示了我们使用此思想的新解决方案。
#define MAX_N 10000
void swap(int *x, int *y) {
int temp = *x;
*x = *y;
*y = temp;
}
int random_value(int left, int width) {
return (rand() % width) + left;
}
➊ void solve(int cap_nums[], int bottle_nums[], int left, int right) {
int border, cap_index, cap_num, i, result, matching_bottle;
if (right < left)
return;
border = left;
➋ cap_index = random_value(left, right - left + 1);
cap_num = cap_nums[cap_index];
i = left;
while (i < right) {
printf("0 %d %d\n", cap_num, bottle_nums[i]);
scanf("%d", &result);
➌ if (result == 0) {
swap(&bottle_nums[i], &bottle_nums[right]);
➍ } else if (result == 1) {
➎ swap(&bottle_nums[border], &bottle_nums[i]);
border++;
i++;
} else {
i++;
}
}
matching_bottle = bottle_nums[right];
➏ printf("1 %d %d\n", cap_num, matching_bottle);
border = left;
i = left;
while (i < right) {
printf("0 %d %d\n", cap_nums[i], matching_bottle);
scanf("%d", &result);
if (result == 0) {
swap(&cap_nums[i], &cap_nums[right]);
} else if (result == -1) {
swap(&cap_nums[border], &cap_nums[i]);
border++;
i++;
} else {
i++;
}
}
➐ solve(cap_nums, bottle_nums, left, border - 1);
➑ solve(cap_nums, bottle_nums, border, right - 1);
}
int main(void) {
int n, i;
int cap_nums[MAX_N], bottle_nums[MAX_N];
srand((unsigned) time(NULL));
scanf("%d", &n);
for (i = 0; i < n; i++) {
cap_nums[i] = i + 1;
bottle_nums[i] = i + 1;
}
solve(cap_nums, bottle_nums, 0, n - 1);
return 0;
}
列表 B-6:无额外内存分配的解决方案
与其使用一个n参数来表示瓶盖和瓶子的数量,现在我们需要使用left和right参数来限定数组的操作部分 ➊。
在第一次while循环之前,我们选择了我们的随机边界 ➋。第一个while循环的关键不变量是:从left到border - 1的所有瓶子都是小瓶子,而从border到i - 1的所有瓶子都是大瓶子。循环最终还会找到匹配的瓶子;当它找到时,会把它放到右边 ➌。然后我们可以在后续的递归调用中忽略这个瓶子。
如果我们发现瓶盖对于当前瓶子来说太大 ➍,这意味着当前瓶子位于border的错误一侧。毕竟,它是一个小瓶子,而小瓶子应该位于border的左边。为了解决这个问题,我们将这个小瓶子与bottle_nums[border]处的大瓶子交换 ➎,然后我们将border递增,以便考虑到我们现在有了一个新的小瓶子在border的左侧。
当while循环完成时,我们将重新排列瓶子,使小瓶子排在前面,大瓶子排在后面。我们还会把匹配的瓶子放到右边,所以现在就可以告诉判断器这个匹配了 ➏。
第二个while循环与第一个几乎完全相同,只不过这次它分割的是瓶盖,而不是瓶子。
我们需要做的最后一件事是进行两个递归调用。第一个调用从left到border - 1 ➐——那是所有的小瓶子和瓶盖。第二个调用从border到right - 1 ➑——那是所有的大瓶子和瓶盖。注意:这里我们需要right - 1,而不是right。索引为right的瓶子和瓶盖已经匹配过了,因此不应再传递给递归调用。
第十四章:**C
PROBLEM CREDITS**

我感谢任何通过竞赛编程帮助人们学习的人的时间和专业知识。对于本书中的每个问题,我都尽力确定其作者和使用场所。如果您有关于以下任何问题的附加信息或致谢,请告诉我。更新将发布在本书的网站上。
以下是下表中使用的缩写:
CCC 加拿大计算机竞赛
CCO 加拿大计算机奥林匹克竞赛
COCI 克罗地亚信息学公开竞赛
DMPG 唐米尔斯编程盛会
ECNA 东中北美地区编程竞赛
IOI 国际信息学奥林匹克
NOIP 全国信息学奥林匹克竞赛
POI 波兰信息学奥林匹克
SAPO 南非编程奥林匹克
SWERC 南欧地区竞赛
USACO 美国计算机奥林匹克竞赛
| 章节 | 小节 | 原始标题 | 竞赛和/或作者 |
|---|---|---|---|
| Intro | 食物队列 | Food Lines | 2018 LKP;Kevin Wan |
| 1 | 独特的雪花 | Snowflakes | 2007 CCO;Ondrej Lhoták |
| 1 | 登录混乱 | n/a | 2017 COCI;Tonko Sabolcec |
| 1 | 拼写检查 | Spelling Check | 2010 School Team Contest 1;Mikhail Mirzayanov, Natalia Bondarenko |
| 2 | 万圣节捡宝 | Trick or Tree’ing | 2012 DWITE;Amlesh Jayakumar |
| 2 | 后代距离 | Countdown | 2005 ECNA;John Bonomo, Todd Feil, Sean McCulloch, Robert Roos |
| 3 | 汉堡狂热 | Homer Simpson | Sadrul Habib Chowdhury |
| 3 | 钱贪婪者 | Lowest Price in Town | MAK Yan Kei, Sabur Zaheed |
| 3 | 曲棍球竞争 | Geese vs. Hawks | 2018 CCO;Troy Vasiga, Andy Huang |
| 4 | 跳跃者 | Nikola | 2007 COCI |
| 4 | 构建方式 | Substring | 2015 NOIP |
| 5 | 骑士追逐 | A Knightly Pursuit | 1999 CCC |
| 5 | 绳索攀登 | Reach for the Top | 2018 Woburn Challenge;Jacob Plachta |
| 5 | 书籍翻译 | Lost in Translation | 2016 ECNA;John Bonomo, Tom Wexler, Sean McCulloch, David Poeschl |
| 6 | 老鼠迷宫 | Mice and Maze | 2001 SWERC |
| 6 | 外婆规划师 | Visiting Grandma | 2008 SAPO;Harry Wiggins, Keegan Carruthers-Smith |
| 7 | 喂蚂蚁 | Mravi | 2014 COCI;Antonio Juric |
| 7 | 河流跳跃 | River Hopscotch | 2006 USACO;Richard Ho |
| 7 | 生活质量 | Quality of Living | 2010 IOI;Chris Chen |
| 7 | 洞穴门 | Cave | 2013 IOI;Amaury Pouly, Arthur Charguéraud(受 Kurt Mehlhorn 启发) |
| 8 | 超市促销 | Promotion | 2000 POI;Tomasz Walen |
| 8 | 构建 Treaps | 二分查找;堆构造 | 2004 Ulm;Walter Guttmann |
| 8 | 两数之和 | Maximum Sum | 2009 Kurukshetra Online Programming Contest;Swarnaprakash Udayakumar |
| 9 | 社交网络 | Social Network Community | Prateek Agarwal |
| 9 | 朋友与敌人 | War | Petko Minkov |
| 9 | 抽屉杂务 | Ladice | 2013 COCI;Luka Kalinovcic, Gustav Matula |
| 10 | 羊羹 | 羊羹 | 2015 DMPG; FatalEagle |
| 10 | 盖子和瓶子 | 瓶盖 | 2009 CCO |
CCC 和 CCO 问题由滑铁卢大学数学与计算机教育中心(CEMC)拥有。


浙公网安备 33010602011771号