Facebook Pixel

2152. Minimum Number of Lines to Cover Points 🔒

MediumBit ManipulationGeometryArrayHash TableMathDynamic ProgrammingBacktrackingBitmask
Leetcode Link

Problem Description

You are given a set of points on a 2D coordinate plane, where each point is represented as [x, y] coordinates. Your task is to find the minimum number of straight lines needed to cover all the given points.

A point is considered "covered" by a line if it lies on that line. Each point must be covered by at least one line, but a single line can cover multiple points if they are collinear (lie on the same straight line).

Key requirements:

  • Every point in the input array must be covered by at least one line
  • You want to minimize the total number of lines used
  • Lines can be placed at any angle and position to cover the points

Example scenarios:

  • If all points are collinear, you only need 1 line
  • If you have 3 points forming a triangle, you need at least 2 lines (one line can cover 2 points, and another line for the third point)
  • The challenge is to find the optimal grouping of points into lines to minimize the total count

The solution uses dynamic programming with bit masking to track which points have been covered, and tries different combinations of points to form lines. The check function determines if three points are collinear using the cross-product formula: (x2-x1)*(y3-y1) == (x3-x1)*(y2-y1).

Flowchart Walkthrough

First, let's pin down the algorithm using the Flowchart. Here's a step-by-step walkthrough:

Is it a graph?

  • No: While we have points on a 2D plane, this is not a traditional graph problem with nodes and edges. We're looking for lines to cover points, not traversing a graph structure.

Need to solve for kth smallest/largest?

  • No: We're looking for the minimum number of lines, not the kth smallest/largest element.

Involves Linked Lists?

  • No: The problem uses an array of coordinate points, not linked lists.

Does the problem have small constraints?

  • Yes: Looking at the solution code, it uses bit masking with (1 << n) - 1 and tries all possible combinations of points. This approach is only feasible with small constraints (typically n ≤ 20), as the state space grows exponentially.

Brute force / Backtracking?

  • Yes: The solution uses a backtracking approach with memoization. It:
    • Tries different combinations of points to form lines
    • Uses bit masking to track which points have been covered (state)
    • Recursively explores all possible ways to cover remaining points
    • Backtracks to try different line configurations
    • Returns the minimum number of lines needed

Conclusion: The flowchart correctly identifies this as a backtracking problem. The small constraints allow us to explore all possible ways to group points into lines, using bit masking to track covered points and dynamic programming (memoization) to optimize the backtracking process.

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

Intuition

When we need to cover all points with the minimum number of lines, we face a challenging optimization problem. The key insight is that we want to maximize the number of points each line covers - if three or more points are collinear, we should use a single line for all of them rather than multiple lines.

The natural approach is to try all possible ways of grouping points into lines. But how do we systematically explore all possibilities? This is where backtracking comes in. We can think of the problem as making decisions: for each uncovered point, we decide which line to use for it.

Since we need to track which points have been covered, we can use bit masking - each bit in an integer represents whether a point has been covered (1) or not (0). This gives us a compact way to represent the state of our problem.

The algorithm works by:

  1. Starting with no points covered (state = 0)
  2. Finding the first uncovered point
  3. Trying to create a line with this point and another uncovered point
  4. Checking if any other uncovered points lie on this same line (using the collinearity check: (x2-x1)*(y3-y1) == (x3-x1)*(y2-y1))
  5. Marking all collinear points as covered in our new state
  6. Recursively solving for the remaining uncovered points

The beauty of this approach is that it naturally finds optimal groupings - when we create a line, we immediately include all points that can lie on it, maximizing each line's utility. The memoization (@cache) ensures we don't recalculate the same state multiple times, turning our backtracking into dynamic programming.

The special case where i == n - 1 handles the situation where we have a single uncovered point left - it needs its own line since we've already tried pairing it with all other points.

Learn more about Math, Dynamic Programming, Backtracking and Bitmask patterns.

Solution Approach

