STL-контейнеры: vector, map, set изнутри

Стандартная библиотека шаблонов (STL) даёт готовые структуры данных на все случаи жизни. Но выбор контейнера — это компромисс между скоростью разных операций. Здесь разберём, как устроены основные контейнеры и откуда берутся их сложности.

O(1) / O(log n) / O(n) Итераторы Reallocation Хеш-таблицы

Три семейства контейнеров

Все контейнеры STL делятся на три большие категории по способу хранения и доступа к элементам.

📏

Последовательные

Элементы хранятся в порядке добавления. Доступ по индексу или через итератор. vector, deque, list, array.

🌲

Ассоциативные (упорядоченные)

Элементы автоматически упорядочены по ключу. Обычно реализованы через красно-чёрное дерево. map, set, multimap, multiset.

#️⃣

Неупорядоченные

Элементы хранятся в хеш-таблицах. Нет порядка обхода, зато в среднем всё за O(1). unordered_map, unordered_set.

Главная идея. Нет «лучшего» контейнера. Есть контейнер, подходящий под конкретную задачу. Если часто ищем по ключу — это map или unordered_map. Если часто добавляем в конец и читаем по индексу — vector. Если нужен произвольный порядок обхода — list.

vector: динамический массив

Хранит элементы непрерывно в памяти, как обычный массив. Но, в отличие от статического массива, умеет расширяться при добавлении элементов.

Внутреннее устройство

Три указателя, определяющих состояние:

  • begin — начало выделенной памяти
  • end — первый свободный слот (size)
  • capacity_end — конец выделенного буфера (capacity)

Что такое reallocation

Когда size == capacity, а мы добавляем новый элемент, вектор выделяет новый буфер в 2 раза больше, копирует туда все элементы и удаляет старый. Именно поэтому push_back в среднем работает за O(1) — амортизированно.

Интерактивно: как работает push_back

Добавляйте элементы и следите за моментом, когда происходит reallocation.

Память вектора

size: 0 capacity: 0 reallocations: 0
$ // начните добавлять элементы
упрощённая реализация vector
template<class T>
class Vector {
    T* data;
    size_t size_;
    size_t capacity_;

    void reallocate(size_t new_cap) {
        T* new_data = new T[new_cap];
        // перемещаем существующие элементы
        for (size_t i = 0; i < size_; ++i)
            new_data[i] = std::move(data[i]);
        delete[] data;
        data = new_data;
        capacity_ = new_cap;
    }

public:
    void push_back(const T& value) {
        if (size_ == capacity_) {
            reallocate(capacity_ == 0 ? 1 : capacity_ * 2);
        }
        data[size_++] = value;
    }

    T& operator[](size_t i) { return data[i]; }   // O(1)
    size_t size() const { return size_; }
};
Опасность: инвалидация итераторов. После reallocation все указатели и итераторы на элементы вектора становятся недействительными — старый буфер удалён. Никогда не сохраняйте итераторы, если планируете добавлять в вектор.
ОперацияСложностьПочему
v[i]O(1)Прямой доступ по адресу: data + i * sizeof(T)
v.push_back(x)амортизированно O(1)Обычно O(1), но при reallocation — O(n)
v.pop_back()O(1)Просто уменьшаем size
v.insert(pos, x)O(n)Сдвигаем все элементы после pos
v.erase(pos)O(n)Сдвигаем все элементы после pos
Поиск по значениюO(n)Перебор всех элементов

map: сбалансированное дерево поиска

Хранит пары «ключ → значение», упорядоченные по ключу. Внутренне реализован как красно-чёрное дерево — самобалансирующееся бинарное дерево поиска.

Почему именно красно-чёрное дерево

  • Гарантирует высоту O(log n) после любой операции
  • Не вырождается в список, как обычный BST на отсортированных данных
  • Балансировка — за счёт O(1) перекрасок и поворотов

Что даёт упорядоченность

  • Обход всегда даёт ключи в отсортированном порядке
  • Легко найти диапазон: lower_bound, upper_bound
  • Минимум и максимум — за O(log n) или даже O(1)

