Последовательные контейнеры: vector, deque, list, array
Изучите последовательные контейнеры STL — vector, deque, list, forward_list и array. Узнайте об их компромиссах производительности, расположении в памяти и правилах инвалидации итераторов.
Что такое последовательные контейнеры?
Последовательные контейнеры хранят элементы в строгом линейном порядке. STL предоставляет пять последовательных контейнеров, каждый из которых оптимизирован для различных паттернов доступа и изменения:
- std::vector — динамический массив, непрерывная память
- std::deque — двусторонняя очередь, быстрая вставка с обоих концов
- std::list — двусвязный список
- std::forward_list — односвязный список
- std::array — массив фиксированного размера, размещённый на стеке
Выбор правильного контейнера — одно из наиболее значимых решений в C++ с точки зрения производительности. Ключевые факторы: паттерн доступа, паттерн вставки/удаления и локальность кэша.
std::vector — выбор по умолчанию
std::vector — наиболее часто используемый контейнер. Он хранит элементы в одном непрерывном блоке памяти, что обеспечивает отличную производительность кэша и произвольный доступ за O(1). Добавление элементов с помощью push_back выполняется за амортизированное O(1), так как вектор удваивает свою ёмкость при заполнении.
#include <iostream>
#include <vector>
int main() {
std::vector<int> nums; // empty vector
nums.reserve(100); // pre-allocate for 100 elements (no reallocation)
for (int i = 0; i < 10; ++i) {
nums.push_back(i * i); // amortized O(1)
}
// Size vs capacity
std::cout << "Size: " << nums.size() << '\n'; // 10
std::cout << "Capacity: " << nums.capacity() << '\n'; // >= 100
// Random access — O(1)
std::cout << "Element 3: " << nums[3] << '\n'; // 9
std::cout << "Element 3: " << nums.at(3) << '\n'; // 9 (bounds-checked)
// Erase-remove idiom (pre-C++20)
nums.erase(std::remove_if(nums.begin(), nums.end(),
[](int x) { return x > 50; }),
nums.end());
// C++20: std::erase_if (cleaner)
std::erase_if(nums, [](int x) { return x > 30; });
// Range-based for loop
for (int n : nums) {
std::cout << n << ' ';
}
std::cout << '\n';
}deque, list и forward_list
std::deque обеспечивает вставку и удаление за O(1) с обоих концов, но использует сегментированную схему памяти (массив блоков фиксированного размера) вместо одного непрерывного блока. std::list — это двусвязный список, предлагающий вставку/удаление за O(1) в любом месте при наличии итератора, но с плохой локальностью кэша. std::forward_list — это односвязный список, использующий меньше памяти на узел, но поддерживающий только прямой обход.
#include <deque>
#include <list>
#include <forward_list>
#include <iostream>
int main() {
// deque — O(1) push_front and push_back
std::deque<int> dq;
dq.push_front(1); // O(1) — vector cannot do this efficiently
dq.push_back(2); // O(1)
dq.push_front(0); // O(1)
// dq: {0, 1, 2}
// list — O(1) insert/erase at any position with iterator
std::list<std::string> names = {"Alice", "Charlie", "Eve"};
auto it = std::next(names.begin()); // points to "Charlie"
names.insert(it, "Bob"); // O(1) insert before "Charlie"
names.erase(it); // O(1) erase "Charlie"
// names: {"Alice", "Bob", "Eve"}
// list has splice — O(1) move of entire sublists
std::list<std::string> more = {"Frank", "Grace"};
names.splice(names.end(), more); // moves all of 'more' into 'names'
// names: {"Alice", "Bob", "Eve", "Frank", "Grace"}
// more is now empty
// forward_list — singly linked, no size(), push_front only
std::forward_list<int> fl = {3, 1, 4, 1, 5};
fl.push_front(0);
fl.sort(); // member sort — O(n log n), stable
fl.unique(); // remove consecutive duplicates
// fl: {0, 1, 3, 4, 5}
for (int v : fl) std::cout << v << ' ';
std::cout << '\n';
}std::array — фиксированный размер, нулевые накладные расходы
std::array оборачивает массив в стиле C в полноценный класс, предоставляя .size(), .at(), итераторы и совместимость со всеми алгоритмами STL — с нулевыми накладными расходами по сравнению с «сырым» T[N].
#include <array>
#include <algorithm>
#include <iostream>
int main() {
std::array<int, 5> arr = {5, 2, 8, 1, 9};
std::sort(arr.begin(), arr.end()); // works with STL algorithms
std::cout << "Size: " << arr.size() << '\n'; // constexpr — known at compile time
std::cout << "Front: " << arr.front() << '\n'; // 1
std::cout << "Back: " << arr.back() << '\n'; // 9
// Bounds-checked access
try {
arr.at(10); // throws std::out_of_range
} catch (const std::out_of_range& e) {
std::cout << "Out of range: " << e.what() << '\n';
}
// Compile-time size via std::tuple_size
constexpr std::size_t n = std::tuple_size_v<decltype(arr)>; // 5
static_assert(n == 5);
}Выбор правильного последовательного контейнера
Используйте это руководство при выборе:
Используйте std::vector (по умолчанию), когда вам нужен произвольный доступ, кэш-дружественная итерация или вставка/удаление в конце. Подавляющее большинство кода должно использовать vector.
Используйте std::deque, когда нужна частая вставка/удаление с обоих концов (например, скользящее окно, очередь BFS).
Используйте std::list, когда нужна вставка/удаление за O(1) в середине при наличии итератора или когда вам нужен splice() для перемещения подсписков между списками без копирования.
Используйте std::forward_list, когда важен расход памяти на узел и достаточен только прямой обход.
Используйте std::array, когда размер известен во время компиляции и никогда не изменится.
Примечание о производительности: из-за эффектов кэша процессора std::vector часто превосходит std::list даже при вставках в середину на современном оборудовании, при удивительно больших размерах (тысячи элементов). Всегда измеряйте, прежде чем выбирать связный список.
Каждый контейнер имеет свои правила инвалидации итераторов, указателей и ссылок:
vector — вставка, вызывающая реаллокацию, инвалидирует все итераторы. Вставка без реаллокации инвалидирует итераторы в точке вставки и после неё. Удаление инвалидирует итераторы в точке удаления и после неё.
deque — вставка в начало или конец инвалидирует все итераторы, но не ссылки/указатели на существующие элементы. Вставка в середину инвалидирует всё. Удаление из начала/конца инвалидирует только итератор удалённого элемента.
list / forward_list — вставка и удаление никогда не инвалидируют итераторы, ссылки или указатели на другие элементы. Инвалидируется только итератор удалённого элемента.
array — итераторы никогда не инвалидируются (массив не может изменить размер).
Частая ошибка — изменение vector внутри цикла range-based for. Если push_back вызовет реаллокацию, скрытые итераторы цикла станут висячими.
Если вы знаете (или можете оценить), сколько элементов будет содержать vector, вызовите reserve() перед его заполнением. Это предварительно выделит память и позволит избежать затратных реаллокаций:
- reserve(n) изменяет ёмкость, но не размер — элементы не добавляются.
- resize(n) изменяет размер — фактически конструирует элементы по умолчанию.
- shrink_to_fit() — необязательный запрос на уменьшение ёмкости до размера.
Для «горячих» циклов, создающих векторы, один вызов reserve() может значительно сократить накладные расходы на выделение памяти. Измеряйте на больших наборах данных, чтобы увидеть разницу.
std::vector— выбор по умолчанию: непрерывная память означает кэш-дружественную итерацию и произвольный доступ за O(1)- Используйте
reserve()для предварительного выделения памяти вектора, когда приблизительный размер известен std::dequeобеспечивает O(1) push/pop с обоих концов, но не является непрерывнымstd::listпредлагает O(1) вставку/удаление с итератором и никогда не инвалидирует другие итераторыstd::array— обёртка с нулевыми накладными расходами над массивами C с полной совместимостью с STL- Правила инвалидации итераторов различаются для каждого контейнера — знайте их, чтобы избежать ошибок с висячими итераторами
Тест — проверьте свои знания
(15 XP)1. Какой последовательный контейнер обеспечивает амортизированный O(1) `push_back` и непрерывное расположение в памяти?
2. Что происходит со всеми итераторами вектора, когда `push_back` вызывает реаллокацию?
3. В чём разница между `reserve(n)` и `resize(n)` для вектора?