STLの両端キューとは実際には何ですか?


192

私はSTLコンテナーを見て、それらが実際に何であるか(つまり、使用されるデータ構造)を理解しようとしていましたが、両端キューによって停止しました。一定の時間ですが、演算子[]が一定の時間で行われるという約束に困っています。リンクされたリストでは、任意のアクセスはO(n)であるべきですよね?

そして、それが動的配列である場合、どのようにして一定の時間で要素追加できますか?再割り当てが発生する可能性があり、O(1)はvectorの場合と同様に償却コストであることに注意してください。

したがって、一定の時間で任意のアクセスを可能にし、同時に新しい大きな場所に移動する必要がないこの構造は何なのかと思います。



1
@Grahamの「デキュー」は、「デキュー」のもう1つの一般的な名前です。「deque」は通常正規名であるため、私はまだ編集を承認しています。
Konrad Rudolph

@Konradありがとう。質問は特に、より短いスペルを使用するC ++ STL dequeに関するものでした。
Graham Borland

2
deque両端のキューを表しますが、中間要素へのO(1)アクセスの厳格な要件はC ++に特有です
Matthieu M.

回答:


181

両端キューはいくぶん再帰的に定義されます。内部的には、両端が固定サイズのチャンクの両端キューを保持します。各チャンクはベクトルであり、チャンク自体のキュー(下図の「マップ」)もベクトルです。

dequeのメモリレイアウトの概略図

パフォーマンス特性の優れた分析と、CodeProjectvectorでのそれと比較する方法があります。

GCC標準ライブラリの実装では、内部的にを使用しT**てマップを表します。各データブロックは、T*固定サイズで割り当てられたです__deque_buf_size(これはに依存しますsizeof(T))。


27
これは、私が学んだように、両端キューの定義ですが、この方法では、一定時間のアクセスを保証できないため、不足しているものがあるはずです。
stefaanv

14
@ stefaanv、@ Konrad:私が見たC ++実装では、固定サイズの配列へのポインターの配列を使用しました。これは事実上、push_frontとpush_backは実際には一定の時間ではないことを意味しますが、スマートな成長係数を使用しても一定の時間で償却されるため、O(1)はそれほどエラーではなく、実際にはスワップしているためベクトルより高速です。オブジェクト全体ではなく単一のポインター(オブジェクトよりも少ないポインター)。
Matthieu M.

5
一定時間のアクセスは引き続き可能です。ちょうど、前に新しいブロックを割り当てる必要がある場合は、新しいポインターをメインベクトルに押し戻し、すべてのポインターをシフトします。
Xeo

4
マップ(キュー自体)が両端のリストである場合、どのようにO(1)ランダムアクセスを許可できるかわかりません。それは循環バッファとして実装され、循環バッファのサイズ変更をより効率的にすることができます。キュー内のすべての要素ではなく、ポインタのみをコピーします。それでもそれは小さな利益のようです。
2013

14
@JeremyWestどうして?インデックス付きアクセスは、i / B番目のブロック(B =ブロックサイズ)のi%B番目の要素にアクセスします。これは明らかにO(1)です。償却済みO(1)に新しいブロックを追加できるため、要素の追加は最後に償却済みO(1)になります。新しいブロックを追加する必要がない限り、最初に新しい要素を追加することはO(1)です。最初に新しいブロックを追加することはO(1)ではなく、真、それはO(N)ですが、実際には、N要素ではなくN / Bポインターを移動するだけでよいので、非常に小さな定数係数があります。
Konrad Rudolph

22

それをベクトルのベクトルとして想像してみてください。それらだけが標準ではありませんstd::vector

外側のベクトルには、内側のベクトルへのポインターが含まれています。すべての空きスペースを最後に割り当てるのではなく、再割り当てによって容量が変更されると、空きスペースstd::vectorがベクターの最初と最後で等しい部分に分割されます。これによりpush_frontpush_backこのベクトルの両方で、償却済みO(1)時間で発生することができます。

