第九章 顺序容器

95 阅读25分钟

元素在顺序容器中的顺序与加入容器时的位置相对应,关联容器中元素的位置由元素相关联的关键字值决定。所有容器类都共享公共的接口,不同容器按不同方式对其扩展。每种容器都提供了不同的性能和功能的权衡。

一个容器就是一个特定类型对象的集合。顺序容器(sequential container)提供了控制元素存储和访问顺序的能力。

顺序容器概述

所有顺序容器都提供了快速顺序访问元素的能力。但是它们在以下方面都有不同的性能折中:

  • 向容器添加、删除元素的代价
  • 非顺序访问容器中元素的代价

sequential-container.png

容器保存元素的策略对容器操作的效率有着固有的、有时是重大的影响。某些情况下,存储策略还会影响容器对特定操作的支持。

  • stringvector:将元素保存在连续的内存空间中,因此下标访问非常快速,但是在容器中间位置增删元素非常耗时。
  • listforward_list:在容器中任何位置的增删元素都很快速,但不支持随机访问,而且相比于 vectordequearray,这两个容器的额外内存开销很大。
  • deque:支持快速随机访问,在两端增删元素速度很快,但在容器中间位置增删元素代价可能很高。
  • array:大小固定,比内置数据更安全、易用。

forward_list 设计目标是达到与最好的手写单向链表数据结构相当的性能,因此没有 size 操作,因为保存或计算大小会多出额外的开销。其它容器的 size 保证是一个快速的常量时间的操作。

新标准库容器的性能几乎肯定与最精心优化过的同类数据结构一样好。现代 C++ 程序应该使用标准库容器,而不是更原始数据结构,比如内置数组。

选择容器的基本原则:

  • 除非有更好的理由选择其他容器,否则应使用 vector
  • 如果程序中有很多小元素,且空间的额外开销很重要,则不要使用 listforward_list
  • 若要求随机访问,则应使用 vectordeque
  • 若要求在容器中间增删元素,应使用 listforward_list
  • 若要求在首尾位置增删元素,但不会在中间位置增删,则使用 deque
  • 若只在读取输入时才需要在容器中间位置插入元素,随后需要随机访问元素,则:
    • 首先,确定是否真的需要在容器中间位置插入元素。是否可以先向 vector 追加数据,然后再调用标准库的 sort 函数重排,避免在中间位置插入元素。
    • 若必须在中间位置插入元素,可以在输入时使用 list,一旦输入完成,将 list 中的内容拷贝到 vector 中。

如果既需要随机访问元素,又需要在容器中间位置插入元素,容器的选择取决于 listforward_list 中访问元素与 vectordeque 中插入/删除元素的相对性能。一般来说,应用中占主导地位的操作决定容器类型的选择,此时有必要对两种容器分别测试应用的性能。

如果不确定应该使用哪种容器,那么可以在程序中只使用 vectorlist 公共操作:迭代器,不使用下标,避免随机访问。这样在必要时选择使用 vectorlist 都很方便。

容器库概览

容器类型上的操作形成了一种层次:

  • 所有容器类型都支持的操作。
  • 仅针对顺序容器、关联容器、无序容器的操作。
  • 适用于一小部分容器的操作。

本节只关注所有容器都支持的操作。一般,每个容器都定义在与类型名同名的头文件中。容器均定义为模板类。

顺序容器几乎可以保存任意类型的元素,元素类型也可以是容器。但某些容器操作对元素类型有特殊要求。虽然可以为不支持特定操作需求的类型定义容器,但只能使用那些没有特殊要求的容器操作。

container-operation-1.png container-operation-2.png container-operation-3.png

迭代器

迭代器有公共接口,如果一个迭代器提供了某操作,那么所有提供相同操作的迭代器对该操作的实现方式都相同。下表是容器迭代器支持的所有操作,forward_list 迭代器不支持 --

container-iterator-operator.png

下表是迭代器支持的算术运算,只能应用于 stringvectordequearray 的迭代器,不能应用于其他任何容器类型的迭代器。

container-iterator-arithmetic.png

迭代器范围(iterator range)由一对迭代器表示,两个迭代器分别指向同一容器中的元素或者尾后。两个迭代器通常称为 beginend。迭代器范围是左闭合区间(left-inclusive interval)[begin, end)beginend 必须指向同一容器,end 可以与 begin 指向同一位置,但不能指向 begin 之前的位置。

