1. 程式人生 > >STL 序列容器

STL 序列容器

轉自時習之
STL中大家最耳熟能詳的可能就是容器,容器大致可以分為兩類,序列型容器(SequenceContainer)和關聯型容器(AssociativeContainer)這裡介紹STL中的各種序列型容器和相關的容器介面卡。主要內容包括

  • std::vector
  • std::array
  • std::deque
  • std::queue
  • std::stack
  • std::priority_queue
  • std::list
  • std::forward_list

std::vector

1)初始化

int initilizer[4] = { 1, 2, 3, 4 };
std::vector<int> ages(initilizer, initilizer + 4);

\(std::vector<int> ages = { 1, 2, 3, 4 }\)這種寫法實際上從語法分析上來說是分成下面幾個步驟的:

//{ 1, 2, 3, 4 } 被編譯器構造成一個臨時變數std::initializer_list<int>
//使用臨時變數構造一個臨時變數 std::vector<int>
//再用 std::vector<int>的拷貝建構函式構造最終的ages
std::initializer_list<int> initilizer;
std::vector<int> tmp(initilizer);
std::vector<int> ags(tmp);

當然上面的分析只是語法上的分析,絕大部分編譯器都可以優化掉tmp,而且因為{1, 2, 3, 4}轉換成std::initializer_list是編譯器在編譯器完成的事情,所以其實效率比我們想象中要高一些。

2)自動增長

std::vector 會在記憶體不夠的時候自動增長空間,這是相對於C陣列來說最大的一個優勢。那麼空間不夠的時候怎麼增長呢?每次容器滿了需要擴容的時候,容量總是呈現兩倍增長(VS上1.5倍),而且每次擴容,容器第一個元素所在地址都會發生改變,由此我們知道,容器的擴容時實際是另外尋找一片更大的空間。

3)縮減 std::vector

std::vector會在空間不夠的時候自動分配空間,但是它並不會在空間冗餘的時候自動釋放空間。如果你使用C++11之後的版本,你可以使用std::vector::shrink_to_fit來回收空間,否則你需要像下面這個縮減空間。

std::vector<int> ages;
std::vector<int>(ages.begin(), ages.end()).swap(ages)

這種用法叫做copy and swap在拷貝建構函式的實現中用的也很多。這個地方需要特別注意的是臨時變數和實際變數位置不能寫反ages.swap(std::vector

void swap( vector& other );

臨時變數(前面哪個匿名物件)是右值,無法繫結到一個左值引用上面。

4)相容 C 陣列

C++很重要的一個特性就是相容C語言,C的介面中,如果需要傳入一個數組,通常的方式s是傳入一個起始地址加上一個長度,如下:

void* memset( void* dest, int ch, std::size_t count );

如果你現在有一個std::vector,現在需要把它傳遞給C,介面你可以呼叫std::vector::data這個成員變數獲取底層的記憶體空間的首地址。std::vector和其他的容器一個非常重要的區別就是它保證了底層的記憶體空間的連續性,也就是說,它保證了記憶體空間和C陣列的相容性,能用C陣列的地方都可以使用std::vector,而且它還能保證記憶體空間的自動管理。

5)std::vector 的記憶體模型

我們來看下面這段程式碼:

std::vector small(100);
std::vector large(1000);

那麼 sizeof(small) 和 sizeof(large) 哪個大呢?答案是一樣大,要解答這個問題我們需要了解 std::vector 的記憶體模型。std::vector的實現的記憶體模型並不完全一樣,但是基本上都大同小異,類似下面這種結構。

            stack
        +------------+
        |  begin     +----------+
        +------------+          |
        |  end       +-------------------------------+
        +------------+          |                    |
+-------+  cap       |          v                    v
|       +------------+          +--------------------+----------+
|       |   ......   |          |                    |          |   heap
|       +------------+          +--------------------+----------+
|                                                               ^
+---------------------------------------------------------------+

從上面的圖中我們可以看出small和large真正的差別其實在heap不在stack,所以說sizeof(small) == sizeof(large) (= 24?)。

6)std::vector

