STL中真正的双端队列是什么?


192

我正在查看STL容器并试图弄清它们的真正含义(即所使用的数据结构),但双端队列使我停了下来:我认为起初它是一个双链表,因此允许在两端插入和删除固定时间,但是操作员[] 承诺要在固定时间内完成,这让我感到困扰。在链表中,任意访问应为O(n),对吗?

如果它是一个动态数组,它如何在恒定时间内添加元素?应该提到的是,可能发生重新分配,并且O(1)是摊销成本,就像vector一样

因此,我想知道这种结构允许在恒定时间内进行任意访问,而同时又无需将其移至更大的新位置。



1
@Graham“ dequeue”是“ deque”的另一个通用名称。我仍然批准了该修改,因为“双端队列”通常是规范名称。
Konrad Rudolph

@Konrad谢谢。问题特别是关于使用较短拼写的C ++ STL双端队列。
Graham Borland

2
deque代表双头队列,尽管显然O(1)访问中间元素的严格要求特定于C ++
Matthieu M.

Answers:


181

双端队列在某种程度上是递归定义的:它在内部维护一个固定大小的的双端队列。每个块都是一个向量,块的队列(下图中的“映射”)本身也是一个向量。

双端队列的内存布局示意图

vectorCodeProject上对性能特征以及如何与性能进行了很好的分析。

GCC标准库实现在内部使用T**来表示地图。每个数据块都是一个T*分配有固定大小__deque_buf_size(取决于sizeof(T))的。


27
这就是我所学的双端队列的定义,但是这样就不能保证恒定的访问时间,因此肯定缺少某些内容。
stefaanv 2011年

14
@ stefaanv,@ Konrad:我见过的C ++实现使用了指向固定大小数组的指针数组。这实际上意味着push_front和push_back并不是真正的固定时间,但是在智能增长因素的情况下,您仍然可以摊销固定时间,因此O(1)并不是那么错误,并且在实践中它比vector更快,因为您正在交换单个指针而不是整个对象(指针比对象少)。
Matthieu M.

5
恒定时间访问仍然是可能的。只是,如果您需要在前面分配一个新块,则将主向量上的新指针向后推并移动所有指针。
Xeo

4
如果映射(队列本身)是一个双端列表,我看不到它如何允许O(1)随机访问。它可能被实现为循环缓冲区,这将使循环缓冲区的大小调整更加有效:仅复制指针而不是队列中的所有元素。看来这还是个小好处。
2013年

14
@JeremyWest为什么不呢?索引访问将访问第i / B个块中的第i%B个元素(B =块大小),这显然是O(1)。您可以在摊销的O(1)中添加一个新块,因此最后添加的元素在摊销的O(1)中。除非需要添加新块,否则在开头添加新元素为O(1)。在开始处添加一个新块不是O(1),是的,它不是O(N),但实际上它的常数因数很小,因为您只需要移动N / B指针而不是N个元素即可。
康拉德·鲁道夫2014年

22

想象它是向量的向量。只有它们不是标准std::vector的。

外部向量包含指向内部向量的指针。当通过重新分配更改其容量时,与其将所有空白空间分配到末尾,不如std::vector将空白空间在向量的开始和结尾处均等地分割。这允许push_front并且push_back在此向量上都发生在摊销的O(1)时间。

内部向量的行为需要更改,具体取决于它位于的前面还是后面deque。在背面,它可以作为标准std::vector,在最后生长,并push_back在O(1)时间出现。在最前面,它需要做相反的事情,在每个方面开始发展push_front。实际上,这可以通过添加指向前元素的指针以及尺寸的增长方向来轻松实现。通过这种简单的修改push_front也可以达到O(1)时间。

访问任何元素都需要偏移并划分为O(1)中出现的适当的外部向量索引,并索引为也是O(1)的内部向量。假设内部向量的大小都是固定的,除了的开头或结尾处的向量之外deque


1
您可以将内部向量描述为具有固定的容量
Caleth,

18

deque =双头队列

可以向任一方向生长的容器。

双端队列通常vectorof的形式实现vectors(向量列表不能提供恒定时间的随机访问)。尽管次要向量的大小取决于实现,但一种常见的算法是使用恒定大小(以字节为单位)。


6
内部不完全是矢量。内部结构在开始和结束时可以分配但未使用的容量
Mooing Duck 2011年

@MooingDuck:它是真正定义的实现,它可以是数组的数组或向量的向量,或者可以提供标准规定的行为和复杂性的任何东西。
Alok Save

