Що насправді деке в STL?


193

Я дивився на контейнери STL і намагався зрозуміти, якими вони є насправді (тобто використовувана структура даних), і деке зупинило мене: спочатку я подумав, що це подвійний зв'язаний список, який дозволить вставити та видалити з обох кінців у постійний час, але мене турбує обіцянка , дана оператором [], що буде виконуватися в постійний час. У пов'язаному списку довільний доступ повинен бути O (n), правда?

І якщо це динамічний масив, як він може додавати елементи за постійний час? Слід зазначити, що перерозподіл може статися і що O (1) - це амортизована вартість, як для вектора .

Тож мені цікаво, що це за структура, яка дозволяє довільний доступ у постійний час, і в той же час ніколи не потребує переміщення на нове велике місце.



1
@Graham "dequeue" - це ще одна поширена назва "deque". Я все ще схвалив редагування, оскільки "deque", як правило, є канонічною назвою.
Конрад Рудольф

@Konrad Дякую Питання стосувалося конкретно декелу C ++ STL, в якому використовується коротший написання.
Грем Борланд

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

Відповіді:


184

Деке дещо рекурсивно визначається: внутрішньо він підтримує двобічну чергу з шматочків фіксованого розміру. Кожен шматок є вектором, а сама черга (“карта” на графіці нижче) також є вектором.

схематичне оформлення пам’яті дека

Там відмінний аналіз характеристик продуктивності і , як він порівнює з , vectorпо меншій CodeProject .

Внутрішня реалізація стандартної бібліотеки GCC використовує a T**для представлення карти. Кожен блок даних - T*це виділений з певним розміром розмір __deque_buf_size(який залежить від sizeof(T)).


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

14
@stefaanv, @Konrad: C ++ реалізацій Я бачив, що використовували масив покажчиків на масиви фіксованого розміру. Це фактично означає, що push_front та push_back насправді не є постійними часом, але з розумними зростаючими факторами ви все одно отримуєте постійні амортизовані часи, тому O (1) не настільки помилковий, а на практиці це швидше, ніж вектор, оскільки ви міняєте місцями поодинокі вказівники, а не цілі об'єкти (і менше вказівники, ніж об'єкти).
Матьє М.

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

4
Якщо карта (сама черга) була двоспинним списком, я не бачу, як це могло б дозволити O (1) випадковий доступ. Він може бути реалізований у вигляді кругового буфера, що дозволить зробити круговий буфер зміненням більш ефективно: копіюйте лише покажчики замість усіх елементів у черзі. Але все-таки це невелика користь.
Wernight

14
@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
Можна описати внутрішні вектори як такі, що мають фіксовану ємність
Caleth

18

deque = подвійна черга

Контейнер, який може рости в будь-якому напрямку.

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


6
Це не зовсім вектори внутрішньо. Внутрішні структури можуть виділити, але невикористану ємність на початку , а також в кінці
Mooing Duck

@MooingDuck: Це реально визначена реалізація. Це може бути масив масивів або вектор векторів або все, що може забезпечити поведінку та складність, накладені стандартом.
Алок Зберегти

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

4
@MooingDuck ця вимога легко виконується, якщо перший шматок росте згори вниз, а не знизу вгору. Очевидно, що стандарт vectorцього не робить, але це досить проста модифікація, щоб зробити це так.
Марк Викуп

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

14

(Це відповідь, яку я дав у іншій темі . По суті, я стверджую, що навіть досить наївні реалізації, використовуючи єдиний vector, відповідають вимогам "постійного неамортизованого push_ {передній, задній}". Ви можете бути здивовані , і думаю, що це неможливо, але я знайшов інші відповідні цитати в стандарті, які визначають контекст дивним чином. Будь ласка, поводьтеся зі мною; якщо я помилився у цій відповіді, було б дуже корисно визначити, які речі Я правильно сказав і де зламалася моя логіка.)

У цій відповіді я не намагаюся визначити хорошу реалізацію, я просто намагаюся допомогти нам інтерпретувати вимоги складності в стандарті C ++. Я цитую з N3242 , який є, за Вікіпедією , останнім у вільному доступі документом зі стандартизації C ++ 11. (Схоже, вона організована інакше, ніж остаточний стандарт, і тому я не буду цитувати точні номери сторінок. Звичайно, ці правила могли змінитися в остаточному стандарті, але я не думаю, що це сталося.)

A deque<T>може бути реалізовано правильно, використовуючи a vector<T*>. Усі елементи копіюються на купу і вказівники, що зберігаються у векторі. (Детальніше про вектор пізніше).

Чому T*замість T? Тому що стандарт цього вимагає

"Вставка на будь-якому кінці деке недійсна для всіх ітераторів дека, але не впливає на обгрунтованість посилань на елементи дека. "

(мій акцент). T*Допомагає задовольнити це. Це також допомагає нам задовольнити це:

"Вставлення одного елемента або на початку, або в кінці deque завжди ..... викликає один виклик конструктору T. "

Тепер про (суперечливий) біт. Навіщо використовувати a vectorдля зберігання T*? Це дає нам випадковий доступ, що є гарним початком. Давайте на мить забудемо про складність вектора і ретельно зробимо це:

Стандарт говорить про "кількість операцій на об'єктах, що містяться". Бо deque::push_frontце очевидно 1, оскільки Tбудується точно один об’єкт, а нуль існуючих Tоб'єктів читається чи сканується будь-яким чином. Це число, 1, явно є постійною і не залежить від кількості об'єктів, які зараз знаходяться в деке. Це дозволяє нам сказати, що:

"Для нас deque::push_frontкількість операцій над об'єктами, що містяться (Ц), є фіксованою і не залежить від кількості об'єктів, які вже є в деке."

Звичайно, кількість операцій за T*заповітом буде не настільки добре відведена. Коли vector<T*>ріст стане занадто великим, він буде перерозподілений і багато T*s буде скопійовано навколо. Так, так, кількість операцій за T*заповітом буде різко відрізнятися, але на кількість операцій над цим Tне впливатиме.

Чому нас хвилює ця різниця між операціями Tпідрахунку та підрахунком операцій T*? Це тому, що стандарт говорить:

Усі вимоги щодо складності в цьому пункті викладені виключно з точки зору кількості операцій на об'єктах, що містяться.

Для dequeоб'єктів, що містяться, є той T, а не той T*, що означає, що ми можемо ігнорувати будь-яку операцію, яка копіює (або перерозподіляє) a T*.

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

В останніх кількох абзацах ми проаналізували deque::push_frontі співвідношення між кількістю об'єктів у деке вже і кількістю операцій, що виконуються push_front над утримуваними T-об'єктами. І ми виявили, що вони незалежні один від одного. Оскільки стандарт наказує, що складність є з точки зору операцій, що виконуються T, то ми можемо сказати, що це має постійну складність.

Так, Операції-на-Т * -комплектність амортизується (завдяки vector), але нас цікавить лише Операції-На-Т-Складність, і це постійно (неамортизовано).

Складність вектора :: push_back або вектора :: push_front не має значення в цій реалізації; ці міркування стосуються операцій на, T*а значить, не мають значення. Якби стандарт мав на увазі «традиційне» теоретичне поняття складності, вони не мали б прямо обмежуватися «кількістю операцій над об'єктами, що містяться». Чи я інтерпретую це речення?


8
Мені це здається дуже схожим на обман! Коли ви вказуєте складність операції, ви не робите це лише на певній частині даних: ви хочете мати уявлення про очікуваний час виконання операції, яку ви викликаєте, незалежно від того, на що вона працює. Якщо я дотримуюся вашої логіки щодо операцій над T, це означатиме, що ви можете перевірити, чи значення кожного T * є простим числом щоразу, коли виконується операція, і все ще дотримуєтесь стандарту, оскільки ви не торкаєтесь Ts. Не могли б ви вказати, звідки беруться ваші котирування?
Зонко

2
Я думаю, що стандартні автори знають, що вони не можуть використовувати звичайну теорію складності, оскільки у нас немає повністю визначеної системи, де ми знаємо, наприклад, складність розподілу пам'яті. Не реально робити вигляд, що пам'ять може бути виділена для нового члена listнезалежно від поточного розміру списку; якщо список занадто великий, розподіл буде повільним або не вдасться. Отже, наскільки я бачу, комітет прийняв рішення лише вказати ті операції, які можна об'єктивно рахувати та вимірювати. (PS: У мене є ще одна теорія щодо цього для іншої відповіді.)
Аарон Макдейд

Я впевнений, O(n)що кількість операцій асимптотично пропорційна кількості елементів. IE, кількість мета-операцій. Інакше не було б сенсу обмежувати пошук O(1). Ерго, пов'язані списки не підлягають.
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. ітератор ( __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] Вставлення одного елемента або на початку, або в кінці деке завжди вимагає постійного часу" в N4713. Виділення патрона даних займає більше ніж постійний час. Немає?
HCSF

7

Я читав «Структури даних та алгоритми в C ++» Адама Дродека і вважав це корисним. HTH.

Дуже цікавим аспектом деке STL є його реалізація. Структура STL реалізується не як пов'язаний список, а як масив покажчиків на блоки або масиви даних. Кількість блоків динамічно змінюється залежно від потреб у сховищі, і розмір масиву покажчиків змінюється відповідно.

Ви можете помітити посередині масив покажчиків на дані (фрагменти праворуч), а також ви можете помітити, що масив посередині динамічно змінюється.

Образ вартує тисячі слів.

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


1
Дякуємо, що звернулися до книги. Я прочитав dequeчастину, і це досить добре.
Рік

@Rick рада почути це. Я пам’ятаю, що в якийсь момент копався в деке, тому що я не міг зрозуміти, як можна мати оператор випадкового доступу ([]) в O (1). Крім того, доказ того, що (push / pop) _ (назад / спереду) амортизував O (1) складність, є цікавим «ага моментом».
Келуо

6

Хоча стандарт не передбачає будь-якої конкретної реалізації (лише випадковий доступ у постійному часі), deque зазвичай реалізується у вигляді колекції суміжних "сторінок" пам'яті. Нові сторінки розподіляються за потребою, але у вас все ще є випадковий доступ. На відміну від std::vectorвас, вам не обіцяють, що дані зберігаються безперервно, але, як і векторні, вставки посередині вимагають багато переміщення.


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

Якщо insertпотрібно багато переїзду, як тут експеримент 4 показує приголомшливу різницю між vector::insert()та deque::insert()?
Була

1
@Bula: Можливо, через неправильне повідомлення деталей? Складність вставки декев "лінійна в кількості вставлених елементів плюс менша відстань до початку та кінця деки". Щоб відчути цю вартість, потрібно вставити в поточну середину; це те, що робить ваш орієнтир?
Керрек СБ

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