C---STL-数据结构与算法-全-

C++ STL 数据结构与算法(全)

原文:zh.annas-archive.org/md5/48ef5ca7ab8dc7936cd1713568fdacfb

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

欢迎阅读使用 C++ STL 的数据结构和算法,这是一本旨在加深你对使用 C++ std::vector提供的强大工具理解数据结构和算法、深入讨论 STL 算法及其与现代 C++特性的增强、以及深入探讨创建与 STL 兼容的类型和算法的资源的书籍。

本书分为五个部分,每部分都专注于 STL 的不同方面。第一部分精通 std::vector,详细介绍了向量的基本用法及其与 STL 算法的操纵。第二部分理解 STL 数据结构,通过序列、有序和无序关联容器以及容器适配器扩展了你的知识库。第三部分精通 STL 算法,全面覆盖了基本、数值和基于范围的算法,重点关注最佳实践。第四部分创建与 STL 兼容的类型和算法,指导你开发自己的类型和算法,这些类型和算法可以无缝地与 STL 集成。最后,第五部分STL 数据结构和算法:内部机制,提供了关于异常安全性、线程安全性、并发性以及 STL 与最新 C++特性(如概念和协程)交互的高级见解。

每一章的结构都是为了在先前介绍的概念基础上进行构建,确保学习体验的连贯性。到本书结束时,你应该能够熟练地应用 STL 的实践应用,有信心和专业知识应对现代软件挑战。让我们开始这段旅程,不仅为了理解 STL 的机制,还要欣赏其在构建卓越 C++软件中的优雅和强大。

本书面向对象

本书是为希望提高其将 STL 组件应用于解决复杂问题效率的技能和知识的中级 C++开发者而编写的。

本书涵盖内容

第一章std::vector 的基础知识,介绍了std::vector,将其与 C 风格数组进行比较,并演示了其声明、初始化和元素操作。

第二章使用 std::vector 精通迭代器,探讨了 STL 中不同类型的迭代器及其在std::vector环境中的应用,包括自定义迭代器的创建。

第三章使用 std::vector 精通内存和分配器,讨论了向量的容量与大小,内存优化技术以及为提高性能而设计的自定义分配器的设计。

第四章使用 std::vector 精通算法,深入探讨了向量上的算法操作,如排序和搜索,以及理解迭代器失效的重要性。

第五章, 为 std::vector 辩护, 检查了std::vector作为 STL 容器的首选性能方面、实际应用和多功能性。

第六章, 高级序列容器使用, 分析了序列容器如 std::arraystd::deque 等的高级使用场景和最佳实践。

第七章, 高级有序关联容器使用, 探讨了有序关联容器如 std::setstd::map 的复杂性及其独特的性能考虑。

第八章, 高级无序关联容器使用, 研究无序关联容器,突出其内部工作原理和用例。

第九章, 高级容器适配器使用, 专注于容器适配器如 std::stackstd::queue,讨论了它们的实现和何时有效地使用它们。

第十章, 高级容器视图使用, 介绍了容器视图如 std::spanstd::mdspan,提供了它们效用和性能优势的见解。

第十一章, 基本算法和搜索, 涵盖了 STL 中排序和搜索的基础算法及其实际应用。

第十二章, 操作和转换, 详细介绍了在 STL 容器内转换数据的技术,包括复制、移动和删除元素的细微差别。

第十三章, 数值和基于范围的运算, 探索了数值运算及其在范围上的应用,展示了它们如何优化算法的复杂性。

第十四章, 排列、划分和堆, 深入探讨了 STL 中的数据组织算法,如划分和堆操作。

第十五章, 使用范围的现代 STL, 讨论了使用范围的现代 STL 方法,增强了算法的可组合性和效率。

第十六章, 创建 STL 类型容器, 指导创建与 STL 算法兼容的自定义容器,并介绍实现完全集成所需的基本组件。

第十七章, 创建与 STL 兼容的算法, 详细介绍了与 STL 容器无缝工作并遵循 STL 原则的自定义算法的开发。

第十八章, 类型特性和策略, 深入探讨了类型特性和策略的高级主题,这对于编写可适应和高效的模板代码至关重要。

第十九章异常安全性,探讨了 STL 保证的异常安全性级别以及如何使用 noexcept 编写健壮的 STL 兼容代码。

第二十章STL 的线程安全和并发,讨论了 STL 容器的并发和线程安全特性、防止竞态条件以及多线程编程的最佳实践。

第二十一章STL 与概念和协程的交互,探讨了 STL 与最新 C++特性(如概念和协程)之间的交互,展示了它们的协同作用。

第二十二章STL 的并行算法,介绍了 STL 中并行算法的执行策略、constexpr 的作用以及性能和效率的考虑因素。

要充分利用本书

在开始本书之前,读者应该对基本的 C++编程概念有牢固的掌握,例如语法、控制结构以及基本的面向对象原则。对指针、内存管理和模板基础的理解也是假设的,因为这些是有效利用 STL 的基础。本书假设读者熟悉 C++11 标准特性,因为许多示例和解释都依赖于这个语言版本或更新的版本。此外,对数组、链表等数据结构以及经典算法的基本了解将有助于理解本书中讨论的高级主题。

本书涵盖的软件/硬件 操作系统要求
C++ Windows、macOS 或 Linux
C++ STL

如果您正在使用本书的数字版,我们建议您亲自输入代码或从本书的 GitHub 仓库(下一节中有一个链接)获取代码。这样做将帮助您避免与代码复制和粘贴相关的任何潜在错误。

下载示例代码文件

您可以从 GitHub 下载本书的示例代码文件。如果代码有更新,它将在 GitHub 仓库中更新。

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

使用的约定

本书使用了多种文本约定。

文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 账号。以下是一个示例:“这就是 std::vector 的精华所在;过度分配减少了频繁和可能计算成本高昂的重新分配的需求。”

代码块设置如下:

template <typename T,
          typename AllocatorPolicy = std::allocator<T>>
class CustomVector {
  // Implementation using AllocatorPolicy for memory
  // allocation
};

任何命令行输入或输出都应如下编写:

Time without reserve: 0.01195 seconds
Time with reserve:    0.003685 seconds

小贴士或重要提示

看起来是这样的。

联系我们

我们始终欢迎读者的反馈。

一般反馈:如果您对此书的任何方面有疑问,请通过电子邮件发送给我们 customercare@packtpub.com,并在邮件主题中提及书名。

勘误:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在此书中发现错误,我们将不胜感激,如果您能向我们报告,我们将不胜感激。请访问 www.packtpub.com/support/errata 并填写表格。

盗版:如果您在互联网上以任何形式发现我们作品的非法副本,我们将不胜感激,如果您能提供位置地址或网站名称,我们将不胜感激。请通过电子邮件发送链接到 copyright@packt.com 与我们联系。

如果您有兴趣成为作者:如果您在某个主题上具有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问 authors.packtpub.com

分享您的想法

一旦您阅读了《使用 C++ STL 的数据结构和算法》,我们非常乐意听到您的想法!请点击此处直接进入此书的亚马逊评论页面并分享您的反馈。

您的评论对我们和科技社区非常重要,并将帮助我们确保我们提供高质量的内容。

下载此书的免费 PDF 副本

感谢您购买此书!

您喜欢在路上阅读,但无法携带您的印刷书籍到处走?

您的电子书购买是否与您选择的设备不兼容?

不要担心,现在,随着每本 Packt 书籍,您都可以免费获得该书的 DRM 免费 PDF 版本。

在任何地方、任何设备上阅读。直接从您最喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。

优惠不会就此结束,您还可以获得独家折扣、时事通讯和每日免费内容的每日电子邮件。

按照以下简单步骤获取优惠:

  1. 扫描下面的二维码或访问以下链接

二维码

packt.link/free-ebook/978-1-83546-855-5

  1. 提交您的购买证明

  2. 就这些!我们将直接将您的免费 PDF 和其他优惠发送到您的电子邮件地址。

第一部分:精通 std::vector

在本部分中,我们将构建我们对 C++ std::vector的知识。我们将从介绍std::vector开始,将其与传统 C 风格数组进行对比,并涵盖初始化、访问和修改元素等基本操作。然后,我们将深入到迭代器的复杂性,揭示它们在std::vector操作中的类型和用途,以及基于范围的 for 循环的优雅性。通过构建对优化资源分配和释放的理解,我们解开了内存管理的神秘面纱,包括创建自定义分配器的介绍。然后,本节将构建到将算法应用于排序、搜索和高效操作向量内容,强调自定义比较器的作用以及理解迭代器失效的重要性。最后一章总结了std::vector的性能考虑和实际应用,巩固了其在 C++开发者默认容器选择中的地位。

本部分包含以下章节:

  • 第一章**:std::vector 的基础知识

  • 第二章**:掌握使用 std::vector 的迭代器

  • 第三章**:掌握使用 std::vector 的内存和分配器

  • 第四章**:掌握使用 std::vector 的算法

  • 第五章**:为 std::vector 辩护

第一章:std::vector 的基本原理

std::vector是 C++编程的一个基本组成部分。本章将探讨std::vector作为一个动态数组,讨论其在各种编程环境中的实用性。到本章结束时,你应该能够熟练地声明、初始化和操作向量。这些技能将使你能够在各种应用中有效地使用std::vector。它将为理解更广泛的数据结构和算法的标准模板库STL)打下坚实的基础。

在本章中,我们将涵盖以下主要内容:

  • std::vector的重要性

  • 声明和初始化std::vector

  • 访问元素

  • 添加和删除元素

技术要求

本章中的代码可以在 GitHub 上找到:

github.com/PacktPublishing/Data-Structures-and-Algorithms-with-the-CPP-STL

std::vector的重要性

在 C++中,std::vector是一个常用的数据结构。虽然初学者可能会将其与 C 中的基本数组看到相似之处,但随着更深入的探索,std::vector的优势变得明显。此外,对std::vector的牢固掌握有助于更顺利地过渡到理解 STL 的其他组件。

向量和数组都作为元素集合的容器。它们之间的关键区别在于它们的灵活性和功能。数组在大小上是静态的,在声明时设置,之后不能更改。

相比之下,向量是动态的。它们可以根据对它们的操作进行扩展或收缩。与在声明时承诺一个固定内存块的数组不同,向量动态管理内存。它们经常分配额外的内存来预测未来的增长,优化效率和灵活性。虽然数组提供简单的基于索引的元素访问和修改,但向量提供更广泛的功能,包括插入、删除和定位元素的方法。

std::vector的主要优势是其动态调整大小和优化性能的结合。传统的 C++数组在编译时设置其大小。如果一个数组被声明为包含 10 个元素,它将受到该容量的限制。然而,在许多实际场景中,数据的量直到运行时才能确定。这正是std::vector大放异彩的地方。

C 风格数组和 std::vector 的基本比较

作为动态数组,std::vector可以在程序执行期间调整其大小。它高效地管理其内存,不是为每个新添加的元素重新分配,而是在较大的块中重新分配,以保持性能和适应性的平衡。因此,std::vector动态地响应不断变化的数据需求。

这里有两个代码示例,展示了使用 C 风格数组和 std::vector 之间的对比。

以下代码演示了 C 风格数组的使用:

#include <iostream>
int main() {
  int *cArray = new int[5];
  for (int i = 0; i < 5; ++i) { cArray[i] = i + 1; }
  for (int i = 0; i < 5; ++i) {
    std::cout << cArray[i] << " ";
  }
  std::cout << "\n";
  const int newSize = 7;
  int *newCArray = new int[newSize];
  for (int i = 0; i < 5; ++i) { newCArray[i] = cArray[i]; }
  delete[] cArray;
  cArray = newCArray;
  for (int i = 0; i < newSize; ++i) {
    std::cout << cArray[i] << " ";
  }
  std::cout << "\n";
  int arraySize = newSize;
  std::cout << "Size of cArray: " << arraySize << "\n";
  delete[] cArray;
  return 0;
}

这里是示例输出:

1 2 3 4 5
1 2 3 4 5 0 0
Size of cArray: 7

在这个例子中,我们执行以下操作:

  1. 声明一个大小为 5 的 C 风格动态数组。

  2. 初始化动态数组。

  3. 打印数组的内容。

  4. 将数组调整到新大小(例如,7)。

  5. 将旧数组中的元素复制到新数组中。

  6. 释放旧数组。

  7. 更新指针到新数组。

  8. 打印调整大小后的数组的内容。

  9. 获取调整大小后数组的大小。

  10. 完成后,释放调整大小后的数组。

相比之下,以下代码演示了 std::vector 的使用:

#include <iostream>
#include <vector>
int main() {
  std::vector<int> stlVector = {1, 2, 3, 4, 5};
  for (const int val : stlVector) {
    std::cout << val << " ";
  }
  std::cout << "\n";
  stlVector.resize(7);
  for (const int val : stlVector) {
    std::cout << val << " ";
  }
  std::cout << "\n";
  std::cout << "Size of stlVector: " << stlVector.size()
            << "\n";
  return 0;
}

这里是示例输出:

1 2 3 4 5
1 2 3 4 5 0 0
Size of stlVector: 7

与 C 风格版本进行对比,在这个例子中,我们执行以下操作:

  1. 声明具有初始值的 std::vector

  2. 打印向量的内容。

  3. 调整大小。使用 std::vector 进行此操作很容易。

  4. 再次打印以查看变化。

  5. 获取大小。这个操作通过 size() 成员函数很简单。

在初始示例中,C 风格数组受其固定大小的限制。修改其大小通常需要非平凡的程序。相反,std::vector 可以轻松调整其大小,并提供一个 size() 方法来确定它包含的元素数量。

除了其动态调整大小的能力之外,std::vector 与传统数组相比,进一步简化了内存管理。使用 std::vector,不需要显式进行内存分配或释放,因为它内部处理这些任务。这种方法最小化了内存泄漏的风险,并简化了开发过程。因此,许多 C++ 开发者,无论经验水平如何,都更喜欢使用 std::vector 而不是原始数组,以方便和安全。

让我们看看一个示例,对比传统的 C 风格数组如何管理内存,以及 std::vector 如何使这一过程更简单、更安全。

C 风格数组和 std::vector 在内存管理方面的比较

首先,让我们考虑一个具有手动内存管理的 C 风格数组的例子。

在这个例子中,我们将使用动态内存分配(newdelete)来模拟 std::vector 的一些调整大小功能:

#include <iostream>
int main() {
  int *cArray = new int[5];
  for (int i = 0; i < 5; ++i) { cArray[i] = i + 1; }
  int *temp = new int[10];
  for (int i = 0; i < 5; ++i) { temp[i] = cArray[i]; }
  delete[] cArray; // Important: free the old memory
  cArray = temp;
  for (int i = 5; i < 10; ++i) { cArray[i] = i + 1; }
  for (int i = 0; i < 10; ++i) {
    std::cout << cArray[i] << " ";
  }
  std::cout << "\n";
  delete[] cArray;
  return 0;
}

这里是示例输出:

1 2 3 4 5 6 7 8 9 10

在这个例子中,我们执行以下操作:

  1. 动态分配一个大小为 5 的 C 风格数组。

  2. 填充数组。

  3. 模拟调整大小:分配一个更大的数组并复制数据。

  4. 填充新数组的其余部分。

  5. 打印数组的内容。

  6. 清理分配的内存。

现在,让我们考虑一个具有内置内存管理的 std::vector 的例子。

使用 std::vector,您不需要手动分配或释放内存;它由内部管理:

#include <iostream>
#include <vector>
int main() {
  std::vector<int> myVector(5);
  for (int i = 0; i < 5; ++i) { myVector[i] = i + 1; }
  for (int i = 5; i < 10; ++i) {
    myVector.push_back(i + 1);
  }
  for (int val : myVector) { std::cout << val << " "; }
  std::cout << "\n";
  return 0;
}

这里是示例输出:

1 2 3 4 5 6 7 8 9 10

这个例子中的步骤包括以下内容:

  1. 创建一个大小为 5std::vector

  2. 填充向量。

  3. 使用 push_back()resize() 进行调整大小非常直接。

  4. 打印向量的内容。

    没有必要进行显式的内存释放。

在第一个例子中,手动内存管理的挑战显而易见。未能适当地使用 delete 可能会导致内存泄漏。另一方面,第二个例子突出了 std::vector 的效率,它内部管理内存,消除了手动调整大小和内存操作的需求,并提高了开发过程。

传统数组提供了一套基本操作。相比之下,std::vector 提供了各种成员函数,这些函数提供了高级的数据操作和检索能力。这些函数将在后续章节中探讨。

在 C++ 开发中,std::vector 是一个基本工具。其灵活性使其成为从游戏开发到复杂软件项目等各种应用的优选选择。内置的防止常见内存问题的安全机制凸显了其价值。作为一个 STL 组件,std::vector 通过与其他 STL 元素的良好集成,鼓励一致的、最优的编码实践。

本节探讨了 C 风格数组和 std::vector 之间的基本区别。与静态的 C 风格数组不同,我们了解到 std::vector 提供了动态调整大小和强大的内存管理,这对于开发灵活和高效的应用程序至关重要。比较详细地说明了 std::vector 如何抽象出低级内存处理,从而最小化与手动内存管理相关的常见错误。

理解 std::vector 是有益的,因为它是在 C++ 编程中最广泛使用的序列容器之一。std::vector 支持在连续分配的内存中动态增长,支持随机访问迭代,并且与 STL 中的多种算法兼容。我们还讨论了 std::vector 如何提供更安全、更直观的接口来管理对象集合。

以下章节将在此基础上构建。我们将学习声明 std::vector 的语法以及初始化它的各种方法。这包括对默认、复制和移动语义的考察,这些语义与向量相关。

声明和初始化 std::vector

在 C++ 开发中建立了 std::vector 的基础知识后,是时候深入探讨其实际应用了——具体来说,是如何声明和初始化向量的。

std::vector 的本质在于其动态性。与固定大小的传统数组不同,向量可以根据需要增长或缩小,这使得它们成为开发者手中多才多艺的工具。

声明一个向量

std::vector的性能源于其设计,它结合了连续内存布局(如数组)的优点和动态调整大小的灵活性。当以指定的大小初始化时,它会预留足够的内存来存储这些元素。但如果向量填满并且需要更多容量,它会分配一个更大的内存块,转移现有元素,并释放旧内存。这个动态调整大小的过程被优化以减少开销,确保向量保持高效。连续存储和自动内存管理的结合使std::vector成为 C++生态系统中的基本组件。

要声明一个基本的std::vector,请使用以下:

std::vector<int> vec;

这行代码初始化了一个名为vec的空std::vector,专门设计用来存储int类型的值。(intstd::vector类型模板参数<>内的内容。)std::vector是一个动态数组,这意味着尽管vec的初始大小为0,但其容量可以根据需要增长。当你向vec中插入整数时,容器将自动分配内存以适应元素数量的增加。这种动态调整大小使得std::vector成为 C++中一种多用途且广泛使用的容器,适用于元素数量事先未知或可能随时间变化的情况。

当创建std::vector时,可以指定其初始大小。如果你事先知道需要存储的元素数量,这可能会很有益:

std::vector<int> vec(10);

在前面的代码中,名为vecstd::vector被初始化为有 10 个整数的空间。默认情况下,这些整数将被值初始化,这意味着对于int这样的基本数据类型,它们将被设置为0

如果你希望使用特定的值初始化元素,可以在构造向量时提供第二个参数:

std::vector<int> vec(10, 5);

在这里,使用 10 个整数声明了std::vector,并且这 10 个整数都被初始化为5的值。这种方法确保了在单步中高效地分配内存和初始化所需值。

初始化向量

在 C++11 及以后的版本中,随着初始化列表的引入,std::vector的初始化变得更加简单。这允许开发者在花括号内直接指定向量的初始值:

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

前面的语句创建了一个名为vecstd::vector实例,并使用五个整数对其进行初始化。这种方法提供了一种简洁的方式来声明和填充向量,这是初始化std::vector的一种方法。根据你的需求,还有许多其他方法可以实现这一点:

// Method 1: Declare a vector and then add elements using
// push_back (Add integers from 0 to 4)
std::vector<int> vec1;
for (int i = 0; i < 5; ++i) { vec1.push_back(i); }
// Method 2: Initialize a vector with a specific size and
// default value (5 elements with the value 10)
std::vector<int> vec2(5, 10);
// Method 3: List initialization with braced initializers
// Initialize with a list of integers
std::vector<int> vec3 = {1, 2, 3, 4, 5};
// Method 4: Initialize a vector using the fill
// constructor Default-initializes the five elements (with
// zeros)
std::vector<int> vec4(5);
// Method 5: Using std::generate with a lambda function
std::vector<int> vec5(5);
int value = 0;
std::generate(vec5.begin(), vec5.end(),
              [&value]() { return value++; });

std::vector是一个多功能的模板容器,能够存储各种数据类型,而不仅仅是像int这样的原始类型。你可以存储自定义类的对象、其他标准库类型和指针。这种适应性使得std::vector适用于广泛的用途和场景。

此外,向量提供了一种将一个向量的内容复制到另一个向量的简单机制。这被称为 复制初始化。以下代码演示了这一点:

std::vector<int> vec1 = {1, 2, 3, 4, 5};
std::vector<int> vec2(vec1);

在这个例子中,vec2 被初始化为 vec1 的精确副本,这意味着 vec2 将包含与 vec1 相同的元素。这种复制初始化确保原始向量(vec1)保持不变,并且新向量(vec2)提供了数据的单独副本。

STL 容器的真正优势之一是它们能够无缝地处理用户定义的类型,而不仅仅是像 intdouble 这样的原始数据类型。这种灵活性是对其模板设计的证明,它允许它适应各种数据类型同时保持类型安全。在接下来的示例中,我们通过使用自定义类来展示这种多功能性:

#include <iostream>
#include <string>
#include <vector>
class Person {
public:
  Person() = default;
  Person(std::string_view n, int a) : name(n), age(a) {}
  void display() const {
    std::cout << "Name: " << name << ", Age: " << age
              << "\n";
  }
private:
  std::string name;
  int age{0};
};
int main() {
  std::vector<Person> people;
  people.push_back(Person("Lisa", 30));
  people.push_back(Person("Corbin", 25));
  people.resize(3);
  people[2] = Person("Aaron", 28);
  for (const auto &person : people) { person.display(); }
  return 0;
}

这里是示例输出:

Name: Lisa, Age: 30
Name: Corbin, Age: 25
Name: Aaron, Age: 28

在这个例子中,首先使用 std::vector 来管理自定义 Person 类的对象。它展示了 std::vector 如何轻松地容纳和管理内置类型和用户定义类型的内存。

在 C++ 中,尽管静态数组有其用途,但它们具有固定的大小,有时可能受到限制。另一方面,std::vector 提供了一种动态和灵活的替代方案。

理解向量的声明和初始化对于有效的 C++ 编程至关重要。std::vector 是一种多用途的工具,适用于从实现复杂算法到开发大型应用程序的各种任务。将 std::vector 纳入编程实践可以提高代码的效率和可维护性。

在本节中,我们涵盖了与 std::vector 一起工作的语法方面。具体来说,我们深入探讨了声明不同类型 std::vector 的正确技术以及初始化这些向量以适应不同编程场景的多种策略。

我们了解到声明 std::vector 需要指定它将包含的元素类型,以及可选的初始大小和元素的默认值。我们发现了多种初始化方法,包括直接列表初始化和使用特定值范围的初始化。本节强调了 std::vector 的灵活性,展示了它如何从预定义的元素集开始或从现有集合中构建。

这些信息对于实际的 C++ 开发至关重要,因为它为有效地使用 std::vector 提供了基础。适当的初始化可以导致性能优化并确保向量处于适合其预期用途的有效状态。能够简洁且正确地声明和初始化向量是利用 STL 在实际 C++ 应用程序中发挥其强大功能的基础技能。

在下一节“访问元素”中,我们将关注允许我们检索和修改std::vector内容的操作。我们将学习随机访问,它允许高效地检索和修改向量中任何位置的元素。此外,我们还将探讨如何访问第一个和最后一个元素,以及理解和管理向量大小的重要性,以确保代码健壮且无错误。

访问元素

在讨论了std::vector的声明和初始化之后,我们的重点现在转向访问和操作包含的数据。C++中的多个方法允许您以速度和安全的方式访问向量元素。

随机访问

下标[]操作符允许通过索引直接访问元素,类似于数组。在以下示例中,给定一个向量,表达式numbers[1]返回值20。然而,使用此操作符不涉及边界检查。超出范围的索引,如numbers[10],会导致未定义的行为,从而导致不可预测的结果。

这在以下示例中显示:

#include <iostream>
#include <vector>
int main() {
  std::vector<int> numbers = {10, 20, 30, 40, 50};
  const auto secondElement = numbers[1];
  std::cout << "The second element is: " << secondElement
            << "\n";
  // Beware: The following line can cause undefined
  // behavior!
  const auto outOfBoundsElement = numbers[10];
  std::cout << "Accessing an out-of-bounds index: "
            << outOfBoundsElement << "\n";
  return 0;
}

这里是示例输出:

The second element is: 20
Accessing an out-of-bounds index: 0

为了更安全地基于索引访问,std::vector提供了at()成员函数。它执行索引边界检查,并在无效索引上抛出out_of_range异常。

这里是此示例的一个例子:

#include <iostream>
#include <vector>
int main() {
  std::vector<int> numbers = {10, 20, 30, 40, 50};
  try {
    const auto secondElement = numbers.at(1);
    std::cout << "The second element is: " << secondElement
              << "\n";
  } catch (const std::out_of_range &e) {
    std::cerr << "Error: " << e.what() << "\n";
  }
  try {
    const auto outOfBoundsElement = numbers.at(10);
    std::cout << "Accessing an out-of-bounds index: "
              << outOfBoundsElement << "\n";
  } catch (const std::out_of_range &e) {
    std::cerr << "Error: " << e.what() << "\n";
  }
  return 0;
}

访问向量元素时,谨慎至关重要。虽然 C++优先考虑性能,但它经常绕过安全检查,例如下标操作符所示。因此,开发者必须通过仔细的索引管理或采用更安全的方法(如at())来确保有效的访问。

访问第一个和最后一个元素

可以使用front()back()分别访问第一个和最后一个元素。

这在以下示例中显示:

#include <iostream>
#include <vector>
int main() {
  std::vector<int> numbers = {10, 20, 30, 40, 50};
  const auto firstElement = numbers.front();
  std::cout << "The first element is: " << firstElement
            << "\n";
  const auto lastElement = numbers.back();
  std::cout << "The last element is: " << lastElement
            << "\n";
  return 0;
}

示例输出如下:

The first element is: 10
The last element is: 50

向量大小

使用std::vector时,理解其结构和包含的数据量是至关重要的。size()成员函数提供了向量中当前存储的元素数量。在std::vector实例上调用此函数将返回它持有的元素数量。这个计数代表活动元素,可以用来确定有效索引的范围。返回值是size_t类型,这是一种无符号整数类型,适合表示大小和计数。当遍历向量、执行大小比较或根据向量元素数量分配空间时,这很有用。

让我们看看以下代码:

#include <iostream>
#include <vector>
int main() {
  std::vector<int> data = {1, 2, 3, 4, 5};
  const auto elementCount = data.size();
  std::cout << "Vector contains " << elementCount
            << " elements.\n";
  return 0;
}

在前面的代码中,对数据向量调用size()函数以检索和显示它包含的元素数量。结果正如预期的那样,表明向量中有五个元素。

总结来说,std::vector提供了一套工具,从高效的索引操作符到更安全的at()方法,再到方便的front()back()方法。理解这些工具对于有效地和安全地访问和操作向量中的数据至关重要。

在本节中,我们专注于检索和检查std::vector内容的方法。我们了解了std::vector提供随机访问其元素的能力,这使得我们可以使用其索引以常数时间复杂度直接访问任何元素。本节还详细介绍了通过front()back()成员函数分别访问向量第一个和最后一个元素的方法。

此外,我们讨论了理解并利用size()成员函数的重要性,以确定当前存储在std::vector中的元素数量。这种理解对于确保我们的访问模式保持在向量的界限内至关重要,从而防止越界错误和未定义的行为。

从本节中获得的能力是至关重要的,因为它们是交互std::vector内容的基础。这些访问模式是使用向量在 C++应用程序中有效使用的关键,无论是读取还是修改元素。直接访问向量中的元素可以导致高效的算法并支持广泛的日常编程任务。

以下部分将通过解决如何修改std::vector的大小和内容来进一步扩展我们的知识。我们将探讨如何向向量中添加元素以及删除它们的各种方法。这包括理解向量如何管理其容量及其对性能的影响。我们将学习为什么以及如何使用.empty()作为检查大小是否为0的更高效替代方案,并且我们将深入了解如何从向量中清除所有元素。

添加和删除元素

与传统的数组相比,std::vector的一个优点是它能够动态地调整大小。随着应用程序的发展,数据需求也在变化;静态数据结构不再适用。在本节中,我们将探索使用std::vector进行动态数据管理,学习无缝地向向量中添加和删除元素,同时确保我们的操作是安全的。

添加元素

让我们从添加元素开始。push_back()成员函数可能是向向量末尾添加元素最直接的方法。假设你有std::vector<int> scores;并希望添加一个新的分数,比如95。你只需调用scores.push_back(95);,哇,你的分数就添加成功了。

下面是一个简单的示例代码:

#include <iostream>
#include <vector>
int main() {
  std::vector<int> scores;
  std::cout << "Initial size of scores: " << scores.size()
            << "\n";
  scores.push_back(95);
  std::cout << "Size after adding one score:"
            << scores.size() << "\n";
  std::cout << "Recently added score: " << scores[0]
            << "\n";
  return 0;
}

当运行此程序时,它将显示在添加分数之前和之后的向量大小以及分数本身,从而演示了push_back()函数的实际应用。

如果您需要在特定位置插入一个分数,而不仅仅是末尾,怎么办?insert()函数将成为您的最佳助手。如果您想在第三位置插入分数85,您将使用迭代器来指定位置:

scores.insert(scores.begin() + 2, 85);

记住,向量索引从0开始;+ 2是为了第三位。

让我们通过在以下代码中结合使用insert()函数来扩展前面的例子:

#include <iostream>
#include <vector>
int main() {
  std::vector<int> scores = {90, 92, 97};
  std::cout << "Initial scores: ";
  for (int score : scores) { std::cout << " " << score; }
  std::cout << "\n";
  scores.push_back(95);
  std::cout << "Scores after adding 95 to the end: ";
  for (int score : scores) { std::cout << " " << score; }
  std::cout << "\n";
  scores.insert(scores.begin() + 2, 85);
  std::cout << "Scores after inserting 85 at the third "
               "position:";
  for (int score : scores) { std::cout << " " << score; }
  std::cout << "\n";
  return 0;
}

这个程序将展示原始分数,显示在末尾追加一个分数后的分数,以及在第三位置插入一个分数。它展示了push_back()insert()函数的作用。

向量并没有停止在这里。emplace_back()emplace()函数允许在向量内部直接构造元素。这意味着更少的临时对象,并且可能提高性能,尤其是在复杂的数据类型中。

让我们考虑一个具有几个数据成员的Person类。要创建一个新的Person对象,会执行字符串连接操作。使用emplace_back()emplace()将避免push_back()可能引起的额外临时对象和复制/移动操作,从而提高性能。以下代码演示了这一点:

#include <iostream>
#include <string>
#include <vector>
class Person {
public:
  Person(const std::string &firstName,
         const std::string &lastName)
      : fullName(firstName + " " + lastName) {}
  const std::string &getName() const { return fullName; }
private:
  std::string fullName;
};
int main() {
  std::vector<Person> people;
  people.emplace_back("John", "Doe");
  people.emplace(people.begin(), "Jane", "Doe");
  for (const auto &person : people) {
    std::cout << person.getName() << "\n";
  }
  return 0;
}

这个例子说明了emplace_back()emplace()如何允许在向量内部直接构造对象。使用push_back()可能会创建临时的Person对象。使用emplace_back()直接在原地构造对象,可能避免临时对象的创建。使用insert()可能会创建临时的Person对象。使用emplace()直接在指定位置构造对象。这对于像Person这样的类型尤其有益,其构造函数可能涉及资源密集型操作(如字符串连接)。在这种情况下,emplace方法相对于它们的push对应方法的性能优势变得明显。

删除元素

但生活不仅仅是加法。有时,我们需要删除数据。pop_back()函数从向量中删除最后一个元素,将其大小减少一个。然而,如果您想从特定位置或一系列位置删除,erase()函数将是您的首选。

erase-remove 惯用法

在 C++及其 STL 中,有经验开发者经常使用的编码模式。一个值得注意的模式是erase-remove惯用法,它根据定义的准则从容器中删除特定元素。本节将详细说明这个惯用法的功能,并讨论 C++20 中引入的新替代方案。

STL 容器,特别是std::vector,没有提供一种直接根据谓词删除元素的方法。相反,它们提供了单独的方法:一个用于重新排列元素(使用std::removestd::remove_if),另一个用于删除它们。

下面是如何实现 erase-remove 惯用法的:

  1. 使用std::removestd::remove_if对容器的元素进行重新排序。需要移除的元素被移动到末尾。

  2. 这些算法返回一个指向被移除元素起始位置的迭代器。

  3. 然后使用容器的erase方法从容器中物理移除元素。

一个经典的例子是从std::vector<int>中移除所有0的实例:

std::vector<int> numbers = {1, 0, 3, 0, 5};
auto end = std::remove(numbers.begin(), numbers.end(), 0);
numbers.erase(end, numbers.end());

使用 std::erase 和 std::erase_if 进行现代化

认识到 erase-remove 惯用语的普遍性和某种程度上反直觉的特性,C++20 引入了直接实用函数来简化此操作:std::erasestd::erase_if。这些函数将两步过程合并为一步,提供了一种更直观且更不易出错的解决方案。

使用之前的例子,在 C++20 中移除所有0的实例变得如下:

std::vector<int> numbers = {1, 0, 3, 0, 5};
std::erase(numbers, 0);

现在不再需要调用单独的算法并记住处理过程的两个阶段。同样,为了根据谓词移除元素,您将执行以下操作:

std::vector<int> numbers = {1, 2, 3, 4, 5};
std::erase_if(numbers, [](int x){ return x % 2 == 0; });

虽然 erase-remove 惯用语多年来一直是基于 STL 的 C++编程的基石,但现代 C++仍在不断发展和简化常见模式。有了std::erasestd::erase_if,开发者现在有更直接的工具来移除容器元素,从而产生更干净、更易读的代码。这是 C++社区持续致力于增强语言的用户友好性、同时保留其强大和表达力的承诺的证明。

注意,std::vector已被巧妙地设计以优化内存操作。虽然人们可能会直观地期望在添加或移除元素时,底层数组会进行大小调整,但这并不是事实。相反,当向量增长时,它通常会分配比立即需要的更多内存,以预测未来的添加。这种策略最小化了频繁的内存重新分配,这可能非常昂贵。相反,当移除元素时,向量并不总是立即缩小其分配的内存。这种行为在内存使用和性能之间提供了平衡。然而,值得注意的是,这些内存管理决策的具体细节可能因 C++库实现而异。因此,虽然接口方面的行为在实现之间是一致的,但内部内存管理的细微差别可能不同。

容量

您可以使用capacity()成员函数来了解已分配了多少内存。std::vector::capacity()成员函数返回为向量分配的内存量,这可能会大于其实际大小。此值表示向量在重新分配内存之前可以容纳的最大元素数,确保在无需频繁内存操作的情况下高效增长。

这可以在以下内容中看到:

#include <iostream>
#include <vector>
int main() {
  std::vector<int> numbers;
  std::cout << "Initial size: " << numbers.size() << "\n";
  std::cout << "Initial capacity: " << numbers.capacity()
            << "\n";
  for (auto i = 1; i <= 10; ++i) { numbers.push_back(i); }
  std::cout << "Size after adding 10 elements: "
            << numbers.size() << "\n";
  std::cout << "Capacity after adding 10 elements: "
            << numbers.capacity() << "\n";
  for (auto i = 11; i <= 20; ++i) { numbers.push_back(i); }
  std::cout << "Size after adding 20 elements: "
            << numbers.size() << "\n";
  std::cout << "Capacity after adding 20 elements: "
            << numbers.capacity() << "\n";
  for (auto i = 0; i < 5; ++i) { numbers.pop_back(); }
  std::cout << "Size after removing 5 elements: "
            << numbers.size() << "\n";
  std::cout << "Capacity after removing 5 elements: "
            << numbers.capacity() << "\n";
  return 0;
}

具体的输出可能因编译器而异,但以下是一个示例输出:

Initial size: 0
Initial capacity: 0
Size after adding 10 elements: 10
Capacity after adding 10 elements: 16
Size after adding 20 elements: 20
Capacity after adding 20 elements: 32
Size after removing 5 elements: 15
Capacity after removing 5 elements: 32

这个例子说明了随着元素的添加和删除,std::vector 实例的大小和容量如何变化。检查输出显示,容量通常并不直接与大小相对应,突出了内存优化技术。

当可能时,优先使用 empty()

在 C++中,当主要目的是检查容器是否为空时,建议使用 .empty() 成员函数而不是将 .size().capacity()0 进行比较。.empty() 函数提供了一种直接确定容器是否有元素的方法,并且在许多实现中,它可以提供性能优势。具体来说,.empty() 通常具有常数时间复杂度 O(1),而 .size() 对于某些容器类型可能具有线性时间复杂度 O(n),这使得 .empty() 对于简单的空检查来说是一个更有效的选择。使用 .empty() 可以使代码更加简洁,并可能更快,尤其是在性能关键部分。

清除所有元素

std::vectorclear() 函数是一个强大的实用工具,可以迅速删除容器内的所有元素。调用此函数后,向量的 size() 将返回 0,表示其现在为空状态。然而,需要注意的一个关键方面是,任何之前指向向量内元素的引用、指针或迭代器都将因这次操作而失效。这也适用于任何超出范围的迭代器。有趣的是,尽管 clear() 清除了所有元素,但它并不改变向量的容量。这意味着为向量分配的内存保持不变,允许在无需立即重新分配的情况下进行高效的后续插入。

std::vectorclear() 成员函数从向量中删除所有元素,有效地将其大小减少到 0。以下是一个简单的示例,演示其用法:

#include <iostream>
#include <vector>
int main() {
  std::vector<int> numbers = {1, 2, 3, 4, 5};
  std::cout << "Original numbers: ";
  for (const auto num : numbers) {
    std::cout << num << " ";
  }
  std::cout << "\n";
  numbers.clear();
  std::cout << "After using clear(): ";
  // This loop will produce no output.
  for (const auto num : numbers) {
    std::cout << num << " ";
  }
  std::cout << "\n";
  std::cout << "Size of vector after clear(): "
            << numbers.size() << "\n";
  return 0;
}

这里是示例输出:

Original numbers: 1 2 3 4 5
After using clear():
Size of vector after clear(): 0

这个例子强调了 std::vector 在使用单个函数调用处理大量删除时的效率,使数据管理更加直接。

动态调整大小是 std::vector 的一个显著特性,但它需要谨慎管理以保持效率。当向量的内容超过其容量时,需要进行内存重新分配,这涉及到分配一个新的内存块、复制现有元素和释放旧内存。这个过程可能会引入性能开销,尤其是在向量通过小量重复增长时。如果你可以预测最大大小,可以使用 reserve() 函数预先分配内存以减轻这种低效。

例如,调用 scores.reserve(100); 为 100 个元素分配内存,减少到该限制之前的频繁重新分配需求。

std::vector提供了一套针对动态数据管理的综合函数。它使得快速添加元素、在中间插入元素或从各种位置删除元素变得容易。结合其高效的内存管理,std::vector作为一个灵活且注重性能的容器脱颖而出。随着你对 C++的深入了解,std::vector的实用性将越来越明显,因为它有效地解决了广泛的编程场景。

在本节中,我们探讨了std::vector的动态特性,这使得我们可以修改其内容和大小。我们学习了如何使用push_backemplace_back等方法向向量中添加元素,以及如何使用迭代器在特定位置插入元素。我们还考察了删除元素的过程,无论是特定位置的单一元素、一系列元素,还是按值删除元素。

我们讨论了容量概念,即向量中元素预分配的空间量,以及它与大小(即向量中当前实际元素的数量)的区别。理解这一区别对于编写内存和性能高效的程序至关重要。

我们还强调了使用empty()作为检查向量是否包含任何元素的推荐方法。我们讨论了empty()相对于检查size()是否返回0的优势,特别是在清晰度和潜在的性能优势方面。

此外,我们还介绍了clear()函数的重要性,该函数从向量中删除所有元素,有效地将其大小重置为0,而无需改变其容量。

本节的信息非常实用,因为它使我们能够积极且高效地管理std::vector的内容。了解添加和删除元素对于实现需要动态数据操作的算法至关重要,这在软件开发中是一个常见的场景。

摘要

在本章中,你学习了 C++ STL 中std::vector的基础知识。本章首先解释了std::vector的重要性,强调了它相对于 C 风格数组的优势,尤其是在内存管理和易用性方面。本章详细比较了 C 风格数组和std::vector,展示了std::vector如何促进动态大小调整和更安全的内存操作。

接下来,你被引导了解了声明和初始化向量的过程。你学习了如何声明std::vector以及使用不同方法初始化这些实例。然后,章节探讨了访问std::vector中元素的各种方法,从随机访问到访问第一个和最后一个元素,并强调了理解向量大小的重要性。

此外,本章深入探讨了添加和删除元素的内情。本节阐明了修改向量内容时的最佳实践,包括何时使用 empty() 而不是检查大小为 0,以及理解向量容量的重要性。

本章所提供的信息极为宝贵,因为它构建了在多种编程场景中有效利用 std::vector(以及许多其他 STL 数据类型)所需的基础知识。掌握 std::vector 允许编写更高效、更易于维护的代码,使 C++ 开发者能够充分利用 STL 在动态数组操作方面的全部潜力。

在下一章中,你将通过学习迭代器来提高你对 std::vector 的理解,迭代器是导航 STL 容器中元素的关键。

第二章:掌握 std::vector 中的迭代器

在本章中,我们将更深入地探索 std::vector,重点关注迭代的复杂性。本章将使我们掌握处理向量遍历的方方面面。掌握这些核心领域可以增强 C++ 代码的效率和可靠性,并深入了解动态数组行为的基础,这对于有效使用 C++ 至关重要。

在本章中,我们将涵盖以下主要主题:

  • STL 中的迭代器类型

  • 使用 std::vector 的基本迭代技术

  • 使用 std::beginstd::end

  • 理解迭代器要求

  • 基于范围的 for 循环

  • 创建自定义迭代器

技术要求

本章中的代码可以在 GitHub 上找到:

github.com/PacktPublishing/Data-Structures-and-Algorithms-with-the-CPP-STL

STL 中的迭代器类型

标准模板库STL)中,迭代器通过连接算法和容器发挥着关键作用。它们为开发者提供了一种遍历、访问以及可能修改容器中元素的手段。迭代器是 STL 中高效数据操作的基本工具。然而,它们的函数并不统一。STL 将迭代器划分为五种主要类型,每种类型提供不同的访问和控制元素的能力。本节将深入探讨这些迭代器类型,详细阐述它们的独特功能和用途。

输入迭代器

输入迭代器(LegacyInputIterator)是探索迭代器类型的起点。它们代表了迭代器的基础类别。正如其名称所暗示的,输入迭代器专注于读取和遍历元素。它们使开发者能够前进到容器中的下一个元素并检索其值。需要注意的是,在移动输入迭代器之后,无法回退到先前元素,并且不允许修改当前元素。这个迭代器类别通常用于需要数据处理但不修改数据的算法中。

以下是一个使用 std::vector 及其输入迭代器的简单示例:

#include <iostream>
#include <vector>
int main() {
  std::vector<int> numbers = {10, 20, 30, 40, 50};
  for (auto it = numbers.begin(); it != numbers.end();
       ++it) {
    std::cout << *it << " ";
  }
  std::cout << "\n";
  return 0;
}

在此示例中,我们使用 std::vector<int>::const_iterator 作为输入迭代器遍历向量并打印其元素。我们遵循输入迭代器的原则,不修改元素或移动迭代器向后。需要注意的是,使用输入迭代器无法更改元素或回到前一个元素。

输出迭代器

接下来,我们将探讨输出迭代器(LegacyOutputIterator)。尽管它们与输入迭代器有相似之处,但它们的主要功能不同:向元素写入。输出迭代器简化了对它们引用的元素的赋值。然而,通过迭代器直接读取这些元素是不支持的。它们通常用于设计用于在容器内生成和填充值序列的算法。

下面是一个使用std::vector演示输出迭代器使用的例子:

#include <algorithm>
#include <iostream>
#include <iterator>
#include <vector>
int main() {
  std::vector<int> numbers;
  std::generate_n(std::back_inserter(numbers), 10,
                  [n = 0]() mutable { return ++n; });
  for (auto num : numbers) { std::cout << num << " "; }
  std::cout << "\n";
  return 0;
}

在前面的代码中,std::back_inserter是一个输出迭代器适配器,用于与std::vector等容器一起工作。它允许你向向量的末尾写入或推送新的值。我们使用std::generate_n算法生成并插入数字。这种模式完美地封装了输出迭代器的只写特性。我们不使用输出迭代器来读取。对于读取,我们使用常规迭代器。

正向迭代器

在掌握基础知识之后,让我们继续前进,了解正向迭代器(LegacyForwardIterator)。正向迭代器结合了输入迭代器和输出迭代器的功能。因此,它们支持读取、写入,并且正如其名称所暗示的——始终向前移动。正向迭代器永远不会改变其方向。它们的通用性使它们非常适合许多在单链表(即std::forward_list)上操作的计算算法。

std::forward_list是专门为单链表设计的,因此它是展示正向迭代器的理想选择。

下面是一个简单的代码示例来说明它们的使用:

#include <forward_list>
#include <iostream>
int main() {
  std::forward_list<int> flist = {10, 20, 30, 40, 50};
  std::cout << "Original list: ";
  for (auto it = flist.begin(); it != flist.end(); ++it) {
    std::cout << *it << " ";
  }
  std::cout << "\n";
  for (auto it = flist.begin(); it != flist.end(); ++it) {
    (*it)++;
  }
  std::cout << "Modified list: ";
  for (auto it = flist.begin(); it != flist.end(); ++it) {
    std::cout << *it << " ";
  }
  std::cout << "\n";
  return 0;
}

下面是示例输出:

Original list: 10 20 30 40 50
Modified list: 11 21 31 41 51

这段代码初始化了一个std::forward_list,使用正向迭代器遍历并显示其元素,然后递增每个元素 1,展示了正向迭代器的读取和写入能力。

反向迭代器

有时,你可能需要以相反的顺序遍历向量。这时就出现了rbegin()rend()。这些函数返回反向迭代器,它们从向量的末尾开始,到开头结束。这种反向遍历在特定的算法和数据处理的任务中可能很有用。

注意,反向迭代器在技术上是一个迭代器适配器。std::reverse_iterator被分类为迭代器适配器。它接受一个给定的迭代器,该迭代器应该是LegacyBidirectionalIterator,或者从 C++20 开始遵守bidirectional_iterator标准。它反转其方向。当给定一个双向迭代器时,std::reverse_iterator产生一个新的迭代器,以相反的方向遍历序列——从末尾到开头。

双向迭代器

继续讨论,我们处理双向迭代器(LegacyBidirectionalIterator)。这些迭代器允许在容器内向前和向后遍历。继承所有正向迭代器的功能,它们引入了反向移动的能力。它们的设计特别有利于需要频繁双向遍历的数据结构,如双向链表。

下面是一个使用 std::list 和其双向迭代器的例子:

#include <iostream>
#include <list>
int main() {
  std::list<int> numbers = {1, 2, 3, 4, 5};
  std::cout << "Traversing the list forwards:\n";
  for (std::list<int>::iterator it = numbers.begin();
       it != numbers.end(); ++it) {
    std::cout << *it << " ";
  }
  std::cout << "\n";
  std::cout << "Traversing the list backwards:\n";
  for (std::list<int>::reverse_iterator rit =
           numbers.rbegin();
       rit != numbers.rend(); ++rit) {
    std::cout << *rit << " ";
  }
  std::cout << "\n";
  return 0;
}

下面是示例输出:

Traversing the list forwards:
1 2 3 4 5
Traversing the list backward:
5 4 3 2 1

在这个例子中,我们创建了一个整数 std::list。然后,我们通过首先使用常规迭代器向前遍历列表,然后使用反向迭代器反向遍历,来演示双向迭代。

随机访问迭代器

在我们的迭代器分类中,我们介绍了随机访问迭代器(LegacyRandomAccessIteratorLegacyContiguousIterator)。这些迭代器代表了最高的通用性,不仅允许顺序访问。使用随机访问迭代器,开发者可以向前移动多个步骤,向后退,或直接访问元素而不需要顺序遍历。这些功能使它们非常适合允许直接元素访问的数据结构,如数组或向量。

下面是一个展示随机访问迭代器(std::vector)的灵活性和能力的例子:

#include <chrono>
#include <iostream>
#include <mutex>
#include <thread>
#include <vector>
std::mutex vecMutex;
void add_to_vector(std::vector<int> &numbers, int value) {
  std::lock_guard<std::mutex> guard(vecMutex);
  numbers.push_back(value);
}
void print_vector(const std::vector<int> &numbers) {
  std::lock_guard<std::mutex> guard(vecMutex);
  for (int num : numbers) { std::cout << num << " "; }
  std::cout << "\n";
}
int main() {
  std::vector<int> numbers;
  std::thread t1(add_to_vector, std::ref(numbers), 1);
  std::thread t2(add_to_vector, std::ref(numbers), 2);
  t1.join();
  t2.join();
  std::thread t3(print_vector, std::ref(numbers));
  t3.join();
  return 0;
}

这个例子展示了随机访问迭代器的各种功能。我们开始于直接访问,然后跳过位置,跳跃回退,计算距离,甚至以非线性的方式访问元素。

理解迭代器类型的选择并非任意选择至关重要。每个迭代器都是针对特定的用例设计的,选择正确的一个可以显著提高您 C++ 代码的效率和优雅性。当与 STL 算法和容器一起工作时,对不同的迭代器类型及其功能有扎实的掌握至关重要。这种知识不仅简化了编码过程,还有助于调试和优化应用程序的性能。

在探索 STL 的迭代器时,我们学习了六种核心类型:输入、输出、正向、反向、双向和随机访问。认识到每种类型的独特功能对于高效的 C++ 编程至关重要,因为它影响我们如何遍历和与 STL 容器交互。掌握这些差异不仅具有学术意义,而且具有实践意义。它使我们能够为任务选择正确的迭代器,例如使用 std::vector 的随机访问迭代器,以利用其快速元素访问能力。

在下一节中,我们将应用这些知识,我们将看到迭代在实际中的应用,强调使用常量迭代器进行只读目的,并强调迭代器在各种容器中的适应性,为编写健壮和通用的代码奠定基础。

使用 std::vector 的基本迭代技术

现在我们已经了解了可用的不同类型的迭代器,让我们来探索遍历数据结构的基本概念。迭代是编程中的一个基本技术,允许开发者高效地访问和操作数据结构中的每个元素。特别是对于 std::vector,由于其动态特性和在 C++ 应用程序中的广泛使用,迭代至关重要。通过掌握迭代,你可以充分利用 std::vector 的潜力,实现诸如搜索、排序和精确轻松地修改元素等操作。本节旨在加深你对为什么迭代是有效管理和利用数据结构的关键技能的理解,为你在程序中更高级的应用打下基础。

遍历 std::vector

std::vector 的一个强大功能是它允许无缝遍历其元素。无论你是访问单个元素还是遍历每一个元素,理解 std::vector 的迭代能力至关重要。迭代是编程中许多操作的基础,从数据处理到算法转换。随着你进入本节,你将熟悉如何在 C++ 中高效且有效地遍历向量。

C++ STL 中迭代的核心概念是迭代器。将迭代器想象成高级指针,引导你遍历容器中的每个元素,例如我们钟爱的 std::vector。有了迭代器,你可以向前、向后移动,跳转到开始或结束位置,并访问它们所指向的内容,这使得它们成为你的 C++ 工具箱中不可或缺的工具。

使用迭代器进行基本迭代

每个 std::vector 都提供了一组成员函数,这些函数返回迭代器。其中两个主要的是 begin()end()。虽然我们将在下一节深入探讨这些函数,但请理解 begin() 返回一个指向第一个元素的迭代器,而 end() 返回一个指向最后一个元素之后的迭代器。

例如,要遍历名为 values 的向量,你通常会使用循环,如下面的代码所示:

for(auto it = values.begin(); it != values.end(); ++it) {
  std::cout << *it << "\n";
}

在这个代码示例中,it 是一个迭代器,它遍历 values 中的每个元素。循环会一直继续,直到 it 达到 values.end() 指示的位置。

使用常量迭代器

当你确定在迭代过程中不会修改元素时,使用常量迭代器是一种良好的做法。它们确保在遍历过程中元素保持不变。

想象你是一名博物馆导游,向游客展示珍贵的文物。你希望他们欣赏和理解历史,但你不希望他们触摸或修改这些脆弱的物品。同样,在编程中,也有你希望遍历集合、展示(或读取)其内容但不更改它们的情况。这就是常量迭代器发挥作用的地方。

要使用常量迭代器,std::vector 提供了 cbegin()cend() 成员函数:

for(auto cit = values.cbegin(); cit != values.cend(); ++cit) {
  std::cout << *cit << "\n";
}

迭代的好处

为什么迭代如此关键?通过有效地遍历向量,你可以做以下事情:

  • 处理数据:无论是规范化数据、过滤它还是执行任何转换,迭代都是这些操作的核心。

  • 搜索操作:寻找特定元素?迭代允许你逐个检查每个项目,与条件或值进行比较。

  • sortfindtransform 需要迭代器来指定它们操作的范围。

std::vector 迭代的灵活性和效率使其成为开发者的首选选择。虽然数组也允许遍历,但向量提供了动态大小、对溢出的鲁棒性以及与 C++ STL 的集成,这使得它们在许多场景下成为首选。

总之,掌握 std::vector 的迭代是成为熟练的 C++ 开发者的基础。通过了解如何遍历这个动态数组,你将解锁一系列功能,使你能够利用算法的力量,高效地处理数据,并构建强大、高效的软件。随着我们不断深入,你将更深入地了解其他向量工具,从而巩固你在这一充满活力的语言中的知识和技能。

在本节中,我们使用迭代器导航 std::vector 遍历,学习按顺序访问元素并利用常量迭代器进行只读操作。理解这些技术对于编写灵活和优化的与各种容器类型兼容的 C++ 代码至关重要。迭代是 STL 中数据操作的基础;掌握它是发挥库全部潜力的关键。

接下来,我们转向“使用 std::begin 和 std::end”部分,以进一步扩展我们对迭代器的知识。我们将揭示这些函数如何在不同容器中标准化迭代的开始和结束,为更灵活和松耦合的代码铺平道路。

使用 std::begin 和 std::end

随着你对 std::vector 的用例了解更多,你将遇到一些情况,在这些情况下,超越成员函数是有利或甚至是必要的。这就是非成员函数,特别是 std::beginstd::end 走到聚光灯下的地方。这两个函数非常实用,提供了一种更通用的方式来访问容器的开始和结束,包括但不限于 std::vector

为什么会有这种区别,你可能会问?难道没有像 vector::begin()vector::end() 这样的成员函数吗?确实有。然而,非成员 std::beginstd::end 的美妙之处在于它们在不同容器类型中的更广泛适用性,这使得你的代码更加灵活和适应性强。

C++中的向量提供了一种强大的动态内存和连续存储的结合,使它们在许多编码场景中变得不可或缺。但要真正利用它们的潜力,了解它们与迭代器的交互至关重要。虽然begin()end()成员函数经常成为焦点,但幕后还有两个多才多艺的演员:std::beginstd::end

当使用 C++容器时,std::begin函数可能看起来是另一种开始遍历容器的方法。然而,它带来了一整套奇迹。虽然它主要获取指向容器第一个元素的迭代器,但其应用并不限于向量。

当你将std::vector传递给std::begin时,就像拥有了一张后台通行证。幕后,该函数通过调用向量的begin()成员函数来平滑地委派任务。这种直观的行为确保了即使在进入泛型编程时,过渡仍然无缝。

与其对应物相呼应,std::end不仅仅是一个返回指向最后一个元素之后迭代器的函数。它是 C++对一致性承诺的见证。正如std::begin依赖于begin()一样,当你与std::end交互时,它巧妙而高效地调用了容器的end()成员函数。

而这里的真正魔法在于:尽管这些非成员函数在std::vector中表现出色,但它们并不受其限制。它们的泛型特性意味着它们可以很好地与各种容器协同工作,从传统的数组到列表,使它们成为那些寻求代码适应性的不可或缺的工具。

让我们看看一个示例,它展示了std::beginstd::end非成员函数在对比其成员对应物时的实用性:

#include <array>
#include <iostream>
#include <list>
#include <vector>
template <typename Container>
void displayElements(const Container &c) {
  for (auto it = std::begin(c); it != std::end(c); ++it) {
    std::cout << *it << " ";
  }
  std::cout << "\n";
}
int main() {
  std::vector<int> vec = {1, 2, 3, 4, 5};
  std::list<int> lst = {6, 7, 8, 9, 10};
  std::array<int, 5> arr = {11, 12, 13, 14, 15};
  std::cout << "Elements in vector: ";
  displayElements(vec);
  std::cout << "Elements in list: ";
  displayElements(lst);
  std::cout << "Elements in array: ";
  displayElements(arr);
  return 0;
}

在这个先前的例子中,我们注意到以下几点:

  • 我们有一个displayElements泛型函数,它接受任何容器并使用std::beginstd::end非成员函数来遍历其元素。

  • 然后,我们创建了三个容器:一个std::vector,一个std::list和一个std::array

  • 我们为每个容器调用displayElements以显示其元素。

使用std::beginstd::end,我们的displayElements函数是多才多艺的,并且可以在不同的容器类型上工作。如果我们仅仅依赖于如vector::begin()vector::end()这样的成员函数,这将不会那么简单,这强调了非成员函数的强大和灵活性。

想象一下,你被 handed 一个承诺不仅效率高而且适应性强的工具箱。这就是std::vector提供的,而std::beginstd::end等函数则完美地补充了这一点。它们不仅仅是函数,而是通往更类型无关的内存管理和遍历的门户。

我们已经看到std::beginstd::end如何通过扩展迭代能力到所有 STL 容器(而不仅仅是std::vector)来提升我们的代码。拥抱这些非成员函数是构建容器无关、可重用代码的关键——这是 C++中灵活算法实现的一个支柱。理解这一区别对于在 STL 中有效地使用迭代器至关重要。

展望未来,下一节将引导我们了解迭代器类别及其基本要素的细微差别。这种洞察对于将算法与适当的迭代器能力相匹配至关重要,反映了 C++的类型系统的深度及其与指针语义的紧密联系。

理解迭代器要求

C++中的迭代器为各种数据结构提供了一个一致的接口,例如容器,以及自 C++20 以来,范围。迭代器库提供了迭代器和相关特性、适配器和实用函数的定义。

由于迭代器扩展了指针的概念,它们在 C++中本质上采用了许多指针语义。因此,任何接受迭代器的函数模板也可以无缝地与常规指针一起工作。

迭代器被分为六种类型:LegacyInputIteratorLegacyOutputIteratorLegacyForwardIteratorLegacyBidirectionalIteratorLegacyRandomAccessIteratorLegacyContiguousIterator。这些类别不是由它们的内在类型决定的,而是由它们支持的运算来区分。例如,指针可以执行为LegacyRandomAccessIterator定义的所有运算,因此可以在需要LegacyRandomAccessIterator的地方使用。

这些迭代器类别(除了LegacyOutputIterator)可以按层次排列。更通用的迭代器类别,如LegacyRandomAccessIterator,包含了较不强大类别(如LegacyInputIterator)的能力。如果一个迭代器符合这些类别中的任何一个,并且也满足LegacyOutputIterator的标准,则称为可变迭代器,能够执行输入和输出函数。不可变的迭代器被称为常量迭代器。

在本节中,我们发现了迭代器作为 C++数据结构(包括容器和范围)统一接口的关键作用。我们探讨了 C++中的迭代器库如何定义迭代器类型、相关特性、适配器和实用函数,提供了一种标准化的方式来遍历这些结构。

我们了解到迭代器扩展了指针语义,允许任何接受迭代器的函数模板与指针无缝工作。我们进一步探讨了迭代器类别的层次结构——LegacyInputIteratorLegacyOutputIteratorLegacyForwardIteratorLegacyBidirectionalIteratorLegacyRandomAccessIteratorLegacyContiguousIterator。这些类别不是由它们的类型定义的,而是由它们支持的运算定义的,更高级的迭代器继承了简单迭代器的功能。

这项知识对我们至关重要,因为它告诉我们根据需要执行的操作来选择迭代器。了解每个迭代器类别的需求和功能使我们能够编写更高效和健壮的代码,因为我们可以选择满足我们需求的最弱迭代器,从而避免不必要的性能开销。

在下一节中,我们将从迭代器的理论基础过渡到实际应用,通过学习如何使用基于范围的 for 循环来迭代 std::vector,我们将了解这些循环如何在底层使用 std::beginstd::end,提供了一种更直观且更不易出错的元素访问和修改方法。

基于范围的 for 循环

在 C++ 中,基于范围的 for 循环为迭代容器如 std::vector 提供了一种简洁实用的机制。凭借对 std::vector 操作和 std::begin 以及 std::end 函数的了解,很明显,基于范围的 for 循环提供了一种简化的遍历技术。

在向量上使用传统的迭代需要声明一个迭代器,将其初始化为容器的开始位置,并更新它以进步到末尾。虽然这种方法可行,但它需要仔细的管理,并且容易出错。基于范围的 for 循环提供了一个更有效的解决方案。

基于范围的 for 循环概述

以下代码演示了基于范围的 for 循环的基本结构:

std::vector<int> numbers = {1, 2, 3, 4, 5};
for (int num : numbers) {
  std::cout << num << " ";
}

在这个例子中,numbers 向量中的每个整数都被打印出来。这种方法消除了显式迭代器和手动循环边界定义的需要。

内在机制

在内部,基于范围的 for 循环利用 begin()end() 函数来导航容器。循环依次从容器中检索每个项目,并将其分配给循环变量(在这种情况下为 num)。

这种方法简化了迭代过程,使开发者能够专注于对每个元素执行的操作,而不是检索过程。

何时使用基于范围的 for 循环

基于范围的 for 循环在以下情况下特别有益:

  • for 循环对于完整向量遍历是最优的。

  • 直接迭代器访问不是必需的:这些循环非常适合显示或修改元素。然而,如果需要访问迭代器本身(例如,在遍历过程中插入或删除元素),则传统的循环更为合适。

  • for 循环简洁地表达了操作每个容器元素的意图。

在迭代过程中修改元素

对于在迭代过程中需要修改向量元素的场景,使用引用作为循环变量是至关重要的,如下面的代码所示:

for (auto &num : numbers) {
  num *= 2;
}

在这种情况下,numbers 向量中的每个整数都乘以二。如果没有引用 (&),循环将改变复制的元素,而原始向量保持不变。

基于范围的 for 循环是 C++ 持续发展的证明,它在性能和可读性之间取得了平衡。它们为开发者提供了直接导航容器的途径,增强了代码的清晰度并最小化了潜在的错误。随着你在 C++ 中的进步,理解可用的工具并选择最适合你任务的工具至关重要。对 std::vector 函数和功能的彻底掌握确保了在多种情况下有效利用。

本节强调了基于范围的 for 循环在迭代 STL 容器时的优势,强调了其可读性和与传统 for 循环相比最小化的错误潜力。利用 std::beginstd::end,这些循环简化了迭代过程,让我们能够专注于元素级别的逻辑。它们在不需要直接迭代器控制时是最优的,这体现了现代 C++ 对高效和清晰的高层抽象的重视。

接下来,创建自定义迭代器这一部分将利用我们的迭代器进行高级抽象、数据转换或过滤数据视图。我们将探讨技术要求以及如何使我们的自定义迭代器与 STL 的分类保持一致。

创建自定义迭代器

C++ 的一个美丽之处在于其灵活性,赋予开发者根据需要塑造语言的能力。这种灵活性不仅限于容器迭代的内置功能。虽然 std::vector 附带了一组内置迭代器,但没有任何阻止我们创建自己的。但我们为什么想要这样做呢?

自定义迭代器的吸引力

让我们来看看你为什么想要实现一个自定义迭代器:

  • 增强抽象:考虑一个以扁平格式存储矩阵的向量。通过行或列而不是单个元素来迭代是否更直观?自定义迭代器可以促进这一点。

  • 数据转换:也许你希望迭代向量,但检索转换后的数据,如每个元素的平方值。而不是在检索前后或期间更改数据,自定义迭代器可以抽象这一点。

  • std::vector.

创建自定义 STL 迭代器可能看起来是一项艰巨的任务,但有了适当的指导,它就变得轻而易举!在其核心,迭代器是一个高级指针——一个引导你通过容器元素的向导。为了让你的迭代器与 STL 稳定地协同工作,你需要实现某些成员函数。

核心要求

这些函数的确切集合取决于你创建的迭代器类型,但其中一些是通用的。

  1. value_type:表示迭代器指向的元素类型。

  2. difference_type:表示两个迭代器之间的距离。

  3. pointerreference:定义迭代器的指针和引用类型。

  4. iterator_category:将迭代器分类为输入、输出、前向、双向或随机访问等类别。每个类别都有其独特的特征,使迭代器变得灵活且有趣!

  5. operator*:解引用运算符,允许访问迭代器指向的元素。

  6. operator++:增量运算符!这些运算符将你的迭代器向前移动(无论是前缀增量还是后缀增量风格)。

  7. operator==operator!=:装备了这些,你的迭代器可以进行比较,让算法知道它们是否到达了末尾或需要继续前进。

迭代器类别及其特性

迭代器有多种风味;每种风味(或类别)都有独特的要求:

  • operator*, operator++, operator==, 和 operator!=

  • operator*operator++* 前向迭代器:它们结合输入和输出迭代器——读取、写入,并且始终向前移动。

    • 基本要求:所有核心要求* operator-- 以步退* std::vector

    • operator+, operator-, operator+=, operator-=, operator[], 和关系运算符如 operator<, operator<=, operator>, 和 operator>=

    C++ 中的随机访问迭代器是功能最强大的迭代器类别之一,需要几个函数和运算符才能完全与 STL 算法和容器兼容。

这里是一个为随机访问迭代器通常实现的函数和运算符列表:

  • iterator_category (应设置为 std::random_access_iterator_tag)

  • value_type

  • difference_type

  • pointer

  • reference

  • operator*() (解引用运算符)* operator->() (箭头运算符)* operator++() (前缀增量)* operator++(int) (后缀增量)* operator--() (前缀减量)* operator--(int) (后缀减量)* ptrdiff_t):

    • operator+(difference_type) (通过某些数量向前移动迭代器)

    • operator-(difference_type) (通过某些数量向后移动迭代器)

    • operator+=(difference_type) (通过某些数量增加迭代器)

    • operator-=(difference_type)(按某些量递减迭代器)* operator-(const RandomAccessIteratorType&)* operator[](difference_type)* operator==(相等)* operator!=(不等)* operator<(小于)* operator<=(小于或等于)* operator>(大于)* operator>=(大于或等于)* 交换(有时很有用,但不是迭代器本身的严格要求):

    • 一个用于交换两个迭代器的交换函数

并非所有这些总是适用,特别是如果底层数据结构有限制或迭代器的特定使用场景不需要所有这些操作。然而,为了与 STL 的随机访问迭代器完全兼容,这是你想要考虑实现的一组完整函数和运算符。

自定义迭代器示例

让我们为std::vector<int>创建一个自定义迭代器,当解引用时,返回向量中值的平方:

#include <iostream>
#include <iterator>
#include <vector>
class SquareIterator {
public:
  using iterator_category =
      std::random_access_iterator_tag;
  using value_type = int;
  using difference_type = std::ptrdiff_t;
  using pointer = int *;
  using reference = int &;
  explicit SquareIterator(pointer ptr) : ptr(ptr) {}
  value_type operator*() const { return (*ptr) * (*ptr); }
  pointer operator->() { return ptr; }
  SquareIterator &operator++() {
    ++ptr;
    return *this;
  }
  SquareIterator operator++(int) {
    SquareIterator tmp = *this;
    ++ptr;
    return tmp;
  }
  SquareIterator &operator+=(difference_type diff) {
    ptr += diff;
    return *this;
  }
  SquareIterator operator+(difference_type diff) const {
    return SquareIterator(ptr + diff);
  }
  value_type operator[](difference_type diff) const {
    return *(ptr + diff) * *(ptr + diff);
  }
  bool operator!=(const SquareIterator &other) const {
    return ptr != other.ptr;
  }
private:
  pointer ptr;
};
int main() {
  std::vector<int> vec = {1, 2, 3, 4, 5};
  SquareIterator begin(vec.data());
  SquareIterator end(vec.data() + vec.size());
  for (auto it = begin; it != end; ++it) {
    std::cout << *it << ' ';
  }
  SquareIterator it = begin + 2;
  std::cout << "\nValue at position 2: " << *it;
  std::cout
      << "\nValue at position 3 using subscript operator: "
      << it[1];
  return 0;
}

当运行此代码时,将输出以下内容:

1 4 9 16 25
Value at position 2: 9
Value at position 3 using subscript operator: 16

代码中的迭代器可以非常类似于内置数组或std::vector迭代器使用,但在解引用时具有平方值的独特功能。

自定义迭代器的挑战和用例

创建自定义迭代器不仅仅是理解你的数据或用例;它还涉及到应对一些挑战:

  • 复杂性:构建迭代器需要遵循某些迭代器概念。根据它是输入迭代器、正向迭代器、双向迭代器还是随机访问迭代器,必须满足不同的要求。

  • push_backerase。确保自定义迭代器保持有效对于安全且可预测的行为至关重要。

  • 性能开销:随着功能的增加,可能会带来额外的计算。确保迭代器的开销不会抵消其好处是至关重要的。

自定义迭代器的说明性用例

为了理解这个概念,让我们简要地看看几个自定义迭代器大放异彩的场景:

  • std::vector可能以线性方式存储图像的像素数据。自定义迭代器可以促进按行、通道或甚至感兴趣区域进行迭代。

  • std::vector<char>,迭代器可以被设计成从单词跳到单词或从句子跳到句子,忽略空白和标点符号。

  • 统计抽样:对于存储在向量中的大数据集,迭代器可能会采样每n个元素,从而在不遍历每个元素的情况下提供快速概述。

创建自定义迭代器需要遵循特定的约定并定义一组必需的运算符,以赋予它迭代器的行为。

以下代码展示了如何为从存储在std::vector中的位图中提取 alpha 通道创建自定义迭代器:

#include <iostream>
#include <iterator>
#include <vector>
struct RGBA {
  uint8_t r, g, b, a;
};
class AlphaIterator {
public:
  using iterator_category = std::input_iterator_tag;
  using value_type = uint8_t;
  using difference_type = std::ptrdiff_t;
  using pointer = uint8_t *;
  using reference = uint8_t &;
  explicit AlphaIterator(std::vector<RGBA>::iterator itr)
      : itr_(itr) {}
  reference operator*() { return itr_->a; }
  AlphaIterator &operator++() {
    ++itr_;
    return *this;
  }
  AlphaIterator operator++(int) {
    AlphaIterator tmp(*this);
    ++itr_;
    return tmp;
  }
  bool operator==(const AlphaIterator &other) const {
    return itr_ == other.itr_;
  }
  bool operator!=(const AlphaIterator &other) const {
    return itr_ != other.itr_;
  }
private:
  std::vector<RGBA>::iterator itr_;
};
int main() {
  std::vector<RGBA> bitmap = {
      {255, 0, 0, 128}, {0, 255, 0, 200}, {0, 0, 255, 255},
      // ... add more colors
  };
  std::cout << "Alpha values:\n";
  for (AlphaIterator it = AlphaIterator(bitmap.begin());
       it != AlphaIterator(bitmap.end()); ++it) {
    std::cout << static_cast<int>(*it) << " ";
  }
  std::cout << "\n";
  return 0;
}

在此示例中,我们定义了一个 RGBA 结构体来表示颜色。然后我们创建了一个自定义的 AlphaIterator 迭代器来导航 alpha 通道。接下来,迭代器使用底层的 std::vector<RGBA>::iterator,但在解引用时仅暴露 alpha 通道。最后,main 函数演示了使用此迭代器打印 alpha 值。

此自定义迭代器遵循 C++ 输入迭代器的约定,使其可用于各种算法和基于范围的 for 循环。示例中的 AlphaIterator 类演示了 C++ 中自定义输入迭代器的基本结构和行为。以下是关键成员函数及其对 STL 兼容性的重要性的分解:

  • iterator_category:定义迭代器的类型/类别。它帮助算法确定迭代器支持的操作。在此处,它定义为 std::input_iterator_tag,表示它是一个输入迭代器。

  • value_type:可以从底层容器中读取的数据类型。在此处,它是表示 alpha 通道的 uint8_t

  • difference_type:用于表示两个迭代器相减的结果。通常用于随机访问迭代器。

  • pointerreference:指向 value_type 的指针和引用类型。它们提供了对值的直接访问。

  • explicit AlphaIterator(std::vector<RGBA>::iterator itr): 此构造函数对于使用底层 std::vector 迭代器的实例初始化迭代器至关重要。* reference operator*():解引用运算符返回序列中当前项的引用。对于此迭代器,它返回 RGBA 值的 alpha 通道的引用。* AlphaIterator& operator++():前置增量运算符将迭代器向前推进到下一个元素。* AlphaIterator operator++(int):后置增量运算符将迭代器向前推进到下一个元素,但在增量之前返回当前元素的迭代器。这种行为对于 it++ 等构造是必需的。* bool operator==(const AlphaIterator& other) const:检查两个迭代器是否指向相同的位置。这对于比较和确定序列的末尾至关重要。* bool operator!=(const AlphaIterator& other) const:前一个操作的相反:此操作检查两个迭代器是否不相等。

这些成员函数和类型别名对于使迭代器与 STL 兼容以及能够无缝使用各种 STL 算法和构造至关重要。它们定义了功能输入迭代器所需的基本接口和语义。

对于具有更多功能(如双向或随机访问)的迭代器,可能需要额外的操作。但对于在 AlphaIterator 中演示的输入迭代器,上述内容是核心组件。

这一节涵盖了自定义迭代器及其为特定需求(如数据抽象、转换和过滤)的创建。学会定义基本类型别名和实现关键运算符对于扩展 std::vector 的功能至关重要。这种知识使我们能够定制数据交互,确保我们的代码以精确的方式满足独特的领域需求。

摘要

在这一章中,我们全面探讨了迭代器在 C++ STL 中最灵活的容器之一的角色和用法。我们首先讨论了 STL 中可用的各种迭代器类型——输入、输出、正向、反向、双向和随机访问——以及它们的特定应用和支持操作。

然后,我们转向了实用的迭代技术,详细说明了如何使用标准迭代器和常量迭代器有效地遍历 std::vector。我们强调了选择正确的迭代器类型来完成手头任务的重要性,以编写干净、高效且具有容错能力的代码。

在使用 std::beginstd::end 的章节中,我们扩展了我们的工具箱,展示了这些非成员函数如何通过不紧密绑定到容器类型来使我们的代码更加灵活。我们还涵盖了迭代器的需求和分类,这是理解 STL 内部工作原理和实现自定义迭代器所必需的基本知识。

基于范围的 for 循环被引入作为一种现代 C++ 功能,它通过抽象迭代器管理的细节来简化迭代。我们学习了何时以及如何充分利用这些循环,特别是它们在迭代过程中修改元素时的便捷性。

最后,我们探讨了创建自定义迭代器的进阶主题。我们发现了背后的动机,例如提供更直观的导航或展示过滤后的数据视图。我们检查了自定义迭代器的核心要求、挑战和用例,从而完善了我们对其如何定制以满足特定需求的理解。

虽然与 std::vector 一起提供的标准迭代器覆盖了许多用例,但这并不是故事的终结。自定义迭代器提供了一条途径,可以扩展迭代可能性的边界,将遍历逻辑定制到特定需求。制作可靠的自定义迭代器的复杂性不容小觑。在我们结束这一章时,请记住,在正确的人手中,自定义迭代器可以是强大的工具。你可以通过对其工作原理的深入了解,做出关于何时以及如何使用它们的明智决策。

在这一章中获得的知识是有益的,因为它使我们能够创建更复杂、更健壮和性能更优的 C++ 应用程序。有效地理解和利用迭代器使我们能够充分利用 std::vector 的全部功能,并编写容器无关且高度优化的算法。

即将到来的章节,使用 std::vector 精通内存和分配器,建立在我们的现有知识之上,并将我们的关注点引向内存效率,这是高性能 C++ 编程的一个关键方面。我们将继续强调这些概念的实际、现实世界的应用,确保内容保持价值,并直接适用于我们作为中级 C++ 开发者的工作。

第三章:使用 std::vector 掌握内存和分配器

本章深入探讨了现代 C++ 编程中的关键内存管理概念。我们首先区分了 std::vector 的容量和大小,这对于编写高效代码至关重要。随着我们的进展,我们将了解内存预留和优化的机制,以及为什么这些操作在现实世界的应用中很重要。本章以彻底探讨自定义分配器结束,包括何时使用它们及其对容器性能的影响。它为我们提供了调整程序内存使用的专业知识。

在本章中,我们将涵盖以下主要主题:

  • 理解容量与大小

  • 调整和预留内存

  • 自定义分配器基础知识

  • 创建自定义分配器

  • 分配器和容器性能

技术要求

本章中的代码可以在 GitHub 上找到:

github.com/PacktPublishing/Data-Structures-and-Algorithms-with-the-CPP-STL

理解容量与大小

随着你深入到 C++ 编程艺术的殿堂,使用 std::vector,掌握向量的大小和容量的区别变得至关重要。虽然这两个术语密切相关,但在管理和优化动态数组时,它们扮演着不同的角色。理解它们将显著提高你代码的效率和清晰度。

回顾基础知识

回顾上一章的内容,向量的大小表示它当前包含的元素数量。当你添加或删除元素时,这个大小会相应调整。所以,如果你有一个包含五个整数的向量,其大小是 5。删除一个整数,大小变为 4

std::vector 的一个引人注目的方面在于:虽然其大小根据其元素而变化,但它分配的内存并不总是立即跟随。为了彻底理解这一点,我们需要探索容量的概念。让我们在下一节中探讨它。

容量究竟是什么?

std::vector 指的是向量为自己预留的内存量——在重新分配内存之前它可以容纳的元素数量。这并不总是等于它当前持有的元素数量(即大小)。std::vector 通常分配比所需更多的内存,这是一种预防策略,以适应未来的元素。这就是 std::vector 的天才之处;过度分配减少了频繁的,可能还有计算成本高昂的重新分配的需求。

让我们用一个类比来使这个问题更直观。想象一下向量就像一列有隔间(内存块)的火车。当火车(向量)开始旅程时,它可能只有几个乘客(元素)。然而,预计在未来的车站会有更多的乘客,火车开始时有一些空隔间。火车的容量是隔间的总数,而大小是有乘客的隔间数量。

为什么这种区别很重要

你可能会想知道为什么我们不在每次添加新元素时仅仅扩展内存。答案在于计算效率。内存操作,尤其是重新分配,可能很耗时。向量通过分配比立即需要的更多内存来最小化这些操作,确保在大多数情况下添加元素仍然是一个快速操作。这种优化是 std::vector 成为 C++ 编程中必备工具的一个原因。

然而,也存在另一面。过度分配意味着一些内存可能暂时未被使用。如果内存使用是一个关键问题,那么理解和管理容量变得至关重要。在某些极端情况下,一个向量可能有 10 的大小,但容量为 1000

查看内部结构

有时必须查看内部结构,以欣赏大小和容量的细微差别。考虑一个新初始化的 std::vector<int> numbers;。如果你逐个将 10 个整数推入它,并定期检查其容量,你可能会注意到一些有趣的事情:容量不会为每个整数增加一个!相反,它可能从 1 跳到 2,然后到 4,然后到 8,依此类推。这种指数增长策略是典型的实现方法,确保向量在用完空间时容量加倍。

让我们看看一个展示 std::vector 中大小和容量差异的代码示例:

#include <iostream>
#include <vector>
int main() {
  std::vector<int> myVec;
  std::cout << "Initial size: " << myVec.size()
            << ", capacity: " << myVec.capacity() << "\n";
  for (auto i = 0; i < 10; ++i) {
    myVec.push_back(i);
    std::cout << "After adding " << i + 1
              << " integers, size: " << myVec.size()
              << ", capacity: " << myVec.capacity()
              << "\n";
  }
  myVec.resize(5);
  std::cout << "After resizing to 5 elements, size: "
            << myVec.size()
            << ", capacity: " << myVec.capacity() << "\n";
  myVec.shrink_to_fit();
  std::cout << "After shrinking to fit, size: "
            << myVec.size()
            << ", capacity: " << myVec.capacity() << "\n";
  myVec.push_back(5);
  std::cout << "After adding one more integer, size: "
            << myVec.size()
            << ", capacity: " << myVec.capacity() << "\n";
  return 0;
}

下面是示例输出:

Initial size: 0, capacity: 0
After adding 1 integers, size: 1, capacity: 1
After adding 2 integers, size: 2, capacity: 2
After adding 3 integers, size: 3, capacity: 3
After adding 4 integers, size: 4, capacity: 4
After adding 5 integers, size: 5, capacity: 6
After adding 6 integers, size: 6, capacity: 6
After adding 7 integers, size: 7, capacity: 9
After adding 8 integers, size: 8, capacity: 9
After adding 9 integers, size: 9, capacity: 9
After adding 10 integers, size: 10, capacity: 13
After resizing to 5 elements, size: 5, capacity: 13
After shrinking to fit, size: 5, capacity: 5
After adding one more integer, size: 6, capacity: 7

下面是这个代码块的解释:

  • 我们首先创建一个空的 std::vector<int> 命名为 myVec

  • 我们然后打印出初始的 sizecapacity。由于它是空的,size 值将是 0。初始的 capacity 值可能因 C++ 的 0 而有所不同。

  • 当我们逐个将整数推入向量时,我们可以看到大小和容量是如何变化的。对于每个添加的元素,size 值将始终增加一个。然而,capacity 值可能保持不变或增加,通常加倍,这取决于底层内存何时需要重新分配。

  • 将向量的大小调整到五个元素表明,虽然 size 减少了,但 capacity 保持不变。这确保了之前分配的内存仍然为潜在的将来元素保留。

  • shrink_to_fit() 函数将向量的 capacity 减少以匹配其 size,从而释放未使用的内存。

  • 我们可以通过在缩小后添加一个元素来再次观察容量是如何表现的。

当你运行这个示例时,你将亲身体验大小和容量之间的差异以及 std::vector 在后台如何管理内存。

通过理解大小和容量之间的关系,你可以优化内存使用并预防潜在的性能陷阱。这为即将到来的章节奠定了基础,我们将讨论如何使用向量进行手动内存管理以及如何有效地遍历它们。

本节加深了我们对于 std::vector 的大小和容量的理解。我们将这些概念与火车的车厢进行了比较,强调了容量规划如何防止频繁、昂贵的重新分配,并导致更高效的内存使用程序。掌握这一点对于性能敏感和内存受限的环境至关重要。

基于此,我们将接下来查看 resize()reserve()shrink_to_fit(),学习如何主动管理 std::vector 的内存占用以实现最佳性能和内存使用。

调整大小和预留内存

在我们对 std::vector 的探索中,理解如何有效地管理其内存是至关重要的。向量的美在于其动态性;它可以增长和缩小,适应我们应用程序不断变化的需求。然而,这种灵活性也带来了确保高效内存利用的责任。本节深入探讨了让我们可以操作向量大小及其预分配内存的操作:resizereserveshrink_to_fit

在处理向量时,我们已经看到它们的容量(预分配内存)可能与其实际大小(元素数量)不同。管理这些方面的方法可能会显著影响你程序的性能和内存占用。

resize() 的力量

假设你有一个包含五个元素的 std::vector。如果你突然需要它来保持八个元素,或者可能只需要三个元素,你将如何进行这种调整?resize() 函数就是你的答案。

resize() 用于改变向量的大小。如果你增加其大小,新元素将被默认初始化。例如,对于 std::vector<int>,新元素将具有值 0。相反,如果你减少其大小,额外的元素将被丢弃。

但请记住,调整大小并不总是影响容量。如果你将向量扩展到其当前容量之外,容量将会增长(通常比大小增长更多,以适应未来的增长)。然而,缩小向量的大小并不会减少其容量。

让我们看看一个示例,该示例演示了如何手动调整 std::vector 实例的容量:

#include <iostream>
#include <vector>
int main() {
  std::vector<int> numbers = {1, 2, 3, 4, 5};
  auto printVectorDetails = [&]() {
    std::cout << "Vector elements: ";
    for (auto num : numbers) { std::cout << num << " "; }
    std::cout << "\nSize: " << numbers.size() << "\n";
    std::cout << "Capacity: " << numbers.capacity()
              << "\n";
  };
  std::cout << "Initial vector:\n";
  printVectorDetails();
  numbers.resize(8);
  std::cout << "After resizing to 8 elements:\n";
  printVectorDetails();
  numbers.resize(3);
  std::cout << "After resizing to 3 elements:\n";
  printVectorDetails();
  std::cout << "Reducing size doesn't affect capacity:\n";
  std::cout << "Capacity after resize: "
            << numbers.capacity() << "\n";
  return 0;
}

下面是示例输出:

Initial vector:
Vector elements: 1 2 3 4 5
Size: 5
Capacity: 5
After resizing to 8 elements:
Vector elements: 1 2 3 4 5 0 0 0
Size: 8
Capacity: 10
After resizing to 3 elements:
Vector elements: 1 2 3
Size: 3
Capacity: 10
Reducing size doesn't affect capacity:
Capacity after resize: 10

在这个例子中,我们看到了以下情况:

  • 我们从一个包含五个元素的 std::vector<int> 开始。

  • 打印实用工具 printVectorDetails lambda 函数显示向量的元素、大小和容量。

  • 我们将向量的大小调整为容纳八个元素,并观察这些变化。

  • 然后,我们将向量的大小调整为仅包含三个元素,并观察大小如何减少,但容量保持不变。

这展示了 resize() 函数的力量以及它如何影响大小但并不总是影响 std::vector 的容量。

进入 reserve()

有时候,我们对数据有所了解。比如说你知道你将向向量中插入 100 个元素。让向量随着元素的添加逐步调整其容量将是不高效的。这时 reserve() 函数就派上用场了。

通过调用 reserve(),你可以预先为向量预留一定量的内存。这就像提前订票一样。大小保持不变,但容量调整为至少指定的值。如果你预留的内存少于当前容量,调用将没有效果;你不能使用 reserve() 减少容量。

让我们通过以下示例来演示 reserve() 函数的实用性:

#include <chrono>
#include <iostream>
#include <vector>
int main() {
  constexpr size_t numberOfElements = 100'000;
  std::vector<int> numbers1;
  auto start1 = std::chrono::high_resolution_clock::now();
  for (auto i = 0; i < numberOfElements; ++i) {
    numbers1.push_back(i);
  }
  auto end1 = std::chrono::high_resolution_clock::now();
  std::chrono::duration<double> elapsed1 = end1 - start1;
  std::cout << "Time without reserve: " << elapsed1.count()
            << " seconds\n";
  std::vector<int> numbers2;
  numbers2.reserve(
      numberOfElements); // Reserve memory upfront.
  auto start2 = std::chrono::high_resolution_clock::now();
  for (auto i = 0; i < numberOfElements; ++i) {
    numbers2.push_back(i);
  }
  auto end2 = std::chrono::high_resolution_clock::now();
  std::chrono::duration<double> elapsed2 = end2 - start2;
  std::cout << "Time with reserve:    " << elapsed2.count()
            << " seconds\n";
  return 0;
}

下面是示例输出:

Time without reserve: 0.01195 seconds
Time with reserve:    0.003685 seconds

从前面的示例中,我们了解到以下内容:

  • 我们打算向两个向量中插入许多元素(numberOfElements)。

  • 在第一个向量(numbers1)中,我们直接插入元素,而没有预先保留任何内存。

  • 在第二个向量(numbers2)中,我们在插入元素之前使用 reserve() 函数预先分配内存。

  • 我们测量并比较了两种情况下插入元素所需的时间。

当你运行代码时,你可能会注意到使用 reserve() 的插入时间更短(通常显著),因为它减少了内存重新分配的次数。这个例子有效地展示了合理使用 reserve() 的性能优势。在这个例子中,使用 reserve() 比不调用 reserve() 快了 3 倍以上。

合理使用 reserve() 可以显著提高性能,尤其是在处理大数据集时。预分配内存意味着更少的内存重新分配,从而加快插入速度。

使用 shrink_to_fit()

虽然 reserve() 允许你扩展预分配的内存,但如果你想要做相反的事情怎么办?如果你在多次操作后发现一个向量的尺寸为 10 但容量为 1000,保留额外的内存可能是浪费的。

shrink_to_fit() 函数允许你请求向量减少其容量以匹配其大小。注意这个词 request。实现可能并不总是保证减少容量,但在大多数情况下,它们会遵守。在大量删除后或向量增长阶段结束时回收内存是减少向量容量的绝佳方式。

让我们通过以下简单的代码示例来说明 shrink_to_fit() 的用法:

#include <iostream>
#include <vector>
int main() {
  std::vector<int> numbers;
  numbers.reserve(1000);
  std::cout << "Initial capacity: " << numbers.capacity()
            << "\n";
  for (auto i = 0; i < 10; ++i) { numbers.push_back(i); }
  std::cout << "Size after adding 10 elements: "
            << numbers.size() << "\n";
  std::cout << "Capacity after adding 10 elements: "
            << numbers.capacity() << "\n";
  numbers.shrink_to_fit();
  std::cout << "Size after shrink_to_fit: "
            << numbers.size() << "\n";
  std::cout << "Capacity after shrink_to_fit: "
            << numbers.capacity() << "\n";
  return 0;
}

下面是示例输出:

Initial capacity: 1000
Size after adding 10 elements: 10
Capacity after adding 10 elements: 1000
Size after shrink_to_fit: 10
Capacity after shrink_to_fit: 10

以下是前面示例的关键要点:

  • 我们从 std::vector<int> 开始,并为 1000 个元素预留内存。

  • 我们只向向量中添加了 10 个元素。

  • 在这个阶段,向量的尺寸是 10,但其容量是 1000。

  • 然后,我们调用 shrink_to_fit() 来将向量的容量减少到与大小完全匹配。

  • 我们在调用 shrink_to_fit() 后显示了大小和容量。

在运行代码后,你应该观察到向量的容量已经减少到接近其大小,这说明了该函数在回收内存方面的实用性。

现实世界的相关性

理解大小和容量之间的区别以及如何操作它们具有深远的意义。对于性能至关重要的应用程序,如实时系统或高频交易平台,有效地管理内存至关重要。同样,确保在嵌入式系统或内存有限的设备中每个字节都得到有效使用也是至关重要的。

虽然std::vector提供了处理数组的动态和高效方法,但要熟练运用它需要深入了解其内存行为。通过有效地使用resizereserveshrink_to_fit,开发者可以调整内存使用以满足应用程序的精确需求,在性能和资源消耗之间实现最佳平衡。

要精通 C++,必须不仅仅是一个程序员;必须像建筑师一样思考,理解手头的材料,并构建经得起时间和负载考验的结构。随着我们继续前进,我们将深入研究迭代方法,使我们更接近于掌握std::vector

本节加深了我们对于std::vector内存分配技术的理解。我们学习了如何通过reserve()策略性地分配内存以优化性能,而shrink_to_fit()可以通过释放未使用的空间来最小化内存占用。这些策略对于开发者提高应用程序效率和明智地管理资源至关重要。

接下来,我们将探讨分配器在内存管理中的核心作用。我们将剖析分配器接口和可能需要自定义分配器的场景,评估它们与标准实践相比对性能和内存使用的影响。

自定义分配器基础

std::vector(以及许多其他 STL 容器)中动态内存管理的奥秘在于一个可能不会立即引起你注意的组件:分配器。在核心上,一个std::vector可以在不绑定到特定内存源或分配策略的情况下运行。

分配器的角色和责任

分配器是内存管理的无名英雄。它们处理内存块的分配和释放,从而确保我们的数据结构能够优雅地增长和收缩。在这些任务之外,分配器还可以构建和销毁对象。它们在原始内存操作和高级对象管理之间架起桥梁。

但为什么我们需要这样的抽象?为什么不直接使用newdelete操作呢?答案在于灵活性。STL 赋予开发者通过解耦容器与特定内存操作来实现自定义内存策略的能力。对于性能关键的应用,这种灵活性是一种福音。

内部机制——分配器接口

默认的 std::allocator 提供了与其职责紧密相关的成员函数。让我们简要地看看这些成员函数:

  • allocate(): 分配一个适合容纳指定数量对象的内存块

  • deallocate(): 将分配器之前分配给系统的内存块返回给系统

  • construct(): 在给定的内存位置构造一个对象

  • destroy(): 在给定的内存位置调用对象的析构函数

记住,虽然 std::allocator 默认使用堆进行内存操作,但分配器接口的真正威力在于自定义分配器发挥作用时。

为了展示 std::allocator 的好处,让我们首先说明一个简单的自定义分配器可能的样子。这个自定义分配器将跟踪并打印其操作,使我们能够可视化其交互。

然后,我们将在这个代码块中使用 std::vector 和这个自定义分配器:

#include <iostream>
#include <memory>
#include <vector>
template <typename T> class CustomAllocator {
public:
  using value_type = T;
  CustomAllocator() noexcept {}
  template <typename U>
  CustomAllocator(const CustomAllocator<U> &) noexcept {}
  T *allocate(std::size_t n) {
    std::cout << "Allocating " << n << " objects of size "
              << sizeof(T) << " bytes.\n";
    return static_cast<T *>(::operator new(n * sizeof(T)));
  }
  void deallocate(T *p, std::size_t) noexcept {
    std::cout << "Deallocating memory.\n";
    ::operator delete(p);
  }
  template <typename U, typename... Args>
  void construct(U *p, Args &&...args) {
    std::cout << "Constructing object.\n";
    new (p) U(std::forward<Args>(args)...);
  }
  template <typename U> void destroy(U *p) {
    std::cout << "Destroying object.\n";
    p->~U();
  }
};
int main() {
  std::vector<int, CustomAllocator<int>> numbers;
  std::cout << "Pushing back numbers 1 to 5:\n";
  for (int i = 1; i <= 5; ++i) { numbers.push_back(i); }
  std::cout << "\nClearing the vector:\n";
  numbers.clear();
  return 0;
}

下面是示例输出:

Pushing back numbers 1 to 5:
Allocating 1 objects of size 4 bytes.
Constructing object.
Allocating 2 objects of size 4 bytes.
Constructing object.
Constructing object.
Destroying object.
Deallocating memory.
Allocating 4 objects of size 4 bytes.
Constructing object.
Constructing object.
Constructing object.
Destroying object.
Destroying object.
Deallocating memory.
Constructing object.
Allocating 8 objects of size 4 bytes.
Constructing object.
Constructing object.
Constructing object.
Constructing object.
Constructing object.
Destroying object.
Destroying object.
Destroying object.
Destroying object.
Deallocating memory.
Clearing the vector:
Destroying object.
Destroying object.
Destroying object.
Destroying object.
Destroying object.
Deallocating memory.

以下是从前面的例子中得出的关键要点:

  • 我们创建了一个简单的 CustomAllocator,当它执行特定的操作,如分配、释放、构造和销毁时,会打印消息。它使用全局的 newdelete 操作符进行内存操作。

  • main() 函数中的 std::vector 使用我们的 CustomAllocator

  • 当我们将元素推入向量时,你会注意到指示内存分配和对象构造的消息。

  • 清除向量将触发对象销毁和内存释放消息。

使用我们的自定义分配器,我们为 std::vector 的内存管理操作添加了自定义行为(在这种情况下是打印)。这展示了分配器在 STL 中提供的灵活性以及它们如何针对特定需求进行定制。

权衡和自定义分配器的需求

你可能想知道,如果 std::allocator 可以直接使用,为什么还要费心使用自定义分配器?就像软件开发中的许多事情一样,答案归结为权衡。

默认分配器的一般性质确保了广泛的适用性。然而,这种万能的解决方案可能不是特定场景的最佳选择。例如,频繁分配和释放小块内存的应用程序如果使用默认分配器可能会遭受碎片化。

此外,某些上下文可能具有独特的内存约束,例如内存有限的嵌入式系统或对性能要求严格的实时系统。在这些情况下,自定义分配器提供的控制和优化变得非常有价值。

选择 std::allocator 而不是 newdelete 和托管指针

关于 C++中的内存管理,开发者有多种机制可供选择。虽然使用newdelete或甚至智能指针如std::shared_ptrstd::unique_ptr可能看起来直观,但当与 STL 容器一起工作时,依赖std::allocator有很强的理由。让我们来探讨这些优势。

与 STL 容器的一致性

STL 中的容器设计时考虑了分配器。使用std::allocator确保了库中的一致性和兼容性。它确保你的定制或优化可以统一地应用于各种容器。

内存抽象和定制

原始内存操作甚至管理指针并不能直接提供定制内存分配策略的途径。另一方面,std::allocator(及其可定制的兄弟)提供了一个抽象层,为定制内存管理方法铺平了道路。这意味着你可以实施对抗碎片化、使用内存池或利用专用硬件的策略。

集中化内存操作

使用原始指针和手动内存管理时,分配和释放操作散布在代码中。这种分散化可能导致错误和不一致性。std::allocator封装了这些操作,确保内存管理保持一致和可追踪。

防止常见陷阱的安全措施

使用newdelete进行手动内存管理容易产生内存泄漏、重复删除和未定义行为等问题。即使使用智能指针,循环引用也可能成为头疼的问题。当与容器一起使用时,分配器通过自动化底层内存过程来减轻许多这些担忧。

与高级 STL 功能的更好协同

STL 中某些高级功能和优化,如分配器感知容器,直接利用了分配器的功能。使用std::allocator(或自定义分配器)确保你更好地利用这些增强功能。

虽然newdelete和管理指针在 C++编程中有其位置,但在基于容器的内存管理方面,std::allocator是一个明显的选择。它提供了一种定制、安全和效率的混合体,这是手动或半手动内存管理技术难以实现的。在你探索 C++开发的丰富领域时,让分配器成为你在动态内存中的忠实伴侣。

本节探讨了分配器及其在管理std::vector内存中的作用。我们揭示了分配器如何为 STL 容器中的内存操作提供抽象,并考察了分配器接口的工作原理。这种理解对于制定能够提升各种环境下应用程序性能的内存管理策略至关重要。

接下来,我们将探讨实现自定义分配器,研究内存池,并指导您创建一个用于std::vector的自定义分配器,展示个性化内存管理的优势。

创建自定义分配器

创建自定义分配器是增强内存管理的战略决策。当默认的内存分配策略与特定应用程序的独特性能要求或内存使用模式不一致时,这种方法尤其有价值。通过设计自定义分配器,开发者可以微调内存分配和释放过程,可能提高效率,减少开销,并确保更好地控制其应用程序中资源的管理。这种程度的定制对于标准分配方案可能无法满足特定需求或优化性能的应用程序至关重要。

自定义分配器——内存灵活性的核心

当您考虑 STL 容器如何处理内存时,表面之下隐藏着一种潜在的力量。例如,std::vector这样的容器通过分配器来满足内存需求。默认情况下,它们使用std::allocator,这是一个适用于大多数任务的通用分配器。然而,在某些情况下,您可能需要更多控制内存分配和释放策略。这就是自定义分配器发挥作用的地方。

理解自定义分配器的动机

初看之下,人们可能会 wonder 为什么需要比默认分配器更多的东西。毕竟,那不是足够了吗?虽然std::allocator很灵活,但它旨在满足广泛的用例。特定情况需要特定的内存策略。以下是一些动机:

  • 性能优化:不同的应用程序有不同的内存访问模式。例如,图形应用程序可能会频繁地分配和释放小块内存。自定义分配器可以针对这种模式进行优化。

  • 缓解内存碎片:碎片化可能导致内存使用效率低下,尤其是在长时间运行的应用程序中。自定义分配器可以采用策略来减少或甚至防止碎片化。

  • 专用硬件或内存区域:有时,应用程序可能需要从特定区域或甚至专用硬件(如图形处理单元GPU)内存)分配内存。自定义分配器提供了这种灵活性。

内存池——一种流行的自定义分配器策略

在自定义内存分配中,一个广受欢迎的策略是内存池的概念。内存池预先分配一大块内存,然后根据应用程序的需求以较小的块形式分配。内存池的卓越之处在于其简单性和效率。以下是它们有益的原因:

  • 更快的分配和释放:由于已经预先分配了一大块内存,因此分配较小的内存块是快速的。

  • 减少碎片化:内存池通过控制内存布局并确保连续块自然地减少了碎片化。

  • 可预测的行为:内存池可以提供一定程度的可预测性,这在性能至关重要的实时系统中特别有益。

解锁自定义分配器的潜力

虽然深入研究自定义分配器可能看起来令人畏惧,但它们的益处是实实在在的。无论是为了性能提升、内存优化还是特定应用需求,理解自定义分配器的潜力是 C++ 开发者工具箱中的一个宝贵资产。随着你继续使用 std::vector 的旅程,请记住,分配器在每一个元素之下勤奋地管理内存,以实现高效的内存管理。通过自定义分配器,你可以根据应用需求定制这种管理。

本节介绍了 std::vector 中自定义分配器的设计和使用,强调了它们如何允许专门的内存管理,这对于优化具有独特内存使用模式的程序至关重要。有了这个见解,开发者可以超越 STL 的默认机制,通过定制的分配策略(如内存池)来提高性能。

我们接下来将检查分配器对 STL 容器性能的影响,仔细审查 std::allocator 的特性,确定自定义替代方案的场景,并强调 性能分析 在明智地选择分配器中的作用。

分配器和容器性能

每个容器的效率都源于其内存管理策略,对于 std::vector 来说,分配器扮演着至关重要的角色。虽然内存分配可能看起来很简单,但分配器设计中的细微差别可以带来各种性能影响。

为什么分配器在性能中很重要

在我们能够利用分配器的潜力之前,我们需要了解为什么它们很重要。内存分配不是一个一刀切的操作。根据应用程序的具体需求,分配的频率、内存块的大小以及这些分配的生存期可能会有很大的差异。

  • 分配和释放的速度:分配和释放内存所需的时间可以是一个重要的因素。一些分配器可能会为了速度而牺牲内存开销,而另一些分配器可能会做相反的事情。

  • 内存开销:开销包括分配器用于簿记或碎片化的额外内存。低开销可能意味着更快的分配器,但可能导致更高的碎片化。相反,高开销的分配器可能较慢,但可能导致较低的碎片化。

  • 内存访问模式:内存的访问方式可以影响缓存性能。确保连续内存分配的分配器可以导致更好的缓存局部性,从而提高性能。

std::allocator 的性能特性

默认的std::allocator旨在为通用情况提供平衡的性能。它是一个多面手,但可能不是特定用例的专家。以下是你可以期待的内容:

  • 通用效率:它在各种场景中表现良好,使其成为许多应用的可靠选择

  • 低开销:虽然开销最小,但内存碎片化风险较大,尤其是在频繁分配和释放不同大小的内存的场景中

  • 一致的行为:由于它是标准库的一部分,其行为和性能在不同平台和编译器之间是一致的

考虑替代分配器的时机

由于std::allocator是一个可靠的通用选择,那么在什么情况下应该考虑替代方案?以下是一些突出的场景:

  • 特定工作负载:如果你知道你的应用程序主要频繁分配小块内存,基于内存池的分配器可能更有效率

  • 实时系统:对于具有可预测性能的系统,针对应用程序需求定制的自定义分配器可以产生影响

  • 硬件限制:如果你在一个具有有限或专用内存的环境中工作,可以设计定制的分配器来适应这些限制

性能分析——做出明智决策的关键

虽然理解分配器性能的理论方面是有益的,但没有实际性能分析是无法替代的。使用不同的分配器测量应用程序的性能是最可靠的方法来确定最佳匹配。例如,Valgrind 或平台特定的分析工具可以提供有关内存使用模式、分配时间和碎片化的见解。

尽管内存管理通常在幕后,但它是高效 C++编程的基石。分配器作为默默无闻的英雄,提供了一种精细调整这一方面的方法。虽然std::vector提供了出色的通用性和性能,但了解分配器的角色和潜力使开发者能够将他们的应用程序提升到新的性能高度。随着我们结束这一章节,请记住,虽然理论提供方向,但性能分析提供清晰度。

在本节中,我们探讨了分配器如何影响std::vector的性能。我们发现分配器选择对容器效率有显著影响,并了解了 C++ STL 中的默认std::allocator,包括在某些情况下替代分配器可能更可取的场景。

这种知识使我们能够根据特定的性能需求定制容器的内存管理,确保我们的应用程序运行得更高效。

概述

在本章中,我们彻底考察了内存管理与std::vector使用之间的关系。我们首先回顾了容量与大小的基本概念,强调了它们各自的作用以及这种区别对于高效内存使用的重要性。然后探讨了std::vector容器内存分配的机制,阐明了当向量增长或缩小时内部发生的情况。

我们讨论了调整大小和预留内存的细微差别,介绍了reserve()shrink_to_fit()等函数作为优化内存使用的工具。这些方法在实际应用中的相关性得到了强调,突出了它们在高性能应用中的实用性。

本章介绍了自定义分配器的基础知识,详细阐述了它们的作用并深入探讨了分配器接口。我们讨论了权衡利弊,并说明了为什么自定义分配器可能比直接使用newdelete和托管指针更可取。为std::vector创建和实现自定义内存池分配器演示了自定义分配器如何释放更大的内存灵活性。

最后,我们分析了分配器对容器性能的影响,详细说明了为什么分配器是性能调优的重要考虑因素。我们涵盖了std::allocator的性能特性,并讨论了何时应考虑使用替代分配器。性能分析被提出作为做出关于分配器使用的明智决策的关键。

本章的见解是无价的,为我们提供了掌握std::vector内存管理的复杂技术。这种知识使我们能够编写高性能的 C++应用程序,因为它允许对内存分配进行细粒度控制,这在内存约束严格或需要快速分配和释放周期的环境中尤为重要。

接下来,我们将关注在向量上操作的计算算法。我们将探索排序技术、搜索操作以及向量内容的操作,强调理解这些算法的效率和多功能性的重要性。我们将讨论使用自定义比较器和谓词以及它们如何被利用来对用户定义的数据类型执行复杂操作。下一章还将提供有关维护容器不变性和管理迭代器失效的指导,这对于确保在多线程场景中的健壮性和正确性至关重要。

第四章:使用 std::vector 掌握算法

在本章中,我们将探讨std::vector与 C++ 标准模板库STL)算法的交互,以释放 C++ STL 的潜力。本章阐述了高效排序、搜索和操作向量的过程,利用头文件中提供的算法。此外,关注 lambda 表达式、自定义比较器和谓词,为可定制、简洁和高效的向量操作铺平了道路。

在本章中,我们将涵盖以下主题:

  • 对向量进行排序

  • 搜索元素

  • 操作向量

  • 自定义比较器和谓词

  • 理解容器不变性和迭代器失效

技术要求

本章中的代码可以在 GitHub 上找到:

github.com/PacktPublishing/Data-Structures-and-Algorithms-with-the-CPP-STL

对向量进行排序

在软件中,组织数据是一个常见的需求。在 C++中,std::vector经常是许多人的首选容器,并且很自然地,人们会希望对其元素进行排序。于是,std::sort算法应运而生,这是来自<algorithm>头文件的一个多功能工具,它将你的std::vector游戏提升到了新的水平。

开始使用 std::sort

std::sort不仅仅适用于向量;它可以对任何顺序容器进行排序。然而,它与std::vector的共生关系特别值得注意。最简单地说,使用std::sort对向量进行排序是一个直接的任务,如下面的代码所示:

std::vector<int> numbers = {5, 1, 2, 4, 3};
std::sort(std::begin(numbers), std::end(numbers));

执行后,numbers将存储{1, 2, 3, 4, 5}。其美在于简单:将向量的起始和结束迭代器传递给std::sort,它就会处理其余部分。

内部引擎——introsort

在 C++ STL 提供的众多算法中,有一个始终因其有效性而突出,那就是std::sort。当与std::vector的动态特性结合时,它成为一股不可阻挡的力量,将你的代码效率推向新的高度。但是什么让它如此出色呢?

要欣赏std::sort背后的天才,首先必须熟悉 introsort 算法。Introsort 不仅仅是一个普通的排序算法。它是一个杰出的混合体,巧妙地融合了三种著名排序算法(快速排序、堆排序和插入排序)的优点。这种组合确保了std::sort能够在各种场景中适应并表现出最佳性能。

虽然我们可以深入探讨算法的复杂性,但对于日常使用来说,真正重要的是这一点:introsort 确保std::sort保持惊人的速度。其底层机制已经经过精炼和优化,以适应各种数据模式。

无与伦比的效率——O(n log n)

对于那些不深入计算机科学术语的人来说,时间复杂度可能听起来像是古老的咒语。然而,它们中蕴含着一种简单的美。当我们说std::sort的平均时间复杂度为O(n log n)时,我们表达了对速度的承诺。

O(n log n)视为一个承诺。即使你的向量增长,扩展到巨大的大小,std::sort也能确保操作的数量不会无控制地爆炸。它找到了一个平衡点,确保排序所需的时间以可管理的速率增长,使其成为即使是最大向量也能信赖的选择。

降序排序

虽然升序是默认行为,但在某些情况下,你可能希望最大的值在前面。C++为你提供了支持。借助std::greater<>(),一个来自<functional>头文件预定义的比较器,你可以按以下代码所示对向量进行降序排序:

std::sort(numbers.begin(), numbers.end(), std::greater<>());

执行后,如果numbers最初有{1, 2, 3, 4, 5},现在将存储{5, 4, 3, 2, 1}

对自定义数据类型进行排序

向量不仅限于原始类型。你可能有自定义对象的向量。为了演示这一点,我们将使用一个例子。我们将使用Person类和一个Person对象的向量。目标是首先按名称(使用内联比较器)然后按年龄(使用 lambda 函数对象作为比较器)对向量进行排序。

让我们看看一个自定义排序的例子:

#include <algorithm>
#include <iostream>
#include <string>
#include <vector>
struct Person {
  std::string name;
  int age{0};
  Person(std::string n, int a) : name(n), age(a) {}
  friend std::ostream &operator<<(std::ostream &os,
                                  const Person &p) {
    os << p.name << " (" << p.age << ")";
    return os;
  }
};
int main() {
  std::vector<Person> people = {Person("Regan", 30),
                                Person("Lisa", 40),
                                Person("Corbin", 45)};
  auto compareByName = [](const Person &a,
                          const Person &b) {
    return a.name < b.name;
  };
  std::sort(people.begin(), people.end(), compareByName);
  std::cout << "Sorted by name:\n";
  for (const auto &p : people) { std::cout << p << "\n"; }
  std::sort(people.begin(), people.end(),
            [](const Person &a, const Person &b) {
              return a.age < b.age;
            });
  std::cout << "\nSorted by age:\n";
  for (const auto &p : people) { std::cout << p << "\n"; }
  return 0;
}

下面是示例输出:

Sorted by name:
Corbin (45)
Lisa (40)
Regan (30)
Sorted by age:
Regan (30)
Lisa (40)
Corbin (45)

在这个例子中,我们做了以下操作:

  • 我们定义一个具有姓名和年龄属性的Person类。

  • 我们还提供了一个内联比较函数(compareByName)来按姓名对Person对象进行排序。

  • 我们然后使用内联比较器对people向量进行排序。

  • 之后,我们使用 lambda 函数作为比较器对people向量按年龄进行排序。

  • 结果被显示出来以验证排序操作是否按预期工作。

陷阱和注意事项

有一种诱惑将std::sort看作是一根魔杖,但请记住,虽然它很强大,但它并非无所不知。算法假设范围(``begin, end)是有效的;传递无效迭代器可能导致未定义的行为。此外,提供的比较器必须建立严格弱排序;否则可能会产生意外的结果。

严格弱排序

术语std::sort。这个概念涉及到用于对集合中的元素进行排序的比较函数。让我们为了清晰起见将其分解:

  • 严格性:这意味着对于任何两个不同的元素ab,比较函数 comp 必须不能同时报告comp(a, b)comp(b, a)为真。用更简单的话说,如果a被认为是小于b的,那么b不能小于a。这确保了排序的一致性。

  • 弱点:在此上下文中,“弱”一词指的是允许等价类。在严格排序(如严格全序)中,两个不同的元素不能是等效的。然而,在严格弱排序中,不同的元素可以被认为是等效的。例如,如果你有一个按年龄排序的人的列表,年龄相同的人即使在他们是不同个体的情况下也属于同一个等价类。

  • 比较的传递性:如果 comp(a, b) 为真且 comp(b, c) 为真,那么 comp(a, c) 也必须为真。这确保了整个元素集合的排序一致性。

  • 等价关系的传递性:如果 a 不小于 bb 不小于 a(意味着它们在排序标准上等效),并且类似地 bc 也等效,那么 ac 也必须被认为是等效的。

提供严格弱排序的比较器允许 std::sort 正确且高效地排序元素。它确保了排序的一致性,允许对等效元素进行分组,并在比较和等效方面尊重逻辑传递性。未能遵守这些规则可能导致排序算法中出现不可预测的行为。

让我们通过一个代码示例来说明文本中提到的概念。我们将展示当向 std::sort 提供无效范围时会发生什么,以及如果比较器没有建立严格的弱排序会发生什么:

#include <algorithm>
#include <iostream>
#include <vector>
int main() {
  std::vector<int> numbers = {3, 1, 4, 1, 5, 9};
  // Let's mistakenly provide an end iterator beyond the
  // actual end of the vector.
  std::vector<int>::iterator invalid = numbers.end() + 1;
  // Uncommenting the following line can lead to undefined
  // behavior due to the invalid range.
  // std::sort(numbers.begin(), invalidEnd);
  // This comparator will return true even when both
  // elements are equal. This violates the strict weak
  // ordering.
  auto badComparator = [](int a, int b) { return a <= b; };
  // Using such a comparator can lead to unexpected
  // results.
  std::sort(numbers.begin(), numbers.end(), badComparator);
  // Displaying the sorted array (might be unexpectedly
  // sorted or cause other issues)
  for (int num : numbers) { std::cout << num << " "; }
  std::cout << "\n";
  return 0;
}

在这个例子中,我们做以下操作:

  • 我们看到错误地提供一个超出向量末尾的结束迭代器如何导致未定义的行为。(出于安全原因,这部分已注释掉。)

  • 我们提供了一个不维护严格弱排序的比较器,因为它在两个数字相等时也返回 true。使用这样的比较器与 std::sort 结合可能会导致意外结果或其他未定义的行为。

通过 std::sort,你拥有一个高效且适应性强的工具。通过理解其默认行为,利用标准比较器的力量,并为独特场景定制比较器,你可以自信且巧妙地处理各种排序任务。随着我们继续本章的学习,请记住这项基础技能,因为我们将进一步深入到 STL 算法和 std::vector 的广阔领域。

在本节中,我们使用 std::sort 算法优化了 std::vector 中的元素排序,解包其 introsort 机制——一个快速排序、堆排序和插入排序的混合体,以确保最佳性能,通常具有 O(n log n) 的复杂度。这种理解对于算法设计中的数据处理效率和高性能应用开发至关重要。

接下来,我们将重点从排序转移到搜索,对比线性搜索和二分搜索技术,以有效地在 std::vector 中找到元素,分析它们在不同用例中的效率。

搜索元素

在集合中查找元素与存储它们一样重要。在 C++ STL 中,有一系列针对搜索的算法。无论 std::vector 是否排序,STL 都提供了一系列函数,可以直接使用经典的线性搜索或更快的二分搜索找到目标。使用 std::vector,这些技术在许多场景中变得不可或缺。

使用 std::find 进行线性搜索

最基本且直观的搜索算法是线性搜索。如果你不确定向量的顺序,或者它只是未排序的,这种方法就会派上用场。

考虑 std::vector<int> numbers = {21, 12, 46, 2};。要找到元素 46 的位置,我们将使用以下代码:

auto it = std::find(numbers.begin(), numbers.end(), 46);

如果元素存在,它将指向其位置;否则,它将指向 numbers.end()。这是一个直接、无装饰的方法,从开始到结束检查每个元素。然而,它所需的时间会随着向量的大小线性增长,这使得它对于大规模数据集来说不太理想。

二分搜索技术

很少有算法搜索策略因其纯粹的美感和效率而脱颖而出,就像 std::vector,二分搜索为我们提供了一堂关于战略思维如何改变我们解决问题的方法的示范课。让我们深入探讨一半的世界,揭示二分搜索背后的 brilliance。

二分搜索基于一个简单而美丽的原则:分而治之。而不是逐个扫描每个元素,二分搜索直接跳到数据集的中心。快速评估确定所需元素位于数据集的前半部分还是后半部分。这种洞察力使它能够排除剩余元素的一半,不断缩小搜索范围,直到找到所需的元素。

为了二分搜索能够发挥作用,有一个不可协商的要求:数据集,或 std::vector,必须在我们这个上下文中排序。这个先决条件至关重要,因为二分搜索的效率依赖于可预测性。每次将搜索空间减半的决定都是基于对元素按特定顺序组织的信心。这种结构化的安排允许算法有信心排除大量数据,从而使搜索非常高效。

使用 std::lower_boundstd::upper_bound

但如果你想要的不仅仅是存在呢?有时,我们试图回答的问题更为复杂:如果这个元素不在向量中,根据当前的排序,它最适合放在哪里?或者,给定一个元素的多个出现,它们是从哪里开始或结束的?C++ STL 提供了两个强大的工具来解决这些查询:std::lower_boundstd::upper_bound

std::lower_bound函数在排序向量领域扮演着关键角色。当遇到一个特定元素时,这个函数会尝试找到这个元素在向量中首次出现的位置,或者它应该正确放置的位置,以确保向量的顺序保持不变。它有效地返回一个迭代器,指向第一个不小于(即大于或等于)指定值的元素。

例如,如果我们的向量包含{1, 3, 3, 5, 7},并且我们使用std::lower_bound来寻找3,函数将指向3的第一个出现位置。然而,如果我们正在寻找4,函数将指示5之前的位置,突出4最适合的位置,同时保持向量的排序特性。

另一方面,std::upper_bound提供了对序列结束的洞察。当给定一个元素时,它确定第一个大于指定值的元素的位置。实际上,如果你有多个元素的出现,std::upper_bound将指向最后一个出现之后的元素。

回到我们的向量{1, 3, 3, 5, 7},如果我们使用std::upper_bound来搜索3,它将引导我们到5之前的位置,展示了3序列的结束。

让我们看看使用std::upper_boundstd::lower_bound与整数std::vector的完整示例。

#include <algorithm>
#include <iostream>
#include <vector>
int main() {
  std::vector<int> numbers = {1, 3, 3, 5, 7};
  int val1 = 3;
  auto low1 = std::lower_bound(numbers.begin(),
                               numbers.end(), val1);
  std::cout << "std::lower_bound for value " << val1
            << ": " << (low1 - numbers.begin()) << "\n";
  int val2 = 4;
  auto low2 = std::lower_bound(numbers.begin(),
                               numbers.end(), val2);
  std::cout << "std::lower_bound for value " << val2
            << ": " << (low2 - numbers.begin()) << "\n";
  int val3 = 3;
  auto up1 = std::upper_bound(numbers.begin(),
                              numbers.end(), val3);
  std::cout << "std::upper_bound for value " << val3
            << ": " << (up1 - numbers.begin()) << "\n";
  return 0;
}

当你运行前面的代码时,对于指定的值将生成以下输出:

std::lower_bound for value 3: 1
std::lower_bound for value 4: 3
std::upper_bound for value 3: 3

以下是对代码示例的解释:

  • 对于std::lower_bound3,它返回一个迭代器,指向3的第一个出现位置,即索引1

  • 对于std::lower_bound4,它指示了4最适合的位置,即在5之前(即索引3)。

  • 对于std::upper_bound3,它指向3的最后一个出现之后的元素,即在5之前(即索引3)。

虽然确认元素的存在无疑是必要的,但当我们提出更详细的问题时,使用std::vector的算法探索的深度才真正显现。结合std::lower_boundstd::upper_bound的能力,我们开始欣赏 STL 支持的数据分析能力。

二分查找与线性查找——效率和多功能性

在算法搜索技术的领域内,二分和线性搜索都成为基本策略。每个都有其独特的优势和理想的应用场景,主要应用于多功能的std::vector。让我们更深入地了解这两种方法的细微差别。

二分查找——条件下的速度高手

二分查找是一种高度有效的方法,以其对数时间复杂度而闻名。这种效率转化为显著的速度,尤其是在处理大型向量时。然而,这种迅速也有一个前提:std::vector必须是有序的。二分查找的本质在于其每次都能消除一半剩余元素的能力,基于元素的顺序进行有根据的猜测。

但如果这种顺序没有得到保持会发生什么?简单地说,结果变得不可预测。如果一个向量没有排序,二分查找可能无法找到即使存在的元素,或者返回不一致的结果。因此,在尝试在std::vector上进行二分查找之前,确保有序性是至关重要的。

线性查找 – 可靠的工作马

相反,线性查找以其直接的方法为特征。它系统地检查向量中的每个元素,直到找到所需的项或得出它不存在的结论。这种简单性是其优势;该方法不需要对元素的排列有任何先前的条件,使其变得灵活且适用于有序和无序向量。

然而,这种逐步检查是有代价的:线性查找具有线性时间复杂度。虽然它可能对较小的向量来说效率很高,但随着向量大小的增加,其性能可能会明显变慢,尤其是在与排序向量的快速二分查找相比时。

搜索是基础,掌握线性和二分技术可以增强你对std::vector的熟练程度。无论你是寻找单个元素,测量有序序列中项的位置,还是找到元素出现的范围,STL 都为你提供了强大而高效的工具来完成这些任务。随着你进一步探索std::vector和 STL,理解这些搜索方法是基石,确保在 C++之旅中没有任何元素被遗漏。

本节提高了我们在std::vector中查找元素的能力,从线性搜索的std::find开始,到使用std::lower_boundstd::upper_bound进行排序数据的二分搜索。与线性搜索不同,我们认识到二分搜索的速度优势,尽管它需要一个预先排序的向量。选择正确的搜索技术对于各种应用中的性能优化至关重要。

接下来,我们将探讨使用如std::copy等方法来更改向量内容,重点关注实际操作技巧以及保持数据结构完整性和性能的关键考虑因素。

操作向量

C++中的向量是动态数组,不仅存储数据,还提供了一系列操作来处理这些数据,尤其是在与 STL 提供的算法结合使用时。这些算法允许开发者以优雅的方式优化数据移动和转换任务。让我们深入探讨使用一些强大的算法来操作std::vector的艺术。

使用 std::copy 进行转换

假设你有一个向量并希望将其元素复制到另一个向量。简单的循环可能出现在你的脑海中,但有一个更高效和更表达性的方法:std::copy

考虑以下代码中的两个向量:

std::vector<int> source = {1, 2, 3, 4, 5};
std::vector<int> destination(5);

复制元素就像以下所示:

std::copy(source.begin(), source.end(), destination.begin());

destination 包含 {1, 2, 3, 4, 5}。值得注意的是,destination 向量应该有足够的空间来容纳复制的元素。

使用 std::reverse 反转元素

经常,你可能需要反转向量的元素。而不是手动交换元素,std::reverse 就会派上用场,如下面的代码所示:

std::vector<int> x = {1, 2, 3, 4, 5};
std::reverse(x.begin(), x.end());

向量数字现在读作 {5, 4, 3, 2, 1}.

使用 std::rotate 旋转向量

另一个用于操作向量的实用算法是 std::rotate,它允许你旋转元素。假设你有一个如下向量的例子:

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

如果你想将其旋转,使 3 成为第一个元素,你将执行以下操作:

std::rotate(values.begin(), values.begin() + 2, values.end());

你的向量 values 现在包含 {3, 4, 5, 1, 2}。这会将元素移动,并围绕向量回绕。

使用 std::fill 填充向量

可能会有一些场景,你希望将所有向量元素重置或初始化为特定值。std::fill 是这个任务的完美工具:

std::vector<int> data(5);
std::fill(data.begin(), data.end(), 42);

现在 data 中的每个元素都是 42

将操作应用于实践

一个音乐流媒体服务希望允许用户以下方式管理他们的播放列表:

  • 年末时,它们有一个独特的功能:用户可以将他们最喜欢的 10 首歌曲移动到播放列表的开头,作为 年度回顾

  • 用户可以反转他们的播放列表,以重新发现他们很久没听过的旧歌,以特定的促销活动。

  • 有时,当用户购买新专辑时,他们喜欢将其曲目插入到当前播放列表的中间,并将旧喜爱的歌曲旋转到末尾,以混合新旧歌曲。

  • 对于春天的全新开始,用户可以用平静和清新的春季主题音乐填充他们的播放列表。

以下代码显示了用户如何管理他们的播放列表:

#include <algorithm>
#include <iostream>
#include <vector>
int main() {
  std::vector<std::string> playlist = {
      "Song A", "Song B", "Song C", "Song D",
      "Song E", "Song F", "Song G", "Song H",
      "Song I", "Song J", "Song K", "Song L"};
  std::rotate(playlist.rbegin(), playlist.rbegin() + 10,
              playlist.rend());
  std::cout << "Year in Review playlist: ";
  for (const auto &song : playlist) {
    std::cout << song << ", ";
  }
  std::cout << "\n";
  std::reverse(playlist.begin(), playlist.end());
  std::cout << "Rediscovery playlist: ";
  for (const auto &song : playlist) {
    std::cout << song << ", ";
  }
  std::cout << "\n";
  std::vector<std::string> newAlbum = {
      "New Song 1", "New Song 2", "New Song 3"};
  playlist.insert(playlist.begin() + playlist.size() / 2,
                  newAlbum.begin(), newAlbum.end());
  std::rotate(playlist.begin() + playlist.size() / 2,
              playlist.end() - newAlbum.size(),
              playlist.end());
  std::cout << "After new album purchase: ";
  for (const auto &song : playlist) {
    std::cout << song << ", ";
  }
  std::cout << "\n";
  std::vector<std::string> springSongs = {
      "Spring 1", "Spring 2", "Spring 3", "Spring 4"};
  if (playlist.size() < springSongs.size()) {
    playlist.resize(springSongs.size());
  }
  std::fill(playlist.begin(),
            playlist.begin() + springSongs.size(),
            "Spring Song");
  std::cout << "Spring Refresh: ";
  for (const auto &song : playlist) {
    std::cout << song << ", ";
  }
  std::cout << "\n";
  return 0;
}

这里是示例输出(已截断):

Year in Review playlist: Song C, Song D, Song E, Song F, Song G, Song H, [...]
Rediscovery playlist: Song B, Song A, Song L, Song K, Song J, Song I, [...]
After new album purchase: Song B, Song A, Song L, Song K, Song J, Song I, [...]
Spring Refresh: Spring Song, Spring Song, Spring Song, Spring Song, Song J, [...]

在这个例子中,我们做以下操作:

  • std::rotate 函数将用户的 10 首最受欢迎的歌曲带到列表开头。

  • std::reverse 函数有助于重新发现旧歌。

  • 用户的新专辑购买展示了 std::rotate 的更实际用途。

  • std::fill 函数用春季主题的歌曲填充播放列表,以迎接新的开始。

操作注意事项

虽然这些函数提供了转换向量的强大和高效方式,但还有一些事情需要注意:

  • 确保目标向量,特别是像 std::copy 这样的函数有足够的空间来容纳数据。如果你不确定大小,使用 std::back_inserter 可能会有所帮助。

  • 例如,std::rotate这样的算法非常高效。它们最小化了元素移动的数量。然而,元素移动的顺序可能一开始并不明显。通过练习不同的场景,将培养出更精确的理解。

  • 函数如std::fillstd::reverse在原地工作,转换原始向量。在应用这些函数或备份之前,始终确保你不需要原始顺序或值。

与 STL 算法配对的向量使开发者能够创建高效、表达性和简洁的操作。无论是复制、旋转、反转还是填充,都有针对该任务的算法。随着你继续使用std::vector,采用这些工具确保你以优雅和速度处理数据,编写出既易于阅读又易于编写的有效代码。

在本节中,我们已经掌握了使用 STL 算法修改std::vector的内容,特别是std::copy,这对于执行安全高效的数据操作至关重要。我们还涵盖了关键考虑因素,例如避免迭代器失效以维护数据完整性和性能。这种专业知识对于 C++开发者来说是无价的,因为在实际应用中,简化复杂数据操作的执行是至关重要的。

在接下来的内容中,我们将深入探讨使用比较器和谓词自定义 STL 算法行为,从而为用户定义的数据类型定义定制的排序和搜索标准。

定制比较器和谓词

当使用std::vector和 STL 算法时,你经常会遇到默认行为不符合需求的情况。有时,两个元素的比较方式或选择元素的准则必须偏离常规。这就是自定义比较器和谓词发挥作用的地方。它们是 C++ STL 强大和灵活性的证明,允许你将逻辑无缝地注入到既定的算法中。

理解比较器

一个bool。它用于指定元素顺序,尤其是在排序或搜索操作中。默认情况下,std::sort等操作使用(<)运算符来比较元素,但通过自定义比较器,你可以重新定义这一点。

想象一个整数的std::vector,你想要按降序排序它们。无需再编写另一个算法,你可以使用带有比较器的std::sort

std::vector<int> numbers = {1, 3, 2, 5, 4};
std::sort(numbers.begin(), numbers.end(), [](int a, int b){
    return a > b;
});

在这个例子中,lambda 表达式充当比较器,反转了通常的小于行为。

谓词的力量

虽然比较器定义了顺序,但谓词有助于做出决策。与比较器一样,谓词也是一个bool。谓词通常与需要根据某些准则进行选择或决策的算法一起使用。

例如,如果你想要计算向量中有多少个偶数,你可以使用如下代码中的std::count_if谓词:

std::vector<int> x = {1, 2, 3, 4, 5};
int evens = std::count_if(x.begin(), x.end(), [](int n){
    return n % 2 == 0;
});

在这里,lambda 谓词检查一个数字是否为偶数,允许std::count_if相应地计数。

构建有效的比较器和谓词

以下是在构建有效的比较器和谓词时需要牢记的最佳实践:

  • 清晰性:确保内部的逻辑清晰。比较器或谓词的目的应该在阅读后显而易见。

  • 无状态性:比较器或谓词应该是无状态的,这意味着它不应该有任何副作用或改变调用之间的行为。

  • 效率:由于比较器和谓词可能在算法中被反复调用,它们应该高效。避免在它们内部进行不必要的计算或调用。

用户定义的结构体和类

虽然 lambda 简洁方便,但定义一个结构体或类允许我们定义更复杂或更适合重用的行为。

考虑一个包含学生姓名和成绩的向量。如果你想按成绩排序,然后按姓名排序,可以使用以下代码:

struct Student {
    std::string name;
    int grade;
};
std::vector<Student> students = { ... };
std::sort(students.begin(), students.end(), [](const Student& a, const Student& b) {
    if(a.grade == b.grade){ return (a.name < b.name); }
    return (a.grade > b.grade);
});

虽然 lambda 方法有效,但对于复杂的逻辑,使用结构体可能更清晰:

struct SortByGradeThenName {
  bool operator()(const Student &first,
                  const Student &second) const {
    if (first.grade == second.grade) {
      return (first.name < second.name);
    }
    return (first.grade > second.grade);
  }
};
std::sort(students.begin(), students.end(), SortByGradeThenName());

自定义比较器和谓词就像给你打开了 STL 引擎室的钥匙。它们允许你利用库的原始力量,但又能精确地满足你的需求。这种精细的控制使得 C++在算法任务和数据处理方面成为一个突出的语言。

本节介绍了自定义比较器和谓词,增强了我们在std::vector中对元素进行排序和过滤的能力。我们学习了如何使用比较器定义排序标准,以及如何使用谓词设置条件,特别是对于用户定义的类型,允许在算法中进行复杂的数据组织。理解和利用这些工具对于开发者来说至关重要,以便在 C++中自定义和优化数据操作。

接下来,我们将探讨容器不变性和迭代器失效,学习如何管理容器稳定性并避免常见的失效问题,这对于确保健壮性,尤其是在多线程环境中至关重要。

理解容器不变性和迭代器失效

在 C++ STL 中,有一个关键的考虑因素经常被许多人忽视:std::vector,其中一个不变量可能是元素存储在连续的内存位置。然而,某些操作可能会破坏这些不变量,导致潜在的陷阱,如迭代器失效。有了这个知识,我们可以编写更健壮和高效的代码。

理解迭代器失效

在没有掌握迭代器失效的情况下,对std::vector的研究是不完整的。迭代器失效就像在有人重新排列了你书中的页面后尝试使用书签一样。你认为你指向了一个位置,但那里的数据可能已经改变或不存在了。

例如,当我们向向量中推送一个元素(push_back)时,如果预留了足够的内存(capacity),则元素可以无障碍地添加。但是,如果由于空间限制,向量需要分配新的内存,它可能会将所有元素重新定位到这个新的内存块。结果,任何指向旧内存块中元素的迭代器、指针或引用现在都将失效。

类似地,其他操作,如inserteraseresize,也可能使迭代器失效。关键是要认识到这些操作何时可能会破坏向量的布局,并准备好处理其后果。

以下是一个代码示例,演示了使用std::vector的迭代器失效以及某些操作如何可能破坏容器的布局:

#include <iostream>
#include <vector>
int main() {
  std::vector<int> numbers = {1, 2, 3, 4, 5};
  std::vector<int>::iterator it = numbers.begin() + 2;
  std::cout << "The element at the iterator before"
               "push_back: "
            << *it << "\n";
  for (int i = 6; i <= 1000; i++) { numbers.push_back(i); }
  std::cout << "The element at the iterator after"
               "push_back: "
            << *it << "\n";
  it = numbers.begin() + 2;
  numbers.insert(it, 99);
  it = numbers.begin() + 3;
  numbers.erase(it);
  return 0;
}

在这个例子中,我们做了以下操作:

  • 我们首先将一个迭代器设置为指向numbers向量的第三个元素。

  • 在向向量中推送许多元素之后,原始内存块可能会重新分配到一个新的内存块,导致迭代器失效。

  • 我们进一步展示了inserterase操作如何使迭代器失效。

  • 强调使用失效的迭代器可能导致未定义的行为,因此,在修改向量后,应始终重新获取迭代器。

在对向量进行修改操作后,始终要小心,因为它们可能会使你的迭代器失效。在这些操作之后重新获取你的迭代器,以确保它们是有效的。

对抗失效的策略

既然我们已经了解了迭代器可能失效的时间,现在是时候揭示绕过或优雅处理这些场景的方法了。

  • reserve方法。这预分配了内存,减少了在添加过程中重新分配和后续迭代器失效的需要。

  • 优先使用位置而非迭代器:考虑存储位置(例如,索引值)而不是存储迭代器。在可能导致迭代器失效的操作之后,你可以轻松地使用位置重新创建一个有效的迭代器。

  • 操作后刷新迭代器:在任何破坏性操作之后,避免使用任何旧的迭代器、指针或引用。相反,获取新的迭代器以确保它们指向正确的元素。

  • <algorithm>头文件提供了许多针对容器(如std::vector)优化的算法。这些算法通常内部处理潜在的失效,保护你的代码免受此类陷阱的影响。

  • 小心使用自定义比较器和谓词:当使用需要比较器或谓词的算法时,确保它们不会以可能导致失效的方式内部修改向量。维护关注点分离的原则。

让我们看看一个集成了避免迭代器失效的关键策略的例子:

#include <algorithm>
#include <iostream>
#include <vector>
int main() {
  std::vector<int> numbers;
  numbers.reserve(1000);
  for (int i = 1; i <= 10; ++i) { numbers.push_back(i); }
  // 0-based index for number 5 in our vector 
  size_t positionOfFive = 4;
  std::cout << "Fifth element: " << numbers[positionOfFive]
            << "\n";
  numbers.insert(numbers.begin() + 5, 99);
  std::vector<int>::iterator it =
      numbers.begin() + positionOfFive;
  std::cout << "Element at the earlier fifth position "
               "after insertion: "
            << *it << "\n";
  // After inserting, refresh the iterator
  it = numbers.begin() + 6;
  std::sort(numbers.begin(), numbers.end());
  // Caution with Custom Comparators and Predicates:
  auto isOdd = [](int num) { return num % 2 != 0; };
  auto countOdd =
      std::count_if(numbers.begin(), numbers.end(), isOdd);
  std::cout << "Number of odd values: " << countOdd
            << "\n";
  // Note: The lambda function 'isOdd' is just a read-only
  // operation and doesn't modify the vector, ensuring we
  // don't have to worry about invalidation.
  return 0;
}

这里是示例输出:

Fifth element: 5
Element at the earlier fifth position after insertion: 5
Number of odd values: 6

这个例子做了以下操作:

  • 展示了如何使用reserve来预分配内存,以预测大小。

  • 显示位置(索引值)而不是迭代器来处理潜在的失效。

  • 在破坏性操作(insert)之后刷新迭代器。

  • 使用<algorithm>头文件(即std::sortstd::count_if),该文件针对容器进行了优化并尊重不变性。

  • 强调了只读操作(通过isOdd lambda)的重要性,以避免可能的失效。(isOdd lambda 函数只是一个只读操作,不会修改向量,确保我们不必担心失效。)

处理多线程场景中的失效

虽然在单线程应用程序中迭代器失效更容易管理,但在多线程环境中事情可能会变得复杂。想象一下,一个线程正在修改一个向量,而另一个线程试图使用迭代器从中读取。混乱!灾难!以下是在多线程场景中处理失效的方法:

  • 使用互斥锁和锁定:使用互斥锁保护修改向量的代码部分。这确保了在任何给定时间只有一个线程可以更改向量,防止可能导致不可预测失效的并发操作。

  • 使用原子操作:某些操作可能是原子的,确保它们在没有中断的情况下完全完成,从而减少未同步访问和修改的可能性。

  • 考虑线程安全的容器:如果你的应用程序以多线程为中心,考虑使用专为处理并发访问和修改而设计的线程安全容器,这样就不会损害不变性。

互斥锁

互斥锁(mutex),即互斥,是一种同步原语,用于并发编程中保护共享资源或代码的关键部分,防止多个线程同时访问。通过在访问共享资源之前锁定互斥锁并在之后解锁,一个线程确保在资源被使用时没有其他线程可以访问该资源,从而防止竞争条件并确保多线程应用程序中的数据一致性。

线程安全容器

线程安全容器指的是一种数据结构,允许多个线程并发访问和修改其内容,而不会导致数据损坏或不一致。这是通过内部机制(如锁定或原子操作)实现的,这些机制确保同步和互斥,从而在多线程环境中保持容器数据的完整性。这种容器在并发编程中对于线程之间安全高效的数据共享至关重要。

让我们看看多线程访问std::vector的实际例子。此示例将演示使用互斥锁来防止并发修改,确保线程安全:

#include <chrono>
#include <iostream>
#include <mutex>
#include <thread>
#include <vector>
std::mutex vecMutex;
void add_to_vector(std::vector<int> &numbers, int value) {
  std::lock_guard<std::mutex> guard(vecMutex);
  numbers.push_back(value);
}
void print_vector(const std::vector<int> &numbers) {
  std::lock_guard<std::mutex> guard(vecMutex);
  for (int num : numbers) { std::cout << num << " "; }
  std::cout << "\n";
}
int main() {
  std::vector<int> numbers;
  std::thread t1(add_to_vector, std::ref(numbers), 1);
  std::thread t2(add_to_vector, std::ref(numbers), 2);
  t1.join();
  t2.join();
  std::thread t3(print_vector, std::ref(numbers));
  t3.join();
  return 0;
}

以下是示例输出:

2 1

此示例说明了以下概念:

  • 我们使用互斥锁(vecMutex)来保护共享的std::vector免受并发访问和修改。

  • add_to_vectorprint_vector函数使用std::lock_guard锁定互斥锁,确保在它们的范围内对向量的独占访问。

  • 我们使用std::thread来运行同时修改或从向量中读取的函数。使用互斥锁确保这些操作是线程安全的。

记住,虽然互斥锁可以防止并发修改,但它们也可能引入潜在的死锁并降低并行性。如果你的应用程序深度集成了多线程,你可能需要考虑其他线程安全的容器或高级同步技术。

理解和尊重容器不变性对于充分利用 STL 容器和<algorithm>头文件的功能至关重要。了解何时以及为什么某些不变性可能会被破坏,可以让我们创建出健壮、高效和可靠的代码。在我们继续探索std::vector之外的算法时,始终牢记这些原则。

在本节中,我们讨论了在容器修改过程中保持std::vector稳定性以及迭代器失效的风险的重要性。我们确定了导致失效的操作及其可能破坏程序完整性的潜在影响。

理解迭代器行为对于防止错误和确保我们应用程序的健壮性至关重要。我们还学习了减轻失效风险的方法,在可能危及向量一致性的操作中保持向量的一致性。

摘要

在整个章节中,我们通过std::vector及其与各种算法的交互,加深了对 STL 的理解。我们从对向量进行排序开始,探讨了std::sort算法及其底层引擎 introsort,并欣赏了其O(n log n)效率。我们进一步到向量中进行搜索,对比了线性搜索和二分搜索技术的条件和效率。

接着,章节引导我们了解有效的向量操作,包括使用std::copy进行转换以及防止性能下降或逻辑错误的必要考虑。我们学习了如何使用自定义比较器和谓词来扩展与用户定义的结构体和类一起使用时的标准算法的功能。最后,我们探讨了容器不变性和迭代器失效,获得了在复杂的多线程环境中保持数据完整性的策略。

重要的是,这些信息为我们提供了如何有效利用std::vector的实际和详细见解。掌握这些算法使开发者能够编写针对各种编程挑战的高效、健壮和适应性强代码。

接下来,我们将把我们的关注点从算法的技术复杂性转移到对为什么std::vector应该成为我们首选容器的大讨论上。我们将比较std::vector与其他容器,深入探讨其内存优势,并反思从数据处理到游戏开发的实际应用案例。这将强调std::vector的通用性和效率,巩固其在安全且强大的默认选择中的地位,同时作为熟练的 C++程序员众多工具之一。

第五章:为 std::vector 辩护

本章通过检查使 std::vector 成为许多开发者首选容器的性能指标和实际应用,讨论了 std::vector 流行背后的原因。通过将 std::vector 与其他容器进行比较,你将清楚地了解其优势,并识别出替代方案可能更适合的场景。这样的见解将使 C++ 开发者能够做出明智的容器选择,从而编写更高效、更有效的代码。

在本章中,我们将涵盖以下与 std::vector 相关的主题:

  • 性能考虑

  • 实际用例

  • 多样性和效率

性能考虑

当在 C++ 中选择数据容器时,性能通常排在考虑因素的首位。自然地,std::vector 的吸引力并不仅仅在于其易用性,而主要在于其效率。在本节中,我们将深入探讨 std::vector 的性能机制,将其与其他 C++ 容器进行比较,并揭示它在哪些方面真正出色。

在其核心,std::vector 是一个动态数组。这意味着其元素存储在连续的内存位置中。这种相邻性质使得 std::vector 在许多场景中具有性能优势,例如以下情况:

  • std::vector 在直接元素访问方面与原始数组一样快。

  • std::vector 通常会导致更好的缓存局部性,这使得数据访问更快,因为缓存未命中更少。

  • std::vector 容器通常是 O(1) 操作。虽然偶尔的调整大小可能会将其变成 O(n) 操作,但平均时间保持不变。

    然而,没有容器是普遍最佳的,std::vector 也有其局限性,具体如下:

  • std::list 因为 std::vector 的缓存友好性。

  • 删除:与插入类似,从除末尾之外的位置删除元素需要移动,这使得它是一个 O(n) 操作。

与其他容器的比较

  • std::list:这是一个双向链表,这意味着在任何位置进行插入和删除都是 O(1)。然而,它缺乏 std::vector 的缓存局部性,使得元素访问变慢。在列表中的随机访问是一个 O(n) 操作,而在向量中是 O(1)

  • std::deque:一个支持在两端进行高效插入和删除的双端队列。虽然它提供了与 std::vector 相似的随机访问时间,但其非连续性可能在某些操作期间导致更多的缓存未命中。

  • std::array:一个具有固定大小的静态数组。它为直接访问提供了与 std::vector 相似的性能特征,但缺乏动态调整大小。

那么,在什么情况下你应该选择std::vector而不是其他容器呢?如果你的主要操作是随机访问以及在末尾插入/删除,由于std::vectorO(1)复杂性和优秀的缓存性能,它通常是最佳选择。然而,如果你经常在中间插入或删除,其他容器如std::list可能对大数据集来说更有效率。始终测量你特定用例的性能,以指导你的决策。

记忆优势

std::vector高效地管理其内存。随着你添加元素,它会智能地调整大小,通常将其容量加倍以最小化分配次数。这种动态调整大小确保了内存得到最优使用,同时分配的开销最小,从而加快操作速度。

经验总结

性能不仅仅是关于原始速度;它关于选择适合正确工作的工具。虽然std::vector在许多场景下提供出色的性能,但了解其优势和劣势至关重要。当你将问题的需求与std::vector的内在优势相匹配时,你不仅编写代码——你创造优化解决方案,以应对现代计算的需求。

在接下来的章节中,我们将探讨std::vector在现实世界中的应用的实用性,并深入了解其多功能性,为你提供利用其全部功能所需的知识。

实际应用案例

虽然理解std::vector的理论和性能优势是必要的,但通常在实际应用中,一个工具的优势才会变得明显。随着我们深入实际用例,你将看到为什么std::vector经常是许多开发者的首选容器,有时为什么其他选项可能更合适。

核心是可调整大小的动态数组

想象一下开发一个模拟程序,该程序模拟一个容器中粒子的行为。粒子的数量可能会因为分裂或合并而有很大变化。在这里,由于std::vector的动态特性,使用std::vector将是理想的。程序将受益于对粒子更新的常数时间直接访问,并且其调整大小能力可以轻松处理变化的粒子数量。

数据处理和分析

数据分析通常涉及读取大量数据集,处理它们,并提取信息。考虑一个场景,你被要求读取一整年的传感器温度。数据量庞大,但一旦读取,它将按顺序进行处理——计算平均值、检测峰值等。std::vector由于其连续的内存和优秀的缓存局部性,成为首选,允许对如此庞大的数据集进行更快的顺序处理。

图形和游戏开发

在游戏开发中,可以使用 std::vector 来表示子弹、敌人以及物品等对象。例如,在射击游戏中发射的子弹可以存储在 std::vector 中。随着子弹的移动或被销毁,向量会自动调整大小。std::vector 的直接访问能力使得对每个子弹位置的更新变得高效。

不仅仅是容器

容器的选择也取决于应用的更广泛架构。例如,在分布式系统中,数据可能更适合用优化序列化和反序列化的结构来表示,即使在一个节点内,std::vector 可能看起来是最好的选择。

总之,std::vector 在实际应用中的效用不容小觑。其动态特性和直接访问以及缓存友好设计的优势使其成为一股强大的力量。然而,就像所有工具一样,其有效性最好是在与正确任务匹配时才能得到体现。知道何时使用 std::vector 以及何时考虑替代方案是对开发者理解和适应能力的证明。随着我们继续探索 std::vector 的多功能性和效率,你将更深入地了解这个非凡容器的世界。

多功能性及效率

C++ 的 std::vector 独具特色,常常成为许多 C++ 开发者的默认选择。它的广泛接受并非偶然,而是其多功能性和效率的结果。

多功能性的证明

std::vector 的基本设计使其能够满足许多编程需求。它是一个可以增长或缩小的动态数组,提供了两全其美的特性:数组的直接访问和链表的灵活性。这意味着无论你是临时存储数据、操作大型数据集,还是仅仅将其用作缓冲区,std::vector 都能优雅地适应。

对于许多应用来说,尤其是那些不受特定复杂性限制的应用,开发者首先会想到的是 std::vector。这不仅仅是因为传统或熟悉,而是因为在绝大多数情况下,std::vector 都能胜任工作,并且做得很好。

效率不仅仅是关于速度

虽然我们已经深入探讨了性能方面,但值得注意的是,效率并不仅仅是关于原始速度。std::vector 的连续内存布局提供了缓存友好性,简化了内存管理,减少了碎片化。它在增长方面的可预测行为确保了最小化的意外开销。

此外,它简单易用的接口,与其他许多 STL 容器类似,降低了学习曲线。开发者可以轻松地从其他容器或数组切换到 std::vector。易用性和其强大的功能使 std::vector 成为提高开发者生产力的工具。

安全的默认选项,但并非唯一选择

成熟的开发者的一大标志是了解他们可用的工具,并选择适合工作的正确工具。std::vector 是一个不可思议的工具,它足够灵活,可以成为许多场景下的安全默认选择。它的直接访问、动态大小和缓存局部性优势使其成为一款通用型强大工具。

然而,这并不意味着它总是正确的选择。在某些情况下,std::dequestd::list 或可能是 std::set 可能更适合。但 std::vector 区别于其他容器的在于,当您不确定从哪个容器开始时,通常从 std::vector 开始是一个安全的赌注。随着开发进程的推进和需求变得更加明显,如果需要,过渡到另一个更专业的容器,这成为一个战略决策而不是必需的选择。

总结

std::vector 以多种方式体现了 C++ 的精神。它代表了性能和灵活性的平衡,是对语言不牺牲效率以实现高级抽象的伦理的证明。

在结束本章时,很明显 std::vector 不仅仅是 STL 中的另一个容器。它是基石。到现在,您应该欣赏它在 C++ 中的重要性,并对自己利用其能力充满信心。随着您进一步深入 C++ 开发,让本书这一部分的教训指导您的容器选择,在适当的时候利用 std::vector 的优势,并在需要时转向其他 STL 提供的选项。

本书第二部分将探讨所有 STL 数据结构。在掌握了第一部分的知识后,您可以比较和对比 std::vector 和其众多替代品。

第二部分:理解 STL 数据结构

本书这一部分是对 STL 数据结构丰富世界的详细参考。我们从顺序容器开始——std::arraystd::vectorstd::dequestd::liststd::forward_liststd::string——为您提供对这些容器设计、使用和性能细微差别的深入理解。每个容器的目的和适用性都会被评估,同时还会讨论它们的理想用例和性能特征。您将了解内存管理和线程安全的高级要点,以及如何有效地与 STL 算法交互。

我们接着关注有序和无序关联容器——std::setstd::mapstd::multisetstd::multimap 以及它们的无序对应物。探索继续到容器适配器,如 std::stackstd::queuestd::priority_queue,详细说明它们的用例和性能见解。我们还介绍了新的添加项,如 std::flat_setstd::flat_map,它们在序列和关联容器之间提供了平衡。

std::spanstd::mdspan等容器视图作为总结,本部分为你提供了选择和操作最适合你数据结构挑战的 STL 容器所需的知识,同时采用最佳实践并理解异常和定制。

由于本部分章节是一系列参考章节,它们的结构略有不同,没有总结部分。

到本部分结束时,你将理解 STL 容器的全部功能,并能够熟练地将它们应用于创建高效且有效的 C++应用程序。

本部分包含以下章节:

  • 第六章**:高级序列容器使用

  • 第七章**:高级有序关联容器使用

  • 第八章**:高级无序关联容器使用

  • 第九章**:容器适配器

  • 第十章**:容器视图

第六章:高级序列容器使用

序列容器是 C++数据处理的核心,提供线性存储数据的结构。对于中级开发者来说,从包括向量、数组、deque 和列表在内的序列容器数组中选择正确的选项可能是至关重要的。本章将详细分析每种容器类型,强调它们的独特优势和理想使用场景。此外,深入了解最佳实践——从高效的调整大小到迭代器管理——将确保开发者选择正确的容器并有效地使用它。掌握这些细微差别将提高代码在实际应用中的效率、可读性和可维护性。

在庞大的 C++ 标准模板库STL)中,序列容器占据着显赫的位置。这不仅是因为它们通常是开发者首选的数据结构,也因为每个容器提供的独特和多功能解决方案。正如其名称所暗示的,这些容器按顺序维护元素。但当我们深入了解时,我们会发现相似之处通常到此为止。每个序列容器都带来其优势,并针对特定场景进行定制。

在本章中,我们将涵盖以下主要主题:

  • std::array

  • std::vector

  • std::deque

  • std::list

  • std::forward_list

  • std::string

技术要求

本章中的代码可以在 GitHub 上找到:

github.com/PacktPublishing/Data-Structures-and-Algorithms-with-the-CPP-STL

std::array

std::array是一个固定大小的容器,它围绕传统的 C 风格数组。如果你来自 C 背景,甚至早期的 C++,你将熟悉原始数组的烦恼——缺乏边界检查、繁琐的语法等等。使用std::array,你可以获得传统数组的所有好处,如静态内存分配和常数时间访问,同时享受现代 C++的便利,包括基于范围的 for 循环和用于大小检查的成员函数。当你事先知道数据集的大小且不会改变时,使用std::array是完美的。在性能至关重要且内存需求静态的场景中,它非常适用。

注意

关于 C++核心指南的更多信息,请参阅C++核心 指南 isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines

目的和适用性

std::array是一个封装固定大小数组的容器。其优势如下:

  • 可预测的固定大小

  • 栈分配,提供快速访问和最小开销

在以下情况下,最好选择std::array

  • 数组的长度在编译时已知。

  • 最小化开销和性能可预测性至关重要。

对于动态大小需求,请考虑使用std::vector

理想使用场景

以下是一些std::array的理想使用场景:

  • std::array是首选选择。这使得它在维度大小预定义的情况下非常适合,例如在某些数学运算或游戏板表示中。

  • std::array不涉及动态内存分配,这可以有利于实时或性能关键的应用。

  • std::array提供了边界检查(通过at()成员函数),提供了比 C 风格数组更安全的替代方案,尤其是在处理潜在的越界访问时。

  • std::array保留了大小信息,这使得编写更安全、更直观的函数变得更容易。

  • std::array可以无缝地与 C 风格数组一起使用,使其成为具有 C 和 C++集成的项目的绝佳选择。

  • std::array是最佳选择。

  • std::vectorstd::array提供连续的内存存储,这使得它们在迭代时对缓存友好。

  • std::array确保没有意外的内存分配。

  • std::array提供了方便的初始化语义。

然而,尽管std::array在传统数组之上提供了几个优势,但必须注意,它并不适合需要动态调整大小的场景。对于这些用例,可以考虑std::vector或其他动态容器。

性能

std::array的算法性能如下所述:

  • 插入:由于大小固定,不适用

  • 删除:不适用

  • 访问:任何位置的常数O(1)

  • 内存开销:由于栈分配,最小化。

  • 权衡:固定大小的效率是以静态大小为代价的。

内存管理

std::vector不同,std::array不会动态分配内存。它是栈分配的,因此没有意外的分配行为或惊喜。

线程安全

你是否在多线程中读取?完全可以。然而,并发写入同一元素需要同步。

扩展和变体

对于动态需求,std::vector是 STL 的主要替代方案。其他固定大小数组选项包括传统的 C 风格数组。

排序和搜索复杂度

  • std::sort()

  • std::binary_search()

接口和成员函数

存在标准函数,如begin()end()size()。值得注意的成员函数如下:

  • fill:将所有元素设置为某个值

  • swap:与相同类型和大小的另一个数组交换内容

比较操作

std::vector相比,std::array不进行大小调整,但提供可预测的性能。在做出选择时,权衡动态大小需求与性能一致性。

与算法的交互

STL 算法由于随机访问能力而很好地与std::array协同工作。然而,那些期望动态大小的将无法与std::array一起工作。

异常

使用std::array时,越界访问(如使用at())可以抛出异常,主要是std::out_of_range

定制化

虽然不能调整大小,但可以集成自定义类型。鉴于容器栈分配的特性,确保它们高效可移动/可复制。

示例

在本例中,我们将展示以下最佳实践和 std::array 的使用:

  • 使用固定大小的 std::array

  • 使用 C++ 结构化绑定与 std::array 解构元素

  • 使用 std::array 实现编译时计算(归功于其 constexpr 特性)

  • 使用 std::sortstd::find 等算法与 std::array

结构化绑定

C++17 中引入的结构化绑定,允许方便且易于阅读地从元组、对或类似结构体对象中解包元素到单独命名的变量中。这种语法简化了访问从函数返回的多个元素或分解复杂数据结构的内容,增强了代码的清晰度并减少了冗余。

下面是讨论上述观点的代码示例:

#include <algorithm>
#include <array>
#include <iostream>
struct Point {
  int x{0}, y{0};
};
constexpr int sumArray(const std::array<int, 5> &arr) {
  int sum = 0;
  for (const auto &val : arr) { sum += val; }
  return sum;
}
int main() {
  std::array<int, 5> numbers = {5, 3, 8, 1, 4};
  std::array<Point, 3> points = {{{1, 2}, {3, 4}, {5, 6}}};
  // Demonstrating structured bindings with &[x, y]
  for (const auto &[x, y] : points) {
    std::cout << "(" << x << ", " << y << ")\n";
  }
  constexpr std::array<int, 5> constNumbers = {1, 2, 3, 4,
                                               5};
  constexpr int totalSum = sumArray(constNumbers);
  std::cout << "\nCompile-time sum of array elements: "
            << totalSum << "\n";
  std::sort(numbers.begin(), numbers.end());
  std::cout << "\nSorted numbers: ";
  for (const auto &num : numbers) {
    std::cout << num << " ";
  }
  std::cout << "\n";
  int searchFor = 3;
  if (std::find(numbers.begin(), numbers.end(),
                searchFor) != numbers.end()) {
    std::cout << "\nFound " << searchFor
              << " in the array.\n";
  } else {
    std::cout << "\nDidn't find " << searchFor
              << " in the array.\n";
  }
  return 0;
}

此示例突出了 std::array 的特性和优势,包括其固定大小特性、与结构化绑定等现代 C++ 功能的兼容性,以及在编译时计算中的实用性。前面的示例还说明了如何无缝地将 STL 算法应用于 std::array

最佳实践

让我们探索使用 std::array 的最佳实践:

  • std::array 封装了 C 风格数组的可预测性,并为其增加了额外的功能。其固定大小在数据大小已确定的情况下特别有用,使其成为此类应用的优选。

  • std::array 提供了一系列成员函数,提高了其功能。这使得它在当代 C++ 开发中相对于其传统对应物更具吸引力。

  • .at() 成员函数非常有价值。它通过在越界时抛出异常来防止越界访问。

  • std::array 既是其优势也是其限制。它承诺常数时间访问,但在调整大小方面缺乏灵活性。因此,在声明时精确指定所需的大小对于防止潜在问题至关重要。

  • std::array 采用这种循环结构最小化了边界错误的概率,促进了代码的稳定性。

  • std::array 可以容纳多种类型,考虑到效率至关重要。如果类型,无论是原始类型还是用户定义类型,特别大或复杂,确保其移动或复制操作已优化,以在数组操作期间保持性能。

  • std::array 在需要固定大小容器的场景中表现出色。然而,对于预期动态调整大小或大量数据的适用,std::vector 等替代方案可能提供更灵活的解决方案。

std::vector

std::vector 是一个动态数组。它根据需要增长和缩小,在直接访问性能和大小灵活性之间提供了良好的平衡。std::vector 具有缓存友好的连续内存布局,在末尾提供摊销常数时间的插入,使其成为一个优秀的通用容器。当你的主要操作是索引和需要动态调整大小,但中间没有频繁的插入或删除时,性能最佳。

目的和适用性

std::vector 在 STL 中本质上是一个动态数组。它的主要优势在于以下方面:

  • 提供常数时间的随机访问

  • 元素插入或删除时动态调整大小

当以下要求时,它尤其适合:

  • 随机访问至关重要。

  • 插入或删除主要在序列的末尾进行。

  • 缓存局部性至关重要。

当常数时间访问、性能或缓存友好性比其他关注点更重要时,选择 std::vector

理想用例

以下是一些 std::vector 的理想用例:

  • std::vector 是你的最佳选择。与标准数组不同,向量会自动管理其大小并无缝处理内存分配/释放。

  • std::vector 提供了对任何元素的常数时间访问,使其适合频繁访问或修改特定索引的数据。

  • std::vector 提供了适合此目的的连续内存块。

  • std::vector 对其末尾的插入进行了优化,因此对于日志系统等新条目持续添加的应用程序来说是一个不错的选择。

  • 由于其连续内存块和缺乏结构开销,std::vector 提供了存储数据最内存高效的方式,与基于链表的容器不同。

  • std::vector 是缓存友好的,与不连续的数据结构相比,在许多场景中性能更快。

  • std::vector 可以轻松地进行流式传输或写入。

  • 为了实现这个目的,std::vector 可以有效地实现一个栈数据结构,其中元素只从末尾添加或删除。

  • std::vector 为此提供了一个高效且动态的容器。

然而,在使用 std::vector 时有一些注意事项。如果需要在中间频繁插入或删除,由于需要移动元素,std::vector 可能不是最有效率的选项。另外,如果你经常推送元素,使用 reserve() 预分配内存并避免频繁重新分配是一个好习惯。

性能

std::vector 的算法性能特点如下:

  • 插入: 末尾的平均情况为 O(1),其他位置为 O(n)

  • 删除: 末尾为 O(1),中间为 O(n)

  • 访问: 任何位置的快速 O(1)

  • 内存开销: 通常较低,但如果未管理预留容量,可能会膨胀

  • 权衡: O(1) 访问的便利性被在开始或中间插入的潜在 O(n) 成本所抵消。

内存管理

std::vector 自动管理其内存。如果其容量耗尽,它通常会将其大小加倍,尽管这并不是强制性的。分配器可以影响这种行为,允许细粒度控制。

线程安全

并发读取?没问题。但是写入,或者读取和写入的混合,需要外部同步。考虑互斥锁或其他并发工具。

扩展和变体

虽然 std::vector 是一个动态数组,但 STL 提供了其他序列容器,如 std::deque,它提供了在两端快速插入的 API 或 std::list,可能优化中间插入和删除。

排序和搜索复杂度

排序和搜索复杂度如下所述:

  • std::sort()

  • std::binary_search()

特殊接口和成员函数

除了常规操作(如 push_backpop_backbeginend)之外,熟悉以下内容:

  • emplace_back: 直接构造元素

  • resize: 改变元素数量

  • shrink_to_fit: 减少内存使用

比较

std::liststd::deque 相比,std::vector 在随机访问方面表现出色,但在频繁修改非常大的数据类型的中间部分时可能会失败。

与算法的交互

许多 STL 算法与 std::vector 的随机访问特性非常和谐。然而,需要频繁重新排序的算法可能更适合与其他容器搭配。

异常

超出容量或访问越界索引可能会抛出异常。值得注意的是,操作是异常安全的,即使在操作(如插入)抛出异常的情况下,也能保留向量的状态。

定制化

使用自定义分配器时,调整内存分配策略。然而,std::vector 并不支持自定义比较器或哈希函数。

示例

在这个例子中,我们将展示以下最佳实践和 std::vector 的使用:

  • 使用 reserve 预分配内存

  • 使用 emplace_back 进行高效插入

  • 使用迭代器进行遍历和修改

  • 使用自定义对象与 std::vector 结合

  • 使用 std::remove 等算法与 std::vector 结合

下面是代码示例:

#include <algorithm>
#include <iostream>
#include <vector>
class Employee {
public:
  Employee(int _id, const std::string &_name)
      : id(_id), name(_name) {}
  int getId() const { return id; }
  const std::string &getName() const { return name; }
  void setName(const std::string &newName) {
    name = newName;
  }
private:
  int id{0};
  std::string name;
};
int main() {
  std::vector<Employee> employees;
  employees.reserve(5);
  employees.emplace_back(1, "Lisa");
  employees.emplace_back(2, "Corbin");
  employees.emplace_back(3, "Aaron");
  employees.emplace_back(4, "Amanda");
  employees.emplace_back(5, "Regan");
  for (const auto &emp : employees) {
    std::cout << "ID: " << emp.getId()
              << ", Name: " << emp.getName() << "\n";
  }
  auto it = std::find_if(
      employees.begin(), employees.end(),
      [](const Employee &e) { return e.getId() == 3; });
  if (it != employees.end()) { it->setName("Chuck"); }
  std::cout << "\nAfter Modification:\n";
  for (const auto &emp : employees) {
    std::cout << "ID: " << emp.getId()
              << ", Name: " << emp.getName() << "\n";
  }
  employees.erase(std::remove_if(employees.begin(),
                                 employees.end(),
                                 [](const Employee &e) {
                                   return e.getId() == 2;
                                 }),
                  employees.end());
  std::cout << "\nAfter Removal:\n";
  for (const auto &emp : employees) {
    std::cout << "ID: " << emp.getId()
              << ", Name: " << emp.getName() << "\n";
  }
  return 0;
}

上述示例展示了 std::vector 与 C++ STL 算法结合的效率和灵活性。它展示了以各种方式管理和操作 Employee 对象列表。

现在,让我们看看一个 std::vector<bool> 的示例。

std::vector<bool> 是 C++ 标准库中的一个有些争议的特殊化。它被设计为每个布尔值只使用一个比特位,从而节省空间。然而,这种空间优化导致了几个意想不到的行为和怪癖,尤其是在与其他类型的 std::vector 相比时。

由于这些原因,许多专家建议在使用 std::vector<bool> 时要谨慎。尽管如此,如果仍然希望使用它,以下是一个展示其使用和一些怪癖的典型示例:

#include <iostream>
#include <vector>
int main() {
  std::vector<bool> boolVec = {true, false, true, true,
                               false};
  boolVec[1] = true;
  std::cout << "Second element: " << boolVec[1] << '\n';
  auto ref = boolVec[1];
  ref = false;
  std::cout << "Second element after modifying copy: "
            << boolVec[1] << '\n';
  // Iterating over the vector
  for (bool val : boolVec) { std::cout << val << ' '; }
  std::cout << '\n';
  // Pushing values
  boolVec.push_back(false);
  // Resizing
  boolVec.resize(10, true);
  // Capacity and size
  std::cout << "Size: " << boolVec.size()
            << ", Capacity: " << boolVec.capacity()
            << '\n';
  // Clearing the vector
  boolVec.clear();
  return 0;
}

上述代码的关键要点如下:

  • std::vector<bool> 通过将布尔值存储为单独的位来节省内存。

  • 当从 std::vector<bool> 访问元素时,你不会得到与其他向量类型相同的普通引用。相反,你得到一个代理对象。这就是为什么在示例中修改 ref 并不会改变向量中的实际值。

  • 其他操作,如迭代、调整大小和容量检查,与其他 std::vector 类型的工作方式类似。

对于许多应用,std::vector<bool> 的特殊性可能超过其内存节省的好处。如果内存优化不是至关重要的,并且行为上的怪癖可能成为问题,请考虑使用其他容器,如 std::deque<bool>std::bitset 或第三方位集/向量库。

最佳实践

让我们探索使用 std::vector 的最佳实践:

  • std::vector<bool> 不仅仅是一个简单的布尔值向量。它被专门化以节省空间,这种空间效率是以牺牲为代价的:元素不是真正的布尔值,而是位字段代理。这种专门化可能导致某些操作中独特的表现,因此完全理解其复杂性至关重要。

  • std::vector 的动态调整大小能力。虽然这很强大,但使用 reserve 函数预测和引导这种调整可能会有所帮助。预分配内存有助于最小化重新分配,并确保高效性能。

  • push_back 是一个常用的方法来添加元素,emplace_back 提供了一种更高效的方法直接在向量中构造对象。就地构造对象通常可以增强性能,尤其是在复杂对象中。

  • std::vector 提供了优秀的随机访问性能。然而,由于需要移动后续元素,中间的操作,如插入或删除,可能会更加耗时。对于需要频繁中间操作的任务,考虑替代的 STL 容器是值得的。

  • .at() 成员函数另一方面,提供带边界检查的访问,如果使用无效索引,将抛出 std::out_of_range 异常。

  • std::vector。虽然 std::vector 本身不是线程安全的,但可以使用适当的同步工具,如互斥锁,来实现线程安全。

  • std::vector 并未针对频繁的中间插入或删除进行优化。然而,其缓存友好性和快速搜索的能力可能仍然意味着它是最佳的数据类型。将其用作链表可能不是最优的,但仅适用于特定的用例(可能是非常大的数据类型或非常大的数据集)。对于这种模式,容器如 std::list 可能更适合。然而,永远不要假设 std::list 仅因为需要频繁的插入和删除就会表现更好。

  • std::map,其中向量可能是值,不要陷入自动更新的陷阱。显式管理和更新这些嵌套容器是至关重要的。

std::deque

std::deque 是一个双端队列。表面上,它看起来像 std::vector,在两端进行插入和删除操作更好。虽然这是真的,但请记住,这种灵活性是以略微复杂的内部结构为代价的。如果你的应用程序需要在两端进行快速插入和删除,但不需要 std::vector 的紧凑内存布局,那么 std::deque 就是你的首选容器。

目的和适用性

std::deque 是一个提供快速在两端进行插入和删除操作的容器。其主要优势如下:

  • 两端高效的 O(1) 插入和删除

  • 动态大小,无需手动内存管理

  • 前端和后端操作具有相当好的缓存性能

std::deque 在以下方面表现出色:

  • 你需要随机访问能力,但预计两端将频繁修改。

  • 你需要一个动态大小的容器,但不想有 std::list 的内存开销。

如果只需要端修改,std::vector 可能是一个更好的选择。

理想用例

以下是一些 std::deque 的理想用例:

  • std::vector 主要允许在末尾快速插入,而 std::deque 支持在两端快速插入和删除,使其非常适合需要两端操作的场景。

  • std::deque 可以作为队列(FIFO 数据结构)和栈(LIFO 数据结构)。在这方面,它非常灵活,不像其他专注于一个或另一个的容器。

  • std::vectorstd::deque 提供了对元素的常数时间随机访问,这使得它们适合需要通过索引访问元素的应用程序。

  • std::vector 只向一个方向增长,而 std::deque 可以向两个方向增长。这使得它在数据集可能在两端不可预测地扩展的情况下特别有用。

  • std::deque 可以在从前端(播放)消耗数据时有所帮助。新的数据可以在后端缓冲,而无需重新整理整个数据集。

  • std::deque 提供了一个高效的解决方案。

  • std::deque 可以轻松地适应其他自定义数据结构。例如,一个平衡树或特定类型的优先队列可能会利用 std::deque 的功能。

  • std::deque 可以有效地处理添加新操作和自动删除最旧的项。

考虑到 std::deque 时,必须权衡其双端和随机访问特性的好处与相对于 std::vector 的略高每元素开销。在其他只有单侧增长的场景中,其他数据结构可能更节省空间。

性能

std::deque 的算法性能如下:

  • 插入: 前端和后端都是 O(1);中间是 O(n)

  • 删除: 前端和后端都是 O(1);中间是 O(n)

  • 访问: 随机访问保持一致的 O(1)

  • 由于分段内存,std::vector

内存管理

std::deque 使用分段分配,这意味着它根据需要分配内存块。与 std::vector 不同,它不会加倍其大小;因此,没有过度的内存开销。自定义分配器可以影响内存分配策略。

线程安全

并发读取是安全的。但像大多数 STL 容器一样,同时写入或读写混合需要外部同步机制,如互斥锁。

扩展和变体

在性能和内存特性方面,std::deque 位于 std::vectorstd::list 之间。然而,它独特地提供了两端快速操作。

排序和搜索的复杂度

排序和搜索的复杂度如下:

  • std::sort()

  • std::binary_search()

接口和成员函数

除了熟悉的(push_backpush_frontpop_backpop_frontbeginend)之外,熟悉以下内容:

  • emplace_frontemplace_back:在各自的端进行就地构造

  • resize:调整容器大小,根据需要扩展或截断

比较

std::vector 相比,std::deque 提供了更好的前端操作。与 std::list 相比,它提供了更好的随机访问,但在中间插入/删除方面可能表现不佳。与 std::vector 相比,std::deque 的非连续存储可能在迭代元素时成为劣势,因为缓存性能较差。

与算法的交互

由于 std::deque 的随机访问特性,它可以有效地利用大多数 STL 算法。特别适合 std::deque 的算法是那些需要快速端修改的算法。

异常

超出大小或访问越界索引可能导致异常。如果插入等操作抛出异常,容器保持完整,确保异常安全。

定制化

std::deque 可以与自定义分配器一起使用,以定制内存分配行为,但它不支持自定义比较器或哈希函数。

示例

在本例中,我们将展示以下最佳实践和 std::deque 的使用:

  • 使用 std::deque 维护元素列表,利用其动态大小

  • 在前后两端插入元素

  • 从前后两端高效移除元素

  • 使用 std::deque 作为滑动窗口以分块处理元素

  • 应用 STL 算法,如 std::transform

下面是代码示例:

#include <algorithm>
#include <deque>
#include <iostream>
// A function to demonstrate using a deque as a sliding
// window over data.
void processInSlidingWindow(const std::deque<int> &data,
                            size_t windowSize) {
  for (size_t i = 0; i <= data.size() - windowSize; ++i) {
    int sum = 0;
    for (size_t j = i; j < i + windowSize; ++j) {
      sum += data[j];
    }
    std::cout << "Average of window starting at index "
              << i << ": "
              << static_cast<double>(sum) / windowSize
              << "\n";
  }
}
int main() {
  std::deque<int> numbers;
  for (int i = 1; i <= 5; ++i) {
    numbers.push_back(i * 10);   // 10, 20, ..., 50
    numbers.push_front(-i * 10); // -10, -20, ..., -50
  }
  std::cout << "Numbers in deque: ";
  for (const auto &num : numbers) {
    std::cout << num << " ";
  }
  std::cout << "\n";
  numbers.pop_front();
  numbers.pop_back();
  std::cout << "After removing front and back: ";
  for (const auto &num : numbers) {
    std::cout << num << " ";
  }
  std::cout << "\n";
  processInSlidingWindow(numbers, 3);
  std::transform(numbers.begin(), numbers.end(),
                 numbers.begin(),
                 [](int n) { return n * 2; });
  std::cout << "After doubling each element: ";
  for (const auto &num : numbers) {
    std::cout << num << " ";
  }
  std::cout << "\n";
  return 0;
}

在前面的示例中,我们执行以下操作:

  • 我们通过向容器的开始和结束添加元素来展示 std::deque 的动态特性。

  • 我们展示了 pop_front()pop_back() 的有效操作。

  • 滑动窗口函数以分块处理元素,利用 std::deque 的随机访问特性。

  • 最后,我们使用 std::transform 算法来操作数据。

最佳实践

让我们探讨使用 std::deque 的最佳实践:

  • std::deque是其分段内存。这有时会导致与std::vector的连续内存布局相比,性能差异更难以预测的细微差别。

  • 当涉及到内存行为时,std::dequestd::vector。这两个有不同的架构,导致在特定场景中性能各异。

  • std::deque在两端提供快速的插入和删除操作,但在中间不行。如果中间操作被认为是瓶颈,考虑其他容器,如std::vectorstd::list

  • std::deque的核心优势在于两端都提供常数时间操作。如果你主要只使用一端,std::vector可能提供更好的性能。即使有这个优势,也不要假设std::deque的性能会优于std::vector。你可能发现,std::vector的连续存储和缓存友好性甚至允许它在头部插入时超越std::deque

  • std::deque不保证连续内存,当处理需要原始数组的 API 或库时可能会带来挑战。始终要意识到这种区别。

  • 在添加元素时使用emplace_frontemplace_back。这些函数直接在 deque 中构建元素,优化内存使用和性能。

  • 当前端和后端操作频繁且可接受的性能损失时,std::deque是合适的。其架构针对这些操作进行了优化,提供一致的性能。

  • std::deque,始终确保你处于其大小边界内,以防止未定义的行为。

  • std::deque,确保使用适当的同步机制,如互斥锁或锁,以确保数据完整性和防止竞态条件。

std::list

std::list是一个双向链表。与之前的容器不同,它不连续存储其元素。这意味着你失去了缓存友好性,但获得了巨大的灵活性。只要你有指向位置的迭代器,无论位置如何,插入和删除操作都是常数时间操作。然而,访问时间是线性的,这使得它不太适合频繁进行随机访问的任务。std::list最适合那些数据集频繁在中间和两端进行插入和删除操作,且直接访问不是优先级的场景。

目的和适用性

std::list是 STL 提供的双向链表。其优势包括以下:

  • 在任何位置实现常数时间插入和删除(虽然牺牲了缓存友好性和快速搜索)

  • 在修改期间保持迭代器有效性(除非迭代器引用的元素被删除)

在以下情况下,它是最合适的选择:

  • 预期会频繁地从容器的头部和中间进行插入和删除操作。

  • 随机访问不是主要要求。

  • 迭代器有效性保持至关重要。

  • 每个节点中存储的(大)数据本身就不利于缓存。

在考虑不同容器时,倾向于使用 std::list 以获得链表的优点。如果随机访问至关重要,std::vectorstd::deque 可能是更好的选择。

理想用例

以下是一些 std::list 的理想用例:

  • std::forward_liststd::list 提供双向遍历能力,允许你向前和向后迭代元素,这对某些算法是有益的。

  • 如果你有要插入的位置的迭代器,std::list 提供任何位置的常数时间插入和删除。这使得它适合于这种操作频繁的应用程序。然而,需要注意的是,搜索插入位置的成本可能会超过插入操作本身的收益。通常,即使对于频繁的插入和删除,std::vector 也可能优于 std::list

  • std::list 可以是一个好的选择。

  • std::list 由于其拼接能力而证明是高效的。

  • std::queue 是标准选择,std::list 可以因其双向特性被用来实现双端队列(deque)。

  • std::list 适合在软件应用程序中维护撤销和重做历史。

  • 由于在元素间移动的高效性,std::list 常常是一个好的选择。

  • std::list 可以调整以创建循环列表,其中最后一个元素链接回第一个。

  • 可以使用 std::list

虽然 std::list 多功能,但应谨慎其局限性。它不支持直接访问或索引,与数组或 std::vector 不同。因此,当其特定优势与应用程序的需求很好地匹配时,选择 std::list 是至关重要的。它也不利于缓存,并且线性搜索的成本较高。

性能

std::list 的算法性能如下:

  • 插入:在任何位置的时间复杂度为 O(1)

  • 删除:对于已知位置的时间复杂度为 O(1)

  • 访问时间:由于其链式结构,为 O(n)

  • 内存开销:通常高于向量,因为存储了下一个和上一个指针。

  • 对于大多数用例,std::vector 将优于 std::list

内存管理

std::vector 不同,std::list 不会大量重新分配。每个元素的分配是独立的。分配器仍然可以影响单个节点的分配,从而提供更具体的内存管理。

线程安全

并发读取是安全的。然而,修改或同时读取和写入需要外部同步。可以使用互斥锁或类似的结构。

扩展和变体

std::forward_list 是单链表的变体,优化了空间但失去了向后遍历的能力。

排序和搜索复杂度

排序和搜索的复杂度如下:

  • std::list::sort(),通常为 O(n log n)

  • 由于缺乏随机访问,std::find()

接口和成员函数

值得注意的函数如下:

  • emplace_front/emplace_back:直接就地构造

  • splice:将元素从一个列表转移到另一个列表

  • merge:合并两个排序后的列表

  • unique:删除重复元素

比较操作

当与std::vectorstd::deque相比时,std::list似乎在频繁的中部插入和删除方面更优越。然而,它并不提供前者容器那样的快速随机访问。这意味着找到执行插入或删除的位置的成本超过了插入或删除本身的利益。

与算法的交互

虽然std::list可以与许多 STL 算法一起工作,但那些需要随机访问的(例如,std::random_shuffle)并不理想。

异常

越界或非法操作可能会抛出异常。然而,std::list的许多操作提供了强大的异常安全性,确保列表保持一致性。

定制化

可以使用自定义分配器来影响节点内存分配。与std::setstd::map等容器不同,自定义比较器在std::list中并不常见。

示例

在这个例子中,我们将展示以下最佳实践和std::list的使用:

  • 利用std::list的双向特性来遍历和修改元素,无论是正向还是反向方向

  • 在列表的任何位置高效地插入和删除元素

  • 使用std::list的成员函数,如sort()merge()splice()remove_if()

  • 应用外部 STL 算法,如std::find

这里是代码示例:

#include <algorithm>
#include <iostream>
#include <list>
void display(const std::list<int> &lst) {
  for (const auto &val : lst) { std::cout << val << " "; }
  std::cout << "\n";
}
int main() {
  std::list<int> numbers = {5, 1, 8, 3, 7};
  std::cout << "Numbers in reverse: ";
  for (auto it = numbers.rbegin(); it != numbers.rend();
       ++it) {
    std::cout << *it << " ";
  }
  std::cout << "\n";
  auto pos = std::find(numbers.begin(), numbers.end(), 8);
  numbers.insert(pos, 2112);
  std::cout << "After insertion: ";
  display(numbers);
  numbers.sort();
  std::list<int> more_numbers = {2, 6, 4};
  more_numbers.sort();
  numbers.merge(more_numbers);
  std::cout << "After sorting and merging: ";
  display(numbers);
  std::list<int> additional_numbers = {99, 100, 101};
  numbers.splice(numbers.end(), additional_numbers);
  std::cout << "After splicing: ";
  display(numbers);
  numbers.remove_if([](int n) { return n % 2 == 0; });
  std::cout << "After removing all even numbers: ";
  display(numbers);
  return 0;
}

在这个例子中,我们将执行以下操作:

  • 我们使用反向迭代器反向遍历std::list

  • 我们展示了在期望位置高效插入元素的能力。

  • 我们展示了使用std::list特定操作,如sort()merge()splice()的用法。

  • 最后,我们使用 lambda 与remove_if()条件性地从列表中删除元素。

这个例子展示了std::list的各种功能,包括特别高效的容器操作和使用其双向特性的操作。

最佳实践

让我们探索使用std::list的一些最佳实践:

  • 在没有针对数据类型如std::vector进行性能分析并发现可测量的性能改进之前,不要使用std::list

  • std::list本身提供的sort()成员函数是必不可少的,而不是求助于std::sort。这是因为 C 需要随机访问迭代器,而std::list不支持。

  • 由于std::list的双链结构,它不提供O(1)随机访问。对于频繁的随机访问,容器如std::vectorstd::deque可能更合适。

  • std::list意味着它为每个元素维护两个指针。这使它能够进行双向遍历,但这也带来了内存成本。如果内存使用至关重要且不需要双向遍历,std::forward_list提供了一个更干净的替代方案。

  • std::list 可以将操作从 O(n) 转换为 O(1)。利用迭代器的力量进行更有效的插入和删除。

  • std::list 提供了使用 splice 函数在常数时间内在不同列表之间传输元素的独特能力。这个操作既高效又可简化列表操作。

  • emplace_frontemplace_back,你可以在原地构建元素,从而消除对临时对象的需求,并可能加快你的代码。

  • std::list。特别是在内存敏感的场景中,了解这种开销对于做出明智的容器选择至关重要。

std::forward_list

std::forward_list 是一个单链表。它与 std::list 类似,但每个元素只指向下一个元素,而不是前一个。这比 std::list 减少了内存开销,但以双向迭代为代价。当你需要一个列表结构但不需要向后遍历且希望节省内存开销时,选择 std::forward_list

目的和适用性

std::forward_list 是 STL 中的一个单链表容器。它的主要吸引力在于以下方面:

  • 在列表的任何位置进行高效的插入和删除

  • 比使用 std::list 消耗更少的内存,因为它不存储前一个指针

它在以下情况下特别适用:

  • 你需要无论位置如何都能进行常数时间的插入或删除。

  • 内存开销是一个需要关注的问题。

  • 不需要双向迭代。

虽然 std::vector 在随机访问方面表现出色,但如果你更重视插入和删除效率,则转向 std::forward_list

理想用例

以下是一些 std::forward_list 的理想用例:

  • std::forward_list 使用单链表,由于它只需要维护一个方向上的链接,所以比双链表的开销更小。这使得它在空间节约是首要考虑的场景中非常适用。

  • std::forward_list 提供了最优效率。

  • std::forward_list 可以是一个合适的选择。

  • using std::forward_list 确保单向移动。

  • std::forward_list 可以用来设计类似栈的行为。

  • std::forward_list 可以存储这些边。

  • std::forward_list 提供了必要的结构。

  • std::forward_list.

重要的是要理解,虽然 std::forward_list 在特定用例中提供了优势,但它缺乏其他容器提供的某些功能,例如在 std::list 中看到的双向遍历。当其优势与应用程序的需求相匹配时,选择 std::forward_list 是合适的。

性能

std::forward_list 的算法性能如下:

  • 插入:无论位置如何都是 O(1)

  • 删除:任何位置都是 O(1)

  • 访问O(n),因为只有顺序访问是唯一的选择。

  • 内存开销:最小化,因为只存储了下一个指针。

  • std::list通常由于其缓存不友好和与std::vector相比的慢速搜索性能而不足。一般来说,std::vector在大多数用例中会比std::forward_list表现更好。

内存管理

元素插入时分配内存。每个节点存储元素和指向下一个节点的指针。自定义分配器可以调整这种分配策略。

线程安全

并发读取是安全的。然而,写入或读取和写入的组合需要外部同步。

扩展和变体

对于希望获得双向迭代能力的人来说,std::list(一个双链表)是一个可行的替代方案。

排序和搜索复杂度

排序和搜索的复杂度如下:

  • std::sort()

  • 搜索O(n),因为没有随机访问

特殊接口和成员函数

值得注意的成员函数如下:

  • emplace_front:用于直接构造元素

  • remove:通过值移除元素

  • splice_after: 用于从另一个std::forward_list转移元素

记住,std::forward_list中没有size()push_back()函数。

比较操作

std::list相比,std::forward_list使用更少的内存,但不支持双向迭代。与std::vector相比,它不允许随机访问,但确保了一致的插入和删除时间。

与算法的交互

由于其单向性质,std::forward_list可能不适合需要双向或随机访问迭代器的算法。

异常

在内存分配失败期间可能会出现异常。大多数对std::forward_list的操作都提供强大的异常安全保证。

自定义

您可以使用自定义分配器调整内存分配策略。std::forward_list本身不支持自定义比较器或哈希函数。

示例

std::forward_list是一个单链表,在从前端进行插入/删除操作时特别高效。它比std::list消耗更少的内存,因为它不存储每个元素的向后指针。

std::forward_list的一个常见用途是实现具有链表的哈希表以解决冲突。以下是一个使用std::forward_list的基本链式哈希表版本:

#include <forward_list>
#include <iostream>
#include <vector>
template <typename KeyType, typename ValueType>
class ChainedHashTable {
public:
  ChainedHashTable(size_t capacity) : capacity(capacity) {
    table.resize(capacity);
  }
  bool get(const KeyType &key, ValueType &value) const {
    const auto &list = table[hash(key)];
    for (const auto &bucket : list) {
      if (bucket.key == key) {
        value = bucket.value;
        return true;
      }
    }
    return false;
  }
  void put(const KeyType &key, const ValueType &value) {
    auto &list = table[hash(key)];
    for (auto &bucket : list) {
      if (bucket.key == key) {
        bucket.value = value;
        return;
      }
    }
    list.emplace_front(key, value);
  }
  bool remove(const KeyType &key) {
    auto &list = table[hash(key)];
    return list.remove_if(& {
      return bucket.key == key;
    });
  }
private:
  struct Bucket {
    KeyType key;
    ValueType value;
    Bucket(KeyType k, ValueType v) : key(k), value(v) {}
  };
  std::vector<std::forward_list<Bucket>> table;
  size_t capacity;
  size_t hash(const KeyType &key) const {
    return std::hash<KeyType>{}(key) % capacity;
  }
};
int main() {
  ChainedHashTable<std::string, int> hashTable(10);
  hashTable.put("apple", 10);
  hashTable.put("banana", 20);
  hashTable.put("cherry", 30);
  int value;
  if (hashTable.get("apple", value)) {
    std::cout << "apple: " << value << "\n";
  }
  if (hashTable.get("banana", value)) {
    std::cout << "banana: " << value << "\n";
  }
  hashTable.remove("banana");
  if (!hashTable.get("banana", value)) {
    std::cout << "banana not found!\n";
  }
  return 0;
}

在这个例子中,我们做以下操作:

  • 哈希表由一个名为tablestd::vector组成,其中包含std::forward_list。向量的每个槽位对应一个哈希值,并且可能包含多个与该哈希值冲突的键(在一个forward_list中)。

  • 在这个上下文中,forward_listemplace_front函数特别有用,因为我们可以在常数时间内向列表的前端添加新的键值对。

  • 我们使用forward_list::remove_if来移除键值对,它遍历列表并移除第一个匹配的键。

最佳实践

让我们探索使用std::forward_list的最佳实践:

  • 在没有对代码与 std::vector 等数据类型进行性能分析并发现可测量的性能改进之前,不要使用 std::forward_list

  • std::forward_list 是一个针对单链表世界中的特定场景进行优化的专用容器。理解其优势和局限性对于有效地使用它至关重要。

  • std::forward_list 是一个不错的选择。然而,它缺乏对元素的快速直接访问,需要 O(n) 操作。

  • std::forward_list 仅支持正向迭代。如果需要双向遍历,请考虑其他容器,例如 std::list

  • 无随机访问:此容器不适用于需要快速随机访问元素的场景。

  • size() 成员函数意味着确定列表的大小需要 O(n) 操作。为了快速检查列表是否为空,请使用 empty() 函数,它效率很高。

  • std::forward_list 提供高效的插入和删除。特别是,emplace_front 对于就地元素构造非常有用,可以减少开销。

  • 使用 sort() 函数来维护元素顺序。要移除连续的重复元素,请应用 unique() 函数。

  • 对迭代器的注意事项:在修改后,尤其是插入或删除后,务必重新检查迭代器的有效性,因为它们可能会失效。

  • 在多线程应用程序中使用 std::forward_list 以防止数据竞争或不一致性。

  • std::list,由于 std::forward_list 只维护每个元素一个指针(前向指针),因此它在使用双向迭代不是必需时,是一个更节省内存的选择。

std::string

在 STL 中,std::string 是一个用于管理字符序列的类。std::string 通过提供一系列字符串操作和分析功能简化了文本处理。尽管在正式的 C++ 标准库文档中,std::string 并未被归类为 序列容器 类别,尽管它的行为非常像。相反,它被归类为单独的 字符串 类别,以认可其通用的容器行为和其在文本处理方面的专用性质。

目的和适用性

std::string 代表一个动态的字符序列,本质上是对 std::vector<char> 的特殊化。它被设计用于以下目的:

  • 操作文本数据

  • 与期望字符串输入或产生字符串输出的函数交互

它在以下情况下尤其适用:

  • 动态文本修改频繁。

  • 希望能够高效地访问单个字符。

对于大多数字符串操作任务,请选择 std::string。如果您需要无所有权的字符串视图,请考虑 std::string_view

理想用例

以下是一些 std::string 的理想用例:

  • 文本处理:解析文件、处理日志或任何需要动态文本操作的其它任务

  • 用户输入/输出: 接受用户输入;生成人类可读的输出

  • 数据序列化: 将数据编码为字符串以进行传输/存储

性能

std::string的算法性能如下所述:

  • 插入: 平均 O(1) 在末尾,其他地方为 O(n)

  • 删除: 由于元素可能需要移动,因此 O(n)

  • 访问: 任何位置的快速 O(1)

  • 内存开销: 通常较低,但如果未使用预留容量,则可能会增长

内存管理

std::string动态分配内存。当缓冲区填满时,它会重新分配,通常加倍其大小。自定义分配器可以修改此行为。

线程安全

并发读取是安全的,但同时修改需要同步,通常使用互斥锁(mutexes)。

扩展和变体

std::wstring是宽字符版本,适用于某些本地化任务。std::string_view提供对字符串的非拥有视图,在特定场景中提高性能。还应考虑std::u8stringstd::u16stringstd::u32string

排序和搜索复杂度

std::string的算法性能如下所述:

  • 搜索: 线性搜索的 O(n)

  • 对于排序序列,可以进行std::binary_search()

特殊接口和成员函数

除了众所周知的(如substrfindappend)之外,熟悉以下内容:

  • c_str(): 返回一个 C 风格字符串(提供与以 null 终止的 C 字符串交互的功能)

  • data(): 直接访问底层字符数据

  • resize(): 调整字符串长度

  • shrink_to_fit(): 减少内存使用

比较操作

虽然std::string管理文本,但std::vector<char>可能看起来相似,但它缺乏字符串语义,例如自动空终止。

与算法的交互

STL 算法与std::string无缝工作,尽管某些算法,如排序,可能很少应用于文本内容。

异常

恶意访问(例如,at())可能会抛出异常。操作通常是异常安全的,这意味着即使在操作抛出异常的情况下,字符串仍然有效。

定制化

std::string支持自定义分配器,但自定义比较器或哈希函数不适用。

示例

C++中的std::string是一个多用途容器,提供了一系列用于不同目的的成员函数,从文本操作到搜索和比较。以下是一个使用std::string的最佳实践的高级示例:

#include <algorithm>
#include <iostream>
#include <string>
int main() {
  std::string s = "Hello, C++ World!";
  std::cout << "Size: " << s.size() << "\n";
  std::cout << "First char: " << s[0] << "\n";
  std::string greet = "Hello";
  std::string target = "World";
  std::string combined = greet + ", " + target + "!";
  std::cout << "Combined: " << combined << "\n";
  if (s.find("C++") != std::string::npos) {
    std::cout << "String contains 'C++'\n";
  }
  std::transform(
      s.begin(), s.end(), s.begin(),
      [](unsigned char c) { return std::toupper(c); });
  std::cout << "Uppercase: " << s << "\n";
  std::transform(
      s.begin(), s.end(), s.begin(),
      [](unsigned char c) { return std::tolower(c); });
  std::cout << "Lowercase: " << s << "\n";
  s.erase(std::remove(s.begin(), s.end(), ' '), s.end());
  std::cout << "Without spaces: " << s << "\n";
  std::string first = "apple";
  std::string second = "banana";
  if (first < second) {
    std::cout << first << " comes before " << second
              << "\n";
  }
  int number = 2112;
  std::string numStr = std::to_string(number);
  std::cout << "Number as string: " << numStr << "\n";
  int convertedBack = std::stoi(numStr);
  std::cout << "String back to number: " << convertedBack
            << "\n";
  return 0;
}

在前面的示例中,我们做了以下操作:

  • 我们演示了基本的字符串操作,包括构造、访问字符和连接。

  • 我们使用find函数检查子字符串。

  • 我们使用std::transformstd::toupperstd::tolower将整个字符串分别转换为大写和小写。

  • 我们使用erase结合std::remove从字符串中删除字符。

  • 我们使用std::string的重载比较运算符提供的自然排序比较了两个字符串。

  • 我们使用 std::to_stringstd::stoi 函数将数字转换为字符串,反之亦然。

这些操作展示了各种 std::string 最佳实践及其与其他 STL 组件的无缝集成。

最佳实践

让我们探索使用 std::string 的最佳实践:

  • 用于字符串连接的 + 操作符可能会影响性能,考虑到可能的重新分配和复制。在循环中使用 += 来提高效率。

  • 使用 reserve() 预分配足够的内存,减少重新分配并提高性能。

  • 迭代调制谨慎:在迭代过程中修改字符串可能会给你带来惊喜。请谨慎行事,并在迭代时避免并发修改。

  • std::string 成员函数,如 find()replace()substr()。它们简化了代码,增强了可读性,并可能提高性能。

  • 受保护元素访问:在深入字符串元素之前,验证你的索引。越界访问是通向未定义行为的单程票。

  • std::string_view 用于对字符串的部分或全部进行轻量级引用。当没有修改计划时,它是传统字符串切片的有效替代方案。

  • std::string。它是 std::basic_string 模板的衍生,可以满足自定义字符类型和特定字符行为的需求。

  • std::string 用于 ASCII 和 UTF-8 需要。你是在探索 UTF-16 或 UTF-32 领域吗?转向 std::wstring 及其宽字符同伴。始终对编码保持警惕,以避免潜在的数据错误。

  • 利用内部优化小字符串优化SSO)是许多标准库袖子中的王牌。它允许直接在字符串对象中存储小字符串,避免动态分配。对于小字符串来说,这是一个性能上的福音。

小字符串究竟有多小?

小字符串 的确切长度因实现而异。然而,小字符串缓冲区的大小通常在 15 到 23 个字符之间。

  • std::stringcompare() 函数比 == 操作符提供了更多的粒度。它可以提供对词法排序的见解,这对于排序操作可能是至关重要的。

  • std::stringstream 提供了一种灵活的方式来连接和转换字符串,但它可能伴随着开销。当性能至关重要时,优先选择直接字符串操作。

  • std::stoistd::to_string 等函数。它们比手动解析更安全且通常更高效。

第七章:高级有序关联容器使用

C++中的关联容器允许开发者以更符合现实场景的方式管理数据,例如使用键来检索值。本章将探讨有序和无序关联容器,它们的独特属性以及理想的应用环境。对于中级 C++开发者来说,理解何时使用 map 而不是 unordered_map,或者理解 set 和 multiset 之间的细微差别,对于优化性能、内存使用和数据检索速度至关重要。此外,掌握最佳实践将使开发者能够编写高效、可维护且无错误的代码,确保容器在多种应用场景中有效地发挥作用。

从本质上讲,有序关联容器,由于其严格的顺序和唯一(有时不是那么唯一)的元素,为 C++开发者提供了强大的工具。它们专为涉及关系、排序和唯一性的场景量身定制。理解它们的特性和用例是充分发挥其潜力的第一步。

本章提供了以下容器的参考:

  • std::set

  • std::map

  • std::multiset

  • std::multimap

技术要求

本章的代码可以在 GitHub 上找到:

github.com/PacktPublishing/Data-Structures-and-Algorithms-with-the-CPP-STL

std::set

在其核心,std::set容器是一个包含唯一元素的集合,其中每个元素都遵循严格的顺序。你可以将其想象为一个俱乐部,每个成员都是独特的,并且都有特定的等级。该容器确保没有两个元素是相同的,这使得它在不需要重复元素的情况下非常有用。

目的和适用性

std::set是一个关联容器,旨在存储类型为Key的有序唯一对象集合。其优势如下:

  • 确保所有元素都是唯一的

  • 在插入元素时自动排序

它特别适合以下场景:

  • 当不希望有重复元素时

  • 当元素的排序很重要时

  • 当预期会有频繁的查找和插入操作时

理想用例

以下是一些std::set的理想用例:

  • std::set自然地强制执行这一点。例如,当收集唯一的学生 ID 列表或产品代码时,它将非常有用。

  • std::set根据比较标准保持其元素排序。当需要数据天生排序时,这很有益,例如在维护一个排行榜,其中分数会持续插入,但始终应该是有序的。

  • std::set提供了对查找操作的对数时间复杂度。这使得它在需要频繁成员检查的场景中非常适用——例如,检查特定用户是否是 VIP 名单的一部分。

  • std::set 在某些情况下非常有价值。它特别适用于你可能想要在两个集合之间找到共同元素或确定哪些元素仅属于一个集合的情况。

  • std::set 容器可以用来跟踪这些时间。由于其有序性,你可以迅速确定下一个事件或特定时间段是否已被预订。

值得注意的是,虽然 std::set 在这些任务上很擅长,但评估手头问题的具体要求至关重要。如果排序不是必需的,并且你主要需要快速插入、删除和查找而不考虑顺序,std::unordered_set 可能是一个更好的选择。

性能

std::set 的算法性能如下:

  • 插入:通常为 O(log n),因为平衡二叉搜索树结构

  • 删除:单个元素为 O(log n)

  • 访问(查找元素)O(log n)

  • std::vector 由于树结构

内存管理

在内部,std::set 使用树结构,通常是平衡二叉搜索树。可以通过自定义分配器来影响内存分配。

线程安全

std::vector 类似,并发读取是安全的,但修改或读取和修改的组合需要外部同步。

扩展和变体

C++ 的 std::multiset(允许重复元素)和 std::unordered_set(一个哈希表,以牺牲无序为代价,提供平均 O(1) 插入/查找)。

排序和搜索复杂度

排序和搜索的复杂度如下:

  • 排序:元素在插入时自动排序

  • find 成员函数

特殊接口和成员函数

一些值得注意的成员函数如下:

  • emplace:就地插入元素

  • count:返回元素数量(在集合中始终为 0 或 1)

  • lower_boundupper_bound:为特定键提供边界

比较操作

std::vector 相比,std::set 在确保唯一性和保持顺序方面表现出色,但可能不是频繁随机访问或顺序不是关注点时的最佳选择。

与算法的交互

由于其双向迭代器,许多 STL 算法与 std::set 兼容。然而,需要随机访问的算法可能不太理想。

异常

由于 std::set 没有固定容量,因此不会因为容量问题而抛出异常。异常可能来自分配器在内存分配期间。

自定义

std::set 允许自定义分配器进行内存管理。你还可以提供一个自定义比较器来定义集合元素的排序方式。

示例

std::set 是一个有序关联容器,包含唯一元素。它通常用于表示一个集合,其中元素的存在比其出现的次数更重要。以下代码是一个示例,说明了使用 std::set 时的最佳实践:

#include <algorithm>
#include <iostream>
#include <set>
#include <vector>
int main() {
  std::set<int> numbers = {5, 3, 8, 1, 4};
  auto [position, wasInserted] = numbers.insert(6);
  if (wasInserted) {
    std::cout << "6 was inserted into the set.\n";
  }
  auto result = numbers.insert(5);
  if (!result.second) {
    std::cout << "5 is already in the set.\n";
  }
  if (numbers.find(3) != numbers.end()) {
    std::cout << "3 is in the set.\n";
  }
  numbers.erase(1);
  std::cout << "Elements in the set:";
  for (int num : numbers) { std::cout << ' ' << num; }
  std::cout << '\n';
  std::set<int> moreNumbers = {9, 7, 2};
  numbers.merge(moreNumbers);
  std::cout << "After merging:";
  for (int num : numbers) { std::cout << ' ' << num; }
  std::cout << '\n';
  if (numbers.count(2)) {
    std::cout << "2 exists in the set.\n";
  }
  std::set<std::string, bool (*)(const std::string &,
                                 const std::string &)>
      caseInsensitiveSet{[](const std::string &lhs,
                            const std::string &rhs) {
        return std::lexicographical_compare(
            lhs.begin(), lhs.end(), rhs.begin(), rhs.end(),
            [](char a, char b) {
              return std::tolower(a) < std::tolower(b);
            });
      }};
  caseInsensitiveSet.insert("Hello");
  if (!caseInsensitiveSet.insert("hello").second) {
    std::cout << "Duplicate insertion (case-insensitive) "
                 "detected.\n";
  }
  return 0;
}

这里是示例输出:

6 was inserted into the set.
5 is already in the set.
3 is in the set.
Elements in the set: 3 4 5 6 8
After merging: 2 3 4 5 6 7 8 9
2 exists in the set.
Duplicate insertion (case-insensitive) detected.

在此示例中,我们做了以下操作:

  • 我们展示了基本的std::set操作,如插入、查找元素和删除。

  • 我们展示了集合如何固有地排序其元素以及如何遍历它们。

  • 使用merge函数的示例是为了将另一个集合合并到我们的主集合中。

  • 使用count方法检查集合中是否存在元素,由于唯一性约束,这只能为 0 或 1。

  • 最后,我们使用自定义比较器创建了一个不区分大小写的字符串集合。

最佳实践

让我们探讨使用std::set的最佳实践:

  • std::set,访问时间不是恒定的,如std::vectorstd::array。由于其基于树的结构,元素检索通常需要对数时间。在设计算法时,要考虑这一点。

  • std::set容器是不可变的。直接通过迭代器修改它可能会破坏集合的内部顺序。如果必须修改,请删除旧元素并插入其更新版本。

  • std::unordered_set。由于其基于哈希的设计,它通常在性能指标上优于std::set,除非是极端情况。

  • 使用emplace在集合中直接创建元素。这项技术防止了不必要的对象复制或移动。

  • 导航元素修改:直接修改集合元素是不可行的。当你需要修改时,最佳方法是两步过程:移除原始元素并引入其修改后的副本。

  • find方法是确定元素是否存在于集合中的首选方法。在std::set的上下文中,它比count方法更简洁、更易于表达,因为集合具有唯一元素的性质。

  • std::is_sorted

  • std::set本身不是线程安全的。如果预计会有多个线程并发访问,请使用同步原语(如std::mutex)保护集合,或者考虑使用某些 C++库提供的并发容器。

  • 在使用std::set容器时,请记住元素是排序的。这通常可以免除在其他容器上可能应用的其他排序操作。

  • std::vectorstd::set不支持reservecapacity操作。树随着元素的添加而增长。为了效率,在删除元素时,考虑偶尔使用某些实现中可用的shrink_to_fit操作。

std::map

std::map容器是std::set的兄弟,它关乎关系。它将唯一的键连接到特定的值,形成一个对。用通俗易懂的话来说,想象一个字典,每个词(键)都有一个唯一的定义(值)。

目的和适用性

std::map是一个有序关联容器,存储键值对,确保键的唯一性。其底层数据结构通常是平衡二叉树(如红黑树RBT))。主要优点包括以下内容:

  • 对数访问、插入和删除时间

  • 通过键对键值对进行排序维护

在以下场景中使用std::map

  • 当你需要将值与唯一键关联时

  • 当维护键的顺序很重要时

  • 当需要频繁地进行访问、插入或删除操作,并且它们需要高效时

理想用例

以下是一些std::map的理想用例:

  • std::map在将唯一键与特定值关联时表现出色。例如,当将一个人的姓名(唯一键)映射到其联系详情或一个单词映射到其定义时,它非常有用。

  • std::map容器可以将不同的配置键与其相应的值关联起来,确保轻松检索和修改设置。

  • 学生记录系统:教育机构可能维护一个记录系统,其中学生 ID(保证唯一)作为键,映射到包含姓名、课程、成绩和其他详细信息的全面学生档案。

  • std::map可以将不同的项目与其出现次数关联起来,确保高效的更新和检索。

  • 用于此目的的std::map容器确保术语按顺序排列,并且可以高效地访问或更新。

  • std::map可以作为缓存,将输入值映射到其计算结果。

  • std::map根据其键的顺序维护其元素,适用于频繁依赖此顺序的操作的场景——例如,根据某些标准获取 top 10lowest 5

总是考虑你问题的具体需求。虽然std::map提供了排序和唯一键值关联,但如果不需要排序,std::unordered_map可能是一个更高效的替代方案,因为它的大多数操作的平均时间复杂度为常数时间。

性能

std::map的算法性能如下:

  • 插入O(log n)

  • 删除O(log n)

  • 访问O(log n) 定位键

  • 内存开销:通常高于基于哈希的对应物,因为基于树的结构

主要的权衡是在内存开销与有序操作的效率和键值对操作的灵活性之间取得平衡。

内存管理

std::map有效地管理其内部内存,确保平衡树。然而,具体行为可能受到自定义分配器的影响,从而允许更多的控制。

线程安全

并发读取是安全的。然而,并发写入或混合读写需要外部同步,例如使用互斥锁。

扩展和变体

std::multimap允许每个键有多个值,而std::unordered_map提供了一个基于哈希表的替代方案,没有排序,但具有潜在的 O(1) 平均访问时间。

排序和搜索复杂度

排序和搜索复杂度如下:

  • std::map本身维护排序

  • 搜索O(log n)

特殊接口和成员函数

以下是一些值得注意的成员函数:

  • emplace:直接在原地构建键值对

  • at:如果键不存在,则抛出异常

  • operator[]:访问或为给定键创建一个值

  • lower_boundupper_bound:提供指向相对于键位置的迭代器

比较操作

std::unordered_map 相比,std::map 在键顺序重要或数据集可能频繁增减的场景中表现更佳。对于需要原始性能且顺序不重要的场景,std::unordered_map 可能更可取。

与算法的交互

尽管许多 STL 算法可以与 std::map 一起工作,但其双向迭代器限制了它与需要随机访问的算法的兼容性。

异常

操作如 at() 可能会抛出越界异常。大多数对 std::map 的操作都提供了强异常安全性,确保在抛出异常时映射保持不变。

自定义

您可以提供自定义比较器来指定键的顺序,或使用自定义分配器来影响内存管理。

示例

std::map 中,键是有序且唯一的,这使得查找特定条目变得容易。以下代码是使用 std::map 的最佳实践的示例:

#include <algorithm>
#include <iostream>
#include <map>
#include <string>
int main() {
  std::map<std::string, int> ageMap = {
      {"Lisa", 25}, {"Corbin", 30}, {"Aaron", 22}};
  ageMap["Kristan"] = 28;
  ageMap.insert_or_assign("Lisa", 26);
  if (ageMap.find("Corbin") != ageMap.end()) {
    std::cout << "Corbin exists in the map.\n";
  }
  ageMap["Aaron"] += 1;
  std::cout << "Age records:\n";
  for (const auto &[name, age] : ageMap) {
    std::cout << name << ": " << age << '\n';
  }
  ageMap.erase("Corbin");
  if (ageMap.count("Regan") == 0) {
    std::cout << "Regan does not exist in the map.\n";
  }
  std::map<std::string, int,
           bool (*)(const std::string &,
                    const std::string &)>
      customOrderMap{[](const std::string &lhs,
                        const std::string &rhs) {
        return lhs > rhs; // reverse lexicographic order
      }};
  customOrderMap["Lisa"] = 25;
  customOrderMap["Corbin"] = 30;
  customOrderMap["Aaron"] = 22;
  std::cout << "Custom ordered map:\n";
  for (const auto &[name, age] : customOrderMap) {
    std::cout << name << ": " << age << '\n';
  }
  return 0;
}

以下是一个示例输出:

Corbin exists in the map.
Age records:
Aaron: 23
Corbin: 30
Kristan: 28
Lisa: 26
Regan does not exist in the map.
Custom ordered map:
Lisa: 25
Corbin: 30
Aaron: 22

在这个例子中,我们做了以下操作:

  • 我们展示了 std::map 的基本操作,例如插入、修改、检查键的存在以及遍历其元素。

  • 我们使用了结构化绑定(C++17)在迭代时重新结构化键值对。

  • 我们展示了如何使用 count 来检查键是否存在于映射中。

  • 我们通过提供一个自定义比较器,该比较器按逆字典序对键进行排序,创建了一个自定义排序的映射。

最佳实践

让我们探讨使用 std::map 的最佳实践:

  • std::map 中,键在其元素的生命周期内保持不变。直接修改是不允许的。如果您需要更新键,正确的方法是删除旧的键值对,并插入一个新的具有所需键的键值对。

  • std::unordered_map。其基于哈希表的实现可能比 std::map 的红黑树提供许多操作的更快平均时间复杂度,从而减少了潜在的开销。

  • emplace 方法,在映射中就地构造元素,避免创建临时对象和不必要的复制。当与 std::make_pairstd::piecewise_construct 等工具配合使用时,它优化了插入的性能。

  • operator[] 方法,虽然方便,但可能是一把双刃剑。如果指定的键不存在,它将在映射中插入一个具有默认初始化值的键。当您只想查询,而不希望有潜在的插入时,请使用 find 方法。如果找到,find 方法返回指向元素的迭代器;如果没有找到,则返回 end() 方法。

  • std::map 可能并不总是适合使用场景。您可以在定义映射时提供一个比较器来自定义顺序。确保此比较器执行严格的弱排序,以保持映射内部结构的完整性。

  • std::map 容器,同步变得至关重要。考虑使用 std::mutex 或其他 STL 同步原语在写入操作期间锁定访问,以保持数据一致性。

  • count 方法可以直接得到计数结果。对于映射,这总是返回 01,这使得检查成员资格成为一种快速方式。

  • erase 操作使迭代器无效。使用 erase 返回的迭代器继续安全操作。

  • std::map 提供了范围方法,如 equal_range,它可以返回与给定键等效的元素子范围的上界和下界。利用它们进行高效的子范围操作。

  • std::map 支持自定义分配器。这允许更好地控制分配和释放过程。

std::multiset

虽然 std::set 容器以其独特性而自豪,但 std::multiset 则更为宽容。它仍然保持顺序,但允许多个元素具有相同的值。这个容器就像一个俱乐部,成员有等级,但每个等级都有空间容纳多个成员。

目的和适用性

std::multiset 是一个关联容器,存储排序元素并允许元素有多个出现。其关键优势如下:

  • 维护元素的排序顺序

  • 允许重复

  • 提供插入、删除和搜索的对数时间复杂度

它特别适合以下场景:

  • 当需要保留重复值时

  • 当你需要元素始终保持排序

  • 当不需要随机访问时

理想用例

以下是一些 std::multiset 的理想用例:

  • std::multiset 容器有益。它允许存储重复值,同时保持它们的排序顺序。

  • 由于其固有的排序特性和容纳重复数字的能力,std::multiset 容器非常宝贵。

  • std::multiset 容器可以帮助高效地管理和跟踪这些选择,尤其是在一个热门会话被多次选择时。

  • std::multiset 容器可以表示此类项目,允许根据需求轻松跟踪和补充。

  • std::map) 将术语映射到文档,一个 std::multiset 容器可以用来跟踪术语在多个文档中出现的频率,即使某些术语很常见且重复出现。

  • 使用 std::multiset 来高效管理事件或点,尤其是在多个事件共享相同位置时。

  • std::multiset 闪耀着光芒。

记住,虽然 std::multiset 设计用于以排序方式处理相同值的多个实例,但如果排序属性不是必需的,并且你想跟踪多个项目,由于基于哈希的实现,结构如 std::unordered_multiset 在某些情况下可能更高效。

性能

std::multiset 的算法性能如下:

  • 插入O(log n)

  • 删除O(log n)

  • 访问:元素以O(log n)的时间复杂度访问

  • 内存开销:由于内部平衡(通常实现为平衡二叉搜索树),存在开销

内存管理

std::multiset不像std::vector那样动态调整大小。相反,它在插入元素时使用动态内存分配来管理节点。分配器可以影响节点内存管理。

线程安全

并发读取是安全的。然而,修改(插入或删除)需要外部同步。建议使用互斥锁或其他同步原语进行并发写入。

扩展和变体

std::set是一个不允许重复的直接变体。还有std::unordered_multiset,它为操作提供平均常数时间复杂度,但不保持顺序。

排序和搜索复杂度

排序和搜索复杂度如下描述:

  • 排序:元素始终排序;因此,不需要排序操作

  • 搜索:由于其基于树的本质,O(log n)

特殊接口和成员函数

虽然它提供了常规函数(inserterasefind),但以下是一些实用的函数:

  • count:返回与指定键匹配的总元素数

  • equal_range:提供元素所有实例的范围(迭代器)

比较操作

std::set相比,std::multiset允许重复,但代价是略微增加的内存。与如std::vector之类的序列容器相比,它保持排序顺序,但不提供常数时间访问。

与算法的交互

从排序数据中受益的算法(如二分搜索或集合操作)与std::multiset配合良好。那些需要随机访问或频繁重新排序的算法可能不合适。

异常

内存分配失败可能会抛出异常。大多数std::multiset操作提供强大的异常安全性保证。

定制化

std::multiset中,定制包括以下内容:

  • 可以使用自定义分配器来控制内存分配。

  • 可以提供自定义比较器来指定元素存储的顺序。

示例

std::multiset是一个可以存储多个键的容器,包括重复键。键始终按从低到高的顺序排序。std::multiset通常用于需要维护排序元素集且允许重复项的情况。

以下代码是使用std::multiset的示例,展示了其一些独特特性和最佳实践:

#include <iostream>
#include <iterator>
#include <set>
#include <string>
int main() {
  std::multiset<int> numbers = {5, 3, 8, 5, 3, 9, 4};
  numbers.insert(6);
  numbers.insert(5); // Inserting another duplicate
  for (int num : numbers) { std::cout << num << ' '; }
  std::cout << '\n';
  std::cout << "Number of 5s: " << numbers.count(5)
            << '\n';
  auto [begin, end] = numbers.equal_range(5);
  for (auto it = begin; it != end; ++it) {
    std::cout << *it << ' ';
  }
  std::cout << '\n';
  numbers.erase(5);
  std::multiset<std::string, std::greater<>> words = {
      "apple", "banana", "cherry", "apple"};
  for (const auto &word : words) {
    std::cout << word << ' ';
  }
  std::cout << '\n';
  std::multiset<int> dataset = {1, 2, 3, 4, 5,
                                6, 7, 8, 9, 10};
  const auto start = dataset.lower_bound(4);
  const auto stop = dataset.upper_bound(7);
  std::copy(start, stop,
            std::ostream_iterator<int>(std::cout, " "));
  std::cout << '\n';
  return 0;
}

这里是示例输出:

3 3 4 5 5 5 6 8 9
Number of 5s: 3
5 5 5
cherry banana apple apple
4 5 6 7

从前面的示例中可以得出以下关键要点:

  • std::multiset自动排序键。

  • 它可以存储重复键,并且可以利用此属性进行某些算法或存储模式,其中重复项是有意义的。

  • 使用equal_range是查找键所有实例的最佳实践。此方法返回开始和结束迭代器,覆盖所有键的实例。

  • 可以使用自定义比较器,如std::greater<>,来反转默认排序。

  • 可以使用lower_boundupper_bound进行高效的范围查询。

记住,如果你不需要存储重复项,那么std::set是一个更合适的选择。

最佳实践

让我们探讨使用std::multiset的最佳实践:

  • 使用std::multiset来防止不必要的开销。相反,优先考虑std::set,它本质上管理唯一元素,可能更高效。

  • std::multiset不提供std::vector提供的相同时间复杂度的常数时间访问。由于底层基于树的数据结构,访问元素是对数复杂度。

  • 使用std::multiset来存储重复元素可能导致内存使用增加,尤其是当这些重复项很多时。分析内存需求并确保容器适用于应用程序至关重要。

  • std::multiset,确保它施加严格的弱排序。任何排序不一致都可能引起未定义的行为。严格测试比较器以确认其可靠性。

  • findcount成员函数。它们提供了更高效和直接的方式来执行此类检查。

  • std::multiset,明确说明需要重复条目的原因。如果理由不强,或者重复项对应用程序逻辑的益处不大,考虑使用std::set

std::multimap

扩展std::map的原则,std::multimap容器允许一个键与多个值关联。它就像一个字典,一个词可能有几个相关定义。

目的和适用性

std::multimap是 STL 中的一个关联容器。其显著特点如下:

  • 存储键值对

  • 允许具有相同键的多个值

  • 按键排序存储元素

它特别适合以下场景:

  • 当你需要维护一个具有非唯一键的集合时

  • 当你需要基于键的排序访问时

  • 当键值映射至关重要时

当你预期在同一个键下有多个值时,选择std::multimap。如果需要唯一键,你可能需要考虑std::map

理想使用场景

以下是一些std::multimap的理想使用场景:

  • std::multimap中,你可以将一个键与多个值关联起来。

  • std::multimap容器可以有效地映射这些多个含义。

  • std::multimap容器可以将目的地(键)与各种航班详情或时间(值)关联起来。

  • 使用std::multimap,你可以轻松跟踪特定日期的所有事件。

  • std::multimap在需要权重或其他与边关联的数据时尤其有用。

  • std::multimap容器。

  • std::multimap容器适用于此类用例。

  • std::multimap有助于根据标签有效地组织和检索媒体。

记住,当一对多关系普遍存在时,std::multimap 是一个不错的选择。然而,如果顺序和排序不是关键,且高效检索更重要,考虑到基于哈希的结构,如 std::unordered_multimap,可能会有所帮助。

性能

std::multimap 的算法性能如下:

  • 插入:在大多数情况下是对数 O(log n)

  • 删除:通常情况下是对数 O(log n)

  • 访问:对于特定键是 O(log n)

  • 内存开销:由于维护基于树的结构和潜在的平衡,它稍微高一点

内存管理

std::multimap 内部使用树结构,通常是红黑树(RBT)。因此,内存分配和平衡操作可能会发生。分配器可以影响其内存处理。

线程安全

多次读取是安全的。然而,写入或读取和写入的组合需要外部同步。使用互斥锁等工具是可取的。

扩展和变体

对于基于哈希表的键值映射,考虑 std::unordered_multimap。对于唯一键值映射,std::map 更为合适。

排序和搜索复杂度

排序和搜索的复杂度如下:

  • 排序:排序是固有的,因为元素是按键顺序维护的

  • 搜索:定位特定键是 O(log n)

特殊接口和成员函数

标准函数,如 inserterasefind 都是可用的。以下也是可用的:

  • count:返回具有特定键的元素数量

  • equal_range:检索具有特定键的元素范围

比较操作

std::unordered_multimap 相比,std::multimap 提供了有序访问,但由于其基于树的本质,可能会有稍微高的开销。

与算法的交互

由于 std::multimap 维护有序访问,从排序数据中受益的算法(如 std::set_intersection)可能很有用。然而,请记住数据是按键排序的。

异常

尝试访问不存在的键或越界场景可能会抛出异常。大多数操作都是强异常安全的,确保即使在抛出异常的情况下容器仍然有效。

自定义

自定义分配器可以优化内存管理。std::multimap 还允许自定义比较器来指定键的排序。

示例

std::multimap 是一种容器,它维护一组键值对集合,其中多个键值对可以具有相同的键。std::multimap 中的键总是排序的。

以下代码是使用 std::multimap 的示例,展示了它的一些独特特性和最佳实践:

#include <iostream>
#include <map>
#include <string>
int main() {
  std::multimap<std::string, int> grades;
  grades.insert({"John", 85});
  grades.insert({"Corbin", 78});
  grades.insert({"Regan", 92});
  grades.insert({"John", 90}); // John has another grade
  for (const auto &[name, score] : grades) {
    std::cout << name << " scored " << score << '\n';
  }
  std::cout << '\n';
  std::cout << "John's grade count:"
            << grades.count("John") << '\n';
  auto [begin, end] = grades.equal_range("John");
  for (auto it = begin; it != end; ++it) {
    std::cout << it->first << " scored " << it->second
              << '\n';
  }
  std::cout << '\n';
  grades.erase("John");
  std::multimap<std::string, int, std::greater<>>
      reverseGrades = {{"Mandy", 82},
                       {"Mandy", 87},
                       {"Aaron", 90},
                       {"Dan", 76}};
  for (const auto &[name, score] : reverseGrades) {
    std::cout << name << " scored " << score << '\n';
  }
  return 0;
}

这里是示例输出:

Corbin scored 78
John scored 85
John scored 90
Regan scored 92
John's grade count:2
John scored 85
John scored 90
Mandy scored 82
Mandy scored 87
Dan scored 76
Aaron scored 90

从前面的代码中可以总结出以下要点:

  • std::multimap 自动排序键。

  • 它可以存储具有相同键的多个键值对。

  • 使用equal_range是查找键的所有实例的最佳实践。此方法返回开始和结束迭代器,覆盖所有键的实例。

  • grades.count("John") 高效地统计了具有指定键的键值对数量。

  • 自定义比较器,如std::greater<>,可以将排序从默认的升序更改为降序。

当你需要一个支持重复键的字典样式的数据结构时,std::multimap 容器非常有用。如果不需要重复键,那么std::map 将是一个更合适的选择。

最佳实践

让我们探索使用std::multimap的最佳实践:

  • std::multimap 的访问复杂度是对数级的,这归因于其基于树的底层结构。

  • std::multimapstd::map一样是唯一的。std::multimap 容器允许单个键有多个条目。如果你的应用程序需要唯一的键,那么std::map是适当的选择。

  • std::multimap 的元素基于键具有固有的排序特性。利用这一特性,尤其是在执行受益于有序数据的操作(如范围搜索或有序合并)时,可以发挥优势。

  • 由于其哈希机制,std::unordered_multimap 可能是一个更合适的替代方案。然而,值得注意的是,最坏情况下的性能和内存开销可能会有所不同。

  • 使用findcount。这有助于防止潜在的问题并确保代码的健壮性。

  • 使用自定义比较器进行定制排序:如果您有与默认排序不同的特定排序要求,请使用自定义比较器。确保您的比较器强制执行严格的弱排序,以保证多映射的一致性和定义良好的行为。

  • equal_range 成员函数。它提供了一个特定键的所有元素的范围(开始和结束迭代器),使得对这些特定元素进行高效迭代成为可能。

  • std::multimap 在处理大量数据集时可能会变得低效,尤其是当频繁的插入和删除是常见操作时。在这种情况下,评估结构的性能并考虑替代方案或优化策略是值得的。

第八章:高级无序关联容器使用

当我们的有序关联容器之旅为我们提供了关系映射的技能和排序的权力时,现在是时候进入一个优先考虑速度而不是排序行为的领域:无序关联容器。正如它们的名称所暗示的,这些容器不保证它们元素的具体顺序,但它们通过可能更快的访问时间来弥补这一点。

在计算的世界里,总是有权衡。无序关联容器可能会放弃顺序的美感,但在许多场景中,它们通过速度来弥补这一点,尤其是在哈希操作最佳时。无论你是开发高频交易系统、缓存机制还是实时多人游戏后端,了解何时利用无序关联容器的力量可以有所区别。

本章提供了以下容器的参考:

  • std::unordered_set

  • std::unordered_map

  • std::unordered_multiset

  • std::unordered_multimap

技术要求

本章的代码可以在 GitHub 上找到:

github.com/PacktPublishing/Data-Structures-and-Algorithms-with-the-CPP-STL

std::unordered_set

这个容器类似于std::set,但有一个转折:它不保持元素在任何特定的顺序。相反,它使用哈希机制快速访问其元素。在给定一个好的哈希函数的情况下,这种基于哈希的方法可以为大多数操作提供平均常数时间复杂度。

目的和适用性

std::unordered_set是 C++ 标准模板库STL)中的一个基于哈希的容器,它以无特定顺序存储唯一元素。其核心优势包括以下内容:

  • 提供插入、删除和搜索的平均常数时间操作

  • 有效处理非平凡的数据类型

在以下场景中,你应该选择std::unordered_set

  • 当你需要快速检查元素的存在时

  • 当元素顺序不是关注点时

  • 当预期频繁的插入和删除时

然而,如果元素的排序至关重要,std::set可能是一个更好的选择。

理想使用场景

以下是一些std::unordered_set的理想使用场景:

  • std::unordered_set是你的候选。

  • 使用std::unordered_set从现有数据集中创建唯一项的集合。

  • 在快速插入和删除比保持顺序更重要的情况下使用std::unordered_set

  • 当元素的顺序不重要时,使用std::unordered_setstd::set更优,因为std::unordered_set提供了更快的查找、插入和删除操作。然而,std::unordered_set可能比std::set使用更多的内存。

性能

std::unordered_set的算法性能如下:

  • 插入:平均情况 O(1),最坏情况 O(n),由于潜在的哈希冲突

  • 删除:平均情况 O(1),最坏情况 O(n),由于潜在的哈希冲突

  • 访问O(1)

  • 内存开销:由于哈希机制,通常高于有序容器

这里的关键权衡在于平均情况与最坏情况,特别是关于哈希冲突的问题。

内存管理

std::unordered_set 使用一系列桶来管理其内存以存储元素。桶的数量可以增长,通常在负载因子超过某个阈值时。使用自定义分配器可以帮助调整这种行为。

线程安全

并发读取是安全的。然而,修改集合的操作(如插入或删除)需要外部同步机制,例如互斥锁。

扩展和变体

std::unordered_multiset 是一个近亲,允许使用元素的多个实例。如果有序存储至关重要,std::setstd::multiset 就派上用场。

排序和搜索复杂度

其排序和搜索复杂度如下:

  • std::unordered_set 是无序的。

  • 搜索:由于哈希,平均时间复杂度为 O(1),但最坏情况可能为 O(n),这取决于哈希质量。

特殊接口和成员函数

一些值得注意的成员函数如下:

  • emplace:这允许直接构造元素。

  • bucket:这可以检索给定元素的桶号。

  • load_factormax_load_factor:这些是管理性能特征所必需的。

比较

std::set 相比,std::unordered_set 通常提供更快的操作,但失去了固有的顺序,并且可能具有更高的内存开销。

与算法的交互

由于其无序性,std::unordered_set 可能不是需要有序数据的 STL 算法的最佳候选者。然而,围绕唯一元素的算法可以很好地适应。

异常

如果分配失败或哈希函数抛出异常,操作可能会抛出异常。确保您的哈希函数无异常,以保证容器的异常安全性。

自定义

可以应用自定义哈希函数和等价谓词来微调容器针对特定数据类型的操作行为。此外,在某些场景下,自定义分配器也可能有益。

示例

std::unordered_set 以无特定顺序存储唯一元素。它支持的主要操作是插入、删除和成员检查。与使用平衡二叉树内部实现的 std::set 不同,std::unordered_set 使用哈希表,使得平均插入、删除和搜索复杂度为 O(1),尽管常数较高且最坏情况性能较差。

以下代码示例展示了使用 std::unordered_set 的最佳实践:

#include <iostream>
#include <unordered_set>
#include <vector>
void displaySet(const std::unordered_set<int> &set) {
  for (const int &num : set) { std::cout << num << " "; }
  std::cout << '\n';
}
int main() {
  std::unordered_set<int> numbers;
  for (int i = 0; i < 10; ++i) { numbers.insert(i); }
  displaySet(numbers);
  int searchValue = 5;
  if (numbers.find(searchValue) != numbers.end()) {
    std::cout << searchValue << " found in the set."
              << '\n';
  } else {
    std::cout << searchValue << " not found in the set."
              << '\n';
  }
  numbers.erase(5);
  displaySet(numbers);
  std::cout << "Size: " << numbers.size() << '\n';
  std::cout << "Load factor: " << numbers.load_factor()
            << '\n';
  numbers.rehash(50);
  std::cout << "Number of buckets after rehash: "
            << numbers.bucket_count() << '\n';
  std::vector<int> moreNumbers = {100, 101, 102, 103};
  numbers.insert(moreNumbers.begin(), moreNumbers.end());
  displaySet(numbers);
  return 0;
}

以下是一个示例输出:

9 8 7 6 5 4 3 2 1 0
5 found in the set.
9 8 7 6 4 3 2 1 0
Size: 9
Load factor: 0.818182
Number of buckets after rehash: 53
103 102 101 100 9 8 7 6 4 3 2 1 0

以下是从前面代码中得出的几个关键要点:

  • std::unordered_set允许快速插入、删除和查找。

  • find可用于检查元素的存在。

  • rehash方法可以改变底层哈希表中的桶数,这在你事先知道元素数量并希望减少重新散列开销时可能有所帮助。

  • 总是小心地考虑负载因子(在下面的最佳实践部分有介绍)并在必要时考虑重新散列以保持高效性能。

  • 记住,std::unordered_set中元素的顺序是不保证的。随着元素的插入或删除,顺序可能会随时间改变。

当你需要快速查找且不关心元素顺序时,使用std::unordered_set是合适的。如果顺序是必需的,你可能想考虑使用std::set

最佳实践

让我们探讨使用std::unordered_set的最佳实践:

  • std::unordered_set设计时无需维护其元素的具体顺序。不要依赖于此容器内的任何顺序一致性。

  • 哈希冲突意识:哈希冲突会损害性能,将平均情况下的常数时间操作转换为最坏情况下的线性时间操作。始终对此保持警觉,尤其是在设计哈希函数或处理大数据集时。

  • std::unordered_set与其桶数和负载因子紧密相关。考虑std::unordered_set的负载因子和重新散列策略以进行性能调整。bucket_count(): 当前桶数

  • load_factor(): 当前元素数量除以桶数

  • max_load_factor(): 负载因子阈值,当超过此阈值时触发重新散列

  • std::hash 标准模板特化。这允许对哈希行为进行微调。* 在必要时进行 rehash()reserve()。这可以帮助防止性能意外下降,尤其是在插入新元素时。* 均匀的哈希分布:一个好的哈希函数将在桶之间均匀分布值,最小化冲突的可能性。在将哈希函数部署到性能关键应用之前,通过测试其分布来确保您的哈希函数实现这一点。使用设计良好的哈希函数,将元素均匀分布到桶中,以避免性能下降。* std::unordered_set 不是理想的选择。考虑迁移到 std::set 或利用 STL 中的其他有序容器。* 在多线程应用程序中,std::unordered_set 确保适当的位置同步机制。并发读取是安全的,但写入或同时读取和写入需要外部同步。* std::unordered_set 动态管理其大小,如果您对要存储的元素数量有一个估计,则使用 reserve() 等函数是有益的。这有助于减少重新哈希的次数并提高性能。* 适度使用 erase 成员函数。记住,通过迭代器删除比通过键值删除更快(最坏情况下为 O(1),而通过键值删除为 O(n))。* 由于 std::unordered_set 的哈希机制,它可能比其他容器具有更高的内存开销。在内存敏感的应用程序中,请考虑这一点。

std::unordered_map

将这个容器视为 std::map 的无序版本。它将键与值关联起来,但不强加任何顺序。相反,它依赖于哈希以实现快速操作。

目的和适用性

std::unordered_map 是 STL 中基于哈希表的键值容器。其核心优势如下:

  • 快速的平均情况基于键的访问、插入和删除

  • 维护键值关联的能力

在这种情况下,这个容器是首选:

  • 当插入、删除和查找必须平均快速时

  • 当元素顺序不是关注点时

理想用例

以下是一些 std::unordered_map 的理想用例:

  • std::unordered_map 提供了平均常数时间复杂度用于 searchinsertdelete 操作

  • std::unordered_map 是理想的

  • std::unordered_map 允许您有效地将项目映射到它们的出现次数

  • std::unordered_map 可以将属性映射到对象列表或集合

  • std::unordered_map 可以将设置键与其当前值关联起来,以便快速查找和修改

  • std::unordered_map 可以作为基于唯一标识符快速记录访问的索引

  • std::unordered_map 提供了一种有效的方法来根据唯一键更新和访问数据类别或计数器

  • std::unordered_map 提供了一种高效的结构来处理键值对

  • std::unordered_map 非常宝贵

  • std::unordered_map 可以将资源键与其状态或属性关联起来

总结来说,std::unordered_map 对于需要快速关联查找、插入和删除,且不需要键保持任何特定顺序的场景是最佳的。如果键的序列或排序性质是优先考虑的,那么像 std::map 这样的结构会更合适。

性能

std::unordered_map 的算法性能如下所述:

  • 插入:平均情况下的 O(1),最坏情况下的 O(n)

  • 删除:平均情况下的 O(1),最坏情况下的 O(n)

  • 访问:平均情况下的 O(1),由于潜在的哈希冲突,最坏情况下的 O(n)

  • 内存开销:由于哈希基础设施,通常高于有序映射的对应项

内存管理

std::unordered_map 自动管理其内存,当负载因子超过某些阈值时进行扩容。分配器可以提供对此过程的更精细控制。

线程安全

并发读取是安全的。然而,修改或混合读写需要外部同步,例如使用互斥锁。

扩展和变体

std::map 是有序的对应项,以维护顺序为代价提供 log(n) 的保证。根据您的需求,决定您是否需要顺序或平均情况的速度。

排序和搜索复杂度

其排序和搜索复杂度如下所述:

  • std::unordered_map 本质上是无序的

  • 搜索:基于键的快速 O(1) 平均情况查找

特殊接口和成员函数

除了标准函数(inserterasefind)之外,熟悉以下内容:

  • emplace:就地构建键值对

  • bucket_count:返回桶的数量

  • load_factor:提供当前的负载因子

比较项

std::map 相比,std::unordered_map 以牺牲顺序为代价换取了更快的平均情况操作。无序版本在常数顺序不是关键的场景中通常表现更好。

与算法的交互

大多数与序列一起工作的 STL 算法不能直接应用于键值映射结构。尽管如此,容器提供了针对其用例优化的方法。

异常

内存分配或哈希函数的失败可以抛出异常。一些操作,如 at(),可以抛出 std::out_of_range。确保异常安全性至关重要,尤其是在插入或就地构造时。

自定义

您可以提供自定义哈希函数和键相等函数以进一步优化或调整行为。此外,还提供了自定义分配器以进行内存管理调整。

示例

std::unordered_map 是一个将键与值关联的容器。它与 std::map 类似,但 std::map 按照键的顺序维护其元素,而 std::unordered_map 不维护任何顺序。内部,它使用哈希表,这使得插入、删除和查找具有 O(1) 的复杂度。

以下代码示例展示了使用std::unordered_map时的最佳实践:

#include <iostream>
#include <unordered_map>
void displayMap(
    const std::unordered_map<std::string, int> &map) {
  for (const auto &[key, value] : map) {
    std::cout << key << ": " << value << '\n';
  }
}
int main() {
  std::unordered_map<std::string, int> ageMap;
  ageMap[„Lisa"] = 28;
  ageMap[„Corbin"] = 25;
  ageMap[„Aaron"] = 30;
  std::cout << "Corbin's age: " << ageMap["Corbin"]
            << '\n';
  if (ageMap.find("Daisy") == ageMap.end()) {
    std::cout << "Daisy not found in the map." << '\n';
  } else {
    std::cout << "Daisy's age: " << ageMap["Daisy"]
              << '\n';
  }
  ageMap["Lisa"] = 29;
  std::cout << "Lisa's updated age: " << ageMap["Lisa"]
            << '\n';
  displayMap(ageMap);
  std::cout << "Load factor: " << ageMap.load_factor()
            << '\n';
  std::cout << "Bucket count: " << ageMap.bucket_count()
            << '\n';
  ageMap.rehash(50);
  std::cout << "Bucket count after rehash:"
            << ageMap.bucket_count() << '\n';
  // Remove an entry
  ageMap.erase("Aaron");
  displayMap(ageMap);
  return 0;
}

这里是示例输出:

Corbin's age: 25
Daisy not found in the map.
Lisa's updated age: 29
Aaron: 30
Corbin: 25
Lisa: 29
Load factor: 0.6
Bucket count: 5
Bucket count after rehash:53
Corbin: 25
Lisa: 29

以下是从前面的代码中得出的关键要点:

  • 使用operator[]insert方法向映射中添加元素。请注意,在不存在键上使用索引操作符将创建它并使用默认值。

  • find方法检查键的存在。当你想检查键的存在而不进行潜在插入时,它比使用index操作符更有效。

  • 总是注意地图的负载因子,并在必要时考虑重新散列以保持高效性能。

  • std::unordered_set一样,std::unordered_map中元素的顺序是不保证的。随着元素的插入或删除,顺序可能会改变。

当你需要快速基于键的访问且不关心元素顺序时,std::unordered_map是合适的。如果顺序很重要,那么std::map将是一个更合适的选择。

最佳实践

让我们探讨使用std::unordered_map的最佳实践:

  • 元素顺序不保证:不要假设映射保持元素顺序。

  • 注意哈希冲突:确保在哈希冲突场景中考虑潜在的糟糕性能。

  • 使用std::unordered_map以保持最佳性能。定期检查负载因子,并在必要时考虑重新散列。

  • std::unordered_map高度依赖于所使用的哈希函数的有效性。一个设计不良的哈希函数可能导致性能不佳,因为缓存未命中和冲突解决开销。

  • 使用std::unordered_map来提高内存效率,尤其是在插入和删除频繁的场景中。

  • 检查现有键:在插入之前始终检查现有键以避免覆盖。

  • 使用emplace就地构建条目,减少开销。

  • 使用operator[]访问元素时,std::unordered_map成本较高,这可能是性能陷阱。

std::unordered_multiset

此容器是std::unordered_set的灵活对应物,允许元素出现多次。它结合了散列的速度和非唯一元素的自由度。

目的和适用性

std::unordered_multiset是一个基于哈希表的容器,允许你以无序方式存储多个等效项。其主要吸引力如下:

  • 快速的平均情况插入和查找时间

  • 存储具有相同值的多个项的能力

它特别适合以下场景:

  • 当元素的顺序不重要时

  • 当你预期有多个具有相同值的元素

  • 当你希望插入和查找的平均情况时间复杂度为常数时

当你在搜索允许重复且顺序不重要的容器时,std::unordered_multiset是一个有力的选择。

理想用例

以下是一些std::unordered_multiset的理想用例:

  • std::unordered_multiset是合适的。它允许存储多个相同的元素。

  • std::unordered_multiset可以是一个有效的结构,其中每个唯一值都与其重复项一起存储。

  • std::unordered_multiset可以通过存储碰撞项来有效地管理哈希冲突。

  • std::unordered_multiset可以存储这些重复模式以供进一步分析。

  • std::unordered_multiset效率高,因为它允许插入的平均复杂度为常数时间。

  • std::unordered_multiset可以有效地管理这些标签出现。

  • std::unordered_multiset提供了一种管理这些分组项的方法。

  • std::unordered_multiset可以作为一个高效的内存工具来管理这些冗余数据点。

std::unordered_multiset最适合需要快速插入和查找、允许重复元素且元素顺序不重要的场景。当需要唯一键或有序数据结构时,其他容器,如std::unordered_setstd::map可能更合适。

性能

std::unordered_multiset的算法性能如下:

  • 插入:平均情况下为O(1),但最坏情况下可以是O(n)

  • 删除:平均情况下为O(1)

  • 访问:没有像数组那样的直接访问,但查找元素的平均情况下为O(1)

  • 内存开销:通常,由于哈希机制,这比有序容器要高

一个权衡是,虽然std::unordered_multiset在平均情况下提供O(1)的插入、查找和删除性能,但在最坏情况下性能可能会下降到O(n)

内存管理

std::unordered_multiset动态管理其桶列表。容器可以调整大小,这可能在元素插入且大小超过max_load_factor时自动发生。可以使用分配器来影响内存分配。

线程安全

从容器中读取是线程安全的,但修改(例如,插入或删除)需要外部同步。多个线程同时写入std::unordered_multiset可能导致竞争条件。

扩展和变体

std::unordered_set功能类似,但不允许重复元素。它与std::multiset形成对比,后者保持其元素有序但允许重复。

排序和搜索复杂度

它的排序和搜索复杂度如下:

  • 排序:不是天生有序的,但你可以将元素复制到向量中并对其进行排序

  • 搜索:由于哈希,查找的平均复杂度为O(1)

特殊接口和成员函数

虽然它提供了标准函数(inserterasefind),但你也可以探索以下内容:

  • count:返回与特定值匹配的元素数量

  • bucket:返回给定值的桶号

  • max_load_factor:管理容器决定何时进行大小调整

比较操作

std::multiset 相比,这个容器提供了更快的平均性能,但牺牲了顺序和可能更高的内存使用。

与算法的交互

基于哈希的容器,如 std::unordered_multiset,并不总是像针对有序容器优化过的 STL 算法那样受益。不依赖于元素顺序的算法更可取(即 std::for_eachstd::countstd::all_ofstd::transform 等)。

异常

对于不良分配可能会抛出标准异常。重要的是要知道对 std::unordered_multiset 的操作提供了强大的异常安全性。

定制化

容器支持自定义分配器和哈希函数,允许对内存分配和哈希行为进行精细控制。

示例

std::unordered_multisetstd::unordered_set 类似,但允许相同元素的多重出现。与其他无序容器一样,它内部使用哈希表,因此不维护元素的任何顺序。unordered_multiset 的关键特性是其存储重复元素的能力,这在某些应用中可能很有用,例如根据某些标准对项目进行计数或分类。

以下示例演示了使用 std::unordered_multiset 时的几个最佳实践:

#include <algorithm>
#include <iostream>
#include <string>
#include <unordered_set>
int main() {
  std::unordered_multiset<std::string> fruits;
  fruits.insert("apple");
  fruits.insert("banana");
  fruits.insert("apple");
  fruits.insert("orange");
  fruits.insert("apple");
  fruits.insert("mango");
  fruits.insert("banana");
  const auto appleCount = fruits.count("apple");
  std::cout << "Number of apples: " << appleCount << '\n';
  auto found = fruits.find("orange");
  if (found != fruits.end()) {
    std::cout << "Found: " << *found << '\n';
  } else {
    std::cout << "Orange not found!" << '\n';
  }
  auto range = fruits.equal_range("banana");
  for (auto itr = range.first; itr != range.second;
       ++itr) {
    std::cout << *itr << " ";
  }
  std::cout << '\n';
  fruits.erase("apple");
  std::cout << "Number of apples after erase:"
            << fruits.count("apple") << '\n';
  std::cout << "Load factor: " << fruits.load_factor()
            << '\n';
  std::cout << "Bucket count: " << fruits.bucket_count()
            << '\n';
  fruits.rehash(50);
  std::cout << "Bucket count after rehashing: "
            << fruits.bucket_count() << '\n';
  for (const auto &fruit : fruits) {
    std::cout << fruit << " ";
  }
  std::cout << '\n';
  return 0;
}

这是示例输出:

Number of apples: 3
Found: orange
banana banana
Number of apples after erase:0
Load factor: 0.363636
Bucket count: 11
Bucket count after rehashing: 53
mango banana banana orange

以下是从前面代码中得出的几个关键要点:

  • std::unordered_multiset 可以存储重复值。使用 count 方法检查容器中给定元素出现的次数。

  • equal_range 函数提供了一个迭代器范围,指向特定元素的所有实例。

  • 与其他无序容器一样,要意识到负载因子,并在必要时考虑重新哈希。

  • 记住,unordered_multiset 中的元素是无序的。如果您需要有序数据且允许重复值,应使用 std::multiset

  • 您需要遍历集合并使用基于迭代器的 erase() 方法来删除特定重复值的实例。在前面的示例中,我们为了简单起见移除了所有 apple 的实例。

使用 std::unordered_multiset 来跟踪顺序不重要且允许重复的元素。它为插入、删除和查找提供了高效的平均常数时间复杂度。

最佳实践

让我们探讨使用 std::unordered_multiset 的最佳实践:

  • std::unordered_multisetstd::unordered_set。与 std::unordered_set 不同,std::unordered_multiset 允许重复。如果您的应用程序必须存储多个等效键,请选择 std::unordered_multiset

  • std::unordered_multiset 的能力在于处理重复元素。这在需要跟踪元素多个实例的场景中特别有用。然而,这也意味着像 find() 这样的操作将返回元素第一个实例的迭代器,并且对于某些操作可能需要遍历所有重复项。

  • std::unordered_setstd::unordered_multiset 的性能会受到负载因子的影响。较高的负载因子可能导致更多的哈希冲突,影响性能。相反,较低的负载因子虽然减少了冲突,但可能导致内存效率低下。使用 load_factor() 来监控,并使用 rehash()max_load_factor() 来有效地管理负载因子。

  • 使用 std::unordered_multiset 进行高效的元素分布,尤其是在处理自定义或复杂数据类型时。使用 std::hash 模板特化实现专门的哈希函数,以确保均匀分布并最小化冲突。

  • 由于其无序性,std::unordered_multiset 可能不是最佳选择。在这种情况下,考虑使用 std::multiset,它维护顺序但仍然允许重复。

  • 使用 erase() 函数来删除元素。通过迭代器删除元素是一个 O(1) 操作,而通过值删除在最坏情况下可能需要 O(n)。在设计删除策略时请注意这一点,尤其是在性能关键的应用中。

  • std::unordered_setstd::unordered_multiset 由于其哈希机制,可能会有更高的内存开销。在内存受限的环境中,这应该是一个考虑因素。

  • std::unordered_multiset 支持并发读取,但写入或并发读取和写入需要外部同步机制。这在多线程环境中至关重要,以避免数据竞争并保持数据完整性。

  • std::unordered_multiset,请注意那些期望有序范围的算法,因为它们不适合无序容器。始终确保所选算法与 std::unordered_multiset 的特性相匹配。

std::unordered_multimap

通过结合 std::unordered_map 的原则和多重性的灵活性,这个容器允许单个键与多个值相关联,而无需维护特定的顺序。

目的和适用性

std::unordered_multimap 是一种基于哈希的容器,允许单个键与多个值相关联。与 std::unordered_map 不同,它不强制唯一键。它特别适用于以下场景:

  • 当需要快速的平均情况查找时间时

  • 当你预期同一个键会有多个值时

  • 当键的顺序不重要时,因为元素没有存储在任何特定的顺序

在需要非唯一键和快速查找的情况下选择 std::unordered_multimap。如果顺序或唯一键很重要,请考虑其他选项。

理想用例

以下是一些 std::unordered_multimap 的理想用例:

  • std::unordered_multimap 是一个合适的容器。例如,一个作者(键)可以在作者及其书籍(值)的数据库中拥有多本书。

  • std::unordered_multimap 是有益的。

  • std::unordered_multimap 可以通过将冲突键链接到它们相应的值来管理哈希冲突。

  • std::unordered_multimap 可以组织这些标签到项目或项目到标签的关系。

  • std::unordered_multimap 可以是一个高效的内存工具。

  • std::unordered_multimap 可以作为存储系统。例如,如果你按出生年份分组人员,其中一年(键)可以对应许多人(值)。

  • std::unordered_multimap 很有用。一个例子是颜色命名系统,其中一种颜色可以有多个相关名称。

  • std::unordered_multimap 可以将一个坐标与该空间中的多个对象关联起来。

std::unordered_multimap 是一个高度通用的工具,适用于需要快速插入和查找的应用程序,并且一个键应与多个值相关联。当需要唯一键或有序数据结构时,其他容器,如 std::unordered_mapstd::set,可能更合适。

性能

std::unordered_multimap 的算法性能如下:

  • 插入:平均情况 O(1),最坏情况 O(n)

  • 删除:平均情况 O(1),最坏情况 O(n)

  • 访问:平均情况 O(1),最坏情况 O(n)

  • 内存开销:由于哈希基础设施,适中,可能因哈希冲突而增加

它的权衡包括快速的平均情况操作,但如果哈希冲突变得普遍,可能会出现潜在的减速。

内存管理

当负载因子超过其最大值时,std::unordered_multimap 会进行大小调整。可以使用分配器来定制内存行为,包括分配和释放策略。

线程安全

从不同实例读取是线程安全的。然而,对同一实例的并发读取和写入需要外部同步。

扩展和变体

std::unordered_map 是一个包含唯一键的变体。如果你需要有序键行为,std::multimapstd::map 是基于树的替代方案。

排序和搜索复杂度

它的排序和搜索复杂度如下:

  • 排序:由于其无序性,本身不可排序;必须复制到可排序的容器中

  • 搜索:由于哈希,平均情况 O(1),但在存在许多哈希冲突的情况下可能会降低

特殊接口和成员函数

除了常见的函数(insertfinderase)之外,深入了解以下内容:

  • emplace:直接在容器中构建元素

  • bucket:获取给定键的桶号

  • load_factor:提供元素到桶的比率

比较操作

std::unordered_map 相比,这个容器允许非唯一键。如果键顺序很重要,std::multimap 是一个基于树的替代方案。

与算法的交互

由于是无序的,许多为有序序列设计的 STL 算法可能不直接适用,或者需要不同的方法。

异常处理

内存分配失败或哈希函数复杂性问题可能会抛出异常。容器操作提供基本的异常安全性,确保容器保持有效。

自定义

您可以使用自定义分配器进行内存调整。自定义哈希函数或键相等谓词也可以针对特定用例优化行为。

示例

std::unordered_multimapstd::unordered_map 类似,但允许具有等效键的多个键值对。它是一个关联容器,其值类型是通过结合其键和映射类型形成的。

以下代码示例演示了使用 std::unordered_multimap 的一些最佳实践:

#include <iostream>
#include <string>
#include <unordered_map>
int main() {
  std::unordered_multimap<std::string, int> grades;
  grades.insert({"Lisa", 85});
  grades.insert({"Corbin", 92});
  grades.insert({"Lisa", 89});
  grades.insert({"Aaron", 76});
  grades.insert({"Corbin", 88});
  grades.insert({"Regan", 91});
  size_t lisaCount = grades.count("Lisa");
  std::cout << "Number of grade entries for Lisa: "
            << lisaCount << '\n';
  auto range = grades.equal_range("Lisa");
  for (auto it = range.first; it != range.second; ++it) {
    std::cout << it->first << " has grade: " << it->second
              << '\n';
  }
  auto lisaGrade = grades.find("Lisa");
  if (lisaGrade != grades.end()) {
    lisaGrade->second = 90; // Updating the grade
  }
  grades.erase("Corbin"); // This will erase all grade
                          // entries for Corbin
  std::cout
      << "Number of grade entries for Corbin after erase: "
      << grades.count("Corbin") << '\n';
  std::cout << "Load factor: " << grades.load_factor()
            << '\n';
  std::cout << "Bucket count: " << grades.bucket_count()
            << '\n';
  grades.rehash(50);
  std::cout << "Bucket count after rehashing: "
            << grades.bucket_count() << '\n';
  for (const auto &entry : grades) {
    std::cout << entry.first
              << " received grade: " << entry.second
              << '\n';
  }
  return 0;
}

下面是示例输出:

Number of grade entries for Lisa: 2
Lisa has grade: 85
Lisa has grade: 89
Number of grade entries for Corbin after erase: 0
Load factor: 0.363636
Bucket count: 11
Bucket count after rehashing: 53
Regan received grade: 91
Aaron received grade: 76
Lisa received grade: 90
Lisa received grade: 89

下面是从前面的代码中得出的几个要点:

  • std::unordered_multimap 中,可以插入具有相同键的多个键值对。

  • 您可以使用 equal_range 获取与特定键关联的所有键值对的迭代器范围。

  • count 方法可以帮助您确定具有特定键的键值对数量。

  • 如同其他无序容器一样,您应该注意负载因子,并在必要时重新散列以实现最佳性能。

  • 使用带有键的 erase() 方法将删除与该键关联的所有键值对。

  • 由于它是一个无序容器,元素的顺序是不保证的。

  • 当您需要跟踪与同一键关联的多个值且不需要对键值对进行排序时,请使用 std::unordered_multimap。它为大多数操作提供平均常数时间复杂度。

最佳实践

让我们探索使用 std::unordered_multimap 的最佳实践:

  • std::unordered_multimap 表示容器不维护其键值对的具体顺序。遍历容器不保证任何特定序列。

  • std::unordered_multimap 的能力是存储单个键的多个条目。在插入、删除或搜索时记住这一点,以避免意外的逻辑错误。

  • 使用 load_factor() 函数来监控当前的负载因子。如果它变得过高,可以考虑使用 rehash() 函数重新散列容器。也可以使用 max_load_factor() 函数设置负载因子的期望上限。

  • 为自定义数据类型提供 std::hash 模板特化,以确保高效且一致的散列。

  • 处理哈希冲突:即使有高效的哈希函数,也可能发生冲突。容器内部处理这些冲突,但了解它们有助于做出更好的设计决策。冲突可能导致插入和搜索操作的性能下降,因此平衡负载因子和桶的数量是至关重要的。

  • 在遍历与特定键关联的所有值时使用 equal_range()

  • 迭代器失效:迭代器失效可能是一个问题,尤其是在重新散列等操作之后。始终确保在可能失效之后不使用指向元素的迭代器、指针或引用。

  • emplaceemplace_hint 方法。这些方法允许在容器内直接构造键值对。

  • 并发考虑: 并发读取是线程安全的,但对于任何修改或并发读取和写入,你需要外部同步。在多线程场景中使用同步原语,如互斥锁(mutexes)。

  • std::unordered_multimap。然而,确保选定的算法不期望排序或唯一键,因为这些假设会与容器的属性相矛盾。

第九章:高级容器适配器使用

容器适配器,正如其名称所示,将底层容器适配以提供特定的接口和功能。可以将它们视为一种增强或修改现有容器的方法,使其服务于不同的目的,而无需重新发明轮子。它们围绕基容器,提供一组独特的成员函数,赋予它们在各种编程场景中可能有用的行为。

本章提供了以下容器的参考:

  • std::stack

  • std::queue

  • std::priority_queue

  • std::flat_set

  • std::flat_map

  • std::flat_multiset

  • std::flat_multimap

技术要求

本章的代码可以在 GitHub 上找到:

github.com/PacktPublishing/Data-Structures-and-Algorithms-with-the-CPP-STL

std::stack

std::stack是一种表示栈的数据结构,它是一个std::dequestd::vectorstd::list,提供了一个简单且易于使用的接口来处理栈。你可以将元素推送到栈顶,从栈顶弹出元素,并访问栈顶元素而无需访问其他位置的元素。std::stack通常用于需要类似栈的行为的任务,例如跟踪函数调用序列、解析表达式和管理临时数据。它提供了一种方便的方式来管理数据,以确保最近添加的元素是第一个被移除的。

目的和适用性

std::stack是一个容器适配器,旨在提供 LIFO 数据结构。它基于另一个容器操作,例如std::vectorstd::dequestd::list

它在以下场景中特别适用:

  • 当需要 LIFO 行为时

  • 当你只需要访问最近添加的元素时

  • 当插入和删除仅发生在一边时

当你需要一个简单的接口来以 LIFO 方式管理数据时,请选择std::stack。对于更灵活的操作,请考虑其底层容器。

理想用例

以下是一些std::stack的理想用例:

  • 表达式评估和解析:例如,评估后缀表达式

  • 回溯算法:例如,在图中执行深度优先搜索

  • 撤销操作在软件中:维护用户操作的历史记录以撤销它们

性能

由于std::stack是一个容器适配器,其算法性能取决于底层容器的实现:

  • 插入(****push)O(1)

  • 删除(****pop)O(1)

  • 访问(****top)O(1)

  • 内存开销:直接与底层容器相关

内存管理

std::stack的行为与其底层容器类似。例如,如果底层是std::vector,则调整大小可能涉及重新分配,将其内存加倍。

线程安全

与大多数 STL 容器一样,std::stack 在写操作上不是线程安全的。对于并发写入或读写组合,需要外部同步。

扩展和变体

std::queuestd::priority_queue 是 STL 中的其他适配器,分别提供 先进先出FIFO)行为和基于优先级的访问。

排序和搜索的复杂度

排序和搜索本身并不适合 std::stack。你可能需要将元素转移到不同的容器中进行排序或搜索。

特殊接口和成员函数

std::stack 设计为提供三个特殊成员函数:

  • push:将一个元素推入栈顶

  • pop:从栈顶移除(弹出)一个元素

  • top:获取栈顶元素的值,而不移除它

比较操作

与原始底层容器相比,std::stack 提供了一个针对 LIFO 操作定制的受限接口。

与算法的交互

由于缺乏迭代器支持,与 STL 算法的直接交互有限。对于算法操作,请直接考虑底层容器。

异常

在空栈上尝试操作,如 poptop,不会抛出异常,但会导致未定义的行为。在执行此类操作之前,请确保栈不为空。

定制化

虽然 std::stack 的行为无法改变太多,但使用自定义分配器或选择特定的底层容器可以影响性能和存储特性。

示例

以下代码展示了使用 std::stack 的示例。此示例实现了一个函数来评估 逆波兰表示法RPN),一种后缀数学表示法。使用栈是此类问题的自然选择:

#include <iostream>
#include <sstream>
#include <stack>
#include <string>
double evaluateRPN(const std::string &expression) {
  std::stack<double> s;
  std::istringstream iss(expression);
  std::string token;
  while (iss >> token) {
    if (token == "+" || token == "-" || token == "*" ||
        token == "/") {
      if (s.size() < 2) {
        throw std::runtime_error("Invalid RPN expression");
      }
      double b = s.top();
      s.pop();
      double a = s.top();
      s.pop();
      if (token == "+") {
        s.push(a + b);
      } else if (token == "-") {
        s.push(a - b);
      } else if (token == "*") {
        s.push(a * b);
      } else if (token == "/") {
        if (b == 0.0) {
          throw std::runtime_error("Division by zero");
        }
        s.push(a / b);
      }
    } else {
      s.push(std::stod(token));
    }
  }
  if (s.size() != 1) {
    throw std::runtime_error("Invalid RPN expression");
  }
  return s.top();
}
int main() {
  try {
    // Evaluate RPN expressions
    std::cout << "46 2 + = " << evaluateRPN("46 2 +")
              << "\n"; // 48
    std::cout << "5 1 2 + 4 * + 3 - = "
              << evaluateRPN("5 1 2 + 4 * + 3 -")
              << "\n"; // 14
    std::cout << "3 4 5 * - = " << evaluateRPN("3 4 5 * -")
              << "\n"; // -17
  } catch (const std::exception &e) {
    std::cerr << "Error: " << e.what() << "\n";
  }
  return 0;
}

以下是一个示例输出:

46 2 + = 48
5 1 2 + 4 * + 3 - = 14
3 4 5 * - = -17

在前面的例子中,发生以下情况:

  • 我们使用 std::stack 来管理操作数并评估逆波兰表达式(RPN)。

  • 操作数被推入栈中。当遇到运算符时,从栈中弹出必要的操作数(通常是两个)。然后执行操作。最后,将结果推回栈中。

  • 如果表达式在评估结束时有效,栈中应该恰好有一个数字:结果。

  • 函数处理可能出现的错误,例如无效的 RPN 表达式或除以零。

这是对 std::stack 的典型使用,因为它展示了数据结构的 LIFO(后进先出)特性和其基本操作(pushpoptop)。

最佳实践

让我们探讨使用 std::stack 的最佳实践:

  • 保持 LIFO 规律:栈是为 LIFO 操作设计的。避免直接操作底层容器以访问除了栈顶元素之外的其他元素。绕过 LIFO 逻辑会损害使用栈的目的和完整性。

  • 在执行top()pop()操作之前,始终使用empty()函数验证栈是否为空。从空栈访问或弹出会导致未定义行为和潜在的运行时错误。

  • std::stack使用std::deque作为其容器,这通常提供高效的推入和弹出操作。虽然你可以使用容器如std::vectorstd::list来自定义它,但请注意它们各自的性能和内存特性。例如,虽然std::vector可能会有偶尔的调整大小开销,但std::list具有每个元素的额外开销。

  • std::stack本身不保证线程安全性。如果你从多个线程访问或修改栈,请使用适当的同步机制,如std::mutex,以防止数据竞争并保持一致性。

  • std::stack接口限制你只能访问栈顶元素,底层容器可能不限制。直接使用底层容器可以提供更广泛的数据访问,但如果不小心,可能会引入错误。

  • 使用emplace直接在栈内构建元素。这可以减少对临时对象的需求以及潜在的复制/移动操作,从而提高代码的效率和简洁性。

  • 异常安全性:某些操作可能提供基本或强异常安全性,这取决于底层容器。了解这些保证是至关重要的,特别是如果你的应用程序需要一定级别的异常安全性。

  • std::stack不直接暴露容量或预留机制,底层容器(尤其是如果它是std::vector)可能具有这种行为。如果你对栈的增长模式有信心,请考虑使用适当的底层容器并管理其容量以进行优化。

  • auto,请明确了解你的栈的类型。这包括它持有的元素类型和底层容器。这种清晰度确保你始终了解栈的性能特性和限制。

  • std::vector<bool>,使用带有特定底层容器的std::stack<bool>可能会由于容器特殊化而产生意外的行为或不效率。如果你需要一个布尔值的栈,请考虑替代方案或充分了解特定容器对布尔类型的处理行为。

std::queue

std::queue 表示一个 FIFO 数据结构。它作为适配器类实现,通常基于其他底层容器,如 std::dequestd::liststd::queue 提供了一个简单的接口来处理队列,允许您在队列末尾(push)插入元素,并从队列前端(pop)删除元素。它在 C++ 中常用,例如在需要按添加顺序处理数据的场景中,如任务调度、图的广度优先遍历或树,以及在多线程程序中管理工作项。std::queue 确保队列中最长的元素是第一个被删除的,这使得它成为管理有序数据处理的有用工具。

目的和适用性

std::queue 是一个容器适配器,它建立在另一个容器(如 std::dequestd::liststd::vector)之上。其主要目的是提供 FIFO 数据访问。

它特别适合以下场景:

  • 当需要顺序访问时

  • 当元素需要按插入顺序处理时

如果搜索、排序或随机访问是主要关注点,std::queue 可能不是最佳选择。

理想的使用场景

以下是一些 std::queue 的理想使用场景:

  • 任务调度:按任务到达的顺序管理任务

  • 数据序列化:确保数据按接收顺序处理

  • 树遍历:图的广度优先遍历或树的遍历

性能

由于 std::queue 是一个容器适配器,其算法性能取决于底层容器的实现:

  • 插入(pushO(1)

  • 删除(popO(1)

  • 访问(前和后)O(1)

  • 内存开销:取决于底层容器

性能特征主要来自基本容器,通常是 std::deque

内存管理

它的内存行为取决于底层容器。例如,如果您正在使用 std::deque,它管理内存块并且可以在两端增长。

线程安全

读取和写入本身不是线程安全的。如果需要并发访问,则必须使用外部同步,例如互斥锁。

扩展和变体

std::priority_queue 是另一个适配器,它根据优先级而不是插入顺序提供对最高元素的访问。

排序和搜索复杂度

排序和搜索不适用于 std::queuestd::queue 是为 FIFO 访问设计的。排序或随机搜索需要手动遍历底层容器,这既不高效,也违背了队列的目的。

特殊接口和成员函数

它的主要操作包括 push()pop()front()back()size()empty() 用于大小检查和空检查。

比较

与提供 LIFO 访问的 std::stack 相比,std::queue 确保了 FIFO 行为。如果需要随机访问,那么 std::vector 可能更合适。

与算法的交互

由于缺乏迭代器,与大多数 STL 算法的直接交互有限。如果需要算法操作,你通常会直接在底层容器上工作。

异常

抛出的异常取决于底层容器的操作。然而,从空队列中访问元素(使用 front()back())可能会导致未定义的行为。

定制化

通过选择适当的底层容器,并可能使用自定义分配器,可以定制内存管理。

示例

std::queue 的一个日常用例是实现 std::queue。以下是一个在无向图上使用邻接表表示的基本 BFS 实现示例:

#include <iostream>
#include <queue>
#include <vector>
class Graph {
public:
  Graph(int vertices) : numVertices(vertices) {
    adjList.resize(vertices);
  }
  void addEdge(int v, int w) {
    adjList[v].push_back(w);
    adjList[w].push_back(v);
  }
  void BFS(int startVertex) {
    std::vector<bool> visited(numVertices, false);
    std::queue<int> q;
    visited[startVertex] = true;
    q.push(startVertex);
    while (!q.empty()) {
      int currentVertex = q.front();
      std::cout << currentVertex << " ";
      q.pop();
      for (int neighbor : adjList[currentVertex]) {
        if (!visited[neighbor]) {
          visited[neighbor] = true;
          q.push(neighbor);
        }
      }
    }
  }
private:
  int numVertices{0};
  std::vector<std::vector<int>> adjList;
};
int main() {
  Graph g(6);
  g.addEdge(0, 1);
  g.addEdge(0, 2);
  g.addEdge(1, 3);
  g.addEdge(1, 4);
  g.addEdge(2, 4);
  g.addEdge(3, 4);
  g.addEdge(3, 5);
  std::cout << "BFS starting from vertex 0: ";
  g.BFS(0); // Output: 0 1 2 3 4 5
  return 0;
}

这里是示例输出:

BFS starting from vertex 0: 0 1 2 3 4 5

以下要点解释了代码:

  • Graph 类使用邻接表(adjList)来表示图。

  • BFS 遍历从给定的顶点开始,将其标记为已访问,然后探索其邻居。邻居被添加到队列中,并按它们遇到的顺序(FIFO 顺序)进行处理,确保广度优先遍历。

  • 随着顶点的访问,它们在访问向量中被标记,以确保它们不会被多次处理。

  • BFS 函数使用 std::queue 的主要操作:push 将顶点添加到队列中,front 检查下一个要处理的顶点,pop 移除它。

最佳实践

让我们探索使用 std::queue 的最佳实践:

  • 保持 FIFO 纪律:队列天生就是为了 FIFO 操作设计的。尝试将其用于其他目的,如随机访问或使用 LIFO 顺序的栈操作,可能会导致次优设计和复杂性。

  • std::queue 不直接暴露迭代器。如果你需要遍历元素,考虑队列是否适合你的需求,或者是否应该直接访问底层容器。

  • 使用 front()back() 时,始终使用 empty() 函数检查队列是否为空。这可以防止尝试访问空队列中的元素时可能出现的未定义行为。

  • std::queue 实际上是 std::deque,但你可以使用其他容器,例如 std::list。每个容器都有其特性、权衡和内存开销。例如,虽然 std::list 提供了高效的插入和删除操作,但其每个元素的内存开销比 std::deque 高。

  • 使用 std::mutex 来避免数据竞争和不一致性。对 std::queue 本身的操作不是天生线程安全的。

  • std::vector 容器(尽管对于队列来说这很少见)。这在实时或性能关键的应用程序中可能会引起性能问题。

  • 使用 emplace 在队列中直接构建元素。这可以导致更高效的代码,因为它避免了临时对象的创建。

  • std::vectorstd::queue 没有与容量相关的成员函数。在没有明确知识或控制的情况下,不要对底层容器的大小或容量做出假设。

  • std::deque为其操作提供了强大的异常安全性,确保在异常期间数据不会被损坏。

  • 小心使用类型别名:如果你正在使用类型别名或自动类型推导,确保你知道队列的确切类型,特别是如果你在同一代码库中处理不同底层容器的队列时。这确保你不会错误地假设不同容器类型的特性或性能权衡。

std::priority_queue

目的和适用性

std::priority_queue是一个基于随机访问容器类型(主要是std::vector)构建的适配器容器。其核心优势围绕以下方面:

  • 总是保持最高优先级元素在顶部

  • 确保高效地插入和检索最高元素

它在以下场景中表现出色:

  • 当需要基于优先级的访问时

  • 当插入是随机的,但访问总是针对最高重要性的元素时

在顺序不是关键因素,或者插入顺序比访问优先级更重要的情况下,std::priority_queue可能不是最佳选择。

理想使用场景

以下是一些std::priority_queue的理想使用场景:

  • 工作调度:根据紧急程度或优先级分配工作

  • 路径查找算法:这类算法的一个例子是 Dijkstra 算法,其中首先处理具有最短试探距离的节点

  • 模拟系统:对于应根据优先级而不是顺序处理的事件

性能

由于std::priority_queue是一个容器适配器,其算法性能取决于底层容器实现:

  • 插入O(log n),因为元素根据其优先级放置在其合适的位置

  • 删除:最高元素为O(log n),因为队列在重新结构化自身

  • 访问:最高元素为O(1)

  • 内存开销:中等,取决于底层容器

注意,当使用std::vector作为底层容器时,在调整大小过程中可能会出现额外的内存开销。

内存管理

这本质上取决于底层容器。使用std::vector时,在达到容量时可能会发生内存重新分配。可以使用分配器进行定制。

线程安全

并发访问需要谨慎。多个读取是安全的,但同时读取或写入需要外部同步机制,例如互斥锁。

扩展和变体

如果你需要一个确保序列保持的容器,你可能会考虑std::queue。如果你需要一个具有键值对和固有排序的关联容器,std::mapstd::set可能更合适。

排序和搜索复杂度

排序不适用于std::priority_queue。直接访问最高优先级元素进行搜索是O(1)。然而,搜索其他元素并不直接,也不是这个容器的主要目的。

特殊接口和成员函数

除了基本操作(pushpoptop)之外,探索以下内容:

  • emplace:直接在优先队列内构建一个元素

  • size:检索元素数量

  • swap:交换两个优先队列的内容

比较操作

与尊重 FIFO 排序的std::queue相比,std::priority_queue始终确保可以访问最高优先级元素。与允许有序访问所有元素的std::set相比,前者专注于优先级。

与算法的交互

由于缺乏迭代器,大多数 STL 算法不能直接与std::priority_queue交互。然而,它自然与关注最高优先级元素的用户定义算法相吻合,例如使用push()pop()的算法。

异常

抛出异常可能发生在底层容器操作期间,例如内存分配。异常安全性通常与底层容器的异常安全性相一致。

自定义

这里有一些自定义选项:

  • 分配器:使用自定义分配器自定义内存分配

  • 比较器:使用自定义比较函数修改优先级逻辑,允许自定义优先级的定义

示例

std::priority_queue常用于需要根据其优先级处理元素的场景。使用std::priority_queue的最常见示例之一是实现加权图的 Dijkstra 最短路径算法。

以下代码展示了使用std::priority_queue实现 Dijkstra 算法的示例:

#include <climits>
#include <iostream>
#include <list>
#include <queue>
#include <vector>
class WeightedGraph {
public:
  WeightedGraph(int vertices) : numVertices(vertices) {
    adjList.resize(vertices);
  }
  void addEdge(int u, int v, int weight) {
    adjList[u].push_back({v, weight});
    adjList[v].push_back({u, weight});
  }
  void dijkstra(int startVertex) {
    std::priority_queue<std::pair<int, int>,
                        std::vector<std::pair<int, int>>,
                        std::greater<std::pair<int, int>>>
        pq;
    std::vector<int> distances(numVertices, INT_MAX);
    pq.push({0, startVertex});
    distances[startVertex] = 0;
    while (!pq.empty()) {
      int currentVertex = pq.top().second;
      pq.pop();
      for (auto &neighbor : adjList[currentVertex]) {
        int vertex = neighbor.first;
        int weight = neighbor.second;
        if (distances[vertex] >
            distances[currentVertex] + weight) {
          distances[vertex] =
              distances[currentVertex] + weight;
          pq.push({distances[vertex], vertex});
        }
      }
    }
    std::cout << "Distances from vertex " << startVertex
              << ":\n";
    for (int i = 0; i < numVertices; ++i) {
      std::cout << i << " -> " << distances[i] << '\n';
    }
  }
private:
  int numVertices{0};
  std::vector<std::list<std::pair<int, int>>> adjList;
};
int main() {
  WeightedGraph g(5);
  g.addEdge(0, 1, 9);
  g.addEdge(0, 2, 6);
  g.addEdge(0, 3, 5);
  g.addEdge(1, 3, 2);
  g.addEdge(2, 4, 1);
  g.addEdge(3, 4, 2);
  g.dijkstra(0);
  return 0;
}

这里是示例输出:

Distances from vertex 0:
0 -> 0
1 -> 7
2 -> 6
3 -> 5
4 -> 7

在此实现中发生以下情况:

  • WeightedGraph类使用邻接表来表示图,其中每个列表元素都是一个表示相邻顶点和边权重的对。

  • dijkstra函数计算图中的给定顶点到所有其他顶点的最短距离。

  • std::priority_queue用于选择具有已知最短距离的最小顶点进行处理。

  • 基于当前处理的顶点和其邻居,更新到顶点的距离。

  • 随着算法的进行,priority_queue确保顶点按其已知最短距离的递增顺序进行处理。

使用std::priority_queue为 Dijkstra 算法中始终处理具有已知最小距离的顶点提供了一种高效的方法。

最佳实践

让我们探讨使用std::priority_queue的最佳实践:

  • std::priority_queue用于高效访问最高优先级元素,而不是提供所有元素的有序访问。不要假设您可以按完全排序的顺序访问元素。

  • 自定义优先级规则:如果默认的比较逻辑不符合您的需求,请始终提供自定义比较器。这确保队列根据您特定的优先级规则维护元素。

  • std::priority_queue 使用 std::vector 作为其底层容器。虽然这通常很合适,但切换到如 std::dequestd::list 这样的容器可能会影响性能。选择与你的具体要求相匹配的容器。

  • 检查是否为空:在尝试访问顶部元素或执行弹出操作之前,始终验证队列不为空。这可以防止未定义的行为。

  • 避免底层容器操作:直接操作底层容器可能会破坏优先队列的完整性。避免这样做以确保优先级顺序保持一致。

  • 使用 emplace 方法而不是 push。这提供了更高效的就地构造,并且可以节省不必要的复制或移动。

  • std::priority_queue 本身不是线程安全的。如果你需要在多个线程中访问或修改它,请确保使用适当的同步机制。

  • 注意内部排序:虽然将优先队列视为始终持有排序元素列表很诱人,但请记住,它仅确保最顶端的元素具有最高优先级。其他元素的内部顺序不保证排序。

  • std::priority_queue 不提供其元素的迭代器。这种设计有意让用户避免意外破坏队列的优先级不变性。

  • 大小考虑:注意底层容器的尺寸和容量,尤其是当你处理大量数据集时。定期检查和管理容量可以帮助优化内存使用。

通过遵循这些最佳实践,你可以确保以高效且与其设计意图一致的方式使用 std::priority_queue

std::flat_set

std::flat_set 是一个排序的关联容器,旨在以排序顺序存储唯一元素的集合。与其他关联容器(如 std::set)相比,std::flat_set 的特点是它实现为一个扁平容器,通常基于排序的 std::vector 容器。这意味着元素在内存中连续存储,与传统的基于树的关联容器相比,具有最优的内存使用和更快的迭代时间。

std::flat_set 维护其元素在一个有序顺序中,允许进行高效的搜索、插入和删除操作,同时提供与其他 C++ STL 中类似容器(如集合)的功能和接口。它在需要排序存储和高效内存管理优势时特别有用。

目的和适用性

std::flat_set 是一个表示存储在排序扁平数组中的关联集合的容器。它结合了 std::vector 容器(如缓存友好性)和 std::set 容器(如有序存储)的优点。

在以下场景中使用 std::flat_set

  • 当你需要具有集合属性的有序数据时

  • 当内存分配开销是一个关注点时

  • 当你想利用与 std::vector 类似的缓存局部性优势时

如果你需要执行许多插入和删除操作,其他集合类型,如 std::set,可能更适合,因为它们的实现基于树。

理想使用场景

以下是一些 std::flat_set 的理想使用场景:

  • std::flat_set 容器和对其进行排序可以很高效

  • std::flat_set 容器对于较小的尺寸可以明显快于基于树的集合

  • std::flat_set 可以具有优势

性能

由于 std::flat_set 是一个容器适配器,其算法性能取决于底层容器的实现:

  • 插入O(n),因为可能需要移动

  • 删除O(n),原因相同

  • 访问:使用二分搜索进行查找的 O(log n)

  • 内存开销:由于内存分配较少,少于基于树的结构

代价是查找速度与插入和删除成本的权衡,尤其是在集合增长时。

内存管理

std::flat_set 使用一段连续的内存块(类似于 std::vector)。当这块内存耗尽时会发生重新分配。你可以使用自定义分配器来影响分配策略。

线程安全

与大多数 STL 容器一样,并发读取是安全的,但写入或混合操作需要外部同步。

扩展和变体

std::flat_mapstd::flat_set 的一个近亲,它在一个扁平结构中存储键值对。它提供了类似的表现特性和用途。

排序和搜索复杂度

它的排序和搜索复杂度如下:

  • 排序:固有的容器操作,通常为 O(n log n)

  • 搜索:由于对排序数据进行二分搜索,O(log n)

特殊接口和成员函数

除了典型的集合函数(inserterasefind)之外,请考虑以下内容:

  • reserve:为了预期插入操作而分配内存

  • capacity:返回当前的分配大小

比较操作

std::set 相比,std::flat_set 提供更好的缓存局部性,但在大型数据集中频繁的插入/删除操作中可能会变得低效。

与算法的交互

需要随机访问迭代器的 STL 算法,如 std::sort(),可以直接应用。然而,请记住 std::flat_set 维持其排序顺序,因此手动排序是多余的。

异常

错误使用迭代器或超出容量可能导致异常。许多操作提供了强大的异常安全性,确保容器的一致性。

定制化

std::flat_set 允许使用自定义分配器,从而实现精细的内存控制。你也可以提供自定义比较器以进行专门的排序。

最佳实践

让我们探讨使用 std::flat_set 的最佳实践:

  • std::flat_set 最适合于一旦构建就多次查询的用例。如果你的应用程序需要频繁的插入和删除,传统的基于树的 std::set 容器可能更合适。

  • std::flat_set 相比其他集合实现的优势在于其连续的内存布局,这使得它对缓存友好。这可以导致对于小数据集或数据可以放入缓存的情况,性能有显著提升。

  • 如果需要合并 std::flat_set 容器,考虑首先将所有元素插入到一个容器中,然后排序并使整个集合唯一。这种方法通常比逐个元素合并排序集合更有效。

  • 如果你有一个合理的元素插入数量估计,建议使用 reserve 方法。这可以最小化内存重新分配并提高性能。

  • std::set 容器,它是基于树的,可以更优雅地处理此类修改。

  • std::flat_set 维护其元素在一个排序顺序中,避免手动对容器进行排序至关重要。向 std::flat_set 容器中添加元素将根据提供的比较器保持它们的顺序。

  • std::flat_set 提供了如 find 等成员函数,用于高效搜索,它们针对其内部结构进行了优化。使用这些成员函数通常比应用泛型算法更有效。如果你需要使用算法,确保它们是为排序序列设计的,例如 std::lower_boundstd::upper_bound

  • std::flat_set 的默认比较器是 std::less。然而,如果你的数据需要自定义排序逻辑,确保在集合构建时提供自定义比较器。记住,这个比较器应该提供严格的弱顺序以保持集合的性质。

  • std::flat_set 提供。如果你的用例有这种模式,评估其他容器可能更适合。

  • std::flat_set 容器中修改元素(例如,通过迭代器)而不确保顺序,可能会导致未定义的行为。始终确认修改后排序顺序保持不变。

  • std::flat_set 与经过优化的随机访问迭代器的算法配合良好。然而,对于修改顺序或内容的算法要小心,因为它们可能会违反集合的性质。

std::flat_map

std::flat_map 是一个排序的关联容器,它结合了 map 和 flat 容器的特性。类似于 std::map,它允许你存储键值对,键是唯一的且有序的。然而,与通常作为平衡二叉搜索树实现的 std::map 不同,std::flat_map 是一个 flat 容器,通常基于一个排序的 std::vector 容器。这意味着 std::flat_map 提供了比传统的基于树的关联容器(如 std::map)更高效的内存使用和更快的迭代时间。std::flat_map 容器中的元素在内存中连续存储,这可能导致更好的缓存局部性和针对某些用例的性能提升。

std::flat_map提供了类似于std::map的功能和接口,允许你在保持元素排序顺序的同时执行插入、删除和搜索等操作。当你需要排序存储的优势和平坦容器的优势时,它是有益的。

目的和适用性

std::flat_map是一个将键和值配对的容器,作为关联数组使用。以下原因使其与其他映射容器区分开来:

  • 它使用类似向量的结构,提供了缓存局部性的优势。

  • 这种连续的内存布局在某些场景中促进了改进的查找时间。

它的利基市场在于以下场景:

  • 当映射主要是在构建一次然后经常查询时

  • 当迭代速度和缓存局部性比插入/删除速度更重要时

  • 当排序的映射表示至关重要时

如果你预见初始化后频繁的修改,考虑使用std::map

理想用例

以下是一些std::flat_map的理想用例:

  • 配置数据:存储在启动时加载一次但在应用程序运行时频繁查询的配置键值对

  • 空间索引:在图形或游戏开发中,快速迭代和检索比频繁修改更重要

  • 数据序列化:对于需要排序和偶尔查找但不是经常修改的数据集

性能

  • 插入:由于底层向量结构,为O(n)

  • 删除:由于可能需要移动元素,为O(n)

  • 访问:由于对排序数组进行二分搜索,为O(log n)

  • 内存开销:通常,这很低,但如果预留容量未有效利用,则可能会增加

内存管理

std::flat_map,像std::vector一样,当其容量超过时可能会重新分配。如果你可以预测最终的大小,使用reserve是明智的。分配器可以提供对内存管理行为的控制。

线程安全

虽然并发读取是安全的,但写入或两者的组合需要外部同步——例如,使用互斥锁。

扩展和变体

对于无序关联容器,STL 提供了std::unordered_map。如果你更喜欢具有有序键的平衡树结构,那么std::map是你的首选。

排序和搜索复杂度

它的排序和搜索复杂度如下:

  • 排序:由于其结构固有,它始终保持有序

  • 搜索:由于二分搜索,为O(log n)

接口和成员函数

常见成员如insertfinderase都存在。但是,你也应该探索以下珍宝:

  • emplace: 直接在原地构建元素

  • lower_boundupper_bound:这些提供了有效的范围搜索

  • at: 通过键提供带边界检查的直接访问值

比较操作

std::flat_map 在迭代和查找性能方面表现出色,尤其是在较小的数据集上。然而,如果频繁的修改主导了您的用例,您可能会倾向于 std::map

与算法的交互

由于其随机访问特性,std::flat_map 与在迭代器上茁壮成长的 STL 算法配合良好。然而,任何破坏键顺序的算法都应谨慎处理。

异常

超出容量或访问越界键可能会触发异常。许多操作提供了强大的异常安全性,在出现异常时保留映射状态。

定制化

std::flat_map 允许自定义分配器,您可以在构造时指定自定义比较器以指定键顺序。

最佳实践

让我们探索使用 std::flat_map 的最佳实践:

  • 由于插入和删除的高成本,std::flat_map 适用于频繁的插入和删除。对于此类情况,请考虑替代方案,例如 std::map

  • 关键修改:不要直接使用迭代器修改键。这会破坏映射的排序顺序。如果需要修改键,请考虑删除旧键值对并插入一个新的,以确保顺序维护。

  • 使用 reserve() 减少内存重新分配的频率,提高性能。

  • 使用 emplace 在原地高效地构建键值对,最大化性能并避免不必要的临时对象创建。

  • 在此类场景中,std::map 可能会提供更好的性能指标。

  • 并发:确保在多线程访问期间线程安全。并发读取通常是安全的,但写入或混合读写操作需要外部同步,例如互斥锁。

  • std::flat_map。它提供了优越的缓存局部性和高效的查找,尤其是在映射主要在初始化后查询时。

  • std::flat_map,在操作可能破坏此顺序时要小心。在修改后始终验证顺序以确保容器的完整性。

  • 使用 lower_boundupper_bound 进行高效的基于范围的查询,利用容器的排序特性。

  • std::less) 在许多场景下适用,std::flat_map 允许在实例化期间指定自定义比较器,以根据特定需求定制键顺序。

std::flat_multiset

std::flat_multiset 是在 C++ STL 中引入的容器,旨在按排序顺序存储元素。与通常作为红黑树实现的 std::multiset 不同,std::flat_multiset 将其元素存储在连续的内存块中,类似于 std::vector 容器。这种设计选择由于数据局部性而提供了改进的缓存性能,使其在容器在填充后不经常修改的情况下效率更高。

目的和适用性

Std::flat_multiset 是一个存储元素在排序数组中的容器,类似于 std::flat_set,但允许出现多个等效元素。

此容器提供以下功能:

  • 由于其排序特性,具有高效的查找时间

  • 改善了缓存局部性和内存使用的可预测性

它在以下场景中尤其适用:

  • 当允许重复且需要排序访问时

  • 当优先考虑缓存局部性时

  • 初始化后,数据集的大小相对稳定

然而,当频繁的插入或删除成为常态时,其他容器可能更合适。

理想的使用场景

以下是一些 std::flat_multiset 的理想使用场景:

  • 历史记录:按时间顺序存储重复事件,例如交易日志

  • 频率计数器:当顺序和访问速度至关重要时,计算元素出现的次数

  • 排序缓冲区:处理过程中的临时存储,其中顺序至关重要,且预期会有重复

性能

由于 std::flat_multiset 是一个容器适配器,其算法性能取决于底层容器的实现:

  • 插入:由于维护顺序可能需要元素移动,所以是 O(n)

  • 删除:由于可能需要移动以填充间隙,所以是 O(n)

  • 访问:由于二分搜索,查找效率为 O(log n)

  • std::vector,但缺乏树结构最小化了内存开销

内存管理

std::flat_multiset 以块的形式管理内存。使用 reserve() 预分配内存可以防止频繁的重新分配。自定义分配器可以进一步修改分配行为。

线程安全

同时读取是安全的。然而,并发修改或同时读取和写入需要外部同步机制。

扩展和变体

虽然 std::flat_multiset 存储一个元素的多个实例,但 std::flat_set 是其唯一元素对应物。对于基于哈希的方法,你可能想看看 std::unordered_multiset

排序和搜索的复杂度

它的排序和搜索复杂度如下:

  • std::flat_multiset 维护顺序

  • 搜索:由于二分搜索,效率为 O(log n)

特殊接口和成员函数

std::flat_multiset 提供了与底层类型几乎相同的接口。以下是一些特别有用的函数:

  • equal_range:返回等效元素的区间

  • count:高效地计算元素出现的次数

  • emplace:直接在原地构造元素

比较操作

std::multiset 相比,std::flat_multiset 提供了更好的缓存局部性,但可能因频繁修改而受到影响。初始化后,它在读密集型场景中表现出色。

与算法的交互

由于是排序的,std::flat_multiset 与基于二分搜索的算法非常协调。然而,那些打乱或重新排序的算法可能并不理想。

异常

尝试访问越界或管理内存不当可能导致异常。通常,操作是异常安全的,确保容器保持一致性。

自定义

std::flat_multiset 支持自定义分配器,允许对内存分配进行微调。此外,自定义比较器可以调整排序行为。

最佳实践

让我们探讨使用 std::flat_multiset 的最佳实践:

  • std::flat_multiset 主要适用于初始化后集合大小稳定的场景。频繁的插入或删除会导致由于需要维护排序顺序而导致的效率低下,通常会导致元素移动。

  • std::multisetstd::flat_multiset 的迭代器在修改后可能会失效,尤其是那些改变容器大小的操作。在修改容器后,始终重新评估迭代器的有效性。

  • std::flat_multiset 容器中,使用 reserve() 在一开始就分配足够的内存。这可以防止重复和昂贵的重新分配。虽然为预期的增长预留空间很重要,但过度预留会导致不必要的内存消耗。在两者之间寻求平衡。

  • std::flat_multiset 比起 std::flat_set 更为合适。它保留了一个元素的所有实例,而后者只保留唯一条目。

  • std::liststd::multiset 可能会为这类操作提供更高的效率。

  • 使用 emplace() 在集合内直接构造元素。这可以消除不必要的临时构造和复制,特别是对于复杂的数据类型。

  • std::flat_multiset 是安全的。写入操作,无论是插入、删除还是修改,在多线程环境中都需要同步,以确保数据完整性和防止数据竞争。

  • std::flat_multiset 与受益于排序数据集的 STL 算法配合良好,例如 std::lower_boundstd::upper_bound。然而,请记住,改变顺序或引入元素的算法可能会使这种固有的排序失效。

  • 使用 std::flat_multiset 来控制其排序行为。

  • 异常安全性:注意可能抛出异常的操作,例如内存分配失败。确保异常安全代码将防止数据不一致和潜在的内存泄漏。

std::flat_multimap

std::flat_multimap 是一个结合了关联和序列容器特性的容器适配器。它存储键值对,类似于 std::multimap,但有一个显著的区别:元素存储在一个平坦的、连续的内存空间中,类似于 std::vector 容器。这种存储方法由于提高了数据局部性而增强了缓存性能,这对于读取密集型操作特别有益。

目的和适用性

Std::flat_multimap 是 STL 中的一个容器,针对快速关联查找进行了优化。其显著特点包括以下内容:

  • 存储在类似 std::vector 的有序连续内存块中

  • 允许存在具有相同键的多个键值对

它在以下场景中最为合适:

  • 当需要缓存局部性和关联查找时

  • 在初始化后数据集稳定时,因为它不是为频繁的插入或删除而优化的

当扁平存储和允许键重复的优势与你的使用场景相匹配时,选择std::flat_multimap而不是其他容器。

理想使用场景

以下是一些std::flat_multimap的理想使用场景:

  • Web 浏览器历史记录:存储带有时间戳的 URL。对于同一 URL(键)可以存在多个条目(时间戳)。

  • 词频计数器:当文本中的单词可以有多个含义,并且你想要存储每个含义及其计数时。

  • 事件调度器:用于维护在特定时间(键)发生的事件(值),其中可能在同一时间戳发生多个事件。

性能

由于std::flat_multimap是一个容器适配器,其算法性能取决于底层容器的实现:

  • 插入:由于可能需要元素移动,为O(n)

  • 删除:由于维护顺序,为O(n)

  • 访问:由于在排序数组上进行二分搜索,为O(log n)

  • 内存开销:相对较低,具有缓存局部性的优势

代价在于在查找更快的同时,插入和删除会变慢。

内存管理

std::flat_multimap管理内存的方式类似于std::vectorreserve()函数可以预测并分配增长所需的内存。自定义分配器可以进一步调整内存行为。

线程安全性

并发读取是安全的。然而,写入或混合读写需要同步机制,如互斥锁。

扩展和变体

对于不允许重复键的容器,有std::flat_map。对于未排序和分桶存储,你可能想考虑std::unordered_multimap

排序和搜索复杂度

其排序和搜索复杂度如下所述:

  • 排序:是容器固有的,由内部管理

  • 搜索:由于二分搜索,为O(log n)

接口和成员函数

除了标准函数(inserterasefind)之外,探索以下内容:

  • equal_range:返回匹配键的所有条目的界限

  • emplace:直接在映射内构建键值对

比较

std::multimap相比,std::flat_multimap提供了更好的缓存局部性,但修改较慢。与std::unordered_multimap相比,它以更快的查找速度换取了固有的排序。

与算法的交互

由于其排序特性,std::flat_multimapstd::lower_boundstd::upper_bound等算法结合使用时很有益。然而,对于修改顺序或引入元素的算法要小心。

异常

键插入或查找不会抛出异常,但要注意内存分配失败,尤其是在插入期间,这可能导致异常。异常安全性是优先考虑的,许多操作提供了强有力的保证。

定制化

虽然允许使用自定义分配器,但 std::flat_multimap 依赖于其内部排序机制。因此,定义键顺序的自定义比较器是必不可少的。

最佳实践

让我们探讨使用 std::flat_multimap 的最佳实践:

  • 当用例涉及连续或频繁的插入和删除时,使用 std::flat_multimap。由于容器的线性特性,此类操作可能成本高昂。

  • std::flat_multimap 只支持输入、输出、前向和双向迭代器。它不提供随机访问迭代器。

  • 键数据类型考虑:对于键,优先选择简洁和轻量级的数据类型。使用大型自定义数据类型可能会加剧插入和删除时元素移动的成本。

  • 利用 std::flat_multimapreserve() 函数。预分配内存可以减轻昂贵的重新分配和复制。

  • 使用 emplace 方法就地构建键值对。这比分别创建和插入条目更有效。

  • std::multimapstd::unordered_multimap。这些容器在类似场景下可能提供更好的性能。

  • std::flat_multimap。并发读取通常是安全的,但如果没有适当的同步,写操作可能导致竞争条件。

  • std::flat_multimap 保留其内部排序,以满足您的特定要求。

  • std::flat_multimap,可以使用二分搜索算法,如 std::lower_boundstd::upper_bound,来有效地执行范围查询或查找特定键的操作。

  • std::flat_multimap 提供强大的异常保证,确保即使操作抛出异常,容器也能保持一致性。

  • 使用 std::flat_multimap 的成员函数,如 equal_range,来处理和加工与特定键相关联的所有条目。

  • 使用 std::flat_multimap 的成员函数,如 capacity()size()。如果未使用额外的预留空间,考虑使用 shrink_to_fit() 释放此内存。

第十章:高级容器视图使用

在本质上,视图是非拥有范围,这意味着它们提供了对其他数据结构的视图(因此得名),而不拥有底层数据。这使得它们非常轻量级和多功能。使用视图,您可以在不复制数据的情况下对数据进行各种操作,确保高效的代码,最大化性能并最小化开销。

本章重点介绍以下容器:

  • std::span

  • std::mdspan

技术要求

本章中的代码可以在 GitHub 上找到:

github.com/PacktPublishing/Data-Structures-and-Algorithms-with-the-CPP-STL

std::span

std::span是 C++20 中引入的一个模板类,它提供对连续元素序列的视图,类似于轻量级、非拥有引用。它表示对某些连续存储的视图,例如数组或向量的部分,而不拥有底层数据。

std::span的主要目的是安全且高效地将数据数组传递给函数,而无需显式传递大小,因为大小信息封装在std::span对象中。它可以被认为是一个更安全、更灵活的替代方案,用于原始指针和大小或指针和长度参数传递。

目的和适用性

std::span是对连续序列的非拥有视图,通常是一个数组或另一个容器的片段。它是一种轻量级、灵活且安全地引用此类序列的方法,确保没有多余的复制。

std::span最适合以下场景:

  • 当需要临时数据视图时

  • 当底层数据的所有权由其他地方管理时

  • 当您想避免不必要的数据复制但仍需要随机访问时

考虑使用std::span向函数提供对数据部分的访问,而不授予所有权。

理想的使用场景

以下是一些std::span的理想使用场景:

  • 处理数据段:解析大型数据块的一个子段,例如处理网络缓冲区中的头信息

  • 函数接口:授予函数对数据的视图,而不转移所有权或风险资源泄露

  • 数据视图:快速且安全地提供对数据源的多重视图,而不复制源数据

性能

std::span的算法性能如下所述:

  • std::span不拥有其数据

  • 删除:不适用

  • 访问O(1),就像直接数组访问一样

  • 内存开销:最小,因为它本质上只持有指针和大小

记住,std::span的性能主要源于其非拥有特性。

内存管理

std::span不进行任何分配。它只是引用其他地方拥有的内存。因此,关于内存行为的主要关注点与底层数据相关,而不是 span 本身。

线程安全

通过范围进行多个并发读取是安全的。然而,与任何数据结构一样,并发写入或写入-读取组合需要同步。

扩展和变体

std::spanstd::string_view 中是独特的,它提供了一个类似的观点概念,它们针对特定的数据类型。

排序和搜索复杂度

排序不能直接应用于 std::span,因为它不拥有其数据。然而,使用适当的 STL 算法进行搜索对于未排序序列是 O(n),对于排序数据是 O(log n)

接口和成员函数

此类别中的关键函数包括以下内容:

  • size(): 返回元素数量

  • data(): 提供对底层数据的访问

  • subspan(): 从当前范围生成另一个范围

比较操作

std::vectorstd::array 相比,std::span 不管理或拥有数据。它提供了一种安全地查看这些容器(或其他)部分的方法,而不需要复制。

与算法的交互

需要访问(而不是结构修改)的 STL 算法可以无缝地与 std::span 交互。那些需要插入或删除的应该避免使用。

异常

超出范围的操作可能会触发异常。在整个生命周期内,始终确保底层数据的有效性。

自定义

由于 std::span 的非拥有性质,通常不会使用分配器、比较器或哈希函数对其进行自定义。

示例

让我们看看一个示例,演示如何使用 std::span 来处理 用户数据报协议UDP)包的头部。

UDP 头通常包括以下内容:

  • 源端口: 2 字节

  • 目标端口: 2 字节

  • 长度: 2 字节

  • 校验和: 2 字节

我们将创建一个简单的结构来表示头部,然后我们将使用 std::span 来处理包含 UDP 数据包头部和数据的缓冲区。让我们探索以下代码:

#include <cstdint>
#include <iostream>
#include <span>
struct UDPHeader {
  uint16_t srcPort{0};
  uint16_t destPort{0};
  uint16_t length{0};
  uint16_t checksum{0};
  void display() const {
    std::cout << "Source Port: " << srcPort << "\n"
              << "Destination Port: " << destPort << "\n"
              << "Length: " << length << "\n"
              << "Checksum: " << checksum << "\n";
  }
};
void processUDPPacket(std::span<const uint8_t> packet) {
  if (packet.size() < sizeof(UDPHeader)) {
    std::cerr << "Invalid packet size!\n";
    return;
  }
  auto headerSpan = packet.subspan(0, sizeof(UDPHeader));
  const UDPHeader &header =
      *reinterpret_cast<const UDPHeader *>(
          headerSpan.data());
  header.display();
  auto dataSpan = packet.subspan(sizeof(UDPHeader));
  std::cout << "Data size: " << dataSpan.size()
            << " bytes\n";
}
int main() {
  uint8_t udpPacket[] = {0x08, 0x15, // Source port
                         0x09, 0x16, // Destination port
                         0x00, 0x10, // Length
                         0x12, 0x34, // Checksum
                         // Some data
                         0x01, 0x02, 0x03, 0x04, 0x05,
                         0x06};
  processUDPPacket(udpPacket);
  return 0;
}

这里是示例输出:

Source Port: 5384
Destination Port: 5641
Length: 4096
Checksum: 13330
Data size: 6 bytes

上一段代码的关键点如下:

  • 我们定义一个 UDPHeader 结构来表示头部字段。

  • processUDPPacket 函数中,我们使用 std::span 来处理缓冲区。

  • 我们为头部创建一个 subspan 并将其重新解释为 UDPHeader 结构。

  • 缓冲区的剩余部分是数据,我们使用另一个 subspan 来处理。

std::span 提供了对连续对象序列的视图,使其适合安全地访问内存区域,例如网络缓冲区,而不拥有底层数据。

最佳实践

让我们探索使用 std::span 的最佳实践:

  • std::span 的生命周期超过了范围本身。这是避免悬垂引用的关键。

  • std::span 不是一个拥有数据的容器。它只提供数据的视图,与像 std::vector 这样的管理其数据的容器不同。

  • std::span 视图。这种联合反映意味着在整个范围的生存周期内必须保持数据完整性。

  • 谨慎使用 std::span。始终确保 span 的持续时间短于或等于底层数据的生命周期。

  • 函数接口中使用 std::span 以防止不必要的数据复制。这可以优化性能,尤其是在处理大数据块时。

  • std::span。利用 size() 等函数进行边界验证。

  • std::span 在原始指针和长度对上。它提供了一个类型安全、更易读的替代方案,减少了常见指针错误的风险。

  • std::span 用于抽象数据段。当程序的不同组件或函数需要访问不同的数据部分而没有完整所有权时,特别有益。

  • std::span 提供随机访问迭代器,与大多数 STL 算法兼容。然而,在使用可能期望数据所有权或超出 std::span 范围的突变能力的算法时要小心。

  • std::span,鉴于其直接的反射属性。

std::mdspan

std::mdspan,在 C++23 标准中引入,是一个多维 span 模板类,它将 std::span 的概念扩展到多个维度。它提供了一个多维连续元素序列的视图,而不拥有底层数据。此类对于数值计算和操作多维数据结构的算法(如矩阵和张量)非常有用。

目的和适用性

std::mdspan 是 C++ STL 中的多维 span。它是一个多维数组的非拥有视图,提供高效的访问和操作。

其优势如下:

  • 表示和访问多维数据而不拥有它

  • 促进与其他语言和库的互操作性,这些语言和库与多维数组一起工作。

std::mdspan 在以下场景中尤其适用:

  • 当你必须与其他库或 API 中的多维数据一起工作而不进行复制时

  • 当你需要对多维数据集进行索引和切片的灵活性时。

理想用例

以下是一些 std::mdspan 的理想用例:

  • 图像处理:访问 2D 图像中的像素或 3D 视频流中的帧

  • 科学计算:在矩阵格式中操作数据以进行数学计算

  • 数据处理:高效地重新索引、切片或重塑多维数据集

  • 互操作性:与其他管理多维数据结构的语言或库进行接口

性能

std::mdspan 的算法性能如下:

  • 访问:通常对任何位置为 O(1)

  • std::mdspan 只提供现有数据的视图。

  • std::mdspan 本身。

内存管理

由于 std::mdspan 不拥有其数据,它不控制内存分配或释放。确保在 mdspan 生命周期内底层数据保持有效。

线程安全

std::span 类似,多个并发读取是安全的,但写入或混合读取和写入需要外部同步。

扩展和变体

std::span 可以看作是 1D 变体。虽然 std::span 提供了线性数据的视图,但 std::mdspan 将此概念扩展到多维数据。

排序和搜索复杂度

由于其本质,排序和搜索不是 std::mdspan 的固有属性。外部算法需要根据其多维特性进行适配。

特殊接口和成员函数

std::mdspan 提供以下特殊接口和成员函数:

  • extent:返回给定维度的尺寸

  • strides:提供每个维度中连续项目之间的元素数量

  • rank:给出维度数

比较

与原始多维数组或指针相比,std::mdspan 提供了一个更安全、更灵活的接口,尽管没有数据所有权。

与算法的交互

虽然许多 STL 算法是为线性数据结构设计的,但特定的算法,尤其是针对多维数据的自定义算法,可以适配以与 std::mdspan 一起工作。

异常

由于其非拥有性质,通过已失效的 std::mdspan(如果底层数据被销毁)访问数据是未定义的行为,并且不会抛出标准异常。始终确保数据的有效性。

定制

std::mdspan 可以使用布局策略进行定制,以定义数据存储模式。

最佳实践

让我们探索使用 std::mdspan 的最佳实践:

  • std::mdspan 是一个非拥有视图。确保您永远不会错误地将它视为拥有数据的容器。这种疏忽可能会引入悬垂引用和未定义的行为。

  • std::mdspan。理解和调整这些方面可以优化您的数据访问模式,使它们更符合缓存友好。

  • std::mdspan 的布局与预期约定可以防止微妙的错误和低效。

  • std::mdspan 指针在整个跨度生命周期内保持有效。避免在 std::mdspan 引用底层数据时,底层数据被销毁或超出作用域的情况。

  • 显式布局指定:当与不同的库一起工作时,尤其是与 C++ STL 外部的库一起工作时,明确预期的数据布局。这种清晰度可以防止歧义并确保一致的数据解释。

  • std::mdspan 作为参数。这种选择提供了对悬垂引用(与原始指针相比)的安全性,并且在多维数据操作方面具有更高的表达性。

  • std::mdspan。虽然 std::mdspan 提供了一定程度的类型安全性,但越界访问仍然会导致未定义的行为。考虑使用 extents 等函数来确认维度。

  • std::mdspan 使用布局策略来解释底层数据,以用于高级用例。这种灵活性在需要非标准数据排列或针对特定硬件架构进行优化时尤其有价值。

  • std::spanstd::mdspan 本身并不保证底层数据的线程安全性。如果预期会有多线程访问,请确保底层数据结构或其操作是线程安全的。

  • 由于 std::mdspan 具有多维性质,它并不自然地适合所有 STL 算法,但您仍然可以在数据的扁平视图或单个切片上使用许多算法。熟悉 STL 算法可以帮助您避免重复造轮子。

第三部分:精通 STL 算法

在本部分中,您将获得对 C++ STL 算法骨架的稳健理解。我们通过基本算法建立基础,强调排序、搜索和元素比较,这些对于高效的数据操作至关重要。然后我们深入 STL 的变革力量,通过复制、移动、填充和生成操作,揭示最优数据操作的技术,同时强调现代惯用术语如返回值优化(RVO)的重要性。

接下来,我们探讨数值操作,从简单的求和到复杂的内积,并将我们的关注点扩展到基于范围的运算,强调其在现代 C++ 中的重要性。随后的章节转向通过分区、堆操作和排列来结构化地处理数据集,展示了它们在数据组织和分析中的关键作用。

最后,我们通过介绍范围的概念来总结,这是 STL 的一次进化,它为算法操作带来了更丰富和高效的途径。我们分析了基于范围的排序和搜索算法的优势和最佳实践,并倡导在当代 C++ 开发中采用这些算法。最佳实践贯穿始终,为您提供了使用 STL 算法编写干净、高效和可维护代码的清晰路径。

本部分包含以下章节:

  • 第十一章:基本算法和搜索

  • 第十二章:操作和转换

  • 第十三章:数值和基于范围的运算

  • 第十四章:排列、分区和堆

  • 第十五章:带有范围的现代 STL

第十一章:基本算法和搜索

本章涵盖了最关键和最常用的 C++ 标准模板库 (STL) 算法。本章通过关注排序、条件检查、查找和搜索技术,使读者能够有效地操作和分析数据。理解这些基本算法对于希望确保高效和健壮应用的开发者至关重要。本章还强调了最佳实践,确保代码正确且优化。

本章涵盖了以下主要主题:

  • 排序

  • 检查条件

  • 计数和查找

  • 搜索和比较

  • 最佳实践

技术要求

本章中的代码可以在 GitHub 上找到:

github.com/PacktPublishing/Data-Structures-and-Algorithms-with-the-CPP-STL

排序

排序 是每个程序员都会遇到的基本概念,但它不仅仅是关于元素排序。它关乎优化,理解数据的本质,并选择合适的方法来有意义地排列这些数据。C++ STL 的强大工具箱提供了一系列针对各种场景和数据集的排序算法。但如何选择?如何有效地运用这些工具以获得最佳结果?让我们共同踏上这段启发性的旅程。

首先,为什么我们要排序?排序使数据看起来更美观,并为高效搜索、数据分析以及优化数据结构铺平了道路。无论是按姓名在地址簿中排序,还是在在线商店中按价格排序产品,排序这一行为深深地融入了计算的纹理中。

STL 提供了一个主要的排序函数:std::sort。这个函数非常灵活,可以排序几乎任何元素序列,从数组到向量。在底层,std::sort 通常使用 introsort 实现的,这是一种结合了快速排序、堆排序和插入排序的混合排序算法,确保了速度和适应性。以下是一个简单的 std::sort 示例:

std::vector<int> numbers = {5, 3, 8, 1, 4};
std::sort(numbers.begin(), numbers.end());

但排序并不总是关于升序或数字。使用 std::sort,自定义比较器允许你定义顺序。想象一下,你有一个产品列表,并想按名称降序排序。你可以这样做:

std::sort(products.begin(), products.end(), [](const Product& a, const Product& b) {
    return a.name > b.name;
});

这不仅仅关于常规排序。当你有几乎排序好的数据时,std::partial_sort 就能提供帮助。这个函数对某个子范围进行排序。比如说,你想根据分数找到前三名的学生;std::partial_sort 可以使这项任务更高效。

然而,了解算法只是战斗的一半;理解何时使用哪个函数是至关重要的。如果你旨在对一个包含一百万个数字的列表进行排序,std::sort 将是你的最佳拍档。但如果你处理的是较小的数据集,其中你必须保持相等元素的原始顺序,那么 std::stable_sort 是一个更合适的选择。

此外,还有一些针对特定场景量身定制的排序函数。例如,当处理大型数据集且你只对排序数据的子集感兴趣时,std::nth_element 是一个极好的工具。它重新排列元素,使得第 n 个位置的元素在排序序列中也将位于该位置。

选择合适的算法还涉及到理解你的数据特性。如果你有一个较小的数据集或几乎排序好的列表,插入排序可能是你的最佳选择。另一方面,对于较大的数据集,更高级的算法如归并排序或快速排序可能更合适。了解这些算法的底层机制和性能指标有助于做出明智的决定。

STL 中的排序不仅仅是安排数据,而是选择最佳方式。这是理解你的数据、应用性质以及你拥有的工具之间的舞蹈。接下来,我们将学习如何检查排序数据上的各种条件。

检查条件

C++ STL 的优雅之处不仅在于其丰富的容器和算法集合,还在于其精细调校的能力,让开发者能够通过基于条件的操作高效地检查和验证数据。借助谓词函数的力量,这些操作使程序员能够回答诸如“这个数据集是否具有特定的属性?”和“这个范围内的所有元素都是正数吗?”等问题。

最直观和基本操作之一是 std::all_of。使用这个算法,你可以检查一个范围内的所有元素是否满足给定的谓词。如果你有一个学生成绩列表,你可以使用 std::all_of 来查看所有成绩是否都是正数(而且应该是!)。

相比之下,它的对应函数 std::none_of 检查一个范围内的所有元素是否都不满足给定的谓词。假设你正在处理一个学生成绩列表,并想确保没有人得分低于及格线。在这种情况下,std::none_of 变得非常有价值。

三元组中的最后一个函数是 std::any_of,它检查序列中至少有一个元素满足特定条件。这在寻找条件存在性的场景中尤其有用,例如查找是否有任何成绩是 A(>= 90)。

让我们看看一个代码示例,说明 std::all_ofstd::none_ofstd::any_of 的用法:

#include <algorithm>
#include <iostream>
#include <vector>
int main() {
  std::vector<int> grades = {85, 90, 78, 92,
                             88, 76, 95, 89};
  if (std::all_of(grades.begin(), grades.end(),
                  [](int grade) { return grade > 0; })) {
    std::cout << "All students have positive grades.\n";
  } else {
    std::cout << "Not all grades are positive.\n";
  }
  if (std::none_of(grades.begin(), grades.end(),
                   [](int grade) { return grade < 80; })) {
    std::cout
        << "No student has scored below passing marks.\n";
  } else {
    std::cout << "There are students who scored below "
                 "passing marks.\n";
  }
  if (std::any_of(grades.begin(), grades.end(),
                  [](int grade) { return grade >= 95; })) {
    std::cout << "There's at least one student with an "
                 "'exceptional' grade.\n";
  } else {
    std::cout
        << "No student has an 'exceptional' grade.\n";
  }
  return 0;
}

下面是示例输出:

All students have positive grades.
There are students who scored below passing marks.
There's at least one student with an 'exceptional' grade.

在这个例子中,我们使用了一组学生成绩作为我们的数据集。我们使用描述的算法来检查所有成绩是否为正数,没有学生得分低于及格分(在本例中认为是 80 分),以及至少有一名学生取得了优异的成绩(90 分或以上)。

超越这些基本检查,还有更多专门的算法,例如 std::is_sorted,正如其名所示,它验证一个范围是否已排序。例如,对于产品价格的数据集,这个函数可以快速检查序列是否按升序排列,确保在执行其他操作之前数据的完整性。

另一个有趣的算法是 std::is_partitioned。想象一下,你有一个混合数据集,你已经使用某些标准对其进行了分区,例如将数字分为偶数和奇数。这个算法根据谓词检查序列中是否存在这种分区。

虽然这些函数提供了直接验证数据的方法,但有时需求更为复杂。考虑这种情况,你可能想要比较两个序列以检查它们是否是彼此的排列。STL 提供了 std::is_permutation 来实现这一目的。无论是字符串、数字还是自定义对象,这个函数都可以确定一个序列是否是另一个序列的重新排序。

让我们用一个产品价格的数据集来演示 std::is_permutation 的用法:

#include <algorithm>
#include <iostream>
#include <vector>
int main() {
  std::vector<double> prices = {5.99, 10.49, 20.89, 25.55,
                                30.10};
  if (std::is_sorted(prices.begin(), prices.end())) {
    std::cout << "The product prices are sorted in"
                 "ascending order.\n";
  } else {
    std::cout << "The product prices are not sorted.\n";
  }
  auto partitionPoint = std::partition(
      prices.begin(), prices.end(),
      [](double price) { return price < 20.0; });
  if (std::is_partitioned(
          prices.begin(), prices.end(),
          [](double price) { return price < 20.0; })) {
    std::cout << "Prices are partitioned with prices less "
                 "than $20 first.\n";
  } else {
    std::cout << "Prices are not partitioned based on the "
                 "given criteria.\n";
  }
  std::vector<double> shuffledPrices = {25.55, 5.99, 30.10,
                                        10.49, 20.89};
  // Using std::is_permutation to ascertain if
  // shuffledPrices is a reordering of prices
  if (std::is_permutation(prices.begin(), prices.end(),
                          shuffledPrices.begin())) {
    std::cout
        << "Sequences are permutations of each other.\n";
  } else {
    std::cout << "Sequences are not permutations of each "
                 "other.\n";
  }
  return 0;
}

这里是示例输出:

The product prices are sorted in ascending order.
Prices are partitioned with prices less than $20 first.
Sequences are permutations of each other.

在这个例子中,我们使用描述的算法对一个产品价格的数据集进行了操作。首先检查价格是否已排序。然后,根据价格标准进行分区。最后,我们验证两个价格序列是否是彼此的排列。

利用这些条件检查函数不仅仅是将它们应用于数据集。真正的力量来自于构建有意义的谓词。通过利用 lambda 或函数对象的能力,你可以设计复杂的条件,精确地捕捉你的需求。无论是检查用户输入的有效性,验证处理前的数据,还是确保处理后的结果的神圣不可侵犯,基于谓词的函数是你的可靠工具。

但就像任何强大的工具包一样,这些函数必须谨慎使用。过度依赖检查可能导致性能开销,尤其是在大型数据集上。在验证和性能之间取得平衡至关重要。通常,了解数据的性质和应用的更广泛背景可以指导你有效地使用这些算法。

在总结对条件检查算法的探索时,很明显,它们是 STL 算法套件的重要组成部分。它们提供了一个强大的基础,可以在其上构建更高级的操作。随着我们继续前进,你会看到这些基础检查如何与其他算法,如计数和查找交织在一起,描绘出 C++ 魅力世界中数据处理的全景。

计数和查找

在我们日常处理的数据中,管理或验证数据,以及积极搜索、定位和量化其中的特定元素或模式,往往变得至关重要。STL 为开发者提供了一宝库精确的算法,用于计数和查找。

让我们从简单而强大的std::count及其孪生兄弟std::count_if开始。虽然std::count可以迅速告诉你特定值在范围内出现的次数,但std::count_if更进一步,允许你根据谓词来计数。想象一下,你有一个学生分数的集合,并希望找出有多少人得分超过 90。使用std::count_if,这就像走平地一样简单,如下所示:

#include <algorithm>
#include <iostream>
#include <vector>
int main() {
  std::vector<int> grades = {85, 90, 78, 92,
                             88, 76, 95, 89};
  const auto exact_count =
      std::count(grades.begin(), grades.end(), 90);
  std::cout << "Number of students who scored exactly 90:"
            << exact_count << "\n";
  const auto above_count =
      std::count_if(grades.begin(), grades.end(),
                    [](int grade) { return grade > 90; });
  std::cout << "Number of students who scored above 90:"
            << above_count << "\n";
  return 0;
}

这里是示例输出:

Number of students who scored exactly 90: 1
Number of students who scored above 90: 2

在这里,我们使用了std::count来检查得分恰好为 90 的学生数量,然后使用了std::count_if来计数得分超过 90 的学生。

除了计数之外,有时目标是要定位一个特定的元素。这就是std::findstd::find_if发挥作用的地方。相比之下,std::find寻找精确匹配,而std::find_if基于谓词进行搜索。在你渴望知道满足条件的第一个元素的位置时,这些函数是你的首选。

然而,生活并不总是关于第一个匹配项。有时,最后一个匹配项才是重要的。在这种情况下,std::find_end证明是无价的。特别是在定位较大序列中子序列的最后一个出现的情况下,这个函数确保你不会错过数据中的细微差别。

让我们看看一个使用std::list的代码示例,其中包含学生姓名和成绩的结构。然后,我们将使用std::find_ifstd::find_end根据成绩定位学生,如下所示:

#include <algorithm>
#include <iostream>
#include <list>
struct Student {
  std::string name;
  int grade{0};
  Student(std::string n, int g) : name(n), grade(g) {}
};
int main() {
  std::list<Student> students = {
      {"Lisa", 85},   {"Corbin", 92}, {"Aaron", 87},
      {"Daniel", 92}, {"Mandy", 78},  {"Regan", 92},
  };
  auto first_92 = std::find_if(
      students.begin(), students.end(),
      [](const Student &s) { return s.grade == 92; });
  if (first_92 != students.end()) {
    std::cout << first_92->name
              << "was the first to score 92.\n";
  }
  std::list<Student> searchFor = {{"", 92}};
  auto last_92 = std::find_end(
      students.begin(), students.end(), searchFor.begin(),
      searchFor.end(),
      [](const Student &s, const Student &value) {
        return s.grade == value.grade;
      });
  if (last_92 != students.end()) {
    std::cout << last_92->name
              << "was the last to score 92.\n";
  }
  return 0;
}

这里是示例输出:

Corbin was the first to score 92.
Regan was the last to score 92.

在这个例子中,我们使用std::find_if来找到第一个得分 92 的学生。然后,我们使用std::find_end来找到最后一个得分 92 的学生。在这个情况下,std::find_end函数有点棘手,因为它旨在查找子序列,但通过提供一个单元素列表(作为我们的子序列),我们仍然可以使用它来找到特定成绩的最后一个出现。

对于那些处理排序数据的人来说,STL 并不会让人失望。通过std::lower_boundstd::upper_bound,你可以在排序序列中高效地找到等于给定值的值的范围的开始和结束。此外,std::binary_search让你能够快速确定一个元素是否存在于排序范围内。记住,这些函数利用了数据的排序特性,使它们的速度比它们的通用版本快得多。

让我们定义一个Student结构,并使用Student对象的std::set。我们将修改比较运算符,以便根据成绩进行排序,如下所示:

#include <algorithm>
#include <iostream>
#include <set>
#include <string>
struct Student {
  std::string name;
  int grade{0};
  bool operator<(const Student &other) const {
    return grade < other.grade; // Sorting based on grade
  }
};
int main() {
  std::set<Student> students = {
      {"Amanda", 68},  {"Claire", 72}, {"Aaron", 85},
      {"William", 85}, {"April", 92},  {"Bryan", 96},
      {"Chelsea", 98}};
  Student searchStudent{"", 85};
  const auto lb = std::lower_bound(
      students.begin(), students.end(), searchStudent);
  if (lb != students.end() && lb->grade == 85) {
    std::cout
        << lb->name
        << " is the first student with a grade of 85.\n";
  }
  const auto ub = std::upper_bound(
      students.begin(), students.end(), searchStudent);
  if (ub != students.end()) {
    std::cout << ub->name
              << " is the next student after the last one "
                 "with a grade of 85, with a grade of "
              << ub->grade << ".\n";
  }
  if (std::binary_search(students.begin(), students.end(),
                         searchStudent)) {
    std::cout << "There's at least one student with a "
                 "grade of 85.\n";
  } else {
    std::cout << "No student has scored an 85.\n";
  }
  return 0;
}

这里是示例输出:

Aaron is the first student with a grade of 85.
April is the next student after the last one with a grade of 85, with a grade of 92.
There's at least one student with a grade of 85.

在这个例子中,Student结构根据成绩在std::set中排序。然后,在输出中使用姓名。

说到速度,邻接算法——std::adjacent_find 是一个典型的例子——允许快速定位序列中的连续重复项。想象一下一个传感器正在发送数据,而你希望快速识别是否存在连续重复的读数。这个函数就是你的首选解决方案。

让我们看看一个 std::list 结构体的例子,其中每个条目都有一个传感器读数(温度)和读取时间:

#include <algorithm>
#include <chrono>
#include <iomanip>
#include <iostream>
#include <list>
struct SensorData {
  int temperature{0};
  std::chrono::system_clock::time_point timestamp;
};
int main() {
  const auto now = std::chrono::system_clock::now();
  std::list<SensorData> sensorReadings = {
      {72, now - std::chrono::hours(10)},
      {73, now - std::chrono::hours(9)},
      {75, now - std::chrono::hours(8)},
      {75, now - std::chrono::hours(7)},
      {76, now - std::chrono::hours(6)},
      {78, now - std::chrono::hours(5)},
      {78, now - std::chrono::hours(4)},
      {79, now - std::chrono::hours(3)},
      {80, now - std::chrono::hours(2)},
      {81, now - std::chrono::hours(1)}};
  auto it = sensorReadings.begin();
  while (it != sensorReadings.end()) {
    it = std::adjacent_find(
        it, sensorReadings.end(),
        [](const SensorData &a, const SensorData &b) {
          return a.temperature == b.temperature;
        });
    if (it != sensorReadings.end()) {
      int duplicateValue = it->temperature;
      std::cout << "Found consecutive duplicate readings "
                   "of value: "
                << duplicateValue
                << " taken at the following times:\n";
      while (it != sensorReadings.end() &&
             it->temperature == duplicateValue) {
        const auto time =
            std::chrono::system_clock::to_time_t(
                it->timestamp);
        std::cout << "\t"
                  << std::put_time(std::localtime(&time),
                                   "%Y-%m-%d %H:%M:%S\n");
        ++it;
      }
    }
  }
  return 0;
}

在这个例子中,每个 SensorData 结构体包含一个温度及其记录的时间戳。我们使用 std::adjacent_find 和自定义比较器来检查连续重复的温度读数。当我们找到这样的读数时,我们会显示读取时间以及温度值。

下面是示例输出:

Found consecutive duplicate readings of value: 75 taken at the following times:
    2099-10-01 03:14:51
    2099-10-01 04:14:51
Found consecutive duplicate readings of value: 78 taken at the following times:
    2099-10-01 06:14:51
    2099-10-01 07:14:51

就像所有工具一样,理解何时以及如何使用这些算法是至关重要的。虽然由于它们的速度,频繁地使用二分搜索可能很有吸引力,但它们仅适用于排序数据。否则,使用它们可能会导致错误的结果。同样,虽然计数出现次数可能看起来很简单,但根据你是否有特定值或条件,使用正确的计数函数可以显著影响你程序的清晰度和效率。

在 C++ 中处理数据时,计数和查找是基础且复杂的操作,为更高级的操作铺平了道路。对这些操作掌握得越好,你就越有可能熟练地处理最复杂的数据场景。鉴于我们的数据已排序,我们可以通过检查 STL 中的高效搜索和比较来进一步扩展我们的工具集。

搜索和比较

搜索数据是一个常见但至关重要的操作,大多数软件都需要。无论是尝试从数据库中检索特定用户详情,还是找到一本书在排序列表中的位置,强大的搜索技术都是至关重要的。STL 提供了多种方法来有效地搜索序列。此外,该库提供了直观的方式来比较序列和检索极值,使数据分析更加流畅。

当处理排序数据时,std::binary_search 是一个强大的工具。这是保持数据排序在可行范围内的重要性的证明。通过反复将数据集分成两半,它定位所需的元素,使其成为一个异常快速的工具。然而,这仅仅是一个布尔操作;它通知元素是否存在,但并不告知其位置。为此,我们依赖 std::lower_boundstd::upper_bound。这些函数检索指向元素首次出现和最后一次出现之后的迭代器。结合这两个函数可以给出表示排序序列中所有实例的值的范围。

然而,并非所有数据都是排序的,并非所有搜索都是精确匹配。STL 不会让你陷入困境。std::findstd::find_if等函数在这些情况下表现出色,提供了基于实际值或谓词进行搜索的灵活性。

在搜索之后,一个自然的步骤是比较元素。通常,我们需要确定一个序列在字典序上是否小于、大于或等于另一个序列。这就是std::lexicographical_compare发挥作用的地方,它允许你像字典排序一样比较两个序列。当处理字符串或自定义数据类型时,这是必不可少的,确保你可以快速地按需排序和排名数据。

这里有一个示例来展示std::lexicographical_compare的使用:

#include <algorithm>
#include <iostream>
#include <string>
#include <vector>
int main() {
  std::vector<char> seq1 = {'a', 'b', 'c'};
  std::vector<char> seq2 = {'a', 'b', 'd'};
  std::vector<char> seq3 = {'a', 'b', 'c', 'd'};
  if (std::lexicographical_compare(
          seq1.begin(), seq1.end(), seq2.begin(),
          seq2.end())) {
    std::cout << "Sequence 1 is lexicographically less"
                 "than Sequence 2"
              << "\n";
  } else {
    std::cout
        << "Sequence 1 is not lexicographically less"
           "than Sequence 2"
        << "\n";
  }
  if (std::lexicographical_compare(
          seq1.begin(), seq1.end(), seq3.begin(),
          seq3.end())) {
    std::cout << "Sequence 1 is lexicographically less"
                 "than Sequence 3"
              << "\n";
  } else {
    std::cout
        << "Sequence 1 is not lexicographically less"
           "than Sequence 3"
        << "\n";
  }
  // For strings
  std::string str1 = "apple";
  std::string str2 = "banana";
  if (std::lexicographical_compare(
          str1.begin(), str1.end(), str2.begin(),
          str2.end())) {
    std::cout << "String 1 (apple) is lexicographically "
                 "less than String 2 (banana)"
              << "\n";
  } else {
    std::cout << "String 1 (apple) is not "
                 "lexicographically less "
                 "than String 2 (banana)"
              << "\n";
  }
  return 0;
}

这里是示例输出:

Sequence 1 is lexicographically less than Sequence 2
Sequence 1 is lexicographically less than Sequence 3
String 1 (apple) is lexicographically less than String 2 (banana)

这展示了如何使用std::lexicographical_compare来确定两个序列的相对顺序。

但如果你只对极端值感兴趣呢?也许你想要找到考试中的最高分或产品列表中的最低价格。在这里,std::max_elementstd::min_element是你的得力助手。它们分别返回指向最大和最小元素的迭代器。如果你两者都要找,std::minmax_element一次就能给出一个迭代器对:

#include <algorithm>
#include <iostream>
#include <vector>
int main() {
  std::vector<int> scores = {85, 93, 78, 90, 96, 82};
  const auto max_it =
      std::max_element(scores.begin(), scores.end());
  if (max_it != scores.end()) {
    std::cout << "The highest score is: "<< *max_it
              << "\n";
  }
  const auto min_it =
      std::min_element(scores.begin(), scores.end());
  if (min_it != scores.end()) {
    std::cout << "The lowest score is: "<< *min_it
              << "\n";
  }
  const auto minmax =
      std::minmax_element(scores.begin(), scores.end());
  if (minmax.first != scores.end() &&
      minmax.second != scores.end()) {
    std::cout << "The lowest and highest scores are: "
              << *minmax.first << " and " << *minmax.second
              << ", respectively.\n";
  }
  std::vector<double> productPrices = {99.99, 79.99, 49.99,
                                       59.99, 89.99};
  // Find the minimum and maximum prices
  auto minmaxPrices = std::minmax_element(
      productPrices.begin(), productPrices.end());
  if (minmaxPrices.first != productPrices.end() &&
      minmaxPrices.second != productPrices.end()) {
    std::cout
        << "The cheapest and priciest products cost: $"
        << *minmaxPrices.first << " and $"
        << *minmaxPrices.second << ", respectively.\n";
  }
  return 0;
}

这里是示例输出:

The highest score is: 96
The lowest score is: 78
The lowest and highest scores are: 78 and 96, respectively.
The cheapest and priciest products cost: $49.99 and $99.99, respectively.

这展示了如何使用std::max_elementstd::min_elementstd::minmax_element在序列中找到极值。

总结来说,STL 中搜索和比较的力量不仅在于其函数的广度,还在于其适应性。有了迭代器和谓词,这些算法非常灵活,确保你可以根据各种场景调整它们。作为开发者,这些工具成为我们思考的延伸,引导我们走向高效且优雅的解决方案。随着我们进一步发展,请记住这些操作是更高级技术和最佳实践的基础,加强我们在 C++中处理数据和算法解决问题的能力。

最佳实践

C++ STL 的优雅之处在于其丰富的实用工具和优化潜力。然而,仅仅了解算法并不是最终目标。你如何使用它们,如何组合它们,以及如何做出细微的决策,这决定了程序是高效还是缓慢。因此,让我们深入研究最佳实践,确保你在 STL 中的探索是正确且效率最高的:

  • 在一个基本排序的数组上使用std::binary_search可能是不切实际的,当std::find可以以更低的开销完成这项任务时。

  • std::setstd::map在搜索和插入元素方面具有固有的优势。然而,它们也可能导致陷阱。持续向此类容器添加元素可能并不高效,有时,批量插入后跟排序操作可能更优。

  • 使用 std::vector 时,通过 reserve 方法,预先对大小进行合理的估计并保留内存至关重要。这样,当你调用 push_back 添加元素时,向量不需要频繁地重新分配内存,从而提供显著的性能提升。

  • std::count_ifstd::find_if 允许设置自定义条件,这使得它们比非谓词对应版本更加灵活和适应更广泛的场景。此外,C++11 及以后的 lambda 表达式使得使用这些算法变得更加简洁和表达力丰富。

  • 警惕算法复杂度:虽然 STL 提供了工具,但它并没有改变算法的基本性质。线性搜索始终是线性的,二分搜索将是对数的。认识到你算法的复杂度,并质疑这对你应用程序的需求是否最佳。

  • std::array 是栈分配的,由于缓存局部性,其访问速度可能比堆分配的对应版本更快。然而,这伴随着固定大小的权衡。因此,事先了解内存需求可以帮助找到正确的平衡点。

  • std::vector 可能会失效迭代器,导致未定义行为。

  • 基准测试和性能分析:假设和最佳实践是起点,但真实性能指标来自对应用程序的性能分析。gprof、Valgrind 和 Celero 等工具在突出瓶颈并指导你进行正确的优化方面可能非常有价值。这些最佳实践概述了如何优化 C++ STL 的使用,强调了理解数据性质、利用排序数据结构、避免不必要的内存重新分配、优先选择具有谓词版本的算法、意识到算法复杂度、在适当的情况下选择栈分配而不是堆分配、谨慎使用迭代器以及基准测试和性能分析在识别性能瓶颈中的重要性。它们强调,虽然 STL 提供了强大的工具,但高效的编程取决于如何使用和组合这些工具。

摘要

在本章中,我们彻底研究了在 STL 容器上操作的核心算法及其在高效 C++ 编程中的作用。我们首先探讨了排序算法的基本原理,了解它们如何组织数据以实现更好的可访问性和性能。然后,我们深入探讨了检查容器条件的方法以及计数和查找元素的技术,这些对于数据分析和处理至关重要。

本章为您提供了有效搜索和比较元素的战略。我们还关注了确保这些操作以最优效率和最小错误率执行的最佳实践。

这些知识为您在中级到高级 C++ 开发中实现复杂算法、执行数据操作和日常任务提供了基础。

在下一章中,我们将进一步扩展我们对算法的理解。我们将学习在 STL 容器内进行复制和移动语义,返回值优化RVO),以及填充、生成、删除和替换元素的技术。此外,我们还将探讨交换和反转元素细微差别,并以去重和抽样策略作为总结。这些主题将有助于全面理解数据操作和转换。

第十二章:操作和转换

本章讨论了 C++ 标准模板库STL)提供的数据操作技术。这些操作数据结构的技术,无论是复制、生成新数据、删除过时条目,还是执行交换或反转等高级操作,构成了大多数应用程序的重要组成部分。本章将向您介绍许多方法和细微差别,使您能够为您的任务选择合适的工具。伴随最佳实践,本章确保您能够高效地理解和应用这些技术。

本章将涵盖以下主要主题:

  • STL 容器中的复制和移动

  • 探索返回值优化

  • STL 容器中的填充和生成

  • STL 容器中的删除和替换

  • STL 容器中的交换和反转

  • 最佳实践

技术要求

本章中的代码可以在 GitHub 上找到:

github.com/PacktPublishing/Data-Structures-and-Algorithms-with-the-CPP-STL

STL 容器中的复制和移动

C++中的 STL 以其强大的数据结构和算法而闻名。其最基本方面包括复制和移动容器的操作。这些操作不仅对数据操作至关重要,而且在 C++应用程序的效率和性能中也起着重要作用。本节探讨了 STL 中复制和移动的细微差别,探讨了它们的语义、对性能的影响以及选择其中一个而不是另一个所涉及的战略决策。

STL 中的复制语义

复制,在最基本的意义上,是指创建一个对象的副本。在 STL 中,当你复制一个容器时,你将其内容复制到一个新的容器中。一种可视化这种方法的方式是想象复印一份文件。原始文件保持不变,而你有一份内容相同的新的文件。

例如,考虑以下内容:

std::vector<int> original{1, 2, 3};
std::vector<int> duplicate(original); // Copy constructor

现在的duplicate向量是原始向量的副本。这两个容器完全独立;修改一个不会影响另一个。虽然这听起来很简单,但魔鬼往往藏在细节中。复制可能是一个昂贵的操作,特别是对于大型容器。原始容器中的每个元素都会被复制,这可能在时间效率至关重要的应用程序中导致性能陷阱。

STL 中的移动语义

在 C++11 中引入的移动语义引领了资源管理的范式转变。它不是复制内容,而是将资源的所有权从(源)对象转移到(目标)对象。

想象一下,你有一个玩具箱(std::vector)。你不需要创建一个新的玩具箱并逐个转移玩具(复制),你只需将这个玩具箱交给别人(移动)。原来的玩具箱就空了,而另一个人拥有了所有的玩具。

这在代码中的表现如下:

std::vector<int> original{1, 2, 3};
std::vector<int> destination(std::move(original)); // Move constructor

在此操作之后,destination拥有数据,而original处于有效但未指定的状态(通常是空的)。这种机制提供了显著的性能优势,尤其是在处理大数据集时,因为它消除了复制数据的开销。

拷贝与移动——一个有意的选择

现在,了解了这两种机制,开发者有责任做出明智的选择。拷贝确保数据完整性,因为原始数据保持未变。这在原始数据在后续操作中仍然发挥作用时很有用。然而,如果原始容器的数据是可丢弃的,或者你确信之后不再需要它,选择移动操作可以显著提高性能。

然而,需要谨慎。粗心使用移动语义可能会导致意外,尤其是如果假设数据仍然位于源容器中。在任何操作之后,始终要意识到对象的状态。

下面是一个示例,展示了粗心使用移动语义可能导致的潜在问题:

#include <iostream>
#include <vector>
void printVector(const std::vector<int> &vec,
                 const std::string &name) {
  std::cout << name << ": ";
  for (int val : vec) { std::cout << val << " "; }
  std::cout << "\n";
}
int main() {
  std::vector<int> source = {10, 20, 30, 40, 50};
  std::vector<int> destination = std::move(source);
  std::cout
      << "Trying to access the 'source' vector after "
         "moving its data:\n";
  printVector(source, "Source");
  printVector(destination, "Destination");
  source.push_back(60);
  std::cout << "After trying to add data to 'source' "
               "post-move:\n";
  printVector(source, "Source");
  return 0;
}

以下为前述代码的输出:

Trying to access the 'source' vector after moving its data:
Source:
Destination: 10 20 30 40 50
After trying to add data to 'source' post-move:
Source: 60

如所示,在从sourcedestination移动数据后,source向量处于有效但未指定的状态。它是空的,但仍然可以执行如push_back之类的操作。关键是要意识到这样的状态,不要假设在移动之后source容器中的数据仍然完好无损。

从本质上讲,当开发者理解 STL 操作的细微差别时,STL 的力量会得到放大。拷贝和移动是基础支柱,决定了数据如何管理以及应用程序如何高效运行。在我们深入探讨后续章节中的操作和转换技术时,始终要牢记这些机制。它们通常是构建高级技术的基础。

探索返回值优化

返回值优化RVO)值得特别提及。现代编译器优化函数中的对象返回,有效地将看似拷贝的操作转换为移动,使操作非常高效。这是 C++不断发展和其倾向于性能优化的例证。

下面是一个代码示例,以展示 RVO 的概念:

#include <iostream>
#include <vector>
class Sample {
public:
  Sample() { std::cout << "Constructor called!\n"; }
  Sample(const Sample &) {
    std::cout << "Copy Constructor called!\n";
  }
  Sample(Sample &&) noexcept {
    std::cout << "Move Constructor called!\n";
  }
  ~Sample() { std::cout << "Destructor called!\n"; }
};
Sample createSample() { return Sample(); }
int main() {
  std::cout << "Creating object via function return:\n";
  Sample obj = createSample();
  return 0;
}

在此代码中,当调用函数createSample时,它返回一个Sample对象。如果没有 RVO(Return Value Optimization,返回值优化),我们可能会预期一系列调用:构造函数 -> 拷贝构造函数(或移动构造函数)-> 析构函数。然而,由于 RVO,许多现代编译器会优化创建过程,使得只需调用构造函数即可。输出通常如下所示:

Creating object via function return:
Constructor called!
Destructor called!

没有调用拷贝构造函数(Sample(const Sample&))或移动构造函数(Sample(Sample&&) noexcept)表明发生了 RVO。对象直接在obj的内存位置中构造,无需额外的复制或移动。

接下来,让我们探讨使用填充和生成元素的概念自动填充 STL 容器的有效方法。

在 STL 容器中进行填充和生成

填充容器和在其中生成数据类似于将粘土塑造成雕塑。数据结构是你的基础,而填充和生成数据的技巧则赋予了你的程序生命。随着我们继续挖掘 STL 的巨大潜力,本部分将专注于 STL 容器中填充和生成的关键技术。让我们挽起袖子,深入探索精确构建数据结构的艺术和科学!

使用静态分配填充

想象一个场景,你需要一个填充了特定值的容器,无论是零、特定字符还是任何其他重复模式。STL 通过针对静态分配的方法简化了这一点。

例如,std::vector提供了其构造函数的重载,允许你指定大小和默认值:

std::vector<int> v(5, 2112);

这种方法确保了数据的一致性,这对于依赖于同质集合的操作至关重要。这并不仅限于向量。许多 STL 容器提供了类似的功能,确保开发者拥有在不同情境下所需的工具。

使用 STL 进行动态生成

虽然静态分配有其魅力,但更常见的情况是需要动态数据生成。无论是创建测试用例、模拟场景,还是任何需要特定模式的情况,STL 都不会让人失望。

STL 提供了std::generatestd::generate_n算法来满足这些需求。这些函数根据生成器函数将值赋给容器。

考虑以下示例:

std::vector<int> v(5);
std::generate(v.begin(), v.end(), [n = 0]() mutable { return n++; });

在这里,我们利用了 lambda 函数动态地生成连续整数。这种方法提供了无与伦比的灵活性,允许开发者生成从简单的数字递增到基于复杂公式或计算的复杂值的数据。

确保相关性和效率

现在,拥有工具只是战斗的一半。有效地使用它们才是掌握的关键。在填充和生成数据时,请遵循以下步骤:

  • 选择合适的方法:考虑数据的生命周期。如果创建后数据集保持静态,静态分配简单且高效。然而,对于不断变化的数据,动态生成方法提供了灵活性和适应性。

  • 注意大小:过度填充可能导致内存效率低下,而不足填充可能会导致操作不完整或意外的行为。始终要敏锐地意识到大小需求。

  • 利用 lambda 的力量:从 C++11 开始,lambda 简洁地定义了快速函数。它们在动态生成中非常有价值,允许定义定制函数,而不需要传统函数定义的冗长。

  • 考虑现实世界情境:始终将问题与手头的情境联系起来。如果你正在填充容器以模拟现实世界数据,确保你的填充和生成技术反映了现实场景。这不仅仅是填充容器,而是有目的地填充它们。

总结来说,STL 容器中有效填充和生成数据的能力证明了该库的强大。无论你是追求静态分配的统一性,还是寻求生成模式的动态魅力,STL 都能很好地满足你的需求。随着我们在接下来的章节中向更复杂的操作迈进,始终要记住,数据是应用程序的核心。你如何塑造和培养它,往往决定了程序的节奏和脉搏。

在 STL 容器中进行移除和替换

在使用 C++ STL 进行数据处理时,我们经常发现自己正在添加或查看元素,并参与它们的整理。随着我们揭开本章的层层面纱,移除和替换 的艺术成为一项基本技能,在保留有价值的内容和丢弃冗余内容之间取得完美平衡。通过掌握这些操作,你可以提高处理 STL 容器的熟练度,增强数据的相关性和整体效率。

移除的本质

当我们深入探索 STL 中的数据存储丰富领域时,改进的需求是显而易见的。无论是移除过时的记录、异常值还是任何冗余信息,STL 都提供了强大的工具来协助你。你可以使用诸如 erase 和 remove 等函数来精确指定要清除的特定值或条件。例如,使用 std::remove,可以将特定元素移动到序列容器的末尾,而 erase 则可以永久地删除它们。正是这种操作组合确保了清理过程的流畅性。

然而,尽管移除操作效率很高,但谨慎是必要的。盲目地删除元素可能会破坏容器的连续性,甚至影响性能。关键在于明智地使用这些操作,并始终关注迭代器的有效性以及潜在的重新分配,尤其是在 std::vector 等动态容器中。

替换

想象一下,你有一组过时的值或占位符元素,需要更新它们。STL 不会让你陷入困境。std::replacestd::replace_if 等函数是你在这个任务中的盟友。使用 std::replace,你可以无缝地在整个集合中交换旧值和新值。对于更复杂的场景,当替换条件不仅仅是简单的值匹配时,std::replace_if 就会成为焦点。std::replace_if 允许通过 lambda 表达式或函数对象来指定条件,从而进行替换。

为了一个动手的例子,考虑一个集合,其中负值被视为错误并需要更新。使用 std::replace_if,你可以查找并替换每个负值,用一个默认值或修正值替换,所有这些都在一行优雅的代码中完成。

让我们看看使用 std::replacestd::replace_if 的一个例子:

#include <algorithm>
#include <iostream>
#include <vector>
int main() {
  std::vector<int> values = {10, -1, 20, -2, 30};
  // Using std::replace to update a specific value
  std::replace(values.begin(), values.end(), -1,
               0); // Replace -1 with 0
  // Using std::replace_if to update based on a condition
  std::replace_if(
      values.begin(), values.end(),
      [](int value) {
        return value < 0;
      },  // Lambda function for condition
      0); // Replace negative values with 0
  // Printing the updated collection
  for (int value : values) { std::cout << value << " "; }
  std::cout << std::endl;
  return 0;
}

这里是示例输出:

10 0 20 0 30

在这个例子中,我们使用 std::replace 来查找并替换一个特定的值(-1)为 0。然后我们使用 std::replace_if 和一个 lambda 函数来识别负值并将它们替换为 0。这个例子展示了 std::replace 在简单直接替换中的应用,以及 std::replace_if 在更复杂场景中的应用,其中条件(如识别负值)决定了替换。

平衡的艺术

平衡删除和替换需要节奏感和平衡感。虽然积极整理很有吸引力,但有时保留特定数据,即使过时或冗余,也可以作为历史记录或参考点。因此,始终以明确的目标进行删除和替换,确保数据完整性、相关性和效率不受损害。

在本节中,我们磨练了整理和修改集合的技能,重点关注它们的删除和替换。这个过程至关重要,因为它在保留有价值的数据与消除冗余之间取得平衡,提高了数据的相关性和容器效率。我们探讨了使用 eraseremove 等函数进行数据精炼的战略使用,以及谨慎删除以保持容器完整性和性能的重要性。我们学习了使用 std::replacestd::replace_if 的替换技术,这些技术在更新集合中至关重要,尤其是在处理复杂条件时。这些工具不仅确保数据的更新和准确性,而且突出了 STL 在数据操作中的灵活性和强大功能。

接下来,我们转向交换和反转,展示如何有效地改变容器内元素顺序和位置,这是管理和管理 C++ 数据结构的一个关键方面。

STL 容器中的交换和反转

当我们遍历添加、初始化和改进我们的 STL 容器时,还有一个同样引人入胜的领域,在那里我们调整和重新排列元素以符合我们的要求。本节承诺带您进行一次探险,展示 STL 在重新定位和重新排列元素方面的能力,同时也会涉及到包括去重和抽样在内的复杂操作。

交换——互换的艺术

在许多实际场景中,需要在容器之间交换内容。无论是为了负载均衡、数据同步还是其他计算任务,STL 提供了交换函数,这是一个高效且简化的机制。

例如,std::swap可以与几乎所有的 STL 容器一起使用。如果你有两个std::vector并且希望交换它们的内容,std::swap可以在常数时间内完成魔法般的交换,而不需要复制或移动单个元素。这种效率来源于底层数据指针的交换,而不是实际内容。

反转 – 从末尾的一瞥

有时候,从不同的角度看待事物可以带来清晰,对于数据也是如此。STL 提供了std::reverse算法,它反转容器内元素顺序,提供新的视角或帮助满足特定的计算需求。无论是分析数据趋势还是满足倒序时间要求,std::reverse确保你的容器可以在线性时间内翻转其序列。

去重 – 突出独特性

随着数据量的增长,冗余的可能性也在增加。然而,STL 已经做好了应对这种情况的准备。std::unique算法有助于在排序序列中移除连续的重复项。虽然它不会直接删除重复项,但它将它们重新定位到容器的末尾,这使得在需要时删除它们变得方便。当与std::sort结合使用时,std::unique成为确保你的容器仅保留每个元素的唯一实例的有力工具。

采样 – 整体的一部分

在某些情况下,需要从更广泛的集合中采样一个子集。虽然 STL 没有提供直接的sample函数,但可以通过组合其他工具,如随机洗牌算法来推导出样本。通过随机洗牌然后选择前n个元素,你可以得到一个代表性的样本,可用于测试、分析或其他目的。

交换、反转、去重和采样只是 STL 广泛功能的一瞥。它们代表了数据的动态性质以及我们可能需要与之交互的多种方式。在你继续旅程的过程中,请记住,STL 不仅仅是工具和函数;它是一个旨在高效移动、塑形和管理你的数据的套件。

最佳实践

让我们回顾实现 STL 算法的最佳方式,以确保效率、维护数据完整性和识别最适合各种用例的最合适方法。

  • std::sort功能多样,但可能不是部分排序序列的最佳选择,此时std::partial_sortstd::stable_sort可能更为适用。

  • 优先选择算法而不是手写循环:面对搜索或排序等任务时,优先选择 STL 算法而不是手写循环,因为它们经过优化和广泛测试,因此更可靠且通常更快。

  • 尽可能使用const。它维护数据完整性并提供更好的接口洞察,避免意外修改。

  • std::copy_n确保没有越界访问,与std::copy相比。

  • std::count.

  • std::transformstd::for_each更合适。

  • 使用std::vector::reserve进行预分配内存。这种做法避免了不必要的重新分配,提高了性能。

  • 用于频繁查找的std::set可以显著优化性能。

  • std::move有助于实现这一点。

摘要

本章涵盖了在 STL 容器内改变和塑造数据的基本技术。我们首先理解了 STL 中复制和移动语义的细微差别,学习根据上下文在复制与移动元素之间做出有意的选择,以优化性能和资源管理。然后我们探讨了 RVO,这是一种优化编译器的技术,它消除了冗余的对象复制。

我们随后检查了填充和生成容器内容的方法,这对于高效初始化和修改大型数据集至关重要。我们涵盖了在容器内删除和替换元素的方法,平衡了数据完整性与性能的需求。本章还介绍了交换和反转元素的操作、去重以消除重复项以及抽样以创建数据的代表性子集。在整个过程中,我们专注于最佳实践,以确保这些操作以精确和高效的方式执行。

随着我们构建更复杂的程序,我们经常需要操作大量数据集。在这些操作方面的熟练程度能够创建更复杂和性能更高的应用程序,使信息对现代 C++编程变得有价值且至关重要。

在下一章中,我们将关注基本的和高级的数值操作,例如生成序列、求和元素以及处理相邻差分和内积。我们还将研究排序范围上的操作,巩固我们对如何将 STL 算法应用于数值数据的理解,从而增强我们在 C++中进行算法问题解决的工具集。下一章将继续建立在前面章节的基础上,确保对 STL 功能有一个连贯和全面的理解。

第十三章:数值和范围基础运算

在本章中,你将发现 C++ 标准模板库STL)强大的数值和排序操作潜力。这些函数为序列注入生命力,使得使用排序范围进行累积、转换和查询变得轻而易举。读者将深入了解基本和高级数值运算,并发现与排序集合一起工作的实用性。结合最佳实践,本章确保开发者拥有一个强大的工具集,以优化、并行化并优雅地处理数值数据。

本章将涵盖以下主要内容:

  • 基本数值运算

  • 高级数值运算

  • 排序范围上的操作

  • 最佳实践

技术要求

本章中的代码可以在 GitHub 上找到:

github.com/PacktPublishing/Data-Structures-and-Algorithms-with-the-CPP-STL

基本数值运算

发现 C++ STL 数值函数的力量是一种令人耳目一新的体验。在本节中,我们将深入探讨基础数值运算。通过掌握这些,你将解锁生成序列、计算综合摘要以及高效执行连续元素上的复杂操作的能力。所以,系好安全带,让我们开始吧!

使用 std::iota 生成序列

我们将要挖掘的第一个宝藏是 std::iota。它是数值运算工具箱中的一个简单而强大的工具。std::iota 用连续值填充一个范围。从一个初始值开始,它将递增的值分配给范围中后续的元素。在这里,你可以看到 std::itoa 用五个连续整数填充了一个向量,从 1 开始:

std::vector<int> vec(5);
std::iota(vec.begin(), vec.end(), 1);
// vec now holds: {1, 2, 3, 4, 5}

当你想要一个容器来存储许多连续的数字序列而不需要手动输入每一个时,这个函数将是一个福音。考虑这样一个场景,你想要一个 std::vector 来存储构造性模拟的时间步长:

#include <iostream>
#include <numeric>
#include <vector>
int main() {
  const int numTimeSteps = 100;
  std::vector<double> timeSteps(numTimeSteps);
  // Generate a sequence of time steps using std::iota
  double timeStep = 0.01; // Time step size
  std::iota(timeSteps.begin(), timeSteps.end(), 0);
  // Scale the time steps to represent actual time
  for (double &t : timeSteps) { t *= timeStep; }
  // Now, timeSteps contains a sequence of time points for
  // simulation
  // Simulate a simple system over time (e.g., particle
  // movement)
  for (const double t : timeSteps) {
    // Simulate the system's behavior at time t
    // ...
    std::cout << "Time: " << t << std::endl;
  }
  return 0;
}

这里是示例输出:

Time:0
Time: 0.01
Time: 0.02
Time: 0.03
Time: 0.04
Time: 0.05
...

在这个例子中,std::iota 用于生成时间步长的序列,这可以用来模拟系统随时间的行为。虽然这是一个简化的例子,但在实际应用中,你可以将 std::iota 作为更复杂模拟和建模场景的基础,例如物理模拟、金融建模或科学研究。

std::iota 有助于创建时间序列或离散事件时间线,这可以是各种计算模拟和建模任务的基本组成部分。当它集成到更大的、更复杂的系统中,时间序列或索引至关重要时,其价值变得更加明显。

使用 std::accumulate 求和元素

假设你有一系列数字,并希望找到它们的和(或者可能是一个乘积)。请使用 std::accumulate。此算法主要用于计算元素范围的总和。让我们看看以下简单示例的实际操作:

std::vector<int> vec = {1, 2, 3, 4, 5};
int sum = std::accumulate(vec.begin(), vec.end(), 0);
// sum will be 15

它主要用于计算元素范围的和,但它的功能并不仅限于此。凭借其灵活的设计,std::accumulate 也可以用于其他操作,例如查找乘积或连接字符串。通过提供自定义二元操作,其应用范围显著扩大。以下是一个简单的示例,说明如何使用 std::accumulate

#include <iostream>
#include <numeric>
#include <string>
#include <vector>
int main() {
  std::vector<std::string> words = {"Hello", ", ", "world",
                                    "!"};
  std::string concatenated = std::accumulate(
      words.begin(), words.end(), std::string(""),
      [](const std::string &x, const std::string &y) {
        return x + y;
      });
  std::cout << "Concatenated string: " << concatenated
            << std::endl;
  return 0;
}

这里是示例输出:

Concatenated string: Hello, world!

通过一些创意,std::accumulate 可以成为你算法工具箱中的多功能工具。

相邻元素及其与 std::adjacent_difference 的交互

有时,我们感兴趣的是单个元素和相邻元素的对。STL 通过 std::adjacent_difference 为此提供了支持。

std::adjacent_difference 计算一个元素与其前驱之间的差值,并将其存储在另一个序列中。这种操作在计算离散导数等任务中很有用。

以下代码演示了 std::adjacent_difference 的用法:

std::vector<int> vec = {2, 4, 6, 8, 10};
std::vector<int> result(5);
std::adjacent_difference(vec.begin(), vec.end(), result.begin());
// result holds: {2, 2, 2, 2, 2}

不仅限于差异,你还可以将自定义二元操作传递给 std::adjacent_difference 以实现不同的结果,例如比率。让我们看看以下示例:

#include <iostream>
#include <numeric>
#include <vector>
int main() {
  std::vector<double> values = {8.0, 16.0, 64.0, 256.0,
                                4096.0};
  // Create a vector to store the calculated ratios
  std::vector<double> ratios(values.size());
  // Write a lambda to use in adjacent_difference
  auto lambda = [](double x, double y) {
    if (x == 0.0) {
      // Handle division by zero for the first element
      return 0.0;
    } else {
      // Calculate the ratio between y and x
      return y / x;
    }
  };
  // Calculate the ratios between consecutive elements
  std::adjacent_difference(values.begin(), values.end(),
                           ratios.begin(), lambda);
  // The first element in the ratios vector is 0.0 because
  //there's no previous element
  // Print the calculated ratios for the remaining elements
  std::cout << "Ratios between consecutive elements:\n";
  for (size_t i = 1; i < ratios.size(); ++i) {
    std::cout << "Ratio " << i << ": " << ratios[i]
              << "\n";
  }
  return 0;
}

这里是示例输出:

Ratios between consecutive elements:
Ratio 1: 0.5
Ratio 2: 0.25
Ratio 3: 0.25
Ratio 4: 0.0625

使用 std::inner_product 的内积

对于那些涉足线性代数的人来说,这个函数是一个奇迹。std::inner_product 计算两个范围的点积。你可能还记得,点积是两个序列中对应对的乘积之和。让我们看看如何计算两个向量的点积:

std::vector<int> vec1 = {1, 2, 3};
std::vector<int> vec2 = {4, 5, 6};
int product = std::inner_product(vec1.begin(), vec1.end(),
                                 vec2.begin(), 0);
// product will be 32 (because 1*4 + 2*5 + 3*6 = 32)

std::inner_product 不仅限于整数或普通乘法。自定义二元操作可以针对不同类型和操作进行定制。

这里有一些现实世界的例子,以证明 std::inner_product 可以与针对不同类型和操作(而不仅仅是整数和普通乘法)定制的自定义二元操作一起工作:

  • std::inner_product 用于计算两个容器中元素的加权平均值,其中一个容器包含值,另一个容器包含相应的权重。自定义二元操作将执行值和权重的逐元素乘法,然后将它们相加以找到加权平均值。

  • 使用自定义二元操作计算投资组合的总价值,通过将资产价格乘以其相应的数量并求和。

  • std::inner_product 可以使用自定义二元操作来完成此目的。

  • std::inner_product 可以通过自定义二元操作进行适配,以高效地执行矩阵乘法。

  • 使用std::inner_product执行复数运算,例如计算两个复数向量的内积或找到复数平方的和。自定义的二元操作将针对复数算术进行定制。

  • 使用自定义的二元操作将字符串连接起来的std::inner_product。这允许你高效地连接字符串集合。

  • std::inner_product可以通过自定义的二元操作进行适配,以根据所需的算法执行颜色混合。

这些示例说明std::inner_product是一个多才多艺的算法,可以根据各种类型和操作进行定制。这使得它在许多现实世界的应用中非常有用,而不仅仅是简单的整数乘法。

在本节中,我们看到了 C++ STL 提供的基本数值操作为高效计算、生成和操作序列铺平了道路。它们改变了开发者解决问题的方法,使得快速有效的解决方案成为可能。正如我们所见,这些算法是多才多艺的,只需一点创意,就可以适应无数任务。

在你的工具包中有了这些工具,你现在可以生成序列,计算快速摘要,并对连续元素执行复杂操作。

高级数值操作

为了进一步探索 C++ STL 的数值操作之旅,让我们来看看那些提升数据处理能力并使并行性和并发成为性能追求盟友的高级数值过程。

记得我们关于生成序列和计算摘要的讨论吗?好吧,想象一下通过利用多个处理器的力量,将这些操作超级充电以高效处理大量数据。这正是高级数值操作大放异彩的地方。C++17 中引入的并行算法提供了实现这一目标的方法,确保我们的计算既快速又高效,即使在并发环境中也是如此。

当处理大量数据集时,顺序处理通常不够。以计算大量数字向量的总和为例。直接进行操作可以完成任务,但可能不是最快的。然而,通过分割数据并在多个数据块上并行工作,可以显著加快操作速度。这正是并行算法的精髓,而像std::reduce这样的函数就是这一点的例证。std::reduce不是顺序累积值,而是在并行中累积子总计,然后合并它们,为大型数据集提供了显著的性能提升。

为了看到这一过程在实际中的应用,让我们并行计算一个大型向量中所有数字的总和:

#include <execution>
#include <iostream>
#include <numeric>
#include <vector>
int main() {
  // Create a large vector of numbers
  const int dataSize = 1000000;
  std::vector<int> numbers(dataSize);
  // Initialize the vector with some values (e.g., 1 to
  // dataSize)
  std::iota(numbers.begin(), numbers.end(), 1);
  // Calculate the sum of the numbers in parallel
  int parallelSum = std::reduce(
      std::execution::par, numbers.begin(), numbers.end());
  std::cout << "Parallel Sum: " << parallelSum
            << std::endl;
  return 0;
}

下面是示例输出:

Parallel Sum: 1784293664

深入并行操作需要细致的方法。虽然速度的承诺很有吸引力,但必须谨慎。并行引入了挑战,如确保线程安全和处理数据竞争。幸运的是,STL 通过执行策略提供了补救措施。通过指定执行策略,例如在调用算法时使用std::execution::par,我们可以指导它并行运行。此外,还有std::execution::par_unseq,用于并行和向量化执行,确保更高的吞吐量。

说到转换,让我们来看看std::transform_reduce。这是std::transformstd::reduce的结合。它对每个范围元素应用转换函数,并将结果归约成一个单一值,这可以并行化。例如,如果我们有一个数字向量,并想对每个元素进行平方然后求和,std::transform_reduce将是我们的首选,尤其是在处理大量数据时。

让我们看看如何使用std::transform_reduce对向量的每个元素进行平方,然后对平方值求和:

#include <algorithm>
#include <execution>
#include <iostream>
#include <numeric>
#include <vector>
int main() {
  const long int dataSize = 1000;
  std::vector<long int> numbers(dataSize);
  std::iota(numbers.begin(), numbers.end(), 1);
  // Use std::transform_reduce to square each element and
  // sum them up in parallel
  long int parallelSumOfSquares = std::transform_reduce(
      std::execution::par, numbers.begin(), numbers.end(),
      0, // Initial value for the accumulation
      std::plus<long int>(),
      [](long int x) { return x * x; });
  std::cout << "Parallel Sum of Squares:"
            << parallelSumOfSquares << "\n";
  return 0;
}

这里是示例输出:

Parallel Sum of Squares: 333833500

高级操作的另一项亮点是std::inclusive_scanstd::exclusive_scan这对组合。这些是生成前缀和的强大工具。std::inclusive_scan将第 i 个输入元素包含在第 i 个和中,而std::exclusive_scan则不包含。像它们的其他高级数值操作一样,它们也可以通过并行执行来增强性能。

重要提示

在输入序列中的i,输出序列中相应的元素包含从输入序列索引0i的所有元素的总和。

并行操作可能非常耗费资源。确保硬件能够处理并行性,并且数据量足够大,足以证明并发执行的开销是必要的。此外,始终警惕潜在的问题,如数据竞争或死锁。关键在于不断权衡利弊,分析具体要求,并选择最合适的方案。

对排序范围的运算

排序的吸引力并不仅仅是为了整齐排列元素。相反,它赋予我们在后续操作中的强大能力——简化导航、高效查询和增强的操纵能力。对于 C++开发者来说,理解对排序范围的运算就像是获得了一套新的超级能力。凭借 C++ STL 为这些排序序列提供的工具,高效的算法操作世界变得一片开阔,等待探索。

那么,拥有排序范围有什么大不了的?考虑一下在杂乱无章的一堆书中找书和在整齐排列的书架上找书的区别。当数据排序后,算法可以采取捷径,例如分而治之,从而实现对数时间复杂度而不是线性时间复杂度。

在排序范围中,一个主要的技巧是利用 std::lower_boundstd::upper_bound,这两个函数是您实现此目的的首选。前者用于找到应该插入值以保持顺序的第一个位置,而后者则标识最后一个合适的点。共同使用,它们可以确定与给定值等效的条目范围。如果您曾对某些应用程序返回搜索结果的快速性感到惊奇,那么这些二分搜索技术通常要归功于它们。

继续讨论查询的话题,std::equal_range 作为上述函数的组合出现,返回排序范围内一个值的上下界;如果您只需要一个简单的检查,std::binary_search 会告诉您元素是否存在于排序范围内。这些工具简化了查询过程,使其既快速又精确。

然而,对排序范围的运算并不局限于搜索。集合运算,类似于我们的基础数学课程,在排序数据中变得生动起来。如果您有两个排序序列,并希望确定它们的公共元素,std::set_intersection 就是完成这项工作的工具。对于属于一个序列但不属于另一个序列的元素,转向 std::set_difference。如果您想要合并两个序列的元素同时保持排序顺序,std::set_union 就准备好了。最后但同样重要的是,为了找到每个序列中独特的元素,std::set_symmetric_difference 扮演着这个角色。

想象一下这些运算赋予我们的力量。比较两个大型数据集以找到共同点或差异是许多应用程序的常见需求,从数据库到数据分析。通过在排序范围内工作,这些运算变得可行且高效。

排序操作合理地假设数据是有序的。如果这个不变量没有得到维护,结果可能是不可预测的。因此,在深入这些操作之前,确保排序顺序至关重要。幸运的是,通过像 std::is_sorted 这样的函数,可以在进一步操作之前验证范围的排序性质。

让我们将所有这些概念结合起来,快速看一下它们是如何被使用的:

#include <algorithm>
#include <iostream>
#include <vector>
int main() {
  std::vector<int> d = {10, 20, 30, 40, 50,
                        60, 70, 80, 90};
  int tgt = 40;
  auto lb = std::lower_bound(d.begin(), d.end(), tgt);
  auto ub = std::upper_bound(d.begin(), d.end(), tgt);
  bool exists =
      std::binary_search(d.begin(), d.end(), tgt);
  std::vector<int> set1 = {10, 20, 30, 40, 50};
  std::vector<int> set2 = {30, 40, 50, 60, 70};
  std::vector<int> intersection(
      std::min(set1.size(), set2.size()));
  auto it = std::set_intersection(set1.begin(), set1.end(),
                                  set2.begin(), set2.end(),
                                  intersection.begin());
  std::vector<int> difference(
      std::max(set1.size(), set2.size()));
  auto diffEnd = std::set_difference(
      set1.begin(), set1.end(), set2.begin(), set2.end(),
      difference.begin());
  bool isSorted = std::is_sorted(d.begin(), d.end());
  std::cout << "Lower Bound:"
            << std::distance(d.begin(), lb) << "\n";
  std::cout << "Upper Bound:"
            << std::distance(d.begin(), ub) << "\n";
  std::cout << "Exists: " << exists << "\n";
  std::cout << "Intersection: ";
  for (auto i = intersection.begin(); i != it; ++i)
    std::cout << *i << " ";
  std::cout << "\n";
  std::cout << "Difference: ";
  for (auto i = difference.begin(); i != diffEnd; ++i)
    std::cout << *i << " ";
  std::cout << "\n";
  std::cout << "Is Sorted: " << isSorted << "\n";
  return 0;
}

下面是示例输出:

Lower Bound: 3
Upper Bound: 4
Exists: 1
Intersection: 30 40 50
Difference: 10 20
Is Sorted: 1

从这些例子中可以看出,对排序范围的运算解锁了一个广阔的可能性领域。它们展示了数学理论与实际编码的结合,为开发者提供了一个强大的框架,以无与伦比的效率导航、查询和操作数据。随着我们继续前进,我们将探讨与数值和基于范围的运算相关的最佳实践,确保我们在利用它们的力量时,能够做到精确、高效和优雅。探索和掌握的旅程仍在继续!

最佳实践

以下是一些与数值和基于范围的运算相关的最佳实践:

  • 对于几乎已排序的数据集,std::stable_sort 可能比其他排序方法更有效。因此,在决定适当的操作时,了解数据集的特征至关重要。

  • 在进行排序操作之前推荐使用 std::is_sorted

  • 明智地使用并行算法:随着对并发的日益重视,并行算法成为提高性能的一个有吸引力的选择。C++ STL 为许多标准算法提供了并行版本。虽然这些算法利用多个 CPU 核心来提供更快的执行结果,但它们也可能引入挑战,尤其是在线程安全方面。并发编程中的一个主要问题是共享可变状态。当多个线程试图同时修改相同的数据时,就会产生问题。为了安全地使用并行算法,线程要么在独立的数据部分上工作,要么使用如互斥锁之类的同步工具来管理同时的数据修改。

    此外,并行化并不总是答案。管理多个线程的开销有时可能会抵消并行执行的好处,尤其是在处理小数据集或简单任务时。为了确定并行化在特定场景中的有效性,最好在顺序和并行配置下对代码进行性能分析。这种评估有助于选择最有效的方法。

在本节中,我们探讨了如何根据数据属性在 C++ STL 中选择合适的算法,强调了数据集特征(如大小和分布)的重要性。对于几乎已排序的数据,选择如 std::stable_sort 这样的适当算法对于最佳性能至关重要。我们还强调了在排序操作中维护数据顺序的必要性,使用如 std::is_sorted 这样的工具来确保数据完整性。讨论了并行算法,重点关注它们的优点和复杂性,如线程安全。关键要点是,虽然并行化功能强大,但需要仔细考虑,尤其是在数据集大小和任务复杂性方面。

摘要

在本章中,我们深入研究了 C++ STL 提供的用于处理数值序列并在排序范围内操作的算法的多样世界。我们从基本的数值操作开始,例如使用 std::iota 生成序列,使用 accumulate 累加元素,以及使用 std::adjacent_difference 探索相邻元素之间的交互。本章探讨了更复杂的任务,例如使用 std::inner_product 计算内积。

这些操作在 STL 容器中的数据处理和分析中是必不可少的,它们简化了从简单累加到复杂转换的各种任务。对于开发者来说,这些信息至关重要,因为它在执行数值计算时提高了效率和效果,并为他们准备应对高性能场景,尤其是在处理大数据集时。

本章还涵盖了高级数值运算,这在并行计算环境中尤其有益。我们学习了如何使用并行算法进行数据转换和汇总,确保在并发环境中高性能。对排序范围的操作得到了探讨,展示了二分搜索技术的效率和集合运算的功能,这些操作由于数据的排序性质而得到了显著优化。

在下一章中,我们将探索范围的概念,这代表了 C++ 中序列的更现代的方法。我们将探讨为什么转向基于范围的运算变得流行,理解这些现代 STL 组件的本质和力量,并探索它们在排序和搜索算法中的可组合性。这一即将到来的章节将赋予读者拥抱现代 STL 全部潜能的知识,使他们能够在 C++ 编程实践中做出明智的决策,了解何时以及如何应用这些新工具。

第十四章:排列、分区和堆

本章探讨了 C++ 标准模板库STL)算法库中最基本且经常被忽视的一些方面。本章通过分区来阐明序列组织,通过排列来改变序列,以及基于堆的操作的迷人世界。这些操作是许多高级算法和数据结构的基础。通过理解和掌握它们,开发者可以提高其应用程序的效率,优化数据处理,并确保数据集的完整性。

在本章中,我们将介绍与 STL 相关的一些主题:

  • 分区

  • 排列

  • 堆操作

  • 最佳实践

技术要求

本章中的代码可以在 GitHub 上找到:

github.com/PacktPublishing/Data-Structures-and-Algorithms-with-the-CPP-STL

分区

分区,在其最简单的形式中,是关于根据特定标准组织序列的过程,确保所有满足要求的元素都排在那些不满足要求的元素之前。这是关于高效地隔离数据,优化其组织以实现快速访问,并提高计算效率。

C++ STL 为分区任务提供了一套丰富的算法。虽然有人可能会倾向于使用简单的循环和条件语句来完成这些任务,但这些 STL 函数经过了优化、测试和设计,以提供最佳性能。这些算法由对底层系统有深刻理解的专家实现,处理边缘情况,并通常利用编译器优化,甚至(潜在地)并行化。

std::partition 及其功能

在这个类别中最基础的功能之一是std::partition。这个函数根据谓词重新组织范围内的元素。它确保所有满足谓词的元素都排在那些不满足谓词的元素之前。但这里有一个需要记住的重要事情:元素的顺序不保证被保留。如果你关心顺序,那么std::stable_partition是你的朋友。虽然它可能有一些轻微的开销,但它保留了元素的相对顺序。

让我们来看一个例子。考虑一个整数序列,假设你想要将偶数和奇数分开。使用适当的 lambda 表达式调用std::partition可以迅速完成这项工作,如下面的代码所示:

std::vector<int> numbers = {7, 1471414, 3, 18, 9, 518955};
auto it = std::partition(numbers.begin(), numbers.end(), [](int n) { return n % 2 == 0; });

在这个操作之后,迭代器将遍历奇数的范围。

使用 std::is_partitioned 检查分区

一旦对范围进行了分区,确保分区正确可能是有益的,尤其是在较大的系统或集成多个算法时。这时,std::is_partitioned 函数就派上用场,它检查一个范围是否根据给定的谓词进行了分区。这在基于多个操作构建时尤其有用,确保关于数据布局的假设保持稳固。

std::partition_point 的实用性

分区之后,人们可能会问:“分界线在哪里?”这正是 std::partition_point 函数发挥作用的地方。该函数返回一个迭代器,指向新分区范围内不满足谓词的第一个元素。它假设范围已分区,并利用二分搜索,确保快速响应。

超越基本序列的分区

虽然前面的例子主要使用向量,但分区并不局限于它们。可以在数组、列表以及更高级的容器上应用这些技术。然而,记住底层容器的特性,例如随机访问能力,可能会影响这些操作的效率。

结合这些分区函数,可以构建高效、灵活且高度组织化的系统,以满足多样化的计算需求。考虑以下实际应用:

  • std::is_partitionedstd::partition 可以用于各种排序算法,如快速排序和豪尔分区。它们有助于根据条件高效地分区元素。

  • std::partition_point 可以用于二分搜索算法。它有助于找到分区范围内不满足给定条件的第一个元素。这在搜索排序数据集时非常有用。

  • std::partition 可以高效地将满足条件的元素与不满足条件的元素分开;例如,从列表中过滤出偶数和奇数。

  • std::partition 可以帮助根据这些标准将元素分离到不同的分区中。

  • std::partition 可以根据某些条件高效地划分数据,从而提高并行化。

  • std::partition 可以用来对数据进行分区,然后单独分析异常值。

  • 游戏开发:在游戏开发中,可能会使用分区来分离可见对象和隐藏对象,以优化渲染。

  • 数据库查询:在查询数据库时,可以使用分区来将匹配特定筛选条件的数据与数据集的其余部分分开。

  • 资源管理:在资源管理场景中,例如内存分配,可以使用分区来高效地隔离已使用和未使用的内存块。作为开发者,我们不断与各种数据及其高效处理作斗争。分区提供了一种结构化的处理方式,使数据组织优化和快速数据访问成为可能。虽然看似简单,但它构成了许多高级算法的基础。

通过掌握 STL 中的分区技术,不仅可以提升单个操作的性能,还能提高应用程序的整体效率和结构。随着我们进入后续章节中的排列和堆操作,请记住高效数据组织的基础重要性。

排列

排列的旅程是了解序列元素如何排列的旅程。随着开发者今天处理的序列和数据集的庞大,组织、打乱、旋转和切换元素的能力成为一项迷人的练习,也是许多应用的关键需求。C++ STL 的强大排列算法提供了一条轻松解锁这种潜力的途径。在本节中,我们将学习如何生成、操作和旋转排列,以及实际示例。

使用 std::next_permutation 生成排列

想象列出数据集的所有可能排列,分析它们,并可能使用它们作为问题的暴力解决方法。STL 提供了 std::next_permutation 用于此目的。给定一个范围,此函数将其元素重新排列为下一个字典序较大的排列。当所有排列都已耗尽时,函数返回 false,为开发者提供清晰的信号。

考虑一个简单的序列:{1, 2, 3}。通过连续调用 std::next_permutation,可以生成 {1, 3, 2}{2, 1, 3} 等等,直到序列循环回,如下面的代码所示:

std::vector<int> data = {1, 2, 3};
 do {
  for (int num : data) { std::cout << num << " "; }
  std::cout << "\n";
} while (std::next_permutation(data.begin(), data.end()));

使用 std::prev_permutation 进行前驱排列

有时,回顾过去是至关重要的,探索先于当前排列的排列。我们之前讨论过的函数 std::prev_permutation 的双胞胎就是这样做的。它将序列转换为它的下一个字典序较小的排列。

使用 std::shuffle 随机排列元素

虽然结构化排列有其位置,但有时随机性才是当天的主题。

进入 std::shuffle,这是一个完全随机排列元素的算法。与一个强大的随机数生成器配对,它确保了真正的随机性,这对于许多应用至关重要。

std::shuffle 的实际应用包括以下内容:

  • std::shuffle 可以用来实现这种随机性。

  • std::shuffle 可以用来打乱答案选项。

  • std::shuffle,然后选择前 N 个元素。

  • std::shuffle 可以帮助将随机性引入游戏。

  • 使用 std::shuffle 随机化轨道的顺序,为听众提供多样性。

  • std::shuffle 可以用来打乱输入或事件,以测试不同的代码路径。

  • 机器学习和数据科学:在训练机器学习模型或进行数据科学实验时,您可能需要打乱数据集,以确保模型不会学习任何与顺序相关的偏差。

  • std::shuffle 可以用来生成此类算法所需的随机化。

  • 使用 std::shuffle 模拟随机结果或卡牌或骰子的洗牌。

  • std::shuffle 不适合加密目的,但在加密算法中,洗牌的概念对于诸如安全卡牌游戏的卡洗功能等目的至关重要。

使用 std::rotate 旋转序列

并非所有的排列都涉及复杂的重新排列。有时,它只是简单的旋转。std::rotate 通过移动元素,使得选定的元素成为新的第一个元素。这就像转动一个旋钮,数字围绕一个中心点旋转。

以下是一个简单的示例,展示了 std::rotate 的使用:

std::vector<int> nums = {1, 2, 3, 4, 5};
std::rotate(nums.begin(), nums.begin() + 2, nums.end());
// nums now holds {3, 4, 5, 1, 2}

让我们现在看看 std::rotate 的广泛实际应用:

  • std::rotate 可以用来高效地重新定位文本。

  • std::rotate 可以用来交换或循环图像资源。

  • 调度和时间管理:在调度应用中,你可能希望通过旋转一天或一周的预约或任务来适应变化。

  • std::rotate 可以用来高效地管理数据在缓冲区内外移动。

  • std::rotate 可以用于此类位操作。

  • 作为排序过程的一部分的 std::rotate,可以有效地处理部分有序数据。

  • 图像处理:在图像处理中,你可能需要旋转像素值以执行图像变换或操作。

  • std::rotate 可以用来在解决方程或进行迭代计算时在向量或数组中移动元素。

  • 内存管理:在内存管理场景中,你可能需要移动内存块以优化内存分配和碎片整理。

  • 算法优化:在算法设计中,旋转元素可以通过减少交换或数据移动的数量来帮助提高某些操作的效率。

  • std::rotate 可以用来模拟拼图块的旋转。

  • std::rotate 可以用来通过旋转或移动数据点来创建动画效果。

使用 STL 的排列带来了数学理论与实际计算相结合的激动人心的混合体。它们体现了重组的精神,从不同的角度看待数据,确保没有遗漏任何一点(或序列!)。

堆操作

如果不探索堆,算法奇妙的旅程将是不完整的。是一种独特的结构,它以特定的顺序(升序或降序)优先处理数据。堆的核心是其承诺:具有最高(或最低)优先级的元素始终位于顶部。使用 C++ STL,堆的管理变得直观,为需要基于优先级操作的应用程序提供了效率和力量。

使用 std::make_heap 构建堆

从随机数据集合创建堆是这个过程的第一步。使用 std::make_heap,可以迅速将任何序列转换为最大堆,其中最大元素位于开头。以下代码演示了 std::make_heap 的使用:

std::vector<int> v = {3, 7, 2, 5, 1, 7, 4, 9};
std::make_heap(v.begin(), v.end());

通过上述简单调用,我们的 v 向量现在包含一个有效的最大堆。基于给定的比较器或默认比较,最重要的元素始终位于前端。

添加和删除元素 – std::push_heap 和 std::pop_heap

使用堆时,操作不仅仅是查看顶部元素。向堆中添加和删除数据是基本操作。当新元素添加到底层序列时,std::push_heap 确保它被适当地放置在堆中,如下面的代码所示:

v.push_back(8);  // Add element to vector
std::push_heap(v.begin(), v.end());  // Re-adjust the heap

相反,要删除顶部元素,使用 std::pop_heap。此函数不会删除元素,而是将其移动到序列的末尾,这使得删除变得方便,如下面的代码所示:

std::pop_heap(v.begin(), v.end());
v.pop_back();  // Remove the former top element

从堆中添加和删除元素是堆操作的核心。现在,让我们继续探讨一些更高级的内容:基于堆的排序。

基于堆的排序 – std::sort_heap 的力量

堆不仅仅是优先级管理。其结构允许高效的排序机制。std::sort_heap 将堆转换为升序排序的范围,如下面的代码所示:

std::sort_heap(v.begin(), v.end());

值得注意的是,当处理插入和提取操作频繁的数据集时,基于堆的排序可以特别有效,使其成为开发者工具箱中的宝贵工具。

使用 std::is_heap 检查堆的有效性

确保一个序列保持其堆属性至关重要。std::is_heap 提供了快速的有效性检查,如果给定的范围形成一个堆则返回 true,否则返回 false,如下面的代码所示:

bool isHeap = std::is_heap(v.begin(), v.end());

此函数在处理复杂序列时特别有价值,确保数据操作没有破坏堆结构。

今天计算中堆的重要性

堆在现代计算中至关重要,从任务调度到网络数据包管理。其结构促进了高效的优先级管理,使它们在包括模拟、事件驱动编程等场景中变得不可或缺。

这些基于堆的操作可用于许多实际场景:

  • std::priority_queue 容器内部使用堆来有效地管理最优先的元素。

  • 作业调度:在作业调度算法中,任务或作业通常具有相关的优先级或截止日期。可以使用最小堆来有效地优先排序和调度任务。

  • 迪杰斯特拉最短路径算法:迪杰斯特拉算法用于在加权图中找到最短路径,它使用最小堆实现的优先队列来选择下一个要探索的顶点。

  • Huffman 编码:一种流行的数据压缩技术,Huffman 编码使用字符频率作为权重构建二叉树。在树构建过程中,最小堆可以用于有效地合并节点。

  • 堆排序:堆排序是一种基于比较的排序算法,它使用二叉堆数据结构,通过反复从未排序的数组中提取最大(对于最大堆)或最小(对于最小堆)元素,从而得到一个排序数组。它是一种原地排序算法,时间复杂度为 O(n * log n)

  • 事件调度:在离散事件模拟或实时系统中,事件通常与时间戳相关联。可以使用最小堆来按时间顺序调度和处理事件。

  • 内存管理:在某些内存管理系统中,动态内存分配和释放使用堆来高效地分配和释放内存块。

  • 负载均衡:在负载均衡算法中,任务或进程被分配到可用资源中。最小堆可以帮助管理资源可用性和任务分配。

  • 在线中值计算:在处理连续数据流时,可以通过维护两个堆(一个最大堆和一个最小堆)来有效地计算数据的中值。

  • 合并排序文件:在合并多个排序文件或流时,可以使用最小堆从所有可用元素中选择最小元素,从而促进合并过程。

  • 磁盘空间管理:在文件系统中,高效管理空闲磁盘空间通常涉及维护一个可用的磁盘块堆。

  • 打印队列中的作业优先级:打印作业队列可以根据用户优先级或文档大小等因素对打印作业进行优先级排序,这可以通过使用堆实现的优先队列来高效管理。

C++ STL 提供的堆操作为开发者提供了高效处理优先级驱动任务的手段。它们将数据结构的理论优雅性与实用性相结合。在我们过渡到下一节的最佳实践时,理解堆在塑造高效、响应和可靠的应用中的作用至关重要。

最佳实践

探索排列、分区和堆可以为 C++ STL 的能力提供有价值的见解。当有效使用时,这些基础元素可以显著提高应用程序的性能和可靠性。遵循最佳实践对于最大化这些好处和确保一致、优化的数据操作至关重要。这些最佳实践包括以下内容:

  • 简化排列任务:尽管排列提供了广泛的序列变体,但重要的是不要过度复杂化过程。选择直接服务于当前任务的排列操作。对于复杂的操作,将其分解可以帮助保持清晰和专注。

  • 使用std::next_permutationstd::prev_permutation来遍历排列。利用这些函数可以消除手动生成排列的需要,并促进高效且无错误的操作。

  • 最优分区:在划分数据时,精确且明确的谓词是必不可少的。不明确的准则可能导致不可预测的分区并可能降低应用程序的效率。熟悉你的数据特性可以帮助有效分区。如果数据具有固有的顺序或结构,将其纳入分区算法中可以增强性能并减少资源使用。

  • 保持比较器一致性:对于堆操作,使用比较器的一致性至关重要。任何使用上的不一致都可能破坏堆结构并导致意外结果。例如,假设你使用一个比较器来构建最大堆,然后切换到不同的比较器来提取元素,在这种情况下,堆的结构可能会被破坏,你可能无法得到预期的最大元素。

  • 使用std::push_heapstd::pop_heap来保持堆的完整性。

  • std::sort可能对于更多静态数据集来说更有效率。

这些分区、排列和堆概念可以显著提高应用程序的性能和可靠性。简化排列任务以避免复杂性,利用 STL 排列函数以提高效率,确保数据分区有明确的准则,保持堆操作的比较器一致性,使用适当的函数优先访问堆,以及根据数据集的特征和更新频率选择排序方法,所有这些都有助于。在这些领域遵循最佳实践对于最大化 C++ STL 的好处以及确保编程中数据操作的持续优化至关重要。

摘要

在本章中,我们讨论了序列的操作。我们探讨了基于特定谓词组织数据的分区技术,并检查了各种排列算法,这些算法允许在范围内重新排列元素。我们还研究了 STL 提供的堆操作,这些操作有助于实现优先队列和高效排序。

理解这些操作对于开发者来说是至关重要的,因为它们是许多高级算法的基础,并且对于高效数据处理至关重要。掌握分区技术可以快速分离数据,排列允许探索数据集所有可能的排序,而堆提供了一种按优先级始终排序集合的方法。这些工具对于需要优化数据检索、操作和组织的任务是基本的。

在下一章中,我们将探讨范围概念,它为处理元素序列提供了一种更具有表现力的方法。本章将讨论基于范围的操作在排序和搜索算法中的优势,突出其增强的可组合性和可读性。随着我们进入本章,我们将深入了解这些现代技术的实际应用,确保我们作为熟练且当代的 C++开发者的持续成长。

第十五章:带有范围的 STL

本章讨论了 C++ 中范围应用的变革性采用,标志着从传统迭代器的范式转变。作为现代 C++ 的一个基本方面,范围推崇表达性和易用性的代码。通过本章,您将掌握使用标准算法利用范围的技术,实现既直观又强大的更简洁代码。通过掌握范围,C++ 开发者可以采用更模块化和高效的算法应用方法,为更可维护和高效的代码库奠定基础。

本章将涵盖以下主题:

  • 范围简介

  • 排序算法的范围

  • 搜索算法的范围

  • 最佳实践

技术要求

本章中的代码可以在 GitHub 上找到:

github.com/PacktPublishing/Data-Structures-and-Algorithms-with-the-CPP-STL

范围简介

编程范式不断发展,C++ 也不例外。随着穿越 C++ 标准模板库STL)广阔领域的旅程展开,其适应性和增长显然是其核心。这一进化步骤,以其表达性和效率而突出,是现代 C++ 中范围的出现。但范围究竟是什么?

理解范围的本质

使用 beginend 定义一个序列,范围将此信息封装在统一的实体中。这种看似微妙的转变具有深远的影响,重塑了我们处理算法和数据操作的方法。

一目了然,以下展示了排序向量的冗长传统方式:


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

然而,使用范围,我们可以这样表达:


std::ranges::sort(v);

美在于其清晰和简洁。我们不再需要同时处理多个迭代器;范围优雅地传达了意图。

为什么转向范围?

从迭代器到范围的演变不仅仅是一个随意的设计选择;它解决了 C++ 社区迫切需要解决的问题,例如以下内容:

  • 表达性:前面的示例表明,代码的可读性提高了。在不需要明确提及边界迭代器的情况下,在整个序列上表达算法可以促进更自然、声明式的编码风格。

  • 可组合性:范围使 C++ 的编程方法更加函数化。这意味着算法可以无缝组合,从而产生模块化和易于理解的代码。

  • 错误最小化:通过减少管理单个迭代器的需求,降低了与不匹配或错误使用迭代器相关的错误概率。这导致代码更安全、更易于维护。

查看范围操作的一瞥

范围不仅仅是关于更简洁的语法;它们提供了一系列可以以表达方式转换序列的操作。以下是一些操作:

  • 过滤:根据特定条件轻松细化序列

  • 转换:通过对其元素应用函数来修改序列

  • 连接:无缝连接多个序列

使用范围,这些操作可以串联起来,从而产生既直观又强大的代码。例如,使用范围对序列进行转换和过滤变成了一项简单直接的任务。

展望未来——现代 STL 的力量

在 STL 中引入范围表示了向更表达性和更人性化的 C++ 的一大步。随着开发者踏上这一新篇章,他们会发现他们已经依赖的熟悉操作已经得到了增强,并准备好应对软件行业的现代挑战。

C++ 中的范围显著提高了代码质量和开发者生产力。范围提供了一种更声明性的数据操作方法,促进了更干净、更易读的代码。它们允许对数据集合进行操作,而无需显式处理迭代器或创建临时容器。这种抽象不仅减少了样板代码,还最小化了与手动迭代器管理相关的错误可能性。此外,范围还促进了延迟评估,其中计算被推迟到实际需要值时。这可能导致性能改进,尤其是在涉及大数据集或复杂过滤标准的情况下。展望未来,范围有可能通过简化算法应用、增强可组合性和可能引入更多编译器优化机会来彻底改变 C++ 编码实践。随着 C++ 语言的不断发展,范围很可能会成为编写更高效、可维护和直观代码的必要组成部分。

在本节中,我们探讨了现代 C++ 中范围的概念,这是 C++ STL 的一项重大进步。范围代表了序列的一种抽象,提供了一种统一的方式来处理数据序列,而不是传统的迭代器对。这种向范围的转变提高了代码的表达性、可读性和可维护性。范围减少了冗长迭代器管理的需求,最小化了错误并增强了代码的清晰度。它们支持各种操作,如过滤、转换和连接,这些操作可以无缝连接,以实现更直观和强大的编码。C++ 中范围的引入标志着向更声明性数据操作迈出的一步,承诺提高开发者的生产力和代码质量。随着 C++ 的不断发展,范围有望在塑造更高效、更人性化的编码实践中发挥关键作用。

在接下来的章节中,我们将更深入地探讨范围的细微差别,探索它们与经典 STL 算法的应用,并揭示最佳实践以确保它们得到最大限度的利用。

排序算法的范围

排序是一个基本操作,是算法广阔宇宙中的基石。多年来,STL 赋予我们强大的排序能力。随着范围的引入,这种能力通过简化语法并注入更强的表现力而得到增强。

在本节中,我们将探讨范围如何简化 C++中排序算法的实现。我们将检查基于范围的排序如何减少与传统的基于迭代器的方法相关的语法开销和潜在错误。此外,我们还将讨论范围如何提高排序代码的可读性和可维护性,使其更容易理解和修改。

传统 STL 排序——回顾

在深入探讨范围带来的增强功能之前,让我们快速回顾一下传统的 STL 排序方法。传统上,使用std::sort函数,需要两个迭代器标记序列的开始和结束:


std::vector<int> nums = {4, 1, 3, 2};
std::sort(nums.begin(), nums.end());

虽然这种方法有效,但在可读性和用户友好性方面仍有提升空间。为了解决这个问题,引入了 STL 的基于范围的排序。

基于范围的排序——基础

重新定义优雅,基于范围的std::ranges::sort函数允许我们直接传递要排序的序列。无需与迭代器纠缠。根据我们之前的例子,使用范围,排序过程变为以下:


std::vector<int> nums = {4, 1, 3, 2};
std::ranges::sort(nums);

基于范围的排序的简洁性使其使用起来非常愉快,减少了潜在的错误途径,并提高了可读性。

在排序中拥抱组合性

范围的皇冠上的宝石之一是它们促进组合的能力,这在排序场景中特别明亮。考虑只需要对序列的子集进行排序的需求,或者在进行排序之前链式连接多个操作的需要。范围无缝地满足这些需求。

例如,想象一下需要按逆序对序列中的偶数进行排序的要求。使用范围,这可以在几行代码中表达,利用过滤和排序的结合力量:


#include <algorithm>
#include <iostream>
#include <ranges>
#include <vector>
int main() {
  std::vector<int> data = {5, 2, 9, 1, 5, 6, 8, 7, 3, 4};
  // Create a view of the data that filters even numbers
  // and then sorts them
  auto even_sorted =
      data | std::views::filter([](int x) {
        return x % 2 == 0;
      }) |
      std::views::transform([](int x) { return -x; }) |
      std::ranges::to<std::vector<int>>();
  std::sort(even_sorted.begin(), even_sorted.end());
  // Display the sorted even numbers
  std::cout << "Sorted even numbers: ";
  for (int num : even_sorted) { std::cout << num << " "; }
  std::cout << "\n";
  return 0;
}

这里是示例输出 1:

1 此示例已测试与 Visual Studio v17.8.6 兼容,但尚未与 GCC v13.2 或 Clang v17.0.1 编译。


Sorted even numbers: -8 -6 -4 -2

在此示例中,向量数据包含一系列整数。我们使用范围管道创建一个视图,首先使用std::views::filter过滤出偶数。然后,我们使用std::views::transform对数字取反,使它们可以按逆序排序。最后,使用std::ranges::to将视图转换为向量。然后显示排序后的偶数。这展示了范围的组合能力,允许对数据序列进行简洁且富有表现力的操作。

能够链式执行操作,从过滤到排序流畅过渡,展示了范围的组合能力。

语法之外的优点——为什么范围在排序中闪耀

除了明显的语法优雅之外,使用范围与 STL 排序算法结合提供了以下好处:

  • beginend 合并为一个单一实体,降低了不匹配迭代器的风险,提高了代码的安全性。

  • 灵活性:范围与视图相结合,提供了一套动态的工具集。无论是使用自定义比较器进行排序,还是适应不同的数据结构,基于范围的途径都保持一致且简单明了。

  • 表达力:范围促进了声明性代码风格,其中代码的意图突出。这种表达力在排序复杂数据类型或应用多方面逻辑时非常有价值。

排序中范围的革命

排序,一种与编程一样古老的操作,随着范围的引入而焕发新生。将传统的 STL 排序能力与现代范围的优雅性相结合,可以革命性地改变我们的实现,使代码更直观、可维护和高效。

范围已经改变了现代 C++ 中的排序算法。我们已经看到了 STL 排序的传统基于迭代器的方法,以及更简洁、更易读的基于范围的方法。传统方法,即使用 std::sort 和迭代器,是有效的,但在可读性和用户友好性方面可以改进。基于范围的排序,使用 std::ranges::sort,通过允许直接传递序列来简化这一点,减少了语法复杂性并降低了潜在错误。一个关键亮点是范围的组合性,这在排序场景中特别有益。

使用范围进行排序的优势不仅限于语法。它们通过将序列的开始和结束封装为单一实体来提供安全性,减少了不匹配迭代器的风险。它们提供了灵活性,通过动态工具使用自定义比较器或不同的数据结构进行排序。此外,范围使代码风格更具声明性,使代码的意图更加明显,特别是对于排序复杂数据类型或应用复杂逻辑非常有用。

C++ 中范围(ranges)的引入将 STL 排序算法的传统优势与现代范围的精致性相结合。这一革命导致了更直观、可维护和高效的代码实现。后续章节将继续探索,深入挖掘范围与其他 STL 排序算法的交互,并揭示其有效利用的最佳实践。

搜索算法的范围

当深入研究 STL 中算法搜索的领域时,很明显,范围的引入预示着一个简化且表达性强的代码时代的到来。为了欣赏这一演变,回顾 STL 中的传统搜索方法是必要的。

在容器内搜索的经典方式涉及使用诸如 std::findstd::find_if 的函数,其中你需要提供标记搜索范围的迭代器:


std::vector<int> nums = {1, 2, 3, 4, 5};
auto it = std::find(nums.begin(), nums.end(), 3);

有效?是的。在表达性和适应性方面最优?或许不是。

寻找优雅——基于范围的搜索

使用基于范围的代码,过渡到更易读和简洁的代码是显而易见的。使用范围,搜索操作变得固有的声明性更强,如下面的代码所示:


std::vector<int> nums = {1, 2, 3, 4, 5};
auto it = std::ranges::find(nums, 3);

除了简单的简洁性之外,基于范围的搜索的真正力量在于与其他范围适配器的结合,打开了一条通往更适应性和模块化代码的大门。

连接和过滤——可组合性的美丽

代码的优雅之处往往在于其能够以简单、易读的方式表达复杂操作。范围,凭借其可组合性,在实现这种优雅方面发挥着关键作用。让我们考虑一个细微的例子来更好地理解这一点:找到序列中既是素数又大于指定值的第一个三个数。当使用传统的 STL 方法处理这个任务时,可能需要繁琐的循环和条件检查。然而,范围将其转化为高效且易于理解的操作序列。

让我们看看一个有趣的例子来说明这个概念:


#include <iostream>
#include <ranges>
#include <vector>
bool is_prime(int number) {
  if (number <= 1) return false;
  for (int i = 2; i * i <= number; i++) {
    if (number % i == 0) return false;
  }
  return true;
}
int main() {
  std::vector<int> nums = {4,  6,  8,  9,  10, 11,
                           13, 15, 17, 19, 23, 25};
  auto prime_greater_than_10 =
      nums |
      std::views::filter([](int n) { return n > 10; }) |
      std::views::filter(is_prime) | std::views::take(3);
  std::cout
      << "First three prime numbers greater than 10: ";
  for (int num : prime_greater_than_10) {
    std::cout << num << " ";
  }
  std::cout << "\n";
  return 0;
}

这里是示例输出:


First three prime numbers greater than 10: 11 13 17

在这个例子中,我们从一个包含一系列整数的nums向量开始。使用范围,我们将三个操作串联起来:

  1. 对大于 10 的数字进行过滤:std::views::filter([](int n) { return n > 10; })选择大于的数字。

  2. 对素数进行过滤:std::views::filter(is_prime)使用is_prime函数仅保留素数

取前三个元素:std::views::take(3)将结果限制为满足先前标准的第一个三个元素。

结果是在单行可读代码中无缝集成条件。这个例子不仅展示了连接和过滤的力量,还突出了范围如何显著增强 C++代码的表达性和适应性。简洁性和表达性的结合使得范围成为现代 C++开发中不可或缺的特性。

理解搜索中的视图

视图在基于范围的景观中至关重要,尤其是在搜索场景中。与容器不同,视图不拥有自己的元素。它们呈现其源数据的转换视图,这可以是一个范围(例如容器)。当纳入搜索时,视图不会修改原始数据,但提供一个新的视角,这在模块化和可重用代码中特别有用。

扩展工具包——不仅仅是查找

虽然std::ranges::find是基石,但现代基于范围的途径提供了广泛的搜索算法。例如,std::ranges::search函数可以定位子序列,或std::ranges::find_end函数可以找到序列的最后一个出现,这些函数封装了基于范围的搜索的丰富性。它们的真正力量是通过其他范围适配器解锁的,为高效和表达性搜索任务提供了一系列可能性。

从传统方法过渡到 STL 中的基于范围的搜索,是 C++向更易读、模块化和表达性代码发展的证明。随着我们进一步深入到范围的世界,掌握这些工具和技术对于渴望编写高效且可维护代码的人来说至关重要。

最佳实践

向基于范围的 STL 过渡无疑是令人兴奋的。有了这种新的表达性和清晰度,我们手头有了强大的工具。然而,理解一系列最佳实践对于最大限度地发挥范围的作用并确保代码库的可维护性至关重要。让我们看看我们可以实施的一些最佳实践。

接受链式操作的力量

范围的一个显著特点是它们自然地能够链式操作。链式操作不仅增强了可读性,而且通过避免中间存储提高了效率:


std::vector<int> nums = {5, 8, 10, 14, 18};
auto result = nums | std::views::filter([](int n) { return n % 2 == 0; })
                   | std::views::transform([](int n) { return n * 2; });

这优雅的一行代码过滤掉了奇数,然后加倍了偶数。通过促进这种链式操作,你可以培养出更干净、更简洁的代码。

防范范围陷阱 - 生命周期意识

全力以赴使用范围是诱人的,尤其是考虑到它们的可组合性。然而,需要谨慎行事。最常见的陷阱之一是无意中悬垂视图,如下面的代码所示:


auto doubledEvens() {
    std::vector<int> nums = {1, 2, 3, 4, 5};
    return nums | std::views::filter([](int n) { return n % 2 == 0; })
                | std::views::transform([](int n) { return n * 2; });
}

上述代码返回一个局部变量的视图。当此函数退出时,局部变量将被销毁。由于返回的是已销毁变量的视图,这将导致未定义的行为。在处理视图时,始终要意识到底层数据的生命周期。

性能考虑 - 懒惰性和评估

范围,尤其是视图,是惰性操作的。这意味着它们仅在访问时才评估它们的元素。虽然这可以提高效率,但这也可能导致陷阱,尤其是在与有状态的运算或副作用结合使用时,如下面的示例所示:


int x = 0;
auto range = std::views::iota(1) | std::views::transform(&x { return x += n; });

如果range被多次评估,lambda 中的副作用会累积,导致意外结果。在这种情况下,建议强制进行急切评估,可能通过将范围转换为容器来实现。

可读性胜于简洁性 - 寻找平衡

虽然范围允许编写简洁的代码,但请记住一个基本原则:代码被阅读的次数比被编写的次数多。过度紧凑的范围操作链可能难以理解,尤其是对于新加入代码库的人来说。在简洁性和清晰性之间找到平衡。

遵循范围习惯用法——保持标准化

与 C++语言的其它部分一样,已经出现了一些用于使用范围的惯用用法和模式。在可能的情况下,优先选择标准惯用用法。(例如,使用std::ranges::sort进行排序,使用std::ranges::find进行查找。这两者都比编写自己的循环来完成本质上相同的事情要好。)这使得你的代码对其他 C++开发者来说更容易理解,并确保你从社区测试过的模式中受益。

本节强调了在 C++中使用基于范围的 STL 时的关键最佳实践。它强调了拥抱链式操作的力量的重要性,通过避免中间存储来提高代码的可读性和效率。然而,它也警告了常见的陷阱,如悬空视图,并建议关注底层数据的生命周期。本节指出范围的惰性求值特性,建议在涉及有状态操作或副作用的情况下进行急切求值以避免意外结果。此外,它建议在代码简洁性和可读性之间保持平衡,确保代码易于访问和理解。最后,它建议遵循既定的范围习惯用法,利用标准模式以获得更好的清晰度和社区一致性。这些实践旨在最大限度地发挥范围的作用,同时确保代码的可维护性和健壮性。

摘要

在本章中,我们探讨了 C++ STL 中的范围概念以及它们如何增强我们处理元素序列的方式。我们从范围的介绍开始,理解其本质以及转向这一新范式背后的动机。我们看到范围操作如何促进更具有表达性和可读性的代码,并深入探讨了范围为排序算法引入的可组合性。

本章接着聚焦于基于范围的排序,讨论了基本概念以及范围带来的优势,例如更简洁的语法和改进的可组合性。我们还考察了范围在搜索算法中的应用,赞赏了范围如何以优雅而强大的方式使我们能够链式操作并应用过滤器。

范围帮助我们以更好地表达我们意图的方式编写更干净的代码。转向范围代表了 STL 中的一个重大进化,提供了代码的增强清晰度和效率。它允许我们以更可读和可维护的方式组合复杂操作,这对开发过程和代码库的长期性都有益。

现在我们对 STL 数据类型、算法和其他核心概念有了深入的理解,我们将在下一章中使用这些知识来创建我们自己的容器类型,这些类型可以与现有的 STL 算法和迭代器无缝集成。我们将涵盖 STL 兼容性的基本要求,如迭代器实现和值语义。我们将指导您有效地使用操作符重载和创建自定义哈希函数。通过学习创建与 STL 兼容的容器,我们可以扩展 STL 以满足其特定需求,确保其自定义类型能够从 STL 算法和实践的强大功能和灵活性中受益。

第四部分:创建与 STL 兼容的类型和算法

我们这本书的这一部分致力于在 C++标准模板库(STL)生态系统中创建和集成自定义类型和算法。我们首先探讨构建与 STL 兼容的容器,详细说明与 STL 算法无缝互操作的基本要求。我们讨论了构建健壮迭代器和操作符重载的细微差别,以提供对自定义类型直观和一致的行为。特别关注创建自定义哈希函数,以促进用户定义类型在无序关联容器中的使用。

接下来,我们将深入了解开发与 STL 兼容算法的复杂性。这包括掌握模板函数,理解重载的微妙之处,并利用内联函数来提高性能。我们将强调使用谓词和函数对象来增强灵活性。

最后,我们被介绍到类型特性和策略,这些是强大的工具,允许开发者编写更适应性和模块化的代码。我们获得了有效实施这些概念的知识,确保您的自定义类型和算法不仅与标准模板库(STL)很好地集成,而且遵循现代 C++编程的最佳实践。

到本部分结束时,您将获得扩展 STL 以满足您独特需求的知识,这将加深对模板元编程和 C++提供的强大抽象的理解。

本部分包含以下章节:

  • 第十六章**:创建 STL-类型容器

  • 第十七章**:创建与 STL 兼容的算法

  • 第十八章**:类型特性和策略

第十六章:创建 STL-类型容器

开发者可以通过将自定义类型与 C++ 标准模板库STL)集成,利用无与伦比的互操作性、一致性和效率。本章重点介绍创建与 STL 算法无缝交互的自定义类型的必要方面,强调适当的操作符重载,并实现健壮的迭代器。到本章结束时,你将熟练于设计和实现自定义类型,确保它们充分利用 STL 的优势,并提高应用程序的整体有效性。

在本节中,我们将涵盖以下主题:

  • STL 兼容类型的优势

  • 与 STL 算法交互

  • 兼容性的基本要求

  • 为自定义类型构建迭代器

  • 有效的操作符重载

  • 创建自定义哈希函数

技术要求

本章中的代码可以在 GitHub 上找到:

github.com/PacktPublishing/Data-Structures-and-Algorithms-with-the-CPP-STL

STL 兼容类型的优势

在 C++中构建 STL 兼容类型为寻求提升编程能力的开发者提供了许多优势。其中最显著的原因是能够根据特定需求和性能要求定制容器。虽然 STL 提供了一套丰富的泛型容器,但自定义容器使我们能够在标准容器无法满足复杂应用需求或优化目标时,对数据结构进行精细调整。此外,创建自己的容器使我们能够对关键方面,如内存布局、分配策略和容器行为,拥有更大的控制权。这种细粒度的控制使我们能够优化内存使用并提高应用程序的效率。除了实际的好处之外,开始构建自定义容器的旅程是加深我们对 C++内部和复杂性的理解的无价机会。这是一条通往语言深度和精确度更高水平的专家知识的道路。

一种语言,一种方法

首先也是最重要的,使自定义类型与 STL 友好提供了一种不可否认的好处——一致性。考虑 STL 中大量算法和容器的多样性。从排序例程到复杂的数据结构,STL 是 C++开发的基石。通过使你的类型与 STL 保持一致,你确保它们可以与这个庞大的库无缝交互。

想象一下——一个刚接触您的代码库的开发者,已经熟悉 STL,当他们看到您的自定义类型遵循相同的模式时,会感到宾至如归。这种一致的方法显著降低了学习曲线,提供了熟悉且直观的体验。想象一下在您的自定义类型上使用 std::for_each 算法的便利性,就像使用 std::vectorstd::list 一样。这种设计上的统一性提高了生产力,并促进了代码的可读性。

可重用性——源源不断的礼物

建立在统一性概念的基础上,关于 STL 兼容性的另一个同样有说服力的论点是可重用性。遵循 STL 规范可以使您的自定义类型在多种场景中可重用。想想 STL 提供的庞大算法集合。一旦您的类型与 STL 兼容,它就可以立即从所有这些算法中受益,无需重新发明轮子。

此外,可重用性不仅限于算法。如果您的类型与 STL 兼容,其他开发者可以轻松地在他们的项目中采用它。随着时间的推移,这鼓励了协作开发,并培养了一个更广泛的社区参与编写、共享、审查和改进代码的生态系统。

在熟悉的领域中提高效率

STL 的核心是对性能的承诺。该库经过精心优化以确保效率。通过使您的类型与 STL 兼容,您使它们能够利用这些优化。无论是排序例程还是复杂的关联容器,您都可以确信您的类型将受益于 STL 中的所有性能优化。

此外,STL 友好型设计通常引导开发者避免常见的陷阱。鉴于 STL 多年来经过测试和验证,与它的规范一致本质上鼓励了类型设计中的最佳实践。

为前进铺路

对 STL 兼容类型优点的明显认可使未来的旅程变得更加有趣。随着我们认识到统一性、可重用性和效率的价值,我们已经为 STL 兼容性做好了准备。接下来的章节将揭示确保您的自定义类型与 STL 保持一致并展现其独特性的复杂性。从与 STL 算法交互到定制迭代器的细微差别,路线图清晰可见——创建兼容性强、多功能性高的类型。

在本节中,我们探讨了使自定义类型 STL 兼容的优点。这段旅程使您理解了 STL 友好型设计不仅仅是一个选择,而且在 C++ 开发中是一个重要的进步。我们研究了统一性、可重用性和效率的优点,强调了这些品质如何提升您的自定义类型在 C++ 生态系统中的地位。

当我们进入下一节“与 STL 算法交互”时,我们将从“为什么”过渡到“如何”实现 STL 兼容性。即将到来的这一节将指导你了解迭代器在接口 STL 算法中的关键作用,调整你的自定义类型以满足算法预期,并有效地处理错误。

与 STL 算法交互

本节将专注于为你提供将自定义类型无缝集成到 STL 算法中的技能,这是高级 C++ 编程的一个关键方面。这种集成不仅仅是符合标准,而且是一种共生关系,其中自定义类型和 STL 算法相互增强彼此的能力。你将学习如何为你的自定义类型设计和实现健壮的迭代器,这对于与 STL 算法实现顺畅交互至关重要。了解不同 STL 算法的具体要求,并调整你的自定义类型以满足这些需求也是关键焦点。这包括支持各种操作,如复制、比较和算术运算,这些都是算法正确运行所必需的。

我们还将涵盖错误处理和反馈机制的细微差别,教你如何让你的自定义类型不仅能够促进 STL 算法的操作,而且能够适当地应对意外情况。强调算法效率,我们将引导你通过最佳实践来确保你的自定义类型不会成为性能瓶颈。到本节结束时,你将获得关于创建与 STL 算法兼容且性能优化的自定义类型的宝贵见解,这将使你的 C++ 编程更加有效,你的应用程序更加高效和易于维护。

迭代器的核心地位

迭代器是自定义类型和 STL 算法之间的桥梁。在核心上,STL 算法主要依赖于迭代器在容器内导航和操作数据。因此,任何旨在完美集成的自定义类型都必须优先考虑一个健壮的迭代器设计。虽然我们将在专门的章节中涉及迭代器的构建,但理解它们的关键作用是至关重要的。提供一系列迭代器——从正向迭代器到双向迭代器,甚至随机访问迭代器——扩展了你的自定义类型可以与之交互的 STL 算法范围。

适应算法预期

每个 STL 算法都有一套来自其交互的容器的需求或预期。例如,std::sort 算法与随机访问迭代器配合工作最为优化。因此,为了确保自定义类型与这种排序例程良好配合,它应该理想地支持随机访问迭代器。

但这种关系更为深入。一些算法期望能够复制元素,一些需要比较操作,而另一些可能需要算术操作。因此,理解你想要支持的算法的先决条件至关重要。你根据这些期望对自定义类型进行越精细的调整,协同作用就越好。

错误处理和反馈机制

一个健壮的自定义类型不仅能够促进算法的操作,还提供了反馈机制。假设 STL 算法在操作你的自定义类型时遇到意外情况。在这种情况下,你的类型将如何响应?实现处理潜在问题并提供有意义反馈的机制是至关重要的。这可能以异常或其他 C++ 支持的错误处理范例的形式出现。

让我们来看以下示例:

#include <algorithm>
#include <iostream>
#include <stdexcept>
#include <vector>
class CustomType {
public:
  CustomType(int value = 0) : value_(value) {}
  // Comparison operation
  bool operator<(const CustomType &other) const {
    return value_ < other.value_;
  }
  // Arithmetic operation
  CustomType operator+(const CustomType &other) const {
    return CustomType(value_ + other.value_);
  }
  // Copy operation
  CustomType(const CustomType &other)
      : value_(other.value_) {}
private:
  int value_{0};
};
class CustomContainer {
public:
  using iterator = std::vector<CustomType>::iterator;
  using const_iterator =
      std::vector<CustomType>::const_iterator;
  iterator begin() { return data_.begin(); }
  const_iterator begin() const { return data_.begin(); }
  iterator end() { return data_.end(); }
  const_iterator end() const { return data_.end(); }
  void push_back(const CustomType &value) {
    data_.push_back(value);
  }
private:
  std::vector<CustomType> data_;
};
int main() {
  CustomContainer container;
  container.push_back(CustomType(3));
  container.push_back(CustomType(1));
  container.push_back(CustomType(2));
  try {
    std::sort(container.begin(), container.end());
  } catch (const std::exception &e) {
    // Handle potential issues and provide meaningful
    // feedback
    std::cerr << "An error occurred: " << e.what() << "\n";
    return 1;
  }
  return 0;
}

上述示例中的 CustomType 支持比较、算术和复制操作。我们还有一个 CustomContainer,它提供了随机访问迭代器(通过底层的 std::vector)。使用 std::sort 算法对容器中的元素进行排序。如果在排序过程中发生错误,它将在 catch 块中被捕获和处理。

算法效率和你的类型

STL 算法以其性能著称,通常经过复杂的优化。然而,如果自定义类型没有考虑到性能,它可能会成为算法效率的瓶颈。考虑以下场景:算法可能需要访问元素或频繁遍历自定义容器。在这些基本操作中的任何延迟都可能在算法执行过程中放大。

作为最佳实践,当你的自定义类型受到 STL 算法的影响时,应持续对其性能进行基准测试。性能分析工具可以提供关于潜在瓶颈和指导优化的见解。

建立坚实的基础

实际上,使自定义类型成为 STL 算法友好的旅程是多方面的。从迭代器的基础元素开始,探索理解算法期望,强调错误处理,以及优先考虑效率,构成了这一努力的精髓。

在本节中,我们深入探讨了将自定义类型与 STL 算法集成的过程。这有助于我们的代码与 STL 形成共生关系,其中自定义类型和 STL 算法相互增强对方的功能。我们探讨了迭代器在自定义类型和 STL 算法之间的关键作用,作为连接两者的纽带,理解它们在数据流畅导航和处理中的必要性。此外,我们还学习了如何使自定义类型适应各种 STL 算法的特定要求,确保最佳性能和有效集成。

当我们进入下一节“兼容性的基本要求”时,我们的关注点将从与 STL 算法的广泛交互转移到实现真正 STL 兼容的具体要求和标准。

兼容性的基本要求

在本节中,我们关注使自定义类型真正与 STL 兼容的基础方面。理解和实现我们将概述的关键元素对于充分利用 STL 强大且多功能的工具包的潜力至关重要。我们将涵盖基本要素,例如迭代器的设计、遵循值语义、操作保证以及提供大小和容量信息,每个要素都在确保与 STL 算法的无缝集成中发挥着至关重要的作用。

此处的目标是使您的自定义类型不仅能够与 STL 算法交互,还能提高其效率和功能。这需要理解 STL 在性能、操作行为和异常安全性方面的期望。通过满足这些要求,您将能够创建不仅功能性强,而且在 STL 框架内针对性能和可靠性进行了优化的自定义类型。

兼容性的基石

进入 STL 兼容性的世界就像加入一个独家俱乐部。进入的关键是理解和遵守基础要求。一旦您掌握了这些,STL 的巨大好处就属于您了。让我们开始这段变革之旅,揭示无缝集成所必需的基本组件。

迭代器的重要性

与 STL 兼容的类型等同于迭代器。它们是向 STL 算法输送数据的脉络。然而,仅仅提供迭代器是不够的。您迭代器的性质和能力定义了哪些算法可以与您的自定义类型交互。正向迭代器可能提供基本功能,但若要利用更高级的算法,您需要双向甚至随机访问迭代器。确保您的自定义类型公开适当的迭代器,将为更广泛的算法交互打开大门。

拥抱值语义

C++ 及其 STL 依赖于值语义。这意味着对象明确理解复制、赋值和销毁。在构建与 STL 兼容的类型时,定义清晰且高效的复制构造函数、复制赋值运算符、移动操作和析构函数至关重要。定义良好的语义行为确保算法可以无缝地创建、修改或销毁您的自定义类型的实例,而不会出现未预见的后果。

操作保证

算法依赖于某些操作在可预测的时间框架内执行。例如,std::vector保证其元素可以以常数时间访问。如果你的自定义类型承诺类似的访问,它应该始终如一地履行这一承诺。提供准确的操作保证确保算法以最佳和预期的方式执行。

大小和容量查询

STL 算法通常需要关于容器大小或在某些情况下其容量的信息。你的自定义类型需要及时提供这些细节。size()empty()和可能还有capacity()函数应该是你设计中的基本组成部分。

元素访问和操作

不仅要理解结构,STL 算法还需要访问和操作其内部的元素。这需要成员函数或运算符来简化直接访问、插入和删除。这些操作越灵活,你的自定义类型能够兼容的算法范围就越广。

异常安全性的一致性

异常安全性是确保你的代码在异常发生时不会泄露资源或变得未定义的保证。STL 采用了一种细微的异常安全性方法,通常分为“基本”和“强”等层次。将你的自定义类型的异常安全性保证与 STL 的保证相一致,确保更顺畅的交互并加强你类型的可靠性。

让我们来看一个例子:

#include <algorithm>
#include <iostream>
#include <vector>
// Custom type that is STL-compatible
class CustomType {
public:
  using iterator = std::vector<int>::iterator;
  using const_iterator = std::vector<int>::const_iterator;
  // Constructors
  CustomType() = default;
  CustomType(const CustomType &other) : data(other.data) {}
  CustomType(CustomType &&other) noexcept
      : data(std::move(other.data)) {}
  // Assignment operators
  CustomType &operator=(const CustomType &other) {
    if (this != &other) { data = other.data; }
    return *this;
  }
  CustomType &operator=(CustomType &&other) noexcept {
    if (this != &other) { data = std::move(other.data); }
    return *this;
  }
  ~CustomType() = default;
  // Size and capacity queries
  size_t size() const { return data.size(); }
  bool empty() const { return data.empty(); }
  // Element access and manipulation
  int &operator[](size_t index) { return data[index]; }
  const int &operator[](size_t index) const {
    return data[index];
  }
  void push_back(int value) { data.push_back(value); }
  void pop_back() { data.pop_back(); }
  // Iterators
  iterator begin() { return data.begin(); }
  const_iterator begin() const { return data.begin(); }
  iterator end() { return data.end(); }
  const_iterator end() const { return data.end(); }
private:
  std::vector<int> data;
};
int main() {
  CustomType custom;
  // Fill with some data
  for (int i = 0; i < 10; ++i) { custom.push_back(i); }
  // Use STL algorithm with our custom type
  std::for_each(
      custom.begin(), custom.end(),
      [](int &value) { std::cout << value << ' '; });
  return 0;
}

下面是示例输出:

0 1 2 3 4 5 6 7 8 9

此代码定义了一个与 STL 兼容的CustomType类。它提供了迭代器,并定义了拷贝构造函数、移动构造函数、赋值运算符和析构函数。它还提供了查询大小和容量以及访问和操作元素的函数。main函数演示了如何使用CustomType实例与 STL 算法(std::for_each)一起使用。

期待增强的集成

在掌握这些基础要求之后,你将朝着制作与 STL 和谐共鸣的类型迈进。记住,这是一个伙伴关系。虽然 STL 提供了无与伦比强大算法和工具,但你的自定义类型带来了独特的功能和细微差别。当这些世界在兼容性中碰撞时,结果就是编码魔法。

随着我们进入下一部分,我们将深化对创建迭代器的复杂艺术和运算符重载的微妙之处的理解。你每迈出的一步都巩固了你在 STL 集成精英俱乐部中的地位,解锁了更大的编程能力。

为自定义类型创建迭代器

迭代器无疑是 STL 数据访问世界的心脏。它们作为桥梁,将自定义数据结构与 STL 算法的广泛数组连接起来。一个精心设计的迭代器确保了无缝的数据访问和修改,使你的自定义类型感觉就像一直是 STL 家族的一员。

在 C++编程中,为自定义类型创建 STL 迭代器至关重要,因为它们作为基本桥梁,使得自定义类型与 STL 算法的众多算法之间能够无缝集成和交互。它们促进了自定义容器内数据的遍历和操作,确保这些类型能够充分利用 STL 算法的强大功能和效率。如果没有设计良好的迭代器,自定义类型将会孤立,无法利用 STL 提供的广泛和优化的功能。

选择正确的迭代器类型

有许多迭代器类型可供选择,每种类型都为其提供了独特的功能。正向迭代器允许通过序列进行单向移动,而双向迭代器则提供了反向遍历的能力。更进一步,随机访问迭代器允许在数据结构中的任何位置进行快速跳跃。当为自定义类型构建迭代器时,确定哪种类型与你的数据和希望支持的运算的性质相匹配至关重要。所选类型为可以使用的算法和这些操作的效率设定了舞台。

选择迭代器类型应该根据你数据结构的固有特性和你打算执行的操作的效率要求来指导。正向迭代器是最简单的,只支持单向遍历。它们适用于只需要顺序访问的数据结构,例如单链表。这种简单性可以导致此类任务性能的优化。

双向迭代器允许双向遍历,适用于如双向链表这样的结构,其中反向迭代与正向迭代一样基本。向后移动的额外灵活性伴随着轻微的复杂性增加,但如果你的数据结构和算法从双向遍历中受益,这是一个合理的选择。

随机访问迭代器提供了最大的灵活性,允许在常数时间内直接访问任何元素,类似于数组索引。它们对于向量、数组等需要此类功能的数据结构是必不可少的。然而,这种级别的功能对于所有数据类型来说并不是必需的,如果数据结构本身不支持快速随机访问,这可能会增加不必要的开销。

从本质上讲,虽然你可以设计一个数据结构来使用更高级的迭代器类型,如随机访问,但在不需要其功能的情况下这样做可能会导致效率低下。迭代器的选择应与数据结构的自然行为和需求相一致,以确保最佳性能和资源利用。这是在迭代器提供的功能与它打算用于的数据结构的本质之间找到正确平衡的问题。

构建基本组件

在本质上,迭代器必须支持一组基本操作来定义其行为。这包括解引用以访问底层数据,递增和可能递减以遍历数据,以及比较以确定两个迭代器的相对位置。有效地实现这些操作确保你的自定义类型的迭代器能够与 STL 算法良好协作。

使用类型特性处理迭代器类别

STL 算法,作为有洞察力的实体,经常寻找关于迭代器性质的线索。它们使用这些线索来优化其行为。这正是类型特性发挥作用的地方。通过为你的自定义迭代器特化 std::iterator_traits,你实际上是在算法耳边低语,告诉它预期什么。这种知识使算法能够在操作中做出最佳选择,确保最佳性能。

结束迭代器 – 标志着终点线

每次旅行都需要一个明确的终点,迭代器也不例外。除了允许访问数据的迭代器之外,提供一个结束迭代器至关重要。这个特殊的迭代器不指向有效数据,而是表示边界 – 超过最后一个有效元素的点。STL 算法依赖于这个哨兵来知道何时停止操作,使其成为任何迭代器套件的重要组成部分。

关于常量迭代器的考虑

正如图书馆提供常规书籍和仅作参考的文本一样,数据结构通常需要满足修改和仅查看的需求。常量迭代器满足后一种场景,允许数据被访问而不存在修改的风险。构建常量迭代器确保你的自定义类型可以在数据完整性至关重要的场景中安全使用。

让我们看看一个说明性的 C++ 代码示例,演示了为自定义数据结构创建自定义迭代器的过程:

#include <iostream>
#include <iterator>
#include <vector>
// Define a custom data structure for custom iterators.
class MyContainer {
public:
  MyContainer(std::initializer_list<int> values)
      : data(values) {}
  // Custom iterator for MyContainer.
  class iterator {
  private:
    std::vector<int>::iterator it;
  public:
    iterator(std::vector<int>::iterator iter) : it(iter) {}
    // Dereferencing operator to access the underlying
    // data.
    int &operator*() { return *it; }
    // Increment operator to navigate through the data.
    iterator &operator++() {
      ++it;
      return *this;
    }
    // Comparison operator to determine the relative
    // positions of two iterators.
    bool operator==(const iterator &other) const {
      return it == other.it;
    }
    bool operator!=(const iterator &other) const {
      return it != other.it;
    }
  };
  // Begin and end functions to provide iterators.
  iterator begin() { return iterator(data.begin()); }
  iterator end() { return iterator(data.end()); }
  // Additional member functions for MyContainer as needed.
private:
  std::vector<int> data;
};
int main() {
  MyContainer container = {1, 2, 3, 4, 5};
  // Using custom iterators to iterate through the data.
  for (MyContainer::iterator it = container.begin();
       it != container.end(); ++it) {
    std::cout << *it << " ";
  }
  std::cout << "\n";
  return 0;
}

这里是示例输出:

1 2 3 4 5

性能优化和高级技术

构建迭代器不仅仅是关于功能,还需要技巧。考虑内存缓存技术、预取和其他优化来提升性能。记住,迭代器是一个经常使用的组件,任何效率提升都可能对整体应用性能产生重大影响。

拥抱迭代精神

在深入了解迭代器世界之后,很明显,它们不仅仅是工具——它们是 STL 通用性和强大功能的证明。通过精心设计自定义类型的迭代器,你可以增强与 STL 算法的互操作性,提升用户体验,使数据访问直观且高效。在本节中,我们学习了为什么选择正确的迭代器类型很重要,如何编写基本的迭代器,以及构建常量迭代器时需要考虑的事项。在下一节中,我们将探讨操作符重载的细微差别,确保我们的自定义类型在 C++ 和 STL 的世界中真正感到舒适。

高效的操作符重载

接下来,让我们努力理解 C++ 中操作符重载的战略实现,这是一个显著增强自定义类型功能和集成特性的特性。操作符重载允许自定义类型模拟内置类型的行为,为 STL 算法提供一个无缝的接口,以便它们能够像处理原生 C++ 类型一样高效地处理这些类型。这一特性对于确保自定义类型不仅与 STL 算法兼容,而且针对其高效执行进行了优化至关重要。

此处重点在于设计操作符重载,以促进自定义类型集成到 STL 框架中。例如,重载算术操作符如 +-* 允许自定义类型直接参与执行数学运算的 STL 算法,如 std::transformstd::accumulate。同样,重载关系操作符如 ==<> 使自定义类型能够有效地用于需要元素比较的 STL 算法,如 std::sortstd::binary_search。关键是要确保这些重载的操作符模仿内置类型对应操作符的行为,保持操作直观性并提高算法结果的可预测性。通过精心实现操作符重载,我们可以确保自定义类型不仅与 STL 算法无缝交互,而且有助于提高 C++ 程序的整体效率和可读性。

C++ 中的操作符重载

操作符重载允许 C++ 中的自定义类型为标准操作符提供特定的行为。通过利用这一特性,开发者可以像使用内置类型一样直接实现自定义类型上的操作,从而提高代码的可读性和一致性。

重载时的考虑因素

尽管操作符重载可以使表达式更加丰富,但关键是要谨慎使用。主要目标应该是提高清晰度,而不是引起混淆。一个基本的指导原则是,重载的操作符应该与内置类型的对应操作符表现相似。偏离这个标准可能会产生难以理解和维护的代码。

实现自定义类型的算术运算符

对于自定义的数学向量类型,实现加法(+)、减法(-)和乘法(*)等操作是合理的。重载这些运算符确保开发者可以像处理原始数据类型一样操作您的自定义类型。

重载关系运算符以进行清晰的比较

关系运算符(==!=<<=>>=)不仅限于原始类型。通过为自定义类型重载这些运算符,您提供了直接比较实例的方法。这种能力简化了如对自定义对象列表进行排序等任务。

考虑一个自定义的 Product 类,它重载了 +<=+= 运算符。实现方式简单直观,为与类的交互提供了非常直观的方式:

#include <iostream>
#include <string>
class Product {
public:
  std::string name;
  double price;
  Product(const std::string &n, double p)
      : name(n), price(p) {}
  // Overloading the addition operator (+) to combine
  // prices
  Product operator+(const Product &other) const {
    return Product(name + " and " + other.name,
                   price + other.price);
  }
  // Overloading the less than operator (<) to compare
  // prices
  bool operator<(const Product &other) const {
    return price < other.price;
  }
  // Overloading the assignment operator (=) to copy
  // products
  Product &operator=(const Product &other) {
    if (this == &other) { return *this; }
    name = other.name;
    price = other.price;
    return *this;
  }
  // Overloading the compound assignment operator (+=) to
  // add prices
  Product &operator+=(const Product &other) {
    price += other.price;
    return *this;
  }
};
int main() {
  Product widget("Widget", 25.99);
  Product gadget("Gadget", 19.95);
  // Using the overloaded operators
  Product combinedProduct = widget + gadget;
  // Using the compound assignment operator
  widget += gadget;
  bool widgetIsCheaper = widget < gadget;
  bool gadgetIsCheaper = gadget < widget;
  std::cout << "Combined Product: " << combinedProduct.name
            << " ($" << combinedProduct.price << ")"
            << "\n";
  std::cout << "Is Widget cheaper than Gadget? "
            << (widgetIsCheaper ? "Yes": "No") << "\n";
  std::cout << "Is Gadget cheaper than Widget? "
            << (gadgetIsCheaper ? "Yes": "No") << "\n";
  std::cout << "Updated widget: " << widget.name << " ($"
            << widget.price << ")"
            << "\n";
  return 0;
}

这里是示例输出:

Combined Product: Widget and Gadget ($45.94)
Is Widget cheaper than Gadget? No
Is Gadget cheaper than Widget? Yes
Updated widget: Widget ($45.94)

这个示例演示了如何利用自定义类型上的运算符重载。这些重载(尤其是比较)对于类型与各种 STL 算法兼容是必需的。

使用赋值和复合赋值简化任务

重载赋值运算符(=)和复合赋值运算符(+=-=|=>>= 以及更多)为修改您的自定义类型的实例提供了一种简单的方法,消除了需要更长的函数调用的需求。

高效 I/O 的流运算符

I/O 操作对于大多数应用程序来说至关重要。重载流插入运算符(<<)和提取运算符(>>)使得自定义类型可以轻松地与 C++ 流一起工作,确保了统一的 I/O 接口。

重载中的运算符优先级和结合性

在定义运算符重载时,牢记 C++ 中已建立的优先级和结合性规则是至关重要的。这确保了涉及您的自定义类型的表达式按预期处理。

C++ 中运算符重载的作用

运算符重载增强了自定义类型在 C++ 中的集成。它促进了简洁直观的操作,使得自定义类型能够很好地与 STL 算法和容器一起工作。通过深思熟虑地使用此功能,开发者可以创建提供功能和使用便利的自定义类型。

在后续章节中,我们将探讨可以优化您的 C++ 开发体验的工具和实践,旨在使应用程序开发既有效又简单。

创建自定义哈希函数

正如我们所见,STL 提供了大量的容器类,如 std::unordered_mapstd::unordered_setstd::unordered_multiset,它们在很大程度上依赖于哈希函数以实现高效操作。当与自定义类型一起工作时,创建针对您的数据结构定制的自定义哈希函数是必不可少的。在本节中,我们将了解实现自定义哈希函数的重要性,探讨良好哈希函数的特征,并提供一个示例,说明如何使用自定义哈希函数将自定义类型与 STL 容器集成。

与 STL 容器的互操作性

STL 容器,如 std::unordered_mapstd::unordered_set,使用哈希表来高效地存储和检索元素。为了使你的自定义类型与这些容器兼容,你需要提供一种方法,让它们能够计算哈希值,该值用于确定元素在容器中的存储位置。没有自定义哈希函数,STL 容器将不知道如何正确地哈希你的自定义对象。

通过实现自定义哈希函数,你可以确保你的自定义类型可以无缝地与 STL 容器交互,从而提供以下好处:

  • 效率:自定义哈希函数可以针对你的特定数据结构进行优化,从而在 STL 容器中实现更快的访问和检索时间。这种优化可以显著提高应用程序的整体性能。

  • 一致性:自定义哈希函数使你的自定义类型能够实现哈希一致性。没有它们,同一自定义类型的不同实例可能会产生不同的哈希值,导致从容器中检索元素时出现问题。

  • 正确性:一个设计良好的自定义哈希函数确保你的自定义类型被正确哈希,防止冲突并保持容器内数据的完整性。

自定义类型语义

自定义类型通常具有独特的语义和内部结构,在哈希时需要特殊处理。STL 容器默认使用标准库提供的 std::hash 函数。这个函数可能无法充分处理你的自定义类型的复杂性。

通过精心设计你的自定义哈希函数,你可以根据数据结构的特定要求定制哈希过程。例如,你可能需要考虑自定义类型的内部状态,有选择地哈希某些成员而排除其他成员,或者甚至应用额外的转换以确保容器中元素的最佳分布。

良好哈希函数的特征

在创建自定义哈希函数时,遵循定义良好哈希函数的具体特征是至关重要的。一个良好的哈希函数应该具备以下属性:

  • 确定性:哈希函数应该总是为输入产生相同的值。这个属性确保元素在容器内始终被放置在相同的位置。

  • 均匀分布:理想情况下,哈希函数应该在所有可能的哈希值范围内均匀分布值。不均匀的分布可能导致性能问题,因为某些桶可能过载,而其他桶则未被充分利用。

  • 最小化冲突:当两个不同的元素产生相同的哈希值时,会发生冲突。一个良好的哈希函数通过确保不同的输入生成不同的哈希值来最小化冲突,这减少了 STL 容器性能下降的可能性。

  • 高效率:效率对于哈希函数至关重要,尤其是在处理大数据集时。一个好的哈希函数应该是计算效率高的,确保在计算哈希值时开销最小。

  • 混合良好:哈希函数应该产生混合良好的哈希值,这意味着输入的微小变化应该导致哈希值有显著的不同。这一特性有助于在容器内保持元素的平衡分布。

自定义哈希函数创建示例

让我们通过一个示例来说明自定义哈希函数的创建。假设我们有一个具有姓名和年龄的自定义 Person 类。我们想使用 std::unordered_map 来存储 Person 对象,并且我们需要一个自定义哈希函数来实现这一点。以下是一个此类哈希函数的实现代码:

#include <iostream>
#include <string>
#include <unordered_map>
class Person {
public:
  Person(const std::string &n, int a) : name(n), age(a) {}
  std::string getName() const { return name; }
  int getAge() const { return age; }
  bool operator==(const Person &other) const {
    return name == other.name && age == other.age;
  }
private:
  std::string name;
  int age{0};
};
struct PersonHash {
  std::size_t operator()(const Person &person) const {
    // Combine the hash values of name and age using XOR
    std::size_t nameHash =
        std::hash<std::string>()(person.getName());
    std::size_t ageHash =
        std::hash<int>()(person.getAge());
    return nameHash ^ ageHash;
  }
};
int main() {
  std::unordered_map<Person, std::string, PersonHash>
      personMap;
  // Insert Person objects into the map
  Person person1("Alice", 30);
  Person person2("Bob", 25);
  personMap[person1] = "Engineer";
  personMap[person2] = "Designer";
  // Access values using custom Person objects
  std::cout << "Alice's profession: " << personMap[person1]
            << "\n";
  return 0;
}

在这个示例中,我们定义了一个具有自定义等价运算符和自定义哈希函数 PersonHashPerson 类。PersonHash 哈希函数结合了 nameage 成员的哈希值,使用 XOR 确保哈希结果混合良好。这个自定义哈希函数使我们能够将 Person 对象用作 std::unordered_map 中的键。

通过实现针对我们自定义类型特定需求的自定义哈希函数,我们使 STL 容器与自定义类型的平滑集成成为可能,并确保高效、一致和正确的操作。

总结来说,当在 STL 容器中使用自定义类型时,自定义哈希函数是必不可少的。它们促进了这些容器中元素的高效、一致和正确的存储和检索。遵循良好哈希函数的特征并创建一个适合自定义类型语义的哈希函数至关重要。我们提供的示例演示了如何为自定义类型创建自定义哈希函数并有效地与 STL 容器一起使用。这种知识使您能够充分利用 C++ STL 来处理自定义数据结构。

概述

在这一章中,我们探讨了在 C++ 中创建 STL 类型容器的根本方面。我们首先探讨了使用 STL 兼容类型的优势,强调了一致性、可重用性和效率的好处。这些优势为更顺畅和更高效的开发过程奠定了基础。

然后,我们讨论了如何与 STL 算法交互,强调了迭代器在导航和操作容器元素中的核心地位。我们强调了将自定义类型适应算法期望、优雅地处理错误以及优化算法效率的重要性。

我们还涵盖了兼容性的基本要求,包括迭代器的重要性、值语义、操作保证、大小和容量查询以及元素访问和操作。理解这些概念确保您的自定义类型能够无缝地与 STL 集成。

此外,我们还探讨了为自定义类型创建迭代器以及操作符重载的过程。最后,我们简要介绍了创建自定义哈希函数,这在你的自定义类型被用于关联容器,如std::unordered_map时是必不可少的。

本章提供的信息为你提供了创建与 STL 兼容的自定义容器所需的基础知识。它使你能够充分利用 C++ STL 在项目中的全部功能,从而实现更高效和可维护的代码。

在下一章中,我们将探索模板函数、重载、内联函数以及创建泛型算法的世界。你将更好地理解如何开发与各种自定义容器类型无缝工作的算法解决方案。我们将深入探讨函数模板、SFINAE、算法重载以及使用谓词和仿函数进行定制。到本章结束时,你将具备构建自己的与 STL 兼容的算法以及进一步提升你的 C++编程技能的充分准备。

第十七章:创建与 STL 兼容的算法

本章讨论了在 C++中创建通用且高效的算法。开发者将学习类型泛型编程,理解函数重载,并学习如何根据特定需求调整现有算法。本章将包括理论、最佳实践和实际技术。到结束时,我们将能够为各种场景开发强大且适应性强的算法。

在本章中,我们将涵盖以下主要主题:

  • 模板函数

  • 重载

  • 创建泛型算法

  • 自定义现有算法

技术要求

本章中的代码可以在 GitHub 上找到:

github.com/PacktPublishing/Data-Structures-and-Algorithms-with-the-CPP-STL

模板函数

C++ 标准模板库STL)的一个显著特点是其对类型泛型编程的承诺。这允许算法被编写以操作多种数据类型,有效地绕过了传统类型特定函数的限制。C++通过使用模板函数实现了这一非凡的成就。让我们来探索这些模板函数。

函数模板入门

类型泛型编程的核心是函数模板,这是一个令人难以置信的工具,它允许开发者编写不指定将操作的确切数据类型的函数。而不是对单一类型做出承诺,模板让你定义一个蓝图,使函数能够适应各种类型。这里有一个简单的例子:想象编写一个交换两个变量值的函数。使用函数模板,这个swap函数可以适用于整数、浮点数、字符串,甚至自定义类型!

可变模板 – 模板中的多重性

可变模板通过允许你编写接受可变数量模板参数的函数,提升了函数模板的能力。这在需要处理不同数量输入的算法中特别有用。当你考虑到需要同时组合、转换或处理多个容器或元素时,它们变得不可或缺。随着你探索 STL,你会看到许多这种灵活性变得至关重要的例子。

SFINAE – 精细调整模板替换

替换失败不是错误SFINAE)听起来像是一个晦涩的概念,但它是在 C++中创建健壮模板函数的基石。这是一个机制,允许编译器根据类型替换是否导致有效结果来丢弃特定的模板重载。本质上,它就像给编译器一套规则,根据提供类型的具体情况来选择模板。

想象你正在编写一个操作 STL 容器的函数模板。使用 SFINAE,你可以指导编译器在容器是序列容器时选择特定的重载版本,而在容器是关联容器时选择另一个版本。这里的魔法在于确保模板替换保持有效。

利用std::enable_if与 SFINAE 结合

std::enable_if实用工具在与 SFINAE 一起工作时是一大福音。它是一个类型特性,可以在模板替换过程中有条件地移除或添加特定的函数重载。将std::enable_if与类型特性结合使用,可以使你精细调整算法以适应特定的 STL 容器特性。

让我们来看一个示例,它展示了函数模板、变长模板和 SFINAE 的概念:

#include <iostream>
#include <map>
#include <type_traits>
#include <vector>
// Function Template
template <typename T> void swap(T &a, T &b) {
  T temp = a;
  a = b;
  b = temp;
}
// Variadic Template
template <typename... Args> void print(Args... args) {
  (std::cout << ... << args) << '\n';
}
// SFINAE with std::enable_if
template <typename T, typename std::enable_if<
                          std::is_integral<T>::value>::type
                          * = nullptr>
void process(T t) {
  std::cout << "Processing integral: " << t << '\n';
}
template <typename T,
          typename std::enable_if<std::is_floating_point<
              T>::value>::type * = nullptr>
void process(T t) {
  std::cout << "Processing floating point: " << t << '\n';
}
// SFINAE for STL containers
template <
    typename T,
    typename std::enable_if<std::is_same<
        T, std::vector<int>>::value>::type * = nullptr>
void processContainer(T &t) {
  std::cout << "Processing vector: ";
  for (const auto &i : t) { std::cout << i << ' '; }
  std::cout << '\n';
}
template <
    typename T,
    typename std::enable_if<std::is_same<
        T, std::map<int, int>>::value>::type * = nullptr>
void processContainer(T &t) {
  std::cout << "Processing map: ";
  for (const auto &[key, value] : t) {
    std::cout << "{" << key << ": " << value << "} ";
  }
  std::cout << '\n';
}
int main() {
  // Function Template
  int a = 5, b = 10;
  swap(a, b);
  std::cout << "Swapped values: " << a << ", " << b
            << '\n';
  // Variadic Template
  print("Hello", " ", "World", "!");
  // SFINAE with std::enable_if
  process(10);
  process(3.14);
  // SFINAE for STL containers
  std::vector<int> vec = {1, 2, 3, 4, 5};
  processContainer(vec);
  std::map<int, int> map = {{1, 2}, {3, 4}, {5, 6}};
  processContainer(map);
  return 0;
}

下面是示例输出:

Swapped values: 10, 5
Hello World!
Processing integral: 10
Processing floating point: 3.14
Processing vector: 1 2 3 4 5
Processing map: {1: 2} {3: 4} {5: 6}

这段代码展示了函数模板、变长模板和 SFINAE 的概念。swap函数是一个简单的函数模板,可以交换任何类型的两个变量。print函数是一个变长模板,可以打印任意数量的参数。process函数通过std::enable_if展示了 SFINAE,根据参数类型选择不同的重载版本。最后,processContainer函数展示了如何使用 SFINAE 来区分不同的 STL 容器。

在深入创建与 STL 兼容的算法时,理解和掌握函数模板将至关重要。它们确保你的算法具有通用性,能够适应各种类型和场景。但不仅仅是灵活性,模板还增强了效率。通过与类型系统紧密合作,你的算法可以针对特定类型进行优化,从而获得性能上的好处。

函数模板、变长模板和 SFINAE 不仅仅是工具;它们是 STL(标准模板库)类型泛型范式的基础。通过利用这些工具,你与 STL 的哲学相一致,并提升了你算法的适应性和能力。

随着我们进一步深入本章,我们将回顾重载技术,理解创建真正泛型算法的微妙之处,并学习如何根据特定需求定制现有算法。每一步都让我们更接近掌握制作卓越的 STL 兼容算法的艺术。

重载

函数重载是 C++编程的基石,它使开发者能够定义具有相同名称但参数不同的多个函数版本。这种能力在创建与 STL 容器交互的算法时尤为重要,因为每个容器都有其独特的特性和要求。通过重载,你可以根据特定容器或情况定制你的算法,确保最佳性能和灵活性。

为 STL 容器制作多个算法版本

在设计与 STL 容器兼容的算法时,可能会出现需要根据其固有属性以不同方式处理特定容器的需求。例如,与 std::vector 交互的算法可能比处理 std::map 时有不同的要求。通过利用函数重载,你可以为每种容器类型设计算法的单独版本,确保每次交互都尽可能高效。

函数解析 – 探索复杂性

函数重载伴随着挑战,理解函数解析至关重要。当存在多个重载函数可能成为调用候选时,编译器遵循一系列严格的规则来确定最佳匹配。它考虑了参数的数量、它们的类型以及它们可能的类型转换。在你为 STL 兼容算法重载函数时,了解这些规则至关重要。它确保了正确版本的函数被调用,并防止了任何意外的行为或歧义。

谨慎重载 – 清晰和一致性

重载函数的能力既是福音也是陷阱。虽然它提供了更大的灵活性,但也引入了在代码库中充斥着过多函数变体的风险,这可能导致混淆。重载时的黄金法则是要保持清晰和一致性。

反问自己,重载版本是否为特定的 STL 容器或场景提供了不同的或优化的方法。如果没有,可能依赖一个能够适应多个场景的通用版本更为谨慎。一个精心设计的函数签名,结合有意义的参数名称,通常可以传达函数的目的,减少过度重载的需要。

此外,确保你的文档精确无误。提及每个重载版本的目的、应使用它的场景以及它与其他版本的区别。这不仅有助于可能使用或维护你的算法的其他开发者,也为你未来的自己提供了一个宝贵的参考。

通过对重载的牢固掌握,你现在已经准备好进一步深入 STL 兼容算法的世界。你在这里获得的技术为创建通用算法和定制现有算法以满足特定需求奠定了基础。前方是一条令人兴奋的旅程,充满了设计出健壮、多功能的算法的机会,这些算法能够无缝地与 STL 容器的广阔领域集成,真正体现了 C++ 编程的精髓。

创建通用算法

在本节中,我们将学习构建超越特定类型边界的算法,这是高级 C++ 编程的一个基本方面。这种方法对于开发健壮和通用的软件至关重要,因为它允许算法在多种数据类型和结构之间无缝运行。本节将指导你了解设计既高效又适应性强、无类型的算法所必需的原则和技术,这与 STL 的哲学完美契合。

能够编写泛型算法的能力是无价的。它确保了你的代码不仅可以在各种应用程序中重用,而且能够处理未来不可预见的需要。这种通用性在 C++ 编程中尤为重要,因为数据类型的复杂性和多样性可能带来重大挑战。通过关注类型无关的方法,并拥抱诸如迭代器、断言和函数对象(functors)等工具,你将学会创建不受特定类型限制的算法。这种知识将使你能够编写更易于维护、可扩展且符合 C++ 编程最佳实践的代码。随着我们深入这些概念,你将获得使你的算法完美适应 STL 的技能,从而提高其效用和性能。

向类型无关的方法迈进

当你创建泛型算法时,一个指导原则是类型无关的方法。C++ 和 STL 的优势在于它们能够构建核心上不关心操作类型(类型无关)的算法。它们关注逻辑,而底层机制处理特定类型的细节,主要是模板和迭代器。

拥抱迭代器——通向泛型的桥梁

在许多方面,迭代器是 STL 算法泛型特性的秘密成分。将迭代器视为连接特定类型容器和无类型算法之间的桥梁。在构建泛型算法时,你通常不会接受容器作为参数。相反,你会接受迭代器,这些迭代器抽象出底层容器及其类型。

例如,与其为 std::vector<int> 设计特定的算法,不如接受迭代器作为参数。这使得你的算法适用于 std::vector<int>,并且可能适用于任何提供所需迭代器类型的容器。

// This function only takes a specific kind of vector
void printElements(const std::vector<int> &vec) {
  std::for_each(vec.begin(), vec.end(),
                [](int x) { std::cout << x << " "; });
  std::cout << "\n";
}
// Template function that operates on iterators, making it
// applicable to any container type
template <typename Iterator>
void printElements(Iterator begin, Iterator end) {
  while (begin != end) {
    std::cout << *begin << " ";
    ++begin;
  }
  std::cout << "\n";
}

这些示例展示了将迭代器作为参数的函数如何比将容器引用作为参数的函数更灵活。

断言(Predicates)——定制算法行为

但如果你希望引入一点定制化呢?如果你想让你的泛型算法具有可配置的行为呢?这就是断言(predicates)的用武之地。

谓词是布尔值一元或二元函数(或函数对象)。当传递给算法时,它们可以影响其行为。例如,在排序一个集合时,你可以提供一个谓词来确定元素的排序顺序。通过利用谓词,你的算法可以保持通用性,同时仍然可以根据特定场景进行调整,而不需要硬编码任何行为。

函数对象的魔法——增强灵活性

当谓词允许自定义时,函数对象(或函数对象)将这一概念提升到了另一个层次。函数对象是一个可以像函数一样调用的对象。这里的基本优势是状态性。与简单的函数指针或 lambda 函数不同,函数对象可以维护状态,提供更大的灵活性。

想象设计一个通用的算法,该算法将转换应用于 STL 容器中的每个元素。通过接受一个函数对象作为参数,你的算法的用户不仅可以指定转换逻辑,还可以携带一些状态,从而提供强大且适应性强的解决方案。

在你的工具箱中有迭代器、谓词和函数对象,你将准备好构建通用算法,这些算法既灵活又类型无关。始终关注逻辑,将类型具体化抽象化,并为用户提供途径(如谓词和函数对象)以注入自定义行为。

随着你继续前进,请记住泛型编程的本质是适应性。算法应该构建以适应广泛的场景和类型。接下来的部分将指导你适应和扩展已经非常健壮的 STL 算法集,增强你的 C++代码库的功能。

自定义现有算法

STL 提供了适应和增强其已经非常健壮的算法集的方法。这项技能对于任何熟练的 C++程序员来说至关重要,因为它允许对算法进行微调以满足特定需求,而不需要从头开始。在本节中,你将学习如何使用设计模式,例如装饰器模式,以及 lambda 函数来修改现有算法,使它们更适合你的独特需求。

在实际的编程场景中,你经常会遇到现有 STL 算法几乎满足你的需求但需要一些调整的情况。知道如何自定义这些算法,而不是从头创建新的算法,可以节省大量的时间和精力。本节将教你如何利用现有解决方案并创造性地对其进行调整,确保效率和可维护性。你将发现如何集成设计模式以添加新行为或修改现有行为,以及如何使用 lambda 函数进行简洁而有效的自定义。

观察装饰器模式在实际中的应用

面对几乎符合要求但又不完全符合的 STL 算法时,抵制重写轮子的冲动至关重要。相反,通过使用经过验证的设计模式来调整这些算法,通常可以导致更优雅、高效和可维护的解决方案。

在这个上下文中,最强大的设计模式之一是装饰器模式。它允许你在不改变其结构的情况下,对现有算法添加或修改行为。考虑这样一个场景,你有一个排序算法,并想添加日志记录功能。你不需要重写或重载函数,而是使用装饰器模式创建一个新的算法,该算法调用原始排序函数并在其上方添加日志记录。这里的美丽之处在于关注点分离和能够链式添加多个额外行为的能力。

让我们看看装饰器模式在实际中的应用。我们将使用它来为一个 STL 比较函数添加日志记录:

#include <algorithm>
#include <functional>
#include <iostream>
#include <vector>
// Decorator for adding logging to the compare function
template <typename Compare> class LoggingCompareDecorator {
public:
  LoggingCompareDecorator(Compare comp) : comp(comp) {}
  template <typename T>
  bool operator()(const T &lhs, const T &rhs) {
    bool result = comp(lhs, rhs);
    std::cout << "Comparing " << lhs << " and " << rhs
              << ": "
              << (result ? "lhs < rhs" : "lhs >= rhs")
              << "\n";
    return result;
  }
private:
  Compare comp;
};
int main() {
  std::vector<int> numbers = {4, 2, 5, 1, 3};
  // Original comparison function
  auto comp = std::less<int>();
  // Decorating the comparison function with logging
  LoggingCompareDecorator<decltype(comp)> decoratedComp(
      comp);
  // Using the decorated comparison in sort algorithm
  std::sort(numbers.begin(), numbers.end(), decoratedComp);
  // Output the sorted numbers
  std::cout << "Sorted numbers: ";
  for (int num : numbers) { std::cout << num << " "; }
  std::cout << "\n";
  return 0;
}

这里是示例输出:

Comparing 2 and 4: lhs < rhs
Comparing 4 and 2: lhs >= rhs
Comparing 5 and 2: lhs >= rhs
Comparing 5 and 4: lhs >= rhs
Comparing 1 and 2: lhs < rhs
Comparing 2 and 1: lhs >= rhs
Comparing 3 and 1: lhs >= rhs
Comparing 3 and 5: lhs < rhs
Comparing 5 and 3: lhs >= rhs
Comparing 3 and 4: lhs < rhs
Comparing 4 and 3: lhs >= rhs
Comparing 3 and 2: lhs >= rhs
Sorted numbers: 1 2 3 4 5

在这个例子中,LoggingCompareDecorator 是一个模板类,它接受一个比较函数对象(comp)并在其周围添加日志记录。operator() 被覆盖以在调用原始比较函数之前添加日志。使用装饰后的比较函数(std::less)与原始排序算法(std::sort)一起使用,从而在不改变排序算法本身的情况下为每个比较操作添加日志记录。这通过允许以干净、可维护的方式将额外行为(日志记录)添加到现有函数中(std::less),展示了装饰器模式,并遵循了关注点分离原则。

利用 Lambda 函数的力量

Lambda 函数是 C++ 工具箱中的神奇工具。它们使开发者能够就地定义匿名函数,使代码更加简洁,在许多情况下,也更容易阅读。当定制现有的 STL 算法时,Lambda 函数可以成为游戏规则的改变者。

假设你正在使用 std::transform 算法,该算法将函数应用于容器中的每个元素。std::transform 的美妙之处在于它接受任何可调用对象的能力,包括 Lambda。因此,你不需要定义全新的函数或函数对象,可以直接传递一个 Lambda 函数来调整其行为以满足你的需求。

让我们举一个例子。假设你想要将向量中的每个元素平方。你不需要创建一个名为 square 的单独函数,你可以传递一个 Lambda,如下面的代码所示:

std::transform(vec.begin(), vec.end(), vec.begin(),
               [](int x) { return x * x; });

Lambda 函数还可以捕获其周围作用域中的变量,赋予你使用外部数据在自定义逻辑中的能力。例如,如果你想将向量中的每个元素乘以一个动态因子,你可以在 Lambda 中捕获该因子并在其中使用它:

void vectorTransform(std::vector<int> &vec, int factor) {
  std::transform(vec.begin(), vec.end(), vec.begin(),
                 factor { return x * factor; });
}

C++ 中的 lambda 函数提供了一种简洁且灵活的方式来定义匿名、内联函数,极大地简化了代码,特别是对于短时使用的函数。它们增强了可读性和可维护性,并且当与 STL 算法结合使用时,它们允许在不需要冗长的函数或函数对象定义的情况下实现简洁且强大的自定义行为。

将模式与 lambda 结合以实现终极定制

当你将设计模式的强大功能与 lambda 函数的灵活性结合起来时,你得到一个工具集,它允许对现有算法进行深刻的定制。例如,你可以使用 策略模式 定义一组算法,然后使用 lambda 函数来微调每个策略的行为。这种协同作用可以导致高度模块化和可适应的代码,最大化代码重用并最小化冗余。

让我们来看一个使用策略模式结合 lambda 表达式的示例:

#include <algorithm>
#include <iostream>
#include <vector>
// Define a Strategy interface
class Strategy {
public:
  virtual void
  execute(const std::vector<int> &data) const = 0;
};
// Define a Concrete Strategy that uses std::for_each and a
// lambda function
class ForEachStrategy : public Strategy {
public:
  void
  execute(const std::vector<int> &data) const override {
    std::for_each(data.begin(), data.end(), [](int value) {
      std::cout << "ForEachStrategy: " << value << "\n";
    });
  }
};
// Define a Concrete Strategy that uses std::transform and
// a lambda function
class TransformStrategy : public Strategy {
public:
  void
  execute(const std::vector<int> &data) const override {
    std::vector<int> transformedData(data.size());
    std::transform(data.begin(), data.end(),
                   transformedData.begin(),
                   [](int value) { return value * 2; });
    for (const auto &value : transformedData) {
      std::cout << "TransformStrategy: " << value << "\n";
    }
  }
};
// Define a Context that uses a Strategy
class Context {
public:
  Context(Strategy *strategy) : strategy(strategy) {}
  void setStrategy(Strategy *newStrategy) {
    strategy = newStrategy;
  }
  void executeStrategy(const std::vector<int> &data) {
    strategy->execute(data);
  }
private:
  Strategy *strategy;
};
int main() {
  std::vector<int> data = {1, 2, 3, 4, 5};
  ForEachStrategy forEachStrategy;
  TransformStrategy transformStrategy;
  Context context(&forEachStrategy);
  context.executeStrategy(data);
  context.setStrategy(&transformStrategy);
  context.executeStrategy(data);
  return 0;
}

这里是示例输出:

ForEachStrategy: 1
ForEachStrategy: 2
ForEachStrategy: 3
ForEachStrategy: 4
ForEachStrategy: 5
TransformStrategy: 2
TransformStrategy: 4
TransformStrategy: 6
TransformStrategy: 8
TransformStrategy: 10

在这个示例中,Strategy 是一个抽象基类,它定义了一组算法。ForEachStrategyTransformStrategy 是具体策略,分别使用 std::for_eachstd::transform 实现这些算法。这两个算法都使用 lambda 函数来定义其行为。Context 类使用 Strategy 来执行算法,并且 Strategy 可以在运行时更改。这展示了将设计模式与 lambda 函数结合以创建高度模块化和可适应代码的强大功能。

定制现有算法是一种艺术和科学。它需要深入理解现有的 STL 工具,一点创造力,以及保持清晰和效率的纪律。随着你继续前进,始终优先考虑理解问题和选择正确的工具。深思熟虑地进行定制,STL 将以优雅的解决方案回报你,即使是解决最复杂的问题。

摘要

在我们结束本章关于创建 STL 兼容算法的讨论时,我们学习了在 C++ 中构建灵活和高效算法的基本技术和概念。从泛型编程的基础开始,你学习了使用函数模板、变长模板以及微妙而强大的 SFINAE 原则的艺术。这些工具使你能够编写适应多种数据类型的算法,这是 STL 灵活性和强大功能的一个标志。

本章还指导你了解了函数重载的复杂性,这是针对不同 STL 容器和场景定制算法的关键技能。你学习了如何导航函数解析的复杂性,以及在使用函数重载时保持清晰和一致性的重要性。这种知识确保了你的算法不仅灵活,而且在与各种 STL 组件交互时直观且高效。

展望未来,下一章将揭示类型特性和策略的世界,探讨这些工具如何增强代码的适应性并赋予元编程能力。你将了解使用策略与 STL 相关的好处,如何构建模块化组件,以及你可能遇到的潜在挑战。这一章不仅将加深你对高级 C++ 特性的理解,还将为你提供在代码中实现类型特性和策略的实用技能,确保你的编程具有兼容性和灵活性。

第十八章:类型特性和策略

本章涵盖了 C++ 中的编译时类型信息(类型特性)以及基于策略的模块化设计。它将展示如何通过使用 C++ 标准模板库STL)的数据类型和算法来增强元编程能力,并促进灵活的代码设计。它还讨论了策略,提出了一种在不改变核心逻辑的情况下定制模板代码行为的方法。通过实际案例、动手实现技术和最佳实践,您将利用这些强大的 C++ 工具与 STL 结合,创建可适应和优化的软件组件。

本章将涵盖以下内容:

  • 理解和使用类型特性

  • 利用类型特性与 STL

  • 理解和使用 C++ 中的策略

  • 使用策略与 STL

技术要求

本章中的代码可以在 GitHub 上找到:

github.com/PacktPublishing/Data-Structures-and-Algorithms-with-the-CPP-STL

理解和使用类型特性

在 C++ 中编写泛型代码时,通常需要在不知道类型具体信息的情况下收集有关类型的信息。这时就出现了 类型特性——一个用于在编译时查询和操作类型信息的工具集。把它们想象成报告类型特性的检查员,允许您根据这些报告在代码中做出明智的决策。

C++ 的 STL 在 <type_traits> 头文件中提供了一组丰富的类型特性。这些特性可以回答诸如:特定类型是否为指针?是否为整数?是否为算术类型?是否可以默认构造?例如,std::is_integral<T>::value 如果 T 是整型类型则返回 true,否则返回 false

使用类型特性提高代码适应性

类型特性不仅仅是内省的手段;它们是适应性的推动者。通过理解类型的属性,您可以设计出能够相应调整其行为的算法和数据结构。

考虑一个必须针对指针和非指针类型执行不同操作的泛型函数。借助 std::is_pointer<T>::value,您可以使用 if constexpr 语句有条件地执行代码路径,在编译时定制行为。这会创建更清晰、更直观的代码,并导致最佳性能,因为编译过程中会剪枝掉不必要的代码路径。

另一个日常用例是优化泛型容器的存储。例如,如果一个类型是平凡可析构的(没有自定义析构逻辑),您可以安全地跳过调用其析构函数,从而提高性能。在这里,std::is_trivially_destructible<T>::value 就能派上用场。

使用类型特性增强元编程

元编程,即编写生成或操作其他代码的代码,是高级 C++编程的一个标志。类型特性是这个领域中的无价工具,它使编译时的计算更加丰富和表达。

编译时阶乘计算是一个经典的元编程问题。虽然这可以通过模板递归实现,但真正的挑战在于如何为非整型类型停止递归。这正是std::is_integral<T>::value证明其价值的地方,确保计算只对有效类型进行。

另一个强大的方面是使用类型特性与static_assert来强制约束。如果你正在编写一个只应接受算术类型的模板函数,一个简单的使用std::is_arithmetic<T>::value的静态断言可以确保代码不会为不合适的类型编译,为开发者提供清晰且及时的反馈。

向更信息化和可适应的代码迈进

当你精通类型特性时,请记住这些工具不仅仅是关于查询类型属性。它们利用这些知识来构建更健壮、更可适应和更高效的代码。无论你追求的是极致性能、更干净的接口,还是元编程掌握的满足感,类型特性都准备帮助你。

在接下来的章节中,我们将进一步探讨类型特性如何与策略协同工作,更重要的是,我们将探讨如何创建自己的类型特性和策略,以适应你项目的独特需求。

利用 STL 中的类型特性

利用 STL 数据类型和算法中的类型特性是一种强大的技术,它增强了 C++编程的效率和正确性。当应用于 STL 数据类型时,类型特性使开发者能够更深入地理解这些类型的特征,例如它们的大小、对齐或是否是基本类型。这种洞察力可以显著优化数据存储和访问模式,从而实现更好的内存管理和性能。

在 STL 算法的上下文中,类型特性在根据涉及类型的属性选择最合适的算法或优化其行为方面起着关键作用。例如,知道一个类型是否支持某些操作可以使算法跳过不必要的检查或使用更有效的方法。这提高了性能并确保了具有各种类型的算法按预期行为。

在 STL 数据类型和算法中应用类型特性对于高级 C++编程至关重要,它使开发者能够编写更高效、健壮和可适应的代码。让我们开始探索 STL 数据类型和算法中类型特性的全部潜力。

与数据类型一起工作

理解和利用类型特性对于编写健壮和可适应的代码非常重要。类型特性,作为 STL 的一部分,允许程序员在编译时查询和交互类型,促进类型安全和效率。

类型特性提供了类型的编译时内省,使程序员能够编写通用和类型安全的代码。它们在模板元编程中特别有用,其中操作依赖于类型属性。通过利用类型特性,开发者可以确定类型属性,例如类型是否为整数、浮点数,或者它是否支持某些操作。我们还可以根据类型特征定制代码行为,而无需承担运行时成本,或者使用它们来编写更简单、更易于维护的代码,这些代码可以自动适应不同的类型。

考虑一个场景,我们需要一个函数模板来处理数值数据,但对于整数和浮点数类型,处理方式不同。使用类型特性,我们可以为每种类型创建特定的行为:

#include <iostream>
#include <type_traits>
template <typename T> void processNumericalData(T data) {
  if constexpr (std::is_integral_v<T>) {
    std::cout << "Processing integer: " << data << "\n";
  } else if constexpr (std::is_floating_point_v<T>) {
    std::cout << "Processing float: " << data << "\n";
  } else {
    static_assert(false, "Unsupported type.");
  }
}
int main() {
  processNumericalData(10);
  processNumericalData(10.5f);
  // Error: static_assert failed: 'Unsupported type.':
  // processNumericalData(10.5);
}

这里是示例输出:

Processing integer: 10
Processing float: 10.5

在此示例中,std::is_integral_vstd::is_floating_point_v 是评估 T 是否为整数或浮点类型的类型特性。if constexpr 构造允许编译时决策,确保仅编译与类型 T 相关的相关代码块。这种方法使代码类型安全,并通过避免运行时不必要的检查来优化性能。

利用类型特性与 STL 数据类型结合可以增强代码的可靠性、效率和可维护性。接下来,让我们探索类型特性的更多高级用法,例如它们如何与其他模板技术结合来构建复杂、类型感知的算法和数据结构。

与算法一起工作

除了在构建可适应的代码和启用元编程中不可或缺的作用外,类型特性在与 STL 算法结合时也发挥着至关重要的作用。类型特性和算法之间的这种协同作用使我们能够编写高度灵活和类型感知的代码。

算法定制的类型特性

STL 算法通常在泛型数据结构上操作,从排序到搜索。根据它们处理元素的属性来定制这些算法的行为对于编写高效和灵活的代码至关重要。

std::sort 算法为例,它可以对一个容器中的元素进行排序。通过使用类型特性,我们可以使其更加灵活。例如,你可能希望对于支持降序排序的类型(例如整数)进行降序排序,而对于其他类型则保持顺序不变。使用 std::is_integral<T>::value,你可以有条件地向 std::sort 传递一个自定义的比较函数,根据排序的类型定制排序行为,以下代码示例说明了这一点:

template <typename T>
void customSort(std::vector<T> &data) {
  if constexpr (std::is_integral<T>::value) {
    std::sort(data.begin(), data.end(), std::greater<T>());
  } else {
    std::sort(data.begin(), data.end());
  }
}

这种方法展示了类型特性如何通过在运行时消除不必要的条件来提高代码的效率。

确保算法兼容性

考虑一个处理对象集合的算法,以展示类型特性在用户定义类型中的强大功能。此算法要求对象提供特定的接口,例如,一个将对象状态转换为字符串的 serialize 方法。通过使用类型特性,我们可以确保算法仅在编译时使用符合此要求的类型:

#include <iostream>
#include <string>
#include <type_traits>
#include <vector>
// Define a type trait to check for serialize method
template <typename, typename T>
struct has_serialize : std::false_type {};
template <typename T>
struct has_serialize<
    std::void_t<decltype(std::declval<T>().serialize())>,
    T> : std::true_type {};
template <typename T>
inline constexpr bool has_serialize_v =
    has_serialize<void, T>::value;
class Person {
public:
  std::string name;
  int age{0};
  std::string serialize() const {
    return "Person{name: " + name +
           ", age: " + std::to_string(age) + "}";
  }
};
class Dog {
public:
  std::string name;
  std::string breed;
  // Note: Dog does not have a serialize method
};
template <typename T>
void processCollection(const std::vector<T> &collection) {
  static_assert(has_serialize_v<T>,
                "T must have a serialize() method.");
  for (const auto &item : collection) {
    std::cout << item.serialize() << std::endl;
  }
}
int main() {
  // Valid use, Person has a serialize method
  std::vector<Person> people = {{"Alice", 30},
                                {"Bob", 35}};
  processCollection(people);
  // Compile-time error:
  // std::vector<Dog> dogs = {{"Buddy", "Beagle"}};
  // processCollection(dogs);
}

这里是示例输出:

Person{name: Alice, age: 30}
Person{name: Bob, age: 35}

在此示例中,has_serialize 是一个自定义的类型特性,用于检查是否存在 serialize 方法。processCollection 函数模板使用此特性来强制只使用提供此方法的类型。如果使用了不兼容的类型,static_assert 会生成一个清晰的编译时错误信息。

开发者可以通过使用类型特性强制算法与自定义类型兼容,从而创建出更健壮且具有自文档特性的代码。这种方法确保了约束在编译时被明确定义和检查,防止了运行时错误,并导致了更可预测和可靠的软件。

为特定类型优化算法

效率是算法设计中的一个关键考虑因素。类型特性可以通过根据类型属性选择最有效的实现来帮助优化针对特定类型的算法。

例如,考虑一个计算容器中元素总和的算法。如果元素类型是整型,你可以使用基于整数的更有效的累加器,而对于浮点类型,你可能更喜欢浮点累加器。像 std::is_integral<T>::value 这样的类型特性可以指导你选择累加器类型,从而实现更高效的计算。

将类型特性与 STL 算法结合使用,可以使你创建出类型感知且高效的代码。通过定制算法行为、确保兼容性以及针对特定类型进行优化,你可以在构建健壮且高性能的 C++ 应用程序的同时充分利用 STL。

理解和使用 C++ 中的策略

基于策略的设计是 C++ 中的一种设计范式,它强调模块化和灵活性,同时不牺牲性能。它围绕将软件组件的行为分解为可互换的策略展开。这些策略决定了特定动作的执行方式。通过选择不同的策略,可以修改组件的行为,而无需更改其基本逻辑。

与 STL 相关的优势

在 STL 的上下文中,基于策略的设计尤其相关。STL 本质上是通用的,旨在满足广泛的编程需求。实现策略可以显著增强其通用性,允许针对特定用例进行精确定制。例如,容器内存分配策略可以定义为一种策略。无论是使用标准分配器、池分配器还是自定义基于栈的分配器,只需简单地插入所需的策略,容器就会进行调整,而无需修改其基本逻辑。

此外,策略可以根据特定上下文进行性能定制。排序算法可以根据数据类型使用不同的比较策略。而不是制定多个算法迭代,可以设计一个版本,并根据需要替换比较策略。

这里有一个展示这个概念的 C++代码示例:

#include <algorithm>
#include <iostream>
#include <vector>
// Define a generic comparison policy for numeric types
template <typename T> struct NumericComparison {
  bool operator()(const T &a, const T &b) const {
    return (a < b);
  }
};
// Define a specific comparison policy for strings
struct StringComparison {
  bool operator()(const std::string &a,
                  const std::string &b) const {
    return (a.length() < b.length());
  }
};
// Generic sort function using a policy
template <typename Iterator, typename ComparePolicy>
void sortWithPolicy(Iterator begin, Iterator end,
                    ComparePolicy comp) {
  std::sort(begin, end, comp);
}
int main() {
  // Example with numeric data
  std::vector<int> numbers = {3, 1, 4, 1, 5, 9,
                              2, 6, 5, 3, 5};
  sortWithPolicy(numbers.begin(), numbers.end(),
                 NumericComparison<int>());
  for (auto n : numbers) { std::cout << n << " "; }
  std::cout << "\n";
  // Example with string data
  std::vector<std::string> strings = {
      "starfruit", "pear", "banana", "kumquat", "grape"};
  sortWithPolicy(strings.begin(), strings.end(),
                 StringComparison());
  for (auto &s : strings) { std::cout << s << " "; }
  std::cout << "\n";
  return 0;
}

这里是示例输出:

1 1 2 3 3 4 5 5 5 6 9
pear grape banana kumquat starfruit

在这个例子中,我们有两个比较策略:NumericComparison用于数值类型和StringComparison用于字符串。sortWithPolicy函数是一个模板,它接受一个比较策略作为参数,允许使用相同的排序函数与不同的数据类型和比较策略一起使用。数值数据按升序排序,而字符串则根据其长度排序,展示了使用策略定制排序行为的灵活性。

使用策略构建模块化组件

考虑设计一个模板化的数据结构,例如哈希表。策略可以指定哈希表的多项元素:哈希技术、冲突解决方法或内存分配方法。通过将这些作为单独的可切换策略进行分离,哈希表可以根据特定要求进行微调,而无需改变其核心功能。

这种模块化也鼓励代码的可重用性。一个精心设计的策略可以应用于各种组件,确保代码的一致性和易于维护。

潜在的挑战

虽然基于策略的设计提供了许多优势,但也带来了特定的挑战。其中主要关注的是确保策略与主要组件逻辑的兼容性。尽管一个组件可能被设计成可以容纳多种策略,但每种策略都必须符合预定的接口或标准。

文档也成为一个挑战。鉴于策略提供的灵活性增加,详细记录预期的行为、接口以及每个策略的影响变得至关重要,使用户能够做出明智的选择。

策略在现代 C++中的作用

随着 C++ 的发展,向更通用和适应性强组件的转变变得明显。基于策略的设计在这一演变中至关重要,它使开发者能够设计优先考虑模块化和性能的组件。掌握这种设计方法将使你能够生产出不仅能够持久存在,而且能够高效适应不断变化需求的软件。

在接下来的章节中,我们将检查实现类型特性和策略的实际方面,为它们在你的项目中的实际应用打下坚实的基础。

使用策略与 STL

在探索基于策略的设计时,我们已经建立了这种设计范式如何促进 C++ 软件组件的模块化和灵活性。现在,让我们具体探讨如何有效地使用策略来增强 STL 数据类型的功能性和适应性,从而为更高效和定制的解决方案做出贡献。

内存分配策略

在 STL 数据类型的背景下,策略的最相关应用之一是内存分配的管理。考虑这样一个场景,你必须优化特定容器的内存分配,例如一个 std::vector 实例。通过引入内存分配策略,你可以根据需求定制容器的内存管理策略。

例如,你可能有一个针对你的应用程序特定用例优化的专用内存分配器。而不是修改容器的内部逻辑,你可以无缝地将这个自定义分配器作为策略集成。这样,std::vector 实例可以高效地使用你的自定义分配器,而不需要基本的代码更改,如下所示:

template <typename T,
          typename AllocatorPolicy = std::allocator<T>>
class CustomVector {
  // Implementation using AllocatorPolicy for memory
  // allocation
};

此模板类接受一个类型 T 和一个分配器策略,默认为 std::allocator<T>。关键点在于这种设计允许在不改变容器的基本代码结构的情况下,无缝集成自定义内存分配策略。

通用算法的排序策略

STL 算法,包括排序算法,通常与各种数据类型一起工作。当需要不同的比较策略进行排序时,策略提供了一个优雅的解决方案。而不是创建多个排序算法版本,你可以设计一个单一的算法,并在需要时引入比较策略。

让我们以排序算法为例。使用比较策略,你可以根据数据类型的不同对元素进行不同的排序。这种方法简化了你的代码库,避免了代码重复:

template <typename T,
          typename ComparisonPolicy = std::less<T>>
void customSort(std::vector<T> &data) {
  // Sorting implementation using ComparisonPolicy for
  // comparisons
}

此示例展示了模板化的 customSort 函数,展示了如何覆盖默认的比较策略以定制不同数据类型的排序行为。这种方法展示了在 STL 框架内创建通用、可维护和高效排序算法的强大策略,展示了基于策略设计的 C++ 编程的优势。

使用策略微调数据结构

当设计模仿 STL 容器的自定义数据结构时,你可以利用策略来微调其行为。想象一下构建一个哈希表。策略可以控制关键方面,如哈希技术、冲突解决方法或内存分配方法。

通过将这些功能作为独立的、可互换的策略进行隔离,你可以创建一个可以适应特定用例而不改变其核心逻辑的哈希表。这种模块化方法简化了维护工作,因为你可以根据需要调整单个策略,同时保持其余结构完整。

让我们来看一个例子,说明如何通过基于策略的设计来定制自定义哈希表,以增强与 STL 类型及算法的交互。这种方法允许通过策略定义哈希表的行为(例如哈希机制、冲突解决策略或内存管理),使数据结构灵活且适应不同的用例:

#include <functional>
#include <list>
#include <string>
#include <type_traits>
#include <vector>
// Hashing Policy
template <typename Key> struct DefaultHashPolicy {
  std::size_t operator()(const Key &key) const {
    return std::hash<Key>()(key);
  }
};
// Collision Resolution Policy
template <typename Key, typename Value>
struct SeparateChainingPolicy {
  using BucketType = std::list<std::pair<Key, Value>>;
};
// Custom Hash Table
template <typename Key, typename Value,
          typename HashPolicy = DefaultHashPolicy<Key>,
          typename CollisionPolicy =
              SeparateChainingPolicy<Key, Value>>
class CustomHashTable {
private:
  std::vector<typename CollisionPolicy::BucketType> table;
  HashPolicy hashPolicy;
  // ...
public:
  CustomHashTable(size_t size) : table(size) {}
  // ... Implement methods like insert, find, erase
};
int main() {
  // Instantiate custom hash table with default policies
  CustomHashTable<int, std::string> hashTable(10);
  // ... Usage of hashTable
}

在这个例子中,DefaultHashPolicySeparateChainingPolicy 是哈希和冲突解决的默认策略。CustomHashTable 模板类可以根据需要实例化不同的策略,使其非常灵活且与各种 STL 类型及算法兼容。这种基于策略的设计允许对哈希表的行为和特性进行精细控制。

C++ 中的策略提供了一套强大的工具集,可以增强 STL 数据类型的适应性和性能。无论是优化内存分配、定制排序策略还是定制满足特定需求的数据结构,策略使我们能够模块化扩展 STL 组件的功能,同时保持代码的一致性和可重用性。

摘要

在本章中,我们探讨了 C++ STL 上下文中的类型特性和策略的复杂性。我们首先检查了类型特性,它作为编译时类型检查的工具包,使我们能够根据类型特征在代码中做出决策。通过探索 <type_traits> 头文件中提供的各种类型特性,我们学习了如何确定一个类型是否是指针、整数、算术类型、默认可构造的等等。

接下来,我们研究了类型特性如何增强代码的适应性,使我们能够定制算法和数据结构的行为。我们亲身体验了诸如 std::is_pointerstd::is_trivially_destructible 这样的特性如何通过通知我们的代码根据类型属性采取不同的行为来优化性能。

然后,我们转向策略,探讨了它们在实现设计模块化和灵活性方面的作用,同时不牺牲性能。我们认识到基于策略的设计在 STL 应用中的好处,例如定制内存分配和排序策略。基于策略组件的模块化被强调为微调行为和鼓励代码重用的一种手段。

本章的实用性在于其潜力可以提升我们的编码实践。我们可以利用类型特性编写更健壮、更适应性强和更高效的代码。同时,策略使我们能够构建灵活、模块化的组件,以满足各种需求,而无需进行根本性的改变。

在下一章,第十九章异常安全性中,我们将通过学习 STL 关于异常提供的保证来扩展在这里获得的知识。我们将从理解异常安全性的基础知识开始,重点关注程序不变性和资源完整性在健壮软件设计中的关键作用。我们将探讨强异常安全性,研究如何构建提供坚定不移保证的 STL 容器。最后,我们将讨论 noexcept 对 STL 操作的影响,进一步为我们编写可靠且高效的 C++ 代码做好准备,使其在面对异常时能够坚韧不拔。

第五部分:STL 数据结构和算法:内部机制

我们通过探讨 STL 数据结构和算法的一些更高级的使用模式来结束对 STL 数据结构和算法的探索。我们将深入到其机制和保证中,这些机制和保证使得健壮、并发的 C++ 应用程序成为可能。我们将从发现异常安全性开始,详细说明 STL 组件提供的保证级别,以及编写具有重点的异常安全代码的策略,强调 noexcept 的影响。

然后,我们将探讨线程安全和并发领域,剖析并发执行与 STL 容器和算法的线程安全之间的微妙平衡。我们将获得关于竞争条件、谨慎使用互斥锁和锁以及 STL 容器的线程安全应用的实际见解,突出具体关注点和多线程环境中它们行为的详细洞察。

接下来,我们将介绍 STL 与现代 C++ 功能(如概念和协程)的交互,展示这些功能如何精炼模板的使用,并使 STL 能够进行异步编程。

最后,我们将深入探讨并行算法,讨论执行策略的整合、constexpr 的影响,以及在 STL 中使用并行性时的性能考虑。本书的这一部分为读者提供了利用 STL 在并发和并行环境中的全部潜力的高级知识,确保他们的代码高效、安全且现代。

本部分包含以下章节:

  • 第十九章**:异常安全性

  • 第二十章**:使用 STL 的线程安全和并发

  • 第二十一章**:STL 与概念和协程的交互

  • 第二十二章**:使用 STL 的并行算法

第十九章:异常安全性

本章将引导你了解异常安全性的复杂性。它揭示了异常安全性的级别,区分了基本和强保证,强调了它们的重要性,并提供了实现它们的经过验证的策略。掌握这些高级主题使你能够创建更健壮、高效和适应性强的高性能 C++应用程序和数据结构。

在本章中,我们将涵盖以下主题:

  • 基本的异常安全性

  • 强异常安全性

  • noexcept 对 STL 容器的影响

技术要求

本章中的代码可以在 GitHub 上找到:

github.com/PacktPublishing/Data-Structures-and-Algorithms-with-the-CPP-STL

基本的异常安全性

基本的异常安全性,俗称为保证,承诺当发生异常且其不变性得到保留时,你的程序不会泄露资源。简单来说,软件不会陷入混乱。当发生未预见的异常时,操作可能会失败,但你的应用程序将继续运行,并且没有数据被损坏。

以下两个现实世界的例子展示了可以有效地管理未预见的异常,而不会导致资源泄露或数据损坏:

  • 数据处理过程中的文件操作失败:考虑一个处理大型数据文件的应用程序。在这个过程中,应用程序可能会遇到意外的异常,例如由于磁盘 I/O 错误而无法读取文件的一部分。在这种情况下,基本的异常安全性确保应用程序不会泄露资源(如文件句柄或为数据处理分配的内存)。它维护任何涉及的数据结构的完整性。应用程序可能无法完成预期的文件处理。然而,它将优雅地处理异常,释放任何资源,并使应用程序处于稳定状态以继续运行。

  • 客户端-服务器应用程序中的网络通信中断:在一个客户端-服务器应用程序中,如果在关键数据交换过程中网络连接突然丢失,可能会发生意外的异常。在这种情况下,基本的异常安全性确保应用程序不会最终处于部分或损坏的数据状态。系统可能无法完成当前操作(如更新记录或检索数据),但它将有效地管理资源,如网络套接字和内存缓冲区。应用程序将捕获异常,清理资源,并确保其核心功能保持完整并准备好进行后续操作。

程序不变量在 STL 中的关键作用

想象你正在构建一个复杂的应用程序,其核心是 C++的std::vectorstd::map或其他,它们在特定的不变性下运行。例如,std::vector容器保证连续的内存。如果任何操作破坏了这些不变性,结果可能从性能损失到隐秘的 bug。

为了确保 STL 的基本异常安全性,你需要确保对这些容器的操作要么成功,要么在抛出异常时,不违反其不变性,使容器保持其原始状态。例如,如果std::vector上的push_back操作抛出异常,向量应该保持不变。

让我们看看如何使用基本异常安全性将数据推入std::vector的例子:

// Adds an element to the vector, ensuring basic exception
// safety
void safePushBack(std::vector<int> &vec, int value) {
  try {
    // Attempt to add value to the vector
    vec.push_back(value);
  } catch (const std::exception &e) {
    // Handle any exception thrown by push_back
    std::cerr << "Exception caught: " << e.what() << "\n";
    // No additional action needed, vec is already in its
    // original state
  }
}

在这个例子中,如果发生异常(例如,由于系统内存不足导致的bad_alloc),catch块将处理它。重要的是,如果push_back抛出异常,它保证向量的状态(vec)保持不变,从而保持容器的不变性。

资源完整性——稳健软件的守护者

如果在内存分配或其他资源密集型任务期间抛出异常,如果没有正确管理,可能会造成灾难。然而,STL 提供了工具,当适当使用时,确保资源保持完整,即使异常即将发生。

STL 容器,如std::vectorstd::string,处理它们的内存。如果在操作期间出现异常,容器确保不会发生内存泄漏。此外,资源获取即初始化RAII),C++设计的标志,确保资源在对象创建时获取,并在它们超出作用域时释放。RAII 原则是防止资源泄漏的哨兵,尤其是在异常期间。

注意

RAII 是 C++中用于管理资源分配和释放的编程习惯。在 RAII 中,资源(如内存、文件句柄和网络连接)由对象获取和释放。当对象创建(初始化)时,它获取资源,当对象销毁(其生命周期结束)时,它释放资源。这确保了自动和异常安全的资源管理,防止资源泄漏,即使在面对异常的情况下也能确保资源干净释放。RAII 是 C++中有效资源管理的基本概念。

利用 STL 实现基本异常安全性

在拥有 STL 及其复杂性的知识后,实现基本异常安全性变得不那么令人畏惧。考虑以下最佳实践:

  • 利用复制和交换习惯用法:在修改 STL 容器时,确保异常安全的一种常见技术是创建容器的副本,在副本上执行操作,然后与原始内容交换。如果出现异常,原始容器不受影响。

  • std::shared_ptrstd::unique_ptr不仅管理内存,而且在异常期间保证没有泄漏。

  • 受保护的操作:在 STL 容器上进行任何不可逆操作之前,始终确保任何可能抛出异常的操作已经执行。

  • 通过 STL 文档保持信息更新:熟悉 STL 函数和方法的异常保证。了解特定 STL 函数可能抛出的异常有助于构建健壮的软件。

使用 STL 拥抱基本的异常安全性为构建更具有弹性、可靠和健壮的软件奠定了基础。有了这种基础理解,你将能够应对 STL 的复杂性,确保即使遇到意外情况,你的软件也能坚定不移。但这只是开始,因为下一个层次,强大的异常安全性,在召唤,提供更多稳健的保证和策略,以优雅地运用 STL。

强大的异常安全性

当你进一步沉浸在 C++和 STL 错综复杂的领域中时,你会遇到术语强大的异常安全性。这不仅是一句华丽的辞藻,而且是 STL 异常处理的黄金标准。它向开发者提供了一种前所未有的保证——操作要么成功完成,要么在没有副作用的情况下恢复到之前的状态。这就像有一个安全网,确保无论发生什么情况,你的应用程序的完整性都完好无损。

带着强大保证导航 STL 容器

记得那些与std::vectorstd::map和其他 STL 容器共度的动态日子吗?现在,想象一下添加元素、调整大小,甚至修改它们。当这些操作成功时,一切照常进行。但如果它们失败并抛出异常,强大的异常安全性保证容器保持原样,未受影响且未更改。

幸运的是,使用 STL 容器实现这一点并不需要超人的努力。许多 STL 容器操作自然提供强大的异常安全性。但当他们不提供时,像复制和交换这样的技巧可以拯救它们。通过在副本上操作,并在确定成功后才将内容与原始内容交换,你可以保证如果抛出异常,原始容器不会发生变化。

带着强大保证定制 STL 容器

当你进入创建自定义 STL 容器的领域时,确保强大异常安全性的责任就完全落在了你的肩上。实现这一目标的关键策略包括以下实践:

  • 本地化提交点:通过将影响容器状态的任何更改推迟到最后时刻,并确保一旦开始这些更改就无异常,你可以巩固强大的保证。

  • RAII 的重要性:利用 RAII 的力量,特别是与资源管理相结合,至关重要。这确保了资源得到适当的处理,如果发生异常,容器保持不变。

  • 不可变操作:尽可能设计不修改容器直到成功为止的操作。

为了说明创建具有强保证的自定义 STL 容器的概念,让我们考虑一个管理动态数组的自定义容器的例子。代码将演示局部提交点、RAII 习语和不可变操作,以提供强异常安全性。

首先,我们将创建 CustomArray 类。CustomArray 类是一个模板类,旨在管理指定数据类型 T 的动态数组。它提供了创建、复制、移动和管理动态数组的基本功能,并具有强异常保证。该类使用 RAII 原则,并利用 std::unique_ptr 进行资源管理,确保高效且安全的内存处理。它支持复制和移动语义,使其适用于各种场景,如动态数组操作和容器重新分配。让我们分部分来探讨这个问题。

我们将把这个例子分成几个部分在这里讨论。对于完整的代码示例,请参阅 GitHub 仓库。首先,我们将查看构造函数:

template <typename T> class CustomArray {
public:
  explicit CustomArray(size_t size)
      : size(size), data(std::make_unique<T[]>(size)) {
    // Initialize with default values, assuming T can be
    // default constructed safely std::fill provides strong
    // guarantee
    std::fill(data.get(), data.get() + size, T());
  }
  // Copy constructor
  CustomArray(const CustomArray &other)
      : size(other.size),
        data(std::make_unique<T[]>(other.size)) {
    safeCopy(data.get(), other.data.get(), size);
  }
  // Move constructor - noexcept for strong guarantee
  // during container reallocation
  CustomArray(CustomArray &&other) noexcept
      : size(other.size), data(std::move(other.data)) {
    other.size = 0;
  }
  void safeCopy(T *destination, T *source, size_t size) {
    // std::copy provides strong guarantee
    std::copy(source, source + size, destination);
  }

我们为我们的类提供了三个构造函数:

  • explicit CustomArray(size_t size): 这是 CustomArray 类的主要构造函数。它允许您通过指定动态数组的大小来创建类的实例。它将 size 成员变量初始化为提供的大小,并使用 std::make_unique 为动态数组分配内存。它还使用 std::fill 初始化数组的元素为默认值(假设类型 T 可以安全地进行默认构造),此构造函数被标记为 explicit,意味着它不能用于隐式类型转换。

  • CustomArray(const CustomArray &other): 这是 CustomArray 类的复制构造函数。它允许您创建一个新的 CustomArray 对象,该对象是现有 CustomArray 对象 other 的副本。它将 size 成员初始化为 other 的大小,为动态数组分配内存,然后使用 safeCopy 函数从 other 到新对象执行深拷贝。当您想要创建现有对象的副本时,将使用此构造函数。

  • CustomArray(CustomArray &&other)noexcept:这是 CustomArray 类的移动构造函数。它使你能够高效地将数据的所有权从一 CustomArray 对象(通常是 rvalue)转移到另一个对象。它使用 std::move 将动态分配的数组所有权从 other 转移到当前对象,更新 size 成员,并将 othersize 设置为零,以表示它不再拥有数据。此构造函数标记为 noexcept,以确保在容器重新分配期间提供强保证,意味着它不会抛出异常。它用于将一个对象的内容移动到另一个对象中,通常用于优化目的。

接下来,让我们看看赋值运算符的重载:

  // Copy assignment operator
  CustomArray &operator=(const CustomArray &other) {
    if (this != &other) {
      std::unique_ptr<T[]> newData(
          std::make_unique<T[]>(other.size));
      safeCopy(newData.get(), other.data.get(),
               other.size);
      size = other.size;
      data = std::move(
          newData); // Commit point, only change state here
    }
    return *this;
  }
  // Move assignment operator - noexcept for strong
  // guarantee during container reallocation
  CustomArray &operator=(CustomArray &&other) noexcept {
    if (this != &other) {
      data = std::move(other.data);
      size = other.size;
      other.size = 0;
    }
    return *this;
  }

在这里,我们提供了赋值运算符的两个重载。这两个成员函数是 CustomArray 类的赋值运算符:

  • CustomArray &operator=(const CustomArray &other):这是复制赋值运算符。它允许你将一个 CustomArray 对象的内容赋值给另一个相同类型的对象。它从 other 到当前对象执行数据的深度复制,确保两个对象都有数据的独立副本。它还更新 size 成员,并使用 std::move 转移新数据的所有权。运算符返回当前对象的引用,允许链式赋值。

  • CustomArray &operator=(CustomArray &&other) noexcept:这是移动赋值运算符。它允许你高效地将数据的所有权从一 CustomArray 对象(通常是 rvalue)转移到另一个对象。它将包含数据的 std::unique_ptrother 移动到当前对象,更新 size 成员,并将 othersize 设置为零,以表示它不再拥有数据。此运算符标记为 noexcept,以确保在容器重新分配期间提供强保证,意味着它不会抛出异常。像复制赋值运算符一样,它返回当前对象的引用:

int main() {
  try {
    // CustomArray managing an array of 5 integers
    CustomArray<int> arr(5);
    // ... Use the array
  } catch (const std::exception &e) {
    std::cerr << "An exception occurred: " << e.what()
              << '\n';
    // CustomArray destructor will clean up resources if an
    // exception occurs
  }
  return 0;
}

总结这个例子,CustomArray 类展示了以下原则:

  • data) 只在提交点改变,例如在复制赋值运算符的末尾,在所有可能抛出异常的操作成功之后。

  • std::unique_ptr 管理动态数组,确保当 CustomArray 对象超出作用域或发生异常时,内存会自动释放。

  • 不可变操作:可能抛出异常的操作,如内存分配和复制,是在临时对象上执行的。只有当这些操作保证成功时,容器状态才会被修改。

此示例遵循 C++ 和 STL 最佳实践,并使用现代 C++ 功能,确保自定义容器遵守强异常安全性保证。

将异常安全性引入自定义 STL 算法

算法与数据和谐共舞。在 STL 中,确保自定义算法提供强异常安全性保证可能是高效应用程序和充满不可预测行为的应用程序之间的区别。

为了确保这一点,你应该牢记以下几点:

  • 操作副本:在可能的情况下,操作数据的副本,确保如果抛出异常,原始数据保持未修改。

  • 原子操作:设计算法,其中操作一旦开始,要么成功完成,要么可以无副作用地回滚。

异常安全性是构建健壮应用程序的途径

强异常安全性不仅仅是一个原则——它是对您应用程序可靠性和健壮性的承诺。当使用 STL、其容器和其算法或尝试创建自己的算法时,这一保证就像一道防线,抵御未预见的异常和不可预测的行为。

通过确保操作要么看到其成功完成,要么恢复到原始状态,强异常安全性不仅提高了应用程序的可靠性,而且使开发者对其软件能够经受异常风暴的考验充满信心,保持其数据和资源的完整性。

有了这一点,我们就结束了我们对 STL 中异常安全性的探索。在我们探讨了基本和强保证之后,希望你现在已经具备了构建健壮和可靠的 C++ 应用程序的知识和工具。并且记住,在软件开发的动态世界中,不仅是要防止异常,还要确保我们准备好应对它们的出现。接下来,我们将检查 STL 操作中使用 noexcept 的情况。

noexcept 对 STL 操作的影响

C++ STL 提供了丰富的数据结构和算法,极大地简化了 C++ 的编程。异常安全性是健壮 C++ 编程的关键方面,noexcept 指定符在实现它方面发挥着关键作用。本节阐述了 noexcept 对 STL 操作的影响以及其正确应用如何提高基于 STL 的代码的可靠性和性能。

noexcept 简介

在 C++11 中引入的 noexcept 是一个可以添加到函数声明中的指定符,表示该函数不期望抛出异常。当一个函数用 noexcept 声明时,它启用特定的优化并确保异常处理更加可预测。例如,当从 noexcept 函数抛出异常时,程序会调用 std::terminate,因为该函数违反了其不抛出异常的合同。因此,noexcept 是一个函数承诺遵守的承诺。

应用于 STL 数据类型

在 STL 数据类型中使用noexcept主要影响移动操作——移动构造函数和移动赋值运算符。这些操作对于 STL 容器的性能至关重要,因为它们允许在不进行昂贵的深拷贝的情况下将资源从一个对象转移到另一个对象。当这些操作是noexcept时,STL 容器可以安全地进行优化,例如在调整大小操作期间更有效地重新分配缓冲区。

考虑一个使用std::vector的场景,这是一个 STL 容器,它会在添加元素时动态调整大小。假设向量包含的对象类型具有noexcept的移动构造函数。在这种情况下,向量可以通过将对象移动到新数组来重新分配其内部数组,而无需处理潜在异常的开销。如果移动构造函数不是noexcept,则向量必须使用复制构造函数,这效率较低,可能会抛出异常,导致潜在的部分状态和强异常安全性的损失。

应用于 STL 算法

noexcept的影响不仅限于数据类型,还扩展到算法。当与noexcept函数一起工作时,STL 算法可以提供更强的保证并表现出更好的性能。例如,如果std::sort的比较函数不抛出异常,则它可以更有效地执行。算法可以优化其实施,因为它知道它不需要考虑由异常处理引起的复杂情况。

让我们以std::for_each算法为例,该算法将一个函数应用于一系列元素。如果使用的函数被标记为noexcept,则std::for_each可以在理解异常不会中断迭代的情况下操作。这可以导致更好的内联和减少开销,因为编译器不需要生成额外的代码来处理异常。

考虑以下示例:

std::vector<int> data{1, 2, 3, 4, 5};
std::for_each(data.begin(), data.end(), [](int& value) noexcept {
    value *= 2;
});

在这个例子中,传递给std::for_each的 lambda 函数被声明为noexcept。这通知编译器和算法该函数保证不会抛出任何异常,从而允许潜在的性能优化。

noexcept指定符是 C++开发者的一项强大工具,它提供了性能优化和关于异常安全的语义保证。当明智地应用于 STL 操作时,noexcept使 STL 容器和算法能够更高效、更可靠地操作。对于希望编写高质量、异常安全代码的中级 C++开发者来说,理解和适当地使用noexcept是至关重要的。

概述

在本章中,我们试图通过 STL 来理解异常安全性的关键概念。我们探讨了不同级别的异常安全性,即基本和强保证,并概述了确保您的程序能够抵抗异常的策略。我们通过详细的讨论学习了如何维护程序不变性和资源完整性,主要关注 RAII 原则和受保护操作,以防止资源泄露并在异常期间保持容器状态。

理解异常安全性对于编写健壮的 C++应用程序至关重要。它确保即使在出现错误的情况下,您的软件的完整性保持完好,防止资源泄露并保持数据结构的有效性。这种知识是可靠和可维护代码的基石,因为它使我们能够保持强有力的保证,即我们的应用程序在异常情况下将表现出可预测的行为。

在下一章中,标题为《使用 STL 的线程安全和并发》,我们将基于异常安全性的基础来处理 C++中并发编程的复杂性。

第二十章:使用 STL 的线程安全和并发

本章探讨了 C++ 标准模板库STL)中的并发。本章首先构建了对线程安全、竞态条件和它们固有风险的坚实基础理解。然后,我们转向 STL,解码其线程安全保证,并突出其潜在陷阱。随着我们的深入,读者将了解 C++ 中可用的各种同步工具,掌握它们在多线程环境中的应用,以保护 STL 容器。完成本章后,读者可以确保在并发 C++ 应用程序中数据的一致性和稳定性。

本章我们将涵盖以下主题:

  • 并发与线程安全

  • 理解线程安全

  • 竞态条件

  • 互斥锁和锁

  • STL 容器和线程安全

  • 特定容器问题

  • STL 中的并发支持

  • 使用 std::threadstd::asyncstd::future 和线程局部存储

  • STL 中的并发数据结构

技术要求

本章中的代码可以在 GitHub 上找到:

github.com/PacktPublishing/Data-Structures-and-Algorithms-with-the-CPP-STL

并发与线程安全

并发是多个任务在重叠时间段内执行的概念。这些任务可以在不同的处理单元上同时运行,或者可能在单个处理单元上交错运行。并发的目标是提高系统的响应性和吞吐量。并发在各种场景中都有益,例如设计处理多个同时客户端请求的服务器或在后台处理任务的同时必须保持用户界面响应。

在 C++ 中,并发可以以多种形式体现:多线程,其中独立的执行线程(可能)并行运行,或者异步编程,其中特定任务被卸载以供稍后执行。

在 C++ 中,理解并发和 线程安全 是相关但不同的概念至关重要。并发指的是程序同时执行多个操作序列的能力,这可以通过多线程或其他并行执行技术实现。然而,并发本身并不保证线程安全。线程安全是确保代码在多个线程并发访问时正确运行的属性。这涉及到仔细管理共享资源、同步数据访问和避免竞态条件。在并发环境中实现线程安全是 C++ 编程的一个挑战性方面。它需要明确的设计选择和使用特定的机制,如互斥锁、锁和原子操作,以防止数据损坏并确保所有线程的一致行为。

线程安全——稳定并发的支柱

线程安全指的是代码在多个线程并发访问时正确运行的能力。它确保共享数据保持其完整性,结果保持一致。线程安全并不本质上意味着函数或方法是无锁的或没有性能瓶颈;相反,它表示并发访问不会导致不可预测的结果或损害数据。

考虑一个类比:如果并发类似于繁忙的城市交叉口,那么线程安全就是交通信号灯,确保汽车(线程)不会相互碰撞。

并发和线程安全之间的相互作用

虽然这两个概念相互交织,但它们服务于不同的目的。并发关注于设计系统以在重叠的时间框架内执行多个任务,旨在提高性能和响应速度。另一方面,线程安全完全是关于正确性的。它关乎确保这些并发任务在交互共享资源时不会相互干扰。

让我们考虑一个简单的例子:C++中的计数器类。并发可能涉及从多个线程中增加计数器的值。然而,如果计数器的增加操作不是线程安全的,两个线程可能会同时读取相同的值,增加它,然后写回相同的增加后的值。在这种情况下,尽管试图通过并发来提高速度,但计数器最终会丢失计数,导致结果不正确。

挑战与回报

引入并发无疑可以使应用程序更快、更响应。然而,它也引入了复杂性。管理多个线程,处理死锁和竞态条件等问题可能具有挑战性。

但是,如果做得正确,回报是巨大的。程序变得更加高效,可能充分利用所有可用的处理单元。应用程序可以更加响应,从而提高用户体验。并发编程不再是选择,而是许多现代高性能应用程序的必要条件。

没有线程安全的并发 – 混乱的配方

想象一个世界,每个任务都试图尽可能快地执行自己,而不进行协调。在这样的世界里,任务可能会相互碰撞,相互干扰,并产生无意义的输出。这就是没有线程安全的并发编程的样子。这是一个优先考虑速度而非正确性的领域,通常会导致混乱。

作为一名 C++开发者,关键是要找到正确的平衡。在追求高并发以使应用程序快速的同时,投资于线程安全机制同样至关重要,以确保正确性。

理解并发和线程安全之间的区别为以下章节奠定了基础。我们将探讨 STL 提供的工具和结构,以实现高并发并确保线程安全。

理解线程安全

执行多个同时任务可以提高性能和响应速度。确保线程安全,尤其是在使用 STL 时,变得至关重要。如果被忽视,无缝并发的梦想可能会迅速变成数据不一致和不可预测行为的噩梦。

STL 容器中的线程安全——奠定基础

STL 的吸引力在于其丰富的容器集合,它们为存储和管理数据提供了流畅的体验。但是,一旦引入多个线程,潜在的危险就会浮现。

线程安全主要关于确保当多个线程访问时,你的代码表现出可预测和正确的行为,即使这些线程是重叠的。对于 STL 容器来说,基本保证很简单:容器的同时只读访问是安全的。然而,一旦引入写入(修改),事情就变得复杂了。

理解这一点至关重要:虽然 STL 容器具有线程安全的读取操作,但写入操作则不是。如果一个线程正在更新一个容器,那么没有其他线程应该读取或写入它。否则,我们就是在招致灾难,或者用技术术语来说,就是未定义行为

理解 STL 算法的线程安全特性

如果 STL 容器是库的灵魂,那么算法无疑是其跳动的心脏。它们负责 STL 的丰富功能,从搜索和排序到数据转换。

这里有个问题:STL 算法是函数,它们的线程安全性不是由算法本身决定的,而是由它们操作的数据决定的。如果一个算法在没有适当同步的情况下操作跨线程的共享数据,你就是在为竞态条件做准备,即使该算法只读取数据。

考虑这样一个场景,你在多个线程中使用 std::find。虽然该算法对于并发读取操作本质上是安全的,但如果在搜索过程中另一个线程修改了数据,结果可能会偏斜。

竞态条件——机器中的幽灵

竞态条件让并发程序员夜不能寐。当你的软件的行为依赖于事件相对时间顺序,例如线程调度的顺序时,就会发生竞态条件。后果从无害(轻微错误的数据)到灾难性(完全数据损坏或应用程序崩溃)不等。

在多线程环境中使用 STL 而没有采取适当的预防措施可能会引入竞态条件。例如,想象两个线程同时向一个 std::vector 推送元素。如果没有同步,向量的内部内存可能会损坏,导致一系列问题。

让我们看看一个简单的竞态条件。在这个例子中,我们将使用两个线程来增加一个计数器:

#include <iostream>
#include <thread>
// Shared variable
int counter = 0;
// Function that increments the counter
void incrementCounter() {
  for (int i = 0; i < 100000; ++i) {
    ++counter; // Race condition occurs here
  }
}
int main() {
  // Creating two threads that run incrementCounter()
  std::thread thread1(incrementCounter);
  std::thread thread2(incrementCounter);
  // Wait for both threads to finish
  thread1.join();
  thread2.join();
  // Print the final value of counter
  std::cout << "Final value of counter is: " << counter
            << std::endl;
  return 0;
}

这里是一个可能的输出示例:

Final value of counter is: 130750

竞争条件发生是因为两个线程同时访问和修改共享变量 counter,而没有任何同步机制(如互斥锁)。由于缺乏同步,两个线程可能以不可预测的顺序读取、增加并写回计数器的值。这导致计数器的最终值不可预测,通常小于预期的 200,000,因为一些增加丢失了。由于竞争条件,多次运行此程序可能会得到不同的计数器最终值。为了解决这个问题,应使用适当的同步机制,如互斥锁,以确保一次只有一个线程修改共享变量。

保护并发——前进的道路

很明显,仅仅理解线程安全性只是战斗的一半。随着我们进入本章,我们将为您提供工具和技术来直面竞争条件,掌握可用的同步机制,并确保您的基于 STL 的多线程应用程序成为稳定性和一致性的堡垒。

竞争条件

在编程中,当系统的行为依赖于多个线程或进程的相对时间顺序时,就会发生竞争条件。在这种情况下,系统的结果变得不可预测,因为不同的线程可能在不适当的同步下同时访问和修改共享数据。这可能导致不一致或错误的结果,因为数据的最终状态取决于线程执行的顺序,而这个顺序无法提前确定。竞争条件是并发编程中常见的问题。它们可能特别具有挑战性,需要仔细的设计和同步机制来确保正确的和可预测的程序行为。

避免无声的陷阱——STL 中的竞争条件

当您进入并发编程之旅时,竞争条件代表了一个最微妙但最危险的陷阱之一。尽管它们的表现在沉默中,但它们可能导致意外和有时令人困惑的结果。识别并避开这些竞争条件,尤其是在 STL 领域,对于构建健壮的多线程应用程序至关重要。

STL 中竞争条件的解剖

在本质上,当您的应用程序的行为取决于不可控事件的序列或时机时,竞争条件就会出现。在 STL 的上下文中,这通常发生在多个线程以无协调的方式访问共享数据时。

想象一个场景,其中两个线程不幸地同时尝试将元素插入std::vector的相同位置,或者考虑另一个实例,其中一个线程从std::unordered_map中读取,而另一个线程删除一个元素。结果会怎样?未定义的行为,在 C++的世界里,这相当于打开了潘多拉的盒子。

超出表面

竞态条件由于其不可预测性而特别危险。虽然并发应用程序可能在一次运行中看似完美无缺,但线程执行时间上的微小变化可能导致下一次运行时出现完全不同的结果。

除了异常行为外,STL 容器和算法中的竞态条件还可能导致更严重的问题。数据损坏、内存泄漏和崩溃只是冰山一角。鉴于这些问题难以捉摸和间歇性出现,调试这些问题可能具有挑战性。

预测竞态条件

有备无患。通过熟悉 STL 中竞态条件出现的常见场景,你可以提前应对这些问题:

  • std::vectorstd::string,当它们的容量超过时,会自动调整大小。如果两个线程同时触发调整大小,内部状态可能会陷入混乱。

  • 迭代器失效:修改容器通常会使得现有的迭代器失效。如果一个线程使用迭代器遍历容器,而另一个线程正在修改容器,那么第一个线程的迭代器可能会陷入无人之地。

  • 算法假设:STL 算法对其操作的数据做出某些假设。并发修改可能会违反这些假设,导致结果错误或无限循环。

保护你的代码——采取主动立场

在熟悉了潜在的“热点”之后,自然地,我们需要加强我们的代码以抵御这些危险。关键在于同步。我们可以通过确保只有一个线程可以同时访问共享数据或执行某些操作来有效地防止竞态条件。

然而,不加区分的同步可能导致性能瓶颈,使并发的优势化为乌有。关键在于找到平衡点,审慎地应用同步。

随着我们进一步深入本章,我们将介绍一系列强大工具和技术。从互斥锁到锁,你将掌握检测和有效中和竞态条件的方法,确保你的 STL 驱动应用程序快速且稳定。

你准备好用 STL 征服并发编程的挑战了吗?让我们共同探索这个领域,确保你的软件保持一致性、可靠性和无竞态条件。

互斥锁和锁

互斥锁,简称互斥,类似于一个数字守门人。它调节访问,确保在任何给定时刻,只有一个线程可以进入其受保护的领域,消除并发访问的混乱。想象一下一个高风险的拍卖室,在任何时刻只能有一个人出价,从而防止重叠和冲突。这就是多线程应用程序中互斥锁的功能。

在 C++标准库中,头文件<mutex>赋予我们几种类型的互斥锁。其中最常用的是std::mutex。这是一个多用途的工具,适用于许多同步需求。一对操作——lock()unlock()——提供了一种简单的方法来保护共享资源。

从手动到自动 - 锁保护与独特锁

手动锁定和解锁互斥锁可能会出错。总会有忘记解锁互斥锁的潜在危险,导致死锁。这时就出现了锁保护与独特锁;它们通过采用资源获取即初始化(RAII)原则简化了互斥锁的管理。

std::lock_guard是一个轻量级包装器,它自动管理互斥锁的状态。一旦锁保护器获取了互斥锁,它就保证在锁保护器的作用域结束时释放它。这消除了忘记释放互斥锁的风险。

另一方面,std::unique_lock要灵活一些。除了lock_guard提供的自动锁定管理外,unique_lock还提供了手动控制、延迟锁定以及转移互斥锁所有权的能力。这使得它适用于更复杂的同步场景。

避免僵局 - 死锁预防

想象一个场景,其中两个线程处于僵持状态,每个线程都期待对方释放资源。结果,两者都陷入永久的等待,导致经典的死锁。这种情况并非纯粹假设,尤其是在涉及互斥锁的情况下,因为如果不小心管理,它们可能会无意中造成这样的死锁。当涉及多个互斥锁时,采用避免死锁的策略至关重要。一种常见的方法是始终以相同的顺序获取互斥锁,无论你在哪个线程中。但是,当这不可行时,std::lock就派上用场了。它设计用来同时锁定多个互斥锁,而不会造成死锁的风险。

将互斥锁与 STL 容器结合使用

在掌握了互斥锁、锁保护、独特锁和死锁预防技术之后,将这些同步工具与 STL 容器集成变得直观起来。

例如,为了保护std::vector免受并发访问,可能需要在修改或访问向量的每个函数中放置std::lock_guard。同样,如果必须在std::unordered_map上执行多个原子操作,std::unique_lock可以提供保护,并在需要时手动控制锁的状态的灵活性。

拥有互斥锁和锁的工具后,STL 中的线程编程不再像在薄冰上行走。通过确保这些同步原语的合理和一致应用,你可以充分利用并发能力,同时避免竞争条件和死锁的陷阱。

在接下来的章节中,我们将继续我们的探索,特别关注在特定 STL 容器中进行线程操作时遇到的独特挑战和考虑因素。

STL 容器和线程安全

当讨论 STL 容器时,假设它们在所有容器中都具备相同的线程安全性是诱人的。然而,这样的假设可能会误导。默认情况下,STL 容器在修改时不是线程安全的,这意味着如果一个线程正在修改一个容器,同时其他线程也在访问它,可能会导致未定义的行为。

然而,存在一些内在的保证。例如,只要没有线程正在修改它,多个线程同时从 STL 容器中读取是安全的。这通常被称为读并发。然而,当甚至有一个线程试图在其他人读取时更改容器,我们就会回到危险的竞争条件领域。

当安全需求需要加固时——并发修改

虽然并发读取是安全的,但修改会带来不同的挑战。假设两个或更多线程同时尝试修改 STL 容器,那么除非使用同步机制(例如我们用互斥锁和锁探索过的那些),否则行为将是未定义的。

std::vector 为例。如果在没有互斥锁保护这些操作的情况下,一个线程使用 push_back 添加元素,而另一个线程尝试使用 pop_back 移除元素,就会产生竞争条件。向量的大小可能在操作过程中改变,或者内存可能被重新分配,导致崩溃或数据不一致。

容器迭代器——脆弱的桥梁

迭代器是 STL 容器的基石,提供了遍历和操作容器元素的手段。然而,当涉及到并发时,迭代器是脆弱的。如果一个线程以导致重新分配或结构重组的方式修改容器,其他线程的迭代器可能会失效。使用失效的迭代器是,再次强调,未定义的行为。

例如,在 std::liststd::map 等容器中,添加元素不会使现有的迭代器失效。然而,在 std::vector 中,当向量超出其当前容量时触发的重新分配可能会使所有现有的迭代器失效。在安排多线程操作时,意识到这些细微差别至关重要。

带有内置保护机制的容器——并发容器

在认识到开发者在同步标准 STL 容器时面临的挑战时,该库引入了并发容器。这些容器,例如 std::atomic 以及某些编译器的 concurrency 命名空间中的容器(对于某些编译器),都内置了同步机制,以性能的潜在代价提供线程安全的操作。

重要的是要注意,这些容器可能不会提供与它们的标准 STL 对应物相同的接口或性能特性。它们是针对手动同步开销可能过于显著的场景而专门设计的工具。

虽然 STL 容器为 C++编程带来了极大的便利和效率,但它们也带来了理解其线程特性的责任。通过判断何时何地需要显式同步,并利用我们可用的工具和技术,我们可以确保我们的多线程应用程序保持健壮、高效,并且没有由并发引起的错误。

特定容器问题

不同的 STL 容器类型在多线程环境中具有独特的挑战和考虑因素。对这些容器上的操作的安全性不是固有的保证,因此在并发场景中使用它们是一个需要仔细规划的问题。例如,std::vectorstd::map 等容器在同时被多个线程访问或修改时可能会表现出不可预测的行为,导致数据损坏或竞争条件。相比之下,std::atomic 等容器是为在单个元素上安全进行并发操作而设计的,但它们并不保护容器结构的整体安全性。因此,了解每个 STL 容器类型的特定线程影响是至关重要的。开发人员必须实现适当的锁定机制或在必要时使用线程安全变体,以确保在多线程环境中数据完整性和正确的程序行为。

std::vector 在多线程中的行为

std::vector 是一种广泛使用的 STL 容器,充当动态数组,根据需要调整其大小。其连续内存分配提供了诸如缓存局部性等优势。然而,在多线程场景中,会面临挑战。

例如,当向量的容量超出并重新分配内存时,所有相关的迭代器、指针和引用都可能失效。如果一个线程正在迭代向量,而另一个线程触发重新分配(添加超出其限制的元素),这可能会导致问题。为了防止此类情况,应在多个线程访问向量时触发重新分配的操作期间实现同步机制。

std::list 在并发中的特性

std::list,作为一种双链表,在多线程情况下具有有益的行为,但也需要谨慎。一个关键优势是,除非目标特定的被删除元素,否则插入或删除操作不会使迭代器失效,这使得某些操作自然地线程安全。

然而,需要谨慎行事。虽然迭代器可能保持不变,但并发修改可能会改变元素的顺序,导致结果不一致。

关联容器的考虑因素

例如,std::setstd::mapstd::multisetstd::multimap这样的容器根据它们的键对元素进行排序。这确保了有组织的数据检索。

在多线程情况下,这个特性会带来挑战。并发的元素插入可能会导致不可预测的最终序列。此外,并发的删除可能会引发竞争条件。

无序容器的并发方面

无序版本的关联容器,如std::unordered_setstd::unordered_map,不按定义的顺序保持元素。然而,它们并不免除多线程问题。这些容器利用散列,元素添加可能会触发重新散列以优化性能。

重新散列可能导致迭代器失效。因此,尽管它们是无序的,但在并发操作期间仍需谨慎处理。

容器适配器的见解

STL 提供了容器适配器,如std::stackstd::queuestd::priority_queue。它们没有自己的存储,而是封装了其他容器。它们的线程安全性取决于它们所基于的容器。例如,使用std::vectorstd::stack实例将会有相同的重新分配和迭代器失效问题。

了解每个 STL 容器的具体行为对于开发线程安全的 C++程序至关重要。虽然 STL 提供了具有不同优势的众多工具,但在多线程环境中它们也面临挑战。

STL 中的并发支持

STL 已经发生了显著的变化,从数据结构和算法的集合转变为一个综合库,其中包含了用于并发编程的高级构造。这种扩展是为了响应对高效和健壮的多线程应用程序的需求,尤其是在多核处理器的时代。现代软件开发通常需要利用并发的力量来提高性能和响应速度。因此,对 STL 并发支持的深入理解对希望优化其应用程序的并发环境中的开发者来说是有益的和必要的。

本节将检查 STL 中集成的并发功能。这包括对线程管理、异步任务、原子操作以及利用并发时的挑战的详细审查。

STL 在并发领域的提供不仅仅是为了促进多线程,还在于以有效和可管理的方式进行。本节旨在提供对这些工具的全面理解,使您能够编写在当今计算需求日益增长的世界中高性能、可扩展和可靠的 C++应用程序。

线程简介

并发编程的核心是线程的概念。在 STL 中,这由std::thread表示。这个类提供了一个创建和监管线程的简单接口。启动一个新线程本质上就是定义一个函数或可调用实体,并将其传递给线程构造函数。在执行完任务后,你可以连接(等待其完成)或分离(允许其独立执行)线程。然而,这里有一个警告:手动处理线程需要仔细注意。确保所有线程都正确连接或分离是必要的,以避免潜在的问题,包括悬挂线程。

异步任务的出现

直接线程管理提供了相当的控制力,但 STL 引入了std::asyncstd::future来处理那些不需要如此细致监管的任务。这些构造函数使开发者能够将任务委托给潜在的并行执行,而无需直接线程监管的复杂性。std::async函数启动一个任务,其结果std::future提供了一个在任务准备好时获取结果的方法。这促进了更有序的代码,尤其是在关注以任务为中心的并行性时。

原子操作

STL 通过原子操作为低开销操作提供了一种强大的解决方案,在这些操作中,锁定机制可能显得不成比例。封装在std::atomic类模板中的原子操作在并发编程中发挥着关键作用,通过保证基本数据类型操作的原子性。

std::atomic旨在确保对基本类型(如整数和指针)的操作作为不可分割的单元执行。这种原子性在多线程环境中至关重要,因为它防止了中断操作可能带来的潜在风险,这些风险可能导致数据状态不一致或损坏。通过确保这些操作在没有中断的情况下完成,std::atomic消除了对传统锁定机制(如互斥锁)的需求,从而通过减少与锁定竞争和上下文切换相关的开销来提高性能。

然而,需要注意的是,使用原子操作需要仔细考虑,并理解它们的特性和限制。虽然它们提供了一种无锁编程的机制,但原子操作并不是所有并发问题的万能药。开发者必须了解内存顺序约束以及在不同硬件架构上的潜在性能影响。特别是,在内存排序(如memory_order_relaxedmemory_order_acquirememory_order_release等)之间的选择,需要彻底理解同步需求和涉及的权衡。

内存排序,例如memory_order_relaxedmemory_order_acquirememory_order_release,决定了原子变量上的操作相对于其他内存操作是如何排序的。

选择正确的内存排序对于确保所需的同步级别并平衡性能至关重要。例如,memory_order_relaxed提供最小的同步,不对内存操作施加排序约束,从而带来更高的性能,但同时也存在风险,即允许其他线程以不同的顺序看到操作。另一方面,memory_order_acquirememory_order_release提供了关于读写排序的更强保证,这对于正确实现无锁数据结构和算法至关重要,但可能会带来性能成本,尤其是在内存模型较弱的系统中。

这些决策中涉及到的权衡是重大的。更宽松的内存排序可能导致性能提升,但也可能引入微妙的 bug,如果程序的正确性依赖于某些内存排序保证的话。相反,选择更强的内存排序可以简化关于并发代码正确性的推理,但可能会因为额外的内存同步屏障而导致性能下降。

因此,开发者必须了解他们特定应用程序的同步需求,并理解他们的内存排序选择如何与底层硬件架构交互。这种知识对于编写高效的 C++并发程序至关重要。

可能的并发挑战

尽管并发编程功能强大,但并非没有挑战。开发者可能会遇到死锁、竞态条件和资源竞争。死锁发生在多个线程无限期地等待彼此释放资源时。竞态条件可能导致由线程操作中的不可预见重叠引起的异常 bug。

内存伪共享是另一个显著的挑战。当不同的线程修改位于同一缓存行中的数据时,就会发生这种情况。这可能会影响性能,因为即使线程修改不同的数据,它们的内存接近性也可能触发多余的缓存失效。意识和谨慎可以帮助避开这些挑战。

使用 STL 的并发特性

STL 提供了用于并发编程的一系列工具,从线程的启动到原子任务的保证。这些工具满足各种需求。然而,明智地使用它们是至关重要的。

并发编程承诺提升性能和灵活的应用,但同时也伴随着复杂性和潜在的 bug。在并发编程中,了解可用的工具是一个必要的起点,但有效地使用它们需要持续的试验和学习。

以下 C++ 代码示例展示了 STL 的各种并发功能。此示例包括线程创建、异步任务执行和原子操作,同时强调了适当线程管理的重要性以及并发可能存在的陷阱:

#include <atomic>
#include <future>
#include <iostream>
#include <thread>
#include <vector>
// A simple function that we will run in a separate thread.
void threadTask(int n) {
  std::this_thread::sleep_for(std::chrono::seconds(n));
  std::cout << "Thread " << std::this_thread::get_id()
            << " completed after " << n << " seconds.\n";
}
// A function that performs a task and returns a result.
int performComputation(int value) {
  std::this_thread::sleep_for(std::chrono::seconds(1));
  return (value * value);
}
int main() {
  // Start a thread that runs threadTask with n=2
  std::thread t(threadTask, 2);
  // task management with std::async and std::future
  std::future<int> futureResult = std::async(
      std::launch::async, performComputation, 5);
  // Atomic operation with std::atomic
  std::atomic<int> atomicCounter(0);
  // Demonstrate atomicity in concurrent operations
  std::vector<std::thread> threads;
  for (int i = 0; i < 10; ++i) {
    threads.emplace_back([&atomicCounter]() {
      for (int j = 0; j < 100; ++j) {
        atomicCounter += 1; // Atomic increment
      }
    });
  }
  // Joining the initial thread to ensure it has finished
  // before main exits
  if (t.joinable()) { t.join(); }
  // Retrieving the result from the future
  int computationResult = futureResult.get();
  std::cout << "The result of the computation is "
            << computationResult << ".\n";
  // Joining all threads to ensure complete execution
  for (auto &th : threads) {
    if (th.joinable()) { th.join(); }
  }
  std::cout << "The final value of the atomic counter is "
            << atomicCounter << ".\n";
  return 0;
}

下面是示例输出:

Thread 32280 completed after 2 seconds.
The result of the computation is 25.
The final value of the atomic counter is 1000.

在这个例子中,我们做了以下几件事:

  • 使用 std::thread 创建一个线程,该线程休眠指定的时间然后打印一条消息。

  • 使用 std::async 以可能并行的方式执行计算,并使用 std::future 在准备好后获取结果。

  • 使用 std::atomic 在多个线程中执行原子递增操作。

  • 确保所有线程都正确连接,以避免悬挂线程。

这段代码是一个简单的演示,作为理解 C++ 中并发的基础。开发者必须进一步探索和处理更复杂的场景,包括同步、防止死锁以及避免竞争条件和虚假共享,以构建健壮的并发应用程序。

使用 std::thread、std::async、std::future 和线程局部存储

让我们看看 C++ 并发工具箱的四个核心组件:std::threadstd::asyncstd::future 和线程局部存储。这些元素对于促进 C++ 的多线程编程至关重要。std::thread 是基础,允许创建和管理线程。std::asyncstd::future 协同工作,以异步执行任务并以受控的方式检索其结果,提供了比原始线程更高的抽象级别。另一方面,线程局部存储为每个线程提供独特的数据实例。这在并发环境中避免数据冲突至关重要。本节旨在全面理解这些工具,展示如何有效地使用它们来编写健壮、高效且线程安全的 C++ 应用程序。

使用 std::thread 启动线程

在 C++ 的并发领域,std::thread 是一个主要工具。这个类允许开发者通过启动不同的线程来并发运行程序。要启动一个新线程,将可调用实体(如函数或 lambda)传递给 std::thread 构造函数。例如,要从独立线程打印“Hello, Concurrent World!”,请参阅以下示例:

std::thread my_thread([]{
    std::cout << "Hello, Concurrent World!" << "\n";
});
my_thread.join();

使用 join() 函数确保主线程等待 my_thread 完成。还有 detach(),它允许主线程无延迟地继续。然而,仔细管理分离的线程对于避免意外行为至关重要。

使用 std::async 和 std::future 管理异步操作

虽然 std::thread 提供了显著的能力,但直接线程管理可能很复杂。STL 通过 std::asyncstd::future 提供了一种高级抽象,用于管理潜在的并行操作。

方法非常明确:将任务分配给 std::async 并检索一个最终将包含该任务结果的 std::future 对象。这种划分允许主线程继续执行或可选地使用 std::futureget() 方法等待结果,如下面的代码示例所示:

auto future_result = std::async([]{
    return "Response from async!";
});
std::cout << future_result.get() << "\n";

如您所见,std::asyncstd::future 被设计成可以很好地协同工作,以帮助管理异步操作。

使用线程局部存储来保持数据一致性

在并发编程中确保每个线程有独立的数据存储以避免重叠并保持数据一致性可能具有挑战性。这通过 线程局部存储TLS)得到解决。

在声明变量时使用 thread_local 关键字确保每个线程都有一个该变量的唯一实例。这在维持数据一致性并避免与共享数据访问相关的问题中起着至关重要的作用:

thread_local int thread_counter = 0;

在这里,thread_counter 为每个线程实例化,从而防止线程间的干扰。

集成用于熟练并发编程的工具

通过 std::threadstd::asyncstd::future 和 TLS,你准备好在 C++ 中导航各种并发编程场景。STL 提供了委托任务以进行并行执行或巧妙管理线程特定数据的必要工具。

需要特别注意,虽然启动线程或任务很简单,但确保同步操作无争用、无死锁或无数据竞争需要关注和持续改进。

在过渡到后续部分,这些部分将回顾 STL 的并发数据结构时,保留这一段的基础洞察至关重要。并发编程是一个不断发展的领域,掌握每个工具和概念可以增强你开发高效和稳定并发应用的能力。

让我们通过一个代码示例来了解如何使用 std::threadstd::asyncstd::future 和 TLS 来并发执行任务和管理每个线程的数据:

#include <future>
#include <iostream>
#include <thread>
#include <vector>
// Function to demonstrate the use of Thread Local Storage
void incrementThreadCounter() {
  // Unique to each thread
  thread_local int thread_counter = 0;
  thread_counter++;
  std::cout << "Thread " << std::this_thread::get_id()
            << " counter: " << thread_counter << "\n";
}
int main() {
  // Initiating a new thread using std::thread
  std::thread my_thread([] {
    std::cout << "Hello, Concurrent World!"
              << "\n";
  });
  // Ensure the main thread waits for my_thread to complete
  if (my_thread.joinable()) { my_thread.join(); }
  // Asynchronous operations w/std::async and std::future
  auto future_result =
      std::async([] { return "Response from async!"; });
  // Retrieve the result with std::future::get when ready
  std::cout << future_result.get() << "\n";
  // Demonstrating the use of Thread Local Storage (TLS)
  std::vector<std::thread> threads;
  for (int i = 0; i < 5; ++i) {
    threads.emplace_back(incrementThreadCounter);
  }
  // Join all threads to the main thread
  for (auto &thread : threads) {
    if (thread.joinable()) { thread.join(); }
  }
  return 0;
}

这里是示例输出:

Hello, Concurrent World!
Response from async!
Thread 11672 counter: Thread 1
32816 counter: 1
Thread 7124 counter: 1
Thread 43792 counter: 1
Thread 23932 counter: 1

在此代码中,我们做了以下操作:

  • 使用 std::thread 创建了一个线程来向控制台打印消息。

  • 使用 std::async 执行一个异步操作,该操作返回一个字符串。结果通过 std::future 对象访问。

  • 使用 thread_local 关键字演示了 TLS 的使用,以为每个线程维护一个单独的计数器。

  • 启动了多个线程,每个线程增加其局部计数器,以展示 TLS 变量是如何为每个线程实例化的。

此示例封装了使用 STL 进行并发编程的要点,从线程创建和同步到使用 TLS 的数据隔离。虽然这些机制简化了并行执行,但我们必须谨慎判断以防止与并发相关的问题,如死锁和竞态条件。接下来的章节将探讨 STL 的并发数据结构,这些数据结构基于这些基础概念,以实现健壮并发程序的创作。

STL 中的并发数据结构

STL 提供了各种数据结构,但并非所有数据结构都天生适合并发访问。理解如何有效地利用和调整这些数据结构,以确保在多线程环境中的安全和高效使用,至关重要。我们将检查常见 STL 数据结构的线程安全性方面,讨论在并发环境中每个数据结构的适当使用案例,并探索确保安全和有效并发访问的策略。本节旨在为开发者提供利用 STL 数据结构以最大化性能同时保持数据完整性的多线程环境下的知识。

STL 的并发优化容器

虽然 STL 提供了许多容器,但并非所有都针对并发访问进行了优化。然而,随着对并发编程需求的增加,特定的并发友好型容器已经进入了许多 C++程序员的工具箱。

一个显著的例子是 std::shared_timed_mutex 及其兄弟 std::shared_mutex(从 C++17 开始)。这些同步原语允许多个线程同时读取共享数据,同时确保写入时的独占访问。这在读取操作比写入操作更频繁的情况下特别有用,例如在缓存场景中。

考虑这样一种情况,你有一个存储配置数据的 std::map

std::map<std::string, std::string> config_data;
std::shared_timed_mutex config_mutex;

要从这个映射中读取,多个线程可以获取共享锁:

std::shared_lock lock(config_mutex);
auto val = config_data["some_key"];

然而,对于写入,唯一锁确保了独占访问:

std::unique_lock lock(config_mutex);
config_data["some_key"] = "new_value";

虽然 std::shared_timed_mutex 不是一个容器,但它可以保护任何 STL 容器,确保并发读取访问同时序列化写入。

在并发环境中追求最大效率

并发不仅仅是使操作线程安全,也是关于实现更好的性能。正如你所看到的,原子类型和并发优化的容器有助于确保安全性,但还有更多。微调性能可能需要考虑锁竞争、避免伪共享和最小化同步开销。

以下是一些提高效率的技巧:

  • 限制锁的范围:虽然锁对于确保数据一致性至关重要,但长时间持有锁可能会阻碍性能。确保你只持有锁的必要时间。

  • 选择合适的数据结构:针对并发优化的容器可能为多线程应用程序提供更好的性能,即使它们在单线程场景中可能较慢。

  • 考虑粒度:考虑你锁的粒度。有时,一个更细粒度的锁(仅保护数据的一部分)可能比一个更粗粒度的锁(保护整个数据结构)表现更好。

最佳实践在行动

让我们看看一个代码示例,该示例展示了在并发环境中使用 STL 容器的最佳实践,重点关注性能优化技术,如最小化锁的作用域、选择合适的数据结构和考虑锁的粒度。

首先,我们将编写一个并发优化的容器,具体为 ConcurrentVector,旨在有效地处理多线程环境。这个自定义容器类,模板化以容纳任何类型的元素(T),封装了一个标准的 std::vector 用于数据存储,同时使用 std::shared_mutex 来管理并发访问(我们将此示例分成几个部分。对于完整的代码,请参阅书籍的 GitHub 仓库):

// A hypothetical concurrency-optimized container that uses
// fine-grained locking
template <typename T> class ConcurrentVector {
private:
  std::vector<T> data;
  mutable std::shared_mutex mutex;
public:
  // Inserts an element into the container with minimal
  // lock duration
  void insert(const T &value) {
    std::unique_lock<std::shared_mutex> lock(mutex);
    data.push_back(value);
  }
  // Finds an element with read access, demonstrating
  // shared locking
  bool find(const T &value) const {
    std::shared_lock<std::shared_mutex> lock(mutex);
    return std::find(data.begin(), data.end(), value) !=
           data.end();
  }
  // Size accessor that uses shared locking
  size_t size() const {
    std::shared_lock<std::shared_mutex> lock(mutex);
    return data.size();
  }
};

接下来,我们将编写 performConcurrentOperations 函数,该函数将演示我们的 ConcurrentVector 类在多线程环境中的实际应用。此函数接受 ConcurrentVector<int> 的引用,并使用 C++ 标准线程启动两个并行操作:

void performConcurrentOperations(
    ConcurrentVector<int> &concurrentContainer) {
  // Multiple threads perform operations on the container
  std::thread writer([&concurrentContainer]() {
    for (int i = 0; i < 100; ++i) {
      concurrentContainer.insert(i);
    }
  });
  std::thread reader([&concurrentContainer]() {
    for (int i = 0; i < 100; ++i) {
      if (concurrentContainer.find(i)) {
        std::cerr << "Value " << i
                  << " found in the container\n";
      }
    }
  });
  // Join threads to ensure complete execution
  writer.join();
  reader.join();
  // Output the final size of the container
  std::cout << "Final size of the container:"
            << concurrentContainer.size() << "\n";
}

最后,我们编写 main() 来驱动程序:

int main() {
  ConcurrentVector<int> concurrentContainer;
  performConcurrentOperations(concurrentContainer);
  return 0;
}

这里是示例输出:

...
Value 98 found in the container.
Value 99 found in the container.
Final size of the container: 100

在前面的代码示例中,我们做了以下操作:

  • 我们定义了一个 ConcurrentVector 模板类,它模拟了一个并发优化的容器,内部使用 std::shared_mutex 来实现读写操作的细粒度控制。

  • insert 方法使用一个独特的锁来确保在写操作期间具有独占访问权限,但锁仅保留在插入期间,最小化锁的作用域。

  • findsize 方法使用共享锁,允许并发读取,展示了使用共享锁来提高读取吞吐量的应用。

  • 创建了一个写线程和一个读线程来对 ConcurrentVector 实例执行并发插入和搜索操作,展示了容器处理并发操作的能力。

此示例说明了优化并发性能的关键考虑因素,例如限制锁的持续时间、选择合适的并发友好型数据结构以及使用细粒度锁来保护数据的小部分。这些实践对于希望提高多线程应用程序性能的中级 C++ 开发者至关重要。

摘要

本章讨论了 STL 中线程安全和并发的复杂性。我们首先区分了并发和线程安全,强调虽然它们相关,但各自都服务于不同的目的。我们的旅程从对线程安全作为稳定并发支柱的基础理解开始,探讨了缺乏线程安全可能导致不可预测的软件行为。我们考察了这些概念之间的相互作用,讨论了在保持线程安全的情况下并发编程的挑战和回报。

我们研究了 STL 容器和算法的线程安全特性,分析了竞争条件以及预测和防范这些条件的技术。本章提供了关于各种 STL 容器在多线程场景下行为的详细见解,从std::vectorstd::list,再到关联容器和无序容器。我们还揭示了容器适配器的并发方面,断言在编写并发应用程序时,知识就是力量。

我们已经配备了核心工具:std::threadstd::asyncstd::future和 TLS。有了这些,我们启动了线程,管理异步操作,并在线程之间保持数据一致性。这些能力使我们能够熟练地处理关于安全和性能的并发。

本章探讨了 STL 的原子类型和针对并发优化的容器,提供了在并发环境中最大化效率的技巧。这些见解对于使用 STL 开发高性能、线程安全的应用程序至关重要。

本章传授的知识至关重要,因为线程安全和高效的并发对于现代 C++开发者至关重要。随着多核和多线程应用程序成为常态,理解这些原则对于能够充分利用 STL 的全部功能至关重要。

在下一章中,我们将进一步深入探讨 STL 的高级用法。我们将介绍概念和强大的模板特性,允许在编译时进行更精确的类型检查。我们将学习如何细化 STL 算法中的约束,并有效地使用这些约束来增强具有显式要求的数据结构。此外,我们还将探索 STL 与协程的集成,评估与范围和视图的潜在协同作用,并为当代 C++编程中即将到来的范式转变做好准备。

第二十一章:STL 与概念和协程的交互

本章将探讨 STL 与 C++ 的两个高级特性之间的相互作用:概念和协程。本章旨在加深你对这些现代 C++ 特性如何增强并与 STL 交互的理解。

我们首先学习关于概念的知识,从介绍开始,逐步探索它们在细化 STL 算法约束、增强数据结构和开发自定义概念中的作用。这一部分对于理解显式类型约束如何导致更健壮和可读的代码至关重要。

接下来,我们将重点关注协程,在检查它们与 STL 算法和数据结构的集成之前,提供一个复习。这包括探索与范围和视图的潜在协同作用,最终讨论协程可能预示着 C++ 编程范式的转变。

本章将全面了解并深入探讨如何有效地使用这些特性,强调它们在现代 C++ 开发中的重要性及其潜在挑战。

在本章中,我们将涵盖以下主题:

  • 概念

  • 协程

技术要求

本章中的代码可以在 GitHub 上找到:

github.com/PacktPublishing/Data-Structures-and-Algorithms-with-the-CPP-STL

概念

C++20 中概念的引入标志着朝着更安全和更具表达性的模板编程迈出的关键一步。凭借其指定模板参数约束的固有能力,概念承诺将重塑我们与 标准模板库STL)交互和利用的方式。让我们发现概念如何与 STL 算法和数据结构的丰富织锦交织,以创建一个更健壮和声明性的 C++ 编程范式。

概念简介

概念提供了一种指定和检查模板参数约束的机制。本质上,它们允许开发者对传递给模板的类型提出要求。概念旨在使模板错误更易于阅读,帮助避免常见陷阱,并促进更通用和可重用代码的创建。

考虑以下关于算术类型的概念:

template<typename T>
concept Arithmetic = std::is_arithmetic_v<T>;

使用这个概念,可以限制一个函数只接受算术类型:

template<Arithmetic T>
T add(T a, T b) { return (a + b); }

STL 算法中的细化约束

历史上,STL 算法依赖于其模板参数的复杂、有时模糊的要求。有了概念,这些要求变得明确和可理解。例如,std::sort 算法需要随机访问迭代器,现在可以使用概念来断言。如果错误地使用列表(仅提供双向迭代器),这将导致更精确的错误消息。

有效地约束模板

在使用 C++ 模板编程时,确保给定的类型满足一组特定的要求在历史上一直是一个挑战。在引入概念之前,开发者会依赖于涉及替换失败不是错误SFINAE)或专门的特质类等复杂技术的技术。这些方法冗长且容易出错,通常会导致难以理解的错误信息。

概念允许开发者定义一组类型必须满足的谓词,提供了一种更结构化和可读性的方式来约束模板。使用概念,你可以指定模板参数必须满足的要求。当一个类型不符合概念定义的约束时,编译器将拒绝模板实例化,生成更直接、更有意义的错误信息。这增强了模板代码的可读性、可维护性和健壮性。使用概念,编译器可以快速确定类型对给定模板的适用性,确保只使用合适的类型,从而最小化运行时错误或未定义行为的风险。

下面是一个代码示例,展示了概念的使用以及在没有引入概念之前如何完成相同的任务:

#include <iostream>
#include <type_traits>
// Create a class that is not inherently printable.
struct NotPrintable
{
  int foo{0};
  int bar{0};
};
// Concept definition using the 'requires' clause
template <typename T>
concept Printable = requires(T t) {
  // Requires that t can be printed to std::cout
  std::cout << t;
};
// Before C++20:
// A Function template that uses SFINAE to implement a
// "Printable concept"
template <typename T,
          typename = typename std::enable_if<std::is_same<
              decltype(std::cout << std::declval<T>()),
              std::ostream &>::value>::type>
void printValueSFINAE(const T &value) {
  std::cout << "Value: " << value << "\n";
}
// After C++20:
// A Function template that uses the Printable concept
template <Printable T> void printValue(const T &value) {
  std::cout << "Value: " << value << "\n";
}
int main() {
  const int num = 42;
  const NotPrintable np;
  const std::string str = "Hello, Concepts!";
  // Using the function template with SFINAE
  printValueSFINAE(num);
  // This line would fail to compile:
  // printValueSFINAE(np);
  printValueSFINAE(str);
  // Using the function template with concepts
  printValue(num);
  // This line would fail to compile
  // printValue(np);
  printValue(str);
  return 0;
}

下面是示例输出:

Value: 42
Value: Hello, Concepts!
Value: 42
Value: Hello, Concepts!

在这个例子中,我们使用所需子句定义了一个名为 Printable 的概念。Printable 概念检查一个类型是否可以被打印到 std::cout。然后我们有两个函数模板 printValueprintValueSFINAE,分别用于满足概念或 SFINAE 条件时打印值。

当使用带有 Printable 概念的 printValue 函数模板时,编译器将确保传递给它的类型可以被打印,如果不能,它将生成清晰的错误信息。这使得代码更易于阅读,并提供了有意义的错误信息。

另一方面,当使用 printValueSFINAE 函数模板时,我们依赖于 SFINAE 来完成相同的任务。这种方法更冗长且容易出错,因为它涉及到复杂的 std::enable_if 构造,并且在约束未满足时可能导致难以理解的错误信息。

通过比较这两种方法,你可以看到概念如何提高 C++ 模板代码的可读性、可维护性和健壮性,使其更容易指定和执行类型要求。

带有显式要求的增强数据结构

STL 容器,如 std::vectorstd::map,通常对存储的类型有要求,例如必须是可复制的或可赋值的。概念可以非常清晰地表达这些要求。

想象一个自定义容器,它要求其元素必须能够使用默认构造函数。这个要求可以用概念来优雅地表达,从而确保容器行为更安全、更可预测。

自定义概念和 STL 交互

概念的一个优点是它们不仅限于标准库提供的那些。开发者可以创建定制的概念,以满足特定需求,确保 STL 结构和算法可以适应独特和复杂的场景,同时不牺牲类型安全。

例如,如果某个算法需要具有特定接口的类型(例如具有 draw() 成员函数),则可以设计一个概念来强制执行此要求,从而实现更直观和自文档化的代码。让我们看看一个代码示例:

#include <concepts>
#include <iostream>
#include <vector>
template <typename T>
concept Drawable = requires(T obj) {
  { obj.draw() } -> std::convertible_to<void>;
};
class Circle {
public:
  void draw() const { std::cout << "Drawing a circle.\n"; }
};
class Square {
public:
  // No draw() member function
};
template <Drawable T> void drawShape(const T &shape) {
  shape.draw();
}
int main() {
  Circle circle;
  Square square;
  drawShape(circle);
  // Uncommenting the line below would result in
  // 'drawShape': no matching overloaded function found:
  // drawShape(square);
  return 0;
}

下面是示例输出:

Drawing a circle.

在前面的代码示例中,我们做了以下操作:

  • 我们定义了一个名为 Drawable 的自定义概念,它要求类型具有返回 voiddraw() 成员函数。

  • 我们创建了两个示例类:Circle,它通过具有 draw() 成员函数满足 Drawable 概念,而 Square 则不满足该概念,因为它缺少 draw() 成员函数。

  • 我们定义了一个名为 drawShape 的泛型函数,它接受一个 Drawable 类型作为参数并调用其 draw() 成员函数。

  • main 函数中,我们创建了 CircleSquare 的实例,并演示了 drawShape 可以用 Drawable 类型(例如 Circle)调用,但不能用不满足 Drawable 概念的类型(例如 Square)调用。

此示例说明了如何使用自定义概念来强制执行特定的接口要求,确保类型安全,并在处理 C++ 中的复杂场景和算法时使代码更直观和自文档化。

潜在的挑战和注意事项

虽然概念无疑很强大,但有一些考虑事项需要考虑:

  • 复杂性:设计复杂的自定义概念可能具有挑战性,可能会增加新手的学习曲线。

  • 编译时间:与大多数基于模板的功能一样,过度依赖或误用可能会增加编译时间。

  • 向后兼容性:较老的代码库可能需要重构才能充分利用或完全符合新的概念驱动约束。

本节介绍了 C++ 中一个强大的功能,允许我们指定模板参数的约束。我们首先简要介绍了概念,理解它们在增强代码可表达性和安全性方面的作用。然后,我们探讨了如何将精细的约束应用于 STL 算法,从而实现更健壮和可读的代码。我们还学习了如何有效地约束模板,这对于防止代码误用和确保代码按预期行为至关重要。

然而,我们也承认了与概念相关的潜在挑战和注意事项。虽然它们提供了许多好处,但明智地使用它们以避免不必要的复杂性和潜在陷阱是很重要的。

从本节中获得的知识是无价的,因为它为我们提供了使用 STL 编写更安全、更表达性和更高效代码的工具。它还为我们为下一节做准备,我们将探索 C++ 的另一个令人兴奋的特性:协程。

下一节将刷新我们对协程的理解,并讨论它们与 STL 算法和数据结构的集成。我们还将探索与范围和视图的潜在协同作用,这可能导致更高效、更优雅的代码。最后,我们将探讨协程如何代表我们在编写异步代码时的一种范式转变。

协程

协程集成到 C++20 中引入了异步编程的新范式,这使得代码更易读、更直观。通过允许函数暂停和恢复,协程为在异步 C++ 代码中常见的回调密集型风格提供了一种替代方案。这种演变本身具有变革性,同时也提供了与尊贵的 STL 交互的新鲜、创新方式。研究协程与 STL 算法和数据结构的交互揭示了它们如何简化异步操作。

理解协程——复习

co_awaitco_returnco_yield

  • co_await: 暂停当前协程,直到等待的表达式准备好,此时协程继续

  • co_return: 这用于结束协程,可能返回一个值

  • co_yield: 以生成器的方式产生一个值,允许对协程进行迭代

STL 算法和协程集成

使用协程后,之前需要更复杂异步方法的 STL 算法现在可以以直接、线性的逻辑优雅地编写。考虑在序列或范围内操作的计算算法;它们可以与协程结合,以异步方式生成值。

例如,一个协程可以异步产生值,然后使用 std::transformstd::for_each 处理这些值,将异步代码与同步 STL 算法无缝结合。

协程和 STL 数据结构

协程的魔法也触及了 STL 数据结构的领域。协程为容器如 std::vectorstd::list 提供了有趣的潜在用途:填充(异步)。

想象一个场景,其中必须从网络源获取数据并将其存储在 std::vector 中。可以使用协程异步获取数据,在数据到达时产生值,然后直接将这些值插入到向量中。这种异步与 STL 数据结构直接性的结合简化了代码并减少了认知开销。

与范围和视图的潜在协同作用

随着 C++语言的演变,其他特性,如范围和视图,与协程结合,可以提供一种更表达性的方式来处理数据操作和转换。协程可以生成范围,这些范围可以延迟评估、过滤和转换,使用视图,从而实现一个强大且可组合的异步编程模型。

让我们看看以下涉及以下步骤的代码示例:

  • std::vector<int> 用于存储数字序列。

  • 协程:一个异步生成数字以填充我们的向量的生成器。

  • std::ranges::copy_if

  • std::views::transform,我们将每个数字乘以二。首先,我们必须创建一个特殊的 promise_type 结构的 generator 类,我们的协程将使用它。在这个代码中,生成器类模板及其嵌套的 promise_type 结构是实现 C++中生成值序列协程的关键组件。

根据请求,一次一个 T。它封装了协程的状态,并提供了一个接口来控制其执行和访问产生的值。

在生成器内部嵌套的 promise_type 是协程的生命周期和状态管理核心。它保存要产生的当前值(value)并定义了几个关键函数:

  • get_return_object:返回与该协程关联的生成器对象

  • initial_suspendfinal_suspend:控制协程的执行,初始暂停并在完成后暂停

  • unhandled_exception:定义未处理异常的行为,终止程序

  • return_void:当协程到达其末尾时的占位符

  • yield_value:当产生一个值时(co_yield),暂停协程并存储产生的值

下面的代码示例被分成几个部分(完整的示例可以在书籍的 GitHub 仓库中找到):

template <typename T> class generator {
public:
  struct promise_type {
    T value;
    auto get_return_object() {
      return generator{handle_type::from_promise(*this)};
    }
    auto initial_suspend() {
      return std::suspend_always{};
    }
    auto final_suspend() noexcept {
      return std::suspend_always{};
    }
    void unhandled_exception() { std::terminate(); }
    void return_void() {}
    auto yield_value(T x) {
      value = x;
      return std::suspend_always{};
    }
  };
  using handle_type = std::coroutine_handle<promise_type>;
  generator(handle_type h) : m_handle(h) {}
  generator(const generator &) = delete;
  generator(generator &&o) noexcept
      : m_handle(std::exchange(o.m_handle, {})) {}
  ~generator() {
    if (m_handle) m_handle.destroy();
  }
  bool next() {
    m_handle.resume();
    return !m_handle.done();
  }
  T value() const { return m_handle.promise().value; }
private:
  handle_type m_handle;
};

上述代码定义了一个名为 generator 的泛型模板类。这个类被实例化为 generate_numbers 函数的返回类型,该函数创建从开始到结束的整数序列。当被调用时,它启动一个协程,迭代地产生指定范围内的整数。每次迭代都会暂停协程,使当前值对调用者可用。生成器类提供了恢复协程(next())和检索当前值(value())的机制。生成器的构造函数、移动构造函数、析构函数和已删除的复制构造函数管理协程的生命周期并确保适当的资源管理。

这就是难点所在。现在,我们可以开始构建和使用我们的协程:

generator<int> generate_numbers(int start, int end) {
  for (int i = start; i <= end; ++i) { co_yield i; }
}
int main() {
  std::vector<int> numbers;
  auto gen = generate_numbers(1, 10);
  while (gen.next()) { numbers.push_back(gen.value()); }
  std::vector<int> evenNumbers;
  std::ranges::copy_if(numbers,
                       std::back_inserter(evenNumbers),
                       [](int n) { return n % 2 == 0; });
  const auto transformed =
      evenNumbers |
      std::views::transform([](int n) { return n * 2; });
  for (int n : transformed) { std::cout << n << " "; }
  return 0;
}

以下是示例输出:

4 8 12 16 20

在这个例子中,我们做了以下几件事:

  • 我们创建了一个 generator 类来表示异步生成器。

  • 我们使用 generate_numbers 协程异步生成从 1 到 10 的数字。

  • 使用范围,我们只过滤出偶数并将它们存储在另一个向量中。

  • 使用视图,我们将这些偶数乘以二进行转换。

  • 最后,我们输出了转换后的序列。

展望未来——范式转变

C++ 中的协程代表了异步编程领域的一项重大进步。通过引入处理异步任务的标准方式,协程促进了编写非阻塞、高效和可维护的代码。当与 STL 结合使用时,协程有可能简化复杂操作,改变 C++ 编程的格局。

STL 为数据操作和算法实现提供了一个健壮的框架。协程的引入通过提供一种比传统线程机制更不易出错、更直观的并发模型来增强这个框架。这种协同作用允许开发复杂的异步程序,利用 STL 容器、迭代器和算法的全部力量,而不牺牲性能。

随着协程在 STL 中的集成越来越紧密,我们预期将出现一种范式转变,其中高性能代码不仅以其速度为特征,还将以其清晰性和模块化结构为特征。协程的采用预计将扩大,这得益于它们产生可扩展和响应性软件的能力。

C++ 标准的未来迭代可能会引入更多功能,以补充协程-STL 接口,为开发者提供更丰富的工具集。这种演变将巩固 C++ 作为开发高性能、异步应用程序的首选语言的地位。C++ 社区对持续改进的承诺保持了该语言在解决现代编程挑战中的相关性和有效性。

摘要

本章揭示了 C++20 的概念和协程与 STL 的集成。我们首先探讨了概念在模板编程中的作用。概念通过强制类型约束和增强模板使用的可表达性和安全性来增强代码的健壮性。它们用更易读和声明性的语法替换了易出错的 SFINAE 技术。我们看到概念如何提高算法要求的可清晰性,从而产生更易于维护的代码。

接下来,我们探讨了协程如何为 C++ 中的异步编程引入一个新的复杂层次。我们讨论了协程的机制,强调使用 co_awaitco_returnco_yield 来创建非阻塞操作。我们研究了协程如何与 STL 数据结构和算法交互,使异步和同步代码能够无缝融合。

理解概念、协程和 STL 之间的相互作用至关重要。它使我们能够编写不仅性能出色,而且清晰和可靠的代码。这种知识使我们能够自信和有远见地应对复杂的编程场景。

接下来,我们将专注于应用能够使 STL 算法实现并行的执行策略。本章将引导我们了解并行执行策略的细微差别,constexpr 在提升编译时优化中的作用,以及在并发环境中实现最佳性能的最佳实践。

第二十二章:使用 STL 的并行算法

本章涵盖了 C++ 并行性的主题,特别是 C++17 中引入的工具和技术。从基础开始,本章展开介绍了执行策略的力量,允许开发者利用并行处理在他们的 C++ 标准模板库 (STL) 算法中。

本章我们将涵盖以下主题:

  • 执行策略简介

  • 引入执行策略

  • constexpr 对算法和容器的影响

  • 性能考虑

技术要求

本章中的代码可以在 GitHub 上找到:

github.com/PacktPublishing/Data-Structures-and-Algorithms-with-the-CPP-STL

执行策略简介

处理器已经从关注提高单个核心的速度转变为结合多个核心以增强性能。对于开发者来说,这意味着可以在这些核心上并发执行多个指令,从而提高应用程序的效率和响应速度。

这种转向多核配置突出了集成并行编程技术的重要性。随着 C++17 的出现,C++ 在这一领域取得了显著进展,通过引入 <execution> 头文件。

<execution> 头文件–在 STL 算法中启用并行性

在 C++17 之前,尽管 STL 提供了一套全面的算法,但它们都是顺序执行的。这种顺序操作意味着 STL 算法没有充分利用多核处理器的功能。

<execution> 头文件解决了这一限制。它不是添加新算法,而是通过引入执行策略,通过结合并行性来增强现有算法。

执行策略作为指令,指示 STL 算法所需的操作模式:顺序、并行或向量化。通过 <execution> 头文件,开发者可以指定这些偏好。

主要的执行策略包括以下内容:

  • std::execution::seq: 规定算法的顺序执行

  • std::execution::par: 在可行的情况下促进并行执行

  • std::execution::par_unseq: 支持并行和向量化执行

实现并行执行

将并行性集成到 STL 算法中非常简单。以 std::sort 算法为例。通常,它被以下方式使用:

std::sort(begin(vec), end(vec));

要使用 <execution> 头文件进行并行排序,语法如下:

std::sort(std::execution::par, begin(vec), end(vec));

这种修改使 sort 算法能够利用多个核心,从而可能提高排序过程的速度。

反思转向并行 STL 的过渡

虽然引入<execution>头文件及其相关的执行策略是一个显著的进步,但使用它们时必须谨慎。并行化确实引入了开销,如线程上下文切换和数据协调。这些开销有时会抵消并行化的好处,特别是对于数据集较小的任务。

然而,当谨慎使用时,<execution>头文件可以显著提高应用程序的性能。后续章节将更详细地探讨执行策略,使开发者能够有效地利用它们。

总结来说,C++17 的<execution>头文件是一个关键的增强。它提供了一种机制,将并行能力注入现有的 STL 算法中,使开发者能够开发针对多核生成优化的应用程序。

集成执行策略

C++17 标准中引入的<execution>头文件,通过提供一套专为并行计算设计的工具,为 C++编程增加了显著深度。当与 STL 算法结合使用时,这个头文件允许开发者有效地利用并发计算的能力。

执行策略是<execution>头文件的一个关键特性,对于控制 STL 算法的执行方式至关重要。通过在调用 STL 算法时指定执行策略,开发者可以指定算法是顺序运行、并行运行还是并行加向量化的运行。这种控制水平可以带来显著的性能提升,尤其是在计算密集型或处理大数据集的应用程序中。

从本质上讲,<execution>头文件及其相关的执行策略为 C++开发者提供了一套强大的工具集。它们提供了一种方式,可以挖掘现代多核处理器和分布式计算环境潜力,从而实现更高效和更快的代码执行。

将策略与标准算法集成

执行策略作为 STL 算法的指令,指示首选的操作模式。对于熟悉 STL 算法的开发者来说,集成这些策略需要对现有代码进行最小修改。

考虑std::for_each算法,它作用于集合中的每个元素。默认情况下,它按顺序操作:

std::for_each(std::begin(vec), std::end(vec), [](int val) { /*...*/ });

对于大数据集或 lambda 函数内的计算密集型操作,并行执行可能是有益的。这可以通过简单地引入一个执行策略来实现:

std::for_each(std::execution::par, std::begin(vec), std::end(vec), [](int val) { /*...*/ });

包含了std::execution::par之后,该算法现在已准备好进行并行执行。

理解并行执行策略

有两种主要的并行执行策略:

  • std::execution::par:这表示算法可以并行执行。它允许实现根据特定上下文来决定是否并行。

  • std::execution::par_unseq:这进一步建议了并行性,并允许向量化。这意味着当硬件支持时,多个循环迭代可能在单个处理器核心上并发执行。

例如,std::transform算法,它将函数应用于每个集合元素,可以利用这些策略:

std::transform(std::execution::par_unseq, std::begin(vec), std::end(vec), std::begin(output), [](int val) { return val * val; });

每个vec元素都被平方,结果填充到输出中。std::execution::par_unseq策略表明了此操作的潜在并行化和向量化。

选择合适的执行策略

虽然执行策略增强了并行计算能力,但必须谨慎应用。并非每个数据集或算法都能从并行执行中获益,有时,开销可能会抵消小型数据集的优势。

std::execution::seq策略明确选择顺序执行,确保算法在单线程模式下运行。这在并行性引入不必要的开销或在不建议并行执行的环境中是有益的。

在使用具有副作用或需要同步的算法的并行策略时,也要警惕潜在的问题。

C++17 的执行策略简化了对并行性的访问。将这些策略与传统的 STL 算法配对,允许开发者最优地使用多核处理器。无论是使用std::transform处理大量数据集,还是使用std::sort对大型集合进行排序,或者使用std::remove_if过滤项目,执行策略都提供了额外的性能维度。

然而,始终要验证并行执行是否真正增强了您的应用程序,而没有带来不可预见的问题或瓶颈。不断地评估和测试您的代码是至关重要的。

在这个基础上,我们准备在下一节中考虑性能考虑因素。通过区分并行性的应用,我们可以开发出针对当代计算需求的效率高的 C++应用程序。

constexpr对算法和容器的影响

随着 C++11 中constexpr指定符的引入及其在后续版本中的增强,C++中的编译时计算取得了重大飞跃。函数和构造函数通过constexpr在编译时操作的能力,使得优化和确保代码运行前的特定属性成为可能。本节探讨了constexpr在 STL 中的集成,特别是关于算法和容器。

constexpr的演变

在 C++11 的初期,constexpr主要用于简单的计算。C++14 的扩展扩大了其范围,包括循环和条件结构。到 C++20,进一步的增强允许通过std::allocator进行constexpr分配。这使得std::vectorstd::string等容器可以在constexpr下使用,尽管存在某些限制。

算法和constexpr的作用

最初,由于它们的泛型设计和多方面的要求,constexpr并不广泛适用于 STL 算法。然而,随着 C++20 标准的推出,更多的 STL 算法变得与constexpr兼容。这意味着,如果所有输入都是常量表达式,那么可以在编译时计算算法的结果。

std::findstd::count函数为例。当用于静态数据结构,如数组或std::array时,它们可以在编译阶段执行。然而,截至 C++20,动态分配仍然主要在constexpr的领域之外。

以下代码片段使用std::array来突出std::findstd::countconstexpr的使用:

#include <algorithm>
#include <array>
#include <iostream>
constexpr std::array<int, 6> data = {1, 2, 3, 4, 3, 5};
constexpr bool contains(int value) {
  return std::find(data.begin(), data.end(), value) !=
         data.end();
}
constexpr size_t countOccurrences(int value) {
  return std::count(data.begin(), data.end(), value);
}
int main() {
  static_assert(contains(3));
  static_assert(countOccurrences(3) == 2);
  std::cout << "Array contains 3: " << contains(3) << "\n";
  std::cout << "Occurrences of 3: " << countOccurrences(3)
            << "\n";
  return 0;
}

这里是示例输出:

Array contains 3: 1
Occurrences of 3: 2

上一段代码中的containscountOccurrences函数在编译时被评估,因为它们操作的是一个与constexpr兼容的std::array,并且它们的所有输入都是常量表达式。

值得注意的是,使用如std::execution::par之类的执行策略的并行算法不适合constexpr上下文,因为它们固有的对运行时资源的依赖。

容器和constexpr集成

C++20 的编译时分配能力使得特定的 STL 容器能够在constexpr环境中工作。虽然std::array始终兼容,甚至std::vectorstd::string的一些操作也变得可行。不过,任何需要动态内存或导致未定义行为的操作,在constexpr上下文中都会导致编译时错误。

constexpr的发展轨迹表明,C++环境正在演变,编译时和运行时评估之间的界限变得越来越模糊。我们可能会很快看到更多高级算法和容器在编译时完全评估,从而优化性能和代码安全性。

然而,由于并行性的基本运行时特性,constexpr和并行算法的收敛仍然是一个不确定的前景。

总结来说,constexpr无疑重塑了 C++开发。随着它更深入地集成到 STL 中,开发者有了更多途径来精炼和巩固他们的应用程序。

性能考虑

并行算法是利用多核处理器能力的基础,旨在提高计算效率和性能。然而,从顺序编程到并行编程的转变并不简单。它需要深入理解固有的复杂性和权衡。在本节中,我们将探讨并行算法的各个方面,包括其性能改进的潜力、并行执行挑战、并行化的最佳数据大小、同步问题和在线程间平衡工作负载的微妙之处。这个全面的概述将更深入地了解并行算法的有效利用,强调在并行计算环境中实现最佳性能时,信息决策和性能分析的重要性。

并行算法在性能增强方面既提供了机会也带来了挑战。虽然它们在多核处理环境中提供了更快计算的可能性,但它们的实际使用需要仔细考虑和决策。

并行开销

随着开发者对并行解决方案进行实验,了解并行执行并不均匀地有利于所有算法或场景至关重要。可能会有开销,例如与启动多个线程和数据同步相关的开销。例如,对于小数据集,线程管理的开销可能会超过计算时间,使得并行化效率较低。

确定最佳数据大小

并行执行在超过特定数据大小阈值时显示出其优势。这个阈值受所采用的算法、计算的特性和硬件规格等因素的影响。具有大量数据集的资源密集型任务通常非常适合并行化,而较小的数据集可能更有效地顺序处理。

理解数据和计算类型对于优化性能至关重要。性能分析变得非常有价值,帮助开发者评估其代码的运行时行为,并决定何时采用并行化。

数据访问和同步挑战

并发可能导致多个线程同时访问相同的资源。数据竞争可能会出现,尤其是在频繁共享数据访问的情况下。实现适当的同步对于防止数据不一致至关重要。然而,同步也有其相关的开销。

伪共享——一个微妙性能问题

即使线程访问不同的数据,仍然可能发生伪共享。这发生在不同核心上的线程修改同一缓存行中的变量时,导致缓存失效和潜在的性能下降。注意数据布局并力求编写缓存优化的代码至关重要。

负载均衡

不同的计算任务可能需要不同的处理时间。如果线程以不同的速率完成任务,可能会导致资源利用率不足。实用的并行算法确保工作负载在线程之间均匀分布。一些高级并行技术,如工作窃取,可以动态重新分配任务,以保持一致的线程参与。

分析的重要性

性能优化的一个一致主题是分析的重要性。仅仅依赖假设是不可取的。如perfgprof之类的分析工具以及如 Intel® VTune™之类的先进工具可以识别性能瓶颈、线程行为和竞争区域。这些工具提供了具体数据,以微调并行策略。

在本节中,我们回顾了与并行算法一起工作时需要考虑的性能因素。我们了解到,虽然并行算法可以显著提高计算效率,但它们的有效使用需要细微地理解各种因素。我们讨论了与并行执行相关的潜在开销,例如线程初始化和数据同步。我们还强调了确定并行执行最佳数据大小的重要性,强调并行化可能并不适用于所有场景,尤其是涉及小数据集的场景。我们进一步探讨了在并发环境中数据访问和同步的挑战,包括假共享问题。我们还简要介绍了负载平衡的概念,解释了计算任务的不均匀分布如何导致资源利用率不足。我们讨论了如工作窃取等高级技术,这些技术可以通过动态重新分配任务来帮助保持一致的线程参与。

从本节中获得的认识是无价的,因为它们为我们提供了在实现并行算法时做出明智决策的知识。理解这些性能考虑因素使我们能够充分利用多核处理器的全部潜力,同时避免常见的陷阱。在当今的多核处理环境中,这些知识至关重要,使我们能够编写更高效、性能更好的代码。它还为我们在 C++ STL 中继续探索数据结构和算法奠定了基础,因为我们努力深化我们的理解并提高我们的编程技能。

摘要

本章介绍了 STL 中的并行算法。我们首先熟悉了 C++17 中引入的<execution>头文件,它在使 STL 算法实现并行化方面发挥了关键作用。这一新增功能使我们能够指定执行策略,如std::execution::seqstd::execution::parstd::execution::par_unseq,从而规定 STL 算法的执行模式。

我们将这些执行策略实施到标准算法中,展示了从顺序执行到并行执行的转换的简单性。这通过将算法如std::sortstd::for_each调整为并行运行来体现,从而利用了多核的计算能力。

章节接着聚焦于constexpr指定符及其对 STL 算法和容器的深远影响。我们探讨了constexpr从 C++11 到 C++20 的演变及其在使算法如std::findstd::count的编译时计算成为可能中的作用。

性能考虑构成了我们最终讨论的核心,强调了使用并行算法的好处和潜在的风险。我们讨论了与并行性相关的开销,确定最佳数据大小的重要性,以及有效数据访问和同步的策略,以避免诸如伪共享和负载不平衡等问题。

本章传达的信息对于在多核处理环境中利用 STL 的能力是无价的。通过理解何时以及如何应用并行算法,我们可以编写更高效、响应更快的代码。对并行执行策略的深入理解以及使用constexpr优化代码的能力使我们能够最大化性能和资源利用率。

posted @ 2025-10-02 09:35  绝不原创的飞龙  阅读(0)  评论(0)    收藏  举报