C++ Primer学习笔记 - 第10章 初识泛型算法

10.2 初识泛型算法

10.2.1 只读算法

  1. find

头文件<algorithm>
find 在指定迭代器范围种,查找目标元素。返回查找到的第一个目标元素的迭代器。支持容器类型,内置数组。

要求:元素支持“==”运算符。

// 1. 在list中查找指定元素
list<string> lst;
// 向lst插入元素
// lst.push_fron("...");

// 利用find在lst中查找指定元素
string val = "a value";
std::find(lst.cbegin(), lst.end(), val);

// 2. 在内置数组中查找指定元素
int ia[] = {1,2,3,4,5,6,7};
int val = 6;
int* res = std::find(begin(ia), end(ia), val);
  1. accumulate

头文件<numeric>
accumulate 求累加和。返回求和结果。
除了指定迭代器范围,还需要指定初值(第三个参数)。运算时,元素会转换为初值类型。

要求:元素支持“+”运算符。

std::vector<int> vec;
// 要求vec元素类型也是int,与accumulate第三个参数0类型一致
// 字符串求和,accumulate会将每个字符串连接起来
int sum = std::accumulate(vec.cbegin(), vec.cend(), 0);

// 错误:因为const char*上没有定义 + 运算
std::string sum = std::accumulate(v.cbegin(), v.cend(), ""); // 这里""会被当作const char*类型空串
  1. equal

头文件<algorithm>
equal 比较2个容器保存的元素序列是否相同。只有所有指定范围元素都相当时,才返回true;返回比较结果,bool类型。
前2个参数是第一个序列的迭代器范围,第3个参数是第二个序列的首元素对应迭代器。

要求:
1)元素类型相同,能支持 == 运算符;
2)第一个序列元素数目 >= 1第一个序列元素数目;

// roster2元素数目 >= roster1元素数目
equal(roster1.cbegin(0, roster1.cend(), roster2.cbegin());

10.2.2 写容器的算法

  1. fill

头文件<algorithm>
fill 在指定迭代器范围返回填充指定元素值。

2个版本:
1)fill() 3个参数分别是起始迭代器(begin())、结束迭代器(end())、初值;
2)fill_n() 3个参数分别是起始迭代器、元素个数、初值;
第1)种,是安全方式;
第2)种,不安全,如果要填充元素个数 > 容器范围,会导致未定义行为。

vector<int> vec(10); // 构造10个元素的vector
fill(vec.begin(), vec.end(), 1); // 将vec所有元素填充为1

fill_n(vec.begin(), vec.size(), 0); // 将vec 10个元素都填充为0

fill_n(vec.begin(), 11, 0); // 11 > vec.size(),将导致未定义行为
  1. back_inserter

头文件<iterator>
back_inserter 插入迭代器,一种向容器中添加元素的迭代器,值被赋予迭代器指向的元素。

// 通过back_inserter插入元素 <=> push_back
vector<int> vec; // 空向量
auto it = back_inserter(vec); // it指向vec尾部
*it = 42; // vec现在有一个元素,值为42

// back_inserter结合fill_n,在指定位置 插入指定个数元素
fill_n(back_inserter(vec), 10, 0); // 在vec末尾添加10个元素
  1. copy

头文件<algorithm>
copy 拷贝算法,从一个指定迭代器范围的序列元素,拷贝到另外一个序列。copy返回目的位置迭代器(递增后)的值,而非起始拷贝的位置值。

int a1[] = {0,1,2,3,4,5,6};
int a2[sizeof(a1) / sizeof(a1[0])];

// ret指向拷贝元素到a2后的尾元素之后位置
auto ret = copy(begin(a1), end(a1), a2); // 将a1内容拷贝到a2
  1. replace

头文件<algorithm>
replace 用指定值,替换指定迭代器范围内的值。无返回值,且会修改原容器的值。
replace_copy 先拷贝容器内容,用指定值替换指定迭代器范围内的值。无返回值,且不会修改原容器的值。

