1. 程式人生 > >STL中常用容器的選擇

STL中常用容器的選擇

今天去面試問到了stl的常用容器演算法問題,但是提前沒準備,平時也沒太在意,還有就是忘了。總之,回答得很狼狽。

希望能在這裡整理一下,首先看了一下《STL原始碼剖析》中對STL六大元件是這樣介紹的:

容器(containers):各種資料結構,用來存放資料。從實現的角度看,容器是一種class template。

演算法(algorithms):各種常用演算法,如sort,search,copy...從實現角度看,演算法是一種function template。

迭代器(iterators):扮演容器與演算法之間的膠合劑,是所謂的泛型指標。共有五種型別,及其他的衍生變化。從實現角度看,迭代器是一種將operator*,operator->,operator++,operator--等指標相關操作予以過載的class template。所有STL容器都附帶有自己的專屬容器——是的,只有容器設計者才知道如何遍歷自己的元素。

仿函式(functors):行為類似函式,可以作為演算法的某種策略。從實現角度看,仿函式是過載了一種operator()的class或class template。

配接器(adapters):一種用來修飾容器或仿函式或迭代器介面的東西。配接器的實現技術很難一言以蔽之,不許逐一分析。

配置器(allocators):負責空間配置與管理。從實現的角度看,配置器是一個實現了動態空間配置,空間管理,空間釋放的class template。

記得我第一次看這裡的時候,對這些元件是沒弄懂的,只是囫圇吞棗。看樣子書還是要重複的看才行,隔一段時間再看,一些不懂的東西可能就豁然開朗了。

六大元件之間的關係如下:


根據資料在容器中的排列特性,可分為序列式容器和關聯式容器:


容器

序列式容器

所謂序列式容器,其中的元素都可序,但未必有序。

vector

vector其實和標準庫中的陣列array十分相似。若在用C++程式設計中,你想到了用陣列,那比較好的建議是你可以用vector。他們之間的區別是,陣列是靜態的,空間分配需要程式設計師自己來管理;而vector是動態的,由它的內部機制自己管理記憶體空間。

vector的內部機制,關鍵在於其對空間大小的控制以及重新分配時的資料移動效率。vector採用的是線性的連續空間,在新增元素時,如果超過當時的容量,則容量會擴充至現有的兩倍。不是簡單的增加一個元素空間,因為在連續空間中擴充空間是一個比較複雜的過程,需要重新配置、移動資料、釋放原空間。但是,在erase元素時,容量大小是不會變的。

從效率上看,由於vector是連續的,所以隨機讀取效率很高,但是insert操作,效率比較低。若需要頻繁的進行插入,vector不是很好的選擇。

list

list的資料結構是一個迴圈雙向連結串列,所以只需要一個指標,可以遍歷整個連結串列。list的這種結構,使插入的效率比較高。

list和vector是兩個比較常用的容器,選擇哪一個必須視情況而定。list提供的元素操作很多,這裡稍微列舉一些:

push_front \ pop_front :插入\移除頭結點

push_back \ pop_back :插入\移除尾節點

clear:移除所有節點

remove(value):將數值為value的所有元素移除

unique:移除數值相同的連續元素。只有連續相同的元素,才會被移除剩餘一個。

splice(iterator position, list<T,Allocator>& x ):將x移動到pos位置之前,x必須不同於*this,x中的元素會刪除

splice ( iterator position, list<T,Allocator>& x, iterator i ):將i所指元素,移動到pos之前,pos和i可以是同一個list,在x中i會被刪除

splice ( iterator position, list<T,Allocator>& x, iterator first, iterator last ):將[first, last]內的所有元素移動到pos之前,pos和[first, last]可以指向同一個list,但pos不能在[first, last]之內。[first, last]將從x中刪除。

merge(list& x):將x合併到*this身上,兩個list的內容必須經過遞增排序

reverse():將sort的內容逆向重置。

sort():list不能使用STL的演算法sort,必須使用自己的成員函式sort()

list有一個重要性質:insert和splice都不會造成原有的list迭代器失效。這在vector中是不成立的,因為vector的insert操作會造成空間重新配置,原有的迭代器全部失效。要理解這句話,要結合例項思考一下。

deque

deque 顧名思義是雙端佇列,可以在頭尾兩端分別做元素的插入和刪除操作。從邏輯上看,deque是一種雙向開口的連續線性空間。實際上,它是由分段的連續空間組合而成。跟vector不一樣,它沒有容量的概念。在擴充空間的時候,不用像vector那樣重新配置、移動資料、釋放原空間。deque由一段一段的定量連續空間組成,一旦有必要在deque兩端及頭部或尾部新增加空間,便配置一段定量的連續空間,串接在整個deque的頭部或尾部。

