Skip to content

LCS

A strand of DNA consists of a string of molecules called bases, where the possible bases are adenine, guanine, cytosine, and thymine.

Representing each of these bases by its initial letter, we can express a strand of DNA as a string over the finite set {A,C,G,T}.

One reason to compare two strands of DNA is to determine how “similar” the two strands are, as some measure of how closely related the two organisms are.

String Similarity

We can, and do, define similarity in many ways. For example, we can say that two DNA strands are similar:

  1. If one is a substring of the other.
  2. Alternatively, we could say that two strands are similar if the number of changes needed to turn one into the other is small.
  3. Yet another way to measure the similarity of strands S1 and S2 is by finding a third strand S3in which the bases in S3 appear in each of S1 and S2.
    1. These bases must appear in the same order, but not necessarily consecutively.
    2. The longer the strand S3 we can find, the more similar S1 and S2 are.

Example:

  • S1=ACCGGTCGAGTGCGCGGAAGCCGGCCGAA
  • S2=GTCGTTCGGAATGCCGTTGCTCTGTAAA
  • S3=GTCGTCGGAAGCCGGCCGAA

Subsequence

We formalize this last notion of similarity as the longest-common-subsequence problem. A subsequence of a given sequence is just the given sequence with zero or more elements left out.

Formally, given a sequence X=x1,x2,,xm, another sequence Z=z1,z2,,zk is a subsequence of X if there exists a strictly increasing sequence i1,i2,,ik of indices of X such that for all j=1,2,,k, we have xj=zj

Given X=x1,x2,,xm, We have xi1,,xiksuch that i1,,ik{1,,m}i1<i2<<ik

Example: Z=B,C,D,B is a subsequence of X=A,B,C,B,D,A,B with corresponding index sequence 2,3,5,7

Common Subsequence

Given two sequences X and Y, we say that a sequence Z is a common subsequence of X and Y if Z is a subsequence of both X and Y

Example: X=A,B,C,B,D,A,B is a subsequence of Y=B,D,C,A,B,A, the sequence B,C,A is a common subsequence of both X and Y.

The sequence B,C,A is not a longest common subsequence (LCS) of X and Y, however, since it has length 3 and the sequence B,C,B,A, which is also common to both X and Y , has length 4. The sequence B,C,B,A is an LCS of X and Y , as is the sequence B,D,A,B since X and Y have no common subsequence of length 5 or greater.

Problem Statement - Longest Common Subsequence

In the longest-common-subsequence problem, we are given two sequences (input)

X=x1,x2,,xmY=y1,y2,,yn

and wish to find a maximum length common subsequence of X and Y, W (output).

Observation 1: Multiple possible LCS

If we define a set LCS(X,Y) which represents the set containing all the longest common subsequences given two sequences X and Y, there might be multiple distinct subsequences of the same length, even though they are the longest.

Observation 2: Brute force is not an option T(n)=Θ(2n)

In a brute-force approach to solve the LCS problem:

  1. We would enumerate all subsequences of X
  2. Check each subsequence to see whether it is also a subsequence of Y
  3. Keep track of the longest subsequence we find.

By generating all the possible sequences of X, verifying the property of common subsequence of Y and storing the longest one, would require an exponential time. This is because at most we have 2n subsequences as each character xi could either appear or not.

Step 1 - Characterizing the longest common subsequence

Remember, we can apply dynamic programming here if we can express the solution in a polynomial count of sub-problems.

The LCS problem has an optimal-substructure property, however, as the following theorem shows. As we shall see, the natural classes of subproblems correspond to pairs of “prefixes” of the two input sequences.

Prefix

Given a sequence X=x1,x2,,xm, we define the k-th prefix of X, for km, as the prefix of X of length k, Xk=x1,x2,,xk

Example: X=A,B,C,B,D,A,B, then

  • X4=A,B,CB
  • And X0 is the empty sequence
  • Also, when k=m, the prefix corresponds to the whole sequence.

In general X=x1,x2,,xm has m+1 prefixes, and by reducing the LCS problem to the LCS problem on prefixes the complexity goes down to O(mn) sub-problems.

  • Where X=x1,x2,,xm and Y=y1,y2,,yn

Theorem 15.1: Optimal Substructure of an LCS

Theroem 15.1

Let's rewrite it for analysis purposes

Let X=x1,x2,,xmY=y1,y2,,yn be two sequences and W=w1,w2,,wkLCS(X,Y) be one of the possible LCS

We consider LCS as the optimal solution of the problem, if it does not apply to the main problem, it could still be valid for a smaller one. Then:

  1. If xm=yn, the last characters of LCS coincide,
    1. The last characters of LCS coincide, wk=xm=yn
    2. The prefix of this common sequence is the LCS of the prefixes of X and Y, Wk1LCS(Xm1,Yn1)
  2. If xmyn, Then:
    1. If wkxm, then WkLCS(Xm1,Y)
    2. If wkyn, then WkLCS(X,Yn1)

