C++-高性能编程(二)

299 阅读1小时+

C++ 高性能编程(二)

原文:annas-archive.org/md5/753c0f2773b6b78b5104ecb1b57442d4

译者:飞龙

协议:CC BY-NC-SA 4.0

分析和测量性能

由于这是一本关于编写高效运行的 C++代码的书,我们需要涵盖一些关于如何衡量软件性能和估算算法效率的基础知识。本章大部分主题并不特定于 C++,在面对性能问题时都可以使用。

您将学习如何使用大 O 符号估算算法效率。在选择 C++标准库中的算法和数据结构时,这是必不可少的知识。如果您对大 O 符号不熟悉,这部分可能需要一些时间来消化。但不要放弃!这是一个非常重要的主题,以便理解本书的其余部分,更重要的是,成为一个注重性能的程序员。如果您想要更正式或更实用的介绍这些概念,有很多专门讨论这个主题的书籍和在线资源。另一方面,如果您已经掌握了大 O 符号并知道摊销时间复杂度是什么,您可以略过下一节,转到本章的后面部分。

本章包括以下部分:

  • 使用大 O 符号估算算法效率

  • 优化代码的建议工作流程,这样您不会在没有充分理由的情况下花费时间微调代码

  • CPU 性能分析器——它们是什么以及为什么你应该使用它们

  • 微基准测试

让我们首先看一下如何使用大 O 符号来估算算法效率。

渐近复杂度和大 O 符号

通常解决问题的方法不止一种,如果效率是一个问题,您应该首先专注于通过选择正确的算法和数据结构进行高级优化。评估和比较算法的一个有用方法是分析它们的渐近计算复杂性——也就是分析输入大小增加时运行时间或内存消耗的增长情况。此外,C++标准库为所有容器和算法指定了渐近复杂度,这意味着如果您使用这个库,对这个主题的基本理解是必须的。如果您已经对算法复杂度和大 O 符号有很好的理解,可以安全地跳过本节。

让我们以一个例子开始。假设我们想编写一个算法,如果在数组中找到特定的键,则返回true,否则返回false。为了找出我们的算法在不同大小的数组上的行为,我们希望分析这个算法的运行时间作为其输入大小的函数:

bool linear_search(const std::vector<int>& vals, int key) noexcept { 
  for (const auto& v : vals) { 
    if (v == key) { 
      return true; 
    } 
  } 
  return false; 
} 

该算法很简单。它遍历数组中的元素,并将每个元素与键进行比较。如果我们幸运的话,在数组的开头找到键并立即返回,但我们也可能在整个数组中循环而根本找不到键。这将是算法的最坏情况,通常情况下,这是我们想要分析的情况。

但是当我们增加输入大小时,运行时间会发生什么变化?假设我们将数组的大小加倍。嗯,在最坏的情况下,我们需要比较数组中的所有元素,这将使运行时间加倍。输入大小和运行时间之间似乎存在线性关系。我们称这为线性增长率。

图 3.1:线性增长率

现在考虑以下算法:

struct Point { 
  int x_{}; 
  int y_{}; 
}; 

bool linear_search(const std::vector<Point>& a, const Point& key) { 
  for (size_t i = 0; i < a.size(); ++i) { 
    if (a[i].x_ == key.x_ && a[i].y_ == key.y_) { 
      return true; 
    } 
  } 
  return false; 
} 

我们比较的是点而不是整数,并且我们使用下标运算符的索引来访问每个元素。这些变化如何影响运行时间?绝对运行时间可能比第一个算法高,因为我们做了更多的工作——例如,比较点涉及两个整数,而不是数组中每个元素的一个整数。然而,在这个阶段,我们对算法表现的增长率感兴趣,如果我们将运行时间绘制成输入大小的函数,我们仍然会得到一条直线,如前图所示。

作为搜索整数的最后一个例子,让我们看看是否可以找到更好的算法,如果我们假设数组中的元素是排序的。我们的第一个算法将在元素的顺序无关紧要的情况下工作,但是如果我们知道它们是排序的,我们可以使用二分搜索。它通过查看中间的元素来确定它是否应该继续在数组的第一半或第二半中搜索。为简单起见,索引highlowmid的类型为int,需要static_cast。更好的选择是使用迭代器,这将在后续章节中介绍。以下是算法:

bool binary_search(const std::vector<int>& a, int key) {
  auto low = 0; 
  auto high = static_cast<int>(a.size()) - 1;
  while (low <= high) {
    const auto mid = std::midpoint(low, high); // C++20
    if (a[mid] < key) {
      low = mid + 1;
    } else if (a[mid] > key) {
      high = mid - 1;
    } else {
      return true;
    }
  }
  return false;
} 

正如您所看到的,这个算法比简单的线性扫描更难正确实现。它通过猜测数组中间的元素来寻找指定的键。如果不是,它将比较键和中间的元素,以决定应该在数组的哪一半中继续寻找键。因此,在每次迭代中,它将数组减半。

假设我们使用包含 64 个元素的数组调用binary_search()。在第一次迭代中,我们拒绝 32 个元素,在下一次迭代中,我们拒绝 16 个元素,在下一次迭代中,我们拒绝 8 个元素,依此类推,直到没有更多元素可以比较,或者直到我们找到键。对于输入大小为 64,最多将有 7 次循环迭代。如果我们将输入大小加倍到 128 呢?由于我们在每次迭代中将大小减半,这意味着我们只需要再进行一次循环迭代。显然,增长率不再是线性的——实际上是对数的。如果我们测量binary_search()的运行时间,我们将看到增长率看起来类似于以下内容:

图 3.2:对数增长率

在我的机器上,对三种算法进行快速计时,每次调用 10,000 次,不同输入大小(n)产生了以下表中显示的结果:

算法n = 10n = 1,000n = 100,000
使用int的线性搜索0.04 毫秒4.7 毫秒458 毫秒
使用Point的线性搜索0.07 毫秒6.7 毫秒725 毫秒
使用int的二分搜索0.03 毫秒0.08 毫秒0.16 毫秒

表 3.1:不同版本搜索算法的比较

比较算法 1 和 2,我们可以看到,比较点而不是整数需要更多时间,但即使输入大小增加,它们仍然处于相同数量级。然而,当输入大小增加时,比较所有三种算法时,真正重要的是算法表现出的增长率。通过利用数组已排序的事实,我们可以用很少的循环迭代来实现搜索功能。对于大数组,与线性扫描数组相比,二分搜索实际上是免费的。

在确定选择正确的算法和数据结构之前,花时间调整代码通常不是一个好主意。

如果我们能以一种有助于我们决定使用哪种算法的方式来表达算法的增长率,那不是很好吗?这就是大 O 符号表示法派上用场的地方。

以下是一个非正式的定义:

如果f(n)是一个指定算法在输入大小n的运行时间的函数,我们说f(n)O(g(n)),如果存在一个常数k,使得

这意味着我们可以说linear_search()的时间复杂度是O(n),对于两个版本(一个操作整数,一个操作点),而binary_search()的时间复杂度是*O(log n)或者O(log n)*的大 O。

实际上,当我们想要找到一个函数的大 O 时,我们可以通过消除除了具有最大增长率的项之外的所有项,然后去掉任何常数因子来做到这一点。例如,如果我们有一个时间复杂度由f(n) = 4n² + 30n + 100描述的算法,我们挑出具有最高增长率的项,4n²。接下来,我们去掉常数因子 4,最终得到n²,这意味着我们可以说我们的算法运行在O(n²*)*。找到算法的时间复杂度可能很难,但是当你在编写代码时开始思考它时,它会变得更容易。在大多数情况下,跟踪循环和递归函数就足够了。

让我们试着找出以下排序算法的时间复杂度:

void insertion_sort(std::vector<int>& a) { 
  for (size_t i = 1; i < a.size(); ++i) { 
    auto j = i; 
    while (j > 0 && a[j-1] > a[j]) {  
      std::swap(a[j], a[j-1]); 
      --j;  
    } 
  } 
} 

输入大小是数组的大小。通过查看迭代所有元素的循环,可以大致估计运行时间。首先,有一个迭代n - 1个元素的外部循环。内部循环不同:第一次到达while循环时,j为 1,循环只运行一次。在下一次迭代中,j从 2 开始减少到 0。对于外部for循环的每次迭代,内部循环需要做更多的工作。最后,jn - 1开始,这意味着在最坏的情况下,我们执行了swap()1 + 2 + 3 + ... + (n - 1)次。我们可以通过注意到这是一个等差数列来用n来表示这一点。数列的和是:

因此,如果我们设k = (n - 1),排序算法的时间复杂度是:

我们现在可以通过首先消除除了具有最大增长率的项之外的所有项来找到这个函数的大 O,这让我们得到了*(1/2)n²。之后,我们去掉常数1/2*,得出排序算法的运行时间是O(n²*)*。

增长率

如前所述,找到复杂度函数的大 O 的第一步是消除除了具有最高增长率的项之外的所有项。为了能够做到这一点,我们必须知道一些常见函数的增长率。在下图中,我画出了一些最常见的函数:

图 3.3:增长率函数的比较

增长率与机器或编码风格等无关。当两个算法之间的增长率不同时,当输入大小足够大时,增长率最慢的算法将始终获胜。让我们看看不同增长率的运行时间会发生什么,假设执行 1 单位的工作需要 1 毫秒。下表列出了增长函数、其常见名称和不同的输入大小n

大 O名称n = 10n = 50n = 1000
O(1)常数0.001 秒0.001 秒0.001 秒
O(log n)对数0.003 秒0.006 秒0.01 秒
O(n)线性0.01 秒0.05 秒1 秒
O(n log n)线性对数或n log n0.03 秒0.3 秒10 秒
O(n²*)*二次方0.1 秒2.5 秒16.7 分钟
O(2^n*)*指数1 秒35,700 年3.4 * 10²⁹⁰年

表 3.2:不同增长率和各种输入大小的绝对运行时间

注意右下角的数字是一个 291 位数!将其与宇宙的年龄 13.7 * 10⁹年相比较,后者只是一个 11 位数。

接下来,我将介绍摊销时间复杂度,这在 C++标准库中经常使用。

摊销时间复杂度

通常,算法在不同的输入下表现不同。回到我们线性搜索数组中元素的算法,我们分析了一个关键字根本不在数组中的情况。对于该算法,这是最坏情况,即算法将需要最多的资源。最佳情况是指算法将需要最少的资源,而平均情况指定了算法在不同输入下平均使用的资源量。

标准库通常指的是对容器进行操作的函数的摊销运行时间。如果算法以恒定的摊销时间运行,这意味着它在几乎所有情况下都将以*O(1)*运行,只有极少数情况下会表现得更差。乍一看,摊销运行时间可能会与平均时间混淆,但正如您将看到的那样,它们并不相同。

为了理解摊销时间复杂度,我们将花一些时间思考std::vector::push_back()。假设向量在内部具有固定大小的数组来存储所有元素。当调用push_back()时,如果固定大小数组中还有空间可以存放更多元素,则该操作将在常数时间*O(1)*内运行,即不依赖于向量中已有多少元素,只要内部数组还有空间可以存放一个以上的元素:

if (internal_array.size() > size) { 
  internal_array[size] = new_element; 
  ++size; 
} 

但是当内部数组已满时会发生什么?处理增长向量的一种方法是创建一个新的空内部数组,大小更大,然后将所有元素从旧数组移动到新数组。这显然不再是常数时间,因为我们需要对数组中的每个元素进行一次移动,即O(n)。如果我们认为这是最坏情况,那么这意味着push_back()O(n)。然而,如果我们多次调用push_back(),我们知道昂贵的push_back()不会经常发生,因此如果我们知道push_back()连续调用多次,那么说push_back()是*O(n)*是悲观且不太有用的。

摊销运行时间用于分析一系列操作,而不是单个操作。我们仍然在分析最坏情况,但是针对一系列操作。摊销运行时间可以通过首先分析整个序列的运行时间,然后将其除以序列的长度来计算。假设我们执行一系列m个操作,总运行时间为T(m)

其中t[0] = 1, t[1] = n, t[2] = 1, t[3] = n,依此类推。换句话说,一半的操作在常数时间内运行,另一半在线性时间内运行。所有m个操作的总时间T可以表示如下:

每个操作的摊销复杂度是总时间除以操作数,结果为O(n)

然而,如果我们可以保证昂贵操作的次数与常数时间操作的次数相比相差很大,我们将实现更低的摊销运行成本。例如,如果我们可以保证昂贵操作仅在序列T(n) + T(1) + T(1) + ...中发生一次,那么摊销运行时间为O(1)。因此,根据昂贵操作的频率,摊销运行时间会发生变化。

现在,回到std::vector。C++标准规定push_back()需要在摊销常数时间内运行,O(1)。库供应商是如何实现这一点的呢?如果每次向量变满时容量增加固定数量的元素,我们将会有一个类似于前面的情况,其中运行时间为O(n)。即使使用一个大常数,容量变化仍然会以固定间隔发生。关键的见解是向量需要呈指数增长,以便使昂贵的操作发生得足够少。在内部,向量使用增长因子,使得新数组的容量是当前大小乘以增长因子。

一个大的增长因子可能会浪费更多的内存,但会使昂贵的操作发生得更少。为了简化数学计算,让我们使用一个常见的策略,即每次向量需要增长时都加倍容量。现在我们可以估计昂贵调用发生的频率。对于大小为n的向量,我们需要增长内部数组log[2](n)次,因为我们一直在加倍大小。每次增长数组时,我们需要移动当前数组中的所有元素。当我们增长数组的第i次时,将有2^i 个元素需要移动。因此,如果我们执行mpush_back()操作,增长操作的总运行时间将是:

这是一个等比数列,也可以表示为:

将这个除以序列的长度m,我们最终得到摊销运行时间O(1)

正如我已经说过的,摊销时间复杂度在标准库中被广泛使用,因此了解这种分析是很有帮助的。思考push_back()如何在摊销常数时间内实现已经帮助我记住了摊销常数时间的简化版本:它几乎在所有情况下都是O(1),只有极少数情况下会表现得更差。

这就是我们将要涵盖的关于渐近复杂度的全部内容。现在我们将继续讨论如何解决性能问题,并通过优化代码来有效地工作。

要测量什么以及如何测量?

优化几乎总是会给你的代码增加复杂性。高级优化,比如选择算法和数据结构,可以使代码的意图更清晰,但在大多数情况下,优化会使代码更难阅读和维护。因此,我们要确信我们添加的优化对我们在性能方面试图实现的目标有实际影响。我们真的需要让代码更快吗?以何种方式?代码真的使用了太多内存吗?为了了解可能的优化,我们需要对要求有一个很好的理解,比如延迟、吞吐量和内存使用。

优化代码是有趣的,但也很容易在没有可衡量的收益的情况下迷失方向。我们将从建议的工作流程开始,以便在调整代码时进行优化:

  1. 定义一个目标:如果有一个明确定义的定量目标,那么知道如何优化以及何时停止优化会更容易。对于一些应用程序,从一开始就很明确要求是什么,但在许多情况下,要求往往更加模糊。即使代码运行太慢可能是显而易见的,但知道什么是足够好是很重要的。每个领域都有自己的限制,所以确保你了解与你的应用程序相关的限制。以下是一些例子,以使其更具体:
  1. 测量:一旦我们知道要测量什么和限制是什么,我们就可以通过测量应用程序当前的性能来继续。从步骤 1开始,如果我们对平均时间、峰值、负载等感兴趣,那么很明显。在这一步中,我们只关心测量我们设定的目标。根据应用程序的不同,测量可以是从使用秒表到使用高度复杂的性能分析工具的任何事情。

  2. 找出瓶颈:接下来,我们需要找出应用程序的瓶颈——那些太慢的部分,使应用程序变得无用。此时不要相信你的直觉!也许在步骤 2的不同点测量代码时你获得了一些见解——这很好,但通常你需要进一步对代码进行分析,以找到最重要的热点。

  3. 做出合理猜测:提出一个如何提高性能的假设。可以使用查找表吗?我们可以缓存数据以获得整体吞吐量吗?我们可以改变代码以便编译器可以对其进行矢量化吗?我们可以通过重用内存来减少关键部分的分配次数吗?如果你知道这些只是合理的猜测,提出想法通常并不那么困难。错了也没关系——你以后会发现它们是否产生了影响。

  4. 优化:让我们实现我们在步骤 4中勾画的假设。在知道它是否真的产生效果之前,不要在这一步上花费太多时间使其完美。准备拒绝这种优化。它可能没有预期的效果。

  5. 评估:再次测量。做与步骤 2中完全相同的测试,并比较结果。我们得到了什么?如果我们没有得到任何东西,拒绝这段代码并返回步骤 4。如果优化实际上产生了积极的效果,你需要问自己是否值得再花更多时间。这种优化有多复杂?是否值得努力?这是一般性能提升还是高度特定于某种情况/平台?它是否可维护?我们能封装它吗,还是它散布在整个代码库中?如果你无法证明这种优化,返回步骤 4,否则继续进行最后一步。

  6. 重构:如果你遵循了步骤 5中的指示,并且在一开始没有花太多时间编写完美的代码,那么现在是时候重构优化以使其更清晰了。优化几乎总是需要一些注释来解释为什么我们以一种不寻常的方式做事情。

遵循这个过程将确保你保持在正确的轨道上,不会最终得到没有动机的复杂优化。花时间定义具体目标和测量的重要性不可低估。为了在这个领域取得成功,你需要了解哪些性能特性对你的应用程序是相关的。

性能特性

