'How to find gaps between multiple, variable DateRanges in C#
I'm doing an appointment finder, where you can input multiple users' appoiments and it should find empty gaps in a list with multiple and variable DateRanges. The methods I wrote do unfortunately not work. How could I do this without a large library like TimePeriodLibrary.
List<DateRange> appointments = new List<DateRange>();
appointments.Add(new DateRange(new DateTime(2020, 7, 6, 10, 30, 0), new DateTime(2020, 7, 6, 11, 30, 0)));
appointments.Add(new DateRange(new DateTime(2020, 7, 7, 8, 30, 0), new DateTime(2020, 7, 7, 15, 30, 0)));
appointments.Add(new DateRange(new DateTime(2020, 7, 6, 16, 30, 0), new DateTime(2020, 7, 6, 17, 0, 0)));
var gaps = FindGapsInUsersCalendars(appointments, 60);
if (gaps.Any())
{
foreach (var gap in gaps)
{
Console.WriteLine($"{gap.Start} - {gap.End}");
}
}
private static List<DateRange> FindGapsInUsersCalendars(List<DateRange> appointments, int minutes)
{
List<DateRange> possibilities = new List<DateRange>();
foreach (var appointment in appointments)
{
if (!DateRangeIncludesDateForMinutes(appointment, appointment.End, minutes)) continue;
possibilities.Add(new DateRange(appointment.Start, appointment.Start.AddMinutes(minutes)));
}
return possibilities;
}
private static bool DateRangeIncludesDateForMinutes(DateRange dateRange, DateTime date, int minutes)
{
var tempDate = date;
for (var i = 0; i < minutes; i++)
{
if (!dateRange.Includes(tempDate.AddMinutes(1))) {
return false;
}
}
return true;
}
DateRange.cs class:
public class DateRange : IRange<DateTime>
{
public DateRange(DateTime start, DateTime end)
{
Start = start;
End = end;
}
public DateTime Start { get; private set; }
public DateTime End { get; private set; }
public bool Includes(DateTime value)
{
return (Start <= value) && (value <= End);
}
public bool Includes(IRange<DateTime> range)
{
return (Start <= range.Start) && (range.End <= End);
}
}
IRange.cs interface:
public interface IRange<T>
{
T Start { get; }
T End { get; }
bool Includes(T value);
bool Includes(IRange<T> range);
}
Solution 1:[1]
I don't have time to create code for this, but here's a relatively simple algorithm you can try to implement in order to find gaps after each appointment:
- Order all appointments by increasing start-time.
- Iterate over the appointments, and for each one:
Search for any appointment that has a start-time that is less than or equal to the current appointment's end-time.
- If so: Does that conflicting appointment end before or at the same time as the current appointment?
- If so, ignore it and continue looking for other conflicting appointments.
- If not (it ends after the current appointment), you know there is no free time directly after the current appointment, so break and skip to checking the next appointment instead.
- (Actually you could skip to the conflicting appointment, since you already know there will at least not be any break until that one ends).
- If not: Congratulations, you've found an appointment after which there is some free time!
- If so: Does that conflicting appointment end before or at the same time as the current appointment?
Tip: Visualize each appointment as a timeline while thinking about this, and look for where they overlap:
|-------------|
|--------|
|------------|
|----------|
|-------|
<free time>
|-----------|
|------------|
Solution 2:[2]
Here is my solution. It uses my own generic range-type with Max/Min rather than Start/End. But I hope it is clear enough to make sense of.
The overall approach is to merge all overlapping ranges into continuous, non-overlapping ranges, and then simply return the space between each subsequent range. I would recommend writing some more tests, there might be edge cases I have not considered.
public bool Intersects<T>(Range<T> left, Range<T> right) where T : IComparable<T>
{
return !(left.Max.CompareTo(right.Min) < 0 ||
right.Max.CompareTo(left.Min) < 0);
}
public Range<T> Union<T>(Range<T> left, Range<T> right) where T : IComparable<T>
{
var min = left.Min.CompareTo(right.Min) < 0 ? left.Min : right.Min;
var max = left.Max.CompareTo(right.Max) > 0 ? left.Max : right.Max;
return new Range<T>(min, max);
}
public List<Range<T>> Merge<T>(IEnumerable<Range<T>> ranges) where T : IComparable<T>
{
var orderedRanges = ranges.OrderBy(r => r.Max).ToList();
for (int i = orderedRanges.Count-2; i >= 0; i--)
{
var current = orderedRanges[i + 1];
var previous = orderedRanges[i];
if (Intersects(current, previous))
{
var union = Union(current, previous);
orderedRanges[i] = union;
orderedRanges.RemoveAt(i+1);
}
}
return orderedRanges;
}
public IEnumerable<Range<T>> Gaps<T>(IEnumerable<Range<T>> ranges) where T : IComparable<T>
{
var merged = Merge(ranges).OrderBy(r => r.Max).ToList();
for (int i = 0; i < merged.Count-1; i++)
{
var current = merged[i];
var next = merged[i + 1];
yield return new Range<T>(current.Max, next.Min);
}
}
Test case:
[Test]
public void TestGaps()
{
var sut = new[]
{
new Range<int>(0, 4),
new Range<int>(1, 2),
new Range<int>(3, 6),
new Range<int>(5, 10),
// Gap
new Range<int>(12, 15),
new Range<int>(13, 14),
// Gap
new Range<int>(20, 25),
};
var results = Gaps(sut);
var expected = new[]
{
new Range<int>(10, 12),
new Range<int>(15, 20),
};
CollectionAssert.AreEqual(expected, results);
}
Edit: The significant difference in my range type is the use of generic constraint to ensure the values can be compared. It is also a struct to avoid allocations. The signature I use is
public readonly struct Range<T> : where T : IComparable<T>
{
public T Min { get; }
public T Max { get; }
public Range(T min, T max) => (Min, Max) = (min, max);
// Bunch of methods
}
You could also use extension methods to gain a similar benefit, like
public static bool Intersects<T>(this IRange<T> self, IRange<T> other) where T : IComparable<T>
or
public static bool Intersects<T>(this IRange<T> self, IRange<T> other, IComparer<T> comparer)
Sources
This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.
Source: Stack Overflow
| Solution | Source |
|---|---|
| Solution 1 | |
| Solution 2 |
