Ассоциативные контейнеры сопоставляют ключам некоторые значения.
В стандартной библиотеке есть ассоциативные контейнеры, основанные на сбалансированных деревьях поиска (map
, set
) и контейнеры, основанные на хеш-таблицах (unordered_map
, unordered_set
). В этих контейнерах ключи уникальны, то есть, не могут повторяться. Также существуют и multi
-версии этих контейнеров, в которых допускаются повторы ключей.
Так как C++ — статически типизированный язык, типы ключей и значений должны быть строго зафиксированы на этапе компиляции.
Контейнер std::map
Начнём с контейнера std::map
. Он определен в заголовочном файле map
. Аналогично вектору, std::map
является шаблонным: в угловых скобках нужно указать типы ключей и значений.
Рассмотрим пример:
#include <iostream>
#include <map>
#include <string>
int main() {
// инициализируем map набором пар {ключ, значение}
std::map<std::string, int> years = {
{"Moscow", 1147},
{"Rome", -753},
{"London", 47},
};
for (const auto& [city, year] : years) {
std::cout << city << ": " << year << "\n";
}
}
Вывод программы:
London: 47 Moscow: 1147 Rome: -753
При итерации с помощью ranged-based for
возвращаются пары std::pair
из константного ключа и значения. Для итерации по элементам мы использовали structured binding, прикрепив ссылки city
и year
к элементам возвращаемой пары, а также auto
для автоматического вывода типа. Согласитесь, это удобнее, чем такая форма записи:
for (const std::pair<const std::string, int>& item : years) {
std::cout << item.first << ": " << item.second << "\n";
}
Контейнер map
реализован как красно-чёрное дерево — сбалансированное дерево поиска с особыми свойствами. Поэтому его элементы при итерации обходятся в порядке возрастания ключей, а на самих ключах должен быть определён оператор <
для сравнения.
Внутренне устройство красно-чёрных деревьев мы оставим за рамками этого учебника: для нас важнее научиться пользоваться таким контейнером и знать, что три основных операции — поиск, вставка и удаление элемента — выполняются за логарифмическое время () от числа элементов в контейнере. Покажем, как воспользоваться этими операциями.
#include <iostream>
#include <map>
#include <string>
int main() {
std::map<std::string, int> data;
std::string key;
int value;
while (std::cin >> key >> value) {
data[key] = value; // вставка
}
data.erase("hello"); // удаление
// поиск
if (auto iter = data.find("test"); iter != data.end()) {
std::cout << "Found the key " << iter->first << " with the value " << iter->second << "\n";
} else {
std::cout << "Not found\n";
}
}
Рассмотрим эту программу подробнее.
Для вставки мы использовали обращение по ключу в квадратных скобках: data[key] = value
. В отличие от вектора или дека, ключ теперь не обязательно является индексом: в нашем случае это строка. Альтернативные способы вставки — data.insert({key, value})
или data.insert_or_assign(key, value)
.
Эти функции принимают пару из ключа и значения, поэтому нам пришлось обрамить в фигурные скобки key
и value
, чтобы экземпляр std::pair
сконструировался на лету. Если ключ key
уже существует в контейнере, то data[key] = value
и функция insert_or_assign
его перезапишут, а insert
— нет (но вернет информацию о старом значении).
Удаляя элемент по ключу, можно не заботиться о его наличии в контейнере: если ключа нет, то функция erase
просто ничего не поменяет.
Для поиска элемента мы вызываем функцию find
, которая возвращает итератор. Мы пользуемся версией if
с инициализатором, чтобы сразу сохранить этот итератор в переменную iter
и потом проверить его значение. Такая переменная будет видна только внутри условного оператора: таким образом мы подчеркнём, что iter
нам нужен только здесь. Итератор будет либо указывать на пару из найденного ключа и его значения, либо окажется равен значению data.end()
, если ключ не найден. Обратиться к найденной паре можно через унарную звёздочку или стрелочку (iter->first
означает (*iter).first
). Это похоже на указатели, но важно понимать, что итератор ассоциативного контейнера — это не указатель, а самостоятельный объект.
Вернёмся ещё раз к конструкции data[key]
. Она возвращает ссылку на значение, которому можно что-то присвоить. Сначала она проверяет, есть ли уже такой ключ в контейнере. Если ключа нет, он тут же вставляется в контейнер со значением по умолчанию (0 для int
). Затем возвращается ссылка на значение в контейнере.
Такое поведение оператора []
требует, чтобы контейнер data
был изменяемым. Поэтому выражение data[key]
не скомпилируется, если data
— константа:
void Check(const std::map<std::string, int>& data) {
if (data["total"] > 0) { // ошибка компиляции!
// ...
}
}
Если мы уверены, что ключ в контейнере есть, то можно воспользоваться функцией at
:
void Check(const std::map<std::string, int>& data) {
if (data.at("total") > 0) { // OK, это скомпилируется
// ...
}
}
Если же ключа всё же не окажется, то at
во время работы программы сгенерирует исключение — «цивилизованную» ошибку, которую можно перехватить и обработать. Исключения мы обсудим в параграфе «Обработка исключений».
Задача о подсчёте частот слов
Рассмотрим классическую задачу: построить частотный словарь из текстового файла. Возьмём для тестирования файл text8
с сайта mattmahoney.net. Это архив со словами статей из английской «Википедии» общим объёмом 100 мегабайт. Его часто используют для оценки эффективности алгоритмов сжатия и для машинного обучения систем обработки текстов.
Напишем программу, которая подсчитывает количество повторов для каждого слова. Слова будем читать со стандартного ввода просто до ближайшего пробельного разделителя.
#include <iostream>
#include <map>
#include <string>
int main() {
std::map<std::string, int> freqs;
std::string word;
while (std::cin >> word) {
++freqs[word];
}
for (const auto& [word, freq] : freqs) {
std::cout << word << "\t" << freq << "\n";
}
}
Посмотрите, как мы элегантно добавляем очередное слово в ассоциативный массив через ++freqs[word]
! Если слово уже встречалось, то мы просто увеличиваем его частоту. Если такого слова не было, то во freqs
автоматически вставится такой ключ со значением 0, и мы тут же увеличим это значение на 1.
Запустим программу, передав ей на вход файл text8
, и посмотрим на первые 10 строк её вывода:
$ clang++ --std=c++20 -o count_freqs count_freqs.cpp $ ./count_freqs < text8 | head a 325873 aa 276 aaa 57 aaaa 7 aaaaaacceglllnorst 1 aaaaaaccegllnorrst 1 aaaaaah 1 aaaaaalmrsstt 1 aaaaaannrstyy 1 aaaaabbcdrr 1
Мы уже знаем, что итерация по контейнеру map
обходит узлы в порядке возрастания ключей. Поэтому мы увидели первые «слова» по алфавиту. Позже мы попробуем сделать так, чтобы слова были отсортированы по убыванию частоты. А сейчас давайте попробуем замерить время работы нашей программы. Скомпилируем её с максимальным уровнем оптимизаций (ключ -O3
) и направим её вывод в /dev/null
(он нам не нужен). Замеряем время с помощью консольной утилиты time.
$ clang++ --std=c++20 -O3 -o count_freqs count_freqs.cpp $ time ./count_freqs < text8 > /dev/null real 0m5,541s user 0m5,500s sys 0m0,040s
Конечно, реальное время работы зависит от процессора и других факторов. Но нельзя ли обработать файл быстрее, чем за пять с половиной секунд? В этом нам поможет контейнер std::unordered_map
.
Контейнер std::unordered_map
Воспользуемся другой реализацией ассоциативного массива из стандартной библиотеки C++ — хеш-таблицей unordered_map
. Само название этого класса подчёркивает, что данные будут храниться не упорядоченными по ключу. Предполагается, что для каждого ключа определена хеш-функция (по умолчанию std::hash<Key>()
), а по ней вычисляется номер корзины (bucket), в которую должен попасть ключ.
Случай, когда два разных ключа оказываются в одной корзине, называется коллизией. В С++ для разрешения коллизий используется метод цепочек, то есть, внутри одной корзины все элементы выстраиваются в односвязный список.
Если хеш-функция достаточно равномерна и корзин достаточно много, то в среднем время поиска, добавления и удаления элементов для unordered_map
будет константным ().
Интерфейс unordered_map
специально сделан похожим на интерфейс map
. Нам будет достаточно заменить только заголовочный файл и имя контейнера:
#include <iostream>
#include <string>
#include <unordered_map>
int main() {
std::unordered_map<std::string, int> freqs;
std::string word;
while (std::cin >> word) {
++freqs[word];
}
for (const auto& [word, freq] : freqs) {
std::cout << word << "\t" << freq << "\n";
}
}
Порядок обхода теперь выглядит произвольным (но на самом деле он диктуется хеш-функцией):
$ clang++ --std=c++20 -o count_freqs count_freqs.cpp $ ./count_freqs < text8 | head storerooms 2 fretensis 1 metzada 1 workmans 1 mikhailgorbachev 1 naevus 3 buildups 1 clandenstine 1 democratised 1 wilgoren 2
Время работы сократилось с 5,5 до 3,1 секунды:
$ clang++ --std=c++20 -O3 -o count_freqs count_freqs.cpp $ time ./count_freqs < text8 > /dev/null real 0m3,117s user 0m3,080s sys 0m0,036s
У контейнера unordered_map
есть функция max_load_factor
, которая задаёт максимально допустимое соотношение между числом элементов и количеством корзин. По умолчанию эта величина равна единице, так что unordered_map
пытается в среднем вообще избежать коллизий. Но это не означает отсутствия коллизий в отдельных корзинах.
Если при вставке очередного элемента среднее число элементов в корзинах превышает этот порог, число корзин автоматически увеличивается и происходит рехеширование. Чем-то это напоминает реаллокацию у вектора.
Если нам заранее известно финальное количество ключей, то можно вызвать заранее функцию reserve
и избежать лишних рехеширований при вставках. Тем самым можно отыграть дополнительное время:
#include <iostream>
#include <string>
#include <unordered_map>
int main() {
std::unordered_map<std::string, int> freqs;
freqs.reserve(300'000); // можно использовать апостроф для выделения разрядов
std::string word;
while (std::cin >> word) {
++freqs[word];
}
for (const auto& [word, freq] : freqs) {
std::cout << word << "\t" << freq << "\n";
}
}
$ clang++ --std=c++20 -O3 -o count_freqs count_freqs.cpp $ time ./count_freqs < text8 > /dev/null real 0m3,067s user 0m3,035s sys 0m0,032s
Из контейнера в контейнер
Вернёмся к сортировке слов по убыванию частоты. Для этого проще всего будет переложить слова с частотами в вектор пар и отсортировать его, используя свою функцию сравнения:
#include <algorithm>
#include <iostream>
#include <string>
#include <tuple>
#include <unordered_map>
#include <vector>
int main() {
std::unordered_map<std::string, int> freqs;
std::string word;
while (std::cin >> word) {
++freqs[word];
}
// копируем пары в вектор, используя шаблонный конструктор от двух итераторов:
std::vector<std::pair<std::string, int>> sortedByFreq(
freqs.begin(),
freqs.end()
);
// сортируем с помощью своей лямбда-функции:
std::sort(
sortedByFreq.begin(),
sortedByFreq.end(),
[](const auto& p1, const auto& p2) {
// сначала сравниваем частоты по убыванию, потом — слова по возрастанию
return std::tie(p2.second, p1.first) < std::tie(p1.second, p2.first);
}
);
for (const auto& [word, freq] : sortedByFreq) {
std::cout << word << "\t" << freq << "\n";
}
}
Здесь мы элегантно копируем данные из unordered_map
в вектор, указывая при инициализации переменной sortedByFreq
пару итераторов другого контейнера. Цикл, копирующий элементы из этого диапазона, скрыт в конструкторе вектора.
Посмотрим на первые 10 результатов с помощью консольной утилиты head. Теперь мы в самом деле получили наиболее частотные слова из файла:
$ clang++ --std=c++20 -o count_freqs count_freqs.cpp $ ./count_freqs < text8 | head the 1061396 of 593677 and 416629 one 411764 in 372201 a 325873 to 316376 zero 264975 nine 250430 two 192644
Контейнеры std::set
и std::unordered_set
Контейнеры std::set
и std::unordered_set
похожи на map
и unordered_map
по внутреннему устройству, но они хранят только ключи, без ассоциированных значений. Вот как можно выписать повторяющиеся слова текста в алфавитном порядке (по одному разу каждое):
#include <iostream>
#include <set>
#include <string>
#include <unordered_set>
int main() {
// здесь будем хранить все слова (каждое по одному разу)
std::unordered_set<std::string> words;
// здесь будем хранить повторяющиеся слова
// используем set, а не unordered_set, чтобы потом напечатать их по алфавиту
std::set<std::string> duplicate_words;
std::string word;
while (std::cin >> word) {
if (words.contains(word)) {
duplicate_words.insert(word);
} else {
words.insert(word);
}
}
for (const auto& word : duplicate_words) {
std::cout << word << "\n";
}
}
Здесь мы применили функцию contains
, которая появилась только в C++20. При использовании более старого стандарта нужно написать if (words.find(word) != words.end())
.
Заметим, что при попадании в else
мы ищем слово в words
дважды: один раз для проверки в contains
, а другой раз — в insert
. Можно было бы обойтись только одним поиском, воспользовавшись тем, что insert
возвращает пару из итератора на элемент и флажка с результатом поиска:
#include <iostream>
#include <set>
#include <string>
#include <unordered_set>
int main() {
std::unordered_set<std::string> words;
std::set<std::string> duplicate_words;
std::string word;
while (std::cin >> word) {
auto [iter, has_been_inserted] = words.insert(word);
if (!has_been_inserted) {
duplicate_words.insert(word);
}
}
for (const auto& word : duplicate_words) {
std::cout << word << "\n";
}
}
Название set
происходит от математического понятия множества, где элементы хранятся только по одному разу. Однако никаких теоретико-множественных операций (объединения, пересечения, разности) у set
и unordered_set
не предусмотрено. В параграфе «Алгоритмы» мы рассмотрим алгоритмы для выполнения таких операций над отсортированными последовательностями.
Мультиконтейнеры
В стандартной библиотеке C++ есть четыре мультиконтейнера:
std::multimap
(в заголовочном файлеmap
);std::multiset
(в заголовочном файлеset
);std::unordered_multimap
(в заголовочном файлеunordered_map
);std::unordered_multiset
(в заголовочном файлеunordered_set
).
Они аналогичны обычным ассоциативным контейнерам, которые мы рассматривали выше, но в мультиконтейнерах один и тот же ключ может встретиться несколько раз.
Пусть, например, мы хотим сохранять для каждого слова в текстовом файле его порядковый номер. Слова в тексте могут повторяться, поэтому воспользуемся контейнером multimap
:
#include <iostream>
#include <map>
int main() {
std::multimap<std::string, int> positions;
std::string word;
int position = 0;
while (std::cin >> word) {
positions.insert({word, position});
++position;
}
}
В этом случае мы могли бы применить вместо std::multimap<std::string, int>
контейнер std::map<std::string, std::vector<int>>
. Разница будет в использовании и в накладных расходах.
Для обхода multimap
потребуется один цикл, а для map
с вектором — два вложенных цикла (по ключам и по элементам вектора для данного ключа). Вектор имеет накладные расходы на хранение метаинформации и резерва, а в multimap
все данные будут храниться в одном сбалансированном дереве.
Наконец, итераторы multimap
стабильны, а у вектора могут инвалидироваться. Применять multimap
имеет смысл там, где повторы ключей сравнительно редки.
Итераторы ассоциативных контейнеров
Контейнеры map
, set
и их мультиверсии предоставляют двусторонние итераторы, которые можно сдвигать на соседние позиции вперёд и назад. Как и в случае последовательных контейнеров, запрещено выходить за пределы диапазона, ограниченного begin()
и end()
, и разыменовывать итератор, равный end()
. Итераторы таких контейнеров и ссылки (указатели) на элементы никогда не инвалидируются.
#include <iostream>
#include <iterator>
#include <map>
#include <string>
int main() {
std::map<int, std::string> numbers = {
{100, "hundred"},
{3, "three"},
{42, "forty two"},
{11, "eleven"},
};
auto iter = numbers.find(11);
if (iter != numbers.end()) {
// печатаем найденный элемент
const auto& [key, value] = *iter;
std::cout << "Found: " << key << ": " << value << "\n"; // Found: 11: eleven
// печатаем предыдущий элемент
if (iter != numbers.begin()) {
const auto& [key, value] = *std::prev(iter);
std::cout << "Previous: " << key << ": " << value << "\n"; // Previous: 3: three
} else {
std::cout << "No previous element\n";
}
// печатаем следующий элемент
if (auto nextIter = std::next(iter); nextIter != numbers.end()) {
const auto& [key, value] = *nextIter;
std::cout << "Next: " << key << ": " << value << "\n"; // Next: 42: forty two
} else {
std::cout << "No next element\n";
}
} else {
std::cout << "Not found\n";
}
}
Может показаться, что в строке const auto& [key, value] = *std::prev(iter)
мы строим висячие ссылки, так как возвращаемое значение функции prev
после вычисления всего выражения сразу станет невалидным. Однако константные ссылки продлевают жизнь объекта до конца текущего блока.
Итераторы unordered-контейнеров однонаправленные: их можно сдвигать только вперёд. Это связано с тем, что коллизии в хеш-таблице обычно разрешаются с помощью односвязного списка элементов, а по односвязному списку нельзя двигаться назад. Итераторы unordered-контейнеров могут инвалидироваться только если произошло рехеширование при вставке. Ссылки и указатели никогда не инвалидируются.
Отдельно отметим функцию erase
у ассоциативных контейнеров. У неё есть несколько перегруженных версий. Одна версия принимает ключ, другая — итератор удаляемого элемента, третья — диапазон итераторов. Разница будет для мультиконтейнеров: если какой-то ключ повторяется, то первая версия erase
удалит все вхождения таких ключей, а вторая — только конкретные:
#include <unordered_map>
int main() {
std::unordered_multimap<std::string, int> data = {
{"a", 1},
{"a", 2},
{"a", 3},
{"b", 4},
};
auto iter = data.find("a");
if (iter != data.end()) {
data.erase(iter); // удаляем первое найденное вхождение с ключом "a"
}
data.erase("a"); // удаляем все остальные вхождения с ключом "a"
}