在开始测量之前,你必须知道对你正在编写的应用程序来说哪些性能特性是重要的。在本节中,我将解释一些在测量性能时经常使用的术语。根据你正在编写的应用程序,有些特性比其他特性更相关。例如,如果你正在编写在线图像转换服务,吞吐量可能比延迟更重要,而在编写具有实时要求的交互式应用程序时,延迟就很关键。以下是一些在性能测量过程中值得熟悉的有价值的术语和概念:

  • 延迟/响应时间:根据领域的不同,延迟和响应时间可能有非常精确和不同的含义。然而,在本书中,我指的是请求和操作响应之间的时间——例如,图像转换服务处理一个图像所需的时间。

  • 吞吐量:这指的是每个时间单位处理的交易(操作,请求等)的数量,例如,图像转换服务每秒可以处理的图像数量。

  • I/O 绑定或 CPU 绑定:任务通常在 CPU 上计算大部分时间或等待 I/O(硬盘,网络等)。如果 CPU 速度更快,任务通常会更快,就称为 CPU 绑定。如果通过加快 I/O 速度,任务通常会更快,就称为 I/O 绑定。有时你也会听到内存绑定任务,这意味着主内存的数量或速度是当前的瓶颈。

  • 功耗:这对于在带电池的移动设备上执行的代码来说非常重要。为了减少功耗,应用程序需要更有效地使用硬件,就像我们在优化 CPU 使用率,网络效率等一样。除此之外,应该避免高频率轮询,因为它会阻止 CPU 进入睡眠状态。

  • 数据聚合:在进行性能测量时,当收集大量样本时通常需要对数据进行聚合。有时平均值足以成为程序性能的良好指标,但更常见的是中位数,因为它对异常值更具鲁棒性,可以更多地告诉你实际性能。如果你对异常值感兴趣,你可以测量最小最大值(或者例如第 10 百分位数)。

这个列表并不是详尽无遗的,但这是一个很好的开始。在这里要记住的重要事情是,在测量性能时,我们可以使用已经确立的术语和概念。花一些时间来定义我们所说的优化代码实际意味着帮助我们更快地达到我们的目标。

执行时间的加速

当我们比较程序或函数的两个版本之间的相对性能时,通常习惯谈论加速。在这里我将给出一个比较执行时间(或延迟)时的加速定义。假设我们已经测量了某段代码的两个版本的执行时间:一个旧的较慢版本和一个新的较快版本。执行时间的加速可以相应地计算如下:

其中T[old]是代码初始版本的执行时间,T[new]是优化版本的执行时间。这个加速的定义意味着加速比为 1 表示根本没有加速。

让我们通过一个例子来确保你知道如何测量相对执行时间。假设我们有一个函数,执行时间为 10 毫秒(T[old] = 10 毫秒),经过一些优化后我们设法让它在 4 毫秒内运行(T[new] = 4 毫秒)。然后我们可以计算加速比如下:

换句话说,我们的新优化版本提供了 2.5 倍的加速。如果我们想将这种改进表示为百分比,我们可以使用以下公式将加速转换为百分比改进:

然后我们可以说新版本的代码比旧版本快 60%,这对应着 2.5 倍的加速。在本书中,当比较执行时间时,我将一贯使用加速,而不是百分比改进。

最终,我们通常对执行时间感兴趣,但时间并不总是最好的衡量标准。通过检查硬件上的其他值,硬件可能会给我们一些其他有用的指导,帮助我们优化我们的代码。

性能计数器

除了显而易见的属性,比如执行时间和内存使用,有时候测量其他东西可能会更有益。要么是因为它们更可靠,要么是因为它们可以更好地帮助我们了解导致代码运行缓慢的原因。

许多 CPU 配备了硬件性能计数器,可以为我们提供诸如指令数、CPU 周期、分支错误预测和缓存未命中等指标。我在本书中尚未介绍这些硬件方面,我们也不会深入探讨性能计数器。但是,知道它们的存在以及所有主要操作系统都有现成的工具和库(通过 API 可访问)来收集运行程序时的性能监视计数器PMC)是很有好处的。

性能计数器的支持因 CPU 和操作系统而异。英特尔提供了一个强大的工具称为 VTune,可用于监视性能计数器。FreeBSD 提供了pmcstat。macOS 自带 DTrace 和 Xcode Instruments。微软 Visual Studio 在 Windows 上提供了收集 CPU 计数器的支持。

另一个流行的工具是perf,它在 GNU/Linux 系统上可用。运行命令:

perf stat ./your-program 

将显示许多有趣的事件,例如上下文切换的次数,页面错误,错误的预测分支等。以下是运行小程序时输出的示例:

Performance counter stats for './my-prog':
     1 129,86 msec task-clock               # 1,000 CPUs utilized          
            8      context-switches         # 0,007 K/sec                  
            0      cpu-migrations           # 0,000 K/sec                  
       97 810      page-faults              # 0,087 M/sec                  
3 968 043 041      cycles                   # 3,512 GHz                    
1 250 538 491      stalled-cycles-frontend  # 31,52% frontend cycles idle
  497 225 466      stalled-cycles-backend   # 12,53% backend cycles idle    
6 237 037 204      instructions             # 1,57  insn per cycle         
                                            # 0,20  stalled cycles per insn
1 853 556 742      branches                 # 1640,516 M/sec                  
    3 486 026      branch-misses            # 0,19% of all branches        
  1,130355771 sec  time elapsed
  1,026068000 sec  user
  0,104210000 sec  sys 

我们现在将重点介绍一些测试和评估性能的最佳实践。

性能测试-最佳实践

由于某种原因,更常见的是回归测试涵盖功能要求,而不是性能要求或其他非功能要求在测试中得到覆盖。性能测试通常更加零星地进行,而且往往太晚了。我的建议是通过将性能测试添加到每晚的构建中,尽早测量并尽快检测到回归。

如果要处理大量输入,则明智地选择算法和数据结构,但不要没有充分理由就对代码进行微调。早期使用真实测试数据测试应用程序也很重要。在项目早期就询问数据大小的问题。应用程序应该处理多少表行并且仍然能够平稳滚动?不要只尝试 100 个元素并希望您的代码能够扩展-进行测试!

绘制数据是了解收集到的数据的一种非常有效的方式。今天有很多好用的绘图工具,所以没有理由不绘图。RStudio 和 Octave 都提供强大的绘图功能。其他例子包括 gnuplot 和 Matplotlib(Python),它们可以在各种平台上使用,并且在收集数据后需要最少的脚本编写来生成有用的图表。图表不一定要看起来漂亮才有用。一旦绘制了数据,您将能够看到通常在充满数字的表中很难找到的异常值和模式。

这结束了我们的要测量和如何测量?部分。接下来,我们将探索找到代码中浪费太多资源的关键部分的方法。

了解您的代码和热点

帕累托原则,或 80/20 法则,自 100 多年前意大利经济学家维尔弗雷多·帕累托首次观察到以来,已经在各个领域得到应用。他能够证明意大利人口的 20%拥有 80%的土地。在计算机科学中,它已被广泛使用,甚至可能被过度使用。在软件优化中,它表明代码的 20%负责程序使用的 80%资源。

当然,这只是一个经验法则,不应该被过于字面理解。尽管如此,对于尚未优化的代码,通常会发现一些相对较小的热点,它们消耗了绝大部分的资源。作为程序员,这实际上是个好消息,因为这意味着我们可以大部分时间编写代码而不需要为了性能而对其进行调整,而是专注于保持代码的清晰。这也意味着在进行优化时,我们需要知道在哪里进行优化;否则,我们很可能会优化对整体性能没有影响的代码。在本节中,我们将探讨寻找可能值得优化的代码中的 20%的方法和工具。

使用性能分析器通常是识别程序中热点的最有效方法。性能分析器分析程序的执行并输出函数或指令被调用的统计摘要,即性能分析结果。

此外,性能分析器通常还会输出一个调用图,显示函数调用之间的关系,即每个在分析期间被调用的函数的调用者和被调用者。在下图中,您可以看到sort()函数是从main()(调用者)调用的,而sort()又调用了swap()函数(被调用者):

图 3.4:调用图的示例。函数sort()被调用一次,并调用swap() 50 次。

性能分析器主要分为两类:采样性能分析器和插装性能分析器。这两种方法也可以混合使用,创建采样和插装的混合性能分析器。Unix 性能分析工具gprof就是一个例子。接下来的部分将重点介绍插装性能分析器和采样性能分析器。

插装性能分析器

通过插装,我指的是向程序中插入代码以便分析,以收集关于每个函数被执行频率的信息。通常,插入的插装代码记录每个入口和出口点。您可以通过手动插入代码来编写自己的原始插装性能分析器,或者您可以使用一个工具,在构建过程中自动插入必要的代码。

一个简单的实现可能对您的目的足够了,但要注意添加的代码对性能的影响,这可能会使性能分析结果产生误导。像这样的天真实现的另一个问题是,它可能会阻止编译器优化或者有被优化掉的风险。

仅仅举一个插装性能分析器的例子,这里是一个我在以前项目中使用过的计时器类的简化版本:

class ScopedTimer { 
public: 
  using ClockType = std::chrono::steady_clock;
  ScopedTimer(const char* func) 
      : function_name_{func}, start_{ClockType::now()} {}
  ScopedTimer(const ScopedTimer&) = delete; 
  ScopedTimer(ScopedTimer&&) = delete; 
  auto operator=(const ScopedTimer&) -> ScopedTimer& = delete; 
  auto operator=(ScopedTimer&&) -> ScopedTimer& = delete;
  ~ScopedTimer() {
    using namespace std::chrono;
    auto stop = ClockType::now(); 
    auto duration = (stop - start_); 
    auto ms = duration_cast<milliseconds>(duration).count(); 
    std::cout << ms << " ms " << function_name_ << '\n'; 
  } 

private: 
  const char* function_name_{}; 
  const ClockType::time_point start_{}; 
}; 

ScopedTimer类将测量从创建到超出作用域(即析构)的时间。我们使用自 C++11 以来可用的std::chrono::steady_clock类,它专门用于测量时间间隔。steady_clock是单调的,这意味着在两次连续调用clock_type::now()之间它永远不会减少。这对于系统时钟来说并非如此,例如,系统时钟可以随时调整。

我们现在可以通过在每个函数的开头创建一个ScopedTimer实例来使用我们的计时器类:

auto some_function() {
  ScopedTimer timer{"some_function"};
  // ...
} 

尽管我们通常不建议使用预处理宏,但这可能是使用预处理宏的一个案例:

#if USE_TIMER 
#define MEASURE_FUNCTION() ScopedTimer timer{__func__} 
#else 
#define MEASURE_FUNCTION() 
#endif 

我们使用自 C++11 以来可用的唯一预定义的函数局部__func__变量来获取函数的名称。C++20 还引入了方便的std::source_location类,它为我们提供了function_name()file_name()line()column()等函数。如果您的编译器尚不支持std::source_location,还有其他非标准的预定义宏被广泛支持,对于调试目的非常有用,例如__FUNCTION____FILE____LINE__

现在,我们的ScopedTimer类可以像这样使用:

auto some_function() { 
  MEASURE_FUNCTION(); 
  // ...
} 

假设我们在编译计时器时定义了USE_TIMER,那么每次some_function()返回时,它将产生以下输出:

2.3 ms some_function 

我已经演示了如何通过在代码中插入打印两个代码点之间经过的时间的代码来手动检测我们的代码。虽然这对于某些情况来说是一个方便的工具,请注意这样一个简单工具可能产生误导性的结果。在下一节中,我将介绍一种不需要对执行代码进行任何修改的性能分析方法。

采样分析器

采样分析器通过在均匀间隔(通常为每 10 毫秒)查看运行程序的状态来创建概要。采样分析器通常对程序的实际性能影响很小,并且还可以在启用所有优化的发布模式下构建程序。采样分析器的缺点是它们的不准确性和统计方法,通常只要你意识到这一点,这通常不是问题。

下图显示了一个运行程序的采样会话,其中包含五个函数:main()f1()f2()f3()f4()。标签t[1] - t[10]表示每个样本的取样时间。方框表示每个执行函数的入口和出口点:

图 3.5:采样分析器会话的示例

概要显示在下表中:

函数总数自身
main()100%10%
f1()80%10%
f2()70%30%
f3()50%50%

表 3.3:对于每个函数,概要显示了它出现在调用堆栈中的总百分比(Total)以及它出现在堆栈顶部的百分比(Self)。

前表中的Total列显示了包含某个函数的调用堆栈的百分比。在我们的示例中,主函数在 10 个调用堆栈中都出现(100%),而f2()函数只在 7 个调用堆栈中被检测到,占所有调用堆栈的 70%。

Self列显示了每个函数在调用堆栈顶部出现的次数。main()函数在第五个样本t[5]中被检测到在调用堆栈顶部出现一次,而f2()函数在样本t[6]、t[8]和t[9]中出现在调用堆栈顶部,对应 3/10 = 30%。

f3()函数具有最高的Self值(5/10),每当检测到它时,它都位于调用堆栈的顶部。

在概念上,采样分析器以均匀的时间间隔存储调用堆栈的样本。它检测当前在 CPU 上运行的内容。纯采样分析器通常只检测当前在运行状态的线程中执行的函数,因为休眠线程不会被调度到 CPU 上。这意味着如果一个函数正在等待导致线程休眠的锁,那么这段时间不会显示在时间概要中。这很重要,因为您的瓶颈可能是由线程同步引起的,这可能对采样分析器是不可见的。

f4()函数发生了什么?根据图表,它在样本二和三之间被f2()函数调用,但它从未出现在我们的统计概要中,因为它从未在任何调用堆栈中注册过。这是采样分析器的一个重要特性。如果每个样本之间的时间太长或总采样会话时间太短,那么短且不经常调用的函数将不会出现在概要中。这通常不是问题,因为这些函数很少是您需要调整的函数。您可能注意到f3()函数也在t[5]和t[6]之间被错过了,但由于f3()被频繁调用,它对概要产生了很大的影响。

确保您了解您的时间分析器实际上记录了什么。要充分利用它,要意识到它的局限性和优势。

微基准测试

分析可以帮助我们找到代码中的瓶颈。如果这些瓶颈是由低效的数据结构(见第四章数据结构)、算法选择错误(见第五章算法)或不必要的争用(见第十一章并发)引起的,那么应该首先解决这些更大的问题。但有时我们会发现需要优化的小函数或小代码块,在这种情况下,我们可以使用一种称为微基准测试的方法。通过这个过程,我们创建一个微基准测试——一个在程序的其余部分中孤立运行小代码片段的程序。微基准测试的过程包括以下步骤:

  1. 找到需要调整的热点,最好使用分析器。

  2. 将其与其余代码分离并创建一个孤立的微基准测试。

  3. 优化微基准测试。使用基准测试框架在优化过程中测试和评估代码。

  4. 将新优化的代码集成到程序中,然后重新测量,看看当代码在更大的上下文中运行时,优化是否相关。

该过程的四个步骤如下图所示:

图 3.6:微基准测试过程

微基准测试很有趣。然而,在着手尝试加快特定函数之前,我们应该首先确保:

  • 运行程序时在函数内部花费的时间显着影响我们想要加速的程序的整体性能。分析和阿姆达尔定律将帮助我们理解这一点。下面将解释阿姆达尔定律。

  • 我们无法轻易减少函数被调用的次数。消除对昂贵函数的调用通常是优化程序整体性能最有效的方法。

使用微基准测试来优化代码通常应该被视为最后的手段。预期的整体性能提升通常很小。然而,有时我们无法避免需要通过调整实现来加快相对较小的代码片段的运行速度,而在这些情况下,微基准测试可以非常有效。

接下来,您将了解微基准测试的加速比如何影响程序的整体加速比。

阿姆达尔定律

在使用微基准测试时,要牢记孤立代码的优化对整个程序的影响有多大(或多小)是至关重要的。我们的经验是,有时在改进微基准测试时很容易有点过于兴奋,只是意识到整体效果几乎可以忽略不计。使用健全的分析技术部分地解决了这种无法前进的风险,同时也要牢记优化的整体影响。

假设我们正在优化程序中的一个孤立部分的微基准测试。然后可以使用阿姆达尔定律计算整个程序的整体加速比的上限。为了计算整体加速比,我们需要知道两个值:

  • 首先,我们需要知道孤立部分的执行时间在整体执行时间中所占的比例。我们用字母p来表示这个比例执行时间的值。

  • 其次,我们需要知道我们正在优化的部分的加速比——即微基准测试的。我们用字母s来表示这个本地加速比的值。

使用ps,我们现在可以使用阿姆达尔定律来计算整体加速比:

希望这看起来不会太复杂,因为当投入使用时,这是非常直观的。为了直观理解阿姆达尔定律,可以看看在使用各种极端ps值时整体加速比会变成什么样:

  • 设置p = 0s = 5x意味着我们优化的部分对整体执行时间没有影响。因此,无论s的值如何,整体加速比始终为 1x。

  • 设置p = 1s = 5x意味着我们优化了整个程序执行时间的一部分,在这种情况下,整体加速将始终等于我们在优化部分所实现的加速——在这种情况下是 5 倍。

  • 设置p = 0.5s = ∞意味着我们完全删除了程序执行时间的一半。整体加速将是 2 倍。

结果总结在下表中:

ps整体加速
05x1x
15x5x
0.52x

表 3.4:p 和 s 的极端值及实现的整体加速

一个完整的例子将演示我们如何在实践中使用阿姆达尔定律。假设你正在优化一个函数,使得优化版本比原始版本快 2 倍,即*2x (s = 2)*的加速。此外,让我们假设这个函数只占程序整体执行时间的 1%(p = 0.01),那么整个程序的整体加速可以计算如下:

因此,即使我们设法使我们的孤立代码快 2 倍,整体加速只有 1.005 倍的因素——并不是说这种加速必然是可以忽略的,但我们不断需要回过头来看我们的收益与整体情况的比例。

微基准测试的陷阱

