Главная страница

анти. Guide to Competitive


Скачать 2.39 Mb.
НазваниеGuide to Competitive
Дата20.12.2022
Размер2.39 Mb.
Формат файлаpdf
Имя файлаанти.pdf
ТипGuide
#854732
страница6 из 26
1   2   3   4   5   6   7   8   9   ...   26
Глава
5
Структуры данных
В этой главе мы познакомимся с наиболее важными структурами дан- ных из стандартной библиотеки C++. В олимпиадном программировании чрезвычайно важно знать, какие структуры данных имеются в стандарт- ной библиотеке и как ими пользоваться. Часто это позволяет сэкономить много времени при реализации алгоритма.
В разделе 5.1 сначала описывается структура вектора, который по сущест- ву является эффективным динамическим массивом. Затем обсуждаются двусторонние очереди (деки), стеки и очереди.
В разделе 5.2 обсуждаются множества, отображения и очереди с прио- ритетом. Эти динамические структуры данных часто являются строитель- ными блоками алгоритмов, поскольку поддерживают эффективный поиск и обновление.
В разделе 5.3 приведены некоторые результаты, касающиеся практиче- ской эффективности структур данных. Как мы увидим, существуют важ- ные различия в производительности, которые нельзя обнаружить, зная только оценки временной сложности.
5.1. Динамические массивы
В C++ обыкновенные массивы – это структуры фиксированного размера, т. е. после создания изменить размер уже нельзя. Например, в следующем фрагменте создается массив, содержащий n целых значений:
int array[n];
Динамическим называется массив, размер которого можно изменять в процессе выполнения программы. В стандартной библиотеке C++ имеется несколько динамических массивов, но полезнее всего вектор.
5.1.1. Векторы
Вектор – это динамический массив, который позволяет эффективно добавлять элементы в конце и удалять последние элементы. Например, в следующем коде создается пустой вектор, в который затем добавляется три элемента:

5.1. Динамические массивы

75
vector<int> v;
v.push_back(3); // [3]
v.push_back(2); // [3,2]
v.push_back(5); // [3,2,5]
Доступ к элементам осуществляется так же, как в обычном массиве:
cout << v[0] << "\n"; // 3
cout << v[1] << "\n"; // 2
cout << v[2] << "\n"; // 5
Еще один способ создать вектор – перечислить все его элементы:
vector<int> v = {2,4,2,5,1};
Можно также задать число элементов и их начальное значение:
vector<int
> a(8); // размер 8, начальное значение 0
vector<int
> b(8,2); // размер 8, начальное значение 2
Функция size возвращает число элементов вектора. В следующем коде мы обходим вектор и печатаем его элементы:
for (int i = 0; i < v.size(); i++) {
cout << v[i] << "\n";
}
Обход вектора можно записать и короче:
for (auto x : v) {
cout << x << "\n";
}
Функция back возвращает последний элемент вектора, а функция pop_back удаляет последний элемент:
vector<int> v = {2,4,2,5,1};
cout << v.back() << "\n"; // 1
v.pop_back();
cout << v.back() << "\n"; // 5
Векторы реализованы так, что функции push_back и pop_back в среднем имеют сложность O(1). На практике работать с вектором почти так же быст ро, как с массивом.
5.1.2. Итераторы и диапазоны
Итератором называется переменная, которая указывает на элемент структуры данных. Итератор begin указывает на первый элемент струк-

76
Глава 5. Структуры данных туры, а итератор end
– на позицию за последним элементом. Например, в случае вектора v
, состоящего из восьми элементов, ситуация может вы- глядеть так:
[ 5, 2, 3, 1, 2, 5, 7, 1 ]


