STL 中的双端队列到底是啥?

Posted

技术标签:

【中文标题】STL 中的双端队列到底是啥?【英文标题】:What really is a deque in STL?STL 中的双端队列到底是什么? 【发布时间】:2021-10-12 17:20:24 【问题描述】:

我正在查看 STL 容器并试图弄清楚它们到底是什么(即使用的数据结构),deque 阻止了我:起初我以为它是一个双链表,这将允许在恒定时间内从两端插入和删除,但我对 the promise made 的操作符 [] 感到困扰,要在恒定时间内完成。在链表中,任意访问应该是O(n)吧?

如果它是一个动态数组,它怎么能add elements 在恒定时间内呢?应该提到的是,可能会发生重新分配,并且 O(1) 是摊销成本,like for a vector。

所以我想知道这个结构是什么,它允许在恒定时间内进行任意访问,同时永远不需要移动到一个新的更大的地方。

【问题讨论】:

STL deque accessing by index is O(1)?的可能重复 @Graham “dequeue”是“deque”的另一个常用名称。我仍然批准了编辑,因为“deque”通常是规范名称。 @Konrad 谢谢。这个问题专门针对 C++ STL 双端队列,它使用较短的拼写。 deque 代表 双端队列,尽管显然 O(1) 访问中间元素的严格要求是 C++ 特有的 【参考方案1】:

双端队列在某种程度上是递归定义的:在内部,它维护一个由固定大小的组成的双端队列。每个chunk都是一个vector,chunks的队列(下图中的“map”)本身也是一个vector。

在CodeProject 上对性能特征及其与vector 的比较进行了很好的分析。

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

【讨论】:

这就是我了解到的deque的定义,但是这样就不能保证恒定的时间访问,所以肯定是少了什么。 @stefaanv, @Konrad:我见过的 C++ 实现使用了指向固定大小数组的指针数组。这实际上意味着 push_front 和 push_back 并不是真正的常数时间,但有了智能增长因素,你仍然会得到摊销常数时间,所以 O(1) 并没有那么错误,实际上它比向量快,因为你正在交换单个指针而不是整个对象(并且指针比对象少)。 仍然可以进行恒定时间访问。只是,如果你需要在前面分配一个新块,在主向量上推回一个新指针,并移位所有指针。 如果地图(队列本身)是一个双端列表,我看不出它如何允许 O(1) 随机访问。它可能被实现为一个循环缓冲区,这将允许循环缓冲区调整大小更有效:只复制指针而不是队列中的所有元素。看起来这仍然是一个小好处。 @JeremyWest 为什么不呢?索引访问到第 i/B 个块中的第 i%B 个元素(B = 块大小),这显然是 O(1)。您可以在摊销 O(1) 中添加新块,因此添加元素最后摊销 O(1)。除非需要添加新块,否则在开头添加新元素是 O(1)。在开头添加一个新块不是 O(1),没错,它是 O(N),但实际上它有一个非常小的常数因子,因为您只需要移动 N/B 指针而不是 N 个元素。【参考方案2】:

从概述中,您可以将deque 视为double-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的核心代码,主要大概三个部分:

    迭代器

    如何构造deque

1。迭代器(__deque_iterator)

迭代器的主要问题是,当++,--迭代器时,它可能会跳转到其他块(如果它指向块的边缘)。比如有三个数据块:chunk 1,chunk 2,chunk 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*获取chunk中的数据

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,它会调用allocate new chunk:

push_back(3)

如果我们push_front,它会在前一个start之前分配新的chunk

注意push_back元素进入deque时,如果map和chunk都被填满,会导致分配新的map,并调整chunk。不过上面的代码可能足以让你理解deque

【讨论】:

你提到,“注意当push_back元素进入deque时,如果所有的maps和chunk都被填满,会导致分配新的map,并调整chunk”。我想知道为什么 C++ 标准在 N4713 中说“[26.3.8.4.3] 在双端队列的开头或结尾插入单个元素总是需要恒定的时间”。分配大量数据需要的不仅仅是固定时间。没有?【参考方案3】:

把它想象成一个向量的向量。只是它们不是标准的std::vectors。

外部向量包含指向内部向量的指针。当它的容量通过重新分配改变时,而不是像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】:

(这是我在another thread 中给出的答案。本质上,我认为即使是相当幼稚的实现,使用单个vector,也符合“常量非摊销push_front,back”的要求"。您可能会感到惊讶,并认为这是不可能的,但我在标准中发现了其他相关引用,这些引用以令人惊讶的方式定义了上下文。请多多包涵;如果我在这个答案中犯了错误,它会非常有助于确定我说的正确的事情以及我的逻辑在哪里发生了故障。)

在这个答案中,我不是想确定一个好的实现,我只是想帮助我们解释 C++ 标准中的复杂性要求。我引用了N3242,根据Wikipedia,这是最新的可免费获得的C++11 标准化文档。 (它的组织方式似乎与最终标准不同,因此我不会引用确切的页码。当然,这些规则可能在最终标准中发生了变化,但我认为没有发生这种情况。)

使用vector&lt;T*&gt; 可以正确实现deque&lt;T&gt;。所有元素都复制到堆上,指针存储在向量中。 (稍后会详细介绍矢量)。

为什么是T* 而不是T?因为标准要求

"在双端队列的任一端插入都会使所有迭代器失效 到双端队列,但对引用的有效性没有影响 双端队列的元素。"

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

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

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

该标准谈到“对所包含对象的操作数”。对于deque::push_front,这显然是1,因为恰好构造了一个T 对象,并且以任何方式读取或扫描了零个现有T 对象。这个数字 1 显然是一个常数,并且与当前在双端队列中的对象数量无关。这让我们可以说:

'对于我们的deque::push_front,对包含的对象(Ts)的操作数量是固定的,并且与双端队列中已经存在的对象数量无关。'

当然,T*上的操作次数不会那么乖。当vector&lt;T*&gt; 变得太大时,它将被重新分配并且许多T*s 将被复制。所以是的,T* 上的操作数会有很大差异,但T 上的操作数不会受到影响。

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

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

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

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

在最后几段中,我们分析了deque::push_front 以及deque 中已经存在的对象数量与push_front 对包含的T-objects 执行的操作数量之间的关系。我们发现它们是相互独立的。 由于标准规定复杂性是在操作T 方面,所以我们可以说它具有恒定的复杂性。

是的,Operations-On-T*-Complexity 已摊销(由于 vector),但我们只对 Operations-On-T-Complexity 感兴趣,这是不变的(非摊销)。

vector::push_back 或 vector::push_front 的复杂性在这个实现中是无关紧要的;这些考虑涉及对T* 的操作,因此无关紧要。如果该标准指的是复杂性的“传统”理论概念,那么他们就不会明确地将自己限制为“对所包含对象的操作数量”。我是不是过度解读了那句话?

【讨论】:

这对我来说似乎很像作弊!当您指定操作的复杂性时,您不会只对数据的某些部分执行此操作:您希望了解您正在调用的操作的预期运行时间,而不管它操作的是什么。如果我遵循您关于 T 上的操作的逻辑,这意味着您可以在每次执行操作时检查每个 T* 的值是否是质数,并且仍然遵守标准,因为您不触摸 Ts。你能指定你的报价来自哪里吗? 我认为标准编写者知道他们不能使用传统的复杂性理论,因为我们没有一个完全指定的系统来了解例如内存分配的复杂性。不管列表的当前大小如何,假装可以为list 的新成员分配内存是不现实的;如果列表太大,分配会很慢或者会失败。因此,据我所知,委员会决定只指定可以客观计数和衡量的操作。 (PS:我对此有 another 理论以寻求另一个答案。) 这是一个非常有趣的解释,但是按照这种逻辑,list 也可以实现为指针的vector(插入到中间将导致 single i> 复制构造函数调用,无论列表大小如何,并且可以忽略指针的O(N) 洗牌,因为它们不是 T 上的操作)。 这是一个很好的语言律师(虽然我不会试图去怀疑它是否真的正确,或者标准中是否有一些微妙的地方禁止这种实现)。但在实践中它不是有用的信息,因为 (1) 常见的实现不会以这种方式实现 deque 并且 (2) 当计算算法复杂度无助于编写高效的程序。 @Kyle Strand,常见的实现将指向单个 T 的指针替换为指向本质上固定的 T 数组的指针(加上与指针或数组一起保存的少量簿记数据)。它们仍然具有相同的渐近特征,只是常数通常更有利。【参考方案5】:

deque = 双端队列

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

Deque通常实现为vectorsvector(向量列表不能提供恒定时间随机访问)。虽然辅助向量的大小取决于实现,但常见的算法是使用以字节为单位的常量大小。

【讨论】:

内部不是 相当 向量。内部结构可以在开始以及结束分配但未使用的容量 @MooingDuck:它是真正定义的实现。它可以是数组数组或向量向量或任何可以提供标准规定的行为和复杂性的东西。 @Als:我不认为任何事情的array 或任何事情的vector 可以承诺摊销O(1) push_front。至少两个结构的内部,必须能够有一个O(1) push_front,arrayvector 都不能保证。 @MooingDuck 如果第一个块自上而下而不是自下而上增长,则很容易满足要求。显然,标准的vector 不会这样做,但它的修改足够简单。 @Mooing Duck,push_front 和 push_back 都可以通过单个向量结构在摊销 O(1) 中轻松完成。这只是循环缓冲区的更多簿记,仅此而已。假设您有一个容量为 1000 的常规向量,其中在位置 0 到 99 处有 100 个元素。现在,当 push_Front 发生时,您只需在最后推动,即在位置 999,然后在 998 等,直到两端相遇。然后你重新分配(以指数增长来保证 amortizet 常数时间),就像你对普通向量所做的那样。如此有效,您只需要一个指向第一个 el 的额外指针。【参考方案6】:

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

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

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

一张图片胜过一千个字。

【讨论】:

感谢您推荐一本书。我读了deque 部分,非常好。 @Rick 很高兴听到这个消息。我记得在某个时候深入研究了双端队列,因为我无法理解如何在 O(1) 中进行随机访问([] 运算符)。同样证明 (push/pop)_(back/front) 已经摊销了 O(1) 复杂度是一个有趣的“啊哈时刻”。【参考方案7】:

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

【讨论】:

或中间删除需要大量迁移 如果 insert 需要大量重定位,实验 4 here 如何显示 惊人 vector::insert()deque::insert() 之间的差异? @Bula:可能是因为细节沟通不畅?双端队列插入的复杂性是“插入的元素数量加上到双端队列开头和结尾的距离中的较小者是线性的”。感受这个成本,需要在当前中间插入;这就是你的基准所做的吗? @KerrekSB:上面的 Konrad 回答中引用了带有基准的文章。其实我没有注意到下面文章的评论部分。在线程“但双端队列有线性插入时间?”作者确实提到他在所有测试中都使用了位置 100 的插入,这使得结果更容易理解。【参考方案8】:

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

使用循环缓冲区,这样我们就可以通过添加/删除具有 O(1) 复杂度的固定大小的数组来扩大/缩小两端 使用固定大小的数组,以便轻松计算索引,因此通过索引访问两个指针取消引用 - 也是 O(1)

【讨论】:

以上是关于STL 中的双端队列到底是啥?的主要内容,如果未能解决你的问题,请参考以下文章

java中的双端队列及使用

单调队列——从入门到入门

Python实现双端队列

检查数组是不是在数组的双端队列中? Python

从零开始写STL-容器-双端队列

Java 数据结构及算法实战系列 014:Java队列08——数组实现的双端队列ArrayDeque