Loading

【C++】《C++ Primer 》第十章

第十章 泛型算法

一、概述

  • 因为它们实现共同的操作,所以称之为"算法"。而"泛型",指的是它们可以操作在多种容器类型上。
  • 泛型算法并不直接操作容器,而是遍历由两个迭代器指定的一个元素范围来进行操作。
  • 头文件#include<algorithm> 或者 #include<numeric>(算数相关)。
  • 大多数算法是通过遍历两个迭代器标记的一段元素来实现其功能。
  • 关键概念:算法永远不会执行容器的操作。必要的编程假定算法永远不会改变底层容器的大小。算法可能改变容器中保存的元素的值,也可能在容器内移动元素,但不能直接添加或者删除元素。
// find

int val = 42;
auto result = find(vec.cbegin(), vec.cend(), val);
cout << "The value " << val << (result == vec.cend()) ? "is not find!" : "is find!" << endl;

// 由于指针就像内置数组上的迭代器一样,所以可以在数组中用find
int ia[] = {267, 32, 64, 64, 1, 6, 7};
int val = 1;
int* result = find(begin(ia), end(ia), val);

二、初识泛型算法

  • 标准库提供了超过100个算法,但这些算法有一致的结构。
  • 除少数例外,标准库算法都对一个范围内的元素进行操作。
  • 理解算法的最基本的方法是了解它们是否读取元素、改变元素以及重排元素顺序。

1. 只读算法

  • 主要包括 findaccumulateequal 等算法。只读算法只读取范围中的元素,不改变元素
  • 只读算法通常最好使用cbegin()和cend()。
  • accumulate算法的第三个参数的类型决定了函数中使用哪个加法运算符以及返回值的类型。且蕴含着一个编程设定:将元素类型加到和的类型上的操作是可行的。
// 对vec中的元素求和,和的初始值是0
int sum = accumulate(vec.cbegin(), vec.end(), 0);

// 将vec中的string元素连接起来
string sum = accumulate(v.cbegin(), v.end(), string(""));

// 错误的例子: const char * 没有定义+运算符
string sum = accumulate(vc.cbegin(), v.end(), "");

  • equal算法确定两个序列是否保存相同的值。编程设定:那些只接受一个单一迭代器来表示第二个序列的算法,都假定第二个序列至少和第一个序列一样长。
// r2中的元素数目至少与r1一样多
equal(r1.cbegin(), r1.cend(), r2.cbegin);

// 如果r1和r2保存的是C风格字符串而不是string,会发生什么?
// 答:equal使用==运算符比较两个序列中的元素。string类重载了==,所以可以比较两个字符串是否长度相等且其中元素对位相等。
// 而C风格字符串本质是 char* 类型,用==比较两个 char* 对象,只是检查两个指针值(地址)是否相等。

2. 写容器元素的算法

  • 主要包括 fillfill_nback_insertercopyreplacereplace_copy 等算法,这些算法将新值赋予给序列中的元素,但是不检查写操作。
  • fill算法:它接受一对迭代器表示一个范围,还接受一个值作为第三个参数。
// 将每个元素重置为0
fill(vec.begin(), vec.end(), 0); 
  • fill_n算法:它接受一个单迭代器、一个计数值和一个值。
// 将每个元素重置为0
fill_n(vec.begin(), vec.size(), 0);
  • back_inserter算法:定义在头文件 #include <iterator> 中,用来确保算法有足够的空间存储数据。它接受一个指向容器的引用,返回一个与该容器绑定的插入迭代器。
vector<int> vec; // 空向量
auto it = back_inserter(vec);
*it = 42;

// 通常用法,用它创建一个迭代器,作为算法的目的位置来使用
vector<int> vec1; // 空向量
fill_n(back_insert(vec1), 10, 0);    // 正确
fill_n(vec1, 10, 0);    // 错误
  • copy算法:向目的位置迭代器指向的输出序列中的元素写入数据的算法。它接受三个迭代器,前两个表示一个输入范围,第三个表示目的序列的起始位置。
