5

I've multiple dates with start and end. These dates could be like follows:

d1:      |----------|
d2:            |------|
d3:        |--------------|
d4:                         |----|
d5:   |----|

Now I need to check the maximum count of overlapping dates. So in this example, we got maximum 3 overlapping dates (d1, d2, d3). Consider, that there can be 0 to n dates.

Can you help me with this task? Thank you in advance.

UPDATE

Input: List of Java-Dates with start and end point, for example List, where MyCustomDate contains start and end date

Output: Overlapping dates (as List of MyCustomDate)

Each time span includes a start and end point of type LocalDateTime with hours and seconds.

marc3l
  • 2,525
  • 7
  • 34
  • 62
  • What is your input and output look like ? – Umeshwaran Jan 11 '21 at 13:05
  • Does the data fit into memory? How are you representing each period? Do you have to support half open periods e.g. no end date? – Karol Dowbecki Jan 11 '21 at 13:06
  • I've update my question. – marc3l Jan 11 '21 at 13:10
  • For inclusive ranges, I can think of a O(n^2) solution. Not sure about edge cases though. First sort the ranges by their starts. For each range `r` in the sorted list, check how many of the ranges does the start of `r` fall in. Find the max. – Sweeper Jan 11 '21 at 13:11
  • what would be the granularity of the overlap? overlap by days for example? – Eugene Jan 11 '21 at 19:33
  • Clarify your last line. Using the word "dates" implies whole days, without a time-of-day. Are you tracking calendar dates or moments? – Basil Bourque Jan 11 '21 at 23:38
  • I've updated my question under the UPDATE section. – marc3l Jan 12 '21 at 10:43
  • @Marcel do you need an update of my answer to return the list of overlapping dates + handling `LocalDateTime` or did you manage to get a solution ? – IQbrod Jan 14 '21 at 10:21
  • 1
    @IQbrod I got the solution using LocalDateTime by just replacing it in your code. Thank you! – marc3l Jan 14 '21 at 11:08

4 Answers4

4

My answer will consider:

  • Given (d3, d5) not overlapping => overlap(d1,d3,d5) = 2 as at a given time only two dates will overlap.
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;

class Event {
    LocalDate startDate; // inclusive
    LocalDate endDate; // inclusive

    Event(LocalDate st, LocalDate end) {
        this.startDate = st;
        this.endDate = end;
    }

    // Getters & Setters omitted
}

public class Main {
    public static void main(String[] args) {
        List<Event> events = new ArrayList<Event>();
        events.add(new Event(LocalDate.of(2019,1,1), LocalDate.of(2019,5,1))); // d1
        events.add(new Event(LocalDate.of(2019,3,1), LocalDate.of(2019,6,1))); // d2
        events.add(new Event(LocalDate.of(2019,2,1), LocalDate.of(2019,7,1))); // d3
        events.add(new Event(LocalDate.of(2019,8,1), LocalDate.of(2019,12,1))); // d4
        // d5 do not overlap d3
        events.add(new Event(LocalDate.of(2018,12,1), LocalDate.of(2019,1,31))); // d5

        Integer startDateOverlaps = events.stream().map(Event::getStartDate).mapToInt(date -> overlap(date, events)).max().orElse(0);
        Integer endDateOverlaps = events.stream().map(Event::getEndDate).mapToInt(date -> overlap(date, events)).max().orElse(0);

        System.out.println(Integer.max(startDateOverlaps, endDateOverlaps));
    }

    public static Integer overlap(LocalDate date, List<Event> events) {
        return events.stream().mapToInt(event -> (! (date.isBefore(event.startDate) || date.isAfter(event.endDate))) ? 1 : 0).sum();
    }
}

We sum each overlapping date (even itself as otherwise (d1, d2, d3) would only count (d2, d3) for d1 check) and test each startDate & endDate.

