Добавил:
Upload Опубликованный материал нарушает ваши авторские права? Сообщите нам.
Вуз: Предмет: Файл:
Итог_Пособие C++.doc
Скачиваний:
8
Добавлен:
01.07.2025
Размер:
2 Мб
Скачать
☆

5.7 Библиотека стандартных шаблонов (stl) – основные понятия

Библиотека стандартных шаблонов (STL, Standard Template Library) является частью стандартной библиотеки языка C++. STL предоставляет программистам готовые реализации наиболее востребованных на практике структур данных и алгоритмов, что позволяет программистам не "изобретать велосипед" и повышает эффективность разработки надёжных и переносимых программ.

В библиотеке можно выделить следующие основные компоненты:

1. Контейнеры – шаблонные классы для хранения объектов в памяти. Контейнеры можно разделить на 4 вида: последовательные, ассоциативные, контейнеры-адаптеры и псевдоконтейнеры.

2. Итераторы – специальные шаблонные классы, предназначенные для доступа к элементам внутри контейнера.

3. Алгоритмы – шаблонные функции, реализующие различные алгоритмы над данными в контейнерах – например, сортировку, поиск и др.

4. Функциональные объекты (функторы) – вспомогательные шаблонные классы, в которых определён operator(). Основное их предназначение – изменение поведения алгоритмов STL (например, вместо сортировки по возрастанию выполнять сортировку по убыванию).

5. Некоторые вспомогательные шаблонные классы. Например, класс std::pair предназначен для хранения произвольных пар элементов и содержит два поля: first − первый элемент, second − второй элемент.

Заметим, что библиотека STL весьма обширна. В данной главе мы лишь познакомимся с основами её использования.

5.8 Последовательные контейнеры. Итераторы

К последовательным контейнерам относятся шаблонные классы std::vector, std::deque и std::list. На практике класс std::vector используется крайне широко, тогда как два оставшихся класса применяются довольно редко.

Класс std::vector можно рассматривать как альтернативу обычным динамическим массивам. При использовании векторов значительно снижается риск утечек памяти, поскольку освобождение памяти происходит автоматически в деструкторе данного класса (который вызовется даже при возникновении исключения).

Также большим плюсом вектора является возможность автоматически увеличивать свой размер при вставке элементов. Рассмотрим подробнее, как это происходит. Внутри вектора имеется указатель на блок памяти (динамический массив), в котором реально хранятся данные. Для того, чтобы можно было эффективно добавлять новые элементы в конец вектора, размер этого блока памяти обычно берётся с некоторым запасом. Для демонстрации этого приведём следующий код:

std::vector<int> a(4);

a.push_back(5);

std::cout << a.size() << " " << a.capacity() << std::endl;

Функция size возвращает количество элементов в векторе, а функция capacity − размер динамического массива, выделенного для хранения данных. Для компилятора, которым пользовался автор, на экран будет выведено "5 8" − то есть реально в массиве хранится пять элементов, но память выделена под хранение восьми. Это означает, что если мы продолжим добавление в вектор, то следующие три вызова функции push_back выполнятся максимально эффективно (с константной временной сложностью).

Что произойдёт при попытке выполнить вставку в вектор в четвёртый раз? Поскольку место для хранения элементов закончится, то будет выделен новый блок памяти большего размера, все текущие элементы скопируются из старого блока памяти в новый, а старая память будет освобождена. Таким образом, четвёртый вызов функции push_back выполнится существенно медленней, чем предыдущие − с линейной временной сложностью. При этом размер зарезервированной памяти снова увеличился.

Примечание. Насколько именно увеличивается размер зарезервированной памяти, стандартом не регламентируется, поэтому в разных компиляторах может отличаться (обычно в полтора-два раза).

Заметим, что хотя отдельные вызовы push_back могут выполняться с линейной сложностью, но средняя (амортизационная) вычислительная сложность данной функции является константной.

Для обращения к элементам вектора имеются два основных способа. Во-первых, вектор поддерживает эффективное обращение по индексу, используя операцию [] или функцию at, например:

a[2] = 5;

a.at(2) = 5;

Отличие между этими вариантами состоит в том, что в функции at проверяется выход индекса за границы, а в операции [] в целях производительности такая проверка не делается (точнее, проверка может выполняться, если проект компилируется в конфигурации Debug, но в конфигурации Release она отключается).

Во-вторых, как и для других контейнеров, для обращения к элементам вектора можно использовать итераторы.

Итератор − специальный объект, ссылающийся на элемент внутри контейнера. По своему использованию итератор похож на обычный указатель. Например, операция ++ перемещает итератор на следующий элемент контейнера, операция -- − на предыдущий элемент (поддерживается для двунаправленных итераторов). Операция разыменования * возвращает ссылку на текущий элемент. Итераторы произвольного доступа (для векторов и деков) поддерживают также операции прибавления и вычитания целого числа. При этом итератор смещается вперёд или назад на соответствующее число элементов.

Любой контейнер содержит методы begin и end, где функция begin возвращает итератор на первый элемент, а функция end − на воображаемый несуществующий элемент, следующий за последним. Для примера напишем вывод на консоль всех элементов вектора с использованием итератора:

