This is an old revision of the document!
Laborator 3: Programare Dinamică
Responsabili: Darius Neațu, Răzvan Chițu
Obiective laborator
Precizari initiale
Toate exemplele de cod se gasesc in
demo-lab03.zip.
Acestea apar incorporate si in textul laboratorului pentru a facilita parcurgerea cursiva a laboratorului.
Toate bucatile de cod prezentate in partea introductiva a laboratorului (inainte de exercitii) au fost testate. Cu toate acestea, este posibil ca din cauza mai multor factori (formatare, caractere invizibile puse de browser etc) un simplu copy-paste sa nu fie de ajuns pentru a compila codul.
Va rugam sa incercati si codul din arhiva demo-lab03.zip, inainte de a raporta ca ceva nu merge. :D
Pentru orice problema legata de continutul acestei pagini, va rugam sa dati email la
neatudarius@gmail.com.
Ce este DP?
Similar cu greedy, tehnica de programare dinamica este folosită pentru rezolvarea problemelor de optimizare.
In continuare vom folosi acronimul DP (dynamic programming).
Aplicatii DP
Programarea dinamică are un câmp larg de aplicare, insa la PA ne vom rezuma la cateva aplicatii care vor fi mentionate pe parcursul laboratoarelor 3 si 4. De asemenea, aceasta tehnica va fi folosita si in laboratoarele de grafuri (ex. algoritmul Floyd-Warshall - pe care il veti implementa si la PA; algoritmi pe arbori etc).
Programare dinamică presupune rezolvarea unei probleme prin descompunerea ei în subprobleme şi rezolvarea acestora. Spre deosebire de divide et impera, subproblemele nu sunt disjuncte, ci se suprapun.
Pentru a evita recalcularea porțiunilor care se suprapun, rezolvarea se face pornind de la cele mai mici subprobleme şi folosindu-ne de rezultatul acestora calculăm subproblema imediat mai mare. Cele mai mici subprobleme sunt numite subprobleme unitare, acestea putând fi rezolvate într-o complexitate constantă, ex: cea mai mare subsecvență dintr-o mulțime de un singur element.
Pentru a nu recalcula soluțiile subproblemelor ce ar trebui rezolvate de mai multe ori, pe ramuri diferite, se reține soluția subproblemelor folosind o tabelă (matrice uni, bi sau multi-dimensională în funcție de problemă) cu rezultatul fiecărei subprobleme. Aceasta tehnica se numește memoizare.
Aceasta tehnică determina ”valoarea” soluției pentru fiecare din subprobleme. Mergând de la subprobleme mici la subprobleme din ce în ce mai mari ajungem la soluția optimă, la nivelul întregii probleme. Motivul pentru care aceasta tehnica se numește Programare Dinamică este datorată flexibilității ei, ”valoarea” schimbându-și înțelesul logic de la o problema la alta. În probleme de minimizarea costului, ”valoarea” este reprezentata de costul minim. In probleme care presupun identificarea unei componente maxime, ”valoarea” este caracterizată de dimensiunea componentei.
După calcularea valorii pentru toate subproblemele se poate determina efectiv mulțimea de elemente care compun soluția. „Reconstrucția” soluţiei se face mergând din subproblemă în subproblemă, începând de la problema cu valoarea optimă și ajungând în subprobleme unitare. Metoda și recurența variază de la problemă la problemă, dar în urma unor exerciții practice va deveni din ce în ce mai facil să le identificați.
Ce determina DP?
Aplicând aceasta tehnică determinăm una din soluțiile optime, problema putând avea mai multe soluții optime. În cazul în care se dorește determinarea tuturor soluțiilor optime, algoritmul trebuie combinat cu unul de backtracking în vederea construcției soluțiilor.
Tipar general DP
Aplicarea acestei tehnici de programare poate fi descompusă în următoarea secvență de pași:
Identificarea structurii și a metricilor utilizate în caracterizarea soluției optime;
Determinarea unei metode de calcul recursiv pentru a afla valoarea fiecărei subprobleme;
Calcularea “bottom-up” a acestei valori (de la subproblemele cele mai mici la cele mai mari);
Reconstrucția soluției optime pornind de la rezultatele obținute anterior.
Exemple clasice
Programarea Dinamică este cea mai flexibilă tehnica din programare. Cel mai ușor mod de a o înțelege presupune parcurgerea cât mai multor exemple.
Propunem cateva categorii de recurente, pe care le vom grupa astfel:
recurente de tip SSM
recurente de tip RUCSAC
recurente de tip PODM
recurente de tip numărat
recurente pe grafuri
Categoria 1: SSM
Aceste recurente au o oarecare asemanare cu problema SSM (enunt + solutie).
SSM
Enunt
Fie un vector $ v $ cu $ n $ elemente intregi. O subsecventa de numere din sir este de forma: $s_i, s_{i+1}, ... , s_j$ ($i <= j$), avand suma asociata $s_{ij} = s_i + s_{i+1} + ... + s_j$. O subsecventa nu poate fi vida.
Cerinta
Sa se determine subsecventa de suma maxima (notata SSM).
Exemple
n = 6
i | 1 | 2 | 3 | 4 | 5 | 6 |
v[i] | -10 | 2 | 3 | -1 | 2 | -3 |
Raspuns: SSM este intre 2 si 5 (pozitii). Are suma +6. ($SSM = 2, 3, -1, 2$)
Explicatie: avem numere pozitive, deci exista o solutie simpla in care putem sa alegem doar un numar pozitiv/mai multe numere pozitive de pe pozitii alaturate (adica incercam sa evitam numere negative). Cele mai lungi subsecvente cu numere pozitive sunt 2,3 si 2. Observam ca daca extindem $2, 3$ la $2, 3, -1, 2$, desi am inclus un numar negativ, suma secventei creste.
n = 4
i | 1 | 2 | 3 | 4 |
v[i] | 10 | 20 | 30 | 40 |
Raspuns: SSM este intre 1 si 4 (pozitii). Are suma 100. ($SSM = 10, 20, 30, 40$)
Explicatie: deoarece toate numerele sunt pozitive, SSM cuprinde toate numerele.
n = 4
i | 1 | 2 | 3 | 4 |
v[i] | -10 | -20 | -30 | -40 |
Raspuns: SSM este intre 1 si 1 (pozitii). Are suma -10. ($SSM = -10$)
Explicatie: deoarece toate numerele sunt negative, SSM cuprinde doar cel mai mare numar.
Rezolvare
TIPAR
Tiparul acestei probleme ne sugereaza ca o solutie este obtinuta incremental, in sensul ca putem privi problema astfel: gasim cea mai buna solutie folosind primele $i-1$ elemente din sir, apoi incercam sa o extindem folosind elementul i (adica ne extindem la dreapta ~CU~ $v[i]$).
NUMIRE RECURENTA
Intrucat la fiecare pas trebuie sa retinem cea mai buna solutie folosind un prefix din vectorul v, solutia va fi salvata intr-un tablou auxiliar definit astfel:
$ dp[i] $ = suma subsecventei de suma maxima (suma SSM) folosind DOAR primele i elemente din vectorul v si care se termina pe pozitia i
= Mentiuni =
Pentru a mentine o conventie, toate tablourile de acest tip din laborator vor fi notate cu dp (dynamic programming).
Ca sa rezolvam problema data, trebuie sa rezolvam o multime de subprobleme
Solutia pentru problema initiala este maximul din vectorul $dp[i]$.
GASIRE RECURENTA
Intrucat dorim ca aceasta problema sa fie rezolvabila printr-un algoritm/bucata de cod, trebuie sa descriem o metoda concreta prin care vom calcula $dp[i]$.
CAZUL DE BAZA
In general in probleme putem avea mai multe cazuri de baza, care in principiu se leaga de valori extreme are dimensiunilor subproblemelor.
In cazul SSM, avem un singur caz de baza, cand avem un singur element in prefix : $dp[1] = v[1] $.
Explicatie: daca avem un singur element, atunci acesta formeaza singura subsecventa posibila, deci $ SSM = v[1] $
CAZUL GENERAL
presupune inductiv ca avem rezolvate toate subproblemele mai mici
in cazul SSM, presupunem ca avem calculat $ dp[i-1] $ si dorim sa calculam $ dp[i] $ (cunoastem cea mai buna solutie folosind primele i-1 elememente si vedem daca elementul de pe pozitia i o poate imbunatati)
la fiecare pas avem de ales daca $v[i]$ extinde cea mai buna solutie care se termina pe $v[i-1]$ sau se incepe o noua secventa cu $v[i]$
decidem in functie de $ dp[i - 1]$ si $v[i] $
IMPLEMENTARE RECURENTA
In majoritatea problemelor de DP, gasirea recurentei ocupa cea mai mare parte a timpului de rezolvare (lucru adevarat si in cazul problemelor de la PA). De aceea, faptul ca ati reusit sa scrieti pe foaie lucruri foarte complicate poate fi un indiciu ca ati pornint e o cale gresita.
Mai jos se afla un exemplu simplu de implementare a recurentei gasite in C++.
// gaseste SSM pentru vectorul v cu n elemente
// pentru a mentine conventia din explicatii:
// - elementele sunt indexate de la 0, dar le folosesc doar pe cele care incep de la 1
// => v[1], ..., v[n]
int SSM(int n, vector<int> &v) {
vector<int> dp(n + 1); // vector cu n + 1 elemente (indexarea incepe de la 0)
// am nevoie de dp[1], ..., dp[n]
// caz de baza
dp[1] = v[1];
// caz general
for (int i = 2; i <= n; ++i) {
if (dp[i - 1] >= 0) {
// extinde la dreapta cu v[i]
dp[i] = dp[i - 1] + v[i];
} else {
// incep o noua secventa
dp[i] = v[i];
}
}
// solutia e maximul din vectorul dp
int sol = dp[1];
for (int i = 2; i <= n; ++i) {
if (dp[i] > sol) {
sol = dp[i];
}
}
return sol; // aceasta este suma asociata cu SSM
}
Daca dorim sa afisam si indicii intre care apare SSM, putem sa stocam si pozitia de start pentru fiecare solutie intermediara. Gasiti aceasta solutie in demo-lab03.zip.
Hint: definiti start[i] = pozitia pe care a inceput subsecventa care da solutia cu cost dp[i].
Mentiuni
Intrucat aceasta solutie presupune calculul iterativ (coloana cu coloana) a matricei dp, complexitatea este liniara. De asemenea, se mai parcurge o data dp pentru a gasi maximul.
Pentru a ilustra toti pasii posibili intr-o astfel de problema, totul a fost prezentat cat mai simplu (NU in toate problemele putem facem simplificari de tipul “NU am nevoie sa stochez tabloul dp”).
SCMAX
Enunt
Fie un vector $ v $ cu $ n $ elemente intregi. Un subsir de numere din sir este de forma: $s_{i_1}, s_{i_2}, ... , {s_{i_k}}$. Un subsir nu poate fi vid ($k >= 1$).
Cerinta
Sa se determine subsirul crescator maximal (notat SCMAX) - un subsir ordonat strict crescator si are lungime maxima (daca sunt mai multe solutii, sa se gaseasca una oarecare).
Exemple
n = 6
i | 1 | 2 | 3 | 4 | 5 | 6 |
v[i] | 100 | 12 | 13 | -1 | 15 | -30 |
Raspuns: $SCMAX = 12, 13, 15$ ($SCMAX = v[2], v[3], v[5])$.
Explicatie:
Toate subsirurile ordonate strict crescator sunt:
$100$
$12$
$12, 13$
$12, 13, 15$
$12, 15$
$13$
$13, 15$
$-1$
$-1, 15$
$15$
$-30$
Cel mentionat este singurul de lungime 3.
n = 6
i | 1 | 2 | 3 | 4 | 5 | 6 |
v[i] | 100 | 12 | 13 | -1 | 15 | 14 |
Raspuns:
$SCMAX = 12, 13, 15$ ($SCMAX = v[2], v[3], v[5])$.
$SCMAX = 12, 13, 14$ ($SCMAX = v[2], v[3], v[6])$.
Explicatie:
Toate subsirurile ordonate strict crescator sunt:
$100$
$12$
$12, 13$
$12, 13, 15$
$12, 13, 14$
$13$
$13, 15$
$13, 14$
$-1$
$-1, 15$
$-1, 14$
$15$
$14$
Cele 2 solutii indicate au ambele lungime maxima.
Rezolvare
TIPAR
Verificam daca se aplica tiparul de la SSM: gasim cea mai buna solutie folosind primele $i-1$ elemente din sir, apoi incercam sa o extindem folosind elementul i (adica ne extindem la dreapta ~CU~ $v[i]$).
Daca avem cea mai buna solutie pentru intervalul $1, 2, .., i-1$ si care se termina cu $v[i-1]$, atunci incercam sa extindem solutia cu $v[i]$ (putem daca $v[i-1] < v[i]$)
Altfel.. Unde am putea sa il punem pe $v[i]$?
NUMIRE RECURENTA
$ dp[i] $ = lungimea celui mai lung subsir(lungime SCMAX) folosind (doar o parte) din primele i elemente din vectorul v si care se termina pe pozitia i
= Mentiuni =
Ca sa rezolvam problema data, trebuie sa rezolvam o multime de subprobleme
Solutia pentru problema initiala este maximul din vectorul $dp[i]$.
GASIRE RECURENTA
CAZUL GENERAL
presupune inductiv ca avem rezolvate toate subproblemele mai mici
in cazul SCMAX, presupunem ca avem calculate $ dp[1], dp[2], ..., dp[i-1] $ si dorim sa calculam $ dp[i] $ (cunoastem cea mai buna solutie folosind primele j elemente si vedem daca elementul de pe pozitia i o poate imbunatati - $j = 1:i-1$)
deoarece nu stim unde e cel mai bine sa il pune pe $v[i]$ (dupa care v[j]?), incercam pentru toate valorile posibile ale lui j ($j = 1 : n - 1$)
daca $v[j] < v[i] $, atunci subsirul crescator care se termina pe pozitia j, poate fi extinds la dreapta cu elementul v[i], generand lungimea dp[j] + 1
Ce se intampla totusi daca nu exista un j care sa indeplineasca conditia de mai sus? Atunci $v[i]$ va forma singur un subsir crescator de lungime 1 (care poate fi la un pas ulterior)
Reunind cele spuse mai sus:
IMPLEMENTARE RECURENTA
Mai jos se afla un exemplu simplu de implementare a recurentei gasite in C++.
// n = numarul de elemente din vector
// v = vectorul dat (v[1], v[2], ..., v[n] - indexare de la 1 ca in explicatii)
void scmax(int n, vector<int> &v) {
vector<int> dp(n + 1); // in explicatii indexarea incepe de la 1
// caz de baza
dp[1] = 1; // [ v[1] ] este singurul subsir (crescator) care se termina pe 1
// caz general
for (int i = 2; i <= n; ++i) {
dp[i] = 1; // [ v[i] ] - este un subsir (crescator) care se termina pe i
// incerc sa il pun pe v[i] la finalul tuturor solutiilor disponibile
// o solutie se termina cu un element v[j]
for (int j = 1; j < i; ++j) {
// solutia triviala: v[i]
if (v[j] < v[i]) {
// din (..., v[j]) pot obtine (..., v[j], v[i])
// (caz in care prec[i] = j)
// voi alege j-ul curent, cand alegerea imi gaseste o solutie mai buna decat ce am deja
if (dp[j] + 1 > dp[i]) {
dp[i] = dp[j] + 1;
}
}
}
}
// solutia e maximul din vectorul dp
int sol = dp[1], pos = 1;
for (int i = 2; i <= n; ++i) {
if (dp[i] > sol) {
sol = dp[i];
pos = i;
}
}
return sol;
}
Exemplu implementare cu reconstituire
Exemplu implementare cu reconstituire
In demo-lab03.zip gasiti un exemplu de implementare care arata si cum puteti reconstitui SCMAX.
Fata de implementarea anterioara, in aceasta versiune se foloseste un tablou auxiliar prec.
$prec[i]$ = indicele j al elementului v[j], pentru care $dp[j] + 1 == dp[i]$ (adica acel j pentru care subsirul crescator maximal care se termina cu $v[i]$ este extinderea cu un element a celui care se termina cu $v[j]$.
Mentiuni
Intrucat aceasta solutie presupune calculul iterativ (coloana cu coloana) a matricei dp, complexitatea este polinomiala (patratica - pentru fiecare element din tabloul, facem o trecere prin elementele deja calculate).
Categoria 2: RUCSAC
Aceste recurente au o oarecare asemanare cu problema RUCSASC - varianta discreta (enunt + solutie).
RUCSAC
Enunt
Fie un set (vector) cu $ n $ obiecte (care nu pot fi taiate - varianta discreta a problemei). Fiecare obiect i are asociata o pereche ($w_i, p_i$) cu semnificatia:
Gigel are la dispozitie un rucsac de volum infinit, dar care suporta o greutate maxima (notata cu $W$ - weight knapsack).
Se gandeste ca nu prea merge treaba cu ACS, asa ca se apuca de furat. El vrea sa gaseasca o submultime de obiecte pe care sa le fure (sa le bage in rucsac), astfel incat suma profiturilor sa fie maxima.
Daca Gigel fura obiectul i, caracterizat de ($w_i, p_i$), atunci profitul adus de obiect este $p_i$ (presupunem ca il vinde cu cat valoreaza obiectul).
Cerinta
Sa se determine profitul maxim pentru Gigel.
Exemple
$n = 5$ si $W = 10$
Raspuns: 24 (profitul maxim)
Explicatie: va alege toate obiectele :D.
$n = 5$ si $W = 3$
Raspuns: 13 (profitul maxim)
Explicatie: va alege obiectele cu indicii 4 si 5 (profit: 8 + 5)
Rezolvare
TIPAR
Cum am transpune tiparul de la SSM/SCMAX in problema RUCSAC?
NUMIRE RECURENTA
Intrucat la fiecare pas trebuie sa retinem cea mai buna solutie folosind un prefix din vectorul de obiecte, dar pentru ca trebuie sa punem si o restrictie de greutate necesara (ocupata in rucsac), solutia va fi salvata intr-un tablou auxiliar definit astfel:
$ dp[i][cap] $ = profitul maxim (profit RUCSAC) obtinut folosind DOAR DINTRE primele i obiecte si avand un rucsac de capacitate maxima cap
Observatii:
NU exista restrictie daca in solutia mentionata de $dp[i][cap]$ este folosit OBLIGATORIU elementul i
Solutia problemei se gaseste in $dp[n][W]$ (profitul maxim folosind DOAR DINTRE primele n elemente - adica toate; capacitatea maxima folosita este W - adica capacitatea maxima a rucsacului).
GASIRE RECURENTA
Reunind cele spuse mai sus, obtinem:
$dp[0][cap] = 0$, pentru $cap = 0 : G$
$dp[i][cap] = max(dp[i - 1], cap], dp[i - 1][cap - w_i] + p_i)$, pentru $i = 1: n$, $cap = 0:W$
IMPLEMENTARE RECURENTA
Mai jos se afla un exemplu simplu de implementare a recurentei gasite in C++.
// n = numarul de obiecte din colectie
// W = capacitatea maxima a rucsacului
// (w[i], p[i]) = caracteristicile obiectului i ($i = 1 : n)
int rucsac(int n, int W, vector<int> &w, vector<int> &p) {
// dp este o matrice de dimensiune (n + 1) x (W + 1)
// pentru ca folosim dp[0][*] pentru multimea vida
// dp[*][0] pentru situatia in care ghiozdanul are capacitate 0
vector< vector<int> > dp(n + 1);
for (int i = 0; i <= n; ++i) {
dp[i].resize(W + 1);
}
// cazul de baza
for (int cap = 0; cap <= W; ++cap) {
dp[0][cap] = 0;
}
// cazul general
for (int i = 1; i <= n; ++i) {
for (int cap = 0; cap <= W; ++cap) {
// nu folosesc obiectu i => e solutia de la pasul i - 1
dp[i][cap] = dp[i-1][cap];
// folosesc obiectul i, deci trebuie sa rezerv w[i] unitati in rucsac
// inseamna ca inainte trebuie sa ocup maxim cap - w[i] unitati
if (cap - w[i] >= 0) {
int sol_aux = dp[i-1][cap - w[i]] + p[i];
dp[i][cap] = max(dp[i][cap], sol_aux);
}
}
}
return dp[n][W];
}
Mentiuni
Intrucat aceasta solutie presupune calculul iterativ (linie cu linie) a matricei dp, complexitatea este polinomiala.
Exercitii
Coin change
CMLSC
BONUS - TODO
Referințe