Facebook Pixel

1751. Maximum Number of Events That Can Be Attended II

Problem Description

You have an array of events where each event is represented as events[i] = [startDay_i, endDay_i, value_i]. This means:

  • The i-th event starts on startDay_i
  • The i-th event ends on endDay_i
  • If you attend this event, you receive a value of value_i

You're also given an integer k which represents the maximum number of events you can attend.

Important constraints:

  • You can only attend one event at a time
  • If you choose to attend an event, you must attend the entire event from start to finish
  • The end day is inclusive - you cannot attend two events where one starts on the same day another ends

Your goal is to find the maximum sum of values you can receive by attending at most k events.

For example, if you have events like [[1,2,10], [2,3,20], [3,4,30]] and k=2, you need to choose which 2 events to attend to maximize your total value. Since the first event ends on day 2 and the second event starts on day 2, you cannot attend both (as the end day is inclusive). You could attend events 1 and 3 for a total value of 40, or events 2 and 3 for a total value of 50, making 50 the maximum achievable value.

Quick Interview Experience
Help others by sharing your interview experience
Have you seen this problem before?

Intuition

When faced with this problem, we need to make decisions about which events to attend to maximize our total value. This is a classic optimization problem where we have choices to make at each step.

The key insight is that for each event, we have two choices: either attend it or skip it. This binary decision-making pattern suggests a recursive approach where we explore both possibilities and choose the better outcome.

To structure our approach, we should first sort the events by start time. This ordering helps us process events chronologically and ensures that once we've made a decision about an event, we only need to consider future events.

For any event at position i, if we decide to skip it, we simply move to the next event at position i+1 with the same number of remaining events we can attend. If we decide to attend it, we gain its value, but we need to find the next event we're allowed to attend (one that starts after the current event ends).

Since events are sorted by start time, we can use binary search to efficiently find the first event whose start time is greater than the current event's end time. This is crucial for performance - instead of checking each subsequent event linearly, we can jump directly to the next valid event in O(log n) time.

The recursive nature of the problem leads us to define a function dfs(i, k) that represents: "Starting from event i, with k events left to attend, what's the maximum value we can get?" At each step, we take the maximum of:

  • Not attending event i: dfs(i+1, k)
  • Attending event i: value[i] + dfs(j, k-1) where j is the next valid event

Since we'll encounter the same subproblems multiple times (same event index with same remaining capacity), we can use memoization to cache results and avoid redundant calculations. This transforms our solution from exponential to polynomial time complexity.

Learn more about Binary Search, Dynamic Programming and Sorting patterns.

Solution Approach

The implementation follows a dynamic programming approach with memoization and binary search optimization.

Step 1: Sort the events We begin by sorting all events by their start time in ascending order. This allows us to process events chronologically and enables efficient binary search later.

events.sort()

Step 2: Define the recursive function with memoization We create a recursive function dfs(i, k) that computes the maximum value obtainable starting from event i with at most k events remaining to attend. The @cache decorator provides automatic memoization to store previously computed results.

@cache
def dfs(i: int, k: int) -> int:

Step 3: Handle base case If we've processed all events (i.e., i >= len(events)), there's no value to gain, so we return 0.

if i >= len(events):
    return 0

Step 4: Extract current event details We unpack the current event's information: start day (which we don't need here), end day, and value.

_, ed, val = events[i]

Step 5: Calculate value if we skip the current event First, we consider not attending event i, which means moving to the next event with the same capacity:

ans = dfs(i + 1, k)

Step 6: Calculate value if we attend the current event If we still have capacity (k > 0), we can consider attending the current event. We need to find the next event we can attend after this one ends.

Using bisect_right with a custom key function, we find the first event whose start time is strictly greater than the current event's end time ed. The search starts from i + 1 to avoid reconsidering earlier events:

if k:
    j = bisect_right(events, ed, lo=i + 1, key=lambda x: x[0])
    ans = max(ans, dfs(j, k - 1) + val)

The key=lambda x: x[0] tells bisect_right to compare against the start times of events. The function returns the index j of the first event that starts after day ed.

Step 7: Return the maximum value We return the maximum between skipping and attending the current event:

return ans