在一般情况下测量软件性能和特别是微基准测试时,有很多隐藏的困难。在这里,我将列出在处理微基准测试时需要注意的事项:

  • 有时结果被过度概括,并被视为普遍真理。

  • 编译器可能会以不同于在完整程序中优化的方式来优化孤立的代码。例如,在微基准测试中可能会内联一个函数,但在完整程序中编译时可能不会内联。或者,编译器可能能够预先计算微基准测试的部分。

  • 在基准测试中未使用的返回值可能会使编译器删除我们试图测量的函数。

  • 在微基准测试中提供的静态测试数据可能会使编译器在优化代码时获得不切实际的优势。例如,如果我们硬编码循环将执行的次数,并且编译器知道这个硬编码的值恰好是 8 的倍数,它可以以不同的方式对循环进行矢量化,跳过可能与 SIMD 寄存器大小不对齐的部分的序言和尾声。然后在真实代码中,这个硬编码的编译时常量被替换为运行时值,这种优化就不会发生。

  • 不切实际的测试数据可能会影响运行基准测试时的分支预测。

  • 多次测量之间的结果可能会有所不同,因为频率缩放、缓存污染和其他进程的调度等因素。

  • 代码性能的限制因素可能是缓存未命中,而不是实际执行指令所需的时间。因此,在许多情况下,微基准测试的一个重要规则是,在测量之前必须清除缓存,否则你实际上并没有在测量任何东西。

我希望有一个简单的公式来避免上面列出的所有陷阱,但不幸的是,我没有。然而,在下一节中,我们将通过使用微基准测试支持库来看一个具体的例子,看看如何通过使用微基准测试支持库来解决其中一些陷阱。

一个微基准测试的例子

我们将通过回到本章的线性搜索和二分搜索的初始例子,并演示如何使用基准测试框架对它们进行基准测试来结束这一章。

我们开始这一章节,比较了在std::vector中搜索整数的两种方法。如果我们知道向量已经排序,我们可以使用二分搜索,这比简单的线性搜索算法效果更好。我不会在这里重复函数的定义,但声明看起来是这样的:

bool linear_search(const std::vector<int>& v, int key);
bool binary_search(const std::vector<int>& v, int key); 

一旦输入足够大,这些函数的执行时间差异是非常明显的,但它将作为我们目的的一个足够好的例子。我们将首先只测量linear_search()。然后,当我们有一个可用的基准测试时,我们将添加binary_search()并比较这两个版本。

为了制作一个测试程序,我们首先需要一种方法来生成一个排序的整数向量。以下是一个简单的实现,对我们的需求来说足够了:

auto gen_vec(int n) {
  std::vector<int> v;
  for (int i = 0; i < n; ++i) { 
    v.push_back(i); 
  }
  return v;
} 

返回的向量将包含 0 到n-1之间的所有整数。一旦我们有了这个,我们就可以创建一个像这样的简单测试程序:

int main() { // Don't do performance tests like this!
  ScopedTimer timer("linear_search");
  int n = 1024;
  auto v = gen_vec(n);
  linear_search(v, n);
} 

我们正在搜索值n,我们知道它不在向量中,所以算法将展示其在这个测试数据中的最坏情况性能。这是这个测试的好部分。除此之外,它还有许多缺陷,这将使得这个基准测试无用:

  • 使用优化编译这段代码很可能会完全删除代码,因为编译器可以看到函数的结果没有被使用。

  • 我们不想测量创建和填充std::vector所需的时间。

  • 只运行一次linear_search()函数,我们将无法获得统计上稳定的结果。

  • 测试不同的输入大小是很麻烦的。

让我们看看如何通过使用微基准支持库来解决这些问题。有各种各样的用于基准测试的工具/库,但我们将使用Google Benchmarkgithub.com/google/benchmark,因为它被广泛使用,而且作为一个奖励,它也可以在quick-bench.com页面上轻松在线测试,而无需任何安装。

这是使用 Google Benchmark 时linear_search()的一个简单微基准测试的样子:

#include <benchmark/benchmark.h> // Non-standard header
#include <vector>
bool linear_search(const std::vector<int>& v, int key) { /* ... */ }
auto gen_vec(int n) { /* ... */ }
static void bm_linear_search(benchmark::State& state) {
  auto n = 1024;
  auto v = gen_vec(n);
  for (auto _ : state) {
    benchmark::DoNotOptimize(linear_search(v, n));
  }
}
BENCHMARK(bm_linear_search); // Register benchmarking function
BENCHMARK_MAIN(); 

就是这样!我们还没有解决的唯一问题是输入大小被硬编码为 1024。我们稍后会解决这个问题。编译和运行这个程序将生成类似这样的东西:

-------------------------------------------------------------------
Benchmark                Time   CPU           Iterations
-------------------------------------------------------------------
bm_linear_search         361 ns 361 ns        1945664 

右侧列中报告的迭代次数报告了循环需要执行多少次才能获得统计上稳定的结果。传递给我们基准测试函数的state对象确定了何时停止。每次迭代的平均时间在两列中报告:时间是挂钟时间,CPU是主线程在 CPU 上花费的时间。在这种情况下,它们是相同的,但如果linear_search()被阻塞等待 I/O(例如),CPU 时间将低于挂钟时间。

另一个重要的事情要注意的是生成向量的代码不包括在报告的时间内。唯一被测量的代码是这个循环内的代码:

for (auto _ : state) {   // Only this loop is measured
  benchmark::DoNotOptimize(binary_search(v, n));
} 

从我们的搜索函数返回的布尔值被包裹在benchmark::DoNotOptimize()中。这是用来确保返回值不被优化掉的机制,这可能会使对linear_search()的整个调用消失。

现在让我们通过改变输入大小使这个基准测试更有趣。我们可以通过使用state对象向我们的基准测试函数传递参数来做到这一点。以下是如何做到的:

static void bm_linear_search(benchmark::State& state) {
  auto n = state.range(0);
  auto v = gen_vec(n);
  for (auto _ : state) {
    benchmark::DoNotOptimize(linear_search(v, n));
  }
}
BENCHMARK(bm_linear_search)->RangeMultiplier(2)->Range(64, 256); 

这将从输入大小为 64 开始,每次加倍大小,直到达到 256。在我的机器上,测试生成了以下输出:

-------------------------------------------------------------------
Benchmark                Time    CPU          Iterations
-------------------------------------------------------------------
bm_linear_search/64      17.9 ns 17.9 ns      38143169
bm_linear_search/128     44.3 ns 44.2 ns      15521161
bm_linear_search/256     74.8 ns 74.7 ns      8836955 

最后,我们将使用可变输入大小对linear_search()binary_search()函数进行基准测试,并尝试让框架估计我们函数的时间复杂度。这可以通过使用SetComplexityN()函数向state对象提供输入大小来实现。完整的微基准测试示例如下:

#include <benchmark/benchmark.h>
#include <vector>
bool linear_search(const std::vector<int>& v, int key) { /* ... */ }
bool binary_search(const std::vector<int>& v, int key) { /* ... */ }
auto gen_vec(int n) { /* ... */ }
static void bm_linear_search(benchmark::State& state) {
  auto n = state.range(0); 
  auto v = gen_vec(n);
  for (auto _ : state) { 
    benchmark::DoNotOptimize(linear_search(v, n)); 
  }
  state.SetComplexityN(n);
}
static void bm_binary_search(benchmark::State& state) {
  auto n = state.range(0); 
  auto v = gen_vec(n);
  for (auto _ : state) { 
    benchmark::DoNotOptimize(binary_search(v, n)); 
  }
  state.SetComplexityN(n);
}
BENCHMARK(bm_linear_search)->RangeMultiplier(2)->
  Range(64, 4096)->Complexity();
BENCHMARK(bm_binary_search)->RangeMultiplier(2)->
  Range(64, 4096)->Complexity();
BENCHMARK_MAIN(); 

运行基准测试时,将在控制台上打印以下结果:

-------------------------------------------------------------------
Benchmark                Time     CPU         Iterations
-------------------------------------------------------------------
bm_linear_search/64      18.0 ns  18.0 ns     38984922
bm_linear_search/128     45.8 ns  45.8 ns     15383123
...
bm_linear_search/8192    1988 ns  1982 ns     331870
bm_linear_search_BigO    0.24 N   0.24 N
bm_linear_search_RMS        4 %   4 %
bm_binary_search/64      4.16 ns  4.15 ns     169294398
bm_binary_search/128     4.52 ns  4.52 ns     152284319
...
bm_binary_search/4096    8.27 ns  8.26 ns     80634189
bm_binary_search/8192    8.90 ns  8.90 ns     77544824
bm_binary_search_BigO    0.67 lgN 0.67 lgN
bm_binary_search_RMS        3 %   3 % 

图 3.7:绘制不同输入大小的执行时间,显示了搜索函数的增长率

输出与本章初步结果一致,我们得出结论,这些算法分别表现出线性运行时间和对数运行时间。如果我们将数值绘制在表中,我们可以清楚地看到函数的线性和对数增长率。

输出结果如下:

总结

以下图是使用 Python 和 Matplotlib 生成的:

“测量让您领先于不需要测量的专家。”

您现在拥有了许多工具和见解,可以找到并改进代码的性能。在处理性能时,我再次强调测量和设定目标的重要性。Andrei Alexandrescu 的一句话将结束本节:

在本章中,您学会了如何使用大 O 符号比较算法的效率。您现在知道 C++标准库为算法和数据结构提供了复杂性保证。所有标准库算法都指定它们的最坏情况或平均情况性能保证,而容器和迭代器指定摊销或精确复杂度。

-Andrei Alexandrescu,2015 年,编写快速代码 I,code::dive conference 2015,codedive.pl/2015/writin…

您还了解了如何通过测量延迟和吞吐量来量化软件性能。

最后,您学会了如何使用 CPU 分析器检测代码中的热点,并如何执行微基准测试来改进程序的孤立部分。

在下一章中,您将了解如何有效使用 C++标准库提供的数据结构。

数据结构

在上一章中,我们讨论了如何分析时间和内存复杂性以及如何衡量性能。在本章中,我们将讨论如何从标准库中选择和使用数据结构。要理解为什么某些数据结构在今天的计算机上运行得非常好,我们首先需要了解一些关于计算机内存的基础知识。在本章中,您将了解以下内容:

  • 计算机内存的属性

  • 标准库容器:序列容器和关联容器

  • 标准库容器适配器

  • 并行数组

在我们开始遍历标准库提供的容器和一些其他有用的数据结构之前,我们将简要讨论一些计算机内存的属性。

计算机内存的属性

C++将内存视为一系列单元。每个单元的大小为 1 字节,并且每个单元都有一个地址。通过其地址访问内存中的一个字节是一个常量时间操作,O(1),换句话说,它与内存单元的总数无关。在 32 位机器上,您可以理论上寻址 2³²字节,即大约 4GB,这限制了进程一次允许使用的内存量。在 64 位机器上,您可以理论上寻址 2⁶⁴字节,这是如此之大,以至于几乎没有任何地址用完的风险。

以下图显示了内存中排列的一系列内存单元。每个单元包含 8 位。十六进制数字是内存单元的地址:

图 4.1:一系列内存单元

由于通过地址访问一个字节是一个*O(1)*操作,从程序员的角度来看,很容易相信每个内存单元都可以快速访问。这种对内存的处理方式在许多情况下都是简单且有用的,但是在选择数据结构以实现高效使用时,您需要考虑现代计算机中存在的内存层次结构。随着从主存储器读取和写入所需的时间与今天处理器的速度相比变得更加昂贵,内存层次结构的重要性已经增加。以下图显示了具有一个 CPU 和四个核心的机器的架构:

图 4.2:具有四个核心的处理器的示例;标有 L1i、L1d、L2 和 L3 的框是内存缓存

我目前正在使用 2018 年的 MacBook Pro 进行撰写本章,它配备了 Intel Quad-Core i7 CPU。在这个处理器上,每个核心都有自己的 L1 和 L2 缓存,而 L3 缓存是所有四个核心共享的。从终端运行以下命令:

sysctl -a hw 

给我提供了以下信息,除其他外:

hw.memsize: 17179869184
hw.cachelinesize: 64
hw.l1icachesize: 32768
hw.l1dcachesize: 32768
hw.l2cachesize: 262144
hw.l3cachesize: 8388608 

报告的hw.memsize是主存储器的总量,本例中为 16GB。

hw.cachelinesize报告的是 64 字节,这是缓存行的大小,也称为块。当访问内存中的一个字节时,机器不仅会获取所请求的字节;相反,机器总是获取一个缓存行,在这种情况下是 64 字节。 CPU 和主存储器之间的各种高速缓存跟踪 64 字节的块,而不是单个字节。

hw.l1icachesize是 L1 指令缓存的大小。这是一个 32KB 的缓存,专门用于存储 CPU 最近使用的指令。 hw.l1dcachesize也是 32KB,专门用于数据,而不是指令。

最后,我们可以读取 L2 缓存和 L3 缓存的大小,分别为 256KB 和 8MB。一个重要的观察是,与可用的主存储器量相比,缓存非常小。

没有提供关于从缓存层中的每一层访问数据所需的实际周期数的详细事实,一个非常粗略的指导原则是,相邻层之间的延迟存在数量级的差异(例如,L1 和 L2)。下表显示了 Peter Norvig 在一篇名为《在十年内自学编程》(2001)的文章中提出的延迟数字的摘录(norvig.com/21-days.html)。完整的表通常被称为《每个程序员都应该知道的延迟数字》,并且由 Jeff Dean 创作:

L1 缓存引用0.5 ns
L2 缓存引用7 ns
主存储器引用100 ns

以这样的方式结构化数据,使得缓存可以被充分利用,对性能有着显著的影响。访问最近使用过的数据,因此可能已经存在于缓存中,将使你的程序更快。这被称为时间局部性

此外,访问位于你正在使用的其他数据附近的数据,将增加你需要的数据已经在先前从主存储器中获取的缓存行中的可能性。这被称为空间局部性

在内部循环中不断清除缓存行可能导致非常糟糕的性能。这有时被称为缓存抖动。让我们看一个例子:

constexpr auto kL1CacheCapacity = 32768; // The L1 Data cache size 
constexpr auto kSize = kL1CacheCapacity / sizeof(int); 
using MatrixType = std::array<std::array<int, kSize>, kSize>; 
auto cache_thrashing(MatrixType& matrix) { 
  auto counter = 0;
  for (auto i = 0; i < kSize; ++i) {
    for (auto j = 0; j < kSize; ++j) {
      matrix[i][j] = counter++;
    }
  }
} 

这个版本在我的电脑上运行大约需要 40 毫秒。然而,只需将内部循环中的一行更改为以下内容,完成函数所需的时间就会从 40 毫秒增加到 800 毫秒以上:

matrix[j][i] = counter++; 

在第一个例子中,使用matrix[i][j]时,大多数情况下我们将访问已经在 L1 缓存中的内存,而在使用matrix[j][i]的修改版本中,每次访问都会生成一个 L1 缓存未命中。一些图像可能会帮助你理解发生了什么。与其绘制完整的 32768 x 32768 矩阵,不如用这里显示的一个小 3 x 3 矩阵作为例子:

图 4.3:一个 3x3 矩阵

即使这可能是我们对矩阵在内存中的想象,实际上并不存在二维内存。相反,当这个矩阵在一维内存空间中排列时,它看起来是这样的:

图 4.4:一个二维矩阵在一维内存空间中

也就是说,它是一个按行排列的连续元素数组。在我们算法的快速版本中,数字按照它们在内存中连续排列的顺序顺序访问,就像这样:

图 4.5:快速顺序步幅-1 访问

而在算法的慢速版本中,元素以完全不同的模式访问。使用慢速版本访问前四个元素现在看起来是这样的:

图 4.6:使用较大步幅的慢速访问

以这种方式访问数据由于空间局部性差而明显较慢。现代处理器通常也配备有预取器,它可以自动识别内存访问模式,并尝试从内存中预取可能在不久的将来被访问的缓存。预取器对于较小的步幅表现最佳。你可以在 Randal E. Bryant 和 David R. O'Hallaron 的优秀著作《计算机系统,程序员的视角》中阅读更多相关内容。

总结本节,即使内存访问是恒定时间操作,缓存对实际访问内存所需时间的影响可能会很大。在使用或实现新数据结构时,这是一件需要时刻牢记的事情。

接下来,我将介绍 C++标准库中的一组数据结构,称为容器。

标准库容器

C++标准库提供了一组非常有用的容器类型。容器是包含一系列元素的数据结构。容器管理它所持有的元素的内存。这意味着我们不必显式地创建和删除放入容器中的对象。我们可以将在堆栈上创建的对象传递给容器,容器将会复制并存储它们在自由存储器上。

迭代器用于访问容器中的元素,因此对于理解标准库中的算法和数据结构来说,它们是一个基本概念。迭代器概念在第五章算法中有介绍。对于本章来说,知道迭代器可以被视为指向元素的指针,并且迭代器根据它们所属的容器定义了不同的操作符就足够了。例如,类似数组的数据结构提供对其元素的随机访问迭代器。这些迭代器支持使用+-的算术表达式,而例如链表的迭代器只支持++--操作符。

容器分为三类:序列容器、关联容器和容器适配器。本节将简要介绍这三类容器中的容器,并讨论在性能成为问题时需要考虑的最重要的事情。

序列容器

序列容器会按照我们添加元素到容器时指定的顺序来保留元素。标准库中的序列容器包括std::arraystd::vectorstd::dequestd::liststd::forward_list。我也会在本节中介绍std::basic_string,尽管它不是正式的通用序列容器,因为它只处理字符类型的元素。

在选择序列容器之前,我们应该知道以下问题的答案:

  1. 元素数量是多少(数量级)?

  2. 使用模式是什么?您将多频繁地添加数据?读取/遍历数据?删除数据?重新排列数据?

  3. 您最常在序列中添加数据的位置是哪里?在末尾、开头还是中间?

  4. 您需要对元素进行排序吗?或者您是否甚至关心顺序?

根据这些问题的答案,我们可以确定哪种序列容器更适合我们的需求。但是,为了做到这一点,我们需要对每种类型的序列容器的接口和性能特征有基本的了解。

接下来的部分将简要介绍不同的序列容器,首先介绍最常用的容器之一。

向量和数组

std::vector可能是最常用的容器类型,原因很充分。向量是一个在需要时动态增长的数组。添加到向量中的元素保证在内存中是连续排列的,这意味着您可以通过索引以常数时间访问数组中的任何元素。这也意味着在按照它们排列的顺序遍历元素时,由于前面提到的空间局部性,它提供了出色的性能。