The solution uses dynamic programming with bit masking and backtracking to find the minimum number of lines needed to cover all points.

Core Data Structures:

  • Bit mask (state): An integer where each bit represents whether a point is covered (1) or uncovered (0)
  • Memoization cache: Stores results for previously computed states to avoid redundant calculations

Key Helper Function:

def check(i, j, k):
    x1, y1 = points[i]
    x2, y2 = points[j]
    x3, y3 = points[k]
    return (x2 - x1) * (y3 - y1) == (x3 - x1) * (y2 - y1)

This function checks if three points are collinear using the cross-product formula. If the cross product equals zero, the points lie on the same line.

Main Algorithm - DFS with Memoization:

  1. Base Case: When state == (1 << n) - 1, all points are covered (all bits are 1), so we return 0.

  2. Recursive Case: For each state, we:

    • Find the first uncovered point i (where bit i is 0)
    • Try pairing it with every other uncovered point j where j > i
    • Create a new state with points i and j marked as covered: nxt = state | 1 << i | 1 << j
  3. Maximize Line Coverage: After selecting two points to form a line:

    • Check all remaining uncovered points k where k > j
    • If point k is collinear with points i and j, add it to the same line: nxt |= 1 << k
    • This ensures we use each line to cover as many points as possible
  4. Handle Single Points: The special case if i == n - 1 handles when only one point remains uncovered. Since we've already tried pairing it with all other points, it needs its own line.

  5. Optimization: The function tracks the minimum number of lines across all possible ways to cover the points: ans = min(ans, dfs(nxt) + 1)

Time Complexity: O(2^n × n^3) where n is the number of points. We have 2^n possible states, and for each state, we potentially check O(n^3) combinations of points.

Space Complexity: O(2^n) for the memoization cache storing results for each possible state.

The algorithm systematically explores all valid ways to group points into lines while using memoization to avoid recalculating overlapping subproblems, ensuring we find the optimal solution efficiently for small constraints.

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 concrete example with 4 points: [[0,0], [1,1], [2,2], [0,2]]

Points visualization:

(0,2) •       • (2,2)
    
    
(0,0) •-------• (1,1)

Notice that points [0,0], [1,1], and [2,2] are collinear (they lie on the line y = x), while [0,2] is not on this line.

Initial State: state = 0000 (binary), meaning no points are covered yet.

Step 1: dfs(0000)

  • Find first uncovered point: i = 0 (point [0,0])
  • Try pairing with point j = 1 (point [1,1])
  • Create initial line state: nxt = 0011 (covering points 0 and 1)
  • Check if remaining points are collinear:
    • Check point k = 2 ([2,2]): Using the collinearity formula:
      • (1-0) * (2-0) == (2-0) * (1-0)1 * 2 == 2 * 12 == 2
      • Point 2 is collinear! Update nxt = 0111
    • Check point k = 3 ([0,2]):
      • (1-0) * (2-0) == (0-0) * (1-0)1 * 2 == 0 * 12 == 0
      • Point 3 is not collinear
  • Result: One line covers points 0, 1, and 2. State becomes 0111
  • Recursively call: 1 + dfs(0111)

Step 2: dfs(0111)

  • Find first uncovered point: i = 3 (point [0,2])
  • Since i == n - 1 (last point), it needs its own line
  • Return: 1 + dfs(1111)

Step 3: dfs(1111)

  • All points covered (state = 1111)
  • Return: 0

Backtracking and trying other combinations:

The algorithm also explores other ways to group points:

  • Pairing [0,0] with [0,2] (vertical line), then handling [1,1] and [2,2]
  • Pairing [0,0] with [2,2] directly, then handling [1,1] and [0,2] separately
  • And so on...

After trying all combinations, the minimum found is 2 lines:

  1. One line through [0,0], [1,1], [2,2]
  2. One line through [0,2]

The memoization cache ensures that when we encounter state 0111 again through different paths, we don't recalculate it.

