Blind Plus 100 Visualized

#87 ยท Heap / Priority Queue

Smallest Range Covering Elements from K Lists

Given k lists of integers sorted in non-decreasing order, find the smallest range [a, b] that includes at least one number from each of the k lists. Among equally small ranges, prefer the one with the smaller start.

HardLeetCode
  • Active
  • Found
  • Window
  • Visited
  • Rejected
step 1 / 1

Pointers

Variables

Hash map / table

Stack / queue

Foundations & data-structure selection

Min-heap of one current element per list

The heap always contains exactly one value from each list. Its top is the minimum (the left end of the current window) and a separately tracked maximum is the right end. Popping the minimum and pushing the next value from the same list slides the window forward.

Memory layout

A contiguous array of k entries; children of i at 2i + 1 and 2i + 2.

OperationCost
push / popO(log k)
peekO(1)

Sliding window over a merged, tagged list

Merge all values into one sorted list of (value, list id). The answer is the shortest window containing every list id at least once, found with the classic two-pointer count technique.

Memory layout

An array of N pairs plus k counters.

OperationCost
sortO(N log N)
windowO(N)

Why it fits

  • A valid range must contain one value per list, and its minimum must be the smallest of those chosen values. Moving the minimum forward is the only way to make a better range, which is a job for a min-heap.
  • Because every list is sorted, advancing the list that supplied the minimum is always safe: smaller values in that list can only produce wider ranges.
  • The heap keeps k elements instead of all N, so memory is O(k) rather than O(N).

Why alternatives degrade

Merge into one sorted array only:
Sorting alone does not find the window; you still need counting for coverage (and O(N) memory).
k pointers with a linear scan for the min:
Works in O(N ยท k); fine for tiny k, but the heap makes each step O(log k).
Binary search on the range width:
Possible with a feasibility check, but needs the value range and re-scans lists on every probe.

Four approaches, from naive to production

1 ยท beginner

Try each value as the left end

time O(N ยท k ยท L)space O(1)

For every value lo in any list, take the smallest value โ‰ฅ lo from each list; the largest of those is hi. Keep the narrowest [lo, hi].

vector<int> smallestRange(vector<vector<int>>& nums) {
    long long bestLo = INT_MIN, bestHi = INT_MAX;
    for (auto& list : nums) {
        for (int lo : list) {
            long long hi = lo;
            bool ok = true;
            for (auto& other : nums) {
                auto it = lower_bound(other.begin(), other.end(), lo);
                if (it == other.end()) { ok = false; break; }
                hi = max(hi, (long long)*it);
            }
            if (ok && (hi - lo < bestHi - bestLo || (hi - lo == bestHi - bestLo && lo < bestLo))) { bestLo = lo; bestHi = hi; }
        }
    }
    return {(int)bestLo, (int)bestHi};
}

Flaws & bottlenecks

  • For each of N candidates it scans all k lists (and each find is linear).
  • Repeats work between neighbouring candidates.
  • Slow once the lists hold thousands of values.

Interviewer cross-questions

Why can the left end be restricted to input values?

Say: If the left end is not a value, raising it to the next value keeps the range valid and makes it narrower.

How do you speed up the find in each list?

Say: Binary search each sorted list, or keep a pointer per list that only moves forward as lo increases.

What does a moving pointer per list lead to?

Say: The heap solution: one current value per list, always advancing the list that holds the smallest value.

2 ยท intermediate

Merge everything, slide a window

time O(N log N)space O(N)

Tag each value with its list id and sort all N pairs. Slide a window with two pointers and a count per list; whenever all k lists are covered, record the width and shrink from the left.

vector<int> smallestRange(vector<vector<int>>& nums) {
    vector<pair<int, int>> items;
    for (int id = 0; id < (int)nums.size(); id++) for (int v : nums[id]) items.push_back({v, id});
    sort(items.begin(), items.end());
    int need = nums.size();
    vector<int> count(need, 0);
    int covered = 0, left = 0;
    long long bestLo = INT_MIN, bestHi = INT_MAX;
    for (int right = 0; right < (int)items.size(); right++) {
        if (count[items[right].second]++ == 0) covered++;
        while (covered == need) {
            if (items[right].first - items[left].first < bestHi - bestLo) { bestLo = items[left].first; bestHi = items[right].first; }
            if (--count[items[left].second] == 0) covered--;
            left++;
        }
    }
    return {(int)bestLo, (int)bestHi};
}

Flaws & bottlenecks

  • Sorting ignores that the input lists are already sorted.
  • O(N) memory for the merged items.
  • The shrink loop needs careful coverage bookkeeping.

Interviewer cross-questions

How is this like "minimum window substring"?

Say: Same technique: grow right until all required kinds are covered, then shrink left while they stay covered.

Can you avoid the full sort?

