C++泛型演算法

發表於2016-12-21

本文主要討論C++標準庫中的泛型演算法(generic algorithm)。泛型演算法是使用容器的強有力的輔助工具。

如果文中有錯誤或遺漏之處,敬請指出,謝謝!

標準庫為容器型別定義的操作很少,並沒有為每個容器實現更多的操作。因為這部分操作可以抽象出來為所有的容器工作,那就是泛型演算法。所謂“泛型”是指這些演算法可以應用於多種容器型別上,而容器內的元素型別也可以多樣化。標準庫提供了100多個泛型演算法,主要定義於標頭檔案<algorithm>中,還有一組泛化的算術演算法定義於標頭檔案<numeric>中。

大多數泛型演算法是工作於容器的一對迭代器所標識的範圍,並完全通過迭代器來實現其功能。這段由迭代器指定的範圍稱為“輸入範圍”。帶有輸入範圍引數的演算法總是使用前兩個引數標記該範圍,分別指向要處理的第一個元素和最後一個元素的下一個位置。

這些演算法一般可劃分為只讀演算法、改寫元素演算法或對元素重新排序演算法,下面分別敘述之。

只讀演算法

find 演算法

查詢迭代器指定範圍[first, last)範圍內是否有val值。如果有,則返回該值對應的迭代器;否則,返回last表示查詢失敗。

accumulate 演算法

累加迭代器指定範圍[first, last)範圍內所有元素,再加上累加的初值val,返回累加的結果。第二個函式自定義操作:val = pr(val, *it)。

注:用於指定累加起始值的第三個引數是必要的,因為演算法對將要累加的元素型別一無所知,沒有別的辦法建立合適的起始值或者關聯的型別。

find_first_of 演算法

查詢第一段範圍內與第二段範圍內任意元素匹配的元素的位置。如果找到,返回該元素對應的迭代器;否則,返回last1。第二個函式使用判斷:pr(*it1, *it2)來代替第一個函式中的判斷:*it1 == *it2。

寫容器元素的演算法

在使用寫元素的演算法時,必須確保演算法所寫的序列至少足以儲存要寫入的元素。有些演算法直接將資料寫入到輸入序列,另外一些則帶有一個額外的迭代器引數指定寫入目標。這類演算法將目標迭代器用作輸出的位置。還有第三種演算法將指定數目的元素寫入某個序列。

寫入輸入序列的元素

寫入到輸入序列的演算法本質上是案例的,因為只會寫入與指定輸入範圍數量相同的元素。如fill演算法:

這個演算法將指定範圍內的每個元素都設定為給定的值。如果輸入範圍有效,則可以安全寫入。這個演算法只會對輸入範圍內已存在的元素進行寫入操作。

不檢查寫入操作的演算法

這類演算法如fill_n演算法:

該演算法從迭代器指向的元素開始,將指定數量的元素設定為給定的值。如果目標範圍內的某些元素不存在,則該操作未定義。如下面的程式碼將發生不可預料的結果:

注:對指定數目的元素做寫入運算,或者寫到目標迭代的演算法,都不檢查目標的大小是否足以儲存要寫入的元素。

back_inserter

確保演算法有足夠的元素儲存輸出資料的一種方法是使用插入迭代器(insert iterator)。插入迭代器是可以給基礎容器新增元素的迭代器。通常,用迭代器給容器元素賦值時,被賦值的是迭代器所指向的元素。而使用插入迭代器賦值時,則會在容器中新增一個新元素,其值等於賦值運算的右運算元的值。

back_inserter函式是迭代器介面卡,其使用一個物件作為實參,並生成一個適應其實參行為的新物件。比如,在下例中,傳遞給back_inserter的實參是一個容器的引用。back_inserter生成一個繫結在該容器上的插入迭代器。在試圖通過這個迭代器給元素賦值時,賦值運算將呼叫push_back在容器中新增一個具有指定值的元素。因此,用back_inserter改寫上面的程式碼可以有效地工作:

寫入到目標迭代器的演算法

第三類演算法向目標迭代器寫入未知個數的元素。這類演算法最簡單的如copy演算法:

copy演算法帶有三個迭代器引數:前兩個指定輸入範圍,第三個指向目標序列的第一個元素。

演算法的_copy版本

有些演算法提供所謂的“_copy”版本。這些演算法對輸入序列的元素做處理,但不修改原來的元素,而是建立一個新序列儲存元素的處理結果

例如,replace演算法:

該演算法指定範圍[first, last)內的所有元素值為vold替換為vnew。

如果不想改變原序列,可以用replace_copy演算法:

這個演算法接受第三個迭代器引數,指定儲存替換後的序列的目標位置。例如:

呼叫該函式後,ilist沒有改變,而ivec儲存ilist的一份替換後的副本。

對容器元素重新排序的演算法

sort演算法

這裡只介紹sort和stable_sort這個類排序演算法:

sort排序演算法是最一般的型別,而stable_sort排序演算法是穩定排序。

unique和unique_copy

unique函式“刪除”指定範圍內的重複元素。注意:這裡的“刪除”不是真正意義上的刪除,只是在有重複元素時,把後面的元素向前移動覆蓋了原來的元素。函式返回的迭代器指向無重複元素序列最後一個元素的下一個位置。而unique_copy是它的“_copy”版本,返回的是生成的序列的最後一個元素的下一個位置。

注意:unique呼叫後,原序列的前面部分是無重複元素的序列,而後半部分是剩下沒有被覆蓋的序列。這裡,需要手動刪除後面的元素序列,範圍由返回的迭代器和容器末端決定。

迭代器

插入迭代器

插入迭代器是一種迭代器介面卡,帶有一個容器引數,並生成一個迭代器,用於在指定的容器中插入元素。通過插入迭代器賦值時,迭代器將會插入一個新的元素。C++語言提供了三種插入器,其差別在於插入元素的位置不同:

1)back_inserter,建立使用push_back實現插入的迭代器;
2)front_inserter,使用push_front實現插入;
3)inserter,使用insert實現插入操作。除了所關聯的容器外,inserter還帶有第二個實參:指向插入起始位置的迭代器。

front_inserter的操作類似於back_inserter:該函式將建立一個迭代器,呼叫它所關聯的基礎容器的push_front成員函式代替賦值操作。注意:只有當容器提供push_front操作時,才能使用front_inserter。在vector或其他沒有push_front運算的容器上使用front_inserter,將產生錯誤。

inserter將產生在指定位置實現插入的迭代器,inserter總是在它的迭代器引數所標明的位置前面插入新元素。看看下面的例子:

iostream 迭代器

雖然iostream型別不是容器,但標準庫同樣提供了在iostream物件上使用的迭代器:istream_iterator用於讀取讀入流,而ostream_iterator用於寫輸出流。這些迭代器將它們所對應的流視為特定型別的元素序列。使用流迭代器時,可以用泛型演算法從流物件中讀資料(或將資料寫到流物件中)。

1

流迭代器只定義了最基本的迭代器操作:自增、解引用和賦值。此外,可比較兩個istream迭代器是否相等(或不等)。而ostream迭代器則不提供比較運算。

2

使迭代器向前移動。通常,字首版本使迭代器在流中向前移動,並返回對加1後的迭代器的引用。it++ 而字尾版本使迭代器在流中向前移動後,返回原值。

流迭代器是類别範本:任何已定義輸入操作符(>>操作符)的型別都可以定義istream_iterator。類似地,任何已定義輸出操作符(<<操作符)的型別也可以ostream_iterator。

istream_iterator使用舉例:

ostream_iterator使用舉例:

流迭代器的限制:

1)不可能從ostream_iterator物件讀入,也不可能寫到istream_iterator物件中;
2)一旦給ostream_iterator物件賦了一個值,寫入就提交了。賦值後,沒有辦法再改變這個值。此外,ostream_iterator物件中每個不同的值都只能正好輸出一次。
3)ostream_iterator沒有->操作符。

與演算法一起使用流迭代器,如下面的示例實現從標準輸入讀取一些數,然後將不重複的數寫到標準輸出:

反向迭代器

反向迭代器是一種反向遍歷容器的迭代器。也就是,從最後一個元素到第一個元素遍歷容器。反向迭代器將自增(和自減)的含義反過來了:對於反向迭代器,++運算將訪問前一個元素,而–運算則訪問下一個元素。

1)反向迭代器需要使用自減操作符:標準容器上的迭代器(reverse_iterator)既支援自增運算,也支援自減運算。但是,流迭代器由於不能反向遍歷流,因此流迭代器不能建立反向迭代器。
2)可以通過reverse_iterator::base()將反向迭代器轉換為普通迭代器使用,從逆序得到普通次序。如下面的例子所示:

const 迭代器

在標準庫中,有輸入範圍的泛型演算法要求其兩個迭代器型別完全一樣,包括const屬性。要麼都是const,要麼都是非const,否則無法通過編譯。同樣,它們的返回值迭代器也與引數型別保持一致。

迭代器分類

不同的迭代器支援不同的操作集,而各種演算法也要求相應的迭代器具有最小的操作集。因此,可以將演算法的迭代器分為下面五類:

3

