Удалить все вхождения определенного значения из списка

Удалить все вхождения определенного значения из списка

1. Вступление

В Java просто удалить конкретное значение изList с помощьюList.remove(). Однакоefficiently removing all occurrences of a value намного сложнее.

В этом руководстве мы увидим несколько решений этой проблемы с описанием плюсов и минусов.

Для удобства чтения мы используем в тестах специальный методlist(int…), который возвращаетArrayList, содержащий переданные нами элементы.

2. Использование циклаwhile

Поскольку мы знаем, какremove a single element, doing it repeatedly in a loop выглядит достаточно просто:

void removeAll(List list, int element) {
    while (list.contains(element)) {
        list.remove(element);
    }
}

Однако это не работает должным образом:

// given
List list = list(1, 2, 3);
int valueToRemove = 1;

// when
assertThatThrownBy(() -> removeAll(list, valueToRemove))
  .isInstanceOf(IndexOutOfBoundsException.class);

Проблема в третьей строке: мы вызываемList.remove(int), which treats its argument as the index, not the value we want to remove.

В приведенном выше тесте мы всегда вызываемlist.remove(1), но индекс элемента, который мы хотим удалить, равен0.. ВызовList.remove() сдвигает все элементы после удаленного на меньшие индексы.

В этом случае это означает, что мы удаляем все элементы, кроме первого.

Когда останется только первый, индекс1 будет недопустимым. Отсюда получаемException.

Обратите внимание, что мы сталкиваемся с этой проблемой, только если мы вызываемList.remove() с примитивным аргументомbyte,short, char илиint, поскольку первое, что делает компилятор, когда пытается найти соответствующий перегруженный метод расширяется.

Мы можем исправить это, передав значение какInteger:

void removeAll(List list, Integer element) {
    while (list.contains(element)) {
        list.remove(element);
    }
}

Теперь код работает как положено:

// given
List 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 list, Integer element) {
    int index;
    while ((index = list.indexOf(element)) >= 0) {
        list.remove(index);
    }
}

Мы можем проверить, что это работает:

// given
List list = list(1, 2, 3);
int valueToRemove = 1;

// when
removeAll(list, valueToRemove);

// then
assertThat(list).isEqualTo(list(2, 3));

Хотя эти решения создают короткий и чистый код,they still have poor performance: поскольку мы не отслеживаем прогресс,List.remove() должен найти первое вхождение предоставленного значения, чтобы удалить его.

Кроме того, когда мы используемArrayList, смещение элемента может вызвать много копий ссылок, даже несколько раз перераспределить резервный массив.

3. Удаление до измененияList

List.remove(E element) имеет функцию, о которой мы еще не упоминали: этоreturns a boolean value, which is true if the List changed because of the operation, therefore it contained the element.

Обратите внимание, чтоList.remove(int index) возвращает void, потому что, если предоставленный индекс действителен,List всегда удаляет его. В противном случае он выбрасываетIndexOutOfBoundsException.

Таким образом, мы можем выполнять удаление, покаList не изменится:

void removeAll(List list, int element) {
    while (list.remove(element));
}

Работает как положено:

// given
List list = list(1, 1, 2, 3);
int valueToRemove = 1;

// when
removeAll(list, valueToRemove);

// then
assertThat(list).isEqualTo(list(2, 3));

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

3. Использование циклаfor

Мы можем отслеживать наш прогресс, просматривая элементы с помощью циклаfor и удаляя текущий, если он совпадает:

void removeAll(List list, int element) {
    for (int i = 0; i < list.size(); i++) {
        if (Objects.equals(element, list.get(i))) {
            list.remove(i);
        }
    }
}

Работает как положено:

// given
List list = list(1, 2, 3);
int valueToRemove = 1;

// when
removeAll(list, valueToRemove);

// then
assertThat(list).isEqualTo(list(2, 3));

Однако, если мы попробуем это с другим вводом, это даст неправильный вывод:

// given
List list = list(1, 1, 2, 3);
int valueToRemove = 1;

// when
removeAll(list, valueToRemove);

// then
assertThat(list).isEqualTo(list(1, 2, 3));

Давайте проанализируем, как работает код, шаг за шагом:

  • я = 0

    • element иlist.get(i) равны1 в строке 3, поэтому Java входит в тело оператораif,

    • удаляем элемент с индексом0,

    • поэтомуlist теперь содержит1,2 и3

  • я = 1

    • list.get(i) возвращает2, потому чтоwhen we remove an element from a List, it shifts all proceeding elements to smaller indices

Итак, мы сталкиваемся с этой проблемой, когдаwe have two adjacent values, which we want to remove. Чтобы решить эту проблему, мы должны поддерживать переменную цикла.

Уменьшаем его, когда удаляем элемент:

void removeAll(List 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 list, int element) {
    for (int i = 0; i < list.size();) {
        if (Objects.equals(element, list.get(i))) {
            list.remove(i);
        } else {
            i++;
        }
    }
}

Обратите внимание, что в последнем мы удалили операторi++ в строке 2.

Оба решения работают как положено:

// given
List list = list(1, 1, 2, 3);
int valueToRemove = 1;

// when
removeAll(list, valueToRemove);

// then
assertThat(list).isEqualTo(list(2, 3));

Эта реализация кажется правильной на первый взгляд. Однако у него все еще естьserious performance problems:

  • удаление элемента изArrayList, сдвигает все элементы после него

  • доступ к элементам по индексу вLinkedList означает переход по элементам один за другим, пока мы не найдем индекс

