Sunteți pe pagina 1din 29

Divide and Conquer

Aplicabilitatea algoritmilor de tip Divide and Conquer


Metoda de rezolvare Divide and Conquer se poate aplica la problemele care se pot descompune
în subprobleme de aceeaşi natură cu problema principală, dar de dimensiuni mai mici.

Modul de lucru al algoritmilor de tip Divide and Conquer


Se poate pune întrebarea ”cum rezolvăm subproblemele?”. Răspunsul este: ”în acelaşi mod în
care am rezolvat problema principală”. Metoda Divide and Conquer se realizează foarte bine prin
implementări recursive. Din moment ce ştim să împărţim problema principală în subprobleme, ce
ne opreşte să facem acelaşi lucru cu fiecare subproblemă în parte? Putem împarţi fiecare
subproblemă în subsubprobleme, pe care la răndul lor le impărţim în subsubsubprobleme,
ş.a.m.d.

Când ne oprim cu aceste împărţiri recursive? Atunci când ajungem la subprobleme de dimensiuni
atât de mici încât rezolvarea lor este trivială.

Implementare

În general implementarea metodei Divide and Conquer se face prin funcţii recursive. De regulă
vom avea o singură funcţie care primeşte ca parametri informaţiile necesare pentru a rezolva o
subproblemă şi returnează rezultatele pentru subproblema respectivă.

Funcţia va determina dacă subproblema este una trivială, caz în care va calcula direct soluţia
pentru ea. Dacă subproblema nu este una trivială, atunci funcţia va împărţi subproblema în
subsubprobleme şi se va auto-apela în mod recursiv pentru fiecare din ele. Pe urmă va combina
rezultate obţinute pentru subsubprobleme şi va găsi soluţia pentru subproblemă.

Prezentăm schema generală de lucru a unei asemena funcţii, în pseudocod:

function divide(parametri care definesc o subproblema)


{
if (subproblema este una triviala)
{
rezolva subproblema in mod direct
returneaza rezultatele
}
imparte subproblema in subsubprobleme
pentru fiecare subsubproblema
apeleaza divide(subsubproblema)

combina rezultatele subsubproblemelor


returneaza rezultatele pentru subproblema
}

Pentru a rezolva problema principală, tot ce trebuie făcut este să se apeleze funcţia recursivă cu
acei parametri care definesc problema principală.

Exemplu: Turnurile din Hanoi

Enunţ: Fie trei tije notate cu a, b şi c. Pe tija a se află n discuri de dimensiuni diferite, aşezate în
ordinea descrescătoare a diametrelor, astfel încât discul cu diametrul cel mai mare se află cel
mai jos, iar discul cu diametrul cel mai mic se află în vârful stivei. Să se găsească o modalitate
de a muta toate cele n discuri de pe tija a pe tija b, folosind tija intermediară c, atfel încât în final
discurile să fie ordonate tot descrescător. În timpul operaţiilor care se fac, este interzisă plasarea
unui disc mai mare peste un disc mai mic.

Rezolvare Problema noastră iniţială este să mutăm n discuri de pe tija a pe tija b, folosind tija
intermediară c. O putem codifica în felul următor: (n,a,b,c). Dacă am găsi o modalitate de a muta
n-1 discuri de pe tija a pe tija intermediară c, atunci am putea să mutăm discul cel mai mare de
pe tija a pe tija b. Pe urmă ar trebui să aducem cele n-1 discuri de pe tija c pe tija b şi problema
ar fi rezolvată. Pentru a muta n-1 discuri de pe tija a pe tija c, putem folosi ca tijă intermediară tija
b. La fel, pentru a muta înapoi cele n-1 discuri de pe tija c pe tija b, putem folosi ca tijă intermediară
tija a.

Putem reformula cele zise mai sus în felul următor: problema (n,a,b,c) se rezumă la problema (n-
1,a,c,b), urmată de mutarea discului de diametru maxim de pe a pe b, urmată de problema (n-
1,c,b,a).

Implementare: implementarea se face printr-o funcţie recursivă. Funcţia primeşte patru


parametri: numărul de discuri de pe tija iniţială, tija initială, tija finală şi tija intermediară. Se
descompune problema in subprobleme, în modul descris mai sus. Cazul trivial este acela când
avem de mutat un singur disc şi în această situaţie discul este mutat direct.
Cod sursă: prezentăm în continuare codul sursă în limbajul C care rezolvă problema:

#include <stdio.h>

/* Functie care muta n discuri de pe tija initiala pe tija


finala, folosind o tija intermediara. Rezolvarea se face
in maniera Divide and Conquer. */
void hanoi(int n, char t_initial, char t_final, char t_intermediar)
{
/* Daca avem mai mult de o tija de mutat, atunci
descompunem problema in subprobleme. */
if (n > 1) {
hanoi(n - 1, t_initial, t_intermediar, t_final);
printf("%c -> %c\n", t_initial, t_final);
hanoi(n - 1, t_intermediar, t_final, t_initial);
}
/* Daca avem un singur disc de mutat, atunci il mutam
direct. La acest nivel problema are o rezolvare
triviala. */
else {
printf("%c -> %c\n", t_initial, t_final);
}
}

int main(void)
{
/* Numarul de discuri. */
int n;

/* Citim numarul de discuri de la tastatura. */


printf("Introduceti numarul de discuri:");
scanf("%d", &n);

/* Apelam functia recursiva. */


hanoi(n, 'a', 'b', 'c');

return 0;
}

Exemplu: Determinarea minimului şi maximului dintr-un şir de numere


Enunţ: Se dă un şir de n numere reale {x[0], x[1], …, x[n-1]}. Să se determine valoarea minimă
şi valoarea maximă din acest şir de numere.

