1035. Uncrossed Lines - Explanation

Problem Link



Prerequisites

Before attempting this problem, you should be comfortable with:

  • Dynamic Programming - Understanding memoization and tabulation techniques for optimization
  • Longest Common Subsequence (LCS) - Recognizing this classic DP pattern since uncrossed lines is equivalent to LCS
  • Recursion - Writing recursive functions with base cases and recursive transitions

1. Recursion

Intuition

This problem is essentially finding the longest common subsequence (LCS) between two arrays. We can draw a line between nums1[i] and nums2[j] only if they have the same value. Lines cannot cross, which means if we connect nums1[i] to nums2[j], we can only connect elements after index i in nums1 to elements after index j in nums2.

At each position, we have two choices: if the current elements match, we draw a line and move both pointers forward. If they don't match, we try skipping an element from either array and take the maximum result.

Algorithm

  1. Define a recursive function dfs(i, j) that returns the maximum number of uncrossed lines starting from index i in nums1 and index j in nums2.
  2. Base case: If either i or j reaches the end of its array, return 0.
  3. If nums1[i] == nums2[j], we can draw a line. Return 1 + dfs(i + 1, j + 1).
  4. Otherwise, try skipping either element and return the maximum of dfs(i, j + 1) and dfs(i + 1, j).
  5. Start the recursion from dfs(0, 0).
class Solution:
    def maxUncrossedLines(self, nums1: List[int], nums2: List[int]) -> int:
        def dfs(i, j):
            if i == len(nums1) or j == len(nums2):
                return 0

            if nums1[i] == nums2[j]:
                return 1 + dfs(i + 1, j + 1)
            return max(dfs(i, j + 1), dfs(i + 1, j))

        return dfs(0, 0)

Time & Space Complexity

  • Time complexity: O(2n+m)O(2 ^ {n + m})
  • Space complexity: O(n+m)O(n + m) for recursion stack.

Where nn and mm are the sizes of the arrays nums1nums1 and nums2nums2 respectively.


2. Dynamic Programming (Top-Down)

Intuition

The recursive solution recomputes many subproblems. For example, dfs(2, 3) might be called multiple times through different paths. We can use memoization to store results of subproblems and avoid redundant calculations.

Algorithm

  1. Create a 2D memoization table dp initialized with -1 to indicate uncomputed states.
  2. Define a recursive function dfs(i, j) similar to the brute force approach.
  3. Before computing, check if dp[i][j] is already computed. If so, return the cached value.
  4. Compute the result using the same logic as the recursive approach and store it in dp[i][j].
  5. Return dfs(0, 0).
class Solution:
    def maxUncrossedLines(self, nums1: List[int], nums2: List[int]) -> int:
        dp = {}

        def dfs(i, j):
            if i == len(nums1) or j == len(nums2):
                return 0

            if (i, j) in dp:
                return dp[(i, j)]

            if nums1[i] == nums2[j]:
                dp[(i, j)] = 1 + dfs(i + 1, j + 1)
            else:
                dp[(i, j)] = max(dfs(i, j + 1), dfs(i + 1, j))

            return dp[(i, j)]

        return dfs(0, 0)

Time & Space Complexity

  • Time complexity: O(nm)O(n * m)
  • Space complexity: O(nm)O(n * m)

Where nn and mm are the sizes of the arrays nums1nums1 and nums2nums2 respectively.


3. Dynamic Programming (Bottom-Up)

Intuition

Instead of recursion with memoization, we can build the solution iteratively. We define dp[i][j] as the maximum number of uncrossed lines using nums1[0..i-1] and nums2[0..j-1]. By filling this table row by row, we avoid recursion overhead.

Algorithm

  1. Create a 2D table dp of size (n+1) x (m+1) initialized with zeros.
  2. Iterate through each element i in nums1 and j in nums2.
  3. If nums1[i] == nums2[j], set dp[i+1][j+1] = 1 + dp[i][j] (draw a line).
  4. Otherwise, set dp[i+1][j+1] = max(dp[i][j+1], dp[i+1][j]) (skip one element).
  5. Return dp[n][m] where n and m are the sizes of the arrays.