Интерактивно: вставка в map

Вставьте несколько ключей и посмотрите, как дерево балансируется. Красные узлы — временные, чёрные — стабильные.

Красно-чёрное дерево

$ // пример загружен по умолчанию
использование map
#include <map>
using namespace std;

int main() {
    map<string, int> scores;

    // Вставка: O(log n)
    scores["Alice"] = 90;
    scores["Bob"]   = 85;
    scores["Carol"] = 95;

    // Поиск: O(log n)
    auto it = scores.find("Bob");
    if (it != scores.end()) {
        cout << it->first << ": " << it->second << endl;
    }

    // Обход — в алфавитном порядке ключей
    for (auto& [name, score] : scores) {
        cout << name << " - " << score << endl;
    }

    // Удаление: O(log n)
    scores.erase("Bob");
}
Обратите внимание. Обход map всегда даёт элементы в отсортированном порядке по ключу. Это следствие того, что внутренне это дерево поиска, а не хеш-таблица.
ОперацияСложностьПочему
m[key]O(log n)Поиск в дереве
m.find(key)O(log n)Поиск в дереве
m.insert(pair)O(log n)Поиск места + балансировка
m.erase(key)O(log n)Поиск + балансировка
m.begin() → m.end()O(n)Обход всех узлов
Минимум / максимумO(log n)Крайний левый/правый узел

set: только ключи, без значений

То же самое, что map, но хранит только ключи. Внутренне — тоже красно-чёрное дерево. Автоматически удаляет дубликаты.

Типичные применения

  • Проверка на дубликаты: set сам их отбросит
  • Множества: пересечение, объединение, разность
  • Быстрый поиск «есть ли элемент»: if (s.count(x))

Отличие от map

map<K, V> хранит пару «ключ → значение». set<K> хранит только ключ. Внутри — та же структура дерева, но узел содержит одно поле, а не пару.

Интерактивно: set и дубликаты

Добавляйте числа. Если значение уже есть — set его не примет.

Содержимое set (в отсортированном порядке)

$ // начните добавлять числа
использование set
#include <set>
using namespace std;

int main() {
    set<int> s;
    s.insert(5);
    s.insert(3);
    s.insert(7);
    s.insert(5);   // дубликат — не добавится

    // Размер = 3, обход даст {3, 5, 7}
    for (int x : s) cout << x << " ";

    // Проверка наличия: O(log n)
    if (s.count(5)) cout << "5 есть в set" << endl;

    // Удаление: O(log n)
    s.erase(3);
}

unordered_map / unordered_set: хеш-таблицы

Те же интерфейсы, но внутри — хеш-таблица. В среднем всё работает за O(1) вместо O(log n). Цена — потеря порядка обхода и чувствительность к качеству хеш-функции.

Как устроена хеш-таблица

Массив «корзин» (bucket). Ключ проходит через хеш-функцию, получается индекс корзины. Если в корзине уже что-то лежит — это коллизия, элементы хранятся в связанном списке внутри корзины.

Хеш-таблица (8 корзин)

$ // введите ключ и нажмите «Вставить»
Что значит «в среднем» O(1). Если хеш-функция равномерно распределяет ключи, корзины заполнены равномерно, и поиск в среднем O(1). Но при плохой хеш-функции (или при большом числе коллизий) может деградировать до O(n) — все элементы попадут в одну корзину.
map vs unordered_map
map<string, int> m;
// O(log n), обход по алфавиту

unordered_map<string, int> um;
// в среднем O(1), порядок не определён

Когда что использовать

  • Нужен порядок (например, вывод по алфавиту, поиск диапазона) → map
  • Нужна максимальная скорость и порядок не важен → unordered_map
  • Ключ — сложный тип без готового хеша → map (нужно написать только operator<)

График сложностей: насколько велика разница

При малых n разница незаметна. Но при n = 1000 или n = 100000 она становится решающей.