向量有一个大小和一个容量。大小是当前容器中保存的元素数量,容量是向量需要分配更多空间之前可以容纳的元素数量:

图 4.7:std::vector 的大小和容量

使用push_back()函数向向量末尾添加元素是快速的,只要大小小于容量。当添加一个元素并且没有更多空间时,向量将会分配一个新的内部缓冲区,然后将所有元素移动到新空间。容量会以一种很少发生调整缓冲区大小的方式增长,因此使push_back()成为摊销的常数时间操作,正如我们在第三章分析和测量性能中讨论的那样。

类型为std::vector<Person>的向量模板实例将按值存储Person对象。当向量需要重新排列Person对象(例如,作为插入的结果),值将被复制构造或移动。如果对象具有nothrow移动构造函数,则对象将被移动。否则,为了保证强异常安全性,对象将被复制构造:

Person(Person&& other) {         // Will be copied 
   // ...
} 
Person(Person&& other) noexcept { // Will be moved 
   // ...
} 

在内部,std::vector使用std::move_if_noexcept来确定对象是应该被复制还是移动。<type_traits>头文件可以帮助您在编译时验证您的类在移动时是否保证不会抛出异常:

static_assert(std::is_nothrow_move_constructible<Person>::value); 

如果您要将新创建的对象添加到向量中,您可以利用emplace_back()函数,它将为您创建对象,而不是使用push_back()函数创建对象,然后将其复制/移动到向量中:

persons.emplace_back("John", 65); 

向量的容量可以通过以下方式改变:

  • 通过在capacity == size时向向量添加元素

  • 通过调用reserve()

  • 通过调用shrink_to_fit()

除此之外,向量不会改变容量,因此也不会分配或释放动态内存。例如,成员函数clear()会清空向量,但不会改变其容量。这些内存保证使得向量即使在实时环境中也可以使用。

自 C++20 以来,还有两个免费函数可以从std::vector中删除元素。在 C++20 之前,我们必须使用擦除-移除惯用法,我们将在第五章 算法中讨论。然而,现在从std::vector中删除元素的推荐方法是使用std::erase()std::erase_if()。以下是如何使用这些函数的简短示例:

auto v = std::vector{-1, 5, 2, -3, 4, -5, 5};
std::erase(v, 5);                               // v: [-1,2,-3,4,-5]
std::erase_if(v, [](auto x) { return x < 0; }); // v: [2, 4] 

作为动态大小向量的替代,标准库还提供了一个名为std::array的固定大小版本,它通过使用堆栈而不是自由存储来管理其元素。数组的大小是在编译时指定的模板参数,这意味着大小和类型元素成为具体类型的一部分:

auto a = std::array<int, 16>{};
auto b = std::array<int, 1024>{}; 

在这个例子中,ab不是相同的类型,这意味着在使用类型作为函数参数时,你必须指定大小:

auto f(const std::array<int, 1024>& input) { 
  // ... 
} 

f(a);  // Does not compile, f requires an int array of size 1024 

这一开始可能看起来有点麻烦,但事实上,这是与内置数组类型(C 数组)相比的一个很大的优势,因为当传递给函数时,它会自动将指针转换为数组的第一个元素,从而丢失大小信息:

// input looks like an array, but is in fact a pointer 
auto f(const int input[]) {  
  // ... 
} 

int a[16]; 
int b[1024]; 
f(a); // Compiles, but unsafe 

数组失去其大小信息通常被称为数组衰变。在本章后面,您将看到如何通过在将连续数据传递给函数时使用std::span来避免数组衰变。

双端队列

有时,您会发现自己处于需要频繁向序列的开头和结尾添加元素的情况。如果您使用的是std::vector并且需要加快在前面插入的速度,您可以使用std::deque,它是双端队列的缩写。std::deque通常实现为一组固定大小的数组,这使得可以在常数时间内通过它们的索引访问元素。然而,正如您在下图中所看到的,所有元素并不是存储在内存中的连续位置,这与std::vectorstd::array的情况不同。

图 4.8:std::deque 的可能布局

列表和前向列表

std::list是一个双向链表,意味着每个元素都有一个指向下一个元素和一个指向前一个元素的链接。这使得可以向前和向后遍历列表。还有一个名为std::forward_list单向链表。之所以不总是选择双向链表而不是std::forward_list,是因为双向链表中的后向指针占用了过多的内存。因此,如果不需要向后遍历列表,就使用std::forward_list。单向链表的另一个有趣特性是它针对非常短的列表进行了优化。当列表为空时,它只占用一个字,这使得它成为稀疏数据的一种可行数据结构。

请注意,即使元素在一个序列中是有序的,它们在内存中并不像向量和数组那样连续布局,这意味着迭代链表很可能会产生比向量更多的缓存未命中。

总之,std::list是一个具有指向下一个和上一个元素的双向链表:

图 4.9:std::list 是一个双向链表

std::forward_list是一个具有指向下一个元素的单向链表:

图 4.10:std::forward_list 是一个单向链表

std::forward_list更加内存高效,因为它只有一个指向下一个元素的指针。

列表也是唯一支持splicing的容器,这是一种在不复制或移动元素的情况下在列表之间传输元素的方法。这意味着,例如,可以在常数时间*O(1)*内将两个列表连接成一个。其他容器对于这样的操作至少需要线性时间。

基本字符串

我们将在本节中介绍的最后一个模板类是std::basic_stringstd::stringstd::basic_string<char>的一个typedef。从历史上看,std::basic_string并不保证在内存中连续布局。这在 C++17 中发生了改变,这使得可以将字符串传递给需要字符数组的 API。例如,以下代码将整个文件读入字符串中:

auto in = std::ifstream{"file.txt", std::ios::binary | std::ios::ate}; 
if (in.is_open()) { 
  auto size = in.tellg(); 
  auto content = std::string(size, '\0'); 
  in.seekg(0); 
  in.read(&content[0], size); 
  // "content" now contains the entire file 
} 

通过使用std::ios::ate打开文件,位置指示器被设置到流的末尾,这样我们就可以使用tellg()来检索文件的大小。之后,我们将输入位置设置为流的开头并开始读取。

大多数std::basic_string的实现都利用了称为小对象优化的东西,这意味着如果字符串的大小很小,它们不会分配任何动态内存。我们将在本书的后面讨论小对象优化。现在,让我们继续讨论关联容器。

关联容器

关联容器根据元素本身的特性放置它们的元素。例如,在关联容器中不可能像使用std::vector::push_back()std::list::push_front()那样在后面或前面添加元素。相反,元素是以一种使得可以在不需要扫描整个容器的情况下找到元素的方式添加的。因此,关联容器对我们想要存储在容器中的对象有一些要求。我们将在后面讨论这些要求。

关联容器有两个主要类别:

  • 有序关联容器:这些容器基于树;容器使用树来存储它们的元素。它们要求元素按照小于运算符(<)进行排序。基于树的容器中添加、删除和查找元素的函数都是 O(log n)。这些容器被命名为std::setstd::mapstd::multisetstd::multimap

  • 无序关联容器:这些容器基于哈希表;容器使用哈希表来存储它们的元素。它们要求元素使用相等运算符(==)进行比较,并且有一种方法可以根据元素计算哈希值。稍后会详细介绍。基于哈希表的容器中添加、删除和查找元素的函数都是O(1)。这些容器的名称是std::unordered_setstd::unordered_mapstd::unordered_multisetstd::unordered_multimap

自 C++20 以来,所有关联容器都配备了一个名为contains()的函数,当您想知道容器是否包含某些特定元素时应该使用它。在较早版本的 C++中,需要使用count()find()来确定容器是否包含元素。

始终使用专门的函数,如contains()empty(),而不是使用count() > 0size() == 0。专门的函数保证是最有效的。

有序集合和映射

有序关联容器保证插入、删除和搜索可以在对数时间O(log n)内完成。如何实现这一点取决于标准库的实现。然而,我们所知道的实现确实使用了某种自平衡二叉搜索树。树保持大致平衡是控制树的高度以及访问元素的最坏情况运行时间的必要条件。树不需要预先分配内存,因此通常情况下,每次插入元素时树都会在自由存储器上分配内存,并在擦除元素时释放内存。请看下面的图表,显示平衡树的高度为O(log n)

图 4.11:如果树是平衡的,则树的高度为 O(log n)

无序集合和映射

无序集合和映射的版本提供了基于哈希的替代方案,而不是基于树的版本。这种数据结构通常被称为哈希表。理论上,哈希表提供了摊销的常数时间插入、添加和删除操作,可以与操作在*O(log n)*的基于树的版本进行比较。然而,在实践中,差异可能并不那么明显,特别是如果您的容器中没有存储非常大数量的元素。

让我们看看哈希表如何提供*O(1)*的操作。哈希表将其元素保存在一些桶的数组中。当向哈希表添加元素时,使用哈希函数计算元素的整数。这个整数通常被称为元素的哈希。然后,哈希值被限制在数组的大小范围内(例如通过使用取模运算),以便新的限制值可以用作数组中的索引。一旦计算出索引,哈希表就可以将元素存储在数组的该索引处。查找元素的操作方式类似,首先计算要查找的元素的哈希值,然后访问数组。

除了计算哈希值,这种技术似乎很简单。然而,这只是故事的一半。如果两个不同的元素生成相同的索引,要么是因为它们产生了相同的哈希值,要么是因为两个不同的哈希值被限制到相同的索引,会发生什么?当两个不相等的元素最终位于同一个索引时,我们称之为哈希冲突。这不仅仅是一个边缘情况:即使我们使用一个很好的哈希函数,尤其是当数组的大小与我们添加的元素数量相比较小时,这种情况会经常发生。有各种方法来处理哈希冲突。在这里,我们将专注于标准库中使用的一种方法,称为分离链接

分离链接解决了两个不相等的元素最终在相同索引处的问题。数组不仅仅是直接存储元素,而是一个序列的。每个桶可以包含多个元素,也就是所有散列到相同索引的元素。因此,每个桶也是某种类型的容器。用于桶的确切数据结构未定义,对于不同的实现可能会有所不同。但是,我们可以将其视为链表,并假设在特定桶中查找元素是缓慢的,因为它需要线性扫描桶中的元素。

下图显示了一个具有八个桶的哈希表。元素分布在三个单独的桶中。索引为2的桶包含四个元素,索引为4的桶包含两个元素,索引为5的桶只包含一个元素。其他桶为空:

图 4.12:每个桶包含 0 个或多个元素

哈希和相等

哈希值可以在与容器大小相关的常量时间内计算,它决定了元素将被放置在哪个桶中。由于可能会有多个对象生成相同的哈希值,因此最终进入同一个桶,每个键还需要提供一个相等函数,用于将要查找的键与桶中的所有键进行比较。

如果两个键相等,则它们需要生成相同的哈希值。但是,两个对象返回相同的哈希值而彼此不相等是完全合法的。

一个好的哈希函数计算快速,并且还会在桶之间均匀分布键,以最小化每个桶中的元素数量。

以下是一个非常糟糕但有效的哈希函数的示例:

auto my_hash = [](const Person& person) {
  return 47; // Bad, don't do this!
}; 

它是有效的,因为它将为两个相等的对象返回相同的哈希值。哈希函数也非常快。然而,由于所有元素将产生相同的哈希值,所有键最终将进入同一个桶,这意味着查找一个元素将是O(n)而不是我们所追求的O(1)

另一方面,一个好的哈希函数可以确保元素在桶之间均匀分布,以最小化哈希冲突。C++标准实际上对此有一个注释,指出哈希函数很少会为两个不同的对象产生相同的哈希值。幸运的是,标准库已经为基本类型提供了良好的哈希函数。在许多情况下,我们可以在为用户定义的类型编写自己的哈希函数时重用这些函数。

假设我们想要将Person类作为unorordered_set中的键。Person类有两个数据成员:age是一个intname是一个std::string。我们首先编写相等谓词:

auto person_eq = [](const Person& lhs, const Person& rhs) {
  return lhs.name() == rhs.name() && lhs.age() == rhs.age();
}; 

为了使两个Person对象相等,它们需要有相同的名称和相同的年龄。现在我们可以通过组合包含在相等谓词中的所有数据成员的哈希值来定义哈希谓词。不幸的是,C++标准中还没有函数来组合哈希值,但 Boost 中有一个很好的函数可用,我们将在这里使用:

#include <boost/functional/hash.hpp>
auto person_hash = [](const Person& person) { 
  auto seed = size_t{0};
  boost::hash_combine(seed, person.name()); 
  boost::hash_combine(seed, person.age()); 
  return seed;
}; 

如果由于某种原因,您无法使用 Boost,boost::hash_combine()实际上只是一个可以从www.boost.org/doc/libs/1_55_0/doc/html/hash/reference.html#boost.hash_combine的文档中复制的一行代码。

有了相等和哈希函数的定义,我们最终可以创建我们的unordered_set

using Set = std::unordered_set<Person, decltype(person_hash),                                decltype(person_eq)>; 
auto persons = Set{100, person_hash, person_eq}; 

一个很好的经验法则是在生成哈希值时始终使用等函数中使用的所有数据成员。这样,我们遵守了等号和哈希之间的约定,同时这使我们能够提供一个有效的哈希值。例如,仅在计算哈希值时使用名称是正确但低效的,因为这意味着所有具有相同名称的Person对象最终都会进入同一个桶中。更糟糕的是,在哈希函数中包括未在等函数中使用的数据成员。这很可能会导致灾难,使您无法在unordered_set中找到相等的对象。

哈希策略

除了创建均匀分布在桶中的键的哈希值之外,我们还可以通过拥有许多桶来减少碰撞的数量。每个桶的平均元素数称为负载因子。在前面的示例中,我们创建了一个具有 100 个桶的unordered_set。如果我们向集合中添加 50 个Person对象,load_factor()将返回 0.5。max_load_factor是负载因子的上限,当达到该值时,集合将需要增加桶的数量,并且因此还需要重新散列当前集合中的所有元素。还可以使用rehash()reserve()成员函数手动触发重新散列。

让我们继续看看第三类:容器适配器。

容器适配器

标准库中有三种容器适配器:std::stackstd::queuestd::priority_queue。容器适配器与序列容器和关联容器非常不同,因为它们代表可以由底层序列容器实现的抽象数据类型。例如,堆栈是一个后进先出LIFO)数据结构,支持在堆栈顶部进行推送和弹出,可以使用vectorlistdeque或任何其他支持back()push_back()pop_back()的自定义序列容器来实现。队列也是如此,它是一个先进先出FIFO)数据结构,以及priority_queue

在本节中,我们将重点关注std::priority_queue,这是一个非常有用的数据结构,很容易被忘记。

优先队列

优先队列提供了具有最高优先级的元素的常数时间查找。使用元素的小于运算符定义优先级。插入和删除都在对数时间内运行。优先队列是一个部分有序的数据结构,可能不明显何时使用它而不是完全排序的数据结构,例如树或排序向量。但是,在某些情况下,优先队列可以为您提供所需的功能,并且成本比完全排序的容器低。

标准库已经提供了一个部分排序算法,所以我们不需要自己写。但让我们看看如何使用优先队列来实现一个部分排序算法。假设我们正在编写一个程序,用于根据查询搜索文档。匹配的文档(搜索命中)应按排名排序,我们只对找到的前 10 个排名最高的搜索命中感兴趣。

文档由以下类表示:

class Document { 
public:  
  Document(std::string title) : title_{std::move(title)} {}
private:  
  std::string title_; 
  // ... 
}; 

在搜索时,算法选择与查询匹配的文档并计算搜索命中的排名。每个匹配的文档由Hit表示:

struct Hit { 
  float rank_{}; 
  std::shared_ptr<Document> document_; 
}; 

最后,我们需要对命中进行排序并返回前m个文档。对于排序命中有哪些选项?如果命中包含在提供随机访问迭代器的容器中,我们可以使用std::sort()并且只返回前m个元素。或者,如果命中的总数远远大于我们要返回的m个文档,我们可以使用std::partial_sort(),这比std::sort()更有效。

但是如果我们没有随机访问迭代器怎么办?也许匹配算法只提供了对命中的前向迭代器。在这种情况下,我们可以使用优先队列,仍然得到一个高效的解决方案。我们的排序接口将如下所示:

template<typename It>
auto sort_hits(It begin, It end, size_t m) -> std::vector<Hit> { 

我们可以使用定义了递增运算符的任何迭代器调用此函数。接下来,我们创建一个由std::vector支持的std::priority_queue,使用自定义比较函数来保持队列顶部的最低排名命中:

 auto cmp = [](const Hit& a, const Hit& b) { 
    return a.rank_ > b.rank_; // Note, we are using greater than 
  };
  auto queue = std::priority_queue<Hit, std::vector<Hit>,                                    decltype(cmp)>{cmp}; 

我们将在优先队列中最多插入 m 个元素。优先队列将包含到目前为止看到的排名最高的命中。在当前在优先队列中的元素中,排名最低的命中将成为最顶部的元素:

 for (auto it = begin; it != end; ++it) { 
    if (queue.size() < m) { 
      queue.push(*it); 
    } 
    else if (it->rank_ > queue.top().rank_) { 
      queue.pop(); 
      queue.push(*it); 
    } 
  } 

现在,我们已经在优先队列中收集了排名最高的命中,所以唯一剩下的事情就是将它们以相反的顺序放入向量中,并返回排序后的命中:

 auto result = std::vector<Hit>{}; 
  while (!queue.empty()) { 
    result.push_back(queue.top()); 
    queue.pop(); 
  } 
  std::reverse(result.begin(), result.end()); 
  return result; 
} // end of sort_hits() 

这个算法的复杂度是多少?如果我们用 n 表示命中次数,用 m 表示返回的命中次数,我们可以看到内存消耗是 O(m),而时间复杂度是 O(n * log m),因为我们正在迭代 n 个元素。此外,在每次迭代中,我们可能需要进行推送和/或弹出,这两者都在 O(log m)时间内运行。

现在我们将离开标准库容器,专注于一些与标准容器密切相关的新的有用的类模板。

