橫掃Java Collections系列 —— List

GuoYaxiang發表於2019-01-23

本文整理了Java中List結構的不同實現,典型的列表操作及實現方式。

List常用實現

ArrayList

簡介

在這篇文章中,要學習的是Java集合框架中的ArrayList,下面會討論其屬性、通用場景以及其優缺點。

ArrayList在Java的核心庫中,因此你不需要引入任何額外的庫,只需要import語句就可以使用它:

import java.util.ArrayList
複製程式碼

List表示有序的值序列,其中某些值可以出現多次。

ArrayList是在陣列基礎上的一種List的實現,會隨著新增/刪除元素而動態伸縮,其中的元素可以通過索引(從0開始)容易地訪問。該實現具有以下特性:

  • 隨機訪問時間複雜度為O(1)
  • 新增元素均攤時間複雜度為O(1)
  • 插入/刪除時間複雜度為O(n)
  • 在無序陣列上搜尋的時間複雜度為O(n),有序陣列上耗時為O(log n)

建立

ArrayList具有多個構造器,我們會在本小節一一介紹。

首先,需要注意ArrayList是一個泛型類,因此你可以通過引數指定任何你需要的型別,編譯器會對其進行驗證。舉例來說,你無法將一個Integer物件插入String類的集合中。同樣,當你需要從集合中獲取物件時也無需做轉換。

使用通用介面List作為變數的型別是一種比較好的做法,這樣可以將變數與特定的實現進行解耦。

預設無參建構函式
List<String> list = new ArrayList<>();
assertTrue(list.isEmpty());
複製程式碼

很容易就可以建立一個空的ArrayLis例項。

接受初始容量引數的建構函式
List<String> list = new ArrayList<>(20);
複製程式碼

在該方法中,可以指定底層陣列的初始化長度,有助於避免在增加新元素時出現不必要的擴容操作。

接受集合引數的建構函式
Collection<Integer> number 
  = IntStream.range(0, 10).boxed().collect(toSet());
 
List<Integer> list = new ArrayList<>(numbers);
assertEquals(10, list.size());
assertTrue(numbers.containsAll(list));
複製程式碼

注意,Collection例項中的元素會填充入底層陣列中。

新增元素

ArrayList中,可以在末尾或者指定位置插入元素:

List<Long> list = new ArrayList<>();
 
list.add(1L);
list.add(2L);
list.add(1, 3L);
 
assertThat(Arrays.asList(1L, 3L, 2L), equalTo(list));
複製程式碼

也可以同時插入集合或者多個元素:

List<Long> list = new ArrayList<>(Arrays.asList(1L, 2L, 3L));
LongStream.range(4, 10).boxed()
  .collect(collectingAndThen(toCollection(ArrayList::new), ys -> list.addAll(0, ys)));
assertThat(Arrays.asList(4L, 5L, 6L, 7L, 8L, 9L, 1L, 2L, 3L), equalTo(list));
複製程式碼

列表迭代

ArrayList可以使用兩種型別的迭代器:IteratorListIterator,通過第一類迭代器可以按照一個方向遍歷列表,第二類迭代器可以對列表進行雙向遍歷。

下面是ListIterator的使用方式:

List<Integer> list = new ArrayList<>(
  IntStream.range(0, 10).boxed().collect(toCollection(ArrayList::new))
);
ListIterator<Integer> it = list.listIterator(list.size());
List<Integer> result = new ArrayList<>(list.size());
while (it.hasPrevious()) {
    result.add(it.previous());
}
 
Collections.reverse(list);
assertThat(result, equalTo(list));
複製程式碼

也可以使用迭代器對元素進行查詢、新增或者刪除操作。

列表搜尋

接下來演示如何在集合中進行搜尋操作:

List<String> list = LongStream.range(0,16)
	.boxed()
	.map(Long::toHexString)
	.collect(toCollection(ArrayList::new));
List<String> stringsToSearch = new ArrayList<>(list);
stringsToSearch.addAll(list);
複製程式碼
搜尋無序陣列

如果需要在列表中查詢某個元素,可以使用indexOf()或者lastIndexOf()方法,這兩個方法都接受一個物件為入參,並返回int值。

assertEquals(10, stringsToSearch.indexOf("a"));
assertEquals(26, stringsToSearch.lastIndexOf("a"));
複製程式碼

如果你想要找到滿足條件的所有元素,可以使用Java8中的Stream APIPredicate)來實現:

Set<String> matchingStrings = new HashSet<>(Arrays.asList("a", "c", "9"));
 
List<String> result = stringsToSearch
  .stream()
  .filter(matchingStrings::contains)
  .collect(toCollection(ArrayList::new));
 
assertEquals(6, result.size());
複製程式碼

也可以使用for迴圈或者迭代器來實現:

Iterator<String> it = stringsToSearch.iterator();
Set<String> matchingStrings = new HashSet<>(Arrays.asList("a", "c", "9"));
 
List<String> result = new ArrayList<>();
while (it.hasNext()) {
    String s = it.next();
    if (matchingStrings.contains(s)) {
        result.add(s);
    }
}
複製程式碼
搜尋有序陣列

