Facebook Pixel

983. Minimum Cost For Tickets

Problem Description

You're planning train travel for a year and have a list of specific days when you'll need to travel. These travel days are given as an integer array days, where each day is a number from 1 to 365.

There are three types of train passes available for purchase:

  • A 1-day pass costs costs[0] dollars and covers travel for 1 consecutive day
  • A 7-day pass costs costs[1] dollars and covers travel for 7 consecutive days
  • A 30-day pass costs costs[2] dollars and covers travel for 30 consecutive days

When you buy a pass on a specific day, it covers that day and the specified number of consecutive days following it. For example, if you buy a 7-day pass on day 2, you can travel on days 2, 3, 4, 5, 6, 7, and 8.

Your goal is to find the minimum total cost needed to cover all the travel days in your list. You need to strategically choose which passes to buy and when to buy them so that every day in your days array is covered by at least one pass, while minimizing the total amount spent.

The solution uses dynamic programming with memoization. The function dfs(i) calculates the minimum cost to cover all travel days starting from index i in the sorted days array. For each position, it considers buying each of the three pass types and uses binary search to find the next uncovered travel day after the current pass expires. The algorithm recursively calculates costs for all possibilities and returns the minimum.

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

Intuition

The key insight is that we need to make a decision at each travel day: which type of pass should we buy to minimize the total cost? This naturally leads to a dynamic programming approach where we explore all possible choices and select the optimal one.

Think of it this way: when we're standing at a particular travel day, we have three options - buy a 1-day, 7-day, or 30-day pass. Each choice will cover some future travel days, and then we'll need to solve the same problem again for the remaining uncovered days. This recursive structure suggests we can break down the problem into smaller subproblems.

For each travel day at index i, we ask: "What's the minimum cost to cover all travel days from index i to the end?" To answer this, we:

  1. Try buying a 1-day pass (costs costs[0]) which covers only the current day
  2. Try buying a 7-day pass (costs costs[1]) which might cover multiple upcoming travel days
  3. Try buying a 30-day pass (costs costs[2]) which might cover even more travel days

After buying any pass, we need to find the next uncovered travel day. Since our travel days are sorted, we can use binary search to efficiently find the first day that isn't covered by our current pass. For example, if we're on day 5 and buy a 7-day pass, we need to find the first travel day that's >= day 12 (5 + 7).

The recursive formula becomes: dfs(i) = min(cost_of_pass + dfs(next_uncovered_day)) for each pass type. We use memoization to avoid recalculating the same subproblems multiple times, as different combinations of passes might lead us to the same remaining travel days.

The base case is when we've covered all travel days (i >= n), at which point the cost is 0 since no more passes are needed.

Learn more about Dynamic Programming patterns.

Solution Approach

The solution implements a top-down dynamic programming approach with memoization and binary search optimization.

Implementation Details:

  1. Main Function Structure: We define a recursive function dfs(i) that calculates the minimum cost to cover all travel days starting from index i in the days array. The answer to our problem is dfs(0).

  2. Memoization with @cache: The @cache decorator is used to automatically memoize the results of dfs(i). This prevents redundant calculations when the same subproblem is encountered multiple times through different paths.

  3. Base Case:

    if i >= n:
        return 0

    When i reaches or exceeds the total number of travel days n, we've covered all required days, so the additional cost is 0.

  4. Recursive Case: For each position i, we try all three pass types:

    ans = inf
    for c, v in zip(costs, valid):
        j = bisect_left(days, days[i] + v)
        ans = min(ans, c + dfs(j))
    • We initialize ans to infinity to track the minimum cost
    • valid = [1, 7, 30] represents the duration of each pass type
    • We iterate through each pass type using zip(costs, valid)
  5. Binary Search Optimization:

    j = bisect_left(days, days[i] + v)

    For each pass type with duration v, we use bisect_left to find the index j of the first travel day that would NOT be covered by this pass. Specifically:

    • If we're at days[i] and buy a pass valid for v days
    • The pass covers days from days[i] to days[i] + v - 1
    • We search for the first day >= days[i] + v in the sorted days array
    • This gives us the starting index for the next subproblem
  6. Cost Calculation: For each pass type, we calculate:

    • Current pass cost: c
    • Cost for remaining days: dfs(j)
    • Total cost: c + dfs(j)

    We keep track of the minimum among all three options.

