3D render abstract digital visualization depicting neural networks and AI technology.

Graph

A graph is nothing but nodes connected through edges.

{ac2e5d22 6e12 44ed 931c 2e2a0c626e83}

Graph is about defyning relation between things. For example, nodes here are cities & edges would be roads connecting cities. Another example is : nodes here are cources & edges represent the prerequisites.
Graph could be of directed / undirected. What does that say?For example; in case of directed graph, you can travel from a to c, but can not travel from c to a. Where as in case of undirected graph, you can travel from a to c, as well as from c to a.

{105c700d c2b3 454b ac54 7ffb64555c9a}

Neighbour nodes of a:

{df662a13 5d5a 4976 8539 4c91227af3f7}

Programatic representation of graph

{fe56c18c 5887 411f b19d a4490cf2e56c}

There are 2 ways in which you can traverse the graph.
Depth first traversal — If you want to traverse in one direction in depth, go for it.
Breadth first traversal — If you don’t want to favour any one direction, instead explore neighbour nodes one after the another, go for it.

    {515e05d8 012d 4a18 90a2 5ea78280f377}
    Graph warmup

    Problem:
    The code of Depth frist traversal uses stack & Bredth first traverasl uses queue like we saw in case of Binary Tree.

    Depth first traversal – Iterative:

    def depth_first_print_iterative(graph, start):
        stack = [start]
        while len(stack) > 0:
            current = stack.pop()
            print(current)
            for neighbor in graph[current]:
                stack.append(neighbor)
    
    
    graph = {"a": ["b", "c"], "b": ["d"], "c": ["e"], "d": ["f"], "e": [], "f": []}
    
    depth_first_print_iterative(graph, "a")

    Depth first traversal – Recursive

    def depth_first_print_recursive(graph, start):
        print(start)
        for neighbor in graph[start]:
            depth_first_print_recursive(graph, neighbor)
    
    
    graph = {"a": ["b", "c"], "b": ["d"], "c": ["e"], "d": ["f"], "e": [], "f": []}
    
    depth_first_print_recursive(graph, "a")

    Breadth first traversal:

    def breadth_first_print(graph, start):
        queue = [start]
        while queue:
            current = queue.pop(0)
            print(current)
            for neighbor in graph[current]:
                queue.append(neighbor)
    
    
    graph = {"a": ["b", "c"], "b": ["d"], "c": ["e"], "d": ["f"], "e": [], "f": []}
    
    breadth_first_print(graph, "a")
    Has path

    Problem:
    Write a function, has_path, that takes in a dictionary representing the adjacency list of a directed acyclic graph and two nodes (srcdst). The function should return a boolean indicating whether or not there exists a directed path between the source and destination nodes.
    Hey. This is our first graph problem, so you should be liberal with watching the Approach and Walkthrough. Be productive, not stubborn. -AZ

    # test_00:
    graph = {
      'f': ['g', 'i'],
      'g': ['h'],
      'h': [],
      'i': ['g', 'k'],
      'j': ['i'],
      'k': []
    }
    
    has_path(graph, 'f', 'k') # True

    Solution:
    The key point is: you are never changing the dst node at any point of time. Each time you make a recursive call, you will pass the neighbour as your src node. So, you started at src node & kept exploring it’s neighbours. At one point of time, src == dst, which means there is a path from src to dst.

    def has_path(graph, src, dst):
        if src == dst:
            return True
    
        for neighbor in graph[src]:
            if has_path(graph, neighbor, dst) == True:
                return True
    
        return False
    Undirected path

    Problem:
    Write a function, undirected_path, that takes in a list of edges for an undirected graph and two nodes (node_Anode_B). The function should return a boolean indicating whether or not there exists a path between node_A and node_B.

    # test_00:
    edges = [
      ('i', 'j'),
      ('k', 'i'),
      ('m', 'k'),
      ('k', 'l'),
      ('o', 'n')
    ]
    
    undirected_path(edges, 'j', 'm') # -> True

    Solution:
    When you are doing undirected graph problems, you must look for cycles. For example, in this problem. There are 2 cycles.
    i, j & k
    o & n
    The problem with cycles is, once you enter into these cycles, you can’t come out. Keep revolving in it 🙂

    {e64f8f36 5813 4670 abec f4bb127045bb}

    Depth first

    def undirected_path(edges, node_A, node_B):
        graph = build_graph(edges)
        return has_path(graph, node_A, node_B, set())
    def build_graph(edges):
        graph = {}
        for edge in edges:
            a, b = edge
            if a not in graph:
                graph[a] = []
            if b not in graph:
                graph[b] = []
            graph[a].append(b)
            graph[b].append(a)
        return graph
    def has_path(graph, src, dst, visited):
        if src == dst:
            return True
        if src in visited:
            return False
        visited.add(src)
        for neighbor in graph[src]:
            if has_path(graph, neighbor, dst, visited) == True:
                return True
        return False

    Breadth first

    from collections import deque
    def undirected_path(edges, node_A, node_B):
        graph = build_graph(edges)
        return has_path(graph, node_A, node_B)
    def build_graph(edges):
        graph = {}
        for edge in edges:
            a, b = edge
            if a not in graph:
                graph[a] = []
            if b not in graph:
                graph[b] = []
            graph[a].append(b)
            graph[b].append(a)
        return graph
    def has_path(graph, src, dst):
        visited = {src}
        queue = deque([src])
        while queue:
            node = queue.popleft()
            if node == dst:
                return True
            for neighbor in graph[node]:
                if neighbor not in visited:
                    visited.add(neighbor)
                    queue.append(neighbor)
        return False
    Connected components count

    Problem:
    Write a function, connected_components_count, that takes in the adjacency list of an undirected graph. The function should return the number of connected components within the graph.

    # test_00:
    connected_components_count({
      0: [8, 1, 5],
      1: [0],
      5: [0, 8],
      8: [0, 5],
      2: [3, 4],
      3: [2, 4],
      4: [3, 2]
    }) # -> 2

    Solution:
    For each key of the graph, will explore it’s connected nodes using dfs → once this dfs finishes, will return count as 1. Inorder to avoid exploring the same nodes again, will use our visited = set() logic.
    For example; for the below graph, connected components count is 3

    Depth first

    def connected_components_count(graph):
        visited = set()
        count = 0
        for node in graph:
            if explore(graph, node, visited) == True:
                count += 1
        return count
    def explore(graph, current, visited):
        if current in visited:
            return False
        visited.add(current)
        for neighbor in graph[current]:
            explore(graph, neighbor, visited)
        return True

    Breadth first

    from collections import deque
    def connected_components_count(graph):
      visited = set()
      count = 0 
      for node in graph:
        if explore(graph, node, visited) == True:
          count += 1      
      return count
    def explore(graph, src, visited):
      if src in visited:
        return False
      visited.add(src)
      queue = deque([src])
      while queue:
        current = queue.popleft()
        for neighbor in graph[current]:
          if neighbor not in visited:
            visited.add(neighbor)
            queue.append(neighbor)
      return True
    Largest component

    Problem:
    Write a function, largest_component, that takes in the adjacency list of an undirected graph. The function should return the size of the largest connected component in the graph.

    # test_00:
    largest_component({
      0: [8, 1, 5],
      1: [0],
      5: [0, 8],
      8: [0, 5],
      2: [3, 4],
      3: [2, 4],
      4: [3, 2]
    }) # -> 4

    Solution:
    For each key of the graph, will explore it’s connected nodes using dfs → once this dfs finishes, will return count as 1. Inorder to avoid exploring the same nodes again, will use our visited = set() logic.
    For example; for the below graph, connected components count is 3

    {2724f73d eeea 441d a399 cc8e645b85fd}

    Depth first

    def connected_components_count(graph):
        visited = set()
        count = 0
        for node in graph:
            if explore(graph, node, visited) == True:
                count += 1
        return count
    def explore(graph, current, visited):
        if current in visited:
            return False
        visited.add(current)
        for neighbor in graph[current]:
            explore(graph, neighbor, visited)
        return True

    Breadth first

    from collections import deque
    def connected_components_count(graph):
      visited = set()
      count = 0 
      for node in graph:
        if explore(graph, node, visited) == True:
          count += 1      
      return count
    def explore(graph, src, visited):
      if src in visited:
        return False
      visited.add(src)
      queue = deque([src])
      while queue:
        current = queue.popleft()
        for neighbor in graph[current]:
          if neighbor not in visited:
            visited.add(neighbor)
            queue.append(neighbor)
      return True
    Shortest path

    Problem:
    Write a function, shortest_path, that takes in a list of edges for an undirected graph and two nodes (node_Anode_B). The function should return the length of the shortest path between A and B. Consider the length as the number of edges in the path, not the number of nodes. If there is no path between A and B, then return -1. You can assume that A and B exist as nodes in the graph.

    # test_00:
    edges = [
      ['w', 'x'],
      ['x', 'y'],
      ['z', 'y'],
      ['z', 'v'],
      ['w', 'v']
    ]
    shortest_path(edges, 'w', 'z') # -> 2

    Solution:
    If you solve this problem using DFS, then the first path you find is not the shortest path. Hence, you need to implement shortest path logic. Where as if you do the BFS; then the first path you are going to find is the shortest path.

    Breadth first

    from collections import deque
    def shortest_path(edges, node_A, node_B):
        graph = build_graph(edges)
        visited = set([node_A])
        queue = deque([(node_A, 0)])
        while queue:
            node, distance = queue.popleft()
            if node == node_B:
                return distance
            for neighbor in graph[node]:
                if neighbor not in visited:
                    visited.add(neighbor)
                    queue.append((neighbor, distance + 1))
        return -1
    def build_graph(edges):
        graph = {}
        for edge in edges:
            a, b = edge
            if a not in graph:
                graph[a] = []
            if b not in graph:
                graph[b] = []
            graph[a].append(b)
            graph[b].append(a)
        return graph
    Island count

    Problem:
    Write a function, island_count, that takes in a grid containing Ws and Ls. W represents water and L represents land. The function should return the number of islands on the grid. An island is a vertically or horizontally connected region of land.

    # test_00:
    grid = [
      ['W', 'L', 'W', 'W', 'W'],
      ['W', 'L', 'W', 'W', 'W'],
      ['W', 'W', 'W', 'L', 'W'],
      ['W', 'W', 'L', 'L', 'W'],
      ['L', 'W', 'W', 'L', 'L'],
      ['L', 'L', 'W', 'W', 'W'],
    ]
    
    island_count(grid) # -> 3

    Solution:
    Grid is represented as list of lists where each list corresponds to an row in grid. Hence, any node on the grid can be accessed with index numbers as grid[3][4].
    Once you are at certain node, the surrounding nodes can be accessed as shown below:

    {a8a6dade 65aa 4296 833f 0f0a584e7b69}

    Logic of code: Will start with iterative code to navigate through whole grid & during that process will see whether we encounter land / water cell.
    If we come across land cell, we don’t do anything….. Eckhart tolle
    Instead if we encounter water cell, will do work, but don’t except results….. again spirituality is looking into my code 🙂 That work we do is dfs. Will explore the island we found completly using the dfs in an helper function & make that fn return 1 to indicate that we just explored an island completly.

    Depth first

    def island_count(grid):
        visited = set()
        count = 0
        for r in range(len(grid)):
            for c in range(len(grid[0])):
                if explore(grid, r, c, visited) == True:
                    count += 1
        return count
    def explore(grid, r, c, visited):
        row_inbounds = 0 <= r < len(grid)
        col_inbounds = 0 <= c < len(grid[0])
        if not row_inbounds or not col_inbounds:
            return False
        if grid[r][c] == "W":
            return False
        pos = (r, c)
        if pos in visited:
            return False
        visited.add(pos)
        explore(grid, r - 1, c, visited)
        explore(grid, r + 1, c, visited)
        explore(grid, r, c - 1, visited)
        explore(grid, r, c + 1, visited)
        return True

    Breadth first

    from collections import deque
    def island_count(grid):
        visited = set()
        count = 0
        for r in range(len(grid)):
            for c in range(len(grid[0])):
                if explore(grid, r, c, visited) == True:
                    count += 1
        return count
    def explore(grid, r, c, visited):
        pos = (r, c)
        if pos in visited or grid[r][c] == "W":
            return False
        visited.add(pos)
        deltas = [(1, 0), (-1, 0), (0, 1), (0, -1)]
        queue = deque([pos])
        while queue:
            currentR, currentC = queue.popleft()
            for delta in deltas:
                dRow, dCol = delta
                neighborR = dRow + currentR
                neighborC = dCol + currentC
                neighborPos = (neighborR, neighborC)
                row_inbounds = 0 <= neighborR < len(grid)
                col_inbounds = 0 <= neighborC < len(grid[0])
                if (
                    neighborPos not in visited
                    and row_inbounds
                    and col_inbounds
                    and grid[neighborR][neighborC] == "L"
                ):
                    queue.append(neighborPos)
                    visited.add(neighborPos)
        return True
    Minimum island

    Problem:
    Write a function, minimum_island, that takes in a grid containing Ws and Ls. W represents water and L represents land. The function should return the size of the smallest island. An island is a vertically or horizontally connected region of land.
    You may assume that the grid contains at least one island.

    # test_00:
    grid = [
      ['W', 'L', 'W', 'W', 'W'],
      ['W', 'L', 'W', 'W', 'W'],
      ['W', 'W', 'W', 'L', 'W'],
      ['W', 'W', 'L', 'L', 'W'],
      ['L', 'W', 'W', 'L', 'L'],
      ['L', 'L', 'W', 'W', 'W'],
    ]
    
    minimum_island(grid) # -> 2

    Solution:

    Depth first

    def minimum_island(grid):
        visited = set()
        min_size = float("inf")
        for r in range(len(grid)):
            for c in range(len(grid[0])):
                size = explore_size(grid, r, c, visited)
                if size > 0 and size < min_size:
                    min_size = size
        return min_size
    def explore_size(grid, r, c, visited):
        row_inbounds = 0 <= r < len(grid)
        col_inbounds = 0 <= c < len(grid[0])
        if not row_inbounds or not col_inbounds:
            return 0
        if grid[r][c] == "W":
            return 0
        pos = (r, c)
        if pos in visited:
            return 0
        visited.add(pos)
        size = 1
        size += explore_size(grid, r - 1, c, visited)
        size += explore_size(grid, r + 1, c, visited)
        size += explore_size(grid, r, c - 1, visited)
        size += explore_size(grid, r, c + 1, visited)
        return size

    Breadth first

    def minimum_island(grid):
        visited = set()
        min_size = float("inf")
        for r in range(len(grid)):
            for c in range(len(grid[0])):
                size = explore_size(grid, r, c, visited)
                if size > 0 and size < min_size:
                    min_size = size
        return min_size
    def explore_size(grid, r, c, visited):
        row_inbounds = 0 <= r < len(grid)
        col_inbounds = 0 <= c < len(grid[0])
        if not row_inbounds or not col_inbounds:
            return 0
        if grid[r][c] == "W":
            return 0
        pos = (r, c)
        if pos in visited:
            return 0
        visited.add(pos)
        size = 1
        size += explore_size(grid, r - 1, c, visited)
        size += explore_size(grid, r + 1, c, visited)
        size += explore_size(grid, r, c - 1, visited)
        size += explore_size(grid, r, c + 1, visited)
        return size