You are given a positive integer n
representing the number of nodes of a Directed Acyclic Graph (DAG). The nodes are numbered from 0
to n - 1
(inclusive).
You are also given a 2D integer array edges
, where edges[i] = [from<sub>i</sub>, to<sub>i</sub>]
denotes that there is a unidirectional edge from from<sub>i</sub>
to to<sub>i</sub>
in the graph.
Return a list answer
, where answer[i]
is the list of ancestors of the i<sup>th</sup>
node, sorted in ascending order.
A node u
is an ancestor of another node v
if u
can reach v
via a set of edges.
Example 1:
n = 8
, edgeList = [[0,3],[0,4],[1,3],[2,4],[2,7],[3,5],[3,6],[3,7],[4,6]]
Output: [[],[],[],[0,1],[0,2],[0,1,3],[0,1,2,3,4],[0,1,2,3]]
Example 2:
n = 5
, edgeList = [[0,1],[0,2],[0,3],[0,4],[1,2],[1,3],[1,4],[2,3],[2,4],[3,4]]
Output: [[],[0],[0,1],[0,1,2],[0,1,2,3]]
Could you provide an algorithm that efficiently computes the ancestors for each node in the given DAG? Consider the time and space complexity of your approach. How would you handle edge cases such as an empty graph, a disconnected graph, or a graph with a single node?
For each node, we can perform a Depth-First Search (DFS) or Breadth-First Search (BFS) to find all the nodes that can reach it. These reachable nodes are the ancestors. We store these ancestors in a sorted list.
Algorithm:
answer
of size n
. This list will store the ancestors for each node.i
from 0 to n-1
.i
, perform a DFS or BFS starting from every other node j
(from 0 to n-1
).i
starting from node j
, it means j
is an ancestor of i
.j
to the list of ancestors for node i
.j
, sort the list of ancestors for node i
in ascending order.answer
list at index i
.answer
list.Code (Python):
def get_ancestors_brute_force(n, edges):
graph = [[] for _ in range(n)]
for u, v in edges:
graph[u].append(v)
ancestors = [[] for _ in range(n)]
for i in range(n):
for j in range(n):
if i == j: continue
visited = [False] * n
stack = [j]
visited[j] = True
while stack:
node = stack.pop()
if node == i:
ancestors[i].append(j)
break
for neighbor in graph[node]:
if not visited[neighbor]:
visited[neighbor] = True
stack.append(neighbor)
ancestors[i].sort()
return ancestors
Time Complexity: O(n^3) in the worst case. For each of the n nodes, we potentially perform a DFS/BFS from all other n nodes, and each DFS/BFS can take O(n) time.
Space Complexity: O(n^2) in the worst case. The graph representation can take O(E) space, where E is the number of edges, but in the worst case, the ancestor list for each node could contain up to n-1 elements, and we store this list for n nodes, leading to O(n^2) space.
We can improve the time complexity by using topological sort and sets to store ancestors.
Algorithm:
u
.v
of u
:
u
to the ancestors of v
.u
to the ancestors of v
.v
.v
becomes 0, add v
to the queue.Code (Python):
from collections import deque
def get_ancestors_optimal(n, edges):
graph = [[] for _ in range(n)]
in_degree = [0] * n
ancestors = [set() for _ in range(n)]
for u, v in edges:
graph[u].append(v)
in_degree[v] += 1
queue = deque([i for i in range(n) if in_degree[i] == 0])
while queue:
u = queue.popleft()
for v in graph[u]:
ancestors[v].add(u)
ancestors[v].update(ancestors[u])
in_degree[v] -= 1
if in_degree[v] == 0:
queue.append(v)
result = [sorted(list(ancestors[i])) for i in range(n)]
return result
Time Complexity: O(n + E), where n is the number of nodes and E is the number of edges. Building the graph takes O(E) time. The topological sort visits each node and edge once, taking O(n + E) time. Sorting the ancestors lists takes O(n * k * log k) time, where k is the average number of ancestors per node. However, in most cases, n+E
will dominate n * k * log k
.
Space Complexity: O(n + E). The graph representation takes O(E) space. The in_degree
array takes O(n) space. The ancestors
sets take O(n^2) space in the worst case, but on average, they will take less space. The queue takes O(n) space in the worst case.