Time Complexity: O(n × log n) where n is the length of the days array. Each of the n states is computed once due to memoization, and each state performs 3 binary searches, each taking O(log n) time.

Space Complexity: O(n) for the memoization cache and recursion stack.

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 small example to illustrate the solution approach.

Example Input:

  • days = [1, 4, 6, 7, 8, 20]
  • costs = [2, 7, 15] (1-day: 2,7day:2, 7-day: 7, 30-day: $15)

Step-by-step execution of dfs(i):

Initial call: dfs(0) (starting at day 1)

  • Current day: days[0] = 1
  • Option 1: Buy 1-day pass ($2)
    • Covers day 1 only
    • Next uncovered day: bisect_left([1,4,6,7,8,20], 1+1=2) → index 1 (day 4)
    • Cost: $2 + dfs(1)
  • Option 2: Buy 7-day pass ($7)
    • Covers days 1-7
    • Next uncovered day: bisect_left([1,4,6,7,8,20], 1+7=8) → index 4 (day 8)
    • Cost: $7 + dfs(4)
  • Option 3: Buy 30-day pass ($15)
    • Covers days 1-30
    • Next uncovered day: bisect_left([1,4,6,7,8,20], 1+30=31) → index 6 (beyond array)
    • Cost: 15+dfs(6)=15 + `dfs(6)` = 15 + 0 = $15

Exploring Option 1: dfs(1) (starting at day 4)

  • Current day: days[1] = 4
  • Option 1: Buy 1-day pass ($2)
    • Next uncovered: index 2 (day 6)
    • Cost: $2 + dfs(2)
  • Option 2: Buy 7-day pass ($7)
    • Covers days 4-10
    • Next uncovered: index 5 (day 20)
    • Cost: $7 + dfs(5)
  • Option 3: Buy 30-day pass ($15)
    • Covers all remaining days
    • Cost: 15+0=15 + 0 = 15

Continuing dfs(2) (starting at day 6)

  • Current day: days[2] = 6
  • Option 1: Buy 1-day pass ($2)
    • Next uncovered: index 3 (day 7)
    • Cost: $2 + dfs(3)
  • Option 2: Buy 7-day pass ($7)
    • Covers days 6-12
    • Next uncovered: index 5 (day 20)
    • Cost: $7 + dfs(5)

And dfs(3) (starting at day 7)

  • Current day: days[3] = 7
  • Option 1: Buy 1-day pass ($2)
    • Next uncovered: index 4 (day 8)
    • Cost: $2 + dfs(4)

Then dfs(4) (starting at day 8)

  • Current day: days[4] = 8
  • Option 1: Buy 1-day pass ($2)
    • Next uncovered: index 5 (day 20)
    • Cost: $2 + dfs(5)

Finally dfs(5) (starting at day 20)

  • Current day: days[5] = 20
  • Option 1: Buy 1-day pass ($2)
    • Next uncovered: index 6 (end of array)
    • Cost: 2+0=2 + 0 = 2
  • Option 2: Buy 7-day pass ($7)
    • Cost: 7+0=7 + 0 = 7
  • Option 3: Buy 30-day pass ($15)
    • Cost: 15+0=15 + 0 = 15
  • Minimum: $2

Working backwards with memoized results:

  • dfs(5) = 2
  • dfs(4) = 2 + 2 = 4
  • dfs(3) = 2 + 4 = 6
  • dfs(2) = min(2 + 6, 7 + 2) = min(8, 9) = 8
  • dfs(1) = min(2 + 8, 7 + 2, 15) = min(10, 9, 15) = 9
  • dfs(0) = min(2 + 9, 7 + 4, 15) = min(11, 11, 15) = 11

Final answer: $11

The optimal strategy is to buy a 7-day pass on day 1 (covering days 1,4,6,7) and a 1-day pass on day 8, and another 1-day pass on day 20, for a total of 7+7 + 2 + 2=2 = 11.

Solution Implementation