使用视图

在本节中,我们将讨论 C++标准库中一些相对较新的类模板:C++17 中的std::string_view和 C++20 中引入的std::span

这些类模板不是容器,而是一系列连续元素的轻量级视图(或切片)。视图是小对象,可以按值复制。它们不分配内存,也不提供有关它们指向的内存的生存期的任何保证。换句话说,它们是非拥有引用类型,与本章前面描述的容器有很大不同。与此同时,它们与std::stringstd::arraystd::vector密切相关,我们将很快看到。我将从描述std::string_view开始。

使用 string_view 避免复制

std::string_view包含一个指向不可变字符串缓冲区开头的指针和一个大小。由于字符串是一系列连续的字符,指针和大小完全定义了一个有效的子字符串范围。通常,std::string_view指向由std::string拥有的一些内存。但它也可以指向具有静态存储期的字符串字面量或类似内存映射文件的东西。以下图表显示了std::string_view指向由std::string拥有的内存:

图 4.13:一个指向由 std::string 实例拥有的内存的 std::string_view 对象。

std::string_view定义的字符序列不需要以空字符结尾,但包含空字符的字符序列是完全有效的。另一方面,std::string需要能够从c_str()返回以空字符结尾的字符串,这意味着它总是在序列的末尾存储额外的空字符。

string_view不需要空终止符的事实意味着它可以比 C 风格字符串或std::string更有效地处理子字符串,因为它不必创建新的字符串来添加空终止符。使用std::string_viewsubstr()的复杂度是常数,这应该与std::stringsubstr()版本进行比较,后者的复杂度是线性时间。

将字符串传递给函数时也会有性能提升。考虑以下代码:

auto some_func(const std::string& s) {
  // process s ...
}
some_func("A string literal"); // Creates a std::string 

当将字符串字面量传递给some_func()时,编译器需要构造一个新的std::string对象以匹配参数的类型。然而,如果我们让some_func()接受一个std::string_view,就不再需要构造一个std::string了:

auto some_func(std::string_view s) { // Pass by value
  // process s ... 
}
some_func("A string literal"); 

std::string_view实例可以有效地从std::string和字符串字面量构造,并且因此是函数参数的合适类型。

使用 std::span 消除数组衰减

在本章前面讨论std::vectorstd::array时,我提到了数组衰减(失去数组的大小信息)在将内置数组传递给函数时会发生:

// buffer looks like an array, but is in fact a pointer 
auto f1(float buffer[]) {
  const auto n = std::size(buffer);   // Does not compile!
  for (auto i = 0u; i < n; ++i) {     // Size is lost!
    // ...
  }
} 

我们可以通过添加大小参数来解决这个问题:

auto f2(float buffer[], size_t n) {
  for (auto i = 0u; i < n; ++i) {
    // ...
  }
} 

尽管这在技术上是有效的,但向该函数传递正确的数据既容易出错又繁琐,如果f2()将缓冲区传递给其他函数,它需要记住传递正确大小的变量n。这是f2()的调用点可能会看起来像的:

float a[256]; 
f2(a, 256);     
f2(a, sizeof(a)/sizeof(a[0])); // A common tedious pattern
f2(a, std::size(a)); 

数组衰减是许多与边界相关的错误的根源,在使用内置数组的情况下(出于某种原因),std::span提供了一种更安全的方法将数组传递给函数。由于 span 在一个对象中同时保存了指向内存的指针和大小,因此我们可以将其用作将元素序列传递给函数时的单一类型:

auto f3(std::span<float> buffer) {  // Pass by value
  for (auto&& b : buffer) {         // Range-based for-loop
    // ...
  }
}
float a[256]; 
f3(a);          // OK! Array is passed as a span with size
auto v = std::vector{1.f, 2.f, 3.f, 4.f};
f3(v);          // OK! 

与内置数组相比,span 更方便使用,因为它更像一个具有迭代器支持的常规容器。

在数据成员(指针和大小)和成员函数方面,std::string_viewstd::span之间有许多相似之处。但也有一些显着的区别:std::span指向的内存是可变的,而std::string_view总是指向常量内存。std::string_view还包含特定于字符串的函数,如hash()substr(),这自然不是std::span的一部分。最后,在std::span中没有compare()函数,因此不可能直接在std::span对象上使用比较运算符。

现在是时候强调一些与使用标准库数据结构相关的一般性能要点了。

一些性能考虑

我们现在已经涵盖了三个主要的容器类别:序列容器、关联容器和容器适配器。本节将为您提供一些在使用容器时考虑的一般性能建议。

在复杂性保证和开销之间取得平衡。

在选择容器时,了解数据结构的时间和内存复杂性是重要的。但同样重要的是要记住,每个容器都带有开销成本,这对于较小的数据集的性能影响更大。复杂性保证只有在足够大的数据集时才变得有趣。在您的用例中,您需要决定足够大的含义。在这里,您需要再次在执行程序时测量以获得见解。

此外,计算机配备了内存缓存的事实使得对缓存友好的数据结构更有可能表现更好。这通常有利于std::vector,它的内存开销低,并且将其元素连续存储在内存中,使得访问和遍历更快。

下图显示了两种算法的实际运行时间。一个以线性时间*O(n)运行,另一个以对数时间O(log n)*运行,但开销更大。当输入大小低于标记的阈值时,对数算法比线性时间算法慢:

图 4.14:对于较小的 n,线性算法 O(n)比运行在 O(log n)的算法更快

我们要记住的下一个要点更加具体,突出了使用最合适的 API 函数的重要性。

了解并使用适当的 API 函数

在 C++中,通常有多种方法可以做某事。语言和库继续发展,但很少有功能被弃用。当新函数添加到标准库中时,我们应该学会何时使用它们,并反思我们可能已经使用的模式,以弥补以前缺失的功能。

在这里,我们将专注于标准库中的两个小但重要的函数:contains()empty()。在检查关联容器中的元素是否存在时使用contains()。如果要知道容器是否有任何元素或为空,请使用empty()。除了更清晰地表达意图外,它还具有性能优势。检查链表的大小是一个*O(n)操作,而在列表上调用empty()则在常数时间O(1)*内运行。

在 C++20 之前和contains()函数的引入之前,每当我们想要检查关联容器中某个值的存在时,我们都不得不绕个弯。您很可能会遇到使用各种方法来查找元素存在性的代码。假设我们使用std::multiset实现了一个单词袋:

auto bag = std::multiset<std::string>{}; // Our bag-of-words
// Fill bag with words ... 

如果我们想知道我们的单词袋中是否有某个特定单词,有许多方法可以继续。一个选择是使用count(),就像这样:

auto word = std::string{"bayes"}; // Our word we want to find
if (bag.count(word) > 0) {
   // ...
} 

这似乎是合理的,但它可能有一些额外开销,因为它计算与我们的单词匹配的所有元素。另一种选择是使用find(),但它有相同的开销,因为它返回所有匹配的单词,而不仅仅是第一次出现的:

if (bag.find(word) != bag.end()) {
  // ...
} 

在 C++20 之前,推荐的方法是使用lower_bound(),因为它只返回第一个匹配的元素,就像这样:

if (bag.lower_bound(word) != bag.end()) { 
  // ...
} 

现在,随着 C++20 和contains()的引入,我们可以更清楚地表达我们的意图,并确保当我们只想检查元素是否存在时,库会为我们提供最有效的实现:

if (bag.contains(word)) { // Efficient and with clear intent 
  // ...
} 

一般规则是,如果有一个特定的成员函数或为特定容器设计的自由函数,那么如果符合您的需求,请使用它。它将是高效的,并且会更清晰地表达意图。不要像之前展示的那样绕道而行,只是因为您还没有学会完整的 API,或者因为您有以某种方式做事的旧习惯。

还应该说的是,零开销原则特别适用于这样的函数,因此不要浪费时间试图通过手工制作自己的函数来智胜库实现者。

我们现在将继续看一个更长的示例,展示我们如何以不同的方式重新排列数据,以优化特定用例的运行时性能。

并行数组

我们将通过讨论迭代元素和探索在迭代类似数组的数据结构时改善性能的方法来结束本章。我已经提到了访问数据时性能的两个重要因素:空间局部性和时间局部性。当在内存中连续存储的元素上进行迭代时,如果我们设法保持对象小,那么我们将增加所需数据已经被缓存的概率,这要归功于空间局部性。显然,这将对性能产生巨大影响。

回想一下在本章开头展示的缓存抖动示例,我们在矩阵上进行了迭代。它表明有时我们需要考虑访问数据的方式,即使我们对数据有一个相当紧凑的表示。

接下来,我们将比较迭代不同大小对象需要多长时间。我们将首先定义两个结构体,SmallObjectBigObject

struct SmallObject { 
  std::array<char, 4> data_{}; 
  int score_{std::rand()}; 
};

struct BigObject { 
 std::array<char, 256> data_{}; 
 int score_{std::rand()}; 
}; 

SmallObjectBigObject是相同的,只是初始数据数组的大小不同。这两个结构都包含一个名为score_int,我们为测试目的初始化为一个随机值。我们可以使用sizeof运算符让编译器告诉我们对象的大小:

std::cout << sizeof(SmallObject); // Possible output is 8 
std::cout << sizeof(BigObject);   // Possible output is 260 

我们需要大量对象来评估性能。创建每种对象一百万个:

auto small_objects = std::vector<SmallObject>(1'000'000); 
auto big_objects = std::vector<BigObject>(1'000'000); 

现在进行迭代。假设我们想要对所有对象的分数进行求和。我们更倾向于使用std::accumulate(),这是我们稍后会在书中介绍的,但是,现在,一个简单的for循环就可以了。我们将这个函数写成一个模板,这样我们就不必为每种类型的对象手动编写一个版本。该函数迭代对象并对所有分数求和:

template <class T> 
auto sum_scores(const std::vector<T>& objects) {  
  ScopedTimer t{"sum_scores"};    // See chapter 3 

  auto sum = 0; 
  for (const auto& obj : objects) { 
    sum += obj.score_; 
  } 
  return sum; 
} 

现在,我们准备看看在小对象中求和分数需要多长时间,与大对象相比:

auto sum = 0; 
sum += sum_scores(small_objects); 
sum += sum_scores(big_objects); 

为了获得可靠的结果,我们需要多次重复测试。在我的电脑上,计算小对象的总和大约需要 1 毫秒,计算大对象的总和需要 10 毫秒。这个例子类似于本章开头的缓存抖动示例,而造成巨大差异的一个原因是,再次是因为计算机使用缓存层次结构从主内存中获取数据的方式。

在处理比前面的例子更现实的场景时,我们如何利用迭代小对象集合比大对象集合更快的事实?

显然,我们可以尽力保持类的大小较小,但这通常说起来容易做起来难。此外,如果我们正在处理一个已经增长了一段时间的旧代码库,很有可能会遇到一些非常大的类,其中包含太多的数据成员和太多的职责。

现在,我们将看一个代表在线游戏系统中用户的类,并看看我们如何将其分成更小的部分。该类具有以下数据成员:

struct User { 
  std::string name_; 
  std::string username_; 
  std::string password_; 
  std::string security_question_; 
  std::string security_answer_; 
  short level_{}; 
  bool is_playing_{}; 
}; 

用户有一个经常使用的名称和一些很少使用的身份验证信息。该类还跟踪玩家当前所玩的级别。最后,User结构还通过存储is_playing_布尔值来知道用户当前是否在玩。

sizeof运算符在 64 位架构编译时报告User类为 128 字节。数据成员的近似布局如下图所示:

图 4.15:User 类的内存布局

所有用户都保存在std::vector中,并且有两个经常调用并且需要快速运行的全局函数:num_users_at_level()num_playing_users()。这两个函数都迭代所有用户,因此我们需要快速迭代用户向量。

第一个函数返回达到特定级别的用户数量:

auto num_users_at_level(const std::vector<User>& users, short level) { 
  ScopedTimer t{"num_users_at_level (using 128 bytes User)"}; 

  auto num_users = 0; 
  for (const auto& user : users)
    if (user.level_ == level)
      ++num_users; 
  return num_users; 
} 

第二个函数计算当前有多少用户在玩:

auto num_playing_users(const std::vector<User>& users) { 
  ScopedTimer t{"num_playing_users (using 128 bytes User)"}; 

  return std::count_if(users.begin(), users.end(), 
    [](const auto& user) { 
      return user.is_playing_; 
    }); 
} 

在这里,我们使用算法std::count_if()而不是手写循环,就像我们在num_users_at_level()中所做的那样。std::count_if()将为用户向量中的每个用户调用我们提供的谓词,并返回谓词返回true的次数。这基本上也是我们在第一个函数中所做的,所以我们也可以在第一个情况下使用std::count_if()。这两个函数都在线性时间内运行。

使用一个包含一百万个用户的向量调用这两个函数会得到以下输出:

11 ms num_users_at_level (using 128 bytes User)
10 ms num_playing_users (using 128 bytes User) 

我们假设通过使User类更小,迭代向量将更快。如前所述,密码和安全数据字段很少使用,可以分组在一个单独的结构中。这将给我们以下类:

struct AuthInfo { 
  std::string username_; 
  std::string password_; 
  std::string security_question_; 
  std::string security_answer_; 
}; 

struct User { 
  std::string name_; 
  std::unique_ptr<AuthInfo> auth_info_; 
  short level_{}; 
  bool is_playing_{}; 
}; 

这个改变将User类的大小从 128 字节减小到 40 字节。在User类中不再存储四个字符串,而是使用指针来引用新的AuthInfo对象。下图显示了我们如何将User类分成两个较小的类:

图 4.16:当认证信息保存在单独的类中时的内存布局

从设计的角度来看,这个改变也是有意义的。将认证数据保存在单独的类中增加了User类的内聚性。User类包含一个指向认证信息的指针。当然,用户数据占用的总内存量并没有减少,但现在重要的是缩小User类以加快迭代所有用户的函数。

从优化的角度来看,我们必须再次测量以验证我们关于较小数据的假设是否有效。结果表明,使用较小的User类时,两个函数的运行速度都提高了两倍以上。修改版本运行时的输出如下:

4 ms num_users_at_level with User
3 ms num_playing_users with User 

接下来,我们将尝试一种更激进的方式来缩小我们需要迭代的数据量,即使用并行数组。首先,警告:在许多情况下,这是一种优化,具有太多的缺点,无法成为可行的替代方案。不要将其视为一般技术,并且不加思考地应用它。在看完几个例子之后,我们将回顾并行数组的优缺点。

通过使用并行数组,我们简单地将大型结构拆分为较小的类型,类似于我们为User类的认证信息所做的操作。但是,我们不是使用指针来关联对象,而是将较小的结构存储在相等大小的单独数组中。不同数组中的较小对象,它们共享相同的索引,形成完整的原始对象。

一个例子将阐明这种技术。我们所使用的User类由 40 个字节组成。现在它只包含一个用户名字符串,一个指向认证信息的指针,一个表示当前级别的整数,以及is_playing_布尔值。通过缩小用户对象,我们发现在迭代对象时性能有所提高。用户对象数组的内存布局看起来像下图所示。我们暂时忽略内存对齐和填充,但在第七章 内存管理中会回到这些主题:

图 4.17:用户对象在向量中连续存储

我们可以将所有short级别和is_playing_标志存储在单独的向量中,而不是一个包含用户对象的向量。用户数组中索引为 0 的用户的当前级别也存储在级别数组的索引 0 处。这样,我们可以避免使用级别的指针,而是只使用索引来连接数据字段。我们也可以对布尔is_playing_字段做同样的操作,最终得到三个并行数组,而不是一个。这三个向量的内存布局看起来像这样:

图 4.18:使用三个并行数组时的内存布局

我们使用三个并行数组来快速迭代一个特定字段。num_users_at_level()函数现在可以通过仅使用级别数组来计算特定级别的用户数量。现在的实现只是std::count()的一个包装器:

auto num_users_at_level(const std::vector<int>& users, short level) { 
  ScopedTimer t{"num_users_at_level using int vector"}; 
  return std::count(users.begin(), users.end(), level); 
} 

同样,num_playing_users()函数只需要迭代布尔向量来确定正在玩游戏的用户数量。同样,我们使用std::count()

auto num_playing_users(const std::vector<bool>& users) { 
  ScopedTimer t{"num_playing_users using vector<bool>"}; 
  return std::count(users.begin(), users.end(), true); 
} 

使用并行数组,我们根本不需要使用用户数组。提取数组所占用的内存量远远小于用户数组,因此让我们再次检查在一百万用户上运行这些函数时是否提高了性能:

auto users = std::vector<User>(1'000'000); 
auto levels = std::vector<short>(1'000'000); 
auto playing_users = std::vector<bool>(1'000'000); 

// Initialize data 
// ... 

auto num_at_level_5 = num_users_at_level(levels, 5);
auto num_playing = num_playing_users(playing_users); 

使用整数数组计算特定级别的用户数量只需要大约 0.7 毫秒。回顾一下,初始版本使用 128 字节大小的User类大约需要 11 毫秒。较小的User类执行时间为 4 毫秒,现在,只使用levels数组,我们的执行时间降至 0.7 毫秒。这是一个相当大的变化。

对于第二个函数num_playing_users()来说,改变更大——只需要大约 0.03 毫秒就能计算出当前正在玩游戏的用户数量。之所以能够如此快速,是因为有一种叫做位数组的数据结构。原来std::vector<bool>并不是标准的 C++ bool对象的向量。在内部,它实际上是一个位数组。在位数组中,诸如count()find()等操作可以被高效地优化,因为它可以一次处理 64 位(在 64 位机器上),甚至可能通过使用 SIMD 寄存器处理更多位。std::vector<bool>的未来尚不明朗,很可能会很快被固定大小的std::bitset和新的动态大小的 bitset 所取代。Boost 中已经有了一个名为boost::dynamic_bitset的版本。

这一切都很棒,但我警告过您会有一些缺点。首先,从类中提取字段实际上会对代码结构产生重大影响。在某些情况下,将大类拆分为较小的部分是完全合理的,但在其他情况下,它完全破坏了封装性,并暴露了本应该隐藏在更高抽象接口后面的数据。

