【決戰西二旗】|理解STL Map使用和原理

明星容器Map

筆者在使用Python進行平常開發時,最經常使用的數據結構就是list和dict,其中dict在Python底層是基於hash_table來實現的,咱們今天就介紹一下對標dict的STL關聯容器Map。html

在使用map時總讓我有種在使用NoSQL的感受,由於說到底都是key-value,感受STL中的map能夠對標LevelDB之類的普通NoSQL,倒更加的貼切。ios

結合C++11來講,目前STL支持的map類型包括:map、multimap、unordered_map。雖然都是map,可是在使用和底層實現上會有一些差別,所以深刻理解一下才能更好將map用於平常開發工做中。面試

Map的定義

map的全部元素都是pair,同時具有key和value,其中pair的第一個元素做爲key,第二個元素做爲value。map不容許相同key出現,而且全部pair根據key來自動排序,其中pair的定義在以下:數組

template <typename T1, typename T2>
struct pair {
    typedef T1 first_type;
    typedef T2 second_type;

    T1 first;
    T2 second;

    pair() : first(T1()), second(T2()) { }
    pair(const T1& a, const T2& b) : first(a), second(b) { }
};複製代碼

從定義中看到pair使用模板化的struct來實現的,成員變量默認都是public類型。bash

map的key不能被修改,可是value能夠被修改,STL中的map是基於紅黑樹來實現的,所以能夠認爲map是基於紅黑樹封裝了一層map的接口,底層的操做都是藉助於RB-Tree的特性來實現的,再來進一步看下map的定義,以下(舒適提示:代碼主要體現map與RB-Tree在實現上的交互,大體看懂便可):微信

template <class Key, class T, class Compare = less<Key>, class Alloc = alloc>
class map {
public:
  typedef Key key_type;                         //key類型
  typedef T data_type;                          //value類型
  typedef T mapped_type;
  typedef pair<const Key, T> value_type;        //元素類型, const要保證key不被修改
  typedef Compare key_compare;                  //用於key比較的函數
private:
  //內部採用RBTree做爲底層容器
  typedef rb_tree<key_type, value_type,
                  identity<value_type>, key_compare, Alloc> rep_type;
  rep_type t; //t爲內部RBTree容器
public:
  //iterator_traits相關
  typedef typename rep_type::const_pointer pointer;            
  typedef typename rep_type::const_pointer const_pointer;
  typedef typename rep_type::const_reference reference;        
  typedef typename rep_type::const_reference const_reference;
  typedef typename rep_type::difference_type difference_type; 

  //迭代器相關
  typedef typename rep_type::iterator iterator;          
  typedef typename rep_type::const_iterator const_iterator;
  typedef typename rep_type::const_reverse_iterator reverse_iterator;
  typedef typename rep_type::const_reverse_iterator const_reverse_iterator;
  typedef typename rep_type::size_type size_type;

  //迭代器函數
  iterator begin() { return t.begin(); }
  const_iterator begin() const { return t.begin(); }
  iterator end() { return t.end(); }
  const_iterator end() const { return t.end(); }
  reverse_iterator rbegin() { return t.rbegin(); }
  const_reverse_iterator rbegin() const { return t.rbegin(); }
  reverse_iterator rend() { return t.rend(); }
  const_reverse_iterator rend() const { return t.rend(); }

  //容量函數
  bool empty() const { return t.empty(); }
  size_type size() const { return t.size(); }
  size_type max_size() const { return t.max_size(); }

  //key和value比較函數
  key_compare key_comp() const { return t.key_comp(); }
  value_compare value_comp() const { return value_compare(t.key_comp()); }

  //運算符
  T& operator[](const key_type& k)
  {
    return (*((insert(value_type(k, T()))).first)).second;
  }
  friend bool operator== __STL_NULL_TMPL_ARGS (const map&, const map&);
  friend bool operator< __STL_NULL_TMPL_ARGS (const map&, const map&);
}複製代碼

Map的成員函數

map的成員函數(部分經常使用)能夠分爲幾類:數據結構

  • 構造函數、析構函數、賦值構造函數
  • 迭代器返回函數
  • 容量空間函數
  • 取值操做
  • 修正處理(插入、刪除、交換、清空)
  • 比較函數
  • 查找函數
  • 構造器函數

Map的經常使用操做舉例

std::map::insert

map的insert操做是很是常見的,而且插入操做不存在迭代器失效問題,其中根據insert的重載類型,能夠支持多種插入方式,在C++11中增長了第四種重載函數,本文暫列舉C++98的3種重載定義,先看下insert的幾種定義:app

//single element (1)    
pair<iterator,bool> insert (const value_type& val);
//with hint (2)    
iterator insert (iterator position, const value_type& val);
//range (3)    
template <class InputIterator>
void insert (InputIterator first, InputIterator last);複製代碼

上述代碼給出了3種插入方式分別是單元素pair、指定位置、範圍區間,看下實際的例子:
less

//map::insert (C++98)
#include <iostream>
#include <map>

