Problema de subsequência comum mais longo - Longest common subsequence problem

Comparação de duas revisões de um arquivo de exemplo, com base em sua maior subsequência comum (preto)

O maior subsequência comum ( LCS ) problema é o problema de encontrar o maior subsequência comum a todas as seqüências em um conjunto de seqüências (frequentemente apenas duas sequências). Ele difere do mais longo problema comum de substrings : ao contrário de substrings, subsequências não precisam ocupar posições consecutivas dentro das sequências originais. O problema de subsequência comum mais longo é um problema clássico de ciência da computação , a base de programas de comparação de dados , como o utilitário diff , e tem aplicações em linguística computacional e bioinformática . Também é amplamente utilizado porsistemas de controle de revisão , como o Git, para reconciliar várias alterações feitas em uma coleção de arquivos controlada por revisão.

Por exemplo, considere as sequências (ABCD) e (ACBAD). Eles têm 5 comprimento-2 subseqüências comuns: (AB), (AC), (AD), (BD) e (CD); 2 comprimento-3 subsequências comuns: (ABD) e (ACD); e não mais subseqüências comuns. Portanto, (ABD) e (ACD) são suas subsequências comuns mais longas.

Complexidade

Para o caso geral de um número arbitrário de sequências de entrada, o problema é NP-difícil . Quando o número de sequências é constante, o problema pode ser resolvido em tempo polinomial por programação dinâmica .

Dadas sequências de comprimentos , uma pesquisa ingênua testaria cada uma das subsequências da primeira sequência para determinar se elas também são subsequências das sequências restantes; cada subsequência pode ser testada no tempo linear nos comprimentos das sequências restantes, então o tempo para este algoritmo seria

Para o caso de duas sequências de n e m elementos, o tempo de execução da abordagem de programação dinâmica é O ( n × m ). Para um número arbitrário de sequências de entrada, a abordagem de programação dinâmica oferece uma solução em

Existem métodos com menor complexidade, que muitas vezes dependem do comprimento do LCS, do tamanho do alfabeto ou de ambos.

O LCS não é necessariamente exclusivo; no pior caso, o número de subsequências comuns é exponencial nos comprimentos das entradas, então a complexidade algorítmica deve ser pelo menos exponencial.

Solução para duas sequências

O problema LCS tem uma subestrutura ótima : o problema pode ser dividido em subproblemas menores e mais simples, que podem, por sua vez, ser decompostos em subproblemas mais simples, e assim por diante, até que, finalmente, a solução se torne trivial. LCS, em particular, tem subproblemas sobrepostos : as soluções para subproblemas de alto nível frequentemente reutilizam soluções para subproblemas de nível inferior. Problemas com essas duas propriedades são passíveis de programação dinâmica se aproxima, em que as soluções dos subproblemas são memoized , isto é, as soluções de subproblemas são guardados para reutilização.

Prefixos

O prefixo S n de S é definida como os primeiros n caracteres de S . Por exemplo, os prefixos de S = (AGCA) são

S 0 = ()
S 1 = (A)
S 2 = (AG)
S 3 = (AGC)
S 4 = (AGCA).

Deixe LCS ( X , Y ) ser uma função que calcula um comum maior subsequência para X e Y . Essa função tem duas propriedades interessantes.

Primeira propriedade

LCS ( X ^ A , Y ^ A ) = LCS ( X , Y ) ^ A , para todas as strings X , Y e todos os símbolos A , onde ^ denota a concatenação de strings. Isso permite simplificar o cálculo LCS para duas sequências que terminam no mesmo símbolo. Por exemplo, LCS ("BANANA", "ATANA") = LCS ("BANAN", "ATAN") ^ "A", Continuando para os símbolos comuns restantes, LCS ("BANANA", "ATANA") = LCS (" BAN "," AT ") ^" ANA ".

Segunda propriedade