除了輸出迭代器,其他類別的迭代器形成了一個層次結構:需要低階類別迭代器的地方,可使用任意一種更高階的迭代器。例如,對於需要輸入迭代器的演算法,可傳遞前向、雙向或隨機訪問迭代器呼叫該演算法。而反之則不行。注意:向演算法傳遞無效的迭代器類別所引起的錯誤,無法保證會在編譯時被捕獲到。

map, set, list型別提供雙向迭代器,而string, vector和deque容器上定義的迭代器都是隨機訪問迭代器,用作訪問內建陣列元素的指標也是隨機訪問迭代器。istream_iterator是輸入迭代器,ostream_iterator是輸出迭代器。

另外,雖然map和set型別提供雙向迭代器,但關聯容器只能使用這部分演算法的一個子集。因為關聯容器的鍵是const物件。因此,關聯容器不能使用任何寫序列元素的演算法。只能使用與關聯容器綁在一起的迭代器來提供用於讀操作的實參。因此,在處理演算法時,最好將關聯容器上的迭代器視為支援自減運算的輸入迭代器,而不是完整的雙向迭代器。

泛型演算法的結構

就像所有的容器都建立在一致的設計模式上一樣,演算法也具有共同的設計基礎。

演算法最基本的性質是需要使用的迭代器種類。

另一種演算法分類方法是前面介紹的按實現的功能分類:只讀演算法,不改變元素的值和順序;給指定元素賦新值的演算法;將一個元素的值移給另一個元素的演算法。

另外,演算法還有兩種結構上的演算法模式:一種模式是由演算法所帶的形參定義;另一種模式則通過兩種函式命名和過載的規範定義。

演算法的形參模式

大多數演算法採用下面四種形式之一:

其中,alg是演算法名,[beg, end)是輸入範圍,beg, end, dest, beg2, end2都是迭代器。

對於帶有單個目標迭代器的演算法:dest形參是一個迭代器,用於指定儲存輸出資料的目標物件。演算法假定無論需要寫入多少個元素都是安全的。注意:呼叫這類演算法時,演算法是將輸出內容寫到容器中已存在的元素上,所以必須確保輸出容器中有足夠大的容量儲存輸出資料,這也正是通過使用插入迭代器或者ostream_iterator來呼叫這些演算法的原因。

對於帶第二個輸入序列的演算法:beg2和end2標記了完整的輸出範圍。而只有beg2的演算法將beg2視為第二個輸入範圍的首元素,演算法假定以beg2開始的範圍至少與beg和end指定的範圍一樣大。

演算法的命名規範

包括兩種重要模式:第一種模式包括測試輸入範圍內元素的演算法,第二種模式則應用於輸入範圍內元素的重新排序的演算法。

1)區別帶有一個值或一個謂詞函式引數的演算法版本

很多演算法通過檢查其輸入範圍內的元素實現其功能。這些演算法通常要用到標準關係操作符:== 或 < 。其中的大部分演算法都提供了第二個版本的演算法,允許程式設計師提供比較或測試函式取代預設的操作符的使用。

例如, 排序演算法預設使用 < 操作符,其過載版本帶有一個額外的形參,表示取代預設的 < 操作符。

又如,查詢演算法預設使用 == 操作符。標準庫為這類演算法提供另外命名的(而非過載的)版本,帶有謂詞函式形參。對於帶有謂詞函式形參的演算法,其名字帶有字尾 _if:

標準庫為這類演算法提供另外命名的版本,而非過載版本,原因在於這兩種版本的演算法帶有相同的引數個數,容易導致二義性。

2)區別是否實現複製的演算法版本

預設情況下,演算法將重新排列的寫回其範圍。標準庫也為這類演算法提供了另外命名的版本,將元素寫到指定的輸出目標。此版本的演算法在名字中新增 _copy字尾,例如:

第一個版本將輸入序列中的元素反向重新排列;而第二個版本將複製輸入序列中的元素,並將它們以逆序儲存到dest開始的序列中。

容器特有的演算法

list容器上的迭代器是雙向的,而不是隨機訪問型別。由於list容器不支援隨機訪問,因此,在此容器上不能使用需要隨機訪問迭代器的演算法。如sort類演算法。其它有些演算法,如merge, remove, reverse, unique等,雖然可以用在list上,但效能太差。list容器結合自己的結構專門實現了更為高效的演算法。因此,對於list物件,應該優先使用list容器特有的成員版本,而不是泛型演算法。

下表列出了list容器特有的操作:

4

list容器特有的演算法與其泛型演算法版本之間有兩個重要的差別:1)remove和unique的list版本修改了其關聯的基礎容器:真正刪除了指定的元素;2)list容器提供的merge和splice操作會破壞它們的實參。使用泛型演算法的merge版本,合併的序列將寫入目標迭代器指向的物件,而它的兩個輸入序列保持不變。

相關文章