«1. Введение
В Java просто удалить конкретное значение из списка с помощью List.remove(). Однако эффективно удалить все вхождения значения намного сложнее.
В этом уроке мы увидим несколько решений этой проблемы с описанием плюсов и минусов.
Ради удобочитаемости мы используем в тестах пользовательский метод list(int…), который возвращает ArrayList, содержащий переданные нами элементы.
2. Использование цикла while
Поскольку мы знаем, как удалить один элемент, повторяющееся выполнение этого действия в цикле выглядит достаточно просто:
void removeAll(List<Integer> list, int element) {
while (list.contains(element)) {
list.remove(element);
}
}
Однако это не работает должным образом: ~~ ~
// given
List<Integer> list = list(1, 2, 3);
int valueToRemove = 1;
// when
assertThatThrownBy(() -> removeAll(list, valueToRemove))
.isInstanceOf(IndexOutOfBoundsException.class);
Проблема в 3-й строке: мы вызываем List.remove(int), который рассматривает свой аргумент как индекс, а не значение, которое мы хотим удалить.
В приведенном выше тесте мы всегда вызываем list.remove(1), но индекс элемента, который мы хотим удалить, равен 0. Вызов List.remove() сдвигает все элементы после удаленного на меньшие индексы.
В данном сценарии это означает, что мы удаляем все элементы, кроме первого.
Когда останется только первый, индекс 1 будет недопустимым. Следовательно, мы получаем исключение.
Обратите внимание, что мы сталкиваемся с этой проблемой, только если мы вызываем List.remove() с примитивным аргументом byte, short, char или int, поскольку первое, что делает компилятор, пытаясь найти соответствующий перегруженный метод, расширяет .
Мы можем исправить это, передав значение как Integer:
void removeAll(List<Integer> list, Integer element) {
while (list.contains(element)) {
list.remove(element);
}
}
Теперь код работает так, как ожидалось:
// given
List<Integer> list = list(1, 2, 3);
int valueToRemove = 1;
// when
removeAll(list, valueToRemove);
// then
assertThat(list).isEqualTo(list(2, 3));
Так как List.contains() и List.remove() должны найти первое вхождение элемента, этот код вызывает ненужный обход элемента.
Мы можем добиться большего успеха, если сохраним индекс первого вхождения:
void removeAll(List<Integer> list, Integer element) {
int index;
while ((index = list.indexOf(element)) >= 0) {
list.remove(index);
}
}
Мы можем убедиться, что это работает:
// given
List<Integer> list = list(1, 2, 3);
int valueToRemove = 1;
// when
removeAll(list, valueToRemove);
// then
assertThat(list).isEqualTo(list(2, 3));
Хотя эти решения производят короткий и чистый код, они по-прежнему имеют низкую производительность. : поскольку мы не отслеживаем прогресс, List.remove() должен найти первое вхождение предоставленного значения, чтобы удалить его.
Кроме того, когда мы используем ArrayList, смещение элементов может привести к многократному копированию ссылок, даже к перераспределению резервного массива несколько раз. 3. Удаление до изменения списка содержал элемент.
Обратите внимание, что List.remove(int index) возвращает void, потому что, если предоставленный индекс действителен, List всегда удаляет его. В противном случае выдается исключение IndexOutOfBoundsException.
Таким образом, мы можем выполнять удаления до тех пор, пока список не изменится:
Работает так, как ожидалось:
Несмотря на свою краткость, эта реализация страдает от тех же проблем, которые мы описали в предыдущем разделе.
void removeAll(List<Integer> list, int element) {
while (list.remove(element));
}
3. Использование цикла for
// given
List<Integer> list = list(1, 1, 2, 3);
int valueToRemove = 1;
// when
removeAll(list, valueToRemove);
// then
assertThat(list).isEqualTo(list(2, 3));
Мы можем отслеживать наш прогресс, просматривая элементы с помощью цикла for и удаляя текущий элемент, если он соответствует:
Это работает так, как ожидалось: ~~ ~
Однако, если мы попробуем это с другим вводом, он выдаст неверный вывод:
void removeAll(List<Integer> list, int element) {
for (int i = 0; i < list.size(); i++) {
if (Objects.equals(element, list.get(i))) {
list.remove(i);
}
}
}
Давайте проанализируем, как работает код, шаг за шагом:
// given
List<Integer> list = list(1, 2, 3);
int valueToRemove = 1;
// when
removeAll(list, valueToRemove);
// then
assertThat(list).isEqualTo(list(2, 3));
i = 0 элемент и список. get(i) равны 1 в строке 3, поэтому Java входит в тело оператора if, мы удаляем элемент с индексом 0, поэтому list теперь содержит 1, 2 и 3 i = 1 list.get(i) возвращает 2, потому что когда мы удаляем элемент из списка, он сдвигает все последующие элементы на меньшие индексы
// given
List<Integer> list = list(1, 1, 2, 3);
int valueToRemove = 1;
// when
removeAll(list, valueToRemove);
// then
assertThat(list).isEqualTo(list(1, 2, 3));
Таким образом, мы сталкиваемся с этой проблемой, когда у нас есть два соседних значения, которые мы хотим удалить. Чтобы решить эту проблему, мы должны сохранить переменную цикла.
-
Уменьшение, когда мы удаляем элемент:
Увеличиваем, только когда мы не удаляем элемент:
Обратите внимание, что в последнем случае мы удалили оператор i++ в строке 2 .
void removeAll(List<Integer> list, int element) {
for (int i = 0; i < list.size(); i++) {
if (Objects.equals(element, list.get(i))) {
list.remove(i);
i--;
}
}
}
Оба решения работают так, как ожидалось:
void removeAll(List<Integer> list, int element) {
for (int i = 0; i < list.size();) {
if (Objects.equals(element, list.get(i))) {
list.remove(i);
} else {
i++;
}
}
}
Эта реализация кажется правильной на первый взгляд. Тем не менее, он по-прежнему имеет серьезные проблемы с производительностью:
удаление элемента из ArrayList сдвигает все элементы после него. Доступ к элементам по индексу в LinkedList означает обход элементов один за другим, пока мы не найдем индекс
// given
List<Integer> list = list(1, 1, 2, 3);
int valueToRemove = 1;
// when
removeAll(list, valueToRemove);
// then
assertThat(list).isEqualTo(list(2, 3));
4 , Использование цикла for-each
-
«Начиная с Java 5 мы можем использовать цикл for-each для перебора списка. Давайте используем его для удаления элементов:
Обратите внимание, что мы используем Integer в качестве типа переменной цикла. Поэтому мы не получим исключение NullPointerException.
Кроме того, таким образом мы вызываем List.remove(E element), который ожидает значение, которое мы хотим удалить, а не индекс.
void removeAll(List<Integer> list, int element) {
for (Integer number : list) {
if (Objects.equals(number, element)) {
list.remove(number);
}
}
}
Как бы чисто это ни выглядело, к сожалению, это не работает:
Цикл for-each использует итератор для обхода элементов. Однако, когда мы изменяем список, итератор переходит в несогласованное состояние. Следовательно, он генерирует исключение ConcurrentModificationException.
Урок таков: мы не должны изменять список, пока обращаемся к его элементам в цикле for-each.
// given
List<Integer> list = list(1, 1, 2, 3);
int valueToRemove = 1;
// when
assertThatThrownBy(() -> removeWithForEachLoop(list, valueToRemove))
.isInstanceOf(ConcurrentModificationException.class);
5. Использование итератора
Мы можем использовать итератор напрямую для обхода и изменения списка с его помощью:
Таким образом, итератор может отслеживать состояние списка (поскольку он делает модификацию ). В результате приведенный выше код работает так, как ожидалось:
Поскольку каждый класс List может предоставить свою собственную реализацию Iterator, мы можем с уверенностью предположить, что он реализует обход и удаление элементов наиболее эффективным способом.
void removeAll(List<Integer> list, int element) {
for (Iterator<Integer> i = list.iterator(); i.hasNext();) {
Integer number = i.next();
if (Objects.equals(number, element)) {
i.remove();
}
}
}
Однако использование ArrayList по-прежнему означает большое смещение элементов (и, возможно, перераспределение массива). Кроме того, приведенный выше код немного сложнее читать, поскольку он отличается от стандартного цикла for, с которым знакомо большинство разработчиков.
// given
List<Integer> list = list(1, 1, 2, 3);
int valueToRemove = 1;
// when
removeAll(list, valueToRemove);
// then
assertThat(list).isEqualTo(list(2, 3));
6. Сбор
До этого мы модифицировали исходный объект List, удаляя ненужные элементы. Вместо этого мы можем создать новый список и собрать элементы, которые хотим сохранить:
Поскольку мы предоставляем результат в новом объекте списка, мы должны вернуть его из метода. Поэтому нам нужно использовать метод по-другому:
Обратите внимание, что теперь мы можем использовать цикл for-each, так как мы не модифицируем список, который мы в данный момент итерируем.
List<Integer> removeAll(List<Integer> list, int element) {
List<Integer> remainingElements = new ArrayList<>();
for (Integer number : list) {
if (!Objects.equals(number, element)) {
remainingElements.add(number);
}
}
return remainingElements;
}
Поскольку нет никаких удалений, нет необходимости сдвигать элементы. Поэтому эта реализация хорошо работает, когда мы используем ArrayList.
// given
List<Integer> list = list(1, 1, 2, 3);
int valueToRemove = 1;
// when
List<Integer> result = removeAll(list, valueToRemove);
// then
assertThat(result).isEqualTo(list(2, 3));
Эта реализация в некоторых отношениях ведет себя иначе, чем предыдущие:
она не изменяет исходный список, а возвращает новый метод решает, какова реализация возвращенного списка, она может отличаться от реализации original
Кроме того, мы можем изменить нашу реализацию, чтобы получить старое поведение; мы очищаем исходный список и добавляем в него собранные элементы:
-
Он работает так же, как и предыдущие:
Поскольку мы не изменяем список постоянно, нам не нужно доступ к элементам по положению или сдвиг их. Кроме того, есть только два возможных перераспределения массива: когда мы вызываем List.clear() и List.addAll().
void removeAll(List<Integer> list, int element) {
List<Integer> remainingElements = new ArrayList<>();
for (Integer number : list) {
if (!Objects.equals(number, element)) {
remainingElements.add(number);
}
}
list.clear();
list.addAll(remainingElements);
}
7. Использование Stream API
// given
List<Integer> list = list(1, 1, 2, 3);
int valueToRemove = 1;
// when
removeAll(list, valueToRemove);
// then
assertThat(list).isEqualTo(list(2, 3));
В Java 8 появились лямбда-выражения и потоковый API. Благодаря этим мощным функциям мы можем решить нашу проблему с очень чистым кодом:
Это решение работает так же, как когда мы собирали оставшиеся элементы.
В результате он имеет те же характеристики, и мы должны использовать его для возврата результата: оригинальная реализация «сбора».
List<Integer> removeAll(List<Integer> list, int element) {
return list.stream()
.filter(e -> !Objects.equals(e, element))
.collect(Collectors.toList());
}
8. Использование removeIf
Вместе с лямбда-выражениями и функциональными интерфейсами в Java 8 также появились некоторые расширения API. Например, метод List.removeIf(), реализующий то, что мы видели в предыдущем разделе.
// given
List<Integer> list = list(1, 1, 2, 3);
int valueToRemove = 1;
// when
List<Integer> result = removeAll(list, valueToRemove);
// then
assertThat(result).isEqualTo(list(2, 3));
Ожидается предикат, который должен возвращать true, когда мы хотим удалить элемент, в отличие от предыдущего примера, где нам приходилось возвращать true, когда мы хотели сохранить элемент:
Это работает как другие решения выше:
В связи с тем, что сам список реализует этот метод, мы можем с уверенностью предположить, что он имеет наилучшую доступную производительность. Кроме того, это решение обеспечивает самый чистый код из всех.
9. Заключение
void removeAll(List<Integer> list, int element) {
list.removeIf(n -> Objects.equals(n, element));
}
В этой статье мы увидели много способов решения простой задачи, в том числе и некорректных. Мы проанализировали их, чтобы найти лучшее решение для каждого сценария.
// given
List<Integer> list = list(1, 1, 2, 3);
int valueToRemove = 1;
// when
removeAll(list, valueToRemove);
// then
assertThat(list).isEqualTo(list(2, 3));
Как обычно, примеры доступны на GitHub.
«
In this article, we saw many ways to solve a simple problem, including incorrect ones. We analyzed them to find the best solution for every scenario.
As usual, the examples are available over on GitHub.