Taro Logo

Is Graph Bipartite?

Medium
Asked by:
Profile picture
Profile picture
Profile picture
Profile picture
+7
More companies
Profile picture
Profile picture
Profile picture
Profile picture
Profile picture
Profile picture
Profile picture
96 views
Topics:
GraphsArrays

There is an undirected graph with n nodes, where each node is numbered between 0 and n - 1. You are given a 2D array graph, where graph[u] is an array of nodes that node u is adjacent to. More formally, for each v in graph[u], there is an undirected edge between node u and node v. The graph has the following properties:

  • There are no self-edges (graph[u] does not contain u).
  • There are no parallel edges (graph[u] does not contain duplicate values).
  • If v is in graph[u], then u is in graph[v] (the graph is undirected).
  • The graph may not be connected, meaning there may be two nodes u and v such that there is no path between them.

A graph is bipartite if the nodes can be partitioned into two independent sets A and B such that every edge in the graph connects a node in set A and a node in set B.

Return true if and only if it is bipartite.

Example 1:

Input: graph = [[1,2,3],[0,2],[0,1,3],[0,2]]
Output: false
Explanation: There is no way to partition the nodes into two independent sets such that every edge connects a node in one and a node in the other.

Example 2:

Input: graph = [[1,3],[0,2],[1,3],[0,2]]
Output: true
Explanation: We can partition the nodes into two sets: {0, 2} and {1, 3}.

Constraints:

  • graph.length == n
  • 1 <= n <= 100
  • 0 <= graph[u].length < n
  • 0 <= graph[u][i] <= n - 1
  • graph[u] does not contain u.
  • All the values of graph[u] are unique.
  • If graph[u] contains v, then graph[v] contains u.

Solution


Clarifying Questions

When you get asked this question in a real-life environment, it will often be ambiguous (especially at FAANG). Make sure to ask these questions in that case:

  1. What is the range of values for the nodes in the graph, and how large can the graph be (number of nodes and edges)?
  2. Is the graph guaranteed to be connected, or could it consist of multiple disconnected components? If it has multiple disconnected components, should I treat each component separately?
  3. Can the adjacency list contain duplicate entries (e.g., `graph[0]` contains `1` and `1` again)?
  4. Will the input graph always be valid (e.g., no self-loops, no out-of-bounds node indices)?
  5. What integer values can represent nodes in the graph? Are they guaranteed to be non-negative and contiguous starting from 0, or could there be gaps in node numbering?

Brute Force Solution

Approach

The brute force approach to determining if a graph is bipartite involves trying every possible way to color the graph using only two colors. We systematically explore each combination to see if any of them satisfy the bipartite property.

Here's how the algorithm would work step-by-step:

  1. Start by picking a color for the very first thing in the graph.
  2. Then, look at everything directly connected to it and give them the opposite color.
  3. If anything connected to something already colored gets the same color, this coloring method doesn't work, so move on.
  4. Otherwise, continue giving opposite colors to anything new that's connected to what's already been colored.
  5. If you run out of colors to give because things are already colored differently, this coloring method still doesn't work, so move on.
  6. Keep doing this, trying all the different ways you could have initially picked colors for things at the beginning.
  7. If you find at least one way to color everything according to the rules (opposite colors for connected things), then the graph is bipartite.

Code Implementation

def is_graph_bipartite_brute_force(graph):
    number_of_nodes = len(graph)

    # Try every possible coloring of the graph with two colors.
    for i in range(2**number_of_nodes):
        coloring = {}

        # Assign colors based on the current bitmask 'i'.
        for node_index in range(number_of_nodes):
            if (i >> node_index) & 1:
                coloring[node_index] = 0 # Color 0
            else:
                coloring[node_index] = 1 # Color 1

        is_bipartite = True

        # Check if the current coloring is valid.
        for node in range(number_of_nodes):

            #Iterate to all neighbors of a given node
            for neighbor in graph[node]:
                #Check color differences. 
                if node in coloring and neighbor in coloring and coloring[node] == coloring[neighbor]:
                    is_bipartite = False
                    break

            if not is_bipartite:
                break

        #If any coloring is bipartite, we're done
        if is_bipartite:
            return True

    return False

Big(O) Analysis