Rezolvare: metoda imediată de rezolvare este parcurgerea întregului şir şi inspectarea fiecărui
element de două ori, o dată pentru aflarea minimului şi a doua oară pentru aflarea maximului.
Codul sursă în limbajul C pentru această metodă imediată este:

#include <stdio.h>

/* Declaram sirul de numere direct din cod. Alternativ el


poate fi citit de la tastatura sau din fisier. */
#define N 10
int x[] = { 10, 5, 23, -11, 4, 2, 0, -6, 66, 40 };

int main(void)
{
/* Folosim doua variabile pentru a stoca minimul si
maximul gasite. */
int min, max;

/* Vom contoriza numarul de comparatii care se fac


pentru gasirea minimului si maximului. */
int comp = 0;

int i;

/* Afisam sirul de numere. */


printf("Avem %d numere.\n", N);
for (i = 0; i < N; i++)
printf("%d ", x[i]);
printf("\n\n");

/* Initializam minimul si maximul cu prima valoare din


sir. */
min = x[0];
max = x[0];

/* Parcurgem intreg sirul si actualizam minimul si


maximul atunci cand e cazul. */
for (i = 1; i < N; i++) {
/* Facem o comparatie pentru minim. */
comp++;
if (min > x[i])
min = x[i];

/* Si o comparatie pentru maxim. */


comp++;
if (max < x[i])
max = x[i];
}

/* Afisam rezultatele. */
printf("Minimul este %d.\n", min);
printf("Maximul este %d.\n", max);
printf("Comparatii facute: %d.\n", comp);

return 0;
}

Dacă analizăm metoda de mai sus, vom vedea că ea face comparaţii inutile, deoarece orice
element care este candidat pentru minim nu poate fi în acelaşi timp candidat pentru maxim, şi
invers. Deci este redundant să testăm fiecare element în parte atât pentru minim cât şi pentru
maxim.

Putem aplica tehnica Divide and Conquer, împărţind şirul de numere în două părţi. Determinăm
minimul şi maximul pentru fiecare din cele două părţi, iar pe urmă determinăm maximul global
prin compararea celor două maxime parţiale, iar minimul global prin compararea celor două
minime parţiale.

Implementare Pentru implementare vom defini o funcţie recursivă ce va căuta minimul şi maximul
într-o secvenţă a şirului. Iniţial vom apela această funcţie pentru întregul şir. Funcţia se va apela
pe ea însăşi, recursiv, pentru jumătatea stângă şi pentru jumătatea dreaptă a secvenţei.

Cod sursă: prezentăm în continuare codul sursă în limbajul C pentru rezolvarea problemei.

#include <stdio.h>
/* Declaram sirul de numere direct din cod. Alternativ el
poate fi citit de la tastatura sau din fisier. */
#define N 10
int x[] = { 10, 5, 23, -11, 4, 2, 0, -6, 66, 40 };

/* Numaram cate comparatii se fac in total. */


int comp = 0;

/* Functie care determina minimul si maximul dintr-o


secventa a sirului de numere. Secventa este delimitata
de indicii "st" si "dr". Valorile minime si maxime
gasite vor fi returnate prin pointerii "min" si "max"
primiti ca si parametru. */
void minmax(int st, int dr, int *min, int *max)
{
int mijloc, min_st, max_st, min_dr, max_dr;

printf("Caut in secventa [%d..%d].\n", st, dr);

/* Daca secventa contine un singur numar, atunci el


este atat minim cat si maxim. */
if (st == dr) {
*min = x[st];
*max = x[st];
}
/* Daca secventa contine doua numere, atunci facem o
comparatie pentru a gasi minimul si maximul. */
else if (st == dr - 1) {
comp++;
if (x[st] < x[dr]) {
*min = x[st];
*max = x[dr];
} else {
*min = x[dr];
*max = x[st];
}
}
/* Daca avem mai multe numere, atunci divizam problema
in subprobleme. */
else {
/* Divizare. */
mijloc = (st + dr) / 2;
minmax(st, mijloc, &min_st, &max_st);
minmax(mijloc + 1, dr, &min_dr, &max_dr);

/* Combinarea rezultatelor partiale. Comparam


minimele partiale intre ele si maximele partiale
intre ele. */
comp++;
if (min_st < min_dr)
*min = min_st;
else
*min = min_dr;
comp++;
if (max_st > max_dr)
*max = max_st;
else
*max = max_dr;
}
}

int main(void)
{
int min, max;
int i;

/* Afisam sirul de numere. */


printf("Avem %d numere.\n", N);
for (i = 0; i < N; i++)
printf("%d ", x[i]);
printf("\n\n");

/* Apelam functia recursiva. */


minmax(0, N - 1, &min, &max);

/* Afisam rezultatele. */
printf("\n");
printf("Minimul este %d.\n", min);
printf("Maximul este %d.\n", max);
printf("Comparatii facute: %d.\n", comp);

return 0;
}

Dacă rulăm în paralel cele două programe, vom vedea că întradevăr pentru acelaşi şir de numere
metoda Divide and Conquer face mai puţine comparaţii decât metoda clasică.

Probleme propuse spre rezolvare

Problema 1: Se consideră n oraşe. Pentru diferite perechi de oraşe (i, j), 0<i<n, 0<j<n se
cunoaşte costul conectării lor directe c[i][j]. Nu toate perechile de oraşe pot fi conectate; pentru
perechile care nu pot fi conectate nu se precizează costul. Se cere să se construiască o reţea
prin care oricare două oraşe să fie conectate între ele direct sau indirect şi costul total al conectării
să fie minim.

Se poate arăta că reţeaua de conectare cerută este un arbore. Problema mai este cunoscută şi
ca problema determinării arborelui parţial de cost minim într-un graf. Pentru această problemă
există un algoritm greedy de rezolvare numit algoritmul lui Prim. În literatura de specialitate există
argumentarea matematică a faptului că acest algoritm găseşte întotdeauna soluţia optimă de
conectare a oraşelor.

