PHP 确定多个 (n) 个日期时间范围何时相互重叠


PHP Determine when multiple(n) datetime ranges overlap each other

我花了很长时间

试图解决以下问题:

这是一个日历程序,给定来自多个人的一组可用日期时间集,我需要弄清楚每个人在 PHP 中可用的日期时间范围

可用性集:

p1: start: "2016-04-30 12:00", end: "2016-05-01 03:00"
p2: start: "2016-04-30 03:00", end: "2016-05-01 03:00"
p3: start: "2016-04-30 03:00", end: "2016-04-30 13:31"
    start: "2016-04-30 15:26", end: "2016-05-01 03:00"

我正在寻找一个可以调用的函数,该函数将告诉我所有 (p) 人同时可用的日期时间范围。

在上面的例子中,答案应该是:

2016-04-30 12:00 -> 2016-04-30 13:31
2016-04-30 15:26 -> 2016-05-01 03:00

我确实找到了类似的问题和答案日期时间 - 确定多个 (n) 个日期时间范围在 R 中是否相互重叠

但我不知道那是什么语言,不得不无法翻译答案中的逻辑。

嗯,

这很有趣。可能有一种比每分钟循环更优雅的方法,但我不知道 PHP 是否是它的语言。请注意,这目前需要管理开始和结束时间以单独搜索,尽管根据可用班次计算它们相当简单。

<?php
$availability = [
    'Alex' => [
        [
            'start' => new DateTime('2016-04-30 12:00'),
            'end'   => new DateTime('2016-05-01 03:00'),
        ],
    ],
    'Ben' => [
        [
            'start' => new DateTime('2016-04-30 03:00'),
            'end'   => new DateTime('2016-05-01 03:00'),
        ],
    ],
    'Chris' => [
        [
            'start' => new DateTime('2016-04-30 03:00'),
            'end'   => new DateTime('2016-04-30 13:31')
        ],
        [
            'start' => new DateTime('2016-04-30 15:26'),
            'end'   => new DateTime('2016-05-01 03:00')
        ],
    ],
];
$start = new DateTime('2016-04-30 00:00');
$end   = new DateTime('2016-05-01 23:59');
$tick  = DateInterval::createFromDateString('1 minute');
$period = new DatePeriod($start, $tick, $end);
$overlaps = [];
$overlapStart = $overlapUntil = null;
foreach ($period as $minute)
{
    $peopleAvailable = 0;
    // Find out how many people are available for the current minute
    foreach ($availability as $name => $shifts)
    {
        foreach ($shifts as $shift)
        {
            if ($shift['start'] <= $minute && $shift['end'] >= $minute)
            {
                // If any shift matches, this person is available
                $peopleAvailable++;
                break;
            }
        }
    }
    // If everyone is available...
    if ($peopleAvailable == count($availability))
    {
        // ... either start a new period...
        if (!$overlapStart)
        {
            $overlapStart = $minute;
        }
        // ... or track an existing one
        else
        {
            $overlapUntil = $minute;
        }
    }
    // If not and we were previously in a period of overlap, end it
    elseif ($overlapStart)
    {
        $overlaps[] = [
            'start' => $overlapStart,
            'end'   => $overlapUntil,
        ];
        $overlapStart = null;
    }
}
foreach ($overlaps as $overlap)
{
    echo $overlap['start']->format('Y-m-d H:i:s'), ' -> ', $overlap['end']->format('Y-m-d H:i:s'), PHP_EOL;
}

此实现存在一些错误,请参阅注释。我无法删除它,因为它是公认的答案。请使用iainn或fusion3k非常好的答案,直到我开始修复它。

实际上没有必要使用任何日期/时间处理来解决这个问题问题。您可以利用此格式的日期按字母顺序和时间顺序排列的事实。

我不确定这是否会使解决方案变得不那么复杂。可能更少这样可读。但它比每分钟迭代要快得多,因此如果性能是一个问题,您可以选择它。

您还可以使用每单数组功能外面,这很好。

当然,因为我没有使用任何日期/时间函数,所以如果夏令时或不同时区的用户需要处理,它可能不起作用。