n O(1) O(log n) O(n) O(n log n) O(n²)
10131033100
1001710066410 000
1 0001101 0009 9661 000 000
10 00011310 000132 877100 000 000
100 000117100 0001 660 96410 000 000 000
Практический вывод. При n = 100 000 разница между O(log n) и O(n) — это 17 операций против 100 000. Разница в 6000 раз. Именно поэтому правильный выбор контейнера — это не «стилистическая придирка», а вопрос работоспособности программы.

Сводная таблица сложностей

Контейнер Доступ Вставка Удаление Поиск Порядок
vector O(1) по индексу O(1)* в конец, O(n) в середину O(1) с конца, O(n) из середины O(n) Порядок добавления
deque O(1) по индексу O(1)* с обоих концов O(1) с обоих концов O(n) Порядок добавления
list O(n) — только через итератор O(1) при известной позиции O(1) при известной позиции O(n) Порядок добавления
map Через итератор O(log n) O(log n) O(log n) По ключу, возрастание
set Через итератор O(log n) O(log n) O(log n) По значению
unordered_map Через итератор O(1) в среднем O(1) в среднем O(1) в среднем Не определён
unordered_set Через итератор O(1) в среднем O(1) в среднем O(1) в среднем Не определён

* — амортизированная сложность: в среднем O(1), но изредка происходит O(n) при расширении буфера.

Правила выбора контейнера

По умолчанию — vector

В 80% случаев вектор — оптимальный выбор. Он компактный (нет накладных расходов на указатели между узлами), кэш-дружелюбный (данные лежат подряд) и амортизированно быстрый. Используйте его, пока не увидите серьёзных причин взять что-то другое.

Часто ищете по ключу — map или unordered_map

Не пишите линейный поиск по вектору — это O(n) на каждый запрос. Если размер данных больше нескольких сотен, map или unordered_map сэкономят огромное количество времени.

Нужна максимальная скорость — unordered_map

Если порядок обхода не важен и у ключа есть готовая хеш-функция (числа, строки) — берите unordered_map. Он в среднем быстрее дерева.

reserve заранее, если знаете размер

без reallocation
vector<int> v;
v.reserve(10000);          // один раз выделяем память
for (int i = 0; i < 10000; i++)
    v.push_back(i);        // ни одной reallocation

Используйте emplace_back вместо push_back

без лишней копии
// push_back: создаёт временный объект, потом копирует
v.push_back(Student("Alice", 20));

// emplace_back: создаёт объект прямо в памяти вектора
v.emplace_back("Alice", 20);

Антипаттерн: list «на всякий случай»

Новички иногда думают, что «связный список — это профессионально». На самом деле list почти всегда медленнее vector: каждый узел — отдельное выделение памяти, нет кэш-локальности, поиск всё равно O(n). Применяйте list только для очень больших данных с частыми вставками в середину при известном итераторе.

Антипаттерн: итераторы после изменения вектора

плохой код
vector<int> v = {1, 2, 3, 4, 5};
auto it = v.begin() + 2;    // указывает на 3

v.push_back(6);              // ← REALLOCATION!
                             // старый буфер уничтожен
cout << *it;                // UB — it больше недействителен

Любая операция, меняющая размер вектора, может инвалидировать итераторы. Работайте с индексами или пересоздавайте итераторы после изменения.

Задание

  1. Заведите vector<int>. Добавьте 5 элементов. Выведите size() и capacity() после каждого шага. Объясните, почему capacity растёт скачками.
  2. Заранее вызовите reserve(100) и повторите. Сколько reallocation теперь?
  3. Заведите map<string, int> для оценок студентов. Вставьте 5 записей. Найдите студента с максимальной оценкой.
  4. Реализуйте подсчёт частоты слов в тексте через map<string, int>.
  5. Реализуйте то же самое через unordered_map<string, int>. Сравните скорость на большом тексте (например, «Война и мир»).
  6. Используйте set<int> для удаления дубликатов из вектора.
  7. Задача на выбор контейнера: дан список из миллиона чисел, нужно отвечать на запросы «есть ли число X в списке». Какой контейнер выбрать и почему?
  8. Объясните, почему обход map всегда даёт ключи в отсортированном порядке, а обход unordered_map — нет.