Java中计算日期间工作日数与检查日期是否重叠

将了解 Java 中计算两个日期之间的工作日数的两种不同方法。另外在约会、预订或项目时间表等各种应用中,避免日程安排冲突至关重要。重叠的日期可能会导致不一致和错误。

计算两个日期之间的工作日数
首先,让我们看看如何使用Stream来做到这一点。该计划是循环两个日期之间的每一天并计算工作日:

long getWorkingDaysWithStream(LocalDate start, LocalDate end){
    return start.datesUntil(end)
      .map(LocalDate::getDayOfWeek)
      .filter(day -> !Arrays.asList(DayOfWeek.SATURDAY, DayOfWeek.SUNDAY).contains(day))
      .count();
}

首先,我们使用LocalDate的 dateUntil()方法。此方法返回从开始日期(包括)到结束日期(不包括)的所有日期的Stream 。

接下来,我们使用map()和LocalDate的 getDayOfWeek()将每个日期转换为一天。例如,这会将 10-01-2023 更改为星期三。

接下来,我们通过对照DaysOfWeek 枚举来检查所有周末,从而过滤掉它们。最后,我们可以计算剩下的天数,因为我们知道这些都是工作日。

这种方法不是最快的,因为我们必须每天查看它。然而,它很容易理解,并且提供了在需要时轻松进行额外检查或处理的机会。

高效搜索,无需循环
我们的另一个选择是不循环所有的日子,而是应用我们知道的关于一周中的日子的规则。这里我们需要几个步骤,以及一些需要处理的边缘情况。

1. 设置初始日期
首先,我们将定义我们的方法签名,它与之前的非常相似:

long getWorkingDaysWithoutStream(LocalDate start, LocalDate end)


处理这些日期的第一步是排除开始和结束时的任何周末。因此,对于开始日期,如果是周末,我们将选择下周一。我们还将跟踪我们使用boolean执行此操作的事实:

boolean startOnWeekend = false;
if(start.getDayOfWeek().getValue() > 5){
    start = start.with(TemporalAdjusters.next(DayOfWeek.MONDAY));
    startOnWeekend = true;
}

我们在这里使用了TemporalAdjusters类,特别是它的next()方法,它允许我们跳转到下一个指定的日期。

然后,我们可以对结束日期执行相同的操作 - 如果是周末,我们就采用前一个星期五。这次我们将使用TemporalAdjusters.previous()将我们带到给定日期之前我们想要的第一天:

boolean endOnWeekend = false;
if(end.getDayOfWeek().getValue() > 5){
    end = end.with(TemporalAdjusters.previous(DayOfWeek.FRIDAY));
    endOnWeekend = true;
}

2. 考虑边缘情况
如果我们从周六开始并在周日结束,这已经给我们带来了潜在的边缘情况。在这种情况下,我们的开始日期将是星期一,结束日期是之前的星期五。开始在结束之后是没有意义的,因此我们可以通过快速检查来涵盖这个潜在的用例:

if(start.isAfter(end)){
    return 0;
}

我们还需要涵盖另一个边缘情况,这就是我们跟踪周末开始和结束的原因。这是可选的,取决于我们想要如何计算天数。例如,如果我们计算同一周的星期二和星期五,我们会​​说它们之间有三天。

我们还可以说星期六和下一个星期六之间有五个工作日。但是,如果我们像这里所做的那样将开始日和结束日移至星期一和星期五,那么现在算作四天。因此,为了解决这个问题,我们可以根据需要简单地添加一天:

long addValue = startOnWeekend || endOnWeekend ? 1 : 0;

3. 最终计算
我们现在可以计算开始和结束之间的总周数。为此,我们将使用ChronoUnit的 Between()方法。此方法计算两个Temporal对象之间的时间,以指定的单位(在我们的例子中为WEEKS):

long weeks = ChronoUnit.WEEKS.between(start, end);

最后,我们可以使用迄今为止收集到的所有信息来获得工作日数的最终值:

return ( weeks * 5 ) + ( end.getDayOfWeek().getValue() - start.getDayOfWeek().getValue() ) + addValue;

