Najdłuższy wspólny podciąg (LCS)
Wprowadzenie
Najdłuższy wspólny podciąg (ang. Longest Common Subsequence, LCS) to fundamentalny problem w informatyce dotyczący porównywania sekwencji. Problem ten ma szerokie zastosowanie w:
- Porównywaniu plików (np. narzędzie diff)
- Wykrywaniu plagiatów
- Bioinformatyce (porównywanie sekwencji DNA)
- Systemach kontroli wersji (Git, SVN)
- Sprawdzaniu podobieństwa tekstów
Definicja podciągu
Podciąg (subsequence) to sekwencja elementów, która może być uzyskana z innej sekwencji przez usunięcie niektórych (lub żadnych) elementów bez zmiany kolejności pozostałych.
Przykład:
Sekwencja: "ABCDEFG"
Podciągi:
✅ "ACE" (usunięto B, D, F, G)
✅ "BDF" (usunięto A, C, E, G)
✅ "ABCDEFG" (nic nie usunięto)
✅ "" (wszystko usunięto)
NIE podciągi:
❌ "CBA" (zmieniona kolejność)
❌ "AXC" (dodano X)
Różnica: Podciąg vs Podstring:
Sekwencja: "ABCDE"
Podciąg: "ACE" ✅ (elementy mogą być nieciągłe)
Podstring: "BCD" ✅ (elementy MUSZĄ być ciągłe)
"ACE" to podciąg, ale NIE podstring
"BCD" to zarówno podciąg, jak i podstring
Problem LCS
Definicja formalna
Dane:
- Dwie sekwencje X = [x₁, x₂, ..., xₘ] i Y = [y₁, y₂, ..., yₙ]
Znajdź:
- Najdłuższą sekwencję Z, która jest podciągiem zarówno X, jak i Y
Przykład:
X = "ABCDGH"
Y = "AEDFHR"
Wspólne podciągi:
- "A"
- "AD"
- "ADH" ← najdłuższy (długość 3)
- "AH"
LCS(X, Y) = "ADH" (długość 3)
Uwaga: LCS może nie być unikalny!
X = "ABCD"
Y = "ABDC"
LCS może być:
- "ABC" (długość 3)
- "ABD" (długość 3)
Oba są poprawne!
Podejście 1: Rekurencja naiwna
Wzór rekurencyjny
Dla dwóch sekwencji X[0..m-1] i Y[0..n-1]:
LCS(X, Y, m, n) =
0 jeśli m = 0 lub n = 0
LCS(X, Y, m-1, n-1) + 1 jeśli X[m-1] = Y[n-1]
max(LCS(X, Y, m, n-1), jeśli X[m-1] ≠ Y[n-1]
LCS(X, Y, m-1, n))
Intuicja: 1. Przypadek bazowy: Jeśli któraś sekwencja jest pusta, LCS = 0 2. Ostatnie znaki się zgadzają: Dołącz ten znak do LCS mniejszego problemu 3. Ostatnie znaki różne: Sprawdź dwie możliwości (usuń z X lub usuń z Y) i weź większą
Implementacja rekurencyjna
Drzewo rekurencji (fragment):
LCS("AGGTAB", "GXTXAYB")
/ \
LCS("AGGTAB", "GXTXAY") LCS("AGGTA", "GXTXAYB")
/ \ / \
... ... ... ...
Problem: Wiele stanów obliczanych wielokrotnie!
Np. LCS("AG", "GX") pojawi się dziesiątki razy.
Podejście 2: Programowanie dynamiczne (Tabulation)
Implementacja tabelaryczna
Wizualizacja tablicy DP:
X = "AGGTAB"
Y = "GXTXAYB"
"" G X T X A Y B
"" 0 0 0 0 0 0 0 0
A 0 0 0 0 0 1 1 1
G 0 1 1 1 1 1 1 1
G 0 1 1 1 1 1 1 1
T 0 1 1 2 2 2 2 2
A 0 1 1 2 2 3 3 3
B 0 1 1 2 2 3 3 4
↑
WYNIK
Krok po kroku dla dp[6][7] (ostatnia komórka):
X[5]='B', Y[6]='B' → ZGADZA SIĘ!
dp[6][7] = dp[5][6] + 1 = 3 + 1 = 4
Drukowanie tablicy DP
Wyjście:
Tablica DP:
A E D F H R
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
Długość LCS: 3
Rekonstrukcja LCS: Odtworzenie podciągu
Algorytm backtracingu
Wyjaśnienie backtracingu:
Start: i=6, j=7, dp[6][7]=4
Krok 1:
X[5]='B', Y[6]='B' → ZGADZA SIĘ
Dodaj 'B' do LCS, idź do (5, 6)
Krok 2:
X[4]='A', Y[5]='Y' → RÓŻNE
dp[4][6]=3, dp[5][5]=3 → Idź w lewo (j--)
Przejdź do (5, 5)
Krok 3:
X[4]='A', Y[4]='A' → ZGADZA SIĘ
Dodaj 'A' do LCS, idź do (4, 4)
...
LCS (od tyłu): ['B', 'A', 'T', 'G']
Po odwróceniu: ['G', 'T', 'A', 'B'] → "GTAB"
Optymalizacja pamięci: O(m·n) → O(min(m, n))
Obserwacja
Przy obliczaniu dp[i][j] potrzebujemy tylko:
- Bieżącego wiersza: dp[i][...]
- Poprzedniego wiersza: dp[i-1][...]
Możemy użyć tylko dwóch tablic 1D zamiast tablicy 2D!
Implementacja z optymalizacją pamięci
Zużycie pamięci:
Bez optymalizacji: O(m·n)
m=1000, n=1000 → 1,000,000 komórek → ~8 MB
Z optymalizacją: O(min(m, n))
m=1000, n=1000 → 1,000 komórek → ~8 KB
Oszczędność: 1000× mniej pamięci!
Warianty problemu LCS
1. Najdłuższy wspólny podstring (Longest Common Substring)
Różnica: Podstring musi być ciągły!
2. LCS dla więcej niż 2 sekwencji
3. Shortest Common Supersequence (SCS)
Definicja: Najkrótsza sekwencja zawierająca obie sekwencje jako podciągi.
Wzór:
SCS(X, Y) = len(X) + len(Y) - LCS(X, Y)
Praktyczne zastosowania LCS
1. Narzędzie diff (porównywanie plików)
Wyjście:
Różnice:
def hello():
- print('Hello')
+ print('Hello, World!')
+ print('Welcome')
return 0
2. Wykrywanie plagiatu
3. Bioinformatyka: Porównywanie sekwencji DNA
Wyjście:
Sekwencja 1: AGGTAB
Sekwencja 2: GXTXAYB
LCS: GTAB (długość: 4)
Podobieństwo: 57.1%
Benchmark: Porównanie implementacji
Przykładowe wyniki:
n=15, m=15:
Rekurencja → czas: 0.124567s, długość LCS: 8
DP (2D tablica) → czas: 0.000234s, długość LCS: 8
DP (optymalizacja) → czas: 0.000189s, długość LCS: 8
n=100, m=100:
DP (2D tablica) → czas: 0.004521s, długość LCS: 52
DP (optymalizacja) → czas: 0.003876s, długość LCS: 52
n=500, m=500:
DP (2D tablica) → czas: 0.089234s, długość LCS: 261
DP (optymalizacja) → czas: 0.078912s, długość LCS: 261
Podsumowanie
| Algorytm | Złożoność czasowa | Złożoność pamięciowa | Uwagi |
|---|---|---|---|
| Rekurencja naiwna | O(2^(m+n)) | O(m+n) | Za wolne dla m,n > 20 |
| Memoization | O(m·n) | O(m·n) | Top-down DP |
| Tabulation | O(m·n) | O(m·n) | Bottom-up DP |
| Optymalizacja pamięci | O(m·n) | O(min(m,n)) | Najbardziej efektywne |
Co dalej?
Po opanowaniu problemu LCS, następne kroki to:
- Lekcja 53: Najdłuższy rosnący podciąg (LIS)
- Problem optymalizacji sekwencji numerycznych
- Rozwiązanie O(n²) i O(n log n)
-
Zastosowania w analizie danych
-
Inne problemy na stringach z DP:
- Edit Distance (odległość Levenshteina)
- Longest Palindromic Subsequence
-
Pattern Matching z DP
-
Zaawansowane algorytmy sekwencyjne:
- Smith-Waterman (bioinformatyka)
- Needleman-Wunsch (global alignment)
- Suffix Trees i Suffix Arrays
Zadania praktyczne
Zadanie 1: Diff dla znaków
Zmodyfikuj funkcję diff_lines aby działała na poziomie znaków, nie linii. Pokaż różnice jak w narzędziu diff.
Zadanie 2: LCS trzech sekwencji
Zaimplementuj efektywny algorytm LCS dla dokładnie 3 sekwencji (tablica 3D).
Zadanie 3: Wizualizacja
Stwórz wizualizację tablicy DP pokazującą, jak wypełniane są kolejne komórki.
Zadanie 4: LCS z kosztami
Rozszerz LCS o koszty: każdy znak ma określoną wagę, znajdź podciąg o maksymalnej sumie wag.
Następna lekcja: Najdłuższy rosnący podciąg (LIS) - problem optymalizacji sekwencji numerycznych