int a1[] = {1, 2, 3, 4, 5, 6, 7, 8, 9};
int a2[sizeof(a1) / sizeof(*a1)];   // a2与a1一样大
auto ret = copy(begin(a1), end(a2), a2);
  • replace算法:读入一个序列,并将其中所有等于给定值的元素都改为另一个值。它接受四个参数,前两个是迭代器,表示输入序列,后两个一个是要搜索的值,另一个是新值。
// 将所有值为0的元素改为42
replace(lst.begin(), lst.end(), 0, 42);
  • replace_copy算法:如果希望原序列不变,可以使用它,它额外接受第三个迭代器参数,指出调整后序列的保存位置。
vector<int> lst_copy;
replace_copy(lst.begin(), lst.end(), back_inserter(lst_copy), 0, 42);

3. 重排容器元素的算法

  • 主要包括 sortunique 等算法,它们都会重排容器中元素的顺序。
  • sort算法:排序,默认递增排序。接受两个迭代器,表示要排序的元素范围。
  • unique算法:消除重复。使用之前要先调用sort,它返回的迭代器指向最后一个不重复元素之后的位置。顺序会变,重复的元素被“删除”,但不是真正的删除,容器大小没变,因为 算法永远不会改变底层容器的大小,所以要想真正删除必须使用容器操作。
// 使每个单词只出现一个

void elimDups(vector<string> &words) {  
    sort(words.begin(), words.end());   // 按字典序排序
    
    auto end_unique = unique(words.begin(), words.end());   // 返回指向不重复区域之后一个位置的迭代器
    
    words.erase(end_unique, words.end());   // 使用向量操作删除重复单词   
}

4. (泛型)算法不改变容器大小的原因

并不是算法应该改变或者不改变容器的问题,是因为为了实现与数据结构的分离,为了实现通用性,算法根本就不应该知道容器的存在。算法访问数据的唯一通道就是迭代器,是否改变容器大小,完全是迭代器的选择和责任。

三、定制操作

1. 向算法传递函数

  • sort算法的重载版本:它接受第三个参数,此参数是一个谓词(predicate)。
  • 谓词(predicate):它是一个可调用的表达式,其返回结果是一个能用作条件的值。
    • 一元谓词:意味着它们只接受单一参数。
    • 二元谓词:意味着它们有两个参数。
// 比较函数,用来按长度排序单词
bool isSorter(const string &s1, const string &s2) {
    return s1.size() < s2.size();
}

sort(words.begin(), words.end(), isSorter);
  • 标准库定义了名为 partition 的算法,它接受一个谓词,对容器内容进行划分,使得谓词为true的值会排在容器的前半部分,而使谓词为false的值会排在后半部分。它返回一个迭代器,指向最后一个使谓词为true的元素之后的位置。
//接受一个string,返回一个bool值,指出string是否有5个或更多字符。并打印出大于等于5的元素。

#include <iostream>
#include <fstream>
#include <vector>
#include <string>
#include <algorithm>

using namespace std;

inline void output_words(const vector<string>& words) {
    for(const auto& word: words) {
        cout << word << " ";
    }
    cout << endl;
}

inline void output_words(vector<string>::iterator beg, vector<string>::iterator end) {
    for(auto it = beg; it != end; ++it) {
        cout << *it << " ";
    }
    cout << endl;
}

bool five_or_more(const string &word) {
    return word.size() >= 5;
}


int main(int argc, char* argv[]) {
//    ifstream in (argv[1]);
    ifstream in("../ch10/words.txt");
    if(!in) {
        cout << "打开输入文件失败!" << endl;
        exit(1);
    }

    vector<string> words;
    string word;
    while(in >> word) {
        words.push_back(word);
    }
    output_words(words);
    auto it = partition(words.begin(), words.end(), five_or_more);
    output_words(words.begin(), it);

    return 0;
}

