Note: An undirected graph is basically the same as a directed graph with bidirectional connections (= two connections in opposite directions) between the connected nodes.
Here, if neighbour is not parent and still visited, that means its a cycle.
def DFS(graph, v, discovered, parent):
discovered[v] = True
for w in graph.adjList[v]:
if not discovered[w]:
if DFS(graph, w, discovered, v):
return True
elif w != parent:
return True
return False
discovered = [False] * N
DFS(graph, 1, discovered, -1)Time Complexity: O(V + E), the Time Complexity of this method is the same as the time complexity of DFS traversal which is O(V+E).
Auxiliary Space: O(V). To store the visited O(V) space is needed.
For every visited vertex ‘v’, if there is an adjacent ‘u’ such that u is already visited and u is not a parent of v, then there is a cycle in the graph. If we don’t find such an adjacent for any vertex, we say that there is no cycle.
We use a parent array to keep track of the parent vertex for a vertex so that we do not consider the visited parent as a cycle.
def isCyclicConnected(adj: list, s, V, visited: list):
parent = [-1] * V
q = deque()
visited[s] = True
q.append(s)
while q != []:
u = q.pop()
# Get all adjacent vertices of the dequeued vertex u. If a adjacent has not been visited,
# then mark it visited and enqueue it. We also mark parent so that parent is not considered for cycle.
for v in adj[u]:
if not visited[v]:
visited[v] = True
q.append(v)
parent[v] = u
elif parent[u] != v:
return True
return False
def isCyclicDisconnected(adj: list, V):
visited = [False] * V
for i in range(V):
if not visited[i] and isCyclicConnected(adj, i, V, visited):
return True
return FalseTime Complexity: O(V + E), the Time Complexity of this method is the same as the time complexity of BFS traversal which is O(V+E).
Auxiliary Space: O(V). To store the visited O(V) space is needed + O(V) by queue.
To find cycle in a directed graph we can use the Depth First Traversal (DFS) technique. It is based on the idea that there is a cycle in a graph only if there is a back edge [i.e., a node points to one of its ancestors] present in the graph.
To detect a back edge, we need to keep track of the nodes visited till now and the nodes that are in the current recursion stack [i.e., the current path that we are visiting]. If during recursion, we reach a node that is already in the recursion stack, there is a cycle present in the graph.
Follow the below steps to Implement the idea:
def isCyclicUtil(self, v, visited, recStack):
visited[v] = True
recStack[v] = True
for neighbour in self.graph[v]:
if visited[neighbour] == False:
if self.isCyclicUtil(neighbour, visited, recStack) == True:
return True
elif recStack[neighbour] == True:
return True
# The node needs to be popped from recursion stack before function ends
recStack[v] = False
return False
def isCyclic(self):
visited = [False] * (self.V + 1)
recStack = [False] * (self.V + 1)
for node in range(self.V):
if visited[node] == False:
if self.isCyclicUtil(node, visited, recStack) == True:
return True
return FalseTime Complexity: O(V + E), the Time Complexity of this method is the same as the time complexity of DFS traversal which is O(V+E).
Auxiliary Space: O(V). To store the visited and recursion stack O(V) space is needed.
Here we are using Kahn’s algorithm for topological sorting, if it successfully removes all vertices from the graph, it’s a DAG with no cycles. If there are remaining vertices with in-degrees greater than 0, it indicates the presence of at least one cycle in the graph. Hence, if we are not able to get all the vertices in topological sorting then there must be at least one cycle.
To understand Topological sorting, it's the next topic.
def isCyclic(self):
inDegree = [0] * self.V
q = deque()
visited = 0
# Calculate in-degree of each vertex
for u in range(self.V):
for v in self.adj[u]:
inDegree[v] += 1
# Enqueue vertices with 0 in-degree
for u in range(self.V):
if inDegree[u] == 0:
q.append(u)
# BFS traversal
while q:
u = q.popleft()
visited += 1
# Reduce in-degree of adjacent vertices
for v in self.adj[u]:
inDegree[v] -= 1
# If in-degree becomes 0, enqueue the vertex
if inDegree[v] == 0:
q.append(v)
# if not all vertex were popped, that means vertex with zero degrees are still left.
return visited != self.V Time Complexity: O(V + E), the Time Complexity of this method is the same as the time complexity of BFS traversal which is O(V+E).
Auxiliary Space: O(V). To store the visited O(V) space is needed + O(V) by queue.
Topological sorting for Directed Acyclic Graph (DAG) is a linear ordering of vertices such that for every directed edge u-v, vertex u comes before v in the ordering.
Note: Topological Sorting for a graph is not possible if the graph is not a DAG.