1class Solution:
2    def mincostTickets(self, days: List[int], costs: List[int]) -> int:
3        from functools import cache
4        from bisect import bisect_left
5        from math import inf
6        from typing import List
7      
8        @cache
9        def calculate_min_cost(day_index: int) -> int:
10            """
11            Calculate minimum cost starting from day_index using dynamic programming.
12          
13            Args:
14                day_index: Current index in the days array
15              
16            Returns:
17                Minimum cost to cover all travel days from day_index onwards
18            """
19            # Base case: if we've covered all days, no more cost needed
20            if day_index >= total_days:
21                return 0
22          
23            min_cost = inf
24          
25            # Try each ticket type (1-day, 7-day, 30-day)
26            for ticket_cost, ticket_duration in zip(costs, ticket_durations):
27                # Find the next day index after current ticket expires
28                # Current ticket covers from days[day_index] to days[day_index] + ticket_duration - 1
29                next_uncovered_day = days[day_index] + ticket_duration
30                next_day_index = bisect_left(days, next_uncovered_day)
31              
32                # Calculate total cost: current ticket + minimum cost for remaining days
33                total_cost = ticket_cost + calculate_min_cost(next_day_index)
34                min_cost = min(min_cost, total_cost)
35          
36            return min_cost
37      
38        # Initialize variables
39        total_days = len(days)
40        ticket_durations = [1, 7, 30]  # Duration coverage for each ticket type
41      
42        # Start calculation from the first day
43        return calculate_min_cost(0)
44
1class Solution {
2    // Pass durations in days for 1-day, 7-day, and 30-day passes
3    private final int[] PASS_DURATIONS = {1, 7, 30};
4  
5    // Input arrays for travel days and pass costs
6    private int[] travelDays;
7    private int[] passCosts;
8  
9    // Memoization array to store minimum cost from index i to end
10    private Integer[] memo;
11  
12    // Total number of travel days
13    private int totalDays;
14
15    public int mincostTickets(int[] days, int[] costs) {
16        // Initialize instance variables
17        totalDays = days.length;
18        memo = new Integer[totalDays];
19        this.travelDays = days;
20        this.passCosts = costs;
21      
22        // Start DFS from day 0
23        return calculateMinCost(0);
24    }
25
26    private int calculateMinCost(int dayIndex) {
27        // Base case: if we've covered all travel days, no more cost needed
28        if (dayIndex >= totalDays) {
29            return 0;
30        }
31      
32        // Return memoized result if already calculated
33        if (memo[dayIndex] != null) {
34            return memo[dayIndex];
35        }
36      
37        // Initialize minimum cost for current position as maximum value
38        memo[dayIndex] = Integer.MAX_VALUE;
39      
40        // Try each type of pass (1-day, 7-day, 30-day)
41        for (int passType = 0; passType < 3; passType++) {
42            // Calculate the day until which current pass is valid
43            int validUntilDay = travelDays[dayIndex] + PASS_DURATIONS[passType];
44          
45            // Find the next travel day index after the pass expires
46            // Using binary search to find the first day not covered by current pass
47            int nextDayIndex = Arrays.binarySearch(travelDays, validUntilDay);
48          
49            // If exact match not found, convert to insertion point (first day after pass expires)
50            nextDayIndex = nextDayIndex < 0 ? -nextDayIndex - 1 : nextDayIndex;
51          
52            // Calculate minimum cost: current pass cost + minimum cost from next uncovered day
53            memo[dayIndex] = Math.min(
54                memo[dayIndex], 
55                calculateMinCost(nextDayIndex) + passCosts[passType]
56            );
57        }
58      
59        return memo[dayIndex];
60    }
61}
62
1class Solution {
2public:
3    int mincostTickets(vector<int>& days, vector<int>& costs) {
4        // Define the validity periods for each ticket type (1-day, 7-day, 30-day)
5        int ticketDurations[3] = {1, 7, 30};
6        int numDays = days.size();
7      
8        // Dynamic programming memoization array
9        // dp[i] represents the minimum cost to cover all travel days starting from index i
10        int dp[numDays];
11        memset(dp, 0, sizeof(dp));
12      
13        // Recursive function with memoization to calculate minimum cost
14        function<int(int)> calculateMinCost = [&](int dayIndex) {
15            // Base case: if we've covered all days, no more cost needed
16            if (dayIndex >= numDays) {
17                return 0;
18            }
19          
20            // Return memoized result if already calculated
21            if (dp[dayIndex]) {
22                return dp[dayIndex];
23            }
24          
25            // Initialize minimum cost for current position as maximum value
26            dp[dayIndex] = INT_MAX;
27          
28            // Try each ticket type and find the minimum cost
29            for (int ticketType = 0; ticketType < 3; ++ticketType) {
30                // Find the next day index that won't be covered by current ticket
31                // days[dayIndex] + ticketDurations[ticketType] gives the first day not covered
32                int nextDayIndex = lower_bound(days.begin(), days.end(), 
33                                              days[dayIndex] + ticketDurations[ticketType]) - days.begin();
34              
35                // Update minimum cost: current ticket cost + cost for remaining days
36                dp[dayIndex] = min(dp[dayIndex], 
37                                  calculateMinCost(nextDayIndex) + costs[ticketType]);
38            }
39          
40            return dp[dayIndex];
41        };
42      
43        // Start calculation from the first day (index 0)
44        return calculateMinCost(0);
45    }
46};
47
1/**
2 * Calculates the minimum cost to buy tickets for all travel days
3 * @param days - Array of days when travel is needed (sorted in ascending order)
4 * @param costs - Array of ticket costs [1-day pass, 7-day pass, 30-day pass]
5 * @returns Minimum cost to cover all travel days
6 */
7function mincostTickets(days: number[], costs: number[]): number {
8    const totalDays: number = days.length;
9    // Memoization array to store minimum cost from each day index
10    const memo: number[] = Array(totalDays).fill(0);
11    // Ticket validity periods in days
12    const ticketDurations: number[] = [1, 7, 30];
13  
14    /**
15     * Binary search to find the first day index that is >= target day
16     * @param targetDay - The day to search for
17     * @returns Index of the first day >= targetDay, or totalDays if none found
18     */
19    const binarySearch = (targetDay: number): number => {
20        let left: number = 0;
21        let right: number = totalDays;
22      
23        while (left < right) {
24            const mid: number = (left + right) >> 1;
25            if (days[mid] >= targetDay) {
26                right = mid;
27            } else {
28                left = mid + 1;
29            }
30        }
31      
32        return left;
33    };
34  
35    /**
36     * Dynamic programming with memoization to find minimum cost from day index
37     * @param dayIndex - Current day index to process
38     * @returns Minimum cost to cover all days from dayIndex onwards
39     */
40    const calculateMinCost = (dayIndex: number): number => {
41        // Base case: no more days to cover
42        if (dayIndex >= totalDays) {
43            return 0;
44        }
45      
46        // Return memoized result if already calculated
47        if (memo[dayIndex]) {
48            return memo[dayIndex];
49        }
50      
51        // Initialize minimum cost as infinity
52        memo[dayIndex] = Infinity;
53      
54        // Try each ticket type and find minimum cost
55        for (let ticketType = 0; ticketType < 3; ++ticketType) {
56            // Find next uncovered day after current ticket expires
57            const nextDayIndex: number = binarySearch(days[dayIndex] + ticketDurations[ticketType]);
58            // Update minimum cost for current position
59            memo[dayIndex] = Math.min(
60                memo[dayIndex], 
61                calculateMinCost(nextDayIndex) + costs[ticketType]
62            );
63        }
64      
65        return memo[dayIndex];
66    };
67  
68    // Start calculation from first day
69    return calculateMinCost(0);
70}
71

