横扫Java Collections系列 —— TreeSet

1,814 阅读6分钟

介绍

简言之,TreeSet是一个继承AbstractSet类的有序集合类,实现了NavigableSet接口,该接口中提供了针对给定搜索目标返回最接近匹配项的系列导航方法。主要有以下特点:

  • 其中保存的元素具有唯一性
  • 不保证元素的插入顺序
  • 对元素进行升序排序
  • 非线程安全

TreeSet中,元素按照其自然序升序排列和存储,内部使用了一种自平衡二叉搜索树,也就是红黑树。红黑树作为自平衡二叉搜索树,其中每个节点都额外保有一个比特,用来指示当前的节点颜色是红色或者黑色。这些“颜色”比特在后续的插入或者删除中,有助于确保树结构保持平衡。

创建TreeSet实例很简单:

Set<String> treeSet = new TreeSet<>();

此外,TreeSet还提供了一个有参构造器,可以传入一个Comparable或者Comparator参数,该比较器会决定集合中元素排列的顺序:

Set<String> treeSet = new TreeSet<>(Comparator.comparing(String::length));

尽管TreeSet不是线程安全的容器,但是可以调用Collections.synchronizedSet()装饰方法使其同步化:

Set<String> syncTreeSet = Collections.synchronizedSet(treeSet);

常用方法

知道了如何创建TreeSet实例之后,接着看一下TreeSet中常用的操作。

add()

顾名思义,add()方法可以向TreeSet集合中添加元素,如果元素添加成功,则返回true,否则返回false。该方法约定,对于某元素而言,只有在集合中不存在相同元素时才可以添加。

让我们向TreeSet中加入一个元素:

@Test
public void AddingElement() {
    Set<String> treeSet = new TreeSet<>();
 
    assertTrue(treeSet.add("String Added"));
 }

add()方法非常重要,因为该方法的实现细节说明了TreeSet的内部实现原理,即利用TreeMapput方法来保存元素:

public boolean add(E e) {
    return m.put(e, PRESENT) == null;
}

代码中的变量m指向内部的一个TreeMap实例(注意TreeMap实现了NavigateableMap接口)。因此,当TreeSet内部依赖于一个NavigableMap,当创建一个TreeSet实例时,内部会通过一个TreeMap实例进行初始化:

public TreeSet() {
    this(new TreeMap<E,Object>());
}

contains()

contain()方法可用于检查给定TreeSet中是否包含某特定元素,如果包含则返回true,否则返回false

用法很简单:

@Test
public void CheckingForElement() {
    Set<String> treeSetContains = new TreeSet<>();
    treeSetContains.add("String Added");
 
    assertTrue(treeSetContains.contains("String Added"));
}

remove()

remove()方法用于删除集合中的特定元素,如果集合中包含该特定元素,该方法会返回true

用法如下:

@Test
public void RemovingElement() {
    Set<String> removeFromTreeSet = new TreeSet<>();
    removeFromTreeSet.add("String Added");
 
    assertTrue(removeFromTreeSet.remove("String Added"));
}

clear()

如果想要清除集合中的所有元素,可以使用clear()方法:

@Test
public void ClearingTreeSet() {
    Set<String> clearTreeSet = new TreeSet<>();
    clearTreeSet.add("String Added");
    clearTreeSet.clear();
  
    assertTrue(clearTreeSet.isEmpty());
}

size()

size()方法可以得到TreeSet中元素的个数,该方法也是Set API中的基本方法之一:

@Test
public void CheckTheSizeOfTreeSet() {
    Set<String> treeSetSize = new TreeSet<>();
    treeSetSize.add("String Added");
  
    assertEquals(1, treeSetSize.size());
}

isEmpty()

isEmpty()方法可用于验证给定的TreeSet实例是否为空:

@Test
public void CheckEmptyTreeSet() {
    Set<String> emptyTreeSet = new TreeSet<>();
     
    assertTrue(emptyTreeSet.isEmpty());
}

first()

如果TreeSet不为空,该方法会返回其中的第一个元素,否则会抛出NoSUchElementException异常。示例如下:

@Test
public void GetFirstElement() {
    TreeSet<String> treeSet = new TreeSet<>();
    treeSet.add("First");
    
    assertEquals("First", treeSet.first());
}

last()

与上面的方法类似,如果TreeSet不为空,该方法将返回其中的最后一个元素:

@Test
public void GetLastElement() {
    TreeSet<String> treeSet = new TreeSet<>();
    treeSet.add("First");
    treeSet.add("Last");
     
    assertEquals("Last", treeSet.last());
}

subSet()

该方法接受fromElementtoElement两个参数,并返回TreeeSet中这两个参数指定索引范围之间的所有元素。注意,该区间中包括fromElement,不包括toElement

@Test
public void UseSubSet() {
    SortedSet<Integer> treeSet = new TreeSet<>();
    treeSet.add(1);
    treeSet.add(2);
    treeSet.add(3);
    treeSet.add(4);
    treeSet.add(5);
    treeSet.add(6);
     
    Set<Integer> expectedSet = new TreeSet<>();
    expectedSet.add(2);
    expectedSet.add(3);
    expectedSet.add(4);
    expectedSet.add(5);
 
    Set<Integer> subSet = treeSet.subSet(2, 6);
  
    assertEquals(expectedSet, subSet);
}

headSet()

该方法会返回TreeSet中小于指定项的所有元素:

@Test
public void UseHeadSet() {
    SortedSet<Integer> treeSet = new TreeSet<>();
    treeSet.add(1);
    treeSet.add(2);
    treeSet.add(3);
    treeSet.add(4);
    treeSet.add(5);
    treeSet.add(6);
 
    Set<Integer> subSet = treeSet.headSet(6);
  
    assertEquals(subSet, treeSet.subSet(1, 6));
}

tailSet()

该方法返回TreeSet中大于或等于指定项的所有元素。

@Test
public void UseTailSet() {
    NavigableSet<Integer> treeSet = new TreeSet<>();
    treeSet.add(1);
    treeSet.add(2);
    treeSet.add(3);
    treeSet.add(4);
    treeSet.add(5);
    treeSet.add(6);
 
    Set<Integer> subSet = treeSet.tailSet(3);
  
    assertEquals(subSet, treeSet.subSet(3, true, 6, true));
}

Iterator()

Iterator()方法会返回一个按照升序对集合中的元素进行迭代的迭代器,且该迭代器具有快速失败机制。

升序迭代如下:

@Test
public void IterateTreeSetInAscendingOrder() {
    Set<String> treeSet = new TreeSet<>();
    treeSet.add("First");
    treeSet.add("Second");
    treeSet.add("Third");
    Iterator<String> itr = treeSet.iterator();
    while (itr.hasNext()) {
        System.out.println(itr.next());
    }
}

此外,TreeSet也允许进行降序迭代:

@Test
public void IterateTreeSetInDescendingOrder() {
    TreeSet<String> treeSet = new TreeSet<>();
    treeSet.add("First");
    treeSet.add("Second");
    treeSet.add("Third");
    Iterator<String> itr = treeSet.descendingIterator();
    while (itr.hasNext()) {
        System.out.println(itr.next());
    }
}

如果迭代器已经创建,并且集合被除迭代器的remove()方法之外的其它方式进行修改,迭代器将会抛出ConcurrentModificationException

示例如下:

@Test(expected = ConcurrentModificationException.class)
public void ModifyingTreeSetWhileIterating() {
    Set<String> treeSet = new TreeSet<>();
    treeSet.add("First");
    treeSet.add("Second");
    treeSet.add("Third");
    Iterator<String> itr = treeSet.iterator();
    while (itr.hasNext()) {
        itr.next();
        treeSet.remove("Second");
    }
}

另外,如果使用迭代器的删除方法,则不会抛出异常:

@Test
public void RemovingElementUsingIterator() {
  
    Set<String> treeSet = new TreeSet<>();
    treeSet.add("First");
    treeSet.add("Second");
    treeSet.add("Third");
    Iterator<String> itr = treeSet.iterator();
    while (itr.hasNext()) {
        String element = itr.next();
        if (element.equals("Second"))
           itr.remove();
    }
  
    assertEquals(2, treeSet.size());
}

TreeSet无法对迭代器的快速失败机制作出保证,因为在未同步的并发修改场景中,无法作出任何硬性保证。

Null元素的存储

在Java 7之前,用户可以向空TreeSet对象中添加null。但是,这个被当做了一个bug,因此在后续的版本中不再支持null值的添加。

当我们向TreeSet中添加元素时,其中的元素会按照自然序或者指定的comparator来进行排序。由于null不能与任何值作比较,因此当向TreeSet中添加null时,null与已有元素做比较时,会抛出NullPointerException

@Test(expected = NullPointerException.class)
public void AddNullToNonEmptyTreeSet() {
    Set<String> treeSet = new TreeSet<>();
    treeSet.add("First");
    treeSet.add(null);
}

所有插入TreeSet中的元素要么实现Comparable接口,要么可以作为指定比较器的参数。这些元素之间可以互相比较,即e1.compareTo(e2)或者comparator.compare(e1,e2)都不会抛出ClassCastException

class Element {
    private Integer id;
 
    // Other methods...
}
 
Comparator<Element> comparator = (ele1, ele2) -> {
    return ele1.getId().compareTo(ele2.getId());
};
 
@Test
public void UsingComparator() {
    Set<Element> treeSet = new TreeSet<>(comparator);
    Element ele1 = new Element();
    ele1.setId(100);
    Element ele2 = new Element();
    ele2.setId(200);
     
    treeSet.add(ele1);
    treeSet.add(ele2);
     
    System.out.println(treeSet);
}

TreeSet性能

HashSet相比,TreeSet的性能稍低些。addremovesearch等操作时间复杂度为O(log n),按照存储顺序打印n个元素则耗时为O(n)

如果我们想要按序保存条目,并且按照升序或者降序对集合进行访问和遍历,那么TreeSet就应该作为首选集合。升序方式的操作与视图性能要强于降序方式。

局部性原则——是一个术语,表示根据内存访问模式频繁访问相同值或者相关的存储位置。

当我们说局部性时,表明:

  • 相似的数据通常会被程序以相近的频率访问
  • 如果两个条目按照给定顺序接近,TreeSet会在数据结构中将这两个元素放在相近的位置,内存中也同样。

TreeSet作为一个有着更强局部性特点的数据结构,我们可以根据局部性原理得出结论,如果内存不足并且需要访问自然顺序相对接近的元素,那我们就应该优先考虑TreeSet。如果需要从硬盘中读取数据,因为硬盘读取的延时大大超过缓存与内存读取,因此TreeSet更加适合,因为其有着更好的局部性。