如果陣列是有序的,則使用二分搜尋會比線性搜尋更快:

List<String> copy = new ArrayList<>(stringsToSearch);
Collections.sort(copy);
int index = Collections.binarySearch(copy, "f");
assertThat(index, not(equalTo(-1)));
複製程式碼

如果元素不存在則會返回-1。

刪除元素

如果你要刪除一個元素,你首先需要找到該元素的索引,如何通過呼叫remove()方法刪除該元素。remove()方法的一個過載方法會接受一個物件作為引數,在列表中搜尋並刪除與其相等的第一個元素:

List<Integer> list = new ArrayList<>(
  IntStream.range(0, 10).boxed().collect(toCollection(ArrayList::new))
);
Collections.reverse(list);
 
list.remove(0);
assertThat(list.get(0), equalTo(8));
 
list.remove(Integer.valueOf(0));
assertFalse(list.contains(0));
複製程式碼

但是在處理Integer之類的裝箱型別時需要注意,如果要刪除某個元素,你需要首先將int值進行裝箱操作,否則刪除的將是該索引對應的元素。

同樣,你也可以使用前面提到的Stream API來刪除一些元素,這裡不做展示。下面是使用迭代器的操作方法:

Set<String> matchingStrings
 = HashSet<>(Arrays.asList("a", "b", "c", "d", "e", "f"));
 
Iterator<String> it = stringsToSearch.iterator();
while (it.hasNext()) {
    if (matchingStrings.contains(it.next())) {
        it.remove();
    }
}
複製程式碼

LinkedList

簡介

LinkedListListDeque介面的雙向連結串列實現,它實現了所有的列表操作,並且可以容納所有的元素(包括null)。

特性

下面是LinkedList的主要特性:

  • 需要索引到連結串列內的操作將從頭部或尾部對連結串列進行遍歷,從離索引位置更近的一端開始;
  • 不保證執行緒安全的,即非synchronized
  • 其中的IteratorListIterator迭代器都採用了快速失敗機制(也就是說,在迭代器建立之後,如果連結串列被修改則會丟擲ConcurrentModificationException 異常)。
  • 其中的每一個元素就是一個節點,每個節點中儲存著指向前驅節點與後繼節點的索引。
  • 保持插入順序。

儘管LinkedList是非同步的,但是可以通過呼叫*Collections.synchronizedList*獲得同步版本,如下所示:

List list = Collections.synchronizedList(new LinkedList(...));
複製程式碼

ArrayList的比較

雖然這兩種都是List介面的實現,但是卻有著不同的語義,而這會影響我們對資料結構的選擇。

結構

ArrayList是在Array基礎上的一種基於索引的資料結構,隨機訪問其元素的效能為O(1)。

LinkedList是以連結串列的形式儲存資料的,每個元素與其前後的節點綴連。在這種情況下,對一個元素的搜尋操作需要的時間複雜度為O(n)。

操作

LinkedList中對元素的插入、新增和刪除操作執行速度更快,因為不需要調整陣列大小,當元素插入到集合中的任意位置時也不需要調整索引,只有前後的元素需要修改。

記憶體使用

LinkedList相比ArrayList要消耗更多的記憶體,因為其中的每個節點都儲存兩個引用,分別指向前驅節點以及後繼節點,而ArrayList中只儲存資料和索引。

使用

下面是一些關於如何使用LinkedList的示例程式碼

建立
LinkedList<Object> linkedList = new LinkedList<>();
複製程式碼
增加元素

LinkedList實現了ListDeque介面,除了標準的*add()addAll()方法之外,還有addFirst()addLast()*方法,分別會在頭尾新增新元素。

刪除元素

與增加元素類似,LinkedList提供了removeFirst()removeLast()方法。同樣,也有便利的方法removeFirstOccurence()removeLastOccurence(),它們的返回值為boolean(如果集合中包含指定元元素,則返回true)。

佇列操作

Deque介面提供了佇列類的操作(實際上Deque繼承了Queue介面):

linkedList.poll();
linkedList.pop();
複製程式碼

這兩個方法會獲取連結串列中的第一個元素,並從連結串列中刪除該元素。

poll()pop()方法的區別在於,當操作空連結串列時,pop會丟擲NoSuchElementException()異常,而poll會返回NULLpollFirst()pollLast() 也是可用的。

下面是push方法的示例:

linkedList.push(Object o);
複製程式碼

該方法會在集合的頭部插入元素。

LinkedList 還有很多其它的方法,對於使用過Lists的使用者而言,其中大多數方法已經熟悉了。其它的方法是Deque介面提供的,是“標準”方法的便利替代方案。

總結

ArrayList通常是List介面的預設實現。

但是,在一些重要的使用場景中,LinkedList是更好的選擇,如追求常數級的插入/刪除耗時,而不要求常數級訪問耗時和高效的記憶體使用(例如,頻繁的插入/刪除/更新)。

CopyOnWriteArrayList

簡介

*CopyOnWriteArrayList*是一個在多執行緒程式設計中很有用的資料結構,我們可以在無需顯式同步操作的前提下,對一個列表進行執行緒安全的遍歷操作。

CopyOnWriteArrayList API

