This is an old revision of the document!
Laborator 4: Programare Dinamică
Responsabili: Darius Neațu, Răzvan Chițu
Obiective laborator
Precizari initiale
Toate exemplele de cod se gasesc in
demo-lab04.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-lab04.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).
Pentru restul notiunilor prezentate pana acum despre DP, va rugam sa consulati pagina laboratorului 3.
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 3: PODM
Aceste recurente au o oarecare asemanare cu problema PODM (enunt + solutie).
ATENTIE! Intrucat acest tip de recurente poate fi mai greu (decat celelalte), doar il vom mentiona. Puteti consulta acasa materialele puse la dispozitie pentru intelege si aceasta categorie.
Caracteristici:
Acest tip de problema presupune ca o putem formula ca pe o problema de tip subinterval $[i, j]$.
Daca dorim sa gasim optimul pentru acest interval, va trebuie sa luam in calcul toate combinatiile de 2 subproblemele care ar fi putut genera solutie pentru probleme $[i, j]$.
Se considera fiecare divizare in 2 subprobleme, data de intermediarul k
$[i, k]$ si $[k + 1, j]$ sunt cele 2 subprobleme pentru care cunoastem solutiile
atunci o solutie pentru $[i,j]$ se poate obtine imbinandu-le pe cele doua
ca sa gasim solutia cea mai buna
Calculul se face de la intervale mici (probleme usoare - $[i,i]$ sau $[i, i+1]$) spre probleme generale (dimensiune generala - $[i, j]$ ). In final se ajunge si la dimensiunile initiale ($[1, n]$).
Fie un produs matricial $M = M_1 M_2 ... M_n$. Putem pune paranteze in mai multe moduri si vom obtine acelasi rezultat (inmultire asociativa), dar este posibil sa obtinem numar diferit de inmultiri scalare .
Matricea $M_i$ are (prin conventie), dimensiunile $d_{i-1} d_{i}$.
Se cere sa se gaseasca o parantezare optima de matrice (PODM), adica sa se gaseasca o parantezare care sa minimizeze numarul de inmultiri scalare.
Se construieste o dinamica de tipul:
Intrucat solutia presupune fixarea capetelor unui subinterval (i, j), apoi alegerea unui intermediar (k), complexitatea este data de aceste 3 cicluri.
Puteti rezolva si testa problema PODM pe infoarena.
Se da o expresie booleana exprimata prin stringurile true, false, and, or, xor.
Numarati modurile in care se pot aseza paranteze astfel incat rezultatul sa fie true. Se respecta regulile de la logica (tabelele de adevar pentru operatiile AND, OR, XOR).
Fie expresia: true and false xor true. Exista doua modalitati de parantezare asftel incat rezultatul sa fie true.
Complexitate temporală dorita este $O(n ^ 3)$. Gasiti vreo asemanare cu problema PODM?
Categoria 4: NUMARAT
Aceste recurente au o oarecare asemanare:
Regulile de lucru cu clase de resturi
Reamintim cateva proprietati matematice pe care ar trebui sa le aveti in vedere atunci in implementati pentru obtine corect resturile pentru anumite expresii. (corect poate sa insemne, de exemplu, sa evitati overflow :D - lucru neintuitiv cateodata).
proprietati de de baza
$(a + b) \ \% \ MOD = ((a \ \% \ MOD) + (b \ \% \ MOD)) \ \% \ MOD $
$(a \ * b) \ \% \ MOD = ((a \ \% \ MOD) \ * (b \ \% \ MOD)) \ \% \ MOD $
$(a - b) \ \% \ MOD = ((a \ \% \ MOD) - (b \ \% \ MOD) + MOD) \ \% \ MOD $ (restul nu poate fi ceva negativ; in C++ % nu functioneaza pe numere negative)
invers modular
Explicatii invers modular
Explicatii invers modular
definitie : b este inversul modular a lui a in raport cu MOD daca $ a * b = 1 (modulo \ MOD)$
utilizare : $ \frac{a}{b} \ \% \ MOD = ((a \ \% \ MOD) * (invers(b) \ \% \ MOD)) \ \% \ MOD $
calculare : deoarece la PA aceasta discutie are sens doar in contextul posibilitatii implementarii unei recurente DP in care folosim resturile doar pentru a evita overflow/imposibilitatea de a retine rezultatul pe tipurile standard de tip int (adica nu ne intereseaza sa dam o metoda generala pentru invers modular), vom simplifica problema - MOD este prim!!!
Mica teorema a lui Fermat: Daca p este un numar prim si a este un număr intreg care nu este multiplu al lui p, atunci $a^{p-1} = 1 (modulo \ p)$.
din definitia inversului modular, reiese ca a si b nu sunt multipli ai lui MOD
introducand notatiile noastre in teorema si prelucrand obtinem
Gardurile lui Gigel
Enunt
Gigel trece de la furat obiecte cu un rucsac la numarat garduri (fiecare are micile lui placeri :D). El doreste sa construiasca un gard folosind in mod repetat un singur tip de piesa.
O piesa are dimensiunile 4 x 1 (o unitate = 1m). Din motive irelevante pentru aceasta problema, orice gard construit trebuie sa aiba inaltime 4m in orice punct.
O piesa poate fi pusa in pozitie orizontala sau in pozitie verticala.
Cerinta
Gigel se intreaba cate garduri de lungime n si inaltime 4 exista? Deoarece celalalt prenume al lui este Bulănel, el intuieste ca acest numar este foarte mare, de aceea va cere restul impartirii acestui numar la 1009.
$n = 1$ sau $n = 2$ sau $n = 3$
Raspuns: 1 (un singur gard)
Explicatie: Se poate forma un singur gard in fiecare caz, dupa cum este ilustrat si in figura Garduri_123.
$n = 4$
Raspuns: 2
Explicatie: Se pot forma 2 garduri, in functie de cum asezam piesele, dupa cum este ilustrat si in figura Garduri_4.
Observam ca de fiecare daca cand punem o piesa in pozitie orizontala, de fapt suntem obligati sa punem 4 piese, una peste alta!
$n = 5$
Raspuns: 3
Explicatie: Se pot forma 3 garduri, in functie de cum asezam piesele, dupa cum este ilustrat si in figura Garduri_5.
daca dorim ca acest gard sa se termine cu 4 piese in pozitie orizontala (una peste alta - marcat cu rosu), atunci la stanga mai ramane de completat un subgard de lungime 1, in toate modurile posibile
daca dorim ca acest gard sa se termine cu o piesa in pozitie verticala (marcat cu rosu), atunci la stanga mai ramane de completat un subgard de lungime 4, in toate modurile posibile
$n = 6$
Raspuns: 4
Explicatie: Se pot forma 4 garduri, in functie de cum asezam piesele, dupa cum este ilustrat si in figura Garduri_6.
daca dorim ca acest gard sa se termine cu o piesa in pozitie verticala (marcat cu rosu), atunci la stanga mai ramane de completat un subgard de lungime 5, in toate modurile posibile
daca dorim ca acest gard sa se termine cu 4 piese in pozitie orizontala (una peste alta - marcat cu rosu), atunci la stanga mai ramane de completat un subgard de lungime 2, in toate modurile posibile
RECURENTA
NUMIRE RECURENTA
$dp[i] $ = numarul de garduri de lungime i si inaltime 4 (nimic special - exact ceeea ce se cere in enunt)
Raspunsul la problema este $dp[n]$.
GASIRE RECURENTA
IMPLEMENTARE RECURENTA
Aici puteti vedea un exemplu simplu de implementare in C++.
#define MOD 1009
int gardurile_lui_Gigel(int n) {
// cazurile de baza
if (n <= 3) return 1;
if (n == 4) return 2;
vector<int> dp(n + 1); // pastrez indexarea de la 1 ca in explicatii
// cazurile de baza
dp[1] = dp[2] = dp[3] = 1;
dp[4] = 2;
// cazul general
for (int i = 5; i <= n; ++i) {
dp[i] = (dp[i - 1] + dp[i - 4]) % MOD;
}
return dp[n];
}
Mentionez ca am folosit expresia $dp[i] = (dp[i - 1] + dp[i - 4]) \ \% \ MOD$ in loc de $dp[i] = ((dp[i - 1] \ \% \ MOD) + (dp[i - 4] \ \% \ MOD)) \ \% \ MOD$, deoarece [e valorile anterior calcule in dp a fost deja aplicata operatia $%$.
Am plecat cu numerele $1, 1, 1, 2$ si la fiecare pas rezultatul stocat este $\ \% \ MOD$, deci tot ce este stocat deja in dp este un rest in raport cu MOD. NU mai era nevoie deci sa aplica % si pe termenii din paranteza.
Complexitate
Tehnici folosite in DP
De multe ori este nevoie sa folosim cateva tehnici pentru a obtine performanta maxima cu recurenta gasita.
In laboratorul 3 se mentiona tehnica de memoizare (in prima parte a laboratorului). In acesta ne vom rezuma la cum putem folosi cunostintele de lucru matricial pentru a favorizarea implementarea unor anumite tipuri de recurente.
Exponentiere pe matrice pentru recurente liniare
Recurente liniare
O recurenta liniara in contextul laboratorului de DP este de forma:
O astfel de recurenta ar insemna ca pentru a calcula costul problemei i , imbinam costurile problemelor $i - 1, i-2, ...., i-k$, fiecare contribuind cu un anumit coeficient $c_{1}, c_{2}, ..., c_{k}$.
Complexitate recurente liniara
Complexitate recurente liniara
Presupunand ca nu mai exista alte specificatii ale problemei si ca avand cele KMAX cazuri de baza (primele KMAX valori ar trebui stiute/deduse prin alte reguli), atunci un algoritm poate implementa recurenta de mai sus folosind 2 cicluri de tip for (for i = 1 : n, for k = 1 : KMAX …).
Exponentiere pe matrice
Facem urmatoarele notatii:
$S_i$ = starea la pasul i
$S_i = (dp[i - k + 1], dp[i - k + 2], ..., dp[i - 1], dp[i])$
$S_k$ = starea initiala (in care cunoaste cele k cazuri de baza)
$S_k = (dp[1], dp[2], ..., dp[k-1], dp[k])$
$C$ = matrice ce coeficienti constanti
are dimensiune $KMAX * KMAX$
putem pune constante in clar
putem pune constantele $c_k$ care tin de problema curenta
Algoritm naiv
Putem formula problema astfel:
Determinare C
Pentru a determina elementele matricei C, trebuie sa ne uitam la inmultirea matriceala de mai sus si sa alegem elementele lui C astfel incat prin inmultirealui $S_{i-1}$ cu $C$ sa obtinem elementele din $S_i$.
\begin{gather}
\begin{bmatrix} dp[i - k + 1] & ... & dp[i-1] & dp[i] \\ \end{bmatrix} =
\begin{bmatrix} dp[i - k] & ... & dp[i-2] & dp[i-1] \\ \end{bmatrix}
\begin{bmatrix}
0 & 0 &... & 0 & 0 & c_{k}\\
1 & 0 &... & 0 & 0 & c_{k-1}\\
0 & 1 &... & 0 & 0 & c_{k-2}\\
... & ... & ... & ... & ...\\
0 & 0 &... & 1 & 0 & c_{2}\\
0 & 0 &... & 0 & 1 & c_{1}\\
\end{bmatrix}
\end{gather}
Exponentiere logaritmica pe matrice
Algoritmul naiv de mai sus are dezavantajul ca are tot o complexitate temporala $O(n)$.
Sa executam cativa pasi de inductie pentru a vedea cum este determinat $S_i$.
$$S_i = S_{i-1}C$$
$$S_i = S_{i-2}C^2$$
$$S_i = S_{i-3}C^3$$
$$...$$
$$S_i = S_{k}C^{i -k}$$
In laboratorul 2 (Divide et Impera) am invatat ca putem calcula $ x ^ n $ in timp logaritmic. Deoarece si inmultirea matricilor este asociativa, putem calcula $C ^ n$ in timp logaritmic.
Obtinem astfel o solutie cu urmatoarele complexitati:
Observatie! In ultimele calcule nu am sters contanta KMAX, intrucat apare la puterea a 3-a! $KMAX = 100$ implica $KMAX^3 = 10^6$, valoare care nu mai poate fi ignorata in practica ($KMAX^3$ poate fi comparabil cu n).
Gardurile lui Gigel (optimizare)
Dupa cum am vazut mai sus, in problema cu garduri data de gigel solutia este o recurenta liniara:
Exponentiere rapida :p
$ k = 4 $
$S_4 = (dp[1], dp[2], dp[3], dp[4]) = (1, 1, 1, 4)$
$S_i = (dp[i-3], dp[i-2], dp[i-1], dp[i])$
Raspunsul se afla efectuand operatia $S_n = S_4 * C^{n - 4}$, unde C are urmatorul continut:
\begin{gather}
C = \begin{bmatrix}
0 & 0 & 0 & 1\\
1 & 0 & 0 & 0\\
0 & 1 & 0 & 0\\
0 & 0 & 1 & 1\\
\end{bmatrix}
\end{gather}
Mai jos se afla o implementare simplista in C++ care cuprinde toate etapele pe care trebuie sa le realizati in cod, dupa ce stiti cum arata recurenta sub forma matriceala.
#define MOD 1009
#define KMAX 4
// C = A * B
void multiply_matrix(int A[KMAX][KMAX], int B[KMAX][KMAX], int C[KMAX][KMAX]) {
int tmp[KMAX][KMAX];
// tmp = A * B
for (int i = 0; i < KMAX; ++i) {
for (int j = 0; j < KMAX; ++j) {
unsigned long long sum = 0; // presupun ca suma incape pe 64 de biti
for (int k = 0; k < KMAX; ++k) {
sum += 1LL * A[i][k] * B[k][j];
}
tmp[i][j] = sum % MOD;
}
}
// C = tmp
memcpy(C, tmp, sizeof(tmp));
}
// R = C^p
void power_matrix(int C[KMAX][KMAX], int p, int R[KMAX][KMAX]) {
// tmp = I (matricea identitate)
int tmp[KMAX][KMAX];
for (int i = 0; i < KMAX; ++i) {
for (int j = 0; j < KMAX; ++j) {
tmp[i][j] = (i == j) ? 1 : 0;
}
}
while (p != 1) {
if (p % 2 == 0) {
multiply_matrix(C, C, C); // C = C*C
p /= 2; // ramane de calculat C^(p/2)
} else {
// reduc la cazul anterior:
multiply_matrix(tmp, C, tmp); // tmp = tmp*C
--p; // ramane de calculat C^(p-1)
}
}
// avem o parte din rezultat in C si o parte in tmp
multiply_matrix(C, tmp, R); // rezultat = tmp * C
}
int garduri_rapide(int n) {
// cazurile de baza
if (n <= 3) return 1;
if (n == 4) return 2;
// construiesc matricea C
int C[KMAX][KMAX] = { {0, 0, 0, 1},
{1, 0, 0, 0},
{0, 1, 0, 0},
{0, 0, 1, 1}};
// vreau sa aplic formula S_n = S_4 * C^(n-4)
// C = C^(n-4)
power_matrix(C, n - 4, C);
// sol = S_4 * C = dp[n] (se afla pe ultima pozitie din S_n,
// deci voi folosi ultima coloana din C)
int sol = 1 * C[0][3] + 1 * C[1][3] + 1 * C[2][3] + 2 * C[3][3];
return sol % MOD;
}
Comparatie solutii (studiu de caz pentru curiosi)
Comparatie solutii (studiu de caz pentru curiosi)
In arhiva demo-lab04.zip gasiti o sursa completa in care se realizeaza:
test case: varianta simpla
n = 100000000 sol = 119; time = 0.984545 s
test case: varianta rapida
n = 100000000 sol = 119; time = 0.000021 s
test case: varianta simpla
n = 1000000000 sol = 812; time = 9.662377 s
test case: varianta rapida
n = 1000000000 sol = 812; time = 0.000022 s
Exercitii
Azerah
Exponentiere
BONUS - TODO
Referințe