Facebook Pixel

2123. Minimum Operations to Remove Adjacent Ones in Matrix πŸ”’

Problem Description

You have a binary matrix (containing only 0s and 1s) where you can flip any 1 to become a 0. Your goal is to make the matrix "well-isolated" using the minimum number of flips.

A matrix is considered well-isolated when no two 1s are adjacent to each other horizontally or vertically (4-directionally connected). In other words, every 1 in the matrix should be completely surrounded by 0s in the four cardinal directions (up, down, left, right).

For example:

  • A matrix with 1s at positions that share an edge (horizontally or vertically adjacent) is NOT well-isolated
  • A matrix where all 1s are separated by at least one 0 in all four directions IS well-isolated
  • 1s that are diagonally adjacent are allowed (since we only consider 4-directional connectivity)

The problem asks you to find the minimum number of 1s that need to be flipped to 0 to achieve this well-isolated state.

The solution uses a bipartite matching approach by treating the grid as a checkerboard pattern where positions with (i + j) % 2 == 0 form one set and positions with (i + j) % 2 == 1 form another set. This works because adjacent cells always belong to different sets in this checkerboard pattern. The algorithm then finds the maximum matching between adjacent 1s in these two sets, which represents the minimum number of 1s that need to be removed to break all connections.

Quick Interview Experience
Help others by sharing your interview experience
Have you seen this problem before?

Intuition

The key insight is recognizing that this problem is about breaking connections between adjacent 1s. When two 1s are adjacent, we must flip at least one of them to make the grid well-isolated.

Think of the grid like a checkerboard where we color cells black and white alternately. Any cell at position (i, j) where (i + j) is even gets one color, and cells where (i + j) is odd get another color. The crucial observation is that adjacent cells always have different colors in this checkerboard pattern.

Now, consider pairs of adjacent 1s in the grid. Since adjacent cells have different colors, every pair of connected 1s forms an edge between a black cell and a white cell. This naturally creates a bipartite graph where:

  • One set contains all 1s on black cells
  • Another set contains all 1s on white cells
  • Edges exist between adjacent 1s

To make the grid well-isolated, we need to break all these edges. Breaking an edge means flipping at least one of the two 1s it connects. The question becomes: what's the minimum number of 1s to flip to break all edges?

This is exactly the Minimum Vertex Cover problem in bipartite graphs - finding the smallest set of vertices (1s to flip) such that every edge (connection between adjacent 1s) has at least one endpoint in this set.

By KΓΆnig's theorem, in a bipartite graph, the size of the minimum vertex cover equals the size of the maximum matching. A matching pairs up vertices from the two sets such that no vertex appears in more than one pair. The maximum matching gives us the optimal way to "cover" the most edges with the fewest vertices.

Therefore, we can solve this by finding the maximum matching in the bipartite graph formed by adjacent 1s, which directly gives us the minimum number of flips needed.

Learn more about Graph patterns.

Solution Approach

The implementation uses the Hungarian Algorithm (specifically, the augmenting path method) to find the maximum bipartite matching.

Step 1: Build the Bipartite Graph

We iterate through the grid and only consider cells where (i + j) % 2 == 1 (one partition of our checkerboard). For each 1 in this partition, we:

  • Convert the 2D position (i, j) to a 1D index: x = i * n + j
  • Check all four adjacent cells (up, down, left, right)
  • If an adjacent cell contains a 1, we add an edge in our graph g
for i, row in enumerate(grid):
    for j, v in enumerate(row):
        if (i + j) % 2 and v:  # Only process 1s in odd-sum positions
            x = i * n + j
            # Check all 4 directions and add edges to adjacent 1s

Step 2: Initialize Matching Array

We create a match array of size m * n initialized to -1. This array tracks which vertex from the even-sum partition is matched to each vertex from the odd-sum partition.

Step 3: Find Maximum Matching

For each vertex in our graph (1s at odd-sum positions), we try to find an augmenting path using DFS:

def find(i: int) -> int:
    for j in g[i]:  # Try all adjacent 1s
        if j not in vis:  # Not visited in current DFS
            vis.add(j)
            if match[j] == -1 or find(match[j]):  # j is unmatched or can be rematched
                match[j] = i
                return 1
    return 0

The find function implements the augmenting path search:

  • It tries to match vertex i with any of its neighbors j
  • If j is unmatched (match[j] == -1), we directly match them
  • If j is already matched to some other vertex, we recursively try to find an alternative match for that vertex
  • The vis set prevents cycles during a single augmenting path search

Step 4: Count the Matches

For each vertex in the odd-sum partition with edges, we call find and accumulate successful matches:

ans = 0
for i in g.keys():
    vis = set()  # Reset visited set for each new augmenting path search
    ans += find(i)

The final answer ans represents the maximum matching size, which by KΓΆnig's theorem equals the minimum vertex cover - the minimum number of 1s we need to flip to make the grid well-isolated.

Ready to land your dream job?

Unlock your dream job with a 5-minute evaluator for a personalized learning plan!

Start Evaluator

Example Walkthrough

Let's walk through a small example to illustrate the solution approach.

Consider this 3Γ—3 binary matrix:

1 1 0
1 1 1
0 1 0

Step 1: Identify the checkerboard pattern

We color cells based on (i + j) % 2:

  • Even sum (0): positions (0,0), (0,2), (1,1), (2,0), (2,2)
  • Odd sum (1): positions (0,1), (1,0), (1,2), (2,1)
E O E    (E = even, O = odd)
O E O
E O E

Our matrix with the pattern overlay:

1(E) 1(O) 0(E)
1(O) 1(E) 1(O)
0(E) 1(O) 0(E)

Step 2: Build the bipartite graph

We process 1s at odd-sum positions and find their adjacent 1s:

  • Position (0,1) has value 1, checks neighbors:

    • Up: out of bounds
    • Down: (1,1) has value 1 βœ“
    • Left: (0,0) has value 1 βœ“
    • Right: (0,2) has value 0
    • Adds edges: (0,1) β†’ {(1,1), (0,0)}
  • Position (1,0) has value 1, checks neighbors:

    • Up: (0,0) has value 1 βœ“
    • Down: (2,0) has value 0
    • Left: out of bounds
    • Right: (1,1) has value 1 βœ“
    • Adds edges: (1,0) β†’ {(0,0), (1,1)}
  • Position (1,2) has value 1, checks neighbors:

    • Up: (0,2) has value 0
    • Down: (2,2) has value 0
    • Left: (1,1) has value 1 βœ“
    • Right: out of bounds
    • Adds edges: (1,2) β†’ {(1,1)}
  • Position (2,1) has value 1, checks neighbors:

    • Up: (1,1) has value 1 βœ“
    • Down: out of bounds
    • Left: (2,0) has value 0
    • Right: (2,2) has value 0
    • Adds edges: (2,1) β†’ {(1,1)}

Converting to 1D indices (i * 3 + j):

  • (0,1) = 1, (1,0) = 3, (1,2) = 5, (2,1) = 7 [odd-sum vertices]
  • (0,0) = 0, (1,1) = 4 [even-sum vertices they connect to]

Graph g:

  • g[1] = {4, 0}
  • g[3] = {0, 4}
  • g[5] = {4}
  • g[7] = {4}

Step 3: Find maximum matching using augmenting paths

Initialize match array: match = [-1, -1, -1, -1, -1, -1, -1, -1, -1]

Process vertex 1:

  • Try to match with 4: unmatched, so match[4] = 1 βœ“
  • Return 1

Process vertex 3:

  • Try to match with 0: unmatched, so match[0] = 3 βœ“
  • Return 1

Process vertex 5:

  • Try to match with 4: already matched to 1
  • Try to rematch 1: can it find another match?
    • Vertex 1 tries 0: already matched to 3
    • Try to rematch 3: can it find another match?
      • Vertex 3 tries 4: visited in this path
      • Return 0
    • Return 0
  • Return 0

