Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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`
120 changes: 120 additions & 0 deletions problems/3414-maximum-score-of-non-overlapping-intervals/solution.go
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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<<n; mask++ {
var picked []int
for i := 0; i < n; i++ {
if mask&(1<<i) != 0 {
picked = append(picked, i)
}
}
if len(picked) > 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)
}