DEV Community

Cover image for Longest Common Subsequence: A New Pattern, the Same Thinking
Nishant Gaurav
Nishant Gaurav

Posted on

Longest Common Subsequence: A New Pattern, the Same Thinking

This chapter covers the first two steps: recursion and memoization. Tabulation comes next.


What Is a Subsequence?

Before anything else, this needs to be clear.

A subsequence is formed by deleting zero or more characters from a string without changing the order of the remaining characters. The selected characters don't have to be adjacent.

String: A B C D E

Valid subsequences:
  ACE    (positions 1, 3, 5)
  ABD    (positions 1, 2, 4)
  BDE    (positions 2, 4, 5)
  ABCDE  (the whole string)
  A      (just the first character)
  ""     (empty — valid)

Not a valid subsequence:
  CA     (order is reversed)
Enter fullscreen mode Exit fullscreen mode

The key point: gaps are allowed, but order is not.


What Is the Longest Common Subsequence?

Given two strings, find the longest subsequence that appears in both.

X = ABCDGH
Y = ABEDFW
Enter fullscreen mode Exit fullscreen mode

Some common subsequences: A, AB, ABD, ABDH

The longest one is ABDH with length 4. That's the LCS.

The characters must appear in the same relative order in both strings, but they don't need to be adjacent in either.


Why This Is a DP Problem

Look at the last characters of both strings.

X = A B C D G H   → last character: H
Y = A B E D F W   → last character: W
Enter fullscreen mode Exit fullscreen mode

Two cases arise.

Case 1: Last characters match.

If X[m-1] == Y[n-1], that character is part of the LCS. Include it and reduce both strings by one.

LCS(X, Y, m, n) = 1 + LCS(X, Y, m-1, n-1)
Enter fullscreen mode Exit fullscreen mode

Case 2: Last characters don't match.

If X[m-1] != Y[n-1], you don't know which one to drop. So you try both and take the better result.

LCS(X, Y, m, n) = max(
    LCS(X, Y, m-1, n),    # drop last char of X
    LCS(X, Y, m, n-1)     # drop last char of Y
)
Enter fullscreen mode Exit fullscreen mode

These two cases completely define the recursive structure.


Base Case

The smallest valid input is when either string runs out of characters.

X = ""         or        Y = ""
Enter fullscreen mode Exit fullscreen mode

An empty string has no characters to share. The LCS is zero.

if m == 0 or n == 0:
    return 0
Enter fullscreen mode Exit fullscreen mode

Always design the base case first. Ask: what is the smallest input where the answer is obvious?


The Recursive Solution

Base case plus choice diagram gives you the full recursion directly.

class Solution:

    def lcs(self, X, Y, m, n):

        # Base case: one string is empty, no common subsequence
        if m == 0 or n == 0:
            return 0

        # Last characters match — include and shrink both
        if X[m - 1] == Y[n - 1]:
            return 1 + self.lcs(X, Y, m - 1, n - 1)

        # Last characters don't match — try dropping from either side
        else:
            return max(
                self.lcs(X, Y, m - 1, n),
                self.lcs(X, Y, m, n - 1)
            )

    def longestCommonSubsequence(self, X, Y):
        m = len(X)
        n = len(Y)
        return self.lcs(X, Y, m, n)
Enter fullscreen mode Exit fullscreen mode

The code is a direct translation of the choice diagram. Nothing is added beyond base case and two cases.


Why the Recursive Solution Gets Slow

When characters don't match, two recursive calls are made. Each of those makes two more. The tree grows exponentially.

Worse, the same state appears multiple times through different paths.

LCS(m, n)
├── LCS(m-1, n)
│   ├── LCS(m-2, n)
│   └── LCS(m-1, n-1)  ← appears here
└── LCS(m, n-1)
    ├── LCS(m-1, n-1)  ← and here again
    └── LCS(m, n-2)
Enter fullscreen mode Exit fullscreen mode

LCS(m-1, n-1) is calculated twice from the top level alone. Deeper in the tree, the overlap gets worse. For long strings, this becomes too slow.

The fix is the same as it was in the Knapsack series: store the answer the first time, reuse it every time after.


Memoization

The state is defined by two values: m and n. So the cache is a 2D table of size (m+1) × (n+1).

The +1 on each dimension is because m and n range from 0 to their respective lengths. Zero is a valid state (it's the base case), so it needs a slot in the table.

Why initialise with -1 and not 0

0 is a valid LCS answer. For example:

X = ABC
Y = XYZ
Enter fullscreen mode Exit fullscreen mode

These strings share no characters. LCS = 0. If the table is initialised to zero, you can't tell whether a cell holds a computed answer of zero or an unvisited state. So we use -1 as the sentinel.

-1 → not computed yet
 0 → computed, answer is 0
>0 → computed, answer is this value
Enter fullscreen mode Exit fullscreen mode

This is the same reasoning as using None in Count of Subsets (Chapter 4), where False was a valid answer and couldn't serve as the placeholder.

Common mistake: initialising with 0 and then checking if T[m][n] != 0. This skips recomputing incorrectly when the real answer is zero.

# WRONG
if T[m][n] != 0:
    return T[m][n]

# CORRECT
if T[m][n] != -1:
    return T[m][n]
Enter fullscreen mode Exit fullscreen mode

The Memoized Solution

class Solution:

    def lcs(self, X, Y, m, n):

        # Base case
        if m == 0 or n == 0:
            return 0

        # Return stored answer if already computed
        if self.T[m][n] != -1:
            return self.T[m][n]

        # Match: include character, shrink both strings
        if X[m - 1] == Y[n - 1]:
            self.T[m][n] = 1 + self.lcs(X, Y, m - 1, n - 1)

        # No match: try both options, take the better one
        else:
            self.T[m][n] = max(
                self.lcs(X, Y, m - 1, n),
                self.lcs(X, Y, m, n - 1)
            )

        return self.T[m][n]

    def longestCommonSubsequence(self, X, Y):
        m = len(X)
        n = len(Y)

        # -1 means not yet computed
        self.T = [[-1] * (n + 1) for _ in range(m + 1)]

        return self.lcs(X, Y, m, n)
Enter fullscreen mode Exit fullscreen mode

The recursive logic is unchanged. Two lines added: the cache check at the top, the store before returning. That's memoization.


The LCS Pattern to Remember

Base case:
  m == 0 or n == 0 → return 0

Choice:
  X[m-1] == Y[n-1] → 1 + LCS(m-1, n-1)
  X[m-1] != Y[n-1] → max(LCS(m-1, n), LCS(m, n-1))

Memoization:
  Table size: (m+1) × (n+1)
  Initialise with -1
  Check before computing, store after
Enter fullscreen mode Exit fullscreen mode

What You Now Understand

LCS is not Knapsack. The inputs are two strings, not an array and a capacity. The table is indexed by string lengths, not items and weight. The choice is match-or-no-match, not take-or-skip.

But the process is the same. Base case first. Choices second. Recursion from those two. Repeated states observed. Memoization added with two lines.

The next chapter converts this recursion into a bottom-up tabulation. Once you see how m-1 and n-1 in the recursion map to i-1 and j-1 in the table, the entire LCS family of problems becomes much easier to build.

Top comments (0)