Se A e B são símbolos distintos ( AB ), então LCS (X ^ A, Y ^ B) é uma das strings de comprimento máximo no conjunto { LCS ( X ^ A , Y ), LCS ( X , Y ^ B )}, para todas as cadeias X , Y .

Por exemplo, LCS ("ABCDEFG", "BCDGK") é a cadeia mais longa entre LCS ("ABCDEFG", "BCDG") e LCS ("ABCDEF", "BCDGK"); se ambos tivessem o mesmo comprimento, um deles poderia ser escolhido arbitrariamente.

Para realizar a propriedade, diferencie dois casos:

  • Se LCS ("ABCDEFG", "BCDGK") termina com um "G", então o "K" final não pode estar no LCS, portanto LCS ("ABCDEFG", "BCDGK") = LCS ("ABCDEFG", "BCDG ").
  • Se LCS ("ABCDEFG", "BCDGK") não terminar com um "G", então o "G" final não pode estar no LCS, portanto, LCS ("ABCDEFG", "BCDGK") = LCS ("ABCDEF", "BCDGK").

Função LCS definida

Sejam duas sequências definidas como segue: e . Os prefixos de são ; os prefixos de são . Deixe representar o conjunto da maior subsequência comum de prefixos e . Este conjunto de sequências é dado a seguir.

Para encontrar o LCS de e , compare e . Se eles forem iguais, a sequência é estendida por esse elemento ,. Se eles não forem iguais, então o mais longo entre as duas sequências , e , é retido. (Se eles tiverem o mesmo comprimento, mas não idênticos, ambos serão mantidos.)

Exemplo trabalhado

A maior subsequência comum a R = (GAC) e C = (AGCAT) será encontrada. Como a função LCS usa um elemento "zero", é conveniente definir prefixos zero que estão vazios para essas sequências: R 0 = Ø; e C 0 = Ø. Todos os prefixos são colocados numa mesa com C na primeira linha (tornando-o um c olumn cabeçalho) e R na primeira coluna (tornando-o uma r OW cabeçalho).

LCS Strings
Ø UMA G C UMA T
Ø Ø Ø Ø Ø Ø Ø
G Ø
UMA Ø
C Ø

Esta tabela é usada para armazenar a sequência LCS para cada etapa do cálculo. A segunda coluna e a segunda linha foram preenchidas com Ø, porque quando uma sequência vazia é comparada com uma sequência não vazia, a maior subsequência comum é sempre uma sequência vazia.

LCS ( R 1 , C 1 ) é determinado comparando os primeiros elementos em cada sequência. G e A não são iguais, então este LCS obtém (usando a "segunda propriedade") a mais longa das duas sequências, LCS ( R 1 , C 0 ) e LCS ( R 0 , C 1 ). De acordo com a tabela, ambos estão vazios, portanto LCS ( R 1 , C 1 ) também está vazio, conforme mostrado na tabela abaixo. As setas indicam que a sequência vem tanto da célula acima, LCS ( R 0 , C 1 ) e da célula à esquerda, LCS ( R 1 , C 0 ).

LCS ( R 1 , C 2 ) é determinado comparando G e G. Eles correspondem, então G é anexado à sequência superior esquerda, LCS ( R 0 , C 1 ), que é (Ø), dando (ØG), que é (G).

Para LCS ( R 1 , C 3 ), G e C não correspondem. A sequência acima está vazia; o que está à esquerda contém um elemento, G. Selecionando o mais longo deles, LCS ( R 1 , C 3 ) é (G). A seta aponta para a esquerda, pois é a mais longa das duas sequências.

LCS ( R 1 , C 4 ), da mesma forma, é (G).

LCS ( R 1 , C 5 ), da mesma forma, é (G).

Linha "G" concluída
Ø UMA G C UMA T
Ø Ø Ø Ø Ø Ø Ø
G Ø Ø (G) (G) (G) (G)
UMA Ø
C Ø