CopyOnWriteArrayList的設計中使用了一個有趣的技巧,不需要同步操作即具有執行緒安全特性。當我們使用任何會對列表結構進行修改的方法(比如add()或者remove())時,CopyOnWriteArrayList中的所有內容將被複制到一個內部副本中。

通過這個簡單的實現,我們可以安全地對列表進行迭代,即使會有併發的修改操作

當我們在對CopyOnWriteArrayList呼叫iterator()方法時,我們會得到一個由CopyOnWriteArrayList內容的不可變快照備份的迭代器。其中的內容是建立迭代器時ArrayList中資料的精確副本。即使在此期間,其他執行緒在列表中新增或刪除了某個元素,該修改也會生成資料的新副本,該副本將用於對該列表進行的後續資料查詢。

CopyOnWriteArrayList這種資料結構的特點使它在迭代操作多於修改時更加有用,如果在應用場景中存在頻繁的增加元素操作,那麼CopyOnWriteArrayList就不是好的選擇,因為多餘的副本會導致效能的急劇下降。

插入時對CopyOnWriteArrayList進行迭代

首先,建立一個儲存整型數的CopyOnWriteArrayList例項:

CopyOnWriteArrayList<Integer> numbers = new CopyOnWriteArrayList<>(new Integer[]{1, 3, 5, 8});
複製程式碼

接下來,我們要對陣列進行迭代,所以建立一個Iterator例項:

Iterator<Integer> iterator = numbers.iterator();
複製程式碼

迭代器建立之後,我們向列表中增加一個新元素:

numbers.add(10);
複製程式碼

注意,當我們建立CopyOnWriteArrayList的迭代器時,得到的是呼叫iterator()方法時列表中的資料的一個不可變快照。

因此,當我們對其進行迭代時,其中沒有數字10:

List<Integer> result = new LinkedList<>();
iterator.forEachRemaining(result::add);
 
assertThat(result).containsOnly(1, 3, 5, 8);
複製程式碼

接著使用新建立的Iterator進行迭代會發現其中包含新加入的數字10:

Iterator<Integer> iterator2 = numbers.iterator();
List<Integer> result2 = new LinkedList<>();
iterator2.forEachRemaining(result2::add);
 
assertThat(result2).containsOnly(1, 3, 5, 8, 10);
複製程式碼

不允許在迭代時進行刪除

CopyOnWriteArrayList設計的目的,在於即使底層列表資料被修改,仍然允許使用者安全地對元素進行遍歷。

由於迭代器的拷貝機制,對於返回的Iterator執行remove()方法是不被允許的,這會導致UnsupportedOperationException

@Test(expected = UnsupportedOperationException.class)
public void IterateOverItAndTryToRemoveElement() {
     
    CopyOnWriteArrayList<Integer> numbers
      = new CopyOnWriteArrayList<>(new Integer[]{1, 3, 5, 8});
 
    Iterator<Integer> iterator = numbers.iterator();
    while (iterator.hasNext()) {
        iterator.remove();
    }
}
複製程式碼

List的常用操作及實現

不可變列表

集合類在Java中是引用型別,在操作的時候可能不經意間被程式修改,類似的“失誤”經常會在程式的其它地方導致錯誤,往往很難定位問題根源。讓ArrayList不可改變,是一個防禦性程式設計技術,可以使用以下方式實現。

JDK

首先,JDK提供了一個方法,可以根據已有的列表獲得一個不可變的集合物件:

Collections.unmodifiableList(list);
複製程式碼

獲得的新集合物件將不能被修改:

@Test(expected = UnsupportedOperationException.class)
public void UnmodifiableListIsCreated() {
    List<String> list = new ArrayList<String>(Arrays.asList("one", "two", "three"));
    List<String> unmodifiableList = Collections.unmodifiableList(list);
    unmodifiableList.add("four");
}
複製程式碼

JDK1.8的Collections類中使用了一個靜態內部類UnmodifiableList對底層陣列進行了封裝,當呼叫get()方法時,會返回對應的元素,如果呼叫set()add()等修改方法時,則會直接丟擲異常UnsupportedOperationException

Guava

Guava中通過ImmutableList提供了一個類似的功能:

ImmutableList.copyOf(list);
複製程式碼

同樣的,得到的結果列表也是不可修改的:

@Test(expected = UnsupportedOperationException.class)
public void UnmodifiableListIsCreatedUsingGuava() {
    List<String> list = new ArrayList<String>(Arrays.asList("one", "two", "three"));
    List<String> unmodifiableList = ImmutableList.copyOf(list);
    unmodifiableList.add("four");
}
複製程式碼

注意這裡的操作實際上返回的是原始列表的一個副本,而不只是一個檢視。

Guava同樣提供了一個構建器,該構建器將返回強型別的ImmutableList而不是簡單的List

@Test(expected = UnsupportedOperationException.class)
public void UnmodifiableListIsCreatedUsingGuavaBuilder() {
    List<String> list = new ArrayList<String>(Arrays.asList("one", "two", "three"));
    ImmutableList<Object> unmodifiableList = ImmutableList.builder().addAll(list).build();
    unmodifiableList.add("four");
}
複製程式碼

Apache Commons Collections

