標準模板庫(STL)使用入門(下)

zaishaoyi發表於2015-07-20

在這篇教程中我們將會使用教程上篇中的一些巨集和預定義型別。

利用 map 建立 vector

正如你所知,map實際上包含的是元素對。因此你可以這樣寫:

現在vector中包含著和map 中相同的元素。當然,和map一樣,向量也是有序的。在你既不想改變map中的元素,又想以map所不允許的方式使用元素索引時,這個特性就派上用場了。

容器間拷貝資料

讓我們看一下 copy(…) 演算法。演算法的原型如下:

這個演算法從第一個區間向第二個區間拷貝元素。第二個區間中應該有足夠的可用空間。請看下面的程式碼:

譯者注:教程上篇中有巨集定義:#define all(c) c.begin(), c.end()

copy 還有另一個用於連線的好特性是inserters。由於篇幅限制,不多加贅述。請看下面的程式碼:

最後一行程式碼等價於:

既然已經有了標準函式,那麼我們還有什麼理由要使用自定義的巨集(這些巨集定義只能夠在 GCC 環境下執行)呢?使用諸如 copy 的標準演算法是 STL 的一個有效應用,因為可以使別人更容易理解你的程式碼。

push_back 使用 back_inserter 向 Vector 中插入元素 ,或者使用f ront_inserter 向 deque 容器中插入元素。在某些情況下,需要知道,不只 begin/end 可以作為 copy 的前兩個引數,rbegin/ren d也可以。使用 rbegin/rend,將會逆序拷貝元素。

歸併 list

歸併佇列是對有序 list 的另一個常見操作。假設你有兩個有序 list,分別是 和 B。你想將這兩個 list 歸併成一個新列表。通常會有四種方式:

  • ‘union’ the lists, R = A+B
  • intersect the lists, R = A*B
  • set difference, R = A*(~B) or R = A-B
  • set symmetric difference, R = A XOR B

STL為這類任務提供了四種演算法:set_union(…)、set_intersection(…)、set_difference(…) 和 set_symmetric_difference(…)。它們的呼叫方式相同,因此我們以 set_intersection 為例。一個常用原型如下:

[begin1,end1) 和 [begin2,end2) 是輸入的兩個list  ‘begin_result’  是隻是輸出結果起點的迭代器。但是輸出結果list的大小是未知的。所以這個函式返回輸出結果終點的迭代器(這決定了在輸出結果中有多少個元素)。 關於使用細節,請看下面的例子:

最後一行,我們建立了一個新向量 res。它通過區間建構函式建立。區間以 tmp 的起點作為起點,以 set_intersection 演算法結果作為結尾。這個演算法會取 v1 和 v2 的交集,並將交集寫到輸出迭代器,從’tmp.begin()’開始寫入。set_intersection 演算法的返回值是結果資料集終點。

補充說明一點可能會幫助你深入地理解:如果你只是想得到交集中元素的數量,則使用 int cnt = set_intersection(all(v1), all(v2), tmp.begin()) – tmp.begin(); 即可。

實際上,我不會使用“vector<int> tmp”這種結構。我認為每呼叫一次“set_***”演算法都開闢一次記憶體是不明智的。相反,我會定義一個型別合適並且空間充足的全域性或者靜態變數。請看下面的程式碼:

‘res’ 中包含兩個輸入資料集中存在差異的元素。

注意,使用這些演算法,輸入的資料集必須是有序的。因此,例外一點也需要牢記,由於set是有序的,我們可以使用set(或者不覺得pair麻煩的話,也可以使用map)作為這些演算法的引數。

這類演算法從一端開始排序,演算法複雜度是 O(N1+N2),N1 和 N2 是輸入資料集的大小。

算術演算法

另外一個有趣的演算法是 accumulate(…)。如果我對一個int型的vector呼叫,並且將第三個引數設為0,accumulate(…) 會返回 vector 中元素之和。

accumulate()的返回值型別與第三個引數的型別一致。所以,當你不確定元素之和是否可以採用int型時,直接指定第三個引數的型別就可以了。

Accumulate也可以用來計算乘積。第四個引數標明瞭計算方法。如果你想獲得乘積,則使用如下程式碼:

另外一個有趣的演算法是inner_product(…),它用來計算兩個向量的數量積。例如:

‘r’是這樣的計算得來的:(v1[0]*v2[0] + v1[1]*v2[1] + v1[2]*v2[2]),或者說是(10*1+9*2+8*3),最終計算結果是52.

和“accumulate”演算法一樣,inner_product的返回值型別是由最後一個引數指定的。最後一個引數是返回結果的初始值。因此inner_product可以用於多維空間的超平面物件,這樣呼叫就可以了:

現在你應該明白,inner_product只需要對迭代器進行遞增,因此queue或者set也可以用作引數。用於計算特殊中間值的卷積濾波器可以這樣來實現:

當然,這些程式碼只是一個例子。老實說,將值拷貝到另一個vector然後排序會更快一些。

這樣用也是可以的:

上面的程式碼將會計算 V[0]*V[N-1] + V[1]+V[N-2] + … + V[N-1]*V[0],其中N是‘v’中元素的個數。

Nontrivial Sorting重要的排序

實際上,sort(…)採用了與STL相同的技術。

  • 所有的比較都基於運算子‘<’

這意味著你只需過載’operator <‘即可。示例程式碼:

為了以防萬一,你的物件應該有預設建構函式和拷貝建構函式(或許,還要過載賦值運算子——這條補充說明並非對TopCoders而言的)。

一定要牢記操作符 ‘ <’ 的原型:返回值是bool型別,有const修飾符,引數是const型別的引用。

另一個實現比較的方法是建立比較函式。特定的比較方式作為sort(…)演算法的第三個引數傳入。例如:按照極角大小對點排序(點的結構是pair<double,double>)

這段程式碼非常複雜,但是證明了STL的強大功能。應當指出,在這個例子中,所有的程式碼在編譯的時候都是內建(inline)的,實際上執行起來是很快的。

也要注意對於兩個相等的物件,操作符 ‘ <’ 會返回FALSE。這是非常重要的,接下來會解釋為什麼如此重要。

在map和set中使用自定義物件

set 和 map 中的元素是有序的。這是一個總體規則。所以,如果想在set或者map中使用你的物件,那麼這些物件應該是可比的。你已經瞭解了STL中的比較規則:

  • 所有的比較都基於運算子 ‘<’

也就是,你應該這樣理解:要實現set或者map中元素有序,我只需要實現操作符“<”

假設我們要對point結構體(或者point類)進行操作。我們想讓一些線段相交,並且取得這些焦點的集合(有點耳熟?)由於計算機精度有限,當一些點的座標差別不大時,這些點將會是相同的。我們應該這樣編碼:

現在,你可以使用set<point>或者map<point, string>,例如,查詢某些點是否已經在交集中存在。更進一步,使用map<point, vector<int> >,獲得相交在一點的所有線段索引的列表。

在STL中相等並不意味著相同,這是一個有趣的概念,但是此處我們不予深究。

Vector中的記憶體管理

據說vector不會在每次push_back()的時候都重新開闢記憶體。實際上,呼叫push_back()的時候,vector開闢了多於當前所需的記憶體空間。當呼叫push_back()的時候,vector的大部分STL實現都開闢了雙倍空間,並不需要每次都開闢分配記憶體。這或許在實際運用中並不好,因為你的程式佔用了雙倍的記憶體空間。有兩種簡易方法和一種複雜方法來處理這個問題。

第一種方法是使用vector的成員函式reserve()。這個函式使vector開闢多餘的記憶體。在未達到reserve()指定的大小之前,vector不會再次呼叫push_back()時開闢記憶體。

考慮一下接下來的例子。你有一個1000個元素的向量,它開闢了1024大小的空間。你打算向vector中追加50個元素。如果你呼叫50次push_back(),vector開闢的記憶體控制元件將會是2048.但是如果在呼叫push_back()之前加上這句程式碼:

Vector開闢的記憶體空間恰好容納1050個元素.

如果你經常使用push_back,那麼reserve()會使你受益匪淺。

順便說一句,對於vector來說,在copy(…, back_inserter(v)) 之後使用v.reserve()是一種很好的模式。

另外一種情況:你希望某些操作之後,vector佔用的記憶體不會增加。該如何擺脫潛在的記憶體追加呢?解決方案如下:

這段程式碼的含義是:建立一個與 ‘v’ 內容相同的臨時向量,然後這個臨時向量與 ‘v’ 互換。互換之後v中的多餘記憶體將會相會小時。在SRMs中這個方案很少用到。

恰當但複雜的解決方案是為vector開發自定義的分配符,但這很明顯不是本教程該討論的內容。

用STL實現真正的演算法

帶著STL知識,我們繼續這篇文章中最有意思的部分:如何實現真正高效的演算法?

深度優先檢索(DFS)

這裡不再贅述DFS的原理——可以閱讀 gladius 所著《Introduction to Graphs and Data Structures》教程中的 這一章——但是我將會展示STL如何有助於實現DFS。

首先,假設有一個無向圖。在STL中,儲存這個無向圖最簡單的方法是儲存每個節點的相鄰節點。最終生成結構體vector< vector<int> > W ,其中W[i] 是到節點 i 的相鄰節點列表。接下來證明一下我們是按照DFS儲存的。

這樣就證明完了。STL演算法”for_each”為V中的每一個元素呼叫指定的函式”dfs”。在check_graph_connected()函式中我們首先建立一個訪問標記陣列(陣列大小合適並且以0填充)。DFS呼叫完成之後,通過檢查V中是否有值為0的元素——只需呼叫一下find()函式就可以實現——就可以確認我們是否訪問到了所有結點,。

注意一下for_each:這個函式的最後一個引數,幾乎可以是任何具有函式功能的值。不僅可以是全域性函式,還可以是函式配接器,標準演算法甚至是成員函式。如果是成員函式的話,則需要成員函式或者是成員函式引用的配接器,在此我們不討論這個問題。

注:不建議使用vector<bool>。儘管在這個特定案例中這樣使用沒有問題,但最好還是避免這種做法。使用預定義的 ‘vi’  (vector<int>)。將“true”或者“false”作為int型別賦值給vi是沒有問題的。儘管這樣需要的記憶體是使用bool型的 8*sizeof(int)=8*4=32 倍,但是可以適應大多數情況並且在TopCoder上執行很快。

關於其他型別容器及其使用方法的簡要介紹

Vector由於是最簡單的陣列容器,因此非常受歡迎。在大多數情況下,你只用到vector的陣列功能。但是,有時你可能需要一個更高階的容器。

在 SRM(Single Round Match) 熱期間,研究某個STL容器的全部功能並不是一個好的做法。如果不清楚需要使用什麼容器,那麼你最好使用vector、map或者set。例如,stack可以通過vector實現,並且如果你忘記了stack容器的符號,這種方式可以執行的更快一些。

STL提供了以下容器:list、stack、queue、deque、priority_queue。我發現在SRM中,list和deque很少用到(除了在某些特殊的基於這些容器的任務中會用到)。但是,queue和priority_queue仍然有必要介紹一下。

Queue

Queue是一種具有三類操作的資料型別,所有操作的平均時間複雜度都是O(1):在頭部追加一個元素,在尾部移除一個元素,獲取第一個無法訪問的元素(“tail”)。換言之,queue是一個先進先出(FIFO)的緩衝區。

廣度優先檢索(BFS)

再次說明,如果你不熟悉BFS演算法,請首先參照一下這篇Topcoder教程(連結)。在廣度優先演算法(BFS)中使用queue是非常便捷的,如下所示:

更確切地說,queue 支援 front()、back()、 push()(==push_back())和 pop()( ==pop_front())操作。如果你會用到push_front()和pop_back(),就使用dequeue。Dequeue提供時間複雜度為O(1)的所有演算法。

queue和map有一個有趣的應用,用於在一幅複雜的圖中,通過BFS演算法實現最短路徑的檢索。假設我們有一幅圖,圖中的節點代表著某些複雜的東西。如:

假設已知我們要查詢的路徑很短,並且路徑上的位置節點很少。如果圖中所有邊的長度都為1,那麼我們可以使用BFS在這幅圖中檢索最短路徑。一段虛擬碼如下:

然而,如果圖中邊長不相等,那麼BFS演算法就無效了。這時我們應該使用Dijkstra演算法代替。通過 priority_queue可以實現這樣一個Dijkstra演算法,請繼續看後面的內容。

Priority_Queue