v.begin() v.end()
Обратите внимание на асимметрию итераторов: begin()
указывает на элемент, принадлежащий структуре данных, а end()
ведет за пределы структуры данных.
Диапазоном называется последовательность соседних элементов струк- туры данных. Чаще всего диапазон задается с помощью двух итераторов: указывающего на первый элемент и на позицию за последним элементом.
В частности, итераторы begin()
и end()
определяют диапазон, содержащий все элементы структуры данных.
Функции из стандартной библиотеки C++ обычно применяются к диа- пазонам. Так, в следующем фрагменте сначала вектор сортируется, затем порядок его элементов меняется на противоположный, и, наконец, эле- менты перемешиваются в случайном порядке.
sort(v.begin(),v.end());
reverse(v.begin(),v.end());
random_shuffle(v.begin(),v.end());
К элементу, на который указывает итератор, можно обратиться, вос- пользовавшись оператором *. В следующем коде печатается первый эле- мент вектора:
cout << *v.begin() << "\n";
Более полезный пример: функция lower_bound возвращает итератор на первый элемент отсортированного диапазона, значение которого не мень-
ше x, а функция upper_bound
– итератор на первый элемент, значение кото- рого не больше x:
vector<int> v = {2,3,3,5,7,8,8,8};
auto a = lower_bound(v.begin(),v.end(),5);
auto b = upper_bound(v.begin(),v.end(),5);
cout << *a << " " << *b << "\n"; // 5 7
Отметим, что эти функции правильно работают, только если заданный диапазон отсортирован. В них применяется двоичный поиск, так что для поиска запрошенного элемента требуется логарифмическое время. Если искомый элемент не найден, то функция возвращает итератор на пози- цию, следующую за последним элементом диапазона.

5.1. Динамические массивы

77
В стандартной библиотеке C++ много полезных функций, заслуживаю- щих внимания. Например, в следующем фрагменте создается вектор, со- держащий уникальные элементы исходного вектора в отсортированном порядке:
sort(v.begin(),v.end());
v.erase(unique(v.begin(),v.end()),v.end());
5.1.3. Другие структуры данных
Двусторонней очередью (деком) называется динамический массив, допус- кающий эффективные операции с обеих сторон. Как и вектор, двусторон- няя очередь предоставляет функции push_back и pop_back
, но вдобавок к ним функции push_front и pop_front
. Используется она следующим образом:
deque<int> d;
d.push_back(5); // [5]
d.push_back(2); // [5,2]
d.push_front(3); // [3,5,2]
d.pop_back(); // [3,5]
d.pop_front(); // [5]
Операции двусторонней очереди в среднем имеют сложность O(1). Од- нако постоянные множители для них больше, чем для вектора, поэтому использовать двусторонние очереди имеет смысл, только когда требуется выполнять какие-то действия на обеих сторонах структуры.
C++ предоставляет также специализированные структуры данных, по умолчанию основанные на двусторонней очереди. Для стека определены функции push и pop
, позволяющие вставлять и удалять элементы в конце структуры, а также функция top
, возвращающая последний элемент без удаления:
stack<int> s;
s.push(2); // [2]
s.push(5); // [2,5]
cout << s.top() << "\n"; // 5
s.pop(); // [2]
cout << s.top() << "\n"; // 2
В случае очереди элементы вставляются в начало, а удаляются из конца.
Для доступа к первому и последнему элементам служат функции front и back queue<int> q;
q.push(2); // [2]

78
Глава 5. Структуры данных q.push(5); // [2,5]
cout << q.front() << "\n"; // 2
q.pop(); // [5]
cout << q.back() << "\n"; // 5
5.2. Множества
Множеством называется структура данных, в которой хранится набор эле- ментов. Основные операции над множествами – вставка, поиск и удале- ние. Множества реализованы так, что все эти операции эффективны, что часто позволяет улучшить время работы алгоритмов, в которых множест- ва используются.
5.2.1. Множества и мультимножества
В стандартной библиотеке C++ имеются две структуры, относящиеся к множествам:
• set основана на сбалансированном двоичном дереве поиска, его операции работают за время O(log n);
• unordered_set основана на хеш-таблице и работает в среднем
1
O(1).
Обе структуры эффективны, и во многих случаях годится любая. По- скольку используются они одинаково, в примерах мы ограничимся только структурой set
В показанном ниже коде создается множество, содержащее целые числа, и демонстрируются некоторые его операции. Функция insert добавляет элемент во множество, функция count возвращает количество вхождений элемента во множество, а функция erase удаляет элемент из множества.
set<int> s;
s.insert(3);
s.insert(2);
s.insert(5);
cout << s.count(3) << "\n"; // 1
cout << s.count(4) << "\n"; // 0
s.erase(3);
s.insert(4);
cout << s.count(3) << "\n"; // 0
cout << s.count(4) << "\n"; // 1
Важным свойством множеств является тот факт, что все их элементы
различны. Следовательно, функция count всегда возвращает 0 (если эле- мент не принадлежит множеству) или 1 (если принадлежит), а функция
1
В худшем случае временная сложность операций составляет O(n), но это крайне маловероятно.

