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

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

译者:飞龙

协议:CC BY-NC-SA 4.0

第九章:动态规划 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 
        (00) = FALSE
        (01) = TRUE
        (10) = TRUE
        (11) = TRUE
(a1 AND a2)> TRUE
        (00) = FALSE
        (01) = FALSE
        (10) = FALSE
        (11) = TRUE
(a1 NOT a1)> FALSE 
        (0 ¬ 0) = FALSE
        (1 ¬ 1) = FALSE
(a1 NOT a2) AND (a1 AND a2)> FALSE 
        (0 ¬ 0)(00) = FALSE
        (0 ¬ 1)(01) = FALSE
        (1 ¬ 0)(10) = FALSE
        (1 ¬ 1)(11) = 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:一个解决的数独难题

验证需要扫描矩阵的每一行和每一列,并确定每个九个 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 背包问题的所有可能子集

这绝对看起来很熟悉。这是否需要对子集和算法进行轻微修改?

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 三角形中的下一个值

使用制表法解决寻找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 表

正如我们所看到的,这个算法在内存使用和冗余计算方面都是相当浪费的。显而易见的问题是,尽管只有一行包含那么多的值,但表格却有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 表

我们用来生成上表的逻辑是基于解决 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:每个重量-索引对的子问题结果

正如前表所示,允许重复意味着只要包含在最大容量内,就不需要排除任何物品。因此,无论重量总和是否可以在集合的索引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://github.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://github.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.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:可视化贝尔曼-福特算法

将此表示为伪代码将看起来类似于以下内容:

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.15:贝尔曼-福特算法第 2 步

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

图 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:最短路径问题的所有状态

我们将在下一个练习中看看这种方法的实现。

练习 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(右)

让我们考虑前一图中右侧的示例输出。在该示例中,从E(0,4)C(5,4)的路径无法建立,因为存在不可通过的障碍物#。让我们考虑一些更复杂的示例:

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

图 9.19:活动 23,测试案例 3(左)和 4(右)

请注意,不同的符号用于表示不同类型的障碍物。尽管任何障碍物的影响都是相同的,但我们不能在那里建造道路。最后,让我们在以下示例中增加复杂性:

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

图 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 的输出

活动 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 的输出

第七章:图算法 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:定义递归的公式

换句话说,到达给定位置的方式数量等于到达连接到它的每个位置的方式数量的总和。使用这种逻辑,解决这个问题的递归函数可能看起来像这样:

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 表

通过收集与路径中值增加相关的每列的字符,我们得到 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;
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值