13. Коллекции

В Java имеется несколько способов хранения объектов (или, точнее, ссылок на объекты). Самый простой вариант хранения объектов - массивы. Массивы обеспечивают самый эффективный способ хранения групп объектов. Однако массив имеет фиксированный размер, а, в общем случае, во время написания программы разработчик не знает точное количество объектов. К тому же массивы неэффективны при большой объеме элементов. Для таких случаев, в Java и в других языках реализован механизм коллекций объектов.

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

  • добавление нового элемента в коллекцию;

  • удаление элемента из коллекции;

  • изменение элемента в коллекции.

Коллекции в Java объединены в библиотеке java.util и представляют собой контейнеры для хранения и манипулирования объектами. Основные интерфейсы коллекций:

  • Map<K, V> - карата отображения вида "ключ-значение" (такие структуры называют словарями или ассоциативными массивами);

  • Collection<E> - вершине иерархии, базовый интерфейс для коллекций;

  • List<E> - специализирует коллекции для обработки списков;

  • Set<E> - специализирует коллекции для обработки множеств, содержащих уникальные элементы.

Ниже представлены основные интерфейсы и классы коллекций.

Интерфейс Collection

Этот интерфейс служит основанием, на котором построен весь каркас коллекций, поскольку он должен быть реализован почти всеми классами коллекций (кроме коллекций, реализующих интерфейс Map). Интерфейс Collection является обобщенным, ссылочная переменная типа Collection объявляется следующим образом

Collection<String> collection;

Интерфейс Collection расширяет интерфейс Iterable. Это означает, что все коллекции можно перебирать, организовав цикл foreach. В интерфейсе Collection определяются основные методы, которые должны иметь все коллекции. Перечислим некоторые основные методы

Метод

Описание

add(Object o)

Добавляет указанный объект в коллекцию

remove(Object o)

Удаляет указанный объект из коллекции

clear()

Удаляет все элементы из коллекции

size()

Возвращает количество элементов в коллекции

iterator()

Возвращает объект, который используется для доступа к элементам коллекции

Объекты вводятся в коллекции методом add(). Следует, однако, иметь ввиду, что метод add() принимает аргумент типа E. Следовательно, добавляемые в коллекцию объекты должны быть совместимы с предполагаемым типом данных в коллекции. Вызвав метод addAll(), можно ввести все содержимое одной коллекции в другую.

Вызвав метод remove(), можно удалить из коллекции отдельный объект. Чтобы из коллекции удалить группу объектов, достаточно вызвать метод removeAll(). А для того чтобы удалить из коллекции все элементы, кроме указанных, следует вызвать метод retainAll(). Вызвав метод removeIf(), можно удалить из коллекции элемент, если он удовлетворяет условию, которое задается в качестве параметра predicate. И наконец, для полной очистки коллекции достаточно вызвать метод clear().

Имеется также возможность определить, содержит ли коллекция определенный объект, вызвав метод contains(). Чтобы определить, содержит ли одна коллекция все члены другой, следует вызвать метод containsAll(). А определить, пуста ли коллекция, можно с помощью метода isEmpty(). Количество элементов, содержащихся в данный момент в коллекции, возвращает метод size().

Оба метода toArray() возвращают массив, который содержит элементы, хранящиеся в коллекции. Первый из них возвращает массив класса Object, а второй - массив элементов того же типа, что и массив, указанный в качестве параметра этого метода. Обычно второй метод более предпочтителен, поскольку он возвращает массив элементов нужного типа. Эти методы оказываются важнее, чем может показаться не первый взгляд. Ведь обрабатывать содержимое коллекции, используя синтаксис массивов, иногда оказывается очень выгодно. Обеспечив связь коллекции с массивом, можно извлечь выгоду из обоих языковых средств Java.

Две коллекции можно сравнить на равенство, вызвав метод equals(). Точный смысл равенства может зависеть от конкретной коллекции. Например, метод equals() можно реализовать таким способом, чтобы он сравнивал значения элементов, хранимых в коллекции. В качестве альтернативы методу equals() можно сравнивать ссылки на эти элементы.

Еще один очень важный метод iterator() возвращает итератор, а метод spliterator() - итератор разделитесь для коллекции. Итераторы очень часто используются для обращения с коллекциями. И наконец, методы stream() и parallelStream() возвращают поток данных типа Stream, использующий коллекцию для своих элементов.

Интерфейс List

Список (List) представляет собой динамический массив - упорядоченный набор элементов и может содержать повторяющиеся элементы. Вы можете получить доступ к любому элементу по индексу. Список является одним из наиболее используемых типов коллекций.

