算法挑战:合并日期范围

15

我目前面临一个有趣的问题:

  • 我有几个可能互相重叠的日期范围
  • 每个日期范围都有一个名称

是否可能将这些日期范围进行“去重叠”操作?也就是生成:

  • 一组新的日期范围,其中任何一个不重叠
  • 每个新日期范围都有一个相应名称列表

也许可以通过更加图形化的方式来描述。这是我最初的情况:

a   |------------------------------|
b                    |-------------------|
c          |-----------------|

这就是我想要得到的:

    |------|---------|-------|-----|-----|
        a      a,c     a,b,c   a,b    b

我找到了一种勉强可行但不够优雅的解决方案:

  1. 将每个范围(起始日期和结束日期)转换成一系列日期(d1、d2、d3等)的列表
  2. 按照日期将名称分组
  3. 将包含相同名称的组进行汇总以重新创建范围

你有更好的解决方案吗?我在使用C#,但是任何与语言无关的想法都将不胜感激。谢谢!

6个回答

10

我会:

  1. 保持一个“开放”范围的无序列表。
  2. 从第一天开始,将第一个范围添加到“开放”范围列表中。
  3. 移动到下一个范围边界(无论是开始还是结束)。创建你的第一个“输出”范围:从第一天开始,到那一天结束。其中包含在你的开放范围列表中的项。
  4. 如果遇到的范围已经在开放范围列表中,则将其删除。否则,添加它。
  5. 重复步骤3和4,沿着线移动。

我肯定没有好好解释清楚。我很快就会为此编写一些代码。但在此之前,请看一下在您的解决方案中会发生什么:

a   |------------------------------|
b                    |-------------------|
c          |-----------------|
1. 从第一天开始,加入A到开放范围列表中,现在列表为[A]
2. 移动到C的起始位置。第一个结果区间:从第一天到C的起始位置的范围,
    值为A(即开放范围列表中的内容)
3. 将C添加到开放范围列表中,现在列表为[A,C]
4. 移动到B的起始位置。第二个结果区间:从C的起始位置到B的起始位置的范围,
    值为A,C(即开放范围列表中的内容)
5. 将B添加到开放范围列表中,现在列表为[A,C,B]
6. 移动到C的结束位置。第三个结果区间:从B的起始位置到C的结束位置的范围,
    值为A,C,B
7. 从开放范围列表中删除C,现在列表为[A,B]
8. 移动到A的结束位置。第四个结果区间:从C的结束位置到A的结束位置的范围,
    值为A,B
9. 从开放范围列表中删除A,现在列表为[B]
10. 移动到A的结束位置。第五个结果区间:从A的结束位置到B的结束位置的范围,
    值为B

结果区间:
1. 从第一天到C的起始位置:A
2. 从C的起始位置到B的起始位置:A,C
3. 从B的起始位置到C的结束位置:A,C,B
4. 从C的结束位置到A的结束位置:A,B
5. 从A的结束位置到B的结束位置:B

另一种方法

您可以按照以下步骤进行:

  1. 维护一个“输出范围”的有序列表。 这使得测试点是否在范围内以及哪些范围相互跟随变得容易。
  2. 获取输入范围。
  3. 检查它完全在所有输出范围之前或之后,如果是这样则进行处理并跳过下一步返回第2步。
  4. 将其起始点与输出范围进行比较。
  5. 如果它在任何其他输出范围之前,则添加一个新的输出范围从其起始点到第一个输出范围的起始点。
  6. 如果它在已存在的输出范围之间,则在该点拆分输出范围。 第一部分将保持相同的“父项”,而第二部分将具有相同的“父项”+新的输入范围。
  7. 现在,将其结束点与输出范围进行比较。
  8. 如果它在任何其他输出范围之后,则添加一个新的输出范围,从最后一个输出范围的结束点到其结束点。
  9. 如果它在已存在的输出范围之间,则在该点拆分输出范围。 第二部分将保持相同的“父项”,而第一部分将具有相同的“父项”+新的输入范围。
  10. 将当前输入范围作为步骤6和9中两个“处理过”的范围之间所有范围的一部分添加。
  11. 对所有输入范围重复步骤2-6。

以下是使用示例数据+另一个范围D的前几个步骤: (用 **双星号** 表示“处理过”的范围)

<code>a   |------------------------------|
b                    |-------------------|
c          |-----------------|
d       |------------------------|
</code>

1.
   进程A
   输出范围: |--------------A---------------|
2.
   进程B
     - B的开始在**A**中; 将A分为两部分:
                  |-------A-------|------AB------|
     - B的结束在任何输出范围之后;
         在结尾处创建新的输出范围
                  |-------A-------|------AB------|---B---|
    - **A**和(无)之间没有任何内容。
3.
   进程C
     - C的开始在**A**中; 将A分为两部分:
                  |---A----|--AC--|------AB------|---B---|
     - C的结束在**AB**中; 将AB分为两部分:
                  |---A----|--AC--|--ABC--|--AB--|---B---|
     - **A**和**AB**之间没有范围

