Facebook Pixel

2830. Maximize the Profit as the Salesman

Problem Description

You have n houses arranged in a line, numbered from 0 to n - 1.

You receive multiple purchase offers for these houses. Each offer is represented as [start, end, gold], meaning a buyer wants to purchase all houses from position start to position end (inclusive) and is willing to pay gold amount for them.

Your task is to select which offers to accept to maximize your total earnings. The key constraint is that each house can only be sold once - if two offers overlap (share any common houses), you cannot accept both offers.

For example:

  • If you have offers [0, 2, 5] and [1, 3, 6], you cannot accept both since they both include houses 1 and 2
  • If you have offers [0, 1, 5] and [2, 3, 6], you can accept both since they don't share any houses

The goal is to find the maximum total gold you can earn by strategically choosing which non-overlapping offers to accept. Some houses may remain unsold if that leads to higher total earnings.

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

Intuition

The problem asks us to select non-overlapping intervals (house ranges) to maximize profit. This is a classic interval scheduling problem with weights.

First, let's think about how to handle overlapping offers. If we process offers in a random order, it becomes difficult to determine which previous offers conflict with the current one. However, if we sort the offers by their ending position, we gain a useful property: when considering an offer, all potentially compatible offers that end before this offer starts are already processed.

This leads us to dynamic programming. For each offer, we face a choice: take it or skip it. If we skip it, our profit remains the same as before. If we take it, we need to add its value to the best profit we could achieve from offers that don't conflict with it.

The key insight is that after sorting by end position, finding compatible offers becomes easier. For an offer starting at position s, we need to find all previous offers that end before s. Since offers are sorted by end position, we can use binary search to efficiently find the rightmost offer that ends before position s.

Let's define f[i] as the maximum profit from considering the first i offers. For each offer i:

  • If we don't take it: f[i] = f[i-1]
  • If we take it: f[i] = f[j] + gold_i, where j is the index of the last offer that doesn't conflict

The answer is the maximum of these two choices. By processing offers in sorted order and using binary search to find compatible offers, we can solve this efficiently in O(n log n) time.

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

Solution Approach

Let's implement the solution step by step based on our intuition:

Step 1: Sort the offers

offers.sort(key=lambda x: x[1])

We sort all offers by their ending position in ascending order. This allows us to efficiently find compatible offers later.

Step 2: Initialize dynamic programming array

f = [0] * (len(offers) + 1)

Create an array f where f[i] represents the maximum gold we can earn from the first i offers. We use size len(offers) + 1 to handle the base case where we consider 0 offers.

Step 3: Extract ending positions for binary search

g = [x[1] for x in offers]

Create an array g containing just the ending positions of all offers. Since offers are sorted by ending position, g is also sorted, making it suitable for binary search.

Step 4: Process each offer

for i, (s, _, v) in enumerate(offers, 1):
    j = bisect_left(g, s)
    f[i] = max(f[i - 1], f[j] + v)

For each offer at index i (1-indexed in the DP array):

  • Extract the start position s and gold value v
  • Use bisect_left(g, s) to find the index j of the rightmost offer that ends before position s. This gives us the last non-conflicting offer
  • Calculate f[i] as the maximum of:
    • f[i - 1]: Don't take the current offer
    • f[j] + v: Take the current offer and add its value to the best result from non-conflicting offers

Step 5: Return the result

return f[-1]

The last element of f contains the maximum gold we can earn from all offers.

Time Complexity: O(n log n) for sorting and O(n log n) for n binary searches, giving overall O(n log n)

Space Complexity: O(n) for the DP array and the array of ending positions

Ready to land your dream job?

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

Start Evaluator

Example Walkthrough

Let's walk through a concrete example with 4 houses (numbered 0-3) and the following offers:

  • Offer 1: [0, 2, 5] - Buy houses 0, 1, 2 for 5 gold
  • Offer 2: [1, 3, 6] - Buy houses 1, 2, 3 for 6 gold
  • Offer 3: [0, 1, 3] - Buy houses 0, 1 for 3 gold

Step 1: Sort offers by ending position After sorting by the second element (end position):

  • Offer 3: [0, 1, 3] (ends at 1)
  • Offer 1: [0, 2, 5] (ends at 2)
  • Offer 2: [1, 3, 6] (ends at 3)

Step 2: Initialize DP array f = [0, 0, 0, 0] where f[i] represents max profit from first i offers

Step 3: Extract ending positions g = [1, 2, 3] (ending positions of sorted offers)

Step 4: Process each offer

Processing Offer 3 [0, 1, 3]:

  • Start position s = 0
  • bisect_left(g, 0) returns 0 (no offers end before position 0)
  • f[1] = max(f[0], f[0] + 3) = max(0, 0 + 3) = 3
  • DP array: [0, 3, 0, 0]

Processing Offer 1 [0, 2, 5]:

  • Start position s = 0
  • bisect_left(g, 0) returns 0 (no offers end before position 0)
  • f[2] = max(f[1], f[0] + 5) = max(3, 0 + 5) = 5
  • DP array: [0, 3, 5, 0]