确保数组同步也很麻烦,因此我们总是需要确保组成一个对象的字段在所有数组中的相同索引处存储。这样的隐式关系很难维护,也容易出错。

最后一个缺点实际上与性能有关。在前面的例子中,您看到对于逐个字段迭代的算法,性能有了很大的提升。然而,如果我们有一个需要访问已提取到不同数组中的多个字段的算法,它将比在一个包含更大对象的数组上迭代要慢得多。

因此,就像在处理性能时一样,没有什么是不需要付出代价的,暴露数据并将一个简单的数组拆分为多个数组的代价可能太高,也可能不太高。这一切取决于您所面临的情况,以及在测量后您所遇到的性能收益。在真正面临性能问题之前,不要考虑并行数组。始终优先考虑良好的设计原则,并倾向于显式地表达对象之间的关系,而不是隐式的。

总结

在本章中,介绍了标准库中的容器类型。您了解到我们如何组织数据对于我们能够高效执行集合对象上的某些操作有着重大影响。标准库容器的渐近复杂度规范是在选择不同数据结构时需要考虑的关键因素。

此外,您了解到现代处理器中的缓存层次结构如何影响我们需要如何组织数据以实现对内存的高效访问。高效利用缓存层次结构的重要性不言而喻。这也是为什么保持元素在内存中连续的容器,如std::vectorstd::string,已经成为最常用的容器之一的原因。

在下一章中,我们将看看如何使用迭代器和算法来高效地操作容器。

算法

标准库中容器的使用在 C++程序员中被广泛采用。很少能找到没有引用std::vectorstd::string等的 C++代码库。然而,在我的经验中,标准库算法的使用频率要低得多,尽管它们提供了与容器相同类型的好处:

  • 在解决复杂问题时可以用作构建块

  • 它们有很好的文档(包括参考资料、书籍和视频)

  • 许多 C++程序员已经熟悉它们

  • 它们的空间和运行时成本是已知的(复杂度保证)

  • 它们的实现非常精心和高效

如果这还不够,C++的特性,比如 lambda、执行策略、概念和范围,都使标准算法更加强大,同时也更加友好。

在本章中,我们将看看如何使用算法库在 C++中编写高效的算法。您将学习在应用程序中使用标准库算法作为构建块的好处,无论是性能还是可读性方面。

在本章中,您将学习:

  • C++标准库中的算法

  • 迭代器和范围-容器和算法之间的粘合剂

  • 如何实现一个可以操作标准容器的通用算法

  • 使用 C++标准算法的最佳实践

让我们首先看一下标准库算法,以及它们如何成为今天的样子。

介绍标准库算法

将标准库算法集成到您的 C++词汇表中是很重要的。在本介绍中,我将介绍一组可以通过使用标准库算法有效解决的常见问题。

C++20 通过引入 Ranges 库和C++概念的语言特性对算法库进行了重大改变。因此,在我们开始之前,我们需要简要了解 C++标准库的历史背景。

标准库算法的演变

您可能已经听说过 STL 算法或 STL 容器。希望您也已经听说了 C++20 引入的新的 Ranges 库。在 C++20 中,标准库有很多新增内容。在继续之前,我需要澄清一些术语。我们将从 STL 开始。

STL,或者标准模板库,最初是在上世纪 90 年代添加到 C++标准库中的一个库的名称。它包含算法、容器、迭代器和函数对象。这个名字一直很粘人,我们已经习惯了听到和谈论 STL 算法和容器。然而,C++标准并没有提到 STL;相反,它谈到了标准库及其各个组件,比如迭代器库和算法库。在本书中,我会尽量避免使用 STL 这个名字,而是在需要时谈论标准库或单独的库。

现在让我们来看看 Ranges 库以及我将称之为受限算法。Ranges 库是 C++20 中添加到标准库的一个库,引入了一个全新的头文件<ranges>,我们将在下一章中更多地谈论它。但是,Ranges 库的添加也对<algorithm>头文件产生了很大影响,通过引入所有先前存在的算法的重载版本。我将这些算法称为受限算法,因为它们使用了 C++概念进行限制。因此,<algorithm>头文件现在包括了旧的基于迭代器的算法和可以操作范围的使用 C++概念限制的新算法。这意味着我们将在本章讨论的算法有两种风味,如下例所示:

#include <algorithm>
#include <vector>
auto values = std::vector{9, 2, 5, 3, 4};
// Sort using the std algorithms
std::sort(values.begin(), values.end());
// Sort using the constrained algorithms under std::ranges
std::ranges::sort(values); 
std::ranges::sort(values.begin(), values.end()); 

请注意,sort()的两个版本都位于<algorithm>头文件中,但它们由不同的命名空间和签名区分。本章将使用这两种版本,但一般来说,我建议尽可能使用新的约束算法。在阅读本章后,这些好处将会变得明显。

现在你已经准备好开始学习如何使用现成的算法来解决常见问题了。

解决日常问题

我在这里列出了一些常见的场景和有用的算法,只是为了让你对标准库中可用的算法有所了解。标准库中有许多算法,在本节中我只会介绍其中的一些。对于标准库算法的快速但完整的概述,我推荐 Jonathan Boccara 在CppCon 2018上的演讲,题为Less Than an Hour,可在sched.co/FnJh上找到。

遍历序列

有一个有用的短小的辅助函数,可以打印序列的元素。下面的通用函数适用于任何容器,其中包含可以使用operator<<()打印到输出流的元素:

void print(auto&& r) {
  std::ranges::for_each(r, [](auto&& i) { std::cout << i << ' '; });
} 

print()函数使用了for_each(),这是从<algorithm>头文件导入的算法。for_each()为我们提供的函数为范围中的每个元素调用一次。我们提供的函数的返回值被忽略,并且对我们传递给for_each()的序列没有影响。我们可以使用for_each()来进行诸如打印到stdout之类的副作用(就像在这个例子中所做的那样)。

一个类似的非常通用的算法是transform()。它也为序列中的每个元素调用一个函数,但它不会忽略返回值,而是将函数的返回值存储在输出序列中,就像这样:

auto in = std::vector{1, 2, 3, 4};
auto out = std::vector<int>(in.size());
auto lambda = [](auto&& i) { return i * i; };
std::ranges::transform(in, out.begin(), lambda);
print(out); 
// Prints: "1 4 9 16" 
print() function defined earlier. The transform() algorithm will call our lambda once for each element in the input range. To specify where the output will be stored, we provide transform() with an output iterator, out.begin(). We will talk a lot more about iterators later on in this chapter.

有了我们的print()函数和一些最常见的算法演示,我们将继续看一些用于生成元素的算法。

生成元素

有时我们需要为一系列元素分配一些初始值或重置整个序列。下面的例子用值-1 填充了一个向量:

auto v = std::vector<int>(4);
std::ranges::fill(v, -1);
print(v); 
// Prints "-1 -1 -1 -1 " 

下一个算法generate()为每个元素调用一个函数,并将返回值存储在当前元素中:

auto v = std::vector<int>(4);
std::ranges::generate(v, std::rand);
print(v);
// Possible output: "1804289383 846930886 1681692777 1714636915 " 

在前面的例子中,std::rand()函数被每个元素调用了一次。

我要提到的最后一个生成算法是<numeric>头文件中的std::iota()。它按递增顺序生成值。起始值必须作为第二个参数指定。下面是一个生成 0 到 5 之间值的简短示例:

 auto v = std::vector<int>(6);
  std::iota(v.begin(), v.end(), 0);
  print(v); // Prints: "0 1 2 3 4 5 " 

这个序列已经排序好了,但更常见的情况是你有一个无序的元素集合需要排序,接下来我们会看一下。

元素排序

排序元素是一个非常常见的操作。有一些好的排序算法替代方案是值得了解的,但在这个介绍中,我只会展示最常规的版本,简单地命名为sort()

auto v = std::vector{4, 3, 2, 3, 6};
std::ranges::sort(v);
print(v);       // Prints: "2 3 3 4 6 " 

如前所述,这不是唯一的排序方式,有时我们可以使用部分排序算法来提高性能。我们将在本章后面更多地讨论排序。

查找元素

另一个非常常见的任务是找出特定值是否在集合中。也许我们想知道集合中有多少个特定值的实例。如果我们知道集合已经排序,那么搜索值的这些算法可以更有效地实现。你在第三章分析和测量性能中看到了这一点,我们比较了线性搜索和二分搜索。

我们从不需要排序的find()算法开始:

auto col = std::list{2, 4, 3, 2, 3, 1};
auto it = std::ranges::find(col, 2);
if (it != col.end()) {
  std::cout << *it << '\n';
} 

如果找不到我们要找的元素,find()会返回集合的end()迭代器。在最坏的情况下,find()需要检查序列中的所有元素,因此它的运行时间为O(n)

使用二分查找进行查找

如果我们知道集合已经排序,我们可以使用二分搜索算法之一:binary_search()equal_range()upper_bound()lower_bound()。如果我们将这些函数与提供对其元素进行随机访问的容器一起使用,它们都保证在O(log n)时间内运行。当我们在本章后面讨论迭代器和范围时(有一个名为Iterators and Ranges的部分即将到来),你将更好地理解算法如何提供复杂度保证,即使它们在不同的容器上操作。

在以下示例中,我们将使用一个排序的std::vector,其中包含以下元素:

图 5.1:一个包含七个元素的排序 std::vector

binary_search()函数根据我们搜索的值是否能找到返回truefalse

auto v = std::vector{2, 2, 3, 3, 3, 4, 5};    // Sorted!
bool found = std::ranges::binary_search(v, 3);
std::cout << std::boolalpha << found << '\n'; //   Output: true 

在调用binary_search()之前,你应该绝对确定集合是排序的。我们可以在代码中使用is_sorted()轻松断言这一点,如下所示:

assert(std::ranges::is_sorted(v)); 

这个检查将在*O(n)*时间内运行,但只有在激活断言时才会被调用,因此不会影响最终程序的性能。

我们正在处理的排序集合包含多个 3。如果我们想知道集合中第一个 3 或最后一个 3 的位置,我们可以使用lower_bound()来找到第一个 3,或者使用upper_bound()来找到最后一个 3 之后的元素:

auto v = std::vector{2, 2, 3, 3, 3, 4, 5};
auto it = std::ranges::lower_bound(v, 3);
if (it != v.end()) {
  auto index = std::distance(v.begin(), it);
  std::cout << index << '\n'; // Output: 2
} 

这段代码将输出2,因为这是第一个 3 的索引。要从迭代器获取元素的索引,我们使用<iterator>头文件中的std::distance()

同样地,我们可以使用upper_bound()来获取一个迭代器,指向最后一个 3 之后的元素:

const auto v = std::vector{2, 2, 3, 3, 3, 4, 5};
auto it = std::ranges::upper_bound(v, 3);
if (it != v.end()) {
  auto index = std::distance(v.begin(), it);
  std::cout << index << '\n'; // Output: 5
} 

如果你想要上下界,你可以使用equal_range(),它返回包含 3 的子范围:

const auto v = std::vector{2, 2, 3, 3, 3, 4, 5};
auto subrange = std::ranges::equal_range(v, 3);
if (subrange.begin() != subrange.end()) {
  auto pos1 = std::distance(v.begin(), subrange.begin());
  auto pos2 = std::distance(v.begin(), subrange.end());
  std::cout << pos1 << " " << pos2 << '\n';
} // Output: "2 5" 

现在让我们探索一些用于检查集合的其他有用算法。

测试特定条件

有三个非常方便的算法叫做all_of()any_of()none_of()。它们都接受一个范围、一个一元谓词(接受一个参数并返回truefalse的函数)和一个可选的投影函数。

假设我们有一个数字列表和一个小 lambda 函数,确定一个数字是否为负数:

const auto v = std::vector{3, 2, 2, 1, 0, 2, 1};
const auto is_negative = [](int i) { return i < 0; }; 

我们可以使用none_of()来检查是否没有任何数字是负数:

if (std::ranges::none_of(v, is_negative)) {
  std::cout << "Contains only natural numbers\n";
} 

此外,我们可以使用all_of()来询问列表中的所有元素是否都是负数:

if (std::ranges::all_of(v, is_negative)) {
  std::cout << "Contains only negative numbers\n";
} 

最后,我们可以使用any_of()来查看列表是否至少包含一个负数:

if (std::ranges::any_of(v, is_negative)) {
  std::cout << "Contains at least one negative number\n";
} 

很容易忘记标准库中存在的这些小而方便的构建块。但一旦你养成使用它们的习惯,你就再也不会回头手写这些了。

计算元素

计算等于某个值的元素数量最明显的方法是调用count()

const auto numbers = std::list{3, 3, 2, 1, 3, 1, 3};
int n = std::ranges::count(numbers, 3);
std::cout << n;                    // Prints: 4 

count()算法运行时间为线性。然而,如果我们知道序列是排序的,并且我们使用的是向量或其他随机访问数据结构,我们可以使用equal_range(),它将在*O(log n)*时间内运行。以下是一个例子:

const auto v = std::vector{0, 2, 2, 3, 3, 4, 5};
assert(std::ranges::is_sorted(v)); // O(n), but not called in release
auto r = std::ranges::equal_range(v, 3);
int n = std::ranges::size(r);
std::cout << n;                    // Prints: 2 

equal_range()函数找到包含我们要计数的所有元素的子范围。一旦找到子范围,我们可以使用<ranges>头文件中的size()来检索子范围的长度。

最小值、最大值和夹紧

我想提到一组小但非常有用的算法,这些算法对于经验丰富的 C++程序员来说是必不可少的知识。std::min()std::max()std::clamp()函数有时会被遗忘,而我们经常发现自己编写这样的代码:

const auto y_max = 100;
auto y = some_func();
if (y > y_max) {
  y = y_max;
} 

该代码确保y的值在某个限制范围内。这段代码可以工作,但我们可以避免使用可变变量和if语句,而是使用std::min(),如下所示:

const auto y = std::min(some_func(), y_max); 

通过使用std::min(),我们消除了代码中的可变变量和if语句。对于类似的情况,我们可以使用std::max()。如果我们想要将一个值限制在最小值和最大值之间,我们可以这样做:

const auto y = std::max(std::min(some_func(), y_max), y_min); 

但是,自 C++17 以来,我们现在有了std::clamp(),它可以在一个函数中为我们完成这个操作。因此,我们可以像下面这样使用clamp()

const auto y = std::clamp(some_func(), y_min, y_max); 

有时我们需要在未排序的元素集合中找到极值。为此,我们可以使用minmax(),它(不出所料地)返回序列的最小值和最大值。结合结构化绑定,我们可以按如下方式打印极值:

const auto v = std::vector{4, 2, 1, 7, 3, 1, 5};
const auto [min, max] = std::ranges::minmax(v);
std::cout << min << " " << max;      // Prints: "1 7" 

我们还可以使用min_element()max_element()找到最小或最大元素的位置。它不返回值,而是返回一个指向我们要查找的元素的迭代器。在下面的例子中,我们正在寻找最小元素:

const auto v = std::vector{4, 2, 7, 1, 1, 3};
const auto it = std::ranges::min_element(v);
std::cout << std::distance(v.begin(), it); // Output: 3 
3, which is the index of the first minimum value that was found.

这是对标准库中一些最常见算法的简要介绍。算法的运行时成本在 C++标准中有规定,所有库实现都需要遵守这些规定,尽管确切的实现可能在不同的平台之间有所不同。为了理解如何保持与许多不同类型的容器一起工作的通用算法的复杂性保证,我们需要更仔细地研究迭代器和范围。

迭代器和范围

正如前面的例子所示,标准库算法操作的是迭代器和范围,而不是容器类型。本节将重点介绍迭代器和 C++20 中引入的新概念范围。一旦掌握了迭代器和范围,正确使用容器和算法就变得容易了。

介绍迭代器

迭代器构成了标准库算法和范围的基础。迭代器是数据结构和算法之间的粘合剂。正如你已经看到的,C++容器以非常不同的方式存储它们的元素。迭代器提供了一种通用的方式来遍历序列中的元素。通过让算法操作迭代器而不是容器类型,算法变得更加通用和灵活,因为它们不依赖于容器的类型以及容器在内存中排列元素的方式。

在本质上,迭代器是表示序列中位置的对象。它有两个主要责任:

  • 在序列中导航

  • 在当前位置读取和写入值

迭代器抽象根本不是 C++独有的概念,而是存在于大多数编程语言中。C++实现迭代器概念的不同之处在于,C++模仿了原始内存指针的语法。

基本上,迭代器可以被认为是具有与原始指针相同属性的对象;它可以移动到下一个元素并解引用(如果指向有效地址)。算法只使用指针允许的一些操作,尽管迭代器可能在内部是一个遍历类似树状的std::map的重对象。

直接在std命名空间下找到的大多数算法只对迭代器进行操作,而不是容器(即std::vectorstd::map等)。许多算法返回的是迭代器而不是值。

为了能够在序列中导航而不越界,我们需要一种通用的方法来告诉迭代器何时到达序列的末尾。这就是我们有哨兵值的原因。

哨兵值和超出末尾的迭代器

哨兵值(或简称哨兵)是指示序列结束的特殊值。哨兵值使得可以在不知道序列大小的情况下迭代一系列值。哨兵值的一个示例用法是 C 风格的以 null 结尾的字符串(在这种情况下,哨兵是'\0'字符)。不需要跟踪以 null 结尾的字符串的长度,字符串开头的指针和末尾的哨兵就足以定义一系列字符。

约束算法使用迭代器来定义序列中的第一个元素,并使用哨兵来指示序列的结束。哨兵的唯一要求是它可以与迭代器进行比较,实际上意味着operator==()operator!=()应该被定义为接受哨兵和迭代器的组合:

bool operator=!(sentinel s, iterator i) {
  // ...
} 