2. lambda表达式

  • 可以向一个算法传递任何类别的可调用对象(callable object)。对于一个对象或者一个表达式,如果可以对其使用调用运算符,则称它为可调用的。
  • 可调用对象:函数函数指针重载了函数调用运算符的类lambda表达式
  • 一个lambda表达式表示一个可调用的代码单元,可以将其理解成一个未命名的内联函数。
  • 与普通函数不同,lambda不能有默认参数
  • 形式[capture list](parameter list) -> return type {function body}
    • capture list 捕获列表是一个lambda所在函数定义的局部遍历的列表(通常为空)。不可忽略
    • return type 是返回类型。可忽略
    • parameter 是参数列表。可忽略
    • function body 是函数体。不可忽略
    • 例子:auto f = [] {return 42;}
  • find_if算法:接受一对表示范围的迭代器和一个谓词,用来查找第一个满足特定要求的元素。返回第一个使谓词返回非0值的元素。
auto wc = find_if(words.begin(), words.end(), [sz](const string &a){return a.size() >= sz;});
  • for_each算法:接受一个可调用对象,并对序列中每个元素调用此对象。
for_each(wc, words.end(), [](const string &s){cout << s << " ";});

3. lambda捕获和返回

  • 当定义一个lambda时,编译器生成一个与lambda对应的新的(未命名的)类类型。
  • 可以理解为:当向一个函数传递一个lambda时,同时定义了一个新类型和该类型的一个对象,传递的参数就是此编译器生成的类类型的未命名对象。
  • 默认情况下,从lambda生成的类都包含一个对应 该lambda所捕获的变量的数据成员,在lambda对象创建时被初始化。
  • 值捕获:与传值参数类似,采用值捕获的前提是变量可以拷贝。与参数不同,被捕获的变量的值是在lambda创建时拷贝,而不是调用时拷贝。
void func1 () {
    size_t v1 = 42; // 局部变量
    
    // 将v1拷贝到名为f的可调用对象
    auto f = [v1] { return v1; };
    v1 = 0;
    auto j = f();   // j为42,f保存了我们创建它时v1的拷贝
}
  • 引用捕获:必须保证在lambda执行时,变量是存在的。
void func2 {
    size_T v1 = 42; //  局部变量
    
    // 对象f2包含v1的引用
    auto f2 = [&v1] { return v1; };
    v1 = 0;
    auto j = f2();  // j为0,f2保存v1的引用,而非拷贝。
}
  • 应该尽量减少捕获的数据量,来避免潜在的捕获导致的问题。而且,应该避免捕获指针或引用。
  • 隐式捕获:让编译器推断捕获列表,在捕获列表中写一个 &(引用方式)=(值方式)
// 重写传递给find_if的lambda
wc = find_if(words.begin(),words.end(), [=](const string &s) {return s.size() >= sz;});
  • lambda捕获列表
捕获列表 解释
[] 空捕获列表。lambda不能使用所在函数中的变量。一个lambda只有在捕获变量后才能使用它们。
[names] names是一个逗号分隔的名字列表,这些名字都是在lambda所在函数的局部变量,捕获列表中的变量都被拷贝,名字前如果使用了&,则采用引用捕获方式。
[&] 隐式捕获列表,采用引用捕获方式。lambda体中所使用的来自所在函数的实体都采用引用方式使用。
[=] 隐式捕获列表,采用值捕获方式。
[&, identifier_list] identifier_list是一个逗号分隔的列表,包含0个或多个来自所在函数的变量。这些变量采用值捕获方式,而任何隐式捕获的变量都采用引用方式捕获。identifier_list中的名字前面不能使用&。
[=, identifier_list] identifier_list中的变量采用引用方式捕获,而任何隐式捕获的变量都采用值方式捕获。identifier_list中的名字不能包括this,且前面必须使用&。
  • 可变lambda:能改变一个被捕获的变量的值。它能省略参数列表。
void fun3 () {
    size_t v1 = 42; // 局部变量
    
    // f可以改变它所捕获的变量的值
    auto f = [v1]() mutable { return ++ v1; };
    v1 = 0;
    auto j = f(); // j为43
}
  • 指定lambda返回类型:当lambda体不是单一的return语句时,则需要指定。
// transform算法:将输入序列中每个元素替换为可调用对象操作该元素得到的结果。
// 它接受三个迭代器和一个可调用对象。前两个迭代器表示输入序列,第三个迭代器表示目的位置。

