DP: Najduži zajednički podniz (LCS)


U ovoj lekciji upoznaćemo se sa problemom Najdužeg zajedničkog podniza (Longest Common Subsequence – LCS), koji je klasičan problem iz oblasti dinamičkog programiranja. Naučićemo kako da definišemo stanje, postavimo DP tabelu i analiziramo rešenje.


Tekst zadatka — Najduži zajednički podniz (LCS)

Datа su dva stringa X i Y. Potrebno je odrediti dužinu najdužeg zajedničkog podniza (Longest Common Subsequence — LCS) između ova dva stringa.

Podniz ne mora biti kontinualan — karakteri se mogu birati sa proizvoljnih pozicija, ali se njihov relativni redosled mora sačuvati.

Ulaz

  • Prvi red sadrži string X
  • Drugi red sadrži string Y

Izlaz

  • Ispisati jedan ceo broj — dužinu najdužeg zajedničkog podniza

Primer

Ulaz:
AGGTAB
GXTXAYB

Izlaz:
4

Objašnjenje:
Jedan od najdužih zajedničkih podnizova je "GTAB".

Napomena

  • Ako ne postoji nijedan zajednički karakter, rezultat je 0.
  • Zadatak zahteva samo dužinu LCS-a, ne i sam podniz.

Vizualna interpretacija DP tabele (LCS)

Jedan od najčešćih problema kod razumevanja LCS algoritma jeste kako se popunjava DP tabela i zašto se u nekim slučajevima uzima maksimum iz susednih polja.

Posmatrajmo primer:

X = "AGGTAB"
Y = "GXTXAYB"

Kreiramo DP tabelu dimenzija (|X|+1) × (|Y|+1), gde dp[i][j] predstavlja dužinu LCS-a prefiksa X[0..i-1] i Y[0..j-1].

Početna DP tabela


      Ø  G  X  T  X  A  Y  B
   Ø  0  0  0  0  0  0  0  0
   A  0
   G  0
   G  0
   T  0
   A  0
   B  0
  

Prvi red i prva kolona su nule, jer LCS sa praznim stringom uvek ima dužinu 0.

Kako se popunjava jedno polje?

Posmatrajmo polje dp[i][j]:

  • Ako su karakteri jednaki:
    X[i-1] == Y[j-1]
    tada:
    dp[i][j] = dp[i-1][j-1] + 1

    ➜ Krećemo se dijagonalno, jer smo produžili zajednički podniz.

  • Ako karakteri nisu jednaki:
    dp[i][j] = max(dp[i-1][j], dp[i][j-1])

    ➜ Biramo bolju od dve opcije:

    • preskočiti karakter iz stringa X (gore)
    • preskočiti karakter iz stringa Y (levo)

Primer popunjene DP tabele


      Ø  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
  

Vrednost u donjem desnom uglu dp[|X|][|Y|] predstavlja dužinu najdužeg zajedničkog podniza. U ovom primeru to je 4.

Zašto gledamo maksimum iz levo i gore?

Kada se karakteri ne poklapaju, trenutni karakter ne može biti deo zajedničkog podniza. Zato ispitujemo dve mogućnosti:

  • ignorisati karakter iz prvog stringa (gore)
  • ignorisati karakter iz drugog stringa (levo)

Biramo onu opciju koja daje duži zajednički podniz, jer je cilj maksimizacija dužine LCS-a.

Opis problema

Dat su dva niza ili stringa X[1..n] i Y[1..m]. Cilj je pronaći najduži podniz koji se pojavljuje u oba niza. Podniz ne mora biti kontinualan.

Primer:


X = "AGGTAB"
Y = "GXTXAYB"
Najduži zajednički podniz: "GTAB", dužina = 4
  

Definicija stanja DP

Neka dp[i][j] predstavlja dužinu LCS-a prvih i karaktera niza X i prvih j karaktera niza Y.