Para LCS ( R 2 , C 1 ), A é comparado com A. Os dois elementos combinam, então A é anexado a Ø, dando (A).

Para LCS ( R 2 , C 2 ), A e G não correspondem, então o mais longo de LCS ( R 1 , C 2 ), que é (G), e LCS ( R 2 , C 1 ), que é (A ), é usado. Nesse caso, cada um deles contém um elemento, então este LCS recebe duas subsequências: (A) e (G).

Para LCS ( R 2 , C 3 ), A não corresponde a C. LCS ( R 2 , C 2 ) contém as sequências (A) e (G); LCS ( R 1 , C 3 ) é (G), que já está contido em LCS ( R 2 , C 2 ). O resultado é que LCS ( R 2 , C 3 ) também contém as duas subsequências, (A) e (G).

Para LCS ( R 2 , C 4 ), A corresponde a A, que é anexado à célula superior esquerda, dando (GA).

Para LCS ( R 2 , C 5 ), A não corresponde a T. Comparando as duas sequências, (GA) e (G), a mais longa é (GA), então LCS ( R 2 , C 5 ) é (GA).

Linhas "G" e "A" concluídas
Ø UMA G C UMA T
Ø Ø Ø Ø Ø Ø Ø
G Ø Ø (G) (G) (G) (G)
UMA Ø (UMA) (A) e (G) (A) e (G) (GA) (GA)
C Ø

Para LCS ( R 3 , C 1 ), C e A não correspondem, então LCS ( R 3 , C 1 ) obtém a mais longa das duas sequências, (A).

Para LCS ( R 3 , C 2 ), C e G não correspondem. Ambos LCS ( R 3 , C 1 ) e LCS ( R 2 , C 2 ) têm um elemento. O resultado é que LCS ( R 3 , C 2 ) contém as duas subsequências, (A) e (G).

Para LCS ( R 3 , C 3 ), C e C coincidem, então C é anexado a LCS ( R 2 , C 2 ), que contém as duas subsequências, (A) e (G), dando (AC) e (GC )

Para LCS ( R 3 , C 4 ), C e A não correspondem. Combinando LCS ( R 3 , C 3 ), que contém (AC) e (GC), e LCS ( R 2 , C 4 ), que contém (GA), dá um total de três sequências: (AC), (GC) , e (GA).

Finalmente, para LCS ( R 3 , C 5 ), C e T não correspondem. O resultado é que LCS ( R 3 , C 5 ) também contém as três sequências, (AC), (GC) e (GA).

Tabela LCS concluída
Ø UMA G C UMA T
Ø Ø Ø Ø Ø Ø Ø
G Ø Ø (G) (G) (G) (G)
UMA Ø (UMA) (A) e (G) (A) e (G) (GA) (GA)
C Ø (UMA) (A) e (G) (AC) e (GC) (AC) e (GC) e (GA) (AC) e (GC) e (GA)

O resultado final é que a última célula contém todas as subsequências mais longas comuns a (AGCAT) e (GAC); estes são (AC), (GC) e (GA). A tabela também mostra as subseqüências comuns mais longas para cada par possível de prefixos. Por exemplo, para (AGC) e (GA), a maior subsequência comum é (A) e (G).

Abordagem Traceback

Calcular o LCS de uma linha da tabela LCS requer apenas as soluções para a linha atual e a linha anterior. Ainda assim, para sequências longas, essas sequências podem ser numerosas e longas, exigindo muito espaço de armazenamento. O espaço de armazenamento pode ser economizado salvando não as subsequências reais, mas o comprimento da subsequência e a direção das setas, como na tabela abaixo.

Armazenamento de comprimento, em vez de sequências
Ø UMA G C UMA T
Ø 0 0 0 0 0 0
G 0 0 1 1 1 1
UMA 0 1 1 1 2 2
C 0 1 1 2 2 2

