跟着 Guava 学 Java 之 新集合类型

Guava 引入了很多 JDK 没有的、但明显有用的新集合类型。

这些新类型是为了和 JDK 集合框架共存,而没有往 JDK 集合抽象中硬塞其他概念。

作为一般规则,Guava 集合非常精准地遵循了 JDK 接口契约。

Multiset

我们都知道 Set 是无序不重复的,与之相反的是 List 是有序可重复的。 Multiset 是几个意思?

没错 ,Multiset 占据了 List 和 Set 之间的一个灰色地带:允许重复,但是不保证顺序

举个例子,使用 JDK 如果我们想:“统计每个单词出现的次数” 一般这样写:

Map<String, Integer> counts = new HashMap<String, Integer>();
for (String word : words) {
    Integer count = counts.get(word);
    if (count == null) {
        counts.put(word, 1);
    } else {
        counts.put(word, count + 1);
    }
}
复制代码

我用 multiset 就轻松多了,比如

  List<String> languages = List.of("java", "python", "javascript", "java");

  HashMultiset<String> multiset = HashMultiset.create(languages);

  System.out.println(multiset.count("java"));   //结果是:2
  System.out.println(multiset.count("python")); //结果是:1
  
  // 如果你想要不重复元素集合,还可以直接转成 Set 
  // Set<String> words = multiset.elementSet();
复制代码

如果你用传统的 HashMap 做统计,那么后续如果再增加元素,你想变更统计结果是不还得再写个 for 循环往 Map 添加元素计数?用 Multiset 轻松多了,直接 add 就行:

  List<String> languages = List.of("java", "python", "javascript", "java");

  HashMultiset<String> multiset = HashMultiset.create(languages);

  multiset.add("python");
  multiset.addAll(Lists.newArrayList("go", "java", "c"));

  multiset.elementSet().forEach(x -> {
      System.out.println(x + " 的出现次数: " + multiset.count(x));
  });
复制代码

结果:

python 的出现次数: 2
java 的出现次数: 3
c 的出现次数: 1
go 的出现次数: 1
javascript 的出现次数: 1
复制代码

当然如果你的需求比较简单,比如只是简单统计去重后的个数什么的,用 JDK8 以上的流式编程一行代码就能搞定,不用搞这么复杂

words.stream().distinct().count();
复制代码

下面是 multiset 的一些常用方法:

扫描二维码关注公众号,回复: 14458778 查看本文章
方法 描述
count(E) 给定元素在 Multiset 中的计数
elementSet() Multiset 中不重复元素的集合,类型为 Set
entrySet() 和 Map 的 entrySet 类似,返回 Set<Multiset.Entry>,其中包含的 Entry 支持 getElement() 和 getCount() 方法
add(E, int) 增加给定元素在 Multiset 中的计数
remove(E, int) 减少给定元素在 Multiset 中的计数
setCount(E, int) 设置给定元素在 Multiset 中的计数,不可以为负数
size() 返回集合元素的总个数(包括重复的元素)

Guava 提供了多种 Multiset 的实现,大致对应 JDK 中 Map 的各种实现:

Map 对应的 Multiset 是否支持 null 元素
HashMap HashMultiset
TreeMap TreeMultiset 是(如果 comparator 支持的话)
LinkedHashMap LinkedHashMultiset
ConcurrentHashMap ConcurrentHashMultiset
ImmutableMap ImmutableMultiset

总结

使用 Multiset 可以减少 Map 的复杂操作,从而减少代码量,代码量少了,bug 自然少。早点儿下班。

Multimap

有的时候我们需要一个 key 对应多个 value 的这种结构,通常我们会构造类似这样的数据结构:

Map<K, List<V>> 或 Map<K, Set<V>> ,甚至可能更复杂,基于这个还有嵌套数据结构。

如果你需要找到 List 中的某个值是否存在,或者删除 List 中的一个元素 ,又或者要遍历整个数据结构,那么要写一坨代码,老费劲了。

