C++

Готові структури даних C++: послідовні контейнери STL

Стандартна бібліотека шаблонів: std::vector, std::deque, std::list, std::array — внутрішня будова, асимптотична складність операцій, критерії вибору контейнера для конкретних задач, переваги та обмеження

Готові структури даних C++: послідовні контейнери STL

🎯 Мета лекції

  • Перейти від власних реалізацій структур даних (статті 89–92) до промислових контейнерів Стандартної бібліотеки шаблонів (STL, Standard Template Library).
  • Опанувати послідовні контейнери (sequence containers): std::vector, std::deque, std::list, std::array — їхню внутрішню будову, асимптотичну складність операцій, критерії вибору.
  • Зрозуміти компроміси (trade-offs) між контейнерами: довільний доступ vs вставка, локальність кешу vs гнучкість, фіксований розмір vs динамічне зростання.
  • Навчитись обґрунтовано обирати контейнер для конкретної задачі: коли vector оптимальний, коли потрібен list, де deque виграє.
  • Застосувати контейнери для розв'язання практичних задач: текстовий редактор з undo, планувальник завдань, буфер історії команд.

🔑 Ключові терміни

  • STL (Standard Template Library): частина Стандартної бібліотеки C++, що містить шаблонні контейнери, алгоритми та ітератори.
  • Sequence Container (Послідовний контейнер): контейнер, елементи якого зберігаються в певному порядку (визначеному користувачем), на відміну від асоціативних контейнерів.
  • Amortized Complexity (Амортизована складність): середня складність операції за серію викликів, з урахуванням рідкісних "дорогих" операцій (наприклад, реалокація вектора).
  • Iterator Invalidation (Інвалідація ітераторів): ситуація, коли ітератор, покажчик або посилання на елемент стають недійсними після модифікації контейнера.
  • Cache Locality (Локальність кешу): міра того, наскільки добре дані розташовані в пам'яті для ефективного завантаження в CPU-кеш; суцільний масив (vector) має кращу локальність, ніж вузлова структура (list).

Hook: від велосипедів до Ferrari — коли перестати писати контейнери вручну

На попередніх чотирьох лекціях (статті 89–92) ми власноруч реалізували чотири фундаментальні структури даних:

  • Стек та черга (стаття 89): дисципліни LIFO/FIFO, кільцевий буфер, обробка переповнення.
  • Однозв'язаний список (стаття 90): вузлова структура, O(1) вставка на початок, O(n) пошук.
  • Бінарне дерево пошуку (стаття 91): ієрархічна структура, O(log n) операції у збалансованому дереві.
  • Хеш-таблиця (стаття 92): асоціативний контейнер, O(1) пошук у середньому, колізії, rehashing.

Це був необхідний фундамент: ви тепер розумієте, як контейнери працюють під капотом — динамічне виділення пам'яті, покажчики, перерозподіл, компроміси між операціями. Ви можете обґрунтувати, чому вставка у vector на початок — O(n), а у list — O(1). Ви знаєте, що таке rehashing і чому коефіцієнт завантаження має значення.

Але у промисловому коді писати власні Stack, List, HashTable — це антипаттерн. Чому?

  1. Помилки: Кожна власна реалізація — потенційне джерело багів (витоки пам'яті, дві підряд delete, неправильна логіка).
  2. Підтримка: Додавання нової операції (наприклад, insertAt(index) у список) вимагає тестування, документації, обробки крайніх випадків.
  3. Оптимізація: Контейнери STL високо оптимізовані командами експертів протягом десятиліть — ви навряд чи напишете швидшу реалізацію без глибоких знань архітектури процесорів.
  4. Сумісність: Код, що використовує STL, зрозумілий будь-якому C++ програмісту без читання вашої документації.
Аналогія: Писати власні контейнери у продакшні — це як виготовляти власні шестерні для автомобіля замість покупки готових деталей. Для навчання — корисно. Для виробництва — марнотратство.

Стандартна бібліотека шаблонів (STL) надає промислові контейнери, які:

  • Шаблонні — працюють з будь-яким типом T (як ваші власні класи).
  • Ефективні — оптимізовані для швидкодії та пам'яті.
  • Надійні — протестовані мільйонами розробників.
  • Стандартизовані — поведінка визначена стандартом C++, працюють однаково на всіх компіляторах.
Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #FFFFFF

package "STL Containers" #F1F5F9 {
    rectangle "Sequence Containers\n(порядок визначений користувачем)" as seq #DBEAFE {
        rectangle "std::vector\n(динамічний масив)" as vec #DCFCE7
        rectangle "std::deque\n(двостороння черга)" as deq #DCFCE7
        rectangle "std::list\n(двозв'язаний список)" as list #DCFCE7
        rectangle "std::array\n(статичний масив)" as arr #DCFCE7
    }
    
    rectangle "Associative Containers\n(автоматичне сортування)" as assoc #FEF3C7 {
        rectangle "std::set\nstd::map" as setmap #FDE68A
        rectangle "std::unordered_set\nstd::unordered_map" as hash #FDE68A
    }
    
    rectangle "Container Adapters\n(обмежений інтерфейс)" as adapt #E0E7FF {
        rectangle "std::stack\nstd::queue\nstd::priority_queue" as adapters #C7D2FE
    }
}

note right of seq #DBEAFE
  Ця лекція: 
  послідовні контейнери
  (вже використовували в попередніх статтях)
end note

note right of assoc #FEF3C7
  Наступна лекція: 
  асоціативні контейнери
  (аналог власної хеш-таблиці)
end note

@enduml

У цій лекції ми зосередимось на послідовних контейнерах (sequence containers) — тих, де порядок елементів визначається користувачем (на відміну від асоціативних, що автоматично сортують елементи). Ви вже використовували std::vector у багатьох попередніх прикладах — тепер ми формалізуємо ваші знання та доповнимо їх альтернативами.


