← Todos los algoritmos

Longest Common Subsequence

Programación Dinámica · ver código fuente en GitHub

Leer en: English · Português · Español

Categoría: Dynamic Programming

El problema

Comparar dos secuencias para encontrar su mayor orden compartido — elementos que aparecen en ambas, en el mismo orden relativo, pero no necesariamente de forma contigua ni en las mismas posiciones. Verificar cada una de las 2^n subsecuencias de una entrada contra la otra para encontrar la más larga que también sea subsecuencia de la segunda es exponencial, y empeora cuanto más difieren las dos entradas.

La solución

Defina dp[i][j] como la longitud de la LCS entre los primeros i elementos de a y los primeros j elementos de b. Si a[i-1] es igual a b[j-1], ese elemento compartido extiende cualquier LCS que ya existiera para los dos prefijos más cortos: dp[i][j] = dp[i-1][j-1] + 1. Si no coinciden, la mejor opción disponible es la que resulte de "descartar el último elemento de a" o "descartar el último elemento de b", lo que deje una LCS más larga: dp[i][j] = max(dp[i-1][j], dp[i][j-1]). Llenar esa tabla de abajo hacia arriba es O(n × m); recorriéndola hacia atrás desde la esquina inferior derecha, reaplicando la misma lógica de coincidencia/no coincidencia de forma inversa, se recupera la subsecuencia real — no solo su longitud.

flowchart TD
    A["dp[i][j]"] -->|"a[i-1] == b[j-1]"| B["dp[i-1][j-1] + 1"]
    A -->|"a[i-1] != b[j-1]"| C["max(dp[i-1][j], dp[i][j-1])"]
Operación Costo Por qué
Llenado de la tabla DP O(n × m) una decisión de tiempo constante por celda
Recuperación de la subsecuencia (backtrack) O(n + m) un paso por celda recorrida hacia atrás, sin volver a resolver
Fuerza bruta (sin memoización) exponencial — se aproxima a C(n+m, n) en el peor caso cada no coincidencia se ramifica en dos caminos, sin caché para interrumpir un par (i, j) repetido

Ejemplo clásico

classic/Lcs es genérico para cualquier tipo de elemento con un equals funcional — funciona con Character[], String[], o cualquier tipo de dominio. longestCommonSubsequence retorna la subsecuencia realmente recuperada; length es un wrapper de conveniencia; bruteForceLength es la misma recursión ingenua y no memoizada contra la que advierte el módulo Fibonacci de este repositorio, incluida aquí específicamente para servir de comparación en el benchmark a continuación. LcsTest cubre un caso inequívoco verificado contra la secuencia exacta recuperada, el ejemplo clásico del libro de texto CLRS ("ABCBDAB" vs. "BDCABA", LCS de longitud 4), ningún elemento en común, entradas idénticas, una entrada vacía, la fuerza bruta coincidiendo con la longitud del DP en las mismas entradas, y las protecciones contra argumento null.

Ejemplo aplicado: diff de conciliación de libro mayor bancario

applied/LedgerReconciliationDiff alinea dos libros mayores de transacciones — un libro mayor interno y el extracto de un banco corresponsal para el mismo período — encontrando la subsecuencia común más larga de referencias de transacción coincidentes que aún conservan el orden relativo original. Las entradas de esa subsecuencia común quedan confirmadas como conciliadas; todo lo demás es una discrepancia de conciliación genuina (presente en un lado, ausente en el otro), y no simplemente un reordenamiento sin relación — la LCS solo descarta elementos para encontrar el orden compartido, nunca trata un reordenamiento como una discrepancia de la forma en que lo haría una comparación posicional estricta. LedgerReconciliationDiffTest cubre una transacción ausente del lado del banco, libros mayores idénticos que concilian por completo, ninguna superposición en absoluto, y las protecciones contra argumento null.

Benchmark

./gradlew :dynamic-programming:longest-common-subsequence:jmh

Ejecución real en esta máquina (JMH 1.37, JDK 26.0.2, 2 iteraciones de warmup + 3 de medición, 1 fork). Ambas secuencias se extraen de alfabetos disjuntosa de {A,B,C,D}, b de {W,X,Y,Z} — de modo que toda comparación de caracteres resulta en no coincidencia, forzando la recursión de fuerza bruta a su verdadero peor caso (una coincidencia colapsa directamente en una única llamada recursiva; una no coincidencia siempre se ramifica en dos caminos):

Costo length=8 length=11 length=14
DP 0.481 µs 0.852 µs 1.070 µs
fuerza bruta 60.22 µs 3,299.02 µs 185,880.08 µs

En length=14, la fuerza bruta es ~173,720x más lenta que el DP para la misma respuesta. El crecimiento coincide con la teoría con una precisión sorprendente: al pasar de length=8 a length=11 (el conteo de llamadas se aproxima a C(22,11)/C(16,8) ≈ 54.8x), la fuerza bruta efectivamente midió ~54.8x más lenta — una coincidencia casi exacta. Al pasar de length=11 a length=14 (C(28,14)/C(22,11) ≈ 56.9x previsto), el costo medido creció ~56.3x — la explosión combinatoria que describe el docstring de este módulo no es una aproximación aquí, es el número que realmente resultó.