Time Complexity
O(2^n)The described brute force approach explores every possible 2-coloring of the graph's nodes. With n nodes, each node can be colored in 2 ways, leading to 2^n possible colorings. For each coloring, we need to verify if it satisfies the bipartite property by checking the colors of adjacent nodes which takes O(n) in the worst case. Therefore, the overall time complexity is O(n * 2^n). Ignoring the linear factor n, the runtime is dominated by the exponential growth of color combinations, making it O(2^n).
Space Complexity
O(2^N)The brute force approach, as described, explores every possible coloring of the graph. In the worst case, we're implicitly generating and testing all 2^N possible color assignments where N is the number of nodes in the graph. While the plain english explanation does not specify a specific data structure for storing color assignments, the exploration of all possibilities conceptually implies the generation of N sized arrays/lists to represent colorings, with 2^N such arrays being explored. The space to hold the current possible assignment may be considered O(N). However, we're doing this an exponential number of times. Therefore, the space complexity can be simplified to O(2^N).

Optimal Solution

Approach

We can efficiently determine if a graph is bipartite by systematically coloring the nodes. If we encounter a conflict in coloring, the graph isn't bipartite; otherwise, it is. We don't need to check every possible arrangement, making it faster than brute force.

Here's how the algorithm would work step-by-step:

  1. Start with an uncolored graph.
  2. Pick any starting node and color it with one color (say, color A).
  3. Look at all the neighbors (connected nodes) of this starting node and color them with the opposite color (color B).
  4. Now, look at the neighbors of the nodes you just colored with color B. Color them with color A.
  5. Continue this process, coloring each node with the opposite color of its neighbors.
  6. If you ever try to color a node that's already colored, check if the color you're about to give it is the same as its existing color. If it's different, it means there's a conflict, and the graph is not bipartite.
  7. If you can color the entire graph without any conflicts, then the graph is bipartite.
  8. If the graph has disconnected parts, repeat steps 2-7 for each disconnected part.

Code Implementation

def is_bipartite(graph):
    number_of_nodes = len(graph)
    node_colors = [0] * number_of_nodes

    def bfs(start_node):
        queue = [start_node]
        node_colors[start_node] = 1 # Assign the starting node a color.

        while queue:
            current_node = queue.pop(0)

            for neighbor_node in graph[current_node]:
                if node_colors[neighbor_node] == 0:
                    node_colors[neighbor_node] = -node_colors[current_node]

                    # Add to the queue for further exploration.
                    queue.append(neighbor_node)
                elif node_colors[neighbor_node] == node_colors[current_node]:
                    return False # Conflict: not bipartite.

        return True

    # Handle disconnected graphs.
    for node in range(number_of_nodes):
        if node_colors[node] == 0:
            if not bfs(node):
                return False

    # If no conflicts were found, the graph is bipartite.
    return True

Big(O) Analysis

Time Complexity
O(V + E)The algorithm visits each node (vertex) in the graph to color it, which takes O(V) time. For each node, it iterates through its adjacency list (edges) to color its neighbors and detect conflicts. The total length of all adjacency lists is equivalent to the number of edges, E, in the graph, taking O(E) time. Therefore, the overall time complexity is determined by the sum of visiting all vertices and traversing all edges, resulting in O(V + E).
Space Complexity
O(N)The algorithm utilizes a coloring array to store the color of each node. This array's size is directly proportional to the number of nodes in the graph, denoted as N. In the case of disconnected components, breadth-first search (BFS) or depth-first search (DFS) is performed, which uses a queue or call stack, respectively, that can hold up to N nodes in the worst-case scenario, where N is the number of nodes. Therefore, the auxiliary space complexity is dominated by the coloring array and the queue/stack used by BFS/DFS. This results in an auxiliary space complexity of O(N).

Edge Cases

Empty graph (graph with no nodes)
How to Handle:
Return true because an empty graph is considered bipartite.
Graph with only one node (isolated node)
How to Handle:
Return true as a single node graph is trivially bipartite.
Disconnected graph (multiple components)
How to Handle:
Iterate through each node, and if uncolored, start a BFS/DFS to color that component.
Graph with a self-loop (node connected to itself)
How to Handle:
If a node has a self-loop, the coloring logic during BFS/DFS will detect the conflict and return false.
Complete graph (every node connected to every other node)
How to Handle:
A complete graph with more than 2 nodes is not bipartite; the algorithm should correctly identify and return false.
Large graph with many nodes and edges, potential stack overflow with recursion
How to Handle:
Use iterative BFS instead of recursive DFS to avoid stack overflow issues on large graphs.
Graph with only two nodes connected to each other
How to Handle:
The algorithm should correctly color each node with a different color and return true.
A bipartite graph with nodes labeled with large numbers
How to Handle:
The coloring algorithm works independent of actual node values, focusing on connectivity thus large node numbers pose no issue.