Processing Offer 2 [1, 3, 6]:

  • Start position s = 1
  • bisect_left(g, 1) returns 0 (no offers end strictly before position 1)
  • f[3] = max(f[2], f[0] + 6) = max(5, 0 + 6) = 6
  • DP array: [0, 3, 5, 6]

Step 5: Return result The maximum gold we can earn is f[3] = 6 by accepting Offer 2 [1, 3, 6].

Note: We cannot accept both Offer 1 and Offer 2 because they overlap at houses 1 and 2. The algorithm correctly identifies that taking Offer 2 alone (6 gold) is better than taking Offer 3 alone (3 gold) or Offer 1 alone (5 gold).

Solution Implementation

1class Solution:
2    def maximizeTheProfit(self, n: int, offers: List[List[int]]) -> int:
3        # Sort offers by end position for easier processing
4        offers.sort(key=lambda offer: offer[1])
5      
6        # dp[i] represents maximum profit using offers[0] to offers[i-1]
7        dp = [0] * (len(offers) + 1)
8      
9        # Extract all end positions for binary search
10        end_positions = [offer[1] for offer in offers]
11      
12        # Process each offer
13        for i, (start, end, profit) in enumerate(offers, 1):
14            # Find the rightmost offer that ends before current offer starts
15            # This gives us the last non-overlapping offer
16            last_non_overlapping = bisect_left(end_positions, start)
17          
18            # Two choices:
19            # 1. Skip current offer: dp[i-1]
20            # 2. Take current offer: dp[last_non_overlapping] + profit
21            dp[i] = max(dp[i - 1], dp[last_non_overlapping] + profit)
22      
23        # Return maximum profit achievable
24        return dp[-1]
25
1class Solution {
2    public int maximizeTheProfit(int n, List<List<Integer>> offers) {
3        // Sort offers by their end points in ascending order
4        offers.sort((a, b) -> a.get(1) - b.get(1));
5      
6        // Update n to represent the number of offers
7        int numberOfOffers = offers.size();
8      
9        // dp[i] represents the maximum profit achievable using the first i offers
10        int[] dp = new int[numberOfOffers + 1];
11      
12        // Array to store end points of offers for binary search
13        int[] endPoints = new int[numberOfOffers];
14        for (int i = 0; i < numberOfOffers; i++) {
15            endPoints[i] = offers.get(i).get(1);
16        }
17      
18        // Process each offer
19        for (int i = 1; i <= numberOfOffers; i++) {
20            List<Integer> currentOffer = offers.get(i - 1);
21            int startPoint = currentOffer.get(0);
22            int profit = currentOffer.get(2);
23          
24            // Find the last offer whose end point is less than current offer's start point
25            int lastNonOverlappingIndex = binarySearch(endPoints, startPoint);
26          
27            // Choose between skipping current offer or taking it
28            dp[i] = Math.max(dp[i - 1], dp[lastNonOverlappingIndex] + profit);
29        }
30      
31        return dp[numberOfOffers];
32    }
33  
34    private int binarySearch(int[] nums, int target) {
35        // Binary search to find the rightmost index where nums[index] < target
36        // Returns the count of elements less than target
37        int left = 0;
38        int right = nums.length;
39      
40        while (left < right) {
41            int mid = (left + right) >> 1;
42            if (nums[mid] >= target) {
43                right = mid;
44            } else {
45                left = mid + 1;
46            }
47        }
48      
49        return left;
50    }
51}
52
1class Solution {
2public:
3    int maximizeTheProfit(int n, vector<vector<int>>& offers) {
4        // Sort offers by their end point (second element) in ascending order
5        sort(offers.begin(), offers.end(), [](const vector<int>& a, const vector<int>& b) {
6            return a[1] < b[1];
7        });
8      
9        // Update n to be the number of offers (overwriting the original parameter)
10        n = offers.size();
11      
12        // dp[i] represents the maximum profit considering the first i offers
13        vector<int> dp(n + 1);
14      
15        // Store all end points for binary search
16        vector<int> endPoints;
17        for (const auto& offer : offers) {
18            endPoints.push_back(offer[1]);
19        }
20      
21        // Process each offer
22        for (int i = 1; i <= n; ++i) {
23            // Get the current offer (0-indexed)
24            vector<int> currentOffer = offers[i - 1];
25            int startPoint = currentOffer[0];
26            int profit = currentOffer[2];
27          
28            // Find the rightmost offer whose end point is less than current start point
29            // This gives us the last non-overlapping offer
30            int lastNonOverlappingIndex = lower_bound(endPoints.begin(), endPoints.end(), startPoint) - endPoints.begin();
31          
32            // Choose maximum between:
33            // 1. Skip current offer: dp[i-1]
34            // 2. Take current offer: dp[lastNonOverlappingIndex] + current profit
35            dp[i] = max(dp[i - 1], dp[lastNonOverlappingIndex] + profit);
36        }
37      
38        return dp[n];
39    }
40};
41
1function maximizeTheProfit(n: number, offers: number[][]): number {
2    // Sort offers by ending position (second element)
3    offers.sort((a, b) => a[1] - b[1]);
4  
5    // Update n to be the number of offers
6    n = offers.length;
7  
8    // Dynamic programming array to store maximum profit up to index i
9    const dp: number[] = Array(n + 1).fill(0);
10  
11    // Extract all ending positions for binary search
12    const endPositions = offers.map(offer => offer[1]);
13  
14    /**
15     * Binary search to find the first offer whose end position >= target
16     * @param target - The target position to search for
17     * @returns The index of the first offer with end position >= target
18     */
19    const binarySearch = (target: number): number => {
20        let left = 0;
21        let right = n;
22      
23        while (left < right) {
24            const mid = (left + right) >> 1;
25          
26            if (endPositions[mid] >= target) {
27                right = mid;
28            } else {
29                left = mid + 1;
30            }
31        }
32      
33        return left;
34    };
35  
36    // Process each offer and calculate maximum profit
37    for (let i = 1; i <= n; ++i) {
38        // Current offer details: [start, end, gold]
39        const currentOffer = offers[i - 1];
40        const startPosition = currentOffer[0];
41        const goldAmount = currentOffer[2];
42      
43        // Find the last non-overlapping offer using binary search
44        const lastNonOverlappingIndex = binarySearch(startPosition);
45      
46        // Choose maximum between:
47        // 1. Not taking current offer (dp[i-1])
48        // 2. Taking current offer (dp[lastNonOverlappingIndex] + current gold)
49        dp[i] = Math.max(dp[i - 1], dp[lastNonOverlappingIndex] + goldAmount);
50    }
51  
52    // Return the maximum profit considering all offers
53    return dp[n];
54}
55

