Skip to content

Latest commit

 

History

History
350 lines (260 loc) · 11.4 KB

File metadata and controls

350 lines (260 loc) · 11.4 KB

Depth-First and Breadth-First Search Algorithms in Python

Slide 1: Introduction to Graph Traversal

Graph traversal is a fundamental technique in computer science for exploring and analyzing graph structures. Two primary methods are Depth-First Search (DFS) and Breadth-First Search (BFS). These algorithms are essential for solving various problems, including pathfinding, connectivity analysis, and cycle detection.

class Graph:
    def __init__(self):
        self.graph = {}
    
    def add_edge(self, u, v):
        if u not in self.graph:
            self.graph[u] = []
        self.graph[u].append(v)

Slide 2: Depth-First Search (DFS) Overview

DFS is an algorithm that explores a graph by going as deep as possible along each branch before backtracking. It uses a stack data structure (or recursion) to keep track of nodes to visit next. DFS is often used for topological sorting, finding connected components, and maze solving.

def dfs(graph, start, visited=None):
    if visited is None:
        visited = set()
    visited.add(start)
    print(start, end=' ')
    
    for neighbor in graph[start]:
        if neighbor not in visited:
            dfs(graph, neighbor, visited)

Slide 3: DFS Implementation - Recursive Approach

The recursive approach to DFS is elegant and intuitive. It naturally uses the call stack to keep track of the nodes to visit. This implementation visits each node once and explores all paths from the starting node.

def dfs_recursive(graph, node, visited=None):
    if visited is None:
        visited = set()
    visited.add(node)
    print(node, end=' ')
    
    for neighbor in graph[node]:
        if neighbor not in visited:
            dfs_recursive(graph, neighbor, visited)

# Usage
graph = {0: [1, 2], 1: [2], 2: [3], 3: [1, 2]}
dfs_recursive(graph, 0)

Slide 4: DFS Implementation - Iterative Approach

An iterative approach to DFS uses an explicit stack to manage the nodes to visit. This method is useful when the recursion depth might be too large or when you need more control over the traversal process.

def dfs_iterative(graph, start):
    visited = set()
    stack = [start]
    
    while stack:
        node = stack.pop()
        if node not in visited:
            visited.add(node)
            print(node, end=' ')
            stack.extend(neighbor for neighbor in graph[node] if neighbor not in visited)

# Usage
graph = {0: [1, 2], 1: [2], 2: [3], 3: [1, 2]}
dfs_iterative(graph, 0)

Slide 5: Breadth-First Search (BFS) Overview

BFS explores a graph level by level, visiting all neighbors of a node before moving to the next level. It uses a queue data structure to keep track of nodes to visit. BFS is often used for finding the shortest path in unweighted graphs and in level-order traversal of trees.

from collections import deque

def bfs(graph, start):
    visited = set()
    queue = deque([start])
    visited.add(start)
    
    while queue:
        node = queue.popleft()
        print(node, end=' ')
        
        for neighbor in graph[node]:
            if neighbor not in visited:
                visited.add(neighbor)
                queue.append(neighbor)

Slide 6: BFS Implementation

This implementation of BFS uses a queue to manage the nodes to visit. It ensures that nodes are visited in order of their distance from the starting node, making it ideal for shortest path problems in unweighted graphs.

from collections import deque

def bfs(graph, start):
    visited = set()
    queue = deque([start])
    visited.add(start)
    
    while queue:
        node = queue.popleft()
        print(node, end=' ')
        
        for neighbor in graph[node]:
            if neighbor not in visited:
                visited.add(neighbor)
                queue.append(neighbor)

# Usage
graph = {0: [1, 2], 1: [2], 2: [3], 3: [1, 2]}
bfs(graph, 0)

Slide 7: Comparison of DFS and BFS

DFS and BFS have different characteristics that make them suitable for different problems. DFS is often simpler to implement recursively and uses less memory in trees. BFS is better for finding the shortest path in unweighted graphs and for level-order traversals.

def compare_dfs_bfs(graph, start):
    print("DFS:", end=' ')
    dfs_iterative(graph, start)
    print("\nBFS:", end=' ')
    bfs(graph, start)

# Usage
graph = {0: [1, 2], 1: [2], 2: [3], 3: [1, 2]}
compare_dfs_bfs(graph, 0)

Slide 8: Time and Space Complexity

Both DFS and BFS have a time complexity of O(V + E), where V is the number of vertices and E is the number of edges. The space complexity for DFS is O(h) where h is the height of the tree, while for BFS it's O(w) where w is the maximum width of the tree.

def analyze_complexity(graph):
    vertices = len(graph)
    edges = sum(len(neighbors) for neighbors in graph.values())
    print(f"Vertices: {vertices}, Edges: {edges}")
    print(f"Time Complexity: O({vertices} + {edges})")
    print(f"Space Complexity (DFS): O(h) where h is the height of the tree")
    print(f"Space Complexity (BFS): O(w) where w is the max width of the tree")

# Usage
graph = {0: [1, 2], 1: [2], 2: [3], 3: [1, 2]}
analyze_complexity(graph)

Slide 9: DFS Applications - Cycle Detection

DFS can be used to detect cycles in a graph. This is particularly useful in directed graphs where cycles can represent circular dependencies or infinite loops.