deque採用一塊所謂的map(不是STL 中的map容器)作為主控。這裡的map是一小塊連續的空間,其中每個元素都是指標,指向另一段較大的連續線性空間,稱為緩衝區。緩衝區才是deque的儲存主體。STL中可以指定緩衝區的大小,預設值0表示將使用512bytes緩衝區。 template<class T, class Alloc = alloc, size_t BufSize = 0> class deque { public:     typedef T value_type;     typedef value_type* pointer;     .... protected:     typedef pointer* map_pointer; public:     typedef __deque_iterator<T, T&, T*, BufSize> iterator; protected:     iterator start;     iterator finish;     map_pointer map;      // map是一個T**     size_type map_size;    //map可以容納多少指標。一旦map容量不足,就必須重新配置一塊更大map。初始最小值為8。 } deque的結構設計圖,map和緩衝區的關係如下圖: deque是分段連續空間,要維持“整體連續”的假象,由deque的迭代器完成。 map中控器,緩衝區和迭代器的相互關係如下圖所示: deque的資料結構與記憶體管理,可以由如下圖說明: 從上面三個圖片可以發現一個問題:當最後一個緩衝區滿了,會多分配一個新的緩衝區備用;但當第一個緩衝區滿了,再push_front新元素時,才會分配新的緩衝區。設計者們為什麼要這樣做?在pop元素時,在尾端和頂端釋放緩衝區時是否也會這樣?現在還不知道。。。。 這裡有個比較關心的問題,map是如何工作的?當push_back和push_front,備用空間不足時,map是怎麼增加節點的,當map容量不夠時,有時如何重新配置更大map的。這個問題的實現是由reserve_map_at_back和reserve_map_at_front完成的,最終實現是由reallocate_map()執行的。 void reserve_map_at_back(size_type nodes_to_add = 1) {     if(nodes_to_add +1 > map_size - (finish.node - map))  //nodes_to_add+1和reserve_map_at_front中的不加1,可以看出處理第一緩衝區滿和最後一個緩衝區滿的區別
        reallocate_map(nodes_to_add, false); } void reserve_map_at_front(size_type nodes_to_add = 1) {     if(nodes_to_add > start.node - map)          reallocate_map(nodes_to_add, false); } template<class T, class Alloc, size_t BufSize> void deque<T, Alloc, BufSize>::reallocate_map(size_type nodes_to_add, bool add_at_front) {     size_type old_num_nodes = finish.node - start.node +1;     size_type new_num_nodes = old_num_nodes + nodes_to_add;     if(map_size > 2 * new_num_nodes)     {         //在原有的map下操作     }     else     {          //配置一塊新的map空間      } } deque的元素操作:pop_back, pop_front, clear, erase, insert pop_back/pop_front: pop元素的時候,若剛好最後一個緩衝區沒有元素或第一個緩衝區只有一個元素,則要對該緩衝區進行釋放。 clear:deque在最初狀態會保留一個緩衝區,clear之後會回到初始狀態。 erase:可以清除一個元素,也可以清除一個區間的元素。 insert:允許在某個點之前插入一個元素,並設定其值。很容易想象的出來,因為deque的假象是“整體連續”的,所以插入的效率並不會很高,若插入點之前的元素比較少,則移動前面的元素;否則,移動插入點後面的元素。

queue和stack其實是非容器,準確說應該是配接器。它們底層是用其他容器實現的,預設通過deque,用list也可以。這裡暫且把他倆放在容器這塊。

stack

stack是一種先進後出的資料結構,只允許在一端插入元素和移出或取得最頂端元素。 STL中stack是以某種既有的容器為底部結構,將其介面改變,使其符合stack“先進後出”的特性。所以往往我們並不把stack歸結為容器,而是被歸類為配接器。 template<class T, class Sequence = deque<T> > class stack{ ..... } 從stack的原始碼可以看到,只要底層容器的函式有empty,size,back,pop_back, push_back, 就可以作為stack的底層容器。list和deque都符合,stack的預設底層容器是deque。 還要注意點,stack必須符合先進後出的特性,且只允許在頂端操作,所以stack是不提供迭代器的。

queue

queue是一種“先進先出”的資料結構。只允許底端加入元素,頂端取出元素,沒有其他方法可以存取queue的其他元素,即不可以實現遍歷。queue和stack一樣,被歸類為配接器,不應該屬於容器。也可以通過既有容器deque,list作為它的底部結構。queue也沒有迭代器。

關聯式容器

set

set的特性是,所有元素都會根據元素的鍵值進行自動排序。set元素的鍵值就是實值,不允許兩個元素有相同的鍵值。不能根據set的迭代器改變set的元素值,因為元素的實值就是set的鍵值,改變會破壞排序規則。
set擁有和list某些相同的性質:當進行元素新增操作或刪除操作後,操作之前的所有迭代器都依然有效,除了被刪除的那個元素的迭代器。
set採用的底層機制是紅黑樹--RB-tree。紅黑樹是一種平衡二叉搜尋樹,自動排序的效果很不錯。
注意一點:在set中一般不用stl中的find演算法,一般用set提供的find方法更有效率。

multiset的特性以及用法 和set完全一樣,唯一區別是multiset允許鍵值重複。因為它的插入操作採用的是RB-tree的insert_equal,而不是insert_unique。

map

map的特性是,所有的元素會根據元素的鍵值自動排序,map的所有元素都是pair,pair的第一元素視為鍵值,第二元素視為實值。map不允許元素擁有相同的鍵值,不可改變map的鍵值,但可以修正元素的實值。
map在進行新增操作或刪除操作之後,操作之前的迭代器也都依然有效。因為map和set一樣,底層也是採用紅黑樹——RB-tree來實現。

multimap的特性以及用法 和map完全一樣,唯一區別是multimap允許鍵值重複。因為它的插入操作採用的是RB-tree的insert_equal,而不是insert_unique。

priority_queue

priority_queue 是一個擁有權值的queue,其內的元素並非按照被推入的次序排列,而是依照元素的權值排列。
在預設情況下,priority queue底層是利用max heap完成,大頂堆是通過vector實現的完全二叉樹——complete binary tree。
heap並不屬於STL容器元件,它是個幕後英雄,扮演priority_queue的助手。binary heap是一種完全二叉樹,有max heap和min heap之分,STL中採用的是max heap——大頂堆。所以priority queue允許使用者以任何次序將元素推入容器內,但取出時一定從優先順序最高的元素開始取。

可以思考,priority queue為什麼不採用list或binary search tree作為底層機制?
若使用list,元素插入達到常數級別,但取出極值,需要對整個list線性掃描;也可先對list進行排序,這時取極值很快,但插入操作只有線性表現。
若使用binary search tree, 插入和取極值的時間複雜度都可達到o(log(n)), 但一來二叉查詢樹的輸入需要足夠的隨機性(沒弄懂);二來binary search tree的實現不容易。

hashtable

hash table實現是通過hash function實現的,但hash function 會帶來“碰撞”問題。避免元素“碰撞”的方法比較多,常用的是二次線性探測和開鏈法。在STL中,hash table採用的是開鏈法。

hash table內的元素為bucket, 每個bucket維護一個link list,但list並不是用stl的list和slist,而是自己定義hash table node。buckets聚合體則是由vector來完成,以便有動態擴充套件的能力。

template<class Value>
struct __hashtable_node{
__hashtable_node* next;
Value val;
}
注意一點,hashtable的迭代器沒有後退操作。

hashtable的模版引數很多,要正確運用它不太容易。

template<class Value, class Key, //節點的實值型別
class HashFcn, //節點的鍵值型別
class ExtractKey, //從節點中取出鍵值的方法(函式或仿函式)
class EqualKey, //判斷鍵值相同與否的方法(函式或仿函式)
class Alloc>//空間配置器,預設std::alloc
class hashtable{
....
}

雖然開鏈法(separate chaining)並不要求表格大小為質數, 但SGI STL仍以質數來設計表格大小。並且先將28個質數計算好(大約兩倍的關係), 以備隨時訪問, 同時提供一個函式,用來查詢28個質數中,最接近某數並大於某數的質數。
在進行插入操作時,表格有可能會重建。表格是否重建的判斷,是拿元素個數和bucket vector的大小來比,若前者大於後者,則重建。表格重建操作分解:
前面說過,hashtable是通過hash functions實現的,其實hash functions只是用來計算元素位置的函式,STL中定義有現成的hash functions,全都是仿函式。通過呼叫hash functions,取得一個可以對hashtable進行模運算的值。char,int, long等整數型別,hash functions什麼都沒做,返回原值,但對字串(const char*),需要設計轉換函式。因此,stl中的hashtable很多型別是不支援的,比如:string,float,double。要處理這些,就必須自己定義hash functions。

hash_set

hash_set是以hashtable為底層機制,RB-tree有自動排序功能而hash table沒有,因此set的元素有自動排序功能,而hash_set沒有。這是它倆的唯一區別,其他操作是一樣的。

hash_multiset與multiset的操作基本一樣,唯一區別也是無自動排序功能。

hash_map

hash_map底層機制也是hash table,與map的區別也是五排序功能。hash_multimap,亦如此。