Что на самом деле является deque в STL?


194

Я смотрел на STL контейнеры и пытаясь понять , что они на самом деле (то есть структура данных , используемая), а Deque остановил меня: я сначала подумал , что это был двойной связанный список, который позволил бы вставку и удаление с обоих концов в постоянное время, но меня беспокоит обещание , данное оператором [], которое должно быть выполнено в постоянное время. В связанном списке произвольный доступ должен быть O (n), верно?

И если это динамический массив, как он может добавлять элементы в постоянное время? Следует отметить, что может произойти перераспределение, и что O (1) является амортизированной стоимостью, как для вектора .

Поэтому мне интересно, что это за структура, которая обеспечивает произвольный доступ в постоянное время, и в то же время никогда не нужно перемещать ее в новое более крупное место.



1
@Graham «dequeue» - еще одно распространенное название «deque». Я все еще одобрил редактирование, так как «deque» - обычно каноническое имя.
Конрад Рудольф

@ Конрад Спасибо. Вопрос был конкретно о C ++ STL deque, который использует более короткое написание.
Грэм Борланд

2
dequeобозначает двустороннюю очередь , хотя очевидно, что строгое требование O (1) доступа к средним элементам является специфическим для C ++
Matthieu M.

Ответы:


186

Deque несколько рекурсивно определен: внутренне он поддерживает двустороннюю очередь кусков фиксированного размера. Каждый чанк является вектором, и очередь («карта» на рисунке ниже) самих чанков также является вектором.

схема расположения памяти deque

Там отличный анализ характеристик производительности и , как он сравнивает с , vectorпо меньшей CodeProject .

Реализация стандартной библиотеки GCC внутренне использует T**для представления карты. Каждый блок данных имеет T*определенный фиксированный размер __deque_buf_size(который зависит от sizeof(T)).


28
Это определение deque в том виде, в котором я его узнал, но в этом случае он не может гарантировать постоянный доступ по времени, поэтому должно быть что-то упущенное.
stefaanv

14
@stefaanv, @Konrad: реализации C ++, которые я видел, использовали массив указателей на массивы фиксированного размера. Это фактически означает, что push_front и push_back на самом деле не являются постоянными временами, но с умными факторами роста вы все равно получаете амортизированные постоянные времена, поэтому O (1) не так ошибочно, и на практике это быстрее, чем vector, потому что вы меняете местами одиночные указатели, а не целые объекты (и меньше указателей, чем объекты).
Матье М.

5
Постоянный доступ все еще возможен. Просто, если вам нужно выделить новый блок спереди, отодвиньте новый указатель на главный вектор и сдвиньте все указатели.
Xeo

4
Если бы карта (сама очередь) была двусторонним списком, я не вижу, как она могла бы разрешить O (1) произвольный доступ. Он может быть реализован в виде циклического буфера, который позволит изменять размер циклического буфера более эффективно: копировать только указатели вместо всех элементов в очереди. Тем не менее, это небольшая выгода, кажется.
Wernight

15
@JeremyWest Почему бы и нет? Индексированный доступ идет к элементу i% B в блоке i / B (B = размер блока), это явно O (1). Вы можете добавить новый блок в амортизированном O (1), следовательно, добавление элементов в конце амортизируется O (1). Добавление нового элемента в начале - это O (1), если не требуется добавлять новый блок. Добавление нового блока в начале - это не O (1), правда, это O (N), но на самом деле он имеет очень маленький постоянный коэффициент, так как вам нужно только перемещать указатели N / B, а не N элементов.
Конрад Рудольф

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
Вы можете описать внутренние векторы как имеющие фиксированную емкость
Калет

18

deque = двусторонняя очередь

Контейнер, который может расти в любом направлении.

Deque обычно реализуется как vectorо vectors(список векторов не может дать произвольный доступ с постоянным временем). В то время как размер вторичных векторов зависит от реализации, общий алгоритм должен использовать постоянный размер в байтах.


6
Это не совсем векторы внутри. Внутренние структуры могут иметь выделенную, но неиспользованную емкость в начале и в конце
Mooing Duck

@MooingDuck: Это действительно определенная реализация. Это может быть массив массивов или векторов векторов или что угодно, что может обеспечить поведение и сложность, предписанные стандартом.
Alok Save

1
@ Алс: Я не думаю arrayни о чем или vectorо чем-то, что может обещать амортизированный O(1)push_front. Внутренняя часть двух структур, по крайней мере, должна иметь O(1)push_front, что ни гарантия, arrayни vectorгарантия.
Mooing Duck