// 错误,不能推到lambda的返回类型
transform(v1.begin(), v1.end(), v2.begin(), [](int i) {if (i < 0) return -i; return i;});

// 正确,使用了尾置返回类型
transform(v1.begin(), v1.end(), v2.begin(), [](int i) -> int {if (i < 0) return -i; return i;});

4. 参数绑定

  • lambda表达式更适合在一两个地方使用的简单操作。
  • 如果很多地方使用相同的操作,还是需要定义函数。
  • 函数如何包装成一元谓词呢?使用参数绑定
  • 标准库bind函数
    • 它定义在 头文件functional 中, 可以看做为一个通用的函数适配器。它接受一个可调用对象,生成一个新的可调用对象来"适应"原对象的参数列表。
    • 调用bind的一般形式:auto newCallable = bind(callable, arg_list); 调用newCallable时,newCallable会调用callable,并传递给它arg_list中的参数。
    • _n代表第n个位置的参数。定义在placeholders的命名空间中。using std::placeholder::_1; auto g = bind(f, a, b, _2, c, _1);,调用g(_1, _2)实际上调用f(a, b, _2, c, _1)
    • 非占位符的参数要使用引用传参,必须使用标准库ref函数或者cref函数。
// bind函数接受几个参数
// bind是可变参数的。它接受的第一个参数是一个可调用对象,即实际工作函数A,返回供算法使用的新的可调用对象B。
// 若A接受x个参数,则bind的参数个数应该是x+1。

四、再探迭代器

  • 除了为每个容器定义的迭代器之外,标准库在 头文件iterator 还定义了几种迭代器:
    • 插入迭代器:这些迭代器被绑定到一个容器上,可用来向容器插入元素。
    • 流迭代器:这些迭代器被绑定到输入或输出流上,可用来遍历所关联的IO流。
    • 反向迭代器:这些迭代器向后而不是向前移动。除了forward_list之外的标准库容器都有反向迭代器。
    • 移动迭代器:这些专门的迭代器不是拷贝其中的元素,而是移动它们。

1. 插入迭代器

  • 插入迭代器是一种迭代器适配器,接受一个容器,生成一个迭代器,能实现向给定容器添加元素。
  • 三种类型,差异在于元素插入的位置
    • back_inserter:创建一个使用push_back的迭代器。
    • front_inserter:创建一个使用push_front的迭代器。
    • inserter:创建一个使用insert的迭代器。接受第二个参数,即一个指向给定容器的迭代器,元素会被插入到迭代器所指向的元素之前。
  • 注意:只有容器支持push_back的情况下,才能用back_inserter,其他同理。
  • 插入迭代器操作:
操作 解释
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
list<int> lst = {1, 2, 3, 4};
list<int> lst2, lst3, lst4;

// 拷贝完成之后,lst2包含 4 3 2 1
copy(lst.begin(), lst.end(), front_inserter(lst2));

// 拷贝完成之后,lst3和lst4都包含 1 2 3 4
copy(lst.begin(), lst.end(), back_inserter(lst3));
copy(lst.begin(), lst.end(), inserter(lst4, ls4.begin()));

2. iostream迭代器

  • 迭代器可与输入或输出流绑定在一起,用于迭代遍历所关联的 IO 流。
  • 通过使用流迭代器,可以用泛型算法从流对象中读取数据以及向其写入数据。
  • istream_iterator的操作
操作 解释
istream_iterator in(is); in从输入流is读取类型为T的值。
istream_iterator end; 读取类型是T的值的istream_iterator迭代器,表示尾后位置。
in1 == in2 in1和in2必须读取相同类型。如果他们都是尾后迭代器,或绑定到相同的输入,则两者相等。
in1 != in2 类似上条。
*in 返回从流中读取的值。
in->mem 与*(in).mem含义相同。
++in, in++ 使用元素类型所定义的>>运算符从流中读取下一个值。前置版本返回一个指向递增后迭代器的引用,后置版本返回旧值。
istream_iterator<int> in_iter(cin), eof;    // 从 cin 读取 int
vector<int> vec(in_iter, eof);  //从迭代器范围构造vec