5.2. Множества

79
insert никогда не добавляет элемент во множество, если он в нем уже при- сутствует. Это демонстрируется в следующем фрагменте:
set<int> s;
s.insert(3);
s.insert(3);
s.insert(3);
cout << s.count(3) << "\n"; // 1
Множество в основном можно использовать как вектор, однако доступ к элементам с помощью оператора
[]
невозможен. В следующем коде пе- чатается количество элементов во множестве, а затем эти элементы пере- бираются:
cout << s.size() << "\n";
for (auto x : s) {
cout << x << "\n";
}
Функция find
(x)возвращает итератор, указывающий на элемент со зна- чением x. Если же множество не содержит x, то возвращается итератор end()
auto it = s.find(x);
if (it == s.end()) {
// x не найден
}
Упорядоченные множества. Основное различие между двумя структу- рами множества в C++ – то, что set
упорядочено, а unordered_set не упоря- дочено. Поэтому если порядок элементов важен, то следует пользоваться структурой set
Рассмотрим задачу о нахождении наименьшего и наибольшего значе- ний во множестве. Чтобы сделать это эффективно, необходимо использо- вать структуру set
. Поскольку элементы отсортированы, найти наимень- шее и наибольшее значения можно следующим образом:
auto first = s.begin();
auto last = s.end(); last--;
cout << *first << " " << *last << "\n";
Отметим, что поскольку end()
указывает на позицию, следующую за по- следним элементом, то необходимо уменьшить итератор на единицу.
В структуре set имеются также функции lower_bound
(x)и upper_bound
(x), которые возвращают итератор на наименьший элемент множества, зна-

80
Глава 5. Структуры данных чение которого не меньше x или больше x соответственно. Если искомого элемента не существует, то обе функции возвращают end()
cout << *s.lower_bound(x) << "\n";
cout << *s.upper_bound(x) << "\n";
Мультимножества. В отличие от множества, в мультимножество один и тот же элемент может входить несколько раз. В C++ имеются структуры multiset и unordered_multiset
, похожие на set и unordered_set
. В следую- щем коде в мультимножество три раза добавляется значение 5.
multiset<int> s;
s.insert(5);
s.insert(5);
s.insert(5);
cout << s.count(5) << "\n"; // 3
Функция erase удаляет все копии значения из мультимножества.
s.erase(5);
cout << s.count(5) << "\n"; // 0
Если требуется удалить только одно значение, то можно поступить так:
s.erase(s.find(5));
cout << s.count(5) << "\n"; // 2
Отметим, что во временной сложности функций count и erase имеет- ся дополнительный множитель O(k), где k – количество подсчитываемых
(удаляемых) элементов. В частности, подсчитывать количество копий зна- чения в мультимножестве с помощью функции count
неэффективно.
5.2.2. Отображения
Отображением называется множество, состоящее из пар ключ-значе- ние. Отображение можно также рассматривать как обобщение массива.
Если в обыкновенном массиве ключами служат последовательные целые числа 0, 1, …, n − 1, где n – размер массива, то в отображении ключи могут иметь любой тип и необязательно должны быть последовательными.
В стандартной библиотеке C++ есть две структуры отображений, соот- ветствующие структурам множеств: в основе map лежит сбалансирован- ное двоичное дерево со временем доступа к элементам O(log n), а в основе unordered_map
– техника хеширования со средним временем доступа к эле- ментам O(1).
В следующем фрагменте создается отображение, ключами которого яв- ляются строки, а значениями – целые числа:

5.2. Множества

