Laborator 1: Divide et Impera

Obiective laborator

  • Înțelegerea conceptului teoretic din spatele descompunerii
  • Rezolvarea de probleme abordabile folosind conceptul de Divide et Impera

Importanţă – aplicaţii practice

Paradigma Divide et Impera stă la baza construirii de algoritmi eficienți pentru diverse probleme:

  • Sortări (ex: MergeSort [1], QuickSort [2])
  • Înmulțirea numerelor mari (ex: Karatsuba [3])
  • Analiza sintactică (ex: parsere top-down [4])
  • Calcularea transformatei Fourier discretă (ex: FFT [5])

Un alt domeniu de utilizare a tehnicii divide et impera este programarea paralelă pe mai multe procesoare, sub-problemele fiind executate pe mașini diferite.

Prezentarea generală a problemei

O descriere a tehnicii D&I: “Divide and Conquer algorithms break the problem into several sub-problems that are similar to the original problem but smaller in size, solve the sub-problems recursively, and then combine these solutions to create a solution to the original problem.” [7]

Deci un algoritm D&I împarte problema în mai multe subprobleme similare cu problema inițială şi de dimensiuni mai mici, rezolva sub-problemele recursiv şi apoi combina soluțiile obţinute pentru a obține soluția problemei inițiale.

Sunt trei pași pentru aplicarea algoritmului D&I:

  • Divide: împarte problema în una sau mai multe probleme similare de dimensiuni mai mici.
  • Impera (stăpânește): rezolva subprobleme recursiv; dacă dimensiunea sub-problemelor este mica se rezolva iterativ.
  • Combină: combină soluțiile sub-problemelor pentru a obține soluția problemei inițiale.

Complexitatea algoritmilor D&I se calculează după formula:

T(n) = D(n) + S(n) + C(n),

unde D(n), S(n) şi C(n) reprezintă complexitățile celor 3 pași descriși mai sus: divide, stăpânește respectiv combină.

Probleme clasice

1. Sortarea prin interclasare

Sortarea prin interclasare (MergeSort [1]) este un algoritm de sortare de vectori ce folosește paradigma D&I:

  • Divide: împarte vectorul inițial în doi sub-vectori de dimensiune n/2.
  • Stăpânește: sortează cei doi sub-vectori recursiv folosind sortarea prin interclasare; recursivitatea se oprește când dimensiunea unui sub-vector este 1 (deja sortat).
  • Combina: Interclasează cei doi sub-vectori sortați pentru a obține vectorul inițial sortat.

Pseudocod:

MergeSort(v, start, end)		// v – vector, start – limită inferioră, end – limită superioară
	if (start == end) return;	// condiția de oprire
	mid = (start + end) / 2;	// etapa divide
	MergeSort(v, start, mid);	// etapa stăpânește
	MergeSort(v, mid+1, end);
	Merge(v, start, end);		// etapa combină
 
Merge(v, start, end)			// interclasare sub-vectori
	mid = (start + end) / 2;
	i = start;
	j = mid + 1;
	k = 1;
	while (i <= mid && j <= end) 
		if (v[i] <= v[j]) u[k++] = v[i++];
		else u[k++] = v[j++];
 
	while (i <= mid) 
		u[k++] = v[i++];
 
	while (j <= end) 
		u[k++] = v[j++];
 
	copy(v[start..end], u[1..k-1]);

Complexitatea algoritmului este dată de formula: T(n) = D(n) + S(n) + C(n), unde D(n)=O(1), S(n) = 2*T(n/2) și C(n) = O(n), rezulta T(n) = 2 * T(n/2) + O(n).

Folosind teorema Master [8] găsim complexitatea algoritmului: T(n) = O(n * lg n).

2. Căutarea binară

Se dă un vector sortat crescător (v[1..n]) ce conține valori reale distincte și o valoare x. Sa se găsească la ce poziție apare x în vectorul dat.

Pentru rezolvarea acestei probleme folosim un algoritm D&I:

  • Divide: împărțim vectorul în doi sub-vectori de dimensiune n/2.
  • Stăpânește: aplicăm algoritmul de căutare binară pe sub-vectorul care conține valoarea căutată.
  • Combină: soluția sub-problemei devine soluția problemei inițiale, motiv pentru care nu mai este nevoie de etapa de combinare.