Demonstration

Demonstration Ad-Absurdum:

  • Part 1.1: wk=xm=yn,
    • Ad-absurdum, if this was not true we could build a sequence by chaining xm to W, resulting in Wxm which is still a subsequence of X and Y.
    • But, this means there should be a subsequence longer than W, which is absurd because WLCS(X,Y), so it is already an optimal solution.
    • Then, wk=xm=yn is true.
  • Part 1.2: Wk1LCS(Xm1,Yn1)
    • Ad-absurdum, if this was not true we could have some WLCS(Xm1,Yn1) and |W|>|Wk1|=k1
    • Since we know, wk=xm=yn, If we concat the sequences with wk we would get |Wwkk1|<|Wwk| (which are both LCS).
    • But, Wwkk1=W as it is the string concatenated with the last character.
    • We reach an absurd as |W|<|Wwk|, and W would not be an optimal solution anymore.
  • Part 2.1: xmynwkxmWkLCS(Xm1,Y)
    • Ad-absurdum, we suppose there exists some WLCS(Xm1,Y)|W|>|W|, it is not possible as W is by definition an optimal solution of maximum length
  • Part 2.2: xmynwkynWkLCS(X,Yn1)
    • Ad-absurdum, we suppose there exists some WLCS(X,Yn1)|W|>|W|, it is not possible as W is by definition an optimal solution of maximum length

Conclusion

To sum up:

  1. Thanks to this we managed to express the LCS(X,Y) in terms of sub-problems, now we have a polynomial way to construct our solution.
  2. The way that Theorem 15.1 characterizes the longest common subsequences tells us that an LCS of two sequences contains within it an LCS of prefixes of the two sequences.
  3. This means we can start from prefixes of length i=1, and then proceed towards i=m (m as the last index)
  4. Thus, the LCS problem has an optimal-substructure property. A recursive solution also has the overlapping sub-problems property, as we shall see in a moment.

Step 2 - Recursive Solution

A recursive solution

  1. If xm=yn
    1. Find an LCS(Xm1,Yn1)
    2. Appending xm=yn, to this LCS yields an LCS(X,Y)
  2. Else, we must solve two subproblems
    1. Finding an LCS(Xm1,Y)
    2. Finding an LCS(X,Yn1)
    3. The longest is an LCS(X,Y) and this exhaust all possibilities recursively.

Our recursive solution to the LCS problem involves establishing a recurrence for the value of an optimal solution. Let us define c[i,j] to be the length of an LCS(Xi,Yj)