Process vertex 7:

  • Try to match with 4: already matched to 1
  • Try to rematch 1: can it find another match?
    • Vertex 1 tries 0: already matched to 3
    • Try to rematch 3: can it find another match?
      • Vertex 3 tries 4: visited in this path
      • Return 0
    • Return 0
  • Return 0

Step 4: Result

Maximum matching size = 2 (vertices 1↔4 and 3↔0 are matched)

This means we need to flip at least 2 ones to make the grid well-isolated. Looking at our original grid, the matching tells us an optimal solution: we can flip the 1s at positions (0,0) and (1,1), which breaks all adjacencies:

Original:

1 1 0
1 1 1
0 1 0

After flipping (0,0) and (1,1):

0 1 0
1 0 1
0 1 0

Now no two 1s are adjacent - the grid is well-isolated with 2 flips, which matches our algorithm's answer.

Solution Implementation

1class Solution:
2    def minimumOperations(self, grid: List[List[int]]) -> int:
3        def find_augmenting_path(source_node: int) -> int:
4            """
5            Find an augmenting path from source_node using DFS.
6            Returns 1 if a matching is found, 0 otherwise.
7            """
8            # Try to find an unmatched neighbor or augment existing matching
9            for neighbor in adjacency_list[source_node]:
10                if neighbor not in visited:
11                    visited.add(neighbor)
12                    # If neighbor is unmatched or we can find an augmenting path from its match
13                    if matching[neighbor] == -1 or find_augmenting_path(matching[neighbor]):
14                        matching[neighbor] = source_node
15                        return 1
16            return 0
17
18        # Build adjacency list for bipartite graph
19        adjacency_list = defaultdict(list)
20        rows, cols = len(grid), len(grid[0])
21      
22        # Process each cell in the grid
23        for row_idx, row in enumerate(grid):
24            for col_idx, value in enumerate(row):
25                # Only process cells where (row + col) is odd and cell value is non-zero
26                # This creates one partition of the bipartite graph
27                if (row_idx + col_idx) % 2 and value:
28                    # Convert 2D coordinates to 1D index
29                    current_cell = row_idx * cols + col_idx
30                  
31                    # Check bottom neighbor
32                    if row_idx < rows - 1 and grid[row_idx + 1][col_idx]:
33                        adjacency_list[current_cell].append(current_cell + cols)
34                  
35                    # Check top neighbor
36                    if row_idx and grid[row_idx - 1][col_idx]:
37                        adjacency_list[current_cell].append(current_cell - cols)
38                  
39                    # Check right neighbor
40                    if col_idx < cols - 1 and grid[row_idx][col_idx + 1]:
41                        adjacency_list[current_cell].append(current_cell + 1)
42                  
43                    # Check left neighbor
44                    if col_idx and grid[row_idx][col_idx - 1]:
45                        adjacency_list[current_cell].append(current_cell - 1)
46
47        # Initialize matching array (-1 means unmatched)
48        matching = [-1] * (rows * cols)
49        max_matching = 0
50      
51        # Find maximum matching using augmenting paths
52        for source_node in adjacency_list.keys():
53            visited = set()
54            max_matching += find_augmenting_path(source_node)
55      
56        return max_matching
57
1class Solution {
2    // Adjacency list for the bipartite graph
3    private Map<Integer, List<Integer>> adjacencyList = new HashMap<>();
4    // Visited set for DFS traversal during augmenting path search
5    private Set<Integer> visited = new HashSet<>();
6    // Array to store matching information (matched pairs)
7    private int[] matchedPartner;
8
9    public int minimumOperations(int[][] grid) {
10        int rows = grid.length;
11        int cols = grid[0].length;
12      
13        // Build bipartite graph by connecting adjacent cells with value 1
14        // Only process cells where (row + col) is odd to create bipartite sets
15        for (int row = 0; row < rows; ++row) {
16            for (int col = 0; col < cols; ++col) {
17                // Check if current cell belongs to the first set of bipartite graph
18                // and has value 1
19                if ((row + col) % 2 == 1 && grid[row][col] == 1) {
20                    // Convert 2D coordinates to 1D index
21                    int currentNode = row * cols + col;
22                  
23                    // Check all four adjacent cells and add edges if they have value 1
24                    // Check bottom neighbor
25                    if (row < rows - 1 && grid[row + 1][col] == 1) {
26                        adjacencyList.computeIfAbsent(currentNode, key -> new ArrayList<>())
27                                    .add(currentNode + cols);
28                    }
29                    // Check top neighbor
30                    if (row > 0 && grid[row - 1][col] == 1) {
31                        adjacencyList.computeIfAbsent(currentNode, key -> new ArrayList<>())
32                                    .add(currentNode - cols);
33                    }
34                    // Check right neighbor
35                    if (col < cols - 1 && grid[row][col + 1] == 1) {
36                        adjacencyList.computeIfAbsent(currentNode, key -> new ArrayList<>())
37                                    .add(currentNode + 1);
38                    }
39                    // Check left neighbor
40                    if (col > 0 && grid[row][col - 1] == 1) {
41                        adjacencyList.computeIfAbsent(currentNode, key -> new ArrayList<>())
42                                    .add(currentNode - 1);
43                    }
44                }
45            }
46        }
47      
48        // Initialize matching array with -1 (indicating no match)
49        matchedPartner = new int[rows * cols];
50        Arrays.fill(matchedPartner, -1);
51      
52        // Find maximum bipartite matching using augmenting path algorithm
53        int maxMatching = 0;
54        for (int node : adjacencyList.keySet()) {
55            // Try to find an augmenting path starting from each unmatched node
56            maxMatching += find(node);
57            // Clear visited set for next iteration
58            visited.clear();
59        }
60      
61        return maxMatching;
62    }
63
64    /**
65     * Finds an augmenting path using DFS from the given starting node
66     * @param startNode the node to start searching from
67     * @return 1 if an augmenting path is found, 0 otherwise
68     */
69    private int find(int startNode) {
70        // Explore all adjacent nodes
71        for (int adjacentNode : adjacencyList.get(startNode)) {
72            // Only process unvisited nodes in current DFS
73            if (visited.add(adjacentNode)) {
74                // If adjacent node is unmatched or we can find an augmenting path
75                // from its current match, then we found an augmenting path
76                if (matchedPartner[adjacentNode] == -1 || 
77                    find(matchedPartner[adjacentNode]) == 1) {
78                    // Update the matching
79                    matchedPartner[adjacentNode] = startNode;
80                    return 1;
81                }
82            }
83        }
84        // No augmenting path found
85        return 0;
86    }
87}
88
1class Solution {
2public:
3    int minimumOperations(vector<vector<int>>& grid) {
4        int rows = grid.size();
5        int cols = grid[0].size();
6      
7        // Store matching information: match[node] = matched partner or -1 if unmatched
8        vector<int> match(rows * cols, -1);
9      
10        // Track visited nodes during DFS for augmenting paths
11        unordered_set<int> visited;
12      
13        // Adjacency list representation of the bipartite graph
14        // Only store edges from odd-indexed cells (one partition of bipartite graph)
15        unordered_map<int, vector<int>> adjacencyList;
16      
17        // Build the bipartite graph
18        // Cells with (row + col) % 2 == 1 form one partition
19        // Connect them to adjacent cells with value 1
20        for (int row = 0; row < rows; ++row) {
21            for (int col = 0; col < cols; ++col) {
22                // Check if current cell belongs to odd partition and has value 1
23                if ((row + col) % 2 == 1 && grid[row][col] == 1) {
24                    int currentNode = row * cols + col;
25                  
26                    // Check bottom neighbor
27                    if (row < rows - 1 && grid[row + 1][col] == 1) {
28                        adjacencyList[currentNode].push_back(currentNode + cols);
29                    }
30                  
31                    // Check top neighbor
32                    if (row > 0 && grid[row - 1][col] == 1) {
33                        adjacencyList[currentNode].push_back(currentNode - cols);
34                    }
35                  
36                    // Check right neighbor
37                    if (col < cols - 1 && grid[row][col + 1] == 1) {
38                        adjacencyList[currentNode].push_back(currentNode + 1);
39                    }
40                  
41                    // Check left neighbor
42                    if (col > 0 && grid[row][col - 1] == 1) {
43                        adjacencyList[currentNode].push_back(currentNode - 1);
44                    }
45                }
46            }
47        }
48      
49        int totalMatches = 0;
50      
51        // DFS function to find augmenting path from node sourceNode
52        function<int(int)> findAugmentingPath = [&](int sourceNode) -> int {
53            // Try to match sourceNode with each of its neighbors
54            for (int& neighbor : adjacencyList[sourceNode]) {
55                // Skip if neighbor already visited in current DFS
56                if (!visited.count(neighbor)) {
57                    visited.insert(neighbor);
58                  
59                    // If neighbor is unmatched or we can find an augmenting path
60                    // from its current match, then we can match sourceNode with neighbor
61                    if (match[neighbor] == -1 || findAugmentingPath(match[neighbor])) {
62                        match[neighbor] = sourceNode;
63                        return 1;
64                    }
65                }
66            }
67            return 0;
68        };
69      
70        // Find maximum matching using augmenting paths
71        for (auto& [node, _] : adjacencyList) {
72            totalMatches += findAugmentingPath(node);
73            visited.clear();  // Clear visited set for next iteration
74        }
75      
76        return totalMatches;
77    }
78};
79
1/**
2 * Finds the minimum number of operations needed using bipartite matching
3 * @param grid - 2D grid of numbers
4 * @returns The minimum number of operations
5 */
6function minimumOperations(grid: number[][]): number {
7    const rows = grid.length;
8    const cols = grid[0].length;
9  
10    // Array to store matching pairs, -1 means unmatched
11    const matchedNodes: number[] = Array(rows * cols).fill(-1);
12  
13    // Set to track visited nodes during DFS
14    const visitedNodes: Set<number> = new Set();
15  
16    // Adjacency list to store graph connections
17    const adjacencyList: Map<number, number[]> = new Map();
18  
19    // Build bipartite graph for cells where (row + col) is odd and cell value is non-zero
20    for (let row = 0; row < rows; ++row) {
21        for (let col = 0; col < cols; ++col) {
22            // Only process cells with odd sum of coordinates and non-zero value
23            if ((row + col) % 2 && grid[row][col]) {
24                const currentNodeId = row * cols + col;
25                adjacencyList.set(currentNodeId, []);
26              
27                // Check bottom neighbor
28                if (row < rows - 1 && grid[row + 1][col]) {
29                    adjacencyList.get(currentNodeId)!.push(currentNodeId + cols);
30                }
31              
32                // Check top neighbor
33                if (row && grid[row - 1][col]) {
34                    adjacencyList.get(currentNodeId)!.push(currentNodeId - cols);
35                }
36              
37                // Check right neighbor
38                if (col < cols - 1 && grid[row][col + 1]) {
39                    adjacencyList.get(currentNodeId)!.push(currentNodeId + 1);
40                }
41              
42                // Check left neighbor
43                if (col && grid[row][col - 1]) {
44                    adjacencyList.get(currentNodeId)!.push(currentNodeId - 1);
45                }
46            }
47        }
48    }
49  
50    /**
51     * Finds an augmenting path using DFS for bipartite matching
52     * @param sourceNode - Starting node for path finding
53     * @returns 1 if augmenting path found, 0 otherwise
54     */
55    const findAugmentingPath = (sourceNode: number): number => {
56        // Try to find a match for each adjacent node
57        for (const adjacentNode of adjacencyList.get(sourceNode)!) {
58            if (!visitedNodes.has(adjacentNode)) {
59                visitedNodes.add(adjacentNode);
60              
61                // If adjacent node is unmatched or we can find alternate path for its match
62                if (matchedNodes[adjacentNode] === -1 || findAugmentingPath(matchedNodes[adjacentNode])) {
63                    matchedNodes[adjacentNode] = sourceNode;
64                    return 1;
65                }
66            }
67        }
68        return 0;
69    };
70  
71    let totalMatches = 0;
72  
73    // Find maximum matching by attempting to match each node in the bipartite set
74    for (const nodeId of adjacencyList.keys()) {
75        totalMatches += findAugmentingPath(nodeId);
76        visitedNodes.clear();
77    }
78  
79    return totalMatches;
80}
81

