diff --git a/problems/3414-maximum-score-of-non-overlapping-intervals/analysis.md b/problems/3414-maximum-score-of-non-overlapping-intervals/analysis.md new file mode 100644 index 0000000..9cf9a28 --- /dev/null +++ b/problems/3414-maximum-score-of-non-overlapping-intervals/analysis.md @@ -0,0 +1,61 @@ +# 3414. Maximum Score of Non-overlapping Intervals + +[LeetCode Link](https://leetcode.com/problems/maximum-score-of-non-overlapping-intervals/) + +Difficulty: Hard +Topics: Array, Binary Search, Dynamic Programming, Sorting +Acceptance Rate: 49.6% + +## Hints + +### Hint 1 + +Forget the "lexicographically smallest indices" part for a moment and ask a simpler question: what is the maximum achievable score if you may pick at most 4 non-overlapping intervals? That reduced question is the classic *weighted interval scheduling* problem with one extra dimension (how many intervals you have spent). Weighted interval scheduling always starts the same way: sort the intervals by an endpoint so that "compatible with the one I just took" becomes "somewhere in a suffix". + +### Hint 2 + +Sort by left endpoint and let `dp[i][j]` be the best you can do using only intervals from sorted position `i` onward while picking at most `j` of them. At each position you either skip the interval, landing on `dp[i+1][j]`, or take it, which forbids everything whose left endpoint is `<= r_i`. Because the array is sorted by left endpoint, the allowed intervals form a contiguous suffix, so the jump target is one **binary search** for the first left endpoint strictly greater than `r_i`. With `j` capped at 4, the whole table is only `5n` states. + +### Hint 3 + +The tie-breaking is what makes this Hard, and the key realization is that it can ride along inside the same DP instead of being a separate post-processing step. Store with every state not just the score but also the winning set of original indices, kept **sorted ascending** (at most 4 of them, so it is a tiny fixed-size array). Compare two candidates by score first, then lexicographically by that sorted array. + +Why is that greedy-looking comparison safe? Because if you insert the *same* index `x` into two sorted arrays `S1 < S2`, the merged arrays keep that order — provided neither is a prefix of the other. And a prefix can never happen between two *tied* candidates: a prefix means `S1` is a strict subset of `S2`, and since every weight is at least 1, the superset would have a strictly larger score, contradicting the tie. So locally optimal sub-answers compose into a globally optimal one, and no extra reconstruction pass is needed. + +## Approach + +**Step 1 — sort and precompute jumps.** Pair every interval with its original index, then sort by left endpoint. Extract the sorted left endpoints into their own array and, for each position `i`, binary search for `next[i]` = the first position whose left endpoint is `>= r_i + 1`. Everything in `[i+1, next[i])` has a left endpoint in `[l_i, r_i]` and therefore genuinely overlaps interval `i` (touching endpoints count as overlapping), so skipping straight to `next[i]` after taking `i` loses nothing. Note `next[i] > i` always holds, because `l_i <= r_i < r_i + 1`. + +**Step 2 — the DP state.** Define `dp[j][i]` for `j` in `0..4` and `i` in `0..n` as the best selection drawn from sorted positions `i..n-1` using **at most** `j` intervals. A selection is stored as a small struct: the total score, a count, and a fixed `[4]int32` of original indices kept in ascending order. Base cases are all-empty: `dp[0][i]` for every `i` (no budget) and `dp[j][n]` for every `j` (no intervals left), both the zero value. + +**Step 3 — the transition.** For `j` from 1 to 4 and `i` from `n-1` down to 0: + +- *skip*: `dp[j][i+1]`, unchanged. +- *take*: `dp[j-1][next[i]]` with interval `i` merged in — add `w_i` to the score and insert the original index into the sorted index array. + +Keep the better of the two, where "better" means a strictly larger score, or an equal score with a lexicographically smaller sorted index array (shorter wins if one is a prefix of the other, which is the correct rule for "at most 4" even though ties never actually reach it). Because `dp[j-1]` is fully computed before `dp[j]` starts, and within a row `i` only depends on `i+1`, the rows can be filled with two simple loops. + +**Step 4 — read off the answer.** `dp[4][0]` is the answer; copy its index array out as an `[]int`. Since all weights are positive and `n >= 1`, it always contains at least one index. + +**Walking example 1**, `intervals = [[1,3,2],[4,5,2],[1,5,5],[6,9,3],[6,7,1],[8,9,1]]`. Sorted by left endpoint: `(1,5,w5,idx2)`, `(1,3,w2,idx0)`, `(4,5,w2,idx1)`, `(6,7,w1,idx4)`, `(6,9,w3,idx3)`, `(8,9,w1,idx5)` (ties in the left endpoint may land in either order — it does not matter, since intervals sharing a left endpoint overlap each other anyway and both orders remain reachable through the skip branch). Taking `idx2` jumps past everything ending at or before 5 and lands on the `l = 6` block, whose best single pick is `idx3` with weight 3, giving score 8 as `[2,3]`. The competing chains `{0,1,3}` and `{2,4,5}` both total 7, so no tie-break is needed and the answer is `[2,3]`. + +**Walking example 2**, the optimum is `{6,1,3,5}` with weights `5+7+6+3 = 21`, which the DP emits already sorted as `[1,3,5,6]`. + +The tie-break machinery does show up on inputs like `[[1,4,5],[5,10,5],[1,10,10]]`: both `{0,1}` and `{2}` score 10, and comparing the sorted arrays `[0,1]` against `[2]` at their first element picks `[0,1]`. + +## Complexity Analysis + +Time Complexity: O(n log n) — sorting dominates; the `n` binary searches cost `O(n log n)` and the DP is `O(4n)` states with `O(4)` work each (the merge and the comparison both touch at most 4 slots), so `O(n)` overall for the DP. + +Space Complexity: O(n) — the sorted copy, the left-endpoint array, the jump table, and the `5 x (n+1)` DP table of fixed-size structs. The constant is larger than a plain score-only DP because each state carries up to 4 indices, but it is still linear. + +## Edge Cases + +- **Touching endpoints overlap.** `[1,5]` and `[5,10]` are *not* compatible. This is why the jump target searches for the first left endpoint `> r_i` (i.e. `>= r_i + 1`) rather than `>= r_i`. Getting this off by one wrong silently inflates scores. +- **Fewer than 4 intervals usable, or fewer than 4 available.** "At most 4" must be a real option: the `skip` branch plus the empty base cases handle a single interval (`n == 1` returns `[0]`) and inputs where everything mutually overlaps (returns the single heaviest, lexicographically smallest on ties). +- **Ties between selections of different sizes.** A set of 2 intervals and a set of 3 can score the same, and the answer array lengths then differ. The comparison has to be a genuine lexicographic comparison of sorted index arrays, not "prefer more intervals" or "prefer fewer". +- **Ties in weight everywhere.** With all weights equal, every maximum-score choice has the same size and the tie-break does all the work — e.g. five unit intervals of weight 1 each must yield `[0,1,2,3]`, the smallest four indices. +- **Order of the answer.** Indices are reported ascending, not in the order the intervals were chosen. Inserting into a sorted array during the DP keeps this invariant for free; sorting only at the very end would break the correctness of the comparisons made along the way. +- **Sorting destroys original indices.** Carry the original index through the sort, since the output is expressed in original-input terms. +- **Score magnitude.** Up to `4 * 10^9` exceeds 32-bit range. On platforms where `int` is 64-bit this is fine, but scoring in an explicit `int64` documents the intent. +- **Duplicate intervals.** Identical `[l, r, w]` triples at different indices are legitimate, and the tie-break must then pick the smaller index — which falls out of the lexicographic comparison. diff --git a/problems/3414-maximum-score-of-non-overlapping-intervals/problem.md b/problems/3414-maximum-score-of-non-overlapping-intervals/problem.md new file mode 100644 index 0000000..c55a6c8 --- /dev/null +++ b/problems/3414-maximum-score-of-non-overlapping-intervals/problem.md @@ -0,0 +1,58 @@ +--- +number: "3414" +frontend_id: "3414" +title: "Maximum Score of Non-overlapping Intervals" +slug: "maximum-score-of-non-overlapping-intervals" +difficulty: "Hard" +topics: + - "Array" + - "Binary Search" + - "Dynamic Programming" + - "Sorting" +acceptance_rate: 4957.0 +is_premium: false +created_at: "2026-09-12T04:49:49.125380+00:00" +fetched_at: "2026-09-12T04:49:49.125380+00:00" +link: "https://leetcode.com/problems/maximum-score-of-non-overlapping-intervals/" +date: "2026-09-12" +--- + +# 3414. Maximum Score of Non-overlapping Intervals + +You are given a 2D integer array `intervals`, where `intervals[i] = [li, ri, weighti]`. Interval `i` starts at position `li` and ends at `ri`, and has a weight of `weighti`. You can choose _up to_ 4 **non-overlapping** intervals. The **score** of the chosen intervals is defined as the total sum of their weights. + +Return the lexicographically smallest array of at most 4 indices from `intervals` with **maximum** score, representing your choice of non-overlapping intervals. + +Two intervals are said to be **non-overlapping** if they do not share any points. In particular, intervals sharing a left or right boundary are considered overlapping. + + + +**Example 1:** + +**Input:** intervals = [[1,3,2],[4,5,2],[1,5,5],[6,9,3],[6,7,1],[8,9,1]] + +**Output:** [2,3] + +**Explanation:** + +You can choose the intervals with indices 2, and 3 with respective weights of 5, and 3. + +**Example 2:** + +**Input:** intervals = [[5,8,1],[6,7,7],[4,7,3],[9,10,6],[7,8,2],[11,14,3],[3,5,5]] + +**Output:** [1,3,5,6] + +**Explanation:** + +You can choose the intervals with indices 1, 3, 5, and 6 with respective weights of 7, 6, 3, and 5. + + + +**Constraints:** + + * `1 <= intevals.length <= 5 * 104` + * `intervals[i].length == 3` + * `intervals[i] = [li, ri, weighti]` + * `1 <= li <= ri <= 109` + * `1 <= weighti <= 109` diff --git a/problems/3414-maximum-score-of-non-overlapping-intervals/solution.go b/problems/3414-maximum-score-of-non-overlapping-intervals/solution.go new file mode 100644 index 0000000..dfc9851 --- /dev/null +++ b/problems/3414-maximum-score-of-non-overlapping-intervals/solution.go @@ -0,0 +1,120 @@ +package main + +import "sort" + +// Weighted interval scheduling with a budget of at most 4 picks. +// +// Sort the intervals by left endpoint, so that the intervals compatible with a +// chosen one always form a suffix: after taking interval i, the first usable +// position is the first left endpoint strictly greater than r_i (touching +// endpoints count as overlapping), found with one binary search. +// +// dp[j][i] = best selection taken from sorted positions i..n-1 using at most j +// intervals, where "best" means the largest score and, on ties, the +// lexicographically smallest ascending array of original indices. Each state +// carries its own index array (at most 4 entries), so inserting the current +// index into the sub-answer's sorted array is enough - no reconstruction pass. +// +// Comparing candidates by their sorted index arrays is safe: inserting the same +// index into two sorted arrays preserves their lexicographic order unless one is +// a prefix of the other, and a prefix means a strict subset, which - since every +// weight is at least 1 - would have a strictly larger score and so could never +// be tied. +// +// Time: O(n log n). Space: O(n). + +// ivl is an input interval paired with its original index. +type ivl struct { + l, r, w int + idx int32 +} + +// sel is a chosen set of at most 4 intervals: its total score plus the original +// indices kept in ascending order. +type sel struct { + score int64 + cnt int8 + idx [4]int32 +} + +// with returns s extended by an interval of index x and weight w, keeping idx +// sorted ascending. The caller guarantees s.cnt < 4. +func (s sel) with(x int32, w int) sel { + out := sel{score: s.score + int64(w), cnt: s.cnt + 1} + i := int8(0) + for ; i < s.cnt && s.idx[i] < x; i++ { + out.idx[i] = s.idx[i] + } + out.idx[i] = x + for ; i < s.cnt; i++ { + out.idx[i+1] = s.idx[i] + } + return out +} + +// better reports whether a beats b: higher score wins, ties go to the +// lexicographically smaller index array (a prefix being the smaller one). +func better(a, b sel) bool { + if a.score != b.score { + return a.score > b.score + } + n := a.cnt + if b.cnt < n { + n = b.cnt + } + for i := int8(0); i < n; i++ { + if a.idx[i] != b.idx[i] { + return a.idx[i] < b.idx[i] + } + } + return a.cnt < b.cnt +} + +func maximumWeight(intervals [][]int) []int { + const maxPicks = 4 + + n := len(intervals) + if n == 0 { + return []int{} + } + + items := make([]ivl, n) + for i, in := range intervals { + items[i] = ivl{l: in[0], r: in[1], w: in[2], idx: int32(i)} + } + sort.Slice(items, func(a, b int) bool { return items[a].l < items[b].l }) + + lefts := make([]int, n) + for i := range items { + lefts[i] = items[i].l + } + + // next[i] is the first position whose interval starts after items[i] ends. + next := make([]int, n) + for i := range items { + next[i] = sort.SearchInts(lefts, items[i].r+1) + } + + dp := make([][]sel, maxPicks+1) + for j := range dp { + dp[j] = make([]sel, n+1) + } + // dp[0][*] and dp[*][n] stay at the zero value: the empty selection. + + for j := 1; j <= maxPicks; j++ { + for i := n - 1; i >= 0; i-- { + best := dp[j][i+1] // skip interval i + if take := dp[j-1][next[i]].with(items[i].idx, items[i].w); better(take, best) { + best = take + } + dp[j][i] = best + } + } + + best := dp[maxPicks][0] + out := make([]int, best.cnt) + for i := range out { + out[i] = int(best.idx[i]) + } + return out +} diff --git a/problems/3414-maximum-score-of-non-overlapping-intervals/solution_test.go b/problems/3414-maximum-score-of-non-overlapping-intervals/solution_test.go new file mode 100644 index 0000000..9441afd --- /dev/null +++ b/problems/3414-maximum-score-of-non-overlapping-intervals/solution_test.go @@ -0,0 +1,154 @@ +package main + +import ( + "math/rand" + "reflect" + "testing" +) + +func TestSolution(t *testing.T) { + tests := []struct { + name string + intervals [][]int + expected []int + }{ + { + name: "example 1: two intervals beat any triple", + intervals: [][]int{{1, 3, 2}, {4, 5, 2}, {1, 5, 5}, {6, 9, 3}, {6, 7, 1}, {8, 9, 1}}, + expected: []int{2, 3}, + }, + { + name: "example 2: full budget of four intervals", + intervals: [][]int{{5, 8, 1}, {6, 7, 7}, {4, 7, 3}, {9, 10, 6}, {7, 8, 2}, {11, 14, 3}, {3, 5, 5}}, + expected: []int{1, 3, 5, 6}, + }, + { + name: "edge case: single interval must be chosen", + intervals: [][]int{{1, 1000000000, 1000000000}}, + expected: []int{0}, + }, + { + name: "edge case: all intervals mutually overlap, heaviest wins", + intervals: [][]int{{1, 5, 3}, {2, 6, 4}, {3, 7, 3}}, + expected: []int{1}, + }, + { + name: "edge case: shared boundary counts as overlapping", + intervals: [][]int{{1, 5, 5}, {5, 10, 5}}, + expected: []int{0}, + }, + { + name: "edge case: more than four disjoint intervals, keep heaviest four", + intervals: [][]int{{1, 1, 1}, {3, 3, 2}, {5, 5, 3}, {7, 7, 4}, {9, 9, 5}}, + expected: []int{1, 2, 3, 4}, + }, + { + name: "edge case: equal weights, smallest indices win", + intervals: [][]int{{1, 1, 1}, {3, 3, 1}, {5, 5, 1}, {7, 7, 1}, {9, 9, 1}}, + expected: []int{0, 1, 2, 3}, + }, + { + name: "edge case: tie between a pair and a single, pair has smaller indices", + intervals: [][]int{{1, 4, 5}, {5, 10, 5}, {1, 10, 10}}, + expected: []int{0, 1}, + }, + { + name: "edge case: tie between a single and a pair, single has smaller index", + intervals: [][]int{{1, 10, 10}, {1, 4, 5}, {5, 10, 5}}, + expected: []int{0}, + }, + { + name: "edge case: duplicate intervals, first index wins", + intervals: [][]int{{1, 2, 5}, {1, 2, 5}}, + expected: []int{0}, + }, + { + name: "edge case: maximal weights do not overflow the score", + intervals: [][]int{{1, 2, 1000000000}, {3, 4, 1000000000}, {5, 6, 1000000000}, {7, 8, 1000000000}, {9, 10, 1000000000}}, + expected: []int{0, 1, 2, 3}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := maximumWeight(tt.intervals) + if !reflect.DeepEqual(result, tt.expected) { + t.Errorf("maximumWeight(%v) = %v, want %v", tt.intervals, result, tt.expected) + } + }) + } +} + +// TestSolutionAgainstBruteForce cross-checks the DP against exhaustive search on +// small random inputs with a tiny coordinate and weight range, so that ties (and +// therefore the lexicographic tie-break) come up often. +func TestSolutionAgainstBruteForce(t *testing.T) { + rng := rand.New(rand.NewSource(42)) + + for _, n := range []int{1, 2, 3, 5, 7} { + for iter := 0; iter < 300; iter++ { + intervals := make([][]int, n) + for i := range intervals { + l := rng.Intn(8) + 1 + r := l + rng.Intn(4) + intervals[i] = []int{l, r, rng.Intn(3) + 1} + } + + got := maximumWeight(intervals) + want := bruteForceMaximumWeight(intervals) + if !reflect.DeepEqual(got, want) { + t.Fatalf("maximumWeight(%v) = %v, want %v", intervals, got, want) + } + } + } +} + +// bruteForceMaximumWeight tries every subset of at most 4 pairwise +// non-overlapping intervals. +func bruteForceMaximumWeight(intervals [][]int) []int { + n := len(intervals) + var bestScore int + var best []int + + for mask := 1; mask < 1< 4 { + continue + } + + score := 0 + ok := true + for a := 0; a < len(picked) && ok; a++ { + score += intervals[picked[a]][2] + for b := a + 1; b < len(picked); b++ { + x, y := intervals[picked[a]], intervals[picked[b]] + if x[1] >= y[0] && y[1] >= x[0] { + ok = false + break + } + } + } + if !ok { + continue + } + + if best == nil || score > bestScore || (score == bestScore && lexLess(picked, best)) { + bestScore, best = score, picked + } + } + return best +} + +func lexLess(a, b []int) bool { + for i := 0; i < len(a) && i < len(b); i++ { + if a[i] != b[i] { + return a[i] < b[i] + } + } + return len(a) < len(b) +}