[CPP] STL 簡介

STL 即標準模板庫(Standard Template Library),是 C++ 標準庫的一部分,裏面包含了一些模板化的通用的數據結構和算法。STL 基於模版的實現,因此能夠支持自定義的數據結構。

STL 中一共有 6 大組件:

  • 容器 (container)
  • 迭代器 (iterator)
  • 算法 (algorithm)
  • 分配器 (allocator)
  • 仿函數 (functor)
  • 容器適配器 (container adapter)

參考資料:

仿函數

仿函數 (Functor) 的本質就是在結構體中重載 () 運算符

例如:

struct Print
{ void operator()(const char *s) const { cout << s << endl; } };
int main()
{
    Print p;
    p("hello");
}

這一概念將會在 priority_queue 中使用(在智能指針的 unique_ptr 自定義 deleter 也會用到)。

容器

容器 (Container) 在 STL 中又分爲序列式容器 (Sequence Containers) ,關聯式容器 (Associative Containers) 和無序容器 (Unorderde Containers) .

序列式容器

常見的序列式容器包括有:vector, string, array, deque, list, forward_list .

vector/string

底層實現:vector內存連續、自動擴容的數組,實質還是定長數組。

特點:

  • 隨機訪問:重載 [] 運算符
  • 動態擴容:插入新元素前,如果 size == capacity 時,那麼擴容爲當前容量的 2 倍,並拷貝原來的數據
  • 支持 ==, !=, <, <=, >, >= 比較運算
    • C++20 前,通過上述 6 個重載運算符實現;C++20中,統一「封裝」爲一個 <=> 運算符 (aka, three-way comparsion )。
    • 不難理解,時間複雜度爲 \(O(n)\)

PS:string 的底層實現與 vector 是類似的,同樣是內存連續、自動擴容的數組(但擴容策略不同)。


array (C++11)

底層實現:array內存連續的固定長度的數組,其本質是對原生數組的直接封裝。

特點(主要是與 vector 比較):

  • 支持 6 種比較運算符,支持 [] 隨機訪問
  • 丟棄自動擴容,以獲得跟原生數組一樣的性能
  • 不支持 pop_front/back, erase, insert 這些操作。
  • 長度在編譯期確定。vector 的初始化方式爲函數參數(如 vector<int> v(10, -1),長度可動態確定),但 array 的長度需要在編譯期確定,如 array<int, 10> a = {1, 2, 3} .

需要注意的是,arrayswap 方法複雜度是 \(\Theta(n)\) ,而其他 STL 容器的 swap\(O(1)\),因爲只需要交換一下指針。


deque

又稱爲“雙端隊列”。

底層實現:多個不連續的緩衝區,而緩衝區中的內存是連續的。而每個緩衝區還會記錄首指針和尾指針,用來標記有效數據的區間。當一個緩衝區填滿之後便會在之前或者之後分配新的緩衝區來存儲更多的數據。

特點:

  • 支持 [] 隨機訪問
  • 線性複雜度的插入和刪除,以及常數複雜度的隨機訪問。

list

底層實現:雙向鏈表。

特點:

  • 不支持 [] 隨機訪問
  • 常數複雜度的插入和刪除

forwar_list (C++11)

底層實現:單向鏈表。

特點:

  • 相比 list 減少了空間開銷
  • 不支持 [] 隨機訪問
  • 不支持反向迭代 rbegin(), rend()

關聯式容器

關聯式容器包括:set/multisetmap/multimapmulti 表示鍵值可重複插入容器。

底層實現:紅黑樹。

特點:

  • 內部自排序,搜索、移除和插入擁有對數複雜度。
  • 對於任意關聯式容器,使用迭代器遍歷容器的時間複雜度均爲 \(O(n)\)

自定義比較方式:

  • 如果是自定義數據類型,重載運算符 <
  • 如果是 int 等內置類型,通過仿函數
struct cmp { bool operator()(int a, int b) { return a > b; } };
set<int, cmp> s;

無序容器

無序容器 (Unorderde Containers) 包括:unordered_set/unordered_multiset,unordered_map/unordered_multimap .

底層實現:哈希表。在標準庫實現裏,每個元素的散列值是將值對一個質數取模得到的,

特點:

  • 內部元素無序
  • 在最壞情況下,對無序關聯式容器進行插入、刪除、查找等操作的時間複雜度會與容器大小成線性關係 。這一情況往往在容器內出現大量哈希衝突時產生。

在實際應用場景下,假設我們已知鍵值的具體分佈情況,爲了避免大量的哈希衝突,我們可以自定義哈希函數(還是通過仿函數的形式)。

struct my_hash { size_t operator()(int x) const { return x; } };
unordered_map<int, int, my_hash> my_map;
unordered_map<pair<int, int>, int, my_hash> my_pair_map;

小結

四種操作的平均時間複雜度比較:

  • 增:在指定位置插入元素
  • 刪:刪除指定位置的元素
  • 改:修改指定位置的元素
  • 查:查找某一元素
Containers 底層結構
vector/deque vector: 動態連續內存
deque: 連續內存+鏈表
\(O(n)\) \(O(n)\) \(O(1)\) \(O(n)\)
list 雙向鏈表 \(O(1)\) \(O(1)\) \(O(1)\) \(O(n)\)
forward_list 單向鏈表 \(O(1)\) \(O(n)\) \(O(1)\) \(O(n)\)
array 連續內存 不支持 不支持 \(O(1)\) \(O(n)\)
set/map/multiset/multimap 紅黑樹 \(O(\log{n})\) \(O(\log{n})\) \(O(\log{n})\) \(O(\log{n})\)
unordered_{set,multiset}
unordered_{map,multimap}
哈希表 \(O(1)\) \(O(1)\) \(O(1)\) \(O(1)\)

