📏 区间问题 — 合并与调度的艺术

会议安排、任务调度、时间管理——区间问题是现实世界最直接的算法应用

📖 区间问题的核心概念

区间问题通常以 [start, end] 形式给出,涉及区间的合并、插入、删除、重叠判断等操作。解题的万能第一步:按起点或终点排序,排序后问题往往变得简单。

区间问题分类: ┌─ 合并类: 合并重叠区间 (LC 56) │ ├─ 插入类: 插入新区间到有序区间 (LC 57) │ ├─ 删除类: 删除最少区间消除重叠 (LC 435) │ ├─ 覆盖类: 用最少区间覆盖目标范围 (LC 45) │ └─ 计数类: 某点被多少区间覆盖 (LC 253) 核心思路: 排序 → 贪心 → 一次遍历

1. 排序策略的选择

按起点排序:最常用的策略。排完后,相邻区间如果重叠,合并为一个大区间。如果不重叠,就是独立区间。适合合并、插入类问题。
按终点排序:贪心选择中常用。终点最早的区间优先保留,因为它给后续区间留下最多空间。适合"最多不重叠区间"、"最少删除"类问题。

2. Python 区间操作模板

# 区间排序
intervals.sort(key=lambda x: x[0])  # 按起点
intervals.sort(key=lambda x: x[1])  # 按终点

# 判断两个区间是否重叠
def is_overlap(a, b):
    return a[0] <= b[1] and b[0] <= a[1]

# 合并两个重叠区间
def merge_two(a, b):
    return [min(a[0], b[0]), max(a[1], b[1])]
区间问题 80% 的难度在排序策略的选择上。选对了排序方式,代码往往只有十几行。面试时先想清楚排序策略再动手写代码。

🎯 题目一:合并区间 (LC 56)

题目描述:给定一组区间,合并所有重叠的区间。

示例: intervals = [[1,3],[2,6],[8,10],[15,18]] 排序后: [1,3] [2,6] [8,10] [15,18] [1,3]与[2,6]: 2≤3 → 重叠 → 合并为[1,6] [1,6]与[8,10]: 8>6 → 不重叠 → 保留[1,6] [8,10]与[15,18]: 15>10 → 不重叠 → 保留[8,10] 结果: [[1,6],[8,10],[15,18]]

思路分析

排序 + 贪心合并:按起点排序后,维护当前合并区间 [cur_start, cur_end]。遍历每个新区间,如果新区间起点 ≤ 当前终点(重叠),则合并(更新终点);否则,保存当前区间,开始新区间。

代码实现

class Solution:
    def merge(self, intervals: List[List[int]]) -> List[List[int]]:
        if not intervals:
            return []
        intervals.sort(key=lambda x: x[0])
        result = [intervals[0]]
        for interval in intervals[1:]:
            # 重叠 → 合并
            if interval[0] <= result[-1][1]:
                result[-1][1] = max(result[-1][1], interval[1])
            else:
                result.append(interval)
        return result

复杂度分析

时间复杂度:O(n log n) — 排序主导

空间复杂度:O(n) — 结果数组

合并时取 max(result[-1][1], interval[1]) 而非直接用 interval[1],因为可能存在 [1,5],[2,3] 这种被包含的情况。

🎯 题目二:插入区间 (LC 57)

题目描述:给定一个无重叠的有序区间列表和一个新区间,插入新区间并合并重叠部分。

示例: intervals = [[1,2],[3,5],[6,7],[8,10],[12,16]], newInterval = [4,8] 阶段1: [1,2] 的end < 4 → 直接加入 阶段2: [3,5]与[4,8]重叠 → 合并为[3,8] [6,7]与[3,8]重叠 → 合并为[3,8] [8,10]与[3,8]重叠 → 合并为[3,10] 阶段3: [12,16]的start > 10 → 直接加入 结果: [[1,2],[3,10],[12,16]]

思路分析

三阶段法:将区间分为三部分:(1) 完全在新区间左侧的,直接加入结果;(2) 与新区间重叠的,合并(更新新区间的起止点);(3) 完全在新区间右侧的,直接加入结果。

代码实现

class Solution:
    def insert(self, intervals: List[List[int]], 
               newInterval: List[int]) -> List[List[int]]:
        result = []
        i = 0
        n = len(intervals)
        
        # 阶段1: 完全在左侧的
        while i < n and intervals[i][1] < newInterval[0]:
            result.append(intervals[i])
            i += 1
        
        # 阶段2: 重叠的,合并
        while i < n and intervals[i][0] <= newInterval[1]:
            newInterval[0] = min(newInterval[0], intervals[i][0])
            newInterval[1] = max(newInterval[1], intervals[i][1])
            i += 1
        result.append(newInterval)
        
        # 阶段3: 完全在右侧的
        while i < n:
            result.append(intervals[i])
            i += 1
        
        return result