我们来看用 Multimap 怎么做,比如:

    ArrayListMultimap<String, String> multimap = ArrayListMultimap.create();

    multimap.put("Fruits", "Bannana");
    multimap.put("Fruits", "Apple");
    multimap.put("Fruits", "Pear");
    multimap.put("Vegetables", "Carrot");

    multimap.put("language", "java");
    multimap.put("language", "python");
    multimap.put("language", "go");
    multimap.put("language", "python");
复制代码

下面是输出的结果:

{Vegetables=[Carrot], language=[java, python, go, python], Fruits=[Bannana, Apple, Pear, Pear]}

复制代码

如果想得到某一个 key 的 value 直接 get 就可以了

List<String> language = multimap.get("language");
复制代码

如果你想转回原生的那种数据结构也是可以的,使用 asMap() :

Map<String, Collection<String>> stringCollectionMap = multimap.asMap();
复制代码

但要注意,stringCollectionMap 是一个关联的视图,在这个 Map 上的操作会作用于原始的Multimap

下面是一些可以很方便地修改 multimap 的方法:

方法签名 描述 等价于
put(K, V) 添加键到单个值的映射 multimap.get(key).add(value)
putAll(K, Iterable) 依次添加键到多个值的映射 Iterables.addAll(multimap.get(key), values)
remove(K, V) 移除键到值的映射;如果有这样的键值并成功移除,返回 true。 multimap.get(key).remove(value)
removeAll(K) 清除键对应的所有值,返回的集合包含所有之前映射到 K 的值,但修改这个集合就不会影响 Multimap 了。 multimap.get(key).clear()
replaceValues(K, Iterable) 清除键对应的所有值,并重新把 key 关联到 Iterable 中的每个元素。返回的集合包含所有之前映射到 K 的值。 multimap.get(key).clear(); Iterables.addAll(multimap.get(key), values)

迭代 Multimap

Guava MultiMap 提供 keySet(), entries(), values(), keys() 方法类似于 Map 的相应视图集合。

// 使用 `keySet()` 方法遍历 Guava 的 `MultiMap`
for (K key: multimap.keySet()) {
    System.out.println(key + ": " + multimap.get(key));
}

// 使用 `entries()` 方法遍历 Guava 的 `MultiMap`
for (Map.Entry<K, V> entry: multimap.entries()) {
    System.out.println(entry.getKey() + ": " + entry.getValue());
}

复制代码

在 Multimap 中查找键/值

Guava 提供了三种方法,即 containsKey(), containsValue()containsEntry() 检查 multimap 是否包含至少一个键值对,分别具有指定的键、指定的值和指定的键值对。

  ListMultimap<String, String> multimap = ArrayListMultimap.create();
 
  multimap.put("John", "Tyler");
  multimap.put("John", "Kennedy");
  multimap.put("George", "Washington");
  multimap.put("George", "Bush");
  // 检查 multimap 是否包含至少一个键值对
  // 以“John”为键
  if (multimap.containsKey("John")) {
      System.out.println("Multimap contains the \"John\" key");
  }
  // 检查 multimap 是否包含至少一个键值对
  // 以“Kennedy”为值
  if (multimap.containsValue("Kennedy")) {
      System.out.println("Multimap contains the \"Kennedy\" value");
  }
  // 检查 multimap 是否包含至少一个键值对
  // 以“George”为键,以“Washington”为值
  if (multimap.containsEntry("George", "Washington")) {
      System.out.println("Multimap contains the specified mapping");
  }
复制代码

不可变 Multimap

Guava’s Multimap 接口有三个不可变的实现—— ImmutableMultimap, ImmutableListMultimap, 和

ImmutableSetMultimap

ListMultimap<String, String> immutableMultimap =
                ImmutableListMultimap.<String, String>builder()
                                    .put("Zachary", "Taylor")
                                    .put("John", "Adams")
                                    .put("John", "Tyler")
                                    .put("John", "Kennedy")
                                    .put("George", "Washington")
                                    .put("George", "Bush")
                                    .put("Grover", "Cleveland").build();
 
  System.out.println("John" + ": " + immutableMultimap.get("John"));
  
  try {
      // 这将失败,因为 map 是不可变的
      immutableMultimap.put("Obama", "Barack");
  }
  catch (UnsupportedOperationException ex) {
      System.out.print("java.lang.UnsupportedOperationException thrown");
  }