std::vector有一個特化版本\(std::vector<bool>\),用於實現dynamic bitset,需要注意的是,這個特化版本並不是容器,它的迭代器無法很好的適配STL中的所有演算法。它的存在是為了節省空間,它的每一個元素只佔用一位而不是一個位元組。為了實現這種優化,operator[]返回的是一個代理類,你沒有辦法取單個元素的地址。通常的建議是,如果你不需要動態的bitset,你可以使用std::bitset,如果你需要dynamic bitset你可以考慮使用std::deque


std::array

std::vector會自動管理使用到的記憶體,這是一個非常重要的特性,但是如果你的資料的大小是已知而且固定的,這個特性對於你來說是不必要的開銷。因為前面提到std::vector的資料實際上放到heap上面,訪問需要額外的解引用,而且它可能內部有記憶體空閒,空間有浪費。這種情況下你可以考慮使用std::array來替換std::vector。

1) 初始化

static const std::array<std::string, 5> kTags = {"trace", "info", "debug",   "waring", "error"};

2)為什麼不直接使用C陣列

std::array實際上是一個容器,它提供來迭代器可以很方便的遍歷元素,它可以用過 size() 方法返回陣列的大小,而且它是zero cost abstraction的絕佳體現,它的開銷實際上並不比C陣列要大,但是卻提供來大量的方便易用的介面,可以和STL很好的整合在一起,所以如果你使用C++,你基本上可以考慮告別C陣列了,變長陣列你可以使用vector,定長陣列可以使用std::array。


std::deque

前文提到,std::vector的記憶體空間是連續的,在頭部插入資料需要移動所有資料是O(N)級別的操作,因為開銷過於巨大,std::vector並沒有提供在頭部插入和刪除的介面。如果我們真的有這樣的需求,我們可以選擇使用std::deque。它支援在頭部和尾部以O(1)的開銷插入和刪除資料,同時可以在O(1)時間內訪問任意元素。

1) push_front、front、pop_front

如果你選擇使用std::deque而不是vector,十有八九你是為了用這三個函式,std::deque提供這三個函式用於在佇列的頭部插入和刪除資料。需要注意的是下面兩點:

這三個函式的複雜度都是O(1)
提供三個函式而不是兩個函式是為了保證異常安全性

2)記憶體模型

std::deque是如何做到O(1)時間內訪問任意元素又保證O(1)時間在頭部和尾部操作資料內?這要從它的記憶體模型說起。

std::deque在邏輯上也是一個數組,只不過在物理上它的空間並不連續,它實際上由一段一段的小塊兒記憶體拼接而成,這些小塊兒的記憶體我們姑且叫它buffer,把這些buffer串在一起的就形成了一個邏輯上的一緯陣列,用來串連這些buffer我們姑且稱之為map

      +------+-------+-------+-------+--------+------+
      |      |       |       |       |        |      | map
      +------+---+---+----+--+----+--+----+---+------+
                 |        |       |       |
              +--v-+   +--v-+  +--v-+  +--v-+
              |    |   |    |  |    |  |    |  buffer
              |    |   |    |  |    |  |    |
              |    |   |    |  |    |  |    |
begin   +-->  +----+   |    |  |    |  +----+  <-+    end
              |    |   |    |  |    |  |    |
              |    |   |    |  |    |  |    |
              +----+   +----+  +----+  +----+

這個結構實際上是把一維陣列變成了二維結構,本質上來說它就是通過增加一個間接層來實現的。再一次印證來那句老化,什麼問題都可以通過增加間接層來解決。

3)邏輯上的陣列

我們說邏輯上,std::deque也是一個數組,它支援取下標操作符,可以在O(1)時間內訪問容器內部的任意元素。需要注意的是std::deque的O(1)和vector的O(1)存在常數上的差別,因為vector只需要一次解引用就可以獲取元素而std::deque需要兩次。
std::deque的這種邏輯和物理儲存不一致的特性也從另外一個側面反應了介面和實現直接的本質區別。程式設計的核心思想在於抽象,而抽象的核心在於分離介面和實現。

4)自動回收空間

和vector一樣空間不足的時候會自動分配分配新的空間,大多數情況下只需要分配固定大小的buffer掛到map上,但是buffer過多的時候會導致map的重新分配。和vector不同的是std::deque會自動回收多餘的空間,如果你對於執行時的記憶體要求非常嚴苛,而且會頻繁的插入和刪除資料可以考慮使用std::deque。

