Lecture 9: Graph Algorithms and Dynamic Programming PDF
Document Details
Uploaded by EliteCarnelian3565
Halmstad University
2024
Tags
Related
- CP312 Algorithm Design and Analysis I Lecture Notes PDF
- CP312 Algorithm Design and Analysis I - Lecture 13 Graph Algorithms - Minimum Spanning Trees.PDF
- DAA 1, 2, 3 Units Notes PDF
- Analysis and Design of Algorithms Past Paper PDF - GUJARAT Technical University - Summer 2022
- Dynamic Programming PDF
- National University of Computer & Emerging Sciences Final Exam CS302 PDF
Summary
This document is a lecture on graph algorithms and dynamic programming, likely part of a computer science or machine learning course at Halmstad University. The lecture notes provide explanations, examples, and Python code snippets for graph representation and algorithms such as DFS and BFS.
Full Transcript
Python a Gateway to Machine Learning (DT8051) Lecture 9: Graph Algorithms and Dynamic Programming Halmstad University October 2024 In this lecture... Graph Optimization Problems Graph Search Algorithms (DFS and BFS...
Python a Gateway to Machine Learning (DT8051) Lecture 9: Graph Algorithms and Dynamic Programming Halmstad University October 2024 In this lecture... Graph Optimization Problems Graph Search Algorithms (DFS and BFS) Dynamic Programming Example Graph Problem Bridges of Königsberg: In the old days, Königsberg was the capital of the East Prussia It has an island in the middle and seven bridges as in the picture Is it possible to have a walk in this city and cross every bridge only once? Königsberg's Bridges! This problem has a very simple solution in graph theory First we should make a graph with landmasses as nodes and bridges as edges Then it is enough to check if every node has an even number of edges connected to it As you can see, it is not possible to take a walk and cross every bridge only once Graph Representation in Python To keep nodes in the memory we can associate an index number to each node, starting from 0 to N-1, where N is the number of nodes. There are two ways to keep the edge connections in memory: 1 - Adjacency matrix: If $M_{ij}$ is 0 there is no edge from node $i$ to node $j$ if $M_{ij}$ is 1 there is an edge from node $i$ to node $j$ 2 - Adjacency list: At position $i$ in the list there is another list that contains the index of the nodes to which node $i$ is connected An adjacency list is more memory efficient and adjacency matrix is more time efficient Graph Representation in Python For our purposes in this course adjacency lists are enough. Instead of using index numbers we use other identifiers to keep nodes in the memory. The identifier could be a number, string, or another type of object. To use arbitrary identifiers in an adjacency list we can take advantage of dictionaries in Python. Therefore the adjacency list becomes a dictionary that associates each node with a list of neighboring nodes. Graph Representation in Python Example for directed graph with edge weights: In : class Graph: def __init__(self,adjacency_list={}): self.adjacency_list = adjacency_list def add_node(self,node_id): if node_id not in self.adjacency_list: self.adjacency_list[node_id] = [] def add_edge(self, source, destination, weight): if source not in self.adjacency_list or destination not in self.adjacen raise Exception("Source or destination are not in the graph. Cannot self.adjacency_list[source].append((destination,weight)) def print_adjacency(self): for node in self.adjacency_list: print(f"{node}:{self.adjacency_list[node]}") def get_children(self,node_id): return self.adjacency_list[node] g = Graph() g.add_node(1) g.add_node(2) g.add_node(3) g.add_edge(1,3,weight = 5) g.add_edge(1,2,weight = 8) g.add_edge(2,3,weight = 14) g.print_adjacency() 1:[(3, 5), (2, 8)] 2:[(3, 14)] 3:[] Some Classic Graph Problems Shortest path: What is the shortest path from node a to node b in a graph? Shortest weighted path: If the graph is weighted what is the path with the shortest sum of weights on its edges? Minimum cut: A cut is a set of edges that if they are removed the nodes can be partitioned into two subsets with no edges between the nodes of the two parititions. Minimum cut is the set of edges with minimum sum of weights. Maximum clique: A clique in a graph is a set of nodes where each node in the set is connected to all other nodes in that set. Maximum clique is a clique with the most number of nodes possible. The Shortest Path Problem The shortest path problem has many efficient algorithmic solutions to it. However they are not the focus of this course. Here we show two simple graph search algorithms that can be used to find the shortest path: 1 - Deapth First Search (DFS) 2 - Breadth First Searth (BFS) These search algorithms start from a srouce node and search different paths from that node to other nodes in the graph until they find the desired destination or path. DFS tends to look at the longer (deeper) paths first, hence it is called depth first search. BFS tends to look at many but shorter paths first, hence it is calle breadth first search. The DFS Algorithm DFS starts from the source node and then explores the rest of the nodes one by one A stack data structure is used to help exploring the nodes At each step one node is taken out of the stack and is expanded if it is not expanded before All the children of the node are added to the stack The DFS Algorithm In a nutshell: 1- In DFS, first the source is added to the stack 2- Then repeatedly a node is taken out of the stack and expanded if it hasn't before 3- This process goes on until no nodes is left in the stack Example Shortest Path Problem Given the following graph find the shortest path from B to D The shortest path has the lowest sum of edge weights First we need to make a graph representation. With dictionaries we get: {"A":[("C", 12), ("D", 60)], "B":[("A", 10)], "C":[("B", 20), ("D", 32)], "D":[], "E":[("A",7)]} The DFS Algorithm An example implementation of the DFS algorithm The Graph class from before is now in the graph.py module. Therefore we can just import it. In : from graph import Graph def dfs(graph, source): stack = [(source,source)] # Add source to the stack. First element is the t expanded = set() # Set of expanded nodes node_shortest_path = {} while len(stack)>0: # while the stack is not empty node, path = stack.pop() # take a node print(path) if node not in expanded: # If it is not expanded expanded.add(node) for child, weight in graph.get_children(node): # add its children t stack.append((child,path+"->"+child)) g = Graph({"A":[("C", 12), ("D", 60)], "B":[("A", 10)], "C":[("B", 20), ("D", 3 dfs(graph = g, source = "B") B B->A B->A->D B->A->C B->A->C->D B->A->C->B The DFS Algorithm How should we adapt the DFS algorithm to find the shortest path from one node to another? We start from source Whenever we reach destination we have found a path from source to destination We should not expand the destination node. We do not need those paths In addition to the path we should keep track of the distance (sum of weights) The DFS Algorithm for Shortest Path An example implementation of the DFS algorithm modified to get shortest path In : from graph import Graph def dfs_shortest_path(graph, source, destination): stack = [(source,source,0)] # Add source to the stack expanded = set() shortest_path, shortest_dist = "", float("inf") # The shortest distance is while len(stack)>0: # While the stack is not empty node, path, dist = stack.pop() # Take a node print(path) if node == destination: # check if we have arrived at the destination # update the shortest path and shortest dist if necessary shortest_path, shortest_dist = (path, dist) if distA B->A->D B->A->C B->A->C->D B->A->C->B Shortest path: B->A->C->D 54 The BFS Algorithm for Shortest Path The BFS algorithm for shortest path is very similar to the DFS algorithm the only difference being that the stack should be replaced with a queue In a nutshell: 1 - In BFS, first the source is added to the queue 2 - Then repeatedly a node is taken out of the queue 3 - Whenever we reach destination we check if it is the shortest path so far 4 - Otherwise the node is expanded if it hasn't before 5 - The process from step 2 repeats until no nodes is left in the queue The BFS Algorithm for Shortest Path An example implementation of the BFS algorithm to find the shortest path In : from collections import deque from graph import Graph def bfs(graph, source, destination): queue = deque([(source,source,0)]) # Add source to the queue expanded = set() shortest_path, shortest_dist = "", float("inf") # The shortest distance is while len(queue)>0: # While the queue is not empty node, path, dist = queue.popleft() # Take a node print(path) if node == destination: # Update the shortest path if needed shortest_path, shortest_dist = (path, dist) if distA B->A->C B->A->D B->A->C->B B->A->C->D Shortest path: B->A->C->D 54 DFS and BFS visualization The DFS and BFS Algorithms The difference between DFS and BFS is mainly the data structure that is used In DFS we use a stack and in BFS we use a queue Because stack is a last-in-first-out data structure children from the most recently expanded nodes are visited first. This makes the algorithm check deeper (longer) paths first. Because queue is a first-on-first-out data structure children from the early expanded nodes are visited first. This makes the algorithm check shorter paths but keep nodes from many different paths in the queue. Time complexity of DFS and BFS Let us take V and E as the number of nodes and edges in a graph, what is the time complexity of DFS and BFS on this graph? In both these algorithms every node has to be expanded In both algorithms every edge is checked two times when expanding nodes at each end Therefore we will have $O(\text{V}+2\text{E})$ which is equal to $O(\text{V}+\text{E})$ since we can take out the coefficents from the big-O notation Dynamic Programing Dynamic programming can be used with problems that have an optimal sub-structure This means that it is possible to combine optimal solutions from local subproblems and combine them the get the global solution For example, merge sort has an optimal sub-structure Dynamic programming is befefical for problems that have overlapping subproblems It means that there are subproblems to our problem that have to be solved multiple times For example, merge sort does not have overlapping subproblems One of the problems that could benefit from dynamic programming is the 0/1 knapsack Dynamic Programing: A simple example Remember the Fibonacci Sequence problem: In : def fibonacci(a): print(f"Calculating Fibonacci({a})") if a == 0 or a == 1: return 1 else: return fibonacci(a-1) + fibonacci(a-2) fibonacci(5) Calculating Fibonacci(5) Calculating Fibonacci(4) Calculating Fibonacci(3) Calculating Fibonacci(2) Calculating Fibonacci(1) Calculating Fibonacci(0) Calculating Fibonacci(1) Calculating Fibonacci(2) Calculating Fibonacci(1) Calculating Fibonacci(0) Calculating Fibonacci(3) Calculating Fibonacci(2) Calculating Fibonacci(1) Calculating Fibonacci(0) Calculating Fibonacci(1) Out: 8 You can see there are overlapping subproblems that are solved multiple times Dynamic Programing To avoid solving the same subproblems repeatedly we can take two approaches in dynamic programming 1 - The Memoization approach 2 - The Tabular approach Practically these approaches are very similar, however they differ in how the problem is broken down into subproblems Memoization in Dynamic Programing The problem is broken down in a top down manner into subproblems Whenever a subproblem is solved the result is saved in the memory The result of subproblems that are already solved are taken from the memory instead of solving them again In : def fibonacci(a, memory=None): #fibonacci with memoization if memory==None: memory={} if a not in memory: print(f"Calculating Fibonacci({a})") if a == 0 or a == 1: memory[a] = 1 else: memory[a] = fibonacci(a-1, memory) + fibonacci(a-2, memory) return memory[a] fibonacci(5) Calculating Fibonacci(5) Calculating Fibonacci(4) Calculating Fibonacci(3) Calculating Fibonacci(2) Calculating Fibonacci(1) Calculating Fibonacci(0) Out: 8 The Tabular approach in Dynamic Programing As opposed to memoization the tabular method is a bottom-up approach The smallest subproblems are first addresses and their results are saved in the memory These results are repeatedly combined to get the results of bigger subproblems This trend is continued until we find the global solution In : def fibonacci(a): #fibonacci with memoization memory = *(a+1) # prepare memory for solutions from fibonacci(0) to fibo for i in range(2,a+1): memory[i] = memory[i-1] + memory[i-2] return memory[a] # take the last element as the result fibonacci(5) Out: 8 The Tabular Approach vs. Memoization The tabular approach: It requires more memory preparation and familiarity of the structure of the problem It is more common in problems that you have to solve all the subproblems to get the answer The memoization approach: It is more intuitive since you can use the modified recursive form of the problem However, it has more time overhead and more advanced datastructures (e.g. dictionaries) are needed It is more common in problems that you do not have to solve all the subproblems to get the answer Dynamic Programing: How much time do we save? The dynamic programming versions of fibonacci(n) regardless of approach is $\text{O}(n)$ This is because for every n we need to do constant time calculation for all numbers from 0 to n We saw that we do less calculations with dynamic programming But how much time exactly are we saving? Dynamic Programing: How much time do we save? Let us check how long it takes to find a fibonacci number without dynamic programming We can assume that calculating fibonacci(n) takes $T(n)$ time Then looking at the recursive function we can write $T(n) = T(n-1)+T(n-2)+C$, where $C$ is a constant number Since $T(n-2) < T(n-1)$, we can write: $T(n) < 2T(n-1) + C$ Also $T(n) < 4T(n-2) + 3C$, and $T(n) < 8T(n-3)+7C$ In general we can write: $T(n) < 2^{k}T(n-k) + (2^{k} -1)C$ Finally, we can write: $T(n) 2T(n-2) + C$ Also $T(n) > 4T(n-4) + 3C$, and $T(n) > 8T(n-6)+7C$ In general we can write: $T(n) > 2^{k}T(n-2k) + (2^{k} -1)C$ Finally, we can write: $T(n)>2^{(n-1)/2}(T(1)+C)-C = \text{O}(2^{n/2})$ Dynamic Programing: How much time do we save? Now we can write $\text{O}(2^{n/2})