4
@MooingDuck это требование легко выполняется, если первый кусок растет сверху вниз, а не снизу вверх. Очевидно, что стандарт vectorэтого не делает, но это достаточно простая модификация, чтобы сделать это.
Марк Рэнсом

3
@ Mooing Duck, push_front и push_back могут быть легко выполнены в амортизированном O (1) с единой векторной структурой. Это просто немного больше бухучета циклического буфера, ничего больше. Предположим, у вас есть постоянный вектор вместимостью 1000 с 100 элементами в позициях от 0 до 99. Теперь, когда происходит push_Front, вы просто толкаете в конце, то есть в позиции 999, затем 998 и т. Д., Пока два конца не встретятся. Затем вы перераспределяете (с экспоненциальным ростом, чтобы гарантировать постоянное время амортизации) так же, как и с обычным вектором. Таким образом, вам просто нужен еще один указатель на первый эл.
Пламенко

14

(Это ответ, который я дал в другой ветке . По сути, я утверждаю, что даже довольно наивные реализации, использующие одну vector, соответствуют требованиям «постоянного не амортизированного push_ {front, back}». Вы можете быть удивлены и думаю, что это невозможно, но я нашел в стандарте другие релевантные цитаты, которые удивительным образом определяют контекст. Пожалуйста, потерпите меня, если я допустил ошибку в этом ответе, было бы очень полезно определить, какие вещи Я правильно сказал, и где моя логика сломалась.)

В этом ответе я не пытаюсь определить хорошую реализацию, я просто пытаюсь помочь нам интерпретировать требования сложности в стандарте C ++. Я цитирую N3242 , который, согласно Википедии , является последним свободно доступным документом по стандартизации C ++ 11. (Похоже, что он организован не так, как в окончательном стандарте, и поэтому я не буду указывать точные номера страниц. Конечно, эти правила могли измениться в окончательном стандарте, но я не думаю, что это произошло.)

А deque<T>может быть правильно реализован с помощью vector<T*>. Все элементы копируются в кучу, а указатели хранятся в векторе. (Подробнее о векторе позже).

Почему T*вместо T? Поскольку стандарт требует, чтобы

«Вставка на любом конце deque делает недействительными все итераторы для deque, но не влияет на достоверность ссылок на элементы deque ».

(мой акцент). Это T*помогает удовлетворить это. Это также помогает нам удовлетворить это:

«Вставка одного элемента в начале или в конце deque всегда ..... вызывает один вызов конструктора 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*.

