Три семейства контейнеров
Все контейнеры 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.
Память вектора
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_; }
};
| Операция | Сложность | Почему |
|---|---|---|
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
Вставьте несколько ключей и посмотрите, как дерево балансируется. Красные узлы — временные, чёрные — стабильные.
Красно-чёрное дерево
#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 (в отсортированном порядке)
#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 корзин)
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²) |
|---|---|---|---|---|---|
| 10 | 1 | 3 | 10 | 33 | 100 |
| 100 | 1 | 7 | 100 | 664 | 10 000 |
| 1 000 | 1 | 10 | 1 000 | 9 966 | 1 000 000 |
| 10 000 | 1 | 13 | 10 000 | 132 877 | 100 000 000 |
| 100 000 | 1 | 17 | 100 000 | 1 660 964 | 10 000 000 000 |
Сводная таблица сложностей
| Контейнер | Доступ | Вставка | Удаление | Поиск | Порядок |
|---|---|---|---|---|---|
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 заранее, если знаете размер
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 больше недействителен
Любая операция, меняющая размер вектора, может инвалидировать итераторы. Работайте с индексами или пересоздавайте итераторы после изменения.
Задание
- Заведите
vector<int>. Добавьте 5 элементов. Выведитеsize()иcapacity()после каждого шага. Объясните, почему capacity растёт скачками. - Заранее вызовите
reserve(100)и повторите. Сколько reallocation теперь? - Заведите
map<string, int>для оценок студентов. Вставьте 5 записей. Найдите студента с максимальной оценкой. - Реализуйте подсчёт частоты слов в тексте через
map<string, int>. - Реализуйте то же самое через
unordered_map<string, int>. Сравните скорость на большом тексте (например, «Война и мир»). - Используйте
set<int>для удаления дубликатов из вектора. - Задача на выбор контейнера: дан список из миллиона чисел, нужно отвечать на запросы «есть ли число X в списке». Какой контейнер выбрать и почему?
- Объясните, почему обход
mapвсегда даёт ключи в отсортированном порядке, а обходunordered_map— нет.