【知識點】深入淺出STL標準模板庫

Macw發表於2024-05-26

前幾天談論了許多關於數論和資料結構的東西,這些內容可能對初學者而言比較晦澀難懂(畢竟是屬於初高等演算法/資料結構的範疇了)。今天打算來講一些簡單的內容 - STL 標準模板庫。

STL 標準模板庫

C++ 標準模板庫 (Standard Template Library, STL),是 C++ 語言非常重要的一個構成部分。它提供了一組通用的類和函式,用於實現一些資料結構和演算法。STL 主要包含以下五個部分:

  1. 容器 (Containers):STL 提供了不同的容器來供開發者根據所需要維護資料來選擇儲存。
  2. 演算法 (Algorithms):STL 提供了許多通用演算法,用於排序、搜尋、複製、修改等操作。
  3. 迭代器 (Iterators):STL 迭代器的作用是遍歷容器元素的物件。
  4. 函式物件 (Function Objects):這是一種行為類似於函式的物件(由於不太常見,因此本篇文章將不會詳細講解 STL 中的函式物件)。
  5. 介面卡 (Adapters):STL 的介面卡用於修改容器、迭代器或函式物件的介面。為方便起見,容器介面卡將會與容器部分一同講解。

綜上所述,本文將會主要圍繞 容器、演算法和迭代器這三者來展開敘述。本文只會闡述一些相對常見的模板/方法,部分生僻的內容還請各位自行上網搜尋。

容器 Containers

STL 容器還可以被細分成三個類別,分別是 序列式容器 (Sequence Containers)關聯式容器 (Associative Containers)無序關聯式容器 (Unordered Associative Containers)

序列式容器 (Sequence Containers) 與常見容器介面卡。

  1. vector 向量容器:向量容器可以被理解為我們常說的動態陣列。與普通陣列不同的是,動態陣列的大小可以在執行過程中更改,並且使用者可以任意地在此陣列的末尾新增資料。它的優點是支援快速隨機訪問和在末尾插入刪除元素。向量容器的基本使用方法如下:
#include <iostream>
#include <vector>  // 引入標頭檔案
using namespace std;

int main(){
    // 建立一個整數型別,名為 vec 的向量容器,初始存放了數字1-5。
    vector<int> vec = {1, 2, 3, 4, 5};
    
    vec.push_back(6);  // 向容器末尾新增一個元素6。
    vec.size();  // 獲取容器的大小,即容器內元素的個數。
    vec.resize(10);  // 動態更改容器的大小,將容器的大小設定為10。

    // 透過 for 迴圈對容器進行遍歷:
    for (int i=0; i<vec.size(); i++){}
    for (int i : vec) {}
    return 0;
}
  1. queue 佇列容器介面卡:佇列是一種基本的資料結構,其講究 先進先出 (FIFO),即最先新增進介面卡的元素會被第一個彈出,以此類推。在佇列中,所有元素都會從隊尾入隊,並從對首出隊。佇列介面卡的優點是支援隨機訪問隊首和隊尾元素。佇列容器嚴格意義上並非序列式容器,而是一種容器介面卡。佇列容器介面卡的基本使用方法如下:
#include <iostream>
#include <queue>  // 引入標頭檔案
using namespace std;

int main(){
    // 建立一個存放整數型別,名為 que 的佇列容器介面卡,一開始預設為空。
    queue<int> que;

    que.push(10);  // 向隊尾新增一個新元素。
    que.front();  // 獲取隊首元素,此時隊首元素為數字10。
    que.pop();  // 將隊首元素出隊,此時的佇列容器為空。
    que.size();  // 獲取佇列的長度,即對內元素的個數。
    que.empty();  // 判斷佇列是否為空,為空返回真,否則返回假。

    // 佇列元素不支援直接遍歷,只能從頭一個一個彈出。
    // 在彈出元素時需要保證隊內不為空。
    while (!que.empty()){
        int t = que.front();
        que.pop();
        cout << t << endl;
    }
    return 0;
}
  1. stack 棧容器介面卡:棧也是一種基本的資料結構,棧實現的是 後進先出 (LIFO),即最後新增進該介面卡的元素會最先彈出。在棧中,所有元素都會從棧頂入棧,並從棧頂出棧。棧容器嚴格意義上並非序列式容器,而是一種容器介面卡。棧容器介面卡的基本使用方法如下:
#include <iostream>
#include <stack>  // 引入標頭檔案
using namespace std;

