Reasoning Into CodeArunank Divya Sharan · Python edition← Arunank’s portfolio
Module 22 · Greedy Algorithms and Kadane-Style State

218. Maximum Number of Non-overlapping Intervals

Interview · Download this solution · All solutions and shared helpers

Problem

Select as many mutually nonoverlapping half-open intervals [start,end) as possible and return their endpoint tuples in selection order. Every interval satisfies start<end. Touching endpoints are compatible. Sort by (end,start,input_index) to break ties deterministically; leave input unchanged.

Examples

Example 1

select_intervals([(1,4),(2,3),(3,5),(5,6)])
=> [(2, 3), (3, 5), (5, 6)]

Earliest finishes permit three compatible choices.

Example 2

select_intervals([])
=> []

No intervals can be selected.

Example 3

select_intervals([(0,2),(0,2)])
=> [(0, 2)]

Identical nonempty intervals overlap, so only one is chosen.

Reveal the worked solution and reasoning

Why this mechanism works

The interval finishing earliest leaves at least as much room for future intervals as any alternative first choice. After selecting it, solve the same problem among intervals starting at or after its end. Sorting once allows this repeated exchange argument to become one scan.

State and boundaries

selected is a compatible sequence. last_end is its final endpoint; an interval is available exactly when start>=last_end.

Protocol

  1. Sort indexed intervals by finishing time and tie keys.
  2. Begin with no chosen endpoint.
  3. Accept each interval whose start is compatible.
  4. Update last_end only on acceptance.
  5. Return the selected sequence.

Visualization

[1,4), [2,3), [3,5), [5,6)
choose [2,3), then [3,5), then [5,6)
earliest start [1,4) would block the first two

Python implementation

def select_intervals(intervals):
    ordered = sorted(enumerate(intervals), key=lambda item:
                     (item[1][1], item[1][0], item[0]))
    selected, last_end = [], None
    for _, (start, end) in ordered:
        if last_end is None or start >= last_end:
            selected.append((start, end))
            last_end = end
    return selected

Worked execution

Recorded state changes after statements; L numbers refer to the solution file. Long traces are explicitly condensed.

select_intervals([(1,4),(2,3),(3,5),(5,6)])

L3 <lambda>: (item[1][1], item[1][0], item[0]))
item=(0, (1, 4))

L3 <lambda>: (item[1][1], item[1][0], item[0]))
item=(1, (2, 3))

L3 <lambda>: (item[1][1], item[1][0], item[0]))
item=(2, (3, 5))

L3 <lambda>: (item[1][1], item[1][0], item[0]))
item=(3, (5, 6))

L2 select_intervals: ordered = sorted(enumerate(intervals), key=lambda item:
intervals=[(1, 4), (2, 3), (3, 5), (5, 6)]; ordered=[(1, (..., ...)), (0, (..., ...)), (2, (..., ...)), (3, (..., ...))]

L4 select_intervals: selected, last_end = [], None
intervals=[(1, 4), (2, 3), (3, 5), (5, 6)]; ordered=[(1, (..., ...)), (0, (..., ...)), (2, (..., ...)), (3, (..., ...))]; selected=[]; last_end=None

L5 select_intervals: for _, (start, end) in ordered:
intervals=[(1, 4), (2, 3), (3, 5), (5, 6)]; ordered=[(1, (..., ...)), (0, (..., ...)), (2, (..., ...)), (3, (..., ...))]; selected=[]; last_end=None; _=1; start=2; end=3

L7 select_intervals: selected.append((start, end))
intervals=[(1, 4), (2, 3), (3, 5), (5, 6)]; ordered=[(1, (..., ...)), (0, (..., ...)), (2, (..., ...)), (3, (..., ...))]; selected=[(2, 3)]; last_end=None; _=1; start=2; end=3

... 4 intermediate state changes omitted ...

L8 select_intervals: last_end = end
intervals=[(1, 4), (2, 3), (3, 5), (5, 6)]; ordered=[(1, (..., ...)), (0, (..., ...)), (2, (..., ...)), (3, (..., ...))]; selected=[(2, 3), (3, 5)]; last_end=5; _=2; start=3; end=5

L5 select_intervals: for _, (start, end) in ordered:
intervals=[(1, 4), (2, 3), (3, 5), (5, 6)]; ordered=[(1, (..., ...)), (0, (..., ...)), (2, (..., ...)), (3, (..., ...))]; selected=[(2, 3), (3, 5)]; last_end=5; _=3; start=5; end=6

L7 select_intervals: selected.append((start, end))
intervals=[(1, 4), (2, 3), (3, 5), (5, 6)]; ordered=[(1, (..., ...)), (0, (..., ...)), (2, (..., ...)), (3, (..., ...))]; selected=[(2, 3), (3, 5), (5, 6)]; last_end=5; _=3; start=5; end=6

L8 select_intervals: last_end = end
intervals=[(1, 4), (2, 3), (3, 5), (5, 6)]; ordered=[(1, (..., ...)), (0, (..., ...)), (2, (..., ...)), (3, (..., ...))]; selected=[(2, 3), (3, 5), (5, 6)]; last_end=6; _=3; start=5; end=6

Result: [(2, 3), (3, 5), (5, 6)]. Earliest finishes permit three compatible choices.

select_intervals([])

L2 select_intervals: ordered = sorted(enumerate(intervals), key=lambda item:
intervals=[]; ordered=[]

L4 select_intervals: selected, last_end = [], None
intervals=[]; ordered=[]; selected=[]; last_end=None

Result: []. No intervals can be selected.

Why the solution is correct

Take an optimum solution's first interval. Replacing it with the earliest-finishing available interval cannot overlap any later selected interval, because its end is no later. Thus an optimum exists containing the greedy choice. Applying the same exchange to the remaining suffix proves maximum cardinality.

Time and space

O(n log n) time and O(n) sorting/output storage.

Common mistakes

Choosing earliest start can select a long interval that blocks many short ones. Using start>last_end incorrectly rejects touching half-open intervals.

Reasoning card

State: selected is a compatible sequence. last_end is its final endpoint; an interval is available exactly when start>=last_end.

Invariant: Chosen intervals leave maximal room for future choices.

Decision to rehearse: Sort by earliest finishing time.

Finish: Return the selected sequence

Transfer exercise

Assign each interval a value and maximize total value. Explain why earliest finish alone no longer proves optimality.