Загальна характеристика послідовних контейнерів

Визначення та спільний інтерфейс

Послідовний контейнер (sequence container) — це контейнер, елементи якого розташовані в певному порядку, визначеному моментом їх додавання (на відміну від асоціативних контейнерів, де порядок визначається ключем).

Спільні операції всіх послідовних контейнерів:

ОпераціяОписТипова складність
size()Кількість елементів у контейнеріO(1)
empty()Перевірка, чи контейнер порожнійO(1)
clear()Видалення всіх елементівO(n)
front()Доступ до першого елементаO(1)
back()Доступ до останнього елементаO(1)*
push_back(x)Додати елемент у кінецьO(1)**
pop_back()Видалити останній елементO(1)
insert(pos, x)Вставити елемент у довільну позиціюO(n) або O(1)***
erase(pos)Видалити елемент у довільній позиціїO(n) або O(1)***

* Для std::list та std::forward_list операція back() може бути O(n) або недоступна.
** Для std::vector та std::deque амортизована O(1); для std::list — завжди O(1).
*** Залежить від контейнера: vector — O(n), list — O(1), якщо маємо ітератор на позицію.

Всі контейнери STL підтримують уніфікований інтерфейс — методи названі однаково (push_back, size, empty), що дозволяє легко замінити один контейнер на інший без переписування коду, якщо потрібна інша продуктивність.

Чотири основні послідовні контейнери

КонтейнерВнутрішня будоваОсновна перевагаОсновний недолік
std::vectorДинамічний масив (суцільний блок пам'яті)Швидкий довільний доступ O(1), відмінна локальність кешуВставка/видалення на початку або в середині — O(n)
std::dequeМасив покажчиків на блоки фіксованого розміруШвидка вставка/видалення на обох кінцях O(1)Гірша локальність кешу, ніж у vector
std::listДвозв'язаний список (вузли з prev/next)Швидка вставка/видалення у будь-якій позиції O(1)Немає довільного доступу, погана локальність кешу
std::arrayСтатичний масив (розмір фіксований на етапі компіляції)Нульові накладні витрати, вбудовування у стекНеможливо змінити розмір після створення

Правило вибору за замовчуванням:

Використовуйте std::vector за замовчуванням, якщо не маєте обґрунтованої причини обрати інший контейнер. Причини для альтернативи:
  • Часто вставляєте/видаляєте на початку → std::deque
  • Часто вставляєте/видаляєте у довільних місцях → std::list
  • Розмір відомий на етапі компіляції і ніколи не змінюється → std::array

Це правило закріплене у C++ Core Guidelines: "Use std::vector by default unless you have a reason to use a different container."


std::vector: динамічний масив зі зростанням

Внутрішня будова та концептуальна модель

std::vector<T> — це динамічний масив, що зберігає елементи у суцільному блоці пам'яті (contiguous memory). Він автоматично збільшує свою ємність (capacity) при додаванні елементів, коли розмір (size) досягає поточної ємності.

Три ключові поля класу vector (концептуально):

template <typename T>
class vector
{
private:
    T* m_data;         // Покажчик на масив елементів
    size_t m_size;     // Кількість реально існуючих елементів
    size_t m_capacity; // Кількість елементів, для яких виділена пам'ять
};
Пам'ять std::vector після 5 push_back
Hex Dump / ASCII
0x00401000
0A141E28320000000000000000000000
Offset: 8 bytes
Big Endian

Інтерпретація:

  • m_size = 5 — у векторі 5 елементів: {10, 20, 30, 40, 50}.
  • m_capacity = 8 — виділено пам'ять для 8 елементів (3 комірки порожні).
  • Адреси 0x00401000–0x00401013 — суцільний блок (20 байт для int[5]).
Якщо б capacity == size, кожна операція push_back вимагала б реалокації (виділення нового масиву, копіювання всіх елементів, видалення старого). Це O(n) на кожну вставку → загальна складність N вставок = O(n²) — катастрофа. Натомість vectorпередбачливо виділяє більше пам'яті, ніж потрібно зараз, щоб майбутні вставки відбувались без реалокації.

Стратегія зростання ємності (growth factor)

Коли size == capacity і ви викликаєте push_back, vector:

  1. Виділяє новий масив розміром capacity * growth_factor (зазвичай 1.5 або 2).
  2. Копіює всі існуючі елементи у новий масив.
  3. Додає новий елемент.
  4. Видаляє старий масив.

Приклад з growth factor = 2:

КрокОпераціяsizecapacityРеалокація?
1vector<int> v;00—
2v.push_back(10);11Так (0 → 1)
3v.push_back(20);22Так (1 → 2)
4v.push_back(30);34Так (2 → 4)
5v.push_back(40);44Ні
6v.push_back(50);58Так (4 → 8)
7v.push_back(60);68Ні

Амортизована складність push_back:

  • Більшість вставок — O(1) (якщо size < capacity).
  • Рідкісні вставки з реалокацією — O(n) (копіювання).
  • Амортизована складність (середня за N операцій) — O(1).

Доведення амортизації: Якщо вставити N елементів, реалокація відбудеться O(log N) разів (1 → 2 → 4 → 8 → ... → N). Загальна робота копіювань: 1 + 2 + 4 + ... + N/2 = N - 1 ≈ N. Розподілена на N вставок: N / N = O(1).

Основні операції та їхня складність

#include <iostream>
#include <vector>

int main()
{
    std::vector<int> numbers;  // Створення порожнього вектора
    
    // Додавання елементів у кінець (O(1) амортизована)
    numbers.push_back(10);
    numbers.push_back(20);
    numbers.push_back(30);
    
    // Довільний доступ (O(1))
    std::cout << "Перший елемент: " << numbers[0] << '\n';       // 10
    std::cout << "Другий елемент: " << numbers.at(1) << '\n';    // 20 (з перевіркою меж)
    
    // Розмір та ємність
    std::cout << "size: " << numbers.size() << '\n';              // 3
    std::cout << "capacity: " << numbers.capacity() << '\n';      // 4 (може бути більше)
    
    // Резервування ємності (уникнення реалокацій)
    numbers.reserve(100);  // Гарантує capacity >= 100
    std::cout << "capacity після reserve: " << numbers.capacity() << '\n';
    
    // Вставка у довільну позицію (O(n))
    numbers.insert(numbers.begin() + 1, 15);  // Вставити 15 на індекс 1
    // Тепер: {10, 15, 20, 30}
    
    // Видалення елемента (O(n))
    numbers.erase(numbers.begin());  // Видалити перший елемент
    // Тепер: {15, 20, 30}
    
    // Видалення останнього елемента (O(1))
    numbers.pop_back();
    // Тепер: {15, 20}
    
    // Очищення вектора (size = 0, capacity не змінюється)
    numbers.clear();
    std::cout << "Після clear — size: " << numbers.size() 
              << ", capacity: " << numbers.capacity() << '\n';
    
    return 0;
}
Виконання програми
Перший елемент: 10
Другий елемент: 20
size: 3
capacity: 4
capacity після reserve: 100
Після clear — size: 0, capacity: 100

Ключові відмінності operator[] vs at():

МетодПеревірка межВиняток при виході за межіШвидкість
v[i]НіНевизначена поведінка (UB)Максимальна (без перевірок)
v.at(i)Такstd::out_of_rangeТрохи повільніше
Код v[1000] для вектора з 10 елементів не спричинить помилку компіляції і може навіть не викликати crash під час виконання — це невизначена поведінка. Програма може прочитати випадкову пам'ять, змінити чужі дані або впасти через segmentation fault. Завжди перевіряйте індекси або використовуйте at() у критичних місцях.

Переваги та обмеження std::vector

Переваги:

  1. Довільний доступ O(1): v[i] — найшвидша операція завдяки арифметиці покажчиків.
  2. Відмінна локальність кешу: Суцільний блок пам'яті → процесор завантажує у кеш одразу багато елементів → швидкі ітерації.
  3. Ефективне зберігання: Немає накладних витрат на покажчики між елементами (на відміну від list).
  4. Швидкі операції у кінці: push_back і pop_back — O(1) амортизована.

Обмеження:

  1. Повільна вставка/видалення на початку або в середині: O(n) через зсув елементів.
  2. Реалокація інвалідує ітератори: Після push_back, що спричинив реалокацію, всі ітератори, покажчики, посилання стають недійсними.
  3. Зайве споживання пам'яті: capacity може бути значно більшим за size (наприклад, 1000 елементів займають 2048 комірок).

Коли використовувати vector:

✅ Оптимальні сценарії

  • Потрібен швидкий довільний доступ (наприклад, v[i] у циклі).
  • Елементи додаються лише у кінець (історія дій, лог подій).
  • Важлива локальність кешу (обробка великих масивів даних).
  • Використовується у алгоритмах STL (більшість оптимізовані під vector).

❌ Неоптимальні сценарії

  • Часто вставка/видалення на початку → використайте std::deque.
  • Часто вставка у довільних позиціях → використайте std::list.
  • Потрібна гарантована стабільність ітераторів → std::list або std::deque.

std::deque: двостороння черга

Внутрішня будова: масив блоків

std::deque<T> (англ. double-ended queue — "двостороння черга") — це контейнер, що дозволяє швидку вставку та видалення на обох кінцях (на відміну від vector, де вставка на початку — O(n)).

Концептуальна модель пам'яті:

deque зберігає масив покажчиків на блоки фіксованого розміру (зазвичай 512 байт або 128 елементів). Кожен блок — це суцільний масив елементів.

Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #FFFFFF

title Структура std::deque із 3 блоками

rectangle "Map (масив покажчиків)" as map #DBEAFE {
    rectangle "map[0]" as p0 #DCFCE7
    rectangle "map[1]" as p1 #DCFCE7
    rectangle "map[2]" as p2 #DCFCE7
}