5)首尾插入和刪除資料可以保證其他迭代器的合法性

std::deque還有一個特性就是如果你只是在頭部或者尾部操作資料,你之前持有的迭代器不會失效,這一點我們會放到後面迭代器相關的文章中重點的討論。


std::queue

傳統的資料結構課程中,提到同時操作頭部和尾部,我們首先想到的應該是佇列,它是一種FIFO的結構,廣泛的使用在各種程式中。STL中提供來std::queue這個模板類來實現這一結構,但是需要特別注意的是它不是一個容器,它是容器介面卡。
std::queue不是容器,因為它不滿足容器的concept,比如它沒有定義iterator這個成員型別,它也不提供begin、end這樣的成員方法,也就誰說你沒有辦法它不提供迭代器,沒有迭代器你就不能使用STL中的演算法,你也就失去了STL中的半壁江山。

1) 什麼是容器介面卡

我們說std::queue是一個容器介面卡,所謂的介面卡從設計模式的角度考慮就是把一個類的介面適配成另外一種介面。std::queue實際上就是拿著容器的介面,適配成佇列的所需要的介面。預設情況下,它用來適配的容器是std::deque,這是為什麼我們在講完std::deque之後接著就講std::queue的原因。我們來看一下標準庫中std::queue的定義:

template<
    class T,
    class Container = std::deque<T>
> class queue;

std::queue預設使用的容器是std::deque。也就是說如果你覺得不合適,你完全可以換掉它,只要你提供的型別滿足 Container 這個模板引數需要的條件:

The type of the underlying container to use to store the elements. The container must satisfy the requirements of SequenceContainer. Additionally, it must provide the following functions with the usual semantics:

  • back()
  • front()
  • push_back()
  • pop_front()

在標準庫中除了std::deque之外std::list也滿足這個條件。

例子

#include <queue>
#include <list>
#include <cassert>

int main(int argc, char *argv[])
{
    std::queue<int, std::list<int>> numbers;

    numbers.push(1);
    numbers.push(2);
    numbers.push(3);

    assert(numbers.front() == 1);
    assert(numbers.back() == 3);

    numbers.pop();
    assert(numbers.front() == 2);

    return 0;
}

請注意上面std::queue的定義,我提供了第二個引數,也請注意前面的寫法是std::list


std::stack

說完FIFO的佇列,我們順便說一下FILO的棧,在STL中提供了std::stack用來實現棧的功能,它和std::queue一樣是容器介面卡而不是容器,而且它同樣預設使用std::deque作為預設的容器,和std::queue不同的是,std::stack只需要操作容器的尾部,所以你可以用vector當作來適配std::stack。
std::stack的介面比較直觀這裡不再贅述,有需要的同學可以自行檢視devdocs或者cppreference上面的文件。


std::priority_queue

在STL中,優先佇列也是一個容器介面卡,每次獲取的資料都是優先順序最大值的值(如何定義優先順序可以通過模板引數來控制)。和前面兩個容器介面卡不同的是,它預設適配的容器是std::vector(std::deque也可以用於適配優先佇列)。

1)堆

優先佇列和前面兩個容器介面卡一個重要的區別就是它不僅僅是用底層的容器來存取資料,它會調整儲存的資料的順序,構建一個堆來達到優先佇列每次都在常量時間取得優先順序最大的資料的功能。

這裡說的堆不是堆空間而是一種特殊的資料結構,它是基於陣列實現的一顆完全二叉樹,有大堆和小堆之分,預設情況下,std::priority_queue是基於大堆實現的,它的特點是父節點比子節點都要大(相反小堆是指它的父節點比子節點都要小)。正是因為堆的這種特點,所以它獲取最高優先順序的資料可以在常量時間內完成。

堆STL中也是一個非常獨特的存在,在傳統的資料結構和演算法課程中,它屬於資料結構部分,經常和佇列和棧一起講。但是在STL中它是放在演算法庫而不是容器或者容器介面卡中實現的,和堆相關的演算法有下面這些:

std::make_heap
std::pop_heap
std::push_heap
之所以這樣設計是因為堆只需要底層是一個邏輯陣列就可以了,把它設計成演算法可以讓它適用於各種邏輯陣列的實現(std::vector,std::deque,std::array,c array)。

