如何在C#中的列表中合并日期范围

时间:2019-07-03 10:39:29

标签: c# algorithm

我有一个这样组织的日期列表:

(From, To)
(From, To)
...
(From, To)

我正在尝试找到一种有效地合并范围的方法(它必须相当快,因为​​它是实时合并财务数据流)。

日期不重叠。

我在想的是:

按时间排序所有内容 然后遍历配对以查看Pair1.To == Pair2.From是否合并它们,但这意味着要进行多次迭代。

有没有更好的方法可以做到这一点,例如单次通过

这里有一些例子

(2019-1-10, 2019-1-12)
(2019-3-10, 2019-3-14)
(2019-1-12, 2019-1-13)

预期输出:

(2019-1-10, 2019-1-12) + (2019-1-12, 2019-1-13) -> (2019-1-10, 2019-1-13)
(2019-3-10, 2019-3-14) -> (2019-3-10, 2019-3-14)

实际上,这实际上是大约几秒钟,而不是日期,但是想法是相同的。

5 个答案:

答案 0 :(得分:7)

您提到日期永远不会重叠,但是我认为编写仅合并重叠日期的代码稍微简单一些。第一步是定义日期范围类型:

class Interval
{
    public DateTime From { get; set; }
    public DateTime To { get; set; }
}

然后您可以定义一个扩展方法,该方法检查两个间隔是否重叠:

static class IntervalExtensions
{
    public static bool Overlaps(this Interval interval1, Interval interval2)
        => interval1.From <= interval2.From
           ? interval1.To >= interval2.From : interval2.To >= interval1.From;
}

请注意,此代码假定使用From <= To,因此您可能希望将Interval更改为不可变的类型,并在构造函数中进行验证。

您还需要一种合并两个间隔的方法:

public static Interval MergeWith(this Interval interval1, Interval interval2)
    => new Interval
    {
        From = new DateTime(Math.Min(interval1.From.Ticks, interval2.To.Ticks)),
        To = new DateTime(Math.Max(interval1.From.Ticks, interval2.To.Ticks))
    };

下一步是定义另一种扩展方法,该方法迭代间隔的序列并尝试合并连续的重叠间隔。最好使用迭代器块完成此操作:

public static IEnumerable<Interval> MergeOverlapping(this IEnumerable<Interval> source)
{
    using (var enumerator = source.GetEnumerator())
    {
        if (!enumerator.MoveNext())
            yield break;
        var previousInterval = enumerator.Current;
        while (enumerator.MoveNext())
        {
            var nextInterval = enumerator.Current;
            if (!previousInterval.Overlaps(nextInterval))
            {
                yield return previousInterval;
                previousInterval = nextInterval;
            }
            else
            {
                previousInterval = previousInterval.MergeWith(nextInterval);
            }
        }
        yield return previousInterval;
    }
}

如果两个连续的时间间隔不重叠,则会产生前一个时间间隔。但是,如果它们重叠,则通过合并两个间隔来更新先前的间隔,并将合并的间隔保留为下一次迭代的先前间隔。

您的样本数据未排序,因此在合并间隔之前必须对它们进行排序:

var mergedIntervals = intervals.OrderBy(interval => interval.From).MergeOverlapping();

但是,如果对您在注释中指示的实际数据进行了排序,则可以跳过排序。该算法将对数据进行一次传递,因此为O(n)

答案 1 :(得分:3)

尝试一下:

var source = new[]
{
    new { from = new DateTime(2019, 1, 10), to = new DateTime(2019, 1, 12) },
    new { from = new DateTime(2019, 3, 10), to = new DateTime(2019, 3, 14) },
    new { from = new DateTime(2019, 1, 12), to = new DateTime(2019, 1, 13) },
};

var data =
    source
        .OrderBy(x => x.from)
        .ThenBy(x => x.to)
        .ToArray();

var results =
    data
        .Skip(1)
        .Aggregate(
            data.Take(1).ToList(),
            (a, x) =>
            {
                if (a.Last().to >= x.from)
                {
                    a[a.Count - 1] = new { from = a.Last().from, to = x.to };
                }
                else
                {
                    a.Add(x);
                }
                return a;
            });

这是一个很好的查询,它可以提供所需的输出。

答案 2 :(得分:1)

创建两个词典(即哈希图),一个以“截止日期”作为键,从“截止日期”作为值,另一个以“起始日期”作为键。

遍历日期范围,对于每个范围,请检查“起始日期”字典中是否存在“起始日期”作为关键字,反之亦然。