rectangle "Блок 0\n(128 елементів)" as b0 #FEF3C7 {
    label "40, 50, 60, ..." as data0
}

rectangle "Блок 1\n(128 елементів)" as b1 #FEF3C7 {
    label "70, 80, 90, ..." as data1
}

rectangle "Блок 2\n(128 елементів)" as b2 #FEF3C7 {
    label "100, 110, 120, ..." as data2
}

p0 -down-> b0
p1 -down-> b1
p2 -down-> b2

note right of map #DBEAFE
  Масив покажчиків (map)
  дозволяє швидко знайти
  потрібний блок: 
  block = map[index / 128]
end note

note right of b0 #FEF3C7
  Кожен блок — суцільний
  масив елементів.
  Новий блок виділяється,
  коли поточні заповнені.
end note

@enduml

Як працює доступ до елемента deq[i]:

  1. Обчислити індекс блоку: blockIndex = i / BLOCK_SIZE.
  2. Обчислити позицію всередині блоку: offset = i % BLOCK_SIZE.
  3. Повернути map[blockIndex][offset].

Складність: O(1) — дві арифметичні операції та два звернення до пам'яті.

Операції та асимптотична складність

#include <iostream>
#include <deque>

int main()
{
    std::deque<int> deq;
    
    // Додавання у кінець (O(1))
    deq.push_back(30);
    deq.push_back(40);
    
    // Додавання на початок (O(1)) — ключова перевага!
    deq.push_front(20);
    deq.push_front(10);
    
    // Тепер: {10, 20, 30, 40}
    
    // Довільний доступ (O(1))
    std::cout << "deq[1] = " << deq[1] << '\n';  // 20
    
    // Видалення з обох кінців (O(1))
    deq.pop_front();  // Видалити 10
    deq.pop_back();   // Видалити 40
    // Тепер: {20, 30}
    
    // Вставка у середину (O(n))
    deq.insert(deq.begin() + 1, 25);  // Вставити 25 на індекс 1
    // Тепер: {20, 25, 30}
    
    // Обхід контейнера
    for (int x : deq)
    {
        std::cout << x << ' ';
    }
    std::cout << '\n';
    
    return 0;
}
Виконання програми
deq[1] = 20
20 25 30

