Generic Algorithm
标准容器库定义的操作集很小。标准容器库并未给每个容器添加大量功能,而是提供了一组算法,这些算法中大多数都独立于任何特定的容器。这些算法是通用的:它们可用于不同类型的容器和不同类型的元素。
本章主要介绍泛型算法和关于迭代器的更多细节。
对于前面介绍的顺序容器,它只定义了很少的操作:增删、访问首尾元素、确定容器是否为空、获得首和尾后元素位置的迭代器。
可以想象,除了上述的操作,用户还可能希望做其他有用的操作:查找特定元素、替换或删除一个特定的值、重排元素顺序等。
标准库并为针对每个容器都定义成员函数来实现这些操作,而是定义了一组泛型算法:
- 所谓算法,是因为它们都实现了一些经典算法的公共接口,如排序搜索
- 所谓泛型,是因为它们可以用不同类型的元素和多种容器类型
文章目录
概述
大多数算法都定义在头文件<algorithm>
中。标准库还在头文件<numeric>
中定义了一组数值泛型算法。
一般情况下,这些算法不直接操作容器,而是遍历由两个迭代器指定的一个元素范围。
关键概念:算法永远不会执行容器的操作
翻新算法本身不会执行容器的操作,它们只会作用于迭代器之上,执行迭代器的操作。泛型算法运行于迭代器之上,而不会执行容器操作,带来了一个非常必要的编程假设:泛型算法永远不会改变底层容器的大小。算法可能改变容器中保存的元素,也可能在容器内移动元素,但是永远不会直接添加或删除元素。
后面会介绍到标准库定义的一类特殊的迭代器,称为插入器(inserter),当算法操作一个这样的迭代器时,这个迭代器(插入器)可以完成向容器添加元素的效果,但算法自身永远不会做这样的操作。
初识泛型算法
标准库提供了超过100个算法,但是,这些算法有一致的结构,本节将介绍这些算法的统一原则。
除了少数算法外,标准库算法都对一个范围内的元素进行操作。我们将这个元素范围称为**“输入范围”,接受输入范围的算法总是用前两个参数来表示此范围,两个参数分别是指向要处理的第一个元素和尾后元素位置**的迭代器。
虽然大多数算法遍历输入范围的方式相似,但是它们使用范围中的元素的方式不同。理解算法的基本方法就是了解他们是否读取元素、改变元素或重排元素。
只读算法
对于只读取而不改变元素的算法,通常最好使用cbegin()
和cend()
。但是如果计划使用算法返回的迭代器来改变元素的值,就要使用begin()
和end()
的结果作为参数。
写容器元素的算法
关键概念:迭代器参数
一些算法从两个序列中读取元素。构成这两个序列的元素可以来自不同类型的容器。如,一个序列可能保存于一个vector,而第二个序列可能保存于一个list,deque或其他容器。而且两个序列中的元素类型不要求严格匹配。算法要求的只是能够比较两个序列中的元素。
操作两个序列的算法之间的区别在于我们如何传递第二个序列。一些算法,接受三个迭代器:前两个表示第一个序列范围,第三个表示第二个序列的收元素。其他算法接受四个迭代器:前两个表示第一个序列的元素范围,后两个表示第二个序列的范围。
用一个单一迭代器表示第二个序列的算法都假定第二个序列至少与第一个一样长。
确保算法不会试图访问第二个序列中不存在的元素是程序员的责任。
算法不检查写操作
向目的位置迭代器写入数据的算法假定目的位置足够大,能容纳要写入的元素。
back_inserter
一种保证算法有足够元素空间来容纳输出数据的方法是使用插入迭代器(insert iterator)。
插入迭代器是一种想容器添加元素的迭代器。
通常情况,通过一个迭代器向容器赋值时,值被赋予迭代器指向的元素。而当我们通过一个插入迭代器赋值时,一个与赋值号右侧值相等的元素被添加到容器中。
back_inserter接受一个指向容器的引用,返回一个与该容器绑定的插入迭代器。通过此迭代器赋值时,赋值运算符会调用push_back将一个具有给定值的元素添加到容器中。
常使用back_inserter来创建一个迭代器,作为算法的目的位置来使用。
如:
vector<int> vec; // 空向量
// 通过back_inserter创建一个插入迭代器,可用来向vec添加元素
fill_n(back_inserter(vec), 10, 0); // 添加10个0 到vec
// 而下面这个是错误的:
fill_n(vec.begin(), 10, 0); // 因为vec是个空向量
拷贝算法
拷贝(copy)算法是向目的位置迭代器指向的输出序列中的元素写入数据的算法。
此算法接受三个迭代器,前两个表示输入范围,第三个表示目的序列的起始位置。
auto ret = copy(src.begin(), src.end(), dst.begin())
copy返回的是目的位置迭代器(递增后)的值。ret指向dst拷贝的尾后元素。
多个算法都提供所谓的**“拷贝”版本**,这些算法计算新元素的值,创建一个新序列保存这些结果。
比如,replace算法读入一个序列,将输入序列中给定值替换为另一个值:
replace(list.begin(), list.end(), 0, 42); // 将0 都替换成42
但是上述操作是在list上修改的,如果我们想不改变原序列,那么可以用replace_copy,此算法接受额外第三个迭代器参数,指出调整后序列的保存位置:
replace_copy(ilst.cbegin(), ilst.cend(), back_insert(ivec), 0, 42);
上述调用,ilst并未改变,ivec保存了这份拷贝,只是其中的0都被替换成了42。
重排容器元素的算法
某些算法会重排容器中元素的顺序,使之有序,利用元素类型的<
运算符来实现排序。
使用unique:
auto end_unique = unique(words.begin(), words.end());
unique可以将相邻重复项“消除”,具体来说是用后面的不重复项进行覆盖。然后返回指向最后一个不重复元素的后一个元素的迭代器。
使用容器操作删除元素
为了删除无用元素,我们只能使用容器操作。比如使用c.erase()
。
定制操作
很多算法都会比较输入序列中的元素。默认情况下,这些算法使用元素类型的<或==运算符完成比较。标准库还为这些算法定义了额外的版本。允许我们提供自己定义的操作来代替默认运算符。
例如,sort算法默认使用元素类型的<运算符,但是我们坑你希望实现我们自己的排序顺序,那么需要重载sort的默认行为。
向算法传递函数
还是以sort举例子,我们想重载它,它接受第三个参数,所谓的谓词(predicate)。
谓词是一个可调用的表达式,其返回结果是一个能用作条件的值。标准库算法使用两类谓词:一元谓词和二元谓词(分别能接受单一参数和两个参数)。
接受谓词参数的算法对输入序列中的元素调用谓词。因此,元素类型必须能转换为谓词的参数类型。
lambda表达式
可以向算法传递任何类别的可调用对象。
一共有四种类型的可调用对象:
- 函数
- 函数指针
- 重载了函数调用运算符 的类
- lambda表达式
一个lambda表达式表示表示一个可调用的代码单元,可以将其理解为一个未命名的内联函数。
与函数类似,一个lambda表达式有一个返回类型、一个参数列表和一个函数体。但与函数不同的是,lambda可能定义在函数内部。
lambda表达形式如下:
[capture list](parameter list) -> return type {function body}
其中,capture list(捕获列表)是一个lambda所在函数中定义的局部变量的列表(通常为空)。
lambda必须使用尾置返回来指定返回类型。
我们可以忽略参数列表和返回类型,但必须包含捕获列表和函数体。
auto f = [] {return 42;};
上例中,定义了一个可调用对象f,它不接受参数,返回42.
在lambda中,忽略括号和参数列表 等价于 指定一个空参数列表。
如果忽略返回类型,lambda根据函数体中的代码推断出返回类型。如果函数体只是一个return语句,则返回类型从返回表达式的类型推断而来。否则返回类型为void。如果lambda的函数体包含任何单一return语句之外的内容,且未指定返回类型,则返回void。
向lambda传递参数
调用一个lambda时给定的实参被用来初始化 lambda形参。
与普通函数不同,lambda不能有默认参数。
因此,一个lambda调用的实参数目永远与形参数目相等。一旦初始化完毕,就可以执行函数体了。
因此可以编写一个与isShorter函数完全相同功能的lambda:
[](const string &a, const string & b) {return a.size()<b.size();}
空的捕获列表表明此lambda不使用它所在函数中任何局部变量。可以使用此lambda来调用stable_sort
:
stable_sort(words.begin(), words.end(), [](const string &a, const string & b) {return a.size()<b.size();});
使用捕获列表
lambda只能使用哪些明确指明的变量。一个lambda通过将局部变量包含在其捕获列表中来指出将会使用的变量。
一个lambda只有在其捕获列表中捕获一个它所在函数中的局部变量,才能在函数体中使用该变量。
捕获列表只用于局部非static变量,lambda可以直接使用局部static变量和在它所在函数之外声明的名字。
lambda捕获和返回
当定义一个lambda时,编译器生成一个与lambda对应的新的(未命名的)类类型。
当向一个函数传递一个lambda时,同时定义了一个新类型和该类型的一个对象:传递的参数就是此编译器生成的类类型的未命名对象。
类似的,使用auto定义一个用lambda初始化的变量时,定义了一个从lambda生成的类型的对象。
默认情况下,lambda生成的类都包含爱lambda所捕获的变量的数据成员,lambda数据成员在lambda对象被创建时初始化。
值捕获
类似参数传递,变量的捕获方式也可以是值或引用。
lambda采用值捕获的前提是变量可以拷贝。与参数不同的是,被捕获的变量的值在lambda创建时拷贝,而不是调用时拷贝:
void fcn1()
{
size_t v1 = 42; // 局部变量
// 将v1拷贝到名为f的可调用对象
auto f = [v1] {return v1;};
v1 = 0;
auto j = f(); // j为42, f保存了我们创建时v1的拷贝
}
由于被捕获变量的值是在lambda创建时拷贝,因此随后对其修改不会影响到lambda内对应的值。
引用捕获
void fcn1()
{
size_t v1 = 42; // 局部变量
// 对象f2包含对v1的引用
auto f2 = [&v1] {return v1;};
v1 = 0;
auto j = f2(); // j为0, f2保存v1的引用,而非拷贝
}
以引用方式捕获一个变量时,必须保证在lambda执行时变量是存在的。
建议:一般来说,我们应该尽量减少捕获的数据量,来避免潜在的捕获导致的问题。而且,如果可能的话,应该避免捕获指针或引用
隐式捕获
除了显式列出我们希望使用的来自所在函数的变量之外,我们还可以让编译器根据lambda体中的代码来推断我们要用哪些变量。
为了指示编译器进行推断来捕获列表,应该在捕获列表中写**&
或=
**,分别表示期望以引用或值捕获的方式。
可以混合使用隐式和显式捕获,但是混合时第一个元素必须是一个&
或=
。
可变lambda
默认n更快下,对一个值被拷贝的变量,lambda不会改变其值。但如果我们希望能改变一个被捕获的变量的值,必须在参数列表首加上mutable。
参数绑定
对于那种只在一两个地方使用的简单操作,lambda表达式是最有用的。但如果我们需要在很多地方使用相同的操作,那么就应该定义一个函数,而不是多次编写相同的lambda表达式。类似的,如果一个操作需要很多语句才能完成,通常使用函数更好。
如果一个lambda表达式捕获列表为空,那么可以用函数来替代它。
但对于捕获局部变量的lambda,用函数来替代就不是那么容易了。
标准库bind函数、ref,cref函数
再探迭代器
除了为每个容器定义的迭代器之外,标准库在头文件iterator中还定义了额外几种迭代器。包括以下几种:
- 插入迭代器(insert iterator):这些迭代器被绑定到一个容器上,可以来向容器插入元素。
- 流迭代器(stream iterator):这些迭代器被绑定到输入或输出流上,可以遍历所有关联的IO流。
- 反向迭代器(reverse iterator):
- 移动迭代器(move iterator):这些专用迭代器不是拷贝其中元素,而是移动它们。
插入迭代器
插入迭代器是一种迭代器适配器。它接受一个容器,生成一个迭代器,能实现向给定容器添加元素。
通过一个插入迭代器进行赋值时,该迭代器调用容器操作,来向给定容器指定位置插入一个元素。
这种迭代器支持的操作如下:
插入迭代器操作 | 说明 |
---|---|
it = t | 在it指定的当前位置插入值t。假定c是it绑定的容器,依赖于插入迭代器的不同种类,此赋值会分别调用c.push_back(t)、c.push_front(t)或c.insert(t,p),其中p为传递给inserter的迭代器位置 |
+it, ++it, it++ | 不会对it做任何事,每个操作都返回it |
插入器有三种类型,差异在于元素插入的位置:
- back_inserter
- front_inserter
- inserter
只有容器支持push_front或push_back才能使用对应的插入器。
反向迭代器
反向迭代器就是在容器中从尾元素向首元素反向移动的迭代器。对于反向迭代器,递增(以及递减)的操作的含义会颠倒过来。递增一个反向迭代器(++it)会移动到前一个元素。
可以调用rbegin,rend,crbegin,cren成员函数获取反向迭代器。
泛型算法结构
任何算法最基本的特性是它要求其迭代器提供哪些操作。
算法所要求的迭代器操作可以分为5个迭代器类别(iterator category)
每个算法都会对它的每个迭代器参数指明需提供哪类迭代器。
迭代器类别 | 说明 |
---|---|
输入迭代器 | 只读,不写;单向扫描,只能递增 |
输出迭代器 | 只写,不读;单向扫描,只能递增 |
前向迭代器 | 可读写;多遍扫描,只能递增 |
双向迭代器 | 可读写;多变扫描,可递增减 |
随机访问迭代器 | 可读写,多遍扫描,支持全部迭代器运算 |
算法还共享一组参数传递规范和一组命名规范。
5类迭代器
类似容器,迭代器也提供了一组公共操作。
迭代器是按它们所提供的操作来分类的,这种分类形成了一种层次。
算法形参模式
任何其他算法分类之上,有一组参数规范。大多数算法具有如下4种形式之1:
alg(beg, end, other args);
alg(beg, end, dest, other args);
alg(beg, end, beg2, other args);
alg(beg, end, beg2, end2, other args);
其中,alg是算法的名字,beg,end表示算法操作的输入范围。dest,beg2和end2都是迭代器参数。
接受单个目标迭代器的算法
dest参数是一个表示算法可以写入的目的位置的迭代器。算法假定:按其需要写入数据,不管写入多少个元素都是安全的。
向输出迭代器写入数据的算法都假定目标空间足够容纳写入数据。
接受第二个输入序列的算法
只接受单独beg2的算法假定从beg2开始的序列与beg和end所表示的范围至少一样大
算法的命名规范
除了参数规范,算法还遵循一套命名和重载规范。
这些规范处理诸如:如何提供一个操作代替默认的<
或==
运算符,以及算法是将输出数据写入输入数据还是一个分离的目的位置等。
一些算法使用重载形式传递一个谓词
_if版本的算法
区分拷贝元素的版本和不拷贝元素的版本
特定容器算法
与其他容器不同,链表型list和forward_list定义了几个成员函数形式的算法。通用版本的sort要求随机访问迭代器,因此不能用于这两个。它们定义了独有的sort,merge,remove,reverse和unique。
对于list和forward_list,应该优先使用成员函数版本的算法而不是通用算法。通用算法需要交换输入序列中的元素,而链表可以通过改变元素间的链接而不是真的交换它们的值来快速“交换”元素。因此,这些链表版本的算法性能比通用的版本好得多。
list和forward_list成员函数版本的算法 | 说明 |
---|---|
lst.merge(lst2) lst.merge(lst,comp) | 将来自lst2的元素合并入lst。lst和lst2必须是有序的。元素将从lst2中删除。合并之后,lst2会变空。 |
lst.remove(val) lst.remove_if(pred) | 调用erase删除指定元素 |
lst.reverse() | 反转元素顺序 |
lst.sort() lst.sort(comp) | 使用<或给定比较操作排序元素 |
lst.unique() lst.unique(pred) | 调用erase删除同一个值的连续拷贝。 |
splice成员
list和forward_list的splice成员函数的参数 | 说明 |
---|---|
lst.splice(args)或flst.splice_after(args) | |
(p, lst2) | |
(p, lst2, p2) | |
(p, lst2, b, e) |