STL中的deque是什么?

248
我在研究STL容器,试图弄清它们到底是什么(即所使用的数据结构),但deque让我困惑了:起初我以为它是一个双向链表,可以在常数时间内从两端进行插入和删除,但我对操作符 [] 所做出的承诺感到困扰,因为在链表中,任意访问应该是O(n),对吧?
如果它是一个动态数组,它怎么能在常数时间内添加元素呢?需要提到的是,重新分配可能会发生,O(1)是摊销成本,就像vector一样
所以我想知道这是什么结构,既可以在常数时间内进行任意访问,又永远不需要移动到新的更大的位置。

4
可能是 STL deque accessing by index is O(1)? 的重复问题。 - fredoverflow
1
@Graham,“deque”的另一个常见名称是“dequeue”。尽管“deque”通常是规范名称,但我仍然批准了编辑。 - Konrad Rudolph
@Konrad 谢谢。问题特别涉及到 C++ STL deque,它使用了更短的拼写方式。 - Graham Borland
3
“deque”是“double ended queue”的缩写,尽管对于C++来说,O(1)访问中间元素的严格要求是特定的。 - Matthieu M.
8个回答

244

双端队列(deque)的定义有点递归:它内部维护了一个固定大小的双端队列块(chunk)的队列。每个块都是一个向量(vector),而块的队列(在下面的图中称为“map”)本身也是一个向量。

schematic of the memory layout of a deque

关于deque的性能特征以及与向量(vector)的比较,这里有一篇很好的分析:CodeProject

GCC标准库的实现内部使用T**来表示map。每个数据块是一个T*,其分配了一些固定大小的空间__deque_buf_size(取决于sizeof(T))。


33
这是我学到的双端队列的定义,但是这种方式无法保证常数时间访问,所以可能有些东西遗漏了。 - stefaanv
24
我看过的C++实现使用了一个指向固定大小数组的指针数组。这实际上意味着push_front和push_back不是真正的常数时间,但通过聪明的增长因子,你仍然可以获得分摊的常数时间,所以O(1)并不是太错误,在实践中它比vector更快,因为你只交换单个指针而不是整个对象(而且指针比对象少)。 - Matthieu M.
7
仍然可以实现常数时间的访问。只需在需要在前面分配新块时,在主向量上推回一个新指针并移动所有指针即可。 - Xeo
21
@JeremyWest 为什么不行?索引访问会定位到第 i/B 个块中的第 i%B 个元素(其中 B 是块大小),这显然是O(1) 的。您可以在分摊O(1) 的时间内添加一个新块,因此最终添加元素是分摊O(1) 的。除非需要添加一个新块,否则在开头添加一个新元素的时间复杂度是O(1) 的。在开头添加一个新块不是O(1),确实是O(N),但实际上它具有非常小的常数因子,因为您只需要移动N/B个指针而不是N个元素。 - Konrad Rudolph
6
如果在外层向量的开头添加新块,并且每次重新分配空间时在已分配块的开头和结尾保留空闲空间,则可以将其摊销为 O(1),只需保持对第一个有效块的指针即可。标准的 vector 不会这样做,但这不是一个难以修改的问题。 - Mark Ransom
显示剩余15条评论

49

从概述来看,您可以将deque视为双端队列

deque overview

deque中的数据是通过固定大小向量块存储的,这些向量块由一个指向map的指针指向(map也是一个向量块,但其大小可能会更改)

deque internal structure

deque iterator的主要代码如下:

/*
buff_size is the length of the chunk
*/
template <class T, size_t buff_size>
struct __deque_iterator{
    typedef __deque_iterator<T, buff_size>              iterator;
    typedef T**                                         map_pointer;

    // pointer to the chunk
    T* cur;       
    T* first;     // the begin of the chunk
    T* last;      // the end of the chunk

    //because the pointer may skip to other chunk
    //so this pointer to the map
    map_pointer node;    // pointer to the map
}

deque 的主要部分代码如下:

/*
buff_size is the length of the chunk
*/
template<typename T, size_t buff_size = 0>
class deque{
    public:
        typedef T              value_type;
        typedef T&            reference;
        typedef T*            pointer;
        typedef __deque_iterator<T, buff_size> iterator;

        typedef size_t        size_type;
        typedef ptrdiff_t     difference_type;

