Cum se rezolva?Problema este tratata in revista Doctor Dobbs' Journal
(DDJ), o revista apreciata din SUA, cu un continut eterogen (de la
algoritmica la probleme serioase de ingineria programarii). Aceasta
problema a fost abordata chiar in 2 numere din aceasta revista
(aprilie 1998 si aprilie 1999).Sursa Ralucai Sauciuc este izbitor de
asemanatoare cu pseudocodul prezentat intr-unul din articolele din
DDJ, ceea ce ma duce cu gandul ca trebuie sa fi avut respectivul numar
din DDJ. Asadar, sursa oficiala a acesteu runde o voi decreta pe cea a
Ralucai. Asta nu inseamna ca sunt scutit de a explica cum anume
functioneaza algoritmul.De asemenea sursa lui Florin Ghetu pare sa fie
o implementare similara cu algoritmul implementat de Raluca.
Despre algoritm
---------------
Vom porni de la algoritmul forta bruta aplicat acestei probleme si vom
refina treptat-treptat o varianta de complexitate-timp O(m*n) si
complexitate-spatiu O(m).
Algoritmul forta bruta functioneaza in felul urmator: formam toate
sub-matricele continute in matricea data si verificam pentru fiecare
din aceste matrice daca e compusa doar din elemente de 1. Daca da,
verificam daca aria sub-matricei curente este mai mare decat aria
maxima gasita pana acum. Daca da, tinem minte aceasta noua arie drept
aria maxima gasita pana acum.Matricea se formeaza prin alegerea
elementelor extremitati ll (lower-left) si ur (upper-right).
Atentie: prin conventie spunem ca un element ll este lower left de alt
element ur daca llx
O(m*n).
Asadar complexitatea totala a algoritmului forta-bruta este produsul
celor 2 complexitati calculate, adica O(m^3*n^3). Sursa care
implementeaza forta-bruta este Comisia\M3_N3\ver1.cpp.
Foarte ineficient, nu-i asa? Putem imbunatati acest algoritm observand
ca daca o sub-matrice contine un element 0, atunci toate matricele
care contin aceasta sub-matrice vor contine si ele respectivul element
0. Sursa acestei implementari este Comisia\M2_N2\ver2.cpp.
Vom proceda astfel: pentru fiecare varianta posibila a extremitatii ll
ne vom extinde (a se vedea functia GrowOnes) cu extremitatea (x,y)
(extremitatea (x,y) este candidatul pentru (urx,ury) care maximizeaza
aria pentru (llx,lly) curent) doar pe portiunile compacte cu
1. Aceasta extindere se face luand initial y <- lly, si x <- llx,
extinzandu-ne la dreapta pana cand intalnim 0. Cand intalnim 0 tinem
minte valoare coloanei imediat la stanga de coloana curenta in
xMax. Pe urma urcam pe un nivel mai sus si repetam procesul de
extindere, avand grija sa nu ne extindem la dreapta mai mult de
xMax.Sa luam un exemplu:
- avem matricea
c c
o o
l l
0 3
linia 6 0 0 1 1
1 1 1 1
1 1 1 1
1 1 1 0
1 1 0 0
1 1 1 1
linia 0 1 1 1 0
unde elementul cel mai stanga jos este elementul (0,0), iar
elementul cel mai de dreapta-sus este (6,3).
- avem llx=0, lly=0. A se consulta in continuare funtia GrowOnes.
- initial avem xMax=3. Pornim cu y=0 si x=0 si ne extindem spre
dreapta. Mergem pana la ultimul element 1 de pe aceasta
linie. Acest element este y=0, x=2. Facem xMax=2 si, verificam daca
aria sub-matricei (0,0)->(0,2) este mai mare decat aria maxima
gasita pana acum (adica 0) si facem maxArea=3.
- urcam cu un nivel (il incrementam pe y --> y=1). Ne extindem din
nou spre dreapta, tinand minte ca nu ne putem extinde mai mult de
coloana 2. Avand doar 1 pe aceasta linie, xMax ramane
neschimbat. Verificam daca aria sub-matricei (0,0)->(1,2) este mai
mare decat aria maxima gasita pana acum --> facem maxArea=6.
- urcam cu un nivel (il incrementam pe y --> y=2). Ne extindem din
nou spre dreapta, tinand minte ca nu ne putem extinde mai mult de
coloana 2. Intalnim 0 de pe coloana x=2. Il facem pe xMax <-
1. Verificam daca aria sub-matricei (0,0)->(2,1) este mai mare
decat aria maxima gasita pana acum, adica 6. Nu este mai mare, asa
ca nu modificam maxArea.
- si tot asa pana sus, functia GrowOnes asigurandu-ne gasirea
maximului ariei sub-matricei cu una din extremitati ll. Acest
algoritm are o complexitate de O(m^2*n^2).
In continuare mai putem sa aducem imbunatatiri, observand ca facem mai
multe extinderi la dreapta asupra acelorasi elemente in cadrul
aceleiasi executii. Atunci putem sa tinem evidenta intr-un vector (cei
din DDJ ii spun cache) cu m elemente, cate elemente consecutive de 1
sunt de la coloana curenta (llx) pana in marginea dreapta a
matricei. Ceea ce aduce un spor de eficienta este faptul ca se poate
calcula foarte usor (in O(m)) cache-ul coloanei imediat la dreapta din
cache-ul coloanei curente.Sursa acestui algoritm este
Comisia\M2_N2\ver3.cpp.
Sa vedem pe cazul matricei prezentate mai sus care este evolutia
cache-ului. Cache-ul este initializat cu 0. Cache-ul este reprezentat
pe coloane. Coloana cea mai din dreapta (doar cu elemente 0)
reprezinta starea lui initiala. A doua coloana din dreapta reprezinta
starea cache-ului pentru llx=3, a treia coloana starea cache-ului
pentru llx=2, etc.
0 0 2 1 0
4 3 2 1 0
4 3 2 1 0
3 2 1 0 0
2 1 0 0 0
4 3 2 1 0
3 2 1 0 0
Vom adapta functia GrowOnes din versiune precedenta a algoritmului,
astfel incat sa foloseasca valorile acestui cache in loc sa ne
extindem la dreapta pas cu pas pentru fiecare y. Acest algoritm are o
complexitate-timp de O(m^2*n), platindu-se costul maririi
complexitatii-spatiu cu O(m) (datorita introducerii cache-ului). O
observatie demna de mentionat este ca daca avem m>n putem sa mai
reducem timpul de executie aplicand algoritmul asupra transpusei
matricei, aria maximala fiind invariant la transpunerea matricei.
Ce imbunatatiri mai putem aduce pentru a reduce complexitatea-timp la
O(m*n)? Observam ca ultimul algoritm verifica dreptunghiuri care sunt
continute in totalitate de alte dreptunghiuri verificate. Spre exemplu
algoritmul precedent verifica sub-matricele (0,0) -> (0,2) si (0,0) ->
(1,2), cea de-a doua continand-o pe prima. Scopul nostru este sa
examinam doar dreptunghiurile care nu sunt continute in alte
dreptunghiuri. Sa luam un exemplu, in care avem cache-ul pentru
coloana curenta cu urmatoarele valori:
1 2 3 4
linia 9 0
4 . . . .
4 . . . .
3 . . .
2 . .
4 . . . .
3 . . .
1 .
1 .
linia 0 0
Coloana din stanga reprezinta cache-ul. Pentru a intui mai clar se
poate recrea matricea de 0 si 1 corespunzatoare cache-ului descris.
Coloanele din dreapta cu linii verticale (care sunt notate deasupra cu
1, 2, 3, 4) reprezinta cat de mult se intind pe verticala
dreptunghiurile cu 1 care nu sunt continute in alte dreptunghiuri cu
1, de latime 1, 2, 3, respectiv 4, care se intind pe orizontala de la
coloana corespunzatoare cache-ului in dreapta.
Evident coloanele cu liniile verticale se deduc din valorile
cache-ului, astfel:
- pornim cu elementul din cache corespunzator liniei 0 din matrice
si "urcam" in cache, pas cu pas, spre elementul corespunzator
liniei 9.
- daca elementul curent din cache este mai mare decat anteriorul
atunci inseamna ca trebuie sa inceapa una sau mai multe linii
verticala corespunzatoare unor latimi cel mult egale cu elementul
curent
- daca elementul curent din cache este mai mic decat anteriorul
atunci inseamna ca s-au terminat una sau mai multe linii
verticale corespunzatoare unor latimi cel putin egale cu
elementul curent
- daca elementul curent din cache este egal cu anteriorul nu incepe
si nu se termina nici o linie verticala.
Se observa ca liniile verticale au o cvasi-proprietate a parantezelor
bine formate: o linie verticala care incepe mai devreme se termina
intotdeauna mai tarziu sau in acelasi moment cu o linie care a inceput
mai tarziu; in cazul in care 2 linii verticale pornesc la acelasi timp
atunci cea corespunzatoare unei latimi mai mari se termina prima.
Aceasta proprietate ne duce cu gandul sa folosim o stiva in care:
- facem PUSH atunci cand elementul curent din cache este mai mare
decat cel anterior, cu perechea de valori
(valoarea_elementului_anterior; pozitia_lui_in_cache)
- facem POP-uri in cazul in care elementul curent este mai mic
decat cel anterior, pana cand extragem o pereche care are
valoare_element mai mica decat elementul curent din c. Pentru
fiecare POP fac verificarea daca dreptunghiul de latime
valoare_element si inaltime
y_curent-pozitie_in_cache_scoasa_din_stiva are aria mai mare
decat aria maxima gasita la pasul curent.
Sursa acestui algoritm este Comisia\M_N\sauciuc.cpp. Sa analizam
complexitatea-timp a algoritmului: se executa de m ori functia care
face actualizarea cache-ului (care functie are complexitate Theta(m)),
iar bucla for(j=0; j<=n; j++) are complexitate O(n), pentru ca se fac
cel mult n+1 PUSH-uri, iar numarul de POP-uri este egal cu cel de
PUSH-uri. Complexitatea spatiu este tot O(n), chiar daca s-a mai
introdus o stiva (care are dimensiune n+1).
Evident aici ia sfarsit questul nostru de reducere a complexitatii,
din moment ce este nevoie de exact m*n operatii de citire a
elementelor din matrice, iar daca nu sunt citite toate este posibil sa
nu se gasesca solutia optima. Pe deasupra aceasta varianta finala de
algoritm este posibil sa o implementam fara a tine minte in memorie
toate matricea (in celelalte versiuni ale algoritmului trebuia sa
retinem intreaga matrice in memorie). Mai exact nu trebuie sa retinem
nici un element din matrice: citim element cu element din linia
curenta si actualizam cache-ul. Practic, reducand complexitatea-timp
am redus-o si pe cea spatiu.
Se observa ca in program (Comisia\M_N\sauciuc.cpp) se citesc
elementele linie cu linie, deci cache-ul se calculeaza pe matricea
transpusa fata de cum a fost prezentat algoritmul mai sus.
Un alt tip de algoritm
----------------------
Spre norocul tuturor, mai exista un algoritm care rezolva problema in
O(m*n). Acesta este dupa parerea mea mai intuitiv decat cel anterior,
dar are o implementare un pic mai costisitoare. Programul care
implementeaza algoritmul pe care il voi descrie mai departe este "M_N
ver 2\algo2.cpp".
Care sunt pasii acestui algoritm:
- se citeste matricea cate o linie la un pas
- se calculeaza in vectorul C, cache-ul despre care am discutat in
algoritmul anterior (adica c[i]=numarul consecutiv de 1 care sunt
incepand de la linia curenta, deasupra).
- avem acum un vector C cu n elemente. Cum anume putem sa
determinam la pasul curent, in timp O(n) care este aria maxima
pentru pasul curent? Pentru asta trebuie sa gasim lungimea unui
interval de elemente din C, (C[i1..i2] - interval solutie) care
sa maximizeze aria, adica valoarea:
(i2-i1+1) * min(C[i])
i1<=i<=i2
Fie M elementul maxim din C. Sa observam ca daca M apartine
intervalului solutie, avem 2 variante: fie intervalul solutie este
compus doar din elementul M (atunci aria maxima este 1*M=M), fie
intervalul contine si un element vecin (poate chiar mai multe) al lui
M (care este mai mic sau cel mult egal cu M), in care caz aria maxima
se obtine dubland valoarea elementului vecin lui M. Spunem in cazul
asta ca elementul vecin lui M contribuie de 2 ori cu valoarea lui, in
calculul ariei.
Definim vectorul length de n elemente, unde length[i] inseamna cati
vecini luam in calcul cand luam in consideratie elementul i din
vectorul C pentru calcul ariei.
Sper ca se vor clarifica lucrurile imediat. Iata cum determinam in
O(n) aria maxima:
- bagam elementele lui C bagate intr-o lista L
- initializam elementele vectorului length cu 1
- atata timp cat L are elemente:
P1. gasim maximul M din lista L. (pentru a obtine complexitate
O(n) pe toata aceasta bucla, se recomanda a se face o
sortare initiala a tuturor elementelor lui C cu un
counting-sort care are complexitate O(n), a.i. sa alegem
pe urma la fiecare pas maximul din L in O(1) - adica al
k-lea maxim, unde k=nr pas)
P2. verificam daca length[i]*M (unde length[i] este elementul
lui length asociat lui M) este mai mare decat cea mai buna
arie gasita pana acum, in care caz o retinem
P3. incrementam length[vecinul_cel_mai_mare_al_lui_M_din_L] cu
length[i]
P4. stergem M din L.
Sa luam un exemplu. Fie C=(4,2,3,6,5,3).
- atunci la primul pas avem:
length=(1,1,1,1,1,1)
L=(4,2,3,6,5,3)
Gasim maximul din L --> M=6. Verificam daca 1*6>maxArea -->
maxArea=6. Gasim vecinul cel mai mare al lui 6 din L (acesta este
5) caruia ii incrementam length cu length-ul lui 6.
- La al doilea pas avem:
length=(1,1,1,1,2,1)
L=(4,2,3, ,5,3)
Gasim maximul din L --> M=5. Verificam daca 2*5>maxArea -->
maxArea=10. Avem ambii vecini din L ai lui 5 egali, il alegem pe
oricare, spre ex. 3-ul din dreapta.
- La al treilea pas avem:
length=(1,1,1,1,2,3)
L=(4,2,3, , ,3)
Gasim maximul din L --> M=4. Verificam daca 1*4>maxArea -->
maxArea=10. 4 are un singur vecin...
- La al 4-lea pas avem:
length=(1,2,1,1,2,3)
L=( ,2,3, , ,3)
Gasim maximul din L --> M=3 (din dreapta). Verificam daca
3*3>maxArea --> maxArea=10. 3 are un singur vecin...
- Pasul 5:
length=(1,2,4,1,2,3)
L=( ,2,3, , , )
Gasim maximul din L --> M=3. Verificam daca 4*3>maxArea -->
maxArea=12. 3 are un singur vecin...
- Pasul 6:
length=(1,6,4,1,2,3)
L=( ,2, , , , )
Gasim maximul din L --> M=2. Verificam daca 6*2>maxArea -->
maxArea=12. L devine vid, deci se termina executia algoritmului.
Observam ca faptul ca ajungem la optim este garantat de ordinea in
care se verifica intervalele candidat.
Obs: In implementare nu respect pasul P1 al algoritmului, anume nu
gasesc maximul din L (trebuia implementat ceva mai mult), ci gasesc un
maxim local, adica un element care are ambii vecini mai mici sau cel
mult egali cu el. Daca aveti timp si chef, va invit sa va uitati la
program si sa incercati sa generati un caz care sa imi faca programul
sa aiba timpul de executie mai mare de O(m*n). Eu unul nu am fost in
stare sa generez.
Se observa ca in cazul algoritmului cu stiva se pot determina si care
sunt coordonatele dreptunghiului solutie imediat, pe cand in cazul
algoritmului cu lista dublu-inlantuita aceasta inseamna a cauta in
vectorul length, intervalul de lungime length[i] care sa contina
pozitia i si care sa contina toate valorile 1..length[i] o singura
data.
Concluzie: din culpa am dat niste solutii kilometrice. Daca am sa mai
propun probleme promit solemn ca le voi rezolva in timp util si cat
mai simplu si cat mai putin cu putinta.
Referinte:
1. David Vandervoorde - The Maximal Rectangle Problem, DDJ, April 1998
2. Udi Manber - Designing Algorithms Incrementally, DDJ, April
1999
|
|