【Effective STL(3)】關聯容器

weixin_33806914發表於2018-03-05

19 瞭解相等和等價的區別

  • find演算法和set::insert是判斷兩個值是否相同的函式代表,它們以不同的方式完成,find的相同基於operator==,set::insert對相同的定義是等價,通常基於operator<
  • 相等基於operator==,在類中過載時,即使兩個類不完全相同也可以相等
  • 等價基於一個有序區間中物件值的相對位置,標準關聯容器保持有序,所以每個容器必須定義一個保持有序的比較函式(預設是less),如果一個元素不在另一個之前(關於某個排序標準),則這兩個元素是等價的(按照這個標準)。如果用相等決定兩個物件是否有相同的值,除了排序的比較函式還需要一個用於判斷兩個值是否相等的比較函式(習慣用operator==)
  • 一般關聯容器的比較函式不是operator<或less,而是使用者定義的判斷式,標準關聯容器通過key_comp成員函式訪問排序判斷式,key_comp預設是一個 std::less 物件,類似操作符 operator<,返回容器中用來比較主鍵的比較物件的一份拷貝
!c.key_comp()(x, y) && !c.key_comp()(y, x) // 為true則xy等價
  • 為了更理解相等和等價的區別,考慮一個忽略大小寫的set<string>,item35實現了忽略大小寫比較的ciStringCompare函式,這裡寫一個仿函式類,類的operator()呼叫ciStringCompare
struct CIStringCompare : public binary_function<string, string, bool>
{ // 這個基類資訊見item40
    bool operator()(const string& lhs, const string& rhs) const
    {
        return ciStringCompare(lhs, rhs);
    }
}
  • 利用這個函式物件建立一個忽略大小寫的set<string>
set<string, CIStringCompare> ciss; // case-insensitive string set
ciss.insert("Persephone"); // 新增到set中
ciss.insert("persephone"); // 未新增到set中
  • 用set的find成員函式搜尋字串“persephone”會成功,但用find演算法會失敗,因為前者基於等價,後者基於相等,“persephone”和"Persephone"在此定義中等價但不相等
if (ciss.find("persephone") != ciss.end())... // true
if (find(ciss.begin(), ciss.end(), "persephone") != ciss.end())... // false

20 為指標的關聯容器指定比較型別

  • 假定有一個string*指標的set,把一些動物的名字插入進set
set<string*> ssp; // set of string ptrs
ssp.insert(new string("Anteater"));
ssp.insert(new string("Wombat"));
ssp.insert(new string("Lemur"));
ssp.insert(new string("Penguin"));
  • 接著希望用下列程式碼使字串按字母順序出現
// 希望看到“Anteater”,“Lemur”,“Penguin”,"Wombat"
for (set<string*>::const_iterator i = ssp.begin(); i != ssp.end(); ++i)
    cout << *i << endl;
  • 然而結果只能看見四個十六進位制數,因為set元素為指標,*i是一個string的指標。馬上會想出把*i改為**i,但這樣也不能保證輸出按字母順序出現,因為set中儲存的是指標,以指標值排序而非string值
  • 為了解決這個問題,首先應要知道set<string*> ssp是下列程式碼的簡寫
set<string*, less<string*>, allocator<string*>> ssp;
  • 因此要string*指標以字串值順序儲存在set中,不能用預設的仿函式類less<string*>,必須改為自己的比較仿函式類,它的物件帶有string*指標並按指向的字串值排序
struct StringPtrLess : public binary_function<const string*, const string*, bool>
{ // 這個基類資訊見item40
    bool operator()(const string *ps1, const string *ps2) const
    {
        return *ps1 < *ps2;
    }
};
  • 然後用StringPtrLess作為比較型別,迴圈就可以得到按字母順序排序的輸出了
typedef set<string*, StringPtrLess> StringPtrSet;
StringPtrSet ssp;
for (StringPtrSet::const_iterator i = ssp.begin(); i != ssp.end(); ++i)
    cout << **i << endl;
  • 可以改為使用演算法,寫一個函式給for_each聯用
void print(const string *ps)
{
    cout << *ps << endl;
}
for_each(ssp.begin(), ssp.end(), print); // 對ssp的每個元素上呼叫print
  • 或者用泛型的解引用仿函式類,然後讓它和transform與ostream_iterator聯用
struct Dereference {
    template <typename T>
    const T& operator()(const T *ptr) const
    {
        return *ptr;
    }
};
// 通過解引用“轉換”ssp中的每個元素,把結果寫入cout
transform(ssp.begin(), ssp.end(), ostream_iterator<string>(cout, "\n"), Dereference());
  • 需要一個仿函式類而不是一個簡單的比較函式的原因是,set不要一個函式,它要的是能在內部用例項化建立函式的一種型別
  • 建立指標的關聯容器得指定容器的比較型別,大多數時候,比較型別只是解引用指標並比較所指向的物件,因此手頭最好有一個這樣的仿函式模板
