C++基础:Stanford CS106L学习笔记 4 容器(关联式容器)

目录

4.3 关联式容器

关联容器按照唯一键来组织它们的元素。

从概念上讲,它们类似于 Python 中的字典和集合。当你需要将唯一键映射到值时,可以使用mapunordered_map;若要存储一组唯一元素,则可以使用setunordered_set

4.3.1 有序容器
使用要求

要使用std::map<K, V> std::set<T>KT必须具有操作符<,即operator<。即必须能比较大小。

cpp 复制代码
std::map<std::string, int> frequencies; 
// 合法,因为std::string可以用<比较大小
std::set<std::ifstream> streams;
// 不合法,因为std::ifstream不能用<比较大小

对于不能比较大小的,可以自己定义,有下列几种方式:

  1. 定义一个小于运算符<。例如,可以编写一个非成员函数 operator<,如下所示。有关如何重载operator<函数的更多详细信息,请参见运算符重载章节。
  2. 定义一个函数对象。如果你不想为 MyType 重载全局的 operator<,这种技术是值得推荐的。其工作原理是向mapset显式传递一个比较模板参数,如果没有作为模板参数提供,该参数默认是std::less<T>------ 这是标准库中的一个内置函数对象,它使用operator< 来比较键!
  3. 使用 lambda 函数(见11节)。 与上述情况类似,如果不想重写全局的operator<,这种方法很有用,而且还避免创建新的函数对象类型。从功能上来说,它与2相同。decltype(comp)会推断出 lambda 函数comp的编译时类型,该类型通常会因使用auto而被隐藏。请注意,我们还将此 lambda 函数传递给了mapset的构造函数。
cpp 复制代码
// 1
bool operator<(const MyType& a, const MyType& b) {
    // Return true if `a` is less than `b`
}
// 2
struct Less {
    bool operator()(const MyType& a, const MyType& b) {
        // Return true if `a` is less than `b`
    }
}

std::map<MyType, double, Less> my_map;
std::set<MyType, Less> my_set; 
// 3
auto comp = [](const MyType& a, const MyType& b){ 
    // Return true if `a` is less than `b`
};

std::map<MyType, double, decltype(comp)> my_map(comp);
std::set<MyType, decltype(comp)> my_set(comp);
std::map<K,V>

map是 C++ 中把键与值关联起来的标准方式,存储一个由 std::pair<const K, V>组成的集合。

为什么是const k?原因见第6节6.4

有时被称为​关联数组​。

工作原理与 Python 字典或 JavaScript 对象完全相同,只是存在以下几点例外情况。

表述 结果
std::map m 创建一个空映射。有关使用自定义比较函数初始化映射的方法,见上文。
std::map m { { k1, v1 }, /* ... */ } 用键值对{k1, v1},{k2, v2}等等统一初始化映射。
auto v = m[k] 获取键k的值。如果k不在映射中,将使用V的默认值插入该键
m[k] = v 设置或更新键k的值
auto v = m.at(k) 获取键k的值。如果k不在映射中,将抛出错误
m.insert({ k, v })m.insert(p) 插入一个std::pair p(或使用kv进行等效的统一初始化的对),该对表示一个键值对,​如果该键值对在映射中尚不存在​。
m.erase(k) 从映射中移除键kk无需存在于映射中
if (m.count(k)) ...if (m.contains(k)) ...(since C++20) 检查k是否在映射中
m.empty() 检查m是否为空

注意

例如,false对应bool类型,0对应intsize_tfloatdouble类型,而大多数容器类型的默认值是一个空容器。这可能会导致一些奇怪的行为,比如即便你只是尝试读取某个值,键也会出现在映射中。

cpp 复制代码
std::map<std::string, std::vector<int>> m;
auto v = m["Bjarne"];
std::cout << m.size() << std::endl;    // 输出1

// C++
std::string quote = "Peace if possible, truth at all costs";
std::map<char, size_t> counts;
for (char c : quote) {
  counts[c]++; 
}                                     