    protected:
        typedef pointer*      map_pointer;

        // allocate memory for the chunk 
        typedef allocator<value_type> dataAllocator;

        // allocate memory for map 
        typedef allocator<pointer>    mapAllocator;

    private:
        //data members

        iterator start;
        iterator finish;

        map_pointer map;
        size_type   map_size;
}

以下是deque的核心代码,主要包括三个部分:

  1. 迭代器

  2. 如何构造一个deque

1. 迭代器(__deque_iterator

迭代器的主要问题是,在++、--迭代器时,如果指针指向chunk的边缘,可能会跳到其他chunk。例如,有三个数据块:chunk 1chunk 2chunk 3

pointer1指向chunk 2的开头,当使用运算符--pointer时,它将指向chunk 1的结尾,pointer2也是如此。

enter image description here

下面是__deque_iterator的主要函数:

首先,跳转到任何一个chunk:

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

请注意,chunk_size() 函数计算块大小,您可以认为它在此处简化后返回 8。 operator* 获取块中的数据。
reference operator*()const{
    return *cur;
}

operator++, --

// 前缀形式的自增/自减运算符

self& operator++(){
    ++cur;
    if (cur == last){      //if it reach the end of the chunk
        set_node(node + 1);//skip to the next chunk
        cur = first;
    }
    return *this;
}

// postfix forms of increment
self operator++(int){
    self tmp = *this;
    ++*this;//invoke prefix ++
    return tmp;
}
self& operator--(){
    if(cur == first){      // if it pointer to the begin of the chunk
        set_node(node - 1);//skip to the prev chunk
        cur = last;
    }
    --cur;
    return *this;
}

self operator--(int){
    self tmp = *this;
    --*this;
    return tmp;
}

迭代器跳过n个步骤/随机访问
self& operator+=(difference_type n){ // n can be postive or negative
    difference_type offset = n + (cur - first);
    if(offset >=0 && offset < difference_type(buffer_size())){
        // in the same chunk
        cur += n;
    }else{//not in the same chunk
        difference_type node_offset;
        if (offset > 0){
            node_offset = offset / difference_type(chunk_size());
        }else{
            node_offset = -((-offset - 1) / difference_type(chunk_size())) - 1 ;
        }
        // skip to the new chunk
        set_node(node + node_offset);
        // set new cur
        cur = first + (offset - node_offset * chunk_size());
    }

    return *this;
}

// skip n steps
self operator+(difference_type n)const{
    self tmp = *this;
    return tmp+= n; //reuse  operator +=
}

self& operator-=(difference_type n){
    return *this += -n; //reuse operator +=
}

self operator-(difference_type n)const{
    self tmp = *this;
    return tmp -= n; //reuse operator +=
}

// random access (iterator can skip n steps)
// invoke operator + ,operator *
reference operator[](difference_type n)const{
    return *(*this + n);
}

2. 如何构建一个 deque

deque 的常见功能

iterator begin(){return start;}
iterator end(){return finish;}

reference front(){
    //invoke __deque_iterator operator*
    // return start's member *cur
    return *start;
}

reference back(){
    // cna't use *finish
    iterator tmp = finish;
    --tmp; 
    return *tmp; //return finish's  *cur
}

reference operator[](size_type n){
    //random access, use __deque_iterator operator[]
    return start[n];
}


template<typename T, size_t buff_size>
deque<T, buff_size>::deque(size_t n, const value_type& value){
    fill_initialize(n, value);
}

template<typename T, size_t buff_size>
void deque<T, buff_size>::fill_initialize(size_t n, const value_type& value){
    // allocate memory for map and chunk
    // initialize pointer
    create_map_and_nodes(n);

    // initialize value for the chunks
    for (map_pointer cur = start.node; cur < finish.node; ++cur) {
        initialized_fill_n(*cur, chunk_size(), value);
    }

    // the end chunk may have space node, which don't need have initialize value
    initialized_fill_n(finish.first, finish.cur - finish.first, value);
}

template<typename T, size_t buff_size>
void deque<T, buff_size>::create_map_and_nodes(size_t num_elements){
    // the needed map node = (elements nums / chunk length) + 1
    size_type num_nodes = num_elements / chunk_size() + 1;

    // map node num。min num is  8 ,max num is "needed size + 2"
    map_size = std::max(8, num_nodes + 2);
    // allocate map array
    map = mapAllocator::allocate(map_size);

    // tmp_start,tmp_finish poniters to the center range of map
    map_pointer tmp_start  = map + (map_size - num_nodes) / 2;
    map_pointer tmp_finish = tmp_start + num_nodes - 1;

    // allocate memory for the chunk pointered by map node
    for (map_pointer cur = tmp_start; cur <= tmp_finish; ++cur) {
        *cur = dataAllocator::allocate(chunk_size());
    }

    // set start and end iterator
    start.set_node(tmp_start);
    start.cur = start.first;

    finish.set_node(tmp_finish);
    finish.cur = finish.first + num_elements % chunk_size();
}

假设i_deque有20个int元素0~19,块大小为8,现在将3个元素(0、1、2)推入i_deque

i_deque.push_back(0);
i_deque.push_back(1);
i_deque.push_back(2);

它的内部结构如下:

enter image description here

然后再次push_back,它会调用分配新块:

push_back(3)

enter image description here

如果我们使用push_front函数,它会在前一个start块之前分配一个新的块。

enter image description here

注意,当我们使用push_back函数将元素添加到双端队列中时,如果所有的映射和块都已满,则会分配一个新的映射,并调整块。但是以上代码可能已经足够让您理解deque了。


你提到:“当将元素 push_back 到 deque 中时,如果所有的映射和块都已填满,则会导致分配新的映射并调整块”。我想知道为什么 C++ 标准在 N4713 中说“[26.3.8.4.3] 在 deque 的开头或结尾插入单个元素始终需要常数时间”。分配数据块需要的时间不是常数时间吧? - HCSF
@HCSF 它需要固定时间,因为分配块所需的时间与容器中的元素数量无关。即使比单个元素的分配花费更多的时间,它仍然保持恒定。 - lucas.exe
代码是来自特定的标准库还是您自己编写的?它是否已经发布在任何地方,最好附带测试以查看其实际运行情况? :-) - underscore_d
@lucas.exe,当地图重新分配时,工作量与地图的总容量成比例,即map_len = capacity / chunk_len。 - Qwert Yuiop
@HCSF,并不总是恒定的,而是摊销恒定的。这是一个很大的区别。具有良好摊销复杂度的东西仍然可能在任何特定调用上有糟糕的最坏情况。 - Qwert Yuiop
显示剩余2条评论