复制代码

Multimap 的各种实现

实现 键行为类似 值行为类似
ArrayListMultimap HashMap ArrayList
HashMultimap HashMap HashSet
LinkedListMultimap LinkedHashMap LinkedList
LinkedHashMultimap LinkedHashMap LinkedHashMap
TreeMultimap TreeMap TreeSet
ImmutableListMultimap ImmutableMap ImmutableList
ImmutableSetMultimap ImmutableMap ImmutableSet

除了两个不可变形式的实现,其他所有实现都支持 null 键和 null 值

注意

我们可以使用 size() 方法来确定 multimap 中键值对的总数。请注意,此方法不会返回多重映射中不同键的总数。要获得不同键的总数,请考虑使用 keySet().size() 或者 asMap().size().

总结

如果你有类似上文的复杂数据结构,请使用 Multimap 它的优点超过 java.util.Map。可能有些同学用过 apache 的org.apache.commons.collections4.MultiValuedMap ,我个人感觉 Guava 的更好用。

BiMap

BiMap 提供了一种 key 和 value 的双向关联的数据结构。

我们知道对于 Map 可能通过 key 获得 value ,但反过来呢,通过 value 怎么取得 key 呢,比较费劲,类似下面代码:

  Map<String, String> testMap = Map.of("language", "java", "fruit", "apple");
  //通过 key 获取 value
  System.out.println(testMap.get("language"));

  //通过 value 获取 key
  for (Map.Entry<String, String> entry : testMap.entrySet()) {

      if (entry.getValue().equals("java")){
          System.out.println(entry.getKey());
          return;
      }
  }
复制代码

你可能会说,不用那么麻烦,我用 stream 一行能搞定,比如:

testMap.entrySet().stream().filter(entry -> entry.getValue().equals("java")).findFirst().get().getKey()
复制代码

这个怎么说呢,行是行,代码可读性也挺好,对于熟悉 stream API 的没问题,不熟悉的理解起来有些成本。

我们来看看用 Guava 的 BiMap 怎么解决:

 HashBiMap<String,String> bimap = HashBiMap.create();

  bimap.put("language","java");
  bimap.put("fruit","apple");

  // 通过 key 获取 value
  System.out.println(bimap.get("fruit"));
  // 通过 value 获取 key
  System.out.println(bimap.inverse().get("apple"));
复制代码

是不是感觉很简洁。

这里要注意:反转的 map 不是新的 map 对象,它实现了一种视图关联,这样你对于反转后的 map 的所有操作都会影响原先的 map 对象

BiMap 数据的强制唯一性

在使用 BiMap 时,会要求 Value 的唯一性。如果 value 重复了则会抛出错误:java.lang.IllegalArgumentException,例如

  HashBiMap<String,String> bimap = HashBiMap.create();

  bimap.put("language","java");
  bimap.put("fruit","apple");
  bimap.put("hello","java");
复制代码

如果我们确实需要插入重复的 value 值,那可以选择 forcePut 方法。但是我们需要注意的是前面的 key 也会被覆盖了。

value 不能重复,本身 map 的 key 就不是重复的,所以 Bimap 等于即不允许 key 重复,也不允许 value 重复。

BiMap 的各种实现

Key-Value Map 实现 Value-Key Map 实现 对应 BiMap 的实现
HashMap HashMap HashBiMap
ImmutableMap ImmutableMap ImmutableBiMap
EnumMap EnumMap EnumBiMap
EnumMap HashMap EnumHashBiMap

Table

当你想使用多个键做索引的时候,你可能会用类似 Map<FirstName, Map<LastName, Person>>的实现,这种方式很丑陋,使用上也不友好。Guava 为此提供了新集合类型Table,它有两个支持所有类型的键:“行”和“列” ,和 sql 中的联合主键有点像?

