«1. Обзор
В этой статье мы углубимся в HashSet. Это одна из самых популярных реализаций Set, а также неотъемлемая часть Java Collections Framework.
2. Знакомство с HashSet
HashSet — одна из основных структур данных в Java Collections API.
Давайте вспомним наиболее важные аспекты этой реализации:
-
Он хранит уникальные элементы и допускает пустые значения. Он поддерживается HashMap. Не поддерживает порядок вставки. при создании экземпляра HashSet:
Если вы хотите глубже понять, как работает HashMap, вы можете прочитать статью, посвященную этому, здесь.
public HashSet() {
map = new HashMap<>();
}
3. API
В этом разделе мы рассмотрим наиболее часто используемые методы и рассмотрим несколько простых примеров.
3.1. add()
Метод add() можно использовать для добавления элементов в набор. В контракте метода указано, что элемент будет добавлен только в том случае, если его еще нет в наборе. Если элемент был добавлен, метод возвращает true, иначе — false.
Мы можем добавить элемент в HashSet следующим образом:
С точки зрения реализации, метод add чрезвычайно важен. Детали реализации иллюстрируют, как HashSet работает внутри и использует метод put HashMap:
@Test
public void whenAddingElement_shouldAddElement() {
Set<String> hashset = new HashSet<>();
assertTrue(hashset.add("String Added"));
}
Переменная map является ссылкой на внутренний поддерживающий HashMap: сначала с хэш-кодом, чтобы получить подробное представление о том, как элементы организованы в структурах данных на основе хэшей.
public boolean add(E e) {
return map.put(e, PRESENT) == null;
}
Резюмируя:
private transient HashMap<E, Object> map;
HashMap — это массив сегментов с емкостью по умолчанию 16 элементов — каждому сегменту соответствует разное значение хэш-кода. Если разные объекты имеют одинаковое значение хэш-кода, они сохраняются в одном сегменте. Если достигается коэффициент загрузки, создается новый массив, вдвое превышающий размер предыдущего, и все элементы повторно хешируются и перераспределяются между новыми соответствующими сегментами. Чтобы получить значение, мы хэшируем ключ, модифицируем его, а затем переходим к соответствующему сегменту и поиск по потенциально связанному списку в случае наличия более одного объекта
3.2. contains()
-
Целью метода contains является проверка наличия элемента в заданном HashSet. Возвращает true, если элемент найден, иначе false.
Мы можем проверить наличие элемента в HashSet:
Всякий раз, когда объект передается этому методу, вычисляется хеш-значение. Затем соответствующее местоположение корзины разрешается и проходится.
3.3. remove()
@Test
public void whenCheckingForElement_shouldSearchForElement() {
Set<String> hashsetContains = new HashSet<>();
hashsetContains.add("String Added");
assertTrue(hashsetContains.contains("String Added"));
}
Метод удаляет указанный элемент из набора, если он присутствует. Этот метод возвращает true, если набор содержит указанный элемент.
Давайте посмотрим на рабочий пример:
3.4. clear()
Мы используем этот метод, когда собираемся удалить все элементы из набора. Базовая реализация просто удаляет все элементы из базового HashMap.
@Test
public void whenRemovingElement_shouldRemoveElement() {
Set<String> removeFromHashSet = new HashSet<>();
removeFromHashSet.add("String Added");
assertTrue(removeFromHashSet.remove("String Added"));
}
Давайте посмотрим, что в действии:
3.5. size()
Это один из основных методов API. Он широко используется, поскольку помогает определить количество элементов, присутствующих в HashSet. Базовая реализация просто делегирует вычисление методу size() HashMap.
@Test
public void whenClearingHashSet_shouldClearHashSet() {
Set<String> clearHashSet = new HashSet<>();
clearHashSet.add("String Added");
clearHashSet.clear();
assertTrue(clearHashSet.isEmpty());
}
Давайте посмотрим, что в действии:
3.6. isEmpty()
Мы можем использовать этот метод, чтобы выяснить, является ли данный экземпляр HashSet пустым или нет. Этот метод возвращает true, если набор не содержит элементов:
@Test
public void whenCheckingTheSizeOfHashSet_shouldReturnThesize() {
Set<String> hashSetSize = new HashSet<>();
hashSetSize.add("String Added");
assertEquals(1, hashSetSize.size());
}
3.7. iterator()
Метод возвращает итератор для элементов в наборе. Элементы посещаются в произвольном порядке, а итераторы работают безотказно.
@Test
public void whenCheckingForEmptyHashSet_shouldCheckForEmpty() {
Set<String> emptyHashSet = new HashSet<>();
assertTrue(emptyHashSet.isEmpty());
}
Мы можем наблюдать случайный порядок итерации здесь:
Если набор изменяется в любое время после создания итератора любым способом, кроме как с помощью собственного метода удаления итератора, итератор выдает исключение ConcurrentModificationException.
Давайте посмотрим, что в действии:
@Test
public void whenIteratingHashSet_shouldIterateHashSet() {
Set<String> hashset = new HashSet<>();
hashset.add("First");
hashset.add("Second");
hashset.add("Third");
Iterator<String> itr = hashset.iterator();
while(itr.hasNext()){
System.out.println(itr.next());
}
}
В качестве альтернативы, если бы мы использовали метод удаления итератора, мы бы не столкнулись с исключением:
«
@Test(expected = ConcurrentModificationException.class)
public void whenModifyingHashSetWhileIterating_shouldThrowException() {
Set<String> hashset = new HashSet<>();
hashset.add("First");
hashset.add("Second");
hashset.add("Third");
Iterator<String> itr = hashset.iterator();
while (itr.hasNext()) {
itr.next();
hashset.remove("Second");
}
}
«Безотказное поведение итератора не может быть гарантировано, поскольку невозможно дать какие-либо жесткие гарантии при наличии несинхронизированной параллельной модификации.
@Test
public void whenRemovingElementUsingIterator_shouldRemoveElement() {
Set<String> hashset = new HashSet<>();
hashset.add("First");
hashset.add("Second");
hashset.add("Third");
Iterator<String> itr = hashset.iterator();
while (itr.hasNext()) {
String element = itr.next();
if (element.equals("Second"))
itr.remove();
}
assertEquals(2, hashset.size());
}
Отказоустойчивые итераторы генерируют исключение ConcurrentModificationException в максимально возможной степени. Следовательно, было бы неправильно писать программу, корректность которой зависела бы от этого исключения.
4. Как HashSet поддерживает уникальность?
Когда мы помещаем объект в HashSet, он использует значение хэш-кода объекта, чтобы определить, отсутствует ли уже элемент в наборе.
Каждое значение хэш-кода соответствует определенному местоположению корзины, которая может содержать различные элементы, для которых вычисленное значение хеш-функции одинаково. Но два объекта с одинаковым hashCode могут не совпадать.
Итак, объекты в пределах одного сегмента будут сравниваться с помощью метода equals().
5. Производительность HashSet
На производительность HashSet в основном влияют два параметра — его начальная емкость и коэффициент загрузки.
Ожидаемая временная сложность добавления элемента в набор составляет O(1), которая может упасть до O(n) в худшем случае (присутствует только одно ведро) — поэтому важно поддерживать правильный HashSet емкость.
Важное примечание: начиная с JDK 8 временная сложность в наихудшем случае составляет O(log*n).
Фактор загрузки описывает максимальный уровень заполнения, выше которого необходимо будет изменить размер набора.
Мы также можем создать HashSet с пользовательскими значениями начальной емкости и коэффициента загрузки:
В первом случае используются значения по умолчанию — начальная емкость 16 и коэффициент загрузки 0,75. Во втором мы переопределяем емкость по умолчанию, а в третьем мы переопределяем обе.
Set<String> hashset = new HashSet<>();
Set<String> hashset = new HashSet<>(20);
Set<String> hashset = new HashSet<>(20, 0.5f);
Низкая начальная емкость снижает сложность пространства, но увеличивает частоту повторного хеширования, что является дорогостоящим процессом.
С другой стороны, высокая начальная емкость увеличивает стоимость итерации и начальное потребление памяти.
Эмпирическое правило:
Высокая начальная емкость хороша для большого количества записей в сочетании с небольшим количеством итераций или без них. Низкая начальная емкость хороша для нескольких записей с большим количеством итераций
-
Следовательно, , очень важно найти правильный баланс между ними. Обычно реализация по умолчанию оптимизирована и работает просто отлично, если мы почувствуем необходимость настроить эти параметры в соответствии с требованиями, нам нужно действовать разумно.
6. Заключение
В этой статье мы описали полезность HashSet, его назначение, а также лежащую в его основе работу. Мы увидели, насколько он эффективен с точки зрения удобства использования, учитывая его постоянную производительность и способность избегать дублирования.
Мы изучили некоторые важные методы API и то, как они могут помочь нам как разработчику использовать HashSet в полной мере.
Как всегда, фрагменты кода можно найти на GitHub.
«