Output: 5 4 2 3 1 0
Explanation: The first vertex in topological sorting is always a vertex with an in-degree of 0 (a vertex with no incoming edges). A topological sorting of the following graph is “5 4 2 3 1 0”. There can be more than one topological sorting for a graph. Another topological sorting of the following graph is “4 5 2 3 1 0”.
In topological sorting:
Approach:
Illustration:
def topologicalSortUtil(self, v, visited, stack):
visited[v] = True
for i in self.graph[v]:
if visited[i] == False:
self.topologicalSortUtil(i, visited, stack)
# Push current vertex to stack which stores result
stack.append(v)
def topologicalSort(self):
visited = [False]*self.V
stack = []
for i in range(self.V):
if visited[i] == False:
self.topologicalSortUtil(i, visited, stack)
print(stack[::-1]) # return list in reverse orderA DAG G has at least one vertex with in-degree 0 and one vertex with out-degree 0.
Proof: There’s a simple proof to the above fact that a DAG does not contain a cycle which means that all paths will be of finite length. Now let S be the longest path from u(source) to v(destination). Since S is the longest path there can be no incoming edge to u and no outgoing edge from v, if this situation had occurred then S would not have been the longest path
=> indegree(u) = 0 and outdegree(v) = 0
Topological sorting is a kind of dependencies problem so, we can start with the tasks which do not have any dependencies and can be done straight away or simply if we talk about in the term of node,
Algorithm: Steps involved in finding the topological ordering of a DAG:
Step-1: Compute in-degree (number of incoming edges) for each of the vertex present in the DAG and initialize the count of visited nodes as 0.
Step-2: Pick all the vertices with in-degree as 0 and add them into a queue (Enqueue operation)
Step-3: Remove a vertex from the queue (Dequeue operation) and then.
* Increment the count of visited nodes by 1.
* Decrease in-degree by 1 for all its neighbouring nodes.
* If the in-degree of neighbouring nodes is reduced to zero, then add it to the queue.
Step 4: Repeat Step 3 until the queue is empty.
Step 5: If the count of visited nodes is not equal to the number of nodes in the graph then the topological sort is not possible for the given graph.
def topologicalSort(self):
in_degree = [0]*(self.V)
for i in self.graph:
for j in self.graph[i]:
in_degree[j] += 1
# Create an queue and enqueue all vertices witt indegree 0
queue = []
for i in range(self.V):
if in_degree[i] == 0:
queue.append(i)
# Initialize count of visited vertices
cnt = 0
top_order = []
while queue:
u = queue.pop(0)
top_order.append(u)
for i in self.graph[u]:
in_degree[i] -= 1
if in_degree[i] == 0:
queue.append(i)
cnt += 1
# Check if there was a cycle
if cnt != self.V:
print ("There exists a cycle in the graph")
else :
print (top_order)Time Complexity: O(V+E). The outer for loop will be executed V number of times and the inner for loop will be executed E number of times.
Auxiliary Space: O(V). The queue needs to store all the vertices of the graph. So the space required is O(V)
A Bipartite Graph is a graph whose vertices can be divided into two independent sets, U and V such that every edge (u, v) either connects a vertex from U to V or a vertex from V to U. In other words, for every edge (u, v), either u belongs to U and v to V, or u belongs to V and v to U. We can also say that there is no edge that connects vertices of same set.

A bipartite graph is possible if the graph coloring is possible using two colors such that vertices in a set are colored with the same color. Note that it is possible to color a cycle graph with even cycle using two colors. For example, see the following graph.

