1786. Number of Restricted Paths From First to Last Node
Problem Description
You have an undirected weighted connected graph with n
nodes labeled from 1
to n
. The graph is defined by an array edges
where each edges[i] = [ui, vi, weighti]
represents an edge between nodes ui
and vi
with weight weighti
.
A path from node start
to node end
is a sequence of nodes [z0, z1, z2, ..., zk]
where:
z0 = start
andzk = end
- There exists an edge between consecutive nodes
zi
andzi+1
for all0 <= i <= k-1
The distance of a path is the sum of all edge weights along that path.
Let distanceToLastNode(x)
represent the shortest distance from node x
to node n
.
A restricted path is a path that satisfies an additional constraint: for every step in the path, you must move to a node that is strictly closer to node n
. Formally, distanceToLastNode(zi) > distanceToLastNode(zi+1)
for all 0 <= i <= k-1
.
Your task is to count the number of restricted paths from node 1
to node n
. Since this number can be very large, return the result modulo 10^9 + 7
.
In essence, you need to find all possible paths from node 1
to node n
where each step moves to a node with a smaller shortest distance to node n
than the current node.
Intuition
The key insight is that we need to find paths where each step moves to a node that is strictly closer to the destination (node n
). This suggests we should first know the shortest distance from every node to node n
.
Think of it like walking downhill - if we know the "elevation" (shortest distance to node n
) of every node, then a restricted path is like always stepping down to lower elevations until we reach the bottom (node n
).
This naturally breaks down into two main tasks:
-
Find shortest distances: We need to calculate
distanceToLastNode(x)
for every nodex
. Since we want distances from all nodes to noden
, we can run Dijkstra's algorithm starting from noden
itself. This gives us the shortest distance from noden
to every other node, which is exactly what we need. -
Count valid paths: Once we know the distances, we need to count paths from node
1
to noden
where each step goes to a node with a smaller distance value. This is a classic dynamic programming problem - the number of restricted paths from any nodei
equals the sum of restricted paths from all its neighborsj
wheredist[i] > dist[j]
.
The beauty of this approach is that the restriction distanceToLastNode(zi) > distanceToLastNode(zi+1)
ensures we never revisit a node (no cycles), making the counting problem tractable with memoization. We can use DFS with memoization starting from node 1
, and at each node, we only explore neighbors that have smaller distances to node n
.
The modulo operation 10^9 + 7
is applied during the counting phase to prevent integer overflow since the number of paths can be exponentially large.
Learn more about Graph, Topological Sort, Dynamic Programming, Shortest Path and Heap (Priority Queue) patterns.
Solution Approach
The implementation consists of two main phases: computing shortest distances using Dijkstra's algorithm and counting restricted paths using DFS with memoization.
Phase 1: Computing Shortest Distances with Dijkstra's Algorithm
First, we build an adjacency list representation of the graph:
g = defaultdict(list)
for u, v, w in edges:
g[u].append((v, w))
g[v].append((u, w))
Then we run Dijkstra's algorithm starting from node n
:
q = [(0, n)] # Priority queue: (distance, node) dist = [inf] * (n + 1) # Initialize all distances to infinity dist[n] = 0 # Distance from n to itself is 0 while q: _, u = heappop(q) for v, w in g[u]: if dist[v] > dist[u] + w: dist[v] = dist[u] + w heappush(q, (dist[v], v))
The priority queue ensures we always process the node with the smallest distance first. After this phase, dist[i]
contains the shortest distance from node i
to node n
.
Phase 2: Counting Restricted Paths with DFS and Memoization
We use a recursive DFS function with @cache
decorator for memoization:
@cache
def dfs(i):
if i == n: # Base case: reached destination
return 1
ans = 0
for j, _ in g[i]: # Check all neighbors
if dist[i] > dist[j]: # Only move to nodes closer to n
ans = (ans + dfs(j)) % mod
return ans
The function dfs(i)
returns the number of restricted paths from node i
to node n
. The key points are:
- Base case: If we're at node
n
, there's exactly one path (the empty path) - Recursive case: For each neighbor
j
of nodei
, ifdist[i] > dist[j]
(meaningj
is closer ton
), we add the number of paths fromj
to our answer - Memoization: The
@cache
decorator ensures we don't recompute the same subproblem multiple times
Finally, we return dfs(1)
to get the number of restricted paths from node 1
to node n
.
The time complexity is O(E log V)
for Dijkstra's algorithm plus O(V + E)
for the DFS traversal, where V
is the number of nodes and E
is the number of edges. The space complexity is O(V + E)
for storing the graph and distance array.
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 to illustrate the solution approach.
Consider a graph with 5 nodes and the following edges:
- Edge (1, 2) with weight 1
- Edge (1, 3) with weight 1
- Edge (2, 4) with weight 1
- Edge (3, 4) with weight 2
- Edge (2, 5) with weight 2
- Edge (4, 5) with weight 1
We want to count restricted paths from node 1 to node 5.
Step 1: Run Dijkstra's algorithm from node 5
Starting from node 5, we compute shortest distances:
- Process node 5: dist[5] = 0
- Process neighbors of 5:
- Node 2: dist[2] = 0 + 2 = 2
- Node 4: dist[4] = 0 + 1 = 1
- Process node 4 (smallest unprocessed distance):
- Node 2: dist[2] stays 2 (not improved)
- Node 3: dist[3] = 1 + 2 = 3
- Process node 2:
- Node 1: dist[1] = 2 + 1 = 3
- Node 4: already processed
- Process node 3:
- Node 1: dist[1] stays 3 (not improved)
Final distances to node 5:
- dist[1] = 3
- dist[2] = 2
- dist[3] = 3
- dist[4] = 1
- dist[5] = 0
Step 2: Count restricted paths using DFS with memoization
Starting from node 1 (dist = 3):
- Check neighbor 2: dist[2] = 2 < 3 ✓ (valid move)
- From node 2 (dist = 2):
- Check neighbor 4: dist[4] = 1 < 2 ✓
- From node 4 (dist = 1):
- Check neighbor 5: dist[5] = 0 < 1 ✓
- Node 5 is destination, return 1
- Total from node 4: 1 path
- Check neighbor 5: dist[5] = 0 < 1 ✓
- From node 4 (dist = 1):
- Check neighbor 5: dist[5] = 0 < 2 ✓
- Node 5 is destination, return 1
- Total from node 2: 1 + 1 = 2 paths
- Check neighbor 4: dist[4] = 1 < 2 ✓
- From node 2 (dist = 2):
- Check neighbor 3: dist[3] = 3, not < 3 ✗ (invalid move)
- Total from node 1: 2 paths
The two restricted paths are:
- 1 → 2 → 4 → 5 (distances: 3 → 2 → 1 → 0)
- 1 → 2 → 5 (distances: 3 → 2 → 0)
Both paths satisfy the restriction that each step moves to a node strictly closer to node 5. The answer is 2.
Solution Implementation
1from collections import defaultdict
2from heapq import heappop, heappush
3from functools import cache
4from typing import List
5from math import inf
6
7
8class Solution:
9 def countRestrictedPaths(self, n: int, edges: List[List[int]]) -> int:
10 # Build adjacency list representation of the graph
11 graph = defaultdict(list)
12 for u, v, weight in edges:
13 graph[u].append((v, weight))
14 graph[v].append((u, weight))
15
16 # Calculate shortest distances from all nodes to node n using Dijkstra's algorithm
17 distance = [inf] * (n + 1)
18 distance[n] = 0
19 priority_queue = [(0, n)] # (distance, node)
20
21 while priority_queue:
22 current_dist, current_node = heappop(priority_queue)
23
24 # Skip if we've already found a shorter path to this node
25 if current_dist > distance[current_node]:
26 continue
27
28 # Relax edges and update distances to neighbors
29 for neighbor, edge_weight in graph[current_node]:
30 new_distance = distance[current_node] + edge_weight
31 if distance[neighbor] > new_distance:
32 distance[neighbor] = new_distance
33 heappush(priority_queue, (new_distance, neighbor))
34
35 # Define modulo for the result
36 MOD = 10**9 + 7
37
38 # Use DFS with memoization to count restricted paths from node i to node n
39 @cache
40 def count_paths(node: int) -> int:
41 # Base case: reached destination node n
42 if node == n:
43 return 1
44
45 # Count all valid paths from current node
46 path_count = 0
47 for next_node, _ in graph[node]:
48 # Only follow edges where distance to n strictly decreases
49 if distance[node] > distance[next_node]:
50 path_count = (path_count + count_paths(next_node)) % MOD
51
52 return path_count
53
54 # Return the number of restricted paths from node 1 to node n
55 return count_paths(1)
56
1class Solution {
2 // Constants
3 private static final int INF = Integer.MAX_VALUE;
4 private static final int MOD = (int) 1e9 + 7;
5
6 // Graph representation: adjacency list where each node stores [neighbor, weight] pairs
7 private List<int[]>[] graph;
8
9 // Shortest distances from each node to node n
10 private int[] distanceToN;
11
12 // Memoization array for dynamic programming
13 private int[] memo;
14
15 // Total number of nodes
16 private int totalNodes;
17
18 public int countRestrictedPaths(int n, int[][] edges) {
19 this.totalNodes = n;
20
21 // Initialize graph with n+1 size (1-indexed nodes)
22 graph = new List[n + 1];
23 for (int i = 0; i < graph.length; i++) {
24 graph[i] = new ArrayList<>();
25 }
26
27 // Build undirected graph from edges
28 for (int[] edge : edges) {
29 int nodeU = edge[0];
30 int nodeV = edge[1];
31 int weight = edge[2];
32 graph[nodeU].add(new int[] {nodeV, weight});
33 graph[nodeV].add(new int[] {nodeU, weight});
34 }
35
36 // Run Dijkstra's algorithm from node n to find shortest distances
37 PriorityQueue<int[]> minHeap = new PriorityQueue<>((a, b) -> a[0] - b[0]);
38 minHeap.offer(new int[] {0, n}); // [distance, node]
39
40 // Initialize distance and memoization arrays
41 distanceToN = new int[n + 1];
42 memo = new int[n + 1];
43 Arrays.fill(distanceToN, INF);
44 Arrays.fill(memo, -1);
45 distanceToN[n] = 0;
46
47 // Dijkstra's algorithm to find shortest paths from node n to all other nodes
48 while (!minHeap.isEmpty()) {
49 int[] current = minHeap.poll();
50 int currentNode = current[1];
51
52 // Explore all neighbors
53 for (int[] neighbor : graph[currentNode]) {
54 int nextNode = neighbor[0];
55 int edgeWeight = neighbor[1];
56
57 // Relaxation step: update distance if a shorter path is found
58 if (distanceToN[nextNode] > distanceToN[currentNode] + edgeWeight) {
59 distanceToN[nextNode] = distanceToN[currentNode] + edgeWeight;
60 minHeap.offer(new int[] {distanceToN[nextNode], nextNode});
61 }
62 }
63 }
64
65 // Count restricted paths from node 1 to node n using DFS with memoization
66 return dfs(1);
67 }
68
69 /**
70 * DFS with memoization to count restricted paths from node i to node n.
71 * A restricted path means we can only move to nodes with strictly smaller distance to n.
72 *
73 * @param currentNode The current node we're at
74 * @return Number of restricted paths from currentNode to node n
75 */
76 private int dfs(int currentNode) {
77 // Return memoized result if already computed
78 if (memo[currentNode] != -1) {
79 return memo[currentNode];
80 }
81
82 // Base case: reached destination node n
83 if (currentNode == totalNodes) {
84 return 1;
85 }
86
87 int pathCount = 0;
88
89 // Explore all neighbors with strictly smaller distance to n
90 for (int[] neighbor : graph[currentNode]) {
91 int nextNode = neighbor[0];
92
93 // Only move to nodes that are strictly closer to n (restricted path condition)
94 if (distanceToN[currentNode] > distanceToN[nextNode]) {
95 pathCount = (pathCount + dfs(nextNode)) % MOD;
96 }
97 }
98
99 // Memoize and return the result
100 memo[currentNode] = pathCount;
101 return pathCount;
102 }
103}
104
1using pii = pair<int, int>;
2
3class Solution {
4public:
5 static const int INF = INT_MAX;
6 static const int MOD = 1e9 + 7;
7
8 vector<vector<pii>> adjacencyList; // Graph representation: adjacencyList[u] = {(v, weight), ...}
9 vector<int> distanceToN; // Shortest distance from each node to node n
10 vector<int> pathCount; // Memoization array for counting paths
11 int nodeCount; // Total number of nodes
12
13 int countRestrictedPaths(int n, vector<vector<int>>& edges) {
14 this->nodeCount = n;
15
16 // Initialize data structures
17 adjacencyList.resize(n + 1);
18 distanceToN.assign(n + 1, INF);
19 pathCount.assign(n + 1, -1);
20
21 // Build the undirected graph
22 for (auto& edge : edges) {
23 int u = edge[0];
24 int v = edge[1];
25 int weight = edge[2];
26 adjacencyList[u].emplace_back(v, weight);
27 adjacencyList[v].emplace_back(u, weight);
28 }
29
30 // Run Dijkstra's algorithm from node n to find shortest distances
31 dijkstra(n);
32
33 // Count restricted paths from node 1 to node n using DFS with memoization
34 return dfs(1);
35 }
36
37private:
38 void dijkstra(int source) {
39 // Initialize distance for source node
40 distanceToN[source] = 0;
41
42 // Min-heap: (distance, node)
43 priority_queue<pii, vector<pii>, greater<pii>> minHeap;
44 minHeap.emplace(0, source);
45
46 while (!minHeap.empty()) {
47 auto [currentDist, currentNode] = minHeap.top();
48 minHeap.pop();
49
50 // Skip if we've already found a shorter path to this node
51 if (currentDist > distanceToN[currentNode]) {
52 continue;
53 }
54
55 // Relax edges from current node
56 for (auto [neighbor, edgeWeight] : adjacencyList[currentNode]) {
57 int newDist = distanceToN[currentNode] + edgeWeight;
58
59 if (newDist < distanceToN[neighbor]) {
60 distanceToN[neighbor] = newDist;
61 minHeap.emplace(newDist, neighbor);
62 }
63 }
64 }
65 }
66
67 int dfs(int currentNode) {
68 // Return memoized result if already computed
69 if (pathCount[currentNode] != -1) {
70 return pathCount[currentNode];
71 }
72
73 // Base case: reached destination node n
74 if (currentNode == nodeCount) {
75 return 1;
76 }
77
78 // Count restricted paths from current node
79 int totalPaths = 0;
80
81 for (auto [neighbor, edgeWeight] : adjacencyList[currentNode]) {
82 // Only follow edge if it satisfies restricted path condition
83 // (distance from current to n) > (distance from neighbor to n)
84 if (distanceToN[currentNode] > distanceToN[neighbor]) {
85 totalPaths = (totalPaths + dfs(neighbor)) % MOD;
86 }
87 }
88
89 // Memoize and return result
90 pathCount[currentNode] = totalPaths;
91 return totalPaths;
92 }
93};
94
1type Pair = [number, number];
2
3const INF = Number.MAX_SAFE_INTEGER;
4const MOD = 1e9 + 7;
5
6let adjacencyList: Pair[][]; // Graph representation: adjacencyList[u] = [[v, weight], ...]
7let distanceToN: number[]; // Shortest distance from each node to node n
8let pathCount: number[]; // Memoization array for counting paths
9let nodeCount: number; // Total number of nodes
10
11function countRestrictedPaths(n: number, edges: number[][]): number {
12 nodeCount = n;
13
14 // Initialize data structures
15 adjacencyList = Array(n + 1).fill(null).map(() => []);
16 distanceToN = Array(n + 1).fill(INF);
17 pathCount = Array(n + 1).fill(-1);
18
19 // Build the undirected graph
20 for (const edge of edges) {
21 const u = edge[0];
22 const v = edge[1];
23 const weight = edge[2];
24 adjacencyList[u].push([v, weight]);
25 adjacencyList[v].push([u, weight]);
26 }
27
28 // Run Dijkstra's algorithm from node n to find shortest distances
29 dijkstra(n);
30
31 // Count restricted paths from node 1 to node n using DFS with memoization
32 return dfs(1);
33}
34
35function dijkstra(source: number): void {
36 // Initialize distance for source node
37 distanceToN[source] = 0;
38
39 // Min-heap: [distance, node]
40 // Using array with custom comparator to simulate priority queue
41 const minHeap: Pair[] = [[0, source]];
42
43 while (minHeap.length > 0) {
44 // Sort to maintain min-heap property (smallest distance first)
45 minHeap.sort((a, b) => a[0] - b[0]);
46 const [currentDist, currentNode] = minHeap.shift()!;
47
48 // Skip if we've already found a shorter path to this node
49 if (currentDist > distanceToN[currentNode]) {
50 continue;
51 }
52
53 // Relax edges from current node
54 for (const [neighbor, edgeWeight] of adjacencyList[currentNode]) {
55 const newDist = distanceToN[currentNode] + edgeWeight;
56
57 if (newDist < distanceToN[neighbor]) {
58 distanceToN[neighbor] = newDist;
59 minHeap.push([newDist, neighbor]);
60 }
61 }
62 }
63}
64
65function dfs(currentNode: number): number {
66 // Return memoized result if already computed
67 if (pathCount[currentNode] !== -1) {
68 return pathCount[currentNode];
69 }
70
71 // Base case: reached destination node n
72 if (currentNode === nodeCount) {
73 return 1;
74 }
75
76 // Count restricted paths from current node
77 let totalPaths = 0;
78
79 for (const [neighbor, edgeWeight] of adjacencyList[currentNode]) {
80 // Only follow edge if it satisfies restricted path condition
81 // (distance from current to n) > (distance from neighbor to n)
82 if (distanceToN[currentNode] > distanceToN[neighbor]) {
83 totalPaths = (totalPaths + dfs(neighbor)) % MOD;
84 }
85 }
86
87 // Memoize and return result
88 pathCount[currentNode] = totalPaths;
89 return totalPaths;
90}
91
Time and Space Complexity
Time Complexity: O(E log V + V + E)
Breaking down the time complexity:
- Building the adjacency list from edges takes
O(E)
whereE
is the number of edges - Dijkstra's algorithm to compute shortest distances from node
n
:- Each node can be pushed to the heap at most once:
O(V)
operations - Each edge is relaxed at most once:
O(E)
operations - Each heap operation (push/pop) takes
O(log V)
- Total for Dijkstra's:
O((V + E) log V)
which simplifies toO(E log V)
since typicallyE ≥ V-1
in connected graphs
- Each node can be pushed to the heap at most once:
- DFS with memoization:
- Each node is visited at most once due to
@cache
- For each node, we iterate through its neighbors
- Total:
O(V + E)
for traversing all nodes and edges once
- Each node is visited at most once due to
Overall: O(E log V + V + E)
= O(E log V)
as the Dijkstra's term dominates
Space Complexity: O(V + E)
Breaking down the space complexity:
- Adjacency list
g
stores all edges:O(E)
- Distance array
dist
of sizen+1
:O(V)
- Priority queue can contain at most
V
nodes:O(V)
- Cache for DFS memoization stores at most
V
results:O(V)
- Recursion stack depth in worst case:
O(V)
Overall: O(V + E)
since we need to store the graph structure and various auxiliary arrays
Learn more about how to find time and space complexity quickly.
Common Pitfalls
1. Using Wrong Starting Node for Dijkstra's Algorithm
A critical pitfall is running Dijkstra's algorithm from node 1 instead of node n. Since we need distanceToLastNode(x)
(the shortest distance from node x to node n), we must start Dijkstra from node n, not from node 1.
Incorrect approach:
# Wrong: Starting from node 1 distance[1] = 0 priority_queue = [(0, 1)]
Correct approach:
# Correct: Starting from node n distance[n] = 0 priority_queue = [(0, n)]
2. Not Handling the Priority Queue Optimization
When using Dijkstra's algorithm with a min-heap, the same node might be added to the queue multiple times with different distances. Failing to skip outdated entries can lead to incorrect results or inefficiency.
Missing optimization:
while priority_queue: current_dist, current_node = heappop(priority_queue) # Missing check - might process same node multiple times for neighbor, edge_weight in graph[current_node]: # ...
With optimization:
while priority_queue: current_dist, current_node = heappop(priority_queue) # Skip if we've already found a shorter path if current_dist > distance[current_node]: continue for neighbor, edge_weight in graph[current_node]: # ...
3. Forgetting to Apply Modulo During Accumulation
Since the number of paths can be very large, forgetting to apply modulo during accumulation can cause integer overflow in languages with fixed-size integers, or lead to incorrect results.
Incorrect:
def count_paths(node):
if node == n:
return 1
path_count = 0
for next_node, _ in graph[node]:
if distance[node] > distance[next_node]:
path_count += count_paths(next_node) # Missing modulo
return path_count % MOD # Only applying modulo at the end
Correct:
def count_paths(node):
if node == n:
return 1
path_count = 0
for next_node, _ in graph[node]:
if distance[node] > distance[next_node]:
path_count = (path_count + count_paths(next_node)) % MOD # Apply modulo during accumulation
return path_count
4. Confusion About the Direction of Movement
The problem states we need to move to nodes that are "strictly closer to node n". This means distance[current] > distance[next]
, not the other way around.
Wrong condition:
# Incorrect: This would move away from node n if distance[node] < distance[next_node]: path_count = (path_count + count_paths(next_node)) % MOD
Correct condition:
# Correct: Move to nodes closer to n if distance[node] > distance[next_node]: path_count = (path_count + count_paths(next_node)) % MOD
5. Not Using Memoization
Without memoization, the DFS solution would have exponential time complexity because the same subproblems would be computed multiple times. This would cause Time Limit Exceeded for larger inputs.
Without memoization (inefficient):
def count_paths(node):
if node == n:
return 1
# ... rest of the logic
With memoization (efficient):
@cache # or use a manual memo dictionary
def count_paths(node):
if node == n:
return 1
# ... rest of the logic
Which technique can we use to find the middle of a linked list?
Recommended Readings
https assets algo monster cover_photos graph svg Graph Fundamentals Tree with 0 cycle At this point you should be pretty familiar with trees A tree is a special kind of graph a connected acyclic cycle less graph A graph may contain cycle s and nodes could be disconnected A tree
Topological Sort Topological Order Prereq Breadth First Search Review problems graph_bfs Directed Graph Before we get started on topological order let's talk about directed graphs first A graph is directed when its edges have directions these edges are also called arcs Suppose that v w are vertices in a directed
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
Want a Structured Path to Master System Design Too? Don’t Miss This!