Основные свойства коллекций, реализующих интерфейс List:

  • список может включать одинаковые элементы;

  • элементы в списке хранятся в том порядке, в котором они помещались;

  • можно получить доступ к любому элементу по его порядковому номеру (индексу) внутри списка.

Начиная с версии Java 9, в интерфейс List внедрен фабричный метод of(), у которого имеется целый ряд перегруженных вариантов, возвращающих неизменяемую коллекцию на основе значений, составленную из переданных аргументов.

Особое назначение фабричного метода of() - предоставить удобный, эффективный способ для создания небольшой коллекции типа List. Во всех перегружаемых вариантах данного метода не допускается указывать пустые (null) элементы создаваемого списка. И в любом случае конкретная реализация интерфейса List не указывается.

List<String> names = List.of("Петр", "Василий", "Александр");

// Выбросит исключение, так как коллекция неизменяемая (Immutable)
names.add("Сергей"); 

Существуют два основных класса, реализующих List.

Класс ArrayList

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

Так как ArrayList использует массив, то время доступа к элементу по индексу выполняется за константное время (в отличие от класса LinkedList). При удалении произвольного элемента из списка, все элементы находящиеся "правее", смещаются на одну ячейку влево, при этом реальный размер массива (его емкость, capacity) не изменяется. Если при добавлении элемента массив будет полностью заполнен, будет создан новый массив размером n * 3 / 2 + 1, в него будут помещены все элементы из старого массива плюс новый, добавляемый элемент.

Несмотря на то, что емкость объектов типа ArrayList наращивается автоматически, ее можно увеличивать и вручную. вызывая метод ensureCapacity(). Это может потребоваться в том случае, если заранее известно, что в коллекции предполагается сохранить намного больше элементов, чем она содержит в данный момент. Увеличив емкость списочного массива в самом начале его обработки, можно избежать дорогостоящей операции постепенного наращивания списка.

С другой стороны, если потребуется уменьшить размер базового массива, на основе которого строится объект типа ArrayList до текущего количества хранящихся объектов, следует вызвать метод trimToSize().

Класс LinkedList

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

LinkedList состоит из узлов, каждый из которых содержит как собственно данные, так и две ссылки ("связки") на следующий и предыдущий узел списка. Доступ к произвольному элементу осуществляется за линейное время (но доступ к первому и последнему элементу списка всегда осуществляется за константное время - ссылки постоянно хранятся на первый и последний элементы, так что добавление элемента в конец списка вовсе не значит, что придется перебирать весь список в поисках последнего элемента).

В целом, в абсолютных величинах LinkedList проигрывает ArrayList и по потребляемой памяти и по скорости выполнения операций.

В общем случае, следует использовать ArrayList. Коллекцию LinkedList имеет смысл использовать в случае, если происходит интенсивная вставка\удаление в середину списка либо необходимо гарантированное, заранее известное время добавления элемента в список.

Рассмотрим основные методы интерфейса List

Метод

Описание

void add(int index, E obj)

Добавляет в список объект obj по индексу index

boolean addAll(int index, Collection<? extends E> col)

Добавляет в список все элементы коллекции col по индексу index. Если в результате добавления список был изменен, то возвращается true, иначе возвращается false

E get(int index)

Возвращает из списка объект по индексу index

int indexOf(Object obj)

Возвращает индекс первого экземпляра заданного объекта в списке. Если заданный объект в списке отсутствует, возвращается значение -1

E remove(int index)

Удаляет объект по индексу index из списка, при этом метод возвращает удаленный объект

E set(int index, E obj)

Присваивает элементу по индексу index объект obj (фактически, записывает новую ссылку на объект поверх старой), метод возвращает старый объект

List<E> sublist(int start, int end)

Возвращает список элементов, которые находятся между индексами start и end

Рассмотрим работу основных методов, заявленных в интерфейсе List

// Создание обычного пустого списка
List<String> list = new ArrayList<>();
// Создание неизменяемого списка, Java 9+
List<String> strings = List.of("one", "two", "three", "four");

// Добавление элементов
list.add("one");
list.add("two");
list.add("three");
list.add("four");

// Удаление элемента с индексом 1
list.remove(1);

// Обход списка с помощью обычного цикла
for (int i = 0; i < list.size(); i++) {
    System.out.println(list.get(i));
}

// Обход списка с помощью foreach
for (String s : list) {
    System.out.println(s);
}

// Получение подсписка
List<String> subList = list.subList(1, 3);

// Изменение значение списка
// В строку old записывается старое значение
String old = list.set(0, "new");

