chapter10_泛型算法

泛型算法

顺序容器只是定义了很少的操作,并未给每个容器都定义成员函数来实现一些功能,而是定义了一组泛型算法,作为一些经典算法的公共接口。

概述

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

一般情况下,这些算法并不直接操作容器,而是遍历由两个迭代器指定过的一个元素范围来进行操作 。例如查找vector中是否包含特定值,使用find算法:

cpp 复制代码
#include <iostream>
#include <vector>
#include <algorithm>

int main()
{
    int val = 10;

    vector<int> v = {0, 2, 5, 10, 5};

    auto res = find(v.begin(), v.end(), val);
    return 0;
}

传递给find的前两个参数是表示元素范围的迭代器,第三个参数是一个值。其返回指向第一个等于给定值的元素的迭代器。

初识泛型算法

只读算法

顾名思义,一些算法只会读取其输入范围内的元素,而从不改变元素,例如find,这种算法就称为只读算法

此外,还有一个算法accumulate,定义在numeric中,其接受三个参数:前两个指出了需要求和的元素的范围,第三个参数是和的初值,示例如下:

cpp 复制代码
#include <iostream>
#include <vector>
#include <numeric>
#include 

int main()
{
    std::vector<int> val1 = {0, 1, 2, 3, 4, 5};

    int sum1 = accumulate(val1.cbegin(), val2.cend(), 0);

    int sum2 = accumulate(val1.cbegin(), val2.cend(), 10);

    std::cout << sum1 << "\n";
    std::cout << sum2 << "\n";
    return 0;
}
  • 算法和元素类型

accumulate将第三个参数作为求和起点,这蕴含着一个编程假定:将元素类型加到和的类型山东个操作必须是可行的。即,序列中元素的类型必须与第三个参数匹配,或者能够转换为第三个参数的类型。

因此,对于string类型,accumulate也是可行的:

cpp 复制代码
#include <iostream>
#include <vector>
#include <string>
#include <numeric>

int main()
{
    std::vector<std::string> val2 = {"hello", ",", "world", "!"};

    std::string res = accumulate(val2.begin(), val2.end(), std::string(""));

    // 不可行 ""是字符串字面值,类型为 const char*
    // std::string res = accumulate(val2.begin(), val2.end(), ""); 

    std::cout << res << "\n";
    
    return 0;
}
  • 操作两个序列的方法

还有一个只读算法equal,用于确定两个序列是否保存相同的值。其将第一个序列中的每个元素与第二个序列中的对应元素进行比较。如果所有对应元素相等,则返回true,否则返回false

其接受三个迭代器,前两个表示第一个序列中的元素范围,第三个表示第二个序列的首元素,并且,equal基于一个非常重要的假定:其假定第二个序列至少与第一个序列一样长。

cpp 复制代码
#include <iostream>
#include <vector>
#include <algorithm>

int main()
{
    std::vector<int> val1 = {0, 1, 2, 3, 4};
    std::vector<int> val2 = {0, 1, 2, 3, 4};
    std::vector<int> val3 = {0, 1, 3, 5, 5};
    std::vector<int> val4 = {0, 1, 2, 3, 4, 5, 6, 7};

    bool res2 = equal(val1.begin(), val1.end(), val2.begin());
    bool res3 = equal(val1.begin(), val1.end(), val3.begin());
    bool res4 = equal(val1.begin(), val1.end(), val4.begin());

    std::cout << res2 << "\n"; // true
    std::cout << res3 << "\n"; // false
    std::cout << res4 << "\n"; // true

    return 0;
}

写容器的算法

  • 算法不检查写操作

一些算法接受一个迭代器来指出一个单独的目的位置。这些算法将新值赋予一个序列中的元素,该序列从目的位置迭代器指向的元素开始。例如,fill_n接受一个单迭代器、一个计数值和一个值。其将给定值赋予迭代器指向的元素开始的指定个元素。

cpp 复制代码
#include <iostream>
#include <vector>

int main()
{
    std::vector<int> val = {1, 2, 3, 4};

    fill_n(val.begin(), val.size(), 0); // 将所有元素置为0

    return 0;
}

若在空容器上调用fill_n,会报错。

  • 介绍back_inserter

一种保证算法有足够元素空间来容纳输出数据的方法是使用插入迭代器 ,其定义在头文件iterator中。