It is not possible to color a cycle graph with odd cycle using two colors.

Following is a simple algorithm to find out whether a given graph is Bipartite or not using Breadth First Search (BFS).
def isBipartiteUtil(self, src):
queue = []
queue.append(src)
while queue:
u = queue.pop()
# Return false if there is a self-loop
if self.graph[u][u] == 1:
return False
for v in range(self.V):
if (self.graph[u][v] == 1 and
self.colorArr[v] == -1):
self.colorArr[v] = 1 - self.colorArr[u]
queue.append(v)
elif (self.graph[u][v] == 1 and
self.colorArr[v] == self.colorArr[u]):
return False
return True
def isBipartite(self):
self.colorArr = [-1 for i in range(self.V)]
for i in range(self.V):
if self.colorArr[i] == -1:
if not self.isBipartiteUtil(i):
return False
return TrueTime complexity: O(E+V).
Auxiliary Space: O(V), because we have a V-size array.
Can DFS algorithm be used to check the bipartite-ness of a graph? If yes, how?
Explanation:
The algorithm can be described as follows:
Step 1: To perform a DFS traversal, we require a starting node and an array to track visited nodes.
However, instead of using a visited array, we will utilize a color array where each node is initially assigned the value -1 to indicate that it has not been colored yet.
Step 2: In the DFS function call, it is important to pass the assigned color value and store it in the color array.
We will attempt to color the nodes using 0 and 1, although alternative colors can also be chosen.
Step 3: During the DFS traversal, For each uncolored node encountered, we assign it the opposite color of its current node.
Step 4: If, at any point, we encounter an adjacent node in the adjacency list that has already been colored and shares
the same color as the current node, it implies that coloring is not possible.
Consequently, we return false, indicating that the given graph is not bipartite. Otherwise, we continue returning true.
def colorGraph(G, color, pos, c):
if color[pos] != -1 and color[pos] != c:
return False
# color this pos as c and all its neighbours and 1-c
color[pos] = c
ans = True
for i in range(0, V):
if G[pos][i]:
if color[i] == -1:
ans &= colorGraph(G, color, i, 1-c)
if color[i] !=-1 and color[i] != 1-c:
return False
if not ans:
return False
return True
def isBipartite(G):
color = [-1] * V
pos = 0
# two colors 1 and 0
return colorGraph(G, color, pos, 1)Limitations:
Initial configuration:
Source Node: Before starting off with the Algorithm, we need to define a source node from which the shortest distances to every other node would be calculated.
Priority Queue: Define a Priority Queue which would contain pairs of the type {dist, node}, where ‘dist’ indicates the currently updated value of the shortest distance from the source to the ‘node’.
Dist Array: Define a dist array initialized with a large integer number at the start indicating that the nodes are unvisited initially. This array stores the shortest distances to all the nodes from the source node.
The Algorithm consists of the following steps :
A node with a lower distance would be at the top of the priority queue as opposed to a node with a higher distance because we are using a min-heap. By following step 3, until our queue becomes empty, we would get the minimum distance from the source node to all other nodes. We can then return the distance array.
import heapq
class Solution:
def dijkstra(self, V, adj, S):
pq = []
distTo = [float('inf')] * V
parent = [-1] * V # To store the path
distTo[S] = 0
heapq.heappush(pq, (0, S))
while pq:
dis, node = heapq.heappop(pq)
for neighbor in adj[node]:
v = neighbor[0]
w = neighbor[1]
if dis + w < distTo[v]:
distTo[v] = dis + w
parent[v] = node
heapq.heappush(pq, (dis + w, v))
return distTo, parent
def print_path(self, parent, node):
path = []
while node != -1:
path.append(node)
node = parent[node]
path.reverse()
return path
# Driver code
if __name__ == "__main__":
V, E, S = 3, 3, 2
adj = [[] for _ in range(V)]
# Edges (directed or undirected depending on context)
adj[0].append([1, 1])
adj[0].append([2, 6])
adj[1].append([2, 3])
adj[1].append([0, 1])
adj[2].append([1, 3])
adj[2].append([0, 6])
obj = Solution()
dist, parent = obj.dijkstra(V, adj, S)
print("Shortest distances from source:", S)
for i in range(V):
print(f"Node {i}: Distance = {dist[i]}, Path = {obj.print_path(parent, i)}")Time Complexity: O( E log(V) ), Where E = Number of edges and V = Number of Nodes.
Space Complexity: O( |E| + |V| ), Where E = Number of edges and V = Number of Nodes.
Limitations:
The bellman-Ford algorithm helps to find the shortest distance from the source node to all other nodes. But, we have already learned Dijkstra’s algorithm (Dijkstra’s algorithm article link) to fulfill the same purpose. Now, the question is how this algorithm is different from Dijkstra’s algorithm.
While learning Dijkstra’s algorithm, we came across the following two situations, where Dijkstra’s algorithm failed:
If the graph contains negative edges.
If the graph has a negative cycle (In this case Dijkstra’s algorithm fails to minimize the distance, keeps on running, and goes into an infinite loop. As a result it gives TLE error).
Negative Cycle: A cycle is called a negative cycle if the sum of all its weights becomes negative. The following illustration is an example of a negative cycle:

Bellman-Ford’s algorithm successfully solves these problems. It works fine with negative edges as well as it is able to detect if the graph contains a negative cycle. But this algorithm is only applicable for directed graphs.
In order to apply this algorithm to an undirected graph, we just need to convert the undirected edges into directed edges like the following:

After doing this we can apply Bellman ford on undirected graphs too.
Intuition:
In this algorithm, the edges can be given in any order. The intuition is to relax all the edges for N-1( N = no. of nodes) times sequentially. After N-1 iterations, we should have minimized the distance to every node.
Let’s understand what the relaxation of edges means using an example.

Let’s consider the above graph with dist[u], dist[v], and wt. Here, wt is the weight of the edge and dist[u] signifies the shortest distance to reach node u found until now. Similarly, dist[v](maybe infinite) signifies the shortest distance to reach node v found until now. If the distance to reach v through u(i.e. dist[u] + wt) is smaller than dist[v], we will update the value of dist[v] with (dist[u] + wt). This process of updating the distance is called the relaxation of edges.
We will apply the above process(i.e. minimizing the distance to reach every node) N-1 times in the Bellman-Ford algorithm.
Two follow-up questions about the algorithm:
1. Why do we need exact N-1 iterations?
Let’s try to first understand this using an example:

2. How to detect a negative cycle in the graph?
Approach:
Initial Configuration:
distance array(dist[ ]): The dist[] array will be initialized with infinity, except for the source node as dist[src] will be initialized to 0.
The algorithm steps will be the following:
def BellmanFord(self, src):
dist = [float("Inf")] * self.V
dist[src] = 0
# Relax all edges |V| - 1 times. A simple shortest path from src to any
# other vertex can have at-most |V| - 1 edges
for _ in range(self.V - 1):
# Update dist value and parent index of the adjacent vertices of
# the picked vertex. Consider only those vertices which are still in queue
for u, v, w in self.graph:
if dist[u] != float("Inf") and dist[u] + w < dist[v]:
dist[v] = dist[u] + w
# check for negative-weight cycles. The above step guarantees
# shortest distances if graph doesn't contain negative weight cycle.
# If we get a shorter path, then there is a cycle.
for u, v, w in self.graph:
if dist[u] != float("Inf") and dist[u] + w < dist[v]:
print("Graph contains negative weight cycle")
return
# print all distance
self.printArr(dist)Time Complexity when graph is connected:
Best Case: O(E), when distance array after 1st and 2nd relaxation are same , we can simply stop further processing
Average Case: O(VE)
Worst Case: O(VE)
Time Complexity when graph is disconnected:
All the cases: O(E*(V^2))
Auxiliary Space: O(V), where V is the number of vertices in the graph.
Limitations:
Basically, the Floyd Warshall algorithm is a multi-source shortest path algorithm and it helps to detect negative cycles as well. The shortest path between node u and v necessarily means the path(from u to v) for which the sum of the edge weights is minimum.
In Floyd Warshall’s algorithm, we need to check every possible path going via each possible node. And after checking every possible path, we will figure out the shortest path(a kind of brute force approach to find the shortest path). Let’s understand it using the following illustration:

From the above example we can derive the following formula:
matrix[i][j] =min(matrix[i][j], matrix[i ][k]+matrix[k][j]), where i = source node, j = destination node, and k = the node via which we are reaching from i to j.
Here we will calculate dist[i][j] for every possible node k (k = 0, 1….V, where V = no. of nodes), and will select the minimum value as our result.
In order to apply this algorithm to an undirected graph, we just need to convert the undirected edges into directed edges.
Note:
Intuition:
The intuition is to check all possible paths between every possible pair of nodes and to choose the shortest one. Checking all possible paths means going via each and every possible node.
The follow-up questions for interviews:
1. How to detect a negative cycle using the Floyd Warshall algorithm?

We have previously said that the cost of reaching a node from itself must be 0. But in the above graph, if we try to reach node 0 from itself we can follow the path: 0->1->2->0. In this case, the cost to reach node 0 from itself becomes -3 which is less than 0. This is only possible if the graph contains a negative cycle.
So, if we find that the cost of reaching any node from itself is less than 0, we can conclude that the graph has a negative cycle.
2. What will happen if we will apply Dijkstra’s algorithm for this purpose?
Approach:
The algorithm is not much intuitive as the other ones’. It is more of a brute force, where all combinations of paths have been tried to get the shortest paths. Nothing to panic much with the intuition, it is a simple brute force approach on all paths. Focus on the three ‘for’ loops.
Formula:
matrix[i][j] =min(matrix[i][j], matrix[i ][k]+matrix[k][j]), where i = source node,
j = destination node and k = the node via which we are reaching from i to j.
The algorithm steps are as follows:
Initial Configuration:
Adjacency Matrix: The adjacency matrix should store the edge weights for the given edges and the rest of the cells must be initialized with infinity().
If we want to check for a negative cycle:
After completing the steps(outside those three loops), we will run a loop and check if any cell having the row and column the same(i = j) contains a value less than 0.
def floydWarshall(graph):
""" dist[][] will be the output matrix that will finally have the shortest distances
between every pair of vertices """
""" initializing the solution matrix same as input graph matrix OR we can say that the initial
values of shortest distances are based on shortest paths considering no intermediate vertices """
dist = list(map(lambda i: list(map(lambda j: j, i)), graph))
for i in range(N):
for j in range(N):
if graph[i][j] == -1:
dist[i][j] = 10**9
if i==j: dist[i][j] = 0
""" Add all vertices one by one to the set of intermediate vertices.
---> Before start of an iteration, we have shortest distances between all pairs of vertices
such that the shortest distances consider only the vertices in the set
{0, 1, 2, .. k-1} as intermediate vertices.
----> After the end of a iteration, vertex no. k is added to the set of intermediate vertices and the
set becomes {0, 1, 2, .. k}
"""
for k in range(V):
# pick all vertices as source one by one
for i in range(V):
# Pick all vertices as destination for the
# above picked source
for j in range(V):
# If vertex k is on the shortest path from
# i to j, then update the value of dist[i][j]
dist[i][j] = min(dist[i][j],
dist[i][k] + dist[k][j]
)
printSolution(dist)Time Complexity: O(V3), as we have three nested loops each running for V times, where V = no. of vertices.
Space Complexity: O(V2), where V = no. of vertices. This space complexity is due to storing the adjacency matrix of the given graph.
Intuition:
Finding the shortest path to a vertex is easy if you already know the shortest paths to all the vertices that can precede it. Processing the vertices in topological order ensures that by the time you get to a vertex, you’ve already processed all the vertices that can precede it which reduces the computation time significantly. In this approach, we traverse the nodes sequentially according to their reachability from the source.
Dijkstra’s algorithm is necessary for graphs that can contain cycles because they can’t be topologically sorted. In other cases, the topological sort would work fine as we start from the first node, and then move on to the others in a directed manner.
Approach:
We will calculate the shortest path in a directed acyclic graph by using topological sort. Topological sort can be implemented in two ways- BFS and DFS. Here, we will be implementing using the DFS technique. Depth First Search, DFS is a traversal technique where we visit a node and then continue visiting its adjacent nodes until we reach the end point, i.e., it keeps on moving in the depth of a particular node and then backtracks when no further adjacent nodes are available.
Initial configuration:
Adjacency List: Create an adjacency list of the formed vector of pairs of size ‘N’, where each index denotes a node ‘u’ and contains a vector that consists of pairs denoting the adjacent nodes ‘v’ and the distance to that adjacent node from initial node ‘u’.
Visited Array: Create a visited array and mark all the indices as unvisited (0) initially.
Stack: Define a stack data structure to store the topological sort.
Distance Array: Initialise this array by Max integer value and then update the value for each node successively while calculating the shortest distance between the source and the current node.
The shortest path in a directed acyclic graph can be calculated by the following steps:
def topologicalSortUtil(self,v,visited,stack):
visited[v] = True
if v in self.graph.keys():
for node,weight in self.graph[v]:
if visited[node] == False:
self.topologicalSortUtil(node,visited,stack)
stack.append(v)
def shortestPath(self, s):
visited = [False]*self.V
stack =[]
for i in range(self.V):
if visited[i] == False:
self.topologicalSortUtil(s,visited,stack)
dist = [float("Inf")] * (self.V)
dist[s] = 0
while stack:
i = stack.pop()
for node,weight in self.graph[i]:
if dist[node] > dist[i] + weight:
dist[node] = dist[i] + weight
return distTime Complexity: O(N+M) {for the topological sort} + O(N+M) {for relaxation of vertices, each node and its adjacent nodes get traversed} ~ O(N+M).
Space Complexity: O( N) {for the stack storing the topological sort} + O(N) {for storing the shortest distance for each node} + O(N) {for the visited array} + O( N+2M) {for the adjacency list} ~ O(N+M) .
Where N= number of vertices and M= number of edges.
A very very good article, MUST READ: https://takeuforward.org/data-structure/disjoint-set-union-by-rank-union-by-size-path-compression-g-46/ (Thank you Striver!)
Usecase: After any step, if we try to figure out whether two arbitrary nodes u and v belong to the same component or not, Disjoint Set will be able to answer this query in constant time.
Union by rank and Size
parent = [i for i in range(V)]
rank = [1 for i in range(V)] # assuming rank 1 for one node.
size = [1 for i in range(V)] # assuming size 1 for one node.
def find(parent, x):
if x == parent[x]:
return x
temp = find(parent, parent[x])
parent[x] = temp
return temp
def unionByRank(parent, rank, x, y):
px = find(parent, x)
py = find(parent, y)
if px == py: return
if rank[px] > rank[py]:
parent[py] = px
elif rank[px] < rank[py]:
parent[px] = py
else:
parent[px] = py
rank[py] += 1
def unionBySize(parent, rank, x, y):
px = find(parent, x)
py = find(parent, y)
if px == py: return
if size[px] < size[py]:
parent[px] = py
size[py] += size[px]
else:
parent[py] = px
size[px] += size[py]The time complexity is O(4) (to call union function one time) which is very small and close to 1. So, we can consider 4 as a constant.(Also known as Amortised TC)
Spanning Tree:
A spanning tree is a tree in which we have N nodes(i.e. All the nodes present in the original graph) and N-1 edges and all nodes are reachable from each other(Undirected graph).
MST? Among all possible spanning trees of a graph, the minimum spanning tree is the one for which the sum of all the edge weights is the minimum.