4. Использование циклаfor-each

Начиная с Java 5, мы можем использовать циклfor-each для перебораList. Давайте использовать его для удаления элементов:

void removeAll(List list, int element) {
    for (Integer number : list) {
        if (Objects.equals(number, element)) {
            list.remove(number);
        }
    }
}

Обратите внимание, что мы используемInteger в качестве типа переменной цикла. Следовательно, мы не получимNullPointerException.

Кроме того, таким образом мы вызываемList.remove(E element), который ожидает значение, которое мы хотим удалить, а не индекс.

Каким бы чистым он ни выглядел, к сожалению, он не работает:

// given
List list = list(1, 1, 2, 3);
int valueToRemove = 1;

// when
assertThatThrownBy(() -> removeWithForEachLoop(list, valueToRemove))
  .isInstanceOf(ConcurrentModificationException.class);

Циклfor-each используетIterator для обхода элементов. Однакоwhen we modify the List, the Iterator gets into an inconsistent state. Hence it throws ConcurrentModificationException.

Урок такой: мы не должны изменятьList, пока мы получаем доступ к его элементам в циклеfor-each.

5. ИспользуяIterator

Мы можем использоватьIterator напрямую для обхода и измененияList с его помощью:

void removeAll(List list, int element) {
    for (Iterator i = list.iterator(); i.hasNext();) {
        Integer number = i.next();
        if (Objects.equals(number, element)) {
            i.remove();
        }
    }
}

Таким образом,the Iterator can track the state of the List (потому что он вносит изменения). В результате приведенный выше код работает, как и ожидалось:

// given
List list = list(1, 1, 2, 3);
int valueToRemove = 1;

// when
removeAll(list, valueToRemove);

// then
assertThat(list).isEqualTo(list(2, 3));

Поскольку каждый классList может предоставлять свою собственную реализациюIterator, мы можем с уверенностью предположить, чтоit implements element traversing and removal the most efficient way possible.

However, using ArrayList still means lots of element shifting (и, возможно, перераспределение массива). Кроме того, приведенный выше код немного сложнее читать, поскольку он отличается от стандартного циклаfor, с которым знакомо большинство разработчиков.

6. сбор

До этого мы модифицировали исходный объектList, удаляя ненужные нам элементы. Скорее, мы можемcreate a new List and collect the items we want to keep:

List removeAll(List list, int element) {
    List remainingElements = new ArrayList<>();
    for (Integer number : list) {
        if (!Objects.equals(number, element)) {
            remainingElements.add(number);
        }
    }
    return remainingElements;
}

Поскольку мы предоставляем результат в новом объектеList, мы должны вернуть его из метода. Поэтому нам нужно использовать метод по-другому:

// given
List list = list(1, 1, 2, 3);
int valueToRemove = 1;

// when
List result = removeAll(list, valueToRemove);

// then
assertThat(result).isEqualTo(list(2, 3));

Обратите внимание, что теперь мы можем использовать циклfor-each, поскольку мы не изменяемList, которые мы в данный момент повторяем.

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

Эта реализация в некоторых отношениях ведет себя иначе, чем предыдущие:

  • он не изменяет исходныйList, аreturns a new

  • the method decides what the returned List‘s implementation is, он может отличаться от оригинала

Также мы можем изменить нашу реализацию наget the old behavior; очищаем исходныйList и добавляем к нему собранные элементы:

void removeAll(List list, int element) {
    List remainingElements = new ArrayList<>();
    for (Integer number : list) {
        if (!Objects.equals(number, element)) {
            remainingElements.add(number);
        }
    }

    list.clear();
    list.addAll(remainingElements);
}

Он работает так же, как и раньше:

// given
List list = list(1, 1, 2, 3);
int valueToRemove = 1;

// when
removeAll(list, valueToRemove);

// then
assertThat(list).isEqualTo(list(2, 3));

Поскольку мы не изменяемListпостоянно, нам не нужно обращаться к элементам по положению или сдвигать их. Кроме того, есть только два возможных перераспределения массива: когда мы вызываемList.clear() иList.addAll().

7. Использование Stream API

Java 8 представила лямбда-выражения и потоковый API. С помощью этих мощных функций мы можем решить нашу проблему с помощью очень чистого кода:

List removeAll(List list, int element) {
    return list.stream()
      .filter(e -> !Objects.equals(e, element))
      .collect(Collectors.toList());
}

Этот растворworks the same way, like when we were collecting the remaining elements.

As a result, it has the same characteristics, и мы должны использовать его для возврата результата:

// given
List list = list(1, 1, 2, 3);
int valueToRemove = 1;

// when
List result = removeAll(list, valueToRemove);

// then
assertThat(result).isEqualTo(list(2, 3));

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

8. ИспользуяremoveIf

С лямбдами и функциональными интерфейсами Java 8 также представила некоторые расширения API. Например,List.removeIf() method, which implements what we saw in the last section.

Он ожидаетPredicate, который должен вернутьtrue when we want to remove элемент, в отличие от предыдущего примера, где мы должны были вернутьtrue, когда мы хотели сохранить элемент:

void removeAll(List list, int element) {
    list.removeIf(n -> Objects.equals(n, element));
}

Это работает как другие решения выше:

// given
List list = list(1, 1, 2, 3);
int valueToRemove = 1;

// when
removeAll(list, valueToRemove);

// then
assertThat(list).isEqualTo(list(2, 3));

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

9. Заключение

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

Как обычно доступны примерыover on GitHub.