28

把它想象成向量的向量,但它们不是标准的std::vector

外部向量包含指向内部向量的指针。当通过重新分配来更改其容量时,它不会像std::vector那样将所有空白空间分配到末尾,而是将空白空间分为相等的两部分,一部分在向量开头,另一部分在向量结尾。这允许在此向量上执行push_frontpush_back都以平摊O(1)的时间发生。

内部向量的行为需要根据其在deque的前面还是后面而变化。在后面,它可以像标准的std::vector一样运行,在末尾增长,push_back以O(1)时间发生。在前面,它需要相反地在每个push_front时在开头增长。实际上,这很容易通过添加指向前面元素的指针,增长方向以及大小来实现。通过这个简单的修改,push_front也可以以O(1) 时间发生。

访问任何元素需要偏移和划分到适当的外部向量索引中,这需要O(1)时间,并且索引到内部向量也是O(1)。 这假设内部向量都具有固定大小,除了在deque的开头或结尾的向量。


4
你可以将内部向量描述为具有固定的“容量”。 - Caleth

20

(这是我在另一个线程中给出的答案。基本上,我认为即使是相当幼稚的实现,使用单个vector,也符合“常数时间非摊销的push_{front,back}”的要求。你可能会感到惊讶,并认为这是不可能的,但我在标准中发现了其他相关的引用,以令人惊讶的方式定义了上下文。请耐心等待;如果我在这个答案中犯了错误,识别我正确的地方和我的逻辑出了问题将非常有帮助。)

