【问题标题】:What data structure, exactly, are deques in C++?C++ 中的双端队列到底是什么数据结构?
【发布时间】:2012-01-27 11:33:30
【问题描述】:

是否存在 C++ STL 中的双端队列应该实现的特定数据结构,或者双端队列只是一个可从前后增长的数组的模糊概念,无论实现选择如何都可以实现?

我过去一直认为双端队列是 circular buffer,但我最近阅读了 C++ 参考资料 here,听起来双端队列是某种数组数组。它似乎不是一个普通的旧循环缓冲区。那么是gap buffer,还是some other variant of growable array,还是只是依赖于实现?

答案更新和总结:

似乎普遍的共识是双端队列是这样一种数据结构:

  • 插入或删除元素的时间在列表的开头或结尾应该是恒定的,并且在其他地方最多是线性的。如果我们将其解释为真正的常数时间而不是摊销的常数时间,就像有人 cmet 那样,这似乎具有挑战性。有些人认为我们不应该将其解释为非摊销常数时间。
  • “双端队列要求任何插入都应保持对成员元素的任何引用有效。迭代器无效是可以的,但成员本身必须保留在内存中的同一位置。”就像某人一样:这很容易,只需将成员复制到堆上的某个位置并将 T* 存储在引擎盖下的数据结构中。
  • “在双端队列的开头或结尾插入单个元素总是需要恒定的时间,并且会导致对 T 的构造函数的单个调用。”如果数据结构在后台存储 T*,也将实现 T 的单个构造函数。
  • 数据结构必须具有随机访问权限。

如果我们将第一个条件设为“非摊销常数时间”,似乎没有人知道如何获得第一个和第四个条件的组合。链表实现 1) 但不是 4),而典型的循环缓冲区实现 4) 但不是 1)。我想我有一个可以满足以下两个方面的实现。评论?

我们从其他人建议的实现开始:我们分配一个数组并从中间开始放置元素,前后都留有空间。在这个实现中,我们跟踪前后方向的中心有多少元素,将这些值称为 F 和 B。然后,让我们用一个两倍于原始大小的辅助数组来扩充这个数据结构数组(所以现在我们浪费了大量空间,但渐近复杂度没有变化)。我们还将从中间填充这个辅助数组,并给它类似的值 F' 和 B'。策略是这样的:每次我们在给定方向上向主数组添加一个元素时,如果 F > F' 或 B > B'(取决于方向),最多复制 两个 值从主阵列到辅助阵列,直到 F' 赶上 F(或 B' 赶上 B)。因此,插入操作涉及将 1 个元素放入主数组并将最多 2 个从主数组复制到辅助数组,但它仍然是 O(1)。当主阵列变满时,我们释放主阵列,使辅助阵列成为主阵列,并创建另一个 2 倍大的辅助阵列。这个新的辅助数组以 F' = B' = 0 开始,并且没有复制任何内容(因此,如果堆分配的复杂度为 O(1),则调整大小操作为 O(1))。由于辅助节点为添加到主节点的每个元素复制 2 个元素,并且主节点开始时最多半满,因此当主节点再次耗尽空间时,辅助节点不可能没有赶上主节点。删除同样只需要从主要元素中删除 1 个元素,从辅助元素中删除 0 或 1。因此,假设堆分配为 O(1),则此实现满足条件 1)。我们将数组设为 T* 并在插入时使用 new 以满足条件 2) 和 3)。最后,4) 实现了,因为我们使用的是数组结构,可以轻松实现 O(1) 访问。

【问题讨论】:

  • 你可能有兴趣阅读this question我刚才问过。
  • 它在任何意义上都不是一个循环缓冲区
  • 间隙缓冲区对于双端队列来说效率很低。
  • @TomalakGeret'kal,使用循环缓冲区没有任何问题。事实上,它们是一个相当合理的实现。在您查看 Pubby 答案的评论线程时,您会看到很多争论。似乎有些人确信必须是一个简单的链表,具有非随机访问。让更多的人看到这一点会很好。
  • 噢! N3242 中的以下句子是相关的,让我停下来思考(即我可能错了)。 “在双端队列的任何一端插入都会使双端队列的所有迭代器无效,但对双端队列元素引用的有效性没有影响。”和“在双端队列的开头或结尾插入单个元素总是需要恒定的时间并导致对 T 的构造函数的一次调用。”