class Solution:
    def maxUncrossedLines(self, nums1: List[int], nums2: List[int]) -> int:
        n, m = len(nums1), len(nums2)
        dp = [[0] * (m + 1) for _ in range(n + 1)]

        for i in range(n):
            for j in range(m):
                if nums1[i] == nums2[j]:
                    dp[i + 1][j + 1] = 1 + dp[i][j]
                else:
                    dp[i + 1][j + 1] = max(dp[i][j + 1], dp[i + 1][j])

        return dp[n][m]

Time & Space Complexity

  • Time complexity: O(nm)O(n * m)
  • Space complexity: O(nm)O(n * m)

Where nn and mm are the sizes of the arrays nums1nums1 and nums2nums2 respectively.


4. Dynamic Programming (Space Optimized)

Intuition

Notice that each row of the dp table only depends on the previous row. We can reduce space complexity by keeping only two rows: the prev row and the current dp row being computed.

Algorithm

  1. Initialize a 1D array prev of size m+1 with zeros.
  2. For each element in nums1, create a new array dp for the current row.
  3. For each element in nums2:
    • If elements match, dp[j+1] = 1 + prev[j].
    • Otherwise, dp[j+1] = max(dp[j], prev[j+1]).
  4. After processing each row, set prev = dp.
  5. Return prev[m].
class Solution:
    def maxUncrossedLines(self, nums1: List[int], nums2: List[int]) -> int:
        prev = [0] * (len(nums2) + 1)

        for i in range(len(nums1)):
            dp = [0] * (len(nums2) + 1)
            for j in range(len(nums2)):
                if nums1[i] == nums2[j]:
                    dp[j + 1] = 1 + prev[j]
                else:
                    dp[j + 1] = max(dp[j], prev[j + 1])
            prev = dp

        return prev[len(nums2)]

Time & Space Complexity

  • Time complexity: O(nm)O(n * m)
  • Space complexity: O(m)O(m)

Where nn and mm are the sizes of the arrays nums1nums1 and nums2nums2 respectively.


5. Dynamic Programming (Optimal)

Intuition

We can further optimize by using a single array and a variable to track the diagonal value from the previous iteration. This requires careful handling to avoid overwriting values we still need.

Algorithm

  1. If nums2 is longer than nums1, swap them so we iterate over the longer array in the outer loop. This minimizes space usage.
  2. Initialize a 1D array dp of size m+1 with zeros.
  3. For each element in nums1:
    • Track prev to store the diagonal value before it gets overwritten.
    • For each element j in nums2:
      • Save dp[j+1] as temp before updating.
      • If elements match, dp[j+1] = 1 + prev.
      • Otherwise, dp[j+1] = max(dp[j+1], dp[j]).
      • Update prev = temp for the next iteration.
  4. Return dp[m].
class Solution:
    def maxUncrossedLines(self, nums1: List[int], nums2: List[int]) -> int:
        n, m = len(nums1), len(nums2)
        if m > n:
            n, m = m, n
            nums1, nums2 = nums2, nums1

        dp = [0] * (m + 1)

        for i in range(n):
            prev = 0
            for j in range(m):
                temp = dp[j + 1]
                if nums1[i] == nums2[j]:
                    dp[j + 1] = 1 + prev
                else:
                    dp[j + 1] = max(dp[j + 1], dp[j])
                prev = temp

        return dp[m]

Time & Space Complexity

  • Time complexity: O(nm)O(n * m)
  • Space complexity: O(min(n,m))O(min(n, m))

Where nn and mm are the sizes of the arrays nums1nums1 and nums2nums2 respectively.


Common Pitfalls

Not Recognizing This as LCS

Many struggle to identify that this problem is equivalent to finding the Longest Common Subsequence (LCS). The "uncrossed lines" constraint naturally maps to the subsequence property where relative order must be preserved. Failing to make this connection leads to overcomplicated solutions.

Off-by-One Errors in DP Table

When implementing the bottom-up DP solution, a common mistake is using incorrect indices. The DP table is typically sized (n+1) x (m+1) to handle base cases, but accessing nums1[i] vs dp[i+1] can be confusing. Ensure consistency between array indices and DP table indices.

Incorrect Pointer Movement in Space-Optimized DP

When using space-optimized DP with a single array, forgetting to track the previous diagonal value before overwriting leads to incorrect results. The prev variable must capture dp[j] before it gets updated, as this value is needed for the next iteration's diagonal lookup.