As subsequências reais são deduzidas em um procedimento de "rastreamento" que segue as setas para trás, começando da última célula da tabela. Quando o comprimento diminui, as sequências devem ter um elemento comum. Vários caminhos são possíveis quando duas setas são mostradas em uma célula. Abaixo está a tabela para tal análise, com números coloridos nas células onde o comprimento está prestes a diminuir. Os números em negrito traçam a sequência, (GA).

Exemplo de traceback
Ø UMA G C UMA T
Ø 0 0 0 0 0 0
G 0 0 1 1 1 1
UMA 0 1 1 1 2 2
C 0 1 1 2 2 2

Relação com outros problemas

Para duas strings e , o comprimento da supersequência comum mais curta está relacionado ao comprimento do LCS por

A distância de edição quando apenas a inserção e exclusão são permitidas (sem substituição), ou quando o custo da substituição é o dobro do custo de uma inserção ou exclusão, é:

Código para a solução de programação dinâmica

Calculando o comprimento do LCS

A função abaixo recebe como sequências de entrada X[1..m]e Y[1..n], calcula o LCS entre X[1..i]e Y[1..j]para todos 1 ≤ i ≤ me 1 ≤ j ≤ n, e o armazena em C[i,j]. C[m,n]conterá o comprimento do LCS de Xe Y.

function LCSLength(X[1..m], Y[1..n])
    C = array(0..m, 0..n)
    for i := 0..m
        C[i,0] = 0
    for j := 0..n
        C[0,j] = 0
    for i := 1..m
        for j := 1..n
            if X[i] = Y[j]
                C[i,j] := C[i-1,j-1] + 1
            else
                C[i,j] := max(C[i,j-1], C[i-1,j])
    return C[m,n]

Alternativamente, memoização pode ser usada.

Exemplo em C #

static int[,] LcsLength(string a, string b)
{
    int[,] C = new int[a.Length + 1, b.Length + 1]; // (a, b).Length + 1
    for (int i = 0; i < a.Length; i++)
        C[i, 0] = 0;
    for (int j = 0; j < b.Length; j++)
        C[0, j] = 0;
    for (int i = 1; i <= a.Length; i++)
        for (int j = 1; j <= b.Length; j++)
        {
            if (a[i - 1] == b[j - 1])//i-1,j-1
                C[i, j] = C[i - 1, j - 1] + 1;
            else
                C[i, j] = Math.Max(C[i, j - 1], C[i - 1, j]);
        }
    return C;
}

Lendo um LCS

A função a seguir rastreia as escolhas feitas ao calcular a Ctabela. Se os últimos caracteres nos prefixos forem iguais, eles devem estar em um LCS. Caso contrário, verifique o que deu o maior LCS de manutenção e , e faça a mesma escolha. Basta escolher um se forem igualmente longos. Chame a função com e . i=mj=n

function backtrack(C[0..m,0..n], X[1..m], Y[1..n], i, j)
    if i = 0 or j = 0
        return ""
    if  X[i] = Y[j]
        return backtrack(C, X, Y, i-1, j-1) + X[i]
    if C[i,j-1] > C[i-1,j]
        return backtrack(C, X, Y, i, j-1)
    return backtrack(C, X, Y, i-1, j)

Exemplo em C #

string Backtrack(int[,] C, char[] aStr, char[] bStr, int x, int y)
{
    if (x == 0 | y == 0)
        return "";
    if (aStr[x - 1] == bStr[y - 1]) // x-1, y-1
        return backtrack(C, aStr, bStr, x - 1, y - 1) + aStr[x - 1]; // x-1
    if (C[x, y - 1] > C[x - 1, y])
        return backtrack(C, aStr, bStr, x, y - 1);
    return backtrack(C, aStr, bStr, x - 1, y);
}

Lendo todos os LCSs

Se escolher e der um resultado igualmente longo, leia as duas subseqüências resultantes. Isso é retornado como um conjunto por esta função. Observe que essa função não é polinomial, pois ela pode se ramificar em quase todas as etapas se as strings forem semelhantes.