// 替换ilst中值为0的元素为42。 因为要修改原容器的值,只能用begin, end版本,不能用cbegin, cend版本
replace(ilst.begin(), ilst.end(), 0, 42);
// il并未改变,ivec包含一份拷贝,原来值ilst中值为0的元素值ivec都变为42了
replace_copy(ilst.cbegin(), ilst.cend(), back_inserter(ivec), 0, 42); 

10.2.3 重排容器元素的算法

  1. sort

sort 对指定迭代器范围元素进行排序,默认是递增顺序。
stable_sort() 稳定排序,除了对元素序列进行排序,还能保持两个“相等”元素的原有顺序。

vector<int> ivec = {8,2,78,11,5};
sort(ivec.begin(), ivec.end()); // 排序

// 改变排列顺序,由递增改为递减
sort(ivec.begin(), ivec.end(), greater<int>()); // 从大到小排列

sort(ivec.begin(), ivec.end(), less<int>());    // 从小到大排列

// 指定比较函数 - 谓词
// 按单词长短排序words
bool isShorter(const string &s1, const string &s2) {
  return s1.size() < s2.size();
}

sort(words.begin(), words.end(), isShorter);
  1. unique

unique 算法排序序列,重复元素“删除”(实际上是放到容器末尾)。返回指向不重复值范围的下一个位置的迭代器。如果有重复元素,unique返回值,相当于移动到末尾的第一个重复值元素的位置。

注意:unique并不会排序。

// 排序words,并删除重复元素
void elimDups(vector<string>& words) {
  sort(words.begin(), words.end());
  auto end_unique = unique(words.begin(), words.end()); // end_unique是指向不重复区域之后一个位置的迭代器
  words.erase(end_unique, words.end()); // [end_unique, words.end() ) 都是重复元素
}

10.3 定制操作

10.3.1 向算法传递参数

  1. 谓词

sort 第3个参数,可传入函数作为参数,函数返回值作为两个迭代器指向的元素的比较的结果。e.g. sort(words.begin(), words.end(), isShorter());
谓词predicate,指一个可调用的表达式,返回结果是一个能用作条件的值。
标准库的谓词分为两类:一元谓词 unary predicate,只接收单一参数;二元谓词 binary predicate,意味着它们(表达式)有2个参数。
sort的第3个参数,作为谓词,起到比较2个参数作用,也是二元谓词。

10.3.2 lambda表达式

可向算法传递可调用对象(callable object)(e.g. 函数,函数指针,仿函数)。
一个lambda表达式,表示一个可调用代码单元,可理解成未命名的内联函数。类似于函数,具有一个返回类型,一个参数列表,一个函数体。

lambda表达式形式:

[capture list](parameter list) -> return type { function body}
  • capture list 捕获列表,是一个lambda所在函数中定义的局部变量的列表,通常为空。
  • parameter list 参数列表;
  • return type 函数返回类型;
  • function body 函数体,类似普通函数。不过,lambda必须使用尾置返回。
  • 参数列表、返回类型都可以忽略,但是必须包含捕获列表和函数体。

如果只有一个return,且没有包含具体返回值,那么编译器无法推断出具体返回类型,则返回类型为void。

auto f = []{ return 42; }
cout << f() << endl; // 打印42

向lambda传递参数

  • lambda不能有默认参数;
  • lambda调用的实参数目永远与形参数目相等;
// 示例:将前面的isShorter函数转化成lambda表达式
bool isShorter(const string& s1, const string& s2) {
  return s1.size() < s2.size();
}

// lambda表达式
[](const string& s1, const string& s2) {
  return s1.size() < s2.size();
}

使用捕获列表

lambda可以使用函数的局部变量,不过需要先捕获(包含中捕获列表中)。
如find_if第3个参数,可以用谓词来过滤出符合条件的元素

[sz](const string& a) { // 捕获局部变量sz
  return a.size() >= sz;
}

// 错误使用示范
[](const string& a) {
  return a.size() >= sz; // 错误:未捕获sz
}