Prelazi:

  • Ako su poslednji karakteri jednaki: dp[i][j] = dp[i-1][j-1] + 1
  • Ako nisu jednaki: dp[i][j] = max(dp[i-1][j], dp[i][j-1])

Osnovni slučajevi:

  • dp[0][j] = 0 za sve j
  • dp[i][0] = 0 za sve i

Detaljno objašnjenje problema Najdužeg zajedničkog podniza (LCS)

Problem najdužeg zajedničkog podniza (LCS – Longest Common Subsequence) jedan je od najvažnijih i najčešćih primera korišćenja dinamičkog programiranja. Datа su dva niza (često stringovi), a cilj je pronaći najduži podniz koji se pojavljuje u oba niza zadržavajući isti redosled elemenata, ali bez obaveze da budu uzastopni.

Šta je zapravo LCS?

Ako su zadati nizovi X = „ABCBDAB“ i Y = „BDCABA“, jedan od najdužih zajedničkih podnizova jeste BCBA, dužine 4. Važno je da elementi LCS-a moraju biti u istom redosledu u oba niza, ali između njih mogu da postoje preskočeni karakteri.

Zašto naivno rešenje nije efikasno?

Naivan pristup bi pokušao da generiše sve podnizove niza X (njih ima 2ⁿ), proveri koji se nalaze u Y i pronađe najduži. Međutim, broj podnizova raste eksponencijalno — za string od 30 karaktera postoji milijardu podnizova. U praksi je to neizvodljivo, pa je potrebna efikasnija metoda.

Zašto koristimo dinamičko programiranje?

Ključna ideja je da se problem LCS-a može podeliti na manje podprobleme. Dužina LCS-a za prefikse X[1…i] i Y[1…j] zavisi samo od LCS-a njihovih manjih prefiksa. Upravo zato kreiramo DP matricu gde svaki element predstavlja rezultat jednog podproblema koji se kasnije koristi za gradnju konačnog rešenja.

Definicija DP stanja

Neka dp[i][j] predstavlja dužinu LCS-a prvih i karaktera niza X i prvih j karaktera niza Y. Matrica dp se popunjava od najmanjih ka većim prefiksima.

Osnovni slučajevi

dp[0][j] = 0  (prazan niz nema zajednički podniz)
dp[i][0] = 0
    

Logika popunjavanja DP matrice

1) Ako se poslednji karakteri poklapaju

Ako važi X[i] == Y[j], onda taj karakter mora biti deo LCS-a. Zato je prelaz:

dp[i][j] = dp[i-1][j-1] + 1
    

2) Ako se poslednji karakteri ne poklapaju

Ako se X[i] ≠ Y[j], onda taj karakter ne može biti deo LCS-a. Zbog toga biramo bolji od dva moguća izbora:

dp[i][j] = max(dp[i-1][j], dp[i][j-1])
    

Ovo znači: ili izbacujemo poslednji karakter X-a, ili poslednji karakter Y-a, i uzimamo najbolje rešenje od ta dva podproblema.

Kako izgleda DP matrica u praksi?

Za stringove dužina m i n, kreira se tabela dimenzija (m+1) × (n+1). Prvi red i prva kolona su nule. Svako polje se popunjava koristeći gore navedena pravila. Na kraju je dp[m][n] dužina najdužeg zajedničkog podniza.

Zašto je ovo efikasno?

DP pristup radi u vremenu O(m × n), što je ogromno ubrzanje u poređenju sa eksponencijalnim pristupom. Na primer, za dva stringa od 1000 karaktera DP izvršava oko milion operacija – to je trenutno u računarskom vremenu.

Sažetak DP rešenja

Stanje:        dp[i][j] = LCS(X[1..i], Y[1..j])
Baza:          dp[0][*] = dp[*][0] = 0
Poklapanje:    dp[i][j] = dp[i-1][j-1] + 1
Nepoklapanje:  dp[i][j] = max(dp[i-1][j], dp[i][j-1])
    

