C++ 学习笔记(五)(泛型算法篇)

前言:主要是自己学习过程的积累笔记,所以跳跃性比较强,建议先自学后拿来作为复习用。

1 概述

1.1 find 算法

顺序容器只定义了很少的操作,这显然不能满足程序员的使用需求。与此同时,标准库也并未给每个容器都定义了成员函数来实现更多的操作,而是定义了一组泛型算法。“算法” 意味着它们实现了一些经典算法的公共接口,比如排序和搜索;“泛型” 表明它们可以用于不同类型的元素多种容器甚至于内置类型

如果读者对于顺序容器不甚了解,可以参考: C++ 标准库类型学习笔记(三)(顺序容器篇)

大多数算法都定义在头文件 algorithm 中,标准库还在头文件 numeric 中定义了一组数值泛型算法。

通常来说,算法并不直接操作容器,而是遍历由两个迭代器指定的一个元素范围。比如可以用标准库算法 find 来查找容器中是否有某一个特定值:

vector<int> vec;
int value = 52;
auto result = find(vec.cbegin()), vec.end(), value);

find 函数接收两个迭代器一个值,在迭代器指定的范围内寻找该值。它返回指向第一个等于给定值的元素的迭代器,如果在范围内未找到,则返回第二个迭代器。因此可以将返回的迭代器和第二个参数做比较来判断是否查找成功。

find 可以应用于任何容器,甚至可以用来在内置数组中查找特定值:

int a[] = {1, 2, 3, 4, 5, 6, 7};
int val = 5;
int *result = find(begin(a), end(a), val);

find 用在容器上时返回的是迭代器, 用在内置数组上时返回的是指针。标准库 begin 和 end 函数返回数组 a 中首元素尾元素之后位置的指针,并传递给 find。当然,我们也可以传递容器或内置数组首尾之间的子范围给 find 函数,只需将子范围的首元素尾元素之后的迭代器(指针)传递给 find 就行。

由于迭代器和指针的存在,泛型算法并不依赖于容器。泛型算法本身不会执行容器的操作,它们只会运行于迭代器之上,执行迭代器的操作。这也意味着泛型算法可能会修改容器中元素的值,或者在容器内移动元素。但永远不会直接向容器添加或删除元素,更不可能改变底层容器的大小(这些操作由容器自带的成员函数来实现)。

2 初识泛型算法

2.1 只读算法

2.1.1 count 和 accumulate 算法

count 函数接受一对迭代器和一个值作为参数,返回给定值在范围中出现的次数

accumulate 函数定义在头文件 numeric 中,它接受一对迭代器和一个和的初始值,返回范围内所有元素与初始值的和。这三个算法都是只读算法,只读取输入范围内的元素而不改变。由于需要执行加法操作,因此范围内的元素类型与 accumulate 的第三个参数的类型需要一致,或者至少能够转换成第三个参数的类型。

同理,泛型算法的适用性使得它可以用于任何容器。比如用 accumulate 函数来将 vector 中 所有的 string 元素连接起来:

// 求和并不改变元素,所以传入常量迭代器,但也这会使得算法返回常量值
// 第三个参数显示地创建了一个 string 对象并初始化为空串,将其作为初始值
string sum = accumulate(vec.cbegin(), vec.cend(), string(""));
// 但是如果写成如下形式就会出错
string sum = accumulate(vec.cbegin(), vec.cend(), "");

上面的第二条语句会报错,原因在于不能直接将字符串字面值传递给 accumulate 函数。如果这样做了,此时初始值对象的类型会是 const char*,而 const char* 不能执行加法操作。

2.1.2 equal 算法

equal 也是一个只读算法,它用于确定两个范围内的元素是否完全一样。它将第一个范围内的每个元素,与第二个范围内对应的元素进行比较。只有全部相等才返回 true,否则返回 false。它接受三个迭代器,前两个迭代器指出第一个范围,第三个迭代器指向第二个范围的首元素:

// vec2 中从 cbegin 开始的元素数目应该至少与第一个范围内的元素数目一样多
equal(vec1.cbegin(), vec1.cend(), vec2.cbegin());

由于 equal 操作的是迭代器,所以可以调用 equal 比较两个不同类型容器的元素,甚至元素类型不同也没关系,只需要这些元素能用 “==” 来进行比较即可。比如上面的 vec1 可以是 vector< string>,vec2 可以是 list< const char*>。

但是有一个前提是一定要注意的,由于并不传入第二个范围的尾元素之后的迭代器,因此第二个范围至少要与第一个范围一样大,以此确保算法不会试图访问第二个范围内不存在的元素。许多算法都像 equal 一样接受这样三个迭代器,因此都需要满足这个前提

2.2 写算法

2.2.1 fill 和 fill_n 算法

使用写算法时,必须确保写入的范围 >= 调用函数时要求写入的元素的数目。算法不执行容器操作,因为它们无法改变容器的大小。比如 fill 函数接受一对迭代器和一个值,将给定的值赋给范围内的每个元素:

// 要改变元素的值,所以传入常规的迭代器
fill(vec.begin(), vec.end(), 0); 					// 将每个元素重置为0
fill(vec.begin(), vec.begin() + vec.end() / 2, 10);	// 将容器的一个子序列重置为0

一些写入算法会接受一个迭代器来指定一个开始位置。比如 fill_n 函数接受一个迭代器、一个计数值和一个值,将给定值赋给迭代器指向的元素开始的指定个元素。切忌不要在空容器上调用 fill_n。同理,调用 fill_n 前也要确保从迭代器指向的元素开始有足够的空间供写入

2.2.2 back_inserter 算法

使用插入迭代器可以保证算法有足够的元素空间来进行写入,它是一个向容器中添加元素的迭代器。它定义在头文件 iterator 中,接受一个指向容器的引用,返回一个与该容器绑定的插入迭代器。通过此迭代器赋值时,赋值运算符会调用 push_back 将一个元素添加到容器中:

vector<int> vec;
auto it = back_iterator(vec);		// 通过它赋值会将元素添加到 vec 中
*it = 52;							// vec 中现在有一个元素,值为52
fill_n(back_iterator(vec), 10, 0);	//	添加10个元素到 vec

向 fill_n 传递的是 back_inserter 返回的迭代器,因此每次赋值都会在 vec 上调用 push_back。

2.2.3 copy 和 replace 算法

经典的拷贝算法如 copy 函数接受三个迭代器,前两个迭代器指出第一个范围(输入范围),第三个迭代器是第二个范围的首元素(目的位置)。它将输入范围内的元素拷贝到第三个迭代器指定的元素开始的位置。同样的,目的位置开始的范围要 >= 输入范围:

int a1[] = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
int a2[sizeof(a1) / sizeof(*a1)];			// a2 与 a1 大小一样
// ret 指向拷贝到 a2 的尾元素之后的的位置
auto ret = copy(begin(a1), end(a1), a2);	// 把 a1 的内容拷贝给 a2

sizeof(a1) 返回的是数组在内存中的大小(数组所有元素的大小之和),sizeof(*a1) 返回的是 a1数组首元素的大小,因此两者相除即为数组的容量大小。

另一个拷贝算法 replace 函数接受4个参数:前两个是迭代器,第三个是要搜索的值,最后一个是新值。它将迭代器指定的范围中所有等于搜索值的元素改为新值:

replace(list.begin(), list.end(), 0, 52); // 将所有值为0的元素改为52

如果不希望改变原容器,可以使用 replace_copy 函数。它接受额外第三个迭代器参数,指向在第二个容器上开始拷贝的位置:

// 使用 back_inserter 按需要增长目标序列
replace_copy(list.cbegin(), list.cend(), back_inserter(vec), 0, 52);

调用该函数后,原容器 list 中的元素并未改变,而 vec 包含了 list 的一份拷贝,只不过原来在 list 中为0的值在 vec 都变成了52。

2.3 重排算法

2.3.1 sort 算法

某些算法会重新排列容器中的元素,比如 sort 函数。默认情况下,它利用元素类型的 < 运算符来实现排序,换句话说就是将元素按从小到大的顺序(字典序)进行排列。比如下面一段话:
t h e    q u i c k    r e d    f o x    j u m p s    o v e r    t h e    s l o w    r e d    t u r t l e the\ \ quick\ \ red\ \ fox\ \ jumps\ \ over\ \ the\ \ slow\ \ red\ \ turtle the  quick  red  fox  jumps  over  the  slow  red  turtle
若将其所有单词作为一个 string 对象存入 vector 中,然后利用 sort 函数进行排序:

vector<string> words{"the", "quick", "red", "fox", "jumps", "over", "the", "slow", "red", "turtle"};
sort(words.begin(), words.end());

排序后就会生成如下序列:
在这里插入图片描述

2.3.2 unique 算法

如果希望消除 words 中的重复单词,首先要将 words 进行排序,使得重复的单词相邻出现,这一需求在上一小节已经实现了。然后可以利用 unique 算法来重排 words,使得不重复的元素出现在 vector 的开头部分。因为算法不能执行容器的操作,所以删除元素的操作由容器自己来实现:

void function(vector<string> &words)
{
	sort(words.begin(), words.end()); 		// 按字典序排序 words,以便查找重复单词
	auto end_unique = unique(words.begin(), words.end());
	words.erase(end_unique, words.end());	// 删除尾部的重复单词
}

unique 返回一个指向最后一个不重复元素之后位置的迭代器,调用 unique 对 words 重排后如下图所示:
在这里插入图片描述

由此可见,words 的大小并未改变,只不过是元素的顺序被改变了。重复的元素被移动到了 words 的尾部,但是具体移到了哪个位置并不清楚,所以打上了问号。接着调用 words 自身的 erase 操作来删除尾部的重复元素。如果容器没有重复的元素,返回的就是尾后迭代器,即 words.end()。将其传给 erase 也没关系,因为这意味着删除的范围是空的,这并不会报错。

3 定制自己的操作

3.1 向算法传递函数

3.1.1 谓词

接着引用2.3.2节中的例子。如果我们希望在调用 function 函数后打印 words 的内容,此外还希望单词按其长度排序,大小相同的再按字典序排序。为此我们需要重载 sort 函数的行为,只需向它传递第三个参数,即谓词

谓词是一个可调用的表达式,其返回结果是一个能用做条件的值。有两类谓词:一元谓词只接受一个参数,二元谓词接受两个参数。接受谓词的算法对范围内的所有元素调用谓词,因此元素类型必须能转换为谓词的参数类型。比如要让 sort 函数按照单词长度由短至长排序单词,可以这么写:

// 比较函数,用来按长度排序单词
bool isShorter(const string &s1, const string &s2)
{
	return s1.size() < s2.size();
}
// 按长度由短至长排序 words
sort(words.begin(), words.end(), isShorter);

3.1.2 stable_sort 算法

前面提到,不仅要按长度排序,我们还希望具有相同长度的元素按大小(字典序)排序。单纯的 sort 函数只能实现其中一个需求。但使用stable_sort 函数,也即稳定排序算法就可以同时实现这两个需求:

// 将 words 按字典序重排,并消除掉重复单词
function(words);
// 按长度重新排列后,再将长度相同的按字典序排列
stable_sort(words.begin(), words.end(), isShorter);

排序后的结果如图所示:
在这里插入图片描述

3.1.3 lambda 表达式

3.1.3.1 lambda 的定义和 find_if 算法

接着上小节的程序,并增添两个新需求:求出大于等于给定长度的单词有多少,打印大于等于给定长度的单词。