1
@Als:我不认为array有任何vector东西可以保证摊销O(1)push_front。至少两个结构的内部必须能够具有O(1)push_front,而a array和a vector都不能保证。
Mooing Duck 2011年

4
@MooingDuck如果第一个块是自上而下而不是自下而上的,则很容易满足该要求。显然,标准vector并不能做到这一点,但这是一个足够简单的修改。
Mark Ransom 2014年

3
@ Mooing Duck,push_front和push_back都可以在具有单个矢量结构的摊销O(1)中轻松完成。这只是一个循环缓冲区的记账,仅此而已。假设您有一个容量为1000的规则向量,其中0到99处有100个元素。现在,当push_Front发生时,您只需在末端即位置999处进行推,然后在位置998处进行推,直到两端相遇为止。然后,您就像使用普通向量一样进行重新分配(以指数增长的方式保证摊销固定时间)。因此,有效地您只需要一个指向first el的附加指针即可。
plamenko '16

14

(这是我在另一个线程中给出的答案。从本质上讲,我认为即使使用一个单一的相当幼稚的实现也vector符合“恒定的未摊销push_ {front,back}”的要求。您可能会感到惊讶,并认为这是不可能的,但是我在标准中发现了其他令人惊讶的方式定义上下文的相关引号,请多多包涵;如果我在此答案中犯了一个错误,那么找出哪些内容将非常有帮助我说得对,我的逻辑出现了问题。)

在这个答案中,我并不是要寻找一个好的实现,而只是试图帮助我们解释C ++标准中的复杂性要求。我引用的是N3242,根据Wikipedia 所说,这是最新的免费提供的C ++ 11标准化文档。(它的组织形式似乎与最终标准不同,因此,我不会引用确切的页码。当然,这些规则在最终标准中可能已更改,但我认为这没有发生。)

一个deque<T>能正确使用来实现vector<T*>。将所有元素复制到堆上,并将指针存储在向量中。(有关向量的更多信息,请稍后)。

为什么要T*代替T?因为该标准要求

“在双端队列的任一端插入将使该双端队列的所有迭代器无效,但对引用该双端队列的元素的有效性没有影响。

(我的重点)。将T*有助于满足这一点。它还可以帮助我们满足以下要求:

“总是在双端队列的开头或结尾插入单个元素,这会导致对T的构造函数的单个调用。”

现在(有争议的)。为什么要使用一个vector来存储T*?它使我们可以随机访问,这是一个良好的开始。让我们暂时忘记向量的复杂性,并仔细建立:

该标准讨论“对所包含对象的操作数”。对于deque::push_front这显然是1,因为只有一个T对象被构造和现有的零T对象被读取或以任何方式进行扫描。这个数字1显然是一个常数,并且与当前双端队列中的对象数无关。这使我们可以说:

“对于我们来说deque::push_front,对所包含对象(Ts)的操作数量是固定的,并且与双端队列中已有的对象数量无关。”

当然,对进行的操作次数T*并不会那么好。当vector<T*>增长太大时,将对其进行重新分配,T*并将复制许多。因此,可以,对的操作数量T*会有很大的不同,但是对的操作数量T不会受到影响。

为什么我们要关注计数操作T和计数操作之间的区别T*?这是因为标准说:

本节中的所有复杂性要求仅根据对所包含对象的操作数来说明。

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

