Longest Common Subsequence - ricorrenza, tabella e ricostruzione
In questa pagina 9
La LCS è il secondo grande esempio di programmazione dinamicaSchema in cinque passi: sottostruttura ottima, ricorrenza sui costi, informazione addizionale, calcolo bottom-up o memoizzato, ricostruzioneProgrammazione dinamica - Fibonacci, memoizzazione e schema del paradigma → ed è il modello di tutti i problemi su stringhe che seguono.
Stringhe, prefissi, sottostringhe, sottosequenze
Sia un alfabeto finito. Una stringa è una sequenza finita di simboli di ; è la stringa vuota e l'insieme di tutte le stringhe finite (stella di Kleene).
- Prefisso , per , con .
- Suffisso , per , con .
- Sottostringa per (simboli contigui); se .
- Sottosequenza: una stringa per cui esiste una successione di indici strettamente crescente con . Si dice che gli indici realizzano in . Una stessa può avere più successioni di indici.
Esempio. e : è sottosequenza di , realizzata da e anche da . Non è una sottostringa. Ogni sottostringa è una sottosequenza (con indici ), non viceversa.
Quante sono. Le sottostringhe sono : ce ne sono di lunghezza , di lunghezza , , una di lunghezza , più :
Le sottosequenze sono molte di più. Se i caratteri di sono tutti distinti, ogni sottoinsieme di indici di dà una sottosequenza distinta (la si ottiene ordinando gli indici), e i sottoinsiemi di taglia sono (Fattoriale e coefficienti binomialiFattoriale, permutazioni, disposizioni, combinazioni e coefficiente binomiale n su k, con il triangolo di Tartaglia.Fattoriale e coefficienti binomiali →). In totale
per il binomio di Newton (Binomio di NewtonLa formula per sviluppare (a+b)^n con i coefficienti binomiali.Binomio di Newton →); contando anche (per ) sono . Per questo un approccio che enumera le sottosequenze di e le verifica su è esponenziale.
Il problema
Definizione (LCS). Date e , determinare una stringa di lunghezza massima che sia sottosequenza sia di sia di .
Esempio. , : la LCS è , lunga , realizzata in da e in da .
La lunghezza della LCS misura la somiglianza tra due stringhe; si usa in genomica (confronto di sequenze di DNA) e nella correzione automatica dei testi.
La LCS può non essere unica: e hanno due LCS di lunghezza , e . Si cerca una LCS.
Proprietà di sottostruttura ottima
Idea: guardare l'ultimo simbolo. Se e finiscono con lo stesso simbolo, quel simbolo si può sempre accoppiare; altrimenti uno dei due simboli finali non è usato. Questo riconduce un'istanza a istanze formate da coppie di prefissi, che sono : uno spazio quadratico, non esponenziale come le sottosequenze. La generica sottoistanza è con e , e si vuole .
Teorema (sottostruttura della LCS). Siano , prefissi di e e sia una LCS di e .
- Se o , allora .
- Se e :
- (a) se , allora e è una LCS di e ;
- (b) se , allora è una LCS di oppure di , e quindi è la più lunga tra le due.
Dimostrazione. Il punto 1 è banale: un prefisso vuoto non ha sottosequenze non vuote.
Caso (a), . Siano e gli indici che realizzano in e in .
- . Per assurdo sia (oppure ). Allora e (altrimenti sarebbe o ), cioè e . Ma allora è una sottosequenza comune, realizzata dagli indici in e in , ed è più lunga di : contraddizione con la massimalità di .
- è una LCS di e . Gli indici sono tutti , e lo stesso vale in : quindi è una sottosequenza comune di e . Se esistesse una sottosequenza comune di e più lunga di , allora sarebbe una sottosequenza comune di e più lunga di (è la tecnica del cut-and-paste: si taglia e si incolla ), contro la massimalità di .
Caso (b), . Il simbolo non può essere uguale sia a sia a . Se (o ), l'indice non è usato per realizzare in , quindi è una sottosequenza comune di e . Se , per lo stesso motivo è sottosequenza comune di e . Almeno una delle due vale. Nel primo caso deve essere una LCS di (una sottosequenza comune più lunga, che è anche sottosequenza comune di e , batterebbe ). Nel secondo, di . Infine le LCS di e di sono entrambe sottosequenze comuni di e , quindi nessuna è più lunga di : è la più lunga delle due.
Ricorrenza sui costi e informazione addizionale
Sia . Dal teorema, per e :
Informazione addizionale. Per i casi base la soluzione è sempre : non si salva nulla. Per gli altri ci sono solo tre modi di costruire da sottoistanze: , oppure , oppure . Basta ricordare quale: una freccia
Il codice bottom-up
Si usano due tabelle e . La cella dipende solo da , e . Per un ordine coerente basta che al momento di calcolare queste tre siano già pronte (si inizializzano riga e colonna , che contengono i casi base). Funzionano sia la scansione per righe (riga da sinistra a destra, poi riga , ...) sia quella per colonne: in entrambe, le tre celle da cui si dipende precedono . Per righe è preferibile in linguaggi che memorizzano le matrici per riga, per l'uso della cache.
LCS(X, Y)
m <- |X|; n <- |Y|
for i <- 0 to m do l[i, 0] <- 0
for j <- 1 to n do l[0, j] <- 0
for i <- 1 to m do
for j <- 1 to n do
if x_i = y_j then
l[i, j] <- 1 + l[i-1, j-1]
b[i, j] <- '↖'
else if l[i-1, j] >= l[i, j-1] then
l[i, j] <- l[i-1, j]
b[i, j] <- '↑'
else
l[i, j] <- l[i, j-1]
b[i, j] <- '←'
return (l[m, n], b)Costo. Contando solo i confronti tra simboli (modello di costo usuale per le stringhe), ce n'è uno per iterazione:
quadratico (con ). L'algoritmo restituisce la lunghezza; per ricostruire la LCS bisogna restituire anche tutta la tabella .
Ricostruzione. Si parte da e si segue la freccia: a ogni passo almeno una delle due coordinate scende, e diminuisce di almeno . Quindi le chiamate sono .
PRINT_LCS(X, b, i, j) { prima chiamata: PRINT_LCS(X, b, m, n) }
if i = 0 or j = 0 then return
if b[i, j] = '↖' then
PRINT_LCS(X, b, i-1, j-1) { prima la LCS dei prefissi... }
print(x_i) { ...poi il simbolo comune }
else if b[i, j] = '←' then PRINT_LCS(X, b, i, j-1)
else PRINT_LCS(X, b, i-1, j)Esempio eseguito
e . In grassetto il percorso della ricostruzione (partendo da ). Ogni cella ha il valore e la freccia .
| A | D | C | C | B | D | ||
|---|---|---|---|---|---|---|---|
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | |
| A | 0 | 1↖ | 1← | 1← | 1← | 1← | 1← |
| B | 0 | 1↑ | 1↑ | 1↑ | 1↑ | 2↖ | 2← |
| C | 0 | 1↑ | 1↑ | 2↖ | 2↖ | 2↑ | 2↑ |
| B | 0 | 1↑ | 1↑ | 2↑ | 2↑ | 3↖ | 3← |
| B | 0 | 1↑ | 1↑ | 2↑ | 2↑ | 3↖ | 3↑ |
| D | 0 | 1↑ | 2↖ | 2↑ | 2↑ | 3↑ | 4↖ |
Alcune celle a mano:
- : , quindi , freccia .
- : , quindi , freccia .
- : , quindi (la cella ha lo stesso valore, perché anche ).
- : . Si confrontano e : sono uguali, e per il del codice vince .
Ricostruzione: stampa (dopo), poi , poi sale a , poi e , , . Letti dall'alto in basso i simboli stampati sono : e la LCS è . Tutta la tabella è stata confrontata con una forza bruta (enumerazione di tutte le sottosequenze) su coppie casuali di stringhe corte.
Una LCS non è unica e il risultato dipende dal criterio nei casi di parità. Per e nella cella si ha : con il del codice si sceglie e la ricostruzione dà ; scegliendo nella parità si otterrebbe .
def lcs(X, Y):
m, n = len(X), len(Y)
l = [[0] * (n + 1) for _ in range(m + 1)]
for i in range(1, m + 1):
for j in range(1, n + 1):
if X[i - 1] == Y[j - 1]:
l[i][j] = 1 + l[i - 1][j - 1]
else:
l[i][j] = max(l[i - 1][j], l[i][j - 1])
i, j, z = m, n, []
while i > 0 and j > 0: # ricostruzione senza la tabella b
if X[i - 1] == Y[j - 1]:
z.append(X[i - 1]); i -= 1; j -= 1
elif l[i - 1][j] >= l[i][j - 1]:
i -= 1
else:
j -= 1
return l[m][n], "".join(reversed(z))
assert lcs("ABCBBD", "ADCCBD") == (4, "ACBD")(La tabella si può anche non memorizzare: la freccia si ricava a posteriori confrontando le celle vicine, come fa il ciclo qui sopra.)
Il divide and conquer sulla stessa ricorrenza
Un algoritmo ricorsivo sulla stessa sottostruttura, al caso peggiore (per esempio e senza simboli in comune), ha un nodo interno con lavoro unitario per ogni coppia con e due chiamate:
Minorazione (srotolamento). Con : (perché è non decrescente in ciascun argomento). Srotolando volte, , e con si ottiene .
Valore esatto: . Per induzione sulla somma : se o , . Altrimenti, con la regola di Pascal,
Per sono nodi, contro le celle della tabella. La causa sono le sottoistanze ripetute: la stessa coppia di prefissi viene raggiunta da molti cammini.
Risparmiare spazio
Se interessa solo la lunghezza della LCS non serve la tabella , e per bastano due righe: la riga dipende solo dalla riga e dalla riga stessa. Scambiando i ruoli in modo che si usa spazio invece di .
LCS_LENGTH(X, Y) { |Y| <= |X| }
m <- |X|; n <- |Y|
for j <- 0 to n do prev[j] <- 0
for i <- 1 to m do
curr[0] <- 0
for j <- 1 to n do
if x_i = y_j then curr[j] <- 1 + prev[j-1]
else curr[j] <- max(prev[j], curr[j-1])
prev <- curr
return prev[n](Con la ricostruzione bisogna conservare tutta la tabella, o ricalcolare a pezzi.) Il confronto con la versione a tabella intera è stato verificato su stringhe casuali.
L'algoritmo memoizzato
Si mantiene la ricorsione e si aggiunge la tabella globale. La sentinella è : è corretta perché . Le celle dei casi base valgono .
INIT_LCS(X, Y)
m <- |X|; n <- |Y|
if m = 0 or n = 0 then return 0
for i <- 0 to m do l[i, 0] <- 0
for j <- 0 to n do l[0, j] <- 0
for i <- 1 to m do
for j <- 1 to n do l[i, j] <- -1
return MEMO_LCS(X, Y, m, n)
MEMO_LCS(X, Y, i, j)
if l[i, j] = -1 then
if x_i = y_j then
l[i, j] <- 1 + MEMO_LCS(X, Y, i-1, j-1)
else
a <- MEMO_LCS(X, Y, i-1, j)
c <- MEMO_LCS(X, Y, i, j-1)
l[i, j] <- max(a, c)
return l[i, j]- Correttezza: dalla sottostruttura e dalla scelta della sentinella.
- Costo al caso peggiore: si analizza l'albero delle chiamate. Per e , la chiamata è un nodo interno al più una volta, a costo unitario (un confronto); le altre occorrenze restituiscono il valore in tabella. Quindi , uguale al bottom-up.
- Non sempre tutte le celle. Se le due stringhe sono simili (hanno lunghi tratti in comune), la discesa top-down tocca poche celle. Esempio: e . Le chiamate sono : ultimi simboli uguali (, poi , poi ), si scende in diagonale fino al caso base . Solo nodi interni (e un caso base) contro le celle del bottom-up. Nelle applicazioni di genomica, dove le sequenze sono quasi uguali, il vantaggio è notevole. Controllato anche con Python: la discesa memoizzata tocca esattamente .
- Per la coppia e dell'esempio eseguito il memoizzato calcola celle su (stringhe poco simili: il vantaggio è piccolo).
Domande d'esame
- (Risposta numerica.) Siano e e sia la lunghezza della LCS di e . Calcolare . È proprio uno dei quesiti della prima parte delle simulazioni: svolto in Esercizio - domande di prima parte su programmazione dinamica (simulazioni d'esame). Risposta: .
- (Risposta numerica.) Per , quanti confronti tra simboli esegue
LCS? E quanti nodi interni al massimo ha l'albero del D&C puro? Traccia: confronti; nodi interni. - (Dimostrazione.) Se perché termina con questo simbolo? Traccia: altrimenti si potrebbe aggiungere il simbolo comune alla fine, ottenendo una sottosequenza comune più lunga (indici e , perché ).
Versione ripasso
- Definizioni. Prefisso (), suffisso , sottostringa (contigua), sottosequenza (indici crescenti, può essere realizzata da più successioni). Sottostringhe ; sottosequenze di una stringa a simboli distinti (Binomio di NewtonLa formula per sviluppare (a+b)^n con i coefficienti binomiali.Binomio di Newton →).
- LCS: sottosequenza comune di e di lunghezza massima, misura di somiglianza (DNA, correzione dei testi). Esempio: e hanno LCS . Può non essere unica ( e per e ).
- Teorema (prefissi , una LCS). (1) o : . (2a) : e è LCS di . (2b) : è la più lunga tra LCS e LCS. Prova per assurdo e cut-and-paste: se si aggiungerebbe ; se non fosse ottima la si sostituirebbe con una più lunga e si aggiungerebbe .
- Ricorrenza: se ; se ; altrimenti. Freccia (informazione addizionale).
- Bottom-up: tabelle , di celle, riga e colonna a , poi per righe (o per colonne), perché dipende da . Costo confronti.
PRINT_LCSsegue le frecce, chiamate; stampa il simbolo dopo la chiamata ricorsiva su . - Codice.
LCS(X, Y)
for i <- 0 to m do l[i, 0] <- 0; for j <- 1 to n do l[0, j] <- 0
for i <- 1 to m do for j <- 1 to n do
if x_i = y_j then l[i,j] <- 1 + l[i-1,j-1]; b[i,j] <- '↖'
else if l[i-1,j] >= l[i,j-1] then l[i,j] <- l[i-1,j]; b[i,j] <- '↑'
else l[i,j] <- l[i,j-1]; b[i,j] <- '←'
return (l[m,n], b)
PRINT_LCS(X, b, i, j)
if i = 0 or j = 0 then return
if b[i,j] = '↖' then PRINT_LCS(X,b,i-1,j-1); print(x_i)
else if b[i,j] = '←' then PRINT_LCS(X,b,i,j-1)
else PRINT_LCS(X,b,i-1,j)- Esempio: , , . Il percorso è in grassetto.
| A | D | C | C | B | D | ||
|---|---|---|---|---|---|---|---|
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | |
| A | 0 | 1↖ | 1← | 1← | 1← | 1← | 1← |
| B | 0 | 1↑ | 1↑ | 1↑ | 1↑ | 2↖ | 2← |
| C | 0 | 1↑ | 1↑ | 2↖ | 2↖ | 2↑ | 2↑ |
| B | 0 | 1↑ | 1↑ | 2↑ | 2↑ | 3↖ | 3← |
| B | 0 | 1↑ | 1↑ | 2↑ | 2↑ | 3↖ | 3↑ |
| D | 0 | 1↑ | 2↖ | 2↑ | 2↑ | 3↑ | 4↖ |
Simboli stampati dalla ricostruzione: .
- D&C puro: , per srotolamento; esatto (Pascal): per sono nodi contro celle.
- Spazio: solo la lunghezza, due righe: (con ); la ricostruzione vuole tutta la tabella.
- Memoizzazione: sentinella (le lunghezze sono ); nodi interni al più , uno per cella; su stringhe simili molti di meno. , : solo contro celle.
MEMO_LCS(X, Y, i, j) { l[0,*] = l[*,0] = 0, le altre celle = -1 }
if l[i,j] = -1 then
if x_i = y_j then l[i,j] <- 1 + MEMO_LCS(X,Y,i-1,j-1)
else a <- MEMO_LCS(X,Y,i-1,j); c <- MEMO_LCS(X,Y,i,j-1); l[i,j] <- max(a,c)
return l[i,j]- Due righe (solo lunghezza):
curr[j] <- 1 + prev[j-1]se , altrimentimax(prev[j], curr[j-1]), poiprev <- curr; spazio . - Domande tipiche: per , vale (Esercizio - domande di prima parte su programmazione dinamica (simulazioni d'esame)); confronti di
LCSper : ; nodi interni del D&C: . - Errori tipici: confondere sottostringa e sottosequenza; inizializzare riga e colonna con altro che ; dimenticare che nel caso non si somma ; usare la tabella con una sola riga e poi voler ricostruire; analizzare il memoizzato con la ricorrenza del D&C.