在前面,我们将所有的单词按照长度进行了排序,因此只需要找到第一个大于等于给定长度的元素, 就能得知有多少单词符合要求。find_if 函数接收一对迭代器,以及一个谓词。它对范围内的所有元素调用谓词,返回指向第一个使谓词返回非0值的元素的迭代;若不存在这样的元素则返回尾迭代器。假设给定长度是4,则谓词如下:

bool isLonger(const string &s)
{	// 如果单词长度大于等于4返回1,否则返回0
	return s.size() >= 4 ? 1 : 0;
}

但是问题就来了,传递给谓词的实参,是算法中迭代器范围内的元素,程序员自己不能向谓词传递其他参数。如果我们想重新指定一个长度,岂不是每次都需要修改谓词?因此谓词不再满足我们的需求,此时可以用 C++ 中的另一个特性来解决——lambda 表达式。

类似于谓词,一个 lambda 表达式表示一个可以调用的代码单元,可以将其理解为一个未命名的内联函数,但它可以定义在函数的内部。lambda 有返回类型、参数列表和函数体,一般形式如下:

[capture list](parameter list) -> return type { function body }

capture list (捕获列表),是 lambda 表达式所在函数局部变量的列表,通常为空。后面三个依次是参数列表、返回类型和函数体。lambda 必须使用尾置返回类型。lambda 表达式可以忽略参数列表和返回类型,但必须包含捕获列表和函数体:

// 定义一个可调用对象 f,不接受参数,返回52
auto f = [] { return 52; };
// lambda 的调用方式与普通函数一样
cout << f() < endl;

关于尾置返回类型的知识可以参考:C++ 函数学习笔记3.6.2 使用尾置返回类型

如果忽略返回类型,lambda 会根据函数体的代码自动推断返回类型。如果函数体有 return 语句,则根据 return 语句来推断。否则返回类型为 void。

3.1.3.2 传递参数

调用 lambda 时传递的实参数量必须与形参数量相同(lambda 没有默认实参)。因此我们可以用 lambda 改写之前的 isShorter 函数:

[](const string &a, const string &b) { return a.size() < b.size(); }

空的捕获列表表明 lambda 不使用它所在函数的任何局部变量。将 lambda 替换掉之前 stable_sort 函数中调用的谓词即可得到:

// 按长度重新排列后,再将长度相同的按字典序排列
// 当 stable_sort 需要比较两个元素的长度大小时,就会自动调用 lambda
stable_sort(words.begin(), words.end(), 
	[](const string &a, const string &b) { return a.size() < b.size(); });
3.1.3.3 使用捕获列表、for_each 算法

尽管 lambda 可以出现在一个函数中并使用它的局部变量,但它只能使用在捕获列表中明确指明的局部变量。假设给定的长度是局部变量 sz,可以这么写:

// 在捕获列表中指明局部变量时,才能在函数体内使用它
[sz](const string &a) { return a.size() >= sz; };

从而我们可以改写前面的 find_if 函数:

// 获取一个迭代器,指向第一个满足 size() >= sz 的元素
// 使用 lambda 查找第一个长度大于等于 sz 的元素
auto wc = find_if(words.begin(), words.end(),
	[sz](const string &a) { return a.size() >= sz; });
// 计算大于等于给定长度单词的个数
auto count = words.end() - wc;
// 打印所有大于等于给定长度的单词,每个单词后跟一个空格
for_each(wc, words.end(), [](const string &a) { cout << s << " "; });
cout << endl;

for_each 函数接受一对迭代器和一个可调用的对象,并对范围内的所有元素调用此对象。lambda 可以不用在捕获列表中声明而直接使用局部的 static 变量它所在函数之外声明的变量。比如上述代码中 lambda 就使用了定义在头文件 iostream 中的 cout(cout 是 C++ 定义的全局对象)。

最终代码:

void function(vector<string> &words)
{
	sort(words.begin(), words.end()); 		// 按字典序排序 words,以便查找重复单词
	auto end_unique = unique(words.begin(), words.end());
	words.erase(end_unique, words.end());	// 删除尾部的重复单词
}
void finalCode(vector<string> &words, vector<string>::size_type sz)
{
	function(words);
	// 按长度重新排列后,再将长度相同的按字典序排列
	// 当 stable_sort 需要比较两个元素的长度大小时,就会自动调用 lambda
	stable_sort(words.begin(), words.end(), 
		[](const string &a, const string &b) { return a.size() < b.size(); });
	// 获取一个迭代器,指向第一个满足 size() >= sz 的元素
	// 使用 lambda 查找第一个长度大于等于 sz 的元素
	auto wc = find_if(words.begin(), words.end(),
		[sz](const string &a) { return a.size() >= sz; });
	// 计算大于等于给定长度的单词的个数
	auto count = words.end() - wc;
	// 打印所有大于等于给定长度的单词,每个单词后跟一个空格
	for_each(wc, words.emd(), [](const string &a) { cout << s << " "; });
	cout << endl;
}

3.1.2 lambda 捕获和返回

3.1.2.1 值捕获和引用捕获

当我们定义一个 lambda 时,编译器会生成一个与 lambda 对应的未命名的类。因此向函数传递一个lambda 时,实际上完成了两个工作:定义了一个新的类该类型的一个对象,传递的参数就是编译器生成的类类型的未命名对象。lambda 的数据成员在 lambda 对象创建时被初始化

类似参数传递,变量的捕获方式分为值捕获引用捕获。采用值捕获的前提是变量可以拷贝,但它们是在 lambda 对象被创建时拷贝,而不是调用 lambda 时拷贝的(所以 lambda 有别于普通的函数),也因此在 lambda 中使用的是局部变量的拷贝,对拷贝的修改并不会影响到被捕获的变量。采用引用捕获的话,在 lambda 中使用的实际上就是局部变量:

void function()
{
	int v1 = 52;	// 局部变量
	// 将 v1 拷贝到名为 f1 的可调用对象,采用值捕获
	auto f1 = [v1]{ return v1; };
	v1 = 0;			// 修改 v1 的值
	auto i = f();	// i 依然为52,因为 f1 保存了创建它时拷贝的 v1 的值

	int v2 = 52;	// 局部变量
	// 将 v2 拷贝到名为 f2 的可调用对象,采用引用捕获
	auto f2 = [&v2]{ return v2; };
	v2 = 0;			// 修改 v2 的值
	auto j = f2();	// j 为0,因为 f2 保存的是 v2 的引用,对 v2 的修改会影响到 f2
}

3.1.2.2 隐式捕获

我们也可以不显示地(不明确地)列出捕获的局部变量,而是在捕获列表中写上 “&” 或者 “=”,来隐式地告诉编译器该 lambda 中的局部变量采用引用捕获还是值捕获。

lambda 捕获列表
[ ]空捕获列表。lambda 不能使用所在函数的局部变量。一个 lambda 只有捕获了变量后才能使用它们
[names]names 是一个逗号分隔的名字列表,这些名字都是 lambda 所在函数的局部变量。默认情况下,捕获列表采用值捕获。如果变量名字前使用了 &,则采用引用捕获方式
[&]隐式捕获列表,采用引用捕获方式。lambda 体中所使用的所有局部变量都采用引用捕获
[=]隐式捕获列表,采用值捕获方式。lambda 体中所使用的的所有局部变量都采用值捕获
[&, identifier_list]identifier_list 是一个逗号分隔的列表,包含0个或多个来自所在函数的变量。这些变量采用值捕获方式,而任何隐式捕获的变量都采用引用方式获取。identifier_list 中的名字前不能使用 &
[=, identifier_list]identifier_list 中的变量都采用引用捕获方式,而任何隐式捕获的变量都采用值捕获方式。identifier_list 中的名字不能包括 this,且这些名字之前必须使用 &

比如:

[&, b](int c) { a = b - c; }	// a 采用隐式的引用捕获,b 采用显示的值捕获
[=, &b](int c) { a = b - c; }	// a 采用隐式的值捕获,b 采用显示的引用捕获

要使谁成为隐式的,就让谁在前面,有且只有一种方式能使用隐式捕获,不能两个同时采用隐式。采用显示捕获的变量,必须完整的写出来。

3.1.2.3 可变 lambda

在 lambda 函数体内对采用值捕方式得到的变量进行修改会报错。但如果在 lambda 的参数列表后加上关键字 mutable,就能修改它的值。

void function()
{
	int i = 52;		// 局部变量
	// f 可以改变它所捕获的变量的值
	auto f = [i]() mutable { return ++i; };
	i = 0;
	auto j = f();	// j 为53
}

如上所示,读者可能会疑惑为什么 j 是53。在 “3.1.2.1 值捕获和引用捕获” 小节中我们提到,lambda 对象在定义时被创建。所以此处的值捕获,相当于将 i 的值52拷贝给了 lambda 对象中的一个未命名变量。lambda 函数体内的语句只有在 lambda 被调用时才执行,因此尽管在 lambda 外的 i 被修改成了0,调用 f 返回的值与它并无关系。返回的是 lambda 对象中未命名变量+1后的值,所以 j 的值是53而不是1;

如果采用引用捕获会出现什么结果呢?

void function()
{
	int i = 52;		// 局部变量
	// f 可以改变它所捕获的变量的值
	auto f = [&i]() mutable { return ++i; };
	i = 0;
	auto j = f();	// j 为1
}

此时 lambda 中的未命名变量就等价于其所在 function 函数内的局部变量 i,调用 f 时返回的就是 i + 1 后的结果,所以 j 的值是1。要弄清楚这两者的区别。

3.1.2.4 指定 lambda 的返回类型和 transform 算法

默认情况下,如果 lambda 函数体包含 return 之外的任何语句,编译器都会假定该 lambda 返回 void。如下面的例子:

// 该 lambda 的作用是返回传入参数的绝对值
transform(vec.begin(), vec.end(), vec.begin(),
	[](int i) { return i < 0 ? -i : i; });

transform 函数接受三个迭代器和一个可调用对象,它对前两个迭代器指定范围内的每一个元素调用 lambda,并将其结果写入到第三个迭代器指定的开始位置之后。此处的指定位置可以等于第一个迭代器,等价于对容器自身某一范围内的元素做一个替换。此处的 lambda 没有指定返回类型,因为编译器可以根据条件运算符来推断。但如果将其写成如下形式则会产生编译错误:

transfom(vec.begin(), vec.end(), vec.begin(),
	[](int i) { if (i < 0) return -i; else return i; });

在此情况下,编译器会认为 lambda 返回 void,但实际上它返回了一个 int 值。所以要使编译器知道它返回的类型,就需要使用尾置返回类型:

transfom(vec.begin(), vec.end(), vec.begin(),
	[](int i) -> int { if (i < 0) return -i; else return i; });

3.1.3 参数绑定

3.1.3.1 标准库 bind 函数

lambda 适用于只在一两个位置使用的简单操作、或者是需要用到函数内局部变量的地方。如果需要在很多地方使用相同的操作、或者说该操作需要很多语句才能完成,通常定义一个函数更好。回顾 “3.1.3.1 lambda 的定义和 find_if 算法” 这一小节,我们想定义一个函数来完成 string 和一个给定大小的比较,可以这么写:

bool check_size(const string &s, string::size_type sz)
{	// 如果单词长度大于等于 sz 返回1,否则返回0
	return s.size() >= sz;
}

但是我们在那里也提到了,find_if 只接受一元谓词,check_size 函数无法作为参数传入。所以我们引入了 lambda 来解决这个问题。在这里我们给出另一个解决方案:使用一个新的名为 bind 的标准库函数,它定义在头文件 functional 中。它接受一个可调用对象,生成一个新的可调用对象来“适应”原对象的参数列表。其一般形式为:

auto newCallable = bind(callable, arg_list);

newCallable 是一个可调用对象,arg_list 是一个逗号分隔的参数列表。当我们调用 newCallable 时,它又会调用 callable,并向它传递 arg_list 中的所有参数。

arg_list 中的参数可能有形如 _n 的名字,n 是一个整数,表明其是 newCallable 的第几位的参数,因此它们被称为 “占位符”。_1 为 newCallable 的第一个参数,_2 为第二个参数,以此类推。假定 f 是一个可调用对象,它接受5个参数:

// 定义一个新的可调用对象 
auto g = bind(f, a, b, _2, c, _1);

上面对 bind 的调用会生成一个新的可调用对象 g。因为 bind 中只有两个占位符,所以 g 只接受两个参数。如果这样调用 g:

g(X, Y);

因为 X 是 g 的第一个参数,所以它传给占位符 _1,而 Y 传给占位符 _2。其余的值则相当于默认实参传给 f,因此接下来 g 会这么调用 f:

f(a, b, Y, c, X);
3.1.3.2 用 bind 重排参数顺序

调换占位符的顺序是有其现实意义的。比如在 “3.1.1 谓词” 中提到的排序:

// 比较函数,用来按长度排序单词
bool isShorter(const string &s1, const string &s2)
{
	return s1.size() < s2.size();
}
// 按单词长度由短至长排序
sort(words.begin(), words.end(), isShorter);
// 按单词长度由长至短排序
sort(words.begin(), words.end(), bind(isShorter, _2, _1));

在第一个调用中,sort 要比较元素 A 和 B 时会调用谓词 isShorter(A, B)。在第二个调用中,参数的顺序被交换了,此时调用的实际是 isShorter(B, A)。

3.1.3.3 绑定 check_size 的 sz 参数

3.1.3.1节的例子,我们要使用 bind 来生成一个调用 check_size 的对象,它用一个给定值作为其大小参数来调用 check_size。如下所示,此 bind 调用只有一个占位符,表示 newCheck 只接受一个参数,且对应 check_size 的第一个参数,因此要传入一个 const string &。:

bool check_size(const string &s, string::size_type sz)
{	// 如果单词长度大于等于 sz 返回1,否则返回0
	return s.size() >= sz;
}
// newCheck 是一个可调用对象,接受一个 string 类型的参数
// newCheck 用 string 和值6来调用 check_size
auto newCheck = bind(check_size, _1, 6);
string s = "hello";
bool b = newCheck(s);	// newCheck(s) 会调用 check_size(s, 6)

因此可以将原来基于 lambda 的 find_if 调用改写成如下形式:

auto wc = find_if(words.begin(), words.end(),
	[sz](const string &a) { return a.size() >= sz; })