function backtrackAll(C[0..m,0..n], X[1..m], Y[1..n], i, j)
    if i = 0 or j = 0
        return {""}
    if X[i] = Y[j]
        return {Z + X[i] for all Z in backtrackAll(C, X, Y, i-1, j-1)}
    R := {}
    if C[i,j-1] ≥ C[i-1,j]
        R := backtrackAll(C, X, Y, i, j-1)
    if C[i-1,j] ≥ C[i,j-1]
        R := R ∪ backtrackAll(C, X, Y, i-1, j)
    return R

Imprima o diff

Esta função retrocederá pela matriz C e imprimirá a diferença entre as duas sequências. Observe que você obterá uma resposta diferente se trocar e <, com >e abaixo.

function printDiff(C[0..m,0..n], X[1..m], Y[1..n], i, j)
    if i >= 0 and j >= 0 and X[i] = Y[j]
        printDiff(C, X, Y, i-1, j-1)
        print "  " + X[i]
    else if j > 0 and (i = 0 or C[i,j-1] ≥ C[i-1,j])
        printDiff(C, X, Y, i, j-1)
        print "+ " + Y[j]
    else if i > 0 and (j = 0 or C[i,j-1] < C[i-1,j])
        printDiff(C, X, Y, i-1, j)
        print "- " + X[i]
    else
        print ""

Exemplo

Seja “ ” e seja “ ”. A maior subsequência comum entre e é “ ”. A tabela mostrada abaixo, que é gerada pela função , mostra os comprimentos das maiores subsequências comuns entre os prefixos de e . A linha e a coluna mostram o comprimento do LCS entre e . XMJYAUZMZJAWXUMJAUCLCSLength

0 1 2 3 4 5 6 7
Ø M Z J UMA C X você
0 Ø 0 0 0 0 0 0 0 0
1 X 0 0 0 0 0 0 1 1
2 M 0 1 1 1 1 1 1 1
3 J 0 1 1 2 2 2 2 2
4 Y 0 1 1 2 2 2 2 2
5 UMA 0 1 1 2 3 3 3 3
6 você 0 1 1 2 3 3 3 4
7 Z 0 1 2 2 3 3 3 4

Os números destacados mostram o caminho que a função backtrackseguiria do canto inferior direito ao canto superior esquerdo, ao ler um LCS. Se os símbolos atuais em e são iguais, eles fazem parte do LCS e vamos para cima e para a esquerda (mostrado em negrito ). Do contrário, subimos ou saímos, dependendo de qual célula tiver um número maior. Isso corresponde a tomar o LCS entre e , ou e .

Otimização de código

Várias otimizações podem ser feitas no algoritmo acima para acelerá-lo para casos do mundo real.

Reduza o conjunto de problemas

A matriz C no algoritmo ingênuo cresce quadraticamente com os comprimentos das sequências. Para duas sequências de 100 itens, uma matriz de 10.000 itens seria necessária e 10.000 comparações precisariam ser feitas. Na maioria dos casos do mundo real, especialmente diffs e patches de código-fonte, o início e o fim dos arquivos raramente mudam, e quase certamente não os dois ao mesmo tempo. Se apenas alguns itens foram alterados no meio da sequência, o início e o fim podem ser eliminados. Isso reduz não apenas os requisitos de memória para a matriz, mas também o número de comparações que devem ser feitas.

function LCS(X[1..m], Y[1..n])
    start := 1
    m_end := m
    n_end := n
    trim off the matching items at the beginning
    while start ≤ m_end and start ≤ n_end and X[start] = Y[start]
        start := start + 1
    trim off the matching items at the end
    while start ≤ m_end and start ≤ n_end and X[m_end] = Y[n_end]
        m_end := m_end - 1
        n_end := n_end - 1
    C = array(start-1..m_end, start-1..n_end)
    only loop over the items that have changed
    for i := start..m_end
        for j := start..n_end
            the algorithm continues as before ...