struct DereferenceLess {
    template <typename PtrType>
    bool operator()(PtrType pT1, PtrType pT2) const
    {
        return *pT1 < *pT2;
    }
};

set<string*, DereferenceLess> ssp; // 行為就像set<string*, StringPtrLess>
  • 對於智慧指標或迭代器的關聯容器,也得指定比較型別。指標的解決方案也可以用於類似指標的物件,正如DereferenceLess適合作為T*的關聯容器的比較型別一樣,它也可以作為T物件的迭代器和智慧指標容器的比較型別

21 永遠讓比較函式對相等的值返回false

  • 建立一個set,比較型別用less_equal,然後插入兩次10
set<int, less_equal<int> > s; // s以“<=”排序
s.insert(10);
s.insert(10);
  • 對於後一個insert呼叫,set必須先要判斷出10是否已經位於其中,為了區分,將前一個10稱為10A,後一個稱為10B。set遍歷內部資料結構以查詢適合插入10B的位置,最終使用比較函式檢查10B是否與10A等價,這裡比較函式為less_equal,而less_equal意思就是operator<=。於是,set將計算這個表示式
!(10A <= 10B) && !(10B <= 10A)
  • 這個表示式結果顯然是false,於是set認為10A與10B不等價,然後將10B插入到10A旁邊,於是set有了兩個10,因此用less_equal作為比較型別破壞了容器,同理任何對相等的值返回true的比較函式都會做同樣的事情
// 對item20的StringPtrLess中的operator()結果取反實現StringPtrGreater
struct StringPtrGreater : public binary_function<const string*, const string*, bool> {
    bool operator()(const string *ps1, const string *ps2) const
    {
        return !(*ps1 < *ps2);
    }
};
// 這裡取反返回的是>=而不是>,因此這是一個無效的比較函式
// 需要改為 return *ps2 < *ps1;
  • 不僅僅對於map和set如此,對於multiset和multimap也是同理。將開頭的程式碼改為multiset
multiset<int, less_equal<int>> s; // s仍然以“<=”排序
s.insert(10);
s.insert(10);
  • s有兩個10,對它做一個equal_range,希望得到一對指出包含這兩個拷貝的範圍的迭代器,而equal_range指示等價而非相等的值的範圍,比較函式認為10A和10B是不等價的,所以不會同時出現在equal_range指示的範圍內。因此,除非比較函式總是為相等的值返回false,否則將會打破所有的標準關聯型容器

22 避免原地修改set和multiset的鍵

  • 對於於map和multimap,試圖改變容器裡的一個鍵值的程式將不能編譯,因為map<K, V>或multimap<K, V>元素的型別是pair<const K, V>
  • 原地修改鍵對map和multimap來說是不可能的(除非用const_cast去掉const屬性,但沒有人會這樣做),但對set和multiset卻是可能的,因為set<T>或multiset<T>的元素型別是T而非const T
  • 首先要理解為什麼set裡的型別不是const
// 一個僱員類
class Employee {
public:
    ...
    const string& name() const; // 獲取僱員名
    void setName(const string& name); // 設定僱員名
    const string& getTitle() const; // 獲取僱員頭銜
    void setTitle(string& title); // 設定僱員頭銜
    int idNumber() const; // 獲取僱員ID號
    ...
};
// 以ID號來排序
struct IDNumberLess : public binary_function<Employee, Employee, bool> {
    bool operator()(const Employees lhs, const Employee& rhs) const
    {
        return lhs.idNumber() < rhs.idNumber();
    }
};
// 建立僱員類的set
typedef set<Employee, IDNumberLess> EmpIDSet;
EmpIDSet se; // se按ID號排序
// ID是主鍵不能修改,但對僱員的頭銜做修改是合法的
// 而這種行為的合法則決定了set元素不能是const
Employee selectedID; // 容納被選擇僱員的ID號
...
EmpIDSet::iterator i = se.find(selectedID);
if (i != se.end())
{
    i->setTitle("Corporate Deity"); // 給僱員新頭銜
}
  • 上面的原因對於map來說其實也是適用的,但標準委員會認為map/multimap鍵應該是const而set/multiset的值不是
  • 即使set和multiset的元素不是const,仍然有很多阻止修改方式,比如讓用於set<T>::iterator的operator*返回一個const T&,不過這樣的實現不一定合法,根據不同編譯器有不同的表現結果,既然標準模稜兩可,試圖修改set或multiset中元素的程式碼就不可移植
  • 如果不關心移植性,要改變set或multiset中元素的值,編譯器可以通過,那就保持這樣,但要確定不要改變元素的鍵部分,即影響容器有序性的元素部分。如果在乎移植性,就認為set和multiset中的元素在沒有const_cast的情況下不能被修改