在这个回答中,我并不试图找到一个“好”的实现,我只是试图帮助我们理解C++标准中的复杂性要求。我引用了N3242,根据Wikipedia,这是最新的免费可用的C++11标准化文档。(它似乎与最终标准组织方式不同,因此我不会引用确切的页码。当然,这些规则可能已经在最终标准中发生了变化,但我认为还没有。)
可以通过使用vector<T*>来正确地实现deque<T>。所有元素都被复制到堆上,并将指针存储在向量中。(稍后详细介绍向量。)
为什么使用T*而不是T?因为标准要求:
“在deque的任一端插入元素都会使得deque的所有迭代器失效,但不会影响对deque中元素引用的有效性。”(我加了强调)“T*有助于满足这一点。它还有助于我们满足以下要求:'在deque的开头或结尾插入单个元素总是……导致一个T的构造函数被调用'。”现在来看一下(有争议的)部分。为什么要使用vector存储T*?因为它给我们提供了随机访问的能力,这是一个好的开始。暂且不考虑vector的复杂度,我们来慢慢建立起这个想法:标准谈到了“对包含对象的操作次数”。对于deque::push_front而言,这个数字显然为1,因为只有一个T对象被构造,没有现有的T对象被读取或扫描。这个数字1显然是一个常数,并且与deque中当前对象的数量无关。这使我们得出结论:”
"对于我们的deque :: push_front ,所包含对象(Ts)上的操作数量是固定的,并且与deque中已有对象的数量无关。"
"当 vector 增长过大时,其中将进行重新分配并复制许多 T * 。因此, T * 上的操作数量会大幅变化,但 T 上的操作数量不会受到影响。"
"为什么我们要关注计算 T 和计算 T * 之间的这种区别?因为标准规定:"
"

本条款中的所有复杂性要求均仅以所包含对象上的操作次数为基础。 "

"对于 deque ,所包含的对象是 T ,而不是 T * ,这意味着我们可以忽略任何复制(或重新分配) T * 的操作。"