2)逆序

如果你需要實現的是每次找到最小值而不是最大值,你可以通過改變預設的模板引數來控制。std::priority_queue的原型如下:

template<
    class T,
    class Container = std::vector<T>,
    class Compare = std::less<typename Container::value_type>
> class priority_queue;

第二個引數可以替換成std::deque,最後一個引數可以替換成你想要的排序演算法,比如std::greater,下面是一個具體的例子:

std::priority_queue<int, std::deque<int>, std::greater<int>> q;

std::list

STL中提供了std::list表示連結串列,通常它的實現是雙鏈表(它支援雙向迭代),如果你的程式碼中需要使用到連結串列結構可以選擇用它做為容器,雖然它的適用場景可能會比我們想象中要低很多。

1)std::vector vs std::list

傳統的資料結構的教程中,list通常都是伴隨著array而來,通常書上會告訴你
list中元素的插入和刪除比array要快,如果你頻繁使用插入和刪除你應該使用list而不是array
這個說法在學術上是可以認為是正確的,但是實際上大部分情況下,上面的說法是不靠譜的。絕大部分情況下,std::vector的效率都會比std::list要高,原因主要有下面幾點:

  • 找到插入點,std::list需要O(N)的時間,而vector只需要O(1)的時間。
  • std::vector的資料是集中儲存的,而std::list的資料是離散儲存的,這意味著vector的cache命中率會比std::list的cache命中率要高,記憶體的讀寫效率可能會比std::list要高。
  • std::list儲存一個數據需要兩個指標(雙鏈表)的額外空間,std::vector不需要,所以std::vector的記憶體記憶體使用效率會高於std::list。
  • std::vector的資料是連續的,可以使用二分查詢,快速查詢等演算法,std::list不行,所以std::vector的查詢效率可能會高於std::list。
    所以大部分情況下你實際需要的可能都是vector而不是std::list,即使你伴隨著資料的刪除和插入。那麼什麼時候應該選用std::list呢?

2)什麼時候考慮用std::list

容器裡面的元素比較大,這種情況下,兩個指標的額外開銷基本上可以接受,而且如果元素本身比較大,它自身cache的命中率會高。
容器的原始特別多,而且插入刪除比較頻繁(而且很多在頭部插入,如果都是在頭部插入可以對比一下deque)
你需要頻繁的在迭代的同時刪除資料,或者你需要頻繁的合併容器。std::list因為本身資料是離散儲存的,所以迭代中刪除資料不會導致後續的迭代器的失效,做區間插入的時候也可以保證全域性的異常安全性。

3)std::list 中特殊的函式

std::list中有一些特殊的成員函式值得我們在這裡稍微的討論一下:

size
這個函式比較特別的是,它的開銷可能是O(N),在C++11之前,標準規定它的開銷可能是O(N)也可能是O(1),所以輕易不要呼叫這個函式。比如

if (list.size() == 0)

最好寫成

if (list.empty() == 0)

remove
這個函式之所以特殊是因為std::vector不提供這個函式,而是使用演算法std::remove,而remove實際上不刪除資料,需要配合std::vector::erase來刪除資料。而 std::list 的提供這個成員函式而且會實際刪除資料。

insert(iterator pos, InputIt first, InputIt last)
這個函式之所以特殊是因為所有的容器中的區間insert,只有std::list的這個方法保證強異常安全性(要麼全部插入,要麼一個都不插入)

4)std::forward_list

前面提到std::list是一個雙向連結串列,在C++11之後,STL還提供了單鏈表:forward_list。單鏈表的開銷比雙鏈表要小一些,但是捨棄了雙向迭代的功能,而且只支援在頭部插入和刪除資料。

在你不需要雙向迭代的時候,你可以考慮使用單鏈表替代雙鏈表,比如雜湊表的衝突列表就可以使用單鏈表來實現的。

5)insert_after、erase_after

這兩個函式和其實容器不太一樣,其他容器是在給定的pos之前(實際上給定的位置,但是因為當前位置的資料往後挪動,相當於插入到來這個位置的元素之前)插入刪除,單鏈表因為不支援雙向迭代,只能實現在給定的位置之後插入和刪除。