迭代器范围是标准库的基础。

如果 beginend 构成一个合法的迭代器范围,则:

  • beginend 相等,则范围为空。
  • beginend 不等,则范围至少包含一个元素,begin 指向该范围中的第一个元素。
  • 可以对 begin 递增若干次,使得 begin == end

容器类型成员

每个容器都定义了多个类型,比如:size_typeiteratorconst_iterator。此外,大多数容器还提供反向迭代器。

容器所提供的类型别名可以在不了解容器元素类型的情况下使用它,value_type 是元素类型,元素类型的引用可以用 referenceconst_reference。这些元素相关的类型别名在泛型编程中很有用。

list<string>::iterator iter;
vector<int>::difference_type count;

beginend 成员

beginend 生成指向容器首元素和尾后的迭代器。最常见的用途是形成一个包含容器中所有元素的迭代器范围。不以 c 开头的函数都是被重载过的,拥有常量成员和非常量成员两个重载版本。一个普通的 iterator 可以转换为对应的 const_iterator

不需要写操作时,应使用 cbegincend

容器定义与初始化

container-definition.png

有两种方式可以将一个新容器创建为另一个容器的拷贝:

  • 直接拷贝整个容器,此时容器类型、元素类型必须匹配。
  • 拷贝由一个迭代器对指定的元素范围,此时只要求被拷贝的元素类型可以转换为要初始化的元素类型即可,新容器大小与范围中元素数目相同。
vector<const char*> articles = {"a", "an"};
vector<const char*> copy_words(articles);
forward_list<string> words(articles.begin(), articles.end());

容器可以列表初始化,除 array 之外,初始化列表自动指定了容器大小。除 array 外,顺序容器还提供一个构造函数,接收一个容器大小和一个可选的元素初始值。若不提供元素初始值,则值初始化。若元素类型没有默认构造函数,除了大小参数之外,还必须指定一个显式的元素初始值。

deque<string> svec(10);

只有顺序容器的构造函数才接受大小参数,关联容器不支持。

标准库 array 的大小也是类型的一部分,定义 array 时必须指定元素类型和容器大小。

array<int, 10> ia1 = {42};
array<int, 10> digits = ia1;

array 默认构造时,容器非空,元素被默认初始化。列表初始化时,初始值数目必须小于等于 array 大小,未列出的元素将会执行值初始化。内置数组类型不能进行拷贝或赋值,但标准库 array 可以,只要容器类型(包括容器大小)、元素类型均相同。

赋值和 swap

容器赋值是将右侧容器的元素拷贝替换左边容器中所有元素。

c1 = c2;
c1 = { a, b, c };

标准库 array 类型允许赋值,左右两边的运算对象类型必须完全相同。array 类型不支持 assign 和列表赋值。

container-assign.png

赋值相关运算会导致左边容器内部的迭代器、引用、指针失效。swap 操作不会导致指向容器的迭代器、引用、指针失效。(容器类型为 arraystring 的情况除外)

赋值运算符要求左右两边的运算对象具有相同类型。除 array 之外的顺序容器都有一个 assign 成员,允许从一个不同但相容的类型赋值,或者容器的一个子序列赋值。assign 操作用参数所指定的元素的拷贝替换左边容器中的所有元素。

由于旧元素被替换,因此传递给 assign 的迭代器不能指向调用 assign 的容器。

list<string> names;
vector<const char*> oldstyle;
names.assign(oldstyle.cbegin(), oldstyle.cend());

list<string> slist(1);
slist.assign(10, "Hi");

swap 操作交换两个相同类型容器的内容。除了 array 之外,交换两个容器内容的操作保证会很快,元素本身并未交换,swap 只交换了两个容器的内部数据结构。迭代器、引用、指针在 swap 操作之后都不会失效,仍指向交换前所指的元素,但交换后这些元素已经属于不同容器了;对 string 调用 swap 将导致迭代器、引用、指针失效。

对于 array,在 swap 操作之后,指针、引用、迭代器所绑定的元素保持不变,但元素值已经和另一个 array 中相应的值进行了交换。