Time and Space Complexity

Time Complexity: O(m * n * E) where m and n are the dimensions of the grid, and E is the number of edges in the bipartite graph (which is O(m * n) in the worst case).

  • Building the graph g takes O(m * n) time as we iterate through each cell once and check at most 4 neighbors for each cell.
  • The matching algorithm (Hungarian/Ford-Fulkerson for bipartite matching) runs with:
    • Outer loop iterates through all keys in g, which is at most O(m * n / 2) (only cells where (i + j) % 2 is true and v is non-zero)
    • For each key, the find() function performs a DFS that can visit at most O(m * n) nodes in the worst case
    • Each edge is explored at most once per DFS call due to the vis set
  • Overall: O(m * n) + O(m * n) * O(m * n) = O((m * n)^2)

Space Complexity: O(m * n)

  • Graph g stores adjacency lists with total edges O(m * n) in worst case
  • match array uses O(m * n) space
  • vis set uses at most O(m * n) space during each DFS call
  • The recursion stack for find() can go up to O(m * n) depth in the worst case
  • Overall space complexity: O(m * n)

Learn more about how to find time and space complexity quickly.

Common Pitfalls

1. Incorrect Bipartite Graph Construction

A common mistake is building edges from both partitions of the checkerboard, which would create duplicate edges and lead to incorrect matching results.

