双端队列 (deque)

除了我们常见的的 vectorlist 之外,还有一种序列式容器 deque。它是一种双向开口连续线性空间,双向开口意味着你可以在它的头部,尾部任意插入元素,连续线性意味着它底层如同 vector 那样是“连续”的空间。

  • 完整实现代码
概述

我们知道 vector 底层是一块连续的空间, 正因为如此它的迭代器及其简单,仅仅是一个 T* 类型的指针,它的缺点也显而易见——头插数据的效率不高,空间“增长”的花费也很昂贵(申请新空间-搬移数据-释放旧空间)。而 deuqe 特殊的空间构造则巧妙的避免的 vector 的头插的缺点。
实际上,它的底层是一段一段的定量的连续空间(我们可以称这些一段一段的连续空间为数据缓冲区),而这些数据缓冲区则由一个被称为中控器的结构和两个迭代器来维护。

结构

先通过一幅图来看看它们之间关系:
双端队列 (deque)_第1张图片

它们之间繁琐的关系让人分析起来无从下手,但其实只要稍稍一整理,我们就可以的出:

  1. 整块空间由一个T** 的指针( map )和两个迭代器维护( startfinish )。
  2. map 指向一段定长的连续线性空间,这些空间中储存的是一个个指向“数据缓冲区”的指针。(map如同一个指针数组)
  3. start 迭代器指的 cur 向第一个缓冲区的第一个元素, finish 迭代器的 cur 指向最后一个缓冲区的最后一个元素的下一个元素。即: start “指向”“整块空间”的第一个元素, finish “指向”“整块空间”的最后一个元素。
  4. 数据实际上是储存在数据缓冲区中,而中控器只是负责把多个缓冲区“链接起来”, 从而造成“整块空间连续的假象”, 而 startfinish 则标记着有效数据的空间的头和尾。

通过上面一系列的分析,我们知道, deuqe 所谓的“连续的空间”只是一种假象,实际上它是通过中控器将多段连续空间拼接在一起。
下面是 STL 库中的相关源码(STL3.0):

...
template <class T, class Alloc = alloc, size_t BufSiz = 0> 
class deque {
public:                         // Basic types
  typedef T value_type;
  typedef value_type* pointer;
...
  typedef pointer* map_pointer;
...
protected:                      // Data members
  iterator start;
  iterator finish;

  map_pointer map;
  size_type map_size;

源码中除了管理我们前面所说的一个指针两个迭代器之外,还有一个标记map大小的变量。

迭代器

通过以上的学习,我们知道 deque 的结构与 vector 相比颇为复杂,同样的,它的迭代器结构也较复杂。
下面是迭代器相关的源码:

...

  T* cur;
  T* first;
  T* last;
  map_pointer node;
//迭代器的构造函数
  __deque_iterator(T* x, map_pointer y) 
    : cur(x), first(*y), last(*y + buffer_size()), node(y) {}
  __deque_iterator() : cur(0), first(0), last(0), node(0) {}
  __deque_iterator(const iterator& x)
    : cur(x.cur), first(x.first), last(x.last), node(x.node) {}

...

  void set_node(map_pointer new_node) {
    node = new_node;
    first = *new_node;
    last = first + difference_type(buffer_size());
  }

通过代码,我们很容易参透该迭代器的奥秘:

map : 当前迭代器标识的是哪个缓冲区;
first : 指向当前缓冲区的头部;
last:指向当前缓冲区的尾部;
cur:指向当前缓冲区的某个具体数据。

该迭代器通过上面 cur 成员就可以精准的定位到该结构中的具体某个数据,不但如此, startfinishnode 之间的相互配合得以让该迭代器在不同数据缓冲区之间跳跃,这一特性将使得迭代器的自增、自减操作简单许多。

下面是它的自增、自减源码:

...

  self& operator++() {
    ++cur;
    if (cur == last) {
      set_node(node + 1);
      cur = first;
    }
    return *this; 
  }
  self operator++(int)  {
    self tmp = *this;
    ++*this;
    return tmp;
  }

