Buang Semua Kejadian Nilai Tertentu dari Senarai

1. Pengenalan

Di Jawa, mudah untuk membuang nilai tertentu dari Daftar menggunakan List.remove () . Walau bagaimanapun, membuang semua kejadian dengan berkesan adalah lebih sukar.

Dalam tutorial ini, kita akan melihat banyak penyelesaian untuk masalah ini, yang menjelaskan kebaikan dan keburukan.

Untuk kesediaan membaca, kami menggunakan kaedah senarai khusus (int ...) dalam ujian, yang mengembalikan ArrayList yang mengandungi elemen yang kami lalui.

2. Menggunakan Loop sebentar

Oleh kerana kita tahu cara membuang satu elemen, melakukannya berulang kali dalam satu gelung kelihatan cukup mudah:

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

Namun, ia tidak berfungsi seperti yang diharapkan:

// given List list = list(1, 2, 3); int valueToRemove = 1; // when assertThatThrownBy(() -> removeAll(list, valueToRemove)) .isInstanceOf(IndexOutOfBoundsException.class);

Masalahnya ada pada baris ke-3: kita memanggil List.remove (int), yang menganggap argumennya sebagai indeks, bukan nilai yang ingin kita hapus.

Dalam ujian di atas, kita selalu memanggil list.remove (1) , tetapi indeks elemen yang ingin kita hapus adalah 0. Calling List.remove () mengalihkan semua elemen setelah yang dihapus menjadi indeks yang lebih kecil.

Dalam senario ini, ini bermaksud bahawa kita menghapus semua elemen, kecuali yang pertama.

Apabila hanya tinggal yang pertama, indeks 1 akan menjadi tidak sah. Oleh itu kita mendapat Pengecualian .

Perhatikan, bahawa kita menghadapi masalah ini hanya jika kita memanggil List.remove () dengan argumen byte primitif , pendek, char atau int , kerana perkara pertama yang dilakukan penyusun ketika cuba mencari kaedah pemuatan yang sesuai, semakin melebar.

Kita boleh membetulkannya dengan meneruskan nilai sebagai Integer:

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

Sekarang kodnya berfungsi seperti yang diharapkan:

// given List list = list(1, 2, 3); int valueToRemove = 1; // when removeAll(list, valueToRemove); // then assertThat(list).isEqualTo(list(2, 3));

Oleh kerana List.contains () dan List.remove () kedua-duanya harus mencari kejadian pertama elemen, kod ini menyebabkan elemen tidak perlu melintasi.

Kita boleh melakukan yang lebih baik jika kita menyimpan indeks kejadian pertama:

void removeAll(List list, Integer element) { int index; while ((index = list.indexOf(element)) >= 0) { list.remove(index); } }

Kami dapat mengesahkan bahawa ia berfungsi:

// given List list = list(1, 2, 3); int valueToRemove = 1; // when removeAll(list, valueToRemove); // then assertThat(list).isEqualTo(list(2, 3));

Walaupun penyelesaian ini menghasilkan kod pendek dan bersih, ia masih mempunyai prestasi yang buruk : kerana kami tidak mengikuti perkembangannya, List.remove () harus mencari kejadian pertama dari nilai yang diberikan untuk menghapusnya.

Juga, ketika kita menggunakan ArrayList , pergeseran elemen dapat menyebabkan banyak rujukan menyalin, bahkan mengalokasikan larik sandaran beberapa kali.

3. Mengeluarkan sehingga Senarai berubah

List.remove (E element) mempunyai ciri yang belum kita sebutkan: ia mengembalikan nilai boolean , yang benar jika Daftar berubah kerana operasi, oleh itu ia mengandungi elemen .

Perhatikan, List.remove (int index) mengembalikan batal, kerana jika indeks yang disediakan valid, List selalu menghapusnya. Jika tidak, ia membuang IndexOutOfBoundsException .

Dengan ini, kita dapat melakukan penyingkiran sehingga Daftar berubah:

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

Ia berfungsi seperti yang diharapkan:

// given List list = list(1, 1, 2, 3); int valueToRemove = 1; // when removeAll(list, valueToRemove); // then assertThat(list).isEqualTo(list(2, 3));

Walaupun singkat, pelaksanaan ini mengalami masalah yang sama seperti yang kami jelaskan di bahagian sebelumnya.

3. Menggunakan a untuk Gelung

Kita dapat melacak kemajuan kita dengan melintasi elemen dengan gelung untuk dan mengeluarkan yang semasa jika sesuai:

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

Ia berfungsi seperti yang diharapkan:

// given List list = list(1, 2, 3); int valueToRemove = 1; // when removeAll(list, valueToRemove); // then assertThat(list).isEqualTo(list(2, 3));

Namun, jika kita mencubanya dengan input yang berbeza, ia memberikan output yang salah:

// given List list = list(1, 1, 2, 3); int valueToRemove = 1; // when removeAll(list, valueToRemove); // then assertThat(list).isEqualTo(list(1, 2, 3));

Mari kita analisis bagaimana kod itu berfungsi, langkah demi langkah:

  • i = 0
    • elemen dan list.get (i) sama dengan 1 pada baris 3, jadi Java memasuki badan pernyataan if ,
    • kita membuang elemen pada indeks 0 ,
    • jadi senarai sekarang mengandungi 1 , 2 dan 3
  • i = 1
    • list.get (i) mengembalikan 2 kerana apabila kita membuang elemen dari Daftar , ia mengalihkan semua elemen yang diteruskan ke indeks yang lebih kecil

