Poglavje 10 Dinamično programiranje, mnozenje matrik Dinamično programiranje je način reševanja problemov. Sodobni pojem je vpeljal ameriški matematik Richard Bellman (Bellman-Fordov algoritem za iskanje najkrajših poti v grafu, tudi ko so uteži negativne) leta 1953. Beseda programiranje se ne nanaša na računalniško programiranje (npr. zasedanje in sproščanje pomnilnika pri objektnem programiranju), ampak na iskanje optimalne rešitve optimizacijskega problema, ki ga zapišemo kot matematični program. Problem rešujemo z dinamičnim programiranjem, kadar izkazuje dve lastnosti: Optimalna podstruktura (optimal substructure). Rečemo, da problem izkazuje optimalno podstrukturo, kadar lahko (optimalno) rešitev izračunamo z uporabo (optimalnih) rešitev podproblemov. To dejstvo se pri problemih ponavadi izrazi z rekurzivnimi enačbami. Prekrivajoči se podproblemi (overlapping subproblems). Rečemo, da ima problem prekrivajoče se podprobleme, kadar pri reševanju različnih podproblemov naletimo na enake podpodprobleme. Torej, če ima problem zgolj prvo lastnost, ga rešimo preprosto z metodo deli in vladaj. Taka problema sta npr. Quicksort ali Mergesort. Če pa problem izkazuje tudi drugo lastnost, potem bi program po metodi deli in vladaj opravljal odvečno delo, saj bi enake (pod)probleme reševal večkrat. Preprost primer je izračun Fibonaccijevega števila (fib(n)=fib(n-1)+fib(n-2), fib(0)=0, fib(1)=1). Le-tega lahko sprogramiramo tako, da funkcija rekurzivno računa levi podproblem (fib(n-1)) in desni podproblem (fib(n-2)). To je preprost primer metode deli in vladaj. Vendar opazimo, da izračun levega podproblema vsebuje tudi izračun desnega in tako po rekurziji naprej s pod-pod-...podproblemi, vse do najmanjšega (glej sliko 9.1 za primer iskanja zaporedja množenja matrik). Z uporabo dinamičnega programiranja poskrbimo, da se enaki podproblemi rešujejo zgolj enkrat. Poznamo dva pristopa: Od zgoraj navzdol (Top-down). Algoritem za problem z optimalno podstrukturo ponavadi 64
Slika 10.1: Primer več enakih rekurzivnih klicev za enake podprobleme. Z memoizacijo sive klice nadomestimo s konstantnim vpogledom v tabelo. zapišemo v rekurzivni obliki. V naivnem zapisu v rekurzivni obliki je algoritem neučinkovit (velikokrat ima eksponentno časovno zahtevnost). Razširimo ga tako, da ko prvič reši nek podproblem, rešitev shrani v tabelo. Pred vsakim rekurzivnim klicem tako preverimo, če smo podproblem že rešili, in če smo ga, vrnemo že izračunano shranjeno rešitev. To tehniko (shranjevanje že izračunanih rešitev) imenujemo memoizacija (memoization). Pristop se imenuje od zgoraj navzdol, ker je algoritem še vedno zapisan rekurzivno in tako najprej izvede klic na glavnem problemu, nato pa rekurzivno na podproblemih. Od spodaj navzgor (Bottom-up). Pri tem pristopu rekurzivni zapis problema reformuliramo tako, da iterativno rešujemo podprobleme različnih velikosti. Začnemo z reševanjem najmanjših podproblemov, iz rešitev teh zgradimo rešitve večjih (pod)problemov in s tem nadaljujemo, dokler ne zgradimo rešitve problema želene velikosti. Red časovne zahtevnosti algoritmov je pri obeh pristopih enak. Pristop od zgoraj navzdol z memoizacijo je morda bolj naraven in zahteva manj spreminjanja naivnega (počasnega) rekurzivnega algoritma (dodati je potrebno memoizacijo), vendar so algoritmi, ki gradijo rešitve od spodaj navzgor, pogosto hitrejši, ker ni režije rekurzivnih klicev. Je pa res, da če nek problem zahteva rešitev zgolj nekaterih podproblemov, in pri pristopu od spodaj navzgor ne delamo dodatnih optimizacij, potem je pristop od zgoraj navzdol boljši, saj izračuna zgolj tiste podprobleme, ki so potrebni za rešitev osnovnega problema. Vsak problem, ki ima obe lastnosti, da ga rešujemo z dinamičnim programiranjem, lahko rešimo z obema pristopoma (od zgoraj navzdol in od spodaj navzgor). Na tem mestu še poudarimo, da kadar rešujemo optimizacijske probleme, algoritem lahko vrne zgolj vrednost (ceno) najbolj optimalne rešitve, lahko pa vrne tudi rešitev samo. Slednje od algoritma zahteva, da pri iskanju optimalne rešitve shrani podatke o razdelitvi na podprobleme, če želimo, da rekonstrukcija rešitve na posameznem koraku zahteva le konstantnem dodatnega časa (kasneje vidimo primer tabele s[i,j]). 65
Slika 10.2: Prodajne cene palic. 10.1 Naloge: 1. Na voljo imamo jeklene palice, ki jih želimo razrezati tako, da bomo maksimizirali dobiček. Prodajne cene posameznih palic so prikazani na sliki 10.8. Slika 10.3: Primer za dolžino 4. Lahko razrežimo palico z dolžino n na 2 n 1 kosov. Optimalno kombinacijo lahko označimo z: n = i 1 + i 2 + + i k. (10.1) Pri tem maksimalni dobiček je: Maksimalni dobiček r i je: r n = p i1 + p i2 + + p ik. (10.2) r 1 =1(brez rezov); r 2 =5(brez rezov); r 3 =8(brez rezov); r 4 = 10 (4=2+2); r 5 = 13 (5=2+3); r 6 = 17 (brez rezov); r 7 = 18 (7=1+6 ali 7=2+2+3); r 8 = 22 (8=2+6) r 9 = 25 (9=3+6) r 10 = 30 (brez rezov). 66
Slika 10.4: Rekurzivno drevo za CUT-ROD(p,n). Dobiček je rekurzivno definiran kot: r n = max(p n,r 1 + r n 1,r 2 + r n 2,,r n 1 + r 1 ). (10.3) Od zgoraj navzdol: r n = max 1appleiapplen (p i + r n i ). (10.4) CUT-ROD(p,n) if n == 0 return 0 q = - Infinity for i = 1 to n q = max(q,p[i] + CUT-ROD(p,n-1)) return q Od zgoraj navzdol: MEMORIZED-CUT-ROD(p,n) let r[0..n] be a new array for i = 0 to n r[i] = - Infinity return MEMORIZED-CUT-ROD-AUX(p,n,r) MEMORIZED-CUT-ROD-AUX(p,n,r) if r[n] >= 0 return r[n] if n == 0 q=0 else q = - Infinity for i = 1 to n q = max(q,p[i] + MEMORIZED-CUT_ROD-AUX(p,n-1,r)) r[n] = q return q Od spodaj navzgor: 67
BOTTOM-UP-CUT-ROD(p,n) let r[0..n] be a new array r[0] = 0 for j = 1 to n q = - Infinity for i = 1 to j q = max(q, p[i] + r[j-i]) r[j] = q return r[n] Slika 10.5: Graf podproblemov za n=4. EXTENDED-BOTTOM-UP-CUT-ROD(p,n) let r[0..n] and s[0..n] be new arrays r[0] = 0 for j = 1 to n q = - Infinity for i = 1 to j if q < p[i] + r[j-i] q = max(q, p[i] + r[j-i]) s[j] = i r[j] = q return r[n] PRINT-CUT-ROD-SOLUTION(p,n) (r,s) = EXTENDED-BOTTOM-UP-CUT-ROD(p,n) while n > 0 print s[n] n = n - s[n] Slika 10.6: Prodajne cene palic. 68
10.2 Mnozenje matrik Slika 10.7: Mnozenje matrik. 2. Napisite psevdokodo mnozenje matrike. MATRIX-MULTIPLY(A,B) if A.columns!= B.rows error "incompatible dimensions" else let C be a new A.rows x B.columns matrix for i = 1 to A. rows for j =1 to B.columns c[i,j] = 0 for k = 1 to A.columns c[i,j] = c[i,j]+a[i,k]*b[k,j] return C Predpostavimo, da želimo izračunati mnozenje matrike: A 1 A 2 A 3 A 4. To lahko izračunamo na več načinov: (A 1 (A 2 (A 3 A 4 ))), (A 1 ((A 2 A 3 )A 4 )), ((A 1 A 2 )(A 3 A 4 )), ((A 1 (A 2 A 3 ))A 4 ), (((A 1 A 2 )A 3 )A 4 ), Predpostavimo, da želimo izračunati mnozenje matrik: 69
A 1 A 2 A 3, A 1 je 10 100, A 2 je 100 5, ina 3 je 5 50. Če izračunamo (A 1 A 2 )A 3 ), moramo narediti 10 100 5 = 5000 skalarnih multipilkacij. Nato pa še 10 5 50 = 2500 skalarnih multipilkacij. Skupaj: 7500. Če izračunamo (A 1 (A 2 A 3 )), moramo narediti 100 5 50 = 25000 skalarnih multipilkacij (A 2 A 3 ). Nato pa še 10 100 50 = 50000 skalarnih multipilkacij. Skupaj: 75000. 3. Nraredimo mnozenje matrik, ki minimizira število skalarnih multipilkacij. Z A i...j, i<j, označimo množenje matrik A i A i+1 A j. Predpostavimo da optimalna rešitev je k, pri kateri mnozenje razdelimo na A k in A k+1. Nato moramo izračunati A i...k in A k+1...j. Predpostavimo da vsako matriko je p i 1 p i. Število skalranih multiplikacij lahko izračunamo kot: m[i, j] =m[i, k]+m[k +1,j]+p i 1 p k p j. (10.5) k je lahko ena od vrednosti j i. Število skalranih multiplikacij lahko izračunamo kot: m[i, j] =0,ifi= j m[i, j] =max iapplek<j (m[i, k]+m[k +1,j]+p i 1 p k p j ),ifi<j. MATRIX-CHAIN-ORDER (p) n = p.length - 1 let m[1..n,1..n] and s[1..n,2..n] be new tables for i = 1 to n m[i,i] = 0 for l = 2 to n // l is the chain length for i = 1 to n - l + 1 j=i+l-1 m[i,j] = Infinity for k = i to j-1 q = m[i,k] + m[k+1,j] + p_i-1p_kp_j if q < m[i,j] m[i,j] = q s[i,j] = k return m and s PRINT-OPTIMAL-PARENTS(s,i,j) if i == j print "A" else print "(" PRINT-OPTIMAL-PARENTS(s,i,s[i,j]) PRINT-OPTIMAL-PARENTS(s,s[i,j]+1,j) print ")" 3. Nraredimo rekurzivno mnozenje matrik, ki minimizira število skalarnih multipilkacij. 70
RECURSIVE-MATRIX-CHAIN(p,i,j) if i == j return 0 m[i,j] = Infinity for k = i to j-1 q = RECURSIVE-MATRIX-CHAIN(p,i,k) + + RECURSIVE-MATRIX-CHAIN(p,k+1,j) + p_i-1p_kp_j if q < m[i,j] m[i,j] = q return m[i,j] Slika 10.8: Primer več enakih rekurzivnih klicev za enake podprobleme. Z memoizacijo sive klice nadomestimo s konstantnim vpogledom v tabelo. MEMORIZED-MATRIX-CHAIN(p) n = p.length -1 let m[1..n,1..n] be a new table for i = 1 to n for j = i to n m[i,j] = Infinity return LOOKUP-CHAIN(m,p,1,n) LOOKUP-CHAIN(m,p,i,j) if m[i,j] < Infinity return m[i,j] if i == j m[i,j] = 0 else for k = i to j - 1 q = LOOKUP-CHAIN(m,p,i,k) + LOOKUP-CHAIN(m,p,k+1,j) + p_i-1p_kp_j if q < m[i,j] m[i,j] = q return m[i,j] 71
4. Točkovna matrika. Imamo dva niza, za katere želimo izračunati podobnosti. Za osnovo bomo uporabili točkovno matriko (angl. dot matrix). Na levo stran napišemo prvi niz, na vrh napišemo drugi niz. Točkovna matrika vsebuje točke tam, kjer se znaka ujemata oz. prazno polje, če se ne. Poisčemo najdaljše skupno podzaporedje (LCSubseq) besed ABCBDAB in BDCABA. Slika 10.9: Najdaljše skupno podzaporedje besed ABCBDAB in BDCABA. Predpostavimo da X =(A, B, C, B, D, A, B) in Y =(B, D, C, A, B, A). Skupna pozdzaporedja besed so: (B, C,A), (B, C,B,A), in(b, D, A, B). Predopostavimo da x =(x 1,x 2,...,x m ) in y =(y 1,y 2,...,y n ) sta dve besedi in Z =(z 1,z 2,...,z k ) je skupno podzaporedje besed x in Y. Če x m = y n, potem z k = x m = y n in Z k 1 je LCS besed X m 1 in Y n 1 ; Če x m 6= y n in z k 6= x m, potem Z je LCS besed X m 1 in Y ; Če x m 6= y n in z k 6= y n, potem Z je LCS besed X in Y n 1. Rekurzivna formula za dolžino najdaljšega skupnega podzaporedja nizov x in y je: c[i, j] =0za i =0, j =0; 72
c[i, j] =c[i 1,j 1] + 1 za i, j > 0 in x i = y j ; c[i, j] =max(c[i, j 1],c[i 1,j]) za i, j > 0 in x i 6= y j ; Slika 10.10: LCS-LENGTH. Slika 10.11: PRINT-LCS. 5. Tim se je odločil za vzgojo kmetije in za to je potreboval zemljišče. Zaslužil je K denarov, na trgu pa je prostega prostora N M kvadratnih metrov, vsak blok (1 x 1) pa ima svojo ceno. Žal pa si ne more privoščiti, da bi kupil vse zemljišče, zato mora odkupiti največjo površino, ki jo omogoča proračun. Če bo več parcel z istim območjem, bo logično izbral cenejšo. Občina tako zemljišče prodaja samo v obliki pravokotnika ali kvadrata. Vaša naloga je, da z dano N, M, K in matrico A[N][M] izračunate, koliko je ta povoršina in koliko stane. V prvi vrstici standardnega vnosa so dimenzije parcele (N,M) in koliko denarja je na voljo K. Pri tem (1 apple N,M apple 100; 0 apple K apple 109). Naslednjih N vrstic, vsaka s številkami M, predstavljajo celotno parcelo v obliki P [i][j], kjer vsaka številka matrice predstavlja ceno za pozicijski blok [i][j]. Prva in edina vrstica standardnega izhoda mora vsebovati dve številki A in C. Kjer je A skupna površina, C pa koliko stane. Primer vhoda: 73
5615 10 1 10 10 20 10 10 30 1 1 5 1 1 50 1 1 20 1 1 10 5 5 10 5 1 40 10 90 1 10 10 Primer izhoda: 610 Rešitev brez dinamičnega programiranja: maxsubrect = -INFINITY; for (int i = 0; i < n; i++) for (int j = 0; j < n; j++) // start point (x1, y1) for (int k = i; k < n; k++) for (int l = j; l < n; l++) // end point (y1, y2) subrect = 0; for (int a = i; a <= k; a++) for (int b = j; b <= l; b++) subrect += A[a][b]; if(subrect <= budget) maxsubrect = max(maxsubrect, subrect); Dinamično programiranje: Kumulativna matrika: for (int i = 0; i < n; i++) for (int j = 0; j < n; j++) // primer n x n matrica cin << [i][j]; if (i > 0) A[i][j] += A[i - 1][j]; // e je vrstica nad vrstico, v kateri smo trenutno if (j > 0) A[i][j] += A[i][j - 1]; // e je stolpec levo od stolpca, v kateri smo trenutno if (i > 0 && j > 0) A[i][j] -= A[i - 1][j - 1]; // inclusion-exclusion principle, avoid duplication maxsubrect = max(maxsubrect, subrect); Lahko izracunamo vsota pod-matrike od (i, j) do (k, l): #include <iostream> #include <fstream> //#include <time.h> 74
Slika 10.12: A: Originalna matrika, B: Kumulativna matrika, C: Inclusion-exclusion principle. Slika 10.13: Dinamično programiranje. using namespace std; int main() ios_base::sync_with_stdio(false); // optimization for I/O int mat_col[102][102]; int n, m, budget, t, max_area, final_cost, area, cost, start; //time_t poc, kraj; //double vreme; cin >> n >> m >> budget; for(int j = 0; j < m; j++) mat_col[0][j] = 0; for(int i = 1; i <= n; i++) for(int j = 0; j < m; j++) cin >> t; mat_col[i][j] = t; mat_col[i][j] += mat_col[i - 1][j]; 75
//poc = clock(); max_area = 0; final_cost = 0; for(int i = 1; i <= n; i++) for(int j = i; j <= n; j++) area = 0; cost = 0; start = 0; for(int k = 0; k < m; k++) cost += (mat_col[j][k] - mat_col[i-1][k]); while(cost > budget) cost -= mat_col[j][start]; cost += mat_col[i - 1][start]; start++; area = (k - start + 1) * (j - i + 1); if(area > max_area) max_area = area; final_cost = cost; else if(area == max_area) final_cost = min(final_cost, cost); cout << max_area << " " << final_cost << endl; return(0); 76