我们看个例子:

  // 大学课程座位表
  Table<String, String, Integer> universityCourseSeatTable
                = HashBasedTable.create();
  universityCourseSeatTable.put("Mumbai", "Chemical", 120);
  universityCourseSeatTable.put("Mumbai", "IT", 60);
  universityCourseSeatTable.put("Harvard", "Electrical", 60);
  universityCourseSeatTable.put("Harvard", "IT", 120);

  int seatCount
          = universityCourseSeatTable.get("Mumbai", "IT");
  Integer seatCountForNoEntry
          = universityCourseSeatTable.get("Oxford", "IT");
复制代码

通过 “行”和“列” 定位到 value,不需要再像以前一样构造复杂的数据结构以及复杂的遍历代码。

Table 有如下几种实现:

  • HashBasedTable:本质上用 HashMap<R, HashMap<C, V>>实现;
  • TreeBasedTable:本质上用 TreeMap<R, TreeMap<C,V>>实现;
  • ImmutableTable:本质上用 ImmutableMap<R, ImmutableMap<C, V>>实现;注:ImmutableTable 对稀疏或密集的数据集都有优化。
  • ArrayTable:要求在构造时就指定行和列的大小,本质上由一个二维数组实现,以提升访问速度和密集 Table 的内存利用率

检查元素

  Table<String, String, Integer> universityCourseSeatTable = HashBasedTable.create();
  universityCourseSeatTable.put("Mumbai", "Chemical", 120);
  universityCourseSeatTable.put("Mumbai", "IT", 60);
  universityCourseSeatTable.put("Harvard", "Electrical", 60);
  universityCourseSeatTable.put("Harvard", "IT", 120);

  //行列的组合是否存在
  boolean entryIsPresent
          = universityCourseSeatTable.contains("Mumbai", "IT");
  //列是否存在
  boolean courseIsPresent
          = universityCourseSeatTable.containsColumn("IT");
  //行是否存在
  boolean universityIsPresent
          = universityCourseSeatTable.containsRow("Mumbai");
  //值是否存在
  boolean seatCountIsPresent
          = universityCourseSeatTable.containsValue(60);
复制代码

删除元素

想要删除元素,也需要通过“行”和“列”的组合

universityCourseSeatTable.remove("Mumbai", "IT");
复制代码

获取子 map

以“行”为 key ,获取 列和值 的 map

 Map<String, Integer> harvard = universityCourseSeatTable.row("Harvard");
复制代码

以“列”为 key , 获取 行和值的 map

 Map<String, Integer> universitySeatMap = universityCourseSeatTable.column("IT");
复制代码

转换到传统 map

如果你看着 Table 不顺眼了,还可以转换回传统的双层 map 嵌套的数据结构:

    Map<String, Map<String, Integer>> courseKeyUniversitySeatMap
                = universityCourseSeatTable.rowMap();

    Map<String, Map<String, Integer>> universityKeyCourseSeatMap
            = universityCourseSeatTable.columnMap();

    System.out.println(courseKeyUniversitySeatMap);
    System.out.println(universityKeyCourseSeatMap);

复制代码

输出:

{Mumbai={Chemical=120, IT=60}, Harvard={Electrical=60, IT=120}}
{Chemical={Mumbai=120}, IT={Mumbai=60, Harvard=120}, Electrical={Harvard=60}}
复制代码

获得行、列或 value 的集合

  System.out.println(universityCourseSeatTable.rowKeySet());
  System.out.println(universityCourseSeatTable.columnKeySet());
  System.out.println(universityCourseSeatTable.values());

  
  输出:
  [Mumbai, Harvard]
  [Chemical, IT, Electrical]
  [120, 60, 60, 120]
复制代码