Priority_Queue是一個二進位制堆。它是一個可以執行以下操作的資料結構:

  • 壓入任意元素
  • 顯示頭部元素
  • 彈出頭部元素

STL中priority_queue的應用請看SRM307中TrainRobber問題。

Dijkstra

在本文的最後一節,介紹一下如何利用STL容器實現稀疏圖中的Dijkstra演算法。請讀這篇教程瞭解一下Dijkstra演算法。

假設我們有一幅帶比重的有向圖,這幅有向圖是以vector<vector<pair<int,int>>>G 儲存的,在G中

  • G.size() 代表有向圖中的節點數量
  • G[i].size() 是從索引為i的節點直接可達的節點數量
  • G[i][j].first 是從索引為i的節點直接可達的第j個節點的索引
  • G[i][j].second 是連線索引為i的節點和索引為 G[i][j].first 節點的邊長

我們假設在如下兩個程式碼段中這樣定義:

通過 priority_queue 實現 Dijstra 演算法

非常感謝 misof 抽出時間給我解釋為什麼這個演算法的時間複雜度很好,儘管沒有從queue中移除獨立的元素。

本文中我不想點評演算法本身,但是你應該注意到priority_queue物件的定義。一般而言,priority_queue<ii>是可以用的,但是成員函式top()將會返回佇列中最大的元素,而不是最小的。我常用的簡單解決方案之一是在pair的第一個元素不儲存偏移量而是儲存偏移量的負值。如果你想以合適的方法實現佇列的反轉,你需要實現priority_queue的反轉。priority_queue的第二個模板引數是容器的儲存型別,第三個模板引數則是比較函式的指標。

通過 set 實現 Dijkstra

在向Petr請教C#中Dijkstra的有效實現的時候,他給我講述了這個方法。在Dijkstra演算法的實現中,我們使用priority_queue向“已經分析過的結點”佇列中追加元素,平均時間複雜度和最差時間複雜度都是O(log N)。但是,除了priority_queue還有一個容器為我們提供這個功能——就是set。經過大量的實踐,我得出:基於priority_queue和基於set的Dijkstra演算法是一樣的效果。

基於set的程式碼如下:

另外重要的一點:STL中的priority_queue不支援DECREASE_KEY 操作,如果你需要這個操作,你最好使用於set。

我曾經花費了大量的時間來弄明白為什麼從queue(還有set)中移除元素和移除第一個元素一樣快。

這兩個實現有著同樣的複雜度並且花費一樣的時間。而且,我進行了實驗,兩種實現方式的效果幾乎相同(時間差大約是%0.1)

對我而言,我更傾向於利用set實現Dijkstra演算法,因為從邏輯上更容易理解,並且不需要記住greater<int>預示著重寫。

STL 之外的一些東西

讀到這裡,我希望你已經明白了STL是一個非常強大的工具,尤其對TopCoder SRMs來說。但是,在你使用STL之前,請記住哪些沒有包含在STL中。

首先,STL沒有BigInteger。如果SRM中的一個任務需要大量的運算,尤其是乘除運算,你有三種選擇:

  • 使用預先寫好的模板
  • 使用JAVA,如果你很熟練的話
  • 說“啊,這真的不是我能解決的SRM任務”

我建議第一個選項。

在幾何庫中有一個幾乎相同的問題。STL不支援幾何學,所以你再次面臨著上面的三個選項。

最後一件事情——有時很煩人的事情——是STL沒有內部的字串分割函式。如果這個ExampleBuilder外掛的預設C++模板中包含這個函式,就更麻煩了。但是,我發現在一般的案例中使用istringstream(s),在複雜的案例中使用sscanf(s.c_str(), …)就足夠了。

通過這些說明,希望你能夠認識到這篇文章的價值,也希望你能發現STL是C++中一個非常有用的附加項。祝你在競賽中好運。

作者注:在本教程的兩部分中,我都建議使用模板來減少實現某些功能的時間。這個建議一直適用於程式設計師。暫且不談在SRM中使用模板是不是一個好的策略,在日常生活中,模板對於想理解程式碼的人來說是一件煩人的事情。我有時會依賴於模板,最終我決定不再使用。我鼓勵你權衡使用模板類的利弊,然後做出自己的決定。

相關文章