除了 array 之外,swap 不对任何元素拷贝、删除、插入,因此可以保证在常数时间内完成。交换 array 会真正交换它们的元素,所需时间与元素数目成正比。

非成员版本的 swap 在泛型编程中非常重要,统一使用非成员版本的 swap 是一个好习惯。

容器大小操作

max_size 返回一个大于等于该类型容器所能容纳的最大元素数的值,forward_list 支持 max_sizeempty,但不支持 size

关系运算符

每个容器都支持 ==!=,除了无序关联容器之外的所有容器都支持 >>=<<=。关系运算符的两个运算对象必须是相同类型的容器,且保存相同类型的元素。容器的比较就是按字典序对元素逐对比较:

  • 若两容器大小相同且对应位置元素相等,则容器相等,否则容器不等。
  • 若存在某个位置,使得两容器在该位置的元素值不相等,则容器间大小关系由第一对不相等的元素的大小关系决定。
  • 若对应位置上两容器的元素值均相等,则容器间的大小关系由容器的大小决定。

容器的关系运算符使用元素的关系运算符来比较,只有当元素类型定义了相应的比较运算符时,才可以使用关系运算符来比较两容器。容器的相等运算符使用元素的 == 运算符实现,其它关系运算符使用元素的 < 运算符实现。

顺序容器操作

向顺序容器添加元素

add-element.png

除了 array 之外,所有标准库容器都提供了灵活的内存管理,可以在运行时动态增删元素。使用这些操作时,不同容器使用不同策略来分配元素空间,这些策略直接影响性能。

vectorstringdeque 插入元素会使所有指向容器的迭代器、引用、指针失效。

push_back

除了 arrayforward_list 之外,每个顺序容器(包括 string)都支持 push_back

string word;
while (cin >> word)
  container.push_back(word);

void pluralize(size_t cnt, string &word)
{
  if (cnt > 1)
    word.push_back('s');
}

使用一个对象初始化容器或将一个对象插入容器时,实际存放的是对象值的拷贝。容器中的元素与提供值的对象之间没有任何关联,随后对容器中元素的任何改变都不会影响原始对象,反之亦然。

push_front

listforward_listdeque 还支持 push_front 操作将元素插入到容器头部。

list<int> ilist;

for (size_t ix = 0; ix != 4; ++ix) {
  ilist.push_front(ix);
}

deque 保证在容器首尾插入和删除元素时只花费常数时间。

在容器中特定位置添加元素、插入范围内元素

vectordequeliststring 都支持 insert,允许在容器中任意位置插入 0 个或多个元素。insert 接收一个迭代器作为第一个参数,然后将元素插入到迭代器所指位置之前。insert 所接受的后续参数,除了单个元素值之外,还可以有多种形式:元素数目和元素值、一对迭代器、初始化列表。如果接收的是一对迭代器,则它们不能指向添加元素的目标容器。

作为第一个参数的迭代器可以指向尾后。

forward_list 提供了特殊版本的 insert 成员。

将元素插入到 vectordequestring 中的任何位置都是合法的,但这样做可能很耗时。

vector<string> svec;
list<string> slist;

slist.insert(slist.begin(), "Hello");
svec.insert(svec.begin(), "Hello");
svec.insert(svec.end(), 10, "Anna");

vector<string> v = {"quasi", "simba", "frollo"};

slist.insert(slist.begin(), v.end() - 2, v.end());
slist.insert(slist.end(), {"these", "words"});

insert 返回指向第一个新加入元素的迭代器。如果没有插入元素,将返回第一个参数。通过使用 insert 返回值,可以在容器中一个特定位置反复插入元素。

list<string> lst;
auto iter = lst.begin();

while (cin >> word)
  iter = lst.insert(iter, word); // 等价于 push_front

使用 emplace 操作

emplace_frontemplaceemplace_back 对应 push_frontinsertpush_back,允许将元素放置在容器头部、一个指定位置之前、容器尾部,只不过它们是构造元素而不是拷贝元素。

调用 emplace 成员函数时,参数将传递给元素类型的构造函数,emplace 成员使用这些参数在容器管理的内存空间中直接构造元素。emplace 函数的参数根据元素类型变化,参数必须与元素类型的构造函数相匹配。