Порівняльна таблиця складності: vector vs deque:

Операціяstd::vectorstd::deque
Довільний доступ [i]O(1)O(1)
push_backO(1)*O(1)
push_frontO(n)O(1) ← переваг
pop_frontO(n)O(1) ← перевага
insert(mid)O(n)O(n)
Локальність кешуВідміннаГірша (блоки розкидані)

* Амортизована складність.

Коли використовувати std::deque

✅ Оптимальні сценарії

  • Потрібна швидка вставка/видалення на обох кінцях (наприклад, реалізація черги завдань з пріоритетом).
  • Часто додаєте елементи на початок контейнера (історія команд з можливістю відкату).
  • Потрібен довільний доступ O(1), але важливіша швидкість операцій на кінцях, ніж локальність кешу.

❌ Неоптимальні сценарії

  • Потрібна максимальна локальність кешу → vector швидший для ітерацій.
  • Часто вставка у довільних позиціях → list ефективніший.
  • Розмір контейнера невеликий (< 100 елементів) → vector простіший і швидший.

Практичний приклад: обробка команд з історією undo/redo

#include <iostream>
#include <deque>
#include <string>

class CommandHistory
{
private:
    std::deque<std::string> m_commands;
    size_t m_currentIndex;

public:
    CommandHistory() : m_currentIndex(0) {}
    
    void execute(const std::string& cmd)
    {
        // Видаляємо команди після поточної позиції (якщо були undo)
        while (m_commands.size() > m_currentIndex)
        {
            m_commands.pop_back();
        }
        
        m_commands.push_back(cmd);
        ++m_currentIndex;
        
        std::cout << "Виконано: " << cmd << '\n';
    }
    
    void undo()
    {
        if (m_currentIndex > 0)
        {
            --m_currentIndex;
            std::cout << "Скасовано: " << m_commands[m_currentIndex] << '\n';
        }
        else
        {
            std::cout << "Немає команд для скасування\n";
        }
    }
    
    void redo()
    {
        if (m_currentIndex < m_commands.size())
        {
            std::cout << "Повторено: " << m_commands[m_currentIndex] << '\n';
            ++m_currentIndex;
        }
        else
        {
            std::cout << "Немає команд для повторення\n";
        }
    }
};

int main()
{
    CommandHistory history;
    
    history.execute("Вставити текст 'Hello'");
    history.execute("Видалити слово");
    history.execute("Вставити зображення");
    
    history.undo();  // Скасовано: Вставити зображення
    history.undo();  // Скасовано: Видалити слово
    
    history.redo();  // Повторено: Видалити слово
    
    history.execute("Змінити шрифт на жирний");
    // Команда "Вставити зображення" видалена з історії
    
    return 0;
}
Виконання програми
Виконано: Вставити текст 'Hello'
Виконано: Видалити слово
Виконано: Вставити зображення
Скасовано: Вставити зображення
Скасовано: Видалити слово
Повторено: Видалити слово
Виконано: Змінити шрифт на жирний

Чому deque тут краще за vector? Якщо користувач виконав 1000 команд, потім скасував 500 з них (undo), а потім виконав нову — потрібно видалити 500 елементів з кінця deque. У vector це теж O(1) для кожного pop_back, але deque не реалокує пам'ять при зменшенні, що робить його трохи ефективнішим для динамічних розмірів.


(Кінець частини 1, ~150 рядків. Продовжую писати частину 2...)

std::list: двозв'язаний список

Внутрішня будова: вузлова структура з prev/next

std::list<T> — це двозв'язаний список (doubly-linked list), де кожен елемент зберігається у вузлі (node), що містить:

  • Дані: власне значення типу T.
  • Покажчик prev: на попередній вузол у списку.
  • Покажчик next: на наступний вузол у списку.

Концептуальна структура вузла:

template <typename T>
struct ListNode
{
    T data;
    ListNode* prev;
    ListNode* next;
};

Клас list зберігає:

  • Покажчик на голову списку (head) — перший вузол.
  • Покажчик на хвіст списку (tail) — останній вузол.
  • Розмір списку (size) — кількість вузлів.
Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #FFFFFF

title std::list з елементами {10, 20, 30, 40}

rectangle "list (об'єкт)" as listObj #DBEAFE {
    label "head → Node1" as head
    label "tail → Node4" as tail
    label "size = 4" as size
}

circle "Node1\ndata: 10" as n1 #DCFCE7
circle "Node2\ndata: 20" as n2 #DCFCE7
circle "Node3\ndata: 30" as n3 #DCFCE7
circle "Node4\ndata: 40" as n4 #DCFCE7

listObj -down-> n1 : head
listObj -down-> n4 : tail

n1 -right-> n2 : next
n2 -left-> n1 : prev

n2 -right-> n3 : next
n3 -left-> n2 : prev

n3 -right-> n4 : next
n4 -left-> n3 : prev

note right of n1 #DCFCE7
  Кожен вузол виділений окремо
  у купі (new ListNode).
  Вузли **не** розташовані
  послідовно у пам'яті.
end note

@enduml

Ключова відмінність від vector/deque: Елементи list розкидані по пам'яті (кожен вузол виділений окремим new), на відміну від суцільних масивів у vector. Це дає гнучкість (вставка/видалення у довільній позиції — O(1)), але забирає локальність кешу (процесор не може завантажити у кеш одразу багато елементів).

Операції та асимптотична складність