Se construieşte arborele parţial minim în manieră greedy, adăugând câte un nod la fiecare pas.
La început de tot arborele parţial este vid, nu conţine nici un nod. Primul pas constă în adăugarea
unui nod arbitrar în arbore. Pe urmă, la fiecare pas se caută muchia de cost minim care porneşte
dintr-un nod deja adăugat la arbore şi ajunge într-un nod care nu este în arbore. Se adaugă în
arbore nodul în care sfârşeşte muchia găsită.

Să considerăm spre exemplu o reţea de 7 oraşe numerotate de la 0 la 6. Costurile de conectare


a oraşelor sunt redate în figură.
Arborele minim este redat cu linii îngroşate. El a fost construit pas cu pas, conform procedeului
descris mai sus. Iniţial arborele a fost vid. La primul pas s-a adăugat un nod arbitrar, şi anume
nodul 0.

Pe urmă s-a ales muchia de cost minim care pleacă din nodul 0 către celelalte noduri. Muchia de
cost minim a fost (0,2) de cost 10. Nodul 2 a fost adăugat în arbore.

La următorul pas s-a ales muchia de cost minim care pleacă din nodurile 0 sau 2 către celelalte
noduri. Muchia aleasă a fost (2,1) de cost 9. Nodul 1 a fost adăugat în arbore.

La următorul pas s-a ales muchia de cost minim care pleacă din nodurile 0, 2 sau 1 către nodurile
încă neintroduse în arbore. Muchia aleasă a fost (1,5) de cost 3. Nodul 5 a fost adăugat în arbore.

Următoarea muchie aleasă a fost (5,4) de cost 2. Nodul 4 a fost adăugat în arbore. Apoi a fost
aleasă muchia (4,6) de cost 2 şi nodul 6 a fost adăugat şi el în arbore.

Pe urmă a fost aleasă muchia (1,3) de cost 4 şi nodul 3 a fost introdus în arbore. În acest moment
algoritmul s-a încheiat deoarece toate oraşele au fost conectate la reţea. Costul total al conectării
a fost 10 + 9 + 3 + 2 + 2 + 4 = 30.

Fisierul cu date de intrare pentru graful din figură este următorul:

7
0 20 10 0 0 0 0
20 0 9 4 0 3 0
10 9 0 10 0 0 0
0 4 10 0 0 0 0
0 0 0 0 0 2 2
0 3 0 0 2 0 3
0 0 0 0 2 3 0

Problema 2 (Cele mai apropiate puncte de pe o dreaptă): Se dau N puncte în plan, situate pe
o dreaptă paralelă cu axa OX. Să se determine perechea de puncte care sunt cel mai apropiate
unul de altul. Dacă există mai multe asemenea perechi, se va determina una din ele.

Datele de intrare se citesc din fişierul puncte.in. Pe prima linie din fişier apare N, numărul de
puncte. Pe a doua linie apar N valori reale, reprezentând coordonatele X ale celor N puncte.
Coordonatele Y ale punctelor nu se precizează, deoarece toate punctele au aceeaşi coordonată
Y. În fişier punctele apar sortate crescător în ordinea coordonatei X.

Programul va scrie în fişierul puncte.out două valori reale reprezentând coordonatele celor mai
apropiate două puncte.

Rezolvarea divide and conquer se face în felul următor:

 se împarte mulţimea de puncte în două jumătăţi;


 punctele fiind sortate după coordonata X, avem trei variante posibile:
o fie perechea pe care o căutăm se află în prima jumătate;
o fie perechea se află în a doua jumătate;
o fie un punct al perechii se află în prima jumătate şi celălalt punct în a doua jumătate;
 în manieră divide and conquer rezolvăm subproblemele pentru cele două jumătăţi;
 memorăm distanţa minimă găsită pentru prima jumătate şi pentru a doua jumătate;
 verificăm dacă există o pereche de puncte cu un punct din prima jumătate şi un punct din a
doua jumătate astfel încât să obţinem o distanţă mai mică: se poate demonstra faptul că
singura variantă în care am putea obţine o distanţă mai mică este folosind cel mai din dreapta
punct din prima jumătate împreună cu cel mai din stânga punct din a doua jumătate;
 în final păstrăm distanţa cea mai scurtă din cele trei variante.

Metoda Backtracking

1 Consideraţii teoretice

1.1 Aplicabilitatea algoritmilor de tip Backtracking

Algoritmii de tip backtracking se aplică la problemele unde spaţiul soluţiilor S este de forma unui
produs cartezian S = S0 × S1 × ... × Sn-1. Orice element x din spaţiul soluţiilor S va fi un vector de
forma x = (x0, x1, ..., xn-1), cu xi Si, 0<i<n.

Nu toate elementele xS sunt soluţii valide ale problemei. Doar acele elemente x care satisfac
anumite condiţii impuse de problemă vor fi soluţii valide. Definim condiţiile care trebuie satisfăcute
sub forma unei funcţii booleene Solutie(x0,x1,...,xn-1). Un element x=(x0, x1, ..., xn-1)  S este
soluţie a problemei dacă funcţia Solutie aplicată componentelor lui x va returna valoarea true.
Scopul este de a găsi acei vectori xS pentru care funcţia Solutie returnează true.

1.2 Modul de lucru al algoritmilor de tip Backtracking

Modul de lucru al algoritmului este următorul: se alege un element x00 din S0, apoi un element
x10 din S1, apoi un element x20 din S2, ş.a.m.d, până când se va fi ales un element x n-10 din Sn-1.
În acest moment vom avea un vector x = (x00, x10, ..., xn-20, xn-10)  S. Evaluăm rezultatul
funcţiei Solutie pentru acest vector. Dacă obţinem rezultatul true atunci avem o soluţie corectă a
problemei. Dacă obţinem rezultatul false, soluţia nu este corectă şi continuăm căutarea.