c[i,j]={0i=0j=0c[i1,j1]+1i,j>0xi=yjmax{c[i1,j],c[i,j1]}i,j>0xiyj

We ruled out some sub-problems due to how we defined the problem and the possible solutions. We have now, nm distinct subproblems

Step 3 & 4 - Bottom Up

Computing the length of an LCS to be the length of an LCS(Xi,Yj)

OperationBU_LCS(X, Y) -> Pair(b,c)
InputSequences X,Y
OutputTables b,c with c[m,n] containing the length of an LCS(X,Y)
c[0m,0n]is a 2D vector that saves the lengths of LCS(Xi,Yj)
b[im,jn]is a 2D vector that helps us construct an optimal solution
b[i,j]Points to the table entry corresponding to the optimal sub-problem solution chosen when computing c[i,j]
movements on the board={xi=yjLCS(Xi,Yj)LCS(Xi1,Yj1)xiyjLCS(Xi,Yj)LCS(Xi1,Yj)xiyjLCS(Xi,Yj)LCS(Xi,Yj1)

LCS movement on matrix B

python
BU_LCS(x,y)
    c[0...m+1,0...n+1]
    b[1...m,1...n]
    m = x.length;
    n = y.length;
    for (i = 0 to m): # When i = 0
        c[i,0] = 0;
    for (j = 1 to n): # When j = 0
        c[0,j] = 0;
    for (i = 1 to m): 
        for (j = 1 to n):
            if(x[i] == y[j]): # CASE 1 
                c[i,j] = c[i-1,j-1] + 1;
                b[i,j] = ↖;
            else if (c[i-1, j] >= c[i,j-1]): # CASE 2
                c[i,j] = c[i-1, j];
                b[i,j] = ↑;
            else: # CASE 3
                c[i,j] = c[i, j - 1];
                b[i,j] = ←;
    return b,c;
$$
**Final Time Complexity** $T(n)= \Theta(m) + \Theta(n) + \Theta(n \cdot m) = \Theta(n \cdot m)$
* Polynomial

![examplebulcs](https://github.com/PayThePizzo/DataStrutucures-Algorithms/blob/main/Resources/examplebulcs.png?raw=TRUE)

Now that we have found the count of an LCS, we want to display which one it could be!

### Printing
Constructing an LCS

Let's print an LCS!
* We start from the $i,j$ position and decrease eithe $i$ or $j$
* We only print if there's an oblique arrow.
* Since the recursive call happens before the print, we get to the top from the bottom
and only print at the very end.

```python
printLCSAux(X, b, i, j)
    if(i > 0 && j > 0): #if not an empty string
        if(b[i,j] == ↖): #if we have a common char
            printLCSAux(X, b, i - 1, j - 1); #first we deal with the subproblem
            print(X[i]); 
        else if(b[i,j] == ↑): #if we have NOT a common char
            printLCSAux(X, b, i - 1, j);
        else:
            printLCSAux(X, b, i, j - 1);
$$
**Final Time Complexity** $T(n)= \mathcal{O}(i+j)$
* At every function call, we decrease either one of the two parameters.

```python
printLCS(X,Y)
    b,c = BU_LCS(X,Y);
    printLCSaux(X,b, X.length, Y.length);
$$
**Final Time Complexity** $T(n)= \Theta(n \cdot m) + \Theta(n + m)= \Theta(n \cdot m)$
* We need to go through LCS


### Improve memory
We can reduce the memory usage through two different optimizations

#### First Method

In the LCS algorithm, for example, we can eliminate the $b$ table altogether.

Given the value of $c[i,j]$, we can determine in $O(1)$ time which of
these three values was used to compute $c[i,j]$ without inspecting table $b$.
Each $c[i,j]$  entry **depends on only three other $c$ table entries**:
1. $c[i-1,j-1]$
2. $c[i-1,j]$
3. $c[i,j-1]$

Thus, we can reconstruct an $LCS$ in $\mathcal{O}(m+n)$ time using a procedure similar to printLCS.
The order here matters a lot!

```python
printLCSAux(X, c, i, j)
    if(i > 0 && j > 0):
        if(c[i,j] == c[i - 1,j]):
            printLCSAux(X, c, i - 1, j);
        else if(c[i,j] == c[i,j - 1]):
            printLCSAux(X, c, i, j - 1);
        else:
            printLCSAux(X, c, i - 1, j - 1);
            print(X[i]);
$$
Although we save $\Theta(n*m)$ space by this method, the auxiliary 
space requirement for computing an LCS does not asymptotically decrease, since 
we need $\Theta(n*m)$ space for the $c$ table anyway.


#### Second Method

We can, however, reduce the asymptotic space requirements for `LCS_Length`,
since it needs only two rows of table $c$ at a time:
* The row being computed, 
* and the previous row

This improvement works if **we need only the length of an LCS**; if we need to reconstruct
the elements of an LCS, the smaller table does not keep enough information to
retrace our steps in $\mathcal{O}(m+n)$ time and using only $\mathcal{O}(n)$.


## Step 3 & 4 - Top Down

```python
TD_LCSAux(x, y, c, i, j)
    if(c[i,j] == -1): # Problem not solved
        if(i == 0 || j == 0): 
            c[i,j] = 0;
        else if(x[i] == y[j]):
            c[i,j] = TD_LCSAux(x, y, i - 1, j - 1) + 1;
        else:
            c[i,j] = max(TD_LCSAux(x, y, i - 1, j),
                         TD_LCSAux(x, y, i, j - 1));
    return c[i,j];
$$
**Final Time Complexity** $T(n)= \mathcal{O}(n \cdot m)$
* This is directly proportional to the possible sub-problems 

```python
TD_LCS(X, Y)
    m = X.length
    n = Y.length
    c[0..m,0..n] = -1 #initialized with all elements equals to -1
    return TD_LCSAux(X, Y, c, m, n)
$$
**Final Time Complexity** $T(n)= \Theta(n \cdot m)$
* If the strings are equivalent, we are in $\mathcal{O}(n)$ rather than $\mathcal{O}(n^{2})$

![example lcs td](https://github.com/PayThePizzo/DataStrutucures-Algorithms/blob/main/Resources/exlcstd.png?raw=TRUE)
BU_LCS(x,y)
    c[0...m+1,0...n+1]
    b[1...m,1...n]
    m = x.length;
    n = y.length;
    for (i = 0 to m): # When i = 0
        c[i,0] = 0;
    for (j = 1 to n): # When j = 0
        c[0,j] = 0;
    for (i = 1 to m): 
        for (j = 1 to n):
            if(x[i] == y[j]): # CASE 1 
                c[i,j] = c[i-1,j-1] + 1;
                b[i,j] = ↖;
            else if (c[i-1, j] >= c[i,j-1]): # CASE 2
                c[i,j] = c[i-1, j];
                b[i,j] = ↑;
            else: # CASE 3
                c[i,j] = c[i, j - 1];
                b[i,j] = ←;
    return b,c;
$$
**Final Time Complexity** $T(n)= \Theta(m) + \Theta(n) + \Theta(n \cdot m) = \Theta(n \cdot m)$
* Polynomial

![examplebulcs](https://github.com/PayThePizzo/DataStrutucures-Algorithms/blob/main/Resources/examplebulcs.png?raw=TRUE)

Now that we have found the count of an LCS, we want to display which one it could be!

### Printing
Constructing an LCS

Let's print an LCS!
* We start from the $i,j$ position and decrease eithe $i$ or $j$
* We only print if there's an oblique arrow.
* Since the recursive call happens before the print, we get to the top from the bottom
and only print at the very end.

```python
printLCSAux(X, b, i, j)
    if(i > 0 && j > 0): #if not an empty string
        if(b[i,j] == ↖): #if we have a common char
            printLCSAux(X, b, i - 1, j - 1); #first we deal with the subproblem
            print(X[i]); 
        else if(b[i,j] == ↑): #if we have NOT a common char
            printLCSAux(X, b, i - 1, j);
        else:
            printLCSAux(X, b, i, j - 1);
$$
**Final Time Complexity** $T(n)= \mathcal{O}(i+j)$
* At every function call, we decrease either one of the two parameters.

```python
printLCS(X,Y)
    b,c = BU_LCS(X,Y);
    printLCSaux(X,b, X.length, Y.length);
$$
**Final Time Complexity** $T(n)= \Theta(n \cdot m) + \Theta(n + m)= \Theta(n \cdot m)$
* We need to go through LCS


### Improve memory
We can reduce the memory usage through two different optimizations

#### First Method

In the LCS algorithm, for example, we can eliminate the $b$ table altogether.

Given the value of $c[i,j]$, we can determine in $O(1)$ time which of
these three values was used to compute $c[i,j]$ without inspecting table $b$.
Each $c[i,j]$  entry **depends on only three other $c$ table entries**:
1. $c[i-1,j-1]$
2. $c[i-1,j]$
3. $c[i,j-1]$

Thus, we can reconstruct an $LCS$ in $\mathcal{O}(m+n)$ time using a procedure similar to printLCS.
The order here matters a lot!

```python
printLCSAux(X, c, i, j)
    if(i > 0 && j > 0):
        if(c[i,j] == c[i - 1,j]):
            printLCSAux(X, c, i - 1, j);
        else if(c[i,j] == c[i,j - 1]):
            printLCSAux(X, c, i, j - 1);
        else:
            printLCSAux(X, c, i - 1, j - 1);
            print(X[i]);
$$
Although we save $\Theta(n*m)$ space by this method, the auxiliary 
space requirement for computing an LCS does not asymptotically decrease, since 
we need $\Theta(n*m)$ space for the $c$ table anyway.


#### Second Method

We can, however, reduce the asymptotic space requirements for `LCS_Length`,
since it needs only two rows of table $c$ at a time:
* The row being computed, 
* and the previous row

This improvement works if **we need only the length of an LCS**; if we need to reconstruct
the elements of an LCS, the smaller table does not keep enough information to
retrace our steps in $\mathcal{O}(m+n)$ time and using only $\mathcal{O}(n)$.


## Step 3 & 4 - Top Down

```python
TD_LCSAux(x, y, c, i, j)
    if(c[i,j] == -1): # Problem not solved
        if(i == 0 || j == 0): 
            c[i,j] = 0;
        else if(x[i] == y[j]):
            c[i,j] = TD_LCSAux(x, y, i - 1, j - 1) + 1;
        else:
            c[i,j] = max(TD_LCSAux(x, y, i - 1, j),
                         TD_LCSAux(x, y, i, j - 1));
    return c[i,j];
$$
**Final Time Complexity** $T(n)= \mathcal{O}(n \cdot m)$
* This is directly proportional to the possible sub-problems 

```python
TD_LCS(X, Y)
    m = X.length
    n = Y.length
    c[0..m,0..n] = -1 #initialized with all elements equals to -1
    return TD_LCSAux(X, Y, c, m, n)
$$
**Final Time Complexity** $T(n)= \Theta(n \cdot m)$
* If the strings are equivalent, we are in $\mathcal{O}(n)$ rather than $\mathcal{O}(n^{2})$

![example lcs td](https://github.com/PayThePizzo/DataStrutucures-Algorithms/blob/main/Resources/exlcstd.png?raw=TRUE)