行列转换

 Table<String, String, Integer> universityCourseSeatTable
                = HashBasedTable.create();
  universityCourseSeatTable.put("Mumbai", "Chemical", 120);
  universityCourseSeatTable.put("Mumbai", "IT", 60);
  universityCourseSeatTable.put("Harvard", "Electrical", 60);
  universityCourseSeatTable.put("Harvard", "IT", 120);

  for (Table.Cell<String, String, Integer> temp : universityCourseSeatTable.cellSet()) {
      System.out.println(temp.getRowKey() + " " + temp.getColumnKey()+ " " + temp.getValue());
  }

  Table<String,String,Integer> tables2= Tables.transpose(universityCourseSeatTable);

  System.out.println("=====行列转换后=====");
  for (Table.Cell<String, String, Integer> temp : tables2.cellSet()) {
      System.out.println(temp.getRowKey() + " " + temp.getColumnKey()+ " " + temp.getValue());
  }
复制代码

利用cellSet方法可以得到所有的数据行,打印结果,可以看到rowcolumn发生了互换,输出:

Mumbai Chemical 120
Mumbai IT 60
Harvard Electrical 60
Harvard IT 120
=====行列转换后=====
Chemical Mumbai 120
IT Mumbai 60
Electrical Harvard 60
IT Harvard 120
复制代码

总结

虽然 Table 底层代码仍然使用的是嵌套 map 结构,但是经过封装使用起来简单多了,如有类似需求可以直接使用 Table,至于应用场景就看大家的需求了,感觉用好了就像用 SQL 操作内存数据库一样,比调用数据库快多了。

RangeSet

介绍 RangeSet 之前,我们得先了解一下 Guava 的Range 类,其实顾名思义就是表达区间范围,我们看一下它的 type 就明白了:

RangeSet 类是用来存储一些不为空的也不相交的范围的数据结构。假如需要向 RangeSet 的对象中加入一个新的范围,那么任何相交的部分都会被合并起来,所有的空范围都会被忽略。

  RangeSet rangeSet = TreeRangeSet.create();
  rangeSet.add(Range.closed(1, 10));
  System.out.println(rangeSet);
  rangeSet.add(Range.closed(2,8));
  System.out.println(rangeSet);
  rangeSet.add(Range.closedOpen(11, 15));
  System.out.println(rangeSet);
  rangeSet.add(Range.open(15, 20));
  System.out.println(rangeSet);
  rangeSet.add(Range.openClosed(0, 0));
  System.out.println(rangeSet);
  rangeSet.remove(Range.open(5, 10));
  System.out.println(rangeSet);

复制代码

输出:

{[1‥10]}
{[1‥10][11‥15)}
{[1‥10][11‥15)(15‥20)}
{[1‥5][10‥10][11‥15)(15‥20)}
复制代码

得到 rangeSet 互补的范围

们可以用 RangeSet 提供的 complement() 方法,rangeSet.complement() 同样是一个 RangeSet,其中的元素也是互不相交、且不为空的 RangeSet,那么 rangeSet 的互补集可以像下面这样来写:

RangeSet complement = rangeSet.complement();
System.out.println(complement);

输出:
{(-∞‥1)(510)(1011)[1515][20‥+∞)}

复制代码

包含查找

如果想知道某个元素是在 rangeSet 中哪个范围里面,可以这样写:

Range integerRange = rangeSet.rangeContaining(17);
System.out.println(integerRange); 
//输出 (15‥20),因为 17 被包含在 (15‥20) 中,所以输出这个范围。
复制代码

如果想知道某个范围是否包含在 rangeSet 的范围中,可以这样写:

boolean encloses = rangeSet.encloses(Range.closedOpen(18, 20));
System.out.println(encloses);//true. 因为范围 (18,20) 包含在范围 (15,20) 中
encloses = rangeSet.encloses(Range.closedOpen(5, 20));
System.out.println(encloses);//false. 因为范围 (5,20) 不被 rangeSet 中任何范围包含。
复制代码

RangeMap

看到这个名字,聪明的你一定猜到了,它又是跟 Range 相关的,对,没错。

RangeMap 是一种集合类型 ( collection type),它将不相交、且不为空的 Range(key)映射给一个值(Value)。和 RangeSet 不一样,RangeMap 不可以将相邻的区间合并,即使这个区间映射的值是一样的。