如果两个都不匹配,则将范围添加到两个字典中。

如果其中一个匹配但另一个不匹配,则从两个字典中删除匹配范围(使用适当的键),将新范围与现有范围合并,然后将结果添加到两者中。

如果两个字典中都有一个匹配项(要添加的范围填补了一个空白),则从两个字典中删除两个匹配项,合并三个范围(两个现有的和一个新的)并将结果添加到两个字典中。

最后,您的字典包含所有日期范围的未排序集合,您可以通过迭代其中一个字典的键来提取这些日期范围。

答案 3 :(得分:1)

这是一个“两字典”实现,可以合并范围而不先对它们进行排序。假设没有重叠,也没有重复的属性。属性重复将导致引发异常。

public static IEnumerable<TSource> Consolidate<TSource, TProperty>(
    this IEnumerable<TSource> source,
    Func<TSource, TProperty> property1Selector,
    Func<TSource, TProperty> property2Selector,
    Func<TSource, TSource, TSource> combine)
{
    var dict1 = source.ToDictionary(property1Selector);
    var dict2 = source.ToDictionary(property2Selector);
    if (dict1.Keys.Count == 0) yield break;
    var first = dict2.Values.First(); // Start with a random element
    var last = first;
    var current = first;
    while (true) // Searching backward
    {
        dict1.Remove(property1Selector(first));
        dict2.Remove(property2Selector(first));
        if (dict2.TryGetValue(property1Selector(first), out current))
        {
            first = current; // Continue searching backward
        }
        else
        {
            while (true) // Searching forward
            {
                if (dict1.TryGetValue(property2Selector(last), out current))
                {
                    last = current; // Continue searching forward
                    dict1.Remove(property1Selector(last));
                    dict2.Remove(property2Selector(last));
                }
                else
                {
                    yield return combine(first, last);
                    break;
                }
            }
            if (dict1.Keys.Count == 0) break;
            first = dict1.Values.First(); // Continue with a random element
            last = first;
        }
    }
}

用法示例:

var source = new List<(DateTime From, DateTime To)>()
{
    (new DateTime(2019, 1, 10), new DateTime(2019, 1, 12)),
    (new DateTime(2019, 3, 10), new DateTime(2019, 3, 14)),
    (new DateTime(2019, 1, 12), new DateTime(2019, 1, 13)),
    (new DateTime(2019, 3, 5), new DateTime(2019, 3, 10)),
};
var consolidated = source
    .Consolidate(r => r.From, r => r.To, (r1, r2) => (r1.From, r2.To))
    .OrderBy(r => r.From)
    .ToList();
foreach (var range in consolidated)
{
    Console.WriteLine($"{range.From:yyyy-MM-dd} => {range.To:yyyy-MM-dd}");
}

输出:

  

2019-01-10 => 2019-01-13
  2019-03-05 => 2019-03-14

答案 4 :(得分:0)

我对使用 MoreLinq 和函数式风格的看法。 IMO,易于理解。这里的大多数行是示例数据,逻辑只有几行(GetAsDays 方法和 all.Segment 调用)

如何完成:我们将日期范围转换为天的集合,合并这些集合并将它们拆分为单独的范围(其中超过 1 天是在下一天的结束和开始之间)。

void Main()
{
    var baseD = new DateTime(01, 01, 01);
    var from = DateTime.Today.Dump("from");

    var to = from.AddDays(20).Dump("to");
    var range1 = GetAsDays(from, to);


    var from2 = DateTime.Today.AddDays(10).Dump("from2");
    var to2 = from2.AddDays(20).Dump("to2");


    var from3 = DateTime.Today.AddDays(50).Dump("from2");
    var to3 = from3.AddDays(10).Dump("to2");

    var range2 = GetAsDays(from2, to2);
    var range3 = GetAsDays(from3, to3);

    var all = range3
    .Union(range1)
    .Union(range2)
    .OrderBy(e=>e);

    var split=all.Segment((iPlus1, i, a) => (iPlus1 - i) > 1);
    
    split.Select(s=>(baseD.AddDays(s.First()),baseD.AddDays(s.Last()))).Dump();


}

public IList<int> GetAsDays(DateTime from, DateTime to)
{
    var baseD = new DateTime(01, 01, 01);
    var fromSpan = from - baseD;
    var toSpan = to - baseD;

    var set1 = Enumerable.Range((int)fromSpan.TotalDays, (int)(toSpan - fromSpan).TotalDays);
    return new List<int>(set1);

}