Я не сказал много о том, как вектор будет вести себя в deque. Возможно, мы интерпретируем его как кольцевой буфер (вектор всегда занимает максимум capacity(), а затем перераспределяем все в больший буфер, когда вектор заполнен. Детали не имеют значения.

В последних нескольких абзацах мы проанализировали deque::push_frontи взаимосвязь между количеством объектов в уже существующей deque и количеством операций, выполняемых push_front над содержащимися T-объектами. И мы обнаружили, что они были независимы друг от друга. Поскольку стандарт предписывает, что сложность заключается в операциях над операциями T, мы можем сказать, что это имеет постоянную сложность.

Да, сложность Operations-On-T *-Амортизируется (из-за vector), но нас интересует только сложность Operations-On-T, и она постоянна (не амортизируется).

Сложность vector :: push_back или vector :: push_front не имеет значения в этой реализации; эти соображения связаны с операциями T*и, следовательно, не имеют значения. Если бы стандарт ссылался на «условное» теоретическое понятие сложности, то они бы не стали явно ограничивать себя «количеством операций над содержащимися объектами». Я переоценил это предложение?


8
Это очень похоже на мошенничество! Когда вы указываете сложность операции, вы делаете это не только для некоторой части данных: вы хотите иметь представление об ожидаемом времени выполнения операции, которую вы вызываете, независимо от того, над чем она работает. Если я буду следовать вашей логике в отношении операций с T, это будет означать, что вы можете проверять, является ли значение каждого T * простым числом при каждом выполнении операции, и при этом соблюдать стандарт, поскольку вы не касаетесь Ts. Не могли бы вы указать, откуда ваши цитаты?
Зонко

2
Я думаю, что стандартные авторы знают, что они не могут использовать обычную теорию сложности, потому что у нас нет полностью определенной системы, в которой мы знаем, например, сложность выделения памяти. Нереально притворяться, что память может быть выделена для нового члена listнезависимо от текущего размера списка; Если список слишком велик, распределение будет медленным или не удастся. Следовательно, насколько я вижу, комитет принял решение указать только те операции, которые можно объективно подсчитать и измерить. (PS: у меня есть другая теория на этот
счет

Я почти уверен, O(n)что число операций асимптотически пропорционально количеству элементов. IE, количество метаопераций. В противном случае не имеет смысла ограничивать поиск O(1). Ergo, связанные списки не имеют права.
Mooing Duck

8
Это очень интересная интерпретация, но по этой логике list может быть реализован как vectorуказатель (вставка в середину приведет к вызову одного конструктора копирования, независимо от размера списка, и O(N)перетасовку указателей можно игнорировать, поскольку они не операции на Т).
Манкарс

1
Это хорошая языковая адвокатура (хотя я не буду пытаться выяснить, действительно ли это правильно или в стандарте есть какой-то тонкий момент, запрещающий эту реализацию). Но это не полезная информация на практике, потому что (1) общие реализации не реализуютdeque этот способ и (2) «обманывают» таким образом (даже если это разрешено стандартом), когда вычисление алгоритмической сложности не помогает при написании эффективных программ ,
Кайл Стрэнд,

13

Из обзора вы можете думать dequeкакdouble-ended queue

обзор deque

Данные в dequeхранятся чанками вектора фиксированного размера, которые

указывается с помощью 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, в основном из трех частей:

  1. итератор

  2. Как построить deque

1. iterator ( __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* получить данные в чанке

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, если все карты и чанки заполнены, это приведет к выделению новой карты и настройке чанков. Но приведенного выше кода может быть достаточно для понимания deque.


Вы упомянули: «Обратите внимание, что когда элемент push_back переходит в deque, если все карты и фрагменты заполнены, это приведет к выделению новой карты и корректировке фрагментов». Интересно, почему в стандарте C ++ говорится: «[26.3.8.4.3] Вставка одного элемента в начале или в конце deque всегда занимает постоянное время» в N4713. Выделение фрагмента данных занимает больше, чем постоянное время. Нет?
ФКПЧ

7

Я читал «Структуры данных и алгоритмы в C ++» Адама Дроздека и нашел это полезным. НТН.

Очень интересный аспект STL deque - это его реализация. Deque STL реализован не как связанный список, а как массив указателей на блоки или массивы данных. Количество блоков изменяется динамически в зависимости от потребностей хранилища, а размер массива указателей изменяется соответственно.

Вы можете заметить, что в середине находится массив указателей на данные (фрагменты справа), а также вы можете заметить, что массив в середине динамически изменяется.

Изображение стоит тысячи слов.

введите описание изображения здесь


1
Спасибо за ссылку на книгу. Я прочитал dequeчасть, и это довольно хорошо.
Рик

@ Рик рад это слышать. Я помню, как копался в deque в какой-то момент, потому что я не мог понять, как вы можете иметь произвольный доступ (оператор []) в O (1). Также доказательство того, что (push / pop) _ (back / front) амортизирует сложность O (1), является интересным «моментом ага».
Keloo

6

В то время как стандарт не предписывает какой-либо конкретной реализации (только произвольный доступ с постоянным временем), deque обычно реализуется как совокупность непрерывных «страниц» памяти. Новые страницы распределяются по мере необходимости, но у вас все еще есть произвольный доступ. В отличие от этого std::vector, вам не обещают, что данные хранятся непрерывно, но, как и в случае с вектором, вставки в середину требуют большого перемещения.


4
или удаление в середине требует много перемещения
Марк Хендриксон

Если insertтребуется много переселения , как это эксперимент- здесь показывает ошеломляющие разницу между vector::insert()и deque::insert()?
Була

1
@Bula: Возможно, из-за недопонимания деталей? Сложность вставки deque «линейна по количеству вставленных элементов плюс меньшее расстояние до начала и конца deque». Чтобы почувствовать эту стоимость, нужно вставить текущую середину; Это то, что делает ваш тест?
Керрек С.Б.

@KerrekSB: в статье с ответом Конрада упоминалась статья с тестом. На самом деле я не заметил комментарий в разделе статьи ниже. В теме "Но deque имеет линейное время вставки?" Автор упомянул, что он использовал вставку в позиции 100 во всех тестах, что делает результаты немного более понятными.
Була
Используя наш сайт, вы подтверждаете, что прочитали и поняли нашу Политику в отношении файлов cookie и Политику конфиденциальности.
Licensed under cc by-sa 3.0 with attribution required.