// 容器 c 存放 Sales_data 对象
c.emplace_back("99-9999-9-999");
c.emplace_back(c.end(), "99-9999-9-999");

访问元素

如果容器中没有元素,访问操作的结果是未定义的。顺序容器的 frontback 成员分别返回首元素和尾元素的引用。访问元素的成员函数返回的都是引用。const 容器返回的是 const 引用,非 const 容器返回的是普通引用。

提供快速随机访问的容器都提供下标运算符,保证下标有效是程序员的责任。如果希望确保下标合法,可以使用 at 成员函数,它类似下标运算符,但如果下标越界,会抛出 out_of_range 异常。

forward_list 没有 back 成员。

seq-container-access.png

删除元素

seq-container-delete.png

删除 deque 中除首尾之外的任何元素都会使迭代器、引用、指针失效。指向 vectorstring 中删除点之后位置的迭代器、引用、指针都会失效。

删除元素的成员函数不做参数检查,程序员必须在删除元素前确保它们是存在的。pop_frontpop_back 成员函数分别删除首元素和尾元素:

  • vectorstring 不支持 pop_front
  • forward_list 不支持 pop_back
  • 不能对空容器执行 pop 操作。

成员函数 erase 从容器中指定位置删除元素。可以根据一个迭代器删除单个元素,也可以删除一个迭代器范围内的所有元素。erase 返回的是删除的最后一个元素之后位置的迭代器。clear 可以删除一个容器中的所有元素。

slist.clear();
slist.erase(slist.begin(), slist.end());

特殊的 forward_list 操作

由于 forward_list 是单向链表,添加或删除元素的操作是通过改变给定元素之后的元素来完成的。

forward_list 中定义了 insert_afteremplace_aftererase_after,对应其它容器中的 insertemplaceerase。为了支持这些操作,forward_list 定义了 before_begin 返回首前(off-the-beginning)迭代器。

forward_list.png

forward_list 中添加或删除元素时,必须关注两个迭代器——一个指向要处理的元素,另一个指向其前驱。

改变容器大小

除了 array 之外,都可以使用 resize 增大或缩小容器:

  • 增大容器时,将新元素添加到容器后部。
  • 缩小容器时,容器后部的元素会被删除。

resize 接受一个可选的元素值参数,用来初始化添加到容器中的元素;若未提供,则新元素执行值初始化。若容器保存的是类类型元素,添加元素时必须提供初始值,或者元素类型必须有默认构造函数。

list<int> ilist(10, 42);

ilist.resize(15);
ilist.resize(25, -1);

resize.png

resize 缩小容器,则指向被删除元素的迭代器、引用、指针都会失效;对 vectorstringdeque 进行 resize 可能导致迭代器、指针、引用失效。

容器操作可能使迭代器失效

对容器添加、删除元素可能会使指向容器元素的指针、引用或迭代器失效。一个失效的指针、引用或迭代器不再表示任何元素。

向容器添加元素后:

  • 对于 vectorstring,若存储空间被重新分配,则迭代器、指针、引用均失效;若未重新分配,如果迭代器、指针、引用指向插入位置之前,则仍有效,否则失效。
  • 对于 deque,插入到除首尾位置之外的任何位置都会使迭代器、指针、引用失效。若在首尾位置添加元素,则迭代器失效,但引用、指针不失效。
  • 对于 listforward_list,迭代器(包括首前和尾后)、指针、引用仍有效。

删除一个元素后,指向该元素的迭代器、指针、引用都会失效:

  • 对于 listforward_list,指向容器其它位置的迭代器(包括首前和尾后)、指针、引用仍有效。
  • 对于 deque,如果在首尾之外的任何位置删除元素,那么指向其它元素的迭代器、引用、指针也会失效。若删除尾元素,则尾后迭代器失效,其它元素的迭代器、指针、引用不受影响;若删除首元素,则其它元素的迭代器、指针、引用仍有效。
  • 对于 vectorstring,指向被删元素之前的迭代器、引用、指针仍有效。尾后迭代器总会失效。

使用失效的迭代器、指针、引用是严重的运行时错误。

使用迭代器时,最小化要求迭代器必须保持有效的程序片段是一个好的方法。

由于添加或删除元素都会导致迭代器失效,因此必须保证每次改变容器操作后都正确地重新定位迭代器,尤其是 vectorstringdeque