这里的步骤首先是将周数乘以每周的工作日数。我们还没有考虑非整周,因此我们添加了一周开始日和一周结束日之间的额外天数。最后,我们添加了在周末开始或结束的调整。

总之:这种方法是应用我们所知道的关于一周中的几天的规则来计算,而不需要循环。这提供了效率,但牺牲了可读性和可维护性。

检查两个日期范围是否重叠
我们确保清楚地了解两个日期范围重叠的含义。在实施高效且准确的重叠检查器时,了解构成日期范围重叠的不同场景至关重要。让我们分解一下需要考虑的各种场景。

  • 部分重叠:当一个日期范围与另一日期范围部分重叠时,就会发生部分重叠。当两个范围共享其时间范围的某些部分但不完全包含彼此时,就会发生这种情况。
  • 完全重叠:当一个日期范围完全包含另一个日期范围时,就会发生完全重叠。当一个日期范围完全落入另一个日期范围的边界时,就会发生这种情况。
  • 连续范围:如果两个范围在另一个范围开始之前立即结束,则认为两个范围相邻。
  • 零范围持续时间和连续范围:零范围持续时间是指范围的开始日期和结束日期相同、导致持续时间为零的情况。例如,我们可能会遇到一个安排在一天但没有持续时间的事件。尽管看似特殊情况,但在考虑连续范围时,显式处理这种情况对于确保日期范围重叠逻辑的正确性至关重要


重叠公式
各种数学公式封装了确定日期范围重叠的逻辑。在这里,我们将探讨三个常用的公式并解释它们的基本原理。
我们首先定义公式中使用的变量:

  • A:第一个日期范围的开始日期
  • B:第一个日期范围的结束日期
  • C:第二个日期范围的开始日期
  • D:第二个日期范围的结束日期

计算重叠持续时间
此公式通过从较晚的开始日期中减去较早的结束日期来计算重叠持续时间:

(min(B, D) - max(A, C)) >= 0

如果此持续时间为负,则不存在重叠。如果为零,则范围可能会接触,或者可能是单个时间点。应用程序应考虑是否将此场景视为重叠。此外,正的持续时间告诉我们存在重叠。

检查非重叠条件
此公式检查两个日期范围之间的不重叠条件。如果任一条件失败,则存在重叠:

!(B <= C || A >= D)

请注意,如果我们更改条件以使用严格不等式“<”和“>”,则意味着范围不能完全接触。两个范围之间不应有共同点。再次,应用程序应决定是否将此场景视为重叠。

 寻找最小 重叠
此实现通过考虑四个计算中的最小值来计算重叠持续时间(以天为单位)。如果最小重叠持续时间为零或负数,则意味着不存在重叠,因为范围在非零持续时间中不相交。正的最小重叠持续时间表示实际重叠,表明范围共享大于零的持续时间:

min((B - A), (B - C), (D - A), (D - C)) >= 0
但是,如果我们将其更改为仅大于零,则条件会变得更严格。这意味着必须不仅仅是在单个点上进行触摸,而且必须是非零重叠持续时间。

现在我们已经探索了不同的重叠场景和公式,接下来让我们深入研究实现代码,以使用各种方法准确检测这些重叠。

1. 使用日历
我们将利用Calendar类来管理日期范围并检查重叠。Calendar类是java.util包的一部分,提供了一种处理日期和时间的方法。getTimeInMillis ()方法用于获取每个Calendar 实例的时间(以毫秒为单位)。

在此示例中,我们将利用Math .min()和Math.max()方法通过从较晚的开始日期中减去较早的结束日期来计算重叠持续时间:

boolean isOverlapUsingCalendarAndDuration(Calendar start1, Calendar end1, Calendar start2, Calendar end2) {
    long overlap = Math.min(end1.getTimeInMillis(), end2.getTimeInMillis()) -
      Math.max(start1.getTimeInMillis(), start2.getTimeInMillis());
    return overlap > 0;
}

为了实现非重叠条件检查,我们将利用Calendar类提供的before()和after()方法。这些方法评估日期实例之间的时间顺序关系,对于确定重叠至关重要。如果任一条件为true,则表示重叠:

boolean isOverlapUsingCalendarAndCondition(Calendar start1, Calendar end1, Calendar start2, Calendar end2) {
    return !(end1.before(start2) || start1.after(end2));
}

现在,让我们使用查找最小值公式来实现逻辑。该方法计算不同场景之间的最小重叠持续时间:

boolean isOverlapUsingCalendarAndFindMin(Calendar start1, Calendar end1, Calendar start2, Calendar end2) {
    long overlap1 = Math.min(end1.getTimeInMillis() - start1.getTimeInMillis(), 
      end1.getTimeInMillis() - start2.getTimeInMillis());
    long overlap2 = Math.min(end2.getTimeInMillis() - start2.getTimeInMillis(), 
      end2.getTimeInMillis() - start1.getTimeInMillis());
   return Math.min(overlap1, overlap2) / (24 * 60 * 60 * 1000) >= 0;
}

为了确保我们实现的正确性,我们可以使用代表不同日期范围的Calendar实例来设置测试数据。

首先,让我们创建开始日期范围:

Calendar start1 = Calendar.getInstance().set(2024, 11, 15); 
Calendar end1 = Calendar.getInstance().set(2024, 11, 20);

随后,我们可以将结束日期范围设置为部分重叠:

Calendar start2 = Calendar.getInstance()set(2024, 11, 18);
Calendar end2 = Calendar.getInstance().set(2024, 11, 22);
assertTrue(isOverlapUsingCalendarAndDuration(start1, end1, start2, end2));
assertTrue(isOverlapUsingCalendarAndCondition(start1, end1, start2, end2));
assertTrue(isOverlapUsingCalendarAndFindMin(start1, end1, start2, end2));

下面是相应的单元测试代码,用于查看测试数据是否完全重叠:

Calendar start2 = Calendar.getInstance()set(2024, 11, 16);
Calendar end2 = Calendar.getInstance().set(2024, 11, 18);
assertTrue(isOverlapUsingCalendarAndDuration(start1, end1, start2, end2));
assertTrue(isOverlapUsingCalendarAndCondition(start1, end1, start2, end2));
assertTrue(isOverlapUsingCalendarAndFindMin(start1, end1, start2, end2));

最后,我们将结束日期范围设置为连续范围,并且我们应该期望不会重叠:

Calendar start2 = Calendar.getInstance()set(2024, 11, 21);
Calendar end2 = Calendar.getInstance().set(2024, 11, 24);
assertFalse(isOverlapUsingCalendarAndDuration(start1, end1, start2, end2)); 
assertFalse(isOverlapUsingCalendarAndCondition(start1, end1, start2, end2)); 
assertFalse(isOverlapUsingCalendarAndFindMin(start1, end1, start2, end2));


2. 使用 Java 8 的LocalDate
Java 8 引入了java.time。LocalDate类,它提供了一种更现代、更方便的日期处理方式。我们可以利用此类来简化日期范围重叠检查。在计算重叠持续时间时,我们利用LocalDate类提供的toEpochDay()方法。此方法用于获取给定LocalDate的纪元日的天数:

boolean isOverlapUsingLocalDateAndDuration(LocalDate start1, LocalDate end1, LocalDate start2, LocalDate end2) {
    long overlap = Math.min(end1.toEpochDay(), end2.toEpochDay()) -
      Math.max(start1.toEpochDay(), start2.toEpochDay());
    return overlap >= 0;
}

LocalDate类提供了两个基本方法isBefore()和isAfter(),用于评估LocalDate实例之间的时间顺序关系。我们将利用这两种方法来确定非重叠条件:

boolean isOverlapUsingLocalDateAndCondition(LocalDate start1, LocalDate end1, LocalDate start2, LocalDate end2) {
    return !(end1.isBefore(start2) || start1.isAfter(end2));
}

接下来,我们将探索如何使用LocalDate来查找两个日期范围之间的最小重叠:

boolean isOverlapUsingLocalDateAndFindMin(LocalDate start1, LocalDate end1, LocalDate start2, LocalDate end2) {
    long overlap1 = Math.min(end1.toEpochDay() - start1.toEpochDay(), 
      end1.toEpochDay() - start2.toEpochDay());
    long overlap2 = Math.min(end2.toEpochDay() - start2.toEpochDay(), 
      end2.toEpochDay() - start1.toEpochDay());
    return Math.min(overlap1, overlap2) >= 0;
}