// 使用算法操作流迭代器
istream_iterator<int> in_iter(cin), eof;
cout << accumulate(in, eof, 0) << endl; // 计算从标准输入读取的值的和
  • ostream_iterator的操作
操作 解释
ostream_iterator out(os); out将类型为T的值写到输出流os中
ostream_iterator out(os, d); out将类型为T的值写到输出流os中,每个值后面都输出一个d。d指向一个空字符结尾的字符数组。
out = val 用<<运算符将val写入到out所绑定的ostream中。val的类型必须和out可写的类型兼容。
*out, ++out, out++ 这些运算符是存在的,但不对out做任何事情。每个运算符都返回out。
// 输出值的序列
ostream_iterator<int> out_iter(cout, " ");
for(auto e: vec)
    *out_iter++ = e;    // 赋值语句实际上将元素写到cout上
cout << endl;

// 更简单的方法
copy(vec.begin(), vec.end(), out_iter);
cout << endl;

3. 反向迭代器

  • 反向迭代器就是在容器中从尾元素向首元素反向移动的迭代器。
  • 对于反向迭代器,递增和递减的操作含义会颠倒。
  • 实现向后遍历,配合rbegin和rend。

五、泛型算法结构

1. 5类迭代器

类别 解释 支持的操作
输入迭代器 只读,不写;单遍扫描,只能递增 ==,!=,++,*,->
输出迭代器 只写,不读;单遍扫描,只能递增 ++,*
前向迭代器 可读写;多遍扫描,只能递增 ==,!=,++,*,->
双向迭代器 可读写;多遍扫描,可递增递减 ==,!=,++,--,*,->
随机访问迭代器 可读写,多遍扫描,支持全部迭代器运算 ,!=,<,<=,>,>=,++,--,+,+=,-,-=,*,->,iter[n]*(iter[n])

2. 算法的形参模式

  • 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都是迭代器参数,是否使用要依赖于执行的操作。

3. 算法命名规范

  • 一些算法使用重载形式传递一个谓词。
  • 接受一个元素值的算法通常有一个不同名的版本:加_if,接受一个谓词代替元素值。
  • 区分拷贝元素的版本和不拷贝的版本:拷贝版本通常加_copy。

六、特定容器算法

  • 对于 listforward_list ,优先使用成员函数版本的算法而不是通用算法。
  • 链表特有的操作会改变容器
  • list和forward_list成员函数版本的算法:都返回void
操作 解释
lst.merge(lst2) 将来自lst2的元素合并入lst,二者都必须是有序的,元素将从lst2中删除。
lst.merge(lst2, comp) 同上,给定比较操作。
lst.remove(val) 调用erase删除掉与给定值相等(==)的每个元素。
lst.remove_if(pred) 调用erase删除掉令一元谓词为真的每个元素。
lst.reverse() 反转lst中元素的顺序。
lst.sort() 使用<排序元素。
lst.sort(comp) 使用给定比较操作排序元素。
lst.unique() 调用erase删除同一个值的连续拷贝。使用==。
lst.unique(pred) 调用erase删除同一个值的连续拷贝。使用给定的二元谓词。
  • list和forward_list的splice成员函数版本的参数:使用lst.splice(args)或flst.splice_after(args)
操作 解释
(p, lst2) p是一个指向lst中元素的迭代器,或者一个指向flst首前位置的迭代器。函数将lst2中的所有元素移动到lst中p之前的位置或是flst中p之后的位置。将元素从lst2中删除。lst2的类型必须和lst相同,而且不能是同一个链表。
(p, lst2, p2) 同上,p2是一个指向lst2中位置的有效的迭代器,将p2指向的元素移动到lst中,或将p2之后的元素移动到flst中。lst2可以是于lst或flst相同的链表。
(p, lst2, b, e) b和e表示lst2中的合法范围。将给定范围中的元素从lst2移动到lst或first中。lst2与lst可以使相同的链表,但p不能指向给定范围中的元素。
posted @ 2020-07-29 10:40  Parzulpan  阅读(172)  评论(0编辑  收藏  举报