Commons Collection也提供了一個API用於建立不可變列表:

ListUtils.unmodifiableList(list);
複製程式碼

同樣,對結果列表進行修改操作會導致UnsupportedOperationException異常:

@Test(expected = UnsupportedOperationException.class)
public void UnmodifiableListIsCreatedUsingCommonsCollections() {
    List<String> list = new ArrayList<String>(Arrays.asList("one", "two", "three"));
    List<String> unmodifiableList = ListUtils.unmodifiableList(list);
    unmodifiableList.add("four");
}
複製程式碼

劃分列表

這裡討論的劃分是將列表拆分為多個給定大小的子列表。這樣一個相對簡單的需求,Java的標準集合框架API中居然不支援,還好Guava和Apache Commons Collections都實現了該操作。

Guava

Guava通過**Lists.partition**操作來完成列表的劃分:

@Test
public void ParitioningIntoNSublists() {
    List<Integer> intList = Lists.newArrayList(1, 2, 3, 4, 5, 6, 7, 8);
    List<List<Integer>> subSets = Lists.partition(intList, 3);
 
    List<Integer> lastPartition = subSets.get(2);
    List<Integer> expectedLastPartition = Lists.<Integer> newArrayList(7, 8);
    assertThat(subSets.size(), equalTo(3));
    assertThat(lastPartition, equalTo(expectedLastPartition));
}
複製程式碼

Guava中也可以對其它的集合型別進行劃分:

@Test
public void ParitioningIntoNSublists() {
    Collection<Integer> intCollection = Lists.newArrayList(1, 2, 3, 4, 5, 6, 7, 8);
 
    Iterable<List<Integer>> subSets = Iterables.partition(intCollection, 3);
 
    List<Integer> firstPartition = subSets.iterator().next();
    List<Integer> expectedLastPartition = Lists.<Integer> newArrayList(1, 2, 3);
    assertThat(firstPartition, equalTo(expectedLastPartition));
}
複製程式碼

需要注意劃分後的子列表只是源集合型別的檢視,對源集合的操作會影響子列表:

@Test
public void OriginalListModified() {
    // Given
    List<Integer> intList = Lists.newArrayList(1, 2, 3, 4, 5, 6, 7, 8);
    List<List<Integer>> subSets = Lists.partition(intList, 3);
 
    // When
    intList.add(9);
 
    // Then
    List<Integer> lastPartition = subSets.get(2);
    List<Integer> expectedLastPartition = Lists.<Integer> newArrayList(7, 8, 9);
    assertThat(lastPartition, equalTo(expectedLastPartition));
}
複製程式碼

Apache Commons Collections

@Test
public void ParitioningIntoNSublists() {
    List<Integer> intList = Lists.newArrayList(1, 2, 3, 4, 5, 6, 7, 8);
    List<List<Integer>> subSets = ListUtils.partition(intList, 3);
 
    List<Integer> lastPartition = subSets.get(2);
    List<Integer> expectedLastPartition = Lists.<Integer> newArrayList(7, 8);
    assertThat(subSets.size(), equalTo(3));
    assertThat(lastPartition, equalTo(expectedLastPartition));
}
複製程式碼

該方法不能像Guava一樣對原生集合類進行劃分,與Guava一樣的是,該方法返回的是源列表的一個檢視。

Java 8 API

使用Collectors.partitioningBy()將列表分為兩個子列表:

@Test
public void ParitioningIntoSublistsUsingPartitionBy {
    List<Integer> intList = Lists.newArrayList(1, 2, 3, 4, 5, 6, 7, 8);
 
    Map<Boolean, List<Integer>> groups = 
      intList.stream().collect(Collectors.partitioningBy(s -> s > 6));
    List<List<Integer>> subSets = new ArrayList<List<Integer>>(groups.values());
 
    List<Integer> lastPartition = subSets.get(1);
    List<Integer> expectedLastPartition = Lists.<Integer> newArrayList(7, 8);
    assertThat(subSets.size(), equalTo(2));
    assertThat(lastPartition, equalTo(expectedLastPartition));
}
複製程式碼

此外,也可以使用Collectors.groupingBy():

@Test
public final void ParitioningIntoNSublistsUsingGroupingBy() {
    List<Integer> intList = Lists.newArrayList(1, 2, 3, 4, 5, 6, 7, 8);
 
    Map<Integer, List<Integer>> groups = 
      intList.stream().collect(Collectors.groupingBy(s -> (s - 1) / 3));
    List<List<Integer>> subSets = new ArrayList<List<Integer>>(groups.values());
 
    List<Integer> lastPartition = subSets.get(2);
    List<Integer> expectedLastPartition = Lists.<Integer> newArrayList(7, 8);
    assertThat(subSets.size(), equalTo(3));
    assertThat(lastPartition, equalTo(expectedLastPartition));
}
複製程式碼

注意,這裡返回的劃分結果不是源列表的檢視,所以源列表的改動不會影響劃分的子列表。

我們還可以使用分隔符來劃分列表:

@Test
public void SplittingBySeparator() {
    List<Integer> intList = Lists.newArrayList(1, 2, 3, 0, 4, 5, 6, 0, 7, 8);
 
    int[] indexes = 
      Stream.of(IntStream.of(-1), IntStream.range(0, intList.size())
      .filter(i -> intList.get(i) == 0), IntStream.of(intList.size()))
      .flatMapToInt(s -> s).toArray();
    List<List<Integer>> subSets = 
      IntStream.range(0, indexes.length - 1)
               .mapToObj(i -> intList.subList(indexes[i] + 1, indexes[i + 1]))
               .collect(Collectors.toList());
 
    List<Integer> lastPartition = subSets.get(2);
    List<Integer> expectedLastPartition = Lists.<Integer> newArrayList(7, 8);
    assertThat(subSets.size(), equalTo(3));
    assertThat(lastPartition, equalTo(expectedLastPartition));
}
複製程式碼

這個例子中我們使用“0”作為分隔符,首先我們獲取所有“0”元素的索引位置,然後將列表在這些位置上進行劃分。

刪除列表中所有的null

JDK

Java集合框架提供了一個簡單的方法來刪除列表中的所有null元素,使用while迴圈:

@Test
public void RemovingNullsWithPlainJava() {
    List<Integer> list = Lists.newArrayList(null, 1, null);
    while (list.remove(null));
 
    assertThat(list, hasSize(1));
}
複製程式碼

也可以使用稍微簡單一些的方法:

@Test
public void RemovingNullsWithPlainJavaAlternative() {
    List<Integer> list = Lists.newArrayList(null, 1, null);
    list.removeAll(Collections.singleton(null));
 
    assertThat(list, hasSize(1));
}
複製程式碼

注意這兩種方式都會修改源列表。

Google Guava

我們也可以使用Guava來刪除列表中的null元素,通過謂詞可以有一個更函式式的實現:

@Test
public void RemovingNullsWithGuavaV1() {
    List<Integer> list = Lists.newArrayList(null, 1, null);
    Iterables.removeIf(list, Predicates.isNull());
 
    assertThat(list, hasSize(1));
}
複製程式碼

此外,如果不想修改源列表,Guava中也可以建立一個新的過濾後的列表:

@Test
public void RemovingNullsWithGuavaV2() {
    List<Integer> list = Lists.newArrayList(null, 1, null, 2, 3);
    List<Integer> listWithoutNulls = Lists.newArrayList(
      Iterables.filter(list, Predicates.notNull()));
 
    assertThat(listWithoutNulls, hasSize(3));
}
複製程式碼

Apache Commons Collections

接下來看一下使用Apache Commons Collections庫的實現方法,使用函式式風格:

@Test
public void RemovingNullsWithCommonsCollections() {
    List<Integer> list = Lists.newArrayList(null, 1, 2, null, 3, null);
    CollectionUtils.filter(list, PredicateUtils.notNullPredicate());
 
    assertThat(list, hasSize(3));
}
複製程式碼

注意這個方法也會修改源列表

使用Lambda表示式(Java 8)

最後,看一下使用Lambda表示式對列表進行過濾的方法,過濾操作可以並行或者序列執行:

@Test
public void FilteringParallel() {
    List<Integer> list = Lists.newArrayList(null, 1, 2, null, 3, null);
    List<Integer> listWithoutNulls = list.parallelStream()
      .filter(Objects::nonNull)
      .collect(Collectors.toList());
}
 
@Test
public void FilteringSerial() {
    List<Integer> list = Lists.newArrayList(null, 1, 2, null, 3, null);
    List<Integer> listWithoutNulls = list.stream()
      .filter(Objects::nonNull)
      .collect(Collectors.toList());
}
 
public void RemovingNullsWithRemoveIf() {
    List<Integer> listWithoutNulls = Lists.newArrayList(null, 1, 2, null, 3, null);
    listWithoutNulls.removeIf(Objects::isNull);
 
    assertThat(listWithoutNulls, hasSize(3));
}
複製程式碼

刪除列表中的重複項

JDK

使用標準的Java集合框架刪除列表中的重複元素,可以通過Set集合完成:

public void RemovingDuplicatesWithPlainJava() {
    List<Integer> listWithDuplicates = Lists.newArrayList(0, 1, 2, 3, 0, 0);
    List<Integer> listWithoutDuplicates = new ArrayList<>(
      new HashSet<>(listWithDuplicates));
 
    assertThat(listWithoutDuplicates, hasSize(4));
}
複製程式碼

可以看出,該方法不會更改源列表中的資料。

Guava

使用Guava的方法是類似的:

public void RemovingDuplicatesWithGuava() {
    List<Integer> listWithDuplicates = Lists.newArrayList(0, 1, 2, 3, 0, 0);
    List<Integer> listWithoutDuplicates = Lists.newArrayList(Sets.newHashSet(listWithDuplicates));
 
    assertThat(listWithoutDuplicates, hasSize(4));
}
複製程式碼

同樣,源列表的資料沒有被修改。

Lambda表示式

還有一個方法是使用Lambda表示式,我們可以使用*Stream API中的*distinct()方法,該方法會返回一個由不同元素組成的資料流,其中通過*equals()*判斷元素是否相同。