Step 8: Initiate the recursion Finally, we start the recursion from the first event (index 0) with the full capacity of k events:

return dfs(0, k)

The time complexity is O(n * k * log n) where n is the number of events. We have n * k possible states in our memoization, and for each state, we perform a binary search that takes O(log n) time. The space complexity is O(n * k) for the memoization cache.

Ready to land your dream job?

Unlock your dream job with a 3-minute evaluator for a personalized learning plan!

Start Evaluator

Example Walkthrough

Let's walk through a concrete example to illustrate the solution approach.

Example Input:

  • events = [[1,2,4], [3,4,3], [2,3,1]]
  • k = 2 (we can attend at most 2 events)

Step 1: Sort events by start time After sorting: events = [[1,2,4], [2,3,1], [3,4,3]]

Step 2: Begin recursion with dfs(0, 2)

Starting at event 0 [1,2,4] with k=2:

  • Option 1: Skip event 0 → dfs(1, 2)
  • Option 2: Attend event 0 → gain value 4, find next valid event
    • Event 0 ends on day 2
    • Using binary search, find first event starting after day 2
    • Event at index 1 starts on day 2 (not valid, same day)
    • Event at index 2 starts on day 3 (valid!)
    • So: 4 + dfs(2, 1)

Step 3: Evaluate dfs(1, 2)

At event 1 [2,3,1] with k=2:

  • Option 1: Skip event 1 → dfs(2, 2)
  • Option 2: Attend event 1 → gain value 1
    • Event 1 ends on day 3
    • Binary search finds no events starting after day 3
    • So: 1 + dfs(3, 1) = 1 + 0 = 1

Step 4: Evaluate dfs(2, 2)

At event 2 [3,4,3] with k=2:

  • Option 1: Skip event 2 → dfs(3, 2) = 0 (no more events)
  • Option 2: Attend event 2 → gain value 3
    • Event 2 ends on day 4
    • No events after this
    • So: 3 + dfs(3, 1) = 3 + 0 = 3

Therefore, dfs(2, 2) = max(0, 3) = 3

Step 5: Back to dfs(1, 2) dfs(1, 2) = max(dfs(2, 2), 1 + dfs(3, 1)) = max(3, 1) = 3

Step 6: Evaluate dfs(2, 1)

At event 2 [3,4,3] with k=1:

  • Option 1: Skip → 0
  • Option 2: Attend → 3 + dfs(3, 0) = 3

Therefore, dfs(2, 1) = 3

Step 7: Back to dfs(0, 2) dfs(0, 2) = max(dfs(1, 2), 4 + dfs(2, 1)) = max(3, 4 + 3) = 7

Final Answer: 7

We attend event 0 (days 1-2, value 4) and event 2 (days 3-4, value 3) for a total value of 7.

The memoization cache stores computed values for state combinations like (0,2), (1,2), (2,2), (2,1), preventing redundant calculations when the same subproblem is encountered again.

Solution Implementation

