chrono/chrono/interval.py

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

207 lines
6.9 KiB
Python
Raw Normal View History

# chrono - agendas system
# Copyright (C) 2017 Entr'ouvert
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import bisect
import collections
Interval = collections.namedtuple('Interval', ['begin', 'end'])
class IntervalSet:
"""Store a set made of an union of disjoint open/closed intervals (it
currently does not really care about their openness), i.e.
S = [a[0], b[0]] [a[n-1], b[n-1]]
where
forall i < n. a_i < b_i
forall i < (n-1). b_i < a[i+1]
2020-12-29 10:42:33 +01:00
"""
__slots__ = ['begin', 'end']
def __init__(self, iterable=(), already_sorted=False):
2020-12-29 10:42:33 +01:00
"""
Initialize a new IntervalSet from a list of Interval or 2-tuple.
Iterable will be sorted, if it's already sorted use the
from_ordered() classmethod.
It's faster than using add() because intervals are merged as we
traverse the list, and self.begin and self.end are built in O(n)
time where len(iterable) = n.
2020-12-29 10:42:33 +01:00
"""
if not already_sorted:
iterable = sorted(iterable)
self.begin = []
self.end = []
last_begin, last_end = None, None
for begin, end in iterable:
# check good order property along the way
if last_begin and not (last_begin < begin or last_begin == begin and last_end <= end):
raise ValueError('not well ordered: ! %s <= %s' % ((last_begin, last_end), (begin, end)))
if self.begin and begin <= self.end[-1]:
self.end[-1] = max(self.end[-1], end)
else:
self.begin.append(begin)
self.end.append(end)
last_begin, last_end = begin, end
@classmethod
def simple(cls, begin, end=Ellipsis):
begin, end = cls._begin_or_interval(begin, end)
return cls.from_ordered([(begin, end)])
@classmethod
def from_ordered(cls, iterable):
return cls(iterable, already_sorted=True)
def __repr__(self):
return repr(list(map(tuple, self)))
@classmethod
def _begin_or_interval(cls, begin, end=Ellipsis):
if end is Ellipsis:
return begin
else:
return begin, end
def add(self, begin, end=Ellipsis):
"""Add a new interval to the set, eventually merging it with actual
ones.
It uses bisect_left() to maintaint the ordering of intervals.
2020-12-29 10:42:33 +01:00
"""
begin, end = self._begin_or_interval(begin, end)
# insert an interval by merging with previous and following intervals
# if they overlap
i = bisect.bisect_left(self.begin, begin)
# merge with previous intervals
while i > 0 and begin <= self.end[i - 1]:
# [begin, end] overlaps previous interval
# so remove it to merge them.
previous_begin = self.begin.pop(i - 1)
previous_end = self.end.pop(i - 1)
i = i - 1
if previous_begin < begin:
# but it does not include it, so replace if begin by previous.begin
begin = previous_begin
# [begin, end] is completely included in previous interval,
# replace end by previous.end
end = max(end, previous_end)
break
# merge with following
while i < len(self.begin) and self.begin[i] <= end:
# [begin, end] overlaps next interval, so remove it to merge them.
next_end = self.end.pop(i)
self.begin.pop(i)
if end < next_end:
# but it does not include it, so replace end by next.end
end = next_end
# no symetry with the previous "while" loop as .bisect_left()
# garanty that begin is left or equal to next.begin.
break
self.begin.insert(i, begin)
self.end.insert(i, end)
def overlaps(self, begin, end=Ellipsis):
2020-12-29 10:42:33 +01:00
"""
Find if the [begin, end] has a non-empty (or closed) overlaps with
one of the contained intervals.
2020-12-29 10:42:33 +01:00
"""
begin, end = self._begin_or_interval(begin, end)
# look for non-zero size overlap
i = bisect.bisect_left(self.begin, begin)
if i > 0 and begin < self.end[i - 1]:
return True
if i < len(self.begin) and self.begin[i] < end:
return True
return False
def __iter__(self):
2020-12-29 10:42:33 +01:00
"""
Generate the ordered list of included intervals as 2-tuples.
2020-12-29 10:42:33 +01:00
"""
return map(Interval._make, zip(self.begin, self.end))
def __eq__(self, other):
if isinstance(other, self.__class__):
return list(self) == list(other)
if hasattr(other, '__iter__'):
return self == self.cast(other)
return False
def __bool__(self):
return bool(self.begin)
@classmethod
def cast(cls, value):
if isinstance(value, cls):
return value
return cls(value)
def __sub__(self, other):
l1 = iter(self)
l2 = iter(self.cast(other))
def gen():
state = 3
while True:
if state & 1:
c1 = next(l1, None)
if state & 2:
c2 = next(l2, None)
if not c1:
break
if not c2:
yield c1
for c1 in l1:
yield c1
break
if c1[1] <= c2[0]:
yield c1
state = 1
elif c2[1] <= c1[0]:
state = 2
elif c1[1] < c2[1]:
if c1[0] < c2[0]:
yield (c1[0], c2[0])
state = 1
elif c2[1] < c1[1]:
if c1[0] < c2[0]:
yield (c1[0], c2[0])
c1 = (c2[1], c1[1])
state = 2
elif c1[1] == c2[1]:
if c1[0] < c2[0]:
yield (c1[0], c2[0])
state = 3
else:
raise Exception('not reachable')
return self.__class__.from_ordered(gen())
def min(self):
if self:
return self.begin[0]
return None
def max(self):
if self:
return self.end[-1]
return None