def has_cycle(graph):
    visited = set()
    rec_stack = set()
    
    def dfs_cycle(node):
        visited.add(node)
        rec_stack.add(node)
        
        for neighbor in graph[node]:
            if neighbor not in visited:
                if dfs_cycle(neighbor):
                    return True
            elif neighbor in rec_stack:
                return True
        
        rec_stack.remove(node)
        return False
    
    for node in graph:
        if node not in visited:
            if dfs_cycle(node):
                return True
    return False

# Usage
graph = {0: [1, 2], 1: [2], 2: [3], 3: [1]}
print("Graph has cycle:", has_cycle(graph))

Slide 10: BFS Applications - Shortest Path

BFS is ideal for finding the shortest path in unweighted graphs. It guarantees the first time a node is discovered is the shortest path to that node from the starting point.

def shortest_path(graph, start, end):
    visited = set()
    queue = deque([(start, [start])])
    
    while queue:
        (node, path) = queue.popleft()
        if node not in visited:
            visited.add(node)
            
            if node == end:
                return path
            
            for neighbor in graph[node]:
                if neighbor not in visited:
                    queue.append((neighbor, path + [neighbor]))
    
    return None

# Usage
graph = {0: [1, 2], 1: [2, 3], 2: [3], 3: []}
print("Shortest path:", shortest_path(graph, 0, 3))

Slide 11: DFS for Topological Sorting

Topological sorting is a linear ordering of vertices in a directed acyclic graph (DAG) such that for every directed edge (u, v), vertex u comes before v in the ordering. DFS is commonly used to perform topological sorting.

def topological_sort(graph):
    visited = set()
    stack = []
    
    def dfs(node):
        visited.add(node)
        for neighbor in graph[node]:
            if neighbor not in visited:
                dfs(neighbor)
        stack.append(node)
    
    for node in graph:
        if node not in visited:
            dfs(node)
    
    return stack[::-1]

# Usage
graph = {0: [1, 2], 1: [3], 2: [3], 3: []}
print("Topological order:", topological_sort(graph))

Slide 12: BFS for Level Order Traversal

BFS is naturally suited for level order traversal of trees or graphs. This is useful in scenarios where you need to process nodes level by level, such as in hierarchical structures.

def level_order_traversal(graph, start):
    visited = set()
    queue = deque([(start, 0)])
    result = []
    
    while queue:
        node, level = queue.popleft()
        if node not in visited:
            visited.add(node)
            
            if len(result) <= level:
                result.append([])
            result[level].append(node)
            
            for neighbor in graph[node]:
                if neighbor not in visited:
                    queue.append((neighbor, level + 1))
    
    return result

# Usage
graph = {0: [1, 2], 1: [3, 4], 2: [5], 3: [], 4: [], 5: []}
print("Level order traversal:", level_order_traversal(graph, 0))

Slide 13: DFS and BFS in Practice

Both DFS and BFS have numerous practical applications in computer science and beyond. They are fundamental to many advanced graph algorithms and are used in various domains such as social network analysis, recommendation systems, and web crawling.

def web_crawler(start_url, max_depth):
    visited = set()
    queue = deque([(start_url, 0)])
    
    while queue:
        url, depth = queue.popleft()
        if url not in visited and depth <= max_depth:
            print(f"Crawling: {url}")
            visited.add(url)
            
            # Simulating fetching links from the page
            links = ['https://example.com/page1', 'https://example.com/page2']
            for link in links:
                if link not in visited:
                    queue.append((link, depth + 1))

# Usage
web_crawler('https://example.com', 2)

Slide 14: Optimizing DFS and BFS

While the basic implementations of DFS and BFS are straightforward, there are various ways to optimize them for specific use cases. This can include using different data structures, pruning unnecessary paths, or implementing early termination conditions.

from heapq import heappush, heappop

def a_star(graph, start, goal, heuristic):
    open_set = [(0, start)]
    came_from = {}
    g_score = {start: 0}
    f_score = {start: heuristic(start, goal)}
    
    while open_set:
        current = heappop(open_set)[1]
        
        if current == goal:
            path = []
            while current in came_from:
                path.append(current)
                current = came_from[current]
            path.append(start)
            return path[::-1]
        
        for neighbor in graph[current]:
            tentative_g_score = g_score[current] + 1
            
            if neighbor not in g_score or tentative_g_score < g_score[neighbor]:
                came_from[neighbor] = current
                g_score[neighbor] = tentative_g_score
                f_score[neighbor] = g_score[neighbor] + heuristic(neighbor, goal)
                heappush(open_set, (f_score[neighbor], neighbor))
    
    return None

# Usage
graph = {0: [1, 2], 1: [3], 2: [3], 3: []}
heuristic = lambda a, b: abs(a - b)
print("A* path:", a_star(graph, 0, 3, heuristic))

Slide 15: Additional Resources

For more in-depth study of graph traversal algorithms and their applications, consider exploring the following resources:

  1. "Graph Algorithms in the Language of Linear Algebra" by J. Kepner and J. Gilbert (2011) ArXiv: https://arxiv.org/abs/0911.4574
  2. "Depth-First Search and Linear Graph Algorithms" by R. Tarjan (1972) SIAM Journal on Computing: https://epubs.siam.org/doi/10.1137/0201010
  3. "Introduction to Algorithms" by T. H. Cormen, C. E. Leiserson, R. L. Rivest, and C. Stein MIT Press (not available on ArXiv)

These resources provide a more theoretical foundation and advanced applications of graph traversal algorithms.