I_R_W14_M35_C++ Programowanie dynamiczne - znajdowanie spójnego podciągu
Wyobraź sobie, że masz dwa napisy: A = ABCDGH oraz B = AEDFHR.
Chcemy znaleźć najdłuższy wspólny podciąg LCS (ang. Longest Common Subsequence), czyli taki ciąg znaków, który:
występuje w obu napisach w tej samej kolejności,
ale nie musi zajmować kolejnych pozycji (to nie jest podciąg spójny, tylko podciąg).
Dla naszych ciągów jednym ze wspólnych podciągów jest np. ADH.
Pytanie brzmi: jaki jest najdłuższy możliwy taki podciąg i jak go znaleźć w sposób systematyczny, a nie „na oko”?
Klasyczne, „siłowe” sprawdzanie wszystkich podciągów byłoby skrajnie nieefektywne. Z pomocą przychodzi programowanie dynamiczne: zamiast rozwiązywać duży problem naraz, rozbijemy go na wiele mniejszych, częściowo się pokrywających podproblemów, zapiszemy ich wyniki w tabeli i wykorzystamy je ponownie.
Krok po kroku wygląda to tak:
Tworzymy tabelę
Budujemy tablicędpo wymiarach [n+1]x[m+1], gdziento długość ciąguA, amdługość ciąguB.
W naszym przykładzie:n = 6(ABCDGH),m = 6(AEDFHR), więc tablica ma rozmiar7 x 7.
Wiersze odpowiadają kolejnym prefiksom ciąguA, kolumny - prefiksom ciąguB. Wartośćdp[i][j]będzie oznaczać długość LCS dla pierwszychiznakówAoraz pierwszychjznakówB.Inicjalizacja
Gdy któryś z prefiksów ma długość0(czyli porównujemy z pustym ciągiem), długość LCS wynosi0.
Dlatego pierwszy wiersz i pierwszą kolumnę wypełniamy zerami.Wypełnianie tabeli regułą przejścia
Dla każdej pary indeksów(i,j)(od1doni od1dom):Jeśli znaki są równe, tzn.
A[i‑1] = B[j‑1],to:dp[i][j] = dp[i‑1][j‑1] + 1Dodajemy 1 do wyniku dla krótszych prefiksów, bo znaleźliśmy wspólny znak, który może wydłużyć podciąg.Jeśli znaki są różne, to:
dp[i][j] = max(dp[i‑1][j],dp[i][j‑1])
LCS dla tych prefiksów jest tak długi, jak najlepszy LCS po „pominięciu” jednego znaku z jednego z ciągów.
Przykładowe kroki dla naszych ciągów
Porównujemy kolejne litery:Dla
i=1(Az pierwszego ciągu) ij=1(Az drugiego): znaki są równe, więcdp[1][1] = dp[0][0] + 1 = 1.Dla
i=2(B) ij=1(A): znaki różne, więcdp[2][1] = max(dp[1][1],dp[2][0]) = max(1,0) = 1.Dla
i=3(C) ij=3(D): znaki różne, więc bierzemy maksimum z lewej lub z góry.Dla
i=4(D) ij=3(D): znaki równe, więcdp[4][3] = dp[3][2] + 1.
Kontynuując w ten sposób, wypełniamy całą tabelę.
Dla
i=1(Az pierwszego ciągu) ij=1(Az drugiego): znaki są równe, więcdp[1][1] = dp[0][0] + 1 = 1.Dla
i=2(B) ij=1(A): znaki różne, więcdp[2][1] = max(dp[1][1],dp[2][0]) = max(1,0) = 1.Dla
i=3(C) ij=3(D): znaki różne, więc bierzemy maksimum z lewej lub z góry.Dla
i=4(D) ij=3(D): znaki równe, więcdp[4][3] = dp[3][2] + 1.
Odczyt wyniku
Po wypełnieniu tablicy, wartość w komórcedp[n][m](czyli w prawym dolnym rogu) to długość najdłuższego wspólnego podciągu.
W naszym przykładzie będzie to 3. Jednym z podciągów o tej długości jestADH.Odtwarzanie samego podciągu
Mając tabelę, możemy „cofnąć się” od komórkidp[n][m]dodp[0][0], poruszając się w górę lub w lewo, a gdy trafimy na komórkę, w której znaki były równe, czyli powstała zdp[i‑1][j‑1] + 1,dopisujemy ten znak do wyniku. W ten sposób odzyskujemy konkretny LCS, a nie tylko jego długość.
W tym rozdziale przejdziemy przez ten proces dokładniej: narysujemy tabelę, przeanalizujemy kolejne kroki i pokażemy, jak z ogólnej idei programowania dynamicznego przejść do konkretnego, działającego algorytmu dla problemu najdłuższego wspólnego podciągu.
A ponieważ każdy ciąg porównany z pustym ma LCS równy 0, cała pierwsza kolumna to zera.
Możesz to zobaczyć tutaj:
i/j | 0 | A | E | D | F | H | R |
|---|---|---|---|---|---|---|---|
0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
A | 0 | 1 | 1 | 1 | 1 | 1 | 1 |
B | 0 | 1 | 1 | 1 | 1 | 1 | 1 |
C | 0 | 1 | 1 | 1 | 1 | 1 | 1 |
D | 0 | 1 | 1 | 2 | 2 | 2 | 2 |
G | 0 | 1 | 1 | 2 | 2 | 2 | 2 |
H | 0 | 1 | 1 | 2 | 2 | 3 | 3 |
Pierwsza kolumna = 0, bo oznacza:
LCS (A[1..i], "") = 0 dla każdego i.
Co to jest pusty prefiks?
Prefiks to po prostu „początek ciągu”. Np. prefiksy słowa ABCD to:
""(pusty)AABABCABCD
Pusty prefiks to pierwszy z nich - ciąg o długości 0, czyli nic.
W tabeli LCS oznaczamy go jako:
wiersz 0 -”pierwsze 0 znaków ciągu A”
kolumna 0 -”pierwsze 0 znaków ciągu B”
Dlaczego potrzebujemy pustego prefiksu? Bo tabela dynamiczna zawsze zaczyna się od najprostszego przypadku. A najprostszy przypadek brzmi:
Jeśli porównujesz jakikolwiek ciąg z pustym ciągiem, to najdłuższy wspólny podciąg ma długość 0.
To dlatego:
cała pierwsza kolumna to zera,
cały pierwszy wiersz to zera.
Jak odtworzyć LCS z tabeli?
Zasady cofania
Startujemy z komórki dp[6][6] = 3 (ostatni wiersz, ostatnia kolumna).
Jeśli
dp[i][j] == dp[i‑1][j], idziemy w górę - znak A nie należy do LCS.Jeśli
dp[i][j] == dp[i][j‑1], idziemy w lewo - znak B nie należy do LCS.Jeśli
A[i] == B[j], to znaleźliśmy literę LCS:dopisujemy ją (na początek wyniku),
przechodzimy po skosie: do
dp[i‑1][j‑1].
Przejście krok po kroku:
1. Start: dp[6][6]=3
Porównujemy H z R → różne.
dp[6][5] = 3, dp[5][6] = 2 → idziemy w lewo.
2. dp[6][5]=3
A[6] = H, B[5] = H → znaki równe!
Dodajemy H do wyniku. Przechodzimy do dp[5][4].
3. dp[5][4] = 2
G vs F → różne.
dp[5][3] = 2, dp[4][4] = 2 → możemy iść w lewo lub w górę.
Wybieramy np. w górę.
4. dp[4][4] = 2
D vs F → różne.
dp[4][3] = 2, dp[3][4] = 1 → idziemy w lewo.
5. dp[4][3] = 2
A[4] = D, B[3] = D → znaki równe!
Dodajemy D do wyniku. Przechodzimy do dp[3][2].
6. dp[3][2] = 1
C vs E → różne.
dp[3][1] = 1, dp[2][2] = 1 → idziemy np. w górę.
7. dp[2][2] = 1
B vs E → różne.
dp[2][1] = 1, dp[1][2] = 1 → idziemy w górę.
8. dp[1][2] = 1
A[1] = A, B[1] = A → znaki równe!
Dodajemy A do wyniku. Przechodzimy do dp[0][1].
Koniec - dotarliśmy do pierwszego wiersza.
Wynik
Zebrane litery (od końca): H, D, A → po odwróceniu:
LCS = ADH
Jak czytać tę ścieżkę
Zaczynamy od prawego dolnego rogu:
dp[6][6] = 3→ strzałka ← (idziemy w lewo)dp[6][5] = 3→ strzałka ↖ → litera Hdp[5][4] = 2→ strzałka ↑dp[4][4] = 2→ strzałka ←dp[4][3] = 2→ strzałka ↖ → litera Ddp[3][2] = 1→ strzałka ↑dp[2][2] = 1→ strzałka ↑dp[1][2] = 1→ strzałka ↖ → litera A
Zebrane litery (od końca): H,D,A → po odwróceniu: LCS = ADH
Legenda:
↖ znak należy do LCS
↑ ruch w górę
← ruch w lewo
Legenda:
↖ znak należy do LCS
↑ ruch w górę
← ruch w lewo
Odczytany podciąg
Idąc po strzałkach ↖, trafiamy kolejno na litery:
H
D
A
Po odwróceniu: LCS = ADH