  self& operator--() {
    if (cur == first) {
      set_node(node - 1);
      cur = last;
    }
    --cur;
    return *this;
  }
  self operator--(int) {
    self tmp = *this;
    --*this;
    return tmp;
  }

...
//该函数让当前迭代器跳到一个新的缓冲区(new_node)上
  void set_node(map_pointer new_node) {
    node = new_node;
    first = *new_node;
    last = first + difference_type(buffer_size());
  }
...

显然,在进行自增操作后,如果 cur 已经到了结尾, 则让迭代器跳到下一个缓冲区上,并且让 cur 指向下一个缓冲区的头部(符合左闭右开原则)。同样的,在自减前,如果 cur 指向当前缓冲区的头部,则先跳到前一个缓冲区上,再让 cur 指向前一个缓冲区的尾部(仍符合左闭右开)。
下面的图将有助于理解自减这一过程:

双端队列 (deque)_第2张图片
自增操作过程和上图如出一辙。

数据操作
  • push_back()
    deque 的尾插操作和 vector 极为相似,不同的是扩容处理。我们知道当 vector 没有预留空间时,会重新找一块更大的空间,然后拷贝数据,释放原空间,而 deque 却不同。它是怎样做的呢?如下:

先插入三个数据:

push_back(1);
push_back(2);
push_back(3);

双端队列 (deque)_第3张图片

再插入四个个数据:

push_back(4);
push_back(5);
push_back(6);
push_back(7);

此时它的数据情况如下图左侧的,然后再插入一个数据后, finish 迭代器将发生变化,如下:
双端队列 (deque)_第4张图片
可以看出当把第一个缓冲区插满时,此时,先申请一块新的缓冲区,然后让当前 node 的下一个节点指向它,再调整 finish 迭代器。现在 finish 迭代器指向的是下一个缓冲区的起始位置,这也符合我们所说的,finish 指向最后一个元素的下一个位置,从表面来看,也使得 deque 如同“一段连续的空间”。

以上就是数据缓冲区的扩容,在有些情况下,中控器也需要扩容。中控器的扩容类似于 vector 的扩容,当中控器中的所有节点都已被分配缓冲区时,且边界的缓冲区已满的情况下再插入数据时,就需要给中控器扩容。

它的扩容做法如下:
双端队列 (deque)_第5张图片

  • push_front()
    头插数据和尾插数据如出一辙。这里不再详述。

  • pop_back()、 pop_front()
    删除数据再简单不过了。头删数据的做法是让 start 迭代器的 cur 后移一个位置,若已经到了缓冲区结尾,则释放该缓冲区,并且让 start 跳到下一个缓冲区,cur 指向其起始位置。如果删除后整个 deque 已经没有数据,则释放掉 map
    尾删也是如此,只不过是 cur 向前走。

下面给出在头部和尾部插入和删除数据的部分源代码(更多详细源码可至STL源码下载网站下载,鉴于3.0之后的版本源码命名风格,其阅读成本稍大,建议下载3.0之前的,仅供学习):

  void push_back(const value_type& t) {
    //如果没到缓冲区尾部,则世界插入数据
    if (finish.cur != finish.last - 1) {
      construct(finish.cur, t);
      ++finish.cur;
    }
    //否则调用备用插入函数进行插入
    else
      push_back_aux(t);//
  }

  void push_front(const value_type& t) {
    if (start.cur != start.first) {
      construct(start.cur - 1, t);
      --start.cur;
    }
    else
      push_front_aux(t);
  }

  void pop_back() {
    //如果被删除数据不是缓冲区的第一个数据,则直接删除
    if (finish.cur != finish.first) {
      --finish.cur;
      destroy(finish.cur);
    }
    //否则调用备用删除方法
    else
      pop_back_aux();
  }

  void pop_front() {
    if (start.cur != start.last - 1) {
      destroy(start.cur);
      ++start.cur;
    }
    else 
      pop_front_aux();
  }
  ...
  //以下是备用的增删函数
  template <class T, class Alloc, size_t BufSize>
void deque::push_back_aux(const value_type& t) {
  value_type t_copy = t;
  reserve_map_at_back();
  //给下一个缓冲区分配空间
  *(finish.node + 1) = allocate_node();
  __STL_TRY {
    //插入数据
    construct(finish.cur, t_copy);
    //将 `finish` 调整到下个缓冲区起始位置
    finish.set_node(finish.node + 1);
    finish.cur = finish.first;
  }
  __STL_UNWIND(deallocate_node(*(finish.node + 1)));
}

// Called only if finish.cur == finish.first.
template <class T, class Alloc, size_t BufSize>
void deque:: pop_back_aux() {
  //释放最后一个缓冲区
  deallocate_node(finish.first);
  //调整 `finish`使其指向上个缓冲区的最后一个数据
  finish.set_node(finish.node - 1);
  finish.cur = finish.last - 1;
  //析构该数据
  destroy(finish.cur);
}

...

deque 复杂的结构和迭代器设计使得我们在平常很少使用它,而相对简单的 vector 则被频繁的使用,当然这个要视场景而定。
实际上,stackqueue 默认使用的容器就是 deque

双端队列 (deque)_第6张图片


—— 谢谢!


参考资料
  • 模拟实现的deque.

【作者:果冻 http://blog.csdn.net/jelly_9】

你可能感兴趣的:(STL,stl,双端队列,C++,数据结构)