容器適配器

容器適配器 (Container Adapter) 其實並不是容器(個人理解是對容器的一種封裝),它們不具有容器的某些特點(如:有迭代器、有 clear() 函數……)。

常見的適配器:stackqueuepriority_queue

對於適配器而言,可以指定某一容器作爲其底層的數據結構。

stack

  • 默認容器:deque
  • 不支持隨機訪問,不支持迭代器
  • top, pop, push, size, empty 操作的時間複雜度均爲 \(O(1)\)

指定容器作爲底層數據結構:

stack<TypeName, Container> s;  // 使用 Container 作爲底層容器

queue

  • 默認容器:deque
  • 不支持隨機訪問,不支持迭代器
  • front, push, pop, size, empty 操作的時間複雜度均爲 \(O(1)\)

指定容器:

queue<int, vector<int>> q;

priority_queue

又稱爲 “優先隊列” 。

  • 默認容器:vector
  • \(O(1)\)top, empty, size
  • \(O(\log{n})\) : push, pop

模版參數解析:

priority_queue<T, Container = vector<T>, Compare = less<T>> q;
// 通過 Container 指定底層容器,默認爲 vector
// 通過 Compare 自定義比較函數,默認爲 less,元素優先級大的在堆頂,即大頂堆
priority_queue<int, vector<int>, greater<int>> q;
// 傳入 greater<int> 那麼將構造一個小頂堆
// 類似的,還有 greater_equal, less_equal

迭代器

迭代器 (Iterator) 實際上也是 GOF 中的一種設計模式:提供一種方法順序訪問一個聚合對象中各個元素,而又不需暴露該對象的內部表示。

迭代器的分類如下圖所示。

各容器的迭代器

STL 中各容器/適配器對應使用的迭代器如下表所示。

Container Iterator
array 隨機訪問迭代器
vector 隨機訪問迭代器
deque 隨機訪問迭代器
list 雙向迭代器
set / multiset 雙向迭代器
map / multimap 雙向迭代器
forward_list 前向迭代器
unordered_{set, multiset} 前向迭代器
unordered_{map, multimap} 前向迭代器
stack 不支持迭代器
queue 不支持迭代器
priority_queue 不支持迭代器

迭代器失效

迭代器失效是因爲向容器插入或者刪除元素導致容器的空間變化或者說是次序發生了變化,使得原迭代器變得不可用。因此在對 STL 迭代器進行增刪操作時,要格外注意迭代器是否失效。

網絡上搜索「迭代器失效」,會發現很多這樣的例子,在一個 vector 中去除所有的 2 和 3,故意用一下迭代器掃描(大家都知道可以用下標):

int main()
{
    vector<int> v = {2, 3, 4, 6, 7, 8, 9, 3, 2, 2, 2, 2, 3, 3, 3, 4, 5, 6};
    for (auto i = v.begin(); i != v.end(); i++)
    {
        if (*i==2 || *i==3) v.erase(i), i--;
        // correct code should be
        // if (*i==2 || *i==3) i=v.erase(i), i--;
    }
    for (auto i = v.begin(); i != v.end(); i++)
        cout << *i << ' ';
}

我很久之前用 Dev C++ (應該是內置了很古老的 MinGW)寫代碼的時候,印象中也遇到過這種情況,v.erase(i), i-- 這樣的操作是有問題的。 erase(i) 會使得 i 及其後面的迭代器失效,從而發生段錯誤。

但現在 MacOS (clang++ 12), Ubuntu16 (g++ 5.4), Windows (mingw 9.2) 上測試,這段代碼都沒有問題,並且能輸出正確結果。編譯選項爲:

g++ test.cpp -std=c++11 -O0

實際上也不難理解,因爲是連續內存,i 指向的內存位置,在 erase 之後被其他數據覆蓋了(這裏的行爲就跟我們使用普通數組一樣),但該位置仍然在 vector 的有效範圍之內。在上述代碼中,當 i = v.begin() 時,執行 erase 後,對 i 進行自減操作,這已經是一種未定義行爲。

我猜應該是 C++11 後(或者是後來的編譯器更新),對迭代器失效的這個問題進行了優化。

雖然能夠正常運行,但我認爲最好還是嚴謹一些,更嚴格地遵循迭代器的使用規則:if (*i==2 || *i==3) i=v.erase(i), i--; .

以下爲各類容器可能會發生迭代器失效的情況:

  • 數組型 (vector, deque)
    • insert(i)erase(i) 會發生數據挪動,使得 i 後的迭代器失效,建議使用 i = erase(i) 獲取下一個有效迭代器。
    • 內存重新分配:當 vector 自動擴容時,可能會申請一塊新的內存並拷貝原數據(也有可能是在當前內存的基礎上,再擴充一段連續內存),因此所有的迭代器都將失效。
  • 鏈表型 (list, forward_list):insert(i)erase(i) 操作不影響其他位置的迭代器,erase(i) 使得迭代器 i 失效,指向數據無效,i = erase(i) 可獲得下一個有效迭代器,或者使用 erase(i++) 也可(在進入 erase 操作前已完成自增)。
  • 樹型 (set/map):與鏈表型相同。
  • 哈希型 (unodered_{set_map}):與鏈表型相同。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章