Time and Space Complexity

Time Complexity: O(n × log n)

The time complexity is determined by the recursive DFS function with memoization. There are n possible states (one for each index i from 0 to n-1), and each state is computed at most once due to the @cache decorator. For each state, we iterate through 3 ticket options (1-day, 7-day, and 30-day passes), and for each option, we perform a binary search using bisect_left to find the next index, which takes O(log n) time. Therefore, the overall time complexity is O(n × 3 × log n) = O(n × log n).

Space Complexity: O(n)

The space complexity consists of two main components:

  1. The recursion call stack, which can go up to depth n in the worst case when we process each day sequentially
  2. The memoization cache from the @cache decorator, which stores at most n computed results (one for each possible starting index)

Both contribute O(n) space, resulting in an overall space complexity of O(n).

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

Common Pitfalls

1. Off-by-One Error in Pass Coverage Calculation

The Pitfall: A common mistake is incorrectly calculating when a pass expires. Many developers mistakenly use:

j = bisect_left(days, days[i] + v - 1)  # WRONG!

This searches for the last day covered by the pass, not the first uncovered day.

Why It's Wrong:

  • A 7-day pass bought on day 2 covers days 2-8 (inclusive)
  • The first uncovered day is day 9, which is 2 + 7 = 9
  • Using days[i] + v - 1 would give us day 8, which is still covered

