Replication Magic: A Guide to Deep Copying Graphs and Binary Trees (Part 1: Graphs)
I came across a couple of Leetcode challenges that involve making deep clones of graphs and binary trees — let’s dive in and unravel these puzzles!
Note: This article will focus on cloning graphs, while you can explore the cloning of binary trees here.”
Clone Graph
Given a reference of a node in a connected undirected graph. Return a deep copy (clone) of the graph. Each node in the graph contains a value (int
) and a list (List[Node]
) of its neighbors.class Node(object):
def __init__(self, val = 0, neighbors = None):
self.val = val
self.neighbors = neighbors if neighbors is not None else []Test case format:
For simplicity, each node’s value is the same as the node’s index (1-indexed). For example, the first node with
val == 1
, the second node withval == 2
, and so on. The graph is represented in the test case using an adjacency list.The given node will always be the first node with
val = 1
. You must return the copy of the given node as a reference to the cloned graph.
Input: adjList = [[2,4],[1,3],[2,4],[1,3]]
Output: [[2,4],[1,3],[2,4],[1,3]]
This might seem like a beast of a problem to tackle in an interview, so before we dive in, let’s take a breather and review the constraints and key concepts. This way, we can tame the beast and simplify the challenge ahead!
- The number of nodes in the graph is in the range
[0, 100]
. 1 <= Node.val <= 100
Node.val
is unique for each node.- There are no repeated edges and no self-loops in the graph.
- The Graph is connected and all nodes can be visited starting from the given node.
It’s always smart to clarify any constraints with the interviewer before tackling a coding problem — after all, you don’t want to end up solving the wrong puzzle!
We are dealing with connected undirected graphs and the task of making a deep copy. Since the graph is connected, a single DFS traversal is sufficient to visit all nodes.
By “deep copy,” we mean creating a new node for each original node in the graph and establishing the corresponding edges between these newly generated nodes.
The intuition for cloning a graph involves performing a DFS traversal on the input graph to build an adjacency list using a hashmap or dictionary. In this dictionary, each node serves as a key, with its value being a list of nodes to which it is connected.
adj_list = {}
def dfs(node): # DFS traversal to build an adjacency list.
if not node:
return
adj_list[node.val] = []
for n in node.neighbors:
adj_list[node.val].append(n.val)
if n.val not in adj_list:
dfs(n)
dfs(node)
The problem simplifies our task by specifying that the input node is always the first node, allowing us to use a straightforward array or list data structure to store the new nodes and leverage their indices for faster access.
Hence, now we can generate new nodes, each corresponding to an original node, and store it in a list starting from the index to correspond to the first node in the graph
nodes = [-1] # List to store new nodes. Start adding nodes from the first index.
for node in adj_list:
nodes.append(Node(val=node)) # No need to worry about establishing edges at this point.
Finally, we establish edges between these newly generated nodes according to the connections defined in the adjacency list, ensuring the cloned graph mirrors the structure of the original.
# Iterate through each node in the list (excluding the first node)
for node_idx in range(1, len(nodes)):
# For each neighbor in the adjacency list of the current node
for nei in adj_list[nodes[node_idx].val]:
# Append the corresponding new node to the current node's neighbors list
nodes[node_idx].neighbors.append(nodes[nei])
In the end, we can just return the node at index 1 of the nodes list.
The following code integrates all the discussed elements to create and return a deep copy of the input graph.
"""
# Definition for a Node.
class Node(object):
def __init__(self, val = 0, neighbors = None):
self.val = val
self.neighbors = neighbors if neighbors is not None else []
"""
class Solution(object):
def cloneGraph(self, node):
"""
:type node: Node
:rtype: Node
"""
if not node:
return None
if len(node.neighbors) == 0:
return Node(node.val)
adj_list = {}
def dfs(node):
if not node:
return
adj_list[node.val] = []
for n in node.neighbors:
adj_list[node.val].append(n.val)
if n.val not in adj_list:
dfs(n)
dfs(node)
nodes = [-1]
for node in adj_list:
nodes.append(Node(val=node))
for node_idx in range(1, len(nodes)):
for nei in adj_list[nodes[node_idx].val]:
nodes[node_idx].neighbors.append(nodes[nei])
return nodes[1]
"""
Input: adjList = [[2,4],[1,3],[2,4],[1,3]]
Output: [[2,4],[1,3],[2,4],[1,3]]
"""
Let’s analyze the time and space complexity of the above code consisting of N nodes and E edges
Time Complexity: O(N+E)
For a graph with N nodes and E edges, using DFS we visit each node and edge exactly once, resulting in O(N+E) time complexity.During traversal, we create copies of each node. Creating a copy of a node is an O(1) operation. Connecting nodes takes O(1) time for each edge since each node and its neighbors are processed once.
Space Complexity: O(N+E)
Storing N nodes and E Edges in an adjacency list, takes O(N + E) space. Additionally, we using a list to store N nodes. But, the overall space complexity will still be O(N + E).
And that wraps up our journey into cloning graphs! Hope you enjoyed the article!
Note: Explore the step-by-step process of cloning a binary tree in the article linked here.
References:
https://leetcode.com/problems/clone-binary-tree-with-random-pointer