// Python
quote = "Peace if possible, truth at all costs"
counts = {}
for c in quote:
  if c not in counts:
    counts[c] = 0
  counts[c] += 1
  1. 核心行为:"读不存在的键,会自动插入默认值"

当通过m[k]访问map中不存在的键k时,C++ 不会报错或返回空值,而是自动在map中插入一个新键值对​:

  • 键为k
  • 值为Vmap的 value 类型)的 "默认初始化值"------ 由V的无参构造函数决定,比如:
    • 基础类型:bool默认falseint/size_t/float/double默认0
    • 容器类型:vector/string等默认空容器。

示例佐证:

cpp 复制代码
std::map<std::string, std::vector<int>> m;
auto v = m["Bjarne"];
  • "Bjarne" 是不存在的键,访问时会自动插入{"Bjarne", 空vector<int>}
  • 因此m.size()输出1,而非直觉中的0(看似 "读",实际触发了 "写")。
  1. 设计目的:简化常见算法实现

这种 "自动插入默认值" 的行为并非 bug,而是为了减少冗余代码 ------ 最典型的场景是​计数类需求​。

以 "统计字符串中字符出现次数" 为例:

  • C++ 代码直接用counts[c]++即可:访问不存在的字符c时,会自动插入{c, 0},随后++变为1,无需手动判断 "键是否存在";
  • 对比 Python:必须先通过if c not in counts检查键是否存在,不存在则手动初始化counts[c] = 0,否则counts[c] += 1会报错。

本质是 C++ 通过m[k]的行为,将 "初始化 + 自增" 合并为一步,简化逻辑。

  1. 提醒:避免意外插入键

该行为的副作用是 "意外增加map的大小"------ 如果仅想 "读取键的值,不存在则不操作",直接用m[k]会导致无关键被插入,需改用m.at(k)(不存在时抛异常)或m.count(k)/m.contains(k)(先判断键是否存在)。

std::set

std::set是 C++ 中存储唯一元素​ 集合的标准方式。无论你向一个std::set中添加同一个元素多少次,效果都如同只添加了一次。

std::set 是没有值的 std::map

表述 结果
std::set s 创建一个空集合。
s.insert(e) e添加到s中。多次调用insert(e)与调用一次的效果相同
s.erase(e) s中移除ee不必是s中的元素
if (s.count(e)) ...if (s.contains(e)) ...(since C++20) 检查e是否在集合中
s.empty() 检查s是否为空
本质

为什么std::map<K, V>std::set<T>要求KT 具备operator < 呢?原因与这些数据结构在幕后的实现方式有关。C++ 标准并未强制规定mapset的具体实现方式,但编译器几乎总是使用红黑树来实现这些数据结构,从而在键查找过程中实现高效遍历。

查找map["Keith"]的过程:

查找map["Alex"]的过程("Alex"键不存在)

set同理

4.3.2 无序容器

无序容器,unordered_mapunordered_set,在其他语言中或许更常被称为 "哈希表",它们利用哈希函数和相等性函数来实现近乎常数时间的键查找。与 map 和 set 不同,它们不依赖于排序比较(例如 operator<)来对元素进行排序,而且在遍历它们时,你无法确定元素会有任何特定的顺序。

使用要求

要使用std::unordered_map<K, V>std::unordered_set<T>KT 必须有相关联的哈希函数相等性函数。

  1. 哈希函数

哈希函数会 "随机地" 将类型为K的元素打乱,生成类型为size_t的值。当正式定义时,哈希函数是确定性的 ------ 相同的K总会生成相同的size_t------ 但它们是不连续的 ------ 输入K的微小变化会导致输出size_t发生巨大且不可预测的变化。这些特性是无序数据结构能够加速元素查找的关键。

许多类型(例如intdoublestd::string)都有内置的哈希函数(您可以在此处查看完整列表)。其他看似基础的类型,如std::pairstd::tuple,则没有内置哈希函数。