back_insterer接受一个指向容器的引用,返回一个与该容器绑定的插入迭代器。当我们通过此迭代器赋值时,赋值运算符会调用push_back将一个具有给定值的元素添加到容器中:

cpp 复制代码
#include <iostream>
#include <vector>
#include <iterator>


int main()
{
    std::vector<int> vec;

    auto it = back_inserter(vec);
    *it = 42;

    std::cout << *vec.begin() << "\n";

    return 0;
}
  • 拷贝算法

拷贝(copy)算法是另一个向目的位置迭代器指向的输出序列中的元素写入数据的算法。此算法接受三个迭代器:前两个表示一个输入范围,第三个表示目的序列的起始位置。此算法将输入范围中的元素拷贝到目的序列中。传递给copy的目的序列至少要包含与输入序列一样多的元素。

cpp 复制代码
int a1[] = {0, 1, 2, 3, 4};
int a2[sizeof(a1)/sizeof(*a1)];

auto ret = copy(begin(a1), end(a1), a2); // 把a1的内容拷贝给a2

重排容器的算法

sort会重排输入序列中的元素,进行排序。

cpp 复制代码
vector<int> vec = {2, 5, 3, 6, 4, 7};

sort(vec.begin(), vec.end());
  • 消除重复元素

为了消除重复元素,首先将vector排序,从而可以使用unique来重拍vector,最后进行删除操作:

cpp 复制代码
#include <iostream>
#include <vector>
#include <algorithm>

int main()
{
        std::vector<int> vec = {7, 5, 4, 6, 4, 7, 8, 1, 2, 8};

        std::cout << vec.size() << "\n";
        sort(vec.begin(), vec.end());

        auto end_unique = unique(vec.begin(), vec.end());

        vec.erase(end_unique, vec.end());

        std::cout << vec.size() << "\n";

        return 0;
}

定制操作

向算法传递函数

标准库为一些算法定义了额外的版本,允许我们提供自己定义的操作来代替默认运算符。

例如sort,其接受第三个参数,此参数是一个谓词(predicate)

  • 谓词

谓词 是一个可调用的表达式,其返回结果是一个能用作条件的值

标准库算法所使用的谓词分为两类:一元谓词 (unary predicate,意味着它们只接受单一参数)和二元谓词(binary predicate,意味着它们有两个参数)。

lambda表达式

  • 介绍lambda

我们可以像一个算法传递任何类别的可调用对象 。对于一个对象或一个表达式,如果可以对其使用调用运算符,则称它为可调用的

一个lambda表达式表示一个可调用的代码单元。我们可以将其理解为一个未命名的内联函数。与其他函数类似,一个lambda具有一个返回类型、一个参数列表和一个函数体。但与函数不同,lambda可能定义在函数内部。一个lambda表达式具有如下形式:

复制代码
[capture list](parameter list) -> reture type { function body }

其中,capture list (捕获列表)是一个lambda所在函数中定义的局部变量的列表(通常为空);return type、parameter listfunction body 与其他普通函数一样,分别表示返回类型、参数列表和函数体。但是,与普通函数不同,lambda必须使用尾置返回来指定返回类型。

我们可以忽略参数列表和返回类型,但必须永远包含捕获列表和函数体:

cpp 复制代码
auto f = [] { return 42; };
  • lambda传递参数

与普通函数类似,调用一个lambda时给定实参被用来初始化lambda的形参。通常,实参和形参的类型必须匹配。但与普通函数不同,lambda不能有默认参数。因此,一个lambda调用的实参数目永远与形参数目相等。

如下实例:

cpp 复制代码
[](const string &a, const string &b){
    return a.size() < b.size();
}

空捕获列表表明此lambda不使用它所在函数中的任何局部变量。lambda的参数是const string的引用,函数体是比较两个参数的size(),并根据两者的相对大小返回一个bool值。

我们可以使用此lambda来实现调用stable_sort

cpp 复制代码
vector<string> words = {"hello", "world", "red", "blue"};
stable_sort(words.begin(), words.end(), 
            [](const string &a, const stringf &b)
                {
                    return a.size() < b.size();
                });
  • 使用捕获列表

捕获列表存在多种使用方法。如下,我们的lambda会捕获sz变量,并只有单一的string参数:

cpp 复制代码
int sz = 5;

[sz](const string &a){
    return a.size() >= sz;
};
  • 调用find_if

使用上述的lambda,我们就可以查找第一个长度大于等于sz的元素:

cpp 复制代码
auto wc = find_if(words.begin(), words.end(),
                [sz](const string &a){
                    return a.size() >= sz;
                });

这里对find_if的调用返回一个迭代器,指向第一个长度不小于给定参数sz的元素,如果这样的元素不存在,则返回words.end()的一个拷贝。

  • for_each()算法

for_each()算法接受一个可调用对象,并对输入序列中每个元素调用此对象,用于打印words中长度大于等于sz的元素:

cpp 复制代码
for_each(wc, words.end(), [](const string &s){cout << s << " ";});
cout << endl;

lambda捕获和返回

当定义一个lambda时,编译器生成一个与lambda对应的新的(未命名的)类类型。也就是说,当向一个函数传递一个lambda时,同时定义一个新类型和该类型的一个对象:传递的参数就是此编译器生成的类类型的未命名对象。类似的,当使用auto定义一个用lambda初始化的变量时,定义了一个从lambda生成的类型的对象。

默认情况下,从lambda生成的类都包含一个对应该lambda所捕获的变量的数据成员。类似任何普通类的数据成员,lambda的数据成员也在lambda对象创建时被初始化。

  • 值捕获

类似参数传递,变量的捕获方式也可以是值或引用。采用值捕获的前提是变量可以拷贝。与参数不同,被捕获的变量的值是在lambda创建时拷贝,而不是调用时拷贝:

cpp 复制代码
#include <iostream>
#include <vector>

int main()
{

    int v1 = 10;
    // 值捕获
    auto f = [v1] { return v1; }; // = auto f = [v1]() -> int{return v1;};
    v1 = 0;
    int i = f();
    std::cout << v1 << "  "  << i << "\n"; // i为10,f保存了我们创建它时v1的拷贝

    return 0;
}
  • 引用捕获

我们定义lambda时可以采用引用方式捕获变量,如下:

cpp 复制代码
#include <iostream>
#include <vector>

int main()
{

    // 引用捕获
    int v2 = 20;
    auto f1 = [&v2] { return v2; };
    v2 = 22;
    int j = f1();
    std::cout << v2 << " " << j << "\n"; //j为22,f1保存v1的引用,而非拷贝

    return 0;
}

v2前的&字符指出v2以引用方式捕获。

引用捕获与返回引用有着相同的问题和限制。如果我们采用引用方式捕获一个变量,就必须确保被引用的对象在lambda执行的时候是存在的。lambda捕获的都是局部变量,这些变量在函数结束后就不复存在了。

  • 隐式捕获

除了显式列出我们希望使用的来自所在函数的变量之外,还可以让编译器根据lambda体中的代码来推断我们要使用哪些变量。

为了指示编译器推断捕获列表,应在捕获列表中写一个&=&告诉编译器采用捕获引用方式,=表示采用值捕获方式。

cpp 复制代码
#include <iostream>

int main()
{
    int v = 0;
    auto f = [=] { return v; };
    v = 1;
    int k = f();
    std::cout << k << std::endl; 

    return 0;
}

如果我们希望对一部分变量采用值捕获,对其他变量采用引用捕获,可以混合使用隐式捕获和显式捕获:

cpp 复制代码
#include <iostream>

int main()
{
    int v1 = 1, v2 = 2;

    auto f1 = [=, &v2] { return v2; };

    v2 = 22;
    auto i = f1();
    std::cout << i << std::endl; // i = 22

    auto f2 = [&, v1] { return v1; };

    v1 = 11;
    auto j = f2(); // j = 1
    std::cout << j << std::endl;

    return 0;
}

当我们混合使用 隐式捕获和显式捕获时,捕获列表中的第一个元素必须是一个&=。此符号指定了默认捕获方式为引用或值。

  • 可变lambda

默认情况下,对于一个值被拷贝的变量,lambda不会改变其值。如果我们希望能改变一个被捕获的变量的值,就必须在参数列表首加上关键字mutable。因此,可变lambda能省略参数列表:

cpp 复制代码
#include <iostream>
#include <vector>

int main()
{

        // 可变lambda
        int v3 = 30;
        auto f3 = [v3] () mutable { return ++v3; };
        v3 = 33;
        auto k = f3(); // k 为31
        std::cout << k << "\n";
        return 0;

}

一个引用捕获的变量是否可以修改依赖此引用指向的是一个const类型还是一个非const类型。

参数绑定

  • 标准库bind函数

bind函数定义在头文件functional中,其接受一个可调用对象,生成一个新的可调用对象来"适应"元对象的参数列表:

调用bind的一般形式为:

cpp 复制代码
auto newCallable = bind(callable, arg_list);

其中,newCallabel本身是一个可调用对象,arg_list是一个逗号分隔的参数列表,对应给定的callabel的参数。即,当我们调用newCallable时,newCallable会调用callabel,并传递给它arg_list中的参数。示例如下:

cpp 复制代码
#include <iostream>
#include <functional>

int main()
{
    // 定义一个求和函数
    auto f = [](int a, int b){ return a + b; };

    // 生成一个调用f的对象
    auto new_f = std::bind(f, 3, 4);
    int k = new_f();
    std::cout << k << std::endl;
    return 0;
}

此外,arg_list中的参数可能包含形如_n的名字,其中n是一个整数。这些参数是占位符 ,表示newCallable的参数,它们占据了传递给newCallable的参数的位置。示例如下:

cpp 复制代码
#include <iostream>
#include <functional>


int main()
{
int main()
{
    auto f = [](int a, int b) {
        return a + b;
    };

    using std::placeholders::_1;
    auto new_f = std::bind(f, _1, 4);

    int k = new_f(5);

    std::cout << k << std::endl; // 9

    return 0;
}
}
  • 使用placeholders名字

名字_n都定义在一个名为placeholders的命名空间中,这个命名空间本身定义在std命名空间,因此,_1对应的using声明类似上述:

cpp 复制代码
using std::placeholders::_1;

对每一个占位符名字,都必须提供一个单独的using声明。当然,我们也可以直接定义整个命名空间,而不限制名字:

cpp 复制代码
using namesapce std::placeholders;

再探迭代器

除了为每个容器定义的迭代器之外,标准库在头文件iterator中还定义了额外几种迭代器。

  • 插入迭代器(insert iterator)

这些迭代器被绑定到一个容器上,可用来像容器插入元素

插入迭代器有三种类型:back_inserter创建一个使用push_back的迭代器;front_inserter创建一个使用push_front的迭代器;insterter创建一个使用insert的迭代器,此函数接受第二个参数,这个参数必须是一个指向给定容器的迭代器。元素被插入到给定迭代器所表示的元素之前。

cpp 复制代码
#include <iostream>
#include <list>
#include <iterator>

int main()
{
    std::list<int> lst;

    // 使用back_inserter
    auto back = std::back_inserter(lst);
    *back = 33;

    // 使用 front_inserter
    auto front = std::front_inserter(lst);
    *front = 11;

    // 使用 inserter
    auto middle = std::inserter(lst, ++lst.begin());
    *middle = 22;

    std::cout << lst.size() << std::endl; // 3
    // 11 22 33
    for (int k : lst)
    {
        std::cout << k << " ";
    }
    std::cout << std::endl;

    return 0;
}
  • 流迭代器(stream iterator)

这些迭代器被绑定到输入或输出流上,可用来遍历所关联的IO流

istream_iterator读取输入流,ostream_iterator向一个输出流写数据。

一个istream_iterator使用>>来读取流:

cpp 复制代码
#include <iostream>
#include <vector>
#include <iterator>

int main()
{
        std::vector<int> vec;

        std::istream_iterator<int> in_iter(std::cin); // 从 cin 读取 int

        std::istream_iterator<int> eof; // 尾后迭代器

        while (in_iter != eof)
        {
                vec.push_back(*in_iter++);
        }

        return 0;
}

我们可以对任何具有输出运算符(<<运算符)的类型定义ostream_iterator。当创建一个ostream_iterator时,我们可以提供第二参数,它是一个字符串,在输出每个元素后都会打印此字符串。此字符串必须是一个C风格字符串(即,一个字符串字面常量或者一个指向以空字符结尾的字符数组的指针)。

cpp 复制代码
#include <iostream>
#include <iterator>
#include <vector>
#include <fstream>

int main()
{
    std::vector<std::string> vec = {"h", "e", "l", "l", "o"};

    std::string filename = "outfile.txt";
    std::ofstream outfile(filename); // 输出流,向文件输出

    if (outfile.is_open())
    {
        std::ostream_iterator<std::string> out(outfile);
        for (auto s: vec)
        {
                *out++ = s;
        }
        *out++ = "\n";

        outfile.close();
    }

    return 0;

}
  • 反向迭代器(reverse iterator)

这些迭代器向后而不是向前移动,除了forward_list之外的标准库容器都有反向迭代器。