public void RemovingDuplicatesWithJava8() {
    List<Integer> listWithDuplicates = Lists.newArrayList(1, 1, 2, 2, 3, 3);
    List<Integer> listWithoutDuplicates = listWithDuplicates.stream()
     .distinct()
     .collect(Collectors.toList());
}
複製程式碼

查詢列表中的某個元素

為了方便後續說明,我們首先定義一個Customer POJO物件:

public class Customer{
    private int id;
    private String name;
    
    //getter/setter 程式碼省略
    
    @Override
    public boolean equals(Object obj){
        if(obj == null){
            return null;
        }
        if(this == obj){
            return true;
        }
        if(getClass() != obj.getClass()){
            return false;
        }
        final Customer other = (Customer)obj;
        if(id == other.getId()){
            return true;
        }
        return false;
    }

    @Override
    public int hashCode(){
        return this.id;
    }
}
複製程式碼

再定義一個儲存Customer物件的ArrayList

List<Customer> customers = new ArrayList<>();
customers.add(new Customer(1, "zhao"));
customers.add(new Customer(2, "qian"));
customers.add(new Customer(3, "sun"));
複製程式碼

注意,我們在Customer類中重寫了equals()hashCode(),在我們的實現中,具有相同id的兩個Customer物件就認為是相等的。

Java API

Java本身提供了多種方法來查詢列表中的某個元素。List提供了名為***contains()***的方法:

boolean contains(Object element)
複製程式碼

如果列表中包含某個元素,該方法會返回true,否則返回false。所以只需要檢查列表中是否存在某個元素,我們可以這樣做:

Customer qian = new Customer(2, "qian");
if (customers.contains(qian)) {
    // ...
}
複製程式碼

indexOf()也是一個用於查詢元素的方法:

int indexOf(Object element)
複製程式碼

該方法會返回給定列表中某元素第一次出現的位置索引,如果列表中不包含該元素則返回*-1*。因此,如果該方法返回的不是-1,就說明列表中不包含該元素:

if(customers.indexOf(qian) != -1) {
    // ...
}
複製程式碼

這個方法否主要優點就是可以獲得元素在列表中的位置

如果我們需要基於某個欄位來搜尋列表中的元素呢?比如通過名字來指定某個使用者。對於這一類基於欄位的搜尋,我們就需要使用迭代。

對列表進行遍歷的最典型的方法就是使用迴圈,在每個迭代中,將列表中的當前元素與我們搜尋的元素進行對比,確認是否匹配:

public Customer findUsingEnhancedForLoop(String name, List<Customer> customers) {
    for (Customer customer : customers) {
        if (customer.getName().equals(name)) {
            return customer;
        }
    }
    return null;
}
複製程式碼

這裡的name對應的就是我們所搜尋的物件的名稱,該方法會返回匹配該欄位的第一個Customer物件,如果沒有匹配項則返回null

同樣可以使用Iterator來遍歷列表中的項:

public Customer findUsingIterator(
  String name, List<Customer> customers) {
    Iterator<Customer> iterator = customers.iterator();
    while (iterator.hasNext()) {
        Customer customer = iterator.next();
        if (customer.getName().equals(name)) {
            return customer;
        }
    }
    return null;
}
複製程式碼

Stream API

要使用Stream API在給定列表中查詢匹配條件的元素時,可以通過以下步驟完成:

  • 在列表上執行stream()
  • 呼叫包含特定篩選條件的*filter()*方法
  • 呼叫findAny()方法,該方法會將**匹配篩選條件的第一個元素封裝為Optional**並返回
Customer james = customers.stream()
  .filter(customer -> "zhao".equals(customer.getName()))
  .findAny()
  .orElse(null);
複製程式碼

如果Optional為空的話,則預設返回null

Guava

Guava中的做法與stream操作類似:

Customer sun = Iterables.tryFind(customers,
  new Predicate<Customer>() {
      public boolean apply(Customer customer) {
          return "sun".equals(customer.getName());
      }
  }).orNull();
複製程式碼

如果列表或者過濾條件為空,Guava會丟擲一個NullPointerException

Apache Commons Collections

常規操作:

Customer zhang = IterableUtils.find(customers,
  new Predicate<Customer>() {
      public boolean evaluate(Customer customer) {
          return "zhang".equals(customer.getName());
      }
  });
複製程式碼

但是,這裡有兩點區別:

  • 如果傳入列表為null,Apache Commons會返回null
  • 該方法不像Guava中的tryFind()一樣可以設定預設值

列表複製

複製列表的一個簡單方法就是使用以集合為引數的構造器:

List<T> copy = new ArrayList<>(list);
複製程式碼

由於該方法是複製引用而非克隆物件,因此對任何元素的修改都會影響兩個列表。因此,該方法適合於複製不可變物件:

List<Integer> copy = new ArrayList<>(list);
複製程式碼

Integer是一個不可變類,在建立例項時會指定取值,並且對應取值不可更改。因此,整數引用可以由多個列表和執行緒共享,任何人都無法更改它的值。

還有一個複製元素的方案就是使用addAll()方法:

List<Integer> copy = new ArrayList<>();
copy.addAll(list);
複製程式碼