Pseudocod:

BinarySearch(v, start, end, x) 
	if (start > end) return;	// condiția de oprire (x nu se află în v)
	mid = (start + end) / 2;	// etapa divide
	// etapa stăpânește
	if (v[mid] == x) return mid
	if (v[mid] > x) return BinarySearch(v, start, mid-1, x);
	if (v[mid] < x) return BinarySearch(v, mid+1, end, x);

Complexitatea algoritmului este data de relația T(n) = T(n/2) + O(1), ceea ce implica: T(n) = O(lg n).

3. Turnurile din Hanoi

Se considera 3 tije A, B, C şi n discuri de dimensiuni distincte (1, 2.. n ordinea crescătoare a dimensiunilor) situate inițial toate pe tija A în ordinea 1,2..n (de la vârf către baza). Singura operație care se poate efectua este de a selecta un disc ce se află în vârful unei tije şi plasarea lui în vârful altei tije astfel încât să fie așezat deasupra unui disc de dimensiune mai mare decât a sa. Sa se găsească un algoritm prin care se mută toate discurile pe tija B (problema turnurilor din Hanoi).

Pentru rezolvarea problemei folosim următoarea strategie [9]:

  • mutam primele n-1 discuri de pe tija A pe tija C folosindu-ne de tija B.
  • mutam discul n pe tija B.
  • mutam apoi cele n-1 discuri de pe tija C pe tija B folosindu-ne de tija A.

Pseudocod [10]:

Hanoi(n, A, B, C)	// mută n discuri de pe tija A pe tija B fol tija C
	if (n >= 1)
		Hanoi(n-1, A, C, B);
		Muta_disc(A, B);
		Hanoi(n-1, C, B, A);

Complexitatea: T(n) = 2*T(n-1) + O(1), recurenta ce conduce la T(n) = O(2n).

4. Cautare număr lipsă

Se dă un șir neordonat S cu n - 1 numere distincte, selectate dintre cele n numere de la 0 la n - 1. Toate sunt numere întregi, reprezentate pe 32 de biti. Folosind metoda getBit(int i, int j) care intoarce al j-lea bit din reprezentarea binara a lui S[i], determinati numarul lipsă.

Pentru un număr dat n, fie m = 2k primul număr mai mare decât n care este o putere a lui 2. Atunci, în șirul numerelor de la 0 până la n - 1 vor exista 2k - 1 numere cu bitul numărul k - 1 egal cu 0.

Pentru n = 7, m = 8 și k = 3. Drept urmare, vor fi 2k-1 = 22 = 4 numere între 0 și 7 cu bitul 2 egal cu 0. Dacă numărul care lipsește din șirul din problemă va avea bitul 2 egal cu 0, atunci vor fi 22 - 1 numere în șir cu bitul zero. Altfel, înseamnă că numărul care lipsește are bitul 2 egal cu 1. După ce determinăm în care “jumătate” se află numărul lipsă, continuăm căutarea mai departe folosind-o doar pe aceea.

Reprezentarea în binar a numerelor întregi din intervalul [0, 7):

2 1 0
0 = 0 0 0
1 = 0 0 1
2 = 0 1 0
3 = 0 1 1
4 = 1 0 0
5 = 1 0 1
6 = 1 1 0

Putem să aplicăm această regulă ca să determinăm elementul lipsă din șir, iterând de la cel mai semnificativ bit spre cel mai nesemnificativ.

Exemplu:

  • pentru șirul {0 1 9 4 5 7 6 8 2} lipsește numarul 3
  • getBit(7,3) întoarce al treilea bit din S[7]. S[7] este 8, în binar: 1000, deci bit-ul 3 este 1.
  • La un prim pas, se observă că bitul 3 este 0 pentru doar 7 numere din șir, deși între 0 și 9 sunt 8 numere care ar trebui să aibă bitul 3 egal cu 0, respectiv numerele de la 0 la 7. Prin urmare, la primul pas ne dăm seama că numărul căutat este între 0 si 7.

Pseudocod:

getMissing(V, n):
    current_vec = V
    current_bit = 31
    result = 0
 
    while (getBit(n - 1, current_bit) == 0)
        current_bit--
 
    while (current_bit >= 0)
        setBit0 = {}
        setBit1 = {}
 
        for (element : current_vec)
            if (getBit(element, current_bit) == 0)
                setBit0.add(element)
            else
                setBit1.add(element)
 
        if (setBit0.size != (1 << current_bit))
            result |= (1 << current_bit)
            current_vec = setBit1
        else
            current_vec = setBit0
 
    return result

O altă soluție se bazează pe aflarea elementului median din vector - elementul care se află la jumătatea intervalului, de exemplu pentru [5 6 7 8 9] elementul median este 7, adică (9+5)/2. Dacă elementul median nu se afla în vector atunci acesta egale cu elementul median, iar în cel de-al doilea toate elementele sunt mai mari decât cel median. Calculam suma elementelor din fiecare vector, și în cazul în care suma nu coincide cu cea pe care o calculam din formula (b*(b+1)/2-(a-1)*a/2, este elementul lipsa. Altfel, se partiționează vectorul în 2 noi vectori: în primul vector toate elementele sunt mai mici sau presupunând ca vectorul are elemente intre a si b) înseamnă ca elementul lipsă se afla în jumătatea respectivă și ne poziționăm în acea parte.

int find(vector<int> V, int a, int b) // vectorul V cuprinde numerele intre a si b, mai putin unul
{
      int median = (a+b)/2;
      int find = 0;
      for (vector<int>::iterator it= V.begin(); it!=V.end(); it++)
            if (*it == median)
             {
                find =1;
                break;
              }
      if (find == 0)
          return median; // acesta este elementul lipsa
      vector<int> V1,V2;
      int s1=0, s2=0;
      for (vector<int>::iterator it= V.begin(); it!=V.end(); it++)
            if (*it <= median)
                 V1.push_back(*it), s1+=*it;
            else 
                 V2.push_back(*it), s2+=*it;
       if (s1 != (median*(median+1)/2 - (a-1)*a/2) )
             return find(V1, a, median);
        else
             return find(V2, median+1, b);
  }

5. Înmulțire de polinoame

Dacă avem două polinoame, P(x)=a0+a1*x+…an*xn si Q(x)=b0+b1*x+….bn*xn, și vrem să calculăm P*Q, în mod normal complexitatea este O(n2) - fiecare termen al produsului va fi obținut ca o suma intre coeficienții ai și bi. De exemplu, pentru coeficientul cn,cn = a0*bn+a1*bn-1+….an*b0.

O metodă mai rapidă, de complexitate O(n*log n) este să folosim transformata Fourier rapidă (FFT) și cea inversă. Transformata Fourier rapidă calculează valoarea unui polinom in N puncte (rădăcinile de ordin N ale lui 1, mai exact). Dacă avem coeficienții x0, x1… xn-1, transformata Fourier va găsi valorile în cele N puncte. Fie Xk valoarea transformatei în punctul k. Folosind proprietăți matematice, se observă că Xk si Xk+n/2 se pot calcula folosind aceleași valori, (mai exact sumele parțiale pentru termenii pari și termenii impari ai sumei în punctul k). Pornind de la această idee, se calculează recursiv, folosind divide et impera, termenii pari respectiv termenii impari ai sumei, pentru fiecare punct x0..xn-1. Cei n termeni se calculează apelând recursiv pentru termenii pari și pentru cei impari, iar etapa de combinare are complexitate O(n), în final complexitatea fiind de O(n * log n).