添加/删除 vectorstringdeque 元素的循环程序必须保证每个循环步中都更新迭代器、引用、指针。如果循环中调用 inserterase,可以根据返回值更新迭代器。

由于尾后迭代器经常失效,因此,如果在一个循环中插入/删除 dequestringvector 中的元素,不要缓存 end 返回的迭代器。通常 C++ 标准库的实现中 end() 操作都很快。

vector 对象如何增长

为支持快速随机访问,vector 将元素连续存储。而 vectorstring 的部分实现也渗透到接口中。为了降低重复分配和释放内存的代价,标准库实现者采用了可以减少容器空间重新分配次数的策略。

当不得不获取新的内存空间时,vectorstring 的实现通常会分配比新的空间需求更大的内存空间,具体分配多少额外空间视标准库具体实现而定。这种分配策略的实际性能也表现得足够好,使用此策略后,其扩张操作通常比 listdeque 更快。

管理容量的成员函数

vectorstring 类型提供了一些成员函数,用于和它的实现中内存分配部分互动。

capacity.png

  • capacity:容器在不扩张内存空间的情况下可以容纳的元素个数。
  • reserve:通知容器至少应容纳多少元素,它不改变容器中元素个数,只影响预先分配的内存空间。只有在所需的内存空间超过当前容量时,reserve 才会改变容量。reserve 不会减少容器占用的内存空间。
  • shrink_to_fit:请求 dequevectorstring 退回不需要的内存空间。但具体实现可以忽略此请求。

resize 只改变容器元素数目,不改变容器容量。

size 是容器已保存的元素数目,capacity 是在不分配新的内存空间的前提下最多可保存的元素数目。

每个 vector 实现都可以选择自己的内存分配策略,但必须遵守一条原则:只有迫不得已时才可以分配新的内存空间。分配策略虽然可以不同,但所有实现都应遵循一个原则:确保 push_back 的高效率操作,即通过在一个初始为空的 vector 上调用 n 次 push_back 来创建一个 vector 所花时间不能超过 n 的常数倍。

额外的 string 操作

除了顺序容器共同的操作之外,string 还提供了一些额外的操作。这些操作中大部分要么是提供 string 和 C 风格字符串之间的转换,要么是增加了允许用下标代替迭代器的版本。

构造 string 的其它方法

除了顺序容器都有的构造函数,以及使用 C 风格字符串的拷贝初始化和直接初始化之外,string 还支持另外三个构造函数:

string-constructor.png

当以 const char* 创建 string 时,可以传递一个可选的计数值指定要拷贝的字符个数,如果未传递计数值且数组未以空字符结尾,或者给定计数值大于数组大小,则构造函数的行为未定义。

当以 string 拷贝字符时,可以提供一个可选的开始位置和一个计数值。开始位置必须小于等于给定的 string 的大小,否则将抛出 out_of_range 异常。如果传递了一个计数值,则最多拷贝到 string 结尾。

string 的成员函数 substr 接收一个可选的开始位置和计数值,返回原始 string 的一部分或全部拷贝。如果开始位置超过了 string 的大小,则抛出 out_of_range 异常,若开始位置加上计数值大于 string 大小,则会调整计数值,只拷贝到 string 的末尾。

substr.png

改变 string 的其它方法

除了顺序容器的赋值运算符、assigninserterase 操作之外,string 还定义了额外的 inserterase 版本。除了接受迭代器之外,stringinserterase 还可以接受下标。string 还提供了接受 C 风格字符串、stringstring 子字符串的 insertassign 版本。

string-modify.png string-modify-1.png

string 类定义了两个额外的成员函数:appendreplace,用于改变 string 的内容。append 是在 string 末尾调用 insert 的一种简写形式。replaceeraseinsert 的一种简写形式。

appendassigninsertreplace 有多个重载版本,根据要添加的字符和 string 中被替换的部分,这些函数的参数有不同版本。这些函数有共同的接口。appendassign 无需指定要替换的部分:

  • assign 总是替换 string 中所有内容。
  • append 总是将新字符追加到 string 末尾。
  • replace 有两种指定删除元素范围的方式:位置和长度、迭代器范围。
  • insert 有两种定义插入点的方式:下标、迭代器。