我还没有详细讲述一个向量在deque中的行为。也许我们可以将其解释为一个循环缓冲区(向量始终占用其最大容量capacity(),当向量满时,将所有内容重新分配到更大的缓冲区中。细节并不重要。

在过去的几段中,我们分析了deque::push_front以及队列中已有对象的数量与push_front对包含的T对象执行的操作数量之间的关系。我们发现它们彼此独立。由于标准规定复杂度是以对T的操作为单位的,因此我们可以说这具有恒定的复杂度。

是的,Operations-On-T*-Complexity是摊销的(由于vector),但我们只关心Operations-On-T-Complexity,而这是常数(非摊销)的。

在这个实现中,vector::push_back或vector::push_front的复杂度是无关紧要的;这些考虑涉及对T*的操作,因此是无关紧要的。如果标准是指传统的理论复杂度概念,那么它们不会明确限制自己只针对“包含对象上的操作次数”。我是否过于解读了这个句子?

9
对我来说,这似乎很像作弊!当你指定一个操作的复杂度时,你不仅对一部分数据进行操作:你想要知道你调用的操作的预期运行时间,无论其操作对象是什么。如果我按照你对T上的操作的逻辑来做,这意味着每次执行操作时都可以检查每个T*的值是否为质数,并且仍然符合标准,因为你没有改变Ts。请问您的引用出处在哪里? - Zonko
2
我认为标准的编写者们知道他们不能使用传统的复杂性理论,因为我们没有一个完全指定的系统,在这个系统中,我们知道例如内存分配的复杂性。假装可以为“列表”的新成员分配内存,而不管列表的当前大小是不现实的;如果列表太大,分配将会很慢或失败。因此,就我所见,委员会决定仅指定可以客观计数和测量的操作。(附注:我对此有另一个理论,将在另一个答案中阐述。) - Aaron McDaid
我很确定 O(n) 意味着操作数量与元素数量渐进成比例关系。也就是说,元操作也要算在内。否则将查询限制为 O(1) 是毫无意义的。因此,链表不符合条件。 - Mooing Duck
9
这个解释非常有趣,但按照这个逻辑,一个list也可以被实现为指针的vector(无论链表大小,插入到中间将导致单个复制构造函数调用,并且指针的O(N)打乱可以忽略,因为它们不是在T上的操作)。 - Mankarse
2
这是很好的语言技巧(虽然我不会尝试去弄清楚它是否正确或者标准中是否有一些微妙的点禁止这种实现)。但在实践中,这并不是有用的信息,因为(1)常见的实现方式并不是这样实现deque的,而且(2)即使标准允许,在计算算法复杂度时以这种方式“作弊”也无助于编写高效的程序。 - Kyle Strand
2
@Kyle Strand,常见的实现方式是将指向单个T的指针替换为指向基本上是T固定数组的指针(加上少量与指针或数组一起保存的簿记数据)。它们仍然具有相同的渐近特性,只是常数通常更有利。 - seb

18
双端队列(deque)是一种可以在任何方向上增长的容器。
Deque 通常被实现为数组的向量(链表的数组无法提供常数时间的随机访问)。数组的大小取决于具体的实现,通常是以字节为单位的固定大小(libc++ 为 4096,libstdc++ 为 512,MS STL 为 16)。
这种方法意味着对于任何大于固定大小一半的元素,deque 实际上变成了指向单个元素分配的指针向量。

7
它在内部并不是“向量”。内部结构可以在“开头”和“结尾”都有已分配但未使用的容量。 - Mooing Duck
@MooingDuck:这实际上是由具体实现定义的。它可以是数组的数组、向量的向量或任何能够提供标准所要求的行为和复杂度的东西。 - Alok Save
3
@Als:我认为任何类型的arrayvector都不能保证平摊复杂度为O(1)push_front操作。至少两种结构中的内部必须能够具有O(1)push_front,而arrayvector都无法保证这一点。 - Mooing Duck
6
如果第一块从上往下增长而不是从下往上,那么很容易满足这个要求。显然,标准的“向量”无法做到这一点,但进行这样的修改相当简单。 - Mark Ransom
9
@ Mooing Duck,使用单个向量结构,可以很容易地实现 push_front 和 push_back 两种操作的摊销 O(1)。这只是对一个环形缓冲区稍微多了一些记录,仅此而已。假设您有一个容量为1000的常规向量,在位置0到99上有100个元素。当发生 push_front 操作时,您只需在末尾(即位置999)推入元素,然后继续推入直到两端相遇。然后,您需要重新分配向量的内存空间(采用指数级增长以保证平摊常数),就像您使用普通向量一样。因此,您实际上只需要一个额外的指针来指向第一个元素。 - plamenko

11
我正在阅读Adam Drozdek的《C++数据结构与算法》一书,发现这很有用。HTH。
STL deque的一个非常有趣的方面是其实现方式。STL deque不是作为链表实现的,而是作为指向块或数据数组的指针数组实现的。块的数量根据存储需求动态变化,指针数组的大小相应地改变。
你可以注意到中间的是指向数据(右侧的块)的指针数组,也可以注意到中间的数组是动态变化的。
一张图片胜过千言万语。 enter image description here

1
感谢您推荐这本书。我读了deque部分,觉得非常不错。 - Rick
@Rick 很高兴听到这个消息。我记得曾经深入研究过双端队列,因为我无法理解如何在O(1)的时间复杂度内进行随机访问([]操作符)。同时证明(push/pop)_(back/front)具有平摊O(1)的复杂度是一个有趣的“顿悟时刻”。 - Keloo

6

虽然标准没有规定任何特定的实现方式(只要求常数时间随机访问),但双端队列通常作为一组连续的内存“页面”来实现。需要时会分配新页面,但仍可进行随机访问。与 std::vector 不同,你不能保证数据存储在连续的位置上,但像 vector 一样,在中间插入需要大量重新定位。


4
对于中间的删除或删除操作需要进行大量重新定位。 - Mark Hendrickson
如果insert需要大量的重定位,那么实验4(在此处)如何展示vector::insert()deque::insert()之间惊人的差异? - Bula
1
@Bula:也许是细节沟通出现了问题?deque插入的复杂度是“与插入的元素数量成线性关系,加上到deque开头和结尾较小距离的和。”要感受这个代价,你需要在当前中间插入;这是你的基准测试正在做的吗? - Kerrek SB
@KerrekSB:Konrad在上面的回答中提到了一篇带有基准测试的文章。实际上,我没有注意到下面文章的评论部分。在“但deque具有线性插入时间吗?”的讨论中,作者确实提到他在所有测试中都使用了位置100处的插入,这使得结果更容易理解一些。 - Bula

0

deque 可以实现为固定大小数组的循环缓冲区:

  • 使用循环缓冲区,因此我们可以通过添加/删除具有 O(1) 复杂度的固定大小数组来在两端增长/缩小
  • 使用固定大小数组,因此易于计算索引,因此通过两个指针解除引用访问索引也是 O(1)

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接