IQbrod
  • 2,060
  • 1
  • 6
  • 28
  • No need to invent your own class. See [`LocalDateRange`](https://www.threeten.org/threeten-extra/apidocs/org.threeten.extra/org/threeten/extra/LocalDateRange.html) in the [*ThreeTen-Extra*](https://www.threeten.org/threeten-extra/) library. That project is led by the same man who led the *java.time* classes (JSR 310) and its predecessor *Joda-Time*, Stephen Colebourne. `LocalDateRange` offers several comparison methods including: intersection, overlaps, isBefore/isAfter, abuts, and more. – Basil Bourque Jan 11 '21 at 23:11
  • @BasilBourque it's a nice lib but you rely on `range.contains()` to determine if `LocalDateRange` is inclusive or exclusive, whereas I rely on my date comparison stored in `overlap()`. – IQbrod Jan 12 '21 at 07:59
  • For comparing a pair of date-ranges, see the `overlaps` method: [`LocalDateRange#overlaps​( LocalDateRange other )`](https://www.threeten.org/threeten-extra/apidocs/org.threeten.extra/org/threeten/extra/LocalDateRange.html#overlaps(org.threeten.extra.LocalDateRange)). – Basil Bourque Jan 12 '21 at 08:02
  • Given `d1 = new Event(LocalDate.of(2019,1,1), LocalDate.of(2019,5,1));` and `d5 = new Event(LocalDate.of(2018,12,1), LocalDate.of(2019,1,1));` what if endDate is exclusive such as `overlap(d1,d5) = 0` ? You can't because you rely the method given above. – IQbrod Jan 12 '21 at 08:06
  • Your `overlap` method sure looks like [`LocalDateRange#contains( LocalDate )`](https://www.threeten.org/threeten-extra/apidocs/org.threeten.extra/org/threeten/extra/LocalDateRange.html#contains(java.time.LocalDate)) to me. By the way, your seem to be making the end inclusive, while date-time handling is usually best handled with start being inclusive while end is exclusive. – Basil Bourque Jan 12 '21 at 08:07
  • To switch between inclusive/exclusive for whole `LocalDateRange` you can't override only this method as the whole behavior is based on [this assumption](https://github.com/ThreeTen/threeten-extra/blob/f4839af047b7f5ac77cb84c22579a2a44c09330c/src/main/java/org/threeten/extra/LocalDateRange.java#L56) `with the start inclusive and the end exclusive.` – IQbrod Jan 12 '21 at 08:16
  • 1
    Both *java.time* and *ThreeTen-Extra* are thread-safe by design, using [immutable objects](https://en.wikipedia.org/wiki/Immutable_object). Specifically, the [`LocalDateTime`](https://www.threeten.org/threeten-extra/apidocs/org.threeten.extra/org/threeten/extra/LocalDateRange.html) class Javadoc declares: *This class is immutable and thread-safe.* – Basil Bourque Jan 12 '21 at 08:20
  • The proper approach to defining spans of time is: Start is *inclusive* while End is *exclusive*. Otherwise, you cannot neatly abut one span to another. – Basil Bourque Jan 12 '21 at 08:25
2

You could simply generate all events between startDate and endDate for each Event (granularity by a day) and compute a Map, where key is LocalDate(as an individual day) and value is the number of times this date was seen:

long l =
    Collections.max(
            events.stream()
                  .flatMap(x -> Stream.iterate(x.getStartDate(), date -> date.plusDays(1))
                        .limit(ChronoUnit.DAYS.between(x.getStartDate(), x.getEndDate().plusDays(1))))
                  .collect(Collectors.groupingBy(Function.identity(), Collectors.counting()))
                  .values()
    );
Eugene
  • 117,005
  • 15
  • 201
  • 306
  • How can I achieve this using LocalDateTime with hours and seconds? – marc3l Jan 12 '21 at 10:58
  • @Marcel don't get me wrong, but the mere fact that you ask this question means you did not really understood what this is supposed to do. anyway: replace : `date -> date.plusDays(1)` with `date -> date.plus(1, ChronoUnit.SECONDS)` – Eugene Jan 12 '21 at 16:37
2

The other 2 answers that exist at this time are both O(n2), doing a Cartesian Join of all the events. This answer shows an alternative approach with a O(n log n) time complexity.

What we do is that we build an ordered list of dates, and register for each date how many ranges starts and ends on that date. It can be stored as a single number, e.g. if a range ends (-1) and 3 ranges start (+3), the delta for the date is +2.

Basically, each event is actually 2 events, a start event and an end event.

Then we iterate the list, in date order, updating a running total, and remember the max running total.

There are a couple of ways to code that. I'm gonna use regular loops, not streams, and since question says that each date has a start and end point down to milliseconds, we'll go with an DateRange object with two Instant fields.

static int maxRangeOverlaps(List<DateRange> ranges) {
    Map<Instant, Delta> dateDelta = new TreeMap<>();
    for (DateRange range : ranges) {
        dateDelta.computeIfAbsent(range.getStart(), k -> new Delta()).value++;
        dateDelta.computeIfAbsent(range.getEnd(), k -> new Delta()).value--;
    }
    int total = 0, max = 0;
    for (Delta delta : dateDelta.values())
        if ((total += delta.value) > max)
            max = total;
    return max;
}
public final class DateRange {
    private final Instant start; // inclusive
    private final Instant end; // exclusive

    // Constructor and getter methods here
}
final class Delta {
    public int value;
}

Test

//       ....:....1....:....2....:....3
// d1:      |----------|
// d2:            |------|
// d3:        |--------------|
// d4:                         |----|
// d5:   |----|
List<DateRange> ranges = Arrays.asList(
        new DateRange(LocalDate.of(2021,1, 4), LocalDate.of(2021,1,15)),
        new DateRange(LocalDate.of(2021,1,10), LocalDate.of(2021,1,17)),
        new DateRange(LocalDate.of(2021,1, 6), LocalDate.of(2021,1,21)),
        new DateRange(LocalDate.of(2021,1,23), LocalDate.of(2021,1,28)),
        new DateRange(LocalDate.of(2021,1, 1), LocalDate.of(2021,1, 6)));

System.out.println(maxRangeOverlaps(ranges)); // prints 3

The above test was simplified to use LocalDate instead of Instant, by adding a helper constructor:

public DateRange(LocalDate start, LocalDate end) {
    this(start.atStartOfDay().toInstant(ZoneOffset.UTC),
         end.atStartOfDay().toInstant(ZoneOffset.UTC));
}
Andreas
  • 154,647
  • 11
  • 152
  • 247
  • Stackoverflow at its finest. An amazingly interesting answer, totally ignored. By far, the best and smartest from all here. – Eugene Jan 13 '21 at 01:41
2

This Question originally asked for dates. After Answers were posted, the Question was changed to ask for LocalDateTime. I’ll leave this Answer posted, as it (a) answered the Question as originally posted, and (b) might be helpful to others.


The other Answers look interesting and possibly correct. But I find the following code easier to follow and to verify/debug.

Caveat: I do not claim this code is the best, the leanest, nor the fastest. Frankly, my attempt here was just an exercise to push the limits of my own understanding of using Java streams and lambdas.

Do not invent your own class to hold the start/end dates. The ThreeTen-Extra library provides a LocalDateRange class to represent a span-of-time attached to the timeline as a pair of java.time.LocalDate objects. LocalDateRange offers several methods for:

  • Comparison such as abuts and overlaps.
  • Factory methods such as union and intersection.

We can define the inputs using the convenient List.of methods in Java 9 and later, to make an unmodifiable list of LocalDateRange.

List < LocalDateRange > dateRanges =
        List.of(
                LocalDateRange.of( LocalDate.of( 2019 , 1 , 1 ) , LocalDate.of( 2019 , 5 , 1 ) ) ,
                LocalDateRange.of( LocalDate.of( 2019 , 3 , 1 ) , LocalDate.of( 2019 , 6 , 1 ) ) ,
                LocalDateRange.of( LocalDate.of( 2019 , 2 , 1 ) , LocalDate.of( 2019 , 7 , 1 ) ) ,
                LocalDateRange.of( LocalDate.of( 2019 , 8 , 1 ) , LocalDate.of( 2019 , 12 , 1 ) ) , // Not connected to the others.
                LocalDateRange.of( LocalDate.of( 2018 , 12 , 1 ) , LocalDate.of( 2019 , 1 , 31 ) )  // Earlier start, in previous year.
        );

Determine the overall range of dates involved, the very first start and the very last end.

Keep in mind that we are dealing with a list of date-ranges (LocalDateRange), each of which holds a pair of date (LocalDate) objects. The comparator is comparing the starting/ending LocalDate object stored within each LocalDateRange, to get min or max. The get method seen here is getting a LocalDateRange, so we then call getStart/getEnd to retrieve the starting/ending LocalDate stored within.

LocalDate start = dateRanges.stream().min( Comparator.comparing( localDateRange -> localDateRange.getStart() ) ).get().getStart();
LocalDate end = dateRanges.stream().max( Comparator.comparing( localDateRange -> localDateRange.getEnd() ) ).get().getEnd();

Make a list of all the dates within that interval. The LocalDate#datesUntil method generates a stream of LocalDate objects found between a start and end pair of dates. Start is inclusive, while the ending is exclusive.

List < LocalDate > dates =
        start
                .datesUntil( end )
                .collect( Collectors.toList() );

For each of those dates, get a list of the date-ranges containing that date.

Map < LocalDate, List < LocalDateRange > > mapDateToListOfDateRanges = new TreeMap <>();
for ( LocalDate date : dates )
{
    List < LocalDateRange > hits = dateRanges.stream().filter( range -> range.contains( date ) ).collect( Collectors.toList() );
    System.out.println( date + " ➡ " + hits );  // Visually interesting to see on the console.
    mapDateToListOfDateRanges.put( date , hits );
}

For each of those dates, get a count of date-ranges containing that date. We want a count of each List we put into the map above. Generating a new map whose values are the count of a collection in an original map is discussed on my Question, Report on a multimap by producing a new map of each key mapped to the count of elements in its collection value, where I pulled code from Answer by Syco.

Map < LocalDate, Integer > mapDateToCountOfDateRanges =
        mapDateToListOfDateRanges
                .entrySet()
                .stream()
                .collect(
                        Collectors.toMap(
                                ( Map.Entry < LocalDate, List < LocalDateRange > > e ) -> { return e.getKey(); } ,
                                ( Map.Entry < LocalDate, List < LocalDateRange > > e ) -> { return e.getValue().size(); } ,
                                ( o1 , o2 ) -> o1 ,
                                TreeMap :: new
                        )
                );

Unfortunately, there seems to be no way to get a stream to filter more than one entry in a map by maximum value. See: Using Java8 Stream to find the highest values from map.

So first we find the maximum number in a value for one or more entries of our map.

Integer max = mapDateToCountOfDateRanges.values().stream().max( Comparator.naturalOrder() ).get();

Then we filter for only entries with a value of that number, moving those entries to a new map.

Map < LocalDate, Integer > mapDateToCountOfDateRangesFilteredByHighestCount =
        mapDateToCountOfDateRanges
                .entrySet()
                .stream()
                .filter( e -> e.getValue() == max )
                .collect(
                        Collectors.toMap(
                                Map.Entry :: getKey ,
                                Map.Entry :: getValue ,
                                ( o1 , o2 ) -> o1 ,
                                TreeMap :: new
                        )
                );

Dump to console.

System.out.println( "dateRanges = " + dateRanges );
System.out.println( "start/end = " + LocalDateRange.of( start , end ).toString() );
System.out.println( "mapDateToListOfDateRanges = " + mapDateToListOfDateRanges );
System.out.println( "mapDateToCountOfDateRanges = " + mapDateToCountOfDateRanges );
System.out.println( "mapDateToCountOfDateRangesFilteredByHighestCount = " + mapDateToCountOfDateRangesFilteredByHighestCount );

Short results.

[Caveat: I have not manually verified these results. Use this code at your own risk, and do your own verification.]

mapDateToCountOfDateRangesFilteredByHighestCount = {2019-03-01=3, 2019-03-02=3, 2019-03-03=3, 2019-03-04=3, 2019-03-05=3, 2019-03-06=3, 2019-03-07=3, 2019-03-08=3, 2019-03-09=3, 2019-03-10=3, 2019-03-11=3, 2019-03-12=3, 2019-03-13=3, 2019-03-14=3, 2019-03-15=3, 2019-03-16=3, 2019-03-17=3, 2019-03-18=3, 2019-03-19=3, 2019-03-20=3, 2019-03-21=3, 2019-03-22=3, 2019-03-23=3, 2019-03-24=3, 2019-03-25=3, 2019-03-26=3, 2019-03-27=3, 2019-03-28=3, 2019-03-29=3, 2019-03-30=3, 2019-03-31=3, 2019-04-01=3, 2019-04-02=3, 2019-04-03=3, 2019-04-04=3, 2019-04-05=3, 2019-04-06=3, 2019-04-07=3, 2019-04-08=3, 2019-04-09=3, 2019-04-10=3, 2019-04-11=3, 2019-04-12=3, 2019-04-13=3, 2019-04-14=3, 2019-04-15=3, 2019-04-16=3, 2019-04-17=3, 2019-04-18=3, 2019-04-19=3, 2019-04-20=3, 2019-04-21=3, 2019-04-22=3, 2019-04-23=3, 2019-04-24=3, 2019-04-25=3, 2019-04-26=3, 2019-04-27=3, 2019-04-28=3, 2019-04-29=3, 2019-04-30=3}

Full code

For your copy-paste convenience, here is an entire class to run this example code.

package work.basil.example;


import org.threeten.extra.LocalDateRange;

import java.time.LocalDate;
import java.util.*;
import java.util.stream.Collectors;

public class DateRanger
{
    public static void main ( String[] args )
    {
        DateRanger app = new DateRanger();
        app.demo();
    }

    private void demo ( )
    {
        // Input.
        List < LocalDateRange > dateRanges =
                List.of(
                        LocalDateRange.of( LocalDate.of( 2019 , 1 , 1 ) , LocalDate.of( 2019 , 5 , 1 ) ) ,
                        LocalDateRange.of( LocalDate.of( 2019 , 3 , 1 ) , LocalDate.of( 2019 , 6 , 1 ) ) ,
                        LocalDateRange.of( LocalDate.of( 2019 , 2 , 1 ) , LocalDate.of( 2019 , 7 , 1 ) ) ,
                        LocalDateRange.of( LocalDate.of( 2019 , 8 , 1 ) , LocalDate.of( 2019 , 12 , 1 ) ) , // Not connected to the others.
                        LocalDateRange.of( LocalDate.of( 2018 , 12 , 1 ) , LocalDate.of( 2019 , 1 , 31 ) )  // Earlier start, in previous year.
                );


        // Determine first start and last end.
        LocalDate start = dateRanges.stream().min( Comparator.comparing( localDateRange -> localDateRange.getStart() ) ).get().getStart();
        LocalDate end = dateRanges.stream().max( Comparator.comparing( localDateRange -> localDateRange.getEnd() ) ).get().getEnd();
        List < LocalDate > dates =
                start
                        .datesUntil( end )
                        .collect( Collectors.toList() );

        // For each date, get a list of the date-dateRanges containing that date.
        Map < LocalDate, List < LocalDateRange > > mapDateToListOfDateRanges = new TreeMap <>();
        for ( LocalDate date : dates )
        {
            List < LocalDateRange > hits = dateRanges.stream().filter( range -> range.contains( date ) ).collect( Collectors.toList() );
            System.out.println( date + " ➡ " + hits );  // Visually interesting to see on the console.
            mapDateToListOfDateRanges.put( date , hits );
        }

        // For each of those dates, get a count of date-ranges containing that date.
        Map < LocalDate, Integer > mapDateToCountOfDateRanges =
                mapDateToListOfDateRanges
                        .entrySet()
                        .stream()
                        .collect(
                                Collectors.toMap(
                                        ( Map.Entry < LocalDate, List < LocalDateRange > > e ) -> { return e.getKey(); } ,
                                        ( Map.Entry < LocalDate, List < LocalDateRange > > e ) -> { return e.getValue().size(); } ,
                                        ( o1 , o2 ) -> o1 ,
                                        TreeMap :: new
                                )
                        );

        // Unfortunately, there seems to be no way to get a stream to filter more than one entry in a map by maximum value.
        // So first we find the maximum number in a value for one or more entries of our map.
        Integer max = mapDateToCountOfDateRanges.values().stream().max( Comparator.naturalOrder() ).get();
        // Then we filter for only entries with a value of that number, moving those entries to a new map.
        Map < LocalDate, Integer > mapDateToCountOfDateRangesFilteredByHighestCount =
                mapDateToCountOfDateRanges
                        .entrySet()
                        .stream()
                        .filter( e -> e.getValue() == max )
                        .collect(
                                Collectors.toMap(
                                        Map.Entry :: getKey ,
                                        Map.Entry :: getValue ,
                                        ( o1 , o2 ) -> o1 ,
                                        TreeMap :: new
                                )
                        );

        System.out.println( "dateRanges = " + dateRanges );
        System.out.println( "start/end = " + LocalDateRange.of( start , end ).toString() );
        System.out.println( "mapDateToListOfDateRanges = " + mapDateToListOfDateRanges );
        System.out.println( "mapDateToCountOfDateRanges = " + mapDateToCountOfDateRanges );
        System.out.println( "mapDateToCountOfDateRangesFilteredByHighestCount = " + mapDateToCountOfDateRangesFilteredByHighestCount );
    }
}
Basil Bourque
  • 303,325
  • 100
  • 852
  • 1,154
  • 1
    Is thiss also possible using LocalDateTime instead if LocalDate? – marc3l Jan 12 '21 at 10:40
  • the main problem of this is `datesUntil`, which means the granularity is an actual day. – Eugene Jan 12 '21 at 16:41
  • @Marcel No, this code is for dates. You were asking for dates when I wrote this Answer. Then you changed your Question to `LocalDateTime`. You should not be changing the nature of your Question after Answers have been written. Put more effort into preparing your Question *before* you post. It is quite rude of you to waste our time authoring Answers that later look senseless and off-topic. – Basil Bourque Jan 12 '21 at 17:05
  • @Eugene My Answer’s granularity of an actual day was a solution, not a problem, to the *original version of this Question*. The author later changed the Question to ask for `LocalDateTime`. – Basil Bourque Jan 12 '21 at 17:08
  • @BasilBourque right, I did not intend to say otherwise, just bringing the mere point. – Eugene Jan 12 '21 at 17:11
  • 1
    *"You were asking for dates when I wrote this Answer"* Then you didn't read the question, because more than 9 hours before this answer was posted, the question was updated with *"down to milliseconds"* clarification, clearly stating that the question was for full timestamps, not for dates only. --- Even if there is only e.g. 2 date ranges, but they cover e.g. 3 years, the code will process 1000+ days. The code doesn't scale well. --- I certainly don't find the 40+ lines of code for the main logic to be *"easier to follow and to verify/debug"* than the 10 lines of main logic code in my answer. – Andreas Jan 13 '21 at 03:41