Transformata Fourier inversă calculează coeficienții unui polinom plecând de la valorile pe care le are în N puncte. Calculul acesteia nu diferă mult de cel al transformatei Fourier. Calculul produsului de polinoame se face în felul următor: se evaluează funcțiile în 2*n puncte (sau n+m, daca au grade diferite polinoamele), folosind FFT (complexitate O(n* logn). În pasul următor se calculează valorile pentru P(k)*Q(k), pentru punctele n care am aplicat FFT, și în ultimul pas se calculează coeficienții folosind transformata Fourier inversă. În final, complexitatea este O(n*log n).

Mai multe informații găsiti la http://en.wikipedia.org/wiki/Cooley%E2%80%93Tukey_FFT_algorithm și la https://www.cs.iastate.edu/~cs577/handouts/polymultiply.pdf.

Concluzii

Divide et impera este o tehnică folosită pentru a realiza algoritmi eficienți pentru diverse probleme. În cadrul acestei tehnici se disting trei etape: divide, stăpânește și combină.

Mai multe exemple de algoritmi care folosesc tehnica divide et impera puteți găsi la [11].

Probleme laborator

Pentru punctaj maxim, asistentul va alege un subpunct pentru fiecare problemă.

La finalul laboratorului, încărcați soluțiile aici.

Problema 1 [3p]

  • 1.1 Se da un sir sortat. Gasiti numarul de elemente egale cu x din sir.

Exemplu: pentru sirul {1 2 4 4 10 10 20} si x = 10, x apare de 2 ori in sir.

  • 1.2 Se da un numar natural n. Scrieti un algoritm de complexitate O(log n) care sa calculeze √n cu o precizie de 0.001.

Exemplu: pentru 0.25 algoritmul poate da orice valoare intre 0.499 si 0.501 inclusiv.

  • 1.3 Fie o valoare întreagă necunoscută, pe care o denumim unknown. Gasiți valoarea lui unknown prin Divide et Impera, folosind metoda isInBounds(int x) pentru Java și is_in_bounds(int x) pentru C++ care întoarce:
    • true, dacă x < = unknown
    • false, dacă x > unknown

Problema 2 [3p]

  • 2.1 Statistici de ordine: se dă un vector de numere întregi neordonate. Scriind o funcție de partitionare, folosiți Divide et Impera pentru
    • a determina a k-lea element ca mărime din vector
    • a sorta vectorii prin QuickSort

Exemplu: pentru vectorul {0 1 2 4 5 7 6 8 9}, al 3-lea element ca ordine este 2, iar vectorul sortat este {0 1 2 4 5 6 7 8 9}

  • 2.2 Se da un sir S de n numere intregi. Sa se detemine cate inversiuni sunt in sirul dat. Numim inversiune o pereche de indici 1 < = i < j < = n astfel incat S[i] > S[j]

Exemplu: in sirul {0 1 9 4 5 7 6 8 2} sunt 12 inversiuni.

  • 2.3 Se dau n - 1 numere naturale distincte intre 0 si n - 1. Scriind o functie de partitionare, determinati numarul lipsa.

Exemplu: pentru n = 9 si vectorul {0 1 9 4 5 7 6 8 2}, numarul lipsa este 3.

Problema 3 [4p]

Această problema nu are schelet de cod.

  • Calculați subsecvența de sumă maximă pentru un vector A de n numere reale folosind o abordare de tip divide și stăpânește.

O subsecvență a unui vector A[1..n] se definește ca o secvența continuă de elemente din A. De exemplu, A[i..j] (pentru orice 1 < = i < = j < = n) reprezintă o subsecvență a lui A. Această problemă se numește problema subsecvenței de sumă maximă și acceptă mai multe rezolvări. O rezolvare mai eficientă poate fi făcută prin programare dinamică.

Exemplu (preluat de pe Wikipedia): Pentru A = {−2, 1, −3, 4, −1, 2, 1, −5, 4}, subsecvența de sumă maximă este {4, −1, 2, 1} de sumă 6.

Referinţe

[1] MergeSort

[2] QuickSort

[3] Karatsuba

[4] Top down parser

[5] Fast Fourier Transform

[6] Divide et impera

[7] T. H. Cormen, C. E. Leiserson, R. L. Rivest, C. Stein, Introduction to Algorithms

[8] Teorema Master

[9] Hanoi Applet

[10] Cristian A. Giumale, Introducere in Analiza Algoritmilor (cap. 2.5.1)

[11] Chapter 2, Divide-and-conquer algorithms, Berkeley University

pa/laboratoare/laborator-01.txt · Last modified: 2015/03/02 20:19 by andrei.poenaru
CC Attribution-Share Alike 3.0 Unported
www.chimeric.de Valid CSS Driven by DokuWiki do yourself a favour and use a real browser - get firefox!! Recent changes RSS feed Valid XHTML 1.0