Facebook Pixel

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 and zk = end
  • There exists an edge between consecutive nodes zi and zi+1 for all 0 <= 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.

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 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:

  1. Find shortest distances: We need to calculate distanceToLastNode(x) for every node x. Since we want distances from all nodes to node n, we can run Dijkstra's algorithm starting from node n itself. This gives us the shortest distance from node n to every other node, which is exactly what we need.

  2. Count valid paths: Once we know the distances, we need to count paths from node 1 to node n 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 node i equals the sum of restricted paths from all its neighbors j where dist[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:

  1. Base case: If we're at node n, there's exactly one path (the empty path)
  2. Recursive case: For each neighbor j of node i, if dist[i] > dist[j] (meaning j is closer to n), we add the number of paths from j to our answer
  3. 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 Evaluator

Example 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 < 2 ✓
        • Node 5 is destination, return 1
      • Total from node 2: 1 + 1 = 2 paths
  • Check neighbor 3: dist[3] = 3, not < 3 ✗ (invalid move)
  • Total from node 1: 2 paths

The two restricted paths are:

  1. 1 → 2 → 4 → 5 (distances: 3 → 2 → 1 → 0)
  2. 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) where E 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 to O(E log V) since typically E ≥ V-1 in connected graphs
  • 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

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 size n+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
Discover Your Strengths and Weaknesses: Take Our 5-Minute Quiz to Tailor Your Study Plan:

Which technique can we use to find the middle of a linked list?


Recommended Readings

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

Load More