Aplicăm principiul revenirii pe cale, adică ne întoarcem cu un pas în urmă, înainte de alegerea
elementului xn-10 Sn-1. De aici continuăm alegând un alt element xn-11 Sn-1, xn-11 ≠ xn-10. Vom

obţine un nou vector x' = (x00,x10,...,xn-20,xn-11) S asupra căruia aplicăm din nou funcţia Solutie.
Dacă rezultatul este false, revenim din nou cu un pas în urmă şi continuăm cu alegerea unui alt
element xn-12 Sn-1, xn-12 ≠ xn-10 şi xn-12 ≠ xn-11. Repetăm aceşti paşi până la epuizarea tuturor
elementelor din Sn-1.

După ce am epuizat toate elementele mulţimii Sn-1, vom fi la faza în care am ales elementele x00,
x10, ..., xn-20. Ar trebui să alegem un element din Sn-1, dar cum toate elementele din Sn-1 au fost
deja alese, revenim cu încă un pas în urmă pe cale, şi alegem un alt element xn-21 Sn-2. Pe urmă

reîncepem alegerea elementelor din Sn-1 cu primul dintre ele, xn-10 Sn-1. Vom avea vectorul x =
(x00, x10,..., xn-30, xn-21, xn-10).

Procedând în acest mod, vom ajunge practic să construim toţi vectorii x S, adică vom explora
întreg spaţiul soluţiilor posibile. Există în principiu două categorii de probleme: unele care cer
găsirea unei singure soluţii şi unele care cer găsirea tuturor soluţiilor. Atunci când se cere găsirea
unei singure soluţii, putem opri căutarea după găsirea primului vector x pentru care
funcţia Solutie returnează valoarea true. La cealaltă categorie de probleme este nevoie să
parcurgem întreg spaţiul soluţiilor pentru a găsi toate soluţiile.

Parcurgerea întregului spaţiu al soluţiilor posibile este foarte mare consumatoare de timp. În
general se poate accelera această parcurgere prin următoarea optimizare: pornind de la
funcţia Solutie(x0, x1, ..., xn-1), definim o funcţie Continuare(x0, x1, ..., xk) care să ne spună dacă,
având alese la un moment dat elementele x0, x1, ..., xk există o şansă de a se ajunge la o soluţie.
Această optimizare este foarte utilă deoarece de multe ori după alegerea câtorva elemente x 0,
x1, ..., xk ne putem da seama că nu vom putea găsi nici o soluţie validă care conţine elementele
respective. În asemenea situaţii nu mai are rost să continuăm alegerea de elemente până la x n-1,
ci putem direct să revenim cu un pas în urmă pe cale şi să încercăm cu un alt element x k'.

2 Implementare

Algoritmul backtracking redactat sub formă de pseudocod arată în felul următor:

k = 0;

while (k >= 0)
{
do
{
* alege urmatorul x[k] din multimea S[k]
* evalueaza Continuare(x[1], x[2], ..., x[k])
}
while ( !Continuare(x[1], x[2], ..., x[k]) &&
(* mai sunt elemente de ales din multimea S[k]) )

if (Continuare(x[1], x[2], ..., x[k]))


{
if (k == n-1)
{
if (Solutie(x[1], x[2], ..., x[n]))
* afiseaza solutie
}
else
{
k = k + 1;
}
}
else
{
k = k - 1;
}
}

Dacă analizăm modul de funcţionare al algoritmului backtracking, vom vedea ca la orice moment
de timp ne este suficient un tablou de n elemente pentru a memora elementele x0, x1, ..., xn-
1 alese. Ca urmare în implementare declarăm un tablou x de dimensiune n. Tipul de date al
tabloului depinde de problemă, de tipul mulţimilor S.

Variabila k ne indică indicele mulţimii din care urmează să alegem un element. La început de tot
trebuie să alegem un element din mulţimea S0, de aceea îl iniţializăm pe k cu valoarea 0. Pe urmă
k se modifică în funcţie de modul în care avansăm sau revenim pe calea de căutare.

Pentru alegerea următorului xk din mulţimea Sk, ne bazăm pe faptul că între elementele fiecărei
mulţimi Sk există o relaţie de ordine. Dacă încă nu s-a ales nici un element din mulţimea S k atunci
îl alegem pe primul conform relaţiei de ordine. Dacă deja s-a ales cel puţin un element, atunci îl
alegem pe următorul neales, conform relaţiei de ordine.

3 Exemple

3.1 Săritura calului pe tabla de şah

Enunţ: Avem o tablă de şah de dimensiune 8x8. Să se gasească toate modalităţile de a deplasa
un cal pe această tablă, astfel încât calul să treacă prin toate căsuţele de pe tablă exact o dată.

Rezolvare: Pentru a parcurge fiecare căsuţă de pe tabla de şah exact o dată, calul va trebui să
facă exact 8 × 8 = 64 de paşi. La fiecare pas el poate alege oricare din cele 64 de căsuţe de pe
tablă. Să codificăm căsuţele de pe tabla de şah în modul următor: căsuţa de la linia i şi coloana j
o notăm prin perechea (i,j). Să notăm mulţimea tuturor căsuţelor de pe tablă cu C: C = {(0,0),
(0,1), ..., (0,7), (1,0), ..., (7,7)}.

O soluţie a problemei o putem nota printr-un vector x = (x0, x1, ..., x63), unde xS = C × C × C ×

... × C (produs cartezian în care mulţimea C apare de 64 de ori), iar xi C,  i {0, 1, ..., 63}.

Cu aceste elemente putem vedea că se poate aplica o rezolvare de tip backtracking.