81
mapint> m;
m["monkey"] = 4;
m["banana"] = 3;
m["harpsichord"] = 9;
cout << m["banana"] << "\n"; // 3
Если в отображении нет запрошенного ключа, то он автоматически до- бавляется, и ему сопоставляется значение по умолчанию. Например, в сле- дующем коде в отображение добавляется ключ «aybabtu» со значением 0.
mapint> m;
cout << m["aybabtu"] << "\n"; // 0
Функция count проверяет, существует ли ключ в отображении:
if (m.count("aybabtu")) {
// ключ существует
}
В следующем коде печатаются все имеющиеся в отображении ключи и значения:
for (auto x : m) {
cout << x.first << " " << x.second << "\n";
}
5.2.3. Очереди с приоритетом
Очередь с приоритетом – это мультимножество, которое поддерживает вставку, а также извлечение и удаление минимального или максимально- го элемента (в зависимости от типа очереди). Вставка и удаление занима- ют время O(log n), а извлечение – время O(1).
Очередь с приоритетом обычно основана на структуре пирамиды (heap), представляющей собой двоичное дерево специального вида. Структура multiset и так предоставляет все операции, которые определены в очереди с приоритетом, и даже больше, но у очереди с приоритетом есть достоинст- во – меньшие постоянные множители в оценке временной сложности.
Поэтому если требуется только найти минимальный или максимальный элемент, то лучше использовать очередь с приоритетом, а не множество или мультимножество.
По умолчанию элементы очереди с приоритетом в C++ отсортированы в порядке убывания, так что поддерживаются поиск и удаление наибольше- го элемента, что и продемонстрировано в следующем коде:
priority_queue<int> q;
q.push(3);

82
Глава 5. Структуры данных q.push(5);
q.push(7);
q.push(2);
cout << q.top() << "\n"; // 7
q.pop();
cout << q.top() << "\n"; // 5
q.pop();
q.push(6);
cout << q.top() << "\n"; // 6
q.pop();
Для создания очереди с приоритетом, поддерживающей поиск и удале- ние наименьшего элемента, нужно поступить так:
priority_queue<int,vector<int>,greater<int>> q;
5.2.4. Множества, основанные на политиках
Компилятор g++ предоставляет также несколько структур данных, не входящих в стандартную библиотеку C++. Они называются структурами,
основанными на политиках (policy-based structure). Для их использования в программу нужно включить такие строки:
#include
using namespace __gnu_pbds;
После этого можно определить структуру данных indexed_set
, которая похожа на множество, но допускает индексирование как массив. Для зна- чений типа int определение выглядит так:
typedef tree<int,null_type,less<int>, rb_tree_tag,
tree_order_statistics_node_update> indexed_set;
А создается множество так:
indexed_set s;
s.insert(2);
s.insert(3);
s.insert(7);
s.insert(9);
Особенность этого множества состоит в том, что доступ можно осуществ- лять по индексу, который элемент имел бы в отсортированном массиве.
Функция find_by_order возвращает итератор, указывающий на элемент в заданной позиции:

5.3. Эксперименты

83
auto x = s.find_by_order(2);
cout << *x << "\n"; // 7
А функция order_of_key возвращает позицию заданного элемента:
cout << s.order_of_key(7) << "\n"; // 2
Если элемент отсутствует во множестве, то мы получим позицию, в ко- торой он находился бы, если бы присутствовал:
cout << s.order_of_key(6) << "\n"; // 2
cout << s.order_of_key(8) << "\n"; // 3
Время работы обеих функций логарифмическое.
5.3. Эксперименты
В этом разделе мы приведем некоторые результаты, касающиеся практи-
ческой эффективности описанных выше структур данных. Хотя временная сложность – отличный инструмент, она не всегда сообщает всю правду об эффективности, поэтому имеет смысл провести эксперименты с настоя- щими реализациями и наборами данных.
5.3.1. Сравнение множества и сортировки
Многие задачи можно решить, применяя как множества, так и сорти- ровку. Важно понимать, что алгоритмы на основе сортировки обычно го- раздо быстрее, даже если это не очевидно из одного лишь анализа времен- ной сложности.
В качестве примера рассмотрим задачу о вычислении количества уни- кальных элементов вектора. Одно из возможных решений – поместить все элементы во множество и вернуть размер этого множества. Поскольку по- рядок элементов не важен, можно использовать как set
, так и unordered_
set
. Можно решить задачу и по-другому: сначала отсортировать вектор, а затем обойти его элементы. Подсчитать количество уникальных элемен- тов отсортированного вектора просто.
В табл. 5.1 приведены результаты эксперимента, в котором оба алгорит- ма тестировались на случайных векторах чисел типа int
. Оказалось, что алгоритм на основе unordered_set примерно в два раза быстрее алгоритма на основе set
, а алгоритм на основе сортировки быстрее алгоритма на ос- нове set более чем в 10 раз. Отметим, что временная сложность обоих ал- горитмов равна O(n log n), и тем не менее алгоритм на основе сортировки работает гораздо быстрее. Причина в том, что сортировка – простая опера- ция, тогда как сбалансированное двоичное дерево поиска, применяемое в реализации set
, – сложная структура данных.