Ovo je najpoznatiji klasični primer dinamičkog programiranja i priprema učenike za mnoge druge napredne algoritme koji koriste istu logiku podele problema na manje podprobleme sa preklapanjem.

Primer popunjavanja DP matrice za LCS

Uzećemo konkretan primer: X = "ABCBDAB", Y = "BDCABA". Matrica ispod prikazuje kako se korak po korak popunjava DP tabela. Redovi odgovaraju karakterima niza X, a kolone karakterima niza Y.

B D C A B A
0 0 0 0 0 0 0
A 0 0 0 0 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
D 0 1 2 2 2 3 3
A 0 1 2 2 3 3 4
B 0 1 2 2 3 4 4

Konačan rezultat se nalazi u donjem desnom uglu matrice: dp[7][6] = 4, što znači da je dužina LCS-a jednaka 4.


Pseudokod za LCS algoritam



function LCS(X, Y):

    # Uzimamo dužine oba stringa
    m = dužina(X)
    n = dužina(Y)

    # Kreiramo DP matricu u kojoj će dp[i][j] da predstavlja
    # dužinu LCS-a za prve i karaktera X i prve j karaktera Y.
    # Matrica ima (m+1) redova i (n+1) kolona zbog slučajeva kada je
    # jedan od stringova "prazan".
    kreiraj matricu dp dimenzija (m+1) × (n+1)

    # Inicijalizacija baze DP-a:
    # Ako je jedan od stringova dužine 0, LCS je uvek 0.
    for i = 0..m:
        dp[i][0] = 0
    for j = 0..n:
        dp[0][j] = 0

    # Popunjavanje DP matrice red po red, kolonu po kolonu
    for i = 1..m:
        for j = 1..n:

            # Ako su trenutno posmatrani karakteri jednaki,
            # oni čine deo LCS-a. Zato dodajemo 1 rešenju
            # prethodnih prefiksa (i-1, j-1).
            if X[i] == Y[j]:
                dp[i][j] = dp[i-1][j-1] + 1

            # Ako se karakteri ne poklapaju, onda se najbolji LCS
            # dobija tako što izbacimo ili:
            #  - poslednji karakter iz X  (dp[i-1][j])
            #  - poslednji karakter iz Y  (dp[i][j-1])
            # Uzimamo maksimum ove dve opcije.
            else:
                dp[i][j] = max(dp[i-1][j], dp[i][j-1])

    # Krajnji rezultat — dužina LCS-a — nalazi se
    # u donjem desnom ćošku matrice.
    return dp[m][n]

Python implementacija



def lcs(X, Y):
    m = len(X)
    n = len(Y)

    dp = [[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]:
                dp[i][j] = dp[i - 1][j - 1] + 1
            else:
                dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])

    return dp[m][n]

C++ implementacija



#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

int lcs(string X, string Y) {
    int m = X.size();
    int n = Y.size();

    vector> dp(m + 1, vector(n + 1, 0));

    for (int i = 1; i <= m; i++) {
        for (int j = 1; j <= n; j++) {

            if (X[i - 1] == Y[j - 1])
                dp[i][j] = dp[i - 1][j - 1] + 1;

            else
                dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]);
        }
    }

    return dp[m][n];
}

Tabulacija i analiza rešenja


Popunjavanje DP tabele se vrši redom (bottom-up). Na kraju, dp[n][m] sadrži dužinu najdužeg zajedničkog podniza.


// DP: Najduži zajednički podniz (LCS)
#include <iostream>
#include <vector>
#include <string>
using namespace std;

