← Todos os algoritmos

Longest Common Subsequence

Programação Dinâmica · ver código-fonte no GitHub

Leia em: English · Português · Español

Categoria: Dynamic Programming

O problema

Comparar duas sequências em busca da maior ordem compartilhada entre elas — elementos que aparecem em ambas, na mesma ordem relativa, mas não necessariamente de forma contígua ou nas mesmas posições. Verificar cada uma das 2^n subsequências de uma entrada contra a outra para encontrar a mais longa que também seja subsequência da segunda é exponencial, e piora quanto mais as duas entradas divergem.

A solução

Defina dp[i][j] como o comprimento da LCS entre os primeiros i elementos de a e os primeiros j elementos de b. Se a[i-1] for igual a b[j-1], esse elemento compartilhado estende qualquer LCS que já existisse para os dois prefixos mais curtos: dp[i][j] = dp[i-1][j-1] + 1. Se não houver correspondência, a melhor opção disponível é a que resultar de "descartar o último elemento de a" ou "descartar o último elemento de b", o que deixar uma LCS mais longa: dp[i][j] = max(dp[i-1][j], dp[i][j-1]). Preencher essa tabela de baixo para cima é O(n × m); percorrendo-a de trás para frente a partir do canto inferior direito, reaplicando a mesma lógica de correspondência/não correspondência de forma reversa, recupera-se a subsequência real — não apenas seu comprimento.

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])"]
Operação Custo Por quê
Preenchimento da tabela DP O(n × m) uma decisão de tempo constante por célula
Recuperação da subsequência (backtrack) O(n + m) um passo por célula percorrida de volta, sem resolver de novo
Força bruta (sem memoização) exponencial — se aproxima de C(n+m, n) no pior caso cada não correspondência se ramifica em dois caminhos, sem cache para interromper um par (i, j) repetido

Exemplo clássico

classic/Lcs é genérico para qualquer tipo de elemento com um equals funcional — funciona com Character[], String[], ou qualquer tipo de domínio. longestCommonSubsequence retorna a subsequência realmente recuperada; length é um wrapper de conveniência; bruteForceLength é a mesma recursão ingênua e não memoizada contra a qual o módulo Fibonacci deste repositório alerta, incluída aqui especificamente para servir de comparação no benchmark abaixo. LcsTest cobre um caso inequívoco verificado em relação à sequência exata recuperada, o exemplo clássico do livro-texto CLRS ("ABCBDAB" vs. "BDCABA", LCS de comprimento 4), nenhum elemento em comum, entradas idênticas, uma entrada vazia, a força bruta concordando com o comprimento do DP nas mesmas entradas, e as proteções contra argumento null.

Exemplo aplicado: diff de reconciliação de livro-razão bancário

applied/LedgerReconciliationDiff alinha dois livros-razão de transações — um livro-razão interno e o extrato de um banco correspondente para o mesmo período — encontrando a maior subsequência comum de referências de transação coincidentes que ainda mantêm a ordem relativa original. As entradas nessa subsequência comum são consideradas reconciliadas; tudo o mais é uma quebra de reconciliação genuína (presente em um lado, ausente no outro), e não apenas uma reordenação sem relação — a LCS apenas descarta elementos para encontrar a ordem compartilhada, nunca trata uma reordenação como uma divergência da forma que uma comparação posicional estrita trataria. LedgerReconciliationDiffTest cobre uma transação ausente do lado do banco, livros-razão idênticos reconciliando completamente, nenhuma sobreposição, e as proteções contra argumento null.

Benchmark

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

Execução real nesta máquina (JMH 1.37, JDK 26.0.2, 2 iterações de warmup + 3 de medição, 1 fork). As duas sequências são extraídas de alfabetos disjuntosa de {A,B,C,D}, b de {W,X,Y,Z} — de forma que toda comparação de caracteres resulta em não correspondência, forçando a recursão de força bruta ao seu verdadeiro pior caso (uma correspondência colapsa diretamente em uma única chamada recursiva; uma não correspondência sempre se ramifica em dois caminhos):

Custo length=8 length=11 length=14
DP 0.481 µs 0.852 µs 1.070 µs
força bruta 60.22 µs 3,299.02 µs 185,880.08 µs

Em length=14, a força bruta é ~173,720x mais lenta que o DP para a mesma resposta. O crescimento corresponde à teoria com precisão impressionante: ao ir de length=8 para length=11 (a contagem de chamadas se aproxima de C(22,11)/C(16,8) ≈ 54.8x), a força bruta de fato mediu ~54.8x mais lenta — uma correspondência quase exata. Ao ir de length=11 para length=14 (C(28,14)/C(22,11) ≈ 56.9x previsto), o custo medido cresceu ~56.3x — a explosão combinatória que a docstring deste módulo descreve não é uma aproximação aqui, é o número que realmente apareceu.

Quando não usar

Cobertura de testes

100% de cobertura de instruções, 100% de cobertura de branches (JaCoCo). Reproduza você mesmo:

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

Relatório em dynamic-programming/longest-common-subsequence/build/reports/jacoco/test/html/index.html.

Leitura complementar

Testes unitários

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 relatório completo de cobertura JaCoCo →