int main ()
{
  std::map<char,int> mymap;

  //first insert function version (single parameter):
  //注意返回值 是兩個 迭代器和是否成功
  mymap.insert ( std::pair<char,int>('a',100) );
  mymap.insert ( std::pair<char,int>('z',200) );

  std::pair<std::map<char,int>::iterator,bool> ret;
  ret = mymap.insert ( std::pair<char,int>('z',500) );
  if (ret.second==false) {
    std::cout << "element 'z' already existed";
    std::cout << " with a value of " << ret.first->second << '\n';
  }

  // second insert function version (with hint position):
  //因爲map的key的有序性 插入位置對效率有必定的影響
  std::map<char,int>::iterator it = mymap.begin();
  mymap.insert (it, std::pair<char,int>('b',300));  // max efficiency inserting
  mymap.insert (it, std::pair<char,int>('c',400));  // no max efficiency inserting

  // third insert function version (range insertion):
  //第三種重載 範圍區間
  std::map<char,int> anothermap;
  anothermap.insert(mymap.begin(),mymap.find('c'));

  // showing contents:
  //迭代器遍歷
  std::cout << "mymap contains:\n";
  for (it=mymap.begin(); it!=mymap.end(); ++it)
    std::cout << it->first << " => " << it->second << '\n';

  std::cout << "anothermap contains:\n";
  for (it=anothermap.begin(); it!=anothermap.end(); ++it)
    std::cout << it->first << " => " << it->second << '\n';

  return 0;
}複製代碼

std::map::erase

清除操做也是很是重要的操做,而且存在迭代器失效問題,刪除操做一樣在C++98中有3個重載函數,定義以下:ide

void erase (iterator position);
size_type erase (const key_type& k);
void erase (iterator first, iterator last);複製代碼

能夠看到這三個函數分別支持:迭代器位置刪除、指定key刪除、迭代器範圍刪除,看下實際的例子:

// erasing from map
#include <iostream>
#include <map>

int main ()
{
  std::map<char,int> mymap;
  std::map<char,int>::iterator it;

  // insert some values:
  mymap['a']=10;
  mymap['b']=20;
  mymap['c']=30;
  mymap['d']=40;
  mymap['e']=50;
  mymap['f']=60;

  it=mymap.find('b');
  mymap.erase (it);                   // erasing by iterator

  mymap.erase ('c');                  // erasing by key

  it=mymap.find ('e');
  mymap.erase ( it, mymap.end() );    // erasing by range

  // show content:
  for (it=mymap.begin(); it!=mymap.end(); ++it)
    std::cout << it->first << " => " << it->second << '\n';

  return 0;
}複製代碼

std::map::swap

交換操做能夠實現兩個相同類型的map的交換,即便map元素容量不一樣,這個操做看着很神奇而且效率很高,能夠想下是如何實現的,舉個使用栗子:

// swap maps
#include <iostream>
#include <map>

int main () {
  std::map<char,int> foo,bar;

  foo['x']=100;
  foo['y']=200;

  bar['a']=11;
  bar['b']=22;
  bar['c']=33;

  foo.swap(bar);

  std::cout << "foo contains:\n";
  for (std::map<char,int>::iterator it=foo.begin(); it!=foo.end(); ++it)
    std::cout << it->first << " => " << it->second << '\n';

  std::cout << "bar contains:\n";
  for (std::map<char,int>::iterator it=bar.begin(); it!=bar.end(); ++it)
    std::cout << it->first << " => " << it->second << '\n';

  return 0;
}複製代碼

代碼輸出:

foo contains:
a => 11
b => 22
c => 33
bar contains:
x => 100
y => 200複製代碼

Map與紅黑樹

建造者紅黑樹

前面說了一些定義,如今介紹今天的重點內容Map與紅黑樹。

從定義能夠看到map的定義中本質上是在內部實現了一棵紅黑樹,由於紅黑樹的增刪改查的全部操做均可以在有時間保證的前提下完成,然而這些操做也正是map所須要的。

換句話說map應該提供的接口功能,紅黑樹也都有,從而map的全部操做都是內部轉向調用RB-Tree來實現的。

提到紅黑樹感受很難很複雜而且離咱們平常開發很遠,其實否則,紅黑樹不只是做爲AVL的工程版本,在增長節點顏色、不嚴格平滑等特性實現了更高效的插入和刪除。

更重要的是RB-Tree做爲一種基礎的數據結構,常常被用於構建其餘對外的結構,咱們今天說的map以及STL的set底層都是基於紅黑樹的,因此要把RB-Tree當作是一種基礎構造類型的數據結構。

SGI STL並無直接只用RB-Tree,而是對其進行了模板化處理以及增長一些有益節點,從而來更加高效的爲STL中的容器服務,因此STL中使用的能夠認爲是變種的紅黑樹。

好比SGI STL針對RB-Tree採用了header技巧,header指向根節點的指針,與根節點互爲對方的父節點,而且left和right指向左子樹最小和右子樹最大,如圖所示:

爲何是紅黑樹