// 调用find_if,返回一个迭代器,指向第一个长度不小于sz的元素
// find_if 第三参数返回非0值,才能通过find_if的过滤
auto wc = find_if(words.begin(), words.end(), [sz](const string& a) {
  return a.size() >= sz;
}

// 调用for_each算法,对指定迭代器范围每个元素,都调用lambda表达式
for_each(wc, words.end(), [](const string& s) {
  cout << s << " ";
  cout << endl;
})

10.3.3 lambda 捕获和返回

值捕获

变量的捕获方式,也分为值捕获和引用捕获。被捕获的变量的值,是在lambda创建时拷贝,而不是调用时。
值捕获的变量值,值lambda函数结束后就不存在了。

引用捕获

[]中,通过在捕获值之前添加&进行引用捕获。需确保引用捕获的对象值,在lambda执行时存在。

// 示例:值捕获
void fcn1() {
  size_t v1 = 42;
  auto f = [v1]{ return v1; }
  v1 = 0;
  auto j = f(); // j == 42,因为f lambda表达式捕获v1时,创建了临时拷贝
}

// 示例:引用捕获
void fcn2() {
  size_t v1 = 42;
  auto f = [&v1]{ return v1; }
  v1 = 0;
  auto j = f(); // j == 0,因为f lambda表达式捕获了v1的引用
}

引用捕获有时是必要的。
例如,希望biggies函数接受一个ostream的引用,用来输出数据,并接受一个字符作为分隔符。

void biggies(vector<string> &words, vector<string>::size_type sz, ostream &os = cout, char c = ' ') {
  for_each(words.cbegin(), words.cend(), 
    [&os, c](const string& s){
      os << s << c;
    });
}

隐式捕获

指示编译器推断捕获列表,值捕获列表中写一个&=,告诉编译器:
1)& 用引用捕获;
2)= 用值捕获;

// sz为隐式捕获,值捕获方式,重写传递给find_if的lambda
wc = find_if(words.begin(), words.end(), [=](const string& s){
  return s.size() >= sz;
});

// 混合隐式捕获和显式捕获
void biggies(vector<string>& words, ostream& os = cout, char c = ' ') {
  for_each(words.begin(), words.end(), 
            [&, c](const string& s){ os << s << c; }); // os隐式捕获,c显示捕获
  
  for_each(words.begin(), words.end(), 
            [=, &os](const string& s){ os << s << c; }); // c隐式捕获,os显式捕获
}

可变lambda

默认情况下,值拷贝的变量,lambda不会改变其值;如果想要改变其值,必须加上关键字mutable。
引用捕获的变量,如果不是const类型,就可以在lambda中修改。

// 示例:lambda修改值捕获变量,需要加mutable关键字
void fcn3() {
  size_t v1 = 42; 
  auto f = [v1]() mutable { return ++v1; };
  v1 = 0;
  auto j = f(); // j为43
}

// 示例:lambda修改引用捕获变量,要求非const类型
void fcn4() {
  size_t v1 = 42;
  auto f = [&v1] { return ++v1;};
  v1 = 0;
  auto j = f(); // j为1
}

指定lambda返回类型

默认情况下,lambda如果包含除return语句以外的语句,编译器会假定返回void。
如果需要包含return以外语句,需要使用尾置返回类型。

// 错误示范:lambda使用return以外语句
transform(vi.begin(), vi.end(), vi.begin(), 
          [](int i){ 
                      if (i < 0) return -i ;
                      else return i; 
});

// 正确示范1:不包含return以外语句
transform(vi.begin(), vi.end(), vi.begin(), [](int i){ return i < 0 ? -i : i; });

// 正确示范2:使用尾置返回类型,包含return以外语句
transform(vi.begin(), vi.end(), vi.begin(), 
          [](int i) -> int {                  // 注意这里 -> int ,表明lambda返回int
                      if (i < 0) return -i ;
                      else return i; 
});

10.3.4 参数绑定

对于在一两个地方使用简单操作,用lambda表达式很合适;但对于需要在很多地方重复使用,用有名函数更合适。

对于lambda捕获的局部变量,如果用函数替换lambda,该如何进行?

例如,下面的sz,是在前面find_if例子中,lambda表达式捕获的局部变量而作为第3个参数。如果改用函数,如何传递给函数check_size?

// 示例:将值捕获lambda改写为有名函数