$availability = [
    [
        ["2016-04-30 12:00", "2016-05-01 03:00"]
    ],
    [
        ["2016-04-30 03:00", "2016-05-01 03:00"]
    ],
    [
        ["2016-04-30 03:00", "2016-04-30 13:31"],
        ["2016-04-30 15:26", "2016-05-01 03:00"]
    ]
];
// Placeholder array to contain the periods when everyone is available.
$periods = [];
// Loop until one of the people has no periods left.
while (count($availability) &&
       count(array_filter($availability)) == count($availability)) {
    // Select every person's earliest date, then choose the latest of these
    // dates.
    $start = array_reduce($availability, function($carry, $ranges) {
        $start = array_reduce($ranges, function($carry, $range) {
            // This person's earliest start date.
            return !$carry ? $range[0] : min($range[0], $carry);
        });
        // The latest of all the start dates.
        return !$carry ? $start : max($start, $carry);
    });
    // Select each person's range which contains this date.
    $matching_ranges = array_filter(array_map(function($ranges) use($start) {
        return current(array_filter($ranges, function($range) use($start) {
            // The range starts before and ends after the start date.
            return $range[0] <= $start && $range[1] >= $start;
        }));
    }, $availability));
    // Find the earliest of the ranges' end dates, and this completes our
    // first period that everyone can attend.
    $end = array_reduce($matching_ranges, function($carry, $range) {
        return !$carry ? $range[1] : min($range[1], $carry);
    });
    // Add it to our list of periods.
    $periods[] = [$start, $end];
    // Remove any availability periods which finish before the end of this
    // new period.
    array_walk($availability, function(&$ranges) use ($end) {
        $ranges = array_filter($ranges, function($range) use($end) {
            return $range[1] > $end;
        });
    });
}
// Output the answer in the specified format.
foreach ($periods as $period) {
    echo "$period[0] -> $period[1]'n";
}
/**
 * Output:
 * 
 * 2016-04-30 12:00 -> 2016-04-30 13:31
 * 2016-04-30 15:26 -> 2016-05-01 03:00
 */

解决问题的不同方法是使用按位运算符。此解决方案的优点是内存使用、速度和短代码。障碍是 - 在您的情况下 - 我们不能使用 php 整数,因为我们处理大数字(1 天以分钟为单位是 224*60),所以我们必须使用 GMP 扩展,这在大多数 php 发行版中默认不可用。但是,如果您使用apt-get或任何其他软件包管理器,则安装非常简单。

为了更好地理解我的方法,我将使用一个总周期为 30 分钟的数组来简化二进制表示:

$calendar = 
[
    'p1' => [
        ['start' => '2016-04-30 12:00', 'end' => '2016-04-30 12:28']
    ],
    'p2' => [
        ['start' => '2016-04-30 12:10', 'end' => '2016-04-30 12:16'],
        ['start' => '2016-04-30 12:22', 'end' => '2016-05-01 12:30']
    ]
];
首先,我们

找到所有数组元素的最小和最大日期,然后我们初始化自由(时间)变量,其中 max 和 min 之间的分钟差。在上面的例子(30 分钟)中,我们得到 2 30-2 0=1,073,741,823,这是一个具有30 个 '1' 的二进制文件(或设置了 30 位):

111111111111111111111111111111

现在,对于每个人,我们使用相同的方法创建相应的空闲时间变量。对于第一人称很容易(我们只有一个时间间隔):开始和min的差是0,结束和min的差是28,所以我们有228-20=268435455,即:

001111111111111111111111111111

此时,我们使用全局空闲时间本身和人员空闲时间之间的AND位运算来更新全局空闲时间。如果两个比较值都设置了位,则OR运算符设置位:

111111111111111111111111111111      global free time
001111111111111111111111111111      person free time
==============================
001111111111111111111111111111      new global free time

对于第二个人称,我们有两个时间间隔:我们使用 know 方法计算每个时间间隔,然后我们使用 OR 运算符组成全局人的空闲时间,如果它们设置为第一个或第二个值,则设置位:

000000000000001111110000000000      12:10 - 12:16
111111110000000000000000000000      12:22 - 12:30
==============================
111111110000001111110000000000      person total free time

现在我们使用与第一人称(AND运算符)相同的方法更新全局空闲时间:

001111111111111111111111111111      previous global free time
111111110000001111110000000000      person total free time
==============================
001111110000001111110000000000      new global free time
  └────┘      └────┘
  :28-:22     :16-:10

如您所见,最后我们有一个整数,当每个人都有空时,位仅以分钟为单位设置(您必须从右开始计数)。现在,您可以将此整数转换回日期时间。幸运的是,GMP扩展有一个查找 1/0 偏移量的方法,因此我们可以避免对所有数字执行 for/foreach 循环(在实际情况下超过 30 位)。


让我们看看将此概念应用于数组的完整代码:

$calendar = 
[
    'p1' => [
        ['start' => '2016-04-30 12:00', 'end' => '2016-05-01 03:00']
    ],
    'p2' => [
        ['start' => '2016-04-30 03:00', 'end' => '2016-05-01 03:00']
    ],
    'p3' => [
        ['start' => '2016-04-30 03:00', 'end' => '2016-04-30 13:31'],
        ['start' => '2016-04-30 15:26', 'end' => '2016-05-01 03:00']
    ]
];
/* Get active TimeZone, then calculate min and max dates in minutes: */
$tz   = new DateTimeZone( date_default_timezone_get() );
$flat = call_user_func_array( 'array_merge', $calendar );
$min  = date_create( min( array_column( $flat, 'start' ) ) )->getTimestamp()/60;
$max  = date_create( max( array_column( $flat, 'end' ) ) )->getTimestamp()/60;
/* Init global free time (initially all-free): */
$free = gmp_sub( gmp_pow( 2, $max-$min ), gmp_pow( 2, 0 ) );
/* Process free time(s) for each person: */
foreach( $calendar as $p )
{
    $pf = gmp_init( 0 );
    foreach( $p as $time )
    {
        $start = date_create( $time['start'] )->getTimestamp()/60;
        $end   = date_create( $time['end'] )->getTimestamp()/60;
        $pf = gmp_or( $pf, gmp_sub( gmp_pow( 2, $end-$min ), gmp_pow( 2, $start-$min ) ) );
    }
    $free = gmp_and( $free, $pf );
}
$result = [];
$start = $end = 0;
/* Create resulting array: */
while( ($start = gmp_scan1( $free, $end )) >= 0 )
{
    $end = gmp_scan0( $free, $start );
    if( $end === False) $end = strlen( gmp_strval( $free, 2 ) )-1;
    $result[] =
    [
        'start' => date_create( '@'.($start+$min)*60 )->setTimezone( $tz )->format( 'Y-m-d H:i:s' ),
        'end'   => date_create( '@'.($end+$min)*60 )->setTimezone( $tz )->format( 'Y-m-d H:i:s' )
    ];
}
print_r( $result );

输出:

Array
(
    [0] => Array
        (
            [start] => 2016-04-30 12:00:00
            [end]   => 2016-04-30 13:31:00
        )
    [1] => Array
        (
            [start] => 2016-04-30 15:26:00
            [end]   => 2016-05-01 03:00:00
        )
)

3v4l.org 演示

一些附加说明:

  • 一开始,我们将$tz设置为当前时区:稍后,当我们从时间戳创建最终日期时,我们将在最后使用它。从时间戳创建的日期采用 UTC,因此我们必须设置正确的时区。
  • 要在几分钟内检索初始$min$max值,首先我们平整原始数组,然后使用 array_column 检索最小和最大日期。
  • gmp_sub从第一个参数中减去第二个参数,gmp_pow将数字 (arg 1) 提高到幂 (arg 2)。
  • 在最后的while循环中,我们使用 gmp_scan1gmp_scan0 来检索每个 '111...." 间隔,然后我们使用gmp_scan1键的位置和end键的位置来创建返回数组元素start gmp_scan0位置。