Обратите внимание, что класс ArrayList преобразуется в List посредством восходящего преобразования. В идеале, большая часть кода должна взаимодействовать с этими интерфейсами (хотя это не всегда возможно), а точный тип указывается только в точке создания контейнера.

// интерфейс             класс, реализующий список
List<String> list = new ArrayList<>();

Интерфейс Queue

Интерфейс Queue (очередь) расширяет интерфейс Collection и определяет поведение очереди, которая действует как список по принципу "первым вошел - первый вышел". Иначе говоря, объекты помещаются в один "конец" очереди, а извлекаются из другого "конца". Таким образом, порядок занесения объектов в контейнер будет совпадать с порядком его извлечения оттуда. Очереди также играют важную роль в параллельном программировании, потому что они обеспечивают безопасную передачу объектов между задачами.

Существуют разные виды очередей, порядок организации в которых основывается на некотором критерии. Интерфейс Queue является обобщенным, ссылочная переменная типа Queue объявляется следующим образом

Queue<String> queue;

Перечислим основные методы, определенные в интерфейсе Queue

Метод

Описание

E element()

Возвращает элемент из головы очереди. Возращаемый элемент не удаляется. Если очередь пуста, генерируется исключение типа NoSuchElementException

boolean offer(E object)

Пытается ввести заданный object в очередь. Возвращает логическое значение true, если object введен, иначе - false

E peek()

Возвращает элемент из головый очереди. Если очередь пуста, возвращает пустое значение null. Возвращаемый элемент не удаляется из очереди

E poll()

Возвращает элемент из головый очереди и удаляет его. Если очередь пуста, возвращает значение null

E remove()

Удаляет элемент из головы очереди, возвращая его. Генерирует исключение типа NoSuchElementException, если очередь пуста

Класс LinkedList

Класс LinkedList, который мы рассматривали в связи с интерфейсом List, также реализует интерфейс Deque и поддерживает поведение очередей. Восходящее преобразование LinkedList в Queue позволяет использовать объект как очередь.

List<String> list = new LinkedList<>();
list.add("one");
list.add("two");
list.add("three");
list.add("four");

Queue<String> queue = (Queue) list;
queue.offer("five");
queue.poll();

Класс PriorityQueue

Класс PriorityQueue расширяет класс AbstractQueue и реализует интерфейс Queue. Он служит для сздания очереди по приоритетам на основании компаратора очереди.

В приоритетной очереди следующим извлекается элемент, обладающий наивысшим приоритетом.

Например, в аэропорту клиент может быть обслужен вне очереди, если его самолет готовится к вылету. В системе передачи сообщений некоторые сообщения могут содержать более важную информацию; они должны быть срочно обработаны независимо от времени поступления.

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

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

Queue<Integer> queue = new PriorityQueue<>();
queue.offer(100);
queue.offer(20);
queue.offer(544);
queue.offer(11);
queue.offer(89);

while (queue.size() > 0)
System.out.println(queue.poll());

11
20
89
100
544

Класс PriorityQueue имеет одну особенность. Так как очередь с приоритетами реализована с помощью кучи (бинарное дерево с определенными свойствами), то попытка вывести элементы с помощью цикла foreach, скорее всего, приведет к некорректному выводу элементов.

Интерфейс Set

В интерфейсе Set определяется множество. Он расширяет интерфейс Collection и определяет поведение коллекций, не допускающих дублирования элементов. Таким образом, метод add() возвращает false при попытке ввести в множество дублирующий элемент. В этом интерфейсе не определяется никаких дополнительных методов.

Интерфейс Set объявляется следующим образом

Set<String> set;

Интерфейс Set не добавляет никакой функциональности по сравнению с интерфейсом Collection, поэтому в Set нет дополнительный функциональности. Вместо этого Set представляет собой разновидность Collection.

Класс HashSet

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

Выгода от хеширования состоит в том, что оно обеспечивает константное время выполнения методов add(), contains(), remove() и size(), даже для больших объемов данных.

Если вы хотите использовать HashSet для хранения объектов собственных пользовательских классов, то вы ДОЛЖНЫ переопределить методы hashCode() и equals(), иначае два логически-одинаковых объекта будут считаться разными, так как при добавлении элемента в коллекцию будет вызываться метод hashCode() класса Object (который вернет разный хеш-код для двух логически одинаковых объектов).

Важно отметить, что класс HashSet не гарантирует упорядоченности элементов, поскольку процесс хеширования сам по себе обычно не порождает сортированных наборов. Если вам нужны сортированные наборы, то лучшим выбором может быть класс TreeSet.