Say: Yes: a k-way merge with a heap produces the sorted order lazily, which is the heap solution.

What decides ties?

Say: Shrinking only updates on a strictly smaller width, so the first (smallest-start) range of that width is kept.

3 ยท advanced

Min-heap of one element per list

time O(N log k)space O(k)

Push the first element of every list and track the maximum. Repeatedly pop the minimum, record [min, max] if narrower, and push the next element of the popped list; stop when a list runs out.

vector<int> smallestRange(vector<vector<int>>& nums) {
    priority_queue<array<int, 3>, vector<array<int, 3>>, greater<array<int, 3>>> heap; // {value, list, index}
    long long mx = INT_MIN;
    for (int id = 0; id < (int)nums.size(); id++) {
        heap.push({nums[id][0], id, 0});
        mx = max(mx, (long long)nums[id][0]);
    }
    long long bestLo = INT_MIN, bestHi = INT_MAX;
    for (;;) {
        auto [value, id, idx] = heap.top(); heap.pop();
        if (mx - value < bestHi - bestLo) { bestLo = value; bestHi = mx; }
        if (idx + 1 == (int)nums[id].size()) break;
        int next = nums[id][idx + 1];
        heap.push({next, id, idx + 1});
        mx = max(mx, (long long)next);
    }
    return {(int)bestLo, (int)bestHi};
}

Flaws & bottlenecks

  • Heap entries are small arrays (value, list, index): allocation per push.
  • Stopping when any list is exhausted must be explained clearly.
  • The maximum is tracked outside the heap and must be updated on every push.

Interviewer cross-questions

Why stop when a list is exhausted?

Say: The popped value was the minimum and has no successor in its list, so every later window would have to skip that list entirely.

Why is advancing only the popped list safe?

Say: The popped value is the window minimum. Keeping it can never narrow the range further, since the max is already fixed, so the only way to improve is to replace it with a larger value from its own list.

What is the complexity if k is 2 or 3?

Say: O(N) effectively; log k is a tiny constant. For huge k the heap matters most.

4 ยท expert

Index heap with guards

time O(N log k)space O(k)

Keep a pos[] array of per-list positions and a heap of list ids ordered by their current value (ties by id). No tuples are allocated. Guard empty input before touching nums[id][0].

vector<int> smallestRange(vector<vector<int>>& nums) {
    int k = nums.size();
    if (k == 0) return {};
    for (auto& list : nums) if (list.empty()) return {};
    vector<int> pos(k, 0);
    auto later = [&](int a, int b) {
        int va = nums[a][pos[a]], vb = nums[b][pos[b]];
        return va != vb ? va > vb : a > b;
    };
    priority_queue<int, vector<int>, decltype(later)> heap(later);
    long long mx = INT_MIN;
    for (int id = 0; id < k; id++) {
        heap.push(id);
        mx = max(mx, (long long)nums[id][0]);
    }
    long long bestLo = nums[heap.top()][0], bestHi = mx;
    for (;;) {
        int id = heap.top(); heap.pop();
        long long value = nums[id][pos[id]];
        if (mx - value < bestHi - bestLo) { bestLo = value; bestHi = mx; }
        if (++pos[id] == (int)nums[id].size()) break;
        heap.push(id);
        mx = max(mx, (long long)nums[id][pos[id]]);
    }
    return {(int)bestLo, (int)bestHi};
}

Flaws & bottlenecks

  • The comparator reads mutable state (pos), so pos[id] must change only while id is outside the heap.
  • More moving parts: pos array, comparator, tie-breaking.
  • A heap of ids is slower for tiny k than a linear scan over k pointers.

Interviewer cross-questions

Why must pos[id] change only after popping?

Say: The heap order depends on nums[id][pos[id]]. Changing it while the id is inside would silently break the heap invariant.

What are the edge cases?

Say: No lists, an empty list (no valid range), k = 1 (the smallest element as a range of width 0), duplicates across lists, negative values.

How would you reduce allocation further?

Say: Preallocate the heap array of size k and store ids in an int array; this version already avoids per-element objects.

Synchronized code walkthrough

Showing: Try each value as the left end โ€” shares the player above; click any step to jump.

vector<int> smallestRange(vector<vector<int>>& nums) {
    long long bestLo = INT_MIN, bestHi = INT_MAX;
    for (auto& list : nums) {
        for (int lo : list) {
            long long hi = lo;
            bool ok = true;
            for (auto& other : nums) {
                auto it = lower_bound(other.begin(), other.end(), lo);
                if (it == other.end()) { ok = false; break; }
                hi = max(hi, (long long)*it);
            }
            if (ok && (hi - lo < bestHi - bestLo || (hi - lo == bestHi - bestLo && lo < bestLo))) { bestLo = lo; bestHi = hi; }
        }
    }
    return {(int)bestLo, (int)bestHi};
}