反向迭代器就是在容器中从尾元素向首元素反向移动的爹大气。对于反向迭代器,递增(以及递减)操作的含义会颠倒过来。递增一个反向迭代器会移动到前一个元素;递减一个迭代器会移动到下一个元素。

我们可以通过调用rbeginrendcrbegincrend成员函数来获得反向迭代器。

cpp 复制代码
#include <iostream>
#include <vector>
#include <iterator>

int main()
{

        std::vector<int> vec = {1, 2, 3, 4, 5};

        auto rb = vec.rbegin(); // 指向最后一个元素,也就是5
        std::cout << *rb << "\n";

        std::cout << *(++rb) << "\n"; // 指向倒数第二个元素 - 4

        std::cout << *(--rb) << "\n"; // 指向最后一个元素 - 5

        auto re = vec.rend(); // 指向第一个元素之前

        return 0;
}
  • 移动迭代器(move iterator)

这些专用的迭代器不是拷贝其中的元素,而是移动它们。

泛型算法结构

五类迭代器

  • 输入迭代器:可以读取序列中的元素。

  • 输出迭代器:可以看做输入迭代器功能上的补集-只写而不读元素。

  • 前向迭代器:可以读写元素。这类迭代器只能在学列中沿一个方向移动,前向迭代器支持所有输入和输出迭代器的操作,而且可以多次多谢同一个元素。

  • 双向迭代器:可以正向/反向读写序列中的元素。除了支持所有前向迭代器的操作之外,双向迭代器还支持前置和后置递减运算符。

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

算法形参模式

在任何其他算法分类之上,还有一组参数规范,大多数算法具有如下四种形式之一:

cpp 复制代码
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 是算法的名字,begend表示算法所操作的输入范围。dest参数是一个表示算法可以写入的目的位置的迭代器。beg2end2表示第二个输入范围。

算法命名规范

除了参数规范,算法还遵循一套命名和重载规范。

  • 一些算法使用重载形式传递一个谓词

接受谓词参数来代替<==运算符的算法,以及那些不接受额外参数的算法,通常都是重载的函数。

cpp 复制代码
unique(beg, end);  // 使用 == 运算符比较元素

unique(beg, end, comp); // 使用 comp 比较元素
  • _if版本的算法

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

cpp 复制代码
find(beg, end, val);    // 查找输入范围中val第一次出现的位置

find_if(beg, end, pred);    // 查找第一个令pred为真的元素
  • 区分拷贝元素的版本和不拷贝的版本

默认情况下,重排元素的算法将重排后的元素写回给定的输入序列中,这些算法该提供另一个版本,将元素写到一个指定的输出目的位置:

cpp 复制代码
reverse(beg, end);

reverse_copy(beg, end, dest); // 将元素按逆序拷贝到 dest

特定容器算法

与其他容器不同,链表类型listforward_list定义了几个成员函数形式的算法。它们定义了独特的sortmergeremovereverseunique

cpp 复制代码
lst.merge(lst2);
lst.merge(lst2, comp); // 元素将从lst2中删除


lst.remove(val);
lst.remove_if(); // 删除pred为真的元素

lst.reverse();

lst.sort();  // <
lst.sort(comp);


lst.unique();
lst.unique(pred);
相关推荐
笨笨饿2 小时前
# 52_浅谈为什么工程基本进入复数域?
linux·服务器·c语言·数据结构·人工智能·算法·学习方法
Code-keys2 小时前
ADSP/ARM 性能/稳定性排查专栏总述
arm开发·算法·边缘计算·dsp开发
山栀shanzhi2 小时前
C++四大常见排序对比
c++·算法·排序算法
云栖梦泽2 小时前
Linux内核与驱动:8.ioctl驱动基础
linux·c++
Allen_LVyingbo2 小时前
量子测量三部曲:投影测量、POVM 与坍缩之谜—从形式主义到物理图像
算法·性能优化·健康医疗·量子计算·空间计算
云栖梦泽2 小时前
Linux内核与驱动:7.从应用层 lseek() 到驱动层 .llseek,Linux 字符设备偏移控制详解
linux·c++
qiqsevenqiqiqiqi2 小时前
位运算 计算
算法
steins_甲乙2 小时前
从0做一个小型内存泄露检测器(2): elf文件的动态链接
c++
甄心爱学习2 小时前
【最优化】1-6章习题
人工智能·算法