Funcţia Solutie va verifica să nu existe două elemente ci şi cj care au aceeaşi valoare, deoarece
asta ar însemna că s-a trecut de două ori prin aceeaşi căsuţă. În plus funcţia mai trebuie să
verifice faptul că  i {0, 1, ..., 61, 62} calul poate sări de la căsuţa c i la căsuţa ci+1. Asta
înseamnă că fie ci şi ci+1 se află la două linii distanţă şi la o coloană distanţă, fie ele se află la o
linie distanţă şi la două coloane distanţă.

Funcţia Continuare trebuie să facă exact aceleaşi verificări ca şi funcţia Solutie, dar nu pentru
toate 64 de căsuţe ci pentru cele k căsuţe care au fost alese până la un moment dat.

Cod sursă În continuare prezentăm codul sursă pentru rezolvarea acestei probleme.

#include <stdio.h>
#include <stdlib.h>

/* Dimensiunea tablei de sah definita ca si constanta. Pentru o tabla de dimensiune 8x8 gasirea
solutiilor dureaza foarte mult, de aceea lucram pe o tabla de 5x5 unde solutiile
sunt gasite mult mai repede. */
#define N 5

#define INVALID -1

int main(void)
{
/* Pentru o tabla de dimensiune N vom memora solutiile intr-un vector de dimensiune
N*N. Fiecare element din vector va fi la randul lui un vector cu doua elemente, primul element
va memora linia de pe tabla, iar al doilea element va memora coloana de pe tabla. */
int c[N*N][2];

int k, i;
int pe_tabla, continuare;
int delta_l, delta_c;

/* Numaram si cate solutii sunt gasite. */


int count = 0;
/* Pentru inceput marcam toate elementele vectorului "c" cu INVALID, semn ca nu am
ales nici un element din multimile produsului cartezian. */
for (i=0; i<N*N; i++)
{
c[i][0] = INVALID;
c[i][1] = INVALID;
}

k = 0;
while (k >= 0)
{
/* Incercam sa plasam mutarea "k" a calului in fiecare casuta a tablei de joc, pe
rand. Evaluam la fiecare alegere functia "Continuare". Ne oprim fie atunci cand am incercat toate
casutele de pe tabla, fie atunci cand gasim o casuta unde functia "Continuare" returneaza "true".
*/
do
{
/* Aici alegem urmatorul element din multimea "C[k]". Daca elementul "c[k]" este setat
pe INVALID, inseamna ca inca nu am ales nici un element din multimea curenta, deci alegem
primul element (plasam calul in casuta de la linia 0 si coloana 0). */
if (c[k][0] == INVALID)
{
c[k][0] = 0;
c[k][1] = 0;
pe_tabla = 1;
}
/* Daca elementul "c[k]" nu este setat pe invalid, inseamna ca deja am ales o casuta din
multimea "C[k]". Acum alegem urmatoarea casuta de pe tabla. Cu alte cuvinte incercam sa
plasam calul in urmatoarea casuta. Daca este posibil incercam sa ramanem pe aceeasi linie si
sa ne deplasam cu o coloana spre dreapta. */
else if (c[k][1] < N-1)
{
c[k][1]++;
pe_tabla = 1;
}
/* Daca cumva eram chiar la ultima casuta din linie, atunci alegem prima casuta din linia
urmatoare. Ne asiguram ca nu eram cumva si pe ultima linie atablei, caz in care am epuizat
toate casutele. */
else if (c[k][0] < N-1)
{
c[k][1] = 0;
c[k][0]++;
pe_tabla = 1;
}
/* Daca eram pe ultima linie a tablei, atunci am epuizat toate casutele. Marcam acest
lucru setand variabila "pe_tabla" pe 0. */
else
{
pe_tabla = 0;
}

/* Daca casuta "c[k]" aleasa este valida (se afla pe tabla de joc), atunci trecem la calculul
functiei "Continuare". */
if (pe_tabla)
{
/* Daca suntem la prima mutare a calului, atunci mutarea este valida oriunde ar fi ea
pe tabla. */
if (k == 0)
continuare = 1;
/* Daca nu suntem la prima mutare,
atunci trebuie sa facem o serie
de verificari. */
else
{
/* In primul rand verificam daca de la pozitia precedenta a calului pe tabla ("c[k-1]") se
poate ajunge in pozitia aleasa acum printr-o mutare.
*/
delta_l = abs(c[k-1][0]-c[k][0]);
delta_c = abs(c[k-1][1]-c[k][1]);
continuare = (((delta_l == 1) && (delta_c == 2)) ||
((delta_l == 2) && (delta_c == 1)));

/* Si apoi verificam daca nu cumva s-a mai trecut prin casuta aleasa acum.
*/
for (i=0; continuare && (i<k); i++)
{
if ((c[i][0] == c[k][0]) && (c[i][1] == c[k][1]))
continuare = 0;
}
}
}
/* Daca casuta "c[k]" aleasa este in afara tablei de sah, atunci functia "Continuare" va
returna automat "false". */
else
{
continuare = 0;
}
}
while (!continuare && pe_tabla);

/* Daca am obtinut rezultat pozitiv in urma verificarilor de "Continuare", atunci consideram


piesa asezata la pozitia "c[k]" si continuam cautarea. */
if (continuare)
{
/* Daca s-a parcurs toata tabla de sah atunci afisam solutia. */
if (k == N*N - 1)
{
for (i=0; i<N*N; i++)
printf("(%d,%d) ", c[i][0], c[i][1]);
printf("\n");
count++;
}
/* Daca nu s-a parcurs inca toata tabla atunci trecem cu un pas inainte pe calea de
cautare. */
else
{
k++;
}
}

/* Daca casuta aleasa nu este valida, atunci marcam elementul "c[k]" cu INVALID
si revenim cu un pas inapoi pe calea de cautare. */
else
{
c[k][0] = INVALID;
c[k][1] = INVALID;
k--;
}
}

printf("%d solutii\n", count);


return 0;
}

