LeetCode 2093 - Minimum Cost to Reach City With Discounts
This problem describes a weighted, undirected graph where each city is a node and each highway is an edge with an associated toll cost. The goal is to travel from city 0 to city n - 1 while minimizing the total travel cost. The special twist is the presence of discounts.
Difficulty: 🟡 Medium
Topics: Graph Theory, Heap (Priority Queue), Shortest Path
Solution
Problem Understanding
This problem describes a weighted, undirected graph where each city is a node and each highway is an edge with an associated toll cost. The goal is to travel from city 0 to city n - 1 while minimizing the total travel cost.
The special twist is the presence of discounts. A discount can be applied to a highway traversal, reducing the toll from toll to toll // 2, using integer division. Each discount can only be used once, and at most one discount may be applied per highway traversal.
The input consists of:
n, the total number of citieshighways, where each entry[u, v, toll]represents a bidirectional edge between citiesuandvdiscounts, the number of discounts available
The output should be the minimum total cost required to travel from city 0 to city n - 1. If no path exists, the result should be -1.
The constraints are important because they guide the algorithm choice:
n <= 1000highways.length <= 1000discounts <= 500
A graph with 1000 nodes and 1000 edges is relatively sparse. However, the discount dimension significantly increases the state space. A naive shortest path algorithm that ignores discounts will not work because the best path depends not only on the current city, but also on how many discounts have already been used.
Several edge cases are important:
- The destination may be unreachable.
- There may be zero discounts available.
- A path with more edges may become cheaper if discounts are used strategically.
- Using a discount early is not always optimal.
- Toll values can be zero.
- Multiple visits to the same city may be necessary if different discount counts produce different costs.
Because the state depends on both position and remaining discount usage, the problem naturally becomes a shortest path problem on an expanded state graph.
Approaches
Brute Force Approach
A brute force solution would attempt to enumerate every possible path from city 0 to city n - 1, while also trying every possible combination of discount usage across the traversed highways.
For each path, we could compute the minimum achievable cost by deciding which edges receive discounts. Since each edge traversal has two possibilities, discounted or not discounted, the number of combinations grows exponentially.
This approach is correct because it eventually examines all valid possibilities. However, it is far too slow. The graph may contain cycles, and the number of paths can become enormous. Even if we restrict ourselves to simple paths, the number of possible routes is exponential in the number of nodes.
The brute force strategy is computationally infeasible for the given constraints.
Key Insight
The key observation is that the optimal state is determined by two pieces of information:
- The current city
- The number of discounts already used
Reaching the same city with a different number of discounts remaining can lead to completely different future costs. Therefore, we cannot use a traditional single-distance Dijkstra algorithm.
Instead, we treat (city, discounts_used) as a unique state.
This transforms the problem into a shortest path problem over an expanded graph:
- Original graph nodes become layered states
- Each layer corresponds to how many discounts have been used
From a state (u, d):
- We may travel normally to
(v, d)with costtoll - If
d < discounts, we may use a discount and travel to(v, d + 1)with costtoll // 2
All edge weights remain non-negative, which means Dijkstra's algorithm is still applicable.
Approach Comparison
| Approach | Time Complexity | Space Complexity | Notes |
|---|---|---|---|
| Brute Force | Exponential | Exponential | Explores all paths and discount combinations |
| Optimal | O((n × discounts + highways) log(n × discounts)) | O(n × discounts) | Dijkstra on expanded state graph |
Algorithm Walkthrough
- Build an adjacency list for the graph.
Since the highways are bidirectional, each edge [u, v, toll] is added in both directions. The adjacency list allows efficient neighbor traversal.
2. Create a distance table.
Define:
dist[city][used_discounts]
This stores the minimum cost required to reach city after using exactly used_discounts discounts.
Initialize all values to infinity. 3. Initialize the starting state.
We begin at city 0 with cost 0 and no discounts used.
dist[0][0] = 0
- Use a priority queue for Dijkstra's algorithm.
Each heap entry contains:
(current_cost, city, discounts_used)
The heap always processes the currently cheapest reachable state first. 5. Process states from the heap.
Pop the minimum-cost state.
If this state is already worse than the recorded distance, skip it. This is the standard Dijkstra optimization. 6. Explore neighbors without using a discount.
For every neighboring city:
new_cost = current_cost + toll
If this improves dist[next_city][discounts_used], update it and push the new state into the heap.
7. Explore neighbors using a discount.
If discounts are still available:
discounted_cost = current_cost + toll // 2
This transitions to:
(next_city, discounts_used + 1)
Again, update the distance table if the new path is better. 8. Continue until the heap is empty.
Since Dijkstra guarantees shortest-first processing, the first optimal route to each state is eventually finalized. 9. Compute the answer.
The destination city may be reached using different numbers of discounts.
Therefore, the final answer is:
min(dist[n - 1])
If all values remain infinity, return -1.
Why it works
Dijkstra's algorithm works because all edge weights are non-negative, even after discount application.
The expanded state graph correctly models every valid situation. A state uniquely captures both location and discount usage history. Every legal movement corresponds to an edge in this expanded graph.
Since Dijkstra always processes the currently cheapest unprocessed state, once a state is finalized, its shortest distance is guaranteed to be optimal.
Therefore, the algorithm correctly computes the minimum achievable travel cost.
Python Solution
from typing import List
import heapq
class Solution:
def minimumCost(self, n: int, highways: List[List[int]], discounts: int) -> int:
graph = [[] for _ in range(n)]
for u, v, toll in highways:
graph[u].append((v, toll))
graph[v].append((u, toll))
INF = float("inf")
dist = [[INF] * (discounts + 1) for _ in range(n)]
dist[0][0] = 0
min_heap = [(0, 0, 0)]
# (cost, city, discounts_used)
while min_heap:
current_cost, city, used = heapq.heappop(min_heap)
if current_cost > dist[city][used]:
continue
for neighbor, toll in graph[city]:
# Travel without discount
normal_cost = current_cost + toll
if normal_cost < dist[neighbor][used]:
dist[neighbor][used] = normal_cost
heapq.heappush(
min_heap,
(normal_cost, neighbor, used)
)
# Travel with discount
if used < discounts:
discounted_cost = current_cost + toll // 2
if discounted_cost < dist[neighbor][used + 1]:
dist[neighbor][used + 1] = discounted_cost
heapq.heappush(
min_heap,
(discounted_cost, neighbor, used + 1)
)
answer = min(dist[n - 1])
return answer if answer != INF else -1
The implementation begins by constructing an adjacency list representation of the graph. Since the highways are bidirectional, every edge is inserted twice.
The dist table is the most important structure in the solution. Instead of storing only one best distance per city, it stores one distance for every possible number of discounts used. This is necessary because reaching the same city with different remaining discounts creates different future opportunities.
The priority queue drives Dijkstra's algorithm. Each state includes the accumulated cost, the current city, and how many discounts have already been consumed.
For every neighboring edge, the code explores two transitions:
- Travel normally
- Travel using a discount
If either produces a better state than previously known, the distance table is updated and the state is pushed into the heap.
At the end, the algorithm checks all possible discount usages for the destination city and returns the minimum.
Go Solution
package main
import (
"container/heap"
"math"
)
type State struct {
cost int
city int
used int
}
type PriorityQueue []State
func (pq PriorityQueue) Len() int {
return len(pq)
}
func (pq PriorityQueue) Less(i, j int) bool {
return pq[i].cost < pq[j].cost
}
func (pq PriorityQueue) Swap(i, j int) {
pq[i], pq[j] = pq[j], pq[i]
}
func (pq *PriorityQueue) Push(x interface{}) {
*pq = append(*pq, x.(State))
}
func (pq *PriorityQueue) Pop() interface{} {
old := *pq
n := len(old)
item := old[n-1]
*pq = old[:n-1]
return item
}
func minimumCost(n int, highways [][]int, discounts int) int {
graph := make([][][2]int, n)
for _, edge := range highways {
u := edge[0]
v := edge[1]
toll := edge[2]
graph[u] = append(graph[u], [2]int{v, toll})
graph[v] = append(graph[v], [2]int{u, toll})
}
inf := math.MaxInt
dist := make([][]int, n)
for i := 0; i < n; i++ {
dist[i] = make([]int, discounts+1)
for j := 0; j <= discounts; j++ {
dist[i][j] = inf
}
}
dist[0][0] = 0
pq := &PriorityQueue{}
heap.Init(pq)
heap.Push(pq, State{
cost: 0,
city: 0,
used: 0,
})
for pq.Len() > 0 {
current := heap.Pop(pq).(State)
currentCost := current.cost
city := current.city
used := current.used
if currentCost > dist[city][used] {
continue
}
for _, edge := range graph[city] {
neighbor := edge[0]
toll := edge[1]
// Without discount
normalCost := currentCost + toll
if normalCost < dist[neighbor][used] {
dist[neighbor][used] = normalCost
heap.Push(pq, State{
cost: normalCost,
city: neighbor,
used: used,
})
}
// With discount
if used < discounts {
discountedCost := currentCost + toll/2
if discountedCost < dist[neighbor][used+1] {
dist[neighbor][used+1] = discountedCost
heap.Push(pq, State{
cost: discountedCost,
city: neighbor,
used: used + 1,
})
}
}
}
}
answer := inf
for d := 0; d <= discounts; d++ {
if dist[n-1][d] < answer {
answer = dist[n-1][d]
}
}
if answer == inf {
return -1
}
return answer
}
The Go implementation follows the same algorithmic structure as the Python version, but requires a manual priority queue implementation using the container/heap package.
The adjacency list stores pairs as fixed-size arrays [2]int, where the first element is the neighbor and the second is the toll.
Go does not provide infinity values for integers directly, so math.MaxInt is used as the initial unreachable distance.
Because Go's heap interface works with interface{}, explicit type assertions are required during push and pop operations.
Worked Examples
Example 1
n = 5
highways = [[0,1,4],[2,1,3],[1,4,11],[3,2,3],[3,4,2]]
discounts = 1
Initial State
| Heap | Meaning |
|---|---|
(0, 0, 0) |
cost = 0, city = 0, discounts used = 0 |
Step 1
Pop (0, 0, 0).
Neighbors:
| Move | Cost | New State |
|---|---|---|
| 0 → 1 normally | 4 | (4, 1, 0) |
| 0 → 1 discounted | 2 | (2, 1, 1) |
Heap becomes:
| Heap Contents |
|---|
(2,1,1) |
(4,1,0) |
Step 2
Pop (2,1,1).
No discounts remain.
Neighbors:
| Move | Cost | State |
|---|---|---|
| 1 → 2 | 5 | (5,2,1) |
| 1 → 4 | 13 | (13,4,1) |
Step 3
Pop (4,1,0).
Neighbors:
| Move | Cost | State |
|---|---|---|
| 1 → 4 normally | 15 | (15,4,0) |
| 1 → 4 discounted | 9 | (9,4,1) |
Now the destination is reachable with cost 9.
No future state can improve this result, so the final answer is:
9
Example 2
n = 4
highways = [[1,3,17],[1,2,7],[3,2,5],[0,1,6],[3,0,20]]
discounts = 20
The algorithm explores many states, but the optimal route becomes:
| Edge | Discount Applied | Cost |
|---|---|---|
| 0 → 1 | Yes | 3 |
| 1 → 2 | Yes | 3 |
| 2 → 3 | Yes | 2 |
Total:
3 + 3 + 2 = 8
Example 3
n = 4
highways = [[0,1,3],[2,3,2]]
discounts = 0
The graph is disconnected.
The algorithm never reaches city 3, so all entries in:
dist[3][*]
remain infinity.
Therefore:
return -1
Complexity Analysis
| Measure | Complexity | Explanation |
|---|---|---|
| Time | O((n × discounts + highways × discounts) log(n × discounts)) | Each state may enter the heap and process edges |
| Space | O(n × discounts) | Distance table and heap storage |
The graph is expanded into multiple layers based on discount usage. Each city can appear in up to discounts + 1 states.
For every state, we may traverse all adjacent edges. Since Dijkstra's algorithm uses a priority queue, every insertion and removal costs logarithmic time relative to the number of states.
The distance table dominates the memory usage.
Test Cases
from typing import List
class Solution:
def minimumCost(self, n: int, highways: List[List[int]], discounts: int) -> int:
import heapq
graph = [[] for _ in range(n)]
for u, v, toll in highways:
graph[u].append((v, toll))
graph[v].append((u, toll))
INF = float("inf")
dist = [[INF] * (discounts + 1) for _ in range(n)]
dist[0][0] = 0
heap = [(0, 0, 0)]
while heap:
cost, city, used = heapq.heappop(heap)
if cost > dist[city][used]:
continue
for nxt, toll in graph[city]:
# No discount
if cost + toll < dist[nxt][used]:
dist[nxt][used] = cost + toll
heapq.heappush(heap, (cost + toll, nxt, used))
# Use discount
if used < discounts:
discounted = cost + toll // 2
if discounted < dist[nxt][used + 1]:
dist[nxt][used + 1] = discounted
heapq.heappush(
heap,
(discounted, nxt, used + 1)
)
ans = min(dist[n - 1])
return ans if ans != INF else -1
sol = Solution()
assert sol.minimumCost(
5,
[[0,1,4],[2,1,3],[1,4,11],[3,2,3],[3,4,2]],
1
) == 9 # Provided example 1
assert sol.minimumCost(
4,
[[1,3,17],[1,2,7],[3,2,5],[0,1,6],[3,0,20]],
20
) == 8 # Provided example 2
assert sol.minimumCost(
4,
[[0,1,3],[2,3,2]],
0
) == -1 # Disconnected graph
assert sol.minimumCost(
2,
[[0,1,10]],
0
) == 10 # Single edge without discount
assert sol.minimumCost(
2,
[[0,1,10]],
1
) == 5 # Single edge with discount
assert sol.minimumCost(
3,
[[0,1,1],[1,2,1],[0,2,100]],
1
) == 1 # Discount applied to expensive direct edge
assert sol.minimumCost(
3,
[[0,1,5],[1,2,5]],
10
) == 4 # More discounts than needed
assert sol.minimumCost(
3,
[[0,1,0],[1,2,0]],
1
) == 0 # Zero toll highways
assert sol.minimumCost(
1,
[],
0
) == 0 # Start already equals destination
assert sol.minimumCost(
4,
[[0,1,100],[1,3,1],[0,2,1],[2,3,100]],
1
) == 51 # Optimal discount placement matters
Test Summary
| Test | Why |
|---|---|
| Example 1 | Standard mixed graph case |
| Example 2 | Many discounts available |
| Example 3 | Destination unreachable |
| Single edge without discount | Basic shortest path |
| Single edge with discount | Basic discount usage |
| Expensive shortcut | Strategic discount placement |
| Excess discounts | Handles large discount counts |
| Zero toll edges | Verifies zero-weight behavior |
| Single node graph | Start equals destination |
| Competing discount choices | Ensures optimal state tracking |
Edge Cases
One important edge case is when the graph is disconnected. A naive implementation might assume that every city is reachable, but this is not guaranteed. In such situations, the destination state's distances remain infinity. The implementation correctly detects this and returns -1.
Another important edge case occurs when there are more discounts available than useful edges in the optimal path. The algorithm does not force all discounts to be used. Instead, it tracks all possible discount counts independently and selects the minimum among them at the destination.
A subtle edge case involves revisiting the same city multiple times with different discount counts. A traditional shortest path implementation that stores only one distance per city would fail here. For example, reaching a city cheaply after consuming all discounts may actually be worse than reaching it slightly more expensively while preserving discounts for later. The expanded state representation handles this correctly.
A final edge case involves tolls equal to zero. Since integer division of zero is still zero, discount application changes nothing. The implementation naturally handles this because Dijkstra's algorithm supports non-negative edge weights, including zero.