EmpIDSet::iterator i = se.find(selectedID);
if (i != se.end()) {
    i->setTitle("Corporate Deity"); // 有些編譯器不能通過,因為*i是const
}
// 為了編譯通過使用const_cast
if (i != se.end()) {
   const_cast<Employee&>(*i).setTitle("Corporate Deity");
}
// static_cast不可行,因為修改的只是臨時物件
if (i != se.end()) {
    static_cast<Employee>(*i).setTitle("Corporate Deity");
}
// 等價於
if (i != se.end()){
    Employee tempCopy(*i); // 把*i拷貝到tempCopy
    tempCopy.setTitle("Corporate Deity"); // 修改tempCopy
}
  • 要安全地改變set、multiset、map或multimap裡的元素,按五個簡單的步驟去做
    • 定位想要改變的容器元素
    • 拷貝一份要被修改的元素
    • 修改副本,使它有你想要在容器裡的值
    • 呼叫erase刪除容器中的元素
    • 把新值插入容器。如果新元素在容器的排序順序中的位置正好相同或相鄰於刪除的元素,使用insert
      的“提示”形式(第一步獲得的迭代器作為提示)把插入的效率從對數時間改進到分攤的常數時間
typedef set<Employee, IDNumberLess> EmpIDSet;
EmpIDSet se;
Employee selectedID;
...
EmpIDSet::iterator i = se.find(selectedID); // 第一步:找到要改變的元素
if (i!=se.end())
{
    Employee e(*i); // 第二步:拷貝元素
    se.erase(i++); // 第三步:刪除元素,自增保持迭代器有效
    e.setTitle("Corporate Deity"); // 第四步:修改副本
    se.insert(i, e); // 第五步:插入新值,提示它的位置和原先元素的一樣
}

23 考慮用有序vector代替關聯容器

  • 標準關聯容器的典型實現是平衡二叉查詢樹,平衡二叉查詢樹是對插入、刪除和查詢的混合操作優
    化的資料結構,因此它的設計目標是優化這些混合操作
  • 很多應用中的資料結構沒有那麼混亂,它們有三個不同的階段
    • 建立。通過插入很多元素建立一個新的資料結構。在這個階段,幾乎所有的操作都是插入和刪除,幾
      乎沒有查詢
    • 查詢。在資料結構中查詢指定的資訊片。在這個階段,幾乎所有的操作都是查詢,幾乎沒有插入和刪除
    • 重組。通過刪除所有現有資料或在原地插入新資料修改內容。這個階段的行為等價於階段1,一旦這個階段完成,程式返回階段2
  • 對於使用上述資料結構的應用來說,vector可能比關聯容器效能高(時間和空間上都是)。但必須是有序vector,因為有序容器才能使用查詢演算法binary_search、lower_bound、equal_range等
  • vector二分查詢比二叉樹的二分查詢效能好的原因是,平衡二叉樹的每個類物件要附加左右孩子和父節點三個指標,而vector沒有這種開銷。假如資料結構足夠大,分為多個記憶體頁面,類的大小為12個位元組,指標為4個位元組,一個記憶體頁面為4096(4K)位元組,用vector可以儲存4096/12=341個物件,而關聯容器只能儲存一半
  • 當然,有序vector的最大缺點是必須保持有序,插入和刪除開銷大,新元素插入時,大於新元素的所有元素都必須向上移一位,刪除同理。因此資料結構使用查詢而幾乎不用插入和刪除時,有序vector代替關聯容器才有意義
  • 使用有序vector代替set的程式碼框架,其中最難的是選擇搜尋演算法,見item45
vector<Widget> vw;
... // 建立階段
sort(vw.begin(), vw.end()); // 結束建立階段,模擬multiset時用stable_sort,見item31
Widget w; // 用於查詢的值的物件
... // 開始查詢階段
if (binary_search(vw.begin(), vw.end(), w))... // 通過binary_search查詢
vector<Widget>::iterator i = lower_bound(vw.begin(), vw.end(), w); // 通過lower_bound查詢
if (i != vw.end() && !(w < *i))...
pair<vector<Widget>::iterator, vector<Widget>::iterator> range =
    equal_range(vw.begin(), vw.end(), w); // 通過equal_range查詢
if (range.first != range.second)...
... // 結束查詢階段,開始重組階段
sort(vw.begin(), vw.end()); // 開始新的查詢階段...
  • map中的元素型別是pair<const K, V>,要用vector模擬map或者multimap時必須去掉const,因為對vector排序時,元素值會通過賦值移動
  • map和multimap排序只作用於元素的key,而pair的operator<作用於pair的兩個元件,所以排序vector時要給pair自定義比較函式,排序的比較函式的引數是兩個pair。另外還需要第二個比較函式用於查詢,查詢只用到key,需要傳給查詢的比較函式一個key和一個pair,因為不知道key還是pair是作為第一個實參傳遞的,所以需要寫兩個函式