Optimizare: Putem face o optimizare la programul prezentat, pornind de la faptul că se cunosc


regulile după care se poate deplasa calul pe tabla de şah. La alegerea din mulţimea C k, în loc să
alegem pe rând fiecare căsută şi apoi să verificăm dacă s-ar putea face mutare în căsuţa
respectivă, e mai eficient să alegem direct dintre căsuţele în care se poate face mutare. Pentru a
putea determina aceste căsuţe, folosim doi vectori care definesc mutările posibile ale calului
(numărul de căsuţe cu care se poate deplasa pe orizontală şi pe verticală). Prezentăm mai jos
codul sursă care implementează această optimizare. Implementarea este una recursivă, pentru
a arăta că modul de lucru al algoritmului backtracking se pretează în mod natural la implementări
recursive.

#include <stdio.h>
#define N 5

int dx[8] = {-1, -2, -2, -1, 1, 2, 2, 1};


int dy[8] = {-2, -1, 1, 2, 2, 1, -1, -2};

int c[N*N][2];
int count = 0;

void back(int pas)


{
int i, j, continuare;

if (pas == N*N)
{
for (i=0; i<pas; i++)
printf("(%d,%d) ", c[i][0], c[i][1]);
printf("\n");
count++;
}
else
{
for (i=0; i<8; i++)
{
c[pas][0] = c[pas-1][0] + dy[i];
c[pas][1] = c[pas-1][1] + dx[i];

if ((c[pas][0]>=0) && (c[pas][0]<N) &&


(c[pas][1]>=0) && (c[pas][1]<N))
{
continuare = 1;
for (j=0; continuare && (j<pas); j++)
{
if ((c[j][0] == c[pas][0]) &&
(c[j][1] == c[pas][1]))
continuare = 0;
}

if (continuare)
back(pas+1);
}
}
}
}

int main(void)
{
int i,j;
for (i=0; i<N; i++)
for (j=0; j<N; j++)
{
c[0][0] = i;
c[0][1] = j;
back(1);
}
printf("%d solutii\n", count);
return 0;
}

4 Probleme propuse

4.1 Ieşirea din labirint

Să se scrie un program care găseşte toate căile de ieşire dintr-un labirint.

Configuraţia labirintului se citeşte din fişierul text “labirint.dat”. Labirintul are forma unei matrici de
dimensiune NxM. Un element al matricii poate fi perete sau spaţiu liber. În fişierul de intrare
labirintul este descris pe N linii de câte M caractere. Peretele se codifică prin litera 'P', iar spaţiul
liber prin caracterul '.'.

Un exemplu de fişier de intrare care descrie un labirint este:


PPPP.PPP
P....PPP
PP..PPSP
PP.PP..P
P..PP.PP
P.PPP..P
P...PP.P
PPP.PP.P
PPP....P
PPPPPPPP

În afară de caracterele 'P' şi '.', în fişierul de intrare va mai apare şi caracterul 'S', o singură dată.
Caracterul 'S' reprezintă un spaţiu liber în labirint şi marchează punctul de unde începem
căutarea ieşirilor.

Deplasarea se poate face doar pe verticală şi pe orizontală între oricare două celule învecinate,
cu condiţia ca ambele celule să fie spaţii libere. Nu este permis să se treacă de mai multe ori prin
aceeaşi celulă.

Pentru fiecare cale de ieşire găsită, programul trebuie să afişeze această cale pe ecran, după
care va aştepta apăsarea unei taste. După apăsarea unei taste se va trece la următoarea cale
de ieşire găsită, ş.a.m.d. Pentru afişarea unei căi de ieşire se va folosi aceeaşi codificare ca şi
cea din fişierul de intrare, cu diferenţa că se va marca drumul de ieşire prin caracterul 'x'.

Spre exemplu un drum de ieşire posibil pentru labirintul de mai sus este:

PPPPxPPP
P.xxxPPP
PPx.PPSP
PPxPPxxP
PxxPPxPP
PxPPPxxP
PxxxPPxP
PPPxPPxP
PPPxxxxP
PPPPPPPP

Dacă nu există nici un drum de ieşire din labirint, programul va afişa mesajul “Nu avem nici un
drum de ieşire.”
Un labirint va avea maxim 24 de linii şi 80 de coloane. Valorile pentru N şi M nu sunt date explicit,
va trebui să le deduceţi din structura fişierului de intrare. Se garantează că datele de intrare sunt
corecte.

4.2 Ieşirea cea mai rapidă din labirint

Pentru problema anterioară să se determine şi să se afişeze pe ecran doar cel mai scurt drum de
ieşire din labirint.

4.3 Problema celor 8 regine

Avem o tablă de şah de dimensiune 8x8. Să se aşeze pe această tablă de şah 8 regine astfel
încât să nu existe două regine care se atacă între ele.

Implementarea trebuie să fie făcută folosind recursivitatea.

Greedy - explicarea numelui

În limba engleză cuvântul greedy înseamnă lacom. Algoritmii de tip greedy sunt algoritmi lacomi.
Ei vor să construiască într-un mod cât mai rapid soluţia problemei, fără a sta mult pe gânduri.

Algoritmii de tip greedy se caracterizează prin luarea unor decizii rapide care duc la găsirea unei
soluţii a problemei. Nu întotdeauna asemenea decizii rapide duc la o soluţie optimă, dar vom
vedea că există anumite tipuri de probleme unde se pot obţine soluţii optime sau foarte apropiate
de optim.

Aplicabilitatea algoritmilor de tip Greedy

Algoritmii de tip Greedy se aplică la acele probleme unde datele de intrare sunt organizate sub
forma unei mulţimi A şi se cere găsirea unei submulţimi B din A care să îndeplinească anumite
condiţii astfel încât să fie acceptată ca soluţie posibilă.