// 等价语句如下
auto wc = find_if(words.begin(), words.end(),
	bind(check_size, _1, sz);

当 find_if 对 words 中的 string 调用这个 bind 时,bind 又会调用 check_size,然后将给定的 string 和 sz 传递 check_size。从而实现范围内的所有 string 大小与 sz 的比较。

4 再探迭代器

标准库在头文件 isterator 中定义了额外的几种迭代器:

  • 插入迭代器:用来向容器插入元素。
  • 流迭代器:用来遍历所有关联的 IO 流。
  • 反向迭代器:从容器末尾向容器头移动。
  • 移动迭代器:移动容器中的元素。

4.1 插入迭代器

插入迭代器操作
*it = t在 it 指定的当前位置插入值 t。假定 c 是 it 绑定的容器,依赖于插入迭代器的不同种类,此赋值会分别调用 c.push_back(t)、c.push_front(t) 或 c.insert(p, t) ,其中 p 为传递给 inserter 的迭代器位置
*it,++it,it++这些操作虽然存在,但不会对 it 做任何事情。每个操作都返回 it

插入迭代器有三种类型:

  • back_inserter 创建一个使用 push_back 的迭代器,该容器必须支持 push_back 操作。
  • front_inserter 创建一个使用 push_front 的迭代器,该容器必须支持 push_front 操作。
  • inserter 创建一个使用 insert 的迭代器。此函数接受第二个参数,这个参数必须是一个指向给定容器的迭代器,元素将被插入到给定迭代器所指向的元素之前

关于容器操作的知识,可以参考:C++ 标准库类型学习笔记(三)(顺序容器篇)4.1 添加元素

调用 inserter(c, iter) 会返回一个迭代器,接下来使用它时,会将元素插入到 iter 原来所指向的元素之前的位置。如果 it 是由 inserter 生成的迭代器,则:

*it = value;
// 该语句等价于下面的两条语句
it = c.insert(it, value);	// it 指向新加入的元素
++it;						// 递增 it 使它指向原来的元素

front_inserter 生成的迭代器的行为则与 inserter 生成的迭代器完全不一样,前者总是将元素插入到容器的第一个元素之前

list<int> lst1 = {1, 2, 3, 4};
list<int> lst2, lst3;	// 空 list
// 拷贝完成后 lst2 包含 4 3 2 1
copy(lst.begin(), lst.end(), front_inserter(lst2));
// 拷贝完成后 lst3 包含 1 2 3 4
copy(lst.begin(), lst.end(), inserter(lst3, lst3.begin()));

调用 front_inserter( c) 时会得到一个插入迭代器,假定为 it,接下来会调用 push_front。每当一个元素被插入到容器 c 中首元素之前时,它就成为了新的首元素。因此用 front_inserter 生成的迭代器执行插入操作会使得元素的顺序颠倒。而 inserter 和 back_inserter 生成的迭代器则不会。

4.2 iostream 迭代器

尽管 iostream 类型不是容器,但标准库依然定义了可以用于这些 IO 类型对象的迭代器。流迭代器将对应的流当做一个序列进行处理,通过流迭代器可以用泛型算法从流对象读取数据以及向其写入数据。

4.2.1 istream_iterator

创建流迭代器时必须指定读写的对象类型,istream_iterator 使用 >> 来读取流。创建一个 istream_iterator 时可以将它绑定到一个流。如果不绑定,则为默认初始化一个流,这样做可以创建一个不指向任何元素的尾后迭代器

// 下面是创建流迭代器的几个例子
istream_iterator<int> it1(cin);		// 从 cin 读取 int
istream_iterator<int> eof;			// 尾后迭代器
ifstream in("file");
istream_iterator<string> it2(in);	// 从 "file" 读取字符串

下面是用一个 istream_iterator 从标准输入读取数据并存入 vector 的例子:

istream_iterator<int> it(cin);		// 从 cin 读取 int
istream_iterator<int> eof;			// 尾后迭代器
while (it != eof) 
	vec.push_back(*it++);

在这里插入图片描述
如上图所示,it 不停解引用然后自增,直到它等于尾后迭代器时停止循环,从而将流中所有整数存入 vector 里。对于一个绑定到流的迭代器,一旦其关联的流遇到文件尾或遇到 IO 错误,迭代器的值就与尾后迭代器相等。

对于上述例子,有个更简便的写法:

istream_iterator<int> it(cin), eof;	// 从 cin 读取 int
vector<int> vec(it, eof);			// 由迭代器范围构造 vec

从迭代器关联的流中读取数据,一直读到文件尾或者一个不是 int 的数据就停止构造 vec。

istream_iterator 操作
istream_iterator< T> it(is);it 从输入流 is 读取类型为 T 的数据
istream_iterator< T> end;读取类型为 T 的值的 istream_iterator 迭代器,表示尾后位置
in1 == in2in1 和 in2 必须读取相同类型。如果他们都是尾后迭代器,或绑定到相同的输入,则两者相等
in1 != in2同上
*in返回从流中读取的值
in->mem与 (*in).mem 的含义相同,mem 是成员
++in,in++递增迭代器

流迭代器也是迭代器,泛型算法操作的就是迭代器,所以某些算法也能操作流迭代器。比如用一对 istream_iterator 来调用 accumulate:

istream_iterator<int> it(is), eof;
cout << accumulate(it, eof, 0) << endl;

4.2.2 ostream_iterator

创建一个 ostream_iterator 时,可以提供第二个参数,一个 C 风格的字符串(一个字符串字面常量或者一个指向以空字符结尾的字符数组指针)。在输出每个元素后都会打印此字符串。必须将 ostream_iterator 绑定到一个指定的流,不允许空的或表示尾后位置的 ostream_iterator。

ostream_iterator 操作
ostream_iterator< T> out(os);out 将类型为 T 的值写到输出流 os 中
ostream_iterator< T> out(os, d);out 将类型为 T 的值写到输出流 os 中,每个值后面都输出一个 d。d 指向一个空字符结尾的字符数组
out = value用 << 运算符将 value 写入到 out 所绑定的 ostream 中。value 的类型必须与 out 可写的类型兼容
*out,++out,out++这些运算符都是存在的,但不对 out 做任何事情。每个运算符都返回 out

可以用 ostream_iterator 来输出序列:

ostream_iterator<int> out(cout, " ");
for (auto i : vec)
	*out++ = i;		// 赋值语句实际上将元素写到 cout

此程序将 vec 中的每个元素写到 cout,每个元素后加一个空格。每次向 out 赋值时,写操作就会被提交。实际上,我们可以忽略解引用和递增运算,即 *out++ = i 和 out = i 其实是等价的,但是这样写会使得程序更易懂。通过调用 copy 来打印 vec 比用 for 循环更简单:

copy(vec.begin(), vec.end(). out);

4.3 反向迭代器

反向迭代器就是在容器中从尾元素向首元素反向移动的迭代器。除了 forward_list 之外,其他容器都支持反向迭代器。可以通过调用 rbegin、rend、crbegin 和 crend 成员函数来获取反向迭代器,前两个为 const 版本,后两个为非 const 版本。

由下面的图片可以看出,当我们用一个普通迭代器初始化一个反向迭代器时,或者是给反向迭代器赋值时,结果迭代器与原迭代器指向的并不是相同的元素。但是它们的范围是一样的,比如 vec.begin(),vec.end() 指出的范围,和 vec.rbegin(),vec.rend() 指出的范围是一致的,都是整个序列的所有元素。普通迭代器和反向迭代器的关系也反映了左闭右开区间的特性。

在这里插入图片描述
递增一个反向迭代器(++it)会移动到前一个元素,递减一个反向迭代器(- -it)会移动到后一个元素。要注意的是,流迭代器不支持递减操作,因为不可能在一个流中反向移动。反向迭代器的递增和递减具有很多现实作用,比如可以通过向 sort 传递一对反向迭代器来将 vector 整理为递减序:

sort(vec.begin(), vec.end());	// 按正常序排列 vec
sort(vec.rbegin(), vec.rend());	// 按逆序排列 vec,最小的元素在末尾

5 泛型算法结构

接下来的内容基本都是 C++ prime plus上的了,属于了解即可的部分。

算法所要求的迭代器操作可以分为5个迭代器类别

迭代器类别
输入迭代器只读,不写;単遍扫描,只能递增
输出迭代器不读,只写;単遍扫描,只能递增
前向迭代器可读,可写;多遍扫描,只能递增
双向迭代器可读,可写;多遍扫描,可增可减
随机访问迭代器可读,可写;多遍扫描,支持全部迭代器运算

5.1 迭代器类别

5.1.1 输入迭代器

输入迭代器可以读取序列中的元素。一个输入迭代器必须支持:

  • 用于比较两个迭代器的相等和不相等运算符(==、!=)。
  • 用于推进迭代器的前置和后置递增运算(++)。
  • 用于读取元素的解引用运算符(*);解引用只会出现在赋值运算符的右侧
  • 箭头运算符(->),等价于 (*it) .member,即解引用迭代器,并提取对象的成员。

输入迭代器只用于顺序访问。对于一个输入迭代器,*it++ 保证是有效的,但递增它可能导致所有其他指向流的迭代器失效。其结果就是,不能保证输入迭代器的状态可以保存下来并用来访问元素。因此,输入迭代器只能用于单遍扫描算法。算法 find 和 accumulate 要求输入迭代器;而 istream_iterator 是一种输入迭代器。

5.1.2 输出迭代器

输出迭代器可以看作输入迭代器功能上的补集,它只写而不读元素。 输出迭代器必须支持:

  • 用于推进迭代器的前置和后置递增运算(++)。
  • 解引用运算符(*);只出现在赋值运算符的左侧(向一个已经解引用的输出迭代器赋值,就是将值写入它所指向的元素)。

只能向一个输出迭代器赋值一次。类似输入迭代器,输出迭代器只能用于单遍扫描算法。用作目的位置的迭代器通常都是输出迭代器。例如,copy 函数的第三个参数就是输出迭代器。ostream_ iterator 类型也是输出迭代器。

5.1.3 前向迭代器

前向迭代器可以读写元素。这类迭代器只能在序列中沿一个方向移动。前向迭代器支持所有输入和输出迭代器的操作,而且可以多次读写同一个元素。因此,我们可以保存前向迭代器的状态,使用前向迭代器的算法可以对序列进行多遍扫描。算法 replace 要求前向迭代器,forward_list 上的迭代器也是前向迭代器。

5.1.4 双向迭代器

双向迭代器可以正向/反向读写序列中的元素。除了支持所有前向迭代器的操作之外,双向迭代器还支持前置和后置递减运算符(- -)。 算法 reverse 要求双向迭代器,除了 forward_list 之外,其他标准库都提供符合双向迭代器要求的迭代

5.1.5 随机访问迭代器

随机访问迭代器提供在常量时间内访间序列中任意元素的能力。此类达代器支持双向迭代器的所有功能,此外还支持:

  • 用于比较两个迭代器相对位置的关系运算符(<、<=、> 和 >=)。
  • 迭代器和一个整数值的加减运算(+、+=、- 和 -=),计算结果是迭代器在序列中前进(或后退)给定整数个元素后的位置。
  • 用于两个迭代器上的减法运算符(-),得到两个迭代器的距离。
  • 下标运算符(iter[n]),与 *(iter[n]) 等价。

算法 sort 要求随机访问迭代器。array、deque、 string 和 vector 的迭代器都是随机访问迭代器,用于访问内置数组元素的指针也是。

5.2 算法形参模式

在任何其他算法分类之上,还有一组参数规范。理解这些参数规范对学习新算法很有帮助。通过理解参数的含义,你可以将注意力集中在算法所做的操作上。大多数算法具有如下4种形式之一:
a l g ( b e g i n ,   e n d ,   o t h e r a r g s ) ; a l g ( b e g i n ,   e n d ,   d e s t ,   o t h e r a r g s ) ; a l g ( b e g i n ,   e n d ,   b e g i n 2 ,   o t h e r a r g s ) ; a l g ( b e g i n ,   e n d ,   b e g i n 2 ,   e n d 2 ,   o t h e r a r g s ) ; alg (begin,\ end,\ other args) ; \\alg(begin,\ end,\ dest,\ other args) ; \\alg(begin,\ end,\ begin2,\ other args); \\alg(begin,\ end,\ begin2,\ end2,\ other args) ; alg(begin, end, otherargs);alg(begin, end, dest, otherargs);alg(begin, end, begin2, otherargs);alg(begin, end, begin2, end2, otherargs);

其中 alg 是算法的名字,begin 和 end 表示算法所操作的输入范围。几乎所有算法都接受一个输入范围,是否有其他参数依赖于要执行的操作。dest、begin2 和 end2 都是迭代器参数。它们分别承担指定目的位置第二个范围的角色。除了这些迭代器参数,一些算法还接受额外的、非迭代器的特定参数。

5.2.1 接受单个目标迭代器的算法

dest 参数是一个表示算法可以写入的目的位置的迭代器。算法假定:按其需要写入数据,不管写入多少个元素都是安全的。

向输出迭代器写入数据的算法都假定目标空间足够容纳写入的数据。

如果 dest 是一个直接指向容器的迭代器,那么算法将输出数据写到容器中已存在的元素内。更常见的情况是,dest 被绑定到一个插入迭代器或是一个 ostream iterator。插入迭代器会将新元素添加到容器中,因而保证空间是足够的。ostream_ iterator 会将数据写入到一个输出流,同样不管要写入多少个元素都没有问题。

5.2.2 接受第二个输入序列的算法

如果一个算法接受 begin2 和 end2,那么这两个迭代器表示第二个范围。 这类算法接受两个完整指定的范围:[begin, end) 表示的范围和 [begin2, end2) 表示的第二个范围。

只接受单独的 begin2(不接受 end2)的算法将 begin2 作为第二个输入范围中的首元素。此范围的结束位置未指定,这些算法假定从 begin2 开始的范围与 begin 和 end 所表示的范围至少一样大。

5.3 算法命名规范

除了参数规范,算法还遵循一套命名和重载规范。这些规范处理诸如:如何提供一个操作代替默认的 < 或 == 运算符以及算法是将输出数据写入输入序列还是一个分离的日的位置等问题。

5.3.1 使用重载形式传递一个谓词

接受谓词参数来代替 < 或 == 运算符的算法,以及那些不接受额外参数的算法,通常都是重载的函数。函数的一个版本用元素类型的运算符来比较元素;另一个版本接受一 个额外谓词参数,来代替 < 或 ==:

unique (beg, end);		// 使用 == 运算符比较元素
unique (beg, end, cmp);	// 使用 cmp 比较元素

两个调用都重新整理给定序列,将相邻的重复元索删除。第一个调用使用元素类型的 == 运算符来检查重复元素;第二个则调用 cmp 来确定两个元素是否相等。由于两个版本的函数在参数个数上不相等,因此具体应该调用哪个版本不会产生歧义。

5.3.2 _if 版本的算法

接受一个元素值的算法通常有另一个不同名的(不是重载的)版本,该版本接受一个谓词代替元素值。接受谓词参数的算法都有附加的 _if 前缀:

find(beg, end, value);		// 查找输入范围中 value 第一次出现的位置
find_if(beg, end, pred);	// 查找第一个令 pred 为真的元素

这两个算法都在输入范围中查找特定元素第一次出现的位置。算法 find 查找一个指定值;算法 find_if 查找使得 pred 返回非零值的元素。

这两个算法提供了命名上差异的版本,而非重载版本,因为两个版本的算法都接受相同数目的参数。因此可能产生重载歧义,虽然很罕见,但为了避免任何可能的歧义,标准库选择提供不同名字的版本而不是重载。

5.3.3 区分拷贝元素的版本和不拷贝的版本

默认情况下,重排元素的算法将重排后的元素写回给定的输入序列中。这些算法还提供另一个版本, 将元素写到一个指定的输出目的位置。写到额外目的空间的算法都在名字后面附加一个 _copy。

reverse(beg, end);				// 反转输入范围中元素的顺序
reverse_copy(beg, end, dest);	// 将元素按逆序拷贝到 dest

一些算法同时提供 copy 和 _if 版本。这些版本接受一个目的位置迭代器和一个谓词:

// 从 v1 中删除奇数元素
remove_ if(v1.begin(), v1.end(), [](int i) { return i % 2; )};
// 将偶数元素从 v1 拷贝到 v2;v1 不变
remove_copy_if(v1.begin(), v1.end(), back_inserter(v2),
	[](int i) { return i % 2; });

两个算法都调用 lambda 来确定元素是否为奇数。在第一个调用中,我们从输入序列中将奇数元素删除。在第二个调用中,我们将偶数元素从输入范围拷贝到 v2 中。

6 特定容器算法

6.1 list 和 forward_ list 版本的算法

与其他容器不同,链表类型 list 和 forward_list 定义了几个成员函数形式的算法,如表所示。特别是,它们定义了独有的 sort、merge、remove、reverse 和 unique。通用版本的 sort 要求随机访问迭代器,因此不能用于 list 和 forward_list,因为这两个类型分别提供双向迭代器和前向迭代器。

链表类型定义的其他算法的通用版本可以用于链表,但代价太高。这些算法需要交换输入序列中的元素。一个链表可以通过改变元素间的链接而不是真的交换它们的值来快速“交换”元素。因此,这些链表版本的算法的性能比对应的通用版本好得多。

对于 list 和 forward_list,应该优先使用成员函数版本的算法而不是通用

list 和 forward_ list 成员函数版本的算法(这些操作都返回void):

lst.merge(lst2)将来自 lst2 的元素合并入 lst。lst 和 lst2 都必须是有序的;
lst.merge(lst2, cmp)(接上)元素将从 lst2 中删除。在合并之后,lst2 变为空。上面的版本使用 < 运算符;下面的版本使用给定的比较操作
lst.remove(value)调用 erase 删除掉与给定值相等(==)或令一元谓词为真的每个元素
lst.remove_if(pred)同上
lst.reverse()反转 lst 中元素的顺序
lst.sort()使用 < 或给定比较操作排序元素
lst.sort(comp)同上
lst.unique()调用 erase 删除同一个值的连续拷贝。第一个版本使用 ==;第二个版本使用给定的二元谓词
lst.unique(pred)同上

6.2 splice 成员

链表类型还定义了splice 算法,其描述见表。此算法是链表数据结构所特有的,因此不需要通用版本。

list 和 forward_ list 的 splice 成员函数的参数:

lst.splice(args) 或 flst.splice_after(args)
(p, lst2)p 是一个指向 lst 中元素的迭代器,或一个指向 flst 首元素之前位置的迭代器。函数将 lst2 的所有元素移动到 lst 中 p 之前的位置或是 flst 中 p 之后的位置。将元素从 lst2 中删除。lst2 的类型必须与 lst 或 flst 相同,且不能是同一个链表
(p, lst2, p2)p2 是一个指向 lst2 中位置的有效的迭代器。将 p2 指向的元素移动到 lst 中,或将 p2 之后的元素移动到 flst 中。lst2 可以是与 lst 或 flst 相同的链表
(p, lst2, b, e)b 和 e 必须表示 lst2 中的合法范围。将给定范围中的元素从 lst2 移动到 lst 或 flst。lst2 与 lst(或 flst)可以是相同的链表,但 p 不能指向给定范围中元素

6.3 链表特有的操作会改变容器

多数链表特有的算法都与其通用版本很相似,但不完全相同。链表特有版本与通用版本间的一个至关重要的区别是链表版本会改变底层的容器。例如 remove 的链表版本会删除指定的元素。unique 的链表版本会删除第二个和后继的重复元素。

类似的,merge 和splice会销毁其参数。例如,通用版本的 merge 将合并的序列写到一个给定的目的迭代器;两个输入序列是不变的。而链表版本的 merge 函数会销毁给定的链表——元素从参数指定的链表中删除,被合并到调用 merge 的链表对象中。在 merge 之后,来自两个链表中的元素仍然存在,但它们都已在同一个链表中。


希望本篇博客能对你有所帮助,也希望看官能动动小手点个赞哟~~。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值