现在你知道了哨兵是什么,我们如何创建一个哨兵来指示序列的结束呢?这里的诀窍是使用一个叫做past-the-end iterator的迭代器作为哨兵。它只是一个指向我们定义的序列中最后一个元素之后(或过去)的迭代器。看一下下面的代码片段和图表:

|

auto vec = std::vector {
  'a','b','c','d'
};
auto first = vec.begin();
auto last = vec.end(); 

如前图所示,last迭代器现在指向了一个想象中的'd'元素之后。这使得可以通过循环迭代序列中的所有元素:

for (; first != last; ++first) {
  char value = *first; // Dereference iterator
  // ... 

我们可以使用 past-the-end 哨兵与我们的迭代器it进行比较,但是我们不能对哨兵进行解引用,因为它不指向范围的元素。这种 past-the-end 迭代器的概念有着悠久的历史,甚至适用于内置的 C 数组:

char arr[] = {'a', 'b', 'c', 'd'};
char* end = arr + sizeof(arr);
for (char* it = arr; it != end; ++it) { // Stop at end
   std::cout << *it << ' ';} 
// Output: a b c d 

再次注意,end实际上指向了越界,因此我们不允许对其进行解引用,但是我们允许读取指针值并将其与我们的it变量进行比较。

范围

范围是指我们在引用一系列元素时使用的迭代器-哨兵对的替代品。<range>头文件包含了定义不同种类范围要求的多个概念,例如input_rangerandom_access_range等等。这些都是最基本概念range的细化,它的定义如下:

template<class T>
concept range = requires(T& t) {
  ranges::begin(t);
  ranges::end(t);
}; 

这意味着任何暴露begin()end()函数的类型都被认为是范围(假设这些函数返回迭代器)。

对于 C++标准容器,begin()end()函数将返回相同类型的迭代器,而对于 C++20 范围,这通常不成立。具有相同迭代器和哨兵类型的范围满足std::ranges::common_range的概念。新的 C++20 视图(在下一章中介绍)返回可以是不同类型的迭代器-哨兵对。但是,它们可以使用std::views::common转换为具有相同迭代器和哨兵类型的视图。

std::ranges命名空间中找到的约束算法可以操作范围而不是迭代器对。由于所有标准容器(vectormaplist等)都满足范围概念,因此我们可以直接将范围传递给约束算法,如下所示:

auto vec = std::vector{1, 1, 0, 1, 1, 0, 0, 1};
std::cout << std::ranges::count(vec, 0); // Prints 3 

范围是可迭代的东西的抽象(可以循环遍历的东西),在某种程度上,它们隐藏了对 C++迭代器的直接使用。然而,迭代器仍然是 C++标准库的一个重要部分,并且在 Ranges 库中也被广泛使用。

你需要理解的下一件事是存在的不同种类的迭代器。

迭代器类别

现在你对范围的定义以及如何知道何时到达序列的末尾有了更好的理解,是时候更仔细地看一下迭代器可以支持的操作,以便导航,读取和写入值。

在序列中进行迭代器导航可以使用以下操作:

  • 向前移动:std::next(it)++it

  • 向后移动:std::prev(it)--it

  • 跳转到任意位置:std::advance(it, n)it += n

通过解引用迭代器来读取和写入迭代器表示的位置的值。下面是它的样子:

  • 阅读:auto value = *it

  • 写入:*it = value

这些是容器公开的迭代器的最常见操作。但此外,迭代器可能在数据源上操作,其中写入或读取意味着向前移动。这些数据源的示例可能是用户输入,网络连接或文件。这些数据源需要以下操作:

  • 只读和向前移动:auto value = *it; ++it;

  • 只写和向前移动:*it = value; ++it;

这些操作只能用两个连续的表达式来表示。第一个表达式的后置条件是第二个表达式必须有效。这也意味着我们只能读取或写入一个值到一个位置一次。如果我们想要读取或写入一个新值,我们必须先将迭代器推进到下一个位置。

并非所有迭代器都支持前述列表中的所有操作。例如,一些迭代器只能读取值和向前移动,而其他一些既可以读取写入,又可以跳转到任意位置。

现在,如果我们考虑一些基本算法,就会显而易见地发现迭代器的要求在不同的算法之间有所不同:

  • 如果算法计算值的出现次数,则需要读取向前移动操作

  • 如果算法用一个值填充容器,则需要写入向前移动操作

  • 对于排序集合上的二分搜索算法需要读取跳转操作

一些算法可以根据迭代器支持的操作来更有效地实现。就像容器一样,标准库中的所有算法都有复杂度保证(使用大 O 表示法)。为了满足某个复杂度保证,算法对其操作的迭代器提出了要求。这些要求被归类为六种基本迭代器类别,它们之间的关系如下图所示:

图 5.2:六种迭代器类别及其相互关系

箭头表示迭代器类别还具有它所指向的类别的所有功能。例如,如果一个算法需要一个前向迭代器,我们同样可以传递一个双向迭代器,因为双向迭代器具有前向迭代器的所有功能。

这六个要求由以下概念正式指定:

  • std::input_iterator:支持只读和向前移动(一次)。一次性算法如std::count()可以使用输入迭代器。std::istream_iterator是输入迭代器的一个例子。

  • std::output_iterator:支持只写和向前移动(一次)。请注意,输出迭代器只能写入,不能读取。std::ostream_iterator是输出迭代器的一个例子。

  • std::forward_iterator:支持读取写入向前移动。当前位置的值可以多次读取或写入。例如std::forward_list公开前向迭代器。

  • std::bidirectional_iterator:支持读取写入向前移动向后移动。双向链表std::list公开双向迭代器。

  • std::random_access_iterator:支持读取写入向前移动向后移动和在常数时间内跳转到任意位置。std::deque中的元素可以使用随机访问迭代器访问。

  • std::contiguous_iterator:与随机访问迭代器相同,但也保证底层数据是连续的内存块,例如std::stringstd::vectorstd::arraystd::span和(很少使用的)std::valarray

迭代器类别对于理解算法的时间复杂度要求非常重要。对底层数据结构有很好的理解,可以很容易地知道哪些迭代器通常属于哪些容器。

现在我们准备深入了解大多数标准库算法使用的常见模式。

标准算法的特性

为了更好地理解标准算法,了解一些<algorithm>头文件中所有算法使用的特性和常见模式是很有帮助的。正如已经提到的,stdstd::ranges命名空间下的算法有很多共同之处。我们将从这里开始讨论适用于std算法和std::range下受限算法的通用原则。然后,在下一节中,我们将继续讨论std::ranges下特有的特性。

算法不会改变容器的大小

来自<algorithm>的函数只能修改指定范围内的元素;元素永远不会被添加或删除到底层容器中。因此,这些函数永远不会改变它们操作的容器的大小。

例如,std::remove()std::unique()实际上并不会从容器中删除元素(尽管它们的名字是这样)。相反,它们将应该保留的元素移动到容器的前面,然后返回一个标记,定义了元素的有效范围的新结尾:

代码示例结果向量

|

// Example with std::remove()
auto v = std::vector{1,1,2,2,3,3};
auto new_end = std::remove(
  v.begin(), v.end(), 2);
v.erase(new_end, v.end()); 

|

// Example with std::unique()
auto v = std::vector{1,1,2,2,3,3};
auto new_end = std::unique(
  v.begin(), v.end());
v.erase(new_end, v.end()); 

C++20 在<vector>头文件中添加了std::erase()std::erase_if()函数的新版本,它们可以立即从向量中删除值,而无需先调用remove()再调用erase()

标准库算法永远不会改变容器的大小,这意味着在调用产生输出的算法时,我们需要自己分配数据。

带有输出的算法需要已分配的数据

向输出迭代器写入数据的算法,如std::copy()std::transform(),需要为输出预留已分配的数据。由于算法只使用迭代器作为参数,它们无法自行分配数据。为了扩大算法操作的容器,它们依赖于迭代器能够扩大它们迭代的容器。

如果将指向空容器的迭代器传递给输出算法,程序很可能会崩溃。下面的示例展示了这个问题,其中squared是空的:

const auto square_func = [](int x) { return x * x; };
const auto v = std::vector{1, 2, 3, 4};
auto squared = std::vector<int>{};
std::ranges::transform(v, squared.begin(), square_func); 

相反,你必须执行以下操作之一:

  • 为结果容器预先分配所需的大小,或者

  • 使用插入迭代器,它在迭代时向容器中插入元素

以下代码片段展示了如何使用预分配的空间:

const auto square_func = [](int x) { return x * x; };
const auto v = std::vector{1, 2, 3, 4};
auto squared = std::vector<int>{};
squared.resize(v.size());
std::ranges::transform(v, squared.begin(), square_func); 
std::back_inserter() and std::inserter() to insert values into a container that is not preallocated:
const auto square_func = [](int x) { return x * x; };
const auto v = std::vector{1, 2, 3, 4};
// Insert into back of vector using std::back_inserter
auto squared_vec = std::vector<int>{};
auto dst_vec = std::back_inserter(squared_vec);
std::ranges::transform(v, dst_vec, square_func);
// Insert into a std::set using std::inserter
auto squared_set = std::set<int>{};
auto dst_set = std::inserter(squared_set, squared_set.end());
std::ranges::transform(v, dst_set, square_func); 

如果你正在操作std::vector并且知道结果容器的预期大小,可以在执行算法之前使用reserve()成员函数来预留空间,以避免不必要的分配。否则,在算法执行期间,向量可能会多次重新分配新的内存块。

算法默认使用operator==()operator<()

作为比较,算法依赖于基本的==<运算符,就像整数的情况一样。为了能够在算法中使用自定义类,类必须提供operator==()operator<(),或者作为算法的参数提供。

通过使用三路比较运算符operator<=>(),我们可以让编译器生成必要的运算符。下面的示例展示了一个简单的Flower类,其中std::find()使用了operator==(),而std::max_element()使用了operator<()

struct Flower {
    auto operator<=>(const Flower& f) const = default; 
    bool operator==(const Flower&) const = default;
    int height_{};
};
auto garden = std::vector<Flower>{{67}, {28}, {14}};
// std::max_element() uses operator<()
auto tallest = std::max_element(garden.begin(), garden.end());
// std::find() uses operator==()
auto perfect = *std::find(garden.begin(), garden.end(), Flower{28}); 

除了使用当前类型的默认比较函数之外,还可以使用自定义比较函数,接下来我们将探讨这一点。

自定义比较函数

有时我们需要比较对象而不使用默认的比较运算符,例如在排序或按长度查找字符串时。在这些情况下,可以提供自定义函数作为额外参数。原始算法使用值(例如std::find()),具有特定运算符的版本在名称末尾附加了_ifstd::find_if()std::count_if()等):

auto names = std::vector<std::string> {
  "Ralph", "Lisa", "Homer", "Maggie", "Apu", "Bart"
};
std::sort(names.begin(), names.end(), 
          [](const std::string& a,const std::string& b) {
            return a.size() < b.size(); });
// names is now "Apu", "Lisa", "Bart", "Ralph", "Homer", "Maggie"
// Find names with length 3
auto x = std::find_if(names.begin(), names.end(), 
  [](const auto& v) { return v.size() == 3; });
// x points to "Apu" 

受限算法使用投影

std::ranges下的受限算法为我们提供了一个称为投影的方便功能,它减少了编写自定义比较函数的需求。前一节中的前面示例可以使用标准谓词std::less结合自定义投影进行重写:

auto names = std::vector<std::string>{
  "Ralph", "Lisa", "Homer", "Maggie", "Apu", "Bart"
};
std::ranges::sort(names, std::less<>{}, &std::string::size);
// names is now "Apu", "Lisa", "Bart", "Ralph", "Homer", "Maggie"

// Find names with length 3
auto x = std::ranges::find(names, 3, &std::string::size);
// x points to "Apu" 

还可以将 lambda 作为投影参数传递,这在想要在投影中组合多个属性时非常方便:

struct Player {
  std::string name_{};
  int level_{};
  float health_{};
  // ...
};
auto players = std::vector<Player>{
  {"Aki", 1, 9.f}, 
  {"Nao", 2, 7.f}, 
  {"Rei", 2, 3.f}};
auto level_and_health = [](const Player& p) {
  return std::tie(p.level_, p.health_);
}; 
// Order players by level, then health
std::ranges::sort(players, std::greater<>{}, level_and_health); 

向标准算法传递投影对象的可能性是一个非常受欢迎的功能,真正简化了自定义比较的使用。

算法要求移动操作不抛出异常

所有算法在移动元素时都使用std::swap()std::move(),但只有在移动构造函数和移动赋值标记为noexcept时才会使用。因此,在使用算法时,对于重型对象来说,实现这些是很重要的。如果它们不可用且无异常,则元素将被复制而不是移动。

请注意,如果您在类中实现了移动构造函数和移动赋值运算符,std::swap()将利用它们,因此不需要指定std::swap()重载。

算法具有复杂性保证

标准库中每个算法的复杂度都使用大 O 表示法进行了规定。算法是以性能为目标创建的。因此,它们既不分配内存,也不具有高于*O(n log n)*的时间复杂度。即使它们是相当常见的操作,也不包括不符合这些标准的算法。

请注意stable_sort()inplace_merge()stable_partition()的异常。许多实现在这些操作期间倾向于临时分配内存。

例如,让我们考虑一个测试非排序范围是否包含重复项的算法。一种选择是通过迭代范围并搜索范围的其余部分来实现它。这将导致一个O(n²*)*复杂度的算法:

template <typename Iterator>
auto contains_duplicates(Iterator first, Iterator last) {
  for (auto it = first; it != last; ++it)
    if (std::find(std::next(it), last, *it) != last)
      return true;
  return false;
} 

另一种选择是复制整个范围,对其进行排序,并查找相邻的相等元素。这将导致*O(n log n)*的时间复杂度,即std::sort()的复杂度。然而,由于它需要复制整个范围,因此仍然不符合构建块算法的条件。分配意味着我们不能相信它不会抛出异常:

template <typename Iterator>
auto contains_duplicates(Iterator first, Iterator last) {
  // As (*first) returns a reference, we have to get 
  // the base type using std::decay_t
  using ValueType = std::decay_t<decltype(*first)>;
  auto c = std::vector<ValueType>(first, last);
  std::sort(c.begin(), c.end());
  return std::adjacent_find(c.begin(),c.end()) != c.end();
} 

复杂性保证从 C++标准库的一开始就是其巨大成功的主要原因之一。C++标准库中的算法是以性能为目标设计和实现的。

算法的性能与 C 库函数等价物一样好

标准 C 库配备了许多低级算法,包括memcpy()memmove()memcmp()memset()。根据我的经验,有时人们使用这些函数而不是标准算法库中的等价物。原因是人们倾向于相信 C 库函数更快,因此接受类型安全的折衷。

这对于现代标准库实现来说是不正确的;等价算法std::copy()std::equal()std::fill()在可能的情况下会使用这些低级 C 函数;因此,它们既提供性能又提供类型安全。

当然,也许会有例外情况,C++编译器无法检测到可以安全地使用低级 C 函数的情况。例如,如果一个类型不是平凡可复制的,std::copy()就不能使用memcpy()。但这是有充分理由的;希望一个不是平凡可复制的类的作者有充分的理由以这种方式设计类,我们(或编译器)不应该忽视这一点,而不调用适当的构造函数。

有时,C++算法库中的函数甚至比它们的 C 库等效函数表现得更好。最突出的例子是std::sort()与 C 库中的qsort()std::sort()qsort()之间的一个重大区别是,qsort()是一个函数,而std::sort()是一个函数模板。当qsort()调用比较函数时,由于它是作为函数指针提供的,通常比使用std::sort()时调用的普通比较函数慢得多,后者可能会被编译器内联。

在本章的其余部分,我们将介绍在使用标准算法和实现自定义算法时的一些最佳实践。

编写和使用通用算法

算法库包含通用算法。为了尽可能具体,我将展示一个通用算法的实现示例。这将为您提供一些关于如何使用标准算法的见解,同时演示实现通用算法并不那么困难。我故意避免在这里解释示例代码的所有细节,因为我们将在本书的后面花费大量时间进行通用编程。

在接下来的示例中,我们将把一个简单的非通用算法转换为一个完整的通用算法。

非通用算法

通用算法是一种可以与各种元素范围一起使用的算法,而不仅仅是一种特定类型,比如std::vector。以下算法是一个非通用算法的例子,它只能与std::vector<int>一起使用:

auto contains(const std::vector<int>& arr, int v) {
  for (int i = 0; i < arr.size(); ++i) {	
    if (arr[i] == v) { return true; }
  }
  return false;
} 

为了找到我们要找的元素,我们依赖于std::vector的接口,它为我们提供了size()函数和下标运算符(operator[]())。然而,并非所有容器都提供这些函数,我也不建议您以这种方式编写原始循环。相反,我们需要创建一个在迭代器上操作的函数模板。

通用算法

通过用两个迭代器替换std::vector,用一个模板参数替换int,我们可以将我们的算法转换为通用版本。以下版本的contains()可以与任何容器一起使用:

template <typename Iterator, typename T>
auto contains(Iterator begin, Iterator end, const T& v) {
  for (auto it = begin; it != end; ++it) {
    if (*it == v) { return true; }
  }
  return false;
} 

例如,要将其与std::vector一起使用,您需要传递begin()end()迭代器:

auto v = std::vector{3, 4, 2, 4};
if (contains(v.begin(), v.end(), 3)) {
 // Found the value...
} 

我们可以通过提供一个接受范围而不是两个单独迭代器参数的版本来改进这个算法:

auto contains(const auto& r, const auto& x) {
  auto it = std::begin(r);
  auto sentinel = std::end(r);
  return contains(it, sentinel, x);
} 

这个算法不强制客户端提供begin()end()迭代器,因为我们已经将其移到函数内部。我们使用了 C++20 的缩写函数模板语法,以避免明确说明这是一个函数模板。最后一步,我们可以为我们的参数类型添加约束:

auto contains(const std::ranges::range auto& r, const auto& x) {
  auto it = std::begin(r);
  auto sentinel = std::end(r);
  return contains(it, sentinel, x);
} 

