Use Duplicate Keys in a Map with Multimap from Google Guava

Overview

The map implementations provided by the Java JDK don't allow duplicate keys.  

If we try to insert an entry with a key that exists, the map will simply overwrite the previous entry.

In this article, we'll explore a collection type that allows duplicate keys in a map.

Sample Data

Suppose we're working with a set of transactions consisting of a date and dollar amount.

Date Amount
2017-01-01 100.00
2017-01-01 125.00
2017-02-01 75.00

(Ideally, we would want a transaction ID to serve as the key but sometimes we don't have that luxury)

Here's some example code with a HashMap implementation:

public class Main {

  public static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-d");

  public static void main(String[] args) {
    Map<LocalDate, BigDecimal> transactions = new HashMap<>();
    
    transactions.put( LocalDate.parse("2017-01-01",formatter), new BigDecimal("100.00") );
    transactions.put( LocalDate.parse("2017-01-01",formatter), new BigDecimal("125.00") );
    transactions.put( LocalDate.parse("2017-02-01",formatter), new BigDecimal("75.00") );

    System.out.println(transactions); // {2017-01-01=125.00, 2017-02-01=75.00}
  }
}

Notice that the first transaction of $100.00 is wiped out once we insert the next transaction with the same date.

A Workaround

A common workaround for this problem is to use Map<Key, List<Value>> where we can map a key to multiple values using a collection type.

public class Main {

  public static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-d");

  public static void main(String[] args) {
    Map<LocalDate, BigDecimal> transactions = new HashMap<>();
    
    List<BigDecimal> list1 = Arrays.asList(new BigDecimal("100.00"), new BigDecimal("125.00"));
    List<BigDecimal> list2 = Arrays.asList(new BigDecimal("75.00"));

    transactions.put(LocalDate.parse("2017-01-01",formatter), list1);
    transactions.put(LocalDate.parse("2017-02-01",formatter), list2);

    System.out.println(transactions); // {2017-01-01=[100.00,125.00], 2017-02-01=[75.00]}
  }
}

On line 8, we create a list containing the two amounts for 2017-01-01. 

On line 9, we create a single element list containing the amount for 2017-02-01.

On lines 11-12, we add the entries to our map using the lists we created as the values.

The disadvantages with this approach are: 

  • It's awkward to use
  • Our list is not bound to exactly two values
  • Insertion of another amount for an existing date requires a check that the key exists, fetching its list, and adding the new amount to it.

Google Guava project

The Google Guava project offers a set of additional collection types that can make our lives easier.   One of these data structures is called Multimap and it allows us to store duplicate keys in a more elegant fashion.

Let's see how we can use it in our project.

Add Dependency

Since this is an external library, we'll need to add it as a dependency into our project.   We've included the relevant configuration below for the Maven and Gradle build tools.

The current version as of this article is 22.0.   Check the mvnrepository for the latest version.

Maven dependency

<dependency>
  <groupId>com.google.guava</groupId>
  <artifactId>guava</artifactId>
  <version>22.0</version>
</dependency>

Gradle dependency

dependencies {
compile 'com.google.guava:guava:22.0'
}

Using Multimap

We declare a map using the ArrayListMultimap.create() method and populate it with our transactions.  

Multimap<LocalDate, BigDecimal> transactions = ArrayListMultimap.create();

transactions.put( LocalDate.parse("2017-01-01",formatter), new BigDecimal("100.00"));
transactions.put( LocalDate.parse("2017-01-01",formatter), new BigDecimal("125.00"));
transactions.put( LocalDate.parse("2017-02-01",formatter), new BigDecimal("75.00"));

System.out.println(transactions); // {2017-01-01=[100.00, 125.00], 2017-02-01=[75.00]}

Filtering by Key

The Multimaps.filterKeys() method allows us to filter our Multimap by keys matching any arbitrary predicate that we define. A predicate is essentially another name for a boolean test.

Suppose we have the following transactions in our map:

transactions.put( LocalDate.parse("2017-02-01",formatter), new BigDecimal("75.00") );
transactions.put( LocalDate.parse("2017-02-02",formatter), new BigDecimal("3.25") );
transactions.put( LocalDate.parse("2017-02-02",formatter), new BigDecimal("8.12") );
transactions.put( LocalDate.parse("2017-02-05",formatter), new BigDecimal("6.14") );
transactions.put( LocalDate.parse("2017-02-08",formatter), new BigDecimal("64.92") );
transactions.put( LocalDate.parse("2017-02-14",formatter), new BigDecimal("21.86") );
transactions.put( LocalDate.parse("2017-02-17",formatter), new BigDecimal("26.17") );
transactions.put( LocalDate.parse("2017-02-18",formatter), new BigDecimal("6.41") );
transactions.put( LocalDate.parse("2017-02-20",formatter), new BigDecimal("106.47") );
transactions.put( LocalDate.parse("2017-02-23",formatter), new BigDecimal("15.89") );

Say we want to find the transactions that occurred on or after 2017-02-01 but before 2017-02-20.

We can define a helper method named between that creates a Predicate for checking that the dates match our conditions:

private static Predicate<LocalDate> between(final LocalDate begin, final LocalDate end) {
  return new Predicate<LocalDate>() {
    @Override
    public boolean apply(LocalDate date) {
      return (date.compareTo(begin) >= 0 && date.compareTo(end) < 0);
    }
  };
}

Now we can use this method to filter by date:

Multimap<LocalDate, BigDecimal> filtered = Multimaps.filterKeys(transactions,
        between(LocalDate.parse("2017-02-01"), LocalDate.parse("2017-02-20")));

System.out.println(filtered); 
// {2017-02-14=[21.86], 2017-02-08=[64.92], 2017-02-05=[6.14], 2017-02-02=[3.25, 8.12], 2017-02-18=[6.41], 2017-02-01=[75.00], 2017-02-17=[26.17]}