Solution Implementation

1class Solution:
2    def minimumLines(self, points: List[List[int]]) -> int:
3        """
4        Find the minimum number of lines needed to cover all points.
5        Uses dynamic programming with bitmask to track covered points.
6        """
7      
8        def are_collinear(point_i: int, point_j: int, point_k: int) -> bool:
9            """
10            Check if three points are collinear using cross product.
11            Points are collinear if (p2-p1) × (p3-p1) = 0
12            """
13            x1, y1 = points[point_i]
14            x2, y2 = points[point_j]
15            x3, y3 = points[point_k]
16          
17            # Cross product equals zero means points are collinear
18            return (x2 - x1) * (y3 - y1) == (x3 - x1) * (y2 - y1)
19      
20        @cache
21        def find_min_lines(covered_mask: int) -> int:
22            """
23            Find minimum lines needed to cover remaining points.
24          
25            Args:
26                covered_mask: Bitmask representing which points are already covered
27              
28            Returns:
29                Minimum number of lines needed
30            """
31            # All points covered - base case
32            if covered_mask == (1 << num_points) - 1:
33                return 0
34          
35            min_lines_needed = float('inf')
36          
37            # Try to draw a line through uncovered points
38            for first_point in range(num_points):
39                # Skip if point is already covered
40                if covered_mask >> first_point & 1:
41                    continue
42                  
43                # Try pairing with another uncovered point
44                for second_point in range(first_point + 1, num_points):
45                    # Create new mask with these two points covered
46                    new_mask = covered_mask | (1 << first_point) | (1 << second_point)
47                  
48                    # Check if any other uncovered points lie on the same line
49                    for third_point in range(second_point + 1, num_points):
50                        if not (new_mask >> third_point & 1) and are_collinear(first_point, second_point, third_point):
51                            # Add this collinear point to the line
52                            new_mask |= (1 << third_point)
53                  
54                    # Recursively find minimum for remaining points
55                    min_lines_needed = min(min_lines_needed, find_min_lines(new_mask) + 1)
56              
57                # Handle case where this is the last uncovered point
58                # (need a line through just this single point)
59                if first_point == num_points - 1:
60                    min_lines_needed = min(min_lines_needed, find_min_lines(covered_mask | (1 << first_point)) + 1)
61                  
62                # Once we've found an uncovered point, we've tried all possibilities starting from it
63                break
64          
65            return min_lines_needed
66      
67        num_points = len(points)
68      
69        # Start with no points covered (mask = 0)
70        return find_min_lines(0)
71
1class Solution {
2    // Memoization array to store results for each state
3    private int[] memo;
4    // Store the input points array
5    private int[][] points;
6    // Number of points
7    private int n;
8
9    /**
10     * Finds the minimum number of straight lines needed to pass through all points
11     * @param points 2D array where each element represents a point [x, y]
12     * @return minimum number of lines needed
13     */
14    public int minimumLines(int[][] points) {
15        n = points.length;
16        this.points = points;
17        // Initialize memoization array with size 2^n for all possible states
18        memo = new int[1 << n];
19        // Start DFS with initial state 0 (no points covered)
20        return dfs(0);
21    }
22
23    /**
24     * Recursive function with memoization to find minimum lines needed
25     * @param state bitmask representing which points have been covered
26     * @return minimum number of lines needed to cover remaining points
27     */
28    private int dfs(int state) {
29        // Base case: all points are covered
30        if (state == (1 << n) - 1) {
31            return 0;
32        }
33      
34        // Return memoized result if already computed
35        if (memo[state] != 0) {
36            return memo[state];
37        }
38      
39        // Initialize result with a large value
40        int minLines = 1 << 30;
41      
42        // Try to form lines starting from uncovered points
43        for (int i = 0; i < n; ++i) {
44            // Skip if point i is already covered
45            if (((state >> i) & 1) == 0) {
46                // Try to form a line with point i and another point j
47                for (int j = i + 1; j < n; ++j) {
48                    // Create new state with points i and j covered
49                    int nextState = state | (1 << i) | (1 << j);
50                  
51                    // Check if other uncovered points are collinear with i and j
52                    for (int k = j + 1; k < n; ++k) {
53                        if (((state >> k) & 1) == 0 && check(i, j, k)) {
54                            // If point k is collinear, add it to the same line
55                            nextState |= (1 << k);
56                        }
57                    }
58                  
59                    // Update minimum lines needed
60                    minLines = Math.min(minLines, dfs(nextState) + 1);
61                }
62              
63                // Special case: if this is the last point, it can form a line by itself
64                if (i == n - 1) {
65                    minLines = Math.min(minLines, dfs(state | (1 << i)) + 1);
66                }
67            }
68        }
69      
70        // Memoize and return the result
71        memo[state] = minLines;
72        return minLines;
73    }
74
75    /**
76     * Checks if three points are collinear using cross product
77     * @param i index of first point
78     * @param j index of second point
79     * @param k index of third point
80     * @return true if the three points are collinear, false otherwise
81     */
82    private boolean check(int i, int j, int k) {
83        // Extract coordinates of the three points
84        int x1 = points[i][0], y1 = points[i][1];
85        int x2 = points[j][0], y2 = points[j][1];
86        int x3 = points[k][0], y3 = points[k][1];
87      
88        // Check collinearity using cross product formula
89        // Points are collinear if (x2-x1)*(y3-y1) == (x3-x1)*(y2-y1)
90        return (x2 - x1) * (y3 - y1) == (x3 - x1) * (y2 - y1);
91    }
92}
93
1class Solution {
2public:
3    int minimumLines(vector<vector<int>>& points) {
4        // Lambda function to check if three points are collinear
5        // Uses cross product: (p2 - p1) × (p3 - p1) = 0 for collinear points
6        auto areCollinear = [&](int i, int j, int k) {
7            int x1 = points[i][0], y1 = points[i][1];
8            int x2 = points[j][0], y2 = points[j][1];
9            int x3 = points[k][0], y3 = points[k][1];
10          
11            // Check if the cross product equals zero (points are collinear)
12            return (x2 - x1) * (y3 - y1) == (x3 - x1) * (y2 - y1);
13        };
14      
15        int n = points.size();
16      
17        // DP array where dp[mask] stores minimum lines needed for points in mask
18        int dp[1 << n];
19        memset(dp, 0, sizeof(dp));
20      
21        // DFS with memoization to find minimum number of lines
22        function<int(int)> dfs = [&](int mask) -> int {
23            // Base case: all points are covered
24            if (mask == (1 << n) - 1) {
25                return 0;
26            }
27          
28            // Return memoized result if already computed
29            if (dp[mask]) {
30                return dp[mask];
31            }
32          
33            int minLines = 1 << 30;  // Initialize with a large value
34          
35            // Try to form a line starting from the first uncovered point
36            for (int i = 0; i < n; ++i) {
37                // Skip if point i is already covered
38                if (!(mask >> i & 1)) {
39                    // Try to form a line with point i and another point j
40                    for (int j = i + 1; j < n; ++j) {
41                        // Create new mask with points i and j covered
42                        int nextMask = mask | (1 << i) | (1 << j);
43                      
44                        // Add all other collinear points to this line
45                        for (int k = j + 1; k < n; ++k) {
46                            if (!(nextMask >> k & 1) && areCollinear(i, j, k)) {
47                                nextMask |= (1 << k);
48                            }
49                        }
50                      
51                        // Recursively solve for remaining points
52                        minLines = min(minLines, dfs(nextMask) + 1);
53                    }
54                  
55                    // Special case: if i is the last point, use a line for just this point
56                    if (i == n - 1) {
57                        minLines = min(minLines, dfs(mask | (1 << i)) + 1);
58                    }
59                }
60            }
61          
62            // Memoize and return the result
63            return dp[mask] = minLines;
64        };
65      
66        // Start DFS with no points covered
67        return dfs(0);
68    }
69};
70
1function minimumLines(points: number[][]): number {
2    // Helper function to check if three points are collinear
3    // Uses cross product: (p2 - p1) × (p3 - p1) = 0 for collinear points
4    const areCollinear = (i: number, j: number, k: number): boolean => {
5        const x1 = points[i][0], y1 = points[i][1];
6        const x2 = points[j][0], y2 = points[j][1];
7        const x3 = points[k][0], y3 = points[k][1];
8      
9        // Check if the cross product equals zero (points are collinear)
10        return (x2 - x1) * (y3 - y1) === (x3 - x1) * (y2 - y1);
11    };
12  
13    const n = points.length;
14  
15    // DP array where dp[mask] stores minimum lines needed for points in mask
16    // Initialize with 0 for all states
17    const dp: number[] = new Array(1 << n).fill(0);
18  
19    // DFS with memoization to find minimum number of lines
20    const dfs = (mask: number): number => {
21        // Base case: all points are covered
22        if (mask === (1 << n) - 1) {
23            return 0;
24        }
25      
26        // Return memoized result if already computed
27        if (dp[mask] !== 0) {
28            return dp[mask];
29        }
30      
31        // Initialize with a large value
32        let minLines = Number.MAX_SAFE_INTEGER;
33      
34        // Try to form a line starting from the first uncovered point
35        for (let i = 0; i < n; i++) {
36            // Skip if point i is already covered
37            if (!((mask >> i) & 1)) {
38                // Try to form a line with point i and another point j
39                for (let j = i + 1; j < n; j++) {
40                    // Create new mask with points i and j covered
41                    let nextMask = mask | (1 << i) | (1 << j);
42                  
43                    // Add all other collinear points to this line
44                    for (let k = j + 1; k < n; k++) {
45                        if (!((nextMask >> k) & 1) && areCollinear(i, j, k)) {
46                            nextMask |= (1 << k);
47                        }
48                    }
49                  
50                    // Recursively solve for remaining points
51                    minLines = Math.min(minLines, dfs(nextMask) + 1);
52                }
53              
54                // Special case: if i is the last point, use a line for just this point
55                if (i === n - 1) {
56                    minLines = Math.min(minLines, dfs(mask | (1 << i)) + 1);
57                }
58            }
59        }
60      
61        // Memoize and return the result
62        dp[mask] = minLines;
63        return minLines;
64    };
65  
66    // Start DFS with no points covered
67    return dfs(0);
68}
69