MST:

There are a couple of algorithms that help us to find the minimum spanning tree of a graph. One is Prim’s algorithm and the other is Kruskal’s algorithm. We will be discussing all of them in the upcoming articles.
Approach:
The algorithm steps are as follows:
Priority Queue(Min Heap): The priority queue will be storing the pairs (edge weight, node). We can start from any given node. Here we are going to start from node 0 and so we will initialize the priority queue with (0, 0). If we wish to store the mst of the graph, the priority queue should instead store the triplets (edge weight, adjacent node, parent node) and in that case, we will initialize with (0, 0, -1).
Visited array: All the nodes will be initially marked as unvisited.
Sum variable: It will be initialized with 0 and we wish that it will store the sum of the edge weights finally.
MST array(optional): If we wish to store the minimum spanning tree(MST) of the graph, we need this array. This will store the edge information as a pair of starting and ending nodes of a particular edge.
Note: Points to remember if we do not wish to store the mst(minimum spanning tree) for the graph and are only concerned about the sum of all the edge weights of the minimum spanning tree:
Intuition:
The intuition of this algorithm is the greedy technique used for every node. If we carefully observe, for every node, we are greedily selecting its unvisited adjacent node with the minimum edge weight(as the priority queue here is a min-heap and the topmost element is the node with the minimum edge weight). Doing so for every node, we can get the sum of all the edge weights of the minimum spanning tree and the spanning tree itself(if we wish to) as well.
def prims(graph,n):
selected = [False]*(n) # n+1 becoz 0 is not the vertex in testcases
mst = 0
mstEdges = set()
# first add the first vertex into the heap with wt 0 and parent -1
heap = [(0,1,-1)] # wt,vertex,parent
heapq.heapify(heap)
while len(heap) > 0:
rem = heapq.heappop(heap) # wt,vertex,parent
# if already visited vertex then just continue
if selected[rem[1]] == True:
continue
selected[rem[1]] = True
# that means that we should not count first temp element pushed into heap
if rem[2] != -1:
mst += rem[0]
mstEdges.add((rem[2],rem[1])) # (parent, vertex)
for k in graph[rem[1]]: # checking neighbours
# only add a neighbour if it is no visited
if selected[k] == False:
add = (graph[rem[1]][k],k,rem[1])
heapq.heappush(heap, add)
return mstTime Complexity: O(ElogE) + O(ElogE)~ O(ElogE), where E = no. of given edges.
The maximum size of the priority queue can be E so after at most E iterations the priority queue will be empty and the loop will end. Inside the loop, there is a pop operation that will take logE time. This will result in the first O(ElogE) time complexity. Now, inside that loop, for every node, we need to traverse all its adjacent nodes where the number of nodes can be at most E. If we find any node unvisited, we will perform a push operation and for that, we need a logE time complexity. So this will result in the second O(E*logE).
Space Complexity: O(E) + O(V), where E = no. of edges and V = no. of vertices. O(E) occurs due to the size of the priority queue and O(V) due to the visited array. If we wish to get the mst, we need an extra O(V-1) space to store the edges of the most.
Approach:
We will be implementing Kruskal’s algorithm using the Disjoint Set data structure that we have previously learned.
Now, we know Disjoint Set provides two methods named findUPar()(This function helps to find the ultimate parent of a particular node) and Union(This basically helps to add the edges between two nodes).
The algorithm steps are as follows:

def find(parent, x):
if x == parent[x]:
return x
temp = find(parent, parent[x])
parent[x] = temp
return temp
def unionByRank(parent, rank, x, y):
px = find(parent, x)
py = find(parent, y)
if px == py: return
if rank[px] > rank[py]:
parent[py] = px
elif rank[px] < rank[py]:
parent[px] = py
else:
parent[px] = py
rank[py] += 1
# Applying Kruskal algorithm
def kruskal_algo(self):
result = []
i, e = 0, 0 #edge_iterator, mst_edge_cnt
self.graph = sorted(self.graph, key=lambda item: item[2])
parent = []
rank = []
for node in range(self.V):
parent.append(node)
rank.append(0)
while e < self.V - 1:
u, v, w = self.graph[i]
i = i + 1
x = self.find(parent, u)
y = self.find(parent, v)
if x != y:
e = e + 1
result.append([u, v, w])
self.apply_union(parent, rank, x, y)
for u, v, weight in result:
print("%d - %d: %d" % (u, v, weight))Time Complexity: O(N+E) + O(E logE) + O(E4α2) where N = no. of nodes and E = no. of edges. O(N+E) for extracting edge information from the adjacency list. O(E logE) for sorting the array consists of the edge tuples. Finally, we are using the disjoint set operations inside a loop. The loop will continue to E times. Inside that loop, there are two disjoint set operations like findUPar() and UnionBySize() each taking 4 and so it will result in 42. That is why the last term O(E4*2) is added.
Space Complexity: O(N) + O(N) + O(E) where E = no. of edges and N = no. of nodes. O(E) space is taken by the array that we are using to store the edge information. And in the disjoint set data structure, we are using two N-sized arrays i.e. a parent and a size array (as we are using unionBySize() function otherwise, a rank array of the same size if unionByRank() is used) which result in the first two terms O(N).
Many more things are yet to be added like
def ArticulationPoints(graph, v, e):
visited = [False]*(v+1)
parent = [-1]*(v+1)
disc = [sys.maxsize]*(v+1)
low = [sys.maxsize]*(v+1)
artPoint = [False]*(v+1)
discoveryTime = [0]
actualSrc = [1]
childrensOfActualSrc = [0]
def AP_DFS(graph,v,e,src):
visited[src] = True
disc[src] = discoveryTime[0]
low[src] = discoveryTime[0]
discoveryTime[0] += 1
neighbours = graph[src]
for nbr in neighbours:
if nbr == parent[src]:
continue
if visited[nbr] == True:
low[src] = min(low[src], disc[nbr])
else:
parent[nbr] = src
if src == actualSrc[0]:
childrensOfActualSrc[0] += 1
AP_DFS(graph, v, e, nbr)
low[src] = min(low[src], low[nbr])
if src != actualSrc[0]:
if disc[src] <= low[nbr]:
artPoint[src] = True
AP_DFS(graph, v, e, 1)
totalArticulationPoints = 0
for i,node in enumerate(artPoint):
if i != 1:
if node == True:
totalArticulationPoints+=1
if childrensOfActualSrc[0] > 1:
totalArticulationPoints+=1
return totalArticulationPoints
import sys
t = int(input())
for _ in range(t):
v, e = map(int,input().split())
graph = {}
for i in range(1,v+1):
graph[i] = []
for ed in range(e):
s, d = map(int,input().split())
graph[s].append(d)
graph[d].append(s)class Solution:
def criticalConnections(self, n: int, connections: List[List[int]]) -> List[List[int]]:
v = n
graph = {}
for i in range(0,n):
graph[i] = []
for ed in connections:
s, d = ed[0], ed[1]
graph[s].append(d)
graph[d].append(s)
visited = [False]*(v)
parent = [-1]*(v)
disc = [sys.maxsize]*(v)
low = [sys.maxsize]*(v)
artPoint = [False]*(v)
discoveryTime = [0]
actualSrc = [0]
childrensOfActualSrc = [0]
result = []
def AP_DFS(graph,v,src):
visited[src] = True
disc[src] = discoveryTime[0]
low[src] = discoveryTime[0]
discoveryTime[0] += 1
neighbours = graph[src]
for nbr in neighbours:
if nbr == parent[src]:
continue
if visited[nbr] == True:
low[src] = min(low[src], disc[nbr])
else:
parent[nbr] = src
if src == actualSrc[0]:
childrensOfActualSrc[0] += 1
AP_DFS(graph, v, nbr)
low[src] = min(low[src], low[nbr])
if disc[src] < low[nbr]:
artPoint[src] = True
result.append([src,nbr])
AP_DFS(graph, v, 0)
# print(artPoint)
totalArticulationPoints = 0
for i,node in enumerate(artPoint):
if i != actualSrc[0]:
if node == True:
totalArticulationPoints+=1
if childrensOfActualSrc[0] > 1:
totalArticulationPoints+=1
return result
Please UPVOTE if you like it. ALso add your feedbacks and all in comments.