リストから特定の値の出現をすべて削除する

リストから特定の値のすべての出現を削除する

1. 前書き

Javaでは、List.remove()を使用してListから特定の値を削除するのは簡単です。 ただし、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);

問題は3行目にあります: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を取得します。

この問題に直面するのは、プリミティブbyteshort, char、またはint引数を使用してList.remove()を呼び出す場合のみです。これは、コンパイラが最初に検索を試みるためです。一致するオーバーロードされたメソッドは、拡大しています。

値を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は常にそれを削除するため、List.remove(int index)はvoidを返すことに注意してください。 それ以外の場合は、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));

コードがどのように機能するかを段階的に分析してみましょう。

  • i = 0

    • elementlist.get(i)は両方とも3行目で1と等しいため、Javaはifステートメントの本体に入ります。

    • インデックス0の要素を削除します。

    • したがって、listには、12、および3が含まれるようになりました。

  • i = 1

    • when we remove an element from a List, it shifts all proceeding elements to smaller indiceswhen we remove an element from a List, it shifts all proceeding elements to smaller indicesであるため、list.get(i)2を返します

したがって、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++;
        }
    }
}

後者では、2行目のステートメントi++を削除したことに注意してください。

どちらのソリューションも期待どおりに機能します。

// 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のインデックスで要素にアクセスするということは、インデックスが見つかるまで要素を1つずつトラバースすることを意味します。

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

教訓は次のとおりです。for-eachループ内の要素にアクセスしている間は、Listを変更しないでください。

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));

現在反復しているListを変更しないため、for-eachループを使用できることに注意してください。

削除がないため、要素をシフトする必要はありません。 したがって、この実装は、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を継続的に変更しないため、位置によって要素にアクセスしたり、要素をシフトしたりする必要はありません。 また、可能な配列の再割り当ては2つだけです。List.clear()List.addAll()を呼び出す場合です。

7. ストリーム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です。

要素を保持したいときにtrueを返さなければならなかった前の例とは対照的に、Predicateは要素をtrue when we want to removeに返すはずです。

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です。