1class Solution:
2    def maxValue(self, events: List[List[int]], k: int) -> int:
3        """
4        Find maximum value by attending at most k non-overlapping events.
5      
6        Args:
7            events: List of events where each event is [start_day, end_day, value]
8            k: Maximum number of events that can be attended
9          
10        Returns:
11            Maximum total value achievable
12        """
13        from functools import cache
14        import bisect
15      
16        @cache
17        def dp(index: int, remaining_events: int) -> int:
18            """
19            Dynamic programming function to find max value from index onwards.
20          
21            Args:
22                index: Current index in sorted events list
23                remaining_events: Number of events we can still attend
24              
25            Returns:
26                Maximum value achievable from current state
27            """
28            # Base case: no more events to consider
29            if index >= len(events):
30                return 0
31          
32            # Extract current event details
33            start_day, end_day, value = events[index]
34          
35            # Option 1: Skip current event
36            max_value = dp(index + 1, remaining_events)
37          
38            # Option 2: Take current event (if we have remaining capacity)
39            if remaining_events > 0:
40                # Find next non-overlapping event using binary search
41                # We need to find first event that starts after current event ends
42                next_valid_index = bisect.bisect_right(
43                    events, 
44                    end_day,
45                    lo=index + 1,
46                    key=lambda event: event[0]  # Compare by start day
47                )
48              
49                # Take current event and continue from next valid index
50                max_value = max(
51                    max_value,
52                    dp(next_valid_index, remaining_events - 1) + value
53                )
54          
55            return max_value
56      
57        # Sort events by start day for binary search to work correctly
58        events.sort()
59      
60        # Start recursion from first event with k available selections
61        return dp(0, k)
62
1class Solution {
2    private int[][] events;
3    private int[][] dp;  // dp[i][j] represents max value starting from event i with j events remaining
4    private int numEvents;
5
6    /**
7     * Find the maximum sum of values from attending at most k non-overlapping events.
8     * @param events Array where events[i] = [startDay, endDay, value]
9     * @param k Maximum number of events that can be attended
10     * @return Maximum sum of values possible
11     */
12    public int maxValue(int[][] events, int k) {
13        // Sort events by start time for efficient processing
14        Arrays.sort(events, (a, b) -> a[0] - b[0]);
15      
16        this.events = events;
17        this.numEvents = events.length;
18        this.dp = new int[numEvents][k + 1];
19      
20        // Start DFS from first event with k events available
21        return dfs(0, k);
22    }
23
24    /**
25     * DFS with memoization to find maximum value.
26     * @param eventIndex Current event index being considered
27     * @param remainingEvents Number of events that can still be attended
28     * @return Maximum value achievable from current state
29     */
30    private int dfs(int eventIndex, int remainingEvents) {
31        // Base cases: no more events or no remaining capacity
32        if (eventIndex >= numEvents || remainingEvents <= 0) {
33            return 0;
34        }
35      
36        // Return memoized result if already computed
37        if (dp[eventIndex][remainingEvents] != 0) {
38            return dp[eventIndex][remainingEvents];
39        }
40      
41        // Find the next event that starts after current event ends
42        int nextAvailableEvent = search(events, events[eventIndex][1], eventIndex + 1);
43      
44        // Choose maximum between:
45        // 1. Skip current event and consider next one
46        // 2. Attend current event and find next non-overlapping event
47        int skipCurrent = dfs(eventIndex + 1, remainingEvents);
48        int attendCurrent = dfs(nextAvailableEvent, remainingEvents - 1) + events[eventIndex][2];
49        int maxValue = Math.max(skipCurrent, attendCurrent);
50      
51        // Memoize and return result
52        dp[eventIndex][remainingEvents] = maxValue;
53        return maxValue;
54    }
55
56    /**
57     * Binary search to find the first event that starts after the given end day.
58     * @param events Sorted array of events
59     * @param endDay The end day of current event
60     * @param startIndex Starting index for search
61     * @return Index of first event that starts after endDay, or numEvents if none exists
62     */
63    private int search(int[][] events, int endDay, int startIndex) {
64        int left = startIndex;
65        int right = numEvents;
66      
67        // Binary search for first event with start day > endDay
68        while (left < right) {
69            int mid = (left + right) >> 1;  // Equivalent to (left + right) / 2
70          
71            if (events[mid][0] > endDay) {
72                // Found a valid event, try to find earlier one
73                right = mid;
74            } else {
75                // Current event overlaps, search in right half
76                left = mid + 1;
77            }
78        }
79      
80        return left;
81    }
82}
83
1class Solution {
2public:
3    int maxValue(vector<vector<int>>& events, int k) {
4        // Sort events by start time (default sorting for vector<int> compares first element)
5        ranges::sort(events);
6      
7        int numEvents = events.size();
8      
9        // dp[i][j] represents the maximum value we can get from events[i...n-1] 
10        // using at most j events
11        int dp[numEvents][k + 1];
12        memset(dp, 0, sizeof(dp));
13      
14        // Recursive function with memoization to find maximum value
15        auto findMaxValue = [&](this auto&& findMaxValue, int eventIndex, int remainingEvents) -> int {
16            // Base cases: no more events to consider or no more events allowed
17            if (eventIndex >= numEvents || remainingEvents <= 0) {
18                return 0;
19            }
20          
21            // Return memoized result if already computed
22            if (dp[eventIndex][remainingEvents] > 0) {
23                return dp[eventIndex][remainingEvents];
24            }
25          
26            // Extract current event details
27            int currentEndTime = events[eventIndex][1];
28            int currentValue = events[eventIndex][2];
29          
30            // Create a dummy event for binary search (only need start time for comparison)
31            vector<int> searchTarget = {currentEndTime};
32          
33            // Find the first event that starts after current event ends
34            // Using upper_bound to find first event with start time > currentEndTime
35            int nextValidEventIndex = upper_bound(
36                events.begin() + eventIndex + 1, 
37                events.end(), 
38                searchTarget,
39                [](const auto& a, const auto& b) { 
40                    return a[0] < b[0];  // Compare start times
41                }
42            ) - events.begin();
43          
44            // Two choices: skip current event or take current event
45            int skipCurrent = findMaxValue(eventIndex + 1, remainingEvents);
46            int takeCurrent = findMaxValue(nextValidEventIndex, remainingEvents - 1) + currentValue;
47          
48            // Store and return the maximum of two choices
49            dp[eventIndex][remainingEvents] = max(skipCurrent, takeCurrent);
50            return dp[eventIndex][remainingEvents];
51        };
52      
53        // Start recursion from first event with k events available
54        return findMaxValue(0, k);
55    }
56};
57
1/**
2 * Finds the maximum value by attending at most k non-overlapping events
3 * @param events - Array of events where each event is [startDay, endDay, value]
4 * @param k - Maximum number of events that can be attended
5 * @returns Maximum sum of values from attending at most k events
6 */
7function maxValue(events: number[][], k: number): number {
8    // Sort events by start time in ascending order
9    events.sort((a, b) => a[0] - b[0]);
10  
11    const numEvents = events.length;
12  
13    // Memoization table: dp[i][j] represents max value starting from event i with j events remaining
14    const memoTable: number[][] = Array.from(
15        { length: numEvents }, 
16        () => Array(k + 1).fill(0)
17    );
18
19    /**
20     * Dynamic programming with memoization to find maximum value
21     * @param eventIndex - Current event index being considered
22     * @param eventsRemaining - Number of events we can still attend
23     * @returns Maximum value achievable from current state
24     */
25    const findMaxValue = (eventIndex: number, eventsRemaining: number): number => {
26        // Base cases: no more events or no remaining capacity
27        if (eventIndex >= numEvents || eventsRemaining <= 0) {
28            return 0;
29        }
30      
31        // Return memoized result if already computed
32        if (memoTable[eventIndex][eventsRemaining] > 0) {
33            return memoTable[eventIndex][eventsRemaining];
34        }
35
36        // Extract current event details
37        const currentEndDay = events[eventIndex][1];
38        const currentValue = events[eventIndex][2];
39
40        // Binary search to find the next non-overlapping event
41        // Find first event that starts after current event ends
42        let leftBound = eventIndex + 1;
43        let rightBound = numEvents;
44      
45        while (leftBound < rightBound) {
46            const midPoint = (leftBound + rightBound) >> 1;
47          
48            if (events[midPoint][0] > currentEndDay) {
49                rightBound = midPoint;
50            } else {
51                leftBound = midPoint + 1;
52            }
53        }
54      
55        const nextValidEventIndex = leftBound;
56
57        // Choose maximum between:
58        // 1. Skip current event and consider next event
59        // 2. Take current event and consider next valid non-overlapping event
60        const skipCurrent = findMaxValue(eventIndex + 1, eventsRemaining);
61        const takeCurrent = findMaxValue(nextValidEventIndex, eventsRemaining - 1) + currentValue;
62      
63        memoTable[eventIndex][eventsRemaining] = Math.max(skipCurrent, takeCurrent);
64      
65        return memoTable[eventIndex][eventsRemaining];
66    };
67
68    // Start the recursive search from first event with k events available
69    return findMaxValue(0, k);
70}
71