std::vector<int> a = {1, 2, 3, 4, 5};

for (std::vector<int>::iterator it=a.begin(); it!=a.end(); it++){

std::cout << *it << " ";

}

Используя итераторы, важно понимать, что при изменении контейнера некоторые итераторы могут стать недействительными. Например, пусть у нас есть итератор, ссылающийся на какой-то элемент вектора. Вставим в вектор несколько новых элементов. Если в процессе вставки в векторе закончится свободное место, будет выполнено перераспределение памяти. При этом все предыдущие итераторы станут недействительными, а попытка их использования приведёт к неопределённому поведению программы:

std::vector<int> a;

a.push_back(1);

std::vector<int>::iterator it = a.begin();

a.push_back(2); a.push_back(3); a.push_back(4);

*it = 8; // ошибка - неопределённое поведение

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

Перечислим кратко некоторые полезные методы, которые имеются в классе std::vector.

• assign − устанавливает новое содержимое для вектора, заменяя его старое содержимое. Пример: a.assign(10, 1); − вектор a будет содержать 10 целых чисел, каждое из которых равно 1.

• resize − изменяет размер вектора. Если размер увеличивается, то новые элементы заполняются значением по умолчанию либо указанным значением. Пример: a.resize(10, 1); устанавливает размер вектора равным 10 элементам. Предыдущие 10 значений вектора сохраняются. Если в векторе было меньше десяти элементов, то новые элементы будут равны единице.

• empty − логическая функция, которая возвращает true, если вектор пуст.

• size − возвращает размер вектора (то есть количество элементов в нём).

• capacity − возвращает ёмкость вектора (количество элементов, которое может быть в нём сохранено перед тем как потребуется перераспределить память).

• reserve − требует установить ёмкость не меньше, чем указанное значение. Пример: a.reserve(100); означает, что если изначально вектор был пуст, то как минимум следующие сто вызовов push_back не вызовут перераспределения памяти. Может использоваться для небольшого повышения производительности, если заранее известно, сколько элементов будет добавляться.

• clear − очистка вектора.

• pop_back − удаляет последний элемент. При этом ёмкость вектора не уменьшается.

• shrink_to_fit − уменьшает ёмкость (capacity) вектора, устанавливая её равной размеру вектора. Данную функцию удобно использовать после большого количества удалений из вектора для экономии памяти, поскольку ёмкость вектора автоматически не уменьшается никогда.

• insert − выполняет вставку в заданную позицию. Пример: a.insert(a.begin(), 8); − число 8 будет вставлено в начало вектора, остальные элементы сдвинутся вправо. Для вектора данная операция имеет линейную сложность.

• erase − выполняет удаление из заданной позиции одного элемента или указанный диапазон элементов. Пример: a.erase(a.begin()); − удалить начальный элемент; a.erase(a.begin() + 1, a.begin() + 3); − удалить элементы с позиции 1 по позицию 3. Последующие элементы при этом сдвинутся влево. Для вектора данная операция имеет линейную сложность.

Заметим, что многие из перечисленных методов имеются не только в классе std::vector, но и в других контейнерах.

В качестве более сложного примера на применение векторов решим следующую задачу. Имеется N городов, пронумерованных от 1 до N. Некоторые пары городов соединены двухсторонними дорогами (всего дорог M). Требуется найти город, из которого выходит больше всего дорог (если таких несколько, то первый из них), и вывести его, а также города, с которыми он соединён.

Для решения этой задачи будем для каждого города запоминать, какие дороги из него выходят. Для этого создадим вектор, элементами которого будут вектора из целых чисел. Такая структура данных часто используется для представления графов в памяти.

// Пример 5.12 - поиск города с максимальным числом дорог

#include <stdio.h>

#include <vector>

int main() {

int n, m;

scanf("%d %d", &n, &m);

std::vector<std::vector<int> > g(n + 1);

for (int i = 1; i <= m; i++) {

int v1, v2;

scanf("%d %d", &v1, &v2);

g[v1].push_back(v2);

g[v2].push_back(v1);

}

int ans = 1;

for (int i = 2; i <= n; i++)

if (g[i].size() > g[ans].size())

ans = i;

printf("%d\n", ans);

for (int to : g[ans])

printf("%d ", to);

}

В заключение данного подраздела поговорим немного о классах std::list и std::deque.

Класс std::list представляет собой двухсвязный список. Основное его преимущество по сравнению с вектором − высокая эффективность выполнения операций insert и erase для текущей позиции списка. Однако, для их использования нужно иметь итератор, указывающий на текущую позицию. На практике данный класс используется довольно редко.

Класс std::deque реализует структуру данных "дек" − двухстороннюю очередь, где возможны эффективная вставка и удаление элементов с обоих концов. Кроме методов push_back и pop_back, в данном классе имеются методы push_front и pop_front, которые работают тоже с константной (в среднем) алгоритмической сложностью.

Как и в векторе, в деке реализована операция индексирования [], которая тоже выполняется с константной алгоритмической сложностью. Однако, на практике она работает несколько медленней, чем в векторе, из-за более сложного внутреннего устройства дека (данные внутри дека хранятся не в одном непрерывном блоке памяти, как в векторе, а в нескольких блоках, связанных между собой).