现在,让我们使用代表不同日期范围的LocaleDate实例设置测试数据。

首先,让我们创建开始日期范围:

LocalDate start1 = LocalDate.of(2024, 11, 15); 
LocalDate end1 = LocalDate.of(2024, 11, 20);

接下来,让我们将结束日期范围设置为部分重叠:

LocalDate start1 = LocalDate.of(2024, 11, 15); 
LocalDate end1 = LocalDate.of(2024, 11, 20);
assertTrue(isOverlapUsingLocalDateAndDuration(start1, end1, start2, end2));
assertTrue(isOverlapUsingLocalDateAndCondition(start1, end1, start2, end2));
assertTrue(isOverlapUsingLocalDateAndFindMin(start1, end1, start2, end2));

我们将通过将结束日期范围设置为完全重叠来遵循此要求:

LocalDate start1 = LocalDate.of(2024, 11, 16); 
LocalDate end1 = LocalDate.of(2024, 11, 18);
assertTrue(isOverlapUsingLocalDateAndDuration(start1, end1, start2, end2));
assertTrue(isOverlapUsingLocalDateAndCondition(start1, end1, start2, end2));
assertTrue(isOverlapUsingLocalDateAndFindMin(start1, end1, start2, end2));

最后,当我们将结束日期范围设置为连续范围时,我们应该期望不会重叠:

LocalDate start1 = LocalDate.of(2024, 11, 21); 
LocalDate end1 = LocalDate.of(2024, 11, 24);
assertFalse(isOverlapUsingLocalDateAndDuration(start1, end1, start2, end2)); 
assertFalse(isOverlapUsingLocalDateAndCondition(start1, end1, start2, end2)); 
assertFalse(isOverlapUsingLocalDateAndFindMin(start1, end1, start2, end2));

3. 使用Joda-Time
Joda-Time是 Java 中广泛采用的日期和时间操作库,它简化了复杂的时间计算,并提供了一种称为Overlaps()的便捷方法来确定两个间隔是否重叠。

Overlaps ()方法接受两个Interval对象作为参数,如果两个间隔之间存在交集,则返回true 。换句话说,它标识指定日期范围之间是否存在任何共享持续时间。

但是,需要注意的是,Joda-Time将具有相同起点和终点的两个间隔视为不重叠。在处理精确边界很重要甚至某个时间点不被视为重叠的场景时,此行为尤其相关。

让我们看看使用 Joda-Time 的实现:

boolean isOverlapUsingJodaTime(DateTime start1, DateTime end1, DateTime start2, DateTime end2) {
    Interval interval1 = new Interval(start1, end1);
    Interval interval2 = new Interval(start2, end2);
    return interval1.overlaps(interval2);
}

现在,让我们使用代表不同日期范围的Interval实例设置测试数据。我们从部分重叠开始:

DateTime startJT1 = new DateTime(2024, 12, 15, 0, 0);
DateTime endJT1 = new DateTime(2024, 12, 20, 0, 0);
DateTime startJT2 = new DateTime(2024, 12, 18, 0, 0);
DateTime endJT2 = new DateTime(2024, 12, 22, 0, 0);
assertTrue(isOverlapUsingJodaTime(startJT1, endJT1, startJT2, endJT2));

让我们将结束日期范围更改为完全重叠:

DateTime startJT2 = new DateTime(2024, 12, 16, 0, 0);
DateTime endJT2 = new DateTime(2024, 12, 18, 0, 0);
assertTrue(isOverlapUsingJodaTime(startJT1, endJT1, startJT2, endJT2));

最后,我们将结束日期范围更改为连续范围,并且它再次应该返回不重叠的情况:

DateTime startJT2 = new DateTime(2024, 12, 21, 0, 0);
DateTime endJT2 = new DateTime(2024, 12, 24, 0, 0);
assertFalse(isOverlapUsingJodaTime(startJT1, endJT1, startJT2, endJT2));