内部ベクトルの動作は、それがの前にあるか後ろにあるかに応じて変更する必要がありdequeます。後ろではstd::vector、最後に大きくなる標準として動作し、push_backO(1)時間で発生します。前部では、それとは反対のことをする必要がありますpush_front。実際には、これを前の要素へのポインタとサイズとともに成長の方向を追加することで簡単に実現できます。この単純な変更により、push_frontO(1)時間にもなります。

任意の要素にアクセスするには、O(1)で発生する適切な外部ベクトルインデックスへのオフセットと除算、およびO(1)でもある内部ベクトルへのインデックス付けが必要です。これは、の最初または最後のベクトルを除いて、内部ベクトルがすべて固定サイズであることを前提としていdequeます。


1
内部ベクトルは容量
Caleth

18

deque =両端キュー

どちらの方向にも成長できるコンテナ。

両端キューがされ、通常として実装vectorvectors(一定の時間にランダムアクセスを与えることができないベクトルのリスト)。二次ベクトルのサイズは実装に依存しますが、一般的なアルゴリズムはバイト単位の一定のサイズを使用することです。


6
内部的にはまったくベクトルではありません。内部構造には、最初と最後だけでなく、未使用の容量を割り当てることができます
Mooing Duck

@MooingDuck:これは実際に定義された実装です。配列の配列、ベクトルのベクトル、または標準で規定されている動作と複雑さを提供できるものであれば何でもかまいません。
Alok Save

1
@Als:私はarray何も考えていません。また、vector償却O(1)済みのpush_front を約束できるものはありません。少なくとも2つの構造の内部は、push_front を持つことができなければなりません。O(1)これは、an arrayもa vectorも保証できません。
Mooing Duck

4
@MooingDuckこの要件は、最初のチャンクがボトムアップではなくトップダウンになる場合に簡単に満たされます。明らかに標準vectorはそれをしませんが、それはそうするのに十分簡単な修正です。
Mark Ransom 2014年

3
@ Mooing Duck、push_frontとpush_backの両方は、単一のベクトル構造を持つ償却済みO(1)で簡単に実行できます。これは、循環バッファーの簿記に過ぎません。0から99の位置に100個の要素があり、容量1000の通常のベクトルがあると仮定します。今度は、push_Frontが発生したときに、端、つまり位置999、次に998などを押すだけで、両端が出会います。次に、通常のベクトルの場合と同じように、(指数の増加を使用して、一定の時間で償却を保証するように)再割り当てします。つまり、最初のelへのポインタを1つ追加するだけです。
plamenko 2016

14

(これは私が別のスレッドで与えた答えです。基本的に、単一のを使用したかなり単純な実装でさえvector、「一定の非償却push_ {front、back}」の要件に準拠していると主張しています。驚くかもしれません。 、これは不可能だと思いますが、驚くべき方法でコンテキストを定義する他の関連する引用を標準で見つけました。ご容赦ください。この回答で間違いを犯した場合、どのことを特定することは非常に役立ちます私は正しく言いました、そして私の論理がどこで壊れたか。)

この回答では、私は適切な実装を特定しようとしているのではなく、単にC ++標準の複雑さの要件を解釈する手助けをしているだけです。ウィキペディアによれば、無料で入手できる最新のC ++ 11標準化文書であるN3242から引用しています。(最終的な標準とは構成が異なっているように見えるため、正確なページ番号を引用しません。もちろん、これらのルールは最終的な標準で変更されている可能性がありますが、実際にはそうは思われません。)

Aは、deque<T>使用して正しく実装することができvector<T*>。すべての要素はヒープにコピーされ、ポインタはベクトルに格納されます。(ベクトルについては後で詳しく説明します)。

なぜT*代わりにT?標準はそれを要求するので

「両端キューの両端に挿入すると、両端キューへのすべてのイテレータが無効になりますが、両端キューの要素への参照の有効性には影響しません。

(私の強調)。T*はそれを満たすのに役立ちます。また、これは私たちがこれを満たすのにも役立ちます:

「両端キューの最初または最後に単一の要素を挿入すると、常に..... Tのコンストラクターが1回呼び出されます。」

さて、(物議を醸す)ビットについて。なぜ使うvector格納しますかT*?それは私たちにランダムアクセスを与えます、それは良いスタートです。少しの間、ベクトルの複雑さを忘れて、これを注意深く組み立てましょう。