#include <iostream>
#include <list>

int main()
{
    std::list<int> lst;
    
    // Додавання у кінець (O(1))
    lst.push_back(30);
    lst.push_back(40);
    
    // Додавання на початок (O(1))
    lst.push_front(20);
    lst.push_front(10);
    
    // Тепер: {10, 20, 30, 40}
    
    // НЕМАЄ довільного доступу [] або at()!
    // std::cout << lst[1];  // ПОМИЛКА компіляції
    
    // Доступ до першого та останнього елемента (O(1))
    std::cout << "Перший: " << lst.front() << '\n';  // 10
    std::cout << "Останній: " << lst.back() << '\n';  // 40
    
    // Вставка у довільну позицію (O(1), якщо маємо ітератор)
    auto it = lst.begin();
    ++it;  // Переміщуємо ітератор на 2-й елемент (20)
    lst.insert(it, 15);  // Вставити 15 перед елементом 20
    // Тепер: {10, 15, 20, 30, 40}
    
    // Видалення елемента (O(1), якщо маємо ітератор)
    auto itToRemove = lst.begin();
    std::advance(itToRemove, 2);  // Перемістити на 3-й елемент (20)
    lst.erase(itToRemove);  // Видалити 20
    // Тепер: {10, 15, 30, 40}
    
    // Обхід контейнера (єдиний спосіб доступу до всіх елементів)
    for (int x : lst)
    {
        std::cout << x << ' ';
    }
    std::cout << '\n';
    
    return 0;
}
Виконання програми
Перший: 10
Останній: 40
10 15 30 40

Порівняльна таблиця складності: vector vs list:

Операціяstd::vectorstd::list
Довільний доступ [i]O(1)Немає (потрібен обхід O(n))
push_back / push_frontO(1) / O(n)O(1) / O(1) ← перевага
pop_back / pop_frontO(1) / O(n)O(1) / O(1) ← перевага
insert(it, x)O(n)O(1) ← перевага
erase(it)O(n)O(1) ← перевага
Локальність кешуВідміннаЖахлива ← недолік
Стабільність ітераторівНі (реалокація)Так (вузли не переміщуються)

Коли використовувати std::list

На практиці std::list використовується рідко (< 5% випадків). Причина: погана локальність кешу робить його повільнішим за vector навіть для задач, де теоретично він має переваги (вставка/видалення у середині). Сучасні процесори настільки швидкі у роботі з суцільними масивами, що копіювання N елементів у vector може бути швидшим, ніж обхід N вузлів у list через кеш-промахи.

Виняткові сценарії для list:

✅ Рідкісні оптимальні сценарії

  • Дуже часта вставка/видалення у довільних позиціях і розмір контейнера великий (> 10 000 елементів).
  • Потрібна стабільність ітераторів: вставка/видалення не інвалідує ітератори на інші елементи.
  • Елементи дорогі для копіювання (великі об'єкти без move-семантики).
  • Реалізація алгоритмів, що вимагають двозв'язаний список (LRU-кеш з O(1) переміщенням елементів).

❌ Типові хибні припущення

  • "Потрібна вставка у середину" → Спочатку спробуйте vector! Його швидкість на малих розмірах (< 1000) може здивувати.
  • "Потрібна черга" → Використайте std::deque, а не list.
  • "Потрібна стабільність покажчиків" → Розгляньте std::deque (стабільніший за vector, швидший за list).

Практичний приклад: LRU-кеш (Least Recently Used)

LRU-кеш — це структура даних, що зберігає обмежену кількість елементів і автоматично видаляє найдавніше використаний елемент при досягненні ліміту. Реалізація вимагає O(1) переміщення елемента на початок списку при доступі — list ідеально підходить.

#include <iostream>
#include <list>
#include <unordered_map>
#include <string>

class LRUCache
{
private:
    size_t m_capacity;
    std::list<std::pair<int, std::string>> m_items;  // {key, value}
    std::unordered_map<int, std::list<std::pair<int, std::string>>::iterator> m_map;

public:
    explicit LRUCache(size_t capacity) : m_capacity(capacity) {}
    
    void put(int key, const std::string& value)
    {
        // Якщо ключ вже є, видаляємо старий запис
        auto it = m_map.find(key);
        if (it != m_map.end())
        {
            m_items.erase(it->second);
        }
        
        // Додаємо новий елемент на початок списку (найсвіжіший)
        m_items.emplace_front(key, value);
        m_map[key] = m_items.begin();
        
        // Якщо перевищено ємність, видаляємо найстаріший (кінець списку)
        if (m_items.size() > m_capacity)
        {
            auto last = m_items.back();
            m_map.erase(last.first);
            m_items.pop_back();
        }
    }
    
    std::string* get(int key)
    {
        auto it = m_map.find(key);
        if (it == m_map.end())
        {
            return nullptr;  // Ключ не знайдено
        }
        
        // Переміщуємо елемент на початок (оновлення часу використання)
        auto listIt = it->second;
        m_items.splice(m_items.begin(), m_items, listIt);  // O(1) переміщення
        
        return &listIt->second;
    }
    
    void display() const
    {
        std::cout << "Кеш (найсвіжіший → найстаріший): ";
        for (const auto& [key, value] : m_items)
        {
            std::cout << "[" << key << ":" << value << "] ";
        }
        std::cout << '\n';
    }
};

int main()
{
    LRUCache cache(3);  // Ємність: 3 елементи
    
    cache.put(1, "один");
    cache.put(2, "два");
    cache.put(3, "три");
    cache.display();  // [3:три] [2:два] [1:один]
    
    cache.get(1);  // Доступ до ключа 1 → переміщує на початок
    cache.display();  // [1:один] [3:три] [2:два]
    
    cache.put(4, "чотири");  // Перевищено ємність → видаляємо найстаріший (2)
    cache.display();  // [4:чотири] [1:один] [3:три]
    
    return 0;
}
Виконання програми
Кеш (найсвіжіший → найстаріший): [3:три] [2:два] [1:один]
Кеш (найсвіжіший → найстаріший): [1:один] [3:три] [2:два]
Кеш (найсвіжіший → найстаріший): [4:чотири] [1:один] [3:три]

Чому тут list незамінний? Метод splice переміщує вузол з однієї позиції у іншу без копіювання даних — просто змінюються покажчики prev/next. У vector це вимагало б видалення елемента (зсув усіх наступних) та вставки на початок (зсув усіх елементів) — O(n) операція. У list — O(1).


std::array: статичний масив з інтерфейсом STL

Внутрішня будова: обгортка над C-масивом

std::array<T, N> — це статичний масив фіксованого розміру, відомого на етапі компіляції. Він є тонкою обгорткою (wrapper) над звичайним C-масивом T arr[N], але надає інтерфейс STL (методи size(), at(), front(), back()).

Ключова відмінність від vector:

Властивістьstd::vectorstd::array
РозмірДинамічний (може змінюватися)Фіксований (задається шаблонним параметром)
ЗберіганняКупа (heap, через new)Стек (stack, якщо локальна змінна)
РеалокаціяТак (при перевищенні ємності)Ні (розмір незмінний)
Накладні витратиТри покажчики (data, size, capacity)Нуль (компілятор знає все на етапі компіляції)

Синтаксис оголошення:

#include <array>

std::array<int, 5> arr = {10, 20, 30, 40, 50};  // Фіксований розмір 5
// arr.push_back(60);  // ПОМИЛКА: немає методу push_back (розмір фіксований)

Внутрішня реалізація (спрощено):

template <typename T, size_t N>
struct array
{
    T m_data[N];  // Звичайний C-масив
    
    constexpr size_t size() const noexcept { return N; }
    T& operator[](size_t i) { return m_data[i]; }
    T& at(size_t i) { /* перевірка меж */ return m_data[i]; }
    // ... інші методи
};

Переваги та обмеження std::array

Переваги:

  1. Нульові накладні витрати: Компілятор знає розмір на етапі компіляції → оптимізує так само, як C-масив.
  2. Безпечний інтерфейс: На відміну від C-масиву, array знає свій розмір (size()) і має at() з перевіркою меж.
  3. Сумісність з STL: Можна використовувати з алгоритмами STL (std::sort, std::find).
  4. Копіювання за значенням: std::array можна копіювати оператором = (C-масив потребує std::copy).

Обмеження:

  1. Фіксований розмір: Неможливо змінити розмір після створення.
  2. Розмір має бути відомий на етапі компіляції: std::array<int, n>, де n — змінна, не компілюється.

Коли використовувати std::array:

✅ Оптимальні сценарії

  • Розмір відомий і фіксований на етапі компіляції (наприклад, матриця 3×3, координати точки у 3D-просторі).
  • Потрібна максимальна продуктивність (нульові накладні витрати).
  • Об'єкт локальний (зберігається у стеку) і розмір невеликий (< 1 КБ).

❌ Неоптимальні сценарії

  • Розмір змінюється під час виконання → std::vector.
  • Розмір великий (> 1 МБ) → може переповнити стек, використайте std::vector.
  • Розмір визначається користувачем → std::vector.

Практичний приклад: 3D-вектор з фіксованим розміром

#include <iostream>
#include <array>
#include <cmath>

class Vector3D
{
private:
    std::array<double, 3> m_coords;  // {x, y, z}

public:
    Vector3D(double x, double y, double z) : m_coords{x, y, z} {}
    
    double x() const { return m_coords[0]; }
    double y() const { return m_coords[1]; }
    double z() const { return m_coords[2]; }
    
    double length() const
    {
        return std::sqrt(m_coords[0] * m_coords[0] +
                        m_coords[1] * m_coords[1] +
                        m_coords[2] * m_coords[2]);
    }
    
    Vector3D operator+(const Vector3D& other) const
    {
        return Vector3D(m_coords[0] + other.m_coords[0],
                       m_coords[1] + other.m_coords[1],
                       m_coords[2] + other.m_coords[2]);
    }
    
    void display() const
    {
        std::cout << "(" << m_coords[0] << ", " 
                  << m_coords[1] << ", " 
                  << m_coords[2] << ")\n";
    }
};

int main()
{
    Vector3D v1(1.0, 2.0, 3.0);
    Vector3D v2(4.0, 5.0, 6.0);
    
    Vector3D v3 = v1 + v2;
    
    std::cout << "v1 = "; v1.display();
    std::cout << "v2 = "; v2.display();
    std::cout << "v1 + v2 = "; v3.display();
    std::cout << "Довжина v3: " << v3.length() << '\n';
    
    return 0;
}
Виконання програми
v1 = (1, 2, 3)
v2 = (4, 5, 6)
v1 + v2 = (5, 7, 9)
Довжина v3: 12.4499

Чому std::array тут краще за std::vector? 3D-вектор завжди має 3 координати — розмір фіксований. Використання vector<double> додало б:

  • 3 додаткові вказівники (data, size, capacity) = 24 байти накладних витрат.
  • Виділення пам'яті у купі (повільніше за стек).
  • Можливість помилково змінити розмір (v.push_back(4.0) скомпілюється, але логічно неправильно).

std::array<double, 3> займає точно 24 байти (3 * 8 для double), зберігається у стеку, компілятор може повністю інлайнити операції.


(Кінець частини 2. Продовжую частину 3...)

Порівняння контейнерів: критерії вибору

Зведена таблиця складності операцій

Операціяvectordequelistarray
Довільний доступ [i]O(1)O(1)НемаєO(1)
push_back / pop_backO(1)*O(1)O(1)Немає
push_front / pop_frontO(n)O(1)O(1)Немає
insert(mid) / erase(mid)O(n)O(n)O(1)**Немає
Локальність кешу⭐⭐⭐⭐⭐⭐⭐⭐⭐
Стабільність ітераторів*❌⚠️✅✅
Зміна розміру✅✅✅❌

* Амортизована складність.
** O(1) лише якщо маємо ітератор на позицію; пошук позиції — O(n).
*** Стабільність: чи залишаються дійсними ітератори/покажчики після вставки/видалення.

Блок-схема вибору контейнера

Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #FFFFFF

start

:Розмір контейнера<br/>фіксований та відомий<br/>на етапі компіляції?;
if (Так) then (yes)
  :Використайте<br/>**std::array**;
  stop
else (no)
endif

:Потрібен довільний<br/>доступ O(1)?;
if (Ні) then (no)
  :Використайте<br/>**std::list**;
  note right
    Рідкісний вибір!
    Перевірте, чи справді
    локальність кешу
    не критична.
  end note
  stop
else (yes)
endif

:Часто вставляєте/<br/>видаляєте на **початку**?;
if (Так) then (yes)
  :Використайте<br/>**std::deque**;
  stop
else (no)
endif

:Використайте<br/>**std::vector**;
note right
  Універсальний вибір
  за замовчуванням
  (C++ Core Guidelines)
end note

stop

@enduml

Практичні рекомендації зі Stack Overflow та C++ Core Guidelines

🏆 Правило 1: vector за замовчуванням

Якщо ви не впевнені — використовуйте std::vector. Це найшвидший контейнер для 90% задач завдяки відмінній локальності кешу. Навіть якщо теоретично інший контейнер має кращу асимптотичну складність, vector на практиці швидший для малих і середніх розмірів (< 10 000 елементів).

Джерело: C++ Core Guidelines SL.con.2

⚖️ Правило 2: Виміряйте перед оптимізацією

"Не вгадуйте — вимірюйте" (Don't guess, measure). Якщо ви припускаєте, що list буде швидшим через часті вставки, напишіть бенчмарк і порівняйте з vector. У 80% випадків vector виграє через кеш-ефективність, навіть якщо теорія каже інше.

Інструменти: Google Benchmark, std::chrono, профайлер Valgrind Cachegrind.

🚫 Правило 3: Уникайте list без причини

std::list — це останній вибір, а не перший. Він має сенс лише у виняткових сценаріях:

  • Вставка/видалення у довільних місцях дуже часта (тисячі разів на секунду).
  • Елементи дорогі для копіювання і немає move-семантики.
  • Потрібна стабільність ітераторів (але deque теж підходить).

Цитата з "Effective STL" (Scott Meyers): "Use list sparingly, if at all."


Ітератори: універсальний спосіб обходу контейнерів

Концепція ітераторів

Ітератор (iterator) — це об'єкт, що узагальнює покажчик: він вказує на елемент контейнера та дозволяє переходити до наступного/попереднього елемента. Завдяки ітераторам, один і той же код може працювати з різними контейнерами (vector, list, deque).

Базовий приклад:

#include <iostream>
#include <vector>

int main()
{
    std::vector<int> numbers = {10, 20, 30, 40, 50};
    
    // Ітератор на перший елемент
    std::vector<int>::iterator it = numbers.begin();
    
    // Обхід контейнера через ітератор
    while (it != numbers.end())
    {
        std::cout << *it << ' ';  // Розіменування (як покажчик)
        ++it;  // Перехід до наступного елемента
    }
    std::cout << '\n';
    
    return 0;
}
Виконання програми
10 20 30 40 50

Категорії ітераторів:

КатегоріяПідтримувані операціїПриклад контейнера
Input IteratorЧитання, інкремент (++it)std::istream_iterator
Output IteratorЗапис, інкрементstd::ostream_iterator
Forward IteratorЧитання/запис, інкрементstd::forward_list
Bidirectional IteratorЧитання/запис, інкремент, декремент (--it)std::list, std::set
Random Access IteratorВсі операції + арифметика (it + 5, it - 3)std::vector, std::deque, std::array

Чому ітератори важливі? Алгоритми STL (std::sort, std::find, std::accumulate) працюють з ітераторами, а не з конкретними контейнерами. Це дозволяє одному алгоритму працювати з будь-яким контейнером.

Range-based for loop: синтаксичний цукор над ітераторами

Цикл for (auto x : container) — це синтаксичний цукор (syntactic sugar) над ітераторами. Компілятор перетворює його у звичайний цикл з begin() та end().

Оригінальний код:

std::vector<int> numbers = {10, 20, 30};

for (int x : numbers)
{
    std::cout << x << ' ';
}

Еквівалентний код (що генерує компілятор):

{
    auto&& __range = numbers;
    auto __begin = __range.begin();
    auto __end = __range.end();
    
    for (; __begin != __end; ++__begin)
    {
        int x = *__begin;
        std::cout << x << ' ';
    }
}
Якщо елементи контейнера великі (об'єкти класів), використовуйте посилання, щоб уникнути копіювання:
for (const auto& obj : largeObjects)  // Посилання (без копіювання)
{
    obj.display();
}
Якщо потрібно модифікувати елементи:
for (auto& x : numbers)  // Неконстантне посилання
{
    x *= 2;  // Подвоюємо кожен елемент
}

Практичне застосування: текстовий редактор з історією undo

Постановка задачі

Реалізувати спрощений текстовий редактор, що підтримує:

  1. Вставку рядка на довільну позицію.
  2. Видалення рядка за індексом.
  3. Undo (скасування останньої операції).
  4. Вивід усього тексту.

Вимоги:

  • Операції вставки/видалення мають бути швидкими (бажано O(1) для вставки у кінець).
  • Історія команд має зберігатись у стеку.

Реалізація з std::deque та std::stack

#include <iostream>
#include <deque>
#include <stack>
#include <string>

// Типи операцій
enum class OperationType { INSERT, DELETE };

// Структура для збереження команди
struct Command
{
    OperationType type;
    size_t position;
    std::string text;
};

class TextEditor
{
private:
    std::deque<std::string> m_lines;  // Рядки тексту
    std::stack<Command> m_undoStack;  // Історія команд

public:
    void insertLine(size_t pos, const std::string& text)
    {
        if (pos > m_lines.size())
        {
            std::cerr << "Помилка: позиція за межами тексту\n";
            return;
        }
        
        m_lines.insert(m_lines.begin() + pos, text);
        m_undoStack.push({OperationType::INSERT, pos, text});
        
        std::cout << "Вставлено рядок на позицію " << pos << '\n';
    }
    
    void deleteLine(size_t pos)
    {
        if (pos >= m_lines.size())
        {
            std::cerr << "Помилка: позиція за межами тексту\n";
            return;
        }
        
        std::string deletedText = m_lines[pos];
        m_lines.erase(m_lines.begin() + pos);
        m_undoStack.push({OperationType::DELETE, pos, deletedText});
        
        std::cout << "Видалено рядок на позиції " << pos << '\n';
    }
    
    void undo()
    {
        if (m_undoStack.empty())
        {
            std::cout << "Немає операцій для скасування\n";
            return;
        }
        
        Command cmd = m_undoStack.top();
        m_undoStack.pop();
        
        if (cmd.type == OperationType::INSERT)
        {
            // Скасовуємо вставку → видаляємо рядок
            m_lines.erase(m_lines.begin() + cmd.position);
            std::cout << "Скасовано вставку на позиції " << cmd.position << '\n';
        }
        else  // DELETE
        {
            // Скасовуємо видалення → повертаємо рядок
            m_lines.insert(m_lines.begin() + cmd.position, cmd.text);
            std::cout << "Скасовано видалення на позиції " << cmd.position << '\n';
        }
    }
    
    void display() const
    {
        std::cout << "=== Вміст документа ===\n";
        for (size_t i = 0; i < m_lines.size(); ++i)
        {
            std::cout << i << ": " << m_lines[i] << '\n';
        }
        std::cout << "=======================\n";
    }
};

int main()
{
    TextEditor editor;
    
    editor.insertLine(0, "Перший рядок");
    editor.insertLine(1, "Другий рядок");
    editor.insertLine(2, "Третій рядок");
    editor.display();
    
    editor.deleteLine(1);  // Видаляємо "Другий рядок"
    editor.display();
    
    editor.undo();  // Скасовуємо видалення
    editor.display();
    
    editor.undo();  // Скасовуємо вставку "Третій рядок"
    editor.display();
    
    return 0;
}
Виконання програми
Вставлено рядок на позицію 0
Вставлено рядок на позицію 1
Вставлено рядок на позицію 2
=== Вміст документа ===
0: Перший рядок
1: Другий рядок
2: Третій рядок
=======================
Видалено рядок на позиції 1
=== Вміст документа ===
0: Перший рядок
1: Третій рядок
=======================
Скасовано видалення на позиції 1
=== Вміст документа ===
0: Перший рядок
1: Другий рядок
2: Третій рядок
=======================
Скасовано вставку на позиції 2
=== Вміст документа ===
0: Перший рядок
1: Другий рядок
=======================

Чому std::deque для рядків тексту?

  • Швидка вставка у кінець (push_back) — O(1) для нових рядків.
  • Довільний доступ (operator[]) — O(1) для читання рядка за номером.
  • Краща стабільність, ніж vector — реалокація рідша (блокова структура).

Чому std::stack для історії? Природна дисципліна LIFO — останнє скасовується першим.


Резюме та рекомендації

Ключові висновки

  1. std::vector — універсальний вибір для 90% задач: швидкий довільний доступ, відмінна локальність кешу, простий інтерфейс.
  2. std::deque — для операцій на обох кінцях: якщо часто додаєте/видаляєте на початку та в кінці, deque ефективніший за vector.
  3. std::list — рідкісний вибір: використовуйте лише якщо справді потрібна вставка/видалення у довільних позиціях O(1) і розмір контейнера великий (> 10 000).
  4. std::array — для фіксованих розмірів: якщо розмір відомий на етапі компіляції, array дає нульові накладні витрати.
  5. Не вгадуйте — вимірюйте: якщо сумніваєтесь у виборі, напишіть бенчмарк і порівняйте продуктивність на реальних даних.

Зв'язок із попередніми та наступними темами

Зв'язок із власними реалізаціями (статті 89–92):

  • Ви тепер розумієте під капотом, як працюють контейнери STL, оскільки самі реалізували аналогічні структури.
  • std::vector — це ваш власний динамічний масив із IntArray, але більш оптимізований.
  • std::list — це ваш LinkedList із статті 90, але з додатковими оптимізаціями.
  • std::unordered_map (наступна лекція) — це ваша хеш-таблиця зі статті 92, але промислового рівня.

Зв'язок із наступними темами:

  • Наступна лекція (стаття 94): Асоціативні контейнери (std::map, std::unordered_map) та адаптери (std::stack, std::queue, std::priority_queue).
  • Стаття 95: Алгоритми STL (std::sort, std::find, std::accumulate) — універсальні функції, що працюють з ітераторами будь-яких контейнерів.
Перехід від навчання до практики: Тепер, коли ви знаєте як працюють контейнери, використовуйте готові рішення STL у своєму коді. Писати власні контейнери — це навчальна вправа, а не промислова практика. STL — це ваш інструмент для реальних проєктів.

Практичні завдання


На наступній лекції ми розглянемо асоціативні контейнери (std::map, std::set, std::unordered_map) та адаптери контейнерів (std::stack, std::queue, std::priority_queue) — завершуючи огляд контейнерів STL перед переходом до алгоритмів.

Copyright © 2026