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

柒柒發表於2015-06-29

或許你已經把 C++ 作為主要的程式語言用來解決 TopCoder 上的問題。這意味著你已經簡單使用過了 STL,因為陣列和字串都是作為 STL 物件傳遞給函式。也許你已經注意到了,很多程式設計師寫程式碼比你快得多,也更簡潔。

或許你還不是但想成為一名 C++ 程式猿,因為這種程式語言功能很強大還有豐富的庫(也許是因為在 TopCoder 的練習室裡和競賽中看到了很多非常精簡的解決方案)。

無論過去如何,這篇文章都會有所幫助。在這裡,我們將回顧標準模板庫(Standard Template Library—STL,一個非常有用的工具,有時甚至能在演算法競賽中為你節省大量時間)的一些強大特性。

要熟悉 STL,最簡單的方式就是從容器開始。

容器

無論何時需要操作大量元素,都會用到某種容器。C語言只有一種內建容器:陣列。

問題不在於陣列有侷限性(例如,不可能在執行時確定陣列大小)。相反,問題主要在於很多工需要功能更強大的容器。

例如,我們可能需要一個或多個下列操作:

  • 向容器新增某種字串
  • 從容器中移除一個字串
  • 確定容器中是否存在某個字串
  • 從容器中返回一些互不相同的元素
  • 對容器進行迴圈遍歷,以某種順序獲取一個附加字串列表。

當然,我們可以在一個普通陣列上實現這些功能。但是,這些瑣碎的實現會非常低效。你可以建立樹結構或雜湊結構來快速解決問題,但是想想:這種容器的實現是取決於即將儲存的元素型別嗎?例如,我們要儲存平面上的點而不是字串的話,是不是要重寫這個模組才能實現功能?

如果不是,那我們可以一勞永逸地為這種容器開發出介面,然後對任何資料型別都能使用。簡言之,這就是 STL 容器的思想。

前言