Time and Space Complexity

Time Complexity: O(2^n * n^3)

The algorithm uses dynamic programming with bit masking to find the minimum number of lines needed to cover all points. The time complexity breaks down as follows:

  • The dfs function can have at most 2^n different states (all possible subsets of points)
  • For each state, we iterate through all points to find the first unvisited point: O(n)
  • For each unvisited point i, we iterate through remaining points to find point j: O(n)
  • For each pair (i, j), we iterate through remaining points to check collinearity: O(n)
  • The check function runs in O(1) time

Therefore, the overall time complexity is O(2^n * n * n * n) = O(2^n * n^3)

Space Complexity: O(2^n)

The space complexity consists of:

  • The memoization cache stores results for up to 2^n different states: O(2^n)
  • The recursion stack depth can go up to n in the worst case: O(n)
  • Other variables use constant space: O(1)

Since 2^n dominates n for reasonable values of n, the overall space complexity is O(2^n).

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

Common Pitfalls

1. Incorrect Collinearity Check with Integer Overflow

The cross-product formula (x2 - x1) * (y3 - y1) == (x3 - x1) * (y2 - y1) can cause integer overflow when dealing with large coordinate values. For example, if coordinates are near the boundaries of a 32-bit integer range, the multiplication can exceed the integer limit and produce incorrect results.

