Daa Unit3
Daa Unit3
GRAPH TRAVERSAL
Stands for BFS stands for Breadth First Search. DFS stands for Depth First Search.
Conceptual
BFS builds the tree level by level. DFS builds the tree sub-tree by sub-tree.
Difference
BFS is more suitable for searching vertices DFS is more suitable when there are solutions away
Suitable for closer to the given source. from source.
BFS is used in various applications such as DFS is used in various applications such as acyclic
bipartite graphs, shortest paths, etc. If graphs and finding strongly connected components
weight of every edge is same, then BFS etc. There are many applications where both BFS
gives shortest path from source to every and DFS can be used like Topological Sorting, Cycle
Applications other vertex. Detection, etc.
BFS ALGO:
Algorithm:
1. Initialization:
2. Traversal:
▪ Process the current_node (e.g., print it, check if it's the target).
▪ For each neighbor of current_node:
Pseudocode:
BFS(graph, start_node):
queue = Queue()
queue.enqueue(start_node)
visited = Set()
visited.add(start_node)
current_node = queue.dequeue()
visited.add(neighbor)
queue.enqueue(neighbor)
DFS Algo
1. Initialization:
2. Traversal:
▪ Process the current_node (e.g., print it, check if it's the target).
▪ For each neighbor of current_node (it's often good to iterate in reverse order if you want
consistent output, or just in any order):
Pseudocode (Iterative):
DFS_Iterative(graph, start_node):
stack = Stack()
stack.push(start_node)
visited = Set()
visited.add(start_node)
current_node = stack.pop()
visited.add(neighbor)
stack.push(neighbor)
Dijkstra's algorithm, conceived by Edsger W. Dijkstra in 1956 and published in 1959, is a single-source shortest path
algorithm. This means it finds the shortest paths from a single starting node (the "source") to all other reachable
nodes in a graph.
Key Idea: The algorithm works by iteratively building a set of nodes for which it has determined the shortest path
from the source. It starts at the source node and then "explores" its neighbors, continually selecting the unvisited
node with the smallest known distance from the source.
Analogy: Imagine you're trying to find the quickest route from your house to all your friends' houses. You start at
your house (source). You know the time it takes to get to your immediate neighbors. You pick the closest neighbor,
mark their house as "visited" (meaning you've found the quickest way there), and then consider how that neighbor's
house connects you to their unvisited neighbors, potentially finding a quicker route to those unvisited neighbors than
you previously knew. You repeat this process, always picking the unvisited house that's currently "closest" to your
starting point, until all houses are visited or unreachable.
Prerequisites/Limitations:
• Non-negative Edge Weights: Dijkstra's algorithm requires all edge weights to be non-negative (i.e., 0 or
positive). If your graph has negative edge weights, Dijkstra's will not work correctly. For graphs with negative
edge weights, you'd typically use algorithms like Bellman-Ford.
• Directed or Undirected Graphs: It works for both. For undirected graphs, you can treat each undirected edge
as two directed edges (one in each direction) with the same weight.
1. distances dictionary/map/array: Stores the shortest known distance from the source node to every other
node. Initially, all distances are ∞ (infinity) except for the source node, which is 0.
2. priority_queue (min-heap): Stores (distance, node) pairs. It's crucial for efficiently selecting the unvisited
node with the smallest current distance.
3. visited set/array: Keeps track of nodes for which the shortest path from the source has already been
finalized.
1. Initialization:
o Create a distances dictionary/map. For every node v in the graph, set distances[v] = infinity.
o Set distances[source_node] = 0.
o Create an empty visited set. (Alternatively, you can use a boolean array if node IDs are integers).
2. Main Loop:
▪ Extract the node u with the smallest current_distance from the priority_queue.
▪ Check for visited: If u is already in visited, continue to the next iteration (this is important
because a node might be added to the priority queue multiple times with different distances,
but we only care about the first time it's extracted, which is guaranteed to be its shortest
path).
▪ Add u to visited.
3. Result:
o Once the priority_queue is empty, the distances dictionary will contain the shortest path distances
from the source_node to all other reachable nodes.
Pseudocode:
Dijkstra(Graph, source_node):
// Initialize distances: All nodes are initially at infinite distance, except source
distances = {}
distances[v] = infinity
distances[source_node] = 0
// Priority queue: stores (distance, node) pairs, ordered by distance
priority_queue = MinPriorityQueue()
priority_queue.add((0, source_node))
visited = Set()
// Get the node with the smallest distance from the priority queue
if current_node in visited:
continue
// Mark the current node as visited (its shortest path is now known)
visited.add(current_node)
// If this new path is shorter than the previously known shortest path to neighbor
distances[neighbor] = new_distance
return distances
Example Walkthrough
Edges:
A --5--> B
A --3--> C
B --2--> D
C --4--> D
C --8--> E
D --1--> E
Source node: A
Initialization:
• distances = {A: 0, B: ∞, C: ∞, D: ∞, E: ∞}
• visited = {}
Iteration 1:
2. current_node = A, current_distance = 0.
4. Neighbors of A:
• distances = {A: 0, B: 5, C: 3, D: ∞, E: ∞}
• priority_queue = [(3, C), (5, B)] (order might vary based on PQ implementation, but (3, C) will be extracted
next)
Iteration 2:
2. current_node = C, current_distance = 3.
4. Neighbors of C:
o E (weight 8): new_distance = 3 + 8 = 11. Since 11 < ∞, update distances[E] = 11. Add (11, E) to
priority_queue.
2. current_node = B, current_distance = 5.
4. Neighbors of B:
Iteration 4:
2. current_node = D, current_distance = 7.
4. Neighbors of D:
o E (weight 1): new_distance = 7 + 1 = 8. Current distances[E] is 11. Since 8 < 11, update distances[E] =
8. Add (8, E) to priority_queue.
• distances = {A: 0, B: 5, C: 3, D: 7, E: 8}
• priority_queue = [(8, E), (11, E)] (Note: (11, E) is still in PQ but will be ignored when extracted because E is
visited with a shorter path)
Iteration 5:
2. current_node = E, current_distance = 8.
4. Neighbors of E: None.
• distances = {A: 0, B: 5, C: 3, D: 7, E: 8}
Iteration 6:
2. current_node = E.
Final distances:
• A: 0
• B: 5 (A -> B)
• C: 3 (A -> C)
• D: 7 (A -> C -> D)
• E: 8 (A -> C -> D -> E)
Time Complexity
The time complexity of Dijkstra's algorithm depends on the implementation of the priority queue:
• Using a Min-Priority Queue (Binary Heap): This is the most common and efficient implementation.
o Each edge relaxation (potential update and addition to PQ) takes O(logV) time (where V is the
number of vertices).
o There are E edges (so E relaxations) and V extractions from the PQ (each taking O(logV)).
• Using a Fibonacci Heap (more complex, theoretical improvement for dense graphs):
• GPS Navigation Systems: Finding the shortest route between two locations on a map (where intersections
are nodes and roads are edges with travel times/distances as weights).
• Network Routing Protocols: Used by routers to find the best path for data packets to travel across a network
(e.g., OSPF).
Dijkstra's algorithm is a cornerstone of graph theory and an essential tool for anyone working with interconnected
data.
The Floyd-Warshall algorithm is a powerful and elegant dynamic programming algorithm used to find the shortest
path between all pairs of vertices in a weighted graph. It can handle graphs with both positive and negative edge
weights, but it cannot handle graphs with negative cycles (as a negative cycle implies that the shortest path is
undefined, as you can keep traversing the cycle to get infinitely smaller path lengths).
Imagine you want to find the shortest path from vertex i to vertex j. The path can either be a direct edge from i to j,
or it can go through some intermediate vertices.
Floyd-Warshall iteratively improves its estimates of shortest paths by considering more and more intermediate
vertices. It uses a triple nested loop structure:
• The outermost loop (k) iterates through every possible vertex in the graph. In each iteration, k is considered
as a potential intermediate vertex on the path between any two other vertices i and j.
• The two inner loops (i and j) iterate through all possible pairs of source and destination vertices.
For each pair (i, j), the algorithm asks: "Is the current shortest path from i to j shorter, or is it shorter to go from i to k
and then from k to j?"
D[k][i][j]=min(D[k−1][i][j],D[k−1][i][k]+D[k−1][k][j])
Where D[k][i][j] represents the shortest path from i to j using only intermediate vertices from the set {0, 1, ..., k}.
However, in practice, we usually optimize the space and use a 2D matrix dist[i][j] that gets updated in place.
1. Initialization:
o Create a square matrix dist of size V×V (where V is the number of vertices).
▪ If there's no direct edge from i to j, dist[i][j] = infinity (or a very large number, representing
unreachable).
▪ Relaxation Step: Check if the path from i to j can be made shorter by going
through k.
o After the main loops complete, iterate through the diagonal of the dist matrix:
▪ If dist[i][i] is ever less than 0 for any i, it indicates the presence of a negative cycle reachable
from i. In such a scenario, shortest paths are not well-defined for nodes involved in or
reachable from that cycle.
4. Result:
o The dist matrix now contains the shortest path distances between all pairs of vertices. dist[i][j] holds
the shortest distance from vertex i to vertex j.
Pseudocode
FloydWarshall(graph_adj_matrix):
if i == j:
dist[i][j] = 0
else:
// or lead to incorrect results. Ensure both parts of the path are reachable.
if dist[i][i] < 0:
return dist
Example Walkthrough
Nodes: 0, 1, 2, 3
Edges:
0 --3--> 1
0 --8--> 3
1 --4--> 2
3 --2--> 2
3 --7--> 0
0 1 2 3
0 [0, 3, 999, 8]
1 [999, 0, 4, 999]
3 [7, 999, 2, 0]
• Example: dist[3][1]
0 1 2 3
0 [0, 3, 999, 8]
1 [999, 0, 4, 999]
• Example: dist[0][2]
• Example: dist[2][0]
o Path 2 -> 1 -> 0: dist[2][1] + dist[1][0] is -5 + 999 = 994. Still 999. No update.
• ...
0 1 2 3
1 [999, 0, 4, 999]
3 [7, 10, 2, 0]
• Example: dist[0][1]
o Current dist[0][1] is 3.
0 1 2 3
1 [999, 0, 4, 999]
o Current dist[0][0] is 0.
• Example: dist[1][0]
• Example: dist[1][1]
o Current dist[1][1] is 0.
o Path 1 -> 3 -> 1: dist[1][3] + dist[3][1] = 999 + (-3) = 996. (Still 0).
0 1 2 3
0 [0, 2, 7, 8]
3 [7, -3, 2, 0]
Final Check for Negative Cycles: Look at the diagonal elements: dist[0][0]=0, dist[1][1]=0, dist[2][2]=0, dist[3][3]=0.
None are negative, so no negative cycles were detected.
The final dist matrix gives the shortest path between every pair of vertices. For instance, the shortest path from
vertex 3 to vertex 1 is -3.
• Time Complexity: O(V3), where V is the number of vertices. This is because there are three nested loops,
each iterating V times.
Advantages:
• Handles Negative Weights: Works correctly with negative edge weights, as long as there are no negative
cycles.
• All-Pairs: Directly solves the problem of finding shortest paths between all pairs of vertices.
Disadvantages:
• Inefficient for Sparse Graphs: For sparse graphs (E≪V2), O(V3) can be less efficient than running Johnson's
algorithm (O(VE+V2logV)).
• Cannot Handle Negative Cycles (gracefully): While it can detect them, it doesn't provide meaningful shortest
paths if negative cycles exist. The distances involving such cycles would tend towards negative infinity.
• Higher Space Complexity for Very Large Graphs: O(V2) might be too much for extremely large graphs where
V is in the millions.
Floyd-Warshall is particularly well-suited for dense graphs or situations where V is small enough (e.g., V≤500) that V3
is acceptable, and you need all-pairs shortest paths.
The transitive closure of a directed graph G=(V,E) is another directed graph G∗=(V,E∗), where E∗ contains an edge
(u,v) if and only if there is a path from vertex u to vertex v in the original graph G.
In simpler terms: If you can reach v from u by following one or more edges in the original graph, then there's a direct
edge from u to v in the transitive closure.
Analogy: Imagine a social network where an edge (A,B) means "A follows B." The transitive closure would tell you
who can eventually see whose posts. If A follows B, and B follows C, then A can "reach" C's posts indirectly. In the
transitive closure, there would be a direct edge (A, C).
Key Idea: The transitive closure essentially shows all reachable pairs in a graph.
The most common and straightforward way to compute the transitive closure is by adapting the Floyd-Warshall
algorithm.
The standard Floyd-Warshall finds shortest paths. We can modify it to find reachability:
1. Initialization:
o Initialize reach[i][j] to true if there is a direct edge from i to j in the original graph, or if i == j (a node
can always reach itself).
▪ Explanation:
▪ reach[i][j] (left side of OR): This represents the possibility that i can
already reach j without passing through k.
▪ (reach[i][k] AND reach[k][j]): This represents the possibility that i can
reach k, AND k can reach j. If both are true, then i can reach j by
going through k.
▪ By taking the OR, we are saying i can reach j if it either already could
or if it can now by passing through k.
3. Result:
o After the loops complete, reach[i][j] will be true if there is a path from i to j in the original graph, and
false otherwise. This reach matrix represents the adjacency matrix of the transitive closure graph G∗.
TransitiveClosure(graph_adj_matrix):
if i == j:
reach[i][j] = true
else:
reach[i][j] = false
return reach
Example Walkthrough
Consider a simple directed graph:
Edges:
0 -> 1
1 -> 2
2 -> 0
0 -> 3
0 1 2 3
1 [F, T, T, F]
2 [T, F, T, F]
3 [F, F, F, T]
• reach[1][0] is F.
• reach[2][0] is T. reach[0][1] is T.
0 1 2 3
0 [T, T, F, T]
1 [F, T, T, F]
3 [F, F, F, T]
• reach[0][1] is T. reach[1][2] is T.
0 1 2 3
2 [T, T, T, T]
3 [F, F, F, T]
• reach[1][2] is T. reach[2][0] is T.
0 1 2 3
0 [T, T, T, T]
2 [T, T, T, T]
3 [F, F, F, T]
No significant changes here as vertex 3 only has paths to it and can reach itself. No other node can reach 3 and then
reach another node via 3 that wasn't already reachable.
0 1 2 3
0 [T, T, T, T]
1 [T, T, T, T]
2 [T, T, T, T]
3 [F, F, F, T]
• Time Complexity: O(V3), where V is the number of vertices. This is due to the three nested loops.
• Reachability: Directly tells you which nodes are reachable from other nodes.
• Strongly Connected Components (SCCs): Can be used as a step in algorithms to find SCCs.
• Database Queries: In relational databases, to find relationships that are not directly stored but can be
inferred (e.g., finding all employees supervised directly or indirectly by a manager).
Given a connected, undirected, and weighted graph, a spanning tree is a subgraph that includes all the vertices of
the original graph and forms a tree (i.e., it's connected and has no cycles).
A Minimum Spanning Tree (MST) is a spanning tree whose sum of edge weights is as small as possible. In other
words, it's the "cheapest" way to connect all the vertices in the graph without forming any cycles.
• It connects all vertices using the minimum possible total edge weight.
• It has no cycles.
Real-world applications:
• Network design: Laying out telecommunications, electrical, or transportation networks with minimal
cabling/cost.
Prim's Algorithm
Prim's algorithm builds an MST by starting from an arbitrary vertex and iteratively adding the cheapest edge that
connects a vertex in the growing MST to a vertex outside the MST. It's a "greedy" algorithm because it always makes
the locally optimal choice.
Analogy: Imagine you're building a network. You pick one building (vertex) to start. Then, you look at all the available
connections from your current network to buildings not yet connected. You pick the cheapest connection, add that
building to your network, and repeat.
Algorithm Steps:
1. Initialization:
o Maintain a min_cost array/dictionary where min_cost[v] stores the minimum weight of an edge
connecting v to a vertex already in the MST. Initialize min_cost[start_vertex] = 0 and min_cost[v] =
infinity for all other vertices.
o Create a visited set to keep track of vertices already included in the MST.
2. Main Loop:
▪ Extract the (weight, u) pair with the minimum weight from the priority_queue.
▪ If u is already in visited, continue (this handles cases where a vertex is added to PQ multiple
times with different costs, we only process the cheapest path).
▪ Add u to visited.
▪ If u is not the start_vertex (i.e., it has a parent), add the edge (parent[u], u) to mst_edges.
▪ Explore Neighbors: For each neighbor_v of u and the edge_weight between u and
neighbor_v:
▪ Set parent[neighbor_v] = u.
3. Result:
o Once the priority_queue is empty (or V-1 edges are found), mst_edges will contain the edges of the
Minimum Spanning Tree.
Pseudocode:
Prim(Graph, start_vertex):
min_cost[v] = infinity
parent[v] = null
min_cost[start_vertex] = 0
priority_queue = MinPriorityQueue()
visited = Set()
mst_edges = []
total_mst_weight = 0
while not priority_queue.is_empty() and len(visited) < Graph.num_vertices:
current_cost, u = priority_queue.extract_min()
if u in visited:
continue
visited.add(u)
total_mst_weight += current_cost
mst_edges.add((parent[u], u, current_cost))
min_cost[neighbor_v] = edge_weight
parent[neighbor_v] = u
priority_queue.add((edge_weight, neighbor_v))
Time Complexity:
• Using a Binary Heap (most common): O(ElogV) or O(ElogE) (since E≤V2, logE can be at most 2logV, so
O(ElogV) is a tighter bound).
• Using a Fibonacci Heap (more complex, theoretical improvement): O(E+VlogV). This is asymptotically faster
for very dense graphs.
Kruskal's Algorithm
Kruskal's algorithm also builds an MST greedily, but it does so by considering edges in increasing order of their
weights. It adds an edge to the MST if it connects two previously disconnected components.
Analogy: Imagine you have a bunch of islands (vertices) and you want to connect them all with bridges (edges) as
cheaply as possible. You list all possible bridges with their costs. You then start building the cheapest bridge first,
then the next cheapest, and so on, as long as building that bridge doesn't create a loop (i.e., it doesn't connect two
islands that are already connected).
Algorithm Steps:
1. Initialization:
o Create a list of all edges in the graph, along with their weights.
o Initialize a Disjoint Set Union (DSU) data structure, where each vertex is initially in its own separate
set (representing individual components).
o num_edges_in_mst = 0.
2. Main Loop:
▪ Use the DSU find operation to determine the representative (root) of the sets containing u
and v.
▪ If find(u) is not equal to find(v) (meaning u and v are in different components and adding this
edge won't form a cycle):
▪ Use the DSU union operation to merge the sets containing u and v.
▪ Increment num_edges_in_mst.
▪ If num_edges_in_mst reaches V-1 (where V is the number of vertices), you've found the MST,
and you can stop.
3. Result:
Pseudocode:
Kruskal(Graph):
mst_edges = []
total_mst_weight = 0
num_edges_in_mst = 0
if num_edges_in_mst == Graph.num_vertices - 1:
dsu.union(u, v)
mst_edges.add((u, v, weight))
total_mst_weight += weight
num_edges_in_mst += 1
Time Complexity:
• DSU operations:
o With path compression and union by rank/size, these operations are nearly constant time (amortized
O(α(V)), where α is the inverse Ackermann function, which grows extremely slowly and is practically
less than 5 for any realistic V).
• Total Time Complexity: Dominated by sorting edges: O(ElogE) (or O(ElogV) since E≤V2⟹logE≤2logV).
Data Structure Min-Priority Queue (Binary Heap) Disjoint Set Union (DSU) / Union-Find
Best for Dense Graphs (where E≈V2) Sparse Graphs (where E≈V)
More complex due to priority queue Simpler to implement due to sorting + DSU
Implementation
management. logic.
Time
O(ElogV) (Binary Heap) O(ElogE) (dominated by sorting)
Complexity
If you need to build the MST from a specific When edges can be easily sorted (e.g., from a
Typical Use
point. list).
Export to Sheets
In summary:
• Prim's is generally better for dense graphs because its complexity becomes closer to O(V2) (as ElogV can
approach V2logV but for very dense graphs E is large so V2 is the dominant term, and V2 algorithms are fast).
• Kruskal's is generally better for sparse graphs because its complexity is dominated by sorting, and ElogE will
be smaller than Prim's ElogV when E is small relative to V2.
Both algorithms are greedy and produce a correct Minimum Spanning Tree. Their choice often comes down to the
graph's characteristics (dense vs. sparse) and the preference for implementation complexity.
Topological Sorting
A topological sort (or topological ordering) of a directed graph is a linear ordering of its vertices such that for every
directed edge (u,v) from vertex u to vertex v, u comes before v in the ordering.
Key Requirements:
• Directed Graph: Topological sorting only applies to directed graphs (where edges have a direction, like u→v).
• Acyclic Graph (DAG): Crucially, the graph must not contain any directed cycles. If there's a cycle (e.g.,
A→B→C→A), it's impossible to create a linear ordering where every u comes before its v, because in a cycle,
A must come before B, B before C, and C before A – a contradiction.
Intuition/Analogy:
A topological sort would give you a valid sequence of tasks to perform, such as A, B, C. You can't start B before A, or C
before B.
Examples:
• Course Prerequisites: If "Calculus I" is a prerequisite for "Calculus II," then "Calculus I" must appear before
"Calculus II" in a topological sort of your course schedule.
• Task Scheduling: Any set of tasks where some must be completed before others can begin.
• Dependency Resolution: In software packages, where installing one package requires others to be installed
first.
Important Notes:
There are two main algorithms for topological sorting, both with O(V+E) time complexity (where V is the number of
vertices and E is the number of edges):
This algorithm relies on the concept of in-degree. The in-degree of a vertex is the number of incoming edges to that
vertex.
Intuition: A vertex with an in-degree of 0 has no prerequisites; it can be the first task in the sequence. Once we
"complete" that task, we can remove it from consideration and decrement the in-degrees of its neighbors. Any
neighbor whose in-degree then becomes 0 can be the next task.
Algorithm Steps:
1. Calculate In-Degrees: For every vertex in the graph, compute its in-degree.
2. Initialize Queue: Create a queue and add all vertices with an in-degree of 0 to it. These are your starting
points.
3. Process Queue:
4. Check for Cycles: After the loop, if the number of vertices in your topological sort result list is less than the
total number of vertices in the graph, it means there was a cycle in the graph (and thus, a full topological sort
is not possible).
Pseudocode (Kahn's):
Kahn_TopologicalSort(Graph):
queue = Queue()
in_degree[v] = 0
in_degree[v] = in_degree[v] + 1
if in_degree[v] == 0:
queue.enqueue(v)
// 3. Process Queue
count_visited_nodes = 0
u = queue.dequeue()
result.add(u)
count_visited_nodes = count_visited_nodes + 1
in_degree[v] = in_degree[v] - 1
if in_degree[v] == 0:
queue.enqueue(v)
if count_visited_nodes != Graph.num_vertices:
else:
return result
2. DFS-based Algorithm
This method uses a recursive Depth-First Search traversal. The key idea is that in a DFS, you visit a node's children
(dependencies) before you finish processing the node itself. For a topological sort, we want the opposite: a node
should appear after all its dependencies. Therefore, we add a node to the sorted list after visiting all of its adjacent
nodes.
Intuition: Think about finishing a task. You can only truly "finish" a task once all its sub-tasks are done. If you're
traversing a dependency graph, when you've explored an entire branch of dependencies and are about to
"backtrack" from a node, it means all its prerequisites have been handled. At this point, you can safely add the node
to the front of your topological order (or to a stack and then reverse the stack).
Algorithm Steps:
1. Initialize:
o Create a visited set to keep track of visited vertices (to avoid redundant work and infinite loops in
case of cycles, though for a true DAG, cycles aren't an issue for correctness, just efficiency).
o Create a recursion_stack (or path_stack) to detect cycles during DFS (optional for DAGs, but good
practice).
o Create an empty topological_order list (this will store the result in reverse, so you'll need to reverse it
at the end, or use a stack and pop).
o Mark u as visited.
▪ If v is not visited:
▪ Else if v is in recursion_stack:
▪ This indicates a cycle! A topological sort is not possible. (This check is crucial if you're
not absolutely sure the input graph is a DAG).
o Prepend u to topological_order (or push onto a stack). This is the key step: u is added after all its
dependencies have been processed.
4. Result:
o The topological_order list (after potentially reversing it if you appended to it during DFS_Visit) will
contain the topological sort.
Pseudocode (DFS-based):
DFS_TopologicalSort(Graph):
visited = Set()
has_cycle = false
if v not in visited:
// For simplicity here, we'll assume it sets a global flag or raises an exception.
if has_cycle:
return []
// Reverse the list to get the correct topological order
reverse(topological_order)
return topological_order
visited.add(u)
recursion_stack.add(u)
if v not in visited:
return
return
Both algorithms are efficient and widely used. Kahn's algorithm is often preferred when you need to detect cycles
explicitly and/or when you want a non-recursive solution. The DFS-based approach is often more concise to
implement if recursion depth isn't an issue.
A flow network (or transportation network) is a directed graph G=(V,E) with the following characteristics:
1. Source (s): A distinguished vertex with only outgoing edges (no incoming edges). This is where the flow
originates.
2. Sink (t): A distinguished vertex with only incoming edges (no outgoing edges). This is where the flow
terminates.
3. Capacities (c(u, v)): Each edge (u,v)∈E has a non-negative capacity, c(u,v)≥0. This represents the maximum
amount of flow that can pass through that edge.
4. Flow (f(u, v)): For each edge (u,v), there is a flow f(u,v) such that:
o Capacity Constraint: 0≤f(u,v)≤c(u,v) (The flow on an edge cannot exceed its capacity).
o Skew Symmetry: f(u,v)=−f(v,u) (Flow from u to v is the negative of flow from v to u; often means you
don't allow flow in both directions, or if you do, net flow is conserved).
o Flow Conservation: For every vertex u∈V∖{s,t} (i.e., every vertex except the source and sink), the
total incoming flow must equal the total outgoing flow: (v,u)∈E∑f(v,u)=(u,w)∈E∑f(u,w) (What flows
into an intermediate node must flow out).
The value of a flow is the total flow leaving the source (which, by flow conservation, must also equal the total flow
entering the sink). The goal of many network flow problems is to find the maximum flow.
This is one of the most fundamental theorems in network flow, connecting the maximum flow in a network to the
minimum capacity of a cut.
• S-T Cut: An s-t cut (S,T) of a flow network G=(V,E) is a partition of the vertices V into two sets S and T=V∖S,
such that the source s∈S and the sink t∈T.
• Capacity of a Cut: The capacity of an s-t cut (S,T), denoted c(S,T), is the sum of the capacities of all edges that
go from a vertex in S to a vertex in T: c(S,T)=u∈S,v∈T,(u,v)∈E∑c(u,v)
Max-Flow Min-Cut Theorem: The maximum value of an s-t flow is equal to the minimum capacity of an s-t cut in the
graph.
This theorem is incredibly powerful because it provides a duality: solving a maximization problem (max flow) is
equivalent to solving a minimization problem (min cut).
The most famous and foundational algorithm for finding the maximum flow is:
1. Ford-Fulkerson Method
The Ford-Fulkerson method is a framework that repeatedly finds an augmenting path from the source to the sink in
the residual graph and increases the flow along this path until no more augmenting paths can be found.
• Residual Graph (Gf): For a given flow f in G, the residual graph represents the "remaining capacity" in the
network.
o For an edge (u,v) with capacity c(u,v) and current flow f(u,v):
▪ Forward edge: If f(u,v)<c(u,v), there's a forward edge (u,v) in Gf with residual capacity cf
(u,v)=c(u,v)−f(u,v). This allows us to push more flow from u to v.
▪ Backward edge: If f(u,v)>0, there's a backward edge (v,u) in Gf with residual capacity cf
(v,u)=f(u,v). This allows us to "undo" flow from u to v (effectively pushing flow from v to u),
which can be necessary to find better paths.
• Augmenting Path: A path from s to t in the residual graph. The bottleneck capacity of this path is the
minimum residual capacity of any edge on the path. This is the maximum amount of flow we can "push"
along this path.
1. Initialize Flow: Set the flow f(u,v)=0 for all edges (u,v)∈E.
2. While loop: While there exists an augmenting path P from s to t in the current residual graph Gf:
o Find the bottleneck capacity (or residual capacity) δ of path P. This is the minimum cf(u,v) for all
edges (u,v) in P.
flow[u][v] = 0
max_flow = 0
while true:
break
max_flow += bottleneck
u = path[i]
v = path[i+1]
flow[u][v] += bottleneck
else: // It must be a backward edge in the residual graph, meaning (v,u) is an original edge
flow[v][u] -= bottleneck
flow[u][v] += bottleneck // For skew symmetry and residual forward-edge
return max_flow
// ... (standard BFS to find path, store parent pointers to reconstruct path)
Complexity of Ford-Fulkerson: The complexity depends heavily on how FindAugmentingPath is implemented. In its
most general form (using arbitrary DFS), Ford-Fulkerson is not polynomial for graphs with irrational capacities.
However, for integer capacities, it is O(E⋅Fmax), where Fmax is the maximum flow value. This can be very slow if Fmax
is large.
2. Edmonds-Karp Algorithm
Edmonds-Karp is a specific implementation of the Ford-Fulkerson method where the augmenting path is always
found using Breadth-First Search (BFS). By using BFS, it guarantees that the shortest augmenting path (in terms of
number of edges) is found in each iteration. This seemingly small change leads to a much better worst-case time
complexity.
Identical to Ford-Fulkerson, but explicitly states that FindAugmentingPath must use BFS.
Complexity of Edmonds-Karp: O(VE2). This is a polynomial time complexity and makes it efficient for many practical
problems.
Dinic's algorithm is a more advanced and generally much faster algorithm for max flow, especially for dense graphs. It
significantly improves upon Edmonds-Karp by finding multiple augmenting paths simultaneously in layers of the
residual graph.
Key Concepts:
• Level Graph (or Layered Network): A directed acyclic graph (DAG) constructed from the residual graph using
BFS. It only contains edges (u,v) where the shortest path distance from s to v is exactly one greater than the
shortest path distance from s to u.
• Blocking Flow: A flow in the level graph such that every s-t path in the level graph contains at least one
saturated edge (an edge whose residual capacity becomes 0). Dinic's finds a blocking flow in each phase.
o Find Blocking Flow: Perform multiple DFS traversals from s in the level graph to find augmenting
paths. In each DFS, push as much flow as possible along the path (saturating edges) and backtrack.
Crucially, a DFS path never uses an edge that goes "down" a level (from level i to level i−1). Also, once
an edge is saturated in a DFS, it's removed (or its capacity becomes 0), so subsequent DFS traversals
won't use it in that phase. This step is optimized by using "dead ends" and avoiding re-exploration of
paths that cannot lead to t.
o Add the flow found in this phase to the total maximum flow.
Complexity of Dinic's:
• Unit capacity networks (where all capacities are 1): O(min(V2/3,E1/2)E) (very efficient)
Network flow algorithms are incredibly versatile and can model a wide range of problems:
• Minimum Cost Flow: Extending max flow to consider edge costs for flow (e.g., sending required flow at
minimum cost).
• Circulation Problems: Ensuring flow conservation in a network with lower bounds on capacities.
Network flow is a rich area, and understanding Max-Flow Min-Cut and algorithms like Edmonds-Karp and Dinic's is
fundamental for solving many complex optimization problems.
3. (a) Compare and contrast dynamic programming and greedy programming strategies. (5 marks)
Solves subproblems and stores their solutions to Makes a locally optimal choice at each step
Approach avoid re-computation. Builds the solution bottom-up with the hope that these choices will lead to a
or top-down (with memoization). globally optimal solution.
Often used for optimization problems that exhibit Used for optimization problems that exhibit
Problem Type overlapping subproblems and optimal greedy choice property and optimal
substructure. substructure.
Export to Sheets
Contrast:
• Dynamic programming explores multiple possibilities by solving overlapping subproblems and combining
their results to find the overall optimum. It's a more exhaustive approach.
• Greedy programming makes a single, irreversible choice at each step, hoping it will lead to the best overall
outcome. It's a more myopic approach.
3. (b) Compare and contrast Branch and Bound method and backtracking method. (5 marks)
Typically used for optimization problems (finding the Typically used for finding all solutions or a
Goal
best solution among many). feasible solution for a decision problem.
Uses bounding functions to prune branches that Prunes branches when it determines that the
Pruning cannot lead to a better solution than the current best- current partial solution cannot lead to a
known solution. valid/complete solution.
State Explores the state space tree, often maintaining a Explores the state space tree, trying to build a
Space lower/upper bound on the optimal solution. solution incrementally.
Solution Aims to find the global optimum. Aims to find any (or all) valid solutions.
Traveling Salesperson Problem (TSP), 0/1 Knapsack N-Queens, Sudoku Solver, Subset Sum (for finding
Example
(for finding optimal), Job Assignment. a subset), Hamiltonian Cycle.
Export to Sheets
Contrast:
• Objective: Branch and Bound is inherently focused on optimization by finding the best solution, while
Backtracking is more general, often used for finding any (or all) valid solutions.
• Pruning Mechanism: Branch and Bound uses calculated bounds to discard suboptimal paths. Backtracking
prunes when a path is determined to be invalid or incomplete.
• Search Strategy: While both explore a state-space tree, Branch and Bound often uses a more informed
search (like least-cost) to speed up finding the optimum, whereas Backtracking is typically a pure DFS.
3. (c) What do you understand by Heuristic characteristics? Also write their application domains. (5 marks)
Heuristic Characteristics:
A heuristic is a technique designed for solving a problem more quickly when classic methods are too slow, or for
finding an approximate solution when finding an exact solution is too computationally expensive or impossible.
Heuristics are not guaranteed to be optimal or even correct, but they are often good enough and practical.
• Not guaranteed optimality: A heuristic does not guarantee finding the best (optimal) solution.
• Not guaranteed correctness: It might not always find a correct solution, especially in complex or adversarial
scenarios.
• Speed/Efficiency: The primary goal is to find a "good enough" solution quickly, especially for computationally
hard problems.
• Exploration: They guide the search process by making "educated guesses" or rules of thumb.
• Problem-specific: Heuristics are often designed for a particular problem domain and may not be easily
transferable.
• Trade-off: There's a trade-off between the quality of the solution and the time/resources spent finding it.
Application Domains:
Heuristics are widely applied in various fields, especially in Artificial Intelligence and Operations Research:
o Search Algorithms: Used extensively in informed search algorithms like A* search, Best-First Search,
etc., to guide the search towards the goal in state-space graphs (e.g., in pathfinding, game playing
like chess).
o Expert Systems: Rules of thumb used by domain experts are often implemented as heuristics.
o Machine Learning: Heuristic functions can be used in feature selection, regularization, or even in
defining the loss functions.
2. Optimization Problems:
o Combinatorial Optimization: For NP-hard problems like the Traveling Salesperson Problem (TSP),
Vehicle Routing Problem (VRP), Knapsack Problem (for larger instances), heuristics (e.g., nearest
neighbor, genetic algorithms, simulated annealing) are used to find good approximate solutions.
o Scheduling: Allocating resources (e.g., jobs to machines, tasks to processors) efficiently.
3. Operations Research:
o Logistics and Supply Chain Management: Optimizing routes, inventory management, facility
location.
4. Computer Science:
o Compilers: Heuristics are used in compiler optimization (e.g., register allocation, instruction
scheduling).
o Network Routing: Determining the best paths for data packets in a network.