Oleh itu, kita menghadapi masalah ini apabila kita mempunyai dua nilai yang berdekatan, yang ingin kita hapuskan . Untuk menyelesaikannya, kita harus mengekalkan pemboleh ubah gelung.

Menurunkannya semasa kita membuang elemen:

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

Meningkatkannya hanya apabila kita tidak membuang elemen:

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++; } } }

Note, that in the latter, we removed the statement i++ at line 2.

Both solutions work as expected:

// given List list = list(1, 1, 2, 3); int valueToRemove = 1; // when removeAll(list, valueToRemove); // then assertThat(list).isEqualTo(list(2, 3));

This implementation seems right for the first sight. However, it still has serious performance problems:

  • removing an element from an ArrayList, shifts all items after it
  • accessing elements by index in a LinkedList means traversing through the elements one-by-one until we find the index

4. Using a for-each Loop

Since Java 5 we can use the for-each loop to iterate through a List. Let's use it to remove elements:

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

Note, that we use Integer as the loop variable's type. Therefore we won't get a NullPointerException.

Also, this way we invoke List.remove(E element), which expects the value we want to remove, not the index.

As clean as it looks, unfortunately, it doesn't work:

// given List list = list(1, 1, 2, 3); int valueToRemove = 1; // when assertThatThrownBy(() -> removeWithForEachLoop(list, valueToRemove)) .isInstanceOf(ConcurrentModificationException.class);

The for-each loop uses Iterator to traverse through the elements. However, when we modify the List, the Iterator gets into an inconsistent state. Hence it throws ConcurrentModificationException.

The lesson is: we shouldn't modify a List, while we're accessing its elements in a for-each loop.

5. Using an Iterator

We can use the Iterator directly to traverse and modify the List with it:

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

This way, the Iterator can track the state of the List (because it makes the modification). As a result, the code above works as expected:

// given List list = list(1, 1, 2, 3); int valueToRemove = 1; // when removeAll(list, valueToRemove); // then assertThat(list).isEqualTo(list(2, 3));

Since every List class can provide their own Iterator implementation, we can safely assume, that it implements element traversing and removal the most efficient way possible.

However, using ArrayList still means lots of element shifting (and maybe array reallocating). Also, the code above is slightly harder to read, because it differs from the standard for loop, that most developers are familiar with.

6. Collecting

Until this, we modified the original List object by removing the items we didn't need. Rather, we can 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; }

Since we provide the result in a new List object, we have to return it from the method. Therefore we need to use the method in another way:

// given List list = list(1, 1, 2, 3); int valueToRemove = 1; // when List result = removeAll(list, valueToRemove); // then assertThat(result).isEqualTo(list(2, 3));

Note, that now we can use the for-each loop since we don't modify the List we're currently iterating through.

Because there aren't any removals, there's no need to shift the elements. Therefore this implementation performs well when we use an ArrayList.

This implementation behaves differently in some ways than the earlier ones:

  • it doesn't modify the original List but returns a new one
  • the method decides what the returned List‘s implementation is, it may be different than the original

Also, we can modify our implementation to get the old behavior; we clear the original List and add the collected elements to it:

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

It works the same way the ones before:

// given List list = list(1, 1, 2, 3); int valueToRemove = 1; // when removeAll(list, valueToRemove); // then assertThat(list).isEqualTo(list(2, 3));

Since we don't modify the List continually, we don't have to access elements by position or shift them. Also, there're only two possible array reallocations: when we call List.clear() and List.addAll().

7. Using the Stream API

Java 8 introduced lambda expressions and stream API. With these powerful features, we can solve our problem with a very clean code:

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

This solution works the same way, like when we were collecting the remaining elements.

As a result, it has the same characteristics, and we should use it to return the result:

// given List list = list(1, 1, 2, 3); int valueToRemove = 1; // when List result = removeAll(list, valueToRemove); // then assertThat(result).isEqualTo(list(2, 3));

Note, that we can convert it to work like the other solutions with the same approach we did with the original ‘collecting' implementation.

8. Using removeIf

With lambdas and functional interfaces, Java 8 introduced some API extensions, too. For example, the List.removeIf() method, which implements what we saw in the last section.

It expects a Predicate, which should return true when we want to remove the element, in contrast to the previous example, where we had to return true when we wanted to keep the element:

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

It works like the other solutions above:

// given List list = list(1, 1, 2, 3); int valueToRemove = 1; // when removeAll(list, valueToRemove); // then assertThat(list).isEqualTo(list(2, 3));

Oleh kerana, bahawa Senarai itu sendiri menggunakan kaedah ini, kita boleh menganggap bahawa ia mempunyai prestasi terbaik yang ada. Selain itu, penyelesaian ini memberikan kod paling bersih dari semua.

9. Kesimpulannya

Dalam artikel ini, kami melihat banyak cara untuk menyelesaikan masalah mudah, termasuk yang salah. Kami menganalisisnya untuk mencari penyelesaian terbaik untuk setiap senario.

Seperti biasa, contohnya terdapat di GitHub.