Solution: Use a more robust collinearity check that avoids large multiplications:

def are_collinear(i, j, k):
    x1, y1 = points[i]
    x2, y2 = points[j]
    x3, y3 = points[k]
  
    # Handle vertical and horizontal lines separately to avoid division
    dx1, dy1 = x2 - x1, y2 - y1
    dx2, dy2 = x3 - x1, y3 - y1
  
    # Use GCD to reduce the risk of overflow
    from math import gcd
    g1 = gcd(abs(dx1), abs(dy1))
    g2 = gcd(abs(dx2), abs(dy2))
  
    if g1 > 0:
        dx1, dy1 = dx1 // g1, dy1 // g1
    if g2 > 0:
        dx2, dy2 = dx2 // g2, dy2 // g2
  
    return dx1 * dy2 == dx2 * dy1

2. Missing Single Point Coverage

The code only handles single point coverage when first_point == num_points - 1, but this logic is flawed. A single uncovered point should be handled immediately after finding it, not just when it's the last point in the array.

Solution: Handle single point coverage more explicitly:

def find_min_lines(covered_mask: int) -> int:
    if covered_mask == (1 << num_points) - 1:
        return 0
  
    min_lines_needed = float('inf')
  
    # Find first uncovered point
    first_point = -1
    for i in range(num_points):
        if not (covered_mask >> i & 1):
            first_point = i
            break
  
    # Try to pair with other uncovered points
    found_pair = False
    for second_point in range(first_point + 1, num_points):
        if covered_mask >> second_point & 1:
            continue
          
        found_pair = True
        new_mask = covered_mask | (1 << first_point) | (1 << second_point)
      
        # Add collinear points
        for third_point in range(second_point + 1, num_points):
            if not (covered_mask >> third_point & 1) and are_collinear(first_point, second_point, third_point):
                new_mask |= (1 << third_point)
      
        min_lines_needed = min(min_lines_needed, find_min_lines(new_mask) + 1)
  
    # If no pair found, use a line for just this point
    if not found_pair:
        min_lines_needed = min(min_lines_needed, 
                              find_min_lines(covered_mask | (1 << first_point)) + 1)
  
    return min_lines_needed