Time and Space Complexity

The time complexity is O(n × log n + n × k), where n is the number of events.

  • The initial sorting of events takes O(n × log n) time.
  • The recursive DFS function with memoization has at most n × k unique states (each combination of index i from 0 to n-1 and remaining count k from 0 to k).
  • For each state, we perform:
    • A binary search using bisect_right which takes O(log n) time
    • Other operations that take O(1) time
  • Since the binary search is performed at most once per state due to memoization, and we have O(n × k) states, the total time for all DFS calls is O(n × k × log n).
  • However, examining the recursion pattern more carefully: each state (i, k) either skips the current event or takes it and jumps to a future index. The binary search is only performed when we choose to take an event (when k > 0), which happens at most k times along any path. With memoization, each state is computed once, giving us O(n × k) for the DFS traversal plus the binary searches within.
  • Combined with sorting: O(n × log n + n × k)

The space complexity is O(n × k), where n is the number of events.

  • The @cache decorator (memoization) stores up to n × k unique states in memory.
  • The recursion stack depth is at most O(n) in the worst case when we skip all events.
  • Since n × k dominates, the overall space complexity is O(n × k).

Learn more about how to find time and space complexity quickly.

Common Pitfalls

1. Incorrect Binary Search for Next Valid Event

One of the most common mistakes is incorrectly finding the next non-overlapping event after attending the current one. Many developers make these errors:

Pitfall Example:

# WRONG: Using bisect_left instead of bisect_right
next_valid_index = bisect.bisect_left(events, end_day, lo=index + 1, key=lambda x: x[0])

# WRONG: Searching for end_day + 1 (off-by-one error)
next_valid_index = bisect.bisect_left(events, end_day + 1, lo=index + 1, key=lambda x: x[0])

Why it's wrong:

  • bisect_left finds the leftmost position where end_day could be inserted, which might give us an event that starts exactly on end_day. Since the problem states that the end day is inclusive (you cannot attend two events where one starts on the same day another ends), this would create an overlap.
  • Using end_day + 1 with bisect_left seems logical but is unnecessary and can cause confusion. The cleaner approach is using bisect_right with end_day.

Correct Solution:

# Find the first event that starts AFTER end_day (strictly greater than)
next_valid_index = bisect.bisect_right(events, end_day, lo=index + 1, key=lambda x: x[0])

2. Forgetting to Sort Events Before Processing

Pitfall Example:

def maxValue(self, events: List[List[int]], k: int) -> int:
    # WRONG: Forgot to sort events!
    # events.sort()  <- Missing this line
  
    @cache
    def dp(index: int, remaining: int) -> int:
        # ... rest of the code

Why it's wrong: The binary search relies on the events being sorted by start time. Without sorting, the binary search will produce incorrect results, leading to wrong answers or even infinite recursion in some cases.

Correct Solution:

# Always sort events by start time before processing
events.sort()  # This sorts by start_day first (default behavior for lists)

3. Incorrect Memoization State

Pitfall Example:

# WRONG: Including unnecessary information in memoization state
@cache
def dp(index: int, remaining: int, last_end_day: int) -> int:
    # Adding last_end_day is redundant since we use binary search

Why it's wrong: Including last_end_day in the state makes the solution less efficient. Since events are sorted and we use binary search to find the next valid event, we don't need to track the last end day explicitly. This would increase both time and space complexity unnecessarily.

Correct Solution:

@cache
def dp(index: int, remaining_events: int) -> int:
    # Only track current index and remaining capacity

4. Off-by-One Error in Base Cases

Pitfall Example:

# WRONG: Checking remaining_events before recursion
if remaining_events == 0:
    return 0

# WRONG: Using > instead of >= for index check
if index > len(events):
    return 0

Why it's wrong:

  • Checking remaining_events == 0 at the start prevents us from exploring the "skip" option, which might lead to a better solution later.
  • Using > instead of >= would cause an index out of bounds error.

Correct Solution:

# Check index bounds first
if index >= len(events):
    return 0

# Check remaining_events only when considering taking an event
if remaining_events > 0:
    # Try taking the current event
Discover Your Strengths and Weaknesses: Take Our 3-Minute Quiz to Tailor Your Study Plan:

Which of the tree traversal order can be used to obtain elements in a binary search tree in sorted order?


Recommended Readings

Want a Structured Path to Master System Design Too? Don’t Miss This!

Load More