举个例子:

 RangeMap<Integer, String> rangeMap = TreeRangeMap.create();

  rangeMap.put(
          Range.closed(90, 100), "偏瘦");
  rangeMap.put(
          Range.closed(100, 130), "正常");
  rangeMap.put(
          Range.closed(101, 111), "正常 1");
  rangeMap.put(
          Range.closed(130, 150), "偏胖");
  rangeMap.put(
          Range.closed(150, 180), "肥胖");

  //正常 1
  System.out.println(rangeMap.get(103));

  //[[90..100)=偏瘦,[100..101)=正常,[101..111]=正常 1, (111..130)=正常,[130..150)=偏胖,[150..180]=肥胖]
  System.out.println(rangeMap); 
复制代码

从输出中我们可以看到,rangeMap 中的每一段 range 都对应着一个 value

TreeMap

通过上面的代码,我们能看到 TreeMap 的一些特性

  • TreeRangeMap 是 RangeMap 的一个实现,保证内部区间不重叠且有序(通过上面的代码能看出来)
  • 如果 TreeRangeMap 要插入的区间与 TreeRangeMap 已保存的区间发生重叠,那么 TreeRangeMap 会对之前的区间切割,保留当前插入区间的完整性
  • TreeRangeMap 虽然以区间作为键,但 get 方法却以单个值 K 作为参数。此时,TreeRangeMap 会先查找这个 K 对应的区间,然后返回这个区间对应的值

remove

remove 方法用来切割 TreeRangeMap 中的键区间 

1)如果 TreeRangeMap 中的某个区间没有被完全删除,那么这个区间只是被切割小,但还是存在于 TreeRangeMap 中  2)如果 TreeRangeMap 中的某个区间被完全删除,那么这个区间和对应的值都被删除掉

subRange

和 RangeSet 不一样,RangeMap 没有提供 complement()、contains()、rangeContaining() 以及 encloses() 方法。

但提供了 subRange ,可以获取一个子区间。

  RangeMap<Integer, String> rangeMap = TreeRangeMap.create();

  rangeMap.put(
          Range.closed(90, 100), "偏瘦");
  rangeMap.put(
          Range.closed(100, 130), "正常");
  rangeMap.put(
          Range.closed(101, 111), "正常 1");
  rangeMap.put(
          Range.closed(130, 150), "偏胖");
  rangeMap.put(
          Range.closed(150, 180), "肥胖");

  RangeMap<Integer, String> subRangeMap1 = rangeMap.subRangeMap(Range.closed(1, 80));
  RangeMap<Integer, String> subRangeMap2 = rangeMap.subRangeMap(Range.closedOpen(93, 150));
  System.out.println(subRangeMap1);
  System.out.println(subRangeMap2);
复制代码

输出结果:

{}
{[93..100)=偏瘦,[100..101)=正常,[101..111]=正常 1, (111..130)=正常,[130..150)=偏胖}
复制代码

从输出结果可以看出,如果要划出的子 Range 和 RangeMap 没有交集,那么返回空,如果有,则返回所有的 Range。

根据 subRange 的特性我想到了一个实用的场景:

假设我们的业务是租赁业务(房子、车、录像带等等),比如租车,一辆车在一天的 24 小时内都可能被租走,如何根据用户预约的租赁时间快速判断一辆车在这个时间段是否被占用?

我们可以把已某辆车哪天的租赁时间段存入 RangeMap ,再调用 subRangeMap 传入预约时间段参数去看有没有交集,如果返回空表明这段时间没被占用,可以租,如果非空则证明有时间冲突不能租。(当然你也可以自己写个贪心算法来解决)

整体跨度

 Range<Integer> span = rangeMap.span();
 System.out.println(span.lowerEndpoint().intValue()); //90
 System.out.println(span.upperEndpoint().intValue()); //180
复制代码

参考

猜你喜欢

转载自juejin.im/post/7129558121231417380