题目描述

✅ 632. 最小区间

image-20260928235837430

image-20260928235837431

题意分析

给定 k 个非递减有序列表,找一个闭区间,使每个列表都至少有一个数落在区间内。先比较区间长度 right - left,长度相同时选左端点更小的区间。

从每个列表选一个代表值后,包含它们的最短区间就是这些代表的最小值到最大值。问题因此转化为:如何有序地调整各列表的代表,找到最好的最小值与最大值组合,而不枚举所有组合。

解法:小根堆维护当前最小值

核心思路

[!blue]

初始选择每个列表的首项。小根堆保存代表的值、来源列表 row 和列表内位置 index,以便快速找出最小代表及其后继;另用 currentMax 保存所有代表的最大值。每轮循环开始时,每个列表都恰好有一个代表,所以 [堆顶值, currentMax] 一定覆盖全部列表。

先用这个完整覆盖区间更新答案,再推进最小代表所在列表。若保留当前最小值,只推进其他列表,由于各列表有序,新代表不会比旧代表更小,右端点只能不变或增大,区间不可能更短。因此,想获得新的更优区间,必须先移走一个当前最小值。

也可以从候选左界 L 理解这个过程:当所有小于 L 的值都被弹出后,每个列表的代表就是该列表中第一个不小于 L 的值。这时每组都选了满足左界要求的最小候选,它们的最大值已经是能达到的最小右界。如果某个合法区间是 [L, R],这些代表就都不超过 R,算法得到的区间不会比它更差。不断推进堆顶会依次到达这些状态,因此不会漏掉最优解。

替换代表时,新值不小于旧值,所有代表的最大值不会下降,所以只需执行 currentMax = max(currentMax, nextValue),无需重新扫描堆。若最小代表所在列表已经没有后继,当前区间仍然有效,必须先比较;此后再提高左界就无法覆盖这个列表,而保留当前左界也无法缩短右端,因此可以结束搜索。

堆顶值随着推进不会下降,所以后检查的区间左端点不会更小。代码只在长度严格变小时更新答案;等长时保留旧答案,就自然满足左端点优先的规则。

解题步骤

  • 初始化每个列表的首项并记录最大值。
  • 弹出最小项,严格更短时更新区间。
  • 该列表已耗尽则结束。
  • 否则补入它的下一项,并提升当前最大值。

题目保证所有列表非空,可以直接用首项初始化。只有一个列表时,第一轮就得到首项构成的零长度区间,后续同长区间不会替换它。重复值不会影响推进或覆盖关系,多个列表共享同一值时可以得到零长度答案。数值范围为 $[-10^5, 10^5]$,比较器中的差值和区间长度都不会超出 int 范围。

代码实现

class Solution {
    public int[] smallestRange(List<List<Integer>> nums) {
        PriorityQueue<int[]> heap = new PriorityQueue<>((a, b) -> a[0] - b[0]);
        int currentMax = Integer.MIN_VALUE;

        for (int row = 0; row < nums.size(); row++) {
            int value = nums.get(row).get(0);

            heap.offer(new int[] {
                value,
                row,
                0
            });
            currentMax = Math.max(currentMax, value);
        }

        int bestLeft = 0;
        int bestRight = Integer.MAX_VALUE;

        // 循环入口每个列表都有一个代表,先记录完整覆盖区间
        while (heap.size() == nums.size()) {
            int[] cur = heap.poll();
            int currentMin = cur[0];

            // 左端不下降,等长时保留此前更小的左端
            if (currentMax - currentMin < bestRight - bestLeft) {
                bestLeft = currentMin;
                bestRight = currentMax;
            }

            int row = cur[1];
            int index = cur[2] + 1;

            // 最小项所属列表耗尽,无法继续保持全覆盖
            if (index == nums.get(row).size()) {
                break;
            }

            int nextValue = nums.get(row).get(index);

            heap.offer(new int[] {
                nextValue,
                row,
                index
            });
            currentMax = Math.max(currentMax, nextValue);
        }

        return new int[] {
            bestLeft,
            bestRight
        };
    }
}
import "container/heap"

type RangeNode struct {
    value int
    row   int
    index int
}

type RangeHeap []RangeNode

func (h RangeHeap) Len() int {
    return len(h)
}

func (h RangeHeap) Less(i int, j int) bool {
    return h[i].value < h[j].value
}

func (h RangeHeap) Swap(i int, j int) {
    h[i], h[j] = h[j], h[i]
}

func (h *RangeHeap) Push(x any) {
    *h = append(*h, x.(RangeNode))
}

func (h *RangeHeap) Pop() any {
    old := *h
    node := old[len(old)-1]
    *h = old[:len(old)-1]
    return node
}

func smallestRange(nums [][]int) []int {
    h := &RangeHeap{}
    currentMax := -1 << 31
    for row := 0; row < len(nums); row++ {
        value := nums[row][0]
        heap.Push(h, RangeNode{value: value, row: row, index: 0})
        if value > currentMax {
            currentMax = value
        }
    }

    bestLeft := 0
    bestRight := 1<<31 - 1
    // 循环入口每个列表都有一个代表,先记录完整覆盖区间
    for h.Len() == len(nums) {
        cur := heap.Pop(h).(RangeNode)
        currentMin := cur.value
        // 左端不下降,等长时保留此前更小的左端
        if currentMax-currentMin < bestRight-bestLeft {
            bestLeft = currentMin
            bestRight = currentMax
        }

        nextIndex := cur.index + 1
        // 最小项所属列表耗尽,无法继续保持全覆盖
        if nextIndex == len(nums[cur.row]) {
            break
        }
        nextValue := nums[cur.row][nextIndex]
        heap.Push(h, RangeNode{value: nextValue, row: cur.row, index: nextIndex})
        if nextValue > currentMax {
            currentMax = nextValue
        }
    }
    return []int{
        bestLeft,
        bestRight,
    }
}

复杂度分析

  • 时间复杂度:$O(N\log(k+1))$,N 为元素总数、k 为列表数,每个元素最多入堆一次。
  • 空间复杂度:$O(k)$,堆中每个列表一个代表。

关键点总结

[!green]

  • 先记录当前完整覆盖,再尝试补充弹出的列表。
  • 严格改善长度即可保持等长时左端优先。

易错点总结

[!yellow]

  • 先推进再记录,会漏掉弹出前的合法区间。
  • 某个列表耗尽后仍继续,会得到缺少该列表的伪答案。
  • 等长时也覆盖旧答案,可能丢失此前更小的左端。

相似题目

题目 难度 关联与区别
76. 最小覆盖子串 困难 同样寻找覆盖所有类别的最短区间,本题类别是来源数组,原题是带重数的字符需求。
23. 合并 K 个升序链表 困难 同样每次弹出当前最小候选并补入同一来源,本题还维护当前最大值来比较覆盖区间。
转载与许可
作者
链接 https://hgnulb.github.io/blog/2025/61916172
许可 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处!