Класс TreeSet

TreeSet - коллекция, которая хранит свои элементы в виде упорядоченного дерева. TreeSet использует сбалансированное красно-черное дерево для хранения элементов.

Для опреаций add(), remove(), contains() потребуется гарантированное время log(n).

Интерфейс Map

Интерфейс Map описывает коллекцию, состоящую из пар "ключ-значение". У каждого ключа только одно значение, что соответствует математическому понятию однозначной функции или отображения. Такую коллекцию часто называют словарем (dictionary) или ассоциативным массивом (associative array). Несмотря на то, что интерфейс Map входит в список коллекций Java, он не расширяет интерфейс Collection.

Интерфейс Map соотносит уникальные ключи со значениями. Ключ - это объект, который вы используете для последующего извлечения данных. Задавая ключ и значение, вы можете помещать значения в объект Map. После того как это значение сохранено, вы можете получить его по ключу. Интерфейс Map - это обобщенный интерфейс, объявленный так, как показано ниже:

Map<K, V> map;

Здесь в качестве K указывается тип ключа, а в качестве V - тип значения.

Рассмотрим основные методы, объявленные в интерфейсе Map

Обращение с отображениями опирается на две основные операции, выполняемые методами get() и put(). Чтобы ввести значение в отображение, следует вызвать put(), указав ключ и значение, а для того чтобы получить значение из отображения - вызвать метод get(), передав ему ключ в качестве аргумента. По этому ключу будет возвращено связанное с ним значение.

Как упоминалось ранее, Map не реализует интерфейс Collection, хотя является частью каркаса коллекций. Тем не менее, можно получить представление отображения в виде коллекции. Для этого можно воспользоваться методом entrySet(), возвращающим множество, содержащее элементы отображения (для получения множества ключей можно использовать метод keySet(), для получения множества значений можно использовать метод values()).

Интерфейс Map.Entry

Этот интерфейс позволяет обращаться с отдельными записями в отображении. Напомним, что метод entrySet(), объявляемый в интерфейсе Map, возвращает множество типа Set, содержащее записи из отображения. Каждый элемент этого множества представляет собой объект типа Map.Entry. Интерфейс Map.Entry является обобщенным и объявляется следующим образом:

где K обозначает тип ключей, а V - тип хранимых в отображении значений.

Класс HashMap

Этот класс расширяет класс AbstractMap и реализует интерфейс Map. В нем используется хеш-таблица для хранения отображений, и благодаря этому обеспечивается постоянное время выполнения методов get() и put(), даже в случае отображения с большим количеством элементов. Класс HashMap является обобщенным и объявляется приведенным ниже образом, где K обозначает тип ключей, а V - тип хранимых в отображении значений.

Класс TreeMap

Класс TreeMap расширяет класс AbstractMap и реализует интерфейс NavigableMap. В нем создается отображение, размещаемое в древовидной структуре. В классе TreeMap предоставляются эффективные средства для хранения пар "ключ-значение" в отсортированном порядке и обеспечивается их быстрое извлечение. Следует заметить, что в отличие от HashMap, древовидное отображение гарантирует, что его элементы будут отсортированы по порядку возрастания ключей. Класс TreeMap является обобщенным и объявляется следующим образом

где K обозначает тип ключей, а V - тип хранимых в отображении значений.

Классы WeakHashMap, LinkedHashMap и интерфейс NavigableMap выносятся на самостоятельное изучение.

Вспомогательный класс Collections

В фреймворке коллекций определяется ряд алгоритмов, которые можно применять к коллекциям и отображениям. Эти алгоритмы определены в виде статических методов класса Collections. По сути, класс Collections предоставляет огромный набор методов для различных операций с коллекциями.

Полный список методов класса Collections можно найти здесь.

Приведем список наиболее полезных методов класса Collections

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

Вспомогательный класс Arrays

Также, в библиотеке java.util присутствует класс Arrays. Класс Arrays предоставляет различные удобные методы для работы с массивами. Эти методы помогает восполнить функциональный пробел между коллекциями и массивами. Например, метод asList() возвращает список, исходя из указанного массива, а метод binarySearch() использует алгоритм двоичного поиска для обнаружения заданного значения.

Полный список методов класса Arrays можно найти здесь.

Приведем список наиболее полезных методов класса Arrays

Применительно к классу Arrays действует то же правило - если вы хотите произвести какие-то манипуляции с массивом - сначала попробуйте найти нужный функционал в классе Arrays. Скорее всего, он там будет присутствовать в виде готового метода.

Сравнительная таблица временной сложности основных операций с коллекциями

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

Last updated