4.
   进程D
     - D的开始在**A**中; 将A分为两部分:
                  |-A-|-AD-|--AC--|--ABC--|--AB--|---B---|
     - D的结束在**AB**中; 将AB分为两部分:
                  |-A-|-AD-|--AC--|--ABC--|ABD|AB|---B---|
     - 范围AC和ABC在**A**和**AB**之间
                  |-A-|-AD-|--ACD-|-ABCD--|ABD|AB|---B---|

最终输出:         |-A-|-AD-|--ACD-|-ABCD--|ABD|AB|---B---|

谢谢你的回答。关于你提供的替代方法中的第6点,我有一个问题。我不确定我理解了。你能详细说明一下吗? - b. austen
我已详细说明并添加了演示。 - Justin L.
谢谢 Justin。在第9步中,你提到了第4步和第5步。你是指第5步和第8步吗? - b. austen
1
解决方案可能会忽略一些情况,比如当没有重叠时(即B完全在A之前或之后),因为这时步骤4和8无法起作用。 - b. austen
第一种解决方案仍然正确处理它,我相信。对于第二种方法,我进行了一些轻微的即兴修改。 - Justin L.

2
我已有代码实现此功能。它依赖于输入集按from,然后按to排序(例如,如果是SQL,则为ORDER BY from_value, to_value),但在此之后它是相当优化的。
我的实现基本上就是Justin L.答案所述的操作,因此如果您只想要文本描述,请查看他的答案以获取算法。
该代码在此处:LVK.DataStructures,您需要查看的文件是: 要使用它:
List<Range<DateTime>> ranges = ...
var slices = ranges.Slice();

这将为每个切片提供一个新的范围,每个这样的范围都会有一个.Data属性,其中包含对贡献范围的引用。例如,在您的原始示例中,您将获得确切所需的各个范围,并在.Data属性中包含贡献范围a、b、c等。
这些类可能使用我的类库的其他部分,该库已经全部提供。如果您决定使用它,请复制您想要使用的部分。
如果您只对实现感兴趣,可以在RangeExtensions.cs文件中找到它,第447行及其后面的InternalSlice方法。

2
你可能想要了解一下区间树。请访问Interval Trees了解更多信息。

1

您可以:

  1. 对所有日期(包括起始日期和截止日期)进行排序
  2. 然后在该列表中运行,每个新的范围将从一个日期开始,直到下一个不同于前面日期的起始日期或截止日期。

为了命名新的范围,最好有当前新范围所包含的原始范围名称列表,并且每次遇到截止日期时,从列表中删除旧的范围名称;每次遇到起始日期时,将其名称添加到列表中。


0
做这个:
创建一个“事件”类。
class DateEvent : IComparable<DateEvent>
{
    public Date Date;
    public int DateRangeId;
    public bool IsBegin; // is this the start of a range?

    public int CompareTo(DateEvent other)
    {
        if (Date < other.Date) return -1;
        if (Date > other.Date) return +1;
        if (IsBegin && !other.IsBegin) return -1;
        if (!IsBegin && other.IsBegin) return +1;
        return 0;
    }
}

定义一个 List<DateEvent> events;

将每个范围的开始日期和结束日期添加到列表中:

for (int i = 0; i < dateRanges.Length; ++i)
{
    DateRange r = dateRanges[i];
    events.Add(new DateEvent(r.BeginDate, i, true));
    events.Add(new DateEvent(r.EndDate, i, false));
}

对事件进行排序。

events.Sort();

现在设置一个输出类:

class OutputDateRange
{
    public Date BeginDate;
    public Date EndDate;
    public List<string> Names;
}

最后,遍历事件,维护哪些范围是存在的:
List<int> activeRanges;
List<OutputDateRange> output;
Date current = events[0].Date;
int i = 0;

while (i < events.Length;)
{
    OutputDateRange out = new OutputDateRange();
    out.BeginDate = current;

    // Find ending date for this sub-range.
    while (i < events.Length && events[i].Date == current)
    {
        out.EndDate = events[i].Date;
        if (events[i].IsBegin)
            activeRanges.Add(events[i].DateRangeId);
        else
            activeRanges.Remove(events[i].DateRangeId);
        ++i;
    }

    if (i < events.Length)
        current = events[i].Date;

    foreach (int j in activeRanges)
        out.Names.Add(dateRanges[j].Name);

    output.Add(out);
}

应该就可以了。请注意,我没有创建构造函数,代码有点丑陋,但希望能传达出一般的想法。


嗨Peter,谢谢你的回答!我不明白为什么在第二个while循环中测试日期事件--它会阻止第一个while循环的退出。你能解释一下这部分吗? - b. austen
哎呀,循环之后本应该更新当前值。我会修复它的。 - Peter Alexander
似乎某处出现了错误:第一次退出第二个while循环... - b. austen

0
  1. 我有一个列表,不知道它的长度,但我有3个字符
  2. 检查一个插槽,如果有A?添加'A',如果有B?添加'B',如果有C?添加'C'
  3. 转到另一个插槽,像#2一样循环
  4. 当没有添加到另一个新插槽时结束
  5. 我得到了这个列表
  6. 展平列表

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接