检查日期重叠的高效算法

141 阅读3分钟

TL;DR使用SQLend_date2 >= start_date1 and end_date1 >= start_date2

问题

想象一下。一个房地产网站的客人想预订一个特定日期的酒店。系统应该检查这些日期是否可用,也就是说,是否与其他现有的预订重叠。比方说,软件工程师用Rails编写了这个假设的网站,并提出了Booking 模型。它表示bookings 表,有两列:start_dateend_datedate 类型。另外,假设有一个验证的地方,检查start_date <= end_date 。下面的解决方案描述了如何应对这种特殊情况。类似的数据模型也可以遵循它。

解决方案

也许,这个问题最简单的解决方案可以用Rails的方式来处理。只需在Booking 模型中定义一个自定义验证,在每次创建新的预订或更新现有预订时执行:

class Booking < ApplicationRecord
  # ... some code is skipped here for simplicity's sake
  validate :validate_other_booking_overlap

  def period
    start_date..end_date
  end

  private

  def validate_other_booking_overlap
    other_bookings = Booking.all
    is_overlapping = other_bookings.any? do |other_booking|
      period.overlaps?(other_booking.period)
    end
    errors.add(:overlaps_with_other) if is_overlapping
  end
end

但不幸的是,这里有一个性能瓶颈。请记住,所有的预订都是先从数据库中获取的。然后他们被反序列化到Booking 模型实例中。之后,根据创建/更新的Booking 实例检查每个人的时期。乍一看--如此简单的代码,但它实际上做了多少复杂的事情!它创建了这么多的对象,消耗了这么多的资源。它创建了这么多的对象,在运行这段代码的机器上消耗了大量的内存。这实际上是任何软件变慢的主要原因。然而,有时这种尝试是可行的,也就是说,当从数据库中获取的对象数量不多。是否采用这种方法由开发者决定,并应考虑到可能的缺点而明智地选择。

如果这个方法不起作用,应该寻找新的方法。怎样才能改进这个问题呢?为了回答这个问题,应该了解问题的根本原因。而它实际上是在上面强调的--分配的对象的数量是巨大的。因此,我们需要减少它。一个可能的方法是将循环转移到数据库中,幸运的是ActiveRecord接受SQL。这就是使用PostgreSQL的人可能最终得到的代码:

def validate_other_booking_overlap
  sql = "daterange(start_date, end_date, '[]') && daterange(:start_date, :end_date, '[]')"
  is_overlapping = Booking.where(sql, start_date: c.start_date, end_date: c.end_date).exists?
  errors.add(:overlaps_with_other) if is_overlapping
end

将语句daterange(start_date, end_date, '[]') 解释为 "创建一个从start_dateend_date 的日期范围,包括右边和左边"。第三个参数[] 指向包容性的属性。关于这个的更多信息可以在这里找到。

这里使用的&& 操作符,用于检查范围重叠。如果出现任何问题,请查看文档

这个尝试有什么问题呢?好吧,这段代码与第一段相比,效率高多了。但是仍然为日期范围创建了对象,不过这次是在数据库层面。记住,不必要的对象数量是导致程序缓慢的原因。这就是为什么,如果可能的话,应该减少分配的数量。这段代码是从以前的版本中翻译过来的,强调了可读性。因此,即使在转换为SQL后,它也是可读的。但如何提高它的速度呢?这一次,强调可读性是关键。通常,为了解决性能问题,当前的解决方案可能会以一种更有效的方式重写。但这通常会牺牲掉清晰度。尝试这种方式,人们可能会结束下一段SQL:

sql = <<~SQL
  (
    (start_date <= :start_date and :start_date <= end_date) or
    (start_date <= :end_date and :end_date <= end_date)
  ) or (
    (:start_date <= start_date and start_date <= :end_date) or
    (:start_dae <= end_date and end_date <= :end_date)
  )
SQL

其余的代码被省略了,因为它仍然是一样的。从现在开始,只有定义sql 变量的验证方法的那一行发生了变化。

它只是检查第一个范围的任何边缘是否在第二个范围内。或者第二个范围的任何边缘是否在第一个范围内。这个选择分配的对象更少,所以它一定比之前的快。但是看看这个--它有点麻烦了。它能更好吗?事实证明它可以:

sql = ":end_date >= start_date and end_date >= :start_date"

这个公式背后的逻辑是什么?当且仅当它们不是从左边重叠,也不是从右边重叠的情况下,区间才会重合。或者以下情况不发生:

                      start_date          end_date
                          |--------------------|
:start_date     :end_date
|-------------------|

start_date          end_date
    |--------------------|
                           :start_date        :end_date
                              |-------------------|

这一点的证明是相当明显的:所有可能的情况都可以画出来并检查。之后就会发现,所有其他情况都会相交。

将此语句转化为布尔公式:

not (:end_date < start_date or end_date < :start_date)

去掉前面的否定词,用括号内的所有语句替换它们的否定词:

=>

not (:end_date < start_date) and not (end_date < :start_date)

=>

:end_date >= start_date and end_date >= :start_date

如果这个解释不清楚,请检查

最后的公式就得出了。但是有什么缺点吗?嗯,这是一个品味的问题。一方面,它的可读性比Rails方式的解决方案差。另一方面,这也是我们目前想到的最有效的方法。如果有人认为这个技巧不清楚,可以提供文档。这样,每个人在阅读这段代码时都能理解它背后隐藏的东西。

结语

这篇文章提供了一个相当普遍的问题的解决方案,特别是日期范围的重叠。有时候,要解决一个特定的问题,在理解和效率之间取得平衡是很难的。这段旅程是为了展示它,并引导到一个解决方案。

这要归功于那些审查了我解决类似问题的拉动请求的同事。建议的最终方法对我来说并不明确,甚至看起来不可行。但经过一番思考后,我改变了看法。这个思考的过程和证明是非常有趣的。它让我写下了这一点。

永远不要放弃为你的问题找到一个好的解决方案。总是有机会改进的。编码愉快!