Wrong approach:

# Processing ALL cells with value 1
for i in range(rows):
    for j in range(cols):
        if grid[i][j] == 1:  # Wrong: processes both partitions
            # Add edges to all adjacent 1s

Correct approach:

# Only process cells where (i + j) % 2 == 1
for i in range(rows):
    for j in range(cols):
        if (i + j) % 2 == 1 and grid[i][j] == 1:  # Correct: single partition
            # Add edges to adjacent 1s

2. Reusing the Visited Set Across Multiple Augmenting Path Searches

Each augmenting path search needs a fresh visited set. Reusing the same set would prevent finding valid augmenting paths in subsequent iterations.

Wrong approach:

visited = set()  # Single visited set for all searches
for source_node in adjacency_list.keys():
    max_matching += find_augmenting_path(source_node)  # Wrong: visited never cleared

Correct approach:

for source_node in adjacency_list.keys():
    visited = set()  # Fresh visited set for each search
    max_matching += find_augmenting_path(source_node)

3. Misunderstanding the Problem Requirements

Some might think we need to flip ALL 1s that have adjacent 1s, but we only need to flip the MINIMUM number to break all adjacencies.

Example:

1 1 1  β†’  We only need to flip the middle 1: 1 0 1

Not all three 1s need to be flipped!

4. Incorrect Index Conversion Between 2D and 1D

When converting between 2D grid coordinates and 1D indices, using the wrong dimension for multiplication causes incorrect neighbor identification.

Wrong approach:

current_cell = row_idx * rows + col_idx  # Wrong: should use cols, not rows

Correct approach:

current_cell = row_idx * cols + col_idx  # Correct: multiply by number of columns

5. Not Handling Edge Cases in Boundary Checks

Forgetting to check grid boundaries before accessing neighbors can cause index out of bounds errors.

Wrong approach:

# Checking neighbor without boundary validation
if grid[row_idx + 1][col_idx]:  # Can cause IndexError
    adjacency_list[current_cell].append(current_cell + cols)

Correct approach:

# Check boundary first
if row_idx < rows - 1 and grid[row_idx + 1][col_idx]:
    adjacency_list[current_cell].append(current_cell + cols)
Discover Your Strengths and Weaknesses: Take Our 5-Minute Quiz to Tailor Your Study Plan:

Which of the following shows the order of node visit in a Breadth-first Search?


Recommended Readings

Want a Structured Path to Master System Design Too? Don’t Miss This!

Load More