În general pot să existe mai multe submulţimi B din A care să reprezinte soluţii posibile ale
problemei. Dintre toate aceste submulţimi B se pot selecta, conform unui anumit criteriu, anumite
submulţimi B* care reprezintă soluţii optime ale problemei. Scopul este de a găsi, dacă este
posibil, una din mulţimile B*. Dacă acest lucru nu este posibil, atunci scopul este găsirea unei
mulţimi B care să fie cât mai aproape de mulţimile B*, conform criteriului de optimalitate impus.

Modul de lucru al algoritmilor de tip Greedy

Construirea mulţimii B se face printr-un şir de decizii. Iniţial se porneşte cu mulţimea vidă (B = Ø).
Fiecare decizie constă în alegerea unui element din mulţimea A, analiza lui şi eventual
introducerea lui în mulţimea B. În funcţie de modul în care se iau aceste decizii, mulţimea B se
va apropia mai mult sau mai puţin de soluţia optimă B*. În cazul ideal vom avea B = B*.

Algoritmii de tip greedy nu urmăresc să determine toate soluţiile posibile şi să aleagă dintre ele,
conform criteriului de optimalitate impus, soluţiile optime. După cum spune şi numele, algoritmii
de tip greedy sunt caracterizaţi prin lăcomie şi nu au răbdarea să investigheze toate variantele
posibile de alegere a soluţiei. Ei încep construirea unei soluţii pornind de la mulţimea vidă, apoi
lucrează în paşi, într-un mod cât se poate de hotărât: la fiecare pas se ia câte o decizie şi se
extinde soluţia cu câte un element.

La fiecare pas se analizează câte un element din mulţimea A şi se decide dacă să fie sau nu
inclus în mulţimea B care se construieşte. Astfel se progresează de la Ø cu un sir de mulţimi
intermediare (Ø, B0, B1, B2, …), până când se obţine o soluţie finală B.

Implementare

Ca şi schemă generală de lucru, există două variante de implementare a algoritmilor de tip


Greedy.

Prima variantă foloseşte două funcţii caracteristice: alege şi posibil. alege este o funcţie care
are rolul de a selecta următorul element din mulţimea A care să fie prelucrat.
Funcţia posibil verifică dacă un element poate fi adăugat soluţiei intermediare Bi astfel încât
noua soluţie Bi+1 care s-ar obţine să fie o soluţie validă. Prezentăm în continuare pseudocodul
pentru această primă variantă greedy. Se consideră că numărul de elemente al mulţimii A este n.

B = multimea vida
for (i=0; i<n; i++)
{
x = alege(A)
if (posibil(B,x))
* adauga elementul x la multimea B
}

Dificultatea la această primă variantă constă în scrierea funcţiei alege. Dacă funcţia alege este
bine concepută, atunci putem fi siguri că soluţia B găsită este o soluţie optimă. Dacă
funcţia alege nu este foarte bine concepută, atunci soluţia B găsită va fi doar o soluţie posibilă şi
nu va fi optimă. Ea se poate apropia însă mai mult sau mai puţin de soluţia optimă B*, în funcţie
de criteriul de selecţie implementat.

A doua variantă de implementare diferă de prima prin faptul că face o etapă iniţială de prelucrare
a mulţimii A. Practic se face o sortare a elementelor mulţimii A, conform unui anumit criteriu. După
sortare, elementele vor fi prelucrate direct în ordirea rezultată. Prezentăm în continuare
pseudocodul pentru această a doua variantă greedy.

B = multimea vida
prelucreaza(A)
for (i=0; i<n; i++)
{
x = A[i]
if (posibil(B,x))
* adauga elementul x la multimea B
}

La a doua variantă, dificultatea funcţiei alege nu a dispărut, ci s-a transferat funcţiei prelucreaza.
Dacă prelucrarea mulţimii A este bine făcută, atunci se va ajunge în mod sigur la o soluţie optimă.
Altfel se va obţine doar o soluţie posibilă, mai mult sau mai puţin apropiată de optim.

Exemplu: Problema comis-voiajorului

Enunţ Se condideră n oraşe. Se cunosc distanţele dintre oricare două oraşe. Un comis-voiajor
trebuie să treacă prin toate cele n oraşe. Se cere să se determine un drum care porneşte dintr-
un oraş, trece exact o dată prin fiecare din celelalte oraşe şi apoi revine la primul oraş, astfel încât
lungimea drumului să fie minimă.

Rezolvare Pentru găsirea unei soluţii optime la această problemă este nevoie de algoritmi cu
timp de rulare foarte mare (de ordin exponenţial O(2^n)). În situaţiile practice asemenea algoritmi
cu timp foarte mare de rulare nu sunt acceptabili. Ca urmare se face un compromis şi se acceptă
algoritmi care nu găsesc soluţia optimă ci doar o soluţie aproape de optim, dar au în schimb un
timp de rulare mic. Propunem în continuare o soluţie greedy la această problemă. Ideea este
următoarea. Se porneşte dintr-un oraş oarecare. Se caută drumul cel mai scurt care pleacă din
oraşul respectiv către oraşe nevizitate încă. Se parcurge acel drum şi se ajunge într-un alt oraş.
Aici din nou se caută cel mai scurt drum către oraşele nevizitate încă. Se parcurge şi acest drum,
ajungându-se într-un nou oraş. Repetând aceşti paşi se parcurg toate oraşele. La final se
parcurge drumul care duce înapoi spre primul oraş.

Să considerăm exemplul cu 4 oraşe cu distanţele reprezentate în figură.