对于向量在双端队列中的表现,我还没有说太多。也许我们会将其解释为循环缓冲区(向量始终占据其最大值capacity(),然后在向量已满时将所有内容重新分配到更大的缓冲区中。细节无关紧要。

在最后几段中,我们分析deque::push_front了双端队列中已存在的对象数与push_front对包含的T对象执行的操作数之间的关系。我们发现它们彼此独立。由于该标准要求复杂性是基于on-on-operation的T,因此我们可以说这具有恒定的复杂性。

是的,T上的复杂性*被摊销(由于vector),但是我们只对T上的复杂性感兴趣,并且它是不变的(未摊销)。

在此实现中,vector :: push_back或vector :: push_front的复杂性无关紧要;这些考虑涉及操作T*,因此是不相关的。如果该标准是指复杂性的“常规”理论概念,那么它们就不会明确地将自己局限于“对所包含对象的操作次数”。我是不是在解释那句话?


8
好像很骗我!当您指定操作的复杂性时,您不仅要在数据的某些部分上执行操作:您希望对正在调用的操作的预期运行时间有所了解,而不管操作的是什么。如果我遵循您对T进行运算的逻辑,那意味着您可以在每次执行一次运算时检查每个T *的值是否为质数,并且由于您不碰Ts,因此仍然遵守标准。您能指定报价的来源吗?
Zonko

2
我认为标准作者知道他们不能使用传统的复杂性理论,因为我们没有一个完全指定的系统,例如,我们知道内存分配的复杂性。假装list无论列表的当前大小如何都可以为a的新成员分配内存是不现实的;如果列表太大,分配将会很慢或失败。因此,据我所知,委员会决定仅指定可以客观计数和衡量的运营。(PS:我对此有一种说法。)
Aaron McDaid

我很确定,这O(n)意味着运算的数量与元素的数量渐近地成比例。IE,元操作很重要。否则,将查询限制为不会有任何意义O(1)。因此,链接列表不符合条件。
Mooing Duck 2011年

8
这是一个非常有趣的解释,但是通过这种逻辑a list也可以作为vector指针的a实现(插入到中间将导致单个副本构造函数的调用,而不管列表大小如何,并且O(N)指针的改组可以忽略,因为它们不是T上的操作)。
Mankarse 2012年

1
这是一个很好的语言咨询工具(尽管我不会试图怀疑它是否正确或标准中是否有一些细微之处禁止该实现)。但这在实践中不是有用的信息,因为(1)常见的实现deque方式不以这种方式实现,并且(2)当计算算法复杂度对编写高效的程序没有帮助时,就以这种方式“作弊”(即使标准允许) 。
Kyle Strand

13

从概述,你能想到dequedouble-ended queue

双端队列概述

中的数据deque通过固定大小向量的块存储,这些块为

map(也是向量的一部分,但其大小可能会改变)指针

双端内部结构

的主要零件代码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. iterator(__deque_iterator

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

pointer1指向开始的指针chunk 2,当运算符--pointer将指向结束的指针chunk 1,从而指向pointer2

在此处输入图片说明

下面我将给出的主要功能__deque_iterator

首先,跳到任何块:

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,现在push_back 3个元素(0、1、2)为i_deque

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

它的内部结构如下:

在此处输入图片说明

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

push_back(3)

在此处输入图片说明

如果我们push_front,它将在上一个之前分配新的块start

在此处输入图片说明

请注意,当push_back元素放入双端队列时,如果所有的图和块都已填充,将导致分配新的图并调整块。但是上面的代码可能足以让您理解deque


您提到:“请注意,当push_back元素放入双端队列时,如果所有图和块均已填充,则将导致分配新图并调整块”。我想知道为什么C ++标准在N4713中说“ [26.3.8.4.3]在双端队列的开始或末尾插入单个元素总是需要固定的时间”。分配大量数据要花费的时间超过固定时间。没有?
HCSF

7

我正在阅读Adam Drozdek的“ C ++中的数据结构和算法”,发现这很有用。HTH。

STL双端队列的一个非常有趣的方面是其实现。STL双端队列未实现为链接列表,而是实现为指向数据块或数组的指针的数组。块的数量根据存储需求动态变化,并且指针数组的大小也相应变化。

您会注意到中间的是指向数据的指针数组(右侧的块),而且您还会注意到中间的数组是动态变化的。

一个图像值一千个字。

在此处输入图片说明


1
感谢您推荐一本书。我读了deque一部分,这很好。
里克(Rick)

@Rick很高兴听到这个消息。我记得在某个时候深入研究了双端队列,因为我不明白如何在O(1)中进行随机访问([]运算符)。还证明(push / pop)_(back / front)已摊销O(1)复杂度是一个有趣的“啊哈时刻”。
Keloo,

6

尽管该标准不要求任何特定的实现(仅是恒定时间的随机访问),但双端队列通常是作为连续内存“页面”的集合来实现的。根据需要分配新页面,但是您仍然可以随机访问。与不同std::vector,您不会保证数据是连续存储的,但是与向量不同,中间的插入需要大量重定位。


4
或中间的删除需要大量重新安置
Mark Hendrickson

如果insert需要大量搬迁,此处的实验4如何显示和之间的惊人差异?vector::insert()deque::insert()
布拉

1
@布拉:也许是由于细节沟通不畅?插入双端队列的复杂度是“插入元素的数量与到双端队列的开始和结束的距离中的较小者成线性关系”。要感觉到这笔费用,您需要在当前中间插入;那是您的基准测试在做什么?
Kerrek SB

@KerrekSB:以上Konrad答案中引用了具有基准的文章。实际上,我没有注意到下面文章的评论部分。在线程中“但双端队列有线性插入时间吗?” 作者确实提到他在所有测试中都使用位置100处的插入,这使得结果更容易理解。
布拉
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.