1. 程式人生 > 實用技巧 >【C++】《C++ Primer 》第十章

【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不能指向給定範圍中的元素。