复杂度分析

时间复杂度:O(n) — 有序区间,一次遍历

空间复杂度:O(n)

LC 57 的优势在于输入已经有序,所以 O(n) 即可。如果输入无序,可以先加入新区间再调用 LC 56 的合并,O(n log n)。

🎯 题目三:无重叠区间 (LC 435)

题目描述:给定一组区间,找到需要移除的最小区间数,使剩余区间互不重叠。

示例: intervals = [[1,2],[2,3],[3,4],[1,3]] 按终点排序: [1,2] [2,3] [3,4] [1,3] 贪心选择: 保留[1,2] → end=2 [2,3]: 2≥2 → 保留 → end=3 [3,4]: 3≥3 → 保留 → end=4 [1,3]: 1<4 → 重叠 → 删除 保留3个, 删除1个 → 答案=1

思路分析

按终点排序 + 贪心:等价于求"最多能保留多少不重叠区间"。按终点排序后,贪心选择终点最早的区间(最早结束的区间给后续留下最多空间)。如果当前区间起点 ≥ 上一个保留区间的终点,保留;否则删除。
答案 = 总数 - 最多保留数

代码实现

class Solution:
    def eraseOverlapIntervals(self, intervals: List[List[int]]) -> int:
        if not intervals:
            return 0
        # 按终点排序
        intervals.sort(key=lambda x: x[1])
        count = 0  # 需要删除的数量
        end = intervals[0][1]
        
        for i in range(1, len(intervals)):
            if intervals[i][0] < end:
                # 重叠,删除当前区间
                count += 1
            else:
                # 不重叠,保留,更新终点
                end = intervals[i][1]
        
        return count

复杂度分析

时间复杂度:O(n log n) — 排序

空间复杂度:O(1)

LC 435 为什么按终点排序而非起点?直觉:按起点排序可能保留一个很长的区间,导致后续很多区间被删除。按终点排序保证每个保留的区间都尽可能短,给后续留最多空间。这是经典的"活动选择问题"。

🔬 区间问题全攻略

区间问题有一套固定的解题范式:

1. 排序 + 贪心范式

# 区间问题通用框架
def solve_intervals(intervals):
    intervals.sort(key=lambda x: x[0])  # 或 x[1]
    result = [intervals[0]]  # 或其他初始值
    
    for interval in intervals[1:]:
        if has_overlap(result[-1], interval):
            # 处理重叠:合并 or 删除
            handle_overlap(result, interval)
        else:
            # 不重叠:直接加入
            result.append(interval)
    
    return result

2. 扫描线算法

对于"某点被多少区间覆盖"的问题,用扫描线:将每个区间的起止点标记为 +1/-1 事件,排序后扫描。

# 扫描线: 求最多同时有多少区间重叠
def min_meeting_rooms(intervals):
    events = []
    for start, end in intervals:
        events.append((start, 1))   # 开始+1
        events.append((end, -1))    # 结束-1
    events.sort()
    
    cur = max_rooms = 0
    for _, delta in events:
        cur += delta
        max_rooms = max(max_rooms, cur)
    return max_rooms

3. 差分数组

对于区间加减、区间查询的问题,差分数组可以在 O(1) 完成区间修改,O(n) 完成前缀和还原。

# 差分数组: 区间[l,r]全部+val
diff = [0] * (n + 1)
diff[l] += val
diff[r + 1] -= val
# 还原: 前缀和
arr = [0] * n
arr[0] = diff[0]
for i in range(1, n):
    arr[i] = arr[i-1] + diff[i]
区间问题的三种武器:(1) 排序+贪心(合并、选择);(2) 扫描线(计数、最大重叠);(3) 差分数组(区间修改)。根据问题特征选择合适武器。
成就解锁:区间大师 — 掌握合并区间、插入区间、贪心选择三大核心技巧
LeetCode AC验证:LC 56 Merge Intervals ✅ | LC 57 Insert Interval ✅ | LC 435 Non-overlapping Intervals ✅

📝 课后练习

  1. LC 253 会议室 II(扫描线求最大重叠数)
  2. LC 45 跳跃游戏 II(区间覆盖的视角)
  3. LC 1288 删除被覆盖的区间(包含关系判断)
  4. LC 352 将数据流变为多个不相交区间(动态合并)
  5. LC 757 交集大小至少为2(贪心+区间)

🔑 本课要点回顾

📚 扩展阅读

思考题:如果区间是环形的(如时间跨越午夜,[22,2] 表示22:00到次日2:00),该如何处理合并?提示:可以将环形区间拆分为两种情况讨论,或固定一个断点将环形转为线性。