Interval algebra
This guide works through the set algebra on
Interval<T> - chained Intersect,
TryUnion over adjacent and disjoint operands, containment, gap and
adjacency detection, and a complete "merge overlapping windows"
walk-through. For the single-operation basics - constructing intervals,
membership, formatting, parsing - see
Working with Interval<T>; this page builds on those and
goes deeper.
Two conventions thread through everything below.
- The canonical empty interval. Every interval that admits no value -
inverted bounds, or equal bounds with at least one open endpoint -
compares equal to
Interval<T>.Empty, anddefault(Interval<T>)reads as empty.Intersectreturns it for disjoint operands;TryUniontreats it as the identity element. - Half-open is the partitioning shape. Adjacent closed-open intervals
[a, b)and[b, c)cover[a, c)with no overlap and no gap, which is what makes the merge algorithm at the end clean. When endpoint values tie,Intersectkeeps the stricter (open) side so the result is a subset of both operands, andTryUnionkeeps the looser (closed) side so the result is a superset of both.
Chained intersection
Intersect is associative and commutative, so a sequence of constraints
folds cleanly. It is also idempotent (a.Intersect(a) == a) and has
Empty as its absorbing element (a.Intersect(Empty) == Empty), which
together make any fold order valid. The intersection narrows
monotonically - each operand can only shrink the running result - and
collapses to Empty the moment two constraints disagree:
using Bodu.Numerics;
// Three independent constraints on an allowed value.
var atLeastTen = Interval<int>.Closed(10, 100); // [10, 100]
var underNinety = Interval<int>.ClosedOpen(0, 90); // [0, 90)
var evenDecade = Interval<int>.Closed(20, 80); // [20, 80]
Interval<int> allowed = atLeastTen
.Intersect(underNinety) // [10, 90)
.Intersect(evenDecade); // [20, 80]
Console.WriteLine(allowed); // "[20, 80]"
Because Empty is absorbing under intersection, a single conflicting
constraint short-circuits the whole chain to the empty set:
var conflict = atLeastTen
.Intersect(Interval<int>.Closed(200, 300)); // disjoint with [10, 100]
Console.WriteLine(conflict.IsEmpty); // True
Console.WriteLine(conflict.Intersect(evenDecade).IsEmpty); // True - stays empty
To fold an arbitrary list of constraints, seed the accumulator with a
range that contains everything you care about (or with the first element)
and Intersect across the rest:
static Interval<int> IntersectAll(IEnumerable<Interval<int>> constraints)
{
using var e = constraints.GetEnumerator();
if (!e.MoveNext())
return Interval<int>.Empty; // no constraints - nothing to intersect
Interval<int> result = e.Current;
while (e.MoveNext())
result = result.Intersect(e.Current);
return result;
}
Union of adjacent versus disjoint intervals
TryUnion returns true only when the union of the two operands is itself
a single contiguous interval - that is, when they overlap or are
adjacent. It never synthesizes a two-piece result; a true gap returns
false and leaves result as Empty.
// Adjacent - the half-open seam at 5 belongs to the second interval.
bool a = Interval<int>.ClosedOpen(1, 5)
.TryUnion(Interval<int>.Closed(5, 10), out var merged);
// a == true, merged == [1, 10]
// Overlapping - shared interior.
bool b = Interval<int>.Closed(1, 6)
.TryUnion(Interval<int>.Closed(4, 10), out var overlap);
// b == true, overlap == [1, 10]
// Disjoint with a gap - no contiguous representation exists.
bool c = Interval<int>.Closed(1, 5)
.TryUnion(Interval<int>.Closed(8, 10), out var gapped);
// c == false, gapped == Interval<int>.Empty
The adjacency rule is precise: two intervals are adjacent when the upper endpoint of one equals the lower endpoint of the other and at least one of those endpoints is inclusive. That single-inclusive requirement is why two half-open intervals that meet at a point still union, while two intervals that both exclude the meeting point do not:
// [1, 5) and [5, 10] - 5 is owned by the second interval → contiguous.
Interval<int>.ClosedOpen(1, 5).TryUnion(Interval<int>.Closed(5, 10), out _); // true
// (1, 5) and (5, 10] - 5 is in neither interval → a one-point gap.
Interval<int>.Open(1, 5).TryUnion(Interval<int>.OpenClosed(5, 10), out _); // false
On endpoint ties the union keeps the looser side, so unioning a closed and an open interval over the same bounds yields the closed result:
Interval<int>.Closed(1, 5).TryUnion(Interval<int>.Open(1, 5), out var u);
// u == [1, 5] - inclusive wins on both ends
Unlike Intersect, TryUnion is partial: it is defined only when the
result is a single contiguous interval. Empty is its identity (a union
with Empty returns the other operand and true), but because the
operation can fail on a gap, folding a list of intervals into a minimal
cover requires sorting first - see the worked example below. This is the
structural reason TryUnion returns bool rather than an
Interval<T>: there is no contiguous value to return for a disjoint
pair.
Containment and overlap
Contains(Interval<T>) tests the subset relation; Overlaps tests for a
shared value. The two answer different questions and a worked comparison
makes the distinction concrete:
var outer = Interval<int>.Closed(0, 10);
var inner = Interval<int>.Closed(2, 8);
var straddle = Interval<int>.Closed(8, 20);
outer.Contains(inner); // True - every value of inner is in outer
outer.Overlaps(inner); // True - they obviously share values
outer.Contains(straddle); // False - straddle reaches 20, outside outer
outer.Overlaps(straddle); // True - they share [8, 10]
outer.Contains(Interval<int>.Empty); // True - ∅ ⊆ every set
outer.Overlaps(Interval<int>.Empty); // False - ∅ shares no value
Note that touching at a boundary is not overlap. [1, 5) and [5, 10]
are adjacent - they union - but they share no value, so Overlaps returns
false. Use Overlaps to detect double-booking and TryUnion (or the
adjacency test below) to detect mergeable neighbours.
Detecting gaps and adjacency
Interval<T> exposes overlap, intersection, and union directly. Adjacency
and the gap between two disjoint intervals are derived from the public
endpoint properties. The following helpers express both as small,
self-documenting functions:
// Two intervals are adjacent when they do not overlap yet still union.
static bool AreAdjacent(Interval<int> a, Interval<int> b) =>
!a.Overlaps(b) && a.TryUnion(b, out _);
// The gap between two disjoint, non-adjacent intervals, as an open interval.
// Returns Empty when the intervals touch, overlap, or are out of order.
static Interval<int> GapBetween(Interval<int> a, Interval<int> b)
{
if (a.IsEmpty || b.IsEmpty || a.Overlaps(b) || a.TryUnion(b, out _))
return Interval<int>.Empty;
// Order the operands so the lower one is first.
(Interval<int> lo, Interval<int> hi) = a.Upper <= b.Lower ? (a, b) : (b, a);
// The gap excludes whichever endpoints the neighbours already claim.
return new Interval<int>(
lo.Upper, hi.Lower,
lowerInclusive: !lo.UpperInclusive,
upperInclusive: !hi.LowerInclusive);
}
AreAdjacent(Interval<int>.ClosedOpen(1, 5), Interval<int>.Closed(5, 10)); // True
AreAdjacent(Interval<int>.Closed(1, 5), Interval<int>.Closed(8, 10)); // False
GapBetween(Interval<int>.Closed(1, 5), Interval<int>.Closed(8, 10));
// [5, 8] excluded on the claimed ends → (5, 8) → "(5, 8)"
Worked example - merging overlapping windows
The canonical interval-set task is normalising an unsorted list of ranges into the smallest set of disjoint intervals that covers the same values. This is the algorithm behind merging calendar bookings, coalescing byte ranges in a download, or collapsing overlapping log windows.
The recipe is the classic sweep: sort by lower endpoint, then walk the
sorted list folding each interval into the current run with TryUnion.
Because TryUnion returns false exactly when the next interval neither
overlaps nor abuts the current run, a false is the signal to close the
run and start a new one:
using Bodu.Numerics;
static List<Interval<int>> Merge(IEnumerable<Interval<int>> windows)
{
// Drop empties; they contribute nothing and would not sort meaningfully.
var sorted = windows
.Where(w => !w.IsEmpty)
.OrderBy(w => w.Lower)
.ThenBy(w => w.Upper)
.ToList();
var merged = new List<Interval<int>>();
if (sorted.Count == 0)
return merged;
Interval<int> current = sorted[0];
for (int i = 1; i < sorted.Count; i++)
{
if (current.TryUnion(sorted[i], out Interval<int> union))
{
current = union; // overlaps or abuts - extend the run
}
else
{
merged.Add(current); // a real gap - close the run
current = sorted[i];
}
}
merged.Add(current);
return merged;
}
Running it over a deliberately messy input - out of order, overlapping, adjacent, and disjoint - produces the normalised cover:
var input = new[]
{
Interval<int>.Closed(10, 14), // disjoint tail
Interval<int>.ClosedOpen(1, 5), // [1, 5)
Interval<int>.Closed(5, 8), // adjacent to [1, 5) at 5
Interval<int>.Closed(3, 6), // overlaps both above
Interval<int>.Closed(20, 22), // disjoint
};
foreach (var window in Merge(input))
Console.WriteLine(window);
// [1, 8]
// [10, 14]
// [20, 22]
The first three inputs collapse into [1, 8]: [1, 5) and [3, 6]
overlap into [1, 6], which abuts [5, 8] into [1, 8]. [10, 14] and
[20, 22] survive as separate runs because a genuine gap separates them -
exactly the false return from TryUnion that closes a run.
Sorting by lower endpoint is what guarantees a single forward pass is enough: once the list is ordered, any interval that can merge with the current run will appear before any interval that cannot, so the algorithm never has to look back.
Unbounded and half-bounded intervals
An interval need not have two finite endpoints. The factories
AtLeast(T) ([a, +∞)),
GreaterThan(T) ((a, +∞)),
AtMost(T) ((-∞, b]),
LessThan(T) ((-∞, b)), and
All ((-∞, +∞)) build the half-bounded and
unbounded shapes. The unbounded side is carried as explicit metadata - read
it with LowerUnbounded / UpperUnbounded / IsBounded - not as a
floating-point infinity, so the same model works for int, decimal, and
BigInteger, none of which have an infinity value.
var nonNegative = Interval<double>.AtLeast(0.0); // [0, +∞)
nonNegative.Contains(1e308); // True
nonNegative.IsBounded; // False
// Set algebra composes with unbounded operands.
Interval<int>.AtLeast(0) & Interval<int>.AtMost(10); // [0, 10]
An unbounded interval is never empty; its Length is infinite, so reading
Length throws InvalidOperationException. Unbounded intervals
format with the ±∞ glyphs ([0, +∞), (-∞, 5]) and parse back from the
same text.
Difference and symmetric difference
Subtracting one interval from another can leave two pieces, so Difference(Interval<T>) and SymmetricDifference(Interval<T>) return an IntervalPair<T> - an allocation-free value holding zero, one, or two disjoint intervals, lowest-first.
IntervalPair<int> gap = Interval<int>.Closed(0, 10).Difference(Interval<int>.Closed(3, 5));
gap.Count; // 2
foreach (var piece in gap)
Console.WriteLine(piece); // "[0, 3)" then "(5, 10]"
// The complement of a finite interval within the whole line is two half-lines.
Interval<double>.All.Difference(Interval<double>.Closed(3, 5)); // (-∞, 3) ∪ (5, +∞)
Disconnected sets with IntervalSet<T>
IntervalPair<T> covers the at-most-two pieces of a binary operation. When a
union or complement can produce arbitrarily many disjoint ranges, use
IntervalSet<T> - an immutable, normalized collection of
disjoint, non-adjacent intervals. Overlapping and adjacent inputs coalesce on
construction, so equal sets share a canonical form.
var set = IntervalSet<int>.Of(
Interval<int>.Closed(1, 3),
Interval<int>.Closed(2, 5),
Interval<int>.Closed(8, 9));
set.Count; // 2 - [1, 5] and [8, 9]
set.Contains(4); // True
// N-ary algebra a single interval cannot express as one value:
set.Except(Interval<int>.Closed(4, 4)); // [1, 3] ∪ (4, 5] ∪ [8, 9] (three pieces)
set.Complement(); // (-∞, 1) ∪ (5, 8) ∪ (9, +∞)
IntervalSet<T> offers Union, Intersect, Except, and Complement over
both an Interval<T> and another IntervalSet<T>, plus Contains,
Encloses, indexing, and enumeration. Complement is taken over the whole
line, so set.Complement().Complement() returns set.
Operators
& is intersection and | is the contiguous union, for the common case
where the two operands form a single interval:
var shared = Interval<int>.Closed(1, 5) & Interval<int>.Closed(3, 7); // [3, 5]
var merged = Interval<int>.ClosedOpen(1, 5) | Interval<int>.Closed(5, 10); // [1, 10]
| throws InvalidOperationException when the operands are
disjoint with a gap, since their union is not a single interval - reach for
Difference or accumulate the pieces when a multi-interval result is
possible.
Continuous versus discrete
Interval<T> is a continuous interval over ordered numeric coordinates -
Interval<int>.Open(1, 2) is a non-empty continuous range even though it
contains no integer. When you need the discrete integer semantics (an open
interval over consecutive integers is empty; [1, 2] and [3, 4] are
adjacent and merge), use DiscreteInterval<T>
instead.
See also
- Working with
Interval<T>- single-operation basics: construction, membership, formatting, parsing. - Discrete integer intervals -
DiscreteInterval<T>and successor-aware emptiness and adjacency. - Generic math with
Fraction<T>andInterval<T>- writing range and ratio code against theINumber<T>abstractions. Interval<T>API reference and theIntervalstatic factory helpers.- Numerics & Financial guides - every guide in this topic, across Bodu.Numerics and Bodu.Financial.