typedef pair<string, int> Data;
class DataCompare { // 用於比較的類
public:
    bool operator()(const Data& lhs, const Data& rhs) const // 用於排序的比較函式
    {
        return keyLess(lhs.first, rhs.first);
    }
    bool operator()(const Data& Ihs, const Data::first_type& k) const // 用於查詢的比較函式(形式1)
    {
        return keyLess(lhs.first, k);
    }
    bool operator()(const Data::first_type& k, const Data& rhs) const // 用於查詢的比較函式(形式2)
    {
        return keyLessfk, rhs.first);
    }
private:
    bool keyLess(const Data::first_type& k1, const Data::first_type& k2) const // 真正的比較函式
    {
        return k1 < k2;
    }
};

vector<Data> vd; // 代替map<string, int>
... // 建立階段
sort(vd.begin(), vd.end(), DataCompare()); // 結束建立階段,模擬multimap時用stable_sort
string s; // 用於查詢的值的物件
... // 開始查詢階段
if (binary_search(vd.begin(), vd.end(), s, DataCompare()))... // 通過binary_search查詢
vector<Data>::iterator i = lower_bound(vd.begin(), vd.end(), s, DataCompare()); // 通過lower_bound查詢
if (i != vd.end() && !DataCompare()(s, *i))...
pair<vector<Data>::iterator, vector<Data>::iterator> range =
    equal_range(vd.begin(), vd.end(), s, DataCompare()); // 通過equal_range查詢
if (range.first != range.second)...
... // 結束查詢階段,開始重組階段
sort(vd.begin(), vd.end(), DataCompare()); // 開始新的查詢階段...

24 當關乎效率時應該在map::operator[]和map-insert之間仔細選擇

  • map::operator[]被設計為簡化“新增或更新”功能,對於m[k] = v,檢查鍵k,如果k已經在map裡,關聯值被更新成v,否則就新增上,以v作為對應值。原理是operator[]返回一個與k關聯的值物件的引用,然後v賦值給所引用物件,如果k不在map裡,operator[]就沒有可以引用的值物件,於是值型別的預設建構函式重新建立一個,operator[]返回新建立物件的引用
map<int, Widget> m;
m[1] = 1.50;
// 等價於
typedef map<int, Widget> IntWidgetMap;
pair<IntWidgetMap::iterator, bool> result = m.insert(IntWidgetMap::value_type(1, Widget())); 
result.first->second = 1.50;
  • 由上述程式碼可知,用想要的值構造Widget比預設構造Widget再賦值顯然更高效。用insert替換operator[],節省了三次函式呼叫:預設構造Widget物件,銷燬臨時物件和一個賦值操作
m.insert(IntWidgetMap::value_type(1, 1.50));
  • 新增時insert比operator[]更高效,當等價的鍵已經在map裡再更新時正好相反。insert需要IntWidgetMap::value_type型別實參(即pair<int, Widget>),呼叫insert時必須構造和析構一個該型別物件,耗費了一對建構函式和解構函式,因為pair<int, Widget>本身包含了一個Widget物件,還會造成一個Widget的構造和析構,operator[]沒有使用pair物件,所以沒有構造和析構pair和Widget
m[k] = v; // 使用operator[]
m.insert(IntWidgetMap::value_type(k, v)).first->second = v; // 使用insert
  • 可以自己實現一個對於新增和更新都適用的函式
template<typename MapType, typename KeyArgType, typename ValueArgtype>
typename MapType::iterator efficientAddOrUpdate
    (MapType& m, const KeyArgType& k, const ValueArgtype& v)
{
    typename MapType::iterator Ib = m.lower_bound(k);
    if(Ib != m.end() && !(m.key_comp()(k, Ib->first)))
    {
        Ib->second = v;
        return Ib;
    }
    else
    {
        typedef typename MapType::value_type MVT;
        return m.insert(Ib, MVT(k, v)); // 把pair(k, v)新增到m並返回指向新map元素的迭代器
    }
}

efficientAddOrUpdate(m, 10, 1.5);
// 如果m已經包含鍵是10的元素,推斷出ValueArgType是double
// 函式體直接把1.5作為double賦給與10相關的那個Widget

25 熟悉非標準雜湊容器

  • 相容STL的雜湊關聯容器可以從多個來源獲得,而且它們的名字通常是:hash_set、hash_multiset、hash_map和hash_multimap。但因為沒有遵循一個標準實現,為了避開這些名字,在C++標準委員會的議案中,雜湊容器的名字是unordered_set、unordered_multiset、unordered_map和unordered_multimap,C++11中引入了這些容器

相關文章