int main(){
    // 建立一個存放整數型別,名為 s 的棧容器介面卡,一開始預設為空。
    stack<int> s;

    s.push(10);  // 將元素壓入棧頂。
    s.top();  // 獲取棧頂元素,此時的棧頂元素為數字10。
    s.pop();  // 將棧頂元素出棧,此時棧內沒有元素。
    s.size();  // 獲取棧內元素的個數。
    s.empty();  // 判斷棧是否為空,為空返回真,否則返回假。

    // 與佇列相同,棧內元素不支援直接遍歷,只能從棧頂一個一個彈出。
    // 在彈出元素時需要保證棧內不為空。
    while (!s.empty()){
        int t = s.top();
        s.pop();
        cout << t << endl;
    }
    return 0;
}
  1. deque 雙端佇列容器:與佇列類似,雙端對類容器支援快速隨機訪問和在兩端插入刪除。上面提及到的 queue 容器介面卡就是基於雙端佇列所實現的。雙端佇列容器的基本使用方法如下:
#include <iostream>
#include <deque>

int main() {
    // 建立一個整數型別,名為 deq 的雙端佇列容器,初始存放了數字1-5。
    deque<int> deq = {1, 2, 3, 4, 5};

    deq.push_front(0);  // 在前端新增元素。
    deq.push_back(6);  // 在末尾新增元素。

    // 透過 for 迴圈對容器進行遍歷:
    for (int i : deq) cout << i << endl;
    return 0;
}
  1. list 雙向連結串列容器:連結串列資料結構支援快速插入刪除操作,但是透過索引訪問元素會比較慢。雙向連結串列的語法與雙端佇列相似。在一般演算法實現上,雙向連結串列的應用並不算廣泛,因此不過多闡述。雙向連結串列的基本使用方法如下:
#include <iostream>
#include <list>  // 引入標頭檔案

int main() {
    // 建立一個整數型別,名為 lst 的雙向佇列容器,初始存放了數字1-5。
    list<int> lst = {1, 2, 3, 4, 5};

    lst.push_front(0);  // 在前端新增元素。
    lst.push_back(6);  // 在末尾新增元素。

    // 透過 for 迴圈對容器進行遍歷:
    for (int i : lst) cout << i << endl;
    return 0;
}

關聯式容器 (Associative Containers)

  1. set 集合容器:集合容器的定義與數學中的集合完全相同。集合中相同的元素只會被存入一次。集合可以進行高效的查詢操作。集合容器的基本使用方法如下:
#include <iostream>
#include <set>  // 引入標頭檔案

int main() {
    // 建立一個整數型別,名為 s 的集合容器,初始存放了許多數字。
    set<int> s = {3, 1, 4, 1, 5, 9};
    
    s.insert(2);  // 向集合內插入元素2。
    s.erase(2);  // 向集合中移除元素2。

    s.count(2);  // 檢查元素是否存在於集合,如果返回真即存在,否則即不存在。
    s.size();  // 獲取集合內元素的個數。
    s.clear();  // 清空集合。

    // 透過 for 迴圈對容器進行遍歷:
    // 由於集合自帶“去重”功能,最輸出的結果為:1 3 4 5 9。
    for (auto i : s) cout << i << " ";
    return 0;
}
  1. map 對映容器:除了集合容器之外,該容器也經常在演算法中被應用。對映容器用於儲存鍵值對。其中鍵在該容器中是唯一,同時它還支援高效的查詢操作(對於某些資料如果懶得離散化一定要試一下這個容器,我覺得真的很好用)。以下是對映容器的基本用法:
#include <iostream>
#include <map>  // 引入標頭檔案

int main() {
    // 建立一個鍵為整數型別、值為字串型別,名為 m 的對映容器。
    map<int, string> m;
    m[1] = "Macw07";  // 插入鍵值對。

    s.count(2);  // 檢查元素是否存在於集合,如果返回真即存在,否則即不存在。
    s.size();  // 獲取集合內元素的個數。
    return 0;
}

無序關聯式容器 (Unordered Associative Containers)

一般情況下,所有的關聯式容器預設都會按照存放陣列(值/鍵)從小到大進行排序。但如果將這些關聯式容器變成無序關聯式容器,那麼 STL 內部就不會預設對其進行排序。一般情況下,如果沒有特殊需求推薦使用無序關聯式容器,因為它們的執行效率通常都比有序的關聯式容器要高很多。

以下是常見的無序關聯式容器和有序關聯式容器的宣告程式碼:

mapunordered_map

#include <map>  // 有序的。
#include <unordered_map>  // 無序的。

map<int, int> mp1;  // 有序對映表的宣告。
unordered_map<int, int> mp2;  // 無序對映表的宣告。

setunordered_set

#include <set>  // 有序的。
#include <unordered_set>  // 無序的。