84
Глава 5. Структуры данных
Таблица 5.1. Результаты эксперимента по вычислению количества уникальных элемен- тов вектора. Первые два алгоритма вставляют элементы во множество, а последний сортирует вектор, а затем просматривает соседние элементы
Размер входных данных
set
(с)
unordered_set
(с)
Сортировка (с)
10 6
0.65 0.34 0.11 2 · 10 6
1.50 0.76 0.18 4 · 10 6
3.38 1.63 0.33 8 · 10 6
7.57 3.45 0.68 16 · 10 6
17.35 7.18 1.38
5.3.2. Сравнение отображения и массива
Отображения – удобные структуры данных, по сравнению с массивами, поскольку позволяют использовать индексы любого типа, но и постоян- ные множители велики. В следующем эксперименте мы создали вектор, содержащий n случайных целых чисел от 1 до 10 6
, а затем искали самое часто встречающееся значение путем подсчета числа вхождений каждого элемента. Сначала мы использовали отображения, но поскольку число 10 6
достаточно мало, то можно использовать и массивы.
Результаты эксперимента сведены в табл. 5.2. Хотя unordered_map при- мерно в три раза быстрее map, массив все равно почти в 100 раз быстрее.
Таким образом, по возможности следует пользоваться массивами, а не отображениями. Особо отметим, что хотя временная сложность операций unordered_map равна O(1), скрытые постоянные множители, характерные для этой структуры данных, довольно велики.
Таблица 5.2. Результаты эксперимента по вычислению самого часто встречающегося элемента вектора. В первых двух алгоритмах используются отображения, а в послед- нем – обыкновенный массив
Размер входных данных
map
(с)
unordered_map
(с)
Массив (с)
10 6
0.55 0.23 0.01 2 · 10 6
1.14 0.39 0.02 4 · 10 6
2.34 0.73 0.03 8 · 10 6
4.68 1.46 0.06 16 · 10 6
9.57 2.83 0.11
5.3.3. Сравнение очереди с приоритетом и мультимножества
Верно ли, что очереди с приоритетом действительно быстрее мульти- множеств? Чтобы выяснить это, мы провели еще один эксперимент. Мы

5.3. Эксперименты

85
создали два вектора, содержащие n случайных чисел типа int
. Сначала мы добавили все элементы первого вектора в структуру данных, а затем обо- шли второй вектор и на каждом шаге удаляли наименьший элемент из структуры данных и добавляли в нее новый элемент.
Результаты эксперимента представлены в табл. 5.3. Оказалось, что с по- мощью очереди с приоритетом эта задача решается примерно в пять раз быстрее, чем с помощью мультимножества.
Таблица 5.3. Результаты эксперимента по добавлению и удалению элементов в муль- тимножество и в очередь с приоритетом
Размер входных данных
multiset
(с)
priority_queue
(с)
10 6
1.17 0.19 2 · 10 6
2.77 0.41 4 · 10 6
6.10 1.05 8 · 10 6
13.96 2.52 16 · 10 6
30.93 5.95

1   2   3   4   5   6   7   8   9   ...   26


написать администратору сайта