The Correct Approach:

j = bisect_left(days, days[i] + v)  # CORRECT

This correctly finds the index of the first day that needs a new pass.

2. Using bisect_right Instead of bisect_left

The Pitfall:

j = bisect_right(days, days[i] + v - 1)  # Subtle bug!

Why It's Wrong: If days[i] + v - 1 exists in the array, bisect_right would skip over it and potentially miss calculating costs for some days. Consider:

  • days = [1, 4, 6], currently at days[0] = 1 with a 3-day pass
  • Pass covers days 1, 2, 3
  • bisect_right(days, 3) returns index 1 (correctly)
  • But if days = [1, 3, 4, 6] and we use bisect_right(days, 3), it returns index 2, skipping day 4

The Fix: Always use bisect_left with days[i] + v to consistently find the first uncovered day.

3. Forgetting to Handle Edge Cases in Binary Search

The Pitfall: Not considering that binary search might return an index equal to len(days):

j = bisect_left(days, days[i] + v)
next_cost = costs[pass_type] + dfs(j)  # j might be >= len(days)

Why It Matters: When the pass covers all remaining travel days, bisect_left returns len(days). The base case if i >= n: return 0 handles this correctly, but developers sometimes add unnecessary bounds checking that can introduce bugs.

The Solution: Trust the base case to handle i >= n. The algorithm naturally handles this scenario without additional checks.

4. Incorrect Pass Duration Values

The Pitfall:

valid = [0, 6, 29]  # WRONG! Zero-indexed thinking

Why It's Wrong: The pass durations represent the number of days covered, not array indices:

  • 1-day pass covers 1 day (not 0)
  • 7-day pass covers 7 days (not 6)
  • 30-day pass covers 30 days (not 29)

The Correct Values:

valid = [1, 7, 30]  # Actual number of days covered

5. Not Memoizing the Recursive Function

The Pitfall: Forgetting the @cache decorator leads to exponential time complexity:

def dfs(i):  # Missing @cache decorator
    if i >= n:
        return 0
    # ... rest of the code

Why It's Critical: Without memoization, the same subproblems are solved multiple times. For example, different combinations of passes might lead to the same remaining days to cover, causing redundant calculations.

The Solution: Always use @cache or implement manual memoization with a dictionary to store computed results.

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

Given a sorted array of integers and an integer called target, find the element that equals to the target and return its index. Select the correct code that fills the ___ in the given code snippet.

1def binary_search(arr, target):
2    left, right = 0, len(arr) - 1
3    while left ___ right:
4        mid = (left + right) // 2
5        if arr[mid] == target:
6            return mid
7        if arr[mid] < target:
8            ___ = mid + 1
9        else:
10            ___ = mid - 1
11    return -1
12
1public static int binarySearch(int[] arr, int target) {
2    int left = 0;
3    int right = arr.length - 1;
4
5    while (left ___ right) {
6        int mid = left + (right - left) / 2;
7        if (arr[mid] == target) return mid;
8        if (arr[mid] < target) {
9            ___ = mid + 1;
10        } else {
11            ___ = mid - 1;
12        }
13    }
14    return -1;
15}
16
1function binarySearch(arr, target) {
2    let left = 0;
3    let right = arr.length - 1;
4
5    while (left ___ right) {
6        let mid = left + Math.trunc((right - left) / 2);
7        if (arr[mid] == target) return mid;
8        if (arr[mid] < target) {
9            ___ = mid + 1;
10        } else {
11            ___ = mid - 1;
12        }
13    }
14    return -1;
15}
16

Recommended Readings

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

Load More