正如你所看到的,创建一个强大的通用算法实际上并不需要太多的代码。我们传递给算法的数据结构唯一的要求是它可以公开begin()end()迭代器。您将在第八章“编译时编程”中了解更多关于约束和概念的知识。

可以被通用算法使用的数据结构

这让我们意识到,只要我们的新自定义数据结构公开begin()end()迭代器或一个范围,它们就可以被标准通用算法使用。举个简单的例子,我们可以实现一个二维Grid结构,其中行被公开为一对迭代器,就像这样:

struct Grid {
  Grid(std::size_t w, std::size_t h) : w_{w}, h_{h} {    data_.resize(w * h); 
  }
  auto get_row(std::size_t y); // Returns iterators or a range

  std::vector<int> data_{};
  std::size_t w_{};
  std::size_t h_{};
}; 

下图说明了带有迭代器对的Grid结构的布局:

图 5.3:建立在一维向量上的二维网格

get_row()的可能实现将返回一个包含代表行的开始和结束的迭代器的std::pair

auto Grid::get_row(std::size_t y) {
  auto left = data_.begin() + w_ * y;
  auto right = left + w_;
  return std::make_pair(left, right);
} 

表示行的迭代器对然后可以被标准库算法使用。在下面的示例中,我们使用std::generate()std::count()

auto grid = Grid{10, 10};
auto y = 3;
auto row = grid.get_row(y);
std::generate(row.first, row.second, std::rand);
auto num_fives = std::count(row.first, row.second, 5); 

虽然这样可以工作,但使用std::pair有点笨拙,而且还要求客户端知道如何处理迭代器对。没有明确说明firstsecond成员实际上表示半开范围。如果它能暴露一个强类型的范围会不会很好呢?幸运的是,我们将在下一章中探讨的 Ranges 库为我们提供了一个名为std::ranges::subrange的视图类型。现在,get_row()函数可以这样实现:

auto Grid::get_row(std::size_t y) {
  auto first = data_.begin() + w_ * y;
  auto sentinel = first + w_;
  return std::ranges::subrange{first, sentinel};
} 

我们甚至可以更懒,使用为这种情况量身定制的方便视图,称为std::views::counted()

auto Grid::get_row(std::size_t y) {
  auto first = data_.begin() + w_ * y;
  return std::views::counted(first, w_);
} 

Grid类返回的行现在可以与接受范围而不是迭代器对的受限算法中的任何一个一起使用:

auto row = grid.get_row(y);
std::ranges::generate(row, std::rand);
auto num_fives = std::ranges::count(row, 5); 

这完成了我们编写和使用支持迭代器对和范围的通用算法的示例。希望这给您一些关于如何以通用方式编写数据结构和算法以避免组合爆炸的见解,如果我们不得不为所有类型的数据结构编写专门的算法,那么组合爆炸就会发生。

最佳实践

让我们考虑一些在使用我们讨论的算法时会对您有所帮助的实践。我将首先强调实际利用标准算法的重要性。

使用受限算法

在 C++20 中引入的std::ranges下的受限算法比std下的基于迭代器的算法提供了一些优势。受限算法执行以下操作:

  • 支持投影,简化元素的自定义比较。

  • 支持范围而不是迭代器对。无需将begin()end()迭代器作为单独的参数传递。

  • 易于正确使用,并且由于受 C++概念的限制,在编译期间提供描述性错误消息。

我建议开始使用受限算法而不是基于迭代器的算法。

您可能已经注意到,本书在许多地方使用了基于迭代器的算法。这样做的原因是,在撰写本书时,并非所有标准库实现都支持受限算法。

仅对需要检索的数据进行排序

算法库包含三种基本排序算法:sort()partial_sort()nth_element()。此外,它还包含其中的一些变体,包括stable_sort(),但我们将专注于这三种,因为根据我的经验,在许多情况下,可以通过使用nth_element()partial_sort()来避免完全排序。

虽然sort()对整个范围进行排序,但partial_sort()nth_element()可以被视为检查该排序范围的部分的算法。在许多情况下,您只对排序范围的某一部分感兴趣,例如:

  • 如果要计算范围的中位数,则需要排序范围中间的值。

  • 如果您想创建一个可以被人口平均身高的 80%使用的身体扫描仪,您需要在排序范围内找到两个值:距离最高者 10%的值和距离最矮者 10%的值。

下图说明了std::nth_elementstd::partial_sort如何处理范围,与完全排序的范围相比:

|

auto v = std::vector{6, 3, 2, 7,
                     4, 1, 5};
auto it = v.begin() + v.size()/2; 

|

std::ranges::sort(v); 

|

std::nth_element(v.begin(), it,
                 v.end()); 

|

std::partial_sort(v.begin(), it,
                  v.end()); 

图 5.1:使用不同算法对范围的排序和非排序元素

下表显示了它们的算法复杂度;请注意,m表示正在完全排序的子范围:

算法复杂度
std::sort()O(n log n)
std::partial_sort()O(n log m)
std::nth_element()O(n)

表 5.2:算法复杂度

用例

现在您已经了解了std:nth_element()std::partial_sort(),让我们看看如何将它们结合起来检查范围的部分,就好像整个范围都已排序:

|

auto v = std::vector{6, 3, 2, 7,
                     4, 1, 5};
auto it = v.begin() + v.size()/2; 

|

auto left = it - 1;
auto right = it + 2;
std::nth_element(v.begin(),
                 left, v.end());
std::partial_sort(left, right,
                  v.end()); 

|

std::nth_element(v.begin(), it,
                 v.end());
std::sort(it, v.end()); 

|

auto left = it - 1;
auto right = it + 2;
std::nth_element(v.begin(),
                 right, v.end());
std::partial_sort(v.begin(),
                  left, right);
std::sort(right, v.end()); 

图 5.3:组合算法和相应的部分排序结果

正如您所看到的,通过使用std::sort()std::nth_element()std::partial_sort()的组合,有许多方法可以在绝对不需要对整个范围进行排序时避免这样做。这是提高性能的有效方法。

性能评估

让我们看看std::nth_element()std::partial_sort()std::sort()相比如何。我们使用了一个包含 1000 万个随机int元素的std::vector进行了测量:

操作代码,其中r是操作的范围时间(加速)
排序
std::sort(r.begin(), r.end()); 
760 毫秒(1.0x)
寻找中位数
auto it = r.begin() + r.size() / 2;
std::nth_element(r.begin(), it, r.end()); 
83 毫秒(9.2x)
对范围的前十分之一进行排序
auto it = r.begin() + r.size() / 10;
std::partial_sort(r.begin(), it, r.end()); 
378 毫秒(2.0x)

表 5.3:部分排序算法的基准结果

使用标准算法而不是原始的 for 循环

很容易忘记复杂的算法可以通过组合标准库中的算法来实现。也许是因为习惯于手工解决问题并立即开始手工制作for循环并使用命令式方法解决问题。如果这听起来对您来说很熟悉,我的建议是要充分了解标准算法,以至于您开始将它们作为首选。

我推荐使用标准库算法而不是原始的for循环,原因有很多:

  • 标准算法提供了性能。即使标准库中的一些算法看起来很琐碎,它们通常以不明显的方式进行了最优设计。

  • 标准算法提供了安全性。即使是更简单的算法也可能有一些特殊情况,很容易忽视。

  • 标准算法是未来的保障;如果您想利用 SIMD 扩展、并行性甚至是以后的 GPU,可以用更合适的算法替换给定的算法(参见第十四章并行算法)。

  • 标准算法有详细的文档。

此外,通过使用算法而不是for循环,每个操作的意图都可以通过算法的名称清楚地表示出来。如果您使用标准算法作为构建块,您的代码的读者不需要检查原始的for循环内部的细节来确定您的代码的作用。

一旦您养成了以算法思考的习惯,您会意识到许多for循环通常是一些简单算法的变体,例如std::transform()std::any_of()std::copy_if()std::find()

使用算法还将使代码更清晰。您通常可以实现函数而不需要嵌套代码块,并且同时避免可变变量。这将在下面的示例中进行演示。

示例 1:可读性问题和可变变量

我们的第一个示例来自一个真实的代码库,尽管变量名已经被伪装。由于这只是一个剪切,您不必理解代码的逻辑。这个例子只是为了向您展示与嵌套的for循环相比,使用算法时复杂度降低的情况。

原始版本如下:

// Original version using a for-loop
auto conflicting = false;
for (const auto& info : infos) {
  if (info.params() == output.params()) {
    if (varies(info.flags())) {
      conflicting = true;
      break;
    }
  }
  else {
    conflicting = true;
    break;
  }
} 

for-循环版本中,很难理解conflicting何时或为什么被设置为true,而在算法的后续版本中,你可以直观地看到,如果info满足谓词,它就会发生。此外,标准算法版本不使用可变变量,并且可以使用短 lambda 和any_of()的组合来编写。它看起来是这样的:

// Version using standard algorithms
const auto in_conflict = & {
  return info.params() != output.params() || varies(info.flags());
};
const auto conflicting = std::ranges::any_of(infos, in_conflict); 

虽然这可能有些言过其实,但想象一下,如果我们要追踪一个 bug 或者并行化它,使用 lambda 和any_of()的标准算法版本将更容易理解和推理。

示例 2:不幸的异常和性能问题

为了进一步说明使用算法而不是for-循环的重要性,我想展示一些不那么明显的问题,当使用手工制作的for-循环而不是标准算法时,你可能会遇到的问题。

假设我们需要一个函数,将容器前面的第 n 个元素移动到后面,就像这样:

图 5.4:将前三个元素移动到范围的后面

方法 1:使用传统的 for 循环

一个天真的方法是在迭代它们时将前 n 个元素复制到后面,然后删除前 n 个元素:

图 5.5:分配和释放以将元素移动到范围的后面

以下是相应的实现:

template <typename Container>
auto move_n_elements_to_back(Container& c, std::size_t n) {
  // Copy the first n elements to the end of the container
  for (auto it = c.begin(); it != std::next(c.begin(), n); ++it) {
    c.emplace_back(std::move(*it));
  }
  // Erase the copied elements from front of container
  c.erase(c.begin(), std::next(c.begin(), n));
} 

乍一看,这可能看起来是合理的,但仔细检查会发现一个严重的问题——如果容器在迭代过程中重新分配了内存,由于emplace_back(),迭代器it将不再有效。由于算法试图访问无效的迭代器,算法将进入未定义的行为,并且在最好的情况下会崩溃。

方法 2:安全的 for 循环(以性能为代价的安全)

由于未定义的行为是一个明显的问题,我们将不得不重写算法。我们仍然使用手工制作的for-循环,但我们将利用索引而不是迭代器:

template <typename Container>
auto move_n_elements_to_back(Container& c, std::size_t n) {
  for (size_t i = 0; i < n; ++i) {
    auto value = *std::next(c.begin(), i);
    c.emplace_back(std::move(value));
  }
  c.erase(c.begin(), std::next(c.begin(), n));
} 

解决方案有效;不再崩溃。但现在,它有一个微妙的性能问题。该算法在std::list上比在std::vector上慢得多。原因是std::next(it, n)std::list::iterator一起使用是O(n),而在std::vector::iterator上是O(1)。由于std::next(it, n)for-循环的每一步中都被调用,这个算法在诸如std::list的容器上将具有O(n²*)*的时间复杂度。除了这个性能限制,前面的代码还有以下限制:

  • 由于emplace_back(),它不适用于静态大小的容器,比如std::array

  • 它可能会抛出异常,因为emplace_back()可能会分配内存并失败(尽管这可能很少见)

方法 3:查找并使用合适的标准库算法

当我们达到这个阶段时,我们应该浏览标准库,看看它是否包含一个适合用作构建块的算法。方便的是,<algorithm>头文件提供了一个名为std::rotate()的算法,它正好可以解决我们正在寻找的问题,同时避免了前面提到的所有缺点。这是我们使用std::rotate()算法的最终版本:

template <typename Container>
auto move_n_elements_to_back(Container& c, std::size_t n) {
  auto new_begin = std::next(c.begin(), n);
  std::rotate(c.begin(), new_begin, c.end());
} 

让我们来看看使用std::rotate()的优势:

  • 该算法不会抛出异常,因为它不会分配内存(尽管包含的对象可能会抛出异常)

  • 它适用于大小无法更改的容器,比如std::array

  • 性能是O(n),无论它在哪个容器上操作

  • 实现很可能针对特定硬件进行优化

也许你会觉得这种for-循环和标准算法之间的比较是不公平的,因为这个问题还有其他解决方案,既优雅又高效。然而,在现实世界中,当标准库中有算法等待解决你的问题时,看到像你刚刚看到的这样的实现并不罕见。

例 3:利用标准库的优化

这个最后的例子突显了一个事实,即即使看起来非常简单的算法可能包含你不会考虑的优化。例如,让我们来看一下std::find()。乍一看,似乎明显的实现无法进一步优化。这是std::find()算法的可能实现:

template <typename It, typename Value>
auto find_slow(It first, It last, const Value& value) {
  for (auto it = first; it != last; ++it)
    if (*it == value)
      return it;
  return last;
} 

然而,通过查看 GNU libstdc++的实现,当与random_access_iterator一起使用时(换句话说,std::vectorstd::stringstd::dequestd::array),libc++实现者已经将主循环展开成一次四个循环的块,导致比较(it != last)执行的次数减少四分之一。

这是从 libstdc++库中取出的std::find()的优化版本:

template <typename It, typename Value>
auto find_fast(It first, It last, const Value& value) {
  // Main loop unrolled into chunks of four
  auto num_trips = (last - first) / 4;
  for (auto trip_count = num_trips; trip_count > 0; --trip_count) {
    if (*first == value) {return first;} ++first;
    if (*first == value) {return first;} ++first;
    if (*first == value) {return first;} ++first;
    if (*first == value) {return first;} ++first;
  }
  // Handle the remaining elements
  switch (last - first) {
    case 3: if (*first == value) {return first;} ++first;
    case 2: if (*first == value) {return first;} ++first;
    case 1: if (*first == value) {return first;} ++first;
    case 0:
    default: return last;
  }
} 

请注意,实际上使用的是std::find_if(),而不是std::find(),它利用了这种循环展开优化。但std::find()是使用std::find_if()实现的。

除了std::find(),libstdc++中还使用std::find_if()实现了大量算法,例如any_of()all_of()none_of()find_if_not()search()is_partitioned()remove_if()is_permutation(),这意味着所有这些都比手工制作的for-循环稍微快一点。

稍微地,我真的是指稍微;加速大约是 1.07 倍,如下表所示:

在包含 1000 万个元素的std::vector中查找整数
算法时间加速
find_slow()3.06 毫秒1.00x
find_fast()3.26 毫秒1.07x

表 5.5:find_fast()使用在 libstdc++中找到的优化。基准测试表明 find_fast()比 find_slow()稍微快一点。

然而,即使好处几乎可以忽略不计,使用标准算法,你可以免费获得它。

"与零比较"优化

除了循环展开之外,一个非常微妙的优化是trip_count是向后迭代以与零比较而不是一个值。在一些 CPU 上,与零比较比任何其他值稍微快一点,因为它使用另一个汇编指令(在 x86 平台上,它使用test而不是cmp)。

下表显示了使用 gcc 9.2 的汇编输出的差异:

动作C++汇编 x86
与零比较
auto cmp_zero(size_t val) {
  return val > 0;
} 

|

test edi, edi
setne al
ret 

|

与另一个值比较
auto cmp_val(size_t val) {
  return val > 42;
} 

|

cmp edi, 42
setba al
ret 

|

表 5.6:汇编输出的差异

尽管标准库实现鼓励这种优化,但不要重新排列你手工制作的循环以从这种优化中受益,除非它是一个(非常)热点。这样做会严重降低你代码的可读性;让算法来处理这些优化。

这是关于使用算法而不是for-循环的建议的结束。如果你还没有使用标准算法,我希望我已经给了你一些理由来说服你尝试一下。现在我们将继续我的最后一个关于有效使用算法的建议。

避免容器拷贝

我们将通过突出一个常见问题来结束这一章,即尝试从算法库中组合多个算法时很难避免底层容器的不必要拷贝。

一个例子将澄清我的意思。假设我们有某种Student类来代表特定年份和特定考试分数的学生,就像这样:

struct Student {
  int year_{};
  int score_{};
  std::string name_{};
  // ...
}; 

如果我们想在一个庞大的学生集合中找到二年级成绩最高的学生,我们可能会在score_上使用max_element(),但由于我们只想考虑二年级的学生,这就变得棘手了。基本上,我们想要将copy_if()max_element()结合起来组成一个新的算法,但是在算法库中组合算法是不可能的。相反,我们需要将所有二年级学生复制到一个新的容器中,然后迭代新容器以找到最高分数:

auto get_max_score(const std::vector<Student>& students, int year) {
  auto by_year = = { return s.year_ == year; }; 
  // The student list needs to be copied in
  // order to filter on the year
  auto v = std::vector<Student>{};
  std::ranges::copy_if(students, std::back_inserter(v), by_year);
  auto it = std::ranges::max_element(v, std::less{}, &Student::score_);
  return it != v.end() ? it->score_ : 0; 
} 

这是一个诱人的地方,可以开始从头开始编写自定义算法,而不利用标准算法的优势。但正如您将在下一章中看到的,没有必要放弃标准库来执行这样的任务。组合算法的能力是使用 Ranges 库的主要动机之一,我们将在下一章中介绍。

总结

在本章中,您学习了如何使用算法库中的基本概念,以及使用它们作为构建模块而不是手写的for循环的优势,以及为什么在以后优化代码时使用标准算法库是有益的。我们还讨论了标准算法的保证和权衡,这意味着您从现在开始可以放心地使用它们。

通过使用算法的优势而不是手动的for循环,您的代码库已经为本书接下来的章节中将讨论的并行化技术做好了准备。标准算法缺少的一个关键特性是组合算法的可能性,这一点在我们试图避免不必要的容器复制时得到了强调。在下一章中,您将学习如何使用 C++ Ranges 库中的视图来克服标准算法的这一限制。