int main() {
    // Dati stringovi
    string X = "AGGTAB";
    string Y = "GXTXAYB";

    // n i m su njihove dužine
    int n = X.size();
    int m = Y.size();

    // DP tabela dimenzija (n+1) × (m+1)
    // dp[i][j] predstavlja dužinu LCS-a između
    // prvih i karaktera stringa X i prvih j karaktera stringa Y.
    vector<vector<int>> dp(n+1, vector<int>(m+1, 0));

    // Popunjavanje DP matrice — bottom-up pristup.
    // Krećemo od (1,1) jer je prvi red i prva kolona već 0
    // (bazični slučaj: poređenje sa praznim stringom → LCS = 0).
    for(int i = 1; i <= n; i++) {
        for(int j = 1; j <= m; j++) {

            // Ako su karakteri isti — deo su LCS-a
            // pa dodajemo 1 rezultatu prethodnih prefiksa.
            if(X[i-1] == Y[j-1])
                dp[i][j] = dp[i-1][j-1] + 1;

            // Ako nisu isti — uzimamo najbolji rezultat
            // između:
            // 1) izbacivanja poslednjeg karaktera iz X → dp[i-1][j]
            // 2) izbacivanja poslednjeg karaktera iz Y → dp[i][j-1]
            else
                dp[i][j] = max(dp[i-1][j], dp[i][j-1]);
        }
    }

    // LCS dužina se nalazi u donjem desnom uglu DP tabele
    cout << "Duzina LCS-a: " << dp[n][m] << endl;

    // ----------------------------------------------
    //  Opcionalno: Rekonstrukcija stvarnog LCS-a.
    //  Krecemo od dp[n][m] i "vracamo se unazad" kroz DP tabelu.
    // ----------------------------------------------

    string lcs = "";
    int i = n, j = m;

    // Dok se nismo vratili do nultog reda ili kolone
    while(i > 0 && j > 0) {

        // Ako su karakteri jednaki — oni su deo LCS-a.
        // Dodajemo ih na početak rezultata.
        if(X[i-1] == Y[j-1]) {
            lcs = X[i-1] + lcs;
            i--;
            j--;
        }

        // Ako nisu jednaki, pomeramo se u onom smeru
        // gde je LCS duži (ka većoj vrednosti u DP tabeli).
        else if(dp[i-1][j] > dp[i][j-1]) {
            i--;
        } else {
            j--;
        }
    }

    // Ispis konačnog rekonstruisanog LCS niza
    cout << "LCS: " << lcs << endl;

    return 0;
}
  

Analiza:
– Vreme izvršavanja: O(n*m)
– Memorija: O(n*m) (može da se optimizuje na O(min(n,m)) korišćenjem samo dva reda)
– Primer prikazuje i rekonstrukciju samog podniza.


Skriveni mini kviz — Najduži zajednički podniz- LCS (Longest Common Subsequence)

Proveri da li razumeš osnovne ideje i DP rešenje za LCS problem.

Otvori kviz

Mini kviz: Najduži zajednički podniz (LCS)

1. Šta predstavlja dp[i][j] u LCS problemu?




2. Šta radimo kada su X[i-1] i Y[j-1] jednaki?




3. Zašto koristimo max(levo, gore) kada se karakteri ne poklapaju?




4. Kolika je vremenska složenost standardnog DP rešenja za LCS?




5. Kako se vrši rekonstrukcija samog LCS-a?





Zadatak — Rekonstrukcija najdužeg zajedničkog podniza (LCS)

Data su dva stringa X i Y. Nakon popunjavanja DP tabele za Longest Common Subsequence (LCS), potrebno je rekonstruisati sam najduži zajednički podniz, a ne samo njegovu dužinu.

Ako postoji više rešenja, dozvoljeno je ispisati bilo koje.

Ulaz

  • Prvi red: string X
  • Drugi red: string Y

Izlaz

  • Jedan string — najduži zajednički podniz

Primer

Ulaz:
AGGTAB
GXTXAYB

Izlaz:
GTAB

Težak zadatak za vežbu — nivo SIO / Državno takmičenje

Sledeći zadatak je namenjen za naprednu praksu i zahteva primenu LCS koncepta na veće ulazne nizove.

