Trigger: bookings arrive one at a time and each needs an answer before the next. The input is not all there yet, so keep a structure that stays sorted (a sorted list, or a sorted map of +1/-1 events) and answer against it. The other pages see the whole list at once. Which question is it asking?
The other interval techniques walk the whole list. Once the list is sorted, though, a single question asked about it - "which stored intervals does this new one touch?", "how many are live at this instant?" - should not cost a full pass. Binary search is what turns those into O(log n), and the surprise is that it needs no new idea: it is doesIntersect from the index, read one more time.
bisect needs a sorted array of comparable scalars, and a list of [start, end] pairs is not that. So the first move is always the same - project the intervals onto two scalar arrays and search those:
starts = [i[0] for i in intervals]
ends = [i[1] for i in intervals]
starts is sorted because the list is sorted by start. ends is sorted only if the list is also disjoint, and that one condition splits this whole section in two.
Each half of the overlap test is one binary search
Take a list that is sorted and non-overlapping, and a query interval q (q = [q0, q1], the new interval being asked about). Ask the overlap test of every stored interval in turn:
doesIntersect(intervals[i], q) = ends[i] >= q0 and starts[i] <= q1
Now look at each half as i runs left to right. ends ascends, so ends[i] >= q0 is false, false, false, then true and stays true. starts ascends, so starts[i] <= q1 is true, true, true, then false and stays false. Neither flips back. A predicate that flips exactly once over a sorted array is precisely what bisect locates the boundary of, so each half costs one binary search:
from bisect import bisect_left, bisect_right
firstTouching = lambda ends, q: bisect_left(ends, q[0]) # first i with ends[i] >= q0
afterLastTouching = lambda starts, q: bisect_right(starts, q[1]) # one past the last i with starts[i] <= q1
The and joining the two halves becomes an intersection of ranges, and since one is a suffix [lo, n) and the other a prefix [0, hi), the result is the half-open slice [lo, hi):
Intervals [1,3], [5,8], [10,12], [15,18], [20,25] against the query q = [7,16], drawn as the shaded band. The left column turns on at i=1 and never turns off; the right column turns off at i=4 and never turns on. Their overlap, rows 1 to 3, is exactly the set of bars the band touches.
intervals[lo:hi] is exactly the set that intersects q, and everything below is a different question asked about that one slice. An empty slice (lo == hi) means nothing was touched.
bisect_left and bisect_right differ only on where they place a value equal to an existing element, which is the same decision > versus >= makes on a shared endpoint. So the convention table from isInGap has a binary-search column, and it crosses over the same way:
Convention
Overlap test
lo = firstTouching
hi = afterLastTouching
Closed - merging, coverage
doesIntersect (>=)
bisect_left(ends, q0)
bisect_right(starts, q1)
Half-open - scheduling
doesIntersectStrictly (>)
bisect_right(ends, q0)
bisect_left(starts, q1)
Under the closed row a stored interval ending exactly at q0 is pulled into the slice; under the half-open row it is left out, because a meeting ending at 10:00 does not conflict with one starting at 10:00. Pick the row, not the function name.
When the list overlaps: depth by two independent searches
Drop the disjoint requirement and ends stops being sorted, which kills both searches above. The repair is to give up the pairing entirely: sort starts and ends as two independent arrays, and count instead of locate.
countStarted = lambda starts, t: bisect_right(starts, t) # how many have begun by t
countFinished = lambda ends, t: bisect_left(ends, t) # how many are already over
This is the depth of the sweep line, with the sweep removed. The sweep maintains depth incrementally and therefore has to visit the events in order; this computes depth at any single t in O(log n) with no ordering requirement at all, so the queries may arrive shuffled and each is answered on its own.
Four overlapping intervals, so ends is not sorted in list order and the slice primitive does not apply. Sorting starts and ends separately still answers 'how many contain t = 5': three have begun, none have finished.
"Started minus finished" survives the lost pairing because every interval falls into exactly one of three buckets at time t: not yet begun (counted by neither term), already over (counted by both, so it cancels), or live (counted by the first only). Which start belongs to which end never enters the arithmetic.
One catch: bisect_left(ends, t) counts ends strictly before t, so here an interval ending at t still counts as live. Use bisect_right(ends, t) for the half-open depth defined on Line Sweeps.
GOTCHAends is sorted only when the list is disjoint
The slice primitive reads ends as an ascending array, and that is a consequence of non-overlap, not of sorting. [[1,10],[2,3]] is sorted by start and its ends are [10, 3], so bisect_left on them returns nonsense with no error. Merge first, or fall back to the depth primitive. The single cheapest guard is to build ends at the same moment you establish disjointness, never from raw input.
Is the list disjoint? Use the slice. intervals[lo:hi] is the touched set, and hi - lo answers most of the rest.
Not disjoint, but only a count at a point is needed? Use depth. Two independent arrays, two bisect calls.
Not disjoint, and you need to know which interval? Binary search can only narrow the candidates - a heap or a max-end segment tree picks the winner among them.
Question
Primitive
Expression
Does a new interval conflict with any stored one?
slice
hi - lo == 0
Where does a new interval merge in?
slice
intervals[:lo] + [merged] + intervals[hi:]
Is a query range fully covered?
slice + doesContain
hi - lo == 1 and doesContain(intervals[lo], q)
Which stored interval contains this point?
slice with q0 == q1
lo if lo < hi else none
How many intervals are live at this instant?
depth
countStarted(t) - countFinished(t)
The two primitives never do the judging themselves. They locate a candidate, and doesContain, getMerged, doesIntersect - the primitives from the index - decide what it means. That composition is the pattern: bisect narrows, the interval primitives judge.
Intervals arrive one by one and each must be answered before the next, so there is no sorting up front. Keep a structure that stays sorted as bookings land - a sorted list with bisect, or a running map of +1/-1 events - and answer against it.