Time and Space Complexity

The time complexity is O(m × log m), where m is the number of offers (length of the offers list).

  • Sorting the offers takes O(m × log m) time
  • Creating list g with list comprehension takes O(m) time
  • The for loop runs m iterations, and within each iteration:
    • bisect_left(g, s) performs binary search which takes O(log m) time
    • The max operation takes O(1) time
  • Total for the loop: O(m × log m)
  • Overall: O(m × log m) + O(m) + O(m × log m) = O(m × log m)

The space complexity is O(m), where m is the number of offers.

  • List f has size m + 1, which is O(m)
  • List g has size m, which is O(m)
  • The sorting operation may use O(m) additional space depending on the implementation
  • Overall: O(m) + O(m) = O(m)

Note: The reference answer uses n to denote the number of purchase offers, while in the code, n is actually a different parameter (likely representing the number of houses), and the number of offers is len(offers). The complexity analysis is based on the number of offers, which I've denoted as m for clarity.

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

Common Pitfalls

Pitfall 1: Incorrect Non-Overlapping Check

The Problem: A common mistake is using bisect_left(end_positions, start) to find non-overlapping offers. This finds offers where end < start, but overlapping occurs when offers share ANY house. Two offers [s1, e1] and [s2, e2] overlap if they share any position, meaning they DON'T overlap only when e1 < s2 OR e2 < s1.

Why This Works (but is subtle): The code actually works correctly because:

  • We sort by end position
  • bisect_left(end_positions, start) finds the leftmost position where we could insert start
  • This gives us the index of the last offer with end < start
  • Since offers are sorted by end position, all offers before this index have end < start, making them non-overlapping

The Confusion: Many developers mistakenly think they need end <= start for non-overlap, leading them to use bisect_right instead. This would be incorrect because offers [0, 2] and [3, 5] don't overlap (house 2 and house 3 are different), but using bisect_right would incorrectly exclude valid combinations.

Pitfall 2: Off-by-One Error in DP Indexing

The Problem: The code uses 1-based indexing for the DP array (enumerate(offers, 1)), which can be confusing. Developers might accidentally mix 0-based and 1-based indexing, especially when accessing offers[i] vs dp[i].

Solution: Be consistent with indexing. Here's an alternative 0-based approach:

dp = [0] * len(offers)
for i in range(len(offers)):
    start, end, profit = offers[i]
    j = bisect_left(end_positions, start)
  
    # Take current offer
    take = profit + (dp[j-1] if j > 0 else 0)
  
    # Skip current offer
    skip = dp[i-1] if i > 0 else 0
  
    dp[i] = max(take, skip)

return dp[-1] if offers else 0

Pitfall 3: Edge Case - Empty Offers List

The Problem: If the offers list is empty, dp[-1] will access an empty list, causing an IndexError.

Solution: Add a guard clause:

if not offers:
    return 0

Pitfall 4: Misunderstanding Binary Search Return Value

The Problem: bisect_left returns an insertion point, not necessarily an existing index. If no offer ends before the current start, it returns 0, which correctly maps to dp[0] = 0 (no previous offers taken).

Key Insight: The beauty of this solution is that dp[0] = 0 serves as a sentinel value, representing "no offers taken yet," which naturally handles the case when the current offer doesn't conflict with any previous offers.

Discover Your Strengths and Weaknesses: Take Our 5-Minute Quiz to Tailor Your Study Plan:

Which of the following is a good use case for backtracking?


Recommended Readings

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

Load More