Zadatak: Najduži zajednički podniz više nizova

Dat je skup od k stringova: S1, S2, ..., Sk. Cilj je pronaći dužinu najdužeg zajedničkog podniza koji se pojavljuje u svim stringovima.

Primer:


k = 3
S1 = "AGGTAB"
S2 = "GXTXAYB"
S3 = "GTTAB"
Najduži zajednički podniz svih stringova: "GTAB", dužina = 4
  

Uputstvo za rešavanje

  • Razmisli kako se standardni LCS između dva stringa može proširiti na više nizova.
  • Definiši DP stanje, npr. dp[i1][i2][...][ik] ili alternativno koristi iterativnu redukciju na parove stringova.
  • Identifikuj bazne slučajeve: prazan string daje dužinu 0.
  • Koristi bottom-up tabulaciju ili memoizaciju (top-down) da popuniš tabelu i izračunaš dužinu LCS.
  • Analiziraj memorijske i vremenske složenosti — za veće ulaze može biti potrebna optimizacija ili redukcija dimenzija.

Napomena: Ovaj zadatak je odlična vežba za pripremu za SIO i državna takmičenja. Pokušaj prvo da osmisliš DP pristup i prelaze, a zatim implementiraj kod. Ne gledaj rešenje unapred.

Savet: Ako se želiš dodatno pripremiti, prvo reši standardni LCS za dva stringa sa rekonstrukcijom podniza, pa zatim proširi na više stringova.

Zadatak — Najduži zajednički podniz (3 stringa)

Data su tri stringa A, B i C. Potrebno je pronaći najduži zajednički podniz koji se pojavljuje u sva tri stringa.

Primer

Ulaz:
AGGTAB
GXTXAYB
GTTAB

Izlaz:
GTAB

Još jedan napredni zadatak za vežbu — nivo SIO / Državno takmičenje

Ovaj zadatak kombinuje LCS sa dodatnom operacijom — brojanje različitih LCS podnizova.

Zadatak: Brojanje različitih LCS podnizova

Dat su dva stringa X i Y. Tvoj zadatak je da izračunaš koliko različitih podnizova maksimalne dužine LCS postoji.

Primer:


X = "ABCBDAB"
Y = "BDCABA"
Maksimalna dužina LCS = 4
Broj različitih LCS podnizova = 3
  

Uputstvo za rešavanje

  • Prvo reši standardni LCS da izračunaš dužinu najdužeg zajedničkog podniza.
  • Definiši DP stanje koje čuva ne samo dužinu LCS do indeksa i,j, već i broj različitih podnizova iste dužine.
  • Koristi bottom-up ili top-down pristup sa memoizacijom da popuniš tabelu.
  • Prati bazne slučajeve: prazan string daje dužinu 0 i broj podnizova 1.
  • Pazi na duplikate: ako dva puta dođeš do istog LCS-a, broji ga samo jednom.
  • Na kraju, dp[n][m] sadrži broj različitih LCS podnizova maksimalne dužine.

Napomena: Ovaj zadatak je odlična vežba za naprednu primenu DP i zahteva dobru analizu prelaza i optimizaciju memorije.

Savet: Pokušaj prvo da rešiš problem za male stringove i proveri rezultate ručno, pre nego što implementiraš za veće ulaze.

Zadatak (napredni): Brojanje različitih LCS podnizova

Data su dva stringa X i Y. Potrebno je izračunati:

  • maksimalnu dužinu najdužeg zajedničkog podniza (LCS)
  • koliko različitih LCS podnizova te maksimalne dužine postoji

Primer

  • X = ABCBDAB
  • Y = BDCABA

Maksimalna dužina LCS = 4
Broj različitih LCS podnizova = 3

4. Povezane stranice

Sledeća lekcija može biti Subset Sum problem ili dodatni primeri LCS-a za vežbu i takmičenja.