2008. Maximum Earnings From Taxi
Problem Description
You are a taxi driver on a road with n
points labeled from 1
to n
. You start at point 1
and want to drive to point n
in one direction only (you cannot go backwards).
Along the way, there are passengers waiting for rides. Each passenger is represented by [start_i, end_i, tip_i]
, meaning:
- They want a ride from point
start_i
to pointend_i
- They will give you a
tip_i
dollar tip
When you pick up passenger i
, you earn end_i - start_i + tip_i
dollars (the distance traveled plus the tip).
Important constraints:
- You can only carry one passenger at a time
- You cannot change direction (always moving from point 1 toward point n)
- You can drop off one passenger and immediately pick up another at the same point
Given n
(the number of points) and a list of rides
(passenger requests), find the maximum amount of money you can earn by choosing which passengers to pick up optimally.
Example scenario: If you have passengers A and B where A wants to go from point 2 to 5 (with tip 3) and B wants to go from point 3 to 7 (with tip 5), you cannot take both since their rides overlap. You must choose which combination of non-overlapping rides gives you the most money.
The solution uses dynamic programming with memoization. After sorting rides by start point, for each ride position, it decides whether to take the current ride or skip it. If taken, it uses binary search to find the next possible ride that doesn't overlap (starts at or after the current ride's end point) and calculates the maximum earnings recursively.
Intuition
Think of this problem as making a series of decisions: for each potential ride, should we take it or skip it?
The key insight is that once we sort the rides by their starting points, we're essentially moving through them in the order we'd encounter them on our journey. This transforms the problem into a classic dynamic programming scenario where we need to make optimal choices at each step.
For any given ride at position i
, we face two choices:
- Skip this ride - Continue to the next available ride and see what maximum earnings we can get from there
- Take this ride - Earn money from this ride, then find the next compatible ride we can take after dropping off this passenger
The challenge with option 2 is finding which rides are "compatible" after taking the current one. Since we can't carry multiple passengers simultaneously, we need to find rides that start at or after our current ride's end point. Because we've sorted the rides by start time, we can use binary search to efficiently find the first compatible ride.
This naturally leads to a recursive structure: the maximum earnings from position i
equals the maximum of:
- Maximum earnings from position
i + 1
(if we skip) - Current ride's earnings + maximum earnings from the next compatible position (if we take it)
The formula becomes: dfs(i) = max(dfs(i + 1), earnings_from_ride_i + dfs(j))
where j
is the index of the first ride that starts at or after the current ride's end point.
By using memoization, we avoid recalculating the same subproblems multiple times, making our solution efficient. The sorting step ensures we process rides in a logical order, and binary search helps us quickly find compatible subsequent rides, bringing the overall approach together elegantly.
Learn more about Binary Search, Dynamic Programming and Sorting patterns.
Solution Approach
The solution implements a dynamic programming approach with memoization and binary search optimization.
Step 1: Sort the rides
rides.sort()
We sort all rides by their starting points in ascending order. This ensures we process rides in the order we'd encounter them while traveling from point 1 to n.
Step 2: Define the recursive function with memoization
@cache
def dfs(i: int) -> int:
The dfs(i)
function calculates the maximum earnings possible starting from the i
-th ride. The @cache
decorator provides memoization to avoid redundant calculations.
Step 3: Base case
if i >= len(rides):
return 0
When we've considered all rides (index out of bounds), there's no more money to be earned, so return 0.
Step 4: Extract current ride information
st, ed, tip = rides[i]
Get the start point, end point, and tip for the current ride.
Step 5: Find the next compatible ride using binary search
j = bisect_left(rides, ed, lo=i + 1, key=lambda x: x[0])
Use bisect_left
to find the first ride whose start point is greater than or equal to the current ride's end point (ed
). The search starts from i + 1
(rides after the current one) and uses key=lambda x: x[0]
to compare based on start points. This gives us index j
of the first compatible ride if we take the current one.
Step 6: Make the optimal decision
return max(dfs(i + 1), dfs(j) + ed - st + tip)
Compare two options:
dfs(i + 1)
: Skip the current ride and consider rides starting from the next positiondfs(j) + ed - st + tip
: Take the current ride (earninged - st + tip
dollars) and then optimally handle rides starting from positionj
The function returns the maximum of these two choices.
Step 7: Start the recursion
return dfs(0)
Begin the process from the first ride (index 0) to get the maximum possible earnings.
The time complexity is O(n log n)
for sorting plus O(m^2 log m)
for the memoized recursion with binary search, where m
is the number of rides. The space complexity is O(m)
for the memoization cache.
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 small example with n = 7
and the following rides:
- Ride A:
[1, 4, 2]
- From point 1 to 4, tip 5) - Ride B:
[2, 5, 3]
- From point 2 to 5, tip 6) - Ride C:
[5, 7, 1]
- From point 5 to 7, tip 3)
Step 1: Sort rides by start point
After sorting: [[1,4,2], [2,5,3], [5,7,1]]
Let's index them as positions 0, 1, and 2.
Step 2: Begin recursion from position 0
We call dfs(0)
to find the maximum earnings starting from the first ride.
Step 3: At position 0 (Ride A: [1,4,2]) We have two choices:
- Skip it: Call
dfs(1)
to see what we can earn from position 1 onward - Take it: Earn $5, then find the next compatible ride
- Using binary search: Find first ride starting at or after point 4
- This gives us position 2 (Ride C starts at point 5)
- So we calculate: $5 +
dfs(2)
Step 4: Evaluate dfs(1) - Position 1 (Ride B: [2,5,3]) Two choices:
- Skip it: Call
dfs(2)
- Take it: Earn $6, then find next compatible ride
- Binary search finds position 2 (Ride C starts at point 5)
- Calculate: $6 +
dfs(2)
Step 5: Evaluate dfs(2) - Position 2 (Ride C: [5,7,1]) Two choices:
- Skip it: Call
dfs(3)
which returns 0 (no more rides) - Take it: Earn $3, then call
dfs(3)
which returns 0 - Result:
max(0, 3) = 3
Step 6: Backtrack and compute
dfs(2) = 3
dfs(1) = max(dfs(2), 6 + dfs(2)) = max(3, 6 + 3) = 9
dfs(0) = max(dfs(1), 5 + dfs(2)) = max(9, 5 + 3) = 9
Final Answer: $9
The optimal strategy is to skip Ride A and take both Ride B and Ride C, earning 3 = 5), we couldn't take Ride B (they overlap), and could only add Ride C for a total of $8.
Solution Implementation
1from typing import List
2from functools import cache
3from bisect import bisect_left
4
5class Solution:
6 def maxTaxiEarnings(self, n: int, rides: List[List[int]]) -> int:
7 """
8 Calculate maximum earnings from taxi rides.
9
10 Args:
11 n: The destination point (1 to n points on the road)
12 rides: List of rides where each ride is [start, end, tip]
13
14 Returns:
15 Maximum possible earnings from completing non-overlapping rides
16 """
17
18 @cache
19 def dfs(ride_index: int) -> int:
20 """
21 Recursively find maximum earnings starting from ride_index.
22
23 Args:
24 ride_index: Current index in the sorted rides list
25
26 Returns:
27 Maximum earnings achievable from ride_index onwards
28 """
29 # Base case: no more rides to consider
30 if ride_index >= len(rides):
31 return 0
32
33 # Extract current ride details
34 start_point, end_point, tip_amount = rides[ride_index]
35
36 # Find the next compatible ride (first ride that starts at or after current ride ends)
37 # Using binary search for efficiency
38 next_compatible_ride = bisect_left(
39 rides,
40 end_point,
41 lo=ride_index + 1,
42 key=lambda ride: ride[0] # Compare based on start point
43 )
44
45 # Two choices for current ride:
46 # 1. Skip current ride and move to next
47 skip_current = dfs(ride_index + 1)
48
49 # 2. Take current ride and add compatible rides
50 # Earnings = distance traveled + tip
51 take_current = dfs(next_compatible_ride) + (end_point - start_point) + tip_amount
52
53 # Return maximum of both choices
54 return max(skip_current, take_current)
55
56 # Sort rides by start point for binary search to work
57 rides.sort()
58
59 # Start recursion from first ride
60 return dfs(0)
61
1class Solution {
2 private int totalRides;
3 private int[][] rides;
4 private Long[] memo;
5
6 /**
7 * Calculates maximum earnings from taxi rides
8 * @param n - maximum distance/time point
9 * @param rides - array of rides where each ride is [start, end, tip]
10 * @return maximum possible earnings
11 */
12 public long maxTaxiEarnings(int n, int[][] rides) {
13 // Sort rides by start time for processing in order
14 Arrays.sort(rides, (a, b) -> a[0] - b[0]);
15
16 // Initialize instance variables
17 totalRides = rides.length;
18 this.rides = rides;
19 memo = new Long[totalRides];
20
21 // Start dynamic programming from first ride
22 return dfs(0);
23 }
24
25 /**
26 * Dynamic programming function to find maximum earnings starting from ride index i
27 * @param i - current ride index to consider
28 * @return maximum earnings possible from ride i onwards
29 */
30 private long dfs(int i) {
31 // Base case: no more rides available
32 if (i >= totalRides) {
33 return 0;
34 }
35
36 // Return memoized result if already computed
37 if (memo[i] != null) {
38 return memo[i];
39 }
40
41 // Extract current ride information
42 int[] currentRide = rides[i];
43 int startPoint = currentRide[0];
44 int endPoint = currentRide[1];
45 int tipAmount = currentRide[2];
46
47 // Find next compatible ride (first ride that starts at or after current ride ends)
48 int nextCompatibleRideIndex = binarySearchNextRide(endPoint, i + 1);
49
50 // Choose maximum between:
51 // 1. Skip current ride and process next ride
52 // 2. Take current ride (earn fare + tip) and process next compatible ride
53 long skipCurrentRide = dfs(i + 1);
54 long takeCurrentRide = dfs(nextCompatibleRideIndex) + (endPoint - startPoint) + tipAmount;
55
56 // Memoize and return the maximum earnings
57 return memo[i] = Math.max(skipCurrentRide, takeCurrentRide);
58 }
59
60 /**
61 * Binary search to find the first ride that starts at or after given time
62 * @param targetStartTime - minimum start time for the next ride
63 * @param leftIndex - starting index for search
64 * @return index of first ride with start time >= targetStartTime, or totalRides if none exists
65 */
66 private int binarySearchNextRide(int targetStartTime, int leftIndex) {
67 int rightIndex = totalRides;
68
69 while (leftIndex < rightIndex) {
70 int midIndex = (leftIndex + rightIndex) >> 1;
71
72 if (rides[midIndex][0] >= targetStartTime) {
73 // Mid ride starts at or after target, search left half including mid
74 rightIndex = midIndex;
75 } else {
76 // Mid ride starts before target, search right half excluding mid
77 leftIndex = midIndex + 1;
78 }
79 }
80
81 return leftIndex;
82 }
83}
84
1class Solution {
2public:
3 long long maxTaxiEarnings(int n, vector<vector<int>>& rides) {
4 // Sort rides by start time to enable binary search
5 sort(rides.begin(), rides.end());
6
7 int totalRides = rides.size();
8
9 // Dynamic programming memoization array
10 // dp[i] represents the maximum earnings starting from ride i
11 long long dp[totalRides];
12 memset(dp, -1, sizeof(dp));
13
14 // Recursive function with memoization to calculate maximum earnings
15 function<long long(int)> calculateMaxEarnings = [&](int currentIndex) -> long long {
16 // Base case: no more rides available
17 if (currentIndex >= totalRides) {
18 return 0;
19 }
20
21 // Return memoized result if already computed
22 if (dp[currentIndex] != -1) {
23 return dp[currentIndex];
24 }
25
26 // Extract current ride information
27 auto& currentRide = rides[currentIndex];
28 int startPoint = currentRide[0];
29 int endPoint = currentRide[1];
30 int tipAmount = currentRide[2];
31
32 // Find the next compatible ride (first ride that starts at or after current ride's end)
33 int nextCompatibleIndex = lower_bound(
34 rides.begin() + currentIndex + 1,
35 rides.end(),
36 endPoint,
37 [](auto& ride, int value) { return ride[0] < value; }
38 ) - rides.begin();
39
40 // Calculate earnings for current ride: distance + tip
41 long long earningsWithCurrentRide = (endPoint - startPoint) + tipAmount + calculateMaxEarnings(nextCompatibleIndex);
42
43 // Calculate earnings if we skip current ride
44 long long earningsWithoutCurrentRide = calculateMaxEarnings(currentIndex + 1);
45
46 // Store and return the maximum of both options
47 return dp[currentIndex] = max(earningsWithoutCurrentRide, earningsWithCurrentRide);
48 };
49
50 // Start calculation from the first ride
51 return calculateMaxEarnings(0);
52 }
53};
54
1/**
2 * Calculates the maximum earnings from taxi rides
3 * @param n - The number of points on the road (1 to n)
4 * @param rides - Array of rides where each ride is [start, end, tip]
5 * @returns Maximum earnings possible
6 */
7function maxTaxiEarnings(n: number, rides: number[][]): number {
8 // Sort rides by start point in ascending order
9 rides.sort((a: number[], b: number[]) => a[0] - b[0]);
10
11 const totalRides: number = rides.length;
12 // Memoization array for dynamic programming (-1 indicates uncomputed)
13 const memo: number[] = Array(totalRides).fill(-1);
14
15 /**
16 * Binary search to find the first ride that starts at or after the given point
17 * @param targetStart - The start point to search for
18 * @param leftIndex - Starting index for the search
19 * @returns Index of the first ride starting at or after targetStart
20 */
21 const binarySearchNextRide = (targetStart: number, leftIndex: number): number => {
22 let rightIndex: number = totalRides;
23
24 while (leftIndex < rightIndex) {
25 const midIndex: number = (leftIndex + rightIndex) >> 1;
26
27 if (rides[midIndex][0] >= targetStart) {
28 rightIndex = midIndex;
29 } else {
30 leftIndex = midIndex + 1;
31 }
32 }
33
34 return leftIndex;
35 };
36
37 /**
38 * Dynamic programming function to calculate maximum earnings from index i onwards
39 * @param rideIndex - Current ride index being considered
40 * @returns Maximum earnings from this ride index onwards
41 */
42 const calculateMaxEarnings = (rideIndex: number): number => {
43 // Base case: no more rides available
44 if (rideIndex >= totalRides) {
45 return 0;
46 }
47
48 // Return memoized result if already computed
49 if (memo[rideIndex] === -1) {
50 const [startPoint, endPoint, tipAmount] = rides[rideIndex];
51
52 // Find the next available ride after completing current ride
53 const nextRideIndex: number = binarySearchNextRide(endPoint, rideIndex + 1);
54
55 // Choose maximum between:
56 // 1. Skip current ride and take earnings from next ride
57 // 2. Take current ride (fare + tip) and add earnings from next available ride
58 const skipCurrentRide: number = calculateMaxEarnings(rideIndex + 1);
59 const takeCurrentRide: number = calculateMaxEarnings(nextRideIndex) + endPoint - startPoint + tipAmount;
60
61 memo[rideIndex] = Math.max(skipCurrentRide, takeCurrentRide);
62 }
63
64 return memo[rideIndex];
65 };
66
67 // Start calculating from the first ride
68 return calculateMaxEarnings(0);
69}
70
Time and Space Complexity
Time Complexity: O(m × log m)
where m
is the length of rides
.
The time complexity breaks down as follows:
- Sorting the rides array takes
O(m × log m)
time - The DFS function with memoization visits each ride at most once due to the
@cache
decorator, resulting inO(m)
unique function calls - For each DFS call, we perform a binary search using
bisect_left
to find the next compatible ride, which takesO(log m)
time - Therefore, the total time for all DFS operations is
O(m × log m)
- The overall time complexity is dominated by
O(m × log m)
from both sorting and the DFS traversal with binary search
Space Complexity: O(m)
where m
is the length of rides
.
The space complexity consists of:
- The recursion call stack can go up to
O(m)
depth in the worst case when processing all rides sequentially - The
@cache
decorator stores memoization results for up toO(m)
different states (one for each possible starting index) - The sorted rides array doesn't require additional space as it sorts in-place
- Therefore, the total space complexity is
O(m)
Learn more about how to find time and space complexity quickly.
Common Pitfalls
1. Incorrect Binary Search Key Function
A common mistake is using the wrong comparison in the binary search when finding the next compatible ride.
Incorrect Implementation:
# Wrong: Comparing entire ride tuples instead of just start points next_compatible_ride = bisect_left(rides, end_point, lo=ride_index + 1)
This fails because bisect_left
would try to compare end_point
(an integer) with entire ride tuples [start, end, tip]
, causing a TypeError or incorrect comparisons.
Correct Implementation:
# Correct: Using key function to extract start point for comparison next_compatible_ride = bisect_left( rides, end_point, lo=ride_index + 1, key=lambda ride: ride[0] )
2. Forgetting to Sort the Rides
The binary search relies on rides being sorted by start point. Forgetting this step breaks the algorithm.
Wrong:
def maxTaxiEarnings(self, n: int, rides: List[List[int]]) -> int:
# Missing rides.sort()!
@cache
def dfs(ride_index: int) -> int:
# ... rest of the code
Correct:
def maxTaxiEarnings(self, n: int, rides: List[List[int]]) -> int:
rides.sort() # Essential for binary search to work
@cache
def dfs(ride_index: int) -> int:
# ... rest of the code
3. Using Wrong Search Boundary
Using i
instead of i + 1
as the lower bound in binary search would include the current ride in the search range, potentially finding itself as "compatible."
Wrong:
# This could return the current ride index if end_point > start_point next_compatible_ride = bisect_left(rides, end_point, lo=i, key=lambda x: x[0])
Correct:
# Start searching from the next ride after current next_compatible_ride = bisect_left(rides, end_point, lo=ride_index + 1, key=lambda x: x[0])
4. Incorrect Earnings Calculation
Mixing up the earnings formula or forgetting components leads to wrong results.
Common Mistakes:
# Wrong: Forgetting the tip take_current = dfs(next_compatible_ride) + (end_point - start_point) # Wrong: Adding start point instead of subtracting take_current = dfs(next_compatible_ride) + (end_point + start_point) + tip_amount # Wrong: Using just the tip without distance take_current = dfs(next_compatible_ride) + tip_amount
Correct:
# Earnings = distance + tip = (end - start) + tip take_current = dfs(next_compatible_ride) + (end_point - start_point) + tip_amount
5. Cache Invalidation with Mutable Data
If rides list is modified after sorting (though unlikely in this problem), the cache would become invalid.
Prevention:
# Convert to tuple if there's any risk of modification
rides_tuple = tuple(map(tuple, rides))
# Or clear cache if rides change
dfs.cache_clear()
Consider the classic dynamic programming of longest increasing subsequence:
Find the length of the longest subsequence of a given sequence such that all elements of the subsequence are sorted in increasing order.
For example, the length of LIS for [50, 3, 10, 7, 40, 80]
is 4
and LIS is
[3, 7, 40, 80]
.
What is the recurrence relation?
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!