同樣,這裡兩個列表中的元素引用了相同的物件。如果我們在複製列表的同時,另外的執行緒中對其進行修改,則會丟擲***ConcurrentAccessException***。可以通過以下方法解決該問題:

  • 使用可併發訪問的集合
  • 在迭代集合時對其加鎖
  • 尋找一種避免複製源集合的方法

我們剛才所使用的方法就不滿足執行緒安全要求。如果使用第一種方式解決該問題,我們可以使用CopyOnWhiteArrayList,之前說過針對該列表的所有改動都會先建立底層資料的新副本。如果需要對集合進行加鎖,可以使用ReentrantReadWriteLock(可重入讀寫鎖)鎖原語將讀寫操作有序化。

此外,Collections類中也提供了工具方法copy()對集合進行復制,該方法需要傳入一個源列表和一個目標列表,且目標列表的長度至少要與源列表相等。該方法在複製元素的時候會保留元素在源列表中的索引位置。

List<Integer> source = Arrays.asList(1,2,3);
List<Integer> dest = Arrays.asList(4,5,6);
Collections.copy(dest, source); // [1,2,3]
複製程式碼

在這個示例程式中,dest列表原來的項都會被覆蓋,因為兩個列表的長度相等。如果目標列表的長度大於源列表:

List<Integer> source = Arrays.asList(1, 2, 3);
List<Integer> dest = Arrays.asList(5, 6, 7, 8, 9, 10);
Collections.copy(dest, source); // [1,2,3,8,9,10]
複製程式碼

只有前三個元素被覆蓋,其它元素保留。

Java 8中的Stream API也提供了函式式的實現方法:

List<String> copy = list.stream().collect(Collectors.toList());
複製程式碼

該方法的優勢在於可以同時對元素進行過濾和跳過,如跳過第一個元素:

List<String> copy = list.stream()
  .skip(1)
  .collect(Collectors.toList());
複製程式碼

也可以對元素的某些欄位進行過濾比較:

List<String> copy = list.stream()
  .filter(s -> s.length() > 10)
  .collect(Collectors.toList());
複製程式碼

也可以通過以下方法對null值進行處理:

List<Flower> flowers = Optional.ofNullable(list)
  .map(List::stream).orElseGet(Stream::empty)
  .skip(1)
  .collect(Collectors.toList());
複製程式碼

向ArrayList中加入多個元素

首先,我們可以使用addAll()方法向一個ArrayList中增加多個元素,該方法接收一個集合作為入參:

List<Integer> anotherList = Arrays.asList(5, 12, 9, 3, 15, 88);
list.addAll(anotherList);
複製程式碼

這裡需要注意的是,新增到列表list中的新元素與anotherList中的元素將引用同樣的物件。因此,對這些元素的修改會影響兩個列表。

除此之外還可以使用集合工具類Collections,該類中只包含對集合進行操作或者返回值為集合的靜態方法。addAll()就是其中之一,該方法需要傳入目標列表,需要傳入列表的項可以單獨指定也可以以陣列形式傳入。下面是示例:

List<Integer> list = new ArrayList<>();
// 單獨指定元素項
Collections.addAll(list, 1, 2, 3, 4, 5);
// 使用陣列作為入參
Integer[] otherList = new Integer[] {1, 2, 3, 4, 5};
Collections.addAll(list, otherList);
複製程式碼

與上一個方法類似,兩個列表中的內容指向相同的物件

Java 8中的Stream模組提供了新的方法:

List<Integer> source = ...;
List<Integer> target = ...;
 
source.stream().forEachOrdered(target::add);
複製程式碼

這種方式的主要優勢在於可以對元素進行跳過或者過濾。下面是跳過第一個元素的示例:

source.stream().skip(1).forEachOrdered(target::add);
複製程式碼

也可以對元素進行過濾:

source.stream().filter(i -> i > 10).forEachOrdered(target::add);
複製程式碼

如果希望處理方法具有空安全屬性,可以使用Optional

Optional.ofNullable(source).ifPresent(target::addAll)
複製程式碼

這裡通過呼叫addAll()方法將source中的元素加入到了target中。

刪除列表中等於特定值的所有元素

在Java中,從List中刪除某個元素可以直接使用List.remove()方法,但是,想要有效地刪除所有的特定值要困難一些,下面整理了一些可供使用的方式。

使用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);
複製程式碼

問題在於程式碼第三行,我們呼叫了**List.remove(int)**,但是該方法會將傳入的引數作為待刪除元素的索引值,而不是我們需要刪除的元素的值。

在上面的測試用例中,我們一直呼叫list.remove(1),但是我們想要刪除的元素的索引為0,而呼叫該方法會將被刪除元素後面的所有元素都向前移動。因此,最後會刪掉除第一個元素以外的所有元素,當列表中只有一個元素時,索引1就是非法值,所以程式會丟擲異常。

要注意,只有當我們傳入原始型別(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);
    }
}
複製程式碼

雖然這個方法程式碼簡潔,但是效能仍然不足:因為我們並沒有對程式過程進行跟蹤,List.remove()方法只能先找到給定值的第一個索引然後再對其進行刪除。

迴圈使用remove方法