標準は、「含まれるオブジェクトに対する操作の数」について話します。deque::push_frontこれは明らかに1です。これは、1つのTオブジェクトが構築Tされ、既存のオブジェクトの0が何らかの方法で読み取られるかスキャンされるためです。この数1は明らかに定数であり、現在両端キューにあるオブジェクトの数とは無関係です。これにより、次のことが言えます。

「私たちの場合deque::push_front、含まれているオブジェクト(Ts)に対する操作の数は固定されており、すでに両端キューにあるオブジェクトの数とは無関係です。」

もちろん、上の操作の数はT*それほどうまく行かないでしょう。がvector<T*>大きくなりすぎると再割り当てされ、多くT*のがコピーされます。そのため、はい、操作の数はT*大きく異なりますが、操作の数はT影響を受けません。

カウント操作Tとカウント操作の違いを気にするのはなぜT*ですか?それは標準が言うからです:

この節のすべての複雑さの要件は、含まれているオブジェクトに対する操作の数に関してのみ記述されています。

の場合、deque含まれるオブジェクトはTでなくです。T*つまり、aをコピー(または再割り当て)する操作は無視できますT*

ベクトルが両端キューでどのように動作するかについては、あまり触れていません。おそらく、それを循環バッファーとして解釈します(ベクターは常に最大値を使用しcapacity()、ベクターがいっぱいになるとすべてをより大きなバッファーに再割り当てします。詳細は重要ではありません。

最後の数段落deque::push_frontで、deque内のオブジェクトの数と、含まれている-objectsに対してTpush_front によって実行された操作の数との関係を分析しました。そして、それらは互いに独立していることがわかりました。規格では複雑さが操作オンの観点から規定されているため、Tこれは一定の複雑さを持っていると言えます。

はい、Operations-On-T * -Complexityは(によりvector)償却されますが、私たちはOperations-On-T-Complexityのみに関心があり、これは一定です(非償却)。

vector :: push_backまたはvector :: push_frontの複雑さは、この実装では無関係です。これらの考慮事項には操作が含まれるT*ため、関係ありません。標準が複雑さの「従来の」理論的概念に言及している場合、それらは「含まれるオブジェクトに対する操作の数」に明示的に制限されていなかっただろう。私はその文を解釈しすぎていますか?


8
浮気みたいです!操作の複雑さを指定するときは、データの一部に対してのみ行うのではなく、操作対象に関係なく、呼び出す操作の予想される実行時間を把握したい場合があります。Tでの操作に関するロジックに従うと、操作が実行されるたびに各T *の値が素数であるかどうかを確認できますが、Tに触れないため、標準を尊重できます。見積もりの​​出所を教えていただけますか?
Zonko

2
標準的なライターは、たとえばメモリ割り当ての複雑さなど、完全に特定されたシステムがないため、従来の複雑さの理論を使用できないことを知っていると思います。listリストの現在のサイズに関係なく、の新しいメンバーにメモリを割り当てることができるというふりをするのは現実的ではありません。リストが大きすぎると、割り当てが遅くなるか、失敗します。したがって、私が見る限り、委員会は客観的にカウントおよび測定できる操作のみを指定することを決定しました。(追記:別の答えについては、これについて別の理論があります。)
アーロン・マクデイド

O(n)操作の数が要素の数に漸近的に比例することを意味すると私はかなり確信しています。IE、メタ操作カウント。そうでなければ、検索をに制限しても意味がありませんO(1)。エルゴ、リンクリストは対象外です。
Mooing Duck

8
これは非常に興味深い解釈ですが、このロジックによってlista vectorもポインタのaとして実装できます(中央に挿入すると、リストのサイズに関係なく、単一のコピーコンストラクタが呼び出され、O(N)ポインタのシャッフルは無視できます。 Tオペレーションではありません)。
Mankarse 2012年

1
これはすばらしい言語弁護士です(ただし、それが実際に正しいかどうか、またはこの実装を禁止している標準に微妙な点があるかどうかを確認するつもりはありません)。しかし、(1)一般的な実装はdequeこの方法を実装しておらず、(2)アルゴリズムの複雑さの計算が効率的なプログラムの作成に役立たない場合、(標準で許可されていても)この方法で「不正行為」を行うため、実際には有用な情報ではありません。 。
カイルストランド

13

概要からは、あなたが考えることができるdequeAとしてdouble-ended queue

dequeの概要

dequeデータは、固定サイズのベクトルのチャンクによって保存されます。

pointered map(また、ベクターのチャンクであるが、その大きさが変化してもよいです)

deque内部構造

の主要部分のコードは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、主に3つの部分からなるコアコードを示します。

  1. イテレータ

  2. を構築する方法 deque

1.イテレータ(__deque_iterator

イテレータの主な問題は、++の場合-イテレータの場合、他のチャンクにスキップすることがあります(チャンクのエッジを指す場合)。例えば、3つのデータチャンクがあります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_deque20個のint型の要素を持っている0~19そのチャンクサイズ8であるが、今まで3つの要素(0、1、2)を一back 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_backelementがdequeになるときに、すべてのマップとチャンクが入力されている場合は、新しいマップが割り当てられ、チャンクが調整されますdeque。ただし、上記のコードで十分理解できる可能性があります。


「push_back要素を両端キューに入れるとき、すべてのマップとチャンクがいっぱいになると、新しいマップが割り当てられ、チャンクが調整されるので注意してください」と述べました。N4713でC ++標準が「[26.3.8.4.3]両端キューの先頭または末尾に単一の要素を挿入すると常に一定の時間がかかる」と言っているのはなぜでしょうか。データのチャックを割り当てるには、一定の時間以上の時間がかかります。番号?
HCSF

7

私はAdam Drozdekによる「C ++でのデータ構造とアルゴリズム」を読んでいて、これが役立つと思いました。HTH。

STL dequeの非常に興味深い側面は、その実装です。STLの両端キューは、リンクリストとしてではなく、データのブロックまたは配列へのポインターの配列として実装されます。ブロックの数はストレージのニーズに応じて動的に変化し、それに応じてポインターの配列のサイズも変化します。

中央にデータへのポインタの配列(右側のチャンク)があり、中央の配列が動的に変化していることに気付くでしょう。

画像は千の言葉の価値があります。

ここに画像の説明を入力してください


1
本をご紹介いただきありがとうございます。私はそのdeque部分を読みましたが、それは非常に良いことです。
リック

@リックはそれを聞いて幸せです。O(1)でランダムアクセス([]演算子)を使用する方法を理解できなかったため、ある時点で両端キューを掘り下げたのを覚えています。また、(プッシュ/ポップ)_(バック/フロント)がO(1)の複雑さを償却したことを証明することは、興味深い「あは瞬間」です。
Keloo

6

標準は特定の実装を義務付けていませんが(一定時間のランダムアクセスのみ)、デキューは通常、連続するメモリ「ページ」のコレクションとして実装されます。必要に応じて新しいページが割り当てられますが、ランダムアクセスは引き続き可能です。とは異なりstd::vector、データが連続して格納されることは保証されていませんが、ベクターと同様に、途中での挿入には多くの再配置が必要です。


4
または途中での削除には、多くの再配置が必要です
Mark Hendrickson

場合はinsert移転の多くを必要とする実験4んどのようにここでは示して驚異の違いvector::insert()とはdeque::insert()
Bula、2017年

1
@ブラ:おそらく詳細の誤解によるのでしょうか?Deque挿入の複雑さは、「挿入された要素の数に加えて、Dequeの最初と最後までの距離の短い方で直線的」です。このコストを感じるには、現在の中間に挿入する必要があります。それはあなたのベンチマークが何をしているのですか?
Kerrek SB、2017

@KerrekSB:ベンチマーク付きの記事は、上記のKonradの回答で参照されました。実際、私は以下の記事のコメントセクションに気づきませんでした。スレッドでは「しかし、両端キューには線形の挿入時間がありますか?」著者は、すべてのテストで100位の挿入を使用したため、結果が少しわかりやすくなると述べました。
Bula
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.