Cuándo no usarlo

Cobertura de pruebas

100% de cobertura de instrucciones, 100% de cobertura de branches (JaCoCo). Reprodúzcalo usted mismo:

./gradlew :dynamic-programming:longest-common-subsequence:jacocoTestReport

Informe en dynamic-programming/longest-common-subsequence/build/reports/jacoco/test/html/index.html.

Lectura complementaria

Pruebas unitarias

src/test/java/com/algorithms/dynamicprogramming/lcs/classic/LcsTest.java
package com.algorithms.dynamicprogramming.lcs.classic;

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

class LcsTest {

    @Test
    void findsTheExactSubsequenceForAnUnambiguousCase() {
        Character[] a = {'X', 'A', 'X', 'B', 'X', 'C'};
        Character[] b = {'A', 'B', 'C'};

        assertThat(Lcs.longestCommonSubsequence(a, b)).containsExactly('A', 'B', 'C');
    }

    @Test
    void knownTextbookExampleHasLcsLengthFour() {
        Character[] a = {'A', 'B', 'C', 'B', 'D', 'A', 'B'};
        Character[] b = {'B', 'D', 'C', 'A', 'B', 'A'};

        assertThat(Lcs.length(a, b)).isEqualTo(4);
    }

    @Test
    void noCommonElementsProducesAnEmptySubsequence() {
        Character[] a = {'A'};
        Character[] b = {'B'};

        assertThat(Lcs.longestCommonSubsequence(a, b)).isEmpty();
    }

    @Test
    void identicalArraysProduceTheFullSequence() {
        Character[] a = {'A', 'B', 'C'};
        Character[] b = {'A', 'B', 'C'};

        assertThat(Lcs.longestCommonSubsequence(a, b)).containsExactly('A', 'B', 'C');
    }

    @Test
    void eitherArrayEmptyProducesAnEmptySubsequence() {
        Character[] a = {};
        Character[] b = {'A', 'B'};

        assertThat(Lcs.longestCommonSubsequence(a, b)).isEmpty();
    }

    @Test
    void bruteForceAgreesWithTheDpLengthOnTheSameInputs() {
        Character[] a = {'A', 'B', 'C', 'B', 'D', 'A', 'B'};
        Character[] b = {'B', 'D', 'C', 'A', 'B', 'A'};

        assertThat(Lcs.bruteForceLength(a, b)).isEqualTo(Lcs.length(a, b));
    }

    @Test
    void rejectsNullArrays() {
        assertThatThrownBy(() -> Lcs.longestCommonSubsequence(null, new Character[0]))
                .isInstanceOf(IllegalArgumentException.class);
        assertThatThrownBy(() -> Lcs.longestCommonSubsequence(new Character[0], null))
                .isInstanceOf(IllegalArgumentException.class);
    }
}
src/test/java/com/algorithms/dynamicprogramming/lcs/applied/LedgerReconciliationDiffTest.java
package com.algorithms.dynamicprogramming.lcs.applied;

import org.junit.jupiter.api.Test;

import java.math.BigDecimal;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

class LedgerReconciliationDiffTest {

    @Test
    void aMissingTransactionOnTheBankSideIsExcludedFromTheReconciledSet() {
        LedgerReconciliationDiff diff = new LedgerReconciliationDiff();
        LedgerEntry[] internalLedger = {
                entry("TX1"), entry("TX2"), entry("TX3"), entry("TX4"),
        };
        LedgerEntry[] bankStatement = {
                entry("TX1"), entry("TX3"), entry("TX4"),
        };

        assertThat(diff.reconciledReferences(internalLedger, bankStatement))
                .containsExactly("TX1", "TX3", "TX4");
    }

    @Test
    void identicalLedgersReconcileEverything() {
        LedgerReconciliationDiff diff = new LedgerReconciliationDiff();
        LedgerEntry[] ledger = {entry("TX1"), entry("TX2")};

        assertThat(diff.reconciledReferences(ledger, ledger)).containsExactly("TX1", "TX2");
    }

    @Test
    void noOverlapReconcilesNothing() {
        LedgerReconciliationDiff diff = new LedgerReconciliationDiff();
        LedgerEntry[] internalLedger = {entry("TX1")};
        LedgerEntry[] bankStatement = {entry("TX2")};

        assertThat(diff.reconciledReferences(internalLedger, bankStatement)).isEmpty();
    }

    @Test
    void rejectsNullLedgers() {
        LedgerReconciliationDiff diff = new LedgerReconciliationDiff();

        assertThatThrownBy(() -> diff.reconciledReferences(null, new LedgerEntry[0]))
                .isInstanceOf(IllegalArgumentException.class);
        assertThatThrownBy(() -> diff.reconciledReferences(new LedgerEntry[0], null))
                .isInstanceOf(IllegalArgumentException.class);
    }

    private static LedgerEntry entry(String reference) {
        return new LedgerEntry(reference, BigDecimal.TEN);
    }
}

Ver informe completo de cobertura JaCoCo →