指定要添加到 string 中的字符有多种方式:可以是另一个 string、字符指针(指向字符数组)、字符列表、字符和计数值。当字符来自 string 或字符指针时,可以传递一个额外的参数来控制是拷贝一部分还是全部字符。

不是每个函数都支持所有形式的参数。

string 搜索操作

string 提供了 6 个不同的搜索函数,每个函数都有 4 个重载版本。这些搜索操作的返回值类型为 string::size_type,表示匹配发生的下标。若搜索失败,返回静态成员 string::npos。标准库将其定义为 const string::size_type 类型,并初始化为 -1,由于是无符号类型,因此 npos 是任何 string 最大的可能大小。

string-find.png string-find-1.png

搜索(以及其它 string 操作)是大小写敏感的。find 操作还可以接受一个可选参数,指定开始搜索的位置。标准库还提供了类似的从右至左的搜索方法 rfindfind_last_offind_last_not_of

compare 函数

除了关系运算符之外,标准库 string 还提供了一组 compare 函数,与 C 标准库的 strcmp 函数类似,根据 s 等于、大于、小于参数指定的字符串,s.compare 返回 0、正数、负数。compare 有 6 个版本,可以比较整个或一部分字符串。

string-compare.png

数值转换

字符串中常包含表示数值的字符,字符串和数值是不同的。有多个标准库函数可以实现数值数据与标准库 string 之间的转换。

要转换为数值的 string 中第一个非空白符必须是数值中可能出现的字符。这些函数读取参数,处理其中的字符,直到遇到不可能是数值的一部分的字符。然后将找到的这个数值的字符串表示形式转换为对应的数值。

string 参数中第一个非空白符必须是符号 +/- 或数字,可以是 0x0X 开头表示的十六进制。对那些将字符串转为浮点数的函数,string 参数可以小数点 . 开头,也可以包含 eE 表示指数部分。对于那些将字符串转换为整型的函数,根据基数不同,string 参数可以包含字母字符,对应大于 9 的数字。

string-number-converter.png

如果 string 不能转换为一个数值,这些函数将抛出 invalid_argument 异常,如果转换得到的数值无法用任何类型表示,则抛出 out_of_range 异常。

容器适配器

除了顺序容器外,标准库还定义了三个顺序容器适配器:stackqueuepriority_queue适配器是标准库中一个通用概念,能使某种事物的行为看起来像另一种事物,容器、迭代器、函数都有适配器。容器适配器接受一种已有的容器类型,使其行为看起来像一种不同的类型。

adaptor.png

每个适配器都有两个构造函数:

  • 默认构造函数,创建一个空对象。
  • 接受容器的构造函数,拷贝该容器以初始化适配器,该容器的类型必须和适配器的容器类型参数相同。

默认情况下,stackqueue 基于 deque 实现,priority_queue 基于 vector 实现。也可以在创建适配器时通过一个命名的顺序容器类型参数,来指定底层容器类型。

stack<string, vector<string>> str_stk;

一个给定的适配器可以使用的容器是有限制的。所有适配器都要求容器具有添加、删除、访问尾元素的能力。因此,不能使用 arrayforward_list 构造适配器。

  • stack
    • 要求容器支持 push_backpop_backback
    • 可以使用容器 vectordequelist
  • queue
    • 要求容器支持 backpush_backfrontpop_front
    • 可以使用容器 listdeque,不能用 vector
  • priority_queue
    • 要求容器支持 frontpush_backpop_back、随机访问能力。
    • 可以使用容器 vectordeque,不能用 list

stack 定义在 stack 头文件中。queuepriority_queue 适配器定义在 queue 头文件中。

stack.png queue.png queue-1.png

原文中存在错误:queue 不能使用 vector 实现。queuepriority_queuepop 方法删除但不返回元素值。

标准库 queue 使用一种先进先出(first-in,first-out,FIFO)的存储和访问策略。进入队列的对象被放置到队尾,离开队列的对象从队首删除。

priority_queue 允许为队列中的元素建立优先级。新加入的元素排在所有优先级比它低的已有元素之前。默认情况下,标准库在元素类型上使用 < 运算符来确定相对优先级。

每个容器适配器都基于底层容器类型的操作定义自己的特殊操作。只能使用适配器操作,而不能使用底层容器类型的操作。