程式要使用 STL 時,應包含(#include)適當的標準標頭檔案。對大部分容器來說,標準標頭檔案的名稱和容器名一致,且不需副檔名。比如說,如果你要用棧(stack),只要在程式最開頭新增下面這行程式碼:

容器型別(還有演算法、運算子和所有 STL也一樣)並不是定義在全域性名稱空間,而是定義在一個叫“std”的特殊名稱空間裡。在包含完所有標頭檔案之後,寫程式碼之前新增下面這一行:

還有另一個很重要的事情要記住:容器型別也是模板引數。在程式碼中用“尖括號”(‘<’/’>’)指明模板引數。比如:

如果要進行巢狀式的構造,確保“方括號”之間不是緊挨著——留出一個空格的位置。(譯者:C++11新特性支援兩個尖括號之間緊挨著,不再需要加空格)

Vector

最簡單的 STL 容器就是 vector。Vector 只是一個擁有擴充套件功能的陣列。順便說一下,vector 是唯一向後相容 C 程式碼的容器——這意味著 vector 實際上就是陣列,只是擁有一些額外特性。

實際上,當你敲下

就建立了一個空 vector。注意這樣的構造方式:

這裡我們把’V’宣告成一個存放了 10 個 vector<int> 型別元素的陣列,初始化為空。大部分情況下,這不是我們想要的。在這裡用圓括號代替方括號。Vector 最常使用的特性就是獲取容器大小。

有兩點要注意:首先,size() 函式返回的值是無符號的,這點有時會引起一些問題。因此,我經常定義巨集,有點像 sz(C) (把C 的大小作為一個普通的帶符號整型返回)這樣的。其次,如果你想知道容器是否為空,把 vector 的 size() 返回值和0比較不是一個好的做法。你最好使用 empty() 函式:

這是因為,不是所有容器都能在常量時間內返回自己的大小,而且你絕不應該為了確定連結串列中至少包含一個節點元素就對一條雙連結串列中的所有元素計數。

另一個 vector 中經常使用的函式是 push_back。Push_back 函式向 vector 尾部新增一個元素,容器長度加 1。思考下面這個例子:

別擔心記憶體分配問題——vector 不會一次只分配一個元素的空間。相反,每次用 push_back 新增新元素時,vector 分配的記憶體空間總是比它實際需要的更多。你應該擔心的唯一一件事情是記憶體使用情況,但在 TopCoder 上這點可能不是問題。(後面再進一步探討 vector 的記憶體策略)

當你需要重新改變 vector 的大小時,使用 resize() 函式:

Resize() 函式讓 vector 只儲存所需個數的元素。如果你需要的元素個數少於 vector 當前儲存的個數,剩餘那些元素就會被刪除。如果你要求 vector 變大,使用這個函式也會擴大它的長度,並用 0 填充新建立的元素。

注意,如果在使用了 resize() 後又用了 push_back(),那新新增的元素就會位於新分配記憶體的後面,而不是被放入新分配的記憶體當中。上面的例子得到的 vector 大小是25,如果在第二個迴圈中使用 push_back(),那vector 的大小最後會是30。

使用 clear() 函式來清空 vector。這個函式使 vector 包含 0 個元素。它並不是讓所有元素的值為0——注意——它是完全刪除所有元素,成為空容器。

有很多種方式初始化 vector。你也許用另一個 vector 來建立新的 vector:

上面的例子中,v2 和 v3 的初始化過程一樣。如果你想建立指定大小的 vector,使用下面的建構函式:

上面的例子中,變數 data 建立後將包含1,000 個0值元素。記得使用圓括號,而不是方括號。如果你想用其他東西來初始化 vector,你可以這麼寫:

記住,你可以建立任何型別的 vector。多維陣列很重要。通過 vector 建立二維陣列,最簡單的方式就是建立一個儲存 vector 元素的 vector。

你現在應該清楚如何建立一個給定大小的二維 vector:

這裡,我們建立了一個 N*M 的矩陣,並用 -1 填充所有位置上的值。向 vector 新增資料的最簡單方式是使用 push_back()。但是,萬一我們想在除了尾部以外的地方新增資料呢?Insert() 函式可以實現這個目的。同時還有 erase() 函式來刪除元素。但我們得先講講迭代器。

你還應該記住另一個非常重要的事情:當 vector 作為引數傳給某個函式時,實際上是複製了這個 vector(也就是值傳遞)。在不需要這麼做的時候建立新的 vector 可能會消耗大量時間和記憶體。實際上,很難找到一個任務需要在傳遞 vector 為引數時對其進行復制。因此,永遠不要這麼寫:

相反,使用下面的構造方法(引用傳遞):

如果在函式裡要改變 vector 中的元素值,那就去掉‘const’修飾符。

鍵值對

在討論迭代器之前,先說說鍵值對(pairs)。STL 中廣泛使用鍵值對。一些簡單的問題,像 TopCoder SRM 250 和 500 分值的簡單題,通常需要一些簡單的資料結構,它們都非常適合用 pair 來構造。STL 中的 std::pair 就是一個元素對。最簡單的形式如下:

普通的 pair<int,int> 就是一對整型值。來點更復雜的,pair<string,pair<int,int>> 就是一個字串和兩個整型組成的值對。第二種情況也許能這麼用:

鍵值對的最大優勢就在於它們有內建操作來比較 pair 物件。鍵值對優先對比第一個元素值,再比較第二個元素。如果第一個元素不相等,那結果就只取決於第一個元素之間的比較;只有在第一個元素相等時才比較第二個元素。使用 STL 的內建函式,可以輕易地對陣列(或 vector)對進行排序。

例如,如果要對存放整型值座標點的陣列排序,使得這些點排列成一個多邊形,一種很好的思路就是把點放入 vector<pair<double, pair<int, int>>>,其中每個元素表示成 {polar angle,{x, y}}(點的極角和點的座標值)。呼叫 STL 的排序函式可以按你的期望對點進行排序。

關聯容器中也廣泛使用 pair,這點會在文章後面提及。

迭代器

什麼是迭代器?STL 迭代器是訪問容器資料的最普通的方式。思考這個簡單的問題:將包含 N 個整型(int)的陣列 A 倒置。從類 C 語言的方案開始:

對你來說這些程式碼應該一目瞭然。很容易用指標來重寫:

看看這個程式碼的主迴圈,它對指標‘first’和‘last’只用了四種不同的操作:

  • 比較指標(first < last),
  • 通過指標取值(*first,*last),
  • 指標自增,以及
  • 指標自減

現在,想象你正面臨第二個問題:將一個雙連結串列翻轉,或部分翻轉。第一個程式使用了下標,肯定不行。至少效率不夠,因為不可能在常數時間內通過下標獲取雙連結串列中的元素值,必須花費 O(N) 的時間複雜度,所以整個演算法的時間複雜度是 O(N^2)。

但是你看:第二個程式對任何類似指標(pointer-like)的物件都能奏效。唯一的要求是,物件能夠執行上面所列出的四種操作:取值(一元運算子 *),對比(<),和自增/自減(++/–)。擁有這些屬性並和容器相關聯的物件就叫迭代器。任何 STL 容器都可以通過迭代器遍歷。儘管 vector 不常用,但對其他型別的容器很重要。

那麼,我們現在討論的這個東西是什麼?一個語法上很像指標的物件。為迭代器定義如下操作:

  • 從迭代器取值,int x = *it;
  • 讓迭代器自增和自減 it1++,it2–;
  • 通過‘!=’和‘<’來比較迭代器大小;
  • 向迭代器新增一個常量值 it += 20;(向前移動了 20 個元素位置)
  • 獲取兩個迭代器之間的差值,int n = it2 – it1;

和指標不同,迭代器提供了許多更強大的功能。它們不僅能操作任何型別的容器,還能執行範圍檢查並分析容器的使用。

當然,迭代器的最大優勢就是極大地增加了程式碼重用性:基於迭代器寫的演算法在大部分的容器上都能使用,而且,自己寫的容器要是提供了迭代器,就能作為引數傳給各種各樣的標準函式。

不是所有型別的迭代器都會提供所有潛在的功能。實際上,存在所謂的“常規迭代器”和“隨機存取迭代器”兩種分類。簡單地說,常規迭代器可以用‘==’和‘!=’來做比較運算,而且還能自增和自減。它們不能做減法,也不能在常規迭代器上做加法。基本上來說,不可能對所有型別的容器都在常數時間範圍內實現以上描述的操作。儘管如此,翻轉陣列的函式應該這麼寫:

這個程式和前面一個程式的主要差別在於,我們沒有在迭代器上進行“<”比較,只用了“==”比較。再次強調,如果你對函式原型感到驚訝(發現函式原型和實際不同),不要慌張:模板只是宣告函式的一種方式,對任何恰當的引數型別都是有效的。

對指向任意物件型別的指標和所有常規迭代器來說,這個函式應該都能完美執行。

還是回到 STL 上吧。STL 演算法常常使用兩個迭代器,稱為“begin”和“end”。尾部迭代器不指向最後一個物件,而是指向第一個無效物件,或是緊跟在最後一個物件後面的物件。這一對迭代器使用起來通常很方便。

每一個 STL 容器都有 begin() 和 end() 兩個成員函式,分別返回容器的初始迭代器和尾部迭代器。

基於這些原理,只有容器 c 為空時,“c.begin() == c.end()”才成立,而“c.end() – c.begin()”總是會等於 c.size()。(後一句只有在迭代器可以做減法運算時才有效,例如,begin() 和 end() 都返回隨機存取迭代器,但不是所有容器的這兩個函式都這樣。見前面的雙向連結串列示例。)

相容 STL 的翻轉函式應該這麼寫:

注意,這個函式和標準函式 std::reverse(T begin, T end) 的功能一樣,這個標準函式可以在演算法模組找到(標頭檔案要包含 #include <algorithm>)。

另外,只要物件定義了足夠的功能函式,任何物件都可以作為迭代器傳遞給 STL 演算法和函式。這些就是模板的強大來源。看下面的例子:

最後一行程式碼用一個普通陣列 C 構造了一個 vector。不帶下標的‘data’作為一個指向陣列頭的指標。‘data + N’指向第 N 個元素,因此,當 N 表示陣列大小時,‘data + N’就指向第一個不在陣列內的元素,那麼‘data + length of data’可以作為陣列‘data’的尾部迭代器。表示式‘sizeof(data)/sizeof(data[0])’返回陣列 data 的大小,但只在少數情況下才成立。因此,除非是用這種方法構造的容器,否則不要在任何其他情況下使用這個表示式來獲取容器大小。

此外,我們甚至可以像下面這樣構造容器:

構造的vector容器 v2 等於v 的前半部分。下面是翻轉函式 reverse() 的示例:

每個容器都有 rbegin()/rend() 函式,它們返回反向迭代器(和正常迭代器的指向相反)。反向迭代器用來從後往前地遍歷容器。因此:

上面用 v 的前半部分來構造 v2,但順序上前後顛倒。要建立一個迭代器物件,必須指定型別。在容器的型別後面加上“::iterator”、“::const_iterator”、“::reverse_iterator”或“::const_reverse_iterator”就可以構建迭代器的型別。因此,可以這樣遍歷 vector:

我推薦使用‘!=’而不是‘<’,使用‘empty()’而不要用‘size() != 0’——對於某些容器型別來說,無法高效地確定迭代器的前後順序。

現在你瞭解了 STL 演算法 reverse()。很多 STL 演算法的宣告方式相同:得到一對迭代器(一個範圍的初始迭代器和尾部迭代器),並返回一個迭代器。

Find() 演算法在一個區間內尋找合適的元素。如果找到了合適的元素,就返回指向第一個匹配元素的迭代器。否則,返回的值指向區間的尾部。看程式碼:

要得到被找到元素的下標,必須用 find() 返回的結果減去初始迭代器:

使用 STL 演算法時,記得在原始碼中加上 #include <algorithm>。

Min_element 和 max_element 演算法分別返回指向最小值元素和最大值元素的迭代器。要得到最小/最大值元素的值,就像在函式 find() 中一樣,用 *min_element(…) 和 *max_elment(…),在陣列中減去一個容器或範圍的初始迭代器來取得下標值:

現在,你可以看到一個有效的巨集定義如下:

不要將巨集定義中的右邊部分全部放到圓括號中去——那是錯的!

另一個很好的演算法是 sort(),使用很簡單。思考下面的示例:

編譯 STL 程式

在這裡有必要指出 STL 的錯誤資訊。由於 STL 分佈在原始碼中,那編譯器就必須建立有效的可執行檔案,而 STL 的一個特性就是錯誤資訊不可讀。例如,如果你把一個 vector<int> 作為常引用引數(當你應該這麼做的時候)傳遞給某個函式:

這裡的錯誤是,你正試圖對一個定義了 begin() 成員函式的常量物件建立非常量迭代器(因為識別這種錯誤比實際更正它更難)。正確的程式碼是這樣:

儘管如此,還是來說說‘typeof’,它是 GNU C++ 非常重要的特性。在編譯過程中,這個運算子會被替換成表示式的型別。思考下面的示例:

這句程式碼建立了變數 x,它的型別和表示式 (a + b)的型別一致。注意,對任何型別的 STL 容器來說,typeof(v.size()) 得到的值都是無符號的。但在Topcoder 上,typeof 最重要的應用是遍歷容器。思考下列巨集定義:

使用這些巨集,我們可以遍歷每一種容器而不僅僅是 vector。這些巨集會為常量物件生成 const_iterator,為非常量物件生成常規迭代器,而你永遠不會在這裡出錯。

注意:為了提高可讀性,在 #define 這一行我並沒有新增額外的圓括號。閱讀文章的後續部分得到更多關於 #define 的正確表述,你可以在練習室裡面自己試試。

Vector 不需要真的遍歷巨集定義,但對於更復雜的資料型別(不支援下標,迭代器是獲取資料的唯一方式)來說很方便。我們稍後會在文章中談及這一點。

Vector 中的資料操作

可以用 insert() 函式往 vector 中插入一個元素:

從第二個(下標為1的元素)往後的所有元素都要右移一位,從而空出一個位置給新插入的元素。如果你打算新增很多元素,那多次右移並不可取——明智的做法是單次呼叫 insert()。因此,insert() 有一種區間形式:

Vector 還有一個成員函式 erase,它有兩種形式。猜猜都是什麼:

第一個例子刪除 vector 中的單個元素,第二個例子用兩個迭代器指定區間並從vector 中刪除整個區間內的元素。

字串(string)

這是一個操縱字串的特殊容器。這個字串容器稍微不同於 vector<char>。絕大部分的不同在於字串控制函式和記憶體管理策略。字串有不支援迭代器的子串函式 substring(),只支援下標:

謹防對空串執行(s.length() – 1),因為 s.length() 的返回值不帶符號,而 unsigned(0) – 1 得到的結果絕對不是你想的那樣。

Set

總是很難決定要先描述哪種容器——set 還是 map。我的觀點是,如果讀者瞭解一些演算法的基本知識,從‘set’開始會更容易理解。

思考我們需要一個擁有下列特性的容器:

  • 新增一個元素,但不允許和已有元素重複[複製?]
  • 移除元素
  • 獲取元素個數(不同元素的個數)
  • 檢查集合中是否存在某個元素

這個操作的使用相當頻繁。STL 為此提供了特殊容器——set。Set 可以在 O(log N)(其中 N 是 set 中物件的個數)的時間複雜度下新增、移除元素,並檢查特定元素是否存在。向 set 新增元素時,如果和已有元素值重複,那新新增的元素就會被拋棄。在常數時間複雜度 O(1) 下返回 set 的元素個數。我們將在後面討論 set 和 map 的演算法實現——現在,我們研究一下函式介面:

Set 不使用 push_back() 成員函式。這樣是有道理的:因為 set 中元素的新增順序並不重要,因此這裡用不上 push_back()。

由於 set 不是線性容器,不可能用下標獲取 set 中的元素。因此,遍歷 set 元素的唯一方法就是使用迭代器。

在這裡使用遍歷巨集會更簡潔。為什麼?想象一下你有這樣的容器 set<pair<string,pair<int,vector<int>>>>,怎麼遍歷呢?寫迭代器的型別名稱?天吶,還是用我們為遍歷迭代器型別而定義的巨集吧。

注意這樣的語法‘it->second.first’。由於‘it’是一個迭代器,所以我們必須在運算前從‘it’得到物件。因此,正確的語法是‘(*it).second.first’。無論如何,寫‘something->’總是比寫‘(*something)’更容易。完整的解釋會很長——只要記住,對迭代器而言兩種語法都允許。

使用‘find()’成員函式確定集合 set 中是否存在某個元素。不要搞混了,因為 STL 中有很多‘find()’。有一個全域性演算法‘find()’,輸入兩個迭代器和一個元素,它能工作在 O(N) 的線性時間複雜度下。你可能會用它來搜尋 set 中的元素,但是明明存在一個 O(log N) 時間複雜度的演算法,為何要用一個 O(N) 的演算法呢?在 set 和 map (還包括 multiset/multimap、hash_map/hash_set等容器)中搜尋元素時,不要使用全域性的搜尋函式 find() ——反而應該使用成員函式‘set::find()’。作為‘順序的’find函式,set::find 會返回一個迭代器,不論這個迭代器指向被找到的元素,還是指向‘end()’。因此,像這樣檢查元素是否存在:

作為成員函式被呼叫時,另一個工作在 O(log N) 時間複雜度下的演算法是計數函式 count。有的人認為這樣

或者甚至這樣

寫更方便。個人來說,我不這麼想。在 set/map 中使用 count() 沒有意義:元素要麼存在,要麼不存在。對我來說,我更願意使用下面兩個巨集:

(記住 all(c) 代表“c.begin(), c.end()”)

這裡,‘present()’用成員函式‘find()’ (比如 set/map 等等)來返回容器中是否存在某個元素,而‘cpresent’則是為 vector 定義的。

使用 erase() 函式從 set 中刪除一個元素。

Erase() 函式也有區間操作形式:

Set 有一個區間建構函式:

這樣可以輕鬆避免 vector 中的重複元素,然後排序:

這裡,‘v2’將和‘v’包含相同元素,但以升序排列,並且移除了重複元素。任何可比較的元素都可以儲存在 set中。這個在後面解釋。

Map

Map 有兩種解釋。簡單版本如下:

很簡單,對吧?

實際上,map 非常像 set,除了一點——它包含的不只是值而是鍵值對 pair<key, value>。Map 保證最多隻有一個鍵值對擁有指定鍵。另一個很討喜的地方是, map 定義了下標運算子 []。

用巨集‘tr()’可以輕易遍歷 map。注意,迭代器是鍵值對 std::pair。因此,用 it->second 來取值,示例如下:

不要通過迭代器來更改 map 元素的鍵,因為這可能破壞 map 內部資料結構的完整性(見下面的解釋)。

在 map::find() 和 map::operator [] 之間有一個重要的區別。Map::find() 永遠不會改變 map 的內容,而操作符 [] 則會在元素不存在時建立一個新元素。有時這樣做很方便,但當你不想新增新元素時,在迴圈中多次使用操作符 [] 絕對不是好主意。這就是為什麼把 map 作為常引用引數傳遞給某個函式時,可能不用操作符 [] 的原因:

關於 Map 和 Set 的注意事項

從內部看,map 和 set 幾乎都是以紅黑樹的結構儲存。我們確實不必擔憂內部結構,要記住的是,遍歷容器時 map 和 set 的元素總是按升序排列。而這也是為何在遍歷 map 或 set時,極力不推薦改變鍵值的原因:如果所做的修改破壞了元素間的順序,這至少會導致容器的演算法失效。

但在解決 TopCoder 的問題時,幾乎都會用上 map 和 set 的元素總是有序這個事實。

另一件重要的事情是,map 和 set 的迭代器都定義了運算子 ++ 和 –。因此,如果 set 裡存在值 42,而它不是第一個也不是最後一個元素,那下列程式碼會奏效:

這裡的‘a’包含 42 左邊的第一個相鄰元素,而‘b’則包含右邊的第一個相鄰元素。

進一步討論演算法

是時候稍微深入探討演算法。大部分演算法都宣告在標準標頭檔案 #include <algorithm> 中。首先,STL 提供了三種很簡單的演算法:min(a, b)、max(a, b)、swap(a, b)。這裡,min(a, b) 和 max(a, b) 分別返回兩個元素間的最小值和最大值,而 swap(a, b) 則交換兩個元素的值。

演算法 sort() 的使用也很普遍。呼叫 sort(begin, end) 按升序對一個區間的元素進行排序。注意,sort() 需要隨機存取迭代器,因此它不能作用在所有型別的容器上。無論如何,你很可能永遠都不會對已然有序的 set 呼叫 sort()。

你已經瞭解了演算法 find()。呼叫 find(begin, end, element) 返回‘element’首次出現時對應的迭代器,如果找不到則返回 end。和 find(…) 相反,count(begin, end, element) 返回一個元素在容器或容器的某個範圍內出現的次數。記住,set 和 map 都有成員函式 find() 和 count(),它們的時間複雜度是 O(log N),而 std::find() 和 std::count() 的時間複雜度是 O(N)。

其他有用的演算法還有 next_permutation() 和 prev_permutation()。先說說 next_permutation。呼叫 next_permutation(begin, end) 令區間 [begin, end) 儲存區間元素的下一個全排列順序,如果當前順序已是最後一種全排列則返回 false。當然, next_permutation 使得許多工變得相當簡單。如果你想驗證所有的全排列方式,只要這麼寫:

在第一次呼叫 next_permutation(…) 之前,別忘了確保容器中的元素已排序。元素的初始狀態應該形成第一個全排列狀態;否則,某些全排列狀態會被遺漏,得不到驗證。

字串流

你常常需要進行一些字串的處理、輸入或輸出,C++ 為此提供了兩個有趣的物件:‘istringstream’和‘ostringstream’。這兩個物件都宣告在標準標頭檔案 #include <sstream> 中。

物件 istringstream 允許你從一個字串讀入,就像從一個標準輸入讀資料一樣。直接看原始碼:

物件 ostringstream 用來格式化輸出。程式碼如下:

總結

為了繼續探討 STL,我將總結後面會用到的模板列表。這會簡化程式碼示例的閱讀,並且希望能提高你的 TopCoder 技巧。模板和巨集的簡短列表如下:

由於容器 vector<int> 的使用相當普遍,因此在列表中一併列出。實際上我發現,給許多容器(尤其是 vector<string>、vector<ii>、vector<pair<double, ii>>等等)定義簡短的別稱非常方便。但上面的列表只給出了理解後文所需的巨集。還有一點要牢記:當 #define 左側的符號出現在右側時,為了避免很多棘手的問題,應該在上面加上一對圓括號。

相關文章