自定义方式

  • 特化std::hash​函数对象。​ 这是为类型添加哈希函数最常用的方法。它包括为std::hash函数对象创建模板特化,这是无序容器用于对其元素进行哈希处理的默认策略。
  • 定义一个自定义函数对象。 如果你不想为某种类型的所有使用者更改默认的哈希函数,这是上述语法的一种替代方案。
  • 使用 lambda 函数。 与上述情况类似,如果不想重写全局的 operator<,这种方法很有用,而且还能避免创建新的函数对象类型。从功能上来说,它与第2种相同。
cpp 复制代码
// 1、特化std::hash函数对象
template<>
struct std::hash<MyType>
{
  std::size_t operator()(const MyType& o) const noexcept
  {
    // Calculate and return the hash of `o`...
  }
};

std::unordered_map<MyType, std::string> my_map;

// 2、定义一个自定义函数对象
struct MyHash {
  std::size_t operator()(const MyType& o) const noexcept
  {
    // Calculate and return the hash of `o`...
  }
};

std::unordered_map<MyType, std::string, MyHash> my_map;

// 3、使用 lambda 函数
auto hash = [](const MyType& o) {
  // Calculate and return the hash of `o`...
};

/* The first number (10) is the starting number of buckets!
 * See the behind the scenes section for more information about buckets. */
std::unordered_map<MyType, std::string, decltype(hash)> my_map(10, hash);
std::unordered_set<MyType, decltype(hash)> my_set(10, hash);

在编写自己的哈希函数时,要确保输出的size_t值分布均匀 ------ 正如我们将要看到的,你所使用的容器的性能取决于其哈希函数输出的 "随机性"。想要了解更多关于如何设计一个好的哈希函数的信息,阅读这篇维基百科文章是值得的。例如,一种组合哈希值的好方法是利用位移位、异或以及与质数相乘,下面这个针对std::vector的哈希函数示例就展示了这一点。最好使用第三方库,例如boost::hash_combine,以一种能在整数范围内产生良好分布的方式来组合哈希值。

cpp 复制代码
template <typename T>
struct std::hash<std::vector<T>> {
  std::size_t operator()(const std::vector<T>& vec) const {
    std::size_t seed = vec.size();
    for (const auto& elem : vec) {
      size_t h = element_hash(elem);
      h = ((h >> 16) ^ h) * 0x45d9f3b;
      h = ((h >> 16) ^ h) * 0x45d9f3b;
      h = (h >> 16) ^ h;
      seed ^= h + 0x9e3779b9 + (seed << 6) + (seed >> 2);
    }
    return seed;
  }

  std::hash<T> element_hash{};
};
  1. 相等性函数

相等性函数(或用于unordered_map的键相等性函数)会检查两个元素 / 键是否相等。默认情况下,unordered_mapunordered_set会尝试使用operator==

自定义方式

  • 定义一个 **operator==**。有关更多信息,请参见运算符重载章节。
  • 定义一个函数对象 。如果你不想为MyType重载全局的operator==,推荐使用这种方法。
  • 使用 lambda 函数。 与上述情况类似,如果不想重写全局的 operator<,这种方法会很有用,而且还能避免创建新的函数对象类型。在功能上,它与第二种方法相同。
cpp 复制代码
// 1、定义一个operator==
bool operator==(const MyType& a, const MyType& b) {
    // Return true if `a` equals `b`
}
// 2、定义一个函数对象
struct Equal {
    bool operator()(const MyType& a, const MyType& b) {
        // Return true if `a` equals `b`
    }
}

std::unordered_map<MyType, double, std::hash<MyType>, Equal> my_map;
std::unordered_set<MyType, std::hash<MyType>, Equal> my_set; 
// 3、使用 lambda 函数
auto equals = [](const MyType& a, const MyType& b){ 
    // Return true if `a` equals `b`
};

std::unordered_map<MyType, double, std::hash<MyType>, decltype(equals)> my_map(10, {}, equals);
std::unordered_set<MyType, std::hash<MyType>, decltype(equals)> my_set(10, {}, equals);
/*
在这里,我们将哈希函数的默认值std::hash {}传递给容器构造函数,但实际上可以使用任何哈希函数。
*/
std::unordered_map<K,V>