Na melhor das hipóteses, uma sequência sem alterações, essa otimização eliminaria completamente a necessidade da matriz C. No pior cenário, uma mudança nos primeiros e nos últimos itens da sequência, apenas duas comparações adicionais são realizadas.

Reduza o tempo de comparação

A maior parte do tempo gasto pelo algoritmo ingênuo é gasto na realização de comparações entre itens nas sequências. Para sequências textuais, como código-fonte, você deseja visualizar as linhas como os elementos da sequência em vez de caracteres únicos. Isso pode significar comparações de strings relativamente longas para cada etapa do algoritmo. Duas otimizações podem ser feitas para ajudar a reduzir o tempo que essas comparações consomem.

Reduza strings para hashes

Uma função hash ou checksum pode ser usada para reduzir o tamanho das strings nas sequências. Ou seja, para o código-fonte em que a linha média tem 60 ou mais caracteres, o hash ou checksum dessa linha pode ter apenas 8 a 40 caracteres. Além disso, a natureza aleatória dos hashes e somas de verificação garantiria que as comparações entrariam em curto-circuito mais rápido, já que as linhas do código-fonte raramente serão alteradas no início.

Existem três desvantagens principais para essa otimização. Primeiro, uma quantidade de tempo precisa ser gasta de antemão para pré-calcular os hashes para as duas sequências. Em segundo lugar, memória adicional precisa ser alocada para as novas sequências em hash. No entanto, em comparação com o algoritmo ingênuo usado aqui, essas duas desvantagens são relativamente mínimas.

A terceira desvantagem são as colisões . Como a soma de verificação ou hash não é garantida como única, há uma pequena chance de que dois itens diferentes possam ser reduzidos ao mesmo hash. Isso é improvável no código-fonte, mas é possível. Um hash criptográfico seria, portanto, muito mais adequado para essa otimização, pois sua entropia será significativamente maior do que uma simples soma de verificação. No entanto, os benefícios podem não valer a pena a configuração e os requisitos computacionais de um hash criptográfico para pequenos comprimentos de sequência.

Reduza o espaço necessário

Se apenas o comprimento do LCS for necessário, a matriz pode ser reduzida a uma matriz com facilidade ou a um vetor (mais inteligente), pois a abordagem de programação dinâmica precisa apenas das colunas atuais e anteriores da matriz. O algoritmo de Hirschberg permite a construção da própria sequência ótima no mesmo tempo quadrático e limites de espaço linear.

Algoritmos otimizados adicionais

Existem vários algoritmos que funcionam mais rápido do que a abordagem de programação dinâmica apresentada. Um deles é o algoritmo Hunt – Szymanski , que normalmente é executado no tempo (para ), onde é o número de correspondências entre as duas sequências. Para problemas com um tamanho de alfabeto limitado, o Método dos Quatro Russos pode ser usado para reduzir o tempo de execução do algoritmo de programação dinâmica por um fator logarítmico.

Comportamento em strings aleatórias

Começando com Chvátal & Sankoff (1975) , vários pesquisadores investigaram o comportamento do maior comprimento de subsequência comum quando as duas cadeias de caracteres fornecidas são tiradas aleatoriamente do mesmo alfabeto. Quando o tamanho do alfabeto é constante, o comprimento esperado do LCS é proporcional ao comprimento das duas strings, e as constantes de proporcionalidade (dependendo do tamanho do alfabeto) são conhecidas como constantes Chvátal – Sankoff . Seus valores exatos não são conhecidos, mas os limites superior e inferior de seus valores foram comprovados, e sabe-se que eles crescem inversamente proporcionalmente à raiz quadrada do tamanho do alfabeto. Demonstrou-se que modelos matemáticos simplificados do maior problema de subsequência comum são controlados pela distribuição Tracy – Widom .

Veja também

Referências

links externos