标签: c++ data-structures


【解决方案1】:

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。迭代器(__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;
}

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];
}

如果想更深入了解deque也可以看这个问题https://stackoverflow.com/a/50959796/6329006

【讨论】:

    【解决方案2】:

    这是对用户重力对 2-array-solution 的评论挑战的回答。

    • 这里讨论了一些细节
    • 给出改进建议

    细节讨论: 用户“地心引力”已经给出了非常简洁的总结。 “重力”还要求我们评论平衡两个数组之间的元素数量以实现 O(1) 最坏情况(而不是平均情况)运行时的建议。好吧,如果两个数组都是环形缓冲区,则该解决方案可以有效地工作,而且在我看来,将双端队列分成两段就足够了,如建议的那样平衡。 我还认为,出于实际目的,标准 STL 实现至少足够好,但在实时要求和适当调整内存管理的情况下,可能会考虑使用这种平衡技术。 Eric Demaine 在较早的 Dr.Dobbs 文章中也给出了不同的实现,具有类似的最坏情况运行时。

    平衡两个缓冲区的负载需要在 0 或 3 个元素之间移动,具体取决于具体情况。例如,如果我们将前段保留在主阵列中,pushFront(x) 必须将最后 3 个元素从主环移动到辅助环,以保持所需的平衡。后面的 pushBack(x) 必须掌握负载差异,然后决定何时将一个元素从主阵列移动到辅助阵列。

    改进建议: 如果前后都存储在辅助环中,则工作和簿记工作就更少了。这可以通过将双端队列分成三个段 q1,q2,q3 来实现,按以下方式排列:前部 q1 在辅助环(双倍大小的环)中,并且可以从元素所在的任何偏移量开始顺时针依次排列。 q1 中的元素数量正好是存储在辅助环中的所有元素的一半。后部 q3 也在辅助环中,与辅助环中的 q1 部分正好相反,也按顺时针顺序依次排列。这个不变量必须在所有双端队列操作之间保持。只有中间部分 q2 位于主环中(顺时针方向)。

    现在,每个操作要么只移动一个元素,要么在其中任何一个为空时分配一个新的空环形缓冲区。例如,pushFront(x) 在辅助环中存储 q1 之前的 x。为了保持不变性,我们将最后一个元素从 q2 移动到后 q3 的前面。因此,q1 和 q3 在它们的前面都有一个额外的元素,因此彼此相对。 PopFront() 反过来工作,后面的操作也一样。主环(与中间部分 q2 相同)恰好在 q1 和 q3 相互接触并在辅助环内形成一个完整的后续元素圈时变为空。此外,当双端队列缩小时,恰好当 q2 在主环中形成适当的圆时,q1、q3 将变为空。

    【讨论】:

    • 请用正确的标记编辑问题。像一个一个
    • 1 你想要什么(样机或设计流程) 2,你尝试了什么(代码示例),你的问题
    【解决方案3】:

    它是特定于实现的。双端队列所需的只是在开始/结束处进行恒定时间的插入/删除,并且在其他地方最多是线性的。元素不需要是连续的。

    大多数实现都使用展开列表。固定大小的数组在堆上分配,指向这些数组的指针存储在属于双端队列的动态大小的数组中。

    【讨论】:

    • @BentFX 是的,这令人困惑。改写了。
    • 限制更加具体。特别是双端队列需要具有随机访问迭代器。开始/结束的插入和删除不需要高效,它们需要是常数时间,在其他位置最多是线性时间。
    • @pmr:是的。我现在认为这是一个错误。随机访问迭代器的要求与插入和删除到任一端的绝对 O(1) 时间的要求不兼容。甚至vector 也无法完成这一壮举。也许一些杰出的计算机科学家知道如何创建一个可以满足这两个要求的数据结构。但是我不知道这样的数据结构。
    • @Omnifarious:完全有可能在任一端进行恒定时间插入/删除,同时保持随机访问。只需在开头和结尾留出额外的空间。请参阅我刚才提出的问题(上面的链接)。我们对此进行了完整的讨论,发帖人删除了,因为他认为这是不可能的。这是完全可能的——它只是浪费了更多的空间(这并不重要)。
    • @Mehrdad:该标准旨在适用于所有环境。这就是为什么有这么多未指定或未定义的原因。绝对常数时间和摊销常数时间之间是有区别的。该标准在某些地方做出了这种区分,最值得注意的是(AFAIK)::std::vector 明确指定push_back 是摊销的常数时间。所以如果这就是他们的意思,那就是他们应该说的。关键是语言要准确明确,而不是对“大多数”环境有意义。
    【解决方案4】:

    我对双端队列的理解

    它从堆中分配“n”个空的连续对象作为第一个子数组。 其中的对象在插入时由头指针添加一次。

    当头指针到达数组的末尾时,它 分配/链接一个新的非连续子数组并在那里添加对象。

    它们在提取时被尾指针删除一次。 当尾指针完成一个对象子数组时,它移动 到下一个链接的子数组,并释放旧的。

    头尾之间的中间对象永远不会在内存中被双端队列移动。

    随机访问首先确定哪个子数组有 对象,然后从它在子数组中的相对偏移量中访问它。

    【讨论】:

      【解决方案5】:

      双端队列通常实现为T 数组的动态数组。

       (a) (b) (c) (d)
       +-+ +-+ +-+ +-+
       | | | | | | | |
       +-+ +-+ +-+ +-+
        ^   ^   ^   ^
        |   |   |   |
      +---+---+---+---+
      | 1 | 8 | 8 | 3 | (reference)
      +---+---+---+---+
      

      数组(a)、(b)、(c)、(d)一般容量固定,内部数组(b)、(c)必然是满的。 (a) 和 (d) 不完整,这使得在两端插入 O(1)。

      假设我们做了很多push_front,(a) 将被填满,当它已满并执行插入时,我们首先需要分配一个新数组,然后增长(引用)向量并将指针推送到前面的新数组。

      这个实现简单地提供了:

      • 随机访问
      • 在两端推送时保留引用
      • 与min(distance(begin, it), distance(it, end)) 成比例的中间插入(标准比您要求的要严格一些)

      但是它不符合摊销 O(1) 增长的要求。因为每当(参考)向量需要增长时,数组都有固定的容量,所以我们有 O(N/容量) 指针副本。因为指针被简单地复制,单个memcpy 调用是可能的,所以在实践中这主要是恒定的......但这不足以通过出色的颜色。

      不过,push_front 和 push_back 比 vector 更有效(除非您使用的 MSVC 实现速度非常慢,因为数组的容量非常小...)


      老实说,我知道没有可以同时满足两者的数据结构或数据结构组合:

      • 随机访问

      和

      • O(1) 插入两端

      我确实知道一些“接近”匹配:

      • 摊销的 O(1) 插入可以通过您在中间写入的动态数组完成,这与 deque 的“引用保留”语义不兼容
      • A B+ 树可以适应提供通过索引而不是键访问,时间接近常量,但访问和插入的复杂度为 O(log N)(常量很小),它需要使用中间层节点中的 Fenwick 树。
      • Finger Trees 也可以类似地进行调整,但实际上也是 O(log N)。

      【讨论】:

      • 但是固定长度的数组只允许在末尾进行恒定时间插入,所以您是否不需要某种标志来指示是否应该向后读取数组以进行索引,从而需要随机访问来迭代?当最前面的数组被填满时会发生什么?顶层数组如何为前面的指针分配空间?您是否不必颠倒前一个最前面数组中的元素顺序,从而破坏恒定时间插入?
      • @EMBLEM:关键字是摊销。摊销的恒定时间是指平均的恒定时间。我会让你read more on the topic。因此,当前面的数组已满并且您推到前面时,首先重新分配顶级数组(例如,长度加倍)并为您正在推的元素分配一个新的固定大小的数组。当然,考虑到内存分配具有很高的方差,“恒定时间”会稍微推动它。但我们的意思是关于元素数量的 O(1)。
      【解决方案6】:

      (将此答案设为社区维基。请坚持下去。)

      首先要做的事情:deque 要求任何对前面或后面的插入都应保持对成员元素的任何引用有效。迭代器失效是可以的,但成员本身必须留在内存中的同一位置。这很容易,只需将成员复制到堆上的某个位置并将T* 存储在引擎盖下的数据结构中。请参阅另一个 StackOverflow 问题“About deque<T>'s extra indirection”

      (vector 不保证保留迭代器或引用,而 list 保留两者)。

      因此,让我们将这种“间接”视为理所当然,然后看看问题的其余部分。有趣的是从列表的开头或结尾插入或删除的时间。起初,看起来deque 可以用vector 轻松实现,或许可以将其解释为circular buffer。

      但是双端队列必须满足“在一个队列的开头或结尾插入单个元素 双端队列总是花费恒定的时间并导致对 T 的构造函数的一次调用。”

      多亏了我们已经提到的间接性,很容易确保只有一个构造函数调用,但挑战在于保证恒定的时间。如果我们可以只使用常量 amortized 时间会很容易,这将允许简单的vector 实现,但它必须是常量(非摊销)时间。

      【讨论】:

      • 我已经改变了这个答案很多。它现在是一个社区 wiki,我不再声称拥有所有答案!我主要专注于澄清问题。
      • 需要恒定时间push 的草案版本也需要恒定时间operator[]。即使忽略引用无效的问题,我认为约束是一个具有挑战性的问题。
      • 如果operator[](和其他非随机操作)可以是线性的,那么标准的链表就是答案。我正在看的草稿中找不到相关的句子,所以我不知道operator[]是否摊销。 (如果不是恒定的,我会感到惊讶)
      • @Hurkyl,这是我在 N3242 中找到的最接近的“双端队列是一个序列容器,就像一个向量 (23.3.6),支持 随机访问迭代器” (我的重点)
      • 在我查看的版本中,有一个表格,带有注释“实现应为所有显示的容器类型提供这些操作......以便采用摊销的常数时间”。 (我读到的另一篇也说了同样的话,但没有“摊销”这个词)在我保存的版本中,这是第 23.1.1 小节中的“表 71:可选序列操作” - 序列 [lib.sequence.reqmts]在容器要求小节中。
      【解决方案7】:

      使用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 感兴趣- 复杂性,这是恒定的(非摊销)。

      结语:vector::push_back 或 vector::push_front 的复杂性在此实现中无关紧要;这些考虑涉及对T* 的操作,因此无关紧要。

      【讨论】:

      • 我不同意。 push_front 的复杂性是相关的,因为标准根据 N 来精确计算复杂性,其中 N 表示序列中元素的数量。添加间接级别并不能解决执行push_front时需要复制N个指针的问题...
      • @MatthieuM.,在我的回答中,您会看到 C++ 标准中的一句话,表明复制指针不是问题,也不计入复杂性。另外,我没有添加间接,其他人已经注意到双端队列是一个两级结构 - 请参阅这个公认的答案stackoverflow.com/a/6292437/146041 这似乎是一个复杂而微妙且有趣(!)的问题。
      • 啊...有趣!实际上,如果复杂性是根据复制构造函数的调用次数来定义的,那么在使用传统的指针数组实现(无论是使用块还是简单指针)最后推送元素时,都没有复杂性。不过,我觉得这有点作弊。尽管这意味着复杂度不会随 T 的复杂度而变化,但它隐藏了它随元素数量而变化的事实......
      猜你喜欢
      • 2021-10-12
      • 2020-10-15
      • 1970-01-01
      • 2015-04-26
      • 2016-09-25
      • 1970-01-01
      • 1970-01-01
      • 2010-10-29
      相关资源
      最近更新 更多