unordered_mapmap的加速版本,它能够以常量时间查找键对应的值,但代价是会占用额外的内存。

unordered_map支持std::map的所有操作。

额外操作:

表述 结果
m.load_factor() 返回映射的当前负载因子
m.max_load_factor(lf) 将最大允许的负载因子设置为lf
m.rehash(b) 确保m至少有b个桶,并根据新的桶数量将元素重新分配到各个桶中
std::unordered_set

unordered_map类似,unordered_setset的加速版本,它允许在常数时间内检查集合成员身份。

unordered_set支持std::set的所有操作,以及为unordered_map列出的额外操作。

本质

std::map 一样,C++ 标准对unordered_map 用于组织其元素的数据结构没有施加任何限制,但编译器几乎总是使用分离链接哈希表​​该映射会选取某个值 b(称为桶数),并维护一个包含 b 个链表的数组,这些链表被称为桶。

在插入操作时,每个键值对会根据 b 的值和映射的哈希函数被放入这 b 个桶中的某一个。具体来说,如果 f 是哈希函数,那么关键字 k 将进入索引为 f (k) mod b 的桶中。

需要注意的是,哈希函数不是单射的:可能存在关键字 k₁≠k₂,使得 f (k₁)=f (k₂),因此多个关键字可能会落入同一个桶中。在用 b 取模后,由于 f 的输出被压缩到 [0, b) 范围内,这种哈希冲突发生的概率会进一步增加。

为了确定键 k 的值,unordered_map首先确定 k 的桶索引,然后遍历该桶中的键值对,对每个键值对应用键相等函数 e,直到找到匹配的键(以及相应的值)。键查找的时间复杂度将取决于映射表在找到匹配键之前必须在每个桶中搜索的元素数量 ------ 换句话说,就是桶中的项目数量。一个包含 n 个映射和 b 个桶的unordered_map,平均每个桶会有α=n/b个元素,这个α就是unordered_map的​**负载因子(Load factor)**​。

通过保持α较小,unordered_map平均而言只需检查更少的项就能找到键的值。unordered_map会将这个数量限制在某个常数阈值内:默认情况下,max_load_factor被设置为1.0。(可以通过map.max_load_factor(2.0);重新设置为2.0)可以通过如果负载因子超过这个阈值,映射会分配额外的桶并进行​重哈希,rehash ​。由于在键查找过程中,映射最多需要搜索恒定数量 α 的元素,因此unordered_map中的键查找被认为是 O (1) 时间复杂度。

就像set的情况一样,unordered_set使用与unordered_map相同的哈希表数据结构。你可以认为unordered_set是通过哈希表实现的,其节点只包含元素(而非键值对)。

有序无序容器的选择

使用 unordered_map 还是 map

  • unordered_map 通常比 map 更快
  • 然而,它占用更多内存
  • 如果你的键类型没有全序(< 运算符),就使用 unordered_map
  • 如果必须选择,unordered_map 是个稳妥的选择

4.4 容器总结

相关推荐
盐焗西兰花1 小时前
鸿蒙学习实战之路:Tabs 组件开发场景最佳实践
学习·华为·harmonyos
巨人张2 小时前
C++火柴人跑酷
开发语言·c++
_Kayo_2 小时前
Next.js 路由 简单学习笔记
笔记·学习·next.js
盐焗西兰花2 小时前
鸿蒙学习实战之路 - 瀑布流操作实现
学习·华为·harmonyos
酒尘&2 小时前
Hook学习-上篇
前端·学习·react.js·前端框架·react
qq_381454993 小时前
Python学习技巧
开发语言·python·学习
im_AMBER3 小时前
算法笔记 18 二分查找
数据结构·笔记·学习·算法
van久3 小时前
.Net Core 学习: Razor Pages -- EF Core简介
学习·.netcore
Gomiko3 小时前
C/C++基础(四):运算符
c语言·c++