diff --git a/walkthroughs/week-5-tdd/project/src/main/java/com/google/sps/FindMeetingQuery.java b/walkthroughs/week-5-tdd/project/src/main/java/com/google/sps/FindMeetingQuery.java index 2f19ec6..e351e54 100644 --- a/walkthroughs/week-5-tdd/project/src/main/java/com/google/sps/FindMeetingQuery.java +++ b/walkthroughs/week-5-tdd/project/src/main/java/com/google/sps/FindMeetingQuery.java @@ -18,15 +18,21 @@ import java.util.Arrays; import java.util.Collection; import java.util.Collections; +import java.util.stream.Collectors; import java.util.Comparator; import java.util.HashSet; +import java.util.HashMap; +import java.util.Iterator; import java.util.List; +import java.util.Map; import java.util.Set; +import java.util.TreeMap; -/** Lists possible meeting times based on meeting information it takes in. */ +/** + * Supports a query function that lists optimal meeting times based on meeting information it takes + * in. + */ public final class FindMeetingQuery { - private static final int END_OF_DAY = TimeRange.getTimeInMinutes(23, 59); - private static final Comparator ORDER_BY_START_ASC = new Comparator() { @Override @@ -35,106 +41,208 @@ public int compare(Event a, Event b) { } }; - /** - * Returns a list of time periods in which the meeting, specified by request, could happen. If one - * or more time slots exists so that both mandatory and optional attendees can attend, it returns - * those time slots. Otherwise, it returns the time slots that fit just the mandatory attendees. - * - * @param eventsCollection the events we know about - * @param request information about the meeting, including attendees, optional attendees, and how - * long it needs to be - */ - public Collection query(Collection eventsCollection, MeetingRequest request) { - Collection withOptionalAttendees = getMeetingTimes(eventsCollection, request, true); - - // Special case: if no mandatory attendees and optional attendees' schedules cannot fit in a - // meeting, no meeting times are possible. - return !withOptionalAttendees.isEmpty() || request.getAttendees().isEmpty() - ? withOptionalAttendees - : getMeetingTimes(eventsCollection, request, false); - } + // All logic needed to find optimal meeting times with one instance of meeting information and + // events to be considered. + private static class SingleMeetingResolver { + // events are all the events to be considered. + private final Collection events; - private Collection getMeetingTimes( - Collection eventsCollection, - MeetingRequest request, - boolean includeOptionalAttendees) { - HashSet attendees = new HashSet(request.getAttendees()); - if (includeOptionalAttendees) { - attendees.addAll(request.getOptionalAttendees()); + /* request contains information about the meeting, including attendees, optional attendees, and how long it needs to be. + */ private final MeetingRequest request; + private ArrayList optimalMeetingTimes = new ArrayList(); + private long minMeetingDuration; + + SingleMeetingResolver(Collection events, MeetingRequest request) { + this.events = events; + this.request = request; + this.minMeetingDuration = request.getDuration(); } - ArrayList events = getRelevantEvents(attendees, new ArrayList(eventsCollection)); - List possibleMeetingTimes = new ArrayList(); - // Need to check this so we don't access out of bounds when we add first gap. - if (events.isEmpty()) { - addIfLongEnough( - TimeRange.fromStartEnd(0, END_OF_DAY, true), possibleMeetingTimes, request.getDuration()); - return possibleMeetingTimes; + /** + * Returns a list of time periods in which the meeting, specified by request, could happen. If + * no time exists for all optional and mandatory attendees, find the time slot(s) that allow + * mandatory attendees and the greatest possible number of optional attendees to attend. + */ + Collection resolveBestTime() { + ArrayList mandatoryAttendeesMeetingTimes = + getMandatoryAttendeesMeetingTimes(new HashSet(request.getAttendees())); + getOptimalMeetingTimes( + mandatoryAttendeesMeetingTimes, + getChangesInOptionalAttendeesAttendance( + getOptionalAttendeesFreeTimes(new HashSet(request.getOptionalAttendees())))); + + // If there are no meeting times with at least one optional attendee, just return + // mandatoryAttendeesMeetingTimes. + return optimalMeetingTimes.isEmpty() ? mandatoryAttendeesMeetingTimes : optimalMeetingTimes; } - Collections.sort(events, ORDER_BY_START_ASC); - - // Add first gap. - addIfLongEnough( - TimeRange.fromStartEnd(0, events.get(0).getWhen().start(), false), - possibleMeetingTimes, - request.getDuration()); - int end = events.get(0).getWhen().end(); - for (Event event : events) { - // event can be merged with current time range - if (event.getWhen().start() <= end) { - end = Math.max(end, event.getWhen().end()); - continue; + + /** + * Returns all meeting times that allow the most optional attendees to attend. These times must + * also fall in a time satisfying all mandatory attendees and must be at least + * minMeetingDuration long. Utilizes two pointers: one to mandatoryAttendeesMeetingTimes, one to + * changeLog. + * + * @param mandatoryAttendeesMeetingTimes all meeting times satisfying mandatory attendees + * @param changes a change log of the number of available optional attendees over time + */ + private void getOptimalMeetingTimes( + ArrayList mandatoryAttendeesMeetingTimes, TreeMap changes) { + int mandatoryAttendeesMeetingTimesIndex = 0, + prevTime = 0, + bestAttendance = 0, + currAttendance = 0; + for (Map.Entry changeEntry : changes.entrySet()) { + // First need to back up mandatoryAttendeesMeetingTimesIndex in case we missed a time range + // in mandatoryAttendeesMeetingTimes. + mandatoryAttendeesMeetingTimesIndex = Math.max(0, mandatoryAttendeesMeetingTimesIndex - 1); + + // Then compare time range from previous time in changeLog to current time in changeLog + // with mandatoryAttendeesMeetingTimes that overlap with this time range. + while (mandatoryAttendeesMeetingTimesIndex < mandatoryAttendeesMeetingTimes.size() + && mandatoryAttendeesMeetingTimes.get(mandatoryAttendeesMeetingTimesIndex).start() + < (int) changeEntry.getKey()) { + TimeRange meetingRange = + TimeRange.fromStartEnd( + Math.max( + mandatoryAttendeesMeetingTimes + .get(mandatoryAttendeesMeetingTimesIndex) + .start(), + prevTime), + Math.min( + mandatoryAttendeesMeetingTimes.get(mandatoryAttendeesMeetingTimesIndex).end(), + (int) changeEntry.getKey()), + false); + mandatoryAttendeesMeetingTimesIndex++; + if (meetingRange.duration() < minMeetingDuration) { + continue; + } + + // Clear out all former optimal meeting times. They aren't the most optimal anymore. + if (currAttendance > bestAttendance) { + bestAttendance = currAttendance; + optimalMeetingTimes.clear(); + } + if (currAttendance == bestAttendance) { + optimalMeetingTimes.add(meetingRange); + } + } + prevTime = (int) changeEntry.getKey(); + currAttendance += (int) changeEntry.getValue(); } - // Add the time range we were tracking, start a new one from event. - addIfLongEnough( - TimeRange.fromStartEnd(end, event.getWhen().start(), false), - possibleMeetingTimes, - request.getDuration()); - end = event.getWhen().end(); } - // Add the last one we were tracking. - addIfLongEnough( - TimeRange.fromStartEnd(end, END_OF_DAY, true), possibleMeetingTimes, request.getDuration()); - return possibleMeetingTimes; - } + /** + * Returns a mapping of optional attendees to their free times. + * + * @param optionalAttendees everyone who needs to attend this meeting + */ + private HashMap> getOptionalAttendeesFreeTimes( + HashSet optionalAttendees) { + HashMap> times = new HashMap>(); + for (String attendee : optionalAttendees) { + HashSet attendeeSet = new HashSet(); + attendeeSet.add(attendee); - /** - * Adds range to ranges if it is long enough to fit in a meeting. - * - * @param range the range being considered - * @param ranges the list of ranges >= meetingDuration - * @param meetingDuration the duration of meeting to be scheduled - */ - private static void addIfLongEnough( - TimeRange range, List ranges, long meetingDuration) { - if (range.duration() >= meetingDuration) { - ranges.add(range); + // Find all possible meeting times for just this one attendee. Must do this to deal with + // double bookings. + times.put(attendee, getMandatoryAttendeesMeetingTimes(attendeeSet)); + } + return times; } - } - /** - * Returns only those events that are attended by at least one attendee that is attending the - * meeting we are trying to schedule. More intuitively, an event is "relevant" if it is attended - * by at least one "relevant" attendee. - * - * @param requestAttendees the set of attendees attending the meeting ("relevant" people) - */ - private static ArrayList getRelevantEvents( - HashSet relevantAttendees, ArrayList events) { - ArrayList relevantEvents = new ArrayList(); - for (Event event : events) { - boolean isRelevant = false; - for (String person : event.getAttendees()) { - if (relevantAttendees.contains(person)) { - isRelevant = true; - break; + /** + * Returns a change log of the number of available optional attendees over time. First part of a + * sweep-line algorithm. + * + * @param optionalAttendeesFreeTimes mapping of all attendees to their free times + */ + private TreeMap getChangesInOptionalAttendeesAttendance( + HashMap> optionalAttendeesFreeTimes) { + TreeMap changes = new TreeMap(); + for (Map.Entry e : optionalAttendeesFreeTimes.entrySet()) { + for (TimeRange time : (ArrayList) e.getValue()) { + changes.put(time.start(), changes.getOrDefault(time.start(), 0) + 1); + changes.put(time.end(), changes.getOrDefault(time.end(), 0) - 1); } } - if (isRelevant) { - relevantEvents.add(event); + return changes; + } + + /** + * Returns all meeting times that satisfy all mandatory attendees of request. Sorted in + * ascending order. + * + * @param mandatoryAttendees everyone who needs to attend this meeting + */ + private ArrayList getMandatoryAttendeesMeetingTimes( + HashSet mandatoryAttendees) { + ArrayList relevantEvents = getRelevantEvents(mandatoryAttendees); + ArrayList possibleMeetingTimes = new ArrayList(); + + // Need to check this so we don't access out of bounds when we add first gap. + if (relevantEvents.isEmpty()) { + addIfLongEnough( + TimeRange.fromStartEnd(0, TimeRange.END_OF_DAY, true), possibleMeetingTimes); + return possibleMeetingTimes; + } + Collections.sort(relevantEvents, ORDER_BY_START_ASC); + + // Add first gap. + addIfLongEnough( + TimeRange.fromStartEnd(0, relevantEvents.get(0).getWhen().start(), false), + possibleMeetingTimes); + int end = relevantEvents.get(0).getWhen().end(); + for (Event event : relevantEvents) { + // event can be merged with current time range + if (event.getWhen().start() <= end) { + end = Math.max(end, event.getWhen().end()); + continue; + } + // Add the time range we were tracking, start a new one from event. + addIfLongEnough( + TimeRange.fromStartEnd(end, event.getWhen().start(), false), possibleMeetingTimes); + end = event.getWhen().end(); } + + // Add the last one we were tracking. + addIfLongEnough( + TimeRange.fromStartEnd(end, TimeRange.END_OF_DAY, true), possibleMeetingTimes); + return possibleMeetingTimes; } - return relevantEvents; + + /** + * Adds range to ranges if it is long enough to fit in a meeting. + * + * @param range the range being considered + * @param ranges the list of ranges >= meetingDuration + */ + private void addIfLongEnough(TimeRange range, List ranges) { + if (range.duration() >= minMeetingDuration) { + ranges.add(range); + } + } + + /** + * Returns only those events that are attended by at least one attendee that is attending the + * meeting we are trying to schedule. More intuitively, an event is "relevant" if it is attended + * by at least one "relevant" attendee. + * + * @param relevantAttendees the set of attendees attending the meeting ("relevant" people) + */ + private ArrayList getRelevantEvents(HashSet relevantAttendees) { + return new ArrayList( + events.stream() + .filter( + e -> + e.getAttendees().stream() + .filter(relevantAttendees::contains) + .findAny() + .isPresent()) + .collect(Collectors.toList())); + } + } + + public Collection query(Collection events, MeetingRequest request) { + return new SingleMeetingResolver(events, request).resolveBestTime(); } } diff --git a/walkthroughs/week-5-tdd/project/src/test/java/com/google/sps/FindMeetingQueryTest.java b/walkthroughs/week-5-tdd/project/src/test/java/com/google/sps/FindMeetingQueryTest.java index edd435b..17bf14a 100644 --- a/walkthroughs/week-5-tdd/project/src/test/java/com/google/sps/FindMeetingQueryTest.java +++ b/walkthroughs/week-5-tdd/project/src/test/java/com/google/sps/FindMeetingQueryTest.java @@ -35,6 +35,7 @@ public final class FindMeetingQueryTest { private static final String PERSON_A = "Person A"; private static final String PERSON_B = "Person B"; private static final String PERSON_C = "Person C"; + private static final String PERSON_D = "Person D"; // All dates are the first day of the year 2020. private static final int TIME_0800AM = TimeRange.getTimeInMinutes(8, 0); @@ -44,6 +45,7 @@ public final class FindMeetingQueryTest { private static final int TIME_1000AM = TimeRange.getTimeInMinutes(10, 0); private static final int TIME_1100AM = TimeRange.getTimeInMinutes(11, 00); private static final int TIME_1230PM = TimeRange.getTimeInMinutes(12, 30); + private static final int TIME_0600PM = TimeRange.getTimeInMinutes(18, 0); private static final int DURATION_15_MINUTES = 15; private static final int DURATION_30_MINUTES = 30; @@ -354,7 +356,8 @@ public void optionalAttendeeNotScheduled() { @Test public void optionalAttendeeRestrictsMeetingTimes() { - // Meeting should scheduled with an optional person C, thus invalidating a meeting time that was + // Meeting should be scheduled with an optional person C, thus invalidating a meeting time that + // was // valid without person C. // // Events : |--A--||--C--||--B--| @@ -461,12 +464,13 @@ public void onlyOptionalAndPossible() { } @Test - public void onlyOptionalAndImpossible() { - // Two optional attendees with no gaps in their schedules. We should see no gaps returned. + public void onlyOptionalAndDisjoint() { + // Two optional attendees with schedules that perfectly split the day. We should see three + // sections returned. // // Events : |--A--| |---A--------| // |---B----| - // Day : |---------------------------| + // Day : |----||-------||------------| // Options : Collection events = @@ -489,7 +493,206 @@ public void onlyOptionalAndImpossible() { request.addOptionalAttendee(PERSON_B); Collection actual = query.query(events, request); - Collection expected = Arrays.asList(); + Collection expected = + Arrays.asList( + TimeRange.fromStartEnd(TimeRange.START_OF_DAY, TIME_0930AM, false), + TimeRange.fromStartDuration(TIME_0930AM, DURATION_90_MINUTES), + TimeRange.fromStartEnd(TIME_1100AM, TimeRange.END_OF_DAY, true)); + Assert.assertEquals(expected, actual); + } + + @Test + public void onlyOptionalOneLeftOut() { + // Three optional attendees A, B, C. One gap in which A, B can attend, another in which B, C can + // attend, another in which only C can attend. The former two should be returned. + // + // Events : |--A--| |-----A--------| + // |--B--| |---B---| + // |--------C-----| + // Day : |---------------------------| + // Options : |-----| |----| + + Collection events = + Arrays.asList( + new Event( + "Event 1", + TimeRange.fromStartEnd(TimeRange.START_OF_DAY, TIME_0900AM, false), + Arrays.asList(PERSON_A, PERSON_B)), + new Event( + "Event 2", + TimeRange.fromStartEnd(TIME_1100AM, TimeRange.END_OF_DAY, true), + Arrays.asList(PERSON_A)), + new Event( + "Event 3", + TimeRange.fromStartEnd(TIME_0600PM, TimeRange.END_OF_DAY, true), + Arrays.asList(PERSON_B)), + new Event( + "Event 4", + TimeRange.fromStartEnd(TimeRange.START_OF_DAY, TIME_1230PM, false), + Arrays.asList(PERSON_C))); + + MeetingRequest request = new MeetingRequest(Arrays.asList(), DURATION_30_MINUTES); + request.addOptionalAttendee(PERSON_A); + request.addOptionalAttendee(PERSON_B); + request.addOptionalAttendee(PERSON_C); + + Collection actual = query.query(events, request); + Collection expected = + Arrays.asList( + TimeRange.fromStartEnd(TIME_0900AM, TIME_1100AM, false), + TimeRange.fromStartEnd(TIME_1230PM, TIME_0600PM, false)); + Assert.assertEquals(expected, actual); + } + + @Test + public void bothTypesOneLeftOut() { + // Three optional attendees A, B, C. One mandatory attendee D. + // One gap in which A, B, D can attend, another in which only C, D can attend. The former should + // be returned. + // + // Events : |--D--| |---D---| + // |---A----| + // |---B----| + // |-C--| + // Day : |---------------------------| + // Options : |----| + + Collection events = + Arrays.asList( + new Event( + "Event 1", + TimeRange.fromStartEnd(TimeRange.START_OF_DAY, TIME_0900AM, false), + Arrays.asList(PERSON_D)), + new Event( + "Event 2", + TimeRange.fromStartEnd(TIME_0600PM, TimeRange.END_OF_DAY, true), + Arrays.asList(PERSON_D)), + new Event( + "Event 3", + TimeRange.fromStartEnd(TIME_0900AM, TIME_1230PM, false), + Arrays.asList(PERSON_A, PERSON_B)), + new Event( + "Event 4", + TimeRange.fromStartEnd(TIME_1230PM, TIME_0600PM, false), + Arrays.asList(PERSON_C))); + + MeetingRequest request = new MeetingRequest(Arrays.asList(PERSON_D), DURATION_30_MINUTES); + request.addOptionalAttendee(PERSON_A); + request.addOptionalAttendee(PERSON_B); + request.addOptionalAttendee(PERSON_C); + + Collection actual = query.query(events, request); + Collection expected = + Arrays.asList(TimeRange.fromStartEnd(TIME_1230PM, TIME_0600PM, false)); + Assert.assertEquals(expected, actual); + } + + @Test + public void optimalIsSplitDay() { + // Two optional attendees A, B. One mandatory attendee C. + // Only the immeidate start and immediate end should be returned. + // + // Events : |---C---| + // |-------A------| |-B--| + // + // Day : |---------------------------| + // Options : |--------------| |----| + + Collection events = + Arrays.asList( + new Event( + "Event 1", + TimeRange.fromStartEnd(TimeRange.START_OF_DAY, TIME_0900AM, false), + Arrays.asList(PERSON_A)), + new Event( + "Event 2", + TimeRange.fromStartEnd(TIME_1230PM, TimeRange.END_OF_DAY, true), + Arrays.asList(PERSON_B)), + new Event( + "Event 3", + TimeRange.fromStartEnd(TIME_0900AM, TIME_1230PM, false), + Arrays.asList(PERSON_C))); + + MeetingRequest request = new MeetingRequest(Arrays.asList(PERSON_C), DURATION_30_MINUTES); + request.addOptionalAttendee(PERSON_B); + request.addOptionalAttendee(PERSON_A); + + Collection actual = query.query(events, request); + Collection expected = + Arrays.asList( + TimeRange.fromStartEnd(TimeRange.START_OF_DAY, TIME_0900AM, false), + TimeRange.fromStartEnd(TIME_1230PM, TimeRange.END_OF_DAY, true)); + Assert.assertEquals(expected, actual); + } + + @Test + public void optimalNestedEvents() { + // Two optional attendees A, B. One mandatory attendee C. + // B's event lies within A. Only the end of day should be returned. + // + // Events : |-B--| |---C---| + // |-------A------| + // + // Day : |---------------------------| + // Options : |----| + + Collection events = + Arrays.asList( + new Event( + "Event 1", + TimeRange.fromStartEnd(TimeRange.START_OF_DAY, TIME_0900AM, false), + Arrays.asList(PERSON_A)), + new Event( + "Event 2", + TimeRange.fromStartEnd(TIME_0800AM, TIME_0830AM, false), + Arrays.asList(PERSON_B)), + new Event( + "Event 3", + TimeRange.fromStartEnd(TIME_0900AM, TIME_1230PM, false), + Arrays.asList(PERSON_C))); + + MeetingRequest request = new MeetingRequest(Arrays.asList(PERSON_C), DURATION_30_MINUTES); + request.addOptionalAttendee(PERSON_B); + request.addOptionalAttendee(PERSON_A); + + Collection actual = query.query(events, request); + Collection expected = + Arrays.asList(TimeRange.fromStartEnd(TIME_1230PM, TimeRange.END_OF_DAY, true)); + Assert.assertEquals(expected, actual); + } + + @Test + public void optimalDoubleBooked() { + // One optional attendees A. One mandatory attendee C. + // A's events should be counted as one. The first half of the day should be returned. + // + // Events : |--A--||---C--------| + // |----A------| + // + // Day : |---------------------------| + // Options : |--------------| + + Collection events = + Arrays.asList( + new Event( + "Event 1", + TimeRange.fromStartEnd(TimeRange.START_OF_DAY, TIME_0830AM, false), + Arrays.asList(PERSON_A)), + new Event( + "Event 2", + TimeRange.fromStartEnd(TIME_0800AM, TIME_0930AM, false), + Arrays.asList(PERSON_A)), + new Event( + "Event 3", + TimeRange.fromStartEnd(TIME_0930AM, TimeRange.END_OF_DAY, true), + Arrays.asList(PERSON_C))); + + MeetingRequest request = new MeetingRequest(Arrays.asList(PERSON_C), DURATION_30_MINUTES); + request.addOptionalAttendee(PERSON_A); + + Collection actual = query.query(events, request); + Collection expected = + Arrays.asList(TimeRange.fromStartEnd(TimeRange.START_OF_DAY, TIME_0930AM, false)); Assert.assertEquals(expected, actual); } }