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.
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
, wherej
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 valuev
- Use
bisect_left(g, s)
to find the indexj
of the rightmost offer that ends before positions
. This gives us the last non-conflicting offer - Calculate
f[i]
as the maximum of:f[i - 1]
: Don't take the current offerf[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 EvaluatorExample 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 takesO(m)
time - The for loop runs
m
iterations, and within each iteration:bisect_left(g, s)
performs binary search which takesO(log m)
time- The
max
operation takesO(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 sizem + 1
, which isO(m)
- List
g
has sizem
, which isO(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 insertstart
- 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.
Which of the following is a good use case for backtracking?
Recommended Readings
Binary Search Speedrun For each of the Speedrun questions you will be given a binary search related problem and a corresponding multiple choice question The multiple choice questions are related to the techniques and template s introduced in the binary search section It's recommended that you have gone through at
What is Dynamic Programming Prerequisite DFS problems dfs_intro Backtracking problems backtracking Memoization problems memoization_intro Pruning problems backtracking_pruning Dynamic programming is an algorithmic optimization technique that breaks down a complicated problem into smaller overlapping sub problems in a recursive manner and uses solutions to the sub problems to construct a solution
Sorting Summary Comparisons We presented quite a few sorting algorithms and it is essential to know the advantages and disadvantages of each one The basic algorithms are easy to visualize and easy to learn for beginner programmers because of their simplicity As such they will suffice if you don't know any advanced
Want a Structured Path to Master System Design Too? Don’t Miss This!