2876. Count Visited Nodes in a Directed Graph
Problem Description
You have a directed graph with n
nodes labeled from 0
to n - 1
. The graph has exactly n
directed edges, where each node has exactly one outgoing edge. The graph is represented by a 0-indexed array edges
where edges[i]
indicates there is a directed edge from node i
to node edges[i]
.
The problem asks you to simulate a specific process for each starting node:
- Start from a node
x
- Keep following the edges to visit other nodes
- Stop when you reach a node that you've already visited in this current process
Your task is to return an array answer
where answer[i]
represents the total number of different nodes you will visit when performing this process starting from node i
.
For example, if you start from a node and follow edges: 2 → 3 → 4 → 3
, you would visit nodes 2, 3, 4
and then 3
again (which was already visited), so you stop. The count of different nodes visited would be 3 (nodes 2, 3, and 4).
Since each node has exactly one outgoing edge, following the edges will eventually lead to a cycle. The traversal from any starting node will either:
- Enter directly into a cycle (if the starting node is part of a cycle)
- Follow a path that eventually leads into a cycle
The challenge is to efficiently compute the number of distinct nodes visited for all possible starting nodes from 0
to n - 1
.
Intuition
Since each node has exactly one outgoing edge, the graph structure forms what's called a "functional graph" - following edges from any starting point will eventually lead to a cycle. Think of it like a comet: there's a "tail" (path leading to the cycle) and a "head" (the cycle itself).
The key insight is that once we know the answer for one node, we can use that information to help calculate answers for other nodes. When we traverse from a node i
, we're essentially walking along a path until we hit a cycle. There are two scenarios we need to handle:
-
We hit a cycle that hasn't been processed yet: This means we've discovered a new cycle. Nodes within the cycle will visit all nodes in the cycle (they go round and round), while nodes on the path leading to the cycle will visit all nodes from their position to the cycle, plus all nodes in the cycle.
-
We hit a node whose answer we already know: This is great! If we know node
j
visitsans[j]
nodes, and we're currently at a node that'sk
steps away fromj
, then our current node visitsk + ans[j]
nodes.
To implement this efficiently, we use a vis
array to track the order in which we visit nodes during our current traversal. When we revisit a node, we can determine:
- If
vis[j] > 0
: We've found a cycle in our current traversal. The cycle length iscurrent_count - vis[j] + 1
. - If
ans[j] > 0
: We've reached a node that was already processed in a previous traversal.
By processing each unvisited node exactly once and reusing previously computed results, we achieve an efficient O(n)
solution. The trick is carefully tracking whether we're inside or outside a cycle, and properly calculating distances based on our position relative to the cycle.
Learn more about Graph, Memoization and Dynamic Programming patterns.
Solution Approach
We use two arrays to track our progress:
ans[i]
: stores the final answer for nodei
(number of distinct nodes visited starting fromi
)vis[i]
: temporarily stores the visit order for nodei
during the current traversal (0 means unvisited in current traversal)
For each unprocessed node i
(where ans[i] == 0
), we perform a traversal:
-
First Pass - Mark Visit Order:
- Start from node
i
and follow edges, incrementing a countercnt
for each step - Mark
vis[j] = cnt
for each visited nodej
- Continue until we hit a node that's already been visited in this traversal (
vis[j] > 0
) or has a known answer (ans[j] > 0
)
- Start from node
-
Determine Cycle Properties:
- If we stopped because
ans[j] > 0
: We've reached a previously processed nodetotal = cnt + ans[j]
(our path length plus the known answer)cycle = 0
(no new cycle discovered)
- If we stopped because
vis[j] > 0
: We've found a new cyclecycle = cnt - vis[j] + 1
(the cycle length)total = cnt
(total nodes in our complete traversal)
- If we stopped because
-
Second Pass - Fill Answers:
- Traverse the path again from node
i
- For each node on the path:
- If it's in the cycle:
ans[j] = cycle
- If it's outside the cycle:
ans[j] = total
, then decrementtotal
for the next node
- If it's in the cycle:
- The
max(total, cycle)
ensures nodes in the cycle get the cycle length
- Traverse the path again from node
The algorithm cleverly handles two cases:
- Case 1: When we discover a new cycle, all nodes in the cycle get
cycle
as their answer, while nodes on the tail get progressively larger values as they're further from the cycle - Case 2: When we hit a known node, we can immediately calculate answers by adding distances
Time Complexity: O(n)
- each node is processed at most twice
Space Complexity: O(n)
- for the ans
and vis
arrays
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 edges = [1, 2, 3, 1, 2]
:
Graph structure: 0 → 1 → 2 → 3 → 1 (cycle: 1→2→3→1) ↑ 4
Initial State:
ans = [0, 0, 0, 0, 0]
(all unprocessed)vis = [0, 0, 0, 0, 0]
(reset for each traversal)
Processing node 0:
First Pass - Mark Visit Order:
- Start at 0,
cnt = 1
, setvis[0] = 1
- Move to 1,
cnt = 2
, setvis[1] = 2
- Move to 2,
cnt = 3
, setvis[2] = 3
- Move to 3,
cnt = 4
, setvis[3] = 4
- Move to 1, but
vis[1] = 2 > 0
(already visited!) - Stop with
cnt = 4
Determine Cycle Properties:
- Since
vis[1] > 0
, we found a cycle cycle = cnt - vis[1] + 1 = 4 - 2 + 1 = 3
(nodes 1, 2, 3 form the cycle)total = cnt = 4
Second Pass - Fill Answers:
- Start at 0 again:
- Node 0: Not in cycle (vis[0]=1 < vis[1]=2), so
ans[0] = total = 4
, thentotal = 3
- Node 1: In cycle (vis[1]=2 >= 2), so
ans[1] = max(3, 3) = 3
- Node 2: In cycle (vis[2]=3 >= 2), so
ans[2] = max(3, 3) = 3
- Node 3: In cycle (vis[3]=4 >= 2), so
ans[3] = max(3, 3) = 3
- Node 0: Not in cycle (vis[0]=1 < vis[1]=2), so
- Result:
ans = [4, 3, 3, 3, 0]
,vis
reset to[0, 0, 0, 0, 0]
Processing node 4:
First Pass - Mark Visit Order:
- Start at 4,
cnt = 1
, setvis[4] = 1
- Move to 2, but
ans[2] = 3 > 0
(already processed!) - Stop with
cnt = 1
Determine Cycle Properties:
- Since
ans[2] > 0
, we hit a known node total = cnt + ans[2] = 1 + 3 = 4
cycle = 0
(no new cycle)
Second Pass - Fill Answers:
- Start at 4 again:
- Node 4:
ans[4] = total = 4
, thentotal = 3
- Node 4:
- Result:
ans = [4, 3, 3, 3, 4]
Final Answer: [4, 3, 3, 3, 4]
This demonstrates how the algorithm:
- Discovers the cycle 1→2→3→1 when processing node 0
- Assigns cycle length (3) to nodes in the cycle
- Assigns increasing values (4) to nodes outside the cycle based on their distance
- Reuses the known answer for node 2 when processing node 4, avoiding redundant traversal
Solution Implementation
1class Solution:
2 def countVisitedNodes(self, edges: List[int]) -> List[int]:
3 """
4 For each node in a functional graph, count how many nodes are visited
5 when following edges starting from that node.
6
7 Since each node has exactly one outgoing edge, paths either lead to
8 a cycle or merge into a cycle.
9
10 Args:
11 edges: List where edges[i] represents the destination from node i
12
13 Returns:
14 List where result[i] is the count of visited nodes starting from node i
15 """
16 n = len(edges)
17 result = [0] * n # Store the final answer for each node
18 visit_order = [0] * n # Track visit order during traversal (0 means unvisited)
19
20 for start_node in range(n):
21 # Skip if we already computed the result for this node
22 if result[start_node]:
23 continue
24
25 # Phase 1: Traverse and mark visit order until we hit a visited node
26 visit_count = 0
27 current_node = start_node
28
29 while visit_order[current_node] == 0:
30 visit_count += 1
31 visit_order[current_node] = visit_count
32 current_node = edges[current_node]
33
34 # Phase 2: Determine cycle length and total path length
35 cycle_length = 0
36 total_path_length = visit_count + result[current_node]
37
38 # If the node we stopped at doesn't have a result yet,
39 # it means we found a new cycle
40 if result[current_node] == 0:
41 # Calculate cycle length: current visit count - visit order of cycle start + 1
42 cycle_length = visit_count - visit_order[current_node] + 1
43 total_path_length = visit_count
44
45 # Phase 3: Assign results to all nodes in the path
46 current_node = start_node
47
48 while result[current_node] == 0:
49 # Nodes get either the full path length or cycle length (if in cycle)
50 result[current_node] = max(total_path_length, cycle_length)
51 total_path_length -= 1
52 current_node = edges[current_node]
53
54 return result
55
1class Solution {
2 public int[] countVisitedNodes(List<Integer> edges) {
3 int n = edges.size();
4 int[] result = new int[n]; // Stores the final answer for each node
5 int[] visitOrder = new int[n]; // Tracks visit order during traversal (0 means unvisited)
6
7 // Process each unprocessed node
8 for (int startNode = 0; startNode < n; startNode++) {
9 if (result[startNode] == 0) { // Node hasn't been processed yet
10 int stepCount = 0;
11 int currentNode = startNode;
12
13 // Traverse the path until we hit a visited node or a processed node
14 while (visitOrder[currentNode] == 0) {
15 visitOrder[currentNode] = ++stepCount; // Mark visit order
16 currentNode = edges.get(currentNode); // Move to next node
17 }
18
19 // Determine cycle length and total path length
20 int cycleLength = 0;
21 int totalPathLength = stepCount + result[currentNode];
22
23 // If we hit an unprocessed node, we found a cycle
24 if (result[currentNode] == 0) {
25 cycleLength = stepCount - visitOrder[currentNode] + 1;
26 }
27
28 // Backtrack and assign results to all nodes in the path
29 currentNode = startNode;
30 while (result[currentNode] == 0) {
31 // Nodes in cycle get cycleLength, others get decreasing totalPathLength
32 result[currentNode] = Math.max(totalPathLength--, cycleLength);
33 currentNode = edges.get(currentNode);
34 }
35 }
36 }
37
38 return result;
39 }
40}
41
1class Solution {
2public:
3 vector<int> countVisitedNodes(vector<int>& edges) {
4 int n = edges.size();
5 vector<int> result(n, 0); // Store the final answer for each node
6 vector<int> visitOrder(n, 0); // Track visit order during traversal
7
8 for (int startNode = 0; startNode < n; ++startNode) {
9 // Skip if this node has already been processed
10 if (result[startNode] != 0) {
11 continue;
12 }
13
14 // Phase 1: Traverse and mark nodes with their visit order
15 int visitCount = 0;
16 int currentNode = startNode;
17
18 while (visitOrder[currentNode] == 0) {
19 visitOrder[currentNode] = ++visitCount;
20 currentNode = edges[currentNode];
21 }
22
23 // Phase 2: Determine cycle information
24 int cycleLength = 0;
25 int totalPathLength = visitCount + result[currentNode];
26
27 // If we reached an unprocessed node, we found a cycle
28 if (result[currentNode] == 0) {
29 cycleLength = visitCount - visitOrder[currentNode] + 1;
30 }
31
32 // Phase 3: Assign results to all nodes in the path
33 currentNode = startNode;
34 while (result[currentNode] == 0) {
35 // Nodes in cycle get cycleLength, nodes before cycle get decreasing totalPathLength
36 result[currentNode] = max(totalPathLength--, cycleLength);
37 currentNode = edges[currentNode];
38 }
39 }
40
41 return result;
42 }
43};
44
1/**
2 * Counts the number of nodes that can be visited from each node in a directed graph
3 * where each node has exactly one outgoing edge.
4 *
5 * @param edges - Array where edges[i] represents the destination node from node i
6 * @returns Array where result[i] is the number of nodes visitable from node i
7 */
8function countVisitedNodes(edges: number[]): number[] {
9 const nodeCount: number = edges.length;
10 const result: number[] = Array(nodeCount).fill(0);
11 const visitOrder: number[] = Array(nodeCount).fill(0);
12
13 for (let startNode = 0; startNode < nodeCount; ++startNode) {
14 // Skip if this node has already been processed
15 if (result[startNode] === 0) {
16 let visitCount: number = 0;
17 let currentNode: number = startNode;
18
19 // Traverse the path and mark visit order until we hit a visited node
20 while (visitOrder[currentNode] === 0) {
21 visitOrder[currentNode] = ++visitCount;
22 currentNode = edges[currentNode];
23 }
24
25 // Calculate cycle length and total visitable nodes
26 let cycleLength: number = 0;
27 let totalVisitable: number = visitCount + result[currentNode];
28
29 // If we hit an unprocessed node, we found a cycle
30 if (result[currentNode] === 0) {
31 cycleLength = visitCount - visitOrder[currentNode] + 1;
32 }
33
34 // Backtrack and assign results to all nodes in the path
35 currentNode = startNode;
36 while (result[currentNode] === 0) {
37 // Nodes in cycle get cycleLength, others get decreasing total
38 result[currentNode] = Math.max(totalVisitable--, cycleLength);
39 currentNode = edges[currentNode];
40 }
41 }
42 }
43
44 return result;
45}
46
Time and Space Complexity
Time Complexity: O(n)
Each node is visited at most twice in the algorithm:
- Once during the initial traversal to detect cycles (the first while loop)
- Once during the backtracking to assign answer values (the second while loop)
For each starting node i
, the algorithm follows edges until it either hits a visited node or a node with a computed answer. Since each node can only be the starting point once (due to the if not ans[i]
check), and each edge is traversed a constant number of times throughout the entire algorithm, the total time complexity is O(n)
.
Space Complexity: O(n)
The algorithm uses three arrays:
ans
: stores the final answer for each node, sizen
vis
: tracks visitation order during cycle detection, sizen
- No recursive calls or additional data structures that grow with input size
Therefore, the space complexity is O(n)
, where n
is the length of the array edges.
Learn more about how to find time and space complexity quickly.
Common Pitfalls
Pitfall 1: Not Resetting visit_order
Between Different Starting Nodes
The Problem:
The visit_order
array is used to track the visiting sequence during traversal from each starting node. A critical mistake is forgetting that this array needs to be "logically reset" for each new traversal. While the code doesn't explicitly reset the entire array (which would be O(n) per starting node), it relies on the fact that visit_order[node] == 0
means "unvisited in current traversal."
Why This Breaks: If you don't properly handle the visit_order values from previous traversals, you might:
- Incorrectly detect cycles where none exist
- Calculate wrong cycle lengths
- Miss actual cycles because nodes appear "already visited"
The Solution:
The current code handles this correctly by only checking visit_order[current_node] == 0
in the while loop. However, there's an implicit assumption that needs attention: after processing a path, the visit_order
values remain non-zero. This works because:
- We only process nodes where
result[node] == 0
(unprocessed nodes) - Once a node gets a result, we never traverse from it again
- The leftover
visit_order
values don't interfere with future traversals
Better Approach for Clarity:
# Option 1: Reset visit_order for the current path after processing visited_nodes = [] while visit_order[current_node] == 0: visit_count += 1 visit_order[current_node] = visit_count visited_nodes.append(current_node) current_node = edges[current_node] # ... process the path ... # Clean up visit_order for these nodes for node in visited_nodes: visit_order[node] = 0
Or Option 2: Use a different tracking mechanism:
# Use a set for the current path instead of reusing visit_order current_path = {} visit_count = 0 current_node = start_node while current_node not in current_path and result[current_node] == 0: visit_count += 1 current_path[current_node] = visit_count current_node = edges[current_node]
Pitfall 2: Incorrect Cycle Length Calculation
The Problem:
When calculating cycle_length = visit_count - visit_order[current_node] + 1
, it's easy to get the formula wrong by:
- Forgetting the
+1
- Using the wrong node's visit_order
- Confusing visit_count with visit_order
Example of the Error:
If we have a path: 0 → 1 → 2 → 3 → 1
(cycle: 1→2→3→1)
- visit_order: [1, 2, 3, 4, ...]
- When we reach node 1 again, visit_count = 4
- Cycle length should be: 4 - 2 + 1 = 3 (nodes 1, 2, 3)
- Common mistake: 4 - 2 = 2 (missing one node!)
The Solution: Always remember that cycle length = (current position - first occurrence position + 1). Draw out small examples to verify your formula.
Pitfall 3: Mishandling the Two Different Stop Conditions
The Problem: The traversal can stop for two different reasons:
- We hit a node with a known result (
result[node] > 0
) - We hit a node we've seen in the current traversal (
visit_order[node] > 0
)
Mixing up these conditions leads to incorrect total_path_length calculations.
The Solution: Structure your conditions clearly:
if result[current_node] > 0: # Case 1: Hit a processed node cycle_length = 0 total_path_length = visit_count + result[current_node] elif visit_order[current_node] > 0: # Case 2: Found a new cycle cycle_length = visit_count - visit_order[current_node] + 1 total_path_length = visit_count
This makes it explicit which case you're handling and prevents mixing up the logic.
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
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
Memoization Prereq Backtracking problems backtracking Memoization is a fancy word for a simple concept so is the case for a lot of things we learn in school It means saving the previous function call result in a dictionary and reading from it when we do the exact same call again
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!