**List.remove(E element)**方法還有一個特點:該方法有一個boolean型別的 返回值,如果列表結構因為該操作發生變化,則返回true,表明列表中包含該元素。要注意,List.remove(int index)方法無返回值,因為如果傳入的索引引數有效,則列表總會刪除該元素,否則就會丟擲IndexOutOfBoundsException

因此我們可以這樣來刪除元素:

void removeAll(List<Integer> list, int element) {
    while (list.remove(element));
}
複製程式碼

該方法也可以正常工作:

// given
List<Integer> list = list(1, 1, 2, 3);
int valueToRemove = 1;
 
// when
removeAll(list, valueToRemove);
 
// then
assertThat(list).isEqualTo(list(2, 3));
複製程式碼

儘管程式碼更簡潔,但是仍然存在前面所說的效能問題。

使用for迴圈

如果使用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));
複製程式碼

但是,如果我們換一個不同的輸入列表,結果就是錯誤的:

// 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 = 0
    • 第3行中elementlist.get(i)值都為1,因此Java進入if程式碼塊
    • 刪除索引0的元素
    • 列表中的值變為[1,2,3]
  • i = 1
    • *list.get(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++;
        }
    }
}
複製程式碼

這裡第二種方法的for條件中去掉了i++

這兩種方法都是有效的,而且這個實現乍一看應該是完全正確的,但是仍然存在一些效能問題:

  • ArrayList中刪除元素,會移動該元素後面的所有元素
  • LinkedList中通過索引訪問元素,意味著需要從表頭遍歷至索引位置。

使用for-each迴圈

Java 5開始可以使用for-each迴圈對列表進行遍歷,我們不妨使用它來刪除元素:

void removeAll(List<Integer> list, int element) {
    for (Integer number : list) {
        if (Objects.equals(number, element)) {
            list.remove(number);
        }
    }
}
複製程式碼

我們在這裡使用了Integer作為迴圈變數型別,因此避免了空指標異常。同時,我們呼叫的是List.remove(E element)方法,傳入引數為我們要刪除的值而不是元素的索引。但是很不幸,這個方法是錯誤的:

// given
List<Integer> list = list(1, 1, 2, 3);
int valueToRemove = 1;
 
// when
assertThatThrownBy(() -> removeWithForEachLoop(list, valueToRemove))
  .isInstanceOf(ConcurrentModificationException.class);
複製程式碼

因為for-each迴圈本質上是使用Iterator對元素進行遍歷,但是當我們修改列表時,Iterator會進入一個不穩定狀態,從而丟擲ConcurrentModificationException異常

我們學到了一點:當通過for-each迴圈訪問列表元素時,不要對列表進行修改。

使用Iterator

我們可以直接使用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();
        }
    }
}
複製程式碼

在上面的程式碼中,Iterator會對列表狀態進行跟蹤,因為列表的修改是由迭代器完成的。因此程式碼可以實現要求:

// given
List<Integer> list = list(1, 1, 2, 3);
int valueToRemove = 1;
 
// when
removeAll(list, valueToRemove);
 
// then
assertThat(list).isEqualTo(list(2, 3));
複製程式碼

因為每一個List類都會提供自己的迭代器實現,我們可以認為,迭代器會以最有效的方式實現元素迭代。但是,在ArrayList時仍然會存在大量的元素移動。

收集元素

截至目前,我們都是通過刪除不需要的元素來修改列表結構。其實我們也可以新建立一個新列表,將需要保留的元素納入其中:

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;
}
複製程式碼

這裡方法的返回值變為了List物件,所以呼叫方式也需要做一些調整:

// 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));
複製程式碼

這個方法中可以使用for-each迴圈的原因在於,我們並沒有對當前正在迭代的列表進行修改操作。同時,由於沒有刪除操作,也就不需要對元素進行移動,因此該方法在使用ArrayList的時候效率較高。

這個實現方法與之前的相比,主要有兩點不同:

  • 沒有對源列表進行修改,而是返回了一個新的列表物件
  • 方法的實現決定了最終返回的List的具體實現,可能會與源列表不同

當然,我們也可以做一些調整,跟前面的方法保持一致。也就是說,將源列表元素清除,再將保留的元素填入其中:

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);
}
複製程式碼

這裡我們不對源列表進行修改,也無需通過索引訪問元素及移動元素,只有兩個地方可能涉及陣列的再分配:呼叫List.clear()List.addAll()方法時。

使用Stream API

Java 8中引入的lambda表示式以及stream API,可以通過非常簡潔的程式碼解決該問題:

List<Integer> removeAll(List<Integer> list, int element) {
    return list.stream()
      .filter(e -> !Objects.equals(e, element))
      .collect(Collectors.toList());
}
複製程式碼

有了lambda表示式及函式式介面之後,Java 8中也引入了一下擴充套件API。List中的removeIf()方法,就可以實現我們所討論的功能。

該方法需要傳入一個謂詞函式,該函式對於需要刪除的元素返回true。這個地方與之前有所區別,前面的判斷都是對於需要保留的元素返回true

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));
複製程式碼

該方案中的程式碼是最為簡潔地,同時由於其中使用的方法removeIf()是由List本身實現的,我們可以放心地認為其效能是最優的。

相關文章