這裏引入一個常見的問題:

面試官:stl的map是基於紅黑樹實現的,那麼爲何要基於紅黑樹?

這個問題也算是高頻問題,不少人上來就回答紅黑樹的各類好處,那確實也沒錯,可是這樣也最多算答上來一半。

其實也是如此,沒有對比就沒有發言權,總說A好,不和BCD對比一下怎麼知道?

map的一些原則

map的場景本質上就是動態查找過程,所謂動態就是其中包含了插入和刪除,而且數據量會比較大並且元素的結構也比較隨意,而且都是基於內存來實現的,所以咱們就須要考慮效率和成本,既要節約內存又要提升調整效率和查找速度。

備選的數據結構

說到查找問題 必然有幾種備選的數據結構不乏:

  • 線性結構及其變種:數組、鏈表、跳躍鏈表
  • 樹形結構:BST、AVL、RB-Tree、Hash_Table、B-Tree、Splay-Tree、Treap

固然還有一些其餘我可能不知道的,可是熱門的基本都在這裏了,那麼一一來看進行優劣勢分析:

  • 數組和鏈表 不用多說,動態高效的插入、刪除、查找都不能知足要求。
  • 跳躍鏈表SkipList 在Redis中和LevelDB中都有應用,而且當時是聲稱要替代RB-Tree,那爲何map不是使用跳躍鏈表來實現的呢?
  • BST和AVL 是二叉搜索樹和平衡二叉樹,這兩個比較容易排除,BST可能退化成爲鏈表,那麼樹就至關於很高,時間沒法保證,AVL做爲嚴格平衡的二叉搜索樹對平衡性要求很高,所以在插入和刪除數據時會形成不斷地重度調整,影響效率,有點學術派而非工程派,可是AVL是後面不少變種樹的基礎也很重要,可是確實不適合用在map中。
  • Hash_Table 其實目前已經有基於哈希表的map版本了,相比紅黑樹查找更快,然而時間的提高也是靠消耗空間完成的,哈希表須要考慮哈希衝突和裝載因子的處理,在1994年左右內存很小而且很貴,所以哈希表在當時並無被應用於實現map,如今內存相對來講已經很大而且再也不昂貴,哈希表天然也有用武之地了。
  • Splay-Tree 伸展樹也是一種變種,它是一種可以自我平衡的二叉查找樹,它能在均攤O(log n)的時間內完成基於伸展(Splay)操做的插入、查找、修改和刪除操做。它是由丹尼爾·斯立特(Daniel Sleator)和羅伯特·塔揚在1985年發明的。
  • Treap 就是Tree+heap,樹堆也是一種二叉搜索樹,是有一個隨機附加域知足堆的性質的二叉搜索樹,其結構至關於以隨機數據插入的二叉搜索樹。其基本操做的指望時間複雜度爲O(log{n})。相對於其餘的平衡二叉搜索樹,Treap的特色是實現簡單,且能基本實現隨機平衡的結構。
  • B-Tree 這裏能夠認爲是B樹族包括B樹、B+樹、B*樹,咱們都知道B樹在MySQL索引中應用普遍,構建了更矮更胖的N叉樹,這種結構結點能夠存儲更多的值,有數據塊的概念,所以應對磁盤存儲頗有利,事實上即便內存中使用B樹也能夠提升CacheHit的成功率,從而提升效率,網上有的文章提到STL之父說若是再有機會他可能會使用B樹來實現一種map,也就是藉助於局部性原理來提升速度。

關於跳躍鏈表的一些猜想

在網上也並無這種爲何不用跳錶的對比,筆者試着想了一下:

對於這個對比必定要結合map的發明年代背景來講,就好像咱們如今問10年前的人們爲何不用微信、拼多多同樣,由於那時候壓根沒有或者剛出來暫時沒有推廣等諸多緣由。

SGI STL是大約1994年左右開發的,然而跳躍鏈表是1990年左右由William Pugh發明的,也就是能夠認爲兩者是同時期的產物,然而Redis是2005年以後的產物,再看看紅黑樹是1978年左右發明出現的,因此對發明STL的那幫大牛,最開始考慮跳躍鏈表的可能性很小。

在國外的網站上有求知者試着用跳錶實現map可是性能和紅黑樹有必定的差距,另外跳錶自己是機率平衡的,節點大小相比於紅黑樹更大,可是我以爲這並非致命的問題,或許過些年STL就會出現基於跳錶的實現版本,這個很難說,因此不能一棒子打死說必須是紅黑樹。

百舸爭流

從上面我所知道的數據結構來看,選擇紅黑樹有不少歷史緣由和性能考慮、時代在進步,並不必定後續就不會出現基於Treap樹堆和B樹的map版本,或許如今就有公司或者大佬本身使用跳錶、Treap、B樹來實現map。

因此咱們不能教條地記憶stl使用紅黑樹的緣由,至於真正的緣由只有創造者知道,不要把推測當作結論,沒有最好只有更好。

參考資料

相關文章
相關標籤/搜索