11.3. Словари (map).
Словари предназначены для хранения произвольного количества элементов, в виде пар «ключ-значение». Причем к «ключам» предъявляется требование уникальности. Словари обладают широкими возможностями доступа к произвольным элементам и незначительными накладными расходами на операцию добавления нового элемента. Если в словарь вставляется новое значение по существующему ключу, то оно затирает старое значение в паре «ключ-значение».
Рисунок 11.3. Словарь экземпляров класса Film.
Поскольку словари хранят элементы в виде «ключ-значение», то принципы работы со словарями несколько отличаются от тех, что используются при работе со списками и векторами. Ниже приводится версия класса Film, которая будет использоваться для иллюстрации работы со словарем:
class Film < public: Film(const QString &title = "", int duration = 0); QString title() const < return myTitle; >void setTitle(const QString &title) < myTitle = title; >int duration() const < return myDuration; >void setDuration(int minutes) < myDuration = minutes; >private: QString myTitle; int myDuration; >; Film::Film(const QString &title, int duration)
В этой версии отсутствует числовой идентификатор фильма, поскольку теперь он будет использоваться в качестве «ключа» в словаре. Кроме того, здесь отсутствуют операторы сравнения — словари изначально упорядочивают элементы по ключу, но не по значению.
Класс словаря в STL определен под именем std::map , в файле заголовка . Ниже приводится пример объявления словаря, с целыми значениями в качестве ключей и Film -- в качестве значения:
map films;
Эквивалент в Qt -- QMap :
QMap films;
Наиболее естесственный способ заполнения словарей -- присваивать значение по заданному ключу:
films[4812] = Film("A Hard Day's Night", 85); films[5051] = Film("Seven Days to Noon", 94); films[1301] = Film("Day of Wrath", 105); films[9227] = Film("A Special Day", 110); films[1817] = Film("Day for Night", 116);
Итератор словаря предоставляет возможность доступа к паре "ключ-значение". Ключ извлекается с помощью (*it).first, а значение -- (*it).second:
map::const_iterator it = films.begin(); while (it != films.end())
Большинство компиляторов допускают запись в виде it->first и it->second, но более переносимый вариант, все таки: (*it).first и (*it).second.
Итераторы словарей в Qt несколько отличаются от итераторов словарей в STL. В Qt ключ можно получить с помощью it.key(), а значение -- it.data():
QMap::const_iterator it = films.begin(); while (it != films.end())
При обходе словаря в цикле, элементы словаря всегда упорядочены по значению ключа.
Для доступа к значениям словаря и их изменения может использоваться оператор "[ ]", однако, при попытке получить значение по несуществующему в словаре ключу, будет создан новый элемент словаря с заданным ключом и пустым значением. Чтобы избежать случайного создания пустых элементов, используйте функцию find(), чтобы получить искомый элемент:
map::const_iterator it = films.find(1817); if (it != films.end()) cerrЭта функция вернет итератор end(), если ключ отсутствует в словаре.
В примере выше, в качестве ключа использовались целые числа, однако, для этих целей могут использоваться и другие типы. Наиболее популярный -- QString, например:
map actorToNationality; actorToNationality["Doris Day"] = "American"; actorToNationality["Greta Garbo"] = "Swedish";Если необходимо хранить несколько значений с одинаковыми ключами, используйте multimap . Если необходимо хранить одни только ключи, используйте set или multiset . Qt не имеет классов, эквивалентных приведенным.
Класс QMap имеет несколько дополнительных функций, особенно удобных при работе с небольшими наборами данных. Функции QMap::keys() и QMap::values() возвращают списки QValueList ключей и значений словаря.
Пред. | В начало | След. |
Списки. | На уровень выше | Контейнеры указателей. |
Что такое словарь map
Карта или std::map представляет контейнер, где каждое значение ассоциировано с определенным ключом. И по этому ключу можно получить элемент. Причем ключи могут иметь только уникальные значения. Примером такого контейнера может служить словарь, где каждому слову сопоставляется его перевод или объяснение. Поэтому такие структуры еще называют словарями.
Стандартная библиотека C++ предоставляет два типа словарей: std::map и std::unordered_map . Эти типы представляют шаблоны, которые типизируются двумя типами. Первый тип - Key задает тип для ключей, а второй тип - Value устанавливает тип для значений.
Тип std::map определен в заголовочном файле . Определение пустого словаря:
#include #include int main() < std::mapproducts; >
Здесь определен словарь products, который будет условно хранить цену товаров. Для ключей будет применяться тип std::string , а для значений - числа типа unsigned (условно в качестве ключа будет выступать название товара, а в качестве значения - его цена).
Обращение к элементам
Для обращения к элементам словаря - получения или изменения их значений, так же, как в массиве или векторе, применяется оператора индексирования [] . Только вместо целочисленных индексов можно использовать ключи любого типа в следующем виде:
map[ключ]=значение
#include #include int main() < std::mapproducts; // установка значений products["bread"] = 30; products["milk"] = 80; products["apple"] = 60; // получение значений std::cout
Здесь определен словарь products, в котором ключами служат строки, а значениями - числа типа unsigned. Поэтому для установки элемента в квадратные скобки передается ключ-строка, а присваивается значение-число:
products["bread"] = 30;
Будем считать, что ключ - название товара, а значение - цена товара. То есть в данном случае элементу с ключом "bread" присваивается значение 30. При этом не важно, что ранее создан пустой словарь, и в нем нет никакого элемента с ключом "bread" - если его нет, то он создается. Если же элемент с данным ключом уже есть, то меняется его значение.
Чтобы получить элемент по определенному ключу, используем тот же синтаксис. Например, поскольку значение элемента - число, то мы можем, обратившись по ключу, получить это число:
unsigned breadPrice = products["bread"];
В выше приведенной программе просто выводим значения элементов на консоль:
bread 30 milk 80 apple 60
Перебор элементов:
Для перебора элементов можно применять цикл for в стиле "for-each":
#include #include int main() < std::mapproducts; // установка значений products["bread"] = 30; products["milk"] = 80; products["apple"] = 60; for (const auto& [product, price] : products) std::cout
Рассмотрим определение цикла. Каждый элемент словаря фактически представляет объект типа std::pair , который хранит, как ключ, так и значение. В нашем случае это объект std::pair . И с помощью полей first и second данного объекта мы могли бы получить соответственно ключ и значение элемента:
for (const auto& element : products) std::coutНо начиная со стандарта С++17 также можно использовать другой синтаксис, который позволяет сразу разложить объект на отдельные части - ключ и значение:
for (const auto& [product, price] : products) std::coutВ данном случае в product будет помещаться ключ, а в price - значение элемента словаря. В итоге при выполнении программы мы получим следующий консольный вывод:
apple 60 bread 30 milk 80Обратите внимание, что элементы располагаются в словаре и соответственно выводятся на консоль по возрастанию ключей. Поскольку ключи представляют строки, то они сортируются в алфавитном порядке.
Инициализация элементов
Тот факт, что в словаре элементы представляют тип std::pair, позволяет инициализировать словарь объектами std::pair:
#include #include int main() < std::mapproducts < std::pair, std::pair, std::pair >; >И даже можно сократить определение:
#include #include int main() < std::mapproducts < , , >; >Удаление элементов
Как было показано выше, для добавления элемента в словарь достаточно просто установить для некоторого ключа какой-нибудь значение. Для удаления же элементов применяется функция erase() , в которую передается ключ удаляемого элемента:
#include #include int main() < std::mapproducts < , , >; products.erase("milk"); // удаляем элемент с ключом "milk" for (const auto& [product, price] : products) std::coutРазмер словаря
Для получения количества элементов в словаре применяется функция size() . Также класс map имеет функцию empty() , которая возвращает true , если словарь пуст.
#include #include int main() < std::mapproducts < , , >; std::coutПроверка наличия элемента
Чтобы проверить, есть ли в словаре элемент с определенным ключом, применяются функции count() (возвращает 1, если элемент есть, и 0 - если отсутствует) и contains() (возвращает true, если элемент есть, и false - если отсутствует). В обе функции передается ключ элемента:
#include #include int main() < std::mapproducts < std::pair, std::pair, std::pair >; std::coutНеупорядоченные словари
Тип std::map определяет словарь, который упорядочиваниет все свои элементы - по умолчанию в порядке возрастания ключей. Если упорядоченность не нужна, можно применять ти std::unordered_map , который в целом предоставляет тот же самый функционал, только не упорядочивает элементы и определен в заголовочном файле
#include #include int main() < std::unordered_mapproducts < std::pair, std::pair, std::pair >; for (const auto& [product, price] : products) std::cout
apple 60 milk 80 bread 30Итераторы
Стоит отметить, что итераторы типа std::map являеются константными, что не позволяет изменять значения элементов при переборе:
#include #include int main() < std::mapphoneBook < , , >; for(auto iter; iter != phoneBook.end(); iter++) < std::cout first << "\t" second // для получения итераторов также можно использовать функции cbegin и cend for(auto iter; iter != phoneBook.cend(); iter++) < std::cout first << "\t" second >Шесть картинок, как создать словарь
Словарь - это абстрактный тип данных, который связывает ключи со значениями. Его ещё называют ассоциативный массив, карта, таблица символов, коллекция. Будет две статьи на эту тему, где мы покажем шесть картинок / способов реализации словаря, которые отличаются друг от друга по времени работы и по требованию к памяти.
- Линейный массив
- Односвязный список
- Отсортированный массив
- Двоичное дерево поиска
- Хэш-таблица
- Префиксное дерево
Дом для кота, Интерфейс для словаря
Начнём созидание словаря с формулировки методов для работы с ним. Минимальный набор таких методов: put - добавить, get - получить.
По хорошему, мы должны создать обобщённый интерфейс с возможностью указать любой тип данных как для ключа, так и для значения. Однако, в рамках этой работы мы будем для удобства использовать string для ключей, int для значений. Ещё в словаре могут быть методы для удаления ключей, для итерации по всем записям, получение количества элементов и много других важных и приятных мелочей, которые мы опустим.
Пример кода на языке Java
interface IStorage < Integer get(String key); void put(String key, Integer value); >public class Main < public static void main(String args[]) < Main test = new Main(); test.runMapExample(new ArrayStorage()); >void runMapExample(IStorage map) < map.put("cat", 30); map.put("fox", 50); map.put("art", 10); map.put("owl", 80); map.put("box", 20); map.put("dog", 40); System.out.println(map.get("art")); System.out.println(map.get("box")); System.out.println(map.get("cat")); System.out.println(map.get("dog")); System.out.println(map.get("fox")); System.out.println(map.get("owl")); >>
Картинка первая. Линейный массив.
Самый простой способ для хранения пар «ключ, значение» - это создание класса или структуры Node (узел) и декларация линейного массива сих пар.
Для поиска узла по ключу необходимо просканировать весь массив от начала до конца и в каждом узле проверять, не совпало ли значение. Если совпало - вернуть найденное значение, если сканирование завершилось, а значение так и не было найдено - рапортуем, что не найдено. На это нужно N операций.
class ArrayStorage implements IStorage < int size = 0; Node[] array = new Node[1]; Node empty = new Node(null, null); class Node < public String key; public Integer value; public Node(String key, Integer value) < this.key = key; this.value = value; >> Node find(String key) < for (int j = 0; j < size; j ++) if (key.equals(array[j].key)) return array[j]; return empty; >public Integer get(String key)Для добавления новой пары достаточно в конец массива вписать новый узел, это быстро, всего 1 операция. Однако, прежде чем добавить новый узел, мы должны убедиться, что в словаре нет узла с точно таким же значением ключа, для этого необходимо вызвать метод поиска. Если такой ключ есть - мы записываем новое значение на место старого.
void resize() < Node[] newArray = new Node[size * 2 + 1]; for (int j = 0; j < size; j ++) newArray[j] = array[j]; array = newArray; >public void put(String key, Integer value) < Node node = find(key); if (node.key == null) < if (size == array.length) resize(); array[size++] = new Node(key, value); >else node.value = value; > >
Сколько памяти выделять для словаря? Какого размера массив необходимо декларировать для эффективной работы? Если размер массива будет совпадать с количеством элементов, то при каждом добавлении пары необходимо пересоздавать массив увеличенного размера и заниматься копированием значений из старого массива в новый, а это, на минуточку, N действий для каждой операции добавления элемента.
Чтобы избежать тьмы лишних действий, можно выделять массив удвоенного размера, тогда процесс пересоздания массива добавит к сложности алгоритма лишь Log N операций, однако размер требуемой памяти удвоится.
Сложность поиска: N операций = O(N).
Сложность вставки: N (поиск) + Log N (пересоздание) + 1 (вставка) = O(N).
Требование к памяти: N (словарь) + N (резерв) = O(N).Вывод: простой, но медленный способ, двойной расход памяти
Исходный код Storage1_Array можно посмотреть и запустить здесь.
Картинка вторая. Односвязный список.
Есть идеи по экономии памяти, но чтобы при этом не нужно было тратиться на дополнительные операции по пересозданию массива и копированию элементов? Ответ лежит на поверхности: односвязный список! Это динамическая структура, которая занимает ровно столько место, сколько в ней элементов и позволяет легко добавлять новые сущности.
Как видим, в класс узла добавилось ещё одно поле next - указатель на следующий элемент списка (между нами говоря, накладные расходы на необходимость хранить этот указатель могут свести на нет то, ради чего мы, собственно, и).
Сложность поиска узла по ключу не изменилась - нам вновь необходимо сканировать весь список от начала до конца, прыгая по кочкам списка.
class LinkedListStorage implements IStorage < Node root = null; Node empty = new Node(null, null, null); class Node < public String key; public Integer value; public Node next; public Node(String key, Integer value, Node next) < this.key = key; this.value = value; this.next = next; >> Node find(String key) < for (Node curr = root; curr != null; curr = curr.next) if (key.equals(curr.key)) return curr; return empty; >public Integer get(String key)Сложность добавления элемента значительно ускорился - нет дополнительных расходов на пересоздание массива, а новые пары удобнее всего добавлять в начало списка. Обратите внимание, что на рисунке порядок расположения пар реверсивен: кот был первым, а стал последним.
public void put(String key, Integer value) < Node node = find(key); if (node.key == null) root = new Node(key, value, root); else node.value = value; >>
Сложность поиска: N операций = O(N).
Сложность вставки: N (поиск) + 1 (вставка) = O(N).
Требование к памяти: N (словарь) = O(N).Вывод: простой, но медленный способ, эффективен по памяти.
Исходный код Storage2_LinkedList можно посмотреть и запустить здесь.
А можно ли как-то ускорить процесс поиска ключей? Линейное время, это слишком медленно и применимо лишь на малых размерах словаря. Можно.
Картина третья. Отсортированный список.
В книжных словарях слова расположены по алфавиту, что позволяет искать их значительно быстрее, за логарифмическое время. Например, для поиска ключа в отсортированном списке из 1000 записей достаточно сделать всего десять сравнений, потому как 2^10 = 1024.
Давайте и мы попробуем располагать ключи в алфавитном порядке.
Теперь для поиска ключа можно применить бинарный поиск: смотрим, какой элемент посередине списка находится и рекурсивно продолжаем поиск либо в первой половине, либо во второй. Время поиска O(Log N).
class SortedStorage implements IStorage < int size = 0; Node[] array = new Node[1]; class Node < public String key; public Integer value; public Node(String key, Integer value) < this.key = key; this.value = value; >> int findIndex(String key) < int left = 0; int right = size - 1; while (left < right) < int mid = (left + right) / 2; if (key.equals(array[mid].key)) return mid; // found! if (key.compareTo(array[mid].key) >0) left = mid + 1; // key < mid.key, move to right else right = mid - 1; // key >mid.key, move to left > if (left < size && key.compareTo(array[left].key) >0) left++; // key must be placed at the right of [left] return left; > public Integer get(String key)Для добавления новой пары в словарь нужно сначала найти индекс в массиве, куда следует вставить пару, чтобы ключи располагались в алфавитном порядке, на это нужно Log N операций. Затем нужно сдвинуть все элементы на одну позицию вперёд и в освободившееся место разместить новую пару. Хотел бы обратить внимание, что сдвигать элементы нужно с хвоста поезда.
public void put(String key, Integer value) < int j = findIndex(key); if (j < size && key.equals(array[j].key)) < array[j].value = value; return; >if (size == array.length) resize(); for (int t = size; t > j; t--) array[t] = array[t - 1]; array[j] = new Node(key, value); size++; > void resize() < Node[] newArray = new Node[size * 2 + 1]; for (int j = 0; j < size; j ++) newArray[j] = array[j]; array = newArray; >>
На сдвиг элементов может потребоваться до N действий. Возможны дополнительные операции на пересоздание и копирование массива в размере Log N.
Сложность поиска: Log N операций = O(Log N).
Сложность вставки: Log N (поиск) + Log N (пересоздание) + N (сдвиг) + 1 (вставка) = O(N).
Требование к памяти: N (словарь) + N (резерв) = O(N).Вывод: логарифмический поиск, линейное добавление, двойной расход памяти.
Исходный код Storage3_SortedArray можно посмотреть и запустить здесь.
Кстати, а можно ли уменьшить расходы по памяти, используя для хранения пар односвязный отсортированный список? Увы, нельзя. Потому как мы можем только скользить по односвязному списку, без возможности перехода к любому элементу по индексу. А отличительная черта обычного массива - это моментальный доступ к любому элементу по его индексу, что и используется при двоичном поиске ключа в отсортированном массиве.
Впрочем, мы можем отказаться от излишних копирований и сдвигов элементов, если для хранения словаря воспользуемся двоичным деревом поиска.
Но об этом в следующей статье, а также на бесплатном уроке, который пройдет уже 20 октября.
- Зарегистрироваться на бесплатный урок
Знакомство с коллекцией HashMap
В Java есть еще одна интересная коллекция (в широком смысле) — это коллекция Map . Точного перевода ее названия на русский нет: чаще всего ее называют «карта», «словарь» или просто «мапа».
Эта коллекция похожа на коллекцию Set , только хранит не множество элементов, а множество «пар элементов». Каждая пара элементов Map состоит из двух: «ключ» и «значение».
Допустим, вы хотите хранить в программе имена сотрудников компании, их зарплаты или имена ваших коллег и их возраст. Тогда вам бы понадобилась таблица типа такой:
Имя | Возраст |
---|---|
Сергей | 21 |
Николай | 22 |
Иван Петрович | 48 |
Анюта | ? |
В каждой строке тут хранится пара величин. Имя мы будем называть ключом пары , а возраст — значением пары .
Весь набор таких пар и будет называться картой — Map .
Ключом пары может быть что угодно, но у некоторых типов карт ключ не может быть null . Ключи должны быть уникальные: одна карта не может содержать два одинаковых ключа.
2. Класс HashMap
Класс HashMap является самой популярной коллекцией из всех карт ( Map ). С одной стороны, он очень похож на HashSet и имеет все его методы, а с другой — на список ( ArrayList ), если бы индексами у списка могли быть не числа, а слова.
Создать объект типа HashMap можно с помощью команды вида:
HashMapTКлюч, TЗначение> имя = new HashMapTКлюч, TЗначение>();
Где TКлюч — это тип ключей из пары элементов, TЗначение — тип значений в паре элементов, которые будут храниться в коллекции HashMap .
У класса HashMap есть такие методы:
void put(ТКлюч key, ТЗначение value)
ТЗначение get(ТКлюч key)
boolean containsKey(ТКлюч key)
boolean containsValue(ТЗначение value)
ТЗначение remove(ТКлюч key)
void clear()
int size()
SetТКлюч> keySet()
CollectionТЗначение> values()
SetMap.EntryTКлюч, TЗначение>> entrySet()
Добавление элементов в HashMap
Элементы добавляются в карту сразу парами: для этого используется метод put() . Первым в него передается ключ, вторым — значение.
HashMapString, Integer> map = new HashMapString, Integer>(); map.put("Серега", 21); map.put("Николай", 22); map.put("Иван Петрович", 48); map.put("Анюта", null);
Если при добавлении элемента выяснится, что элемент с таким ключом уже есть, старое значение ключа заменится на новое.
Такое поведение делает HashMap похожим на массив или список, если бы у них в качестве индексов выступали слова ( String ), а не числа.
В качестве Типа-Ключа и Типа-Значения могут выступать практически любые типы. Есть небольшие дополнительные требования к Типу-Ключу, но о них вы узнаете при детальном изучении коллекций в квесте Java Collections.
3. Подмножества HashMap : множество ключей
Допустим мы хотим просто вывести все элементы HashMap на экран, как нам это сделать? Для этого нужно понять, как пройтись по всем элементам HashMap . Это можно сделать разными способами.
Самый простой способ – использовать цикл по ключам
У элементов класса HashMap нет порядкового номера, поэтому цикл со счетчиком тут не подойдет. Зато мы можем получить множество ключей с помощью метода keySet () , а как пройтись по множеству вы уже знаете:
HashMapString, Integer> map = new HashMapString, Integer>(); map.put("Серега", 21); map.put("Николай", 22); map.put("Иван Петрович", 48); map.put("Анюта", null); for (String key: map.keySet()) < Integer value = map.get(key); System.out.println(key + " --> " + value); >
Цикл по всем ключам map
Метод keySet() возвращает множество ключей. Можно использовать это множество двумя способами:
for (String key: map.keySet()) < Integer value = map.get(key); System.out.println(key + " --> " + value); >
Set keys = map.keySet(); for (String key: keys) < Integer value = map.get(key); System.out.println(key + " --> " + value); >
4. Использование цикла по парам
Есть и более сложный способ: можно преобразовать Map в множество пар элементов , а потом использовать цикл по элементам множества, как мы уже раньше учили.
В коллекции HashMap есть вспомогательный класс для хранения пары элементов. Выглядит он примерно так:
class EntryKeyType, ValueType> < private KeyType key; private ValueType value; public KeyType getKey() < return this.key; > public ValueType getValue() < return this.value; > >
Результат вызова метода entrySet () у объекта типа HashMap < ТКлюч, ТЗначение >будет иметь тип Set < Entry < ТКлюч, ТЗначение >> :
SetEntryКлюч, Значение>> имя = map.entrySet();
Тут мы видим сложный тип Set с параметром-значением, а в качестве параметра-значение выступает еще один сложный тип ( Entry ), так еще и с двумя параметрами.
Новичку очень легко в этом запутаться. Хотя, если разберетесь, сможете писать код вида:
HashMapString, Integer> map = new HashMapString, Integer>(); map.put("Серега", 21); map.put("Николай", 22); map.put("Иван Петрович", 48); map.put("Анюта", null); Set, Integer>> entries = map.entrySet(); for(Map.EntryString, Integer> pair: entries) < String key = pair.getKey(); Integer value = pair.getValue(); System.out.println(key + " --> " + value); >
Хотя этот код можно и немножко упростить:
Во-первых, можно не создавать отдельную переменную для entries , а сразу вызвать метод entrySet () внутри цикла for :
for(Map.EntryString, Integer> pair: map.entrySet()) < String key = pair.getKey(); Integer value = pair.getValue(); System.out.println(key + " --> " + value); >
Во-вторых, можно воспользоваться недавно появившимся оператором var для автоматического выведения типа пары элементов :
for(var pair: map.entrySet()) < String key = pair.getKey(); Integer value = pair.getValue(); System.out.println(key + " --> " + value); >
5. Сравнение ArrayList vs HashMap
HashMap сильно напоминает ArrayList , у которого в качестве индексов используются не цифры, а слова (или другой тип ключей).
А если в качестве ключа в HashMap использовать Integer , он становится еще более похожим на ArrayList . Сравните:
ArrayList list = new ArrayList(); list.add("Привет"); list.add("Hello"); String s = list.get(0); list.set(0, s + "!"); for (String item: list) < System.out.println(item); >
HashMap map = new HashMap(); map.put(0, "Привет"); map.put(1, "Hello"); String s = map.get(0); map.put(0, s + "!"); for (String item: map.values()) < System.out.println(item); >