3. Inefficient Point Selection Order

The current approach always picks the first uncovered point and tries to pair it with all subsequent points. This can lead to unnecessary computations since the order of point selection doesn't matter for the final result.

Solution: Always select the lowest indexed uncovered point first to ensure consistent state exploration:

def find_min_lines(covered_mask: int) -> int:
    if covered_mask == (1 << num_points) - 1:
        return 0
  
    # Always work with the first uncovered point
    first_point = next(i for i in range(num_points) if not (covered_mask >> i & 1))
  
    min_lines_needed = float('inf')
  
    # Must include first_point in the next line
    # Option 1: Line through first_point alone
    min_lines_needed = min(min_lines_needed, 
                          find_min_lines(covered_mask | (1 << first_point)) + 1)
  
    # Option 2: Line through first_point and at least one other point
    for second_point in range(first_point + 1, num_points):
        if covered_mask >> second_point & 1:
            continue
          
        new_mask = covered_mask | (1 << first_point) | (1 << second_point)
      
        # Greedily add all collinear points
        for third_point in range(num_points):
            if third_point != first_point and third_point != second_point:
                if not (covered_mask >> third_point & 1) and are_collinear(first_point, second_point, third_point):
                    new_mask |= (1 << third_point)
      
        min_lines_needed = min(min_lines_needed, find_min_lines(new_mask) + 1)
  
    return min_lines_needed
Discover Your Strengths and Weaknesses: Take Our 5-Minute Quiz to Tailor Your Study Plan:

Is the following code DFS or BFS?

void search(Node root) {
  if (!root) return;
  visit(root);
  root.visited = true;
  for (Node node in root.adjacent) {
    if (!node.visited) {
      search(node);
    }
  }
}

Recommended Readings

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

Load More