set<int> mp1;  // 有序集合的宣告。
unordered_set<int> mp2;  // 無序集合的宣告。

演算法 Algorithms

終於講完了容器部分,接下來來到了演算法的部分。C++ 的 STL 供了許多演算法,用於對容器中的元素進行操作。這些演算法主要包括:排序、搜尋、修改、集合操作、數值操作等。STL 的演算法通常以函式模板的形式實現,可以與任意容器型別配合使用。

以下所有有關區間的內容均預設為 閉區間操作

  1. 排序演算法 sortstable_sort。兩種演算法的目的都是對一個容器/陣列進行排序。兩個演算法均傳入三個引數,分別是排序區間的頭尾地址和排序方式。其中排序方式不強制傳參,對於整形型別的容器而言,預設按照從小到大的順序進行排序。兩種演算法的作用基本相同,但 stable_sort 保證了排序的穩定性,普通的 sort 無法保證排序的穩定性。基本使用程式碼如下:
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

int main() {
    vector<int> vec = {5, 3, 8, 6, 2};
    int arr[] = {5, 4, 3, 2, 1};
    // 前兩個引數傳入的是地址。
    // xxx.begin()/ end() 表示獲取某容器的首地址或尾地址。
    sort(vec.begin(), vec.end());  // 對動態陣列進行排序。
    sort(arr, arr+5);  // 對陣列進行排序。

    // 輸出排序後的動態陣列。
    for (int i : vec) cout << n << " ";
    return 0;
}

如果想要使用自定義排序,那麼可以仿照以下方法來寫:

#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

// 建立一個自定義比較函式。
// 傳入兩個引數(型別與所需要排序的容器型別一致)。
// 說直白點,想要哪個前一個引數考前就返回真,否則返回假。
bool cmp(int a, int b){
    return a > b;  // 按照從大到小的順序排。
    // return a < b;  // 按照從小到大的順序排。
}

int main() {
    vector<int> vec = {5, 3, 8, 6, 2};

    sort(vec.begin(), vec.end(), cmp);  // 對動態陣列按照cmp規則進行排序。

    // 輸出排序後的動態陣列。
    for (int i : vec) cout << n << " ";
    return 0;
}
  1. 搜尋演算法 find:該演算法幫助我們在指定範圍內查詢等於指定值的第一個元素。該演算法也傳入三個引數,也分別是查詢區間的左地址和右地址,以及需要查詢的物件。該演算法的返回值也是一個地址。具體的使用方法如下:
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

int main() {
    vector<int> vec = {5, 3, 8, 6, 2};
    // 如需要獲取所查詢物件的索引,因在最後減去首地址。
    auto index = std::find(vec.begin(), vec.end(), 8) - vec.begin();
    return 0;
}
  1. 集合操作演算法 set_unionset_intersection:之前已經提到了集合的容器,這兩個演算法均應用在集合容器(當然其他容器也可以)當中。其中 set_union 演算法會將兩個集合合併。set_intersection 演算法可以求出兩個集合的交集。這兩個演算法均傳入五個引數,分別是第一個集合的左右地址、第二個集合的左右地址和存放最終計算結果的容器。兩個函式的使用方法如下:
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

int main() {
    vector<int> vec1 = {1, 2, 3};
    vector<int> vec2 = {3, 4, 5};
    vector<int> unionVec, intersectionVec;

    // 計算 vec1 和 vec2 的並集,並將結果儲存到 unionVec 陣列中。    
    set_union(vec1.begin(), vec1.end(), vec2.begin(), vec2.end(), back_inserter(unionVec));

   // 計算 vec1 和 vec2 的交集,並將結果儲存到 intersectionVec 陣列中。    
    set_union(vec1.begin(), vec1.end(), vec2.begin(), vec2.end(), back_inserter(intersectionVec));
    return 0;
}
  1. 反轉演算法 reverse:反轉一個容器(陣列/字串也可以)指定範圍內的元素順序。該演算法傳入兩個引數,分別表示區間的左右地址。該演算法具體的使用方法如下:
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

int main() {
    vector<int> vec = {1, 2, 3, 4, 5};
    // 反轉整一個 vec 陣列
    reverse(vec.begin(), vec.end());  // 此時 vec = {5, 4, 3, 2, 1}

    string str = "Macw07";
    reverse(str.begin(), str.end());  // 此時 str = "70wcaM"。
    return 0;
}
  1. 去重演算法 uniqueeraseunique 演算法用於移除指定範圍內相鄰的重複元素(需要先排序)。該演算法只能在保證容器內有序地情況下使用,否則演算法將無法返回正確的結果。該演算法的會將重複的元素存放到區間的末尾,並返回一個指標,表示從該指標開始到區間結束是重複元素所在的區間。而 erase 的用處是刪除容器指定區間內的所有元素。相同地,兩個演算法都需傳入兩個引數,分別表示區間的左右地址。該演算法具體的使用方法如下:
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