Pornim vizitarea oraşelor din oraşul 0. De aici alegem drumul cel mai scurt către oraşele
nevizitate, şi anume (0,2) de lungime 2. Ajunşi în oraşul 2, alegem din nou drumul cel mai scurt
spre oraşele nevizitate, şi anume (2,3) de lungime 1. Din oraşul 3 mai avem doar un singur oraş
nevizitat, 1, aşa că alegem drumul spre el (3,1) de lungime 1. În acest moment am parcurs toate
oraşele şi ne reîntoarcem în oraşul 0 pe drumul (1,0) de lungime 4. Drumul rezultat este 0, 2, 3,
1, 0, iar distanţa totală de parcurs este 2 + 1 + 1 + 4 = 8.

Implementare Distanţele între oraşe le memorăm într-un tablou bidimensional D. Distanţa între
oraşele (i,j) va fi memorată în elementul d[i][j] al matricii. În termeni Greedy, mulţimea iniţială A
este mulţimea tuturor perechilor de oraşe. Pentru reţeaua de oraşe din figura 2 mulţimea A
conţine elementele {(0,1), (0,2), (0,3), (1,2), (1,3), (2,3)}. Mulţimea B care trebuie găsită va conţine
o parte din aceste perechi de oraşe, şi anume acele perechi care înlănţuite să formeze un drum
ce trece prin toate oraşele. Dacă avem un număr de n oraşe, atunci mulţimea B va conţine n
perechi de oraşe.

În implementare nu vom lucra cu mulţimea A sub această formă explicită de perechi de oraşe, ci
vom folosi matricea distanţelor D. De asemenea drumul comis-voiajorului nu îl vom păstra sub
formă de perechi de oraşe, ci sub forma unui sir al oraşelor.

Pentru a memora drumul parcurs de comis-voiajor, folosim un tablou unidimensional drum. În


acest tablou vom memora indicii oraşelor parcuse, în ordinea parcurgerii.

Pentru a şti care oraşe au fost parcurse, facem o marcare logică a oraşelor folosind un tablou
unidimensional vizitat. Elementele din acest tablou care au valoarea 1 reprezintă oraşe vizitate.

Cod sursă În continuare este prezentat codul sursă în limbajul C care implementează algoritmul
descris mai sus.

#include <stdio.h>

/* Numarul maxim de orase. */


#define N_MAX 30
/* Constanta care se foloseste ca valoare de initializare
la cautarea minimului. */
#define MINIM 10000

/* Numarul de orase. */
int n;

/* Matricea distantelor dintre orase. */


int d[N_MAX][N_MAX];

/* Drumul comis voiajorului. Contine indicii oraselor in


ordinea in care sunt ele parcurse. */
int drum[N_MAX];

/* Vector care memoreaza care orase au fost vizitate.


vizitat[k] va fi 1 daca orasul k a fost vizitat, 0
altfel. */
int vizitat[N_MAX];
/* Functie care alege urmatorul element care sa fie
prelucrat din multimea oraselor. Primeste ca parametru
ultimul oras care a fost vizitat, si returneaza
urmatorul oras care sa fie vizitat precum si lungimea
drumului catre acesta. */
void alege(int ultimul, int *min, int *j_min)
{
int j;
/* Cautam drumul minim de la ultimul oras pana la
orasele neparcurse inca. */
*min = MINIM;
*j_min = -1;
for (j = 0; j < n; j++)
if (!vizitat[j]) {
if (d[ultimul][j] < *min) {
*min = d[ultimul][j];
*j_min = j;
}
}
}

int main(void)
{
FILE *fin;
int i, j;
int count, cost, min, j_min;

/* Deschidem fisierul pentru citire in mod text. */


fin = fopen("comis.in", "rt");
if (!fin) {
printf("Eroare: nu pot deschide fisierul.\n");
return -1;
}

/* Citim datele din fisier. */


fscanf(fin, "%d", &n);
for (i = 0; i < n; i++)
for (j = 0; j < n; j++)
fscanf(fin, "%d", &(d[i][j]));

/* Afisam pe ecran datele preluate din fisier. */


printf("Avem %d orase.\n", n);
printf("Distantele dintre orase sunt:\n");
for (i = 0; i < n; i++) {
for (j = 0; j < n; j++)
printf("%d ", d[i][j]);
printf("\n");
}
printf("\n");

/* Initial nici un oras nu este vizitat. */


for (i = 0; i < n; i++)
vizitat[i] = 0;

/* Primul oras vizitat este cel cu numarul "0". Costul


total este zero deocamdata. */
drum[0] = 0;
vizitat[0] = 1;
count = 1;
cost = 0;

/* Parcurgem restul de n-1 orase. */


for (i = 0; i < n - 1; i++) {
/* Alegem urmatorul oras care sa fie vizitat. */
alege(drum[count - 1], &min, &j_min);

/* Parcurgem drumul minim gasit si vizitam un nou


oras. */
printf("Am ales drumul (%d, %d) de cost %d.\n",
drum[count - 1], j_min, min);
drum[count] = j_min;
vizitat[j_min] = 1;
count++;
cost += min;
}

/* Parcurgem drumul de la ultimul oras vizitat catre


primul oras si actualizam costul total. */
cost += d[drum[n - 1]][0];

/* Afisam drumul parcurs. */


printf("\nDrumul are costul %d si este:\n", cost);
for (i = 0; i < n; i++)
printf("%d ", drum[i]);
printf("0\n");
return 0;
}

Fişierul cu date de intrare pentru reţeaua de oraşe din figura 1 este următorul:

4
0 4 2 7
4 0 2 1
2 2 0 1
7 1 1 0

Îmbunătăţiri: algoritmul greedy prezentat se poate îmbunătăţi pentru a furniza soluţii mai
aproape de soluţia optimă. O variantă de îmbunătăţire este să nu se pornească doar din primul
oraş la parcurgerea drumului. Se poate relua calculul având ca punct de pornire fiecare oraş pe
rând şi se poate memora minimul global astfel obţinut.

S-ar putea să vă placă și