C++-数据结构与算法设计原理(全)

C++ 数据结构与算法设计原理(全)

原文:annas-archive.org/md5/89b76b51877d088e41b92eef0985a12b

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

关于

本节简要介绍了作者、本书的覆盖范围、开始所需的技术技能,以及完成所有包含的活动和练习所需的硬件和软件要求。

关于本书

C++是一种成熟的多范式编程语言,可以让您编写具有对硬件高度控制的高级代码。今天,包括数据库、浏览器、多媒体框架和 GUI 工具包在内的重要软件基础设施都是用 C++编写的。

本书首先介绍了 C++数据结构以及如何使用链表、数组、栈和队列存储数据。在后面的章节中,本书解释了基本的算法设计范式,如贪婪方法和分治方法,用于解决各种计算问题。最后,您将学习动态规划的高级技术,以开发本书讨论的几种算法的优化实现。

通过本书,您将学会如何在高效且可扩展的 C++ 14 代码中实现标准数据结构和算法。

关于作者

John Carey

作曲家和钢琴家 John Carey 的正式教育几乎完全基于音乐领域。在他的艺术努力中广泛使用计算机和其他形式的技术后,他投入了多年的自学,学习编程和数学,并现在作为软件工程师专业工作。他相信他不寻常的背景为他提供了对软件开发主题的独特和相对非学术的视角。他目前在 Hydratec Industries 工作,该公司主要为消防洒水系统设计师开发 CAD 软件,用于对拟议设计进行水力计算,以确定其有效性和合法性。

Shreyans Doshi

Shreyans 毕业于 Nirma 大学,获得计算机工程学士学位。毕业后,他加入了金融行业,致力于使用尖端 C++应用程序开发超低延迟交易系统。在过去的三年里,他一直在 C++中设计交易基础设施。

Payas Rajan

Payas 毕业于 NIT Allahabad,获得计算机科学技术学士学位。后来,他加入了三星研究印度,在那里帮助开发了 Tizen 设备的多媒体框架。目前,他在加州大学河滨分校攻读博士学位,专攻地理空间数据库和路径规划算法,并担任教学和研究助理,他已经使用 C++创建应用程序十年。

学习目标

通过本书,您将能够:

  • 使用哈希表、字典和集合构建应用程序

  • 使用布隆过滤器实现 URL 缩短服务

  • 应用常见算法,如堆排序和归并排序,用于字符串数据类型

  • 使用 C++模板元编程编写代码库

  • 探索现代硬件如何影响程序的实际运行性能

  • 使用适当的现代 C++习语,如std::array,而不是 C 风格数组

受众

这本书适用于想要重新学习基本数据结构和算法设计技术的开发人员或学生。虽然不需要数学背景,但一些复杂度类和大 O 符号的基本知识,以及算法课程的资格,将帮助您充分利用本书。假定您熟悉 C++ 14 标准。

方法

本书采用实用的、动手的方法来解释各种概念。通过练习,本书展示了在现代计算机上,理论上应该执行类似的不同数据结构实际上表现出了不同的性能。本书不涉及任何理论分析,而是专注于基准测试和实际结果。

硬件要求

为了获得最佳的学生体验,我们建议以下硬件配置:

  • 任何带有 Windows、Linux 或 macOS 的入门级 PC/Mac 都足够了

  • 处理器:Intel Core 2 Duo,Athlon X2 或更好

  • 内存:4 GB RAM

  • 存储:10 GB 可用空间

软件要求

您还需要提前安装以下软件:

  • 操作系统:Windows 7 SP1 32/64 位,Windows 8.1 32/64 位,或 Windows 10 32/64 位,Ubuntu 14.04 或更高版本,或 macOS Sierra 或更高版本

  • 浏览器:Google Chrome 或 Mozilla Firefox

  • 任何支持 C++ 14 标准的现代编译器和集成开发环境(可选)。

安装和设置

在开始阅读本书之前,请安装本书中使用的以下库。您将在这里找到安装这些库的步骤:

安装 Boost 库:

本书中的一些练习和活动需要 Boost C++库。您可以在以下链接找到库以及安装说明:

Windows:www.boost.org/doc/libs/1_71_0/more/getting_started/windows.html

Linux/macOS:www.boost.org/doc/libs/1_71_0/more/getting_started/unix-variants.html

安装代码包

将课程的代码包复制到C:/Code文件夹中。

额外资源

本书的代码包也托管在 GitHub 上,网址为github.com/TrainingByPackt/CPP-Data-Structures-and-Algorithm-Design-Principles

我们还有来自丰富书籍和视频目录的其他代码包,可在github.com/PacktPublishing/上找到。快去看看吧!

第一章:列表、栈和队列

学习目标

在本章结束时,您将能够:

  • 描述在任何应用程序中使用正确数据结构的重要性

  • 根据问题实现各种内置数据结构,以使应用程序开发更加简单

  • 如果 C++提供的数据结构不适合用例,实现适合特定情况的自定义线性数据结构

  • 分析现实生活中的问题,不同类型的线性数据结构如何有帮助,并决定哪种对于给定的用例最合适

本章描述了在任何应用程序中使用正确数据结构的重要性。我们将学习如何在 C++中使用一些最常见的数据结构,以及使用这些结构的内置和自定义容器。

介绍

在设计任何应用程序时,数据管理是需要牢记的最重要考虑因素之一。任何应用程序的目的都是获取一些数据作为输入,对其进行处理或操作,然后提供合适的数据作为输出。例如,让我们考虑一个医院管理系统。在这里,我们可能有关于不同医生、患者和档案记录等的数据。医院管理系统应该允许我们执行各种操作,比如接收患者,并更新不同专业医生的加入和离开情况。虽然用户界面会以对医院管理员相关的格式呈现信息,但在内部,系统会管理不同的记录和项目列表。

程序员可以使用多种结构来保存内存中的任何数据。选择正确的数据结构对于确保可靠性、性能和在应用程序中实现所需功能至关重要。除了正确的数据结构,还需要选择正确的算法来访问和操作数据,以实现应用程序的最佳行为。本书将使您能够为应用程序设计实现正确的数据结构和算法,从而使您能够开发出经过优化和可扩展的应用程序。

本章介绍了 C++中提供的基本和常用的线性数据结构。我们将研究它们的设计、优缺点。我们还将通过练习来实现这些结构。了解这些数据结构将帮助您以更高效、标准化、可读和可维护的方式管理任何应用程序中的数据。

线性数据结构可以广泛地分为连续或链式结构。让我们了解一下两者之间的区别。

连续与链式数据结构

在处理任何应用程序中的数据之前,我们必须决定如何存储数据。对这个问题的答案取决于我们想要对数据执行什么样的操作以及操作的频率。我们应该选择能够在延迟、内存或任何其他参数方面给我们最佳性能的实现,而不影响应用程序的正确性。

确定要使用的数据结构类型的一个有用的度量标准是算法复杂度,也称为时间复杂度。时间复杂度表示执行某个操作所需的时间相对于数据大小的比例。因此,时间复杂度显示了如果我们改变数据集的大小,时间将如何变化。对于任何数据类型上的不同操作的时间复杂度取决于数据在其中的存储方式。

数据结构可以分为两种类型:连续和链式数据结构。我们将在接下来的章节中更仔细地看看它们。

连续数据结构

如前所述,连续数据结构将所有元素存储在单个内存块中。下图显示了连续数据结构中数据的存储方式:

图 1.1:连续数据结构的图示表示

在上图中,考虑较大的矩形是存储所有元素的单个内存块,而较小的矩形表示为每个元素分配的内存。这里需要注意的一点是,所有元素都是相同类型的。因此,它们都需要相同数量的内存,这由sizeof(type)表示。第一个元素的地址也被称为BA + sizeof(type)位置,其后的元素位于BA + 2 * sizeof(type),依此类推。因此,要访问索引i处的任何元素,我们可以使用通用公式获取:BA + i * sizeof(type)

在这种情况下,我们可以立即使用公式访问任何元素,而不管数组的大小如何。因此,访问时间始终是恒定的。这在大 O 符号中用O(1)表示。

数组的两种主要类型是静态和动态。静态数组仅在其声明块内存在,但动态数组提供了更好的灵活性,因为程序员可以确定何时应该分配它,何时应该释放它。根据需求,我们可以选择其中之一。对于不同的操作,它们的性能是相同的。由于这个数组是在 C 中引入的,它也被称为 C 风格数组。以下是这些数组的声明方式:

  • 静态数组声明为int arr[size];

  • C 中声明动态数组为int* arr = (int*)malloc(size * sizeof(int));

  • C++中声明动态数组为int* arr = new int[size];

静态数组是聚合的,这意味着它是在堆栈上分配的,因此在流程离开函数时被释放。另一方面,动态数组是在堆上分配的,并且会一直保留在那里,直到手动释放内存。

由于所有元素都是相邻的,当访问其中一个元素时,它旁边的几个元素也会被带入缓存。因此,如果要访问这些元素,这是一个非常快速的操作,因为数据已经存在于缓存中。这个属性也被称为缓存局部性。虽然它不会影响任何操作的渐近时间复杂度,但在遍历数组时,对于实际上连续的数据,它可以提供令人印象深刻的优势。由于遍历需要顺序地遍历所有元素,获取第一个元素后,接下来的几个元素可以直接从缓存中检索。因此,该数组被认为具有良好的缓存局部性。

链接数据结构

链接数据结构将数据存储在多个内存块中,也称为节点,这些节点可以放置在内存的不同位置。下图显示了链接数据结构中数据的存储方式:

图 1.2:链接数据结构

在链表的基本结构中,每个节点包含要存储在该节点中的数据和指向下一个节点的指针。最后一个节点包含一个NULL指针,表示列表的结尾。要访问任何元素,我们必须从链表的开头,即头部开始,然后沿着下一个指针继续,直到达到预期的元素。因此,要到达索引i处的元素,我们需要遍历链表并迭代i次。因此,我们可以说访问元素的复杂度是O(n);也就是说,时间与节点数成比例变化。

如果我们想要插入或删除任何元素,并且我们有指向该元素的指针,与数组相比,对于链表来说,这个操作是非常小且相当快的。让我们看看在链表中如何插入一个元素。下图说明了在链表中插入两个元素之间的情况:

图 1.3:向链表中插入一个元素

对于插入,一旦我们构造了要插入的新节点,我们只需要重新排列链接,使得前一个元素的下一个指针(i = 1)指向新元素(i = 2),而不是当前元素的当前元素(i = 3),并且新元素(i = 2)的下一个指针指向当前元素的下一个元素(i = 3)。这样,新节点就成为链表的一部分。

同样,如果我们想要删除任何元素,我们只需要重新排列链接,使得要删除的元素不再连接到任何列表元素。然后,我们可以释放该元素或对其采取任何其他适当的操作。

由于链表中的元素不是连续存储在内存中的,所以链表根本无法提供缓存局部性。因此,没有办法将下一个元素带入缓存,而不是通过当前元素中存储的指针实际访问它。因此,尽管在理论上,它的遍历时间复杂度与数组相同,但在实践中,它的性能很差。

以下部分提供了关于连续和链式数据结构的比较总结。

比较

以下表格简要总结了链式和连续数据结构之间的重要区别:

图 1.4:比较连续和链式数据结构的表

以下表格包含了关于数组和链表在各种参数方面的性能总结:

图 1.5:显示数组和链表某些操作的时间复杂度的表

对于任何应用程序,我们可以根据要求和不同操作的频率选择数据结构或两者的组合。

数组和链表是非常常见的,广泛用于任何应用程序中存储数据。因此,这些数据结构的实现必须尽可能无缺陷和高效。为了避免重新编写代码,C++提供了各种结构,如std::arraystd::vectorstd::list。我们将在接下来的章节中更详细地看到其中一些。

C 风格数组的限制

虽然 C 风格的数组可以完成任务,但它们并不常用。有许多限制表明需要更好的解决方案。其中一些主要限制如下:

  • 内存分配和释放必须手动处理。未能释放可能导致内存泄漏,即内存地址变得不可访问。

  • operator[]函数不会检查参数是否大于数组的大小。如果使用不正确,这可能导致分段错误或内存损坏。

  • 嵌套数组的语法变得非常复杂,导致代码难以阅读。

  • 默认情况下不提供深拷贝功能,必须手动实现。

为了避免这些问题,C++提供了一个非常薄的包装器,称为std::array,覆盖了 C 风格数组。

std::array

std::array自动分配和释放内存。std::array是一个带有两个参数的模板类——元素的类型和数组的大小。

在下面的例子中,我们将声明大小为10int类型的std::array,设置任何一个元素的值,然后打印该值以确保它能正常工作:

std::array<int, 10> arr;        // array of int of size 10
arr[0] = 1;                    // Sets the first element as 1
std::cout << "First element: " << arr[0] << std::endl;
std::array<int, 4> arr2 = {1, 2, 3, 4};
std::cout << "Elements in second array: ";
  for(int i = 0; i < arr.size(); i++)
    std::cout << arr2[i] << " ";

这个例子将产生以下输出:

First element: 1
Elements in second array: 1 2 3 4 

正如我们所看到的,std::array提供了operator[],与 C 风格数组相同,以避免检查索引是否小于数组的大小的成本。此外,它还提供了一个名为at(index)的函数,如果参数无效,则会抛出异常。通过这种方式,我们可以适当地处理异常。因此,如果我们有一段代码,其中将访问一个具有一定不确定性的元素,例如依赖于用户输入的数组索引,我们总是可以使用异常处理来捕获错误,就像以下示例中演示的那样。

try
{
    std::cout << arr.at(4);    // No error
    std::cout << arr.at(5);    // Throws exception std::out_of_range
}
catch (const std::out_of_range& ex)
{
    std::cerr << ex.what();
}

除此之外,将std::array传递给另一个函数类似于传递任何内置数据类型。我们可以按值或引用传递它,可以使用const也可以不使用。此外,语法不涉及任何指针相关操作或引用和解引用操作。因此,与 C 风格数组相比,即使是多维数组,可读性要好得多。以下示例演示了如何按值传递数组:

void print(std::array<int, 5> arr)
{
    for(auto ele: arr)
    {
        std::cout << ele << ", ";
    }
}
std::array<int, 5> arr = {1, 2, 3, 4, 5};
print(arr);

这个例子将产生以下输出:

1, 2, 3, 4, 5

我们不能将任何其他大小的数组传递给这个函数,因为数组的大小是函数参数数据类型的一部分。因此,例如,如果我们传递std::array<int, 10>,编译器将返回一个错误,说它无法匹配函数参数,也无法从一个类型转换为另一个类型。然而,如果我们想要一个通用函数,可以处理任何大小的std::array,我们可以使该函数的数组大小成为模板化,并且它将为所需大小的数组生成代码。因此,签名将如下所示:

template <size_t N>
void print(const std::array<int, N>& arr)

除了可读性之外,在传递std::array时,默认情况下会将所有元素复制到一个新数组中。因此,会执行自动深复制。如果我们不想要这个特性,我们总是可以使用其他类型,比如引用和const引用。因此,它为程序员提供了更大的灵活性。

在实践中,对于大多数操作,std::array提供与 C 风格数组类似的性能,因为它只是一个薄包装器,减少了程序员的工作量并使代码更安全。std::array提供两个不同的函数来访问数组元素——operator[]at()operator[]类似于 C 风格数组,并且不对索引进行任何检查。然而,at()函数对索引进行检查,如果索引超出范围,则抛出异常。因此,在实践中它会慢一些。

如前所述,迭代数组是一个非常常见的操作。std::array通过范围循环和迭代器提供了一个非常好的接口。因此,打印数组中所有元素的代码如下所示:

std::array<int, 5> arr = {1, 2, 3, 4, 5};
for(auto element: arr)
{
    std::cout << element << ' ';
}

这个例子将显示以下输出:

1 2 3 4 5 

在前面的示例中,当我们演示打印所有元素时,我们使用了一个索引变量进行迭代,我们必须确保它根据数组的大小正确使用。因此,与这个示例相比,它更容易出现人为错误。

我们可以使用范围循环迭代std::array是因为迭代器。std::array有名为begin()end()的成员函数,返回访问第一个和最后一个元素的方法。为了从一个元素移动到下一个元素,它还提供了算术运算符,比如递增运算符(++)和加法运算符(+)。因此,范围循环从begin()开始,到end()结束,使用递增运算符(++)逐步前进。迭代器为所有动态可迭代的 STL 容器提供了统一的接口,比如std::arraystd::vectorstd::mapstd::setstd::list

除了迭代之外,所有需要在容器内指定位置的函数都基于迭代器;例如,在特定位置插入、在范围内或特定位置删除元素以及其他类似的函数。这使得代码更具可重用性、可维护性和可读性。

注意

对于 C++中使用迭代器指定范围的所有函数,start()迭代器通常是包含的,而end()迭代器通常是排除的,除非另有说明。

因此,array::begin()函数返回一个指向第一个元素的迭代器,但array::end()返回一个指向最后一个元素之后的迭代器。因此,可以编写基于范围的循环如下:

for(auto it = arr.begin(); it != arr.end(); it++)
{
    auto element = (*it);
    std::cout << element << ' ';
}

还有一些其他形式的迭代器,比如const_iteratorreverse_iterator,它们也非常有用。const_iterator是正常迭代器的const版本。如果数组被声明为const,与迭代器相关的函数(如begin()end())会返回const_iterator

reverse_iterator允许我们以相反的方向遍历数组。因此,它的函数,如增量运算符(++)和advance,是正常迭代器的逆操作。

除了operator[]at()函数外,std::array还提供了其他访问器,如下表所示:

图 1.6:显示std::array的一些访问器

以下代码片段演示了这些函数的使用:

std::array<int, 5> arr = {1, 2, 3, 4, 5};
std::cout << arr.front() << std::endl;       // Prints 1
std::cout << arr.back() << std::endl;        // Prints 5
std::cout << *(arr.data() + 1) << std::endl; // Prints 2

std::array提供的另一个有用功能是用于深度比较的关系运算符和用于深度复制的复制赋值运算符。所有大小运算符(<><=>===!=)都被定义用于比较两个数组,前提是相同的运算符也被提供给std::array的基础类型。

C 风格数组也支持所有关系运算符,但这些运算符实际上并不比较数组内部的元素;事实上,它们只是比较指针。因此,只是将元素的地址作为整数进行比较,而不是对数组进行深度比较。这也被称为浅比较,并且并不太实用。同样,赋值也不会创建分配数据的副本。相反,它只是创建一个指向相同数据的新指针。

注意

关系运算符仅适用于相同大小的std::array。这是因为数组的大小是数据类型本身的一部分,它不允许比较两种不同数据类型的值。

在下面的示例中,我们将看到如何包装由用户定义大小的 C 风格数组。

练习 1:实现动态大小数组

让我们编写一个小型应用程序来管理学校中学生的记录。班级中的学生数量和他们的详细信息将作为输入给出。编写一个类似数组的容器来管理数据,该容器还可以支持动态大小。我们还将实现一些实用函数来合并不同的班级。

执行以下步骤以完成练习:

  1. 首先,包括所需的头文件:
#include <iostream>
#include <sstream>
#include <algorithm>
  1. 现在,让我们编写一个名为dynamic_array的基本模板结构,以及主要数据成员:
template <typename T>
class dynamic_array
{
    T* data;
    size_t n;
  1. 现在,让我们添加一个接受数组大小并复制它的构造函数:
public:
dynamic_array(int n)
{
    this->n = n;
    data = new T[n];
}
    dynamic_array(const dynamic_array<T>& other)
  {
    n = other.n;
    data = new T[n];
    for(int i = 0; i < n; i++)
    data[i] = other[i];
  }
  1. 现在,让我们在public访问器中添加operator[]function()来支持直接访问数据,类似于std::array
T& operator[](int index)
{
    return data[index];
}
const T& operator[](int index) const
{
    return data[index];
}
T& at(int index)
{
    if(index < n)
    return data[index];
    throw "Index out of range";
}
  1. 现在,让我们添加一个名为size()的函数来返回数组的大小,以及一个析构函数来避免内存泄漏:
size_t size() const
{
    return n;
}
~dynamic_array()
{
    delete[] data;   // A destructor to prevent memory leak
}
  1. 现在,让我们添加迭代器函数来支持基于范围的循环,以便遍历dynamic_array
T* begin()
{
    return data;
}
const T* begin() const
{
    return data;
}
T* end()
{
    return data + n;
}
const T* end() const
{
    return data + n;
}
  1. 现在,让我们添加一个函数,使用+运算符将一个数组追加到另一个数组中。让我们将其保持为friend函数以提高可用性:
friend dynamic_array<T> operator+(const dynamic_array<T>& arr1, dynamic_array<T>& arr2)
{
    dynamic_array<T> result(arr1.size() + arr2.size());
    std::copy(arr1.begin(), arr1.end(), result.begin());
    std::copy(arr2.begin(), arr2.end(), result.begin() + arr1.size());
    return result;
}
  1. 现在,让我们添加一个名为to_string的函数,它接受一个分隔符作为参数,默认值为“,”:
std::string to_string(const std::string& sep = ", ")
{
  if(n == 0)
    return "";
  std::ostringstream os;
  os << data[0];
  for(int i = 1; i < n; i++)
    os << sep << data[i];
  return os.str();
}
};
  1. 现在,让我们为学生添加一个struct。我们将只保留姓名和标准(即学生所在的年级/班级)以简化,并添加operator<<以正确打印它:
struct student
{
    std::string name;
    int standard;
};
std::ostream& operator<<(std::ostream& os, const student& s)
{
    return (os << "[Name: " << s.name << ", Standard: " << s.standard << "]");
}
  1. 现在,让我们添加一个main函数来使用这个数组:
int main()
{
    int nStudents;
    std::cout << "Enter number of students in class 1: ";
    std::cin >> nStudents;
dynamic_array<student> class1(nStudents);
for(int i = 0; i < nStudents; i++)
{
    std::cout << "Enter name and class of student " << i + 1 << ": ";
    std::string name;
    int standard;
    std::cin >> name >> standard;
    class1[i] = student{name, standard};
}
// Now, let's try to access the student out of range in the array
try
{
    class1[nStudents] = student{"John", 8};  // No exception, undefined behavior
    std::cout << "class1 student set out of range without exception" << std::endl;
    class1.at(nStudents) = student{"John", 8};  // Will throw exception
}
catch(...)
{
std::cout << "Exception caught" << std::endl;
}
auto class2 = class1;  // Deep copy
    std::cout << "Second class after initialized using first array: " << class2.to_string() << std::endl;
    auto class3 = class1 + class2;
    // Combines both classes and creates a bigger one
    std::cout << "Combined class: ";
    std::cout << class3.to_string() << std::endl;
    return 0;
}
  1. 使用三个学生Raj(8)Rahul(10),和Viraj(6)作为输入执行上述代码。在控制台中输出如下:
Enter number of students in class 1 : 3
Enter name and class of student 1: Raj 8
Enter name and class of student 2: Rahul 10
Enter name and class of student 3: Viraj 6
class1 student set out of range without exception
Exception caught
Second class after initialized using first array : [Name: Raj, Standard: 8], [Name: Rahul, Standard: 10], [Name: Viraj, Standard: 6]
Combined class : [Name: Raj, Standard: 8], [Name: Rahul, Standard: 10], [Name: Viraj, Standard: 6], [Name: Raj, Standard: 8], [Name: Rahul, Standard: 10], [Name: Viraj, Standard: 6]

这里提到的大多数函数都有类似于std::array的实现。

现在我们已经看到了各种容器,接下来我们将学习如何实现一个容器,它可以接受任何类型的数据并以通用形式存储在下一个练习中。

练习 2:通用且快速的数据存储容器构建器

在这个练习中,我们将编写一个函数,该函数接受任意数量的任意类型的元素,这些元素可以转换为一个通用类型。该函数还应返回一个包含所有元素转换为该通用类型的容器,并且遍历速度应该很快:

  1. 让我们首先包括所需的库:
#include <iostream>
#include <array>
#include <type_traits>
  1. 首先,我们将尝试构建函数的签名。由于返回类型是一个快速遍历的容器,我们将使用std::array。为了允许任意数量的参数,我们将使用可变模板:
template<typename ... Args>
std::array<?,?> build_array(Args&&... args)

考虑到返回类型的容器应该是快速遍历的要求,我们可以选择数组或向量。由于元素的数量在编译时基于函数的参数数量是已知的,我们可以继续使用std::array

  1. 现在,我们必须为std::array提供元素的类型和元素的数量。我们可以使用std::common_type模板来找出std::array内部元素的类型。由于这取决于参数,我们将函数的返回类型作为尾随类型提供:
template<typename ... Args>
auto build_array(Args&&... args) -> std::array<typename std::common_type<Args...>::type, ?>
{
    using commonType = typename std::common_type<Args...>::type;
    // Create array
}
  1. 如前面的代码所示,我们现在需要弄清楚两件事——元素的数量,以及如何使用commonType创建数组:
template< typename ... Args>
auto build_array(Args&&... args) -> std::array<typename std::common_type<Args...>::type, sizeof...(args)>
{
    using commonType = typename std::common_type<Args...>::type;
    return {std::forward<commonType>(args)...};
}
  1. 现在,让我们编写main函数来看看我们的函数如何工作:
int main()
{
    auto data = build_array(1, 0u, 'a', 3.2f, false);
    for(auto i: data)
        std::cout << i << " ";
    std::cout << std::endl;
}
  1. 运行代码应该得到以下输出:
1 0 97 3.2 0

正如我们所看到的,所有最终输出都是浮点数形式,因为一切都可以转换为浮点数。

  1. 为了进一步测试,我们可以在main函数中添加以下内容并测试输出:
auto data2 = build_array(1, "Packt", 2.0);

通过这种修改,我们应该会得到一个错误,说所有类型都无法转换为通用类型。确切的错误消息应该提到模板推导失败。这是因为没有单一类型可以将字符串和数字都转换为。

构建器函数,比如我们在这个练习中创建的函数,可以在你不确定数据类型但需要优化效率时使用。

std::array没有提供许多有用的功能和实用函数。其中一个主要原因是为了保持与 C 风格数组相比类似或更好的性能和内存需求。

对于更高级的功能和灵活性,C++提供了另一个称为std::vector的结构。我们将在下一节中看看它是如何工作的。

std::vector

正如我们之前看到的,std::array相对于 C 风格数组是一个真正的改进。但是std::array也有一些局限性,在某些常见的应用程序编写用例中缺乏函数。以下是std::array的一些主要缺点:

  • std::array的大小必须是常量且在编译时提供,并且是固定的。因此,我们无法在运行时更改它。

  • 由于大小限制,我们无法向数组中插入或删除元素。

  • std::array不允许自定义分配。它总是使用堆栈内存。

在大多数现实生活应用中,数据是非常动态的,而不是固定大小的。例如,在我们之前的医院管理系统示例中,我们可能会有更多的医生加入医院,我们可能会有更多的急诊病人等。因此,提前知道数据的大小并不总是可能的。因此,std::array并不总是最佳选择,我们需要一些具有动态大小的东西。

现在,我们将看一下std::vector如何解决这些问题。

std::vector - 变长数组

正如标题所示,std::vector解决了数组的一个最突出的问题 - 固定大小。在初始化时,std::vector不需要我们提供其长度。

以下是一些初始化向量的方法:

std::vector<int> vec;
// Declares vector of size 0
std::vector<int> vec = {1, 2, 3, 4, 5};
// Declares vector of size 5 with provided elements
std::vector<int> vec(10);
// Declares vector of size 10
std::vector<int> vec(10, 5);
// Declares vector of size 10 with each element's value = 5

正如我们从第一个初始化中看到的,提供大小并不是强制的。如果我们没有明确指定大小,并且没有通过指定元素来推断大小,向量将根据编译器的实现初始化元素的容量。术语“大小”指的是向量中实际存在的元素数量,这可能与其容量不同。因此,对于第一次初始化,大小将为零,但容量可能是一些小数字或零。

我们可以使用push_backinsert函数在向量中插入元素。push_back会在末尾插入元素。insert以迭代器作为第一个参数表示位置,可以用来在任何位置插入元素。push_back是向量中非常常用的函数,因为它的性能很好。push_back的伪代码如下:

push_back(val):
    if size < capacity
    // If vector has enough space to accommodate this element
    - Set element after the current last element = val
    - Increment size
    - return; 
    if vector is already full
    - Allocate memory of size 2*size
    - Copy/Move elements to newly allocated memory
    - Make original data point to new memory
    - Insert the element at the end

实际的实现可能会有所不同,但逻辑是相同的。正如我们所看到的,如果有足够的空间,向后插入元素只需要O(1)的时间。但是,如果没有足够的空间,它将不得不复制/移动所有元素,这将需要O(n)的时间。大多数实现在容量不足时会将向量的大小加倍。因此,O(n)的时间操作是在 n 个元素之后进行的。因此,平均而言,它只需要额外的一步,使其平均时间复杂度更接近O(1)。实际上,这提供了相当不错的性能,因此它是一个被广泛使用的容器。

对于insert函数,除了将给定迭代器后面的元素向右移动之外,没有其他选项。insert函数会为我们完成这些操作。它还会在需要时进行重新分配。由于需要移动元素,它的时间复杂度为O(n)。以下示例演示了如何实现向量插入函数。

考虑一个包含前五个自然数的向量:

std::vector<int> vec = {1, 2, 3, 4, 5};

注意

向量没有push_front函数。它有通用的insert函数,它以迭代器作为参数表示位置。

通用的insert函数可以用来在前面插入元素,如下所示:

vec.insert(int.begin(), 0);

让我们看一些push_backinsert函数的更多示例:

std::vector<int> vec;
// Empty vector {}
vec.push_back(1);
// Vector has one element {1}
vec.push_back(2);
// Vector has 2 elements {1, 2}
vec.insert(vec.begin(), 0);
// Vector has 3 elements {0, 1, 2}
vec.insert(find(vec.begin(), vec.end(), 1), 4);
// Vector has 4 elements {0, 4, 1, 2}

如前面的代码所示,push_back在末尾插入元素。此外,insert函数以插入位置作为参数。它以迭代器的形式接受。因此,begin()函数允许我们在开头插入元素。

现在我们已经了解了常规插入函数,让我们来看一些更好的替代方案,与push_backinsert函数相比,这些替代方案对于向量来说更好。push_backinsert的一个缺点是它们首先构造元素,然后将元素复制或移动到向量缓冲区内的新位置。这个操作可以通过在新位置本身调用构造函数来优化,这可以通过emplace_backemplace函数来实现。建议您使用这些函数而不是普通的插入函数以获得更好的性能。由于我们是就地构造元素,我们只需要传递构造函数参数,而不是构造的值本身。然后,函数将负责将参数转发到适当位置的构造函数。

std::vector还提供了pop_backerase函数来从中删除元素。pop_back从向量中删除最后一个元素,有效地减小了大小。erase有两种重载方式 - 通过指向单个元素的迭代器来删除该元素,以及通过迭代器提供的元素范围来删除元素,其中范围由定义要删除的第一个元素(包括)和要删除的最后一个元素(不包括)来定义。C++标准不要求这些函数减少向量的容量。这完全取决于编译器的实现。pop_back不需要对元素进行重新排列,因此可以非常快速地完成。它的复杂度是O(1)。然而,erase需要对元素进行移动,因此需要O(n)的时间。在接下来的练习中,我们将看到这些函数是如何实现的。

现在,让我们看一个关于不同方式从向量中删除元素的示例:

考虑一个有 10 个元素的向量 - {0, 1, 2, 3, 4, 5, 6, 7, 8, 9}:

vec.pop_back();
// Vector has now 9 elements {0, 1, 2, 3, 4, 5, 6, 7, 8}
vec.erase(vec.begin());
// vector has now 7 elements {1, 2, 3, 4, 5, 6, 7, 8}
vec.erase(vec.begin() + 1, vec.begin() + 4);
// Now, vector has 4 elements {1, 5, 6, 7, 8}

现在,让我们来看一些其他有用的函数:

  • clear(): 这个函数通过删除所有元素来简单地清空向量。

  • reserve(capacity): 这个函数用于指定向量的容量。如果指定的参数值大于当前容量,它将重新分配内存,新的容量将等于参数。然而,对于所有其他情况,它不会影响向量的容量。这个函数不会修改向量的大小。

  • shrink_to_fit(): 这个函数可以用来释放额外的空间。调用这个函数后,大小和容量变得相等。当我们不希望向量的大小进一步增加时,可以使用这个函数。

std::vector 的分配器

std::vector通过允许我们在数据类型之后将分配器作为模板参数传递来解决了std::array关于自定义分配器的缺点。

为了使用自定义分配器,我们遵循一些概念和接口。由于向量使用分配器函数来处理与内存访问相关的大部分行为,我们需要将这些函数作为分配器的一部分提供 - allocatedeallocateconstructdestroy。这个分配器将负责内存分配、释放和处理,以免损坏任何数据。对于高级应用程序,其中依赖自动内存管理机制可能太昂贵,而应用程序拥有自己的内存池或类似资源必须使用而不是默认的堆内存时,自定义分配器非常方便。

因此,std::vectorstd::array的一个非常好的替代品,并在大小、增长和其他方面提供了更多的灵活性。从渐近的角度来看,数组的所有类似函数的时间复杂度与向量相同。我们通常只为额外的功能付出额外的性能成本,这是相当合理的。在平均情况下,向量的性能与数组的性能相差不大。因此,在实践中,由于其灵活性和性能,std::vector是 C++中最常用的 STL 容器之一。

std::forward_list

到目前为止,我们只看到了类似数组的结构,但是,正如我们所看到的,对于连续数据结构来说,在数据结构的中间进行插入和删除是非常低效的操作。这就是链表结构的作用所在。许多应用程序需要在数据结构的中间频繁进行插入和删除。例如,任何具有多个选项卡的浏览器都可以在任何时间点和任何位置添加额外的选项卡。同样,任何音乐播放器都会有一个可以循环播放的歌曲列表,并且您还可以在其中插入任何歌曲。在这种情况下,我们可以使用链表结构来获得良好的性能。我们将在Activity 1中看到音乐播放器的用例,实现歌曲播放列表。现在,让我们探索 C++为我们提供了哪些类型的容器。

链表的基本结构要求我们使用指针,并手动使用newdelete运算符来管理内存分配和释放。虽然这并不困难,但可能会导致难以追踪的错误。因此,就像std::array提供了对 C 风格数组的薄包装一样,std::forward_list提供了对基本链表的薄包装。

std::forward_list的目的是在不影响性能的情况下提供一些额外的功能,与基本链表相比。为了保持性能,它不提供获取列表大小或直接获取除第一个元素之外的任何元素的函数。因此,它有一个名为front()的函数,用于获取对第一个元素的引用,但没有像back()那样访问最后一个元素的函数。它确实提供了常见操作的函数,如插入、删除、反转和拼接。这些函数不会影响基本链表的内存需求或性能。

此外,就像std::vector一样,如果需要,std::forward_list也可以接受自定义分配器作为第二个模板参数。因此,我们可以轻松地将其用于受益于自定义内存管理的高级应用程序。

forward_list中插入和删除元素

std::forward_list提供了push_frontinsert_after函数,可用于在链表中插入元素。这两个函数与向量的插入函数略有不同。push_front用于在前面插入元素。由于forward_list无法直接访问最后一个元素,因此它不提供push_back函数。对于特定位置的插入,我们使用insert_after而不是insert。这是因为在链表中插入元素需要更新元素的下一个指针,然后我们想要插入一个新元素。如果我们只提供要插入新元素的迭代器,我们无法快速访问前一个元素,因为在forward_list中不允许向后遍历。

由于这是基于指针的机制,因此在插入期间我们实际上不需要移动元素。因此,这两个插入函数与任何基于数组的结构相比要快得多。这两个函数只是修改指针以在预期位置插入新元素。这个操作不依赖于列表的大小,因此时间复杂度为O(1)。我们将在接下来的练习中看一下这些函数的实现。

现在,让我们看看如何在链表中插入元素:

std::forward_list<int> fwd_list = {1, 2, 3};
fwd_list.push_front(0);
// list becomes {0, 1, 2, 3}
auto it = fwd_list.begin();
fwd_list.insert_after(it, 5);
// list becomes {0, 5, 1, 2, 3}
fwd_list.insert_after(it, 6);
// list becomes {0, 6, 5, 1, 2, 3}

forward_list还提供了emplace_frontemplace_after,类似于向量的emplace。这两个函数都与插入函数做相同的事情,但通过避免额外的复制和移动来更有效地执行。

forward_list还具有pop_fronterase_after函数用于删除元素。pop_front如其名称所示,删除第一个元素。由于不需要任何移动,实际上操作非常快,时间复杂度为O(1)erase_after有两个重载 - 通过取其前一个元素的迭代器来删除单个元素,以及通过取范围的第一个元素之前的迭代器和最后一个元素的另一个迭代器来删除多个元素。

erase_after函数的时间复杂度与被删除的元素数量成正比,因为无法通过释放单个内存块来删除元素。由于所有节点都分散在内存中的随机位置,函数需要分别释放每个节点。

现在,让我们看看如何从列表中删除元素:

std::forward_list<int> fwd_list = {1, 2, 3, 4, 5};
fwd_list.pop_front();
// list becomes {2, 3, 4, 5}
auto it = fwd_list.begin();
fwd_list.erase_after(it);
// list becomes {2, 4, 5}
fwd_list.erase_after(it, fwd_list.end());
// list becomes {2}

让我们在下一节中探讨forward_list可以进行的其他操作。

forward_list 上的其他操作

除了根据迭代器确定位置来删除元素的erase函数外,forward_list还提供了removeremove_if函数来根据其值删除元素。remove函数接受一个参数 - 要删除的元素的值。它会删除所有与给定元素匹配的元素,基于该值类型定义的相等运算符。如果没有相等运算符,编译器将不允许我们调用该函数,并抛出编译错误。由于remove仅根据相等运算符删除元素,因此无法根据其他条件使用它进行删除,因为我们无法在定义一次后更改相等运算符。对于条件删除,forward_list提供了remove_if函数。它接受一个谓词作为参数,该谓词是一个接受值类型元素作为参数并返回布尔值的函数。因此,谓词返回 true 的所有元素都将从列表中删除。使用最新的 C++版本,我们也可以使用 lambda 轻松指定谓词。以下练习应该帮助你了解如何实现这些函数。

练习 3:使用 remove_if 条件删除链表中的元素

在这个练习中,我们将使用印度选民的样本信息,并根据他们的年龄从选民名单中删除不合格的公民。为简单起见,我们只存储公民的姓名和年龄。

我们将在链表中存储数据,并使用remove_if删除所需的元素,该函数提供了一种删除满足特定条件的元素的方法,而不是定义要删除的元素的位置:

  1. 让我们首先包含所需的头文件并添加struct citizen
#include <iostream>
#include <forward_list>
struct citizen
{
    std::string name;
    int age;
};
std::ostream& operator<<(std::ostream& os, const citizen& c)
{
    return (os << "[Name: " << c.name << ", Age: " << c.age << "]");
}
  1. 现在,让我们编写一个main函数,并在std::forward_list中初始化一些公民。我们还将对其进行复制,以避免再次初始化:
int main()
{
  std::forward_list<citizen> citizens = {{"Raj", 22}, {"Rohit", 25}, {"Rohan", 17}, {"Sachin", 16}};
  auto citizens_copy = citizens;
  std::cout << "All the citizens: ";
  for (const auto &c : citizens)
      std::cout << c << " ";
  std::cout << std::endl;
  1. 现在,让我们从列表中删除所有不合格的公民:
citizens.remove_if(
    [](const citizen& c)
    {
        return (c.age < 18);
    });
std::cout << "Eligible citizens for voting: ";
for(const auto& c: citizens)
    std::cout << c << " ";
std::cout << std::endl;

remove_if函数会删除所有满足给定条件的元素。在这里,我们提供了一个 lambda,因为条件非常简单。如果条件很复杂,我们也可以编写一个接受链表底层类型的参数并返回布尔值的普通函数。

  1. 现在,让我们找出明年有资格投票的人:
citizens_copy.remove_if(
    [](const citizen& c)
    {
    // Returns true if age is less than 18
        return (c.age != 17);
    });
std::cout << "Citizens that will be eligible for voting next year: ";
for(const auto& c: citizens_copy)
    std::cout << c << " ";
std::cout << std::endl;
}

正如你所看到的,我们只保留那些年龄为 17 岁的公民。

  1. 运行练习。你应该会得到这样的输出:
All the citizens: [Name: Raj, Age: 22] [Name: Rohit, Age: 25] [Name: Rohan, Age: 17] [Name: Sachin, Age: 16] 
Eligible citizens for voting: [Name: Raj, Age: 22] [Name: Rohit, Age: 25] 
Citizens that will be eligible for voting next year: [Name: Rohan, Age: 17] 

remove_if函数的时间复杂度为O(n),因为它只需遍历列表一次,同时根据需要删除所有元素。如果我们想要删除具有特定值的元素,我们可以使用remove的另一个版本,它只需要一个对象的参数,并删除列表中与给定值匹配的所有对象。它还要求我们为给定类型实现==运算符。

forward_list还提供了一个sort函数来对数据进行排序。所有与数组相关的结构都可以通过通用函数std::sort(first iterator, last iterator)进行排序。然而,它不能被链表结构使用,因为我们无法随机访问任何数据。这也使得forward_list提供的迭代器与数组或向量的迭代器不同。我们将在下一节中更详细地看一下这一点。forward_list提供的sort函数有两个重载版本 - 基于小于运算符(<)的sort,以及基于作为参数提供的比较器的sort。默认的sort函数使用std::less<value_type>进行比较。如果第一个参数小于第二个参数,则简单地返回true,因此,需要我们为自定义类型定义小于运算符(<)。

此外,如果我们想要基于其他参数进行比较,我们可以使用参数化重载,它接受一个二元谓词。这两个重载的时间复杂度都是线性对数级的 - O(n × log n)。以下示例演示了sort的两个重载:

std::forward_list<int> list1 = {23, 0, 1, -3, 34, 32};
list1.sort();
// list becomes {-3, 0, 1, 23, 32, 34}
list1.sort(std::greater<int>());
// list becomes {34, 32, 23, 1, 0, -3}

在这里,greater<int>是标准库中提供的一个谓词,它是对大于运算符(>)的包装器,用于将元素按降序排序,正如我们从列表的值中所看到的。

forward_list中提供的其他函数包括reverseuniquereverse函数简单地颠倒元素的顺序,其时间复杂度与列表中元素的数量成正比,即时间复杂度为O(n)unique函数仅保留列表中的唯一元素,并删除除第一个元素外的所有重复值函数。由于它依赖于元素的相等性,它有两个重载版本 - 第一个不带参数,使用值类型的相等运算符,而第二个带有两个值类型参数的二元谓词。unique函数的时间复杂度是线性的。因此,它不会将每个元素与其他每个元素进行比较。相反,它只会比较连续的元素是否相等,并根据默认或自定义的二元谓词删除后一个元素。因此,要使用unique函数从列表中删除所有唯一元素,我们需要在调用函数之前对元素进行排序。借助给定的谓词,unique将比较所有元素与其相邻元素,并在谓词返回true时删除后一个元素。

现在让我们看看如何使用reversesortunique函数来操作列表:

std::forward_list<int> list1 = {2, 53, 1, 0, 4, 10};
list1.reverse();
// list becomes {2, 53, 1, 0, 4, 10}
list1 = {0, 1, 0, 1, -1, 10, 5, 10, 5, 0};
list1.sort();
// list becomes {-1, 0, 0, 0, 1, 1, 5, 5, 10, 10}
list1.unique();
// list becomes {-1, 0, 1, 5, 10}
list1 = {0, 1, 0, 1, -1, 10, 5, 10, 5, 0};
list1.sort();
// list becomes {-1, 0, 0, 0, 1, 1, 5, 5, 10, 10}

以下示例将删除元素,如果它们与之前的有效元素相比至少相差 2:

list1.unique([](int a, int b) { return (b - a) < 2; });
// list becomes {-1, 1, 5, 10}

注意

在调用unique函数之前,程序员必须确保数据已经排序。因此,在调用unique函数之前,我们会先调用sort函数。unique函数将元素与已满足条件的前一个元素进行比较。此外,它始终保留原始列表的第一个元素。因此,总是有一个元素可以进行比较。

在下一节中,我们将看一看forward_list迭代器与向量/数组迭代器的不同之处。

迭代器

正如您可能已经注意到的,在一些数组和向量的例子中,我们向迭代器添加数字。迭代器类似于指针,但它们还为 STL 容器提供了一个公共接口。这些迭代器上的操作严格基于迭代器的类型,这取决于容器。对于向量和数组的迭代器在功能上是最灵活的。我们可以根据位置直接访问容器中的任何元素,使用operator[],因为数据的连续性。这个迭代器也被称为随机访问迭代器。然而,对于forward_list,没有直接的方法可以向后遍历,甚至从一个节点到其前一个节点,而不是从头开始。因此,这个迭代器允许的唯一算术运算符是增量。这个迭代器也被称为前向迭代器。

还有其他实用函数可以使用,比如advancenextprev,取决于迭代器的类型。nextprev接受一个迭代器和一个距离值,然后返回指向距离给定迭代器给定距离的元素的迭代器。这在给定迭代器支持该操作的情况下可以正常工作。例如,如果我们尝试使用prev函数与forward迭代器,它将抛出编译错误,因为这个迭代器是一个前向迭代器,只能向前移动。这些函数所花费的时间取决于所使用的迭代器的类型。对于随机访问迭代器,所有这些都是常数时间函数,因为加法和减法都是常数时间操作。对于其余的迭代器,所有这些都是线性的,需要向前或向后遍历的距离。我们将在接下来的练习中使用这些迭代器。

练习 4:探索不同类型的迭代器

让我们假设我们有一份新加坡 F1 大奖赛近年来的获奖者名单。借助向量迭代器的帮助,我们将发现如何从这些数据中检索有用的信息。之后,我们将尝试使用forward_list做同样的事情,并看看它与向量迭代器有何不同:

  1. 让我们首先包含头文件:
#include <iostream>
#include <forward_list>
#include <vector>
int main()
{
  1. 让我们写一个包含获奖者名单的向量:
std::vector<std::string> vec = {"Lewis Hamilton", "Lewis Hamilton", "Nico Roseberg", "Sebastian Vettel", "Lewis Hamilton", "Sebastian Vettel", "Sebastian Vettel", "Sebastian Vettel", "Fernando Alonso"};
auto it = vec.begin();       // Constant time
std::cout << "Latest winner is: " << *it << std::endl;
it += 8;                    // Constant time
std::cout << "Winner before 8 years was: " << *it << std::endl;
advance(it, -3);            // Constant time
std::cout << "Winner before 3 years of that was: " << *it << std::endl;
  1. 让我们尝试使用forward_list迭代器做同样的事情,并看看它们与向量迭代器有何不同:
std::forward_list<std::string> fwd(vec.begin(), vec.end());
auto it1 = fwd.begin();
std::cout << "Latest winner is: " << *it << std::endl;
advance(it1, 5);   // Time taken is proportional to the number of elements
std::cout << "Winner before 5 years was: " << *it << std::endl;
// Going back will result in compile time error as forward_list only allows us to move towards the end.
// advance(it1, -2);      // Compiler error
}
  1. 运行这个练习应该产生以下输出:
Latest winner is : Lewis Hamilton
Winner before 8 years was : Fernando Alonso
Winner before 3 years of that was : Sebastian Vettel
Latest winner is : Sebastian Vettel
Winner before 5 years was : Sebastian Vettel
  1. 现在,让我们看看如果我们在main函数的末尾放入以下行会发生什么:
it1 += 2;

我们将得到类似于这样的错误消息:

no match for 'operator+=' (operand types are std::_Fwd_list_iterator<int>' and 'int')

我们在这个练习中探索的各种迭代器对于轻松获取数据集中的任何数据非常有用。

正如我们所见,std::array是 C 风格数组的一个薄包装器,std::forward_list只是一个薄包装器,它提供了一个简单且不易出错的接口,而不会影响性能或内存。

除此之外,由于我们可以立即访问向量中的任何元素,因此向量迭代器的加法和减法操作为O(1)。另一方面,forward_list只支持通过遍历访问元素。因此,它的迭代器的加法操作为O(n),其中 n 是我们正在前进的步数。

在接下来的练习中,我们将制作一个自定义容器,其工作方式类似于std::forward_list,但具有一些改进。我们将定义许多等效于forward_list函数的函数。这也应该帮助您了解这些函数在底层是如何工作的。

练习 5:构建基本自定义容器

在这个练习中,我们将实现一个带有一些改进的std::forward_list等效容器。我们将从一个名为singly_ll的基本实现开始,并逐渐不断改进:

  1. 让我们添加所需的头文件,然后从一个单节点开始基本实现singly_ll
#include <iostream>
#include <algorithm>
struct singly_ll_node
{
    int data;
    singly_ll_node* next;
};
  1. 现在,我们将实现实际的singly_ll类,它将节点包装起来以便更好地进行接口设计。
class singly_ll
{
public:
    using node = singly_ll_node;
    using node_ptr = node*;
private:
    node_ptr head;
  1. 现在,让我们添加push_frontpop_front,就像在forward_list中一样:
public:
void push_front(int val)
{
    auto new_node = new node{val, NULL};
    if(head != NULL)
        new_node->next = head;
    head = new_node;
}
void pop_front()
{
    auto first = head;
    if(head)
    {
        head = head->next;
        delete first;
    }
    else
        throw "Empty ";
}
  1. 现在让我们为我们的singly_ll类实现一个基本的迭代器,包括构造函数和访问器:
struct singly_ll_iterator
{
private:
    node_ptr ptr;
public:
    singly_ll_iterator(node_ptr p) : ptr(p)
    {
}
int& operator*()
{
    return ptr->data;
}
node_ptr get()
{
    return ptr;
}
  1. 让我们为前置和后置递增添加operator++函数:
singly_ll_iterator& operator++()     // pre-increment
{
        ptr = ptr->next;
        return *this;
}
singly_ll_iterator operator++(int)    // post-increment
{
    singly_ll_iterator result = *this;
++(*this);
return result;
}
  1. 让我们添加等式操作作为friend函数:
    friend bool operator==(const singly_ll_iterator& left, const singly_ll_iterator& right)
    {
        return left.ptr == right.ptr;
    }
    friend bool operator!=(const singly_ll_iterator& left, const singly_ll_iterator& right)
    {
        return left.ptr != right.ptr;
    }
};
  1. 让我们回到我们的链表类。现在我们已经有了迭代器类,让我们实现beginend函数来方便遍历。我们还将为两者添加const版本:
singly_ll_iterator begin()
{
    return singly_ll_iterator(head);
}
singly_ll_iterator end()
{
    return singly_ll_iterator(NULL);
}
singly_ll_iterator begin() const
{
    return singly_ll_iterator(head);
}
singly_ll_iterator end() const
{
    return singly_ll_iterator(NULL);
}
  1. 让我们实现一个默认构造函数,一个用于深度复制的复制构造函数,以及一个带有initializer_list的构造函数:
singly_ll() = default;
singly_ll(const singly_ll& other) : head(NULL)
{
    if(other.head)
        {
            head = new node;
            auto cur = head;
            auto it = other.begin();
            while(true)
            {
                cur->data = *it;
                auto tmp = it;
                ++tmp;
                if(tmp == other.end())
                    break;
                cur->next = new node;
                cur = cur->next;
                it = tmp;
            }
        }
}
singly_ll(const std::initializer_list<int>& ilist) : head(NULL)
{
    for(auto it = std::rbegin(ilist); it != std::rend(ilist); it++)
            push_front(*it);
}
};
  1. 让我们编写一个main函数来使用前面的函数:
int main()
{
    singly_ll sll = {1, 2, 3};
    sll.push_front(0);
    std::cout << "First list: ";
    for(auto i: sll)
        std::cout << i << " ";
    std::cout << std::endl;

    auto sll2 = sll;
    sll2.push_front(-1);
    std::cout << "Second list after copying from first list and inserting -1 in front: ";
    for(auto i: sll2)
        std::cout << i << ' ';  // Prints -1 0 1 2 3
    std::cout << std::endl;
    std::cout << "First list after copying - deep copy: ";
for(auto i: sll)
        std::cout << i << ' ';  // Prints 0 1 2 3
    std::cout << std::endl;
}
  1. 运行这个练习应该产生以下输出:
First list: 0 1 2 3
Second list after copying from first list and inserting -1 in front: -1 0 1 2 3 
First list after copying - deep copy: 0 1 2 3

正如我们在前面的例子中看到的,我们能够使用std::initializer_list初始化我们的列表。我们可以调用pushpop_frontback函数。正如我们所看到的,sll2.pop_back只从sll2中删除了元素,而不是sllsll仍然保持完整,有五个元素。因此,我们也可以执行深度复制。

活动 1:实现歌曲播放列表

在这个活动中,我们将看一些双向链表不足或不方便的应用。我们将构建一个适合应用的调整版本。我们经常遇到需要自定义默认实现的情况,比如在音乐播放器中循环播放歌曲或者在游戏中多个玩家依次在圈内轮流。

这些应用有一个共同的特点——我们以循环方式遍历序列的元素。因此,在遍历列表时,最后一个节点之后的节点将是第一个节点。这就是所谓的循环链表。

我们将以音乐播放器的用例为例。它应该支持以下功能:

  1. 使用多首歌曲创建一个播放列表。

  2. 向播放列表添加歌曲。

  3. 从播放列表中删除一首歌曲。

  4. 循环播放歌曲(对于这个活动,我们将打印所有歌曲一次)。

注意

您可以参考练习 5构建基本自定义容器,我们在那里从头开始构建了一个支持类似功能的容器。

解决问题的步骤如下:

  1. 首先,设计一个支持循环数据表示的基本结构。

  2. 之后,在结构中实现inserterase函数,以支持各种操作。

  3. 我们必须编写一个自定义迭代器。这有点棘手。重要的是要确保我们能够使用基于范围的方法来遍历容器。因此,begin()end()应该返回不同的地址,尽管结构是循环的。

  4. 构建容器后,再构建一个包装器,它将在播放列表中存储不同的歌曲并执行相关操作,比如nextpreviousprint allinsertremove

注意

这个活动的解决方案可以在第 476 页找到。

std::forward_list有一些限制。std::list提供了更灵活的列表实现,并帮助克服了forward_list的一些缺点。

std::list

正如前面的部分所示,std::forward_list只是一个基本链表的简单包装。它不提供在末尾插入元素、向后遍历或获取列表大小等有用操作。功能受限是为了节省内存并保持快速性能。除此之外,forward_list的迭代器只支持很少的操作。在任何应用的实际情况中,像在容器末尾插入东西和获取容器大小这样的函数是非常有用且经常使用的。因此,当需要快速插入时,std::forward_list并不总是理想的容器。为了克服std::forward_list的这些限制,C++提供了std::list,它由于是双向链表,也被称为双向链表,因此具有几个额外的特性。但是,请注意,这是以额外的内存需求为代价的。

双向链表的普通版本看起来像这样:

struct doubly_linked_list
{
    int data;
    doubly_linked_list *next, *prev;
};

正如你所看到的,它有一个额外的指针指向前一个元素。因此,它为我们提供了一种向后遍历的方式,我们还可以存储大小和最后一个元素以支持快速的push_backsize操作。而且,就像forward_list一样,它也可以支持客户分配器作为模板参数。

std::list的常用函数

std::list的大多数函数要么与std::forward_list的函数相同,要么类似,只是有一些调整。其中一个调整是以_after结尾的函数有没有_after的等价函数。因此,insert_afteremplace_after变成了简单的insertemplace。这是因为,使用std::list迭代器,我们也可以向后遍历,因此不需要提供前一个元素的迭代器。相反,我们可以提供我们想要执行操作的确切元素的迭代器。除此之外,std::list还提供了push_backemplace_backpop_back的快速操作。以下练习演示了std::list的插入和删除函数的使用。

练习 6:std::list的插入和删除函数

在这个练习中,我们将使用std::list创建一个简单的整数列表,并探索各种插入和删除元素的方法:

  1. 首先,让我们包含所需的头文件:
#include <iostream>
#include <list>
int main()
{
  1. 然后,用一些元素初始化一个列表,并用各种插入函数进行实验:
std::list<int> list1 = {1, 2, 3, 4, 5};
list1.push_back(6);
// list becomes {1, 2, 3, 4, 5, 6}
list1.insert(next(list1.begin()), 0);
// list becomes {1, 0, 2, 3, 4, 5, 6}
list1.insert(list1.end(), 7);
// list becomes {1, 0, 2, 3, 4, 5, 6, 7}

正如你所看到的,push_back函数在末尾插入一个元素。insert函数在第一个元素后插入0,这由next(list1.begin())表示。之后,我们在最后一个元素后插入7,这由list1.end()表示。

  1. 现在,让我们来看看pop_back这个删除函数,它在forward_list中不存在:
list1.pop_back();
// list becomes {1, 0, 2, 3, 4, 5, 6}
std::cout << "List after insertion & deletion functions: ";
for(auto i: list1)
    std::cout << i << " ";
}
  1. 运行这个练习应该会得到以下输出:
List after insertion & deletion functions: 1 0 2 3 4 5 6

在这里,我们正在删除刚刚插入的最后一个元素。

注意

尽管push_frontinsertpop_fronterase的时间复杂度与forward_list的等价函数相同,但对于std::list来说,这些函数稍微昂贵一些。原因是列表中每个节点有两个指针,而不是forward_list中的一个。因此,我们需要维护这些指针的有效性。因此,在重新指向这些变量时,我们需要付出几乎是单向链表的两倍的努力。

之前,我们看到了单向链表的插入。现在让我们在下图中演示双向链表的指针操作是什么样子的:

图 1.7:在双向链表中插入元素

正如您所看到的,即使在std::list的情况下,操作的数量也是恒定的;然而,与forward_list相比,为了维护双向链表,我们必须修复prevnext指针,这在内存和性能方面几乎是双倍的成本。其他函数也适用类似的想法。

其他函数,如removeremove_ifsortuniquereverse,提供了与它们在std::forward_list中等效函数相似的功能。

双向迭代器

迭代器部分,我们看到了基于数组的随机访问迭代器和forward_list的前向迭代器之间的灵活性差异。std::list::iterator的灵活性介于两者之间。与前向迭代器相比,它更灵活,因为它允许我们向后遍历。因此,std::list还支持通过暴露反向迭代器来进行反向遍历的函数,其中操作是反转的。话虽如此,它不像随机访问迭代器那样灵活。虽然我们可以向任何方向移动任意数量的步骤,但由于这些步骤必须逐个遍历元素而不是直接跳转到所需的元素,因此时间复杂度仍然是线性的,而不是常数,就像随机访问迭代器的情况一样。由于这些迭代器可以向任何方向移动,它们被称为双向迭代器。

不同容器的迭代器失效

到目前为止,我们已经看到迭代器为我们提供了一种统一的方式来访问、遍历、插入和删除任何容器中的元素。但是在某些情况下,迭代器在修改容器后会变为无效,因为迭代器是基于指针实现的,而指针绑定到内存地址。因此,如果由于容器的修改而改变了任何节点或元素的内存地址,迭代器就会失效,而不管如何使用它都可能导致未定义的行为。

例如,一个非常基本的例子是vector::push_back,它只是在末尾添加一个新元素。然而,正如我们之前所看到的,在某些情况下,它也需要将所有元素移动到一个新的缓冲区。因此,所有迭代器、指针,甚至对任何现有元素的引用都将失效。同样,如果vector::insert函数导致重新分配,所有元素都将需要移动。因此,所有迭代器、指针和引用都将失效。如果不是这样,该函数将使指向插入位置右侧元素的所有迭代器失效,因为这些元素在过程中将被移动。

与向量不同,基于链表的迭代器对于插入和删除操作更安全,因为元素不会被移动或移位。因此,std::listforward_list的所有插入函数都不会影响迭代器的有效性。一个例外是与删除相关的操作会使被删除的元素的迭代器失效,这是显而易见和合理的。它不会影响其余元素的迭代器的有效性。以下示例显示了不同迭代器的失效:

std::vector<int> vec = {1, 2, 3, 4, 5};
auto it4 = vec.begin() + 4;
// it4 now points to vec[4]
vec.insert(vec.begin() + 2, 0);
// vec becomes {1, 2, 0, 3, 4, 5}

it4现在无效,因为它位于插入位置之后。访问它将导致未定义的行为:

std::list<int> lst = {1, 2, 3, 4, 5};
auto l_it4 = next(lst.begin(), 4);
lst.insert(next(lst.begin(), 2), 0);
// l_it4 remains valid

正如我们所看到的,与std::forward_list相比,std::list更加灵活。许多操作,如sizepush_backpop_back,都具有O(1)的时间复杂度。因此,与std::forward_list相比,std::list更常用。如果我们对内存和性能有非常严格的限制,并且确定不需要向后遍历,那么forward_list是一个更好的选择。因此,在大多数情况下,std::list是一个更安全的选择。

活动 2:模拟一场纸牌游戏

在这个活动中,我们将分析一个给定的情况,并尝试找到最适合的数据结构,以实现最佳性能。

我们将尝试模拟一场纸牌游戏。游戏中有 4 名玩家,每个玩家从 13 张随机牌开始。然后,我们将尝试从每个玩家手中随机抽取一张牌。这样,我们将有 4 张牌进行比较。之后,我们将从这 4 张牌中移除匹配的牌。剩下的牌(如果有的话)将由放出的玩家重新抽取。如果有多个匹配对,但只能移除一个,我们可以选择任意一个。如果没有匹配对,玩家可以洗牌。

现在,我们需要一遍又一遍地继续这个过程,直到其中至少有一名玩家没有牌。第一个摆脱所有牌的人赢得比赛。然后,我们将在最后打印获胜者。

执行以下步骤来解决这个活动:

  1. 首先确定哪种容器最适合存储每个玩家的牌。我们应该有四个包含一组牌的容器 - 每个玩家一个。

  2. 编写一个函数来初始化和洗牌。

  3. 编写一个函数,将所有的牌随机分配给四名玩家。

  4. 编写一个匹配函数。这个函数将从每个玩家那里抽取一张牌,并按照游戏规则进行比较。然后,它将移除必要的牌。我们必须明智地选择牌,以便更快地移除它。在决定容器时,也应考虑这个参数。

  5. 现在,让我们编写一个函数,看看是否有获胜者。

  6. 最后,我们将编写游戏的核心逻辑。这将简单地调用匹配函数,直到根据上一步中编写的函数找到获胜者。

注意

这个活动的解决方案可以在第 482 页找到。

std::deque - std::vector 的特殊版本

到目前为止,我们已经看到了基于数组和链表的容器。std::deque将它们两者结合起来,并在一定程度上结合了它们各自的优点。正如我们所见,尽管向量是一个可变长度的数组,但它的一些函数,比如push_frontpop_front,是非常昂贵的操作。std::deque可以帮助我们克服这一点。Deque 是双端队列的缩写。

Deque 的结构

C++标准只定义了容器的行为,而没有实现。到目前为止,我们所见过的容器对于我们来说足够简单,可以预测它们的实现。然而,deque 比这要复杂一些。因此,我们将首先看一下它的要求,然后再尝试深入一点的实现。

C++标准保证 deque 的不同操作的时间复杂度如下:

    • O(1) * 对于push_frontpop_frontpush_backpop_back
    • O(1) * 对于所有元素的随机访问
  • 在插入或删除中,最多 * N/2 * 步骤,其中 * N * = deque 的大小

从要求来看,我们可以说这个容器应该能够快速地向任一方向扩展,并且仍然能够提供对所有元素的随机访问。因此,这个结构必须有点像一个向量,但仍然可以从前面和后面扩展。插入和删除的要求略微暗示了我们将移动元素,因为我们只能走 * N/2 * 步。这也验证了我们之前关于行为类似于向量的假设。由于容器可以快速向任一方向扩展,我们不一定每次都要将元素向右移动。相反,我们可以将元素移向最近的端点。这将给我们一个最多 * N/2 * 步的时间复杂度,因为最近的端点不能比容器内的任何插入点更远超过 * N/2 * 个节点。

现在,让我们专注于随机访问和在前端插入。这种结构无法存储在单个内存块中。相反,我们可以有多个相同大小的内存块。通过这种方式,根据块的索引和大小(或每块元素的数量),我们可以决定我们想要哪个块的索引元素。这有助于我们在O(1)时间内实现随机访问,只要我们将所有内存块的指针存储在连续的位置上。因此,该结构可以被假定为类似于数组的向量。

当我们想要在前面插入一些东西时,如果第一个内存块中没有足够的空间,我们必须分配另一个块,并将其地址插入到指针向量的前面。这可能需要重新分配指针向量,但实际数据不会被移动。为了优化该重新分配,我们可以从向量的中间块开始插入,而不是从第一个块开始。这样,我们可以在一定数量的前端插入中保持安全。在重新分配指针向量时,我们可以采取相同的方法。

注意

由于 deque 不像本章讨论的其他容器那样简单,实际的实现可能会有所不同,或者可能有比我们讨论的更多的优化,但基本思想仍然是一样的。也就是说,我们需要多个连续内存块来实现这样一个容器。

deque 支持的函数和操作更多地是向量和列表支持的函数的组合;因此,我们有push_frontpush_backinsertemplace_frontemplace_backemplacepop_frontpop_backerase等。我们还有向量的函数,比如shrink_to_fit,以优化容量,但我们没有一个叫做capacity的函数,因为这高度依赖于实现,因此不会被暴露。正如你所期望的,它提供了与向量一样的随机访问迭代器。

让我们看看如何在 deque 上使用不同的插入和删除操作:

std::deque<int> deq = {1, 2, 3, 4, 5};
deq.push_front(0);
// deque becomes {0, 1, 2, 3, 4, 5}
deq.push_back(6);
// deque becomes {0, 1, 2, 3, 4, 5, 6}
deq.insert(deq.begin() + 2, 10);
// deque becomes {0, 1, 10, 2, 3, 4, 5, 6}
deq.pop_back();
// deque becomes {0, 1, 10, 2, 3, 4, 5}
deq.pop_front();
// deque becomes {1, 10, 2, 3, 4, 5}
deq.erase(deq.begin() + 1);
// deque becomes {1, 2, 3, 4, 5}
deq.erase(deq.begin() + 3, deq.end());
// deque becomes {1, 2, 3}

这样的结构可以用于飞行登机队列等情况。

容器之间唯一不同的是性能和内存需求。对于插入和删除,deque 在前端和末尾都提供非常好的性能。在中间插入和删除的速度也比向量平均快一点,尽管在渐近意义上,它与向量相同。

除此之外,deque 还允许我们像向量一样拥有自定义分配器。我们可以在初始化时将其指定为第二个模板参数。这里需要注意的一点是,分配器是类型的一部分,而不是对象的一部分。这意味着我们不能比较两个具有不同类型分配器的 deque 或两个向量的对象。同样,我们不能对具有不同类型分配器的对象进行其他操作,比如赋值或复制构造函数。

正如我们所看到的,std::deque与我们之前讨论过的其他容器相比具有稍微复杂的结构。事实上,它是唯一一个既提供高效的随机访问又提供快速的push_frontpush_back函数的容器。Deque 被用作其他容器的底层容器,我们将在接下来的部分中看到。

容器适配器

到目前为止,我们看到的容器都是从头开始构建的。在本节中,我们将看看建立在其他容器之上的容器。提供对现有容器的包装有多种原因,比如为代码提供更多的语义含义,防止某人意外使用不期望的函数,以及提供特定的接口。

一个这样的特定用例是数据结构。栈遵循LIFO(后进先出)结构来访问和处理数据。在功能方面,它只能在容器的一端插入和删除,并且不能更新或甚至访问除了变异端之外的任何元素。这一端被称为栈顶。我们也可以轻松地使用任何其他容器,比如 vector 或 deque,因为它默认可以满足这些要求。然而,这样做会有一些根本性的问题。

以下示例展示了栈的两种实现:

std::deque<int> stk;
stk.push_back(1);  // Pushes 1 on the stack = {1}
stk.push_back(2);  // Pushes 2 on the stack = {1, 2}
stk.pop_back();    // Pops the top element off the stack = {1}
stk.push_front(0); // This operation should not be allowed for a stack
std::stack<int> stk;
stk.push(1);       // Pushes 1 on the stack = {1}
stk.push(2);       // Pushes 2 on the stack = {1, 2}
stk.pop();         // Pops the top element off the stack = {1}
stk.push_front(0); // Compilation error

正如我们在这个例子中所看到的,使用 deque 的栈的第一个块仅通过变量的名称提供了语义上的含义。操作数据的函数仍然不会强迫程序员添加不应该被允许的代码,比如push_front。此外,push_backpop_back函数暴露了不必要的细节,这些细节应该默认情况下就应该知道,因为它是一个栈。

与此相比,如果我们看第二个版本,它看起来更准确地指示了它的功能。而且,最重要的是,它不允许任何人做任何意外的事情。

栈的第二个版本只是通过为用户提供一个良好且受限的接口来包装前一个容器 deque。这被称为容器适配器。C++提供了三个容器适配器:std::stackstd::queuestd::priority_queue。现在让我们简要地看一下它们各自。

std::stack

如前所述,适配器简单地重用其他容器,比如 deque、vector 或其他任何容器。std::stack默认适配std::deque作为其底层容器。它提供了一个仅与 stack 相关的接口——emptysizetoppushpopemplace。在这里,push只是调用底层容器的push_back函数,而pop只是调用pop_back函数。top调用底层容器的back函数来获取最后一个元素,也就是栈顶。因此,它限制了用户操作为 LIFO,因为它只允许我们在底层容器的一端更新值。

在这里,我们使用 deque 作为底层容器,而不是 vector。其背后的原因是 deque 在重新分配时不需要您移动所有元素,而 vector 需要。因此,与 vector 相比,使用 deque 更有效率。然而,如果在某种情况下,任何其他容器更可能提供更好的性能,stack 允许我们将容器作为模板参数提供。因此,我们可以使用 vector 或 list 构建一个 stack,就像这里所示:

std::stack<int, std::vector<int>> stk;
std::stack<int, std::list<int>> stk;

栈的所有操作的时间复杂度都是O(1)。通常不会有将调用转发到底层容器的开销,因为编译器可以通过优化将所有内容内联化。

std::queue

就像std::stack一样,我们还有另一个容器适配器来处理频繁的std::queue场景。它几乎具有与栈相同的一组函数,但意义和行为不同,以遵循 FIFO 而不是 LIFO。对于std::queuepush意味着push_back,就像栈一样,但poppop_front。而不是pop,因为队列应该暴露两端以供读取,它有frontback函数。

以下是std::queue的一个小例子:

std::queue<int> q;
q.push(1);  // queue becomes {1}
q.push(2);  // queue becomes {1, 2}
q.push(3);  // queue becomes {1, 2, 3}
q.pop();    // queue becomes {2, 3}
q.push(4);  // queue becomes {2, 3, 4}

如本例所示,首先,我们按顺序插入123。然后,我们从队列中弹出一个元素。由于1被先推入,所以它首先从队列中移除。然后,下一个推入将4插入到队列的末尾。

std::queue也出于与 stack 相同的原因使用std::deque作为底层容器,它的所有方法的时间复杂度也都是O(1)

std::priority_queue

优先队列通过其接口提供了一个非常有用的结构称为。堆数据结构以快速访问容器中的最小(或最大)元素而闻名。获取最小/最大元素是一个时间复杂度为O(1)的操作。插入的时间复杂度为O(log n),而删除只能针对最小/最大元素进行,它总是位于顶部。

这里需要注意的一点是,我们只能快速获得最小值或最大值函数中的一个,而不是两者都有。这是由提供给容器的比较器决定的。与栈和队列不同,优先队列默认基于向量,但如果需要,我们可以更改它。此外,默认情况下,比较器是std::less。由于这是一个堆,结果容器是一个最大堆。这意味着默认情况下最大元素将位于顶部。

在这里,由于插入需要确保我们可以立即访问顶部元素(根据比较器是最小值还是最大值),它不仅仅是将调用转发给底层容器。相反,它通过使用比较器实现了堆化数据的算法,根据需要将其冒泡到顶部。这个操作的时间复杂度与容器的大小成对数比例,因此时间复杂度为O(log n)。在初始化时也需要保持不变。然而,在这里,priority_queue构造函数不仅仅是为每个元素调用插入函数;相反,它应用不同的堆化算法以在O(n)的时间内更快地完成。

适配器的迭代器

到目前为止我们所见过的所有适配器都只暴露出满足其语义意义所需的功能。从逻辑上讲,遍历栈、队列和优先队列是没有意义的。在任何时候,我们只能看到前面的元素。因此,STL 不为此提供迭代器。

基准测试

正如我们所见,不同的容器有各种优缺点,没有一个容器是每种情况的完美选择。有时,多个容器可能在给定情况下平均表现出类似的性能。在这种情况下,基准测试是我们的朋友。这是一个根据统计数据确定更好方法的过程。

考虑这样一个情景,我们想要在连续的内存中存储数据,访问它,并使用各种函数对其进行操作。我们可以说我们应该使用std::vectorstd::deque中的一个。但我们不确定其中哪一个是最好的。乍一看,它们两个似乎都对这种情况有良好的性能。在不同的操作中,比如访问、插入、push_back和修改特定元素,有些对std::vector有利,有些对std::deque有利。那么,我们应该如何继续?

这个想法是创建一个实际模型的小型原型,并使用std::vectorstd::deque来实现它。然后,测量原型的性能。根据性能测试的结果,我们可以选择总体表现更好的那个。

最简单的方法是测量执行不同操作所需的时间,并比较它们。然而,同样的操作在不同运行时可能需要不同的时间,因为还有其他因素会影响,比如操作系统调度、缓存和中断等。这些参数可能会导致我们的结果相差很大,因为执行任何操作一次只需要几百纳秒。为了克服这一点,我们可以多次执行操作(也就是说,几百万次),直到我们在两次测量之间得到了相当大的时间差异。

有一些基准测试工具可以使用,比如[quick-bench.com],它们为我们提供了一个简单的方法来运行基准测试。您可以尝试在向量和双端队列上快速比较性能差异。

活动 3:模拟办公室中共享打印机的队列

在这个活动中,我们将模拟办公室中共享打印机的队列。在任何公司办公室中,通常打印机是在打印机房间整个楼层共享的。这个房间里的所有计算机都连接到同一台打印机。但是一台打印机一次只能做一项打印工作,而且完成任何工作也需要一些时间。与此同时,其他用户可以发送另一个打印请求。在这种情况下,打印机需要将所有待处理的作业存储在某个地方,以便在当前任务完成后可以处理它们。

执行以下步骤来解决这个活动:

  1. 创建一个名为Job的类(包括作业的 ID、提交作业的用户的名称和页数)。

  2. 创建一个名为Printer的类。这将提供一个接口来添加新的作业并处理到目前为止添加的所有作业。

  3. 要实现printer类,它将需要存储所有待处理的作业。我们将实现一个非常基本的策略 - 先来先服务。谁先提交作业,谁就会第一个完成作业。

  4. 最后,模拟多人向打印机添加作业,并且打印机逐个处理它们的情景。

注意

此活动的解决方案可在第 487 页找到。

总结

在本章中,我们学习了根据需求设计应用程序的方法,选择我们想要存储数据的方式。我们解释了可以对数据执行的不同类型的操作,这些操作可以用作多个数据结构之间比较的参数,基于这些操作的频率。我们了解到容器适配器为我们在代码中指示我们的意图提供了一种非常有用的方式。我们看到,使用更为限制的容器适配器,而不是使用提供更多功能的主要容器,从可维护性的角度来看更有效,并且还可以减少人为错误。我们详细解释了各种数据结构 - std::arraystd::vectorstd::liststd::forward_list,这些数据结构在任何应用程序开发过程中都非常频繁,并且它们的接口是由 C++默认提供的。这帮助我们编写高效的代码,而不需要重新发明整个周期,使整个过程更快。

在本章中,我们看到的所有结构在逻辑上都是线性的,也就是说,我们可以从任何元素向前或向后移动。在下一章中,我们将探讨无法轻松解决这些结构的问题,并实现新类型的结构来解决这些问题。

第二章:树、堆和图

学习目标

在本章结束时,您将能够:

  • 分析和确定非线性数据结构可以使用的地方

  • 实现和操作树结构来表示数据和解决问题

  • 使用各种方法遍历树

  • 实现图结构来表示数据和解决问题

  • 根据给定的场景使用不同的方法表示图

在本章中,我们将看两种非线性数据结构,即树和图,以及它们如何用于表示现实世界的场景和解决各种问题。

介绍

在上一章中,我们实现了不同类型的线性数据结构,以线性方式存储和管理数据。在线性结构中,我们最多可以沿着两个方向遍历 - 向前或向后。然而,这些结构的范围非常有限,不能用来解决高级问题。在本章中,我们将探讨更高级的问题。我们将看到我们之前实现的解决方案不足以直接使用。因此,我们将扩展这些数据结构,以创建更复杂的结构,用于表示非线性数据。

在查看了这些问题之后,我们将讨论使用数据结构的基本解决方案。我们将实现不同类型的树来解决不同类型的问题。之后,我们将看一种特殊类型的树,称为,以及它的可能实现和应用。接下来,我们将看另一种复杂结构 - 。我们将实现图的两种不同表示。这些结构有助于将现实世界的场景转化为数学形式。然后,我们将应用我们的编程技能和技术来解决与这些场景相关的问题。

对树和图有深刻的理解是理解更高级问题的基础。数据库(B 树)、数据编码/压缩(哈夫曼树)、图着色、分配问题、最小距离问题等许多问题都是使用树和图的某些变体来解决的。

现在,让我们看一些不能用线性数据结构表示的问题的例子。

非线性问题

无法使用线性数据结构表示的两种主要情况是分层问题和循环依赖。让我们更仔细地看看这些情况。

分层问题

让我们看一些固有分层属性的例子。以下是一个组织的结构:

图 2.1:组织结构

图 2.1:组织结构

正如我们所看到的,CEO 是公司的负责人,管理副总监。副总监领导其他三名官员,依此类推。

数据本质上是分层的。使用简单的数组、向量或链表来管理这种类型的数据是困难的。为了巩固我们的理解,让我们看另一个用例;即,大学课程的结构,如下图所示:

图 2.2:大学课程结构中的课程层次结构

前面的图显示了一个假设大学中一些课程的课程依赖关系。正如我们所看到的,要学习高等物理 II,学生必须成功完成以下课程:高等物理和高等数学。同样,许多其他课程也有它们自己的先决条件。

有了这样的数据,我们可以有不同类型的查询。例如,我们可能想找出需要成功完成哪些课程,以便学习高等数学。

这些问题可以使用一种称为树的数据结构来解决。所有的对象都被称为树的节点,而从一个节点到另一个节点的路径被称为边。我们将在本章后面的部分更深入地研究这一点。

循环依赖

让我们来看另一个可以用非线性结构更好地表示的复杂现实场景。以下图表示了几个人之间的友谊:

图 2.3:朋友网络

图 2.3:朋友网络

这种结构称为图。人的名字,或元素,称为节点,它们之间的关系表示为边。各种社交网络通常使用这样的结构来表示他们的用户及其之间的连接。我们可以观察到 Alice 和 Charlie 是朋友,Charlie 和 Eddard 是朋友,Eddard 和 Grace 是朋友,依此类推。我们还可以推断 Alice、Bob 和 Charlie 彼此认识。我们还可以推断 Eddard 是 Grace 的一级连接,Charlie 是二级连接,Alice 和 Bob 是三级连接。

图表在图表部分中的另一个有用的领域是当我们想要表示城市之间的道路网络时,您将在本章后面的图表部分中看到。

树-它颠倒了!

正如我们在上一节中讨论的那样,树只是通过关系连接到其他节点的一些对象或节点,从而产生某种层次结构。如果我们要以图形方式显示这种层次结构,它看起来像一棵树,而不同的边缘看起来像它的分支。主节点,不依赖于任何其他节点,也被称为根节点,并通常表示在顶部。因此,与实际树不同,这棵树是颠倒的,根在顶部!

让我们尝试构建一个非常基本版本的组织层次结构的结构。

练习 7:创建组织结构

在这个练习中,我们将实现我们在本章开头看到的组织树的基本版本。让我们开始吧:

  1. 首先,让我们包括所需的标头:
#include <iostream>
#include <queue>
  1. 为简单起见,我们假设任何人最多可以有两个下属。我们将看到这不难扩展以类似于现实生活中的情况。这种树也被称为二叉树。让我们为此编写一个基本结构:
struct node
{
    std::string position;
    node *first, *second;
};

正如我们所看到的,任何节点都将有两个链接到其他节点-它们的下属。通过这样做,我们可以显示数据的递归结构。我们目前只存储位置,但我们可以轻松扩展此功能,以包括该位置的名称,甚至包括关于该位置的人的所有信息的整个结构。

  1. 我们不希望最终用户处理这种原始数据结构。因此,让我们将其包装在一个名为org_tree的良好接口中:
struct org_tree
{
    node *root;
  1. 现在,让我们添加一个函数来创建根,从公司的最高指挥官开始:
static org_tree create_org_structure(const std::string& pos)
{
    org_tree tree;
    tree.root = new node{pos, NULL, NULL};
    return tree;
}

这只是一个静态函数,用于创建树。现在,让我们看看如何扩展树。

  1. 现在,我们想要添加一个员工的下属。该函数应该接受两个参数-树中已存在的员工的名字和要添加为下属的新员工的名字。但在此之前,让我们编写另一个函数,以便更容易地找到基于值的特定节点来帮助我们编写插入函数:
static node* find(node* root, const std::string& value)
{
    if(root == NULL)
        return NULL;
    if(root->position == value)
        return root;
    auto firstFound = org_tree::find(root->first, value);
    if(firstFound != NULL)
        return firstFound;
    return org_tree::find(root->second, value);
}

当我们在搜索元素时遍历树时,要么元素将是我们所在的节点,要么它将在右子树或左子树中。

因此,我们需要首先检查根节点。如果不是所需的节点,我们将尝试在左子树中找到它。最后,如果我们没有成功做到这一点,我们将查看右子树。

  1. 现在,让我们实现插入函数。我们将利用find函数以便重用代码:
bool addSubordinate(const std::string& manager, const std::string& subordinate)
{
    auto managerNode = org_tree::find(root, manager);
    if(!managerNode)
    {
        std::cout << "No position named " << manager << std::endl;
        return false;
    }
    if(managerNode->first && managerNode->second)
    {
        std::cout << manager << " already has 2 subordinates." << std::endl;
        return false;
    }
    if(!managerNode->first)
        managerNode->first = new node{subordinate, NULL, NULL};
    else
        managerNode->second = new node{subordinate, NULL, NULL};
    return true;
}
};

正如我们所看到的,该函数返回一个布尔值,指示我们是否可以成功插入节点。

  1. 现在,让我们使用此代码在main函数中创建一棵树:
int main()
{
    auto tree = org_tree::create_org_structure("CEO");
    if(tree.addSubordinate("CEO", "Deputy Director"))
        std::cout << "Added Deputy Director in the tree." << std::endl;
    else
        std::cout << "Couldn't add Deputy Director in the tree" << std::endl;
    if(tree.addSubordinate("Deputy Director", "IT Head"))
        std::cout << "Added IT Head in the tree." << std::endl;
    else
        std::cout << "Couldn't add IT Head in the tree" << std::endl;
    if(tree.addSubordinate("Deputy Director", "Marketing Head"))
        std::cout << "Added Marketing Head in the tree." << std::endl;
    else
        std::cout << "Couldn't add Marketing Head in the tree" << std::endl;
    if(tree.addSubordinate("IT Head", "Security Head"))
        std::cout << "Added Security Head in the tree." << std::endl;
    else
        std::cout << "Couldn't add Security Head in the tree" << std::endl;
    if(tree.addSubordinate("IT Head", "App Development Head"))
        std::cout << "Added App Development Head in the tree." << std::endl;
    else
        std::cout << "Couldn't add App Development Head in the tree" << std::endl;
if(tree.addSubordinate("Marketing Head", "Logistics Head"))
        std::cout << "Added Logistics Head in the tree." << std::endl;
    else
        std::cout << "Couldn't add Logistics Head in the tree" << std::endl;
    if(tree.addSubordinate("Marketing Head", "Public Relations Head"))
        std::cout << "Added Public Relations Head in the tree." << std::endl;
    else
        std::cout << "Couldn't add Public Relations Head in the tree" << std::endl;
    if(tree.addSubordinate("Deputy Director", "Finance Head"))
        std::cout << "Added Finance Head in the tree." << std::endl;
    else
        std::cout << "Couldn't add Finance Head in the tree" << std::endl;
}

在执行上述代码后,您应该获得以下输出:

Added Deputy Director in the tree.
Added IT Head in the tree.
Added Marketing Head in the tree.
Added Security Head in the tree.
Added App Development Head in the tree.
Added Logistics Head in the tree.
Added Public Relations Head in the tree.
Deputy Director already has 2 subordinates.
Couldn't add Finance Head in the tree

此输出在以下图表中说明:

图 2.4:基于组织层次结构的二叉家谱树

图 2.4:基于组织层次结构的二叉树

到目前为止,我们只是插入了元素。现在,我们将看看如何遍历树。虽然我们已经看到了如何使用find函数进行遍历,但这只是我们可以做的其中一种方式。我们可以以许多其他方式遍历树,所有这些方式我们将在下一节中看到。

遍历树

一旦我们有了一棵树,就有各种方法可以遍历它并到达我们需要的节点。让我们简要看一下各种遍历方法:

  • 先序遍历:在这种方法中,我们首先访问当前节点,然后是当前节点的左子节点,然后是当前节点的右子节点,以递归的方式。这里,前缀“pre”表示父节点在其子节点之前被访问。使用先序方法遍历图 2.4中显示的树如下:
CEO, Deputy Director, IT Head, Security Head, App Development Head, Marketing Head, Logistics Head, Public Relations Head,

正如我们所看到的,我们总是先访问父节点,然后是左子节点,然后是右子节点。我们不仅对根节点是这样,对于任何节点都是这样。我们使用以下函数实现前序遍历:

static void preOrder(node* start)
{
    if(!start)
        return;
    std::cout << start->position << ", ";
    preOrder(start->first);
    preOrder(start->second);
}
  • 中序遍历:在这种遍历中,首先访问左节点,然后是父节点,最后是右节点。遍历图 2.4中显示的树如下:
Security Head, IT Head, App Development Head, Deputy Director, Logistics Head, Marketing Head, Public Relations Head, CEO, 

我们可以这样实现一个函数:

static void inOrder(node* start)
{
    if(!start)
        return;
    inOrder(start->first);
std::cout << start->position << ", ";
    inOrder(start->second);
}
  • 后序遍历:在这种遍历中,我们首先访问两个子节点,然后是父节点。遍历图 2.4中显示的树如下:
Security Head, App Development Head, IT Head, Logistics Head, Public Relations Head, Marketing Head, Deputy Director, CEO, 

我们可以这样实现一个函数:

static void postOrder(node* start)
{
    if(!start)
        return;
    postOrder(start->first);
    postOrder(start->second);
    std::cout << start->position << ", ";
}
  • 层次遍历:这要求我们逐层遍历树,从顶部到底部,从左到右。这类似于列出树的每个级别的元素,从根级别开始。这种遍历的结果通常表示为每个级别,如下所示:
CEO, 
Deputy Director, 
IT Head, Marketing Head, 
Security Head, App Development Head, Logistics Head, Public Relations Head, 

这种遍历方法的实现在以下练习中演示。

练习 8:演示层次遍历

在这个练习中,我们将在练习 7中创建的组织结构中实现层次遍历。与先前的遍历方法不同,这里我们不是直接遍历到当前节点直接连接的节点。这意味着遍历更容易实现而不需要递归。我们将扩展练习 7中显示的代码来演示这种遍历。让我们开始吧:

  1. 首先,我们将在练习 7中的org_tree结构中添加以下函数:
static void levelOrder(node* start)
{
    if(!start)
        return;
    std::queue<node*> q;
    q.push(start);
    while(!q.empty())
    {
        int size = q.size();
        for(int i = 0; i < size; i++)
        {
            auto current = q.front();
            q.pop();
            std::cout << current->position << ", ";
            if(current->first)
                q.push(current->first);
            if(current->second)
                q.push(current->second);
        }
        std::cout << std::endl;
    }
}

如前面的代码所示,首先我们遍历根节点,然后是它的子节点。在访问子节点时,我们将它们的子节点推入队列中,以便在当前级别完成后处理。这个想法是从第一级开始队列,并将下一级的节点添加到队列中。我们将继续这样做,直到队列为空,表示下一级没有更多的节点。

  1. 我们的输出应该是这样的:
CEO, 
Deputy Director, 
IT Head, Marketing Head, 
Security Head, App Development Head, Logistics Head, Public Relations Head, 

树的变体

在以前的练习中,我们主要看了二叉树,这是最常见的树之一。在二叉树中,每个节点最多可以有两个子节点。然而,普通的二叉树并不总是满足这个目的。接下来,我们将看一下二叉树的更专业版本,称为二叉搜索树。

二叉搜索树

二叉搜索树BST)是二叉树的一种流行版本。BST 只是具有以下属性的二叉树:

  • 父节点的值≥左子节点的值

  • 父节点的值≤右子节点的值

简而言之,左子节点≤父节点≤右子节点。

这带我们到一个有趣的特性。在任何时候,我们总是可以说小于或等于父节点的所有元素将在左侧,而大于或等于父节点的所有元素将在右侧。因此,搜索元素的问题在每一步中都会减少一半,就搜索空间而言。

如果 BST 构造成除了最后一级的所有元素都有两个子节点的方式,树的高度将为log n,其中n是元素的数量。由于这个原因,搜索和插入的时间复杂度将为O(log n)。这种二叉树也被称为完全二叉树

在 BST 中搜索

让我们看看如何在二叉搜索树中搜索、插入和删除元素。考虑一个具有唯一正整数的 BST,如下图所示:

图 2.5:在二叉搜索树中搜索元素

图 2.5:在二叉搜索树中搜索元素

假设我们要搜索 7。从前面图中箭头表示的步骤中可以看出,我们在比较值与当前节点数据后选择侧边。正如我们已经提到的,左侧的所有节点始终小于当前节点,右侧的所有节点始终大于当前节点。

因此,我们首先将根节点与 7 进行比较。如果大于 7,则移动到左子树,因为那里的所有元素都小于父节点,反之亦然。我们比较每个子节点,直到我们遇到 7,或者小于 7 且没有右节点的节点。在这种情况下,来到节点 4 会导致我们的目标 7。

正如我们所看到的,我们并没有遍历整个树。相反,每次当前节点不是所需节点时,我们通过选择左侧或右侧来减少我们的范围一半。这类似于对线性结构进行二分搜索,我们将在第四章“分而治之”中学习。

向 BST 中插入新元素

现在,让我们看看插入是如何工作的。步骤如下图所示:

图 2.6:向二叉搜索树插入元素

图 2.6:向二叉搜索树插入元素

正如您所看到的,首先我们必须找到要插入新值的父节点。因此,我们必须采取与搜索相似的方法;也就是说,通过根据每个节点与我们的新元素进行比较的方向前进,从根节点开始。在最后一步,18 大于 17,但 17 没有右子节点。因此,我们在那个位置插入 18。

从 BST 中删除元素

现在,让我们看看删除是如何工作的。考虑以下 BST:

图 2.7:根节点为 12 的二叉搜索树

图 2.7:根节点为 12 的二叉搜索树

我们将删除树中的根节点 12。让我们看看如何删除任何值。这比插入要棘手,因为我们需要找到已删除节点的替代品,以使 BST 的属性保持真实。

第一步是找到要删除的节点。之后,有三种可能性:

  • 节点没有子节点:只需删除节点。

  • 节点只有一个子节点:将父节点的相应指针指向唯一存在的子节点。

  • 节点有两个子节点:在这种情况下,我们用它的后继替换当前节点。

后继是当前节点之后的下一个最大数。换句话说,后继是所有大于当前元素的所有元素中最小的元素。因此,我们首先转到右子树,其中包含所有大于当前元素的元素,并找到其中最小的元素。找到最小的节点意味着尽可能多地向子树的左侧移动,因为左子节点始终小于其父节点。在图 2.7中显示的树中,12 的右子树从 18 开始。因此,我们从那里开始查找,然后尝试向 15 的左子节点移动。但是 15 没有左子节点,另一个子节点 16 大于 15。因此,15 应该是这里的后继。

要用 15 替换 12,首先,我们将复制根节点处的后继的值,同时删除 12,如下图所示:

图 2.8:后继复制到根节点

图 2.8:后继复制到根节点

接下来,我们需要从右子树中删除后继 15,如下图所示:

图 2.9:从旧位置删除的后继

图 2.9:从旧位置删除的后继

在最后一步中,我们正在删除节点 15。我们对此删除使用相同的过程。由于 15 只有一个子节点,我们将用 15 的子节点替换 18 的左子节点。因此,以 16 为根的整个子树成为 18 的左子节点。

注意

后继节点最多只能有一个子节点。如果它有一个左子节点,我们将选择该子节点而不是当前节点作为后继。

树上操作的时间复杂度

现在,让我们看看这些函数的时间复杂度。理论上,我们可以说每次将搜索范围减半。因此,搜索具有n个节点的 BST 所需的时间为T(n) = T(n / 2) + 1。这个方程导致时间复杂度为T(n) = O(log n)

但这里有一个问题。如果我们仔细看插入函数,插入的顺序实际上决定了树的形状。并不一定总是减半搜索范围,如前面公式中的T(n/2)所描述的那样。因此,复杂度O(log n)并不总是准确的。我们将在平衡树部分更深入地研究这个问题及其解决方案,我们将看到如何更准确地计算时间复杂度。

现在,让我们在 C++中实现我们刚刚看到的操作。

练习 9:实现二叉搜索树

在这个练习中,我们将实现图 2.7中显示的 BST,并添加一个“查找”函数来搜索元素。我们还将尝试在前面的子节中解释的插入和删除元素。让我们开始吧:

  1. 首先,让我们包括所需的头文件:
#include <iostream>
  1. 现在,让我们写一个节点。这将类似于我们之前的练习,只是我们将有一个整数而不是一个字符串:
struct node
{
    int data;
    node *left, *right;
};
  1. 现在,让我们在节点上添加一个包装器,以提供一个清晰的接口:
struct bst
{
    node* root = nullptr;
  1. 在编写插入函数之前,我们需要编写“查找”函数:
node* find(int value)
{
    return find_impl(root, value);
}
    private:
node* find_impl(node* current, int value)
{
    if(!current)
    {
        std::cout << std::endl;
        return NULL;
    }
    if(current->data == value)
    {
        std::cout << "Found " << value << std::endl;
        return current;
    }
    if(value < current->data)  // Value will be in the left subtree
    {
        std::cout << "Going left from " << current->data << ", ";
        return find_impl(current->left, value);
    }
    if(value > current->data) // Value will be in the right subtree
    {
        std::cout << "Going right from " << current->data << ", ";
        return find_impl(current->right, value);
    }
}

由于这是递归的,我们将实现放在一个单独的函数中,并将其设置为私有,以防止有人直接使用它。

  1. 现在,让我们编写一个“插入”函数。它将类似于“查找”函数,但有一些小调整。首先,让我们找到父节点,这是我们想要插入新值的地方:
public:
void insert(int value)
{
    if(!root)
        root = new node{value, NULL, NULL};
    else
        insert_impl(root, value);
}
private:
void insert_impl(node* current, int value)
{
    if(value < current->data)
    {
        if(!current->left)
            current->left = new node{value, NULL, NULL};
        else
            insert_impl(current->left, value);
    }
    else
    {
        if(!current->right)
            current->right = new node{value, NULL, NULL};
            else
                insert_impl(current->right, value);
    }
}

正如我们所看到的,我们正在检查值应该插入左侧还是右侧子树。如果所需侧面没有任何内容,我们直接在那里插入节点;否则,我们递归调用该侧的“插入”函数。

  1. 现在,让我们编写一个“中序”遍历函数。中序遍历在应用于 BST 时提供了重要的优势,正如我们将在输出中看到的:
public:
void inorder()
{
    inorder_impl(root);
}
private:
void inorder_impl(node* start)
{
    if(!start)
        return;
    inorder_impl(start->left);        // Visit the left sub-tree
    std::cout << start->data << " ";  // Print out the current node
    inorder_impl(start->right);       // Visit the right sub-tree
}
  1. 现在,让我们实现一个实用函数来获取后继:
public:
node* successor(node* start)
{
    auto current = start->right;
    while(current && current->left)
        current = current->left;
    return current;
}

这遵循了我们在删除 BST 中的元素子节中讨论的逻辑。

  1. 现在,让我们看一下delete的实际实现。由于删除需要重新指向父节点,我们将通过每次返回新节点来执行此操作。我们将通过在其上放置更好的接口来隐藏这种复杂性。我们将命名接口为deleteValue,因为delete是 C++标准中的保留关键字:
void deleteValue(int value)
{
    root = delete_impl(root, value);
}
private:
node* delete_impl(node* start, int value)
{
    if(!start)
        return NULL;
    if(value < start->data)
        start->left = delete_impl(start->left, value);
    else if(value > start->data)
        start->right = delete_impl(start->right, value);
    else
    {
        if(!start->left)  // Either both children are absent or only left child is absent
        {
            auto tmp = start->right;
            delete start;
            return tmp;
        }
        if(!start->right)  // Only right child is absent
        {
            auto tmp = start->left;
            delete start;
            return tmp;
        }
        auto succNode = successor(start);
        start->data = succNode->data;
        // Delete the successor from right subtree, since it will always be in the right subtree
        start->right = delete_impl(start->right, succNode->data);
    }
    return start;
}
};
  1. 让我们编写main函数,以便我们可以使用 BST:
int main()
{
    bst tree;
    tree.insert(12);
    tree.insert(10);
    tree.insert(20);
    tree.insert(8);
    tree.insert(11);
    tree.insert(15);
    tree.insert(28);
    tree.insert(4);
    tree.insert(2);
    std::cout << "Inorder: ";
    tree.inorder();  // This will print all the elements in ascending order
    std::cout << std::endl;
    tree.deleteValue(12);
    std::cout << "Inorder after deleting 12: ";
    tree.inorder();  // This will print all the elements in ascending order
    std::cout << std::endl;
    if(tree.find(12))
        std::cout << "Element 12 is present in the tree" << std::endl;
    else
        std::cout << "Element 12 is NOT present in the tree" << std::endl;
}

执行上述代码的输出应该如下所示:

Inorder: 2 4 8 10 11 12 15 20 28 
Inorder after deleting 12: 2 4 8 10 11 15 20 28 
Going left from 15, Going right from 10, Going right from 11, 
Element 12 is NOT present in the tree

观察 BST 的中序遍历结果。中序遍历将首先访问左子树,然后是当前节点,然后是右子树,如代码片段中的注释所示。因此,根据 BST 的属性,我们将首先访问所有小于当前值的值,然后是当前值,然后我们将访问所有大于当前值的值。由于这是递归进行的,我们将按升序排序获取我们的数据。

平衡树

在我们理解平衡树之前,让我们从以下插入顺序的 BST 示例开始:

bst tree;
tree.insert(10);
tree.insert(9);
tree.insert(11);
tree.insert(8);
tree.insert(7);
tree.insert(6);
tree.insert(5);
tree.insert(4);

可以使用以下图来可视化这个 BST:

图 2.10:倾斜的二叉搜索树

图 2.10:倾斜的二叉搜索树

如前图所示,几乎整个树都向左倾斜。如果我们调用find函数,即bst.find(4),步骤将如下所示:

图 2.11:在倾斜的二叉搜索树中查找元素

图 2.11:在倾斜的二叉搜索树中查找元素

正如我们所看到的,步骤数几乎等于元素数。现在,让我们尝试以不同的插入顺序再次尝试相同的操作,如下所示:

bst tree;
tree.insert(7);
tree.insert(5);
tree.insert(9);
tree.insert(4);
tree.insert(6);
tree.insert(10);
tree.insert(11);
tree.insert(8);

现在,查找元素 4 所需的 BST 和步骤如下:

图 2.12:在平衡树中查找元素

图 2.12:在平衡树中查找元素

正如我们所看到的,树不再倾斜。换句话说,树是平衡的。通过这种配置,查找 4 的步骤已经大大减少。因此,find的时间复杂度不仅取决于元素的数量,还取决于它们在树中的配置。如果我们仔细观察步骤,我们在搜索时总是朝树的底部前进一步。最后,我们将到达叶节点(没有任何子节点的节点)。在这里,我们根据元素的可用性返回所需的节点或 NULL。因此,我们可以说步骤数始终小于 BST 的最大级别数,也称为 BST 的高度。因此,查找元素的实际时间复杂度为 O(height)。

为了优化时间复杂度,我们需要优化树的高度。这也被称为平衡树。其思想是在插入/删除后重新组织节点以减少树的倾斜程度。结果树称为高度平衡 BST。

我们可以以各种方式执行此操作并获得不同类型的树,例如 AVL 树、红黑树等。AVL 树的思想是执行一些旋转以平衡树的高度,同时仍保持 BST 的属性。考虑下面图中显示的例子:

图 2.13:旋转树

图 2.13:旋转树

正如我们所看到的,右侧的树与左侧的树相比更加平衡。旋转超出了本书的范围,因此我们不会深入探讨这个例子的细节。

N 叉树

到目前为止,我们主要看到了二叉树或其变体。对于 N 叉树,每个节点可以有N个子节点。由于N在这里是任意的,我们将其存储在一个向量中。因此,最终的结构看起来像这样:

struct nTree
{
    int data;
    std::vector<nTree*> children;
};

正如我们所看到的,每个节点可以有任意数量的子节点。因此,整个树是完全任意的。然而,就像普通的二叉树一样,普通的 N 叉树也不是很有用。因此,我们必须为不同类型的应用构建不同的树,其中的层次结构比二叉树的度要高。图 2.1中所示的例子代表了一个组织的层次结构,是一个 N 叉树。

在计算机世界中,有两种非常好的、著名的 N 叉树实现,如下所示:

  • 计算机中的文件系统结构:从 Linux 中的root/)或 Windows 中的驱动器开始,我们可以在任何文件夹内拥有任意数量的文件(终端节点)和任意数量的文件夹。我们将在活动 1,为文件系统创建数据结构中更详细地讨论这一点。

  • 编译器:大多数编译器根据源代码的语法构建抽象语法树(AST)。编译器通过解析 AST 生成低级别代码。

活动 4:为文件系统创建数据结构

使用 N 叉树创建一个文件系统的数据结构,支持以下操作:转到目录,查找文件/目录,添加文件/目录和列出文件/目录。我们的树将保存文件系统中所有元素(文件和文件夹)的信息和文件夹层次结构(路径)。

执行以下步骤来解决此活动:

  1. 创建一个 N 叉树,其中一个节点中有两个数据元素-目录/文件的名称和指示它是目录还是文件的标志。

  2. 添加一个数据成员来存储当前目录。

  3. 用单个目录根(/)初始化树。

  4. 添加查找目录/文件的函数,它接受一个参数-pathpath可以是绝对的(以/开头)或相对的。

  5. 添加函数以添加文件/目录并列出位于给定路径的文件/目录。

  6. 同样,添加一个函数来更改当前目录。

注意

此活动的解决方案可在第 490 页找到。

我们已经打印了带有d的目录,以区分它们与文件,文件是以"-"(连字符)开头打印的。您可以通过创建具有绝对或相对路径的更多目录和文件来进行实验。

到目前为止,我们还没有支持某些 Linux 约定,例如用单个点寻址任何目录和用双点寻址父目录。这可以通过扩展我们的节点来完成,以便还保存指向其父节点的指针。这样,我们可以非常容易地在两个方向上遍历。还有其他各种可能的扩展,例如添加符号链接,以及使用"*"扩展各种文件/目录名称的通配符操作符。这个练习为我们提供了一个基础,这样我们就可以根据自己的需求构建一些东西。

在上一章中,我们简要介绍了堆以及 C++如何通过 STL 提供堆。在本章中,我们将更深入地了解堆。简而言之,以下是预期的时间复杂度:

  • O(1):立即访问最大元素

  • O(log n):插入任何元素

  • O(log n):删除最大元素

为了实现O(log n)的插入/删除,我们将使用树来存储数据。但在这种情况下,我们将使用完全树。完全树被定义为一个树,其中除了最后一层以外的所有级别的节点都有两个子节点,并且最后一层尽可能多地在左侧具有元素。例如,考虑以下图中显示的两棵树:

图 2.14:完全树与非完全树

图 2.14:完全树与非完全树

因此,可以通过在最后一级插入元素来构建完整的树,只要那里有足够的空间。如果没有,我们将在新级别的最左边位置插入它们。这给了我们一个很好的机会,可以使用数组逐级存储这棵树。因此,树的根将是数组/向量的第一个元素,其后是其左孩子,然后是右孩子,依此类推。与其他树不同,这是一种非常高效的内存结构,因为不需要额外的内存来存储指针。要从父节点到其子节点,我们可以轻松地使用数组的索引。如果父节点是第i个节点,其子节点将始终是2i + 12i + 2索引。同样,我们可以通过使用(i – 1) / 2来获取第i个子节点的父节点。我们也可以从前面的图中确认这一点。

现在,让我们看看我们需要在每次插入/删除时保持的不变量(或条件)。第一个要求是立即访问最大元素。为此,我们需要固定其位置,以便每次都可以立即访问。我们将始终将我们的最大元素保持在顶部 - 根位置。为了保持这一点,我们还需要保持另一个不变量 - 父节点必须大于其两个子节点。这样的堆也被称为最大堆

正如你可能猜到的那样,为了快速访问最大元素所需的属性可以很容易地反转,以便快速访问最小元素。我们在执行堆操作时所需要做的就是反转我们的比较函数。这种堆被称为最小堆

堆操作

在本节中,我们将看到如何在堆上执行不同的操作。

向堆中插入元素

作为插入的第一步,我们将保留最重要的不变量,这为我们提供了一种将此结构表示为数组的方式 - 完整树。这可以很容易地通过在末尾插入新元素来完成,因为它将代表最后一级的元素,就在所有现有元素之后,或者作为新级别中的第一个元素,如果当前的最后一级已满。

现在,我们需要保持另一个不变量 - 所有节点的值必须大于它们的两个子节点的值,如果有的话。假设我们当前的树已经遵循这个不变量,在最后位置插入新元素后,唯一可能违反不变量的元素将是最后一个元素。为了解决这个问题,如果父节点比元素小,我们将元素与其父节点交换。即使父节点已经有另一个元素,它也将小于新元素(新元素 > 父节点 > 子节点)。

因此,通过将新元素视为根创建的子树满足所有不变量。然而,新元素可能仍然大于其新父节点。因此,我们需要不断交换节点,直到整个树的不变量得到满足。由于完整树的高度最多为O(log n),整个操作将最多需要O(log n)时间。下图说明了向树中插入元素的操作:

图 2.15:向具有一个节点的堆中插入元素

图 2.15:向具有一个节点的堆中插入元素

如前图所示,在插入 11 后,树不再具有堆属性。因此,我们将交换 10 和 11 以使其再次成为堆。这个概念在下面的例子中更清晰,该例子有更多级别:

图 2.16:向具有多个节点的堆中插入元素

图 2.16:向具有多个节点的堆中插入元素

从堆中删除元素

首先要注意的是,我们只能删除最大的元素。我们不能直接触摸任何其他元素。最大的元素始终存在于根部。因此,我们将删除根元素。但我们还需要决定谁将接替它的位置。为此,我们首先需要将根与最后一个元素交换,然后删除最后一个元素。这样,我们的根将被删除,但它将打破每个父节点都大于其子节点的不变性。为了解决这个问题,我们将根与它的两个子节点进行比较,并与较大的子节点交换。现在,不变性在一个子树中被破坏。我们继续在整个子树中递归地进行交换过程。这样,不变性的破坏点就会沿着树向下冒泡。就像插入一样,我们一直遵循这个过程,直到满足不变性。所需的最大步数将等于树的高度,即O(log n)。下图说明了这个过程:

图 2.17:删除堆中的一个元素

图 2.17:删除堆中的一个元素

初始化堆

现在,让我们看看最重要的一步 - 初始化堆。与向量、列表、双端队列等不同,堆的初始化并不简单,因为我们需要维护堆的不变性。一个简单的解决方案是从一个空堆开始逐个插入所有元素。但是这样需要的时间是O(n * log(n)),这并不高效。

然而,有一个std::make_heap,它可以接受任何数组或向量迭代器,并将它们转换为堆。

练习 10:流式中位数

在这个练习中,我们将解决一个在数据分析相关应用中经常出现的有趣问题,包括机器学习。想象一下,某个来源不断地给我们提供数据(数据流)中的一个元素。我们需要在每次接收到每个元素后找到到目前为止已接收到的元素的中位数。一个简单的方法是每次有新元素进来时对数据进行排序并返回中间元素。但是由于排序的原因,这将具有O(n log n)的时间复杂度。根据输入元素的速率,这可能非常消耗资源。然而,我们将通过堆来优化这个问题。让我们开始吧:

  1. 首先让我们包括所需的头文件:
#include <iostream>
#include <queue>
#include <vector>
  1. 现在,让我们编写一个容器来存储到目前为止收到的数据。我们将数据存储在两个堆中 - 一个最小堆和一个最大堆。我们将把较小的前半部分元素存储在最大堆中,将较大的或另一半存储在最小堆中。因此,在任何时候,中位数可以使用堆的顶部元素来计算,这些元素很容易访问:
struct median
{
    std::priority_queue<int> maxHeap;
    std::priority_queue<int, std::vector<int>, std::greater<int>> minHeap;
  1. 现在,让我们编写一个insert函数,以便我们可以插入新到达的数据:
void insert(int data)
{
    // First element
    if(maxHeap.size() == 0)
    {
        maxHeap.push(data);
        return;
    }
    if(maxHeap.size() == minHeap.size())
    {
        if(data <= get())
            maxHeap.push(data);
        else
            minHeap.push(data);
        return;
    }
    if(maxHeap.size() < minHeap.size())
    {
        if(data > get())
        {
            maxHeap.push(minHeap.top());
            minHeap.pop();
            minHeap.push(data);
        }
        else
            maxHeap.push(data);
        return;
    }
    if(data < get())
    {
        minHeap.push(maxHeap.top());
        maxHeap.pop();
        maxHeap.push(data);
    }
    else
        minHeap.push(data);
}
  1. 现在,让我们编写一个get函数,以便我们可以从容器中获取中位数:
double get()
{
    if(maxHeap.size() == minHeap.size())
        return (maxHeap.top() + minHeap.top()) / 2.0;
    if(maxHeap.size() < minHeap.size())
        return minHeap.top();
    return maxHeap.top();
}
};
  1. 现在,让我们编写一个main函数,以便我们可以使用这个类:
int main()
{
    median med;
    med.insert(1);
    std::cout << "Median after insert 1: " << med.get() << std::endl;
    med.insert(5);
    std::cout << "Median after insert 5: " << med.get() << std::endl;
    med.insert(2);
    std::cout << "Median after insert 2: " << med.get() << std::endl;
    med.insert(10);
    std::cout << "Median after insert 10: " << med.get() << std::endl;
    med.insert(40);
    std::cout << "Median after insert 40: " << med.get() << std::endl;
    return 0;
}

上述程序的输出如下:

Median after insert 1: 1
Median after insert 5: 3
Median after insert 2: 2
Median after insert 10: 3.5
Median after insert 40: 5

这样,我们只需要插入任何新到达的元素,这只需要O(log n)的时间复杂度,与如果我们每次有新元素就对元素进行排序的时间复杂度O(n log n)相比。

活动 5:使用堆进行 K 路合并

考虑一个与遗传学相关的生物医学应用,用于处理大型数据集。它需要对 DNA 的排名进行排序以计算相似性。但由于数据集很大,无法放在一台机器上。因此,它在分布式集群中处理和存储数据,每个节点都有一组排序的值。主处理引擎需要所有数据以排序方式和单个流的形式。因此,基本上,我们需要将多个排序数组合并成一个排序数组。借助向量模拟这种情况。

执行以下步骤来解决这个活动:

  1. 最小的数字将出现在所有列表的第一个元素中,因为所有列表已经分别排序。为了更快地获取最小值,我们将构建这些元素的堆。

  2. 从堆中获取最小元素后,我们需要将其移除并用它所属列表中的下一个元素替换。

  3. 堆节点必须包含关于列表的信息,以便它可以从该列表中找到下一个数字。

注意

此活动的解决方案可在第 495 页找到。

现在,让我们计算前面算法的时间复杂度。如果有k个列表可用,我们的堆大小将为k,我们所有的堆操作都将是O(log k)。构建堆将是O(k log k)。之后,我们将不得不为结果中的每个元素执行堆操作。总元素为n × k。因此,总复杂度将是O(nk log k)

这个算法的奇妙之处在于,考虑到我们之前描述的现实场景,它实际上并不需要同时存储所有的n × k元素;它只需要在任何时刻存储k个元素,其中k是集群中列表或节点的数量。由于这个原因,k的值永远不会太大。借助堆,我们可以一次生成一个数字,然后立即处理该数字,或者将其流式传输到其他地方进行处理,而无需实际存储它。

尽管树是表示分层数据的一种很好的方式,但我们无法在树中表示循环依赖,因为我们总是有一条单一且唯一的路径可以从一个节点到另一个节点。然而,还有更复杂的情况具有固有的循环结构。例如,考虑一个道路网络。可以有多种方式从一个地点(地点可以表示为节点)到另一个地点。这样的一组情景可以更好地用图来表示。

与树不同,图必须存储节点的数据,以及节点之间的边的数据。例如,在任何道路网络中,对于每个节点(地点),我们都必须存储它连接到哪些其他节点(地点)的信息。这样,我们就可以形成一个包含所有所需节点和边的图。这被称为无权图。我们可以为每条边添加权重或更多信息。对于我们的道路网络示例,我们可以添加每条边(路径)从一个节点(地点)到另一个节点的距离。这种表示被称为加权图,它包含了解决诸如找到两个地点之间最小距离的路径等问题所需的道路网络的所有信息。

图有两种类型 - 无向图和有向图。无向图表示边是双向的。双向表示具有双边或可交换属性。对于道路网络示例,点 A 和点 B 之间的双向边意味着我们可以从 A 到 B,也可以从 B 到 A。但假设我们有一些有单向限制的道路 - 我们需要使用有向图来表示。在有向图中,每当我们需要指示可以双向行驶时,我们使用两条边 - 从点 A 到 B,以及从 B 到 A。我们主要关注双向图,但我们在这里学到的关于结构和遍历方法的知识对于有向图也是正确的。唯一的变化将是我们如何向图中添加边。

由于图可以具有循环边和从一个节点到另一个节点的多条路径,我们需要唯一标识每个节点。为此,我们可以为每个节点分配一个标识符。为了表示图的数据,我们实际上不需要像在树中那样以编程方式构建类似节点的结构。事实上,我们可以通过组合std容器来存储整个图。

将图表示为邻接矩阵

以下是理解图的最简单方法之一——考虑一组节点,其中任何节点都可以直接连接到该组中的任何其他节点。这意味着我们可以使用大小为N×N的二维数组来表示这一点,其中N为节点数。每个单元格中的值将根据单元格的索引指示相应节点之间的边的权重。因此,data[1][2]将指示节点 1 和节点 2 之间边的权重。这种方法称为邻接矩阵。我们可以使用-1 的权重表示边的缺失。

考虑下图中所示的加权图,它表示了一些主要国际城市之间的航空网络,带有假设的距离:

图 2.18:一些城市之间的航空网络

图 2.18:一些城市之间的航空网络

如前面的图所示,我们可以通过伊斯坦布尔或直接从伦敦到迪拜。从一个地方到另一个地方有多种方式,这在树的情况下是不可能的。此外,我们可以从一个节点遍历到另一个节点,然后通过一些不同的边回到原始节点,这在树中也是不可能的。

让我们实现前面图中所示的图的矩阵表示方法。

练习 11:实现图并将其表示为邻接矩阵

在这个练习中,我们将实现一个代表前面图中所示的城市网络的图,并演示如何将其存储为邻接矩阵。让我们开始吧:

  1. 首先,让我们包括所需的头文件:
#include <iostream>
#include <vector>
  1. 现在,让我们添加一个enum类,以便我们可以存储城市的名称:
enum class city: int
{
    LONDON,
    MOSCOW,
    ISTANBUL,
    DUBAI,
    MUMBAI,
    SEATTLE,
    SINGAPORE
};
  1. 让我们还为city枚举添加<<运算符:
std::ostream& operator<<(std::ostream& os, const city c)
{
    switch(c)
    {
        case city::LONDON:
            os << "LONDON";
            return os;
        case city::MOSCOW:
            os << "MOSCOW";
            return os;
        case city::ISTANBUL:
            os << "ISTANBUL";
            return os;
        case city::DUBAI:
            os << "DUBAI";
            return os;
        case city::MUMBAI:
            os << "MUMBAI";
            return os;
        case city::SEATTLE:
            os << "SEATTLE";
            return os;
        case city::SINGAPORE:
            os << "SINGAPORE";
            return os;
        default:
            return os;
    }
}
  1. 现在,让我们编写struct graph,它将封装我们的数据:
struct graph
{
    std::vector<std::vector<int>> data;
  1. 现在,让我们添加一个构造函数,它将创建一个空图(没有任何边的图)并给定节点数:
graph(int n)
{
    data.reserve(n);
    std::vector<int> row(n);
    std::fill(row.begin(), row.end(), -1);
    for(int i = 0; i < n; i++)
    {
        data.push_back(row);
    }
}
  1. 现在,让我们添加最重要的函数——addEdge。它将接受三个参数——要连接的两个城市和边的权重(距离):
void addEdge(const city c1, const city c2, int dis)
{
    std::cout << "ADD: " << c1 << "-" << c2 << "=" << dis << std::endl;
    auto n1 = static_cast<int>(c1);
    auto n2 = static_cast<int>(c2);
    data[n1][n2] = dis;
    data[n2][n1] = dis;
}
  1. 现在,让我们添加一个函数,这样我们就可以从图中删除一条边:
void removeEdge(const city c1, const city c2)
{
    std::cout << "REMOVE: " << c1 << "-" << c2 << std::endl;
    auto n1 = static_cast<int>(c1);
    auto n2 = static_cast<int>(c2);
    data[n1][n2] = -1;
    data[n2][n1] = -1;
}
};
  1. 现在,让我们编写main函数,以便我们可以使用这些函数:
int main()
{
    graph g(7);
    g.addEdge(city::LONDON, city::MOSCOW, 900);
    g.addEdge(city::LONDON, city::ISTANBUL, 500);
    g.addEdge(city::LONDON, city::DUBAI, 1000);
    g.addEdge(city::ISTANBUL, city::MOSCOW, 1000);
    g.addEdge(city::ISTANBUL, city::DUBAI, 500);
    g.addEdge(city::DUBAI, city::MUMBAI, 200);
    g.addEdge(city::ISTANBUL, city::SEATTLE, 1500);
    g.addEdge(city::DUBAI, city::SINGAPORE, 500);
    g.addEdge(city::MOSCOW, city::SEATTLE, 1000);
    g.addEdge(city::MUMBAI, city::SINGAPORE, 300);
    g.addEdge(city::SEATTLE, city::SINGAPORE, 700);
    g.addEdge(city::SEATTLE, city::LONDON, 1800);
    g.removeEdge(city::SEATTLE, city::LONDON);
    return 0;
}
  1. 执行此程序后,我们应该得到以下输出:
ADD: LONDON-MOSCOW=900
ADD: LONDON-ISTANBUL=500
ADD: LONDON-DUBAI=1000
ADD: ISTANBUL-MOSCOW=1000
ADD: ISTANBUL-DUBAI=500
ADD: DUBAI-MUMBAI=200
ADD: ISTANBUL-SEATTLE=1500
ADD: DUBAI-SINGAPORE=500
ADD: MOSCOW-SEATTLE=1000
ADD: MUMBAI-SINGAPORE=300
ADD: SEATTLE-SINGAPORE=700
ADD: SEATTLE-LONDON=1800
REMOVE: SEATTLE-LONDON

正如我们所看到的,我们正在将数据存储在一个向量的向量中,两个维度都等于节点数。因此,这种表示所需的总空间与V2成正比,其中V是节点数。

将图表示为邻接表

矩阵表示图的一个主要问题是所需的内存量与节点数的平方成正比。可以想象,随着节点数的增加,这会迅速增加。让我们看看如何改进这一点,以便使用更少的内存。

在任何图中,我们将有固定数量的节点,每个节点将有固定数量的连接节点,等于总节点数。在矩阵中,我们必须存储所有节点的所有边,即使两个节点不直接连接。相反,我们只会在每一行中存储节点的 ID,指示哪些节点直接连接到当前节点。这种表示也称为邻接表

让我们看看实现与之前练习的不同之处。

练习 12:实现图并将其表示为邻接表

在这个练习中,我们将实现一个代表城市网络的图,如图 2.18所示,并演示如何将其存储为邻接表。让我们开始吧:

  1. 在这个练习中,我们将实现邻接表表示。让我们像往常一样从头文件开始:
#include <iostream>
#include <vector>
#include <algorithm>
  1. 现在,让我们添加一个enum类,以便我们可以存储城市的名称:
enum class city: int
{
    MOSCOW,
    LONDON,
    ISTANBUL,
    SEATTLE,
    DUBAI,
    MUMBAI,
    SINGAPORE
};
  1. 让我们还为city枚举添加<<运算符:
std::ostream& operator<<(std::ostream& os, const city c)
{
    switch(c)
    {
        case city::MOSCOW:
            os << "MOSCOW";
            return os;
        case city::LONDON:
            os << "LONDON";
            return os;
        case city::ISTANBUL:
            os << "ISTANBUL";
            return os;
        case city::SEATTLE:
            os << "SEATTLE";
            return os;
        case city::DUBAI:
            os << "DUBAI";
            return os;
        case city::MUMBAI:
            os << "MUMBAI";
            return os;
        case city::SINGAPORE:
            os << "SINGAPORE";
            return os;
        default:
            return os;
    }
}
  1. 让我们编写struct graph,它将封装我们的数据:
struct graph
{
    std::vector<std::vector<std::pair<int, int>>> data;
  1. 让我们看看我们的构造函数与矩阵表示有何不同:
graph(int n)
{
    data = std::vector<std::vector<std::pair<int, int>>>(n, std::vector<std::pair<int, int>>());
}

正如我们所看到的,我们正在用 2D 向量初始化数据,但所有行最初都是空的,因为开始时没有边。

  1. 让我们为此实现addEdge函数:
void addEdge(const city c1, const city c2, int dis)
{
    std::cout << "ADD: " << c1 << "-" << c2 << "=" << dis << std::endl;
    auto n1 = static_cast<int>(c1);
    auto n2 = static_cast<int>(c2);
    data[n1].push_back({n2, dis});
    data[n2].push_back({n1, dis});
}
  1. 现在,让我们编写removeEdge,这样我们就可以从图中移除一条边:
void removeEdge(const city c1, const city c2)
{
    std::cout << "REMOVE: " << c1 << "-" << c2 << std::endl;
    auto n1 = static_cast<int>(c1);
    auto n2 = static_cast<int>(c2);
    std::remove_if(data[n1].begin(), data[n1].end(), n2
        {
            return pair.first == n2;
        });
    std::remove_if(data[n2].begin(), data[n2].end(), n1
        {
            return pair.first == n1;
        });
}
};
  1. 现在,让我们编写main函数,这样我们就可以使用这些函数:
int main()
{
    graph g(7);
    g.addEdge(city::LONDON, city::MOSCOW, 900);
    g.addEdge(city::LONDON, city::ISTANBUL, 500);
    g.addEdge(city::LONDON, city::DUBAI, 1000);
    g.addEdge(city::ISTANBUL, city::MOSCOW, 1000);
    g.addEdge(city::ISTANBUL, city::DUBAI, 500);
    g.addEdge(city::DUBAI, city::MUMBAI, 200);
    g.addEdge(city::ISTANBUL, city::SEATTLE, 1500);
    g.addEdge(city::DUBAI, city::SINGAPORE, 500);
    g.addEdge(city::MOSCOW, city::SEATTLE, 1000);
    g.addEdge(city::MUMBAI, city::SINGAPORE, 300);
    g.addEdge(city::SEATTLE, city::SINGAPORE, 700);
    g.addEdge(city::SEATTLE, city::LONDON, 1800);
    g.removeEdge(city::SEATTLE, city::LONDON);
    return 0;
}

执行此程序后,我们应该得到以下输出:

ADD: LONDON-MOSCOW=900
ADD: LONDON-ISTANBUL=500
ADD: LONDON-DUBAI=1000
ADD: ISTANBUL-MOSCOW=1000
ADD: ISTANBUL-DUBAI=500
ADD: DUBAI-MUMBAI=200
ADD: ISTANBUL-SEATTLE=1500
ADD: DUBAI-SINGAPORE=500
ADD: MOSCOW-SEATTLE=1000
ADD: MUMBAI-SINGAPORE=300
ADD: SEATTLE-SINGAPORE=700
ADD: SEATTLE-LONDON=1800
REMOVE: SEATTLE-LONDON

由于我们为每个节点存储了一个相邻节点的列表,这种方法被称为邻接表。这种方法也使用了一个向量的向量来存储数据,就像前一种方法一样。但内部向量的维度不等于节点的数量;相反,它取决于边的数量。对于图中的每条边,根据我们的addEdge函数,我们将有两个条目。这种表示所需的内存将与 E 成正比,其中 E 是边的数量。

到目前为止,我们只看到了如何构建图。我们需要遍历图才能执行任何操作。有两种广泛使用的方法可用——广度优先搜索(BFS)和深度优先搜索(DFS),我们将在第六章图算法 I中看到这两种方法。

总结

在本章中,我们看了与上一章相比更高级的问题类别,这有助于我们描述更广泛的现实场景。我们看了并实现了两种主要的数据结构——树和图。我们还看了我们可以在不同情况下使用的各种类型的树。然后,我们看了不同的方式来以编程方式表示这些结构的数据。通过本章的帮助,您应该能够应用这些技术来解决类似种类的现实世界问题。

现在我们已经看过线性和非线性数据结构,在下一章中,我们将看一个非常特定但广泛使用的概念,称为查找,目标是将值存储在容器中,以便搜索非常快速。我们还将看一下哈希的基本思想以及如何实现这样的容器。

第三章:哈希表和布隆过滤器

学习目标

在本章结束时,您将能够:

  • 在任何大型应用程序中轻松识别与查找相关的问题

  • 评估问题是否适合确定性或非确定性查找解决方案

  • 基于场景实现高效的查找解决方案

  • 在大型应用程序中实现 C++ STL 提供的通用解决方案

在本章中,我们将研究快速查找的问题。我们将了解解决此问题的各种方法,并了解哪种方法可以用于特定情况。

介绍

查找只是检查元素是否存在于容器中或在容器中查找键的相应值。在我们在前几章中提到的学生数据库系统和医院管理系统示例中,一个常见的操作是从系统中存储的大量数据中获取特定记录。在从字典中获取单词的含义,根据一组记录(访问控制)检查某人是否被允许进入某个设施等许多应用程序中也会出现类似的问题。

对于大多数情况,线性遍历所有元素并匹配值将非常耗时,特别是考虑到存储的大量记录。让我们以在字典中查找单词为例。英语词典中大约有 17 万个单词。最简单的方法之一是线性遍历字典,并将给定的单词与字典中的所有单词进行比较,直到找到单词或者到达字典的末尾。但这太慢了,它的时间复杂度为O(n),其中 n 是字典中的单词数,这不仅庞大而且每天都在增加。

因此,我们需要更高效的算法来实现更快的查找。在本章中,我们将看一些高效的结构,即哈希表和布隆过滤器。我们将实现它们并比较它们的优缺点。

哈希表

让我们来看看在字典中搜索的基本问题。牛津英语词典中大约有 17 万个单词。正如我们在介绍中提到的,线性搜索将花费O(n)的时间,其中n是单词的数量。存储数据的更好方法是将其存储在具有类似 BST 属性的高度平衡树中。这使得它比线性搜索快得多,因为它的时间复杂度仅为O(log n)。但对于需要大量此类查询的应用程序来说,这仍然不是足够好的改进。想想在包含数百万甚至数十亿条记录的数据中查找所需的时间,比如神经科学数据或遗传数据。在这些情况下,我们需要更快的东西,比如哈希表

哈希表的一个重要部分是哈希。其背后的想法是用可能唯一的键表示每个值,然后稍后使用相同的键来检查键的存在或检索相应的值,具体取决于使用情况。从给定数据派生唯一键的函数称为哈希函数。让我们看看如何通过一些示例存储和检索数据,并让我们了解为什么我们需要这样的函数。

哈希

在跳入哈希之前,让我们举一个简单的例子。假设我们有一个存储整数的容器,并且我们想尽快知道特定整数是否是容器的一部分。最简单的方法是使用一个布尔数组,其中每个位表示与其索引相同的值。当我们想要插入一个元素时,我们将设置与该元素对应的布尔值为0。要插入x,我们只需设置data[x] = true。检查特定整数x是否在容器内同样简单——我们只需检查data[x]是否为true。因此,我们的插入、删除和搜索函数变为O(1)。存储从09编号的整数的简单哈希表如下所示:

图 3.1:一个简单的哈希表

图 3.1:一个简单的哈希表

然而,这种方法存在一些问题:

  • 如果数据是浮点数呢?

  • 如果数据不仅仅是一个数字呢?

  • 如果数据的范围太高怎么办?也就是说,如果我们有十亿个数字,那么我们需要一个大小为十亿的布尔数组,这并不总是可行的。

为了解决这个问题,我们可以实现一个函数,将任何数据类型的任何值映射到所需范围内的整数。我们可以选择范围,使其布尔数组的大小可行。这个函数被称为哈希函数,正如我们在前一节中提到的。它将一个数据元素作为输入,并在提供的范围内提供相应的输出整数。

对于大范围内的整数,最简单的哈希函数是模函数(用%表示),它将元素除以指定的整数(n)并返回余数。因此,我们将简单地有一个大小为n的数组。

如果我们想要插入一个给定的值x,我们可以对其应用模函数(x % n),并且我们将始终得到一个在0和(n – 1)之间的值,两者都包括在内。现在,x可以插入到位置(x % n)。这里,通过应用哈希函数获得的数字称为哈希值

我们可能会遇到的一个主要问题是,两个元素可能具有相同的模函数输出。一个例子是(9 % 7)和(16 % 7),它们都得到哈希值2。因此,如果对应于2的槽位为TRUE(或布尔值为1),我们将不知道我们的容器中存在2916或任何返回x % 7 = 2的其他整数。这个问题被称为冲突,因为多个键具有相同的值而不是唯一值,而不是应用哈希函数后的唯一值。

如果我们在哈希表中存储实际值而不是布尔整数,我们将知道我们有哪个值,但我们仍然无法存储具有相同哈希值的多个值。我们将在下一节中看看如何处理这个问题。但首先,让我们看看在下一个练习中为一堆整数实现基本字典的实现。

练习 13:整数的基本字典

在这个练习中,我们将实现一个无符号整数的基本版本的哈希映射。让我们开始吧:

  1. 首先,让我们包括所需的头文件:
#include <iostream>
#include <vector>
  1. 现在,让我们添加hash_map类。我们将别名unsigned int以避免编写一个很长的名称:
using uint = unsigned int;
class hash_map
{
    std::vector<int> data;
  1. 现在,让我们为此添加一个构造函数,它将接受数据或哈希映射的大小:
public:
hash_map(size_t n)
{
    data = std::vector<int>(n, -1);
}

如图所示,我们使用“-1”来表示元素的缺失。这是我们作为数据使用的唯一负值。

  1. 让我们添加insert函数:
void insert(uint value)
{
    int n = data.size();
    data[value % n] = value;
    std::cout << "Inserted " << value << std::endl;
}

正如我们所看到的,我们并没有真正检查是否已经存在具有相同哈希值的值。我们只是覆盖了已经存在的任何值。因此,对于给定的哈希值,只有最新插入的值将被存储。

  1. 让我们编写一个查找函数,看看元素是否存在于映射中:
bool find(uint value)
{
    int n = data.size();
    return (data[value % n] == value);
}

我们将简单地检查值是否存在于根据哈希值计算的索引处。

  1. 让我们实现一个remove函数:
void erase(uint value)
{
    int n = data.size();
    if(data[value % n] == value)
    {
data[value % n] = -1;
        std::cout << "Removed " << value << std::endl;
}
}
};
  1. 让我们在main中编写一个小的 lambda 函数来打印查找的状态:
int main()
{
    hash_map map(7);
    auto print = &
        {
            if(map.find(value))
                std::cout << value << " found in the hash map";
            else
                std::cout << value << " NOT found in the hash map";
            std::cout << std::endl;
        };
  1. 让我们在地图上使用inserterase函数:
    map.insert(2);
    map.insert(25);
    map.insert(290);
    print(25);
    print(100);
    map.insert(100);
    print(100);
    map.erase(25);
}
  1. 这是程序的输出:
Inserted 2
Inserted 25
Inserted 290
25 found in the hash map
100 NOT found in the hash map
Inserted 100
100 found in the hash map
Removed 25

正如我们所看到的,我们能够找到我们之前插入的大多数值,如预期的那样,除了最后一种情况,其中1000覆盖,因为它们具有相同的哈希值。这被称为碰撞,正如我们之前所描述的。在接下来的章节中,我们将看到如何避免这种问题,使我们的结果更准确。

以下图示说明了上一个练习中的不同函数,这应该更清楚:

图 3.2:哈希表中的基本操作

图 3.2:哈希表中的基本操作

图 3.3:哈希表中的基本操作(续)

图 3.3:哈希表中的基本操作(续)

正如前面的图所示,我们无法插入具有相同哈希值的两个元素;我们必须放弃其中一个。

现在,正如我们之前提到的,哈希表的一个主要用途是找到与键对应的值,而不仅仅是检查键是否存在。这可以通过存储键值对而不仅仅是数据中的键来简单实现。因此,我们的插入、删除和查找函数仍将根据我们的键计算哈希值,但一旦我们在数组中找到位置,我们的值将作为对的第二个参数。

哈希表中的碰撞

在前面的章节中,我们看到了哈希表如何帮助我们以一种便于查找任何所需键的方式存储大量键。然而,我们也遇到了一个问题,即多个键具有相同的哈希值,也称为碰撞。在练习 13中,整数的基本字典,我们通过简单地重写键并保留与给定哈希值对应的最新键来处理了这个问题。然而,这并不允许我们存储所有的键。在接下来的子主题中,我们将看一下几种方法,这些方法可以帮助我们克服这个问题,并允许我们在哈希表中保留所有的键值。

闭合寻址 - 链接

到目前为止,我们只为任何哈希值存储了一个单一元素。如果我们已经有一个特定哈希值的元素,我们除了丢弃新值或旧值之外别无选择。push_back方法(用于新元素)是为了能够快速从任何位置删除元素。让我们在下一个练习中实现这一点。

练习 14:使用链表的哈希表

在这个练习中,我们将实现一个哈希表,并使用链接来处理碰撞。让我们开始吧:

  1. 首先,让我们包括所需的头文件:
#include <iostream>
#include <vector>
#include <list>
#include <algorithm>
  1. 现在,让我们添加hash_map类。我们将别名unsigned int以避免编写一个很长的名称:
using uint = unsigned int;
class hash_map
{
    std::vector<std::list<int>> data;
  1. 现在,让我们为hash_map添加一个构造函数,该构造函数将接受数据或哈希映射的大小:
public:
hash_map(size_t n)
{
    data.resize(n);
}
  1. 让我们添加一个insert函数:
void insert(uint value)
{
    int n = data.size();
    data[value % n].push_back(value);
    std::cout << "Inserted " << value << std::endl;
}

正如我们所看到的,我们总是在数据中插入值。一个替代方法是搜索该值,并仅在该值不存在时插入。

  1. 让我们编写查找函数,以查看地图中是否存在元素:
bool find(uint value)
{
    int n = data.size();
    auto& entries = data[value % n];
    return std::find(entries.begin(), entries.end(), value) != entries.end();
}

正如我们所看到的,我们的查找似乎比传统方法更快,但不像之前那样快。这是因为现在它也依赖于数据,以及n的值。在这个练习之后,我们将再次回到这一点。

  1. 让我们实现一个函数来删除元素:
void erase(uint value)
{
    int n = data.size();
    auto& entries = data[value % n];
    auto iter = std::find(entries.begin(), entries.end(), value);

    if(iter != entries.end())
    {
entries.erase(iter);
        std::cout << "Removed " << value << std::endl;
}
}
};
  1. 让我们编写与上一个练习中相同的main函数,并查看其中的区别:
int main()
{
    hash_map map(7);
    auto print = &
        {
            if(map.find(value))
                std::cout << value << " found in the hash map";
            else
                std::cout << value << " NOT found in the hash map";
            std::cout << std::endl;
        };
  1. 让我们在map上使用inserterase函数:
    map.insert(2);
    map.insert(25);
    map.insert(290);
    map.insert(100);
    map.insert(55);
    print(100);
    map.erase(2);
}

这是我们程序的输出:

Inserted 2
Inserted 25
Inserted 290
Inserted 100
Inserted 55
100 found in the hash map
Removed 2

正如我们所看到的,值没有被覆盖,因为我们可以在列表中存储任意数量的值。因此,我们的输出是完全准确和可靠的。

以下图片说明了如何在数据集上执行不同的操作:

图 3.4:哈希表上的基本操作(链接)

图 3.4:使用链接的哈希表的基本操作

图 3.5:使用链接的哈希表的基本操作(续)

图 3.5:使用链接的哈希表的基本操作(续)

正如我们所看到的,我们将具有相同哈希值的元素附加到节点中的列表中,而不是单个元素。

现在,让我们考虑这些操作的时间复杂度。正如我们所看到的,插入函数仍然是O(1)。虽然push_back可能比仅设置一个值慢一些,但并不显著慢。考虑到这种方法解决的问题,这是一个小代价。但查找和删除可能会显著慢一些,这取决于我们的哈希表大小和数据集。例如,如果所有的键都具有相同的哈希值,搜索所需的时间将是 O(n),因为它将简单地成为链表中的线性搜索。

如果哈希表与要存储的键的数量相比非常小,将会有很多碰撞,并且平均而言列表会更长。另一方面,如果我们保留一个非常大的哈希表,可能会最终产生非常稀疏的数据,并最终浪费内存。因此,哈希表的大小应该根据应用程序的上下文和情景进行优化。我们也可以在数学上定义这些事情。

负载因子表示哈希表中每个列表中存在的平均键的数量。它可以使用以下公式计算:

图 3.6:负载因子

图 3.6:负载因子

如果键的数量等于我们的哈希表大小,负载因子将是1。这是一个理想的情况;我们将接近O(1)的所有操作,并且所有的空间将被充分利用。

如果值小于1,这意味着我们甚至没有在每个列表中存储一个键(假设我们希望在每个索引处都有一个列表),实际上浪费了一些空间。

如果值大于1,这意味着我们的列表的平均长度大于 1,因此我们的查找和删除函数在平均情况下会慢一些。

负载因子的值可以在任何时候以O(1)的时间计算。一些高级的哈希表实现利用这个值来修改哈希函数(也称为重新散列),如果该值跨过 1 的某些阈值。哈希函数被修改,以使负载因子更接近 1。然后,哈希表的大小可以根据我们的负载因子进行更新,并根据更新后的哈希函数重新分配值。重新散列是一个昂贵的操作,因此不应该太频繁地执行。但是,如果应用了适当的策略,我们可以在平均时间复杂度方面取得非常好的结果。

然而,负载因子并不是决定这种技术性能的唯一因素。考虑以下情景:我们有一个大小为7的哈希表,它有七个元素。然而,它们全部具有相同的哈希值,因此全部存在于一个单独的桶中。因此,搜索将始终需要O(n)的时间,而不是O(1)的时间。然而,负载因子将是 1,这是一个绝对理想的值。在这里,实际的问题是哈希函数。哈希函数应该被设计成以尽可能均匀地分布不同的键到所有可能的索引中。基本上,最小桶大小和最大桶大小之间的差异不应该太大(在这种情况下是七)。如果哈希函数被设计成所有七个元素都获得不同的哈希值,那么所有的搜索函数调用将导致O(1)的复杂度和即时结果。这是因为最小和最大桶大小之间的差异将为0。然而,这通常不是哈希表实现中所做的。它应该由哈希函数本身来处理,因为哈希表不依赖于哈希函数的实现。

开放寻址

解决碰撞的另一种方法是开放寻址。在这种方法中,我们将所有元素存储在哈希表中,而不是将元素链接到哈希表。因此,为了容纳所有元素,哈希表的大小必须大于元素的数量。其思想是探测特定哈希值对应的单元格是否已被占用。我们可以通过多种方式来探测值,我们将在以下子主题中看到。

线性探测

这是一种简单的探测技术。如果在特定哈希值处发生碰撞,我们可以简单地查看后续的哈希值,找到一个空单元并在找到空间后插入我们的元素。如果hash(x)处的单元格已满,则需要检查hash(x + 1)处的单元格是否为空。如果它也已满,再看hash(x + 2),依此类推。

以下图示了线性探测的工作原理:

图 3.7:使用线性探测的哈希表上的基本操作

图 3.7:使用线性探测的哈希表上的基本操作

图 3.8:哈希表填满后无法插入元素

图 3.8:哈希表填满后无法插入元素

正如我们所看到的,如果与其哈希值对应的位置已被占用,我们会将元素插入到下一个可用的插槽中。在插入了前三个元素后,我们可以看到它们聚集在一起。如果在相同范围内插入更多元素,它们都将连续地放在聚集的末尾,从而使聚集增长。现在,当我们尝试搜索一个不在哈希函数计算的位置上,但在一个大聚集的末尾的值时,我们必须线性搜索整个聚集中的所有键。因此,搜索变得极其缓慢。

因此,如果数据密集聚集,我们会遇到一个主要问题。我们可以说数据密集聚集,如果数据分布方式是某些组围绕着非常高频率的值。例如,假设在大小为 100 的哈希表中有很多哈希值为 3 到 7 的键。所有键将在此范围内连续探测到一些值,这将极大地减慢我们的搜索速度。

二次探测

正如我们所看到的,线性探测的主要问题是聚集。其原因是在碰撞的情况下我们是线性探测的。这个问题可以通过使用二次方程而不是线性方程来解决。这就是二次探测提供的。

首先,我们尝试将值x插入到位置hash(x)。如果该位置已被占用,我们继续到位置hash(x + 12),然后hash(x + 22),依此类推。因此,我们以二次方式增加偏移量,从而降低了创建小数据集的概率。

这两种探测技术还有一个优势 - 元素的位置可能会受到没有相同哈希值的其他元素的影响。因此,即使只有一个具有特定哈希值的键,也可能会因为该位置存在其他元素而发生碰撞,而这在链接中是不会发生的。例如,在线性探测中,如果我们有两个哈希值为 4 的键,其中一个将被插入到位置 4,另一个将被插入到位置 5。接下来,如果我们需要插入一个哈希值为 5 的键,它将需要插入到 6。即使它与任何其他键的哈希值不同,这个键也受到了影响。

完美哈希 - 布谷鸟哈希

正如标题所示,布谷鸟哈希是完美哈希技术之一。我们之前提到的方法在最坏情况下不能保证O(1)的时间复杂度,但是如果正确实现,布谷鸟哈希可以实现这一点。

在布谷鸟哈希中,我们保持两个相同大小的哈希表,每个哈希表都有自己独特的哈希函数。任何元素都可以存在于任一哈希表中,并且其位置基于相应的哈希函数。

布谷鸟哈希与我们以前的哈希技术有两种主要不同之处:

  • 任何元素都可以存在于两个哈希表中的任何一个。

  • 任何元素都可以在将来移动到另一个位置,即使在插入后。

以前的哈希技术在插入后不允许元素移动,除非我们进行完全的重新哈希,但布谷鸟哈希不是这样,因为任何元素都可以有两个可能的位置。我们仍然可以通过增加任何元素的可能位置的数量来增加程度,以便获得更好的结果并减少频繁的重新哈希。然而,在本章中,我们只会看两个可能位置(哈希表)的版本,因为这样更容易理解。

对于查找,我们只需要查看两个位置来确定元素是否存在。因此,查找总是需要 O(1) 的时间。

然而,插入函数可能需要更长的时间。在这种情况下,插入函数首先检查是否可能将新元素(比如 A)插入第一个哈希表中。如果可以,它就在那里插入元素,然后完成。但是,如果该位置被现有元素(比如 B)占据,我们仍然继续插入 A 并将 B 移动到第二个哈希表中。如果第二个哈希表中的新位置也被占据(比如元素 C),我们再次在那里插入 B 并将 C 移动到第一个表中。我们可以递归地进行这个过程,直到我们能够为所有元素找到空槽。这个过程在下图中有所说明:

图 3.9:布谷鸟哈希

图 3.9:布谷鸟哈希

一个主要问题是我们可能会陷入循环,递归可能导致无限循环。对于前面段落中的例子,考虑我们希望插入 C 的元素 D,但如果我们尝试移动 D,它会到达 A 的位置。因此,我们陷入了无限循环。下图应该帮助您可视化这一点:

图 3.10:布谷鸟哈希中形成的循环

图 3.10:布谷鸟哈希中形成的循环

为了解决这个问题,一旦我们确定了循环,我们需要使用新的哈希函数重新对所有内容进行哈希。使用新哈希函数创建的哈希表可能仍然存在相同的问题,因此我们可能需要重新哈希并尝试不同的哈希函数。然而,通过聪明的策略和明智选择的哈希函数,我们可以以高概率实现摊销 O(1) 的性能。

就像开放寻址一样,我们不能存储比哈希表的总大小更多的元素。为了确保良好的性能,我们应该确保负载因子小于 50%,也就是说,元素的数量应该小于可用容量的一半。

我们将在下一个练习中看一下布谷鸟哈希的实现。

练习 15:布谷鸟哈希

在这个练习中,我们将实现布谷鸟哈希来创建一个哈希表,并在其中插入各种元素。我们还将获得操作进行的跟踪,这将允许我们查看插入是如何工作的。让我们开始吧:

  1. 让我们像往常一样包括所需的头文件:
#include <iostream>
#include <vector>
  1. 让我们为哈希映射添加一个类。这次我们也将单独存储大小:
class hash_map
{
    std::vector<int> data1;
    std::vector<int> data2;
    int size;

正如我们所看到的,我们使用了两个表。

  1. 现在,让我们添加相应的哈希函数:
int hash1(int key) const
{
    return key % size;
}
int hash2(int key) const
{
    return (key / size) % size;
}

在这里,我们将两个函数都保持得非常简单,但这些函数可以根据需求进行调整。

  1. 现在,让我们添加一个构造函数,用于设置我们的数据进行初始化:
public:
hash_map(int n) : size(n)
{
    data1 = std::vector<int>(size, -1);
    data2 = std::vector<int>(size, -1);
}

正如我们所看到的,我们只是将两个数据表都初始化为空(用 –1 表示)。

  1. 让我们首先编写一个 lookup 函数:
std::vector<int>::iterator lookup(int key)
{
    auto hash_value1 = hash1(key);
    if(data1[hash_value1] == key)
    {
        std::cout << "Found " << key << " in first table" << std::endl;
        return data1.begin() + hash_value1;
    }
    auto hash_value2 = hash2(key);
    if(data2[hash_value2] == key)
    {
        std::cout << "Found " << key << " in second table" << std::endl;
        return data2.begin() + hash_value2;
    }
    return data2.end();
}

我们试图在两个表中找到键,并在找到时返回相关的迭代器。我们并不总是需要迭代器,但我们将在删除函数中使用它以简化事情。如果未找到元素,我们将返回data2表的末尾。正如我们所看到的,查找将具有O(1)的时间复杂度,并且将被执行得非常快速。

  1. 让我们实现一个删除函数:
void erase(int key)
{
    auto position = lookup(key);
    if(position != data2.end())
    {
        *position = -1;
        std::cout << "Removed the element " << key << std::endl;
    }
    else
    {
        std::cout << "Key " << key << " not found." << std::endl;
    }
}

正如我们所看到的,大部分工作是通过调用lookup函数完成的。我们只需要验证结果并重置值以将其从表中移除。

  1. 对于插入,我们将在不同的函数中实现实际逻辑,因为它将是递归的。我们还想要避免循环。然而,保留所有访问过的值的记录可能代价高昂。为了避免这种情况,我们将简单地在函数被调用超过 n 次时停止函数。由于递归深度 n 的阈值取决于我们的内存(或哈希表大小),这样可以获得良好的性能:
void insert(int key)
{
    insert_impl(key, 0, 1);
}
void insert_impl(int key, int cnt, int table)
{
    if(cnt >= size)
    {
        std::cout << "Cycle detected, while inserting " << key << ". Rehashing required." << std::endl;
        return;
    }
    if(table == 1)
    {
int hash = hash1(key);
        if(data1[hash] == -1)
        {
            std::cout << "Inserted key " << key << " in table " << table << std::endl;
            data1[hash] = key;
        }
        else
        {
            int old = data1[hash];
            data1[hash] = key;
            std::cout << "Inserted key " << key << " in table " << table << " by replacing " << old << std::endl;
            insert_impl(old, cnt + 1, 2);
        }
    }
    else
    {
int hash = hash2(key);
        if(data2[hash] == -1)
        {
            std::cout << "Inserted key " << key << " in table " << table << std::endl;
            data2[hash] = key;
        }
        else
        {
            int old = data2[hash];
            data2[hash] = key;
            std::cout << "Inserted key " << key << " in table " << table << " by replacing " << old << std::endl;
            insert_impl(old, cnt + 1, 2);
        }
    }
}

正如我们所看到的,实现需要三个参数-键、我们要插入键的表以及递归调用堆栈的计数,以跟踪我们已经改变位置的元素数量。

  1. 现在,让我们编写一个实用函数来打印哈希表中的数据。虽然这并不是真正必要的,也不应该暴露,但我们将这样做,以便更好地了解我们的插入函数如何在内部管理数据:
void print()
{
    std::cout << "Index: ";
    for(int i = 0; i < size; i++)
        std::cout << i << '\t';
    std::cout << std::endl;
    std::cout << "Data1: ";
    for(auto i: data1)
        std::cout << i << '\t';
    std::cout << std::endl;
    std::cout << "Data2: ";
    for(auto i: data2)
        std::cout << i << '\t';
    std::cout << std::endl;
}
};
  1. 现在,让我们编写main函数,以便我们可以使用这个哈希映射:
int main()
{
    hash_map map(7);
    map.print();
    map.insert(10);
    map.insert(20);
    map.insert(30);
    std::cout << std::endl;
    map.insert(104);
    map.insert(2);
    map.insert(70);
    map.insert(9);
    map.insert(90);
    map.insert(2);
    map.insert(7);
    std::cout << std::endl;
    map.print();
    std::cout << std::endl;
    map.insert(14);  // This will cause cycle.
}
  1. 您应该看到以下输出:
Index: 0    1    2    3    4    5    6    
Data1: -1    -1    -1    -1    -1    -1    -1    
Data2: -1    -1    -1    -1    -1    -1    -1    
Inserted key 10 in table 1
Inserted key 20 in table 1
Inserted key 30 in table 1
Inserted key 104 in table 1 by replacing 20
Inserted key 20 in table 2
Inserted key 2 in table 1 by replacing 30
Inserted key 30 in table 2
Inserted key 70 in table 1
Inserted key 9 in table 1 by replacing 2
Inserted key 2 in table 2
Inserted key 90 in table 1 by replacing 104
Inserted key 104 in table 2 by replacing 2
Inserted key 2 in table 1 by replacing 9
Inserted key 9 in table 2
Inserted key 2 in table 1 by replacing 2
Inserted key 2 in table 2 by replacing 104
Inserted key 104 in table 1 by replacing 90
Inserted key 90 in table 2
Inserted key 7 in table 1 by replacing 70
Inserted key 70 in table 2
Index: 0    1    2    3    4    5     6
Data1: 7   -1    2    10  -1   -1     104
Data2: 2    9    20   70   30   90   -1
Inserted key 14 in table 1 by replacing 7
Inserted key 7 in table 2 by replacing 9
Inserted key 9 in table 1 by replacing 2
Inserted key 2 in table 2 by replacing 2
Inserted key 2 in table 1 by replacing 9
Inserted key 9 in table 2 by replacing 7
Inserted key 7 in table 1 by replacing 14
Cycle detected, while inserting 14\. Rehashing required.

正如我们所看到的,输出显示了内部维护两个表的完整跟踪。我们打印了内部步骤,因为一些值正在移动。我们可以从跟踪中看到,14的最后插入导致了一个循环。插入的深度已经超过了7。同时,我们还可以看到两个表几乎已经满了。我们已经填充了14中的11个元素,因此在每一步替换值的机会都在增加。我们还在循环之前打印了表。

此外,这里删除元素的时间复杂度为O(1),因为它只是使用lookup函数并删除元素(如果找到)。因此,唯一昂贵的函数是插入。因此,如果在任何应用程序中插入的数量要比查找的数量少得多,这是一个理想的实现。

让我们使用以下视觉辅助工具,以便更好地理解这一点:

图 3.11:在使用布谷鸟哈希的哈希表中插入元素

图 3.11:在使用布谷鸟哈希的哈希表中插入元素

图 3.12:使用布谷鸟哈希处理哈希表中的碰撞

图 3.12:使用布谷鸟哈希处理哈希表中的碰撞

图 3.13:使用布谷鸟哈希处理哈希表中的碰撞(续)

图 3.13:使用布谷鸟哈希处理哈希表中的碰撞(续)

图 3.14:在使用布谷鸟哈希的哈希表中查找值

图 3.14:在使用布谷鸟哈希的哈希表中查找值

图 3.15:在使用布谷鸟哈希的哈希表中删除值

图 3.15:在使用布谷鸟哈希的哈希表中删除值

正如我们从前面一系列的图中所看到的,首先,我们尝试在第一个表中插入元素。如果已经有另一个元素,我们将覆盖它并将现有元素插入到另一个表中。我们重复这个过程,直到安全地插入最后一个元素。

C++哈希表

正如我们之前提到的,查找操作在大多数应用程序中是非常频繁的。然而,我们可能并不总是遇到正整数,这些很容易进行哈希。大部分时间你可能会遇到字符串。考虑我们之前考虑过的英语词典的例子。我们可以使用单词作为键,单词定义作为值来存储词典数据。另一个例子是我们在第一章列表、栈和队列中考虑过的医院记录数据库,患者的姓名可能被用作键,其他相关信息可以作为值存储。

我们之前使用的简单取模函数来计算整数的哈希值对于字符串不起作用。一个简单的选择是计算所有字符的 ASCII 值的总和的模。然而,字符串中字符的所有排列可能非常庞大,这将导致很多碰撞。

C++提供了一个名为std::hash<std::string>(std::string)的函数,我们可以用它来生成字符串的哈希值。它有一个内置算法来处理哈希函数。同样,C++为所有基本数据类型提供了这样的函数。

现在,看看我们在练习 14中实现的哈希表,链式哈希表,很明显我们可以根据数据类型简单地将其模板化,并提供一个通用解决方案来为任何给定类型的数据提供哈希函数。STL 为此提供了几种解决方案:std::unordered_set<Key>std::unordered_map<Key, Value>。无序集合只能存储一组键,而无序映射可以存储键和它们的值。因此,容器中的每个唯一键都将有一个相应的值。

这两个容器都是以相同的方式实现的 - 使用链式哈希表。哈希表中的每一行都是一个存储键(和映射的值)的向量。这些行被称为。因此,在计算密钥的哈希值后,它将被放置到其中一个桶中。每个桶也是一个列表,以支持链式处理。

默认情况下,这些容器的最大负载因子为1。一旦元素数量超过哈希表的大小,哈希函数将被更改,哈希值将被重新计算(重新散列),并且将重新构建一个更大的哈希表以降低负载因子。我们也可以使用rehash函数手动执行此操作。使用max_load_factor(float)函数可以更改负载因子的默认最大限制为1。一旦负载因子超过定义的最大限制,值将被重新散列。

这些容器提供了常用的函数,如findinserterase。它们还提供迭代器来遍历所有元素,以及使用其他容器(如向量和数组)创建无序集合和映射的构造函数。无序映射还提供operator[],以便它可以返回已知键的值。

我们将在下一个练习中看一下无序集合和映射的实现。

练习 16:STL 提供的哈希表

在这个练习中,我们将实现无序集合和映射,并对这些容器进行插入、删除和查找等操作。让我们开始吧:

  1. 包括所需的头文件:
#include <iostream>
#include <unordered_map>
#include <unordered_set>
  1. 现在,让我们编写一些简单的print函数,以使我们的main函数更易读:
void print(const std::unordered_set<int>& container)
{
    for(const auto& element: container)
        std::cout << element << " ";
    std::cout << std::endl;
}
void print(const std::unordered_map<int, int>& container)
{
    for(const auto& element: container)
        std::cout << element.first << ": " << element.second << ", ";
    std::cout << std::endl;
}
  1. 同样,添加对find函数的包装器,以保持代码整洁:
void find(const std::unordered_set<int>& container, const auto& element)
{
    if(container.find(element) == container.end())
        std::cout << element << " not found" << std::endl;
    else
        std::cout << element << " found" << std::endl;
}
void find(const std::unordered_map<int, int>& container, const auto& element)
{
    auto it = container.find(element);
    if(it == container.end())
        std::cout << element << " not found" << std::endl;
    else
        std::cout << element << " found with value=" << it->second << std::endl;
}
  1. 现在,编写main函数,以便我们可以使用unordered_setunordered_map,然后对其执行各种操作。我们将查找、插入和删除元素:
int main()
{
    std::cout << "Set example: " << std::endl;
    std::unordered_set<int> set1 = {1, 2, 3, 4, 5};
    std::cout << "Initial set1: ";
    print(set1);
    set1.insert(2);
    std::cout << "After inserting 2: ";
    print(set1);
    set1.insert(10);
    set1.insert(351);
    std::cout << "After inserting 10 and 351: ";
    print(set1);
    find(set1, 4);
    find(set1, 100);
    set1.erase(2);
    std::cout << "Erased 2 from set1" << std::endl;
    find(set1, 2);
    std::cout << "Map example: " << std::endl;
    std::unordered_map<int, int> squareMap;
    squareMap.insert({2, 4});
    squareMap[3] = 9;
    std::cout << "After inserting squares of 2 and 3: ";
    print(squareMap);
    squareMap[30] = 900;
    squareMap[20] = 400;
    std::cout << "After inserting squares of 20 and 30: ";
    print(squareMap);
    find(squareMap, 10);
    find(squareMap, 20);
    std::cout << "Value of map[3]=" << squareMap[3] << std::endl;
    std::cout << "Value of map[100]=" << squareMap[100] << std::endl;
}
  1. 这个程序的可能输出之一如下。集合和映射中元素的顺序可能不同,因此被称为无序集合/映射:
Set example: 
Initial set1: 5 4 3 2 1 
After inserting 2: 5 4 3 2 1 
After inserting 10 and 351: 351 10 1 2 3 4 5 
4 found
100 not found
Erased 2 from set1
2 not found
Map example: 
After inserting squares of 2 and 3: 3: 9, 2: 4, 
After inserting squares of 20 and 30: 20: 400, 30: 900, 2: 4, 3: 9, 
10 not found
20 found with value=400
Value of map[3]=9
Value of map[100]=0

正如我们所看到的,我们可以向这两个容器插入、查找和删除元素。这些操作都按预期工作。如果我们将这些操作与其他容器(如 vector、list、array、deque 等)进行基准测试,性能会更快。

我们可以存储键值对,并使用operator[]访问任何给定键的值,就像本练习中所示的那样。它返回一个引用,因此还允许我们设置值,而不仅仅是检索它。

注意

由于operator[]返回一个引用,如果找不到键,它将向条目添加默认值。

在最后一行,我们得到了map[100] = 0,即使100从未被插入到映射中。这是因为operator[]返回了默认值。

如果我们想要跟踪基于重新散列而更改的桶的数量,我们可以使用bucket_count()函数来实现。还有其他函数可以获取有关其他内部参数的详细信息,比如load_factormax_bucket_count等等。我们还可以使用rehash函数手动重新散列。

由于这些容器是使用链接实现的,它们实际上将键/值对存储在不同的桶中。因此,在任何桶中搜索键时,我们需要比较它们是否相等。因此,我们需要为键类型定义相等运算符。或者,我们可以将其作为另一个模板参数传递。

在这个练习中,我们可以看到,无序集合和映射不允许重复的键。如果我们需要存储重复的值,我们可以使用unordered_multisetunordered_multimap。为了支持多个值,插入函数不会检查键是否已经存在于容器中。此外,它支持一些额外的函数来检索具有特定键的所有项。我们不会再深入研究这些容器的细节,因为这超出了本书的范围。

STL 为 C++支持的所有基本数据类型提供了哈希函数。因此,如果我们想要将自定义类或结构作为前述容器中的键类型,我们需要在std命名空间内实现一个哈希函数。或者,我们可以将其作为模板参数传递。然而,每次都自己编写哈希函数并不是一个好主意,因为性能在很大程度上取决于它。设计哈希函数需要进行相当多的研究和对手头问题的理解,以及数学技能。因此,我们将其排除在本书的范围之外。对于我们的目的,我们可以简单地使用boost库中提供的hash_combine函数,就像下面的例子中所示的那样。

#include <boost/functional/hash.hpp>
struct Car
{
    std::string model;
    std::string brand;
    int buildYear;
};
struct CarHasher
{
    std::size_t operator()(const Car& car) const
    {
        std::size_t seed = 0;
        boost::hash_combine(seed, car.model);
        boost::hash_combine(seed, car.brand);
        return seed;
    }
};
struct CarComparator
{
    bool operator()(const Car& car1, const Car& car2) const
    {
    return (car1.model == car2.model) && (car1.brand == car2.brand);
    }
};
// We can use the hasher as follows:
std::unordered_set<Car, CarHasher, CarComparator> carSet;
std::unordered_map<Car, std::string, CarHasher, CarComparator> carDescriptionMap;

正如我们所看到的,我们已经定义了一个具有operator()的哈希结构,它将被无序容器使用。我们还定义了一个具有operator()的比较器结构,以支持相关函数。我们将这些结构作为模板参数传递。这也允许我们为不同的对象使用不同类型的比较器和哈希器。

除了简单的哈希函数,如取模,还有一些复杂的哈希函数,称为加密哈希函数,如 MD5、SHA-1 和 SHA-256。这些算法非常复杂,它们可以接受任何类型的数据——甚至是文件——作为输入值。加密函数的一个重要特征是,很难从给定的哈希值确定实际数据(也称为逆哈希),因此它们被用于一些最安全的系统中。例如,比特币区块链使用 SHA-256 算法来存储交易记录的重要真实性证明。区块链中的每个都包含其前一个链接块的 SHA-256 哈希值,并且当前块的哈希值包含在后续块中。非法修改任何块将使整个区块链从该块开始无效,因为现在修改后的块的哈希值将与下一个块中存储的值不匹配。即使使用世界上一些最快的超级计算机,也需要数百年才能打破这一点,并创建伪造的交易记录。

活动 6:将长 URL 映射到短 URL

在这个活动中,我们将创建一个程序来实现类似于tinyurl.com/的服务。它可以将一个非常长的 URL 映射到一个易于分享的小 URL。每当我们输入短 URL 时,它应该检索原始 URL。

我们想要以下功能:

  • 高效地存储用户提供的原始 URL 和相应的较小 URL

  • 如果找到,基于给定的较小 URL 检索原始 URL;否则,返回错误

这些高层次的步骤应该帮助你解决这个活动:

  1. 创建一个包含unordered_map作为主要数据成员的类。

  2. 添加一个插入值的函数。这个函数应该接受两个参数:原始 URL 和它的较小版本。

  3. 添加一个函数来查找基于给定小 URL 的实际 URL(如果存在)。

注意

这个活动的解决方案可以在第 498 页找到。

布隆过滤器

与哈希表相比,布隆过滤器在空间上非常高效,但代价是确定性答案;也就是说,我们得到的答案是不确定的。它只保证不会有假阴性,但可能会有假阳性。换句话说,如果我们得到一个正面的命中,元素可能存在,也可能不存在;但如果我们得到一个负面的命中,那么元素肯定不存在。

就像布谷鸟哈希一样,我们将在这里使用多个哈希函数。然而,我们将保留三个函数,因为两个函数无法达到合理的准确性。基本思想是,我们不存储实际值,而是存储一个布尔数组,指示值是否(可能)存在。

要插入一个元素,我们计算所有哈希函数的值,并将数组中所有三个哈希值对应的位设置为1。对于查找,我们计算所有哈希函数的值,并检查所有相应的位是否都设置为1。如果是,我们返回true;否则,我们返回false(元素不存在)。

显而易见的问题是——为什么查找是不确定的?原因是任何位都可以被多个元素设置。因此,有相当大的概率,所有特定值(称为x)的相关位都设置为1,因为之前插入了一些其他元素,尽管x根本没有被插入。在这种情况下,查找函数仍然会返回true。因此,我们可以期望一些误报。我们插入的元素越多,误报的机会就越大。然而,如果x的某个位没有设置,那么我们可以确定地说元素不存在。因此,假阴性不可能发生。

数组中的所有位都设置为1时,数组将饱和。因此,查找函数将始终返回true,并且插入函数根本不会产生任何影响,因为所有位已经设置为1

以下图表使这一点更清晰:

图 3.16:在 Bloom 过滤器中插入元素

图 3.16:在 Bloom 过滤器中插入元素

图 3.17:在 Bloom 过滤器中查找元素

图 3.17:在 Bloom 过滤器中查找元素

图 3.18:在 Bloom 过滤器中查找元素(续)

图 3.18:在 Bloom 过滤器中查找元素(续)

如前面的图表所示,我们根据哈希函数设置相关位,并且对于插入,我们对元素进行位AND查找,就像我们之前解释的那样。

我们将在接下来的练习中用 C++实现一个 Bloom 过滤器。

练习 17:创建 Bloom 过滤器

在这个练习中,我们将创建一个 Bloom 过滤器并尝试一些基本操作。我们还将测试查找中的误报。让我们开始吧:

  1. 让我们包括所需的头文件:
#include <iostream>
#include <vector>
  1. 现在,让我们为我们的 Bloom 过滤器创建一个类,并添加所需的数据成员:
class bloom_filter
{
    std::vector<bool> data;
    int nBits;
  1. 现在,让我们添加所需的哈希函数。同样,我们将使用非常基本的哈希函数:
int hash(int num, int key)
{
    switch(num)
    {
    case 0:
        return key % nBits;
    case 1:
        return (key / 7) % nBits;
    case 2:
        return (key / 11) % nBits;
    }
    return 0;
}

如您所见,我们使用单个函数,参数称为num,确定哈希函数,以避免其他函数中不必要的if-else块。这也很容易扩展;我们只需要为每个哈希函数添加一个情况。

  1. 让我们为 Bloom 过滤器添加一个构造函数:
public:
bloom_filter(int n) : nBits(n)
{
    data = std::vector<bool>(nBits, false);
}
  1. 现在,让我们添加一个lookup函数:
void lookup(int key)
{
    bool result = data[hash(0, key)] & data[hash(1, key)] & data[hash(2, key)];
    if(result)
    {
        std::cout << key << " may be present." << std::endl;
    }
    else
    {
        std::cout << key << " is not present." << std::endl;
    }
}

如预期的那样,lookup函数非常简单。它检查所有必需的位是否都设置为1。如果有可变数量的哈希函数,我们总是可以循环遍历所有这些函数,以检查所有相应的位是否都设置为1。为了使我们的话更准确,我们还说由于误报的可能性,一个键可能存在。另一方面,如果lookup返回负数,我们完全确定一个键不存在。

  1. 甚至插入函数同样简单:
void insert(int key)
{
    data[hash(0, key)] = true;
    data[hash(1, key)] = true;
    data[hash(2, key)] = true;
    std::cout << key << " inserted." << std::endl;
}
};
  1. 现在,让我们添加main函数,以便我们可以使用这个类:
int main()
{
bloom_filter bf(11);
bf.insert(100);
bf.insert(54);
bf.insert(82);
bf.lookup(5);
bf.lookup(50);
bf.lookup(2);
bf.lookup(100);
bf.lookup(8);
bf.lookup(65);
}
  1. 您应该看到以下输出:
100 inserted.
54 inserted.
82 inserted.
5 may be present.
50 is not present.
2 is not present.
100 may be present.
8 is not present.
65 may be present.

正如我们所看到的,有一些误报,但没有错误的否定。

与以前的技术不同,这种结构只需要 11 位来存储这些信息,正如我们从 Bloom 过滤器的构造函数中所看到的。因此,我们可以轻松地增加过滤器的大小,并相应地更新哈希函数,以获得更好的结果。例如,我们可以将数组的大小增加到 1,000(1,023 经常被使用,因为它是一个质数),我们仍然将使用少于 130 字节,这比大多数其他技术要少得多。随着哈希表大小的增加,我们的哈希函数也将变为%1023或类似的,并且将提供更好的结果和更好的数字分布。

这里需要注意的一个重要点是,由于我们没有在容器中存储实际数据,我们可以将其用作异构结构;也就是说,只要我们的哈希函数足够好,我们可以同时在同一个 Bloom 过滤器中插入不同类型的数据,比如整数、字符串和双精度浮点数。

在现实生活中有一些非常好的用例,特别是当数据量太大,即使使用哈希表也无法搜索,一些误报也是可以接受的。例如,在创建像 Gmail 或 Outlook 这样的电子邮件提供商的新电子邮件地址时,会检查电子邮件地址是否已经存在。数据库中存在数十亿个电子邮件地址,对于这样一个基本且频繁的查询,进行准确的检查将非常昂贵。幸运的是,即使电子邮件地址尚未被占用,有时说它已被占用也没关系,因为这不会造成任何伤害。用户只需选择其他内容。在这种情况下,使用 Bloom 过滤器是一个可行的选择。我们将在Activity 7电子邮件地址验证器中看到它的运作。

另一个例子是用于显示新广告的推荐算法,这些广告被 Facebook 等服务使用。每次查看动态时,它都会向您显示一个新广告。它可以简单地将您观看的广告的 ID 存储在 Bloom 过滤器中。然后,在显示广告之前,可以针对特定广告的 ID 进行检查。如果检查返回您观看了特定广告,即使您没有(误报),它也不会显示该广告。然而,这没关系,因为您根本不知道,毕竟您也没有看到那个广告。这样,您可以每次都以非常快的查找获得新广告。

活动 7:电子邮件地址验证器

在这个活动中,我们将创建一个类似于我们在许多电子邮件服务提供商(如 Gmail 和 Outlook)的注册过程中找到的电子邮件验证器。我们将使用 Bloom 过滤器来检查电子邮件地址是否已被他人占用。

这些高级步骤应该帮助您完成此活动:

  1. 创建一个BloomFilter类,可以接受一定数量的哈希函数和 Bloom 的大小。

  2. 对于哈希,使用 OpenSSL 库中的 MD5 算法生成给定电子邮件的哈希值。MD5 是一种 128 位的哈希算法。对于多个哈希函数,我们可以使用每个字节作为单独的哈希值。

  3. 要在 Bloom 过滤器中添加电子邮件,我们需要将在步骤 2中计算的哈希值的每个字节的所有位设置为true

  4. 要查找任何电子邮件,我们需要检查基于步骤 2中计算的哈希值的所有相关位是否为true

此活动的解决方案可在第 503 页找到。

总结

正如我们在介绍中提到的,查找问题在大多数应用程序中以一种或另一种方式遇到。根据我们的需求,我们可以使用确定性和概率性解决方案。在本章中,我们实现并看到了如何使用它们。最后,我们还看了 C++中用于哈希的内置容器的示例。这些容器在编写应用程序时非常有用,因为我们不需要每次为每种类型都实现它们。一个简单的经验法则是:如果我们可以看到对容器的find函数的大量调用,我们应该选择基于查找的解决方案。

到目前为止,我们已经看到了如何将数据存储在各种数据结构中并执行一些基本操作。在接下来的章节中,我们将研究各种类型的算法设计技术,以便优化这些操作,从分而治之开始。

第四章:分治

学习目标

在本章结束时,您将能够:

  • 描述分治设计范式

  • 实现标准的分治算法,如归并排序、快速排序和线性时间选择

  • 使用 MapReduce 编程模型解决问题

  • 学习如何使用多线程的 C++ MapReduce 实现

在本章中,我们将学习分治算法设计范式,并学习如何使用它来解决计算问题。

介绍

在上一章中,我们学习了一些常用的数据结构。数据结构是以不同形式组织数据的方式,数据结构使得控制和访问存储在其中的数据的成本成为可能。然而,使软件有用的不仅仅是存储和检索各种格式的数据的能力,而是能够对数据进行转换以解决计算问题的能力。对于给定的问题,对数据的精确定义和转换顺序由一系列称为算法的指令确定。

算法接受一组定义问题实例的输入,应用一系列变换,并输出一组结果。如果这些结果是手头计算问题的正确解决方案,我们称算法是正确的。算法的好坏由其效率决定,或者说算法需要执行多少指令才能产生正确的结果:

图 4.1:算法所需步骤随输入大小的扩展

图 4.1:算法所需步骤随输入大小的扩展

上图显示了算法所需步骤随输入大小的增长情况。复杂度更高的算法随着输入大小的增加而增长更快,对于足够大的输入,它们甚至在现代计算机系统上也可能无法运行。例如,假设我们有一台每秒可以执行一百万次操作的计算机。对于大小为 50 的输入,需要N log(N)步的算法将花费 283 微秒完成;需要N**2步的算法将花费 2.5 毫秒;需要N!N的阶乘)步的算法将需要大约 9,637,644,561,599,544,267,027,654,516,581,964,749,586,575,812,734.82 世纪来运行!

如果对于输入大小 N,算法以 N 的多项式步骤解决问题,则称算法是高效的。

多项式时间算法表达为解决方案的问题也被称为属于计算复杂性的类P(多项式)。问题可以分为几种其他计算复杂性,以下是一些示例:

  • NP(非确定性多项式时间)问题的解决方案可以在多项式时间内验证,但没有已知的多项式时间解决方案。

  • EXPTIME(指数时间)问题的解决方案运行时间与输入大小呈指数关系。

  • PSPACE(多项式空间)问题需要多项式数量的空间。

找出P中的问题集是否与NP中的问题集完全相同是著名的P = NP问题,经过数十年的努力仍未解决,甚至为任何能解决它的人提供了 100 万美元的奖金。我们将在第九章 动态规划 II中再次研究PNP类型的问题。

计算机科学家们几十年来一直将算法作为数学对象进行研究,并确定了一组通用的方法(或范式)来设计高效的算法,这些方法可以用来解决各种各样的问题。其中最广泛适用的算法设计范式之一被称为分治,将是我们在本章的研究对象。

分而治之类型的算法将给定的问题分解成较小的部分,尝试为每个部分解决问题,最后将每个部分的解决方案合并为整个问题的解决方案。几种广泛使用的算法属于这一类,例如二分搜索、快速排序、归并排序、矩阵乘法、快速傅里叶变换和天际线算法。这些算法几乎出现在今天使用的所有主要应用程序中,包括数据库、Web 浏览器,甚至语言运行时,如 Java 虚拟机和 V8 JavaScript 引擎。

在本章中,我们将向您展示使用分而治之的方法解决问题的含义,以及如何确定您的问题是否适合这样的解决方案。接下来,我们将练习递归思维,并向您展示现代 C++标准库提供的工具,以便您可以使用分而治之来解决问题。最后,我们将通过查看 MapReduce 来结束本章,包括讨论为什么以及如何扩展,以及如何使用相同的范例来扩展您的程序,包括 CPU 级别和机器级别的并行化。

让我们深入研究一种使用分而治之方法的基本算法-二分搜索。

二分搜索

让我们从标准搜索问题开始:假设我们有一个排序的正整数序列,并且需要找出一个数字N是否存在于序列中。搜索问题自然地出现在几个地方;例如,接待员在一组按客户 ID 排序的文件中寻找客户的文件,或者老师在学生注册表中寻找学生的成绩。他们实际上都在解决搜索问题。

现在,我们可以以两种不同的方式解决问题。在第一种方法中,我们遍历整个序列,检查每个元素是否等于N。这称为线性搜索,并在以下代码中显示:

bool linear_search(int N, std::vector<int>& sequence)
{
    for (auto i : sequence)
    {
        if (i == N)
            return true;      // Element found!
    }

    return false;
}

这种方法的一个好处是它适用于所有数组,无论是排序还是未排序。但是,它效率低下,并且没有考虑到给定数组是排序的。在算法复杂度方面,它是一个O(N)算法。

利用序列已排序的事实的另一种解决方案如下:

  1. range中开始整个序列。

  2. 将当前range的中间元素与N进行比较。让这个中间元素为M

  3. 如果M = N,我们在序列中找到了N,因此搜索停止。

  4. 否则,我们根据两条规则修改range

  • 如果N < M,这意味着如果N存在于range中,它将在M的左侧,因此我们可以安全地从range中删除M右侧的所有元素。

  • 如果N > M,算法从range中删除所有左侧的M元素。

  1. 如果range中仍有多于 1 个元素,则转到步骤 2

  2. 否则,N不存在于序列中,搜索停止。

为了说明这个算法,我们将展示二分搜索是如何工作的,其中S是从19的整数排序序列,N = 2

  1. 算法从将S的所有元素放入范围开始。在这一步中,中间元素被发现是5。我们比较N5
图 4.2:二分搜索算法-步骤 1
  1. 由于N < 5,如果N存在于序列中,它必须在5的左边。因此,我们可以安全地丢弃序列中位于5右侧的所有元素。现在我们的范围只有15之间的元素,中间元素现在是3。我们现在可以比较N3
图 4.3:二分搜索算法-步骤 2
  1. 我们发现当前的中间元素3仍然大于N,并且范围可以进一步修剪为仅包含13之间的元素。新的中间元素现在是2,它等于N,搜索终止:

图 4.4:二分搜索算法-步骤 3

图 4.4:二分搜索算法-步骤 3

在下一个练习中,我们将看一下二分搜索算法的实现。

练习 18:二分搜索基准

在这个练习中,我们将编写并基准测试二分搜索实现。按照以下步骤完成这个练习:

  1. 首先添加以下头文件:
#include <iostream>
#include <vector>
#include <chrono>
#include <random>
#include <algorithm>
#include <numeric>
  1. 添加线性搜索代码如下:
bool linear_search(int N, std::vector<int>& S)
{
        for (auto i : S)
        {
            if (i == N)
                return true;       // Element found!
        }

        return false;
}
  1. 添加此处显示的二分搜索代码:
bool binary_search(int N, std::vector<int>& S)
{
    auto first = S.begin();
    auto last = S.end();
    while (true)
    {
        // Get the middle element of current range
        auto range_length = std::distance(first, last);
        auto mid_element_index = first + std::floor(range_length / 2);
        auto mid_element = *(first + mid_element_index);
        // Compare the middle element of current range with N
        if (mid_element == N)
            return true;
        else if (mid_element > N)
            std::advance(last, -mid_element_index);
        if (mid_element < N)
            std::advance(first, mid_element_index);
        // If only one element left in the current range
        if (range_length == 1)
            return false;
    }
}
  1. 为了评估二分搜索的性能,我们将实现两个函数。首先,编写小测试:
void run_small_search_test()
{
    auto N = 2;
    std::vector<int> S{ 1, 3, 2, 4, 5, 7, 9, 8, 6 };
    std::sort(S.begin(), S.end());
    if (linear_search(N, S))
        std::cout << "Element found in set by linear search!" << std::endl;
    else
        std::cout << "Element not found." << std::endl;
    if (binary_search(N, S))
        std::cout << "Element found in set by binary search!" << std::endl;
    else
        std::cout << "Element not found." << std::endl;
}
  1. 现在,添加大型测试函数,如下所示:
void run_large_search_test(int size, int N)
{
    std::vector<int> S;
    std::random_device rd;
    std::mt19937 rand(rd());
      // distribution in range [1, size]
    std::uniform_int_distribution<std::mt19937::result_type> uniform_dist(1, size); 
    // Insert random elements
    for (auto i=0;i<size;i++)
        S.push_back(uniform_dist(rand));
    std::sort(S.begin(), S.end());
    // To measure the time taken, start the clock
    std::chrono::steady_clock::time_point begin = std::chrono::steady_clock::now();

    bool search_result = binary_search(111, S);
    // Stop the clock
    std::chrono::steady_clock::time_point end = std::chrono::steady_clock::now();

    std::cout << "Time taken by binary search = " << 
std::chrono::duration_cast<std::chrono::microseconds>
(end - begin).count() << std::endl;

    if (search_result)
        std::cout << "Element found in set!" << std::endl;
    else
        std::cout << "Element not found." << std::endl;
}
  1. 最后,添加以下驱动程序代码,用于在不同大小的随机生成向量中搜索数字36543
int main()
{
    run_small_search_test();
    run_large_search_test(100000, 36543);
    run_large_search_test(1000000, 36543);
    run_large_search_test(10000000, 36543);
    return 0;
}
  1. 以 x64-Debug 模式编译程序并运行。输出应如下所示:

图 4.5:启用调试的二分搜索

图 4.5:启用调试的二分搜索

请注意,三个输入数组的大小都比前一个数组大 10 倍,因此第三个数组比第一个数组大 100 倍,它本身包含十万个元素。然而,使用二分搜索在数组中搜索所花费的时间仅增加了 10 微秒。

在上一个测试中,我们没有允许任何编译器优化,并且在运行时附加了调试器。现在,让我们看看当我们的编译器允许优化 C++代码而没有附加调试器时会发生什么。尝试以 x64-Release 模式编译练习 18中的二分搜索基准代码,并运行。输出应如下所示:

图 4.6:打开编译器优化的二分搜索

图 4.6:打开编译器优化的二分搜索

无论向量大小如何,二分搜索在这三种情况下大致需要相同的时间!

请注意,我们的二分搜索实现使用迭代器和 C++标准库函数,如std::distance()std::advance()。这在现代 C++中被认为是良好的实践,因为它有助于使我们的代码不依赖于基础数据类型,并且可以避免索引越界错误。

现在,假设我们想在一个浮点数向量上执行搜索。我们如何修改上一个练习中的函数?答案非常简单。我们可以修改函数签名如下:

bool linear_search(float N, std::vector<float>& S)
bool binary_search(float N, std::vector<float>& S)

搜索函数内部的其余代码仍然可以保持完全相同,因为它完全独立于基础数据类型,仅取决于容器数据类型的行为。在现代 C++中,将核心算法逻辑与算法操作的基础数据类型分离开来,是编写可重用代码的基石。我们将在本书的过程中看到几个这样的分离示例,并深入研究标准库提供的更多函数,这些函数可以帮助我们编写可重用和健壮的代码。

活动 8:疫苗接种

想象一下,现在是流感季节,卫生部门官员计划访问一所学校,以确保所有入学的孩子都接种了流感疫苗。然而,出现了一个问题:一些孩子已经接种了流感疫苗,但不记得他们是否已经接种了卫生官员计划为所有学生接种的特定类别的流感疫苗。官方记录被寻找出来,部门能够找到已经接种疫苗的学生名单。这里显示了名单的一个小节:

图 4.7:疫苗接种记录摘录

图 4.7:疫苗接种记录摘录

假设所有名称都是正整数,并且给定列表已排序。您的任务是编写一个程序,可以查找列表中给定学生的接种状况,并向官员输出学生是否需要接种疫苗。学生需要接种疫苗,如果满足以下两个条件:

  • 如果它们不在列表中

  • 如果他们在名单上但尚未接种流感疫苗。

由于列表中可能有大量学生,您的程序应尽可能快速和高效。程序的最终输出应如下所示:

图 4.8:活动 8 的示例输出

图 4.8:活动 8 的示例输出

高级步骤

此活动的解决方案使用了稍微修改过的二分搜索算法。让我们开始吧:

  1. 将每个学生表示为Student类的对象,可以定义如下:
 class Student
{
    std::pair<int, int> name;
    bool vaccinated;
}
  1. 重载Student类的必要运算符,以便可以使用标准库的std::sort()函数对学生向量进行排序。

  2. 使用二分搜索查看学生是否在名单上。

  3. 如果学生不在列表中,则您的函数应返回true,因为学生需要接种疫苗。

  4. 否则,如果学生在名单上但尚未接种疫苗,则返回true

  5. 否则,返回false

注意

此活动的解决方案可在第 506 页找到。

理解分而治之方法

在分而治之方法的核心是一个简单直观的想法:如果您不知道如何解决问题的大实例,请找到一个小部分的问题,您可以解决,然后解决它。然后,迭代更多这样的部分,一旦解决了所有部分,将结果合并成原始问题的大一致解决方案。使用分而治之方法解决问题有三个步骤:

  1. 划分:将原始问题划分为部分,以便为每个部分解决相同的问题。

  2. 征服:解决每个部分的问题。

  3. 合并:将不同部分的解决方案合并成原始问题的解决方案。

在前一节中,我们看了一个使用分而治之来在序列中搜索的示例。在每一步中,二分搜索尝试仅在标记为range的序列的一部分中搜索。当找到元素或不再能将range进一步分割为更小的部分时,搜索终止。然而,搜索问题与大多数分而治之算法有所不同:在搜索问题中,如果元素可以在序列的较小range中找到,则它也一定存在于完整序列中。换句话说,在序列的较小部分中的问题的解决方案给出了整个问题的解决方案。因此,解决方案不需要实现一般分而治之方法的组合步骤。遗憾的是,这种特性并不适用于绝大多数可以使用分而治之方法解决的计算问题。在接下来的部分中,我们将深入探讨并查看更多使用分而治之方法解决问题的示例。

使用分而治之进行排序

现在我们将探讨如何在解决另一个标准问题——排序时实现分治方法。拥有高效的排序算法的重要性不言而喻。在计算机发展的早期,即 20 世纪 60 年代,计算机制造商估计他们机器中 25%的 CPU 周期都用于对数组元素进行排序。尽管多年来计算机领域发生了重大变化,但排序仍然是当今广泛研究的内容,并且仍然是几个应用中的基本操作。例如,它是数据库索引背后的关键思想,然后允许使用对数时间搜索快速访问存储的数据,这类似于二分搜索。

排序算法的一般要求如下:

  • 实现应该能够处理任何数据类型。它应该能够对整数、浮点小数甚至 C++结构或类进行排序,其中不同元素之间可以定义顺序。

  • 排序算法应该能够处理大量数据,也就是说,相同的算法应该能够处理甚至大于计算机主存储器的数据大小。

  • 排序算法应该在理论上和实践中都很快。

虽然所有三个列出的目标都是可取的,但在实践中,很难同时实现第二和第三个目标。第二个目标需要外部排序,即对不驻留在计算机主存储器上的数据进行排序。外部排序算法可以在执行期间仅持有整个数据的一个小子集时工作。

在本节中,我们将介绍两种排序算法:归并排序和快速排序。归并排序是一种外部排序算法,因此实现了我们的第二个目标,而快速排序,顾名思义,是实践中已知的最快的排序算法之一,并且作为 C++标准库的std::sort()函数的一部分出现。

归并排序

归并排序是已知的最古老的排序算法之一,出现在 20 世纪 40 年代末的报告中。当时的计算机只有几百字节的主存储器,通常用于复杂的数学分析。因此,对于排序算法来说,即使不能将所有要操作的数据都保存在主存储器中,也是至关重要的。归并排序通过利用一个简单的思想解决了这个问题——对一组大量元素进行排序与对一小部分元素进行排序,然后合并排序的子集,以保持元素的递增或递减顺序是相同的:

图 4.9:归并排序

图 4.9:归并排序

上图显示了使用归并排序对整数数组进行排序的示例。首先,算法将原始数组分成子数组,直到每个子数组只包含一个元素(步骤 14)。在随后的所有步骤中,算法将元素合并到更大的数组中,保持每个子数组中的元素按递增顺序排列。

练习 19:归并排序

在本练习中,我们将实现归并排序算法。步骤如下:

  1. 导入以下头文件:
#include <iostream>
#include <vector>
#include <chrono>
#include <random>
#include <algorithm>
#include <numeric>
  1. 对两个向量进行合并操作的 C++代码如下。编写merge()函数如下:
template <typename T>
std::vector<T> merge(std::vector<T>& arr1, std::vector<T>& arr2)
{
    std::vector<T> merged;
    auto iter1 = arr1.begin();
    auto iter2 = arr2.begin();
    while (iter1 != arr1.end() && iter2 != arr2.end())
    {
        if (*iter1 < *iter2)
        {
            merged.emplace_back(*iter1);
            iter1++;
        }
        else
        {
            merged.emplace_back(*iter2);
            iter2++;
        }
    }
    if (iter1 != arr1.end())
    {
        for (; iter1 != arr1.end(); iter1++)
            merged.emplace_back(*iter1);
    }
    else
    {
        for (; iter2 != arr2.end(); iter2++)
            merged.emplace_back(*iter2);
    }
    return merged;
}

模板化的merge()函数接受类型为T的两个向量的引用,并返回一个包含输入数组中元素的新向量,但按递增顺序排序。

  1. 现在我们可以使用合并操作来编写递归的归并排序实现,如下所示:
template <typename T>
std::vector<T> merge_sort(std::vector<T> arr)
{
    if (arr.size() > 1)
    {
        auto mid = size_t(arr.size() / 2);
        auto left_half = merge_sort<T>(std::vector<T>(arr.begin(), arr.begin() + mid));
        auto right_half = merge_sort<T>(std::vector<T>(arr.begin() + mid, arr.end()));
        return merge<T>(left_half, right_half);
    }

    return arr;
}
  1. 添加以下函数以打印向量:
template <typename T>
void print_vector(std::vector<T> arr)
{
    for (auto i : arr)
        std::cout << i << " ";

    std::cout << std::endl;
}
  1. 以下函数允许我们测试归并排序算法的实现:
void run_merge_sort_test()
{
    std::vector<int>    S1{ 45, 1, 3, 1, 2, 3, 45, 5, 1, 2, 44, 5, 7 };
    std::vector<float>  S2{ 45.6f, 1.0f, 3.8f, 1.01f, 2.2f, 3.9f, 45.3f, 5.5f, 1.0f, 2.0f, 44.0f, 5.0f, 7.0f };
    std::vector<double> S3{ 45.6, 1.0, 3.8, 1.01, 2.2, 3.9, 45.3, 5.5, 1.0, 2.0,  44.0, 5.0, 7.0 };
    std::vector<char>   C{ 'b','z','a','e','f','t','q','u','y' };
    std::cout << "Unsorted arrays:" << std::endl;
    print_vector<int>(S1);
    print_vector<float>(S2);
    print_vector<double>(S3);
    print_vector<char>(C);
    std::cout << std::endl;
    auto sorted_S1 = merge_sort<int>(S1);
    auto sorted_S2 = merge_sort<float>(S2);
    auto sorted_S3 = merge_sort<double>(S3);
    auto sorted_C = merge_sort<char>(C);
    std::cout << "Arrays sorted using merge sort:" 
                << std::endl;
    print_vector<int>(sorted_S1);
    print_vector<float>(sorted_S2);
    print_vector<double>(sorted_S3);
    print_vector<char>(sorted_C);
    std::cout << std::endl;
}
int main()
{
    run_merge_sort_test();
    return 0;
}
  1. 编译并运行程序。输出应该如下所示:

图 4.10:归并排序

图 4.10:归并排序

本练习中对归并排序的实现延续了我们不将算法实现与底层数据类型绑定并且仅依赖于容器公开的函数的主题。

快速排序

在归并排序的情况下,目标是对大量数据进行排序,而快速排序试图减少平均情况下的运行时间。快速排序中的基本思想与归并排序相同-将原始输入数组分成较小的子数组,对子数组进行排序,然后合并结果以获得排序后的数组。但是,快速排序使用的基本操作是分区而不是合并。

分区操作的工作原理

给定一个数组和数组中的枢轴元素 P分区操作执行两件事:

  1. 它将原始数组分成两个子数组LR,其中L包含给定数组中小于或等于P的所有元素,R包含给定数组中大于P的所有元素。

  2. 它重新组织数组中的元素顺序LPR

以下图表显示了对未排序数组应用的分区的结果,其中选择了第一个元素作为枢轴:

图 4.11:选择一个枢轴并围绕它对向量进行分区

图 4.11:选择一个枢轴并围绕它对向量进行分区

分区操作的一个有用属性是,在应用分区操作后,向量中枢轴P的新位置成为向量排序时P将具有的位置。例如,元素5在应用分区操作后出现在数组的第 5 个位置,这是元素5在数组按递增顺序排序时将出现的位置。

前面的属性也是快速排序算法的核心思想,其工作原理如下:

  1. 如果输入数组A中有超过 1 个元素,则在A上应用分区操作。它将产生子数组LR

  2. 使用L作为步骤 1的输入。

  3. 使用R作为步骤 1的输入。

步骤 23是对由分区操作生成的数组进行递归调用,然后应用于原始输入数组。分区操作的简单递归应用导致元素按递增顺序排序。由于快速排序递归树可能会迅速变得很深,因此以下图表显示了在一个包含六个元素的小数组{5, 6, 7, 3, 1, 9}上应用快速排序的示例:

图 4.12:快速排序算法的可视化

图 4.12:快速排序算法的可视化

算法的每次迭代都显示了对先前步骤中使用突出显示的枢轴应用的分区操作的结果。应该注意,我们将数组的第一个元素作为枢轴的选择是任意的。数组的任何元素都可以被选择为枢轴,而不会影响快速排序算法的正确性。

练习 20:快速排序

在本练习中,我们将实现并测试快速排序的实现。让我们开始吧:

  1. 导入以下标头:
#include <iostream>
#include <vector>
#include <chrono>
#include <random>
#include <algorithm>
#include <numeric>
  1. 分区操作的 C++代码如下。按照以下所示编写partition()函数:
template <typename T>
auto partition(typename std::vector<T>::iterator begin,
            typename std::vector<T>::iterator last)
{
      // Create 3 iterators, 
      // one pointing to the pivot, one to the first element and 
      // one to the last element of the vector.
    auto pivot_val = *begin;
    auto left_iter = begin+1;
    auto right_iter = last;
    while (true)
    {
        // Starting from the first element of vector, find an element that is greater than pivot.
        while (*left_iter <= pivot_val && 
                   std::distance(left_iter, right_iter) > 0)
            left_iter++;
        // Starting from the end of vector moving to the beginning, find an element that is lesser than the pivot.
        while (*right_iter > pivot_val && 
                   std::distance(left_iter, right_iter) > 0)
            right_iter--;
        // If left and right iterators meet, there are no elements left to swap. Else, swap the elements pointed to by the left and right iterators
        if (left_iter == right_iter)
            break;
        else
            std::iter_swap(left_iter, right_iter);
    }
    if (pivot_val > *right_iter)
        std::iter_swap(begin, right_iter);

    return right_iter;
}

此处显示的实现仅接受底层容器对象上的迭代器,并返回指向数组中分区索引的另一个迭代器。这意味着向量的所有元素都大于右分区中的枢轴,而小于或等于枢轴的所有元素都在左分区中。

  1. 快速排序算法递归使用分区操作,如下所示:
template <typename T>
void quick_sort(typename std::vector<T>::iterator begin, 
        typename std::vector<T>::iterator last)
{
    // If there are more than 1 elements in the vector
    if (std::distance(begin, last) >= 1)
    {
        // Apply the partition operation
        auto partition_iter = partition<T>(begin, last);

        // Recursively sort the vectors created by the partition operation
        quick_sort<T>(begin, partition_iter-1);
        quick_sort<T>(partition_iter, last);
    }
}
  1. print_vector()用于将向量打印到控制台,并实现如下:
template <typename T>
void print_vector(std::vector<T> arr)
{
    for (auto i : arr)
        std::cout << i << " ";

    std::cout << std::endl;
}
  1. 根据练习 19归并排序中的驱动程序代码进行调整:
void run_quick_sort_test()
{
    std::vector<int> S1{ 45, 1, 3, 1, 2, 3, 45, 5, 1, 2, 44, 5, 7 };
    std::vector<float>  S2{ 45.6f, 1.0f, 3.8f, 1.01f, 2.2f, 3.9f, 45.3f, 5.5f, 1.0f, 2.0f, 44.0f, 5.0f, 7.0f };
    std::vector<double> S3{ 45.6, 1.0, 3.8, 1.01, 2.2, 3.9, 45.3, 5.5, 1.0, 2.0,  44.0, 5.0, 7.0 };
    std::vector<char> C{ 'b','z','a','e','f','t','q','u','y'};
    std::cout << "Unsorted arrays:" << std::endl;
    print_vector<int>(S1);
    print_vector<float>(S2);
    print_vector<double>(S3);
    print_vector<char>(C);
    std::cout << std::endl;
    quick_sort<int>(S1.begin(), S1.end() - 1);
    quick_sort<float>(S2.begin(), S2.end() - 1);
    quick_sort<double>(S3.begin(), S3.end() - 1);
    quick_sort<char>(C.begin(), C.end() - 1);
    std::cout << "Arrays sorted using quick sort:" << std::endl;
    print_vector<int>(S1);
    print_vector<float>(S2);
    print_vector<double>(S3);
    print_vector<char>(C);
    std::cout << std::endl;
}
  1. 编写一个main()函数,调用run_quick_sort_test()
int main()
{
    run_quick_sort_test();
    return 0;
}
  1. 您的最终输出应如下所示:

图 4.13:快速排序排序

图 4.13:快速排序排序

然而,快速排序的运行时间取决于我们选择的枢轴有多“好”。快速排序的最佳情况是在任何步骤中,枢轴都是当前数组的中位数元素;在这种情况下,快速排序能够将元素分成每一步相等大小的向量,因此,递归树的深度恰好是log(n)。如果不选择中位数作为枢轴,会导致分区大小不平衡,因此递归树更深,运行时间更长。

快速排序和归并排序的渐近复杂度如下所示:

图 4.14:快速排序和归并排序的渐近复杂度

图 4.14:快速排序和归并排序的渐近复杂度

活动 9:部分排序

在最后两个练习中,我们实现了总排序算法,它按照递增(或递减)顺序对向量的所有元素进行排序。然而,在一些问题实例中,这可能是过度的。例如,想象一下,您得到一个包含地球上所有人的年龄的向量,并被要求找到人口最老的 10%的人的中位数年龄。

对这个问题的一个天真的解决方案是对年龄向量进行排序,从向量中提取最老的 10%人的年龄,然后找到提取向量的中位数。然而,这种解决方案是浪费的,因为它做的远远超出了计算解决方案所需的,也就是说,它对整个数组进行排序,最终只使用排序数组的 10%来计算所需的解决方案。

对这类问题的更好解决方案可以通过将归并排序和快速排序等总排序算法专门化为部分排序算法来得到。部分排序算法只对给定向量中的指定数量的元素进行排序,而将向量的其余部分保持未排序状态。

部分快速排序的描述如下:

  1. 假设我们有一个向量V,我们需要创建一个有序的k元素的子向量。

  2. V上应用分区操作,假设V的第一个元素作为枢轴(同样,这个选择完全是任意的)。分区操作的结果是两个向量,LR,其中L包含所有小于枢轴的V的元素,R包含所有大于枢轴的元素。此外,枢轴的新位置是排序数组中枢轴的“正确”位置。

  3. 使用L作为步骤 1的输入。

  4. 如果步骤 2中枢轴的新位置小于k,则使用R作为步骤 1的输入。

您在本活动中的任务是实现部分快速排序算法,该算法使用随机生成的数组来测试算法的输出。大小为100k = 100的向量的最终输出应如下所示:

图 4.15:活动 9 的示例输出

图 4.15:活动 9 的示例输出

注意

本活动的解决方案可在第 510 页找到。

线性时间选择

在前一节中,我们看了使用分治范式的简单算法示例,并介绍了分区和合并操作。到目前为止,我们对分治算法的看法局限于那些将每个中间步骤递归地分成两个子部分的算法。然而,有些问题在将每一步分成更多子部分时可以产生实质性的好处。在接下来的部分,我们将研究这样一个问题——线性时间选择。

想象一下,你负责为你的学校组织一场游行队伍。为了确保所有乐队成员看起来一致,学生的身高是相同的很重要。此外,要求所有年级的学生都参加。为了解决这些问题,你提出了以下解决方案——你将选择每个年级第 15 矮的学生参加游行。问题可以形式化如下:给定一个随机排序的元素集S,要求你找到S中第i小的元素。一个简单的解决方案可能是对输入进行排序,然后选择第i个元素。然而,这种解决方案的算法复杂度是O(n log n)。在本节中,我们将通过分治法解决这个问题,其复杂度为O(n)

我们的解决方案依赖于正确使用分区操作。我们在上一小节介绍的分区操作接受一个向量和一个枢轴,然后将向量分成两部分,一部分包含所有小于枢轴的元素,另一部分包含所有大于枢轴的元素。最终算法的工作如下:

  1. 假设我们有一个输入向量V,我们需要找到第i小的元素。

  2. 将输入向量V分成向量V**1V**2V**3V**n/5,每个向量包含五个元素(如果需要,最后一个向量可以少于五个元素)。

  3. 接下来,我们对每个V**i进行排序。

  4. 对于每个V**i,找到中位数m**i,并将所有中位数收集到一个集合M中,如下所示:图 4.16:找到每个子向量的中位数

图 4.16:找到每个子向量的中位数
  1. 找到M的中位数元素q图 4.17:找到一组中位数的中位数
图 4.17:找到一组中位数的中位数
  1. 使用分区操作在V上使用q作为枢轴得到两个向量LR图 4.18:对整个向量进行分区
图 4.18:对整个向量进行分区
  1. 根据分区操作的定义,L包含所有小于q的元素,R包含所有大于q的元素。假设L(k-1)个元素:
  • 如果i = k,那么q就是V中的第i个元素。

  • 如果i < k,则设置V = L并转到步骤 1

  • 如果i > k,则设置V = Ri = i - k,并转到步骤 1

以下练习演示了在 C++中实现此算法。

练习 21:线性时间选择

在这个练习中,我们将实现线性时间选择算法。让我们开始吧:

  1. 导入以下头文件:
#include <iostream>
#include <vector>
#include <chrono>
#include <random>
#include <algorithm>
#include <numeric>
  1. 编写如下所示的辅助函数:
template<typename T>
auto find_median(typename std::vector<T>::iterator begin, typename std::vector<T>::iterator last)
{
    // Sort the array
    quick_sort<T>(begin, last);

    // Return the middle element, i.e. median
    return begin + (std::distance(begin, last)/2); 
}
  1. 练习 20中,快速排序,我们的分区函数假设给定向量中的第一个元素始终是要使用的枢轴。现在我们需要一个更一般的分区操作形式,可以与任何枢轴元素一起使用:
template <typename T>
auto partition_using_given_pivot(
typename std::vector<T>::iterator begin, 
typename std::vector<T>::iterator end, 
typename std::vector<T>::iterator pivot)
{
        // Since the pivot is already given,
        // Create two iterators pointing to the first and last element of the vector respectively
    auto left_iter = begin;
    auto right_iter = end;
    while (true)
    {
        // Starting from the first element of vector, find an element that is greater than pivot.
        while (*left_iter < *pivot && left_iter != right_iter)
            left_iter++;
        // Starting from the end of vector moving to the beginning, find an element that is lesser than the pivot.
        while (*right_iter >= *pivot && 
                  left_iter != right_iter)
            right_iter--;
        // If left and right iterators meet, there are no elements left to swap. Else, swap the elements pointed to by the left and right iterators.
        if (left_iter == right_iter)
            break;
        else
            std::iter_swap(left_iter, right_iter);
    }
    if (*pivot > *right_iter)
        std::iter_swap(pivot, right_iter);
    return right_iter;
}
  1. 使用以下代码来实现我们的线性时间搜索算法:
// Finds ith smallest element in vector V
template<typename T>
typename std::vector<T>::iterator linear_time_select(
typename std::vector<T>::iterator begin,
typename std::vector<T>::iterator last, size_t i)
{
    auto size = std::distance(begin, last);
    if (size > 0 && i < size) {
        // Get the number of V_i groups of 5 elements each
        auto num_Vi = (size+4) / 5; 
        size_t j = 0;
        // For each V_i, find the median and store in vector M
        std::vector<T> M;
        for (; j < size/5; j++)
        {
            auto b = begin + (j * 5);
            auto l = begin + (j * 5) + 5;
            M.push_back(*find_median<T>(b, l));
        }
        if (j * 5 < size)
        {
            auto b = begin + (j * 5);
            auto l = begin + (j * 5) + (size % 5);
            M.push_back(*find_median<T>(b, l));
        }
        // Find the middle element ('q' as discussed)
           auto median_of_medians = (M.size() == 1)? M.begin():
      linear_time_select<T>(M.begin(), 
                            M.end()-1, M.size() / 2);

         // Apply the partition operation and find correct position 'k' of pivot 'q'.
        auto partition_iter = partition_using_given_pivot<T>(begin, last, median_of_medians);
        auto k = std::distance(begin, partition_iter)+1;
        if (i == k)
            return partition_iter;
        else if (i < k)
            return linear_time_select<T>(begin, partition_iter - 1, i);
        else if (i > k)
            return linear_time_select<T>(partition_iter + 1, last, i-k);
    }
    else {
        return begin;
    }
}
  1. 添加合并排序实现,如下所示的代码。我们将使用排序算法来证明我们实现的正确性:
template <typename T>
std::vector<T> merge(std::vector<T>& arr1, std::vector<T>& arr2)
{
    std::vector<T> merged;
    auto iter1 = arr1.begin();
    auto iter2 = arr2.begin();
    while (iter1 != arr1.end() && iter2 != arr2.end())
    {
        if (*iter1 < *iter2)
        {
            merged.emplace_back(*iter1);
            iter1++;
        }
        else
        {
            merged.emplace_back(*iter2);
            iter2++;
        }
    }
    if (iter1 != arr1.end())
    {
        for (; iter1 != arr1.end(); iter1++)
            merged.emplace_back(*iter1);
    }
    else
    {
        for (; iter2 != arr2.end(); iter2++)
            merged.emplace_back(*iter2);
    }
    return merged;
}
template <typename T>
std::vector<T> merge_sort(std::vector<T> arr)
{
    if (arr.size() > 1)
    {
        auto mid = size_t(arr.size() / 2);
        auto left_half = merge_sort(std::vector<T>(arr.begin(),
            arr.begin() + mid));
        auto right_half = merge_sort(std::vector<T>(arr.begin() + mid,
            arr.end()));
        return merge<T>(left_half, right_half);
    }
    return arr;
}
  1. 最后,添加以下驱动程序和测试函数:
void run_linear_select_test()
{
    std::vector<int> S1{ 45, 1, 3, 1, 2, 3, 45, 5, 1, 2, 44, 5, 7 };
    std::cout << "Original vector:" << std::endl;
    print_vector<int> (S1);
    std::cout << "Sorted vector:" << std::endl;
    print_vector<int>(merge_sort<int>(S1));
    std::cout << "3rd element: " 
                 << *linear_time_select<int>(S1.begin(), S1.end() - 1, 3) << std::endl;
    std::cout << "5th element: " 
                 << *linear_time_select<int>(S1.begin(), S1.end() - 1, 5) << std::endl;
    std::cout << "11th element: " 
                 << *linear_time_select<int>(S1.begin(), S1.end() - 1, 11) << std::endl;
}
int main()
{
    run_linear_select_test();
    return 0;
}
  1. 编译并运行代码。你的最终输出应该如下所示:

图 4.19:使用线性时间选择找到第 3、第 5 和第 11 个元素

图 4.19:使用线性时间选择找到第 3、第 5 和第 11 个元素

虽然对给定算法的详细理论分析超出了本章的范围,但算法的运行时间值得讨论。前面算法为什么有效的基本思想是,每次调用linear_time_select()时,都会应用分区操作,然后函数在其中一个分区上递归调用自身。在每个递归步骤中,问题的大小至少减少 30%。由于找到五个元素的中位数是一个常数时间操作,因此通过对前面算法得到的递归方程进行归纳,可以看到运行时间确实是O(n)

注意

线性时间选择算法的一个有趣特性是,当V被分成每个五个元素的子向量时,它的已知渐近复杂度(线性)被实现。找到导致更好渐近复杂度的子向量的恒定大小仍然是一个未解决的问题。

C++标准库工具用于分治

在上一节中,我们手动实现了分治算法所需的函数。然而,C++标准库捆绑了一大批预定义函数,可以在编程时节省大量工作。以下表格提供了一个常用函数的便捷列表,这些函数在实现使用分治范例的算法时使用。我们简要描述了这些函数以供参考,但出于简洁起见,详细实现超出了本章的范围。请随意探索更多关于这些函数的信息;基于本章涵盖的概念,您应该能够理解它们。

图 4.20:一些用于算法的有用 STL 函数

图 4.20:一些用于算法的有用 STL 函数

在更高抽象级别上的分治-MapReduce

到目前为止,在本章中,我们已经将分治作为一种算法设计技术,并使用它来使用预定义的分治合并步骤集来解决我们的问题。在本节中,我们将稍微偏离一下,看看当我们需要将问题分解为较小部分并分别解决每个部分时,相同的分治原则如何在需要将软件扩展到单台计算机的计算能力之外并使用计算机集群来解决问题时特别有帮助。

原始MapReduce论文的开头如下:

“MapReduce 是一个用于处理和生成大型数据集的编程模型及其相关实现。用户指定一个映射函数,该函数处理键值对以生成一组中间键/值对,以及一个减少函数,该函数合并与相同中间键关联的所有中间值。”

注意

您可以参考 Jeffrey Dean 和 Sanjay Ghemawat 于 2004 年发表的有关 MapReduce 模型的原始研究论文,链接在这里:static.googleusercontent.com/media/research.google.com/en/us/archive/mapreduce-osdi04.pdf

自从原始论文首次出现以来,MapReduce 编程模型的几个开源实现已经出现,其中最引人注目的是 Hadoop。Hadoop 为用户提供了一个编程工具包,用户可以编写映射和减少函数,这些函数可以应用于存储在名为 Hadoop 分布式文件系统(HDFS)中的数据。由于 HDFS 可以轻松扩展到通过网络连接的数千台机器的集群,因此 MapReduce 程序能够随着集群的规模而扩展。

然而,在这一部分,我们对 Hadoop 不感兴趣,而是对 MapReduce 作为一种编程范式以及它与手头的主题,即分治技术的关联感兴趣。我们将坚持使用一个使用多线程来模拟任务并行化的开源单机 MapReduce 实现,而不是 Hadoop。

映射和减少抽象

mapreduce这两个术语起源于诸如 Lisp 之类的函数式编程语言。

映射是一个操作,它接受一个容器C,并对C的每个元素应用给定的函数f(x)。下图显示了使用f(x) = x**2的示例:

图 4.21:映射容器的值

图 4.21:映射容器的值

减少是一个操作,它通过将给定函数f(acc, x)应用于容器C的每个元素x来聚合值,并返回单个值。下图显示了这一点:

图 4.22:减少容器的值

图 4.22:减少容器的值

C++标准库包含映射和减少操作,即std::transform()std::accumulate(),分别(std::reduce()也在 C++ 17 中可用)。

注意

std::accumulate()是一种只使用加法函数的限制形式的减少操作。更新的编译器还提供了std::reduce(),它更通用并且可以并行化。

以下练习演示了使用 C++标准库实现 MapReduce。

练习 22:在 C++标准库中进行映射和减少

在这个练习中,我们将看到如何使用这些函数来进一步理解映射和减少操作。让我们开始吧:

  1. 导入以下头文件:
#include <iostream>
#include <vector>
#include <chrono>
#include <random>
#include <algorithm>
#include <numeric>
  1. 首先创建一个具有随机元素的数组:
void transform_test(size_t size)
{
    std::vector<int> S, Tr;
    std::random_device rd;
    std::mt19937 rand(rd());
    std::uniform_int_distribution<std::mt19937::result_type> uniform_dist(1, size);
    // Insert random elements
    for (auto i = 0; i < size; i++)
        S.push_back(uniform_dist(rand));
    std::cout << "Original array, S: ";
    for (auto i : S)
        std::cout << i << " ";
    std::cout << std::endl;
    std::transform(S.begin(), S.end(), std::back_inserter(Tr), 
                      [](int x) {return std::pow(x, 2.0); });
    std::cout << "Transformed array, Tr: ";
    for (auto i : Tr)
        std::cout << i << " ";
    std::cout << std::endl;
    // For_each
    std::for_each(S.begin(), S.end(), [](int &x) {x = std::pow(x, 2.0); });
    std::cout << "After applying for_each to S: ";
    for (auto i : S)
            std::cout << i << " ";
    std::cout << std::endl;
}
  1. transform_test()函数随机生成给定大小的向量,并将变换f(x) = x**2应用于向量。

注意

void reduce_test(size_t size)
{
    std::vector<int> S;
    std::random_device rd;
    std::mt19937 rand(rd());
    std::uniform_int_distribution<std::mt19937::result_type> uniform_dist(1, size);
    // Insert random elements
    for (auto i = 0; i < size; i++)
        S.push_back(uniform_dist(rand));
    std::cout << std::endl << "Reduce test== " << std::endl << "Original array, S: ";
    for (auto i : S)
        std::cout << i << " ";
    std::cout << std::endl;
    // Accumulate
    std::cout<<"std::accumulate() = " << std::accumulate(S.begin(), S.end(), 0, [](int acc, int x) {return acc+x; });
    std::cout << std::endl;
}
  1. 添加以下驱动程序代码:
int main() 
{
    transform_test(10);
    reduce_test(10);
    return 0;
}
  1. 编译并运行代码。您的输出应该如下所示:

图 4.23:映射和减少数组

图 4.23:映射和减少数组

整合部分-使用 MapReduce 框架

要使用 MapReduce 模型编写程序,我们必须能够将我们期望的计算表达为两个阶段的系列:映射(也称为分区),在这个阶段程序读取输入并创建一组中间<key,value>对,以及减少,在这个阶段中间<key,value>对以所需的方式组合以生成最终结果。以下图表说明了这个想法:

图 4.24:通用 MapReduce 框架

图 4.24:通用 MapReduce 框架

像 Hadoop 这样的框架为 MapReduce 编程模型增加的主要价值在于,它们使映射和减少操作分布式和高度可扩展,从而使计算在一组机器上运行,并且总共所需的时间减少了。

我们将使用 MapReduce 框架来执行以下练习中的示例任务。

注意

以下的练习和活动需要在您的系统上安装 Boost C++库。请按照以下链接获取 Boost 库:

Windows:www.boost.org/doc/libs/1_71_0/more/getting_started/windows.html

Linux/macOS:www.boost.org/doc/libs/1_71_0/more/getting_started/unix-variants.html

练习 23:使用 MapReduce 检查质数

给定一个正整数N,我们希望找出1N之间的质数。在这个练习中,我们将看到如何使用 MapReduce 编程模型来实现这一点,并使用多个线程解决这个问题。让我们开始吧:

  1. 让我们首先包括所需的库,并定义一个使用质因数分解检查给定数字是否为质数的函数:
#include <iostream>
#include "mapreduce.hpp"
namespace prime_calculator {
    bool const is_prime(long const number)
    {
        if (number > 2)
        {
            if (number % 2 == 0)
                return false;
            long const n = std::abs(number);
            long const sqrt_number = static_cast<long>(std::sqrt(
static_cast<double>(n)));
            for (long i = 3; i <= sqrt_number; i += 2)
            {
                if (n % i == 0)
                    return false;
            }
        }
        else if (number == 0 || number == 1)
            return false;
        return true;
    }
  1. 以下类用于生成具有给定差值的一系列数字(也称为步长):
    template<typename MapTask>
    class number_source : mapreduce::detail::noncopyable
    {
    public:
        number_source(long first, long last, long step)
            : sequence_(0), first_(first), last_(last), step_(step)
        {
        }
        bool const setup_key(typename MapTask::key_type& key)
        {
            key = sequence_++;
            return (key * step_ <= last_);
        }
        bool const get_data(typename MapTask::key_type const& key, typename MapTask::value_type& value)
        {
            typename MapTask::value_type val;
            val.first = first_ + (key * step_);
            val.second = std::min(val.first + step_ - 1, last_);
            std::swap(val, value);
            return true;
        }
    private:
        long sequence_;
        long const step_;
        long const last_;
        long const first_;
    };
  1. 以下函数定义了映射阶段要执行的步骤:
    struct map_task : public mapreduce::map_task<long, std::pair<long, long> >
    {
        template<typename Runtime>
        void operator()(Runtime& runtime, key_type const& key, 
value_type const& value) const
        {
            for (key_type loop = value.first; 
                loop <= value.second; loop++)
            runtime.emit_intermediate(is_prime(loop), loop);
        }
    };
  1. 现在,让我们实现减少阶段:
    struct reduce_task : public mapreduce::reduce_task<bool, long>
    {
        template<typename Runtime, typename It>
        void operator()(Runtime& runtime, key_type const& key, It it, It ite) const
        {
            if (key)
                std::for_each(it, ite, std::bind(&Runtime::emit, 
&runtime, true, std::placeholders::_1));
        }
    };
    typedef
        mapreduce::job<
            prime_calculator::map_task,
            prime_calculator::reduce_task,
            mapreduce::null_combiner,
            prime_calculator::number_source<prime_calculator::map_task>> job;
} // namespace prime_calculator

前面的命名空间有三个函数:首先,它定义了一个检查给定数字是否为质数的函数;其次,它定义了一个在给定范围内生成一系列数字的函数;第三,它定义了映射和减少任务。如前所述,映射函数发出< k, v >对,其中kv都是long类型,其中k如果v是质数,则为1,如果v不是质数,则为0。然后,减少函数充当过滤器,仅在k = 1时输出< k, v >对。

  1. 接下来的驱动代码设置了相关参数并启动了 MapReduce 计算:
int main()
{
    mapreduce::specification spec;
    int prime_limit = 1000;
    // Set number of threads to be used
    spec.map_tasks = std::max(1U, std::thread::hardware_concurrency());
    spec.reduce_tasks = std::max(1U, std::thread::hardware_concurrency());
    // Set the source of numbers in given range
    prime_calculator::job::datasource_type datasource(0, prime_limit, prime_limit / spec.reduce_tasks);
    std::cout << "\nCalculating Prime Numbers in the range 0 .. " << prime_limit << " ..." << std::endl;

std::cout << std::endl << "Using "
        << std::max(1U, std::thread::hardware_concurrency()) << " CPU cores";
    // Run mapreduce
    prime_calculator::job job(datasource, spec);
    mapreduce::results result;
    job.run<mapreduce::schedule_policy::cpu_parallel<prime_calculator::job> >(result);

    std::cout << "\nMapReduce finished in " 
<< result.job_runtime.count() << " with " 
<< std::distance(job.begin_results(), job.end_results()) 
<< " results" << std::endl;

// Print results
    for (auto it = job.begin_results(); it != job.end_results(); ++it)
        std::cout << it->second << " ";
    return 0;
}

驱动代码设置了 MapReduce 框架所需的参数,运行计算,从减少函数收集结果,最后输出结果。

  1. 编译并运行上述代码。您的输出应如下所示:

图 4.25:使用 MapReduce 框架计算质数

使用 MapReduce 模型编程的主要好处是它产生了具有极大可扩展性的软件。我们在本练习中使用的 MapReduce 框架只在单台机器上使用多线程来实现并行化。但是,如果它能够支持分布式系统,我们在这里编写的相同代码可以在大型服务器集群上运行,使计算规模扩展到巨大。将前面的代码移植到 Hadoop 等系统是 Java 中的一个微不足道的练习,但超出了本书的范围。

活动 10:在 MapReduce 中实现 WordCount

在本章中,我们已经看到了分治技术背后的强大思想作为一种非常有用的算法设计技术,以及在处理大型和复杂计算时提供有用工具的能力。在这个活动中,我们将练习将一个大问题分解成小部分,解决小部分,并使用前一节中介绍的 MapReduce 模型合并后续结果。

我们的问题定义来自原始的 MapReduce 论文,如下所示:给定一组包含文本的文件,找到文件中出现的每个单词的频率。例如,假设您有两个文件,内容如下:

文件 1:

The quick brown fox jumps over a rabbit

文件 2:

The quick marathon runner won the race

考虑输入文件,我们的程序应该输出以下结果:

The         2
quick       2
a           1
brown       1
fox         1
jumps       1
marathon    1
over        1
rabbit      1
race        1
runner      1
the         1
won         1

这类问题经常出现在索引工作负载中,也就是说,当您获得大量文本并需要对内容进行索引以便后续对文本的搜索可以更快地进行时。谷歌和必应等搜索引擎大量使用这样的索引。

在这个活动中,您需要实现单词计数问题的映射和减少阶段。由于这涉及到我们库特定的大部分代码,因此在mapreduce_wordcount_skeleton.cpp中为您提供了样板代码。

活动指南:

  1. 阅读并理解mapreduce_wordcount_skeleton.cpp中给定的代码。您会注意到我们需要在头文件中导入 Boost 库。另一个需要注意的是,给定代码中的映射阶段创建了< k, v >对,其中k是一个字符串,v设置为1。例如,假设您的输入文件集包含一组随机组合的单词,w**1w**2w**3,…,w**n。如果是这样,映射阶段应该输出k, 1对,其中k = {w1, w2, w3, …, wn},如下图所示:图 4.26:映射阶段
图 4.26:映射阶段
  1. 地图阶段的骨架代码如下:
struct map_task : public mapreduce::map_task<
    std::string,                            // MapKey (filename)
    std::pair<char const*, std::uintmax_t>> // MapValue (memory mapped file               
                                               // contents)
{
template<typename Runtime>
    void operator()(Runtime& runtime, key_type const& key, 
                                         value_type& value) const
    {
        // Write your code here.
        // Use runtime.emit_intermediate() to emit <k,v> pairs
    }
};
  1. 由于问题的地图阶段生成了< k, 1 >对,我们的程序的减少任务现在应该组合具有匹配k值的对,如下所示:图 4.27:减少阶段
图 4.27:减少阶段
  1. 在给定的代码中,减少任务接受两个迭代器,这些迭代器可用于迭代具有相同键的元素,即,itite之间的所有元素都保证具有相同的键。然后,您的减少阶段应创建一个新的< k, v >对,其中k设置为输入对的键,v等于输入对的数量:
template<typename KeyType>
struct reduce_task : public mapreduce::reduce_task<KeyType, unsigned>
{
    using typename mapreduce::reduce_task<KeyType, unsigned>::key_type;
    template<typename Runtime, typename It>
    void operator()(Runtime& runtime, key_type const& key, It it, It const ite) const
    {
        // Write your code here.
        // Use runtime.emit() to emit the resulting <k,v> pairs
    }
};
  1. 您将在testdata/中获得一组测试数据。编译并运行您的代码。输出应如下所示:

图 4.28:获取给定输入文件中单词的频率

图 4.28:获取给定输入文件中单词的频率

此活动的解决方案可在第 514 页找到。

摘要

在本章中,我们以两种不同的方式讨论了分而治之:首先作为算法设计范式,然后在设计其他帮助我们扩展软件的工具中使用它。我们涵盖了一些标准的分而治之算法(归并排序和快速排序)。我们还看到了简单操作,如分区是不同问题的解决方案的基础,例如部分排序和线性时间选择。

在实践中实施这些算法时要牢记的一个重要思想是将保存数据的数据结构与算法本身的实现分开。使用 C++模板通常是实现这种分离的好方法。我们看到,C++标准库配备了一大套原语,可用于实现分而治之算法。

分而治之背后的基本思想的简单性使其成为解决问题的非常有用的工具,并允许创建诸如 MapReduce 之类的并行化框架。我们还看到了使用 MapReduce 编程模型在给定范围内找到质数的示例。

在下一章中,我们将介绍贪婪算法设计范式,这将导致诸如 Dijkstra 算法在图中找到最短路径的解决方案。

第五章:贪婪算法

学习目标

在本章结束时,您将能够:

  • 描述算法设计的贪婪方法

  • 识别问题的最优子结构和贪婪选择属性

  • 实现贪婪算法,如分数背包和贪婪图着色

  • 使用不相交集数据结构实现 Kruskal 的最小生成树算法

在本章中,我们将研究各种用于算法设计的“贪婪”方法,并看看它们如何应用于解决现实世界的问题。

介绍

在上一章中,我们讨论了分治算法设计技术,该技术通过将输入分解为较小的子问题,解决每个子问题,然后合并结果来解决给定问题。继续我们的算法设计范式主题,我们现在将看看我们的下一个主题:贪婪方法

在每次迭代中,贪婪算法是选择“看似最佳”替代方案的算法。换句话说,问题的贪婪解决方案由一系列局部最优解组成,从而构成了给定问题的全局最优解。例如,以下屏幕截图显示了一辆汽车从华盛顿特区杜丨勒丨斯国际机场到东里弗代尔办公大楼的最短路径。自然地,所示路径也是任何不是起点和终点的路径上任意两点的最短路径:

图 5.1:从机场到华盛顿特区办公室的路线(来源:project-osrm.org)

图 5.1:从机场到华盛顿特区办公室的路线(来源:project-osrm.org)

因此,我们可以推断整个最短路径 P 实际上是沿 P 的道路网络顶点之间的几条最短路径的连接。因此,如果我们被要求设计一个最短路径算法,一种可能的策略是:从起点顶点开始,绘制一条到尚未探索的最近顶点的路径,然后重复直到到达目标顶点。恭喜 - 您刚刚使用 Dijkstra 算法解决了最短路径问题,这也是商业软件如 Google Maps 和 Bing Maps 使用的算法!

可以预料到,贪婪算法采用的简单方法使它们只适用于算法问题的一小部分。然而,贪婪方法的简单性通常使它成为“第一攻击”的绝佳工具,通过它我们可以了解底层问题的属性和行为,然后可以使用其他更复杂的方法来解决问题。

在本章中,我们将研究给定问题适合贪婪解决方案的条件 - 最优子结构和贪婪选择属性。我们将看到,当问题可以证明具有这两个属性时,贪婪解决方案保证产生正确的结果。我们还将看到一些实际中使用贪婪解决方案的示例,最后我们将讨论最小生成树问题,这在电信和供水网络、电网和电路设计中常见。但首先,让我们从一些可以使用贪婪算法解决的更简单的问题开始。

基本贪婪算法

在本节中,我们将学习可以使用贪婪方法解决的两个标准问题:最短作业优先调度分数背包问题。

最短作业优先调度

假设你站在银行的队列中。今天很忙,队列中有N个人,但银行只开了一个柜台(今天也是个糟糕的日子!)。假设一个人p**i在柜台上被服务需要a**i的时间。由于队列中的人都很理性,每个人都同意重新排队,以使得队列中每个人的平均等待时间最小化。你的任务是找到一种重新排队的方法。你会如何解决这个问题?

图 5.2:原始队列

图 5.2:原始队列

为了进一步分解这个问题,让我们看一个例子。前面的图示显示了原始队列的一个例子,其中A**i表示服务时间,W**i表示第i个人的等待时间。离柜台最近的人可以立即开始被服务,所以他们的等待时间为 0。队列中第二个人必须等到第一个人完成,所以他们必须等待a**1 = 8单位时间才能被服务。以类似的方式继续,第i个人的等待时间等于队列中他们之前的i – 1个人的服务时间之和。

解决这个问题的线索如下:由于我们希望最小化平均等待时间,我们必须找到一种方法来尽可能减少最大可能的一组人的等待时间。减少所有人的等待时间的一种方法是完成时间最短的工作。通过对队列中的所有人重复这个想法,我们的解决方案导致了以下重新排序后的队列:

图 5.3:重新排序后的队列,平均等待时间最短

图 5.3:重新排序后的队列,平均等待时间最短

注意,我们重新排序后的队列的平均等待时间为 8.87 单位,而原始排序的平均等待时间为 15.25 单位,这是一个大约 2 倍的改进。

练习 24:最短作业优先调度

在这个练习中,我们将通过一个类似于前面图示的示例来实现最短作业优先调度解决方案。我们将考虑队列中的 10 个人,并尝试最小化所有人的平均等待时间。让我们开始吧:

  1. 首先添加所需的头文件并创建用于计算等待时间和输入/输出的函数:
#include <iostream>
#include <algorithm>
#include <vector>
#include <random>
#include <numeric>
// Given a set of service times, computes the service times for all users
template<typename T>
auto compute_waiting_times(std::vector<T>& service_times)
{
    std::vector<T> W(service_times.size());
    W[0] = 0;

    for (auto i = 1; i < service_times.size(); i++)
        W[i] = W[i - 1] + service_times[i - 1];
    return W;
}
// Generic function to print a vector
template<typename T>
void print_vector(std::vector<T>& V)
{
    for (auto& i : V)
        std::cout << i << " ";
    std::cout << std::endl;
}
template<typename T>
void compute_and_print_waiting_times(std::vector<T>& service_times)
{
    auto waiting_times = compute_waiting_times<int>(service_times);

    std::cout << "Service times: " << std::endl;
    print_vector<T>(service_times);
    std::cout << "Waiting times: " << std::endl;
    print_vector<T>(waiting_times);
    std::cout << "Average waiting time = "
        << std::accumulate(waiting_times.begin(),            waiting_times.end(), 0.0) /
        waiting_times.size();
    std::cout<< std::endl;
}
  1. 添加主求解器和驱动代码,如下所示:
void shortest_job_first(size_t size)
{
    std::vector<int> service_times;
    std::random_device rd;
    std::mt19937 rand(rd());
    std::uniform_int_distribution<std::mt19937::result_type> uniform_dist(1, size);
    // Insert random elements as service times
    service_times.reserve(size);
    for (auto i = 0; i < size; i++)
        service_times.push_back(uniform_dist(rand));
    compute_and_print_waiting_times<int>(service_times);
    // Reorder the elements in the queue
    std::sort(service_times.begin(), service_times.end());
    compute_and_print_waiting_times<int>(service_times);
}
int main(int argc, char* argv[])
{
    shortest_job_first(10);
}
  1. 编译并运行代码!你的输出应该如下所示:

图 5.4:调度最短作业的程序输出

图 5.4:调度最短作业的程序输出

背包问题

在本节中,我们将讨论标准的背包问题,也称为 0-1 背包问题,它被认为是 NP 完全的,因此不允许我们有任何多项式时间的解决方案。然后,我们将把讨论转向背包问题的一个版本,称为分数背包问题,它可以使用贪婪方法来解决。本节的重点是演示问题定义方式的细微差别如何导致解决方案策略的巨大变化。

背包问题

假设你有一组物体,O = {O1, O2, …, On}, 每个物体都有一个特定的重量 W**i 和价值 V**i。你还有一个只能携带总重量为 T 单位的袋子(或者背包)。现在,假设你的任务是找出一组物体放入你的袋子中,使得总重量小于或等于 T,并且物体的总价值尽可能最大。

如果想象一个旅行商人,他在所有交易中都能获得固定百分比的利润,就可以理解这个问题的现实世界例子。他想携带最大价值的商品以最大化利润,但他的车辆(或背包)最多只能承载 T 单位的重量。商人知道每个物品的确切重量和价值。他应该携带哪组物品,以便携带的物品的总价值是可能的最大值?

图 5.5:背包问题

图 5.5:背包问题

前面图中呈现的问题是著名的背包问题,已被证明是 NP 完全的。换句话说,目前没有已知的多项式时间解决方案。因此,我们必须查看所有可能的物品组合,以找到价值最大且总重量仅为T单位的组合。前面的图表显示了填充容量为 8 单位的背包的两种方式。灰色显示的物品是被选择放入背包的物品。我们可以看到第一组物品的总价值为 40,第二组物品的总价值为 37,而在两种情况下的总重量均为 8 单位。因此,第二组物品比第一组更好。为了找到最佳的物品组合,我们必须列出所有可能的组合,并选择具有最大价值的组合。

分数背包问题

现在,我们将对前面小节中给出的背包问题进行一点改动:假设我们现在可以将每个物品分成我们需要的任意部分,然后我们可以选择要在背包中保留每个物品的什么比例。

就现实世界的类比而言,假设我们之前的类比中的交易商正在交易石油、谷物和面粉等物品。交易商可以取任何较小的重量。

与标准背包问题的 NP 完全性相反,分数背包问题有一个简单的解决方案:根据它们的价值/重量比对元素进行排序,并“贪婪地”选择尽可能多的具有最大比率的物品。下图显示了在背包容量设置为 8 单位时给定一组物品的最佳选择。请注意,所选的物品是具有最高价值/重量比的物品。

图 5.6:分数背包问题

图 5.6:分数背包问题

我们将在接下来的练习中实现这个解决方案。

练习 25:分数背包问题

在这个练习中,我们将考虑 10 个物品,并尝试最大化我们的背包中的价值,背包最大承重为 25 单位。让我们开始吧:

  1. 首先,我们将添加所需的头文件并定义一个Object结构,它将代表我们解决方案中的一个物品:
#include <iostream>
#include <algorithm>
#include <vector>
#include <random>
#include <numeric>
template <typename weight_type, 
    typename value_type, 
    typename fractional_type>
struct Object
{
    using Wtype = weight_type;
    using Vtype = value_type;
    using Ftype = fractional_type;
    Wtype weight;
    Vtype value;
    Ftype value_per_unit_weight;
    // NOTE: The following overloads are to be used for std::sort() and I/O
    inline bool operator< (const Object<Wtype,Vtype,Ftype>& obj) const
    {
        // An object is better or worse than another object only on the
        // basis of its value per unit weight
        return this->value_per_unit_weight < obj.value_per_unit_weight;
    }
    inline bool operator== (const Object<Wtype, Vtype, Ftype>& obj) const
    {
        // An object is equivalent to another object only if 
        // its value per unit weight is equal
        return this->value_per_unit_weight == obj.value_per_unit_weight;
    }
    // Overloads the << operator so an object can be written directly to a stream
    // e.g. Can be used as std::cout << obj << std::endl;
    template <typename Wtype,
        typename Vtype,
        typename Ftype>
    friend std::ostream& operator<<(std::ostream& os, 
                         const Object<Wtype,Vtype,Ftype>& obj);
};
template <typename Wtype,
    typename Vtype,
    typename Ftype>
std::ostream& operator<<(std::ostream& os, const Object<Wtype,Vtype,Ftype>& obj)
{
    os << "Value: "<<obj.value 
    << "\t Weight: " << obj.weight
        <<"\t Value/Unit Weight: " << obj.value_per_unit_weight;
    return os;
}

请注意,我们已经重载了<==运算符,因为我们将在objects的向量上使用std::sort()

  1. 分数背包求解器的代码如下:
template<typename weight_type, 
    typename value_type, 
    typename fractional_type>
auto fill_knapsack(std::vector<Object<weight_type, value_type,fractional_type>>& objects, 
                    weight_type knapsack_capacity)
{

    std::vector<Object<weight_type, value_type, fractional_type>> knapsack_contents;
    knapsack_contents.reserve(objects.size());

    // Sort objects in the decreasing order
    std::sort(objects.begin(), objects.end());
    std::reverse(objects.begin(), objects.end());
    // Add the 'best' objects to the knapsack
    auto current_object = objects.begin();
    weight_type current_total_weight = 0;
    while (current_total_weight <= knapsack_capacity && 
current_object != objects.end())
    {
        knapsack_contents.push_back(*current_object);

        current_total_weight += current_object->weight;
        current_object++;
    }
    // Since the last object overflows the knapsack, adjust weight
    auto weight_of_last_obj_to_remove = current_total_weight - knapsack_capacity;
    knapsack_contents.back().weight -= weight_of_last_obj_to_remove;
    knapsack_contents.back().value -= knapsack_contents.back().value_per_unit_weight * 
                        weight_of_last_obj_to_remove;
    return knapsack_contents;
}

前面的函数按照价值/重量比的递减顺序对物品进行排序,然后选择所有可以放入背包的物品的分数,直到背包装满为止。

  1. 最后,为了测试我们的实现,添加以下测试和驱动代码:
void test_fractional_knapsack(unsigned num_objects, unsigned knapsack_capacity)
{
    using weight_type = unsigned;
    using value_type = double;
    using fractional_type = double;
    // Initialize the Random Number Generator
    std::random_device rd;
    std::mt19937 rand(rd());
    std::uniform_int_distribution<std::mt19937::result_type> 
uniform_dist(1, num_objects);

    // Create a vector of objects
    std::vector<Object<weight_type, value_type, fractional_type>> objects;
    objects.reserve(num_objects);
    for (auto i = 0; i < num_objects; i++)
    {
        // Every object is initialized with a random weight and value
        auto weight = uniform_dist(rand);
        auto value = uniform_dist(rand);
        auto obj = Object<weight_type, value_type, fractional_type> { 
            static_cast<weight_type>(weight), 
            static_cast<value_type>(value), 
            static_cast<fractional_type>(value) / weight 
        };
        objects.push_back(obj);
    }
    // Display the set of objects
    std::cout << "Objects available: " << std::endl;
    for (auto& o : objects)
        std::cout << o << std::endl;
    std::cout << std::endl;
    // Arbitrarily assuming that the total knapsack capacity is 25 units
    auto solution = fill_knapsack(objects, knapsack_capacity);
    // Display items selected to be in the knapsack
    std::cout << "Objects selected to be in the knapsack (max capacity = "
        << knapsack_capacity<< "):" << std::endl;
    for (auto& o : solution)
        std::cout << o << std::endl;
    std::cout << std::endl;
}
int main(int argc, char* argv[])
{
    test_fractional_knapsack(10, 25);
}

前面的函数创建物品并使用 STL 随机数生成器中的随机数据对其进行初始化。接下来,它调用我们的分数背包求解器的实现,然后显示结果。

  1. 编译并运行此代码!您的输出应如下所示:

图 5.7:练习 25 的输出

图 5.7:练习 25 的输出

注意求解器如何取了一个分数,也就是说,只取了最后一个物体的 5 个单位中的 4 个单位。这是一个例子,说明在被选择放入背包之前,物体可以被分割,这使得分数背包问题与 0-1(标准)背包问题有所不同。

活动 11:区间调度问题

想象一下,你的待办事项清单上有一系列任务(洗碗、去超市买食品、做一个世界统治的秘密项目等类似的琐事)。每个任务都有一个 ID,并且只能在特定的开始和结束时间之间完成。假设你希望完成尽可能多的任务。你应该在哪个子集上,以及以什么顺序,来完成你的任务以实现你的目标?假设你一次只能完成一个任务。

例如,考虑下图中显示的问题实例。我们有四个不同的任务,可能花费我们的时间来完成(矩形框表示任务可以完成的时间间隔):

图 5.8:给定任务安排

图 5.8:给定任务安排

下图显示了任务的最佳调度,最大化完成的任务总数:

图 5.9:任务的最佳选择

图 5.9:任务的最佳选择

注意,不完成任务 3 使我们能够完成任务 1 和 2,增加了完成任务的总数。在这个活动中,你需要实现这个贪婪的区间调度解决方案。

解决这个活动的高层步骤如下:

  1. 假设每个任务都有一个开始时间、一个结束时间和一个 ID。创建一个描述任务的结构体。我们将用这个结构体的不同实例表示不同的任务。

  2. 实现一个函数,创建一个包含 N 个任务的std::list,将它们的 ID 从 1 到 N 依次设置,并使用随机数生成器的值作为开始和结束时间。

  3. 按照以下方式实现调度函数:

a. 按照它们的结束时间递增的顺序对任务列表进行排序。

b. 贪婪地选择完成最早结束的任务。

c. 删除所有与当前选择的任务重叠的任务(所有在当前任务结束之前开始的任务)。

d. 如果任务列表中仍有任务,转到步骤 b。否则,返回所选的任务向量。

你的最终输出应该类似于以下内容:

图 5.10:活动 11 的预期输出

图 5.10:活动 11 的预期输出

注意

这个活动的解决方案可以在第 516 页找到。

贪婪算法的要求

在前一节中,我们看了一些问题的例子,贪婪方法给出了最优解。然而,只有当一个问题具有两个属性时,贪婪方法才能给出最优解:最优子结构属性和贪婪选择属性。在本节中,我们将尝试理解这些属性,并向你展示如何确定一个问题是否具有这些属性。

最优子结构:当给定问题 P 的最优解由其子问题的最优解组成时,P 被认为具有最优子结构。

贪婪选择:当给定问题 P 的最优解可以通过在每次迭代中选择局部最优解来达到时,P 被认为具有贪婪选择属性。

为了理解最优子结构和贪婪选择属性,我们将实现 Kruskal 的最小生成树算法。

最小生成树(MST)问题

最小生成树问题可以陈述如下:

“给定一个图 G = <V,E>,其中 V 是顶点集,E 是边集,每个边关联一个边权重,找到一棵树 T,它跨越 V 中的所有顶点,并且具有最小的总权重。”

MST 问题的一个现实应用是设计供水和交通网络,因为设计者通常希望最小化使用的管道总长度或创建的道路总长度,并确保服务能够到达所有指定的用户。让我们尝试通过以下示例来解决这个问题。

假设你被给定地图上 12 个村庄的位置,并被要求找到需要修建的道路的最小总长度,以便所有村庄彼此可达,并且道路不形成循环。假设每条道路都可以双向行驶。这个问题中村庄的自然表示是使用图数据结构。假设以下图 G 的顶点代表 12 个给定村庄的位置,图 G 的边代表顶点之间的距离:

图 5.11:代表村庄和它们之间距离的图 G

图 5.11:代表村庄和它们之间距离的图 G

构建最小生成树 T 的一个简单贪婪算法可能如下:

  1. 将图 G 的所有边添加到最小堆 H 中。

  2. 从 H 中弹出一条边 e。显然,e 在 H 中的所有边中具有最小成本。

  3. 如果 e 的两个顶点已经在 T 中,这意味着添加 e 会在 T 中创建一个循环。因此,丢弃 e 并转到步骤 2。否则,继续下一步。

  4. 在最小生成树 T 中插入 e。

让我们花点时间思考为什么这个策略有效。在步骤 2 和 3 的循环的每次迭代中,我们选择具有最低成本的边,并检查它是否向我们的解决方案中添加了任何顶点。这存储在最小生成树 T 中。如果是,我们将边添加到 T;否则,我们丢弃该边并选择另一条具有最小值的边。我们的算法是贪婪的,因为在每次迭代中,它选择要添加到解决方案中的最小边权重。上述算法是在 1956 年发明的,称为Kruskal 的最小生成树算法。将该算法应用于图 5.11 中显示的图将得到以下结果:

图 5.12:图 G 显示最小生成树 T(带有红色边)

图 5.12:显示最小生成树 T 的图 G(带有红色边)

最小生成树 T 中边的总权重为(2×1)+(3×2)+(2×3)= 14 个单位。因此,我们的问题的答案是至少需要修建 12 个单位的道路。

我们如何知道我们的算法确实是正确的?我们需要回到最优子结构和贪婪选择的定义,并展示 MST 问题具有这两个属性。虽然对这些属性的严格数学证明超出了本书的范围,但以下是证明背后的直观思想:

最优子结构:我们将通过反证法来证明这一点。假设 MST 问题没有最优子结构;也就是说,最小生成树不是由一组较小的最小生成树组成的:

  1. 假设我们得到了图 G 的顶点上的最小生成树 T。让我们从 T 中移除任意边 e。移除 e 会将 T 分解成较小的树 T1 和 T2。

  2. 由于我们假设 MST 问题没有最优子结构,因此必须存在一个跨越 T1 顶点的总权重更小的生成树。将这个生成树和边 e 和 T2 添加到一起。这个新树将是 T'。

  3. 现在,由于 T'的总权重小于 T 的总权重,这与我们最初的假设相矛盾,即 T 是 MST。因此,MST 问题必须具有最优子结构性质。

贪婪选择:如果 MST 问题具有贪婪选择属性,则对于顶点v,连接v到图G的其余部分的最小权重边应始终是最小生成树T的一部分。我们可以通过反证法证明这个假设,如下所示:

  1. 假设边(u, v)是连接vG中任何其他顶点的最小权重边。假设(u, v)不是T的一部分。

  2. 如果(u, v)不是T的一部分,则T必须由连接vG的其他某条边组成。让这条边为(x, v)。由于(u, v)是最小权重边,根据定义,(x, v)的权重大于(u, v)的权重。

  3. 如果在T中用(u, v)替换(x, v),则可以获得总权重小于T的树。这与我们假设的T是最小生成树相矛盾。因此,MST 问题必须具有贪婪选择属性。

注意

正如我们之前提到的,我们也可以采用严格的数学方法来证明 MST 问题具有最优子结构属性,并适用于贪婪选择属性。您可以在这里找到它:ocw.mit.edu/courses/electrical-engineering-and-computer-science/6-046j-design-and-analysis-of-algorithms-spring-2015/lecture-notes/MIT6_046JS15_lec12.pdf

让我们考虑如何实现 Kruskal 算法。我们在第二章“树、堆和图”中介绍了图和堆数据结构,因此我们知道如何实现步骤 1 和 2。第 3 步有点更复杂。我们需要一个数据结构来存储图的边,并告诉我们是否添加新边会与已存储的任何可能的边组合创建循环。这个问题可以使用不相交集数据结构来解决。

不相交集(或并查集)数据结构

不相交集数据结构由一个元素的森林(一组树)组成,其中每个元素由一个数字 ID 表示,具有“等级”,并包含指向其父元素的指针。当数据结构初始化时,它从等级为 0 的N个独立元素开始,每个元素都是树的一部分,该树只包含元素本身。数据结构支持另外两种操作:

  • 对树进行find操作会返回该树的根元素

  • 对两棵树进行union操作会将较小的树合并为较大的树,树的大小存储为其根的等级。

更准确地说,不相交集数据结构支持以下操作:

  • Make-Set:这将使用 N 个元素初始化数据结构,将每个元素的等级设置为 0,并将父指针设置为自身。下图显示了一个用五个元素初始化的不相交集DS的示例。圆圈内的数字显示元素 ID,括号中的数字显示等级,箭头表示指向根元素的指针:

图 5.13:用五个元素初始化不相交集

图 5.13:用五个元素初始化不相交集

在这个阶段,数据结构由五棵树组成,每棵树都包含一个元素。

  • Find:从给定元素x开始,find操作遵循元素的父指针,直到到达树的根。根元素的父元素是根本身。在前面的示例中,每个元素都是树的根,因此此操作将返回树中的孤立元素。

  • Union:给定两个元素xyunion操作找到xy的根。如果两个根相同,这意味着xy属于同一棵树。因此,它什么也不做。否则,它将具有较低秩的根设置为具有较高秩的根的父节点。下图显示了在DS上实现Union(1,2)Union(4,5)操作的结果:

图 5.14:合并 1,2 和 4,5

图 5.14:合并 1,2 和 4,5

随着后续的并操作的应用,更多的树合并成了更少(但更大)的树。下图显示了在应用Union(2, 3)DS中的树:

图 5.15:合并 2,3

图 5.15:合并 2,3

在应用Union(2, 4)后,DS中的树如下图所示:

图 5.16:合并 2,4

图 5.16:合并 2,4

现在,让我们了解不相交集数据结构如何帮助我们实现 Kruskal 算法。在算法开始之前,在步骤 1 之前,我们使用DS初始化了一个包含图G中顶点数量N的不相交集数据结构。然后,步骤 2 从最小堆中取出一条边,步骤 3 检查正在考虑的边是否形成循环。请注意,可以使用在DS上的union操作来实现对循环的检查,该操作应用于边的两个顶点。如果union操作成功合并了两棵树,那么边将被添加到 MST;否则,边可以安全地丢弃,因为它会在 MST 中引入一个循环。以下详细说明了这个逻辑:

  1. 首先,我们开始初始化一个包含图中所有给定顶点的不相交集数据结构DS图 5.17:Kruskal 算法的第 1 步-初始化
图 5.17:Kruskal 算法的第 1 步-初始化
  1. 让我们继续向我们的 MST 中添加权重最低的边。如下图所示,当我们添加边(2,4)时,我们也将Union(2,4)应用于DS中的元素:
图 5.18:在将 Union(2, 4)应用于不相交集之后,将边(2, 4)添加到 MST
  1. 按照算法添加边的过程中,我们到达了边(1,5)。如您所见,在DS中,相应的元素在同一棵树中。因此,我们无法添加该边。如下图所示,添加该边将会创建一个循环:
图 5.19:尝试将边(1,5)添加到 MST 失败,因为顶点 1 和 5 在 DS 中的同一棵树中

在接下来的练习中,我们将使用不相交集数据结构实现 Kruskal 的最小生成树算法。

练习 26:Kruskal 的 MST 算法

在这个练习中,我们将实现不相交集数据结构和 Kruskal 算法来找到图中的最小生成树。让我们开始:

  1. 开始添加以下头文件并声明Graph数据结构:
#include<iostream>
#include<vector>
#include<algorithm>
#include<queue>
#include<map>
template <typename T> class Graph;
  1. 首先,我们将实现不相交集:
template<typename T>
class SimpleDisjointSet
{
private:
    struct Node
    {
        T data;
        Node(T _data) : data(_data)
        {}
        bool operator!=(const Node& n) const
        {
            return this->data != n.data;
        }
    };
    // Store the forest
    std::vector<Node> nodes;
    std::vector<size_t> parent;
    std::vector<size_t> rank;
  1. 添加类的构造函数并实现Make-setFind操作,如下所示:
public:
    SimpleDisjointSet(size_t N)
    {
        nodes.reserve(N);
        parent.reserve(N);
        rank.reserve(N);
    }
    void add_set(const T& x)
    {
        nodes.emplace_back(x);
        parent.emplace_back(nodes.size() - 1);    // the parent is the node itself
        rank.emplace_back(0);        // the initial rank for all nodes is 0
    }
    auto find(T x)
    {
        // Find the node that contains element 'x'
        auto node_it = std::find_if(nodes.begin(), nodes.end(), 
            x 
            {return n.data == x; });
        auto node_idx = std::distance(nodes.begin(), node_it);
        auto parent_idx = parent[node_idx];
        // Traverse the tree till we reach the root
        while (parent_idx != node_idx)
        {
            node_idx = parent_idx;
            parent_idx = parent[node_idx];
        }
        return parent_idx;
    }
  1. 接下来,我们将实现不相交集中两棵树之间的Union操作,如下所示:
    // Union the sets X and Y belong to
    void union_sets(T x, T y)
    {
        auto root_x = find(x);
        auto root_y = find(y);
        // If both X and Y are in the same set, do nothing and return
        if (root_x == root_y)
        {
            return;
        }
        // If X and Y are in different sets, merge the set with lower rank 
        // into the set with higher rank
        else if (rank[root_x] > rank[root_y]) 
        {
            parent[root_y] = parent[root_x];
            rank[root_x]++;
        }
        else 
        {
            parent[root_x] = parent[root_y];
            rank[root_y]++;
        }
    }
};
  1. 现在我们的不相交集的实现已经完成,让我们开始实现图。我们将使用边列表表示。edge结构定义如下:
template<typename T>
struct Edge 
{
    size_t src;
    size_t dest;
    T weight;
    // To compare edges, only compare their weights,
    // and not the source/destination vertices
    inline bool operator< (const Edge<T>& e) const
    {
        return this->weight < e.weight;
    }
    inline bool operator> (const Edge<T>& e) const
    {
        return this->weight > e.weight;
    }
};

由于我们的边的实现是模板化的,边的权重允许是实现了<>操作的任何数据类型。

  1. 以下函数允许图被序列化并输出到流中:
template <typename T>
std::ostream& operator<<(std::ostream& os, const Graph<T>& G)
{
    for (auto i = 1; i < G.vertices(); i++)
    {
        os << i <<":\t";
        auto edges = G.edges(i);
        for (auto& e : edges)
            os << "{" << e.dest << ": " << e.weight << "}, ";

        os << std::endl;
    }

    return os;
}
  1. 现在可以使用以下代码实现图数据结构:
template<typename T>
class Graph
{
public:
    // Initialize the graph with N vertices
    Graph(size_t N): V(N)
    {}
    // Return number of vertices in the graph
    auto vertices() const
    {
        return V;
    }
    // Return all edges in the graph
    auto& edges() const
    {
        return edge_list;
    }
    void add_edge(Edge<T>&& e)
    {
        // Check if the source and destination vertices are within range
        if (e.src >= 1 && e.src <= V && e.dest >= 1 && e.dest <= V)
            edge_list.emplace_back(e);
        else
            std::cerr << "Vertex out of bounds" << std::endl;
    }
    // Returns all outgoing edges from vertex v
    auto edges(size_t v) const
    {
        std::vector<Edge<T>> edges_from_v;
        for(auto& e:edge_list)
        {
            if (e.src == v)
                edges_from_v.emplace_back(e);
        }
        return edges_from_v;
    }
    // Overloads the << operator so a graph be written directly to a stream
    // Can be used as std::cout << obj << std::endl;
    template <typename T>
    friend std::ostream& operator<< <>(std::ostream& os, const Graph<T>& G);
private: 
    size_t V;        // Stores number of vertices in graph
    std::vector<Edge<T>> edge_list;
};

注意

我们的图的实现在创建后不允许更改图中顶点的数量。此外,虽然我们可以添加任意数量的边,但是删除边没有实现,因为在这个练习中不需要。

  1. 现在,我们可以这样实现 Kruskal 算法:
// Since a tree is also a graph, we can reuse the Graph class
// However, the result graph should have no cycles
template<typename T>
Graph<T> minimum_spanning_tree(const Graph<T>& G)
{
    // Create a min-heap for the edges
    std::priority_queue<Edge<T>, 
        std::vector<Edge<T>>, 
        std::greater<Edge<T>>> edge_min_heap;
    // Add all edges in the min-heap
    for (auto& e : G.edges()) 
        edge_min_heap.push(e);
    // First step: add all elements to their own sets
    auto N = G.vertices();
    SimpleDisjointSet<size_t> dset(N);
    for (auto i = 0; i < N; i++)
        dset.add_set(i);

    // Second step: start merging sets
    Graph<T> MST(N);
    while (!edge_min_heap.empty())
    {
        auto e = edge_min_heap.top();
        edge_min_heap.pop();
// Merge the two trees and add edge to the MST only if the two vertices of the edge belong to different trees in the MST
        if (dset.find(e.src) != dset.find(e.dest))
        {
            MST.add_edge(Edge <T>{e.src, e.dest, e.weight});
            dset.union_sets(e.src, e.dest); 
        }
    }
    return MST;
}
  1. 最后,添加以下驱动代码:
 int main()
{
    using T = unsigned;
    Graph<T> G(9);
    std::map<unsigned, std::vector<std::pair<size_t, T>>> edges;
    edges[1] = { {2, 2}, {5, 3} };
    edges[2] = { {1, 2}, {5, 5}, {4, 1} };
    edges[3] = { {4, 2}, {7, 3} };
    edges[4] = { {2, 1}, {3, 2}, {5, 2}, {6, 4}, {8, 5} };
    edges[5] = { {1, 3}, {2, 5}, {4, 2}, {8, 3} };
    edges[6] = { {4, 4}, {7, 4}, {8, 1} };
    edges[7] = { {3, 3}, {6, 4} };
    edges[8] = { {4, 5}, {5, 3}, {6, 1} };

    for (auto& i : edges)
        for(auto& j: i.second)
            G.add_edge(Edge<T>{ i.first, j.first, j.second });

    std::cout << "Original Graph" << std::endl;
    std::cout << G;
    auto MST = minimum_spanning_tree(G);
    std::cout << std::endl << "Minimum Spanning Tree" << std::endl;
    std::cout << MST;
    return 0;
}
  1. 最后,运行程序!您的输出应如下所示:

图 5.20:从给定图中获取最小生成树

图 5.20:从给定图中获取最小生成树

验证我们的算法的输出确实是图 5.12中显示的最小生成树。

Kruskal 算法的复杂度,如果不使用不相交集,为O(E log E),其中 E 是图中的边数。然而,使用不相交集后,总复杂度降至O(Eα(V)),其中α**(v)是 Ackermann 函数的倒数。由于倒数 Ackermann 函数增长速度远远慢于对数函数,因此对于顶点较少的图,两种实现的性能差异很小,但对于较大的图实例,性能差异可能显著。

顶点着色问题

顶点着色问题可以陈述如下:

“给定一个图 G,为图的每个顶点分配一个颜色,以便相邻的两个顶点没有相同的颜色。”

例如,下图显示了图 5.11中显示的图的有效着色:

图 5.21:给未着色的图着色

图 5.21:给未着色的图着色

图着色在解决现实世界中的各种问题中有应用——为出租车制定时间表,解决数独谜题,为考试制定时间表都可以映射到找到问题的有效着色,建模为图。然而,找到产生有效顶点着色所需的最小颜色数量(也称为色数)被认为是一个 NP 完全问题。因此,问题性质的微小变化可能会对其复杂性产生巨大影响。

图着色问题的应用示例,让我们考虑数独求解器的情况。数独是一个数字放置谜题,其目标是用 1 到 9 的数字填充一个 9×9 的盒子,每行中没有重复的数字。每列是一个 3×3 的块。数独谜题的示例如下:

图 5.22:(左)数独谜题,(右)它的解决方案

图 5.22:(左)数独谜题,(右)它的解决方案

我们可以将谜题的一个实例建模为图着色问题:

  • 用图G中的顶点来表示谜题中的每个单元格。

  • 在相同列、行或相同的 3×3 块中的顶点之间添加边。

  • G的有效着色然后给出了原始数独谜题的解决方案。

我们将在下面的练习中看一下图着色的实现。

练习 27:贪婪图着色

在这个练习中,我们将实现一个贪婪算法,为图着色,当可以使用的最大颜色数为六时,如图 5.21所示。让我们开始吧:

  1. 首先,包括所需的头文件并声明Graph数据结构,稍后我们将在本练习中实现:
#include <unordered_map>
#include <set>
#include <map>
#include <string>
#include <vector>
#include <iostream>
template <typename T> class Graph;
  1. 以下结构实现了我们图中的一条边:
template<typename T>
struct Edge
{
    size_t src;
    size_t dest;
    T weight;
    // To compare edges, only compare their weights,
    // and not the source/destination vertices
    inline bool operator< (const Edge<T>& e) const
    {
        return this->weight < e.weight;
    }
    inline bool operator> (const Edge<T>& e) const
    {
        return this->weight > e.weight;
    }
};
  1. 以下函数允许我们将图直接写入输出流:
template <typename T>
std::ostream& operator<<(std::ostream& os, const Graph<T>& G)
{
    for (auto i = 1; i < G.vertices(); i++)
    {
        os << i << ":\t";
        auto edges = G.outgoing_edges(i);
        for (auto& e : edges)
            os << "{" << e.dest << ": " << e.weight << "}, ";
        os << std::endl;
    }
    return os;
}
  1. 将图实现为边列表,如下所示:
template<typename T>
class Graph
{
public:
    // Initialize the graph with N vertices
    Graph(size_t N) : V(N)
    {}
    // Return number of vertices in the graph
    auto vertices() const
    {
        return V;
    }
    // Return all edges in the graph
    auto& edges() const
    {
        return edge_list;
    }
    void add_edge(Edge<T>&& e)
    {
        // Check if the source and destination vertices are within range
        if (e.src >= 1 && e.src <= V &&
            e.dest >= 1 && e.dest <= V)
            edge_list.emplace_back(e);
        else
            std::cerr << "Vertex out of bounds" << std::endl;
    }
    // Returns all outgoing edges from vertex v
    auto outgoing_edges(size_t v) const
    {
        std::vector<Edge<T>> edges_from_v;
        for (auto& e : edge_list)
        {
            if (e.src == v)
                edges_from_v.emplace_back(e);
        }
        return edges_from_v;
    }
    // Overloads the << operator so a graph be written directly to a stream
    // Can be used as std::cout << obj << std::endl;
    template <typename T>
    friend std::ostream& operator<< <>(std::ostream& os, const Graph<T>& G);
private:
    size_t V;        // Stores number of vertices in graph
    std::vector<Edge<T>> edge_list;
};
  1. 以下哈希映射存储了我们的着色算法将使用的颜色列表:
// Initialize the colors that will be used to color the vertices
std::unordered_map<size_t, std::string> color_map = {
    {1, "Red"},
    {2, "Blue"},
    {3, "Green"},
    {4, "Yellow"},
    {5, "Black"},
    {6, "White"}
};
  1. 接下来,让我们实现一个辅助函数,打印已分配给每个顶点的颜色:
void print_colors(std::vector<size_t>& colors)
{
    for (auto i=1; i<colors.size(); i++)
    {
        std::cout << i << ": " << color_map[colors[i]] << std::endl;
    }
}
  1. 以下函数实现了我们的着色算法:
template<typename T>
auto greedy_coloring(const Graph<T>& G)
{
    auto size = G.vertices();
    std::vector<size_t> assigned_colors(size);
    // Let us start coloring with vertex number 1\. 
    // Note that this choice is arbirary.
    for (auto i = 1; i < size; i++)
    {
        auto outgoing_edges = G.outgoing_edges(i);
        std::set<size_t> neighbour_colors;
        for (auto e : outgoing_edges)
        {
            auto dest_color = assigned_colors[e.dest];
            neighbour_colors.insert(dest_color);
        }
        // Find the smallest unassigned color 
        // that is not currently used by any neighbor
        auto smallest_unassigned_color = 1;
        for (; 
            smallest_unassigned_color <= color_map.size();
            smallest_unassigned_color++)
        {
          if (neighbour_colors.find(smallest_unassigned_color) == 
              neighbour_colors.end())
              break;
        }
        assigned_colors[i] = smallest_unassigned_color;
    }
    return assigned_colors;
}
  1. 最后,添加驱动代码,如下所示:
int main()
{
    using T = size_t;
    Graph<T> G(9);
    std::map<unsigned, std::vector<std::pair<size_t, T>>> edges;
    edges[1] = { {2, 2}, {5, 3} };
    edges[2] = { {1, 2}, {5, 5}, {4, 1} };
    edges[3] = { {4, 2}, {7, 3} };
    edges[4] = { {2, 1}, {3, 2}, {5, 2}, {6, 4}, {8, 5} };
    edges[5] = { {1, 3}, {2, 5}, {4, 2}, {8, 3} };
    edges[6] = { {4, 4}, {7, 4}, {8, 1} };
    edges[7] = { {3, 3}, {6, 4} };
    edges[8] = { {4, 5}, {5, 3}, {6, 1} };
    for (auto& i : edges)
        for (auto& j : i.second)
            G.add_edge(Edge<T>{ i.first, j.first, j.second });
    std::cout << "Original Graph: " << std::endl;
    std::cout << G << std::endl;
    auto colors = greedy_coloring<T>(G);
    std::cout << "Vertex Colors: " << std::endl;
    print_colors(colors);
    return 0;
}
  1. 运行实现!您的输出应如下所示:

图 5.23:图着色实现的输出

我们的实现总是从顶点 ID 1 开始着色顶点。但是,这个选择是任意的,即使在相同的图上,从不同的顶点开始贪婪着色算法很可能会导致需要不同颜色数量的不同图着色。

图的着色质量通常通过着色图所使用的颜色数量来衡量。虽然找到使用尽可能少的颜色的最佳图着色是 NP 完全的,但贪婪图着色通常作为有用的近似。例如,在设计编译器时,图着色用于将 CPU 寄存器分配给正在编译的程序的变量。贪婪着色算法与一组启发式方法一起使用,以得到问题的“足够好”的解决方案,在实践中这是可取的,因为我们需要编译器快速才能有用。

活动 12:威尔士-鲍威尔算法

改进简单方法的方法之一是按顶点的边数递减顺序着色顶点(或按顶点的度递减顺序)。

算法的工作方式如下:

  1. 按度的递减顺序对所有顶点进行排序,并将它们存储在数组中。

  2. 取排序后数组中的第一个未着色顶点,并将尚未分配给其任何邻居的第一个颜色分配给它。让这个颜色为C

  3. 遍历排序后的数组,并将颜色C分配给每个未着色的顶点,这些顶点没有被分配颜色C的邻居。

  4. 如果数组中仍有未着色的顶点,则转到步骤 2。否则,结束程序。到目前为止已分配给顶点的颜色是最终输出。

以下是算法的四次迭代的图示示例,这些迭代需要找到图 5.21中所示图的有效着色:

  1. 这是我们开始的图:图 5.24:从未着色的图开始
图 5.24:从未着色的图开始
  1. 接下来,按顶点的递减顺序排序,并从红色开始着色:图 5.25:红色着色
图 5.25:红色着色
  1. 在下一轮中,我们开始着蓝色:
图 5.26:蓝色着色
  1. 在最后一轮中,我们着绿色:

图 5.27:绿色着色

完成此活动的高级步骤如下:

  1. 假设图的每条边都保存源顶点 ID、目标顶点 ID 和边权重。实现一个表示图边的结构。我们将使用该结构的实例来创建图表示中的不同边。

  2. 使用边列表表示实现图。

  3. 实现一个实现威尔士-鲍威尔图着色并返回颜色向量的函数。向量中索引i处的颜色应该是分配给顶点 ID i的颜色。

  4. 根据需要添加驱动程序和输入/输出代码以创建图 5.24中显示的图。假设着色始终从顶点 ID 1开始是可以的。

您的输出应如下所示:

图 5.28:活动 12 的预期输出

图 5.28:活动 12 的预期输出

注意

此活动的解决方案可在第 518 页找到。

摘要

贪婪方法很简单:在算法的每次迭代中,从所有可能的选择中选择看似最佳的选择。换句话说,当在每次迭代中选择局部“最佳”选择导致问题的全局最优解时,贪婪解决方案适用于问题。

在本章中,我们看了贪婪方法在问题中是最优的,并且可以导致给定问题的正确解决方案的示例;也就是说,最短作业优先调度。我们还讨论了稍微修改过的 NP 完全问题的例子,比如 0-1 背包和图着色问题,可以有简单的贪婪解决方案。这使得贪婪方法成为解决困难问题的重要算法设计工具。对于具有贪婪解决方案的问题,它很可能是解决它们的最简单方法;即使对于没有贪婪解决方案的问题,它通常也可以用来解决问题的放松版本,这在实践中可能是“足够好的”(例如,在编程语言编译器中分配寄存器给变量时使用贪婪图着色)。

接下来,我们讨论了贪婪选择和最优子结构属性,并看了一个给定问题展现这些属性的证明示例。我们用 Kruskal 算法和 Welsh-Powell 算法解决了最小生成树问题。我们对 Kruskal 算法的讨论还介绍了不相交集数据结构。

在下一章中,我们将专注于图算法,从广度优先和深度优先搜索开始,然后转向 Dijkstra 的最短路径算法。我们还将看看另一个解决最小生成树问题的方法:Prim 算法。

第六章:图算法 I

学习目标

到本章结束时,您将能够:

  • 描述图在解决各种现实世界问题中的实用性

  • 选择并实现正确的遍历方法来找到图中的元素

  • 使用 Prim 算法解决最小生成树(MST)问题

  • 确定何时使用 Prim 和 Kruskal 算法解决 MST 问题

  • 使用 Dijkstra 算法在图中找到两个顶点/节点之间的最短路径

在本章中,我们将学习解决可以用图表示的问题的基本和最常用的算法,这将在下一章中进一步讨论。

介绍

在前两章中,我们讨论了两种算法设计范式:分治和贪婪方法,这使我们得到了广泛使用和重要的计算问题的众所周知的解决方案,如排序、搜索和在图上找到最小权重生成树。在本章中,我们将讨论一些专门适用于图数据结构的算法。

被定义为一组连接一对顶点的顶点。在数学上,这经常被写为G = < V, E >,其中V表示顶点的集合,E表示构成图的边的集合。指向另一个节点的边称为有向,而没有方向的边称为无向。边也可以与权重相关联,也可以是无权重,正如我们在第二章树、堆和图中看到的那样。

注意

当我们谈论图时,“节点”和“顶点”可以互换使用。在本章中,我们将坚持使用“顶点”。

图是一些最通用的数据结构之一,以至于其他链接数据结构,如树和链表,被认为只是图的特殊情况。图的有用之处在于它们是关系(表示为)和对象(表示为节点)的一般表示。图可以在同一对节点之间有多个边,甚至可以在单个边上有多个边权重,节点也可以从自身到自身有边(也称为自环)。下图显示了这些特征如何存在于图中。图的变体称为“超图”,允许有连接多个节点的边,另一组变体称为“混合图”,允许在同一图中既有有向边又有无向边:

图 6.1:具有多个边权重、自环(也称为循环)以及有向和无向边的图

图 6.1:具有多个边权重、自环(也称为循环)以及有向和无向边的图

由于图提供了高度的通用性,它们在多个应用中被使用。理论计算机科学家使用图来建模有限状态机和自动机,人工智能和机器学习专家使用图来从不同类型的网络结构随时间变化中提取信息,交通工程师使用图来研究交通通过道路网络的流动。

在本章中,我们将限制自己研究使用加权、有向图的算法,如果需要,还有正边权。我们将首先研究图遍历问题并提供两种解决方案:广度优先搜索BFS)和深度优先搜索DFS)。接下来,我们将回到前一章介绍的最小生成树问题,并提供一个称为 Prim 算法的不同解决方案。最后,我们将涵盖单源最短路径问题,该问题支持导航应用程序,如 Google 地图和 OSRM 路线规划器。

让我们首先看一下遍历图的基本问题。

图遍历问题

假设您最近搬进了一个新社区的公寓。当您遇到新邻居并交新朋友时,人们经常推荐附近的餐馆用餐。您希望访问所有推荐的餐馆,因此您拿出社区地图,在地图上标记所有餐馆和您的家,地图上已经标有所有道路。如果我们将每个餐馆和您的家表示为一个顶点,并将连接餐馆的道路表示为图中的边,则从给定顶点开始访问图中所有顶点的问题称为图遍历问题。

在下图中,蓝色数字表示假定的顶点 ID。顶点1Home,餐馆从R1R7标记。由于边被假定为双向的,因此没有边箭头,也就是说,可以沿着道路双向行驶:

图 6.2:将邻域地图表示为图

图 6.2:将邻域地图表示为图

在数学表示中,给定一个图,G = < V, E >,图遍历问题是从给定顶点s开始访问所有V中的所有v。图遍历问题也称为图搜索问题,因为它可以用来在图中“找到”一个顶点。不同的图遍历算法给出了访问图中顶点的不同顺序。

广度优先搜索

图的“广度优先”搜索或广度优先遍历从将起始顶点添加到由先前访问的顶点组成的前沿开始,然后迭代地探索与当前前沿相邻的顶点。下面的示例步骤应该帮助您理解这个概念:

  1. 首先访问Home顶点,即起点。R1R2是当前前沿顶点的邻居,如下图中蓝色虚线所示:图 6.3:BFS 前沿的初始化
图 6.3:BFS 前沿的初始化
  1. 以下图显示了访问R1R1后的 BFS,可以先访问其中任何一个。从源顶点距离相同的顶点的访问顺序是无关紧要的;但是,距离源顶点较近的顶点总是首先被访问:图 6.4:访问 R1 和 R2 顶点后的 BFS 前沿
图 6.4:访问 R1 和 R2 顶点后的 BFS 前沿
  1. 下图显示了访问R3R5R6后 BFS 的状态。这基本上是整个图被遍历之前的倒数第二阶段:

图 6.5:访问 R3、R5 和 R6 后的 BFS 前沿

图 6.5:访问 R3、R5 和 R6 后的 BFS 前沿

BFS 的一个有用特性是,对于每个被访问的顶点,所有子顶点都会在任何孙顶点之前被访问。然而,在实现 BFS 时,前沿通常不会在单独的数据结构中显式维护。相反,使用顶点 ID 的队列来确保比离源顶点更近的顶点总是在更远的顶点之前被访问。在下面的练习中,我们将在 C++中实现 BFS。

练习 28:实现 BFS

在这个练习中,我们将使用图的边缘列表表示来实现广度优先搜索算法。为此,请执行以下步骤:

  1. 添加所需的头文件并声明图,如下所示:
#include <string>
#include <vector>
#include <iostream>
#include <set>
#include <map>
#include <queue>
template<typename T> class Graph;
  1. 编写以下结构,表示图中的一条边:
template<typename T>
struct Edge
{
    size_t src;
    size_t dest;
    T weight;
    // To compare edges, only compare their weights,
    // and not the source/destination vertices
    inline bool operator< (const Edge<T>& e) const
    {
        return this->weight < e.weight;
    }
    inline bool operator> (const Edge<T>& e) const
    {
        return this->weight > e.weight;
    }
};

由于我们对边的定义使用了模板,因此可以轻松地使边具有所需的任何数据类型的边权重。

  1. 接下来,重载<<运算符,以便显示图的内容:
template <typename T>
std::ostream& operator<<(std::ostream& os, const Graph<T>& G)
{
    for (auto i = 1; i < G.vertices(); i++)
    {
        os << i << ":\t";
        auto edges = G.outgoing_edges(i);
        for (auto& e : edges)
            os << "{" << e.dest << ": " << e.weight << "}, ";
        os << std::endl;
    }
    return os;
}
  1. 编写一个类来定义我们的图数据结构,如下所示:
template<typename T>
class Graph
{
public:
    // Initialize the graph with N vertices
    Graph(size_t N) : V(N)
    {}
    // Return number of vertices in the graph
    auto vertices() const
    {
        return V;
    }
    // Return all edges in the graph
    auto& edges() const
    {
        return edge_list;
    }
    void add_edge(Edge<T>&& e)
    {
        // Check if the source and destination vertices are within range
        if (e.src >= 1 && e.src <= V &&
            e.dest >= 1 && e.dest <= V)
            edge_list.emplace_back(e);
        else
            std::cerr << "Vertex out of bounds" << std::endl;
    }
    // Returns all outgoing edges from vertex v
    auto outgoing_edges(size_t v) const
    {
        std::vector<Edge<T>> edges_from_v;
        for (auto& e : edge_list)
        {
            if (e.src == v)
                edges_from_v.emplace_back(e);
        }
        return edges_from_v;
    }
    // Overloads the << operator so a graph be written directly to a stream
    // Can be used as std::cout << obj << std::endl;
    template <typename T>
    friend std::ostream& operator<<(std::ostream& os, const Graph<T>& G);
private:
    size_t V;        // Stores number of vertices in graph
    std::vector<Edge<T>> edge_list;
};
  1. 在这个练习中,我们将在以下图上测试我们的 BFS 实现:图 6.6:在练习 28 中实现 BFS 遍历的图
图 6.6:在练习 28 中实现 BFS 遍历的图

我们需要一个函数来创建并返回所需的图。请注意,虽然图中为每条边分配了边权重,但这并不是必需的,因为 BFS 算法不需要使用边权重。实现函数如下:

template <typename T>
auto create_reference_graph()
{
    Graph<T> G(9);
    std::map<unsigned, std::vector<std::pair<size_t, T>>> edges;
    edges[1] = { {2, 2}, {5, 3} };
    edges[2] = { {1, 2}, {5, 5}, {4, 1} };
    edges[3] = { {4, 2}, {7, 3} };
    edges[4] = { {2, 1}, {3, 2}, {5, 2}, {6, 4}, {8, 5} };
    edges[5] = { {1, 3}, {2, 5}, {4, 2}, {8, 3} };
    edges[6] = { {4, 4}, {7, 4}, {8, 1} };
    edges[7] = { {3, 3}, {6, 4} };
    edges[8] = { {4, 5}, {5, 3}, {6, 1} };
    for (auto& i : edges)
        for (auto& j : i.second)
            G.add_edge(Edge<T>{ i.first, j.first, j.second });
    return G;
}
  1. 实施广度优先搜索如下:
template <typename T>
auto breadth_first_search(const Graph<T>& G, size_t dest)
{
    std::queue<size_t> queue;
    std::vector<size_t> visit_order;
    std::set<size_t> visited;
    queue.push(1); // Assume that BFS always starts from vertex ID 1
    while (!queue.empty())
    {
        auto current_vertex = queue.front();
        queue.pop();
        // If the current vertex hasn't been visited in the past
        if (visited.find(current_vertex) == visited.end())
        {
            visited.insert(current_vertex);
            visit_order.push_back(current_vertex);
            for (auto e : G.outgoing_edges(current_vertex))
                queue.push(e.dest);
        }
    }
    return visit_order;
}
  1. 添加以下测试和驱动代码,创建参考图,从顶点1开始运行 BFS,并输出结果:
template <typename T>
void test_BFS()
{
    // Create an instance of and print the graph
    auto G = create_reference_graph<unsigned>();
    std::cout << G << std::endl;
    // Run BFS starting from vertex ID 1 and print the order
    // in which vertices are visited.
    std::cout << "BFS Order of vertices: " << std::endl;
    auto bfs_visit_order = breadth_first_search(G, 1);
    for (auto v : bfs_visit_order)
        std::cout << v << std::endl;
}
int main()
{
    using T = unsigned;
    test_BFS<T>();
    return 0;
}
  1. 运行上述代码。您的输出应如下所示:

图 6.7:练习 28 的预期输出

图 6.7:练习 28 的预期输出

以下图显示了我们的 BFS 实现访问顶点的顺序。请注意,搜索从顶点1开始,然后逐渐访问离源顶点更远的顶点。在下图中,红色的整数显示了顺序,箭头显示了我们的 BFS 实现访问图的顶点的方向:

图 6.8:练习 28 中的 BFS 实现

图 6.8:练习 28 中的 BFS 实现

BFS 的时间复杂度为O(V + E),其中V是顶点数,E是图中的边数。

深度优先搜索

虽然 BFS 从源顶点开始,逐渐向外扩展搜索到更远的顶点,DFS 从源顶点开始,迭代地访问尽可能远的顶点沿着某条路径,然后返回到先前的顶点,以探索图中另一条路径上的顶点。这种搜索图的方法也称为回溯。以下是说明 DFS 工作的步骤:

  1. 自然地,我们开始遍历,访问Home顶点,如下图所示:图 6.9:DFS 初始化
图 6.9:DFS 初始化
  1. 接下来,我们访问顶点R2。请注意,R2是任意选择的,因为R2R1都与Home相邻,选择任何一个都不会影响算法的正确性:图 6.10:访问 R2 后的 DFS
图 6.10:访问 R2 后的 DFS
  1. 接下来,我们访问顶点R3,如下图所示。同样,R3R1都可以任意选择,因为它们都与R2相邻:图 6.11:访问 R3 后的 DFS
图 6.11:访问 R3 后的 DFS
  1. 搜索继续通过在每次迭代中访问任意未访问的相邻顶点来进行。访问了R1之后,搜索尝试寻找下一个未访问的顶点。由于没有剩下的顶点,搜索终止:

图 6.12:访问图中所有顶点后的 DFS

图 6.12:访问图中所有顶点后的 DFS

在实现 BFS 时,我们使用队列来跟踪未访问的顶点。由于队列是先进先出FIFO)数据结构,顶点被按照加入队列的顺序从队列中移除,因此 BFS 算法使用它来确保离起始顶点更近的顶点先被访问,然后才是离得更远的顶点。实现 DFS 与实现 BFS 非常相似,唯一的区别是:不再使用队列作为待访问顶点列表的容器,而是使用栈,而算法的其余部分保持不变。这种方法之所以有效,是因为在每次迭代中,DFS 访问当前顶点的未访问邻居,这可以很容易地通过栈来跟踪,栈是后进先出LIFO)数据结构。

练习 29:实现 DFS

在这个练习中,我们将在 C++中实现 DFS 算法,并在图 6.2中显示的图上进行测试。步骤如下:

  1. 包括所需的头文件,如下所示:
#include <string>
#include <vector>
#include <iostream>
#include <set>
#include <map>
#include <stack>
template<typename T> class Graph;
  1. 编写以下结构以实现图中的边:
template<typename T>
struct Edge
{
    size_t src;
    size_t dest;
    T weight;
    // To compare edges, only compare their weights,
    // and not the source/destination vertices
    inline bool operator< (const Edge<T>& e) const
    {
        return this->weight < e.weight;
    }
    inline bool operator> (const Edge<T>& e) const
    {
        return this->weight > e.weight;
    }
};

同样,由于我们的实现使用了结构的模板化版本,它允许我们分配任何所需的数据类型的边权重。然而,为了 DFS 的目的,我们将使用空值作为边权重的占位符。

  1. 接下来,重载图的<<运算符,以便可以使用以下函数打印出来:
 template <typename T>
std::ostream& operator<<(std::ostream& os, const Graph<T>& G)
{
    for (auto i = 1; i < G.vertices(); i++)
    {
        os << i << ":\t";
        auto edges = G.outgoing_edges(i);
        for (auto& e : edges)
            os << "{" << e.dest << ": " << e.weight << "}, ";
        os << std::endl;
    }
    return os;
}
  1. 实现使用边列表表示的图数据结构如下:
template<typename T>
class Graph
{
public:
    // Initialize the graph with N vertices
    Graph(size_t N) : V(N)
    {}
    // Return number of vertices in the graph
    auto vertices() const
    {
        return V;
    }
    // Return all edges in the graph
    auto& edges() const
    {
        return edge_list;
    }
    void add_edge(Edge<T>&& e)
    {
        // Check if the source and destination vertices are within range
        if (e.src >= 1 && e.src <= V &&
            e.dest >= 1 && e.dest <= V)
            edge_list.emplace_back(e);
        else
            std::cerr << "Vertex out of bounds" << std::endl;
    }
    // Returns all outgoing edges from vertex v
    auto outgoing_edges(size_t v) const
    {
        std::vector<Edge<T>> edges_from_v;
        for (auto& e : edge_list)
        {
            if (e.src == v)
                edges_from_v.emplace_back(e);
        }
        return edges_from_v;
    }
    // Overloads the << operator so a graph be written directly to a stream
    // Can be used as std::cout << obj << std::endl;
    template <typename T>
    friend std::ostream& operator<< <>(std::ostream& os, const Graph<T>& G);
private:
    size_t V;        // Stores number of vertices in graph
    std::vector<Edge<T>> edge_list;
};
  1. 现在,我们需要一个函数来执行我们的图的 DFS。实现如下:
 template <typename T>
auto depth_first_search(const Graph<T>& G, size_t dest)
{
    std::stack<size_t> stack;
    std::vector<size_t> visit_order;
    std::set<size_t> visited;
    stack.push(1); // Assume that DFS always starts from vertex ID 1
    while (!stack.empty())
    {
        auto current_vertex = stack.top();
        stack.pop();
        // If the current vertex hasn't been visited in the past
        if (visited.find(current_vertex) == visited.end())
        {
            visited.insert(current_vertex);
            visit_order.push_back(current_vertex);
            for (auto e : G.outgoing_edges(current_vertex))
            {    
                // If the vertex hasn't been visited, insert it in the stack.
                if (visited.find(e.dest) == visited.end())
                {
                    stack.push(e.dest);
                }
            }
        }
    }
    return visit_order;
}
  1. 我们将在这里显示的图上测试我们的 DFS 实现:图 6.13:用于实现练习 29 中 DFS 遍历的图
图 6.13:用于实现练习 29 中 DFS 遍历的图

使用以下函数创建并返回图:

template <typename T>
auto create_reference_graph()
{
    Graph<T> G(9);
    std::map<unsigned, std::vector<std::pair<size_t, T>>> edges;
    edges[1] = { {2, 0}, {5, 0} };
    edges[2] = { {1, 0}, {5, 0}, {4, 0} };
    edges[3] = { {4, 0}, {7, 0} };
    edges[4] = { {2, 0}, {3, 0}, {5, 0}, {6, 0}, {8, 0} };
    edges[5] = { {1, 0}, {2, 0}, {4, 0}, {8, 0} };
    edges[6] = { {4, 0}, {7, 0}, {8, 0} };
    edges[7] = { {3, 0}, {6, 0} };
    edges[8] = { {4, 0}, {5, 0}, {6, 0} };
    for (auto& i : edges)
        for (auto& j : i.second)
            G.add_edge(Edge<T>{ i.first, j.first, j.second });
    return G;
}

请注意,在 DFS 中使用空值表示边权重,因此 DFS 不需要边权重。图的更简单的实现可以完全省略边权重而不影响我们的 DFS 算法的行为。

  1. 最后,添加以下测试和驱动代码,运行我们的 DFS 实现并打印输出:
template <typename T>
void test_DFS()
{
    // Create an instance of and print the graph
    auto G = create_reference_graph<unsigned>();
    std::cout << G << std::endl;
    // Run DFS starting from vertex ID 1 and print the order
    // in which vertices are visited.
    std::cout << "DFS Order of vertices: " << std::endl;
    auto dfs_visit_order = depth_first_search(G, 1);
    for (auto v : dfs_visit_order)
        std::cout << v << std::endl;
}
int main()
{
    using T = unsigned;
    test_DFS<T>();
    return 0;
}
  1. 编译并运行上述代码。您的输出应如下所示:

图 6.14:练习 29 的预期输出

图 6.14:练习 29 的预期输出

以下图显示了我们的 DFS 实现访问顶点的顺序:

图 6.15:访问顶点的顺序和 DFS 的方向

图 6.15:访问顶点的顺序和 DFS 的方向

BFS 和 DFS 的时间复杂度均为O(V + E)。然而,这两种算法之间有几个重要的区别。以下列表总结了两者之间的区别,并指出了一些情况下应该优先选择其中一种:

  • BFS 更适合找到靠近源顶点的顶点,而 DFS 通常更适合找到远离源顶点的顶点。

  • 一旦在 BFS 中访问了一个顶点,从源到该顶点找到的路径将保证是最短路径,而对于 DFS 则没有这样的保证。这就是为什么所有单源和多源最短路径算法都使用 BFS 的某种变体的原因。这将在本章的后续部分中探讨。

  • 由于 BFS 访问当前前沿相邻的所有顶点,因此 BFS 创建的搜索树短而宽,需要相对更多的内存,而 DFS 创建的搜索树长而窄,需要相对较少的内存。

活动 13:使用 DFS 找出图是否为二部图

二部图是指顶点可以分为两组,使得图中的任何边必须连接一组中的顶点到另一组中的顶点。

二部图可用于模拟几种不同的实际用例。例如,如果我们有一个学生名单和一个课程名单,学生和课程之间的关系可以被建模为一个二部图,如果学生在该课程中注册,则包含学生和课程之间的边。正如您所想象的那样,从一个学生到另一个学生,或者从一个科目到另一个科目的边是没有意义的。因此,在二部图中不允许这样的边。以下图示例了这样一个模型:

图 6.16:代表不同班级学生注册情况的样本二部图

图 6.16:代表不同班级学生注册情况的样本二部图

一旦像这里展示的模型准备好了,就可以用它来创建课程表,以便没有两个被同一学生选修的课程时间冲突。例如,如果 Jolene 选修了数学计算机科学,这两门课就不应该在同一时间安排,以避免冲突。通过解决图中的最大流问题可以实现在时间表中最小化这种冲突。已知有几种标准算法用于最大流问题:Ford-Fulkerson 算法、Dinic 算法和推-重标记算法是其中的一些例子。然而,这些算法通常很复杂,因此超出了本书的范围。

建模实体之间关系的另一个用例是使用二部图在大型视频流媒体平台(如 Netflix 和 YouTube)的观众和电影列表之间建立关系。

二部图的一个有趣特性是,一些在一般图中是NP 完全的操作,如查找最大匹配和顶点覆盖,对于二部图可以在多项式时间内解决。因此,确定给定图是否是二部图是很有用的。在这个活动中,您需要实现一个检查给定图G是否是二部图的 C++程序。

二部图检查算法使用了 DFS 的略微修改版本,并按以下方式工作:

  1. 假设 DFS 从顶点1开始。将顶点 ID 1添加到堆栈。

  2. 如果堆栈上仍有未访问的顶点,则弹出一个顶点并将其设置为当前顶点。

  3. 如果分配给父顶点的颜色是蓝色,则将当前顶点分配为红色;否则,将当前顶点分配为蓝色。

  4. 将当前顶点的所有未访问相邻顶点添加到堆栈,并将当前顶点标记为已访问。

  5. 重复步骤 234,直到所有顶点都被赋予颜色。如果算法终止时所有顶点都被着色,则给定的图是二部图。

  6. 如果在运行步骤 2时,搜索遇到一个已经被访问并且被赋予与在步骤 3中应该被赋予的颜色不同的颜色(与搜索树中其父顶点被赋予的颜色相反)的顶点,算法立即终止,给定的图就不是二部图。

以下图示说明了前述算法的工作方式:

图 6.17:初始化

图 6.17:初始化

图 6.18:由于顶点 1 被赋予蓝色,我们将顶点 2 涂成红色

图 6.18:由于顶点 1 被赋予蓝色,我们将顶点 2 涂成红色

图 6.19:由于顶点 2 被涂成红色,我们将顶点 8 涂成蓝色。

从前面一系列图中可以观察到,该算法在图中穿行,为每个访问的顶点分配交替的颜色。如果所有顶点都可以以这种方式着色,那么图就是二部图。如果 DFS 到达两个已经被分配相同颜色的顶点,那么可以安全地声明图不是二部图。

使用图 6.17中的图作为输入,最终输出应如下所示:

图 6.20:活动 13 的预期输出

图 6.20:活动 13 的预期输出

此活动的解决方案可在第 524 页找到。

Prim 的 MST 算法

MST 问题在第五章“贪婪算法”中介绍,并定义如下:

“给定图 G = <V,E>,其中 V 是顶点集,E 是边集,每个边关联一个边权重,找到一棵树 T,它跨越 V 中的所有顶点并具有最小总权重。”

第五章贪婪算法中,我们讨论了 MST 问题和 Kruskal 算法的实际应用,Kruskal 算法将图的所有边添加到最小堆中,并贪婪地将最小成本边添加到 MST 中,每次添加时检查树中是否形成了循环。

Prim 算法(也称为 Jarvik 算法)的思想与 BFS 类似。该算法首先将起始顶点添加到frontier中,frontier包括先前访问过的顶点集,然后迭代地探索与当前frontier相邻的顶点。然而,在每次迭代选择要访问的顶点时,会选择frontier中具有最低成本边的顶点。

在实现 Prim 算法时,我们为图的每个顶点附加一个label,用于存储其与起始顶点的距离。算法的工作方式如下:

  1. 首先,初始化所有顶点的标签,并将所有距离设置为无穷大。由于从起始顶点到自身的距离为0,因此将起始顶点的标签设置为0。然后,将所有标签添加到最小堆H中。

在下图中,红色数字表示从起始顶点(假定为顶点1)的估计距离;黑色数字表示边权重:

图 6.21:初始化 Prim 的 MST 算法
  1. 接下来,从H中弹出一个顶点U。显然,U是距离起始顶点最近的顶点。

  2. 对于所有与U相邻的顶点V,如果V的标签 > (U, V)的边权重,则将V的标签设置为(U, V)的边权重。这一步骤称为settlingvisiting顶点U图 6.22:访问顶点 1 后图的状态

图 6.22:访问顶点 1 后图的状态
  1. 当图中仍有未访问的顶点时,转到步骤 2。下图显示了访问顶点2后图的状态,绿色边是迄今为止我们 MST 中的唯一边:
图 6.23:访问顶点 2 后图的状态
  1. 所有顶点都已经 settled 后的最终 MST 如下所示:

图 6.24:我们的图的 MST

图 6.24:我们的图的 MST

练习 30:Prim 算法

在这个练习中,我们将实现 Prim 算法来找到图 6.22中所示图中的 MST。按照以下步骤完成这个练习:

  1. 添加所需的头文件,如下所示:
#include <set>
#include <map>
#include <queue>
#include <limits>
#include <string>
#include <vector>
#include <iostream>
  1. 使用以下结构在图中实现一条边:
template<typename T> class Graph;
template<typename T>
struct Edge
{
    size_t src;
    size_t dest;
    T weight;
    // To compare edges, only compare their weights,
    // and not the source/destination vertices
    inline bool operator< (const Edge<T>& e) const
    {
        return this->weight < e.weight;
    }
    inline bool operator> (const Edge<T>& e) const
    {
        return this->weight > e.weight;
    }
};
  1. 使用以下函数重载Graph类的<<运算符,以便我们可以将图输出到 C++流中:
 template <typename T>
std::ostream& operator<<(std::ostream& os, const Graph<T>& G)
{
    for (auto i = 1; i < G.vertices(); i++)
    {
        os << i << ":\t";
        auto edges = G.outgoing_edges(i);
        for (auto& e : edges)
            os << "{" << e.dest << ": " << e.weight << "}, ";
        os << std::endl;
    }
    return os;
}
  1. 添加基于边列表的图实现,如下所示:
template<typename T>
class Graph
{
public:
    // Initialize the graph with N vertices
    Graph(size_t N) : V(N)
    {}
    // Return number of vertices in the graph
    auto vertices() const
    {
        return V;
    }
    // Return all edges in the graph
    auto& edges() const
    {
        return edge_list;
    }
    void add_edge(Edge<T>&& e)
    {
        // Check if the source and destination vertices are within range
        if (e.src >= 1 && e.src <= V &&
            e.dest >= 1 && e.dest <= V)
            edge_list.emplace_back(e);
        else
            std::cerr << "Vertex out of bounds" << std::endl;
    }
    // Returns all outgoing edges from vertex v
    auto outgoing_edges(size_t v) const
    {
        std::vector<Edge<T>> edges_from_v;
        for (auto& e : edge_list)
        {
            if (e.src == v)
                edges_from_v.emplace_back(e);
        }
        return edges_from_v;
    }
    // Overloads the << operator so a graph be written directly to a stream
    // Can be used as std::cout << obj << std::endl;
    template <typename T>
    friend std::ostream& operator<< <>(std::ostream& os, const Graph<T>& G);
private:
    size_t V;        // Stores number of vertices in graph
    std::vector<Edge<T>> edge_list;
};
  1. 使用以下代码创建并返回图 6.22中所示的图的函数:
 template <typename T>
auto create_reference_graph()
{
    Graph<T> G(9);
    std::map<unsigned, std::vector<std::pair<size_t, T>>> edges;
    edges[1] = { {2, 2}, {5, 3} };
    edges[2] = { {1, 2}, {5, 5}, {4, 1} };
    edges[3] = { {4, 2}, {7, 3} };
    edges[4] = { {2, 1}, {3, 2}, {5, 2}, {6, 4}, {8, 5} };
    edges[5] = { {1, 3}, {2, 5}, {4, 2}, {8, 3} };
    edges[6] = { {4, 4}, {7, 4}, {8, 1} };
    edges[7] = { {3, 3}, {6, 4} };
    edges[8] = { {4, 5}, {5, 3}, {6, 1} };
    for (auto& i : edges)
        for (auto& j : i.second)
            G.add_edge(Edge<T>{ i.first, j.first, j.second });
    return G;
}
  1. 接下来,我们将实现Label结构,为图中的每个顶点分配一个实例,以存储其与frontier的距离。使用以下代码来实现:
template<typename T>
struct Label
{
    size_t vertex_ID;
    T distance_from_frontier;
    Label(size_t _id, T _distance) :
        vertex_ID(_id),
        distance_from_frontier(_distance)
    {}
    // To compare labels, only compare their distances from source
    inline bool operator< (const Label<T>& l) const
    {
        return this->distance_from_frontier < l.distance_from_frontier;
    }
    inline bool operator> (const Label<T>& l) const
    {
        return this->distance_from_frontier > l.distance_from_frontier;
    }
    inline bool operator() (const Label<T>& l) const
    {
        return this > l;
    }
};
  1. 编写一个函数来实现 Prim 的 MST 算法,如下所示:
template <typename T>
auto prim_MST(const Graph<T>& G, size_t src)
{
    std::priority_queue<Label<T>, std::vector<Label<T>>, std::greater<Label<T>>> heap;
    std::set<int> visited;
    std::vector<T> distance(G.vertices(), std::numeric_limits<T>::max());
    std::vector<size_t> MST;
    heap.emplace(src, 0);
    // Search for the destination vertex in the graph
    while (!heap.empty())
    {
        auto current_vertex = heap.top();
        heap.pop();
        // If the current vertex hasn't been visited in the past
        if (visited.find(current_vertex.vertex_ID) == visited.end())
        {
            std::cout << "Settling vertex ID " 
<< current_vertex.vertex_ID << std::endl;
            MST.push_back(current_vertex.vertex_ID);
        // For each outgoing edge from the current vertex, 
        // create a label for the destination vertex and add it to the heap
            for (auto e : G.outgoing_edges(current_vertex.vertex_ID))
            {
                auto neighbor_vertex_ID = e.dest;
                auto new_distance_to_frontier = e.weight;
        // Check if the new path to the vertex is shorter
        // than the previously known best path. 
        // If yes, update the distance 
                if (new_distance_to_frontier < distance[neighbor_vertex_ID])
                {
heap.emplace(neighbor_vertex_ID,  new_distance_to_frontier);
                    distance[e.dest] = new_distance_to_frontier;
                }
            }
            visited.insert(current_vertex.vertex_ID);
        }
    }
    return MST;
}
  1. 最后,添加以下代码,运行我们的 Prim 算法实现并输出结果:
template<typename T>
void test_prim_MST()
{
    auto G = create_reference_graph<T>();
    std::cout << G << std::endl;
    auto MST = prim_MST<T>(G, 1);
    std::cout << "Minimum Spanning Tree:" << std::endl;
    for (auto v : MST)
        std::cout << v << std::endl;
    std::cout << std::endl;
}
int main()
{
    using T = unsigned;
    test_prim_MST<T>();
    return 0;
}
  1. 运行程序。您的输出应如下所示:

图 6.25:练习 30 的输出

图 6.25:练习 30 的输出

使用二进制最小堆和邻接表存储 MST 时,Prim 算法的时间复杂度为O(E log V),当使用一种称为“Fibonacci 最小堆”的堆时,可以改进为O(E + V log V)

虽然 Prim 和 Kruskal 都是贪婪算法的例子,但它们在一些重要方面有所不同,其中一些总结如下:

图 6.26:比较 Kruskal 和 Prim 算法的表

图 6.26:比较 Kruskal 和 Prim 算法的表

Dijkstra 的最短路径算法

每当用户在路线规划应用程序(如 Google 地图)或内置在汽车中的导航软件上请求路线时,都会解决图上的单源最短路径问题。该问题定义如下:

“给定一个有向图 G - <V,E>,其中 V 是顶点集合,E 是边集合,每条边都与边权重、源顶点和目标顶点相关联,找到从源到目标的最小成本路径。”

Dijkstra 算法适用于具有非负边权重的图,它只是 Prim 最小生成树算法的轻微修改,有两个主要变化:

  • Dijkstra 算法不是将每个顶点上的标签设置为从前沿到顶点的最小距离,而是将每个顶点上的标签设置为顶点到源的总距离。

  • Dijkstra 算法在从堆中弹出目的地顶点时终止,而 Prim 算法只有在没有更多顶点需要在堆上解决时才终止。

算法的工作如下步骤所示:

  1. 首先,初始化所有顶点的标签,并将所有距离设置为无穷大。由于从起始顶点到自身的距离为 0,因此将起始顶点的标签设置为 0。然后,将所有标签添加到最小堆H中。

在下图中,红色数字表示从源顶点(顶点 2)和目标顶点(顶点 6)的当前已知最佳距离:

图 6.27:初始化 Dijkstra 算法

图 6.27:初始化 Dijkstra 算法
  1. 然后,从H中弹出顶点U。自然地,U是距离起始顶点最小的顶点。如果U是所需的目的地,则我们已经找到了最短路径,算法终止。

  2. 对于所有邻接到U的顶点V,如果V的标签>(U的标签+ (U,V)的边权重),则找到了一条到V的路径,其长度比先前已知的最小成本路径更短。因此,将V的标签设置为(U的标签+ (U,V)的边权重)。这一步称为解决访问顶点U

图 6.28:解决顶点 1 后算法的状态
  1. 当图中仍有未访问的顶点时,转到步骤 2。下图显示了在解决顶点 2 后图的状态:
图 6.29:解决顶点 2 后算法的状态
  1. 当目标顶点(顶点 ID 为 6)从 H 中弹出时,算法终止。算法从 1 到 6 找到的最短路径如下图所示。此外,其他已解决顶点上的标签显示了从 1 到该顶点的最短距离:

图 6.30:从 1 到 6 的最短路径

图 6.30:从 1 到 6 的最短路径

练习 31:实现 Dijkstra 算法

在这个练习中,我们将实现 Dijkstra 算法来找到图 6.28中的图中的最短路径。按照以下步骤完成这个练习:

  1. 包括所需的头文件并声明图数据结构,如下所示:
#include <string>
#include <vector>
#include <iostream>
#include <set>
#include <map>
#include <limits>
#include <queue>
template<typename T> class Graph;
  1. 编写以下结构来实现图中边的结构:
template<typename T>
struct Edge
{
    size_t src;
    size_t dest;
    T weight;
    // To compare edges, only compare their weights,
    // and not the source/destination vertices
    inline bool operator< (const Edge<T>& e) const
    {
        return this->weight < e.weight;
    }
    inline bool operator> (const Edge<T>& e) const
    {
        return this->weight > e.weight;
    }
};
  1. 重载Graph类的<<运算符,以便可以使用流输出,如下所示:
 template <typename T>
std::ostream& operator<<(std::ostream& os, const Graph<T>& G)
{
    for (auto i = 1; i < G.vertices(); i++)
    {
        os << i << ":\t";
        auto edges = G.outgoing_edges(i);
        for (auto& e : edges)
            os << "{" << e.dest << ": " << e.weight << "}, ";
        os << std::endl;
    }
    return os;
}
  1. 实现图,如下所示:
template<typename T>
class Graph
{
public:
    // Initialize the graph with N vertices
    Graph(size_t N) : V(N)
    {}
    // Return number of vertices in the graph
    auto vertices() const
    {
        return V;
    }
    // Return all edges in the graph
    auto& edges() const
    {
        return edge_list;
    }
    void add_edge(Edge<T>&& e)
    {
        // Check if the source and destination vertices are within range
        if (e.src >= 1 && e.src <= V &&
            e.dest >= 1 && e.dest <= V)
            edge_list.emplace_back(e);
        else
            std::cerr << "Vertex out of bounds" << std::endl;
    }
    // Returns all outgoing edges from vertex v
    auto outgoing_edges(size_t v) const
    {
        std::vector<Edge<T>> edges_from_v;
        for (auto& e : edge_list)
        {
            if (e.src == v)
                edges_from_v.emplace_back(e);
        }
        return edges_from_v;
    }
    // Overloads the << operator so a graph be written directly to a stream
    // Can be used as std::cout << obj << std::endl;
    template <typename T>
    friend std::ostream& operator<< <>(std::ostream& os, const Graph<T>& G);
private:
    size_t V;        // Stores number of vertices in graph
    std::vector<Edge<T>> edge_list;
};
  1. 编写一个函数,使用Graph类创建图 6.28中显示的参考图,如下所示:
template <typename T>
auto create_reference_graph()
{
    Graph<T> G(9);
    std::map<unsigned, std::vector<std::pair<size_t, T>>> edges;
    edges[1] = { {2, 2}, {5, 3} };
    edges[2] = { {1, 2}, {5, 5}, {4, 1} };
    edges[3] = { {4, 2}, {7, 3} };
    edges[4] = { {2, 1}, {3, 2}, {5, 2}, {6, 4}, {8, 5} };
    edges[5] = { {1, 3}, {2, 5}, {4, 2}, {8, 3} };
    edges[6] = { {4, 4}, {7, 4}, {8, 1} };
    edges[7] = { {3, 3}, {6, 4} };
    edges[8] = { {4, 5}, {5, 3}, {6, 1} };
    for (auto& i : edges)
        for (auto& j : i.second)
            G.add_edge(Edge<T>{ i.first, j.first, j.second });
    return G;
}
  1. 实现 Dijkstra 算法,如下所示:
template <typename T>
auto dijkstra_shortest_path(const Graph<T>& G, size_t src, size_t dest)
{
    std::priority_queue<Label<T>, std::vector<Label<T>>, std::greater<Label<T>>> heap;
    std::set<int> visited;
    std::vector<size_t> parent(G.vertices());
    std::vector<T> distance(G.vertices(), std::numeric_limits<T>::max());
    std::vector<size_t> shortest_path;
    heap.emplace(src, 0);
    parent[src] = src;
    // Search for the destination vertex in the graph
    while (!heap.empty()) {
        auto current_vertex = heap.top();
        heap.pop();
        // If the search has reached the destination vertex
        if (current_vertex.vertex_ID == dest) {
            std::cout << "Destination " << 
current_vertex.vertex_ID << " reached." << std::endl;
            break;
        }
        if (visited.find(current_vertex.vertex_ID) == visited.end()) {
            std::cout << "Settling vertex " << 
current_vertex.vertex_ID << std::endl;
            // For each outgoing edge from the current vertex, 
            // create a label for the destination vertex and add it to the heap
            for (auto e : G.outgoing_edges(current_vertex.vertex_ID)) {
                auto neighbor_vertex_ID = e.dest;
                auto new_distance_to_dest=current_vertex.distance_from_source 
+ e.weight;
                // Check if the new path to the destination vertex 
// has a lower cost than any previous paths found to it, if // yes, then this path should be preferred 
                if (new_distance_to_dest < distance[neighbor_vertex_ID]) {
                    heap.emplace(neighbor_vertex_ID, new_distance_to_dest);
                    parent[e.dest] = current_vertex.vertex_ID;
                    distance[e.dest] = new_distance_to_dest;
                }
            }
            visited.insert(current_vertex.vertex_ID);
        }
    }
    // Construct the path from source to the destination by backtracking 
    // using the parent indexes
    auto current_vertex = dest;
    while (current_vertex != src) {
        shortest_path.push_back(current_vertex);
        current_vertex = parent[current_vertex];
    }
    shortest_path.push_back(src);
    std::reverse(shortest_path.begin(), shortest_path.end());
    return shortest_path;
}

我们的实现分为两个阶段——从源顶点开始搜索目标顶点,并使用回溯阶段,在这个阶段通过从目标顶点回溯到源顶点的父指针来找到最短路径。

  1. 最后,添加以下代码来测试我们对 Dijkstra 算法的实现,以找到图中顶点 1 和 6 之间的最短路径:
 template<typename T>
void test_dijkstra()
{
    auto G = create_reference_graph<T>();
    std::cout << "Reference graph:" << std::endl;
    std::cout << G << std::endl;
    auto shortest_path = dijkstra_shortest_path<T>(G, 1, 6);
    std::cout << "The shortest path between 1 and 6 is:" << std::endl;
    for (auto v : shortest_path)
        std::cout << v << " ";
    std::cout << std::endl;
}
int main()
{
    using T = unsigned;
    test_dijkstra<T>();
    return 0;
}
  1. 运行程序。您的输出应如下所示:

图 6.31:练习 31 的输出

图 6.31:练习 31 的输出

如前面的输出所示,我们的程序在顶点16之间的最短路径上跟踪了顶点。Dijkstra 算法的已知最佳运行时间是O(E + V log V),当使用斐波那契最小堆时。

活动 14:纽约的最短路径

在此活动中,您需要在 C++中实现 Dijkstra 算法,以便在纽约给定的道路网络中找到最短路径。我们的道路图包括 264,326 个顶点和 733,846 个有向边,边的权重是顶点之间的欧几里德距离。此活动的步骤如下:

  1. 从以下链接下载道路图文件:raw.githubusercontent.com/TrainingByPackt/CPP-Data-Structures-and-Algorithm-Design-Principles/master/Lesson6/Activity14/USA-road-d.NY.gr

注意

如果文件没有自动下载,而是在浏览器中打开,请右键单击任何空白处并选择“另存为…”进行下载

  1. 如果您正在运行 Windows,请将下载的文件移动到<project directory>/out/x86-Debug/Chapter6

如果您正在运行 Linux,请将下载的文件移动到<project directory>/build/Chapter6

注意

目录结构可能会根据您的 IDE 而有所不同。文件需要放在与已编译二进制文件相同的目录中。或者,您可以调整实现以接受文件路径。

  1. 道路图是一个文本文件,有三种不同类型的行:图 6.32:描述纽约道路图文件的表
图 6.32:描述纽约道路图文件的表
  1. 实现加权边图。假设一旦创建了图,就不能从图中添加或删除顶点。

  2. 实现一个函数来解析道路图文件并填充图。

  3. 实现 Dijkstra 算法,并通过找到顶点913542之间的最短路径来测试您的实现。您的输出应如下所示:

图 6.33:活动 14 的预期输出

图 6.33:活动 14 的预期输出

注意

此活动的解决方案可在第 530 页找到。

总结

本章介绍了三个主要的图问题:首先是图遍历问题,介绍了两种解决方案,即广度优先搜索(BFS)和深度优先搜索(DFS)。其次,我们重新讨论了最小生成树(MST)问题,并使用 Prim 算法解决了该问题。我们还将其与 Kruskal 算法进行了比较,并讨论了应优先选择哪种算法的条件。最后,我们介绍了单源最短路径问题,该问题在图中寻找最小成本的最短路径,并介绍了 Dijkstra 的最短路径算法。

然而,Dijkstra 算法仅适用于具有正边权重的图。在下一章中,我们将寻求放宽此约束,并引入一种可以处理负边权重的最短路径算法。我们还将将最短路径问题概括为在图中找到所有顶点对之间的最短路径。

第七章:图算法 II

学习目标

在本章结束时,你将能够:

  • 描述 Dijkstra 算法的固有问题,并演示如何修改和/或与其他算法结合以规避这些问题

  • 使用贝尔曼-福特和约翰逊算法在图中找到最短路径

  • 描述图中强连通分量的重要性

  • 使用 Kosaraju 算法在图中找到强连通分量

  • 描述有向图和无向图中连通性的区别

  • 实现复杂问题的深度优先搜索

  • 评估图中的负权重循环

本章在上一章的基础上介绍了一些更高级的图算法。你还将学习如何处理负权重,并处理负权重循环的异常情况。

介绍

到目前为止,我们已经探讨了各种常见的编程结构和范式。现在,我们将深入探讨一些扩展我们之前讨论的主题的技术,首先是一系列高级图问题,然后转向动态规划这个广泛的主题。

在本章中,我们将讨论三种著名的算法,即贝尔曼-福特算法、约翰逊算法和 Kosaraju 算法。所有这些算法与我们在本书中已经涵盖的算法有明显的相似之处,但它们以各种方式扩展和组合这些算法,以比次优实现更高效地解决潜在复杂的问题。除了学习这些具体的技术,本章还应该增加你对基本图相关技术的一般熟悉度,并提供更深入的洞察力,了解这些基本技术如何应用于不同问题的各种不同范围。

重新审视最短路径问题

我们之前讨论了几种在图中找到两个节点之间最短路径的方法。我们首先探讨了最常见的图遍历形式,即深度优先搜索和广度优先搜索,最终讨论了如何处理包含加权边的图的更为棘手的情况。我们演示了如何使用 Dijkstra 算法来高效地找到加权图中的最短距离,通过贪婪地优先考虑遍历中的每一步,根据当前可用的最佳选项。然而,尽管 Dijkstra 算法提供了性能的改进,但它并不适用于每种情况。

考虑一个 Wi-Fi 信号通过网络进行广播;随着信号传播到原始传输点之外,其强度可能会受到许多因素的影响,比如传播距离和必须穿过的墙壁和其他障碍物的数量。如果你想确定信号到达每个目的地的路径,以最小化信号衰减,你可以创建一个加权图,网络中的每个点都由一个节点表示,任意两点之间的信号损失程度由加权边表示。然后,你可以使用 Dijkstra 算法计算图中的最短距离,以确定网络中成本最低的路径。

现在,假设网络中安装了中继器/增强器来增加特定点的信号强度-这种添加可能如何在你的图中表示?最明显的方法是将增强器节点的出边权重设置为负值(相当于它增加信号强度的程度),这将减少通过它的任何路径的总距离/衰减。如果我们在网络图上使用 Dijkstra 算法,这将如何影响我们的结果?

正如我们在上一章中讨论的,Dijkstra 算法在选择遍历中的每个顶点时采取了贪婪的方法。在每一步中,它找到最近的未访问的顶点,并将其添加到已访问的集合中,从而排除它不再考虑。Dijkstra 算法所做的假设是,到目前为止已经考虑的每个顶点的最短路径已经被找到,因此寻找更好的替代方案是没有意义的。然而,在包含负边权的图中,这种方法不会探索导致最佳解决方案的可能性,如果它们在遍历的早期阶段产生了更高的总和。

考虑一个带有负边权的图,如下图所示:

图 7.1:将 Dijkstra 算法应用于带有负权的图

](https://gitee.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/cpp-dsal-dsn-prin/img/C14498_07_01.jpg)

图 7.1:将 Dijkstra 算法应用于带有负权的图

在上图中,Dijkstra 算法遍历的路径用红色表示。假设我们从顶点 A 开始,第一次从节点A到节点B移动后,将有两个潜在的选择:B —> C,边权为5,和B —> D,边权为10。由于 Dijkstra 的贪婪方法,C将被选择为最短路径的下一个节点,但我们可以清楚地看到另一个选择(B —> D —> C = 10 + -7 = 3)实际上是最佳选择。

面对负边权时,Dijkstra 算法中固有的优化最终导致了它的失败。幸运的是,对于这样的图,我们可以采用一种类似于 Dijkstra 算法的替代方法,实现起来可能更简单。

Bellman-Ford 算法

我们可以使用Bellman-Ford 算法来处理带有负权的图。它用图中每条边的替代方法替换了 Dijkstra 的贪婪选择方法,需要在图中迭代V-1次(其中V等于顶点的总数),并在每次迭代中找到从源节点到目的节点的逐渐最优距离值。这自然使其具有比 Dijkstra 算法更高的渐近复杂度,但也使其能够为 Dijkstra 算法会误解的图产生正确的结果。下面的练习展示了如何实现 Bellman-Ford 算法。

练习 32:实现 Bellman-Ford 算法(第一部分)

在这个练习中,我们将使用基本的 Bellman-Ford 算法来找到带有负权的图中的最短距离。让我们开始吧:

  1. 首先,通过包括必要的库(以及为了方便起见,namespace std)来设置您的代码:
#include <iostream>
#include <vector>
#include <climits>
using namespace std;
  1. 让我们首先定义图中边的表示,这将需要三个变量:源节点的索引、目的节点的索引和它们之间的遍历成本:
struct Edge
{
    int start;    // The starting vertex
    int end;      // The destination vertex
    int weight;   // The edge weight
    // Constructor
    Edge(int s, int e, int w) : start(s), end(e), weight(w) {}
};
  1. 要实现 Bellman-Ford 算法,我们需要对图进行一些表示。为了简单起见,让我们假设我们的图可以用一个整数V来表示,表示图中顶点的总数,以及一个向量edges(指向定义图的邻接的'edge'对象的指针集合)。让我们还定义一个整数常量UNKNOWN,我们可以将其设置为某个始终大于图中任何边权子集的总和的任意高值(在climits中定义的INT_MAX常量很适合这个目的):
const int UNKNOWN = INT_MAX;
vector<Edge*> edges;   // Collection of edge pointers
int V;                 // Total number of vertices in the graph
int E;                 // Total number of edges in the graph
  1. 让我们也编写一些代码来收集图的数据作为用户输入:
int main()
{
    cin >> V >> E;
    for(int i = 0; i < E; i++)
    {
        int node_a, node_b, weight;
        cin >> node_a >> node_b >> weight;
        // Add a new edge using the defined constructor
        edges.push_back(new Edge(node_a, node_b, weight));
    }
    // Choose a starting node
    int start;
    cin >> start;
    // Run the Bellman-Ford algorithm on the graph for 
    // the chosen starting vertex 
    BellmanFord(start);
    return 0;
}
  1. 现在,我们可以开始实现贝尔曼-福特算法本身。为了我们的目的,让我们创建一个名为BellmanFord()的函数,它接受一个参数start(我们要在图中找到最短路径的起始节点)并返回void。然后,我们将定义一个大小为V的距离数组,其中每个元素都初始化为UNKNOWN,除了起始节点,其索引初始化为0
    void BellmanFord(int start)
    {
        vector<int> distance(V, UNKNOWN);
        distance[start] = 0;
  1. 大部分工作是在下一步完成的,在那里我们定义一个持续V-1次迭代并在每次重复中遍历整个边集的循环。对于每条边,我们检查其源节点的当前距离值是否不等于UNKNOWN(在第一次迭代中,这仅适用于起始节点)。假设这是真的,然后我们将其目标节点的当前距离值与边的权重与源节点的距离的总和进行比较。如果将边权重添加到当前节点的距离的结果小于目标节点的存储距离,则用新的总和替换距离数组中的值:
// Perform V - 1 iterations
for(int i = 0; i < V; i++)
{
    // Iterate over entire set of edges
    for(auto edge : edges)
    {
        int u = edge->start;
        int v = edge->end;
        int w = edge->weight;
        // Skip nodes which have not yet been considered
        if(distance[u] == UNKNOWN)
        {
            continue;
        }
        // If the current distance value for the destination
        // node is greater than the sum of the source node's
        // distance and the edge's weight, change its distance
        // to the lesser value.
        if(distance[u] + w < distance[v])
        {
            distance[v] = distance[u] + w;
        }
    }
}
  1. 在我们的函数结束时,我们现在可以遍历distance数组并输出从源到图中每个其他节点的最短距离:
cout << "DISTANCE FROM VERTEX " << start << ":\n"
for(int i = 0; i < V; i++)
{
    cout << "\t" << i << ": ";
    if(distance[i] == UNKNOWN)
    {
        cout << "Unvisited" << endl;
        continue;
    }
    cout << distance[i] << endl;
}
  1. 现在,我们可以返回到我们的main()方法,并调用我们新实现的BellmanFord()函数。让我们在图 7.1中的示例图上测试我们的实现。为此,我们应该运行我们的代码并输入以下输入:
5 5
0 1 3
1 2 5
1 3 10
3 2 -7
2 4 2
0
  1. 我们的程序应该输出以下内容:
DISTANCE FROM VERTEX 0:
    0: 0
    1: 3
    2: 6
    3: 13
    4: 8

正如我们所看到的,贝尔曼-福特避免了导致狄克斯特拉算法错误评估最短路径的陷阱。然而,仍然存在另一个重要的问题需要解决,我们将在下一节中讨论。

贝尔曼-福特算法(第二部分)-负权重循环

考虑下图中显示的图形:

图 7.2:带有负权重循环的图

用红色突出显示的边表示负权重循环或图中产生负和的组合边权重的循环。在这种情况下,将重复考虑此循环,并且最终结果将被扭曲。

为了进行比较,考虑一个仅具有正边权重的图。在这样的图中,循环永远不会被考虑在解决方案中,因为已经找到了到循环中第一个节点的最短距离。为了证明这一点,想象一下在前面的图中节点BD之间的边权重是正的。从节点A开始,通过边的第一次迭代将确定到节点B的最短距离等于3。再经过两次迭代,我们还将知道从AC的最短距离(A—>B—>D—>C),它等于143+8+3)。

显然,无法将任何正数添加到 14 中,以产生小于 3 的总和。在任何图遍历中最多可以有| V-1 |步骤,其中每个节点仅访问一次,我们可以确定| V-1 |次迭代足以确定每个可能的最短距离。通过推论,我们可以得出结论,在| V-1 |次迭代后,唯一可能存在更短路径的方式是如果重新访问节点并且导致它的边权重为负。因此,贝尔曼-福特算法的最后一步包括通过边执行一次迭代以检查是否存在这样的循环。

我们可以通过与找到最短路径时使用的相同逻辑来实现这一点:通过检查每条边的权重与其源节点的距离值的总和是否小于其目标节点的当前存储距离。如果在此步骤中找到了更短的路径,则终止算法并报告存在负循环。

我们将在下一个练习中探讨该算法的实现。

练习 33:实现贝尔曼-福特算法(第二部分)

在这个练习中,我们将修改练习 32中的实现,实现贝尔曼-福特算法(第一部分),以处理具有负权重循环的图。让我们开始吧:

  1. 我们基本上可以直接从上一步复制我们的代码。但是,这次,我们将用某种输出替换在确定是否找到了更短路径的条件下的代码,指示图包含负循环,从而使其无效:
    // Iterate through edges one last time
    for(auto edge : edges)
    {
        int u = edge->start;
        int v = edge->end;
        int w = edge->weight;

        if(distance[u] == UNKNOWN)
        {
            continue;
        }
  1. 如果我们仍然可以找到比我们已经找到的路径更短的路径,则图必须包含负循环。让我们用以下if语句检查负权重循环:
        if(distance[u] + w < distance[v])
        {
            cout << "NEGATIVE CYCLE FOUND" << endl;
            return;
        }
    }
  1. 现在,让我们将这段代码块插入到第一个for循环结束和第一行输出之间:
void BellmanFord(int start)
{
    vector<int> distance(V, UNKNOWN);
    distance[start] = 0;
    for(int i = 1; i < V; i++)
    {
        for(auto edge : edges)
        {
            int u = edge->start;
            int v = edge->end;
            int w = edge->weight;
            if(distance[u] == UNKNOWN)
            {
                continue;
            } 
            if(distance[u] + w < distance[v])
            {
                distance[v] = distance[u] + w;
            }
        }
    }
    for(auto edge : edges)
    {
        int u = edge->start;
        int v = edge->end;
        int w = edge->weight;
        if(distance[u] == UNKNOWN)
        {
            continue;
        }
        if(distance[u] + w < distance[v])
        {
            cout << "NEGATIVE CYCLE FOUND" << endl;
            return;
        }
    }
    cout << "DISTANCE FROM VERTEX " << start << ":\n";
    for(int i = 0; i < V; i++)
    {
        cout << "\t" << i << ": ";
        if(distance[i] == UNKNOWN)
        {
            cout << "Unvisited" << endl;
            continue;
        }
        cout << distance[i] << endl;
    }
}
  1. 为了测试我们添加的逻辑,让我们在以下输入上运行算法:
6 8
0 1 3
1 3 -8
2 1 3
2 5 5
3 2 3
2 4 2
4 5 -1
5 1 8
0
  1. 我们的程序应输出以下内容:
NEGATIVE CYCLE FOUND

活动 15:贪婪机器人

您正在开发一款寻路机器人,必须找到通过障碍课程的最有效路径。为了测试目的,您设计了几个课程,每个课程都是一个方形网格。您的机器人能够穿越遇到的任何障碍,但这也需要更多的能量消耗。假设您的机器人从网格的左上角开始,并且可以沿着四个基本方向(北、南、东、西)移动,您必须实现一个算法,确定您的机器人可以以最大能量完成课程的数量。

由于执行此遍历所需的能量可能很高,您已经在整个网格中间插入了充电站,您的机器人有能力使用这些充电站来充电。不幸的是,您的机器人在能源消耗方面非常贪婪-如果它可以在不必回头的情况下多次到达一个能源站,它将不断返回到相同的位置,直到最终过度充电并爆炸!因此,您需要预测您的机器人是否会重新访问一个充电站,并在灾难发生之前中止遍历尝试。

输入

  • 第一行包含一个整数N,即课程的高度和宽度。

  • 接下来的N``2 - 1行每行包含directions字符串和称为power的整数。每组N行对应于单行,从网格的顶部开始,每个单元格的数据从左到右定义(例如,在3 x 3网格中,0 —> [0, 0], 1 —> [0, 1], 2 —> [0, 2], 3 —> [1, 0], 4 —> [1, 1],依此类推)。

  • directions包含来自集合{ 'N','S','E','W' }的 0-3 个字符,它们代表您的机器人可以从每个点访问的单元格。因此,如果directions字符串是SW,则机器人可以从该点向南或向西移动。power表示穿过单元格所需的能量消耗。power的正值表示充电站位于单元格内。

输出

  • 如果遍历课程导致机器人爆炸,请打印一行- 遍历中止

  • 否则,打印出机器人在到达课程的右下角时可以拥有的最大能量,相对于它开始时的能量。例如,如果机器人可以比开始时多 10 个能量单位完成迷宫,则打印10;如果它完成迷宫时比开始时少 10 个能量单位,则打印-10

例子

假设我们有以下输入:

3
SE -10
SE -8
S -6
S 7
E -10
S 20
E -1
NE 5

网格的布局如下:

图 7.3:机器人遍历的网格

图 7.3:机器人遍历的网格

达到右下角单元格的路径如下:

0 —> 3 (-10)
3 —> 6 (+7)
6 —> 7 (-1)
7 —> 4 (+5)
4 —> 5 (-10)
5 —> 8 (+20)
(-10) + 7 + (-1) + 5 + (-10) + 20 
= 11 more units of energy

因此,您的程序应输出11

测试用例

以下测试用例应帮助您更好地理解这个问题:

图 7.4:活动 15 的测试用例 1

图 7.4:活动 15 的测试案例 1

图 7.5:活动 15 的测试案例 2

图 7.5:活动 15 的测试案例 2

图 7.6:活动 15 的测试案例 3

图 7.6:活动 15 的测试案例 3

图 7.7:活动 15 的测试案例 4

图 7.7:活动 15 的测试案例 4

图 7.8:活动 15 的测试案例 5

图 7.8:活动 15 的测试案例 5

活动指南

  • 不需要超出练习 33实现贝尔曼-福特算法(第二部分)中涵盖的算法。

  • 您可能需要重新解释一些输入,以使其与您试图解决的实际问题相对应。

  • 无需将网格表示为二维。

注意

此活动的解决方案可在第 537 页找到。

我们现在已经确定贝尔曼-福特比迪杰斯特拉算法更加灵活,因为它具有在迪杰斯特拉算法产生错误结果的情况下产生正确解决方案的能力。然而,如果我们考虑的图中不包含任何负边权,那么在这两者之间显然应选择迪杰斯特拉算法,因为其贪婪方法可能带来的潜在效率优势。现在,我们将探讨如何将贝尔曼-福特算法与迪杰斯特拉算法结合使用,以便用于具有负权重的图。

约翰逊算法

在比较了贝尔曼-福特算法和迪杰斯特拉算法的相对优点和缺点之后,我们现在将讨论一种将它们两者结合起来以检索图中每对顶点之间的最短路径的算法。约翰逊算法为我们提供了利用迪杰斯特拉算法的效率,同时为具有负边权的图产生正确结果的优势。

约翰逊算法的概念非常新颖 - 为了应对迪杰斯特拉处理负权重时的局限性,约翰逊算法简单地重新调整图中的边,使它们统一为非负数。这是通过贝尔曼-福特算法与一些特别优雅的数学逻辑相结合而实现的。

约翰逊算法的第一步是向图中添加一个新的“虚拟”顶点,然后通过权重为零的边将其连接到其他每个顶点。然后使用贝尔曼-福特算法找到新顶点与其余顶点之间的最短路径,并将距离存储以备后用。

考虑添加这个新顶点的影响:因为它与图中每个其他节点都有一条权重为 0 的边相连,所以它的最短路径距离永远不会是正数。此外,它与图中每个节点的连接确保了它的距离值在所有潜在遍历路径上保持恒定的关系,这导致这些值及其相应边权重形成的总和“望远镜”,换句话说,序列中的后续项互相抵消,使总和等同于第一项和最后一项的差。看一下下面的图:

图 7.9:在具有负权重的图上应用约翰逊算法

图 7.9:在具有负权重的图上应用约翰逊算法

在前面的图中,标有S的菱形节点代表虚拟顶点,黑色括号中的数字代表边权重,红色文本代表从S到每个节点的最短路径,橙色箭头代表从S遍历的最佳路径,蓝色箭头代表从S分支出的权重为 0 的边,这些边不包括在任何S的最短路径中。

让我们将新的距离值按照它们在图的遍历中的出现顺序排列成一个序列 - A --> B --> C --> A --> D --> E

图 7.10:在每个节点遍历的距离

图 7.10:每个节点遍历的距离

如果我们将原始边权重插入到它们连接的节点的距离值之间,序列将如下所示:

图 7.11:计算已经遍历的距离

图 7.11:计算已经遍历的距离

现在,让我们将以下公式应用于边值:

W(uv) = w(uv) + d[s, u] - d[s, v]

这里,w(uv)表示节点uv之间的原始边权重,d[s, u]d[s, v]表示Su/v之间的最短路径距离,W(uv)表示转换后的边权重值。应用这个公式得到以下结果:

AB —> (-7) +   0  – (-7) = 0
BC —> (-2) + (-7) – (-9) = 0
CA —>  10  + (-9) –   0  = 1
AD —> (-5) +   0  – (-5) = 0
DE —>   4  + (-5) – (-1) = 0

请注意,表达式中的第三项总是在后续迭代中被中间项抵消;这展示了公式的“折叠”特性。由于这个特性,表示节点 A 和 E 之间距离的以下两个表达式是等价的:

(w(AB) + d[s, A] - d[s, B]) + (w(BC) + d[s, B] - d[s, C]) + … + (w(DE) + d[s, D] - d[s, E])
(w(AB) + w(BC) + w(CA) + w(AD) + w(DE)) + d[s, A] - d[s, E]

这意味着在图中任何路径上添加的权重量等于添加到其子路径的权重量。我们知道,由于 Bellman-Ford 返回的距离数组确保对于任何一对u,v,我们有d[s, u] + weight(u, v) >= d[s, v],因此这些值的相加结果总是非负的。因此,w(u, v) + d[s, u] - d[s, v]的值永远不会小于 0。

由于应用了转换,图中任何最短路径中将要遍历的每条边都将被重新加权为零,这使我们得到了非负的权重值,而且令人惊讶的是,它们仍然保留了原始的最短路径顺序!现在我们可以使用这些新的权重值在图上执行 Dijkstra 算法,以高效地检索每对节点的最短路径。

我们将在下一个练习中探讨 Johnson 的算法的实现。

练习 34:实现 Johnson 的算法

在这个练习中,我们将实现 Johnson 的算法,以找到具有负权重的图中每个节点到其他每个节点的最短距离。让我们开始吧:

  1. 我们可以重用前一个练习中的大部分代码,包括我们的Edge结构,UNKNOWN常量和图数据:
#include <iostream>
#include <vector>
#include <climits>
using namespace std;
struct Edge
{
    int start;
    int end;   
    int weight;
    Edge(int s, int e, int w) : start(s), end(e), weight(w) {}
};
const int UNKNOWN = INT_MAX;
vector<Edge*> edges;
int V;             
int E;             
  1. 我们应该修改 Bellman-Ford 的函数声明,使其接受两个参数(一个整数V和一个Edge指针的向量edges),并返回一个整数向量。我们还可以删除start参数:
vector<int> BellmanFord(int V, vector<Edge*> edges)
  1. 我们将首先向图中添加虚拟顶点S。因为S实际上对图的其余部分没有影响,所以这只是简单地增加距离数组的大小到| V + 1 |并在S和每个其他节点之间添加一条边:
vector<int> distance(V + 1, UNKNOWN);
int s = V;
for(int i = 0; i < V; i++)
{
    edges.push_back(new Edge(s, i, 0));
}
distance[s] = 0;
  1. 我们继续将 Bellman-Ford 的标准实现应用于修改后的图,使用S作为源节点:
for(int i = 1; i < V; i++)
{
    for(auto edge : edges)
    {
        int u = edge->start;
        int v = edge->end;
        int w = edge->weight;
        if(distance[u] == UNKNOWN)
        {
            continue;
        }
        if(distance[u] + w < distance[v])
        {
            distance[v] = distance[u] + w;
        }
    }
}
  1. 这次,让我们将负循环的最终检查移到自己的函数中:
bool HasNegativeCycle(vector<int> distance, vector<Edge*> edges)
{
    for(auto edge : edges)
    {
        int u = edge->start;
        int v = edge->end;
        int w = edge->weight;
        if(distance[u] == UNKNOWN) continue;
        if(distance[u] + w < distance[v])
        {
            return true;
        }
    }
    return false;
}
  1. 现在,我们可以在原始函数的末尾调用它,如果发现负循环,则返回一个空数组:
if(HasNegativeCycle(distance, edges))
{
    cout << "NEGATIVE CYCLE FOUND" << endl;
    return {};
}
  1. 在确保图中没有负循环之后,我们可以将结果距离值集返回给调用函数,并对图中的每条边应用重新加权公式。但首先,让我们实现 Dijkstra 的算法:
vector<int> Dijkstra(int V, int start, vector<Edge*> edges)
  1. 现在,让我们声明一个整数向量distance和一个布尔向量visited。通常情况下,distance的每个索引都将初始化为UNKNOWN(除了起始顶点),visited的每个索引都将初始化为 false:
vector<int> distance(V, UNKNOWN);
vector<bool> visited(V, false);
distance[start] = 0;
  1. 我们的 Dijkstra 算法实现将利用一个简单的迭代方法,使用for循环。正如你可能还记得的,Dijkstra 算法需要在遍历的每一步中找到具有最小距离值的节点。虽然通常是通过优先队列来实现这一点,但我们将通过编写另一个短函数GetMinDistance()来实现这一点,该函数将以距离和访问数组作为参数,并返回具有最短路径值的节点的索引:
// Find vertex with shortest distance from current position and
// return its index
int GetMinDistance(vector<int> &distance, vector<bool> &visited)
{
    int minDistance = UNKNOWN;
    int result;
    for(int v = 0; v < distance.size(); v++)
    {            
        if(!visited[v] && distance[v] <= minDistance)
        {
            minDistance = distance[v];
            result = v;
        }
    }
    return result;
}
  1. 现在我们可以完成实现 Dijkstra 算法:
for(int i = 0; i < V - 1; i++)
{
    // Find index of unvisited node with shortest distance
    int curr = GetMinDistance(distance, visited);
    visited[curr] = true;
    // Iterate through edges
    for(auto edge : edges)
    {
        // Only consider neighboring nodes
        if(edge->start != curr) continue;
        // Disregard if already visited
        if(visited[edge->end]) continue;
        if(distance[curr] != UNKNOWN && distance[curr] + edge->weight < distance[edge->end])
        {
        distance[edge->end] = distance[curr] + edge->weight;
        }
    }
}
return distance;
  1. 现在我们已经有了执行 Johnson 算法所需的一切。让我们声明一个新函数Johnson(),它也将以Vedges作为参数:
void Johnson(int V, vector<Edge*> edges)
  1. 我们首先创建一个整数向量h,并将其设置为BellmanFord()的输出:
// Get distance array from modified graph
vector<int> h = BellmanFord(V, edges);
  1. 我们检查h是否为空。如果是,我们终止函数:
if(h.empty()) return; 
  1. 否则,我们应用重新加权公式:
for(int i = 0; i < edges.size(); i++)
{
    edges[i]->weight += (h[edges[i]->start] - h[edges[i]->end]);
}
  1. 为了存储每对节点的最短路径距离,我们初始化一个具有V行的矩阵(这样每对二维索引[i, j]表示顶点i和顶点j之间的最短路径)。然后我们对 Dijkstra 算法进行V次调用,它返回每个起始节点的distance数组:
// Create a matrix for storing distance values
vector<vector<int>> shortest(V);
// Retrieve shortest distances for each vertex
for(int i = 0; i < V; i++)
{
    shortest[i] = Dijkstra(V, i, edges);
}
  1. 毫不奇怪,我们在这一步积累的结果相当不准确。由于我们的重新加权操作,现在每个距离值都是正的。然而,这可以通过将相同的公式逆向应用于每个结果来很简单地纠正:
// Reweight again in reverse to get original values
for(int i = 0; i < V; i++)
{
    cout << i << ":\n";
    for(int j = 0; j < V; j++)
    {
        if(shortest[i][j] != UNKNOWN)
        {
            shortest[i][j] += h[j] - h[i];
            cout << "\t" << j << ": " << shortest[i][j] << endl;
        }
    }
}
  1. 现在,让我们回到我们的main()函数并实现处理输入的代码。在我们收集了输入图的边之后,我们只需要对Johnson()进行一次调用,我们的工作就完成了:
int main()
{
    int V, E;
    cin >> V >> E;
    vector<Edge*> edges;
    for(int i = 0; i < E; i++)
    {
        int node_a, node_b, weight;
        cin >> node_a >> node_b >> weight;
        edges.push_back(new Edge(node_a, node_b, weight));
    }
    Johnson(V, edges);
    return 0;
}
  1. 让我们使用以下输入来测试我们的算法:
7 9
0 1 3
1 2 5
1 3 10
1 5 -4
2 4 2
3 2 -7
4 1 -3
5 6 -8
6 0 12
  1. 输出应该如下:
0:
    0: 0
    1: 3
    2: 6
    3: 13
    4: 8
    5: -1
    6: -9
1:
    0: 0
    1: 0
    2: 3
    3: 10
    4: 5
    5: -4
    6: -12
2:
    0: -1
    1: -1
    2: 0
    3: 9
    4: 2
    5: -5
    6: -13
4:
    0: -3
    1: -3
    2: 0
    3: 7
    4: 0
    5: -7
    6: -15
5:
    0: 4
    1: 7
    2: 10
    3: 17
    4: 12
    5: 0
    6: -8
6:
    0: 12
    1: 15
    2: 18
    3: 25
    4: 20
    5: 11
    6: 0

从前面的输出中可以看出,我们已成功打印了从每个节点到其他每个节点的最短距离。

活动 16:随机图统计

你是一家知名软件公司的开发人员,每年都会接收大量的新求职者。因此,每个员工都必须参与进行技术面试的过程。在每次面试之前,你会得到一组三个编程问题,每个问题包含一个简短的描述,以及两到三个不断增加难度的测试用例。

最近有人向你提出,一些面试者事先获得了某些面试问题的测试用例。因此,有关方面要求你每隔几周就创建新的测试用例集。对大多数问题产生合理的测试用例并不特别具有挑战性,除了涉及图论的问题。你已经注意到设计一个既有效又与问题相关的图的过程可能有点耗时,因此你已决定自动化这个过程。

你的公司最常见的与图相关的面试问题是全对最短路径问题,这要求面试者找到有向加权边图中每对顶点之间的最短距离。由于这个问题的性质,你希望生成的图对于评估面试者对问题的理解是有用的。你决定如果一个图符合以下标准,它将对技术面试有用:

  • 这是一个有向图,可以包含正边权和负边权。

  • 任何一对节点之间应该只有一条边,且没有节点应该有指向自身的边。

  • 每个节点应至少有一条入边或出边。

  • 任何边权的绝对值应小于 100。

该实用程序应接受以下输入:

  • seed:用于随机数生成的种子值

  • iterations:要生成的图的数量

  • V:顶点的数量

  • E:边的数量

该工具应该使用对std::rand()的调用来处理每条边的生成。如果它尝试在相同节点对之间创建第二条边,则应停止生成新的边,直到找到有效的节点对。

图的生成应按以下方式进行:

  1. 接收输入(seediterationsVE

  2. 设置随机数生成器的种子值

  3. 对于每次迭代,执行以下操作:

  • 将 i 设置为 0
  • 尝试通过执行三次rand()调用来创建边,以生成源节点、目标节点和边权值(按顺序)的值。

  • 检查rand()生成的下一个值是否能被3整除;如果可以,使边权值为负数。

  • 如果源节点和目标节点之间已经存在一条边,请重试:
  • edge(source, destination, weight)添加到边集合中并递增i

  • 如果创建了E条边后存在一个不属于任何边的节点,则认为图无效。

如果生成的图是有效的,您应该找到图中每对节点之间的最短路径,就像我们在面试中所期望的那样。对于图中的每个节点,您希望找到其所有路径的平均最短距离(即距离值之和除以可到达节点的数量)。图的平均距离将被定义为这些值的平均值。

您还对哪些值集合倾向于产生最多“有趣”的图感兴趣。当图的平均距离小于最高边权值的一半时,您认为图是有趣的。因此,您的算法应该输出有趣图与有效图的比率(以百分比显示并四舍五入到两位小数)。请注意,对于这个特定目的,您认为具有负权重环的连通图是有效但不是有趣的。

输入格式

包含四个整数的一行;即seediterationsVE

输出格式

两行,第一行包含INVALID:字符串,后面是无效图的数量,第二行包含PERCENT INTERESTING:字符串,后面是有趣图与有效图的比率,以百分比显示并四舍五入到两位小数。

活动指南

std::rand()的调用在每个环境中不一定会产生相同的值。为了确保一致性,您可以将以下代码复制/粘贴到您的程序中(取自 C 标准):

static unsigned long int randNext = 1;
int rand(void) // RAND_MAX assumed to be 32767
{
    randNext = randNext * 1103515245 + 12345;
    return (unsigned int)(randNext/65536) % 32768
}
void srand(unsigned int seed)
{
    randNext = seed;
}

在实现图生成工具时,请确保按照问题描述中的确切顺序进行步骤。

测试用例

以下是一些示例输入和输出,应该帮助您更好地理解问题:

图 7.12:活动 16 的测试用例

图 7.12:活动 16 的测试用例

注意

此活动的解决方案可在第 541 页找到。

强连通分量

在前几章中,我们讨论了图的几种分类。描述图特征的最常见方式之一是说明它是有向的还是无向的。后者定义了边默认是双向的图(如果节点 A 有一条连接到节点 B 的边,则节点 B 也有一条连接到节点 A 的边),而前者描述了具有定向边的图。

想象一下,你是一个视频托管网站的员工,负责制作关于各个频道订阅者之间共同点的统计数据。你的公司特别希望发现订阅某些频道的个人与频道所有者的订阅之间的模式,希望更深入地了解他们的定向广告服务应该如何定位。你的公司提供的服务最近变得相当广泛,因此你需要一种方法来以清晰的方式组织相关数据,以产生有用的统计信息。

让我们将网站每个用户的频道视为定向图中的节点,它们之间的邻接表示他们订阅的其他频道的所有者。我们可能会注意到,即使在订阅相同频道的大群用户中,所有个人订阅集合的多样性也会极大地复杂化我们找到它们之间的任何区别相似性的能力。理想情况下,我们希望解开图中庞大的连接混乱,并将数据放入明确的组中,其中每个用户的订阅与其他用户的订阅有某种关联。

我们可以通过观察定向图的某些共同特征来解开这个特定问题的复杂性。因为定向图的边不一定是双向的,我们可以逻辑推断出,取决于从哪个节点开始遍历,对图的某些部分的访问可能会受到限制。如果你将一个图分成不同的集合,使得同一集合中的任意一对顶点之间都有连接路径,那么得到的组将代表图的强连通分量。

定向图和无向图中的连通性

无向图的连通分量可以描述为主图中包括每个节点的最大子图的集合,其中同一组内的每个节点都与其他节点“连接”(即,单个分量内任意两个节点之间的访问是无限制的)。在一个连通图中,无论遍历从哪里开始,每个节点都可以被访问,因此我们可以推断出这样的图由单个连通分量(整个图)组成。相反,任何具有从一点到另一点受限制访问的图被描述为不连通。

另一方面,所谓的“强”连通性是定向图所特有的特征。为了相对地理解“强连通性”的定义上的差异,观察以下无向图的例子:

图 7.13:具有不同连通分量的图

三个彩色子图分别代表一个独立的连通分量。正如我们之前所述,它们的连通性是由每个顶点都与同一组内的其他顶点有路径连接这一事实所定义的。此外,来自一个分量的任何顶点都没有与不同分量连接的路径。从前面的图中,我们可以看到无向图的连通分量被划分为完全独立的组,其中任何分量的节点和边都与其他分量完全隔离。

相比之下,强连通分量不需要完全与图中的其他分量隔离 - 也就是说,可以存在在分量之间重叠的路径:

![图 7.14:具有不同强连通分量的图

图 7.14:具有不同强连通分量的图

在前面的图中,我们可以看到有四个强连通分量:ABCEFGDHI。请注意,节点AB是它们各自集合中唯一的成员。通过进一步研究节点A,我们可以看到,尽管ADHI集合中的每个节点都有路径,但DHI集合中的节点没有任何通往节点A的路径。

回到我们的视频托管网站示例,我们可以将网络图的强连通分量定义为组,其中每个频道都可以通过在同一组内与其他用户频道的订阅路径中找到。以这种方式分解潜在的大量数据可能有助于从没有区别相似性的图关系中隔离出相关的图关系集:

图 7.15:将不同强连通分量表示为图的示例数据集

图 7.15:将不同强连通分量表示为图的示例数据集

Kosaraju's Algorithm

找到图的强连通分量最常见且概念上容易理解的方法之一是 Kosaraju 算法。Kosaraju 算法通过执行两组独立的 DFS 遍历来工作,首先探索原始形式的图,然后对其进行转置。

注意

尽管 DFS 是 Kosaraju 算法中通常使用的遍历类型,但 BFS 也是一个可行的选择。然而,在本章中包括的解释和练习中,我们将坚持传统的基于 DFS 的方法。

图的转置与原始图本质上相同,只是其每条边中的源/目标顶点被交换(也就是说,如果原始图中有一条从节点A到节点B的边,转置图中将有一条从节点B到节点A的边):

图 7.16:图的转置

图 7.16:图的转置

算法的第一步(初始化后)是遍历图的顶点并执行 DFS 遍历,从尚未在先前遍历中访问过的每个节点开始。在 DFS 的每个点开始时,当前节点被标记为已访问,然后探索其所有未访问的邻居。在调查完每个当前节点的邻接点之后,它被添加到栈的顶部,然后当前递归子树被终止。

在探索原始图中的每个顶点之后,从栈的顶部弹出的每个未访问节点也会在其转置中执行相同的操作。在这一点上,每次以唯一起点进行的后续 DFS 遍历遇到的节点集合代表了图的一个强连通分量。

Kosaraju 算法在直观上简化了一个潜在复杂的问题,将其简化为相当容易实现的东西,因此在效率上也是相当高效的,假设输入图具有邻接表表示,它的渐近复杂度也是线性的O(V + E)

注意

由于在遍历中需要大量额外的迭代来查找每个顶点的邻居,因此不建议使用此算法的邻接矩阵。

我们将在下面的练习中看一下 Kosarju 算法的实现。

练习 35:实现 Kosaraju 算法

在这个练习中,我们将使用 Kosaraju 算法找到图中的强连通分量。让我们开始吧:

  1. 对于我们实现 Kosaraju 算法,我们需要包括以下头文件:
#include <iostream>
#include <vector>
#include <stack>
  1. 让我们定义一个名为Kosaraju()的函数,它接受两个参数 - 一个整数V(顶点的数量),一个整数向量的向量adj(图的邻接表表示) - 并返回一个整数向量的向量,表示输入图的每个强连通分量中的节点索引集合:
vector<vector<int>> Kosaraju(int V, vector<vector<int>> adj)
  1. 我们的第一步是声明我们的堆栈容器和访问数组(每个索引都初始化为false)。然后我们遍历图的每个节点,从尚未标记为visited的每个索引开始我们的 DFS 遍历:
vector<bool> visited(V, false);
stack<int> stack;
for(int i = 0; i < V; i++)
{
    if(!visited[i])    
    {
        FillStack(i, visited, adj, stack);
    }
}
  1. 我们的第一个 DFS 函数FillStack()接受四个参数:一个整数节点(遍历中当前点的顶点索引),一个名为visited的布尔向量(先前遍历的节点集),以及两个整数向量adj(图的邻接表)和stack(按照探索顺序排列的已访问节点索引列表)。最后三个参数将从调用函数中传递引用。DFS 是以标准方式实现的,除了在每个函数调用结束时将当前节点的索引推送到堆栈的附加步骤:
void FillStack(int node, vector<bool> &visited,
vector<vector<int>> &adj, stack<int> &stack)
{
    visited[node] = true;
    for(auto next : adj[node])
    {
        if(!visited[next])
        {
            FillStack(next, visited, adj, stack);
        }
    }
    stack.push(node);
}
  1. 现在,让我们定义另一个名为Transpose()的函数,它接受原始图的参数,并返回其转置的邻接表:
vector<vector<int>> Transpose(int V, vector<vector<int>> adj)
{
    vector<vector<int>> transpose(V);
    for(int i = 0; i < V; i++)
    {
        for(auto next : adj[i])
        {
            transpose[next].push_back(i);
        }
    }
    return transpose;
}
  1. 为了准备下一组遍历,我们声明了邻接表转置(初始化为我们的Transpose()函数的输出),并重新将我们的访问数组初始化为false
    vector<vector<int>> transpose = Transpose(V, adj);

    fill(visited.begin(), visited.end(), false);
  1. 我们的算法的第二部分,我们需要定义我们的第二个 DFS 函数CollectConnectedComponents(),它与FillStack()接受相同的参数,除了第四个参数现在被替换为整数向量组件的引用。这个向量组件是我们将在图中存储每个强连通分量的节点索引的地方。遍历的实现也几乎与FillStack()函数相同,除了我们删除将节点推入堆栈的行。相反,我们在函数的开头包含一行,将遍历的节点收集到组件向量中:
void CollectConnectedComponents(int node, vector<bool> &visited,
vector<vector<int>> &adj, vector<int> &component)
{
    visited[node] = true;
    component.push_back(node);
    for(auto next : adj[node])
    {
        if(!visited[next])
        {
            CollectConnectedComponents(next, visited, adj, component);
        }
    }
}
  1. 回到我们的Kosaraju()函数,我们定义了一个称为connectedComponents的整数向量的向量,这是我们将存储在转置上执行的每次遍历的结果的地方。然后,我们在while循环中迭代地从堆栈中弹出元素,再次从未访问的节点开始每次 DFS 遍历。在每次调用 DFS 函数之前,我们声明由CollectConnectedComponents()引用的组件向量,然后在遍历完成后将其推送到connectedComponents。当堆栈为空时,算法完成,之后我们返回connectedComponents
vector<vector<int>> connectedComponents;
while(!stack.empty())
{
    int node = stack.top();
    stack.pop();
    if(!visited[node])
    {
        vector<int> component;
        CollectConnectedComponents(node, visited, transpose, component);
        connectedComponents.push_back(component);
    }
}
return connectedComponents;
  1. 从我们的main()函数中,我们现在可以通过在单独的行上打印每个向量的值来输出每个强连通分量的结果:
int main()
{
    int V;
    vector<vector<int>> adj;
    auto connectedComponents = Kosaraju(V, adj);
    cout << "Graph contains " << connectedComponents.size() << " strongly connected components." << endl;
    for(auto component : connectedComponents)
    {
        cout << "\t";
        for(auto node : component)
        {
            cout << node << " ";
        }
        cout << endl;
    }
}
  1. 为了测试我们新实现的算法的功能,让我们基于以下图创建一个邻接表表示:图 7.17:示例输入数据的图形表示
图 7.17:示例输入数据的图形表示
  1. main()中,Vadj将被定义如下:
int V = 9;
vector<vector<int>> adj =
{
    { 1, 3 },
    { 2, 4 },
    { 3, 5 },
    { 7 },
    { 2 },
    { 4, 6 },
    { 7, 2 },
    { 8 },
    { 3 } 
};
  1. 执行我们的程序后,应该显示以下输出:
Graph contains 4 strongly connected components.
    0 
    1 
    2 4 5 6 
    3 8 7

活动 17:迷宫传送游戏

您正在设计一个游戏,其中多个玩家随机放置在一个迷宫的房间中。每个房间都包含一个或多个传送装置,玩家可以使用它们在迷宫的不同部分之间旅行。每个传送装置都有一个与之关联的值,这个值将被添加到使用它的任何玩家的得分中。玩家轮流穿越迷宫,直到每个房间至少被访问一次为止,然后回合结束,得分最低的玩家获胜。

您已经实施了一个系统,在每场比赛开始时会程序生成一个新的迷宫。不幸的是,您最近发现一些生成的迷宫包含了玩家可以使用的循环,无限降低他们的得分。您还注意到玩家经常根据他们所在的房间拥有不公平的优势。最糟糕的是,传送门经常分散在这样的方式,以至于玩家最终可能被隔离在迷宫的某个部分,持续一轮比赛。

您希望实施一个测试程序,以确保生成的迷宫是公平且平衡的。您的测试应该首先确定迷宫是否包含可用于无限降低玩家得分的路径。如果是,它应该输出INVALID MAZE。如果迷宫有效,您应该找到可以从每个起点获得的最低分数并报告它们(或在没有传送门的房间的情况下报告DEAD END)。

此外,您希望防止在迷宫的特定部分中被困住的可能性,因此您的测试还应输出玩家无法访问迷宫其他部分的任何房间组。

预期输入

每个测试应该接收以下输入:

  • 迷宫中的房间数量

  • 迷宫中的传送门数量

  • 源房间、目标房间以及与每个传送门相关联的点数

预期输出

对于每个测试,程序应首先确定迷宫中是否存在可以用于无限降低玩家得分的路径。如果是,它应该打印一行:INVALID MAZE

如果迷宫有效,您的程序应输出可以从每个房间开始实现的最低分数(或在房间没有传送门的情况下输出DEAD END),假设至少进行一次移动并且整个迷宫只能遍历一次。最后,您的程序应列出玩家可能被“困住”的房间组(即,他们完全无法访问迷宫的其他部分);对于每个这样的组,您的程序应在单独的行上打印每个房间的索引。

样本输入和输出

以下是一些样本输入,应该帮助您更好地理解这个问题:

图 7.18:活动 17 的测试用例 1

图 7.18:活动 17 的测试用例 1

图 7.19:活动 17 的测试用例 2

图 7.19:活动 17 的测试用例 2

图 7.20:活动 17 的测试用例 3

图 7.20:活动 17 的测试用例 3

图 7.21:活动 17 的测试用例 4

图 7.21:活动 17 的测试用例 4

图 7.22:活动 17 的测试用例 5

图 7.22:活动 17 的测试用例 5

图 7.23:活动 3 的测试用例 6图 7.23:活动 17 的测试用例 6

图 7.23:活动 17 的测试用例 6

图 7.24:活动 17 的测试用例 7

图 7.24:活动 17 的测试用例 7

活动指南

  • 不要被无关的信息分散注意力。问问自己需要完成什么具体任务。

  • 问题的第一个条件(确定迷宫是否包含可以无限降低我们得分的路径)也可以表述为:如果将迷宫表示为加权图,是否存在任何产生负总和的路径上的循环?显然,这是我们有能力处理的问题!您可能还意识到第二个条件(找到可以从给定点开始获得的最低分数)与第一个条件密切相关。

  • 最后一个条件有点更具挑战性。考虑如何根据我们在本章讨论过的图术语重新定义在迷宫的某个部分被“困住”的情况。具有这种属性的迷宫会是什么样子?

  • 考虑在纸上绘制一个或多个输入图。什么特征表征了玩家可能被困的房间组?

此活动的解决方案可在第 550 页找到。

选择正确的方法

到目前为止,很明显很少有单一的“完美”图结构实现方法。我们所代表的数据的特征,加上我们试图解决的问题的细节,可能会使某些方法在不同条件下变得不合理低效。

无论您试图确定是使用邻接表还是矩阵,类/结构还是简单数组,贝尔曼-福特还是约翰逊算法,BFS 还是 DFS 等,最终决定应主要取决于数据的具体情况以及您打算如何使用它。例如,如果您想要在图中的每对节点之间找到最短距离,约翰逊算法将是一个很好的选择。然而,如果您只需要偶尔为单个起始节点找到最短距离,约翰逊算法将执行相当多不必要的工作,而一次贝尔曼-福特的调用就足够了。

尝试使用不同形式的图表示来编写我们在本章讨论过的每个算法是一种有益的练习。例如,贝尔曼-福特可以通过用邻接表和边权重的二维矩阵替换我们在第一个练习中使用的Edge指针向量来轻松实现。在某些情况下,一个实现所提供的效率潜力可能只比另一个略好一些;而在其他时候,差异可能相当显著。有时,某种方法的价值更多地与简单性和可读性有关,而不是任何可衡量的性能基准。比较各种算法在不同数据集和场景中的性能扩展如何,通常是真实开发中的一个重要实践。

在努力发展对图论和实现的更好理解时,我们提供以下建议:

  • 抵制使用“复制粘贴”方法来实现新算法的冲动。如果您不理解算法的工作原理,您很有可能会错误地使用它。此外,即使它能够按照您的意愿运行,重要的是要记住,图的实现高度特定于上下文。盲目使用任何算法意味着您将缺乏扩展解决方案功能所必需的理解。

  • 在将新概念付诸实践时,避免完全依赖抽象的、非情境化的实现。在纯理论数据上使用某种算法后,尝试修改它以适应某种实际数据模型(即使该数据本身是假设的)。想象您可以在哪些真实场景中使用您新获得的算法知识,将增加您在工作中知道何时以及如何使用它的可能性。

在您真正考虑以下内容之前,避免实现您的图:

  • 它的基本目的和实现该目的所需的基本功能(即它描述的数据,它需要执行的查询类型,它需要多动态等)

  • 它需要表示有关问题的相关信息的最基本组件

未能评估这些关键思想可能导致混乱和过于冗长的代码,其中包含不必要的数据和函数,实质上对实际解决方案没有任何价值。在编写任何代码之前规划图的必要组件可能会节省您相当多的混乱和繁琐的重构。

最终,全面理解图形编程是一项技能,远远超出了简单学习所有正确算法的范围。与任何非平凡图形问题相关的简单网络搜索将导致大量深入分析的研究文章,对不同方法的比较评估,以及尚未发现合理实现的猜想解决方案。一如既往,持续的实践是掌握任何编程技能的最佳方法;而图形理论作为一个广阔而动态的研究领域,当然也不例外!

总结

到目前为止,我们已经相当全面地涵盖了图形。现在你应该对图形理论在软件开发中的一些基本用途有了扎实的理解,同时也能够欣赏到基于图形的解决方案如何能够以相对容易的方式封装复杂数据,使我们能够查询和操作它。在第六章中学习了图形结构和遍历的基础知识后,然后在本章中扩展了它们以解决更高级的问题,你现在应该已经准备好在未来探索更深入的图形实现,因为这些基本概念是它们所有的核心。

虽然本章并没有完全结束我们对本书中图形算法的讨论,但我们现在将暂时停止讨论图形,转而探讨现代开发人员技能库中最强大和具有挑战性的编程技术之一。与图形算法一样,我们接下来要讨论的主题是如此广泛和概念抽象,以至于它将跨越两个单独的章节。然而,由于它的实用性(和难度),它是许多软件公司在技术面试中喜欢的一个主题。

第八章:动态规划 I

学习目标

在本章结束时,您将能够:

  • 分析动态规划方法是否适用于给定问题

  • 比较并选择记忆化和表格法之间的正确方法

  • 选择使用记忆化的适当缓存解决方案

  • 使用朴素的蛮力方法分析问题

  • 通过实现逐步优化的算法来开发动态规划解决方案

在本章中,您将介绍动态规划方法。本章将指导您实现这种方法来解决计算机科学中一些众所周知的问题。

介绍

许多程序员对动态规划DP)既爱又恐惧,它是分治范例的概念扩展,适用于特定类别的问题。动态规划问题涉及的困难是多方面的,通常需要创造力、耐心和对抽象概念的可视化能力。然而,这些问题提出的挑战通常有优雅且令人惊讶地简单的解决方案,这些解决方案可以为程序员提供超出即时任务范围的见解。

在上一章中,我们讨论了几种技术,比如分治和贪婪方法。这些方法在适当的情况下非常有效,但在某些情况下不会产生最佳结果。例如,在上一章中,我们讨论了 Dijkstra 算法对于具有负边权重的图不会产生最佳结果,而 Bellman-Ford 算法会。对于可以递归解决但不能使用前述技术解决的问题,DP 解决方案通常是最佳方法。

DP 问题也出现在各种情况下。以下只是一些广泛的例子:

  • 组合数学(计算符合特定条件的序列的组合/排列数)

  • 字符串/数组(编辑距离、最长公共子序列、最长递增子序列等)

  • 图(最短路径问题)

  • 机器学习(语音/人脸识别)

让我们从理解动态规划的基本思想开始。

什么是动态规划?

回答这个问题的最佳方法是通过例子。为了说明动态规划的目的,让我们考虑斐波那契数列:

{ 0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, … }

通过观察前述序列,我们可以看到,从第三个元素开始,每个项都等于前两个项的和。这可以用以下公式简单表示:

F(0) = 0
F(1) = 1
…
F(n) = F(n-1) + F(n-2)

正如我们可以清楚地看到,这个序列的项之间存在递归关系 - 当前项F(n)基于前面项F(n-1)F(n-2)的结果,因此前述方程F(n) = F(n-1) + F(n-2)被描述为序列的递归关系。初始项F(0)F(1)被描述为基本情况,或者在不需要进一步递归的情况下产生解决方案的点。这些操作如下图所示:

图 8.1:计算斐波那契数列的第 n 项

图 8.1:计算斐波那契数列的第 n 项

用英语描述前述图可能是这样的:

F5 is equal to: 
    F4 + F3, where F4 is equal to:
    .    F3 + F2, where F3 is equal to:
    .    .    F2 + F1, where F2 is equal to:
    .    .    .    F1 + F0, where F1 = 1 and F0 = 0.
    .    .    …and F1 = 1.
    .    …and F2 is equal to:
    .        F1 + F0, where F1 = 1 and F0 = 0.
    …and F3 is equal to:
        F2 + F1, where F2 is equal to:
        .    F1 + F0, where F1 = 1 and F0 = 0
        …and F1 = 1.

我们将前述方法描述为自顶向下的解决方案,因为它从递归树的顶部(即解决方案)开始,并沿着其分支向下遍历,直到达到基本情况。在 C++中,这可以使用以下递归函数来编写:

    int Fibonacci(int n)
    {
        if(n < 2)
        {
            return n;
        }
        return Fibonacci(n – 1) + Fibonacci(n – 2);
    }

通过进一步观察树,我们可以看到几个必须多次解决的子问题,或者说必须解决以找到最终解决方案的中间问题。例如,必须找到F(2)的解决方案才能得到F(4) [F(3) + F(2)]F(3) [F(2) + F(1)]的解决方案。因此,斐波那契数列被认为具有重叠子问题的特性。这是将标准的分治问题与动态规划问题区分开的定义特征之一;在前者中,子问题往往是唯一的,而在后者中,相同的子问题必须重复解决。

我们还可以看到几个解决方案分支彼此完全相同。例如,找到F(2)的解决方案将需要相同的一组计算,无论您需要它来解决F(4)还是F(3)。这展示了动态规划问题的第二个定义特征,即最优子结构。当问题的最优解可以通过其子问题的最优解的某种组合形成时,问题被认为具有最优子结构

要使用动态规划解决问题,问题必须具备这两个特性。由于重叠子问题的特性,这些问题的复杂性随着输入的增加而呈指数级增长;然而,利用最优子结构的特性可以显著减少复杂性。因此,DP 的目的实质上是设计一种缓存先前解决方案的方法,以避免重复计算先前解决的子问题。

备忘录化 - 自顶向下的方法

不,这不是“记忆”,尽管这也可以相当准确地描述这种技术。使用备忘录化,我们可以重新制定我们之前描述的自顶向下解决方案,以利用斐波那契数列所展示的最优子结构特性。我们的程序逻辑基本上与以前一样,只是现在,在每一步找到解决方案后,我们将结果缓存到一个数组中,根据当前值n进行索引(在这个问题中,n代表定义当前递归分支的状态或参数集)。在每次函数调用的开始,我们将检查是否在缓存中有状态F(n)的解决方案可用。如果有,我们将简单地返回缓存的值:

const int UNKNOWN = -1;
const int MAX_SIZE = 100000;
vector<int> memo(MAX_SIZE, UNKNOWN);
int Fibonacci(int n)
{
    if(n < 2)
    {
        return n;
    }
    if(memo[n] != UNKNOWN)
    {
        return memo[n];
    }
    int result = Fibonacci(n - 1) + Fibonacci(n - 2);
    memo[n] = result;
    return result;
}

递归树现在看起来是这样的:

图 8.2:使用缓存解决方案计算斐波那契数列中的第 n 个项

图 8.2:使用缓存解决方案计算斐波那契数列中的第 n 个项

通过这样做,我们消除了相当多的冗余工作。这种以自顶向下递归地缓存解决方案的技术称为备忘录化,并且基本上可以用于任何 DP 问题,只要以下条件为真:

  1. 您可以设计一个利用不同状态的相似性并保持其唯一性的缓存方案。

  2. 在超出可用堆栈空间之前,您可以累积所需子问题的解决方案。

第一点意味着索引结果以供以后使用的方法应该既有效又有用。为了使缓存方案有效,它必须只被视为与其解决方案源自相同一系列子问题的状态匹配;为了使其有用,它必须不是那么特定于状态,以至于无法有效使用(例如,如果每个子问题在缓存中被分配一个唯一的索引,条件"if(memo[KEY] != UNKNOWN)"将永远不会成立)。

第二点指的是可能引起堆栈溢出错误的可能性,这是任何自顶向下方法的基本限制,如果递归调用的次数可能非常高。堆栈溢出发生在程序超出调用堆栈上可用的内存分配量时。根据给定问题的性质,可能需要的递归深度可能会阻止记忆化成为可行的选择;因此,在选择方法之前评估手头任务的潜在复杂性是非常有益的。

记忆化经常是动态规划问题的一个不错的优化方法。然而,在许多情况下,有更好的选择,我们将在下一节中学习。

制表 - 自底向上的方法

动态规划的核心是制表法,这是记忆化的逆向方法。事实上,尽管动态规划这个术语有时被应用于记忆化和制表,但通常认为它特指后者。

制表的标准实现包括存储基本情况的解决方案,然后迭代地填充一个表格,其中包含每个子问题的解决方案,然后可以重复使用这些解决方案来找到其他子问题的解决方案。制表解决方案通常被认为比记忆化的解决方案更难以理解,因为每个子问题的状态必须以可以迭代表示的方式来表示。

计算斐波那契数列的制表解决方案如下:

int Fibonacci(int n)
{
        vector<int> DP(n + 1, 0);
        DP[1] = 1;
        for(int i = 2; i <= n; i++)
        {
            DP[i] = DP[i-1] + DP[i-2];
        }
        return DP[n];
} 

在斐波那契数列的例子中,状态非常简单,因为它是一维的和无条件的——对于任何大于 1 的 n,公式总是成立,即 F(n) = F(n-1) + F(n-2)。然而,动态规划问题通常包含定义给定状态的多个维度,并且可能有多个条件影响状态之间的转换。在这种情况下,确定如何表示当前状态可能需要相当多的创造力,以及对问题的全面理解。

然而,制表的优势是显著的。除了制表解决方案通常在内存方面更有效之外,它们还产生一个包含每个给定状态的完整查找表。因此,如果你可能会收到关于问题的任何状态的查询,制表很可能是你最好的选择。

有趣的是,任何可以通过记忆化解决的问题理论上都可以重新制定为制表解决方案,反之亦然。使用前者通常可以为如何处理后者提供巨大的见解。在接下来的几节中,我们将探讨动态规划问题的几个经典示例,并演示如何使用多种方法(从朴素的蛮力开始)可以使你达到制表解决方案所需的理解水平。

子集和问题

想象一下,你正在为一个数字现金注册逻辑。每当顾客需要找零时,你希望显示一条消息,告诉收银员当前注册处的钱是否可以以某种方式组合,使其总和等于所需的找零金额。例如,如果一个产品售价为 7.50 美元,顾客支付 10.00 美元,消息将报告注册处的钱是否可以用来产生精确的 2.50 美元的找零。

假设注册处当前包含十个 25 美分的硬币(10 x 0.25 美元),四个 10 美分的硬币(4 x 0.10 美元)和六个 5 美分的硬币(6 x 0.05 美元)。我们可以很容易地得出结论,2.50 美元的目标总额可以以下列方式形成:

10 quarters                    -> $2.50
9 quarters, 2 dimes, 1 nickel  -> $2.25 + $0.20 + $0.05
9 quarters, 1 dime,  3 nickels -> $2.25 + $0.10 + $0.15
9 quarters, 5 nickels          -> $2.25 + $0.25
8 quarters, 4 dimes, 2 nickels -> $2.00 + $0.40 + $0.10
8 quarters, 3 dimes, 4 nickels -> $2.00 + $0.30 + $0.20
8 quarters, 2 dimes, 6 nickels -> $2.00 + $0.20 + $0.30

有了这些参数,问题就变得相当简单,可以通过简单地尝试所有可用的货币组合,直到找到与 2.50 美元相匹配的总和。但是,如果需要的找零是 337.81 美元,而收银机包含 100 张面额分别为 20.00 美元、10.00 美元、5.00 美元、1.00 美元、0.25 美元、0.10 美元、0.05 美元和 0.01 美元的纸币/硬币呢?我们可以清楚地看到,随着复杂度的增加,尝试每种可能的总和变得相当不切实际。这是一个被称为子集和问题的经典问题的例子。

在其最基本的形式中,对于集合S和整数x,是否存在S的元素的一个子集,其总和等于x?看下面的例子:

S = { 13, 79, 45, 29 }
x = 42 —> True (13 + 29)
x = 25 —> False 

以前面的集合为例,我们可以找到以下 16 个子集:

{ }
{ 13 }
{ 79 }
{ 45 }
{ 29 }
{ 13, 79 }
{ 13, 45 }
{ 13, 29 }
{ 79, 45 }
{ 79, 29 }
{ 45, 29 }
{ 13, 79, 45 }
{ 13, 79, 29 }
{ 13, 45, 29 }
{ 79, 45, 29 }
{ 13, 79, 45, 29 }

通过列出不同大小的集合可以产生的子集总数,我们得到以下数字:

0: 1
1: 2
2: 4
3: 8
4: 16
5: 32
6: 64
7: 128
…

从这个列表中,我们可以推断出从大小为n的集合中可以形成的子集的总数等于2**n,这表明要考虑的子集数量随着n的大小呈指数增长。假设S中的元素数量较小,比如 10 个元素或更少,那么对这个问题的蛮力方法可能会很快找到解决方案;但是如果我们重新考虑一个包含 100 种不同纸币/硬币的收银机的例子,S的大小将等于 100,这将需要探索 1,267,650,600,228,229,401,496,703,205,376 个子集!

解决子集和问题-步骤 1:评估是否需要 DP

面对这样的问题,我们的第一步是确定它是否可以(和/或应该)用 DP 解决。重申一下,如果问题具有以下特性,则可以用 DP 解决:

  • 重叠子问题:与标准的分治方法一样,最终解可以通过某种方式结合较小子问题的解来得出;与分治方法相反,某些子问题会被多次遇到。

  • 最优子结构:给定问题的最优解可以由其子问题的最优解产生。

让我们根据是否具有这些特征来分析前面的例子:

图 8.3:最优子结构和重叠子问题

'
图 8.3:最优子结构和重叠子问题

重新整理子集的集合,如图所示清楚地说明了每个大小为 n 的新子集是如何通过向大小为n-1的子集追加一个新元素来形成的。这是构建新子集的最佳方法,并且对于大于 0 的每个子集大小都成立。因此,子集和问题具有最优子结构。我们还可以看到,几个子集都是从相同的“子子集”派生出来的(例如,{13 79 45}{13 79 29}都基于{13 79})。因此,该问题还具有重叠子问题

满足了我们的两个标准后,我们可以得出结论,这个问题可以用动态规划解决。

第 2 步-定义状态和基本情况

确定这是一个 DP 问题后,我们现在必须确定在这个问题的背景下什么构成了一个状态。换句话说,就我们试图回答的问题而言,什么使一个可能的解决方案与另一个不同?

虽然通常建议在过程的早期考虑问题的这些方面,但通常很难在没有清晰理解最终结果是如何形成的情况下定义 DP 问题的状态,因此最好从最直接的方式开始实施解决方案。因此,我们将通过两种更简单的方式解决子集和问题来发展我们对基本情况和状态的理解。

在我们探索动态规划的过程中,我们将考虑每个问题的四种不同方法:蛮力回溯记忆化表格化。与任何 DP 问题一样,所有这些方法都能够产生正确的结果,但前三种方法在输入规模增加时很快显示出它们的局限性。然而,以这种方式逐渐实现优化的解决方案在解决任何动态规划问题时都可以产生很大的效果。

步骤 2.a:蛮力

尽管其效率低下,蛮力解决方案在开发对手头问题的理解方面可能非常有益。以蛮力方法实现可能是形成 DP 解决方案过程中的一个重要步骤,原因有几个:

  • 简单性:在不考虑效率的情况下编写解决方案的简单性可以是开发对问题基本方面的理解的绝佳方式;它还可以带来关于问题性质的见解,否则可能会在尝试理解其复杂性时因缺乏足够的上下文而被忽略。

  • 解决方案正确性的确定性:通常,特别复杂的 DP 解决方案在更好地理解问题时需要进行重新设计。因此,比较解决方案的输出与正确答案是至关重要的。

  • 可视化子问题的能力:蛮力解决方案将生成每个潜在解决方案,然后选择符合问题标准的解决方案。这提供了一种有效的方式来可视化正确解决方案的形成方式,然后可以检查其中可以在后续方法中使用的基本模式。

以下练习演示了蛮力方法的实现。

练习 36:使用蛮力方法解决子集和问题

在这个练习中,我们将使用蛮力方法找到子集和问题的解决方案。让我们开始吧:

  1. 让我们首先包括以下标头(以及std命名空间以方便起见):
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
  1. 此外,让我们定义一个名为DEBUG的预处理器常量和一个名为PRINT的宏,它将仅在DEBUG不为零时打印到stderr
#define DEBUG 0
#if DEBUG
#define PRINT(x) cerr << x
#else
#define PRINT(x) 
#endif
  1. 现在,我们将声明一个新函数SubsetSum_BruteForce(),它接受两个参数 - 一个整数数组set和一个整数sum - 并返回一个布尔值:
bool SubsetSum_BruteForce(vector<int> set, int sum)
{
    ……
}
  1. 现在,让我们声明另一个函数GetAllSubsets(),它接受四个参数 - 两个整数向量setsubset;一个整数index;和一个名为allSubsets的三维整数向量(通过引用传递)。我们将使用此函数递归地生成S的所有子集:
void GetAllSubsets(vector<int> set, vector<int> subset, int index, vector<vector<vector<int>>> &allSubsets)
{    
    // Terminate if the end of the set is reached
    if(index == set.size()) 
    {
        // Add the accumulated subset to the results, indexed by size
        allSubsets[subset.size()].push_back(subset);
        return;
    }
    // Continue without adding element to subset
    GetAllSubsets(set, subset, index + 1, allSubsets);
    // Add element to subset
    subset.push_back(set[index]);
    GetAllSubsets(set, subset, index + 1, allSubsets);
}
  1. 回到我们的SubsetSum_BruteForce()函数,我们现在可以声明allSubsets并调用该函数:
bool SubsetSum_BruteForce(vector<int> set, int target)
{
    vector<vector<vector<int>>> allSubsets(set.size() + 1);

    GetAllSubsets(set, {}, 0, allSubsets);

    ……
  1. 现在,我们可以遍历每个子集并将其总和与target进行比较,如果找到匹配,则返回true
for(int size = 0; size <= set.size(); size++)
{
    PRINT("SIZE = " << size << endl);
    for(auto subset : allSubsets[size])
    {
        int sum = 0;
        PRINT("\t{ ");
        for(auto number : subset)
        {
                PRINT(number << " ");
                sum += number;
        }
        PRINT("} = " << sum << endl);
        if(sum == target) return true;
    }
}
  1. 如果在检查每个子集后找不到匹配的和,我们返回false
    ……
    return false;
}
  1. 现在,在main()函数中,让我们定义我们的集合和目标如下:
int main()
{
    vector<int> set = { 13, 79, 45, 29 };
    int target = 58;
    ……
}
  1. 我们现在可以这样调用SubsetSum_BruteForce()与这些输入:
bool found = SubsetSum_BruteForce(set, target);
if(found)
{
    cout << "Subset with sum " << target << " was found in the set." << endl;
}
else 
{
    cout << "Subset with sum " << target << " was not found in the set." << endl;
}
  1. 运行上述代码后,您应该看到以下输出:
Subset with sum 58 was found in the set.
  1. 现在,让我们将target设置为一个集合中找不到的和:
int target = 1000000;
  1. 再次运行程序应该产生以下输出:
Subset with sum 1000000 was not found in the set.
  1. 最后,让我们将我们的DEBUG常量重新定义为 1:
#define DEBUG 1
  1. 现在运行程序将产生以下输出:
SIZE = 0
    { } = 0
SIZE = 1
    { 29 } = 29
    { 45 } = 45
    { 79 } = 79
    { 13 } = 13
SIZE = 2
    { 45 29 } = 74
    { 79 29 } = 108
    { 79 45 } = 124
    { 13 29 } = 42
    { 13 45 } = 58
    { 13 79 } = 92
SIZE = 3
    { 79 45 29 } = 153
    { 13 45 29 } = 87
    { 13 79 29 } = 121
    { 13 79 45 } = 137
SIZE = 4
    { 13 79 45 29 } = 166
Subset with sum 1000000 was not found in the set.

因此,我们能够使用蛮力方法找到所需的子集。请注意,我们基本上是尝试找到解决方案的每种可能性。在下一节中,我们将对其进行一层优化。

步骤 2.b:优化我们的方法 - 回溯

显然,蛮力方法还有很多不足之处。在性能方面,它几乎是尽可能低效的。通过不加区分地检查每个可能的子集,我们在可以确定它们永远不会导致解决方案的点之后仍然考虑选项(例如,总和超过目标的子集)。为了改进我们的算法,我们可以利用回溯法来排除所有已经被保证无效的子问题的分支。

在尝试使用 DP 之前实现回溯解决方案的主要优势是,它要求我们确定问题的基本情况和中间递归状态。正如我们在本章前面定义的那样,基本情况是递归函数中的一个条件,它不依赖于进一步递归来产生答案。为了进一步澄清,考虑计算一个数字的阶乘的问题(一个数字n的阶乘等于n * (n-1) * (n-2) * (n-3) … * 1)。我们可以编写一个 C++函数来实现这个问题,如下所示:

int Factorial(int n)
{
    // Base case — stop recursing
    if(n == 1)
    {
        return 1;
    }
    // Recurse until base case is reached
    return n * Factorial(n - 1);
}

这个递归函数的结构可以用下面的方式来说明:

图 8.4:递归计算第 N 个阶乘

图 8.4:递归计算第 N 个阶乘

n = 1的条件是基本情况,因为这是可以在不进一步递归的情况下返回解决方案的点。

在子集和问题中,定义我们的基本情况的一种方式是:

If sum of a given subset is equal to target : TRUE

Otherwise:
    — If sum is greater than target : FALSE
    — If end of set is reached : FALSE

现在我们已经建立了基本情况,我们需要定义中间状态。使用我们的蛮力算法的输出作为参考,我们可以分析每个大小组的子集是如何形成的,以绘制出我们的状态转换:

Base case —> { } [SUM = 0]
{ } —> { 13 } [0 + 13 = 13]
       { 79 } [0 + 79 = 79]
       { 45 } [0 + 45 = 45]
       { 29 } [0 + 29 = 29]

当然,大小为0和大小为1的状态是最容易理解的。我们从一个空集开始,我们可以添加任何元素到它,以创建所有大小为 1 的子集。

{ 13 } —> { 13 79 } [13 + 79 = 92]
          { 13 45 } [13 + 45 = 58]
          { 13 29 } [13 + 29 = 42]
{ 79 } —> { 79 45 } [79 + 45 = 124]
          { 79 29 } [79 + 29 = 108]
{ 45 } —> { 45 29 } [45 + 29 = 74]

我们可以对大小为 2 的子集采用相同的逻辑。只需取每个大小为 1 的子集,并附加索引大于子集中已有的最高索引的每个元素。这本质上是我们在蛮力实现中采取的方法;然而,这一次,我们在处理它们时将考虑每个子集的总和,并在当前总和超过目标时终止它们。

图 8.5:消除超过目标值的值

图 8.5:消除超过目标值的值

target等于58时,我们可以看到不需要考虑大小为 3 或 4 的子集。因此,我们可以描述我们的中间状态转换如下:

for element of set at index i and subset ss:
    If sum of ss with set[i] is less than or equal to target: 
        1) Append set[i] to ss
        2) Increment i 
        Next state —> (i = i + 1, ss = ss ∪ set[i])
    In any case: 
        1) Do not append set[i] to ss
        2) Increment i
        Next state —> (i = i + 1, ss = ss)

现在,我们应该问以下问题:

  • 表示这种状态所需的最少数据是什么?

  • 我们如何重新构思前面的逻辑以去除不必要的信息?

考虑我们要解决的具体问题:找出是否存在一个子集的元素,其总和等于目标值。根据问题描述,我们的任务不需要产生实际的子集,而只需要它们的总和。因此,我们的伪代码可以更简洁地表示如下:

for element of set at index i and its sum as sum:
    If sum plus set[i] is less than or equal to target: 
        1) Add value of set[i] to sum
        2) Increment i 
        Next state —> (i = i + 1, sum = sum + set[i])
    In any case: 
        1) Do not add value of set[i] to sum
        2) Increment i
        Next state —> (i = i + 1, sum = sum)

使用这种新方法,我们基本上可以用两个整数sumi来表示每个状态转换,从而在最坏情况下消除了存储2**n子集数组的需要。此外,我们可以通过反转问题(即从target开始,并在每一步减去set[i])来消除跟踪目标值的需要。最后,我们可以在调用函数之前对集合进行排序,这样我们就可以在总和超过目标时确定没有其他有效可能性。我们将在接下来的练习中用 C++来实现这一点。

练习 37:使用回溯法解决子集和问题

在这个练习中,我们将解决一个类似于练习 36中演示的问题,即使用蛮力方法解决子集和问题,但是使用回溯方法和更复杂的输入来突出差异。让我们开始吧:

  1. 为了实现子集和问题的回溯解决方案,我们定义一个名为SubsetSum_Backtracking()的函数,如下所示:
bool SubsetSum_Backtracking(vector<int> &set, int sum, int i) 
{
    ……
}
  1. 在递归函数中经常这样,我们在一开始就定义了基本情况:
// The sum has been found
if(sum == 0)
{
    return true;
}
// End of set is reached, or sum would be exceeded beyond this point
if(i == set.size() || set[i] > sum)
{
    return false;
}
  1. 在每一步,我们的选择是将当前元素的值加到总和中,或者保持总和不变。我们可以将这个逻辑压缩成一行,如下所示:
// Case 1: Add to sum
// Case 2: Leave as-is 
return SubsetSum_Backtracking(set, sum – set[i], i + 1) 
    || SubsetSum_Backtracking(set, sum, i + 1); 
  1. 回到main,让我们对集合进行排序,并在调用SubsetSum_BruteForce()之后添加我们对SubsetSum_Backtracking()的调用:
sort(set.begin(), set.end());
bool found;

found = SubsetSum_BruteForce(set, target);
found = SubsetSum_Backtracking(set, target, 0); 
  1. 为了测试,我们将实现一个函数,它将显示每种方法找到解决方案所花费的时间。首先,我们需要包含<time.h><iomanip>头文件:
#include <iostream>
#include <vector>
#include <algorithm> 
#include <time.h>
#include <iomanip>
  1. 我们还将定义一个名为types的字符串数组,我们将用它来标记每种方法的结果:
vector<string> types = 
{
    "BRUTE FORCE",
    "BACKTRACKING",
    "MEMOIZATION",
    "TABULATION"
};
const int UNKNOWN = INT_MAX;
  1. 现在,我们将编写另一个函数GetTime(),它接受一个名为timerclock_t对象的引用和一个string类型,然后返回void
void GetTime(clock_t &timer, string type)
{
    // Subtract timer value from current time to get time elapsed
    timer = clock() - timer;
    // Display seconds elapsed
    cout << "TIME TAKEN USING " << type << ": " << fixed << setprecision(5) << (float)timer / CLOCKS_PER_SEC << endl; 

    timer = clock(); // Reset timer 
}
  1. 现在,让我们重写main()函数,以便我们可以依次执行每个函数调用并比较每种方法所花费的时间:
int main()
{
    vector<int> set = { 13, 79, 45, 29 };
    int target = 58;
    int tests = 2;
    clock timer = clock();
    sort(set.begin(), set.end());
    for(int i = 0; i < tests; i++)
    {
        bool found;
        switch(i)
        {
            case 0: found = SubsetSum_BruteForce(set, target); break;
            case 1: found = SubsetSum_Backtracking(set, target, 0); break;
        }
        if(found)
        {
            cout << "Subset with sum " << target << " was found in the set." << endl;
        }
        else 
        {
            cout << "Subset with sum " << target << " was not found in the set." << endl;
        }    
        GetTime(timer, types[i]);
        cout << endl;
    }
    return 0;
}
  1. 最后,让我们重新定义我们的输入,以突出两种方法之间效率的差异:
vector<int> set = { 16, 1058, 22, 13, 46, 55, 3, 92, 47, 7, 98, 367, 807, 106, 333, 85, 577, 9, 3059 };
int target = 6076;
  1. 您的输出将产生类似以下内容的东西:
Subset with sum 6076 was found in the set.
TIME TAKEN USING BRUTE FORCE: 0.89987
Subset with sum 6076 was found in the set.
TIME TAKEN USING BACKTRACKING: 0.00078

注意

实际的时间取值会根据您的系统而有所不同。请注意数值上的差异。

正如您所看到的,在这种特殊情况下,使用回溯方法找到答案要快 1000 多倍。在接下来的部分中,我们将通过利用缓存来进一步优化这个解决方案。

步骤 3:记忆化

虽然比蛮力方法好得多,但回溯解决方案仍然远非理想。考虑一个目标和集合中没有的情况-如果目标大于或等于集合中每个元素的总和,我们可以通过预先计算总和并检查目标是否在有效范围内来轻松确定结果。然而,如果目标总和略低于这个数额,我们的算法仍然需要在完成之前探索几乎每种可能性。

为了展示这种差异,尝试使用6799作为目标来运行上一个练习中的代码(恰好比集合中所有元素的总和少 1)。在作者的机器上,回溯解决方案平均花费大约 0.268 秒来产生结果-几乎比练习中使用的目标值所花费的平均时间长了近 350 倍。

幸运的是,我们已经拥有了所有需要设计自顶向下解决方案并利用记忆化的信息。更好的是,我们几乎不需要修改我们以前的方法来实现它!

设计缓存方案

使用记忆化最重要的方面是定义一个缓存方案。对于记忆化解决方案的缓存结果可以通过多种方式来完成,但最常见的方式如下:

  • 简单数组,状态由数字索引表示

  • 哈希表/映射,状态由使用内置语言特性散列的描述性字符串表示

  • 哈希表/映射,状态由使用原始哈希公式创建的哈希值表示

这里要做出的选择在很大程度上取决于上下文,但以下是一些一般性指导方针:

  • 通过数字索引访问的数组/向量通常比必须在映射中定位给定键以确定是否已经被缓存的映射要快得多。

  • 即使状态可以表示为整数,如果缓存键非常大,足以包含它们的数组的内存需求可能是不合理的。在这种情况下,映射是更好的选择。

  • 哈希表(例如std::unordered_map)在定位和检索键时往往比标准的映射/字典结构快得多(但仍然比数组慢)。

  • std::map在可以用作键的数据类型方面比std::unordered_map更加灵活。尽管std::unordered_map在技术上可以提供相同的功能,但它要求程序员为默认情况下无法存储为键的数据类型创建自己的哈希函数。

正如您可能还记得本章节介绍的那样,缓存方案应该是这样的:

  • 有效:缓存键必须以一种方式表示,以避免不用于解决相同子问题集的不同状态之间发生冲突。

  • 有价值/有用:如果您的缓存方案如此特定,以至于实际上从未产生任何“命中”,那么它基本上什么也没做。

在子集和问题中,我们可能错误地认为,从具有给定sum值的状态中找不到目标意味着从具有相同和的任何其他状态中都不可能得到真正的结果。因此,我们可能决定仅基于sum的值缓存每个解决方案(即if(memo[sum] != UNKNOWN) return memo[sum];)。这是一个无效的缓存方案的例子,因为它未考虑到在同一组内可能有多种达到相同和的方式,如下所示:

{ 1 5 6 2 3 9 } 
Sum of { 1 5 } = 6
Sum of { 6 } = 6
Sum of { 1 2 3 } = 6

假设在前面的例子中目标值为8。如果首先遇到第三种情况,memo[6]将被设置为false,这显然是不正确的,因为目标可以通过包括第 4 个元素(2)从其他两种情况中达到。

一个无用的记忆化方案的例子是,其中键等于子集的索引,因为每个可能的状态都将包含一个完全独特的键;因此,由相同子问题集形成的状态不会触发缓存命中。

如果您对自己的缓存方案的有效性不确定,可以有用的是存储一个在每次缓存命中时递增的计数器。如果这个计数器的最终值等于0,或者相对于您需要考虑的状态数量来说非常低,那么您可以得出结论,您的缓存方案需要修订。

我们将探讨使用向量进行缓存的记忆化实现。

练习 38:使用记忆化解决子集和问题

在这个练习中,我们将尝试实现与练习 37中实现的相同解决方案,即使用回溯法解决子集和问题,但增加了记忆化。让我们开始吧:

  1. 我们现在将创建另一个名为SubsetSum_Memoization()的函数。这个函数的定义将与SubsetSub_Backtracking()完全相同,只是它将包括对名为memo的二维整数向量的引用:
bool SubsetSum_Memoization(vector<int> &set, int sum, int         i, vector<vector<int>> &memo)
{
    ……
}
  1. 这个函数的大部分代码看起来与回溯法的方法非常相似。例如,我们的基本情况将与以前定义的完全相同:
if(sum == 0)
{
    return true;
}
if(i == set.size() || set[i] > sum)
{
    return false;
}
  1. 现在,关键的区别在于,在基本情况之后,我们不是立即调查下两种状态,而是检查memo表以获取缓存的结果:
// Is this state's solution cached?
if(memo[i][sum] == UNKNOWN)
{
    // If not, find the solution for this state and cache it
    bool append = SubsetSum_Memoization(set, sum - set[i], i + 1, memo);
    bool ignore = SubsetSum_Memoization(set, sum, i + 1, memo);
    memo[i][sum] = append || ignore;
}
// Return cached value
return memo[i][sum];
  1. 现在,我们应该在main()函数中插入对SubsetSum_Memoization()的调用:
int tests = 3;
for(int i = 0; i < tests; i++)
{
    bool found;
    switch(i)
    {
        case 0: found = SubsetSum_BruteForce(set, target); break;
        case 1: found = SubsetSum_Backtracking(set, target, 0); break;
        case 2:
        {
            // Initialize memoization table
            vector<vector<int>> memo(set.size(), vector<int>(7000, UNKNOWN));
            found = SubsetSum_Memoization(set, target, 0, memo);
            break;
        }
    }

    if(found)
    {
        cout << "Subset with sum " << target << " was found in the set." << endl;
    }
    else
    {
        cout << "Subset with sum " << target << " was not found in the set." << endl;
    }
    GetTime(timer, types[i]);
    cout << endl;
}
  1. 现在,让我们将target定义为6799并运行我们的代码。您应该看到类似于这样的输出:
Subset with sum 6799 was not found in the set.
TIME TAKEN USING BRUTE FORCE: 1.00100
Subset with sum 6799 was not found in the set.
TIME TAKEN USING BACKTRACKING: 0.26454
Subset with sum 6799 was not found in the set.
TIME TAKEN USING MEMOIZATION: 0.00127

注意

实际的时间值会根据您的系统而有所不同。请注意值的差异。

我们可以从输出中看到,缓存已经将我们的问题优化了指数倍。

第四步:制表

到目前为止,我们已经实现了三种不同的算法方法来解决子集和问题,每种方法都比前一种有了显著的改进。然而,假设我们想要得到给定集合中每个可能子集和的列表。我们将不得不针对每个和从 1 到整个集合的总和重复运行我们的算法。对于这类情况,表格化通常是唯一有效的选择。

实现迭代的表格化解决方案对于这样的问题通常很难概念化。虽然问题的递归公式很适合多维状态和分支条件,但表格化解决方案必须以某种方式将复杂性层次压缩成一组简单的迭代,使用标准的for/while循环:

图 8.6:展示了子集和问题递归结构的复杂性在表格化 DP 解决方案中是如何减少的

图 8.6:展示了子集和问题的递归结构在表格化 DP 解决方案中是如何减少的

有几种方法可以解决这个问题,但最终归根结底是你是否足够理解问题,能够做出正确的概括。

与记忆化一样,在定义了问题的基本情况和状态之后,第一个目标是开发一种用于存储不同状态解的方案。通常,表格化方法使用简单的数组/向量来实现这一目的。我们已经看过一个非常简单的 DP 表的例子,即斐波那契数列的计算:

F[n] = F[n – 1] + F[n – 2];

在本章的早些时候,我们还讨论了如何递归地计算阶乘。填充该问题的表格的自底向上方法将如下所示:

factorial[n] = factorial[n – 1] * n;

这些都是非常简单的例子,因为它们只包含一个维度和没有条件逻辑。每个状态从头到尾都有一个一致、可预测的公式。

这些例子与子集和问题之间的主要区别在于,后者中唯一表示每个状态的最小方式需要两个维度——集合中的索引和当前总和。

让我们更深入地考虑一些关于这个问题的见解:

  • 每个大小为k的可能子集都可以通过取新元素并将其附加到每个大小为k-1的子集上来形成。

  • 如果在索引i处找到了和值为x的解,那么最终导致相同条件的任何状态转换序列都会产生相同的结果:

图 8.7:相同索引值上具有相同和值的多条路径

图 8.7:相同索引值上具有相同和值的多条路径

这两条递归路径都在红色标记的状态处具有和值等于8和索引值等于3,由于子集和问题的最优子结构,这意味着该状态的解只需要找到一次——无论之前发生了什么,每次到达这些条件时,其结果都将是相同的。

有了这些事实,我们基本上可以颠倒我们的自顶向下方法,来发展自底向上的方法。

自顶向下的逻辑:

  1. 从目标总和和集合的第一个索引开始。

  2. 遍历集合:

  • 如果总和减少到零,则结果为TRUE

  • 如果到达集合的末尾或超过目标,则结果为FALSE

  • 否则,您可以从总和中减去当前值或忽略它。

  1. 如果可以从状态S找到目标,其中总和等于x,索引等于i,那么也可以从任何最终导致状态S的较早状态找到目标。

自底向上的逻辑:

  1. 从和值和索引值等于0开始。

  2. 遍历集合:

  • 如果在索引0i之间找到和为x的总和,则在索引0i+1之间也可以找到和为x的总和。

  • 如果可以在索引0i之间找到总和等于x,则可以在索引0i+1之间找到总和等于x + set[i]

就填充表的方式而言,自顶向下的方法可以描述如下:

如果总和等于x且索引等于i在状态 S1 中,如果发生以下情况之一,则memo(i, x) = true

  • 目标可以从状态 S2 中找到(其中总和等于x – set[i]且索引等于i + 1),或者…

  • 目标可以从状态 S3 中找到(其中总和等于x且索引等于i + 1

  • 否则,memo(i, x) = false

这个逻辑的自底向上版本如下:

如果总和等于x且索引等于i,则如果发生以下情况之一,则DP(i, x) = true

  • x小于set[i]的值且DP(i-1, x) = true

  • x大于或等于set[i]的值且DP(i-1, sum) = true OR DP(i-1, sum – set[i]) = true

  • 否则,DP(i, x) = false

换句话说,如果我们已经确定了可以在索引0i(包括)之间形成总和x,那么很明显,可以在索引0i + 1之间形成总和等于xx + set[i]。我们将在下一个练习中看一下这个实现。

练习 39:使用表格法解决子集和问题

在这个练习中,我们将修改练习 38的解决方案,即使用备忘录解决子集和问题,以便我们可以通过将逻辑从自顶向下转换为自底向上来使用表格化。让我们开始吧:

  1. 我们将定义一个名为SubsetSum_Tabulation()的新函数,该函数以整数向量set作为参数并返回一个二维布尔向量:
vector<vector<bool>> SubsetSum_Tabulation(vector<int> set)
{
    ……
}
  1. 我们声明一个名为DP的二维布尔向量。第一维的大小应该等于set的长度,第二维的大小应该等于集合中可能的最高子集和(即所有元素的总和)加一。DP 的每个值都应初始化为false,除了基本情况(即总和等于零):
int maxSum = 0;
for(auto num : set) 
{
    maxSum += num;
}
vector<vector<bool>> DP(set.size() + 1, vector<bool>(maxSum + 1, false));
for(int i = 0; i < set.size(); i++)
{
    // Base case — a subset sum of 0 can be found at any index
    DP[i][0] = true;
}
  1. 现在,我们遍历两个嵌套的for循环,对应于DP表的第一维和第二维:
for(int i = 1; i <= set.size(); i++)
{
    for(int sum = 1; sum <= maxSum; sum++)
    {
        ……
    }
}
  1. 现在,使用以下代码填充表:
for(int i = 1; i <= set.size(); i++)
{
    for(int sum = 1; sum <= maxSum; sum++)
    {
        if(sum < set[i-1])
        {
            DP[i][sum] = DP[i-1][sum];
        }
        else
        {
            DP[i][sum] = DP[i-1][sum]
                    || DP[i-1][sum – set[i-1]];
        }
    }
}
return DP;
  1. 现在,我们再次修改main()函数以包括我们的表格化解决方案:
int main()
{
    vector<int> set = { 16, 1058, 22, 13, 46, 55, 3, 92, 47, 7, 98, 367, 807, 106, 333, 85, 577, 9, 3059 };
    int target = 6076
    int tests = 4;
    clock_t timer = clock();
    sort(set.begin(), set.end());
    for(int i = 0; i < tests; i++)
    {
        bool found;
        switch(i)
        {
            ……
            case 3:
            {
                vector<vector<bool>> DP = SubsetSum_Tabulation(set);
                found = DP[set.size()][target];
                break;
            }
        }
    }
    ……
}
  1. 您应该看到类似于这里显示的输出:
Subset with sum 6076 was found in the set.
TIME TAKEN USING BRUTE FORCE: 0.95602
Subset with sum 6076 was found in the set.
TIME TAKEN USING BACKTRACKING: 0.00082
Subset with sum 6076 was found in the set.
TIME TAKEN USING MEMOIZATION: 0.00058
Subset with sum 6076 was found in the set.
TIME TAKEN USING TABULATION: 0.00605

注意

实际的时间值将根据您的系统而有所不同。请注意值的差异。

  1. 正如我们所看到的,表格化解决方案所花费的时间比备忘录和回溯解决方案都要长。然而,使用SubsetSum_Tabulation()返回的 DP 表,我们可以使用以下代码找到每个可能的子集和:
int total = 0;
for(auto num : set) 
{
    total += num;
}
vector<vector<bool>> DP = SubsetSum_Tabulation(set);
vector<int> subsetSums;
for(int sum = 1; sum <= total; sum++)
{
    if(DP[set.size()][sum])
    {
        subsetSums.push_back(sum);
    }
}
cout << "The set contains the following " << subsetSums.size() << " subset sums: ";
for(auto sum : subsetSums) 
{
    cout << sum << " ";
}
cout << endl; 
  1. 这个输出应该以这样开始和结束:
The set contains the following 6760 subset sums: 3 7 9 10 12 13 16 19 20 22 …… 6790 6791 6793 6797 6800

因此,我们已经优化了解决方案,并且还获得了所有状态的总和值。

在本章中,我们探讨了解决子集和问题的各种方法,这反过来证明了动态规划方法的明显优势;然而,尽管 DP 解决方案相对于其他方法具有比较优势,我们还演示了如何使用朴素和相对低效的方法来更好地理解问题,这极大地简化了使用 DP 设计解决方案的过程。

动态规划解决方案所需的一些逻辑可能最初看起来相当复杂且难以理解。强烈建议在继续之前充分理解我们在本节讨论的每种解决方案方法,因为这是一个可以通过使用不同的输入参数并比较结果来加速的过程。此外,绘制如何从给定输入形成不同解决方案的图表可能特别有帮助。

活动 18:旅行行程

您正在为一家旅行社设计一个网络应用程序,帮助客户规划他们的假期行程。这个软件的一个主要方面是路线规划,允许用户指定他们想要访问的多个位置,然后查看他们在最终目的地前经过的城市列表。

您的旅行社与每个主要城市的特定交通公司有合同,并且每家交通公司都对他们可以行驶的距离设定了限制。而飞机或火车可以穿越多个城市,甚至整个国家,但公共汽车或出租车服务可能只愿意在初始位置之外行驶一两个城市。当您的软件生成可能的中间停靠点列表时,它还会显示交通公司在该位置愿意行驶的最大城市数量,以便客户可以相应地规划他们的行程。

您最近意识到您的应用程序需要一些方法,允许客户过滤呈现给他们的选项数量,因为许多热门旅游地点之间被密集的城镇分隔。为此,您希望确定从给定起始位置到最终目的地的可能方式的总数,以便在信息过多时减少显示的信息量。

您的应用程序已经具备计算出理想路线上的位置列表的能力。基于此,您得出了以下数据:

  • N:表示起点和目的地之间的城市数量的整数

  • distance:表示每个位置的交通公司愿意穿越的最大城市数量的整数数组

您的任务是实现一个算法,计算通过一系列中间位置旅行到达目的地的可能方式的总数。

输入

第一行包含一个整数N,表示起点和目的地之间的城市数量。

第二行包含N个空格分隔的整数,其中每个整数 di 表示从索引i的城市出发可以行驶的最大距离。

输出

您的程序应输出一个整数和从索引0开始到索引N结束的穿越城市的总方式数。因为随着N的增加,值会变得非常大,所以请将每个结果输出为模 1000000007

示例

假设您获得了以下输入:

6
1 2 3 2 2 1

这意味着在起点和目标位置之间总共有六个城市。从索引i的给定城市,您可以选择在i + 1i + distance[i](包括)的范围内前往任何其他城市。如果我们将城市序列视为图形,那么上面例子的相邻城市将如下所示:

[0]: { 1 }
[1]: { 2, 3 }
[2]: { 3, 4, 5 }
[3]: { 4, 5 }
[4]: { 5, 6 }
[5]: { 6 }  

请参考以下图表以获得进一步的澄清:

图 8.8:城市相邻示例

图 8.8:城市相邻示例

在上面的例子中,可以通过以下方式到达目的地(其中E表示终点):

0 > 1 > 2 > 3 > 4 > 5 > E
0 > 1 > 2 > 3 > 4 > E
0 > 1 > 2 > 3 > 5 > E
0 > 1 > 2 > 4 > 5 > E
0 > 1 > 3 > 4 > 5 > E
0 > 1 > 2 > 4 > E
0 > 1 > 2 > 5 > E
0 > 1 > 3 > 4 > E
0 > 1 > 3 > 5 > E

这给我们一个答案为9

一般来说,遍历总是从索引0开始,结束于索引N。可以保证城市索引idistance[i]的和永远不会大于N,并且每个城市都将具有至少1的对应距离值。

测试案例

以下测试案例应该帮助您更好地理解这个问题:

图 8.9:活动 18 简单测试案例

图 8.9:活动 18 简单测试案例

以下是一些更复杂的测试案例:

图 8.10:活动 18 复杂测试案例

图 8.10:活动 18 复杂测试案例

额外学分

假设您已找到了一个在合理时间限制内通过了前面的测试用例的方法,您可以使用一个最终的测试用例来真正测试算法的效率,其中N等于10000000。因为值的数量太多,打印出来会占用太多空间,您可以使用以下代码来以编程方式生成数组值:

vector<int> Generate(int n)
{
    vector<int> A(n);

    ULL val = 1;

    for(int i = 0; i < n; i++)
    {
        val = (val * 1103515245 + 12345) / 65536;
        val %= 32768;

        A[i] = ((val % 10000) % (n – i)) + 1;
    }
    return A;
}

您的程序应该打印出318948158作为这个测试用例的结果。一个最佳算法应该能够在一秒内找到结果。

活动指南

  • 最佳方法将在O(n)时间内运行,并且需要确切的n次迭代。

  • 如果您完全不确定如何制定 DP 解决方案,请使用本章中描述的增量方法,即首先使用蛮力,然后逐渐优化解决方案。

  • 要了解问题的状态是如何形成的,可以考虑斐波那契数列所展示的递推关系。

注意

此活动的解决方案可以在第 556 页找到。

字符串和序列上的动态规划

到目前为止,我们对动态规划的探索主要集中在组合问题和计算具有定义公式的整数序列的项上。现在,我们将考虑 DP 的另一个最常见用途之一,即处理数据序列中的模式。程序员通常会使用 DP 来搜索、比较和构建字符串的最典型情况通常涉及到这个目的。

作为软件开发人员,我们经常与几个人合作,他们都有能力对同一个项目进行贡献和修改。由于程序员可能会无意中引入代码错误,或者团队可能尝试不同的方法来实现某个功能,然后决定返回到原始方法,因此拥有某种版本控制系统变得非常重要。如果最近工作正常的功能突然出现故障,那么有能力查看对代码所做的更改是至关重要的,特别是在它们与早期版本的不同之处。因此,所有版本控制系统都有一个“差异”功能,它分析同一代码的两个版本之间的相似性,然后以某种方式向用户显示这一点。

例如,假设您已将以下代码添加到存储库中:

bool doSomething = true;
void DoStuff()
{
    DoSomething();
    DoSomethingElse();
    DoAnotherThing();
}

第二天,您做了一些更改:

bool doSomething = false;
void DoStuff()
{
    if(doSomething == true)
    { 
        DoSomething();
    }
    else 
    {
        DoSomethingElse();
    }
}

然后,差异实用程序会显示类似于以下内容:

图 8.11:差异实用程序输出

图 8.11:差异实用程序输出

为了实现这一点,实用程序需要计算两个代码文件的相似性,考虑到两个版本中共同的文本序列可能不一定在字符串中是连续的。此外,原始文本的部分可能已被删除或出现在新版本的其他位置。这展示了近似(或模糊字符串匹配的需求,这种技术通常使用动态规划。

最长公共子序列问题

最长公共子序列问题(通常缩写为LCS)是动态规划最著名的经典例子之一。它回答了以下问题:给定两个数据序列,它们的最长公共子序列是什么?

例如,考虑两个字符串,AB

图 8.12:用于查找最长公共子序列的两个给定字符串

图 8.12:用于查找最长公共子序列的两个给定字符串

最长公共子序列将是"LONGEST":

图 8.13:给定字符串中的最长公共子序列

图 8.13:给定字符串中的最长公共子序列

有了我们从子集和问题实现的一系列方法中获得的见解,让我们对如何提前攻击这个问题的结构有一些聪明的想法。我们将从基本情况开始制定一些关于问题结构的想法。

由于很难理解大输入的 DP 问题的性质,而没有先考虑微不足道的问题,让我们使用小输入字符串创建一些不同情景的例子,并尝试找到最长公共子序列(LCS)的长度:

Case 1): A or B is empty
A   = ""
B   = ""
LCS = 0
A   = "A"
B   = ""
LCS = 0
A   = ""
B   = "PNEUMONOULTRAMICROSCOPICSILICOVOLCANOCONIOSIS"
LCS = 0

在其中一个或两个字符串为空的情况下,很明显最长公共子序列的长度总是等于零:

Case 2) Both A and B contain a single character
A   = "A"
B   = "A"
LCS = 1
A   = "A"
B   = "B"
LCS = 0
Case 3) A has one character, B has two characters
A   = "A"
B   = "AB"
LCS = 1
A   = "A"
B   = "BB"
LCS = 0

这两种情况有一个简单的二进制定义 - 要么它们有一个共同的字符,要么没有:

Case 4) Both A and B contain two characters
A:  = "AA"
B:  = "AA"
LCS = 2
A   = "BA"
B   = "AB"
LCS = 1
A   = "AA"
B   = "BB"
LCS = 0

对于长度为 2 的字符串,情况变得更有趣,但逻辑仍然相当简单。给定长度为 2 的两个字符串,它们要么相同,要么有一个共同的字符,要么没有共同的字符:

Case 5) A and B both contain 3 characters
A   = "ABA"
B   = "AAB"
LCS = 2    
A   = "ABC"
B   = "BZC"
LCS = 2

现在,问题的复杂性开始显现。这种情况表明,比较逐渐变得更加不那么直接:

Case 6: A and B both contain 4 characters
A   = AAAB
B   = AAAA
{ "AAA_", "AAA_" }
{ "AAA_", "AA_A" }
{ "AAA_", "A_AA" }
{ "AAA_", "_AAA" }
LCS = 3
A   = AZYB
B   = YZBA    
{ "_Z_B", "_ZB_" }
{ "__YB", "Y_B_" }
LCS = 2

到现在为止,很明显 LCS 问题确实包含有重叠子问题。与之前的问题类似,我们可以观察到给定字符串有 2n 个可能的子集,其中n等于字符串的长度,只是现在我们有两个序列要处理。更糟糕的是,我们不仅仅考虑每个序列的子集,还必须在它们之间进行比较:

图 8.14:两个字符串 ABCX 和 ACY 的所有可能字符子序列

图 8.14:两个字符串 ABCX 和 ACY 的所有可能字符子序列

事实上,我们不仅仅是在寻找连续的字符组,这有一些含义:首先,相同的字符序列可以在整个字符串中多次出现,并且可以跨越任一字符串以任何可能的排列方式分布,假设字符的顺序是相同的。其次,从任一给定索引开始,可能有许多共同的子序列。

在实施我们的蛮力方法之前,让我们也定义一下这个问题的状态。假设我们维护两个指针,ij,它们分别表示AB中的字符索引,以及我们找到的共同字符的子序列的记录:

if i exceeds length of A, or j exceeds length of B:
— Terminate recursion and return length of subsequence

如果我们已经到达了任一字符串的末尾,那么没有其他可比较的了,因为子序列的索引是有序的:

if A[i] = B[j]:
— Increase length of subsequence by 1
— Increment both i and j by 1 

如果字符相等,将其包含在我们找到的子序列中并没有优势。我们增加两个指针,因为任何给定字符在子序列中只能考虑一次:

Otherwise:
    Option 1) Explore further possibilities with i + 1, and j
    Option 2) Explore further possibilities with i, and j + 1
    LCS from this state is equal to maximum value of Option 1 and Option 2

如果我们没有找到匹配,我们可以选择要么探索 A 的字符的下一个子集,要么探索 B 的字符的下一个子集。我们不包括同时从这个状态递增两个索引的情况,因为那样会是多余的。这种情况将在下一个函数调用中探索。这个递归的结构如下所示:

图 8.15:最长子序列问题的子问题树

图 8.15:最长子序列问题的子问题树

在前面的图中,重叠子问题已经被着色。这个问题的最优子结构还不太清楚,但我们仍然可以做一些基本的概括:

  • 我们只需要比较相等长度的子集。

  • 从给定的状态开始,可以通过增加ij或两者来探索下一个状态的可能性。

  • 我们的搜索总是在到达任一字符串的末尾时结束。

希望我们的初步蛮力实现可以提供额外的见解。让我们在下一个练习中立即开始。

练习 40:使用蛮力方法找到最长公共子序列

在这个练习中,我们将使用蛮力方法来解决这个问题,就像我们在练习 36中解决子集和问题时所做的那样,使用蛮力方法。让我们开始吧:

  1. 首先包括以下头文件,并定义我们在上一章中使用的DEBUGPRINT宏:
#include <iostream>
#include <time.h>
#include <iomanip>
#include <algorithm>
#include <utility>
#include <vector>
#include <strings.h>
#define DEBUG 1
#if DEBUG
#define PRINT(x) cerr << x
#else 
#define PRINT(x)
#endif
using namespace std;
  1. 定义一个名为LCS_BruteForce()的函数,该函数接受以下参数 - 两个字符串AB,两个整数ij,以及一个整数对的向量subsequence - 并返回一个整数。在这个函数之上,我们还将声明一个具有全局范围的二维整数对向量,即found
vector<vector<pair<int, int>>> found;
int LCS_BruteForce(string A, string B, int i, int j, vector<pair<int, int>> subsequence)
{
    ……
}
  1. AB当然是我们要比较的字符串,ij分别表示我们在AB中的当前位置,subsequence是形成每个公共子序列的索引对的集合,它将在found中收集以进行输出。

由于我们已经有了伪代码可以使用,我们可以通过简单地将伪代码的每一行插入到我们的函数中作为注释,并在其下面将其翻译成 C++代码来相对容易地实现我们的函数:

// If i exceeds length of A, or j exceeds length of B:
if(i >= A.size() || j >= B.size())
{
    found.push_back(subsequence);
    //Terminate recursion and return length of subsequence
    return subsequence.size();
}
// if A[i] = B[j]:
if(A[i] == B[j])
{
    // Increase length of subsequence by 1
    subsequence.push_back({ i, j });
    // Increment both i and j by 1 
    return LCS_BruteForce(A, B, i + 1, j + 1, subsequence);
}    
/*
    Option 1) Explore further possibilities with i + 1, and j        
    Option 2) Explore further possibilities with i, and j + 1
    LCS from this state is equal to maximum value of Option 1 and Option 2
*/
return max(LCS_BruteForce(A, B, i + 1, j, subsequence),
         LCS_BruteForce(A, B, i, j + 1, subsequence));
  1. main()中,我们将以两个字符串的形式接收输入,然后调用我们的函数:
int main() 
{
    string A, B;
    cin >> A >> B;
    int LCS = LCS_BruteForce(A, B, 0, 0, {}); 
    cout << "Length of the longest common subsequence of " << A << " and " << B << " is: " << LCS << endl;
    …    
}
  1. 就像我们在上一章中所做的那样,如果DEBUG没有设置为0,我们还将输出找到的子序列到stderr。然而,由于这个问题的复杂性更大,我们将把这个输出放在一个单独的函数PrintSubsequences()中:
void PrintSubsequences(string A, string B)
{
    // Lambda function for custom sorting logic
    sort(found.begin(), found.end(), [](auto a, auto b)
    {
        // First sort subsequences by length
        if(a.size() != b.size())
        {
            return a.size() < b.size();
        }
        // Sort subsequences of same size by lexicographical order of index
        return a < b;
    });
    // Remove duplicates 
    found.erase(unique(found.begin(), found.end()), found.end());
    int previousSize = 0;
    for(auto subsequence : found)
    {
        if(subsequence.size() != previousSize)
        {
            previousSize = subsequence.size();
            PRINT("SIZE = " << previousSize << endl);
        }
        // Fill with underscores as placeholder characters
        string a_seq(A.size(), '_');
        string b_seq(B.size(), '_');
        for(auto pair : subsequence)
        {
            // Fill in the blanks with the characters of each string that are part of the subsequence
            a_seq[pair.first] = A[pair.first];
            b_seq[pair.second] = B[pair.second];
        }
        PRINT("\t" << a_seq << " | " << b_seq << endl);
    }
}
  1. 然后我们可以在main()中调用这个函数,指定只有在DEBUG设置为1时才应该被忽略:
int main()
{
    ……
#if DEBUG
    PrintSubsequences();
#endif
    return 0;
}
  1. DEBUG设置为1,并使用ABCXACYXB作为输入应该产生以下输出:
Length of the longest common subsequence of ABCX and ACYXB is: 3
SIZE = 1
    A___ A____
SIZE = 2
    AB__ A___B
    A_C_ AC___
    A__X A__X_
SIZE = 3
    A_CX AC_X_

这个输出显示了所有可能的子序列对的组合。让我们在下一节中分析这个输出,并努力优化我们的解决方案。

优化的第一步 - 寻找最优子结构

让我们再次回顾我们先前的方法的逻辑,看看如何进行优化。使用上一个练习中的输入字符串ABCXACYXB,如果我们当前的状态是i = 0j = 0,我们可以清楚地看到我们下一个状态的唯一可能性如下:

LCS(A, B, 0, 0) = 1 + LCS(A, B, 1, 1)

正如您可能记得的那样,我们最初的一个见解是,如果一个或两个字符串为空,LCS 等于0。我们还可以推广,A的给定前缀和B的给定前缀的 LCS 等于 A 的前缀减少一个字符与B的最大 LCS,以及B的前缀减少一个字符与A的最大 LCS:

A = "ABC"
B = "AXB"
LCS of "ABC", "AXB" 
= max(LCS of "AB" and "AXB", LCS of "ABC" and "AX") 
= LCS of "AB" and "AXB"
= "AB"

利用两个字符串的 LCS 基于它们的前缀的 LCS 的概念,我们可以重新定义我们的逻辑如下:

If prefix for either string is empty:
   LCS = 0
Otherwise:
   If character in last position of A's prefix is equal to character in last position of B's prefix:
         LCS is equal to 1 + LCS of prefix of A with last character removed and prefix of B with last character removed
   Otherwise:
          LCS is equal to maximum of:
            1) LCS of A's current prefix and B's prefix with last character removed 
            2) LCS of B's current prefix and A's prefix with last character removed 

使用记忆化,我们可以在一个二维表中的每一步存储我们的结果,第一维等于A的大小,第二维等于B的大小。假设我们还没有达到基本情况,我们可以检查我们是否在memo[i - 1][j - 1]中存储了一个缓存的结果。如果有,我们返回结果;如果没有,我们以与之前相同的方式递归地探索可能性,并相应地存储结果。我们将在下一个活动中实现这一点。

活动 19:使用记忆化找到最长公共子序列

在解决子集和问题时,我们实现了各种方法,包括蛮力、回溯、记忆化和表格法。在这个活动中,您的任务是独立使用记忆化来实现最长公共子序列问题的解决方案。

输入

两个字符串AB

输出

AB的最长公共子序列的长度。

测试案例

以下测试案例应该帮助您更好地理解这个问题:

图 8.16:活动 19 测试案例

图 8.16:活动 19 测试案例

活动指南:

  • 您可以用两个维度表示状态,第一个维度受A的长度限制,第二个维度受B的长度限制。

  • 几乎没有什么需要改变来将蛮力算法转换为记忆化算法。

  • 确保您的方法有办法区分已经被缓存和尚未被缓存的子问题。

注意

这个活动的解决方案可以在第 563 页找到。

从自顶向下到自底向上——将记忆化方法转换为表格法

如果我们打印出对字符串ABCABDBEFBAABCBEFBEAB的记忆表的值,它会是这样的(请注意,值为-1的是未知的):

图 8.17:ABCABDBEFBA 和 ABCBEFBE 的记忆表

图 8.17:ABCABDBEFBA 和 ABCBEFBE 的记忆表

查找任何字符相等的行/列组合(比如第 7 行和第 7 列),我们注意到一个模式:memo[i][j]的值等于memo[i - 1][j - 1] + 1

现在,让我们看看另一种情况(即字符不相等);我们看到的模式是memo[i][j]等于memo[i - 1][j]memo[i][j - 1]的最大值。

假设我们已经找到了问题的最优子结构,使用记忆化解决方案形成解决方案通常是一个非常简单的任务,只需简单地采用由记忆化解决方案产生的表,并设计一种方案从底部构建它。我们需要以稍微不同的方式制定一些逻辑,但总体思路基本相同。需要处理的第一个不同之处是记忆表的值被初始化为UNKNOWN-1)。记住,表格解决方案将用适当的结果填充整个表,因此在算法完成时不应该有任何未知的值。

让我们来看看第二行和第三列的未知值;这个值应该等于多少?假设我们在那一点考虑的前缀是AB_________ABC_______,很明显,在这一点上 LCS 的值等于2。现在,让我们考虑第 10 行和第 9 列的未知值:我们在这一点考虑的前缀是ABCABDBEFB_ABCBEFBEA_,在这一点找到的 LCS 是ABC_B__EFB_ —> ABCBEFB___,长度为七个字符。我们可以逻辑推断,在给定状态下 LCS 的值要么等于先前找到的 LCS,要么比先前找到的 LCS 多一个,如果字符相等的话。当然,最低可能的 LCS 值应该等于 0。因此,我们迭代地填充 DP 表的逻辑看起来应该是这样的:

If i = 0 or j = 0 (empty prefix):
  LCS(i, j) = 0
Otherwise:
  If the last characters of both prefixes are equal:
    LCS(i, j) = LCS(i - 1, j - 1) + 1
  Otherwise:
    LCS(i, j) = Maximum of:
        LCS(i - 1, j)  LCS for A's current prefix and B's prefix with the last character removed 
        LCS(i, j - 1)  LCS for B's current prefix and A's prefix with the last character removed

我们的逻辑本质上与记忆化解决方案相同,只是我们不是递归地找到未探索状态的值来填充表中当前状态的值,而是首先填充这些状态的值,然后根据需要简单地重用它们。我们将在以下活动中将这种逻辑转化为代码。

活动 20:使用表格法找到最长公共子序列

在这个活动中,您的任务是使用表格法实现最长公共子序列问题的自底向上解决方案。

输入

两个字符串,AB

输出

AB的最长公共子序列的长度。

额外学分

除了 LCS 的长度之外,还输出它包含的实际字符。

测试用例

以下测试用例应该有助于您更好地理解这个问题:

图 8.18:活动 20 测试用例

图 8.18:活动 20 测试用例

活动指南

  • 与子集和问题一样,表格解决方案需要迭代两个嵌套的for循环。

  • 对于给定状态LCS(I, j),有三种可能需要处理——要么字符串的前缀为空,要么AB的前缀的最后一个字符相等,要么AB的前缀的最后一个字符不相等。

  • 通过回溯 DP 表格可以找到 LCS 的字符。

这个活动的解决方案可以在 568 页找到。

活动 21:旋律排列

这个活动是基于传统的西方 8 音符均匀音阶,尽管学生不需要了解任何音乐理论来进行这个活动。所有关于音乐方面的必要信息都在这里提供了。

音乐集合理论是一种根据音符的间隔关系对音乐和旋律进行分类的形式。在音乐术语中,间隔可以被定义为音符在音乐符号中的相对位置之间的距离:

图 8.19:音乐符号

图 8.19:音乐符号

下图展示了用音乐符号表示不同音符之间的距离:

图 8.20:音乐间隔

图 8.20:音乐间隔

你是一个对各种作曲家的旋律中特定音符集的排列出现了多少次感到好奇的音乐理论家。给定完整旋律的音符和一组音符,计算音符集的任何排列在旋律中出现的次数。对于任何有效的排列,音符可以重复任意次数,并且可以以任何顺序出现:

               0    1    2    3    4    5   6
Melody:     { "A", "B", "C", "C", "E", "C, "A" }
Note set:     { "A", "C", "E" }
Subsets:
    { 0, 2, 4 }    —>    { "A", "C", "E" }
    { 0, 3, 4 }    —>    { "A", "C", "E" }
    { 0, 4, 5 }    —>    { "A", "E", "C" }
    { 2, 4, 6 }    —>    { "C", "E", "A" }
    { 3, 4, 6 }    —>    { "C", "E", "A" }
    { 4, 5, 6 }    —>    { "E", "C", "A" }

    { 0, 2, 3, 4 }    —>    { "A", "C", "C", "E" }
    { 0, 2, 4, 5 }    —>    { "A", "C", "E", "C" }
    { 0, 2, 4, 6 }    —>    { "A", "C", "E", "A" }
    { 0, 3, 4, 5 }    —>    { "A", "C", "E", "C" }
    { 0, 3, 4, 6 }    —>    { "A", "C", "E", "A" }
    { 0, 4, 5, 6 }    —>    { "A", "E", "C", "A" }  
    { 2, 3, 4, 6 }    —>    { "C", "C", "E", "A" }
    { 2, 4, 5, 6 }    —>    { "C", "E", "C", "A" }
    { 3, 4, 5, 6 }    —>    { "C", "E", "C", "A" }
    { 0, 2, 3, 4, 5 }       —>    { "A", "C", "C", "E", "C" }
    { 0, 2, 3, 4, 6 }       —>    { "A", "C", "C", "E", "A" }
    { 0, 2, 4, 5, 6 }       —>    { "A", "C", "E", "C", "A" }
    { 0, 3, 4, 5, 6 }       —>    { "A", "C", "E", "C", "A" }
    { 2, 3, 4, 5, 6 }       —>    { "C", "C", "E", "C", "A" }

    { 0, 2, 3, 4, 5, 6 }    —>    { "A", "C", "C", "E", "C", "A" }
Total Permutations = 21

下面的注释被描述为同音异名,应被视为相同的:

C  — B# (B# is pronounced as "B sharp")
C# — Db (Db is pronounced as "D flat")
D# — Eb
E  — Fb
E# — F
F# — Gb
G# — Ab
A# — Bb
B  — Cb

下图说明了钢琴上的这种等价关系:

图 8.21:钢琴上表示的同音异名音符

图 8.21:钢琴上表示的同音异名音符

因此,以下音符组合将被视为等价的:

{ A#, B#, C# }   = { Bb, C, Db },
{ Fb, Db, Eb }   = { E, C#, D# },
{ C, B#, E#, F } = { C, C, F, F }
And so on…

以下是一些示例输入和相应的输出:

输入:

Melody:    { "A", "B", "C", "C", "E", "C, "A" }
Note Set:    { "A", "C", "E" }

输出:21

输入:

Melody:    { "A", "B", "D", "C#", "E", "A", "F#", "B", "C", "C#", "D", "E" }
Note Set:    { "B", "D", "F#", "E" }

输出:27

输入:

Melody:    { "Bb", "Db", "Ab", "G", "Fb", "Eb", "G", "G", "Ab", "A", "Bb", "Cb", "Gb", "G", "E", "A", "G#" }
Note Set:    { "Ab", "E", "G" }

输出:315

输入:

Melody:    { "C", "C#", "D", "Bb", "E#", "F#", "D", "C#", "A#", "B#", "C#", "Eb", "Gb", "A", "A#", "Db", "B", "D#" }
Note Set:    { "Bb", "C#", "D#", "B#" }

输出:945

输入:

Melody:    { "A#", "B", "D#", "F#", "Bb", "A", "C", "C#", "Db", "Fb", "G#", "D", "Gb", "B", "Ab", "G", "C", "Ab", "F", "F#", "E#", "G", "Db" }
Note Set:    { "A", "Db", "Gb", "A#", "B", "F#", "E#" }

输出:1323

这个活动的指导方针如下:

  • 实际上,你解决这个问题并不需要了解音乐理论之外的东西,除了描述中解释的内容。

  • 有没有更好的方法来表示这些音符?它们能否被转换成更适合表格 DP 解决方案的格式?

  • n元素的子集的总数是多少?这个信息对解决这个问题有用吗?

这个活动的解决方案可以在 574 页找到。

总结

在本章中,我们分析并实施了动态规划的两个典型例子,并学习了几种不同 DP 问题可能被解决的方法。我们还学习了如何识别可以用 DP 解决的问题的特征,DP 算法应该如何在概念上考虑,以及状态、基本情况和递归关系的概念如何被用来将一个复杂的问题分解成更简单的组成部分。

我们只是刚刚触及了动态规划技术的表面。事实上,我们深入探讨的两个问题在概念上和解决方案的实现方式上实际上是非常相似的。然而,这些相似之处中的许多都用来展示几乎每个 DP 问题中遇到的一些共同点,因此,它们作为对一个明显相当复杂和难以掌握的主题的绝佳介绍。

使用动态规划是一种技能,你不太可能仅通过阅读或观察来提高。真正提高这种技术的唯一方法是尽可能多地解决问题,最好是不受指导地解决。起初,某些困难的 DP 问题可能需要多次尝试才能找到最佳解决方案,但通过这种常常艰难的过程获得的经验,可能比你仅仅通过研究任意数量的 DP 问题的解决方案所获得的要大得多。

本章展示的解决 DP 问题的渐进方法在未来会对你有所帮助,但这绝不是到达最终解决方案的唯一方法。在解决了许多 DP 问题之后,你无疑会开始注意到某些模式,这将使得从一开始就能设计出表格化的解决方案成为可能。然而,这些模式可能直到你遇到各种不同的 DP 问题之后才会被发现。请记住,使用 DP,就像任何具有挑战性的技能一样,持续的练习会让它变得更容易,而且不久之后,最初看起来极其艰巨的事情最终会变得非常容易处理,甚至相当有趣!

在最后一章中,我们将学习如何将动态规划应用于更高级的情况,并深入了解一开始看起来完全不同的 DP 问题通常只是相同概念的变体。最后,我们将通过重新讨论图的主题来结束这本书,以展示 DP 范式如何有效地应用于最短路径问题。

第九章:动态规划 II

学习目标

通过本章结束时,你将能够:

  • 描述如何在多项式与非确定性多项式时间内解决问题,以及这对我们开发高效算法的影响

  • 实现 0-1 和无界变体的背包问题的解决方案

  • 将状态空间缩减的概念应用于动态规划问题

  • 使用动态规划范式优化的方法确定加权图中的每条最短路径

在本章中,我们将建立在对动态规划方法的理解之上,并研究如何优化我们在上一章中讨论的问题。

介绍

从上一章,你应该对动态规划有一个基本的理解,以及一套有效的策略,用于找到一个陌生问题的动态规划(DP)解决方案。在本章中,我们将通过探讨问题之间的关系来进一步发展这一理解,特别是在基本 DP 逻辑如何被修改以找到另一个问题的解决方案方面。我们还将讨论状态空间缩减的概念,这使我们能够利用问题的某些方面来进一步优化工作的 DP 解决方案,减少找到结果所需的维度和/或操作的数量。我们将通过重新讨论图的主题来结束本章,以展示 DP 方法如何应用于最短路径问题。

P 与 NP 的概述

第八章动态规划 I中,我们展示了动态规划相对于其他方法所能提供的显著效率提升,但可能还不清楚差异有多大。重要的是要意识到某些问题的复杂性将随着输入边界的增加而扩展,因为这样我们就能理解 DP 不仅仅是可取而且是必要的情况。

考虑以下问题:

"给定布尔公式的术语和运算符,确定它是否求值为 TRUE。"

看看以下例子:

(0 OR 1)  —> TRUE
(1 AND 0) —> FALSE
(1 NOT 1) —> FALSE
(1 NOT 0) AND (0 NOT 1) —> TRUE

这个问题在概念上非常简单解决。只需要对给定的公式进行线性评估即可得到正确的结果。然而,想象一下,问题是这样陈述的:

给定布尔公式的变量和运算符,确定是否存在对每个变量的 TRUE/FALSE 赋值,使得公式求值为 TRUE。

看看以下例子:

(a1 OR a2) —> TRUE 
        (0 ∨ 0) = FALSE
        (0 ∨ 1) = TRUE
        (1 ∨ 0) = TRUE
        (1 ∨ 1) = TRUE
(a1 AND a2) —> TRUE
        (0 ∧ 0) = FALSE
        (0 ∧ 1) = FALSE
        (1 ∧ 0) = FALSE
        (1 ∧ 1) = TRUE
(a1 NOT a1) —> FALSE 
        (0 ¬ 0) = FALSE
        (1 ¬ 1) = FALSE
(a1 NOT a2) AND (a1 AND a2) —> FALSE 
        (0 ¬ 0) ∧ (0 ∧ 0) = FALSE
        (0 ¬ 1) ∧ (0 ∧ 1) = FALSE
        (1 ¬ 0) ∧ (1 ∧ 0) = FALSE
        (1 ¬ 1) ∧ (1 ∧ 1) = FALSE

注意:

如果你不熟悉逻辑符号,¬表示NOT,因此(1 ¬ 1) = FALSE(1 ¬ 0) = TRUE。另外,表示AND,而表示OR

基本的概念仍然是相同的,但这两个问题之间的差异是巨大的。在原始问题中,找到结果的复杂性仅取决于一个因素——公式的长度——但是以这种方式陈述,似乎没有明显的方法来解决它,而不需要搜索每个可能的变量赋值的二进制子集,直到找到解决方案。

现在,让我们考虑另一个问题:

"给定一个图,其中每个顶点被分配了三种可能的颜色,确定相邻的两个顶点是否是相同的颜色。"

像我们的第一个例子一样,这个问题实现起来非常简单——遍历图的每个顶点,将其颜色与每个邻居的颜色进行比较,只有在找到相邻颜色匹配对时才返回 false。但现在,想象一下问题是这样的:

"给定一个图,其中每个顶点被分配了三种可能的颜色之一,确定是否可能对其顶点进行着色,以便没有两个相邻的顶点共享相同的颜色。"

同样,这是一个非常不同的情景。

这些问题的第一个版本通常被归类为P,这意味着有一种方法可以在多项式时间内解决它们。当我们将问题描述为O(n)O(n2)O(log n)等时间复杂度时,我们描述的是P类中的问题。然而,重新表述的形式——至少目前为止据证明——没有现有的方法可以在其最坏情况下找到解决方案,因此我们将它们的复杂性分类为NP,或非确定性多项式时间

这些问题类别之间的关系是一个备受争议的话题。特别感兴趣的问题是,验证它们的解决方案所需的计算复杂度是“简单”的,而生成解决方案的复杂度是“困难”的。这展示了编程中最广泛讨论的未解决问题之一:验证解决方案在P类中是否意味着也存在一种方法可以在多项式时间内生成解决方案?换句话说,P = NP吗?虽然对这个问题的普遍假设答案是否定的(P ≠ NP),但这还没有被证明,而且无论实际答案是什么,证明它都将是算法和计算研究中真正革命性的进展。

可以说,NP 中最有趣的一组问题被称为NP-complete,因为它们共享一个显著的特征:如果发现了一个有效地解决任何一个这些问题的解决方案(即在多项式时间内),实际上可以修改该解决方案以有效地解决NP中的所有其他问题。换句话说,如果第一个示例(称为布尔可满足性问题SAT)的多项式解决方案被找到,同样的逻辑的某个变体也可以用来解决第二个示例(称为图着色问题),反之亦然。

请记住,并非每个指数级复杂的问题都符合这个分类。考虑一下在国际象棋游戏中确定下一步最佳移动的问题。您可以将递归逻辑描述如下:

    For each piece, a, belonging to the current player:
        Consider every possible move, m_a, that a can make:

            For each piece, b, belonging to the opponent:
                Consider each possible move m_b that b can make
                in response to m_a.
                    for each piece, a, belonging to the 
                    current player…
                    (etc.)
        Count number of ways player_1 can win after this move
Next best move is the one where the probability that player_1 wins is maximized.

寻找解决方案的复杂度无疑是指数级的。然而,这个问题不符合NP-完全性的标准,因为验证某个移动是否是最佳移动的基本行为需要相同程度的复杂性。

将此示例与解决数独难题的问题进行比较:

图 9.1:一个解决的数独难题

图 9.1:一个解决的数独难题

验证需要扫描矩阵的每一行和每一列,并确定每个九个 3 x 3 方格中都包含 1-9 的每个数字,且没有行或列包含相同的数字超过一次。这个问题的一个直接实现可以使用三个包含{1, 2, 3, 4, 5, 6, 7, 8, 9}的集合,第一个集合表示每行中的数字,第二个表示每列中的数字,第三个表示每个 3 x 3 方格中的数字。当扫描每个单元格时,我们将检查它包含的数字是否在对应的集合中;如果是,就从集合中移除。否则,结果为FALSE。一旦每个单元格都被考虑过,如果每个集合都为空,则结果为TRUE。由于这种方法只需要我们遍历矩阵一次,我们可以得出结论,它可以在多项式时间内解决。然而,假设提供的难题是不完整的,任务是确定是否存在解决方案,我们将不得不递归地考虑原始网格中每个空单元格的每个数字组合,直到找到有效的解决方案,导致最坏情况下的复杂度为O(9n),其中n等于原始网格中的空单元格数;因此,我们可以得出结论,解决数独难题属于NP

重新考虑子集和问题

在上一章中,我们讨论了子集和问题,我们发现在最坏的情况下具有指数复杂度。让我们考虑这个问题可以用两种方式来表达-在找到解决方案的相对困难和验证解决方案的有效性方面。

让我们考虑验证解决方案的问题:

Set    —> { 2 6 4 15 3 9 }
Target —> 24
Subset —> { 2 6 4 }
Sum = 2 + 6 + 4 = 12 
FALSE
Subset —> { 2 6 15 3 }
Sum = 2 + 6 + 15 + 3 = 24
TRUE
Subset —> { 15 9 }
Sum = 15 + 9 = 24
TRUE
Subset —> { 6 4 3 9 }
Sum = 6 + 4 + 3 + 9 = 22
FALSE

毫无疑问,验证的复杂性与每个子集的长度成正比-将所有数字相加并将总和与目标进行比较-这使得它完全属于 P 类。我们找到了一些看似有效的处理解决方案复杂性的方法,我们可以假设它们具有O(N×M)的多项式时间复杂度,其中N是集合的大小,M是目标总和。这似乎排除了这个问题是NP完全的。然而,实际情况并非如此,因为M不是输入的大小,而是它的大小。请记住,计算机用二进制表示整数,需要更多位来表示的整数也需要更多时间来处理。因此,每当 M 的最大值加倍时,计算它实际上需要两倍的时间。

因此,不幸的是,我们的 DP 解决方案不符合多项式复杂性。因此,我们将我们对这个问题的方法定义为运行在“伪多项式时间”中,并且我们可以得出结论,子集和问题实际上是NP完全的。

背包问题

现在,让我们重新考虑我们在第五章“贪婪算法”中看到的背包问题,我们可以将其描述为子集和问题的“大哥”。它提出了以下问题:

“给定一个容量有限的背包和一系列不同价值的加权物品,可以在背包中包含哪些物品,以产生最大的组合价值而不超过容量?”

这个问题也是NP完全性的一个典型例子,因此它与这个类中的其他问题有许多紧密联系。

考虑以下例子:

Capacity —> 10 
Number of items —> 5
Weights —> { 2, 3, 1, 4, 6 } 
Values —>  { 4, 2, 7, 3, 9 }

有了这些数据,我们可以产生以下子集:

图 9.2:给定 0-1 背包问题的所有可能子集

图 9.2:给定 0-1 背包问题的所有可能子集

这绝对看起来很熟悉。这是否需要对子集和算法进行轻微修改?

0-1 背包-扩展子集和算法

您可能还记得我们在第六章“图算法 I”中的讨论,先前的例子就是 0-1 背包问题。在这里,我们注意到当前算法与我们用来解决子集和问题的状态逻辑之间存在另一个明显的并行。

在子集和问题中,我们得出结论,对于集合中索引为 i 的每个元素x,我们可以执行以下操作:

  1. x的值添加到先前找到的子集和中。

  2. 将子集和保持不变。

这意味着可以将新总和y的 DP 表条目标记为TRUE,如果它如下所示:

  1. 表中上一行中的现有总和x,即DP(i, x)

  2. x与集合[i]中的当前元素的组合和,即DP(i, x + set[i])

换句话说,是否可以使用跨越集合中前 i 个元素的子集来形成总和取决于它是否已经被找到,或者是否可以通过将当前元素的值添加到先前找到的总和中找到。

在当前问题中,我们可以观察到对于集合中索引为 i 的每个项目x和权重w,我们可以执行以下操作之一:

  1. x的值添加到先前找到的项目值的子集和中,只要相应项目的权重与w的组合总和小于或等于最大容量。

  2. 将子集和保持不变。

这反过来意味着可以在具有组合重量W的物品集合的索引i+1处找到的最大值和y可以是以下两者之一:

  1. 先前i个物品中找到的现有最大值和x,并且组合重量为w

  2. x的组合和与索引i处的物品的值,假设将物品的重量添加到w时不超过容量

换句话说,可以用前i个物品的子集形成的最大值和,且组合重量为w,要么等于前i-1个物品的重量w对应的最大和,要么等于将当前物品的值添加到先前找到的子集的总值中产生的和。

在伪代码中,我们表达了子集和问题的填表方案如下:

for sum (1 <= sum <= max_sum) found at index i of the set: 
   if sum < set[i-1]: 
    DP(i, sum) = DP(i-1, sum)
   if sum >= set[i-1]:
    DP(i, sum) = DP(i-1, sum) OR DP(i-1, sum - set[i-1])

0-1 背包问题的等效逻辑将如下所示:

for total_weight (1 <= total_weight <= max_capacity) found at index i of the set:
  if total_weight < weight[i]:
     maximum_value(i, total_weight) = maximum_value(i-1, total_weight)
  if total_weight >= weight[i]:
     maximum_value(i, total_weight) = maximum of:
        1) maximum_value(i-1, total_weight)
        2) maximum_value(i-1, total_weight – weight[i]) + value[i]

在这里,我们可以看到一般的算法概念实际上是基本相同的:我们正在遍历一个二维搜索空间,其边界由集合的大小和集合元素的最大和确定,并确定是否可以找到新的子集和。不同之处在于,我们不仅仅记录某个子集和是否存在,而是收集与每个物品子集相关的最大对应值和,并根据它们的总组合重量进行组织。我们将在下一个练习中看一下它的实现。

练习 41:0-1 背包问题

现在,我们将使用表格化的自底向上方法实现前面的逻辑。让我们开始吧:

  1. 我们将首先包括以下标题:
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
  1. 我们的第一步将是处理输入。我们需要声明两个整数itemscapacity,它们分别表示可供选择的物品总数和背包的重量限制。我们还需要两个数组valueweight,我们将在其中存储与每个物品对应的数据:
int main()
{
    int items, capacity;
    cin >> items >> capacity;
    vector<int> values(items), weight(items);
    for(auto &v : values) cin >> v;
    for(auto &w : weight) cin >> w;
    ……
}
  1. 现在,我们将定义函数Knapsack_01(),它具有与输入对应的参数,并返回一个整数:
int Knapsack_01(int items, int capacity, vector<int> value, vector<int> weight)
{
    ……
}
  1. 我们的 DP 表将是二维的,并且将与我们在子集和问题中使用的表非常接近。在子集和表中,第一维的大小初始化为比集合的长度大一,而第二维的大小初始化为比集合中所有元素的最大和大一。在这里,我们的第一维的大小将等效地初始化为items + 1;同样,第二维的大小将初始化为capacity + 1
vector<vector<int>> DP(items + 1, vector<int>(capacity + 1, 0));
  1. 我们需要从1开始迭代两个维度的长度。在外部循环的每次迭代开始时,我们将定义两个变量currentWeightcurrentValue,它们分别对应于weight[i-1]values[i-1]中的元素:
for(int i = 1; i <= items; i++)
{
    int currentWeight = weight[i-1];
    int currentValue = values[i-1];
    for(int totalWeight = 1; totalWeight <= capacity; totalWeight++)
    {
        ……
    }
}
  1. 现在,我们将实现我们的表格化方案:
if(totalWeight < currentWeight)
{
    DP[i][totalWeight] = DP[i-1][totalWeight];
}
else 
{
    DP[i][totalWeight] = max(DP[i-1][totalWeight], DP[i-1][totalWeight - currentWeight] + currentValue);
}
  1. 在我们的函数结束时,我们返回表的最后一个元素:
return DP[items][capacity];
  1. 现在,我们添加一个对main()的调用并打印输出:
int result = Knapsack_01(items, capacity, values, weight);
cout << "The highest-valued subset of items that can fit in the knapsack is: " << result << endl;
return 0;
  1. 让我们尝试使用以下输入运行我们的程序:
8 66
20 4 89 12 5 50 8 13
5 23 9 72 16 14 32 4

输出应该如下:

The highest-valued subset of items that can fit in the knapsack is: 180

正如我们所看到的,相对高效的 DP 解决方案对于背包问题而言只是对解决子集和问题所使用的相同算法的轻微修改。

无界背包

我们探讨的背包问题的实现是最传统的版本,但正如我们在本章前面提到的,实际上有许多种类的问题可以适用于不同的场景。现在,我们将考虑我们拥有每个物品无限数量的情况。

让我们考虑一个通过蛮力找到解决方案的例子:

Capacity = 25
Values —> { 5, 13, 4, 3, 8  }
Weight —> { 9, 12, 3, 7, 19 }
{ 0 } —> Weight = 9, Value = 5
{ 1 } —> Weight = 12, Value = 13
{ 2 } —> Weight = 3, Value = 4
{ 3 } —> Weight = 7, Value = 3
{ 4 } —> Weight = 32, Value = 8
{ 0, 0 } —> Weight = 18, Value = 10
{ 0, 1 } —> Weight = 21, Value = 18
{ 0, 2 } —> Weight = 12, Value = 9
{ 0, 3 } —> Weight = 16, Value = 8
{ 0, 4 } —> Weight = 28, Value = 13
{ 1, 1 } —> Weight = 24, Value = 26
{ 1, 2 } —> Weight = 15, Value = 17
{ 1, 3 } —> Weight = 19, Value = 16
{ 1, 4 } —> Weight = 31, Value = 21
{ 2, 2 } —> Weight = 6, Value = 8
{ 2, 3 } —> Weight = 10, Value = 7
{ 2, 4 } —> Weight = 22, Value = 12
{ 3, 3 } —> Weight = 14, Value = 6
{ 3, 4 } —> Weight = 26, Value = 11
{ 4, 4 } —> Weight = 38, Value = 16
{ 0, 0, 0 } —> Weight = 27, Value = 15
{ 0, 0, 1 } —> Weight = 30, Value = 26
{ 0, 0, 2 } —> Weight = 21, Value = 14
{ 0, 0, 3 } —> Weight = 25, Value = 13
{ 0, 0, 4 } —> Weight = 37, Value = 18
{ 0, 1, 1 } —> Weight = 33, Value = 31
……

从蛮力的角度来看,这个问题似乎要复杂得多。让我们重新陈述我们从 0-1 背包实现中的伪代码逻辑,以处理这个额外的规定。

可以在集合的项目索引i处找到的具有组合重量total_weight的最大值总和y可以是以下之一:

  1. 在先前i-1个项目中找到的现有最大值总和x,并且还具有等于total_weight的组合重量

  2. 假设total_weight可以通过将current_weight添加到前i-1个项目中找到的某个其他子集的总重量来形成:

a) 当前项目值与跨越前i-1个项目的子集的最大值总和,且组合重量为total_weight - current_weight

b) 当前项目值与最近迭代中找到的某个先前y的总重量为total_weight - current_weight的组合

在 DP 表方面,我们可以将新逻辑表示如下:

for total_weight (1 <= total_weight <= max_capacity) found at index i of the set:
    if total_weight < set[i-1]:
      maximum_value(i, total_weight) = maximum_value(i-1, total_weight)

    if total_weight >= set[i-1]:
      maximum_value(i, total_weight) = maximum of:
        1) maximum_value(i-1, total_weight)
        2) maximum_value(i-1, total_weight - current_weight) + current_value
        3) maximum_value(i, total_weight - current_weight) + current_value

我们可以这样实现:

auto max = [](int a, int b, int c) { return std::max(a, std::max(b, c)); };
for(int i = 1; i <= items; i++)
{
    int current_weight = weight[i—1];
    int value = values[i-1];
    for(int total_weight = 0; total_weight <= capacity; w++)
    {
        if(total_weight < current_weight)
        {
            DP[i][total_weight] = DP[i-1][total_weight];
        }
        else 
        {
            DP[i][total_weight] = max
            (
                DP[i-1][total_weight], 
                DP[i-1][total_weight – current_weight] + value, 
                DP[i][total_weight – current_weight] + value
            );
        }
    }
}

从逻辑上讲,这种方法是可行的,但事实证明这实际上并不是最有效的实现。让我们在下一节中了解它的局限性以及如何克服它们。

状态空间缩减

有效使用 DP 的一个相当棘手的方面是状态空间缩减的概念,即重新构建工作的 DP 算法以使用表示状态所需的最小空间。这通常归结为利用问题本质中固有的某种模式或对称性。

为了演示这个概念,让我们考虑寻找Pascal 三角形n行和第m列中的值的问题,可以表示如下:

图 9.3:Pascal 三角形

Pascal 三角形是根据以下逻辑构建的:

For m <= n:
        Base case:
            m = 1, m = n —> triangle(n, m) = 1
        Recurrence: 
            triangle(n, m) = triangle(n-1, m-1) + triangle(n-1, m)

换句话说,每一行的第一个值是1,每个后续列的值都等于前一行的当前列和前一列的和。正如您从下图中看到的,在第二行的第二列中,通过将前一行的第二列(1)和第一列(1)的元素相加,我们得到2

图 9.4:获取 Pascal 三角形中的下一个值

图 9.4:获取 Pascal 三角形中的下一个值

使用制表法解决寻找Pascal 三角形n行和第m列中的值的问题可以如下进行:

vector<vector<int>> DP(N + 1, vector<int>(N + 1, 0));
DP[1][1] = 1;
for(int row = 2; row <= N; row++)
{
    for(int col = 1; col <= row; col++)
    {
        DP[row][col] = DP[row-1][col-1] + DP[row-1][col];
    }
}

在先前的代码中构建的 DP 表对于N = 7将如下所示:

图 9.5:将 Pascal 三角形表示为 N×N DP 表

图 9.5:将 Pascal 三角形表示为 N×N DP 表

正如我们所看到的,这个算法在内存使用和冗余计算方面都是相当浪费的。显而易见的问题是,尽管只有一行包含那么多的值,但表格却有N + 1列。我们可以通过根据所需的元素数量初始化每行,从而将空间复杂度降低,从N**2减少到N × (N + 1) / 2。让我们修改我们的实现如下:

vector<vector<int>> DP(N + 1);
DP[1] = { 0, 1 };
for(int row = 2; row <= N; row++)
{
    DP[row].resize(row + 1);
    for(int col = 1; col <= row; col++)
    {            
        int a = DP[row-1][col-1];
        int b = DP[row-1][min(col, DP[row-1].size()-1)];
        DP[row][col] = a + b;
    }
}

我们还可以观察到每一行的前半部分和后半部分之间存在对称关系,这意味着我们实际上只需要计算前(n/2)列的值。因此,我们有以下内容:

DP(7, 7) ≡ DP(7, 1)
DP(7, 6) ≡ DP(7, 2)
DP(7, 5) ≡ DP(7, 3)

我们可以以一般化的方式陈述如下:

DP(N, M) ≡ DP(N, N - M + 1)

考虑到这一点,我们可以修改我们的实现如下:

vector<vector<int>> DP(N + 1);
DP[0] = { 0, 1 };
for(int row = 1; row <= N; row++)
{
    int width = (row / 2) + (row % 2);
    DP[row].resize(width + 2);
    for(int col = 1; col <= width; col++)
    {
        DP[row][col] = DP[row-1][col-1] + DP[row-1][col];
    }
    if(row % 2 == 0) 
    {
        DP[row][width+1] = DP[row][width];
    }
}
……
for(int i = 0; i < queries; i++)
{
    int N, M;
    cin >> N >> M;
    if(M * 2 > N)
    {
        M = N - M + 1;
    } 
    cout << DP[N][M] << endl;
}

最后,假设我们能够提前接收输入查询并预先计算结果,我们可以完全放弃存储完整的表,因为只需要前一行来生成当前行的结果。因此,我们可以进一步修改我们的实现如下:

map<pair<int, int>, int> results;
vector<pair<int, int>> queries;
int q;
cin >> q;
int maxRow = 0;
for(int i = 0; i < q; i++)
{
    int N, M;
    cin >> N >> M;
    queries.push_back({N, M});

    if(M * 2 > N) M = N - M + 1;
    results[{N, M}] = -1; 
    maxRow = max(maxRow, N);
}
vector<int> prev = { 0, 1 };
for(int row = 1; row <= maxRow; row++)
{
    int width = (row / 2) + (row % 2);
    vector<int> curr(width + 2);
    for(int col = 1; col <= width; col++)
    {
        curr[col] = prev[col-1] + prev[col];
        if(results.find({row, col}) != results.end())
        {
            queries[{row, col}] = curr[col];
        }
    }
    if(row % 2 == 0)
    {
        curr[width + 1] = curr[width];
    }
    prev = move(curr);
}
for(auto query : queries)
{
    int N = query.first, M = query.second;
    if(M * 2 > N) M = N - M + 1;

    cout << results[{N, M}] << endl;
}

现在,让我们回到无界背包问题:

Capacity     —>   12
Values       —> { 5, 1, 6, 3, 4 }
Weight       —> { 3, 2, 4, 5, 2 }

我们提出的解决方案在前一节中构建的 DP 表将如下所示:

图 9.6:由提议算法构建的二维 DP 表

图 9.6:由提议的算法构建的二维 DP 表

我们用来生成上表的逻辑是基于解决 0-1 背包问题的方法,因此,我们假设给定weighti种物品的最大值之和,即DP(i, weight),可以如下:

  1. 相同重量和i-1种物品的最大值之和,不包括当前物品,即DP(i-1, weight)

  2. 当前物品的valuei-1种物品的最大和的总和,即DP(i-1, weight-w) + value

  3. 当前物品的valuei种物品的最大和的总和,如果要多次包括该物品,即DP(i, weight-w) + value

前两个条件对应于 0-1 背包问题的逻辑。然而,在无界背包的情况下考虑它们,并根据我们的算法生成的表进行检查,我们实际上可以得出前两个条件基本上是无关紧要的结论。

在原始问题中,我们关心i-1个物品的值,因为我们需要决定是否包括或排除物品i,但在这个问题中,只要它们的重量不超过背包的容量,我们就没有理由排除任何物品。换句话说,规定每个状态转换的条件仅由weight限制,因此可以用一维表示!

这导致必须进行重要的区分:模拟状态所需的维度不一定与描述状态所需的维度相同。到目前为止,我们研究的每个 DP 问题,在缓存时,结果都基本上等同于状态本身。然而,在无界背包问题中,我们可以描述每个状态如下:

“对于每个重量 w 和价值 v 的物品,容量为 C 的背包的最大价值等于 v 加上容量为 C-w 的背包的最大价值。”

考虑以下输入数据:

Capacity —> 12
Values   —> { 5, 1, 6, 3, 4 }
Weight   —> { 3, 2, 4, 5, 2 }

在下表中,每一行代表一个重量w,从0到最大容量,每一列代表一个物品的索引i。每个单元格中的数字表示考虑了索引i的物品后每个重量的最大值之和:

图 9.7:每个重量-索引对的子问题结果

图 9.7:每个重量-索引对的子问题结果

正如前表所示,允许重复意味着只要包含在最大容量内,就不需要排除任何物品。因此,无论重量总和是否可以在集合的索引0或索引1,000处找到都是无关紧要的,因为我们永远不会保留先前找到的子集总和,除非添加到它超出了背包的定义边界。这意味着维护物品索引的记录没有任何优势,这使我们能够在一维中缓存我们的子问题-任意数量的物品的组合重量。我们将在下一个练习中看到它的实现。

练习 42:无界背包

在这个练习中,我们将应用状态空间缩减的概念来将无界背包问题表示为一维的 DP 表。让我们开始:

  1. 让我们使用与上一个练习中相同的标题和输入:
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
……
int main()
{
    int items, capacity;
    cin >> items >> capacity;
    vector<int> values(items), weight(items);
    for(auto &v : values) cin >> v;
    for(auto &w : weight) cin >> w;
    ……
}
  1. 现在,我们将实现一个名为UnboundedKnapsack()的函数,它返回一个整数。它的参数将与输入相同:
int UnboundedKnapsack(int items, int capacity, vector<int> values, vector<int> weight)
{
    ……
}
  1. 我们的 DP 表将表示为一个整数向量,大小为capacity + 1,每个索引初始化为0
vector<int> DP(capacity + 1, 0);
  1. 与 0-1 背包问题一样,我们的状态逻辑将包含在两个嵌套循环中;但是,在这个问题的变体中,我们将颠倒循环的嵌套,使得外部循环从0capacity(包括),内部循环遍历项目索引:
for(int w = 0; w <= capacity; w++)
{
    for(int i = 0; i < items; i++)
    {
        ……
    }
} 
  1. 现在,我们必须决定如何缓存我们的状态。我们唯一关心的是容量不被选择物品的重量超过。由于我们的表只足够大,可以表示从0capacity的重量值,我们只需要确保wweight[i]之间的差值是非负的。因此,所有的赋值逻辑都可以包含在一个if语句中:
for(int w = 0; w <= capacity; w++)
{
    for(int i = 0; i < items; i++)
    {
        if(weight[i] <= w)
        {
            DP[w] = max(DP[w], DP[w - weight[i]] + values[i]);
        }
    }
}
return DP[capacity];
  1. 现在,让我们返回到main(),添加一个调用UnboundedKnapsack(),并输出结果:
int main()
{
        ……
    int result = UnboundedKnapsack(items, capacity, values, weight);
    cout << "Maximum value of items that can be contained in the knapsack: " << result << endl;
    return 0;
}
  1. 尝试使用以下输入运行您的程序:
30 335
91 81 86 64 24 61 13 57 60 25 94 54 39 62 5 34 95 12 53 33 53 3 42 75 56 1 84 38 46 62 
40 13 4 17 16 35 5 33 35 16 25 29 6 28 12 37 26 27 32 27 7 24 5 28 39 15 38 37 15 40 

您的输出应该如下:

Maximum value of items that can be contained in the knapsack: 7138

正如前面的实现所示,通常值得考虑在 DP 算法中缓存解决方案的更便宜的方法。看起来需要复杂状态表示的问题经过仔细检查后通常可以显著简化。

活动 22:最大化利润

您正在为一家大型连锁百货商店工作。像任何零售业务一样,您的公司以大量购买商品从批发分销商那里,然后以更高的价格出售以获得利润。您商店销售的某些类型的产品可以从多个不同的分销商那里购买,但产品的质量和价格可能有很大的差异,这自然会影响其相应的零售价值。一旦考虑到汇率和公众需求等因素,某些分销商的产品通常可以以比最终销售价格低得多的价格每单位购买。您的任务是设计一个系统,计算您可以在分配的预算中获得的最大利润。

您已经提供了一份类似产品的目录。列出的每个产品都有以下信息:

  • 产品的批发价格

  • 通过销售相同产品后的标记,可以获得的利润金额

  • 分销商每单位销售的产品数量

鉴于分销商只会按照指定的确切数量出售产品,您的任务是确定通过购买列出的一些产品的子集可以获得的最大金额。为了确保商店提供多种选择,列出的每个物品只能购买一次。

由于您只有有限的仓库空间,并且不想过度库存某种类型的物品,因此您还受到可以购买的单个单位的最大数量的限制。因此,您的程序还应确保购买的产品的总数不超过此限制。

例子

假设目录中列出了五种产品,具有以下信息:

图 9.8:利润优化的示例值

您有 100 美元的预算和 20 个单位的仓库容量。以下一组购买将是有效的:

{ A B }    Cost: 30     | Quantity: 15    | Value: 70
{ A D }    Cost: 70     | Quantity: 13    | Value: 110
{ A E }    Cost: 60     | Quantity: 14    | Value: 130
{ B C }    Cost: 25     | Quantity: 17    | Value: 40
{ C D }    Cost: 65     | Quantity: 15    | Value: 80
{ C E }    Cost: 55     | Quantity: 16    | Value: 100
{ D E }    Cost: 90     | Quantity: 7     | Value: 140
{ A B D }  Cost: 80     | Quantity: 18    | Value: 130
{ A B E }  Cost: 70     | Quantity: 19    | Value: 150
{ B C D }  Cost: 75     | Quantity: 20    | Value: 100
{ B D E }  Cost: 100    | Quantity: 12    | Value: 160

因此,程序应该输出160

输入

第一行包含三个整数,N作为分销商的数量,budget作为可以花费的最大金额,capacity作为可以购买的最大单位数的限制。

接下来的N行应包含三个以空格分隔的整数:

  • quantity:分销商提供的每单位数量

  • cost:物品的价格

  • value:销售产品后可以获得的利润金额

输出

表示从目录中选择一些子项可以获得的最大利润的单个整数。

测试用例

以下一组测试用例应该帮助您更好地理解这个问题:

图 9.9:活动 22 测试用例 1

](https://gitee.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/cpp-dsal-dsn-prin/img/C14498_09_09.jpg)

图 9.9:活动 22 测试用例 1

图 9.10:活动 22 测试用例 2

](https://gitee.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/cpp-dsal-dsn-prin/img/C14498_09_10.jpg)

图 9.10:活动 22 测试案例 2

图 9.11:活动 22 测试案例 3

图 9.11:活动 22 测试案例 3

图 9.12:活动 22 测试案例 4

图 9.12:活动 22 测试案例 4

活动指南

  • 所需的实现与 0-1 背包问题非常相似。

  • 由于存在两个约束(容量和预算),DP 表将需要三个维度。

注意

此活动的解决方案可在第 581 页找到。

图和动态规划

在本节中,我们已经讨论了高级图算法和 DP 作为截然不同的主题,但通常情况下,它们可以根据我们试图解决的问题类型和图的性质同时使用。与图相关的几个常见问题被确定为NP完全问题(图着色和顶点覆盖问题,这只是两个例子),在适当的情况下,可以用动态规划来解决。然而,大多数这些主题都超出了本书的范围(实际上,它们值得专门撰写整本书来进行分析)。

然而,图论中的一个问题非常适合 DP 方法,而且幸运的是,这是我们已经非常熟悉的问题:最短路径问题。实际上,在第七章图算法 II中,我们实际上讨论了一个通常归类为 DP 范畴的算法,尽管我们从未将其标识为这样。

重新考虑贝尔曼-福特算法

在我们探讨贝尔曼-福特算法时,我们是根据之前讨论的迪杰斯特拉算法来看待它的,它确实与迪杰斯特拉算法有一些相似之处。但是现在我们对动态规划范式的概念有了牢固的理解,让我们根据新的理解重新考虑贝尔曼-福特算法。

简而言之,贝尔曼-福特使用的方法可以描述如下:

给定一个名为start的源节点,图的顶点数V和边E,执行以下操作:

  1. 将每个节点从0到“V – 1”(包括)的距离标记为UNKNOWN,除了start0

  2. 1迭代到“V – 1”(包括)。

  3. 在每次迭代中,考虑图中的每条边,并检查源节点的相应距离值是否为UNKNOWN。如果不是,则将相邻节点当前存储的距离与源节点的距离与它们之间的边权重的和进行比较。

  4. 如果源节点的距离与边权重的和小于目标节点的距离,则将目标节点的距离更新为较小值。

  5. 经过“V – 1”次迭代后,要么找到了最短路径,要么图中存在负权重循环,可以通过对边进行额外迭代来确定。

该算法的成功显然取决于问题具有最优子结构的事实。我们可以将这个概念背后的递归逻辑描述如下:

图 9.13:可视化贝尔曼-福特算法

图 9.13:可视化贝尔曼-福特算法

将此表示为伪代码将看起来类似于以下内容:

Source —> A
Destination —> E
The shortest path from A to E is equal to:
    …the edge weight from A to B (4), plus…
        …the shortest path from B to E, which is:
            …the edge weight from B to C (3), plus:
                …the edge weight from C to E (2).
            …or the edge weight from B to E (9).
    …or the edge weight from A to D (3), plus:
        …the shortest path from D to E, which is:
            …the edge weight from D to B (8), plus:
                …the shortest path from B to E (9), which is:
                    …the edge weight from B to C (3), plus:
                        …the edge weight from C to E (2).
                    …or the edge weight from B to E (9).
            …the edge weight from D to C (3), plus:
                …the edge weight from C to E (2).
            …or the edge weight from D to E (7).

显然,最短路径问题也具有重叠子问题属性。贝尔曼-福特算法有效地避免了由于两个关键观察而导致的重复计算:

  • 在图中任意两个节点之间进行非循环遍历的最大移动次数为“| V – 1 |”(即图中的每个节点减去起始节点)。

  • 经过 N 次迭代后,源节点与每个可达节点之间的最短路径等于在“| N – 1 |”次迭代后可达的每个节点的最短路径,加上到它们各自邻居的边权重。

以下一组图表应该能帮助你更好地可视化贝尔曼-福特算法中的步骤:

图 9.14:贝尔曼-福特算法第 1 步

图 9.14:贝尔曼-福特算法第 1 步

图 9.15:贝尔曼-福特算法第 2 步

图 9.15:贝尔曼-福特算法第 2 步

图 9.16:贝尔曼-福特算法第 3 步

图 9.16:贝尔曼-福特算法第 3 步

贝尔曼-福特算法被认为解决的具体问题被称为单源最短路径问题,因为它用于找到单个节点的最短路径。在第七章图算法 II中,我们讨论了约翰逊算法,它解决了被称为全对最短路径问题的问题,因为它找到了图中每对顶点之间的最短路径。

约翰逊算法将贝尔曼-福特算法中看到的 DP 方法与狄克斯特拉算法中看到的贪婪方法相结合。在本节中,我们将探讨全对最短路径问题的完整 DP 实现。然而,让我们通过实现一个自顶向下的解决方案来更深入地考虑问题的性质。

将最短路径问题作为 DP 问题来解决

更好地理解贝尔曼-福特背后的逻辑的一种方法是将其转化为自顶向下的解决方案。为此,让我们从考虑我们的基本情况开始。

贝尔曼-福特算法通过图的边执行V - 1次迭代,通常通过for循环。由于我们先前的实现已经从1迭代到V - 1,让我们的自顶向下解决方案从V - 1开始,并递减到0。就我们的递归结构而言,让我们说每个状态可以描述如下:

ShortestPath(node, depth)
node —> the node being considered
depth —> the current iteration in the traversal

因此,我们的第一个基本情况可以定义如下:

if depth = 0:
        ShortestPath(node, depth) —> UNKNOWN

换句话说,如果depth已经减少到0,我们可以得出结论,没有路径存在,并终止我们的搜索。

我们需要处理的第二个基本情况当然是我们找到从源到目标的路径的情况。在这种情况下,搜索的深度是无关紧要的;从目标到自身的最短距离总是0

if node = target: 

        ShortestPath(node, depth) —> 0

现在,让我们定义我们的中间状态。让我们回顾一下贝尔曼-福特算法使用的迭代方法:

for i = 1 to V - 1:
        for each edge in graph:
            edge —> u, v, weight 
            if distance(u) is not UNKNOWN and distance(u) + weight < distance(v):
                distance(v) = distance(u) + weight

就递归遍历而言,可以重新表述如下:

for each edge adjacent to node:

        edge —> neighbor, weight
    if ShortestPath(neighbor, depth - 1) + weight < ShortestPath(node, depth):
            ShortestPath(node, depth) = ShortestPath(neighbor, depth - 1) + weight

由于每个状态可以根据这两个维度和可能存在循环的存在而唯一描述,并且可能会多次遇到相同的状态,我们可以得出结论,根据节点深度对缓存进行缓存既有效又有用于记忆化目的:

Depth = 7:
    SP(0, 7): 0
    SP(1, 7): 6
    SP(2, 7): UNKNOWN
    SP(3, 7): 12
    SP(4, 7): UNKNOWN
    SP(5, 7): UNKNOWN
    SP(6, 7): 13
    SP(7, 7): UNKNOWN
Depth = 6:
    SP(0, 6): 0
    SP(1, 6): 6
    SP(2, 6): 14
    SP(3, 6): 12
    SP(4, 6): UNKNOWN
    SP(5, 6): UNKNOWN
    SP(6, 6): 12
    SP(7, 6): 15
Depth = 5:
    SP(0, 5): 0
    SP(1, 5): 6
    SP(2, 5): 14

这些状态在下图中说明:

图 9.17:最短路径问题的所有状态

图 9.17:最短路径问题的所有状态

我们将在下一个练习中看看这种方法的实现。

练习 43:单源最短路径(记忆化)

在这个练习中,我们将采用自顶向下的动态规划方法来解决单源最短路径问题。让我们开始吧:

  1. 让我们从包括以下标头和std命名空间开始,并定义一个UNKNOWN常量:
#include <iostream>
#include <vector>
#include <utility>
#include <map>
using namespace std;
const int UNKNOWN = 1e9;
  1. 让我们还声明VE(顶点数和边数),以及两个二维整数向量,adj(图的邻接表)和weight(边权重值的矩阵)。最后,我们将定义一个名为memo的记忆化表。这次,我们将使用std::map来简化区分检查缓存中的键是否存在与其值是否未知:
int V, E;
vector<vector<int>> adj;
vector<vector<int>> weight;
map<pair<int, int>, int> memo;
  1. main()函数中,我们应该处理输入,以便接收我们希望应用算法的图。输入的第一行将包含VE,接下来的E行将包含三个整数:uvw(每条边的源、目的地和权重):
int main()
{
        int V, E;
        cin >> V >> E;
        weight.resize(V, vector<int>(V, UNKNOWN));
        adj.resize(V);
        for(int i = 0; i < E; i++)
        {
            int u, v, w;
            cin >> u >> v >> w;
            adj[u].push_back(v);
            weight[u][v] = w;
        }
        …
}
  1. 我们现在将定义一个名为SingleSourceShortestPaths()的函数,它将接受一个参数——source,即源顶点的索引,并将返回一个整数向量:
vector<int> SingleSourceShortestPaths(int source)
{
        ……
}
  1. 现在,我们需要对我们的图进行一些初步的修改。与其从源节点遍历图中的所有其他节点,我们将从其他节点开始每次遍历,并计算从源到目标的最短路径。由于我们的图是有向的,我们将不得不使用其转置来实现这一点:
// Clear table
vector<vector<int>> adj_t(V);
vector<vector<int>> weight_t(V, vector<int>(V, UNKNOWN));
for(int i = 0; i < V; i++)
{
        // Create transpose of graph
        for(auto j : adj[i])
        {
            adj_t[j].push_back(i);
            weight_t[j][i] = weight[i][j];
        }
        // Base case — shortest distance from source to itself is zero at any depth
        memo[{source, i}] = 0;
        if(i != source) 
        {
            // If any node other than the source has been reached 
            // after V - 1 iterations, no path exists.
            memo[{i, 0}] = UNKNOWN;
        }
}

在这里,我们定义了两个新的二维整数向量,adj_tweight_t,它们将对应于转置图的邻接表和权重矩阵。然后,我们使用嵌套循环来创建我们的修改后的图,并初始化了我们memo表中的值。

  1. 现在,我们应该定义ShortestPath_Memoization()函数,它有四个参数:两个整数,depthnode,以及adjweight(在这种情况下,它们将是对转置图的引用):
    int ShortestPath_Memoization(int depth, int node, vector<vector<int>> &adj, vector<vector<int>> &weight)
{
        ……
    }
  1. 我们的算法本质上是标准的深度优先搜索,除了我们将在每次函数调用结束时缓存每个{节点,深度}对的结果。在函数顶部,我们将检查缓存的结果,如果键存在于映射中,则返回它:
// Check if key exists in map
if(memo.find({node, depth}) != memo.end())
{
    return memo[{node, depth}];
}
memo[{node, depth}] = UNKNOWN;
// Iterate through adjacent edges
for(auto next : adj[node])
{
    int w = weight[node][next];
    int dist = ShortestPath_Memoization(depth - 1, next, adj, weight) + w;
    memo[{node, depth}] = min(memo[{node, depth}], dist);
}
return memo[{node, depth}];
  1. 回到SingleSourceShortestPaths()函数,我们将定义一个名为distance的整数向量,大小为V,并通过对ShortestPath_Memoization()的连续调用来填充它:
vector<int> distance;

for(int i = 0; i < V; i++)
{
    distance[i] = ShortestPath_Memoization(V - 1, i, adj_t, weight_t);
}
return distance;
  1. 回到main(),我们将定义一个名为paths的二维整数矩阵,它将存储从0V的每个节点索引返回的SingleSourceShortestPaths()的距离:
vector<vector<int>> paths(V);
for(int i = 0; i < V; i++)
{
    paths[i] = SingleSourceShortestPaths(i);
}
  1. 现在,我们可以使用paths表来打印图中每对节点的距离值:
cout << "The shortest distances between each pair of vertices are:" << endl;
for(int i = 0; i < V; i++)
{
        for(int j = 0; j < V; j++)
        {
          cout << "\t" << j << ": ";
          (paths[i][j] == UNKNOWN) ? cout << "- ";
                                   : cout << paths[i][j] << " ";
        }
        cout << endl;
}
  1. 现在,使用以下输入运行您的代码:
8 20
0 1 387
0 3 38
0 5 471
1 0 183
1 4 796
2 5 715
3 0 902
3 1 712
3 2 154
3 6 425
4 3 834
4 6 214
5 0 537
5 3 926
5 4 125
5 6 297
6 1 863
6 7 248
7 0 73
7 3 874

输出应该如下所示:

The shortest distances between each pair of vertices are:
0: 0 387 192 38 596 471 463 711 
1: 183 0 375 221 779 654 646 894 
2: 1252 1639 0 1290 840 715 1012 1260 
3: 746 712 154 0 994 869 425 673 
4: 535 922 727 573 0 1006 214 462 
5: 537 924 729 575 125 0 297 545 
6: 321 708 513 359 917 792 0 248 
7: 73 460 265 111 669 544 536 0  

毫不奇怪,这并不是处理这个特定问题的首选方式,但是和之前的练习一样,我们可以通过实现像这样的递归解决方案来学习到很多关于最优子结构是如何形成的。有了这些见解,我们现在可以完全理解如何使用制表法同时找到每对节点之间的最短距离。

所有对最短路径

我们之前的程序确实打印了每对顶点的最短路径,但它的效率大致相当于执行V次贝尔曼-福特算法,同时还有与递归算法相关的内存缺点。

幸运的是,对于这个问题有一个非常有用的自底向上算法,它能够在O(V3)时间和O(V2)空间内处理其他算法所能处理的一切。特别是在实现了本书中其他最短路径算法之后,这种算法也是相当直观的。

弗洛伊德-沃舍尔算法

到目前为止,我们应该已经相当清楚地掌握了贝尔曼-福特算法如何利用最短路径问题中所表现出的最优子结构。关键是,两个图顶点之间的任何最短路径都将是从源开始的某些其他最短路径和连接路径终点到目标顶点的边的组合。

弗洛伊德-沃舍尔算法通过使用相同的概念取得了很大的效果:

"如果节点 A 和节点 B 之间的最短距离是 AB,节点 B 和节点 C 之间的最短距离是 BC,那么节点 A 和节点 C 之间的最短距离是 AB + BC。"

这个逻辑本身当然并不是突破性的;然而,结合贝尔曼-福特所展示的洞察力——图的边上的V次迭代足以确定从源节点到图中每个其他节点的最短路径——我们可以使用这个想法来逐步生成以节点 A为源的节点对之间的最短路径,然后使用这些结果来生成节点 BCD等的潜在最短路径。

Floyd-Warshall 通过在顶点之间执行V**3次迭代来实现这一点。第一维表示潜在的中点B,在每对顶点AC之间。然后算法检查从AC的当前已知距离值是否大于从AB和从BC的最短已知距离之和。如果是,它确定该和至少更接近AC的最优最短距离值,并将其缓存到表中。Floyd-Warshall 使用图中的每个节点作为中点进行这些比较,不断提高其结果的准确性。在每个可能的起点和终点对被测试过每个可能的中点之后,表中的结果包含每对顶点的正确最短距离值。

与任何与图相关的算法一样,Floyd-Warshall 并不保证在每种情况下都是最佳选择,应始终考虑 Floyd-Warshall 和其他替代方案之间的比较复杂性。一个很好的经验法则是在稠密图(即包含大量边的图)中使用 Floyd-Warshall。例如,假设你有一个包含 100 个顶点和 500 条边的图。在每个起始顶点上连续运行 Bellman-Ford 算法(最坏情况下的复杂度为O(V×E))可能导致总复杂度为 500×100×100(或 5,000,000)次操作,而 Floyd-Warshall 只需要 100×100×100(或 1,000,000)次操作。Dijkstra 算法通常比 Bellman-Ford 更有效,也可能是一个可行的替代方案。然而,Floyd-Warshall 的一个明显优势是算法的总体复杂度始终是O(V3),不需要知道输入图的其他属性,就能确定 Floyd-Warshall 的效率(或低效性)。

最后要考虑的一点是,与 Bellman-Ford(不同于 Dijkstra 算法),Floyd-Warshall 能够处理具有负边权重的图,但也会受到负边权重循环的阻碍,除非有明确的处理。

我们将在以下练习中实现 Floyd-Warshall 算法。

练习 44:实现 Floyd-Warshall 算法

在这个练习中,我们将使用 Floyd-Warshall 算法找到每对顶点之间的最短距离。让我们开始吧:

  1. 我们将首先包括以下标头并定义一个UNKNOWN常量:
#include <iostream>
#include <vector>
using namespace std;
const int UNKNOWN = 1e9;
  1. 让我们首先处理输入,几乎与我们在上一个练习中所做的完全相同。然而,这一次,我们不需要图的邻接表表示:
int main()
{
        int V, E;
        cin >> V >> E;
        vector<vector<int>> weight(V, vector<int>(V, UNKNOWN));
        for(int i = 0; i < E; i++)
        {
            int u, v, w;
            cin >> u >> v >> w;
            weight[u][v] = w;
        }
        ……
        return 0;
}
  1. 我们的FloydWarshall()函数将接受两个参数——Vweight——并返回一个二维整数向量,表示最短路径距离:
vector<vector<int>> FloydWarshall(int V, vector<vector<int>> weight)
{
        ……
}
  1. 让我们定义一个名为distance的二维 DP 表,并将每个值初始化为UNKNOWN。然后,我们需要为每对节点分配最初已知的最短距离“估计”(即weight矩阵中的值),以及基本情况值(即从每个节点到自身的最短距离0):
    vector<vector<int>> distance(V, vector<int>(V, UNKNOWN));
for(int i = 0; i < V; i++)
{
    for(int j = 0; j < V; j++)
    {
        distance[i][j] = weight[i][j];
    }
    distance[i][i] = 0;
}
  1. 现在,我们将从0V – 1(包括)执行三个嵌套的for循环,外部循环表示当前中间顶点mid,中间循环表示源顶点start,最内部循环表示目标顶点end。然后我们将比较每对顶点之间的距离值,并在找到更短的路径时重新分配从起点到终点的距离值:
for(int mid = 0; mid < V; mid++)
{
    for(int start = 0; start < V; start++)
    {
        for(int end = 0; end < V; end++)
        {
            if(distance[start][mid] + distance[mid][end] < distance[start][end])
            {
                distance[start][end] = distance[start][mid] + distance[mid][end];
            }
        }
    }
}
  1. 类似于 Bellman-Ford,如果我们的输入预计包含负边权重,我们需要检查负循环。值得庆幸的是,使用距离表可以很容易地实现这一点。

考虑到图中的循环是一条长度大于零的路径,起点和终点顶点相同。在表示每对节点之间的距离的表中,节点和自身之间的最短路径将包含在distance[node][node]中。在只包含正边权的图中,distance[node][node]中包含的值显然只能等于0;然而,如果图中包含负权重循环,distance[node][node]将为负。因此,我们可以这样测试负循环:

for(int i = 0; i < V; i++)
{
        // If distance from a node to itself is negative, there must be a negative cycle
        if(distance[i][i] < 0)
        {
            return {};
        }
} 
return distance;
  1. 现在我们已经完成了算法的编写,可以在main()中调用FloydWarshall()并输出结果:
int main()
{
    ……
    vector<vector<int>> distance = FloydWarshall(V, weight);
    // Graphs with negative cycles will return an empty vector
    if(distance.empty())
    {
        cout << "NEGATIVE CYCLE FOUND" << endl;
        return 0;
    }
    for(int i = 0; i < V; i++)
    {
        cout << i << endl;
        for(int j = 0; j < V; j++)
        {
            cout << "\t" << j << ": ";

            (distance[i][j] == UNKNOWN) 
                ? cout << "_" << endl 
                : cout << distance[i][j] << endl;
        }
    }
    return 0;
}
  1. 让我们在以下输入集上运行我们的程序:
Input:
7 9
0 1 3
1 2 5
1 3 10
1 5 -4
2 4 2
3 2 -7
4 1 -3
5 6 -8
6 0 12
Output:
0:
        0: 0
        1: 3
        2: 6
        3: 13
        4: 8
        5: -1
        6: -9
1:
        0: 0
        1: 0
        2: 3
        3: 10
        4: 5
        5: -4
        6: -12
2:
        0: -1
        1: -1
        2: 0
        3: 9
        4: 2
        5: -5
        6: -13
3:
        0: -8
        1: -8
        2: -7
        3: 0
        4: -5
        5: -12
        6: -20
4:
        0: -3
        1: -3
        2: 0
        3: 7
        4: 0
        5: -7
        6: -15
5:
        0: 4
        1: 7
        2: 10
        3: 17
        4: 12
        5: 0
        6: -8
6:
        0: 12
        1: 15
        2: 18
        3: 25
        4: 20
        5: 11
        6: 0
  1. 现在,让我们尝试另一组输入:
Input:
6 8
0 1 3
1 3 -8
2 1 3
2 4 2
2 5 5
3 2 3
4 5 -1
5 1 8
Output:
NEGATIVE CYCLE FOUND

正如你所看到的,Floyd-Warshall 是一种非常有用的算法,不仅有效而且相当容易实现。在效率方面,我们应该选择 Floyd-Warshall 还是 Johnson 算法完全取决于图的结构。但严格从实现的角度来看,Floyd-Warshall 是明显的赢家。

活动 23:住宅道路

你是一个房地产开发项目的负责人,计划建造一些高端住宅社区。你已经收到了关于将要建造的各种属性的各种信息,目前的任务是尽可能便宜地设计一套道路系统。

许多社区计划建在湖泊、森林和山区。在这些地区,地形通常非常崎岖,这可能会使建筑变得更加复杂。你已经被警告,建筑成本会根据地形的崎岖程度而增加。在初稿中,你被告知要考虑成本的增加是与可以建造道路的每个坐标的崎岖值成正比的。

你已经收到了以下信息:

  • 属性地图

  • 可以建造属性的坐标

  • 每个坐标的地形崎岖程度

你还收到了确定如何建造道路的以下准则:

  • 地图上可以建造道路的位置将用“。”字符标记。

  • 只能在两个房屋之间建造道路,这两个房屋之间有直接的垂直、水平或对角线路径。

  • 社区中的所有房屋都应该可以从其他房屋到达。

  • 道路不能建在水域、山区、森林等地方。

  • 两个房屋之间建造道路的成本等于它们之间路径上的地形崎岖值之和。

  • 只有在建造的道路是通往指定入口的最低成本路径上时,两个房屋之间才应该建造一条道路。

  • 入口点始终是输入中索引最高的房屋。

确定了房屋和道路的位置后,应根据以下图例生成原始地图的新版本:

  • 房屋应该用大写字母标记,对应于它们在输入中给出的顺序(即 0=A,1=B,2=C等)。

  • 道路应该用字符|-\/表示,取决于它们的方向。如果两条具有不同方向的道路相交,应该用+字符表示。

  • 地图上的其他所有内容应该显示为输入中原始给出的样子。

输入格式

程序应按以下格式接受输入:

  • 第一行包含两个以空格分隔的整数HW,表示地图的高度和宽度。

  • 第二行包含一个整数N,表示要建造的房屋数量。

  • 接下来的H行每行包含一个长度为W的字符串,表示网格上的一行。可以建造道路的有效位置将用“。”字符标记。

  • 接下来的N行包含两个整数xy,它们是房屋的坐标。最后一个索引(即N-1)始终代表社区的入口点。

输出格式

程序应输出与输入中给出的相同地图,并添加以下内容:

  • 每个房屋的位置应标有大写字母,对应它们的从零开始的索引,原点在左上角,相对于N(即 0 = A,1 = B,2 = C,依此类推)。

  • 连接每对房屋的道路应如下所示:

- 如果道路的方向是水平的

| 如果道路的方向是垂直的

/\ 如果道路的方向是对角线的

+ 如果任意数量的具有不同方向的道路在同一点相交

提示/指导

  • 要得出最终结果,需要一些不同的步骤。建议您在实施之前概述必要的步骤。

  • 为每个程序的各个部分设计一些调试和生成测试输出的方案可能非常有帮助。在过程的早期出现错误可能会导致后续步骤失败。

  • 如果您在理解需要完成的任务方面有困难,请研究更简单的输入和输出示例。

  • 首先实施您知道需要的算法,特别是我们在上一章讨论过的算法。完成此任务的每个部分可能有多种方法-要有创造力!

测试案例

这些测试案例应该帮助您了解如何继续。让我们从一个简单的例子开始:

图 9.18:活动 23,测试案例 1(左)和 2(右)

图 9.18:活动 23,测试案例 1(左)和 2(右)

让我们考虑前一图中右侧的示例输出。在该示例中,从E(0,4)C(5,4)的路径无法建立,因为存在不可通过的障碍物#。让我们考虑一些更复杂的示例:

图 9.19:活动 23,测试案例 3(左)和 4(右)

图 9.19:活动 23,测试案例 3(左)和 4(右)

请注意,不同的符号用于表示不同类型的障碍物。尽管任何障碍物的影响都是相同的,但我们不能在那里建造道路。最后,让我们在以下示例中增加复杂性:

图 9.20:活动 23,测试案例 5

图 9.20:活动 23,测试案例 5

注意

此活动的解决方案可在第 585 页找到。

摘要

现在您已经完成了本章,您应该对动态规划的价值有相当高的欣赏。如果您最初发现这个主题有些焦虑,希望您已经意识到它并不像最初看起来那么复杂。像我们在本章中所做的那样,通过动态规划的视角来看待熟悉的问题,肯定可以帮助我们理解需要达到工作动态规划解决方案所需的核心思想。为此,我们鼓励您调查背包问题的其他变体,并尝试使用提供的策略来实现它们。

有了这一点,您在 C++中算法和数据结构的广阔世界中的旅程已经结束。到达本书的末尾,您应该对何时以及如何使用我们行业中最有用的工具有了明显加深的理解。希望您对本书中涵盖的结构和技术的实际应用有了更好的认识,以及对 C++语言及其丰富的功能集有了更广泛的了解。

值得注意的是,实践中使用许多这些技术的适当场合并不一定明显,这就是为什么将所学知识应用于各种不同的情境中是非常有益的。我们努力提供了一系列有趣的活动来练习本书中的概念,但强烈建议您也尝试在其他情况下使用这些技能。有大量在线资源提供独特而引人入胜的编程挑战,适合各个级别的开发人员,如果您希望训练自己认识到某些技术如何在各种情况下被利用,这些资源将是非常宝贵的。

当然,我们在本书中讨论的每个主题都值得进行比任何一本书所能涵盖的更深入的研究,我们希望我们提供的信息足够使这些主题变得易于访问,以鼓励您深入探索它们。无论您是学生,正在寻找发展工作,还是已经在专业领域工作,您可能会遇到本书涵盖的至少一个(很可能是许多)主题的用途;并且幸运的话,当那个时机来临时,您将会知道该怎么做!

附录

关于

本节包括帮助学生完成书中活动的内容。它包括学生需要执行的详细步骤,以实现活动的目标。

第一章:列表、栈和队列

活动 1:实现歌曲播放列表

在这个活动中,我们将实现一个稍加改进的双向链表的版本,它可以用来存储歌曲播放列表,并支持必要的功能。按照以下步骤完成这个活动:

  1. 让我们首先包括头文件,并编写具有所需数据成员的节点结构:
#include <iostream>
template <typename T>
struct cir_list_node
{
    T* data;
    cir_list_node *next, *prev;

~cir_list_node()
    {
        delete data;
    }
};
template <typename T>
struct cir_list
{
    public:
        using node = cir_list_node<T>;
        using node_ptr = node*;
    private:
        node_ptr head;
        size_t n;
  1. 现在,让我们编写一个基本的构造函数和 size 函数:
public:
cir_list(): n(0)
{
    head = new node{NULL, NULL, NULL};  // Dummy node – having NULL data
    head->next = head;
    head->prev = head;
}
size_t size() const
{
    return n;
}

稍后,在使用迭代器进行迭代时,我们将讨论为什么需要在第一个节点和最后一个节点之间有一个虚拟节点。

  1. 现在,让我们编写inserterase函数。两者都将接受一个要插入或删除的值:
void insert(const T& value)
{
    node_ptr newNode = new node{new T(value), NULL, NULL};
    n++;
auto dummy = head->prev;
dummy->next = newNode;
newNode->prev = dummy;
    if(head == dummy)
    {
        dummy->prev = newNode;
        newNode->next = dummy;
        head = newNode;
        return;
    }
    newNode->next = head;
    head->prev = newNode;
    head = newNode;
}
void erase(const T& value)
{
    auto cur = head, dummy = head->prev;
    while(cur != dummy)
    {
        if(*(cur->data) == value)
        {
            cur->prev->next = cur->next;
            cur->next->prev = cur->prev;
            if(cur == head)
                head = head->next;
            delete cur;
            n--;
            return;
        }
        cur = cur->next;
    }
}
  1. 现在,让我们为所需的迭代器编写一个基本结构,并添加成员来访问实际数据:
struct cir_list_it
{
private:
    node_ptr ptr;
public:
    cir_list_it(node_ptr p) : ptr(p)
    {}

    T& operator*()
    {
        return *(ptr->data);
    }
    node_ptr get()
    {
        return ptr;
    }
  1. 现在,让我们实现迭代器的核心函数——前增量和后增量:
cir_list_it& operator++()
{
    ptr = ptr->next;
    return *this;
}
cir_list_it operator++(int)
{
    cir_list_it it = *this;
    ++(*this);
    return it;    
}
  1. 让我们添加与递减相关的操作,使其双向:
cir_list_it& operator--()
{
    ptr = ptr->prev;
    return *this;
}
cir_list_it operator--(int)
{
    cir_list_it it = *this;
    --(*this);
    return it;
}
  1. 让我们为迭代器实现与相等相关的运算符,这对于基于范围的循环是必不可少的:
friend bool operator==(const cir_list_it& it1, const cir_list_it& it2)
{
    return it1.ptr == it2.ptr;
}
friend bool operator!=(const cir_list_it& it1, const cir_list_it& it2)
{
    return it1.ptr != it2.ptr;
}
};
  1. 现在,让我们编写beginend函数,以及它们的const版本:
cir_list_it begin()
{
    return cir_list_it{head};
}
cir_list_it begin() const
{
    return cir_list_it{head};
}
cir_list_it end()
{
    return cir_list_it{head->prev};
}
cir_list_it end() const
{
    return cir_list_it{head->prev};
}
  1. 让我们编写一个复制构造函数、初始化列表构造函数和析构函数:
cir_list(const cir_list<T>& other): cir_list()
{
// Although, the following will insert the elements in a reverse order, it won't matter in a logical sense since this is a circular list.
    for(const auto& i: other)
        insert(i);
}
cir_list(const std::initializer_list<T>& il): head(NULL), n(0)
{

// Although, the following will insert the elements in a reverse order, it won't matter in a logical sense since this is a circular list.
    for(const auto& i: il)
        insert(i);
}
~cir_list()
{
    while(size())
    {
        erase(head->data);
    }
}
};
  1. 现在,让我们为音乐播放器的播放列表添加一个类,用于我们实际的应用程序。我们将直接存储表示歌曲 ID 的整数,而不是存储歌曲:
struct playlist
{
    cir_list<int> list;
  1. 现在,让我们实现添加和删除歌曲的函数:
void insert(int song)
{
    list.insert(song);
}
void erase(int song)
{
    list.erase(song);
}
  1. 现在,让我们实现打印所有歌曲的函数:
void loopOnce()
{
    for(auto& song: list)
        std::cout << song << " ";
    std::cout << std::endl;
}
};
  1. 让我们编写一个main函数来使用我们音乐播放器的播放列表:
int main()
{
    playlist pl;
    pl.insert(1);
    pl.insert(2);
    std::cout << "Playlist: ";
    pl.loopOnce();
    playlist pl2 = pl;
    pl2.erase(2);
    pl2.insert(3);
    std::cout << "Second playlist: ";
    pl2.loopOnce();
}
  1. 执行此操作后,您应该得到如下输出:
Playlist: 2 1 
Second playlist: 3 1

活动 2:模拟一场纸牌游戏

在这个活动中,我们将模拟一场纸牌游戏,并实现一个高效的数据结构来存储每个玩家的卡牌信息。按照以下步骤完成这个活动:

  1. 首先,让我们包括必要的头文件:
#include <iostream>
#include <vector>
#include <array>
#include <sstream>
#include <algorithm>
#include <random>
#include <chrono>
  1. 现在,让我们创建一个类来存储卡牌,并编写一个实用方法来正确打印它们:
struct card
{
    int number;
    enum suit
    {
        HEART,
        SPADE,
        CLUB,
        DIAMOND
    } suit;
    std::string to_string() const
    {
        std::ostringstream os;
        if(number > 0 && number <= 10)
            os << number;
        else
{
switch(number)
{
case 1:
    os << "Ace";
    break;
    case 11:
        os << "Jack";
        break;
    case 12:
        os << "Queen";
        break;
    case 13:
        os << "King";
        break;
    default:
        return "Invalid card";
}
        }
        os << " of ";
        switch(suit)
        {
            case HEART:
                os << "hearts";
                break;
            case SPADE:
                os << "spades";
                break;
            case CLUB:
                os << "clubs";
                break;
            case DIAMOND:
                os << "diamonds";
                break;            
        }
        return os.str();
    }
};
  1. 现在,我们可以创建一副牌,并洗牌以将牌随机分发给四名玩家。我们将在一个game类中编写这个逻辑,并在main函数中稍后调用这些函数:
struct game
{
    std::array<card, 52> deck;
    std::vector<card> player1, player2, player3, player4;
    void buildDeck()
    {
        for(int i = 0; i < 13; i++)
            deck[i] = card{i + 1, card::HEART};
        for(int i = 0; i < 13; i++)
            deck[i + 13] = card{i + 1, card::SPADE};
        for(int i = 0; i < 13; i++)
            deck[i + 26] = card{i + 1, card::CLUB};
        for(int i = 0; i < 13; i++)
            deck[i + 39] = card{i + 1, card::DIAMOND};
    }
    void dealCards()
    {
        unsigned seed = std::chrono::system_clock::now().time_since_epoch().count();
        std::shuffle(deck.begin(), deck.end(), std::default_random_engine(seed));
        player1 = {deck.begin(), deck.begin() + 13};
player2 = {deck.begin() + 13, deck.begin() + 26};
player3 = {deck.begin() + 26, deck.begin() + 39};
player4 = {deck.begin() + 39, deck.end()};
    }
  1. 让我们编写核心逻辑来进行一轮游戏。为了避免重复代码,我们将编写一个实用函数,用于比较两个玩家的手牌,并在需要时移除两张卡:
bool compareAndRemove(std::vector<card>& p1, std::vector<card>& p2)
{
    if(p1.back().number == p2.back().number)
    {
        p1.pop_back();
        p2.pop_back();
        return true;
    }
    return false;
}
void playOneRound()
{
        if(compareAndRemove(player1, player2))
        {
            compareAndRemove(player3, player4);
            return;
        }
        else if(compareAndRemove(player1, player3))
        {
            compareAndRemove(player2, player4);
            return;
        }
        else if(compareAndRemove(player1, player4))
        {
            compareAndRemove(player2, player3);
            return;
        }
        else if(compareAndRemove(player2, player3))
        {
            return;
        }
        else if(compareAndRemove(player2, player4))
        {
            return;
        }
        else if(compareAndRemove(player3, player4))
        {
return;
        }
        unsigned seed = std::chrono::system_clock::now().time_since_epoch().count();
        std::shuffle(player1.begin(), player1.end(), std::default_random_engine(seed));
        std::shuffle(player2.begin(), player2.end(), std::default_random_engine(seed));
        std::shuffle(player3.begin(), player3.end(), std::default_random_engine(seed));
        std::shuffle(player4.begin(), player4.end(), std::default_random_engine(seed));
}
  1. 现在,让我们编写主要逻辑来找出谁是赢家。我们将在循环中调用前面的函数,直到其中一个玩家能够摆脱所有的卡牌。为了使代码更易读,我们将编写另一个实用函数来检查游戏是否已经完成:
bool isGameComplete() const
{
    return player1.empty() || player2.empty() || player3.empty() || player4.empty();
}
void playGame()
{
        while(not isGameComplete())
        {
            playOneRound();    
        }
}
  1. 为了找出谁是赢家,让我们在开始main函数之前编写一个实用函数:
int getWinner() const
{
    if(player1.empty())
        return 1;
    if(player2.empty())
        return 2;
    if(player3.empty())
        return 3;
    if(player4.empty())
        return 4;
}
};
  1. 最后,让我们编写main函数来执行游戏:
int main()
{
    game newGame;
    newGame.buildDeck();
    newGame.dealCards();
    newGame.playGame();
    auto winner = newGame.getWinner();
    std::cout << "Player " << winner << " won the game." << std::endl;
}
  1. 可能的输出之一如下:
Player 4 won the game.

注意

赢家可能是 1 到 4 号玩家中的任何一个。由于游戏是基于执行期间的时间种子随机性的,任何玩家都有可能获胜。多次运行代码可能会产生不同的输出。

活动 3:模拟办公室共享打印机的队列

在这个活动中,我们将实现一个队列,用于处理办公室中共享打印机的打印请求。按照以下步骤完成这个活动:

  1. 让我们包括所需的头文件:
#include <iostream>
#include <queue>
  1. 让我们实现一个Job类:
class Job
{
    int id;
    std::string user;
    int time;
    static int count;
public:
    Job(const std::string& u, int t) : user(u), time(t), id(++count)
    {}
    friend std::ostream& operator<<(std::ostream& os, const Job& j)
     {
    os << "id: " << id << ", user: " << user << ", time: " << time << " seconds" << std::endl;    return os;
     }
};
int Job::count = 0;
  1. 现在,让我们实现Printer类。我们将使用std::queue来实现先到先服务的jobs策略。我们将基于内存中可以存储的最大作业数来模板化类:
template <size_t N>
class Printer
{
    std::queue<Job> jobs;
public:
    bool addNewJob(const Job& job)
    {
        if(jobs.size() == N)
            return false;
        std::cout << "Added job in the queue: " << job;
        jobs.push(job);
        return true;
    }
  1. 现在,让我们实现另一个重要功能——打印作业:
    void startPrinting()
    {
        while(not jobs.empty())
        {
            std::cout << "Processing job: " << jobs.front();
            jobs.pop();
        }
    }
};
  1. 现在,让我们使用这些类来模拟这种情景:
int main()
{
    Printer<5> printer;
    Job j1("John", 10);
    Job j2("Jerry", 4);
    Job j3("Jimmy", 5);
    Job j4("George", 7);
    Job j5("Bill", 8);
    Job j6("Kenny", 10);
    printer.addNewJob(j1);
    printer.addNewJob(j2);
    printer.addNewJob(j3);
    printer.addNewJob(j4);
    printer.addNewJob(j5);
    if(not printer.addNewJob(j6))  // Can't add as queue is full.
    {
        std::cout << "Couldn't add 6th job" << std::endl;
    }
    printer.startPrinting();

    printer.addNewJob(j6);  // Can add now, as queue got emptied
    printer.startPrinting();
}
  1. 以下是前述代码的输出:
Added job in the queue: id: 1, user: John, time: 10 seconds
Added job in the queue: id: 2, user: Jerry, time: 4 seconds
Added job in the queue: id: 3, user: Jimmy, time: 5 seconds
Added job in the queue: id: 4, user: George, time: 7 seconds
Added job in the queue: id: 5, user: Bill, time: 8 seconds
Couldn't add 6th job
Processing job: id: 1, user: John, time: 10 seconds
Processing job: id: 2, user: Jerry, time: 4 seconds
Processing job: id: 3, user: Jimmy, time: 5 seconds
Processing job: id: 4, user: George, time: 7 seconds
Processing job: id: 5, user: Bill, time: 8 seconds
Added job in the queue: id: 6, user: Kenny, time: 10 seconds
Processing job: id: 6, user: Kenny, time: 10 seconds

第二章:树、堆和图

活动 4:为文件系统创建数据结构

在这个活动中,我们将使用 N 叉树创建一个文件系统的数据结构。按照以下步骤完成这个活动:

  1. 首先,让我们包括所需的头文件:
#include <iostream>
#include <vector>
#include <algorithm>
  1. 现在,让我们编写一个节点来存储目录/文件的数据:
struct n_ary_node
{
    std::string name;
    bool is_dir;
    std::vector<n_ary_node*> children;
};
  1. 现在,让我们将这个节点包装在一个树结构中,以获得良好的接口,并添加一个静态成员,以便我们可以存储当前目录:
struct file_system
{
    using node = n_ary_node;
    using node_ptr = node*;
private:
    node_ptr root;
    node_ptr cwd;
  1. 现在,让我们添加一个构造函数,以便我们可以创建一个带有根目录的树:
public:
    file_system()
    {
        root = new node{"/", true, {}};
        cwd = root;  // We'll keep the current directory as root in the beginning
    }
  1. 现在,让我们添加一个函数来查找目录/文件:
node_ptr find(const std::string& path)
{
    if(path[0] == '/')  // Absolute path
    {
        return find_impl(root, path.substr(1));
    }
    else
    {
        return find_impl(cwd, path);
    }
}
private:
node_ptr find_impl(node_ptr directory, const std::string& path)
{
    if(path.empty())
        return directory;
    auto sep = path.find('/');
    std::string current_path = sep == std::string::npos ? path : path.substr(0, sep);
    std::string rest_path = sep == std::string::npos ? "" : path.substr(sep + 1);
    auto found = std::find_if(directory->children.begin(), directory->children.end(), &
{
    return child->name == current_path;
});
        if(found != directory->children.end())
        {
            return find_impl(*found, rest_path);
        }
    return NULL;
}
  1. 现在,让我们添加一个函数来添加一个目录:
public:
bool add(const std::string& path, bool is_dir)
{
    if(path[0] == '/')
    {
        return add_impl(root, path.substr(1), is_dir);
    }
    else
    {
        return add_impl(cwd, path, is_dir);
    }
}
private:
bool add_impl(node_ptr directory, const std::string& path, bool is_dir)
{
    if(not directory->is_dir)
    {
        std::cout << directory->name << " is a file." << std::endl;
        return false;
    }

auto sep = path.find('/');
// This is the last part of the path for adding directory. It's a base condition of the recursion
    if(sep == std::string::npos)
    {
        auto found = std::find_if(directory->children.begin(), directory->children.end(), &
{
    return child->name == path;
});
if(found != directory->children.end())
{
    std::cout << "There's already a file/directory named " << path << " inside " << directory->name << "." << std::endl;
    return false;
}
directory->children.push_back(new node{path, is_dir, {}});
return true;
    }

    // If the next segment of the path is still a directory
    std::string next_dir = path.substr(0, sep);
    auto found = std::find_if(directory->children.begin(), directory->children.end(), &
{
    return child->name == next_dir && child->is_dir;
});
        if(found != directory->children.end())
        {
            return add_impl(*found, path.substr(sep + 1), is_dir);
        }

std::cout << "There's no directory named " << next_dir << " inside " << directory->name << "." << std::endl;
    return false;
}
  1. 现在,让我们添加一个函数来更改当前目录。这将非常简单,因为我们已经有一个函数来查找路径:
public:
bool change_dir(const std::string& path)
{
    auto found = find(path);
    if(found && found->is_dir)
    {
        cwd = found;
        std::cout << "Current working directory changed to " << cwd->name << "." << std::endl;
        return true;
    }
    std::cout << "Path not found." << std::endl;
    return false;
}
  1. 现在,让我们添加一个函数来打印目录或文件。对于文件,我们只会打印文件的名称。对于目录,我们将打印所有子目录的名称,就像 Linux 中的ls命令一样:
public:
void show_path(const std::string& path)
{
    auto found = find(path);
    if(not found)
    {
        std::cout << "No such path: " << path << "." << std::endl;
        return;
    }
    if(found->is_dir)
    {
        for(auto child: found->children)
        {
std::cout << (child->is_dir ? "d " : "- ") << child->name << std::endl;}
    }
    else
    {
        std::cout << "- " << found->name << std::endl;
    }
}
};
  1. 让我们编写一个主函数,以便我们可以使用上述函数:
int main()
{
    file_system fs;
    fs.add("usr", true);  // Add directory usr in "/"
    fs.add("etc", true);  // Add directory etc in "/"
    fs.add("var", true);  // Add directory var in "/"
    fs.add("tmp_file", false);  // Add file tmp_file in "/"
    std::cout << "Files/Directories under \"/\"" << std::endl;
    fs.show_path("/");  // List files/directories in "/"
    std::cout << std::endl;
    fs.change_dir("usr");
    fs.add("Packt", true);
    fs.add("Packt/Downloads", true);
    fs.add("Packt/Downloads/newFile.cpp", false);
    std::cout << "Let's see the contents of dir usr: " << std::endl;
    fs.show_path("usr");  // This will not print the path successfully, since we're already inside the dir usr. And there's no directory named usr inside it.
    std::cout << "Let's see the contents of \"/usr\"" << std::endl;
    fs.show_path("/usr");
    std::cout << "Let's see the contents of \"/usr/Packt/Downloads\"" << std::endl;
    fs.show_path("/usr/Packt/Downloads");

}

前面代码的输出如下:

Files/Directories under "/"
d usr
d etc
d var
- tmp_file
Current working directory changed to usr.
Let's try to print the contents of usr: 
No such path: usr.
Let's see the contents of "/usr"
d Packt
Contents of "/usr/Packt/Downloads"
- newFile.cpp

活动 5:使用堆进行 K 路合并

在这个活动中,我们将把多个排序的数组合并成一个排序的数组。以下步骤将帮助您完成这个活动:

  1. 从所需的头文件开始:
#include <iostream>
#include <algorithm>
#include <vector>
  1. 现在,实现合并的主要算法。它将以一个 int 向量的向量作为输入,并包含所有排序向量的向量。然后,它将返回合并的 int 向量。首先,让我们构建堆节点:
struct node
{
    int data;
    int listPosition;
    int dataPosition;
};
std::vector<int> merge(const std::vector<std::vector<int>>& input)
{
    auto comparator = [] (const node& left, const node& right)
        {
            if(left.data == right.data)
                return left.listPosition > right.listPosition;
            return left.data > right.data;
        };

正如我们所看到的,堆节点将包含三个东西——数据、输入列表中的位置和该列表中数据项的位置。

  1. 让我们构建堆。想法是创建一个最小堆,其中包含所有列表中的最小元素。因此,当我们从堆中弹出时,我们保证得到最小的元素。删除该元素后,如果可用,我们需要插入相同列表中的下一个元素:
std::vector<node> heap;
for(int i = 0; i < input.size(); i++)
{
    heap.push_back({input[i][0], i, 0});
    std::push_heap(heap.begin(), heap.end(), comparator);
}
  1. 现在,我们将构建结果向量。我们将简单地从堆中删除元素,直到它为空,并用相同列表中的下一个元素(如果可用)替换它:
std::vector<int> result;
while(!heap.empty())
{
    std::pop_heap(heap.begin(), heap.end(), comparator);
    auto min = heap.back();
    heap.pop_back();
    result.push_back(min.data);
    int nextIndex = min.dataPosition + 1;
    if(nextIndex < input[min.listPosition].size())
    {
        heap.push_back({input[min.listPosition][nextIndex], min.listPosition, nextIndex});
        std::push_heap(heap.begin(), heap.end(), comparator);
    }
}
return result;
}
  1. 让我们编写一个main函数,以便我们可以使用前面的函数:
int main()
{
    std::vector<int> v1 = {1, 3, 8, 15, 105};
    std::vector<int> v2 = {2, 3, 10, 11, 16, 20, 25};
    std::vector<int> v3 = {-2, 100, 1000};
    std::vector<int> v4 = {-1, 0, 14, 18};
    auto result = merge({v1, v2, v3, v4});
    for(auto i: result)
    std::cout << i << ' ';
    return 0;
}

您应该看到以下输出:

-2 -1 0 1 2 3 3 8 10 11 14 15 16 18 20 25 100 105 1000 

第三章:哈希表和布隆过滤器

活动 6:将长 URL 映射到短 URL

在这个活动中,我们将创建一个程序,将较短的 URL 映射到相应的较长 URL。按照以下步骤完成这个活动:

  1. 让我们包括所需的头文件:
#include <iostream>
#include <unordered_map>
  1. 让我们编写一个名为URLService的结构,它将提供所需服务的接口:
struct URLService
{
    using ActualURL = std::string;
    using TinyURL = std::string;
private:
    std::unordered_map<TinyURL, ActualURL> data;

正如我们所看到的,我们已经创建了一个从小 URL 到原始 URL 的映射。这是因为我们使用小 URL 进行查找。我们希望将其转换为原始 URL。正如我们之前看到的,映射可以根据键进行快速查找。因此,我们将较小的 URL 保留为映射的键,将原始 URL 保留为映射的值。我们已经创建了别名,以避免混淆,不清楚我们在谈论哪个字符串。

  1. 让我们添加一个“查找”函数:
public:
    std::pair<bool, ActualURL> lookup(const TinyURL& url) const
    {
        auto it = data.find(url);
        if(it == data.end())  // If small URL is not registered.
        {
            return std::make_pair(false, std::string());
        }
        else
        {
            return std::make_pair(true, it->second);
        }
    }
  1. 现在,让我们编写一个函数,为给定的实际 URL 注册较小的 URL:
bool registerURL(const ActualURL& actualURL, const TinyURL& tinyURL)
{
    auto found = lookup(tinyURL).first;
    if(found)
    {
        return false;
    }
    data[tinyURL] = actualURL;
    return true;
}

registerURL函数返回数据中是否已经存在条目。如果是,它将不会触及该条目。否则,它将注册该条目并返回true以指示注册成功。

  1. 现在,让我们编写一个删除条目的函数:
bool deregisterURL(const TinyURL& tinyURL)
{
    auto found = lookup(tinyURL).first;
    if(found)
    {
        data.erase(tinyURL);
        return true;
    }
    return false;
}

正如我们所看到的,我们使用了“查找”函数,而不是重新编写查找逻辑。现在这个函数更易读了。

  1. 现在,让我们编写一个函数来打印所有映射以进行日志记录:
void printURLs() const
{
    for(const auto& entry: data)
    {
        std::cout << entry.first << " -> " << entry.second << std::endl;
    }
    std::cout << std::endl;
}
};
  1. 现在,编写main函数,以便我们可以使用这个服务:
int main()
{
    URLService service;
    if(service.registerURL("https://www.packtpub.com/eu/big-data-and-business-intelligence/machine-learning-r-third-edition", "https://ml-r-v3"))
    {
        std::cout << "Registered https://ml-r-v3" << std::endl;
    }
    else
    {
        std::cout << "Couldn't register https://ml-r-v3" << std::endl;
    }
    if(service.registerURL("https://www.packtpub.com/eu/virtualization-and-cloud/hands-aws-penetration-testing-kali-linux", "https://aws-test-kali"))
    {
        std::cout << "Registered https://aws-test-kali" << std::endl;
    }
    else
    {
        std::cout << "Couldn't register https://aws-test-kali" << std::endl;
    }
    if(service.registerURL("https://www.packtpub.com/eu/application-development/hands-qt-python-developers", "https://qt-python"))
    {
        std::cout << "Registered https://qt-python" << std::endl;
    }
    else
    {
        std::cout << "Couldn't register https://qt-python" << std::endl;
    }

    auto findMLBook = service.lookup("https://ml-r-v3");
    if(findMLBook.first)
    {
        std::cout << "Actual URL: " << findMLBook.second << std::endl;
    }
    else
    {
        std::cout << "Couldn't find URL for book for ML." << std::endl;
    }
    auto findReactBook = service.lookup("https://react-cookbook");
    if(findReactBook.first)
    {
        std::cout << "Actual URL: " << findReactBook.second << std::endl;
    }
    else
    {
        std::cout << "Couldn't find URL for book for React." << std::endl;
    }
    if(service.deregisterURL("https://qt-python"))
    {
        std::cout << "Deregistered qt python link" << std::endl;
    }
    else
    {
        std::cout << "Couldn't deregister qt python link" << std::endl;
    }
    auto findQtBook = service.lookup("https://qt-python");
    if(findQtBook.first)
    {
        std::cout << "Actual URL: " << findQtBook.second << std::endl;
    }
    else
    {
        std::cout << "Couldn't find Qt Python book" << std::endl;
    }
    std::cout << "List of registered URLs: " << std::endl;
    service.printURLs();
}
  1. 让我们看看前面代码的输出:
Registered https://ml-r-v3
Registered https://aws-test-kali
Registered https://qt-python
Actual URL: https://www.packtpub.com/eu/big-data-and-business-intelligence/machine-learning-r-third-edition
Couldn't find URL for book for React.
Deregistered qt python link
Couldn't find Qt Python book
List of registered URLs: 
https://ml-r-v3 -> https://www.packtpub.com/eu/big-data-and-business-intelligence/machine-learning-r-third-edition
https://aws-test-kali -> https://www.packtpub.com/eu/virtualization-and-cloud/hands-aws-penetration-testing-kali-linux

正如我们所看到的,最后得到的是两个有效的 URL,而不是我们成功注销的 URL。

活动 7:电子邮件地址验证器

在这个活动中,我们将创建一个验证器来检查用户请求的电子邮件地址是否已经被使用。按照以下步骤完成活动:

  1. 让我们包括所需的头文件:
#include <iostream>
#include <vector>
#include <openssl/md5.h>
  1. 让我们为 Bloom 过滤器添加一个类:
class BloomFilter
{
    int nHashes;
    std::vector<bool> bits;
    static constexpr int hashSize = 128/8;
    unsigned char hashValue[hashSize];
  1. 让我们为此添加一个构造函数:
BloomFilter(int size, int hashes) : bits(size), nHashes(hashes)
{
    if(nHashes > hashSize)
    {
        throw ("Number of hash functions too high");
    }
    if(size > 255)
    {
        throw ("Size of bloom filter can't be >255");
    }
}

由于我们将使用哈希值缓冲区中的每个字节作为不同的哈希函数值,并且哈希值缓冲区的大小为 16 字节(128 位),我们不能有比这更多的哈希函数。由于每个哈希值只是 1 个字节,它的可能值是0255。因此,Bloom 过滤器的大小不能超过255。因此,我们在构造函数中抛出错误。

  1. 现在,让我们编写一个哈希函数。它简单地使用 MD5 函数来计算哈希:
void hash(const std::string& key)
{
    MD5(reinterpret_cast<const unsigned char*>(key.data()), key.length(), hashValue);
}
  1. 让我们添加一个函数,以便我们可以插入一个电子邮件:
void add(const std::string& key)
{
    hash(key);
    for(auto it = &hashValue[0]; it < &hashValue[nHashes]; it++)
    {
        bits[*it] = true;
    }
    std::cout << key << " added in bloom filter." << std::endl;
}

正如我们所看到的,我们正在从哈希值缓冲区中的字节0迭代到nHashes,并将每个位设置为1

  1. 同样地,让我们添加一个查找电子邮件地址的函数:
bool mayContain(const std::string &key)
    {
        hash(key);
        for (auto it = &hashValue[0]; it < &hashValue[nHashes]; it++)
        {
            if (!bits[*it])
            {
                std::cout << key << " email can by used." << std::endl;
                return false;
            }
        }
        std::cout << key << " email is used by someone else." << std::endl;
        return true;
    }
};
  1. 让我们添加main函数:
int main()
{
    BloomFilter bloom(10, 15);
    bloom.add("abc@packt.com");
    bloom.add("xyz@packt.com");
    bloom.mayContain("abc");
    bloom.mayContain("xyz@packt.com");
    bloom.mayContain("xyz");
    bloom.add("abcd@packt.com");
    bloom.add("ab@packt.com");
    bloom.mayContain("abc");
    bloom.mayContain("ab@packt.com");
}

以下是前面代码的可能输出之一:

abc@packt.com added in bloom filter.
xyz@packt.com added in bloom filter.
abc email can by used.
xyz@packt.com email is used by someone else.
xyz email can by used.
abcd@packt.com added in bloom filter.
ab@packt.com added in bloom filter.
abcd email can by used.
ab@packt.com email is used by someone else.

这是可能的输出之一,因为 MD5 是一个随机算法。如果我们以周到的方式选择函数的数量和 Bloom 过滤器的大小,我们应该能够通过 MD5 算法获得非常好的准确性。

第四章:分而治之

活动 8:疫苗接种

在这个活动中,我们将存储和查找学生的疫苗接种状态,以确定他们是否需要接种疫苗。这些步骤应该帮助您完成这个活动:

  1. 首先包括以下头文件:
#include <iostream>
#include <vector>
#include <chrono>
#include <random>
#include <algorithm>
#include <numeric>
  1. 按照以下方式定义Student类:
class Student
{
private:
    std::pair<int, int> name;
    bool vaccinated;
public:
    // Constructor
    Student(std::pair<int, int> n, bool v) :
        name(n), vaccinated(v)
    {}
    // Getters
    auto get_name() { return name; }
    auto is_vaccinated() { return vaccinated; }
    // Two people are same if they have the same name
    bool operator ==(const Student& p) const
    {
        return this->name == p.name;
    }
    // The ordering of a set of people is defined by their name
    bool operator< (const Student& p) const
    {
        return this->name < p.name;
    }
    bool operator> (const Student& p) const
    {
        return this->name > p.name;
    }
};
  1. 以下函数让我们从随机数据生成一个学生:
auto generate_random_Student(int max)
{
    std::random_device rd;
    std::mt19937 rand(rd());
    // the IDs of Student should be in range [1, max]
    std::uniform_int_distribution<std::mt19937::result_type> uniform_dist(1, max);
    // Generate random credentials
    auto random_name = std::make_pair(uniform_dist(rand), uniform_dist(rand));
    bool is_vaccinated = uniform_dist(rand) % 2 ? true : false;
    return Student(random_name, is_vaccinated);
}
  1. 以下代码用于运行和测试我们实现的输出:
 void search_test(int size, Student p)
{
    std::vector<Student> people;
    // Create a list of random people
    for (auto i = 0; i < size; i++)
        people.push_back(generate_random_Student(size));
    std::sort(people.begin(), people.end());
    // To measure the time taken, start the clock
    std::chrono::steady_clock::time_point begin = std::chrono::steady_clock::now();
    bool search_result = needs_vaccination(p, people);
    // Stop the clock
    std::chrono::steady_clock::time_point end = std::chrono::steady_clock::now();
    std::cout << "Time taken to search = " <<
        std::chrono::duration_cast<std::chrono::microseconds>
        (end - begin).count() << " microseconds" << std::endl;
    if (search_result)
        std::cout << "Student (" << p.get_name().first 
<< " " << p.get_name().second << ") "
            << "needs vaccination." << std::endl;
    else
        std::cout << "Student (" << p.get_name().first 
<< " " << p.get_name().second << ") "
            << "does not need vaccination." << std::endl;
}
  1. 以下函数实现了我们是否需要接种疫苗的逻辑:
bool needs_vaccination(Student P, std::vector<Student>& people)
{
    auto first = people.begin();
    auto last = people.end();
    while (true)
    {
        auto range_length = std::distance(first, last);
        auto mid_element_index = std::floor(range_length / 2);
        auto mid_element = *(first + mid_element_index);
        // Return true if the Student is found in the sequence and 
// he/she's not vaccinated 
        if (mid_element == P && mid_element.is_vaccinated() == false)
            return true;
        else if (mid_element == P && mid_element.is_vaccinated() == true)
            return false;
        else if (mid_element > P)
            std::advance(last, -mid_element_index);
        if (mid_element < P)
            std::advance(first, mid_element_index);
        // Student not found in the sequence and therefore should be vaccinated
        if (range_length == 1)
            return true;
    }
}
  1. 最后,驱动代码的实现如下:
int main()
{
    // Generate a Student to search
    auto p = generate_random_Student(1000);
    search_test(1000, p);
    search_test(10000, p);
    search_test(100000, p);
    return 0;
}

注意

由于我们在步骤 3中随机化值,因此您的输出可能与此活动所示的预期输出不同。

活动 9:部分排序

部分快速排序只是原始快速排序算法的轻微修改,该算法在练习 20快速排序中有所展示。与该练习相比,只有步骤 4不同。以下是一个参考实现:

  1. 添加以下头文件:
#include <iostream>
#include <vector>
#include <chrono>
#include <random>
#include <algorithm>
  1. 接下来,我们将按照以下方式实现分区操作:
 template <typename T>
auto partition(typename std::vector<T>::iterator begin,
    typename std::vector<T>::iterator end)
{
    auto pivot_val = *begin;
    auto left_iter = begin + 1;
    auto right_iter = end;
    while (true)
    {
        // Starting from the first element of vector, 
        // find an element that is greater than pivot.
        while (*left_iter <= pivot_val && std::distance(left_iter, right_iter) > 0)
            left_iter++;
        // Starting from the end of vector moving to the beginning, 
        // find an element that is lesser than the pivot.
        while (*right_iter > pivot_val && std::distance(left_iter, right_iter) > 0)
            right_iter--;
        // If left and right iterators meet, there are no elements left to swap. 
        // Else, swap the elements pointed to by the left and right iterators
        if (left_iter == right_iter)
            break;
        else
            std::iter_swap(left_iter, right_iter);
    }
    if (pivot_val > *right_iter)
        std::iter_swap(begin, right_iter);
    return right_iter;
}
  1. 由于期望的输出还需要快速排序算法的实现,我们将按以下方式实现一个:
 template <typename T>
void quick_sort(typename std::vector<T>::iterator begin,
    typename std::vector<T>::iterator last)
{
    // If there are more than 1 elements in the vector
    if (std::distance(begin, last) >= 1)
    {
        // Apply the partition operation
        auto partition_iter = partition<T>(begin, last);
        // Recursively sort the vectors created by the partition operation
        quick_sort<T>(begin, partition_iter-1);
        quick_sort<T>(partition_iter, last);
    }
}
  1. 按照以下方式实现部分快速排序函数:
 template <typename T>
void partial_quick_sort(typename std::vector<T>::iterator begin,
    typename std::vector<T>::iterator last,
    size_t k)
{
    // If there are more than 1 elements in the vector
    if (std::distance(begin, last) >= 1)
    {
        // Apply the partition operation
        auto partition_iter = partition<T>(begin, last);
        // Recursively sort the vectors created by the partition operation
        partial_quick_sort<T>(begin, partition_iter-1, k);

        // Sort the right subvector only if the final position of pivot < k 
        if(std::distance(begin, partition_iter) < k)
            partial_quick_sort<T>(partition_iter, last, k);
    }
}
  1. 接下来的辅助函数可以用来打印向量的内容和生成一个随机向量:
 template <typename T>
void print_vector(std::vector<T> arr)
{
    for (auto i : arr)
        std::cout << i << " ";
    std::cout << std::endl;
}
// Generates random vector of a given size with integers [1, size]
template <typename T>
auto generate_random_vector(T size)
{
    std::vector<T> V;
    V.reserve(size);
    std::random_device rd;
    std::mt19937 rand(rd());
    // the IDs of Student should be in range [1, max]
    std::uniform_int_distribution<std::mt19937::result_type> uniform_dist(1, size);
    for (T i = 0; i < size; i++)
        V.push_back(uniform_dist(rand));
    return std::move(V);
}
  1. 以下函数实现了我们排序函数的测试逻辑:
// Sort the first K elements of a random vector of a given 'size'
template <typename T>
void test_partial_quicksort(size_t size, size_t k)
{
        // Create two copies of a random vector to use for the two algorithms
        auto random_vec = generate_random_vector<T>(size);
        auto random_vec_copy(random_vec);
        std::cout << "Original vector: "<<std::endl;
        print_vector<T>(random_vec); 

        // Measure the time taken by partial quick sort
        std::chrono::steady_clock::time_point 
begin_qsort = std::chrono::steady_clock::now();
        partial_quick_sort<T>(random_vec.begin(), random_vec.end()-1, k);
        std::chrono::steady_clock::time_point 
end_qsort = std::chrono::steady_clock::now();

        std::cout << std::endl << "Time taken by partial quick sort = " 
            << 'std::chrono::duration_cast<std::chrono::microseconds>
            (end_qsort - begin_qsort).count() 
            << " microseconds" << std::endl;

        std::cout << "Partially sorted vector (only first "<< k <<" elements):";
        print_vector<T>(random_vec);
        // Measure the time taken by partial quick sort
        begin_qsort = std::chrono::steady_clock::now();
        quick_sort<T>(random_vec_copy.begin(), random_vec_copy.end()-1);
        end_qsort = std::chrono::steady_clock::now();
        std::cout << std::endl <<"Time taken by full quick sort = " 
            << std::chrono::duration_cast<std::chrono::microseconds>
            (end_qsort - begin_qsort).count() 
            << " microseconds" << std::endl;

        std::cout << "Fully sorted vector: ";
        print_vector<T>(random_vec_copy);
}
  1. 最后,按照以下方式添加驱动代码:
 int main()
{
    test_partial_quicksort<unsigned>(100, 10);
    return 0;
}

活动 10:在 MapReduce 中实现 WordCount

在这个活动中,我们将实现 MapReduce 模型来解决 WordCount 问题。以下是这个活动的解决方案:

  1. 按照以下方式实现映射任务:
struct map_task : public mapreduce::map_task<
    std::string,                             // MapKey (filename)
    std::pair<char const*, std::uintmax_t>>  // MapValue (memory mapped file contents)
{
    template<typename Runtime>
    void operator()(Runtime& runtime, key_type const& key, value_type& value) const
    {
        bool in_word = false;
        char const* ptr = value.first;
        char const* end = ptr + value.second;
        char const* word = ptr;
        // Iterate over the contents of the file, extract words and emit a <word,1> pair.
        for (; ptr != end; ++ptr)
        {
            // Convert the character to upper case.
            char const ch = std::toupper(*ptr, std::locale::classic());
            if (in_word)
            {
                if ((ch < 'A' || ch > 'Z') && ch != '\'')
                {
runtime.emit_intermediate(std::pair<char const*,
              std::uintmax_t> (word, ptr - word), 1);
                    in_word = false;
                }
            }
            else if (ch >= 'A' && ch <= 'Z')
            {
                word = ptr;
                in_word = true;
            }
        }
        // Handle the last word.
        if (in_word)
        {
            assert(ptr > word);
            runtime.emit_intermediate(std::pair<char const*,
                          std::uintmax_t>(word, ptr - word), 1);
        }
    }
};

前面的映射函数分别应用于输入目录中的每个文件。输入文件的内容被接受为*字符中的value。然后内部循环遍历文件的内容,提取不同的单词并发出< key, value >对,其中key是一个单词,value设置为1

  1. 按照以下方式实现减少任务:
template<typename KeyType>
struct reduce_task : public mapreduce::reduce_task<KeyType, unsigned>
{
    using typename mapreduce::reduce_task<KeyType, unsigned>::key_type;
    template<typename Runtime, typename It>
    void operator()(Runtime& runtime, key_type const& key, It it, It const ite) const
    {
        runtime.emit(key, std::accumulate(it, ite, 0));    
}
}; 

然后可以将减少操作应用于映射函数发出的所有< key, value >对。由于在上一步中值被设置为1,我们现在可以使用std::accumulate()来获得减少操作的输入对中键出现的总次数。

第五章:贪婪算法

活动 11:区间调度问题

在这个活动中,我们将找到任务的最佳调度,以最大化可以完成的任务数量。按照以下步骤完成这个活动:

  1. 添加所需的头文件并按以下方式定义Task结构:
#include <list>
#include <algorithm>
#include <iostream>
#include <random>
// Every task is represented as a pair <start_time, end_time>
struct Task
{
    unsigned ID;
    unsigned start_time;
    unsigned end_time;
};
  1. 以下函数可用于生成具有随机数据的N个任务列表:
auto initialize_tasks(size_t num_tasks)
{
    std::random_device rd;
    std::mt19937 rand(rd());
    std::uniform_int_distribution<std::mt19937::result_type> 
uniform_dist(1, num_tasks);
    // Create and initialize a set of tasks
    std::list<Task> tasks;
    for (unsigned i = 1; i <= num_tasks; i++)
    {
        auto start_time = uniform_dist(rand);
        auto duration = uniform_dist(rand);
        tasks.push_back({i, start_time, start_time + duration });
    }
    return tasks;
}
  1. 实现调度算法如下:
auto schedule(std::list<Task> tasks)
{
    // Sort the list of tasks by their end times
    tasks.sort([](const auto& lhs, const auto& rhs)
        { return lhs.end_time < rhs.end_time; });
    // Remove the tasks that interfere with one another
    for (auto curr_task = tasks.begin(); curr_task != tasks.end(); curr_task++)
    {
        // Point to the next task
        auto next_task = std::next(curr_task, 1);
        // While subsequent tasks interfere with the current task in iter
        while (next_task != tasks.end() &&
            next_task->start_time < curr_task->end_time)
        {
            next_task = tasks.erase(next_task);
        }
    }
    return tasks;
}
  1. 以下实用函数用于打印任务列表,测试我们的实现,并包括程序的驱动代码:
void print(std::list<Task>& tasks)
{
    std::cout << "Task ID \t Starting Time \t End time" << std::endl;
    for (auto t : tasks)
        std::cout << t.ID << "\t\t" << t.start_time << "\t\t" << t.end_time << std::endl;
}
void test_interval_scheduling(unsigned num_tasks)
{
    auto tasks = initialize_tasks(num_tasks);
    std::cout << "Original list of tasks: " << std::endl;
    print(tasks);
    std::cout << "Scheduled tasks: " << std::endl;
    auto scheduled_tasks = schedule(tasks);
    print(scheduled_tasks);
}
int main()
{
    test_interval_scheduling(20);
    return 0;
}

活动 12:Welsh-Powell 算法

我们将在这个活动中实现 Welsh-Powell 算法。这里提供了一个参考实现:

  1. 添加所需的头文件并声明稍后将实现的图:
#include <unordered_map>
#include <set>
#include <map>
#include <string>
#include <vector>
#include <algorithm>
#include <iostream>
template <typename T> class Graph;
  1. 实现表示边的结构如下:
template<typename T>
struct Edge
{
    size_t src;
    size_t dest;
    T weight;
    // To compare edges, only compare their weights,
    // and not the source/destination vertices
    inline bool operator< (const Edge<T>& e) const
    {
        return this->weight < e.weight;
    }
    inline bool operator> (const Edge<T>& e) const
    {
        return this->weight > e.weight;
    }
};
  1. 以下函数允许我们通过重载图数据类型的<<运算符来序列化和打印图:
template <typename T>
std::ostream& operator<<(std::ostream& os, const Graph<T>& G)
{
    for (auto i = 1; i < G.vertices(); i++)
    {
        os << i << ":\t";
        auto edges = G.outgoing_edges(i);
        for (auto& e : edges)
            os << "{" << e.dest << ": " << e.weight << "}, ";
        os << std::endl;
    }
    return os;
}
  1. 实现边缘列表表示的图,如下所示:
template<typename T>
class Graph
{
public:
    // Initialize the graph with N vertices
    Graph(size_t N) : V(N)
    {}
    // Return number of vertices in the graph
    auto vertices() const
    {
        return V;
    }
    // Return all edges in the graph
    auto& edges() const
    {
        return edge_list;
    }
    void add_edge(Edge<T>&& e)
    {
        // Check if the source and destination vertices are within range
        if (e.src >= 1 && e.src <= V &&
            e.dest >= 1 && e.dest <= V)
            edge_list.emplace_back(e);
        else
            std::cerr << "Vertex out of bounds" << std::endl;
    }
    // Returns all outgoing edges from vertex v
    auto outgoing_edges(size_t v) const
    {
        std::vector<Edge<T>> edges_from_v;
        for (auto& e : edge_list)
        {
            if (e.src == v)
                edges_from_v.emplace_back(e);
        }
        return edges_from_v;
    }
    // Overloads the << operator so a graph be written directly to a stream
    // Can be used as std::cout << obj << std::endl;
    template <typename T>
    friend std::ostream& operator<< <>(std::ostream& os, const Graph<T>& G);
private:
    size_t V;        // Stores number of vertices in graph
    std::vector<Edge<T>> edge_list;
};
  1. 初始化我们将在 Welsh-Powell 算法实现中使用的颜色集。让颜色数为6,如下unordered_map中实现的那样:
// Initialize the colors that will be used to color the vertices
std::unordered_map<size_t, std::string> color_map = {
    {1, "Red"},
    {2, "Blue"},
    {3, "Green"},
    {4, "Yellow"},
    {5, "Black"},
    {6, "White"}
};
  1. 实现 Welsh-Powell 图着色算法如下:
template<typename T>
auto welsh_powell_coloring(const Graph<T>& G)
{
    auto size = G.vertices();
    std::vector<std::pair<size_t, size_t>> degrees;
    // Collect the degrees of vertices as <vertex_ID, degree> pairs
    for (auto i = 1; i < size; i++)
        degrees.push_back(std::make_pair(i, G.outgoing_edges(i).size()));
    // Sort the vertices in decreasing order of degree
    std::sort(degrees.begin(),
        degrees.end(),
        [](const auto& a, const auto& b)
        { return a.second > b.second; });
    std::cout << "The vertices will be colored in the following order: " << std::endl;
    std::cout << "Vertex ID \t Degree" << std::endl;
    for (auto const i : degrees)
        std::cout << i.first << "\t\t" << i.second << std::endl;
    std::vector<size_t> assigned_colors(size);
    auto color_to_be_assigned = 1;
    while (true)
    {
        for (auto const i : degrees)
        {
            if (assigned_colors[i.first] != 0)
                continue;
            auto outgoing_edges = G.outgoing_edges(i.first);
            std::set<size_t> neighbour_colors;
            // We assume that the graph is bidirectional
            for (auto e : outgoing_edges)
            {
                auto dest_color = assigned_colors[e.dest];
                neighbour_colors.insert(dest_color);
            }
if (neighbour_colors.find(color_to_be_assigned) == neighbour_colors.end())
                assigned_colors[i.first] = color_to_be_assigned;
        }
        color_to_be_assigned++;
        // If there are no uncolored vertices left, exit
        if (std::find(assigned_colors.begin() + 1, assigned_colors.end(), 0) ==
            assigned_colors.end())
            break;
    }
    return assigned_colors;
}
  1. 以下函数输出颜色向量:
void print_colors(std::vector<size_t>& colors)
{
    for (auto i = 1; i < colors.size(); i++)
    {
        std::cout << i << ": " << color_map[colors[i]] << std::endl;
    }
}
  1. 最后,以下驱动代码创建所需的图,运行顶点着色算法,并输出结果:
int main()
{
    using T = unsigned;
    Graph<T> G(9);
    std::map<unsigned, std::vector<std::pair<size_t, T>>> edges;
    edges[1] = { {2, 2}, {5, 3} };
    edges[2] = { {1, 2}, {5, 5}, {4, 1} };
    edges[3] = { {4, 2}, {7, 3} };
    edges[4] = { {2, 1}, {3, 2}, {5, 2}, {6, 4}, {8, 5} };
    edges[5] = { {1, 3}, {2, 5}, {4, 2}, {8, 3} };
    edges[6] = { {4, 4}, {7, 4}, {8, 1} };
    edges[7] = { {3, 3}, {6, 4} };
    edges[8] = { {4, 5}, {5, 3}, {6, 1} };
    for (auto& i : edges)
        for (auto& j : i.second)
            G.add_edge(Edge<T>{ i.first, j.first, j.second });
    std::cout << "Original Graph" << std::endl;
    std::cout << G;
    auto colors = welsh_powell_coloring<T>(G);
    std::cout << "Vertex Colors: " << std::endl;
    print_colors(colors);
    return 0;
}

第六章:图算法 I

活动 13:使用 DFS 找出图是否为二分图

在这个活动中,我们将使用深度优先搜索遍历来检查图是否是二分图。按照以下步骤完成活动:

  1. 添加所需的头文件并声明要使用的图:
#include <string>
#include <vector>
#include <iostream>
#include <set>
#include <map>
#include <stack>
template<typename T> class Graph;
  1. 编写以下结构以定义图中的边:
template<typename T>
struct Edge
{
    size_t src;
    size_t dest;
    T weight;
    // To compare edges, only compare their weights,
    // and not the source/destination vertices
    inline bool operator< (const Edge<T>& e) const
    {
        return this->weight < e.weight;
    }
    inline bool operator> (const Edge<T>& e) const
    {
        return this->weight > e.weight;
    }
};
  1. 使用以下函数重载图的<<运算符,以便将其写入标准输出:
template <typename T>
std::ostream& operator<<(std::ostream& os, const Graph<T>& G)
{
    for (auto i = 1; i < G.vertices(); i++)
    {
        os << i << ":\t";
        auto edges = G.outgoing_edges(i);
        for (auto& e : edges)
            os << "{" << e.dest << ": " << e.weight << "}, ";
        os << std::endl;
    }
    return os;
}
  1. 如下所示实现边缘列表图:
template<typename T>
class Graph
{
public:
    // Initialize the graph with N vertices
    Graph(size_t N) : V(N)
    {}
    // Return number of vertices in the graph
    auto vertices() const
    {
        return V;
    }
    // Return all edges in the graph
    auto& edges() const
    {
        return edge_list;
    }
    void add_edge(Edge<T>&& e)
    {
        // Check if the source and destination vertices are within range
        if (e.src >= 1 && e.src <= V &&
            e.dest >= 1 && e.dest <= V)
            edge_list.emplace_back(e);
        else
            std::cerr << "Vertex out of bounds" << std::endl;
    }
    // Returns all outgoing edges from vertex v
    auto outgoing_edges(size_t v) const
    {
        std::vector<Edge<T>> edges_from_v;
        for (auto& e : edge_list)
        {
            if (e.src == v)
                edges_from_v.emplace_back(e);
        }
        return edges_from_v;
    }
    // Overloads the << operator so a graph be written directly to a stream
    // Can be used as std::cout << obj << std::endl;
    template <typename T>
    friend std::ostream& operator<< <>(std::ostream& os, const Graph<T>& G);
private:
    size_t V;        // Stores number of vertices in graph
    std::vector<Edge<T>> edge_list;
};
  1. 创建图 6.17中显示的图,如下所示:
template <typename T>
auto create_bipartite_reference_graph()
{
    Graph<T> G(10);
    std::map<unsigned, std::vector<std::pair<size_t, T>>> edges;
    edges[1] = { {2, 0} };
    edges[2] = { {1, 0}, {3, 0} , {8, 0} };
    edges[3] = { {2, 0}, {4, 0} };
    edges[4] = { {3, 0}, {6, 0} };
    edges[5] = { {7, 0}, {9, 0} };
    edges[6] = { {1, 0}, {4, 0} };
    edges[7] = { {5, 0} };
    edges[8] = { {2,0}, {9, 0} };
    edges[9] = { {5, 0} };
    for (auto& i : edges)
        for (auto& j : i.second)
            G.add_edge(Edge<T>{ i.first, j.first, j.second });
    return G;
}
  1. 现在,我们需要一个函数,以便我们可以实现我们的算法并检查图是否是二分图。编写以下函数:
template <typename T>
auto bipartite_check(const Graph<T>& G)
{
    std::stack<size_t> stack;
    std::set<size_t> visited;
    stack.push(1); // Assume that BFS always starts from vertex ID 1
    enum class colors {NONE, RED, BLUE};
    colors current_color{colors::BLUE}; // This variable tracks the color to be assigned to the next vertex that is visited.
    std::vector<colors> vertex_colors(G.vertices(), colors::NONE);
    while (!stack.empty())
    {
        auto current_vertex = stack.top();
        stack.pop();
        // If the current vertex hasn't been visited in the past
        if (visited.find(current_vertex) == visited.end())
        {
            visited.insert(current_vertex);
            vertex_colors[current_vertex] = current_color;
            if (current_color == colors::RED)
            {
std::cout << "Coloring vertex " << current_vertex << " RED" << std::endl;
                current_color = colors::BLUE;
            }
            else
            {
                std::cout << "Coloring vertex " 
<< current_vertex << " BLUE" << std::endl;
                current_color = colors::RED;
            }
            // Add unvisited adjacent vertices to the stack.
            for (auto e : G.outgoing_edges(current_vertex))
                if (visited.find(e.dest) == visited.end())
                    stack.push(e.dest);
        }
        // If the found vertex is already colored and 
        // has a color same as its parent's color, the graph is not bipartite
        else if (visited.find(current_vertex) != visited.end() && 
            ((vertex_colors[current_vertex] == colors::BLUE && 
                current_color == colors::RED) ||
            (vertex_colors[current_vertex] == colors::RED && 
                current_color == colors::BLUE)))
            return false;
    }
    // If all vertices have been colored, the graph is bipartite
    return true;
}
  1. 使用以下函数来实现测试和驱动代码,测试我们对二分图检查算法的实现:
template <typename T>
void test_bipartite()
{
    // Create an instance of and print the graph
    auto BG = create_bipartite_reference_graph<T>();
    std::cout << BG << std::endl;
    if (bipartite_check<T>(BG))
        std::cout << "The graph is bipartite" << std::endl;
    else
        std::cout << "The graph is not bipartite" << std::endl;
}
int main()
{
    using T = unsigned;
    test_bipartite<T>();
    return 0;
}
  1. 运行程序。您应该看到以下输出:图 6.34:活动 13 的输出
图 6.34:活动 13 的输出

活动 14:纽约的最短路径

在这个活动中,我们将使用纽约市各个地点的图,并找到两个给定顶点之间的最短距离。按照以下步骤完成活动:

  1. 添加所需的头文件并声明图,如下所示:
#include <string>
#include <vector>
#include <iostream>
#include <set>
#include <map>
#include <limits>
#include <queue>
#include <fstream>
#include <sstream>
template<typename T> class Graph;
  1. 实现将在图中使用的加权边:
template<typename T>
struct Edge
{
    size_t src;
    size_t dest;
    T weight;
    // To compare edges, only compare their weights,
    // and not the source/destination vertices
    inline bool operator< (const Edge<T>& e) const
    {
        return this->weight < e.weight;
    }
    inline bool operator> (const Edge<T>& e) const
    {
        return this->weight > e.weight;
    }
};
  1. 重载Graph类的<<运算符,以便将其输出到 C++流中:
template <typename T>
std::ostream& operator<<(std::ostream& os, const Graph<T>& G)
{
    for (auto i = 1; i < G.vertices(); i++)
    {
        os << i << ":\t";
        auto edges = G.outgoing_edges(i);
        for (auto& e : edges)
            os << "{" << e.dest << ": " << e.weight << "}, ";
        os << std::endl;
    }
    return os;
}
  1. 实现边缘列表图,如下所示:
template<typename T>
class Graph
{
public:
    // Initialize the graph with N vertices
    Graph(size_t N) : V(N)
    {}
    // Return number of vertices in the graph
    auto vertices() const
    {
        return V;
    }
    // Return all edges in the graph
    auto& edges() const
    {
        return edge_list;
    }
    void add_edge(Edge<T>&& e)
    {
        // Check if the source and destination vertices are within range
        if (e.src >= 1 && e.src <= V &&
            e.dest >= 1 && e.dest <= V)
            edge_list.emplace_back(e);
        else
            std::cerr << "Vertex out of bounds" << std::endl;
    }
    // Returns all outgoing edges from vertex v
    auto outgoing_edges(size_t v) const
    {
        std::vector<Edge<T>> edges_from_v;
        for (auto& e : edge_list)
        {
            if (e.src == v)
                edges_from_v.emplace_back(e);
        }
        return edges_from_v;
    }
    // Overloads the << operator so a graph be written directly to a stream
    // Can be used as std::cout << obj << std::endl;
    template <typename T>
    friend std::ostream& operator<< <>(std::ostream& os, const Graph<T>& G);
private:
    size_t V;        // Stores number of vertices in graph
    std::vector<Edge<T>> edge_list;
};
  1. 编写以下函数,以便您可以解析图文件并准备图:
template <typename T>
auto read_graph_from_file()
{
    std::ifstream infile("USA-road-d.NY.gr");
    size_t num_vertices, num_edges;
    std::string line;

    // Read the problem description line that starts with 'p' and looks like:
    // p <num_vertices> <num_edges>
    while (std::getline(infile, line))
    {
        if (line[0] == 'p')
        {
            std::istringstream iss(line);
            char p;
            std::string sp;
            iss >> p >>sp >> num_vertices >> num_edges; 
            std::cout << "Num vertices: " << num_vertices 
<< " Num edges: " << num_edges <<std::endl;
            break;
        }
    }
    Graph<T> G(num_vertices + 1);
    // Read the edges and edge weights, which look like:
    // a <source_vertex> <destination_vertex> <weight>
    while (std::getline(infile, line))
    {
        if (line[0] == 'a')
        {
            std::istringstream iss(line);
            char p;
            size_t source_vertex, dest_vertex;
            T weight;
            iss >> p >> source_vertex >> dest_vertex >> weight;
            G.add_edge(Edge<T>{source_vertex, dest_vertex, weight});
        }
    }
    infile.close();
    return G;
}
  1. 现在,我们需要一个实现Label结构的结构,该结构将分配给 Dijkstra 算法运行时的每个顶点。实现如下:
template<typename T>
struct Label
{
    size_t vertex_ID;
    T distance_from_source;
    Label(size_t _id, T _distance) :
        vertex_ID(_id),
        distance_from_source(_distance)
    {}
    // To compare labels, only compare their distances from source
    inline bool operator< (const Label<T>& l) const
    {
        return this->distance_from_source < l.distance_from_source;
    }
    inline bool operator> (const Label<T>& l) const
    {
        return this->distance_from_source > l.distance_from_source;
    }
    inline bool operator() (const Label<T>& l) const
    {
        return this > l;
    }
};
  1. Dijkstra 算法可以实现如下:
template <typename T>
auto dijkstra_shortest_path(const Graph<T>& G, size_t src, size_t dest)
{
    std::priority_queue<Label<T>, std::vector<Label<T>>, std::greater<Label<T>>> heap;
    std::set<int> visited;
    std::vector<size_t> parent(G.vertices());
    std::vector<T> distance(G.vertices(), std::numeric_limits<T>::max());
    std::vector<size_t> shortest_path;
    heap.emplace(src, 0);
    parent[src] = src;
    // Search for the destination vertex in the graph
    while (!heap.empty()) {
        auto current_vertex = heap.top();
        heap.pop();
        // If the search has reached the destination vertex
        if (current_vertex.vertex_ID == dest) {
            std::cout << "Destination " << 
current_vertex.vertex_ID << " reached." << std::endl;
            break;
        }
        if (visited.find(current_vertex.vertex_ID) == visited.end()) {
            std::cout << "Settling vertex " << 
current_vertex.vertex_ID << std::endl;
            // For each outgoing edge from the current vertex, 
            // create a label for the destination vertex and add it to the heap
            for (auto e : G.outgoing_edges(current_vertex.vertex_ID)) {
                auto neighbor_vertex_ID = e.dest;
                auto new_distance_to_dest=current_vertex.distance_from_source 
+ e.weight;
                // Check if the new path to the destination vertex 
// has a lower cost than any previous paths found to it, if // yes, then this path should be preferred 
                if (new_distance_to_dest < distance[neighbor_vertex_ID]) {
                    heap.emplace(neighbor_vertex_ID, new_distance_to_dest);
                    parent[e.dest] = current_vertex.vertex_ID;
                    distance[e.dest] = new_distance_to_dest;
                }
            }
            visited.insert(current_vertex.vertex_ID);
        }
    }
    // Construct the path from source to the destination by backtracking 
    // using the parent indexes
    auto current_vertex = dest;
    while (current_vertex != src) {
        shortest_path.push_back(current_vertex);
        current_vertex = parent[current_vertex];
    }
    shortest_path.push_back(src);
    std::reverse(shortest_path.begin(), shortest_path.end());
    return shortest_path;
}
  1. 最后,实现测试和驱动代码,如下所示:
template<typename T>
void test_dijkstra()
{
    auto G = read_graph_from_file<T>();
    //std::cout << G << std::endl;
    auto shortest_path = dijkstra_shortest_path<T>(G, 913, 542);
    std::cout << "The shortest path between 913 and 542 is:" << std::endl;
    for (auto v : shortest_path)
        std::cout << v << " ";
    std::cout << std::endl;
}
int main()
{
    using T = unsigned;
    test_dijkstra<T>();
    return 0;
}
  1. 运行程序。您的输出应如下所示:

图 6.35:活动 14 的输出

图 6.35:活动 14 的输出

第七章:图算法 II

活动 15:贪婪机器人

我们可以使用练习 33中的确切算法来解决这个活动,实现贝尔曼-福特算法(第二部分)。这里潜在的陷阱与正确解释所需任务和在实际尝试解决的问题的上下文中表示图有关。让我们开始吧:

  1. 第一步将与练习相同。我们将包括相同的头文件并定义一个Edge结构和一个UNKNOWN常量:
#include <iostream>
#include <vector>
#include <climits>
using namespace std;
struct Edge
{
        int start;
        int end;   
        int weight;
        Edge(int s, int e, int w) : start(s), end(e), weight(w) {}
};
const int UNKNOWN = INT_MAX;
vector<Edge*> edges;
  1. main()中,我们将声明一个整数N,它确定网格的高度/宽度。然后我们将在for循环中从 0 到 N * N - 1 进行迭代,并读取输入中给定的邻接数据:
int main()
{
    int N;
    cin >> N;
    for(int i = 0; i < N * N - 1; i++)
    {
        string directions;
        int power;

        cin >> directions >> power;

        ……
    }
    return 0;
}
  1. 现在,我们必须面对第一个潜在的问题——准确表示邻接关系。通常,我们会倾向于将二维网格表示为一个网格,虽然这种方式当然可以解决问题,但对于这个特定的问题来说并不是最佳的方法。为了重新解释一维中的网格和邻接关系,我们只需观察一维索引i与相应的二维网格坐标之间的关系:
CURRENT CELL: (x, y) —> i
NORTH: (x, y - 1) —> i - N
SOUTH: (x, y + 1) —> i + N
EAST: (x + 1, y) —> i + 1
WEST: (x - 1, y) —> i - 1 
  1. 我们可以通过迭代directions的字符并在switch语句中包含逻辑来处理这些关系:
for(int i = 0; i < N * N - 1; i++)
{
    string directions;
    int power;
    cin >> directions >> power;
    int next;
    for(auto d : directions)
    {
        switch(d)
        {
            case 'N': next = i - N; break;
            case 'S': next = i + N; break;
            case 'E': next = i + 1; break;
            case 'W': next = i - 1; break;
        }
        ……
    }
}
  1. 这导致了这个活动的第二个问题方面;即power值的解释。当然,这些值将是定义相邻单元格之间边权重的值,但在这个问题的背景下,输入可能会相当误导。根据问题的描述,我们希望找到达到最大能量的路径与基线相比。对问题陈述的粗心阅读可能会导致我们得出power值确切对应边权重的结论,但这实际上会产生与我们意图相反的结果。"最大化能量"可以被视为等同于"最小化能量损失",由于负值实际上代表每个单元格的能量消耗,正值代表获得的能量,我们必须颠倒每个power值的符号:
for(auto d : directions)
{
    switch(d)
    {
        ……
    }
    // Add edge with power variable's sign reversed 
    edges.push_back(new Edge(i, next, -power));
}
  1. 现在,我们可以实现BellmanFord()。这次,我们的函数将以Nedges作为参数,并返回一个等于最大相对能量的整数。为了简化我们的代码,我们将N作为网格中单元格的总数传递(即N * N):
int BellmanFord(int N, vector<Edge*> edges)
{
    vector<int> distance(N, UNKNOWN);

    // Starting node is always index 0
    distance[0] = 0;
    for(int i = 0; i < N - 1; i++)
    {
        for(auto edge : edges)
        {
            if(distance[edge->start] == UNKNOWN)
            {
                continue;
            }
            if(distance[edge->start] + edge->weight < distance[edge->end])
            {
                distance[edge->end] = distance[edge->start] + edge->weight;
            }
        }
    }
    ……
}
  1. 根据标准实现,我们还将检查负循环以处理与机器人贪婪能量消耗相关的条件。如果找到负循环,我们将返回UNKNOWN
// Check for negative cycles
for(auto edge : edges)
{
    if(distance[edge->start] == UNKNOWN)
    {
        continue;
    }
    if(distance[edge->start] + edge->weight < distance[edge->end])
    {
        return UNKNOWN;
    }
}
return distance[N];
  1. 现在,我们可以在main()中调用BellmanFord()并相应地处理输出:
int result = BellmanFord(N * N, edges);
(result == UNKNOWN) ? cout << "ABORT TRAVERSAL" << endl 
               : cout << -1 * result << endl;
return 0;

活动 16:随机图形统计

在这个活动中,我们将按照活动简介中描述的方式生成随机图形进行面试测试。按照以下步骤完成活动:

  1. 首先包括以下标头,并定义UNKNOWN常量和Edge结构:
#include <iostream>
#include <vector>
#include <iomanip>
#include <algorithm>
#include <queue>
#include <utility>
using namespace std;
const int UNKNOWN = 1e9;
struct Edge 
{
    int u;
    int v;
    int w;
    Edge(int u, int v, int w) 
        : u(u), v(v), w(w) {}
};
  1. 我们的第一个任务是处理每个图的生成。对于这个活动,我们将在一个结构体中封装我们的图形数据:
struct Graph
{
    int V, E;
    int maxWeight = -1e9;
    vector<Edge> edges;
    vector<vector<int>> adj;
    vector<vector<int>> weight;
    Graph(int v, int e) : V(v), E(e) 
    {
        ...
    }
};
  1. 为了确保生成的边和生成的图是有效的,我们将创建一个邻接矩阵,并在每次尝试创建另一个边时进行检查。如果两个节点之间已经存在一条边,我们将开始另一个迭代。为了确保每个节点至少有一个入边或出边,我们还将为矩阵中的对角线单元格设置每个节点的值为 true。如果在创建E条边后,任何对角线单元格为 false,则图将无效。我们可以通过将V设置为-1来表示图无效:
Graph(int v, int e) : V(v), E(e)
{
    vector<vector<bool>> used(V, vector<bool>(V, false));
    adj.resize(V);
    weight.resize(V, vector<int>(V, UNKNOWN));
    while(e)
    {
        // Generate edge values
        int u = rand() % V;
        int v = rand() % V;
        int w = rand() % 100;
        if(rand() % 3 == 0)
        {
            w = -w;
        }
        // Check if the edge is valid
        if(u == v || used[u][v])
        {
            continue;
        }
        // Add to edges and mark as used
        edges.push_back(Edge(u, v, w));
        adj[u].push_back(v);
        weight[u][v] = w;
        maxWeight = max(maxWeight, w);
        used[u][u] = used[v][v] = used[u][v] = used[v][u] = true;
        e--;
    }
    for(int i = 0; i < V; i++)
    {
        // Set V to -1 to indicate the graph is invalid
        if(!used[i][i])
        {
            V = -1;
            break;
        }
    }
}
  1. 让我们还定义一个名为RESULT的枚举,其中包含我们需要考虑的每种图形的相应值:
enum RESULT
{
    VALID,
    INVALID,
    INTERESTING
};
  1. main()中,我们将接收输入,并声明每种图形的计数器。然后,我们将循环给定的迭代次数,创建一个新图形,并调用一个以Graph对象为输入并返回RESULTTestGraph()函数。根据返回的值,我们将相应地递增每个计数器:
int main()
{
    unsigned int seed;
    int iterations, V, E;

    cin >> seed;
    cin >> iterations;
    cin >> V >> E;
    int invalid = 0;
    int valid = 0;
    int interesting = 0;
    srand(seed);
    while(iterations--)
    {
        Graph G(V, E);

        switch(TestGraph(G))
        {
            case INVALID: invalid++; break;
            case VALID: valid++; break;
            case INTERESTING: 
            {
                valid++;
                interesting++;
                break;
            }
        }
    }

    return 0;
}
  1. TestGraph()首先会检查每个图的V值是否等于-1,如果是,则返回INVALID。否则,它将执行 Johnson 算法来检索最短距离。第一步是使用 Bellman-Ford 算法检索重新加权数组:
RESULT TestGraph(Graph G)
{
    if(G.V == -1)
    {
        return INVALID;
    }

    vector<int> distance = BellmanFord(G);
    ……
}
  1. 在这个解决方案中使用的 Bellman-Ford 的实现与练习中的实现完全相同,只是它接收一个Graph结构作为参数:
vector<int> BellmanFord(Graph G)
{
    vector<int> distance(G.V + 1, UNKNOWN);
    int s = G.V;
    for(int i = 0; i < G.V; i++)
    {
        G.edges.push_back(Edge(s, i, 0));
    }

    distance[s] = 0;
    for(int i = 0; i < G.V; i++)
    {
        for(auto edge : G.edges)
        {
            if(distance[edge.u] == UNKNOWN)
            {
                continue;
            }
            if(distance[edge.u] + edge.w < distance[edge.v])
            {
                distance[edge.v] = distance[edge.u] + edge.w;
            }
        }
    }
    for(auto edge : G.edges)
    {
        if(distance[edge.u] == UNKNOWN)
        {
            continue;
        }
        if(distance[edge.u] + edge.w < distance[edge.v])
        {
            return {};
        }
    }
return distance;
}
  1. 与练习中一样,我们将检查BellmanFord()返回的向量是否为空。如果是,我们返回VALID(图是有效的但无趣的)。否则,我们将通过重新加权边缘并为每个顶点执行 Dijkstra 算法来跟随约翰逊算法的其余部分:
RESULT TestGraph(Graph G)
{
    if(G.V == -1)
    {
        return INVALID;
    }

    vector<int> distance = BellmanFord(G);
    if(distance.empty())
    {
        return VALID;
    }
    for(auto edge : G.edges)
    {
        G.weight[edge.u][edge.v] += (distance[edge.u] – distance[edge.v]);
    }
    double result = 0;
    for(int i = 0; i < G.V; i++)
    {
        vector<int> shortest = Dijkstra(i, G);
    }
}
  1. 对于这个解决方案,让我们使用更高效的 Dijkstra 算法形式,它使用最小优先队列来确定遍历顺序。为了做到这一点,添加到队列中的每个值必须由两个值组成:节点的索引和其距离值。我们将使用std::pair<int, int>来实现这一点,这里已经重新定义为State。当将元素推送到队列时,第一个值必须对应于距离,因为这将是优先队列内部排序逻辑考虑的第一个值。所有这些都可以由std::priority_queue处理,但我们需要提供三个模板参数,分别对应于数据类型、容器和比较谓词:
vector<int> Dijkstra(int source, Graph G)
{
    typedef pair<int, int> State;
    priority_queue<State, vector<State>, greater<State>> Q;
    vector<bool> visited(G.V, false);
    vector<int> distance(G.V, UNKNOWN);
    Q.push({0, source});
    distance[source] = 0;
    while(!Q.empty())
    {
        State top = Q.top();
        Q.pop();
        int node = top.second;
        int dist = top.first;
        visited[node] = true;
        for(auto next : G.adj[node])
        {
            if(visited[next])
            {
                continue;
            }
            if(dist != UNKNOWN && distance[next] > dist + G.weight[node][next])
            {
                distance[next] = distance[node] + G.weight[node][next];

                Q.push({distance[next], next});
            }

        }
    }
    return distance;
}
  1. 现在,我们将计算TestGraph()中每组路径的平均值。我们通过迭代Dijkstra()返回的数组,并保持距离的总和,其中索引不等于起始节点的索引。相应的值不等于UNKNOWN。每次找到有效距离时,计数器也会递增,以便我们可以通过将总和除以计数来获得最终平均值。然后将这些平均值中的每一个添加到总结果中,然后将其除以图中顶点的总数。记住,我们必须重新加权距离,以获得正确的值:
double result = 0;
for(int i = 0; i < G.V; i++)
{
    vector<int> shortest = Dijkstra(i, G);
    double average = 0;
    int count = 0;
    for(int j = 0; j < G.V; j++)
    {
        if(i == j || shortest[j] == UNKNOWN)
        {
            continue;
        }
        shortest[j] += (distance[j] – distance[i]);
        average += shortest[j];
        count++;
    }
    average = average / count;
    result += average;
}
result = result / G.V;
  1. 最后一步是计算结果与图中最大权重之间的比率。如果值小于0.5,我们返回INTERESTING;否则,我们返回VALID
double ratio = result / G.maxWeight;
return (ratio < 0.5) ? INTERESTING : VALID;
  1. 现在我们可以返回main()并打印输出。第一行将等于invalid的值。第二行将等于interesting / valid,乘以100,以便显示为百分比。根据您的操作方式,您可能需要将变量转换为浮点数,以防止值被四舍五入为整数。在打印输出时,您可以通过使用cout << fixed << setprecision(2)轻松确保它四舍五入到两位小数:
double percentInteresting = (double)interesting / valid * 100;
cout << "INVALID GRAPHS: " << invalid << endl;
cout << "PERCENT INTERESTING: " << fixed << setprecision(2) << percentInteresting << endl;
return 0;

活动 17:迷宫传送游戏

整个活动与本章讨论的算法的标准实现非常接近,但有一些细微的修改。

在问题描述中使用的术语,即mazeroomsteleporterspoints,当然也可以被称为graphverticesedgesedge weights。玩家能够无限减少他们的分数的条件可以被重新定义为负权重循环。按照以下步骤完成活动:

  1. 让我们首先包括必要的头文件,并设置变量和输入以进行活动:
#include <iostream>
#include <vector>
#include <stack>
#include <climits>
struct Edge
{
    int start;
    int end;
    int weight;
    Edge(int s, int e, int w) : start(s), end(e), weight(w) {}
}
const int UNKNOWN = INT_MAX;
vector<Edge*> edges; // Collection of edge pointers
  1. 我们将以与我们原始的 Bellman-Ford 实现相同的形式接收输入,但我们还将为我们的图构建一个邻接表(在这里表示为整数向量的向量,adj):
int main()
{
    int V, E;
    cin >> V >> E;
    vector<Edge*> edges;
    vector<vector<int>> adj(V + 1);
    for(int i = 0; i < E; i++)
    {
        int u, v, w;
        cin >> u >> v >> w;
        edges.push_back(new Edge(u, v, w));
        adj[u].push_back(v);
    }
    vector<int> results;
  1. 问题的第一部分可以通过使用 Bellman-Ford 以与练习 32中概述的完全相同的方式来解决。但是,我们将其返回类型设置为int,并包括一些额外的代码行,以便它仅返回从源顶点到最短距离(或如果检测到负循环,则返回UNKNOWN):
int BellmanFord(int V, int start, vector<Edge*> edges)
{
    // Standard Bellman-Ford implementation
    vector<int> distance(V, UNKNOWN);

    distance[start] = 0;
    for(int i = 0; i < V - 1; i++)
    {
        for(auto edge : edges)
        {
            if(distance[edge->start] == UNKNOWN)
            {
                continue;
            }
            if(distance[edge->start] + edge->weight < distance[edge->end])
            {
                distance[edge->end] = distance[edge->start] + edge->weight;
            }
        }
    }
    // Return UNKNOWN if a negative cycle is found
    if(HasNegativeCycle(distance, edges))
    {
        return UNKNOWN;
    }
    int result = UNKNOWN;
    for(int i = 0; i < V; i++)
    {
        if(i == start) continue;
        result = min(result, distance[i]);
    }
    return result;
}
  1. 我们现在可以在main()中调用这个函数,并为输出填充一个结果向量。如果BellmanFord()返回UNKNOWN,我们输出INVALID MAZE并终止程序(根据第一个条件)。如果某个起始节点没有出边,我们可以完全跳过对BellmanFord的调用,并简单地将UNKNOWN附加到向量中。如果我们通过了每个顶点,我们可以输出结果中的值(或者如果值是UNKNOWN,则输出DEAD END):
vector<int> results;
for(int i = 0; i < V; i++)
{
    if(adj[i].empty())
    {
        results.push_back(UNKNOWN);
        continue;
    }
    int shortest = BellmanFord(V, i, edges);
    if(shortest == UNKNOWN)
    {
        cout << "INVALID MAZE" << endl;
        return 0;
    }
    results.push_back(shortest);
}
for(int i = 0; i < V; i++)
{
    cout << i << ": ";
    (results[i] == INVALID) ? cout << "DEAD END" << endl : cout << results[i] << endl;
}
  1. 现在,我们来到了最后一个条件——找到玩家可能会“卡住”的房间。从图的连通性角度考虑这种情况,我们可以重新定义它如下:找到没有出边通往其他组件的强连通分量。一旦获得了所有强连通分量,就有许多简单的方法来做到这一点,但让我们尝试最大化我们程序的效率,并直接将必要的逻辑添加到我们现有的 Kosaraju 实现中。

为了实现这一点,我们将声明两个新向量:一个名为isStuckbool类型,另一个名为inComponentint类型。inComponent将存储每个节点所属的组件的索引,而isStuck将告诉我们组件索引i的组件是否与图的其余部分隔离。

为了简单起见,让我们在全局声明新变量:

vector<bool> isStuck;
vector<int> inComponent;
int componentIndex;

在这里,我们真的可以开始欣赏封装和面向对象的图结构实现的好处。在我们的函数之间传递如此大量的数据不仅在心理上难以跟踪,而且大大复杂化了我们可能希望在将来进行的任何修改(更不用说像GetComponent(node, adj, visited, component, isStuck, inComponent, componentIndex)这样的函数调用看起来令人头疼)。出于示例和可读性的考虑,我们选择在全局声明这些数据,但在实际的大型应用程序环境中,强烈建议不要采用这种方法。

  1. 在我们的Kosaraju函数中,我们初始化新数据如下:
isStuck.resize(V, true);
inComponent.resize(V, UNKNOWN);
componentIndex = 0;
  1. 现在,我们将开始我们的while循环,通过在栈上执行每次 DFS 遍历来递增componentIndex
while(!stack.empty())
{
    int node = stack.top();
    stack.pop();
    if(!visited[node])
    {
        vector<int> component;
        GetComponent(node, transpose, visited, component);
        components.push_back(component);
        componentIndex++;
    }
}
  1. 现在,我们可以在GetComponent()中编写处理这种情况的逻辑。我们将从将每个节点的索引值设置为componentIndex开始。现在,当我们遍历每个节点的邻居时,我们将包括另一个条件,即当节点已经被访问时发生:
component.push_back(node);
visited[node] = true;
inComponent[node] = componentIndex;
for(auto next : adj[node])
{
    if(!visited[next])
    {
        GetComponent(next, visited, adj, component);
    }
    else if(inComponent[node] != inComponent[next])
    {
        isStuck[inComponent[next]] = false;
    }
}

基本上,我们正在检查每个先前访问的邻居的组件是否与当前节点的组件匹配。如果它们各自的组件 ID 不同,我们可以得出结论,即邻居的组件具有延伸到图的其他部分的路径。

你可能会想知道为什么在有向图中,当前节点存在一条边意味着相邻节点具有通往其自身组件之外的出边。这种逻辑之所以看起来“反向”,是因为它确实如此。请记住,我们正在遍历原始图的转置,因此邻接之间的方向都是相反的!

  1. 完成 DFS 遍历后,我们现在可以返回components向量并打印结果:
auto components = Kosaraju(V, adj);
for(int i = 0; i < components.size(); i++)
{
    if(isStuck[i])
    {
        for(auto node : components[i])
        {
            cout << node << " ";
        }
        cout << endl;
    }
}
return 0;

第八章:动态规划 I

活动 18:旅行行程

让我们首先考虑这个问题的基本情况和递归关系。与本章讨论过的其他一些例子不同,这个特定的问题只有一个基本情况——到达目的地的点。中间状态也很简单:给定一个具有距离限制x的索引i的位置,我们可以前往索引i + 1i + x(包括)之间的任何位置。例如,让我们考虑以下两个城市:

  • 城市 1:distance[1] = 2

  • 城市 2:distance[2] = 1

假设我们想要计算到达索引为3的城市的方式数量。因为我们可以从城市 1城市 2到达城市 3,所以到达城市 3的方式数量等于到达城市 1 的方式数量和到达城市 2的方式数量的总和。这种递归与斐波那契数列非常相似,只是当前状态的子结构形成的前一个状态的数量根据distance的值是可变的。

所以,假设我们有以下四个城市:

[1]: distance = 5
[2]: distance = 3
[3]: distance = 1
[4]: distance = 2

从这里,我们想计算到达城市 5 的方式数量。为此,我们可以将子结构公式化如下:

Cities reachable from index [1] -> { 2 3 4 5 6 }
Cities reachable from index [2] -> { 3 4 5 }
Cities reachable from index [3] -> { 4 }
Cities reachable from index [4] -> { 5 6 }

现在我们可以反转这种逻辑,找到我们可以通过旅行到达给定位置的城市:

Cities that connect to index [1] -> START
Cities that connect to index [2] -> { 1 }
Cities that connect to index [3] -> { 1 2 }
Cities that connect to index [4] -> { 1 2 3 }
Cities that connect to index [5] -> { 1 2 }

更进一步,我们现在可以设计状态逻辑的大纲:

Ways to reach City 1 = 1 (START)
Ways to reach City 2 = 1 
    1 " 2
Ways to reach City 3 = 2
    1 " 2 " 3
    1 " 3
Ways to reach City 4 = 4
    1 " 2 " 3 " 4
    1 " 2 " 4
    1 " 3 " 4
    1 " 4
Ways to reach City 5 = 6
    1 " 2 " 3 " 4 " 5
    1 " 2 " 4 " 5
    1 " 2 " 5
    1 " 3 " 4 " 5
    1 " 4 " 5
    1 " 5

因此,我们可以将递归定义如下:

  • 基本情况:

F(1) = 1(我们已经到达目的地)

  • 递归:

图 8.22:定义递归的公式

图 8.22:定义递归的公式

换句话说,到达给定位置的方式数量等于到达连接到它的每个位置的方式数量的总和。使用这种逻辑,解决这个问题的递归函数可能看起来像这样:

F(n) -> number of ways to reach n'th location
F(i) = 
    if i = N: 
         return 1 
        Otherwise:
            result = 0
            for j = 1 to distance[i]:
                result = result + F(i + j)
            return result

现在我们已经有了问题状态的功能性定义,让我们开始在代码中实现它。

  1. 对于这个问题,我们将包括以下头文件和std命名空间:
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
  1. 因为这个问题的输出需要计算超过 32 位的数字,我们将使用long long int作为结果。为了避免重复编写这个,我们将使用typedef语句来缩写它:
typedef long long LL;
  1. 最后,我们将定义输出结果的模数值:
const LL MOD = 1000000007;

在这个问题中处理输入和输出可以非常简单地实现:

int main()
{
    int n;
    cin >> n;

vector<int> distance(n);
    for(int i = 0; i < n; i++)
    {
        cin >> distance[i];
    }
    LL result = TravelItinerary(n, distance);
    cout << result << endl;
    return 0;
}
  1. 现在,我们将定义一个名为TravelItinerary()的函数,它以ndistance作为参数,并返回一个长整数:
LL TravelItinerary(int n, vector<int> distance)
{
    ...
}
  1. 现在,我们必须将我们之前提出的递归算法转换为自底向上的方法。在伪代码中,这可能如下所示:
DP -> Array of size N + 1
DP[0] = 1 (There is one way to reach the starting location)
for i = 0 to N-1:
    for j = 1 to distance[i]: 

        DP[i + j] += DP[i]
return DP[N]
  1. 要在 C++中编写这个代码,我们将首先声明一个大小为n + 1的一维 DP 表,并将其所有元素初始化为0。然后,我们将将其第一个元素设置为1,以表示基本情况:
vector<LL> DP(n + 1, 0);
DP[0] = 1;
  1. 为了实现我们之前描述的递归,我们将首先反转距离数组,这样我们基本上是从目的地索引开始计算。这样做有几个原因,但主要原因是我们的算法处理当前状态,通过组合先前状态的结果,而不是根据当前状态的结果计算未来状态。虽然伪代码中描述的逻辑会产生正确的结果,但通常更倾向于以底向上的逻辑来表述前一个状态的解如何形成立即状态的结果:
reverse(distance.begin(), distance.end());
DP[0] = 1;
for(int i = 1; i <= n; i++)
{
    int dist = distance[i-1];
    for(int j = 1; j <= dist; j++)
    {
        DP[i] = (DP[i] + DP[i – j]) % MOD;
    }
}
return DP[n];

这当然是问题的一个可行解决方案,在绝大多数情况下都会完全令人满意。然而,由于动态规划首先是一种优化技术,我们仍然应该问自己是否存在更好的方法。

n和最大distance值增加,即使先前的算法最终也会变得相当低效。如果n = 10000000,距离值可以在 1 到 10000 之间变化,那么内部for循环在最坏的情况下将不得不执行近 100000000000 次迭代。幸运的是,有一种非常简单的技术可以完全消除内部循环,这意味着我们对任何输入都只需要执行n次迭代。

为了处理这种缩减,我们将创建一个前缀和数组,这将允许我们以常数时间计算我们之前通过内循环处理的范围和。如果您对这种技术不熟悉,基本概念如下:

  • 创建一个名为sums的数组,其长度等于要求和的值的总数加一,所有元素都初始化为0

  • 对于每个从0n的索引i,使用sum[i + 1] = sum[i] + distance[i]

  • 在计算出和之后,任何范围[L,R]内所有元素的和将等于sum[R+1] – sum[L]

看下面的例子:

        0 1  2  3  4
A    =   { 3 1 10  2  5 } 
           0 1 2  3  4  5
sums  =  { 0 3 4 14 16 21 }
range(1, 3) = A[1] + A[2] + A[3]
         = 1 + 10 + 2
         = 13
sums[4]  – sums[1] = 13
range(3, 4) = A[3] + A[4]
        = 2 + 5
        = 7
sums[5] – sums[3] = 7
  1. 我们可以在我们的函数中实现这种方法如下:
LL TravelItinerary(int n, vector<int> distance)
{
    vector<LL> DP(n + 1, 0);
    vector<LL> sums(n + 2, 0);
    DP[0] = sums[1] = 1;
    reverse(distance.begin(), distance.end());
    for(int i = 1; i <= n; i++)
    {
        int dist = distance[i-1];
        LL sum = sums[i] – sums[i – dist];
        DP[i] = (DP[i] + sum) % MOD;
        sums[i + 1] = (sums[i] + DP[i]) % MOD;
    }
    return DP[n];
}
  1. 现在,你可能会遇到另一个问题,那就是前面函数返回的结果可能是负数。这是因为模运算导致sums中的高索引值小于低索引值,这导致减法结果为负数。在需要频繁对非常大的数字进行模运算的问题中,这种问题可能非常常见,但可以通过稍微修改返回语句来轻松解决:
return (DP[n] < 0) ? DP[n] + MOD : DP[n];

通过这些轻微的修改,我们现在有了一个优雅而高效的解决方案,可以在几秒钟内处理大量输入数组!

活动 19:使用记忆化找到最长公共子序列

  1. 与子集和问题一样,我们将在同一个代码文件中包含每种新方法,以便比较它们的相对性能。为此,让我们以与之前相同的方式定义我们的GetTime()函数:
vector<string> types =
{
    "BRUTE FORCE",
    "MEMOIZATION",
    "TABULATION"
};
const int UNKNOWN = INT_MAX;
void GetTime(clock_t &timer, string type)
{
    timer = clock() - timer;
    cout << "TIME TAKEN USING " << type << ": " << fixed << setprecision(5) << (float)timer / CLOCKS_PER_SEC << " SECONDS" << endl;
    timer = clock();
}
  1. 现在,让我们定义我们的新函数LCS_Memoization(),它将接受与LCS_BruteForce()相同的参数,只是subsequence将被替换为对二维整数向量memo的引用:
int LCS_Memoization(string A, string B, int i, int j, vector<vector<int>> &memo)
{
    ……
}
  1. 我们的这个函数的代码也将与LCS_BruteForce()非常相似,只是我们将通过递归遍历两个字符串的前缀(从完整字符串开始)并在每一步将结果存储在我们的memo表中来颠倒逻辑:
// Base case — LCS is always zero for empty strings
if(i == 0 || j == 0)
{
    return 0;
}
// Have we found a result for the prefixes of the two strings?
if(memo[i - 1][j - 1] != UNKNOWN)
{
    // If so, return it
    return memo[i - 1][j - 1];
}
// Are the last characters of A's prefix and B's prefix equal?
if(A[i-1] == B[j-1])
{
    // LCS for this state is equal to 1 plus the LCS of the prefixes of A and B, both reduced by one character
    memo[i-1][j-1] = 1 + LCS_Memoization(A, B, i-1, j-1, memo);
    // Return the cached result
    return memo[i-1][j-1];
}
// If the last characters are not equal, LCS for this state is equal to the maximum LCS of A's prefix reduced by one character and B's prefix, and B's prefix reduced by one character and A's prefix
memo[i-1][j-1] = max(LCS_Memoization(A, B, i-1, j, memo), 
                 LCS_Memoization(A, B, i, j-1, memo));
return memo[i-1][j-1];
  1. 现在,让我们重新定义我们的main()函数,执行两种方法并显示每种方法所花费的时间:
int main()
{
    string A, B;
    cin >> A >> B;
    int tests = 2;
    clock_t timer = clock();
    for(int i = 0; i < tests; i++)
    {
        int LCS;
        switch(i)
        {
            case 0:
            {
                LCS = LCS_BruteForce(A, B, 0, 0, {});
            #if DEBUG
                PrintSubsequences(A, B);
            #endif
                break;
            }
            case 1:
            {
                vector<vector<int>> memo(A.size(), vector<int>(B.size(), UNKNOWN));
                LCS = LCS_Memoization(A, B, A.size(), B.size(), memo);
                break;
            }
        }
        cout << "Length of the longest common subsequence of " << A << " and " << B << " is: " << LCS << ends;
        GetTime(timer, types[i]);
        cout << endl;
    }
    return 0;
}
  1. 现在,让我们尝试在两个新字符串ABCABDBEFBAABCBEFBEAB上执行我们的两种算法。你的程序输出应该类似于以下内容:
SIZE = 3
    ABC________ ABC_______
SIZE = 4
    ABC_B______ ABCB______
    ABC_B______ ABC___B___
    ABC_B______ ABC______B
    ABC___B____ ABC______B
    ABC____E___ ABC____E__
    ABC______B_ ABC___B___
    ABC______B_ ABC______B
    ABC_______A ABC_____A_
SIZE = 5
    ABCAB______ ABC_____AB
    ABC_B_B____ ABCB_____B
    ABC_B__E___ ABCB___E__
    ABC_B____B_ ABCB__B___
    ABC_B____B_ ABCB_____B
    ABC_B_____A ABCB____A_
    ABC_B_B____ ABC___B__B
    ABC_B__E___ ABC___BE__
    ABC_B____B_ ABC___B__B
    ABC_B_____A ABC___B_A_
    ABC___BE___ ABC___BE__
    ABC____E_B_ ABC____E_B
    ABC____E__A ABC____EA_
    ABC_____FB_ ABC__FB___
    ABC______BA ABC___B_A_
SIZE = 6
    ABC_B_BE___ ABCB__BE__
    ABC_B__E_B_ ABCB___E_B
    ABC_B__E__A ABCB___EA_
    ABC_B___FB_ ABCB_FB___
    ABC_B____BA ABCB__B_A_
    ABC_B__E_B_ ABC___BE_B
    ABC_B__E__A ABC___BEA_
    ABC___BE_B_ ABC___BE_B
    ABC___BE__A ABC___BEA_
    ABC____EFB_ ABC_EFB___
    ABC_____FBA ABC__FB_A_
SIZE = 7
    ABC_B_BE_B_ ABCB__BE_B
    ABC_B_BE__A ABCB__BEA_
    ABC_B__EFB_ ABCBEFB___
    ABC_B___FBA ABCB_FB_A_
    ABC____EFBA ABC_EFB_A_
SIZE = 8
    ABC_B__EFBA ABCBEFB_A_
Length of the longest common subsequence of ABCABDBEFBA and ABCBEFBEAB is: 8
TIME TAKEN USING BRUTE FORCE: 0.00242 SECONDS
Length of the longest common subsequence of ABCABDBEFBA and ABCBEFBEAB is: 8
TIME TAKEN USING MEMOIZATION: 0.00003 SECONDS
  1. 当然,蛮力方法所花费的时间将受到打印子序列的额外步骤的影响。将DEBUG常量设置为0后再次运行我们的代码,输出现在如下所示:
Length of the longest common subsequence of ABCABDBEFBA and ABCBEFBEAB is: 8
TIME TAKEN USING BRUTE FORCE: 0.00055 SECONDS
Length of the longest common subsequence of ABCABDBEFBA and ABCBEFBEAB is: 8
TIME TAKEN USING MEMOIZATION: 0.00002 SECONDS
  1. 现在,让我们尝试推动我们的算法极限,使用两个更大的字符串ABZCYDABAZADAEAYABAZADBBEAAECYACAZ。你应该得到类似于以下的输出:
Length of the longest common subsequence of ABZCYDABAZADAEA and YABAZADBBEAAECYACAZ is: 10
TIME TAKEN USING BRUTE FORCE: 8.47842 SECONDS
Length of the longest common subsequence of ABZCYDABAZADAEA and YABAZADBBEAAECYACAZ is: 10
TIME TAKEN USING MEMOIZATION: 0.00008 SECONDS

注意

实际的时间值将根据您的系统而有所不同。请注意数值上的差异。

正如我们可以清楚地看到的那样,记忆化提供的性能增益非常显著!

活动 20:使用制表法找到最长公共子序列

与之前一样,我们将在包含蛮力和记忆化解决方案的同一代码文件中添加一个新函数LCS_Tabulation()

  1. 我们的LCS_Tabulation()函数接收两个参数——字符串AB——并返回一个字符串:
string LCS_Tabulation(string A, string B)
{
    ……
} 
  1. 我们的第一步是定义 DP 表,我们将其表示为一个整数的二维向量,第一维的大小等于字符串A的大小加一,第二维的大小等于字符串B的大小加一:
vector<vector<int>> DP(A.size() + 1, vector<int>(B.size() + 1));
  1. 与子集和问题一样,我们算法的所有逻辑都可以包含在两个嵌套循环中,第一个循环从0A的大小,第二个循环从0B的大小:
for(int i = 0; i <= A.size(); i++)
{
    for(int j = 0; j <= B.size(); j++)
    {
        ……
    }
}
  1. 与子集和问题不同,我们的基本情况不会在循环执行之前处理,而是在每个循环开始时处理。这是因为我们的基本情况将在任何时候发生,即AB的前缀为空(即i = 0j = 0)。在我们的代码中表示如下:
if(i == 0 || j == 0)
{
    DP[i][j] = 0;
}
  1. 现在,我们必须处理A前缀和B前缀末尾的字符相等的情况。请记住,这种状态的 LCS 值总是等于1,加上两个前缀比它们当前小一个字符的状态的 LCS 值。这可以表示如下:
else if(A[i-1] == B[j-1])
{
    DP[i][j] = DP[i-1][j-1] + 1;
}
  1. 对于最后一种情况,结束字符相等。对于这种状态,我们知道 LCS 等于A的前缀的 LCS 和B的当前前缀的最大值,以及 B 的前缀的 LCS 和 A 的当前前缀的最大值。就我们表的结构而言,这相当于说 LCS 等于表中相同列和前一行的值的最大值,以及表中相同行和前一列的值:
else
{
    DP[i][j] = max(DP[i-1][j], DP[i][j-1]);
}
  1. 当我们完成时,最长公共子序列的长度将包含在DP[A.size()][B.size()]中 —— 当AB的前缀等于整个字符串时的 LCS 的值。因此,我们完整的 DP 逻辑写成如下:
string LCS_Tabulation(string A, string B)
{
    vector<vector<int>> DP(A.size() + 1, vector<int>(B.size() + 1));
    for(int i = 0; i <= A.size(); i++)
    {
        for(int j = 0; j <= B.size(); j++)
        {
            if(i == 0 || j == 0)
            {
                DP[i][j] = 0;
            }
            else if(A[i-1] == B[j-1])
            {
                DP[i][j] = DP[i-1][j-1] + 1;
            }
            else
            {
                DP[i][j] = max(DP[i-1][j], DP[i][j-1]);
            }
        }
    }
    int length = DP[A.size()][B.size()];
    ……
}

到目前为止,我们已经讨论了几种找到最长公共子序列长度的方法,但如果我们还想输出其实际字符呢?当然,我们的蛮力解决方案可以做到这一点,但效率非常低;然而,使用前面 DP 表中包含的结果,我们可以使用回溯来相当容易地重建 LCS。让我们突出显示我们需要在表中遵循的路径:

图 8.23:活动 20 DP 表

图 8.23:活动 20 DP 表

通过收集与路径中值增加相关的每列的字符,我们得到 LCS ABCBEFBA

  1. 让我们定义一个名为ReconstructLCS()的函数,它接受ABijDP作为参数。我们的回溯逻辑可以定义如下:
if i = 0 or j = 0:
    Return an empty string
If the characters at the end of A's prefix and B's prefix are equal:
    Return the LCS of the next smaller prefix of both A and B, plus the equal character
Otherwise:
    If the value of DP(i - 1, j) is greater than the value of DP(i, j - 1):
      – Return the LCS of A's next smaller prefix with B's current prefix
      – Otherwise:
          Return the LCS of B's next smaller prefix with A's current prefix

在 C++中,这可以编码如下:

string ReconstructLCS(vector<vector<int>> &DP, string &A, string &B, int i, int j)
{
    if(i == 0 || j == 0)
    {
        return "";
    }
    if(A[i-1] == B[j-1])
    {
        return ReconstructLCS(DP, A, B, i-1, j-1) + A[i-1];
    }
    else if(DP[i-1][j] > DP[i][j-1])
    {
        return ReconstructLCS(DP, A, B, i-1, j);
    }
    else
    {
        return ReconstructLCS(DP, A, B, i, j-1);
    }
}
  1. 现在,我们可以在LCS_Tabulation()的最后一行返回ReconstructLCS()的结果:
string LCS_Tabulation(string A, string B)
{
    ……
    string lcs = ReconstructLCS(DP, A, B, A.size(), B.size());
    return lcs; 
}
  1. 我们的main()中的代码现在应该被修改以适应LCS_Tabulation()的添加:
int main()
{
    string A, B;
    cin >> A >> B;
    int tests = 3;
    clock_t timer = clock();
    for(int i = 0; i < tests; i++)
    {
        int LCS;
        switch(i)
        {
            ……
            case 2:
            {
                string lcs = LCS_Tabulation(A, B);
                LCS = lcs.size();
                cout << "The longest common subsequence of " << A << " and " << B << " is: " << lcs << endl;
                break; 
            }
        }
        cout << "Length of the longest common subsequence of " << A << " and " << B << " is: " << LCS << endl;
        GetTime(timer, types[i]);
    }
    return 0;
}
  1. 使用字符串ABCABDBEFBAABCBEFBEAB,您程序的输出应该类似于这样:
Length of the longest common subsequence of ABCABDBEFBA and ABCBEFBEAB is: 8
TIME TAKEN USING BRUTE FORCE: 0.00060 SECONDS
Length of the longest common subsequence of ABCABDBEFBA and ABCBEFBEAB is: 8
TIME TAKEN USING MEMOIZATION: 0.00005 SECONDS
The longest common subsequence of ABCABDBEFBA and ABCBEFBEAB is: ABCBEFBA
Length of the longest common subsequence of ABCABDBEFBA and ABCBEFBEAB is: 8
TIME TAKEN USING TABULATION: 0.00009 SECONDS

注意

实际的时间值将根据您的系统而异。请注意值的差异。

现在,我们已经看了另一个详细的例子,说明了相同的逻辑如何可以应用于同一个问题,使用不同的技术以及这对算法的执行时间产生的相应影响。

活动 21:旋律排列

我们要问自己的第一个问题是:在这个问题中,什么构成一个单一状态?

基本情况 --> 空集:

  1. 考虑旋律中的每个音符。

  2. 对于先前遇到的每个音符子集,要么附加当前音符,要么不做任何操作。

  3. 如果子集与目标匹配,则将其添加到解决方案中。

考虑到我们的选择是要么将一个音符附加到先前的子集,要么保持不变,我们可以将逻辑重新表述如下:

对于旋律中的给定音符,包含该音符的大小为| n |的子集的计数等于不包含该音符的大小为| n - 1 |的所有子集的总计数。

因此,每个状态可以用两个维度表示:

  • 维度 1:到目前为止考虑的旋律的长度。

  • [length - 1]的旋律,或者什么都不做。

在伪代码中,逻辑可以表达如下:

for i = 1 to length of melody (inclusive):
    for each subset previously found:
    DP(i, subset) = DP(i, subset) + DP(i - 1, subset)
    DP(i, subset ∪ melody[i - 1]) = DP(i, subset ∪ melody[i - 1]) + DP(i - 1, subset)

因此,现在的主要问题是,我们如何表示这些状态?

请记住,对于一个n元素的集合,它包含的子集总数为2**n —— 例如,一个包含 4 个元素的集合可以被划分为2**4(或 16)个子集:

S = { A, B, C, D }
{ }            —>        { _ _ _ _ }
{ A }          —>        { # _ _ _ }
{ B }          —>        { _ # _ _ }
{ C }          —>        { _ _ #_  }
{ D }          —>        { _ _ _ # }
{ A, B }       —>        { # # _ _ }
{ A, C }       —>        { # _ #_  }
{ A, D }       —>        { # _ _ # }
{ B, C }       —>        { _ # #_  }
{ B, D }       —>        { _ # _ # }
{ C, D }       —>        { _ _ # # }
{ A, B, C }    —>        { # # # _ }
{ A, B, D }    —>        { # # _ # }
{ A, C, D }    —>        { # _ # # }
{ B, C, D }    —>        { _ # # # }
{ A, B, C, D } —>        { # # # # }

如果我们在二进制中从0迭代到(2**4 - 1),我们得到以下数字:

0     —>    0000    —>    { _ _ _ _ }
1     —>    0001    —>    { # _ _ _ }
2     —>    0010    —>    { _ # _ _ }
3     —>    0011    —>    { # # _ _ }
4     —>    0100    —>    { _ _ # _ }
5     —>    0101    —>    { # _ # _ }
6     —>    0110    —>    { _ # # _ }
7     —>    0111    —>    { # # # _ }
8     —>    1000    —>    { _ _ _ # }
9     —>    1001    —>    { # _ _ # }
10    —>    1010    —>    { _ # _ # }
11    —>    1011    —>    { # # _ # }
12    —>    1100    —>    { _ _ # # }
13    —>    1101    —>    { # _ # # }
14    —>    1110    —>    { _ # # # }
15    —>    1111    —>    { # # # # }

正如我们所看到的,从02**n的每个二进制数的数字恰好对应于 n 个元素的一个可能子集的索引。由于音阶中有 12 个音符,这意味着一共有2**12(或 4,096)个可能的音符子集。通过将音阶中的每个音符映射到 2 的幂,我们可以使用位运算来表示在每个状态下遇到的子集。

以下是解决此活动的步骤:

  1. 继续编码,我们应该从包括以下标题开始:
#include <iostream>
#include <vector>
#include <string>
#include <map>
using namespace std;
  1. 让我们从在我们的main()函数中处理输入开始:
int main()
{
    int melodyLength;
    int setLength;
    cin >> melodyLength;
    vector<string> melody(melodyLength);
    for(int i = 0; i < melodyLength; i++)
    {
        cin >> melody[i];
    }
    cin >> setLength;
    vector<string> set(setLength);
    for(int i = 0; i < setLength; i++)
    {
        cin >> set[i];
    }
    ……
}
  1. 现在,让我们编写一个名为ConvertNotes()的函数,它接收一个音符字符串向量作为输入,并返回它们对应的整数值向量。音阶中的 12 个音符中的每一个都需要映射到特定的位(从A开始),与增值等价音符分配给相同的值。我们将使用std::map来处理转换:
vector<int> ConvertNotes(vector<string> notes)
{
    map<string, int> M = 
    {
        { "A",  0 }, 
        { "A#", 1 },
        { "Bb", 1 },
        { "B",  2 },
        { "Cb", 2 },
        { "B#", 3 },
        { "C",  3 },
        { "C#", 4 },
        { "Db", 4 },
        { "D",  5 },
        { "D#", 6 },
        { "Eb", 6 },
        { "E",  7 },
        { "Fb", 7 },
        { "E#", 8 },
        { "F",  8 },
        { "F#", 9 },
        { "Gb", 9 },
        { "G",  10 },
        { "G#", 11 },
        { "Ab", 11 }
    };
    vector<int> converted;
    for(auto note : notes)
    {
        // Map to powers of 2
        converted.push_back(1 << M[note]); 
    }
    return converted;
}
  1. 现在,我们将定义一个名为CountMelodicPermutations()的函数,它以两个整数向量melodyset作为参数,并返回一个整数:
int CountMelodicPermutations(vector<int> melody, vector<int> set)
{
    ……
}
  1. 我们的第一步是定义我们的目标子集。我们将使用按位或运算符来实现这一点:
unsigned int target = 0;
for(auto note : set)
{
    target |= note;
}
  1. 例如,如果我们的目标集是{C, F#, A},映射将如下所示:
C  = 3
F# = 9
A  = 0
converted = { 23, 29, 20 } = { 8, 512, 1 }
target = (8 | 512 | 1) = 521
    0000001000
  + 0000000001
  + 1000000000
  = 1000001001
  1. 我们现在将定义一个二维 DP 表,第一维初始化为melodyLength + 1,第二维初始化为最大子集值的一个更大的值(即111111111111 = 2``12 - 1,因此第二维将包含2**12,或 4,096 个元素):
vector<vector<int>> DP(melody.size() + 1, vector<int>(4096, 0));
  1. 我们的 DP 公式可以定义如下:
Base case:
    DP(0, 0) —> 1 
Recurrence:
    DP(i, subset) —> DP(i, subset) + DP(i - 1, subset)
    DP(i, subset ∪ note[i-1]) —> DP(i, subset ∪ note[i]) + DP(i - 1, subset)

在这里,i的范围是从旋律的长度为1到。我们可以用 C++来写前面的逻辑:

// Base case —> empty set
DP[0][0] = 1;
for(int i = 1; i <= melody.size(); i++)
{
    for(unsigned int subset = 0; subset < 4096; subset++)
    {
        // Keep results for previous values of i
        DP[i][subset] += DP[i-1][subset];
        // Add results for union of subset with melody[i-1]
        DP[i][subset | melody[i-1]] += DP[i-1][subset];
    }
}
// Solution
return DP[melody.size()][target];
  1. 现在,我们可以通过调用CountMelodicPermutations并输出结果来完成我们的main()函数:
int count = CountMelodicPermutations(ConvertNotes(melody), ConvertNotes(set));
cout << count << endl;

第九章:动态规划 II

活动 22:最大化利润

在这个活动中,我们将优化我们的库存以最大化利润。按照以下步骤完成活动:

  1. 让我们首先包括以下标题:
#include <iostream>
#include <vector>
using namespace std;
  1. 首先,我们将定义一个结构Product,它封装了与每个项目相关的数据:
struct Product 
{
    int quantity;
    int price;
    int value;
    Product(int q, int p, int v) 
        : quantity(q), price(p), value(v) {}
};
  1. 接下来,我们将在main()函数中处理输入,并填充一个Product类型的数组:
int main()
{
    int N, budget, capacity;
    cin >> N >> budget >> capacity;
    vector<Product> products;
    for(int i = 0; i < N; i++)
    {
        int quantity, cost, value;
        cin >> quantity >> cost >> value;
        products.push_back(Product(quantity, cost, value));
    }
...
return 0;
}
  1. 与任何 DP 算法一样,我们现在必须定义状态和基本情况。我们知道形成最终结果的项目子集必须符合以下标准:
  • 子集中所有产品的cost之和不能超过budget

  • 子集中所有产品的quantity之和不能超过capacity

  • 子集中所有产品的value之和必须最大化。

鉴于这些标准,我们可以看到每个状态可以由以下参数定义:

  • 正在考虑的当前项目

  • 先前购买的单位数

  • 购买项目的总成本

  • 以零售价出售产品后获得的总利润

我们还可以得出结论,搜索将在以下情况下终止:

  • 所有项目都已考虑过

  • 总成本超出预算

  • 总单位数超过容量

与传统的 0-1 背包问题一样,我们将线性地考虑从0N-1的每个项目。对于索引为i的每个项目,我们的状态可以以两种方式之一转换:包括当前项目或留下它。用伪代码编写递归逻辑可能是这样的:

F(i, count, cost, total): 
I        –> The index of the current item 
Cost     –> The total money spent 
count    –> The number of units purchased
total    –> The total profit value of the chosen items
Base cases: 
    if i = N: return total
    if cost > budget: return 0
    if count > capacity: return 0
Recurrence:
F(i, count, cost, total) = maximum of:
F(i + 1, count + quantity[i], cost + price[i], 
      total + value[i]) – Include the item
        AND
    F(i + 1, count, cost, total) – Leave as-is

如前面的代码所示,递归关系根据icountcosttotal的值来定义。将这种逻辑从自顶向下转换为自底向上可以这样做:

Base case:
    DP(0, 0, 0) = 0 [Nothing has been chosen yet]
For i = 1 to N:
    Product -> quantity, price, value
    For cost = 0 to budget:
        For count = 0 to capacity:
            If price is greater than cost OR 
           quantity is greater than count:
                DP(i, cost, count) = DP(i-1, cost, count)
            Otherwise:
                DP(i, cost, count) = maximum of:
                    DP(i-1, cost, count)
                        AND
                    DP(i-1, cost – price, count – quantity) + value

换句话说,每个状态都根据当前索引、总成本和总计数来描述。对于每对有效的costcount值,对于相同的costcount值在索引i – 1处找到的最大子集和(即DP[i – 1][cost][count]),当前项目在索引i处的当前结果将等于当前项目的value与在索引i – 1处的最大和的costcount值相等的和(即DP[i - 1][cost – price][count – quantity] + value)。

  1. 我们可以将前面的逻辑编码如下:
vector<vector<vector<int>>> DP(N + 1, vector<vector<int>>(budget + 1, vector<int>(capacity + 1, 0)));
for(int i = 1; i <= N; i++)
{
    Product product = products[i-1];

for(int cost = 0; cost <= budget; cost++)
{
        for(int count = 0; count <= capacity; count++)
        {
            if(cost < product.price || count < product.quantity)
            {
                DP[i][cost][count] = DP[i-1][cost][count];
            }
            else
            {
                DP[i][cost][count] = max
                (
                    DP[i-1][cost][count],
                    DP[i-1][cost – product.price][count – product.quantity] + product.value
                );
            }
        }
}
cout << DP[N][budget][capacity] << endl;
}  

正如你所看到的,该实现等同于具有额外维度的 0-1 背包解决方案。

活动 23:住宅道路

如果你不事先考虑,这个活动可能会有一些潜在的陷阱。它最困难的一点是,它需要许多不同的步骤,而在任何时候的疏忽错误都可能导致整个程序失败。因此,建议逐步实现。所需的主要步骤如下:

  1. 处理输入

  2. 构建图(找到邻接和权值)

  3. 查找图节点之间的最短距离

  4. 重建最短路径中的边

  5. 重绘输入网格

由于这比本章中的其他活动要长得多,让我们分别处理这些步骤。

步骤 0:初步设置

在编写与输入相关的任何代码之前,我们应该提前决定如何表示我们的数据。我们将收到的输入如下:

  • 两个整数HW,表示网格的高度和宽度。

  • 一个整数N,表示属性上包含的房屋数量。

  • H个宽度为W的字符串,表示属性的地图。我们可以将这些数据存储为一个包含字符串的H元素向量。

  • HW个整数,表示地形的崎岖程度。我们可以将这些值存储在一个整数矩阵中。

  • N行,包含两个整数xy,表示每个房屋的坐标。为此,我们可以创建一个简单的结构称为Point,包含两个整数xy

现在,让我们看看实现:

  1. 包括所需的头文件,并定义一些全局常量和变量,我们将在问题后面需要。出于方便起见,我们将大部分数据声明为全局数据,但值得重申的是,在全面应用的情况下,这通常被认为是不良实践:
#include <iostream>
#include <vector>
using namespace std;
const int UNKNOWN = 1e9;
const char EMPTY_SPACE = '.';
const string roads = "-|/\\";
struct Point
{
    int x;
    int y;
    Point(){}
    Point(int x, int y) : x(x), y(y) {}
};
int N;
int H, W;
vector<string> grid;
vector<vector<int>> terrain;
vector<vector<int>> cost;
vector<Point> houses;

步骤 1:处理输入

  1. 由于这个问题需要相当多的输入,让我们将它们都包含在自己的函数Input()中,该函数将返回void
void Input()
{
    cin >> H >> W;
    cin >> N;
    grid.resize(H);
    houses.resize(N);
    terrain.resize(H, vector<int>(W, UNKNOWN));    cost.resize(H, vector<int>(W, UNKNOWN));
    // Map of property
    for(auto &row : grid) cin >> row;
    // Terrain ruggedness
    for(int I = 0; i < H; i++)
    {
        for(int j = 0; j < W; j++)
        {
            cin >> terrain[i][j];
        }
    }
    // House coordinates
    for(int i = 0; i < N; i++)
    {
        cin >> houses[i].x >> house[i].y;
        // Set house labels in grid
        grid[houses[i].y][houses[i].x] = char(i + 'A');
    }
}

步骤 2:构建图

问题描述如下:

  • 只有在它们之间存在直接的水平、垂直或对角路径时,才能在两个房屋之间修建道路。

  • 道路不能修建在水域、山脉、森林等地方。

  • 在两个房屋之间修建道路的成本等于它们之间路径上的崎岖值之和。

要测试第一个条件,我们只需要比较两点的坐标,并确定以下三个条件中是否有任何一个为真:

  • A.x = B.x(它们之间有一条水平线)

  • A.y = B.y(它们之间有一条垂直线)

  • | A.x – B.x | = | A.y – B.y |(它们之间有一条对角线)

现在,让我们回到我们的代码。

  1. 为此,让我们编写一个名为DirectLine()的函数,它以两个点ab作为参数,并返回一个布尔值:
bool DirectLine(Point a, Point b)
{
    return a.x == b.x || a.y == b.y || abs(a.x – b.x) == abs(a.y – b.y);
}
  1. 为了处理第二和第三种情况,我们可以简单地在网格中从点a到点b执行线性遍历。当我们考虑网格中的每个点时,我们可以累积包含在地形矩阵中的值的总和。在这样做的同时,我们可以同时检查grid[a.y][a.x]中的字符,并在我们遇到一个不等于EMPTY_SPACE(即'.')的字符时终止它。如果在遍历结束时点a等于点b,我们将在cost矩阵中存储我们获得的总和;否则,我们已经确定ab之间没有邻接关系,在这种情况下,我们返回UNKNOWN。我们可以使用GetCost()函数来做到这一点,它接受两个整数startend作为参数。这些代表ab的索引,分别返回一个整数:
int GetCost(int start, int end)
{
    Point a = houses[start];
    Point b = houses[end];
    // The values by which the coordinates change on each iteration
    int x_dir = 0;
    int y_dir = 0;
    if(a.x != b.x)
    {
        x_dir = (a.x < b.x) ? 1 : -1;
    }
    if(a.y != b.y)
    {
        y_dir = (a.y < b.y) ? 1 : -1;
    }
    int cost = 0;

    do
    {
        a.x += x_dir;
        a.y += y_dir;
        cost += terrain[a.y][a.x];
    }
    while(grid[a.y][a.x] == '.');
    return (a != b) ? UNKNOWN : res;
}
  1. 最后一行要求我们在Point结构中定义operator !=
struct Point
{
    ......
    bool operator !=(const Point &other) const { return x != other.x || y != other.y; }
}
  1. 现在,让我们创建以下GetAdjacencies()函数:
void GetAdjacencies()
{
    for(int i = 0; i < N; i++)
    {
        for(int j = 0; j < N; j++)
        {
            if(DirectLine(houses[i], houses[j])
            {
                cost[i][j] = cost[j][i] = GetCost(i, j);
            }
        }
    }
}

步骤 3:查找节点之间的最短距离

问题说明两个房屋应该由一条道路连接,该道路位于最小化到达出口点的成本路径上。对于这个实现,我们将使用 Floyd-Warshall 算法。让我们回到我们的代码:

  1. 让我们定义一个名为GetShortestPaths()的函数,它将处理 Floyd-Warshall 的实现以及路径的重建。为了处理后一种情况,我们将维护一个名为nextN x N整数矩阵,它将存储从节点ab到最短路径上下一个点的索引。最初,它的值将设置为图中现有边的值:
void GetShortestPaths()
{
    vector<vector<int>> dist(N, vector<int>(N, UNKNOWN));
    vector<vector<int>> next(N, vector<int>(N, UNKNOWN));
for(int i = 0; i < N; i++)
{
    for(int j = 0; j < N; j++)
    {
        dist[i][j] = cost[i][j]
        if(dist[i][j] != UNKNOWN)
        {
            next[i][j] = j;
        }
    }
    dist[i][j] = 0;
    next[i][i] = i;
}
...
}
  1. 然后,我们将执行 Floyd-Warshall 的标准实现,在最内层循环中的一个额外行将next[start][end]设置为next[start][mid],每当我们发现startend之间的距离更短时:
for(int mid = 0; mid < N; mid++)
{
    for(int start = 0; start < N; start++)
    {
        for(int end = 0; end < N; end++)
        {
            if(dist[start][end] > dist[start][mid] + dist[mid][end])
            {
                dist[start][end] = dist[start][mid] + dist[mid][end];
                next[start][end] = next[start][mid];
            }
        }
    }
}

next矩阵,我们可以轻松地以与 LCS 或 0-1 背包问题的重建方法类似的方式重建每条路径上的点。为此,我们将定义另一个名为GetPath()的函数,它具有三个参数——两个整数startend,以及对next矩阵的引用,并返回一个整数向量,其中包含路径的节点索引:

vector<int> GetPath(int start, int end, vector<vector<int>> &next)
{
    vector<int> path = { start };
    do
    {
        start = next[start][end];
        path.push_back(start);
    }
    while(next[start][end] != end);
    return path;
}
  1. 回到GetShortestPaths(),我们现在将在 Floyd-Warshall 的实现下面添加一个循环,调用GetPath(),然后在网格中为路径中的每一对点绘制线条:
for(int i = 0; i < N; i++)
{
    auto path = GetPath(i, N – 1, next);

    int curr = i;
    for(auto neighbor : path)
    {
        DrawPath(curr, neighbor);
        curr = neighbor;
    }
}

步骤 5:重绘网格

  1. 现在,我们必须在网格中绘制道路。我们将在另一个名为DrawPath()的函数中执行此操作,该函数具有startend参数:
void DrawPath(int start, int end)
{
    Point a = houses[start];
    Point b = houses[end];
    int x_dir = 0;
    int y_dir = 0;
    if(a.x != b.x)
    {
        x_dir = (a.x < b.x) 1 : -1;
    }
    if(a.y != b.y)
    {
        y_dir = (a.y < b.y) 1 : -1;
    }

    ……
}
  1. 我们需要选择与每条道路方向相对应的正确字符。为此,我们将定义一个名为GetDirection()的函数,它返回一个整数,对应于我们在开始时定义的roads字符串中的索引("-|/\"):
int GetDirection(int x_dir, int y_dir)
{
    if(y_dir == 0) return 0;
    if(x_dir == 0) return 1;
    if(x_dir == -1)
    {
        return (y_dir == 1) ? 2 : 3;
    }
    return (y_dir == 1) ? 3 : 2;
}
void DrawPath(int start, int end)
{
    ……
    int direction = GetDirection(x_dir, y_dir);
    char mark = roads[direction];
        ……
}
  1. 现在,我们可以从ab进行线性遍历,如果其值为EMPTY_SPACE,则将网格中的每个单元格设置为mark。否则,我们必须检查单元格中的字符是否是不同方向的道路字符,如果是,则将其设置为+
do
{
    a.x += x_dir;
    a.y += y_dir;

    if(grid[a.y][a.x] == EMPTY_SPACE)
    {
        grid[a.y][a.x] = mark;
    }
    else if(!isalpha(grid[a.y][a.x]))
    {
            // If two roads of differing orientations intersect, replace symbol with '+'
            grid[a.y][a.x] = (mark != grid[a.y][a.x]) ? '+' : mark;
    }
}
while(a != b);
  1. 现在,唯一剩下的就是在main()中调用我们的函数并打印输出:
int main()
{
        Input();
        BuildGraph();
        GetShortestPaths();

        for(auto it : grid)
        {
            cout << it << endl;
        }
        return 0;
}
posted @ 2024-05-04 22:47  绝不原创的飞龙  阅读(118)  评论(0编辑  收藏  举报