int main() {
    vector<int> vec = {1, 2, 2, 3, 4, 4, 5};

    // 去重。
    auto last = unique(vec.begin(), vec.end());
    // 刪除重複元素。
    vec.erase(last, vec.end());
    return 0;
}
  1. 查詢演算法 lower_boundupper_boundlower_boundupper_bound 是兩種常用的搜尋演算法,主要用於有序範圍內查詢元素。它們分別返回 第一個不小於(大於或等於)指定值 和 第一個大於指定值的位置 。請注意,這兩個演算法需要在給定區間內的元素從小到大升序的情況下使用,否則將會返回錯誤的結果。這兩個演算法的底層原理是透過二分查詢來實現的。假設存在一個數列 \([1, 2, 3, 5, 6, 6, 7]\),那麼這個數列對 \(4\)\(lower\_bound\) 就是 \(3\)(索引),而這個數列對 \(6\)\(upper\_bound\) 就是 \(6\)(索引)兩個演算法的程式碼示例如下:
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

int main() {
    vector<int> vec = {1, 2, 3, 4, 5, 6};

    // ---------- lower_bound 演算法 ----------
    auto it1 = lower_bound(vec.begin(), vec.end(), 4);
    // 如果要獲取索引,那麼再減去首地址即可。
    int index1 =  lower_bound(vec.begin(), vec.end(), 4) - vec.begin();

    if (it1 != vec.end()) 
        cout << "Lower bound of 4 is: " << *it1 << endl;
    else 
        cout << "4 is not in the vector" << endl;


    // ---------- upper_bound 演算法 ----------
    auto it2 = upper_bound(vec.begin(), vec.end(), 4);
    // 如果要獲取索引,那麼再減去首地址即可。
    int index2 =  upper_bound(vec.begin(), vec.end(), 4) - vec.begin();

    if (it2 != vec.end()) 
        cout << "Upper bound of 4 is: " << *it2 << endl;
    else 
        cout << "All elements are <= 4" << endl;

    return 0;
}

迭代器 Iterators

接下來來到了迭代器部分,迭代器是一種用於遍歷容器元素的抽象概念。它提供了一種統一的訪問容器內元素的方式,使得演算法可以獨立於容器型別工作,增強了程式碼的通用性和可重用性。

迭代器的基本概念

迭代器 是一種指標-like 物件(表示與指標類似),它允許你遍歷容器中的元素。你可以使用迭代器訪問容器的每個元素,而不需要了解容器的內部實現細節。

迭代器範圍 由兩個迭代器表示,通常是一個指向範圍起始位置的迭代器和一個指向範圍末尾位置的迭代器。這兩個迭代器標識了一個半開區間,即左閉右開。

雖然迭代器與指標近乎相同,但也有細微的差別(在本文前問中,由於未詳細講解迭代器,因此將迭代器和指標統稱為指標)。以下是迭代器的常見方法:

  1. .begin():獲取指向容器的第一個元素的迭代器。
  2. .end():獲取指向容器的最後一個元素的下一個位置的迭代器。
  3. 迭代器++:將迭代器指向容器的下一個位置。
  4. *迭代器:獲取迭代器指向的元素(類似於透過地址取值)。

一般情況下,我們透過使用 auto 型別來實現迭代器(因為這個型別比較方面,可以在任何時候用)。auto 型別是 C++11 引入的一個關鍵字,用於在編譯時自動推斷變數的型別。使用 auto 關鍵字宣告的變數會根據其初始化表示式的型別自動推匯出變數的型別。這樣做可以簡化程式碼、提高可讀性,並且使程式碼更具靈活性

透過使用 auto 型別來對 STL 容器進行遍歷操作:

#include <iostream>
#include <vector>
using namespace std;

int main() {
    vector<int> vec = {1, 2, 3, 4, 5};

    // 使用迭代器遍歷容器並輸出元素。
    for (auto it = vec.begin(); it != vec.end(); it++) 
        cout << *it << " ";
    return 0;
}

小結

至此,對於 STL 的基本講解就到此為止了。STL 標準模板庫 本身是非常龐大的,因此本文並不涵蓋所有的知識點。與此同時,我希望各位在使用不同的演算法/容器的時候自行搜尋有關該模板的時間複雜度,以更好的在做題過程中預估演算法的整體耗時。

本文後續可能會持續更新。

相關文章