// 改写前:使用lambda作为find_if第3个参数
auto wc = find_if(words.begin(), words.end(), [sz](const string& a) {
  return a.size() >= sz;
}
// 定义与lambda一致的有名函数
bool check_size(const string& s, string::size_type sz) {
  return s.size() >= sz;
}
// 改写后:使用有名函数作为find_if的第3个参数
auto wc = find_if(words.begin(), words.end(), check_size);

标准库bind函数

头文件<functional>

bind 可看做一个通用的函数适配器,接收一个可调用对象(函数),生成一个新的可调用对象来适应原对象的参数列表。
调用bind一般形式:

auto newCallable = bind(callable, arg_list);
  • newCallable 新生成的可调用对象
  • callable 给定的可调用对象
  • arg_list 参数列表。arg_list中的参数可能包含形如_n的名字,其中n是一个整数,称为“占位符” ,表示newCallable的参数,数字对应传递给newCallable形参的位置。e.g. _1表示newCallable的第一个参数,_2表示第二个参数,...以此类推

绑定check_size的sz参数

#include <functional>
#include <iostream>
#include <string>
#include <vector>

// 为了使用_1, _2这些,需要声明使用placeholders命名空间
using namepsace std::placeholders;
// 也可声明只使用_1
using std::placeholders::_1;

bool check_size(const string& s, string::size_type sz) {
	return s.size() >= sz;
}

void fcn5() {
  vector<string> words = {"the", "red", "fox", "jumps", "over", "the", "slow", "red", "turtle"};
  string::size_type sz = 6;

  auto check6 = bind(check_size, _1, sz); // check6 是一个可调用对象
  
  string s = "hello";
  bool b1 = check6(s); // check6(s)会调用check_size(s,sz)

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

bind参数

auto g = bind(f, a, b, _2, c, _1);

g(x, y);
// 调用g(x, y)将映射为调用下面的
f(a, b, y, c, x);

bind参数重排序

// 按长度从小到大排序单词数组words
sort(words.begin(), words.end(), isShorter);
// 调整placeholders顺序,交互绑定的参数顺序,从而改变调用isShorter比较两个元素时返回的结果,
// 达到按长度从大到小排序单词数组的目的
sort(words.begin(), words.end(), bind(isShorter, _2, _1)); 

绑定引用参数

使用ref(),通过bind(f, a, b, _1)传递a、b到可调用对象,都是拷贝。如果想传递引用,需要使用ref(a), ref(b)。如果要使用const引用,则需要用cref(a)

ref函数会返回给定对象的引用。

char c = ' ';

// 使用lambda的等价语句,打印每个words元素
for_each(words.begin(), words.end(), [&os, c](const string& s) {os << s << c; });

// 等价的bind语句
// 这里必须使用ref(os),如果直接用os,传递给函数会发生拷贝,
// 而我们知道ostream对象是无法拷贝的,会发生错误
for_each(words.begin(), words.end(), bind(print, ref(os), _1, c);

ostream &print(ostream &os, const string& s, char c) {
  return os << s << c;
}

10.4 再探迭代器

头文件<iterator>

  • 插入迭代器 insert iterator: 迭代器被绑定到一个容器上,可用来向容器插入数据;
  • 流迭代器 stream iterator:迭代器被绑定到输入或输出流上,可用来遍历所关联的IO流;
  • 反向迭代器 reverse iterator:迭代器向后而不是向前移动。除了forward_list之外的标准容器库都有反向迭代器;
  • 移动迭代器 move iterator:迭代器不是拷贝其中的元素,而是移动它们。

10.4.1 插入迭代器

插入迭代器有3种,根据元素插入位置区分:

  • back_inserter 创建一个使用push_back的迭代器。只有容器支持push_back操作时,才能使用back_inserter。
  • front_inserter 创建一个使用push_front的迭代器。同样需要容器支持push_front。
  • inserter 创建一个使用insert的迭代器。接受2个参数,第1个是容器,第2个是迭代器。

insert(pos, args)会将元素插入到pos位置之前,所以inserter(c, iter)也会将元素插入到iter之前,并返回新插入元素位置(iter的前一个位置)。

// 如果it是通过inserter获取的迭代器
// 那么下面2条语句等价
*it = val;
// <=>
it = c.insert(it, val); // it指向新加入的元素
++it; // 递增it,指向原来的元素

// front_inserter, inserter使用示例
list<int> lst = {1, 2, 3, 4};
list<int> lst2, lst3;

copy(lst.cbegin(), lst.cend(), front_inserter(lst2)); // lst2 == 4,3,2,1
copy(lst.cbegin(), lst.cend(), inserter(lst3, lst3.begin())); // lst3 == 1,2,3,4

10.4.2 iostream迭代器

iostream不是类型容器,而是输入输出流,标准库定义了可用于这些IO类型对象的迭代器,称为流迭代器。泛型算法可通过流迭代器,从流对象读取数据以及向其写入数据。

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

istream_iterator操作

输入流迭代器istream_iterator使用 >> (输入运算符)读取流,要读取的的类型必须定义了输入运算符。
默认初始化迭代器,可以当作尾后值使用的迭代器。

// 示例1: 通过istream_iterator从cin读入数据int数据, 直到输入结束,
// 或者遇到导致输入结束的非数字字符, 将读取到的数据存入vector并打印
istream_iterator<int> int_it(cin);
istream_iterator<int> int_eof;
vector<int> vec;

while(int_it != int_eof) {
	vec.push_back(*int_it++);
}

for (const auto &d : vec) {
	cout << d << " ";
}
cout << endl;

// 示例2: 通过istream_iterator从ifstream读入string数据, 并打印
ifstream in("records.txt");
istream_iterator<string> str_it(in); // 从"afile"读取字符串
istream_iterator<string> str_eof;
while (str_it != str_eof) {
	cout << *str_it++ << endl;
}

ostream_iterator操作

ostream_iteratoristream_iterator类似,必须指定要写到输出流的元素类型。ostream_iterator out对象,与out, ++out, out++等价(不对out做任何事情),都返回out。

vector<int> vec = {1,3,5,7,9,11};

// 下面2个示例效果一样,都是将数组vec通过流迭代器打印出来
// 示例1:通过for循环,将数组内容写到流迭代器指向位置(标准输出流cout)
ostream_iterator<int> out_iter(cout, " ");
for (auto e : vec) {
  *out_iter++ = e; // <=> out_iter = e; 
}
cout << endl;

// 示例2:通过copy和vec迭代器范围,将数组内容写到流迭代器指向位置(标准输出流cout)
copy(vec.begin(), vec.end(), out_iter);
cout << endl;

10.4.3 反向迭代器

正向迭代器

在容器中,从首部向尾部正向移动的迭代器。

vec.cbegin() ... vec.cend()
vec.crend() ... vec.crbegin()

反向迭代器

在容器中,从尾元素向首元素反向移动的迭代器。

// 示例1:逆序打印vector
vector<int> vec = {1,2,3,4,5,6,7,8};
// 从容器末尾向前开始打印,打印结果:8 7 6 5 4 3 2 1
for (auto r_iter : vec.crbegin(); r_iter != vec.cend(); ++r_iter) {
  cout << *r_iter << "" ;
}
cout << endl;

// 示例2:逆序排序
sort(vec.begin(), vec.end()); // 递增排序
sort(vec.rbegin(), vec.rend()); // 递减排序

反向迭代器需要递减运算符

除了forward_list和流迭代器不支持递减运算,其他标准容器都支持递减运算,也就支持反向迭代器。此时,如果需要将反向迭代器转化成普通迭代器,需要使用.base()函数进行转换。

反向迭代器和其他迭代器之间的关系

直接使用反向迭代器,会导致每个元素之间的顺序也是反向的。

注意:在普通迭代器和反向迭代器的转换过程中,要非常小心,仔细观察位置是否与预期一致。

string line = "hello, this is martin zhang, I'm 18 years old, who are you?";
// 打印','前第一个单词
auto comma = find(line.cbegin(), line.cend(), ',');
cout << string(line.cbegin(), comma) << endl; // 打印hello

// 打印','后最后一个单词,但是单词本身也会反序打印
auto rcomma = find(line.crbegin(), line.crend(), ',');
cout << string(line.crbegin(), rcomma) << endl; // 打印?uoy era ohw 

// 打印','后最后一个单词,单词本身正序打印
// 使用.base()将反向迭代器转化成普通迭代器,注意.base()返回的是当前迭代器位置的下一个位置,
// i.e. 当前位置是',',下一个位置是' '(空格)
cout << string(rcomma.base(), line.cend()) << endl; // 打印 who are you?

10.5 泛型算法结构

10.5.1 5类迭代器

  • 输入迭代器 input iterator

只读不写,单遍扫描,只能递增。还支持相等性 判定运算符(== , !=),支持解引用运算符()(只出现在赋值语句右侧)和箭头运算符(->,等价于(it).member,解引用迭代器)。
只能用于顺序访问,典型应用:find, accumulate算法,istream_iterator输入流迭代器

  • 输出迭代器 output iterator

输入迭代器的补集,只写不读,单向扫描,只能递增。支持解引用(*)(只出现在赋值语句左侧。
典型应用:copy算法,ostream_iterator输出流迭代器

  • 前向迭代器 forward iterator

可读写,多遍扫描,只能递增,支持所有输入、输出迭代器的操作。
典型应用:replace算法,forward_list的迭代器

  • 双向迭代器 bidirectional iterator

可正反读写序列中元素,多遍扫描,可递增递减,支持所有前向迭代器。
典型应用:除forward_list外,其他标准库都提供符合要求双向迭代器

  • 随机访问迭代器 random-access iterator

可读写,多遍扫描,可递增递减,支持全部迭代器操作。支持常量时间访问任意元素。支持<, <=, >, >=, +,+=,-,-=,下标运算等。支持两个迭代器-,得到距离。
典型应用:sort算法,array, deque, string, vector迭代器

10.5.2 算法形参模式

大多数算法具有下面4种形式之一:

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, 都是迭代器参数,指定目的第二个范围的角色;
  • args: 额外的非迭代器参数

10.5.3 算法命名规范

// 重新整理给定序列,将相邻重复元素删除
unique(begin, end); // 使用 == 比较元素
unique(begin, end, comp); // 使用comp比较元素,当comp为真时,删除第二个元素

// _if版本算法,接受一个谓词代替元素值
find(beg, end, val); // 查找输入范围中val第一次
find_if(beg, end, pred); // 查找输入范围中,第一个令谓词pred为真的元素

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

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

10.6 特定容器算法

list, forward_list特定成员函数算法

// 都返回void

list<XXX> lst;
forward_list<XXX> flst; 

// 合并链表
lst.merge(lst2); // lst2合并到lst。要求lst和lst2有序,元素从lst2删除(lst2合并后为空)。使用运算符 <
lst.merge(lst2, comp); // ... 使用谓词comp

// 删除元素
lst.remove(val); // 调用erase删除与给定值相等(==)的元素
lst.remove_if(pred); // ...满足谓词pred的元素

lst.reverse(); // 反转lst元素顺序
lst.sort(); // 链表排序,使用<
lst.sort(comp); // ..,使用comp进行比较
lst.unique(); // 调用erase删除连续的重复元素(==)
lst.unique(pred); // (满足pred)

// splice
// p是一个指向lst中元素迭代器,splice函数将lst2所有元素移动到lst中p之前。
// 元素从lst2删除,lst2类型必须与lst相同,且不能是同一个链表
lst.splice(p, lst2);
// ...lst2所有元素移动到lst中p之后...
flst.splice_after(p, lst2);

// p2是一个指向lst2中位置的有效迭代器。将p2指向的元素移动到lst中,
// 包含p2及以后的元素。lst2可以与lst相同链表
lst.splice(p, lst2, p2);
// ...将p2之后的元素移动到lst中(不包含p2)。lst2可以与flst相同链表
flst.splice_after(p, lst2, p2);

// b和e必须表示lst2中的合法范围。将给定范围元素从lst2移动到lst。
// lst2跟lst可同链表,但p不能指向给定范围中的元素
lst.splice(p, lst2, b, e);
// ...将给定范围元素从lst2移动到flst。llst2跟flst可以相同链表...
flst.splic_after(p, lst2,);
posted @ 2021-03-04 19:02  明明1109  阅读(709)  评论(0)    收藏  举报